Reorder tabs by dragging, and pop the browser view into its own window
Build App / compute-version (pull_request) Successful in 3s
Build App / build-macos (pull_request) Successful in 2m31s
Build App / build-linux (pull_request) Successful in 5m35s
Build App / build-windows (pull_request) Successful in 6m9s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped
Build App / compute-version (pull_request) Successful in 3s
Build App / build-macos (pull_request) Successful in 2m31s
Build App / build-linux (pull_request) Successful in 5m35s
Build App / build-windows (pull_request) Successful in 6m9s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped
Two things the UI couldn't do: rearrange the tab strip, and watch the browser while working somewhere else. **Drag to reorder.** `moveTab`/`moveActiveTab` on the store, HTML5 drag on the strip with a marker showing where the drop lands, `Ctrl+Shift+←/→` for the same thing without a mouse. Reordering deliberately does not select what it moves, so a drag aimed at a background tab doesn't yank the main area away from a terminal mid-run. A tab being renamed is not draggable — a draggable ancestor swallows the mouse-drag that selects text in its input. **Pop the browser view out.** `browser_view/popout.rs` opens the view's existing token-bearing loopback URL as a second OS window, with a "Keep on top" toggle so it can float above the app. Window-only: the viewer, the proxy and the container are untouched, so popping out and back interrupts nothing. Three things it rests on: - No capability lists that window, so it has no IPC surface — right for a page served out of a container, and it must stay that way. - The app CSP is irrelevant to it: `frame-src` constrains what the app's document may *embed*, and this is a top-level document. The port range and the token gate are what actually protect it, unchanged. - The window is owned by the session, so the supervisor's teardown closes it. A window onto a viewer that no longer exists is worse than none. The pane drops its iframe while popped out — two viewers can both *drive* the browser, and two cursors on one page is not a feature. `lib.rs`'s `on_window_event` is now guarded on `label() == "main"`. It fires for every window and its body stops every container and exits, so without the guard closing a pop-out would quit the app. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -5,7 +5,7 @@
|
||||
use tauri::{AppHandle, State};
|
||||
|
||||
use crate::browser_view::install::{self, BrowserSetupOutcome};
|
||||
use crate::browser_view::{manager, BrowserViewStatus};
|
||||
use crate::browser_view::{manager, popout, BrowserViewState, BrowserViewStatus};
|
||||
use crate::AppState;
|
||||
|
||||
/// Turn the pane on or off for a project.
|
||||
@@ -97,6 +97,67 @@ pub async fn install_browser_view_browser(
|
||||
install::install_browser(&app_handle, &project_id, &container_id, target).await
|
||||
}
|
||||
|
||||
/// Detach the view into a window of its own, or raise the one already open.
|
||||
///
|
||||
/// Host-side and window-only: the viewer keeps running exactly as it was, and
|
||||
/// this touches neither the container nor the proxy. Requires a *live* view,
|
||||
/// because a window with nothing behind it is not worth opening — the pane
|
||||
/// only offers the button in that state, and this enforces it.
|
||||
#[tauri::command]
|
||||
pub async fn open_browser_view_popout(
|
||||
project_id: String,
|
||||
always_on_top: bool,
|
||||
app_handle: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
let status = manager().status(&project_id).await;
|
||||
let (BrowserViewState::Running, Some(url)) = (status.state, status.url.as_deref()) else {
|
||||
return Err(
|
||||
"The browser view isn't running. Start it before opening it in its own window."
|
||||
.to_string(),
|
||||
);
|
||||
};
|
||||
|
||||
let name = state
|
||||
.projects_store
|
||||
.get(&project_id)
|
||||
.map(|p| p.name)
|
||||
.unwrap_or_else(|| "Triple-C".to_string());
|
||||
|
||||
popout::open(&app_handle, &project_id, &name, url, always_on_top)
|
||||
}
|
||||
|
||||
/// Close the pop-out, putting the view back in the tab. No-op if it is closed.
|
||||
#[tauri::command]
|
||||
pub async fn close_browser_view_popout(
|
||||
project_id: String,
|
||||
app_handle: AppHandle,
|
||||
) -> Result<(), String> {
|
||||
popout::close(&app_handle, &project_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether the pop-out is open — asked on tab open, since a window can outlive
|
||||
/// the pane that spawned it.
|
||||
#[tauri::command]
|
||||
pub async fn is_browser_view_popout_open(
|
||||
project_id: String,
|
||||
app_handle: AppHandle,
|
||||
) -> Result<bool, String> {
|
||||
Ok(popout::is_open(&app_handle, &project_id))
|
||||
}
|
||||
|
||||
/// Pin the pop-out above other windows, so it can be watched while working in
|
||||
/// the main one.
|
||||
#[tauri::command]
|
||||
pub async fn set_browser_view_popout_always_on_top(
|
||||
project_id: String,
|
||||
on_top: bool,
|
||||
app_handle: AppHandle,
|
||||
) -> Result<(), String> {
|
||||
popout::set_always_on_top(&app_handle, &project_id, on_top)
|
||||
}
|
||||
|
||||
/// The project's container, or a sentence saying why there isn't one.
|
||||
///
|
||||
/// Every command here needs a *running* container, and every one of them used
|
||||
|
||||
@@ -64,6 +64,7 @@
|
||||
pub mod commands;
|
||||
pub mod detect;
|
||||
pub mod install;
|
||||
pub mod popout;
|
||||
pub mod proxy;
|
||||
|
||||
use std::collections::HashMap;
|
||||
@@ -474,6 +475,11 @@ async fn supervise(
|
||||
}
|
||||
}
|
||||
|
||||
// A pop-out outlives the tab, so nothing else would take it down: the
|
||||
// window would sit there showing a frozen last frame of a viewer that no
|
||||
// longer exists. The session owns it, and this is where the session ends.
|
||||
popout::close(&app, &project_id);
|
||||
|
||||
let enabled = manager().is_enabled(&project_id).await;
|
||||
emit(&app, &project_id, &BrowserViewStatus::off(enabled));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
//! The browser view in a window of its own.
|
||||
//!
|
||||
//! Watching a browser and working in a terminal are the same task done at the
|
||||
//! same time, and a tab can only be one of them. So the pane can be detached
|
||||
//! into a second OS window — put on the other monitor, or pinned on top of
|
||||
//! whatever else is in front.
|
||||
//!
|
||||
//! ## Why this is a native window and not a second iframe
|
||||
//!
|
||||
//! The window loads the *same* token-bearing loopback URL the pane's iframe
|
||||
//! uses ([`crate::browser_view::BrowserViewStatus::url`]), as its top-level
|
||||
//! document. That has two consequences worth stating:
|
||||
//!
|
||||
//! - It is a **remote-origin** webview. No capability lists this window, so it
|
||||
//! has no IPC surface at all — `invoke` is not reachable from it, which is
|
||||
//! exactly right for a page served out of a container. Do not add one.
|
||||
//! - The app CSP does not apply, and does not need to: `frame-src` exists to
|
||||
//! constrain what the *app's* document may embed, and this is not embedded.
|
||||
//! The port is still confined to [`crate::browser_view::proxy`]'s range and
|
||||
//! still gated by the session token, which is what actually protects it.
|
||||
//!
|
||||
//! ## Lifetime
|
||||
//!
|
||||
//! The window is owned by the session, not by the user's patience: when a view
|
||||
//! stops — the user pressed Stop, the container went away, the viewer died —
|
||||
//! the supervisor's teardown calls [`close`], because a window left showing a
|
||||
//! dead viewer is worse than no window. The reverse is not true; closing the
|
||||
//! window leaves the view running, and the pane takes it back into the tab.
|
||||
|
||||
use tauri::{AppHandle, Emitter, Manager, WebviewUrl, WebviewWindowBuilder, WindowEvent};
|
||||
|
||||
/// Emitted when a pop-out opens or closes. Payload: `{ project_id, open }`.
|
||||
///
|
||||
/// The window can close without the app asking it to — the user hits its X, or
|
||||
/// a teardown takes it — so the pane learns about it the same way it learns
|
||||
/// about everything else here, by listening.
|
||||
const POPOUT_EVENT: &str = "browser-view-popout-changed";
|
||||
|
||||
/// Tauri window labels admit `[a-zA-Z0-9-/:_]` only. Project ids are UUIDs, so
|
||||
/// this never fires in practice; it exists so a hand-edited `projects.json`
|
||||
/// cannot produce a label Tauri rejects at build time.
|
||||
pub fn window_label(project_id: &str) -> String {
|
||||
let id: String = project_id
|
||||
.chars()
|
||||
.map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' })
|
||||
.collect();
|
||||
format!("browser-view-{}", id)
|
||||
}
|
||||
|
||||
/// Open the pop-out, or raise it if it is already open.
|
||||
///
|
||||
/// `url` is the live session's URL; the caller has already established that the
|
||||
/// view is running, because there is nothing to show otherwise.
|
||||
pub fn open(
|
||||
app: &AppHandle,
|
||||
project_id: &str,
|
||||
project_name: &str,
|
||||
url: &str,
|
||||
always_on_top: bool,
|
||||
) -> Result<(), String> {
|
||||
let label = window_label(project_id);
|
||||
|
||||
if let Some(window) = app.get_webview_window(&label) {
|
||||
// Asking twice means "I can't see it", not "open another".
|
||||
let _ = window.unminimize();
|
||||
let _ = window.set_focus();
|
||||
let _ = window.set_always_on_top(always_on_top);
|
||||
emit(app, project_id, true);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let parsed = url
|
||||
.parse()
|
||||
.map_err(|e| format!("The browser view's address is not a URL: {}", e))?;
|
||||
|
||||
let project_id_owned = project_id.to_string();
|
||||
let app_for_event = app.clone();
|
||||
|
||||
let window = WebviewWindowBuilder::new(app, &label, WebviewUrl::External(parsed))
|
||||
.title(format!("{} — browser", project_name))
|
||||
.inner_size(1100.0, 820.0)
|
||||
.min_inner_size(480.0, 360.0)
|
||||
.always_on_top(always_on_top)
|
||||
.build()
|
||||
.map_err(|e| format!("Could not open the browser window: {}", e))?;
|
||||
|
||||
// Closed from its own titlebar, this is the only thing that tells the pane
|
||||
// to take the view back into the tab.
|
||||
window.on_window_event(move |event| {
|
||||
if matches!(event, WindowEvent::Destroyed) {
|
||||
emit(&app_for_event, &project_id_owned, false);
|
||||
}
|
||||
});
|
||||
|
||||
log::info!("Browser view: popped out for project {}", project_id);
|
||||
emit(app, project_id, true);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Close the pop-out if there is one. Safe to call when there isn't.
|
||||
///
|
||||
/// `destroy`, not `close`: `close` raises `CloseRequested`, and the app's
|
||||
/// window-event handler treats that as a request to quit for the main window.
|
||||
/// Nothing here should ever be able to be mistaken for that.
|
||||
pub fn close(app: &AppHandle, project_id: &str) {
|
||||
if let Some(window) = app.get_webview_window(&window_label(project_id)) {
|
||||
if let Err(e) = window.destroy() {
|
||||
log::warn!(
|
||||
"Browser view: could not close the pop-out for project {}: {}",
|
||||
project_id,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
// Unconditional: `Destroyed` covers the normal path, but a window that was
|
||||
// already gone still owes the pane an answer.
|
||||
emit(app, project_id, false);
|
||||
}
|
||||
|
||||
pub fn is_open(app: &AppHandle, project_id: &str) -> bool {
|
||||
app.get_webview_window(&window_label(project_id)).is_some()
|
||||
}
|
||||
|
||||
/// Pin the pop-out above other windows, or unpin it. No-op when it is closed.
|
||||
pub fn set_always_on_top(app: &AppHandle, project_id: &str, on_top: bool) -> Result<(), String> {
|
||||
let Some(window) = app.get_webview_window(&window_label(project_id)) else {
|
||||
return Ok(());
|
||||
};
|
||||
window
|
||||
.set_always_on_top(on_top)
|
||||
.map_err(|e| format!("Could not change the window's stacking: {}", e))
|
||||
}
|
||||
|
||||
fn emit(app: &AppHandle, project_id: &str, open: bool) {
|
||||
let _ = app.emit(
|
||||
POPOUT_EVENT,
|
||||
serde_json::json!({ "project_id": project_id, "open": open }),
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn labels_are_derived_from_the_project_and_are_tauri_safe() {
|
||||
assert_eq!(
|
||||
window_label("6b1f4a2c-0d5e-4f9a-9c11-2f0b7d3e8a44"),
|
||||
"browser-view-6b1f4a2c-0d5e-4f9a-9c11-2f0b7d3e8a44"
|
||||
);
|
||||
assert_eq!(window_label("a b/c.d"), "browser-view-a_b_c_d");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distinct_projects_get_distinct_windows() {
|
||||
assert_ne!(window_label("alpha"), window_label("beta"));
|
||||
}
|
||||
}
|
||||
@@ -328,6 +328,14 @@ pub fn run() {
|
||||
})
|
||||
.on_window_event(|window, event| {
|
||||
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
|
||||
// This handler fires for *every* window, and what follows stops
|
||||
// containers and exits the process. Only the main window means
|
||||
// that. Secondary windows — the browser view's pop-out — are
|
||||
// closed and reopened freely and must just close.
|
||||
if window.label() != "main" {
|
||||
return;
|
||||
}
|
||||
|
||||
let state = window.state::<AppState>();
|
||||
let lifecycle = state.lifecycle.clone();
|
||||
|
||||
@@ -428,6 +436,10 @@ pub fn run() {
|
||||
browser_view::commands::check_browser_view_support,
|
||||
browser_view::commands::install_browser_view_support,
|
||||
browser_view::commands::install_browser_view_browser,
|
||||
browser_view::commands::open_browser_view_popout,
|
||||
browser_view::commands::close_browser_view_popout,
|
||||
browser_view::commands::is_browser_view_popout_open,
|
||||
browser_view::commands::set_browser_view_popout_always_on_top,
|
||||
// Shared Claude Code auth token
|
||||
commands::auth_token_commands::acquire_claude_token,
|
||||
commands::auth_token_commands::submit_claude_token_code,
|
||||
|
||||
Reference in New Issue
Block a user