feat: Second pass aligning interactions with IntelliJ IDEA (#23)

* feat: Second pass aligning interactions with IntelliJ IDEA

Result tool windows
- Next/Previous Occurrence (⌥⌘↓/↑) across Find, Usages, Problems, TODO,
  Inspection and Hierarchy, with an "N of M" status hint
- Find: reuse the unpinned tab, Pin Tab, source preview, group by file or
  directory, exclude moves to the next result, Rerun
- Usages: tabs with pinning, exclude and preview
- Problems, TODO, Inspection: no autoscroll by default, severity-first sorting,
  context menu with quick fixes and copy description
- Trees: F4/⌘↓ jump to source; speed search Home/End and arrow handling

Find in Files, bookmarks, navigation
- Replace mode: Enter replaces the selection, ⇧⌥Enter replaces all, multi-select
- Pin Window toggle, highlighted read-only preview, search/replace history (⌥↓),
  Alt mnemonics, search context filter (comments / string literals)
- Mnemonic bookmarks (⌥F3, ⌃⇧digit, ⌃digit), ⌘F3 bookmarks popup, descriptions,
  ⌘-click gutter toggles a bookmark
- Select In popup (⌥F1); Recent Locations double-click and Delete;
  Search Everywhere remembers the query per tab

Code insight and refactoring
- Extract/Introduce continue with in-place rename and name suggestions;
  expression and occurrence choosers with live highlighting
- Safe Delete confirms first; numbered Refactor This
- Quick Documentation history, in-popup links and a Documentation tool window;
  Quick Definition switches between targets and shows the whole declaration
- Goto chooser lists symbol names and can pin to the Find window
- Parameter Info Tab/⇧Tab, intention preview, Reformat dialog and summary,
  inlay hint ⌘-click and context menu
- Live templates: Enter moves to the next field, two-step Esc, ⌘J as a lookup,
  language surrounders with mnemonics
- File Structure popup as a filterable tree; Show Usages filter and preview;
  call hierarchy jumps to call sites

Editor
- Paste re-indents each line by default; Move Statement moves whole statements
  in and out of blocks and re-indents
- Type over closing quotes of existing strings; ⌘D/⌘⌫ cover folded regions;
  ⌥⌫ deletes the preceding word
- ⌃G / ⌃⌘G whole-word rules and wrap hint; double-modifier caret cloning
- Visual-column block selection, nested Collapse All, recursive folding,
  fold selection toggle, Go to Matching Brace, gutter drag selects lines,
  sticky line and error stripe clicks, Insert toggles overwrite mode

Run, VCS, workbench
- Gutter icons show the last test result; Modify Run Configuration; test tree
  hides passed tests, context menu and assertion diff
- Stack frames resolve by fully qualified class name; context run chooser
- Inline Smart Step Into
- Git Log multi-select filters, "me" and date range; favourite branches,
  Fetch and tags in the branch popup; stash Keep index and Unstash options
- Terminal tab titles follow the shell; Distraction Free persists; gear menu;
  sticky balloons move to the notifications tool window after a while

* fix(vcs): Label restored local changes as yours after Smart Checkout; name the branch checked out

Conflicts from restoring stashed changes show the user's uncommitted
changes on the left like other operations, and the checkout notification
names the new branch instead of the previous one.

* fix: Close the merge editor on Apply; confirm before terminating a running Run tab

Like IDEA, applying a merge result closes its tab, and closing a Run tab
whose process still runs asks before terminating it (with a Settings
toggle and "Do not ask again").

* fix(terminal): Don't start a new session in the background after closing the last tab

The hidden Terminal window stays mounted, so it reopened a session as soon
as the last tab closed. It now opens the Local tab only once the window is
shown again, as IDEA does.

* fix(vcs): Start the merge result from a three-way merge; close the editor on Apply

The result pane now begins with non-conflicting changes merged and each
conflict showing the base text, resolved from the side panes, instead of
raw conflict markers. Apply now reports success, so the tab closes.

fix(search): Match File Structure filter on word starts

* fix: Limit Smart Step Into to the paused line; drop usages hints on callbacks; show full intention titles

Adapters that list every remaining call in the function (debugpy) made Smart
Step Into fall back to a chooser that offered calls from later lines. Targets
are now narrowed to the calls on the paused line, so they are picked in place.

Anonymous functions and callbacks no longer get a 'no usages' hint, and
intention titles are no longer cut short when there is room for them.

* fix: Undo / Redo from the Edit menu and Find Action use the editor's history

They ran the webview's native undo, which misses programmatic edits such as
templates, completion and refactorings, so nothing happened in the editor.
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-10-25 09:44:46 +08:00
1 джерело 81e0e8e2d0
коміт 82b6980a59
262 files changed
+13085 -1596

No files matched your search

+4 -4
Переглянути файл
@@ -2,7 +2,7 @@ import { useEffect, useRef, useState, useSyncExternalStore } from "react";
import { useTxTemplate } from "@/platform/i18n";
import { EmptyState, IconButton, Toast, T } from "@/ui";
import {
BALLOON_TIMEOUT,
balloonTimeout,
clearNotifications,
dismissBalloon,
markAllNotificationsRead,
@@ -39,10 +39,10 @@ function Balloon({ n }: { n: IdeNotification }) {
const active = useWindowActive();
const remaining = useRef<number | null>(null);
const txt = useTxTemplate();
const ms = balloonTimeout(n);
// IDEA's smart fade-out: the clock runs only while the window is active and the balloon not hovered.
useEffect(() => {
const ms = BALLOON_TIMEOUT[n.level];
if (n.sticky || ms === null || hover || !active) return;
if (ms === null || hover || !active) return;
const left = remaining.current ?? ms;
const start = Date.now();
const t = setTimeout(() => dismissBalloon(n.id), left);
@@ -50,7 +50,7 @@ function Balloon({ n }: { n: IdeNotification }) {
clearTimeout(t);
remaining.current = Math.max(1, left - (Date.now() - start));
};
}, [n.id, n.level, n.sticky, hover, active]);
}, [n.id, ms, hover, active]);
return (
<Toast
className={styles.balloon}
+7
Переглянути файл
@@ -102,3 +102,10 @@
text-overflow: ellipsis;
white-space: nowrap;
}
.info {
color: var(--fg-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
+12 -1
Переглянути файл
@@ -1,7 +1,7 @@
import { useMemo, useRef, useState } from "react";
import clsx from "clsx";
import { BarButton, IconButton, Popup, ProgressBar, showContextMenu, T } from "@/ui";
import { useContributions, type StatusItemContribution } from "./contributions";
import { useContributions, useStatusInfo, type StatusItemContribution } from "./contributions";
import { ErrorBoundary } from "./ErrorBoundary";
import { isStatusItemVisible, statusBarMenu, useStatusWidgetOverrides } from "./statusBarWidgets";
import { useTasks } from "./tasks";
@@ -55,6 +55,17 @@ export function StatusBar() {
);
}
/** Built-in: the transient status message (`showStatusInfo`). */
export function StatusInfo() {
const text = useStatusInfo((s) => s.text);
if (!text) return null;
return (
<span className={styles.info} role="status" title={text}>
{text}
</span>
);
}
/** Built-in: background task progress; click lists all running tasks. */
export function TaskStatus() {
const tasks = useTasks((s) => s.tasks);
+46
Переглянути файл
@@ -0,0 +1,46 @@
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";
let disposers: Array<() => void> = [];
afterEach(() => {
disposers.forEach((d) => d());
disposers = [];
useThemeOverride.setState({ theme: null });
});
describe("title bar gear menu", () => {
it("lists Settings, Plugins, Theme and Keymap switches 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([
"file.settings",
"plugins.settings",
"separator",
"Theme",
"Keymap",
"separator",
"app.checkForUpdates",
]);
const theme = (items[3] as { submenu: Array<{ label?: string; checked?: boolean }> }).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;
expect(keymap.map((i) => i.label)).toContain("Mine");
expect(keymap.filter((i) => i.checked).map((i) => i.label)).toEqual(["macOS"]);
});
it("opens a menu instead of Settings directly, and switches the theme", async () => {
let opened = 0;
disposers.push(
registerActions([{ id: "file.settings", title: "Settings…", category: "File", run: () => void opened++ }]),
);
render(<TitleBar />);
fireEvent.click(screen.getByRole("button", { name: "IDE and Project Settings" }));
expect(opened).toBe(0);
expect(await screen.findByText("Settings…")).toBeInTheDocument();
fireEvent.click(screen.getByText("Theme"));
fireEvent.click(await screen.findByText("High Contrast"));
expect(useThemeOverride.getState().theme).toBe("dark-hc");
});
});
+89 -2
Переглянути файл
@@ -1,7 +1,8 @@
import { useMemo, useRef, useState } from "react";
import clsx from "clsx";
import { useActions } from "@/platform/actions";
import { shortcutLabel } from "@/platform/keymap";
import { call, commands, isTauri } from "@/platform/ipc";
import { PRESET_TITLES, shortcutLabel, useKeymap } from "@/platform/keymap";
import { useProject } from "@/platform/project";
import { BarButton, Icon, IconButton, Menu, type MenuItem, T } from "@/ui";
import { useContributions, useWorkbenchSlots, type TitleItemContribution } from "./contributions";
@@ -10,6 +11,7 @@ import { MainMenu } from "./MainMenu";
import { WindowControls } from "./WindowControls";
import { usePlatform } from "@/platform/platformInfo";
import { projectInitials } from "./projectColor";
import { currentThemeId, setTheme, THEME_CHOICES, useThemeOverride } from "./theme";
import styles from "./TitleBar.module.css";
export function orderTitleItems(items: Iterable<TitleItemContribution>) {
@@ -104,6 +106,91 @@ 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);
}
}
/** IDEA's main toolbar gear: Settings, Plugins, quick Theme / Keymap switches, Check for Updates. */
export function gearMenuItems(themeId: string, keymapId: string, customKeymaps: string[]): MenuItem[] {
return [
{ 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" },
],
},
{
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),
})),
{ kind: "separator" },
{ kind: "action", action: "settings.keymap", label: "Configure Keymap…" },
],
},
{ kind: "separator" },
{ kind: "action", action: "app.checkForUpdates" },
];
}
function GearButton() {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLButtonElement>(null);
const settings = useProject((s) => s.settings);
useThemeOverride((s) => s.theme);
useActions((s) => s.actions);
const preset = useKeymap((s) => s.preset);
const customs = settings?.customKeymaps;
const items = gearMenuItems(
currentThemeId(),
typeof settings?.keymap === "string" ? settings.keymap : preset,
customs && typeof customs === "object" ? Object.keys(customs) : [],
);
return (
<>
<IconButton
ref={ref}
icon="settings"
title="IDE and Project Settings"
iconSize={18}
className={styles.tool}
aria-haspopup="menu"
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
/>
<Menu
open={open}
onClose={() => setOpen(false)}
anchor={ref}
items={items}
aria-label="IDE and Project Settings"
/>
</>
);
}
/**
* 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
@@ -166,7 +253,7 @@ export function TitleBar({
iconSize={18}
className={styles.tool}
/>
<IconButton icon="settings" action="file.settings" title="Settings" iconSize={18} className={styles.tool} />
<GearButton />
</div>
</>
)}
+13
Переглянути файл
@@ -9,6 +9,7 @@ import {
BALLOON_TIMEOUT,
clearNotifications,
installNotifyEvents,
STICKY_BALLOON_TIMEOUT,
notify,
useNotifications,
} from "./notificationStore";
@@ -129,6 +130,18 @@ describe("notifications", () => {
expect(useNotifications.getState().items).toHaveLength(2);
});
it("sticky balloons fade into the tool window after 5 minutes, staying unread", () => {
vi.useFakeTimers();
vi.spyOn(document, "hasFocus").mockReturnValue(true);
render(<Balloons />);
act(() => void notify({ level: "error", title: "Sync failed" }));
act(() => vi.advanceTimersByTime(STICKY_BALLOON_TIMEOUT - 1000));
expect(screen.getByText("Sync failed")).toBeInTheDocument();
act(() => vi.advanceTimersByTime(1100));
expect(screen.queryByText("Sync failed")).toBeNull();
expect(useNotifications.getState().items[0]).toMatchObject({ title: "Sync failed", read: false, balloon: false });
});
it("pauses the balloon timeout while the app window is inactive", () => {
vi.useFakeTimers();
const hasFocus = vi.spyOn(document, "hasFocus").mockReturnValue(true);
+2 -1
Переглянути файл
@@ -22,7 +22,7 @@ import {
} from "./Notifications";
import { loadPlatformInfo } from "@/platform/platformInfo";
import { registerShellActions } from "./shellActions";
import { TaskStatus } from "./StatusBar";
import { StatusInfo, TaskStatus } from "./StatusBar";
import { installTaskEvents } from "./tasks";
import { installToolWindowActions } from "./toolwindows/actions";
import {
@@ -61,6 +61,7 @@ export function activateWorkbench(): () => void {
installNotifyEvents(),
installBrowserGuards(),
registerStatusItem({ id: "workbench.tasks", align: "right", priority: 0, component: TaskStatus }),
registerStatusItem({ id: "workbench.info", align: "left", priority: 5, component: StatusInfo }),
registerToolWindow({
id: NOTIFICATIONS_TOOL_WINDOW,
title: "Notifications",
+77 -2
Переглянути файл
@@ -8,7 +8,7 @@
* The workbench renders whatever is registered; the action
* `toolwindow.<id>` toggles it (registered automatically).
*/
import type { ComponentType, ReactNode, RefObject } from "react";
import { useEffect, useRef, type ComponentType, type ReactNode, type RefObject } from "react";
import type { RunConfig } from "@/bindings";
import type { MenuItem } from "@/ui/Menu";
import { create } from "zustand";
@@ -311,6 +311,8 @@ export interface EditorService {
openPaths?(): string[];
/** Closes every tab showing `path`. */
closePath?(path: string): void;
/** Closes the tab of the input with this `key` (e.g. a merge editor once applied). */
closeInput?(key: string): void;
}
export interface ToolWindowService {
@@ -522,8 +524,16 @@ export function projectViewRoots(map: ReadonlyMap<string, ProjectViewRootContrib
// `openIn` is the Open In › submenu those three menus share (Browser › from features/web).
// `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.
export type ContextMenuId = "editor" | "projectView" | "editorTab" | "editorGutter" | "openIn" | "stopProcess";
export type ContextMenuId =
| "editor"
| "projectView"
| "editorTab"
| "editorGutter"
| "openIn"
| "stopProcess"
| "selectIn";
export interface MenuGroupContext {
/** Files / directories the menu is for (absolute paths; a virtual URI for virtual documents). */
@@ -636,3 +646,68 @@ export function registerRunExecutor(executor: RunExecutorContribution): () => vo
export function runExecutors(map = useRunExecutors.getState().executors): RunExecutorContribution[] {
return [...map.values()].sort((a, b) => (a.order ?? 0) - (b.order ?? 0) || a.id.localeCompare(b.id));
}
// ---------------------------------------------------------------------------
// Occurrence navigators: result views (Find, Usages, Problems, TODO…) let
// Next / Previous Occurrence step through their results from anywhere, the
// editor included. The actions ask the focused, else the last active tool
// window's navigator; the latest registration per tool window wins.
/** 1-based position of the occurrence navigated to, among `total`. */
export interface OccurrenceStep {
index: number;
total: number;
}
export interface OccurrenceNavigator {
/**
* Selects the next (1) / previous (-1) occurrence and opens it in the editor,
* focusing the editor only when `focusEditor` (the action ran outside the tool
* window). `null` when there is none in that direction.
*/
go(dir: 1 | -1, focusEditor: boolean): OccurrenceStep | null;
}
const occurrenceNavigators = new Map<string, OccurrenceNavigator[]>();
export function registerOccurrenceNavigator(toolWindow: string, nav: OccurrenceNavigator): () => void {
occurrenceNavigators.set(toolWindow, [...(occurrenceNavigators.get(toolWindow) ?? []), nav]);
return () => {
const rest = (occurrenceNavigators.get(toolWindow) ?? []).filter((n) => n !== nav);
if (rest.length) occurrenceNavigators.set(toolWindow, rest);
else occurrenceNavigators.delete(toolWindow);
};
}
export function occurrenceNavigator(toolWindow: string): OccurrenceNavigator | null {
return occurrenceNavigators.get(toolWindow)?.at(-1) ?? null;
}
/** Registers `nav` (the latest render's) for `toolWindow` while the calling view is mounted. */
export function useOccurrenceNavigator(toolWindow: string, nav: OccurrenceNavigator["go"]) {
const latest = useRef(nav);
useEffect(() => {
latest.current = nav;
});
useEffect(
() => registerOccurrenceNavigator(toolWindow, { go: (dir, focus) => latest.current(dir, focus) }),
[toolWindow],
);
}
// ---------------------------------------------------------------------------
// 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.
export const useStatusInfo = create<{ text: string | null; seq: number }>(() => ({ text: null, seq: 0 }));
let statusInfoTimer: ReturnType<typeof setTimeout> | undefined;
export function showStatusInfo(text: string, ms = 8000) {
clearTimeout(statusInfoTimer);
const seq = useStatusInfo.getState().seq + 1;
useStatusInfo.setState({ text, seq });
statusInfoTimer = setTimeout(() => {
if (useStatusInfo.getState().seq === seq) useStatusInfo.setState({ text: null });
}, ms);
}
+6 -5
Переглянути файл
@@ -79,7 +79,7 @@ describe("distraction free mode", () => {
setDistractionFree(true);
showToolWindow("structure");
expect(savedVisible()).toMatchObject({ left: "project", bottom: "terminal", right: null });
// An app restart (no session) loads the normal layout.
// Leaving the mode (now or after a restart) loads the normal layout.
resetDistractionFree();
resetToolWindowLayout();
loadToolWindowLayout("/work/df");
@@ -95,10 +95,11 @@ describe("distraction free mode", () => {
expect(isToolWindowVisible("project")).toBe(true);
});
it("survives a reload of the window (sessionStorage) and re-applies to a newly loaded project", () => {
it("survives an app restart and re-applies to a newly loaded project", () => {
setDistractionFree(true);
expect(sessionStorage.getItem("openidea.distractionFree")).toBe("1");
// Reload: fresh module and layout state, same session.
expect(localStorage.getItem("openidea.distractionFree")).toBe("1");
// Restart: fresh module, layout and session state; only localStorage remains.
sessionStorage.clear();
useDistractionFree.setState({ active: false });
resetToolWindowLayout();
disposers.push(installDistractionFree());
@@ -107,7 +108,7 @@ describe("distraction free mode", () => {
expect(isToolWindowVisible("project")).toBe(false);
setDistractionFree(false);
expect(isToolWindowVisible("project")).toBe(true);
expect(sessionStorage.getItem("openidea.distractionFree")).toBeNull();
expect(localStorage.getItem("openidea.distractionFree")).toBeNull();
});
it("is unbound in the IntelliJ keymap (⌃⌘D is macOS Look Up) and Escape does not exit", () => {
+15 -13
Переглянути файл
@@ -3,9 +3,11 @@
* stripes, main toolbar contents, status bar, editor tabs, breadcrumbs and
* the editor gutter are hidden and the text column is centred at Settings ›
* Editor › General › Appearance "Distraction free mode text width". The same
* action enters and exits; Esc does not exit. Kept per window across reloads
* (sessionStorage), not across restarts. Published as the `distractionFree`
* context key; components read `useDistractionFree`.
* action enters and exits; Esc does not exit. Like IDEA the mode is an
* application-wide preference that survives restarts (localStorage, next to
* the saved tool window layouts, which keep the layout to restore on exit).
* Published as the `distractionFree` context key; components read
* `useDistractionFree`.
*/
import { create } from "zustand";
import { registerAction, type Action } from "@/platform/actions";
@@ -16,7 +18,7 @@ import { enterDistractionFreeLayout, exitDistractionFreeLayout, useToolWindowLay
export const DISTRACTION_FREE_ACTION = "editor.toggleZen";
export const DISTRACTION_FREE_CONTEXT = "distractionFree";
export const DEFAULT_DISTRACTION_FREE_WIDTH = 80;
const SESSION_KEY = "openidea.distractionFree";
const STORAGE_KEY = "openidea.distractionFree";
export const useDistractionFree = create<{ active: boolean }>(() => ({ active: false }));
@@ -33,18 +35,18 @@ export function distractionFreeTitle(active: boolean): string {
return active ? "Exit Distraction Free Mode" : "Enter Distraction Free Mode";
}
function writeSession(active: boolean) {
function writeStored(active: boolean) {
try {
if (active) sessionStorage.setItem(SESSION_KEY, "1");
else sessionStorage.removeItem(SESSION_KEY);
if (active) localStorage.setItem(STORAGE_KEY, "1");
else localStorage.removeItem(STORAGE_KEY);
} catch {
/* storage unavailable */
}
}
function readSession(): boolean {
function readStored(): boolean {
try {
return sessionStorage.getItem(SESSION_KEY) === "1";
return localStorage.getItem(STORAGE_KEY) === "1";
} catch {
return false;
}
@@ -54,7 +56,7 @@ export function setDistractionFree(active: boolean) {
if (active === isDistractionFree()) return;
useDistractionFree.setState({ active });
setContext(DISTRACTION_FREE_CONTEXT, active);
writeSession(active);
writeStored(active);
if (active) enterDistractionFreeLayout();
else exitDistractionFreeLayout();
}
@@ -75,7 +77,7 @@ function action(active: boolean): Action {
/**
* Registers the action (its title follows the mode, like IDEA's
* Enter / Exit Distraction Free Mode), restores the mode of this window
* Enter / Exit Distraction Free Mode), restores the saved mode
* and re-applies the hidden tool window layout when a project's layout loads.
*/
export function installDistractionFree(): () => void {
@@ -90,7 +92,7 @@ export function installDistractionFree(): () => void {
if (s.scope !== prev.scope && isDistractionFree()) enterDistractionFreeLayout();
});
setContext(DISTRACTION_FREE_CONTEXT, isDistractionFree());
if (readSession()) setDistractionFree(true);
if (readStored()) setDistractionFree(true);
return () => {
unsubMode();
unsubLayout();
@@ -102,5 +104,5 @@ export function installDistractionFree(): () => void {
export function resetDistractionFree() {
useDistractionFree.setState({ active: false });
setContext(DISTRACTION_FREE_CONTEXT, false);
writeSession(false);
writeStored(false);
}
+13 -1
Переглянути файл
@@ -85,8 +85,20 @@ interface NotificationState {
export const useNotifications = create<NotificationState>(() => ({ items: [] }));
/** IDEA fades balloons out after 10 s; errors stay until dismissed (as IDEA's sticky error groups). */
/** IDEA fades balloons out after 10 s; errors are sticky balloons (as IDEA's error groups). */
export const BALLOON_TIMEOUT: Record<NotificationLevel, number | null> = { info: 10000, warning: 10000, error: null };
/**
* Sticky balloons fade too, after 5 minutes of active window time, as in IDEA:
* the notification stays unread in the Notifications tool window. One that the
* tool window does not list stays until dismissed.
*/
export const STICKY_BALLOON_TIMEOUT = 5 * 60 * 1000;
/** Fade-out delay of a balloon, or null when it stays until dismissed. */
export function balloonTimeout(n: Pick<IdeNotification, "level" | "sticky" | "logged">): number | null {
if (n.sticky || BALLOON_TIMEOUT[n.level] === null) return n.logged ? STICKY_BALLOON_TIMEOUT : null;
return BALLOON_TIMEOUT[n.level];
}
const MAX_HISTORY = 200;
let seq = 0;
+21 -7
Переглянути файл
@@ -103,15 +103,25 @@ function syncWindowBackground() {
}
export function currentTheme(): "dark" | "light" {
const override = useThemeOverride.getState().theme;
const id = override
? resolveThemeId({ theme: override }, prefersLight())
: resolveThemeId(useProject.getState().settings?.appearance as AppearanceLike | undefined, prefersLight());
return id === "light" ? "light" : "dark";
return currentThemeId() === "light" ? "light" : "dark";
}
export async function toggleTheme() {
const next: ThemeSetting = currentTheme() === "dark" ? "light" : "dark";
/** Themes offered by quick switch menus, in IDEA's order. */
export const THEME_CHOICES: ReadonlyArray<{ id: "light" | "dark" | "dark-hc"; label: string }> = [
{ id: "light", label: "Islands Light" },
{ id: "dark", label: "Islands Dark" },
{ id: "dark-hc", label: "High Contrast" },
];
/** The concrete theme in effect (`dark-hc` kept distinct). */
export function currentThemeId(): "dark" | "light" | "dark-hc" {
const override = useThemeOverride.getState().theme;
return override
? resolveThemeId({ theme: override }, prefersLight())
: resolveThemeId(useProject.getState().settings?.appearance as AppearanceLike | undefined, prefersLight());
}
export async function setTheme(next: ThemeSetting) {
useThemeOverride.setState({ theme: next });
if (!isTauri()) return;
try {
@@ -124,3 +134,7 @@ export async function toggleTheme() {
/* keep the session override */
}
}
export async function toggleTheme() {
await setTheme(currentTheme() === "dark" ? "light" : "dark");
}
+39 -1
Переглянути файл
@@ -1,5 +1,6 @@
import { registerAction, registerActions } from "@/platform/actions";
import { useContributions } from "../contributions";
import { formatMessage, tx } from "@/platform/i18n";
import { occurrenceNavigator, showStatusInfo, useContributions } from "../contributions";
import { focusEditor } from "../focus";
import { closeSelectedContent } from "./content";
import {
@@ -8,10 +9,29 @@ import {
hideAllToolWindows,
hideToolWindow,
jumpToLastToolWindow,
lastUsedToolWindow,
useToolWindowLayout,
toggleMaximizeToolWindow,
} from "./store";
/**
* Next / Previous Occurrence: steps through the results of the focused tool
* window, else of the last active one, else of a shown one, so it also works
* from the editor. The editor gets the focus only when the action ran outside
* the tool window.
*/
export function goToOccurrence(dir: 1 | -1): boolean {
const s = useToolWindowLayout.getState();
for (const id of new Set([s.focused, s.lastActive, lastUsedToolWindow(), ...Object.values(s.visible)])) {
const nav = id ? occurrenceNavigator(id) : null;
if (!nav) continue;
const step = nav.go(dir, s.focused !== id);
if (step) showStatusInfo(formatMessage(tx("{index} of {total}"), { index: step.index, total: step.total }));
return !!step;
}
return false;
}
/** 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 }>();
@@ -80,6 +100,24 @@ export function installToolWindowActions(): () => void {
when: "toolWindowFocus && !popupOpen",
run: () => focusEditor(),
},
{
id: "navigate.nextOccurrence",
title: "Next Occurrence",
category: "Navigate",
icon: "arrow-down",
keywords: ["usage", "result", "find"],
when: "projectOpen",
run: () => void goToOccurrence(1),
},
{
id: "navigate.prevOccurrence",
title: "Previous Occurrence",
category: "Navigate",
icon: "arrow-up",
keywords: ["usage", "result", "find"],
when: "projectOpen",
run: () => void goToOccurrence(-1),
},
{
id: "toolwindow.maximize",
title: "Maximize Tool Window",
+54
Переглянути файл
@@ -0,0 +1,54 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { registerOccurrenceNavigator, registerToolWindow, useStatusInfo } from "../contributions";
import { goToOccurrence } from "./actions";
import { resetToolWindowLayout, setFocusedToolWindow, showToolWindow } from "./store";
const Empty = () => null;
let disposers: Array<() => void> = [];
beforeEach(() => {
localStorage.clear();
resetToolWindowLayout();
disposers = [
registerToolWindow({ id: "project", title: "Project", icon: "folder", anchor: "left", component: Empty }),
registerToolWindow({ id: "find", title: "Find", icon: "search", anchor: "bottom", component: Empty }),
];
});
afterEach(() => {
disposers.reverse().forEach((d) => d());
useStatusInfo.setState({ text: null });
});
describe("Next / Previous Occurrence", () => {
it("steps the focused tool window's results, keeping the focus there", () => {
const go = vi.fn(() => ({ index: 2, total: 5 }));
disposers.push(registerOccurrenceNavigator("find", { go }));
showToolWindow("find");
setFocusedToolWindow("find");
expect(goToOccurrence(1)).toBe(true);
expect(go).toHaveBeenCalledWith(1, false);
expect(useStatusInfo.getState().text).toBe("2 of 5");
});
it("from elsewhere (the editor) uses the last active tool window and focuses the editor", () => {
const go = vi.fn(() => ({ index: 1, total: 1 }));
disposers.push(registerOccurrenceNavigator("find", { go }));
showToolWindow("find");
setFocusedToolWindow("project");
setFocusedToolWindow(null);
expect(goToOccurrence(-1)).toBe(true);
expect(go).toHaveBeenCalledWith(-1, true);
});
it("does nothing without a navigator or past the last occurrence", () => {
showToolWindow("project");
expect(goToOccurrence(1)).toBe(false);
const nav = { go: vi.fn(() => null) };
const dispose = registerOccurrenceNavigator("project", nav);
expect(goToOccurrence(1)).toBe(false);
expect(nav.go).toHaveBeenCalled();
expect(useStatusInfo.getState().text).toBeNull();
dispose();
});
});