Files
openidea/src/platform/actions.ts
T
l0ng-ai 1e00ea5ee2 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
2026-10-25 21:24:00 +08:00

187 lines
6.3 KiB
TypeScript

/**
* The action system ("everything is an action"), modelled on IDEA's ActionManager.
*
* Menus, keybindings, toolbar buttons, context menus and the Actions tab of
* Search Everywhere all resolve to the same registry, so a feature registers
* its behaviour once:
*
* registerAction({ id: "editor.formatDocument", title: "Reformat Code",
* category: "Code", when: "editorFocus", run: () => … });
*
* Default keybindings live in keymaps (`keymap.ts`), not on the action.
*
* Titles are written in English; `action.<id>`, `action.<id>.description` and
* `action.category.<category>` catalog keys, else the source-text catalog
* (`tx`), translate them, and the registry re-localises on locale change. The
* English title stays searchable.
*/
import { create } from "zustand";
import { evaluateWhen, getContext, type ContextKeys } from "./context";
import { formatMessage, lookup, tx, useI18n } from "./i18n";
export interface ActionContext {
ctx: ContextKeys;
/** Optional argument from the invoker (e.g. a menu passing a file path). */
arg?: unknown;
}
export interface Action {
/** Dotted, stable id, e.g. `navigate.searchEverywhere`. */
id: string;
title: string;
/** Group shown in Search Everywhere and the keymap editor, e.g. `Navigate`. */
category?: string;
description?: string;
/** Icon name from `ui/icons`. */
icon?: string;
/** Enablement; evaluated against context keys. */
when?: string;
/**
* Extra condition for keyboard shortcuts only (menus and Find Action ignore
* it), e.g. to yield a shared shortcut to an editor action while typing.
*/
keyWhen?: string;
/** Extra search terms for the Actions tab. */
keywords?: string[];
/** Hidden from Search Everywhere (internal / parameterised actions). */
hidden?: boolean;
/**
* Marks a toggle action (IDEA's ToggleAction): returns the current state.
* Search Everywhere shows an ON/OFF switch and keeps the popup open.
*/
toggled?: () => boolean;
run: (c: ActionContext) => void | Promise<void>;
}
interface ActionStore {
actions: Map<string, Action>;
}
export const useActions = create<ActionStore>(() => ({ actions: new Map() }));
/** Registered actions as written (English), by id; the store holds their localised copies. */
const sources = new Map<string, Action>();
function translated(key: string): string | undefined {
const message = lookup(key);
return message === undefined ? undefined : formatMessage(message, {}, useI18n.getState().locale);
}
/** Catalog key, else the source-text catalog; `undefined` when untranslated. */
function localized(key: string, text: string | undefined): string | undefined {
if (!text) return undefined;
const v = translated(key) ?? tx(text);
return v === text ? undefined : v;
}
function localize(action: Action): Action {
const title = localized(`action.${action.id}`, action.title);
const description = localized(`action.${action.id}.description`, action.description);
const category = localized(`action.category.${action.category}`, action.category);
if (!title && !description && !category) return action;
return {
...action,
title: title ?? action.title,
description: description ?? action.description,
category: category ?? action.category,
keywords: title ? [...(action.keywords ?? []), action.title] : action.keywords,
};
}
useI18n.subscribe((s, prev) => {
if ((s.messages === prev.messages && s.texts === prev.texts) || !sources.size) return;
useActions.setState({ actions: new Map([...sources].map(([id, a]) => [id, localize(a)])) });
});
/** Registers an action; returns a disposer. Re-registering an id replaces it. */
export function registerAction(action: Action): () => void {
sources.set(action.id, action);
useActions.setState((s) => ({ actions: new Map(s.actions).set(action.id, localize(action)) }));
return () => {
if (sources.get(action.id) !== action) return;
sources.delete(action.id);
useActions.setState((s) => {
const actions = new Map(s.actions);
actions.delete(action.id);
return { actions };
});
};
}
export function registerActions(actions: Action[]): () => void {
const disposers = actions.map(registerAction);
return () => disposers.forEach((d) => d());
}
export function getAction(id: string): Action | undefined {
return useActions.getState().actions.get(id);
}
export function allActions(): Action[] {
return [...useActions.getState().actions.values()];
}
export function isActionEnabled(action: Action, ctx: ContextKeys = getContext()): boolean {
return evaluateWhen(action.when, ctx);
}
/** Whether a keyboard shortcut may run `action` (`when` and `keyWhen`). */
export function isActionKeyEnabled(action: Action, ctx: ContextKeys = getContext()): boolean {
return isActionEnabled(action, ctx) && evaluateWhen(action.keyWhen, ctx);
}
/** Current state of a toggle action, or `undefined` for plain actions. */
export function actionToggled(action: Action | undefined): boolean | undefined {
if (!action?.toggled) return undefined;
try {
return action.toggled();
} catch {
return false;
}
}
type ActionListener = (id: string, arg: unknown) => void;
const listeners = new Set<ActionListener>();
/** Actions running synchronously inside another action's `run`. */
let nesting = 0;
/**
* Called before each action the user runs (keyboard, menus, Find Action), not
* for the actions an action runs itself. Macro recording listens here.
*/
export function onActionExecuted(listener: ActionListener): () => void {
listeners.add(listener);
return () => void listeners.delete(listener);
}
/** Runs an action if registered and enabled. Returns whether it ran. */
export async function executeAction(id: string, arg?: unknown): Promise<boolean> {
const action = getAction(id);
if (!action) {
console.warn(`unknown action: ${id}`);
return false;
}
const ctx = getContext();
if (!isActionEnabled(action, ctx)) return false;
if (nesting === 0)
for (const l of listeners)
try {
l(id, arg);
} catch (e) {
console.error("action listener failed", e);
}
try {
let pending: void | Promise<void>;
nesting++;
try {
pending = action.run({ ctx, arg });
} finally {
nesting--;
}
await pending;
} catch (e) {
console.error(`action ${id} failed`, e);
}
return true;
}