дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 19:25:54 +02:00
feat: Third pass aligning core experience with IntelliJ IDEA
Project & files - Project view modes (Project Files / Open Files / All Changed Files / Scratches); External Libraries for Python, JDK (src.zip), Node, Cargo, Go, Maven/Gradle jars; read-only jar/zip browsing - ⌘N New popup with type search and more file kinds; New Project, Rename Project, Reload All from Disk, Export/Import Settings, Power Save Mode - File type associations, extra content roots and exclude patterns (indexed and watched without reopening the project) Editor & navigation - Find bar: search scope (comments/strings), Preserve Case, Exclude, multiline, history; Find in Files: Module scope, custom pattern scopes, multiline - Search Everywhere filters, Assign Shortcut, settings results; named bookmark lists, file/folder bookmarks; Structure filters and Show Inherited; Recent Locations timestamps and breadcrumbs; Code Vision author - Completion options, editor tab placement/layout, inline completion (LSP 3.18) - Spell checking (Typo inspection, ide-spell) and Inspections settings with custom profiles; Code Style schemes, formatter markers, Do not format Run, VCS, services, database - Typed run configurations (Shell, Program, npm, Gradle, Maven, Docker, Compound), Modify options, Before launch, env dialog, Current File, Emulate terminal (PTY) - Git log graph/view options, Go to Hash/Branch/Tag, date filter keeps the graph; Pull/Merge/Rebase dialogs, Manage Remotes, Worktrees - Generic Services host (Docker connections/registries, run sessions); TODO scope/changes tabs with changelists; Coverage report import - Data sources: wire-compatible vendors, URL import, SSL certs, Options, Schemas and Advanced tabs, color and comment Workbench - Run Anything, Customize Main Toolbar, named tool window layouts, menu completeness, Code Cleanup, macros, "Don't Show Again", editor banners - Chinese translations for the new UI Claude-Session: https://claude.ai/code/session_01SaPPsVPuk6ZTNsoUCNCbMv
Цей коміт міститься в:
1 джерело
294198a727
коміт
1e00ea5ee2
426 files changed
+85241
-2886
No files matched your search
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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()];
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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"],
|
||||
};
|
||||
@@ -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"],
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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"],
|
||||
};
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
@@ -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} 的位置。"
|
||||
}
|
||||
@@ -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%"
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
Reference in new issue
Заблокувати користувача