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

+14 -2
Переглянути файл
@@ -1911,6 +1911,9 @@ export type AppearanceSettings = {
showToolWindowNames?: boolean,
showToolWindowBadges?: boolean,
widescreenToolWindows?: boolean,
/** View › Appearance › Toolbar; hidden leaves IDEA's compact window header. */
showMainToolbar?: boolean,
showStatusBar?: boolean,
/**
* Status bar widgets shown (`true`) or hidden (`false`) by the user
* (status bar context menu); widgets not listed use their default.
@@ -3548,6 +3551,8 @@ export type DynamicItem = {
action: string,
arg: string | null,
label: string,
/** Drawn with a check mark in this state; `None` for a plain item. */
checked?: boolean | null,
};
/** Editor › General › Appearance. */
@@ -5705,6 +5710,8 @@ export type MenuConfig = {
accelerators: { [key in string]: string },
/** [`Entry::Dynamic`] slot → its items, already localised. */
dynamic?: { [key in string]: DynamicItem[] },
/** Ids of the toggle actions currently on. */
checked?: string[],
};
/**
@@ -5712,8 +5719,13 @@ export type MenuConfig = {
* Labels are the English defaults; the frontend localises them like the native menu.
*/
export type MenuNode = { kind: "submenu"; id: string; label: string; children: MenuNode[] } |
/** Runs `id` with `arg`. Items with an `arg` (Open Recent) carry their final label. */
{ kind: "action"; id: string; label: string; hint: string | null; arg: string | null } |
/**
* Runs `id` with `arg`. Items with an `arg` (Open Recent) carry their final label.
* `checked` is the state of a frontend-listed toggle (Status Bar Widgets). Spec toggles
* leave it unset: the frontend menu reads their state from the action (`toggled`), the
* source [`MenuConfig::checked`] is built from for the native menu.
*/
{ kind: "action"; id: string; label: string; hint: string | null; arg: string | null; checked: boolean | null } |
/** A disabled placeholder such as "No Recent Projects". */
{ kind: "placeholder"; id: string; label: string } | { kind: "separator" };
+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(),
+4
Переглянути файл
@@ -394,6 +394,10 @@
"menu.view.zoomOut": "Zoom Out IDE",
"menu.view.resetZoom": "Reset IDE Zoom",
"menu.toolbar.customize": "Customize Main Toolbar…",
"menu.view.toggleToolbar": "Toolbar",
"menu.view.toggleToolWindowBars": "Tool Window Bars",
"menu.view.toggleStatusBar": "Status Bar",
"menu.statusBarWidgets": "Status Bar Widgets",
"menu.code.quickDefinition": "Quick Definition",
"menu.code.quickTypeDefinition": "Quick Type Definition",
"menu.localHistory.recentChanges": "Recent Changes",
+10 -1
Переглянути файл
@@ -552,5 +552,14 @@
"No build": "No build",
"Build, no error check": "Build, no error check",
"Before launch build for this Java configuration": "Before launch build for this Java configuration",
"the bundle of {gemfile} has no `debug` gem. Add `gem \"debug\"` to the Gemfile and run `bundle install`": "the bundle of {gemfile} has no `debug` gem. Add `gem \"debug\"` to the Gemfile and run `bundle install`"
"the bundle of {gemfile} has no `debug` gem. Add `gem \"debug\"` to the Gemfile and run `bundle install`": "the bundle of {gemfile} has no `debug` gem. Add `gem \"debug\"` to the Gemfile and run `bundle install`",
"Toolbar": "Toolbar",
"Tool Window Bars": "Tool Window Bars",
"Status Bar": "Status Bar",
"Status Bar Widget": "Status Bar Widget",
"Show main toolbar": "Show main toolbar",
"Show status bar": "Show status bar",
"No background tasks": "No background tasks",
"Status Bar Widgets": "Status Bar Widgets",
"Background Tasks": "Background Tasks"
}
+9 -1
Переглянути файл
@@ -3452,7 +3452,13 @@
"Build images before starting (--build)": "启动前构建镜像 (--build)",
"Up options": "Up 选项",
"e.g. --force-recreate --remove-orphans": "例如 --force-recreate --remove-orphans",
"Show main toolbar": "显示主工具栏",
"Show status bar": "显示状态栏",
"Compact Mode": "紧凑模式",
"Toolbar": "工具栏",
"Tool Window Bars": "工具窗口栏",
"Status Bar": "状态栏",
"Status Bar Widget": "状态栏微件",
"Override File Type": "覆盖文件类型",
"Revert File Type Override": "还原文件类型覆盖",
"Add Content Root": "添加内容根",
@@ -6896,5 +6902,7 @@
"Servers": "服务器",
"Open main menu with a lone Alt press": "单独按下 Alt 打开主菜单",
"When off, Alt key combinations still work but pressing Alt alone does not focus the main menu": "关闭后,Alt 组合键仍然可用,但单独按下 Alt 不会聚焦主菜单",
"the bundle of {gemfile} has no `debug` gem. Add `gem \"debug\"` to the Gemfile and run `bundle install`": "{gemfile} 的 bundle 中没有 `debug` gem。请在 Gemfile 中添加 `gem \"debug\"`,然后运行 `bundle install`"
"the bundle of {gemfile} has no `debug` gem. Add `gem \"debug\"` to the Gemfile and run `bundle install`": "{gemfile} 的 bundle 中没有 `debug` gem。请在 Gemfile 中添加 `gem \"debug\"`,然后运行 `bundle install`",
"No background tasks": "没有后台任务",
"Status Bar Widgets": "状态栏微件"
}
+4
Переглянути файл
@@ -394,6 +394,10 @@
"menu.view.zoomOut": "缩小 IDE",
"menu.view.resetZoom": "重置 IDE 缩放",
"menu.toolbar.customize": "自定义主工具栏…",
"menu.view.toggleToolbar": "工具栏",
"menu.view.toggleToolWindowBars": "工具窗口栏",
"menu.view.toggleStatusBar": "状态栏",
"menu.statusBarWidgets": "状态栏微件",
"menu.code.quickDefinition": "快速定义",
"menu.code.quickTypeDefinition": "快速类型定义",
"menu.localHistory.recentChanges": "最近的更改",
+2
Переглянути файл
@@ -257,6 +257,8 @@ html {
/* metrics (theme JSON at 100%) */
--h-titlebar: 40px;
/* IDEA's compact header (HEADER_HEIGHT_DFM), the same in compact mode. */
--h-compact-header: 30px;
--titlebar-tool-button: 30px;
/* tool window stripe button, icons only */
--stripe-icon-button: 30px;
+4 -2
Переглянути файл
@@ -31,6 +31,8 @@ export type MenuItem =
action: string;
arg?: unknown;
label?: string;
/** A check mark in this state instead of the action's own `toggled` (one row per argument). */
checked?: boolean;
icon?: IconName | (string & {});
disabled?: boolean;
}
@@ -82,12 +84,12 @@ export function resolveMenuItems(items: MenuItem[]): Resolved[] {
if (!a) return;
out.push({
kind: "item",
key: item.action,
key: typeof item.arg === "string" ? `${item.action}|${item.arg}` : item.action,
label: tx(item.label) ?? a.title,
icon: item.icon ?? a.icon,
shortcut: shortcutLabel(item.action),
disabled: !!item.disabled || !isActionEnabled(a),
checked: actionToggled(a),
checked: item.checked ?? actionToggled(a),
run: () => void executeActionFrom("menu", item.action, item.arg),
});
return;
+39 -2
Переглянути файл
@@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { commands, type Settings } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { registerActions } from "@/platform/actions";
import { usePlatform } from "@/platform/platformInfo";
import { MainMenu } from "./MainMenu";
let available = 500;
@@ -35,13 +36,13 @@ beforeEach(() => {
kind: "submenu",
id: "file",
label: "File",
children: [{ kind: "action", id: "test.open", label: "Open", hint: null, arg: null }],
children: [{ kind: "action", id: "test.open", label: "Open", hint: null, arg: null, checked: null }],
},
{
kind: "submenu",
id: "edit",
label: "Edit",
children: [{ kind: "action", id: "test.undo", label: "Undo", hint: null, arg: null }],
children: [{ kind: "action", id: "test.undo", label: "Undo", hint: null, arg: null, checked: null }],
},
]);
dispose = registerActions([
@@ -267,3 +268,39 @@ describe("main menu presentation", () => {
expect(screen.getByRole("button", { name: "Main Menu" })).toBeInTheDocument();
});
});
it.each(["windows", "linux"] as const)("renders toggle and widget checks in the %s main menu", async (os) => {
const platform = usePlatform.getState();
usePlatform.setState({ os, framelessTitleBar: true, overlayTitleBar: false });
const off = registerActions([
{ id: "view.toggleToolbar", title: "Toolbar", toggled: () => true, run: () => {} },
{ id: "view.toggleStatusBarWidget", title: "Status Bar Widget", run: () => {} },
]);
vi.mocked(commands.menuModel).mockResolvedValue([
{
kind: "submenu",
id: "view",
label: "View",
children: [
{ kind: "action", id: "view.toggleToolbar", label: "Toolbar", hint: null, arg: null, checked: null },
{
kind: "action",
id: "view.toggleStatusBarWidget",
label: "Memory Indicator",
hint: null,
arg: "memory",
checked: false,
},
],
},
]);
try {
render(<MainMenu />);
fireEvent.click(screen.getByRole("button", { name: "Main Menu" }));
expect(await screen.findByRole("menuitemcheckbox", { name: "Toolbar" })).toHaveAttribute("aria-checked", "true");
expect(screen.getByRole("menuitemcheckbox", { name: "Memory Indicator" })).toHaveAttribute("aria-checked", "false");
} finally {
off();
usePlatform.setState(platform);
}
});
+21 -1
Переглянути файл
@@ -1,4 +1,6 @@
import { describe, expect, it } from "vitest";
import { registerAction } from "@/platform/actions";
import { resolveMenuItems } from "@/ui/Menu";
import { menuItems, onLoneAlt } from "./MainMenu";
describe("lone Alt opens the main menu", () => {
@@ -55,7 +57,7 @@ describe("lone Alt opens the main menu", () => {
describe("main menu model", () => {
it("maps menu nodes to menu items, localising spec labels only", () => {
const items = menuItems([
{ kind: "action", id: "file.save", label: "Save All", hint: null, arg: null },
{ kind: "action", id: "file.save", label: "Save All", hint: null, arg: null, checked: null },
{ kind: "separator" },
{
kind: "submenu",
@@ -68,6 +70,7 @@ describe("main menu model", () => {
label: "openidea — ~/openidea",
hint: null,
arg: "/p/openidea",
checked: null,
},
{ kind: "placeholder", id: "recent.none", label: "No Recent Projects" },
],
@@ -86,3 +89,20 @@ describe("main menu model", () => {
expect(sub[1]).toMatchObject({ label: "No Recent Projects", disabled: true });
});
});
describe("check marks in the main menu", () => {
it("shows a frontend-listed item's own state, one row per argument", () => {
const off = registerAction({ id: "test.mainMenu.widget", title: "Widget", run: () => {} });
const node = (arg: string, checked: boolean) =>
({ kind: "action", id: "test.mainMenu.widget", label: arg, hint: null, arg, checked }) as const;
const items = menuItems([node("memory", true), node("caret", false)]);
expect(items[0]).toMatchObject({ action: "test.mainMenu.widget", arg: "memory", checked: true });
const rows = resolveMenuItems(items);
expect(rows.map((r) => [r.label, r.checked])).toEqual([
["memory", true],
["caret", false],
]);
expect(new Set(rows.map((r) => r.key)).size).toBe(2);
off();
});
});
+7 -1
Переглянути файл
@@ -29,7 +29,13 @@ export function menuItems(nodes: MenuNode[]): MenuItem[] {
case "submenu":
return { id: n.id, label: label(n.id, n.label), submenu: menuItems(n.children) };
case "action":
return { kind: "action", action: n.id, arg: n.arg ?? undefined, label: n.arg ? n.label : label(n.id, n.label) };
return {
kind: "action",
action: n.id,
arg: n.arg ?? undefined,
label: n.arg ? n.label : label(n.id, n.label),
...(n.checked != null && { checked: n.checked }),
};
}
});
}
+54 -32
Переглянути файл
@@ -1,4 +1,5 @@
import { useMemo, useRef, useState } from "react";
import { create } from "zustand";
import { useMemo } from "react";
import clsx from "clsx";
import { BarButton, IconButton, Popup, ProgressBar, showContextMenu, T, tooltipAttrs } from "@/ui";
import { useContributions, useStatusInfo, type StatusItemContribution } from "./contributions";
@@ -86,11 +87,16 @@ function TaskCancel({ task: t }: { task: BackgroundTask }) {
);
}
const useTaskPopup = create<{ open: boolean }>(() => ({ open: false }));
export function showBackgroundTasks() {
useTaskPopup.setState({ open: true });
}
/** Built-in: background task progress; click lists all running tasks. */
export function TaskStatus() {
const tasks = useTasks((s) => s.tasks);
const [open, setOpen] = useState(false);
const ref = useRef<HTMLButtonElement>(null);
const open = useTaskPopup((s) => s.open);
const running = tasks.filter((t) => !t.done);
// A task that is working wins over the queued ones waiting behind it.
const current = running.findLast((t) => !t.queued) ?? running[running.length - 1] ?? tasks[tasks.length - 1];
@@ -99,11 +105,11 @@ export function TaskStatus() {
return (
<>
<BarButton
ref={ref}
data-background-tasks
aria-expanded={open}
aria-label={tx`Background tasks: ${current.title}`}
tooltip={current.message ?? current.title}
onClick={() => setOpen((v) => !v)}
onClick={() => useTaskPopup.setState({ open: !open })}
className={styles.task}
>
<span className={styles.taskTitle}>
@@ -116,33 +122,49 @@ export function TaskStatus() {
/>
{more > 0 && <span className={styles.taskMore}>+{more}</span>}
</BarButton>
<Popup
open={open && tasks.length > 0}
anchor={ref}
placement="top-end"
offset={6}
onClose={() => setOpen(false)}
aria-label="Background tasks"
>
<div className={styles.taskList}>
<div className={styles.taskListHeader}>
<T>Background Tasks</T>
</div>
{tasks.map((t) => (
<div key={t.id} className={styles.taskRow}>
<div className={styles.taskRowHead}>
<span className={styles.taskRowTitle}>
<T>{t.title}</T>
</span>
{t.fraction != null && <span className={styles.taskPct}>{Math.round(t.fraction * 100)}%</span>}
<TaskCancel task={t} />
</div>
<ProgressBar value={t.done ? 1 : t.fraction} aria-label={t.title} />
{t.message && <div className={styles.taskMessage}>{t.message}</div>}
</div>
))}
</div>
</Popup>
</>
);
}
export function BackgroundTasksHost() {
const open = useTaskPopup((s) => s.open);
const tasks = useTasks((s) => s.tasks);
const anchor = document.querySelector("[data-background-tasks]") ?? {
x: window.innerWidth - 16,
y: window.innerHeight - 16,
};
return (
<Popup
open={open}
anchor={anchor}
placement="top-end"
offset={6}
onClose={() => useTaskPopup.setState({ open: false })}
aria-label="Background tasks"
>
<div className={styles.taskList}>
<div className={styles.taskListHeader}>
<T>Background Tasks</T>
</div>
{tasks.length === 0 && (
<div className={styles.taskRow}>
<T>No background tasks</T>
</div>
)}
{tasks.map((t) => (
<div key={t.id} className={styles.taskRow}>
<div className={styles.taskRowHead}>
<span className={styles.taskRowTitle}>
<T>{t.title}</T>
</span>
{t.fraction != null && <span className={styles.taskPct}>{Math.round(t.fraction * 100)}%</span>}
<TaskCancel task={t} />
</div>
<ProgressBar value={t.done ? 1 : t.fraction} aria-label={t.title} />
{t.message && <div className={styles.taskMessage}>{t.message}</div>}
</div>
))}
</div>
</Popup>
);
}
+20
Переглянути файл
@@ -30,6 +30,26 @@
background: var(--bg-dialog, var(--bg-elev));
box-shadow: none;
}
/* IDEA's compact header (Distraction Free, toolbar hidden): the window title, centred. */
.compactHeader {
--h-titlebar: var(--h-compact-header);
}
.overlay.compactHeader {
padding-right: max(82px, calc(82px / var(--titlebar-zoom, 1)));
}
.overlayFullscreen.compactHeader {
padding-right: 10px;
}
.compactTitle {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
text-align: center;
font-size: var(--fs-ui);
color: var(--fg-secondary);
cursor: default;
}
.windowTitle {
min-width: 0;
overflow: hidden;
+21
Переглянути файл
@@ -163,3 +163,24 @@ describe("merged title bar menu", () => {
},
);
});
describe("compact window header", () => {
it("shows the window title and follows it, without toolbar widgets", async () => {
document.title = "demo – src/main.rs";
render(<TitleBar compact />);
const bar = document.querySelector("header[data-titlebar='compact']")!;
expect(bar.textContent).toBe("demo – src/main.rs");
expect(bar.querySelector("button")).toBeNull();
await act(async () => {
document.title = "demo – Cargo.toml";
await Promise.resolve();
});
expect(bar.textContent).toBe("demo – Cargo.toml");
});
it("prefers an explicit title (LightEdit)", () => {
document.title = "ignored";
render(<TitleBar compact lightEdit title="notes.txt" />);
expect(document.querySelector("header[data-titlebar='compact']")!.textContent).toBe("notes.txt");
});
});
+27 -11
Переглянути файл
@@ -29,6 +29,7 @@ import { useContributedThemes } from "@/platform/themes";
import { currentThemeChoice, useThemeOverride } from "./theme";
import styles from "./TitleBar.module.css";
import { useNativeTitlebar } from "./nativeTitlebar";
import { useDocumentTitle } from "./documentTitle";
import { windowDragRegion } from "./window-drag";
export function orderTitleItems(items: Iterable<TitleItemContribution>) {
@@ -271,21 +272,35 @@ function ToolbarEntry({ id, items }: { id: string; items: ReadonlyMap<string, Ti
);
}
/** IDEA's compact header: the window title in the middle of a 30px bar. */
function CompactTitle({ title }: { title?: string }) {
const documentTitle = useDocumentTitle();
return (
<span className={styles.compactTitle} {...windowDragRegion}>
{title ?? documentTitle}
</span>
);
}
/**
* 40px main toolbar. macOS: native traffic lights overlay its left edge.
* Windows and Linux: the window is frameless, so the bar carries IDEA's main menu (☰) and
* the caption buttons. `dialog`: a dialog window's bar (Settings), close button only.
* `lightEdit`: a LightEdit window's bar, its title and the menu but no toolbar widgets.
* `compact`: IDEA's compact header (Distraction Free, View › Appearance › Toolbar off).
* `bare`: the window frame only, while a project opens.
*/
export function TitleBar({
minimal = false,
bare = false,
compact = false,
dialog = false,
lightEdit = false,
title,
}: {
minimal?: boolean;
bare?: boolean;
compact?: boolean;
dialog?: boolean;
lightEdit?: boolean;
title?: string;
@@ -303,18 +318,19 @@ export function TitleBar({
overlay && fullscreen && styles.overlayFullscreen,
frameless && styles.frameless,
);
// Distraction Free Mode: the window frame only. Where the title bar carries the
// main menu (Windows, Linux) it stays, or nothing would lead back out of the mode.
if (bare)
// Where the title bar carries the main menu (Windows, Linux) it stays, or nothing
// would lead back out of Distraction Free Mode or bring the toolbar back.
if (bare || compact)
return (
<header ref={setBar} className={className} {...windowDragRegion} data-titlebar="bare">
{frameless && (
<>
<MainMenu />
<div className={styles.drag} {...windowDragRegion} />
<WindowControls />
</>
)}
<header
ref={setBar}
className={clsx(className, compact && styles.compactHeader)}
{...windowDragRegion}
data-titlebar={compact ? "compact" : "bare"}
>
{frameless && <MainMenu />}
{compact ? <CompactTitle title={title} /> : frameless && <div className={styles.drag} {...windowDragRegion} />}
{frameless && <WindowControls />}
</header>
);
const entry = (id: string) => <ToolbarEntry key={id} id={id} items={map} />;
+12 -5
Переглянути файл
@@ -23,19 +23,25 @@ import { WindowResizeHandles } from "./WindowResizeHandles";
import { useContributions, useStandaloneEditor } from "./contributions";
import styles from "./Workbench.module.css";
/** View › Appearance switches (`appearance.showMainToolbar`, `appearance.showStatusBar`), shown by default. */
function useAppearanceFlag(key: "showMainToolbar" | "showStatusBar"): boolean {
return useProject((s) => s.settings?.appearance?.[key] !== false);
}
/** LightEdit: editor tabs only; no tool windows, toolbar widgets or project status items. */
function LightEditFrame({ title }: { title?: string }) {
const path = useEditorRegistry((s) => s.active?.ctx.path);
const distractionFree = useDistractionFree((s) => s.active);
const statusBar = useAppearanceFlag("showStatusBar");
return (
<>
<TitleBar lightEdit bare={distractionFree} title={title ?? (path ? basename(path) : "LightEdit")} />
<TitleBar lightEdit compact={distractionFree} title={title ?? (path ? basename(path) : "LightEdit")} />
<main className={styles.lightEdit} data-light-edit>
<div className={styles.lightEditIsland}>
<EditorArea />
</div>
</main>
{!distractionFree && <StatusBar lightEdit />}
{!distractionFree && statusBar && <StatusBar lightEdit />}
</>
);
}
@@ -70,6 +76,8 @@ export function Workbench() {
const openingPath = useProject((s) => s.openingPath);
const root = project?.root ?? null;
const distractionFree = useDistractionFree((s) => s.active);
const mainToolbar = useAppearanceFlag("showMainToolbar");
const statusBar = useAppearanceFlag("showStatusBar");
const frame = project ? styles.framed : lightEdit || standaloneTitle ? styles.lightEditFrame : undefined;
return (
@@ -80,14 +88,13 @@ export function Workbench() {
<div className={styles.content} inert={openingPath !== null} aria-hidden={openingPath ? true : undefined}>
{project ? (
<>
{/* Distraction Free Mode keeps the bar for the traffic lights and window dragging only. */}
<TitleBar bare={distractionFree} />
<TitleBar compact={distractionFree || !mainToolbar} />
{!distractionFree && <WindowGroupTabs />}
{[...banners.values()].map(({ id, component: Banner }) => (
<Banner key={id} />
))}
<ToolWindowLayout key={root} editor={<EditorArea />} />
{!distractionFree && <StatusBar />}
{!distractionFree && statusBar && <StatusBar />}
</>
) : lightEdit || standaloneTitle ? (
<LightEditFrame title={standaloneTitle ?? undefined} />
+2 -1
Переглянути файл
@@ -25,7 +25,7 @@ import {
import { loadPlatformInfo } from "@/platform/platformInfo";
import { registerShellActions } from "./shellActions";
import { MainToolbarDialogHost } from "./MainToolbarDialog";
import { StatusInfo, TaskStatus } from "./StatusBar";
import { BackgroundTasksHost, StatusInfo, TaskStatus } from "./StatusBar";
import { installTaskEvents } from "./tasks";
import { installToolWindowActions } from "./toolwindows/actions";
import {
@@ -67,6 +67,7 @@ export function activateWorkbench(): () => void {
installBrowserGuards(),
installFileDrop(),
registerOverlay({ id: "workbench.customizeToolbar", component: MainToolbarDialogHost }),
registerOverlay({ id: "workbench.backgroundTasks", component: BackgroundTasksHost }),
registerStatusItem({ id: "workbench.tasks", align: "right", priority: 0, component: TaskStatus, lightEdit: true }),
registerStatusItem({ id: "workbench.info", align: "left", priority: 5, component: StatusInfo, lightEdit: true }),
registerToolWindow({
+31 -1
Переглянути файл
@@ -3,6 +3,7 @@ import { act, render, screen } from "@testing-library/react";
import { executeAction, getAction } from "@/platform/actions";
import { getContext } from "@/platform/context";
import { bindingsFor, installKeyboardDispatcher, useKeymap } from "@/platform/keymap";
import type { Settings } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { registerStatusItem, registerToolWindow } from "./contributions";
import { clearNotifications, useNotifications } from "./notificationStore";
@@ -216,7 +217,7 @@ describe("distraction free shell", () => {
expect(screen.queryByText("status-x")).toBeNull();
expect(screen.queryByRole("button", { name: "Project" })).toBeNull();
expect(screen.queryByRole("button", { name: "Search Everywhere" })).toBeNull();
const bar = document.querySelector("header[data-titlebar='bare']");
const bar = document.querySelector("header[data-titlebar='compact']");
// macOS uses its own drag region attribute (window-drag.ts); jsdom reports the host OS.
expect(bar?.hasAttribute("data-tauri-drag-region") || bar?.hasAttribute("data-window-drag-region")).toBe(true);
@@ -226,4 +227,33 @@ describe("distraction free shell", () => {
unmount();
useProject.setState({ project: null });
});
it("View › Appearance hides the main toolbar and the status bar on their own", async () => {
const { Workbench } = await import("./Workbench");
disposers.push(
registerStatusItem({ id: "t.status", align: "left", priority: 0, component: () => <span>status-x</span> }),
);
const appearance = (a: Record<string, boolean>) =>
act(() => useProject.setState({ settings: { appearance: a } as unknown as Settings }));
act(() =>
useProject.setState({
project: { root: "/work/df", name: "df-demo", trusted: true, isGit: false, settings: {} },
}),
);
document.title = "df-demo – main.rs";
const { unmount } = render(<Workbench />);
expect(await screen.findByText("df-demo")).toBeInTheDocument();
appearance({ showMainToolbar: false });
expect(screen.queryByText("df-demo")).toBeNull();
expect(document.querySelector("header[data-titlebar='compact']")?.textContent).toBe("df-demo – main.rs");
expect(screen.getByText("status-x")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Project" })).toBeInTheDocument();
appearance({ showStatusBar: false });
expect(screen.getByText("df-demo")).toBeInTheDocument();
expect(screen.queryByText("status-x")).toBeNull();
unmount();
useProject.setState({ project: null, settings: null });
});
});
+12
Переглянути файл
@@ -0,0 +1,12 @@
import { useSyncExternalStore } from "react";
function subscribe(changed: () => void): () => void {
const observer = new MutationObserver(changed);
observer.observe(document.head, { subtree: true, childList: true, characterData: true });
return () => observer.disconnect();
}
/** The window title as `features/app/title.ts` writes it to `document.title`. */
export function useDocumentTitle(): string {
return useSyncExternalStore(subscribe, () => document.title);
}
+12
Переглянути файл
@@ -95,6 +95,18 @@ describe("native title bar alignment", () => {
expect(disconnect).toHaveBeenCalledTimes(2);
});
it("hands the window buttons back to the remaining bar when another bar goes away", () => {
zoom(100);
renderHook(() => useNativeTitlebar(titlebar(30).bar));
expect(setHeight).toHaveBeenLastCalledWith(30);
// The project-opening screen covers the frame with its own 40px bar.
const opening = renderHook(() => useNativeTitlebar(titlebar(40).bar));
expect(setHeight).toHaveBeenLastCalledWith(40);
opening.unmount();
expect(setHeight).toHaveBeenLastCalledWith(30);
expect(setHeight).toHaveBeenCalledTimes(3);
});
it("leaves windows without native overlay buttons alone", () => {
usePlatform.setState({ overlayTitleBar: false });
const { bar } = titlebar(40);
+14
Переглянути файл
@@ -4,6 +4,13 @@ import { usePlatform } from "@/platform/platformInfo";
import { useProject } from "@/platform/project";
import { clampZoom } from "@/platform/zoom";
/**
* Mounted bars, newest last, each with a resend that ignores what it sent before. The window
* has one set of buttons: when a bar covering another goes away (the project-opening screen
* over the frame), the one left re-aligns them to its own height.
*/
const mounted: Array<() => void> = [];
export function useNativeTitlebar(bar: HTMLElement | null) {
const overlay = usePlatform((s) => s.overlayTitleBar);
const zoom = useProject((s) => (s.settings ? clampZoom(s.settings.appearance?.zoom) : null));
@@ -25,9 +32,16 @@ export function useNativeTitlebar(bar: HTMLElement | null) {
update();
const observer = new ResizeObserver(update);
observer.observe(bar);
const resend = () => {
lastHeight.current = null;
update();
};
mounted.push(resend);
return () => {
observer.disconnect();
bar.style.removeProperty("--titlebar-zoom");
mounted.splice(mounted.indexOf(resend), 1);
mounted.at(-1)?.();
};
}, [bar, overlay, zoom]);
}
+17
Переглянути файл
@@ -1,3 +1,4 @@
import { showBackgroundTasks } from "./StatusBar";
import { executeAction, getAction, registerActions } from "@/platform/actions";
import { isTauri } from "@/platform/ipc";
import { closeProject, confirmLeaveProject, openProject, useProject } from "@/platform/project";
@@ -10,6 +11,7 @@ import { activateToolWindow } from "./toolwindows/store";
import { toggleTheme } from "./theme";
import { cancelTask, canCancel, useTasks } from "./tasks";
import { maybeEditorService, showStatusInfo } from "./contributions";
import { toggleStatusWidget } from "./statusBarWidgets";
export async function openProjectDialog() {
if (!isTauri()) {
@@ -105,6 +107,21 @@ export function registerShellActions(): () => void {
keywords: ["toolbar", "widgets", "title bar"],
run: showCustomizeToolbar,
},
{
// View › Appearance › Status Bar Widgets: the argument is the widget id.
id: "view.toggleStatusBarWidget",
title: "Status Bar Widget",
category: "View",
hidden: true,
run: ({ arg }) => (typeof arg === "string" ? toggleStatusWidget(arg) : undefined),
},
{
id: "view.backgroundTasks",
title: "Background Tasks",
category: "View",
keywords: ["progress", "running", "processes"],
run: showBackgroundTasks,
},
{
id: "view.notifications",
title: "Notifications",
+23
Переглянути файл
@@ -17,6 +17,8 @@ import { ContextMenuHost } from "@/ui";
import { registerStatusItem, type StatusItemContribution } from "./contributions";
import { StatusBar } from "./StatusBar";
import { isStatusItemVisible, statusBarMenu } from "./statusBarWidgets";
import { executeAction } from "@/platform/actions";
import { registerShellActions } from "./shellActions";
const item = (id: string, extra: Partial<StatusItemContribution> = {}): StatusItemContribution => ({
id,
@@ -103,4 +105,25 @@ describe("status bar widgets", () => {
);
expect(await screen.findByText("w.memory")).toBeTruthy();
});
it("toggles one widget by id from View › Appearance › Status Bar Widgets", async () => {
disposers.push(
registerShellActions(),
registerStatusItem(item("w.caret", { title: "Line:Column Number" })),
registerStatusItem(item("w.memory", { title: "Memory Indicator", defaultVisible: false })),
registerStatusItem(item("w.tasks")),
);
await act(() => executeAction("view.toggleStatusBarWidget", "w.memory"));
expect(settingsSet).toHaveBeenLastCalledWith(
"global",
"appearance.statusBarWidgets",
JSON.stringify({ "w.memory": true }),
);
await act(() => executeAction("view.toggleStatusBarWidget", "w.caret"));
expect(widgets).toEqual({ "w.memory": true, "w.caret": false });
// Untitled or unknown widgets cannot be hidden.
await act(() => executeAction("view.toggleStatusBarWidget", "w.tasks"));
await act(() => executeAction("view.toggleStatusBarWidget", "w.gone"));
expect(settingsSet).toHaveBeenCalledTimes(2);
});
});
+39 -14
Переглянути файл
@@ -2,7 +2,7 @@
import type { MenuItem } from "@/ui";
import { call, commands } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import type { StatusItemContribution } from "./contributions";
import { useContributions, type StatusItemContribution } from "./contributions";
export type WidgetOverrides = Partial<Record<string, boolean>>;
@@ -14,25 +14,42 @@ export function isStatusItemVisible(
return overrides?.[item.id] ?? item.defaultVisible !== false;
}
export interface StatusWidgetRow {
id: string;
title: string;
/** Plugin widgets are named by their plugin, not translated. */
verbatim: boolean;
visible: boolean;
}
/** Every titled widget of `items` (in bar order) with its visibility: the rows users can switch. */
export function statusWidgetRows(
items: StatusItemContribution[],
overrides: WidgetOverrides | undefined,
): StatusWidgetRow[] {
return items
.filter((i) => i.title)
.map((i) => ({
id: i.id,
title: i.title!,
verbatim: i.id.startsWith("plugin."),
visible: isStatusItemVisible(i, overrides),
}));
}
/** The status bar context menu: every titled widget with a checkmark, in bar order. */
export function statusBarMenu(
items: StatusItemContribution[],
overrides: WidgetOverrides | undefined,
set: (id: string, visible: boolean) => void = (id, v) => void setStatusWidgetVisible(id, v),
): MenuItem[] {
return items
.filter((i) => i.title)
.map((i) => {
const visible = isStatusItemVisible(i, overrides);
// Plugin widgets are named by their plugin, not translated.
return {
id: i.id,
label: i.title!,
verbatim: i.id.startsWith("plugin."),
checked: visible,
run: () => set(i.id, !visible),
};
});
return statusWidgetRows(items, overrides).map((row) => ({
id: row.id,
label: row.title,
verbatim: row.verbatim,
checked: row.visible,
run: () => set(row.id, !row.visible),
}));
}
/** Persists the choice in the global settings (the whole map, since widget ids contain dots). */
@@ -43,6 +60,14 @@ export async function setStatusWidgetVisible(id: string, visible: boolean): Prom
useProject.setState({ settings });
}
/** Flips one widget by id (View › Appearance › Status Bar Widgets); untitled or unknown widgets stay as they are. */
export async function toggleStatusWidget(id: string): Promise<void> {
const item = useContributions.getState().statusItems.get(id);
if (!item?.title) return;
const visible = isStatusItemVisible(item, useProject.getState().settings?.appearance?.statusBarWidgets);
await setStatusWidgetVisible(id, !visible);
}
export function useStatusWidgetOverrides(): WidgetOverrides | undefined {
return useProject((s) => s.settings?.appearance?.statusBarWidgets);
}
+23 -2
Переглянути файл
@@ -9,7 +9,7 @@ vi.mock("@/platform/ipc", async (orig) => {
const { emitLocalIdeEvent, isCancelled } = await import("@/platform/ipc");
const { cancelTask, installTaskEvents, queueTask, setIndexing, upsertTask, useTasks } = await import("./tasks");
const { TaskStatus } = await import("./StatusBar");
const { BackgroundTasksHost, TaskStatus } = await import("./StatusBar");
const task = (id: string) => useTasks.getState().tasks.find((t) => t.id === id);
@@ -126,7 +126,12 @@ describe("cancelling", () => {
done: false,
uncancellable: "This step can't be cancelled",
});
render(<TaskStatus />);
render(
<>
<TaskStatus />
<BackgroundTasksHost />
</>,
);
fireEvent.click(screen.getByRole("button", { name: /Background tasks/ }));
fireEvent.click(screen.getByRole("button", { name: "Cancel Downloading JDK 25" }));
expect(cancel).toHaveBeenCalledOnce();
@@ -160,3 +165,19 @@ describe("cancelling", () => {
uninstall();
});
});
it("shows progress and uncancellable tasks through an action without the status bar", async () => {
const { registerShellActions } = await import("./shellActions");
const { executeAction } = await import("@/platform/actions");
const dispose = registerShellActions();
try {
upsertTask({ id: "scan", title: "Scanning", message: "Reading files", fraction: 0.25, done: false });
render(<BackgroundTasksHost />);
await act(() => executeAction("view.backgroundTasks"));
expect(screen.getByRole("dialog", { name: "Background tasks" })).toHaveTextContent("25%");
expect(screen.getByText("Reading files")).toBeVisible();
fireEvent.keyDown(document, { key: "Escape" });
} finally {
dispose();
}
});