Reorder tabs by dragging, and pop the browser view into its own window #19
@@ -62,6 +62,14 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li
|
||||
`tabOrder` is user-reorderable (drag, or `Ctrl+Shift+←/→` via `moveActiveTab`) — so **never
|
||||
treat a tab's position as identity**: address tabs by key, and index only through `tabOrder`.
|
||||
`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`)
|
||||
- **`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
|
||||
|
||||
+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
|
||||
a terminal.
|
||||
|
||||
**Drag a tab to reorder it.** A line shows where it will land, and dropping it does not change
|
||||
which tab you are looking at — so you can rearrange the strip without pulling focus away from a
|
||||
terminal that is mid-run. `Ctrl+Shift+←` and `Ctrl+Shift+→` move the *active* tab the same way
|
||||
without the mouse. The order is per-session: it is not saved when you quit.
|
||||
**Drag a tab to reorder it.** A line shows where it will land; **Escape** abandons the drag.
|
||||
Dropping does not change which tab you are looking at — so you can rearrange the strip without
|
||||
pulling focus away from a terminal that is mid-run. `Ctrl+Shift+←` and `Ctrl+Shift+→` move the
|
||||
*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
|
||||
a coloured dot with a word, so status is never conveyed by colour alone. The **?** button opens
|
||||
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.
|
||||
///
|
||||
/// 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]
|
||||
pub async fn close_browser_view_popout(
|
||||
project_id: String,
|
||||
app_handle: AppHandle,
|
||||
) -> Result<(), String> {
|
||||
popout::close(&app_handle, &project_id);
|
||||
Ok(())
|
||||
popout::close(&app_handle, &project_id)
|
||||
}
|
||||
|
||||
/// Whether the pop-out is open — asked on tab open, since a window can outlive
|
||||
/// the pane that spawned it.
|
||||
/// Whether the pop-out is open, and whether it is pinned on top.
|
||||
///
|
||||
/// 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]
|
||||
pub async fn is_browser_view_popout_open(
|
||||
pub async fn get_browser_view_popout_state(
|
||||
project_id: String,
|
||||
app_handle: AppHandle,
|
||||
) -> Result<bool, String> {
|
||||
Ok(popout::is_open(&app_handle, &project_id))
|
||||
) -> Result<popout::PopoutState, String> {
|
||||
Ok(popout::state(&app_handle, &project_id))
|
||||
}
|
||||
|
||||
/// 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;
|
||||
|
||||
// Deregister, unless a newer session has already taken this project's slot.
|
||||
{
|
||||
let superseded = {
|
||||
let mut map = sessions.lock().await;
|
||||
if map.get(&project_id).is_some_and(|s| s.epoch == epoch) {
|
||||
map.remove(&project_id);
|
||||
match map.get(&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
|
||||
// window would sit there showing a frozen last frame of a viewer that no
|
||||
// longer exists. The session owns it, and this is where the session ends.
|
||||
popout::close(&app, &project_id);
|
||||
let _ = popout::close(&app, &project_id);
|
||||
|
||||
let enabled = manager().is_enabled(&project_id).await;
|
||||
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
|
||||
//! window leaves the view running, and the pane takes it back into the tab.
|
||||
|
||||
use serde::Serialize;
|
||||
use tauri::{AppHandle, Emitter, Manager, WebviewUrl, WebviewWindowBuilder, WindowEvent};
|
||||
|
||||
/// Emitted when a pop-out opens or closes. Payload: `{ 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
|
||||
/// a teardown takes it — so the pane learns about it the same way it learns
|
||||
/// about everything else here, by listening.
|
||||
const POPOUT_EVENT: &str = "browser-view-popout-changed";
|
||||
|
||||
/// What the pane needs to render its pop-out controls.
|
||||
///
|
||||
/// Both fields are read from the window itself rather than remembered on either
|
||||
/// side: the pane is unmounted whenever another Project Home sub-tab is
|
||||
/// selected, so anything it merely *remembers* about the window is gone by the
|
||||
/// time the user comes back, while the window is still there.
|
||||
#[derive(Debug, Clone, Copy, Serialize)]
|
||||
pub struct PopoutState {
|
||||
pub open: bool,
|
||||
pub always_on_top: bool,
|
||||
}
|
||||
|
||||
impl PopoutState {
|
||||
const CLOSED: Self = Self {
|
||||
open: false,
|
||||
always_on_top: false,
|
||||
};
|
||||
}
|
||||
|
||||
/// Tauri window labels admit `[a-zA-Z0-9-/:_]` only. Project ids are UUIDs, so
|
||||
/// this never fires in practice; it exists so a hand-edited `projects.json`
|
||||
/// cannot produce a label Tauri rejects at build time.
|
||||
@@ -65,7 +86,7 @@ pub fn open(
|
||||
let _ = window.unminimize();
|
||||
let _ = window.set_focus();
|
||||
let _ = window.set_always_on_top(always_on_top);
|
||||
emit(app, project_id, true);
|
||||
emit(app, project_id, state(app, project_id));
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -88,12 +109,12 @@ pub fn open(
|
||||
// to take the view back into the tab.
|
||||
window.on_window_event(move |event| {
|
||||
if matches!(event, WindowEvent::Destroyed) {
|
||||
emit(&app_for_event, &project_id_owned, false);
|
||||
emit(&app_for_event, &project_id_owned, PopoutState::CLOSED);
|
||||
}
|
||||
});
|
||||
|
||||
log::info!("Browser view: popped out for project {}", project_id);
|
||||
emit(app, project_id, true);
|
||||
emit(app, project_id, state(app, project_id));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -102,23 +123,39 @@ pub fn open(
|
||||
/// `destroy`, not `close`: `close` raises `CloseRequested`, and the app's
|
||||
/// window-event handler treats that as a request to quit for the main window.
|
||||
/// Nothing here should ever be able to be mistaken for that.
|
||||
pub fn close(app: &AppHandle, project_id: &str) {
|
||||
///
|
||||
/// 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 Err(e) = window.destroy() {
|
||||
window.destroy().map_err(|e| {
|
||||
log::warn!(
|
||||
"Browser view: could not close the pop-out for project {}: {}",
|
||||
project_id,
|
||||
e
|
||||
);
|
||||
}
|
||||
format!("Could not close the browser window: {}", e)
|
||||
})?;
|
||||
}
|
||||
// Unconditional: `Destroyed` covers the normal path, but a window that was
|
||||
// already gone still owes the pane an answer.
|
||||
emit(app, project_id, false);
|
||||
// `Destroyed` covers the normal path; a window that was already gone still
|
||||
// owes the pane an answer.
|
||||
emit(app, project_id, PopoutState::CLOSED);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn is_open(app: &AppHandle, project_id: &str) -> bool {
|
||||
app.get_webview_window(&window_label(project_id)).is_some()
|
||||
/// Whether the window exists and how it is stacked, read from the window.
|
||||
pub fn state(app: &AppHandle, project_id: &str) -> PopoutState {
|
||||
match app.get_webview_window(&window_label(project_id)) {
|
||||
Some(window) => PopoutState {
|
||||
open: true,
|
||||
// A window that cannot answer is not a reason to fail the call; the
|
||||
// pin is a preference, and "not pinned" is the safe reading.
|
||||
always_on_top: window.is_always_on_top().unwrap_or(false),
|
||||
},
|
||||
None => PopoutState::CLOSED,
|
||||
}
|
||||
}
|
||||
|
||||
/// Pin the pop-out above other windows, or unpin it. No-op when it is closed.
|
||||
@@ -128,13 +165,19 @@ pub fn set_always_on_top(app: &AppHandle, project_id: &str, on_top: bool) -> Res
|
||||
};
|
||||
window
|
||||
.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(
|
||||
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::open_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,
|
||||
// Shared Claude Code auth token
|
||||
commands::auth_token_commands::acquire_claude_token,
|
||||
|
||||
@@ -45,33 +45,22 @@ const S1 = terminalTabKey("s1");
|
||||
const S2 = terminalTabKey("s2");
|
||||
|
||||
/**
|
||||
* A stand-in for the DataTransfer jsdom doesn't implement. It only has to
|
||||
* carry the tab key, which is what a drop falls back to reading.
|
||||
* A pointer event carrying a real `clientX`.
|
||||
*
|
||||
* 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() {
|
||||
const store: Record<string, string> = {};
|
||||
return {
|
||||
effectAllowed: "",
|
||||
dropEffect: "",
|
||||
setData: (format: string, value: string) => {
|
||||
store[format] = value;
|
||||
},
|
||||
getData: (format: string) => store[format] ?? "",
|
||||
};
|
||||
function pointer(el: Element, type: string, clientX: number) {
|
||||
fireEvent(el, new MouseEvent(type, { bubbles: true, cancelable: true, clientX, button: 0 }));
|
||||
}
|
||||
|
||||
/**
|
||||
* A dragover carrying a real `clientX`.
|
||||
*
|
||||
* jsdom has no `DragEvent`, so Testing Library's synthesized one is a plain
|
||||
* `Event` with no pointer coordinates — and the coordinate is the whole point
|
||||
* 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);
|
||||
/** Press, move past the drag threshold, and release over `endX`. */
|
||||
function dragTab(el: Element, fromX: number, endX: number) {
|
||||
pointer(el, "pointerdown", fromX);
|
||||
pointer(el, "pointermove", endX);
|
||||
pointer(el, "pointerup", endX);
|
||||
}
|
||||
|
||||
/** 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;
|
||||
}
|
||||
|
||||
/** 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;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -95,73 +91,124 @@ beforeEach(() => {
|
||||
describe("MainTabs reordering", () => {
|
||||
it("drags a tab to the front", () => {
|
||||
render(<MainTabs />);
|
||||
const tabs = screen.getAllByRole("tab");
|
||||
tabs.forEach((tab, i) => place(tab, i * 100));
|
||||
const tabs = laidOut();
|
||||
|
||||
const dt = dataTransfer();
|
||||
fireEvent.dragStart(tabs[2], { dataTransfer: dt });
|
||||
// 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 });
|
||||
// Left half of the first tab — the tab lands before it.
|
||||
dragTab(tabs[2], 250, 10);
|
||||
|
||||
expect(order()).toEqual([S2, HOME, S1]);
|
||||
});
|
||||
|
||||
it("drops after the tab when the pointer is past its midpoint", () => {
|
||||
render(<MainTabs />);
|
||||
const tabs = screen.getAllByRole("tab");
|
||||
tabs.forEach((tab, i) => place(tab, i * 100));
|
||||
const tabs = laidOut();
|
||||
|
||||
const dt = dataTransfer();
|
||||
fireEvent.dragStart(tabs[0], { dataTransfer: dt });
|
||||
dragOverAt(tabs[1], 190, dt);
|
||||
fireEvent.drop(tabs[1], { dataTransfer: dt });
|
||||
dragTab(tabs[0], 50, 190);
|
||||
|
||||
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", () => {
|
||||
render(<MainTabs />);
|
||||
const tabs = screen.getAllByRole("tab");
|
||||
tabs.forEach((tab, i) => place(tab, i * 100));
|
||||
const tabs = laidOut();
|
||||
|
||||
const dt = dataTransfer();
|
||||
fireEvent.dragStart(tabs[1], { dataTransfer: dt });
|
||||
dragOverAt(tabs[2], 290, dt);
|
||||
fireEvent.drop(tabs[2], { dataTransfer: dt });
|
||||
dragTab(tabs[1], 150, 290);
|
||||
|
||||
expect(order()).toEqual([HOME, S2, S1]);
|
||||
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 />);
|
||||
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();
|
||||
});
|
||||
|
||||
it("clears the marker when the drag ends without a drop", () => {
|
||||
it("abandons the drag on Escape, leaving the order alone", () => {
|
||||
render(<MainTabs />);
|
||||
const tabs = screen.getAllByRole("tab");
|
||||
tabs.forEach((tab, i) => place(tab, i * 100));
|
||||
const tabs = laidOut();
|
||||
|
||||
const dt = dataTransfer();
|
||||
fireEvent.dragStart(tabs[2], { dataTransfer: dt });
|
||||
dragOverAt(tabs[0], 10, dt);
|
||||
fireEvent.dragEnd(tabs[2], { dataTransfer: dt });
|
||||
pointer(tabs[2], "pointerdown", 250);
|
||||
pointer(tabs[2], "pointermove", 10);
|
||||
fireEvent.keyDown(window, { key: "Escape" });
|
||||
|
||||
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(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 />);
|
||||
const tabs = screen.getAllByRole("tab");
|
||||
expect(tabs[1]).toHaveAttribute("draggable", "true");
|
||||
|
||||
const tabs = laidOut();
|
||||
fireEvent.doubleClick(tabs[1]);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/** 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 }> = {
|
||||
plan: { text: "plan", 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
|
||||
* terminals (▣).
|
||||
*
|
||||
* Tabs are draggable. The drag is HTML5's, not a pointer-event
|
||||
* reimplementation, so the OS supplies the drag image and the Escape-to-cancel
|
||||
* behaviour for free; the only thing tracked here is where the drop would land.
|
||||
* Tabs are draggable, on pointer events rather than HTML5 drag-and-drop — see
|
||||
* `pointerProps` for why neither of the two obvious alternatives works.
|
||||
* `Ctrl+Shift+←/→` does the same thing without a mouse.
|
||||
*/
|
||||
export default function MainTabs() {
|
||||
@@ -53,6 +55,10 @@ export default function MainTabs() {
|
||||
/** The tab being dragged, and the slot it would drop into. */
|
||||
const [dragKey, setDragKey] = useState<string | 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(() => {
|
||||
if (!menu) return;
|
||||
@@ -72,6 +78,20 @@ export default function MainTabs() {
|
||||
}
|
||||
}, [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) {
|
||||
return (
|
||||
<div className="px-3 text-xs text-[var(--text-secondary)] leading-10">
|
||||
@@ -152,54 +172,91 @@ export default function MainTabs() {
|
||||
}${dragging ? " opacity-40" : ""}`;
|
||||
|
||||
const endDrag = () => {
|
||||
pending.current = null;
|
||||
setDragKey(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
|
||||
* mouse-drag that selects text inside the input, which would make the rename
|
||||
* field impossible to select in.
|
||||
* Measured from the tabs actually on screen rather than from the event's
|
||||
* target, so the answer is the same whatever the pointer happens to be over —
|
||||
* 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) => ({
|
||||
draggable: !renaming,
|
||||
onDragStart: (e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
// Some platforms refuse to start a drag with an empty payload.
|
||||
e.dataTransfer.setData("text/plain", key);
|
||||
setDragKey(key);
|
||||
setDropIndex(index);
|
||||
const dropIndexAt = (clientX: number): number => {
|
||||
const strip = stripRef.current;
|
||||
if (!strip) return tabOrder.length;
|
||||
for (const el of strip.querySelectorAll<HTMLElement>("[data-tab-index]")) {
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (clientX < rect.left + rect.width / 2) return Number(el.dataset.tabIndex);
|
||||
}
|
||||
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>) => {
|
||||
if (!dragKey) return; // not our drag — a file dropped on the strip isn't one
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const after = e.clientX > rect.left + rect.width / 2;
|
||||
setDropIndex(index + (after ? 1 : 0));
|
||||
onPointerMove: (e: React.PointerEvent<HTMLDivElement>) => {
|
||||
const drag = pending.current;
|
||||
if (!drag) return;
|
||||
// A few pixels of slop, so a click that trembles stays a click.
|
||||
if (!drag.dragging && Math.abs(e.clientX - drag.startX) < DRAG_THRESHOLD) return;
|
||||
drag.dragging = true;
|
||||
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. */
|
||||
const onDrop = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
const key = dragKey ?? e.dataTransfer.getData("text/plain");
|
||||
if (!key || dropIndex === null) return endDrag();
|
||||
e.preventDefault();
|
||||
const from = tabOrder.indexOf(key);
|
||||
// `dropIndex` is a slot in the strip as it looks *now*; `moveTab` places the
|
||||
// tab after pulling it out, so every slot past the tab's own shifts down one.
|
||||
moveTab(key, dropIndex > from ? dropIndex - 1 : dropIndex);
|
||||
endDrag();
|
||||
/** A drag in progress swallows the click it ends with. */
|
||||
const activateTab = (key: string) => {
|
||||
if (suppressClick.current) {
|
||||
suppressClick.current = false;
|
||||
return;
|
||||
}
|
||||
setActiveTabKey(key);
|
||||
};
|
||||
|
||||
const dropMarker = (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
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"
|
||||
aria-selected={active}
|
||||
tabIndex={0}
|
||||
onClick={() => setActiveTabKey(key)}
|
||||
data-tab-index={index}
|
||||
onClick={() => activateTab(key)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
setActiveTabKey(key);
|
||||
}
|
||||
}}
|
||||
{...dragProps(key, index, false)}
|
||||
{...pointerProps(key, false)}
|
||||
className={tabClass(active, dragKey === key)}
|
||||
>
|
||||
<span aria-hidden="true" className="text-[var(--text-secondary)]">⌂</span>
|
||||
@@ -265,7 +323,8 @@ export default function MainTabs() {
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
tabIndex={0}
|
||||
onClick={() => setActiveTabKey(terminalTabKey(session.id))}
|
||||
data-tab-index={index}
|
||||
onClick={() => activateTab(terminalTabKey(session.id))}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
@@ -277,7 +336,7 @@ export default function MainTabs() {
|
||||
setMenu({ sessionId: session.id, x: e.clientX, y: e.clientY });
|
||||
}}
|
||||
onDoubleClick={() => startRename(session.id)}
|
||||
{...dragProps(key, index, isRenaming)}
|
||||
{...pointerProps(key, isRenaming)}
|
||||
className={tabClass(active, dragKey === key)}
|
||||
>
|
||||
<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 (
|
||||
<div
|
||||
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);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div ref={stripRef} className="flex items-center h-full" role="tablist" aria-label="Open tabs">
|
||||
{tabOrder.map((key, index) => {
|
||||
const tab = renderTab(key, index);
|
||||
if (!tab) return null;
|
||||
const marker = markerPending && index >= (dropIndex ?? 0);
|
||||
if (marker) markerPending = false;
|
||||
return (
|
||||
<Fragment key={key}>
|
||||
{dragKey !== null && dropIndex === index && dropMarker}
|
||||
{marker && dropMarker}
|
||||
{tab}
|
||||
</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 hand naturally goes to say "put it at the end". */}
|
||||
<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>
|
||||
<div className="flex-1 self-stretch">{markerPending && dropMarker}</div>
|
||||
|
||||
{menu && (() => {
|
||||
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 openBrowserViewPopout = vi.fn<(id: string, onTop: boolean) => 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 pushToast = vi.fn();
|
||||
const setContainerProgress = vi.fn();
|
||||
@@ -28,7 +29,7 @@ vi.mock("../../../lib/tauri-commands", () => ({
|
||||
installBrowserViewBrowser: (id: string, b: string) => installBrowserViewBrowser(id, b),
|
||||
openBrowserViewPopout: (id: string, onTop: boolean) => openBrowserViewPopout(id, onTop),
|
||||
closeBrowserViewPopout: (id: string) => closeBrowserViewPopout(id),
|
||||
isBrowserViewPopoutOpen: () => isBrowserViewPopoutOpen(),
|
||||
getBrowserViewPopoutState: () => getBrowserViewPopoutState(),
|
||||
setBrowserViewPopoutAlwaysOnTop: (id: string, onTop: boolean) =>
|
||||
setBrowserViewPopoutAlwaysOnTop(id, onTop),
|
||||
}));
|
||||
@@ -120,7 +121,7 @@ beforeEach(() => {
|
||||
storeState.containerProgress = {};
|
||||
getBrowserViewStatus.mockResolvedValue(OFF);
|
||||
checkBrowserViewSupport.mockResolvedValue(READY);
|
||||
isBrowserViewPopoutOpen.mockResolvedValue(false);
|
||||
getBrowserViewPopoutState.mockResolvedValue({ open: false, always_on_top: false });
|
||||
openBrowserViewPopout.mockResolvedValue(undefined);
|
||||
closeBrowserViewPopout.mockResolvedValue(undefined);
|
||||
setBrowserViewPopoutAlwaysOnTop.mockResolvedValue(undefined);
|
||||
@@ -406,16 +407,17 @@ describe("BrowserTab", () => {
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
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.
|
||||
isBrowserViewPopoutOpen.mockResolvedValue(true);
|
||||
getBrowserViewPopoutState.mockResolvedValue({ open: true, always_on_top: true });
|
||||
checkBrowserViewSupport.mockResolvedValue({ ...READY, browsers: ["chromium-1200"] });
|
||||
getBrowserViewStatus.mockResolvedValue(LIVE);
|
||||
|
||||
@@ -423,6 +425,33 @@ describe("BrowserTab", () => {
|
||||
|
||||
expect(await screen.findByText(/in its own window/i)).toBeInTheDocument();
|
||||
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 () => {
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
getBrowserViewStatus,
|
||||
installBrowserViewBrowser,
|
||||
installBrowserViewSupport,
|
||||
isBrowserViewPopoutOpen,
|
||||
getBrowserViewPopoutState,
|
||||
openBrowserViewPopout,
|
||||
setBrowserViewEnabled,
|
||||
setBrowserViewPopoutAlwaysOnTop,
|
||||
@@ -72,8 +72,13 @@ export default function BrowserTab({ project, active }: Props) {
|
||||
const [job, setJob] = useState<SetupJob>(null);
|
||||
const [outcome, setOutcome] = useState<BrowserSetupOutcome | 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 pushToast = useAppState((s) => s.pushToast);
|
||||
const setContainerProgress = useAppState((s) => s.setContainerProgress);
|
||||
@@ -112,7 +117,7 @@ export default function BrowserTab({ project, active }: Props) {
|
||||
listen<BrowserViewPopoutChangedEvent>("browser-view-popout-changed", (event) => {
|
||||
if (event.payload.project_id === projectId && mounted.current) {
|
||||
setPoppedOut(event.payload.open);
|
||||
if (!event.payload.open) setOnTop(false);
|
||||
setOnTop(event.payload.always_on_top);
|
||||
}
|
||||
}).then((un) => {
|
||||
if (mounted.current) dispose = un;
|
||||
@@ -123,9 +128,15 @@ export default function BrowserTab({ project, active }: Props) {
|
||||
|
||||
useEffect(() => {
|
||||
if (!active || !running) return;
|
||||
isBrowserViewPopoutOpen(projectId)
|
||||
.then((open) => mounted.current && setPoppedOut(open))
|
||||
.catch(() => {});
|
||||
getBrowserViewPopoutState(projectId)
|
||||
.then((s) => {
|
||||
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)
|
||||
.then((s) => mounted.current && setStatus(s))
|
||||
.catch(() => {});
|
||||
@@ -304,22 +315,21 @@ export default function BrowserTab({ project, active }: Props) {
|
||||
</span>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
{live && poppedOut && (
|
||||
<label className="flex items-center gap-1.5 text-xs text-[var(--text-secondary)]">
|
||||
{live && poppedOut === true && (
|
||||
<span className="flex items-center gap-1.5 text-xs text-[var(--text-secondary)]">
|
||||
Keep on top
|
||||
<Toggle
|
||||
checked={onTop}
|
||||
onChange={toggleOnTop}
|
||||
label="Keep the browser window above other windows"
|
||||
/>
|
||||
</label>
|
||||
{/* The accessible name matches the visible text, as everywhere else
|
||||
a Toggle is used — a `<label>` around it would be inert anyway,
|
||||
since a Toggle renders a button. */}
|
||||
<Toggle checked={onTop} onChange={toggleOnTop} label="Keep on top" />
|
||||
</span>
|
||||
)}
|
||||
{live && !poppedOut && (
|
||||
{live && poppedOut === false && (
|
||||
<Button size="md" onClick={() => setReloadKey((k) => k + 1)}>
|
||||
Reload
|
||||
</Button>
|
||||
)}
|
||||
{live && (
|
||||
{live && poppedOut !== null && (
|
||||
<Button size="md" onClick={poppedOut ? popIn : popOut}>
|
||||
{poppedOut ? "Put back in tab" : "Open in own window"}
|
||||
</Button>
|
||||
@@ -334,7 +344,7 @@ export default function BrowserTab({ project, active }: Props) {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{live && poppedOut ? (
|
||||
{live && poppedOut === true ? (
|
||||
// The iframe is unmounted while the window is up, on purpose. Two
|
||||
// viewers on one browser both work, but both also *drive* it — two
|
||||
// cursors taking over the same page is not a feature.
|
||||
@@ -355,7 +365,7 @@ export default function BrowserTab({ project, active }: Props) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : live ? (
|
||||
) : live && poppedOut === false ? (
|
||||
<iframe
|
||||
key={reloadKey}
|
||||
// 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}`}
|
||||
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">
|
||||
{/* 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 { 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
|
||||
* 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
|
||||
// dragging a tab does. Shift is what keeps it clear of the terminal:
|
||||
// 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 (!state.activeTabKey) return;
|
||||
if (!state.activeTabKey || inTextField(document.activeElement)) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
state.moveActiveTab(e.key === "ArrowLeft" ? -1 : 1);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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
|
||||
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. */
|
||||
export const closeBrowserViewPopout = (projectId: string) =>
|
||||
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) =>
|
||||
invoke<boolean>("is_browser_view_popout_open", { projectId });
|
||||
/**
|
||||
* Whether the pop-out is open and whether it is pinned, read from the window.
|
||||
*
|
||||
* 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. */
|
||||
export const setBrowserViewPopoutAlwaysOnTop = (projectId: string, onTop: boolean) =>
|
||||
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,
|
||||
* or the session tears down and takes it — so this is the only reliable way to
|
||||
* know whether it is on screen.
|
||||
*/
|
||||
export interface BrowserViewPopoutChangedEvent {
|
||||
export interface BrowserViewPopoutChangedEvent extends BrowserViewPopoutState {
|
||||
project_id: string;
|
||||
open: boolean;
|
||||
}
|
||||
|
||||
/** Payload of the `claude-token-progress` event: milestones during
|
||||
|
||||
Reference in New Issue
Block a user