Fix the review's findings: drag on pointer events, read the window back
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
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>
This commit is contained in:
@@ -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) {
|
||||||
map.remove(&project_id);
|
Some(session) if session.epoch == epoch => {
|
||||||
|
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)
|
||||||
|
})?;
|
||||||
}
|
}
|
||||||
// Unconditional: `Destroyed` covers the normal path, but a window that was
|
// `Destroyed` covers the normal path; a window that was already gone still
|
||||||
// already gone still owes the pane an answer.
|
// owes the pane an answer.
|
||||||
emit(app, project_id, false);
|
emit(app, project_id, PopoutState::CLOSED);
|
||||||
|
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