From 83b8ca713b73f1c271825e7e04e4934a9e6ee039 Mon Sep 17 00:00:00 2001 From: Kang Date: Fri, 14 Aug 2026 15:04:56 +0800 Subject: [PATCH 01/11] perf(terminal): implement alternate screen state tracking and output scheduling - Added `AlternateScreenStateTracker` to manage and detect alternate screen states in terminal output. - Introduced `TerminalOutputScheduler` to optimize write chunk sizes and manage foreground delays based on screen state. - Enhanced `TerminalOutputDrain` to support low-latency flushing and improved performance during foreground operations. - Added comprehensive tests for both the alternate screen state tracker and output scheduling functionalities. - Updated `XTerminal` to integrate the new tracking and scheduling logic, ensuring consistent behavior across terminal operations. --- src-tauri/Cargo.toml | 3 + src-tauri/src/core/output.rs | 47 +++++++- src/components/terminal/XTerminal.tsx | 43 +++---- .../alternateScreenStateTracker.test.ts | 52 +++++++++ .../terminal/alternateScreenStateTracker.ts | 88 ++++++++++++++ .../terminal/terminalOutputDrain.test.ts | 76 +++++++++++- .../terminal/terminalOutputDrain.ts | 22 +++- .../terminal/terminalOutputScheduling.test.ts | 36 ++++++ .../terminal/terminalOutputScheduling.ts | 74 ++++++++++++ .../terminal/xterminalOutputQueue.test.ts | 110 ++++++++++++++++++ .../terminal/xterminalOutputQueue.ts | 31 ++++- src/hooks/useTerminalSettings.ts | 25 +++- src/lib/xtermPerformance.ts | 8 +- 13 files changed, 573 insertions(+), 42 deletions(-) create mode 100644 src/components/terminal/alternateScreenStateTracker.test.ts create mode 100644 src/components/terminal/alternateScreenStateTracker.ts create mode 100644 src/components/terminal/terminalOutputScheduling.test.ts create mode 100644 src/components/terminal/terminalOutputScheduling.ts create mode 100644 src/components/terminal/xterminalOutputQueue.test.ts diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 3813beea..b737e657 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -80,6 +80,9 @@ crate-type = ["staticlib", "cdylib", "rlib"] [build-dependencies] tauri-build = { version = "2", features = [] } +[dev-dependencies] +tokio = { version = "1", features = ["test-util"] } + [dependencies] nyaterm-otp = { path = "crates/otp" } tauri = { version = "2", features = ["tray-icon", "protocol-asset"] } diff --git a/src-tauri/src/core/output.rs b/src-tauri/src/core/output.rs index 61a9d7d7..a0f3fae3 100644 --- a/src-tauri/src/core/output.rs +++ b/src-tauri/src/core/output.rs @@ -470,7 +470,7 @@ mod tests { use crate::core::SessionCommand; use std::sync::{Arc, Mutex}; use tokio::sync::mpsc; - use tokio::time::{Duration, sleep}; + use tokio::time::{Duration, Instant, advance, sleep}; fn collect_sink() -> ( Arc>>, @@ -524,6 +524,51 @@ mod tests { assert_eq!(emitted[0].bytes, "hello world".len()); } + #[tokio::test(start_paused = true)] + async fn one_millisecond_tiny_bursts_are_coalesced_without_delaying_first_flush() { + let emitted = Arc::new(Mutex::new(Vec::::new())); + let first_emit_at = Arc::new(Mutex::new(None::)); + let started_at = Instant::now(); + let emitted_sink = emitted.clone(); + let first_emit_sink = first_emit_at.clone(); + let output = SessionOutputCoalescer::with_sink(move |payload| { + let mut first = first_emit_sink.lock().unwrap(); + if first.is_none() { + *first = Some(Instant::now().duration_since(started_at)); + } + emitted_sink.lock().unwrap().push(payload); + }); + + output.attach(); + + let mut expected = String::new(); + for index in 0..1000 { + let chunk = format!("{index:04};"); + expected.push_str(&chunk); + output.push_owned(chunk); + advance(Duration::from_millis(1)).await; + } + advance(Duration::from_millis(20)).await; + + let emitted = emitted.lock().unwrap(); + let actual = emitted + .iter() + .map(|payload| payload.data.as_str()) + .collect::(); + let first_emit_at = first_emit_at.lock().unwrap().expect("first emit"); + + assert_eq!(actual, expected); + assert!( + emitted.len() < 350, + "expected coalesced events, got {} events", + emitted.len() + ); + assert!( + first_emit_at <= Duration::from_millis(8), + "first emit took {first_emit_at:?}" + ); + } + #[tokio::test] async fn size_threshold_flushes_immediately() { let (emitted, sink) = collect_sink(); diff --git a/src/components/terminal/XTerminal.tsx b/src/components/terminal/XTerminal.tsx index a53593b2..5f8523cd 100644 --- a/src/components/terminal/XTerminal.tsx +++ b/src/components/terminal/XTerminal.tsx @@ -112,6 +112,8 @@ import { TerminalOutputDrain, type TerminalOutputDrainMode, } from "./terminalOutputDrain"; +import { AlternateScreenStateTracker } from "./alternateScreenStateTracker"; +import { TerminalOutputScheduler } from "./terminalOutputScheduling"; import { useTerminalExternalDrop } from "./useTerminalExternalDrop"; import { useTerminalRefreshEffects } from "./useTerminalRefreshEffects"; import { @@ -361,7 +363,7 @@ export default function XTerminal({ const visibleRef = useRef(visible); const activeRef = useRef(active); const performanceModeRef = useRef("normal"); - const lastAlternateScreenWriteAtRef = useRef(0); + const alternateScreenTrackerRef = useRef(new AlternateScreenStateTracker()); const handleVisibilityChangeRef = useRef<(() => void) | null>(null); const replaceInputCommandRef = useRef<((command: string) => void) | null>( null, @@ -801,7 +803,7 @@ export default function XTerminal({ gutterLineOffsetRef.current = 0; outputDrainRef.current?.dispose({ ackRemaining: true }); outputDrainRef.current = null; - lastAlternateScreenWriteAtRef.current = 0; + alternateScreenTrackerRef.current.reset(); disconnectedRef.current = false; disconnectedNoticeShownRef.current = false; disconnectedCloseRequestedRef.current = false; @@ -1969,6 +1971,9 @@ export default function XTerminal({ ); const writeParsedDisposable = terminal.onWriteParsed(() => { + alternateScreenTrackerRef.current.setXtermBufferType( + terminal.buffer.active.type, + ); if (terminal.buffer.active.type === "alternate") { dismissSuggestions(); } @@ -2145,20 +2150,15 @@ export default function XTerminal({ }; const isAlternateScreenActive = () => - terminal.buffer.active.type === "alternate"; + terminal.buffer.active.type === "alternate" || + alternateScreenTrackerRef.current.isAlternateScreenActive(); - const getWriteChunkBytes = () => - isAlternateScreenActive() - ? XTERM_PERFORMANCE_CONFIG.output.alternateScreenWriteChunkBytes - : XTERM_PERFORMANCE_CONFIG.output.writeChunkBytes; + const outputScheduler = new TerminalOutputScheduler({ + getQueueBytes: () => outputDrainRef.current?.getQueueBytes() ?? 0, + isAlternateScreenActive, + }); - const getAlternateScreenWriteIntervalMs = () => - 1000 / XTERM_PERFORMANCE_CONFIG.output.alternateScreenMaxWriteFps; - - const shouldThrottleAlternateScreenWrite = () => - isAlternateScreenActive() && - (outputDrainRef.current?.getQueueBytes() ?? 0) > - XTERM_PERFORMANCE_CONFIG.output.alternateScreenThrottleBacklogBytes; + const getWriteChunkBytes = () => outputScheduler.getWriteChunkBytes(); const getRecoveryThresholdBytes = () => visibleRef.current @@ -2215,13 +2215,7 @@ export default function XTerminal({ }; const getForegroundDelayMs = () => { - if (!shouldThrottleAlternateScreenWrite()) return 0; - const now = Date.now(); - const intervalMs = getAlternateScreenWriteIntervalMs(); - const elapsedMs = now - lastAlternateScreenWriteAtRef.current; - return lastAlternateScreenWriteAtRef.current > 0 && elapsedMs < intervalMs - ? Math.max(1, intervalMs - elapsedMs) - : 0; + return outputScheduler.getForegroundDelayMs(); }; const updateOutputDrainMode = () => { @@ -2239,9 +2233,7 @@ export default function XTerminal({ shouldUseLowLatencyFlush, onAck: sendOutputAck, onWriteStart: () => { - if (visibleRef.current && isAlternateScreenActive()) { - lastAlternateScreenWriteAtRef.current = Date.now(); - } + outputScheduler.noteWriteStart(); return { beforeLine: getCurrentAbsoluteLine(), ts: Date.now() }; }, onWriteComplete: (_payload, context) => { @@ -2683,6 +2675,7 @@ export default function XTerminal({ return; } + alternateScreenTrackerRef.current.ingest(payload.data); outputDrain.enqueue({ data: payload.data, bytes: payload.bytes, @@ -3398,7 +3391,7 @@ export default function XTerminal({ if (outputDrainRef.current === outputDrain) { outputDrainRef.current = null; } - lastAlternateScreenWriteAtRef.current = 0; + outputScheduler.reset(); const latestLifecycleState = terminalLifecycleStateRef.current; if ( !hibernationCleanupRef.current && diff --git a/src/components/terminal/alternateScreenStateTracker.test.ts b/src/components/terminal/alternateScreenStateTracker.test.ts new file mode 100644 index 00000000..d21327f5 --- /dev/null +++ b/src/components/terminal/alternateScreenStateTracker.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import { AlternateScreenStateTracker } from "./alternateScreenStateTracker"; + +describe("AlternateScreenStateTracker", () => { + it("detects alternate-screen enter and leave sequences", () => { + const tracker = new AlternateScreenStateTracker(); + + expect(tracker.ingest("before\x1b[?1049hafter").alternateScreen).toBe(true); + expect(tracker.ingest("\x1b[?1049l").alternateScreen).toBe(false); + expect(tracker.ingest("\x1b[?47h").alternateScreen).toBe(true); + expect(tracker.ingest("\x1b[?47l").alternateScreen).toBe(false); + }); + + it("detects split CSI sequences across chunks", () => { + const tracker = new AlternateScreenStateTracker(); + + expect(tracker.ingest("\x1b[?10").alternateScreen).toBe(false); + expect(tracker.snapshot().pendingSequence).toBe("\x1b[?10"); + expect(tracker.ingest("49hpayload").alternateScreen).toBe(true); + expect(tracker.snapshot().pendingSequence).toBe(""); + }); + + it("supports multiple CSI params without modifying payload ownership", () => { + const tracker = new AlternateScreenStateTracker(); + const payload = "\x1b[?1;1047hhello"; + + const before = payload; + expect(tracker.ingest(payload).alternateScreen).toBe(true); + expect(payload).toBe(before); + expect(tracker.ingest("\x1b[?1;1047l").alternateScreen).toBe(false); + }); + + it("bounds malformed CSI buffering", () => { + const tracker = new AlternateScreenStateTracker(); + + tracker.ingest("\x1b[?1234567890123456789012345678901234567890"); + + expect(tracker.snapshot().pendingSequence.length).toBeLessThanOrEqual(32); + expect(tracker.ingest("not-a-final").alternateScreen).toBe(false); + }); + + it("accepts xterm buffer type as authoritative after parser catches up", () => { + const tracker = new AlternateScreenStateTracker(); + + tracker.ingest("\x1b[?1049h"); + expect(tracker.isAlternateScreenActive()).toBe(true); + tracker.setXtermBufferType("normal"); + expect(tracker.isAlternateScreenActive()).toBe(false); + tracker.setXtermBufferType("alternate"); + expect(tracker.isAlternateScreenActive()).toBe(true); + }); +}); diff --git a/src/components/terminal/alternateScreenStateTracker.ts b/src/components/terminal/alternateScreenStateTracker.ts new file mode 100644 index 00000000..0a471922 --- /dev/null +++ b/src/components/terminal/alternateScreenStateTracker.ts @@ -0,0 +1,88 @@ +const ALT_SCREEN_PARAMS = new Set(["47", "1047", "1049"]); +const MAX_PENDING_SEQUENCE_CHARS = 32; + +export interface AlternateScreenStateSnapshot { + alternateScreen: boolean; + pendingSequence: string; +} + +function detectAlternateScreen(sequence: string): boolean | null { + if (!sequence.startsWith("\x1b[?")) return null; + const final = sequence[sequence.length - 1]; + if (final !== "h" && final !== "l") return null; + + const params = sequence.slice(3, -1).split(";"); + if (!params.some((param) => ALT_SCREEN_PARAMS.has(param))) return null; + return final === "h"; +} + +function isPotentialPrefix(text: string): boolean { + if (!"\x1b[?".startsWith(text) && !text.startsWith("\x1b[?")) return false; + if (text.length > MAX_PENDING_SEQUENCE_CHARS) return false; + if (!text.startsWith("\x1b[?")) return true; + return /^\x1b\[\?[0-9;]*$/u.test(text); +} + +function findPendingSequenceSuffix(text: string): string { + const start = Math.max(0, text.length - MAX_PENDING_SEQUENCE_CHARS); + for (let index = text.length - 1; index >= start; index -= 1) { + if (text.charCodeAt(index) !== 0x1b) continue; + const suffix = text.slice(index); + if (isPotentialPrefix(suffix)) return suffix; + } + return ""; +} + +export class AlternateScreenStateTracker { + private alternateScreen = false; + private pendingSequence = ""; + + ingest(data: string): AlternateScreenStateSnapshot { + if (!data) return this.snapshot(); + + const text = `${this.pendingSequence}${data}`; + this.pendingSequence = ""; + + for (let index = 0; index < text.length; index += 1) { + if (text.charCodeAt(index) !== 0x1b) continue; + const candidate = text.slice(index, Math.min(text.length, index + MAX_PENDING_SEQUENCE_CHARS)); + const match = /^\x1b\[\?([0-9;]*)([hl])/u.exec(candidate); + if (!match) continue; + + const next = detectAlternateScreen(match[0]); + if (next !== null) { + this.alternateScreen = next; + } + index += match[0].length - 1; + } + + this.pendingSequence = findPendingSequenceSuffix(text); + return this.snapshot(); + } + + setXtermBufferType(type: string | undefined) { + if (type === "alternate") { + this.alternateScreen = true; + return; + } + if (type === "normal") { + this.alternateScreen = false; + } + } + + reset() { + this.alternateScreen = false; + this.pendingSequence = ""; + } + + isAlternateScreenActive() { + return this.alternateScreen; + } + + snapshot(): AlternateScreenStateSnapshot { + return { + alternateScreen: this.alternateScreen, + pendingSequence: this.pendingSequence, + }; + } +} diff --git a/src/components/terminal/terminalOutputDrain.test.ts b/src/components/terminal/terminalOutputDrain.test.ts index 595c5d58..5b780013 100644 --- a/src/components/terminal/terminalOutputDrain.test.ts +++ b/src/components/terminal/terminalOutputDrain.test.ts @@ -3,12 +3,20 @@ import { XTERM_PERFORMANCE_CONFIG } from "@/lib/xtermPerformance"; import { TerminalOutputDrain } from "./terminalOutputDrain"; const settle = async () => { - await Promise.resolve(); - await Promise.resolve(); - await Promise.resolve(); + for (let i = 0; i < 10; i += 1) { + await Promise.resolve(); + } }; -function createHarness(options: { writeChunkBytes?: number; autoCompleteWrites?: boolean } = {}) { +function createHarness( + options: { + writeChunkBytes?: number; + autoCompleteWrites?: boolean; + shouldUseLowLatencyFlush?: () => boolean; + getForegroundDelayMs?: () => number; + writeDurationMs?: number; + } = {}, +) { let now = 0; let nextTimerId = 1; let nextFrameId = 1; @@ -22,6 +30,7 @@ function createHarness(options: { writeChunkBytes?: number; autoCompleteWrites?: const terminal = { write: vi.fn((data: string, callback?: () => void) => { writes.push(data); + now += options.writeDurationMs ?? 0; if (!callback) return; if (options.autoCompleteWrites === false) { pendingWriteCallbacks.push(callback); @@ -35,6 +44,8 @@ function createHarness(options: { writeChunkBytes?: number; autoCompleteWrites?: sessionId: "session-1", getTerminal: () => terminal, getWriteChunkBytes: () => options.writeChunkBytes ?? 1024, + getForegroundDelayMs: options.getForegroundDelayMs, + shouldUseLowLatencyFlush: options.shouldUseLowLatencyFlush, onAck: (bytes) => acks.push(bytes), onPressureChange: (bytes) => pressure.push(bytes), timers: { @@ -90,6 +101,8 @@ function createHarness(options: { writeChunkBytes?: number; autoCompleteWrites?: terminal, timers, writes, + getFrameCount: () => frames.size, + getNow: () => now, }; } @@ -141,6 +154,61 @@ describe("TerminalOutputDrain", () => { expect(writes).toEqual(["abcd", "efgh", "ij"]); }); + it("uses the microtask fast path for light foreground pressure", async () => { + const { drain, getFrameCount, writes } = createHarness({ + shouldUseLowLatencyFlush: () => true, + writeChunkBytes: 16, + }); + + drain.setMode("foreground"); + drain.enqueue({ data: "hello", bytes: 5 }); + await settle(); + + expect(writes).toEqual(["hello"]); + expect(getFrameCount()).toBe(0); + }); + + it("yields to the next frame when a foreground drain turn exhausts its budget", async () => { + const { drain, flushFrame, getFrameCount, writes } = createHarness({ + shouldUseLowLatencyFlush: () => true, + writeChunkBytes: 4, + writeDurationMs: XTERM_PERFORMANCE_CONFIG.output.maxForegroundDrainTurnMs + 1, + }); + + drain.setMode("foreground"); + drain.enqueue({ data: "abcdefghijkl", bytes: 12 }); + await settle(); + + expect(writes).toEqual(["abcd"]); + expect(getFrameCount()).toBe(1); + + flushFrame(); + await settle(); + expect(writes).toEqual(["abcd", "efgh"]); + }); + + it("honors foreground delay only when the scheduler reports severe backlog", async () => { + let severeBacklog = false; + const { advance, drain, timers, writes } = createHarness({ + getForegroundDelayMs: () => (severeBacklog ? 50 : 0), + writeChunkBytes: 8, + }); + + drain.setMode("foreground"); + severeBacklog = true; + drain.enqueue({ data: "alt", bytes: 3 }); + expect(timers.size).toBe(1); + expect(writes).toEqual([]); + + advance(49); + await settle(); + expect(writes).toEqual([]); + + advance(1); + await settle(); + expect(writes).toEqual(["alt"]); + }); + it("acks only bytes completed by write callbacks", async () => { const { acks, drain, flushFrame, pendingWriteCallbacks } = createHarness({ autoCompleteWrites: false, diff --git a/src/components/terminal/terminalOutputDrain.ts b/src/components/terminal/terminalOutputDrain.ts index a68e2215..cf7a295f 100644 --- a/src/components/terminal/terminalOutputDrain.ts +++ b/src/components/terminal/terminalOutputDrain.ts @@ -103,6 +103,7 @@ export class TerminalOutputDrain { private backgroundTimer: number | null = null; private ackTimer: number | null = null; private microtaskPending = false; + private foregroundTurnStartedAt: number | null = null; private disposed = false; constructor(private readonly options: TerminalOutputDrainOptions) { @@ -223,6 +224,7 @@ export class TerminalOutputDrain { private schedule() { if (this.disposed) return; if (!hasOutputQueueItems(this.queue)) { + this.foregroundTurnStartedAt = null; this.flushPendingAck(true); this.notifyPressure(); return; @@ -253,12 +255,13 @@ export class TerminalOutputDrain { if (delayMs > 0) { this.foregroundTimer = this.timers.setTimeout(() => { this.foregroundTimer = null; + this.foregroundTurnStartedAt = null; this.flushForeground(); }, delayMs); return; } - if (this.options.shouldUseLowLatencyFlush?.()) { + if (this.options.shouldUseLowLatencyFlush?.() && this.hasForegroundTurnBudgetRemaining()) { this.microtaskPending = true; this.timers.queueMicrotask(() => { this.microtaskPending = false; @@ -269,6 +272,7 @@ export class TerminalOutputDrain { this.foregroundFrame = this.timers.requestAnimationFrame(() => { this.foregroundFrame = null; + this.foregroundTurnStartedAt = null; this.flushForeground(); }); } @@ -286,6 +290,7 @@ export class TerminalOutputDrain { this.schedule(); return; } + this.beginForegroundTurn(); this.flushOne(this.options.getWriteChunkBytes()); } @@ -402,6 +407,7 @@ export class TerminalOutputDrain { this.foregroundTimer = null; } this.microtaskPending = false; + this.foregroundTurnStartedAt = null; } private cancelBackground() { @@ -421,4 +427,18 @@ export class TerminalOutputDrain { private notifyPressure() { this.options.onPressureChange?.(this.getPendingBytes()); } + + private beginForegroundTurn() { + if (this.foregroundTurnStartedAt === null) { + this.foregroundTurnStartedAt = this.timers.now(); + } + } + + private hasForegroundTurnBudgetRemaining() { + if (this.foregroundTurnStartedAt === null) return true; + return ( + this.timers.now() - this.foregroundTurnStartedAt < + XTERM_PERFORMANCE_CONFIG.output.maxForegroundDrainTurnMs + ); + } } diff --git a/src/components/terminal/terminalOutputScheduling.test.ts b/src/components/terminal/terminalOutputScheduling.test.ts new file mode 100644 index 00000000..5247fd4e --- /dev/null +++ b/src/components/terminal/terminalOutputScheduling.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { XTERM_PERFORMANCE_CONFIG } from "@/lib/xtermPerformance"; +import { TerminalOutputScheduler } from "./terminalOutputScheduling"; + +describe("TerminalOutputScheduler", () => { + it("uses normal write chunks outside alternate screen", () => { + const scheduler = new TerminalOutputScheduler({ + getQueueBytes: () => 1024, + isAlternateScreenActive: () => false, + }); + + expect(scheduler.getWriteChunkBytes()).toBe(XTERM_PERFORMANCE_CONFIG.output.writeChunkBytes); + expect(scheduler.getForegroundDelayMs()).toBe(0); + }); + + it("uses alternate chunks without FPS delay until severe backlog", () => { + let queueBytes = XTERM_PERFORMANCE_CONFIG.output.alternateScreenThrottleBacklogBytes; + let now = 1000; + const scheduler = new TerminalOutputScheduler({ + getQueueBytes: () => queueBytes, + isAlternateScreenActive: () => true, + now: () => now, + }); + + expect(scheduler.getWriteChunkBytes()).toBe( + XTERM_PERFORMANCE_CONFIG.output.alternateScreenWriteChunkBytes, + ); + expect(scheduler.getForegroundDelayMs()).toBe(0); + + queueBytes += 1; + scheduler.noteWriteStart(); + now += 10; + + expect(scheduler.getForegroundDelayMs()).toBeGreaterThan(0); + }); +}); diff --git a/src/components/terminal/terminalOutputScheduling.ts b/src/components/terminal/terminalOutputScheduling.ts new file mode 100644 index 00000000..705ff7f5 --- /dev/null +++ b/src/components/terminal/terminalOutputScheduling.ts @@ -0,0 +1,74 @@ +import { XTERM_PERFORMANCE_CONFIG } from "@/lib/xtermPerformance"; + +export interface TerminalOutputSchedulerOptions { + getQueueBytes: () => number; + isAlternateScreenActive: () => boolean; + now?: () => number; +} + +export interface TerminalOutputSchedulingSnapshot { + alternateScreen: boolean; + queueBytes: number; + severeBacklog: boolean; + writeChunkBytes: number; + foregroundDelayMs: number; +} + +export class TerminalOutputScheduler { + private lastAlternateScreenWriteAt = 0; + private readonly now: () => number; + + constructor(private readonly options: TerminalOutputSchedulerOptions) { + this.now = options.now ?? (() => Date.now()); + } + + getWriteChunkBytes() { + return this.options.isAlternateScreenActive() + ? XTERM_PERFORMANCE_CONFIG.output.alternateScreenWriteChunkBytes + : XTERM_PERFORMANCE_CONFIG.output.writeChunkBytes; + } + + getForegroundDelayMs() { + if (!this.shouldUseSevereAlternateScreenThrottle()) return 0; + + const intervalMs = 1000 / XTERM_PERFORMANCE_CONFIG.output.alternateScreenMaxWriteFps; + const elapsedMs = this.now() - this.lastAlternateScreenWriteAt; + return this.lastAlternateScreenWriteAt > 0 && elapsedMs < intervalMs + ? Math.max(1, intervalMs - elapsedMs) + : 0; + } + + noteWriteStart() { + if (this.options.isAlternateScreenActive()) { + this.lastAlternateScreenWriteAt = this.now(); + } + } + + reset() { + this.lastAlternateScreenWriteAt = 0; + } + + snapshot(): TerminalOutputSchedulingSnapshot { + const queueBytes = this.options.getQueueBytes(); + const alternateScreen = this.options.isAlternateScreenActive(); + const severeBacklog = + alternateScreen && + queueBytes > XTERM_PERFORMANCE_CONFIG.output.alternateScreenThrottleBacklogBytes; + + return { + alternateScreen, + queueBytes, + severeBacklog, + writeChunkBytes: this.getWriteChunkBytes(), + foregroundDelayMs: this.getForegroundDelayMs(), + }; + } + + private shouldUseSevereAlternateScreenThrottle() { + return ( + this.options.isAlternateScreenActive() && + this.options.getQueueBytes() > + XTERM_PERFORMANCE_CONFIG.output.alternateScreenThrottleBacklogBytes + ); + } +} diff --git a/src/components/terminal/xterminalOutputQueue.test.ts b/src/components/terminal/xterminalOutputQueue.test.ts new file mode 100644 index 00000000..cc567312 --- /dev/null +++ b/src/components/terminal/xterminalOutputQueue.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from "vitest"; +import { + createOutputQueue, + getOutputQueueDebugSnapshot, + hasOutputQueueItems, + peekOutputQueue, + pushOutputQueue, + replaceOutputQueueHead, + shiftOutputQueue, + splitOutputChunk, + type QueuedOutputChunk, +} from "./xterminalOutputQueue"; + +describe("OutputQueue", () => { + it("releases consumed chunk references while preserving order and bytes", () => { + const queue = createOutputQueue(); + const chunks: QueuedOutputChunk[] = Array.from({ length: 256 }, (_, index) => ({ + data: `${index}:`.padEnd(4096, "x"), + bytes: 4096, + })); + + for (const chunk of chunks) { + pushOutputQueue(queue, chunk); + } + + expect(queue.bytes).toBe(256 * 4096); + + for (let i = 0; i < 128; i += 1) { + expect(shiftOutputQueue(queue)).toBe(chunks[i]); + } + + const snapshot = getOutputQueueDebugSnapshot(queue); + expect(snapshot.bytes).toBe(128 * 4096); + expect(snapshot.liveSlots).toBe(128); + expect(snapshot.consumedSlots).toBe(128); + expect(queue.chunks.slice(0, queue.headIndex).every((slot) => slot === undefined)).toBe(true); + }); + + it("continues growing and consuming without retaining old consumed slots", () => { + const queue = createOutputQueue(); + const written: string[] = []; + + for (let round = 0; round < 40; round += 1) { + for (let i = 0; i < 10; i += 1) { + const data = `r${round}-c${i};`; + pushOutputQueue(queue, { data, bytes: data.length }); + } + + for (let i = 0; i < 7; i += 1) { + const chunk = shiftOutputQueue(queue); + if (chunk) written.push(chunk.data); + } + + expect(queue.chunks.slice(0, queue.headIndex).every((slot) => slot === undefined)).toBe( + true, + ); + } + + const remaining: string[] = []; + while (hasOutputQueueItems(queue)) { + const chunk = shiftOutputQueue(queue); + if (chunk) remaining.push(chunk.data); + } + + expect([...written, ...remaining].join("")).toBe( + Array.from({ length: 40 }, (_, round) => + Array.from({ length: 10 }, (_unused, i) => `r${round}-c${i};`).join(""), + ).join(""), + ); + expect(queue.bytes).toBe(0); + expect(getOutputQueueDebugSnapshot(queue).liveSlots).toBe(0); + }); + + it("handles split head replacement and final queue drain", () => { + const queue = createOutputQueue(); + const original = { data: "abcde", bytes: 5 }; + pushOutputQueue(queue, original); + pushOutputQueue(queue, { data: "fg", bytes: 2 }); + + const head = peekOutputQueue(queue); + expect(head).toBe(original); + expect(head).not.toBeNull(); + + const [splitHead, splitTail] = splitOutputChunk(head!, 2); + replaceOutputQueueHead(queue, splitTail); + queue.bytes = Math.max(0, queue.bytes - splitHead.bytes); + + expect(splitHead).toEqual({ data: "ab", bytes: 2 }); + expect(shiftOutputQueue(queue)).toEqual({ data: "cde", bytes: 3 }); + expect(queue.chunks.slice(0, queue.headIndex).every((slot) => slot === undefined)).toBe(true); + expect(shiftOutputQueue(queue)).toEqual({ data: "fg", bytes: 2 }); + expect(queue.bytes).toBe(0); + expect(hasOutputQueueItems(queue)).toBe(false); + }); + + it("does not retain a consumed large string reference in the queue slots", () => { + const queue = createOutputQueue(); + const large = "x".repeat(8 * 1024 * 1024); + const chunk = { data: large, bytes: large.length }; + + pushOutputQueue(queue, chunk); + expect(shiftOutputQueue(queue)).toBe(chunk); + + expect(queue.chunks.some((slot) => slot?.data === large)).toBe(false); + expect(getOutputQueueDebugSnapshot(queue)).toMatchObject({ + bytes: 0, + liveSlots: 0, + }); + }); +}); diff --git a/src/components/terminal/xterminalOutputQueue.ts b/src/components/terminal/xterminalOutputQueue.ts index 8dfc4d43..5cde0bb7 100644 --- a/src/components/terminal/xterminalOutputQueue.ts +++ b/src/components/terminal/xterminalOutputQueue.ts @@ -8,7 +8,15 @@ export interface QueuedOutputChunk { } export interface OutputQueue { - chunks: QueuedOutputChunk[]; + chunks: Array; + headIndex: number; + bytes: number; +} + +export interface OutputQueueDebugSnapshot { + totalSlots: number; + liveSlots: number; + consumedSlots: number; headIndex: number; bytes: number; } @@ -147,6 +155,7 @@ export function pushOutputQueue(queue: OutputQueue, chunk: QueuedOutputChunk) { export function shiftOutputQueue(queue: OutputQueue): QueuedOutputChunk | null { const chunk = queue.chunks[queue.headIndex]; if (!chunk) return null; + queue.chunks[queue.headIndex] = undefined; queue.headIndex += 1; queue.bytes = Math.max(0, queue.bytes - chunk.bytes); compactOutputQueue(queue); @@ -167,6 +176,26 @@ export function hasOutputQueueItems(queue: OutputQueue) { return queue.headIndex < queue.chunks.length; } +export function getOutputQueueDebugSnapshot(queue: OutputQueue): OutputQueueDebugSnapshot { + let liveSlots = 0; + let consumedSlots = 0; + for (let i = 0; i < queue.chunks.length; i += 1) { + if (queue.chunks[i]) { + liveSlots += 1; + } else if (i < queue.headIndex) { + consumedSlots += 1; + } + } + + return { + totalSlots: queue.chunks.length, + liveSlots, + consumedSlots, + headIndex: queue.headIndex, + bytes: queue.bytes, + }; +} + export function outputQueueToBoundedString(queue: OutputQueue) { const maxBytes = XTERM_PERFORMANCE_CONFIG.lifecycle.snapshotMaxBytes; const parts: string[] = []; diff --git a/src/hooks/useTerminalSettings.ts b/src/hooks/useTerminalSettings.ts index baf5a73d..2213f09f 100644 --- a/src/hooks/useTerminalSettings.ts +++ b/src/hooks/useTerminalSettings.ts @@ -9,6 +9,21 @@ import { XTERM_PERFORMANCE_CONFIG } from "@/lib/xtermPerformance"; import type { TerminalFitScheduler } from "@/components/terminal/terminalFitScheduler"; import type { AppSettings } from "@/types/global"; +type TerminalRendererPreference = "dom" | "webgl" | "auto"; +type ResolvedTerminalRendererMode = "dom" | "webgl"; + +function resolveTerminalRendererMode(options: { + preference: TerminalRendererPreference; + transparencyEnabled: boolean; + webglCircuitBroken: boolean; +}): ResolvedTerminalRendererMode { + if (options.preference === "dom") return "dom"; + if (options.transparencyEnabled || options.webglCircuitBroken) return "dom"; + if (options.preference === "webgl") return "webgl"; + + return "dom"; +} + export function useTerminalSettings( terminalRef: RefObject, fitSchedulerRef: RefObject, @@ -131,10 +146,12 @@ export function useTerminalSettings( disposeWebgl(); } - const shouldUseWebgl = - terminalSettings.hardware_acceleration && - !terminalTransparencyEnabled && - !webglCircuitBrokenRef.current; + const rendererMode = resolveTerminalRendererMode({ + preference: terminalSettings.hardware_acceleration ? "webgl" : "dom", + transparencyEnabled: terminalTransparencyEnabled, + webglCircuitBroken: webglCircuitBrokenRef.current, + }); + const shouldUseWebgl = rendererMode === "webgl"; if (!shouldUseWebgl) { clearHiddenWebglDisposeTimer(); diff --git a/src/lib/xtermPerformance.ts b/src/lib/xtermPerformance.ts index dd7e4997..8081dfdb 100644 --- a/src/lib/xtermPerformance.ts +++ b/src/lib/xtermPerformance.ts @@ -24,6 +24,8 @@ export const XTERM_PERFORMANCE_CONFIG = { strainedBacklogBytes: 128 * 1024, /** Backlog threshold for using microtask low-latency writes on normal shell output. */ lowLatencyFlushBacklogBytes: 64 * 1024, + /** Main-thread time budget for one continuous foreground drain turn. */ + maxForegroundDrainTurnMs: 10, /** Max UTF-8 bytes to write into xterm in a single call. */ writeChunkBytes: 32 * 1024, /** Max UTF-8 bytes to write into xterm during one hidden background drain. */ @@ -36,12 +38,6 @@ export const XTERM_PERFORMANCE_CONFIG = { alternateScreenMaxWriteFps: 20, /** Backlog threshold before alternate-screen foreground writes are throttled. */ alternateScreenThrottleBacklogBytes: 32 * 1024, - /** Queue cap while the terminal is visible. */ - visibleBacklogCapBytes: 1_000_000, - /** Queue cap while an alternate-screen TUI is repainting; older frames are stale. */ - alternateScreenBacklogCapBytes: 128 * 1024, - /** Queue cap while the terminal is hidden; backend flow control normally stops at 1 MiB. */ - hiddenBacklogCapBytes: 2_000_000, /** Recovery threshold after overload while visible. */ visibleRecoveryThresholdBytes: 200_000, /** Recovery threshold after overload while hidden. */ From fb09c8b2488568d9905329ae6771fe3b50825144 Mon Sep 17 00:00:00 2001 From: Kang Date: Fri, 14 Aug 2026 16:07:53 +0800 Subject: [PATCH 02/11] docs: add external and protocol invocation section to SSH connection guide - Introduced a new section detailing how NyaTerm can open connection links from external sources such as browsers and scripts. - Explained supported entry points, link formats, and handling rules for SSH and Telnet connections. - Enhanced documentation clarity for users on utilizing external invocation features. --- docs-site/docs/guide/ssh-connection.md | 25 +++++++++++++++++++ .../current/guide/ssh-connection.md | 25 +++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/docs-site/docs/guide/ssh-connection.md b/docs-site/docs/guide/ssh-connection.md index afc211fa..0c97f73e 100644 --- a/docs-site/docs/guide/ssh-connection.md +++ b/docs-site/docs/guide/ssh-connection.md @@ -230,6 +230,31 @@ NyaTerm 可以在同一条 SSH 连接上多路复用多个终端会话。向同 临时会话不会写入已保存连接:NyaTerm 会剥离连接 ID、代理、跳板机、登录后命令、X11 与算法偏好,因此它始终只是一次性会话。 +## 外部调用与协议调用 + +NyaTerm 也可以从浏览器、脚本、启动器或其他工具中打开连接链接。外部调用会把链接交给当前 NyaTerm 主窗口;如果应用尚未启动,启动参数中的链接也会在主窗口就绪后处理。 + +支持的入口: + +- 程序调用:把链接作为启动参数传给 NyaTerm,例如 `NyaTerm.exe ssh://root@example.com:22` +- 协议调用:通过系统 URL Scheme 打开 `ssh://`、`telnet://` 或 `nyaterm://` 链接 + +支持的链接格式: + +- `ssh://user@host:port` +- `ssh://user:password@host:port`;密码只用于本次 SSH 临时会话,不会保存 +- `telnet://host:port` +- `nyaterm://connect/ssh?host=host&port=22&username=user` +- `nyaterm://connect/telnet?host=host&port=23` + +处理规则: + +- SSH 默认用户名为 `root`,默认端口为 `22`;Telnet 默认端口为 `23` +- NyaTerm 会优先匹配同协议、同主机、同端口的已保存连接;SSH 链接显式写了用户名时,还会按用户名精确匹配 +- 如果匹配到多个已保存连接,会弹出选择窗口;如果没有匹配项,会按临时连接打开 +- 带一次性密码的 `ssh://` 链接始终作为临时连接处理,避免把外部传入的密码绑定到已保存连接 +- `nyaterm://` 链接不接受 `password`、登录后命令、代理、跳板机、端口转发或私钥参数;需要这些能力时,请先保存连接后再打开 + ## 会话输入同步 需要同时对多台主机执行相同操作时,可以使用 **会话输入同步组**,把在一个终端里输入的内容广播到多个会话。 diff --git a/docs-site/i18n/en/docusaurus-plugin-content-docs/current/guide/ssh-connection.md b/docs-site/i18n/en/docusaurus-plugin-content-docs/current/guide/ssh-connection.md index 0dc05b2a..11d10060 100644 --- a/docs-site/i18n/en/docusaurus-plugin-content-docs/current/guide/ssh-connection.md +++ b/docs-site/i18n/en/docusaurus-plugin-content-docs/current/guide/ssh-connection.md @@ -232,6 +232,31 @@ Conventions and limits: A temporary session never becomes a saved connection: NyaTerm strips the connection ID, proxy, jump host, post-login command, X11, and algorithm preferences, so it stays a one-off session. +## External and protocol invocation + +NyaTerm can also open connection links from browsers, scripts, launchers, or other tools. External invocation sends the link to the current NyaTerm main window; if the app is not running yet, links passed as startup arguments are handled after the main window is ready. + +Supported entry points: + +- Program invocation: pass a link as a NyaTerm startup argument, for example `NyaTerm.exe ssh://root@example.com:22` +- Protocol invocation: open an `ssh://`, `telnet://`, or `nyaterm://` link through the operating system URL scheme handler + +Supported link formats: + +- `ssh://user@host:port` +- `ssh://user:password@host:port`; the password is used only for this temporary SSH session and is not saved +- `telnet://host:port` +- `nyaterm://connect/ssh?host=host&port=22&username=user` +- `nyaterm://connect/telnet?host=host&port=23` + +Handling rules: + +- SSH defaults to username `root` and port `22`; Telnet defaults to port `23` +- NyaTerm first looks for saved connections with the same protocol, host, and port; when an SSH link includes a username, the username must match exactly +- If multiple saved connections match, NyaTerm shows a chooser; if none match, it opens a temporary connection +- `ssh://` links with one-time passwords always open as temporary connections, so an externally supplied password is not attached to a saved connection +- `nyaterm://` links do not accept `password`, post-login command, proxy, jump host, port forwarding, or private-key parameters; save a connection first if you need those capabilities + ## Session input synchronization When you need to run the same operation on several hosts at once, use **session input sync groups** to broadcast what you type in one terminal to multiple sessions. From a3393fb99849418e592f3469c8954ad7a8a5b0aa Mon Sep 17 00:00:00 2001 From: litcc Date: Fri, 14 Aug 2026 16:09:07 +0800 Subject: [PATCH 03/11] perf(window): optimize child startup and macOS title bar alignment --- src-tauri/src/cmd/app.rs | 16 ++ src-tauri/tauri.macos.conf.json | 4 + src/ChildWindowRouter.tsx | 38 ++-- src/components/layout/ChildWindowHeader.tsx | 2 +- src/components/layout/Header.tsx | 2 +- src/context/ChildAppProvider.tsx | 20 +- src/lib/windowManager.ts | 205 +++++++++++++++++--- src/main.tsx | 25 ++- 8 files changed, 255 insertions(+), 57 deletions(-) diff --git a/src-tauri/src/cmd/app.rs b/src-tauri/src/cmd/app.rs index 59a1d7ee..2f008c2d 100644 --- a/src-tauri/src/cmd/app.rs +++ b/src-tauri/src/cmd/app.rs @@ -344,6 +344,11 @@ pub async fn open_child_window( .inner_size(width, height) .maximized(maximized) .visible(false) + // On macOS, parent/addChildWindow can add the child to the parent hierarchy during + // creation; keep it unfocusable until the ready handshake to prevent the native window + // from stealing focus before the page is rendered. + .focusable(false) + .focused(false) .decorations(cfg!(target_os = "macos")) .resizable(resizable) .always_on_top(options.always_on_top.unwrap_or(false)); @@ -352,6 +357,10 @@ pub async fn open_child_window( { builder = builder .title_bar_style(tauri::TitleBarStyle::Overlay) + // Position the traffic light controls in logical points so they align with the + // 40px custom header centerline; macOS applies the backing scale factor, so do not + // hard-code coordinates based on the current display's physical resolution. + .traffic_light_position(tauri::LogicalPosition::new(12.0, 22.0)) .hidden_title(true); } @@ -376,6 +385,13 @@ pub async fn open_child_window( .build() .map_err(|error| AppError::Config(error.to_string()))?; + // macOS addChildWindow:ordered: can bypass builder.visible(false) and place the window + // above its parent. Order it out immediately after build so the WebView's first frame and + // page ready handshake complete before an empty window is exposed; revealChildWindow + // restores focusability before showing it. + let _ = window.hide(); + let _ = window.set_focusable(false); + if let Some(placement) = placement { if window .set_position(crate::window_state::placement_to_position(placement)) diff --git a/src-tauri/tauri.macos.conf.json b/src-tauri/tauri.macos.conf.json index fff78b48..a9aa82ce 100644 --- a/src-tauri/tauri.macos.conf.json +++ b/src-tauri/tauri.macos.conf.json @@ -10,6 +10,10 @@ "visible": false, "decorations": true, "titleBarStyle": "Overlay", + "trafficLightPosition": { + "x": 12, + "y": 22 + }, "hiddenTitle": true, "create": false } diff --git a/src/ChildWindowRouter.tsx b/src/ChildWindowRouter.tsx index 693674cf..41623606 100644 --- a/src/ChildWindowRouter.tsx +++ b/src/ChildWindowRouter.tsx @@ -6,7 +6,6 @@ import { isModalChildLabel, prepareForModalChildClose, setOwnerMainWindowLabel, - signalChildWindowReady, } from "./lib/windowManager"; const SettingsPage = lazy(() => import("./pages/SettingsPage")); @@ -31,16 +30,27 @@ const PAGES: Record = { "note-editor": NoteEditorPage, }; +function ChildWindowLoadingShell() { + return ( +
+ +
+ ); +} + function ReadyContent({ children }: { children: ReactNode }) { - useEffect(() => { - const timeoutId = window.setTimeout(() => { - void signalChildWindowReady(); - }, 0); - - return () => window.clearTimeout(timeoutId); - }, []); - - return children; + return ( +
+
+ +
+
{children}
+
+ ); } export default function ChildWindowRouter({ windowType }: { windowType: string }) { @@ -112,10 +122,10 @@ export default function ChildWindowRouter({ windowType }: { windowType: string } } return ( - - + + }> - - + + ); } diff --git a/src/components/layout/ChildWindowHeader.tsx b/src/components/layout/ChildWindowHeader.tsx index 3981d757..1dbb244e 100644 --- a/src/components/layout/ChildWindowHeader.tsx +++ b/src/components/layout/ChildWindowHeader.tsx @@ -89,7 +89,7 @@ export default function ChildWindowHeader({ style={{ backgroundColor: "var(--df-bg-panel)", borderColor: "var(--df-border)" }} >
{icon ? {icon} : null} diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx index 6fd84df3..ea3a8264 100644 --- a/src/components/layout/Header.tsx +++ b/src/components/layout/Header.tsx @@ -1799,7 +1799,7 @@ export default function Header({ className="h-10 border-b flex items-center gap-2 px-2 select-none shrink-0" style={{ backgroundColor: "var(--df-bg-panel)", borderColor: "var(--df-border)" }} > -
+
{!isMacOS && ( )} diff --git a/src/context/ChildAppProvider.tsx b/src/context/ChildAppProvider.tsx index 73cca7a6..0b2c2c44 100644 --- a/src/context/ChildAppProvider.tsx +++ b/src/context/ChildAppProvider.tsx @@ -20,7 +20,6 @@ import i18n from "../i18n"; import { invoke } from "../lib/invoke"; import { logger, setLoggerLevel } from "../lib/logger"; import { DEFAULT_TERMINAL_FONT_SIZE } from "../lib/terminalFontSize"; -import { signalChildWindowReady } from "../lib/windowManager"; import { AppContext } from "./AppContext"; const DEFAULT_APP_SETTINGS: AppSettings = { @@ -309,16 +308,6 @@ export function ChildAppProvider({ children }: { children: ReactNode }) { } }, [appSettings.ui?.language]); - useEffect(() => { - if (!settingsLoaded || !lockStateLoaded || !isLocked) return; - - const timeoutId = window.setTimeout(() => { - void signalChildWindowReady(); - }, 0); - - return () => window.clearTimeout(timeoutId); - }, [isLocked, lockStateLoaded, settingsLoaded]); - useIdleLock( appSettings.security.enable_screen_lock ? appSettings.security.idle_lock_minutes : 0, isLocked, @@ -460,6 +449,15 @@ export function ChildAppProvider({ children }: { children: ReactNode }) { return ( + {!appStateReady ? ( +
+ +
+ ) : null} {showContent ? children : null} {appStateReady && isLocked ? ( (); @@ -72,11 +73,14 @@ interface ModalGroupRaiseOptions { interface ChildWindowReadyPayload { label: string; + /** Bind the ready event to the WebView created for this request. */ + token?: string; } interface ChildWindowReadyWaiter { promise: Promise; cancel: () => void; + failed: () => boolean; } interface PendingChildWindowOpen { @@ -115,7 +119,96 @@ export function isPrimaryMainWindow() { } export function signalChildWindowReady() { - return emit(CHILD_WINDOW_READY_EVENT, { label: getCurrentWindow().label }); + const token = new URLSearchParams(window.location.search).get(CHILD_WINDOW_READY_TOKEN_PARAM); + return emit(CHILD_WINDOW_READY_EVENT, { + label: getCurrentWindow().label, + token: token ?? undefined, + }); +} + +/** + * Wait for at least two WebView layout frames before asking the parent to reveal the window; + * use a timer fallback when the hidden WebView pauses animation frames. Font loading is not a + * ready prerequisite because font swapping does not create a blank window but would delay it. + */ +export function scheduleChildWindowReady() { + let settled = false; + let firstFrameId: number | undefined; + let secondFrameId: number | undefined; + let contentPollTimeoutId: number | undefined; + let fallbackTimeoutId: number | undefined; + + const cleanup = () => { + settled = true; + if (firstFrameId !== undefined) window.cancelAnimationFrame(firstFrameId); + if (secondFrameId !== undefined) window.cancelAnimationFrame(secondFrameId); + if (contentPollTimeoutId !== undefined) window.clearTimeout(contentPollTimeoutId); + if (fallbackTimeoutId !== undefined) window.clearTimeout(fallbackTimeoutId); + }; + + // requestAnimationFrame only means JavaScript had a chance to run; it does not prove that the + // WebView has mounted page content. This is especially important for hidden macOS windows: + // confirm that root has a layoutable child before allowing reveal. + const hasMountedContent = () => { + const root = document.getElementById("root"); + if (!root?.firstElementChild) return false; + const rect = root.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + }; + + const waitForMountedContent = () => { + if (settled) return; + if (hasMountedContent()) { + waitForPaint(); + return; + } + contentPollTimeoutId = window.setTimeout(waitForMountedContent, 16); + }; + + const signalReady = () => { + cleanup(); + void signalChildWindowReady(); + }; + + function emitReady() { + if (settled) return; + if (!hasMountedContent()) { + waitForMountedContent(); + return; + } + signalReady(); + } + + // A hidden WebView may pause requestAnimationFrame. Once the loading shell exists, the + // fallback can complete the handshake without waiting for two more frames; otherwise the + // first open could approach the parent timeout. + const emitReadyFromFallback = () => { + if (settled) return; + if (hasMountedContent()) { + signalReady(); + return; + } + fallbackTimeoutId = window.setTimeout(emitReadyFromFallback, 16); + }; + + const waitForPaint = () => { + if (settled) return; + if (typeof window.requestAnimationFrame !== "function") { + emitReady(); + return; + } + + firstFrameId = window.requestAnimationFrame(() => { + secondFrameId = window.requestAnimationFrame(emitReady); + }); + }; + + // The first hidden WebView frame does not need to wait for custom fonts; confirming that the + // page shell is mounted is sufficient. + waitForMountedContent(); + fallbackTimeoutId = window.setTimeout(emitReadyFromFallback, 250); + + return cleanup; } function scopedModalLabel(baseLabel: string, ownerLabel = ownerMainWindowLabel) { @@ -178,6 +271,17 @@ function childWindowTypeFromUrl(url: string) { } } +function appendChildWindowReadyToken(url: string, token: string) { + const separator = url.includes("?") ? "&" : "?"; + return `${url}${separator}${CHILD_WINDOW_READY_TOKEN_PARAM}=${encodeURIComponent(token)}`; +} + +function createChildWindowReadyToken() { + return typeof crypto.randomUUID === "function" + ? crypto.randomUUID() + : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; +} + function shouldWarnPendingOpenConflict(existingUrl: string, requestedUrl: string) { const existingWindowType = childWindowTypeFromUrl(existingUrl); const requestedWindowType = childWindowTypeFromUrl(requestedUrl); @@ -424,10 +528,14 @@ export async function bounceTopModalWindow() { await raiseModalChildWindowGroup({ requestAttention: true, reason: "backdrop" }); } -async function createChildWindowReadyWaiter(label: string): Promise { +async function createChildWindowReadyWaiter( + label: string, + token: string, +): Promise { let settled = false; let timeoutId: number | undefined; let unlisten: (() => void) | undefined; + let failed = false; let resolveReady: () => void = () => {}; const promise = new Promise((resolve) => { resolveReady = resolve; @@ -445,11 +553,12 @@ async function createChildWindowReadyWaiter(label: string): Promise(CHILD_WINDOW_READY_EVENT, ({ payload }) => { - if (payload.label === label) { + if (payload.label === label && payload.token === token) { settle(); } }); timeoutId = window.setTimeout(() => { + failed = true; logger.warn({ domain: "window.lifecycle", event: "child_ready_timeout", @@ -459,6 +568,7 @@ async function createChildWindowReadyWaiter(label: string): Promise failed }; } -async function revealChildWindow(win: WebviewWindow, opts: ChildWindowOptions, isModal: boolean) { - await win.setTitle(opts.title).catch(() => {}); - await win.setAlwaysOnTop(needsAlwaysOnTop(opts.label)).catch(() => {}); +async function revealChildWindow( + win: WebviewWindow, + opts: ChildWindowOptions, + isModal: boolean, + isNewWindow = false, + onShown?: () => void, +) { + // The Rust builder already sets the title, always-on-top state, and position for a new window. + // Repeating those IPC calls would delay show(), especially during the first macOS open. + if (!isNewWindow) { + await win.setTitle(opts.title).catch(() => {}); + await win.setAlwaysOnTop(needsAlwaysOnTop(opts.label)).catch(() => {}); + } attachChildWindowDestroyedHandler(opts.label, win); - await ensureChildWindowVisible(win, opts); + if (!isNewWindow) { + await ensureChildWindowVisible(win, opts); + } + // Keep the child hidden until the ready handshake, then restore interactivity before showing. + await win.setFocusable(true).catch(() => {}); await win.show().catch(() => {}); + onShown?.(); await win.setFocus().catch(() => {}); emit("child-window-opened", { label: opts.label }); if (isModal) { @@ -487,20 +612,40 @@ async function revealChildWindow(win: WebviewWindow, opts: ChildWindowOptions, i } async function openChildWindowInternal(opts: ChildWindowOptions) { + const startedAt = performance.now(); + const logTiming = (data: Record) => { + logger.info({ + domain: "window.lifecycle", + event: "child_window_open_timing", + message: "Child window open timing", + data: { + label: opts.label, + total_ms: Math.round(performance.now() - startedAt), + ...data, + }, + }); + }; const kind = childWindowKind(opts); const isModal = kind === "modal"; const existing = await WebviewWindow.getByLabel(opts.label); if (existing) { - return revealChildWindow(existing, opts, isModal); + let shownMs: number | undefined; + const revealed = await revealChildWindow(existing, opts, isModal, false, () => { + shownMs = Math.round(performance.now() - startedAt); + }); + logTiming({ existing: true, shown_ms: shownMs }); + return revealed; } - const readyWaiter = await createChildWindowReadyWaiter(opts.label); + const readyToken = createChildWindowReadyToken(); + const readyWaiter = await createChildWindowReadyWaiter(opts.label, readyToken); + const listenerReadyMs = Math.round(performance.now() - startedAt); try { await invoke("open_child_window", { options: { label: opts.label, title: opts.title, - url: opts.url, + url: appendChildWindowReadyToken(opts.url, readyToken), kind, parentLabel: opts.parentLabel ?? ownerMainWindowLabel, width: opts.width ?? 720, @@ -510,17 +655,38 @@ async function openChildWindowInternal(opts: ChildWindowOptions) { stateKey: opts.stateKey, }, }); + const invokeMs = Math.round(performance.now() - startedAt); const win = await WebviewWindow.getByLabel(opts.label); if (!win) { throw new Error(`Failed to create child window: ${opts.label}`); } + const handleMs = Math.round(performance.now() - startedAt); attachChildWindowDestroyedHandler(opts.label, win); await readyWaiter.promise; - return revealChildWindow(win, opts, isModal); + if (readyWaiter.failed()) { + throw new Error(`Child window did not finish rendering: ${opts.label}`); + } + const readyMs = Math.round(performance.now() - startedAt); + let shownMs: number | undefined; + const revealed = await revealChildWindow(win, opts, isModal, true, () => { + shownMs = Math.round(performance.now() - startedAt); + }); + logTiming({ + existing: false, + listener_ready_ms: listenerReadyMs, + invoke_ms: invokeMs, + handle_ms: handleMs, + ready_ms: readyMs, + shown_ms: shownMs, + }); + return revealed; } catch (error) { readyWaiter.cancel(); + // Destroy a failed first-open window promptly so it cannot remain as a background orphan. + const orphan = await WebviewWindow.getByLabel(opts.label).catch(() => null); + await orphan?.close().catch(() => {}); throw error; } } @@ -576,11 +742,6 @@ export async function openSettings(tab?: string) { if (tab) { const payload = { tab, targetWindowLabel: ownerMainWindowLabel }; emit("settings-open-tab", payload); - window.setTimeout(() => { - void win.show().catch(() => {}); - void win.setFocus().catch(() => {}); - emit("settings-open-tab", payload); - }, 120); } return win; } @@ -736,11 +897,6 @@ export function openRemoteFileEditor(data: RemoteFileEditorWindowData) { }).then((win) => { const payload = { targetLabel: label, data }; emit("remote-file-editor-open", payload); - window.setTimeout(() => { - void win.show().catch(() => {}); - void win.setFocus().catch(() => {}); - emit("remote-file-editor-open", payload); - }, 120); return win; }); } @@ -770,11 +926,6 @@ export function openFilePreview(data: FilePreviewWindowData) { }).then((win) => { const payload = { targetLabel: label, data }; emit("file-preview-open", payload); - window.setTimeout(() => { - void win.show().catch(() => {}); - void win.setFocus().catch(() => {}); - emit("file-preview-open", payload); - }, 120); return win; }); } diff --git a/src/main.tsx b/src/main.tsx index 29caca36..b9757d65 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -19,6 +19,7 @@ import { } from "./context/ThemeContext"; import { DEFAULT_THEME_ID, themes } from "./lib/themes"; import { installWebviewReloadGuard } from "./lib/webviewReloadGuard"; +import { scheduleChildWindowReady } from "./lib/windowManager"; // Apply cached theme synchronously before React renders to avoid flash try { @@ -45,10 +46,28 @@ const windowType = params.get("window"); if (windowType) { // Child window: lightweight provider stack, no full App - const { ChildAppProvider } = await import("./context/ChildAppProvider"); - const { default: ChildWindowRouter } = await import("./ChildWindowRouter"); + // These entry points are independent and should load in parallel; serial awaits would add an + // unnecessary chunk round trip to every child-window open. + const childRoot = ReactDOM.createRoot(document.getElementById("root") as HTMLElement); + // Commit an inline-background loading shell before loading provider and page chunks. This lets + // the parent reveal a stable surface without reintroducing the macOS white or empty window. + childRoot.render( +
+ +
, + ); + scheduleChildWindowReady(); - ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( + const [{ ChildAppProvider }, { default: ChildWindowRouter }] = await Promise.all([ + import("./context/ChildAppProvider"), + import("./ChildWindowRouter"), + ]); + + childRoot.render( From 8595375488b13b03b69e17d60065aec73353cf26 Mon Sep 17 00:00:00 2001 From: litcc Date: Fri, 14 Aug 2026 22:04:40 +0800 Subject: [PATCH 04/11] perf(window): streamline child window startup Split the main app provider from the lightweight child context, reveal child windows after a stable shell is rendered, and queue commands until listeners are ready. --- src/ChildWindowRouter.tsx | 9 +- src/context/AppContext.tsx | 1333 +--------------------- src/context/AppProvider.tsx | 1299 +++++++++++++++++++++ src/hooks/useChildWindowCommand.test.tsx | 85 ++ src/hooks/useChildWindowCommand.ts | 42 + src/lib/childWindowCommandQueue.test.ts | 78 ++ src/lib/childWindowCommandQueue.ts | 75 ++ src/lib/childWindowLifecycle.test.ts | 55 + src/lib/childWindowLifecycle.ts | 134 +++ src/lib/childWindowProtocol.ts | 29 + src/lib/windowManager.test.ts | 17 +- src/lib/windowManager.ts | 345 +++--- src/main.tsx | 99 +- src/pages/FilePreviewPage.tsx | 28 +- src/pages/RemoteFileEditorPage.tsx | 28 +- src/pages/SettingsPage.tsx | 23 +- 16 files changed, 2116 insertions(+), 1563 deletions(-) create mode 100644 src/context/AppProvider.tsx create mode 100644 src/hooks/useChildWindowCommand.test.tsx create mode 100644 src/hooks/useChildWindowCommand.ts create mode 100644 src/lib/childWindowCommandQueue.test.ts create mode 100644 src/lib/childWindowCommandQueue.ts create mode 100644 src/lib/childWindowLifecycle.test.ts create mode 100644 src/lib/childWindowLifecycle.ts create mode 100644 src/lib/childWindowProtocol.ts diff --git a/src/ChildWindowRouter.tsx b/src/ChildWindowRouter.tsx index 41623606..3167d708 100644 --- a/src/ChildWindowRouter.tsx +++ b/src/ChildWindowRouter.tsx @@ -43,14 +43,7 @@ function ChildWindowLoadingShell() { } function ReadyContent({ children }: { children: ReactNode }) { - return ( -
-
- -
-
{children}
-
- ); + return
{children}
; } export default function ChildWindowRouter({ windowType }: { windowType: string }) { diff --git a/src/context/AppContext.tsx b/src/context/AppContext.tsx index 9d8140f6..f1c31d6e 100644 --- a/src/context/AppContext.tsx +++ b/src/context/AppContext.tsx @@ -1,48 +1,4 @@ -import { listen } from "@tauri-apps/api/event"; -import { - createContext, - type ReactNode, - useCallback, - useContext, - useEffect, - useMemo, - useRef, - useState, -} from "react"; -import { useAppLockState } from "@/hooks/useAppLockState"; -import { DEFAULT_AI_SETTINGS } from "@/lib/aiSettings"; -import { DEFAULT_CLOUD_SYNC_SETTINGS } from "@/lib/cloudSync"; -import { updateConnectionAutoIconAfterSessionStart } from "@/lib/connectionAutoIcon"; -import { DEFAULT_TERMINAL_FONT_FAMILY, getDefaultUiFontFamily } from "@/lib/defaultFonts"; -import { getErrorMessage } from "@/lib/errors"; -import { - DEFAULT_COMMAND_SUGGESTION_MAX_CHARS, - DEFAULT_COMMAND_SUGGESTION_MIN_CHARS, - DEFAULT_TAB_DOUBLE_CLICK_ACTION, - DEFAULT_TAB_MIDDLE_CLICK_ACTION, - DEFAULT_TAB_RIGHT_CLICK_ACTION, -} from "@/lib/interactionSettings"; -import { - normalizeQuickCommandAppSettings, - normalizeQuickCommandUiConfig, -} from "@/lib/quickCommandSettings"; -import { - collectSessionPanes, - createSessionPane, - createWorkspaceTab, - ensureActivePane, - findSessionPaneById, - getFirstSessionPane, - getNextPersistOrder, - insertTabAfter, - moveTab, - removeSessionPane, - restoreTabFromPersistence, - serializeTabsForPersistence, - splitSessionPane, - updateSessionPane, - updateSplitRatio as updateWorkspaceSplitRatio, -} from "@/lib/workspaceTabs"; +import { createContext, useContext } from "react"; import type { AppRuntimeInfo, AppSettings, @@ -56,17 +12,17 @@ import type { UiConfig, WorkspaceSessionType, } from "@/types/global"; -import { invoke } from "../lib/invoke"; -import { logger, setLoggerLevel } from "../lib/logger"; -import { DEFAULT_TERMINAL_FONT_SIZE } from "../lib/terminalFontSize"; -import { isPrimaryMainWindow } from "../lib/windowManager"; -type PaneConnectingUpdates = Partial> & { +export type PaneConnectingUpdates = Partial> & { display?: RemoteDesktopSessionPane["display"]; }; -interface AppContextType { - // Tabs +export interface PendingTabCreation { + tabId: string; + createRequestId: string; +} + +export interface AppContextType { tabs: Tab[]; activeTabId: string | null; setActiveTabId: (id: string | null) => void; @@ -78,7 +34,6 @@ interface AppContextType { extra?: Partial>, options?: { afterTabId?: string }, ) => string; - /** Immediately add a "connecting" tab and make it active. Returns the new tabId. */ addPendingTab: ( name: string, type: WorkspaceSessionType, @@ -87,15 +42,10 @@ interface AppContextType { options?: { afterTabId?: string }, paneOverrides?: Partial, ) => PendingTabCreation; - /** Swap the active pane's temporary sessionId for the real one and clear the connecting flag. */ updateTabSession: (tabId: string, sessionId: string) => void; - /** Mark the active pane in a tab as failed while keeping the tab visible. */ markTabConnectionFailed: (tabId: string, error: string) => void; - /** Update one specific pane's session binding. */ updatePaneSession: (tabId: string, paneId: string, sessionId: string) => void; - /** Mark a specific pane as failed while keeping the layout intact. */ markPaneConnectionFailed: (tabId: string, paneId: string, error: string) => void; - /** Put a specific pane back into connecting state, optionally refreshing its metadata first. */ markPaneConnecting: ( tabId: string, paneId: string, @@ -114,7 +64,6 @@ interface AppContextType { ) => string | null; closePane: (tabId: string, paneId: string, options?: { immediatePersist?: boolean }) => void; reorderTabs: (fromTabId: string, toIndex: number) => void; - /** Update user-editable tab properties (customName, tabColor, locked). */ updateTab: ( tabId: string, updates: Partial>, @@ -126,55 +75,34 @@ interface AppContextType { ) => void; closeTab: (tabId: string) => void; persistTabsNow: (extraUi?: Partial) => Promise; - - // App Settings (includes UI config) appSettings: AppSettings; updateAppSettings: ( updates: Partial | ((prev: AppSettings) => Partial), ) => void; replaceAppSettings: (next: AppSettings) => void; updateUi: (updates: Partial | ((prev: UiConfig) => Partial)) => void; - - // Data savedConnections: SavedConnection[]; savedGroups: Group[]; refreshConnections: () => Promise; recordRecentConnection: (connectionId: string) => void; - - // Dialogs showNewSession: boolean; setShowNewSession: (show: boolean) => void; editingConnection: SavedConnection | undefined; setEditingConnection: (conn: SavedConnection | undefined) => void; showSettingsDialog: boolean; setShowSettingsDialog: (show: boolean) => void; - - // Sync Input Groups syncGroups: SyncGroup[]; setSyncGroups: (groups: SyncGroup[] | ((prev: SyncGroup[]) => SyncGroup[])) => void; broadcastToAll: boolean; setBroadcastToAll: (value: boolean | ((prev: boolean) => boolean)) => void; - - // Idle Lock isLocked: boolean; setIsLocked: (locked: boolean) => void; - - // Loading settingsLoaded: boolean; startupRestoreComplete: boolean; runtimeInfo: AppRuntimeInfo; runtimeInfoLoaded: boolean; } -export interface PendingTabCreation { - tabId: string; - createRequestId: string; -} - -function createSessionRequestId() { - return crypto.randomUUID(); -} - export type TerminalAppSettings = Pick< AppSettings, | "appearance" @@ -187,1251 +115,10 @@ export type TerminalAppSettings = Pick< | "transfer" >; -/** - * App-wide state: tabs, settings (debounced save), saved connections (polled), - * and dialog visibility. Updates via setState/useCallback; config persisted to backend. - */ +/** 仅包含上下文契约,避免子窗口加载主窗口的工作区状态实现。 */ export const AppContext = createContext(null); -const TerminalAppSettingsContext = createContext(null); +export const TerminalAppSettingsContext = createContext(null); -const DEFAULT_APP_SETTINGS: AppSettings = { - general: { - startup_restore: true, - startup_restore_window_layout: true, - minimize_to_tray: false, - boss_key: null, - confirm_on_close: true, - }, - appearance: { - theme: "github-dark", - custom_themes: [], - font_family: DEFAULT_TERMINAL_FONT_FAMILY, - ui_font_family: getDefaultUiFontFamily(), - font_size: DEFAULT_TERMINAL_FONT_SIZE, - font_weight: 400, - font_weight_bold: 700, - background_opacity: 1.0, - background_image_path: null, - background_image_fit: "cover", - background_image_opacity: 0.45, - cursor_style: "block", - cursor_blink: true, - ui_font_size: 16, - terminal_theme: null, - minimum_contrast_ratio: 1, - panel_multi_open: false, - window_transparency: "none", - window_transparency_tint: 1, - window_transparency_blur: false, - }, - proxy: { - enabled: false, - protocol: "socks5", - host: "127.0.0.1", - port: 1080, - }, - search: { - custom_engines: [ - { name: "Google", url_template: "https://google.com/search?q=%s", show_in_menu: true }, - { name: "Bing", url_template: "https://bing.com/search?q=%s", show_in_menu: true }, - { name: "GitHub", url_template: "https://github.com/search?q=%s", show_in_menu: true }, - ], - }, - translation: { - target_language: "zh-CN", - deepl_api_key: "", - baidu_app_id: "", - baidu_app_key: "", - ali_app_id: "", - ali_app_key: "", - youdao_app_id: "", - youdao_app_key: "", - }, - security: { - use_os_keyring: true, - enable_screen_lock: false, - idle_lock_minutes: 0, - host_key_policy: "prompt", - }, - terminal: { - scrollback_lines: 10000, - keep_alive_mode: "compatible", - keep_alive_interval: 60, - font_size_delta: 0, - x11_display: "", - hardware_acceleration: false, - keyword_highlights_enabled: false, - keyword_highlights_across_wrapped_lines: false, - keyword_highlight_builtin_rules: {}, - keyword_highlights: [], - action_links_enabled: false, - action_links_matchers: { - ipv4: true, - archive: true, - host_port: true, - }, - show_workspace_padding: false, - show_line_numbers: false, - show_timestamps: false, - timestamp_format: "[HH:mm:ss]", - show_multi_line_paste_dialog: true, - paste_image_as_path: true, - }, - interaction: { - copy_on_select: false, - allow_osc52_clipboard_write: false, - right_click_paste: false, - terminal_zoom_enabled: true, - command_suggestions_enabled: true, - command_suggestion_min_chars: DEFAULT_COMMAND_SUGGESTION_MIN_CHARS, - command_suggestion_max_chars: DEFAULT_COMMAND_SUGGESTION_MAX_CHARS, - duplicate_session_command_delay_ms: 1000, - word_separators: " ()[]{}\"':=,;|&<>", - alt_as_meta: false, - ime_compatibility: false, - default_encoding: "UTF-8", - tab_double_click_action: DEFAULT_TAB_DOUBLE_CLICK_ACTION, - tab_middle_click_action: DEFAULT_TAB_MIDDLE_CLICK_ACTION, - tab_right_click_action: DEFAULT_TAB_RIGHT_CLICK_ACTION, - }, - recording: { - auto_start: false, - default_mode: "transcript", - base_path: "", - path_template: "{group}/{session}/{yyyy}-{MM}-{dd}/{HH}-{mm}-{ss}-{SSS}-{session_short_id}.log", - include_timestamps: true, - include_io_labels: true, - include_session_metadata: true, - rotation: { type: "session" }, - existing_file_behavior: "unique", - memory_limit_bytes: 5 * 1024 * 1024, - include_binary_transfer_payloads: false, - }, - transfer: { - editor_type: "external", - download_threads: 3, - upload_threads: 3, - duplicate_strategy: "ask", - preserve_timestamps: true, - resume_broken_transfer: true, - default_file_permissions: "644", - max_transfer_retries: 2, - transfer_buffer_size: 32, - download_path: "", - ask_save_location: false, - default_editor: "", - recording_path: "", - recording_include_io_labels: true, - recording_include_timestamps: true, - recording_auto_start: false, - recording_memory_limit_bytes: 5 * 1024 * 1024, - }, - diagnostics: { - level: "info", - retention_days: 7, - }, - ai: { - ...DEFAULT_AI_SETTINGS, - }, - cloud_sync: DEFAULT_CLOUD_SYNC_SETTINGS, - ui: { - open_tabs: [], - terminal_window_layout: null, - start_workspace_mode: "workbench", - left_width: 256, - right_width: 288, - quick_cmd_height: 180, - quick_cmd_category_width: 176, - quick_cmd_view_mode: "tile", - quick_cmd_sort_mode: "created", - quick_cmd_selected_category: "all", - active_left_panel: "fileExplorer", - active_right_panel: "savedConnections", - left_open_panels: [], - right_open_panels: [], - panel_stack_sizes: {}, - network_panel_active_tab: "tunnel", - security_auth_panel_active_tab: "keys", - show_quick_cmd_bar: true, - show_serial_send_panel: false, - serial_send_height: 180, - zoom_level: 1.0, - language: "en", - header_status_mode: "session", - header_status_visible: true, - show_notes_panel: true, - show_remote_stats: true, - remote_stats_interval: 3, - show_gpu_monitor: false, - gpu_monitor_interval: 3, - show_ascend_npu_monitor: false, - ascend_npu_monitor_interval: 3, - show_process_manager: false, - process_manager_interval: 5, - show_docker_manager: false, - docker_manager_interval: 10, - saved_connections_sort_mode: "default", - saved_connections_expanded_group_ids: [], - asset_sort_key: null, - asset_sort_direction: null, - recent_connection_ids: [], - transfer_height: 180, - file_explorer_show_hidden_files: true, - file_explorer_auto_sync_cwd_connection_ids: [], - file_explorer_favorite_dirs_by_connection_id: {}, - notes_expanded_folder_ids: [], - notes_last_selected_node_id: null, - activity_bar_layout: { - left_top: ["fileExplorer", "notes", "network", "securityAuth"], - left_bottom: ["syncBackupHistory", "settings"], - right_top: [ - "savedConnections", - "aiAssistant", - "activeSessions", - "commandHistory", - "resourceMonitor", - "gpuMonitor", - "ascendNpuMonitor", - "processManager", - "dockerManager", - ], - right_bottom: ["quickCmdBar", "serialSend", "recording", "lock"], - show_labels: false, - }, - }, - keybindings: {}, -}; - -const RECENT_CONNECTION_LIMIT = 10; - -const DEFAULT_RUNTIME_INFO: AppRuntimeInfo = { - portable: false, - mode: "installed", - executableDir: "", - dataDir: "", - configDir: "", - logDir: "", - webviewDataDir: "", - portableMarkerPath: null, -}; - -function areSettingsValuesEqual(left: unknown, right: unknown): boolean { - if (Object.is(left, right)) return true; - if (typeof left !== typeof right) return false; - if (left === null || right === null) return left === right; - - if (Array.isArray(left) || Array.isArray(right)) { - if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) return false; - for (let index = 0; index < left.length; index += 1) { - if (!areSettingsValuesEqual(left[index], right[index])) { - return false; - } - } - return true; - } - - if (typeof left !== "object" || typeof right !== "object") { - return false; - } - - const leftRecord = left as Record; - const rightRecord = right as Record; - const leftKeys = Object.keys(leftRecord); - const rightKeys = Object.keys(rightRecord); - if (leftKeys.length !== rightKeys.length) return false; - - for (const key of leftKeys) { - if (!(key in rightRecord)) return false; - if (!areSettingsValuesEqual(leftRecord[key], rightRecord[key])) { - return false; - } - } - - return true; -} - -function preserveAppSettingsReferences(prev: AppSettings, next: AppSettings): AppSettings { - const general = areSettingsValuesEqual(prev.general, next.general) ? prev.general : next.general; - const appearance = areSettingsValuesEqual(prev.appearance, next.appearance) - ? prev.appearance - : next.appearance; - const proxy = areSettingsValuesEqual(prev.proxy, next.proxy) ? prev.proxy : next.proxy; - const search = areSettingsValuesEqual(prev.search, next.search) ? prev.search : next.search; - const translation = areSettingsValuesEqual(prev.translation, next.translation) - ? prev.translation - : next.translation; - const security = areSettingsValuesEqual(prev.security, next.security) - ? prev.security - : next.security; - const terminal = areSettingsValuesEqual(prev.terminal, next.terminal) - ? prev.terminal - : next.terminal; - const interaction = areSettingsValuesEqual(prev.interaction, next.interaction) - ? prev.interaction - : next.interaction; - const transfer = areSettingsValuesEqual(prev.transfer, next.transfer) - ? prev.transfer - : next.transfer; - const diagnostics = areSettingsValuesEqual(prev.diagnostics, next.diagnostics) - ? prev.diagnostics - : next.diagnostics; - const ai = areSettingsValuesEqual(prev.ai, next.ai) ? prev.ai : next.ai; - const cloudSync = areSettingsValuesEqual(prev.cloud_sync, next.cloud_sync) - ? prev.cloud_sync - : next.cloud_sync; - const ui = areSettingsValuesEqual(prev.ui, next.ui) ? prev.ui : next.ui; - const keybindings = areSettingsValuesEqual(prev.keybindings, next.keybindings) - ? prev.keybindings - : next.keybindings; - - if ( - general === prev.general && - appearance === prev.appearance && - proxy === prev.proxy && - search === prev.search && - translation === prev.translation && - security === prev.security && - terminal === prev.terminal && - interaction === prev.interaction && - transfer === prev.transfer && - diagnostics === prev.diagnostics && - ai === prev.ai && - cloudSync === prev.cloud_sync && - ui === prev.ui && - keybindings === prev.keybindings - ) { - return prev; - } - - return { - ...next, - general, - appearance, - proxy, - search, - translation, - security, - terminal, - interaction, - transfer, - diagnostics, - ai, - cloud_sync: cloudSync, - ui, - keybindings, - }; -} - -/** Provides tabs, appSettings, savedConnections, and dialog state to the app. */ -export function AppProvider({ children }: { children: ReactNode }) { - // Tabs State - const [tabs, setTabs] = useState([]); - const tabsRef = useRef([]); - const [activeTabIdState, setActiveTabIdState] = useState(null); - const activeTabIdRef = useRef(null); - - // App Settings State (includes UI config) - const [appSettings, setAppSettings] = useState(DEFAULT_APP_SETTINGS); - const appSettingsRef = useRef(DEFAULT_APP_SETTINGS); - const appSettingsLoaded = useRef(false); - const appSettingsSaveTimerRef = useRef | null>(null); - const uiSaveTimerRef = useRef | null>(null); - - // Data State - const [savedConnections, setSavedConnections] = useState([]); - const [savedGroups, setSavedGroups] = useState([]); - - // Dialog State - const [showNewSession, setShowNewSession] = useState(false); - const [editingConnection, setEditingConnection] = useState( - undefined, - ); - const [showSettingsDialog, setShowSettingsDialog] = useState(false); - - // Sync Input Groups - const [syncGroups, setSyncGroups] = useState([]); - const [broadcastToAll, setBroadcastToAll] = useState(false); - - // Idle Lock State - const { isLocked, setIsLocked, lockStateLoaded } = useAppLockState(); - - // Loading State - const [settingsLoaded, setSettingsLoaded] = useState(false); - const [startupRestoreComplete, setStartupRestoreComplete] = useState(false); - const [runtimeInfo, setRuntimeInfo] = useState(DEFAULT_RUNTIME_INFO); - const [runtimeInfoLoaded, setRuntimeInfoLoaded] = useState(false); - - const setActiveTabId = useCallback((id: string | null) => { - activeTabIdRef.current = id; - setActiveTabIdState(id); - }, []); - - // 1. Load App Settings - useEffect(() => { - invoke("get_app_runtime_info") - .then((info) => { - setRuntimeInfo(info); - }) - .catch((error) => { - logger.error({ - domain: "app.lifecycle", - event: "runtime_info.load_failed", - message: "Failed to load app runtime info", - error, - }); - }) - .finally(() => { - setRuntimeInfoLoaded(true); - }); - - invoke("get_app_settings") - .then((cfg) => { - const normalized = normalizeQuickCommandAppSettings(cfg); - appSettingsRef.current = normalized; - setAppSettings(normalized); - setLoggerLevel(normalized.diagnostics.level); - appSettingsLoaded.current = true; - setSettingsLoaded(true); - if (isPrimaryMainWindow() && normalized.security?.enable_screen_lock) { - setIsLocked(true); - } - }) - .catch(() => { - appSettingsRef.current = DEFAULT_APP_SETTINGS; - appSettingsLoaded.current = true; - setAppSettings(DEFAULT_APP_SETTINGS); - setSettingsLoaded(true); - }); - }, [setIsLocked]); - - // Apply UI font size to root element - useEffect(() => { - document.documentElement.style.fontSize = `${appSettings.appearance.ui_font_size}px`; - }, [appSettings.appearance.ui_font_size]); - - useEffect(() => { - const fontFamily = appSettings.appearance.ui_font_family; - document.documentElement.style.setProperty("--font-sans", fontFamily); - document.documentElement.style.setProperty("--font-display", fontFamily); - }, [appSettings.appearance.ui_font_family]); - - // 2. Save App Settings Debounced - const updateAppSettings = useCallback( - (updates: Partial | ((prev: AppSettings) => Partial)) => { - setAppSettings((prev) => { - const nextUpdates = typeof updates === "function" ? updates(prev) : updates; - const next = normalizeQuickCommandAppSettings({ - ...prev, - ...nextUpdates, - }); - appSettingsRef.current = next; - setLoggerLevel(next.diagnostics.level); - if (appSettingsLoaded.current) { - if (appSettingsSaveTimerRef.current) clearTimeout(appSettingsSaveTimerRef.current); - appSettingsSaveTimerRef.current = setTimeout(() => { - invoke("save_app_settings", { settings: next }).catch((e) => - logger.error({ - domain: "settings.persistence", - event: "settings.save_failed", - message: "Failed to save app settings", - error: e, - }), - ); - }, 500); - } - return next; - }); - }, - [], - ); - - const replaceAppSettings = useCallback((next: AppSettings) => { - if (appSettingsSaveTimerRef.current) { - clearTimeout(appSettingsSaveTimerRef.current); - appSettingsSaveTimerRef.current = null; - } - setAppSettings((current) => { - const normalized = preserveAppSettingsReferences( - current, - normalizeQuickCommandAppSettings(next), - ); - appSettingsRef.current = normalized; - setLoggerLevel(normalized.diagnostics.level); - return normalized; - }); - }, []); - - // Convenience helper to update just the UI config portion via lightweight path - const updateUi = useCallback( - (updates: Partial | ((prev: UiConfig) => Partial)) => { - setAppSettings((prev) => { - const nextUpdates = typeof updates === "function" ? updates(prev.ui) : updates; - const nextUi = normalizeQuickCommandUiConfig({ - ...prev.ui, - ...nextUpdates, - }); - const next = { ...prev, ui: nextUi }; - appSettingsRef.current = next; - if (appSettingsLoaded.current) { - if (uiSaveTimerRef.current) clearTimeout(uiSaveTimerRef.current); - uiSaveTimerRef.current = setTimeout(() => { - invoke("save_app_ui_settings", { ui: nextUi }).catch((e) => - logger.error({ - domain: "settings.persistence", - event: "ui_settings.save_failed", - message: "Failed to save UI settings", - error: e, - }), - ); - }, 500); - } - return next; - }); - }, - [], - ); - - const recordRecentConnection = useCallback( - (connectionId: string) => { - if (!connectionId) return; - updateUi((prev) => ({ - recent_connection_ids: [ - connectionId, - ...(prev.recent_connection_ids ?? []).filter((id) => id !== connectionId), - ].slice(0, RECENT_CONNECTION_LIMIT), - })); - }, - [updateUi], - ); - - // 3. Load Connections - const refreshConnections = useCallback(async () => { - try { - const [saved, groups] = await Promise.all([ - invoke("get_saved_connections"), - invoke("get_groups"), - ]); - setSavedConnections(saved); - setSavedGroups(groups); - } catch (e) { - logger.error({ - domain: "ui.error", - event: "connections.fetch_failed", - message: "Failed to fetch connections", - error: e, - }); - } - }, []); - - useEffect(() => { - refreshConnections(); - const unlisten = listen("connections-changed", () => { - refreshConnections(); - }); - return () => { - unlisten.then((fn) => fn()); - }; - }, [refreshConnections]); - - const syncOpenTabs = useCallback( - async (nextTabs: Tab[], options?: { immediatePersist?: boolean }) => { - if (!hasRestored.current || !appSettingsRef.current.general.startup_restore) return; - - const openTabs = serializeTabsForPersistence(nextTabs); - updateUi({ open_tabs: openTabs }); - - if (!options?.immediatePersist) return; - - const nextUi = { ...appSettingsRef.current.ui, open_tabs: openTabs }; - appSettingsRef.current = { ...appSettingsRef.current, ui: nextUi }; - await invoke("save_app_ui_settings", { ui: nextUi }); - }, - [updateUi], - ); - - const commitTabs = useCallback( - async ( - nextTabs: Tab[], - options?: { - syncPersisted?: boolean; - immediatePersist?: boolean; - }, - ) => { - const normalizedTabs = nextTabs.map(ensureActivePane); - tabsRef.current = normalizedTabs; - setTabs(normalizedTabs); - - if (options?.syncPersisted === false) return; - await syncOpenTabs(normalizedTabs, { immediatePersist: options?.immediatePersist }); - }, - [syncOpenTabs], - ); - - // 4. Tab Logic - const addTab = useCallback( - ( - sessionId: string, - name: string, - type: WorkspaceSessionType, - connectionId?: string, - extra?: Partial>, - options?: { afterTabId?: string }, - ) => { - const pane = createSessionPane(name, type, connectionId, { sessionId }); - const newTab = createWorkspaceTab(pane, getNextPersistOrder(tabsRef.current), extra); - const nextTabs = options?.afterTabId - ? insertTabAfter(tabsRef.current, options.afterTabId, newTab) - : [...tabsRef.current, newTab]; - void commitTabs(nextTabs); - setActiveTabId(newTab.id); - - // Close dialogs when session starts - setShowNewSession(false); - setEditingConnection(undefined); - return newTab.id; - }, - [commitTabs, setActiveTabId], - ); - - const addPendingTab = useCallback( - ( - name: string, - type: WorkspaceSessionType, - connectionId?: string, - extra?: Partial>, - options?: { afterTabId?: string }, - paneOverrides?: Partial, - ): PendingTabCreation => { - const createRequestId = createSessionRequestId(); - const pane = createSessionPane(name, type, connectionId, { - ...paneOverrides, - connecting: true, - createRequestId, - }); - const newTab = createWorkspaceTab(pane, getNextPersistOrder(tabsRef.current), extra); - const nextTabs = options?.afterTabId - ? insertTabAfter(tabsRef.current, options.afterTabId, newTab) - : [...tabsRef.current, newTab]; - void commitTabs(nextTabs); - setActiveTabId(newTab.id); - return { tabId: newTab.id, createRequestId }; - }, - [commitTabs, setActiveTabId], - ); - - const updateTabSession = useCallback( - (tabId: string, sessionId: string) => { - const tab = tabsRef.current.find((item) => item.id === tabId); - if (!tab) return; - const paneId = tab.activePaneId; - const nextTabs = tabsRef.current.map((item) => - item.id === tabId - ? { - ...item, - root: updateSessionPane(item.root, paneId, { - sessionId, - connecting: false, - connectError: undefined, - createRequestId: undefined, - }), - } - : item, - ); - void commitTabs(nextTabs); - }, - [commitTabs], - ); - - const markTabConnectionFailed = useCallback( - (tabId: string, error: string) => { - const tab = tabsRef.current.find((item) => item.id === tabId); - if (!tab) return; - const paneId = tab.activePaneId; - const nextTabs = tabsRef.current.map((item) => - item.id === tabId - ? { - ...item, - root: updateSessionPane(item.root, paneId, { - connecting: false, - connectError: error, - createRequestId: undefined, - }), - } - : item, - ); - void commitTabs(nextTabs); - }, - [commitTabs], - ); - - const updatePaneSession = useCallback( - (tabId: string, paneId: string, sessionId: string) => { - const nextTabs = tabsRef.current.map((tab) => - tab.id === tabId - ? { - ...tab, - root: updateSessionPane(tab.root, paneId, { - sessionId, - connecting: false, - connectError: undefined, - createRequestId: undefined, - }), - } - : tab, - ); - void commitTabs(nextTabs); - }, - [commitTabs], - ); - - const markPaneConnectionFailed = useCallback( - (tabId: string, paneId: string, error: string) => { - const nextTabs = tabsRef.current.map((tab) => - tab.id === tabId - ? { - ...tab, - root: updateSessionPane(tab.root, paneId, { - connecting: false, - connectError: error, - createRequestId: undefined, - }), - } - : tab, - ); - void commitTabs(nextTabs); - }, - [commitTabs], - ); - - const markPaneConnecting = useCallback( - (tabId: string, paneId: string, updates?: PaneConnectingUpdates) => { - const createRequestId = createSessionRequestId(); - const nextTabs = tabsRef.current.map((tab) => - tab.id === tabId - ? { - ...tab, - root: updateSessionPane(tab.root, paneId, { - ...updates, - connecting: true, - connectError: undefined, - createRequestId, - }), - } - : tab, - ); - void commitTabs(nextTabs); - return tabsRef.current.some((tab) => tab.id === tabId) ? createRequestId : null; - }, - [commitTabs], - ); - - const hasTab = useCallback((tabId: string) => { - return tabsRef.current.some((tab) => tab.id === tabId); - }, []); - - const hasPane = useCallback((tabId: string, paneId: string) => { - const tab = tabsRef.current.find((item) => item.id === tabId); - return !!tab && !!findSessionPaneById(tab.root, paneId); - }, []); - - const setActivePane = useCallback( - (tabId: string, paneId: string) => { - const nextTabs = tabsRef.current.map((tab) => - tab.id === tabId ? ensureActivePane({ ...tab, activePaneId: paneId }) : tab, - ); - void commitTabs(nextTabs); - setActiveTabId(tabId); - }, - [commitTabs, setActiveTabId], - ); - - const splitPane = useCallback( - ( - tabId: string, - paneId: string, - direction: PaneSplitDirection, - pane: SessionPane, - options?: { immediatePersist?: boolean }, - ) => { - const tab = tabsRef.current.find((item) => item.id === tabId); - if (!tab) return null; - - const nextTabs = tabsRef.current.map((item) => - item.id === tabId - ? ensureActivePane({ - ...item, - activePaneId: pane.id, - root: splitSessionPane(item.root, paneId, direction, pane), - }) - : item, - ); - void commitTabs(nextTabs, { immediatePersist: options?.immediatePersist }); - setActiveTabId(tabId); - return pane.id; - }, - [commitTabs, setActiveTabId], - ); - - const updateSplitRatio = useCallback( - (tabId: string, splitId: string, ratio: number) => { - const nextTabs = tabsRef.current.map((tab) => - tab.id === tabId - ? { - ...tab, - root: updateWorkspaceSplitRatio(tab.root, splitId, ratio), - } - : tab, - ); - void commitTabs(nextTabs); - }, - [commitTabs], - ); - - const closePane = useCallback( - (tabId: string, paneId: string, options?: { immediatePersist?: boolean }) => { - const currentTabs = tabsRef.current; - const index = currentTabs.findIndex((item) => item.id === tabId); - if (index === -1) return; - - const tab = currentTabs[index]; - const nextRoot = removeSessionPane(tab.root, paneId); - - if (!nextRoot) { - const nextTabs = currentTabs.filter((item) => item.id !== tabId); - if (activeTabIdRef.current === tabId) { - const fallback = nextTabs[Math.max(0, index - 1)] ?? nextTabs[0] ?? null; - setActiveTabId(fallback?.id ?? null); - } - void commitTabs(nextTabs, { immediatePersist: options?.immediatePersist }); - return; - } - - const nextActivePaneId = - tab.activePaneId === paneId - ? (getFirstSessionPane(nextRoot)?.id ?? tab.activePaneId) - : tab.activePaneId; - - const nextTabs = currentTabs.map((item) => - item.id === tabId - ? ensureActivePane({ - ...item, - activePaneId: nextActivePaneId, - root: nextRoot, - }) - : item, - ); - void commitTabs(nextTabs, { immediatePersist: options?.immediatePersist }); - }, - [commitTabs, setActiveTabId], - ); - - const updateTab = useCallback( - async ( - tabId: string, - updates: Partial>, - options?: { immediatePersist?: boolean }, - ) => { - const nextTabs = tabsRef.current.map((tab) => - tab.id === tabId ? { ...tab, ...updates } : tab, - ); - await commitTabs(nextTabs, { immediatePersist: options?.immediatePersist }); - }, - [commitTabs], - ); - - const closeTabs = useCallback( - ( - tabIds: string[], - options?: { immediatePersist?: boolean; nextActiveTabId?: string | null }, - ) => { - if (tabIds.length === 0) return; - - const idsToClose = new Set(tabIds); - const currentTabs = tabsRef.current; - const nextTabs = currentTabs.filter((tab) => !idsToClose.has(tab.id)); - const currentActiveTabId = activeTabIdRef.current; - - let nextActiveTabId = - options?.nextActiveTabId !== undefined ? options.nextActiveTabId : currentActiveTabId; - - if (nextActiveTabId && !nextTabs.some((tab) => tab.id === nextActiveTabId)) { - nextActiveTabId = null; - } - - if (!nextActiveTabId && currentActiveTabId && idsToClose.has(currentActiveTabId)) { - const activeIndex = currentTabs.findIndex((tab) => tab.id === currentActiveTabId); - const fallbackTab = nextTabs[Math.max(0, activeIndex - 1)] ?? nextTabs[0] ?? null; - nextActiveTabId = fallbackTab?.id ?? null; - } - - if (!nextActiveTabId && nextTabs.length > 0) { - nextActiveTabId = nextTabs[0].id; - } - - if (nextActiveTabId !== currentActiveTabId) { - setActiveTabId(nextActiveTabId); - } - - void commitTabs(nextTabs, { immediatePersist: options?.immediatePersist }); - }, - [commitTabs, setActiveTabId], - ); - - const closeTab = useCallback( - (tabId: string) => { - closeTabs([tabId]); - }, - [closeTabs], - ); - - const reorderTabs = useCallback( - (fromTabId: string, toIndex: number) => { - const nextTabs = moveTab(tabsRef.current, fromTabId, toIndex); - void commitTabs(nextTabs, { syncPersisted: false }); - }, - [commitTabs], - ); - - const persistTabsNow = useCallback(async (extraUi?: Partial) => { - if (!hasRestored.current || !appSettingsRef.current.general.startup_restore) return; - const nextUi = { - ...appSettingsRef.current.ui, - open_tabs: serializeTabsForPersistence(tabsRef.current), - ...extraUi, - }; - appSettingsRef.current = { ...appSettingsRef.current, ui: nextUi }; - await invoke("save_app_ui_settings", { ui: nextUi }); - }, []); - - const closeStaleCreatedSession = useCallback(async (sessionId: string) => { - try { - await invoke("close_session", { sessionId }); - } catch (error) { - logger.error({ - domain: "session.lifecycle", - event: "session.stale_close_failed", - message: "Failed to close stale restored session", - ids: { session_id: sessionId }, - error, - }); - } - }, []); - - const handleRestoredSessionCreated = useCallback( - async (tabId: string, paneId: string, sessionId: string, connectionId?: string) => { - if (!hasPane(tabId, paneId)) { - await closeStaleCreatedSession(sessionId); - return; - } - updatePaneSession(tabId, paneId, sessionId); - if (connectionId) { - void updateConnectionAutoIconAfterSessionStart({ - connectionId, - sessionId, - remoteStatsEnabled: appSettingsRef.current.ui.show_remote_stats ?? true, - }); - } - }, - [closeStaleCreatedSession, hasPane, updatePaneSession], - ); - - const handleRestoredSessionFailed = useCallback( - ( - tabId: string, - paneId: string, - sessionType: WorkspaceSessionType, - connectionId: string | undefined, - error: unknown, - ) => { - const errorMessage = getErrorMessage(error); - if ( - errorMessage.toLowerCase().includes("session creation cancelled") || - !hasPane(tabId, paneId) - ) { - return; - } - logger.error({ - domain: "session.lifecycle", - event: "session.restore_failed", - message: `Restore ${sessionType} failed`, - ids: connectionId ? { connection_id: connectionId } : undefined, - data: { - session_type: sessionType, - pane_id: paneId, - }, - error, - }); - markPaneConnectionFailed(tabId, paneId, errorMessage); - }, - [hasPane, markPaneConnectionFailed], - ); - - // 5. Startup Restore Logic - const hasRestored = useRef(false); - const pendingLockedStartupRestoreTabsRef = useRef(null); - - const restoreSessionsForTabs = useCallback( - (tabsToRestore: Tab[]) => { - tabsToRestore.forEach((tab) => { - const panes = collectSessionPanes(tab.root); - - panes.forEach((pane) => { - if (!hasPane(tab.id, pane.id)) return; - - const cid = pane.connectionId; - switch (pane.type) { - case "SSH": - if (!cid) { - markPaneConnectionFailed(tab.id, pane.id, "Missing SSH connection id"); - return; - } - invoke("create_ssh_session", { - connectionId: cid, - createRequestId: pane.createRequestId, - }) - .then((sessionId) => handleRestoredSessionCreated(tab.id, pane.id, sessionId, cid)) - .catch((e) => - handleRestoredSessionFailed(tab.id, pane.id, "SSH", pane.connectionId, e), - ); - break; - case "Local": - invoke("create_local_session", { - connectionId: cid || null, - createRequestId: pane.createRequestId, - }) - .then((sessionId) => handleRestoredSessionCreated(tab.id, pane.id, sessionId)) - .catch((e) => - handleRestoredSessionFailed(tab.id, pane.id, "Local", pane.connectionId, e), - ); - break; - case "Telnet": - if (!cid) { - markPaneConnectionFailed(tab.id, pane.id, "Missing Telnet connection id"); - return; - } - invoke("create_telnet_session", { - connectionId: cid, - createRequestId: pane.createRequestId, - }) - .then((sessionId) => handleRestoredSessionCreated(tab.id, pane.id, sessionId)) - .catch((e) => - handleRestoredSessionFailed(tab.id, pane.id, "Telnet", pane.connectionId, e), - ); - break; - case "Serial": - if (!cid) { - markPaneConnectionFailed(tab.id, pane.id, "Missing Serial connection id"); - return; - } - invoke("create_serial_session", { - connectionId: cid, - createRequestId: pane.createRequestId, - }) - .then((sessionId) => handleRestoredSessionCreated(tab.id, pane.id, sessionId)) - .catch((e) => - handleRestoredSessionFailed(tab.id, pane.id, "Serial", pane.connectionId, e), - ); - break; - case "VNC": - if (!cid) { - markPaneConnectionFailed(tab.id, pane.id, "Missing VNC connection id"); - return; - } - invoke("create_vnc_session", { - connectionId: cid, - createRequestId: pane.createRequestId, - }) - .then((sessionId) => handleRestoredSessionCreated(tab.id, pane.id, sessionId, cid)) - .catch((e) => - handleRestoredSessionFailed(tab.id, pane.id, "VNC", pane.connectionId, e), - ); - break; - case "RDP": - if (!cid) { - markPaneConnectionFailed(tab.id, pane.id, "Missing RDP connection id"); - return; - } - invoke("create_rdp_session", { - connectionId: cid, - createRequestId: pane.createRequestId, - }) - .then((sessionId) => handleRestoredSessionCreated(tab.id, pane.id, sessionId, cid)) - .catch((e) => - handleRestoredSessionFailed(tab.id, pane.id, "RDP", pane.connectionId, e), - ); - break; - } - }); - }); - }, - [handleRestoredSessionCreated, handleRestoredSessionFailed, hasPane, markPaneConnectionFailed], - ); - - useEffect(() => { - if (hasRestored.current || !appSettingsLoaded.current || !lockStateLoaded) return; - - hasRestored.current = true; - if ( - isPrimaryMainWindow() && - appSettings.general.startup_restore && - appSettings.ui.open_tabs && - appSettings.ui.open_tabs.length > 0 - ) { - const restoredTabs = appSettings.ui.open_tabs - .map((tab, index) => restoreTabFromPersistence(tab, index)) - .filter((tab): tab is Tab => tab !== null); - - tabsRef.current = restoredTabs; - setTabs(restoredTabs); - if (restoredTabs.length > 0) { - setActiveTabId(restoredTabs[restoredTabs.length - 1].id); - } - - if (appSettings.security.enable_screen_lock && isLocked) { - pendingLockedStartupRestoreTabsRef.current = restoredTabs; - } else { - restoreSessionsForTabs(restoredTabs); - } - } - - setStartupRestoreComplete(true); - }, [appSettings, isLocked, lockStateLoaded, restoreSessionsForTabs, setActiveTabId]); - - useEffect(() => { - if (isLocked) return; - - const pendingTabs = pendingLockedStartupRestoreTabsRef.current; - if (!pendingTabs) return; - - pendingLockedStartupRestoreTabsRef.current = null; - restoreSessionsForTabs(pendingTabs); - }, [isLocked, restoreSessionsForTabs]); - - const contextValue = useMemo( - () => ({ - tabs, - activeTabId: activeTabIdState, - setActiveTabId, - addTab, - addPendingTab, - updateTabSession, - markTabConnectionFailed, - updatePaneSession, - markPaneConnectionFailed, - markPaneConnecting, - hasTab, - hasPane, - setActivePane, - updateSplitRatio, - splitPane, - closePane, - reorderTabs, - updateTab, - closeTabs, - closeTab, - persistTabsNow, - appSettings, - updateAppSettings, - replaceAppSettings, - updateUi, - savedConnections, - savedGroups, - refreshConnections, - recordRecentConnection, - showNewSession, - setShowNewSession, - editingConnection, - setEditingConnection, - showSettingsDialog, - setShowSettingsDialog, - syncGroups, - setSyncGroups, - broadcastToAll, - setBroadcastToAll, - isLocked, - setIsLocked, - settingsLoaded, - startupRestoreComplete, - runtimeInfo, - runtimeInfoLoaded, - }), - [ - tabs, - activeTabIdState, - setActiveTabId, - addTab, - addPendingTab, - updateTabSession, - markTabConnectionFailed, - updatePaneSession, - markPaneConnectionFailed, - markPaneConnecting, - hasTab, - hasPane, - setActivePane, - updateSplitRatio, - splitPane, - closePane, - reorderTabs, - updateTab, - closeTabs, - closeTab, - persistTabsNow, - appSettings, - updateAppSettings, - replaceAppSettings, - updateUi, - savedConnections, - savedGroups, - refreshConnections, - recordRecentConnection, - showNewSession, - editingConnection, - showSettingsDialog, - syncGroups, - broadcastToAll, - isLocked, - setIsLocked, - settingsLoaded, - startupRestoreComplete, - runtimeInfo, - runtimeInfoLoaded, - ], - ); - - const terminalAppSettingsValue = useMemo( - () => ({ - appearance: appSettings.appearance, - interaction: appSettings.interaction, - terminal: appSettings.terminal, - translation: appSettings.translation, - search: appSettings.search, - ai: appSettings.ai, - keybindings: appSettings.keybindings, - transfer: appSettings.transfer, - }), - [ - appSettings.appearance, - appSettings.interaction, - appSettings.terminal, - appSettings.translation, - appSettings.search, - appSettings.ai, - appSettings.keybindings, - appSettings.transfer, - ], - ); - - return ( - - - {lockStateLoaded && settingsLoaded ? children : null} - - - ); -} - -/** Hook to access AppContext. Throws if used outside AppProvider. */ export function useApp() { const context = useContext(AppContext); if (!context) throw new Error("useApp must be used within AppProvider"); diff --git a/src/context/AppProvider.tsx b/src/context/AppProvider.tsx new file mode 100644 index 00000000..93168a55 --- /dev/null +++ b/src/context/AppProvider.tsx @@ -0,0 +1,1299 @@ +import { listen } from "@tauri-apps/api/event"; +import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useAppLockState } from "@/hooks/useAppLockState"; +import { DEFAULT_AI_SETTINGS } from "@/lib/aiSettings"; +import { DEFAULT_CLOUD_SYNC_SETTINGS } from "@/lib/cloudSync"; +import { updateConnectionAutoIconAfterSessionStart } from "@/lib/connectionAutoIcon"; +import { DEFAULT_TERMINAL_FONT_FAMILY, getDefaultUiFontFamily } from "@/lib/defaultFonts"; +import { getErrorMessage } from "@/lib/errors"; +import { + DEFAULT_COMMAND_SUGGESTION_MAX_CHARS, + DEFAULT_COMMAND_SUGGESTION_MIN_CHARS, + DEFAULT_TAB_DOUBLE_CLICK_ACTION, + DEFAULT_TAB_MIDDLE_CLICK_ACTION, + DEFAULT_TAB_RIGHT_CLICK_ACTION, +} from "@/lib/interactionSettings"; +import { + normalizeQuickCommandAppSettings, + normalizeQuickCommandUiConfig, +} from "@/lib/quickCommandSettings"; +import { + collectSessionPanes, + createSessionPane, + createWorkspaceTab, + ensureActivePane, + findSessionPaneById, + getFirstSessionPane, + getNextPersistOrder, + insertTabAfter, + moveTab, + removeSessionPane, + restoreTabFromPersistence, + serializeTabsForPersistence, + splitSessionPane, + updateSessionPane, + updateSplitRatio as updateWorkspaceSplitRatio, +} from "@/lib/workspaceTabs"; +import type { + AppRuntimeInfo, + AppSettings, + Group, + PaneSplitDirection, + SavedConnection, + SessionPane, + SyncGroup, + Tab, + UiConfig, + WorkspaceSessionType, +} from "@/types/global"; +import { invoke } from "../lib/invoke"; +import { logger, setLoggerLevel } from "../lib/logger"; +import { DEFAULT_TERMINAL_FONT_SIZE } from "../lib/terminalFontSize"; +import { isPrimaryMainWindow } from "../lib/windowManager"; +import { + AppContext, + type PaneConnectingUpdates, + type PendingTabCreation, + TerminalAppSettingsContext, +} from "./AppContext"; + +function createSessionRequestId() { + return crypto.randomUUID(); +} + +const DEFAULT_APP_SETTINGS: AppSettings = { + general: { + startup_restore: true, + startup_restore_window_layout: true, + minimize_to_tray: false, + boss_key: null, + confirm_on_close: true, + }, + appearance: { + theme: "github-dark", + custom_themes: [], + font_family: DEFAULT_TERMINAL_FONT_FAMILY, + ui_font_family: getDefaultUiFontFamily(), + font_size: DEFAULT_TERMINAL_FONT_SIZE, + font_weight: 400, + font_weight_bold: 700, + background_opacity: 1.0, + background_image_path: null, + background_image_fit: "cover", + background_image_opacity: 0.45, + cursor_style: "block", + cursor_blink: true, + ui_font_size: 16, + terminal_theme: null, + minimum_contrast_ratio: 1, + panel_multi_open: false, + window_transparency: "none", + window_transparency_tint: 1, + window_transparency_blur: false, + }, + proxy: { + enabled: false, + protocol: "socks5", + host: "127.0.0.1", + port: 1080, + }, + search: { + custom_engines: [ + { name: "Google", url_template: "https://google.com/search?q=%s", show_in_menu: true }, + { name: "Bing", url_template: "https://bing.com/search?q=%s", show_in_menu: true }, + { name: "GitHub", url_template: "https://github.com/search?q=%s", show_in_menu: true }, + ], + }, + translation: { + target_language: "zh-CN", + deepl_api_key: "", + baidu_app_id: "", + baidu_app_key: "", + ali_app_id: "", + ali_app_key: "", + youdao_app_id: "", + youdao_app_key: "", + }, + security: { + use_os_keyring: true, + enable_screen_lock: false, + idle_lock_minutes: 0, + host_key_policy: "prompt", + }, + terminal: { + scrollback_lines: 10000, + keep_alive_mode: "compatible", + keep_alive_interval: 60, + font_size_delta: 0, + x11_display: "", + hardware_acceleration: false, + keyword_highlights_enabled: false, + keyword_highlights_across_wrapped_lines: false, + keyword_highlight_builtin_rules: {}, + keyword_highlights: [], + action_links_enabled: false, + action_links_matchers: { + ipv4: true, + archive: true, + host_port: true, + }, + show_workspace_padding: false, + show_line_numbers: false, + show_timestamps: false, + timestamp_format: "[HH:mm:ss]", + show_multi_line_paste_dialog: true, + paste_image_as_path: true, + }, + interaction: { + copy_on_select: false, + allow_osc52_clipboard_write: false, + right_click_paste: false, + terminal_zoom_enabled: true, + command_suggestions_enabled: true, + command_suggestion_min_chars: DEFAULT_COMMAND_SUGGESTION_MIN_CHARS, + command_suggestion_max_chars: DEFAULT_COMMAND_SUGGESTION_MAX_CHARS, + duplicate_session_command_delay_ms: 1000, + word_separators: " ()[]{}\"':=,;|&<>", + alt_as_meta: false, + ime_compatibility: false, + default_encoding: "UTF-8", + tab_double_click_action: DEFAULT_TAB_DOUBLE_CLICK_ACTION, + tab_middle_click_action: DEFAULT_TAB_MIDDLE_CLICK_ACTION, + tab_right_click_action: DEFAULT_TAB_RIGHT_CLICK_ACTION, + }, + recording: { + auto_start: false, + default_mode: "transcript", + base_path: "", + path_template: "{group}/{session}/{yyyy}-{MM}-{dd}/{HH}-{mm}-{ss}-{SSS}-{session_short_id}.log", + include_timestamps: true, + include_io_labels: true, + include_session_metadata: true, + rotation: { type: "session" }, + existing_file_behavior: "unique", + memory_limit_bytes: 5 * 1024 * 1024, + include_binary_transfer_payloads: false, + }, + transfer: { + editor_type: "external", + download_threads: 3, + upload_threads: 3, + duplicate_strategy: "ask", + preserve_timestamps: true, + resume_broken_transfer: true, + default_file_permissions: "644", + max_transfer_retries: 2, + transfer_buffer_size: 32, + download_path: "", + ask_save_location: false, + default_editor: "", + recording_path: "", + recording_include_io_labels: true, + recording_include_timestamps: true, + recording_auto_start: false, + recording_memory_limit_bytes: 5 * 1024 * 1024, + }, + diagnostics: { + level: "info", + retention_days: 7, + }, + ai: { + ...DEFAULT_AI_SETTINGS, + }, + cloud_sync: DEFAULT_CLOUD_SYNC_SETTINGS, + ui: { + open_tabs: [], + terminal_window_layout: null, + start_workspace_mode: "workbench", + left_width: 256, + right_width: 288, + quick_cmd_height: 180, + quick_cmd_category_width: 176, + quick_cmd_view_mode: "tile", + quick_cmd_sort_mode: "created", + quick_cmd_selected_category: "all", + active_left_panel: "fileExplorer", + active_right_panel: "savedConnections", + left_open_panels: [], + right_open_panels: [], + panel_stack_sizes: {}, + network_panel_active_tab: "tunnel", + security_auth_panel_active_tab: "keys", + show_quick_cmd_bar: true, + show_serial_send_panel: false, + serial_send_height: 180, + zoom_level: 1.0, + language: "en", + header_status_mode: "session", + header_status_visible: true, + show_notes_panel: true, + show_remote_stats: true, + remote_stats_interval: 3, + show_gpu_monitor: false, + gpu_monitor_interval: 3, + show_ascend_npu_monitor: false, + ascend_npu_monitor_interval: 3, + show_process_manager: false, + process_manager_interval: 5, + show_docker_manager: false, + docker_manager_interval: 10, + saved_connections_sort_mode: "default", + saved_connections_expanded_group_ids: [], + asset_sort_key: null, + asset_sort_direction: null, + recent_connection_ids: [], + transfer_height: 180, + file_explorer_show_hidden_files: true, + file_explorer_auto_sync_cwd_connection_ids: [], + file_explorer_favorite_dirs_by_connection_id: {}, + notes_expanded_folder_ids: [], + notes_last_selected_node_id: null, + activity_bar_layout: { + left_top: ["fileExplorer", "notes", "network", "securityAuth"], + left_bottom: ["syncBackupHistory", "settings"], + right_top: [ + "savedConnections", + "aiAssistant", + "activeSessions", + "commandHistory", + "resourceMonitor", + "gpuMonitor", + "ascendNpuMonitor", + "processManager", + "dockerManager", + ], + right_bottom: ["quickCmdBar", "serialSend", "recording", "lock"], + show_labels: false, + }, + }, + keybindings: {}, +}; + +const RECENT_CONNECTION_LIMIT = 10; + +const DEFAULT_RUNTIME_INFO: AppRuntimeInfo = { + portable: false, + mode: "installed", + executableDir: "", + dataDir: "", + configDir: "", + logDir: "", + webviewDataDir: "", + portableMarkerPath: null, +}; + +function areSettingsValuesEqual(left: unknown, right: unknown): boolean { + if (Object.is(left, right)) return true; + if (typeof left !== typeof right) return false; + if (left === null || right === null) return left === right; + + if (Array.isArray(left) || Array.isArray(right)) { + if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) return false; + for (let index = 0; index < left.length; index += 1) { + if (!areSettingsValuesEqual(left[index], right[index])) { + return false; + } + } + return true; + } + + if (typeof left !== "object" || typeof right !== "object") { + return false; + } + + const leftRecord = left as Record; + const rightRecord = right as Record; + const leftKeys = Object.keys(leftRecord); + const rightKeys = Object.keys(rightRecord); + if (leftKeys.length !== rightKeys.length) return false; + + for (const key of leftKeys) { + if (!(key in rightRecord)) return false; + if (!areSettingsValuesEqual(leftRecord[key], rightRecord[key])) { + return false; + } + } + + return true; +} + +function preserveAppSettingsReferences(prev: AppSettings, next: AppSettings): AppSettings { + const general = areSettingsValuesEqual(prev.general, next.general) ? prev.general : next.general; + const appearance = areSettingsValuesEqual(prev.appearance, next.appearance) + ? prev.appearance + : next.appearance; + const proxy = areSettingsValuesEqual(prev.proxy, next.proxy) ? prev.proxy : next.proxy; + const search = areSettingsValuesEqual(prev.search, next.search) ? prev.search : next.search; + const translation = areSettingsValuesEqual(prev.translation, next.translation) + ? prev.translation + : next.translation; + const security = areSettingsValuesEqual(prev.security, next.security) + ? prev.security + : next.security; + const terminal = areSettingsValuesEqual(prev.terminal, next.terminal) + ? prev.terminal + : next.terminal; + const interaction = areSettingsValuesEqual(prev.interaction, next.interaction) + ? prev.interaction + : next.interaction; + const transfer = areSettingsValuesEqual(prev.transfer, next.transfer) + ? prev.transfer + : next.transfer; + const diagnostics = areSettingsValuesEqual(prev.diagnostics, next.diagnostics) + ? prev.diagnostics + : next.diagnostics; + const ai = areSettingsValuesEqual(prev.ai, next.ai) ? prev.ai : next.ai; + const cloudSync = areSettingsValuesEqual(prev.cloud_sync, next.cloud_sync) + ? prev.cloud_sync + : next.cloud_sync; + const ui = areSettingsValuesEqual(prev.ui, next.ui) ? prev.ui : next.ui; + const keybindings = areSettingsValuesEqual(prev.keybindings, next.keybindings) + ? prev.keybindings + : next.keybindings; + + if ( + general === prev.general && + appearance === prev.appearance && + proxy === prev.proxy && + search === prev.search && + translation === prev.translation && + security === prev.security && + terminal === prev.terminal && + interaction === prev.interaction && + transfer === prev.transfer && + diagnostics === prev.diagnostics && + ai === prev.ai && + cloudSync === prev.cloud_sync && + ui === prev.ui && + keybindings === prev.keybindings + ) { + return prev; + } + + return { + ...next, + general, + appearance, + proxy, + search, + translation, + security, + terminal, + interaction, + transfer, + diagnostics, + ai, + cloud_sync: cloudSync, + ui, + keybindings, + }; +} + +/** Provides tabs, appSettings, savedConnections, and dialog state to the app. */ +export function AppProvider({ children }: { children: ReactNode }) { + // Tabs State + const [tabs, setTabs] = useState([]); + const tabsRef = useRef([]); + const [activeTabIdState, setActiveTabIdState] = useState(null); + const activeTabIdRef = useRef(null); + + // App Settings State (includes UI config) + const [appSettings, setAppSettings] = useState(DEFAULT_APP_SETTINGS); + const appSettingsRef = useRef(DEFAULT_APP_SETTINGS); + const appSettingsLoaded = useRef(false); + const appSettingsSaveTimerRef = useRef | null>(null); + const uiSaveTimerRef = useRef | null>(null); + + // Data State + const [savedConnections, setSavedConnections] = useState([]); + const [savedGroups, setSavedGroups] = useState([]); + + // Dialog State + const [showNewSession, setShowNewSession] = useState(false); + const [editingConnection, setEditingConnection] = useState( + undefined, + ); + const [showSettingsDialog, setShowSettingsDialog] = useState(false); + + // Sync Input Groups + const [syncGroups, setSyncGroups] = useState([]); + const [broadcastToAll, setBroadcastToAll] = useState(false); + + // Idle Lock State + const { isLocked, setIsLocked, lockStateLoaded } = useAppLockState(); + + // Loading State + const [settingsLoaded, setSettingsLoaded] = useState(false); + const [startupRestoreComplete, setStartupRestoreComplete] = useState(false); + const [runtimeInfo, setRuntimeInfo] = useState(DEFAULT_RUNTIME_INFO); + const [runtimeInfoLoaded, setRuntimeInfoLoaded] = useState(false); + + const setActiveTabId = useCallback((id: string | null) => { + activeTabIdRef.current = id; + setActiveTabIdState(id); + }, []); + + // 1. Load App Settings + useEffect(() => { + invoke("get_app_runtime_info") + .then((info) => { + setRuntimeInfo(info); + }) + .catch((error) => { + logger.error({ + domain: "app.lifecycle", + event: "runtime_info.load_failed", + message: "Failed to load app runtime info", + error, + }); + }) + .finally(() => { + setRuntimeInfoLoaded(true); + }); + + invoke("get_app_settings") + .then((cfg) => { + const normalized = normalizeQuickCommandAppSettings(cfg); + appSettingsRef.current = normalized; + setAppSettings(normalized); + setLoggerLevel(normalized.diagnostics.level); + appSettingsLoaded.current = true; + setSettingsLoaded(true); + if (isPrimaryMainWindow() && normalized.security?.enable_screen_lock) { + setIsLocked(true); + } + }) + .catch(() => { + appSettingsRef.current = DEFAULT_APP_SETTINGS; + appSettingsLoaded.current = true; + setAppSettings(DEFAULT_APP_SETTINGS); + setSettingsLoaded(true); + }); + }, [setIsLocked]); + + // Apply UI font size to root element + useEffect(() => { + document.documentElement.style.fontSize = `${appSettings.appearance.ui_font_size}px`; + }, [appSettings.appearance.ui_font_size]); + + useEffect(() => { + const fontFamily = appSettings.appearance.ui_font_family; + document.documentElement.style.setProperty("--font-sans", fontFamily); + document.documentElement.style.setProperty("--font-display", fontFamily); + }, [appSettings.appearance.ui_font_family]); + + // 2. Save App Settings Debounced + const updateAppSettings = useCallback( + (updates: Partial | ((prev: AppSettings) => Partial)) => { + setAppSettings((prev) => { + const nextUpdates = typeof updates === "function" ? updates(prev) : updates; + const next = normalizeQuickCommandAppSettings({ + ...prev, + ...nextUpdates, + }); + appSettingsRef.current = next; + setLoggerLevel(next.diagnostics.level); + if (appSettingsLoaded.current) { + if (appSettingsSaveTimerRef.current) clearTimeout(appSettingsSaveTimerRef.current); + appSettingsSaveTimerRef.current = setTimeout(() => { + invoke("save_app_settings", { settings: next }).catch((e) => + logger.error({ + domain: "settings.persistence", + event: "settings.save_failed", + message: "Failed to save app settings", + error: e, + }), + ); + }, 500); + } + return next; + }); + }, + [], + ); + + const replaceAppSettings = useCallback((next: AppSettings) => { + if (appSettingsSaveTimerRef.current) { + clearTimeout(appSettingsSaveTimerRef.current); + appSettingsSaveTimerRef.current = null; + } + setAppSettings((current) => { + const normalized = preserveAppSettingsReferences( + current, + normalizeQuickCommandAppSettings(next), + ); + appSettingsRef.current = normalized; + setLoggerLevel(normalized.diagnostics.level); + return normalized; + }); + }, []); + + // Convenience helper to update just the UI config portion via lightweight path + const updateUi = useCallback( + (updates: Partial | ((prev: UiConfig) => Partial)) => { + setAppSettings((prev) => { + const nextUpdates = typeof updates === "function" ? updates(prev.ui) : updates; + const nextUi = normalizeQuickCommandUiConfig({ + ...prev.ui, + ...nextUpdates, + }); + const next = { ...prev, ui: nextUi }; + appSettingsRef.current = next; + if (appSettingsLoaded.current) { + if (uiSaveTimerRef.current) clearTimeout(uiSaveTimerRef.current); + uiSaveTimerRef.current = setTimeout(() => { + invoke("save_app_ui_settings", { ui: nextUi }).catch((e) => + logger.error({ + domain: "settings.persistence", + event: "ui_settings.save_failed", + message: "Failed to save UI settings", + error: e, + }), + ); + }, 500); + } + return next; + }); + }, + [], + ); + + const recordRecentConnection = useCallback( + (connectionId: string) => { + if (!connectionId) return; + updateUi((prev) => ({ + recent_connection_ids: [ + connectionId, + ...(prev.recent_connection_ids ?? []).filter((id) => id !== connectionId), + ].slice(0, RECENT_CONNECTION_LIMIT), + })); + }, + [updateUi], + ); + + // 3. Load Connections + const refreshConnections = useCallback(async () => { + try { + const [saved, groups] = await Promise.all([ + invoke("get_saved_connections"), + invoke("get_groups"), + ]); + setSavedConnections(saved); + setSavedGroups(groups); + } catch (e) { + logger.error({ + domain: "ui.error", + event: "connections.fetch_failed", + message: "Failed to fetch connections", + error: e, + }); + } + }, []); + + useEffect(() => { + refreshConnections(); + const unlisten = listen("connections-changed", () => { + refreshConnections(); + }); + return () => { + unlisten.then((fn) => fn()); + }; + }, [refreshConnections]); + + const syncOpenTabs = useCallback( + async (nextTabs: Tab[], options?: { immediatePersist?: boolean }) => { + if (!hasRestored.current || !appSettingsRef.current.general.startup_restore) return; + + const openTabs = serializeTabsForPersistence(nextTabs); + updateUi({ open_tabs: openTabs }); + + if (!options?.immediatePersist) return; + + const nextUi = { ...appSettingsRef.current.ui, open_tabs: openTabs }; + appSettingsRef.current = { ...appSettingsRef.current, ui: nextUi }; + await invoke("save_app_ui_settings", { ui: nextUi }); + }, + [updateUi], + ); + + const commitTabs = useCallback( + async ( + nextTabs: Tab[], + options?: { + syncPersisted?: boolean; + immediatePersist?: boolean; + }, + ) => { + const normalizedTabs = nextTabs.map(ensureActivePane); + tabsRef.current = normalizedTabs; + setTabs(normalizedTabs); + + if (options?.syncPersisted === false) return; + await syncOpenTabs(normalizedTabs, { immediatePersist: options?.immediatePersist }); + }, + [syncOpenTabs], + ); + + // 4. Tab Logic + const addTab = useCallback( + ( + sessionId: string, + name: string, + type: WorkspaceSessionType, + connectionId?: string, + extra?: Partial>, + options?: { afterTabId?: string }, + ) => { + const pane = createSessionPane(name, type, connectionId, { sessionId }); + const newTab = createWorkspaceTab(pane, getNextPersistOrder(tabsRef.current), extra); + const nextTabs = options?.afterTabId + ? insertTabAfter(tabsRef.current, options.afterTabId, newTab) + : [...tabsRef.current, newTab]; + void commitTabs(nextTabs); + setActiveTabId(newTab.id); + + // Close dialogs when session starts + setShowNewSession(false); + setEditingConnection(undefined); + return newTab.id; + }, + [commitTabs, setActiveTabId], + ); + + const addPendingTab = useCallback( + ( + name: string, + type: WorkspaceSessionType, + connectionId?: string, + extra?: Partial>, + options?: { afterTabId?: string }, + paneOverrides?: Partial, + ): PendingTabCreation => { + const createRequestId = createSessionRequestId(); + const pane = createSessionPane(name, type, connectionId, { + ...paneOverrides, + connecting: true, + createRequestId, + }); + const newTab = createWorkspaceTab(pane, getNextPersistOrder(tabsRef.current), extra); + const nextTabs = options?.afterTabId + ? insertTabAfter(tabsRef.current, options.afterTabId, newTab) + : [...tabsRef.current, newTab]; + void commitTabs(nextTabs); + setActiveTabId(newTab.id); + return { tabId: newTab.id, createRequestId }; + }, + [commitTabs, setActiveTabId], + ); + + const updateTabSession = useCallback( + (tabId: string, sessionId: string) => { + const tab = tabsRef.current.find((item) => item.id === tabId); + if (!tab) return; + const paneId = tab.activePaneId; + const nextTabs = tabsRef.current.map((item) => + item.id === tabId + ? { + ...item, + root: updateSessionPane(item.root, paneId, { + sessionId, + connecting: false, + connectError: undefined, + createRequestId: undefined, + }), + } + : item, + ); + void commitTabs(nextTabs); + }, + [commitTabs], + ); + + const markTabConnectionFailed = useCallback( + (tabId: string, error: string) => { + const tab = tabsRef.current.find((item) => item.id === tabId); + if (!tab) return; + const paneId = tab.activePaneId; + const nextTabs = tabsRef.current.map((item) => + item.id === tabId + ? { + ...item, + root: updateSessionPane(item.root, paneId, { + connecting: false, + connectError: error, + createRequestId: undefined, + }), + } + : item, + ); + void commitTabs(nextTabs); + }, + [commitTabs], + ); + + const updatePaneSession = useCallback( + (tabId: string, paneId: string, sessionId: string) => { + const nextTabs = tabsRef.current.map((tab) => + tab.id === tabId + ? { + ...tab, + root: updateSessionPane(tab.root, paneId, { + sessionId, + connecting: false, + connectError: undefined, + createRequestId: undefined, + }), + } + : tab, + ); + void commitTabs(nextTabs); + }, + [commitTabs], + ); + + const markPaneConnectionFailed = useCallback( + (tabId: string, paneId: string, error: string) => { + const nextTabs = tabsRef.current.map((tab) => + tab.id === tabId + ? { + ...tab, + root: updateSessionPane(tab.root, paneId, { + connecting: false, + connectError: error, + createRequestId: undefined, + }), + } + : tab, + ); + void commitTabs(nextTabs); + }, + [commitTabs], + ); + + const markPaneConnecting = useCallback( + (tabId: string, paneId: string, updates?: PaneConnectingUpdates) => { + const createRequestId = createSessionRequestId(); + const nextTabs = tabsRef.current.map((tab) => + tab.id === tabId + ? { + ...tab, + root: updateSessionPane(tab.root, paneId, { + ...updates, + connecting: true, + connectError: undefined, + createRequestId, + }), + } + : tab, + ); + void commitTabs(nextTabs); + return tabsRef.current.some((tab) => tab.id === tabId) ? createRequestId : null; + }, + [commitTabs], + ); + + const hasTab = useCallback((tabId: string) => { + return tabsRef.current.some((tab) => tab.id === tabId); + }, []); + + const hasPane = useCallback((tabId: string, paneId: string) => { + const tab = tabsRef.current.find((item) => item.id === tabId); + return !!tab && !!findSessionPaneById(tab.root, paneId); + }, []); + + const setActivePane = useCallback( + (tabId: string, paneId: string) => { + const nextTabs = tabsRef.current.map((tab) => + tab.id === tabId ? ensureActivePane({ ...tab, activePaneId: paneId }) : tab, + ); + void commitTabs(nextTabs); + setActiveTabId(tabId); + }, + [commitTabs, setActiveTabId], + ); + + const splitPane = useCallback( + ( + tabId: string, + paneId: string, + direction: PaneSplitDirection, + pane: SessionPane, + options?: { immediatePersist?: boolean }, + ) => { + const tab = tabsRef.current.find((item) => item.id === tabId); + if (!tab) return null; + + const nextTabs = tabsRef.current.map((item) => + item.id === tabId + ? ensureActivePane({ + ...item, + activePaneId: pane.id, + root: splitSessionPane(item.root, paneId, direction, pane), + }) + : item, + ); + void commitTabs(nextTabs, { immediatePersist: options?.immediatePersist }); + setActiveTabId(tabId); + return pane.id; + }, + [commitTabs, setActiveTabId], + ); + + const updateSplitRatio = useCallback( + (tabId: string, splitId: string, ratio: number) => { + const nextTabs = tabsRef.current.map((tab) => + tab.id === tabId + ? { + ...tab, + root: updateWorkspaceSplitRatio(tab.root, splitId, ratio), + } + : tab, + ); + void commitTabs(nextTabs); + }, + [commitTabs], + ); + + const closePane = useCallback( + (tabId: string, paneId: string, options?: { immediatePersist?: boolean }) => { + const currentTabs = tabsRef.current; + const index = currentTabs.findIndex((item) => item.id === tabId); + if (index === -1) return; + + const tab = currentTabs[index]; + const nextRoot = removeSessionPane(tab.root, paneId); + + if (!nextRoot) { + const nextTabs = currentTabs.filter((item) => item.id !== tabId); + if (activeTabIdRef.current === tabId) { + const fallback = nextTabs[Math.max(0, index - 1)] ?? nextTabs[0] ?? null; + setActiveTabId(fallback?.id ?? null); + } + void commitTabs(nextTabs, { immediatePersist: options?.immediatePersist }); + return; + } + + const nextActivePaneId = + tab.activePaneId === paneId + ? (getFirstSessionPane(nextRoot)?.id ?? tab.activePaneId) + : tab.activePaneId; + + const nextTabs = currentTabs.map((item) => + item.id === tabId + ? ensureActivePane({ + ...item, + activePaneId: nextActivePaneId, + root: nextRoot, + }) + : item, + ); + void commitTabs(nextTabs, { immediatePersist: options?.immediatePersist }); + }, + [commitTabs, setActiveTabId], + ); + + const updateTab = useCallback( + async ( + tabId: string, + updates: Partial>, + options?: { immediatePersist?: boolean }, + ) => { + const nextTabs = tabsRef.current.map((tab) => + tab.id === tabId ? { ...tab, ...updates } : tab, + ); + await commitTabs(nextTabs, { immediatePersist: options?.immediatePersist }); + }, + [commitTabs], + ); + + const closeTabs = useCallback( + ( + tabIds: string[], + options?: { immediatePersist?: boolean; nextActiveTabId?: string | null }, + ) => { + if (tabIds.length === 0) return; + + const idsToClose = new Set(tabIds); + const currentTabs = tabsRef.current; + const nextTabs = currentTabs.filter((tab) => !idsToClose.has(tab.id)); + const currentActiveTabId = activeTabIdRef.current; + + let nextActiveTabId = + options?.nextActiveTabId !== undefined ? options.nextActiveTabId : currentActiveTabId; + + if (nextActiveTabId && !nextTabs.some((tab) => tab.id === nextActiveTabId)) { + nextActiveTabId = null; + } + + if (!nextActiveTabId && currentActiveTabId && idsToClose.has(currentActiveTabId)) { + const activeIndex = currentTabs.findIndex((tab) => tab.id === currentActiveTabId); + const fallbackTab = nextTabs[Math.max(0, activeIndex - 1)] ?? nextTabs[0] ?? null; + nextActiveTabId = fallbackTab?.id ?? null; + } + + if (!nextActiveTabId && nextTabs.length > 0) { + nextActiveTabId = nextTabs[0].id; + } + + if (nextActiveTabId !== currentActiveTabId) { + setActiveTabId(nextActiveTabId); + } + + void commitTabs(nextTabs, { immediatePersist: options?.immediatePersist }); + }, + [commitTabs, setActiveTabId], + ); + + const closeTab = useCallback( + (tabId: string) => { + closeTabs([tabId]); + }, + [closeTabs], + ); + + const reorderTabs = useCallback( + (fromTabId: string, toIndex: number) => { + const nextTabs = moveTab(tabsRef.current, fromTabId, toIndex); + void commitTabs(nextTabs, { syncPersisted: false }); + }, + [commitTabs], + ); + + const persistTabsNow = useCallback(async (extraUi?: Partial) => { + if (!hasRestored.current || !appSettingsRef.current.general.startup_restore) return; + const nextUi = { + ...appSettingsRef.current.ui, + open_tabs: serializeTabsForPersistence(tabsRef.current), + ...extraUi, + }; + appSettingsRef.current = { ...appSettingsRef.current, ui: nextUi }; + await invoke("save_app_ui_settings", { ui: nextUi }); + }, []); + + const closeStaleCreatedSession = useCallback(async (sessionId: string) => { + try { + await invoke("close_session", { sessionId }); + } catch (error) { + logger.error({ + domain: "session.lifecycle", + event: "session.stale_close_failed", + message: "Failed to close stale restored session", + ids: { session_id: sessionId }, + error, + }); + } + }, []); + + const handleRestoredSessionCreated = useCallback( + async (tabId: string, paneId: string, sessionId: string, connectionId?: string) => { + if (!hasPane(tabId, paneId)) { + await closeStaleCreatedSession(sessionId); + return; + } + updatePaneSession(tabId, paneId, sessionId); + if (connectionId) { + void updateConnectionAutoIconAfterSessionStart({ + connectionId, + sessionId, + remoteStatsEnabled: appSettingsRef.current.ui.show_remote_stats ?? true, + }); + } + }, + [closeStaleCreatedSession, hasPane, updatePaneSession], + ); + + const handleRestoredSessionFailed = useCallback( + ( + tabId: string, + paneId: string, + sessionType: WorkspaceSessionType, + connectionId: string | undefined, + error: unknown, + ) => { + const errorMessage = getErrorMessage(error); + if ( + errorMessage.toLowerCase().includes("session creation cancelled") || + !hasPane(tabId, paneId) + ) { + return; + } + logger.error({ + domain: "session.lifecycle", + event: "session.restore_failed", + message: `Restore ${sessionType} failed`, + ids: connectionId ? { connection_id: connectionId } : undefined, + data: { + session_type: sessionType, + pane_id: paneId, + }, + error, + }); + markPaneConnectionFailed(tabId, paneId, errorMessage); + }, + [hasPane, markPaneConnectionFailed], + ); + + // 5. Startup Restore Logic + const hasRestored = useRef(false); + const pendingLockedStartupRestoreTabsRef = useRef(null); + + const restoreSessionsForTabs = useCallback( + (tabsToRestore: Tab[]) => { + tabsToRestore.forEach((tab) => { + const panes = collectSessionPanes(tab.root); + + panes.forEach((pane) => { + if (!hasPane(tab.id, pane.id)) return; + + const cid = pane.connectionId; + switch (pane.type) { + case "SSH": + if (!cid) { + markPaneConnectionFailed(tab.id, pane.id, "Missing SSH connection id"); + return; + } + invoke("create_ssh_session", { + connectionId: cid, + createRequestId: pane.createRequestId, + }) + .then((sessionId) => handleRestoredSessionCreated(tab.id, pane.id, sessionId, cid)) + .catch((e) => + handleRestoredSessionFailed(tab.id, pane.id, "SSH", pane.connectionId, e), + ); + break; + case "Local": + invoke("create_local_session", { + connectionId: cid || null, + createRequestId: pane.createRequestId, + }) + .then((sessionId) => handleRestoredSessionCreated(tab.id, pane.id, sessionId)) + .catch((e) => + handleRestoredSessionFailed(tab.id, pane.id, "Local", pane.connectionId, e), + ); + break; + case "Telnet": + if (!cid) { + markPaneConnectionFailed(tab.id, pane.id, "Missing Telnet connection id"); + return; + } + invoke("create_telnet_session", { + connectionId: cid, + createRequestId: pane.createRequestId, + }) + .then((sessionId) => handleRestoredSessionCreated(tab.id, pane.id, sessionId)) + .catch((e) => + handleRestoredSessionFailed(tab.id, pane.id, "Telnet", pane.connectionId, e), + ); + break; + case "Serial": + if (!cid) { + markPaneConnectionFailed(tab.id, pane.id, "Missing Serial connection id"); + return; + } + invoke("create_serial_session", { + connectionId: cid, + createRequestId: pane.createRequestId, + }) + .then((sessionId) => handleRestoredSessionCreated(tab.id, pane.id, sessionId)) + .catch((e) => + handleRestoredSessionFailed(tab.id, pane.id, "Serial", pane.connectionId, e), + ); + break; + case "VNC": + if (!cid) { + markPaneConnectionFailed(tab.id, pane.id, "Missing VNC connection id"); + return; + } + invoke("create_vnc_session", { + connectionId: cid, + createRequestId: pane.createRequestId, + }) + .then((sessionId) => handleRestoredSessionCreated(tab.id, pane.id, sessionId, cid)) + .catch((e) => + handleRestoredSessionFailed(tab.id, pane.id, "VNC", pane.connectionId, e), + ); + break; + case "RDP": + if (!cid) { + markPaneConnectionFailed(tab.id, pane.id, "Missing RDP connection id"); + return; + } + invoke("create_rdp_session", { + connectionId: cid, + createRequestId: pane.createRequestId, + }) + .then((sessionId) => handleRestoredSessionCreated(tab.id, pane.id, sessionId, cid)) + .catch((e) => + handleRestoredSessionFailed(tab.id, pane.id, "RDP", pane.connectionId, e), + ); + break; + } + }); + }); + }, + [handleRestoredSessionCreated, handleRestoredSessionFailed, hasPane, markPaneConnectionFailed], + ); + + useEffect(() => { + if (hasRestored.current || !appSettingsLoaded.current || !lockStateLoaded) return; + + hasRestored.current = true; + if ( + isPrimaryMainWindow() && + appSettings.general.startup_restore && + appSettings.ui.open_tabs && + appSettings.ui.open_tabs.length > 0 + ) { + const restoredTabs = appSettings.ui.open_tabs + .map((tab, index) => restoreTabFromPersistence(tab, index)) + .filter((tab): tab is Tab => tab !== null); + + tabsRef.current = restoredTabs; + setTabs(restoredTabs); + if (restoredTabs.length > 0) { + setActiveTabId(restoredTabs[restoredTabs.length - 1].id); + } + + if (appSettings.security.enable_screen_lock && isLocked) { + pendingLockedStartupRestoreTabsRef.current = restoredTabs; + } else { + restoreSessionsForTabs(restoredTabs); + } + } + + setStartupRestoreComplete(true); + }, [appSettings, isLocked, lockStateLoaded, restoreSessionsForTabs, setActiveTabId]); + + useEffect(() => { + if (isLocked) return; + + const pendingTabs = pendingLockedStartupRestoreTabsRef.current; + if (!pendingTabs) return; + + pendingLockedStartupRestoreTabsRef.current = null; + restoreSessionsForTabs(pendingTabs); + }, [isLocked, restoreSessionsForTabs]); + + const contextValue = useMemo( + () => ({ + tabs, + activeTabId: activeTabIdState, + setActiveTabId, + addTab, + addPendingTab, + updateTabSession, + markTabConnectionFailed, + updatePaneSession, + markPaneConnectionFailed, + markPaneConnecting, + hasTab, + hasPane, + setActivePane, + updateSplitRatio, + splitPane, + closePane, + reorderTabs, + updateTab, + closeTabs, + closeTab, + persistTabsNow, + appSettings, + updateAppSettings, + replaceAppSettings, + updateUi, + savedConnections, + savedGroups, + refreshConnections, + recordRecentConnection, + showNewSession, + setShowNewSession, + editingConnection, + setEditingConnection, + showSettingsDialog, + setShowSettingsDialog, + syncGroups, + setSyncGroups, + broadcastToAll, + setBroadcastToAll, + isLocked, + setIsLocked, + settingsLoaded, + startupRestoreComplete, + runtimeInfo, + runtimeInfoLoaded, + }), + [ + tabs, + activeTabIdState, + setActiveTabId, + addTab, + addPendingTab, + updateTabSession, + markTabConnectionFailed, + updatePaneSession, + markPaneConnectionFailed, + markPaneConnecting, + hasTab, + hasPane, + setActivePane, + updateSplitRatio, + splitPane, + closePane, + reorderTabs, + updateTab, + closeTabs, + closeTab, + persistTabsNow, + appSettings, + updateAppSettings, + replaceAppSettings, + updateUi, + savedConnections, + savedGroups, + refreshConnections, + recordRecentConnection, + showNewSession, + editingConnection, + showSettingsDialog, + syncGroups, + broadcastToAll, + isLocked, + setIsLocked, + settingsLoaded, + startupRestoreComplete, + runtimeInfo, + runtimeInfoLoaded, + ], + ); + + const terminalAppSettingsValue = useMemo( + () => ({ + appearance: appSettings.appearance, + interaction: appSettings.interaction, + terminal: appSettings.terminal, + translation: appSettings.translation, + search: appSettings.search, + ai: appSettings.ai, + keybindings: appSettings.keybindings, + transfer: appSettings.transfer, + }), + [ + appSettings.appearance, + appSettings.interaction, + appSettings.terminal, + appSettings.translation, + appSettings.search, + appSettings.ai, + appSettings.keybindings, + appSettings.transfer, + ], + ); + + return ( + + + {lockStateLoaded && settingsLoaded ? children : null} + + + ); +} diff --git a/src/hooks/useChildWindowCommand.test.tsx b/src/hooks/useChildWindowCommand.test.tsx new file mode 100644 index 00000000..30398d25 --- /dev/null +++ b/src/hooks/useChildWindowCommand.test.tsx @@ -0,0 +1,85 @@ +import { render, waitFor } from "@testing-library/react"; +import { StrictMode } from "react"; +import { beforeEach, expect, it, vi } from "vitest"; +import { CHILD_WINDOW_COMMANDS } from "@/lib/childWindowProtocol"; +import { useChildWindowCommand } from "./useChildWindowCommand"; + +const mocks = vi.hoisted(() => ({ + listen: vi.fn(), + signalReady: vi.fn(), + signalFailed: vi.fn(), +})); + +vi.mock("@tauri-apps/api/event", () => ({ listen: mocks.listen })); +vi.mock("@/lib/childWindowLifecycle", () => ({ + signalChildWindowCommandReady: mocks.signalReady, + signalChildWindowLoadFailed: mocks.signalFailed, +})); + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +function Probe({ handler = vi.fn() }: { handler?: (payload: { tab: string }) => void }) { + useChildWindowCommand(CHILD_WINDOW_COMMANDS.settingsOpenTab, handler); + return null; +} + +beforeEach(() => { + vi.clearAllMocks(); + mocks.signalReady.mockResolvedValue(undefined); + mocks.signalFailed.mockResolvedValue(undefined); +}); + +it("signals ready only after the active StrictMode listener resolves", async () => { + const first = deferred<() => void>(); + const second = deferred<() => void>(); + const disposeFirst = vi.fn(); + const disposeSecond = vi.fn(); + mocks.listen.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise); + + render( + + + , + ); + + first.resolve(disposeFirst); + await waitFor(() => expect(disposeFirst).toHaveBeenCalledOnce()); + expect(mocks.signalReady).not.toHaveBeenCalled(); + + second.resolve(disposeSecond); + await waitFor(() => expect(mocks.signalReady).toHaveBeenCalledOnce()); +}); + +it("uses the latest handler without registering another Tauri listener", async () => { + const listener = deferred<() => void>(); + const firstHandler = vi.fn(); + const secondHandler = vi.fn(); + mocks.listen.mockReturnValueOnce(listener.promise); + + const view = render(); + listener.resolve(vi.fn()); + await waitFor(() => expect(mocks.signalReady).toHaveBeenCalledOnce()); + + view.rerender(); + const receive = mocks.listen.mock.calls[0][1]; + receive({ payload: { tab: "appearance" } }); + + expect(firstHandler).not.toHaveBeenCalled(); + expect(secondHandler).toHaveBeenCalledWith({ tab: "appearance" }); + expect(mocks.listen).toHaveBeenCalledOnce(); +}); + +it("reports listener registration failures without signaling ready", async () => { + mocks.listen.mockRejectedValueOnce(new Error("listen failed")); + + render(); + + await waitFor(() => expect(mocks.signalFailed).toHaveBeenCalledWith("command-listener")); + expect(mocks.signalReady).not.toHaveBeenCalled(); +}); diff --git a/src/hooks/useChildWindowCommand.ts b/src/hooks/useChildWindowCommand.ts new file mode 100644 index 00000000..fa93955b --- /dev/null +++ b/src/hooks/useChildWindowCommand.ts @@ -0,0 +1,42 @@ +import { listen } from "@tauri-apps/api/event"; +import { useEffect, useRef } from "react"; +import { + signalChildWindowCommandReady, + signalChildWindowLoadFailed, +} from "@/lib/childWindowLifecycle"; +import type { ChildWindowCommandName } from "@/lib/childWindowProtocol"; + +/** + * 注册子窗口业务命令,并在当前 effect 的 listener 确认可用后报告 ready。 + * active 标记会忽略 StrictMode 首轮已清理的异步注册,避免在有效 listener 建立前释放队列。 + */ +export function useChildWindowCommand( + event: ChildWindowCommandName, + handler: (payload: T) => void, +) { + const handlerRef = useRef(handler); + handlerRef.current = handler; + + useEffect(() => { + let active = true; + let dispose: (() => void) | undefined; + + void listen(event, ({ payload }) => handlerRef.current(payload)) + .then((unlisten) => { + if (!active) { + unlisten(); + return; + } + dispose = unlisten; + void signalChildWindowCommandReady(event).catch(() => {}); + }) + .catch(() => { + if (active) void signalChildWindowLoadFailed("command-listener").catch(() => {}); + }); + + return () => { + active = false; + dispose?.(); + }; + }, [event]); +} diff --git a/src/lib/childWindowCommandQueue.test.ts b/src/lib/childWindowCommandQueue.test.ts new file mode 100644 index 00000000..e3afcfd6 --- /dev/null +++ b/src/lib/childWindowCommandQueue.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; +import { ChildWindowCommandQueue } from "./childWindowCommandQueue"; +import { CHILD_WINDOW_COMMANDS } from "./childWindowProtocol"; + +describe("ChildWindowCommandQueue", () => { + it("keeps commands in FIFO order until the matching listener is ready", () => { + const queue = new ChildWindowCommandQueue(); + queue.register("file-editor-main", "token-new", CHILD_WINDOW_COMMANDS.remoteFileEditorOpen); + + expect( + queue.dispatch("file-editor-main", CHILD_WINDOW_COMMANDS.remoteFileEditorOpen, { name: "a" }), + ).toEqual([]); + expect( + queue.dispatch("file-editor-main", CHILD_WINDOW_COMMANDS.remoteFileEditorOpen, { name: "b" }), + ).toEqual([]); + + expect( + queue.markReady("file-editor-main", "token-new", CHILD_WINDOW_COMMANDS.remoteFileEditorOpen), + ).toEqual([ + { event: CHILD_WINDOW_COMMANDS.remoteFileEditorOpen, payload: { name: "a" } }, + { event: CHILD_WINDOW_COMMANDS.remoteFileEditorOpen, payload: { name: "b" } }, + ]); + }); + + it("ignores a ready event from a stale WebView token", () => { + const queue = new ChildWindowCommandQueue(); + queue.register("settings", "token-new", CHILD_WINDOW_COMMANDS.settingsOpenTab); + queue.dispatch("settings", CHILD_WINDOW_COMMANDS.settingsOpenTab, { tab: "appearance" }); + + expect(queue.markReady("settings", "token-old", CHILD_WINDOW_COMMANDS.settingsOpenTab)).toEqual( + [], + ); + }); + + it("dispatches immediately after the listener is ready", () => { + const queue = new ChildWindowCommandQueue(); + queue.register("settings", "token", CHILD_WINDOW_COMMANDS.settingsOpenTab); + queue.markReady("settings", "token", CHILD_WINDOW_COMMANDS.settingsOpenTab); + + expect( + queue.dispatch("settings", CHILD_WINDOW_COMMANDS.settingsOpenTab, { tab: "general" }), + ).toEqual([{ event: CHILD_WINDOW_COMMANDS.settingsOpenTab, payload: { tab: "general" } }]); + }); + + it("queues new commands again while the child page reloads", () => { + const queue = new ChildWindowCommandQueue(); + queue.register("settings", "token", CHILD_WINDOW_COMMANDS.settingsOpenTab); + queue.markReady("settings", "token", CHILD_WINDOW_COMMANDS.settingsOpenTab); + + expect(queue.markLoading("settings", "token")).toBe(true); + expect( + queue.dispatch("settings", CHILD_WINDOW_COMMANDS.settingsOpenTab, { tab: "appearance" }), + ).toEqual([]); + expect(queue.markReady("settings", "token", CHILD_WINDOW_COMMANDS.settingsOpenTab)).toEqual([ + { event: CHILD_WINDOW_COMMANDS.settingsOpenTab, payload: { tab: "appearance" } }, + ]); + }); + + it("keeps direct dispatch compatibility for an untracked window", () => { + const queue = new ChildWindowCommandQueue(); + + expect( + queue.dispatch("legacy-window", CHILD_WINDOW_COMMANDS.filePreviewOpen, { name: "a.png" }), + ).toEqual([{ event: CHILD_WINDOW_COMMANDS.filePreviewOpen, payload: { name: "a.png" } }]); + }); + + it("clears state when a window is destroyed", () => { + const queue = new ChildWindowCommandQueue(); + queue.register("settings", "token", CHILD_WINDOW_COMMANDS.settingsOpenTab); + queue.dispatch("settings", CHILD_WINDOW_COMMANDS.settingsOpenTab, { tab: "appearance" }); + + queue.clear("settings"); + + expect( + queue.dispatch("settings", CHILD_WINDOW_COMMANDS.settingsOpenTab, { tab: "general" }), + ).toEqual([{ event: CHILD_WINDOW_COMMANDS.settingsOpenTab, payload: { tab: "general" } }]); + }); +}); diff --git a/src/lib/childWindowCommandQueue.ts b/src/lib/childWindowCommandQueue.ts new file mode 100644 index 00000000..e2628c9d --- /dev/null +++ b/src/lib/childWindowCommandQueue.ts @@ -0,0 +1,75 @@ +import type { ChildWindowCommandName } from "./childWindowProtocol"; + +export interface ChildWindowCommandEnvelope { + event: ChildWindowCommandName; + payload: unknown; +} + +interface ChildWindowCommandState { + token: string; + expectedEvent: ChildWindowCommandName; + ready: boolean; + pending: ChildWindowCommandEnvelope[]; +} + +/** + * 父窗口只在内存中保存尚未被子页面消费的命令。状态以窗口 label 和 ready token + * 共同隔离,避免已销毁 WebView 的迟到事件释放新窗口队列。 + */ +export class ChildWindowCommandQueue { + private readonly states = new Map(); + + register(label: string, token: string, expectedEvent: ChildWindowCommandName) { + const current = this.states.get(label); + if (current?.token === token && current.expectedEvent === expectedEvent) return; + + this.states.set(label, { + token, + expectedEvent, + ready: false, + pending: [], + }); + } + + dispatch( + label: string, + event: ChildWindowCommandName, + payload: unknown, + ): ChildWindowCommandEnvelope[] { + const command = { event, payload }; + const state = this.states.get(label); + if (!state || state.expectedEvent !== event || state.ready) { + return [command]; + } + + state.pending.push(command); + return []; + } + + markReady( + label: string, + token: string, + event: ChildWindowCommandName, + ): ChildWindowCommandEnvelope[] { + const state = this.states.get(label); + if (!state || state.token !== token || state.expectedEvent !== event) return []; + state.ready = true; + return state.pending.splice(0); + } + + markLoading(label: string, token: string) { + const state = this.states.get(label); + if (!state || state.token !== token) return false; + + state.ready = false; + return true; + } + + markFailed(label: string, token: string) { + return this.markLoading(label, token); + } + + clear(label: string) { + this.states.delete(label); + } +} diff --git a/src/lib/childWindowLifecycle.test.ts b/src/lib/childWindowLifecycle.test.ts new file mode 100644 index 00000000..da1519c2 --- /dev/null +++ b/src/lib/childWindowLifecycle.test.ts @@ -0,0 +1,55 @@ +import { beforeEach, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + emit: vi.fn(), + getCurrentWindow: vi.fn(() => ({ label: "file-preview-main" })), +})); + +vi.mock("@tauri-apps/api/event", () => ({ emit: mocks.emit })); +vi.mock("@tauri-apps/api/window", () => ({ getCurrentWindow: mocks.getCurrentWindow })); + +beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + mocks.emit.mockResolvedValue(undefined); + window.history.replaceState({}, "", "/?window=file-preview&readyToken=token"); +}); + +it("sends load-started once before later lifecycle phases", async () => { + const lifecycle = await import("./childWindowLifecycle"); + + await Promise.all([ + lifecycle.signalChildWindowLoadStarted(), + lifecycle.signalChildWindowLoadStarted(), + lifecycle.signalChildWindowCommandReady("file-preview-open"), + ]); + + expect(mocks.emit).toHaveBeenCalledTimes(2); + expect(mocks.emit.mock.calls.map((call) => call[1])).toEqual([ + { + label: "file-preview-main", + token: "token", + phase: "load-started", + }, + { + label: "file-preview-main", + token: "token", + phase: "command-ready", + command: "file-preview-open", + }, + ]); +}); + +it("allows a later lifecycle signal to retry a failed load-started emit", async () => { + mocks.emit.mockRejectedValueOnce(new Error("emit failed")).mockResolvedValue(undefined); + const lifecycle = await import("./childWindowLifecycle"); + + await expect(lifecycle.signalChildWindowLoadStarted()).rejects.toThrow("emit failed"); + await lifecycle.signalChildWindowLoadFailed("bootstrap-import"); + + expect(mocks.emit.mock.calls.map((call) => call[1].phase)).toEqual([ + "load-started", + "load-started", + "load-failed", + ]); +}); diff --git a/src/lib/childWindowLifecycle.ts b/src/lib/childWindowLifecycle.ts new file mode 100644 index 00000000..74b07b2d --- /dev/null +++ b/src/lib/childWindowLifecycle.ts @@ -0,0 +1,134 @@ +import { emit } from "@tauri-apps/api/event"; +import { getCurrentWindow } from "@tauri-apps/api/window"; +import { + CHILD_WINDOW_LIFECYCLE_EVENT, + CHILD_WINDOW_READY_TOKEN_PARAM, + type ChildWindowCommandName, + type ChildWindowLifecyclePayload, + type ChildWindowLoadFailureStage, +} from "./childWindowProtocol"; + +// load-started 不阻塞 shell 渲染;后续信号复用该 Promise,保持单次加载内的顺序。 +let loadStartedPromise: Promise | undefined; + +function lifecycleIdentity() { + const token = new URLSearchParams(window.location.search).get(CHILD_WINDOW_READY_TOKEN_PARAM); + return { + label: getCurrentWindow().label, + token: token ?? undefined, + }; +} + +function emitChildWindowLifecycle( + payload: + | { phase: "load-started" } + | { phase: "shell-ready" } + | { phase: "command-ready"; command: ChildWindowCommandName } + | { phase: "load-failed"; stage: ChildWindowLoadFailureStage }, +) { + return emit(CHILD_WINDOW_LIFECYCLE_EVENT, { + ...lifecycleIdentity(), + ...payload, + } satisfies ChildWindowLifecyclePayload); +} + +export function signalChildWindowLoadStarted() { + loadStartedPromise ??= emitChildWindowLifecycle({ phase: "load-started" }).catch((error) => { + loadStartedPromise = undefined; + throw error; + }); + return loadStartedPromise; +} + +function signalChildWindowLifecycle( + payload: + | { phase: "shell-ready" } + | { phase: "command-ready"; command: ChildWindowCommandName } + | { phase: "load-failed"; stage: ChildWindowLoadFailureStage }, +) { + return signalChildWindowLoadStarted().then(() => emitChildWindowLifecycle(payload)); +} + +export function signalChildWindowCommandReady(command: ChildWindowCommandName) { + return signalChildWindowLifecycle({ phase: "command-ready", command }); +} + +export function signalChildWindowLoadFailed(stage: ChildWindowLoadFailureStage) { + return signalChildWindowLifecycle({ phase: "load-failed", stage }); +} + +/** + * loading shell 形成稳定布局后再通知父窗口显示。隐藏 WebView 可能暂停 rAF, + * 因此 fallback 只确认 shell 已挂载,不等待字体、provider 或业务页面。 + */ +export function scheduleChildWindowShellReady() { + let settled = false; + let firstFrameId: number | undefined; + let secondFrameId: number | undefined; + let contentPollTimeoutId: number | undefined; + let fallbackTimeoutId: number | undefined; + + const cleanup = () => { + settled = true; + if (firstFrameId !== undefined) window.cancelAnimationFrame(firstFrameId); + if (secondFrameId !== undefined) window.cancelAnimationFrame(secondFrameId); + if (contentPollTimeoutId !== undefined) window.clearTimeout(contentPollTimeoutId); + if (fallbackTimeoutId !== undefined) window.clearTimeout(fallbackTimeoutId); + }; + + const hasMountedContent = () => { + const root = document.getElementById("root"); + if (!root?.firstElementChild) return false; + const rect = root.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + }; + + const signalReady = () => { + cleanup(); + void signalChildWindowLifecycle({ phase: "shell-ready" }).catch(() => {}); + }; + + const waitForMountedContent = () => { + if (settled) return; + if (hasMountedContent()) { + waitForPaint(); + return; + } + contentPollTimeoutId = window.setTimeout(waitForMountedContent, 16); + }; + + const emitReady = () => { + if (settled) return; + if (!hasMountedContent()) { + waitForMountedContent(); + return; + } + signalReady(); + }; + + const emitReadyFromFallback = () => { + if (settled) return; + if (hasMountedContent()) { + signalReady(); + return; + } + fallbackTimeoutId = window.setTimeout(emitReadyFromFallback, 16); + }; + + function waitForPaint() { + if (settled) return; + if (typeof window.requestAnimationFrame !== "function") { + emitReady(); + return; + } + + firstFrameId = window.requestAnimationFrame(() => { + secondFrameId = window.requestAnimationFrame(emitReady); + }); + } + + waitForMountedContent(); + fallbackTimeoutId = window.setTimeout(emitReadyFromFallback, 250); + + return cleanup; +} diff --git a/src/lib/childWindowProtocol.ts b/src/lib/childWindowProtocol.ts new file mode 100644 index 00000000..b3cb913d --- /dev/null +++ b/src/lib/childWindowProtocol.ts @@ -0,0 +1,29 @@ +export const CHILD_WINDOW_LIFECYCLE_EVENT = "child-window-lifecycle"; +export const CHILD_WINDOW_READY_TOKEN_PARAM = "readyToken"; + +export const CHILD_WINDOW_COMMANDS = { + settingsOpenTab: "settings-open-tab", + remoteFileEditorOpen: "remote-file-editor-open", + filePreviewOpen: "file-preview-open", +} as const; + +export type ChildWindowCommandName = + (typeof CHILD_WINDOW_COMMANDS)[keyof typeof CHILD_WINDOW_COMMANDS]; + +export type ChildWindowLoadFailureStage = "bootstrap-import" | "command-listener"; + +export type ChildWindowLifecyclePayload = + | { label: string; token?: string; phase: "load-started" } + | { label: string; token?: string; phase: "shell-ready" } + | { + label: string; + token?: string; + phase: "command-ready"; + command: ChildWindowCommandName; + } + | { + label: string; + token?: string; + phase: "load-failed"; + stage: ChildWindowLoadFailureStage; + }; diff --git a/src/lib/windowManager.test.ts b/src/lib/windowManager.test.ts index 450535d9..548d8e55 100644 --- a/src/lib/windowManager.test.ts +++ b/src/lib/windowManager.test.ts @@ -1,5 +1,20 @@ import { describe, expect, it } from "vitest"; -import { centerWindowRectInWorkArea, rectOverlapsWorkArea } from "./windowManager"; +import { + centerWindowRectInWorkArea, + childWindowCommandForUrl, + rectOverlapsWorkArea, +} from "./windowManager"; + +describe("child window command mapping", () => { + it.each([ + ["index.html?window=settings", "settings-open-tab"], + ["index.html?window=file-editor", "remote-file-editor-open"], + ["index.html?window=file-preview", "file-preview-open"], + ["index.html?window=new-session", undefined], + ])("maps %s to %s", (url, expected) => { + expect(childWindowCommandForUrl(url)).toBe(expected); + }); +}); describe("child window work-area helpers", () => { const primaryWorkArea = { diff --git a/src/lib/windowManager.ts b/src/lib/windowManager.ts index d1e1f076..3e7d6ed3 100644 --- a/src/lib/windowManager.ts +++ b/src/lib/windowManager.ts @@ -9,6 +9,14 @@ import { UserAttentionType, } from "@tauri-apps/api/window"; import i18n from "../i18n"; +import { ChildWindowCommandQueue } from "./childWindowCommandQueue"; +import { + CHILD_WINDOW_COMMANDS, + CHILD_WINDOW_LIFECYCLE_EVENT, + CHILD_WINDOW_READY_TOKEN_PARAM, + type ChildWindowCommandName, + type ChildWindowLifecyclePayload, +} from "./childWindowProtocol"; import { invoke } from "./invoke"; import { logger } from "./logger"; import { isMacOS } from "./platform"; @@ -51,12 +59,14 @@ const MODAL_CHILD_BASE_LABELS = new Set([ ]); const MODAL_GROUP_RAISE_SUPPRESS_MS = 250; const MODAL_TOPMOST_PULSE_MS = 120; -const CHILD_WINDOW_READY_EVENT = "child-window-ready"; -const CHILD_WINDOW_READY_TOKEN_PARAM = "readyToken"; const CHILD_WINDOW_READY_TIMEOUT_MS = 5_000; const INIT_URL_ONLY_WINDOW_TYPES = new Set(["new-session", "quick-command"]); -const registeredDestroyedHandlers = new Set(); +const registeredDestroyedHandlers = new Map(); const pendingChildWindowOpens = new Map(); +const childWindowCommands = new ChildWindowCommandQueue(); +const childWindowTokens = new Map(); +const childWindowShellWaiters = new Map(); +let childWindowLifecycleListenerPromise: Promise | undefined; let ownerMainWindowLabel = MAIN_WINDOW_LABEL; let modalGroupRaiseInFlight = false; let suppressChildFocusSyncUntil = 0; @@ -71,15 +81,12 @@ interface ModalGroupRaiseOptions { reason?: ModalGroupRaiseReason; } -interface ChildWindowReadyPayload { - label: string; - /** Bind the ready event to the WebView created for this request. */ - token?: string; -} - -interface ChildWindowReadyWaiter { +interface ChildWindowLifecycleWaiter { + token: string; promise: Promise; + resolve: () => void; cancel: () => void; + fail: () => void; failed: () => boolean; } @@ -118,99 +125,6 @@ export function isPrimaryMainWindow() { return ownerMainWindowLabel === MAIN_WINDOW_LABEL; } -export function signalChildWindowReady() { - const token = new URLSearchParams(window.location.search).get(CHILD_WINDOW_READY_TOKEN_PARAM); - return emit(CHILD_WINDOW_READY_EVENT, { - label: getCurrentWindow().label, - token: token ?? undefined, - }); -} - -/** - * Wait for at least two WebView layout frames before asking the parent to reveal the window; - * use a timer fallback when the hidden WebView pauses animation frames. Font loading is not a - * ready prerequisite because font swapping does not create a blank window but would delay it. - */ -export function scheduleChildWindowReady() { - let settled = false; - let firstFrameId: number | undefined; - let secondFrameId: number | undefined; - let contentPollTimeoutId: number | undefined; - let fallbackTimeoutId: number | undefined; - - const cleanup = () => { - settled = true; - if (firstFrameId !== undefined) window.cancelAnimationFrame(firstFrameId); - if (secondFrameId !== undefined) window.cancelAnimationFrame(secondFrameId); - if (contentPollTimeoutId !== undefined) window.clearTimeout(contentPollTimeoutId); - if (fallbackTimeoutId !== undefined) window.clearTimeout(fallbackTimeoutId); - }; - - // requestAnimationFrame only means JavaScript had a chance to run; it does not prove that the - // WebView has mounted page content. This is especially important for hidden macOS windows: - // confirm that root has a layoutable child before allowing reveal. - const hasMountedContent = () => { - const root = document.getElementById("root"); - if (!root?.firstElementChild) return false; - const rect = root.getBoundingClientRect(); - return rect.width > 0 && rect.height > 0; - }; - - const waitForMountedContent = () => { - if (settled) return; - if (hasMountedContent()) { - waitForPaint(); - return; - } - contentPollTimeoutId = window.setTimeout(waitForMountedContent, 16); - }; - - const signalReady = () => { - cleanup(); - void signalChildWindowReady(); - }; - - function emitReady() { - if (settled) return; - if (!hasMountedContent()) { - waitForMountedContent(); - return; - } - signalReady(); - } - - // A hidden WebView may pause requestAnimationFrame. Once the loading shell exists, the - // fallback can complete the handshake without waiting for two more frames; otherwise the - // first open could approach the parent timeout. - const emitReadyFromFallback = () => { - if (settled) return; - if (hasMountedContent()) { - signalReady(); - return; - } - fallbackTimeoutId = window.setTimeout(emitReadyFromFallback, 16); - }; - - const waitForPaint = () => { - if (settled) return; - if (typeof window.requestAnimationFrame !== "function") { - emitReady(); - return; - } - - firstFrameId = window.requestAnimationFrame(() => { - secondFrameId = window.requestAnimationFrame(emitReady); - }); - }; - - // The first hidden WebView frame does not need to wait for custom fonts; confirming that the - // page shell is mounted is sufficient. - waitForMountedContent(); - fallbackTimeoutId = window.setTimeout(emitReadyFromFallback, 250); - - return cleanup; -} - function scopedModalLabel(baseLabel: string, ownerLabel = ownerMainWindowLabel) { return ownerLabel === MAIN_WINDOW_LABEL ? baseLabel : `${baseLabel}-${ownerLabel}`; } @@ -271,6 +185,19 @@ function childWindowTypeFromUrl(url: string) { } } +export function childWindowCommandForUrl(url: string): ChildWindowCommandName | undefined { + switch (childWindowTypeFromUrl(url)) { + case "settings": + return CHILD_WINDOW_COMMANDS.settingsOpenTab; + case "file-editor": + return CHILD_WINDOW_COMMANDS.remoteFileEditorOpen; + case "file-preview": + return CHILD_WINDOW_COMMANDS.filePreviewOpen; + default: + return undefined; + } +} + function appendChildWindowReadyToken(url: string, token: string) { const separator = url.includes("?") ? "&" : "?"; return `${url}${separator}${CHILD_WINDOW_READY_TOKEN_PARAM}=${encodeURIComponent(token)}`; @@ -503,17 +430,33 @@ export async function raiseModalChildWindowGroup(options: ModalGroupRaiseOptions } } -function attachChildWindowDestroyedHandler(label: string, win: WebviewWindow) { - if (registeredDestroyedHandlers.has(label)) return; - registeredDestroyedHandlers.add(label); +async function attachChildWindowDestroyedHandler(label: string, win: WebviewWindow) { + const lifecycleToken = childWindowTokens.get(label); + // 回调绑定注册时的窗口代际;旧实例迟到的 destroyed 不得清理同 label 新实例。 + const registrationId = + lifecycleToken ?? registeredDestroyedHandlers.get(label) ?? createChildWindowReadyToken(); + if (registeredDestroyedHandlers.get(label) === registrationId) return; + registeredDestroyedHandlers.set(label, registrationId); - win.once("tauri://destroyed", () => { - registeredDestroyedHandlers.delete(label); - emit("child-window-closed", { label }); - if (isModalChildLabel(label)) { - void prepareForModalChildClose(label); + try { + await win.once("tauri://destroyed", () => { + if (registeredDestroyedHandlers.get(label) !== registrationId) return; + const currentToken = childWindowTokens.get(label); + if (currentToken && currentToken !== lifecycleToken) return; + + registeredDestroyedHandlers.delete(label); + clearChildWindowLifecycle(label, lifecycleToken, true); + void emit("child-window-closed", { label }).catch(() => {}); + if (isModalChildLabel(label)) { + void prepareForModalChildClose(label).catch(() => {}); + } + }); + } catch (error) { + if (registeredDestroyedHandlers.get(label) === registrationId) { + registeredDestroyedHandlers.delete(label); } - }); + throw error; + } } export async function syncMainWindowModalState() { @@ -528,58 +471,145 @@ export async function bounceTopModalWindow() { await raiseModalChildWindowGroup({ requestAttention: true, reason: "backdrop" }); } -async function createChildWindowReadyWaiter( +function emitChildWindowCommands(commands: ReturnType) { + for (const command of commands) { + void emit(command.event, command.payload).catch((error) => { + logger.warn({ + domain: "window.lifecycle", + event: "child_command_emit_failed", + message: "Failed to emit a command to a child window", + data: { command: command.event }, + error, + }); + }); + } +} + +function dispatchChildWindowCommand( + label: string, + event: ChildWindowCommandName, + payload: unknown, +) { + emitChildWindowCommands(childWindowCommands.dispatch(label, event, payload)); +} + +function handleChildWindowLifecycle(payload: ChildWindowLifecyclePayload) { + if (!payload.token || childWindowTokens.get(payload.label) !== payload.token) return; + + if (payload.phase === "load-started") { + childWindowCommands.markLoading(payload.label, payload.token); + return; + } + + switch (payload.phase) { + case "shell-ready": { + const waiter = childWindowShellWaiters.get(payload.label); + if (waiter?.token === payload.token) waiter.resolve(); + break; + } + case "command-ready": + emitChildWindowCommands( + childWindowCommands.markReady(payload.label, payload.token, payload.command), + ); + break; + case "load-failed": + childWindowCommands.markFailed(payload.label, payload.token); + logger.warn({ + domain: "window.lifecycle", + event: "child_load_failed", + message: "Child window failed to finish loading", + data: { label: payload.label, stage: payload.stage }, + }); + break; + } +} + +async function ensureChildWindowLifecycleListener() { + if (!childWindowLifecycleListenerPromise) { + childWindowLifecycleListenerPromise = listen( + CHILD_WINDOW_LIFECYCLE_EVENT, + ({ payload }) => handleChildWindowLifecycle(payload), + ) + .then(() => undefined) + .catch((error) => { + childWindowLifecycleListenerPromise = undefined; + logger.warn({ + domain: "window.lifecycle", + event: "child_lifecycle_listener_failed", + message: "Failed to listen for child window lifecycle events", + error, + }); + throw error; + }); + } + await childWindowLifecycleListenerPromise; +} + +function clearChildWindowLifecycle(label: string, token?: string, failWaiter = false) { + if (token && childWindowTokens.get(label) !== token) return; + + childWindowTokens.delete(label); + childWindowCommands.clear(label); + const waiter = childWindowShellWaiters.get(label); + if (failWaiter) waiter?.fail(); + else waiter?.cancel(); +} + +async function createChildWindowLifecycleWaiter( label: string, token: string, -): Promise { + expectedCommand: ChildWindowCommandName | undefined, +): Promise { + await ensureChildWindowLifecycleListener(); + + childWindowShellWaiters.get(label)?.cancel(); + childWindowTokens.set(label, token); + if (expectedCommand) { + childWindowCommands.register(label, token, expectedCommand); + } + let settled = false; let timeoutId: number | undefined; - let unlisten: (() => void) | undefined; let failed = false; let resolveReady: () => void = () => {}; const promise = new Promise((resolve) => { resolveReady = resolve; }); - const settle = () => { + const settle = (didFail: boolean) => { if (settled) return; settled = true; + failed = didFail; if (timeoutId !== undefined) { window.clearTimeout(timeoutId); } - unlisten?.(); + if (childWindowShellWaiters.get(label)?.token === token) { + childWindowShellWaiters.delete(label); + } resolveReady(); }; - try { - unlisten = await listen(CHILD_WINDOW_READY_EVENT, ({ payload }) => { - if (payload.label === label && payload.token === token) { - settle(); - } - }); - timeoutId = window.setTimeout(() => { - failed = true; - logger.warn({ - domain: "window.lifecycle", - event: "child_ready_timeout", - message: "Child window did not signal ready before timeout", - data: { label }, - }); - settle(); - }, CHILD_WINDOW_READY_TIMEOUT_MS); - } catch (error) { - failed = true; + const waiter: ChildWindowLifecycleWaiter = { + token, + promise, + resolve: () => settle(false), + cancel: () => settle(false), + fail: () => settle(true), + failed: () => failed, + }; + childWindowShellWaiters.set(label, waiter); + + timeoutId = window.setTimeout(() => { logger.warn({ domain: "window.lifecycle", - event: "child_ready_listener_failed", - message: "Failed to listen for child window ready event", + event: "child_ready_timeout", + message: "Child window did not signal shell ready before timeout", data: { label }, - error, }); - settle(); - } + settle(true); + }, CHILD_WINDOW_READY_TIMEOUT_MS); - return { promise, cancel: settle, failed: () => failed }; + return waiter; } async function revealChildWindow( @@ -595,13 +625,13 @@ async function revealChildWindow( await win.setTitle(opts.title).catch(() => {}); await win.setAlwaysOnTop(needsAlwaysOnTop(opts.label)).catch(() => {}); } - attachChildWindowDestroyedHandler(opts.label, win); + await attachChildWindowDestroyedHandler(opts.label, win); if (!isNewWindow) { await ensureChildWindowVisible(win, opts); } // Keep the child hidden until the ready handshake, then restore interactivity before showing. await win.setFocusable(true).catch(() => {}); - await win.show().catch(() => {}); + await win.show(); onShown?.(); await win.setFocus().catch(() => {}); emit("child-window-opened", { label: opts.label }); @@ -638,7 +668,11 @@ async function openChildWindowInternal(opts: ChildWindowOptions) { } const readyToken = createChildWindowReadyToken(); - const readyWaiter = await createChildWindowReadyWaiter(opts.label, readyToken); + const lifecycleWaiter = await createChildWindowLifecycleWaiter( + opts.label, + readyToken, + childWindowCommandForUrl(opts.url), + ); const listenerReadyMs = Math.round(performance.now() - startedAt); try { await invoke("open_child_window", { @@ -663,9 +697,9 @@ async function openChildWindowInternal(opts: ChildWindowOptions) { } const handleMs = Math.round(performance.now() - startedAt); - attachChildWindowDestroyedHandler(opts.label, win); - await readyWaiter.promise; - if (readyWaiter.failed()) { + const destroyedListenerPromise = attachChildWindowDestroyedHandler(opts.label, win); + await Promise.all([lifecycleWaiter.promise, destroyedListenerPromise]); + if (lifecycleWaiter.failed()) { throw new Error(`Child window did not finish rendering: ${opts.label}`); } const readyMs = Math.round(performance.now() - startedAt); @@ -683,7 +717,8 @@ async function openChildWindowInternal(opts: ChildWindowOptions) { }); return revealed; } catch (error) { - readyWaiter.cancel(); + lifecycleWaiter.cancel(); + clearChildWindowLifecycle(opts.label, readyToken); // Destroy a failed first-open window promptly so it cannot remain as a background orphan. const orphan = await WebviewWindow.getByLabel(opts.label).catch(() => null); await orphan?.close().catch(() => {}); @@ -727,11 +762,12 @@ export function openChildWindow(opts: ChildWindowOptions): Promise { const payload = { targetLabel: label, data }; - emit("remote-file-editor-open", payload); + dispatchChildWindowCommand(label, CHILD_WINDOW_COMMANDS.remoteFileEditorOpen, payload); return win; }); } @@ -925,7 +958,7 @@ export function openFilePreview(data: FilePreviewWindowData) { stateKey: "file-preview", }).then((win) => { const payload = { targetLabel: label, data }; - emit("file-preview-open", payload); + dispatchChildWindowCommand(label, CHILD_WINDOW_COMMANDS.filePreviewOpen, payload); return win; }); } diff --git a/src/main.tsx b/src/main.tsx index b9757d65..278a7af8 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -7,9 +7,6 @@ import "@fontsource/inter/400.css"; import "@fontsource/inter/500.css"; import "@fontsource/inter/600.css"; import "@fontsource-variable/noto-sans-sc"; -import "./i18n"; -import ErrorBoundary from "./components/ErrorBoundary"; -import { Toaster } from "./components/ui/sonner"; import "./index.css"; import { applyThemeToDOM, @@ -17,9 +14,13 @@ import { THEME_SNAPSHOT_CACHE_KEY, ThemeProvider, } from "./context/ThemeContext"; +import { + scheduleChildWindowShellReady, + signalChildWindowLoadFailed, + signalChildWindowLoadStarted, +} from "./lib/childWindowLifecycle"; import { DEFAULT_THEME_ID, themes } from "./lib/themes"; import { installWebviewReloadGuard } from "./lib/webviewReloadGuard"; -import { scheduleChildWindowReady } from "./lib/windowManager"; // Apply cached theme synchronously before React renders to avoid flash try { @@ -45,6 +46,7 @@ const params = new URLSearchParams(window.location.search); const windowType = params.get("window"); if (windowType) { + void signalChildWindowLoadStarted().catch(() => {}); // Child window: lightweight provider stack, no full App // These entry points are independent and should load in parallel; serial awaits would add an // unnecessary chunk round trip to every child-window open. @@ -60,31 +62,78 @@ if (windowType) {
, ); - scheduleChildWindowReady(); + scheduleChildWindowShellReady(); - const [{ ChildAppProvider }, { default: ChildWindowRouter }] = await Promise.all([ - import("./context/ChildAppProvider"), - import("./ChildWindowRouter"), - ]); + try { + const [ + { ChildAppProvider }, + { default: ChildWindowRouter }, + { default: ErrorBoundary }, + { Toaster }, + ] = await Promise.all([ + import("./context/ChildAppProvider"), + import("./ChildWindowRouter"), + import("./components/ErrorBoundary"), + import("./components/ui/sonner"), + ]); - childRoot.render( - - - - - - - - - - , - ); + childRoot.render( + + + + + + + + + + , + ); + } catch { + void signalChildWindowLoadFailed("bootstrap-import").catch(() => {}); + let errorTitle = "Something went wrong"; + let reloadLabel = "Reload"; + try { + const { default: i18n } = await import("./i18n"); + errorTitle = i18n.t("error.somethingWentWrong"); + reloadLabel = i18n.t("error.reloadApplication"); + } catch {} + childRoot.render( +
+
+

{errorTitle}

+ +
+
, + ); + } } else { // Main window: full app with all providers - const { getCurrentWindow } = await import("@tauri-apps/api/window"); - const { setOwnerMainWindowLabel } = await import("./lib/windowManager"); - const { AppProvider } = await import("./context/AppContext"); - const { default: App } = await import("./App"); + const [ + { getCurrentWindow }, + { setOwnerMainWindowLabel }, + { AppProvider }, + { default: App }, + { default: ErrorBoundary }, + { Toaster }, + ] = await Promise.all([ + import("@tauri-apps/api/window"), + import("./lib/windowManager"), + import("./context/AppProvider"), + import("./App"), + import("./components/ErrorBoundary"), + import("./components/ui/sonner"), + ]); setOwnerMainWindowLabel(getCurrentWindow().label); ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( diff --git a/src/pages/FilePreviewPage.tsx b/src/pages/FilePreviewPage.tsx index f8622c64..c98b1282 100644 --- a/src/pages/FilePreviewPage.tsx +++ b/src/pages/FilePreviewPage.tsx @@ -1,4 +1,3 @@ -import { listen } from "@tauri-apps/api/event"; import { join, tempDir } from "@tauri-apps/api/path"; import { getCurrentWindow } from "@tauri-apps/api/window"; import { openPath } from "@tauri-apps/plugin-opener"; @@ -29,6 +28,8 @@ import { } from "@/components/ui/dropdown-menu"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { useApp } from "@/context/AppContext"; +import { useChildWindowCommand } from "@/hooks/useChildWindowCommand"; +import { CHILD_WINDOW_COMMANDS } from "@/lib/childWindowProtocol"; import { getErrorMessage } from "@/lib/errors"; import { invoke } from "@/lib/invoke"; import { cn, formatSize, parseJsonSearchParam } from "@/lib/utils"; @@ -157,23 +158,14 @@ export default function FilePreviewPage() { [activateTab, updateTabs], ); - useEffect(() => { - const currentWindow = getCurrentWindow(); - let unlisten: (() => void) | undefined; - - listen("file-preview-open", (event) => { - if (event.payload.targetLabel && event.payload.targetLabel !== currentWindow.label) return; - addOrFocusTab(event.payload.data); - }) - .then((dispose) => { - unlisten = dispose; - }) - .catch(() => {}); - - return () => { - unlisten?.(); - }; - }, [addOrFocusTab]); + useChildWindowCommand( + CHILD_WINDOW_COMMANDS.filePreviewOpen, + (payload) => { + const currentWindow = getCurrentWindow(); + if (payload.targetLabel && payload.targetLabel !== currentWindow.label) return; + addOrFocusTab(payload.data); + }, + ); useEffect(() => { const currentWindow = getCurrentWindow(); diff --git a/src/pages/RemoteFileEditorPage.tsx b/src/pages/RemoteFileEditorPage.tsx index 0100d47f..965b6a8c 100644 --- a/src/pages/RemoteFileEditorPage.tsx +++ b/src/pages/RemoteFileEditorPage.tsx @@ -1,7 +1,6 @@ import { closeSearchPanel } from "@codemirror/search"; import { EditorState } from "@codemirror/state"; import { EditorView } from "@codemirror/view"; -import { listen } from "@tauri-apps/api/event"; import { join, tempDir } from "@tauri-apps/api/path"; import { getCurrentWindow } from "@tauri-apps/api/window"; import { openPath } from "@tauri-apps/plugin-opener"; @@ -36,6 +35,8 @@ import { } from "@/components/ui/dropdown-menu"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { useApp } from "@/context/AppContext"; +import { useChildWindowCommand } from "@/hooks/useChildWindowCommand"; +import { CHILD_WINDOW_COMMANDS } from "@/lib/childWindowProtocol"; import { type CursorPosition, codeMirrorFileViewExtensions, @@ -317,23 +318,14 @@ export default function RemoteFileEditorPage() { void loadFile(tabId(initialData)); }, [initialData, loadFile]); - useEffect(() => { - const currentWindow = getCurrentWindow(); - let unlisten: (() => void) | undefined; - - listen("remote-file-editor-open", (event) => { - if (event.payload.targetLabel && event.payload.targetLabel !== currentWindow.label) return; - addOrFocusTab(event.payload.data); - }) - .then((dispose) => { - unlisten = dispose; - }) - .catch(() => {}); - - return () => { - unlisten?.(); - }; - }, [addOrFocusTab]); + useChildWindowCommand( + CHILD_WINDOW_COMMANDS.remoteFileEditorOpen, + (payload) => { + const currentWindow = getCurrentWindow(); + if (payload.targetLabel && payload.targetLabel !== currentWindow.label) return; + addOrFocusTab(payload.data); + }, + ); useEffect(() => { const currentWindow = getCurrentWindow(); diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index 0e0ab98d..6dcc4e82 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -1,4 +1,3 @@ -import { listen } from "@tauri-apps/api/event"; import { getCurrentWindow } from "@tauri-apps/api/window"; import { type ComponentType, @@ -57,7 +56,9 @@ import { Button } from "@/components/ui/button"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { AppContext, useApp } from "@/context/AppContext"; import { SettingsDraftContext } from "@/context/SettingsDraftContext"; +import { useChildWindowCommand } from "@/hooks/useChildWindowCommand"; import { useSettingsDraftState } from "@/hooks/useSettingsDraftState"; +import { CHILD_WINDOW_COMMANDS } from "@/lib/childWindowProtocol"; import { type CloudSyncValidationCode, getCloudSyncValidationErrors } from "@/lib/cloudSync"; import { getErrorMessage } from "@/lib/errors"; import { invoke } from "@/lib/invoke"; @@ -133,19 +134,13 @@ export default function SettingsPage() { } }, [activeTab]); - useEffect(() => { - const unlisten = listen<{ tab: string; targetWindowLabel?: string | null }>( - "settings-open-tab", - ({ payload }) => { - if (payload.targetWindowLabel && payload.targetWindowLabel !== ownerWindowLabel) return; - setActiveTab(normalizeSettingsTab(payload.tab)); - }, - ); - - return () => { - unlisten.then((dispose) => dispose()); - }; - }, [ownerWindowLabel]); + useChildWindowCommand<{ tab: string; targetWindowLabel?: string | null }>( + CHILD_WINDOW_COMMANDS.settingsOpenTab, + (payload) => { + if (payload.targetWindowLabel && payload.targetWindowLabel !== ownerWindowLabel) return; + setActiveTab(normalizeSettingsTab(payload.tab)); + }, + ); type SettingsCategory = { id: string; From 4495338d37706a77f91bbc8fcf33267165d9f8ab Mon Sep 17 00:00:00 2001 From: Kang Date: Fri, 14 Aug 2026 22:27:24 +0800 Subject: [PATCH 05/11] perf(terminal): implement DEC 2026 frame gate for enhanced terminal output management - Introduced `Dec2026FrameGate` to manage DEC 2026 frame sequences, allowing for better handling of visual output in terminal applications. - Added comprehensive tests for frame classification and gate behavior, ensuring accurate detection and processing of frames. - Implemented a benchmark for evaluating frame gate performance under various conditions. - Updated `XTerminal` to integrate the new frame gate functionality, improving output scheduling and pressure management. - Enhanced zmodem event handling to utilize the new frame gate for terminal status updates. --- scripts/benchmark-dec2026-frame-gate.mjs | 12 + src/components/terminal/XTerminal.tsx | 127 +++- .../terminal/dec2026FrameGate.test.ts | 254 +++++++ src/components/terminal/dec2026FrameGate.ts | 707 ++++++++++++++++++ .../dec2026FrameGateBenchmark.test.ts | 152 ++++ .../terminal/zmodemTerminalEvents.ts | 17 +- 6 files changed, 1236 insertions(+), 33 deletions(-) create mode 100644 scripts/benchmark-dec2026-frame-gate.mjs create mode 100644 src/components/terminal/dec2026FrameGate.test.ts create mode 100644 src/components/terminal/dec2026FrameGate.ts create mode 100644 src/components/terminal/dec2026FrameGateBenchmark.test.ts diff --git a/scripts/benchmark-dec2026-frame-gate.mjs b/scripts/benchmark-dec2026-frame-gate.mjs new file mode 100644 index 00000000..452bc8ef --- /dev/null +++ b/scripts/benchmark-dec2026-frame-gate.mjs @@ -0,0 +1,12 @@ +import { spawnSync } from "node:child_process"; + +const result = spawnSync( + "pnpm", + ["vitest", "run", "src/components/terminal/dec2026FrameGateBenchmark.test.ts", "--reporter", "verbose"], + { + stdio: "inherit", + shell: process.platform === "win32", + }, +); + +process.exitCode = result.status ?? 1; diff --git a/src/components/terminal/XTerminal.tsx b/src/components/terminal/XTerminal.tsx index 5f8523cd..700441a3 100644 --- a/src/components/terminal/XTerminal.tsx +++ b/src/components/terminal/XTerminal.tsx @@ -113,6 +113,10 @@ import { type TerminalOutputDrainMode, } from "./terminalOutputDrain"; import { AlternateScreenStateTracker } from "./alternateScreenStateTracker"; +import { + Dec2026FrameGate, + resolveDec2026FrameGateMode, +} from "./dec2026FrameGate"; import { TerminalOutputScheduler } from "./terminalOutputScheduling"; import { useTerminalExternalDrop } from "./useTerminalExternalDrop"; import { useTerminalRefreshEffects } from "./useTerminalRefreshEffects"; @@ -350,6 +354,7 @@ export default function XTerminal({ beforeLine: number; ts: number; }> | null>(null); + const frameGateRef = useRef(null); const lineTimestampsRef = useRef>(new Map()); const gutterLineOffsetRef = useRef(0); const sessionTypeRef = useRef(sessionType); @@ -801,6 +806,11 @@ export default function XTerminal({ setTerminalReady(false); lineTimestampsRef.current = new Map(); gutterLineOffsetRef.current = 0; + frameGateRef.current?.dispose({ + ackRemaining: true, + reason: "terminal_rebuild", + }); + frameGateRef.current = null; outputDrainRef.current?.dispose({ ackRemaining: true }); outputDrainRef.current = null; alternateScreenTrackerRef.current.reset(); @@ -858,6 +868,9 @@ export default function XTerminal({ ); const serializeAddon = new SerializeAddon(); const unicodeGraphemesAddon = new UnicodeGraphemesAddon(); + let writeOrderedTerminalStatus = (data: string) => { + terminal.write(data); + }; const zmodemHandler = createZmodemEventHandler( terminal, sessionId, @@ -869,6 +882,9 @@ export default function XTerminal({ complete: completeExternalTransfer, fail: failExternalTransfer, }, + (data) => { + writeOrderedTerminalStatus(data); + }, ); terminal.options.linkHandler = oscLinkHandler; @@ -2154,7 +2170,9 @@ export default function XTerminal({ alternateScreenTrackerRef.current.isAlternateScreenActive(); const outputScheduler = new TerminalOutputScheduler({ - getQueueBytes: () => outputDrainRef.current?.getQueueBytes() ?? 0, + getQueueBytes: () => + (outputDrainRef.current?.getQueueBytes() ?? 0) + + (frameGateRef.current?.getHeldBytes() ?? 0), isAlternateScreenActive, }); @@ -2166,7 +2184,8 @@ export default function XTerminal({ : XTERM_PERFORMANCE_CONFIG.output.hiddenRecoveryThresholdBytes; const getPendingOutputBytes = () => - outputDrainRef.current?.getPendingBytes() ?? 0; + (outputDrainRef.current?.getPendingBytes() ?? 0) + + (frameGateRef.current?.getHeldBytes() ?? 0); const getNonOverloadedPressureMode = (): PerformanceMode => getPendingOutputBytes() >= @@ -2272,6 +2291,7 @@ export default function XTerminal({ visible: visibleRef.current, queue_bytes: outputDrainRef.current?.getQueueBytes() ?? 0, pending_bytes: outputDrainRef.current?.getPendingBytes() ?? 0, + frame_gate: frameGateRef.current?.snapshot(), performance_mode: performanceModeRef.current, }, }); @@ -2286,6 +2306,7 @@ export default function XTerminal({ queue_bytes: queueBytes, writing_bytes: writingBytes, unacked_bytes: unackedBytes, + frame_gate: frameGateRef.current?.snapshot(), performance_mode: performanceModeRef.current, buffer_type: terminal.buffer.active.type, }, @@ -2293,9 +2314,58 @@ export default function XTerminal({ }, }); outputDrainRef.current = outputDrain; + const frameGateMode = resolveDec2026FrameGateMode(); + const frameGate = new Dec2026FrameGate({ + mode: frameGateMode, + forward: (chunk) => outputDrain.enqueue(chunk), + ackDropped: sendOutputAck, + getPressureSnapshot: () => ({ + alternateScreen: isAlternateScreenActive(), + outputDrainQueueBytes: outputDrain.getQueueBytes(), + outputDrainPendingBytes: outputDrain.getPendingBytes(), + frameGateHeldBytes: frameGateRef.current?.getHeldBytes() ?? 0, + performanceMode: performanceModeRef.current, + }), + onPressureChange: () => { + maybeRecoverPerformanceMode(); + refreshOutputPressureMode(); + }, + logDebug: (event, message, data) => { + logger.debug({ + domain: "terminal.input", + event, + message, + ids: { session_id: sessionId }, + data: { + mode: frameGateMode, + ...(data ?? {}), + }, + }); + }, + }); + frameGateRef.current = frameGate; + logger.debug({ + domain: "terminal.input", + event: "terminal.dec2026_frame_gate.mode", + message: "Initialized DEC 2026 frame gate", + ids: { session_id: sessionId }, + data: { mode: frameGateMode }, + }); updateOutputDrainMode(); - const writeTerminalTextAfterOutputQueue = (data: string) => { + const flushFrameGateAndDrain = async (reason: string) => { + clearHibernateTimer(); + frameGateRef.current?.flush(reason); + const drained = await outputDrain.waitForIdle( + XTERM_PERFORMANCE_CONFIG.output.hibernateDrainTimeoutMs, + ); + maybeRecoverPerformanceMode(); + refreshOutputPressureMode(); + return drained; + }; + + const writeTerminalTextAfterOutputQueue = async (data: string) => { + await flushFrameGateAndDrain("terminal_status_write"); return outputDrain.writeExternal( () => new Promise((resolve) => { @@ -2320,15 +2390,13 @@ export default function XTerminal({ ); }; - const flushQueuedOutputBeforeStatusNotice = async () => { - clearHibernateTimer(); - await outputDrain.waitForIdle( - XTERM_PERFORMANCE_CONFIG.output.hibernateDrainTimeoutMs, - ); - maybeRecoverPerformanceMode(); - refreshOutputPressureMode(); + writeOrderedTerminalStatus = (data: string) => { + void writeTerminalTextAfterOutputQueue(data); }; + const flushQueuedOutputBeforeStatusNotice = async () => + flushFrameGateAndDrain("status_notice"); + const resetDisconnectedInputState = () => { inputStateRef.current = createTerminalInputState(); clearCredentialPromptInputMode(); @@ -2447,10 +2515,14 @@ export default function XTerminal({ inputStateRef.current = createTerminalInputState(); clearCredentialPromptInputMode(); dismissSuggestions(); - terminal.write(renderAiCommandStart(event.payload)); + void writeTerminalTextAfterOutputQueue( + renderAiCommandStart(event.payload), + ); } else if (event.payload.type === "commandEnd") { aiCapturingRef.current = false; - terminal.write(renderAiCommandEnd(event.payload)); + void writeTerminalTextAfterOutputQueue( + renderAiCommandEnd(event.payload), + ); } break; } @@ -2531,9 +2603,8 @@ export default function XTerminal({ "Draining terminal output before hibernation", { epoch }, ); - const drainedBeforeDetach = await outputDrain.waitForIdle( - XTERM_PERFORMANCE_CONFIG.output.hibernateDrainTimeoutMs, - ); + const drainedBeforeDetach = + await flushFrameGateAndDrain("hibernate_before_detach"); if (!drainedBeforeDetach) { hibernationPhaseRef.current = "idle"; logHibernation( @@ -2574,9 +2645,8 @@ export default function XTerminal({ return; } - const drainedAfterDetach = await outputDrain.waitForIdle( - XTERM_PERFORMANCE_CONFIG.output.hibernateDrainTimeoutMs, - ); + const drainedAfterDetach = + await flushFrameGateAndDrain("hibernate_after_detach"); if (!drainedAfterDetach) { logHibernation( "drain_timeout", @@ -2675,16 +2745,11 @@ export default function XTerminal({ return; } - alternateScreenTrackerRef.current.ingest(payload.data); - outputDrain.enqueue({ - data: payload.data, - bytes: payload.bytes, - }); - const recentPayload = payload.data.length > 4096 ? payload.data.slice(-4096) : payload.data; + alternateScreenTrackerRef.current.ingest(payload.data); updateCredentialPromptInputMode(recentPayload); feedCredentialOutput(recentPayload); if (visibleRef.current && hasErrorKeyword(recentPayload)) { @@ -2699,6 +2764,10 @@ export default function XTerminal({ } noteSkippedOutput(payload.droppedBytes ?? 0); + frameGate.enqueue({ + data: payload.data, + bytes: payload.bytes, + }); if (!visibleRef.current) { maybeRecoverPerformanceMode(); @@ -2803,12 +2872,14 @@ export default function XTerminal({ clearCredentialPromptInputMode(); dismissSuggestions(); if (isTerminalAlive()) { - terminal.write(renderAiCommandStart(payload)); + void writeTerminalTextAfterOutputQueue( + renderAiCommandStart(payload), + ); } } else if (payload.type === "commandEnd") { aiCapturingRef.current = false; if (isTerminalAlive()) { - terminal.write(renderAiCommandEnd(payload)); + void writeTerminalTextAfterOutputQueue(renderAiCommandEnd(payload)); } } }, @@ -3387,6 +3458,10 @@ export default function XTerminal({ if (zmodemUnlisten) zmodemUnlisten(); if (commandAcceptedUnlisten) commandAcceptedUnlisten(); zmodemHandler.dispose(); + frameGate.dispose({ ackRemaining: true, reason: "terminal_cleanup" }); + if (frameGateRef.current === frameGate) { + frameGateRef.current = null; + } outputDrain.dispose(); if (outputDrainRef.current === outputDrain) { outputDrainRef.current = null; diff --git a/src/components/terminal/dec2026FrameGate.test.ts b/src/components/terminal/dec2026FrameGate.test.ts new file mode 100644 index 00000000..47ae9697 --- /dev/null +++ b/src/components/terminal/dec2026FrameGate.test.ts @@ -0,0 +1,254 @@ +import { describe, expect, it } from "vitest"; +import { XTERM_PERFORMANCE_CONFIG } from "@/lib/xtermPerformance"; +import { + classifyDec2026Frame, + Dec2026FrameGate, + type Dec2026FrameGateMode, +} from "./dec2026FrameGate"; +import type { QueuedOutputChunk } from "./xterminalOutputQueue"; + +const encoder = new TextEncoder(); +const begin = "\x1b[?2026h"; +const end = "\x1b[?2026l"; +const c1Begin = "\x9b?2026h"; +const c1End = "\x9b?2026l"; +const resetClearHome = "\x1b[0m\x1b[2J\x1b[1;1H"; + +function bytes(text: string) { + return encoder.encode(text).length; +} + +function successorProof(content: string) { + const classification = classifyDec2026Frame(content); + return classification.kind === "replaceable-visual" + ? classification.successorProof + : null; +} + +function frame(content: string) { + return `${begin}${content}${end}`; +} + +function createHarness( + options: { + mode?: Dec2026FrameGateMode; + alternateScreen?: boolean; + queueBytes?: number; + pendingBytes?: number; + performanceMode?: string; + } = {}, +) { + let now = 0; + let nextTimer = 1; + const timers = new Map void }>(); + const forwarded: QueuedOutputChunk[] = []; + const acks: number[] = []; + let gate!: Dec2026FrameGate; + gate = new Dec2026FrameGate({ + mode: options.mode ?? "collapse", + forward: (chunk) => forwarded.push(chunk), + ackDropped: (count) => acks.push(count), + getPressureSnapshot: () => ({ + alternateScreen: options.alternateScreen ?? true, + outputDrainQueueBytes: + options.queueBytes ?? + XTERM_PERFORMANCE_CONFIG.output.alternateScreenThrottleBacklogBytes + 1, + outputDrainPendingBytes: options.pendingBytes ?? 0, + frameGateHeldBytes: gate.getHeldBytes(), + performanceMode: options.performanceMode ?? "strained", + }), + setTimeout: (callback, delay) => { + const id = nextTimer; + nextTimer += 1; + timers.set(id, { at: now + delay, callback }); + return id; + }, + clearTimeout: (id) => { + timers.delete(id); + }, + }); + + const enqueue = (data: string, ingressBytes = bytes(data)) => { + gate.enqueue({ data, bytes: ingressBytes }); + }; + + const advance = (ms: number) => { + now += ms; + const due = [...timers.entries()] + .filter(([, timer]) => timer.at <= now) + .sort((left, right) => left[1].at - right[1].at); + for (const [id, timer] of due) { + timers.delete(id); + timer.callback(); + } + }; + + return { + acks, + advance, + enqueue, + forwarded, + gate, + joined: () => forwarded.map((chunk) => chunk.data).join(""), + forwardedBytes: () => forwarded.reduce((total, chunk) => total + chunk.bytes, 0), + }; +} + +describe("Dec2026FrameGate detector", () => { + it("detects complete, split, repeated, and C1 DEC 2026 frames in shadow mode", () => { + const { enqueue, forwarded, gate, joined } = createHarness({ mode: "shadow" }); + const payload = `${frame("one")}${frame("two")}`; + + enqueue(payload); + enqueue(`${begin}thr`); + enqueue(`ee${end}`); + enqueue(`${c1Begin}four${c1End}`); + + expect(joined()).toBe(`${payload}${frame("three")}${c1Begin}four${c1End}`); + expect(forwarded.every((chunk) => chunk.bytes === bytes(chunk.data))).toBe(true); + expect(gate.snapshot().completeFrames).toBe(4); + expect(gate.snapshot().framesSeen).toBe(4); + }); + + it("fails open for close without open, nested open, and malformed partial state", () => { + const { advance, enqueue, gate, joined } = createHarness(); + + enqueue(`${end}plain`); + enqueue(`${begin}first${begin}nested`); + advance(200); + + expect(joined()).toContain(`${end}plain`); + expect(joined()).toContain(`${begin}first${begin}nested`); + expect(gate.snapshot().failOpenCandidates).toBeGreaterThanOrEqual(2); + }); +}); + +describe("Dec2026FrameGate classification", () => { + it("accepts pure visual printable, SGR, cursor movement, erase, and safe C0", () => { + expect(classifyDec2026Frame("hello中文é😀\r\t\b\x1b[31m\x1b[2K\x1b[10;20H").kind).toBe( + "replaceable-visual", + ); + }); + + it("rejects stateful and unknown sequences conservatively", () => { + expect(classifyDec2026Frame("\x07").kind).toBe("stateful"); + expect(classifyDec2026Frame("\x1b]0;title\x07").kind).toBe("stateful"); + expect(classifyDec2026Frame("\x1bPpayload\x1b\\").kind).toBe("stateful"); + expect(classifyDec2026Frame("\x1b[?25l").kind).toBe("stateful"); + expect(classifyDec2026Frame("\x1b[?1049h").kind).toBe("stateful"); + expect(classifyDec2026Frame("\x1b[3J").kind).toBe("stateful"); + expect(classifyDec2026Frame("\x1b[S").kind).toBe("stateful"); + expect(classifyDec2026Frame("\x1b[999z").kind).toBe("unknown"); + }); + + it("requires reset, ED2 after reset, and home before printable for replacement proof", () => { + expect(successorProof(`${resetClearHome}new`)).toBe("self-contained-replacement"); + expect(successorProof("\x1b[2J\x1b[1;1Hnew")).toBe("none"); + expect(successorProof("\x1b[0m\x1b[1;1Hnew")).toBe("none"); + expect(successorProof("\x1b[0m\x1b[2Jnew")).toBe("none"); + expect(successorProof(`new${resetClearHome}`)).toBe("none"); + }); +}); + +describe("Dec2026FrameGate collapse behavior", () => { + it("forwards everything immediately below pressure threshold", () => { + const { acks, enqueue, gate, joined } = createHarness({ + queueBytes: XTERM_PERFORMANCE_CONFIG.output.alternateScreenThrottleBacklogBytes, + }); + + enqueue(frame("A")); + enqueue(frame(`${resetClearHome}B`)); + + expect(joined()).toBe(`${frame("A")}${frame(`${resetClearHome}B`)}`); + expect(acks).toEqual([]); + expect(gate.snapshot().droppedFrames).toBe(0); + }); + + it("drops a pure visual predecessor when the successor is self-contained under pressure", () => { + const a = frame("old visual"); + const b = frame(`${resetClearHome}new visual`); + const { acks, enqueue, gate, joined } = createHarness(); + + enqueue(a); + expect(gate.getHeldBytes()).toBe(bytes(a)); + expect(joined()).toBe(""); + + enqueue(b); + expect(acks).toEqual([bytes(a)]); + expect(gate.snapshot().droppedFrames).toBe(1); + + gate.flush("test"); + expect(joined()).toBe(b); + }); + + it("does not collapse across a barrier", () => { + const a = frame("old visual"); + const barrier = "\x1b]0;title\x07"; + const b = frame(`${resetClearHome}new visual`); + const { acks, enqueue, gate, joined } = createHarness(); + + enqueue(a); + enqueue(barrier); + enqueue(b); + gate.flush("test"); + + expect(acks).toEqual([]); + expect(joined()).toBe(`${a}${barrier}${b}`); + }); + + it("keeps exact UTF-8 accounting for Unicode collapsed frames", () => { + const samples = ["ASCII", "中文", "é", "e\u0301", "😀", "👨‍👩‍👧‍👦", "\x1b[31m中文😀"]; + + for (const sample of samples) { + const a = frame(sample); + const b = frame(`${resetClearHome}${sample}`); + const { acks, enqueue, forwardedBytes, gate } = createHarness(); + + enqueue(a); + expect(forwardedBytes() + acks.reduce((sum, count) => sum + count, 0) + gate.getHeldBytes()).toBe( + bytes(a), + ); + enqueue(b); + gate.flush("unicode-test"); + + expect(forwardedBytes() + acks.reduce((sum, count) => sum + count, 0)).toBe( + bytes(a) + bytes(b), + ); + } + }); + + it("fails open on timeout and max held bytes", () => { + const timeoutHarness = createHarness(); + timeoutHarness.enqueue(`${begin}unterminated`); + timeoutHarness.advance(200); + expect(timeoutHarness.joined()).toBe(`${begin}unterminated`); + + const capHarness = createHarness(); + const large = `${begin}${"x".repeat(512 * 1024)}`; + capHarness.enqueue(large); + expect(capHarness.joined()).toBe(large); + }); + + it("fails open instead of guessing when ingress byte accounting mismatches", () => { + const a = frame("中文"); + const { acks, enqueue, gate, joined } = createHarness(); + + enqueue(a, a.length); + + expect(joined()).toBe(a); + expect(acks).toEqual([]); + expect(gate.snapshot().failOpenCandidates).toBe(1); + }); + + it("flushes held output before lifecycle text", () => { + const a = frame("held"); + const lifecycle = "\r\n[session closed]\r\n"; + const { enqueue, forwarded, gate, joined } = createHarness(); + + enqueue(a); + gate.flush("session-close"); + forwarded.push({ data: lifecycle, bytes: bytes(lifecycle) }); + + expect(joined()).toBe(`${a}${lifecycle}`); + }); +}); diff --git a/src/components/terminal/dec2026FrameGate.ts b/src/components/terminal/dec2026FrameGate.ts new file mode 100644 index 00000000..e52145af --- /dev/null +++ b/src/components/terminal/dec2026FrameGate.ts @@ -0,0 +1,707 @@ +import { XTERM_PERFORMANCE_CONFIG } from "@/lib/xtermPerformance"; +import type { QueuedOutputChunk } from "./xterminalOutputQueue"; + +export type Dec2026FrameGateMode = "off" | "shadow" | "collapse"; + +export type Dec2026FrameClassification = + | { + kind: "replaceable-visual"; + bytes: number; + successorProof: "self-contained-replacement" | "none"; + } + | { + kind: "stateful"; + bytes: number; + reason: string; + } + | { + kind: "unknown"; + bytes: number; + reason: string; + }; + +export interface Dec2026FrameGateSnapshot { + mode: Dec2026FrameGateMode; + framesSeen: number; + completeFrames: number; + partialFrames: number; + candidateFrames: number; + replaceableFrames: number; + wouldDropFrames: number; + wouldDropBytes: number; + droppedFrames: number; + droppedBytes: number; + statefulFrames: number; + malformedFrames: number; + failOpenCandidates: number; + maxFrameBytes: number; + heldBytes: number; + lastRejectReason: string | null; + lastCandidateContext: Dec2026FrameCandidateContext | null; +} + +export interface Dec2026FrameCandidateContext { + outputDrainPendingBytes: number; + outputDrainQueueBytes: number; + frameGateHeldBytes: number; + alternateScreen: boolean; + performanceMode: string; +} + +interface Dec2026FrameGateOptions { + mode: Dec2026FrameGateMode; + forward: (chunk: QueuedOutputChunk) => void; + ackDropped: (bytes: number) => void; + getPressureSnapshot: () => Dec2026FrameCandidateContext; + onPressureChange?: () => void; + logDebug?: (event: string, message: string, data?: Record) => void; + setTimeout?: (callback: () => void, delay: number) => number; + clearTimeout?: (handle: number) => void; +} + +interface Dec2026CompleteFrame { + data: string; + content: string; + bytes: number; + classification: Dec2026FrameClassification; +} + +interface Dec2026Boundary { + index: number; + sequence: string; + kind: "begin" | "end"; +} + +const DEC2026_BEGIN_ESC = "\x1b[?2026h"; +const DEC2026_END_ESC = "\x1b[?2026l"; +const DEC2026_BEGIN_C1 = "\x9b?2026h"; +const DEC2026_END_C1 = "\x9b?2026l"; +const DEC2026_SEQUENCES = [ + DEC2026_BEGIN_ESC, + DEC2026_END_ESC, + DEC2026_BEGIN_C1, + DEC2026_END_C1, +] as const; + +const MAX_PENDING_CSI_CHARS = 64; +const DEFAULT_PARTIAL_FRAME_FAIL_OPEN_MS = 200; +const DEFAULT_MAX_HELD_FRAME_BYTES = 512 * 1024; + +const textEncoder = new TextEncoder(); + +function utf8ByteLength(text: string): number { + return textEncoder.encode(text).length; +} + +export function resolveDec2026FrameGateMode(): Dec2026FrameGateMode { + const requested = import.meta.env.VITE_NYATERM_DEC2026_FRAME_GATE; + if (requested === "off" || requested === "shadow" || requested === "collapse") { + return requested; + } + return import.meta.env.DEV ? "shadow" : "off"; +} + +function boundaryKind(sequence: string): "begin" | "end" { + return sequence.endsWith("h") ? "begin" : "end"; +} + +function findNextBoundary(text: string, startIndex: number): Dec2026Boundary | null { + let next: Dec2026Boundary | null = null; + for (const sequence of DEC2026_SEQUENCES) { + const index = text.indexOf(sequence, startIndex); + if (index < 0) continue; + if (!next || index < next.index || (index === next.index && sequence.length > next.sequence.length)) { + next = { index, sequence, kind: boundaryKind(sequence) }; + } + } + return next; +} + +function pendingBoundarySuffix(text: string): string { + const max = Math.min(MAX_PENDING_CSI_CHARS, text.length); + for (let length = max; length > 0; length -= 1) { + const suffix = text.slice(text.length - length); + if (DEC2026_SEQUENCES.some((sequence) => sequence.startsWith(suffix))) { + return suffix; + } + } + return ""; +} + +function parseCsi( + text: string, + index: number, +): { endIndex: number; params: string; intermediates: string; final: string; raw: string } | null { + const isC1 = text.charCodeAt(index) === 0x9b; + const start = isC1 ? index + 1 : index + 2; + let cursor = start; + let params = ""; + let intermediates = ""; + + while (cursor < text.length) { + const code = text.charCodeAt(cursor); + if (code >= 0x30 && code <= 0x3f && intermediates.length === 0) { + params += text[cursor]; + cursor += 1; + continue; + } + if (code >= 0x20 && code <= 0x2f) { + intermediates += text[cursor]; + cursor += 1; + continue; + } + if (code >= 0x40 && code <= 0x7e) { + const final = text[cursor]; + const raw = text.slice(index, cursor + 1); + return { endIndex: cursor + 1, params, intermediates, final, raw }; + } + return null; + } + + return null; +} + +function numericParams(params: string): string[] { + return params.length === 0 ? [] : params.split(";"); +} + +function hasPrivateMarker(params: string): boolean { + return params.includes("?") || params.includes(">") || params.includes("<") || params.includes("="); +} + +function isDeviceReport(final: string): boolean { + return final === "c" || final === "n"; +} + +function isAllowedCursorCsi(final: string, params: string): boolean { + if (!"ABCDEFGHfd`".includes(final)) return false; + if (hasPrivateMarker(params)) return false; + return numericParams(params).every((param) => param === "" || /^\d+$/u.test(param)); +} + +function isAllowedEraseCsi(final: string, params: string): boolean { + if (final !== "J" && final !== "K") return false; + if (hasPrivateMarker(params)) return false; + const parts = numericParams(params); + if (!parts.every((param) => param === "" || /^\d+$/u.test(param))) return false; + if (final === "J" && parts.some((param) => param === "3")) return false; + return true; +} + +function isSgrReset(csi: { params: string; final: string; raw: string }): boolean { + return csi.final === "m" && csi.params === "0" && csi.raw.endsWith("0m"); +} + +function isEd2(csi: { params: string; final: string }): boolean { + return csi.final === "J" && csi.params === "2"; +} + +function isHome(csi: { params: string; final: string }): boolean { + if (csi.final === "H") { + return csi.params === "" || csi.params === "1;1"; + } + return csi.final === "f" && csi.params === "1;1"; +} + +function classifyCsi(csi: { + params: string; + intermediates: string; + final: string; +}): "allowed" | { kind: "stateful" | "unknown"; reason: string } { + if (csi.intermediates.length > 0) { + return { kind: "unknown", reason: "csi-intermediate" }; + } + if (isDeviceReport(csi.final)) { + return { kind: "stateful", reason: "device-report" }; + } + if (csi.final === "h" || csi.final === "l") { + return { kind: "stateful", reason: "mode-change" }; + } + if (csi.final === "r") { + return { kind: "stateful", reason: "scroll-region" }; + } + if ("@LMP".includes(csi.final)) { + return { kind: "stateful", reason: "insert-delete" }; + } + if (csi.final === "S" || csi.final === "T") { + return { kind: "stateful", reason: "scroll-up-down" }; + } + if (csi.final === "m") { + return hasPrivateMarker(csi.params) + ? { kind: "unknown", reason: "private-sgr" } + : "allowed"; + } + if (isAllowedCursorCsi(csi.final, csi.params)) return "allowed"; + if (csi.final === "J" && numericParams(csi.params).some((param) => param === "3")) { + return { kind: "stateful", reason: "clear-scrollback" }; + } + if (isAllowedEraseCsi(csi.final, csi.params)) { + return "allowed"; + } + return { kind: "unknown", reason: `unknown-csi-${csi.final}` }; +} + +export function classifyDec2026Frame( + content: string, + frameBytes = utf8ByteLength(content), +): Dec2026FrameClassification { + let sawResetBeforeEd2 = false; + let sawEd2AfterReset = false; + let sawHomeBeforePrintable = false; + let sawPrintable = false; + + for (let index = 0; index < content.length; ) { + const code = content.charCodeAt(index); + + if (code === 0x1b) { + const next = content[index + 1]; + if (next === "[") { + const csi = parseCsi(content, index); + if (!csi) return { kind: "unknown", bytes: frameBytes, reason: "malformed-csi" }; + const result = classifyCsi(csi); + if (result !== "allowed") { + return { kind: result.kind, bytes: frameBytes, reason: result.reason }; + } + if (!sawPrintable) { + if (isSgrReset(csi)) sawResetBeforeEd2 = true; + if (sawResetBeforeEd2 && isEd2(csi)) sawEd2AfterReset = true; + if (isHome(csi)) sawHomeBeforePrintable = true; + } + index = csi.endIndex; + continue; + } + if (next === "]") return { kind: "stateful", bytes: frameBytes, reason: "osc" }; + if (next === "P") return { kind: "stateful", bytes: frameBytes, reason: "dcs" }; + if (next === "_") return { kind: "stateful", bytes: frameBytes, reason: "apc" }; + if (next === "^") return { kind: "stateful", bytes: frameBytes, reason: "pm" }; + if (next === "X") return { kind: "stateful", bytes: frameBytes, reason: "sos" }; + if (next === "c") return { kind: "stateful", bytes: frameBytes, reason: "ris" }; + return { kind: "unknown", bytes: frameBytes, reason: "unknown-esc" }; + } + + if (code === 0x9b) { + const csi = parseCsi(content, index); + if (!csi) return { kind: "unknown", bytes: frameBytes, reason: "malformed-c1-csi" }; + const result = classifyCsi(csi); + if (result !== "allowed") { + return { kind: result.kind, bytes: frameBytes, reason: result.reason }; + } + if (!sawPrintable) { + if (isSgrReset(csi)) sawResetBeforeEd2 = true; + if (sawResetBeforeEd2 && isEd2(csi)) sawEd2AfterReset = true; + if (isHome(csi)) sawHomeBeforePrintable = true; + } + index = csi.endIndex; + continue; + } + + if (code === 0x9d) return { kind: "stateful", bytes: frameBytes, reason: "c1-osc" }; + if (code === 0x90) return { kind: "stateful", bytes: frameBytes, reason: "c1-dcs" }; + if (code === 0x9f) return { kind: "stateful", bytes: frameBytes, reason: "c1-apc" }; + if (code === 0x9e) return { kind: "stateful", bytes: frameBytes, reason: "c1-pm" }; + if (code === 0x98) return { kind: "stateful", bytes: frameBytes, reason: "c1-sos" }; + + if (code === 0x0d || code === 0x09 || code === 0x08) { + index += 1; + continue; + } + if (code === 0x0a) return { kind: "stateful", bytes: frameBytes, reason: "lf" }; + if (code === 0x07) return { kind: "stateful", bytes: frameBytes, reason: "bel" }; + if (code < 0x20 || code === 0x7f || (code >= 0x80 && code <= 0x9f)) { + return { kind: "unknown", bytes: frameBytes, reason: "unknown-control" }; + } + + sawPrintable = true; + const codePoint = content.codePointAt(index) ?? code; + index += codePoint > 0xffff ? 2 : 1; + } + + return { + kind: "replaceable-visual", + bytes: frameBytes, + successorProof: + sawResetBeforeEd2 && sawEd2AfterReset && sawHomeBeforePrintable + ? "self-contained-replacement" + : "none", + }; +} + +export class Dec2026FrameGate { + private readonly setTimer: (callback: () => void, delay: number) => number; + private readonly clearTimer: (handle: number) => void; + private pendingPrefix = ""; + private currentFrameData = ""; + private currentFrameContent = ""; + private heldFrame: Dec2026CompleteFrame | null = null; + private shadowPendingPrefix = ""; + private shadowCurrentFrameData = ""; + private shadowCurrentFrameContent = ""; + private shadowHeldFrame: Dec2026CompleteFrame | null = null; + private failOpenTimer: number | null = null; + private disposed = false; + private snapshotState: Dec2026FrameGateSnapshot; + + constructor(private readonly options: Dec2026FrameGateOptions) { + this.setTimer = options.setTimeout ?? ((callback, delay) => window.setTimeout(callback, delay)); + this.clearTimer = options.clearTimeout ?? ((handle) => window.clearTimeout(handle)); + this.snapshotState = { + mode: options.mode, + framesSeen: 0, + completeFrames: 0, + partialFrames: 0, + candidateFrames: 0, + replaceableFrames: 0, + wouldDropFrames: 0, + wouldDropBytes: 0, + droppedFrames: 0, + droppedBytes: 0, + statefulFrames: 0, + malformedFrames: 0, + failOpenCandidates: 0, + maxFrameBytes: 0, + heldBytes: 0, + lastRejectReason: null, + lastCandidateContext: null, + }; + } + + enqueue(chunk: QueuedOutputChunk) { + if (this.disposed || chunk.bytes <= 0 || !chunk.data) return; + if (chunk.bytes !== utf8ByteLength(chunk.data)) { + this.failOpen("byte-mismatch"); + this.options.forward(chunk); + return; + } + if (this.options.mode === "off") { + this.options.forward(chunk); + return; + } + if (this.options.mode === "shadow") { + this.scanShadow(chunk.data); + this.options.forward(chunk); + return; + } + if (!this.canCollapseNow()) { + this.scanShadow(chunk.data); + this.flush("pressure-open"); + this.options.forward(chunk); + return; + } + this.processCollapseText(chunk.data); + this.updateHeldBytes(); + } + + flush(reason = "flush") { + if (this.disposed) return; + this.clearFailOpenTimer(); + this.forwardText(this.pendingPrefix); + this.pendingPrefix = ""; + this.forwardText(this.currentFrameData); + this.currentFrameData = ""; + this.currentFrameContent = ""; + if (this.heldFrame) { + this.forwardText(this.heldFrame.data); + this.heldFrame = null; + } + this.options.logDebug?.("terminal.dec2026_frame_gate.flush", "Flushed DEC 2026 frame gate", { + reason, + }); + this.updateHeldBytes(); + } + + dispose(options: { ackRemaining?: boolean; reason?: string } = {}) { + if (this.disposed) return; + this.disposed = true; + this.clearFailOpenTimer(); + const remainingBytes = this.getHeldBytes(); + if (options.ackRemaining && remainingBytes > 0) { + this.options.ackDropped(remainingBytes); + this.options.logDebug?.( + "terminal.dec2026_frame_gate.teardown_ack", + "ACKed gate-owned bytes during terminal teardown", + { reason: options.reason ?? "dispose", bytes: remainingBytes }, + ); + } else { + this.forwardText(this.pendingPrefix); + this.forwardText(this.currentFrameData); + if (this.heldFrame) this.forwardText(this.heldFrame.data); + } + this.pendingPrefix = ""; + this.currentFrameData = ""; + this.currentFrameContent = ""; + this.heldFrame = null; + this.shadowPendingPrefix = ""; + this.shadowCurrentFrameData = ""; + this.shadowCurrentFrameContent = ""; + this.shadowHeldFrame = null; + this.updateHeldBytes(); + } + + reset() { + this.clearFailOpenTimer(); + this.pendingPrefix = ""; + this.currentFrameData = ""; + this.currentFrameContent = ""; + this.heldFrame = null; + this.shadowPendingPrefix = ""; + this.shadowCurrentFrameData = ""; + this.shadowCurrentFrameContent = ""; + this.shadowHeldFrame = null; + this.updateHeldBytes(); + } + + getHeldBytes() { + return ( + utf8ByteLength(this.pendingPrefix) + + utf8ByteLength(this.currentFrameData) + + (this.heldFrame?.bytes ?? 0) + ); + } + + snapshot(): Dec2026FrameGateSnapshot { + this.updateHeldBytes(); + return { ...this.snapshotState }; + } + + private scanShadow(data: string) { + const previousPrefix = this.pendingPrefix; + const previousFrameData = this.currentFrameData; + const previousFrameContent = this.currentFrameContent; + const previousHeldFrame = this.heldFrame; + const previousTimer = this.failOpenTimer; + this.failOpenTimer = null; + this.pendingPrefix = this.shadowPendingPrefix; + this.currentFrameData = this.shadowCurrentFrameData; + this.currentFrameContent = this.shadowCurrentFrameContent; + this.processCollapseText(data, true); + this.shadowPendingPrefix = this.pendingPrefix; + this.shadowCurrentFrameData = this.currentFrameData; + this.shadowCurrentFrameContent = this.currentFrameContent; + this.pendingPrefix = previousPrefix; + this.currentFrameData = previousFrameData; + this.currentFrameContent = previousFrameContent; + this.heldFrame = previousHeldFrame; + this.failOpenTimer = previousTimer; + this.updateHeldBytes(); + } + + private processCollapseText(data: string, shadow = false) { + let text = `${this.pendingPrefix}${data}`; + this.pendingPrefix = ""; + + while (text.length > 0) { + if (this.currentFrameData) { + const boundary = findNextBoundary(text, 0); + if (!boundary) { + this.appendCurrentFrame(text, shadow); + text = ""; + break; + } + const before = text.slice(0, boundary.index); + this.appendCurrentFrame(before, shadow); + if (boundary.kind === "begin") { + this.snapshotState.malformedFrames += 1; + this.snapshotState.failOpenCandidates += 1; + if (shadow) { + this.shadowHeldFrame = null; + } else { + this.flush("nested-open"); + this.forwardText(boundary.sequence); + } + text = text.slice(boundary.index + boundary.sequence.length); + continue; + } + this.currentFrameData += boundary.sequence; + const frameData = this.currentFrameData; + const frameContent = this.currentFrameContent; + this.currentFrameData = ""; + this.currentFrameContent = ""; + this.handleCompleteFrame(frameData, frameContent, shadow); + text = text.slice(boundary.index + boundary.sequence.length); + continue; + } + + const boundary = findNextBoundary(text, 0); + if (!boundary) { + const suffix = pendingBoundarySuffix(text); + const barrier = suffix ? text.slice(0, -suffix.length) : text; + this.handleBarrier(barrier, shadow); + this.pendingPrefix = suffix; + text = ""; + break; + } + + const before = text.slice(0, boundary.index); + this.handleBarrier(before, shadow); + if (boundary.kind === "end") { + this.snapshotState.failOpenCandidates += 1; + this.handleBarrier(boundary.sequence, shadow); + text = text.slice(boundary.index + boundary.sequence.length); + continue; + } + this.snapshotState.framesSeen += 1; + this.snapshotState.partialFrames += 1; + if (shadow) { + this.currentFrameData = boundary.sequence; + this.currentFrameContent = ""; + } else { + this.currentFrameData = boundary.sequence; + this.currentFrameContent = ""; + this.scheduleFailOpenTimer(); + } + text = text.slice(boundary.index + boundary.sequence.length); + } + + if (!shadow && this.getHeldBytes() >= DEFAULT_MAX_HELD_FRAME_BYTES) { + this.failOpen("held-byte-cap"); + } + } + + private appendCurrentFrame(text: string, shadow: boolean) { + if (!text) return; + this.currentFrameData += text; + this.currentFrameContent += text; + if (!shadow && utf8ByteLength(this.currentFrameData) >= DEFAULT_MAX_HELD_FRAME_BYTES) { + this.failOpen("partial-byte-cap"); + } + } + + private handleBarrier(text: string, shadow: boolean) { + if (!text) return; + if (shadow) { + this.shadowHeldFrame = null; + return; + } + this.flush("barrier"); + this.forwardText(text); + } + + private handleCompleteFrame(frameData: string, frameContent: string, shadow: boolean) { + const bytes = utf8ByteLength(frameData); + const contentBytes = utf8ByteLength(frameContent); + const classification = classifyDec2026Frame(frameContent, bytes); + const frame = { data: frameData, content: frameContent, bytes, classification }; + this.snapshotState.completeFrames += 1; + this.snapshotState.maxFrameBytes = Math.max(this.snapshotState.maxFrameBytes, bytes); + this.recordClassification(classification, contentBytes); + + if (shadow) { + this.simulateShadowFrame(frame); + return; + } + + if (classification.kind !== "replaceable-visual") { + this.snapshotState.lastRejectReason = classification.reason; + this.flush("stateful-frame"); + this.forwardText(frame.data); + return; + } + + this.snapshotState.lastCandidateContext = this.options.getPressureSnapshot(); + if (this.heldFrame) { + if (classification.successorProof === "self-contained-replacement") { + this.dropHeldFrame("successor-replacement"); + } else { + this.forwardText(this.heldFrame.data); + this.heldFrame = null; + } + } + this.heldFrame = frame; + this.scheduleFailOpenTimer(); + this.updateHeldBytes(); + } + + private simulateShadowFrame(frame: Dec2026CompleteFrame) { + if (frame.classification.kind !== "replaceable-visual") { + this.shadowHeldFrame = null; + return; + } + this.snapshotState.lastCandidateContext = this.options.getPressureSnapshot(); + if ( + this.canCollapseNow() && + this.shadowHeldFrame && + frame.classification.successorProof === "self-contained-replacement" + ) { + this.snapshotState.wouldDropFrames += 1; + this.snapshotState.wouldDropBytes += this.shadowHeldFrame.bytes; + } + this.shadowHeldFrame = this.canCollapseNow() ? frame : null; + } + + private recordClassification(classification: Dec2026FrameClassification, contentBytes: number) { + if (classification.kind === "replaceable-visual") { + this.snapshotState.candidateFrames += 1; + if (classification.successorProof === "self-contained-replacement") { + this.snapshotState.replaceableFrames += 1; + } + return; + } + this.snapshotState.lastRejectReason = classification.reason; + if (classification.kind === "stateful") { + this.snapshotState.statefulFrames += 1; + return; + } + this.snapshotState.malformedFrames += contentBytes > MAX_PENDING_CSI_CHARS ? 1 : 0; + } + + private canCollapseNow() { + if (this.options.mode !== "collapse") return false; + const pressure = this.options.getPressureSnapshot(); + return ( + pressure.alternateScreen && + pressure.outputDrainQueueBytes + pressure.frameGateHeldBytes > + XTERM_PERFORMANCE_CONFIG.output.alternateScreenThrottleBacklogBytes + ); + } + + private dropHeldFrame(reason: string) { + if (!this.heldFrame) return; + const bytes = this.heldFrame.bytes; + this.options.ackDropped(bytes); + this.snapshotState.wouldDropFrames += 1; + this.snapshotState.wouldDropBytes += bytes; + this.snapshotState.droppedFrames += 1; + this.snapshotState.droppedBytes += bytes; + this.options.logDebug?.( + "terminal.dec2026_frame_gate.drop", + "Dropped stale DEC 2026 frame", + { reason, bytes }, + ); + this.heldFrame = null; + this.updateHeldBytes(); + } + + private failOpen(reason: string) { + this.snapshotState.failOpenCandidates += 1; + this.options.logDebug?.( + "terminal.dec2026_frame_gate.fail_open", + "Fail-open forwarded DEC 2026 frame gate data", + { reason, held_bytes: this.getHeldBytes() }, + ); + this.flush(reason); + } + + private forwardText(text: string) { + if (!text) return; + this.options.forward({ data: text, bytes: utf8ByteLength(text) }); + } + + private scheduleFailOpenTimer() { + this.clearFailOpenTimer(); + this.failOpenTimer = this.setTimer(() => { + this.failOpenTimer = null; + this.failOpen("timeout"); + }, DEFAULT_PARTIAL_FRAME_FAIL_OPEN_MS); + } + + private clearFailOpenTimer() { + if (this.failOpenTimer === null) return; + this.clearTimer(this.failOpenTimer); + this.failOpenTimer = null; + } + + private updateHeldBytes() { + this.snapshotState.heldBytes = this.getHeldBytes(); + this.options.onPressureChange?.(); + } +} diff --git a/src/components/terminal/dec2026FrameGateBenchmark.test.ts b/src/components/terminal/dec2026FrameGateBenchmark.test.ts new file mode 100644 index 00000000..876eaaf8 --- /dev/null +++ b/src/components/terminal/dec2026FrameGateBenchmark.test.ts @@ -0,0 +1,152 @@ +import { describe, expect, it } from "vitest"; +import { XTERM_PERFORMANCE_CONFIG } from "@/lib/xtermPerformance"; +import { + Dec2026FrameGate, + type Dec2026FrameGateMode, +} from "./dec2026FrameGate"; +import type { QueuedOutputChunk } from "./xterminalOutputQueue"; + +const encoder = new TextEncoder(); +const begin = "\x1b[?2026h"; +const end = "\x1b[?2026l"; +const resetClearHome = "\x1b[0m\x1b[2J\x1b[1;1H"; + +interface BenchmarkMetrics { + maxOutputDrainQueueBytes: number; + maxFrameGateHeldBytes: number; + maxTotalFrontendPendingBytes: number; + framesReceived: number; + framesRendered: number; + framesCollapsed: number; + bytesCollapsed: number; + xtermWriteCount: number; + maxWriteCallbackLatencyMs: number; + longestForegroundStallMs: number; + severeFallbackTicks: number; + newestFrameWriteLagMs: number; +} + +function bytes(text: string) { + return encoder.encode(text).length; +} + +function makeFrame(index: number, safe: boolean) { + const body = `${String(index).padStart(4, "0")} ${"abcdef0123456789".repeat(128)}`; + if (safe) return `${begin}${resetClearHome}${body}${end}`; + if (index % 4 === 0) return `${begin}\x1b]0;title-${index}\x07${body}${end}`; + if (index % 4 === 1) return `${begin}\x1b[?25l${body}${end}`; + if (index % 4 === 2) return `${begin}\x1b[999z${body}${end}`; + return `${begin}${body}${end}`; +} + +function makeCorpus(safe: boolean, frameCount = 180) { + return Array.from({ length: frameCount }, (_, index) => makeFrame(index, safe)); +} + +function runSynthetic(mode: Dec2026FrameGateMode, corpus: string[]): BenchmarkMetrics { + let now = 0; + let outputDrainQueueBytes = XTERM_PERFORMANCE_CONFIG.output.alternateScreenThrottleBacklogBytes + 1; + let maxOutputDrainQueueBytes = outputDrainQueueBytes; + let maxFrameGateHeldBytes = 0; + let maxTotalFrontendPendingBytes = outputDrainQueueBytes; + let xtermWriteCount = 0; + let framesRendered = 0; + let maxWriteCallbackLatencyMs = 0; + let longestForegroundStallMs = 0; + let severeFallbackTicks = 0; + let newestFrameWriteLagMs = 0; + const frameArrivalTimes = new Map(); + + const writes: QueuedOutputChunk[] = []; + const acks: number[] = []; + let gate!: Dec2026FrameGate; + gate = new Dec2026FrameGate({ + mode, + forward: (chunk) => { + writes.push(chunk); + outputDrainQueueBytes += chunk.bytes; + maxOutputDrainQueueBytes = Math.max(maxOutputDrainQueueBytes, outputDrainQueueBytes); + const writeLatency = Math.min(24, Math.ceil(chunk.bytes / 4096)); + now += writeLatency; + xtermWriteCount += 1; + maxWriteCallbackLatencyMs = Math.max(maxWriteCallbackLatencyMs, writeLatency); + longestForegroundStallMs = Math.max(longestForegroundStallMs, writeLatency); + if ( + outputDrainQueueBytes > + XTERM_PERFORMANCE_CONFIG.output.alternateScreenThrottleBacklogBytes + ) { + severeFallbackTicks += 1; + } + outputDrainQueueBytes = Math.max(0, outputDrainQueueBytes - chunk.bytes); + framesRendered += (chunk.data.match(/\x1b\[\?2026l/g) ?? []).length; + const match = /(\d{4})/u.exec(chunk.data); + if (match) { + const index = Number(match[1]); + newestFrameWriteLagMs = Math.max( + newestFrameWriteLagMs, + now - (frameArrivalTimes.get(index) ?? now), + ); + } + }, + ackDropped: (count) => acks.push(count), + getPressureSnapshot: () => ({ + alternateScreen: true, + outputDrainQueueBytes, + outputDrainPendingBytes: outputDrainQueueBytes, + frameGateHeldBytes: gate.getHeldBytes(), + performanceMode: "strained", + }), + onPressureChange: () => { + maxFrameGateHeldBytes = Math.max(maxFrameGateHeldBytes, gate.getHeldBytes()); + maxTotalFrontendPendingBytes = Math.max( + maxTotalFrontendPendingBytes, + outputDrainQueueBytes + gate.getHeldBytes(), + ); + }, + setTimeout: () => 0, + clearTimeout: () => {}, + }); + + corpus.forEach((data, index) => { + frameArrivalTimes.set(index, now); + gate.enqueue({ data, bytes: bytes(data) }); + now += 1000 / 60; + }); + gate.flush("benchmark-end"); + + const snapshot = gate.snapshot(); + return { + maxOutputDrainQueueBytes, + maxFrameGateHeldBytes, + maxTotalFrontendPendingBytes, + framesReceived: corpus.length, + framesRendered, + framesCollapsed: snapshot.droppedFrames, + bytesCollapsed: acks.reduce((sum, count) => sum + count, 0), + xtermWriteCount, + maxWriteCallbackLatencyMs, + longestForegroundStallMs, + severeFallbackTicks, + newestFrameWriteLagMs, + }; +} + +describe("DEC 2026 synthetic frame gate benchmark", () => { + it("collapses only the safe self-contained corpus under pressure", () => { + const safeCorpus = makeCorpus(true); + const unsafeCorpus = makeCorpus(false); + const baselineSafe = runSynthetic("off", safeCorpus); + const shadowSafe = runSynthetic("shadow", safeCorpus); + const collapseSafe = runSynthetic("collapse", safeCorpus); + const collapseUnsafe = runSynthetic("collapse", unsafeCorpus); + + expect(shadowSafe.framesCollapsed).toBe(0); + expect(shadowSafe.framesRendered).toBe(baselineSafe.framesRendered); + expect(collapseSafe.framesCollapsed).toBeGreaterThan(0); + expect(collapseSafe.bytesCollapsed).toBeGreaterThan(0); + expect(collapseSafe.xtermWriteCount).toBeLessThan(baselineSafe.xtermWriteCount); + expect(collapseUnsafe.framesCollapsed).toBe(0); + expect(collapseUnsafe.framesRendered).toBe(unsafeCorpus.length); + expect(collapseSafe.maxFrameGateHeldBytes).toBeLessThan(512 * 1024); + }); +}); diff --git a/src/components/terminal/zmodemTerminalEvents.ts b/src/components/terminal/zmodemTerminalEvents.ts index 2373ec6b..700dc517 100644 --- a/src/components/terminal/zmodemTerminalEvents.ts +++ b/src/components/terminal/zmodemTerminalEvents.ts @@ -55,6 +55,8 @@ export interface ZmodemTransferProgressSink { fail: (id: string, reason: string) => void; } +type TerminalStatusWriter = (data: string) => void; + interface CurrentZmodemTransferFile { id: string; fileName: string; @@ -91,6 +93,7 @@ export function createZmodemEventHandler( getT: () => Translate, getDuplicateStrategy: () => string = () => "ask", progressSink?: ZmodemTransferProgressSink, + writeTerminalStatus: TerminalStatusWriter = (data) => terminal.write(data), ): ZmodemEventHandler { let pendingProgress: Extract | null = null; let progressRaf: number | null = null; @@ -146,7 +149,7 @@ export function createZmodemEventHandler( const totalSize = payload.totalSize ?? payload.total_size ?? 0; const percent = totalSize > 0 ? Math.round((bytesTransferred / totalSize) * 100) : 0; const t = getT(); - terminal.write(`\r\x1b[36m[ZMODEM] ${t("zmodem.downloading", { fileName, percent })}\x1b[K`); + writeTerminalStatus(`\r\x1b[36m[ZMODEM] ${t("zmodem.downloading", { fileName, percent })}\x1b[K`); }; const scheduleProgressRender = () => { @@ -272,7 +275,7 @@ export function createZmodemEventHandler( if (disposed) return; if (payload.direction === "download") { - terminal.write(`\r\n\x1b[36m[ZMODEM] ${t("zmodem.selectSaveDir")}\x1b[0m\r\n`); + writeTerminalStatus(`\r\n\x1b[36m[ZMODEM] ${t("zmodem.selectSaveDir")}\x1b[0m\r\n`); const dir = await openDialog({ directory: true, multiple: false }); if (disposed) return; if (dir) { @@ -282,7 +285,7 @@ export function createZmodemEventHandler( }); } else { await invoke("zmodem_cancel", { sessionId }); - terminal.write(`\r\n\x1b[33m[ZMODEM] ${t("zmodem.cancelled")}\x1b[0m\r\n`); + writeTerminalStatus(`\r\n\x1b[33m[ZMODEM] ${t("zmodem.cancelled")}\x1b[0m\r\n`); } return; } @@ -324,7 +327,7 @@ export function createZmodemEventHandler( if (resolvedPaths.length === 0) { await invoke("zmodem_cancel", { sessionId }); - terminal.write(`\r\n\x1b[33m[ZMODEM] ${t("zmodem.cancelled")}\x1b[0m\r\n`); + writeTerminalStatus(`\r\n\x1b[33m[ZMODEM] ${t("zmodem.cancelled")}\x1b[0m\r\n`); return; } @@ -340,7 +343,7 @@ export function createZmodemEventHandler( }); } else { await invoke("zmodem_cancel", { sessionId }); - terminal.write(`\r\n\x1b[33m[ZMODEM] ${t("zmodem.cancelled")}\x1b[0m\r\n`); + writeTerminalStatus(`\r\n\x1b[33m[ZMODEM] ${t("zmodem.cancelled")}\x1b[0m\r\n`); } }; @@ -375,7 +378,7 @@ export function createZmodemEventHandler( showUploadCompletedToast(); completePendingZmodemUpload(sessionId); } else { - terminal.write(`\r\n\x1b[32m[ZMODEM] ${getT()("zmodem.complete")}\x1b[0m\r\n`); + writeTerminalStatus(`\r\n\x1b[32m[ZMODEM] ${getT()("zmodem.complete")}\x1b[0m\r\n`); if (lastDownloadLocalPath) { void revealDownloadedFile(lastDownloadLocalPath); } @@ -404,7 +407,7 @@ export function createZmodemEventHandler( } failPendingZmodemUpload(sessionId, normalizedPayload.reason); } else { - terminal.write( + writeTerminalStatus( `\r\n\x1b[31m[ZMODEM] ${getT()("zmodem.failed", { reason: normalizedPayload.reason, })}\x1b[0m\r\n`, From 2e02fcd58431e4f52f9136c8bf7182c9ead583b4 Mon Sep 17 00:00:00 2001 From: litcc Date: Fri, 14 Aug 2026 23:28:10 +0800 Subject: [PATCH 06/11] fix(context-menu): prevent right-click from activating menu items Block secondary pointerup events when a context menu overlaps the cursor, including macOS Control-click, while preserving normal left-click behavior. --- src/components/ui/context-menu.tsx | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/components/ui/context-menu.tsx b/src/components/ui/context-menu.tsx index 1af6dbaf..0301abd5 100644 --- a/src/components/ui/context-menu.tsx +++ b/src/components/ui/context-menu.tsx @@ -2,6 +2,7 @@ import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"; import { ContextMenu as ContextMenuPrimitive } from "radix-ui"; import type * as React from "react"; +import { isMacOS } from "@/lib/platform"; import { cn } from "@/lib/utils"; function ContextMenu({ ...props }: React.ComponentProps) { @@ -74,8 +75,19 @@ function ContextMenuSubContent({ function ContextMenuContent({ className, + onPointerUpCapture, ...props }: React.ComponentProps) { + const handlePointerUpCapture = (event: React.PointerEvent) => { + // Prevent Radix from converting a secondary pointerup into a click when the menu covers the cursor before right-button release. + if (event.button !== 0 || (isMacOS && event.ctrlKey)) { + event.preventDefault(); + event.stopPropagation(); + return; + } + onPointerUpCapture?.(event); + }; + return ( ); From a29c5d04251dadfc04b1f8402fc055344538eedf Mon Sep 17 00:00:00 2001 From: litcc Date: Sat, 15 Aug 2026 08:47:59 +0800 Subject: [PATCH 07/11] fix(saved-connections): prevent duplicate folder submissions Guard folder creation and rename against rapid Enter/click events. Disable dialog controls while submitting and prevent Enter's default action. Add a regression test for same-turn Enter and Save submissions. --- .../dialog/connections/FolderDialog.test.tsx | 37 +++++++++++++++++++ .../dialog/connections/FolderDialog.tsx | 34 +++++++++++++++-- 2 files changed, 67 insertions(+), 4 deletions(-) create mode 100644 src/components/dialog/connections/FolderDialog.test.tsx diff --git a/src/components/dialog/connections/FolderDialog.test.tsx b/src/components/dialog/connections/FolderDialog.test.tsx new file mode 100644 index 00000000..a1423de0 --- /dev/null +++ b/src/components/dialog/connections/FolderDialog.test.tsx @@ -0,0 +1,37 @@ +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import FolderDialog from "./FolderDialog"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +function renderFolderDialog(onSubmit: () => void, open = true) { + return render( + , + ); +} + +describe("FolderDialog", () => { + it("submits only once when Enter and Save happen in the same turn", () => { + const onSubmit = vi.fn(); + renderFolderDialog(onSubmit); + + const input = screen.getByRole("textbox"); + const saveButton = screen.getByRole("button", { name: "dialog.save" }); + + act(() => { + fireEvent.keyDown(input, { key: "Enter" }); + fireEvent.click(saveButton); + }); + + expect(onSubmit).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/components/dialog/connections/FolderDialog.tsx b/src/components/dialog/connections/FolderDialog.tsx index 9d3fec89..2e5d3cc3 100644 --- a/src/components/dialog/connections/FolderDialog.tsx +++ b/src/components/dialog/connections/FolderDialog.tsx @@ -1,3 +1,4 @@ +import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { Button } from "@/components/ui/button"; import { @@ -28,9 +29,29 @@ export default function FolderDialog({ onCancel, }: FolderDialogProps) { const { t } = useTranslation(); + const [isSubmitting, setIsSubmitting] = useState(false); + const submitInFlightRef = useRef(false); + + useEffect(() => { + if (!open) { + submitInFlightRef.current = false; + setIsSubmitting(false); + } + }, [open]); + + const handleSubmit = () => { + if (!name.trim() || submitInFlightRef.current) return; + submitInFlightRef.current = true; + setIsSubmitting(true); + onSubmit(); + }; return ( - !v && onCancel()}> + !v && !submitInFlightRef.current && onCancel()} + > @@ -46,15 +67,20 @@ export default function FolderDialog({ placeholder={t("savedConnections.folderNamePlaceholder")} value={name} onChange={(e) => onNameChange(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && onSubmit()} + onKeyDown={(e) => { + if (e.key !== "Enter") return; + e.preventDefault(); + handleSubmit(); + }} + disabled={isSubmitting} autoFocus />
- - From 325d07905c9dae8608acb864960dbd0d4f0b5808 Mon Sep 17 00:00:00 2001 From: Kang Date: Sat, 15 Aug 2026 15:39:22 +0800 Subject: [PATCH 08/11] feat(child-window): enhance command queue with failure handling and recovery - Updated `ChildWindowCommandQueue` to manage command states using a status system (`loading`, `ready`, `failed`) instead of a boolean flag. - Implemented methods to mark commands as failed, drop queued commands, and recover from failed states when new tokens are registered. - Added tests to verify the behavior of failure handling and recovery scenarios in the command queue. - Enhanced `windowManager` to close failed child windows and clear their lifecycle appropriately. --- src/lib/childWindowCommandQueue.test.ts | 41 ++++ src/lib/childWindowCommandQueue.ts | 25 ++- src/lib/windowManager.test.ts | 257 ++++++++++++++++++++++-- src/lib/windowManager.ts | 48 ++++- 4 files changed, 343 insertions(+), 28 deletions(-) diff --git a/src/lib/childWindowCommandQueue.test.ts b/src/lib/childWindowCommandQueue.test.ts index e3afcfd6..b91b0ecb 100644 --- a/src/lib/childWindowCommandQueue.test.ts +++ b/src/lib/childWindowCommandQueue.test.ts @@ -75,4 +75,45 @@ describe("ChildWindowCommandQueue", () => { queue.dispatch("settings", CHILD_WINDOW_COMMANDS.settingsOpenTab, { tab: "general" }), ).toEqual([{ event: CHILD_WINDOW_COMMANDS.settingsOpenTab, payload: { tab: "general" } }]); }); + + it("marks a matching token as failed and drops queued commands", () => { + const queue = new ChildWindowCommandQueue(); + queue.register("settings", "token", CHILD_WINDOW_COMMANDS.settingsOpenTab); + queue.dispatch("settings", CHILD_WINDOW_COMMANDS.settingsOpenTab, { tab: "appearance" }); + + expect(queue.markFailed("settings", "token")).toBe(true); + expect(queue.isFailed("settings", "token")).toBe(true); + expect( + queue.dispatch("settings", CHILD_WINDOW_COMMANDS.settingsOpenTab, { tab: "general" }), + ).toEqual([]); + expect(queue.markReady("settings", "token", CHILD_WINDOW_COMMANDS.settingsOpenTab)).toEqual([]); + }); + + it("recovers from a failed state when a new token is registered", () => { + const queue = new ChildWindowCommandQueue(); + queue.register("settings", "token-old", CHILD_WINDOW_COMMANDS.settingsOpenTab); + queue.dispatch("settings", CHILD_WINDOW_COMMANDS.settingsOpenTab, { tab: "appearance" }); + queue.markFailed("settings", "token-old"); + + queue.register("settings", "token-new", CHILD_WINDOW_COMMANDS.settingsOpenTab); + expect(queue.isFailed("settings", "token-new")).toBe(false); + expect( + queue.dispatch("settings", CHILD_WINDOW_COMMANDS.settingsOpenTab, { tab: "general" }), + ).toEqual([]); + expect(queue.markReady("settings", "token-new", CHILD_WINDOW_COMMANDS.settingsOpenTab)).toEqual([ + { event: CHILD_WINDOW_COMMANDS.settingsOpenTab, payload: { tab: "general" } }, + ]); + }); + + it("ignores a failed event from a stale WebView token", () => { + const queue = new ChildWindowCommandQueue(); + queue.register("settings", "token-new", CHILD_WINDOW_COMMANDS.settingsOpenTab); + queue.dispatch("settings", CHILD_WINDOW_COMMANDS.settingsOpenTab, { tab: "appearance" }); + + expect(queue.markFailed("settings", "token-old")).toBe(false); + expect(queue.isFailed("settings")).toBe(false); + expect(queue.markReady("settings", "token-new", CHILD_WINDOW_COMMANDS.settingsOpenTab)).toEqual([ + { event: CHILD_WINDOW_COMMANDS.settingsOpenTab, payload: { tab: "appearance" } }, + ]); + }); }); diff --git a/src/lib/childWindowCommandQueue.ts b/src/lib/childWindowCommandQueue.ts index e2628c9d..9b792768 100644 --- a/src/lib/childWindowCommandQueue.ts +++ b/src/lib/childWindowCommandQueue.ts @@ -8,7 +8,7 @@ export interface ChildWindowCommandEnvelope { interface ChildWindowCommandState { token: string; expectedEvent: ChildWindowCommandName; - ready: boolean; + status: "loading" | "ready" | "failed"; pending: ChildWindowCommandEnvelope[]; } @@ -26,7 +26,7 @@ export class ChildWindowCommandQueue { this.states.set(label, { token, expectedEvent, - ready: false, + status: "loading", pending: [], }); } @@ -38,9 +38,10 @@ export class ChildWindowCommandQueue { ): ChildWindowCommandEnvelope[] { const command = { event, payload }; const state = this.states.get(label); - if (!state || state.expectedEvent !== event || state.ready) { + if (!state || state.expectedEvent !== event || state.status === "ready") { return [command]; } + if (state.status === "failed") return []; state.pending.push(command); return []; @@ -53,20 +54,32 @@ export class ChildWindowCommandQueue { ): ChildWindowCommandEnvelope[] { const state = this.states.get(label); if (!state || state.token !== token || state.expectedEvent !== event) return []; - state.ready = true; + state.status = "ready"; return state.pending.splice(0); } markLoading(label: string, token: string) { const state = this.states.get(label); if (!state || state.token !== token) return false; + if (state.status === "failed") return false; - state.ready = false; + state.status = "loading"; return true; } markFailed(label: string, token: string) { - return this.markLoading(label, token); + const state = this.states.get(label); + if (!state || state.token !== token) return false; + + state.status = "failed"; + state.pending = []; + return true; + } + + isFailed(label: string, token?: string) { + const state = this.states.get(label); + if (!state || state.status !== "failed") return false; + return token === undefined || state.token === token; } clear(label: string) { diff --git a/src/lib/windowManager.test.ts b/src/lib/windowManager.test.ts index 548d8e55..ed0ef550 100644 --- a/src/lib/windowManager.test.ts +++ b/src/lib/windowManager.test.ts @@ -1,9 +1,150 @@ -import { describe, expect, it } from "vitest"; -import { - centerWindowRectInWorkArea, - childWindowCommandForUrl, - rectOverlapsWorkArea, -} from "./windowManager"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { CHILD_WINDOW_LIFECYCLE_EVENT, type ChildWindowLifecyclePayload } from "./childWindowProtocol"; + +const mocks = vi.hoisted(() => { + type MockWindow = { + label: string; + close: () => Promise; + hide: () => Promise; + isVisible: () => Promise; + once: (event: string, handler: () => void) => Promise; + outerPosition: () => Promise<{ x: number; y: number }>; + outerSize: () => Promise<{ width: number; height: number }>; + requestUserAttention: () => Promise; + setAlwaysOnTop: () => Promise; + setEnabled: () => Promise; + setFocus: () => Promise; + setFocusable: () => Promise; + setPosition: () => Promise; + setTitle: () => Promise; + show: () => Promise; + }; + + const listeners = new Map void>(); + const windows = new Map(); + const currentWindow = createMockWindow("main", windows); + + function createMockWindow(label: string, registry: Map): MockWindow { + const destroyedHandlers: Array<() => void> = []; + const win = { + label, + close: vi.fn(async () => { + registry.delete(label); + for (const handler of destroyedHandlers) handler(); + }), + hide: vi.fn(async () => {}), + isVisible: vi.fn(async () => true), + once: vi.fn(async (event: string, handler: () => void) => { + if (event === "tauri://destroyed") destroyedHandlers.push(handler); + }), + outerPosition: vi.fn(async () => ({ x: 0, y: 0 })), + outerSize: vi.fn(async () => ({ width: 800, height: 560 })), + requestUserAttention: vi.fn(async () => {}), + setAlwaysOnTop: vi.fn(async () => {}), + setEnabled: vi.fn(async () => {}), + setFocus: vi.fn(async () => {}), + setFocusable: vi.fn(async () => {}), + setPosition: vi.fn(async () => {}), + setTitle: vi.fn(async () => {}), + show: vi.fn(async () => {}), + }; + return win; + } + + return { + availableMonitors: vi.fn(async () => [ + { + workArea: { + position: { x: 0, y: 0 }, + size: { width: 1920, height: 1040 }, + }, + }, + ]), + createMockWindow, + currentWindow, + emit: vi.fn(async () => {}), + getAll: vi.fn(async () => Array.from(windows.values())), + getByLabel: vi.fn(async (label: string) => windows.get(label) ?? null), + getCurrentWindow: vi.fn(() => currentWindow), + invoke: vi.fn(async (_command: string, args?: { options?: { label?: string } }) => { + const label = args?.options?.label; + if (label) windows.set(label, createMockWindow(label, windows)); + }), + listen: vi.fn(async (event: string, handler: (event: { payload: unknown }) => void) => { + listeners.set(event, handler); + return () => listeners.delete(event); + }), + listeners, + primaryMonitor: vi.fn(async () => ({ + workArea: { + position: { x: 0, y: 0 }, + size: { width: 1920, height: 1040 }, + }, + })), + windows, + }; +}); + +vi.mock("@tauri-apps/api/event", () => ({ + emit: mocks.emit, + listen: mocks.listen, +})); + +vi.mock("@tauri-apps/api/webviewWindow", () => ({ + WebviewWindow: { + getAll: mocks.getAll, + getByLabel: mocks.getByLabel, + }, +})); + +vi.mock("@tauri-apps/api/window", () => ({ + availableMonitors: mocks.availableMonitors, + getCurrentWindow: mocks.getCurrentWindow, + PhysicalPosition: class PhysicalPosition { + constructor( + public x: number, + public y: number, + ) {} + }, + primaryMonitor: mocks.primaryMonitor, + UserAttentionType: { Critical: 1 }, +})); + +vi.mock("./invoke", () => ({ invoke: mocks.invoke })); +vi.mock("../i18n", () => ({ default: { t: (key: string) => key } })); +vi.mock("./logger", () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + }, +})); + +beforeEach(() => { + vi.clearAllMocks(); + vi.resetModules(); + mocks.listeners.clear(); + mocks.windows.clear(); +}); + +async function importWindowManager() { + return import("./windowManager"); +} + +function emitLifecycle(payload: ChildWindowLifecyclePayload) { + mocks.listeners.get(CHILD_WINDOW_LIFECYCLE_EVENT)?.({ payload }); +} + +async function waitForInvoke() { + await vi.waitFor(() => expect(mocks.invoke).toHaveBeenCalledWith("open_child_window", expect.anything())); +} + +function createdToken(callIndex = 0) { + const args = mocks.invoke.mock.calls[callIndex][1] as { options: { url: string } }; + const params = new URLSearchParams(args.options.url.slice(args.options.url.indexOf("?") + 1)); + const token = params.get("readyToken"); + expect(token).toBeTruthy(); + return token as string; +} describe("child window command mapping", () => { it.each([ @@ -11,7 +152,8 @@ describe("child window command mapping", () => { ["index.html?window=file-editor", "remote-file-editor-open"], ["index.html?window=file-preview", "file-preview-open"], ["index.html?window=new-session", undefined], - ])("maps %s to %s", (url, expected) => { + ])("maps %s to %s", async (url, expected) => { + const { childWindowCommandForUrl } = await importWindowManager(); expect(childWindowCommandForUrl(url)).toBe(expected); }); }); @@ -22,22 +164,105 @@ describe("child window work-area helpers", () => { size: { width: 1920, height: 1040 }, }; - it("detects a child window completely outside disconnected monitor bounds", () => { - expect( - rectOverlapsWorkArea({ x: 2500, y: 100, width: 800, height: 560 }, primaryWorkArea), - ).toBe(false); + it("detects a child window completely outside disconnected monitor bounds", async () => { + const { rectOverlapsWorkArea } = await importWindowManager(); + expect(rectOverlapsWorkArea({ x: 2500, y: 100, width: 800, height: 560 }, primaryWorkArea)).toBe( + false, + ); }); - it("keeps a child window that still intersects the visible work area", () => { - expect( - rectOverlapsWorkArea({ x: 1800, y: 100, width: 800, height: 560 }, primaryWorkArea), - ).toBe(true); + it("keeps a child window that still intersects the visible work area", async () => { + const { rectOverlapsWorkArea } = await importWindowManager(); + expect(rectOverlapsWorkArea({ x: 1800, y: 100, width: 800, height: 560 }, primaryWorkArea)).toBe( + true, + ); }); - it("centers an off-screen child window in the selected work area", () => { + it("centers an off-screen child window in the selected work area", async () => { + const { centerWindowRectInWorkArea } = await importWindowManager(); expect(centerWindowRectInWorkArea({ width: 800, height: 560 }, primaryWorkArea)).toEqual({ x: 560, y: 240, }); }); }); + +describe("child window load failure recovery", () => { + it("closes and clears a revealed window after the command listener fails", async () => { + const { openSettings } = await importWindowManager(); + const open = openSettings("appearance"); + await waitForInvoke(); + const token = createdToken(); + emitLifecycle({ label: "settings", token, phase: "shell-ready" }); + await open; + + const win = mocks.windows.get("settings"); + expect(win?.show).toHaveBeenCalled(); + emitLifecycle({ label: "settings", token, phase: "load-failed", stage: "command-listener" }); + + await vi.waitFor(() => expect(win?.close).toHaveBeenCalledOnce()); + expect(mocks.windows.has("settings")).toBe(false); + }); + + it("recreates a failed existing window on the next open", async () => { + const { openSettings } = await importWindowManager(); + const firstOpen = openSettings("appearance"); + await waitForInvoke(); + const firstToken = createdToken(); + emitLifecycle({ label: "settings", token: firstToken, phase: "shell-ready" }); + await firstOpen; + + const firstWindow = mocks.windows.get("settings"); + emitLifecycle({ + label: "settings", + token: firstToken, + phase: "load-failed", + stage: "command-listener", + }); + await vi.waitFor(() => expect(firstWindow?.close).toHaveBeenCalledOnce()); + + const secondOpen = openSettings("general"); + await vi.waitFor(() => expect(mocks.invoke).toHaveBeenCalledTimes(2)); + const secondToken = createdToken(1); + emitLifecycle({ label: "settings", token: secondToken, phase: "shell-ready" }); + await secondOpen; + + const secondWindow = mocks.windows.get("settings"); + expect(secondWindow).toBeTruthy(); + expect(secondWindow).not.toBe(firstWindow); + }); + + it("fails first open promptly and closes the orphan when bootstrap fails before shell ready", async () => { + const { openSettings } = await importWindowManager(); + const open = openSettings("appearance"); + await waitForInvoke(); + const token = createdToken(); + const win = mocks.windows.get("settings"); + + emitLifecycle({ label: "settings", token, phase: "load-failed", stage: "bootstrap-import" }); + + await expect(open).rejects.toThrow("Child window did not finish rendering: settings"); + await vi.waitFor(() => expect(win?.close).toHaveBeenCalled()); + }); + + it("ignores stale load-failed events from an old token", async () => { + const { openSettings } = await importWindowManager(); + const open = openSettings("appearance"); + await waitForInvoke(); + const token = createdToken(); + emitLifecycle({ label: "settings", token, phase: "shell-ready" }); + await open; + + const win = mocks.windows.get("settings"); + emitLifecycle({ + label: "settings", + token: "stale-token", + phase: "load-failed", + stage: "command-listener", + }); + + await Promise.resolve(); + expect(win?.close).not.toHaveBeenCalled(); + expect(mocks.windows.get("settings")).toBe(win); + }); +}); diff --git a/src/lib/windowManager.ts b/src/lib/windowManager.ts index 3e7d6ed3..f2497f8f 100644 --- a/src/lib/windowManager.ts +++ b/src/lib/windowManager.ts @@ -66,6 +66,7 @@ const pendingChildWindowOpens = new Map(); const childWindowCommands = new ChildWindowCommandQueue(); const childWindowTokens = new Map(); const childWindowShellWaiters = new Map(); +const failedChildWindowClosures = new Map(); let childWindowLifecycleListenerPromise: Promise | undefined; let ownerMainWindowLabel = MAIN_WINDOW_LABEL; let modalGroupRaiseInFlight = false; @@ -493,6 +494,27 @@ function dispatchChildWindowCommand( emitChildWindowCommands(childWindowCommands.dispatch(label, event, payload)); } +async function closeFailedChildWindow(label: string, token: string) { + failedChildWindowClosures.set(label, token); + try { + const win = await WebviewWindow.getByLabel(label).catch(() => null); + await win?.close().catch((error) => { + logger.warn({ + domain: "window.lifecycle", + event: "child_failed_window_close_failed", + message: "Failed to close a child window after load failure", + data: { label }, + error, + }); + }); + } finally { + if (failedChildWindowClosures.get(label) === token) { + failedChildWindowClosures.delete(label); + } + clearChildWindowLifecycle(label, token, true); + } +} + function handleChildWindowLifecycle(payload: ChildWindowLifecyclePayload) { if (!payload.token || childWindowTokens.get(payload.label) !== payload.token) return; @@ -514,12 +536,17 @@ function handleChildWindowLifecycle(payload: ChildWindowLifecyclePayload) { break; case "load-failed": childWindowCommands.markFailed(payload.label, payload.token); + { + const waiter = childWindowShellWaiters.get(payload.label); + if (waiter?.token === payload.token) waiter.fail(); + } logger.warn({ domain: "window.lifecycle", event: "child_load_failed", message: "Child window failed to finish loading", data: { label: payload.label, stage: payload.stage }, }); + void closeFailedChildWindow(payload.label, payload.token); break; } } @@ -659,12 +686,21 @@ async function openChildWindowInternal(opts: ChildWindowOptions) { const isModal = kind === "modal"; const existing = await WebviewWindow.getByLabel(opts.label); if (existing) { - let shownMs: number | undefined; - const revealed = await revealChildWindow(existing, opts, isModal, false, () => { - shownMs = Math.round(performance.now() - startedAt); - }); - logTiming({ existing: true, shown_ms: shownMs }); - return revealed; + const existingToken = childWindowTokens.get(opts.label); + const existingFailed = + existingToken !== undefined && + (childWindowCommands.isFailed(opts.label, existingToken) || + failedChildWindowClosures.get(opts.label) === existingToken); + if (existingFailed) { + await closeFailedChildWindow(opts.label, existingToken); + } else { + let shownMs: number | undefined; + const revealed = await revealChildWindow(existing, opts, isModal, false, () => { + shownMs = Math.round(performance.now() - startedAt); + }); + logTiming({ existing: true, shown_ms: shownMs }); + return revealed; + } } const readyToken = createChildWindowReadyToken(); From 338d0615fe28eb9917a158904d6a7d355eb3cca8 Mon Sep 17 00:00:00 2001 From: Kang Date: Sat, 15 Aug 2026 16:02:05 +0800 Subject: [PATCH 09/11] feat(child-window): add macOS drag-only functionality to ChildWindowHeader - Introduced a new prop `macOSDragOnly` to the `ChildWindowHeader` component, allowing for conditional rendering of header content based on macOS drag behavior. - Updated the `SettingsPage` to utilize the new `macOSDragOnly` prop when rendering the `ChildWindowHeader`. --- src/components/layout/ChildWindowHeader.tsx | 22 ++++++++++++++------- src/pages/SettingsPage.tsx | 1 + 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/src/components/layout/ChildWindowHeader.tsx b/src/components/layout/ChildWindowHeader.tsx index 1dbb244e..10c848f2 100644 --- a/src/components/layout/ChildWindowHeader.tsx +++ b/src/components/layout/ChildWindowHeader.tsx @@ -18,6 +18,7 @@ interface ChildWindowHeaderProps { icon?: ReactNode; windowControls?: boolean; alwaysOnTopControl?: boolean; + macOSDragOnly?: boolean; } export default function ChildWindowHeader({ @@ -26,6 +27,7 @@ export default function ChildWindowHeader({ icon, windowControls = false, alwaysOnTopControl = false, + macOSDragOnly = false, }: ChildWindowHeaderProps) { const { t } = useTranslation(); const [appWindow] = useState(() => getCurrentWindow()); @@ -83,18 +85,24 @@ export default function ChildWindowHeader({ setIsAlwaysOnTop(alwaysOnTop); }; + const hideHeaderContent = isMacOS && macOSDragOnly; + return (
-
- {icon ? {icon} : null} - {title} -
+ {hideHeaderContent ? ( +
+ ) : ( +
+ {icon ? {icon} : null} + {title} +
+ )} {!isMacOS && (
diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index 6dcc4e82..7cfe9f29 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -452,6 +452,7 @@ export default function SettingsPage() { } + macOSDragOnly onClose={requestClose} /> From 886730c51c94a991e5dfdb55adba0e0d60224ffe Mon Sep 17 00:00:00 2001 From: Kang Date: Sat, 15 Aug 2026 16:11:41 +0800 Subject: [PATCH 10/11] fix(macOS): adjust traffic light position for better alignment - Updated the traffic light position in the macOS configuration and command to ensure the native buttons are visually centered in the custom header. - Changed the vertical position from 22 to 14 for improved aesthetics. --- src-tauri/src/cmd/app.rs | 7 +++---- src-tauri/tauri.macos.conf.json | 2 +- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src-tauri/src/cmd/app.rs b/src-tauri/src/cmd/app.rs index 2f008c2d..0d6cce3c 100644 --- a/src-tauri/src/cmd/app.rs +++ b/src-tauri/src/cmd/app.rs @@ -357,10 +357,9 @@ pub async fn open_child_window( { builder = builder .title_bar_style(tauri::TitleBarStyle::Overlay) - // Position the traffic light controls in logical points so they align with the - // 40px custom header centerline; macOS applies the backing scale factor, so do not - // hard-code coordinates based on the current display's physical resolution. - .traffic_light_position(tauri::LogicalPosition::new(12.0, 22.0)) + // Position the traffic light controls in logical points so the 12px native buttons + // sit visually centered in the 40px custom header. + .traffic_light_position(tauri::LogicalPosition::new(12.0, 14.0)) .hidden_title(true); } diff --git a/src-tauri/tauri.macos.conf.json b/src-tauri/tauri.macos.conf.json index a9aa82ce..c34d557f 100644 --- a/src-tauri/tauri.macos.conf.json +++ b/src-tauri/tauri.macos.conf.json @@ -12,7 +12,7 @@ "titleBarStyle": "Overlay", "trafficLightPosition": { "x": 12, - "y": 22 + "y": 14 }, "hiddenTitle": true, "create": false From 6108fc97ac04ea1a3946158198e0b14f2b81db81 Mon Sep 17 00:00:00 2001 From: Kang Date: Sat, 15 Aug 2026 16:36:25 +0800 Subject: [PATCH 11/11] fix(macOS): update traffic light position for alignment consistency - Adjusted the vertical position of the traffic light controls in both the macOS configuration and command to enhance visual alignment with the custom header. - Changed the position from 14 to 18 for improved aesthetics and consistency across the application. --- src-tauri/src/cmd/app.rs | 2 +- src-tauri/tauri.macos.conf.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/cmd/app.rs b/src-tauri/src/cmd/app.rs index 0d6cce3c..5bd2ef23 100644 --- a/src-tauri/src/cmd/app.rs +++ b/src-tauri/src/cmd/app.rs @@ -359,7 +359,7 @@ pub async fn open_child_window( .title_bar_style(tauri::TitleBarStyle::Overlay) // Position the traffic light controls in logical points so the 12px native buttons // sit visually centered in the 40px custom header. - .traffic_light_position(tauri::LogicalPosition::new(12.0, 14.0)) + .traffic_light_position(tauri::LogicalPosition::new(12.0, 18.0)) .hidden_title(true); } diff --git a/src-tauri/tauri.macos.conf.json b/src-tauri/tauri.macos.conf.json index c34d557f..a009b901 100644 --- a/src-tauri/tauri.macos.conf.json +++ b/src-tauri/tauri.macos.conf.json @@ -12,7 +12,7 @@ "titleBarStyle": "Overlay", "trafficLightPosition": { "x": 12, - "y": 14 + "y": 18 }, "hiddenTitle": true, "create": false