feat: Third pass aligning core experience with IntelliJ IDEA

Project & files
- Project view modes (Project Files / Open Files / All Changed Files /
  Scratches); External Libraries for Python, JDK (src.zip), Node, Cargo, Go,
  Maven/Gradle jars; read-only jar/zip browsing
- ⌘N New popup with type search and more file kinds; New Project, Rename
  Project, Reload All from Disk, Export/Import Settings, Power Save Mode
- File type associations, extra content roots and exclude patterns (indexed
  and watched without reopening the project)

Editor & navigation
- Find bar: search scope (comments/strings), Preserve Case, Exclude, multiline,
  history; Find in Files: Module scope, custom pattern scopes, multiline
- Search Everywhere filters, Assign Shortcut, settings results; named bookmark
  lists, file/folder bookmarks; Structure filters and Show Inherited; Recent
  Locations timestamps and breadcrumbs; Code Vision author
- Completion options, editor tab placement/layout, inline completion (LSP 3.18)
- Spell checking (Typo inspection, ide-spell) and Inspections settings with
  custom profiles; Code Style schemes, formatter markers, Do not format

Run, VCS, services, database
- Typed run configurations (Shell, Program, npm, Gradle, Maven, Docker,
  Compound), Modify options, Before launch, env dialog, Current File,
  Emulate terminal (PTY)
- Git log graph/view options, Go to Hash/Branch/Tag, date filter keeps the
  graph; Pull/Merge/Rebase dialogs, Manage Remotes, Worktrees
- Generic Services host (Docker connections/registries, run sessions); TODO
  scope/changes tabs with changelists; Coverage report import
- Data sources: wire-compatible vendors, URL import, SSL certs, Options,
  Schemas and Advanced tabs, color and comment

Workbench
- Run Anything, Customize Main Toolbar, named tool window layouts, menu
  completeness, Code Cleanup, macros, "Don't Show Again", editor banners
- Chinese translations for the new UI

Claude-Session: https://claude.ai/code/session_01SaPPsVPuk6ZTNsoUCNCbMv
Цей коміт міститься в:
l0ng-ai committed 2026-10-25 21:24:00 +08:00
1 джерело 294198a727
коміт 1e00ea5ee2
426 files changed
+85241 -2886

No files matched your search

+75
Переглянути файл
@@ -0,0 +1,75 @@
.body {
display: flex;
flex-direction: column;
gap: 10px;
padding: 14px;
}
.sides {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.side {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.heading {
margin: 0;
color: var(--fg-secondary);
font-size: var(--fs-small);
font-weight: 600;
}
.list {
height: 200px;
border: 1px solid var(--border);
border-radius: var(--radius-control);
}
.row {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
flex: 1;
padding: 0 6px;
}
.activeRow .rowLabel {
color: var(--fg-strong);
}
.rowIcon {
flex: none;
color: var(--fg-muted);
}
.rowLabel {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rowDetail {
margin-left: auto;
color: var(--fg-subtle);
font-size: var(--fs-small);
white-space: nowrap;
}
.empty {
padding: 6px 8px;
color: var(--fg-subtle);
font-size: var(--fs-small);
}
.buttons {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.picker {
display: flex;
flex-direction: column;
gap: 6px;
}
.pickerList {
height: 180px;
border: 1px solid var(--border);
border-radius: var(--radius-control);
}
+224
Переглянути файл
@@ -0,0 +1,224 @@
/** Customize Main Toolbar dialog: the entries of each side, reorderable, plus added actions. */
import { useMemo, useState } from "react";
import clsx from "clsx";
import { create } from "zustand";
import { allActions } from "@/platform/actions";
import { t } from "@/platform/i18n";
import { Button, Dialog, Icon, List, SearchField, T, fuzzyMatch } from "@/ui";
import { useContributions } from "./contributions";
import {
ACTION_PREFIX,
editableToolbar,
isActionEntry,
saveToolbar,
defaultToolbar,
entryAction,
useMainToolbarSettings,
widgetTitle,
type ToolbarLayout,
} from "./mainToolbar";
import styles from "./MainToolbarDialog.module.css";
const useCustomize = create<{ open: boolean }>(() => ({ open: false }));
export const showCustomizeToolbar = () => useCustomize.setState({ open: true });
export function MainToolbarDialogHost() {
const open = useCustomize((s) => s.open);
return open ? <CustomizeDialog onClose={() => useCustomize.setState({ open: false })} /> : null;
}
type Side = "left" | "right";
const SIDES: Side[] = ["left", "right"];
function move(layout: ToolbarLayout, side: Side, index: number, delta: -1 | 1): ToolbarLayout {
const list = [...layout[side]];
const j = index + delta;
if (j < 0 || j >= list.length) return layout;
[list[index], list[j]] = [list[j]!, list[index]!];
return { ...layout, [side]: list };
}
function remove(layout: ToolbarLayout, side: Side, index: number): ToolbarLayout {
const id = layout[side][index];
if (id === undefined) return layout;
return {
...layout,
[side]: layout[side].filter((_, i) => i !== index),
hidden: isActionEntry(id) || layout.hidden.includes(id) ? layout.hidden : [...layout.hidden, id],
};
}
function CustomizeDialog({ onClose }: { onClose: () => void }) {
const items = useContributions((s) => s.titleItems);
const saved = useMainToolbarSettings();
const [layout, setLayout] = useState<ToolbarLayout>(() => editableToolbar(saved, items.values()));
const [sel, setSel] = useState<{ side: Side; index: number }>({ side: "left", index: 0 });
const [adding, setAdding] = useState(false);
const selected = layout[sel.side][sel.index];
const apply = async () => {
await saveToolbar(layout);
onClose();
};
const add = (id: string) => {
const side = sel.side;
const at = selected === undefined ? layout[side].length : sel.index + 1;
const list = [...layout[side].filter((x) => x !== id)];
list.splice(Math.min(at, list.length), 0, id);
setLayout({ ...layout, [side]: list, hidden: layout.hidden.filter((x) => x !== id) });
setSel({ side, index: list.indexOf(id) });
setAdding(false);
};
return (
<Dialog
open
title="Customize Main Toolbar"
onClose={onClose}
width={620}
footerStart={
<Button
onClick={() => {
setLayout(defaultToolbar(items.values()));
setSel({ side: "left", index: 0 });
}}
>
<T>Restore Defaults</T>
</Button>
}
buttons={[
{ label: "OK", role: "default", onClick: () => void apply() },
{ label: t("common.cancel"), role: "cancel" },
]}
>
<div className={styles.body}>
<div className={styles.sides}>
{SIDES.map((side) => (
<section key={side} className={styles.side} aria-label={side === "left" ? "Left" : "Right"}>
<h3 className={styles.heading}>{side === "left" ? <T>Left</T> : <T>Right</T>}</h3>
<List
items={layout[side]}
getKey={(id) => id}
activeIndex={sel.side === side ? sel.index : -1}
onActiveChange={(index) => setSel({ side, index })}
rowHeight={24}
className={styles.list}
aria-label={side === "left" ? "Left group" : "Right group"}
empty={
<div className={styles.empty}>
<T>Empty</T>
</div>
}
renderItem={(id) => (
<div className={styles.row}>
<Icon name={isActionEntry(id) ? (allActionIcon(id) ?? "run") : "main-menu"} size={14} className={styles.rowIcon} />
<span className={styles.rowLabel}>{widgetTitle(id, items)}</span>
</div>
)}
/>
</section>
))}
</div>
<div className={styles.buttons}>
<Button onClick={() => setAdding((v) => !v)} aria-expanded={adding}>
<T>Add…</T>
</Button>
<Button
disabled={selected === undefined}
onClick={() => {
setLayout(remove(layout, sel.side, sel.index));
setSel({ ...sel, index: Math.max(0, sel.index - 1) });
}}
>
<T>Remove</T>
</Button>
<Button
disabled={sel.index <= 0 || selected === undefined}
onClick={() => {
setLayout(move(layout, sel.side, sel.index, -1));
setSel({ ...sel, index: sel.index - 1 });
}}
>
<T>Move Up</T>
</Button>
<Button
disabled={selected === undefined || sel.index >= layout[sel.side].length - 1}
onClick={() => {
setLayout(move(layout, sel.side, sel.index, 1));
setSel({ ...sel, index: sel.index + 1 });
}}
>
<T>Move Down</T>
</Button>
<Button
disabled={selected === undefined}
onClick={() => {
if (selected === undefined) return;
const other: Side = sel.side === "left" ? "right" : "left";
setLayout({
...layout,
[sel.side]: layout[sel.side].filter((_, i) => i !== sel.index),
[other]: [...layout[other], selected],
});
setSel({ side: other, index: layout[other].length });
}}
>
{sel.side === "left" ? <T>Move to Right</T> : <T>Move to Left</T>}
</Button>
</div>
{adding && <AddPicker hidden={layout.hidden} present={[...layout.left, ...layout.right]} onPick={add} />}
</div>
</Dialog>
);
}
function allActionIcon(id: string): string | undefined {
return allActions().find((a) => a.id === entryAction(id))?.icon;
}
/** Removed widgets, then every visible action with an icon, filtered by the query. */
function AddPicker({ hidden, present, onPick }: { hidden: string[]; present: string[]; onPick: (id: string) => void }) {
const items = useContributions((s) => s.titleItems);
const [query, setQuery] = useState("");
const [active, setActive] = useState(0);
const candidates = useMemo(() => {
const widgets = hidden.map((id) => ({ id, label: widgetTitle(id, items), detail: "Widget", icon: "main-menu" }));
const actions = allActions()
.filter((a) => !a.hidden && a.icon && !present.includes(`${ACTION_PREFIX}${a.id}`))
.map((a) => ({ id: `${ACTION_PREFIX}${a.id}`, label: a.title, detail: a.category ?? "", icon: a.icon! }))
.sort((a, b) => a.label.localeCompare(b.label));
const all = [...widgets, ...actions];
return query.trim() ? all.filter((c) => fuzzyMatch(c.label, query.trim())) : all;
}, [hidden, present, items, query]);
return (
<div className={styles.picker}>
<SearchField
value={query}
onChange={(v) => {
setQuery(v);
setActive(0);
}}
placeholder="Search actions"
aria-label="Search actions"
autoFocus
/>
<List
items={candidates}
getKey={(c) => c.id}
activeIndex={active}
onActiveChange={setActive}
onActivate={(c) => onPick(c.id)}
rowHeight={24}
className={styles.pickerList}
aria-label="Actions and widgets to add"
renderItem={(c, state) => (
<div className={clsx(styles.row, state.active && styles.activeRow)}>
<Icon name={c.icon} size={14} className={styles.rowIcon} />
<span className={styles.rowLabel}>{c.label}</span>
<span className={styles.rowDetail}>{c.detail}</span>
</div>
)}
/>
</div>
);
}
+13 -2
Переглянути файл
@@ -3,10 +3,12 @@ import { useTxTemplate } from "@/platform/i18n";
import { EmptyState, IconButton, Toast, T } from "@/ui";
import {
balloonTimeout,
canSuppress,
clearNotifications,
dismissBalloon,
markAllNotificationsRead,
removeNotification,
suppressGroup,
useNotifications,
type IdeNotification,
} from "./notificationStore";
@@ -34,6 +36,15 @@ function useWindowActive(): boolean {
return useSyncExternalStore(subscribeWindowFocus, () => document.hasFocus(), () => true);
}
const DONT_SHOW_AGAIN = "Don't Show Again";
/** The notification's own actions, then "Don't Show Again" for its group (IDEA). */
export function notificationActions(n: IdeNotification, wrap: (run: () => void) => () => void = (r) => r) {
const own = n.actions ?? [];
if (!canSuppress(n)) return own;
return [...own, { label: DONT_SHOW_AGAIN, run: wrap(() => void suppressGroup(n.group).catch(() => {})) }];
}
function Balloon({ n }: { n: IdeNotification }) {
const [hover, setHover] = useState(false);
const active = useWindowActive();
@@ -57,7 +68,7 @@ function Balloon({ n }: { n: IdeNotification }) {
level={n.level}
title={txt(n.title)}
body={n.body}
actions={n.actions?.map((a) => ({
actions={notificationActions(n).map((a) => ({
label: txt(a.label),
run: () => {
dismissBalloon(n.id);
@@ -120,7 +131,7 @@ export function NotificationsToolWindow() {
title={txt(n.title)}
body={n.body}
meta={timeLabel(n.time)}
actions={n.actions?.map((a) => ({ ...a, label: txt(a.label) }))}
actions={notificationActions(n).map((a) => ({ ...a, label: txt(a.label) }))}
onClose={() => removeNotification(n.id)}
/>
))
+2
Переглянути файл
@@ -1,6 +1,7 @@
import { ContextMenuHost, TooltipHost } from "@/ui";
import { useContributions } from "./contributions";
import { ErrorBoundary } from "./ErrorBoundary";
import { InputDialogsHost } from "./inputDialogs";
import { MessagesHost } from "./messages";
import { Balloons } from "./Notifications";
@@ -15,6 +16,7 @@ export function OverlayHost() {
</ErrorBoundary>
))}
<MessagesHost />
<InputDialogsHost />
<Balloons />
<ContextMenuHost />
<TooltipHost />
+35 -4
Переглянути файл
@@ -2,7 +2,7 @@ import { afterEach, describe, expect, it } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import { registerActions } from "@/platform/actions";
import { useThemeOverride } from "./theme";
import { gearMenuItems, TitleBar } from "./TitleBar";
import { customizeToolbarItems, gearMenuItems, TitleBar } from "./TitleBar";
let disposers: Array<() => void> = [];
afterEach(() => {
@@ -12,22 +12,53 @@ afterEach(() => {
});
describe("title bar gear menu", () => {
it("lists Settings, Plugins, Theme and Keymap switches and Check for Updates", () => {
it("lists Run Anything, Settings, Plugins, quick switches, toolbar customization and Check for Updates", () => {
const items = gearMenuItems("dark", "intellij-macos", ["Mine"]);
expect(items.map((i) => ("action" in i ? i.action : "label" in i ? i.label : i.kind))).toEqual([
"runAnything.show",
"separator",
"file.settings",
"plugins.settings",
"separator",
"Theme",
"Keymap",
"View Mode",
"separator",
"Customize Main Toolbar",
"separator",
"app.checkForUpdates",
]);
const theme = (items[3] as { submenu: Array<{ label?: string; checked?: boolean }> }).submenu;
type Sub = { submenu: Array<{ label?: string; checked?: boolean; action?: string }> };
const theme = (items[5] as Sub).submenu;
expect(theme.filter((i) => i.checked).map((i) => i.label)).toEqual(["Islands Dark"]);
const keymap = (items[4] as { submenu: Array<{ label?: string; checked?: boolean }> }).submenu;
const keymap = (items[6] as Sub).submenu;
expect(keymap.map((i) => i.label)).toContain("Mine");
expect(keymap.filter((i) => i.checked).map((i) => i.label)).toEqual(["macOS"]);
// View Mode only switches the existing modes.
expect((items[7] as Sub).submenu.map((i) => i.action)).toEqual(["editor.toggleZen", "window.toggleFullScreen"]);
});
it("lists every widget with a checkbox in Customize Main Toolbar", () => {
const toggled: string[] = [];
let reset = 0;
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" }]]),
(id) => toggled.push(id),
() => void reset++,
);
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],
["navigate.searchEverywhere", true],
["Run Widget", false],
]);
rows[3]!.run();
expect(toggled).toEqual(["run.widget"]);
(items.at(-1) as { run: () => void }).run();
expect(reset).toBe(1);
});
it("opens a menu instead of Settings directly, and switches the theme", async () => {
+108 -52
Переглянути файл
@@ -1,17 +1,30 @@
import { useMemo, useRef, useState } from "react";
import clsx from "clsx";
import { useActions } from "@/platform/actions";
import { call, commands, isTauri } from "@/platform/ipc";
import { PRESET_TITLES, shortcutLabel, useKeymap } from "@/platform/keymap";
import { shortcutLabel, useKeymap } from "@/platform/keymap";
import { useProject } from "@/platform/project";
import { BarButton, Icon, IconButton, Menu, type MenuItem, T } from "@/ui";
import { BarButton, Icon, IconButton, Menu, type MenuItem, showContextMenu, T } from "@/ui";
import { useContributions, useWorkbenchSlots, type TitleItemContribution } from "./contributions";
import { ErrorBoundary, Slot } from "./ErrorBoundary";
import { MainMenu } from "./MainMenu";
import {
editableToolbar,
entryAction,
isActionEntry,
PROJECT_WIDGET,
resolveToolbar,
saveToolbar,
SEARCH_ENTRY,
toggleWidget,
useMainToolbarSettings,
VCS_WIDGET,
widgetTitle,
} from "./mainToolbar";
import { WindowControls } from "./WindowControls";
import { usePlatform } from "@/platform/platformInfo";
import { projectInitials } from "./projectColor";
import { currentThemeId, setTheme, THEME_CHOICES, useThemeOverride } from "./theme";
import { keymapItems, themeItems, VIEW_MODE_ITEMS } from "./quickSwitch";
import { currentThemeId, useThemeOverride } from "./theme";
import styles from "./TitleBar.module.css";
export function orderTitleItems(items: Iterable<TitleItemContribution>) {
@@ -106,56 +119,81 @@ function VcsFallback() {
);
}
async function selectKeymap(id: string) {
if (!isTauri()) return;
try {
useProject.setState({ settings: await call(commands.settingsSet("global", "keymap", JSON.stringify(id))) });
} catch (e) {
console.warn("cannot switch keymap", e);
}
/**
* Customize Main Toolbar ›: a checkbox per widget (and added action), then the
* dialog and Restore Default. Also the title bar's context menu.
*/
export function customizeToolbarItems(
shown: { left: string[]; right: string[] },
hidden: readonly string[],
items: ReadonlyMap<string, TitleItemContribution>,
toggle: (id: string) => void,
reset: () => void,
): MenuItem[] {
const entries = [...shown.left, ...shown.right, ...hidden.filter((id) => !isActionEntry(id))];
return [
...entries.map<MenuItem>((id) => ({
id: `toolbar.${id}`,
label: widgetTitle(id, items),
checked: !hidden.includes(id),
run: () => toggle(id),
})),
{ kind: "separator" },
{ kind: "action", action: "toolbar.customize", label: "Customize Toolbar…" },
{ id: "toolbar.reset", label: "Restore Default Toolbar", run: reset },
];
}
/** IDEA's main toolbar gear: Settings, Plugins, quick Theme / Keymap switches, Check for Updates. */
export function gearMenuItems(themeId: string, keymapId: string, customKeymaps: string[]): MenuItem[] {
/** IDEA's main toolbar gear: Run Anything, Settings, Plugins, quick switches, toolbar customization, updates. */
export function gearMenuItems(
themeId: string,
keymapId: string,
customKeymaps: string[],
customize: MenuItem[] = [{ kind: "action", action: "toolbar.customize", label: "Customize Toolbar…" }],
): MenuItem[] {
return [
{ kind: "action", action: "runAnything.show" },
{ kind: "separator" },
{ kind: "action", action: "file.settings" },
{ kind: "action", action: "plugins.settings" },
{ kind: "separator" },
{
id: "gear.theme",
label: "Theme",
submenu: [
...THEME_CHOICES.map<MenuItem>((c) => ({
id: `theme.${c.id}`,
label: c.label,
checked: c.id === themeId,
run: () => void setTheme(c.id),
})),
{ kind: "separator" },
{ kind: "action", action: "settings.appearance" },
],
submenu: [...themeItems(themeId), { kind: "separator" }, { kind: "action", action: "settings.appearance" }],
},
{
id: "gear.keymap",
label: "Keymap",
submenu: [
...[...Object.keys(PRESET_TITLES), ...customKeymaps].map<MenuItem>((id) => ({
id: `keymap.${id}`,
label: PRESET_TITLES[id] ?? id,
verbatim: !PRESET_TITLES[id],
checked: id === keymapId,
run: () => void selectKeymap(id),
})),
...keymapItems(keymapId, customKeymaps),
{ kind: "separator" },
{ kind: "action", action: "settings.keymap", label: "Configure Keymap…" },
],
},
{ id: "gear.viewMode", label: "View Mode", submenu: VIEW_MODE_ITEMS },
{ kind: "separator" },
{ id: "gear.customizeToolbar", label: "Customize Main Toolbar", submenu: customize },
{ kind: "separator" },
{ kind: "action", action: "app.checkForUpdates" },
];
}
function GearButton() {
/** Toolbar entries and the customization menu, from the saved layout. */
function useToolbar() {
const map = useContributions((s) => s.titleItems);
const saved = useMainToolbarSettings();
const shown = useMemo(() => resolveToolbar(saved, map.values()), [saved, map]);
const toggle = (id: string) => {
const layout = editableToolbar(saved, map.values());
const side = map.get(id)?.align ?? (id === SEARCH_ENTRY ? "right" : "left");
void saveToolbar(toggleWidget(layout, id, side));
};
const menu = customizeToolbarItems(shown, saved?.hidden ?? [], map, toggle, () => void saveToolbar(null));
return { map, shown, menu };
}
function GearButton({ customize }: { customize: MenuItem[] }) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLButtonElement>(null);
const settings = useProject((s) => s.settings);
@@ -167,6 +205,7 @@ function GearButton() {
currentThemeId(),
typeof settings?.keymap === "string" ? settings.keymap : preset,
customs && typeof customs === "object" ? Object.keys(customs) : [],
customize,
);
return (
<>
@@ -191,6 +230,30 @@ function GearButton() {
);
}
/** One main toolbar entry: a built-in widget, a contributed one or an added action. */
function ToolbarEntry({ id, items }: { id: string; items: ReadonlyMap<string, TitleItemContribution> }) {
const actions = useActions((s) => s.actions);
if (id === PROJECT_WIDGET) return <ProjectButton />;
const item = items.get(id);
if (item) return <TitleItem item={item} />;
if (id === VCS_WIDGET) return <VcsFallback />;
if (!isActionEntry(id)) return null;
const action = actions.get(entryAction(id));
if (!action) return null;
return (
<div className={styles.tools}>
<IconButton
icon={action.icon ?? "run"}
action={action.id}
title={action.title}
shortcut={shortcutLabel(action.id) ?? (action.id === "navigate.searchEverywhere" ? "⇧⇧" : undefined)}
iconSize={18}
className={styles.tool}
/>
</div>
);
}
/**
* 40px main toolbar. macOS: native traffic lights overlay its left edge.
* Windows: the window is frameless, so the bar carries IDEA's main menu (☰) and
@@ -209,8 +272,7 @@ export function TitleBar({
}) {
const overlay = usePlatform((s) => s.overlayTitleBar);
const frameless = usePlatform((s) => s.framelessTitleBar);
const map = useContributions((s) => s.titleItems);
const { left, right } = useMemo(() => orderTitleItems(map.values()), [map]);
const { map, shown, menu } = useToolbar();
const [menuExpanded, setMenuExpanded] = useState(false);
const className = clsx(styles.bar, overlay && styles.overlay, frameless && styles.frameless);
if (bare)
@@ -224,36 +286,30 @@ export function TitleBar({
)}
</header>
);
const entry = (id: string) => <ToolbarEntry key={id} id={id} items={map} />;
return (
<header className={clsx(className, minimal && styles.minimal)} data-tauri-drag-region>
<header
className={clsx(className, minimal && styles.minimal)}
data-tauri-drag-region
onContextMenu={(e) => {
if (minimal || e.defaultPrevented) return;
e.preventDefault();
showContextMenu(e, menu);
}}
>
{frameless && !minimal && <MainMenu onExpandedChange={setMenuExpanded} />}
{title && (
<span className={styles.windowTitle} data-tauri-drag-region>
{typeof title === "string" ? <T>{title}</T> : title}
</span>
)}
{!minimal && !menuExpanded && (
<>
<ProjectButton />
{left.length > 0 ? left.map((i) => <TitleItem key={i.id} item={i} />) : <VcsFallback />}
</>
)}
{!minimal && !menuExpanded && shown.left.map(entry)}
<div className={styles.drag} data-tauri-drag-region />
{!minimal && (
<>
{right.map((i) => (
<TitleItem key={i.id} item={i} />
))}
{shown.right.map(entry)}
<div className={styles.tools}>
<IconButton
icon="search"
action="navigate.searchEverywhere"
title="Search Everywhere"
shortcut={shortcutLabel("navigate.searchEverywhere") ?? "⇧⇧"}
iconSize={18}
className={styles.tool}
/>
<GearButton />
<GearButton customize={menu} />
</div>
</>
)}
+22 -1
Переглянути файл
@@ -4,9 +4,10 @@ import { emitLocalIdeEvent } from "@/platform/ipc";
import { registerStatusItem, registerToolWindow, type StatusItemContribution } from "./contributions";
import { orderStatusItems, StatusBar, TaskStatus } from "./StatusBar";
import { orderTitleItems } from "./TitleBar";
import { Balloons, NOTIFICATIONS_TOOL_WINDOW, NotificationsToolWindow } from "./Notifications";
import { Balloons, NOTIFICATIONS_TOOL_WINDOW, notificationActions, NotificationsToolWindow } from "./Notifications";
import {
BALLOON_TIMEOUT,
canSuppress,
clearNotifications,
installNotifyEvents,
STICKY_BALLOON_TIMEOUT,
@@ -316,3 +317,23 @@ describe("workbench shell", () => {
useProject.setState({ project: null });
});
});
describe("Don't Show Again", () => {
it("is offered for non-error notifications of groups still shown as balloons", async () => {
const { useProject } = await import("@/platform/project");
const base = { id: 1, time: 0, read: false, balloon: true, sticky: false, logged: true, title: "x" } as const;
const own = [{ label: "Open", run: () => {} }];
expect(notificationActions({ ...base, level: "info", group: "vcs", actions: own }).map((a) => a.label)).toEqual([
"Open",
"Don't Show Again",
]);
expect(notificationActions({ ...base, level: "error", group: "vcs" })).toEqual([]);
useProject.setState({ settings: { notifications: { groups: { vcs: { display: "none" } } } } as never });
try {
expect(canSuppress({ level: "warning", group: "vcs" })).toBe(false);
expect(canSuppress({ level: "warning", group: "run" })).toBe(true);
} finally {
useProject.setState({ settings: null });
}
});
});
+3
Переглянути файл
@@ -7,6 +7,7 @@ import { useProject } from "@/platform/project";
import {
provideNotificationService,
provideToolWindowService,
registerOverlay,
registerStatusItem,
registerToolWindow,
} from "./contributions";
@@ -22,6 +23,7 @@ import {
} from "./Notifications";
import { loadPlatformInfo } from "@/platform/platformInfo";
import { registerShellActions } from "./shellActions";
import { MainToolbarDialogHost } from "./MainToolbarDialog";
import { StatusInfo, TaskStatus } from "./StatusBar";
import { installTaskEvents } from "./tasks";
import { installToolWindowActions } from "./toolwindows/actions";
@@ -60,6 +62,7 @@ export function activateWorkbench(): () => void {
installTaskEvents(),
installNotifyEvents(),
installBrowserGuards(),
registerOverlay({ id: "workbench.customizeToolbar", component: MainToolbarDialogHost }),
registerStatusItem({ id: "workbench.tasks", align: "right", priority: 0, component: TaskStatus }),
registerStatusItem({ id: "workbench.info", align: "left", priority: 5, component: StatusInfo }),
registerToolWindow({
+337 -7
Переглянути файл
@@ -46,6 +46,12 @@ export interface ToolWindowContribution {
* present the header title also gets a "▾" opening just these items.
*/
headerMenu?: MenuItem[] | ((id: string) => MenuItem[]);
/**
* The title as a view switcher (IDEA's "Project ▾" panes): a React hook
* returning the current view's label and the items the title opens instead
* of `headerMenu`'s.
*/
titleMenu?: (id: string) => { label: string; items: MenuItem[] };
/** Badge (e.g. problem count) shown on the stripe button. */
useBadge?: () => number | string | null;
/** Only shown when a project is open (default true). */
@@ -81,6 +87,8 @@ export interface TitleItemContribution {
/** Higher = further from the centre (closer to the window edge). */
priority: number;
component: ComponentType;
/** Name in Customize Main Toolbar (defaults to the id). */
title?: string;
}
/** Popups / dialogs rendered above the workbench (Search Everywhere, Settings…). */
@@ -114,6 +122,73 @@ export interface ServiceViewContribution {
onUse?: boolean;
}
/** A node of the Services tree; `key` is unique across providers (prefix it with the provider id). */
export interface ServiceTreeItem {
key: string;
label: string;
icon: string;
/** Grey text after the label (status, counts…). */
detail?: string;
/** Greyed label (stopped, not running). */
dim?: boolean;
/** Undefined for leaves. */
children?: ServiceTreeItem[];
}
/** A toolbar button the Services tree shows for the selected node. */
export interface ServiceItemAction {
id: string;
title: string;
icon: string;
disabled?: boolean;
run(): void;
}
/**
* Top-level nodes of the Services tree (IDEA's ServiceViewContributor): Docker
* connections, run configurations… Each provider owns its nodes' toolbar,
* context menu and detail pane, and adds entries to the "Add Service" menu.
*/
export interface ServiceProviderContribution {
id: string;
/** Root order; lower first. */
order?: number;
/** React hook: this provider's root nodes (none: nothing shown). */
useRoots(): ServiceTreeItem[];
/** Keeps the provider's data live while the tree is shown; returns a disposer. */
watch?(): () => void;
/** Toolbar buttons for a selected node of this provider. */
actions?(item: ServiceTreeItem): ServiceItemAction[];
menu?(item: ServiceTreeItem): MenuItem[];
/** Double click / Enter. */
activate?(item: ServiceTreeItem): void;
/** Right pane for a selected node. */
detail?: ComponentType<{ item: ServiceTreeItem }>;
/** Status line above the tree (a long operation in progress…); renders null when idle. */
banner?: ComponentType;
/** Entries of the "Add Service" (+, ⌘N) menu. */
addMenu?(): MenuItem[];
}
export const useServiceProviders = create<{ providers: Map<string, ServiceProviderContribution> }>(() => ({
providers: new Map(),
}));
export function registerServiceProvider(p: ServiceProviderContribution): () => void {
useServiceProviders.setState((s) => ({ providers: new Map(s.providers).set(p.id, p) }));
return () =>
useServiceProviders.setState((s) => {
if (s.providers.get(p.id) !== p) return s;
const providers = new Map(s.providers);
providers.delete(p.id);
return { providers };
});
}
export function serviceProviders(map = useServiceProviders.getState().providers): ServiceProviderContribution[] {
return [...map.values()].sort((a, b) => (a.order ?? 0) - (b.order ?? 0) || a.id.localeCompare(b.id));
}
export interface EditorInput {
kind: string; // "file" for text documents
/** Stable key; tabs with equal keys are the same tab. */
@@ -313,6 +388,8 @@ export interface EditorService {
closePath?(path: string): void;
/** Closes the tab of the input with this `key` (e.g. a merge editor once applied). */
closeInput?(key: string): void;
/** Reformat Code on the whole open document at `path` (quietly); whether anything changed. */
reformat?(path: string): Promise<boolean>;
}
export interface ToolWindowService {
@@ -342,10 +419,32 @@ export interface NotificationService {
expire?(key: string): void;
}
/** A terminal widget (xterm) embedded in another view, e.g. a Run console emulating a terminal. */
export interface EmbeddedTerminal {
write(data: string): void;
clear(): void;
focus(): void;
/** Re-measures the host element (after it was shown or resized). */
fit(): void;
dispose(): void;
}
export interface EmbeddedTerminalOptions {
/** Keystrokes and pastes, as terminal input. */
onData(data: string): void;
onResize(cols: number, rows: number): void;
/** Directory relative file links resolve against. */
cwd?: string;
"aria-label"?: string;
}
export type EmbeddedTerminalFactory = (host: HTMLElement, options: EmbeddedTerminalOptions) => Promise<EmbeddedTerminal>;
const services: {
editor?: EditorService;
toolWindows?: ToolWindowService;
notifications?: NotificationService;
embeddedTerminal?: EmbeddedTerminalFactory;
} = {};
function need<T>(s: T | undefined, name: string): T {
@@ -356,6 +455,7 @@ function need<T>(s: T | undefined, name: string): T {
export const provideEditorService = (s: EditorService) => void (services.editor = s);
export const provideToolWindowService = (s: ToolWindowService) => void (services.toolWindows = s);
export const provideNotificationService = (s: NotificationService) => void (services.notifications = s);
export const provideEmbeddedTerminal = (f: EmbeddedTerminalFactory | undefined) => void (services.embeddedTerminal = f);
export const editorService = (): EditorService => need(services.editor, "editor");
export const toolWindowService = (): ToolWindowService => need(services.toolWindows, "toolWindows");
@@ -363,6 +463,8 @@ export const notifications = (): NotificationService =>
services.notifications ?? { notify: (n) => console.info(`[${n.level}] ${n.title}`) };
/** The editor service if one has been provided (for optional integrations). */
export const maybeEditorService = (): EditorService | undefined => services.editor;
/** Creates terminal widgets; provided by the terminal feature. */
export const embeddedTerminal = (): EmbeddedTerminalFactory | undefined => services.embeddedTerminal;
// ---------------------------------------------------------------------------
// File decorations: colour / badge / tooltip for a path, shown by the project
@@ -372,6 +474,8 @@ export const maybeEditorService = (): EditorService | undefined => services.edit
export interface FileDecoration {
/** CSS colour, normally a token like `var(--vcs-modified)`. */
color?: string;
/** CSS colour tinting the editor tab (a data source's colour on its console). */
tint?: string;
/** Short badge text, e.g. a problem count. */
badge?: string;
tooltip?: string;
@@ -483,18 +587,54 @@ export function registerSettingsPage(page: SettingsPageContribution): () => void
});
}
// ---------------------------------------------------------------------------
// Project view roots shown below the project ("Scratches").
/** A searchable settings entry: a page, or an option label on it (Search Everywhere's "Settings: …" items). */
export interface SettingsSearchEntry {
pageId: string;
/** Page title or option label. */
title: string;
/** Breadcrumb of the page ("Editor", "General"). */
path: string[];
/** Extra words that match this entry (page keywords). */
keywords?: string[];
}
export interface ProjectViewRootContribution {
id: string;
let settingsSearchIndex: (() => Promise<SettingsSearchEntry[]>) | null = null;
/** The settings feature provides its search index (pages load lazily, so it is async). */
export function provideSettingsSearchIndex(index: () => Promise<SettingsSearchEntry[]>): () => void {
settingsSearchIndex = index;
return () => {
if (settingsSearchIndex === index) settingsSearchIndex = null;
};
}
export function searchableSettings(): Promise<SettingsSearchEntry[]> {
return settingsSearchIndex ? settingsSearchIndex() : Promise.resolve([]);
}
// ---------------------------------------------------------------------------
// Project view roots shown below the project ("External Libraries", "Scratches").
export interface ProjectViewRootEntry {
label: string;
/** Icon name from `ui/icons`. */
icon: string;
/** Folder whose contents the node lists (outside the project). */
path: string;
/** Folder whose contents the node lists (outside the project); omitted for a group of `children`. */
path?: string;
/** Child nodes of a group node (one per SDK, then its library folders). */
children?: readonly ProjectViewRootEntry[];
/** Grey text after the label ("library root"). */
detail?: string;
/** Library sources: shown read-only (no new / rename / move / delete). */
readOnly?: boolean;
}
export interface ProjectViewRootContribution extends ProjectViewRootEntry {
id: string;
/** Lower first. */
order?: number;
/** Which project view mode lists it besides "Project" ("Scratches and Consoles"). */
scope?: "scratches";
}
export const useProjectViewRoots = create<{ roots: Map<string, ProjectViewRootContribution> }>(() => ({
@@ -525,6 +665,8 @@ export function projectViewRoots(map: ReadonlyMap<string, ProjectViewRootContrib
// `stopProcess` is the Stop chooser (as IDEA): one item per running process
// a feature can stop (features/run: run sessions, features/debugger: debug sessions).
// `selectIn` is the Select In chooser (⌥F1): targets for a file besides the project view.
// `new` is the New popup / New › submenu (IDEA's NewGroup): file kinds a feature creates
// in `paths[0]`, the target directory (features/templates: file templates, http-client: HTTP Request).
export type ContextMenuId =
| "editor"
@@ -533,7 +675,8 @@ export type ContextMenuId =
| "editorGutter"
| "openIn"
| "stopProcess"
| "selectIn";
| "selectIn"
| "new";
export interface MenuGroupContext {
/** Files / directories the menu is for (absolute paths; a virtual URI for virtual documents). */
@@ -647,6 +790,72 @@ export function runExecutors(map = useRunExecutors.getState().executors): RunExe
return [...map.values()].sort((a, b) => (a.order ?? 0) - (b.order ?? 0) || a.id.localeCompare(b.id));
}
// ---------------------------------------------------------------------------
// Run configuration types (IDEA's "Add New Configuration" list), contributed by
// features and edited by features/run. A type is declarative: its form fields
// write `RunConfig.params`, and `commandLine` turns them into what is launched.
export interface RunConfigTypeField {
/** Key in `RunConfig.params`. */
key: string;
label: string;
/** `checkbox` values are `"true"` or absent. */
control?: "text" | "textarea" | "select" | "checkbox";
mono?: boolean;
placeholder?: string;
options?: ReadonlyArray<{ value: string; label: string }>;
/** Values offered while typing (e.g. the scripts of detected configurations). */
suggestions?(configs: readonly RunConfig[], params: Readonly<Record<string, string>>): string[];
required?: boolean;
visible?(params: Readonly<Record<string, string>>): boolean;
}
export interface RunConfigTypeContribution {
/** Stored as `RunConfig.kind`. */
id: string;
label: string;
icon: string;
description?: string;
/** Parent node in "Add New Configuration" (e.g. `Docker`). */
group?: string;
/** Params of a new configuration (before templates apply). */
defaults?: Readonly<Record<string, string>>;
fields: readonly RunConfigTypeField[];
/**
* What is launched for `params`; null when the type runs no process itself (Compound).
* `stop`: a shell command line run on Stop before the process is signalled (`docker stop …`).
*/
commandLine(
params: Readonly<Record<string, string>>,
): { command: string; args: string[]; cwd?: string | null; stop?: string | null } | null;
/** Params that reproduce a configuration made elsewhere (detection, older files); null when it doesn't fit. */
paramsFrom?(config: RunConfig): Record<string, string> | null;
/** The type's fields set the working directory (no separate field). */
ownsWorkingDirectory?: boolean;
/** "Emulate terminal in output console" applies (default true). */
terminal?: boolean;
/** Adjusts what is launched right before it starts (e.g. the environment selecting a Docker daemon). */
prepareLaunch?(config: RunConfig): Promise<RunConfig>;
}
export const useRunConfigTypes = create<{ types: Map<string, RunConfigTypeContribution> }>(() => ({ types: new Map() }));
export function registerRunConfigType(type: RunConfigTypeContribution): () => void {
useRunConfigTypes.setState((s) => ({ types: new Map(s.types).set(type.id, type) }));
return () =>
useRunConfigTypes.setState((s) => {
if (s.types.get(type.id) !== type) return s;
const types = new Map(s.types);
types.delete(type.id);
return { types };
});
}
/** Registered configuration types, by label. */
export function runConfigTypes(map = useRunConfigTypes.getState().types): RunConfigTypeContribution[] {
return [...map.values()].sort((a, b) => a.label.localeCompare(b.label));
}
// ---------------------------------------------------------------------------
// Occurrence navigators: result views (Find, Usages, Problems, TODO…) let
// Next / Previous Occurrence step through their results from anywhere, the
@@ -695,6 +904,127 @@ export function useOccurrenceNavigator(toolWindow: string, nav: OccurrenceNaviga
);
}
// ---------------------------------------------------------------------------
// Editor notification banners (IDEA's EditorNotificationProvider): a feature
// answers, per file, with a banner shown above its text editor, or null.
export interface EditorBanner {
text: string;
level?: "info" | "warning";
/** Icon name (`ui/icons`); defaults to the level's icon. */
icon?: string;
/** Links on the right ("Install", "Ignore"). */
actions?: Array<{ label: string; run: () => void }>;
}
export interface EditorBannerContribution {
id: string;
/** Lower first. */
order?: number;
/** A React hook (may read stores), called for each open text editor's file. */
useBanner(path: string): EditorBanner | null;
}
export const useEditorBanners = create<{ banners: Map<string, EditorBannerContribution> }>(() => ({
banners: new Map(),
}));
export function registerEditorBanner(banner: EditorBannerContribution): () => void {
useEditorBanners.setState((s) => ({ banners: new Map(s.banners).set(banner.id, banner) }));
return () =>
useEditorBanners.setState((s) => {
if (s.banners.get(banner.id) !== banner) return s;
const banners = new Map(s.banners);
banners.delete(banner.id);
return { banners };
});
}
// ---------------------------------------------------------------------------
// Local changes: the VCS provider publishes its change lists (IDEA's
// ChangeListManager) for features that filter by changed files (TODO › Changes,
// the project view's "All Changed Files"). Unversioned and ignored files belong
// to no list.
export interface LocalChangedFile {
path: string;
/** Deleted in the working tree (no longer on disk). */
deleted?: boolean;
}
export interface LocalChangelist {
id: string;
name: string;
/** New changes go to the active list. */
active: boolean;
files: LocalChangedFile[];
}
export const useLocalChanges = create<{ provider: string | null; lists: LocalChangelist[] }>(() => ({
provider: null,
lists: [],
}));
/** Replaces the change lists (one VCS provider at a time); the returned function clears them. */
export function setLocalChanges(provider: string, lists: LocalChangelist[]): () => void {
useLocalChanges.setState({ provider, lists });
return () => {
if (useLocalChanges.getState().provider === provider) useLocalChanges.setState({ provider: null, lists: [] });
};
}
/** Paths of the changed files, of one list (`listId`) or all; `existing` leaves out deleted files. */
export function localChangedPaths(lists: readonly LocalChangelist[], listId?: string | null, existing = false): string[] {
const out: string[] = [];
for (const l of lists) {
if (listId && l.id !== listId) continue;
for (const f of l.files) if (!existing || !f.deleted) out.push(f.path);
}
return out.sort();
}
// ---------------------------------------------------------------------------
// Run Anything (⌃⌃) providers: features offer entries for the typed text
// (run configurations, `mvn …` / `gradle …` command lines) and may take over
// running a whole command line; anything else runs as a shell command.
export interface RunAnythingItem {
/** Stable within the provider. */
key: string;
label: string;
detail?: string;
icon?: string;
/** Text put in the field when the item is completed with Tab (default: label). */
completion?: string;
run(): void | Promise<void>;
}
export interface RunAnythingProvider {
id: string;
/** Group heading, e.g. "Run Configurations". */
group: string;
/** Lower first. */
order?: number;
/** Matching entries for the field's text (cheap, synchronous). */
items(query: string): RunAnythingItem[];
/** Runs `line` when it belongs to this provider (`mvn clean`); false leaves it to the next one. */
execute?(line: string): boolean | Promise<boolean>;
}
const runAnythingProviders = new Map<string, RunAnythingProvider>();
export function registerRunAnythingProvider(p: RunAnythingProvider): () => void {
runAnythingProviders.set(p.id, p);
return () => {
if (runAnythingProviders.get(p.id) === p) runAnythingProviders.delete(p.id);
};
}
/** Registered providers in order. */
export function runAnythingProviderList(): RunAnythingProvider[] {
return [...runAnythingProviders.values()].sort((a, b) => (a.order ?? 100) - (b.order ?? 100) || a.id.localeCompare(b.id));
}
// ---------------------------------------------------------------------------
// Status bar info text (IDEA's status bar message, e.g. "3 of 12" after Next
// Occurrence). Cleared by the next message or after a while.
+34
Переглянути файл
@@ -0,0 +1,34 @@
.form {
display: flex;
flex-direction: column;
gap: 8px;
padding: 14px;
}
.label {
color: var(--fg-secondary);
font-size: var(--fs-small);
}
.error {
color: var(--danger);
font-size: var(--fs-small);
}
.hint {
color: var(--fg-subtle);
font-size: var(--fs-small);
line-height: 1.5;
}
.list {
height: 200px;
border: 1px solid var(--border);
border-radius: var(--radius-control, 4px);
}
.row,
.activeRow {
padding: 0 8px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.activeRow {
color: var(--fg-strong);
}
+167
Переглянути файл
@@ -0,0 +1,167 @@
/**
* Promise-based input dialogs for shell and feature actions (IDEA's
* `Messages.showInputDialog` and a simple chooser):
*
* const name = await promptInput({ title: "Save Layout", label: "Name:" });
* const picked = await chooseItem({ title: "Delete Layout", items: names });
*
* Rendered by `InputDialogsHost` (in `OverlayHost`).
*/
import { useState, type ReactNode } from "react";
import { create } from "zustand";
import { t } from "@/platform/i18n";
import { Dialog, Input, List, T } from "@/ui";
import styles from "./inputDialogs.module.css";
export interface PromptInputOptions {
title: string;
label?: string;
initial?: string;
placeholder?: string;
okLabel?: string;
/** An error message for `value`, or null when valid (default: must not be blank). */
validate?: (value: string) => string | null;
/** Extra content under the field (hints). */
hint?: ReactNode;
}
export interface ChooseItemOptions {
title: string;
items: readonly string[];
/** Shown when `items` is empty. */
empty?: string;
}
type Request =
| { kind: "prompt"; id: number; options: PromptInputOptions; resolve: (v: string | null) => void }
| { kind: "choose"; id: number; options: ChooseItemOptions; resolve: (v: string | null) => void };
const useInputDialogs = create<{ stack: Request[] }>(() => ({ stack: [] }));
let seq = 0;
function open(make: (id: number, finish: (v: string | null) => void) => Request): Promise<string | null> {
return new Promise((resolve) => {
const id = ++seq;
const finish = (v: string | null) => {
useInputDialogs.setState((s) => ({ stack: s.stack.filter((r) => r.id !== id) }));
resolve(v);
};
useInputDialogs.setState((s) => ({ stack: [...s.stack, make(id, finish)] }));
});
}
/** A text field dialog; resolves with the trimmed value, or null when cancelled. */
export function promptInput(options: PromptInputOptions): Promise<string | null> {
return open((id, resolve) => ({ kind: "prompt", id, options, resolve }));
}
/** A list to pick one entry from; resolves with it, or null when cancelled. */
export function chooseItem(options: ChooseItemOptions): Promise<string | null> {
return open((id, resolve) => ({ kind: "choose", id, options, resolve }));
}
export function InputDialogsHost() {
const stack = useInputDialogs((s) => s.stack);
return (
<>
{stack.map((r) =>
r.kind === "prompt" ? (
<PromptDialog key={r.id} options={r.options} done={r.resolve} />
) : (
<ChooseDialog key={r.id} options={r.options} done={r.resolve} />
),
)}
</>
);
}
const notBlank = (v: string) => (v.trim() ? null : "Value must not be empty");
function PromptDialog({ options, done }: { options: PromptInputOptions; done: (v: string | null) => void }) {
const [value, setValue] = useState(options.initial ?? "");
const [touched, setTouched] = useState(false);
const error = (options.validate ?? notBlank)(value);
const submit = () => {
setTouched(true);
if (!error) done(value.trim());
};
return (
<Dialog
open
title={options.title}
onClose={() => done(null)}
width={420}
buttons={[
{ label: options.okLabel ?? "OK", role: "default", disabled: !!error, onClick: submit },
{ label: t("common.cancel"), role: "cancel" },
]}
>
<form
className={styles.form}
onSubmit={(e) => {
e.preventDefault();
submit();
}}
>
{options.label && (
<label className={styles.label}>
<T>{options.label}</T>
</label>
)}
<Input
data-autofocus
aria-label={options.label ?? options.title}
value={value}
placeholder={options.placeholder}
invalid={touched && !!error}
onChange={(e) => {
setTouched(true);
setValue(e.target.value);
}}
/>
{touched && error && (
<div className={styles.error}>
<T>{error}</T>
</div>
)}
{options.hint && <div className={styles.hint}>{options.hint}</div>}
</form>
</Dialog>
);
}
function ChooseDialog({ options, done }: { options: ChooseItemOptions; done: (v: string | null) => void }) {
const [active, setActive] = useState(0);
const pick = options.items[active];
return (
<Dialog
open
title={options.title}
onClose={() => done(null)}
width={360}
buttons={[
{ label: "OK", role: "default", disabled: pick === undefined, onClick: () => pick !== undefined && done(pick) },
{ label: t("common.cancel"), role: "cancel" },
]}
>
<div className={styles.form}>
<List
items={options.items}
getKey={(item) => item}
activeIndex={active}
onActiveChange={setActive}
onActivate={(item) => done(item)}
rowHeight={24}
className={styles.list}
aria-label={options.title}
empty={
<div className={styles.hint}>
<T>{options.empty ?? "Nothing to choose from"}</T>
</div>
}
renderItem={(item, state) => <div className={state.active ? styles.activeRow : styles.row}>{item}</div>}
/>
</div>
</Dialog>
);
}
+44
Переглянути файл
@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import type { TitleItemContribution } from "./contributions";
import { defaultToolbar, editableToolbar, resolveToolbar, SEARCH_ENTRY, toggleWidget, VCS_WIDGET } from "./mainToolbar";
const item = (id: string, align: "left" | "right", priority: number): TitleItemContribution => ({
id,
align,
priority,
component: () => null,
});
const items = [item("run.widget", "right", 10), item("build.project", "right", 0), item(VCS_WIDGET, "left", 0)];
describe("main toolbar layout", () => {
it("defaults to the project and VCS widgets, contributed widgets by priority, then Search Everywhere", () => {
expect(defaultToolbar(items)).toEqual({
left: ["project", VCS_WIDGET],
right: ["build.project", "run.widget", SEARCH_ENTRY],
hidden: [],
});
// Without a VCS widget the left side keeps its "Version Control" fallback.
expect(defaultToolbar([]).left).toEqual(["project", VCS_WIDGET]);
});
it("keeps the saved order and appends widgets registered since", () => {
const saved = { left: ["project"], right: [SEARCH_ENTRY, "run.widget"], hidden: [VCS_WIDGET] };
expect(resolveToolbar(saved, items)).toEqual({
left: ["project"],
right: [SEARCH_ENTRY, "run.widget", "build.project"],
});
});
it("hides and re-adds widgets; removed actions are dropped, not hidden", () => {
const layout = editableToolbar(null, items);
const hidden = toggleWidget(layout, "run.widget", "right");
expect(hidden.right).toEqual(["build.project", SEARCH_ENTRY]);
expect(hidden.hidden).toEqual(["run.widget"]);
const shown = toggleWidget(hidden, "run.widget", "right");
expect(shown.right).toEqual(["build.project", SEARCH_ENTRY, "run.widget"]);
expect(shown.hidden).toEqual([]);
expect(toggleWidget(layout, SEARCH_ENTRY, "right").hidden).toEqual([]);
// A hidden widget stays hidden after reload.
expect(resolveToolbar(hidden, items).right).not.toContain("run.widget");
});
});
+100
Переглянути файл
@@ -0,0 +1,100 @@
/**
* Customize Main Toolbar: which widgets (project, VCS, run…) and actions the
* title bar shows on each side, in which order. Stored in
* `appearance.mainToolbar` (global settings); unset = the default toolbar.
*/
import type { MainToolbarSettings } from "@/bindings";
import { getAction } from "@/platform/actions";
import { call, commands, isTauri } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import type { TitleItemContribution } from "./contributions";
export const PROJECT_WIDGET = "project";
/** Shown as "Version Control ▾" while the project has no repository. */
export const VCS_WIDGET = "vcs.branch";
export const ACTION_PREFIX = "action:";
export const SEARCH_ENTRY = `${ACTION_PREFIX}navigate.searchEverywhere`;
export interface ToolbarLayout {
left: string[];
right: string[];
hidden: string[];
}
export const isActionEntry = (id: string) => id.startsWith(ACTION_PREFIX);
export const entryAction = (id: string) => id.slice(ACTION_PREFIX.length);
const byPriority = (a: TitleItemContribution, b: TitleItemContribution) => a.priority - b.priority || a.id.localeCompare(b.id);
/** Widgets in their default place (built-ins included), by side. */
function defaultWidgets(items: Iterable<TitleItemContribution>): { left: string[]; right: string[] } {
const all = [...items];
const left = all.filter((i) => i.align === "left").sort(byPriority).map((i) => i.id);
const right = all.filter((i) => i.align === "right").sort(byPriority).map((i) => i.id);
return { left: [PROJECT_WIDGET, ...(left.includes(VCS_WIDGET) ? [] : [VCS_WIDGET]), ...left], right };
}
export function defaultToolbar(items: Iterable<TitleItemContribution>): ToolbarLayout {
const { left, right } = defaultWidgets(items);
return { left, right: [...right, SEARCH_ENTRY], hidden: [] };
}
/**
* The entries to show per side: the saved order, plus widgets registered since
* (not listed, not removed) at their default side's end.
*/
export function resolveToolbar(
saved: MainToolbarSettings | null | undefined,
items: Iterable<TitleItemContribution>,
): { left: string[]; right: string[] } {
const list = [...items];
if (!saved) {
const d = defaultToolbar(list);
return { left: d.left, right: d.right };
}
const left = [...(saved.left ?? [])];
const right = [...(saved.right ?? [])];
const known = new Set([...left, ...right, ...(saved.hidden ?? [])]);
const d = defaultWidgets(list);
for (const id of d.left) if (!known.has(id)) left.push(id);
for (const id of d.right) if (!known.has(id)) right.push(id);
return { left, right };
}
/** The full editable layout (Customize Main Toolbar dialog). */
export function editableToolbar(saved: MainToolbarSettings | null | undefined, items: Iterable<TitleItemContribution>): ToolbarLayout {
const list = [...items];
const { left, right } = resolveToolbar(saved, list);
return { left, right, hidden: saved ? [...(saved.hidden ?? [])] : [] };
}
export function widgetTitle(id: string, items: ReadonlyMap<string, TitleItemContribution>): string {
if (id === PROJECT_WIDGET) return "Project Widget";
if (id === VCS_WIDGET && !items.has(id)) return "VCS Widget";
if (isActionEntry(id)) return getAction(entryAction(id))?.title ?? entryAction(id);
return items.get(id)?.title ?? id;
}
/** Shows or removes a widget, keeping the rest of the layout. */
export function toggleWidget(layout: ToolbarLayout, id: string, defaultSide: "left" | "right"): ToolbarLayout {
const shown = layout.left.includes(id) || layout.right.includes(id);
if (shown)
return {
left: layout.left.filter((x) => x !== id),
right: layout.right.filter((x) => x !== id),
hidden: [...layout.hidden.filter((x) => x !== id), ...(isActionEntry(id) ? [] : [id])],
};
const side = defaultSide === "left" ? { left: [...layout.left, id], right: layout.right } : { left: layout.left, right: [...layout.right, id] };
return { ...side, hidden: layout.hidden.filter((x) => x !== id) };
}
export function useMainToolbarSettings(): MainToolbarSettings | null | undefined {
return useProject((s) => s.settings?.appearance?.mainToolbar);
}
/** Saves `layout`; `null` restores the default toolbar. */
export async function saveToolbar(layout: ToolbarLayout | null): Promise<void> {
if (!isTauri()) return;
const settings = await call(commands.settingsSet("global", "appearance.mainToolbar", JSON.stringify(layout)));
useProject.setState({ settings });
}
+20 -1
Переглянути файл
@@ -1,7 +1,7 @@
/** Notification center: balloons + history, backing `NotificationService`. */
import type { ReactNode } from "react";
import { create } from "zustand";
import { onIdeEvent } from "@/platform/ipc";
import { call, commands, onIdeEvent } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import type { NotificationService } from "./contributions";
@@ -53,6 +53,25 @@ export function notificationPrefs(group: string): NotificationPrefs {
return { balloons: n?.balloons !== false, sound: n?.sound === true, display, log: g?.log !== false };
}
/** Errors always show; other notifications offer "Don't Show Again" for their group. */
export function canSuppress(n: Pick<IdeNotification, "level" | "group">): boolean {
return n.level !== "error" && notificationPrefs(n.group).display !== "none";
}
/**
* "Don't Show Again": the group's balloons are turned off (Settings ›
* Notifications › "No popup"); its notifications are still logged.
*/
export async function suppressGroup(group: string): Promise<void> {
const groups = { ...(useProject.getState().settings?.notifications?.groups ?? {}) };
groups[group] = { ...(groups[group] ?? {}), display: "none", log: groups[group]?.log ?? true };
const settings = await call(commands.settingsSet("global", "notifications.groups", JSON.stringify(groups)));
useProject.setState({ settings });
useNotifications.setState((s) => ({
items: s.items.flatMap((x) => (x.group !== group || !x.balloon ? [x] : x.logged ? [{ ...x, balloon: false }] : [])),
}));
}
function groupOf(n: { group?: string; key?: string }): string {
if (n.group) return n.group;
if (n.key?.startsWith("app.update")) return "updates";
+80
Переглянути файл
@@ -0,0 +1,80 @@
/**
* Theme / color scheme / keymap / view mode switches shared by the main
* toolbar's gear menu and View › Quick Switch Scheme… (⌃`).
*/
import { call, commands, isTauri } from "@/platform/ipc";
import { SCHEMES } from "@/platform/colorScheme";
import { PRESET_TITLES, useKeymap } from "@/platform/keymap";
import { useProject } from "@/platform/project";
import type { MenuItem } from "@/ui";
import { currentThemeId, setTheme, THEME_CHOICES } from "./theme";
async function setGlobal(key: string, value: unknown, what: string) {
if (!isTauri()) return;
try {
useProject.setState({ settings: await call(commands.settingsSet("global", key, JSON.stringify(value))) });
} catch (e) {
console.warn(`cannot switch ${what}`, e);
}
}
export const selectKeymap = (id: string) => setGlobal("keymap", id, "keymap");
export function themeItems(themeId: string = currentThemeId()): MenuItem[] {
return THEME_CHOICES.map<MenuItem>((c) => ({
id: `theme.${c.id}`,
label: c.label,
checked: c.id === themeId,
run: () => void setTheme(c.id),
}));
}
export function keymapItems(keymapId: string, customKeymaps: string[]): MenuItem[] {
return [...Object.keys(PRESET_TITLES), ...customKeymaps].map<MenuItem>((id) => ({
id: `keymap.${id}`,
label: PRESET_TITLES[id] ?? id,
verbatim: !PRESET_TITLES[id],
checked: id === keymapId,
run: () => void selectKeymap(id),
}));
}
export function colorSchemeItems(current: string | undefined): MenuItem[] {
const value = current && SCHEMES.some((s) => s.id === current) ? current : "theme-default";
return [
{ id: "scheme.theme-default", label: "Theme default", checked: value === "theme-default", run: () => void setGlobal("editor.colorScheme", "theme-default", "color scheme") },
...SCHEMES.map<MenuItem>((s) => ({
id: `scheme.${s.id}`,
label: s.name,
checked: s.id === value,
run: () => void setGlobal("editor.colorScheme", s.id, "color scheme"),
})),
];
}
/** The existing view modes: Distraction Free Mode and Full Screen. */
export const VIEW_MODE_ITEMS: MenuItem[] = [
{ kind: "action", action: "editor.toggleZen" },
{ kind: "action", action: "window.toggleFullScreen" },
];
/** The keymap in effect and the user's custom keymaps. */
export function keymapState(): { keymapId: string; customs: string[] } {
const settings = useProject.getState().settings;
const customs = settings?.customKeymaps;
return {
keymapId: typeof settings?.keymap === "string" ? settings.keymap : useKeymap.getState().preset,
customs: customs && typeof customs === "object" ? Object.keys(customs) : [],
};
}
/** View › Quick Switch Scheme…: IDEA's popup of the switchable schemes. */
export function quickSwitchItems(): MenuItem[] {
const { keymapId, customs } = keymapState();
return [
{ id: "qs.scheme", label: "Editor Color Scheme", submenu: colorSchemeItems(useProject.getState().settings?.editor?.colorScheme) },
{ id: "qs.keymap", label: "Keymap", submenu: keymapItems(keymapId, customs) },
{ id: "qs.viewMode", label: "View Mode", submenu: VIEW_MODE_ITEMS },
{ id: "qs.theme", label: "Theme", submenu: themeItems() },
];
}
+34
Переглянути файл
@@ -1,6 +1,9 @@
import { executeAction, getAction, registerActions } from "@/platform/actions";
import { isTauri } from "@/platform/ipc";
import { closeProject, confirmLeaveProject, openProject, useProject } from "@/platform/project";
import { runSearchFieldCommand, showContextMenu } from "@/ui";
import { showCustomizeToolbar } from "./MainToolbarDialog";
import { quickSwitchItems } from "./quickSwitch";
import { notify } from "./notificationStore";
import { NOTIFICATIONS_TOOL_WINDOW } from "./Notifications";
import { activateToolWindow } from "./toolwindows/store";
@@ -60,6 +63,20 @@ export function registerShellActions(): () => void {
keywords: ["appearance", "theme"],
run: toggleTheme,
},
{
id: "view.quickSwitchScheme",
title: "Quick Switch Scheme…",
category: "View",
keywords: ["theme", "color scheme", "keymap", "view mode"],
run: () => showContextMenu({ x: window.innerWidth / 2 - 120, y: 96 }, quickSwitchItems()),
},
{
id: "toolbar.customize",
title: "Customize Main Toolbar…",
category: "View",
keywords: ["toolbar", "widgets", "title bar"],
run: showCustomizeToolbar,
},
{
id: "view.notifications",
title: "Notifications",
@@ -67,5 +84,22 @@ export function registerShellActions(): () => void {
icon: "bell",
run: () => activateToolWindow(NOTIFICATIONS_TOOL_WINDOW),
},
// Keys of search fields (find bar, Find in Files) that are editor shortcuts elsewhere.
{
id: "find.field.newLine",
title: "New Line in Search Field",
category: "Find",
hidden: true,
when: "searchFieldNewLine",
run: () => void runSearchFieldCommand("newLine"),
},
{
id: "find.field.recentSearches",
title: "Recent Searches",
category: "Find",
hidden: true,
when: "searchFieldHistory",
run: () => void runSearchFieldCommand("history"),
},
]);
}
+9 -4
Переглянути файл
@@ -553,6 +553,8 @@ function staticHeaderMenu(items: MenuItem[] | undefined): (id: string) => MenuIt
return f;
}
const noTitleMenu = (): { label: string; items: MenuItem[] } | null => null;
function ToolWindowView({ tw, hidden }: { tw: ToolWindowContribution; hidden: boolean }) {
const ref = useRef<HTMLElement>(null);
const focusRequest = useToolWindowLayout((s) => s.focusRequest);
@@ -580,6 +582,9 @@ function ToolWindowView({ tw, hidden }: { tw: ToolWindowContribution; hidden: bo
const useHeaderMenu = typeof tw.headerMenu === "function" ? tw.headerMenu : staticHeaderMenu(tw.headerMenu);
const own = useHeaderMenu(tw.id);
const useTitleMenu = tw.titleMenu ?? noTitleMenu;
const titleMenu = useTitleMenu(tw.id);
const titleItems = titleMenu?.items ?? own;
const more: MenuItem[] = [
...own,
...(own.length ? [{ kind: "separator" } as MenuItem] : []),
@@ -606,7 +611,7 @@ function ToolWindowView({ tw, hidden }: { tw: ToolWindowContribution; hidden: bo
className={clsx(styles.header, Tabs && styles.headerTabbed)}
onDoubleClick={(e) => e.target === e.currentTarget && toggleMaximizeToolWindow(tw.id)}
>
{Tabs && tw.tabsReplaceTitle ? null : own.length > 0 ? (
{Tabs && tw.tabsReplaceTitle ? null : titleItems.length > 0 ? (
<>
<button
ref={titleRef}
@@ -617,7 +622,7 @@ function ToolWindowView({ tw, hidden }: { tw: ToolWindowContribution; hidden: bo
onClick={() => setTitleOpen((v) => !v)}
>
<span className={styles.title}>
<T>{tw.title}</T>
<T>{titleMenu?.label ?? tw.title}</T>
</span>
<Icon name="chevron-down" size={12} className={styles.titleChevron} />
</button>
@@ -625,8 +630,8 @@ function ToolWindowView({ tw, hidden }: { tw: ToolWindowContribution; hidden: bo
open={titleOpen}
onClose={() => setTitleOpen(false)}
anchor={titleRef}
items={own}
aria-label={`${tx(tw.title)} view options`}
items={titleItems}
aria-label={titleMenu ? `${tx(tw.title)} views` : `${tx(tw.title)} view options`}
/>
</>
) : (
+97 -1
Переглянути файл
@@ -1,7 +1,20 @@
import { registerAction, registerActions } from "@/platform/actions";
import { registerAction, registerActions, type Action } from "@/platform/actions";
import { setContext } from "@/platform/context";
import { formatMessage, tx } from "@/platform/i18n";
import { occurrenceNavigator, showStatusInfo, useContributions } from "../contributions";
import { isDistractionFree, setDistractionFree } from "../distractionFree";
import { focusEditor } from "../focus";
import { chooseItem, promptInput } from "../inputDialogs";
import { confirm } from "../messages";
import {
deleteLayout,
layoutNames,
renameLayout,
restoreDefaultLayout,
restoreLayout,
saveLayout,
useToolWindowLayouts,
} from "./layouts";
import { closeSelectedContent } from "./content";
import {
activateToolWindow,
@@ -32,6 +45,84 @@ export function goToOccurrence(dir: 1 | -1): boolean {
return false;
}
/** Window › Tool Window Layouts. */
function layoutActions(): Action[] {
const exitDistractionFree = () => {
if (isDistractionFree()) setDistractionFree(false);
};
const pick = (title: string) =>
chooseItem({ title, items: layoutNames(), empty: "No saved layouts. Use Save Current Layout as… first." });
const free = (current?: string) => (v: string) =>
!v.trim() ? "Enter a layout name" : v.trim() !== current && layoutNames().includes(v.trim()) ? "A layout with this name exists" : null;
return [
{
id: "layout.save",
title: "Save Current Layout as…",
category: "Window",
keywords: ["tool window layout", "store layout"],
when: "projectOpen",
run: async () => {
const active = useToolWindowLayouts.getState().active;
const name = await promptInput({
title: "Save Current Layout as",
label: "Layout name:",
initial: active ?? "",
okLabel: "Save",
validate: (v) => (v.trim() ? null : "Enter a layout name"),
});
if (name) saveLayout(name);
},
},
{
id: "layout.restore",
title: "Restore Tool Window Layout",
description: "Restores the saved layout named `arg`, or asks which one.",
category: "Window",
keywords: ["tool window layout"],
when: "projectOpen",
run: async ({ arg }) => {
exitDistractionFree();
const name = typeof arg === "string" ? arg : await pick("Restore Layout");
if (name) restoreLayout(name);
},
},
{
id: "layout.restoreDefault",
title: "Restore Default Layout",
category: "Window",
keywords: ["tool window layout", "reset"],
when: "projectOpen",
run: () => {
exitDistractionFree();
restoreDefaultLayout();
},
},
{
id: "layout.rename",
title: "Rename Tool Window Layout…",
category: "Window",
when: "toolWindowLayoutsSaved",
run: async () => {
const from = await pick("Rename Layout");
if (!from) return;
const to = await promptInput({ title: "Rename Layout", label: "New name:", initial: from, okLabel: "Rename", validate: free(from) });
if (to) renameLayout(from, to);
},
},
{
id: "layout.delete",
title: "Delete Tool Window Layout…",
category: "Window",
when: "toolWindowLayoutsSaved",
run: async () => {
const name = await pick("Delete Layout");
if (name && (await confirm({ title: "Delete Layout", message: `Delete the layout '${name}'?`, ok: "Delete", icon: "warning" })))
deleteLayout(name);
},
},
];
}
/** Registers `toolwindow.<id>` for every tool window plus the generic tool window actions. */
export function installToolWindowActions(): () => void {
const perWindow = new Map<string, { title: string; icon: string; dispose: () => void }>();
@@ -62,6 +153,9 @@ export function installToolWindowActions(): () => void {
const unsubscribe = useContributions.subscribe((s, prev) => {
if (s.toolWindows !== prev.toolWindows) sync();
});
const publishLayouts = () => setContext("toolWindowLayoutsSaved", useToolWindowLayouts.getState().layouts.length > 0);
publishLayouts();
const unsubscribeLayouts = useToolWindowLayouts.subscribe(publishLayouts);
const disposeStatic = registerActions([
{ id: "toolwindow.hideAll", title: "Hide All Tool Windows", category: "Window", run: hideAllToolWindows },
@@ -118,6 +212,7 @@ export function installToolWindowActions(): () => void {
when: "projectOpen",
run: () => void goToOccurrence(-1),
},
...layoutActions(),
{
id: "toolwindow.maximize",
title: "Maximize Tool Window",
@@ -133,6 +228,7 @@ export function installToolWindowActions(): () => void {
return () => {
unsubscribe();
unsubscribeLayouts();
disposeStatic();
perWindow.forEach((e) => e.dispose());
perWindow.clear();
+102
Переглянути файл
@@ -0,0 +1,102 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { getContext, setContext } from "@/platform/context";
import { executeAction } from "@/platform/actions";
import { registerToolWindow } from "../contributions";
import { installToolWindowActions } from "./actions";
import {
_reloadLayouts,
deleteLayout,
layoutNames,
renameLayout,
restoreDefaultLayout,
restoreLayout,
saveLayout,
useToolWindowLayouts,
} from "./layouts";
import {
isToolWindowVisible,
loadToolWindowLayout,
resetToolWindowLayout,
setToolWindowSize,
showToolWindow,
useToolWindowLayout,
} from "./store";
const Empty = () => null;
let disposers: Array<() => void> = [];
beforeEach(() => {
localStorage.clear();
_reloadLayouts();
resetToolWindowLayout();
disposers = [
registerToolWindow({ id: "project", title: "Project", icon: "folder", anchor: "left", component: Empty }),
registerToolWindow({ id: "structure", title: "Structure", icon: "structure", anchor: "left", component: Empty }),
registerToolWindow({ id: "terminal", title: "Terminal", icon: "terminal", anchor: "bottom", component: Empty }),
installToolWindowActions(),
];
loadToolWindowLayout("/work/a");
});
afterEach(() => disposers.forEach((d) => d()));
describe("tool window layouts", () => {
it("saves and restores visible windows and sizes by name", () => {
showToolWindow("structure");
showToolWindow("terminal");
setToolWindowSize("bottom", 400);
saveLayout("Debugging");
showToolWindow("project");
setToolWindowSize("bottom", 200);
expect(restoreLayout("Debugging")).toBe(true);
expect(isToolWindowVisible("structure")).toBe(true);
expect(isToolWindowVisible("terminal")).toBe(true);
expect(useToolWindowLayout.getState().sizes.bottom).toBe(400);
expect(useToolWindowLayouts.getState().active).toBe("Debugging");
expect(restoreLayout("Missing")).toBe(false);
});
it("restores the default layout", () => {
showToolWindow("terminal");
restoreDefaultLayout();
expect(isToolWindowVisible("terminal")).toBe(false);
expect(isToolWindowVisible("project")).toBe(true);
});
it("replaces a layout of the same name, renames and deletes", () => {
saveLayout("A");
saveLayout("B");
saveLayout("A");
expect(layoutNames()).toEqual(["A", "B"]);
expect(renameLayout("A", "B")).toBe(false);
expect(renameLayout("A", " ")).toBe(false);
expect(renameLayout("A", "C")).toBe(true);
expect(layoutNames()).toEqual(["C", "B"]);
expect(useToolWindowLayouts.getState().active).toBe("C");
deleteLayout("C");
expect(layoutNames()).toEqual(["B"]);
expect(useToolWindowLayouts.getState().active).toBeNull();
});
it("persists across reloads and ignores malformed entries", () => {
saveLayout("Kept");
_reloadLayouts();
expect(layoutNames()).toEqual(["Kept"]);
localStorage.setItem("openidea.toolWindowLayouts.v1", JSON.stringify({ layouts: [{ name: "" }, { nope: 1 }, { name: "X", layout: 5 }] }));
_reloadLayouts();
expect(layoutNames()).toEqual(["X"]);
});
it("restores by name through the action (Window › Tool Window Layouts items)", async () => {
expect(getContext().toolWindowLayoutsSaved).toBeFalsy();
showToolWindow("terminal");
saveLayout("Run");
expect(getContext().toolWindowLayoutsSaved).toBe(true);
restoreDefaultLayout();
setContext("projectOpen", true);
await executeAction("layout.restore", "Run");
setContext("projectOpen", false);
expect(isToolWindowVisible("terminal")).toBe(true);
});
});
+100
Переглянути файл
@@ -0,0 +1,100 @@
/**
* Window › Tool Window Layouts (IDEA's ToolWindowLayoutManager): named
* snapshots of the tool window layout (visible windows, sizes, placements),
* shared by every project and kept in localStorage.
*/
import { create } from "zustand";
import {
applyToolWindowLayout,
defaultLayoutSnapshot,
sanitizeLayoutSnapshot,
toolWindowLayoutSnapshot,
type ToolWindowLayoutSnapshot,
} from "./store";
const STORAGE_KEY = "openidea.toolWindowLayouts.v1";
interface LayoutsState {
/** Saved layouts by name, in save order. */
layouts: ReadonlyArray<{ name: string; layout: ToolWindowLayoutSnapshot }>;
/** Name of the layout last saved or restored (shown checked). */
active: string | null;
}
function read(): LayoutsState {
try {
const raw: unknown = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "null");
if (!raw || typeof raw !== "object") return { layouts: [], active: null };
const r = raw as { layouts?: unknown; active?: unknown };
const layouts = Array.isArray(r.layouts)
? r.layouts
.filter((l): l is { name: string; layout: unknown } => !!l && typeof l.name === "string" && !!l.name.trim())
.map((l) => ({ name: l.name, layout: sanitizeLayoutSnapshot(l.layout) }))
: [];
const active = typeof r.active === "string" && layouts.some((l) => l.name === r.active) ? r.active : null;
return { layouts, active };
} catch {
return { layouts: [], active: null };
}
}
export const useToolWindowLayouts = create<LayoutsState>(read);
function write(next: LayoutsState) {
useToolWindowLayouts.setState(next);
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
} catch {
/* storage unavailable */
}
}
export function layoutNames(): string[] {
return useToolWindowLayouts.getState().layouts.map((l) => l.name);
}
/** Save Current Layout as…: replaces a layout of the same name. */
export function saveLayout(name: string) {
const n = name.trim();
if (!n) return;
const { layouts } = useToolWindowLayouts.getState();
const entry = { name: n, layout: toolWindowLayoutSnapshot() };
const i = layouts.findIndex((l) => l.name === n);
write({ layouts: i >= 0 ? layouts.map((l, j) => (j === i ? entry : l)) : [...layouts, entry], active: n });
}
export function restoreLayout(name: string): boolean {
const s = useToolWindowLayouts.getState();
const found = s.layouts.find((l) => l.name === name);
if (!found) return false;
applyToolWindowLayout(found.layout);
write({ ...s, active: name });
return true;
}
export function restoreDefaultLayout() {
applyToolWindowLayout(defaultLayoutSnapshot());
write({ ...useToolWindowLayouts.getState(), active: null });
}
/** Renames `from`; false when `to` is empty or taken by another layout. */
export function renameLayout(from: string, to: string): boolean {
const n = to.trim();
const s = useToolWindowLayouts.getState();
if (!n || (n !== from && s.layouts.some((l) => l.name === n))) return false;
write({
layouts: s.layouts.map((l) => (l.name === from ? { ...l, name: n } : l)),
active: s.active === from ? n : s.active,
});
return true;
}
export function deleteLayout(name: string) {
const s = useToolWindowLayouts.getState();
write({ layouts: s.layouts.filter((l) => l.name !== name), active: s.active === name ? null : s.active });
}
/** Test helper: re-reads storage. */
export function _reloadLayouts() {
useToolWindowLayouts.setState(read());
}
+60
Переглянути файл
@@ -551,6 +551,66 @@ export const toolWindowServiceImpl: ToolWindowService = {
isVisible: isToolWindowVisible,
};
/** What Window › Tool Window Layouts saves and restores. */
export interface ToolWindowLayoutSnapshot {
visible: SlotRecord;
sizes: Record<ToolWindowAnchor, number>;
splits: Record<ToolWindowAnchor, number>;
placements: Record<string, ToolWindowPlacement>;
stripeButtons: Record<string, boolean>;
}
export function toolWindowLayoutSnapshot(): ToolWindowLayoutSnapshot {
const s = useToolWindowLayout.getState();
return {
visible: { ...(s.distractionFreeStash ?? s.visible) },
sizes: { ...s.sizes },
splits: { ...s.splits },
placements: { ...s.placements },
stripeButtons: { ...s.stripeButtons },
};
}
/** Validates a stored snapshot (unknown shape: from storage). */
export function sanitizeLayoutSnapshot(raw: unknown): ToolWindowLayoutSnapshot {
const r = raw && typeof raw === "object" ? (raw as Record<string, unknown>) : {};
return {
visible: sanitizeVisible(r.visible),
sizes: sanitizeSizes(r.sizes),
splits: sanitizeSplits(r.splits),
placements: sanitizePlacements(r.placements),
stripeButtons: sanitizeStripeButtons(r.stripeButtons),
};
}
/** The layout of a fresh project (Restore Default Layout). */
export function defaultLayoutSnapshot(): ToolWindowLayoutSnapshot {
return sanitizeLayoutSnapshot(null);
}
/** Applies a saved layout to this project (and persists it as the project's layout). */
export function applyToolWindowLayout(snapshot: ToolWindowLayoutSnapshot) {
const s = useToolWindowLayout.getState();
const placements = { ...snapshot.placements };
// Windows registered later keep their saved area; unknown ones simply never show.
const visible = reconcile(snapshot.visible, placements) ?? { ...snapshot.visible };
const mounted = new Set(s.mounted);
for (const id of Object.values(visible)) if (id) mounted.add(id);
const hadFocus = !!s.focused && !Object.values(visible).includes(s.focused);
update({
visible,
sizes: { ...snapshot.sizes },
splits: { ...snapshot.splits },
placements,
stripeButtons: { ...snapshot.stripeButtons },
mounted,
maximized: null,
stashed: null,
distractionFreeStash: null,
});
if (hadFocus) focusEditor();
}
/** Test helper. */
export function resetToolWindowLayout() {
lastUsed = null;