mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-08 17:02:17 +00:00
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:
1 parent
b4a3e866c6
commit
642b80cef0
7 files changed
+232
-11
No files matched your search
@@ -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" },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 />
|
||||
</>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in new issue
Block a user