дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 19:25:54 +02:00
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
Цей коміт міститься в:
1 джерело
294198a727
коміт
1e00ea5ee2
426 files changed
+85241
-2886
No files matched your search
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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)}
|
||||
/>
|
||||
))
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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() },
|
||||
];
|
||||
}
|
||||
@@ -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"),
|
||||
},
|
||||
]);
|
||||
}
|
||||
@@ -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`}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Заблокувати користувача