Reorder tabs by dragging, and pop the browser view into its own window #19

Merged
jknapp merged 11 commits from feature/tab-reorder-browser-popout into main 2026-08-11 18:20:15 +00:00
14 changed files with 522 additions and 185 deletions
Showing only changes of commit a41d93ea46 - Show all commits
+8
View File
@@ -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
View File
@@ -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.
+13 -7
View File
@@ -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
+20 -4
View File
@@ -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));
+58 -15
View File
@@ -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,
}),
); );
} }
+1 -1
View File
@@ -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,
+105 -58
View File
@@ -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");
}
}); });
}); });
+108 -63
View File
@@ -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 wouldnt open instead of pretending it did", async () => { it("says why the window wouldnt open instead of pretending it did", async () => {
+34 -19
View File
@@ -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]);
});
});
+22 -1
View File
@@ -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);
+10 -4
View File
@@ -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
View File
@@ -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