Reorder tabs by dragging, and pop the browser view into its own window
Build App / compute-version (pull_request) Successful in 3s
Build App / build-macos (pull_request) Successful in 2m31s
Build App / build-linux (pull_request) Successful in 5m35s
Build App / build-windows (pull_request) Successful in 6m9s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped
Build App / compute-version (pull_request) Successful in 3s
Build App / build-macos (pull_request) Successful in 2m31s
Build App / build-linux (pull_request) Successful in 5m35s
Build App / build-windows (pull_request) Successful in 6m9s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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:<id>` and
|
||||
`home:<id>`; `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/<hash>/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
|
||||
|
||||
+39
-1
@@ -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
|
||||
|
||||
@@ -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<bool, String> {
|
||||
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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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::<AppState>();
|
||||
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,
|
||||
|
||||
@@ -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<string, string> = {};
|
||||
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<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. */
|
||||
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(<MainTabs />);
|
||||
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(<MainTabs />);
|
||||
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(<MainTabs />);
|
||||
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(<MainTabs />);
|
||||
expect(screen.queryByTestId("tab-drop-marker")).toBeNull();
|
||||
});
|
||||
|
||||
it("clears the marker when the drag ends without a drop", () => {
|
||||
render(<MainTabs />);
|
||||
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(<MainTabs />);
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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<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.
|
||||
* `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<ContextMenuState | null>(null);
|
||||
const [renamingId, setRenamingId] = useState<string | null>(null);
|
||||
const [renameDraft, setRenameDraft] = useState("");
|
||||
const renameInputRef = useRef<HTMLInputElement>(null);
|
||||
/** 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);
|
||||
|
||||
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<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);
|
||||
},
|
||||
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));
|
||||
},
|
||||
onDragEnd: 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();
|
||||
};
|
||||
|
||||
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"
|
||||
/>
|
||||
);
|
||||
|
||||
const renderTab = (key: string, index: number) => {
|
||||
const active = activeTabKey === key;
|
||||
|
||||
if (isHomeTab(key)) {
|
||||
const projectId = tabKeyId(key);
|
||||
const project = projects.find((p) => p.id === projectId);
|
||||
if (!project) return null;
|
||||
return (
|
||||
<div
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
tabIndex={0}
|
||||
onClick={() => setActiveTabKey(key)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
setActiveTabKey(key);
|
||||
}
|
||||
}}
|
||||
{...dragProps(key, index, false)}
|
||||
className={tabClass(active, dragKey === key)}
|
||||
>
|
||||
<span aria-hidden="true" className="text-[var(--text-secondary)]">⌂</span>
|
||||
<span className="truncate max-w-[160px]" title={`${project.name} — project home`}>
|
||||
{project.name}
|
||||
</span>
|
||||
<ProjectStatusIndicator status={project.status} iconOnly />
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
closeHomeTab(projectId);
|
||||
}}
|
||||
aria-label={`Close ${project.name} home tab`}
|
||||
title="Close tab"
|
||||
className="w-6 h-6 flex items-center justify-center rounded-[var(--radius-control)] text-[var(--text-secondary)] hover:text-[var(--error)] hover:bg-[var(--bg-tertiary)] transition-colors"
|
||||
>
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const sessionId = tabKeyId(key);
|
||||
const session = sessions.find((s) => s.id === sessionId);
|
||||
if (!session) return null;
|
||||
const project = projects.find((p) => p.id === session.projectId);
|
||||
const customName = getCustomName(session.projectId, session.id);
|
||||
const baseLabel =
|
||||
(session.sessionName ?? session.projectName) +
|
||||
(session.sessionType === "bash" ? " (bash)" : "");
|
||||
const displayLabel = customName
|
||||
? `${session.projectName}: ${customName}`
|
||||
: baseLabel;
|
||||
const isRenaming = renamingId === session.id;
|
||||
const badge = project ? MODE_BADGE[effectivePermissionMode(project)] : null;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
tabIndex={0}
|
||||
onClick={() => setActiveTabKey(terminalTabKey(session.id))}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
setActiveTabKey(terminalTabKey(session.id));
|
||||
}
|
||||
}}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
setMenu({ sessionId: session.id, x: e.clientX, y: e.clientY });
|
||||
}}
|
||||
onDoubleClick={() => startRename(session.id)}
|
||||
{...dragProps(key, index, isRenaming)}
|
||||
className={tabClass(active, dragKey === key)}
|
||||
>
|
||||
<span aria-hidden="true" className="text-[var(--text-secondary)]">▣</span>
|
||||
{isRenaming ? (
|
||||
<input
|
||||
ref={renameInputRef}
|
||||
value={renameDraft}
|
||||
aria-label="Rename tab"
|
||||
onChange={(e) => setRenameDraft(e.target.value)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onBlur={() => commitRename(session.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") (e.target as HTMLInputElement).blur();
|
||||
if (e.key === "Escape") setRenamingId(null);
|
||||
}}
|
||||
className="max-w-[180px] px-1 py-0 bg-[var(--bg-primary)] border border-[var(--accent)] rounded-[var(--radius-control)] text-xs text-[var(--text-primary)]"
|
||||
/>
|
||||
) : (
|
||||
<span className="truncate max-w-[180px]" title={displayLabel}>
|
||||
{displayLabel}
|
||||
</span>
|
||||
)}
|
||||
{badge && (
|
||||
<span
|
||||
className={`px-1 py-0.5 rounded-[4px] text-[10px] leading-none font-medium ${badge.className}`}
|
||||
title={`Permission mode: ${badge.text}`}
|
||||
>
|
||||
{badge.text}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
close(session.id);
|
||||
}}
|
||||
aria-label={`Close ${displayLabel}`}
|
||||
title="Close terminal"
|
||||
className="w-6 h-6 flex items-center justify-center rounded-[var(--radius-control)] text-[var(--text-secondary)] hover:text-[var(--error)] hover:bg-[var(--bg-tertiary)] transition-colors"
|
||||
>
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center h-full" role="tablist" aria-label="Open tabs">
|
||||
{tabOrder.map((key) => {
|
||||
const active = activeTabKey === key;
|
||||
|
||||
if (isHomeTab(key)) {
|
||||
const projectId = tabKeyId(key);
|
||||
const project = projects.find((p) => p.id === projectId);
|
||||
if (!project) return null;
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
tabIndex={0}
|
||||
onClick={() => setActiveTabKey(key)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
setActiveTabKey(key);
|
||||
}
|
||||
}}
|
||||
className={tabClass(active)}
|
||||
>
|
||||
<span aria-hidden="true" className="text-[var(--text-secondary)]">⌂</span>
|
||||
<span className="truncate max-w-[160px]" title={`${project.name} — project home`}>
|
||||
{project.name}
|
||||
</span>
|
||||
<ProjectStatusIndicator status={project.status} iconOnly />
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
closeHomeTab(projectId);
|
||||
}}
|
||||
aria-label={`Close ${project.name} home tab`}
|
||||
title="Close tab"
|
||||
className="w-6 h-6 flex items-center justify-center rounded-[var(--radius-control)] text-[var(--text-secondary)] hover:text-[var(--error)] hover:bg-[var(--bg-tertiary)] transition-colors"
|
||||
>
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
<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);
|
||||
}
|
||||
|
||||
const sessionId = tabKeyId(key);
|
||||
const session = sessions.find((s) => s.id === sessionId);
|
||||
if (!session) return null;
|
||||
const project = projects.find((p) => p.id === session.projectId);
|
||||
const customName = getCustomName(session.projectId, session.id);
|
||||
const baseLabel =
|
||||
(session.sessionName ?? session.projectName) +
|
||||
(session.sessionType === "bash" ? " (bash)" : "");
|
||||
const displayLabel = customName
|
||||
? `${session.projectName}: ${customName}`
|
||||
: baseLabel;
|
||||
const isRenaming = renamingId === session.id;
|
||||
const badge = project ? MODE_BADGE[effectivePermissionMode(project)] : null;
|
||||
|
||||
}}
|
||||
>
|
||||
{tabOrder.map((key, index) => {
|
||||
const tab = renderTab(key, index);
|
||||
if (!tab) return null;
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
tabIndex={0}
|
||||
onClick={() => setActiveTabKey(terminalTabKey(session.id))}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
setActiveTabKey(terminalTabKey(session.id));
|
||||
}
|
||||
}}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
setMenu({ sessionId: session.id, x: e.clientX, y: e.clientY });
|
||||
}}
|
||||
onDoubleClick={() => startRename(session.id)}
|
||||
className={tabClass(active)}
|
||||
>
|
||||
<span aria-hidden="true" className="text-[var(--text-secondary)]">▣</span>
|
||||
{isRenaming ? (
|
||||
<input
|
||||
ref={renameInputRef}
|
||||
value={renameDraft}
|
||||
aria-label="Rename tab"
|
||||
onChange={(e) => setRenameDraft(e.target.value)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onBlur={() => commitRename(session.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") (e.target as HTMLInputElement).blur();
|
||||
if (e.key === "Escape") setRenamingId(null);
|
||||
}}
|
||||
className="max-w-[180px] px-1 py-0 bg-[var(--bg-primary)] border border-[var(--accent)] rounded-[var(--radius-control)] text-xs text-[var(--text-primary)]"
|
||||
/>
|
||||
) : (
|
||||
<span className="truncate max-w-[180px]" title={displayLabel}>
|
||||
{displayLabel}
|
||||
</span>
|
||||
)}
|
||||
{badge && (
|
||||
<span
|
||||
className={`px-1 py-0.5 rounded-[4px] text-[10px] leading-none font-medium ${badge.className}`}
|
||||
title={`Permission mode: ${badge.text}`}
|
||||
>
|
||||
{badge.text}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
close(session.id);
|
||||
}}
|
||||
aria-label={`Close ${displayLabel}`}
|
||||
title="Close terminal"
|
||||
className="w-6 h-6 flex items-center justify-center rounded-[var(--radius-control)] text-[var(--text-secondary)] hover:text-[var(--error)] hover:bg-[var(--bg-tertiary)] transition-colors"
|
||||
>
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
<Fragment key={key}>
|
||||
{dragKey !== null && dropIndex === index && dropMarker}
|
||||
{tab}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 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>
|
||||
|
||||
{menu && (() => {
|
||||
const session = sessions.find((s) => s.id === menu.sessionId);
|
||||
const hasCustom = session
|
||||
|
||||
@@ -13,6 +13,10 @@ const setBrowserViewEnabled = vi.fn<() => Promise<BrowserViewStatus>>();
|
||||
const checkBrowserViewSupport = vi.fn<() => Promise<PlaywrightDetection>>();
|
||||
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 setBrowserViewPopoutAlwaysOnTop = vi.fn<(id: string, onTop: boolean) => Promise<void>>();
|
||||
const pushToast = vi.fn();
|
||||
const setContainerProgress = vi.fn();
|
||||
|
||||
@@ -22,6 +26,11 @@ vi.mock("../../../lib/tauri-commands", () => ({
|
||||
checkBrowserViewSupport: () => checkBrowserViewSupport(),
|
||||
installBrowserViewSupport: () => installBrowserViewSupport(),
|
||||
installBrowserViewBrowser: (id: string, b: string) => installBrowserViewBrowser(id, b),
|
||||
openBrowserViewPopout: (id: string, onTop: boolean) => openBrowserViewPopout(id, onTop),
|
||||
closeBrowserViewPopout: (id: string) => closeBrowserViewPopout(id),
|
||||
isBrowserViewPopoutOpen: () => isBrowserViewPopoutOpen(),
|
||||
setBrowserViewPopoutAlwaysOnTop: (id: string, onTop: boolean) =>
|
||||
setBrowserViewPopoutAlwaysOnTop(id, onTop),
|
||||
}));
|
||||
|
||||
vi.mock("@tauri-apps/api/event", () => ({
|
||||
@@ -111,8 +120,34 @@ beforeEach(() => {
|
||||
storeState.containerProgress = {};
|
||||
getBrowserViewStatus.mockResolvedValue(OFF);
|
||||
checkBrowserViewSupport.mockResolvedValue(READY);
|
||||
isBrowserViewPopoutOpen.mockResolvedValue(false);
|
||||
openBrowserViewPopout.mockResolvedValue(undefined);
|
||||
closeBrowserViewPopout.mockResolvedValue(undefined);
|
||||
setBrowserViewPopoutAlwaysOnTop.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
const LIVE: BrowserViewStatus = {
|
||||
...OFF,
|
||||
enabled: true,
|
||||
state: "running",
|
||||
url: "http://127.0.0.1:47820/index.html?ws=abc&token=SEKRIT",
|
||||
host_port: 47820,
|
||||
container_port: 39321,
|
||||
started_at: "2026-08-09T10:00:00Z",
|
||||
};
|
||||
|
||||
/** Render with the view already live, which is the only state that pops out. */
|
||||
async function renderLive() {
|
||||
checkBrowserViewSupport.mockResolvedValue({ ...READY, browsers: ["chromium-1200"] });
|
||||
setBrowserViewEnabled.mockResolvedValue(LIVE);
|
||||
render(<BrowserTab project={project} active />);
|
||||
await waitFor(() => expect(getBrowserViewStatus).toHaveBeenCalled());
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: /start browser view/i }));
|
||||
});
|
||||
await screen.findByTitle("Playwright browser view for api-server");
|
||||
}
|
||||
|
||||
describe("BrowserTab", () => {
|
||||
it("does not offer to start anything while the container is stopped", async () => {
|
||||
render(<BrowserTab project={{ ...project, status: "stopped" }} active />);
|
||||
@@ -325,4 +360,83 @@ describe("BrowserTab", () => {
|
||||
expect(await screen.findByText("Off")).toBeInTheDocument();
|
||||
expect(screen.queryByTitle(/browser view for/i)).toBeNull();
|
||||
});
|
||||
|
||||
it("only offers a window of its own once there is something to watch", async () => {
|
||||
checkBrowserViewSupport.mockResolvedValue({ ...READY, browsers: ["chromium-1200"] });
|
||||
render(<BrowserTab project={project} active />);
|
||||
await waitFor(() => expect(getBrowserViewStatus).toHaveBeenCalled());
|
||||
expect(screen.queryByRole("button", { name: /own window/i })).toBeNull();
|
||||
});
|
||||
|
||||
it("pops the live view out, and drops the iframe so only one viewer drives", async () => {
|
||||
await renderLive();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: /own window/i }));
|
||||
});
|
||||
|
||||
expect(openBrowserViewPopout).toHaveBeenCalledWith("p1", false);
|
||||
// The window is showing it now — a second copy here would be a second
|
||||
// cursor on the same page.
|
||||
expect(screen.queryByTitle(/browser view for/i)).toBeNull();
|
||||
expect(await screen.findByText(/in its own window/i)).toBeInTheDocument();
|
||||
// Still live, and still stoppable from the tab.
|
||||
expect(screen.getByText("Live")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Stop" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("puts the view back in the tab when the window is closed from here", async () => {
|
||||
await renderLive();
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: /own window/i }));
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getAllByRole("button", { name: /put back in tab/i })[0]);
|
||||
});
|
||||
|
||||
expect(closeBrowserViewPopout).toHaveBeenCalledWith("p1");
|
||||
expect(await screen.findByTitle("Playwright browser view for api-server")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("pins the window on top on request", async () => {
|
||||
await renderLive();
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: /own window/i }));
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("switch", { name: /above other windows/i }));
|
||||
});
|
||||
|
||||
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
|
||||
// state back — otherwise the pane would render an iframe alongside it.
|
||||
isBrowserViewPopoutOpen.mockResolvedValue(true);
|
||||
checkBrowserViewSupport.mockResolvedValue({ ...READY, browsers: ["chromium-1200"] });
|
||||
getBrowserViewStatus.mockResolvedValue(LIVE);
|
||||
|
||||
render(<BrowserTab project={project} active />);
|
||||
|
||||
expect(await screen.findByText(/in its own window/i)).toBeInTheDocument();
|
||||
expect(screen.queryByTitle(/browser view for/i)).toBeNull();
|
||||
});
|
||||
|
||||
it("says why the window wouldn’t open instead of pretending it did", async () => {
|
||||
await renderLive();
|
||||
openBrowserViewPopout.mockRejectedValue("no display");
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: /own window/i }));
|
||||
});
|
||||
|
||||
expect(pushToast).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ kind: "error", detail: "no display" }),
|
||||
);
|
||||
// The view is still in the tab, where it was.
|
||||
expect(screen.getByTitle("Playwright browser view for api-server")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,21 +4,27 @@ import type {
|
||||
BrowserInstallTarget,
|
||||
BrowserSetupOutcome,
|
||||
BrowserViewChangedEvent,
|
||||
BrowserViewPopoutChangedEvent,
|
||||
BrowserViewStatus,
|
||||
PlaywrightDetection,
|
||||
Project,
|
||||
} from "../../../lib/types";
|
||||
import {
|
||||
checkBrowserViewSupport,
|
||||
closeBrowserViewPopout,
|
||||
getBrowserViewStatus,
|
||||
installBrowserViewBrowser,
|
||||
installBrowserViewSupport,
|
||||
isBrowserViewPopoutOpen,
|
||||
openBrowserViewPopout,
|
||||
setBrowserViewEnabled,
|
||||
setBrowserViewPopoutAlwaysOnTop,
|
||||
} from "../../../lib/tauri-commands";
|
||||
import { useAppState } from "../../../store/appState";
|
||||
import AccordionSection from "../../ui/AccordionSection";
|
||||
import Button from "../../ui/Button";
|
||||
import StatusIndicator from "../../ui/StatusIndicator";
|
||||
import Toggle from "../../ui/Toggle";
|
||||
|
||||
interface Props {
|
||||
project: Project;
|
||||
@@ -66,6 +72,9 @@ 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);
|
||||
const [onTop, setOnTop] = useState(false);
|
||||
const pushToast = useAppState((s) => s.pushToast);
|
||||
const setContainerProgress = useAppState((s) => s.setContainerProgress);
|
||||
const progress = useAppState((s) => s.containerProgress[project.id]);
|
||||
@@ -95,8 +104,28 @@ export default function BrowserTab({ project, active }: Props) {
|
||||
return () => dispose?.();
|
||||
}, [projectId]);
|
||||
|
||||
// The window is the backend's, not this component's: it survives the tab
|
||||
// being closed, the pane being unmounted and the view being torn down from
|
||||
// elsewhere. So its state is listened for, never assumed.
|
||||
useEffect(() => {
|
||||
let dispose: (() => void) | undefined;
|
||||
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);
|
||||
}
|
||||
}).then((un) => {
|
||||
if (mounted.current) dispose = un;
|
||||
else un();
|
||||
});
|
||||
return () => dispose?.();
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active || !running) return;
|
||||
isBrowserViewPopoutOpen(projectId)
|
||||
.then((open) => mounted.current && setPoppedOut(open))
|
||||
.catch(() => {});
|
||||
getBrowserViewStatus(projectId)
|
||||
.then((s) => mounted.current && setStatus(s))
|
||||
.catch(() => {});
|
||||
@@ -129,6 +158,56 @@ export default function BrowserTab({ project, active }: Props) {
|
||||
[projectId, pushToast],
|
||||
);
|
||||
|
||||
/**
|
||||
* Pop the view out, or pull it back.
|
||||
*
|
||||
* Both are window operations only — the viewer keeps running either way — so
|
||||
* this is cheap enough to toggle freely and never interrupts what the agent
|
||||
* is doing in the browser.
|
||||
*/
|
||||
const popOut = useCallback(async () => {
|
||||
try {
|
||||
await openBrowserViewPopout(projectId, onTop);
|
||||
if (mounted.current) setPoppedOut(true);
|
||||
} catch (e) {
|
||||
pushToast({
|
||||
kind: "error",
|
||||
message: "Could not open the browser in its own window",
|
||||
detail: String(e),
|
||||
});
|
||||
}
|
||||
}, [projectId, onTop, pushToast]);
|
||||
|
||||
const popIn = useCallback(async () => {
|
||||
try {
|
||||
await closeBrowserViewPopout(projectId);
|
||||
if (mounted.current) setPoppedOut(false);
|
||||
} catch (e) {
|
||||
pushToast({
|
||||
kind: "error",
|
||||
message: "Could not close the browser window",
|
||||
detail: String(e),
|
||||
});
|
||||
}
|
||||
}, [projectId, pushToast]);
|
||||
|
||||
const toggleOnTop = useCallback(
|
||||
async (next: boolean) => {
|
||||
setOnTop(next);
|
||||
try {
|
||||
await setBrowserViewPopoutAlwaysOnTop(projectId, next);
|
||||
} catch (e) {
|
||||
if (mounted.current) setOnTop(!next);
|
||||
pushToast({
|
||||
kind: "error",
|
||||
message: "Could not change the window's stacking",
|
||||
detail: String(e),
|
||||
});
|
||||
}
|
||||
},
|
||||
[projectId, pushToast],
|
||||
);
|
||||
|
||||
/** Run one install. Every path clears the progress line it started. */
|
||||
const install = useCallback(
|
||||
async (which: Exclude<SetupJob, null>) => {
|
||||
@@ -225,11 +304,26 @@ export default function BrowserTab({ project, active }: Props) {
|
||||
</span>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
{live && (
|
||||
{live && poppedOut && (
|
||||
<label 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>
|
||||
)}
|
||||
{live && !poppedOut && (
|
||||
<Button size="md" onClick={() => setReloadKey((k) => k + 1)}>
|
||||
Reload
|
||||
</Button>
|
||||
)}
|
||||
{live && (
|
||||
<Button size="md" onClick={poppedOut ? popIn : popOut}>
|
||||
{poppedOut ? "Put back in tab" : "Open in own window"}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="md"
|
||||
variant={live ? "secondary" : "primary"}
|
||||
@@ -240,7 +334,28 @@ export default function BrowserTab({ project, active }: Props) {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{live ? (
|
||||
{live && poppedOut ? (
|
||||
// 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.
|
||||
<div className="flex-1 min-h-0 flex items-center justify-center p-6">
|
||||
<div className="max-w-[28rem] text-center">
|
||||
<h2 className="text-[13px] font-semibold text-[var(--text-primary)]">
|
||||
This view is in its own window.
|
||||
</h2>
|
||||
<p className="mt-1 text-[13px] text-[var(--text-secondary)] leading-relaxed">
|
||||
Move it to another screen, or keep it on top, and watch the browser while
|
||||
you work here. The view keeps running either way — closing the window
|
||||
brings it back into this tab.
|
||||
</p>
|
||||
<div className="mt-3 flex items-center justify-center gap-2">
|
||||
<Button size="md" variant="primary" onClick={popIn}>
|
||||
Put back in tab
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : live ? (
|
||||
<iframe
|
||||
key={reloadKey}
|
||||
// Loopback only, and the URL carries the one-time session token the
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useTerminal } from "./useTerminal";
|
||||
* Ctrl+Shift+W close the active tab
|
||||
* Ctrl+Tab next tab (Ctrl+Shift+Tab for previous)
|
||||
* Ctrl+1..9 jump to the nth tab
|
||||
* Ctrl+Shift+←/→ move the active tab along the strip
|
||||
*/
|
||||
export function useKeyboardShortcuts() {
|
||||
const { open: openTerminal, close: closeTerminal } = useTerminal();
|
||||
@@ -51,6 +52,17 @@ export function useKeyboardShortcuts() {
|
||||
return;
|
||||
}
|
||||
|
||||
// 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.
|
||||
if (e.shiftKey && (e.key === "ArrowLeft" || e.key === "ArrowRight")) {
|
||||
if (!state.activeTabKey) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
state.moveActiveTab(e.key === "ArrowLeft" ? -1 : 1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.shiftKey) return;
|
||||
|
||||
// Ctrl+1..9 — jump to tab
|
||||
|
||||
@@ -201,6 +201,25 @@ export const installBrowserViewBrowser = (
|
||||
browser: BrowserInstallTarget,
|
||||
) => invoke<BrowserSetupOutcome>("install_browser_view_browser", { projectId, browser });
|
||||
|
||||
/**
|
||||
* Detach the live view into its own OS window, or raise it if already open.
|
||||
*
|
||||
* Window-only: the viewer, the proxy and the container are untouched, so
|
||||
* popping out and back costs nothing. The window loads the same token-bearing
|
||||
* loopback URL as the pane, and has no IPC access.
|
||||
*/
|
||||
export const openBrowserViewPopout = (projectId: string, alwaysOnTop: boolean) =>
|
||||
invoke<void>("open_browser_view_popout", { projectId, alwaysOnTop });
|
||||
/** 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 });
|
||||
/** 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 });
|
||||
|
||||
// Shared Claude Code auth token — one `claude setup-token` run authenticates
|
||||
// every Anthropic-backend project. The token itself is never exposed here: it
|
||||
// lives in the OS keychain and is injected as a container env var.
|
||||
|
||||
@@ -514,6 +514,18 @@ export interface BrowserViewChangedEvent {
|
||||
status: BrowserViewStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload of the `browser-view-popout-changed` event.
|
||||
*
|
||||
* 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 {
|
||||
project_id: string;
|
||||
open: boolean;
|
||||
}
|
||||
|
||||
/** Payload of the `claude-token-progress` event: milestones during
|
||||
* `acquire_claude_token`. Never contains the token. */
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { useAppState, homeTabKey, terminalTabKey } from "./appState";
|
||||
|
||||
const A = homeTabKey("a");
|
||||
const B = terminalTabKey("b");
|
||||
const C = terminalTabKey("c");
|
||||
|
||||
function seed(tabOrder: string[], activeTabKey: string | null = null) {
|
||||
useAppState.setState({
|
||||
tabOrder,
|
||||
activeTabKey,
|
||||
activeSessionId: null,
|
||||
selectedProjectId: null,
|
||||
});
|
||||
}
|
||||
|
||||
const order = () => useAppState.getState().tabOrder;
|
||||
|
||||
describe("tab reordering", () => {
|
||||
beforeEach(() => seed([A, B, C]));
|
||||
|
||||
it("moves a tab to an earlier slot", () => {
|
||||
useAppState.getState().moveTab(C, 0);
|
||||
expect(order()).toEqual([C, A, B]);
|
||||
});
|
||||
|
||||
it("moves a tab to a later slot", () => {
|
||||
useAppState.getState().moveTab(A, 2);
|
||||
expect(order()).toEqual([B, C, A]);
|
||||
});
|
||||
|
||||
it("clamps a destination past the ends rather than dropping the tab", () => {
|
||||
useAppState.getState().moveTab(A, 99);
|
||||
expect(order()).toEqual([B, C, A]);
|
||||
useAppState.getState().moveTab(A, -5);
|
||||
expect(order()).toEqual([A, B, C]);
|
||||
});
|
||||
|
||||
it("ignores a tab that isn't in the strip", () => {
|
||||
useAppState.getState().moveTab("term:gone", 0);
|
||||
expect(order()).toEqual([A, B, C]);
|
||||
});
|
||||
|
||||
it("does not change what's active — dragging a tab is not selecting it", () => {
|
||||
seed([A, B, C], B);
|
||||
useAppState.getState().moveTab(C, 0);
|
||||
const state = useAppState.getState();
|
||||
expect(state.tabOrder).toEqual([C, A, B]);
|
||||
expect(state.activeTabKey).toBe(B);
|
||||
});
|
||||
|
||||
it("nudges the active tab with the keyboard, in both directions", () => {
|
||||
seed([A, B, C], B);
|
||||
useAppState.getState().moveActiveTab(-1);
|
||||
expect(order()).toEqual([B, A, C]);
|
||||
useAppState.getState().moveActiveTab(1);
|
||||
expect(order()).toEqual([A, B, C]);
|
||||
});
|
||||
|
||||
it("stops the active tab at the ends instead of wrapping it around", () => {
|
||||
seed([A, B, C], A);
|
||||
useAppState.getState().moveActiveTab(-1);
|
||||
// A held-down key must not teleport the tab to the far end.
|
||||
expect(order()).toEqual([A, B, C]);
|
||||
});
|
||||
|
||||
it("does nothing when no tab is active", () => {
|
||||
seed([A, B, C], null);
|
||||
useAppState.getState().moveActiveTab(1);
|
||||
expect(order()).toEqual([A, B, C]);
|
||||
});
|
||||
|
||||
it("keeps Ctrl+1..9 addressing the strip as reordered", () => {
|
||||
seed([A, B, C], A);
|
||||
useAppState.getState().moveTab(C, 0);
|
||||
useAppState.getState().focusTabIndex(0);
|
||||
expect(useAppState.getState().activeTabKey).toBe(C);
|
||||
});
|
||||
});
|
||||
@@ -75,6 +75,10 @@ interface AppState {
|
||||
setActiveTabKey: (key: string) => void;
|
||||
cycleTab: (delta: number) => void;
|
||||
focusTabIndex: (index: number) => void;
|
||||
/** Reorder: put `key` at `toIndex` in the strip. Never changes what's active. */
|
||||
moveTab: (key: string, toIndex: number) => void;
|
||||
/** Nudge the active tab left/right — the keyboard route to the same thing. */
|
||||
moveActiveTab: (delta: number) => void;
|
||||
|
||||
// Inline container progress, replacing the blocking progress modal.
|
||||
containerProgress: Record<string, string>;
|
||||
@@ -274,6 +278,35 @@ export const useAppState = create<AppState>((set) => ({
|
||||
? { ...patch, selectedProjectId: tabKeyId(key) }
|
||||
: patch;
|
||||
}),
|
||||
// Reordering is deliberately *only* a reordering: dragging a tab does not
|
||||
// select it, so a drag can be aimed at a background tab without yanking the
|
||||
// main area (and a running terminal's focus) away mid-gesture.
|
||||
moveTab: (key, toIndex) =>
|
||||
set((state) => {
|
||||
const from = state.tabOrder.indexOf(key);
|
||||
if (from === -1) return {};
|
||||
const to = Math.max(0, Math.min(toIndex, state.tabOrder.length - 1));
|
||||
if (from === to) return {};
|
||||
const tabOrder = [...state.tabOrder];
|
||||
tabOrder.splice(from, 1);
|
||||
tabOrder.splice(to, 0, key);
|
||||
return { tabOrder };
|
||||
}),
|
||||
moveActiveTab: (delta) =>
|
||||
set((state) => {
|
||||
const key = state.activeTabKey;
|
||||
if (!key) return {};
|
||||
const from = state.tabOrder.indexOf(key);
|
||||
if (from === -1) return {};
|
||||
// Clamped, not wrapped: a tab dragged off the end would otherwise
|
||||
// reappear at the other end, which reads as a bug on a held-down key.
|
||||
const to = Math.max(0, Math.min(from + delta, state.tabOrder.length - 1));
|
||||
if (from === to) return {};
|
||||
const tabOrder = [...state.tabOrder];
|
||||
tabOrder.splice(from, 1);
|
||||
tabOrder.splice(to, 0, key);
|
||||
return { tabOrder };
|
||||
}),
|
||||
|
||||
// Container progress
|
||||
containerProgress: {},
|
||||
|
||||
Reference in New Issue
Block a user