diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c324c11..83d2d4d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -234,6 +234,7 @@ pub fn run() { pty::resize_pty, pty::stop_pty_session, pty::send_pty_query, + pty::list_pty_sessions, // 通知 & Hooks hooks::send_notification, i18n::set_app_locale, diff --git a/src-tauri/src/pty.rs b/src-tauri/src/pty.rs index 2fe8cb7..a1ae191 100644 --- a/src-tauri/src/pty.rs +++ b/src-tauri/src/pty.rs @@ -4,7 +4,7 @@ use tauri::{Emitter, Manager}; use crate::{ cli_detect::resolve_command_path, - state::{PtyKillerMap, PtyMasterMap, PtySessionMeta, PtySessionMetaMap, PtyWriterMap}, + state::{now_ms, PtyKillerMap, PtyMasterMap, PtySessionInfo, PtySessionMeta, PtySessionMetaMap, PtyWriterMap}, util::{expand_path, home_dir, resolve_windows_pty_command}, }; @@ -85,7 +85,8 @@ impl AnsiStripper { #[tauri::command] pub async fn start_pty_session( app: tauri::AppHandle, - session_id: String, + pty_id: String, + session_id: Option, workdir: String, command: String, args: Vec, @@ -97,6 +98,16 @@ pub async fn start_pty_session( use portable_pty::{native_pty_system, CommandBuilder, PtySize}; let expanded = expand_path(&workdir); + let normalized_session_id = session_id + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); + let kind = if pty_id.starts_with("runner:") { + "runner" + } else if pty_id.starts_with("terminal:") { + "terminal" + } else { + "ephemeral" + }; let runner_type = env .as_ref() .and_then(|pairs| { @@ -118,12 +129,11 @@ pub async fn start_pty_session( .to_string() }); - // 先停掉同 session 的旧 PTY { let km = pty_killer_map(&app); - let mut km = km.lock().unwrap(); - if let Some(mut old) = km.remove(&session_id) { - let _ = old.kill(); + let km = km.lock().unwrap(); + if km.contains_key(&pty_id) { + return Ok(()); } } @@ -226,33 +236,39 @@ pub async fn start_pty_session( { let wm = pty_writer_map(&app); let mut wm = wm.lock().unwrap(); - wm.insert(session_id.clone(), master_writer); + wm.insert(pty_id.clone(), master_writer); } { let km = pty_killer_map(&app); let mut km = km.lock().unwrap(); - km.insert(session_id.clone(), child); + km.insert(pty_id.clone(), child); } { let mm = pty_master_map(&app); let mut mm = mm.lock().unwrap(); - mm.insert(session_id.clone(), pair.master); + mm.insert(pty_id.clone(), pair.master); } { let meta_map = pty_session_meta_map(&app); let mut meta_map = meta_map.lock().unwrap(); + let now = now_ms(); meta_map.insert( - session_id.clone(), + pty_id.clone(), PtySessionMeta { + session_id: normalized_session_id.clone(), + kind: kind.to_string(), runner_type, workdir: expanded.clone(), + created_at_ms: now, + last_active_at_ms: now, + status: "starting".to_string(), }, ); } // 读取线程:转发 PTY 输出并检测状态特征 let app_r = app.clone(); - let sid_r = session_id.clone(); + let sid_r = pty_id.clone(); let killer_map_r = pty_killer_map(&app); let session_meta_map_r = pty_session_meta_map(&app); std::thread::spawn(move || { @@ -267,9 +283,14 @@ pub async fn start_pty_session( Ok(n) => { // 转发原始数据(base64 编码) let b64 = base64::engine::general_purpose::STANDARD.encode(&buf[..n]); + let legacy_session_id = sid_r.strip_prefix("runner:").unwrap_or(&sid_r); let _ = app_r.emit( "pty-data", - serde_json::json!({ "session_id": sid_r, "data": b64 }), + serde_json::json!({ + "pty_id": sid_r, + "session_id": legacy_session_id, + "data": b64, + }), ); // 检测 CLI 状态特征 @@ -291,7 +312,21 @@ pub async fn start_pty_session( } else { "pty-running" }; - let _ = app_r.emit(event, serde_json::json!({ "session_id": sid_r })); + { + let mut meta_map = session_meta_map_r.lock().unwrap(); + if let Some(meta) = meta_map.get_mut(&sid_r) { + meta.status = if new_status == 2 { "waiting" } else { "running" }.to_string(); + meta.last_active_at_ms = now_ms(); + } + } + let legacy_session_id = sid_r.strip_prefix("runner:").unwrap_or(&sid_r); + let _ = app_r.emit( + event, + serde_json::json!({ + "pty_id": sid_r, + "session_id": legacy_session_id, + }), + ); } } } @@ -309,7 +344,14 @@ pub async fn start_pty_session( meta_map.remove(&sid_r); } - let _ = app_r.emit("pty-exit", serde_json::json!({ "session_id": sid_r })); + let legacy_session_id = sid_r.strip_prefix("runner:").unwrap_or(&sid_r); + let _ = app_r.emit( + "pty-exit", + serde_json::json!({ + "pty_id": sid_r, + "session_id": legacy_session_id, + }), + ); }); Ok(()) @@ -317,11 +359,11 @@ pub async fn start_pty_session( /// 向 PTY 写入数据(键盘输入,base64 编码) #[tauri::command] -pub fn write_pty(app: tauri::AppHandle, session_id: String, data: String) -> Result<(), String> { +pub fn write_pty(app: tauri::AppHandle, pty_id: String, data: String) -> Result<(), String> { use base64::Engine; let wm = pty_writer_map(&app); let mut wm = wm.lock().unwrap(); - if let Some(writer) = wm.get_mut(&session_id) { + if let Some(writer) = wm.get_mut(&pty_id) { let bytes = base64::engine::general_purpose::STANDARD .decode(&data) .map_err(|e| format!("base64 decode 失败: {e}"))?; @@ -336,12 +378,12 @@ pub fn write_pty(app: tauri::AppHandle, session_id: String, data: String) -> Res #[tauri::command] pub fn send_pty_query( app: tauri::AppHandle, - session_id: String, + pty_id: String, query: String, ) -> Result<(), String> { let wm = pty_writer_map(&app); let mut wm = wm.lock().unwrap(); - if let Some(writer) = wm.get_mut(&session_id) { + if let Some(writer) = wm.get_mut(&pty_id) { let mut data = query.into_bytes(); data.push(if cfg!(windows) { b'\r' } else { b'\n' }); writer @@ -352,7 +394,7 @@ pub fn send_pty_query( .map_err(|e| format!("send_pty_query flush 失败: {e}"))?; Ok(()) } else { - Err(format!("PTY session '{session_id}' 不存在或尚未就绪")) + Err(format!("PTY session '{pty_id}' 不存在或尚未就绪")) } } @@ -360,7 +402,7 @@ pub fn send_pty_query( #[tauri::command] pub fn resize_pty( app: tauri::AppHandle, - session_id: String, + pty_id: String, cols: u16, rows: u16, ) -> Result<(), String> { @@ -369,7 +411,7 @@ pub fn resize_pty( let rows = rows.max(5); let mm = pty_master_map(&app); let mm = mm.lock().unwrap(); - if let Some(master) = mm.get(&session_id) { + if let Some(master) = mm.get(&pty_id) { master .resize(PtySize { rows, @@ -379,17 +421,24 @@ pub fn resize_pty( }) .map_err(|e| format!("resize_pty 失败: {e}"))?; } + { + let meta_map = pty_session_meta_map(&app); + let mut meta_map = meta_map.lock().unwrap(); + if let Some(meta) = meta_map.get_mut(&pty_id) { + meta.last_active_at_ms = now_ms(); + } + } Ok(()) } /// 停止 PTY 会话 #[tauri::command] -pub fn stop_pty_session(app: tauri::AppHandle, session_id: String) -> Result<(), String> { +pub fn stop_pty_session(app: tauri::AppHandle, pty_id: String) -> Result<(), String> { let mut had_session = false; { let km = pty_killer_map(&app); let mut km = km.lock().unwrap(); - if let Some(mut child) = km.remove(&session_id) { + if let Some(mut child) = km.remove(&pty_id) { had_session = true; let _ = child.kill(); } @@ -397,26 +446,58 @@ pub fn stop_pty_session(app: tauri::AppHandle, session_id: String) -> Result<(), { let wm = pty_writer_map(&app); let mut wm = wm.lock().unwrap(); - if wm.remove(&session_id).is_some() { + if wm.remove(&pty_id).is_some() { had_session = true; } } { let mm = pty_master_map(&app); let mut mm = mm.lock().unwrap(); - if mm.remove(&session_id).is_some() { + if mm.remove(&pty_id).is_some() { had_session = true; } } { let meta_map = pty_session_meta_map(&app); let mut meta_map = meta_map.lock().unwrap(); - if meta_map.remove(&session_id).is_some() { + if let Some(meta) = meta_map.get_mut(&pty_id) { + meta.status = "exited".to_string(); + meta.last_active_at_ms = now_ms(); + } + if meta_map.remove(&pty_id).is_some() { had_session = true; } } if had_session { - let _ = app.emit("pty-exit", serde_json::json!({ "session_id": session_id })); + let legacy_session_id = pty_id.strip_prefix("runner:").unwrap_or(&pty_id); + let _ = app.emit( + "pty-exit", + serde_json::json!({ + "pty_id": pty_id, + "session_id": legacy_session_id, + }), + ); } Ok(()) } + +#[tauri::command] +pub fn list_pty_sessions(app: tauri::AppHandle) -> Result, String> { + let meta_map = pty_session_meta_map(&app); + let meta_map = meta_map.lock().unwrap(); + let mut entries: Vec = meta_map + .iter() + .map(|(pty_id, meta)| PtySessionInfo { + pty_id: pty_id.clone(), + session_id: meta.session_id.clone(), + kind: meta.kind.clone(), + runner_type: meta.runner_type.clone(), + workdir: meta.workdir.clone(), + created_at_ms: meta.created_at_ms, + last_active_at_ms: meta.last_active_at_ms, + status: meta.status.clone(), + }) + .collect(); + entries.sort_by(|a, b| a.pty_id.cmp(&b.pty_id)); + Ok(entries) +} diff --git a/src-tauri/src/session_lifecycle.rs b/src-tauri/src/session_lifecycle.rs index 2cdd9bd..d195419 100644 --- a/src-tauri/src/session_lifecycle.rs +++ b/src-tauri/src/session_lifecycle.rs @@ -93,7 +93,9 @@ fn normalize_workdir(path: &str) -> String { fn active_session_ids(app: &AppHandle) -> Vec { let km_arc = app.state::().inner().clone(); let km = km_arc.lock().unwrap(); - km.keys().cloned().collect() + km.keys() + .filter_map(|pty_id| pty_id.strip_prefix("runner:").map(ToString::to_string)) + .collect() } pub(crate) fn resolve_session_ids(app: &AppHandle, routing: &SessionRoutingHint) -> Vec { @@ -119,7 +121,8 @@ pub(crate) fn resolve_session_ids(app: &AppHandle, routing: &SessionRoutingHint) if !active_ids.iter().any(|sid| sid == session_id) { return Vec::new(); } - let Some(info) = meta.get(session_id) else { + let runner_pty_id = format!("runner:{session_id}"); + let Some(info) = meta.get(&runner_pty_id) else { return Vec::new(); }; if info.runner_type != routing.source.runner_type() { @@ -136,7 +139,8 @@ pub(crate) fn resolve_session_ids(app: &AppHandle, routing: &SessionRoutingHint) let mut cwd_matches: Vec = active_ids .into_iter() .filter(|sid| { - let Some(info) = meta.get(sid) else { + let runner_pty_id = format!("runner:{sid}"); + let Some(info) = meta.get(&runner_pty_id) else { return false; }; if info.runner_type != routing.source.runner_type() { @@ -179,19 +183,35 @@ pub fn emit_session_lifecycle( match signal { SessionLifecycleSignal::Running => { for sid in session_ids { - let _ = app.emit("pty-running", serde_json::json!({ "session_id": sid })); + let _ = app.emit( + "pty-running", + serde_json::json!({ + "pty_id": format!("runner:{sid}"), + "session_id": sid, + }), + ); } } SessionLifecycleSignal::Waiting => { for sid in session_ids { - let _ = app.emit("pty-waiting", serde_json::json!({ "session_id": sid })); + let _ = app.emit( + "pty-waiting", + serde_json::json!({ + "pty_id": format!("runner:{sid}"), + "session_id": sid, + }), + ); } } SessionLifecycleSignal::Error { message } => { for sid in session_ids { let _ = app.emit( "pty-error", - serde_json::json!({ "session_id": sid, "error": message.clone() }), + serde_json::json!({ + "pty_id": format!("runner:{sid}"), + "session_id": sid, + "error": message.clone(), + }), ); } } @@ -214,6 +234,7 @@ pub fn emit_session_lifecycle( let _ = app.emit( "pty-notification", serde_json::json!({ + "pty_id": format!("runner:{sid}"), "session_id": sid, "title": title.clone(), "message": message.clone(), diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index 62d508e..5b0b83b 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -2,7 +2,7 @@ use std::{ collections::HashMap, io::Write, sync::{Arc, Mutex}, - time::{Duration, Instant}, + time::{Duration, Instant, SystemTime, UNIX_EPOCH}, }; use notify::RecommendedWatcher; @@ -20,13 +20,39 @@ pub type PtyMasterMap = Arc, + pub kind: String, pub runner_type: String, pub workdir: String, + pub created_at_ms: u64, + pub last_active_at_ms: u64, + pub status: String, } -/// session_id → PTY 会话元信息(用于 hooks 事件精确路由) +/// pty_id → PTY 会话元信息(用于 hooks 事件精确路由与管理面板) pub type PtySessionMetaMap = Arc>>; +#[derive(Debug, Clone, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PtySessionInfo { + pub pty_id: String, + pub session_id: Option, + pub kind: String, + pub runner_type: String, + pub workdir: String, + pub created_at_ms: u64, + pub last_active_at_ms: u64, + pub status: String, +} + +pub fn now_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .ok() + .map(|duration| duration.as_millis() as u64) + .unwrap_or(0) +} + /// session_id → Git watcher(用于 SCM 自动刷新) pub type GitWatcherMap = Arc>>; diff --git a/src/App.tsx b/src/App.tsx index c9a3e14..6ae8049 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -24,6 +24,8 @@ import { useWorkbenchStore } from "./store/workbenchStore"; import { useScmStore } from "./store/scmStore"; import { useExplorerStore, type ExplorerEntry } from "./store/explorerStore"; import { useEditorStore } from "./store/editorStore"; +import { listPtys } from "./services/ptyCommands"; +import { usePtyStore } from "./store/ptyStore"; const spring = { type: "spring" as const, stiffness: 320, damping: 28, mass: 1 }; const MAX_FRONTEND_ERROR_LOGS = 50; @@ -71,6 +73,9 @@ export default function App() { const setScmDiffOverride = useScmStore((s) => s.setDiffOverride); const { settings, patchSettings } = useSettingsStore(); + const syncPtysWithSessions = usePtyStore((s) => s.syncWithSessions); + const markPtyExited = usePtyStore((s) => s.markRuntimeExited); + const syncLivePtys = usePtyStore((s) => s.syncLiveRuntimes); const effectiveLocale = resolveEffectiveLocale(settings.locale); const direction = getLocaleDirection(effectiveLocale); const settingsOpen = useSettingsStore((s) => s.settingsOpen); @@ -472,6 +477,13 @@ export default function App() { (s) => s.id === focusedSessionId && s.workspaceId === activeWorkspaceId ) ?? activeSession ?? null; + useEffect(() => { + const terminalTabs = settings.splitWidgetCanvas.items.flatMap((item) => ( + item.type === "terminal" ? item.tabs : [] + )); + syncPtysWithSessions(sessions, terminalTabs); + }, [sessions, settings.splitWidgetCanvas.items, syncPtysWithSessions]); + const refreshSessionDiff = useCallback((sessionId?: string | null, options?: { reloadExplorer?: boolean; reloadDirs?: string[] }) => { if (!("__TAURI_INTERNALS__" in window)) return; @@ -588,6 +600,24 @@ export default function App() { }, []); // ── Esc 关闭 ────────────────────────────────────────────── + useEffect(() => { + if (!("__TAURI_INTERNALS__" in window)) return; + listPtys() + .then((entries) => { + syncLivePtys(entries.map((entry) => ({ + ptyId: entry.pty_id, + sessionId: entry.session_id ?? undefined, + kind: entry.kind, + runnerType: entry.runner_type, + workdir: entry.workdir, + createdAtMs: entry.created_at_ms, + lastActiveAtMs: entry.last_active_at_ms, + status: entry.status, + }))); + }) + .catch(() => {}); + }, [syncLivePtys]); + useEffect(() => { const handler = (e: KeyboardEvent) => { if (e.key !== "Escape") return; @@ -867,14 +897,21 @@ export default function App() { // PTY 退出:将 running/waiting/suspended 状态的 session 标记为 done // SessionPanel 关闭后不再常驻,此处补全全局兜底监听 - const u6 = listen<{ session_id: string }>( + const u6 = listen<{ pty_id?: string; session_id?: string }>( "pty-exit", ({ payload }) => { + const ptyId = payload.pty_id?.trim() ?? ""; + if (ptyId) { + markPtyExited(ptyId); + } + const exitedRunnerSessionId = payload.session_id?.trim() + || (ptyId.startsWith("runner:") ? ptyId.slice("runner:".length) : ""); + if (!exitedRunnerSessionId) return; // 延迟 1.2s 与 SessionPanel 内的逻辑保持一致 setTimeout(() => { - const s = useSessionStore.getState().sessions.find((x) => x.id === payload.session_id); + const s = useSessionStore.getState().sessions.find((x) => x.id === exitedRunnerSessionId); if (s && (s.status === "running" || s.status === "waiting" || s.status === "suspended")) { - updateSession(payload.session_id, { status: "done" }); + updateSession(exitedRunnerSessionId, { status: "done" }); } }, 1200); } @@ -910,7 +947,7 @@ export default function App() { return () => { [u1, u2, u3, u4, u5, u5b, u5c, u5d, u6, u7].forEach((p) => p.then((f) => f()).catch(() => {})); }; - }, [appendOutput, updateSession, setDiffFiles, setScmSnapshot, setScmStatus, setScmDiffOverride, refreshSessionDiff]); + }, [appendOutput, updateSession, setDiffFiles, setScmSnapshot, setScmStatus, setScmDiffOverride, refreshSessionDiff, markPtyExited]); // ── 会话切换时主动拉一次 Diff(覆盖非 running / 外部改动场景)── useEffect(() => { diff --git a/src/components/PtyTerminal.tsx b/src/components/PtyTerminal.tsx index 334cbc5..930c8a9 100644 --- a/src/components/PtyTerminal.tsx +++ b/src/components/PtyTerminal.tsx @@ -5,10 +5,11 @@ import { Terminal } from "@xterm/xterm"; import { FitAddon } from "@xterm/addon-fit"; import "@xterm/xterm/css/xterm.css"; import { useAppI18n } from "../i18n"; +import { usePtyStore } from "../store/ptyStore"; import { useSettingsStore, isGlassTheme, type ThemeMode } from "../store/settingsStore"; interface Props { - sessionId: string; + ptyId: string; command: string; // e.g. "claude" args?: string[]; // e.g. ["--dangerously-skip-permissions"] workdir: string; @@ -120,8 +121,12 @@ function wheelDeltaToLines(event: WheelEvent, rows: number): number { return Math.min(-1, Math.round(lines)); } +function buildPtyViewId(ptyId: string) { + return `view:${ptyId}`; +} + export function PtyTerminal({ - sessionId, + ptyId, command, args = [], workdir, @@ -137,16 +142,38 @@ export function PtyTerminal({ }: Props) { const { t } = useAppI18n(); const containerRef = useRef(null); + const attachPtyView = usePtyStore((s) => s.attachView); + const detachPtyView = usePtyStore((s) => s.detachView); + const setPtyViewVisible = usePtyStore((s) => s.setViewVisible); + const touchPtyRuntime = usePtyStore((s) => s.touchRuntime); + const setPtyRuntimeStatus = usePtyStore((s) => s.setRuntimeStatus); + const markPtyExited = usePtyStore((s) => s.markRuntimeExited); + const setPtyRuntimeSize = usePtyStore((s) => s.setRuntimeSize); const termRef = useRef(null); const fitRef = useRef(null); const startedRef = useRef(false); const startingRef = useRef(false); const launchTokenRef = useRef(0); const [exited, setExited] = useState(false); + const ptyViewIdRef = useRef(buildPtyViewId(ptyId)); + const lastTouchAtRef = useRef(0); + const lastSizeRef = useRef<{ cols: number; rows: number } | null>(null); + ptyViewIdRef.current = buildPtyViewId(ptyId); // 读取当前主题 const theme = useSettingsStore((s) => s.settings.theme); + useEffect(() => { + attachPtyView(ptyId, ptyViewIdRef.current); + return () => { + detachPtyView(ptyId, ptyViewIdRef.current); + }; + }, [attachPtyView, detachPtyView, ptyId]); + + useEffect(() => { + setPtyViewVisible(ptyId, ptyViewIdRef.current, active); + }, [active, ptyId, setPtyViewVisible]); + // ── 初始化 xterm ────────────────────────────────────────── useEffect(() => { if (!containerRef.current) return; @@ -197,7 +224,7 @@ export function PtyTerminal({ term.onData((data: string) => { const bytes = new TextEncoder().encode(data); const b64 = btoa(String.fromCharCode(...bytes)); - invoke("write_pty", { sessionId, data: b64 }).catch(() => {}); + invoke("write_pty", { ptyId, data: b64 }).catch(() => {}); }); return () => { @@ -207,19 +234,15 @@ export function PtyTerminal({ fitRef.current = null; }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [sessionId]); + }, [ptyId]); - // runner/workdir 切换会通过 key 触发卸载;这里顺手停掉旧 PTY,避免旧 CLI 抢到下一条输入。 + // 卸载时只让当前视图失效,不主动结束 PTY;PTY 生命周期由 owner 显式管理。 useEffect(() => { return () => { - if (!startedRef.current && !startingRef.current) return; startingRef.current = false; launchTokenRef.current += 1; - if (startedRef.current) { - invoke("stop_pty_session", { sessionId }).catch(() => {}); - } }; - }, [sessionId]); + }, [ptyId]); // ── 主题切换时动态更新 xterm 颜色 ──────────────────────── useEffect(() => { @@ -274,27 +297,33 @@ export function PtyTerminal({ // ── 监听 PTY 数据事件 ───────────────────────────────────── useEffect(() => { - const u1 = listen<{ session_id: string; data: string }>( + const u1 = listen<{ pty_id: string; data: string }>( "pty-data", ({ payload }) => { - if (payload.session_id !== sessionId) return; + if (payload.pty_id !== ptyId) return; const term = termRef.current; if (!term) return; try { const bin = atob(payload.data); const bytes = Uint8Array.from(bin, (c) => c.charCodeAt(0)); term.write(bytes); + const now = Date.now(); + if (now - lastTouchAtRef.current >= 1000) { + lastTouchAtRef.current = now; + touchPtyRuntime(ptyId, { lastActiveAt: now }); + } } catch {} } ); - const u2 = listen<{ session_id: string }>( + const u2 = listen<{ pty_id: string; session_id?: string }>( "pty-exit", ({ payload }) => { - if (payload.session_id !== sessionId) return; + if (payload.pty_id !== ptyId) return; termRef.current?.writeln("\r\n\x1b[90m─────────────────────────────────────\x1b[0m"); termRef.current?.writeln(`\x1b[90m${t("pty.processExited")}\x1b[0m`); setExited(true); + markPtyExited(ptyId); startedRef.current = false; // 允许重启 startingRef.current = false; launchTokenRef.current += 1; @@ -302,37 +331,40 @@ export function PtyTerminal({ ); // CLI 完成任务,等待下一条 query(检测到 "? for shortcuts") - const u3 = listen<{ session_id: string }>( + const u3 = listen<{ pty_id: string; session_id?: string }>( "pty-waiting", ({ payload }) => { - if (payload.session_id !== sessionId) return; + if (payload.pty_id !== ptyId) return; + setPtyRuntimeStatus(ptyId, "waiting", { live: true }); onWaitingRef.current?.(); } ); // CLI 开始处理 query(检测到 "esc to interrupt") - const u4 = listen<{ session_id: string }>( + const u4 = listen<{ pty_id: string; session_id?: string }>( "pty-running", ({ payload }) => { - if (payload.session_id !== sessionId) return; + if (payload.pty_id !== ptyId) return; + setPtyRuntimeStatus(ptyId, "running", { live: true }); onRunningRef.current?.(); } ); // API 错误中断(如 Claude StopFailure hook) - const u5 = listen<{ session_id: string; error: string }>( + const u5 = listen<{ pty_id: string; error: string }>( "pty-error", ({ payload }) => { - if (payload.session_id !== sessionId) return; + if (payload.pty_id !== ptyId) return; + setPtyRuntimeStatus(ptyId, "error", { live: false, error: payload.error }); onErrorRef.current?.(payload.error); } ); // CLI hook: Notification(当前主要来自 Claude,需要用户确认/输入) - const u6 = listen<{ session_id: string; title: string; message: string; notification_type: string }>( + const u6 = listen<{ pty_id: string; session_id?: string; title: string; message: string; notification_type: string }>( "pty-notification", ({ payload }) => { - if (payload.session_id !== sessionId) return; + if (payload.pty_id !== ptyId) return; onNotificationRef.current?.(payload.title, payload.message, payload.notification_type); } ); @@ -345,7 +377,7 @@ export function PtyTerminal({ u5.then((f) => f()).catch(() => {}); u6.then((f) => f()).catch(() => {}); }; - }, [sessionId]); + }, [markPtyExited, ptyId, setPtyRuntimeStatus, t, touchPtyRuntime]); // ── 启动 PTY 进程(仅第一次,之后常驻直到 exit)──────── // 用 ref 保存最新的 args/onReady/env,避免加入依赖导致每次渲染重启 @@ -393,8 +425,9 @@ export function PtyTerminal({ : [command, ...launchArgs].join(" "); term?.writeln(`\x1b[90m$ ${displayCmd}\x1b[0m`); + setPtyRuntimeStatus(ptyId, "starting", { live: true }); invoke("start_pty_session", { - sessionId, + ptyId, workdir, command, args: launchArgs, @@ -406,6 +439,7 @@ export function PtyTerminal({ if (launchTokenRef.current !== launchToken) return; startedRef.current = true; startingRef.current = false; + setPtyRuntimeStatus(ptyId, "running", { live: true }); // spawn 返回 = CLI 进程已启动(resolve_command_path 保证是完整路径直接 spawn) onReadyRef.current?.(); }) @@ -413,6 +447,7 @@ export function PtyTerminal({ if (launchTokenRef.current !== launchToken) return; startingRef.current = false; startedRef.current = false; + setPtyRuntimeStatus(ptyId, "error", { live: false, error: String(e) }); termRef.current?.writeln(`\x1b[31m${t("session.installFailed", { error: String(e) })}\x1b[0m`); }); }, 250); @@ -423,7 +458,7 @@ export function PtyTerminal({ startingRef.current = false; } }; - }, [active, sessionId, workdir, command]); + }, [active, command, ptyId, setPtyRuntimeStatus, workdir]); // ── 重新启动(退出后用户点击重启)─────────────────────── const handleRestart = () => { @@ -445,8 +480,9 @@ export function PtyTerminal({ const displayCmd = [command, ...argsRef.current].join(" "); term?.writeln(`\x1b[90m$ ${displayCmd}\x1b[0m`); + setPtyRuntimeStatus(ptyId, "starting", { live: true }); invoke("start_pty_session", { - sessionId, + ptyId, workdir, command, args: argsRef.current, @@ -458,12 +494,14 @@ export function PtyTerminal({ if (launchTokenRef.current !== launchToken) return; startedRef.current = true; startingRef.current = false; + setPtyRuntimeStatus(ptyId, "running", { live: true }); onReadyRef.current?.(); }) .catch((e) => { if (launchTokenRef.current !== launchToken) return; startedRef.current = false; startingRef.current = false; + setPtyRuntimeStatus(ptyId, "error", { live: false, error: String(e) }); termRef.current?.writeln(`\x1b[31m${t("session.installFailed", { error: String(e) })}\x1b[0m`); }); }; @@ -476,10 +514,13 @@ export function PtyTerminal({ const term = termRef.current; term?.focus(); if (!term) return; - invoke("resize_pty", { sessionId, ...getClampedTerminalSize(term) }).catch(() => {}); + const size = getClampedTerminalSize(term); + lastSizeRef.current = size; + setPtyRuntimeSize(ptyId, size.cols, size.rows); + invoke("resize_pty", { ptyId, ...size }).catch(() => {}); }, 80); return () => clearTimeout(t); - }, [active, sessionId]); + }, [active, ptyId, setPtyRuntimeSize]); // active ref:供 ResizeObserver 回调访问(避免闭包过时) const activeRef = useRef(active); @@ -498,11 +539,15 @@ export function PtyTerminal({ fit.fit(); // 仅在面板可见时同步给 Rust,防止收起时 cols/rows 为 0 导致进程崩溃 if (!activeRef.current) return; - invoke("resize_pty", { sessionId, ...getClampedTerminalSize(term) }).catch(() => {}); + const size = getClampedTerminalSize(term); + if (lastSizeRef.current?.cols === size.cols && lastSizeRef.current?.rows === size.rows) return; + lastSizeRef.current = size; + setPtyRuntimeSize(ptyId, size.cols, size.rows); + invoke("resize_pty", { ptyId, ...size }).catch(() => {}); }); ro.observe(el); return () => ro.disconnect(); - }, [sessionId]); + }, [ptyId, setPtyRuntimeSize]); const isGlass = isGlassTheme(theme); const { termBg } = getTerminalLook(theme); diff --git a/src/components/SessionDetail.tsx b/src/components/SessionDetail.tsx index d0ac521..89afbf2 100644 --- a/src/components/SessionDetail.tsx +++ b/src/components/SessionDetail.tsx @@ -95,7 +95,7 @@ function InstallTerminal({ installId, installCmd, onFinished }: InstallTerminalP const rows = Math.max(term.rows, 10); invoke("start_pty_session", { - sessionId: installId, + ptyId: installId, workdir: "~", command: isWindows ? "cmd.exe" : "sh", args: isWindows ? ["/d", "/c", installCmd] : ["-c", installCmd], @@ -106,8 +106,8 @@ function InstallTerminal({ installId, installCmd, onFinished }: InstallTerminalP term?.writeln(`\x1b[31m${t("session.installFailed", { error: String(e) })}\x1b[0m`); }); - const u1 = listen<{ session_id: string; data: string }>("pty-data", ({ payload }) => { - if (payload.session_id !== installId) return; + const u1 = listen<{ pty_id: string; data: string }>("pty-data", ({ payload }) => { + if (payload.pty_id !== installId) return; try { const bin = atob(payload.data); const bytes = Uint8Array.from(bin, (c) => c.charCodeAt(0)); @@ -115,8 +115,8 @@ function InstallTerminal({ installId, installCmd, onFinished }: InstallTerminalP } catch {} }); - const u2 = listen<{ session_id: string }>("pty-exit", ({ payload }) => { - if (payload.session_id !== installId) return; + const u2 = listen<{ pty_id: string; session_id?: string }>("pty-exit", ({ payload }) => { + if (payload.pty_id !== installId) return; termRef.current?.writeln(`\r\n\x1b[90m${t("session.installDoneRerun")}\x1b[0m`); setTimeout(onFinished, 800); }); diff --git a/src/components/SessionList.tsx b/src/components/SessionList.tsx index d483452..ba451e4 100644 --- a/src/components/SessionList.tsx +++ b/src/components/SessionList.tsx @@ -9,6 +9,7 @@ import { ClaudeSession, SessionStatus, orderWorkspaceSessions, useSessionStore } import { useWorkspaceStore, getWorkspaceColor } from "../store/workspaceStore"; import { useSettingsStore, RUNNER_LABELS, sanitizeRunnerConfig, isGlassTheme } from "../store/settingsStore"; import { useWorkbenchStore } from "../store/workbenchStore"; +import { stopSessionPtys } from "../services/ptyCommands"; import { showExplorer, showSessionSurface } from "../services/workbenchCommands"; // ── 状态配置(使用 CSS 变量)──────────────────────────────── @@ -543,6 +544,7 @@ export function SessionList() { const handleRemoveSession = async (session: ClaudeSession) => { setPendingDeleteSessionId(null); + await stopSessionPtys(session.id); if ("__TAURI_INTERNALS__" in window) { await invoke("mark_deleted_items", { diff --git a/src/components/SplitSwapLayout.tsx b/src/components/SplitSwapLayout.tsx index c12c40a..1871470 100644 --- a/src/components/SplitSwapLayout.tsx +++ b/src/components/SplitSwapLayout.tsx @@ -6,6 +6,7 @@ import { useLayoutEffect, useMemo, useRef, + useState, type PointerEvent as ReactPointerEvent, type ReactNode, } from "react"; @@ -20,6 +21,7 @@ import { type SplitWidgetCanvasItem, type SplitWidgetTerminalItem, } from "../store/settingsStore"; +import { buildTerminalPtyId } from "../services/ptyIdentity"; import { resetWorkbenchMode } from "../services/workbenchCommands"; import { useSessionStore } from "../store/sessionStore"; import { useWorkspaceStore } from "../store/workspaceStore"; @@ -31,14 +33,6 @@ function shellQuote(value: string) { return `'${value.replace(/'/g, `'"'"'`)}'`; } -function sanitizeSessionKey(value: string) { - return value.replace(/[^a-zA-Z0-9_-]/g, "_"); -} - -function buildWidgetTerminalSessionId(sessionId: string, ptySessionKey: string) { - return `widget-${sanitizeSessionKey(sessionId)}-${sanitizeSessionKey(ptySessionKey)}`; -} - function createTerminalTab(tabs: SplitWidgetTerminalItem["tabs"]) { const numericTitles = tabs .map((tab) => Number(tab.title.match(/^Terminal\s+(\d+)$/)?.[1] ?? Number.NaN)) @@ -177,19 +171,23 @@ function TerminalWidgetBody({ itemId }: { itemId: string }) { }); const sessions = useSessionStore((s) => s.sessions); const expandedSessionId = useSessionStore((s) => s.expandedSessionId); - const session = useMemo( - () => sessions.find((item) => item.id === expandedSessionId) ?? null, - [expandedSessionId, sessions] - ); - const terminalWorkdir = session?.worktreePath ?? session?.workdir ?? ""; - const terminalCommand = navigator.userAgent.toLowerCase().includes("windows") ? "cmd.exe" : "sh"; - const terminalArgs = navigator.userAgent.toLowerCase().includes("windows") - ? ["/K", `cd /d "${terminalWorkdir}"`] - : ["-lc", `cd ${shellQuote(terminalWorkdir)} && exec zsh -i`]; + const [mountedSessionIds, setMountedSessionIds] = useState([]); + + useEffect(() => { + if (!expandedSessionId) return; + setMountedSessionIds((prev) => ( + prev.includes(expandedSessionId) ? prev : [...prev, expandedSessionId] + )); + }, [expandedSessionId]); + + useEffect(() => { + const sessionIds = new Set(sessions.map((session) => session.id)); + setMountedSessionIds((prev) => prev.filter((sessionId) => sessionIds.has(sessionId))); + }, [sessions]); if (!widget) return null; - if (!session || !terminalWorkdir) { + if (mountedSessionIds.length === 0) { return (
- {widget.tabs.map((tab) => { - const ptySessionId = buildWidgetTerminalSessionId(session.id, tab.ptySessionKey); - const isActiveTab = tab.id === widget.activeTabId; - return ( -
- -
- ); + {mountedSessionIds.map((sessionId) => { + const session = sessions.find((item) => item.id === sessionId) ?? null; + const terminalWorkdir = session?.worktreePath ?? session?.workdir ?? ""; + if (!session || !terminalWorkdir) return null; + const terminalCommand = navigator.userAgent.toLowerCase().includes("windows") ? "cmd.exe" : "sh"; + const terminalArgs = navigator.userAgent.toLowerCase().includes("windows") + ? ["/K", `cd /d "${terminalWorkdir}"`] + : ["-lc", `cd ${shellQuote(terminalWorkdir)} && exec zsh -i`]; + const isVisibleSession = expandedSessionId === sessionId; + + return widget.tabs.map((tab) => { + const ptyId = buildTerminalPtyId(session.id, tab.ptySessionKey); + const isActiveTab = tab.id === widget.activeTabId; + const isVisible = isVisibleSession && isActiveTab; + return ( +
+ +
+ ); + }); })}
); diff --git a/src/components/SplitWidgetPanel.tsx b/src/components/SplitWidgetPanel.tsx index b6a9f5b..d4affc4 100644 --- a/src/components/SplitWidgetPanel.tsx +++ b/src/components/SplitWidgetPanel.tsx @@ -21,6 +21,8 @@ import { type SplitWidgetTerminalItem, type SplitWidgetTerminalTab, } from "../store/settingsStore"; +import { stopTerminalTabPtys } from "../services/ptyCommands"; +import { usePtyStore } from "../store/ptyStore"; import { getWorkspaceColor, useWorkspaceStore } from "../store/workspaceStore"; const WIDGET_GAP = 1; @@ -1086,6 +1088,7 @@ export function SplitWidgetPanel() { })); }} onClose={() => { + stopTerminalTabPtys(tab.ptySessionKey).catch(() => {}); updateTerminalWidget(item.id, (current) => removeTabFromWidget(current, tab.id).nextWidget ?? current); }} /> diff --git a/src/components/WorkspaceStack.tsx b/src/components/WorkspaceStack.tsx index 95db8a0..57467fb 100644 --- a/src/components/WorkspaceStack.tsx +++ b/src/components/WorkspaceStack.tsx @@ -12,6 +12,7 @@ import { } from "../store/workspaceStore"; import { useSessionStore, type ClaudeSession } from "../store/sessionStore"; import { useSettingsStore, isGlassTheme } from "../store/settingsStore"; +import { stopSessionPtys } from "../services/ptyCommands"; import { useWorkbenchStore } from "../store/workbenchStore"; // ── 常量 ───────────────────────────────────────────────────── @@ -558,6 +559,7 @@ export function WorkspaceStack() { .getState() .sessions .filter((session) => session.workspaceId === id); + await Promise.allSettled(sessionsToRemove.map((session) => stopSessionPtys(session.id))); const workspace = workspaces.find((item) => item.id === id); if ("__TAURI_INTERNALS__" in window) { diff --git a/src/components/session/SessionRunnerSurface.tsx b/src/components/session/SessionRunnerSurface.tsx index 1b95f8d..421de7b 100644 --- a/src/components/session/SessionRunnerSurface.tsx +++ b/src/components/session/SessionRunnerSurface.tsx @@ -1,4 +1,5 @@ import { AnimatePresence, motion } from "framer-motion"; +import { buildRunnerPtyId } from "../../services/ptyIdentity"; import { PtyTerminal } from "../PtyTerminal"; interface InstallTerminalProps { @@ -52,6 +53,7 @@ export function SessionRunnerSurface({ contextEnv, InstallTerminal, }: SessionRunnerSurfaceProps) { + const runnerPtyId = buildRunnerPtyId(sessionId); const installOverlayBackground = isGlass ? "transparent" : "var(--ci-pty-panel-bg)"; const installStripBackground = isGlass ? "var(--ci-toolbar-bg)" : "transparent"; const installPromptColor = isGlass ? "var(--ci-text-dim)" : "var(--ci-pty-mask-footer)"; @@ -108,7 +110,7 @@ export function SessionRunnerSurface({ pointerEvents: querySent && ptyEverActive ? "auto" : "none", }}> { const s = useSessionStore.getState().sessions.find((x) => x.id === sessionId); @@ -79,7 +83,7 @@ export function useSessionRunnerController({ const query = pendingQueryRef.current?.trim(); if (!query || !ptyReadyRef.current) return; invoke("send_pty_query", { - sessionId: sessionIdRef.current, + ptyId: runnerPtyId, query, }) .then(() => { @@ -106,7 +110,7 @@ export function useSessionRunnerController({ send(); return true; - }, [clearPendingQueryTimer, isWindows]); + }, [clearPendingQueryTimer, isWindows, runnerPtyId]); const handlePtyReady = useCallback(() => { ptyReadyRef.current = true; @@ -192,9 +196,9 @@ export function useSessionRunnerController({ clearPendingQueryTimer(); ptyReadyRef.current = false; pendingQueryRef.current = null; - invoke("stop_pty_session", { sessionId }).catch(() => {}); + stopPty(runnerPtyId).catch(() => {}); switchRunnerForSession(sessionId, type); - }, [clearPendingQueryTimer, sessionId]); + }, [clearPendingQueryTimer, runnerPtyId, sessionId]); useEffect(() => { if (!isOpen) return; @@ -247,15 +251,15 @@ export function useSessionRunnerController({ }, [recheckCli]); useEffect(() => { - const u = listen<{ session_id: string }>("pty-exit", ({ payload }) => { - if (payload.session_id !== sessionIdRef.current) return; + const u = listen<{ pty_id?: string; session_id?: string }>("pty-exit", ({ payload }) => { + if (payload.pty_id !== runnerPtyId) return; setTimeout(() => { updateSession(sessionIdRef.current, { status: "done" }); setQuerySent(false); }, 1200); }); return () => { void u.then((f) => f()).catch(() => {}); }; - }, [updateSession]); + }, [runnerPtyId, updateSession]); useEffect(() => { pendingQueryForInputRef.current = pendingQuery; diff --git a/src/services/ptyCommands.ts b/src/services/ptyCommands.ts new file mode 100644 index 0000000..99ca43a --- /dev/null +++ b/src/services/ptyCommands.ts @@ -0,0 +1,61 @@ +import { invoke } from "@tauri-apps/api/core"; +import { buildRunnerPtyId, buildTerminalPtyId } from "./ptyIdentity"; +import { usePtyStore } from "../store/ptyStore"; +import { useSessionStore } from "../store/sessionStore"; +import { useSettingsStore } from "../store/settingsStore"; + +export async function stopPty(ptyId: string) { + await invoke("stop_pty_session", { ptyId }).catch(() => {}); + usePtyStore.getState().markRuntimeExited(ptyId); +} + +export async function listPtys() { + return invoke>("list_pty_sessions"); +} + +function getTerminalTabKeys(): string[] { + const items = useSettingsStore.getState().settings.splitWidgetCanvas.items; + const keys = items.flatMap((item) => ( + item.type === "terminal" ? item.tabs.map((tab) => tab.ptySessionKey) : [] + )); + return [...new Set(keys)]; +} + +export function getManagedPtyIdsForSession(sessionId: string): string[] { + const descriptorIds = usePtyStore.getState().descriptors + .filter((descriptor) => descriptor.sessionId === sessionId) + .map((descriptor) => descriptor.ptyId); + if (descriptorIds.length > 0) { + return [...new Set(descriptorIds)]; + } + return [ + buildRunnerPtyId(sessionId), + ...getTerminalTabKeys().map((tabKey) => buildTerminalPtyId(sessionId, tabKey)), + ]; +} + +export async function stopSessionPtys(sessionId: string) { + const ptyIds = getManagedPtyIdsForSession(sessionId); + await Promise.allSettled(ptyIds.map((ptyId) => stopPty(ptyId))); + usePtyStore.getState().removeSessionDescriptors(sessionId); +} + +export async function stopTerminalTabPtys(tabKey: string) { + const descriptorIds = usePtyStore.getState().descriptors + .filter((descriptor) => descriptor.widgetTabKey === tabKey) + .map((descriptor) => descriptor.ptyId); + const ptyIds = descriptorIds.length > 0 + ? descriptorIds + : useSessionStore.getState().sessions.map((session) => buildTerminalPtyId(session.id, tabKey)); + await Promise.allSettled(ptyIds.map((ptyId) => stopPty(ptyId))); + usePtyStore.getState().removeTerminalDescriptorsByTabKey(tabKey); +} diff --git a/src/services/ptyIdentity.ts b/src/services/ptyIdentity.ts new file mode 100644 index 0000000..fa02ac7 --- /dev/null +++ b/src/services/ptyIdentity.ts @@ -0,0 +1,11 @@ +export function sanitizePtySegment(value: string) { + return value.replace(/[^a-zA-Z0-9_-]/g, "_"); +} + +export function buildRunnerPtyId(sessionId: string) { + return `runner:${sanitizePtySegment(sessionId)}`; +} + +export function buildTerminalPtyId(sessionId: string, terminalId: string) { + return `terminal:${sanitizePtySegment(sessionId)}:${sanitizePtySegment(terminalId)}`; +} diff --git a/src/store/persistStorage.ts b/src/store/persistStorage.ts index 82ee034..e9d6218 100644 --- a/src/store/persistStorage.ts +++ b/src/store/persistStorage.ts @@ -4,6 +4,7 @@ const PERSIST_KEYS = [ "code-bar-sessions", "code-bar-workspaces", "code-bar-settings", + "code-bar-ptys", ] as const; type PersistKey = typeof PERSIST_KEYS[number]; @@ -324,6 +325,7 @@ function mergePersistedValue( case "code-bar-workspaces": return mergeWorkspaceValue(fileValue, localValue, deletedState); case "code-bar-settings": + case "code-bar-ptys": return localValue ?? fileValue ?? null; default: return localValue ?? fileValue ?? null; diff --git a/src/store/ptyStore.ts b/src/store/ptyStore.ts new file mode 100644 index 0000000..4987b50 --- /dev/null +++ b/src/store/ptyStore.ts @@ -0,0 +1,421 @@ +import { create } from "zustand"; +import { createJSONStorage, persist } from "zustand/middleware"; +import { buildRunnerPtyId, buildTerminalPtyId } from "../services/ptyIdentity"; +import { mirroredPersistStorage } from "./persistStorage"; +import type { ClaudeSession } from "./sessionStore"; +import type { SplitWidgetTerminalTab } from "./settingsStore"; + +export type PtyKind = "runner" | "terminal" | "ephemeral"; +export type PtyCwdMode = "sessionWorktree" | "repoPath" | "customPath"; +export type PtyRuntimeStatus = "idle" | "starting" | "running" | "waiting" | "error" | "exited"; + +export interface PtyDescriptor { + ptyId: string; + sessionId: string; + workspaceId: string; + kind: PtyKind; + title: string; + cwdMode: PtyCwdMode; + cwdPath?: string; + pinned: boolean; + createdAt: number; + widgetTabKey?: string; +} + +export interface PtyRuntimeSnapshot { + ptyId: string; + sessionId?: string; + kind?: PtyKind; + status: PtyRuntimeStatus; + lastActiveAt: number; + lastDetachedAt?: number; + lastCols?: number; + lastRows?: number; + attachedViewIds: string[]; + visibleViewIds: string[]; + error?: string; + live: boolean; + evictable: boolean; + protectedReason?: string; +} + +export interface BackendPtySessionInfo { + ptyId: string; + sessionId?: string | null; + kind: string; + runnerType: string; + workdir: string; + createdAtMs: number; + lastActiveAtMs: number; + status: string; +} + +interface PtyStore { + descriptors: PtyDescriptor[]; + runtimeById: Record; + + syncWithSessions: (sessions: ClaudeSession[], terminalTabs: SplitWidgetTerminalTab[]) => void; + updateDescriptor: (ptyId: string, patch: Partial>) => void; + togglePinned: (ptyId: string) => void; + attachView: (ptyId: string, viewId: string) => void; + detachView: (ptyId: string, viewId: string) => void; + setViewVisible: (ptyId: string, viewId: string, visible: boolean) => void; + setRuntimeStatus: (ptyId: string, status: PtyRuntimeStatus, extra?: Partial) => void; + touchRuntime: (ptyId: string, patch?: Partial) => void; + markRuntimeExited: (ptyId: string, error?: string) => void; + setRuntimeSize: (ptyId: string, cols: number, rows: number) => void; + syncLiveRuntimes: (entries: BackendPtySessionInfo[]) => void; + removeSessionDescriptors: (sessionId: string) => void; + removeTerminalDescriptorsByTabKey: (tabKey: string) => void; +} + +function dedupeStrings(values: string[]) { + return [...new Set(values.filter(Boolean))]; +} + +function normalizeKind(kind: string): PtyKind { + if (kind === "runner" || kind === "terminal") return kind; + return "ephemeral"; +} + +function ensureRuntimeSnapshot( + current: PtyRuntimeSnapshot | undefined, + ptyId: string, + patch?: Partial +): PtyRuntimeSnapshot { + return { + ptyId, + status: "idle", + lastActiveAt: Date.now(), + attachedViewIds: current?.attachedViewIds ?? [], + visibleViewIds: current?.visibleViewIds ?? [], + live: current?.live ?? false, + evictable: current?.evictable ?? false, + ...current, + ...(patch ?? {}), + }; +} + +function describeProtection(descriptor: PtyDescriptor | undefined, runtime: PtyRuntimeSnapshot | undefined) { + if (!descriptor) { + return { evictable: false, protectedReason: undefined as string | undefined }; + } + if (descriptor.kind === "runner") { + return { evictable: false, protectedReason: "runner" }; + } + if (descriptor.pinned) { + return { evictable: false, protectedReason: "pinned" }; + } + if (runtime?.visibleViewIds.length) { + return { evictable: false, protectedReason: "visible" }; + } + if (runtime?.attachedViewIds.length) { + return { evictable: false, protectedReason: "attached" }; + } + if (runtime?.live) { + return { evictable: true, protectedReason: undefined }; + } + return { evictable: false, protectedReason: "inactive" }; +} + +function buildDesiredDescriptors(sessions: ClaudeSession[], terminalTabs: SplitWidgetTerminalTab[], current: PtyDescriptor[]) { + const currentById = new Map(current.map((descriptor) => [descriptor.ptyId, descriptor])); + const next: PtyDescriptor[] = []; + + sessions.forEach((session) => { + const runnerPtyId = buildRunnerPtyId(session.id); + const existingRunner = currentById.get(runnerPtyId); + next.push({ + ptyId: runnerPtyId, + sessionId: session.id, + workspaceId: session.workspaceId, + kind: "runner", + title: session.name, + cwdMode: "sessionWorktree", + cwdPath: session.worktreePath ?? session.workdir, + pinned: existingRunner?.pinned ?? true, + createdAt: existingRunner?.createdAt ?? session.createdAt, + }); + + terminalTabs.forEach((tab) => { + const ptyId = buildTerminalPtyId(session.id, tab.ptySessionKey); + const existingTerminal = currentById.get(ptyId); + next.push({ + ptyId, + sessionId: session.id, + workspaceId: session.workspaceId, + kind: "terminal", + title: existingTerminal?.title?.trim() || tab.title, + cwdMode: existingTerminal?.cwdMode ?? "sessionWorktree", + cwdPath: existingTerminal?.cwdPath, + pinned: existingTerminal?.pinned ?? false, + createdAt: existingTerminal?.createdAt ?? Date.now(), + widgetTabKey: tab.ptySessionKey, + }); + }); + }); + + return next; +} + +export const usePtyStore = create()( + persist( + (set) => ({ + descriptors: [], + runtimeById: {}, + + syncWithSessions: (sessions, terminalTabs) => + set((state) => { + const nextDescriptors = buildDesiredDescriptors(sessions, terminalTabs, state.descriptors); + const validIds = new Set(nextDescriptors.map((descriptor) => descriptor.ptyId)); + const runtimeById = Object.fromEntries( + Object.entries(state.runtimeById).filter(([ptyId]) => validIds.has(ptyId)) + ); + return { + descriptors: nextDescriptors, + runtimeById, + }; + }), + + updateDescriptor: (ptyId, patch) => + set((state) => ({ + descriptors: state.descriptors.map((descriptor) => ( + descriptor.ptyId === ptyId ? { ...descriptor, ...patch } : descriptor + )), + })), + + togglePinned: (ptyId) => + set((state) => ({ + descriptors: state.descriptors.map((descriptor) => ( + descriptor.ptyId === ptyId ? { ...descriptor, pinned: !descriptor.pinned } : descriptor + )), + })), + + attachView: (ptyId, viewId) => + set((state) => { + const current = ensureRuntimeSnapshot(state.runtimeById[ptyId], ptyId); + return { + runtimeById: { + ...state.runtimeById, + [ptyId]: { + ...current, + attachedViewIds: dedupeStrings([...current.attachedViewIds, viewId]), + lastActiveAt: Date.now(), + }, + }, + }; + }), + + detachView: (ptyId, viewId) => + set((state) => { + const current = state.runtimeById[ptyId]; + if (!current) return {}; + const nextRuntime = { + ...current, + attachedViewIds: current.attachedViewIds.filter((id) => id !== viewId), + visibleViewIds: current.visibleViewIds.filter((id) => id !== viewId), + lastActiveAt: Date.now(), + lastDetachedAt: Date.now(), + }; + const descriptor = state.descriptors.find((item) => item.ptyId === ptyId); + const protection = describeProtection(descriptor, nextRuntime); + return { + runtimeById: { + ...state.runtimeById, + [ptyId]: { + ...nextRuntime, + ...protection, + }, + }, + }; + }), + + setViewVisible: (ptyId, viewId, visible) => + set((state) => { + const current = ensureRuntimeSnapshot(state.runtimeById[ptyId], ptyId); + const visibleViewIds = visible + ? dedupeStrings([...current.visibleViewIds, viewId]) + : current.visibleViewIds.filter((id) => id !== viewId); + const nextRuntime = { + ...current, + visibleViewIds, + lastActiveAt: Date.now(), + }; + const descriptor = state.descriptors.find((item) => item.ptyId === ptyId); + const protection = describeProtection(descriptor, nextRuntime); + return { + runtimeById: { + ...state.runtimeById, + [ptyId]: { + ...nextRuntime, + ...protection, + }, + }, + }; + }), + + setRuntimeStatus: (ptyId, status, extra) => + set((state) => { + const nextRuntime = ensureRuntimeSnapshot(state.runtimeById[ptyId], ptyId, { + ...extra, + status, + live: status !== "exited", + lastActiveAt: Date.now(), + }); + const descriptor = state.descriptors.find((item) => item.ptyId === ptyId); + const protection = describeProtection(descriptor, nextRuntime); + return { + runtimeById: { + ...state.runtimeById, + [ptyId]: { + ...nextRuntime, + ...protection, + }, + }, + }; + }), + + touchRuntime: (ptyId, patch) => + set((state) => { + const nextRuntime = ensureRuntimeSnapshot(state.runtimeById[ptyId], ptyId, { + ...(patch ?? {}), + lastActiveAt: patch?.lastActiveAt ?? Date.now(), + }); + const descriptor = state.descriptors.find((item) => item.ptyId === ptyId); + const protection = describeProtection(descriptor, nextRuntime); + return { + runtimeById: { + ...state.runtimeById, + [ptyId]: { + ...nextRuntime, + ...protection, + }, + }, + }; + }), + + markRuntimeExited: (ptyId, error) => + set((state) => { + const nextRuntime = ensureRuntimeSnapshot(state.runtimeById[ptyId], ptyId, { + status: error ? "error" : "exited", + error, + live: false, + lastActiveAt: Date.now(), + }); + const descriptor = state.descriptors.find((item) => item.ptyId === ptyId); + const protection = describeProtection(descriptor, nextRuntime); + return { + runtimeById: { + ...state.runtimeById, + [ptyId]: { + ...nextRuntime, + ...protection, + }, + }, + }; + }), + + setRuntimeSize: (ptyId, cols, rows) => + set((state) => { + const nextRuntime = ensureRuntimeSnapshot(state.runtimeById[ptyId], ptyId, { + lastCols: cols, + lastRows: rows, + lastActiveAt: Date.now(), + }); + const descriptor = state.descriptors.find((item) => item.ptyId === ptyId); + const protection = describeProtection(descriptor, nextRuntime); + return { + runtimeById: { + ...state.runtimeById, + [ptyId]: { + ...nextRuntime, + ...protection, + }, + }, + }; + }), + + syncLiveRuntimes: (entries) => + set((state) => { + const liveIds = new Set(entries.map((entry) => entry.ptyId)); + const runtimeById = { ...state.runtimeById }; + + entries.forEach((entry) => { + const existing = runtimeById[entry.ptyId]; + const nextRuntime = ensureRuntimeSnapshot(existing, entry.ptyId, { + sessionId: entry.sessionId ?? undefined, + kind: normalizeKind(entry.kind), + status: (entry.status as PtyRuntimeStatus) || "running", + live: true, + lastActiveAt: entry.lastActiveAtMs || Date.now(), + }); + const descriptor = state.descriptors.find((item) => item.ptyId === entry.ptyId); + runtimeById[entry.ptyId] = { + ...nextRuntime, + ...describeProtection(descriptor, nextRuntime), + }; + }); + + Object.keys(runtimeById).forEach((ptyId) => { + if (liveIds.has(ptyId)) return; + const existing = runtimeById[ptyId]; + if (!existing?.live) return; + const nextRuntime = { + ...existing, + live: false, + status: existing.status === "error" ? "error" : "exited", + }; + const descriptor = state.descriptors.find((item) => item.ptyId === ptyId); + runtimeById[ptyId] = { + ...nextRuntime, + ...describeProtection(descriptor, nextRuntime), + }; + }); + + return { runtimeById }; + }), + + removeSessionDescriptors: (sessionId) => + set((state) => { + const removedIds = new Set( + state.descriptors + .filter((descriptor) => descriptor.sessionId === sessionId) + .map((descriptor) => descriptor.ptyId) + ); + return { + descriptors: state.descriptors.filter((descriptor) => descriptor.sessionId !== sessionId), + runtimeById: Object.fromEntries( + Object.entries(state.runtimeById).filter(([ptyId]) => !removedIds.has(ptyId)) + ), + }; + }), + + removeTerminalDescriptorsByTabKey: (tabKey) => + set((state) => { + const removedIds = new Set( + state.descriptors + .filter((descriptor) => descriptor.widgetTabKey === tabKey) + .map((descriptor) => descriptor.ptyId) + ); + return { + descriptors: state.descriptors.filter((descriptor) => descriptor.widgetTabKey !== tabKey), + runtimeById: Object.fromEntries( + Object.entries(state.runtimeById).filter(([ptyId]) => !removedIds.has(ptyId)) + ), + }; + }), + }), + { + name: "code-bar-ptys", + storage: createJSONStorage(() => mirroredPersistStorage), + partialize: (state) => ({ + descriptors: state.descriptors, + }), + merge: (persisted, current) => ({ + ...current, + ...(persisted as Partial), + runtimeById: {}, + }), + } + ) +);