From d73096c9370e0e49950efefbba4fe5d5ca78be88 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Tue, 11 Aug 2026 06:50:40 -0700 Subject: [PATCH] Reorder tabs by dragging, and pop the browser view into its own window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things the UI couldn't do: rearrange the tab strip, and watch the browser while working somewhere else. **Drag to reorder.** `moveTab`/`moveActiveTab` on the store, HTML5 drag on the strip with a marker showing where the drop lands, `Ctrl+Shift+←/→` for the same thing without a mouse. Reordering deliberately does not select what it moves, so a drag aimed at a background tab doesn't yank the main area away from a terminal mid-run. A tab being renamed is not draggable — a draggable ancestor swallows the mouse-drag that selects text in its input. **Pop the browser view out.** `browser_view/popout.rs` opens the view's existing token-bearing loopback URL as a second OS window, with a "Keep on top" toggle so it can float above the app. Window-only: the viewer, the proxy and the container are untouched, so popping out and back interrupts nothing. Three things it rests on: - No capability lists that window, so it has no IPC surface — right for a page served out of a container, and it must stay that way. - The app CSP is irrelevant to it: `frame-src` constrains what the app's document may *embed*, and this is a top-level document. The port range and the token gate are what actually protect it, unchanged. - The window is owned by the session, so the supervisor's teardown closes it. A window onto a viewer that no longer exists is worse than none. The pane drops its iframe while popped out — two viewers can both *drive* the browser, and two cursors on one page is not a feature. `lib.rs`'s `on_window_event` is now guarded on `label() == "main"`. It fires for every window and its body stops every container and exits, so without the guard closing a pop-out would quit the app. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 21 +- HOW-TO-USE.md | 40 ++- app/src-tauri/src/browser_view/commands.rs | 63 +++- app/src-tauri/src/browser_view/mod.rs | 6 + app/src-tauri/src/browser_view/popout.rs | 158 ++++++++ app/src-tauri/src/lib.rs | 12 + app/src/components/layout/MainTabs.test.tsx | 167 +++++++++ app/src/components/layout/MainTabs.tsx | 340 +++++++++++------- .../projects/home/BrowserTab.test.tsx | 114 ++++++ .../components/projects/home/BrowserTab.tsx | 119 +++++- app/src/hooks/useKeyboardShortcuts.ts | 12 + app/src/lib/tauri-commands.ts | 19 + app/src/lib/types.ts | 12 + app/src/store/appState.test.ts | 79 ++++ app/src/store/appState.ts | 33 ++ 15 files changed, 1068 insertions(+), 127 deletions(-) create mode 100644 app/src-tauri/src/browser_view/popout.rs create mode 100644 app/src/components/layout/MainTabs.test.tsx create mode 100644 app/src/store/appState.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 9dae98a..0d98103 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -59,6 +59,9 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li - **`store/appState.ts`** — Single Zustand store for all app state (projects, sessions, UI). The main area is a single ordered tab strip holding two tab kinds, keyed `term:` and `home:`; `activeSessionId` is *derived* from `activeTabKey` so exactly one thing is current. + `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. - **`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 @@ -84,8 +87,10 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li Use `--text-disabled` rather than `disabled:opacity-50`. - **Never write `focus:outline-none`.** A global `:focus-visible` ring is defined in `index.css`. - **Status must not be encoded in colour alone** — `StatusIndicator` pairs a glyph with a word. -- Keyboard: `Ctrl+T` new terminal, `Ctrl+Shift+W` close tab, `Ctrl+Tab` cycle, `Ctrl+1..9` jump. - `Ctrl+W` is intentionally left alone — it is readline's `kill-word` inside the terminal. +- Keyboard: `Ctrl+T` new terminal, `Ctrl+Shift+W` close tab, `Ctrl+Tab` cycle, `Ctrl+1..9` jump, + `Ctrl+Shift+←/→` move the active tab. `Ctrl+W` is intentionally left alone — it is readline's + `kill-word` inside the terminal, and plain `Ctrl+←/→` is its word-wise cursor motion, which is + why tab-moving takes Shift. ### Backend Structure (`app/src-tauri/src/`) @@ -104,6 +109,18 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li OAuth listener, wrong for remote control of a browser. Host ports are confined to `47820..=47827` because CSP `frame-src` cannot express a port range and must enumerate them; a unit test asserts the Rust range matches `tauri.conf.json`. Opt-in per project. + - **`popout.rs` puts the same URL in a second OS window** (`WebviewUrl::External`), so the view + can be watched on another monitor or pinned on top while the main window is used for work. + Three things it rests on: no capability lists that window, so it has **no IPC surface** — do + not give it one; the app CSP does not apply, because it is a top-level document rather than a + frame, and the token gate is what protects the port in both cases; and the window is owned by + the *session*, so the supervisor's teardown closes it rather than leaving a window onto a + viewer that no longer exists. It closes with `destroy()`, never `close()`, to stay clear of + `CloseRequested`. The pane drops its iframe while popped out — two viewers can both *drive* + the browser. + - **`lib.rs`'s `on_window_event` fires for every window and must stay guarded on + `label() == "main"`.** Without that guard, closing a pop-out runs the app's shutdown: every + container stopped, process exited. - **Detection has to look past `node_modules`.** `claude mcp add … npx @playwright/mcp@latest` installs into `~/.npm/_npx//node_modules`, not any `node_modules`, so `detect.rs` globs that cache as well as `/workspace`, `$HOME/node_modules` and `npm root -g`. It also diff --git a/HOW-TO-USE.md b/HOW-TO-USE.md index 09c1979..335f744 100644 --- a/HOW-TO-USE.md +++ b/HOW-TO-USE.md @@ -191,6 +191,11 @@ Anthropic-backend project uses that token without its own login. See terminal tab to rename it, jump to its project home, or close it; double-click to rename inline. 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. - **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. @@ -211,7 +216,7 @@ for selecting a project and for two quick controls that appear on hover — star Claude terminal. Everything else about a project lives in Project Home. The header shows the project name, its status, how long the container has been up, and the action -buttons. Below that are five tabs: +buttons. Below that are six tabs: | Tab | What it's for | |---|---| @@ -220,6 +225,7 @@ buttons. Below that are five tabs: | **Automation** | The scheduled tasks running inside this container — see [Automation & Scheduled Tasks](#automation--scheduled-tasks) | | **Config** | All per-project configuration — see [Project Configuration](#project-configuration) | | **Files** | Browse, download and upload files inside the container | +| **Browser** | Watch — and take over — the browser Claude is driving with Playwright, see [The Browser Tab](#the-browser-tab) | ### Sessions @@ -257,6 +263,37 @@ included, and each tile opens a list of what it found. The counts are only available while the container is running. +### The Browser Tab + +When Claude drives a browser with Playwright inside the container, the **Browser** tab shows you +that browser live — and lets you take it over with your own mouse and keyboard. + +It is **off by default and opted into per project**, and it never installs anything on its own. +Opening the tab only *probes* the container, so it can tell you what is missing before you ask for +a view; installing Playwright and downloading a browser are separate, labelled buttons that state +what they cost before you press them. See +[What's Inside the Container](#whats-inside-the-container) for why the browser itself is not +pre-installed. + +Press **Start browser view** and the pane fills with Playwright's own dashboard, running inside the +container and reached over a token-gated listener on your machine's loopback address. Nothing is +exposed off the machine. + +#### Watching it while you work + +Press **Open in own window** and the view moves out of the tab into a window of its own — put it on +a second monitor, or turn on **Keep on top** and let it float above the app while you work in a +terminal. This is a window change only: the browser and the view keep running throughout, so +popping out and back costs nothing and interrupts nothing. + +While the view is in its own window the tab shows a placeholder rather than a second copy of it — +two viewers would both be able to *drive* the browser, and two cursors on one page is not useful. +**Put back in tab**, or just closing the window, brings it back. + +The window belongs to the view, not to the tab: closing the project's home tab leaves it open, and +stopping the view — by pressing **Stop**, stopping the container, or removing the project — closes +it, because a window showing a viewer that no longer exists is worse than no window. + --- ## Project Management @@ -1119,6 +1156,7 @@ triple-c-scheduler add --name "test" --schedule "0 */6 * * *" --prompt "Run test | **Ctrl+Tab** | Switch to the next tab | | **Ctrl+Shift+Tab** | Switch to the previous tab | | **Ctrl+1** … **Ctrl+9** | Jump to the first through ninth tab | +| **Ctrl+Shift+←** / **Ctrl+Shift+→** | Move the active tab one place along the strip (the mouse equivalent is dragging it) | > **Why Ctrl+Shift+W and not Ctrl+W?** `Ctrl+W` is readline's `kill-word` — it deletes the word > before the cursor, and it is used constantly in the terminal this app is built around. Binding it diff --git a/app/src-tauri/src/browser_view/commands.rs b/app/src-tauri/src/browser_view/commands.rs index 293b0f8..aa20c38 100644 --- a/app/src-tauri/src/browser_view/commands.rs +++ b/app/src-tauri/src/browser_view/commands.rs @@ -5,7 +5,7 @@ use tauri::{AppHandle, State}; use crate::browser_view::install::{self, BrowserSetupOutcome}; -use crate::browser_view::{manager, BrowserViewStatus}; +use crate::browser_view::{manager, popout, BrowserViewState, BrowserViewStatus}; use crate::AppState; /// Turn the pane on or off for a project. @@ -97,6 +97,67 @@ pub async fn install_browser_view_browser( install::install_browser(&app_handle, &project_id, &container_id, target).await } +/// Detach the view into a window of its own, or raise the one already open. +/// +/// Host-side and window-only: the viewer keeps running exactly as it was, and +/// this touches neither the container nor the proxy. Requires a *live* view, +/// because a window with nothing behind it is not worth opening — the pane +/// only offers the button in that state, and this enforces it. +#[tauri::command] +pub async fn open_browser_view_popout( + project_id: String, + always_on_top: bool, + app_handle: AppHandle, + state: State<'_, AppState>, +) -> Result<(), String> { + let status = manager().status(&project_id).await; + let (BrowserViewState::Running, Some(url)) = (status.state, status.url.as_deref()) else { + return Err( + "The browser view isn't running. Start it before opening it in its own window." + .to_string(), + ); + }; + + let name = state + .projects_store + .get(&project_id) + .map(|p| p.name) + .unwrap_or_else(|| "Triple-C".to_string()); + + popout::open(&app_handle, &project_id, &name, url, always_on_top) +} + +/// Close the pop-out, putting the view back in the tab. No-op if it is closed. +#[tauri::command] +pub async fn close_browser_view_popout( + project_id: String, + app_handle: AppHandle, +) -> Result<(), String> { + popout::close(&app_handle, &project_id); + Ok(()) +} + +/// Whether the pop-out is open — asked on tab open, since a window can outlive +/// the pane that spawned it. +#[tauri::command] +pub async fn is_browser_view_popout_open( + project_id: String, + app_handle: AppHandle, +) -> Result { + Ok(popout::is_open(&app_handle, &project_id)) +} + +/// Pin the pop-out above other windows, so it can be watched while working in +/// the main one. +#[tauri::command] +pub async fn set_browser_view_popout_always_on_top( + project_id: String, + on_top: bool, + app_handle: AppHandle, +) -> Result<(), String> { + popout::set_always_on_top(&app_handle, &project_id, on_top) +} + /// The project's container, or a sentence saying why there isn't one. /// /// Every command here needs a *running* container, and every one of them used diff --git a/app/src-tauri/src/browser_view/mod.rs b/app/src-tauri/src/browser_view/mod.rs index cde1369..597b132 100644 --- a/app/src-tauri/src/browser_view/mod.rs +++ b/app/src-tauri/src/browser_view/mod.rs @@ -64,6 +64,7 @@ pub mod commands; pub mod detect; pub mod install; +pub mod popout; pub mod proxy; use std::collections::HashMap; @@ -474,6 +475,11 @@ async fn supervise( } } + // 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 enabled = manager().is_enabled(&project_id).await; emit(&app, &project_id, &BrowserViewStatus::off(enabled)); } diff --git a/app/src-tauri/src/browser_view/popout.rs b/app/src-tauri/src/browser_view/popout.rs new file mode 100644 index 0000000..fe9cca1 --- /dev/null +++ b/app/src-tauri/src/browser_view/popout.rs @@ -0,0 +1,158 @@ +//! The browser view in a window of its own. +//! +//! Watching a browser and working in a terminal are the same task done at the +//! same time, and a tab can only be one of them. So the pane can be detached +//! into a second OS window — put on the other monitor, or pinned on top of +//! whatever else is in front. +//! +//! ## Why this is a native window and not a second iframe +//! +//! The window loads the *same* token-bearing loopback URL the pane's iframe +//! uses ([`crate::browser_view::BrowserViewStatus::url`]), as its top-level +//! document. That has two consequences worth stating: +//! +//! - It is a **remote-origin** webview. No capability lists this window, so it +//! has no IPC surface at all — `invoke` is not reachable from it, which is +//! exactly right for a page served out of a container. Do not add one. +//! - The app CSP does not apply, and does not need to: `frame-src` exists to +//! constrain what the *app's* document may embed, and this is not embedded. +//! The port is still confined to [`crate::browser_view::proxy`]'s range and +//! still gated by the session token, which is what actually protects it. +//! +//! ## Lifetime +//! +//! The window is owned by the session, not by the user's patience: when a view +//! stops — the user pressed Stop, the container went away, the viewer died — +//! the supervisor's teardown calls [`close`], because a window left showing a +//! dead viewer is worse than no window. The reverse is not true; closing the +//! window leaves the view running, and the pane takes it back into the tab. + +use tauri::{AppHandle, Emitter, Manager, WebviewUrl, WebviewWindowBuilder, WindowEvent}; + +/// Emitted when a pop-out opens or closes. Payload: `{ project_id, open }`. +/// +/// 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"; + +/// Tauri window labels admit `[a-zA-Z0-9-/:_]` only. Project ids are UUIDs, so +/// this never fires in practice; it exists so a hand-edited `projects.json` +/// cannot produce a label Tauri rejects at build time. +pub fn window_label(project_id: &str) -> String { + let id: String = project_id + .chars() + .map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' }) + .collect(); + format!("browser-view-{}", id) +} + +/// Open the pop-out, or raise it if it is already open. +/// +/// `url` is the live session's URL; the caller has already established that the +/// view is running, because there is nothing to show otherwise. +pub fn open( + app: &AppHandle, + project_id: &str, + project_name: &str, + url: &str, + always_on_top: bool, +) -> Result<(), String> { + let label = window_label(project_id); + + if let Some(window) = app.get_webview_window(&label) { + // Asking twice means "I can't see it", not "open another". + let _ = window.unminimize(); + let _ = window.set_focus(); + let _ = window.set_always_on_top(always_on_top); + emit(app, project_id, true); + return Ok(()); + } + + let parsed = url + .parse() + .map_err(|e| format!("The browser view's address is not a URL: {}", e))?; + + let project_id_owned = project_id.to_string(); + let app_for_event = app.clone(); + + let window = WebviewWindowBuilder::new(app, &label, WebviewUrl::External(parsed)) + .title(format!("{} — browser", project_name)) + .inner_size(1100.0, 820.0) + .min_inner_size(480.0, 360.0) + .always_on_top(always_on_top) + .build() + .map_err(|e| format!("Could not open the browser window: {}", e))?; + + // Closed from its own titlebar, this is the only thing that tells the pane + // to take the view back into the tab. + window.on_window_event(move |event| { + if matches!(event, WindowEvent::Destroyed) { + emit(&app_for_event, &project_id_owned, false); + } + }); + + log::info!("Browser view: popped out for project {}", project_id); + emit(app, project_id, true); + Ok(()) +} + +/// Close the pop-out if there is one. Safe to call when there isn't. +/// +/// `destroy`, not `close`: `close` raises `CloseRequested`, and the app's +/// window-event handler treats that as a request to quit for the main window. +/// Nothing here should ever be able to be mistaken for that. +pub fn close(app: &AppHandle, project_id: &str) { + if let Some(window) = app.get_webview_window(&window_label(project_id)) { + if let Err(e) = window.destroy() { + log::warn!( + "Browser view: could not close the pop-out for project {}: {}", + project_id, + 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); +} + +pub fn is_open(app: &AppHandle, project_id: &str) -> bool { + app.get_webview_window(&window_label(project_id)).is_some() +} + +/// Pin the pop-out above other windows, or unpin it. No-op when it is closed. +pub fn set_always_on_top(app: &AppHandle, project_id: &str, on_top: bool) -> Result<(), String> { + let Some(window) = app.get_webview_window(&window_label(project_id)) else { + return Ok(()); + }; + window + .set_always_on_top(on_top) + .map_err(|e| format!("Could not change the window's stacking: {}", e)) +} + +fn emit(app: &AppHandle, project_id: &str, open: bool) { + let _ = app.emit( + POPOUT_EVENT, + serde_json::json!({ "project_id": project_id, "open": open }), + ); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn labels_are_derived_from_the_project_and_are_tauri_safe() { + assert_eq!( + window_label("6b1f4a2c-0d5e-4f9a-9c11-2f0b7d3e8a44"), + "browser-view-6b1f4a2c-0d5e-4f9a-9c11-2f0b7d3e8a44" + ); + assert_eq!(window_label("a b/c.d"), "browser-view-a_b_c_d"); + } + + #[test] + fn distinct_projects_get_distinct_windows() { + assert_ne!(window_label("alpha"), window_label("beta")); + } +} diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index d442ede..634f512 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -328,6 +328,14 @@ pub fn run() { }) .on_window_event(|window, event| { if let tauri::WindowEvent::CloseRequested { api, .. } = event { + // This handler fires for *every* window, and what follows stops + // containers and exits the process. Only the main window means + // that. Secondary windows — the browser view's pop-out — are + // closed and reopened freely and must just close. + if window.label() != "main" { + return; + } + let state = window.state::(); let lifecycle = state.lifecycle.clone(); @@ -428,6 +436,10 @@ pub fn run() { browser_view::commands::check_browser_view_support, browser_view::commands::install_browser_view_support, 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::set_browser_view_popout_always_on_top, // Shared Claude Code auth token commands::auth_token_commands::acquire_claude_token, commands::auth_token_commands::submit_claude_token_code, diff --git a/app/src/components/layout/MainTabs.test.tsx b/app/src/components/layout/MainTabs.test.tsx new file mode 100644 index 0000000..d3162b2 --- /dev/null +++ b/app/src/components/layout/MainTabs.test.tsx @@ -0,0 +1,167 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { fireEvent, render, screen } from "@testing-library/react"; +import MainTabs from "./MainTabs"; +import { useAppState, homeTabKey, terminalTabKey } from "../../store/appState"; +import type { Project, TerminalSession } from "../../lib/types"; + +const close = vi.fn(); + +const sessions: TerminalSession[] = [ + { + id: "s1", + projectId: "p1", + projectName: "api-server", + sessionName: "claude", + sessionType: "claude", + }, + { + id: "s2", + projectId: "p1", + projectName: "api-server", + sessionName: "shell", + sessionType: "bash", + }, +] as unknown as TerminalSession[]; + +const projects: Project[] = [ + { + id: "p1", + name: "api-server", + status: "running", + permission_mode: "bypass", + renamed_session_names: {}, + }, +] as unknown as Project[]; + +vi.mock("../../hooks/useTerminal", () => ({ + useTerminal: () => ({ sessions, close }), +})); +vi.mock("../../hooks/useProjects", () => ({ + useProjects: () => ({ projects, update: vi.fn() }), +})); + +const HOME = homeTabKey("p1"); +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. + */ +function dataTransfer() { + const store: Record = {}; + return { + effectAllowed: "", + dropEffect: "", + setData: (format: string, value: string) => { + store[format] = value; + }, + getData: (format: string) => store[format] ?? "", + }; +} + +/** + * 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) { + 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. */ +function place(el: Element, left: number, width = 100) { + el.getBoundingClientRect = () => + ({ left, width, right: left + width, top: 0, bottom: 30, height: 30, x: left, y: 0 }) as DOMRect; +} + +const order = () => useAppState.getState().tabOrder; + +beforeEach(() => { + vi.clearAllMocks(); + useAppState.setState({ + tabOrder: [HOME, S1, S2], + activeTabKey: HOME, + activeSessionId: null, + projects, + }); +}); + +describe("MainTabs reordering", () => { + it("drags a tab to the front", () => { + render(); + const tabs = screen.getAllByRole("tab"); + tabs.forEach((tab, i) => place(tab, i * 100)); + + 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 }); + + expect(order()).toEqual([S2, HOME, S1]); + }); + + it("drops after the tab when the pointer is past its midpoint", () => { + render(); + const tabs = screen.getAllByRole("tab"); + tabs.forEach((tab, i) => place(tab, i * 100)); + + const dt = dataTransfer(); + fireEvent.dragStart(tabs[0], { dataTransfer: dt }); + dragOverAt(tabs[1], 190, dt); + fireEvent.drop(tabs[1], { dataTransfer: dt }); + + expect(order()).toEqual([S1, HOME, S2]); + }); + + it("dragging does not steal the selection", () => { + render(); + const tabs = screen.getAllByRole("tab"); + tabs.forEach((tab, i) => place(tab, i * 100)); + + const dt = dataTransfer(); + fireEvent.dragStart(tabs[1], { dataTransfer: dt }); + dragOverAt(tabs[2], 290, dt); + fireEvent.drop(tabs[2], { dataTransfer: dt }); + + expect(order()).toEqual([HOME, S2, S1]); + expect(useAppState.getState().activeTabKey).toBe(HOME); + }); + + it("shows no drop marker until a drag is under way", () => { + render(); + expect(screen.queryByTestId("tab-drop-marker")).toBeNull(); + }); + + it("clears the marker when the drag ends without a drop", () => { + render(); + const tabs = screen.getAllByRole("tab"); + tabs.forEach((tab, i) => place(tab, i * 100)); + + const dt = dataTransfer(); + fireEvent.dragStart(tabs[2], { dataTransfer: dt }); + dragOverAt(tabs[0], 10, dt); + fireEvent.dragEnd(tabs[2], { dataTransfer: dt }); + + 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", () => { + render(); + const tabs = screen.getAllByRole("tab"); + expect(tabs[1]).toHaveAttribute("draggable", "true"); + + fireEvent.doubleClick(tabs[1]); + + expect(screen.getByLabelText("Rename tab")).toBeInTheDocument(); + expect(screen.getAllByRole("tab")[1]).toHaveAttribute("draggable", "false"); + }); +}); diff --git a/app/src/components/layout/MainTabs.tsx b/app/src/components/layout/MainTabs.tsx index 5d768c6..ab223f2 100644 --- a/app/src/components/layout/MainTabs.tsx +++ b/app/src/components/layout/MainTabs.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react"; +import { Fragment, useEffect, useRef, useState } from "react"; import { useShallow } from "zustand/react/shallow"; import { useTerminal } from "../../hooks/useTerminal"; import { useProjects } from "../../hooks/useProjects"; @@ -28,22 +28,31 @@ const MODE_BADGE: Record = /** * 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. + * `Ctrl+Shift+←/→` does the same thing without a mouse. */ export default function MainTabs() { const { sessions, close } = useTerminal(); const { projects, update } = useProjects(); - const { tabOrder, activeTabKey, setActiveTabKey, closeHomeTab } = useAppState( + const { tabOrder, activeTabKey, setActiveTabKey, closeHomeTab, moveTab } = useAppState( useShallow((s) => ({ tabOrder: s.tabOrder, activeTabKey: s.activeTabKey, setActiveTabKey: s.setActiveTabKey, closeHomeTab: s.closeHomeTab, + moveTab: s.moveTab, })), ); const [menu, setMenu] = useState(null); const [renamingId, setRenamingId] = useState(null); const [renameDraft, setRenameDraft] = useState(""); const renameInputRef = useRef(null); + /** The tab being dragged, and the slot it would drop into. */ + const [dragKey, setDragKey] = useState(null); + const [dropIndex, setDropIndex] = useState(null); useEffect(() => { if (!menu) return; @@ -135,136 +144,225 @@ export default function MainTabs() { } }; - const tabClass = (active: boolean) => + const tabClass = (active: boolean, dragging: boolean) => `flex items-center gap-1.5 pl-3 pr-1.5 h-full text-xs cursor-pointer border-r border-[var(--border-color)] transition-colors ${ active ? "bg-[var(--bg-primary)] text-[var(--text-primary)]" : "text-[var(--text-secondary)] hover:text-[var(--text-primary)]" - }`; + }${dragging ? " opacity-40" : ""}`; + + const endDrag = () => { + setDragKey(null); + setDropIndex(null); + }; + + /** + * Drag props shared by both tab kinds. + * + * 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. + */ + const dragProps = (key: string, index: number, renaming: boolean) => ({ + draggable: !renaming, + onDragStart: (e: React.DragEvent) => { + 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); + }, + onDragOver: (e: React.DragEvent) => { + 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)); + }, + onDragEnd: endDrag, + }); + + /** Drop lands wherever the marker is showing, and only there. */ + const onDrop = (e: React.DragEvent) => { + 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(); + }; + + const dropMarker = ( +