mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
feat(studio): one way into the workbench
The title-bar Browser button and the Workbench tab opened the same column but the button also asked for a blank browser page. The tab is now the only entry.
This commit is contained in:
1 parent
a22ee52eaa
commit
aeb46aeb4e
22 files changed
+63
-274
No files matched your search
@@ -33,7 +33,7 @@ for (const scheme of ["light", "dark"]) {
|
||||
for (const round of ["first open", "after a reload"]) {
|
||||
await page.goto(`${BASE}?pref=en&turns=3`, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".app");
|
||||
await page.locator('[data-action="browser.open"]').first().click();
|
||||
await page.locator('[data-action="pane.view"][data-value="browser"]').first().click();
|
||||
await page.waitForTimeout(700);
|
||||
const open = await geometry(page);
|
||||
docked = open;
|
||||
@@ -51,7 +51,7 @@ for (const scheme of ["light", "dark"]) {
|
||||
}
|
||||
if (SHOTS && round === "first open") await page.screenshot({ path: `${SHOTS}/${process.env.DOCK_TAG ?? "shot"}-${tag.replace("/", "-")}.png` });
|
||||
}
|
||||
await page.locator('[data-action="browser.open"]').first().click();
|
||||
await page.locator('[data-action="pane.view"][data-value="flow"]').first().click();
|
||||
await page.waitForTimeout(700);
|
||||
const shut = await geometry(page);
|
||||
check(`${tag} undocked: the column is gone and the navigation row is as wide as the pane's body again`, (!shut.side || shut.side.left >= shut.pane.right - 1) && !!shut.tabs && shut.tabs.right - shut.tabs.left >= shut.pane.right - shut.pane.left - 24, JSON.stringify([shut.side, shut.tabs, shut.pane]));
|
||||
@@ -67,7 +67,7 @@ for (const scheme of ["light", "dark"]) {
|
||||
await page.setViewportSize({ width, height: 800 });
|
||||
await page.goto(`${BASE}?pref=en&turns=3`, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".app");
|
||||
await page.locator('[data-action="browser.open"]').first().click();
|
||||
await page.locator('[data-action="pane.view"][data-value="browser"]').first().click();
|
||||
await page.waitForTimeout(400);
|
||||
const g = await page.evaluate(() => {
|
||||
const tabs = document.querySelector(".pane > .tabs");
|
||||
|
||||
@@ -50,7 +50,7 @@ const probe = (page) =>
|
||||
};
|
||||
});
|
||||
|
||||
const CONTROLS = [".phone-action", ".find-action", ".theme-toggle", ".top-action", ".browser-action"];
|
||||
const CONTROLS = [".phone-action", ".find-action", ".theme-toggle", ".top-action"];
|
||||
|
||||
// Opens the fold when there is one, then presses nothing: asks whether each control is on screen with nothing over it.
|
||||
async function reachable(page) {
|
||||
|
||||
@@ -18,11 +18,6 @@ for (const scheme of ["light", "dark"]) {
|
||||
await page.goto(`${BASE}?pref=en&turns=2`, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".app");
|
||||
const tag = `${scheme}/${label}`;
|
||||
const tabs = () => page.evaluate(() => document.querySelectorAll(".workbench-tab").length);
|
||||
await page.locator('[data-action="browser.open"]').first().click();
|
||||
await page.waitForTimeout(700);
|
||||
const direct = await page.evaluate(() => ({ launcher: !!document.querySelector(".workbench-launcher"), tabs: document.querySelectorAll(".workbench-tab").length }));
|
||||
check(`${tag}: the title-bar Browser button goes straight to a browser`, direct.tabs === 1 && !direct.launcher, JSON.stringify(direct));
|
||||
await page.goto(`${BASE}?pref=en&turns=2`, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".app");
|
||||
await page.locator('[data-action="pane.view"][data-value="browser"]').click();
|
||||
|
||||
@@ -117,8 +117,6 @@ export const EN_WINDOW: Record<string, string> = {
|
||||
"Reasonix 内置 Browser": "Reasonix built-in Browser",
|
||||
"部分网站可能禁止嵌入;此时可在外部浏览器打开。": "Some sites block embedding; open them in your external browser instead.",
|
||||
"正在打开网页…": "Opening page…",
|
||||
"收起工作台": "Collapse the workbench",
|
||||
"打开内置 Browser": "Open the built-in Browser",
|
||||
"在上方地址栏输入网址,即可在当前任务旁浏览资料。": "Enter an address above to browse beside the current task.",
|
||||
"浏览内容不会替换或中断当前会话。部分网站禁止嵌入时,可使用右上角的外部打开。": "Browsing will not replace or interrupt the current task. If a site blocks embedding, use Open externally in the toolbar.",
|
||||
"点击步骤可原位查看详情、结果或文件差异": "Select a step to inspect its details, result, or file diff in place",
|
||||
|
||||
@@ -25,10 +25,6 @@ const DECORATIVE: [string, string, string[]][] = [
|
||||
["studio", ".chipmirror .skillchip", ["--brand", "--brand-wash"]],
|
||||
["studio", ".studio-wallet > span", ["--brand"]],
|
||||
["studio", ':root[data-theme="light"] .studio-wallet > span', ["--brand"]],
|
||||
["studio", ".chrome .browser-action", ["--brand"]],
|
||||
["studio", '.chrome .browser-action[aria-pressed="true"]', ["--brand-wash"]],
|
||||
["studio", ':root[data-theme="light"] .chrome .browser-action', ["--brand"]],
|
||||
["studio", ':root[data-theme="light"] .chrome .browser-action[aria-pressed="true"]', ["--brand-wash"]],
|
||||
["studio", ".flow .opt[data-on]", ["--brand-wash"]],
|
||||
["studio", ':root[data-theme="light"] .flow .opt[data-on]', ["--brand-wash"]],
|
||||
["feedback", '.fbk-note[data-tone="info"] > .studio-icon', ["--brand"]],
|
||||
|
||||
@@ -497,8 +497,6 @@ body { background: var(--page) !important; }
|
||||
.chrome .thbtn:hover,
|
||||
.chrome .top-action:hover,
|
||||
.chrome .top-action[aria-pressed="true"] { color: var(--text); background: var(--float); border-color: var(--border); }
|
||||
.chrome .browser-action { color: var(--brand); }
|
||||
.chrome .browser-action[aria-pressed="true"] { color: #c8d3f0; background: var(--brand-wash); }
|
||||
.chrome-context-card {
|
||||
position: absolute; z-index: 80; top: calc(100% + 8px); right: 0; width: 326px;
|
||||
display: grid; gap: 10px; padding: 13px; border: 1px solid var(--border); border-radius: 10px;
|
||||
@@ -2568,8 +2566,6 @@ body { font-size: 14px; line-height: 1.75; }
|
||||
:root[data-theme="light"] .studio-new-task > span,
|
||||
:root[data-theme="light"] .studio-search > span,
|
||||
:root[data-theme="light"] .studio-wallet > span { color: var(--brand); }
|
||||
:root[data-theme="light"] .chrome .browser-action { color: var(--brand); }
|
||||
:root[data-theme="light"] .chrome .browser-action[aria-pressed="true"] { color: #3f568b; background: var(--brand-wash); }
|
||||
:root[data-theme="light"] .studio-browser,
|
||||
:root[data-theme="light"] .studio-browser-frame { background: var(--page); color: var(--text); }
|
||||
:root[data-theme="light"] .studio-browser-toolbar,
|
||||
@@ -2948,8 +2944,6 @@ body { font-size: 14px; line-height: 1.75; }
|
||||
.studio-settings { color: var(--ghost); }
|
||||
.chrome .badge,
|
||||
.chrome .top-action[aria-pressed="true"] { background: var(--overlay); color: var(--muted); border-color: var(--border); }
|
||||
.chrome .browser-action { color: var(--accent); }
|
||||
.chrome .browser-action[aria-pressed="true"] { color: var(--accent); background: var(--accent-wash); }
|
||||
|
||||
.studio-brand-sub { color: var(--faint); }
|
||||
.studio-settings:hover,
|
||||
@@ -4203,7 +4197,6 @@ body { font-size: 14px; line-height: 1.75; }
|
||||
--pal-accent: var(--default-accent);
|
||||
}
|
||||
|
||||
:root[data-pack] .chrome .browser-action[aria-pressed="true"] { color: var(--accent-ink); background: var(--accent-wash); }
|
||||
:root[data-pack] .chrome-context-card section > button { color: var(--link); }
|
||||
:root[data-pack] .studio-brand-accent { color: var(--brand); }
|
||||
:root[data-pack] .navbtn[aria-current],
|
||||
|
||||
@@ -119,7 +119,6 @@ export function App({ hub }: { hub: HubPort }) {
|
||||
const [feedback, setFeedback] = useState<FeedbackTab | null>(null);
|
||||
const [feedbackUnread, setFeedbackUnread] = useState(0);
|
||||
const [browser, setBrowser] = useState(false);
|
||||
const [browserAsk, setBrowserAsk] = useState(0);
|
||||
const [setup, setSetup] = useState<ProviderSetup | null | undefined>(undefined);
|
||||
// undefined until asked; false means the opening sequence is still owed.
|
||||
const [welcomed, setWelcomed] = useState<boolean | undefined>(undefined);
|
||||
@@ -564,11 +563,6 @@ export function App({ hub }: { hub: HubPort }) {
|
||||
title={tabs.find((tab) => tab.rt.id === active)?.title ?? report.title}
|
||||
steer={report.steer}
|
||||
onSettings={showPrefs}
|
||||
onBrowser={() => {
|
||||
if (!browser) setBrowserAsk((n) => n + 1);
|
||||
setBrowser((value) => !value);
|
||||
}}
|
||||
browser={browser}
|
||||
account={account}
|
||||
theme={scheme}
|
||||
onTheme={() => setTheme(scheme === "dark" ? "light" : "dark")}
|
||||
@@ -702,7 +696,6 @@ export function App({ hub }: { hub: HubPort }) {
|
||||
dockMax={dockLimit}
|
||||
onDockW={onDockW}
|
||||
manualBrowser={rt.id === active && browser}
|
||||
browserAsk={browserAsk}
|
||||
onManualBrowser={setBrowser}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
@@ -27,8 +27,6 @@ interface Props {
|
||||
title?: string;
|
||||
steer: number;
|
||||
onSettings: (section?: string) => void;
|
||||
onBrowser: () => void;
|
||||
browser: boolean;
|
||||
account: AccountState | null;
|
||||
theme: string;
|
||||
onTheme: () => void;
|
||||
@@ -38,7 +36,7 @@ interface Props {
|
||||
onError?: (e: unknown) => void;
|
||||
}
|
||||
|
||||
export function Chrome({ port, status, title, steer, onSettings, onBrowser, browser, account, host, theme, onTheme, onFind, hub, onError }: Props) {
|
||||
export function Chrome({ port, status, title, steer, onSettings, account, host, theme, onTheme, onFind, hub, onError }: Props) {
|
||||
const root = status?.workspaceRoot || status?.cwd || "";
|
||||
const project = root ? base(root) : "—";
|
||||
// The folder mark opens the project itself. Only a shell that can enter a
|
||||
@@ -149,9 +147,6 @@ export function Chrome({ port, status, title, steer, onSettings, onBrowser, brow
|
||||
<button className="top-action" data-action="settings.section" data-value="tools" onClick={() => onSettings("tools")} aria-label={t("沙盒")} title={t("沙盒与运行边界")}>
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="m8 2.5 5 2.7v5.6l-5 2.7-5-2.7V5.2Z" /><path d="m3 5.2 5 2.7 5-2.7M8 7.9v5.6" /></svg><span>{t("沙盒")}</span>
|
||||
</button>
|
||||
<button className="thbtn browser-action" data-action="browser.open" onClick={onBrowser} aria-pressed={browser} aria-label="Browser" title={browser ? t("收起工作台") : t("打开内置 Browser")}>
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true"><circle cx="8" cy="8" r="5.4" /><path d="M2.8 8h10.4M8 2.6c1.5 1.5 2.3 3.3 2.3 5.4S9.5 11.9 8 13.4C6.5 11.9 5.7 10.1 5.7 8S6.5 4.1 8 2.6Z" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
<span className="account-presence" title={account?.signedIn ? account.user?.label : t("未登录")} />
|
||||
<WindowControls />
|
||||
|
||||
@@ -81,7 +81,7 @@ const totalsOf = (st: SessionStatus) => ({
|
||||
incompleteReason: st.sessionCostQuote?.incompleteReason,
|
||||
});
|
||||
|
||||
function PaneView({ port, rt, title, active, visible, sideHost, side, onFocus, onReport, onSessionChanged, onTurnDone, pulse, findPulse, filesPulse = 0, onSettings, onOpenSession, needsProject, onOpenProject, onKeepHere, connect, theme, dockW, dockMax, onDockW, manualBrowser = false, browserAsk = 0, onManualBrowser, alert }: PaneProps) {
|
||||
function PaneView({ port, rt, title, active, visible, sideHost, side, onFocus, onReport, onSessionChanged, onTurnDone, pulse, findPulse, filesPulse = 0, onSettings, onOpenSession, needsProject, onOpenProject, onKeepHere, connect, theme, dockW, dockMax, onDockW, manualBrowser = false, onManualBrowser, alert }: PaneProps) {
|
||||
const [s, dispatch] = useReducer(reduce, initialState);
|
||||
const [traj, trajDispatch] = useReducer(reduceTraj, initialTraj);
|
||||
const [sessionRead, setSessionRead] = useState<SessionRead>({ kind: "pending" });
|
||||
@@ -521,7 +521,6 @@ function PaneView({ port, rt, title, active, visible, sideHost, side, onFocus, o
|
||||
tabs={pages}
|
||||
wideMode={docked ? { wide: browserSize.size === "wide", onToggle: browserSize.toggle } : undefined}
|
||||
manual={manualBrowser}
|
||||
browserAsk={browserAsk}
|
||||
shown={visible && workbench}
|
||||
onSurfaces={setSurfaces}
|
||||
scheme={theme === "light" ? "light" : "dark"}
|
||||
|
||||
@@ -260,18 +260,6 @@ describe("WorkbenchPanel", () => {
|
||||
} finally { views.mockRestore(); hide.mockRestore(); vi.unstubAllGlobals(); }
|
||||
});
|
||||
|
||||
it("dismisses the launcher when the title-bar Browser is reopened on an iframe host", async () => {
|
||||
const user = userEvent.setup();
|
||||
const props = { port: new MockPort(), tabs: [], manual: true, shown: true, scheme: "light" as const, changes: [], onCloseManual: vi.fn(), onSurfaces: vi.fn(), onExternal: vi.fn() };
|
||||
const { rerender } = render(<WorkbenchPanel {...props} browserAsk={0} />);
|
||||
await user.click(screen.getByRole("button", { name: "新建标签" }));
|
||||
rerender(<WorkbenchPanel {...props} manual={false} browserAsk={1} />);
|
||||
rerender(<WorkbenchPanel {...props} browserAsk={2} />);
|
||||
expect(screen.queryByRole("group", { name: "开始" })).toBeNull();
|
||||
expect(screen.getByRole("tab", { name: "浏览器" }).getAttribute("aria-selected")).toBe("true");
|
||||
expect(screen.getByRole("region", { name: "内置 Browser" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it.each(["escape", "plus"])("restores the explorer when new-tab choice is cancelled with %s", async (cancel) => {
|
||||
const user = userEvent.setup();
|
||||
const { container } = render(<WorkbenchPanel port={new MockPort()} tabs={[]} manual shown scheme="light" changes={[]} onCloseManual={vi.fn()} onSurfaces={vi.fn()} onExternal={vi.fn()} />);
|
||||
@@ -283,7 +271,7 @@ describe("WorkbenchPanel", () => {
|
||||
expect(container.querySelector(".workbench-body")?.hasAttribute("data-files")).toBe(true);
|
||||
});
|
||||
|
||||
it.each([[false, false], [true, false], [false, true]])("respects newer Files choices (reopen: %s, title bar: %s)", async (reopen, titleBar) => {
|
||||
it.each([false, true])("respects newer Files choices (reopen: %s)", async (reopen) => {
|
||||
const user = userEvent.setup();
|
||||
const port = new MockPort();
|
||||
const views = vi.spyOn(hostModule.host(), "drawsBrowserViews").mockReturnValue(true);
|
||||
@@ -291,12 +279,9 @@ describe("WorkbenchPanel", () => {
|
||||
vi.spyOn(port, "browserOpen").mockImplementation(() => new Promise((resolve) => { finish = resolve; }));
|
||||
try {
|
||||
const props = { port, tabs: [], manual: true, shown: true, scheme: "light" as const, changes: [], onCloseManual: vi.fn(), onSurfaces: vi.fn(), onExternal: vi.fn() };
|
||||
const { container, rerender } = render(<WorkbenchPanel {...props} browserAsk={0} />);
|
||||
if (titleBar) rerender(<WorkbenchPanel {...props} browserAsk={1} />);
|
||||
else {
|
||||
await user.click(screen.getByRole("button", { name: "新建标签" }));
|
||||
await user.click(screen.getByRole("button", { name: "浏览器" }));
|
||||
}
|
||||
const { container } = render(<WorkbenchPanel {...props} />);
|
||||
await user.click(screen.getByRole("button", { name: "新建标签" }));
|
||||
await user.click(screen.getByRole("button", { name: "浏览器" }));
|
||||
await user.click(container.querySelector('[data-action="workbench.launch-files"]')!);
|
||||
if (reopen) {
|
||||
await user.click(screen.getByRole("button", { name: "新建标签" }));
|
||||
|
||||
@@ -105,7 +105,6 @@ export function WorkbenchPanel({
|
||||
port,
|
||||
tabs: allTabs,
|
||||
manual,
|
||||
browserAsk = 0,
|
||||
shown,
|
||||
scheme,
|
||||
changes,
|
||||
@@ -128,7 +127,6 @@ export function WorkbenchPanel({
|
||||
tabs: BrowserTab[];
|
||||
manual: boolean;
|
||||
/** Counts the title-bar Browser presses; a change asks for a browser, not just the column. */
|
||||
browserAsk?: number;
|
||||
shown: boolean;
|
||||
scheme: "light" | "dark";
|
||||
changes: WorkspaceChange[];
|
||||
@@ -277,7 +275,7 @@ export function WorkbenchPanel({
|
||||
const agentNow = useRef(agentTarget);
|
||||
agentNow.current = agentTarget;
|
||||
const filesAsk = useMemo(() => ({ revealChanges, fileRequest }), [revealChanges, fileRequest]);
|
||||
useColumnOpening({ manual, browserAsk, filesAsk, port, agentNow, dropBlankStart, pick, follow, current, setFailed, setBrowsers, manualSelection: selected.startsWith("manual:") ? selected : `manual:${browsers[0] ?? "b0"}` });
|
||||
useColumnOpening({ manual, filesAsk, port, agentNow, dropBlankStart, follow, current, setFailed });
|
||||
// A page that has just appeared is the one somebody wants to see, whoever
|
||||
// opened it: the agent's popup, or a link the person followed, which opens
|
||||
// beside the agent's tab without becoming the one it acts on.
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import "./testkit";
|
||||
import { App } from "./App";
|
||||
import { MockHub } from "../port/mock_hub";
|
||||
import { boot, STORAGE } from "../i18n";
|
||||
import { refresh } from "./viewport";
|
||||
|
||||
// jsdom lays nothing out, so the width the fold reads is whatever is said here.
|
||||
function room(width: number) {
|
||||
vi.spyOn(document.body, "clientWidth", "get").mockReturnValue(width);
|
||||
vi.spyOn(document.body, "clientHeight", "get").mockReturnValue(900);
|
||||
refresh();
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await import("./Settings");
|
||||
sessionStorage.clear();
|
||||
localStorage.clear();
|
||||
localStorage.setItem(STORAGE, "zh");
|
||||
boot();
|
||||
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(null);
|
||||
room(1440);
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
sessionStorage.clear();
|
||||
localStorage.clear();
|
||||
delete document.documentElement.dataset.fold;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
async function open() {
|
||||
const hub = new MockHub();
|
||||
const build = hub.portFor.bind(hub);
|
||||
const prepared = new WeakSet();
|
||||
hub.portFor = (rt) => {
|
||||
const port = build(rt);
|
||||
if (!prepared.has(port)) {
|
||||
prepared.add(port);
|
||||
port.providerSetup = async () => null;
|
||||
port.welcomeSeen = async () => true;
|
||||
}
|
||||
return port;
|
||||
};
|
||||
const view = render(<App hub={hub} />);
|
||||
await screen.findByRole("combobox", { name: "任务输入" });
|
||||
return { hub, view };
|
||||
}
|
||||
|
||||
|
||||
const browserButton = () => screen.getByRole("button", { name: "Browser" });
|
||||
|
||||
it("the title-bar Browser button opens the column on a browser", async () => {
|
||||
await open();
|
||||
await userEvent.click(browserButton());
|
||||
expect(await screen.findByRole("tab", { name: "浏览器" })).toBeTruthy();
|
||||
expect(screen.queryByRole("group", { name: "开始" })).toBeNull();
|
||||
await userEvent.click(browserButton());
|
||||
expect(browserButton().getAttribute("title")).toBe("打开内置 Browser");
|
||||
});
|
||||
|
||||
it("the Workbench tab opens the column on the launcher, with no browser made for the person", async () => {
|
||||
await open();
|
||||
await userEvent.click(screen.getByRole("tab", { name: /工作台/ }));
|
||||
expect(await screen.findByRole("group", { name: "开始" })).toBeTruthy();
|
||||
expect(screen.queryByRole("tab", { name: "浏览器" })).toBeNull();
|
||||
});
|
||||
|
||||
it("says the button collapses the workbench while the column is open", async () => {
|
||||
await open();
|
||||
await userEvent.click(browserButton());
|
||||
expect(browserButton().getAttribute("title")).toBe("收起工作台");
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { expect, it, vi } from "vitest";
|
||||
import { HttpError } from "../port/http_error";
|
||||
import type { AgentPort, BrowserTab } from "../port/port";
|
||||
import { failedPageID, observePageTabs, openPage, pageForEmptyColumn, pageInFlight, preparePage, readPageTabs, recheckPageTabs, pageReadAfterSettlement, unseenPageOpenings } from "./browser_open";
|
||||
import { failedPageID, observePageTabs, openPage, pageInFlight, preparePage, readPageTabs, recheckPageTabs, pageReadAfterSettlement, unseenPageOpenings } from "./browser_open";
|
||||
|
||||
it.each(["browser.open_failed", "browser.engine_missing", "browser.engine_failed", "browser.profile_busy"])("reads the public tab identity from %s", (code) => {
|
||||
expect(failedPageID(new HttpError(400, "failed", { code, params: { tab: "b1" } }))).toBe("b1");
|
||||
@@ -31,50 +31,6 @@ it("reserves preparation before it runs and releases a failure that created no t
|
||||
expect(unseenPageOpenings(port)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("does not make an explicit browser wait for an unstarted preview preparation", async () => {
|
||||
const tab: BrowserTab = {id:"blank",target:"blank-target",url:"about:blank",title:"Blank",active:false};
|
||||
const port = { browserOpen: vi.fn(async () => tab) } as unknown as AgentPort;
|
||||
let ready!: (tab: BrowserTab) => void;
|
||||
const prepared = preparePage(port, () => new Promise((resolve) => {ready=resolve;}));
|
||||
const browser = pageForEmptyColumn(port);
|
||||
expect(port.browserOpen).toHaveBeenCalledWith("about:blank", true);
|
||||
expect(await browser).toBe(tab);
|
||||
ready({...tab,id:"preview",target:"preview-target"});
|
||||
await prepared;
|
||||
expect(port.browserOpen).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("still shares an actual browser request when a later preview is only preparing", async () => {
|
||||
const tab: BrowserTab = {id:"native",target:"native-target",url:"about:blank",title:"Native",active:false};
|
||||
let done!: (tab: BrowserTab) => void, ready!: (tab: BrowserTab) => void;
|
||||
const port = { browserOpen: vi.fn(() => new Promise<BrowserTab>((resolve) => {done=resolve;})) } as unknown as AgentPort;
|
||||
const native = openPage(port, "about:blank");
|
||||
const prepared = preparePage(port, () => new Promise((resolve) => {ready=resolve;}));
|
||||
expect(pageInFlight(port)).not.toBeNull();
|
||||
const shared = pageForEmptyColumn(port);
|
||||
expect(port.browserOpen).toHaveBeenCalledTimes(1);
|
||||
done(tab);
|
||||
expect(await shared).toBe(tab);
|
||||
await native;
|
||||
ready({...tab,id:"existing",target:"existing-target"});
|
||||
await prepared;
|
||||
});
|
||||
|
||||
it("does not redirect a newer browser request to an older preparation that starts late", async () => {
|
||||
const native: BrowserTab = {id:"native",target:"native-target",url:"about:blank",title:"Native",active:false};
|
||||
const preview = {...native,id:"preview",target:"preview-target",url:"file:///w/index.html"};
|
||||
let ready!: (url: string) => void, done!: (tab: BrowserTab) => void;
|
||||
const port = { browserOpen: vi.fn((url: string) => url === "about:blank"
|
||||
? new Promise<BrowserTab>((resolve) => {done=resolve;}) : Promise.resolve(preview)) } as unknown as AgentPort;
|
||||
const prepared = preparePage(port, () => new Promise((resolve) => {ready=resolve;}));
|
||||
const requested = pageForEmptyColumn(port);
|
||||
await Promise.resolve();
|
||||
ready(preview.url);
|
||||
await prepared;
|
||||
done(native);
|
||||
expect(await requested).toBe(native);
|
||||
});
|
||||
|
||||
it.each([false, true])("keeps settled navigation ownership until its URL is observed, or its early tab closes: %s", async (closed) => {
|
||||
const tab: BrowserTab = {id:"page",target:"target",url:"https://final.example/",title:"Final",active:true};
|
||||
let done!: (tab: BrowserTab) => void;
|
||||
|
||||
@@ -146,12 +146,6 @@ export function pageInFlight(port: AgentPort): Promise<BrowserTab> | null {
|
||||
return current?.pending && current.started ? newestPage(port, current.page) : null;
|
||||
}
|
||||
|
||||
/** A page for a column that has none: the one already on its way, else a blank. */
|
||||
export function pageForEmptyColumn(port: AgentPort): Promise<BrowserTab> {
|
||||
const current = opening.get(port);
|
||||
return newestPage(port, current?.pending && current.started ? current.page : openPage(port, "about:blank"));
|
||||
}
|
||||
|
||||
async function newestPage(port: AgentPort, page: Promise<BrowserTab>): Promise<BrowserTab> {
|
||||
try {
|
||||
const tab = await page;
|
||||
|
||||
@@ -67,7 +67,7 @@ it("gathers the controls under one button below the bar fold, keeps them in the
|
||||
expect(document.documentElement.dataset.fold).toContain("bar");
|
||||
const button = more()!;
|
||||
expect(button.getAttribute("aria-expanded")).toBe("false");
|
||||
for (const sel of [".find-action", ".theme-toggle", ".top-action", ".browser-action"]) {
|
||||
for (const sel of [".find-action", ".theme-toggle", ".top-action"]) {
|
||||
expect(document.querySelector(`.chrome .r-fold ${sel}`), sel).not.toBeNull();
|
||||
}
|
||||
await userEvent.click(button);
|
||||
|
||||
@@ -29,8 +29,6 @@ async function draw(shell: Record<string, unknown>, host?: string) {
|
||||
title="会话"
|
||||
steer={0}
|
||||
onSettings={vi.fn()}
|
||||
onBrowser={vi.fn()}
|
||||
browser={false}
|
||||
account={null}
|
||||
theme="dark"
|
||||
onTheme={vi.fn()}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import "./testkit";
|
||||
import { App } from "./App";
|
||||
import { MockHub } from "../port/mock_hub";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
function readyHub() {
|
||||
const hub = new MockHub();
|
||||
const build = hub.portFor.bind(hub);
|
||||
const opened = vi.fn();
|
||||
hub.portFor = (rt) => {
|
||||
const port = build(rt);
|
||||
port.providerSetup = async () => null;
|
||||
port.welcomeSeen = async () => true;
|
||||
const open = port.browserOpen?.bind(port);
|
||||
port.browserOpen = ((...args: Parameters<typeof port.browserOpen>) => { opened(...args); return open!(...args); }) as typeof port.browserOpen;
|
||||
return port;
|
||||
};
|
||||
return { hub, opened };
|
||||
}
|
||||
|
||||
describe("the workbench has one way in", () => {
|
||||
it("has no separate browser button in the title bar", async () => {
|
||||
render(<App hub={readyHub().hub} />);
|
||||
await screen.findByRole("tab", { name: /工作台/ });
|
||||
expect(document.querySelector(".chrome .browser-action")).toBeNull();
|
||||
expect(document.querySelector('[data-action="browser.open"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("opens on its start page when nothing is running, and does not open a blank browser page", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { hub, opened } = readyHub();
|
||||
render(<App hub={hub} />);
|
||||
await user.click(await screen.findByRole("tab", { name: /工作台/ }));
|
||||
|
||||
await waitFor(() => expect(document.querySelector(".workbench-launcher")).not.toBeNull());
|
||||
expect(opened).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -47,8 +47,7 @@ async function mount(running = false, pathless = false) {
|
||||
const [report, setReport] = useState<PaneReport | null>(null);
|
||||
const onReport = useCallback((_: string, next: PaneReport) => setReport(next), []);
|
||||
return <>
|
||||
<Chrome port={port} status={report?.status ?? null} title="active" steer={0} onSettings={() => {}} onBrowser={() => {}}
|
||||
browser={false} account={null} theme="light" onTheme={() => {}} onFind={() => {}} />
|
||||
<Chrome port={port} status={report?.status ?? null} title="active" steer={0} onSettings={() => {}} account={null} theme="light" onTheme={() => {}} onFind={() => {}} />
|
||||
<Pane port={port} rt={{ id: "r1", base: "/rt/r1", root: "/workspace", name: "workspace", sessionPath }}
|
||||
title="active" active visible sideHost={null} side={false} onFocus={() => {}} onReport={onReport}
|
||||
onSessionChanged={() => {}} pulse={0} findPulse={0} onSettings={() => {}} needsProject={false}
|
||||
|
||||
@@ -61,7 +61,6 @@ export interface PaneProps {
|
||||
dockMax: number;
|
||||
onDockW: (w: number) => void;
|
||||
manualBrowser?: boolean;
|
||||
browserAsk?: number;
|
||||
onManualBrowser?: (on: boolean) => void;
|
||||
// The window's failed-request notice, handed only to the pane in front.
|
||||
alert?: ReactNode;
|
||||
|
||||
@@ -1,31 +1,23 @@
|
||||
import { useEffect, useRef, type Dispatch, type MutableRefObject, type SetStateAction } from "react";
|
||||
import { reason } from "../i18n/kernel";
|
||||
import { host } from "../port/host";
|
||||
import type { AgentPort } from "../port/port";
|
||||
import { pageForEmptyColumn, pageInFlight } from "./browser_open";
|
||||
import { pageInFlight } from "./browser_open";
|
||||
|
||||
interface Opening {
|
||||
manual: boolean;
|
||||
/** Counts the title-bar Browser presses; a change asks for a browser, not just the column. */
|
||||
browserAsk: number;
|
||||
filesAsk: unknown;
|
||||
port: AgentPort;
|
||||
agentNow: MutableRefObject<string>;
|
||||
dropBlankStart: () => void;
|
||||
pick: (key: string) => void;
|
||||
follow: (key: string) => void;
|
||||
manualSelection: string;
|
||||
current: () => () => boolean;
|
||||
setFailed: Dispatch<SetStateAction<string>>;
|
||||
setBrowsers: Dispatch<SetStateAction<string[]>>;
|
||||
}
|
||||
|
||||
/** What the workbench column does when it is opened. It follows the agent's page,
|
||||
* and a page a followed link already has on its way; with neither it stays on
|
||||
* its launcher until a browser is asked for. The title bar's Browser button
|
||||
* asks for one itself: each press is one ask, and a page already on its way is
|
||||
* shared rather than requested twice. */
|
||||
export function useColumnOpening({ manual, browserAsk, filesAsk, port, agentNow, dropBlankStart, pick, follow, manualSelection, current, setFailed, setBrowsers }: Opening) {
|
||||
* its launcher until a browser is asked for from there. */
|
||||
export function useColumnOpening({ manual, filesAsk, port, agentNow, dropBlankStart, follow, current, setFailed }: Opening) {
|
||||
const filesRequested = useRef(filesAsk);
|
||||
useEffect(() => {
|
||||
const files = filesRequested.current !== filesAsk;
|
||||
@@ -43,28 +35,4 @@ export function useColumnOpening({ manual, browserAsk, filesAsk, port, agentNow,
|
||||
.catch((e) => { if (alive && valid()) setFailed(reason(e)); });
|
||||
return () => { alive = false; };
|
||||
}, [manual, filesAsk, agentNow, dropBlankStart, follow, port, setFailed, current]);
|
||||
|
||||
const asked = useRef(browserAsk);
|
||||
useEffect(() => {
|
||||
if (asked.current === browserAsk) return;
|
||||
asked.current = browserAsk;
|
||||
if (!manual) return;
|
||||
if (agentNow.current) {
|
||||
dropBlankStart();
|
||||
pick(`browser:${agentNow.current}`);
|
||||
return;
|
||||
}
|
||||
// A shell that draws views opens a view here too: a framed page is refused
|
||||
// by any site sending X-Frame-Options or frame-ancestors, as many do.
|
||||
if (host().drawsBrowserViews()) {
|
||||
const valid = current();
|
||||
let alive = true;
|
||||
pageForEmptyColumn(port)
|
||||
.then((tab) => { if (alive && valid()) pick(`browser:${tab.target}`); })
|
||||
.catch((e) => { if (alive && valid()) setFailed(reason(e)); });
|
||||
return () => { alive = false; };
|
||||
}
|
||||
setBrowsers((open) => (open.length ? open : ["b0"]));
|
||||
pick(manualSelection);
|
||||
}, [browserAsk, filesAsk, manual, agentNow, dropBlankStart, pick, port, setFailed, setBrowsers, manualSelection, current]);
|
||||
}
|
||||
@@ -1,11 +1,10 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { act, cleanup, render } from "@testing-library/react";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import "./testkit";
|
||||
import { WorkbenchPanel } from "./WorkbenchPanel";
|
||||
import { MockPort } from "../port/mock";
|
||||
import type { BrowserTab } from "../port/port";
|
||||
import { host } from "../port/host";
|
||||
import { openPage } from "./browser_open";
|
||||
|
||||
const page = { id: "b1", target: "t1", url: "https://example.com", title: "Existing page", active: true };
|
||||
@@ -33,48 +32,3 @@ it("keeps a newer files request visible when the previously followed link finish
|
||||
await act(async () => { resolve(page); await pending; });
|
||||
expect(filesShown()).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps Files visible when an earlier explicit browser request finishes", async () => {
|
||||
vi.spyOn(host(), "drawsBrowserViews").mockReturnValue(true);
|
||||
const port = new MockPort();
|
||||
let resolve!: (tab: BrowserTab) => void;
|
||||
const pending = new Promise<BrowserTab>((done) => { resolve = done; });
|
||||
vi.spyOn(port, "browserOpen").mockReturnValue(pending);
|
||||
const { rerender } = render(<WorkbenchPanel {...props} port={port} tabs={[]} manual={false} />);
|
||||
rerender(<WorkbenchPanel {...props} port={port} tabs={[]} manual browserAsk={1} />);
|
||||
expect(port.browserOpen).toHaveBeenCalledTimes(1);
|
||||
rerender(<WorkbenchPanel {...props} port={port} tabs={[]} manual browserAsk={1} revealChanges={1} />);
|
||||
await act(async () => { resolve(page); await pending; });
|
||||
expect(filesShown()).toBe(true);
|
||||
});
|
||||
|
||||
it.each([false, true])("lets an explicit title-bar Browser request replace Files (native=%s)", async (native) => {
|
||||
vi.spyOn(host(), "drawsBrowserViews").mockReturnValue(native);
|
||||
const port = new MockPort();
|
||||
let resolve!: (tab: BrowserTab) => void;
|
||||
const pending = new Promise<BrowserTab>((done) => { resolve = done; });
|
||||
vi.spyOn(port, "browserOpen").mockReturnValue(pending);
|
||||
if (native) void openPage(port, page.url);
|
||||
const { rerender } = render(<WorkbenchPanel {...props} port={port} tabs={[]} manual />);
|
||||
rerender(<WorkbenchPanel {...props} port={port} tabs={[]} manual revealChanges={1} />);
|
||||
await act(async () => { resolve(page); await pending; });
|
||||
rerender(<WorkbenchPanel {...props} port={port} tabs={native ? [page] : []} manual revealChanges={1} />);
|
||||
expect(filesShown()).toBe(true);
|
||||
rerender(<WorkbenchPanel {...props} port={port} tabs={native ? [page] : []} manual revealChanges={1} browserAsk={1} />);
|
||||
expect(filesShown()).toBe(false);
|
||||
});
|
||||
|
||||
it("does not replace a file picked while the title-bar browser request is pending", async () => {
|
||||
vi.spyOn(host(), "drawsBrowserViews").mockReturnValue(true);
|
||||
const port = new MockPort();
|
||||
let resolve!: (tab: BrowserTab) => void;
|
||||
const pending = new Promise<BrowserTab>((done) => { resolve = done; });
|
||||
vi.spyOn(port, "browserOpen").mockReturnValue(pending);
|
||||
const { rerender } = render(<WorkbenchPanel {...props} port={port} tabs={[]} manual shown />);
|
||||
rerender(<WorkbenchPanel {...props} port={port} tabs={[]} manual shown browserAsk={1} />);
|
||||
fireEvent.click(await screen.findByRole("button", { name: "README.md" }));
|
||||
await act(async () => { resolve(page); await pending; });
|
||||
rerender(<WorkbenchPanel {...props} port={port} tabs={[page]} manual shown browserAsk={1} />);
|
||||
await act(async () => {});
|
||||
expect(screen.getByRole("tab", { name: "README.md" }).getAttribute("aria-selected")).toBe("true");
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
- 去掉顶栏的「浏览器」按钮:「工作台」标签是唯一入口,没有运行中的网页时显示「文件 / 改动 / 浏览器」启动页,不再直接开一个空白页。关联 #13067。
|
||||
- The title-bar Browser button is gone: the Workbench tab is the one way in, and with nothing running it shows the Files / Changes / Browser start page instead of opening a blank page. Refs #13067.
|
||||
Reference in new issue
Block a user