diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index d84b561..0396a7e 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -239,6 +239,7 @@ dependencies = [ "rusqlite", "serde", "serde_json", + "sysinfo", "tauri", "tauri-build", "tauri-plugin-dialog", @@ -2287,6 +2288,15 @@ dependencies = [ "pin-utils", ] +[[package]] +name = "ntapi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +dependencies = [ + "winapi", +] + [[package]] name = "num-conv" version = "0.2.2" @@ -3909,6 +3919,20 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "sysinfo" +version = "0.30.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a5b4ddaee55fb2bea2bf0e5000747e5f5c0de765e5a5ff87f4cd106439f4bb3" +dependencies = [ + "cfg-if", + "core-foundation-sys", + "libc", + "ntapi", + "once_cell", + "windows 0.52.0", +] + [[package]] name = "system-deps" version = "6.2.2" @@ -3969,7 +3993,7 @@ dependencies = [ "tao-macros", "unicode-segmentation", "url", - "windows", + "windows 0.61.3", "windows-core 0.61.2", "windows-version", "x11-dl", @@ -4046,7 +4070,7 @@ dependencies = [ "webkit2gtk", "webview2-com", "window-vibrancy", - "windows", + "windows 0.61.3", ] [[package]] @@ -4219,7 +4243,7 @@ dependencies = [ "uuid", "webkit2gtk", "webview2-com", - "windows", + "windows 0.61.3", "windows-core 0.61.2", ] @@ -4245,7 +4269,7 @@ dependencies = [ "url", "webkit2gtk", "webview2-com", - "windows", + "windows 0.61.3", ] [[package]] @@ -4270,7 +4294,7 @@ dependencies = [ "url", "webkit2gtk", "webview2-com", - "windows", + "windows 0.61.3", "wry", ] @@ -5115,7 +5139,7 @@ checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" dependencies = [ "webview2-com-macros", "webview2-com-sys", - "windows", + "windows 0.61.3", "windows-core 0.61.2", "windows-implement", "windows-interface", @@ -5139,7 +5163,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" dependencies = [ "thiserror 2.0.19", - "windows", + "windows 0.61.3", "windows-core 0.61.2", ] @@ -5189,6 +5213,16 @@ dependencies = [ "windows-version", ] +[[package]] +name = "windows" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e48a53791691ab099e5e2ad123536d0fff50652600abaf43bbf952894110d0be" +dependencies = [ + "windows-core 0.52.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows" version = "0.61.3" @@ -5211,6 +5245,15 @@ dependencies = [ "windows-core 0.61.2", ] +[[package]] +name = "windows-core" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-core" version = "0.61.2" @@ -5670,7 +5713,7 @@ dependencies = [ "webkit2gtk", "webkit2gtk-sys", "webview2-com", - "windows", + "windows 0.61.3", "windows-core 0.61.2", "windows-version", "x11-dl", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index d20934e..158ebc5 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -35,6 +35,7 @@ time = { version = "0.3", features = ["formatting"] } uuid = { version = "1", features = ["v4"] } libc = "0.2" futures = "0.3" +sysinfo = { version = "0.30", default-features = false } tauri-plugin-wdio-webdriver = { version = "1", optional = true } [target.'cfg(target_os = "macos")'.dependencies] diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index c7304f6..c61101d 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -23,6 +23,7 @@ mod pty; mod review; mod scripts; mod settings; +mod system_metrics; mod traffic_lights; mod vault; mod web_panel; @@ -188,6 +189,7 @@ fn main() { install_menu(app)?; if let Some(window) = app.get_webview_window("main") { let manager = app.state::().inner().clone(); + let pty_manager = app.state::>().inner().clone(); let closing = Arc::new(AtomicBool::new(false)); let close_window = window.clone(); window.clone().on_window_event(move |event| { @@ -197,9 +199,11 @@ fn main() { } api.prevent_close(); let manager = manager.clone(); + let pty_manager = pty_manager.clone(); let window = close_window.clone(); tauri::async_runtime::spawn(async move { agent::cancel_all(&manager).await; + pty::kill_all(&pty_manager); let _ = window.close(); }); } @@ -211,12 +215,14 @@ fn main() { .manage(agent::AgentManager::default()) .manage(web_panel::WebPanelState::default()) .manage(docker::LogStreams::default()) + .manage(system_metrics::SystemMetricsState::default()) .manage(vault::VaultState(std::sync::Mutex::new(None))) .invoke_handler(tauri::generate_handler![ http_get, http_request, http_fetch_base64, app_identifier, + system_metrics::app_memory_usage, agent::start_agent, agent::cancel_agent, agent_sessions::agent_codex_clear_lock, diff --git a/src-tauri/src/pty.rs b/src-tauri/src/pty.rs index b427e36..57f6ef0 100644 --- a/src-tauri/src/pty.rs +++ b/src-tauri/src/pty.rs @@ -2,11 +2,13 @@ use portable_pty::{native_pty_system, CommandBuilder, PtySize}; use std::collections::HashMap; use std::io::{Read, Write}; use std::sync::{Arc, Mutex}; +use sysinfo::{Pid, System}; use tauri::{AppHandle, Emitter}; struct PtyInstance { writer: Box, master: Box, + child: Box, } #[derive(Default)] @@ -14,6 +16,99 @@ pub struct PtyManager { instances: Mutex>, } +#[cfg(unix)] +fn terminate_process_tree(root: u32) { + let session = Pid::from_u32(root); + let members = |system: &System| -> Vec { + system + .processes() + .iter() + .filter_map(|(pid, process)| { + (process.session_id() == Some(session)).then_some(pid.as_u32() as i32) + }) + .collect() + }; + + let system = System::new_all(); + for pid in members(&system) { + // SAFETY: the PID comes from the current system process snapshot. + unsafe { libc::kill(pid, libc::SIGTERM) }; + } + std::thread::sleep(std::time::Duration::from_millis(150)); + let system = System::new_all(); + for pid in members(&system) { + // Agents may handle/ignore SIGHUP and SIGTERM; SIGKILL guarantees that + // closing their owning terminal actually releases their memory. + unsafe { libc::kill(pid, libc::SIGKILL) }; + } +} + +#[cfg(windows)] +fn terminate_process_tree(root: u32) { + use std::ffi::c_void; + + type Handle = *mut c_void; + const PROCESS_TERMINATE: u32 = 0x0001; + #[link(name = "kernel32")] + unsafe extern "system" { + fn OpenProcess(access: u32, inherit_handle: i32, process_id: u32) -> Handle; + fn TerminateProcess(process: Handle, exit_code: u32) -> i32; + fn CloseHandle(handle: Handle) -> i32; + } + + let system = System::new_all(); + let mut tree = vec![Pid::from_u32(root)]; + loop { + let before = tree.len(); + for (pid, process) in system.processes() { + if process + .parent() + .is_some_and(|parent| tree.contains(&parent)) + && !tree.contains(pid) + { + tree.push(*pid); + } + } + if tree.len() == before { + break; + } + } + + // Children first so they cannot survive after their parent disappears. + for pid in tree.into_iter().rev() { + // SAFETY: handles are checked and closed, and PROCESS_TERMINATE is the + // minimum access required for this operation. + unsafe { + let handle = OpenProcess(PROCESS_TERMINATE, 0, pid.as_u32()); + if !handle.is_null() { + TerminateProcess(handle, 1); + CloseHandle(handle); + } + } + } +} + +fn terminate_instance(mut instance: PtyInstance) { + if let Some(pid) = instance.child.process_id() { + terminate_process_tree(pid); + } + let _ = instance.child.kill(); + let _ = instance.child.wait(); +} + +pub fn kill_all(manager: &PtyManager) { + let instances: Vec = manager + .instances + .lock() + .unwrap() + .drain() + .map(|(_, instance)| instance) + .collect(); + for instance in instances { + terminate_instance(instance); + } +} + fn dirs_home() -> Option { std::env::var("HOME") .or_else(|_| std::env::var("USERPROFILE")) @@ -70,6 +165,38 @@ mod tests { assert_eq!(drain_utf8(&mut p), "a"); assert_eq!(p, vec![0xE2, 0x94]); } + + #[cfg(unix)] + #[test] + fn terminating_a_pty_kills_its_whole_session() { + use super::{terminate_instance, PtyInstance}; + use portable_pty::{native_pty_system, CommandBuilder, PtySize}; + + let pair = native_pty_system() + .openpty(PtySize { + rows: 24, + cols: 80, + pixel_width: 0, + pixel_height: 0, + }) + .unwrap(); + let mut command = CommandBuilder::new("/bin/sh"); + command.args(["-c", "sleep 30 & wait"]); + let child = pair.slave.spawn_command(command).unwrap(); + let pid = child.process_id().unwrap(); + let writer = pair.master.take_writer().unwrap(); + + terminate_instance(PtyInstance { + writer, + master: pair.master, + child, + }); + + // Signal 0 only checks existence; ESRCH proves the session leader was + // killed and reaped rather than merely detached from the UI. + let exists = unsafe { libc::kill(pid as i32, 0) } == 0; + assert!(!exists, "PTY process {pid} survived termination"); + } } #[tauri::command] @@ -141,7 +268,7 @@ pub fn pty_spawn( cmd.cwd(dir); } - let _child = pair.slave.spawn_command(cmd).map_err(|e| e.to_string())?; + let child = pair.slave.spawn_command(cmd).map_err(|e| e.to_string())?; let writer = pair.master.take_writer().map_err(|e| e.to_string())?; let mut reader = pair.master.try_clone_reader().map_err(|e| e.to_string())?; @@ -149,6 +276,16 @@ pub fn pty_spawn( let id_clone = id.clone(); let app_clone = app.clone(); + state.instances.lock().unwrap().insert( + id.clone(), + PtyInstance { + writer, + master: pair.master, + child, + }, + ); + + let manager = state.inner().clone(); std::thread::spawn(move || { let mut buf = [0u8; 4096]; // Holds bytes of a multi-byte char split across reads (see drain_utf8). @@ -167,17 +304,13 @@ pub fn pty_spawn( } // EOF: the shell exited (e.g. the user typed `exit`); tell the frontend // so it can close the panel instead of leaving a dead terminal. + let instance = manager.instances.lock().unwrap().remove(&id_clone); + if let Some(instance) = instance { + terminate_instance(instance); + } let _ = app_clone.emit(&format!("pty-exit-{}", id_clone), ()); }); - state.instances.lock().unwrap().insert( - id, - PtyInstance { - writer, - master: pair.master, - }, - ); - Ok(()) } @@ -217,6 +350,9 @@ pub fn pty_resize( #[tauri::command] pub fn pty_kill(id: String, state: tauri::State>) -> Result<(), String> { - state.instances.lock().unwrap().remove(&id); + let instance = state.instances.lock().unwrap().remove(&id); + if let Some(instance) = instance { + terminate_instance(instance); + } Ok(()) } diff --git a/src-tauri/src/system_metrics.rs b/src-tauri/src/system_metrics.rs new file mode 100644 index 0000000..9559cef --- /dev/null +++ b/src-tauri/src/system_metrics.rs @@ -0,0 +1,302 @@ +use std::collections::HashSet; +use std::sync::Mutex; + +use sysinfo::{Pid, Process, System}; +use tauri::State; + +struct MetricsState { + system: System, + // PIDs in Bento's process group, rebuilt every TREE_REFRESH_EVERY calls so + // new terminals/agents are picked up without scanning all processes each time. + cached_pids: HashSet, + calls_since_tree_refresh: u8, +} + +const TREE_REFRESH_EVERY: u8 = 5; // full scan every ~15 s (at 3 s poll interval) + +pub struct SystemMetricsState(Mutex); + +impl Default for SystemMetricsState { + fn default() -> Self { + Self(Mutex::new(MetricsState { + system: System::new(), + cached_pids: HashSet::new(), + calls_since_tree_refresh: TREE_REFRESH_EVERY, // force scan on first call + })) + } +} + +fn descendant_processes(system: &System, root: Pid) -> HashSet { + let mut result = HashSet::from([root]); + loop { + let before = result.len(); + for (pid, process) in system.processes() { + if process + .parent() + .is_some_and(|parent| result.contains(&parent)) + { + result.insert(*pid); + } + } + if result.len() == before { + return result; + } + } +} + +fn app_processes(system: &System, root: Pid) -> HashSet { + // WebKit XPC services are re-parented to launchd on macOS, so a normal + // parent/child walk misses most of Bento's UI memory. Resource coalitions + // are the kernel grouping that keeps an app and those services together. + #[cfg(target_os = "macos")] + if let Some(root_coalition) = resource_coalition_id(root) { + let root_process = system.process(root); + let launched_as_app = root_process + .and_then(Process::parent) + .is_some_and(|parent| parent.as_u32() == 1); + + if launched_as_app { + // A packaged app launched by macOS owns an isolated coalition. This + // is the authoritative grouping and includes its re-parented XPCs. + let members: HashSet = system + .processes() + .keys() + .copied() + .filter(|pid| resource_coalition_id(*pid) == Some(root_coalition)) + .collect(); + if !members.is_empty() { + return members; + } + } else if let Some(started_at) = root_process.map(Process::start_time) { + // `tauri dev` inherits the terminal's long-lived coalition, which can + // contain hundreds of unrelated/old processes. Keep the real child + // tree and add only this run's re-parented WebKit XPC services. + let mut members = descendant_processes(system, root); + members.extend(system.processes().iter().filter_map(|(pid, process)| { + (process.start_time() >= started_at + && process.name().contains("WebKit") + && resource_coalition_id(*pid) == Some(root_coalition)) + .then_some(*pid) + })); + return members; + } + } + + // Linux and Windows keep the terminal/agent process tree attached to the + // host process. This is also the safe fallback if coalition lookup fails. + descendant_processes(system, root) +} + +#[cfg(target_os = "macos")] +fn resource_coalition_id(pid: Pid) -> Option { + use std::ffi::c_void; + + const PROC_PIDCOALITIONINFO: i32 = 20; + #[repr(C)] + struct ProcPidCoalitionInfo { + // RESOURCE and JETSAM coalition IDs, followed by three reserved fields. + coalition_id: [u64; 2], + reserved: [u64; 3], + } + unsafe extern "C" { + fn proc_pidinfo( + pid: i32, + flavor: i32, + arg: u64, + buffer: *mut c_void, + buffer_size: i32, + ) -> i32; + } + + let mut info = ProcPidCoalitionInfo { + coalition_id: [0; 2], + reserved: [0; 3], + }; + // SAFETY: info matches proc_pidcoalitioninfo's C layout and buffer size. + let read = unsafe { + proc_pidinfo( + pid.as_u32() as i32, + PROC_PIDCOALITIONINFO, + 0, + (&mut info as *mut ProcPidCoalitionInfo).cast(), + std::mem::size_of::() as i32, + ) + }; + (read == std::mem::size_of::() as i32).then_some(info.coalition_id[0]) +} + +#[cfg(target_os = "macos")] +fn physical_memory(pid: Pid, fallback: &Process) -> u64 { + use std::ffi::c_void; + + // rusage_info_v2 is a UUID followed by 18 u64 fields. phys_footprint is the + // eighth field and is the same footprint metric used by Apple's memory tools. + #[repr(C)] + struct RusageInfoV2 { + uuid: [u8; 16], + values: [u64; 18], + } + unsafe extern "C" { + fn proc_pid_rusage(pid: i32, flavor: i32, buffer: *mut c_void) -> i32; + } + + let mut info = RusageInfoV2 { + uuid: [0; 16], + values: [0; 18], + }; + // SAFETY: info has the C layout and complete size required by RUSAGE_INFO_V2. + let ok = unsafe { + proc_pid_rusage( + pid.as_u32() as i32, + 2, + (&mut info as *mut RusageInfoV2).cast(), + ) + } == 0; + if ok { + info.values[7] + } else { + fallback.memory() + } +} + +#[cfg(target_os = "linux")] +fn physical_memory(pid: Pid, fallback: &Process) -> u64 { + // PSS charges each process only its proportional share of common pages, so + // adding a process tree does not multiply shared libraries/WebView mappings. + let path = format!("/proc/{}/smaps_rollup", pid.as_u32()); + std::fs::read_to_string(path) + .ok() + .and_then(|contents| { + contents.lines().find_map(|line| { + let value = line + .strip_prefix("Pss:")? + .trim() + .split_whitespace() + .next()?; + value.parse::().ok().map(|kb| kb * 1024) + }) + }) + .unwrap_or_else(|| fallback.memory()) +} + +#[cfg(target_os = "windows")] +fn physical_memory(pid: Pid, fallback: &Process) -> u64 { + use std::ffi::c_void; + use std::mem::{size_of, zeroed}; + + type Handle = *mut c_void; + const PROCESS_QUERY_LIMITED_INFORMATION: u32 = 0x1000; + + #[repr(C)] + struct ProcessMemoryCountersEx2 { + cb: u32, + page_fault_count: u32, + peak_working_set_size: usize, + working_set_size: usize, + quota_peak_paged_pool_usage: usize, + quota_paged_pool_usage: usize, + quota_peak_non_paged_pool_usage: usize, + quota_non_paged_pool_usage: usize, + pagefile_usage: usize, + peak_pagefile_usage: usize, + private_usage: usize, + private_working_set_size: usize, + shared_commit_usage: u64, + } + #[link(name = "kernel32")] + unsafe extern "system" { + fn OpenProcess(access: u32, inherit_handle: i32, process_id: u32) -> Handle; + fn CloseHandle(handle: Handle) -> i32; + fn K32GetProcessMemoryInfo(handle: Handle, counters: *mut c_void, size: u32) -> i32; + } + + // SAFETY: the handle is closed on every successful OpenProcess path and the + // output buffer has the exact PROCESS_MEMORY_COUNTERS_EX2 C layout. + unsafe { + let handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid.as_u32()); + if handle.is_null() { + return fallback.virtual_memory(); + } + let mut counters: ProcessMemoryCountersEx2 = zeroed(); + counters.cb = size_of::() as u32; + let ok = K32GetProcessMemoryInfo( + handle, + (&mut counters as *mut ProcessMemoryCountersEx2).cast(), + counters.cb, + ) != 0; + CloseHandle(handle); + if ok { + counters.private_working_set_size as u64 + } else { + fallback.virtual_memory() + } + } +} + +#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] +fn physical_memory(_pid: Pid, fallback: &Process) -> u64 { + fallback.memory() +} + +// Current physical footprint of Bento plus its WebViews, terminals and agents. +// Each supported OS uses a non-duplicating native metric instead of adding RSS. +// The process tree is rebuilt every TREE_REFRESH_EVERY calls; between rebuilds +// only the known PIDs are refreshed, avoiding a full system-wide scan. +#[tauri::command] +pub fn app_memory_usage(state: State<'_, SystemMetricsState>) -> Result { + let mut ms = state.0.lock().map_err(|e| e.to_string())?; + let root = Pid::from_u32(std::process::id()); + + if ms.calls_since_tree_refresh >= TREE_REFRESH_EVERY || ms.cached_pids.is_empty() { + ms.system.refresh_processes(); + ms.cached_pids = app_processes(&ms.system, root); + ms.calls_since_tree_refresh = 0; + } else { + // Copy the small cached set first so refreshing `system` does not + // mutably borrow `ms` while `cached_pids` is still borrowed. + let cached_pids: Vec = ms.cached_pids.iter().copied().collect(); + for pid in cached_pids { + ms.system.refresh_process(pid); + } + ms.calls_since_tree_refresh += 1; + } + + let total = ms.cached_pids + .iter() + .filter_map(|&pid| { + ms.system + .process(pid) + .map(|process| physical_memory(pid, process)) + }) + .sum(); + Ok(total) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn native_metric_reads_current_process() { + let mut system = System::new(); + let pid = Pid::from_u32(std::process::id()); + system.refresh_process(pid); + let process = system.process(pid).expect("current process"); + assert!(physical_memory(pid, process) > 0); + } + + #[test] + fn tree_refresh_resets_after_threshold() { + let state = SystemMetricsState::default(); + let ms = state.0.lock().unwrap(); + assert_eq!(ms.calls_since_tree_refresh, TREE_REFRESH_EVERY); + assert!(ms.cached_pids.is_empty()); + } + + #[cfg(target_os = "macos")] + #[test] + fn native_metric_reads_current_resource_coalition() { + let pid = Pid::from_u32(std::process::id()); + assert!(resource_coalition_id(pid).is_some()); + } +} diff --git a/src/app/createWorkspaceView.ts b/src/app/createWorkspaceView.ts index f14717f..3c9c9ca 100644 --- a/src/app/createWorkspaceView.ts +++ b/src/app/createWorkspaceView.ts @@ -2,15 +2,11 @@ import { createDockview, type DockviewApi, type AddPanelOptions } from 'dockview import type { PanelRegistry } from '../panels/registry' import { lowestAvailableNumber } from '../core/terminal/lowestAvailableNumber' import { cycleTheme } from '../panels/terminal/themePreference' -import { showContextMenu } from '../ui/contextMenu' -import { furthestEdgeIndex, type MoveDirection } from '../core/workspace/edge' import { icon } from '../ui/icons' import { isMac, shortcutLabel } from '../ui/platform' import { currentPanelIndex } from '../core/workspace/currentPanel' import { appT } from '../core/i18n' -export type SplitDirection = 'within' | 'left' | 'right' | 'above' | 'below' - export interface WorkspaceView { element: HTMLElement fit: () => void @@ -66,24 +62,6 @@ export function createWorkspaceView(panels: PanelRegistry, options: WorkspaceOpt api.addPanel({ id: `${type}-${n}`, component: type, title: `${def.title} ${n}`, position }) } - const splitFrom = (refId: string, direction: SplitDirection): void => - addPanel(typeOf(refId), { referencePanel: refId, direction }) - - // Move a panel to the edge of the layout (alternative to dragging, which the - // macOS WebView doesn't support for HTML5 drag-and-drop). moveTo needs a - // target group: we pick the group at the requested edge (pure logic in core/edge). - const edgeOf = { right: 'right', left: 'left', above: 'top', below: 'bottom' } as const - const movePanel = (id: string, direction: MoveDirection): void => { - const panel = api.getPanel(id) - if (!panel) return - const groups = api.groups - const i = furthestEdgeIndex(groups.map(g => g.element.getBoundingClientRect()), direction) - const target = groups[i] - const movingIntoOwnLoneGroup = target === panel.group && target.panels.length === 1 - if (movingIntoOwnLoneGroup) return - panel.api.moveTo({ group: target, position: edgeOf[direction] }) - } - const addInActiveGroup = (type: string): void => addPanel(type, api.activeGroup ? { referenceGroup: api.activeGroup, direction: 'within' } : undefined) @@ -101,13 +79,15 @@ export function createWorkspaceView(panels: PanelRegistry, options: WorkspaceOpt const def = panels.get(name) if (!def) throw new Error(appT('panelNotRegistered', { name })) - const instance = def.create({ panelId: id, removeSelf: () => removePanel(id), projectPath: options.projectPath?.() }) + const instance = def.create({ + panelId: id, + removeSelf: () => removePanel(id), + projectPath: options.projectPath?.(), + newSibling: () => addPanel(name, { referencePanel: id, direction: 'within' }), + }) instanceMap.set(id, instance) fits.add(instance.fit ?? (() => {})) - // The tab bar (with its × close) is hidden, so wrap each panel and overlay a - // hover close button in the corner — reliable across win/mac/linux (macOS's - // WKWebView swallows the right-click menu). Kept alongside the context menu. const wrapper = document.createElement('div') wrapper.className = 'panel-wrapper' wrapper.appendChild(instance.element) @@ -121,30 +101,16 @@ export function createWorkspaceView(panels: PanelRegistry, options: WorkspaceOpt closeBtn.addEventListener('click', () => removePanel(id)) wrapper.appendChild(closeBtn) - // Context menu: close, split, move (HTML5 drag doesn't work in WKWebView) - wrapper.addEventListener('contextmenu', e => { - e.preventDefault() - showContextMenu(e.clientX, e.clientY, [ - { label: appT('closePanel'), onClick: () => removePanel(id) }, - { label: appT('moveRight'), onClick: () => movePanel(id, 'right') }, - { label: appT('moveLeft'), onClick: () => movePanel(id, 'left') }, - { label: appT('moveUp'), onClick: () => movePanel(id, 'above') }, - { label: appT('moveDown'), onClick: () => movePanel(id, 'below') }, - { label: appT('splitRight'), onClick: () => splitFrom(id, 'right') }, - { label: appT('splitLeft'), onClick: () => splitFrom(id, 'left') }, - { label: appT('splitUp'), onClick: () => splitFrom(id, 'above') }, - { label: appT('splitDown'), onClick: () => splitFrom(id, 'below') }, - { label: appT('newTab', { name: def.title }), onClick: () => splitFrom(id, 'within') }, - ]) - }) - return { element: wrapper, init: params => { if (instance.fit) { params.api.onDidDimensionsChange(() => instance.fit!()) - params.api.onDidVisibilityChange(({ isVisible }) => { if (isVisible) instance.fit!() }) } + params.api.onDidVisibilityChange(({ isVisible }) => { + if (isVisible && instance.fit) instance.fit() + instance.onVisibilityChange?.(isVisible) + }) instance.onTitleChange?.(title => params.api.setTitle(title)) instance.onReady?.({ maximize: () => params.api.maximize(), @@ -160,21 +126,6 @@ export function createWorkspaceView(panels: PanelRegistry, options: WorkspaceOpt }, } }, - createRightHeaderActionComponent: () => { - const btn = document.createElement('button') - btn.className = 'group-add-tab' - btn.textContent = '+' - btn.title = appT('addPanel') - const onClick = () => { - const rect = btn.getBoundingClientRect() - showContextMenu(rect.right, rect.bottom, panels.list().map(d => ({ - label: d.title, - onClick: () => addInActiveGroup(d.type), - })), { align: 'right' }) - } - btn.addEventListener('click', onClick) - return { element: btn, init: () => {}, dispose: () => btn.removeEventListener('click', onClick) } - }, }) function removePanel(id: string): void { @@ -266,9 +217,6 @@ export function createWorkspaceView(panels: PanelRegistry, options: WorkspaceOpt if (e.key === 't') { e.preventDefault() addInActiveGroup('terminal') - } else if (e.key === 'd' && active) { - e.preventDefault() - splitFrom(active.id, e.shiftKey ? 'below' : 'right') } else if (e.key === 'j') { // The focused terminal cycles its local theme; outside it, the global one. const inTerminal = active ? typeOf(active.id) === 'terminal' : false diff --git a/src/i18n/en.json b/src/i18n/en.json index c9c4741..a755621 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -27,6 +27,8 @@ "agentWaiting": "waiting", "agentWorking": "working", "agentIdle": "idle", + "ramUsage": "Physical RAM used by Bento and its processes", + "ramUnavailable": "RAM usage unavailable", "resize": "Resize", "top": "top", "bottom": "bottom", @@ -35,15 +37,6 @@ "windowBorders": "Window borders", "theme": "Theme: {name}", "panelNotRegistered": "Panel not registered: {name}", - "moveRight": "↦ Move right", - "moveLeft": "↤ Move left", - "moveUp": "↥ Move up", - "moveDown": "↧ Move down", - "splitRight": "Split right", - "splitLeft": "Split left", - "splitUp": "Split above", - "splitDown": "Split below", - "newTab": "New tab ({name})", "panelDb": "Databases", "panelDocker": "Docker", "panelHttp": "HTTP", diff --git a/src/i18n/es.json b/src/i18n/es.json index 9311c96..98a09d3 100644 --- a/src/i18n/es.json +++ b/src/i18n/es.json @@ -27,6 +27,8 @@ "agentWaiting": "esperando", "agentWorking": "trabajando", "agentIdle": "inactivo", + "ramUsage": "RAM física usada por Bento y sus procesos", + "ramUnavailable": "Uso de memoria RAM no disponible", "resize": "Redimensionar", "top": "arriba", "bottom": "abajo", @@ -35,15 +37,6 @@ "windowBorders": "Bordes de ventana", "theme": "Tema: {name}", "panelNotRegistered": "Panel no registrado: {name}", - "moveRight": "↦ Mover a la derecha", - "moveLeft": "↤ Mover a la izquierda", - "moveUp": "↥ Mover arriba", - "moveDown": "↧ Mover abajo", - "splitRight": "Dividir derecha", - "splitLeft": "Dividir izquierda", - "splitUp": "Dividir arriba", - "splitDown": "Dividir abajo", - "newTab": "Nueva pestaña ({name})", "panelDb": "Bases de datos", "panelDocker": "Docker", "panelHttp": "HTTP", diff --git a/src/panels/agents/AgentsPanel.ts b/src/panels/agents/AgentsPanel.ts index 594dfc8..62d36dc 100644 --- a/src/panels/agents/AgentsPanel.ts +++ b/src/panels/agents/AgentsPanel.ts @@ -524,11 +524,10 @@ export function createAgentsPanel(projectPath = '', opts: AgentsPanelOptions = { renderSidebar() } + // ── Fit ─────────────────────────────────────────────────────── const fit = () => { - if (activeIndex >= 0 && slots[activeIndex]) { - slots[activeIndex].handle.fit?.() - } + if (activeIndex >= 0) slots[activeIndex]?.handle.fit?.() } // Save agents synchronously if the page is torn down (reload/close) before a diff --git a/src/panels/docker/DockerPanel.ts b/src/panels/docker/DockerPanel.ts index 43cab67..ee18f6e 100644 --- a/src/panels/docker/DockerPanel.ts +++ b/src/panels/docker/DockerPanel.ts @@ -1,14 +1,15 @@ import { t as i18nT } from '../../i18n' import { invoke } from '@tauri-apps/api/core' import { parseContainers, isRunning, groupByProject, runningCount, type Container } from '../../core/docker/containers' -import { renderContainerLogs, renderContainerTerminal } from './containerDetail' +import { renderContainerLogs, renderContainerTerminal, type DetailLifecycle } from './containerDetail' import { createCollapsibleSidebar } from '../../ui/collapsibleSidebar' import { icon } from '../../ui/icons' export function createDockerPanel(filterPrefix?: string): { element: HTMLElement; dispose: () => void } { let containers: Container[] = [] // Teardown for the current detail's body (live stream / exec terminal). - let bodyCleanup: () => void = () => {} + const emptyLifecycle = (): DetailLifecycle => ({ pause: () => {}, resume: () => {}, dispose: () => {} }) + let bodyLifecycle = emptyLifecycle() const iconBtn = (name: string, title: string, onClick: () => void): HTMLButtonElement => { const b = document.createElement('button') @@ -124,18 +125,18 @@ export function createDockerPanel(filterPrefix?: string): { element: HTMLElement // ---- logs + terminal: delegated to shared containerDetail module ---- function showLogs(body: HTMLElement, c: Container): void { - bodyCleanup() - bodyCleanup = renderContainerLogs(c, body) + bodyLifecycle.dispose() + bodyLifecycle = renderContainerLogs(c, body) } async function showTerminal(body: HTMLElement, c: Container, backToLogs: () => void): Promise { - bodyCleanup() - bodyCleanup = await renderContainerTerminal(c, body, backToLogs) + bodyLifecycle.dispose() + bodyLifecycle = await renderContainerTerminal(c, body, backToLogs) } function renderDetail(name: string): void { - bodyCleanup() - bodyCleanup = () => {} + bodyLifecycle.dispose() + bodyLifecycle = emptyLifecycle() const c = find(name) if (!c) { detail.replaceChildren(Object.assign(document.createElement('div'), { className: 'docker-detail-hint', textContent: i18nT('docker.selectAContainerToViewItsDetailsAnd') })) @@ -192,5 +193,5 @@ export function createDockerPanel(filterPrefix?: string): { element: HTMLElement } load() - return { element: root, dispose: () => bodyCleanup() } + return { element: root, dispose: () => bodyLifecycle.dispose() } } diff --git a/src/panels/docker/containerDetail.ts b/src/panels/docker/containerDetail.ts index 64a32f2..52b4182 100644 --- a/src/panels/docker/containerDetail.ts +++ b/src/panels/docker/containerDetail.ts @@ -7,6 +7,14 @@ import { createTerminalPanel } from '../terminal/TerminalPanel' import { icon } from '../../ui/icons' import type { Container } from '../../core/docker/containers' +export interface DetailLifecycle { + pause: () => void + resume: () => void + dispose: () => void +} + +const noOp = (): void => {} + function btn(name: string, title: string, onClick: () => void): HTMLButtonElement { const b = document.createElement('button') b.className = 'docker-action' @@ -16,12 +24,16 @@ function btn(name: string, title: string, onClick: () => void): HTMLButtonElemen return b } -// Renders logs for a container into `target`. Returns a cleanup (stops live stream). -export function renderContainerLogs(c: Container, target: HTMLElement): () => void { +// Renders logs for a container into `target`. Live streaming can be paused while +// its panel is hidden without confusing that temporary pause with disposal. +export function renderContainerLogs(c: Container, target: HTMLElement): DetailLifecycle { const pre = document.createElement('pre') pre.className = 'docker-logs' let rawLogs = '', errorsOnly = false, live = false let unlisten: (() => void) | null = null + let resumeLive = false + let disposed = false + let streamGeneration = 0 const applyStatic = (): void => { pre.textContent = errorsOnly @@ -41,20 +53,29 @@ export function renderContainerLogs(c: Container, target: HTMLElement): () => vo pre.textContent += text pre.scrollTop = pre.scrollHeight } - const stopLive = (): void => { + const stopLiveStream = (): void => { if (!live) return live = false + streamGeneration += 1 liveBtn.innerHTML = icon('play'); liveBtn.title = i18nT('docker.followLiveLogs'); liveBtn.classList.remove('active') invoke('docker_logs_stop', { id: c.name }).catch(() => {}) unlisten?.(); unlisten = null } + const stopLive = (): void => { + resumeLive = false + stopLiveStream() + } const startLive = async (): Promise => { + if (disposed || live) return + const generation = ++streamGeneration live = true liveBtn.innerHTML = icon('stop'); liveBtn.title = i18nT('docker.stopFollowing'); liveBtn.classList.add('active') rawLogs = ''; pre.textContent = '' try { await invoke('docker_logs_follow', { id: c.name, tail: 200 }) - unlisten = await listen(`docker-logs-${c.name}`, e => onChunk(e.payload)) + const stopListening = await listen(`docker-logs-${c.name}`, e => onChunk(e.payload)) + if (disposed || !live || generation !== streamGeneration) stopListening() + else unlisten = stopListening } catch (e) { pre.textContent = String(e) } } @@ -82,15 +103,30 @@ export function renderContainerLogs(c: Container, target: HTMLElement): () => vo target.replaceChildren(head, pre) loadStatic() - return stopLive + return { + pause: () => { + resumeLive = live + stopLiveStream() + }, + resume: () => { + if (!resumeLive || disposed) return + resumeLive = false + void startLive() + }, + dispose: () => { + disposed = true + resumeLive = false + stopLiveStream() + }, + } } -// Renders a docker exec terminal into `target`. Returns a cleanup (disposes terminal). -export async function renderContainerTerminal(c: Container, target: HTMLElement, onBack?: () => void): Promise<() => void> { +// Interactive terminals deliberately stay alive while their panel is hidden. +export async function renderContainerTerminal(c: Container, target: HTMLElement, onBack?: () => void): Promise { const argv = await invoke('docker_exec_argv', { container: c.name }).catch(() => null) if (!argv) { target.replaceChildren(Object.assign(document.createElement('div'), { className: 'docker-detail-hint', textContent: i18nT('docker.couldNotOpenTheTerminal') })) - return () => {} + return { pause: noOp, resume: noOp, dispose: noOp } } const term = createTerminalPanel('', '', onBack, argv) const wrap = document.createElement('div') @@ -98,5 +134,5 @@ export async function renderContainerTerminal(c: Container, target: HTMLElement, wrap.appendChild(term.element) target.replaceChildren(wrap) requestAnimationFrame(() => term.fit()) - return () => term.dispose() + return { pause: noOp, resume: () => term.fit(), dispose: () => term.dispose() } } diff --git a/src/panels/lazyPanel.ts b/src/panels/lazyPanel.ts index b541ec5..6d0c40b 100644 --- a/src/panels/lazyPanel.ts +++ b/src/panels/lazyPanel.ts @@ -12,6 +12,7 @@ export function lazyPanel(load: () => Promise): PanelInstance { let disposed = false let titleCb: ((title: string) => void) | undefined let readyApi: PanelApi | undefined + let pendingVisibility: boolean | undefined load().then(instance => { if (disposed) { instance.dispose?.(); return } @@ -19,6 +20,9 @@ export function lazyPanel(load: () => Promise): PanelInstance { element.replaceChildren(instance.element) if (titleCb) instance.onTitleChange?.(titleCb) if (readyApi) instance.onReady?.(readyApi) + // If the panel was hidden while its module was still loading, apply the + // last known visibility now so it doesn't start active while off-screen. + if (pendingVisibility === false) instance.onVisibilityChange?.(false) instance.fit?.() }).catch(error => { if (disposed) return @@ -34,6 +38,10 @@ export function lazyPanel(load: () => Promise): PanelInstance { // Remembers the callback until the panel loads; then re-registers it. onTitleChange: cb => { titleCb = cb; return () => { titleCb = undefined } }, onReady: api => { readyApi = api; inner?.onReady?.(api) }, + onVisibilityChange: (visible) => { + pendingVisibility = visible + inner?.onVisibilityChange?.(visible) + }, getCwd: () => inner?.getCwd?.(), } } diff --git a/src/panels/registry.ts b/src/panels/registry.ts index e0f07aa..686de9b 100644 --- a/src/panels/registry.ts +++ b/src/panels/registry.ts @@ -8,6 +8,8 @@ export interface PanelContext { removeSelf: () => void // The session's project folder; new terminals start here projectPath?: string + // Opens a new panel of the same type in the same group + newSibling?: () => void } export interface PanelApi { @@ -23,6 +25,7 @@ export interface PanelInstance { dispose?: () => void onTitleChange?: (cb: (title: string) => void) => () => void onReady?: (api: PanelApi) => void + onVisibilityChange?: (visible: boolean) => void // Current working directory (terminals report it via OSC 7) getCwd?: () => string | undefined } diff --git a/src/panels/review/ReviewPanel.ts b/src/panels/review/ReviewPanel.ts index 9111322..8b58775 100644 --- a/src/panels/review/ReviewPanel.ts +++ b/src/panels/review/ReviewPanel.ts @@ -175,7 +175,7 @@ function extractFirstJsonObject(text: string): string | null { return null } -export function createReviewPanel(sessionPath?: string): { element: HTMLElement; dispose?: () => void } { +export function createReviewPanel(sessionPath?: string): { element: HTMLElement; dispose?: () => void; onVisibilityChange?: (visible: boolean) => void } { const root = document.createElement('div') root.className = 'review-panel' @@ -188,6 +188,7 @@ export function createReviewPanel(sessionPath?: string): { element: HTMLElement; let currentPrNumber: number | null = null let intervalId: ReturnType | null = null let autoRefresh = false + let panelVisible = true let existingComments: GhComment[] = [] let loadingBranch = '' let sidebarMode: SidebarMode = 'branches' @@ -1478,7 +1479,7 @@ export function createReviewPanel(sessionPath?: string): { element: HTMLElement; autoRefresh = on autoBtn.classList.toggle('review-icon-btn--active', on) if (intervalId) { clearInterval(intervalId); intervalId = null } - if (on) intervalId = setInterval(() => { if (selectedBranch) loadDiff() }, 5000) + if (on && panelVisible) intervalId = setInterval(() => { if (selectedBranch) loadDiff() }, 5000) } const pickRepo = async (): Promise => { @@ -1669,5 +1670,10 @@ export function createReviewPanel(sessionPath?: string): { element: HTMLElement; if (intervalId) clearInterval(intervalId) document.removeEventListener('keydown', handleKeydown) }, + onVisibilityChange: (visible: boolean) => { + panelVisible = visible + if (!visible && intervalId) { clearInterval(intervalId); intervalId = null } + else if (visible && autoRefresh && !intervalId) intervalId = setInterval(() => { if (selectedBranch) loadDiff() }, 5000) + }, } } diff --git a/src/panels/tasks/TaskDataLoader.ts b/src/panels/tasks/TaskDataLoader.ts index 3bf4571..8b647c6 100644 --- a/src/panels/tasks/TaskDataLoader.ts +++ b/src/panels/tasks/TaskDataLoader.ts @@ -28,14 +28,20 @@ export async function loadTaskData(options: { upstream: Map } renderList: (statuses: Map, runningPaths: Set) => void + shouldRestoreSelection: () => boolean selectRow: (row: HTMLElement) => void showChanges: (wt: Worktree) => void showRebasePaused: (wt: Worktree, status: RebaseStatus) => void }): Promise { - const { repoPath, panelStore, baseSelect, filterInput, listWrap, fetchAgeEl, note, setBaseBranch, setWorktrees, setJiraConfig, maps, renderList, selectRow, showChanges, showRebasePaused } = options + const { repoPath, panelStore, baseSelect, filterInput, listWrap, fetchAgeEl, note, setBaseBranch, setWorktrees, setJiraConfig, maps, renderList, shouldRestoreSelection, selectRow, showChanges, showRebasePaused } = options baseSelect.disabled = false filterInput.style.display = '' listWrap.replaceChildren(note(taskT('loading'), 'db-detail-loading')) + // Capture restoration state before any asynchronous enrichment. A user may + // select a freshly rendered row while Docker/Jira/GitHub data is still + // loading; that new interaction must not be mistaken for startup recovery + // and rebuild the detail view underneath their input. + const savedPath = panelStore.selected() try { const [defaultBranch, remoteBranches] = await Promise.all([ invoke('git_default_branch', { repo: repoPath }).catch(() => 'main'), @@ -48,6 +54,11 @@ export async function loadTaskData(options: { baseSelect.replaceChildren(...remoteBranches.map(branch => Object.assign(document.createElement('option'), { value: branch, textContent: taskT('baseOption', { branch }), selected: branch === baseBranch }))) const worktrees = await taskGit.worktrees(repoPath) setWorktrees(worktrees) + // Worktree discovery is the only data required to build the task list. + // Render it immediately: optional integrations such as Docker, Jira and + // GitHub CLI can be slow (or wait for a daemon/login) on CI and Windows, + // but must never leave the whole panel stuck on “Loading…”. + renderList(new Map(worktrees.map(wt => [wt.path, 0])), new Set()) const fetchedAt = worktrees[0] ? (await invoke<{ fetchedAt: number }>('git_fetch_info', { path: worktrees[0].path }).catch(() => ({ fetchedAt: 0 }))).fetchedAt : 0 if (fetchedAt) { const ageMinutes = Math.max(0, Math.floor((Date.now() / 1000 - fetchedAt) / 60)) @@ -75,8 +86,9 @@ export async function loadTaskData(options: { if (allContainers.some(c => isRunning(c) && c.name.startsWith(`${dir}-`))) runningPaths.add(wt.path) }) renderList(statuses, runningPaths) - const savedPath = panelStore.selected() - const selectedWt = worktrees.find(w => maps.rebase.get(w.path)?.active) ?? (savedPath ? worktrees.find(w => w.path === savedPath) : undefined) + const selectedWt = shouldRestoreSelection() + ? worktrees.find(w => maps.rebase.get(w.path)?.active) ?? (savedPath ? worktrees.find(w => w.path === savedPath) : undefined) + : undefined if (selectedWt) { const rows = listWrap.querySelectorAll('.tasks-row') const row = [...rows].find(item => item.dataset.path === selectedWt.path) diff --git a/src/panels/tasks/TaskDockerView.ts b/src/panels/tasks/TaskDockerView.ts index f942a64..45aeabf 100644 --- a/src/panels/tasks/TaskDockerView.ts +++ b/src/panels/tasks/TaskDockerView.ts @@ -5,7 +5,7 @@ import { confirm as askConfirm } from '@tauri-apps/plugin-dialog' import { isRunning, parseContainers, type Container } from '../../core/docker/containers' import type { Worktree } from '../../core/git/worktree' import { icon } from '../../ui/icons' -import { renderContainerLogs, renderContainerTerminal } from '../docker/containerDetail' +import { renderContainerLogs, renderContainerTerminal, type DetailLifecycle } from '../docker/containerDetail' import { taskT } from './i18n' export interface IsolateResult { @@ -42,7 +42,7 @@ export interface RecipeApplyResult { interface TaskDockerViewOptions { showDetail: (...nodes: HTMLElement[]) => void resetDetail: () => void - setCleanup: (cleanup: () => void) => void + setLifecycle: (lifecycle: DetailLifecycle) => void } function iconButton(name: string, title: string, onClick: () => void): HTMLButtonElement { @@ -79,7 +79,7 @@ export function createTaskDockerView(options: TaskDockerViewOptions) { body.className = 'tasks-logs-body' wrap.append(subHeader(shortName, goBack), body) options.showDetail(wrap) - options.setCleanup(renderContainerLogs(container, body)) + options.setLifecycle(renderContainerLogs(container, body)) } const showContainerTerminal = async (container: Container, shortName: string, goBack: () => void): Promise => { @@ -90,7 +90,7 @@ export function createTaskDockerView(options: TaskDockerViewOptions) { body.className = 'tasks-term-body' wrap.append(subHeader(shortName, goBack), body) options.showDetail(wrap) - options.setCleanup(await renderContainerTerminal(container, body, goBack)) + options.setLifecycle(await renderContainerTerminal(container, body, goBack)) } const showStackLogs = (worktree: Worktree, worktreeDirectory: string, goBack: () => void): void => { @@ -100,14 +100,18 @@ export function createTaskDockerView(options: TaskDockerViewOptions) { const logsBody = document.createElement('div') logsBody.className = 'tasks-logs-body' let live = false + let resumeLive = false + let disposed = false + let streamGeneration = 0 let unlisten: (() => void) | null = null const event = `docker-compose-logs-${worktreeDirectory}` const output = document.createElement('pre') output.className = 'docker-logs' - const stopLive = (): void => { + const stopLiveStream = (): void => { if (!live) return live = false + streamGeneration += 1 liveButton.innerHTML = icon('play') liveButton.title = taskT('followLiveLogs') liveButton.classList.remove('active') @@ -115,7 +119,13 @@ export function createTaskDockerView(options: TaskDockerViewOptions) { unlisten?.() unlisten = null } + const stopLive = (): void => { + resumeLive = false + stopLiveStream() + } const startLive = async (): Promise => { + if (disposed || live) return + const generation = ++streamGeneration live = true liveButton.innerHTML = icon('stop') liveButton.title = taskT('stopFollowingLogs') @@ -123,10 +133,12 @@ export function createTaskDockerView(options: TaskDockerViewOptions) { output.textContent = '' try { await invoke('docker_compose_logs_follow', { worktreePath: worktree.path, tail: 200 }) - unlisten = await listen(event, eventData => { + const stopListening = await listen(event, eventData => { output.textContent += eventData.payload output.scrollTop = output.scrollHeight }) + if (disposed || !live || generation !== streamGeneration) stopListening() + else unlisten = stopListening } catch (error) { output.textContent = String(error) } @@ -150,7 +162,11 @@ export function createTaskDockerView(options: TaskDockerViewOptions) { logsBody.append(header, output) wrap.append(subHeader(taskT('stackLogs'), goBack), logsBody) options.showDetail(wrap) - options.setCleanup(stopLive) + options.setLifecycle({ + pause: () => { resumeLive = live; stopLiveStream() }, + resume: () => { if (resumeLive && !disposed) { resumeLive = false; void startLive() } }, + dispose: () => { disposed = true; resumeLive = false; stopLiveStream() }, + }) void startLive() } @@ -225,8 +241,10 @@ export function createTaskDockerView(options: TaskDockerViewOptions) { } } void refresh() - const interval = setInterval(refresh, 3000) - options.setCleanup(() => clearInterval(interval)) + let pollInterval: ReturnType | null = setInterval(refresh, 3000) + const stopPoll = (): void => { if (pollInterval !== null) { clearInterval(pollInterval); pollInterval = null } } + const resumePoll = (): void => { stopPoll(); void refresh(); pollInterval = setInterval(refresh, 3000) } + options.setLifecycle({ pause: stopPoll, resume: resumePoll, dispose: stopPoll }) options.showDetail(wrap) } diff --git a/src/panels/tasks/TasksPanelRuntime.ts b/src/panels/tasks/TasksPanelRuntime.ts index bddf4b7..1d8790f 100644 --- a/src/panels/tasks/TasksPanelRuntime.ts +++ b/src/panels/tasks/TasksPanelRuntime.ts @@ -36,8 +36,9 @@ import { TauriAppSettingsRepository } from '../../adapters/TauriAppSettingsRepos import type { AppSettings } from '../../ports/AppSettingsRepository' import { isRunning, parseContainers } from '../../core/docker/containers' import { createCollapsibleSidebar } from '../../ui/collapsibleSidebar' +import type { DetailLifecycle } from '../docker/containerDetail' -export function createTasksPanel(panelId = 'default'): { element: HTMLElement; dispose: () => void } { +export function createTasksPanel(panelId = 'default'): { element: HTMLElement; dispose: () => void; onVisibilityChange: (visible: boolean) => void } { const panelStore = new TaskPanelStore(panelId) const settingsRepository = new TauriAppSettingsRepository() let appSettings: AppSettings = {} @@ -45,6 +46,27 @@ export function createTasksPanel(panelId = 'default'): { element: HTMLElement; d let worktrees: Worktree[] = [] let repoPath = panelStore.repository() let detailCleanup: () => void = () => {} + let detailPause: () => void = () => {} + let detailResume: () => void = () => {} + let panelVisible = true + const disposeDetail = (): void => { + // Invalidate async work started by the outgoing detail. Stopping an + // interval is not enough when one of its refresh requests is already in + // flight: without a new generation it could finish later and replace the + // newly opened commit/rebase/conflict UI. + detailVersion += 1 + const cleanup = detailCleanup + detailCleanup = () => {} + detailPause = () => {} + detailResume = () => {} + cleanup() + } + const setDetailLifecycle = (lifecycle: DetailLifecycle): void => { + detailCleanup = lifecycle.dispose + detailPause = lifecycle.pause + detailResume = lifecycle.resume + if (!panelVisible) detailPause() + } // Live agents hub per worktree. Kept alive across detail navigation so the // running agents/terminals survive switching to changes/history and back; // disposed only when the worktree is removed or the whole panel closes. @@ -284,8 +306,8 @@ export function createTasksPanel(panelId = 'default'): { element: HTMLElement; d } const dockerView = createTaskDockerView({ showDetail, - resetDetail: () => { stopDiffRefresh(); detailCleanup(); detailCleanup = () => {} }, - setCleanup: cleanup => { detailCleanup = cleanup }, + resetDetail: () => { stopDiffRefresh(); disposeDetail() }, + setLifecycle: setDetailLifecycle, }) const defaultProjectKey = (repository = repoPath): string => repository.replace(/\/$/, '').split('/').pop() ?? '' @@ -305,7 +327,7 @@ export function createTasksPanel(panelId = 'default'): { element: HTMLElement; d async function showTaskSettings(): Promise { stopDiffRefresh() - detailCleanup(); detailCleanup = () => {} + disposeDetail() showDetail(note(taskT('loading'), 'db-detail-loading')) await settingsReady @@ -874,6 +896,11 @@ export function createTasksPanel(panelId = 'default'): { element: HTMLElement; d }) const menuBtn = iconBtn('more', taskT('actions'), () => { + // iconBtn stops propagation, so opening the row menu must explicitly + // count as user interaction. Otherwise a slow startup enrichment can + // still "restore" the saved task after an action (for example Backups) + // has navigated elsewhere and replace that newly opened detail. + selectWorktree(row, wt) const r = menuBtn.getBoundingClientRect() showContextMenu(r.right - 4, r.bottom, menuItems()) }) @@ -953,7 +980,7 @@ export function createTasksPanel(panelId = 'default'): { element: HTMLElement; d // ---- detail: changes (GitHub-style diff + commit bar) ---- async function showChanges(wt: Worktree): Promise { stopDiffRefresh() - detailCleanup(); detailCleanup = () => {} + disposeDetail() const requestVersion = ++detailVersion showDetail(note(taskT('loadingChanges'), 'db-detail-loading')) try { @@ -967,7 +994,7 @@ export function createTasksPanel(panelId = 'default'): { element: HTMLElement; d showDetail(buildDiffView(raw, wt, { statusRaw: statusRaw.raw, rebaseActive })) // Auto-refresh: re-fetch diff every 5 s and update if content changed let lastSnapshot = `${statusRaw.raw}\0${raw}` - diffRefreshInterval = setInterval(async () => { + const refreshChanges = async (): Promise => { const [newRaw, newStatus] = await Promise.all([ invoke('git_diff', { path: wt.path }).catch(() => null), taskGit.safeStatus(wt.path), @@ -975,11 +1002,28 @@ export function createTasksPanel(panelId = 'default'): { element: HTMLElement; d if (requestVersion !== detailVersion) return const snapshot = `${newStatus.raw}\0${newRaw ?? ''}` if (newRaw !== null && snapshot !== lastSnapshot) { + const draft = detailPane.querySelector('[data-testid="tasks-commit-message"]') + // Replacing the entire diff also replaces the commit controls. Keep + // the current DOM stable while the user (or WebDriver) is editing so + // their text and the button they are about to activate cannot become + // stale underneath them. Once editing ends, the pending snapshot is + // intentionally retried on the next interval. + if (draft && (draft.value.length > 0 || document.activeElement === draft)) return lastSnapshot = snapshot showDetail(buildDiffView(newRaw, wt, { statusRaw: newStatus.raw, rebaseActive })) } - }, 5000) - detailCleanup = () => stopDiffRefresh() + } + const startDiffRefresh = (): void => { + stopDiffRefresh() + if (requestVersion !== detailVersion) return + diffRefreshInterval = setInterval(() => { void refreshChanges() }, 5000) + } + startDiffRefresh() + setDetailLifecycle({ + pause: stopDiffRefresh, + resume: () => { void refreshChanges(); startDiffRefresh() }, + dispose: stopDiffRefresh, + }) } catch (e) { showDetail(note(String(e), 'db-detail-error')) } } @@ -1110,7 +1154,7 @@ export function createTasksPanel(panelId = 'default'): { element: HTMLElement; d // ---- detail: choose an existing commit for fixup ---- async function showFixupPicker(wt: Worktree, files: string[] | undefined, incomingDiff: string, selectedPatch?: string): Promise { stopDiffRefresh() - detailCleanup(); detailCleanup = () => {} + disposeDetail() showDetail(note(taskT('loadingCommits'), 'db-detail-loading')) try { const worktreeBase = baseFor(wt) @@ -1243,7 +1287,7 @@ export function createTasksPanel(panelId = 'default'): { element: HTMLElement; d // ---- detail: automatic history backups ---- async function showBackupHistory(wt: Worktree): Promise { stopDiffRefresh() - detailCleanup(); detailCleanup = () => {} + disposeDetail() showDetail(note(taskT('loadingBackups'), 'db-detail-loading')) try { showDetail(await buildBackupHistoryView({ @@ -1257,7 +1301,7 @@ export function createTasksPanel(panelId = 'default'): { element: HTMLElement; d function showOperationHistory(wt: Worktree): void { stopDiffRefresh() - detailCleanup(); detailCleanup = () => {} + disposeDetail() const branch = wt.branch ?? taskT('detached') const repository = repositoryFor(wt) showDetail(buildOperationHistoryView({ @@ -1275,7 +1319,7 @@ export function createTasksPanel(panelId = 'default'): { element: HTMLElement; d // ---- detail: reset commits ---- function showResetView(wt: Worktree): void { stopDiffRefresh() - detailCleanup(); detailCleanup = () => {} + disposeDetail() showDetail(buildResetView({ worktree: wt, baseBranch: baseFor(wt), @@ -1289,7 +1333,7 @@ export function createTasksPanel(panelId = 'default'): { element: HTMLElement; d // ---- detail: commit log ---- async function showCommitGraph(wt: Worktree): Promise { stopDiffRefresh() - detailCleanup(); detailCleanup = () => {} + disposeDetail() await buildGraphView({ worktree: wt, baseBranch: baseFor(wt), @@ -1302,7 +1346,7 @@ export function createTasksPanel(panelId = 'default'): { element: HTMLElement; d function showPrDetails(wt: Worktree, pr: PrStatus): void { stopDiffRefresh() - detailCleanup(); detailCleanup = () => {} + disposeDetail() showDetail(buildPrStatusView({ pr, baseBranch: baseFor(wt), onBack: () => showChanges(wt), @@ -1312,7 +1356,7 @@ export function createTasksPanel(panelId = 'default'): { element: HTMLElement; d async function showCommitLog(wt: Worktree): Promise { stopDiffRefresh() - detailCleanup(); detailCleanup = () => {} + disposeDetail() showDetail(note(taskT('loadingHistory'), 'db-detail-loading')) try { const entries = await taskGit.log(wt.path) @@ -1333,7 +1377,7 @@ export function createTasksPanel(panelId = 'default'): { element: HTMLElement; d // ---- detail: interactive rebase ---- async function showInteractiveRebase(wt: Worktree): Promise { stopDiffRefresh() - detailCleanup(); detailCleanup = () => {} + disposeDetail() showDetail(note(taskT('loading'), 'db-detail-loading')) try { const st = await invoke('git_rebase_status', { path: wt.path }) @@ -1635,10 +1679,12 @@ export function createTasksPanel(panelId = 'default'): { element: HTMLElement; d // ---- Inline conflict resolver ---- function showConflictResolver(wt: Worktree, file: string, onBack: () => void): void { stopDiffRefresh() + disposeDetail() showDetail(buildConflictResolverView({ path: wt.path, file, onBack })) } function showRebasePaused(wt: Worktree, st: RebaseStatus): void { + disposeDetail() const wrap = document.createElement('div') wrap.className = 'tasks-rebase-paused' @@ -1676,6 +1722,11 @@ export function createTasksPanel(panelId = 'default'): { element: HTMLElement; d const continueBtn = Object.assign(document.createElement('button'), { className: 'tasks-commit-btn', textContent: taskT('continueRebase') }) let intervalId = 0 + const stopPolling = (): void => { + clearInterval(intervalId) + intervalId = 0 + } + let resumePolling: () => void editBtn.addEventListener('click', () => showChanges(wt)) splitBtn.addEventListener('click', async () => { @@ -1802,19 +1853,25 @@ export function createTasksPanel(panelId = 'default'): { element: HTMLElement; d wrap.appendChild(conflictList) // Auto-refresh conflict list in case user resolves from terminal - intervalId = window.setInterval(async () => { + const refreshConflicts = async (): Promise => { const fresh = await invoke('git_rebase_status', { path: wt.path }).catch(() => null) if (!fresh) return - if (!fresh.active) { clearInterval(intervalId); showChanges(wt); load(); return } + if (!fresh.active) { stopPolling(); showChanges(wt); load(); return } const freshConflicts = fresh.conflicts ?? [] freshConflicts.forEach(f => { if (!freshConflicts.includes(f)) resolved.delete(f) }) if (freshConflicts.length === 0) { - clearInterval(intervalId) + stopPolling() showRebasePaused(wt, fresh) } else { renderConflicts(freshConflicts) } - }, 4000) + } + const startPolling = (): void => { + stopPolling() + intervalId = window.setInterval(() => { void refreshConflicts() }, 4000) + } + resumePolling = () => { void refreshConflicts(); startPolling() } + startPolling() continueBtn.disabled = conflicts.length > 0 @@ -1834,11 +1891,16 @@ export function createTasksPanel(panelId = 'default'): { element: HTMLElement; d }).catch(() => {}) } refreshDiff() - intervalId = window.setInterval(refreshDiff, 5000) + const startPolling = (): void => { + stopPolling() + intervalId = window.setInterval(refreshDiff, 5000) + } + resumePolling = () => { refreshDiff(); startPolling() } + startPolling() wrap.appendChild(diffWrap) } - detailCleanup = () => clearInterval(intervalId) + setDetailLifecycle({ pause: stopPolling, resume: resumePolling, dispose: stopPolling }) actionsEl.append(statusEl, abortBtn, editBtn, splitBtn, continueBtn) wrap.appendChild(actionsEl) showDetail(wrap) @@ -1847,7 +1909,7 @@ export function createTasksPanel(panelId = 'default'): { element: HTMLElement; d // ---- detail: worktree terminal ---- async function showWorktreeTerminal(wt: Worktree): Promise { stopDiffRefresh() - detailCleanup(); detailCleanup = () => {} + disposeDetail() const { createAgentsPanel } = await import('../agents/AgentsPanel') // Reuse the live hub for this worktree if we already opened it; otherwise // create one scoped to the worktree (own storage, off the global dock). @@ -1868,13 +1930,17 @@ export function createTasksPanel(panelId = 'default'): { element: HTMLElement; d // hub stays alive in the cache, so the agents keep running. Persist on leave // so they're restorable even if the tab closes without a clean dispose. const livePanel = panel - detailCleanup = () => livePanel.persist() + setDetailLifecycle({ + pause: () => {}, + resume: () => livePanel.fit(), + dispose: () => livePanel.persist(), + }) } // ---- detail: git sync error (with conflict detection + AI explain) ---- function showSyncError(mode: string, errorText: string, wt: Worktree): void { stopDiffRefresh() - detailCleanup(); detailCleanup = () => {} + disposeDetail() buildSyncErrorView({ mode, errorText, path: wt.path, showDetail, iconButton: iconBtn, status: path => taskGit.status(path) }) } @@ -2012,6 +2078,7 @@ export function createTasksPanel(panelId = 'default'): { element: HTMLElement; d } baseSelect.disabled = false baseRow.style.display = '' + const selectionVersionAtLoad = selectionVersion await loadTaskData({ repoPath, panelStore, @@ -2025,6 +2092,7 @@ export function createTasksPanel(panelId = 'default'): { element: HTMLElement; d setJiraConfig: value => { jiraCfg = value }, maps: { issue: issueMap, aheadBehind: aheadBehindMap, pr: prStatusMap, backup: backupStatusMap, rebase: rebaseStatusMap, upstream: upstreamStatusMap }, renderList, + shouldRestoreSelection: () => selectionVersion === selectionVersionAtLoad, selectRow, showChanges, showRebasePaused, @@ -2050,8 +2118,18 @@ export function createTasksPanel(panelId = 'default'): { element: HTMLElement; d load() // Dispose all live worktree hubs when the panel/tab closes (persists each). const dispose = (): void => { + stopDiffRefresh() + disposeDetail() for (const panel of worktreeTerminals.values()) panel.dispose() worktreeTerminals.clear() } - return { element: root, dispose } + return { + element: root, + dispose, + onVisibilityChange: (visible: boolean) => { + panelVisible = visible + if (!visible) detailPause() + else detailResume() + }, + } } diff --git a/src/panels/terminal/TerminalPanel.ts b/src/panels/terminal/TerminalPanel.ts index bcf5bc9..de74cfb 100644 --- a/src/panels/terminal/TerminalPanel.ts +++ b/src/panels/terminal/TerminalPanel.ts @@ -14,16 +14,12 @@ import { dimsChanged, type Dims } from '../../core/terminal/dims' import { splitAtSyncBoundary } from '../../core/terminal/syncOutput' import { getThemeName, onThemeChange } from './themePreference' import { nextTheme } from '../../core/terminal/nextTheme' -import type { TerminalProfile } from '../../core/terminal/profiles' import { createActivityTracker } from '../../core/terminal/activityTracker' import { createAgentStatusTracker } from '../../core/terminal/agentStatusTracker' import type { AgentStore } from '../../core/terminal/agentStore' import { parseOsc7Path, toDisplayPath } from '../../core/terminal/osc7' import { createSearchBar } from './searchBar' -import { createTerminalAppearanceControls } from './appearanceControls' -import { createTerminalProfileControls } from './profileControls' import { askAi } from '../../ui/askAi' -import { icon } from '../../ui/icons' import type { PanelApi } from '../registry' import 'xterm/css/xterm.css' @@ -47,7 +43,7 @@ export interface TerminalPanelHandle { focus: () => void dispose: () => void onTitleChange: (cb: (title: string) => void) => () => void - onReady: (api: PanelApi) => void + onReady?: (api: PanelApi) => void getCwd: () => string | undefined sendInput: (text: string) => void onInput: (cb: (line: string) => void) => () => void @@ -63,7 +59,7 @@ export function createTerminalPanel(panelId = '', projectPath = '', onExit?: () const root = document.createElement('div') root.className = 'terminal-panel' - let localFontFamily = DEFAULT_FONT_FAMILY + const localFontFamily = DEFAULT_FONT_FAMILY const term = new Terminal({ cursorBlink: true, @@ -80,7 +76,7 @@ export function createTerminalPanel(panelId = '', projectPath = '', onExit?: () // Opaque background: allowTransparency makes the renderer clear-to-transparent // and repaint each frame, which flickers on animated fullscreen TUIs. allowTransparency: false, - scrollback: 10000, + scrollback: 2000, theme: getTheme(getThemeName()), }) @@ -131,34 +127,6 @@ export function createTerminalPanel(panelId = '', projectPath = '', onExit?: () applyLocalTheme(localTheme) } - const applyCustomBackground = (bg: string) => { - followGlobal = false - const base = getTheme(localTheme) - const custom = { ...base, background: bg, cursorAccent: bg } - term.options.theme = custom - applyBackground(bg) - } - - const appearance = createTerminalAppearanceControls({ - themeName: localTheme, - onThemeSelected: name => { - followGlobal = false - localTheme = name - applyLocalTheme(name) - }, - onCustomBackground: applyCustomBackground, - onShellChanged: shell => { - restartShell(shell) - }, - onFontChanged: font => { - localFontFamily = font.trim() || DEFAULT_FONT_FAMILY - term.options.fontFamily = localFontFamily - fit() - }, - }) - const { popover, themeButton: themeBtn, shellSelect, fontInput } = appearance - root.append(popover, themeBtn) - root.addEventListener('click', () => popover.classList.add('hidden')) const fitAddon = new FitAddon() const searchAddon = new SearchAddon() @@ -203,12 +171,6 @@ export function createTerminalPanel(panelId = '', projectPath = '', onExit?: () .catch(err => term.writeln(`\r\n\x1b[31mError PTY: ${err}\x1b[0m`)) } - const restartShell = (shellPath: string): void => { - invoke('pty_kill', { id }).catch(() => {}) - term.reset() - spawnShell(shellPath) - popover.classList.add('hidden') - } spawnShell('auto') @@ -466,41 +428,6 @@ export function createTerminalPanel(panelId = '', projectPath = '', onExit?: () return () => { titleCallback = undefined; d1.dispose(); d2.dispose() } } - const maxBtn = document.createElement('button') - maxBtn.className = 'term-theme-btn term-max-btn' - maxBtn.title = i18nT('terminal.maximizeRestore') - maxBtn.innerHTML = icon('expand') - - const onReady = (panelApi: PanelApi) => { - maxBtn.addEventListener('click', () => { - if (panelApi.isMaximized()) panelApi.exitMaximized() - else panelApi.maximize() - }) - - const profiles = createTerminalProfileControls({ - getSettings: () => ({ - shell: shellSelect.value, - theme: localTheme, - fontSize: term.options.fontSize ?? BASE_FONT_SIZE, - fontFamily: localFontFamily !== DEFAULT_FONT_FAMILY ? localFontFamily : undefined, - }), - onSelect: (profile: TerminalProfile) => { - followGlobal = false - localTheme = profile.theme - applyLocalTheme(profile.theme) - setFontSize(profile.fontSize) - if (profile.fontFamily) { - localFontFamily = profile.fontFamily - fontInput.value = profile.fontFamily - term.options.fontFamily = profile.fontFamily - } - restartShell(profile.shell) - }, - }) - popover.appendChild(profiles.element) - } - - root.appendChild(maxBtn) // Run a command here: focus and write it + Enter. If the shell is still // starting up, queue it (early writes get discarded) and flush when ready. @@ -511,7 +438,7 @@ export function createTerminalPanel(panelId = '', projectPath = '', onExit?: () } return { - element: root, fit, focus: () => term.focus(), dispose, onTitleChange, onReady, + element: root, fit, focus: () => term.focus(), dispose, onTitleChange, getCwd: () => lastCwd || undefined, sendInput, onInput, onBell, getSnapshot: () => { try { return serializeAddon.serialize({ scrollback: 500 }) } catch { return '' } }, // A malformed/huge restored snapshot must not throw — that would drop the diff --git a/src/panels/tv/TVPanel.ts b/src/panels/tv/TVPanel.ts index 63e4240..0315c31 100644 --- a/src/panels/tv/TVPanel.ts +++ b/src/panels/tv/TVPanel.ts @@ -10,13 +10,14 @@ import { renderGrid } from './grid' import { HLSPlayer } from './player' import { icon } from '../../ui/icons' import { getCurrentWindow } from '@tauri-apps/api/window' +import type { PanelInstance } from '../registry' // repo = lightweight base (M3U); worldRepo = heavy source loaded on demand export function createTVPanel( repo: ChannelRepository, favoritesRepo: FavoritesRepository, worldRepo?: ChannelRepository -): HTMLElement { +): PanelInstance { const root = document.createElement('div') root.className = 'tv-panel' @@ -57,9 +58,14 @@ export function createTVPanel( toggleButton.innerHTML = icon('panel') toggleButton.title = i18nT('tv.showHideChannelList') + const pipButton = document.createElement('button') + pipButton.className = 'tv-btn' + pipButton.innerHTML = icon('pip') + pipButton.title = 'Picture in Picture' + toolbar.append(input, countrySelect, categorySelect, status) if (worldRepo) toolbar.append(worldButton) - toolbar.append(favButton, fullscreenButton, toggleButton) + toolbar.append(favButton, pipButton, fullscreenButton, toggleButton) const main = document.createElement('div') main.className = 'tv-main' @@ -87,7 +93,8 @@ export function createTVPanel( // dockview the panel can take up any fraction). Container queries don't work // here because they would break the position:fixed of cinema mode. const syncWide = (w: number) => main.classList.toggle('wide', w >= 700) - new ResizeObserver(entries => { for (const e of entries) syncWide(e.contentRect.width) }).observe(main) + const resizeObserver = new ResizeObserver(entries => { for (const e of entries) syncWide(e.contentRect.width) }) + resizeObserver.observe(main) let data: ChannelData = { channels: [], countries: [], categories: [] } let allChannels: Channel[] = [] @@ -156,9 +163,8 @@ export function createTVPanel( getCurrentWindow().setFullscreen(on).catch(() => {}) } fullscreenButton.addEventListener('click', () => setCinema(!cinema)) - window.addEventListener('keydown', e => { - if (e.key === 'Escape' && cinema) setCinema(false) - }) + const onEscapeKey = (e: KeyboardEvent) => { if (e.key === 'Escape' && cinema) setCinema(false) } + window.addEventListener('keydown', onEscapeKey) favButton.addEventListener('click', () => { onlyFavorites = !onlyFavorites favButton.classList.toggle('active', onlyFavorites) @@ -179,10 +185,39 @@ export function createTVPanel( } }) + const onEnterPip = () => pipButton.classList.add('active') + const onLeavePip = () => pipButton.classList.remove('active') + document.addEventListener('enterpictureinpicture', onEnterPip) + document.addEventListener('leavepictureinpicture', onLeavePip) + + pipButton.addEventListener('click', async () => { + try { + await player.pip() + } catch { + const prev = pipButton.title + pipButton.title = 'Reproduce un canal primero' + pipButton.style.opacity = '0.4' + setTimeout(() => { pipButton.title = prev; pipButton.style.opacity = '' }, 1500) + } + }) + status.textContent = i18nT('tv.loading') repo.fetchAll() .then(applyData) .catch(err => { status.textContent = i18nT('tv.errorMessage', { message: err.message }) }) - return root + return { + element: root, + dispose: () => { + player.dispose() + resizeObserver.disconnect() + window.removeEventListener('keydown', onEscapeKey) + document.removeEventListener('enterpictureinpicture', onEnterPip) + document.removeEventListener('leavepictureinpicture', onLeavePip) + }, + onVisibilityChange: (visible: boolean) => { + if (!visible && !player.isInPiP) player.pause() + else if (visible) player.resume() + }, + } } diff --git a/src/panels/tv/definition.ts b/src/panels/tv/definition.ts index e357c91..d34b6d0 100644 --- a/src/panels/tv/definition.ts +++ b/src/panels/tv/definition.ts @@ -14,7 +14,7 @@ export function tvPanelDefinition( title: appT('panelTv'), create: () => lazyPanel(async () => { const { createTVPanel } = await import('./TVPanel') - return { element: createTVPanel(repo, favoritesRepo, worldRepo) } + return createTVPanel(repo, favoritesRepo, worldRepo) }), } } diff --git a/src/panels/tv/player.ts b/src/panels/tv/player.ts index 21a6180..d978fe1 100644 --- a/src/panels/tv/player.ts +++ b/src/panels/tv/player.ts @@ -11,6 +11,8 @@ export class HLSPlayer { readonly element: HTMLDivElement private readonly video: HTMLVideoElement private readonly iframe: HTMLIFrameElement + private iframeUrl: string | null = null + private iframePaused = false onStatus?: (status: PlayerStatus) => void constructor() { @@ -44,6 +46,8 @@ export class HLSPlayer { if (isEmbedUrl(url)) { this.video.classList.add('hidden') this.iframe.classList.remove('hidden') + this.iframeUrl = url + this.iframePaused = false this.iframe.src = url this.onStatus?.('playing') return @@ -66,7 +70,7 @@ export class HLSPlayer { const { default: Hls } = await import('hls.js') const mode = choosePlaybackMode(canPlayNative, Hls.isSupported()) if (mode !== 'hls') { this.onStatus?.('error'); return } - this.hls = new Hls({ lowLatencyMode: false }) + this.hls = new Hls({ lowLatencyMode: false, maxBufferLength: 10, maxMaxBufferLength: 20 }) this.hls.on(Hls.Events.ERROR, (_e, data) => { if (data.fatal) this.onStatus?.('error') }) this.hls.loadSource(url) this.hls.attachMedia(this.video) @@ -82,6 +86,42 @@ export class HLSPlayer { tracks.forEach((t, i) => { t.mode = i === index ? 'showing' : 'disabled' }) } + get isInPiP(): boolean { + return document.pictureInPictureElement === this.video + } + + pause(): void { + this.video.pause() + this.hls?.stopLoad() + if (!this.iframe.classList.contains('hidden') && this.iframe.hasAttribute('src')) { + // Third-party embeds cannot be paused reliably with postMessage. Unload + // them while hidden to stop audio, network traffic and retained memory. + this.iframePaused = true + this.iframe.removeAttribute('src') + } + } + + resume(): void { + if (this.iframePaused && this.iframeUrl) { + this.iframePaused = false + this.iframe.src = this.iframeUrl + this.onStatus?.('playing') + return + } + if (this.video.classList.contains('hidden') || !this.video.src) return + this.hls?.startLoad() + this.video.play().catch(() => {}) + } + + async pip(): Promise { + if (this.video.classList.contains('hidden')) throw new Error('pip-not-video') + if (document.pictureInPictureElement === this.video) { + await document.exitPictureInPicture() + } else { + await this.video.requestPictureInPicture() + } + } + stop(): void { if (this.hls) { this.hls.destroy() @@ -89,6 +129,8 @@ export class HLSPlayer { } this.video.removeAttribute('src') this.video.load() + this.iframeUrl = null + this.iframePaused = false this.iframe.removeAttribute('src') } diff --git a/src/panels/web/WebPanel.ts b/src/panels/web/WebPanel.ts index 1f9d087..723b789 100644 --- a/src/panels/web/WebPanel.ts +++ b/src/panels/web/WebPanel.ts @@ -273,10 +273,11 @@ export function createWebPanel() { let intersecting = true let suppressed = false let menuOpen = false + let dockviewVisible = true const reevaluate = () => { if (!currentUrl) return const style = getComputedStyle(content) - const visible = isWebviewVisible({ + const visible = dockviewVisible && isWebviewVisible({ intersecting, visibility: style.visibility, display: style.display, @@ -322,6 +323,7 @@ export function createWebPanel() { element: root, fit: () => updateBounds(), focus: () => input.focus(), + onVisibilityChange: (visible: boolean) => { dockviewVisible = visible; reevaluate() }, dispose: () => { resizeObserver.disconnect() intersectionObserver.disconnect() diff --git a/src/styles.css b/src/styles.css index da3fa0a..ac2111b 100644 --- a/src/styles.css +++ b/src/styles.css @@ -40,8 +40,7 @@ input:focus, textarea:focus, select:focus { } .tv-btn, -.window-control, -.term-theme-btn { +.window-control { display: inline-flex; align-items: center; justify-content: center; @@ -199,11 +198,36 @@ html, body, #app { border-top: 1px solid color-mix(in srgb, var(--border) 80%, transparent); background: color-mix(in srgb, #050608 88%, transparent); color: var(--fg-dim); - overflow-x: auto; + overflow: hidden; flex-shrink: 0; -webkit-app-region: drag; } .agent-status-bar.hidden { display: none; } +.agent-status-list { + display: flex; + align-items: center; + gap: 8px; + flex: 1 1 auto; + min-width: 0; + overflow-x: auto; + scrollbar-width: none; +} +.agent-status-list::-webkit-scrollbar { display: none; } +.app-memory-status { + display: inline-flex; + align-items: center; + gap: 5px; + flex-shrink: 0; + margin-left: auto; + padding: 2px 7px; + border-left: 1px solid var(--border); + font-size: 11px; + font-variant-numeric: tabular-nums; + white-space: nowrap; + -webkit-app-region: no-drag; +} +.app-memory-label { color: var(--fg-dim); font-weight: 600; } +.app-memory-value { color: var(--fg); min-width: 48px; text-align: right; } .agent-status-chip { -webkit-app-region: no-drag; display: inline-flex; @@ -308,6 +332,7 @@ html, body, #app { .session-instance { position: absolute; inset: 0; + contain: layout style paint; background: radial-gradient(60% 50% at 100% 0%, color-mix(in srgb, var(--accent) 18%, transparent), transparent 65%), radial-gradient(50% 40% at 0% 100%, color-mix(in srgb, var(--accent-2) 10%, transparent), transparent 60%), @@ -462,7 +487,7 @@ html, body, #app { /* Launcher-driven navigation: the panel tab bar is hidden entirely so panels fill to the very top. Open/switch from the side launcher; close via right-click. */ .workspace-view .dv-groupview > .dv-tabs-and-actions-container { - display: none; + display: none !important; } .panel-placeholder { @@ -480,6 +505,7 @@ html, body, #app { .panel-lazy { width: 100%; height: 100%; + contain: layout style paint; } .panel-load-error { padding: 16px; color: #f7768e; white-space: pre-wrap; overflow: auto; } @@ -490,6 +516,86 @@ html, body, #app { background: transparent !important; } +/* Terminal hub: tab strip + stacked terminal instances */ +.terminal-hub { position: relative; width: 100%; height: 100%; } + +.terminal-hub-bar { + position: absolute; + top: 0; left: 0; right: 0; + height: 30px; + z-index: 1; + display: flex; + align-items: center; + background: var(--surface-2); + border-bottom: 1px solid var(--border); + overflow-x: auto; + scrollbar-width: none; +} +.terminal-hub-bar::-webkit-scrollbar { display: none; } + +.terminal-hub-tab { + display: flex; + align-items: center; + gap: 4px; + padding: 0 8px 0 10px; + height: 100%; + background: transparent; + border: none; + border-right: 1px solid var(--border); + color: var(--fg-dim); + font-size: 11px; + white-space: nowrap; + cursor: pointer; + flex-shrink: 0; +} +.terminal-hub-tab.active { background: var(--surface-2); color: var(--fg); } +.terminal-hub-tab:hover:not(.active) { background: var(--surface-hover); color: var(--fg); } + +.terminal-hub-tab-label { max-width: 120px; overflow: hidden; text-overflow: ellipsis; } + +.terminal-hub-tab-close { + display: flex; + align-items: center; + padding: 1px; + background: transparent; + border: none; + color: inherit; + cursor: pointer; + opacity: 0.4; + border-radius: 2px; +} +.terminal-hub-tab-close:hover { opacity: 1; background: var(--surface-3); } +.terminal-hub-tab-close svg { width: 10px; height: 10px; } + +.terminal-hub-add { + display: flex; + align-items: center; + padding: 0 10px; + height: 100%; + background: transparent; + border: none; + border-right: 1px solid var(--border); + color: var(--fg); + font-size: 16px; + line-height: 1; + cursor: pointer; + flex-shrink: 0; +} +.terminal-hub-add:hover { color: var(--accent); } + +.terminal-hub-content { position: absolute; top: 30px; left: 0; right: 0; bottom: 0; } + +.terminal-hub-instance { + position: absolute; + inset: 0; + visibility: hidden; + pointer-events: none; +} +.terminal-hub-instance.terminal-hub-active { + visibility: visible; + pointer-events: auto; +} + /* Terminal: grid on the wrapper, xterm canvas sits on top */ .terminal-panel { background: @@ -528,175 +634,7 @@ html, body, #app { height: 0; } -.term-theme-btn { - position: absolute; - bottom: 8px; - right: 12px; - z-index: 15; - width: 26px; - height: 26px; - padding: 0; - background: var(--surface); - border: 1px solid var(--border); - border-radius: var(--radius); - font-size: 13px; - cursor: pointer; - opacity: 0.35; - transition: opacity 0.15s; -} - -.term-theme-btn:hover { - opacity: 1; -} - -.term-theme-popover { - position: absolute; - bottom: 42px; - right: 8px; - z-index: 20; - display: flex; - flex-direction: column; - gap: 10px; - padding: 12px; - background: color-mix(in srgb, var(--surface) 90%, transparent); - border: 1px solid var(--border); - border-radius: 10px; - backdrop-filter: blur(16px); - box-shadow: 0 8px 24px rgba(0,0,0,0.4); -} -.term-theme-popover.hidden { - display: none; -} - -.term-theme-swatches { - display: grid; - grid-template-columns: repeat(5, 1fr); - gap: 6px; -} - -.term-theme-swatch { - width: 28px; - height: 28px; - border-radius: 50%; - border: 2px solid transparent; - cursor: pointer; - transition: transform 0.1s, border-color 0.1s; -} - -.term-theme-swatch:hover { - transform: scale(1.2); - border-color: var(--fg) !important; -} - -.term-theme-color-row { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; - font-size: 11px; - color: var(--fg-dim); - cursor: pointer; -} - -.term-theme-color-row input[type="color"] { - width: 28px; - height: 28px; - border: 1px solid var(--border); - border-radius: 50%; - padding: 0; - cursor: pointer; - background: none; -} - -/* Terminal maximize button (next to the palette one) */ -.term-max-btn { - bottom: 8px; - right: 44px; -} - -/* Profiles section in the popover */ -.term-profiles-section { - border-top: 1px solid color-mix(in srgb, var(--border) 50%, transparent); - padding-top: 10px; - display: flex; - flex-direction: column; - gap: 6px; -} - -.term-profile-list { - display: flex; - flex-direction: column; - gap: 4px; -} - -.term-profile-row { - display: flex; - align-items: center; - gap: 4px; -} - -.term-profile-name { - flex: 1; - text-align: left; - background: var(--surface-2); - border: 1px solid var(--border); - border-radius: 4px; - color: var(--fg); - font-size: 11px; - padding: 3px 8px; - cursor: pointer; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.term-profile-name:hover { border-color: var(--accent); color: var(--accent); } - -.term-profile-del { - background: transparent; - border: none; - color: var(--fg-dim); - cursor: pointer; - font-size: 14px; - padding: 0 4px; - line-height: 1; - flex-shrink: 0; -} - -.term-profile-del:hover { color: var(--fg); } - -.term-profile-save { - background: transparent; - border: 1px dashed var(--border); - border-radius: 4px; - color: var(--fg-dim); - font-size: 11px; - padding: 4px 8px; - cursor: pointer; - text-align: left; - transition: color 0.1s, border-color 0.1s; -} - -.term-profile-save:hover { color: var(--accent); border-color: var(--accent); } - -/* "+" button in the header of each group */ -.group-add-tab { - height: 100%; - min-width: 28px; - padding: 0 8px; - background: transparent; - border: none; - color: var(--fg-dim); - font-size: 16px; - line-height: 1; - cursor: pointer; -} - -.group-add-tab:hover { - background: var(--surface-2); - color: var(--fg); -} /* Context menu */ .context-menu { @@ -1721,6 +1659,7 @@ html, body, #app { border-right: 1px solid var(--border); transition: width 0.18s ease; min-width: 32px; + will-change: width; } .cs-sidebar.collapsed { width: 32px !important; min-width: 32px; } @@ -1795,7 +1734,7 @@ html, body, #app { transition: opacity 0.12s; } .cs-sidebar-toggle:hover { opacity: 1; background: color-mix(in srgb, var(--fg) 10%, transparent); } -.cs-sidebar-toggle svg { width: 13px; height: 13px; transition: transform 0.18s; } +.cs-sidebar-toggle svg { width: 13px; height: 13px; transition: transform 0.18s; will-change: transform; } .cs-sidebar.collapsed .cs-sidebar-toggle svg { transform: rotate(180deg); } .cs-sidebar-list { flex: 1; overflow-y: auto; min-height: 0; } @@ -1838,6 +1777,7 @@ html, body, #app { background: color-mix(in srgb, var(--bg) 85%, var(--surface)); overflow: hidden; transition: width 0.2s ease; + will-change: width; } .agents-sidebar.collapsed { @@ -2133,6 +2073,7 @@ html, body, #app { pointer-events: auto; } + .agents-term-slot .terminal-panel { height: 100%; width: 100%; @@ -2188,24 +2129,7 @@ html, body, #app { opacity: 1; } -/* Group header "+" (Dockview): always visible */ -.group-add-tab { - opacity: 1; - color: var(--fg-dim); -} -.group-add-tab:hover { - color: var(--accent); -} - -/* Terminal theme button: only when hovering the panel */ -.term-theme-btn { - opacity: 0; -} - -.terminal-panel:hover .term-theme-btn { - opacity: 1; -} /* ===== Workspace empty state (no panels open) ===== */ .workspace-empty { @@ -2348,6 +2272,10 @@ html, body, #app { max-width: 100%; } +.dv-content-container > * { + contain: layout style paint; +} + /* ── Panel launcher (inside the hover title strip) ──────────────────────────── A horizontal toolbar of panel icons living in the top strip. It reveals with the strip (fade handled by the .session-bar rules above), so the panel dock @@ -3731,6 +3659,7 @@ select.db-cell-input { appearance: auto; cursor: pointer; } backdrop-filter: blur(24px) saturate(160%); transition: width 0.16s ease; transform: translate(var(--ai-drag-x, 0px), var(--ai-drag-y, 0px)); + will-change: transform, width; } .ai-modal.hidden { display: none; } .ai-agent-select.hidden { display: none; } diff --git a/src/ui/agentStatusBar.ts b/src/ui/agentStatusBar.ts index a28ff9d..2d2906c 100644 --- a/src/ui/agentStatusBar.ts +++ b/src/ui/agentStatusBar.ts @@ -1,5 +1,6 @@ import { AGENT_DOCK_EVENT, savedAgentDockEntries, type AgentDockEntry } from '../core/terminal/agentDockState' import { appT } from '../core/i18n' +import { invoke } from '@tauri-apps/api/core' interface AgentStatusBarOptions { onOpenAgents: () => void @@ -22,17 +23,32 @@ const statusText = (entry: AgentDockEntry): string => { return appT('agentIdle') } +export const formatMemoryUsage = (bytes: number): string => { + if (!Number.isFinite(bytes) || bytes < 0) return '—' + const mb = bytes / (1024 * 1024) + if (mb < 1024) return `${Math.round(mb)} MB` + return `${(mb / 1024).toFixed(1)} GB` +} + export function createAgentStatusBar({ onOpenAgents }: AgentStatusBarOptions): { element: HTMLElement; dispose: () => void } { const element = document.createElement('div') - element.className = 'agent-status-bar hidden' + element.className = 'agent-status-bar' element.setAttribute('role', 'status') let entries = savedAgentDockEntries() + const agents = document.createElement('div') + agents.className = 'agent-status-list' + + const memory = document.createElement('div') + memory.className = 'app-memory-status' + memory.setAttribute('aria-label', appT('ramUsage')) + memory.title = appT('ramUsage') + memory.innerHTML = 'RAM' + const memoryValue = memory.querySelector('.app-memory-value')! + const render = (): void => { - element.replaceChildren() - element.classList.toggle('hidden', entries.length === 0) - if (entries.length === 0) return + agents.replaceChildren() for (const entry of entries) { const button = document.createElement('button') @@ -56,7 +72,23 @@ export function createAgentStatusBar({ onOpenAgents }: AgentStatusBarOptions): { }) button.append(dot, bar, label, state) - element.appendChild(button) + agents.appendChild(button) + } + } + + let memoryRequestActive = false + const refreshMemory = async (): Promise => { + if (memoryRequestActive || document.hidden) return + memoryRequestActive = true + try { + const bytes = await invoke('app_memory_usage') + memoryValue.textContent = formatMemoryUsage(bytes) + memory.title = `${appT('ramUsage')}: ${formatMemoryUsage(bytes)}` + } catch { + memoryValue.textContent = '—' + memory.title = appT('ramUnavailable') + } finally { + memoryRequestActive = false } } @@ -65,10 +97,55 @@ export function createAgentStatusBar({ onOpenAgents }: AgentStatusBarOptions): { render() } window.addEventListener(AGENT_DOCK_EVENT, onDock) + + // Schedule the next RAM read during idle time (avoids contending with frame + // rendering), but with a hard deadline so it still fires under sustained load. + let timeoutHandle: number | undefined + let idleHandle: number | undefined + let disposed = false + const cancelScheduledRefresh = (): void => { + if (timeoutHandle !== undefined) { + window.clearTimeout(timeoutHandle) + timeoutHandle = undefined + } + if (idleHandle !== undefined) { + if (typeof cancelIdleCallback !== 'undefined') cancelIdleCallback(idleHandle) + idleHandle = undefined + } + } + const scheduleRefresh = (): void => { + if (disposed || document.hidden) return + cancelScheduledRefresh() + timeoutHandle = window.setTimeout(() => { + timeoutHandle = undefined + if (disposed || document.hidden) return + if (typeof requestIdleCallback !== 'undefined') { + idleHandle = requestIdleCallback(() => { + idleHandle = undefined + void refreshMemory().finally(scheduleRefresh) + }, { timeout: 2000 }) + } else { + void refreshMemory().finally(scheduleRefresh) + } + }, 3000) + } + + const onVisibilityChange = (): void => { + cancelScheduledRefresh() + if (!document.hidden && !disposed) void refreshMemory().finally(scheduleRefresh) + } + document.addEventListener('visibilitychange', onVisibilityChange) + element.replaceChildren(agents, memory) render() + void refreshMemory().finally(scheduleRefresh) return { element, - dispose: () => window.removeEventListener(AGENT_DOCK_EVENT, onDock), + dispose: () => { + disposed = true + window.removeEventListener(AGENT_DOCK_EVENT, onDock) + document.removeEventListener('visibilitychange', onVisibilityChange) + cancelScheduledRefresh() + }, } } diff --git a/src/ui/icons.ts b/src/ui/icons.ts index 27bfd82..11c658f 100644 --- a/src/ui/icons.ts +++ b/src/ui/icons.ts @@ -11,6 +11,7 @@ const ICONS: Record = { square: '', palette: '', tv: '', + pip: '', terminal: '', expand: '', refresh: '', diff --git a/tests/panels/tasks/taskDataLoader.test.ts b/tests/panels/tasks/taskDataLoader.test.ts new file mode 100644 index 0000000..b2e60ba --- /dev/null +++ b/tests/panels/tasks/taskDataLoader.test.ts @@ -0,0 +1,134 @@ +// @vitest-environment happy-dom +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { invokeMock, taskGitMock } = vi.hoisted(() => ({ + invokeMock: vi.fn(), + taskGitMock: { + remoteBranches: vi.fn(), + worktrees: vi.fn(), + safeStatus: vi.fn(), + }, +})) + +vi.mock('@tauri-apps/api/core', () => ({ invoke: invokeMock })) +vi.mock('../../../src/panels/tasks/taskGitClient', () => ({ taskGit: taskGitMock })) +vi.mock('../../../src/panels/tasks/taskJiraClient', () => ({ + loadJiraConfig: vi.fn(async () => null), + fetchIssue: vi.fn(async () => null), +})) + +import { loadTaskData } from '../../../src/panels/tasks/TaskDataLoader' + +describe('loadTaskData', () => { + beforeEach(() => { + vi.clearAllMocks() + taskGitMock.remoteBranches.mockResolvedValue([]) + taskGitMock.worktrees.mockResolvedValue([ + { path: 'C:/repo', branch: 'main', head: 'abc', bare: false }, + { path: 'C:/tarea unicode ñ', branch: 'task/e2e', head: 'def', bare: false }, + ]) + taskGitMock.safeStatus.mockResolvedValue({ raw: '', staged: 0, unstaged: 0, untracked: 0, total: 0 }) + }) + + it('renders discovered worktrees before optional integrations finish', async () => { + const dockerNeverCompletes = new Promise(() => {}) + invokeMock.mockImplementation((command: string) => { + if (command === 'git_default_branch') return Promise.resolve('main') + if (command === 'git_fetch_info') return Promise.resolve({ fetchedAt: 0 }) + if (command === 'docker_list') return dockerNeverCompletes + return Promise.resolve(null) + }) + const renderList = vi.fn() + + void loadTaskData({ + repoPath: 'C:\\repo', + panelStore: { + savedBase: () => null, + setBase: () => {}, + selected: () => null, + setSelected: () => {}, + }, + baseSelect: document.createElement('select'), + filterInput: document.createElement('input'), + listWrap: document.createElement('div'), + fetchAgeEl: document.createElement('span'), + note: text => Object.assign(document.createElement('div'), { textContent: text }), + setBaseBranch: () => {}, + setWorktrees: () => {}, + setJiraConfig: () => {}, + maps: { + issue: new Map(), + aheadBehind: new Map(), + pr: new Map(), + backup: new Map(), + rebase: new Map(), + upstream: new Map(), + }, + renderList, + shouldRestoreSelection: () => true, + selectRow: () => {}, + showChanges: () => {}, + showRebasePaused: () => {}, + }) + + for (let index = 0; index < 8; index += 1) await Promise.resolve() + + expect(renderList).toHaveBeenCalledTimes(1) + expect([...renderList.mock.calls[0][0].keys()]).toEqual(['C:/repo', 'C:/tarea unicode ñ']) + }) + + it('does not restore a selection made during progressive enrichment', async () => { + invokeMock.mockImplementation((command: string) => { + if (command === 'git_default_branch') return Promise.resolve('main') + if (command === 'git_fetch_info') return Promise.resolve({ fetchedAt: 0 }) + if (command === 'docker_list' || command === 'git_ahead_behind') return Promise.resolve('') + if (command === 'git_backup_status') return Promise.resolve({ available: false, different: null, hash: null, short: null, subject: null }) + if (command === 'git_rebase_status') return Promise.resolve({ active: false }) + return Promise.resolve(null) + }) + let selectedPath: string | null = 'C:/tarea unicode ñ' + let interacted = false + const listWrap = document.createElement('div') + const showChanges = vi.fn() + const renderList = vi.fn(() => { + interacted = true + const rows = ['C:/repo', 'C:/tarea unicode ñ'].map(path => { + const row = document.createElement('div') + row.className = 'tasks-row' + row.dataset.path = path + return row + }) + listWrap.replaceChildren(...rows) + }) + + await loadTaskData({ + repoPath: 'C:\\repo', + panelStore: { + savedBase: () => null, + setBase: () => {}, + selected: () => selectedPath, + setSelected: path => { selectedPath = path }, + }, + baseSelect: document.createElement('select'), + filterInput: document.createElement('input'), + listWrap, + fetchAgeEl: document.createElement('span'), + note: text => Object.assign(document.createElement('div'), { textContent: text }), + setBaseBranch: () => {}, + setWorktrees: () => {}, + setJiraConfig: () => {}, + maps: { + issue: new Map(), aheadBehind: new Map(), pr: new Map(), + backup: new Map(), rebase: new Map(), upstream: new Map(), + }, + renderList, + shouldRestoreSelection: () => !interacted, + selectRow: () => {}, + showChanges, + showRebasePaused: () => {}, + }) + + expect(renderList).toHaveBeenCalledTimes(2) + expect(showChanges).not.toHaveBeenCalled() + }) +}) diff --git a/tests/panels/tvPlayer.test.ts b/tests/panels/tvPlayer.test.ts new file mode 100644 index 0000000..9928f86 --- /dev/null +++ b/tests/panels/tvPlayer.test.ts @@ -0,0 +1,30 @@ +// @vitest-environment happy-dom +import { describe, expect, it } from 'vitest' +import { HLSPlayer } from '../../src/panels/tv/player' + +describe('HLSPlayer embedded playback lifecycle', () => { + it('unloads a third-party iframe while hidden and restores it when visible', async () => { + const player = new HLSPlayer() + const streamUrl = 'https://www.youtube.com/embed/example' + await player.play({ + id: 'example', + name: 'Example', + logo: '', + country: '', + categories: [], + streamUrl, + }) + + const iframe = player.element.querySelector('iframe')! + expect(iframe.getAttribute('src')).toBe(streamUrl) + + player.pause() + expect(iframe.hasAttribute('src')).toBe(false) + + player.resume() + expect(iframe.getAttribute('src')).toBe(streamUrl) + + player.dispose() + expect(iframe.hasAttribute('src')).toBe(false) + }) +}) diff --git a/tests/ui/agentStatusBar.test.ts b/tests/ui/agentStatusBar.test.ts new file mode 100644 index 0000000..4e3f160 --- /dev/null +++ b/tests/ui/agentStatusBar.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest' +import { formatMemoryUsage } from '../../src/ui/agentStatusBar' + +describe('formatMemoryUsage', () => { + it('formats bytes as whole megabytes', () => { + expect(formatMemoryUsage(256 * 1024 * 1024)).toBe('256 MB') + }) + + it('uses gigabytes for larger footprints', () => { + expect(formatMemoryUsage(1536 * 1024 * 1024)).toBe('1.5 GB') + }) + + it('rejects invalid readings', () => { + expect(formatMemoryUsage(Number.NaN)).toBe('—') + expect(formatMemoryUsage(-1)).toBe('—') + }) +}) diff --git a/tests/ui/agentStatusBarLifecycle.test.ts b/tests/ui/agentStatusBarLifecycle.test.ts new file mode 100644 index 0000000..01323f9 --- /dev/null +++ b/tests/ui/agentStatusBarLifecycle.test.ts @@ -0,0 +1,46 @@ +// @vitest-environment happy-dom +import { afterEach, describe, expect, it, vi } from 'vitest' + +const { invokeMock } = vi.hoisted(() => ({ + invokeMock: vi.fn(async () => 256 * 1024 * 1024), +})) + +vi.mock('@tauri-apps/api/core', () => ({ invoke: invokeMock })) + +import { createAgentStatusBar } from '../../src/ui/agentStatusBar' + +const settle = async (): Promise => { + for (let index = 0; index < 6; index += 1) await Promise.resolve() +} + +describe('agent status bar memory polling lifecycle', () => { + afterEach(() => { + vi.useRealTimers() + invokeMock.mockClear() + Object.defineProperty(document, 'hidden', { configurable: true, value: false }) + }) + + it('keeps one scheduled refresh across visibility changes and cancels it on dispose', async () => { + vi.useFakeTimers() + Object.defineProperty(document, 'hidden', { configurable: true, value: false }) + const bar = createAgentStatusBar({ onOpenAgents: () => {} }) + await settle() + expect(vi.getTimerCount()).toBe(1) + + Object.defineProperty(document, 'hidden', { configurable: true, value: true }) + document.dispatchEvent(new Event('visibilitychange')) + expect(vi.getTimerCount()).toBe(0) + + Object.defineProperty(document, 'hidden', { configurable: true, value: false }) + document.dispatchEvent(new Event('visibilitychange')) + await settle() + expect(vi.getTimerCount()).toBe(1) + + document.dispatchEvent(new Event('visibilitychange')) + await settle() + expect(vi.getTimerCount()).toBe(1) + + bar.dispose() + expect(vi.getTimerCount()).toBe(0) + }) +})