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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user