fix(workbench): Keep a way out of Distraction Free Mode on Windows (#47)

Root cause: on Windows the window is frameless and the main menu lives in the custom title bar; Distraction Free Mode rendered a bare title bar without it, the toggle has no default shortcut (as in IDEA) and the mode persists across restarts, leaving no way out.
Fix (IDEA): the bare title bar keeps the main menu when there is no native menu bar; a lone Alt opens the main menu; entering the mode (or restoring it) shows "To exit Distraction Free Mode, use View | Appearance … or Find Action" with an Exit action, in its own notification group.
Siblings covered: Distraction Free + Full Screen on Windows; other chrome-hiding settings checked (unaffected).
Verified in-app on macOS (hint, Exit action, single Don't Show Again). Windows-only parts (☰ in bare title bar, lone Alt) not verified on Windows.

Claude-Session: https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn
This commit is contained in:
l0ng-ai authored and GitHub committed 2026-10-27 10:38:26 +08:00
1 parent b4a3e866c6
commit 642b80cef0
7 files changed
+232 -11

No files matched your search

+59 -2
View File
@@ -1,5 +1,56 @@
import { describe, expect, it } from "vitest";
import { menuItems } from "./MainMenu";
import { menuItems, onLoneAlt } from "./MainMenu";
describe("lone Alt opens the main menu", () => {
const key = (type: string, k: string, init: KeyboardEventInit = {}) =>
window.dispatchEvent(new KeyboardEvent(type, { key: k, bubbles: true, cancelable: true, ...init }));
it.each<[string, () => void, number]>([
["Alt alone", () => (key("keydown", "Alt", { altKey: true }), key("keyup", "Alt")), 1],
[
"Alt+1",
() => (
key("keydown", "Alt", { altKey: true }),
key("keydown", "1", { altKey: true }),
key("keyup", "1"),
key("keyup", "Alt")
),
0,
],
[
"Ctrl+Alt",
() => (
key("keydown", "Control", { ctrlKey: true }),
key("keydown", "Alt", { ctrlKey: true, altKey: true }),
key("keyup", "Alt")
),
0,
],
[
"Alt+click",
() => (
key("keydown", "Alt", { altKey: true }),
window.dispatchEvent(new Event("pointerdown")),
key("keyup", "Alt")
),
0,
],
[
"held Alt (repeat)",
() => (
key("keydown", "Alt", { altKey: true }),
key("keydown", "Alt", { altKey: true, repeat: true }),
key("keyup", "Alt")
),
0,
],
])("%s", (_, press, expected) => {
let opened = 0;
const off = onLoneAlt(window, () => void opened++);
press();
off();
expect(opened).toBe(expected);
});
});
describe("main menu model", () => {
it("maps menu nodes to menu items, localising spec labels only", () => {
@@ -11,7 +62,13 @@ describe("main menu model", () => {
id: "openRecent",
label: "Open Recent",
children: [
{ kind: "action", id: "file.openRecentProject", label: "openidea — ~/openidea", hint: null, arg: "/p/openidea" },
{
kind: "action",
id: "file.openRecentProject",
label: "openidea — ~/openidea",
hint: null,
arg: "/p/openidea",
},
{ kind: "placeholder", id: "recent.none", label: "No Recent Projects" },
],
},
+35 -2
View File
@@ -2,9 +2,10 @@
* Windows title bar: IDEA's main menu button. Clicking ☰ expands the menu bar
* in place (File Edit View …) with File open; hovering another title switches
* menus, ←/→ walk them, and closing the menu collapses the bar again. The
* entries come from the same spec as the native menu (`menu_model`).
* entries come from the same spec as the native menu (`menu_model`). As in
* Windows apps and IDEA, pressing and releasing Alt alone opens it too.
*/
import { useRef, useState, type KeyboardEvent } from "react";
import { useEffect, useRef, useState, type KeyboardEvent } from "react";
import clsx from "clsx";
import { lookup, t } from "@/platform/i18n";
import { commands, type MenuNode } from "@/platform/ipc";
@@ -38,6 +39,37 @@ export function menuItems(nodes: MenuNode[]): MenuItem[] {
});
}
/**
* Calls `onAlt` when Alt is pressed and released with nothing else in between
* (no other key, no click): Alt+1, Alt+Enter or Alt+click never open the menu.
*/
export function onLoneAlt(target: Window, onAlt: () => void): () => void {
let armed = false;
const down = (e: globalThis.KeyboardEvent) => {
armed = e.key === "Alt" && !e.repeat && !e.ctrlKey && !e.shiftKey && !e.metaKey;
};
const up = (e: globalThis.KeyboardEvent) => {
const fire = armed && e.key === "Alt";
armed = false;
if (!fire) return;
e.preventDefault();
onAlt();
};
const cancel = () => {
armed = false;
};
target.addEventListener("keydown", down, true);
target.addEventListener("keyup", up, true);
target.addEventListener("pointerdown", cancel, true);
target.addEventListener("blur", cancel);
return () => {
target.removeEventListener("keydown", down, true);
target.removeEventListener("keyup", up, true);
target.removeEventListener("pointerdown", cancel, true);
target.removeEventListener("blur", cancel);
};
}
export function MainMenu({ onExpandedChange }: { onExpandedChange?: (expanded: boolean) => void }) {
const [menus, setMenus] = useState<Submenu[] | null>(null);
const [open, setOpen] = useState<number | null>(null);
@@ -65,6 +97,7 @@ export function MainMenu({ onExpandedChange }: { onExpandedChange?: (expanded: b
else collapse();
});
};
useEffect(() => onLoneAlt(window, () => (menus ? collapse() : void expand())));
const step = (delta: number) => {
if (!menus || open === null) return;
setOpen((open + delta + menus.length) % menus.length);
+31 -3
View File
@@ -1,6 +1,9 @@
import { afterEach, describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
vi.mock("./WindowControls", () => ({ WindowControls: () => null }));
import { fireEvent, render, screen } from "@testing-library/react";
import { registerActions } from "@/platform/actions";
import { usePlatform } from "@/platform/platformInfo";
import { useThemeOverride } from "./theme";
import { customizeToolbarItems, gearMenuItems, TitleBar } from "./TitleBar";
@@ -44,11 +47,15 @@ describe("title bar gear menu", () => {
const items = customizeToolbarItems(
{ left: ["project", "vcs.branch"], right: ["action:navigate.searchEverywhere"] },
["run.widget"],
new Map([["run.widget", { id: "run.widget", align: "right", priority: 1, component: () => null, title: "Run Widget" }]]),
new Map([
["run.widget", { id: "run.widget", align: "right", priority: 1, component: () => null, title: "Run Widget" }],
]),
(id) => toggled.push(id),
() => void reset++,
);
const rows = items.filter((i): i is { id: string; label: string; checked: boolean; run: () => void } => "checked" in i);
const rows = items.filter(
(i): i is { id: string; label: string; checked: boolean; run: () => void } => "checked" in i,
);
expect(rows.map((r) => [r.label, r.checked])).toEqual([
["Project Widget", true],
["VCS Widget", true],
@@ -75,3 +82,24 @@ describe("title bar gear menu", () => {
expect(useThemeOverride.getState().theme).toBe("dark-hc");
});
});
describe("title bar in Distraction Free Mode (bare)", () => {
// The main menu is the way back out of a view mode that hides the toolbar:
// the title bar's ☰ where the window is frameless (Windows), the OS menu bar elsewhere.
it.each([
{ os: "windows", overlayTitleBar: false, framelessTitleBar: true, mainMenu: true },
{ os: "macos", overlayTitleBar: true, framelessTitleBar: false, mainMenu: false },
{ os: "linux", overlayTitleBar: false, framelessTitleBar: false, mainMenu: false },
])("$os: keeps the main menu button where there is no native menu bar", ({ mainMenu, ...platform }) => {
const saved = usePlatform.getState();
usePlatform.setState(platform);
try {
const { container } = render(<TitleBar bare />);
expect(container.querySelector("header[data-titlebar='bare']")).not.toBeNull();
expect(screen.queryByRole("button", { name: "IDE and Project Settings" })).toBeNull();
expect(!!screen.queryByRole("button", { name: "Main Menu" })).toBe(mainMenu);
} finally {
usePlatform.setState(saved);
}
});
});
+3
View File
@@ -275,11 +275,14 @@ export function TitleBar({
const { map, shown, menu } = useToolbar();
const [menuExpanded, setMenuExpanded] = useState(false);
const className = clsx(styles.bar, overlay && styles.overlay, frameless && styles.frameless);
// Distraction Free Mode: the window frame only. Where the title bar carries the
// main menu (Windows) it stays, or nothing would lead back out of the mode.
if (bare)
return (
<header className={className} data-tauri-drag-region data-titlebar="bare">
{frameless && (
<>
<MainMenu />
<div className={styles.drag} data-tauri-drag-region />
<WindowControls />
</>
+63 -1
View File
@@ -5,8 +5,12 @@ import { getContext } from "@/platform/context";
import { bindingsFor, installKeyboardDispatcher, useKeymap } from "@/platform/keymap";
import { useProject } from "@/platform/project";
import { registerStatusItem, registerToolWindow } from "./contributions";
import { clearNotifications, useNotifications } from "./notificationStore";
import { notificationActions } from "./Notifications";
import {
DISTRACTION_FREE_ACTION,
DISTRACTION_FREE_HINT_KEY,
distractionFreeHint,
distractionFreeWidth,
installDistractionFree,
isDistractionFree,
@@ -127,6 +131,62 @@ describe("distraction free mode", () => {
setDistractionFree(false);
});
describe("exit hint", () => {
const hint = () => useNotifications.getState().items.find((n) => n.key === DISTRACTION_FREE_HINT_KEY);
beforeEach(() => clearNotifications());
it("tells how to leave the mode on entering, with an Exit action, and expires on exit", () => {
setDistractionFree(true);
expect(hint()?.balloon).toBe(true);
expect(hint()?.body).toBe(distractionFreeHint());
expect(distractionFreeHint()).toContain("View | Appearance | Exit Distraction Free Mode");
// Unbound toggle (IntelliJ keymap): Find Action with its shortcut is the keyboard way out.
expect(distractionFreeHint()).toMatch(/Find Action \(.+\)/);
hint()!
.actions!.find((a) => a.label === "Exit Distraction Free Mode")!
.run();
expect(isDistractionFree()).toBe(false);
expect(hint()?.balloon ?? false).toBe(false);
});
it("names the toggle's own shortcut when it has one", () => {
useKeymap.setState({ overrides: { [DISTRACTION_FREE_ACTION]: ["Ctrl+Alt+Z"] } });
expect(distractionFreeHint()).not.toContain("Find Action");
});
it("shows again after a restart in the mode, once the project frame loads", () => {
setDistractionFree(true);
clearNotifications();
useDistractionFree.setState({ active: false });
resetToolWindowLayout();
disposers.push(installDistractionFree());
expect(isDistractionFree()).toBe(true);
expect(hint()).toBeUndefined();
loadToolWindowLayout("/work/df");
expect(hint()?.balloon).toBe(true);
});
it("offers Don't Show Again once, scoped to its own notification group", () => {
setDistractionFree(true);
const labels = notificationActions(hint()!).map((a) => a.label);
expect(labels.filter((l) => l === "Don't Show Again")).toHaveLength(1);
expect(hint()!.group).toBe("distractionFree");
});
it("stays off once its group shows no popup", () => {
const settings = useProject.getState().settings;
useProject.setState({
settings: { ...settings, notifications: { groups: { distractionFree: { display: "none" } } } } as never,
});
try {
setDistractionFree(true);
expect(hint()?.balloon ?? false).toBe(false);
} finally {
useProject.setState({ settings });
}
});
});
it("clamps the text width setting", () => {
expect(distractionFreeWidth(undefined)).toBe(80);
expect(distractionFreeWidth(100.4)).toBe(100);
@@ -138,7 +198,9 @@ describe("distraction free mode", () => {
describe("distraction free shell", () => {
it("hides stripes, status bar and main toolbar contents, keeping the drag region", async () => {
const { Workbench } = await import("./Workbench");
disposers.push(registerStatusItem({ id: "t.status", align: "left", priority: 0, component: () => <span>status-x</span> }));
disposers.push(
registerStatusItem({ id: "t.status", align: "left", priority: 0, component: () => <span>status-x</span> }),
);
act(() => useProject.setState({ project: { root: "/work/df", name: "df-demo", isGit: false, settings: {} } }));
const { unmount } = render(<Workbench />);
expect(await screen.findByText("df-demo")).toBeInTheDocument();
+40 -3
View File
@@ -7,11 +7,14 @@
* application-wide preference that survives restarts (localStorage, next to
* the saved tool window layouts, which keep the layout to restore on exit).
* Published as the `distractionFree` context key; components read
* `useDistractionFree`.
* `useDistractionFree`. Entering (or starting in) the mode shows IDEA's hint
* on how to leave it, since the main toolbar and status bar are gone.
*/
import { create } from "zustand";
import { registerAction, type Action } from "@/platform/actions";
import { setContext } from "@/platform/context";
import { shortcutLabel } from "@/platform/keymap";
import { expireNotification, notify } from "./notificationStore";
import { enterDistractionFreeLayout, exitDistractionFreeLayout, useToolWindowLayout } from "./toolwindows/store";
/** Kept from the original id so keymaps (VS Code's ⌘K Z, user bindings) stay bound. */
@@ -19,6 +22,7 @@ export const DISTRACTION_FREE_ACTION = "editor.toggleZen";
export const DISTRACTION_FREE_CONTEXT = "distractionFree";
export const DEFAULT_DISTRACTION_FREE_WIDTH = 80;
const STORAGE_KEY = "openidea.distractionFree";
export const DISTRACTION_FREE_HINT_KEY = "distractionFree.hint";
export const useDistractionFree = create<{ active: boolean }>(() => ({ active: false }));
@@ -28,7 +32,9 @@ export function isDistractionFree(): boolean {
/** Text width in columns (Settings › Editor › General › Appearance), clamped like IDEA's field. */
export function distractionFreeWidth(raw: unknown): number {
return typeof raw === "number" && Number.isFinite(raw) ? Math.min(500, Math.max(20, Math.round(raw))) : DEFAULT_DISTRACTION_FREE_WIDTH;
return typeof raw === "number" && Number.isFinite(raw)
? Math.min(500, Math.max(20, Math.round(raw)))
: DEFAULT_DISTRACTION_FREE_WIDTH;
}
export function distractionFreeTitle(active: boolean): string {
@@ -52,13 +58,41 @@ function readStored(): boolean {
}
}
/** IDEA's "To exit Distraction Free Mode, use View | Appearance or <shortcut>". */
export function distractionFreeHint(): string {
const own = shortcutLabel(DISTRACTION_FREE_ACTION);
const find = shortcutLabel("navigate.findAction");
const way = own ?? (find ? `Find Action (${find})` : "Find Action");
return `To exit Distraction Free Mode, use View | Appearance | Exit Distraction Free Mode or ${way}.`;
}
/** Set on entering the mode; the hint shows once a project frame is there to leave. */
let hintPending = false;
function showHint() {
if (!hintPending || !isDistractionFree() || !useToolWindowLayout.getState().scope) return;
hintPending = false;
notify({
key: DISTRACTION_FREE_HINT_KEY,
// Its own group: the balloon's "Don't Show Again" turns off this hint only (Settings › Notifications).
group: "distractionFree",
level: "info",
title: "Distraction Free Mode",
body: distractionFreeHint(),
actions: [{ label: "Exit Distraction Free Mode", run: () => setDistractionFree(false) }],
});
}
export function setDistractionFree(active: boolean) {
if (active === isDistractionFree()) return;
useDistractionFree.setState({ active });
setContext(DISTRACTION_FREE_CONTEXT, active);
writeStored(active);
hintPending = active;
if (active) enterDistractionFreeLayout();
else exitDistractionFreeLayout();
if (active) showHint();
else expireNotification(DISTRACTION_FREE_HINT_KEY);
}
export function toggleDistractionFree() {
@@ -89,7 +123,9 @@ export function installDistractionFree(): () => void {
previous();
});
const unsubLayout = useToolWindowLayout.subscribe((s, prev) => {
if (s.scope !== prev.scope && isDistractionFree()) enterDistractionFreeLayout();
if (s.scope === prev.scope || !isDistractionFree()) return;
enterDistractionFreeLayout();
showHint();
});
setContext(DISTRACTION_FREE_CONTEXT, isDistractionFree());
if (readStored()) setDistractionFree(true);
@@ -102,6 +138,7 @@ export function installDistractionFree(): () => void {
/** Test helper. */
export function resetDistractionFree() {
hintPending = false;
useDistractionFree.setState({ active: false });
setContext(DISTRACTION_FREE_CONTEXT, false);
writeStored(false);
+1
View File
@@ -34,6 +34,7 @@ export const NOTIFICATION_GROUPS: ReadonlyArray<{ id: string; title: string }> =
{ id: "search", title: "Find in Files" },
{ id: "plugins", title: "Plugins" },
{ id: "updates", title: "IDE Updates" },
{ id: "distractionFree", title: "Distraction Free Mode" },
];
export type NotificationDisplay = "balloon" | "sticky" | "none";