Reorder tabs by dragging, and pop the browser view into its own window #19
@@ -62,6 +62,14 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li
|
|||||||
`tabOrder` is user-reorderable (drag, or `Ctrl+Shift+←/→` via `moveActiveTab`) — so **never
|
`tabOrder` is user-reorderable (drag, or `Ctrl+Shift+←/→` via `moveActiveTab`) — so **never
|
||||||
treat a tab's position as identity**: address tabs by key, and index only through `tabOrder`.
|
treat a tab's position as identity**: address tabs by key, and index only through `tabOrder`.
|
||||||
`moveTab` deliberately does not activate what it moves.
|
`moveTab` deliberately does not activate what it moves.
|
||||||
|
- **The tab drag is pointer events, not HTML5 drag-and-drop, and must stay that way.** Tauri's
|
||||||
|
`dragDropEnabled` blocks HTML5 drag inside the webview on Windows, and it cannot simply be
|
||||||
|
turned off: `TerminalView` needs Tauri's native drag-drop event because it is the only one
|
||||||
|
that carries dropped *file paths*. An HTML5 drag also carries a `DataTransfer`, which the
|
||||||
|
default handler types into any text field the drag is released over.
|
||||||
|
- **A new app-level shortcut must not swallow a text-editing chord.** `useKeyboardShortcuts`
|
||||||
|
binds on `document` in the capture phase, so `inTextField()` guards the arrow bindings —
|
||||||
|
excluding xterm's helper textarea, which is an input-method shim rather than a field.
|
||||||
- **`hooks/`** — All Tauri IPC calls are encapsulated in hooks (`useTerminal`, `useProjects`, `useDocker`, `useSettings`)
|
- **`hooks/`** — All Tauri IPC calls are encapsulated in hooks (`useTerminal`, `useProjects`, `useDocker`, `useSettings`)
|
||||||
- **`lib/tauri-commands.ts`** — Typed `invoke()` wrappers; TypeScript types in `lib/types.ts` must match Rust models
|
- **`lib/tauri-commands.ts`** — Typed `invoke()` wrappers; TypeScript types in `lib/types.ts` must match Rust models
|
||||||
- **`components/terminal/TerminalView.tsx`** — xterm.js integration with WebGL rendering, URL detection for OAuth flow
|
- **`components/terminal/TerminalView.tsx`** — xterm.js integration with WebGL rendering, URL detection for OAuth flow
|
||||||
|
|||||||
+5
-4
@@ -192,10 +192,11 @@ Anthropic-backend project uses that token without its own login. See
|
|||||||
There is no separate terminal tab bar and no "+" button — tabs appear when you open a project or
|
There is no separate terminal tab bar and no "+" button — tabs appear when you open a project or
|
||||||
a terminal.
|
a terminal.
|
||||||
|
|
||||||
**Drag a tab to reorder it.** A line shows where it will land, and dropping it does not change
|
**Drag a tab to reorder it.** A line shows where it will land; **Escape** abandons the drag.
|
||||||
which tab you are looking at — so you can rearrange the strip without pulling focus away from a
|
Dropping does not change which tab you are looking at — so you can rearrange the strip without
|
||||||
terminal that is mid-run. `Ctrl+Shift+←` and `Ctrl+Shift+→` move the *active* tab the same way
|
pulling focus away from a terminal that is mid-run. `Ctrl+Shift+←` and `Ctrl+Shift+→` move the
|
||||||
without the mouse. The order is per-session: it is not saved when you quit.
|
*active* tab the same way without the mouse (they leave text fields alone, where that chord
|
||||||
|
still selects by word). The order is per-session: it is not saved when you quit.
|
||||||
- **Status indicators (top right)** — Docker connection and container image availability. Each pairs
|
- **Status indicators (top right)** — Docker connection and container image availability. Each pairs
|
||||||
a coloured dot with a word, so status is never conveyed by colour alone. The **?** button opens
|
a coloured dot with a word, so status is never conveyed by colour alone. The **?** button opens
|
||||||
the built-in help.
|
the built-in help.
|
||||||
|
|||||||
@@ -128,23 +128,29 @@ pub async fn open_browser_view_popout(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Close the pop-out, putting the view back in the tab. No-op if it is closed.
|
/// Close the pop-out, putting the view back in the tab. No-op if it is closed.
|
||||||
|
///
|
||||||
|
/// Propagates a failed close rather than reporting success: the pane restores
|
||||||
|
/// its iframe on success, and doing that with the window still up puts two
|
||||||
|
/// viewers on one browser.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn close_browser_view_popout(
|
pub async fn close_browser_view_popout(
|
||||||
project_id: String,
|
project_id: String,
|
||||||
app_handle: AppHandle,
|
app_handle: AppHandle,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
popout::close(&app_handle, &project_id);
|
popout::close(&app_handle, &project_id)
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether the pop-out is open — asked on tab open, since a window can outlive
|
/// Whether the pop-out is open, and whether it is pinned on top.
|
||||||
/// the pane that spawned it.
|
///
|
||||||
|
/// Read on every pane mount: the window outlives the pane — which is unmounted
|
||||||
|
/// whenever another Project Home sub-tab is selected — so neither fact can be
|
||||||
|
/// carried in component state.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn is_browser_view_popout_open(
|
pub async fn get_browser_view_popout_state(
|
||||||
project_id: String,
|
project_id: String,
|
||||||
app_handle: AppHandle,
|
app_handle: AppHandle,
|
||||||
) -> Result<bool, String> {
|
) -> Result<popout::PopoutState, String> {
|
||||||
Ok(popout::is_open(&app_handle, &project_id))
|
Ok(popout::state(&app_handle, &project_id))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pin the pop-out above other windows, so it can be watched while working in
|
/// Pin the pop-out above other windows, so it can be watched while working in
|
||||||
|
|||||||
@@ -468,17 +468,33 @@ async fn supervise(
|
|||||||
let _ = kill_dashboard(&container_id, &cli_entry).await;
|
let _ = kill_dashboard(&container_id, &cli_entry).await;
|
||||||
|
|
||||||
// Deregister, unless a newer session has already taken this project's slot.
|
// Deregister, unless a newer session has already taken this project's slot.
|
||||||
{
|
let superseded = {
|
||||||
let mut map = sessions.lock().await;
|
let mut map = sessions.lock().await;
|
||||||
if map.get(&project_id).is_some_and(|s| s.epoch == epoch) {
|
match map.get(&project_id) {
|
||||||
|
Some(session) if session.epoch == epoch => {
|
||||||
map.remove(&project_id);
|
map.remove(&project_id);
|
||||||
|
false
|
||||||
}
|
}
|
||||||
|
// Someone else owns this project now: `stop` removes the session
|
||||||
|
// from the map *before* awaiting this task, and teardown below is
|
||||||
|
// seconds of Docker work, so a restart in that window is ordinary.
|
||||||
|
Some(_) => true,
|
||||||
|
None => false,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Everything past here speaks for the project as a whole, so a superseded
|
||||||
|
// supervisor must say nothing: closing the pop-out would destroy the *new*
|
||||||
|
// session's window, and the off-status would report a running view as
|
||||||
|
// stopped.
|
||||||
|
if superseded {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// A pop-out outlives the tab, so nothing else would take it down: the
|
// 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
|
// 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.
|
// longer exists. The session owns it, and this is where the session ends.
|
||||||
popout::close(&app, &project_id);
|
let _ = popout::close(&app, &project_id);
|
||||||
|
|
||||||
let enabled = manager().is_enabled(&project_id).await;
|
let enabled = manager().is_enabled(&project_id).await;
|
||||||
emit(&app, &project_id, &BrowserViewStatus::off(enabled));
|
emit(&app, &project_id, &BrowserViewStatus::off(enabled));
|
||||||
|
|||||||
@@ -27,15 +27,36 @@
|
|||||||
//! dead viewer is worse than no window. The reverse is not true; closing the
|
//! 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.
|
//! 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};
|
use tauri::{AppHandle, Emitter, Manager, WebviewUrl, WebviewWindowBuilder, WindowEvent};
|
||||||
|
|
||||||
/// Emitted when a pop-out opens or closes. Payload: `{ project_id, open }`.
|
/// 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
|
/// 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
|
/// a teardown takes it — so the pane learns about it the same way it learns
|
||||||
/// about everything else here, by listening.
|
/// about everything else here, by listening.
|
||||||
const POPOUT_EVENT: &str = "browser-view-popout-changed";
|
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
|
/// 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`
|
/// this never fires in practice; it exists so a hand-edited `projects.json`
|
||||||
/// cannot produce a label Tauri rejects at build time.
|
/// cannot produce a label Tauri rejects at build time.
|
||||||
@@ -65,7 +86,7 @@ pub fn open(
|
|||||||
let _ = window.unminimize();
|
let _ = window.unminimize();
|
||||||
let _ = window.set_focus();
|
let _ = window.set_focus();
|
||||||
let _ = window.set_always_on_top(always_on_top);
|
let _ = window.set_always_on_top(always_on_top);
|
||||||
emit(app, project_id, true);
|
emit(app, project_id, state(app, project_id));
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,12 +109,12 @@ pub fn open(
|
|||||||
// to take the view back into the tab.
|
// to take the view back into the tab.
|
||||||
window.on_window_event(move |event| {
|
window.on_window_event(move |event| {
|
||||||
if matches!(event, WindowEvent::Destroyed) {
|
if matches!(event, WindowEvent::Destroyed) {
|
||||||
emit(&app_for_event, &project_id_owned, false);
|
emit(&app_for_event, &project_id_owned, PopoutState::CLOSED);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
log::info!("Browser view: popped out for project {}", project_id);
|
log::info!("Browser view: popped out for project {}", project_id);
|
||||||
emit(app, project_id, true);
|
emit(app, project_id, state(app, project_id));
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,23 +123,39 @@ pub fn open(
|
|||||||
/// `destroy`, not `close`: `close` raises `CloseRequested`, and the app's
|
/// `destroy`, not `close`: `close` raises `CloseRequested`, and the app's
|
||||||
/// window-event handler treats that as a request to quit for the main window.
|
/// 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.
|
/// Nothing here should ever be able to be mistaken for that.
|
||||||
pub fn close(app: &AppHandle, project_id: &str) {
|
///
|
||||||
|
/// 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)) {
|
if let Some(window) = app.get_webview_window(&window_label(project_id)) {
|
||||||
if let Err(e) = window.destroy() {
|
window.destroy().map_err(|e| {
|
||||||
log::warn!(
|
log::warn!(
|
||||||
"Browser view: could not close the pop-out for project {}: {}",
|
"Browser view: could not close the pop-out for project {}: {}",
|
||||||
project_id,
|
project_id,
|
||||||
e
|
e
|
||||||
);
|
);
|
||||||
|
format!("Could not close the browser window: {}", e)
|
||||||
|
})?;
|
||||||
}
|
}
|
||||||
}
|
// `Destroyed` covers the normal path; a window that was already gone still
|
||||||
// Unconditional: `Destroyed` covers the normal path, but a window that was
|
// owes the pane an answer.
|
||||||
// already gone still owes the pane an answer.
|
emit(app, project_id, PopoutState::CLOSED);
|
||||||
emit(app, project_id, false);
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn is_open(app: &AppHandle, project_id: &str) -> bool {
|
/// Whether the window exists and how it is stacked, read from the window.
|
||||||
app.get_webview_window(&window_label(project_id)).is_some()
|
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.
|
/// Pin the pop-out above other windows, or unpin it. No-op when it is closed.
|
||||||
@@ -128,13 +165,19 @@ pub fn set_always_on_top(app: &AppHandle, project_id: &str, on_top: bool) -> Res
|
|||||||
};
|
};
|
||||||
window
|
window
|
||||||
.set_always_on_top(on_top)
|
.set_always_on_top(on_top)
|
||||||
.map_err(|e| format!("Could not change the window's stacking: {}", e))
|
.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, open: bool) {
|
fn emit(app: &AppHandle, project_id: &str, state: PopoutState) {
|
||||||
let _ = app.emit(
|
let _ = app.emit(
|
||||||
POPOUT_EVENT,
|
POPOUT_EVENT,
|
||||||
serde_json::json!({ "project_id": project_id, "open": open }),
|
serde_json::json!({
|
||||||
|
"project_id": project_id,
|
||||||
|
"open": state.open,
|
||||||
|
"always_on_top": state.always_on_top,
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -438,7 +438,7 @@ pub fn run() {
|
|||||||
browser_view::commands::install_browser_view_browser,
|
browser_view::commands::install_browser_view_browser,
|
||||||
browser_view::commands::open_browser_view_popout,
|
browser_view::commands::open_browser_view_popout,
|
||||||
browser_view::commands::close_browser_view_popout,
|
browser_view::commands::close_browser_view_popout,
|
||||||
browser_view::commands::is_browser_view_popout_open,
|
browser_view::commands::get_browser_view_popout_state,
|
||||||
browser_view::commands::set_browser_view_popout_always_on_top,
|
browser_view::commands::set_browser_view_popout_always_on_top,
|
||||||
// Shared Claude Code auth token
|
// Shared Claude Code auth token
|
||||||
commands::auth_token_commands::acquire_claude_token,
|
commands::auth_token_commands::acquire_claude_token,
|
||||||
|
|||||||
@@ -45,33 +45,22 @@ const S1 = terminalTabKey("s1");
|
|||||||
const S2 = terminalTabKey("s2");
|
const S2 = terminalTabKey("s2");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A stand-in for the DataTransfer jsdom doesn't implement. It only has to
|
* A pointer event carrying a real `clientX`.
|
||||||
* carry the tab key, which is what a drop falls back to reading.
|
*
|
||||||
|
* jsdom implements no `PointerEvent`, so Testing Library's synthesized one has
|
||||||
|
* no coordinates — and the coordinate is the whole point here, since it decides
|
||||||
|
* which slot the drop lands in. `MouseEvent` has one, and React dispatches on
|
||||||
|
* the event's type name either way.
|
||||||
*/
|
*/
|
||||||
function dataTransfer() {
|
function pointer(el: Element, type: string, clientX: number) {
|
||||||
const store: Record<string, string> = {};
|
fireEvent(el, new MouseEvent(type, { bubbles: true, cancelable: true, clientX, button: 0 }));
|
||||||
return {
|
|
||||||
effectAllowed: "",
|
|
||||||
dropEffect: "",
|
|
||||||
setData: (format: string, value: string) => {
|
|
||||||
store[format] = value;
|
|
||||||
},
|
|
||||||
getData: (format: string) => store[format] ?? "",
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Press, move past the drag threshold, and release over `endX`. */
|
||||||
* A dragover carrying a real `clientX`.
|
function dragTab(el: Element, fromX: number, endX: number) {
|
||||||
*
|
pointer(el, "pointerdown", fromX);
|
||||||
* jsdom has no `DragEvent`, so Testing Library's synthesized one is a plain
|
pointer(el, "pointermove", endX);
|
||||||
* `Event` with no pointer coordinates — and the coordinate is the whole point
|
pointer(el, "pointerup", endX);
|
||||||
* here, since it decides which side of a tab the drop lands on. A `MouseEvent`
|
|
||||||
* has one, and React reads it the same way.
|
|
||||||
*/
|
|
||||||
function dragOverAt(el: Element, clientX: number, dt: ReturnType<typeof dataTransfer>) {
|
|
||||||
const event = new MouseEvent("dragover", { bubbles: true, cancelable: true, clientX });
|
|
||||||
Object.defineProperty(event, "dataTransfer", { value: dt });
|
|
||||||
fireEvent(el, event);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Pin a tab's geometry so "past the midpoint" means something in jsdom. */
|
/** Pin a tab's geometry so "past the midpoint" means something in jsdom. */
|
||||||
@@ -80,6 +69,13 @@ function place(el: Element, left: number, width = 100) {
|
|||||||
({ left, width, right: left + width, top: 0, bottom: 30, height: 30, x: left, y: 0 }) as DOMRect;
|
({ left, width, right: left + width, top: 0, bottom: 30, height: 30, x: left, y: 0 }) as DOMRect;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Lay the strip out as three 100px tabs starting at x=0. */
|
||||||
|
function laidOut() {
|
||||||
|
const tabs = screen.getAllByRole("tab");
|
||||||
|
tabs.forEach((tab, i) => place(tab, i * 100));
|
||||||
|
return tabs;
|
||||||
|
}
|
||||||
|
|
||||||
const order = () => useAppState.getState().tabOrder;
|
const order = () => useAppState.getState().tabOrder;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -95,73 +91,124 @@ beforeEach(() => {
|
|||||||
describe("MainTabs reordering", () => {
|
describe("MainTabs reordering", () => {
|
||||||
it("drags a tab to the front", () => {
|
it("drags a tab to the front", () => {
|
||||||
render(<MainTabs />);
|
render(<MainTabs />);
|
||||||
const tabs = screen.getAllByRole("tab");
|
const tabs = laidOut();
|
||||||
tabs.forEach((tab, i) => place(tab, i * 100));
|
|
||||||
|
|
||||||
const dt = dataTransfer();
|
// Left half of the first tab — the tab lands before it.
|
||||||
fireEvent.dragStart(tabs[2], { dataTransfer: dt });
|
dragTab(tabs[2], 250, 10);
|
||||||
// Left half of the first tab — the marker sits before it.
|
|
||||||
dragOverAt(tabs[0], 10, dt);
|
|
||||||
expect(screen.getByTestId("tab-drop-marker")).toBeInTheDocument();
|
|
||||||
fireEvent.drop(tabs[0], { dataTransfer: dt });
|
|
||||||
|
|
||||||
expect(order()).toEqual([S2, HOME, S1]);
|
expect(order()).toEqual([S2, HOME, S1]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("drops after the tab when the pointer is past its midpoint", () => {
|
it("drops after the tab when the pointer is past its midpoint", () => {
|
||||||
render(<MainTabs />);
|
render(<MainTabs />);
|
||||||
const tabs = screen.getAllByRole("tab");
|
const tabs = laidOut();
|
||||||
tabs.forEach((tab, i) => place(tab, i * 100));
|
|
||||||
|
|
||||||
const dt = dataTransfer();
|
dragTab(tabs[0], 50, 190);
|
||||||
fireEvent.dragStart(tabs[0], { dataTransfer: dt });
|
|
||||||
dragOverAt(tabs[1], 190, dt);
|
|
||||||
fireEvent.drop(tabs[1], { dataTransfer: dt });
|
|
||||||
|
|
||||||
expect(order()).toEqual([S1, HOME, S2]);
|
expect(order()).toEqual([S1, HOME, S2]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("drops at the end when released past the last tab", () => {
|
||||||
|
render(<MainTabs />);
|
||||||
|
const tabs = laidOut();
|
||||||
|
|
||||||
|
dragTab(tabs[0], 50, 800);
|
||||||
|
|
||||||
|
expect(order()).toEqual([S1, S2, HOME]);
|
||||||
|
});
|
||||||
|
|
||||||
it("dragging does not steal the selection", () => {
|
it("dragging does not steal the selection", () => {
|
||||||
render(<MainTabs />);
|
render(<MainTabs />);
|
||||||
const tabs = screen.getAllByRole("tab");
|
const tabs = laidOut();
|
||||||
tabs.forEach((tab, i) => place(tab, i * 100));
|
|
||||||
|
|
||||||
const dt = dataTransfer();
|
dragTab(tabs[1], 150, 290);
|
||||||
fireEvent.dragStart(tabs[1], { dataTransfer: dt });
|
|
||||||
dragOverAt(tabs[2], 290, dt);
|
|
||||||
fireEvent.drop(tabs[2], { dataTransfer: dt });
|
|
||||||
|
|
||||||
expect(order()).toEqual([HOME, S2, S1]);
|
expect(order()).toEqual([HOME, S2, S1]);
|
||||||
expect(useAppState.getState().activeTabKey).toBe(HOME);
|
expect(useAppState.getState().activeTabKey).toBe(HOME);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows no drop marker until a drag is under way", () => {
|
it("shows the drop marker only while a drag is under way", () => {
|
||||||
render(<MainTabs />);
|
render(<MainTabs />);
|
||||||
|
const tabs = laidOut();
|
||||||
|
expect(screen.queryByTestId("tab-drop-marker")).toBeNull();
|
||||||
|
|
||||||
|
pointer(tabs[2], "pointerdown", 250);
|
||||||
|
pointer(tabs[2], "pointermove", 10);
|
||||||
|
expect(screen.getByTestId("tab-drop-marker")).toBeInTheDocument();
|
||||||
|
|
||||||
|
pointer(tabs[2], "pointerup", 10);
|
||||||
expect(screen.queryByTestId("tab-drop-marker")).toBeNull();
|
expect(screen.queryByTestId("tab-drop-marker")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("clears the marker when the drag ends without a drop", () => {
|
it("abandons the drag on Escape, leaving the order alone", () => {
|
||||||
render(<MainTabs />);
|
render(<MainTabs />);
|
||||||
const tabs = screen.getAllByRole("tab");
|
const tabs = laidOut();
|
||||||
tabs.forEach((tab, i) => place(tab, i * 100));
|
|
||||||
|
|
||||||
const dt = dataTransfer();
|
pointer(tabs[2], "pointerdown", 250);
|
||||||
fireEvent.dragStart(tabs[2], { dataTransfer: dt });
|
pointer(tabs[2], "pointermove", 10);
|
||||||
dragOverAt(tabs[0], 10, dt);
|
fireEvent.keyDown(window, { key: "Escape" });
|
||||||
fireEvent.dragEnd(tabs[2], { dataTransfer: dt });
|
|
||||||
|
expect(screen.queryByTestId("tab-drop-marker")).toBeNull();
|
||||||
|
pointer(tabs[2], "pointerup", 10);
|
||||||
|
expect(order()).toEqual([HOME, S1, S2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a press that barely moves as a click, not a drag", () => {
|
||||||
|
render(<MainTabs />);
|
||||||
|
const tabs = laidOut();
|
||||||
|
|
||||||
|
// Two pixels of tremble, under the threshold.
|
||||||
|
pointer(tabs[2], "pointerdown", 250);
|
||||||
|
pointer(tabs[2], "pointermove", 252);
|
||||||
|
pointer(tabs[2], "pointerup", 252);
|
||||||
|
fireEvent.click(tabs[2]);
|
||||||
|
|
||||||
|
expect(order()).toEqual([HOME, S1, S2]);
|
||||||
|
expect(useAppState.getState().activeTabKey).toBe(S2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not select the tab it just dropped", () => {
|
||||||
|
render(<MainTabs />);
|
||||||
|
const tabs = laidOut();
|
||||||
|
|
||||||
|
dragTab(tabs[2], 250, 10);
|
||||||
|
// The browser fires a click after the pointerup that ended the drag.
|
||||||
|
fireEvent.click(tabs[2]);
|
||||||
|
|
||||||
|
expect(order()).toEqual([S2, HOME, S1]);
|
||||||
|
expect(useAppState.getState().activeTabKey).toBe(HOME);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores a press that starts on the close button", () => {
|
||||||
|
render(<MainTabs />);
|
||||||
|
const tabs = laidOut();
|
||||||
|
const close = screen.getByRole("button", { name: "Close shell (bash)" });
|
||||||
|
|
||||||
|
fireEvent(close, new MouseEvent("pointerdown", { bubbles: true, clientX: 290, button: 0 }));
|
||||||
|
pointer(tabs[2], "pointermove", 10);
|
||||||
|
|
||||||
expect(screen.queryByTestId("tab-drop-marker")).toBeNull();
|
expect(screen.queryByTestId("tab-drop-marker")).toBeNull();
|
||||||
expect(order()).toEqual([HOME, S1, S2]);
|
expect(order()).toEqual([HOME, S1, S2]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("leaves a tab being renamed undraggable, so its text stays selectable", () => {
|
it("does not drag a tab that is being renamed — that drag selects text", () => {
|
||||||
render(<MainTabs />);
|
render(<MainTabs />);
|
||||||
const tabs = screen.getAllByRole("tab");
|
const tabs = laidOut();
|
||||||
expect(tabs[1]).toHaveAttribute("draggable", "true");
|
|
||||||
|
|
||||||
fireEvent.doubleClick(tabs[1]);
|
fireEvent.doubleClick(tabs[1]);
|
||||||
|
|
||||||
expect(screen.getByLabelText("Rename tab")).toBeInTheDocument();
|
expect(screen.getByLabelText("Rename tab")).toBeInTheDocument();
|
||||||
expect(screen.getAllByRole("tab")[1]).toHaveAttribute("draggable", "false");
|
|
||||||
|
dragTab(screen.getAllByRole("tab")[1], 150, 10);
|
||||||
|
|
||||||
|
expect(order()).toEqual([HOME, S1, S2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("carries no drag payload that another element could receive", () => {
|
||||||
|
// An HTML5 drag would put the tab key in a DataTransfer, and releasing over
|
||||||
|
// any text field in the app would type `term:…` into it. Pointer events
|
||||||
|
// have nothing to hand over, and the tabs are not draggable at all.
|
||||||
|
render(<MainTabs />);
|
||||||
|
for (const tab of screen.getAllByRole("tab")) {
|
||||||
|
expect(tab).not.toHaveAttribute("draggable", "true");
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ interface ContextMenuState {
|
|||||||
y: number;
|
y: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Pixels of horizontal travel before a press becomes a drag rather than a click. */
|
||||||
|
const DRAG_THRESHOLD = 4;
|
||||||
|
|
||||||
const MODE_BADGE: Record<PermissionMode, { text: string; className: string }> = {
|
const MODE_BADGE: Record<PermissionMode, { text: string; className: string }> = {
|
||||||
plan: { text: "plan", className: "bg-[var(--bg-tertiary)] text-[var(--text-secondary)]" },
|
plan: { text: "plan", className: "bg-[var(--bg-tertiary)] text-[var(--text-secondary)]" },
|
||||||
default: { text: "ask", className: "bg-[var(--bg-tertiary)] text-[var(--text-secondary)]" },
|
default: { text: "ask", className: "bg-[var(--bg-tertiary)] text-[var(--text-secondary)]" },
|
||||||
@@ -29,9 +32,8 @@ const MODE_BADGE: Record<PermissionMode, { text: string; className: string }> =
|
|||||||
* One strip for both main-area tab kinds: Project Home views (⌂) and
|
* One strip for both main-area tab kinds: Project Home views (⌂) and
|
||||||
* terminals (▣).
|
* terminals (▣).
|
||||||
*
|
*
|
||||||
* Tabs are draggable. The drag is HTML5's, not a pointer-event
|
* Tabs are draggable, on pointer events rather than HTML5 drag-and-drop — see
|
||||||
* reimplementation, so the OS supplies the drag image and the Escape-to-cancel
|
* `pointerProps` for why neither of the two obvious alternatives works.
|
||||||
* behaviour for free; the only thing tracked here is where the drop would land.
|
|
||||||
* `Ctrl+Shift+←/→` does the same thing without a mouse.
|
* `Ctrl+Shift+←/→` does the same thing without a mouse.
|
||||||
*/
|
*/
|
||||||
export default function MainTabs() {
|
export default function MainTabs() {
|
||||||
@@ -53,6 +55,10 @@ export default function MainTabs() {
|
|||||||
/** The tab being dragged, and the slot it would drop into. */
|
/** The tab being dragged, and the slot it would drop into. */
|
||||||
const [dragKey, setDragKey] = useState<string | null>(null);
|
const [dragKey, setDragKey] = useState<string | null>(null);
|
||||||
const [dropIndex, setDropIndex] = useState<number | null>(null);
|
const [dropIndex, setDropIndex] = useState<number | null>(null);
|
||||||
|
const stripRef = useRef<HTMLDivElement>(null);
|
||||||
|
/** A press that has not yet moved far enough to be a drag. */
|
||||||
|
const pending = useRef<{ key: string; startX: number; dragging: boolean } | null>(null);
|
||||||
|
const suppressClick = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!menu) return;
|
if (!menu) return;
|
||||||
@@ -72,6 +78,20 @@ export default function MainTabs() {
|
|||||||
}
|
}
|
||||||
}, [renamingId]);
|
}, [renamingId]);
|
||||||
|
|
||||||
|
// Escape abandons a drag — the one affordance a pointer-event drag has to
|
||||||
|
// supply for itself, since the OS is not running this one.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!dragKey) return;
|
||||||
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key !== "Escape") return;
|
||||||
|
pending.current = null;
|
||||||
|
setDragKey(null);
|
||||||
|
setDropIndex(null);
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", onKeyDown);
|
||||||
|
return () => window.removeEventListener("keydown", onKeyDown);
|
||||||
|
}, [dragKey]);
|
||||||
|
|
||||||
if (tabOrder.length === 0) {
|
if (tabOrder.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="px-3 text-xs text-[var(--text-secondary)] leading-10">
|
<div className="px-3 text-xs text-[var(--text-secondary)] leading-10">
|
||||||
@@ -152,54 +172,91 @@ export default function MainTabs() {
|
|||||||
}${dragging ? " opacity-40" : ""}`;
|
}${dragging ? " opacity-40" : ""}`;
|
||||||
|
|
||||||
const endDrag = () => {
|
const endDrag = () => {
|
||||||
|
pending.current = null;
|
||||||
setDragKey(null);
|
setDragKey(null);
|
||||||
setDropIndex(null);
|
setDropIndex(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Drag props shared by both tab kinds.
|
* Which slot the pointer is currently over, as an insertion index into
|
||||||
|
* `tabOrder`.
|
||||||
*
|
*
|
||||||
* A tab in rename mode is not draggable: a `draggable` ancestor swallows the
|
* Measured from the tabs actually on screen rather than from the event's
|
||||||
* mouse-drag that selects text inside the input, which would make the rename
|
* target, so the answer is the same whatever the pointer happens to be over —
|
||||||
* field impossible to select in.
|
* including the drop marker itself, and including a `tabOrder` entry whose
|
||||||
|
* session has already gone and which therefore renders nothing.
|
||||||
*/
|
*/
|
||||||
const dragProps = (key: string, index: number, renaming: boolean) => ({
|
const dropIndexAt = (clientX: number): number => {
|
||||||
draggable: !renaming,
|
const strip = stripRef.current;
|
||||||
onDragStart: (e: React.DragEvent<HTMLDivElement>) => {
|
if (!strip) return tabOrder.length;
|
||||||
e.dataTransfer.effectAllowed = "move";
|
for (const el of strip.querySelectorAll<HTMLElement>("[data-tab-index]")) {
|
||||||
// Some platforms refuse to start a drag with an empty payload.
|
const rect = el.getBoundingClientRect();
|
||||||
e.dataTransfer.setData("text/plain", key);
|
if (clientX < rect.left + rect.width / 2) return Number(el.dataset.tabIndex);
|
||||||
setDragKey(key);
|
}
|
||||||
setDropIndex(index);
|
return tabOrder.length;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dragging is done with pointer events, not HTML5 drag-and-drop.
|
||||||
|
*
|
||||||
|
* Two reasons, both load-bearing. Tauri's `dragDropEnabled` — which the
|
||||||
|
* terminal needs left on, because only the native drag-drop event carries
|
||||||
|
* dropped *file paths* — blocks HTML5 drag inside the webview on Windows, so
|
||||||
|
* an HTML5 implementation is simply dead there. And an HTML5 drag carries a
|
||||||
|
* `DataTransfer`: released over any text field in the app, the default
|
||||||
|
* handler types the payload into it.
|
||||||
|
*/
|
||||||
|
const pointerProps = (key: string, renaming: boolean) => ({
|
||||||
|
onPointerDown: (e: React.PointerEvent<HTMLDivElement>) => {
|
||||||
|
// Left button only, never from the close button, and never while the
|
||||||
|
// rename input is up — that drag is a text selection.
|
||||||
|
if (e.button !== 0 || renaming) return;
|
||||||
|
if ((e.target as HTMLElement).closest("button, input")) return;
|
||||||
|
pending.current = { key, startX: e.clientX, dragging: false };
|
||||||
|
e.currentTarget.setPointerCapture?.(e.pointerId);
|
||||||
},
|
},
|
||||||
onDragOver: (e: React.DragEvent<HTMLDivElement>) => {
|
onPointerMove: (e: React.PointerEvent<HTMLDivElement>) => {
|
||||||
if (!dragKey) return; // not our drag — a file dropped on the strip isn't one
|
const drag = pending.current;
|
||||||
e.preventDefault();
|
if (!drag) return;
|
||||||
e.dataTransfer.dropEffect = "move";
|
// A few pixels of slop, so a click that trembles stays a click.
|
||||||
const rect = e.currentTarget.getBoundingClientRect();
|
if (!drag.dragging && Math.abs(e.clientX - drag.startX) < DRAG_THRESHOLD) return;
|
||||||
const after = e.clientX > rect.left + rect.width / 2;
|
drag.dragging = true;
|
||||||
setDropIndex(index + (after ? 1 : 0));
|
setDragKey(drag.key);
|
||||||
|
setDropIndex(dropIndexAt(e.clientX));
|
||||||
},
|
},
|
||||||
onDragEnd: endDrag,
|
onPointerUp: (e: React.PointerEvent<HTMLDivElement>) => {
|
||||||
|
const drag = pending.current;
|
||||||
|
e.currentTarget.releasePointerCapture?.(e.pointerId);
|
||||||
|
if (!drag?.dragging) {
|
||||||
|
pending.current = null;
|
||||||
|
return; // a plain click: leave it to `onClick` to select the tab
|
||||||
|
}
|
||||||
|
const to = dropIndexAt(e.clientX);
|
||||||
|
const from = tabOrder.indexOf(drag.key);
|
||||||
|
// `to` is a slot in the strip as it looks *now*; `moveTab` places the tab
|
||||||
|
// after pulling it out, so every slot past its own shifts down one.
|
||||||
|
if (from !== -1) moveTab(drag.key, to > from ? to - 1 : to);
|
||||||
|
// The click that follows this pointerup is the drag's, not a selection.
|
||||||
|
suppressClick.current = true;
|
||||||
|
endDrag();
|
||||||
|
},
|
||||||
|
onPointerCancel: endDrag,
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Drop lands wherever the marker is showing, and only there. */
|
/** A drag in progress swallows the click it ends with. */
|
||||||
const onDrop = (e: React.DragEvent<HTMLDivElement>) => {
|
const activateTab = (key: string) => {
|
||||||
const key = dragKey ?? e.dataTransfer.getData("text/plain");
|
if (suppressClick.current) {
|
||||||
if (!key || dropIndex === null) return endDrag();
|
suppressClick.current = false;
|
||||||
e.preventDefault();
|
return;
|
||||||
const from = tabOrder.indexOf(key);
|
}
|
||||||
// `dropIndex` is a slot in the strip as it looks *now*; `moveTab` places the
|
setActiveTabKey(key);
|
||||||
// tab after pulling it out, so every slot past the tab's own shifts down one.
|
|
||||||
moveTab(key, dropIndex > from ? dropIndex - 1 : dropIndex);
|
|
||||||
endDrag();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const dropMarker = (
|
const dropMarker = (
|
||||||
<div
|
<div
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
data-testid="tab-drop-marker"
|
data-testid="tab-drop-marker"
|
||||||
className="w-0.5 -mx-px h-full bg-[var(--accent)] flex-shrink-0"
|
className="w-0.5 -mx-px h-full bg-[var(--accent)] flex-shrink-0 pointer-events-none"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -215,14 +272,15 @@ export default function MainTabs() {
|
|||||||
role="tab"
|
role="tab"
|
||||||
aria-selected={active}
|
aria-selected={active}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onClick={() => setActiveTabKey(key)}
|
data-tab-index={index}
|
||||||
|
onClick={() => activateTab(key)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setActiveTabKey(key);
|
setActiveTabKey(key);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
{...dragProps(key, index, false)}
|
{...pointerProps(key, false)}
|
||||||
className={tabClass(active, dragKey === key)}
|
className={tabClass(active, dragKey === key)}
|
||||||
>
|
>
|
||||||
<span aria-hidden="true" className="text-[var(--text-secondary)]">⌂</span>
|
<span aria-hidden="true" className="text-[var(--text-secondary)]">⌂</span>
|
||||||
@@ -265,7 +323,8 @@ export default function MainTabs() {
|
|||||||
role="tab"
|
role="tab"
|
||||||
aria-selected={active}
|
aria-selected={active}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onClick={() => setActiveTabKey(terminalTabKey(session.id))}
|
data-tab-index={index}
|
||||||
|
onClick={() => activateTab(terminalTabKey(session.id))}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -277,7 +336,7 @@ export default function MainTabs() {
|
|||||||
setMenu({ sessionId: session.id, x: e.clientX, y: e.clientY });
|
setMenu({ sessionId: session.id, x: e.clientX, y: e.clientY });
|
||||||
}}
|
}}
|
||||||
onDoubleClick={() => startRename(session.id)}
|
onDoubleClick={() => startRename(session.id)}
|
||||||
{...dragProps(key, index, isRenaming)}
|
{...pointerProps(key, isRenaming)}
|
||||||
className={tabClass(active, dragKey === key)}
|
className={tabClass(active, dragKey === key)}
|
||||||
>
|
>
|
||||||
<span aria-hidden="true" className="text-[var(--text-secondary)]">▣</span>
|
<span aria-hidden="true" className="text-[var(--text-secondary)]">▣</span>
|
||||||
@@ -324,26 +383,22 @@ export default function MainTabs() {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// The marker goes before the first tab that is *actually on screen* at or
|
||||||
|
// past the drop slot. Addressing it by raw index would lose it whenever a
|
||||||
|
// `tabOrder` entry renders nothing — the window between a session ending and
|
||||||
|
// the store dropping its key — leaving the drag with no visible target.
|
||||||
|
let markerPending = dragKey !== null && dropIndex !== null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div ref={stripRef} className="flex items-center h-full" role="tablist" aria-label="Open tabs">
|
||||||
className="flex items-center h-full"
|
|
||||||
role="tablist"
|
|
||||||
aria-label="Open tabs"
|
|
||||||
onDrop={onDrop}
|
|
||||||
onDragLeave={(e) => {
|
|
||||||
// Leaving the strip entirely (not crossing between tabs) parks the
|
|
||||||
// marker, so a drag aborted outside doesn't leave one behind.
|
|
||||||
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) {
|
|
||||||
setDropIndex(null);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{tabOrder.map((key, index) => {
|
{tabOrder.map((key, index) => {
|
||||||
const tab = renderTab(key, index);
|
const tab = renderTab(key, index);
|
||||||
if (!tab) return null;
|
if (!tab) return null;
|
||||||
|
const marker = markerPending && index >= (dropIndex ?? 0);
|
||||||
|
if (marker) markerPending = false;
|
||||||
return (
|
return (
|
||||||
<Fragment key={key}>
|
<Fragment key={key}>
|
||||||
{dragKey !== null && dropIndex === index && dropMarker}
|
{marker && dropMarker}
|
||||||
{tab}
|
{tab}
|
||||||
</Fragment>
|
</Fragment>
|
||||||
);
|
);
|
||||||
@@ -351,17 +406,7 @@ export default function MainTabs() {
|
|||||||
|
|
||||||
{/* The empty run after the last tab is a drop target too — it is where
|
{/* The empty run after the last tab is a drop target too — it is where
|
||||||
the hand naturally goes to say "put it at the end". */}
|
the hand naturally goes to say "put it at the end". */}
|
||||||
<div
|
<div className="flex-1 self-stretch">{markerPending && dropMarker}</div>
|
||||||
className="flex-1 self-stretch"
|
|
||||||
onDragOver={(e) => {
|
|
||||||
if (!dragKey) return;
|
|
||||||
e.preventDefault();
|
|
||||||
e.dataTransfer.dropEffect = "move";
|
|
||||||
setDropIndex(tabOrder.length);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{dragKey !== null && dropIndex === tabOrder.length && dropMarker}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{menu && (() => {
|
{menu && (() => {
|
||||||
const session = sessions.find((s) => s.id === menu.sessionId);
|
const session = sessions.find((s) => s.id === menu.sessionId);
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ const installBrowserViewSupport = vi.fn<() => Promise<BrowserSetupOutcome>>();
|
|||||||
const installBrowserViewBrowser = vi.fn<(id: string, b: string) => Promise<BrowserSetupOutcome>>();
|
const installBrowserViewBrowser = vi.fn<(id: string, b: string) => Promise<BrowserSetupOutcome>>();
|
||||||
const openBrowserViewPopout = vi.fn<(id: string, onTop: boolean) => Promise<void>>();
|
const openBrowserViewPopout = vi.fn<(id: string, onTop: boolean) => Promise<void>>();
|
||||||
const closeBrowserViewPopout = vi.fn<(id: string) => Promise<void>>();
|
const closeBrowserViewPopout = vi.fn<(id: string) => Promise<void>>();
|
||||||
const isBrowserViewPopoutOpen = vi.fn<() => Promise<boolean>>();
|
const getBrowserViewPopoutState =
|
||||||
|
vi.fn<() => Promise<{ open: boolean; always_on_top: boolean }>>();
|
||||||
const setBrowserViewPopoutAlwaysOnTop = vi.fn<(id: string, onTop: boolean) => Promise<void>>();
|
const setBrowserViewPopoutAlwaysOnTop = vi.fn<(id: string, onTop: boolean) => Promise<void>>();
|
||||||
const pushToast = vi.fn();
|
const pushToast = vi.fn();
|
||||||
const setContainerProgress = vi.fn();
|
const setContainerProgress = vi.fn();
|
||||||
@@ -28,7 +29,7 @@ vi.mock("../../../lib/tauri-commands", () => ({
|
|||||||
installBrowserViewBrowser: (id: string, b: string) => installBrowserViewBrowser(id, b),
|
installBrowserViewBrowser: (id: string, b: string) => installBrowserViewBrowser(id, b),
|
||||||
openBrowserViewPopout: (id: string, onTop: boolean) => openBrowserViewPopout(id, onTop),
|
openBrowserViewPopout: (id: string, onTop: boolean) => openBrowserViewPopout(id, onTop),
|
||||||
closeBrowserViewPopout: (id: string) => closeBrowserViewPopout(id),
|
closeBrowserViewPopout: (id: string) => closeBrowserViewPopout(id),
|
||||||
isBrowserViewPopoutOpen: () => isBrowserViewPopoutOpen(),
|
getBrowserViewPopoutState: () => getBrowserViewPopoutState(),
|
||||||
setBrowserViewPopoutAlwaysOnTop: (id: string, onTop: boolean) =>
|
setBrowserViewPopoutAlwaysOnTop: (id: string, onTop: boolean) =>
|
||||||
setBrowserViewPopoutAlwaysOnTop(id, onTop),
|
setBrowserViewPopoutAlwaysOnTop(id, onTop),
|
||||||
}));
|
}));
|
||||||
@@ -120,7 +121,7 @@ beforeEach(() => {
|
|||||||
storeState.containerProgress = {};
|
storeState.containerProgress = {};
|
||||||
getBrowserViewStatus.mockResolvedValue(OFF);
|
getBrowserViewStatus.mockResolvedValue(OFF);
|
||||||
checkBrowserViewSupport.mockResolvedValue(READY);
|
checkBrowserViewSupport.mockResolvedValue(READY);
|
||||||
isBrowserViewPopoutOpen.mockResolvedValue(false);
|
getBrowserViewPopoutState.mockResolvedValue({ open: false, always_on_top: false });
|
||||||
openBrowserViewPopout.mockResolvedValue(undefined);
|
openBrowserViewPopout.mockResolvedValue(undefined);
|
||||||
closeBrowserViewPopout.mockResolvedValue(undefined);
|
closeBrowserViewPopout.mockResolvedValue(undefined);
|
||||||
setBrowserViewPopoutAlwaysOnTop.mockResolvedValue(undefined);
|
setBrowserViewPopoutAlwaysOnTop.mockResolvedValue(undefined);
|
||||||
@@ -406,16 +407,17 @@ describe("BrowserTab", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
fireEvent.click(screen.getByRole("switch", { name: /above other windows/i }));
|
// The accessible name is the visible text, as with every other Toggle.
|
||||||
|
fireEvent.click(screen.getByRole("switch", { name: "Keep on top" }));
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(setBrowserViewPopoutAlwaysOnTop).toHaveBeenCalledWith("p1", true);
|
expect(setBrowserViewPopoutAlwaysOnTop).toHaveBeenCalledWith("p1", true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps a pop-out that outlived the tab, rather than showing an empty pane", async () => {
|
it("keeps a pop-out that outlived the tab, rather than showing an empty pane", async () => {
|
||||||
// The window belongs to the backend, so reopening the tab has to read its
|
// The window belongs to the backend, so remounting the pane has to read its
|
||||||
// state back — otherwise the pane would render an iframe alongside it.
|
// state back — otherwise the pane would render an iframe alongside it.
|
||||||
isBrowserViewPopoutOpen.mockResolvedValue(true);
|
getBrowserViewPopoutState.mockResolvedValue({ open: true, always_on_top: true });
|
||||||
checkBrowserViewSupport.mockResolvedValue({ ...READY, browsers: ["chromium-1200"] });
|
checkBrowserViewSupport.mockResolvedValue({ ...READY, browsers: ["chromium-1200"] });
|
||||||
getBrowserViewStatus.mockResolvedValue(LIVE);
|
getBrowserViewStatus.mockResolvedValue(LIVE);
|
||||||
|
|
||||||
@@ -423,6 +425,33 @@ describe("BrowserTab", () => {
|
|||||||
|
|
||||||
expect(await screen.findByText(/in its own window/i)).toBeInTheDocument();
|
expect(await screen.findByText(/in its own window/i)).toBeInTheDocument();
|
||||||
expect(screen.queryByTitle(/browser view for/i)).toBeNull();
|
expect(screen.queryByTitle(/browser view for/i)).toBeNull();
|
||||||
|
// The pin is read from the window too — the pane is unmounted every time
|
||||||
|
// another sub-tab is selected, so remembering it would show Off over a
|
||||||
|
// window that is still floating on top.
|
||||||
|
expect(screen.getByRole("switch", { name: "Keep on top" })).toBeChecked();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never mounts the iframe before the window's state is known", async () => {
|
||||||
|
// The status and the pop-out state are two separate round trips. If the
|
||||||
|
// status wins the race, guessing "not popped out" would flash a second
|
||||||
|
// viewer onto a browser the window is already driving.
|
||||||
|
checkBrowserViewSupport.mockResolvedValue({ ...READY, browsers: ["chromium-1200"] });
|
||||||
|
getBrowserViewStatus.mockResolvedValue(LIVE);
|
||||||
|
let answer: (s: { open: boolean; always_on_top: boolean }) => void = () => {};
|
||||||
|
getBrowserViewPopoutState.mockReturnValue(
|
||||||
|
new Promise((resolve) => {
|
||||||
|
answer = resolve;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
render(<BrowserTab project={project} active />);
|
||||||
|
await waitFor(() => expect(screen.getByText("Live")).toBeInTheDocument());
|
||||||
|
expect(screen.queryByTitle(/browser view for/i)).toBeNull();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
answer({ open: false, always_on_top: false });
|
||||||
|
});
|
||||||
|
expect(await screen.findByTitle("Playwright browser view for api-server")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("says why the window wouldn’t open instead of pretending it did", async () => {
|
it("says why the window wouldn’t open instead of pretending it did", async () => {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
getBrowserViewStatus,
|
getBrowserViewStatus,
|
||||||
installBrowserViewBrowser,
|
installBrowserViewBrowser,
|
||||||
installBrowserViewSupport,
|
installBrowserViewSupport,
|
||||||
isBrowserViewPopoutOpen,
|
getBrowserViewPopoutState,
|
||||||
openBrowserViewPopout,
|
openBrowserViewPopout,
|
||||||
setBrowserViewEnabled,
|
setBrowserViewEnabled,
|
||||||
setBrowserViewPopoutAlwaysOnTop,
|
setBrowserViewPopoutAlwaysOnTop,
|
||||||
@@ -72,8 +72,13 @@ export default function BrowserTab({ project, active }: Props) {
|
|||||||
const [job, setJob] = useState<SetupJob>(null);
|
const [job, setJob] = useState<SetupJob>(null);
|
||||||
const [outcome, setOutcome] = useState<BrowserSetupOutcome | null>(null);
|
const [outcome, setOutcome] = useState<BrowserSetupOutcome | null>(null);
|
||||||
const [setupError, setSetupError] = useState<string | null>(null);
|
const [setupError, setSetupError] = useState<string | null>(null);
|
||||||
/** Whether the view is currently in its own window instead of this pane. */
|
/**
|
||||||
const [poppedOut, setPoppedOut] = useState(false);
|
* Whether the view is in its own window instead of this pane, and whether
|
||||||
|
* that window is pinned. `null` means "not asked yet" — a distinct state from
|
||||||
|
* "not popped out", because rendering the iframe on a guess is what puts a
|
||||||
|
* second viewer on the browser.
|
||||||
|
*/
|
||||||
|
const [poppedOut, setPoppedOut] = useState<boolean | null>(null);
|
||||||
const [onTop, setOnTop] = useState(false);
|
const [onTop, setOnTop] = useState(false);
|
||||||
const pushToast = useAppState((s) => s.pushToast);
|
const pushToast = useAppState((s) => s.pushToast);
|
||||||
const setContainerProgress = useAppState((s) => s.setContainerProgress);
|
const setContainerProgress = useAppState((s) => s.setContainerProgress);
|
||||||
@@ -112,7 +117,7 @@ export default function BrowserTab({ project, active }: Props) {
|
|||||||
listen<BrowserViewPopoutChangedEvent>("browser-view-popout-changed", (event) => {
|
listen<BrowserViewPopoutChangedEvent>("browser-view-popout-changed", (event) => {
|
||||||
if (event.payload.project_id === projectId && mounted.current) {
|
if (event.payload.project_id === projectId && mounted.current) {
|
||||||
setPoppedOut(event.payload.open);
|
setPoppedOut(event.payload.open);
|
||||||
if (!event.payload.open) setOnTop(false);
|
setOnTop(event.payload.always_on_top);
|
||||||
}
|
}
|
||||||
}).then((un) => {
|
}).then((un) => {
|
||||||
if (mounted.current) dispose = un;
|
if (mounted.current) dispose = un;
|
||||||
@@ -123,9 +128,15 @@ export default function BrowserTab({ project, active }: Props) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!active || !running) return;
|
if (!active || !running) return;
|
||||||
isBrowserViewPopoutOpen(projectId)
|
getBrowserViewPopoutState(projectId)
|
||||||
.then((open) => mounted.current && setPoppedOut(open))
|
.then((s) => {
|
||||||
.catch(() => {});
|
if (!mounted.current) return;
|
||||||
|
setPoppedOut(s.open);
|
||||||
|
setOnTop(s.always_on_top);
|
||||||
|
})
|
||||||
|
// Unreachable in practice, but a pane stuck at "not asked yet" would
|
||||||
|
// never show the view at all — so fail towards the tab.
|
||||||
|
.catch(() => mounted.current && setPoppedOut(false));
|
||||||
getBrowserViewStatus(projectId)
|
getBrowserViewStatus(projectId)
|
||||||
.then((s) => mounted.current && setStatus(s))
|
.then((s) => mounted.current && setStatus(s))
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
@@ -304,22 +315,21 @@ export default function BrowserTab({ project, active }: Props) {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
{live && poppedOut && (
|
{live && poppedOut === true && (
|
||||||
<label className="flex items-center gap-1.5 text-xs text-[var(--text-secondary)]">
|
<span className="flex items-center gap-1.5 text-xs text-[var(--text-secondary)]">
|
||||||
Keep on top
|
Keep on top
|
||||||
<Toggle
|
{/* The accessible name matches the visible text, as everywhere else
|
||||||
checked={onTop}
|
a Toggle is used — a `<label>` around it would be inert anyway,
|
||||||
onChange={toggleOnTop}
|
since a Toggle renders a button. */}
|
||||||
label="Keep the browser window above other windows"
|
<Toggle checked={onTop} onChange={toggleOnTop} label="Keep on top" />
|
||||||
/>
|
</span>
|
||||||
</label>
|
|
||||||
)}
|
)}
|
||||||
{live && !poppedOut && (
|
{live && poppedOut === false && (
|
||||||
<Button size="md" onClick={() => setReloadKey((k) => k + 1)}>
|
<Button size="md" onClick={() => setReloadKey((k) => k + 1)}>
|
||||||
Reload
|
Reload
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{live && (
|
{live && poppedOut !== null && (
|
||||||
<Button size="md" onClick={poppedOut ? popIn : popOut}>
|
<Button size="md" onClick={poppedOut ? popIn : popOut}>
|
||||||
{poppedOut ? "Put back in tab" : "Open in own window"}
|
{poppedOut ? "Put back in tab" : "Open in own window"}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -334,7 +344,7 @@ export default function BrowserTab({ project, active }: Props) {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{live && poppedOut ? (
|
{live && poppedOut === true ? (
|
||||||
// The iframe is unmounted while the window is up, on purpose. Two
|
// The iframe is unmounted while the window is up, on purpose. Two
|
||||||
// viewers on one browser both work, but both also *drive* it — two
|
// viewers on one browser both work, but both also *drive* it — two
|
||||||
// cursors taking over the same page is not a feature.
|
// cursors taking over the same page is not a feature.
|
||||||
@@ -355,7 +365,7 @@ export default function BrowserTab({ project, active }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : live ? (
|
) : live && poppedOut === false ? (
|
||||||
<iframe
|
<iframe
|
||||||
key={reloadKey}
|
key={reloadKey}
|
||||||
// Loopback only, and the URL carries the one-time session token the
|
// Loopback only, and the URL carries the one-time session token the
|
||||||
@@ -364,6 +374,11 @@ export default function BrowserTab({ project, active }: Props) {
|
|||||||
title={`Playwright browser view for ${project.name}`}
|
title={`Playwright browser view for ${project.name}`}
|
||||||
className="flex-1 min-h-0 w-full border-0 bg-[var(--bg-primary)]"
|
className="flex-1 min-h-0 w-full border-0 bg-[var(--bg-primary)]"
|
||||||
/>
|
/>
|
||||||
|
) : live ? (
|
||||||
|
// Live, but the window's state hasn't come back yet. An instant, and
|
||||||
|
// deliberately empty: guessing "not popped out" here is what would
|
||||||
|
// flash a second viewer onto the browser.
|
||||||
|
<div className="flex-1 min-h-0" />
|
||||||
) : (
|
) : (
|
||||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||||
{/* Setup stays on screen while an install is running and after it
|
{/* Setup stays on screen while an install is running and after it
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { renderHook } from "@testing-library/react";
|
||||||
|
import { useKeyboardShortcuts } from "./useKeyboardShortcuts";
|
||||||
|
import { useAppState, homeTabKey, terminalTabKey } from "../store/appState";
|
||||||
|
|
||||||
|
vi.mock("./useTerminal", () => ({
|
||||||
|
useTerminal: () => ({ open: vi.fn(), close: vi.fn() }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const HOME = homeTabKey("p1");
|
||||||
|
const S1 = terminalTabKey("s1");
|
||||||
|
const S2 = terminalTabKey("s2");
|
||||||
|
|
||||||
|
const order = () => useAppState.getState().tabOrder;
|
||||||
|
|
||||||
|
/** Press a chord, from whatever is focused. */
|
||||||
|
function press(key: string, { shift = false } = {}) {
|
||||||
|
document.dispatchEvent(
|
||||||
|
new KeyboardEvent("keydown", { key, ctrlKey: true, shiftKey: shift, bubbles: true }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Focus a real element of the given kind, inside `parent` if given. */
|
||||||
|
function focus(tag: "input" | "textarea", parentClass?: string): HTMLElement {
|
||||||
|
const el = document.createElement(tag);
|
||||||
|
if (parentClass) {
|
||||||
|
const parent = document.createElement("div");
|
||||||
|
parent.className = parentClass;
|
||||||
|
parent.appendChild(el);
|
||||||
|
document.body.appendChild(parent);
|
||||||
|
} else {
|
||||||
|
document.body.appendChild(el);
|
||||||
|
}
|
||||||
|
el.focus();
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
useAppState.setState({ tabOrder: [HOME, S1, S2], activeTabKey: S1, activeSessionId: "s1" });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.innerHTML = "";
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Ctrl+Shift+←/→", () => {
|
||||||
|
it("moves the active tab along the strip", () => {
|
||||||
|
renderHook(() => useKeyboardShortcuts());
|
||||||
|
|
||||||
|
press("ArrowLeft", { shift: true });
|
||||||
|
expect(order()).toEqual([S1, HOME, S2]);
|
||||||
|
|
||||||
|
press("ArrowRight", { shift: true });
|
||||||
|
expect(order()).toEqual([HOME, S1, S2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves word-wise selection alone in a text field", () => {
|
||||||
|
// Ctrl+Shift+←/→ already means "extend the selection by a word" in every
|
||||||
|
// input in the app — the rename field, Config fields, Settings fields.
|
||||||
|
// Taking it there would break selection *and* silently reorder tabs.
|
||||||
|
renderHook(() => useKeyboardShortcuts());
|
||||||
|
focus("input");
|
||||||
|
|
||||||
|
press("ArrowLeft", { shift: true });
|
||||||
|
|
||||||
|
expect(order()).toEqual([HOME, S1, S2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still moves tabs from the terminal, whose focus lives in a textarea", () => {
|
||||||
|
// xterm keeps a hidden textarea focused as its input-method shim. It is
|
||||||
|
// not a field anyone edits word-wise, and the terminal is where these
|
||||||
|
// shortcuts matter most, so it is not treated as one.
|
||||||
|
renderHook(() => useKeyboardShortcuts());
|
||||||
|
focus("textarea", "xterm xterm-helper-textarea-host");
|
||||||
|
|
||||||
|
press("ArrowLeft", { shift: true });
|
||||||
|
|
||||||
|
expect(order()).toEqual([S1, HOME, S2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does nothing without Shift — that chord is readline's word motion", () => {
|
||||||
|
renderHook(() => useKeyboardShortcuts());
|
||||||
|
|
||||||
|
press("ArrowLeft");
|
||||||
|
|
||||||
|
expect(order()).toEqual([HOME, S1, S2]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,6 +2,22 @@ import { useEffect } from "react";
|
|||||||
import { useAppState, isTerminalTab, tabKeyId } from "../store/appState";
|
import { useAppState, isTerminalTab, tabKeyId } from "../store/appState";
|
||||||
import { useTerminal } from "./useTerminal";
|
import { useTerminal } from "./useTerminal";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the focus is in something the user is editing text in.
|
||||||
|
*
|
||||||
|
* xterm's hidden textarea is deliberately excluded: it is an input-method
|
||||||
|
* shim, not a field anyone edits word-wise, and the terminal is exactly where
|
||||||
|
* the tab shortcuts need to keep working.
|
||||||
|
*/
|
||||||
|
function inTextField(el: Element | null): boolean {
|
||||||
|
if (!el || el.closest(".xterm")) return false;
|
||||||
|
return (
|
||||||
|
el.tagName === "INPUT" ||
|
||||||
|
el.tagName === "TEXTAREA" ||
|
||||||
|
(el as HTMLElement).isContentEditable === true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* App-level shortcuts. Registered on `document` in the *capture* phase so they
|
* App-level shortcuts. Registered on `document` in the *capture* phase so they
|
||||||
* win over xterm.js, which would otherwise forward them to the shell inside
|
* win over xterm.js, which would otherwise forward them to the shell inside
|
||||||
@@ -55,8 +71,13 @@ export function useKeyboardShortcuts() {
|
|||||||
// Ctrl+Shift+←/→ — move the active tab, the keyboard route to what
|
// Ctrl+Shift+←/→ — move the active tab, the keyboard route to what
|
||||||
// dragging a tab does. Shift is what keeps it clear of the terminal:
|
// dragging a tab does. Shift is what keeps it clear of the terminal:
|
||||||
// Ctrl+←/→ is readline's word-wise cursor motion.
|
// Ctrl+←/→ is readline's word-wise cursor motion.
|
||||||
|
//
|
||||||
|
// In a text field this chord already means "extend the selection by a
|
||||||
|
// word", which is not ours to take: swallowing it would make word-wise
|
||||||
|
// selection impossible in every input in the app *and* silently reorder
|
||||||
|
// the strip each time someone tried it.
|
||||||
if (e.shiftKey && (e.key === "ArrowLeft" || e.key === "ArrowRight")) {
|
if (e.shiftKey && (e.key === "ArrowLeft" || e.key === "ArrowRight")) {
|
||||||
if (!state.activeTabKey) return;
|
if (!state.activeTabKey || inTextField(document.activeElement)) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
state.moveActiveTab(e.key === "ArrowLeft" ? -1 : 1);
|
state.moveActiveTab(e.key === "ArrowLeft" ? -1 : 1);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import type { Project, ProjectPath, ContainerInfo, SiblingContainer, AppSettings, UpdateInfo, ImageUpdateInfo, FileEntry, WebTerminalInfo, SttStatus, GatewayStatus, InstallOptions, ClaudeSession, ContainerCapabilities, ScheduledTask, ScheduledTaskInput, SchedulerNotification, AuthBridgeStatus, BrowserViewStatus, PlaywrightDetection, BrowserSetupOutcome, BrowserInstallTarget, ContainerStaleness, MigrationOptions, MigrationReport, MigrationState, ClearTokenOutcome, CaCertInfo } from "./types";
|
import type { Project, ProjectPath, ContainerInfo, SiblingContainer, AppSettings, UpdateInfo, ImageUpdateInfo, FileEntry, WebTerminalInfo, SttStatus, GatewayStatus, InstallOptions, ClaudeSession, ContainerCapabilities, ScheduledTask, ScheduledTaskInput, SchedulerNotification, AuthBridgeStatus, BrowserViewStatus, BrowserViewPopoutState, PlaywrightDetection, BrowserSetupOutcome, BrowserInstallTarget, ContainerStaleness, MigrationOptions, MigrationReport, MigrationState, ClearTokenOutcome, CaCertInfo } from "./types";
|
||||||
|
|
||||||
// Docker
|
// Docker
|
||||||
export const checkDocker = () => invoke<boolean>("check_docker");
|
export const checkDocker = () => invoke<boolean>("check_docker");
|
||||||
@@ -213,9 +213,15 @@ export const openBrowserViewPopout = (projectId: string, alwaysOnTop: boolean) =
|
|||||||
/** Close the pop-out and put the view back in the tab. No-op if it isn't open. */
|
/** Close the pop-out and put the view back in the tab. No-op if it isn't open. */
|
||||||
export const closeBrowserViewPopout = (projectId: string) =>
|
export const closeBrowserViewPopout = (projectId: string) =>
|
||||||
invoke<void>("close_browser_view_popout", { projectId });
|
invoke<void>("close_browser_view_popout", { projectId });
|
||||||
/** Asked on tab open: the window outlives the pane, so its state has to be read back. */
|
/**
|
||||||
export const isBrowserViewPopoutOpen = (projectId: string) =>
|
* Whether the pop-out is open and whether it is pinned, read from the window.
|
||||||
invoke<boolean>("is_browser_view_popout_open", { projectId });
|
*
|
||||||
|
* Asked on every mount: the pane is unmounted whenever another Project Home
|
||||||
|
* sub-tab is selected, while the window carries on — so neither fact can live
|
||||||
|
* in component state and survive.
|
||||||
|
*/
|
||||||
|
export const getBrowserViewPopoutState = (projectId: string) =>
|
||||||
|
invoke<BrowserViewPopoutState>("get_browser_view_popout_state", { projectId });
|
||||||
/** Pin the pop-out above other windows — the point of popping it out at all. */
|
/** Pin the pop-out above other windows — the point of popping it out at all. */
|
||||||
export const setBrowserViewPopoutAlwaysOnTop = (projectId: string, onTop: boolean) =>
|
export const setBrowserViewPopoutAlwaysOnTop = (projectId: string, onTop: boolean) =>
|
||||||
invoke<void>("set_browser_view_popout_always_on_top", { projectId, onTop });
|
invoke<void>("set_browser_view_popout_always_on_top", { projectId, onTop });
|
||||||
|
|||||||
+15
-3
@@ -515,15 +515,27 @@ export interface BrowserViewChangedEvent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Payload of the `browser-view-popout-changed` event.
|
* Mirrors Rust `PopoutState` — read from the window, never remembered.
|
||||||
|
*
|
||||||
|
* The pane is unmounted whenever another Project Home sub-tab is selected while
|
||||||
|
* the window carries on, so anything it holds in component state is stale by
|
||||||
|
* the time the user comes back.
|
||||||
|
*/
|
||||||
|
export interface BrowserViewPopoutState {
|
||||||
|
open: boolean;
|
||||||
|
always_on_top: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Payload of the `browser-view-popout-changed` event: a `BrowserViewPopoutState`
|
||||||
|
* plus the project it belongs to.
|
||||||
*
|
*
|
||||||
* The pop-out window can close without the pane asking — the user hits its X,
|
* The pop-out window can close without the pane asking — the user hits its X,
|
||||||
* or the session tears down and takes it — so this is the only reliable way to
|
* or the session tears down and takes it — so this is the only reliable way to
|
||||||
* know whether it is on screen.
|
* know whether it is on screen.
|
||||||
*/
|
*/
|
||||||
export interface BrowserViewPopoutChangedEvent {
|
export interface BrowserViewPopoutChangedEvent extends BrowserViewPopoutState {
|
||||||
project_id: string;
|
project_id: string;
|
||||||
open: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Payload of the `claude-token-progress` event: milestones during
|
/** Payload of the `claude-token-progress` event: milestones during
|
||||||
|
|||||||
Reference in New Issue
Block a user