feat(workbench): toggle the toolbar, status bar and tool window bars from View › Appearance (#2206)

The main toolbar and status bar could not be hidden, Tool Window Bars was a Settings row only, and widgets could be switched only from the status bar's context menu. View > Appearance now has Toolbar, Tool Window Bars, Status Bar and Status Bar Widgets, with check marks in the macOS and in-app menus, plus Settings toggles. Both new settings default to shown. With the toolbar hidden, the window keeps a compact header with the title, which Distraction Free now shares.

Controls stay reachable with a bar hidden: the Git branches popup and background tasks have their own hosts, and background tasks has an action.

Co-authored-by: lleviathe <[email protected]>
Цей коміт міститься в:
lleviathe authored and GitHub committed 2026-11-09 07:20:54 +08:00
1 джерело 75565927d8
коміт 8dd29bad41
42 files changed
+1001 -144

No files matched your search

+61 -5
Переглянути файл
@@ -4,7 +4,8 @@ import { _resetI18nForTests, setLocale } from "@/platform/i18n";
import { useKeymap } from "@/platform/keymap";
import * as ipc from "@/platform/ipc";
import { resetDistractionFree, setDistractionFree } from "@/workbench/distractionFree";
import { registerToolWindow } from "@/workbench/contributions";
import { registerStatusItem, registerToolWindow } from "@/workbench/contributions";
import { useProject } from "@/platform/project";
import { _reloadLayouts, saveLayout } from "@/workbench/toolwindows/layouts";
import { clearContext, setContext } from "@/platform/context";
import {
@@ -29,7 +30,7 @@ describe("menu state from the frontend", () => {
vi.spyOn(ipc.commands, "appBuildInfo").mockRejectedValue(new Error("No build info in tests"));
vi.spyOn(ipc.commands, "menuConfigure").mockResolvedValue();
vi.spyOn(ipc.commands, "menuModel").mockResolvedValue([
{ kind: "action", id: "test.menuFocus", label: "Test", hint: null, arg: null },
{ kind: "action", id: "test.menuFocus", label: "Test", hint: null, arg: null, checked: null },
]);
const pushDisabled = vi.spyOn(ipc.commands, "menuSetDisabled").mockResolvedValue();
const offAction = registerAction({ id: "test.menuFocus", title: "Test", run: () => {} });
@@ -84,11 +85,11 @@ describe("menu state from the frontend", () => {
id: "x",
label: "X",
children: [
{ kind: "action", id: "test.menu.whenEditor", label: "B", hint: null, arg: null },
{ kind: "action", id: "test.menu.always", label: "A", hint: null, arg: null },
{ kind: "action", id: "test.menu.whenEditor", label: "B", hint: null, arg: null, checked: null },
{ kind: "action", id: "test.menu.always", label: "A", hint: null, arg: null, checked: null },
],
},
{ kind: "action", id: "not.registered", label: "C", hint: null, arg: null },
{ kind: "action", id: "not.registered", label: "C", hint: null, arg: null, checked: null },
]);
expect([...ids].sort()).toEqual(["not.registered", "test.menu.always", "test.menu.whenEditor"]);
// Unregistered ids stay enabled: the action may register later (lazy features).
@@ -206,3 +207,58 @@ describe("window-scoped menu labels", () => {
resetDistractionFree();
});
});
describe("View › Appearance check marks", () => {
it("sends the toggle actions that are on", () => {
let on = true;
const offs = [
registerAction({ id: "test.menu.toggleOn", title: "On", toggled: () => on, run: () => {} }),
registerAction({ id: "test.menu.toggleOff", title: "Off", toggled: () => false, run: () => {} }),
];
const checked = () => buildMenuConfig("macos").checked!.filter((id) => id.startsWith("test.menu."));
expect(checked()).toEqual(["test.menu.toggleOn"]);
on = false;
expect(checked()).toEqual([]);
offs.forEach((d) => d());
});
it("lists titled status bar widgets in bar order with their visibility", () => {
const widget = (id: string, align: "left" | "right", priority: number, title?: string, defaultVisible?: boolean) =>
registerStatusItem({ id, align, priority, title, defaultVisible, component: () => null });
const offs = [
widget("w.encoding", "right", 2, "File Encoding"),
widget("w.caret", "right", 1, "Line:Column Number"),
widget("w.tasks", "left", 0),
widget("w.memory", "right", 3, "Memory Indicator", false),
widget("plugin.demo.w", "left", 1, "Demo Widget"),
];
useProject.setState({ settings: { appearance: { statusBarWidgets: { "w.caret": false } } } as never });
expect(dynamicMenuItems().statusBarWidgets).toEqual([
{ action: "view.toggleStatusBarWidget", arg: "plugin.demo.w", label: "Demo Widget", checked: true },
{ action: "view.toggleStatusBarWidget", arg: "w.caret", label: "Line:Column Number", checked: false },
{ action: "view.toggleStatusBarWidget", arg: "w.encoding", label: "File Encoding", checked: true },
{ action: "view.toggleStatusBarWidget", arg: "w.memory", label: "Memory Indicator", checked: false },
]);
useProject.setState({ settings: null });
offs.forEach((d) => d());
});
it("lists only the widgets a LightEdit window's status bar has", () => {
const offs = [
registerStatusItem({
id: "w.caret",
align: "right",
priority: 1,
title: "Line:Column Number",
lightEdit: true,
component: () => null,
}),
registerStatusItem({ id: "w.branch", align: "right", priority: 2, title: "Git Branch", component: () => null }),
];
useProject.setState({ project: null, lightEdit: true });
expect(dynamicMenuItems().statusBarWidgets!.map((i) => i.arg)).toEqual(["w.caret"]);
useProject.setState({ lightEdit: false });
expect(dynamicMenuItems().statusBarWidgets!.map((i) => i.arg)).toEqual(["w.caret", "w.branch"]);
offs.forEach((d) => d());
});
});
+40 -5
Переглянути файл
@@ -20,8 +20,10 @@ import {
useKeymap,
} from "@/platform/keymap";
import { useProject } from "@/platform/project";
import { useContributions, useMenuSlotItems } from "@/workbench/contributions";
import { useContributions, useMenuSlotItems, useStandaloneEditor } from "@/workbench/contributions";
import { isDistractionFree, useDistractionFree } from "@/workbench/distractionFree";
import { orderStatusItems } from "@/workbench/StatusBar";
import { statusWidgetRows } from "@/workbench/statusBarWidgets";
import { useToolWindowLayouts } from "@/workbench/toolwindows/layouts";
import { hostOs, type HostOs } from "./host";
@@ -77,10 +79,40 @@ export function buildMenuConfig(os: HostOs): MenuConfig {
const accelerator = first && menuAccelerator(first);
if (accelerator) accelerators[id] = accelerator;
}
return { locale: useI18n.getState().locale, labels, accelerators, dynamic: dynamicMenuItems() };
return {
locale: useI18n.getState().locale,
labels,
accelerators,
dynamic: dynamicMenuItems(),
checked: toggledActions(),
};
}
/** View › Tool Windows (every registered window, by title) and Window › Tool Window Layouts. */
/** Toggle actions that are on: their menu items show a check mark (View › Appearance). */
function toggledActions(): string[] {
return [...useActions.getState().actions.values()]
.filter((a) => a.toggled?.())
.map((a) => a.id)
.sort();
}
/** View › Appearance › Status Bar Widgets: the status bar's context menu, in bar order. */
function statusBarWidgetItems(): DynamicItem[] {
const { project, lightEdit, settings } = useProject.getState();
// A LightEdit window's status bar has only the items that apply there (Workbench.tsx).
const lightEditFrame = !project && (lightEdit || useStandaloneEditor.getState().title !== null);
const items = [...useContributions.getState().statusItems.values()].filter((i) => !lightEditFrame || i.lightEdit);
const { left, right } = orderStatusItems(items);
const overrides = settings?.appearance?.statusBarWidgets;
return statusWidgetRows([...left, ...right], overrides).map((row) => ({
action: "view.toggleStatusBarWidget",
arg: row.id,
label: row.verbatim ? row.title : tx(row.title),
checked: row.visible,
}));
}
/** View › Tool Windows (every registered window, by title), View › Appearance › Status Bar Widgets and Window › Tool Window Layouts. */
export function dynamicMenuItems(): Record<string, DynamicItem[]> {
const toolWindows = [...useContributions.getState().toolWindows.values()]
.map((tw) => ({ action: `toolwindow.${tw.id}`, arg: null, label: tw.verbatim ? tw.title : tx(tw.title) }))
@@ -96,7 +128,7 @@ export function dynamicMenuItems(): Record<string, DynamicItem[]> {
.sort((a, b) => a.label.localeCompare(b.label)),
]),
);
return { ...slots, toolWindows, toolWindowLayouts: layouts };
return { ...slots, toolWindows, toolWindowLayouts: layouts, statusBarWidgets: statusBarWidgetItems() };
}
/** Action ids of every IDE item in a menu model. */
@@ -186,7 +218,7 @@ export function installMenuBridge(): () => void {
if (s.active !== prev.active) push();
}),
useContributions.subscribe((s, prev) => {
if (s.toolWindows !== prev.toolWindows) push();
if (s.toolWindows !== prev.toolWindows || s.statusItems !== prev.statusItems) push();
}),
useToolWindowLayouts.subscribe(push),
useMenuSlotItems.subscribe(push),
@@ -195,6 +227,9 @@ export function installMenuBridge(): () => void {
// Opening / closing a project changes the Open Recent list.
useProject.subscribe((s, prev) => {
if (s.project?.root !== prev.project?.root) void commands.menuRefresh();
if (s.lightEdit !== prev.lightEdit) push();
// View › Appearance check marks and Status Bar Widgets.
if (s.settings?.appearance !== prev.settings?.appearance) push();
}),
];
return () => {
+55
Переглянути файл
@@ -0,0 +1,55 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ActionContext } from "@/platform/actions";
import type { Settings } from "@/platform/ipc";
const settingsSet = vi.fn();
vi.mock("@/platform/ipc", async (importOriginal) => {
const real = await importOriginal<typeof import("@/platform/ipc")>();
return {
...real,
isTauri: () => true,
commands: { ...real.commands, settingsSet: (...a: unknown[]) => settingsSet(...a) },
};
});
import { useProject } from "@/platform/project";
import { appearanceToggleActions } from "../appearanceRuntime";
import { validateAppearanceExtras } from "../model/behaviorSchema";
let appearance: Record<string, unknown>;
beforeEach(() => {
appearance = {};
const settings = () => ({ appearance: { ...appearance } }) as unknown as Settings;
useProject.setState({ settings: settings() });
settingsSet.mockReset().mockImplementation(async (_scope: string, key: string, json: string) => {
appearance[key.replace("appearance.", "")] = JSON.parse(json);
return { status: "ok", data: settings() };
});
});
afterEach(() => useProject.setState({ settings: null }));
describe("View › Appearance toggles", () => {
it("shows the toolbar and the status bar unless settings hide them", () => {
expect(validateAppearanceExtras({})).toMatchObject({ showMainToolbar: true, showStatusBar: true });
expect(validateAppearanceExtras({ showMainToolbar: false, showStatusBar: "no" })).toMatchObject({
showMainToolbar: false,
showStatusBar: true,
});
});
it.each([
["view.toggleCompactMode", "compactMode", false],
["view.toggleToolbar", "showMainToolbar", true],
["view.toggleToolWindowBars", "showToolWindowBars", true],
["view.toggleStatusBar", "showStatusBar", true],
])("%s flips appearance.%s in the global settings", async (id, key, initial) => {
const action = appearanceToggleActions().find((a) => a.id === id)!;
expect(action.toggled?.()).toBe(initial);
await action.run({ ctx: {} } as ActionContext);
expect(settingsSet).toHaveBeenCalledWith("global", `appearance.${key}`, JSON.stringify(!initial));
expect(action.toggled?.()).toBe(!initial);
});
});
+7
Переглянути файл
@@ -90,3 +90,10 @@ describe("page tree", () => {
);
});
});
it("indexes toolbar and status bar visibility in Appearance settings", () => {
const appearance = BEHAVIOR_PAGES.find((p) => p.id === "appearance")!;
const labels = specLabels(appearance);
expect(labels).toContain("Show main toolbar");
expect(labels).toContain("Show status bar");
});
+36 -14
Переглянути файл
@@ -114,21 +114,43 @@ export function zoomActions(): Action[] {
keywords: ["zoom", "scale", "100%", "accessibility"],
run: () => setZoom(100),
},
{
// View › Appearance › Compact Mode (Settings › Appearance › "Compact mode").
id: "view.toggleCompactMode",
title: "Compact Mode",
category: "View",
keywords: ["appearance", "density", "smaller"],
toggled: () => validateSettings(useProject.getState().settings).appearance.compactMode,
run: async () => {
if (!isTauri()) return;
const next = !validateSettings(useProject.getState().settings).appearance.compactMode;
useProject.setState({
settings: await call(commands.settingsSet("global", "appearance.compactMode", JSON.stringify(next))),
});
},
];
}
// ---------------------------------------------------------------------------
// View › Appearance toggles over boolean appearance settings (IDEA's ToggleActions)
type AppearanceFlag = "compactMode" | "showMainToolbar" | "showToolWindowBars" | "showStatusBar";
const appearanceFlag = (key: AppearanceFlag) => validateSettings(useProject.getState().settings).appearance[key];
function appearanceToggle(id: string, title: string, key: AppearanceFlag, keywords: string[]): Action {
return {
id,
title,
category: "View",
keywords: ["appearance", ...keywords],
toggled: () => appearanceFlag(key),
run: async () => {
if (!isTauri()) return;
const next = !appearanceFlag(key);
useProject.setState({
settings: await call(commands.settingsSet("global", `appearance.${key}`, JSON.stringify(next))),
});
},
};
}
export function appearanceToggleActions(): Action[] {
return [
appearanceToggle("view.toggleCompactMode", "Compact Mode", "compactMode", ["density", "smaller"]),
appearanceToggle("view.toggleToolbar", "Toolbar", "showMainToolbar", ["main toolbar", "header", "hide"]),
appearanceToggle("view.toggleToolWindowBars", "Tool Window Bars", "showToolWindowBars", [
"stripes",
"tool window buttons",
"hide",
]),
appearanceToggle("view.toggleStatusBar", "Status Bar", "showStatusBar", ["hide"]),
];
}
+2 -1
Переглянути файл
@@ -37,7 +37,7 @@ import {
settingsTitle,
useSettingsDialog,
} from "./model/staging";
import { zoomActions } from "./appearanceRuntime";
import { appearanceToggleActions, zoomActions } from "./appearanceRuntime";
import { activateFileTypes } from "./fileTypesRuntime";
import { trackRuntimeSettings } from "./runtime";
import { installActionProxies, installSettingsHost } from "./window/bridge";
@@ -123,6 +123,7 @@ export function activate(): () => void {
registerOverlay({ id: "settings.dialog", component: LazySettingsDialog }),
registerOverlay({ id: "settings.projectStructure", component: LazyProjectStructureDialog }),
registerActions(zoomActions()),
registerActions(appearanceToggleActions()),
registerActions(settingsActions((page, newProjects) => void showSettings(page, newProjects))),
registerActions([
{
+7
Переглянути файл
@@ -45,6 +45,9 @@ export interface AppearanceExtras {
showToolWindowNames: boolean;
showToolWindowBadges: boolean;
widescreenToolWindows: boolean;
/** View › Appearance › Toolbar; hidden leaves IDEA's compact window header. */
showMainToolbar: boolean;
showStatusBar: boolean;
}
export interface DateFormatValid {
@@ -105,6 +108,8 @@ export const DEFAULT_APPEARANCE_EXTRAS: AppearanceExtras = {
showToolWindowNames: false,
showToolWindowBadges: true,
widescreenToolWindows: false,
showMainToolbar: true,
showStatusBar: true,
};
export const DEFAULT_GENERAL_EXTRAS: GeneralExtras = {
@@ -163,6 +168,8 @@ export function validateAppearanceExtras(raw: unknown): AppearanceExtras {
showToolWindowNames: b("showToolWindowNames"),
showToolWindowBadges: b("showToolWindowBadges"),
widescreenToolWindows: b("widescreenToolWindows"),
showMainToolbar: b("showMainToolbar"),
showStatusBar: b("showStatusBar"),
};
}
+2
Переглянути файл
@@ -642,6 +642,8 @@ export const BEHAVIOR_PAGES: PageSpec[] = [
{
title: "UI Options",
options: [
{ kind: "toggle", key: "appearance.showMainToolbar", label: "Show main toolbar" },
{ kind: "toggle", key: "appearance.showStatusBar", label: "Show status bar" },
{
kind: "custom",
label: "Main menu",
+11 -5
Переглянути файл
@@ -1,6 +1,6 @@
import { branchesByRepo } from "./multiRoot";
/** Title bar branch widget (branch name + incoming / outgoing markers) + Branches popup. */
import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type RefObject } from "react";
import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react";
import clsx from "clsx";
import { create } from "zustand";
import type { Branch, BranchInfo } from "@/bindings";
@@ -23,6 +23,7 @@ import {
type MenuItem,
T,
} from "@/ui";
import type { PopupAnchor } from "@/ui/Popup";
import { buildBranchRows, withExpanded, type BranchRow, type PopupAction } from "./branches";
import { branchPresentation, extensionPopupActions, useBranchWidgetExtensions } from "./branchWidgetExtensions";
import { branchMenu, commonBranchMenu, inRepo, tagMenu } from "./branchMenu";
@@ -68,7 +69,6 @@ export function BranchWidget() {
const otherOp = useVcs((s) => (current ? (s.operations[current] ?? null) : null));
const open = useBranchesPopup((s) => s.open);
const extensions = useBranchWidgetExtensions((s) => s.extensions);
const ref = useRef<HTMLButtonElement>(null);
if (!available || !selected) return null;
// Another feature presents the current branch (the GitHub pull request on it).
const presented = other ? null : branchPresentation(extensions);
@@ -98,7 +98,7 @@ export function BranchWidget() {
return (
<>
<BarButton
ref={ref}
data-branches-widget
variant="title"
className={styles.widget}
tooltip={tooltip}
@@ -120,11 +120,17 @@ export function BranchWidget() {
<BranchSync behind={branch.behind} ahead={branch.ahead} showCounts={false} />
<Icon name="chevron-down" size={14} className={styles.chevron} />
</BarButton>
{open && <BranchesPopup anchor={ref} onClose={() => useBranchesPopup.setState({ open: false })} />}
</>
);
}
export function BranchesPopupHost() {
const open = useBranchesPopup((s) => s.open);
if (!open) return null;
const anchor = document.querySelector("[data-branches-widget]") ?? { x: window.innerWidth / 2 - 160, y: 60 };
return <BranchesPopup anchor={anchor} onClose={() => useBranchesPopup.setState({ open: false })} />;
}
const POPUP_ACTIONS: PopupAction[] = [
{ id: "vcs.update", label: "Update Project…", icon: "git-pull", group: "sync" },
{ id: "vcs.commit", label: "Commit…", icon: "commit", group: "sync" },
@@ -283,7 +289,7 @@ const caretAt = (e: KeyboardEvent, where: "start" | "end") => {
return where === "end" ? input.selectionStart === input.value.length : input.selectionStart === 0;
};
function BranchesPopup({ anchor, onClose }: { anchor: RefObject<HTMLElement | null>; onClose: () => void }) {
function BranchesPopup({ anchor, onClose }: { anchor: PopupAnchor; onClose: () => void }) {
const multi = useVcs((s) => s.roots.length > 1);
const extensions = useBranchWidgetExtensions((s) => s.extensions);
const actions = useMemo(() => [...POPUP_ACTIONS, ...extensionPopupActions(extensions)], [extensions]);
+18
Переглянути файл
@@ -0,0 +1,18 @@
import { act, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, expect, it, vi } from "vitest";
import { BranchesPopupHost, showBranchesPopup, useBranchesPopup } from "../BranchWidget";
vi.mock("@/platform/ipc", async (original) => {
const real = await original<typeof import("@/platform/ipc")>();
return { ...real, commands: { ...real.commands, vcsTags: async () => ({ status: "ok", data: [] }) } };
});
afterEach(() => useBranchesPopup.setState({ open: false }));
it("opens Git Branches without a mounted toolbar widget", () => {
render(<BranchesPopupHost />);
act(showBranchesPopup);
expect(screen.getByRole("dialog", { name: "Git Branches" })).toBeInTheDocument();
fireEvent.keyDown(document, { key: "Escape" });
expect(useBranchesPopup.getState().open).toBe(false);
});
+2 -1
Переглянути файл
@@ -24,7 +24,7 @@ import { registerLogPaneActions } from "./logPaneActions";
import { controlLabel, controlOperation, inProgress } from "./operations";
import { annotationsShown, blameSlot } from "./blame";
import { inlineBlame } from "./inlineBlame";
import { BranchWidget, useBranchesPopup } from "./BranchWidget";
import { BranchWidget, BranchesPopupHost, useBranchesPopup } from "./BranchWidget";
import { showConflictsDialog, takeContinueWhenResolved } from "./conflictsUi";
import { registerCompareActions } from "./compare";
import { DIFF_KIND, diffTitle, type DiffData } from "./diffInput";
@@ -238,6 +238,7 @@ export function activate(): () => void {
trackMergeRebaseItem(),
installHunkPopup(),
registerOverlay({ id: "vcs.dialogs", component: VcsDialogHost }),
registerOverlay({ id: "vcs.branches", component: BranchesPopupHost }),
activateConflictBanner(),
activateConflictMarkers(),
trackCommitWindow(),