Build App / compute-version (pull_request) Successful in 4s
Build App / build-macos (pull_request) Successful in 2m30s
Build App / build-linux (pull_request) Successful in 5m11s
Build App / build-windows (pull_request) Successful in 5m23s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped
Ten findings from the review of the previous commit, all applied. **The tab drag is now pointer events, not HTML5 drag-and-drop.** Two independent reasons, either one fatal. Tauri's `dragDropEnabled` blocks HTML5 drag inside the webview on Windows, and it cannot just be turned off — `TerminalView` needs Tauri's native drag-drop event, which is the only one that carries dropped *file paths*. And an HTML5 drag carries a `DataTransfer`: released over any text field in the app, the default handler types `term:<uuid>` into it, and in Config that is then saved with the project. Pointer events have neither problem, and the drag is measured from the tabs on screen rather than from the event target, so the marker and the drop agree even over the marker itself. Escape abandons a drag; a press under 4px stays a click; the click that ends a drag does not select. **`Ctrl+Shift+←/→` no longer swallows word-wise selection.** It is bound on `document` in the capture phase, so in any input — the rename field, Config, Settings — it was taking the OS's extend-selection chord *and* silently reordering the strip. Guarded by `inTextField()`, which excludes xterm's helper textarea: that is an input-method shim, and the terminal is where the shortcut matters most. **The pop-out's state is read from the window, never remembered.** The pane is unmounted whenever another Project Home sub-tab is selected, so "Keep on top" came back Off over a window still floating on top. `get_browser_view_popout_state` returns both facts from the window itself, and the change event carries them. `poppedOut` is tri-state: until the answer arrives the iframe is not mounted, because guessing "not popped out" is what flashes a second viewer onto the browser. Also: `popout::close` and the off-status emit in the supervisor are behind the same epoch guard as the deregistration above them, so a supervisor whose teardown outlives a restart can no longer destroy the *new* session's window; `close()` returns its `destroy()` error instead of logging it and reporting success, since the pane restores its iframe on success; the drop marker is `pointer-events-none` and is placed before the first *visible* tab at or past the slot, so it neither refuses a drop nor vanishes when a `tabOrder` entry renders nothing; and the "Keep on top" Toggle's accessible name now matches its visible text. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
202 lines
7.6 KiB
Rust
202 lines
7.6 KiB
Rust
//! 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 serde::Serialize;
|
|
use tauri::{AppHandle, Emitter, Manager, WebviewUrl, WebviewWindowBuilder, WindowEvent};
|
|
|
|
/// Emitted when a pop-out opens or closes. Payload: [`PopoutState`] plus the
|
|
/// project id.
|
|
///
|
|
/// 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";
|
|
|
|
/// What the pane needs to render its pop-out controls.
|
|
///
|
|
/// Both fields are read from the window itself rather than remembered on either
|
|
/// side: the pane is unmounted whenever another Project Home sub-tab is
|
|
/// selected, so anything it merely *remembers* about the window is gone by the
|
|
/// time the user comes back, while the window is still there.
|
|
#[derive(Debug, Clone, Copy, Serialize)]
|
|
pub struct PopoutState {
|
|
pub open: bool,
|
|
pub always_on_top: bool,
|
|
}
|
|
|
|
impl PopoutState {
|
|
const CLOSED: Self = Self {
|
|
open: false,
|
|
always_on_top: false,
|
|
};
|
|
}
|
|
|
|
/// 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, state(app, project_id));
|
|
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, PopoutState::CLOSED);
|
|
}
|
|
});
|
|
|
|
log::info!("Browser view: popped out for project {}", project_id);
|
|
emit(app, project_id, state(app, project_id));
|
|
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.
|
|
///
|
|
/// A failure is **returned, not logged and forgotten**. The pane puts its
|
|
/// iframe back the moment it believes the window is gone, so reporting a close
|
|
/// that did not happen is how you end up with two viewers driving one browser —
|
|
/// the exact state the iframe is dropped to prevent.
|
|
pub fn close(app: &AppHandle, project_id: &str) -> Result<(), String> {
|
|
if let Some(window) = app.get_webview_window(&window_label(project_id)) {
|
|
window.destroy().map_err(|e| {
|
|
log::warn!(
|
|
"Browser view: could not close the pop-out for project {}: {}",
|
|
project_id,
|
|
e
|
|
);
|
|
format!("Could not close the browser window: {}", e)
|
|
})?;
|
|
}
|
|
// `Destroyed` covers the normal path; a window that was already gone still
|
|
// owes the pane an answer.
|
|
emit(app, project_id, PopoutState::CLOSED);
|
|
Ok(())
|
|
}
|
|
|
|
/// Whether the window exists and how it is stacked, read from the window.
|
|
pub fn state(app: &AppHandle, project_id: &str) -> PopoutState {
|
|
match app.get_webview_window(&window_label(project_id)) {
|
|
Some(window) => PopoutState {
|
|
open: true,
|
|
// A window that cannot answer is not a reason to fail the call; the
|
|
// pin is a preference, and "not pinned" is the safe reading.
|
|
always_on_top: window.is_always_on_top().unwrap_or(false),
|
|
},
|
|
None => PopoutState::CLOSED,
|
|
}
|
|
}
|
|
|
|
/// 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))?;
|
|
emit(app, project_id, state(app, project_id));
|
|
Ok(())
|
|
}
|
|
|
|
fn emit(app: &AppHandle, project_id: &str, state: PopoutState) {
|
|
let _ = app.emit(
|
|
POPOUT_EVENT,
|
|
serde_json::json!({
|
|
"project_id": project_id,
|
|
"open": state.open,
|
|
"always_on_top": state.always_on_top,
|
|
}),
|
|
);
|
|
}
|
|
|
|
#[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"));
|
|
}
|
|
}
|