feat: Third pass aligning core experience with IntelliJ IDEA

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

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

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

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

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

No files matched your search

+29 -1
Переглянути файл
@@ -140,6 +140,20 @@ export function actionToggled(action: Action | undefined): boolean | undefined {
}
}
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);
@@ -149,8 +163,22 @@ export async function executeAction(id: string, arg?: unknown): Promise<boolean>
}
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 {
await action.run({ ctx, arg });
let pending: void | Promise<void>;
nesting++;
try {
pending = action.run({ ctx, arg });
} finally {
nesting--;
}
await pending;
} catch (e) {
console.error(`action ${id} failed`, e);
}
+42 -5
Переглянути файл
@@ -86,6 +86,9 @@ export const EDITOR_EXTRA_DEFAULTS = {
caseSensitivity: "first",
autoPopup: true,
insertOnSpecialKeys: false,
autoInsertSingle: true,
sortAlphabetically: false,
insertParentheses: true,
showDocumentation: false,
documentationDelay: 500,
parameterInfo: true,
@@ -96,9 +99,16 @@ export const EDITOR_EXTRA_DEFAULTS = {
tabs: {
placement: "top",
multipleRows: false,
oneRowOverflow: "scroll",
pinnedSeparateRow: false,
showFileIcon: true,
showExtension: true,
showDirectoryForNonUnique: true,
markModified: false,
showFullPathOnHover: true,
showCloseButton: true,
closeButtonPosition: "right",
sortAlphabetically: false,
previewTab: true,
openAtEnd: false,
tabLimit: 30,
@@ -125,7 +135,8 @@ export const EDITOR_EXTRA_DEFAULTS = {
nextErrorErrorsOnly: true,
stickyLinesMax: 5,
},
inlay: { types: true, parameters: true, other: true, disabledLanguages: [] as string[], codeVisionUsages: true },
inlay: { types: true, parameters: true, other: true, disabledLanguages: [] as string[], codeVisionUsages: true, codeVisionAuthor: true },
inlineCompletion: { enabled: true, onlyOnPause: false, pauseMs: 500, disabledLanguages: [] as string[] },
gutterIconsDisabled: [] as string[],
showGutterIcons: true,
codeStyle: {
@@ -135,6 +146,13 @@ export const EDITOR_EXTRA_DEFAULTS = {
detectIndents: true,
editorconfig: true,
languages: {} as Record<string, LanguageCodeStyle>,
/** Formatter › Do not format: globs Reformat Code skips. */
doNotFormat: [] as string[],
/** Formatter › Turn formatter on/off with markers in code comments. */
formatterTagsEnabled: false,
formatterOffTag: "@formatter:off",
formatterOnTag: "@formatter:on",
formatterTagsRegexp: false,
},
encodings: { global: "utf-8", project: "utf-8", properties: "iso-8859-1", utf8Bom: "never" },
};
@@ -171,14 +189,33 @@ export function validateEditorExtras(raw: unknown): EditorExtras {
return coerceLike(raw, EDITOR_EXTRA_DEFAULTS);
}
let cache: { raw: unknown; value: EditorExtras } | null = null;
let cache: { raw: unknown; powerSave: boolean; value: EditorExtras } | null = null;
/** File › Power Save Mode (`general.powerSaveMode`). */
export function isPowerSaveMode(settings: unknown = useProject.getState().settings): boolean {
const general = settings && typeof settings === "object" ? (settings as Obj).general : undefined;
return isObj(general) && general.powerSaveMode === true;
}
/** Power Save Mode turns off the editor's automatic popups and background highlighting. */
export function powerSaveExtras(p: EditorExtras): EditorExtras {
return {
...p,
completion: { ...p.completion, autoPopup: false, parameterInfo: false, showDocumentation: false },
codeEditing: { ...p.codeEditing, quickDocOnHover: false, highlightUsages: false },
inlay: { ...p.inlay, codeVisionUsages: false },
inlineCompletion: { ...p.inlineCompletion, enabled: false },
};
}
/** Effective Editor-group settings (validated, memoised per settings object). */
export function editorPrefs(settings: unknown = useProject.getState().settings): EditorExtras {
const raw = settings && typeof settings === "object" ? (settings as Obj).editor : undefined;
if (cache && cache.raw === raw) return cache.value;
const value = validateEditorExtras(raw);
cache = { raw, value };
const powerSave = isPowerSaveMode(settings);
if (cache && cache.raw === raw && cache.powerSave === powerSave) return cache.value;
const valid = validateEditorExtras(raw);
const value = powerSave ? powerSaveExtras(valid) : valid;
cache = { raw, powerSave, value };
return value;
}
+165
Переглянути файл
@@ -0,0 +1,165 @@
/**
* Settings › Editor › File Types: the user's file name / hashbang patterns per
* language (`files.fileTypes`) and per-file Override File Type
* (`files.fileTypeOverrides`, project scope). The backend registry
* (`ide-language`) detects languages with them; this module holds the shared
* model and mirrors the name matching for the UI (file icons).
*/
import type { LanguageInfo_Serialize as LanguageInfo } from "./ipc";
export interface FileTypeAssociation {
/** Added file name patterns (`*.foo`, `Jenkinsfile`, `Dockerfile.*`). */
patterns: string[];
/** Patterns of the language definition the user removed. */
removedPatterns: string[];
/** Interpreters run by a `#!` first line (`node`). */
hashbangs: string[];
}
export type FileTypeAssociations = Record<string, FileTypeAssociation>;
const strings = (v: unknown): string[] =>
Array.isArray(v)
? [
...new Set(
v
.filter((x): x is string => typeof x === "string")
.map((x) => x.trim())
.filter(Boolean),
),
]
: [];
const isObj = (v: unknown): v is Record<string, unknown> => !!v && typeof v === "object" && !Array.isArray(v);
export function validateFileTypes(raw: unknown): FileTypeAssociations {
const out: FileTypeAssociations = {};
if (!isObj(raw)) return out;
for (const [id, a] of Object.entries(raw)) {
if (!id.trim() || !isObj(a)) continue;
out[id] = {
patterns: strings(a.patterns),
removedPatterns: strings(a.removedPatterns),
hashbangs: strings(a.hashbangs),
};
}
return out;
}
export function validateFileTypeOverrides(raw: unknown): Record<string, string> {
const out: Record<string, string> = {};
if (!isObj(raw)) return out;
for (const [path, lang] of Object.entries(raw))
if (path.trim() && typeof lang === "string" && lang.trim()) out[path] = lang.trim();
return out;
}
/** Built-in file name patterns of a language definition: `*.ext` and file names. */
export function builtinPatterns(lang: Pick<LanguageInfo, "extensions" | "filenames">): string[] {
return [...lang.extensions.map((e) => `*.${e}`), ...(lang.filenames ?? [])];
}
const EMPTY: FileTypeAssociation = { patterns: [], removedPatterns: [], hashbangs: [] };
/** Effective file name patterns of a language: definition minus removed, plus added. */
export function effectivePatterns(
lang: Pick<LanguageInfo, "id" | "extensions" | "filenames">,
assoc: FileTypeAssociations,
): Array<{ pattern: string; builtin: boolean }> {
const own = assoc[lang.id] ?? EMPTY;
const removed = new Set(own.removedPatterns.map((p) => p.toLowerCase()));
const out = builtinPatterns(lang)
.filter((p) => !removed.has(p.toLowerCase()))
.map((pattern) => ({ pattern, builtin: true }));
for (const pattern of own.patterns)
if (!out.some((o) => o.pattern === pattern)) out.push({ pattern, builtin: false });
return out;
}
function withAssoc(assoc: FileTypeAssociations, id: string, f: (a: FileTypeAssociation) => FileTypeAssociation) {
const next = { ...assoc, [id]: f(assoc[id] ?? EMPTY) };
const a = next[id]!;
if (!a.patterns.length && !a.removedPatterns.length && !a.hashbangs.length) delete next[id];
return next;
}
/**
* Adds `pattern` to `id`. As in IDEA a pattern belongs to one file type: it is
* taken away from any other language that has it (built-in or added).
*/
export function addPattern(
assoc: FileTypeAssociations,
langs: ReadonlyArray<Pick<LanguageInfo, "id" | "extensions" | "filenames">>,
id: string,
pattern: string,
): FileTypeAssociations {
const p = pattern.trim();
if (!p) return assoc;
let next = assoc;
for (const l of langs) {
if (l.id === id || !effectivePatterns(l, next).some((e) => e.pattern === p)) continue;
next = removePattern(next, l, p);
}
const lang = langs.find((l) => l.id === id);
return withAssoc(next, id, (a) => {
// Re-adding a removed built-in pattern restores it.
if (lang && builtinPatterns(lang).includes(p))
return { ...a, removedPatterns: a.removedPatterns.filter((r) => r !== p) };
return a.patterns.includes(p) ? a : { ...a, patterns: [...a.patterns, p] };
});
}
export function removePattern(
assoc: FileTypeAssociations,
lang: Pick<LanguageInfo, "id" | "extensions" | "filenames">,
pattern: string,
): FileTypeAssociations {
const builtin = builtinPatterns(lang).includes(pattern);
return withAssoc(assoc, lang.id, (a) => ({
...a,
patterns: a.patterns.filter((p) => p !== pattern),
removedPatterns:
builtin && !a.removedPatterns.includes(pattern) ? [...a.removedPatterns, pattern] : a.removedPatterns,
}));
}
export function setHashbangs(assoc: FileTypeAssociations, id: string, hashbangs: string[]): FileTypeAssociations {
return withAssoc(assoc, id, (a) => ({ ...a, hashbangs: strings(hashbangs) }));
}
/** `*` matches any run of characters, `?` one (as `ide-language`'s file name patterns). */
export function wildcardMatch(pattern: string, name: string): boolean {
const re = new RegExp(
`^${pattern
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
.replace(/\*/g, ".*")
.replace(/\?/g, ".")}$`,
"s",
);
return re.test(name);
}
/** Language a user-added pattern assigns to `fileName` (null: none applies). */
export function userFileType(assoc: FileTypeAssociations, fileName: string): string | null {
const lower = fileName.toLowerCase();
let glob: { id: string; len: number } | null = null;
let ext: { id: string; len: number } | null = null;
for (const [id, a] of Object.entries(assoc)) {
for (const p of a.patterns) {
const simpleExt = /^\*\.[^*?]+$/.test(p);
if (!/[*?]/.test(p)) {
if (p === fileName) return id;
} else if (simpleExt) {
const e = p.slice(1).toLowerCase();
if (lower.endsWith(e) && lower.length > e.length && (!ext || e.length > ext.len)) ext = { id, len: e.length };
} else if (wildcardMatch(p, fileName) && (!glob || p.length > glob.len)) glob = { id, len: p.length };
}
}
return glob?.id ?? ext?.id ?? null;
}
/** File name standing for a language when picking its icon (`x.py`). */
export function sampleFileName(lang: Pick<LanguageInfo, "extensions" | "filenames">): string | null {
if (lang.extensions[0]) return `file.${lang.extensions[0]}`;
return lang.filenames?.find((n) => !/[*?]/.test(n)) ?? null;
}
+222
Переглянути файл
@@ -0,0 +1,222 @@
/**
* Code Style › Formatter: "Do not format" globs and formatter on/off markers
* in code comments (`@formatter:off` … `@formatter:on`). Reformat Code, format
* on save and the before-commit reformat skip excluded files and leave the
* text between markers untouched.
*/
import { editorPrefs } from "./editorSettings";
export interface FormatGuardOptions {
doNotFormat: readonly string[];
formatterTagsEnabled: boolean;
formatterOffTag: string;
formatterOnTag: string;
formatterTagsRegexp: boolean;
}
export interface TextChangeLike {
from: number;
to: number;
insert: string;
}
/** The Formatter options of the effective settings. */
export function formatGuardOptions(settings?: unknown): FormatGuardOptions {
const cs = editorPrefs(settings).codeStyle;
return {
doNotFormat: cs.doNotFormat,
formatterTagsEnabled: cs.formatterTagsEnabled,
formatterOffTag: cs.formatterOffTag,
formatterOnTag: cs.formatterOnTag,
formatterTagsRegexp: cs.formatterTagsRegexp,
};
}
function globRegExp(glob: string): RegExp {
let re = "";
for (let i = 0; i < glob.length; i++) {
const c = glob[i]!;
if (c === "*" && glob[i + 1] === "*") {
re += ".*";
i++;
if (glob[i + 1] === "/") i++;
} else if (c === "*") re += "[^/]*";
else if (c === "?") re += "[^/]";
else re += c.replace(/[.+^${}()|[\]\\]/g, "\\$&");
}
return new RegExp(`^${re}$`);
}
/**
* Whether "Do not format" excludes `path`. A pattern without `/` matches the
* file name or any folder name (`*.min.js`, `generated`); one with `/` matches
* the project-relative path or a folder containing it (`src/gen/**`, `vendor/`).
*/
export function formatExcluded(path: string, root: string | null, globs: readonly string[]): boolean {
const rel = root && path.startsWith(root + "/") ? path.slice(root.length + 1) : path;
const segments = rel.split("/").filter(Boolean);
for (const raw of globs) {
const glob = raw.trim().replace(/^\.\//, "");
if (!glob) continue;
if (!glob.includes("/")) {
const re = globRegExp(glob);
if (segments.some((s) => re.test(s))) return true;
continue;
}
const re = globRegExp(glob.replace(/^\/+/, "").replace(/\/+$/, ""));
for (let i = segments.length; i > 0; i--) if (re.test(segments.slice(0, i).join("/"))) return true;
}
return false;
}
/** A comment opener before the marker on its line (`//`, `#`, `/*`, `<!--`, `--`, `;`, `*`, `%`). */
const COMMENT_BEFORE = /(\/\/|#|\/\*|<!--|--|;|\*|%|\{-|\(\*|')/;
function markerMatcher(tag: string, regexp: boolean): RegExp | null {
if (!tag.trim()) return null;
try {
return new RegExp(regexp ? tag : tag.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g");
} catch {
return null;
}
}
/** Marker hits in comments: offsets of their lines (`start`..`end`, end excludes the newline). */
function markerLines(text: string, re: RegExp): Array<{ start: number; end: number }> {
const out: Array<{ start: number; end: number }> = [];
re.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = re.exec(text))) {
if (m[0] === "") {
re.lastIndex++;
continue;
}
const start = text.lastIndexOf("\n", m.index - 1) + 1;
const nl = text.indexOf("\n", m.index + m[0].length);
const end = nl < 0 ? text.length : nl;
if (COMMENT_BEFORE.test(text.slice(start, m.index)) && out.at(-1)?.start !== start) out.push({ start, end });
re.lastIndex = Math.max(re.lastIndex, end);
}
return out;
}
/**
* Text the formatter must not touch: from the line of each off marker to the
* end of the line of the next on marker (or the end of the text).
*/
export function protectedRegions(text: string, opts: FormatGuardOptions): Array<{ from: number; to: number }> {
if (!opts.formatterTagsEnabled) return [];
const off = markerMatcher(opts.formatterOffTag, opts.formatterTagsRegexp);
const on = markerMatcher(opts.formatterOnTag, opts.formatterTagsRegexp);
if (!off) return [];
const offs = markerLines(text, off);
const ons = on ? markerLines(text, on) : [];
const out: Array<{ from: number; to: number }> = [];
let pos = 0;
for (const o of offs) {
if (o.start < pos) continue;
const end = ons.find((x) => x.start > o.start);
out.push({ from: o.start, to: end ? end.end : text.length });
pos = end ? end.end : text.length;
if (!end) break;
}
return out;
}
const touches = (c: { from: number; to: number }, r: { from: number; to: number }) =>
c.from === c.to ? c.from > r.from && c.from < r.to : c.from < r.to && c.to > r.from;
/** The change minus its common prefix / suffix with the replaced text. */
function narrow(text: string, c: TextChangeLike): TextChangeLike | null {
const old = text.slice(c.from, c.to);
if (old === c.insert) return null;
const max = Math.min(old.length, c.insert.length);
let s = 0;
while (s < max && old.charCodeAt(s) === c.insert.charCodeAt(s)) s++;
let eo = old.length;
let ei = c.insert.length;
while (eo > s && ei > s && old.charCodeAt(eo - 1) === c.insert.charCodeAt(ei - 1)) {
eo--;
ei--;
}
return { from: c.from + s, to: c.from + eo, insert: c.insert.slice(s, ei) };
}
/**
* Drops formatting changes inside protected regions. A change replacing whole
* regions (a formatter rewriting the file) is split around them, provided the
* formatted text keeps the same markers; other overlapping changes are dropped.
*/
export function guardFormatChanges<T extends TextChangeLike>(
text: string,
changes: readonly T[],
opts: FormatGuardOptions,
): TextChangeLike[] {
const regions = protectedRegions(text, opts);
if (!regions.length) return [...changes];
const out: TextChangeLike[] = [];
for (const raw of changes) {
const c = narrow(text, raw);
if (!c) continue;
const hit = regions.filter((r) => touches(c, r));
if (!hit.length) {
out.push(c);
continue;
}
// Whole regions inside the change: map them onto the formatted text.
if (hit.some((r) => r.from < c.from || r.to > c.to)) continue;
const local = protectedRegions(c.insert, opts);
if (local.length !== hit.length) continue;
let oldPos = c.from;
let newPos = 0;
for (let i = 0; i <= hit.length; i++) {
const oldEnd = i < hit.length ? hit[i]!.from : c.to;
const newEnd = i < hit.length ? local[i]!.from : c.insert.length;
const piece = narrow(text, { from: oldPos, to: oldEnd, insert: c.insert.slice(newPos, newEnd) });
if (piece) out.push(piece);
if (i < hit.length) {
oldPos = hit[i]!.to;
newPos = local[i]!.to;
}
}
}
return out;
}
interface PosLike {
line: number;
col: number;
}
/** [`guardFormatChanges`] for LSP-style edits (0-based lines, UTF-16 columns) against `text`. */
export function guardTextEdits(
text: string,
edits: ReadonlyArray<{ range: { start: PosLike; end: PosLike }; new_text: string }>,
opts: FormatGuardOptions,
): Array<{ range: { start: PosLike; end: PosLike }; new_text: string }> {
if (!protectedRegions(text, opts).length) return [...edits];
const starts = [0];
for (let i = text.indexOf("\n"); i >= 0; i = text.indexOf("\n", i + 1)) starts.push(i + 1);
const offset = (p: PosLike) => {
const line = Math.min(Math.max(0, p.line), starts.length - 1);
const end = line + 1 < starts.length ? starts[line + 1]! - 1 : text.length;
return Math.min(starts[line]! + p.col, end);
};
const pos = (o: number): PosLike => {
let lo = 0;
let hi = starts.length - 1;
while (lo < hi) {
const mid = (lo + hi + 1) >> 1;
if (starts[mid]! <= o) lo = mid;
else hi = mid - 1;
}
return { line: lo, col: o - starts[lo]! };
};
const changes = edits
.map((e) => ({ from: offset(e.range.start), to: offset(e.range.end), insert: e.new_text }))
.sort((a, b) => a.from - b.from || a.to - b.to);
return guardFormatChanges(text, changes, opts).map((c) => ({
range: { start: pos(c.from), end: pos(c.to) },
new_text: c.insert,
}));
}
+144
Переглянути файл
@@ -0,0 +1,144 @@
import { afterEach, describe, expect, it } from "vitest";
import type { Diagnostic } from "@/bindings";
import {
activeProfileId,
applyProfile,
applyProfileMixed,
copyProfileName,
effectiveProfileId,
isTypoStyled,
listProfiles,
newProfileKey,
overridesOf,
profileLabel,
validateProfileName,
inspectionProfile,
producerInspectionId,
subscribeInspectionProfile,
validateInspections,
} from "./inspections";
import { useProject } from "./project";
const diag = (code: string | null, severity: Diagnostic["severity"] = "warning", server?: string): Diagnostic => ({
range: { start: { line: 0, col: 0 }, end: { line: 0, col: 1 } },
severity,
message: `m ${code}`,
source: "Ruff",
code,
server,
});
describe("inspection profiles", () => {
afterEach(() => useProject.setState({ project: null, settings: null }));
it("validates leniently", () => {
expect(
validateInspections({
profile: "project",
default: { "lsp:ruff": { enabled: false, severity: "loud" }, empty: {}, x: { severity: "typo" } },
project: 3,
}),
).toEqual({
profile: "project",
default: { "lsp:ruff": { enabled: false }, x: { severity: "typo" } },
project: {},
ideProfiles: {},
projectProfiles: {},
});
expect(validateInspections(undefined).profile).toBe("default");
expect(effectiveProfileId(validateInspections({ profile: "project" }), false)).toBe("default");
});
it("maps built-in producers to their inspections", () => {
expect(producerInspectionId("file-references")).toBe("builtin:file-references");
expect(producerInspectionId("ruff")).toBe("lsp:ruff");
});
it("drops disabled inspections and remaps severities", () => {
const items = [diag("F401"), diag("E501", "error"), diag(null, "info")];
expect(applyProfile({}, "ruff", items)).toBe(items);
expect(applyProfile({ "lsp:ruff": { enabled: false } }, "ruff", items)).toEqual([]);
const out = applyProfile(
{
"lsp:ruff": { severity: "error" },
"lsp:ruff:F401": { severity: "weakWarning" },
"lsp:ruff:E501": { enabled: false },
},
"ruff",
items,
);
expect(out.map((d) => [d.code, d.severity])).toEqual([
["F401", "info"],
[null, "error"],
]);
expect(applyProfile({ "lsp:ruff": { severity: "none" } }, "ruff", [diag("x")])[0]!.severity).toBe("hint");
});
it("groups mixed results by reporting server", () => {
const out = applyProfileMixed({ "lsp:ruff": { enabled: false } }, [
diag("a", "warning", "ruff"),
diag("b", "warning", "ty"),
]);
expect(out.map((d) => d.code)).toEqual(["b"]);
});
it("follows the project's choice of profile and notifies on content changes", () => {
const calls: number[] = [];
const off = subscribeInspectionProfile(() => calls.push(1));
const settings = {
inspections: { profile: "project", default: { a: { enabled: false } }, project: { b: { enabled: false } } },
};
useProject.setState({ settings: settings as never });
expect(activeProfileId()).toBe("default");
expect(inspectionProfile()).toEqual({ a: { enabled: false } });
useProject.setState({ project: { root: "/p" } as never });
expect(activeProfileId()).toBe("project");
expect(inspectionProfile()).toEqual({ b: { enabled: false } });
useProject.setState({ settings: JSON.parse(JSON.stringify(settings)) });
off();
expect(calls.length).toBe(2);
});
it("gives the Typo inspection its default level and marks typo-level diagnostics", () => {
const typo = { ...diag(null, "info"), source: "Spelling" };
const [shown] = applyProfile({}, "spelling", [typo]);
expect(shown!.severity).toBe("info");
expect(isTypoStyled(shown!)).toBe(true);
const [asError] = applyProfile({ "builtin:spelling": { severity: "error" } }, "spelling", [typo]);
expect(asError!.severity).toBe("error");
expect(isTypoStyled(asError!)).toBe(false);
expect(applyProfile({ "builtin:spelling": { enabled: false } }, "spelling", [typo])).toEqual([]);
// Any inspection can be shown at the Typo level.
expect(isTypoStyled(applyProfile({ "lsp:ruff": { severity: "typo" } }, "ruff", [diag("x")])[0]!)).toBe(true);
});
it("lists, labels and resolves copied profiles", () => {
const s = validateInspections({
profile: "ide:strict",
default: { a: { enabled: false } },
ideProfiles: { strict: { name: "Strict", overrides: { b: { severity: "error" } } }, bad: 3 },
projectProfiles: { team: { name: "Team", overrides: {} } },
});
expect(s.ideProfiles.bad).toEqual({ name: "bad", overrides: {} });
expect(listProfiles(s, true).map((p) => [p.id, p.label, p.level])).toEqual([
["project", "Project Default", "project"],
["project:team", "Team", "project"],
["default", "Default", "ide"],
["ide:bad", "bad", "ide"],
["ide:strict", "Strict", "ide"],
]);
expect(listProfiles(s, false).map((p) => p.id)).toEqual(["default", "ide:bad", "ide:strict"]);
expect(effectiveProfileId(s, false)).toBe("ide:strict");
expect(effectiveProfileId({ ...s, profile: "project:team" }, false)).toBe("default");
expect(effectiveProfileId({ ...s, profile: "ide:gone" }, true)).toBe("default");
expect(overridesOf(s, "ide:strict")).toEqual({ b: { severity: "error" } });
expect(profileLabel(s, "project")).toBe("Project Default");
expect(validateProfileName("Strict", "ide", s)).toMatch(/already exists/);
expect(validateProfileName("Strict", "project", s)).toBeNull();
expect(validateProfileName("Strict", "ide", s, "ide:strict")).toBeNull();
expect(validateProfileName(" ", "ide", s)).toMatch(/empty/);
expect(copyProfileName("Strict", "ide", s)).toBe("Strict copy");
expect(newProfileKey("Strict copy!", s.ideProfiles)).toBe("strict-copy");
expect(newProfileKey("Strict", s.ideProfiles)).toBe("strict-2");
});
});
+436
Переглянути файл
@@ -0,0 +1,436 @@
/**
* Inspection profiles (Settings › Editor › Inspections). Every diagnostic
* producer is an inspection: a language server (`lsp:<server>`), optionally
* refined per diagnostic code (`lsp:<server>:<code>`), or a built-in check.
* The profile in effect disables inspections and remaps their severity; the
* editor, Problems view and Inspect Code all filter through `applyProfile`.
* Besides `Default` (IDE) and `Project Default`, profiles made with Copy live
* at either level (`ide:<key>` / `project:<key>`).
* Mirrors `ide-core::inspection_settings`.
*/
import type { Diagnostic, Severity } from "@/bindings";
import { useProject } from "./project";
export type InspectionLevel = "error" | "warning" | "weakWarning" | "serverProblem" | "typo" | "none";
/** `default`, `project`, `ide:<key>` or `project:<key>`. */
export type ProfileId = string;
export type ProfileLevel = "ide" | "project";
export interface InspectionOverride {
enabled?: boolean;
severity?: InspectionLevel;
}
export type ProfileOverrides = Readonly<Record<string, InspectionOverride>>;
export interface CustomProfile {
name: string;
overrides: ProfileOverrides;
}
export type CustomProfiles = Readonly<Record<string, CustomProfile>>;
export interface InspectionSettingsValid {
profile: ProfileId;
default: ProfileOverrides;
project: ProfileOverrides;
ideProfiles: CustomProfiles;
projectProfiles: CustomProfiles;
}
export const DEFAULT_INSPECTIONS: InspectionSettingsValid = {
profile: "default",
default: {},
project: {},
ideProfiles: {},
projectProfiles: {},
};
/** IDEA's highlighting levels and the editor severity each one is shown with. */
export const INSPECTION_LEVELS: readonly { value: InspectionLevel; label: string; severity: Severity }[] = [
{ value: "error", label: "Error", severity: "error" },
{ value: "warning", label: "Warning", severity: "warning" },
{ value: "weakWarning", label: "Weak Warning", severity: "info" },
{ value: "serverProblem", label: "Server Problem", severity: "warning" },
{ value: "typo", label: "Typo", severity: "info" },
{ value: "none", label: "No highlighting", severity: "hint" },
];
export const PROFILE_LABELS: Record<"default" | "project", string> = { default: "Default", project: "Project Default" };
export function levelSeverity(level: InspectionLevel): Severity {
return INSPECTION_LEVELS.find((l) => l.value === level)?.severity ?? "warning";
}
type Obj = Record<string, unknown>;
const obj = (v: unknown): Obj => (v && typeof v === "object" && !Array.isArray(v) ? (v as Obj) : {});
const LEVELS = new Set<string>(INSPECTION_LEVELS.map((l) => l.value));
function validateOverrides(raw: unknown): Record<string, InspectionOverride> {
const out: Record<string, InspectionOverride> = {};
for (const [id, v] of Object.entries(obj(raw))) {
const o = obj(v);
const entry: InspectionOverride = {};
if (typeof o.enabled === "boolean") entry.enabled = o.enabled;
if (typeof o.severity === "string" && LEVELS.has(o.severity)) entry.severity = o.severity as InspectionLevel;
if (id && (entry.enabled !== undefined || entry.severity !== undefined)) out[id] = entry;
}
return out;
}
function validateCustom(raw: unknown): Record<string, CustomProfile> {
const out: Record<string, CustomProfile> = {};
for (const [key, v] of Object.entries(obj(raw))) {
const o = obj(v);
if (!key.trim()) continue;
const name = typeof o.name === "string" && o.name.trim() ? o.name.trim() : key;
out[key] = { name, overrides: validateOverrides(o.overrides) };
}
return out;
}
const CUSTOM_ID = /^(ide|project):(.+)$/;
export function validateInspections(raw: unknown): InspectionSettingsValid {
const r = obj(raw);
const profile =
typeof r.profile === "string" && (r.profile === "project" || CUSTOM_ID.test(r.profile)) ? r.profile : "default";
return {
profile,
default: validateOverrides(r.default),
project: validateOverrides(r.project),
ideProfiles: validateCustom(r.ideProfiles),
projectProfiles: validateCustom(r.projectProfiles),
};
}
/** Where a profile is stored: its level, and its key among that level's copies (null for the two defaults). */
export function profileLocation(id: ProfileId): { level: ProfileLevel; key: string | null } {
if (id === "project") return { level: "project", key: null };
const m = CUSTOM_ID.exec(id);
if (!m) return { level: "ide", key: null };
return { level: m[1] as ProfileLevel, key: m[2]! };
}
export const customProfileId = (level: ProfileLevel, key: string): ProfileId => `${level}:${key}`;
function customOf(s: InspectionSettingsValid, id: ProfileId): CustomProfile | undefined {
const { level, key } = profileLocation(id);
if (key === null) return undefined;
return (level === "ide" ? s.ideProfiles : s.projectProfiles)[key];
}
/** Whether profile `id` exists (project-level ones only with a project open). */
export function profileExists(s: InspectionSettingsValid, id: ProfileId, hasProject: boolean): boolean {
if (id === "default") return true;
if (profileLocation(id).level === "project" && !hasProject) return false;
return id === "project" || !!customOf(s, id);
}
/** The profile chosen, if it exists here; else `Default`. */
export function effectiveProfileId(s: InspectionSettingsValid, hasProject: boolean): ProfileId {
return profileExists(s, s.profile, hasProject) ? s.profile : "default";
}
export function profileLabel(s: InspectionSettingsValid, id: ProfileId): string {
if (id === "default" || id === "project") return PROFILE_LABELS[id];
return customOf(s, id)?.name ?? id;
}
/** Overrides of profile `id` (empty when it does not exist). */
export function overridesOf(s: InspectionSettingsValid, id: ProfileId): ProfileOverrides {
if (id === "default") return s.default;
if (id === "project") return s.project;
return customOf(s, id)?.overrides ?? {};
}
export interface ProfileEntry {
id: ProfileId;
label: string;
level: ProfileLevel;
}
/** Profiles to choose from: project level first (with a project), then IDE level; copies by name. */
export function listProfiles(s: InspectionSettingsValid, hasProject: boolean): ProfileEntry[] {
const copies = (level: ProfileLevel, map: CustomProfiles): ProfileEntry[] =>
Object.entries(map)
.map(([key, p]) => ({ id: customProfileId(level, key), label: p.name, level }))
.sort((a, b) => a.label.localeCompare(b.label));
const project: ProfileEntry[] = hasProject
? [{ id: "project", label: PROFILE_LABELS.project, level: "project" }, ...copies("project", s.projectProfiles)]
: [];
return [...project, { id: "default", label: PROFILE_LABELS.default, level: "ide" }, ...copies("ide", s.ideProfiles)];
}
/** Error text for a proposed profile name, or null when valid (names are unique per level). */
export function validateProfileName(
name: string,
level: ProfileLevel,
s: InspectionSettingsValid,
ownId?: ProfileId,
): string | null {
const n = name.trim();
if (!n) return "Profile name cannot be empty";
const taken = listProfiles(s, true).some((p) => p.level === level && p.id !== ownId && p.label === n);
return taken ? `A profile named '${n}' already exists` : null;
}
/** `<name> copy`, numbered when taken at `level`. */
export function copyProfileName(name: string, level: ProfileLevel, s: InspectionSettingsValid): string {
const base = `${name} copy`;
if (!validateProfileName(base, level, s)) return base;
for (let i = 2; ; i++) if (!validateProfileName(`${base} ${i}`, level, s)) return `${base} ${i}`;
}
/** A fresh storage key for a copy named `name` among `existing`. */
export function newProfileKey(name: string, existing: CustomProfiles): string {
const slug =
name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "") || "profile";
if (!existing[slug]) return slug;
for (let i = 2; ; i++) if (!existing[`${slug}-${i}`]) return `${slug}-${i}`;
}
// --- inspection identity -----------------------------------------------------
export interface BuiltinInspection {
id: string;
/** Producer key the diagnostics arrive under. */
producer: string;
name: string;
group: string;
description: string;
/** Highlighting level when the profile sets none (else as reported). */
defaultLevel?: InspectionLevel;
}
/** Producer key of the Typo inspection's diagnostics. */
export const SPELLING_PRODUCER = "spelling";
export const SPELLING_INSPECTION = "builtin:spelling";
/** Checks the IDE runs itself (not a language server). */
export const BUILTIN_INSPECTIONS: readonly BuiltinInspection[] = [
{
id: "builtin:file-references",
producer: "file-references",
name: "Unresolved file references",
group: "General",
description:
"Reports paths in HTML attributes, CSS url() and @import that do not name an existing file, resolved relative to the file, the project root and its resource roots.",
},
{
id: "builtin:build",
producer: "build",
name: "Build problems",
group: "General",
description: "Compiler errors and warnings of the last Build Project, shown at their source positions.",
},
{
id: "builtin:sql",
producer: "sql-inspections",
name: "Unresolved database references",
group: "SQL",
description:
"Reports tables, columns and aliases in query consoles that the data source's schema does not contain.",
},
{
id: SPELLING_INSPECTION,
producer: SPELLING_PRODUCER,
name: "Typo",
group: "Proofreading",
description:
"Reports misspelt words in comments, string literals and the names declarations introduce (camelCase and snake_case parts checked separately), and in plain text and Markdown. Words can be saved to the project or application dictionary (Editor › Natural Languages › Spelling).",
defaultLevel: "typo",
},
];
const BUILTIN_BY_PRODUCER = new Map(BUILTIN_INSPECTIONS.map((b) => [b.producer, b]));
/** Inspection id of everything a producer reports. */
export function producerInspectionId(producer: string): string {
return BUILTIN_BY_PRODUCER.get(producer)?.id ?? `lsp:${producer}`;
}
export const codeInspectionId = (base: string, code: string) => `${base}:${code}`;
/** Producer of a diagnostic: the reporting server, else the event's producer, else its `source`. */
export function producerOf(d: Diagnostic, fallback?: string): string {
return d.server ?? fallback ?? d.source ?? "";
}
/** Set on diagnostics shown at the Typo level (rendered as a typo, not a weak warning). */
export interface TypoStyled {
typo?: boolean;
}
export const isTypoStyled = (d: object): boolean => (d as TypoStyled).typo === true;
/**
* `items` of `producer` under `profile`: disabled inspections dropped, severity
* overrides applied (a code's own override wins over its server's, which wins
* over a built-in check's default level). Returns `items` itself when the
* profile changes nothing.
*/
export function applyProfile<D extends Diagnostic>(
profile: ProfileOverrides,
producer: string,
items: readonly D[],
): readonly D[] {
const base = producerInspectionId(producer);
const own = profile[base];
if (own?.enabled === false) return [];
const fallback = BUILTIN_BY_PRODUCER.get(producer)?.defaultLevel;
let changed = false;
const out: D[] = [];
for (const d of items) {
const code = d.code ? profile[codeInspectionId(base, d.code)] : undefined;
if (code?.enabled === false) {
changed = true;
continue;
}
const level = code?.severity ?? own?.severity ?? fallback;
const severity = level ? levelSeverity(level) : d.severity;
if (level === "typo") {
changed = true;
out.push({ ...d, severity, typo: true });
} else if (severity !== d.severity) {
changed = true;
out.push({ ...d, severity });
} else {
out.push(d);
}
}
return changed ? out : items;
}
/** Applies `profile` to diagnostics of mixed producers (Inspect Code results). */
export function applyProfileMixed<D extends Diagnostic>(profile: ProfileOverrides, items: readonly D[]): D[] {
const byProducer = new Map<string, D[]>();
for (const d of items) {
const p = producerOf(d);
let list = byProducer.get(p);
if (!list) byProducer.set(p, (list = []));
list.push(d);
}
return [...byProducer].flatMap(([p, list]) => applyProfile(profile, p, list));
}
// --- profile in effect --------------------------------------------------------
let cache: { raw: unknown; hasProject: boolean; id: ProfileId; value: ProfileOverrides } | null = null;
function current(): { id: ProfileId; value: ProfileOverrides } {
const { settings, project } = useProject.getState();
const raw = obj(settings).inspections;
const hasProject = !!project;
if (cache && cache.raw === raw && cache.hasProject === hasProject) return cache;
const valid = validateInspections(raw);
const id = effectiveProfileId(valid, hasProject);
cache = { raw, hasProject, id, value: overridesOf(valid, id) };
return cache;
}
/** Overrides of the profile in effect. */
export function inspectionProfile(): ProfileOverrides {
return current().value;
}
export function activeProfileId(): ProfileId {
return current().id;
}
/** Overrides of a named profile from the effective settings. */
export function profileOverrides(id: ProfileId): ProfileOverrides {
return overridesOf(inspectionSettings(), id);
}
/** Validated inspection settings (effective). */
export function inspectionSettings(): InspectionSettingsValid {
return validateInspections(obj(useProject.getState().settings).inspections);
}
/** Calls `listener` whenever the profile in effect changes (its content, not just identity). */
export function subscribeInspectionProfile(listener: () => void): () => void {
let last = JSON.stringify(inspectionProfile());
return useProject.subscribe((s, prev) => {
if (s.settings === prev.settings && s.project === prev.project) return;
const next = JSON.stringify(inspectionProfile());
if (next === last) return;
last = next;
listener();
});
}
// --- diagnostic codes seen ----------------------------------------------------
/** A diagnostic code met at runtime, offered as its own inspection in Settings. */
export interface SeenCode {
producer: string;
code: string;
source: string;
sample: string;
}
const SEEN_KEY = "openidea.inspections.seenCodes";
const SEEN_LIMIT = 500;
let seen: Map<string, SeenCode> | null = null;
let saveTimer: ReturnType<typeof setTimeout> | null = null;
function loadSeen(): Map<string, SeenCode> {
if (seen) return seen;
seen = new Map();
try {
const raw = JSON.parse(localStorage.getItem(SEEN_KEY) ?? "[]");
if (Array.isArray(raw)) {
for (const e of raw) {
const o = obj(e);
if (typeof o.producer === "string" && typeof o.code === "string") {
seen.set(`${o.producer}\u0000${o.code}`, {
producer: o.producer,
code: o.code,
source: typeof o.source === "string" ? o.source : "",
sample: typeof o.sample === "string" ? o.sample : "",
});
}
}
}
} catch {
// Unavailable storage: the list lives for this session only.
}
return seen;
}
/** Records the codes of a producer's diagnostics (persisted, shared with the Settings window). */
export function noteDiagnosticCodes(producer: string, items: readonly Diagnostic[]) {
if (!producer) return;
const map = loadSeen();
let added = false;
for (const d of items) {
if (!d.code) continue;
const key = `${producer}\u0000${d.code}`;
if (map.has(key)) continue;
map.set(key, { producer, code: d.code, source: d.source ?? "", sample: d.message.slice(0, 200) });
added = true;
}
if (!added) return;
while (map.size > SEEN_LIMIT) map.delete(map.keys().next().value!);
saveTimer ??= setTimeout(() => {
saveTimer = null;
try {
localStorage.setItem(SEEN_KEY, JSON.stringify([...map.values()]));
} catch {
// Ignored: see loadSeen.
}
}, 1000);
}
/** Codes seen in any window (re-read from storage, merged with this window's unsaved ones). */
export function seenDiagnosticCodes(): SeenCode[] {
const mine = seen;
seen = null;
const map = loadSeen();
for (const [k, v] of mine ?? []) if (!map.has(k)) map.set(k, v);
return [...map.values()];
}
+3
Переглянути файл
@@ -47,6 +47,8 @@ describe("IDEA keymap parity", () => {
// Rerun in the Find / Find Usages tool windows uses the Rerun shortcut.
["find.rerun", ["Meta+R"], ["Ctrl+F5"]],
["usages.rerun", ["Meta+R"], ["Ctrl+F5"]],
// Vcs.Log.GoToRef: Find in the Git Log table.
["vcs.log.goToRef", ["Meta+F"], ["Ctrl+F"]],
])("%s", (id, mac, win) => {
expect(intellijMacos[id]).toEqual(mac);
expect(intellijWindows[id]).toEqual(win);
@@ -68,5 +70,6 @@ describe("IDEA keymap parity", () => {
order(km, "navigate.nextError", "vcs.changelist.edit");
order(km, "debug.setValue", "vcs.changelist.edit");
order(km, "find.replaceInFile", "find.rerun");
order(km, "find.inFile", "vcs.log.goToRef");
});
});
+19 -1
Переглянути файл
@@ -72,6 +72,12 @@ export const intellijMacos: Record<string, string[]> = {
// Code
"code.completion": ["Ctrl+Space"],
"editor.inlineCompletion.trigger": ["Alt+\\"],
"editor.inlineCompletion.accept": ["Tab"],
"editor.inlineCompletion.acceptWord": ["Meta+Right"],
"editor.inlineCompletion.dismiss": ["Escape"],
"editor.inlineCompletion.next": ["Alt+]"],
"editor.inlineCompletion.previous": ["Alt+["],
"code.parameterInfo": ["Meta+P"],
"code.quickDocumentation": ["F1", "Ctrl+J"],
"code.quickDefinition": ["Alt+Space", "Meta+Y"],
@@ -157,6 +163,7 @@ export const intellijMacos: Record<string, string[]> = {
// Project view (when: projectViewFocus)
"project.newFile": ["Meta+N"],
"project.newInThisDirectory": ["Ctrl+Alt+N"],
"project.rename": ["Shift+F6"],
"project.move": ["F6"],
"project.delete": ["Delete", "Backspace", "Meta+Backspace"],
@@ -211,6 +218,7 @@ export const intellijMacos: Record<string, string[]> = {
"run.stop": ["Meta+F2"],
// Rerun in the Run / Debug tool window only (when / keyWhen); Meta+R stays with the editor elsewhere.
"run.rerun": ["Meta+R"],
"coverage.manageReports": ["Meta+Alt+F6"],
"debug.rerun": ["Meta+R"],
// Rerun in the Find / Find Usages tool windows only (when).
"find.rerun": ["Meta+R"],
@@ -250,10 +258,15 @@ export const intellijMacos: Record<string, string[]> = {
"vcs.branches": [],
"vcs.annotate": [],
"vcs.localHistory": [],
"localHistory.recentChanges": ["Alt+Shift+C"],
// Run Anything (⌃⌃) and View › Quick Switch Scheme… (⌃`)
"runAnything.show": ["Ctrl Ctrl"],
"view.quickSwitchScheme": ["Ctrl+`"],
// Commit tool window (change lists) and Git log; keyWhen limits them to those tool windows.
"vcs.changelist.move": ["Meta+Shift+M"],
"vcs.changelist.edit": ["F2", "Shift+F6"],
"vcs.log.copyRevision": ["Meta+Alt+Shift+C"],
"vcs.log.goToRef": ["Meta+F"],
// View › Appearance › Zoom IDE
"view.zoomIn": ["Ctrl+Alt+="],
"view.zoomOut": ["Ctrl+Alt+-"],
@@ -289,6 +302,7 @@ export const intellijMacos: Record<string, string[]> = {
"db.openConsole": ["Meta+Shift+L"],
"db.newDataSource": [],
"db.new": ["Meta+N"],
"services.addService": ["Meta+N"],
"db.properties": ["Shift+Enter"],
"db.refresh": ["Meta+R"],
"db.disconnect": ["Meta+F2"],
@@ -312,7 +326,8 @@ export const intellijMacos: Record<string, string[]> = {
"db.collapseAll": [],
// Terminal (keyWhen: terminalFocus); after VCS Update / Go to Test so it wins there.
"terminal.new": ["Meta+T"],
// Last: later bindings win, so ⌘N is Generate in the editor and New in tool windows.
// Last: later bindings win, so ⌘N is Generate in editors with Generate actions (keyWhen)
// and New elsewhere (the project view, other editors: the New popup for the file).
"code.generate": ["Meta+N"],
// Find in Files popup (findPopupOpen): after the editor's Find Next, Jump to Source and
// selection keys, so these win while the popup is open.
@@ -320,4 +335,7 @@ export const intellijMacos: Record<string, string[]> = {
"find.popup.previousResult": ["Meta+Shift+G"],
"find.popup.openResult": ["F4", "Meta+Down"],
"find.popup.recentSearches": ["Alt+Down"],
// Search fields (find bar, Find in Files): last, so they win over editor keys while one is focused.
"find.field.newLine": ["Meta+Shift+Enter"],
"find.field.recentSearches": ["Alt+Down"],
};
+19 -1
Переглянути файл
@@ -73,6 +73,12 @@ export const intellijWindows: Record<string, string[]> = {
// Code
"code.completion": ["Ctrl+Space"],
"editor.inlineCompletion.trigger": ["Alt+\\"],
"editor.inlineCompletion.accept": ["Tab"],
"editor.inlineCompletion.acceptWord": ["Ctrl+Right"],
"editor.inlineCompletion.dismiss": ["Escape"],
"editor.inlineCompletion.next": ["Alt+]"],
"editor.inlineCompletion.previous": ["Alt+["],
"code.parameterInfo": ["Ctrl+P"],
"code.quickDocumentation": ["Ctrl+Q"],
"code.quickDefinition": ["Ctrl+Shift+I"],
@@ -158,6 +164,7 @@ export const intellijWindows: Record<string, string[]> = {
// Project view (when: projectViewFocus)
"project.newFile": ["Alt+Insert"],
"project.newInThisDirectory": ["Ctrl+Alt+Insert"],
"project.rename": ["Shift+F6"],
"project.move": ["F6"],
"project.delete": ["Delete"],
@@ -209,6 +216,7 @@ export const intellijWindows: Record<string, string[]> = {
"run.stop": ["Ctrl+F2"],
// Rerun in the Run / Debug tool window only (when / keyWhen); Ctrl+F5 stays with the editor elsewhere.
"run.rerun": ["Ctrl+F5"],
"coverage.manageReports": ["Ctrl+Alt+F6"],
"debug.rerun": ["Ctrl+F5"],
// Rerun in the Find / Find Usages tool windows only (when).
"find.rerun": ["Ctrl+F5"],
@@ -248,9 +256,14 @@ export const intellijWindows: Record<string, string[]> = {
"vcs.branches": ["Ctrl+Shift+`"],
"vcs.annotate": [],
"vcs.localHistory": [],
"localHistory.recentChanges": ["Alt+Shift+C"],
// Run Anything (⌃⌃) and View › Quick Switch Scheme… (⌃`)
"runAnything.show": ["Ctrl Ctrl"],
"view.quickSwitchScheme": ["Ctrl+`"],
"vcs.changelist.move": ["Alt+Shift+M"],
"vcs.changelist.edit": ["F2", "Shift+F6"],
"vcs.log.copyRevision": ["Ctrl+Alt+Shift+C"],
"vcs.log.goToRef": ["Ctrl+F"],
// View › Appearance › Zoom IDE
"view.zoomIn": ["Alt+Shift+="],
"view.zoomOut": ["Alt+Shift+-"],
@@ -286,6 +299,7 @@ export const intellijWindows: Record<string, string[]> = {
"db.openConsole": ["Ctrl+Shift+Q"],
"db.newDataSource": [],
"db.new": ["Alt+Insert"],
"services.addService": ["Alt+Insert"],
"db.properties": ["Shift+Enter"],
"db.refresh": ["Ctrl+F5"],
"db.disconnect": ["Ctrl+F2"],
@@ -309,7 +323,8 @@ export const intellijWindows: Record<string, string[]> = {
"db.collapseAll": [],
// Terminal (keyWhen: terminalFocus); after VCS Update / Go to Test so it wins there.
"terminal.new": ["Ctrl+Shift+T"],
// Last: later bindings win, so Alt+Insert is Generate in the editor and New in tool windows.
// Last: later bindings win, so Alt+Insert is Generate in editors with Generate actions (keyWhen)
// and New elsewhere (the project view, other editors: the New popup for the file).
"code.generate": ["Alt+Insert"],
// Find in Files popup (findPopupOpen): after the editor's Find Next, Jump to Source and
// selection keys, so these win while the popup is open.
@@ -317,4 +332,7 @@ export const intellijWindows: Record<string, string[]> = {
"find.popup.previousResult": ["Shift+F3", "Ctrl+Shift+L"],
"find.popup.openResult": ["F4"],
"find.popup.recentSearches": ["Alt+Down"],
// Search fields (find bar, Find in Files): last, so they win over editor keys while one is focused.
"find.field.newLine": ["Ctrl+Shift+Enter"],
"find.field.recentSearches": ["Alt+Down"],
};
+28
Переглянути файл
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { compileWhen } from "@/platform/context";
import { GENERATE_KEY_WHEN } from "@/editor/lsp/actions";
import { intellijMacos } from "./intellij-macos";
import { intellijWindows } from "./intellij-windows";
import { vscodeMacos } from "./vscode-macos";
/** IDEA's NewElement (⌘N / Alt+Insert, shared with Generate) and NewElementSamePlace (⌃⌥N / Ctrl+Alt+Insert). */
describe("New keymap", () => {
it("binds New in This Directory like IDEA", () => {
expect(intellijMacos["project.newInThisDirectory"]).toEqual(["Ctrl+Alt+N"]);
expect(intellijWindows["project.newInThisDirectory"]).toEqual(["Ctrl+Alt+Insert"]);
expect(vscodeMacos["project.newInThisDirectory"]).toEqual([]);
});
it.each([intellijMacos, intellijWindows])("lets Generate win the New key only where it applies", (km) => {
const ids = Object.keys(km);
const key = km["project.newFile"]![0]!;
expect(km["code.generate"]).toContain(key);
expect(ids.indexOf("project.newFile")).toBeLessThan(ids.indexOf("code.generate"));
const generate = compileWhen(GENERATE_KEY_WHEN);
expect(generate({ editorLangId: "java" })).toBe(true);
expect(generate({ editorLangId: "rust" })).toBe(true);
expect(generate({ editorLangId: "python" })).toBe(false);
expect(generate({ editorLangId: "markdown" })).toBe(false);
expect(generate({})).toBe(false);
});
});
+16
Переглянути файл
@@ -73,6 +73,12 @@ export const vscodeMacos: Record<string, string[]> = {
// Code
"code.completion": ["Ctrl+Space"],
"editor.inlineCompletion.trigger": ["Alt+\\"],
"editor.inlineCompletion.accept": ["Tab"],
"editor.inlineCompletion.acceptWord": ["Meta+Right"],
"editor.inlineCompletion.dismiss": ["Escape"],
"editor.inlineCompletion.next": ["Alt+]"],
"editor.inlineCompletion.previous": ["Alt+["],
"code.parameterInfo": ["Meta+Shift+Space"],
"code.quickDocumentation": ["Meta+K Meta+I"],
"code.quickDefinition": ["Alt+F12"],
@@ -159,6 +165,7 @@ export const vscodeMacos: Record<string, string[]> = {
// Explorer (when: projectViewFocus)
"project.newFile": [],
"project.newInThisDirectory": [],
"project.rename": ["Enter"],
"project.move": [],
"project.delete": ["Meta+Backspace"],
@@ -209,6 +216,7 @@ export const vscodeMacos: Record<string, string[]> = {
"run.chooseConfig": [],
"run.editConfigs": [],
"run.rerun": [],
"coverage.manageReports": [],
"web.openInBrowser": [],
"debug.toggleBreakpoint": ["F9"],
"debug.viewBreakpoints": [],
@@ -246,10 +254,14 @@ export const vscodeMacos: Record<string, string[]> = {
"vcs.branches": [],
"vcs.annotate": [],
"vcs.localHistory": [],
"localHistory.recentChanges": [],
"runAnything.show": [],
"view.quickSwitchScheme": [],
// Commit tool window (change lists) and Git log; keyWhen limits them to those tool windows.
"vcs.changelist.move": [],
"vcs.changelist.edit": [],
"vcs.log.copyRevision": [],
"vcs.log.goToRef": [],
// View › Appearance (VS Code: workbench.action.zoomIn / zoomOut / zoomReset)
"view.zoomIn": ["Meta+="],
"view.zoomOut": ["Meta+-"],
@@ -283,6 +295,7 @@ export const vscodeMacos: Record<string, string[]> = {
"db.openConsole": [],
"db.newDataSource": [],
"db.new": [],
"services.addService": [],
"db.properties": [],
"db.refresh": [],
"db.disconnect": [],
@@ -309,4 +322,7 @@ export const vscodeMacos: Record<string, string[]> = {
"find.popup.previousResult": ["Meta+Shift+G"],
"find.popup.openResult": [],
"find.popup.recentSearches": ["Alt+Down"],
// Search fields (find bar, Find in Files): last, so they win over editor keys while one is focused.
"find.field.newLine": ["Meta+Shift+Enter"],
"find.field.recentSearches": ["Alt+Down"],
};
+24 -2
Переглянути файл
@@ -27,8 +27,15 @@ export interface ProjectStructureValid {
excluded: string[];
/** Auto-detected roots the user unmarked. */
unmarked: string[];
/** Content roots besides the project folder (absolute paths). */
contentRoots: string[];
/** "Exclude files": `;`-separated name patterns. */
excludeFiles: string;
}
/** The folder lists of `ProjectStructureValid` (marks). */
export type FolderListKey = "sourceRoots" | "testRoots" | "resourceRoots" | "testResourceRoots" | "excluded" | "unmarked";
export const DEFAULT_LANGUAGES: LanguagesValid = {
python: { interpreter: "", activateVirtualenv: true },
node: { interpreter: "", packageManager: "" },
@@ -45,6 +52,8 @@ export const DEFAULT_PROJECT_STRUCTURE: ProjectStructureValid = {
testResourceRoots: [],
excluded: [],
unmarked: [],
contentRoots: [],
excludeFiles: "",
};
type Obj = Record<string, unknown>;
@@ -122,15 +131,27 @@ export function validateProjectStructure(raw: unknown): ProjectStructureValid {
testResourceRoots: folders(p.testResourceRoots),
excluded: folders(p.excluded),
unmarked: folders(p.unmarked),
contentRoots: Array.isArray(p.contentRoots)
? [...new Set(p.contentRoots.filter((x): x is string => typeof x === "string" && !!x.trim()))]
: [],
excludeFiles: typeof p.excludeFiles === "string" ? p.excludeFiles : "",
};
}
/** The "Exclude files" name patterns (`*.log;tmp*`). */
export function excludeFilePatterns(excludeFiles: string): string[] {
return excludeFiles
.split(";")
.map((p) => p.trim())
.filter(Boolean);
}
/** Root kinds, as in IDEA's Project Structure (`ide-workspace::roots::RootKind`). */
export type FolderRole = "source" | "test" | "resources" | "testResources" | "excluded";
export const FOLDER_ROLES: readonly FolderRole[] = ["source", "test", "resources", "testResources", "excluded"];
const ROLE_KEYS: Record<FolderRole, Exclude<keyof ProjectStructureValid, "unmarked">> = {
const ROLE_KEYS: Record<FolderRole, Exclude<FolderListKey, "unmarked">> = {
source: "sourceRoots",
test: "testRoots",
resources: "resourceRoots",
@@ -216,6 +237,7 @@ export function markFolder(
): ProjectStructureValid {
const r = storedFolder(rel);
const next: ProjectStructureValid = {
...ps,
sourceRoots: ps.sourceRoots.filter((x) => x !== r),
testRoots: ps.testRoots.filter((x) => x !== r),
resourceRoots: ps.resourceRoots.filter((x) => x !== r),
@@ -253,7 +275,7 @@ export const PROJECT_STRUCTURE_KEYS = [
"testResourceRoots",
"excluded",
"unmarked",
] as const satisfies ReadonlyArray<keyof ProjectStructureValid>;
] as const satisfies ReadonlyArray<FolderListKey>;
/** Effective Project Structure of the open project (reactive). */
export function useProjectStructure(): ProjectStructureValid {
+62
Переглянути файл
@@ -268,6 +268,14 @@
"menu.vcs.stash": "Stash Changes…",
"menu.vcs.unstash": "Unstash Changes…",
"menu.vcs.update": "Update Project…",
"menu.vcs.pull": "Pull…",
"menu.vcs.merge": "Merge…",
"menu.vcs.rebase": "Rebase…",
"menu.vcs.newWorktree": "New Worktree…",
"menu.vcs.worktrees": "Worktrees…",
"menu.vcs.manageRemotes": "Manage Remotes…",
"menu.vcs.clone": "Clone…",
"menu.vcs.operations": "VCS Operations Popup…",
"menu.view": "View",
"menu.view.notifications": "Notifications",
"menu.view.toggleTheme": "Toggle Light/Dark Theme",
@@ -318,5 +326,59 @@
"menu.github.createPullRequest": "Create Pull Request…",
"menu.github.checkoutPullRequest": "Checkout Pull Request…",
"menu.github.openInBrowser": "Open on GitHub",
"menu.file.newProject": "New Project…",
"menu.file.renameProject": "Rename Project…",
"menu.file.reloadAllFromDisk": "Reload All from Disk",
"menu.file.invalidateCaches": "Invalidate Caches…",
"menu.manageIdeSettings": "Manage IDE Settings",
"menu.file.exportSettings": "Export Settings…",
"menu.file.importSettings": "Import Settings…",
"menu.file.togglePowerSave": "Power Save Mode",
"menu.macros": "Macros",
"menu.macro.startRecording": "Start Macro Recording",
"menu.macro.stopRecording": "Stop Macro Recording",
"menu.macro.playBack": "Play Back Last Macro",
"menu.macro.playSaved": "Play Saved Macros…",
"menu.macro.edit": "Edit Macros…",
"menu.bookmarks": "Bookmarks",
"menu.navigate.toggleBookmarkWithMnemonic": "Toggle Bookmark with Mnemonic",
"menu.navigate.nextBookmark": "Next Bookmark",
"menu.navigate.prevBookmark": "Previous Bookmark",
"menu.view.toggleCompactMode": "Compact Mode",
"menu.view.zoomIn": "Zoom In IDE",
"menu.view.zoomOut": "Zoom Out IDE",
"menu.view.resetZoom": "Reset IDE Zoom",
"menu.toolbar.customize": "Customize Main Toolbar…",
"menu.code.quickDefinition": "Quick Definition",
"menu.code.quickTypeDefinition": "Quick Type Definition",
"menu.localHistory.recentChanges": "Recent Changes",
"menu.diff.compareWithClipboard": "Compare with Clipboard",
"menu.view.quickSwitchScheme": "Quick Switch Scheme…",
"menu.editor.resetFontSize": "Reset Font Size in All Editors",
"menu.code.cleanup": "Code Cleanup…",
"menu.runAnything.show": "Run Anything…",
"menu.localHistory.putLabel": "Put Label…",
"menu.toolWindowLayouts": "Tool Window Layouts",
"menu.layout.save": "Save Current Layout as…",
"menu.layout.restoreDefault": "Restore Default Layout",
"menu.layout.rename": "Rename Layout…",
"menu.layout.delete": "Delete Layout…",
"menu.activeToolWindow": "Active Tool Window",
"menu.toolwindow.hideActive": "Hide Active Tool Window",
"menu.toolwindow.maximize": "Maximize Tool Window",
"menu.toolwindow.closeContent": "Close Active Tab",
"menu.toolwindow.jumpToLast": "Jump to Last Tool Window",
"menu.toolwindow.focusEditor": "Focus Editor",
"menu.editorTabs": "Editor Tabs",
"menu.editor.nextTab": "Select Next Tab",
"menu.editor.prevTab": "Select Previous Tab",
"menu.editor.pinTab": "Pin Tab",
"menu.editor.closeTab": "Close Tab",
"menu.editor.closeOtherTabs": "Close Other Tabs",
"menu.editor.closeAllTabs": "Close All Tabs",
"menu.editor.reopenClosedTab": "Reopen Closed Tab",
"menu.editor.moveToOppositeGroup": "Move to Opposite Group",
"run.services.configurationCount": "{count} configurations",
"search.bookmarks.count": "{count, plural, one {# bookmark} other {# bookmarks}}",
"settings.zoom.hint": "Change with {zoomIn} or {zoomOut}. Set to 100% with {reset}"
}
+741 -1
Переглянути файл
@@ -1870,6 +1870,8 @@
"Show All": "全部显示",
"Show All Frames": "显示所有帧",
"Show Bookmarks": "显示书签",
"Show Inherited": "显示继承的成员",
"Show inherited members": "显示继承的成员",
"Show Build": "显示构建",
"Show Commit in Git Log": "在 Git 日志中显示提交",
"Show Container Log": "显示容器日志",
@@ -2221,6 +2223,7 @@
"usages": "处用法",
"more": "更多",
"Usages": "用法",
"Code author": "代码作者",
"Usages Detected": "检测到用法",
"Usages actions": "用法操作",
"Usages of '{symbol}'": "'{symbol}' 的用法",
@@ -2518,5 +2521,742 @@
"Select In…": "选择位置…",
"Next Result": "下一个结果",
"Previous Result": "上一个结果",
"Open Result": "打开结果"
"Open Result": "打开结果",
"Graph Options": "图形选项",
"Go to Hash/Branch/Tag": "转到哈希/分支/标签",
"Enter hash or branch/tag name": "输入哈希或分支/标签名称",
"Sort": "排序",
"By Commit Date": "按提交日期",
"Topologically": "拓扑",
"First Parent": "第一个父级",
"No Merges": "无合并",
"Branch Actions": "分支操作",
"Collapse Linear Branches": "折叠线性分支",
"Expand Linear Branches": "展开线性分支",
"Compact References View": "紧凑引用视图",
"Tag Names": "标签名称",
"Long Edges": "长边",
"Commit Timestamp": "提交时间戳",
"References on the Left": "左侧引用",
"Hash": "哈希",
"Highlight": "高亮显示",
"My Commits": "我的提交",
"Merge Commits": "合并提交",
"Rebase…": "变基…",
"Manage Remotes…": "管理远程…",
"Clone…": "克隆…",
"New Worktree…": "新建工作树…",
"Worktrees…": "工作树…",
"Worktrees": "工作树",
"New Worktree": "新建工作树",
"Git Remotes": "Git 远程",
"Define Remote": "定义远程",
"Edit Remote": "编辑远程",
"Add Remote…": "添加远程…",
"Remove Remote": "移除远程",
"Edit Remote…": "编辑远程…",
"Remove Worktree": "移除工作树",
"Branch to merge:": "要合并的分支:",
"Check out:": "检出:",
"Create a new branch": "创建新分支",
"Folder:": "文件夹:",
"Options:": "选项:",
"No remotes": "无远程",
"Include disabled actions": "包括禁用的操作",
"Symbol Kinds": "符号类型",
"⌥↵ to assign a shortcut": "⌥↵ 分配快捷键",
"Open Settings Page": "打开设置页面",
"Assign Shortcut…": "分配快捷键…",
"New List…": "新建列表…",
"Rename List…": "重命名列表…",
"Make Default": "设为默认",
"Delete List": "删除列表",
"Move to Another List": "移动到其他列表",
"New Bookmark List…": "新建书签列表…",
"Toggle File Bookmark": "切换文件书签",
"New Bookmark List": "新建书签列表",
"Rename Bookmark List": "重命名书签列表",
"Delete Bookmark List": "删除书签列表",
"Add Bookmark": "添加书签",
"Remove Bookmark": "移除书签",
"Interfaces": "接口",
"Structs": "结构体",
"Enums": "枚举",
"Traits": "特征",
"Type Aliases": "类型别名",
"Functions": "函数",
"Methods": "方法",
"Constructors": "构造函数",
"Fields": "字段",
"Constants": "常量",
"Enum Members": "枚举成员",
"Modules": "模块",
"Namespaces": "命名空间",
"List name": "列表名称",
"Add Service": "添加服务",
"Add service": "添加服务",
"No services configured.": "未配置服务。",
"Select service to view details": "选择服务以查看详细信息",
"Run Configuration Type": "运行配置类型",
"Docker Registry…": "Docker 注册表…",
"Docker Connection…": "Docker 连接…",
"Show Output": "显示输出",
"Address:": "地址:",
"Username:": "用户名:",
"Scope Based": "基于范围",
"Scope:": "范围:",
"Edit Scopes…": "编辑范围…",
"Import a report collected in CI from disk": "从磁盘导入在 CI 中收集的报告",
"Manage Coverage Reports…": "管理覆盖率报告…",
"Coverage Reports": "覆盖率报告",
"Show Selected": "显示所选项",
"Import…": "导入…",
"Reload All from Disk": "从磁盘全部重新加载",
"Reset Font Size in All Editors": "重置所有编辑器中的字体大小",
"Library source (read-only).": "库源代码(只读)。",
"Search In Selection": "在所选内容中搜索",
"Search Scope": "搜索范围",
"Filter Search Results": "过滤搜索结果",
"Filter search results": "过滤搜索结果",
"Replace (⌥P)": "替换 (⌥P)",
"Replace All (⌥A)": "全部替换 (⌥A)",
"Include (⌥L)": "包括 (⌥L)",
"Exclude (⌥L)": "排除 (⌥L)",
"Recent searches": "最近的搜索",
"New Line": "新行",
"New Line in Search Field": "在搜索字段中插入新行",
"Quick Type Definition": "快速类型定义",
"Code Cleanup in File": "文件中的代码清理",
"Nothing to generate here": "此处没有可生成的内容",
"Sort by name": "按名称排序",
"Sort suggestions by relevance": "按相关性对建议排序",
"Sort suggestions alphabetically": "按字母顺序对建议排序",
"Press ↵ to insert, ⇥ to replace": "按 ↵ 插入,按 ⇥ 替换",
"Formatting is turned off for this file (Code Style › Formatter › Do not format)": "此文件已关闭格式化(代码样式 › 格式化程序 › 不格式化)",
"No type definition found": "未找到类型定义",
"No definition found": "未找到定义",
"Typo: Change to…": "拼写错误:更改为…",
"Spelling": "拼写",
"No refactoring applies here": "此处没有适用的重构",
"Inline Completion": "内联补全",
"Call Inline Completion": "调用内联补全",
"Inline completion is off for this language": "此语言已关闭内联补全",
"No inline suggestions": "没有内联建议",
"No language server provides inline completion": "没有语言服务器提供内联补全",
"Insert Inline Proposal": "插入内联建议",
"Insert Inline Proposal: Next Word": "插入内联建议:下一个单词",
"Hide Inline Proposal": "隐藏内联建议",
"Show Next Inline Proposal": "显示下一个内联建议",
"Show Previous Inline Proposal": "显示上一个内联建议",
"Inspection profile:": "检查配置文件:",
"Inspection profile": "检查配置文件",
"Configure inspections": "配置检查",
"Run Code Cleanup": "运行代码清理",
"Apply the language servers' automatic fixes": "应用语言服务器的自动修复",
"No files with a language server in this scope": "此范围内没有带语言服务器的文件",
"Code Cleanup": "代码清理",
"Code Cleanup…": "代码清理…",
"Clean Up": "清理",
"No custom scopes": "没有自定义范围",
"Custom scope": "自定义范围",
"Custom Scopes": "自定义范围",
"Choose a scope": "选择范围",
"Enter a project name": "请输入项目名称",
"The name contains characters a folder name cannot have": "名称包含文件夹名称中不能使用的字符",
"Project Location": "项目位置",
"New Project": "新建项目",
"New Project…": "新建项目…",
"Create Git repository": "创建 Git 仓库",
"Export Settings": "导出设置",
"Export Settings…": "导出设置…",
"OpenIDEA settings": "OpenIDEA 设置",
"Settings exported": "设置已导出",
"Cannot export settings": "无法导出设置",
"Import Settings": "导入设置",
"Import Settings…": "导入设置…",
"Settings imported": "设置已导入",
"Restart to apply imported templates, macros and layouts.": "重启以应用导入的模板、宏和布局。",
"Cannot import settings": "无法导入设置",
"Rename Project": "重命名项目",
"Rename Project…": "重命名项目…",
"New project name:": "新项目名称:",
"Cannot rename project": "无法重命名项目",
"Power Save Mode": "省电模式",
"Cannot change Power Save Mode": "无法更改省电模式",
"Power save mode is on: completion and documentation popups and usage highlighting are off.": "省电模式已开启:补全和文档弹出窗口以及用法高亮显示已关闭。",
"This needs the desktop app": "此功能需要桌面应用",
"Turn Off": "关闭",
"Build Tools": "构建工具",
"Import a Coverage Report…": "导入覆盖率报告…",
"Choose the shown coverage suite, delete suites and imported reports": "选择显示的覆盖率套件,删除套件和导入的报告",
"Import Coverage Report": "导入覆盖率报告",
"Coverage reports (lcov, JaCoCo XML, Go cover profile)": "覆盖率报告(lcov、JaCoCo XML、Go cover profile)",
"All files": "所有文件",
"Importing coverage needs the desktop app": "导入覆盖率需要桌面应用",
"Coverage reports": "覆盖率报告",
"not loaded": "未加载",
"No coverage reports. Run with coverage or import a report.": "没有覆盖率报告。请使用覆盖率运行或导入报告。",
"No color": "无颜色",
"Verify CA": "验证 CA",
"Verify Full": "完全验证",
"Manual": "手动",
"Name or pattern, e.g. app_*": "名称或模式,例如 app_*",
"Schemas": "架构",
"Advanced": "高级",
"Data source type": "数据源类型",
"path/to/database.db (absolute or project-relative)": "path/to/database.db(绝对路径或相对于项目的路径)",
"Connection type:": "连接类型:",
"Connection type": "连接类型",
"URL only": "仅 URL",
"from the URL": "来自 URL",
"Connects with the URL alone: host, port, database, SSL and parameters come from it.": "仅使用 URL 连接:主机、端口、数据库、SSL 和参数均来自 URL。",
"Editing the URL fills in the settings above.": "编辑 URL 会填写上方的设置。",
"Connection": "连接",
"Transaction control:": "事务控制:",
"Transaction control": "事务控制",
"Default schema:": "默认架构:",
"public (search_path, e.g. app, public)": "public(search_path,例如 app, public)",
"Time zone:": "时区:",
"Server default, e.g. UTC or Europe/Berlin": "服务器默认值,例如 UTC 或 Europe/Berlin",
"UTC (+00:00), e.g. Europe/Berlin or +08:00": "UTC (+00:00),例如 Europe/Berlin 或 +08:00",
"Run keep-alive query each": "执行保持活动查询,间隔",
"Auto-disconnect after": "在以下时间后自动断开连接",
"Startup script:": "启动脚本:",
"Runs on every new connection, before any other statement.": "在每个新连接上、任何其他语句之前运行。",
"Passphrase:": "密码短语:",
"Mode:": "模式:",
"CA file:": "CA 文件:",
"System roots when empty": "为空时使用系统根证书",
"CA Certificate File": "CA 证书文件",
"Client certificate file:": "客户端证书文件:",
"Client Certificate File": "客户端证书文件",
"Client key file:": "客户端密钥文件:",
"Client Key File": "客户端密钥文件",
"schemas (databases)": "架构(数据库)",
"Show system schemas and template databases": "显示系统架构和模板数据库",
"Show system schemas": "显示系统架构",
"Load Names from Server": "从服务器加载名称",
"Driver properties": "驱动程序属性",
"Property name": "属性名称",
"Property value": "属性值",
"Remove property": "移除属性",
"Add Property": "添加属性",
"Color:": "颜色:",
"Color": "颜色",
"Add comment": "添加注释",
"Connecting…": "正在连接…",
"OceanBase (MySQL mode)": "OceanBase(MySQL 模式)",
"HTTP Request": "HTTP 请求",
"External Libraries": "外部库",
"library root": "库根",
"Scratch File": "临时文件",
"Stop Macro Recording": "停止宏录制",
"Start Macro Recording": "开始宏录制",
"Recording macro…": "正在录制宏…",
"Rename Macro": "重命名宏",
"Enter a macro name": "请输入宏名称",
"A macro with this name exists": "已存在同名的宏",
"Delete Macro": "删除宏",
"Edit Macros": "编辑宏",
"Edit Macros…": "编辑宏…",
"No saved macros": "没有已保存的宏",
"Recorded steps": "录制的步骤",
"Macro playback stopped": "宏回放已停止",
"A recorded step could not run here.": "某个录制的步骤无法在此处运行。",
"Nothing was recorded": "未录制任何内容",
"Enter Macro Name": "输入宏名称",
"Name (leave empty to keep it only as the last macro):": "名称(留空则仅保留为上一个宏):",
"Play Back Last Macro": "回放上一个宏",
"Play Saved Macros…": "播放已保存的宏…",
"Play Saved Macro": "播放已保存的宏",
"Search for file type": "搜索文件类型",
"File types": "文件类型",
"this view": "此视图",
"New in This Directory…": "在此目录中新建…",
"All Changed Files": "所有已更改的文件",
"Scratches and Consoles": "临时文件和控制台",
"Run Anything": "运行任何内容",
"Run Anything…": "运行任何内容…",
"Run a command, configuration or goal": "运行命令、配置或目标",
"Run Anything entries": "“运行任何内容”条目",
"Press Enter to run the command in the project directory": "按 Enter 在项目目录中运行命令",
"Type a command": "输入命令",
"Enter to run, Tab to complete. Other commands run in the project directory.": "按 Enter 运行,按 Tab 补全。其他命令将在项目目录中运行。",
"Recent": "最近",
"Search types": "搜索类型",
"Search configuration types": "搜索配置类型",
"Configuration types": "配置类型",
"Redirect input": "重定向输入",
"Paths to \".env\" files": "\".env\" 文件路径",
"Save console output to file": "将控制台输出保存到文件",
"Show console when a message is printed to stdout": "当消息输出到 stdout 时显示控制台",
"Show console when a message is printed to stderr": "当消息输出到 stderr 时显示控制台",
"Add before launch task": "添加启动前任务",
"Open run/debug tool window when started": "启动时打开运行/调试工具窗口",
"Focus run/debug tool window when started": "启动时聚焦运行/调试工具窗口",
"Emulate terminal in output console": "在输出控制台中模拟终端",
"Operating System": "操作系统",
"Logs": "日志",
"Before Launch": "启动前",
"Before launch": "启动前",
"Before launch tasks": "启动前任务",
"Store as project file": "存储为项目文件",
"Modify options": "修改选项",
"e.g. ./gradlew or cargo": "例如 ./gradlew 或 cargo",
"Edit Environment Variables": "编辑环境变量",
"System environment variables are not passed": "不传递系统环境变量",
"Redirect input from": "重定向输入自",
"File relative to the project root": "相对于项目根目录的文件",
"Shell command, e.g. make generate": "Shell 命令,例如 make generate",
"Remove Task": "移除任务",
"Add task": "添加任务",
"Add configuration…": "添加配置…",
"Add configuration": "添加配置",
"New Folder": "新建文件夹",
"Remove Folder": "移除文件夹",
"Create New Folder": "创建新文件夹",
"Unnamed": "未命名",
"No run configurations added.": "未添加运行配置。",
"Add new…": "新增…",
"Edit configuration templates…": "编辑配置模板…",
"Templates": "模板",
"Configuration templates": "配置模板",
"Back to configurations": "返回配置",
"Select a configuration type": "选择配置类型",
"Add new run configuration…": "添加新的运行配置…",
"or select run configuration to edit": "或选择要编辑的运行配置",
"Folder name": "文件夹名称",
"Name is required": "名称为必填项",
"Environment variable names can't be empty": "环境变量名称不能为空",
"Environment Variables": "环境变量",
"User environment variables:": "用户环境变量:",
"User environment variables": "用户环境变量",
"Include system environment variables": "包括系统环境变量",
"System environment variables": "系统环境变量",
"Click the Run icon in the editor gutter": "点击编辑器装订区域中的“运行”图标",
"Select \"Run…\" in the editor context menu": "在编辑器上下文菜单中选择“运行…”",
"Launch a": "启动",
"run configuration": "运行配置",
"Run Current File": "运行当前文件",
"Debug Current File": "调试当前文件",
"Run Another Configuration": "运行另一个配置",
"Run External Tool": "运行外部工具",
"Run Shell Command": "运行 Shell 命令",
"Run shell command": "运行 Shell 命令",
"Activate Tool Window": "激活工具窗口",
"Building is not available": "构建不可用",
"A build is already running": "已有构建正在运行",
"The before launch tasks run in a cycle": "启动前任务形成了循环",
"No command": "没有命令",
"Compound": "复合",
"Add at least one configuration": "请至少添加一个配置",
"A compound configuration can't include itself": "复合配置不能包含自身",
"Some configurations no longer exist": "某些配置已不存在",
"Command is required": "命令为必填项",
"A before launch task runs a missing configuration or this one": "某个启动前任务运行了不存在的配置或此配置本身",
"A before launch task is incomplete": "某个启动前任务不完整",
"Run Widget": "运行微件",
"Run Configurations": "运行配置",
"Starting": "正在启动",
"Stopped": "已停止",
"Finished": "已完成",
"Remove from Services": "从服务中移除",
"Configuration": "配置",
"Type": "类型",
"Not started": "未启动",
"Started": "已启动",
"Shell Script": "Shell 脚本",
"Run a script file, script text or command line": "运行脚本文件、脚本文本或命令行",
"Script file": "脚本文件",
"Script text": "脚本文本",
"Script path": "脚本路径",
"Script options": "脚本选项",
"Interpreter path": "解释器路径",
"Run the script directly": "直接运行脚本",
"Interpreter options": "解释器选项",
"Run an executable with arguments": "使用实参运行可执行文件",
"Executable": "可执行文件",
"Path or command on PATH": "路径或 PATH 中的命令",
"Program arguments": "程序实参",
"Run a package.json script": "运行 package.json 脚本",
"package.json directory": "package.json 目录",
"Tasks": "任务",
"Use the Gradle wrapper": "使用 Gradle 包装器",
"Goals": "目标",
"Use the Maven wrapper": "使用 Maven 包装器",
"Run or debug several configurations together": "同时运行或调试多个配置",
" · default": " · 默认",
"Bookmarked file": "已添加书签的文件",
"(empty line)": "(空行)",
"Name cannot be empty": "名称不能为空",
"Every scope needs a name": "每个范围都需要名称",
"Scopes": "范围",
"Add Scope": "添加范围",
"Remove Scope": "移除范围",
"Pattern:": "模式:",
"files by name, anywhere": "按名称匹配任意位置的文件",
"files directly in a folder": "直接位于文件夹中的文件",
"at any depth": "任意深度",
"Add a scope to define it by a file pattern.": "添加一个范围,并用文件模式定义它。",
"Just now": "刚刚",
"Show Fields": "显示字段",
"Show Non-Public": "显示非公共成员",
"Sort by Visibility": "按可见性排序",
"Show fields": "显示字段",
"Show non-public": "显示非公共成员",
"Docker CLI default (DOCKER_HOST / current context)": "Docker CLI 默认值(DOCKER_HOST/当前上下文)",
"Docker CLI default": "Docker CLI 默认值",
"Unix socket": "Unix 套接字",
"TCP / SSH host": "TCP/SSH 主机",
"Docker context": "Docker 上下文",
"Edit Docker Connection": "编辑 Docker 连接",
"New Docker Connection": "新建 Docker 连接",
"Connect to Docker daemon with:": "连接 Docker 守护进程的方式:",
"Socket path or Engine API URL:": "套接字路径或 Engine API URL:",
"Engine API URL:": "Engine API URL:",
"tcp://build-host:2376 or ssh://me@build-host": "tcp://build-host:2376 或 ssh://me@build-host",
"Certificates folder (TLS, optional):": "证书文件夹(TLS,可选):",
"contains ca.pem, cert.pem and key.pem": "包含 ca.pem、cert.pem 和 key.pem",
"Certificates Folder": "证书文件夹",
"Context name:": "上下文名称:",
"e.g. desktop-linux, orbstack, colima": "例如 desktop-linux、orbstack、colima",
"Docker Registry": "Docker 注册表",
"Edit Docker Registry": "编辑 Docker 注册表",
"New Docker Registry": "新建 Docker 注册表",
"docker.io, ghcr.io, registry.example.com:5000": "docker.io、ghcr.io、registry.example.com:5000",
"Unchanged": "未更改",
"Password or access token": "密码或访问令牌",
"Images from this registry are pulled with these credentials.": "将使用这些凭据从此注册表拉取镜像。",
"not running": "未运行",
"Remove Docker Connection": "移除 Docker 连接",
"Remove Docker Registry": "移除 Docker 注册表",
"Edit Registry…": "编辑注册表…",
"Test Login": "测试登录",
"Remove Registry": "移除注册表",
"Edit Connection…": "编辑连接…",
"Remove Connection": "移除连接",
"Waiting for output…": "正在等待输出…",
"No output": "无输出",
"Address": "地址",
"Username": "用户名",
"Edit…": "编辑…",
"Add a run configuration type, Docker connection or registry to the Services tool window": "向服务工具窗口添加运行配置类型、Docker 连接或注册表",
"Dangling Images": "悬空镜像",
"Compose Down": "Compose 停止 (down)",
"Cannot read Docker settings": "无法读取 Docker 设置",
"Cannot save Docker settings": "无法保存 Docker 设置",
"Cannot save the registry password": "无法保存注册表密码",
"Server": "服务器",
"Container name": "容器名称",
"Derived from the image": "从镜像派生",
"Bind ports": "绑定端口",
"Bind mounts": "绑定挂载",
"Container environment": "容器环境",
"Run options": "运行选项",
"e.g. --rm --network host": "例如 --rm --network host",
"Build an image from a Dockerfile and run a container from it": "从 Dockerfile 构建镜像并从中运行容器",
"Relative to the project root": "相对于项目根目录",
"Context folder": "上下文文件夹",
"The Dockerfile's folder": "Dockerfile 所在文件夹",
"Image tag": "镜像标签",
"Derived from the context folder": "从上下文文件夹派生",
"Build stage": "构建阶段",
"The last stage": "最后一个阶段",
"Build args": "构建实参",
"Build options": "构建选项",
"e.g. --no-cache --platform linux/amd64": "例如 --no-cache --platform linux/amd64",
"Run built image": "运行构建的镜像",
"The image's default": "镜像的默认值",
"Docker Image": "Docker 镜像",
"Run a container from an image": "从镜像运行容器",
"Image ID or name": "镜像 ID 或名称",
"e.g. postgres:16": "例如 postgres:16",
"Pull the image before running": "运行前拉取镜像",
"Compose files": "Compose 文件",
"All services": "所有服务",
"The folder name": "文件夹名称",
"Run in the background (-d)": "在后台运行 (-d)",
"Build images before starting (--build)": "启动前构建镜像 (--build)",
"Up options": "Up 选项",
"e.g. --force-recreate --remove-orphans": "例如 --force-recreate --remove-orphans",
"Compact Mode": "紧凑模式",
"Override File Type": "覆盖文件类型",
"Revert File Type Override": "还原文件类型覆盖",
"Add Content Root": "添加内容根",
"Exclude files:": "排除文件:",
"Filter file types": "过滤文件类型",
"Recognized file types": "已识别的文件类型",
"Recognized File Types": "已识别的文件类型",
"File name patterns": "文件名模式",
"* matches any characters, ? one (e.g. *.foo, Jenkinsfile).": "* 匹配任意字符,? 匹配一个字符(例如 *.foo、Jenkinsfile)。",
"Hashbang patterns": "Hashbang 模式",
"Interpreters run by a #! first line, e.g. node for #!/usr/bin/env node.": "由首行 #! 运行的解释器,例如 #!/usr/bin/env node 对应 node。",
"Also recognized by its first line:": "也可通过首行识别:",
"Default (IDE)": "默认 (IDE)",
"Stored in the project.": "存储在项目中。",
"Copy to Project": "复制到项目",
"Copy to IDE": "复制到 IDE",
"Copy to Project…": "复制到项目…",
"Copy to IDE…": "复制到 IDE…",
"Do not format": "不格式化",
"File name patterns and folders (glob), e.g. *.min.js, generated/**.": "文件名模式和文件夹 (glob),例如 *.min.js、generated/**。",
"Turn formatter on/off with markers in code comments": "使用代码注释中的标记开启/关闭格式化程序",
"Off": "关",
"On": "开",
"Enable regular expressions in formatter markers": "在格式化程序标记中启用正则表达式",
"Show inline completion for:": "为以下语言显示内联补全:",
"Automatically insert single suggestions": "自动插入单个建议",
"When Basic Completion (⌃Space) finds only one suggestion.": "当基本补全 (⌃Space) 只找到一个建议时。",
"Also toggled by Sort by name in the completion popup.": "也可通过补全弹出窗口中的“按名称排序”切换。",
"Insert parentheses automatically when applicable": "适用时自动插入圆括号",
"Left": "左侧",
"Right": "右侧",
"Show tabs in:": "标签页显示方式:",
"Show tabs in": "标签页显示方式",
"Show pinned tabs in a separate row": "在单独的行中显示固定的标签页",
"Show file icon": "显示文件图标",
"Show directory for non-unique file names": "为不唯一的文件名显示目录",
"Show full path on mouse hover": "鼠标悬停时显示完整路径",
"Close button position:": "关闭按钮位置:",
"Close button position": "关闭按钮位置",
"Tab Order": "标签页顺序",
"Sort tabs alphabetically": "按字母顺序对标签页排序",
"One row, and if tabs don't fit:": "单行,标签页放不下时:",
"If tabs don't fit": "标签页放不下时",
"Scroll the tabs panel": "滚动标签页面板",
"Squeeze tabs": "压缩标签页",
"Multiple rows": "多行",
"Gray suggestions at the caret from language servers that support inline completion. Tab accepts a suggestion, Escape dismisses it.": "在光标处以灰色显示来自支持内联补全的语言服务器的建议。按 Tab 接受建议,按 Escape 取消。",
"Enable inline completion": "启用内联补全",
"Show only when typing pauses for": "仅在输入暂停以下时长后显示",
"Inspections": "检查",
"Delete Profile": "删除配置文件",
"Rename Profile": "重命名配置文件",
"Copy Profile": "复制配置文件",
"Profile:": "配置文件:",
"Profile Actions": "配置文件操作",
"Stored in Project": "存储在项目中",
"Stored in IDE": "存储在 IDE 中",
"Search inspections": "搜索检查",
"Reset to Default": "重置为默认值",
"Select an inspection to see its description and change its severity.": "选择一项检查以查看其描述并更改其严重性。",
"Severity:": "严重性:",
"As reported": "按报告",
"Error": "错误",
"Weak Warning": "弱警告",
"Server Problem": "服务器问题",
"Typo": "拼写错误",
"No highlighting": "无高亮显示",
"Project Default": "项目默认",
"Profile name cannot be empty": "配置文件名称不能为空",
"Unresolved file references": "未解析的文件引用",
"Reports paths in HTML attributes, CSS url() and @import that do not name an existing file, resolved relative to the file, the project root and its resource roots.": "报告 HTML 特性、CSS url() 和 @import 中未指向现有文件的路径,路径相对于该文件、项目根目录及其资源根进行解析。",
"Build problems": "构建问题",
"Unresolved database references": "未解析的数据库引用",
"Reports tables, columns and aliases in query consoles that the data source's schema does not contain.": "报告查询控制台中数据源架构不包含的表、列和别名。",
"Proofreading": "校对",
"Reports misspelt words in comments, string literals and the names declarations introduce (camelCase and snake_case parts checked separately), and in plain text and Markdown. Words can be saved to the project or application dictionary (Editor › Natural Languages › Spelling).": "报告注释、字符串字面量和声明引入的名称(camelCase 和 snake_case 的各部分分别检查)以及纯文本和 Markdown 中拼写错误的单词。单词可以保存到项目词典或应用程序词典(编辑器 › 自然语言 › 拼写)。",
"English (USA)": "英语(美国)",
"Bundled Hunspell dictionary (SCOWL en_US) and common programming terms.": "捆绑的 Hunspell 词典 (SCOWL en_US) 和常用编程术语。",
"Used by Proofreading › Typo": "由“校对 › 拼写错误”使用",
"Accepted words": "已接受的单词",
"Project dictionary": "项目词典",
"Stored with the project.": "随项目存储。",
"No words": "没有单词",
"Word": "单词",
"Application dictionary": "应用程序词典",
"Accepted in every project.": "在所有项目中接受。",
"Custom dictionaries": "自定义词典",
"Dictionary files": "词典文件",
"Plain-text word lists (one word per line) or Hunspell .dic files. Relative paths resolve against the project root, ~/ against the home folder.": "纯文本单词列表(每行一个单词)或 Hunspell .dic 文件。相对路径相对于项目根目录解析,~/ 相对于主文件夹解析。",
"No custom dictionaries": "没有自定义词典",
"Dictionary path": "词典路径",
"Natural Languages": "自然语言",
"Java Class › JUnit 5 Test": "Java 类 › JUnit 5 测试",
"Kotlin Class/File › JUnit 5 Test": "Kotlin 类/文件 › JUnit 5 测试",
"Go File › Test file": "Go 文件 › 测试文件",
"Rust File › Test module": "Rust 文件 › 测试模块",
"TypeScript File › Test": "TypeScript 文件 › 测试",
"Python File › Test (pytest)": "Python 文件 › 测试 (pytest)",
"CSS File": "CSS 文件",
"SCSS File": "SCSS 文件",
"Sass File": "Sass 文件",
"Less File": "Less 文件",
"JSON file": "JSON 文件",
"YAML file": "YAML 文件",
"TOML file": "TOML 文件",
"Bash script": "Bash 脚本",
"POSIX sh script": "POSIX sh 脚本",
"Zsh script": "Zsh 脚本",
"Default properties": "默认属性",
"Root only": "仅根",
".gitignore file": ".gitignore 文件",
"Includes": "包含",
"Template kinds": "模板类型",
"Reformat according to style": "按照样式重新格式化",
"New Include": "新建包含",
"Add Include": "添加包含",
"Remove Include": "移除包含",
"Include name": "包含名称",
"Include body": "包含正文",
"Code templates": "代码模板",
"Used by": "用于",
"Code template body": "代码模板正文",
"Changelist": "变更列表",
"No TODO items found in this scope": "在此范围内未找到 TODO 项",
"No TODO items in changed files": "已更改的文件中没有 TODO 项",
"Author": "作者",
"Merge options": "合并选项",
"Pull": "拉取",
"No remotes are configured. Add one with Git › Manage Remotes….": "未配置远程。请使用 Git › 管理远程… 添加一个。",
"Remote branch": "远程分支",
"Uncommitted changes are stashed before the pull and restored afterwards.": "拉取前会储藏未提交的更改,之后再恢复。",
"Branch to merge": "要合并的分支",
"Select branch": "选择分支",
"-m (commit message)": "-m(提交消息)",
"Upstream": "上游",
"New base": "新基",
"Uncommitted changes are stashed before the rebase and restored afterwards.": "变基前会储藏未提交的更改,之后再恢复。",
"Enter a remote name": "请输入远程名称",
"Invalid remote name": "远程名称无效",
"Remotes": "远程",
"Enter the remote URL": "请输入远程 URL",
"Force Remove": "强制移除",
"(bare)": "(裸)",
"Choose what to check out": "选择要签出的内容",
"Enter a folder": "请输入文件夹",
"Check out": "签出",
"New branch name": "新分支名称",
"Folder": "文件夹",
"VCS Widget": "VCS 微件",
"Recent Changes": "最近的更改",
"Cannot read Local History": "无法读取本地历史记录",
"No recent changes": "没有最近的更改",
"Local History has no entries yet": "本地历史记录中还没有条目",
"Customize Main Toolbar": "自定义主工具栏",
"Customize Main Toolbar…": "自定义主工具栏…",
"Left group": "左侧组",
"Right group": "右侧组",
"Add…": "添加…",
"Move to Right": "移到右侧",
"Move to Left": "移到左侧",
"Widget": "微件",
"Actions and widgets to add": "要添加的操作和微件",
"Don't Show Again": "不再显示",
"Customize Toolbar…": "自定义工具栏…",
"Restore Default Toolbar": "恢复默认工具栏",
"View Mode": "视图模式",
"Value must not be empty": "值不能为空",
"Nothing to choose from": "没有可选择的内容",
"Project Widget": "项目微件",
"Editor Color Scheme": "编辑器配色方案",
"Quick Switch Scheme…": "快速切换方案…",
"No saved layouts. Use Save Current Layout as… first.": "没有已保存的布局。请先使用“将当前布局另存为…”。",
"Enter a layout name": "请输入布局名称",
"A layout with this name exists": "已存在同名的布局",
"Save Current Layout as…": "将当前布局另存为…",
"Save Current Layout as": "将当前布局另存为",
"Layout name:": "布局名称:",
"Restore Tool Window Layout": "恢复工具窗口布局",
"Restores the saved layout named `arg`, or asks which one.": "恢复名为 `arg` 的已保存布局,或询问要恢复哪一个。",
"Restore Layout": "恢复布局",
"Rename Tool Window Layout…": "重命名工具窗口布局…",
"Rename Layout": "重命名布局",
"Delete Tool Window Layout…": "删除工具窗口布局…",
"Delete Layout": "删除布局",
"Formatted {count} lines": "已格式化 {count} 行",
"Formatted {count} line": "已格式化 {count} 行",
" the Reformat dialog": "重新格式化对话框",
"The file changed while formatting; try again": "格式化期间文件已更改,请重试",
"No lines changed: content is already properly formatted": "没有更改任何行:内容已正确格式化",
"The file is read-only": "文件为只读",
"No documentation found": "未找到文档",
"Cannot read the definition's file": "无法读取定义所在的文件",
"Cannot find declaration to go to": "找不到要转到的声明",
"No structure information for this file": "此文件没有结构信息",
"No super method found": "未找到超方法",
"No super class found": "未找到超类",
"No parameters": "无形参",
"No usages found in file": "文件中未找到用法",
"No type information found": "未找到类型信息",
"Nothing to rename": "没有可重命名的内容",
"The file changed; rename cancelled": "文件已更改,重命名已取消",
"No suggestions": "无建议",
"No intentions available": "没有可用的意图操作",
"The document changed; invoke the refactoring again": "文档已更改,请重新调用重构",
"The document changed; the name was not applied": "文档已更改,名称未应用",
"The file changed; Safe Delete cancelled": "文件已更改,安全删除已取消",
"No language server is available": "没有可用的语言服务器",
"Separate variables with semicolon: VAR=value; VAR1=value1": "使用分号分隔变量:VAR=value; VAR1=value1",
"Relative to the project root; separate with semicolon": "相对于项目根目录;使用分号分隔",
"Configurations of the compound": "复合配置中的配置",
"Another configuration has this name": "已有其他配置使用此名称",
"Invalid directory": "目录无效",
"Application": "应用程序",
"Java Single File": "Java 单文件",
"Rust Single File": "Rust 单文件",
"Kotlin Single File": "Kotlin 单文件",
"C/C++ Single File": "C/C++ 单文件",
"Native Executable": "原生可执行文件",
"Kotlin Script": "Kotlin 脚本",
"{name} is required": "{name} 为必填项",
"{name} project": "{name} 项目",
"{adapter} (no launch template)": "{adapter}(无启动模板)",
"Before launch command {n}": "启动前命令 {n}",
"Invalid environment variable name: {key}": "环境变量名称无效:{key}",
"Run '{name}'": "运行 '{name}'",
"'{name}' failed": "'{name}' 失败",
"'{name}' was not started": "'{name}' 未启动",
"External tool '{name}'": "外部工具 '{name}'",
"Activate '{name}' tool window": "激活 '{name}' 工具窗口",
"Configuration '{name}' no longer exists": "配置 '{name}' 已不存在",
"External tool '{name}' no longer exists": "外部工具 '{name}' 已不存在",
"'{name}' was not run": "'{name}' 未运行",
"Tool window '{name}' is not available": "工具窗口 '{name}' 不可用",
"Unknown before launch task '{name}'": "未知的启动前任务 '{name}'",
"Variable {n} name": "变量 {n} 名称",
"Variable {n} value": "变量 {n} 值",
"Duplicate environment variable: {key}": "环境变量重复:{key}",
"Nothing to show in {view}": "{view}中没有可显示的内容",
"Stopped Containers": "已停止的容器",
"Unused Images": "未使用的镜像",
"Unused Volumes": "未使用的卷",
"Unused Networks": "未使用的网络",
"Unused Containers": "未使用的容器",
"Everything Unused (System Prune)": "所有未使用的对象(系统清理)",
"Remove all stopped containers?": "移除所有已停止的容器?",
"Remove all dangling (untagged) images?": "移除所有悬空(无标签)镜像?",
"Remove all images not used by a container?": "移除所有未被容器使用的镜像?",
"Remove all anonymous volumes not used by a container? Their data is lost.": "移除所有未被容器使用的匿名卷?其数据将丢失。",
"Remove all networks not used by a container?": "移除所有未被容器使用的网络?",
"Remove stopped containers, unused networks, dangling images and the build cache?": "移除已停止的容器、未使用的网络、悬空镜像和构建缓存?",
"Prune {target}": "清理{target}",
"Prune {target}…": "清理{target}…",
"Pruning {target}…": "正在清理 {target}…",
"Pruning {target}": "清理 {target}",
"Pulling {name}…": "正在拉取 {name}…",
"Pulling {name}": "拉取 {name}",
"containers": "容器",
"images": "镜像",
"volumes": "卷",
"networks": "网络",
"unused images": "未使用的镜像",
"Compose project '{name}'": "Compose 项目 '{name}'",
"Engine {server} · CLI {client}": "引擎 {server} · CLI {client}",
" · context {name}": " · 上下文 {name}",
"Failed (exit {code})": "失败(退出代码 {code})",
"No configurations of this type": "没有此类型的配置",
"system": "系统",
"{name} cancelled": "{name}已取消",
"Image to pull:": "要拉取的镜像:",
"Pull Image": "拉取镜像",
"Not a {name} URL": "不是 {name} URL",
"Duplicate scope name '{name}'": "范围名称 '{name}' 重复",
"Scope '{name}' has no pattern": "范围 '{name}' 没有模式",
"Unnamed ({n})": "未命名 ({n})",
"Project will be created in: {path}": "项目将创建在:{path}",
"(or": "(或",
"inserts a template from the Includes or Code tab.": "插入“包含”或“代码”标签页中的模板。",
"File templates insert this with {code}, indented like the directive; a directive line whose include is empty is dropped. The file template variables apply.": "文件模板通过 {code} 插入此内容,缩进与该指令一致;所含内容为空的指令行将被删除。文件模板变量同样适用。",
"Inserted where a file template says {code}.": "插入到文件模板中写有 {code} 的位置。"
}
+62
Переглянути файл
@@ -268,6 +268,14 @@
"menu.vcs.stash": "贮藏更改…",
"menu.vcs.unstash": "取消贮藏更改…",
"menu.vcs.update": "更新项目…",
"menu.vcs.pull": "拉取…",
"menu.vcs.merge": "合并…",
"menu.vcs.rebase": "变基…",
"menu.vcs.newWorktree": "新建工作树…",
"menu.vcs.worktrees": "工作树…",
"menu.vcs.manageRemotes": "管理远程…",
"menu.vcs.clone": "克隆…",
"menu.vcs.operations": "VCS 操作弹出窗口…",
"menu.view": "视图",
"menu.view.notifications": "通知",
"menu.view.toggleTheme": "切换亮色/暗色主题",
@@ -318,5 +326,59 @@
"menu.github.createPullRequest": "创建拉取请求…",
"menu.github.checkoutPullRequest": "签出拉取请求…",
"menu.github.openInBrowser": "在 GitHub 上打开",
"menu.file.newProject": "新建项目…",
"menu.file.renameProject": "重命名项目…",
"menu.file.reloadAllFromDisk": "从磁盘全部重新加载",
"menu.file.invalidateCaches": "清除缓存…",
"menu.manageIdeSettings": "管理 IDE 设置",
"menu.file.exportSettings": "导出设置…",
"menu.file.importSettings": "导入设置…",
"menu.file.togglePowerSave": "省电模式",
"menu.macros": "宏",
"menu.macro.startRecording": "开始录制宏",
"menu.macro.stopRecording": "停止录制宏",
"menu.macro.playBack": "回放上一个宏",
"menu.macro.playSaved": "播放已保存的宏…",
"menu.macro.edit": "编辑宏…",
"menu.bookmarks": "书签",
"menu.navigate.toggleBookmarkWithMnemonic": "使用助记符切换书签",
"menu.navigate.nextBookmark": "下一个书签",
"menu.navigate.prevBookmark": "上一个书签",
"menu.view.toggleCompactMode": "紧凑模式",
"menu.view.zoomIn": "放大 IDE",
"menu.view.zoomOut": "缩小 IDE",
"menu.view.resetZoom": "重置 IDE 缩放",
"menu.toolbar.customize": "自定义主工具栏…",
"menu.code.quickDefinition": "快速定义",
"menu.code.quickTypeDefinition": "快速类型定义",
"menu.localHistory.recentChanges": "最近的更改",
"menu.diff.compareWithClipboard": "与剪贴板比较",
"menu.view.quickSwitchScheme": "快速切换方案…",
"menu.editor.resetFontSize": "重置所有编辑器中的字体大小",
"menu.code.cleanup": "代码清理…",
"menu.runAnything.show": "运行任何内容…",
"menu.localHistory.putLabel": "添加标签…",
"menu.toolWindowLayouts": "工具窗口布局",
"menu.layout.save": "将当前布局另存为…",
"menu.layout.restoreDefault": "恢复默认布局",
"menu.layout.rename": "重命名布局…",
"menu.layout.delete": "删除布局…",
"menu.activeToolWindow": "活动工具窗口",
"menu.toolwindow.hideActive": "隐藏活动工具窗口",
"menu.toolwindow.maximize": "最大化工具窗口",
"menu.toolwindow.closeContent": "关闭活动标签页",
"menu.toolwindow.jumpToLast": "跳转到上一个工具窗口",
"menu.toolwindow.focusEditor": "聚焦编辑器",
"menu.editorTabs": "编辑器标签页",
"menu.editor.nextTab": "选择下一个标签页",
"menu.editor.prevTab": "选择上一个标签页",
"menu.editor.pinTab": "固定标签页",
"menu.editor.closeTab": "关闭标签页",
"menu.editor.closeOtherTabs": "关闭其他标签页",
"menu.editor.closeAllTabs": "关闭所有标签页",
"menu.editor.reopenClosedTab": "重新打开已关闭的标签页",
"menu.editor.moveToOppositeGroup": "移动到另一组",
"run.services.configurationCount": "{count} 个配置",
"search.bookmarks.count": "{count} 个书签",
"settings.zoom.hint": "使用 {zoomIn} 或 {zoomOut} 更改,使用 {reset} 重置为 100%"
}
+27
Переглянути файл
@@ -138,3 +138,30 @@ export function inFileSet(set: ProjectFileSet, path: string, roots: readonly Roo
if (set === "all") return true;
return isTestPath(roots, rel) === (set === "test");
}
/** A module: the project itself or a folder with a build file roots were detected from. */
export interface ProjectModule {
name: string;
/** Project-relative, `/`-separated; `""` is the project root. */
path: string;
}
/** Modules of the project, root first, then by path. */
export function projectModules(detected: readonly RootFolder[], projectName: string): ProjectModule[] {
const dirs = new Set([""]);
for (const r of detected) {
if (!r.detectedFrom) continue;
const i = r.detectedFrom.lastIndexOf("/");
dirs.add(i < 0 ? "" : r.detectedFrom.slice(0, i));
}
return [...dirs]
.sort((a, b) => (a === "" ? -1 : b === "" ? 1 : a < b ? -1 : a > b ? 1 : 0))
.map((path) => ({ path, name: path ? path.slice(path.lastIndexOf("/") + 1) : projectName }));
}
/** Modules of the open project (reactive). */
export function useProjectModules(): ProjectModule[] {
const detected = useDetectedRoots();
const name = useProject((s) => s.project?.name ?? "");
return useMemo(() => projectModules(detected, name), [detected, name]);
}
+71
Переглянути файл
@@ -0,0 +1,71 @@
import { beforeEach, describe, expect, it } from "vitest";
import { useProject } from "./project";
import { projectModules } from "./projectRoots";
import {
contextAccepts,
customScopes,
findCustomScope,
rememberSearch,
saveCustomScopes,
searchHistory,
useCustomScopes,
} from "./search";
const openProject = (root: string | null) =>
useProject.setState({
project: root ? ({ root, name: "p", isGit: false } as never) : null,
});
describe("shared search pieces", () => {
beforeEach(() => {
localStorage.clear();
useCustomScopes.setState({ root: null, scopes: [] });
openProject(null);
});
it("search context accepts matches like the backend", () => {
expect(contextAccepts("any", "comment")).toBe(true);
expect(contextAccepts("inComments", "comment")).toBe(true);
expect(contextAccepts("inComments", null)).toBe(false);
expect(contextAccepts("exceptStringLiterals", "comment")).toBe(true);
expect(contextAccepts("exceptStringLiterals", "string")).toBe(false);
expect(contextAccepts("exceptCommentsAndStringLiterals", null)).toBe(true);
});
it("history is shared, newest first and unique", () => {
rememberSearch("find", "a");
rememberSearch("find", "b\nc");
rememberSearch("find", "a");
expect(searchHistory("find")).toEqual(["a", "b\nc"]);
});
it("custom scopes are saved per project", () => {
openProject("/p1");
saveCustomScopes([
{ name: " Sources ", pattern: " file:src//* " },
{ name: "", pattern: "x" },
]);
expect(customScopes()).toEqual([{ name: "Sources", pattern: "file:src//*" }]);
openProject("/p2");
expect(customScopes()).toEqual([]);
openProject("/p1");
expect(findCustomScope("Sources")?.pattern).toBe("file:src//*");
});
it("modules are the project and the folders of detected build files", () => {
const modules = projectModules(
[
{ path: "web/src", kind: "source", detectedFrom: "web/package.json" },
{ path: "src", kind: "source", detectedFrom: "Cargo.toml" },
{ path: "crates/a/src", kind: "source", detectedFrom: "crates/a/Cargo.toml" },
{ path: "gen", kind: "excluded", detectedFrom: null },
],
"app",
);
expect(modules).toEqual([
{ path: "", name: "app" },
{ path: "crates/a", name: "a" },
{ path: "web", name: "web" },
]);
});
});
+149
Переглянути файл
@@ -0,0 +1,149 @@
/**
* Search pieces shared by Find / Replace in the editor and Find in Files:
* the search context filter, Preserve Case, the recent-search history and the
* user's custom (named) scopes, persisted per project.
*/
import { useEffect } from "react";
import { create } from "zustand";
import type { SearchContext } from "./ipc";
import { useProject } from "./project";
export type { SearchContext };
/** IDEA's search context filter: anywhere, only / except comments and string literals. */
export const SEARCH_CONTEXTS: ReadonlyArray<{ value: SearchContext; label: string }> = [
{ value: "any", label: "Anywhere" },
{ value: "inComments", label: "In Comments" },
{ value: "inStringLiterals", label: "In String Literals" },
{ value: "exceptComments", label: "Except Comments" },
{ value: "exceptStringLiterals", label: "Except String Literals" },
{ value: "exceptCommentsAndStringLiterals", label: "Except Comments and String Literals" },
];
export type LiteralKind = "comment" | "string";
/** Whether a match starting in `kind` (`null`: plain code) is kept (as the backend's `SearchContext::accepts`). */
export function contextAccepts(context: SearchContext, kind: LiteralKind | null): boolean {
switch (context) {
case "any":
return true;
case "inComments":
return kind === "comment";
case "inStringLiterals":
return kind === "string";
case "exceptComments":
return kind !== "comment";
case "exceptStringLiterals":
return kind !== "string";
case "exceptCommentsAndStringLiterals":
return kind === null;
}
}
/** IDEA's "Preserve case" (same rules as the backend's `preserve_case`). */
export function preserveCase(found: string, replacement: string): string {
const letters = [...found].filter((c) => c.toLowerCase() !== c.toUpperCase());
const first = letters[0];
if (!first || !replacement) return replacement;
const isUpper = (c: string) => c === c.toUpperCase() && c !== c.toLowerCase();
if (letters.length > 1 && letters.every(isUpper)) return replacement.toUpperCase();
const [head = "", ...rest] = [...replacement];
return (isUpper(first) ? head.toUpperCase() : head.toLowerCase()) + rest.join("");
}
function load(key: string): unknown {
try {
const raw = globalThis.localStorage?.getItem(key);
return raw ? JSON.parse(raw) : null;
} catch {
return null;
}
}
function save(key: string, value: unknown) {
try {
globalThis.localStorage?.setItem(key, JSON.stringify(value));
} catch {
/* storage unavailable */
}
}
const HISTORY_KEYS = { find: "openidea.search.findHistory.v1", replace: "openidea.search.replaceHistory.v1" };
const MAX_HISTORY = 20;
/** Recent search (or replace) strings, newest first; shared by the find bar and Find in Files. */
export function searchHistory(kind: "find" | "replace"): string[] {
const v = load(HISTORY_KEYS[kind]);
return Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : [];
}
export function rememberSearch(kind: "find" | "replace", text: string) {
if (!text.trim()) return;
save(HISTORY_KEYS[kind], [text, ...searchHistory(kind).filter((x) => x !== text)].slice(0, MAX_HISTORY));
}
/**
* A user-defined scope: a name and an IDEA-style pattern
* (`file:src//*.ts&&!file:*test*`), matched by the backend (`SearchScope::Pattern`).
*/
export interface CustomScope {
name: string;
pattern: string;
}
interface CustomScopesState {
/** Project the scopes belong to. */
root: string | null;
scopes: CustomScope[];
}
export const useCustomScopes = create<CustomScopesState>(() => ({ root: null, scopes: [] }));
const scopesKey = (root: string) => `openidea.scopes.v1:${root}`;
function validScopes(v: unknown): CustomScope[] {
if (!Array.isArray(v)) return [];
return v.flatMap((x) => {
const s = x as Partial<CustomScope> | null;
return s && typeof s.name === "string" && typeof s.pattern === "string" && s.name.trim()
? [{ name: s.name, pattern: s.pattern }]
: [];
});
}
/** Custom scopes of the open project (loaded on first use after the project changes). */
export function customScopes(): CustomScope[] {
const root = useProject.getState().project?.root ?? null;
const s = useCustomScopes.getState();
if (s.root === root) return s.scopes;
const scopes = root ? validScopes(load(scopesKey(root))) : [];
useCustomScopes.setState({ root, scopes });
return scopes;
}
/** Reactive {@link customScopes}. */
export function useCustomScopeList(): CustomScope[] {
const root = useProject((p) => p.project?.root ?? null);
const state = useCustomScopes();
const stale = state.root !== root;
useEffect(() => {
if (stale) customScopes();
}, [stale]);
return stale ? NO_SCOPES : state.scopes;
}
const NO_SCOPES: CustomScope[] = [];
export function saveCustomScopes(scopes: CustomScope[]) {
const root = useProject.getState().project?.root ?? null;
const clean = validScopes(scopes).map((s) => ({ name: s.name.trim(), pattern: s.pattern.trim() }));
useCustomScopes.setState({ root, scopes: clean });
if (root) save(scopesKey(root), clean);
}
export function findCustomScope(name: string): CustomScope | null {
return customScopes().find((s) => s.name === name) ?? null;
}
/** Action that opens the scope editor (registered by the search feature); `arg`: `{ select?: string }`. */
export const EDIT_SCOPES_ACTION = "search.editScopes";