Skip to content

Commit 057dea1

Browse files
committed
Merge branch 'feature/workspace-fullscreen' into feature/refactor-ts
2 parents a8c4774 + 9bd591d commit 057dea1

17 files changed

Lines changed: 627 additions & 5 deletions

File tree

docs/help/desktop-guide.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
- 终端面板切换按钮
2828
- 文件面板切换按钮
2929
- 设置按钮
30+
- 全屏按钮
3031

3132
### 左侧面板
3233

@@ -138,6 +139,12 @@
138139

139140
`F` 键进入专注模式,会隐藏左侧面板和底部终端面板,只保留中央工作区。按 `Escape` 退出专注模式。
140141

142+
### 原生全屏
143+
144+
点击设置按钮右侧的全屏按钮可进入浏览器原生全屏。处于全屏时,同一位置的按钮会切换为退出全屏,按 `Escape` 也可以退出。
145+
146+
如果同时开启了专注模式和原生全屏,`Escape` 会先退出原生全屏,再退出专注模式。
147+
141148
## 常见问题
142149

143150
**Q:如何找回被关闭的面板?**

docs/help/mobile-guide.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222

2323
- 工作区名称(点击打开工作区抽屉,可切换工作区)
2424
- 设置入口
25+
- 全屏入口(仅在浏览器支持原生全屏时显示)
2526

2627
### 当前 Agent 区域
2728

@@ -76,6 +77,8 @@
7677
- 移动端适合"查看"为主,复杂的文件编辑和 Agent 管理建议在桌面端完成
7778
- 手机键盘弹出时会压缩底部 Dock 区域,操作时注意布局变化
7879
- 终端操作在小屏幕上可能不便,建议仅在需要时查看
80+
- 如果浏览器支持原生全屏,可点击顶部栏右侧的全屏按钮进入;再次点击同一位置的按钮可退出
81+
- 某些移动端浏览器不支持原生全屏,此时不会显示该按钮
7982

8083
## 常见问题
8184

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { fireEvent, render } from "@testing-library/react";
2+
import { createStore, Provider } from "jotai";
3+
import { describe, expect, it } from "vitest";
4+
import { focusModeAtom } from "../../workspace/atoms";
5+
import { FocusMode } from "./focus-mode";
6+
7+
function renderFocusModeWithStore(initialFocusMode: boolean) {
8+
const store = createStore();
9+
store.set(focusModeAtom, initialFocusMode);
10+
11+
render(
12+
<Provider store={store}>
13+
<FocusMode />
14+
</Provider>
15+
);
16+
17+
return store;
18+
}
19+
20+
describe("FocusMode escape priority", () => {
21+
it("does not close focus mode while native fullscreen is active", () => {
22+
Object.defineProperty(document, "fullscreenElement", {
23+
configurable: true,
24+
get: () => document.body,
25+
});
26+
27+
const store = renderFocusModeWithStore(true);
28+
29+
fireEvent.keyDown(window, { key: "Escape" });
30+
31+
expect(store.get(focusModeAtom)).toBe(true);
32+
});
33+
34+
it("still exits focus mode when fullscreen is not active", () => {
35+
Object.defineProperty(document, "fullscreenElement", {
36+
configurable: true,
37+
get: () => null,
38+
});
39+
40+
const store = renderFocusModeWithStore(true);
41+
42+
fireEvent.keyDown(window, { key: "Escape" });
43+
44+
expect(store.get(focusModeAtom)).toBe(false);
45+
});
46+
});

packages/web/src/features/focus-mode/components/focus-mode.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,10 @@ export function FocusMode() {
8484

8585
// Escape key exits focus mode
8686
if (e.key === "Escape" && focusMode) {
87+
if (document.fullscreenElement) {
88+
return;
89+
}
90+
8791
e.preventDefault();
8892
setFocusMode(false);
8993
}

packages/web/src/features/topbar/index.test.tsx

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,4 +130,51 @@ describe("TopBar", () => {
130130
expect(screen.getByRole("button", { name: "Hide Terminal" })).toHaveClass("topbar-btn--active");
131131
expect(screen.getByRole("button", { name: "Show Files" })).toHaveClass("topbar-btn--muted");
132132
});
133+
134+
it("renders the fullscreen toggle immediately to the right of settings when supported", () => {
135+
const store = createStore();
136+
store.set(localeAtom, "en");
137+
store.set(workspacesLoadStateAtom, "ready");
138+
139+
render(
140+
<Provider store={store}>
141+
<TopBar
142+
fullscreenController={{
143+
supported: true,
144+
isFullscreen: false,
145+
enterFullscreen: vi.fn(),
146+
exitFullscreen: vi.fn(),
147+
toggleFullscreen: vi.fn(),
148+
}}
149+
/>
150+
</Provider>
151+
);
152+
153+
const settingsButton = screen.getByTestId("settings-open");
154+
const fullscreenButton = screen.getByRole("button", { name: "Enter Fullscreen" });
155+
156+
expect(settingsButton.nextElementSibling).toBe(fullscreenButton);
157+
});
158+
159+
it("hides the fullscreen toggle when the controller reports unsupported", () => {
160+
const store = createStore();
161+
store.set(localeAtom, "en");
162+
store.set(workspacesLoadStateAtom, "ready");
163+
164+
render(
165+
<Provider store={store}>
166+
<TopBar
167+
fullscreenController={{
168+
supported: false,
169+
isFullscreen: false,
170+
enterFullscreen: vi.fn(),
171+
exitFullscreen: vi.fn(),
172+
toggleFullscreen: vi.fn(),
173+
}}
174+
/>
175+
</Provider>
176+
);
177+
178+
expect(screen.queryByRole("button", { name: "Enter Fullscreen" })).toBeNull();
179+
});
133180
});

packages/web/src/features/topbar/index.tsx

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,17 @@ import { useNavigate } from "react-router-dom";
1212
import { commandPaletteOpenAtom } from "../../atoms/app-ui";
1313
import { orderedWorkspacesAtom, resolvedActiveWorkspaceIdAtom } from "../../atoms/workspaces";
1414
import { useTranslation } from "../../lib/i18n";
15+
import type { WorkspaceFullscreenController } from "../workspace/actions/use-workspace-fullscreen";
1516
import { sidebarCollapsedAtom, terminalPanelVisibleAtom } from "../workspace/atoms";
17+
import { WorkspaceFullscreenButton } from "../workspace/components/workspace-fullscreen-button";
1618
import { WorkspaceLaunchModal } from "../workspace/views/shared/workspace-launch-modal";
1719
import { ConnectionStatus } from "./components/connection-status";
1820
import { WorkspaceTab } from "./components/tab";
1921

22+
interface TopBarProps {
23+
fullscreenController?: WorkspaceFullscreenController;
24+
}
25+
2026
/**
2127
* TopBar Component
2228
*
@@ -25,7 +31,7 @@ import { WorkspaceTab } from "./components/tab";
2531
* - Left: Workspace tabs + Add button
2632
* - Right: ConnectionStatus, Quick Actions, Terminal toggle, Files toggle, Settings
2733
*/
28-
export const TopBar: FC = () => {
34+
export const TopBar: FC<TopBarProps> = ({ fullscreenController }) => {
2935
const t = useTranslation();
3036
const navigate = useNavigate();
3137
const workspaceList = useAtomValue(orderedWorkspacesAtom);
@@ -95,6 +101,12 @@ export const TopBar: FC = () => {
95101
>
96102
<Settings size={14} />
97103
</button>
104+
<WorkspaceFullscreenButton
105+
controller={fullscreenController}
106+
className="topbar-btn"
107+
iconSize={14}
108+
dataTestId="workspace-fullscreen-open"
109+
/>
98110
</div>
99111
{workspaceLaunchOpen ? (
100112
<WorkspaceLaunchModal onClose={() => setWorkspaceLaunchOpen(false)} />
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
2+
import { useRef } from "react";
3+
import { afterEach, describe, expect, it, vi } from "vitest";
4+
import { useWorkspaceFullscreen } from "./use-workspace-fullscreen";
5+
6+
function installFullscreenApi() {
7+
let fullscreenElement: Element | null = null;
8+
9+
const requestFullscreen = vi.fn().mockImplementation(function (this: HTMLElement) {
10+
fullscreenElement = this;
11+
document.dispatchEvent(new Event("fullscreenchange"));
12+
return Promise.resolve();
13+
});
14+
15+
const exitFullscreen = vi.fn().mockImplementation(async () => {
16+
fullscreenElement = null;
17+
document.dispatchEvent(new Event("fullscreenchange"));
18+
});
19+
20+
Object.defineProperty(document, "fullscreenEnabled", {
21+
configurable: true,
22+
value: true,
23+
});
24+
25+
Object.defineProperty(document, "fullscreenElement", {
26+
configurable: true,
27+
get: () => fullscreenElement,
28+
});
29+
30+
Object.defineProperty(document, "exitFullscreen", {
31+
configurable: true,
32+
value: exitFullscreen,
33+
});
34+
35+
Object.defineProperty(HTMLElement.prototype, "requestFullscreen", {
36+
configurable: true,
37+
value: requestFullscreen,
38+
});
39+
40+
return {
41+
requestFullscreen,
42+
exitFullscreen,
43+
setFullscreenElement(next: Element | null) {
44+
fullscreenElement = next;
45+
document.dispatchEvent(new Event("fullscreenchange"));
46+
},
47+
};
48+
}
49+
50+
function clearFullscreenApi() {
51+
Object.defineProperty(document, "fullscreenEnabled", {
52+
configurable: true,
53+
value: false,
54+
});
55+
56+
Object.defineProperty(document, "fullscreenElement", {
57+
configurable: true,
58+
get: () => null,
59+
});
60+
61+
Object.defineProperty(document, "exitFullscreen", {
62+
configurable: true,
63+
value: undefined,
64+
});
65+
66+
Object.defineProperty(HTMLElement.prototype, "requestFullscreen", {
67+
configurable: true,
68+
value: undefined,
69+
});
70+
}
71+
72+
function HookHarness() {
73+
const targetRef = useRef<HTMLDivElement>(null);
74+
const controller = useWorkspaceFullscreen(targetRef);
75+
76+
return (
77+
<div>
78+
<div ref={targetRef} data-testid="fullscreen-target" />
79+
<output data-testid="supported">{String(controller.supported)}</output>
80+
<output data-testid="fullscreen">{String(controller.isFullscreen)}</output>
81+
<button
82+
type="button"
83+
onClick={() => {
84+
void controller.enterFullscreen();
85+
}}
86+
>
87+
enter
88+
</button>
89+
<button
90+
type="button"
91+
onClick={() => {
92+
void controller.exitFullscreen();
93+
}}
94+
>
95+
exit
96+
</button>
97+
<button
98+
type="button"
99+
onClick={() => {
100+
void controller.toggleFullscreen();
101+
}}
102+
>
103+
toggle
104+
</button>
105+
</div>
106+
);
107+
}
108+
109+
describe("useWorkspaceFullscreen", () => {
110+
afterEach(() => {
111+
clearFullscreenApi();
112+
vi.restoreAllMocks();
113+
});
114+
115+
it("reports unsupported when the browser fullscreen api is unavailable", async () => {
116+
clearFullscreenApi();
117+
118+
render(<HookHarness />);
119+
120+
await waitFor(() => {
121+
expect(screen.getByTestId("supported")).toHaveTextContent("false");
122+
});
123+
expect(screen.getByTestId("fullscreen")).toHaveTextContent("false");
124+
});
125+
126+
it("enters and exits fullscreen against the target element", async () => {
127+
const api = installFullscreenApi();
128+
129+
render(<HookHarness />);
130+
131+
await waitFor(() => {
132+
expect(screen.getByTestId("supported")).toHaveTextContent("true");
133+
});
134+
135+
fireEvent.click(screen.getByRole("button", { name: "enter" }));
136+
137+
await waitFor(() => {
138+
expect(api.requestFullscreen).toHaveBeenCalledTimes(1);
139+
expect(screen.getByTestId("fullscreen")).toHaveTextContent("true");
140+
});
141+
142+
fireEvent.click(screen.getByRole("button", { name: "exit" }));
143+
144+
await waitFor(() => {
145+
expect(api.exitFullscreen).toHaveBeenCalledTimes(1);
146+
expect(screen.getByTestId("fullscreen")).toHaveTextContent("false");
147+
});
148+
});
149+
150+
it("tracks fullscreenchange even when the browser exits fullscreen outside the button", async () => {
151+
const api = installFullscreenApi();
152+
153+
render(<HookHarness />);
154+
155+
const target = await screen.findByTestId("fullscreen-target");
156+
157+
act(() => {
158+
api.setFullscreenElement(target);
159+
});
160+
161+
expect(screen.getByTestId("fullscreen")).toHaveTextContent("true");
162+
163+
act(() => {
164+
api.setFullscreenElement(null);
165+
});
166+
167+
expect(screen.getByTestId("fullscreen")).toHaveTextContent("false");
168+
});
169+
170+
it("leaves the state in enter mode when requestFullscreen rejects", async () => {
171+
installFullscreenApi();
172+
const requestError = new Error("fullscreen denied");
173+
const requestSpy = vi
174+
.spyOn(HTMLElement.prototype, "requestFullscreen")
175+
.mockRejectedValue(requestError);
176+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
177+
178+
render(<HookHarness />);
179+
180+
fireEvent.click(screen.getByRole("button", { name: "toggle" }));
181+
182+
await waitFor(() => {
183+
expect(requestSpy).toHaveBeenCalledTimes(1);
184+
expect(warnSpy).toHaveBeenCalledWith("Failed to enter fullscreen", requestError);
185+
});
186+
187+
expect(screen.getByTestId("fullscreen")).toHaveTextContent("false");
188+
});
189+
});

0 commit comments

Comments
 (0)