diff --git a/rust/limux-host-linux/src/control_bridge.rs b/rust/limux-host-linux/src/control_bridge.rs index 7b74d01b..7358e846 100644 --- a/rust/limux-host-linux/src/control_bridge.rs +++ b/rust/limux-host-linux/src/control_bridge.rs @@ -71,6 +71,24 @@ const METHODS: &[&str] = &[ "browser.errors.clear", "browser.is_ready", "browser.is_editable", + "browser.cookies.get", + "browser.cookies.clear", + "browser.storage.local.get", + "browser.storage.local.set", + "browser.storage.local.clear", + "browser.storage.session.get", + "browser.storage.session.set", + "browser.storage.session.clear", + "browser.state.save", + "browser.state.load", + "browser.tab.list", + "browser.tab.new", + "browser.tab.switch", + "browser.tab.close", + "browser.addscript", + "browser.addinitscript", + "browser.addstyle", + "browser.highlight", ]; const PARSE_ERROR_CODE: i64 = -32700; @@ -181,6 +199,35 @@ pub enum ControlCommand { wrap_key: Option, reply: mpsc::Sender, }, + BrowserTabList { + target: WorkspaceTarget, + pane: Option, + reply: mpsc::Sender, + }, + BrowserTabNew { + target: WorkspaceTarget, + pane: Option, + url: Option, + reply: mpsc::Sender, + }, + BrowserTabSwitch { + surface: String, + reply: mpsc::Sender, + }, + BrowserTabClose { + surface: String, + reply: mpsc::Sender, + }, + BrowserAddInitScript { + surface: String, + script: String, + reply: mpsc::Sender, + }, + BrowserAddStyle { + surface: String, + css: String, + reply: mpsc::Sender, + }, } impl ControlCommand { @@ -204,7 +251,13 @@ impl ControlCommand { | Self::BrowserForward { reply, .. } | Self::BrowserReload { reply, .. } | Self::BrowserScreenshot { reply, .. } - | Self::BrowserEval { reply, .. } => { + | Self::BrowserEval { reply, .. } + | Self::BrowserTabList { reply, .. } + | Self::BrowserTabNew { reply, .. } + | Self::BrowserTabSwitch { reply, .. } + | Self::BrowserTabClose { reply, .. } + | Self::BrowserAddInitScript { reply, .. } + | Self::BrowserAddStyle { reply, .. } => { let _ = reply.send(result); } } @@ -589,6 +642,96 @@ fn handle_method( rx, ) } + "browser.tab.list" => { + let target = match parse_optional_workspace_target(params, false) { + Ok(t) => t, + Err(error) => return error_response(id, error), + }; + let pane = optional_string(params, &["pane_id", "pane_ref", "pane"]) + .map(|p| p.strip_prefix("pane:").unwrap_or(&p).to_string()); + let (reply, rx) = mpsc::channel(); + ( + ControlCommand::BrowserTabList { + target, + pane, + reply, + }, + rx, + ) + } + "browser.tab.new" => { + let target = match parse_optional_workspace_target(params, false) { + Ok(t) => t, + Err(error) => return error_response(id, error), + }; + let pane = optional_string(params, &["pane_id", "pane_ref", "pane"]) + .map(|p| p.strip_prefix("pane:").unwrap_or(&p).to_string()); + let url = optional_string(params, &["url"]); + let (reply, rx) = mpsc::channel(); + ( + ControlCommand::BrowserTabNew { + target, + pane, + url, + reply, + }, + rx, + ) + } + "browser.tab.switch" => { + let surface = match required_string(params, &["surface_id", "id"], "surface_id") { + Ok(value) => normalize_handle(value, "surface:"), + Err(error) => return error_response(id, error), + }; + let (reply, rx) = mpsc::channel(); + (ControlCommand::BrowserTabSwitch { surface, reply }, rx) + } + "browser.tab.close" => { + let surface = match required_string(params, &["surface_id", "id"], "surface_id") { + Ok(value) => normalize_handle(value, "surface:"), + Err(error) => return error_response(id, error), + }; + let (reply, rx) = mpsc::channel(); + (ControlCommand::BrowserTabClose { surface, reply }, rx) + } + "browser.addinitscript" => { + let surface = match required_string(params, &["surface_id", "id"], "surface_id") { + Ok(value) => normalize_handle(value, "surface:"), + Err(error) => return error_response(id, error), + }; + let script = match required_string(params, &["script"], "script") { + Ok(value) => value, + Err(error) => return error_response(id, error), + }; + let (reply, rx) = mpsc::channel(); + ( + ControlCommand::BrowserAddInitScript { + surface, + script, + reply, + }, + rx, + ) + } + "browser.addstyle" => { + let surface = match required_string(params, &["surface_id", "id"], "surface_id") { + Ok(value) => normalize_handle(value, "surface:"), + Err(error) => return error_response(id, error), + }; + let css = match required_string(params, &["css", "style"], "css") { + Ok(value) => value, + Err(error) => return error_response(id, error), + }; + let (reply, rx) = mpsc::channel(); + ( + ControlCommand::BrowserAddStyle { + surface, + css, + reply, + }, + rx, + ) + } m if m.starts_with("browser.") => { let surface = match required_string(params, &["surface_id", "id"], "surface_id") { Ok(value) => normalize_handle(value, "surface:"), @@ -766,12 +909,232 @@ fn build_browser_script( r#"JSON.stringify({ editable: !!(window.__limux && window.__limux.isEditable()) })"#.to_string(), None, )), + "browser.cookies.get" => Ok((browser_cookies_get(), None)), + "browser.cookies.clear" => Ok((browser_cookies_clear(params), None)), + "browser.storage.local.get" => Ok((browser_storage_get("localStorage", params), None)), + "browser.storage.local.set" => Ok((browser_storage_set("localStorage", params)?, None)), + "browser.storage.local.clear" => Ok((browser_storage_clear("localStorage"), None)), + "browser.storage.session.get" => Ok((browser_storage_get("sessionStorage", params), None)), + "browser.storage.session.set" => Ok((browser_storage_set("sessionStorage", params)?, None)), + "browser.storage.session.clear" => Ok((browser_storage_clear("sessionStorage"), None)), + "browser.state.save" => Ok((browser_state_save(params)?, None)), + "browser.state.load" => Ok((browser_state_load(params)?, None)), + "browser.addscript" => { + let script = required_string(params, &["script"], "script")?; + Ok((format!("(() => {{ {script}\nreturn JSON.stringify({{ ok: true }}); }})()", script = script), None)) + } + "browser.highlight" => Ok((browser_highlight(params)?, None)), _ => Err(BridgeError::invalid_params(format!( "no browser script for {method}" ))), } } +/// Return all cookies visible to `document.cookie` (same-origin, non-HttpOnly). +/// HttpOnly session cookies are intentionally hidden from scripts by the +/// browser, but they are persisted in the NetworkSession sqlite store so +/// login state survives relaunches without an explicit save/load. +fn browser_cookies_get() -> String { + r#"(() => { + const raw = document.cookie || ""; + const cookies = raw.split(/;\s*/).filter(Boolean).map(pair => { + const i = pair.indexOf("="); + return i >= 0 + ? { name: pair.slice(0, i), value: decodeURIComponent(pair.slice(i + 1)) } + : { name: pair, value: "" }; + }); + return JSON.stringify({ ok: true, cookies, origin: location.origin }); + })()"# + .to_string() +} + +/// Clear every cookie visible to the current origin by overwriting with an +/// expired date. HttpOnly cookies remain untouched because they aren't +/// visible to scripts; use browser.navigate to a fresh origin + clear there, +/// or rely on the sqlite store for persistent invalidation. +fn browser_cookies_clear(params: &Map) -> String { + let name = optional_string(params, &["name"]); + match name { + Some(n) => format!( + r#"(() => {{ + const name = {n}; + document.cookie = name + "=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT"; + return JSON.stringify({{ ok: true, cleared: [name] }}); + }})()"#, + n = js_literal(&n) + ), + None => r#"(() => { + const names = (document.cookie || "").split(/;\s*/).filter(Boolean).map(p => { + const i = p.indexOf("="); + return i >= 0 ? p.slice(0, i) : p; + }); + for (const name of names) { + document.cookie = name + "=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT"; + } + return JSON.stringify({ ok: true, cleared: names }); + })()"# + .to_string(), + } +} + +fn browser_storage_get(kind: &str, params: &Map) -> String { + let key = optional_string(params, &["key", "name"]); + match key { + Some(k) => format!( + r#"(() => {{ + const store = {kind}; + const key = {k}; + return JSON.stringify({{ ok: true, key, value: store.getItem(key) }}); + }})()"#, + kind = kind, + k = js_literal(&k) + ), + None => format!( + r#"(() => {{ + const store = {kind}; + const all = {{}}; + for (let i = 0; i < store.length; i++) {{ + const key = store.key(i); + if (key != null) all[key] = store.getItem(key); + }} + return JSON.stringify({{ ok: true, items: all }}); + }})()"#, + kind = kind + ), + } +} + +fn browser_storage_set(kind: &str, params: &Map) -> Result { + let key = required_string(params, &["key", "name"], "key")?; + let value = optional_string(params, &["value"]).unwrap_or_default(); + Ok(format!( + r#"(() => {{ + try {{ + {kind}.setItem({k}, {v}); + return JSON.stringify({{ ok: true, key: {k} }}); + }} catch (e) {{ + return JSON.stringify({{ ok: false, error: {{ code: "STORAGE_ERROR", message: String(e && e.message || e) }} }}); + }} + }})()"#, + kind = kind, + k = js_literal(&key), + v = js_literal(&value) + )) +} + +fn browser_storage_clear(kind: &str) -> String { + format!( + r#"(() => {{ {kind}.clear(); return JSON.stringify({{ ok: true }}); }})()"#, + kind = kind + ) +} + +/// Pulse an outline ring on the target element. Useful for visual debugging: +/// `limux-cli browser --surface ... highlight --ref @e3 --duration 800`. +fn browser_highlight(params: &Map) -> Result { + let handle = target_handle(params)?; + let duration = params + .get("duration") + .or_else(|| params.get("duration_ms")) + .and_then(|v| v.as_u64()) + .unwrap_or(600); + let body = format!( + r#" + const prev_outline = el.style.outline; + const prev_outline_offset = el.style.outlineOffset; + const prev_transition = el.style.transition; + el.style.transition = "outline-color 200ms"; + el.style.outline = "3px solid #ff00aa"; + el.style.outlineOffset = "2px"; + setTimeout(() => {{ + el.style.outline = prev_outline; + el.style.outlineOffset = prev_outline_offset; + el.style.transition = prev_transition; + }}, {duration}); + return JSON.stringify({{ ok: true, ref: target.ref, selector: target.selector, duration_ms: {duration} }}); + "#, + duration = duration + ); + Ok(wrap_action_js(&js_literal(&handle), &body)) +} + +fn browser_state_save(_params: &Map) -> Result { + Ok(r#"(() => { + const dumpStore = (store) => { + const out = {}; + for (let i = 0; i < store.length; i++) { + const k = store.key(i); + if (k != null) out[k] = store.getItem(k); + } + return out; + }; + const cookies = (document.cookie || "").split(/;\s*/).filter(Boolean).map(pair => { + const i = pair.indexOf("="); + return i >= 0 ? { name: pair.slice(0, i), value: decodeURIComponent(pair.slice(i + 1)) } + : { name: pair, value: "" }; + }); + return JSON.stringify({ + ok: true, + version: 1, + url: location.href, + origin: location.origin, + cookies, + local_storage: dumpStore(localStorage), + session_storage: dumpStore(sessionStorage), + }); + })()"# + .to_string()) +} + +/// Apply a saved state bundle. Caller passes the `bundle` object (already +/// parsed from JSON on the client side). Cookies must match the current +/// origin; we don't attempt cross-origin injection. +fn browser_state_load(params: &Map) -> Result { + let bundle = params + .get("bundle") + .or_else(|| params.get("state")) + .ok_or_else(|| BridgeError::invalid_params("bundle required"))?; + let bundle_literal = serde_json::to_string(bundle) + .map_err(|e| BridgeError::invalid_params(format!("bundle not serializable: {e}")))?; + Ok(format!( + r#"((bundle) => {{ + try {{ + if (!bundle || typeof bundle !== "object") {{ + return JSON.stringify({{ ok: false, error: {{ code: "INVALID_BUNDLE", message: "bundle must be an object" }} }}); + }} + if (bundle.local_storage && typeof bundle.local_storage === "object") {{ + for (const [k, v] of Object.entries(bundle.local_storage)) {{ + try {{ localStorage.setItem(k, v); }} catch (_) {{}} + }} + }} + if (bundle.session_storage && typeof bundle.session_storage === "object") {{ + for (const [k, v] of Object.entries(bundle.session_storage)) {{ + try {{ sessionStorage.setItem(k, v); }} catch (_) {{}} + }} + }} + if (Array.isArray(bundle.cookies)) {{ + for (const c of bundle.cookies) {{ + if (c && typeof c.name === "string") {{ + document.cookie = c.name + "=" + encodeURIComponent(c.value || "") + "; path=/"; + }} + }} + }} + return JSON.stringify({{ + ok: true, + applied: {{ + cookies: Array.isArray(bundle.cookies) ? bundle.cookies.length : 0, + local_storage: bundle.local_storage ? Object.keys(bundle.local_storage).length : 0, + session_storage: bundle.session_storage ? Object.keys(bundle.session_storage).length : 0, + }} + }}); + }} catch (e) {{ + return JSON.stringify({{ ok: false, error: {{ code: "LOAD_ERROR", message: String(e && e.message || e) }} }}); + }} + }})({bundle})"#, + bundle = bundle_literal + )) +} + fn snapshot_opts(params: &Map) -> String { let mut obj = serde_json::Map::new(); if let Some(v) = params.get("full_tree") { diff --git a/rust/limux-host-linux/src/pane.rs b/rust/limux-host-linux/src/pane.rs index 238fbbb3..42619417 100644 --- a/rust/limux-host-linux/src/pane.rs +++ b/rust/limux-host-linux/src/pane.rs @@ -614,6 +614,64 @@ pub fn cycle_tab_in_pane(pane_widget: >k::Widget, delta: i32) { (internals.callbacks.on_state_changed)(); } +/// Close a specific tab by id within a pane. Returns false if the pane or tab +/// doesn't exist. Triggers the same empty-pane handling as closing the active +/// tab (may close the pane if it was the last tab). +pub fn close_tab_in_pane_by_id(pane_widget: >k::Widget, tab_id: &str) -> bool { + let Some(outer) = pane_widget.downcast_ref::() else { + return false; + }; + let internals: Rc = unsafe { + match outer.data::>("limux-pane-internals") { + Some(ptr) => ptr.as_ref().clone(), + None => return false, + } + }; + let exists = internals + .tab_state + .borrow() + .tabs + .iter() + .any(|e| e.id == tab_id); + if !exists { + return false; + } + remove_tab( + &internals.tab_strip, + &internals.content_stack, + &internals.tab_state, + tab_id, + &internals.callbacks, + outer, + PaneEmptyReason::ClosedLastTab, + ); + true +} + +/// Activate a specific tab by id within a pane. Returns false if the tab +/// doesn't exist. +pub fn activate_tab_in_pane_by_id(pane_widget: >k::Widget, tab_id: &str) -> bool { + let Some(internals) = find_pane_internals(pane_widget) else { + return false; + }; + let exists = internals + .tab_state + .borrow() + .tabs + .iter() + .any(|e| e.id == tab_id); + if !exists { + return false; + } + activate_tab( + &internals.tab_strip, + &internals.content_stack, + &internals.tab_state, + tab_id, + ); + true +} + pub fn focus_active_tab_in_pane(pane_widget: >k::Widget) -> bool { let Some(internals) = find_pane_internals(pane_widget) else { return false; @@ -2651,16 +2709,45 @@ impl BrowserShortcutTarget { ) { #[cfg(feature = "webkit")] { + // Use call_async_javascript_function so Promise-returning scripts + // are awaited by webkit instead of surfacing as + // "Unsupported result type". The body is wrapped as an async + // function internally, so we prepend `return ` to turn the + // expression-style scripts (e.g. `(() => ...)()`) into a valid + // body that returns the expression's value. + // Strip trailing semicolons before wrapping as `return (...);` + // otherwise a script like `(() => ...)();` becomes `return (...;);` + // which is a syntax error. + let trimmed = script.trim().trim_end_matches(';').trim_end(); + let body = format!("return ({trimmed});"); let mut cb = Some(callback); - self.handles.webview.evaluate_javascript( - &script, + self.handles.webview.call_async_javascript_function( + &body, + None, None, None, None::<>k::gio::Cancellable>, move |result| { if let Some(cb) = cb.take() { match result { - Ok(value) => cb(Ok(value.to_str().to_string())), + Ok(value) => { + // jsc::Value::to_json serializes any type + // (string, object, null, bool) to a JSON + // string — safer than to_str which rejects + // non-string types. Our JS always returns a + // JSON-encoded string so value.to_str is + // usually fine; fall back to to_json for + // safety. + let raw = value.to_str(); + let raw = raw.as_str(); + if !raw.is_empty() && raw != "[object Promise]" { + cb(Ok(raw.to_string())); + } else { + let json = + value.to_json(0).map(|g| g.to_string()).unwrap_or_default(); + cb(Ok(json)); + } + } Err(error) => cb(Err(error.to_string())), } } @@ -2714,6 +2801,56 @@ impl BrowserShortcutTarget { callback(Err("webkit feature disabled".to_string())); } } + + /// Register a user script that webkit injects on every top-frame load. + /// Useful for `browser.addinitscript` — the script persists across + /// navigations for the lifetime of the WebView. + pub fn add_user_script(&self, source: &str) -> bool { + #[cfg(feature = "webkit")] + { + use webkit6::prelude::*; + if let Some(ucm) = self.handles.webview.user_content_manager() { + ucm.add_script(&webkit6::UserScript::new( + source, + webkit6::UserContentInjectedFrames::TopFrame, + webkit6::UserScriptInjectionTime::Start, + &[], + &[], + )); + return true; + } + false + } + #[cfg(not(feature = "webkit"))] + { + let _ = source; + false + } + } + + /// Register a user stylesheet that webkit injects on every top-frame load. + pub fn add_user_style(&self, css: &str) -> bool { + #[cfg(feature = "webkit")] + { + use webkit6::prelude::*; + if let Some(ucm) = self.handles.webview.user_content_manager() { + ucm.add_style_sheet(&webkit6::UserStyleSheet::new( + css, + webkit6::UserContentInjectedFrames::TopFrame, + webkit6::UserStyleLevel::User, + &[], + &[], + )); + return true; + } + false + } + #[cfg(not(feature = "webkit"))] + { + let _ = css; + false + } + } } #[cfg(feature = "webkit")] @@ -2975,6 +3112,18 @@ const LIMUX_BROWSER_EDITABLE_STATE_SCRIPT: &str = r#" })(); "#; +#[cfg(feature = "webkit")] +fn cookie_storage_path() -> std::path::PathBuf { + dirs::data_dir() + .unwrap_or_else(|| { + dirs::home_dir() + .unwrap_or_else(|| std::path::PathBuf::from(".")) + .join(".local/share") + }) + .join("limux") + .join("cookies.sqlite") +} + #[cfg(feature = "webkit")] fn create_browser_widget( initial_uri: Option<&str>, @@ -2985,6 +3134,20 @@ fn create_browser_widget( // Use a NetworkSession to avoid sandbox issues let network_session = webkit6::NetworkSession::default(); + // Enable persistent cookie storage so logins survive limux relaunches. + // Idempotent across webviews — the same NetworkSession singleton backs + // every browser tab, so calling set_persistent_storage here affects the + // whole app. + if let Some(cookie_manager) = network_session.as_ref().and_then(|ns| ns.cookie_manager()) { + let cookies_path = cookie_storage_path(); + if let Some(parent) = cookies_path.parent() { + let _ = std::fs::create_dir_all(parent); + } + cookie_manager.set_persistent_storage( + &cookies_path.to_string_lossy(), + webkit6::CookiePersistentStorage::Sqlite, + ); + } let web_context = webkit6::WebContext::default(); let user_content_manager = webkit6::UserContentManager::new(); let dom_editable = Rc::new(Cell::new(false)); diff --git a/rust/limux-host-linux/src/window.rs b/rust/limux-host-linux/src/window.rs index 869fcb64..da97e7ae 100644 --- a/rust/limux-host-linux/src/window.rs +++ b/rust/limux-host-linux/src/window.rs @@ -3169,6 +3169,47 @@ fn handle_control_command(state: &State, command: ControlCommand) { } => { browser_eval(&surface, script, wrap_key, reply); } + ControlCommand::BrowserTabList { + target, + pane, + reply, + } => { + let result = list_surfaces_for_target(state, &target, pane.as_deref()); + let _ = reply.send(result); + } + ControlCommand::BrowserTabNew { + target, + pane, + url, + reply, + } => { + let result = browser_tab_new(state, &target, pane.as_deref(), url); + let _ = reply.send(result); + } + ControlCommand::BrowserTabSwitch { surface, reply } => { + let result = browser_tab_switch(&surface); + let _ = reply.send(result); + } + ControlCommand::BrowserTabClose { surface, reply } => { + let result = browser_tab_close(&surface); + let _ = reply.send(result); + } + ControlCommand::BrowserAddInitScript { + surface, + script, + reply, + } => { + let result = browser_add_init_script(&surface, &script); + let _ = reply.send(result); + } + ControlCommand::BrowserAddStyle { + surface, + css, + reply, + } => { + let result = browser_add_style(&surface, &css); + let _ = reply.send(result); + } } } @@ -3501,6 +3542,205 @@ fn browser_eval( ); } +/// Find a pane widget by id within the active workspace. Falls back to the +/// first pane when pane_id is None. +fn find_pane_widget_in_workspace( + state: &State, + target: &WorkspaceTarget, + pane_id: Option, +) -> Option<(String, gtk::Widget)> { + let app_state = state.borrow(); + let idx = workspace_index_for_target(&app_state, target)?; + let workspace = &app_state.workspaces[idx]; + let ws_id = workspace.id.clone(); + let mut found: Option = None; + pane::walk_panes(&workspace.root, |pane_widget| { + if found.is_some() { + return; + } + match pane_id { + Some(wanted) => { + if let Some(info) = pane::pane_snapshot_info(pane_widget) { + if info.pane_id == wanted { + found = Some(pane_widget.clone()); + } + } + } + None => found = Some(pane_widget.clone()), + } + }); + found.map(|w| (ws_id, w)) +} + +fn browser_tab_new( + state: &State, + target: &WorkspaceTarget, + pane: Option<&str>, + url: Option, +) -> Result { + let explicit_pane_id: Option = pane.and_then(|p| p.parse::().ok()); + + // Workspace id + walker context. + let workspace_id = { + let app_state = state.borrow(); + let idx = workspace_index_for_target(&app_state, target) + .ok_or_else(|| crate::control_bridge::BridgeError::not_found("workspace not found"))?; + app_state.workspaces[idx].id.clone() + }; + + // Pick target pane: + // 1. Explicit pane_id param wins. + // 2. Prefer an existing non-caller pane that already contains a browser + // surface — tabs naturally stack with other browsers. + // 3. Else any non-caller pane. + // 4. Else split the caller's pane so the terminal stays visible. + let caller_pane = find_focused_pane(state).map(|(_, w)| w); + + let mut chosen: Option; + let mut created_split = false; + + if explicit_pane_id.is_some() { + chosen = find_pane_widget_in_workspace(state, target, explicit_pane_id).map(|(_, w)| w); + } else { + let mut candidates_with_browser: Vec = Vec::new(); + let mut candidates_other: Vec = Vec::new(); + { + let app_state = state.borrow(); + if let Some(ws) = app_state.workspaces.iter().find(|w| w.id == workspace_id) { + pane::walk_panes(&ws.root, |pane_widget| { + if caller_pane + .as_ref() + .map(|c| c == pane_widget) + .unwrap_or(false) + { + return; + } + let has_browser = pane::pane_snapshot_info(pane_widget) + .map(|info| { + info.surfaces + .iter() + .any(|s| matches!(s.kind, pane::SurfaceSnapshotKind::Browser)) + }) + .unwrap_or(false); + if has_browser { + candidates_with_browser.push(pane_widget.clone()); + } else { + candidates_other.push(pane_widget.clone()); + } + }); + } + } + chosen = candidates_with_browser + .into_iter() + .next() + .or_else(|| candidates_other.into_iter().next()); + + // No non-caller pane → split caller's pane. + if chosen.is_none() { + let caller = caller_pane.clone().ok_or_else(|| { + crate::control_bridge::BridgeError::not_found("no pane to host tab") + })?; + let new_pane = split_pane( + state, + &workspace_id, + &caller, + gtk::Orientation::Horizontal, + SplitPaneOptions { + initial_state: None, + skip_default_tab: true, + new_pane_first: false, + persist: true, + }, + ); + chosen = Some(new_pane); + created_split = true; + } + } + + let pane_widget = + chosen.ok_or_else(|| crate::control_bridge::BridgeError::not_found("pane not found"))?; + + let resolved_url = url.unwrap_or_else(|| "about:blank".to_string()); + let new_surface_id = pane::add_browser_tab_returning_id(&pane_widget, Some(&resolved_url)) + .ok_or_else(|| { + crate::control_bridge::BridgeError::internal("browser tab creation failed") + })?; + let snapshot = pane::pane_snapshot_info(&pane_widget) + .ok_or_else(|| crate::control_bridge::BridgeError::internal("pane snapshot failed"))?; + let surface = snapshot + .surfaces + .iter() + .find(|s| s.id == new_surface_id) + .ok_or_else(|| { + crate::control_bridge::BridgeError::internal("new surface missing from snapshot") + })?; + Ok(serde_json::json!({ + "surface_id": new_surface_id.as_str(), + "surface_ref": surface_ref(&new_surface_id), + "pane_id": snapshot.pane_id.to_string(), + "pane_ref": pane_ref(snapshot.pane_id), + "created_split": created_split, + "surface": encode_surface_row(&workspace_id, &snapshot, surface), + "url": resolved_url, + })) +} + +fn browser_tab_switch( + surface: &str, +) -> Result { + let pane_widget = pane::find_pane_widget_for_surface(surface) + .ok_or_else(|| crate::control_bridge::BridgeError::not_found("surface not found"))?; + let ok = pane::activate_tab_in_pane_by_id(&pane_widget, surface); + Ok(serde_json::json!({ + "surface_id": surface, + "surface_ref": surface_ref(surface), + "ok": ok, + })) +} + +fn browser_tab_close( + surface: &str, +) -> Result { + let pane_widget = pane::find_pane_widget_for_surface(surface) + .ok_or_else(|| crate::control_bridge::BridgeError::not_found("surface not found"))?; + let ok = pane::close_tab_in_pane_by_id(&pane_widget, surface); + Ok(serde_json::json!({ + "surface_id": surface, + "surface_ref": surface_ref(surface), + "ok": ok, + })) +} + +fn browser_add_init_script( + surface: &str, + script: &str, +) -> Result { + let target = pane::find_browser_target(surface).ok_or_else(|| { + crate::control_bridge::BridgeError::not_found("browser surface not found") + })?; + let ok = target.add_user_script(script); + Ok(serde_json::json!({ + "surface_id": surface, + "surface_ref": surface_ref(surface), + "ok": ok, + })) +} + +fn browser_add_style( + surface: &str, + css: &str, +) -> Result { + let target = pane::find_browser_target(surface).ok_or_else(|| { + crate::control_bridge::BridgeError::not_found("browser surface not found") + })?; + let ok = target.add_user_style(css); + Ok(serde_json::json!({ + "surface_id": surface, + "surface_ref": surface_ref(surface), + "ok": ok, + })) +} + fn add_workspace_from_state(state: &State, workspace: &WorkspaceState) { let shortcuts = { let s = state.borrow();