feat: Third pass aligning core experience with IntelliJ IDEA

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

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

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

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

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

No files matched your search

+85 -4
Переглянути файл
@@ -8,12 +8,15 @@ import {
type StructuralFile,
type StructuralLanguage,
} from "@/platform/ipc";
import { basename } from "@/platform/project";
import { Checkbox, Dialog, Input, Select, Spinner, T } from "@/ui";
import { executeAction } from "@/platform/actions";
import { activeProfileId, listProfiles, validateInspections, type ProfileId } from "@/platform/inspections";
import { basename, useProject } from "@/platform/project";
import { Button, Checkbox, Dialog, Input, Select, Spinner, T } from "@/ui";
import { ScopeFields, openDialog } from "./dialogs";
import { locateDuplicates, loadDuplicateOptions } from "./duplicates";
import { runInspection } from "./inspect";
import { defaultScope, type ScopeChoice } from "./scope";
import { runCodeCleanup, type CleanupOptions } from "./cleanup";
import { currentFile, defaultScope, type ScopeChoice } from "./scope";
import {
applyStructuralReplace,
buildReplaceEdit,
@@ -28,9 +31,21 @@ import styles from "./Analysis.module.css";
function InspectCodeDialog({ onClose }: { onClose: () => void }) {
const [scope, setScope] = useState<ScopeChoice>(() => defaultScope());
const [profile, setProfile] = useState<ProfileId>(activeProfileId);
const hasProject = useProject((s) => !!s.project);
const settings = useProject((s) => s.settings);
const profiles = useMemo(
() =>
listProfiles(validateInspections(settings?.inspections), hasProject).map((p) => ({
value: p.id,
label: p.label,
detail: p.level === "project" ? "Project" : "IDE",
})),
[hasProject, settings],
);
const ok = () => {
onClose();
void runInspection(scope);
void runInspection(scope, profile);
};
return (
<Dialog
@@ -45,6 +60,24 @@ function InspectCodeDialog({ onClose }: { onClose: () => void }) {
>
<div className={styles.form}>
<ScopeFields name="inspect-scope" value={scope} onChange={setScope} />
<div className={styles.fieldRow}>
<span>
<T>Inspection profile:</T>
</span>
<Select
className={styles.grow}
aria-label="Inspection profile"
value={profile}
options={profiles}
onChange={setProfile}
/>
<Button
aria-label="Configure inspections"
onClick={() => void executeAction("file.settings", "editor.inspections")}
>
…
</Button>
</div>
<div className={styles.hint}>
<T>Files are checked by their language servers; files without one are skipped.</T>
</div>
@@ -53,6 +86,53 @@ function InspectCodeDialog({ onClose }: { onClose: () => void }) {
);
}
const CLEANUP_SCOPES = ["project", "directory", "file", "changed"] as const;
function CodeCleanupDialog({ onClose }: { onClose: () => void }) {
const [scope, setScope] = useState<ScopeChoice>(() => defaultScope(currentFile() ? "file" : "project"));
const [options, setOptions] = useState<CleanupOptions>({ fixAll: true, optimizeImports: true, reformat: true });
const any = options.fixAll || options.optimizeImports || options.reformat;
const ok = () => {
if (!any) return;
onClose();
void runCodeCleanup(scope, options);
};
return (
<Dialog
open
title="Run Code Cleanup"
onClose={onClose}
width={440}
buttons={[
{ label: "OK", role: "default", disabled: !any, onClick: ok },
{ label: "Cancel", role: "cancel" },
]}
>
<div className={styles.form}>
<ScopeFields name="cleanup-scope" value={scope} onChange={setScope} kinds={CLEANUP_SCOPES} />
<Checkbox
checked={options.fixAll}
onChange={(fixAll) => setOptions({ ...options, fixAll })}
label="Apply the language servers' automatic fixes"
/>
<Checkbox
checked={options.optimizeImports}
onChange={(optimizeImports) => setOptions({ ...options, optimizeImports })}
label="Optimize imports"
/>
<Checkbox
checked={options.reformat}
onChange={(reformat) => setOptions({ ...options, reformat })}
label="Reformat code"
/>
<div className={styles.hint}>
<T>Files are cleaned up by their language servers and saved; files without one are skipped.</T>
</div>
</div>
</Dialog>
);
}
function DuplicatesDialog({ onClose }: { onClose: () => void }) {
const [scope, setScope] = useState<ScopeChoice>(() => defaultScope());
const [options, setOptions] = useState<DuplicateOptions>(loadDuplicateOptions);
@@ -303,6 +383,7 @@ function ReplacePreviewDialog({ files, onClose }: { files: StructuralFile[]; onC
export const showInspectCodeDialog = () => openDialog((close) => <InspectCodeDialog onClose={close} />);
export const showDuplicatesDialog = () => openDialog((close) => <DuplicatesDialog onClose={close} />);
export const showCodeCleanupDialog = () => openDialog((close) => <CodeCleanupDialog onClose={close} />);
export const showStructuralDialog = (replace: boolean) =>
openDialog((close) => <StructuralDialog replace={replace} onClose={close} />);
export const showReplacePreview = (files: StructuralFile[]) =>
+8
Переглянути файл
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import type { Diagnostic, StructuralFile } from "@/platform/ipc";
import { useCustomScopes } from "@/platform/search";
import { fragmentLines } from "./duplicates";
import { groupInspection, kindOf } from "./inspect";
import { buildInspectionNodes } from "./InspectionView";
@@ -149,6 +150,13 @@ describe("analysis scopes and fragments", () => {
expect(await resolveScope({ kind: "directory", directory: "" })).toBe("Choose a directory");
expect(await resolveScope({ kind: "file", directory: "" })).toBe("No file is open");
expect(await resolveScope({ kind: "changed", directory: "" })).toBe("There are no uncommitted files");
expect(await resolveScope({ kind: "custom", directory: "" })).toBe("Choose a scope");
useCustomScopes.setState({ root: null, scopes: [{ name: "Src", pattern: "file:src//*" }] });
expect(await resolveScope({ kind: "custom", directory: "", custom: "Src" })).toEqual({
kind: "pattern",
pattern: "file:src//*",
});
useCustomScopes.setState({ root: null, scopes: [] });
});
it("numbers fragment lines from the fragment's first line", () => {
+136
Переглянути файл
@@ -0,0 +1,136 @@
/**
* Code › Code Cleanup…: the language servers' fix-all, organize imports and
* reformat over a scope, one file after another (opened, cleaned, saved; tabs
* opened only for the cleanup are closed again). Cancellable from the
* Background Tasks popup.
*/
import { executeAction } from "@/platform/actions";
import { call, commands, isTauri, type FileEntry } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { maybeEditorService, notifications } from "@/workbench/contributions";
import { confirm } from "@/workbench/messages";
import { removeTask, upsertTask } from "@/workbench/tasks";
import { changedFiles, currentFile, type ScopeChoice } from "./scope";
export interface CleanupOptions {
fixAll: boolean;
optimizeImports: boolean;
reformat: boolean;
}
/** Above this many files the user confirms first. */
const CONFIRM_ABOVE = 100;
const extOf = (path: string) => {
const name = path.slice(path.lastIndexOf("/") + 1);
const i = name.lastIndexOf(".");
return i > 0 ? name.slice(i + 1).toLowerCase() : "";
};
/** Extensions of languages that have a language server (the only files a cleanup can change). */
async function serverExtensions(): Promise<Set<string>> {
const out = new Set<string>();
for (const l of await commands.languages())
if (l.languageServers?.length) for (const e of l.extensions) out.add(e.replace(/^\./, "").toLowerCase());
return out;
}
/** Files under `dir`, skipping ignored, excluded and hidden entries and symlinks. */
export async function walkFiles(
dir: string,
readDir: (path: string) => Promise<FileEntry[]>,
accept: (path: string) => boolean,
limit = 5000,
): Promise<string[]> {
const out: string[] = [];
const queue = [dir];
while (queue.length && out.length < limit) {
let entries: FileEntry[];
try {
entries = await readDir(queue.shift()!);
} catch {
continue;
}
for (const e of entries) {
if (e.ignored || e.excluded || e.hidden || e.kind === "symlink") continue;
if (e.kind === "dir") queue.push(e.path);
else if (accept(e.path)) out.push(e.path);
}
}
return out.sort();
}
/** The files of `choice` a cleanup applies to, or a message why there are none. */
export async function cleanupFiles(choice: ScopeChoice): Promise<string[] | string> {
const root = useProject.getState().project?.root;
if (!root) return "No project is open";
const exts = await serverExtensions();
const accept = (p: string) => exts.has(extOf(p));
const readDir = (p: string) => call(commands.fsReadDir(p));
let files: string[];
switch (choice.kind) {
case "file": {
const f = currentFile();
files = f ? [f] : [];
break;
}
case "changed":
files = (await changedFiles()).filter(accept);
break;
case "directory":
files = choice.directory.trim() ? await walkFiles(choice.directory.trim(), readDir, accept) : [];
break;
default:
files = await walkFiles(root, readDir, accept);
}
return files.length ? files : "No files with a language server in this scope";
}
let running: { cancelled: boolean } | null = null;
export async function runCodeCleanup(choice: ScopeChoice, options: CleanupOptions): Promise<void> {
if (!isTauri() || running) return;
const files = await cleanupFiles(choice);
if (typeof files === "string") {
notifications().notify({ group: "analysis", level: "warning", title: "Code Cleanup", body: files, key: "code.cleanup" });
return;
}
if (
files.length > CONFIRM_ABOVE &&
!(await confirm({ title: "Code Cleanup", message: `Clean up ${files.length} files?`, ok: "Clean Up" }))
)
return;
const editors = maybeEditorService();
const wasOpen = new Set(editors?.openPaths?.() ?? []);
const run: { cancelled: boolean } = { cancelled: false };
running = run;
const id = "code.cleanup";
let done = 0;
try {
for (const path of files) {
if (run.cancelled) break;
upsertTask({
id,
title: "Code Cleanup",
message: path.slice(path.lastIndexOf("/") + 1),
fraction: done / files.length,
done: false,
cancel: () => (run.cancelled = true),
});
await executeAction("code.cleanupFile", { path, ...options });
await editors?.saveAll();
if (!wasOpen.has(path)) editors?.closePath?.(path);
done++;
}
} finally {
running = null;
upsertTask({ id, title: "Code Cleanup", message: null, fraction: 1, done: true });
if (run.cancelled) removeTask(id);
}
notifications().notify({
group: "analysis",
level: "info",
title: run.cancelled ? `Code Cleanup cancelled after ${done} of ${files.length} files` : `Code Cleanup processed ${done} files`,
key: "code.cleanup",
});
}
+29 -4
Переглянути файл
@@ -1,8 +1,10 @@
/** Dialog stack of the analysis feature and the shared "Specify Scope" fields. */
import { Fragment, type ReactNode } from "react";
import { create } from "zustand";
import { executeAction } from "@/platform/actions";
import { isTauri } from "@/platform/ipc";
import { Button, Input, T } from "@/ui";
import { EDIT_SCOPES_ACTION, useCustomScopeList } from "@/platform/search";
import { Button, IconButton, Input, Select, T } from "@/ui";
import { SCOPE_LABELS, currentFile, type ScopeChoice, type ScopeKind } from "./scope";
import styles from "./Analysis.module.css";
@@ -42,32 +44,38 @@ async function browseDirectory(initial: string): Promise<string | null> {
return typeof picked === "string" ? picked : null;
}
const KINDS: ScopeKind[] = ["project", "directory", "file", "changed"];
const KINDS: ScopeKind[] = ["project", "directory", "file", "changed", "custom"];
/** IDEA's "Specify Scope" radio group. */
export function ScopeFields({
value,
onChange,
name,
kinds = KINDS,
}: {
value: ScopeChoice;
onChange: (v: ScopeChoice) => void;
name: string;
/** The choices offered (default: all). */
kinds?: readonly ScopeKind[];
}) {
const file = currentFile();
const scopes = useCustomScopeList();
return (
<fieldset className={styles.scope}>
<legend className={styles.fieldLabel}>
<T>Scope</T>
</legend>
{KINDS.map((kind) => (
{kinds.map((kind) => (
<label key={kind} className={styles.radioRow}>
<input
type="radio"
name={name}
checked={value.kind === kind}
disabled={kind === "file" && !file}
onChange={() => onChange({ ...value, kind })}
onChange={() =>
onChange({ ...value, kind, custom: kind === "custom" ? (value.custom ?? scopes[0]?.name) : value.custom })
}
/>
<span>{kind === "file" && file ? `File '${file.split("/").pop()}'` : SCOPE_LABELS[kind]}</span>
</label>
@@ -87,6 +95,23 @@ export function ScopeFields({
</Button>
</div>
)}
{value.kind === "custom" && (
<div className={styles.dirRow}>
<Select<string>
className={styles.grow}
value={value.custom ?? null}
options={scopes.map((s) => ({ value: s.name, label: s.name, detail: s.pattern }))}
placeholder="No custom scopes"
onChange={(custom) => onChange({ ...value, custom })}
aria-label="Custom scope"
/>
<IconButton
icon="more-horizontal"
title="Edit Scopes…"
onClick={() => void executeAction(EDIT_SCOPES_ACTION, value.custom ? { select: value.custom } : undefined)}
/>
</div>
)}
</fieldset>
);
}
+9 -1
Переглянути файл
@@ -5,7 +5,7 @@
import { registerActions } from "@/platform/actions";
import { useProject } from "@/platform/project";
import { registerOverlay, registerToolWindow } from "@/workbench/contributions";
import { showDuplicatesDialog, showInspectCodeDialog, showStructuralDialog } from "./AnalysisDialogs";
import { showCodeCleanupDialog, showDuplicatesDialog, showInspectCodeDialog, showStructuralDialog } from "./AnalysisDialogs";
import { AnalysisDialogHost } from "./dialogs";
import { resetDuplicates } from "./duplicates";
import { DuplicatesHeaderActions, DuplicatesView } from "./DuplicatesView";
@@ -51,6 +51,14 @@ export function activate(): () => void {
keywords: ["inspection", "analyze", "diagnostics", "problems"],
run: () => void showInspectCodeDialog(),
},
{
id: "code.cleanup",
title: "Code Cleanup…",
category: "Code",
when: "projectOpen",
keywords: ["fix all", "cleanup", "organize imports", "reformat"],
run: () => void showCodeCleanupDialog(),
},
{
id: "analyze.locateDuplicates",
title: "Locate Duplicates…",
+28 -6
Переглянути файл
@@ -14,6 +14,14 @@ import {
type InspectSummary,
type RequestId,
} from "@/platform/ipc";
import {
activeProfileId,
applyProfileMixed,
noteDiagnosticCodes,
producerOf,
profileOverrides,
type ProfileId,
} from "@/platform/inspections";
import { notifications, toolWindowService } from "@/workbench/contributions";
import { describeScope, resolveScope, type ScopeChoice } from "./scope";
@@ -99,6 +107,8 @@ interface InspectState {
error: string | null;
/** Scope of the last run (Rerun). */
last: ScopeChoice | null;
/** Inspection profile of the last run. */
profile: ProfileId | null;
/** Problems excluded from the results (⌫), by `problemId`. */
excluded: ReadonlySet<string>;
}
@@ -112,6 +122,7 @@ const initialInspection = (): InspectState => ({
scopeLabel: null,
error: null,
last: null,
profile: null,
excluded: new Set(),
});
@@ -144,7 +155,8 @@ export function resetInspection() {
useInspection.setState(initialInspection());
}
export async function runInspection(choice: ScopeChoice): Promise<void> {
/** Inspect Code over `choice` with `profileId` (default: the profile in effect). */
export async function runInspection(choice: ScopeChoice, profileId: ProfileId = activeProfileId()): Promise<void> {
const scope = await resolveScope(choice);
if (typeof scope === "string") {
notifications().notify({ group: "analysis", level: "warning", title: "Inspect Code", body: scope });
@@ -162,8 +174,10 @@ export async function runInspection(choice: ScopeChoice): Promise<void> {
error: null,
scopeLabel: describeScope(choice, scope),
last: choice,
profile: profileId,
excluded: new Set(),
});
const profile = profileOverrides(profileId);
toolWindowService().show("inspection");
if (!isTauri()) {
useInspection.setState({ running: false, error: "Inspect Code needs the desktop app" });
@@ -182,15 +196,23 @@ export async function runInspection(choice: ScopeChoice): Promise<void> {
case "started":
useInspection.setState({ total: ev.total });
break;
case "file":
if (ev.file.diagnostics.length) found.set(ev.file.path, ev.file);
case "file": {
const diagnostics = applyProfileMixed(profile, ev.file.diagnostics);
for (const d of ev.file.diagnostics) if (d.code) noteDiagnosticCodes(producerOf(d), [d]);
if (diagnostics.length) found.set(ev.file.path, { ...ev.file, diagnostics });
useInspection.setState({ done: ev.done });
flush ??= setTimeout(publish, 150);
break;
}
case "done":
if (flush) clearTimeout(flush);
inflight = null;
useInspection.setState({ running: false, files: new Map(found), summary: ev.summary });
useInspection.setState({
running: false,
files: new Map(found),
// Problems the profile kept, not all the servers reported.
summary: { ...ev.summary, problems: [...found.values()].reduce((n, f) => n + f.diagnostics.length, 0) },
});
break;
}
};
@@ -205,6 +227,6 @@ export async function runInspection(choice: ScopeChoice): Promise<void> {
}
export function rerunInspection() {
const last = useInspection.getState().last;
if (last) void runInspection(last);
const { last, profile } = useInspection.getState();
if (last) void runInspection(last, profile ?? undefined);
}
+11 -1
Переглянути файл
@@ -5,13 +5,16 @@
import { activeEditor } from "@/editor/api";
import { call, commands, isTauri, type SearchScope } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { findCustomScope } from "@/platform/search";
export type ScopeKind = "project" | "directory" | "file" | "changed";
export type ScopeKind = "project" | "directory" | "file" | "changed" | "custom";
export interface ScopeChoice {
kind: ScopeKind;
/** Absolute directory for `directory`. */
directory: string;
/** Name of the custom scope for `custom` (`@/platform/search`). */
custom?: string;
}
export const SCOPE_LABELS: Record<ScopeKind, string> = {
@@ -19,6 +22,7 @@ export const SCOPE_LABELS: Record<ScopeKind, string> = {
directory: "Module or directory",
file: "Current file",
changed: "Uncommitted files",
custom: "Custom scope",
};
/** The file of the active editor, if it is a real file. */
@@ -66,6 +70,10 @@ export async function resolveScope(choice: ScopeChoice): Promise<SearchScope | s
}
return paths.length ? { kind: "files", paths } : "There are no uncommitted files";
}
case "custom": {
const scope = choice.custom ? findCustomScope(choice.custom) : null;
return scope ? { kind: "pattern", pattern: scope.pattern } : "Choose a scope";
}
}
}
@@ -84,5 +92,7 @@ export function describeScope(choice: ScopeChoice, scope: SearchScope): string {
return scope.kind === "files"
? `${scope.paths.length} uncommitted file${scope.paths.length === 1 ? "" : "s"}`
: "Uncommitted files";
case "custom":
return `Scope '${choice.custom ?? ""}'`;
}
}
+146
Переглянути файл
@@ -0,0 +1,146 @@
/**
* File › New Project…: an empty project folder (no framework templates),
* optionally a Git repository, opened in a new window (this one when it has
* no project).
*/
import { useEffect, useState } from "react";
import { create } from "zustand";
import { call, commands, isTauri } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { useTxTemplate } from "@/platform/i18n";
import { Button, Checkbox, Dialog, Input, T } from "@/ui";
import { defaultProjectDirectory, openProjectInWindow } from "./windows";
import styles from "./app.module.css";
const useNewProject = create<{ open: boolean }>(() => ({ open: false }));
export const showNewProject = () => useNewProject.setState({ open: true });
export function NewProjectOverlay() {
const open = useNewProject((s) => s.open);
return open ? <NewProjectDialog onClose={() => useNewProject.setState({ open: false })} /> : null;
}
/** Why `name` cannot be a project folder name, or null. */
export function projectNameError(name: string): string | null {
const n = name.trim();
if (!n) return "Enter a project name";
if (/[/\\:*?"<>|]/.test(n) || n === "." || n === "..") return "The name contains characters a folder name cannot have";
return null;
}
export function joinPath(dir: string, name: string): string {
const sep = dir.includes("\\") && !dir.includes("/") ? "\\" : "/";
return dir.endsWith("/") || dir.endsWith("\\") ? `${dir}${name.trim()}` : `${dir}${sep}${name.trim()}`;
}
function errorText(e: unknown) {
return e instanceof Error ? e.message : String(e);
}
/** Creates `path` (or reuses it when it is an empty folder) and runs `git init` when asked. */
export async function createProjectFolder(path: string, gitInit: boolean): Promise<void> {
let entries: unknown[] | null;
try {
entries = await call(commands.fsReadDir(path));
} catch {
entries = null;
}
if (entries && entries.length) throw new Error(`${path} already exists and is not empty`);
if (!entries) await call(commands.fsCreateDir(path));
if (gitInit) await call(commands.vcsInit(path));
}
/** Default Project Directory, else the parent of the open project. */
function initialLocation(): string {
const preset = defaultProjectDirectory();
if (preset) return preset;
const root = useProject.getState().project?.root;
return root ? root.slice(0, Math.max(root.lastIndexOf("/"), 1)) : "";
}
function NewProjectDialog({ onClose }: { onClose: () => void }) {
const [name, setName] = useState("untitled");
const [location, setLocation] = useState(initialLocation);
const [git, setGit] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const txTemplate = useTxTemplate();
useEffect(() => {
if (initialLocation()) return;
// Otherwise the user's home.
void import("@tauri-apps/api/path")
.then((p) => p.homeDir())
.then(setLocation)
.catch(() => {});
}, []);
const nameError = projectNameError(name);
const invalid = !!nameError || !location.trim();
const path = location.trim() && !nameError ? joinPath(location.trim(), name) : "";
const browse = async () => {
if (!isTauri()) return;
const { open } = await import("@tauri-apps/plugin-dialog");
const picked = await open({ directory: true, multiple: false, title: "Project Location", defaultPath: location || undefined });
const dir = Array.isArray(picked) ? picked[0] : picked;
if (dir) setLocation(dir);
};
const create = async () => {
if (invalid || busy) return;
setBusy(true);
setError(null);
try {
await createProjectFolder(path, git);
} catch (e) {
setBusy(false);
setError(errorText(e));
return;
}
onClose();
await openProjectInWindow(path, useProject.getState().project !== null);
};
return (
<Dialog
open
title="New Project"
onClose={onClose}
width={520}
buttons={[
{ label: "Create", role: "default", disabled: invalid || busy, onClick: () => void create() },
{ label: "Cancel", role: "cancel" },
]}
>
<form
className={styles.newProject}
onSubmit={(e) => {
e.preventDefault();
void create();
}}
>
<label className={styles.newProjectRow}>
<span className={styles.newProjectLabel}>
<T>Name:</T>
</span>
<Input data-autofocus value={name} invalid={!!nameError} onChange={(e) => setName(e.target.value)} aria-label="Name" />
</label>
<div className={styles.newProjectRow}>
<span className={styles.newProjectLabel}>
<T>Location:</T>
</span>
<Input value={location} onChange={(e) => setLocation(e.target.value)} aria-label="Location" />
<Button onClick={() => void browse()} disabled={!isTauri()}>
<T>Browse…</T>
</Button>
</div>
<div className={styles.newProjectHint}>
{nameError ? txTemplate(nameError) : path && txTemplate(`Project will be created in: ${path}`)}
</div>
<Checkbox checked={git} onChange={setGit} label="Create Git repository" />
{error && <div className={styles.newProjectError}>{txTemplate(error)}</div>}
</form>
</Dialog>
);
}
+59 -1
Переглянути файл
@@ -3,7 +3,65 @@ import { registerAction } from "@/platform/actions";
import { _resetI18nForTests, setLocale } from "@/platform/i18n";
import { useKeymap } from "@/platform/keymap";
import { resetDistractionFree, setDistractionFree } from "@/workbench/distractionFree";
import { buildMenuConfig, menuShortcut, routeMenuEvent } from "../menuBridge";
import { registerToolWindow } from "@/workbench/contributions";
import { _reloadLayouts, saveLayout } from "@/workbench/toolwindows/layouts";
import { setContext } from "@/platform/context";
import {
buildMenuConfig,
disabledMenuActions,
dynamicMenuItems,
menuActionIds,
menuShortcut,
routeMenuEvent,
} from "../menuBridge";
describe("menu state from the frontend", () => {
it("lists registered tool windows by title and saved layouts", () => {
localStorage.clear();
_reloadLayouts();
const offs = [
registerToolWindow({ id: "zeta", title: "Terminal", icon: "terminal", anchor: "bottom", component: () => null }),
registerToolWindow({ id: "alpha", title: "Problems", icon: "problems", anchor: "bottom", component: () => null }),
];
saveLayout("Review");
const dyn = dynamicMenuItems();
expect(dyn.toolWindows!.map((i) => [i.action, i.label])).toEqual([
["toolwindow.alpha", "Problems"],
["toolwindow.zeta", "Terminal"],
]);
expect(dyn.toolWindowLayouts).toEqual([{ action: "layout.restore", arg: "Review", label: "Review" }]);
expect(buildMenuConfig("macos").dynamic).toEqual(dyn);
offs.forEach((d) => d());
localStorage.clear();
_reloadLayouts();
});
it("disables the registered actions that cannot run now", () => {
const offs = [
registerAction({ id: "test.menu.always", title: "A", run: () => {} }),
registerAction({ id: "test.menu.whenEditor", title: "B", when: "testMenuEditor", run: () => {} }),
];
const ids = menuActionIds([
{
kind: "submenu",
id: "x",
label: "X",
children: [
{ kind: "action", id: "test.menu.whenEditor", label: "B", hint: null, arg: null },
{ kind: "action", id: "test.menu.always", label: "A", hint: null, arg: null },
],
},
{ kind: "action", id: "not.registered", label: "C", hint: null, arg: null },
]);
expect([...ids].sort()).toEqual(["not.registered", "test.menu.always", "test.menu.whenEditor"]);
// Unregistered ids stay enabled: the action may register later (lazy features).
expect(disabledMenuActions(ids)).toEqual(["test.menu.whenEditor"]);
setContext("testMenuEditor", true);
expect(disabledMenuActions(ids)).toEqual([]);
setContext("testMenuEditor", false);
offs.forEach((d) => d());
});
});
afterEach(() => {
_resetI18nForTests();
+36
Переглянути файл
@@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import { joinPath, projectNameError } from "../NewProject";
import { exportedStorage } from "../projectActions";
describe("File › New Project…", () => {
it("validates the folder name", () => {
expect(projectNameError("demo")).toBeNull();
expect(projectNameError(" ")).toBe("Enter a project name");
expect(projectNameError("a/b")).not.toBeNull();
expect(projectNameError("..")).not.toBeNull();
});
it("joins the location and name with the location's separator", () => {
expect(joinPath("/Users/me/Projects", " demo ")).toBe("/Users/me/Projects/demo");
expect(joinPath("/Users/me/", "demo")).toBe("/Users/me/demo");
expect(joinPath("C:\\Users\\me", "demo")).toBe("C:\\Users\\me\\demo");
});
});
describe("Manage IDE Settings › Export Settings…", () => {
it("exports templates, macros and layouts, not per-project state", () => {
const data: Record<string, string> = {
"openidea.liveTemplates.v1": "[]",
"openidea.macros.v1": "{}",
"openidea.toolWindowLayouts.v1": "{}",
"openidea.runAnything.history.v1:/p": "[]",
};
const keys = Object.keys(data);
const storage = { length: keys.length, key: (i: number) => keys[i] ?? null, getItem: (k: string) => data[k] ?? null };
expect(Object.keys(exportedStorage(storage)).sort()).toEqual([
"openidea.liveTemplates.v1",
"openidea.macros.v1",
"openidea.toolWindowLayouts.v1",
]);
});
});
+34
Переглянути файл
@@ -180,3 +180,37 @@
margin: 4px 0 0;
color: var(--fg-weak-warning);
}
/* File › New Project… */
.newProject {
display: flex;
flex-direction: column;
gap: 10px;
padding: 16px;
}
.newProjectRow {
display: flex;
align-items: center;
gap: 8px;
}
.newProjectRow > :nth-child(2) {
flex: 1;
min-width: 0;
}
.newProjectLabel {
flex: none;
width: 72px;
color: var(--fg);
}
.newProjectHint {
min-height: 1.5em;
padding-left: 80px;
color: var(--fg-subtle);
font-size: var(--fs-small);
overflow-wrap: anywhere;
}
.newProjectError {
color: var(--danger);
font-size: var(--fs-small);
overflow-wrap: anywhere;
}
+5
Переглянути файл
@@ -11,6 +11,8 @@ import { buildInfo } from "./host";
import { KeymapReferenceOverlay } from "./KeymapReference";
import { installLocaleSync, registerLocalizedActions } from "./locale";
import { installMenuBridge } from "./menuBridge";
import { NewProjectOverlay } from "./NewProject";
import { projectActions, registerPowerSaveBanner } from "./projectActions";
import { installWindowTitle } from "./title";
import { installLaunchHandling, windowActions } from "./windows";
@@ -24,6 +26,9 @@ export function activate(): () => void {
registerOverlay({ id: "app.about", component: AboutOverlay }),
registerOverlay({ id: "app.keymapReference", component: KeymapReferenceOverlay }),
registerOverlay({ id: "app.invalidateCaches", component: InvalidateCachesOverlay }),
registerOverlay({ id: "app.newProject", component: NewProjectOverlay }),
registerLocalizedActions(projectActions),
registerPowerSaveBanner(),
registerInvalidateCachesActions(),
installMenuBridge(),
installWindowTitle(),
+75 -6
Переглянути файл
@@ -7,12 +7,15 @@
* shortcut text of every keymap binding, so the menu shows the JS keymap's
* shortcuts without registering native accelerators (see src-tauri/src/menu.rs).
*/
import { executeAction } from "@/platform/actions";
import { keysWithPrefix, t, useI18n } from "@/platform/i18n";
import { commands, isTauri, type MenuConfig } from "@/platform/ipc";
import { executeAction, getAction, isActionEnabled, useActions } from "@/platform/actions";
import { useContextKeys } from "@/platform/context";
import { keysWithPrefix, t, tx, useI18n } from "@/platform/i18n";
import { commands, isTauri, type DynamicItem, type MenuConfig, type MenuNode } from "@/platform/ipc";
import { bindingsFor, effectiveBindings, formatBinding, useKeymap } from "@/platform/keymap";
import { useProject } from "@/platform/project";
import { useContributions } from "@/workbench/contributions";
import { isDistractionFree, useDistractionFree } from "@/workbench/distractionFree";
import { useToolWindowLayouts } from "@/workbench/toolwindows/layouts";
import { hostOs, type HostOs } from "./host";
export const MENU_EVENT = "openidea://menu";
@@ -48,7 +51,37 @@ export function buildMenuConfig(os: HostOs): MenuConfig {
const first = bindingsFor(id)[0];
if (first) shortcuts[id] = menuShortcut(first, os);
}
return { locale: useI18n.getState().locale, labels, shortcuts };
return { locale: useI18n.getState().locale, labels, shortcuts, dynamic: dynamicMenuItems() };
}
/** View › Tool Windows (every registered window, by title) and Window › Tool Window Layouts. */
export function dynamicMenuItems(): Record<string, DynamicItem[]> {
const toolWindows = [...useContributions.getState().toolWindows.values()]
.map((tw) => ({ action: `toolwindow.${tw.id}`, arg: null, label: tx(tw.title) ?? tw.title }))
.sort((a, b) => a.label.localeCompare(b.label));
const layouts = useToolWindowLayouts
.getState()
.layouts.map((l) => ({ action: "layout.restore", arg: l.name, label: l.name }));
return { toolWindows, toolWindowLayouts: layouts };
}
/** Action ids of every IDE item in a menu model. */
export function menuActionIds(nodes: readonly MenuNode[], out = new Set<string>()): Set<string> {
for (const n of nodes) {
if (n.kind === "submenu") menuActionIds(n.children, out);
else if (n.kind === "action") out.add(n.id);
}
return out;
}
/** The registered ones among `ids` that cannot run in the current context. */
export function disabledMenuActions(ids: Iterable<string>): string[] {
const out: string[] = [];
for (const id of ids) {
const action = getAction(id);
if (action && !isActionEnabled(action)) out.push(id);
}
return out.sort();
}
export function installMenuBridge(): () => void {
@@ -73,9 +106,38 @@ export function installMenuBridge(): () => void {
else unlisten = off;
});
// Enablement of the native items follows this window's context (IDEA greys out
// what cannot run). Recomputed shortly after context changes, sent only when it changed.
let ids: Set<string> | null = null;
let lastDisabled = "";
let enableTimer: ReturnType<typeof setTimeout> | undefined;
const pushEnablement = (force = false) => {
clearTimeout(enableTimer);
enableTimer = setTimeout(() => {
if (!ids || disposed || !document.hasFocus()) return;
const dynamic = Object.values(dynamicMenuItems()).flatMap((items) => items.map((d) => d.action));
const disabled = disabledMenuActions(new Set([...ids, ...dynamic]));
const key = disabled.join(",");
if (!force && key === lastDisabled) return;
lastDisabled = key;
void commands.menuSetDisabled(disabled);
}, 120);
};
void commands
.menuModel()
.then((model) => {
ids = menuActionIds(model);
pushEnablement(true);
})
.catch(() => {});
push();
const onFocus = () => {
push();
pushEnablement(true);
};
// The native menu bar is shared: a focused window re-pushes its own window-scoped labels.
window.addEventListener("focus", push);
window.addEventListener("focus", onFocus);
const unsubs = [
useI18n.subscribe((s, prev) => {
if (s.messages !== prev.messages) push();
@@ -84,6 +146,12 @@ export function installMenuBridge(): () => void {
useDistractionFree.subscribe((s, prev) => {
if (s.active !== prev.active) push();
}),
useContributions.subscribe((s, prev) => {
if (s.toolWindows !== prev.toolWindows) push();
}),
useToolWindowLayouts.subscribe(push),
useContextKeys.subscribe(() => pushEnablement()),
useActions.subscribe(() => pushEnablement()),
// Opening / closing a project changes the Open Recent list.
useProject.subscribe((s, prev) => {
if (s.project?.root !== prev.project?.root) void commands.menuRefresh();
@@ -92,7 +160,8 @@ export function installMenuBridge(): () => void {
return () => {
disposed = true;
clearTimeout(timer);
window.removeEventListener("focus", push);
clearTimeout(enableTimer);
window.removeEventListener("focus", onFocus);
unlisten?.();
unsubs.forEach((u) => u());
};
+182
Переглянути файл
@@ -0,0 +1,182 @@
/**
* File menu actions around the project and the IDE settings: New Project…,
* Rename Project…, Manage IDE Settings › Export / Import Settings… and Power
* Save Mode (with its editor banner).
*/
import { executeAction, type Action } from "@/platform/actions";
import { isPowerSaveMode } from "@/platform/editorSettings";
import { call, commands, isTauri } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { notifications, registerEditorBanner } from "@/workbench/contributions";
import { promptInput } from "@/workbench/inputDialogs";
import { showNewProject } from "./NewProject";
/** localStorage prefixes of IDE-level state exported with the settings (templates, macros, layouts). */
export const EXPORTED_STORAGE = [
"openidea.liveTemplates.",
"openidea.fileTemplates.",
"openidea.postfix.",
"openidea.macros.",
"openidea.toolWindowLayouts.",
];
export function exportedStorage(storage: Pick<Storage, "length" | "key" | "getItem"> = localStorage): Record<string, string> {
const out: Record<string, string> = {};
for (let i = 0; i < storage.length; i++) {
const key = storage.key(i);
if (!key || !EXPORTED_STORAGE.some((p) => key.startsWith(p))) continue;
const value = storage.getItem(key);
if (value !== null) out[key] = value;
}
return out;
}
function errorText(e: unknown) {
return e instanceof Error ? e.message : String(e);
}
async function exportSettings() {
const { save } = await import("@tauri-apps/plugin-dialog");
const path = await save({
title: "Export Settings",
defaultPath: "openidea-settings.json",
filters: [{ name: "OpenIDEA settings", extensions: ["json"] }],
});
if (!path) return;
try {
await call(commands.settingsExport(path, exportedStorage()));
notifications().notify({ level: "info", title: "Settings exported", body: path, key: "app.settingsExport" });
} catch (e) {
notifications().notify({ level: "error", title: "Cannot export settings", body: errorText(e), key: "app.settingsExport" });
}
}
async function importSettings() {
const { open } = await import("@tauri-apps/plugin-dialog");
const picked = await open({
title: "Import Settings",
multiple: false,
filters: [{ name: "OpenIDEA settings", extensions: ["json"] }],
});
const path = Array.isArray(picked) ? picked[0] : picked;
if (!path) return;
try {
const result = await call(commands.settingsImport(path));
for (const [key, value] of Object.entries(result.storage))
if (EXPORTED_STORAGE.some((p) => key.startsWith(p)))
try {
localStorage.setItem(key, value);
} catch {
/* storage full */
}
useProject.setState({ settings: result.settings });
notifications().notify({
level: "info",
title: "Settings imported",
body: "Restart to apply imported templates, macros and layouts.",
key: "app.settingsImport",
sticky: true,
actions: [{ label: "Restart", run: () => void executeAction("app.restart") }],
});
} catch (e) {
notifications().notify({ level: "error", title: "Cannot import settings", body: errorText(e), key: "app.settingsImport" });
}
}
async function renameProject() {
const project = useProject.getState().project;
if (!project) return;
const folder = project.root.slice(project.root.replace(/[\\/]+$/, "").search(/[^\\/]*$/));
const name = await promptInput({
title: "Rename Project",
label: "New project name:",
initial: project.name,
okLabel: "Rename",
validate: () => null,
hint: `Leave empty to use the folder name (${folder}).`,
});
if (name === null) return;
try {
const info = await call(commands.projectRename(name));
useProject.setState((s) => ({ project: s.project && { ...s.project, name: info.name }, settings: info.settings }));
void commands.menuRefresh();
} catch (e) {
notifications().notify({ level: "error", title: "Cannot rename project", body: errorText(e) });
}
}
async function togglePowerSave() {
const on = !isPowerSaveMode();
try {
useProject.setState({
settings: await call(commands.settingsSet("global", "general.powerSaveMode", JSON.stringify(on))),
});
} catch (e) {
notifications().notify({ level: "error", title: "Cannot change Power Save Mode", body: errorText(e) });
}
}
const desktop = (run: () => Promise<void>) => () => {
if (isTauri()) return run();
notifications().notify({ level: "warning", title: "This needs the desktop app" });
};
export function projectActions(): Action[] {
return [
{
id: "file.newProject",
title: "New Project…",
category: "File",
icon: "folder-open",
keywords: ["create project", "empty project", "git init"],
run: desktop(async () => showNewProject()),
},
{
id: "file.renameProject",
title: "Rename Project…",
category: "File",
keywords: ["project name"],
when: "projectOpen",
run: desktop(renameProject),
},
{
id: "file.exportSettings",
title: "Export Settings…",
category: "File",
keywords: ["backup settings", "keymap", "templates", "manage ide settings"],
run: desktop(exportSettings),
},
{
id: "file.importSettings",
title: "Import Settings…",
category: "File",
keywords: ["restore settings", "keymap", "templates", "manage ide settings"],
run: desktop(importSettings),
},
{
id: "file.togglePowerSave",
title: "Power Save Mode",
category: "File",
keywords: ["battery", "performance", "code insight"],
toggled: () => isPowerSaveMode(),
run: togglePowerSave,
},
];
}
/** The banner above every editor while Power Save Mode is on. */
export function registerPowerSaveBanner(): () => void {
return registerEditorBanner({
id: "app.powerSave",
order: 0,
useBanner: () => {
const on = useProject((s) => isPowerSaveMode(s.settings));
return on
? {
text: "Power save mode is on: completion and documentation popups and usage highlighting are off.",
actions: [{ label: "Turn Off", run: () => void togglePowerSave() }],
}
: null;
},
});
}
+4 -24
Переглянути файл
@@ -8,16 +8,9 @@ import { useMemo, useState, type KeyboardEvent } from "react";
import clsx from "clsx";
import type { BuildTool } from "@/platform/ipc";
import { HighlightedText, Input, List, Popup, Select, listNavigate, T } from "@/ui";
import {
goalCompletions,
gradleTaskConfig,
mavenGoalConfig,
parseGoalLine,
parseTaskLine,
taskCompletions,
type Completion,
} from "./model";
import { pushHistory, readHistory, runConfig, useBuild } from "./store";
import { goalCompletions, taskCompletions, type Completion } from "./model";
import { executeBuildLine } from "./runAnything";
import { readHistory, useBuild } from "./store";
import styles from "./BuildTools.module.css";
export function ExecutePopupHost() {
@@ -58,20 +51,7 @@ function ExecutePopup({ tool }: { tool: BuildTool }) {
const execute = () => {
const text = line.trim();
if (!text) return;
if (tool === "maven" && maven) {
const p = maven.projects.find((x) => x.pom === project);
const goals = parseGoalLine(text);
if (!p || !goals.length) return;
runConfig(mavenGoalConfig(p, goals, maven, useBuild.getState().settings.maven), "run");
} else if (gradle) {
const p = gradle.projects.find((x) => x.path === project);
const tasks = parseTaskLine(text);
if (!p || !tasks.length) return;
runConfig(gradleTaskConfig(gradle, p, tasks, useBuild.getState().settings.gradle), "run");
}
pushHistory(tool, text);
close();
if (text && executeBuildLine(tool, text, project)) close();
};
const accept = (c: Completion | undefined) => {
+3 -1
Переглянути файл
@@ -7,9 +7,10 @@ import { registerActions } from "@/platform/actions";
import { setContext } from "@/platform/context";
import { onIdeEvent, type BuildModel, type BuildTool } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { registerOverlay, registerToolWindow } from "@/workbench/contributions";
import { registerOverlay, registerRunAnythingProvider, registerToolWindow } from "@/workbench/contributions";
import { GradleView, MavenView } from "./BuildToolView";
import { ExecutePopupHost } from "./ExecutePopup";
import { buildToolsRunAnything } from "./runAnything";
import { expandableKeys, gradleChildren, gradleRoots, mavenChildren, mavenRoots } from "./model";
import {
cancelSync,
@@ -83,6 +84,7 @@ export function activate(): () => void {
};
const disposers: Array<() => void> = [
registerOverlay({ id: "build-tools.execute", component: ExecutePopupHost }),
registerRunAnythingProvider(buildToolsRunAnything),
useBuild.subscribe((s, prev) => {
if (s.model !== prev.model || s.root !== prev.root) apply(s.root ? s.model : null, registered);
}),
+97
Переглянути файл
@@ -0,0 +1,97 @@
/**
* Maven / Gradle command lines: what Execute Maven Goal / Execute Gradle Task
* run, and the Run Anything provider for `mvn …` / `gradle …` lines (with goal
* and task completion in the selected project).
*/
import type { BuildTool } from "@/platform/ipc";
import type { RunAnythingItem, RunAnythingProvider } from "@/workbench/contributions";
import {
goalCompletions,
gradleTaskConfig,
mavenGoalConfig,
parseGoalLine,
parseTaskLine,
taskCompletions,
} from "./model";
import { pushHistory, readHistory, runConfig, useBuild } from "./store";
const COMMANDS: Record<BuildTool, RegExp> = {
maven: /^(\.\/)?mvnw?(\.cmd)?(\s|$)/,
gradle: /^(\.\/)?gradlew?(\.bat)?(\s|$)/,
};
const WORD: Record<BuildTool, string> = { maven: "mvn", gradle: "gradle" };
/** The build tool a Run Anything line is for (`mvn clean`, `./gradlew build`), when the project has it. */
export function lineTool(line: string): BuildTool | null {
const model = useBuild.getState().model;
if (model?.maven && COMMANDS.maven.test(line)) return "maven";
if (model?.gradle && COMMANDS.gradle.test(line)) return "gradle";
return null;
}
/** The project a line runs in: the given one, else the tool window's current one, else the first. */
function projectFor(tool: BuildTool, key?: string | null) {
const { model, current } = useBuild.getState();
if (tool === "maven") {
const projects = model?.maven?.projects ?? [];
return projects.find((p) => p.pom === (key ?? current.maven)) ?? projects[0];
}
const projects = model?.gradle?.projects ?? [];
return projects.find((p) => p.path === (key ?? current.gradle)) ?? projects[0];
}
/** Runs goals / tasks typed for `tool` in a project; false when there is nothing to run. */
export function executeBuildLine(tool: BuildTool, text: string, projectKey?: string | null): boolean {
const { model, settings } = useBuild.getState();
if (tool === "maven") {
const maven = model?.maven;
const project = projectFor("maven", projectKey);
const goals = parseGoalLine(text);
if (!maven || !project || !("pom" in project) || !goals.length) return false;
runConfig(mavenGoalConfig(project, goals, maven, settings.maven), "run");
} else {
const gradle = model?.gradle;
const project = projectFor("gradle", projectKey);
const tasks = parseTaskLine(text);
if (!gradle || !project || "pom" in project || !tasks.length) return false;
runConfig(gradleTaskConfig(gradle, project, tasks, settings.gradle), "run");
}
pushHistory(tool, text.replace(COMMANDS[tool], "").trim());
return true;
}
function completions(tool: BuildTool, rest: string) {
const model = useBuild.getState().model;
if (tool === "maven") {
const project = projectFor("maven");
return goalCompletions(rest, project && "pom" in project ? project.plugins : [], readHistory("maven"));
}
return model?.gradle ? taskCompletions(rest, model.gradle, readHistory("gradle")) : [];
}
export const buildToolsRunAnything: RunAnythingProvider = {
id: "build-tools",
group: "Build Tools",
order: 20,
items(query) {
const tool = lineTool(query);
if (!tool) return [];
const head = /^\S+\s*/.exec(query)![0];
return completions(tool, query.slice(head.length))
.slice(0, 50)
.map(
(c): RunAnythingItem => ({
key: `${c.detail === "recent" ? "h" : "c"}:${c.line}`,
label: `${WORD[tool]} ${c.line}`,
detail: c.detail,
icon: tool === "maven" ? "file-maven" : "file-gradle",
completion: `${WORD[tool]} ${c.detail === "recent" ? c.line : `${c.line} `}`,
run: () => void executeBuildLine(tool, c.line),
}),
);
},
execute(line) {
const tool = lineTool(line);
return !!tool && executeBuildLine(tool, line);
},
};
+3 -2
Переглянути файл
@@ -35,7 +35,7 @@ export function activate(): () => void {
headerActions: BuildHeaderActions,
useBadge: useErrorBadge,
}),
registerTitleItem({ id: "build.project", align: "right", priority: -1, component: BuildTitleItem }),
registerTitleItem({ id: "build.project", title: "Build Project", align: "right", priority: -1, component: BuildTitleItem }),
registerActions([
{
id: "build.buildProject",
@@ -45,7 +45,8 @@ export function activate(): () => void {
icon: "build-project",
when: WHEN,
keywords: ["compile", "make", "mvn", "gradle", "cargo", "tsc", "go build"],
run: () => void startBuild("build"),
// Awaitable: Run's "Build" before launch task waits for the result.
run: async () => void (await startBuild("build")),
},
{
id: "build.buildModule",
+3
Переглянути файл
@@ -30,6 +30,8 @@ import { appendOutput, finishSummary, firstError, messageFiles, type BuildStatus
export const BUILD_TASK_ID = "build:compile";
export const BUILD_RUNNING = "build.running";
/** Outcome of the last build (`success` | `failed` | `cancelled`), e.g. for Run's "Build" before launch task. */
export const BUILD_LAST_STATUS = "build.lastStatus";
/** How builds reach the backend; replaced in tests. */
export interface BuildBackend {
@@ -205,6 +207,7 @@ export async function startBuild(kind: BuildKind, file: string | null = null): P
}
} finally {
if (useBuildRun.getState().request === request) useBuildRun.setState({ request: null });
setContext(BUILD_LAST_STATUS, result?.success ? "success" : useBuildRun.getState().status === "cancelled" ? "cancelled" : "failed");
setContext(BUILD_RUNNING, false);
upsertTask({ id: BUILD_TASK_ID, title: taskTitle, message: null, fraction: 1, done: true });
}
+1 -1
Переглянути файл
@@ -225,7 +225,7 @@ export function UsagesView() {
return (
<div className={styles.view}>
<div className={styles.toolbar} role="toolbar" aria-orientation="vertical" aria-label="Usages actions">
<div className={styles.toolbar} role="toolbar" aria-orientation="vertical" aria-label={tx("Usages actions")}>
<IconButton
icon="rerun"
title={tx("Rerun Find Usages")}
+12
Переглянути файл
@@ -6,6 +6,7 @@ import {
countSeverities,
fileDiagnostics,
mergeDiagnostics,
profiledProblems,
projectCounts,
useProblems,
worstSeverity,
@@ -69,3 +70,14 @@ describe("store + decorations", () => {
expect(deco.get("/p/src/a.rs")).toEqual({ error: true, propagate: true, tooltip: "1 error, 1 warning" });
});
});
describe("profiledProblems", () => {
it("applies the inspection profile per producer", () => {
let raw = mergeDiagnostics(new Map(), "/a", "ra", [d(1), d(2, "warning")]);
raw = mergeDiagnostics(raw, "/b", "eslint", [d(3)]);
const out = profiledProblems(raw, { "lsp:eslint": { enabled: false }, "lsp:ra": { severity: "weakWarning" } });
expect([...out.keys()]).toEqual(["/a"]);
expect(fileDiagnostics(out, "/a").map((x) => x.severity)).toEqual(["info", "info"]);
expect(fileDiagnostics(profiledProblems(raw, {}), "/b")).toHaveLength(1);
});
});
+34 -5
Переглянути файл
@@ -6,6 +6,12 @@
import { create } from "zustand";
import type { Diagnostic, Severity } from "@/bindings";
import { onIdeEvent } from "@/platform/ipc";
import {
applyProfile,
inspectionProfile,
subscribeInspectionProfile,
type ProfileOverrides,
} from "@/platform/inspections";
export type FileDiagnostics = ReadonlyMap<string, readonly Diagnostic[]>;
/** path → source → diagnostics. */
@@ -89,23 +95,46 @@ export function worstSeverity(diags: Iterable<Diagnostic>): Severity | null {
return best;
}
/** `raw` under the inspection profile: disabled inspections dropped, severities remapped. */
export function profiledProblems(raw: ProblemsMap, profile: ProfileOverrides): ProblemsMap {
let out: ProblemsMap = new Map();
for (const [path, sources] of raw) {
for (const [source, items] of sources) {
out = mergeDiagnostics(out, path, source, applyProfile(profile, source, items));
}
}
return out;
}
interface ProblemsState {
/** As reported, before the inspection profile. */
raw: ProblemsMap;
/** What the views show. */
map: ProblemsMap;
}
export const useProblems = create<ProblemsState>(() => ({ map: new Map() }));
export const useProblems = create<ProblemsState>(() => ({ raw: new Map(), map: new Map() }));
export function applyDiagnosticsEvent(path: string, source: string, items: readonly Diagnostic[]) {
useProblems.setState((s) => {
const map = mergeDiagnostics(s.map, path, source, items);
return map === s.map ? s : { map };
const raw = mergeDiagnostics(s.raw, path, source, items);
if (raw === s.raw) return s;
const map = mergeDiagnostics(s.map, path, source, applyProfile(inspectionProfile(), source, items));
return { raw, map };
});
}
export function clearProblems() {
useProblems.setState({ map: new Map() });
useProblems.setState({ raw: new Map(), map: new Map() });
}
export function installProblemsEvents(): () => void {
return onIdeEvent("diagnostics", (e) => applyDiagnosticsEvent(e.path, e.source, e.items));
const off = onIdeEvent("diagnostics", (e) => applyDiagnosticsEvent(e.path, e.source, e.items));
const offProfile = subscribeInspectionProfile(() =>
useProblems.setState((s) => ({ map: profiledProblems(s.raw, inspectionProfile()) })),
);
return () => {
off();
offProfile();
};
}
+39
Переглянути файл
@@ -108,3 +108,42 @@
.link:hover {
color: var(--link-hover);
}
.links {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
}
/* Manage Coverage Reports */
.reports {
height: 260px;
margin: 12px;
border: 1px solid var(--border);
display: flex;
flex-direction: column;
}
.reportRow {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
flex: 1;
white-space: nowrap;
}
.reportDetail {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
color: var(--fg-subtle);
}
.reportTime {
flex: none;
font-size: var(--fs-small);
color: var(--fg-muted);
}
.reportsEmpty {
padding: 12px;
color: var(--fg-muted);
}
+19 -10
Переглянути файл
@@ -35,13 +35,17 @@ export function CoverageHeaderActions() {
const visible = useCoverage((s) => s.visible);
const hasSuite = useCoverage((s) => !!s.activeId);
return (
<IconButton
icon={visible ? "close" : "eye"}
size="small"
title={visible ? "Hide Coverage" : "Show Coverage"}
disabled={!hasSuite}
onClick={() => setCoverageVisible(!visible)}
/>
<>
<IconButton icon="folder-open" size="small" action="coverage.importReport" />
<IconButton icon="list" size="small" action="coverage.manageReports" />
<IconButton
icon={visible ? "close" : "eye"}
size="small"
title={visible ? "Hide Coverage" : "Show Coverage"}
disabled={!hasSuite}
onClick={() => setCoverageVisible(!visible)}
/>
</>
);
}
@@ -87,9 +91,14 @@ export function CoverageView() {
<span>
<T>Run a configuration with coverage to see which lines its tests execute.</T>
</span>
<button type="button" className={styles.link} onClick={() => void executeAction("run.coverage")}>
<T>Run with Coverage</T>
</button>
<span className={styles.links}>
<button type="button" className={styles.link} onClick={() => void executeAction("run.coverage")}>
<T>Run with Coverage</T>
</button>
<button type="button" className={styles.link} onClick={() => void executeAction("coverage.importReport")}>
<T>Import a report collected in CI from disk</T>
</button>
</span>
</EmptyState>
);
}
+21 -1
Переглянути файл
@@ -3,7 +3,7 @@ import { EditorState } from "@codemirror/state";
import type { CoverageSuite, FileCoverage, RunConfig } from "@/platform/ipc";
import { coverageMarkers } from "./gutter";
import { coverageBadges, coverageSupports, coverageTree, formatPercent, lineState, sortTree } from "./model";
import { MAX_SUITES, addSuite, closeSuite, resetCoverage, setCoverageVisible, shownFileCoverage, useCoverage } from "./store";
import { MAX_SUITES, addSuite, closeSuite, reportEntries, resetCoverage, setCoverageVisible, shownFileCoverage, useCoverage } from "./store";
function cfg(command: string, args: string[], extra: Partial<RunConfig> = {}): RunConfig {
return { id: "c", name: "c", kind: "shell", command, args, cwd: null, env: {}, detected: true, isTest: false, debugAdapter: null, ...extra };
@@ -115,4 +115,24 @@ describe("gutter and suites", () => {
expect(useCoverage.getState().activeId).toBe(s.suites[1]!.id);
resetCoverage();
});
it("replaces a re-imported report and lists remembered imports in Manage Coverage Reports", () => {
addSuite("tests", suite([file("/p/a.rs", 1, 2)]));
const first = addSuite("lcov.info", suite([file("/p/a.rs", 1, 2)]), "/ci/lcov.info");
const again = addSuite("lcov.info", suite([file("/p/a.rs", 2, 2)]), "/ci/lcov.info");
const { suites } = useCoverage.getState();
expect(suites.map((s) => s.id)).toEqual([again.id, suites[1]!.id]);
expect(suites.some((s) => s.id === first.id)).toBe(false);
expect(shownFileCoverage("/p/a.rs")?.linesCovered).toBe(2);
const entries = reportEntries(suites, [
{ path: "/ci/lcov.info", name: "lcov.info", importedAt: 1 },
{ path: "/ci/jacoco.xml", name: "jacoco.xml", importedAt: 2 },
]);
expect(entries.map((e) => [e.name, e.detail, !!e.suite])).toEqual([
["lcov.info", "/ci/lcov.info", true],
["tests", "t", true],
["jacoco.xml", "/ci/jacoco.xml", false],
]);
resetCoverage();
});
});
+23 -1
Переглянути файл
@@ -6,10 +6,11 @@
import { registerEditorContribution } from "@/editor/api";
import { executeAction, registerActions } from "@/platform/actions";
import { useProject } from "@/platform/project";
import { registerRunExecutor, registerToolWindow, toolWindowService } from "@/workbench/contributions";
import { registerOverlay, registerRunExecutor, registerToolWindow, toolWindowService } from "@/workbench/contributions";
import { CoverageHeaderActions, CoverageTabs, CoverageView } from "./CoverageView";
import { coverageExecutor } from "./executor";
import { coverageGutter } from "./gutter";
import { ManageReportsOverlay, importReport, openManageReports } from "./reports";
import { resetCoverage, setCoverageVisible, syncCoverageViews, useCoverage } from "./store";
export function activate(): () => void {
@@ -28,6 +29,7 @@ export function activate(): () => void {
}),
registerEditorContribution({ id: "coverage.gutter", order: 65, extensions: coverageGutter }),
syncCoverageViews(),
registerOverlay({ id: "coverage.manageReports", component: ManageReportsOverlay }),
registerActions([
{
id: "run.coverage",
@@ -49,6 +51,26 @@ export function activate(): () => void {
keywords: ["coverage"],
run: ({ arg }) => executeAction("run.withExecutor", { executor: "coverage", context: arg ?? true }).then(() => undefined),
},
{
id: "coverage.importReport",
title: "Import a Coverage Report…",
description: "Show a report collected elsewhere (CI): lcov, JaCoCo XML or a Go cover profile; argument: the file path",
category: "Run",
icon: "folder-open",
when: "projectOpen",
keywords: ["coverage", "lcov", "jacoco", "ci", "import"],
run: ({ arg }) => importReport(typeof arg === "string" ? arg : undefined).then(() => undefined),
},
{
id: "coverage.manageReports",
title: "Manage Coverage Reports…",
description: "Choose the shown coverage suite, delete suites and imported reports",
category: "Run",
icon: "list",
when: "projectOpen",
keywords: ["coverage", "suites", "show coverage data"],
run: () => openManageReports(),
},
{
id: "coverage.hide",
title: "Hide Coverage",
+165
Переглянути файл
@@ -0,0 +1,165 @@
/**
* Coverage reports collected elsewhere: "Import a report collected in CI from
* disk" (lcov, JaCoCo XML, Go cover profile) and "Manage Coverage Reports"
* (choose the shown suite, delete suites and remembered imports).
*/
import { useMemo, useState } from "react";
import { create } from "zustand";
import { call, commands, isTauri } from "@/platform/ipc";
import { basename } from "@/platform/project";
import { Dialog, Icon, List, T } from "@/ui";
import { notifications, toolWindowService } from "@/workbench/contributions";
import { formatPercent } from "./model";
import {
addSuite,
closeSuite,
forgetImport,
importedReports,
rememberImport,
reportEntries,
selectSuite,
useCoverage,
type ReportEntry,
type Suite,
} from "./store";
import styles from "./Coverage.module.css";
const errorText = (e: unknown) => (e instanceof Error ? e.message : String(e));
async function pickReport(): Promise<string | null> {
const { open } = await import("@tauri-apps/plugin-dialog");
const file = await open({
multiple: false,
directory: false,
title: "Import Coverage Report",
filters: [
{
name: "Coverage reports (lcov, JaCoCo XML, Go cover profile)",
extensions: ["info", "lcov", "xml", "out", "txt", "cov", "coverprofile"],
},
{ name: "All files", extensions: ["*"] },
],
});
return typeof file === "string" ? file : null;
}
/** Imports `path` (asks for a file without one) and shows it like a run's suite. */
export async function importReport(path?: string): Promise<Suite | null> {
if (!isTauri()) {
notifications().notify({ group: "run", level: "warning", title: "Importing coverage needs the desktop app" });
return null;
}
const file = path ?? (await pickReport());
if (!file) return null;
try {
const data = await call(commands.coverageImport(file));
const suite = addSuite(basename(file), data, file);
rememberImport({ path: file, name: suite.name, importedAt: suite.createdAt });
try {
toolWindowService().show("coverage");
} catch {
/* no workbench (tests) */
}
const [covered, total] = data.files.reduce((a, f) => [a[0]! + f.linesCovered, a[1]! + f.linesTotal], [0, 0]);
notifications().notify({
group: "run",
level: "info",
key: "coverage-import",
title: `Imported coverage '${suite.name}': ${formatPercent(covered!, total!)} lines`,
body: data.files.length ? undefined : "The report covers no files of this project.",
});
return suite;
} catch (e) {
notifications().notify({
group: "run",
level: "error",
key: "coverage-import",
title: `Cannot import ${basename(file)}`,
body: errorText(e),
});
return null;
}
}
const useManageDialog = create<{ open: boolean }>(() => ({ open: false }));
export const openManageReports = () => useManageDialog.setState({ open: true });
export function ManageReportsOverlay() {
const open = useManageDialog((s) => s.open);
return open ? <ManageReportsDialog onClose={() => useManageDialog.setState({ open: false })} /> : null;
}
function ManageReportsDialog({ onClose }: { onClose: () => void }) {
const suites = useCoverage((s) => s.suites);
const activeId = useCoverage((s) => s.activeId);
const [imported, setImported] = useState(() => importedReports());
const entries = useMemo(() => reportEntries(suites, imported), [suites, imported]);
const [active, setActive] = useState(() =>
Math.max(
0,
entries.findIndex((e) => e.suite?.id === activeId),
),
);
const current: ReportEntry | undefined = entries[Math.min(active, entries.length - 1)];
const show = async (entry: ReportEntry | undefined) => {
if (!entry) return;
if (entry.suite) selectSuite(entry.suite.id);
else if (entry.file && !(await importReport(entry.file))) return;
onClose();
};
const remove = (entry: ReportEntry | undefined) => {
if (!entry) return;
if (entry.suite) closeSuite(entry.suite.id);
if (entry.file) {
forgetImport(entry.file);
setImported(importedReports());
}
};
const importMore = async () => {
if (await importReport()) setImported(importedReports());
};
return (
<Dialog
open
title="Coverage Reports"
onClose={onClose}
width={560}
buttons={[
{ label: "Import…", role: "extra", onClick: () => void importMore() },
{ label: "Delete", role: "extra", disabled: !current, onClick: () => remove(current) },
{ label: "Show Selected", role: "default", disabled: !current, onClick: () => void show(current) },
{ label: "Cancel", role: "cancel" },
]}
>
<div className={styles.reports}>
<List<ReportEntry>
aria-label="Coverage reports"
items={entries}
getKey={(e) => e.key}
activeIndex={active}
onActiveChange={setActive}
onActivate={(e) => void show(e)}
renderItem={(e) => (
<span className={styles.reportRow}>
<Icon name={e.file ? "folder-open" : "run-coverage"} size={16} className={styles.icon} />
<span className={styles.label}>{e.name}</span>
<span className={styles.reportDetail}>{e.detail}</span>
<span className={styles.reportTime}>
{e.suite?.id === activeId ? <T>shown</T> : e.suite ? null : <T>not loaded</T>}{" "}
{new Date(e.time).toLocaleString()}
</span>
</span>
)}
empty={
<div className={styles.reportsEmpty}>
<T>No coverage reports. Run with coverage or import a report.</T>
</div>
}
/>
</div>
</Dialog>
);
}
+83 -3
Переглянути файл
@@ -14,9 +14,11 @@ export const MAX_SUITES = 5;
export interface Suite {
id: string;
/** Run configuration name. */
/** Run configuration name, or the imported report's file name. */
name: string;
createdAt: number;
/** The report file of an imported suite ("Import a report collected in CI"). */
file?: string;
data: CoverageSuite;
byPath: Map<string, FileCoverage>;
}
@@ -43,15 +45,21 @@ export function shownFileCoverage(path: string, s: CoverageState = useCoverage.g
return activeSuite(s)?.byPath.get(path) ?? null;
}
export function addSuite(name: string, data: CoverageSuite): Suite {
export function addSuite(name: string, data: CoverageSuite, file?: string): Suite {
const suite: Suite = {
id: `suite${++seq}`,
name,
createdAt: Date.now(),
file,
data,
byPath: new Map(data.files.map((f) => [f.path, f])),
};
useCoverage.setState((s) => ({ suites: [suite, ...s.suites].slice(0, MAX_SUITES), activeId: suite.id, visible: true }));
useCoverage.setState((s) => ({
// A re-imported file replaces its earlier suite.
suites: [suite, ...s.suites.filter((x) => !file || x.file !== file)].slice(0, MAX_SUITES),
activeId: suite.id,
visible: true,
}));
return suite;
}
@@ -97,3 +105,75 @@ export function syncCoverageViews(): () => void {
setFileDecorations("coverage", new Map());
};
}
// ---------------------------------------------------------------------------
// Imported reports, remembered per project so "Manage Coverage Reports" lists
// them after a restart.
export interface ImportedReport {
path: string;
name: string;
importedAt: number;
}
const IMPORTED_KEY = "openidea.coverage.imported.v1:";
const MAX_IMPORTED = 20;
export function importedReports(root: string | null = useProject.getState().project?.root ?? null): ImportedReport[] {
if (!root) return [];
try {
const v: unknown = JSON.parse(globalThis.localStorage?.getItem(IMPORTED_KEY + root) ?? "[]");
if (!Array.isArray(v)) return [];
return v.filter(
(r): r is ImportedReport =>
!!r && typeof r.path === "string" && typeof r.name === "string" && typeof r.importedAt === "number",
);
} catch {
return [];
}
}
function saveImported(root: string, reports: ImportedReport[]) {
try {
globalThis.localStorage?.setItem(IMPORTED_KEY + root, JSON.stringify(reports.slice(0, MAX_IMPORTED)));
} catch {
/* storage unavailable */
}
}
export function rememberImport(report: ImportedReport) {
const root = useProject.getState().project?.root;
if (root) saveImported(root, [report, ...importedReports(root).filter((r) => r.path !== report.path)]);
}
export function forgetImport(path: string) {
const root = useProject.getState().project?.root;
if (root) saveImported(root, importedReports(root).filter((r) => r.path !== path));
}
/** A row of "Manage Coverage Reports": a loaded suite, or an imported report not loaded now. */
export interface ReportEntry {
key: string;
name: string;
/** Tool or file shown beside the name. */
detail: string;
time: number;
suite: Suite | null;
file: string | null;
}
export function reportEntries(suites: readonly Suite[], imported: readonly ImportedReport[]): ReportEntry[] {
const out: ReportEntry[] = suites.map((s) => ({
key: s.id,
name: s.name,
detail: s.file ?? s.data.runner,
time: s.createdAt,
suite: s,
file: s.file ?? null,
}));
for (const r of imported) {
if (suites.some((s) => s.file === r.path)) continue;
out.push({ key: `file:${r.path}`, name: r.name, detail: r.path, time: r.importedAt, suite: null, file: r.path });
}
return out;
}
+129
Переглянути файл
@@ -245,6 +245,13 @@
gap: 6px;
min-width: 0;
}
.tinted {
flex: 1;
margin: 0 -4px;
padding: 0 4px;
border-radius: var(--radius-control, 4px);
background: color-mix(in srgb, var(--db-tint) var(--tint-strength), transparent);
}
.nodeLabel {
overflow: hidden;
text-overflow: ellipsis;
@@ -334,6 +341,128 @@
gap: 8px;
align-items: center;
}
/* Data source dialog. */
.formSection {
grid-column: 1 / -1;
display: flex;
align-items: center;
gap: 8px;
color: var(--fg-strong);
}
.formSection::after {
content: "";
flex: 1;
height: 1px;
background: var(--border);
}
.formWide {
grid-column: 1 / -1;
display: flex;
flex-direction: column;
gap: 10px;
min-width: 0;
}
.portInput {
width: 80px;
}
.secondsInput {
width: 72px;
}
.narrowSelect {
min-width: 140px;
}
.segmented {
display: inline-flex;
justify-self: start;
border-radius: var(--radius-control, 4px);
box-shadow: inset 0 0 0 1px var(--border-button);
}
.segmented button {
height: var(--h-field, 28px);
padding: 0 14px;
border: 0;
border-radius: inherit;
background: transparent;
color: var(--fg);
font: inherit;
}
.segmented button[aria-pressed="true"] {
background: var(--bg-tab-selected);
box-shadow: inset 0 0 0 1px var(--border-tab-selected);
color: var(--fg-strong);
}
.segmented button:focus-visible {
outline: 2px solid var(--focus-ring);
outline-offset: -2px;
}
.textArea {
width: 100%;
min-height: 64px;
resize: vertical;
padding: 6px 8px;
border: 1px solid var(--border-strong);
border-radius: var(--radius-control, 4px);
background: var(--bg-input);
color: var(--fg-editor, var(--fg-strong));
font-family: var(--font-mono);
font-size: var(--fs-code);
line-height: 1.5;
}
.textArea:focus {
outline: none;
border-color: var(--accent);
}
.linkButton {
justify-self: start;
padding: 0;
border: 0;
background: none;
color: var(--link);
font: inherit;
cursor: pointer;
}
.linkButton:hover {
text-decoration: underline;
}
.colorValue {
display: inline-flex;
align-items: center;
gap: 6px;
}
.colorSwatch {
width: 12px;
height: 12px;
border-radius: 3px;
}
.namePicker {
display: flex;
flex-direction: column;
gap: 6px;
}
.nameList {
display: flex;
flex-direction: column;
gap: 4px;
max-height: 180px;
overflow: auto;
padding-left: 22px;
}
.propertyTable {
display: flex;
flex-direction: column;
gap: 4px;
}
.propertyHead,
.propertyRow {
display: grid;
grid-template-columns: 1fr 1fr 24px;
align-items: center;
gap: 6px;
}
.propertyHead {
color: var(--fg-secondary);
font-size: var(--fs-small);
}
.testResult {
grid-column: 1 / -1;
font-size: var(--fs-small);
+28 -10
Переглянути файл
@@ -4,8 +4,8 @@
* with counts and column types, loaded lazily. Double-click a table to open its data, a data
* source to open its query console.
*/
import { useEffect, useMemo, useRef } from "react";
import type { DbTableDetails } from "@/platform/ipc";
import { useEffect, useMemo, useRef, type CSSProperties } from "react";
import type { DbDatabases, DbTableDetails } from "@/platform/ipc";
import { setContext } from "@/platform/context";
import { executeAction } from "@/platform/actions";
import { shortcutLabel } from "@/platform/keymap";
@@ -21,7 +21,7 @@ import {
type TreeHandle,
T,
} from "@/ui";
import { databaseKey, driverIcon, splitTarget, targetOf } from "./model";
import { colorVar, databaseKey, shownNames, sourceIcon, splitTarget, targetOf } from "./model";
import { nodeTarget, type DbNode as Node, type Group } from "./nodes";
import {
connect,
@@ -105,9 +105,20 @@ export function DatabaseView() {
return d?.status === "ok" ? d.value : null;
};
const sourceOf = (target: string) => st.sources.find((s) => s.id === splitTarget(target).id);
/** The Schemas tab's choice. */
const shownSchemas = (target: string, names: string[]) => {
const i = sourceOf(target)?.introspection;
return shownNames(names, i?.allSchemas, i?.schemas);
};
const shownDatabases = (id: string, v: DbDatabases) => {
const i = sourceOf(id)?.introspection;
return shownNames(v.names, i?.allDatabases, i?.databases, v.current);
};
const schemasOf = (key: string, id: string) =>
status(key, st.schemas[id], (v) =>
v.map((schema) => ({ kind: "schema", key: `${id}/${schema}`, id, schema }) as Node),
shownSchemas(id, v).map((schema) => ({ kind: "schema", key: `${id}/${schema}`, id, schema }) as Node),
);
const children = (n: Node): readonly Node[] | undefined => {
@@ -116,7 +127,7 @@ export function DatabaseView() {
const id = n.source.id ?? "";
if (listsDatabases(n.source))
return status(n.key, st.databases[id], (v) =>
v.names.map((name) => ({
shownDatabases(id, v).map((name) => ({
kind: "database",
key: databaseKey(id, name),
id: targetOf(id, name, v.current),
@@ -226,14 +237,14 @@ export function DatabaseView() {
const id = n.source.id ?? "";
if (listsDatabases(n.source)) {
const d = st.databases[id];
return d?.status === "ok" ? String(d.value.names.length) : undefined;
return d?.status === "ok" ? String(shownDatabases(id, d.value).length) : undefined;
}
const s = st.schemas[id];
return s?.status === "ok" ? String(s.value.length) : undefined;
return s?.status === "ok" ? String(shownSchemas(id, s.value).length) : undefined;
}
case "database": {
const s = st.schemas[n.id];
return s?.status === "ok" ? String(s.value.length) : undefined;
return s?.status === "ok" ? String(shownSchemas(n.id, s.value).length) : undefined;
}
case "folder":
return String(
@@ -259,7 +270,7 @@ export function DatabaseView() {
const icon = (n: Node): string | undefined => {
switch (n.kind) {
case "source":
return driverIcon(n.source.driver);
return sourceIcon(n.source);
case "database":
return "database";
case "schema":
@@ -334,8 +345,15 @@ export function DatabaseView() {
? styles.keyLabel
: styles.nodeLabel;
const live = n.kind === "source" && [...st.connected].some((t) => splitTarget(t).id === n.source.id);
const target = nodeTarget(n);
// IDEA tints every row of a coloured data source.
const tint = colorVar(target ? sourceOf(target)?.color : null);
return (
<span className={styles.nodeRow}>
<span
className={tint ? `${styles.nodeRow} ${styles.tinted}` : styles.nodeRow}
style={tint ? ({ "--db-tint": tint } as CSSProperties) : undefined}
title={n.kind === "source" && n.source.comment ? n.source.comment : undefined}
>
{i && (
<span className={styles.nodeIcon}>
<Icon name={i} size={16} />
+5 -395
Переглянути файл
@@ -1,404 +1,14 @@
/** Data source (add / edit / test connection) and password dialogs. */
import { Fragment, useState } from "react";
import {
call,
commands,
IpcError,
type DataSource,
type DbDriver,
type DbSsh,
type DbSshAuth,
type DbSslMode,
} from "@/platform/ipc";
import { Button, Checkbox, Dialog, Input, Select, Tabs, lazyComponent, T } from "@/ui";
import { connectRemedy, fileSourceName, newSourceId, sshLabel } from "./model";
import { closeDialog, saveSource, useDatabase } from "./store";
import { call, commands, type DataSource } from "@/platform/ipc";
import { Checkbox, Dialog, Input, lazyComponent, T } from "@/ui";
import { sshLabel } from "./model";
import { SourceDialog } from "./SourceDialog";
import { closeDialog, useDatabase } from "./store";
import styles from "./Database.module.css";
const ObjectDialogs = lazyComponent(() => import("./ObjectDialogs").then((m) => m.ObjectDialogs));
const DRIVERS: Array<{ value: DbDriver; label: string }> = [
{ value: "postgres", label: "PostgreSQL" },
{ value: "mysql", label: "MySQL / MariaDB" },
{ value: "sqlite", label: "SQLite" },
];
const SSL: Array<{ value: DbSslMode; label: string }> = [
{ value: "prefer", label: "Prefer" },
{ value: "require", label: "Require" },
{ value: "disable", label: "Disable" },
];
const SSH_AUTH: Array<{ value: DbSshAuth; label: string }> = [
{ value: "openSsh", label: "OpenSSH config and authentication agent" },
{ value: "password", label: "Password" },
{ value: "keyPair", label: "Key pair" },
];
const blankSsh = (): DbSsh => ({
enabled: false,
host: "",
port: null,
user: "",
auth: "openSsh",
keyFile: "",
saveSecret: true,
});
const parsePort = (v: string) => {
const n = parseInt(v, 10);
return Number.isFinite(n) && n > 0 && n < 65536 ? n : null;
};
function blank(): DataSource {
return {
id: newSourceId(),
name: "",
driver: "postgres",
host: "localhost",
port: null,
database: "",
user: "",
file: null,
ssl: "prefer",
savePassword: true,
readOnly: false,
};
}
function SourceDialog({
initial,
preset,
presetPassword,
}: {
initial: DataSource | null;
preset?: Partial<DataSource>;
presetPassword?: string | null;
}) {
const [s, setS] = useState<DataSource>(() => ({ ...blank(), ...preset, ...initial }));
// `undefined`: unchanged (editing keeps the stored password).
const [password, setPassword] = useState<string | undefined>(presetPassword ?? undefined);
// Same convention for the SSH password / passphrase.
const [sshSecret, setSshSecret] = useState<string | undefined>(undefined);
const [tab, setTab] = useState<"general" | "ssh">("general");
const [test, setTest] = useState<{ ok: boolean; text: string; trust?: boolean } | null>(null);
const [busy, setBusy] = useState(false);
const set = (patch: Partial<DataSource>) => setS((x) => ({ ...x, ...patch }));
const ssh: DbSsh = { ...blankSsh(), ...s.ssh };
const setSsh = (patch: Partial<DbSsh>) => setS((x) => ({ ...x, ssh: { ...blankSsh(), ...x.ssh, ...patch } }));
const sqlite = s.driver === "sqlite";
const name = s.name?.trim() || (sqlite ? fileSourceName(s.file ?? "") : `${s.database || s.user || "db"}@${s.host}`);
const sshOn = !sqlite && !!ssh.enabled;
const valid = sqlite ? !!s.file?.trim() : !!s.host?.trim() && (!sshOn || !!ssh.host?.trim());
const runTest = async () => {
setBusy(true);
setTest(null);
try {
const v = await call(commands.dbTest({ ...s, name }, password ?? null, sshOn ? (sshSecret ?? null) : null));
setTest({ ok: true, text: `Succeeded: ${v}` });
} catch (e) {
const text = e instanceof Error ? e.message : String(e);
const trust = e instanceof IpcError && connectRemedy(e.kind, e.message) === "trustHostKey";
setTest({ ok: false, text, trust });
} finally {
setBusy(false);
}
};
const trustAndRetry = async () => {
await call(commands.dbTrustSshHost(s.id ?? ""));
await runTest();
};
const save = async () => {
setBusy(true);
try {
await saveSource(
{ ...s, name },
sqlite ? undefined : password === undefined ? undefined : password || null,
!sshOn || sshSecret === undefined || ssh.auth === "openSsh" ? undefined : sshSecret || null,
);
closeDialog();
} catch (e) {
setTest({ ok: false, text: e instanceof Error ? e.message : String(e) });
} finally {
setBusy(false);
}
};
const browse = async () => {
const { open } = await import("@tauri-apps/plugin-dialog");
const file = await open({ multiple: false, directory: false, title: "SQLite Database File" });
if (typeof file === "string") set({ file });
};
const browseKey = async () => {
const { open } = await import("@tauri-apps/plugin-dialog");
const file = await open({ multiple: false, directory: false, title: "Private Key File" });
if (typeof file === "string") setSsh({ keyFile: file });
};
return (
<Dialog
open
onClose={closeDialog}
title={initial ? `Data Source: ${initial.name}` : "New Data Source"}
width={560}
footerStart={
<Button onClick={() => void runTest()} disabled={busy || !valid}>
<T>Test Connection</T>
</Button>
}
buttons={[
{ label: "Cancel", role: "cancel" },
{ label: "OK", role: "default", disabled: busy || !valid, onClick: () => void save() },
]}
>
{!sqlite && (
<Tabs
variant="compact"
className={styles.dialogTabs}
activeId={tab}
onSelect={(id) => setTab(id as "general" | "ssh")}
aria-label="Data source settings"
panelIdPrefix="db-source"
items={[
{ id: "general", label: "General" },
{ id: "ssh", label: "SSH/SSL" },
]}
/>
)}
<div
className={styles.form}
role={sqlite ? undefined : "tabpanel"}
id={sqlite ? undefined : `db-source-panel-${tab}`}
>
{sqlite || tab === "general" ? (
<>
<label className={styles.formLabel} htmlFor="db-name">
<T>Name:</T>
</label>
<Input
id="db-name"
value={s.name ?? ""}
placeholder={name}
onChange={(e) => set({ name: e.target.value })}
/>
<span className={styles.formLabel}>
<T>Driver:</T>
</span>
<Select
aria-label="Driver"
value={s.driver ?? "postgres"}
options={DRIVERS}
onChange={(driver) => set({ driver })}
/>
{sqlite ? (
<>
<label className={styles.formLabel} htmlFor="db-file">
<T>File:</T>
</label>
<div className={styles.formRow}>
<Input
id="db-file"
className={styles.fill}
value={s.file ?? ""}
placeholder="path/to/database.db (absolute or project-relative)"
onChange={(e) => set({ file: e.target.value || null })}
/>
<Button onClick={() => void browse()}>
<T>Browse…</T>
</Button>
</div>
</>
) : (
<>
<label className={styles.formLabel} htmlFor="db-host">
<T>Host:</T>
</label>
<div className={styles.formRow}>
<Input
id="db-host"
className={styles.fill}
value={s.host ?? ""}
onChange={(e) => set({ host: e.target.value })}
/>
<label className={styles.formLabel} htmlFor="db-port">
<T>Port:</T>
</label>
<Input
id="db-port"
style={{ width: 80 }}
inputMode="numeric"
value={s.port ?? ""}
placeholder={s.driver === "mysql" ? "3306" : "5432"}
onChange={(e) => set({ port: parsePort(e.target.value) })}
/>
</div>
{sshOn && (
<>
<span />
<span className={styles.formHint}>Reached from the SSH host {sshLabel({ ssh })}.</span>
</>
)}
<label className={styles.formLabel} htmlFor="db-user">
<T>User:</T>
</label>
<Input id="db-user" value={s.user ?? ""} onChange={(e) => set({ user: e.target.value })} />
<label className={styles.formLabel} htmlFor="db-password">
<T>Password:</T>
</label>
<div className={styles.formRow}>
<Input
id="db-password"
className={styles.fill}
type="password"
value={password ?? ""}
placeholder={initial && password === undefined ? "<unchanged>" : ""}
onChange={(e) => setPassword(e.target.value)}
/>
<Checkbox
checked={!!s.savePassword}
onChange={(savePassword) => set({ savePassword })}
label="Save password"
/>
</div>
<label className={styles.formLabel} htmlFor="db-database">
<T>Database:</T>
</label>
<Input id="db-database" value={s.database ?? ""} onChange={(e) => set({ database: e.target.value })} />
</>
)}
<span />
<Checkbox checked={!!s.readOnly} onChange={(readOnly) => set({ readOnly })} label="Read-only" />
</>
) : (
<>
<span />
<Checkbox checked={!!ssh.enabled} onChange={(enabled) => setSsh({ enabled })} label="Use SSH tunnel" />
<label className={styles.formLabel} htmlFor="db-ssh-host">
<T>Host:</T>
</label>
<div className={styles.formRow}>
<Input
id="db-ssh-host"
className={styles.fill}
disabled={!sshOn}
value={ssh.host ?? ""}
placeholder={ssh.auth === "openSsh" ? "host or ~/.ssh/config alias" : "bastion.example.com"}
onChange={(e) => setSsh({ host: e.target.value })}
/>
<label className={styles.formLabel} htmlFor="db-ssh-port">
<T>Port:</T>
</label>
<Input
id="db-ssh-port"
style={{ width: 80 }}
inputMode="numeric"
disabled={!sshOn}
value={ssh.port ?? ""}
placeholder="22"
onChange={(e) => setSsh({ port: parsePort(e.target.value) })}
/>
</div>
<label className={styles.formLabel} htmlFor="db-ssh-user">
<T>User:</T>
</label>
<Input
id="db-ssh-user"
disabled={!sshOn}
value={ssh.user ?? ""}
placeholder={ssh.auth === "openSsh" ? "from ~/.ssh/config" : ""}
onChange={(e) => setSsh({ user: e.target.value })}
/>
<span className={styles.formLabel}>
<T>Authentication:</T>
</span>
<Select
aria-label="SSH authentication type"
disabled={!sshOn}
value={ssh.auth ?? "openSsh"}
options={SSH_AUTH}
onChange={(auth) => setSsh({ auth })}
/>
{ssh.auth === "keyPair" && (
<>
<label className={styles.formLabel} htmlFor="db-ssh-key">
<T>Private key:</T>
</label>
<div className={styles.formRow}>
<Input
id="db-ssh-key"
className={styles.fill}
disabled={!sshOn}
value={ssh.keyFile ?? ""}
placeholder="~/.ssh/id_ed25519"
onChange={(e) => setSsh({ keyFile: e.target.value })}
/>
<Button disabled={!sshOn} onClick={() => void browseKey()}>
<T>Browse…</T>
</Button>
</div>
</>
)}
{ssh.auth === "openSsh" ? (
<>
<span />
<span className={styles.formHint}>
<T>
{
"Uses HostName, User, Port, IdentityFile and ProxyJump from ~/.ssh/config, and keys of the SSH agent."
}
</T>
</span>
</>
) : (
<>
<label className={styles.formLabel} htmlFor="db-ssh-secret">
{ssh.auth === "password" ? "Password:" : "Passphrase:"}
</label>
<div className={styles.formRow}>
<Input
id="db-ssh-secret"
className={styles.fill}
type="password"
disabled={!sshOn}
value={sshSecret ?? ""}
placeholder={initial && sshSecret === undefined ? "<unchanged>" : ""}
onChange={(e) => setSshSecret(e.target.value)}
/>
<Checkbox
checked={!!ssh.saveSecret}
disabled={!sshOn}
onChange={(saveSecret) => setSsh({ saveSecret })}
label="Save password"
/>
</div>
</>
)}
<span className={styles.formLabel}>
<T>SSL:</T>
</span>
<Select aria-label="SSL mode" value={s.ssl ?? "prefer"} options={SSL} onChange={(ssl) => set({ ssl })} />
</>
)}
<div className={`${styles.testResult} ${test?.ok ? styles.ok : styles.bad}`} role="status">
{busy && !test ? "Connecting…" : test?.text}
{test?.trust && (
<>
{" "}
<Button size="small" onClick={() => void trustAndRetry()} disabled={busy}>
<T>Trust Host and Retry</T>
</Button>
</>
)}
</div>
</div>
</Dialog>
);
}
function PasswordDialog({ source, resolve }: { source: DataSource; resolve: (ok: boolean) => void }) {
const [password, setPassword] = useState("");
const [save, setSave] = useState(!!source.savePassword);
+3 -2
Переглянути файл
@@ -515,8 +515,8 @@ function SourceUrlDialog() {
/>
<span className={styles.formHint}>
{url.trim() && !parsed
? "Not a PostgreSQL, MySQL / MariaDB or SQLite URL"
: "postgresql://, mysql://, mariadb://, jdbc:…, sqlite:path"}
? "Not a URL of PostgreSQL, MySQL, SQLite or a compatible database"
: "postgresql://, mysql://, mariadb://, redshift://, tidb://…, jdbc:…, sqlite:path"}
</span>
</div>
</Dialog>
@@ -550,6 +550,7 @@ function QuickDoc({ node, anchor }: { node: DbNode; anchor: { x: number; y: numb
switch (node.kind) {
case "source":
rows.push(["Driver", sourceLabel(node.source).replace(/^.*\((.*)\)$/, "$1")]);
if (node.source.comment?.trim()) rows.push(["Comment", node.source.comment.trim()]);
rows.push(
node.source.driver === "sqlite"
? ["File", node.source.file ?? ""]
+935
Переглянути файл
@@ -0,0 +1,935 @@
/**
* Data source properties (IDEA's Data Sources and Drivers page): name, colour and
* comment above General / Options / SSH/SSL / Schemas / Advanced tabs, Test Connection.
*/
import { useState, type ReactNode } from "react";
import {
call,
commands,
IpcError,
type DataSource,
type DbColor,
type DbIntrospection,
type DbOptions,
type DbProperty,
type DbSsh,
type DbSshAuth,
type DbSslMode,
type DbTxMode,
} from "@/platform/ipc";
import { useTx, useTxTemplate } from "@/platform/i18n";
import { Button, Checkbox, Dialog, IconButton, Input, Select, Tabs, T, type SelectOption } from "@/ui";
import { colorVar, connectRemedy, fileSourceName, newSourceId, parseSourceUrl, sshLabel } from "./model";
import { closeDialog, loadDatabases, loadSchemas, saveSource, useDatabase } from "./store";
import { VENDORS, sourceUrl, vendorById, vendorOf } from "./vendors";
import styles from "./Database.module.css";
type Tab = "general" | "options" | "ssh" | "schemas" | "advanced";
const TYPES: SelectOption[] = VENDORS.map((v) => ({ value: v.id, label: v.label, icon: v.icon }));
const COLORS: Array<SelectOption<DbColor | "none">> = [
{ value: "none", label: "No color" },
{ value: "red", label: "Red" },
{ value: "orange", label: "Orange" },
{ value: "yellow", label: "Yellow" },
{ value: "green", label: "Green" },
{ value: "blue", label: "Blue" },
{ value: "violet", label: "Violet" },
];
const SSL: Array<SelectOption<DbSslMode>> = [
{ value: "disable", label: "Disable" },
{ value: "prefer", label: "Prefer" },
{ value: "require", label: "Require" },
{ value: "verifyCa", label: "Verify CA" },
{ value: "verifyFull", label: "Verify Full" },
];
const TX: Array<SelectOption<DbTxMode>> = [
{ value: "auto", label: "Auto" },
{ value: "manual", label: "Manual" },
];
const SSH_AUTH: Array<SelectOption<DbSshAuth>> = [
{ value: "openSsh", label: "OpenSSH config and authentication agent" },
{ value: "password", label: "Password" },
{ value: "keyPair", label: "Key pair" },
];
/** Driver property names the Advanced tab suggests, and what the backend does with them. */
const PROPERTIES: Record<string, { names: string[]; hint: string }> = {
postgres: {
names: ["statement_timeout", "lock_timeout", "idle_in_transaction_session_timeout", "search_path", "work_mem", "application_name", "connect_timeout"],
hint: "Sent as run-time parameters when connecting (any server setting, e.g. statement_timeout). application_name names the session; connect_timeout is in seconds.",
},
mysql: {
names: ["sql_mode", "max_execution_time", "wait_timeout", "charset", "collation", "connect_timeout"],
hint: "Set as session variables after connecting (SET SESSION name = value). charset and collation pick the connection character set; connect_timeout is in seconds.",
},
sqlite: {
names: ["foreign_keys", "busy_timeout", "cache_size", "journal_mode", "synchronous"],
hint: "Applied as pragmas when opening the file (PRAGMA name = value).",
},
};
const blankSsh = (): DbSsh => ({
enabled: false,
host: "",
port: null,
user: "",
auth: "openSsh",
keyFile: "",
saveSecret: true,
});
const blankOptions = (): DbOptions => ({
transaction: "auto",
defaultSchema: "",
timeZone: "",
startupScript: "",
keepAliveSecs: null,
autoDisconnectSecs: null,
});
const blankIntrospection = (): DbIntrospection => ({
allDatabases: true,
databases: [],
allSchemas: true,
schemas: [],
showSystem: false,
});
const parsePort = (v: string) => {
const n = parseInt(v, 10);
return Number.isFinite(n) && n > 0 && n < 65536 ? n : null;
};
const parseSecs = (v: string) => {
const n = parseInt(v, 10);
return Number.isFinite(n) && n > 0 ? n : null;
};
function blank(): DataSource {
return {
id: newSourceId(),
name: "",
driver: "postgres",
vendor: "",
host: "localhost",
port: null,
database: "",
user: "",
file: null,
ssl: "prefer",
savePassword: true,
readOnly: false,
color: null,
comment: "",
urlOnly: false,
url: "",
sslCaFile: "",
sslCertFile: "",
sslKeyFile: "",
options: blankOptions(),
introspection: blankIntrospection(),
properties: [],
};
}
async function pickFile(title: string): Promise<string | null> {
const { open } = await import("@tauri-apps/plugin-dialog");
const file = await open({ multiple: false, directory: false, title });
return typeof file === "string" ? file : null;
}
/** A path field with Browse…. */
function FileField({
id,
label,
value,
placeholder,
disabled,
title,
onChange,
}: {
id: string;
label: string;
value: string;
placeholder?: string;
disabled?: boolean;
title: string;
onChange: (v: string) => void;
}) {
return (
<>
<label className={styles.formLabel} htmlFor={id}>
<T>{label}</T>
</label>
<div className={styles.formRow}>
<Input
id={id}
className={styles.fill}
disabled={disabled}
value={value}
placeholder={placeholder}
onChange={(e) => onChange(e.target.value)}
/>
<Button
disabled={disabled}
onClick={() =>
void pickFile(title).then((f) => {
if (f) onChange(f);
})
}
>
<T>Browse…</T>
</Button>
</div>
</>
);
}
/** Seconds after a checkbox (Run keep-alive query each N sec.). */
function SecondsOption({
label,
value,
fallback,
onChange,
}: {
label: string;
value: number | null | undefined;
fallback: number;
onChange: (v: number | null) => void;
}) {
const [draft, setDraft] = useState(String(value ?? fallback));
const on = value != null;
return (
<>
<span />
<div className={styles.formRow}>
<Checkbox checked={on} onChange={(c) => onChange(c ? (parseSecs(draft) ?? fallback) : null)} label={label} />
<Input
className={styles.secondsInput}
inputMode="numeric"
aria-label={`${label} (seconds)`}
disabled={!on}
value={draft}
onChange={(e) => {
setDraft(e.target.value);
const n = parseSecs(e.target.value);
if (n) onChange(n);
}}
/>
<span>
<T>sec.</T>
</span>
</div>
</>
);
}
/** Schemas tab: "All …" or a choice of names and `*` / `?` patterns. */
function NamePicker({
title,
all,
chosen,
known,
onAll,
onChosen,
}: {
title: string;
all: boolean;
chosen: string[];
known: string[];
onAll: (all: boolean) => void;
onChosen: (chosen: string[]) => void;
}) {
const [pattern, setPattern] = useState("");
const names = [...new Set([...known, ...chosen])];
const add = () => {
const p = pattern.trim();
if (p && !chosen.includes(p)) onChosen([...chosen, p]);
setPattern("");
};
return (
<div className={styles.namePicker}>
<Checkbox checked={all} onChange={onAll} label={`All ${title}`} />
{!all && (
<div className={styles.nameList}>
{names.map((n) => (
<Checkbox
key={n}
checked={chosen.includes(n)}
onChange={(c) => onChosen(c ? [...chosen, n] : chosen.filter((x) => x !== n))}
label={n}
/>
))}
<div className={styles.formRow}>
<Input
className={styles.fill}
value={pattern}
placeholder="Name or pattern, e.g. app_*"
aria-label={`Add ${title} pattern`}
onChange={(e) => setPattern(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.nativeEvent.isComposing) {
e.preventDefault();
add();
}
}}
/>
<Button disabled={!pattern.trim()} onClick={add}>
<T>Add Pattern</T>
</Button>
</div>
</div>
)}
</div>
);
}
function Section({ title }: { title: string }) {
return (
<div className={styles.formSection}>
<T>{title}</T>
</div>
);
}
export function SourceDialog({
initial,
preset,
presetPassword,
}: {
initial: DataSource | null;
preset?: Partial<DataSource>;
presetPassword?: string | null;
}) {
const tx = useTx();
const txTemplate = useTxTemplate();
const [s, setS] = useState<DataSource>(() => ({ ...blank(), ...preset, ...initial }));
// `undefined`: unchanged (editing keeps the stored password).
const [password, setPassword] = useState<string | undefined>(presetPassword ?? undefined);
// Same convention for the SSH password / passphrase.
const [sshSecret, setSshSecret] = useState<string | undefined>(undefined);
const [tab, setTab] = useState<Tab>("general");
const [test, setTest] = useState<{ ok: boolean; text: string; trust?: boolean } | null>(null);
const [busy, setBusy] = useState(false);
const [showComment, setShowComment] = useState(!!s.comment);
// The URL being typed in default mode (null: show the fields' URL).
const [urlDraft, setUrlDraft] = useState<string | null>(null);
const loadedDbs = useDatabase((st) => (initial?.id ? st.databases[initial.id] : undefined));
const loadedSchemas = useDatabase((st) => (initial?.id ? st.schemas[initial.id] : undefined));
const set = (patch: Partial<DataSource>) => setS((x) => ({ ...x, ...patch }));
const ssh: DbSsh = { ...blankSsh(), ...s.ssh };
const setSsh = (patch: Partial<DbSsh>) => setS((x) => ({ ...x, ssh: { ...blankSsh(), ...x.ssh, ...patch } }));
const options: DbOptions = { ...blankOptions(), ...s.options };
const setOptions = (patch: Partial<DbOptions>) => setS((x) => ({ ...x, options: { ...blankOptions(), ...x.options, ...patch } }));
const intro: DbIntrospection = { ...blankIntrospection(), ...s.introspection };
const setIntro = (patch: Partial<DbIntrospection>) =>
setS((x) => ({ ...x, introspection: { ...blankIntrospection(), ...x.introspection, ...patch } }));
const properties = s.properties ?? [];
const setProperty = (i: number, patch: Partial<DbProperty>) =>
set({ properties: properties.map((p, j) => (j === i ? { ...p, ...patch } : p)) });
const vendor = vendorOf(s);
const sqlite = s.driver === "sqlite";
const postgres = s.driver === "postgres";
const urlOnly = !sqlite && !!s.urlOnly;
const parsedUrl = urlOnly ? parseSourceUrl(s.url ?? "") : null;
const urlValid = !!parsedUrl && parsedUrl.source.driver === s.driver;
const host = urlOnly ? (parsedUrl?.source.host ?? "") : (s.host ?? "");
const database = urlOnly ? (parsedUrl?.source.database ?? "") : (s.database ?? "");
const name = s.name?.trim() || (sqlite ? fileSourceName(s.file ?? "") : `${database || s.user || "db"}@${host || "localhost"}`);
const sshOn = !sqlite && !!ssh.enabled;
const valid = sqlite ? !!s.file?.trim() : (urlOnly ? urlValid : !!s.host?.trim()) && (!sshOn || !!ssh.host?.trim());
const sslOn = (s.ssl ?? "prefer") !== "disable";
/** What is saved / tested: a preset's port when the field was left empty. */
const effective = (): DataSource => ({ ...s, name, port: s.port ?? (s.vendor ? vendor.port : null) });
const setType = (id: string) => {
const v = vendorById(id);
if (!v) return;
setS((x) => {
const old = vendorOf(x);
const port = x.port == null || x.port === old.port ? v.port : x.port;
return { ...x, driver: v.driver, vendor: v.id === v.driver ? "" : v.id, port, ssl: v.ssl ?? x.ssl };
});
};
/** Typing a URL in default mode fills the fields it names. */
const typeUrl = (text: string) => {
setUrlDraft(text);
const p = parseSourceUrl(text);
if (!p || p.source.driver !== s.driver) return;
const { host: h, port, database: db, user, ssl, sslCaFile, sslCertFile, sslKeyFile, vendor: v } = p.source;
set({
host: h,
port,
database: db,
...(user ? { user } : {}),
...(ssl ? { ssl } : {}),
...(sslCaFile ? { sslCaFile } : {}),
...(sslCertFile ? { sslCertFile } : {}),
...(sslKeyFile ? { sslKeyFile } : {}),
...(v ? { vendor: v } : {}),
});
if (p.password) setPassword(p.password);
};
const setUrlOnly = (on: boolean) => set(on && !s.url?.trim() ? { urlOnly: on, url: sourceUrl(s) } : { urlOnly: on });
const runTest = async () => {
setBusy(true);
setTest(null);
try {
const v = await call(commands.dbTest(effective(), password ?? null, sshOn ? (sshSecret ?? null) : null));
setTest({ ok: true, text: `Succeeded: ${v}` });
} catch (e) {
const text = e instanceof Error ? e.message : String(e);
const trust = e instanceof IpcError && connectRemedy(e.kind, e.message) === "trustHostKey";
setTest({ ok: false, text, trust });
} finally {
setBusy(false);
}
};
const trustAndRetry = async () => {
await call(commands.dbTrustSshHost(s.id ?? ""));
await runTest();
};
const save = async () => {
setBusy(true);
try {
await saveSource(
{ ...effective(), properties: properties.filter((p) => p.name?.trim()) },
sqlite ? undefined : password === undefined ? undefined : password || null,
!sshOn || sshSecret === undefined || ssh.auth === "openSsh" ? undefined : sshSecret || null,
);
closeDialog();
} catch (e) {
setTest({ ok: false, text: e instanceof Error ? e.message : String(e) });
} finally {
setBusy(false);
}
};
const loadNames = async () => {
const id = initial?.id;
if (!id) return;
await Promise.all([postgres ? loadDatabases(id) : Promise.resolve(), loadSchemas(id)]);
};
const tabs: Array<{ id: Tab; label: string }> = [
{ id: "general", label: "General" },
{ id: "options", label: "Options" },
...(sqlite
? []
: [
{ id: "ssh" as const, label: "SSH/SSL" },
{ id: "schemas" as const, label: "Schemas" },
]),
{ id: "advanced", label: "Advanced" },
];
const current = tabs.some((t) => t.id === tab) ? tab : "general";
const props = PROPERTIES[s.driver ?? "sqlite"]!;
const general = (
<>
<span className={styles.formLabel}>
<T>Driver:</T>
</span>
<Select aria-label="Data source type" value={vendor.id} options={TYPES} onChange={setType} />
{sqlite ? (
<FileField
id="db-file"
label="File:"
value={s.file ?? ""}
placeholder="path/to/database.db (absolute or project-relative)"
title="SQLite Database File"
onChange={(file) => set({ file: file || null })}
/>
) : (
<>
<span className={styles.formLabel}>
<T>Connection type:</T>
</span>
<div className={styles.segmented} role="group" aria-label={tx("Connection type")}>
<button type="button" aria-pressed={!urlOnly} onClick={() => setUrlOnly(false)}>
<T>default</T>
</button>
<button type="button" aria-pressed={urlOnly} onClick={() => setUrlOnly(true)}>
<T>URL only</T>
</button>
</div>
{!urlOnly && (
<>
<label className={styles.formLabel} htmlFor="db-host">
<T>Host:</T>
</label>
<div className={styles.formRow}>
<Input id="db-host" className={styles.fill} value={s.host ?? ""} onChange={(e) => set({ host: e.target.value })} />
<label className={styles.formLabel} htmlFor="db-port">
<T>Port:</T>
</label>
<Input
id="db-port"
className={styles.portInput}
inputMode="numeric"
value={s.port ?? ""}
placeholder={String(vendor.port ?? "")}
onChange={(e) => set({ port: parsePort(e.target.value) })}
/>
</div>
</>
)}
{sshOn && (
<>
<span />
<span className={styles.formHint}>Reached from the SSH host {sshLabel({ ssh })}.</span>
</>
)}
<label className={styles.formLabel} htmlFor="db-user">
<T>User:</T>
</label>
<Input
id="db-user"
value={s.user ?? ""}
placeholder={urlOnly ? "from the URL" : ""}
onChange={(e) => set({ user: e.target.value })}
/>
<label className={styles.formLabel} htmlFor="db-password">
<T>Password:</T>
</label>
<div className={styles.formRow}>
<Input
id="db-password"
className={styles.fill}
type="password"
value={password ?? ""}
placeholder={initial && password === undefined ? "<unchanged>" : ""}
onChange={(e) => setPassword(e.target.value)}
/>
<Checkbox checked={!!s.savePassword} onChange={(savePassword) => set({ savePassword })} label="Save password" />
</div>
{!urlOnly && (
<>
<label className={styles.formLabel} htmlFor="db-database">
<T>Database:</T>
</label>
<Input id="db-database" value={s.database ?? ""} onChange={(e) => set({ database: e.target.value })} />
</>
)}
<label className={styles.formLabel} htmlFor="db-url">
<T>URL:</T>
</label>
<Input
id="db-url"
mono
value={urlOnly ? (s.url ?? "") : (urlDraft ?? sourceUrl(s))}
invalid={urlOnly ? !urlValid : urlDraft !== null && parseSourceUrl(urlDraft)?.source.driver !== s.driver}
placeholder={sourceUrl(s)}
onChange={(e) => (urlOnly ? set({ url: e.target.value }) : typeUrl(e.target.value))}
onBlur={() => setUrlDraft(null)}
/>
<span />
<span className={styles.formHint}>
{txTemplate(
urlOnly
? urlValid || !s.url?.trim()
? "Connects with the URL alone: host, port, database, SSL and parameters come from it."
: `Not a ${vendor.label} URL`
: "Editing the URL fills in the settings above.",
)}
</span>
</>
)}
</>
);
const optionsTab = (
<>
<Section title="Connection" />
<span />
<Checkbox checked={!!s.readOnly} onChange={(readOnly) => set({ readOnly })} label="Read-only" />
<span className={styles.formLabel}>
<T>Transaction control:</T>
</span>
<Select
aria-label="Transaction control"
className={styles.narrowSelect}
value={options.transaction ?? "auto"}
options={TX}
onChange={(transaction) => setOptions({ transaction })}
/>
{postgres && (
<>
<label className={styles.formLabel} htmlFor="db-default-schema">
<T>Default schema:</T>
</label>
<Input
id="db-default-schema"
value={options.defaultSchema ?? ""}
placeholder="public (search_path, e.g. app, public)"
onChange={(e) => setOptions({ defaultSchema: e.target.value })}
/>
</>
)}
{!sqlite && (
<>
<label className={styles.formLabel} htmlFor="db-time-zone">
<T>Time zone:</T>
</label>
<Input
id="db-time-zone"
value={options.timeZone ?? ""}
placeholder={postgres ? "Server default, e.g. UTC or Europe/Berlin" : "UTC (+00:00), e.g. Europe/Berlin or +08:00"}
onChange={(e) => setOptions({ timeZone: e.target.value })}
/>
<SecondsOption
label="Run keep-alive query each"
value={options.keepAliveSecs}
fallback={60}
onChange={(keepAliveSecs) => setOptions({ keepAliveSecs })}
/>
</>
)}
<SecondsOption
label="Auto-disconnect after"
value={options.autoDisconnectSecs}
fallback={300}
onChange={(autoDisconnectSecs) => setOptions({ autoDisconnectSecs })}
/>
<label className={styles.formLabel} htmlFor="db-startup">
<T>Startup script:</T>
</label>
<textarea
id="db-startup"
className={styles.textArea}
spellCheck={false}
value={options.startupScript ?? ""}
placeholder={postgres ? "SET statement_timeout = '30s';" : sqlite ? "PRAGMA foreign_keys = ON;" : "SET SESSION sql_mode = 'ANSI';"}
onChange={(e) => setOptions({ startupScript: e.target.value })}
/>
<span />
<span className={styles.formHint}>
<T>Runs on every new connection, before any other statement.</T>
</span>
</>
);
const sshTab = (
<>
<span />
<Checkbox checked={!!ssh.enabled} onChange={(enabled) => setSsh({ enabled })} label="Use SSH tunnel" />
<label className={styles.formLabel} htmlFor="db-ssh-host">
<T>Host:</T>
</label>
<div className={styles.formRow}>
<Input
id="db-ssh-host"
className={styles.fill}
disabled={!sshOn}
value={ssh.host ?? ""}
placeholder={ssh.auth === "openSsh" ? "host or ~/.ssh/config alias" : "bastion.example.com"}
onChange={(e) => setSsh({ host: e.target.value })}
/>
<label className={styles.formLabel} htmlFor="db-ssh-port">
<T>Port:</T>
</label>
<Input
id="db-ssh-port"
className={styles.portInput}
inputMode="numeric"
disabled={!sshOn}
value={ssh.port ?? ""}
placeholder="22"
onChange={(e) => setSsh({ port: parsePort(e.target.value) })}
/>
</div>
<label className={styles.formLabel} htmlFor="db-ssh-user">
<T>User:</T>
</label>
<Input
id="db-ssh-user"
disabled={!sshOn}
value={ssh.user ?? ""}
placeholder={ssh.auth === "openSsh" ? "from ~/.ssh/config" : ""}
onChange={(e) => setSsh({ user: e.target.value })}
/>
<span className={styles.formLabel}>
<T>Authentication:</T>
</span>
<Select
aria-label="SSH authentication type"
disabled={!sshOn}
value={ssh.auth ?? "openSsh"}
options={SSH_AUTH}
onChange={(auth) => setSsh({ auth })}
/>
{ssh.auth === "keyPair" && (
<FileField
id="db-ssh-key"
label="Private key:"
value={ssh.keyFile ?? ""}
placeholder="~/.ssh/id_ed25519"
disabled={!sshOn}
title="Private Key File"
onChange={(keyFile) => setSsh({ keyFile })}
/>
)}
{ssh.auth === "openSsh" ? (
<>
<span />
<span className={styles.formHint}>
<T>{"Uses HostName, User, Port, IdentityFile and ProxyJump from ~/.ssh/config, and keys of the SSH agent."}</T>
</span>
</>
) : (
<>
<label className={styles.formLabel} htmlFor="db-ssh-secret">
{tx(ssh.auth === "password" ? "Password:" : "Passphrase:")}
</label>
<div className={styles.formRow}>
<Input
id="db-ssh-secret"
className={styles.fill}
type="password"
disabled={!sshOn}
value={sshSecret ?? ""}
placeholder={initial && sshSecret === undefined ? "<unchanged>" : ""}
onChange={(e) => setSshSecret(e.target.value)}
/>
<Checkbox
checked={!!ssh.saveSecret}
disabled={!sshOn}
onChange={(saveSecret) => setSsh({ saveSecret })}
label="Save password"
/>
</div>
</>
)}
<Section title="SSL" />
<span className={styles.formLabel}>
<T>Mode:</T>
</span>
<Select
aria-label="SSL mode"
className={styles.narrowSelect}
value={s.ssl ?? "prefer"}
options={SSL}
onChange={(ssl) => set({ ssl })}
/>
<FileField
id="db-ssl-ca"
label="CA file:"
value={s.sslCaFile ?? ""}
placeholder={s.ssl === "verifyCa" || s.ssl === "verifyFull" ? "System roots when empty" : ""}
disabled={!sslOn}
title="CA Certificate File"
onChange={(sslCaFile) => set({ sslCaFile })}
/>
<FileField
id="db-ssl-cert"
label="Client certificate file:"
value={s.sslCertFile ?? ""}
disabled={!sslOn}
title="Client Certificate File"
onChange={(sslCertFile) => set({ sslCertFile })}
/>
<FileField
id="db-ssl-key"
label="Client key file:"
value={s.sslKeyFile ?? ""}
disabled={!sslOn}
title="Client Key File"
onChange={(sslKeyFile) => set({ sslKeyFile })}
/>
</>
);
const knownDbs = loadedDbs?.status === "ok" ? loadedDbs.value.names : [];
const knownSchemas = loadedSchemas?.status === "ok" ? loadedSchemas.value : [];
const schemasTab = (
<>
<div className={styles.formWide}>
{postgres && (
<NamePicker
title="databases"
all={intro.allDatabases !== false}
chosen={intro.databases ?? []}
known={knownDbs}
onAll={(allDatabases) => setIntro({ allDatabases })}
onChosen={(databases) => setIntro({ databases })}
/>
)}
<NamePicker
title={s.driver === "mysql" ? "schemas (databases)" : "schemas"}
all={intro.allSchemas !== false}
chosen={intro.schemas ?? []}
known={knownSchemas}
onAll={(allSchemas) => setIntro({ allSchemas })}
onChosen={(schemas) => setIntro({ schemas })}
/>
<Checkbox
checked={!!intro.showSystem}
onChange={(showSystem) => setIntro({ showSystem })}
label={postgres ? "Show system schemas and template databases" : "Show system schemas"}
/>
<div className={styles.formRow}>
<Button disabled={!initial?.id || busy} onClick={() => void loadNames()}>
<T>Load Names from Server</T>
</Button>
<span className={styles.formHint}>
{initial?.id
? "The Database tree shows only the checked names and patterns (* and ?); the connected database always shows."
: "Save the data source to pick from the server's names; patterns (* and ?) work any time."}
</span>
</div>
</div>
</>
);
const advancedTab = (
<div className={styles.formWide}>
<div className={styles.propertyTable} role="table" aria-label={tx("Driver properties")}>
<div role="row" className={styles.propertyHead}>
<span role="columnheader">
<T>Name</T>
</span>
<span role="columnheader">
<T>Value</T>
</span>
<span />
</div>
{properties.map((p, i) => (
<div role="row" key={i} className={styles.propertyRow}>
<Input
aria-label="Property name"
list="db-property-names"
value={p.name ?? ""}
placeholder="<user defined>"
onChange={(e) => setProperty(i, { name: e.target.value })}
/>
<Input aria-label="Property value" value={p.value ?? ""} placeholder="<value>" onChange={(e) => setProperty(i, { value: e.target.value })} />
<IconButton
icon="close"
size="small"
aria-label="Remove property"
title="Remove"
onClick={() => set({ properties: properties.filter((_, j) => j !== i) })}
/>
</div>
))}
</div>
<datalist id="db-property-names">
{props.names.map((n) => (
<option key={n} value={n} />
))}
</datalist>
<div className={styles.formRow}>
<Button icon="plus" onClick={() => set({ properties: [...properties, { name: "", value: "" }] })}>
<T>Add Property</T>
</Button>
</div>
<span className={styles.formHint}>
<T>{props.hint}</T>
</span>
</div>
);
const body: Record<Tab, ReactNode> = {
general,
options: optionsTab,
ssh: sshTab,
schemas: schemasTab,
advanced: advancedTab,
};
return (
<Dialog
open
onClose={closeDialog}
title={initial ? `Data Source: ${initial.name}` : "New Data Source"}
width={640}
footerStart={
<Button onClick={() => void runTest()} disabled={busy || !valid}>
<T>Test Connection</T>
</Button>
}
buttons={[
{ label: "Cancel", role: "cancel" },
{ label: "OK", role: "default", disabled: busy || !valid, onClick: () => void save() },
]}
>
<div className={styles.form}>
<label className={styles.formLabel} htmlFor="db-name">
<T>Name:</T>
</label>
<div className={styles.formRow}>
<Input id="db-name" className={styles.fill} value={s.name ?? ""} placeholder={name} onChange={(e) => set({ name: e.target.value })} />
<span className={styles.formLabel}>
<T>Color:</T>
</span>
<Select
aria-label="Color"
className={styles.narrowSelect}
value={s.color ?? "none"}
options={COLORS}
onChange={(c) => set({ color: c === "none" ? null : c })}
renderValue={(o) => (
<span className={styles.colorValue}>
{o && o.value !== "none" && <span className={styles.colorSwatch} style={{ background: colorVar(o.value as DbColor) }} />}
<T>{o?.label ?? "No color"}</T>
</span>
)}
/>
</div>
<span />
{showComment ? (
<textarea
className={styles.textArea}
aria-label={tx("Comment")}
autoFocus={!s.comment}
rows={2}
value={s.comment ?? ""}
placeholder={tx("Comment")}
onChange={(e) => set({ comment: e.target.value })}
/>
) : (
<button type="button" className={styles.linkButton} onClick={() => setShowComment(true)}>
<T>Add comment</T>
</button>
)}
</div>
<Tabs
variant="compact"
className={styles.dialogTabs}
activeId={current}
onSelect={(id) => setTab(id as Tab)}
aria-label="Data source settings"
panelIdPrefix="db-source"
items={tabs}
/>
<div className={styles.form} role="tabpanel" id={`db-source-panel-${current}`}>
{body[current]}
<div className={`${styles.testResult} ${test?.ok ? styles.ok : styles.bad}`} role="status">
{busy && !test ? tx("Connecting…") : test?.text}
{test?.trust && (
<>
{" "}
<Button size="small" onClick={() => void trustAndRetry()} disabled={busy}>
<T>Trust Host and Retry</T>
</Button>
</>
)}
</div>
</div>
</Dialog>
);
}
+103 -1
Переглянути файл
@@ -1,6 +1,23 @@
import { describe, expect, it } from "vitest";
import { exportData, type ExportData } from "./export";
import { buildRowEdits, connectRemedy, emptyEdits, hasEdits, parseSourceUrl, sourceLabel, splitTarget, sshLabel, targetOf, toCsv, toTsv } from "./model";
import {
buildRowEdits,
colorVar,
connectRemedy,
emptyEdits,
hasEdits,
idleTargets,
matchesPattern,
parseSourceUrl,
shownNames,
sourceLabel,
splitTarget,
sshLabel,
targetOf,
toCsv,
toTsv,
} from "./model";
import { VENDORS, sourceUrl, vendorById, vendorOf, vendorPreset } from "./vendors";
describe("clipboard formats", () => {
it("copies TSV with NULL as empty and whitespace flattened", () => {
@@ -131,3 +148,88 @@ describe("parseSourceUrl", () => {
expect(parseSourceUrl("oracle://x")).toBeNull();
});
});
describe("data source types", () => {
it("presets wire-compatible products on the existing drivers", () => {
expect(vendorPreset(vendorById("cockroachdb")!)).toEqual({ driver: "postgres", vendor: "cockroachdb", port: 26257 });
expect(vendorPreset(vendorById("postgres")!)).toEqual({ driver: "postgres", vendor: "", port: 5432 });
expect(vendorPreset(vendorById("supabase")!).ssl).toBe("require");
expect(VENDORS.every((v) => ["postgres", "mysql", "sqlite"].includes(v.driver))).toBe(true);
const labels = VENDORS.map((v) => v.label);
expect(labels).toEqual([...labels].sort((a, b) => a.localeCompare(b)));
});
it("falls back to the driver for unknown or mismatched presets", () => {
expect(vendorOf({ driver: "mysql", vendor: "tidb" }).label).toBe("TiDB");
expect(vendorOf({ driver: "postgres", vendor: "tidb" }).id).toBe("postgres");
expect(vendorOf({ driver: "mysql" }).id).toBe("mysql");
expect(sourceLabel({ name: "tidb", driver: "mysql", vendor: "tidb" })).toBe("tidb (TiDB)");
});
it("previews the JDBC URL of the fields", () => {
expect(sourceUrl({ driver: "postgres", host: "localhost", port: 5432, database: "postgres" })).toBe(
"jdbc:postgresql://localhost:5432/postgres",
);
expect(sourceUrl({ driver: "postgres", vendor: "redshift", host: "r", port: null, database: "" })).toBe("jdbc:redshift://r:5439");
expect(sourceUrl({ driver: "mysql", vendor: "mariadb", host: "::1", port: 3307, database: "a b" })).toBe(
"jdbc:mariadb://[::1]:3307/a%20b",
);
expect(sourceUrl({ driver: "sqlite", file: "app.db" })).toBe("jdbc:sqlite:app.db");
});
it("reads URLs of compatible products, SSL settings and driver properties", () => {
const tidb = parseSourceUrl("jdbc:tidb://[email protected]:4000/test?sslmode=verify-full&sslrootcert=/ca.pem&sql_mode=ANSI");
expect(tidb?.source).toEqual({
driver: "mysql",
vendor: "tidb",
host: "tidb.local",
port: 4000,
user: "root",
database: "test",
ssl: "verifyFull",
sslCaFile: "/ca.pem",
properties: [{ name: "sql_mode", value: "ANSI" }],
});
expect(parseSourceUrl("redshift://r.example:5439/dev")?.source.vendor).toBe("redshift");
expect(parseSourceUrl("postgresql://h:70000/db")).toBeNull();
});
});
describe("Schemas tab", () => {
it("matches names and * / ? patterns", () => {
expect(matchesPattern("app_1", "app_*")).toBe(true);
expect(matchesPattern("app.1", "app?1")).toBe(true);
expect(matchesPattern("apps", "app")).toBe(false);
expect(matchesPattern("a.b", "a.b")).toBe(true);
});
it("shows all names, or the chosen ones and the connected database", () => {
const names = ["app", "app_test", "billing", "postgres"];
expect(shownNames(names, undefined, undefined)).toEqual(names);
expect(shownNames(names, true, ["billing"])).toEqual(names);
expect(shownNames(names, false, ["app*"], "postgres")).toEqual(["app", "app_test", "postgres"]);
expect(shownNames(names, false, [])).toEqual([]);
});
});
describe("Options tab", () => {
it("finds connections idle past their auto-disconnect", () => {
const sources = [
{ id: "a", options: { autoDisconnectSecs: 60 } },
{ id: "b", options: { autoDisconnectSecs: null } },
];
const used = new Map([
["a", 0],
["a:other", 50_000],
["b", 0],
]);
const idle = (busy: string[]) => idleTargets(["a", "a:other", "b", "c"], sources, used, (t) => busy.includes(t), 60_000);
expect(idle([])).toEqual(["a"]);
expect(idle(["a"])).toEqual([]);
});
it("names the colour tokens", () => {
expect(colorVar("red")).toBe("var(--db-color-red)");
expect(colorVar(null)).toBeUndefined();
});
});
+16 -1
Переглянути файл
@@ -8,7 +8,14 @@ import { lazyComponent } from "@/ui";
import { activeEditor, registerEditorContribution } from "@/editor/api";
import { registerActions } from "@/platform/actions";
import { useProject } from "@/platform/project";
import { registerEditorKind, registerOverlay, registerServiceView, registerToolWindow, toolWindowService } from "@/workbench/contributions";
import {
registerEditorKind,
registerOverlay,
registerServiceView,
registerToolWindow,
setFileDecorations,
toolWindowService,
} from "@/workbench/contributions";
import { SQL_LANGUAGE, consoleExtension, executeInView } from "./console";
import { DatabaseDialogs } from "./Dialogs";
import { TABLE_KIND, tableTitle } from "./tableInput";
@@ -18,10 +25,12 @@ import {
DB_TOOL_WINDOW,
RESULTS_VIEW,
consoleSource,
consoleTints,
loadSources,
openConsole,
openSourceDialog,
useDatabase,
watchIdleConnections,
} from "./store";
export function activate(): () => void {
@@ -72,6 +81,12 @@ export function activate(): () => void {
newMenuOpen: false,
});
}),
// Consoles of coloured data sources get a tinted tab.
useDatabase.subscribe((s, prev) => {
if (s.consoles !== prev.consoles || s.sources !== prev.sources) setFileDecorations("database", consoleTints(s));
}),
() => setFileDecorations("database", new Map()),
watchIdleConnections(),
registerDatabaseTreeActions(),
registerActions([
{
+110 -20
Переглянути файл
@@ -1,5 +1,6 @@
/** Pure helpers of the database tools: clipboard formats, edits, labels. */
import type { DbCellValue, DbDriver, DbRowEdit, DataSource } from "@/platform/ipc";
import type { DbCellValue, DbColor, DbProperty, DbRowEdit, DbSslMode, DataSource } from "@/platform/ipc";
import { vendorOf, vendorOfScheme } from "./vendors";
export type Cell = string | null;
@@ -62,14 +63,55 @@ export function fileSourceName(file: string): string {
return base.replace(/\.[^.]+$/, "") || base;
}
/** The tree / menu icon of a driver (IDEA shows the vendor's mark). */
export function driverIcon(driver: DbDriver | null | undefined): string {
return driver === "postgres" ? "db-postgres" : driver === "mysql" ? "db-mysql" : "db-sqlite";
/** The tree / menu icon of a data source (IDEA shows the vendor's mark). */
export function sourceIcon(s: Pick<DataSource, "vendor" | "driver">): string {
return vendorOf(s).icon;
}
export function sourceLabel(s: Pick<DataSource, "name" | "driver">): string {
const kind = s.driver === "postgres" ? "PostgreSQL" : s.driver === "mysql" ? "MySQL" : "SQLite";
return `${s.name} (${kind})`;
export function sourceLabel(s: Pick<DataSource, "name" | "driver" | "vendor">): string {
return `${s.name} (${vendorOf(s).label})`;
}
/** The token of a data source colour (tree rows, console tab). */
export function colorVar(color: DbColor | null | undefined): string | undefined {
return color ? `var(--db-color-${color})` : undefined;
}
/** `*` / `?` wildcard match of the Schemas tab (case-sensitive, like the server's names). */
export function matchesPattern(name: string, pattern: string): boolean {
if (!/[*?]/.test(pattern)) return name === pattern;
const re = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".");
return new RegExp(`^${re}$`).test(name);
}
/**
* The databases / schemas the tree shows (Schemas tab): all of them, or the
* ones matching a chosen name or pattern; `keep` (the connected database) always stays.
*/
export function shownNames(names: readonly string[], all: boolean | undefined, chosen: readonly string[] | undefined, keep?: string | null): string[] {
if (all !== false) return [...names];
const picks = (chosen ?? []).map((p) => p.trim()).filter(Boolean);
return names.filter((n) => n === keep || picks.some((p) => matchesPattern(n, p)));
}
/**
* Connected targets idle for longer than their data source's Auto-disconnect
* (`lastUsed`: ms of the last statement / introspection; `busy`: running or holding a transaction).
*/
export function idleTargets(
connected: Iterable<string>,
sources: readonly DataSource[],
lastUsed: ReadonlyMap<string, number>,
busy: (target: string) => boolean,
now: number,
): string[] {
const out: string[] = [];
for (const t of connected) {
const secs = sources.find((s) => s.id === splitTarget(t).id)?.options?.autoDisconnectSecs;
if (!secs || busy(t)) continue;
if (now - (lastUsed.get(t) ?? now) >= secs * 1000) out.push(t);
}
return out;
}
export function newSourceId(): string {
@@ -120,20 +162,36 @@ export function targetOf(id: string, database: string | null, current: string |
/** Key of the Database tree node of database `name` of data source `id`. */
export const databaseKey = (id: string, name: string) => `${id}/@${name}`;
const SSL_MODES: Record<string, DbSslMode> = {
disable: "disable",
disabled: "disable",
prefer: "prefer",
preferred: "prefer",
require: "require",
required: "require",
"verify-ca": "verifyCa",
verify_ca: "verifyCa",
"verify-full": "verifyFull",
verify_identity: "verifyFull",
"verify-identity": "verifyFull",
};
/**
* Data Source from URL: `postgres://user:pass@host:5432/db`, `mysql://…`,
* `mariadb://…`, the `jdbc:` forms of those, `jdbc:sqlite:path` / `sqlite:path`.
* Null when the URL names no supported driver.
* Data Source from URL (and the General tab's URL field): `postgres://user:pass@host:5432/db`,
* `mysql://…`, `mariadb://…`, wire-compatible products (`redshift://`, `tidb://`…), the
* `jdbc:` forms of those, `jdbc:sqlite:path` / `sqlite:path`. SSL parameters fill the
* SSL tab, other parameters become driver properties. Null when the URL names no supported driver.
*/
export function parseSourceUrl(raw: string): { source: Partial<DataSource>; password: string | null } | null {
const url = raw.trim().replace(/^jdbc:/i, "");
const sqlite = /^sqlite:(?:\/\/)?(.+)$/i.exec(url);
const sqlite = /^sqlite3?:(?:\/\/)?(.+)$/i.exec(url);
if (sqlite) {
const file = sqlite[1]!.replace(/^\/([A-Za-z]:)/, "$1");
return { source: { driver: "sqlite", file, name: fileSourceName(file) }, password: null };
}
const m = /^(postgres(?:ql)?|mysql|mariadb):\/\/(?:([^:@/]*)(?::([^@/]*))?@)?([^:/?]+|\[[^\]]+\])(?::(\d+))?(?:\/([^?]*))?(?:\?(.*))?$/i.exec(url);
if (!m) return null;
const m = /^([a-z][a-z0-9]*):\/\/(?:([^:@/]*)(?::([^@/]*))?@)?([^:/?,]+|\[[^\]]+\])(?::(\d+))?(?:,[^/?]*)?(?:\/([^?]*))?(?:\?(.*))?$/i.exec(url);
const vendor = m ? vendorOfScheme(m[1]!) : undefined;
if (!m || !vendor || vendor.driver === "sqlite") return null;
const dec = (s: string | undefined) => {
try {
return s ? decodeURIComponent(s) : "";
@@ -141,13 +199,45 @@ export function parseSourceUrl(raw: string): { source: Partial<DataSource>; pass
return s ?? "";
}
};
const driver = m[1]!.toLowerCase().startsWith("postgres") ? "postgres" : "mysql";
const params = new URLSearchParams(m[7] ?? "");
const user = dec(m[2]) || params.get("user") || "";
const password = m[3] !== undefined ? dec(m[3]) : params.get("password");
const port = m[5] ? parseInt(m[5], 10) : null;
const source: Partial<DataSource> = { driver, host: m[4]!, port, user, database: dec(m[6]) };
const ssl = params.get("sslmode")?.toLowerCase();
if (ssl === "disable" || ssl === "require" || ssl === "prefer") source.ssl = ssl;
if (port !== null && !(port > 0 && port < 65536)) return null;
let user = dec(m[2]);
let password = m[3] !== undefined ? dec(m[3]) : null;
const host = m[4]!.replace(/^\[(.*)\]$/, "$1");
const source: Partial<DataSource> = { driver: vendor.driver, host, port, user, database: dec(m[6]).replace(/\/$/, "") };
if (vendor.id !== vendor.driver) source.vendor = vendor.id;
const properties: DbProperty[] = [];
for (const [k, v] of new URLSearchParams(m[7] ?? "")) {
switch (k.toLowerCase()) {
case "user":
user ||= v;
break;
case "password":
password ??= v;
break;
case "sslmode":
case "ssl-mode": {
const mode = SSL_MODES[v.toLowerCase()];
if (mode) source.ssl = mode;
break;
}
case "sslrootcert":
case "ssl-ca":
source.sslCaFile = v;
break;
case "sslcert":
case "ssl-cert":
source.sslCertFile = v;
break;
case "sslkey":
case "ssl-key":
source.sslKeyFile = v;
break;
default:
properties.push({ name: k, value: v });
}
}
source.user = user;
if (properties.length) source.properties = properties;
return { source, password };
}
+40 -3
Переглянути файл
@@ -20,9 +20,9 @@ import {
type SqlStatement,
} from "@/platform/ipc";
import { editorsFor } from "@/editor/api";
import { editorService, notifications, showServiceView, toolWindowService } from "@/workbench/contributions";
import { editorService, notifications, showServiceView, toolWindowService, type FileDecoration } from "@/workbench/contributions";
import { ask, confirm } from "@/workbench/messages";
import { connectRemedy, databaseKey, newSourceId, splitTarget } from "./model";
import { colorVar, connectRemedy, databaseKey, idleTargets, newSourceId, splitTarget } from "./model";
import type { DbNode } from "./nodes";
import type { DbObjectRef } from "./objects";
import { formatted } from "./sql/format";
@@ -321,13 +321,25 @@ export async function resolveConnectError(e: unknown, source: DataSource): Promi
}
}
/** Target → time of its last use (Options › Auto-disconnect). */
const lastUsed = new Map<string, number>();
/** Options › Transaction control: a manual data source's consoles start in manual commit. */
function applyConnectDefaults(id: string) {
if (sourceById(id)?.options?.transaction === "manual" && !useDatabase.getState().manualCommit.has(id))
void setManualCommit(id, true).catch(() => {});
}
/** Runs `op`, asking for passwords / host key trust and retrying when the connection needs them. */
export async function withConnection<T>(id: string, op: () => Promise<T>): Promise<T> {
for (;;) {
try {
const r = await op();
if (!useDatabase.getState().connected.has(id))
lastUsed.set(id, Date.now());
if (!useDatabase.getState().connected.has(id)) {
useDatabase.setState((s) => ({ connected: new Set(s.connected).add(id) }));
applyConnectDefaults(id);
}
return r;
} catch (e) {
const source = sourceById(id);
@@ -360,6 +372,31 @@ export async function disconnect(id: string): Promise<void> {
});
}
/** Disconnects targets idle past their data source's Auto-disconnect; returns the stopper. */
export function watchIdleConnections(everyMs = 5000): () => void {
const timer = setInterval(() => {
const s = useDatabase.getState();
const running = new Set(Object.keys(s.running).map((path) => s.consoles[path]));
const busy = (t: string) => running.has(t) || s.pendingTx.has(t);
for (const t of idleTargets(s.connected, s.sources, lastUsed, busy, Date.now())) {
lastUsed.delete(t);
void disconnect(t).then(() => log("info", `${targetLabel(t)}: disconnected after being idle`));
}
}, everyMs);
return () => clearInterval(timer);
}
/** Console files of coloured data sources, tinted like IDEA's (file decorations). */
export function consoleTints(s: Pick<DatabaseState, "consoles" | "sources">): Map<string, FileDecoration> {
const out = new Map<string, FileDecoration>();
for (const [path, target] of Object.entries(s.consoles)) {
const id = splitTarget(target).id;
const tint = colorVar(s.sources.find((x) => x.id === id)?.color);
if (tint) out.set(path, { tint });
}
return out;
}
/** PostgreSQL data sources show their server's databases (IDEA's data source › database › schema). */
export function listsDatabases(source: DataSource | undefined): boolean {
return source?.driver === "postgres";
+4 -9
Переглянути файл
@@ -1,7 +1,7 @@
/** Menus of the Database tool window, laid out like IDEA's (New ▸, Copy/Paste ▸, SQL Scripts ▸ …). */
import type { DbDriver } from "@/platform/ipc";
import type { MenuItem } from "@/ui";
import { driverIcon, fileSourceName } from "./model";
import { fileSourceName } from "./model";
import { VENDORS, vendorPreset } from "./vendors";
import { nodeTarget, objectOf, tableOf, type DbNode } from "./nodes";
import { SCRIPT_LABELS, canDrop, canRename, type ScriptKind } from "./objects";
import { connectedTargets, openDialog, openSourceDialog, sourceById, useDatabase } from "./store";
@@ -21,18 +21,13 @@ async function sqliteFromFile() {
if (typeof file === "string") openSourceDialog(null, { driver: "sqlite", file, name: fileSourceName(file) });
}
/** New ▸ Data Source ▸ — the drivers the backend speaks. */
/** New ▸ Data Source ▸ — the drivers the backend speaks and wire-compatible products, A–Z like IDEA's. */
function dataSourceItems(): MenuItem[] {
const preset = (label: string, driver: DbDriver): MenuItem => ({
label,
icon: driverIcon(driver),
run: () => openSourceDialog(null, { driver }),
});
return [
{
label: "Data Source",
icon: "database",
submenu: [preset("MariaDB", "mysql"), preset("MySQL", "mysql"), preset("PostgreSQL", "postgres"), preset("SQLite", "sqlite")],
submenu: VENDORS.map((v) => ({ label: v.label, icon: v.icon, run: () => openSourceDialog(null, vendorPreset(v)) })),
},
{ label: "Data Source from File…", icon: "folder", run: () => void sqliteFromFile() },
{ label: "Data Source from URL…", icon: "external-link", run: () => openDialog({ kind: "sourceUrl" }) },
+86
Переглянути файл
@@ -0,0 +1,86 @@
/**
* Data source types of New ▸ Data Source: the three drivers the backend speaks,
* and products that speak their wire protocol (a preset of port, icon and URL scheme).
*/
import type { DataSource, DbDriver, DbSslMode } from "@/platform/ipc";
export interface DbVendor {
/** Stored in `DataSource.vendor`; a driver's own entry has the driver's id. */
id: string;
label: string;
driver: DbDriver;
port: number | null;
icon: string;
/** JDBC URL scheme (`jdbc:<scheme>://…`). */
scheme: string;
/** Managed services that only accept encrypted connections. */
ssl?: DbSslMode;
}
export const VENDORS: readonly DbVendor[] = [
{ id: "aurora-mysql", label: "Amazon Aurora MySQL", driver: "mysql", port: 3306, icon: "db-aurora", scheme: "mysql" },
{ id: "redshift", label: "Amazon Redshift", driver: "postgres", port: 5439, icon: "db-redshift", scheme: "redshift", ssl: "require" },
{ id: "cockroachdb", label: "CockroachDB", driver: "postgres", port: 26257, icon: "db-cockroach", scheme: "postgresql" },
{ id: "greenplum", label: "Greenplum", driver: "postgres", port: 5432, icon: "db-greenplum", scheme: "postgresql" },
{ id: "mariadb", label: "MariaDB", driver: "mysql", port: 3306, icon: "db-mariadb", scheme: "mariadb" },
{ id: "mysql", label: "MySQL", driver: "mysql", port: 3306, icon: "db-mysql", scheme: "mysql" },
{ id: "neon", label: "Neon", driver: "postgres", port: 5432, icon: "db-neon", scheme: "postgresql", ssl: "require" },
{ id: "oceanbase", label: "OceanBase (MySQL mode)", driver: "mysql", port: 2881, icon: "db-oceanbase", scheme: "oceanbase" },
{ id: "postgres", label: "PostgreSQL", driver: "postgres", port: 5432, icon: "db-postgres", scheme: "postgresql" },
{ id: "singlestore", label: "SingleStore", driver: "mysql", port: 3306, icon: "db-singlestore", scheme: "singlestore" },
{ id: "sqlite", label: "SQLite", driver: "sqlite", port: null, icon: "db-sqlite", scheme: "sqlite" },
{ id: "supabase", label: "Supabase", driver: "postgres", port: 5432, icon: "db-supabase", scheme: "postgresql", ssl: "require" },
{ id: "tidb", label: "TiDB", driver: "mysql", port: 4000, icon: "db-tidb", scheme: "mysql" },
{ id: "timescaledb", label: "TimescaleDB", driver: "postgres", port: 5432, icon: "db-timescale", scheme: "postgresql" },
{ id: "yugabytedb", label: "YugabyteDB", driver: "postgres", port: 5433, icon: "db-yugabyte", scheme: "postgresql" },
];
const BY_ID = new Map(VENDORS.map((v) => [v.id, v]));
/** The type of a data source: its preset, else its driver's own entry. */
export function vendorOf(s: Pick<DataSource, "vendor" | "driver">): DbVendor {
const v = s.vendor ? BY_ID.get(s.vendor) : undefined;
return v && v.driver === (s.driver ?? "sqlite") ? v : BY_ID.get(s.driver ?? "sqlite")!;
}
export const vendorById = (id: string): DbVendor | undefined => BY_ID.get(id);
/** The fields a new data source of type `v` starts with. */
export function vendorPreset(v: DbVendor): Partial<DataSource> {
const preset: Partial<DataSource> = { driver: v.driver, vendor: v.id === v.driver ? "" : v.id, port: v.port };
if (v.ssl) preset.ssl = v.ssl;
return preset;
}
/** URL schemes (besides the JDBC ones) that name a type. */
const SCHEMES: Record<string, string> = {
postgres: "postgres",
postgresql: "postgres",
pgsql: "postgres",
redshift: "redshift",
cockroachdb: "cockroachdb",
cockroach: "cockroachdb",
yugabytedb: "yugabytedb",
timescaledb: "timescaledb",
greenplum: "greenplum",
mysql: "mysql",
mariadb: "mariadb",
tidb: "tidb",
singlestore: "singlestore",
memsql: "singlestore",
oceanbase: "oceanbase",
};
export const vendorOfScheme = (scheme: string): DbVendor | undefined => BY_ID.get(SCHEMES[scheme.toLowerCase()] ?? "");
const bracketed = (host: string) => (host.includes(":") && !host.startsWith("[") ? `[${host}]` : host);
/** The JDBC URL of a data source's fields (the General tab's URL preview). */
export function sourceUrl(s: DataSource): string {
const v = vendorOf(s);
if (v.driver === "sqlite") return `jdbc:sqlite:${s.file ?? ""}`;
const host = bracketed(s.host?.trim() || "localhost");
const port = s.port ?? v.port;
const db = s.database ? `/${encodeURIComponent(s.database)}` : "";
return `jdbc:${v.scheme}://${host}${port ? `:${port}` : ""}${db}`;
}
+45 -6
Переглянути файл
@@ -5,10 +5,16 @@
* (`ide-http`).
*/
import { activeEditor, registerEditorContribution, registerLanguageMode } from "@/editor/api";
import { registerActions } from "@/platform/actions";
import { executeAction, getAction, registerActions } from "@/platform/actions";
import { call, commands, emitLocalIdeEvent, IpcError, isTauri } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { editorService, notifications, registerServiceView, showServiceView } from "@/workbench/contributions";
import {
editorService,
notifications,
registerMenuGroup,
registerServiceView,
showServiceView,
} from "@/workbench/contributions";
import { HTTP_LANGUAGE, httpEditorExtension, refreshRequests, requestAtCaret } from "./editor";
import { HttpClientHeaderActions, HttpClientView } from "./HttpClientView";
import { httpLanguageDescription } from "./language";
@@ -44,11 +50,31 @@ async function runAtCaret() {
await runRequest(filePath(e.ctx.path, e.ctx.virtual), e.view.state.doc.toString(), r.index);
}
/** New HTTP Request: a scratch `requests[_n].http` with an example request. */
async function newRequest() {
/** New › HTTP Request in `directory`: the name prompt of the file templates (features/templates), with the example body. */
function newRequestArg(directory: string) {
return {
directory,
template: {
id: "http-request",
name: "HTTP Request",
icon: "file-http",
extension: "http",
nameStyle: "asis",
kinds: [{ id: "request", label: "HTTP Request", body: NEW_REQUEST_TEMPLATE }],
},
};
}
function directoryArg(arg: unknown): string | null {
const dir = (arg as { directory?: unknown } | undefined)?.directory;
return typeof dir === "string" && dir ? dir : null;
}
/** New HTTP Request: `requests[_n].http` in `directory`, else a scratch, with an example request. */
async function newRequest(directory?: string) {
try {
const { appDataDir } = await import("@tauri-apps/api/path");
const dir = `${(await appDataDir()).replace(/\/+$/, "")}/scratches`;
const dir = directory ?? `${(await appDataDir()).replace(/\/+$/, "")}/scratches`;
try {
await call(commands.fsCreateDir(dir));
} catch (e) {
@@ -88,6 +114,13 @@ export function activate(): () => void {
component: HttpClientView,
headerActions: HttpClientHeaderActions,
}),
registerMenuGroup({
id: "http.new",
menus: ["new"],
order: 20,
items: (_menu, { paths: [directory] }) =>
directory ? [{ kind: "action", action: "http.newRequest", arg: { directory }, label: "HTTP Request" }] : [],
}),
registerActions([
{
id: "http.runRequest",
@@ -115,7 +148,13 @@ export function activate(): () => void {
icon: "file-http",
keywords: ["http client", "rest", "scratch", "curl"],
when: "projectOpen",
run: () => (isTauri() ? newRequest() : undefined),
// `{ directory }` (New › HTTP Request): ask for the name there, like IDEA.
run: async ({ arg }) => {
const directory = directoryArg(arg);
if (directory && getAction("templates.newFromTemplate"))
await executeAction("templates.newFromTemplate", newRequestArg(directory));
else if (isTauri()) await newRequest(directory ?? undefined);
},
},
{
id: "http.showToolWindow",
+6
Переглянути файл
@@ -12,6 +12,8 @@ import { activate as codeInsight } from "./code-insight";
import { activate as vcs } from "./vcs";
import { activate as terminal } from "./terminal";
import { activate as run } from "./run";
import { activate as runAnything } from "./run-anything";
import { activate as macros } from "./macros";
import { activate as debuggerFeature } from "./debugger";
import { activate as coverage } from "./coverage";
import { activate as profiler } from "./profiler";
@@ -25,6 +27,7 @@ import { activate as perf } from "./perf";
import { activate as plugins, activateSettingsPages as pluginsPages } from "./plugins";
import { activate as tools, activateSettingsPages as toolsPages } from "./tools";
import { activate as scratch } from "./scratch";
import { activate as libraries } from "./libraries";
import { activate as viewers } from "./viewers";
import { activate as web } from "./web";
import { activate as analysis } from "./analysis";
@@ -45,6 +48,8 @@ const features: Array<[string, Activate]> = [
["vcs", vcs],
["terminal", terminal],
["run", run],
["run-anything", runAnything],
["macros", macros],
["debugger", debuggerFeature],
["coverage", coverage],
["profiler", profiler],
@@ -58,6 +63,7 @@ const features: Array<[string, Activate]> = [
["plugins", plugins],
["tools", tools],
["scratch", scratch],
["libraries", libraries],
["viewers", viewers],
["web", web],
["analysis", analysis],
+151
Переглянути файл
@@ -0,0 +1,151 @@
/**
* External Libraries: the project's SDK / library source folders (the Python
* interpreter's standard library and site-packages, the JDK, node_modules
* packages, Cargo crates and the Rust standard library, GOROOT and Go modules,
* Maven / Gradle dependency jars) as a Project view root. Their files open
* read-only, like IDEA's library sources. Archives (the JDK's `src.zip`, jars)
* are browsed through the backend's `<archive>!/<entry>` paths.
*/
import { commands, type LibraryGroup } from "@/bindings";
import { setLibraryRoots } from "@/editor/api";
import { call, isTauri, onIdeEvent } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { registerProjectViewRoot, type ProjectViewRootContribution } from "@/workbench/contributions";
export const EXTERNAL_LIBRARIES_LABEL = "External Libraries";
const GROUP_ICONS: Record<string, string> = {
python: "file-python",
jdk: "file-java",
nodeCore: "file-js",
nodeModules: "file-npm",
rust: "file-rust",
cargo: "file-cargo",
go: "file-go",
goModules: "file-gomod",
maven: "maven-project",
gradle: "file-gradle",
};
/** An archive's root (`/jdk/lib/src.zip!`): its entries live below `<archive>!/`. */
const isArchiveRoot = (path: string) => path.endsWith("!");
/** Files whose change can change the libraries (the backend re-detects only the affected ecosystem). */
const MANIFESTS = new Set([
"pyvenv.cfg",
"pom.xml",
"build.gradle",
"build.gradle.kts",
"settings.gradle",
"settings.gradle.kts",
"package.json",
"package-lock.json",
"yarn.lock",
"pnpm-lock.yaml",
"bun.lockb",
"Cargo.toml",
"Cargo.lock",
"rust-toolchain",
"rust-toolchain.toml",
"go.mod",
"go.sum",
"go.work",
]);
/** Whether an `fsChanged` event touches a library manifest. */
export function touchesManifest(paths: readonly string[]): boolean {
return paths.some(
(p) =>
!/[\\/]node_modules[\\/]/.test(p) && MANIFESTS.has(p.slice(Math.max(p.lastIndexOf("/"), p.lastIndexOf("\\")) + 1)),
);
}
/** The "External Libraries" node for `groups` (`< Python 3.12 (.venv) >` › `python3.12`, `site-packages`). */
export function librariesRoot(groups: readonly LibraryGroup[]): ProjectViewRootContribution | null {
if (!groups.length) return null;
return {
id: "externalLibraries",
label: EXTERNAL_LIBRARIES_LABEL,
icon: "library",
// After the project, before Scratches and Consoles (IDEA's order).
order: 5,
readOnly: true,
children: groups.map((g) => ({
label: `< ${g.name} >`,
icon: GROUP_ICONS[g.kind] ?? "library",
children: g.roots.map((r) => ({
label: r.name,
icon: isArchiveRoot(r.path) ? "file-archive" : "folder-library",
path: r.path,
detail: "library root",
})),
})),
};
}
/** Settings that change which SDKs the project uses. */
const sdkKey = (s: ReturnType<typeof useProject.getState>) => {
const l = s.settings?.languages;
return JSON.stringify([
s.project?.root ?? null,
l?.python?.interpreter,
l?.java?.jdk,
l?.node?.interpreter,
l?.rust?.toolchain,
l?.rust?.stdlib,
l?.go?.goroot,
l?.go?.gopath,
]);
};
const MANIFEST_REFRESH_DELAY = 500;
export function activate(): () => void {
let disposeRoot = () => {};
let disposeReadOnly = () => {};
let seq = 0;
let shown = "";
const clear = () => {
disposeRoot();
disposeReadOnly();
disposeRoot = disposeReadOnly = () => {};
shown = "";
};
const refresh = () => {
const id = ++seq;
if (!useProject.getState().project || !isTauri()) return clear();
call(commands.sdkProjectLibraries())
.then((groups) => {
if (id !== seq) return;
const key = JSON.stringify(groups);
if (key === shown) return;
clear();
shown = key;
const root = librariesRoot(groups);
if (!root) return;
disposeReadOnly = setLibraryRoots(
"externalLibraries",
groups.flatMap((g) => g.roots.map((r) => r.path)),
);
disposeRoot = registerProjectViewRoot(root);
})
.catch((e: unknown) => console.warn("external libraries: cannot list SDK roots", e));
};
refresh();
const unsub = useProject.subscribe((s, prev) => {
if (sdkKey(s) !== sdkKey(prev)) refresh();
});
let timer: ReturnType<typeof setTimeout> | undefined;
const unsubFs = onIdeEvent("fsChanged", (e) => {
if (!touchesManifest([...e.created, ...e.modified, ...e.removed])) return;
clearTimeout(timer);
timer = setTimeout(refresh, MANIFEST_REFRESH_DELAY);
});
return () => {
seq++;
clearTimeout(timer);
unsub();
unsubFs();
clear();
};
}
+84
Переглянути файл
@@ -0,0 +1,84 @@
import { afterEach, describe, expect, it } from "vitest";
import { isLibraryPath, setLibraryRoots } from "@/editor/api";
import { librariesRoot, touchesManifest } from "./index";
describe("External Libraries", () => {
let dispose = () => {};
afterEach(() => dispose());
it("groups each SDK's folders under one read-only root", () => {
expect(librariesRoot([])).toBeNull();
const root = librariesRoot([
{
name: "Python 3.12 (.venv)",
kind: "python",
roots: [
{ name: "python3.12", path: "/py/lib/python3.12" },
{ name: "site-packages", path: "/p/.venv/lib/python3.12/site-packages" },
],
},
])!;
expect(root).toMatchObject({ id: "externalLibraries", label: "External Libraries", readOnly: true });
expect(root.children?.[0]).toMatchObject({ label: "< Python 3.12 (.venv) >", icon: "file-python" });
expect(root.children?.[0]?.children?.map((c) => [c.label, c.path, c.detail])).toEqual([
["python3.12", "/py/lib/python3.12", "library root"],
["site-packages", "/p/.venv/lib/python3.12/site-packages", "library root"],
]);
});
it("gives each ecosystem's groups their icon", () => {
const root = librariesRoot(
["jdk", "nodeCore", "nodeModules", "rust", "cargo", "go", "goModules", "maven", "gradle", "other"].map((kind) => ({
name: kind,
kind,
roots: [{ name: "r", path: `/libs/${kind}` }],
})),
)!;
expect(root.children?.map((g) => g.icon)).toEqual([
"file-java",
"file-js",
"file-npm",
"file-rust",
"file-cargo",
"file-go",
"file-gomod",
"maven-project",
"file-gradle",
"library",
]);
});
it("lists archives (src.zip, dependency jars) as browsable roots", () => {
const root = librariesRoot([
{
name: "temurin-21",
kind: "jdk",
roots: [
{ name: "temurin-21", path: "/jdk" },
{ name: "src.zip", path: "/jdk/lib/src.zip!" },
],
},
{ name: "Maven: g:a:1", kind: "maven", roots: [{ name: "a-1-sources.jar", path: "/m2/g/a/1/a-1-sources.jar!" }] },
])!;
expect(root.children?.[0]?.children?.map((c) => c.icon)).toEqual(["folder-library", "file-archive"]);
expect(root.children?.[1]).toMatchObject({ label: "< Maven: g:a:1 >", icon: "maven-project" });
dispose = setLibraryRoots("test", ["/jdk/lib/src.zip!"]);
expect(isLibraryPath("/jdk/lib/src.zip!/java.base/java/lang/String.java")).toBe(true);
});
it("refreshes on manifest changes outside node_modules", () => {
expect(touchesManifest(["/p/src/main.rs", "/p/Cargo.toml"])).toBe(true);
expect(touchesManifest(["C:\\p\\go.mod"])).toBe(true);
expect(touchesManifest(["/p/.venv/pyvenv.cfg"])).toBe(true);
expect(touchesManifest(["/p/node_modules/react/package.json"])).toBe(false);
expect(touchesManifest(["/p/src/package.json.bak", "/p/README.md"])).toBe(false);
});
it("marks files below library roots for read-only editors", () => {
dispose = setLibraryRoots("test", ["/py/lib/python3.12"]);
expect(isLibraryPath("/py/lib/python3.12/os.py")).toBe(true);
expect(isLibraryPath("/py/lib/python3.120/x.py")).toBe(false);
dispose();
expect(isLibraryPath("/py/lib/python3.12/os.py")).toBe(false);
});
});
+111
Переглянути файл
@@ -0,0 +1,111 @@
/** The "Macro recording" status bar item and the Edit Macros dialog. */
import { useState } from "react";
import { create } from "zustand";
import { executeAction, getAction } from "@/platform/actions";
import { t } from "@/platform/i18n";
import { BarButton, Button, Dialog, Icon, List, T } from "@/ui";
import { confirm } from "@/workbench/messages";
import { promptInput } from "@/workbench/inputDialogs";
import { deleteMacro, describeStep, renameMacro, useMacros } from "./model";
import styles from "./macros.module.css";
const useEditOpen = create<{ open: boolean }>(() => ({ open: false }));
export const showEditMacros = () => useEditOpen.setState({ open: true });
const actionTitle = (id: string) => getAction(id)?.title ?? id;
/** Shown while recording (IDEA: "Macro recording started" in the status bar); click stops. */
export function MacroStatus() {
const steps = useMacros((s) => s.recording?.length);
if (steps === undefined) return null;
return (
<BarButton
className={styles.status}
tooltip="Stop Macro Recording"
aria-label="Stop Macro Recording"
onClick={() => void executeAction("macro.stopRecording")}
>
<span className={styles.dot} aria-hidden="true" />
<T>Recording macro…</T>
<Icon name="stop" size={14} />
</BarButton>
);
}
export function MacrosDialogHost() {
const open = useEditOpen((s) => s.open);
return open ? <EditMacrosDialog onClose={() => useEditOpen.setState({ open: false })} /> : null;
}
function EditMacrosDialog({ onClose }: { onClose: () => void }) {
const saved = useMacros((s) => s.saved);
const [active, setActive] = useState(0);
const macro = saved[Math.min(active, saved.length - 1)];
const rename = async () => {
if (!macro) return;
const name = await promptInput({
title: "Rename Macro",
label: "New name:",
initial: macro.name,
okLabel: "Rename",
validate: (v) =>
!v.trim()
? "Enter a macro name"
: v.trim() !== macro.name && saved.some((m) => m.name === v.trim())
? "A macro with this name exists"
: null,
});
if (name) renameMacro(macro.name, name);
};
const remove = async () => {
if (!macro) return;
if (await confirm({ title: "Delete Macro", message: `Delete the macro '${macro.name}'?`, ok: "Delete", icon: "warning" }))
deleteMacro(macro.name);
};
return (
<Dialog
open
title="Edit Macros"
onClose={onClose}
width={620}
buttons={[{ label: t("common.close"), role: "cancel" }]}
>
<div className={styles.edit}>
<div className={styles.column}>
<List
items={saved}
getKey={(m) => m.name}
activeIndex={Math.min(active, saved.length - 1)}
onActiveChange={setActive}
rowHeight={24}
className={styles.list}
aria-label="Macros"
empty={
<div className={styles.empty}>
<T>No saved macros</T>
</div>
}
renderItem={(m) => <div className={styles.row}>{m.name}</div>}
/>
<div className={styles.buttons}>
<Button disabled={!macro} onClick={() => void rename()}>
<T>Rename…</T>
</Button>
<Button disabled={!macro} onClick={() => void remove()}>
<T>Delete</T>
</Button>
</div>
</div>
<ol className={styles.steps} aria-label="Recorded steps">
{macro?.steps.map((step, i) => (
<li key={i} className={styles.step}>
{describeStep(step, actionTitle)}
</li>
))}
</ol>
</div>
</Dialog>
);
}
+89
Переглянути файл
@@ -0,0 +1,89 @@
/**
* Editing and caret keys a macro records in the editor (keys the IDE keymap
* does not bind run CodeMirror commands directly), and their replay.
*/
import {
cursorCharLeft,
cursorCharRight,
cursorDocEnd,
cursorDocStart,
cursorGroupLeft,
cursorGroupRight,
cursorLineBoundaryBackward,
cursorLineBoundaryForward,
cursorLineDown,
cursorLineUp,
cursorPageDown,
cursorPageUp,
deleteCharBackward,
deleteCharForward,
deleteGroupBackward,
deleteGroupForward,
indentLess,
insertNewlineAndIndent,
insertTab,
selectCharLeft,
selectCharRight,
selectDocEnd,
selectDocStart,
selectGroupLeft,
selectGroupRight,
selectLineBoundaryBackward,
selectLineBoundaryForward,
selectLineDown,
selectLineUp,
selectPageDown,
selectPageUp,
} from "@codemirror/commands";
import type { Command } from "@codemirror/view";
/** Recorded key name (`Shift+ArrowLeft`, `Alt+Backspace`) → command. */
export const EDITOR_KEY_COMMANDS: Record<string, Command> = {
Enter: insertNewlineAndIndent,
Backspace: deleteCharBackward,
Delete: deleteCharForward,
"Alt+Backspace": deleteGroupBackward,
"Alt+Delete": deleteGroupForward,
Tab: insertTab,
"Shift+Tab": indentLess,
ArrowLeft: cursorCharLeft,
ArrowRight: cursorCharRight,
ArrowUp: cursorLineUp,
ArrowDown: cursorLineDown,
"Shift+ArrowLeft": selectCharLeft,
"Shift+ArrowRight": selectCharRight,
"Shift+ArrowUp": selectLineUp,
"Shift+ArrowDown": selectLineDown,
"Alt+ArrowLeft": cursorGroupLeft,
"Alt+ArrowRight": cursorGroupRight,
"Alt+Shift+ArrowLeft": selectGroupLeft,
"Alt+Shift+ArrowRight": selectGroupRight,
Home: cursorLineBoundaryBackward,
End: cursorLineBoundaryForward,
"Shift+Home": selectLineBoundaryBackward,
"Shift+End": selectLineBoundaryForward,
PageUp: cursorPageUp,
PageDown: cursorPageDown,
"Shift+PageUp": selectPageUp,
"Shift+PageDown": selectPageDown,
"Meta+ArrowLeft": cursorLineBoundaryBackward,
"Meta+ArrowRight": cursorLineBoundaryForward,
"Meta+Shift+ArrowLeft": selectLineBoundaryBackward,
"Meta+Shift+ArrowRight": selectLineBoundaryForward,
"Meta+ArrowUp": cursorDocStart,
"Meta+ArrowDown": cursorDocEnd,
"Ctrl+Home": cursorDocStart,
"Ctrl+End": cursorDocEnd,
"Ctrl+Shift+Home": selectDocStart,
"Ctrl+Shift+End": selectDocEnd,
"Ctrl+ArrowLeft": cursorGroupLeft,
"Ctrl+ArrowRight": cursorGroupRight,
};
/** The recorded name of a key event, or null when it is not an editor key a macro records. */
export function editorKeyName(e: Pick<KeyboardEvent, "key" | "altKey" | "ctrlKey" | "metaKey" | "shiftKey">): string | null {
const name = [e.metaKey && "Meta", e.ctrlKey && "Ctrl", e.altKey && "Alt", e.shiftKey && "Shift", e.key]
.filter(Boolean)
.join("+");
return name in EDITOR_KEY_COMMANDS ? name : null;
}
+200
Переглянути файл
@@ -0,0 +1,200 @@
/**
* Edit › Macros: Start / Stop Macro Recording, Play Back Last Macro, Play
* Saved Macros… and Edit Macros…. Every saved macro is also an action
* (`macros.play.<name>`), so it can get a shortcut in the keymap.
*/
import { EditorView } from "@codemirror/view";
import { activeEditor, registerEditorContribution } from "@/editor/api";
import { executeAction, onActionExecuted, registerActions, type Action } from "@/platform/actions";
import { setContext } from "@/platform/context";
import { notifications, registerOverlay, registerStatusItem } from "@/workbench/contributions";
import { chooseItem, promptInput } from "@/workbench/inputDialogs";
import { EDITOR_KEY_COMMANDS, editorKeyName } from "./editorKeys";
import { MacroStatus, MacrosDialogHost, showEditMacros } from "./MacrosDialog";
import {
playSteps,
recordStep,
saveMacro,
startRecording,
stopRecording,
storableArg,
useMacros,
type MacroStep,
} from "./model";
/** Records typing and editor keys of every text editor while a macro records. */
const recorder = [
EditorView.inputHandler.of((_view, _from, _to, text) => {
if (useMacros.getState().recording && !useMacros.getState().playing) recordStep({ kind: "text", text });
return false;
}),
EditorView.domEventHandlers({
keydown(e) {
// Keys the IDE keymap ran as actions were recorded as those actions.
if (e.defaultPrevented || e.isComposing || !useMacros.getState().recording) return false;
const key = editorKeyName(e);
if (key) recordStep({ kind: "key", key });
return false;
},
}),
];
function typeText(text: string): boolean {
const view = activeEditor()?.view;
if (!view) return false;
const { from, to } = view.state.selection.main;
const insert = () => view.state.update(view.state.replaceSelection(text), { userEvent: "input.type" });
// Input handlers (auto-closing brackets, completion triggers) see it as typed.
const handled = view.state.facet(EditorView.inputHandler).some((h) => h(view, from, to, text, insert));
if (!handled) view.dispatch(insert());
return true;
}
function pressKey(key: string): boolean {
const view = activeEditor()?.view;
const command = EDITOR_KEY_COMMANDS[key];
if (!view || !command) return false;
command(view);
return true;
}
export async function playMacro(steps: readonly MacroStep[], name?: string) {
const ok = await playSteps(steps, { action: (id, arg) => executeAction(id, arg), text: typeText, key: pressKey });
if (!ok)
notifications().notify({
level: "warning",
title: name ? `Macro '${name}' stopped` : "Macro playback stopped",
body: "A recorded step could not run here.",
key: "macro.playback",
});
}
async function stop() {
const steps = stopRecording();
if (!steps.length) {
notifications().notify({ level: "info", title: "Nothing was recorded", key: "macro.recording" });
return;
}
const name = await promptInput({
title: "Enter Macro Name",
label: "Name (leave empty to keep it only as the last macro):",
okLabel: "OK",
validate: () => null,
});
if (name) saveMacro(name, steps);
}
const playActionId = (name: string) => `macros.play.${name}`;
/** One action per saved macro, kept in step with the saved list. */
function registerSavedMacroActions(): () => void {
let dispose = () => {};
let names = "";
const sync = () => {
const saved = useMacros.getState().saved;
const key = saved.map((m) => m.name).join("\n");
if (key === names) return;
names = key;
dispose();
dispose = registerActions(
saved.map(
(m): Action => ({
id: playActionId(m.name),
title: m.name,
category: "Macros",
keywords: ["macro", "play"],
when: "!macroRecording",
run: () => {
const macro = useMacros.getState().saved.find((x) => x.name === m.name);
if (macro) return playMacro(macro.steps, macro.name);
},
}),
),
);
};
sync();
const unsub = useMacros.subscribe(sync);
return () => {
unsub();
dispose();
};
}
export function activate(): () => void {
const publish = () => {
const s = useMacros.getState();
setContext("macroRecording", s.recording !== null);
setContext("macroLastAvailable", !!s.last?.length);
setContext("macrosSaved", s.saved.length > 0);
};
publish();
const disposers = [
useMacros.subscribe(publish),
onActionExecuted((id, arg) => {
const s = useMacros.getState();
if (!s.recording || s.playing || id.startsWith("macro.") || id.startsWith("macros.")) return;
const stored = storableArg(arg);
// Actions run with an argument that cannot be stored (a menu's context) are left out.
if (stored === null) return;
recordStep(stored === undefined ? { kind: "action", id } : { kind: "action", id, arg: stored });
}),
registerEditorContribution({ id: "macros.recorder", order: 0, extensions: () => recorder }),
registerOverlay({ id: "macros.dialogs", component: MacrosDialogHost }),
registerStatusItem({ id: "macros.recording", align: "left", priority: 4, component: MacroStatus }),
registerSavedMacroActions(),
registerActions([
{
id: "macro.startRecording",
title: "Start Macro Recording",
category: "Edit",
keywords: ["macro", "record"],
when: "!macroRecording",
run: startRecording,
},
{
id: "macro.stopRecording",
title: "Stop Macro Recording",
category: "Edit",
icon: "stop",
keywords: ["macro", "record"],
when: "macroRecording",
run: stop,
},
{
id: "macro.playBack",
title: "Play Back Last Macro",
category: "Edit",
keywords: ["macro", "replay"],
when: "!macroRecording && macroLastAvailable",
run: () => {
const last = useMacros.getState().last;
if (last) return playMacro(last);
},
},
{
id: "macro.playSaved",
title: "Play Saved Macros…",
category: "Edit",
keywords: ["macro", "replay"],
when: "!macroRecording && macrosSaved",
run: async () => {
const name = await chooseItem({ title: "Play Saved Macro", items: useMacros.getState().saved.map((m) => m.name) });
const macro = useMacros.getState().saved.find((m) => m.name === name);
if (macro) await playMacro(macro.steps, macro.name);
},
},
{
id: "macro.edit",
title: "Edit Macros…",
category: "Edit",
keywords: ["macro", "rename", "delete"],
when: "!macroRecording && macrosSaved",
run: showEditMacros,
},
]),
];
return () => {
if (useMacros.getState().recording) stopRecording();
disposers.reverse().forEach((d) => d());
};
}
+58
Переглянути файл
@@ -0,0 +1,58 @@
.status {
gap: 6px;
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--danger);
}
.edit {
display: grid;
grid-template-columns: 220px 1fr;
gap: 12px;
height: 300px;
padding: 14px;
}
.column {
display: flex;
flex-direction: column;
gap: 8px;
min-height: 0;
}
.list {
flex: 1;
min-height: 0;
border: 1px solid var(--border);
border-radius: var(--radius-control);
}
.row {
padding: 0 8px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.buttons {
display: flex;
gap: 6px;
}
.empty {
padding: 6px 8px;
color: var(--fg-subtle);
font-size: var(--fs-small);
}
.steps {
margin: 0;
padding: 6px 8px 6px 32px;
overflow: auto;
border: 1px solid var(--border);
border-radius: var(--radius-control);
font-family: var(--font-mono);
font-size: var(--fs-code);
color: var(--fg);
}
.step {
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
}
+112
Переглянути файл
@@ -0,0 +1,112 @@
import { beforeEach, describe, expect, it } from "vitest";
import { editorKeyName } from "./editorKeys";
import {
_reloadMacros,
deleteMacro,
describeStep,
playSteps,
recordStep,
renameMacro,
saveMacro,
startRecording,
stopRecording,
storableArg,
STORAGE_KEY,
useMacros,
} from "./model";
beforeEach(() => {
localStorage.clear();
_reloadMacros();
});
describe("macros", () => {
it("records actions, merged typing and keys only while recording", () => {
recordStep({ kind: "text", text: "ignored" });
startRecording();
recordStep({ kind: "text", text: "he" });
recordStep({ kind: "text", text: "llo" });
recordStep({ kind: "key", key: "Enter" });
recordStep({ kind: "action", id: "editor.duplicateLine" });
const steps = stopRecording();
expect(steps).toEqual([
{ kind: "text", text: "hello" },
{ kind: "key", key: "Enter" },
{ kind: "action", id: "editor.duplicateLine" },
]);
expect(useMacros.getState().last).toEqual(steps);
// An empty recording keeps the last macro.
startRecording();
stopRecording();
expect(useMacros.getState().last).toEqual(steps);
});
it("persists saved macros; renames and deletes them", () => {
saveMacro("Dup", [{ kind: "action", id: "editor.duplicateLine" }]);
saveMacro("Two", []);
expect(renameMacro("Dup", "Two")).toBe(false);
expect(renameMacro("Dup", "Duplicate")).toBe(true);
_reloadMacros();
expect(useMacros.getState().saved.map((m) => m.name)).toEqual(["Duplicate", "Two"]);
deleteMacro("Two");
_reloadMacros();
expect(useMacros.getState().saved.map((m) => m.name)).toEqual(["Duplicate"]);
});
it("drops malformed stored steps", () => {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({ last: [{ kind: "text", text: "a" }, { kind: "action" }, { kind: "action", id: "x", arg: {} }], saved: [{ name: "" }] }),
);
_reloadMacros();
expect(useMacros.getState().last).toEqual([{ kind: "text", text: "a" }]);
expect(useMacros.getState().saved).toEqual([]);
});
it("plays steps in order and stops at the first that cannot run; nothing is recorded meanwhile", async () => {
const log: string[] = [];
startRecording();
const ok = await playSteps(
[
{ kind: "action", id: "a", arg: 1 },
{ kind: "text", text: "t" },
{ kind: "key", key: "Tab" },
{ kind: "action", id: "b" },
],
{
action: async (id, arg) => {
recordStep({ kind: "action", id });
log.push(`${id}${arg ?? ""}`);
return id !== "b";
},
text: (t) => (log.push(t), true),
key: (k) => (log.push(k), true),
},
);
expect(ok).toBe(false);
expect(log).toEqual(["a1", "t", "Tab", "b"]);
expect(useMacros.getState().recording).toEqual([]);
expect(useMacros.getState().playing).toBe(false);
});
it("stores plain action arguments only and describes steps", () => {
expect(storableArg("x")).toBe("x");
expect(storableArg(2)).toBe(2);
expect(storableArg(undefined)).toBeUndefined();
expect(storableArg({ path: "/a" })).toBeNull();
expect(storableArg(Number.NaN)).toBeNull();
const title = (id: string) => (id === "a" ? "Action A" : id);
expect(describeStep({ kind: "action", id: "a", arg: "x" }, title)).toBe("Action A (x)");
expect(describeStep({ kind: "text", text: "a\nb" }, title)).toBe('Type "a↵b"');
expect(describeStep({ kind: "key", key: "Enter" }, title)).toBe("Press Enter");
});
it("names the editor keys a macro records", () => {
const k = (key: string, mods: Partial<Record<"altKey" | "ctrlKey" | "metaKey" | "shiftKey", boolean>> = {}) =>
editorKeyName({ key, altKey: false, ctrlKey: false, metaKey: false, shiftKey: false, ...mods });
expect(k("Enter")).toBe("Enter");
expect(k("ArrowLeft", { shiftKey: true, altKey: true })).toBe("Alt+Shift+ArrowLeft");
expect(k("a")).toBeNull();
expect(k("s", { metaKey: true })).toBeNull();
});
});
+159
Переглянути файл
@@ -0,0 +1,159 @@
/**
* Edit › Macros (IDEA's ActionMacroManager): a macro is the list of actions
* the user ran plus what they typed and the editing / caret keys they pressed
* in the editor. The last recording and the named macros persist in
* localStorage (per user, like live templates).
*/
import { create } from "zustand";
/** An editor key replayed as a CodeMirror command (see `editorKeys.ts`). */
export type MacroStep =
| { kind: "action"; id: string; arg?: string | number | boolean }
| { kind: "text"; text: string }
| { kind: "key"; key: string };
export interface Macro {
name: string;
steps: MacroStep[];
}
interface MacroState {
/** Steps recorded so far, while recording. */
recording: MacroStep[] | null;
/** A macro is being played back. */
playing: boolean;
/** The last recorded macro (Play Back Last Macro). */
last: MacroStep[] | null;
saved: Macro[];
}
export const STORAGE_KEY = "openidea.macros.v1";
function isStep(v: unknown): v is MacroStep {
if (!v || typeof v !== "object") return false;
const s = v as Record<string, unknown>;
if (s.kind === "action") return typeof s.id === "string" && ["undefined", "string", "number", "boolean"].includes(typeof s.arg);
if (s.kind === "text") return typeof s.text === "string";
return s.kind === "key" && typeof s.key === "string";
}
const steps = (v: unknown): MacroStep[] | null => (Array.isArray(v) ? v.filter(isStep) : null);
function read(): Pick<MacroState, "last" | "saved"> {
try {
const raw = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "null") as { last?: unknown; saved?: unknown } | null;
const saved = Array.isArray(raw?.saved)
? raw.saved
.filter((m): m is { name: string; steps: unknown } => !!m && typeof m.name === "string" && !!m.name.trim())
.map((m) => ({ name: m.name, steps: steps(m.steps) ?? [] }))
: [];
return { last: steps(raw?.last), saved };
} catch {
return { last: null, saved: [] };
}
}
export const useMacros = create<MacroState>(() => ({ recording: null, playing: false, ...read() }));
function persist() {
const { last, saved } = useMacros.getState();
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify({ last, saved }));
} catch {
/* storage unavailable */
}
}
/** Test helper: re-reads storage. */
export function _reloadMacros() {
useMacros.setState({ recording: null, playing: false, ...read() });
}
/** Appends a step to the recording; consecutive typing is merged into one text step. */
export function recordStep(step: MacroStep) {
const rec = useMacros.getState().recording;
if (!rec || useMacros.getState().playing) return;
const prev = rec[rec.length - 1];
const next =
step.kind === "text" && prev?.kind === "text"
? [...rec.slice(0, -1), { kind: "text" as const, text: prev.text + step.text }]
: [...rec, step];
useMacros.setState({ recording: next });
}
/** An action's argument when it can be stored (plain values only). */
export function storableArg(arg: unknown): string | number | boolean | undefined | null {
if (arg === undefined) return undefined;
if (typeof arg === "string" || typeof arg === "boolean" || (typeof arg === "number" && Number.isFinite(arg))) return arg;
return null;
}
export function startRecording() {
useMacros.setState({ recording: [] });
}
/** Ends the recording; returns its steps (also the new "last macro" when not empty). */
export function stopRecording(): MacroStep[] {
const rec = useMacros.getState().recording ?? [];
useMacros.setState({ recording: null, ...(rec.length ? { last: rec } : {}) });
persist();
return rec;
}
/** Saves `steps` as `name`, replacing a macro of that name. */
export function saveMacro(name: string, macroSteps: MacroStep[]) {
const n = name.trim();
if (!n) return;
const saved = useMacros.getState().saved;
const i = saved.findIndex((m) => m.name === n);
const entry = { name: n, steps: macroSteps };
useMacros.setState({ saved: i >= 0 ? saved.map((m, j) => (j === i ? entry : m)) : [...saved, entry] });
persist();
}
export function renameMacro(from: string, to: string): boolean {
const n = to.trim();
const saved = useMacros.getState().saved;
if (!n || (n !== from && saved.some((m) => m.name === n))) return false;
useMacros.setState({ saved: saved.map((m) => (m.name === from ? { ...m, name: n } : m)) });
persist();
return true;
}
export function deleteMacro(name: string) {
useMacros.setState((s) => ({ saved: s.saved.filter((m) => m.name !== name) }));
persist();
}
/** One line per step, for Edit Macros. */
export function describeStep(step: MacroStep, title: (id: string) => string): string {
switch (step.kind) {
case "action":
return step.arg === undefined ? title(step.id) : `${title(step.id)} (${String(step.arg)})`;
case "text":
return `Type "${step.text.replace(/\n/g, "↵")}"`;
case "key":
return `Press ${step.key}`;
}
}
/** Runs `macroSteps` one after another; stops at the first step that cannot run. */
export async function playSteps(
macroSteps: readonly MacroStep[],
run: { action(id: string, arg?: unknown): Promise<boolean>; text(text: string): boolean; key(key: string): boolean },
): Promise<boolean> {
if (useMacros.getState().playing) return false;
useMacros.setState({ playing: true });
try {
for (const step of macroSteps) {
const ok =
step.kind === "action" ? await run.action(step.id, step.arg) : step.kind === "text" ? run.text(step.text) : run.key(step.key);
if (!ok) return false;
// Let the UI settle (popups open, editors mount) before the next step.
await new Promise((r) => setTimeout(r, 0));
}
return true;
} finally {
useMacros.setState({ playing: false });
}
}
+10 -1
Переглянути файл
@@ -1,6 +1,15 @@
import { shortcutLabel } from "@/platform/keymap";
import { IconButton, type MenuItem } from "@/ui";
import { setViewOption, useProjectTree, type ViewOptions } from "./store";
import { VIEW_MODES, setViewMode, setViewOption, useProjectTree, type ViewOptions } from "./store";
/** The title's "Project ▾" pane switcher (IDEA: Project / Project Files / Open Files / …). */
export function useProjectTitleMenu(): { label: string; items: MenuItem[] } {
const mode = useProjectTree((s) => s.mode);
return {
label: VIEW_MODES.find(([id]) => id === mode)?.[1] ?? "Project",
items: VIEW_MODES.map(([id, label]) => ({ id, label, checked: id === mode, run: () => setViewMode(id) })),
};
}
export const OPTION_LABELS: Array<[keyof ViewOptions, string]> = [
["showHidden", "Show Hidden Files"],
+69
Переглянути файл
@@ -0,0 +1,69 @@
/* IDEA's New popup: search field over a list of file kinds. */
.popup {
width: 280px;
overflow: hidden;
display: flex;
flex-direction: column;
padding-bottom: 4px;
}
.search {
display: flex;
align-items: center;
gap: 6px;
height: var(--h-row);
padding: 0 10px;
border-bottom: 1px solid var(--border-popup);
margin-bottom: 4px;
}
.searchIcon {
flex: none;
color: var(--fg-muted);
}
.input {
flex: 1;
min-width: 0;
}
.list {
max-height: 420px;
height: auto;
}
.row {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
flex: 1;
}
.icon {
width: 16px;
flex: none;
display: inline-flex;
justify-content: center;
}
.label {
flex: 1;
min-width: 0;
color: var(--fg-emphasis);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.active .label {
color: var(--fg-strong);
}
.shortcut {
flex: none;
margin-left: 16px;
font-family: var(--font-keys);
color: var(--fg-muted);
}
.separator {
flex: 1;
height: 1px;
background: var(--border-popup);
}
.empty {
padding: 6px 10px;
color: var(--fg-faint);
font-size: var(--fs-small);
}
+47
Переглянути файл
@@ -0,0 +1,47 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { act, fireEvent, render, screen } from "@testing-library/react";
import { NewPopupHost } from "./NewPopup";
import { closeNewPopup, openNewPopup, useNewPopup } from "./newPopupModel";
afterEach(() => act(() => closeNewPopup()));
describe("New popup", () => {
it("filters file kinds as you type and runs the selection with Enter", () => {
const python = vi.fn();
const css = vi.fn();
render(<NewPopupHost />);
act(() =>
openNewPopup({ x: 10, y: 10 }, [
{ id: "file", label: "File", icon: "file", run: () => undefined },
{ kind: "separator" },
{ id: "py", label: "Python File", icon: "file-python", run: python },
{ id: "css", label: "Stylesheet", icon: "file-css", run: css },
]),
);
const search = screen.getByRole("textbox", { name: "Search for file type" });
expect(search).toHaveAttribute("placeholder", "Search for file type");
expect(screen.getAllByRole("option").map((o) => o.textContent)).toEqual(["File", "Python File", "Stylesheet"]);
fireEvent.change(search, { target: { value: "sty" } });
expect(screen.getAllByRole("option").map((o) => o.textContent)).toEqual(["Stylesheet"]);
fireEvent.keyDown(search, { key: "Enter" });
expect(css).toHaveBeenCalledOnce();
expect(useNewPopup.getState().request).toBeNull();
});
it("moves the selection with the arrow keys, skipping separators", () => {
const python = vi.fn();
render(<NewPopupHost />);
act(() =>
openNewPopup({ x: 10, y: 10 }, [
{ id: "file", label: "File", run: () => undefined },
{ kind: "separator" },
{ id: "py", label: "Python File", run: python },
]),
);
const search = screen.getByRole("textbox", { name: "Search for file type" });
fireEvent.keyDown(search, { key: "ArrowDown" });
fireEvent.keyDown(search, { key: "Enter" });
expect(python).toHaveBeenCalledOnce();
});
});
+102
Переглянути файл
@@ -0,0 +1,102 @@
import { useMemo, useState, type KeyboardEvent } from "react";
import clsx from "clsx";
import { HighlightedText, Icon, Input, List, Popup, T, firstSelectable, listNavigate, type MenuItem } from "@/ui";
import { closeNewPopup, newPopupRows, useNewPopup, type NewPopupRow } from "./newPopupModel";
import styles from "./NewPopup.module.css";
const isSeparator = (r: NewPopupRow) => !!r.separator;
/** Overlay: IDEA's "New" popup — file kinds with a type-to-filter field. */
export function NewPopupHost() {
const request = useNewPopup((s) => s.request);
if (!request) return null;
return <NewPopup key={request.seq} at={request.at} items={request.items} />;
}
function NewPopup({ at, items }: { at: { x: number; y: number }; items: MenuItem[] }) {
const [query, setQuery] = useState("");
const [active, setActive] = useState(-1);
const rows = useMemo(() => newPopupRows(items, query), [items, query]);
const current =
active >= 0 && active < rows.length && !rows[active]!.separator ? active : firstSelectable(rows, isSeparator);
const pick = (row: NewPopupRow | undefined) => {
if (!row?.run) return;
closeNewPopup();
row.run();
};
const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
const next = listNavigate(e.key === "ArrowUp" || e.key === "ArrowDown" ? e.key : "", {
items: rows,
active: current,
isSection: isSeparator,
wrap: true,
});
if (next !== null) {
e.preventDefault();
setActive(next);
} else if (e.key === "Enter") {
e.preventDefault();
pick(rows[current]);
}
};
return (
<Popup
open
anchor={at}
placement="bottom-start"
onClose={closeNewPopup}
role="dialog"
aria-label="New"
className={styles.popup}
>
<div className={styles.search}>
<Icon name="search" size={14} className={styles.searchIcon} />
<Input
variant="bare"
data-autofocus
value={query}
placeholder="Search for file type"
aria-label="Search for file type"
className={styles.input}
onChange={(e) => {
setQuery(e.target.value);
setActive(-1);
}}
onKeyDown={onKeyDown}
/>
</div>
<List
items={rows}
getKey={(r) => r.key}
isSection={isSeparator}
renderSection={() => <div className={styles.separator} />}
sectionHeight={9}
activeIndex={current}
onActiveChange={setActive}
onActivate={(r) => pick(r)}
focusable={false}
hoverSelects
rowHeight={24}
className={styles.list}
aria-label="File types"
empty={
<div className={styles.empty}>
<T>Nothing found</T>
</div>
}
renderItem={(r, state) => (
<div className={clsx(styles.row, state.active && styles.active)}>
<span className={styles.icon}>{r.icon && <Icon name={r.icon} size={16} />}</span>
<span className={styles.label}>
<HighlightedText text={r.label} indices={r.matches ?? undefined} />
</span>
{r.shortcut && <span className={styles.shortcut}>{r.shortcut}</span>}
</div>
)}
/>
</Popup>
);
}
+5
Переглянути файл
@@ -115,3 +115,8 @@
pointer-events: none;
white-space: nowrap;
}
.empty {
padding: 8px 12px;
color: var(--fg-subtle);
}
+20 -6
Переглянути файл
@@ -21,6 +21,7 @@ import clsx from "clsx";
import { useEditorRegistry } from "@/editor/api";
import { executeAction } from "@/platform/actions";
import { setContext } from "@/platform/context";
import { useTxTemplate } from "@/platform/i18n";
import { isTauri } from "@/platform/ipc";
import type { RootFolder } from "@/platform/languageSettings";
import { useEffectiveRoots } from "@/platform/projectRoots";
@@ -39,9 +40,10 @@ import {
type TreeRowState,
} from "@/ui";
import { contextMenuItems, menuGroupContext, newMenuItems } from "./contextMenu";
import { openNewPopup } from "./newPopupModel";
import { folderKind } from "./folderKind";
import { useHomeDir } from "./home";
import { nodeKeyFor, visibleInfo, type ProjectNode } from "./model";
import { keyLike, nodeKeyFor, visibleInfo, type ProjectNode } from "./model";
import { lastFileRefactoring, undoFileRefactoring } from "./fileRefactor";
import {
cancelEditing,
@@ -61,6 +63,8 @@ import {
} from "./operations";
import { isInside, nameOf, parentOf, splitExtension, tildify } from "./paths";
import {
VIEW_MODES,
autoExpand,
clearRevealRequest,
loadDir,
revealPath,
@@ -89,7 +93,7 @@ export function showNewPopup(): boolean {
const row = lead ? document.querySelector<HTMLElement>(`[data-project-key="${CSS.escape(lead)}"]`) : null;
const rect = (row ?? document.querySelector<HTMLElement>('[role="tree"][aria-label="Project"]'))?.getBoundingClientRect();
if (!rect) return false;
showContextMenu({ x: rect.left + 24, y: rect.bottom }, newMenuItems(targetFolder()));
openNewPopup({ x: rect.left + 24, y: rect.bottom }, newMenuItems(targetFolder()));
return true;
}
@@ -100,7 +104,8 @@ const loadChildren = (n: ProjectNode) => loadDir(n.path);
function iconFor(node: ProjectNode, roots: readonly RootFolder[], root: string | null): string {
if (node.placeholder) return node.placeholder === "dir" ? "folder" : "file";
if (node.root) return node.icon ?? "module";
if (node.icon) return node.icon;
if (node.root) return "module";
if (node.dir) return folderIconName(folderKind(node, roots, root));
return fileIconName(node.label);
}
@@ -310,6 +315,7 @@ function folderAt(container: HTMLElement | null, x: number, y: number): { key: s
// ---------------------------------------------------------------------------
export function ProjectView() {
const txTemplate = useTxTemplate();
const root = useProjectTree((s) => s.root);
const model = useProjectTree(selectModel);
const expanded = useProjectTree((s) => s.expanded);
@@ -326,6 +332,10 @@ export function ProjectView() {
const revealing = useRef(false);
const info = useMemo(() => visibleInfo(model, expanded), [model, expanded]);
const mode = useProjectTree((s) => s.mode);
// Open Files / All Changed Files: show new files' folders expanded.
useEffect(() => autoExpand(model), [model]);
// Load folders that are expanded (restored state, reveal, chains) but not read yet.
useEffect(() => {
@@ -363,7 +373,7 @@ export function ProjectView() {
const onExpandedChange = useCallback((next: Set<string>, node: ProjectNode, open: boolean) => {
// Expanding a compacted chain opens every folder in it, so it stays open if the chain breaks.
if (open) for (const p of node.chain) next.add(p);
if (open) for (const p of node.chain) next.add(keyLike(node.key, p));
setExpanded(next);
}, []);
@@ -389,6 +399,7 @@ export function ProjectView() {
}
const nodes = [...sel].map((k) => m.byKey.get(k)).filter((n): n is ProjectNode => !!n && !n.placeholder);
const target = nodes.length ? nodes : [node];
if (target.every((n) => n.group)) return;
// Contributed groups may read the file first (bounded wait).
const at = { x: e.clientX, y: e.clientY };
void prepareMenuGroups("projectView", menuGroupContext(target)).then(() => showContextMenu(at, contextMenuItems(target)));
@@ -407,7 +418,7 @@ export function ProjectView() {
// Pointer-driven drag & drop (HTML5 DnD is unreliable inside the Tauri webview).
const onRowPointerDown = useCallback((node: ProjectNode, e: ReactPointerEvent) => {
if (e.button !== 0 || node.root || node.placeholder || useProjectTree.getState().editing) return;
if (e.button !== 0 || node.root || node.placeholder || node.readOnly || useProjectTree.getState().editing) return;
const before = useProjectTree.getState().selected;
const lead = useProjectTree.getState().lead;
const startX = e.clientX,
@@ -553,7 +564,7 @@ export function ProjectView() {
editing={editing}
cut={cutPaths.has(node.path)}
dropTarget={dropTarget === node.key}
detail={node.root && !node.icon ? rootDetail : null}
detail={node.root && !node.icon ? rootDetail : (node.detail ?? null)}
onEditDone={focusTree}
onClick={onRowClick}
onPointerDown={onRowPointerDown}
@@ -576,6 +587,9 @@ export function ProjectView() {
onFocus={(e) => setContext("projectViewFocus", (e.target as HTMLElement).getAttribute("role") === "tree")}
onBlur={() => setContext("projectViewFocus", false)}
>
{!model.roots.length && (
<div className={styles.empty}>{txTemplate(`Nothing to show in ${VIEW_MODES.find(([id]) => id === mode)?.[1] ?? "this view"}`)}</div>
)}
<Tree<ProjectNode>
aria-label="Project"
handleRef={setTreeHandle}
+63 -4
Переглянути файл
@@ -1,10 +1,12 @@
/** Actions of the project view (Find Action, keymap, other features via `executeAction`). */
import { activeEditor } from "@/editor/api";
import { getAction, registerActions, type Action } from "@/platform/actions";
import { dirname, useProject } from "@/platform/project";
import { showContextMenu, type MenuItem } from "@/ui";
import { maybeEditorService, menuGroupItems, toolWindowService } from "@/workbench/contributions";
import { OPTION_LABELS } from "./HeaderActions";
import { revealLabel } from "./contextMenu";
import { newMenuItems, revealLabel } from "./contextMenu";
import { openNewPopup } from "./newPopupModel";
import type { ProjectNode } from "./model";
import {
beginNew,
@@ -28,7 +30,18 @@ import {
import { nameOf, parentOf } from "./paths";
import { showNewPopup, treeHandle } from "./ProjectView";
import { lastFileRefactoring, undoFileRefactoring } from "./fileRefactor";
import { collapseAll, currentModel, expandAll, reloadAll, revealPath, setViewOption, useProjectTree } from "./store";
import {
VIEW_MODES,
collapseAll,
currentModel,
expandAll,
modeShows,
reloadAll,
revealPath,
setViewMode,
setViewOption,
useProjectTree,
} from "./store";
const FOCUS = "projectViewFocus";
const OPEN = "projectOpen";
@@ -75,6 +88,7 @@ function showProjectView(focus: boolean) {
export function selectInProjectView(path?: string | null, focus = true) {
const target = path ?? maybeEditorService()?.activePath() ?? null;
if (!target) return false;
if (!modeShows(target)) setViewMode("project");
showProjectView(focus);
return revealPath(target, focus);
}
@@ -99,6 +113,23 @@ export function selectInTargets(path: string): MenuItem[] {
];
}
/**
* The New popup for the active editor's directory, at the caret (centred near
* the top without one); `orRoot` falls back to the project root without a file.
*/
export function showNewPopupForEditor(orRoot = false): boolean {
const editor = activeEditor();
const path = editor && !editor.ctx.virtual ? editor.ctx.path : null;
const directory = path ? dirname(path) : orRoot ? (useProject.getState().project?.root ?? null) : null;
if (!directory) return false;
const coords = path ? editor!.view.coordsAtPos(editor!.view.state.selection.main.head) : null;
const at = coords
? { x: coords.left, y: coords.bottom + 2 }
: { x: window.innerWidth / 2 - 140, y: window.innerHeight / 5 };
openNewPopup(at, newMenuItems(directory, { outsideTree: true }));
return true;
}
function showSelectIn(path: string) {
// At the caret, like IDEA's popup; centred near the top otherwise.
const view = activeEditor()?.view;
@@ -125,6 +156,18 @@ export function registerProjectActions(): () => void {
run: () => setViewOption(key, !useProjectTree.getState().options[key]),
}));
const modes: Action[] = VIEW_MODES.map(([mode, label]) => ({
id: `project.viewMode.${mode}`,
title: `Project View: Show ${label}`,
category: "View",
when: OPEN,
keywords: ["project", "pane", "scope", "view as"],
run: () => {
setViewMode(mode);
showProjectView(false);
},
}));
return registerActions([
{
// A path argument (links, breadcrumbs, Open In › Project View) selects it in the project view.
@@ -165,13 +208,28 @@ export function registerProjectActions(): () => void {
when: OPEN,
keywords: ["create"],
run: ({ arg, ctx }) => {
// ⌘N inside the project view opens the New popup (File, Directory, templates).
// ⌘N inside the project view opens the New popup (File, Directory, templates);
// in an editor (where Generate does not apply) it opens for the file's directory.
if (arg === undefined && ctx[FOCUS] && showNewPopup()) return;
if (arg === undefined && ctx.editorFocus && showNewPopupForEditor()) return;
// No focus request: the inline editor takes focus itself.
showProjectView(false);
return beginNew("file", folderFrom(arg));
},
},
{
// IDEA's NewElementSamePlace: the New popup for the current file's directory, wherever the focus is.
id: "project.newInThisDirectory",
title: "New in This Directory…",
category: "File",
icon: "plus",
when: OPEN,
keywords: ["create", "file", "same place", "current directory"],
run: ({ ctx }) => {
if (ctx[FOCUS] && showNewPopup()) return;
showNewPopupForEditor(true);
},
},
{
// File › New (native menu, VS Code preset): same as New File at the selection.
id: "file.new",
@@ -380,7 +438,7 @@ export function registerProjectActions(): () => void {
keyWhen: FOCUS,
run: () => {
const lead = leadNode();
return expandAll(lead?.dir && !lead.root ? lead.path : undefined);
return expandAll(lead?.dir && !lead.root ? lead.key : undefined);
},
},
{
@@ -405,5 +463,6 @@ export function registerProjectActions(): () => void {
run: reloadAll,
},
...toggles,
...modes,
]);
}
+65 -16
Переглянути файл
@@ -1,10 +1,10 @@
/** Context menu and "New" popup of the project view. */
import { allActions, getAction } from "@/platform/actions";
import { executeAction, getAction } from "@/platform/actions";
import { formatBinding, hostBinding, isMacHost, shortcutLabel } from "@/platform/keymap";
import { relativePath, useProject } from "@/platform/project";
import { currentDetectedRoots, currentRoots } from "@/platform/projectRoots";
import type { MenuItem } from "@/ui";
import { menuGroupItems, type MenuGroupContext } from "@/workbench/contributions";
import { menuGroupItems, toolWindowService, type MenuGroupContext } from "@/workbench/contributions";
import type { FolderRole } from "@/platform/languageSettings";
import { directoryRole, markDirectory } from "./markDirectory";
import type { ProjectNode } from "./model";
@@ -44,16 +44,37 @@ export const LOCAL_KEYS: Record<string, string> = {
export const keyLabel = (id: string) =>
shortcutLabel(id) ?? (LOCAL_KEYS[id] ? formatBinding(hostBinding(LOCAL_KEYS[id])) : undefined);
export function newMenuItems(parent: string | null): MenuItem[] {
/**
* New › items for `parent`. `outsideTree` (⌘N in the editor): File asks for the
* name in a dialog, and the inline Directory / Package editors bring up the project view.
*/
export function newMenuItems(parent: string | null, { outsideTree = false } = {}): MenuItem[] {
const root = useProject.getState().project?.root;
const rel = parent && root ? (parent === root ? "" : relativePath(root, parent)) : null;
const pkg = rel === null ? null : packageKindAt(rel, currentRoots(), currentDetectedRoots());
const dir: MenuItem = { id: "new.dir", label: "Directory", icon: "folder", run: () => void beginNew("dir", parent) };
const inline = (start: () => Promise<void>) => () => {
if (outsideTree) showProjectView();
void start();
};
const emptyFile = outsideTree && parent && getAction("templates.new.file");
const dir: MenuItem = {
id: "new.dir",
label: "Directory",
icon: "folder",
run: inline(() => beginNew("dir", parent)),
};
return [
{ id: "new.file", label: "File", icon: "file", run: () => void beginNew("file", parent) },
emptyFile
? {
id: "new.file",
label: "File",
icon: "file",
run: () => void executeAction(emptyFile.id, { directory: parent }),
}
: { id: "new.file", label: "File", icon: "file", run: inline(() => beginNew("file", parent)) },
// IDEA offers "Package" instead of "Directory" inside a Java / Kotlin source root.
pkg === "jvm"
? { id: "new.package", label: "Package", icon: "package", run: () => void beginNewPackage("jvm", parent) }
? { id: "new.package", label: "Package", icon: "package", run: inline(() => beginNewPackage("jvm", parent)) }
: dir,
...(pkg === "python"
? [
@@ -61,22 +82,25 @@ export function newMenuItems(parent: string | null): MenuItem[] {
id: "new.pythonPackage",
label: "Python Package",
icon: "package",
run: () => void beginNewPackage("python", parent),
run: inline(() => beginNewPackage("python", parent)),
},
]
: []),
...templateItems(parent),
...contributedNewItems(parent),
];
}
/** File templates contributed by features/templates (`templates.new.*`). */
function templateItems(parent: string | null): MenuItem[] {
const arg = parent ? { directory: parent } : undefined;
const items: MenuItem[] = allActions()
.filter((a) => a.id.startsWith("templates.new.") && a.id !== "templates.new.file")
.map((a) => ({ kind: "action", action: a.id, arg, label: a.title.replace(/^New /, "").replace(/…$/, "") }));
const more = getAction("templates.newFromTemplate");
if (more) items.push({ kind: "separator" }, { kind: "action", action: more.id, arg, label: "From Template…" });
function showProjectView() {
try {
toolWindowService().show("project", false);
} catch {
/* tool window service not available (tests) */
}
}
/** File kinds contributed to the `new` menu group (templates, HTTP Request, Scratch File…). */
function contributedNewItems(parent: string | null): MenuItem[] {
const items = menuGroupItems("new", parent ? { paths: [parent], dirs: [true] } : { paths: [] });
return items.length ? [{ kind: "separator" }, ...items] : [];
}
@@ -107,6 +131,7 @@ function submenu(id: string, label: string, entries: Entry[], icon?: string): Me
* clipboard group), so the project root and Scratches get a shorter menu.
*/
export function contextMenuItems(nodes: ProjectNode[]): MenuItem[] {
if (nodes.some((n) => n.readOnly)) return libraryMenuItems(nodes.filter((n) => !n.group));
const lead = nodes[0] ?? null;
const single = nodes.length === 1 ? lead : null;
const nonRoot = nodes.filter((n) => !n.root);
@@ -338,6 +363,30 @@ export function contextMenuItems(nodes: ProjectNode[]): MenuItem[] {
return items;
}
/** Library sources (External Libraries) are read-only: copy, find and reveal only. */
function libraryMenuItems(nodes: ProjectNode[]): MenuItem[] {
if (!nodes.length) return [];
const single = nodes.length === 1 ? nodes[0]! : null;
const folder = single?.dir ? single.path : null;
const items: Entry[] = [
{ id: "copy", label: "Copy", icon: "copy", shortcut: keyLabel("project.copy"), run: () => copySelection(false) },
{
id: "copyPath",
label: "Copy Path/Reference…",
submenu: [
{ id: "abs", label: "Absolute Path", shortcut: keyLabel("project.copyAbsolutePath"), run: () => void copyPaths("absolute", nodes) },
{ id: "name", label: "File Name", run: () => void copyPaths("name", nodes) },
],
},
{ kind: "separator" },
!!folder && delegated("find.inPath", { directory: folder }, "Find in Files…", { icon: "search" }),
!!folder && { kind: "separator" },
submenu("openIn", "Open In", [{ id: "finder", label: revealLabel(), icon: "external-link", run: () => void revealInFinder(nodes) }]),
single && !single.dir && delegated("diff.compareWith", single.path, "Compare With…", { icon: "diff", shortcut: keyLabel("project.compareWith") }),
];
return items.filter((i): i is MenuItem => !!i);
}
export function revealLabel() {
const ua = typeof navigator !== "undefined" ? navigator.userAgent : "";
if (/Win/i.test(ua)) return "Explorer";
+71 -5
Переглянути файл
@@ -1,16 +1,26 @@
/** Project view: the "project" tool window, its file operations and actions. */
import { onEditorEvent, openEditorPaths } from "@/editor/api";
import { onIdeEvent } from "@/platform/ipc";
import type { RootFolder } from "@/platform/languageSettings";
import { validateProjectStructure, type RootFolder } from "@/platform/languageSettings";
import { useProject } from "@/platform/project";
import { initProjectRoots, useProjectRoots } from "@/platform/projectRoots";
import { registerOverlay, registerToolWindow, useProjectViewRoots } from "@/workbench/contributions";
import { folderIconName } from "@/ui";
import {
localChangedPaths,
registerOverlay,
registerProjectViewRoot,
registerToolWindow,
useLocalChanges,
useProjectViewRoots,
} from "@/workbench/contributions";
import { registerProjectActions } from "./actions";
import { FileRefactorDialogHost } from "./FileRefactorDialogs";
import { NewPopupHost } from "./NewPopup";
import { RefactoringPreviewHost } from "./RefactoringPreview";
import { ProjectHeaderActions, useProjectHeaderMenu } from "./HeaderActions";
import { ProjectHeaderActions, useProjectHeaderMenu, useProjectTitleMenu } from "./HeaderActions";
import { loadHomeDir } from "./home";
import { ProjectView } from "./ProjectView";
import { applyFsChange, initProjectTree, reloadAll, useProjectTree } from "./store";
import { applyFsChange, initProjectTree, reloadAll, setChangedFiles, setOpenFiles, useProjectTree } from "./store";
export function activate(): () => void {
loadHomeDir();
@@ -27,7 +37,12 @@ export function activate(): () => void {
// Excluded folders / exclude globs change which entries are `ignored`.
const unsubExcludes = useProject.subscribe((s, prev) => {
const ex = (x: typeof s.settings) =>
JSON.stringify([x?.files?.exclude, x?.projectStructure?.excluded, x?.projectStructure?.unmarked]);
JSON.stringify([
x?.files?.exclude,
x?.projectStructure?.excluded,
x?.projectStructure?.unmarked,
x?.projectStructure?.excludeFiles,
]);
if (s.settings !== prev.settings && prev.settings && ex(s.settings) !== ex(prev.settings)) void reloadAll();
});
// …and so do excluded roots detected from build files (`target`, `node_modules`, …).
@@ -38,15 +53,64 @@ export function activate(): () => void {
if (s.root && s.root === prev.root && detectedExcluded(s) !== detectedExcluded(prev)) void reloadAll();
});
// Project Structure content roots besides the project folder, listed after it.
let contentRoots: Array<() => void> = [];
let shownRoots = "";
const syncContentRoots = () => {
const { project, settings } = useProject.getState();
const paths = project ? validateProjectStructure(settings?.projectStructure).contentRoots : [];
const key = JSON.stringify(paths);
if (key === shownRoots) return;
shownRoots = key;
contentRoots.forEach((d) => d());
contentRoots = paths.map((path, i) =>
registerProjectViewRoot({
id: `contentRoot:${path}`,
label: path.slice(path.lastIndexOf("/") + 1) || path,
detail: path,
icon: folderIconName("module"),
path,
order: -100 + i,
}),
);
};
syncContentRoots();
const unsubContentRoots = useProject.subscribe((s, prev) => {
if (s.settings !== prev.settings || s.project !== prev.project) syncContentRoots();
});
// Contributed roots (Scratches) are part of the derived model: re-render.
const unsubExtraRoots = useProjectViewRoots.subscribe(() =>
useProjectTree.setState((s) => ({ listings: new Map(s.listings) })),
);
// Open Files / All Changed Files panes: editor tabs and VCS changes, via the editor API and decorations.
let openTimer: ReturnType<typeof setTimeout> | undefined;
const syncOpenFiles = () => {
clearTimeout(openTimer);
// Tabs update after the document events.
openTimer = setTimeout(() => setOpenFiles(openEditorPaths()), 0);
};
syncOpenFiles();
const unsubEditor = onEditorEvent((e) => {
if (e.type === "docOpened" || e.type === "docClosed" || e.type === "activeEditorChanged") syncOpenFiles();
});
// "All Changed Files": change-list files still on disk (IDEA leaves out unversioned, ignored and deleted ones).
const syncChanged = () => setChangedFiles(localChangedPaths(useLocalChanges.getState().lists, null, true));
syncChanged();
const unsubDecorations = useLocalChanges.subscribe((s, prev) => {
if (s.lists !== prev.lists) syncChanged();
});
const disposers = [
() => clearTimeout(openTimer),
unsubEditor,
unsubDecorations,
unsubProject,
unsubExtraRoots,
unsubExcludes,
unsubContentRoots,
() => contentRoots.forEach((d) => d()),
disposeRoots,
unsubRoots,
registerToolWindow({
@@ -58,9 +122,11 @@ export function activate(): () => void {
component: ProjectView,
headerActions: ProjectHeaderActions,
headerMenu: useProjectHeaderMenu,
titleMenu: useProjectTitleMenu,
}),
registerOverlay({ id: "project.fileRefactor", component: FileRefactorDialogHost }),
registerOverlay({ id: "project.refactoringPreview", component: RefactoringPreviewHost }),
registerOverlay({ id: "project.newPopup", component: NewPopupHost }),
registerProjectActions(),
onIdeEvent("fsChanged", (e) => void applyFsChange(e)),
];
+190 -12
Переглянути файл
@@ -5,7 +5,7 @@
*/
import type { FileEntry } from "@/bindings";
import { isIgnoredName } from "@/platform/ignoredNames";
import { isSameOrInside, nameOf, parentOf, relativeTo } from "./paths";
import { ancestorsWithin, isInside, isSameOrInside, nameOf, parentOf, relativeTo } from "./paths";
export interface TreeOptions {
/** Show ignored names (`files.ignoredNames`: `.git`, `.DS_Store`, …). Dotfiles are always shown. */
@@ -43,6 +43,12 @@ export interface ProjectNode {
placeholder?: "file" | "dir";
/** Root of a contributed folder outside the project ("Scratches"): its icon. */
icon?: string;
/** Grey text after the label ("library root"). */
detail?: string;
/** A node grouping other nodes, without a folder of its own ("External Libraries"). */
group?: boolean;
/** Library sources: no new / rename / move / delete. */
readOnly?: boolean;
}
export interface TreeModel {
@@ -210,13 +216,53 @@ export function buildModel(
return { roots: [rootNode], children, byKey, keyOfPath };
}
/** A contributed root (scratches, External Libraries) or one of its child entries. */
export interface ExtraRoot {
id?: string;
label: string;
icon: string;
/** Folder listed by the node; omitted for a group node of `children`. */
path?: string;
children?: readonly ExtraRoot[];
detail?: string;
readOnly?: boolean;
}
/** Key prefix of group nodes that show no folder ("External Libraries", an SDK). */
export const GROUP_PREFIX = "\u0000group:";
export const isGroupKey = (key: string) => key.startsWith(GROUP_PREFIX);
/**
* Adds contributed roots (folders outside the project, e.g. scratches) after
* the project root. Each lists its folder like the project root does.
* Key prefix of nodes in a library folder below a group (`\0lib:<group>\0<path>`):
* their own namespace, so a folder inside the project (a `.venv`'s
* site-packages, `node_modules/<dep>`) shows both there and under External Libraries.
*/
export const LIB_PREFIX = "\u0000lib:";
const LIB_SEP = "\u0000";
export const isLibKey = (key: string) => key.startsWith(LIB_PREFIX);
export const libKey = (group: string, path: string) => `${LIB_PREFIX}${group}${LIB_SEP}${path}`;
/** The folder / file a node key stands for (keys outside library subtrees are paths). */
export function pathOfKey(key: string): string {
return isLibKey(key) ? key.slice(key.indexOf(LIB_SEP, LIB_PREFIX.length) + 1) : key;
}
/** The key of `path` in the same subtree as the node keyed `like`. */
export function keyLike(like: string, path: string): string {
return isLibKey(like) ? like.slice(0, like.indexOf(LIB_SEP, LIB_PREFIX.length) + 1) + path : path;
}
/**
* Adds contributed roots (folders outside the project, e.g. scratches, and
* groups of them like External Libraries) after the project root. Each folder
* lists its contents like the project root does.
*/
export function withExtraRoots(
model: TreeModel,
extras: ReadonlyArray<{ path: string; label: string; icon: string }>,
extras: readonly ExtraRoot[],
listings: Listings,
opts: TreeOptions,
placeholder?: Placeholder | null,
@@ -228,19 +274,151 @@ export function withExtraRoots(
byKey: new Map(model.byKey),
keyOfPath: new Map(model.keyOfPath),
};
const add = (extra: ExtraRoot, parentKey: string | null, readOnly: boolean): ProjectNode | null => {
const ro = readOnly || !!extra.readOnly;
if (extra.path !== undefined && !extra.children) {
const path = extra.path;
// Library folders of a group are keyed in their own namespace; other
// folders (scratches) by path, so ones inside the project stay under it.
const ns = ro && parentKey ? parentKey.slice(GROUP_PREFIX.length) : null;
const rekey = (k: string) => (ns === null ? k : libKey(ns, k));
if (out.byKey.has(rekey(path))) return null;
if (ns === null && model.roots.some((r) => !r.group && isSameOrInside(path, r.path))) return null;
const sub = buildModel({ path, name: extra.label }, listings, opts, ro ? null : placeholder);
const subRoot = sub.roots[0]!;
const rootNode: ProjectNode = { ...subRoot, key: rekey(subRoot.key), root: !parentKey, icon: extra.icon, detail: extra.detail };
if (ro) rootNode.readOnly = true;
const nodes = new Map<string, ProjectNode>();
for (const [k, v] of sub.byKey)
nodes.set(k, k === subRoot.key ? rootNode : ns !== null || ro ? { ...v, key: rekey(k), readOnly: ro || v.readOnly } : v);
for (const n of nodes.values()) out.byKey.set(n.key, n);
for (const [k, v] of sub.children) out.children.set(rekey(k), v.map((n) => nodes.get(n.key) ?? n));
for (const [p, k] of sub.keyOfPath) if (!out.keyOfPath.has(p)) out.keyOfPath.set(p, rekey(k));
return rootNode;
}
const key = `${parentKey ? `${parentKey}/` : GROUP_PREFIX}${extra.id ?? extra.label}`;
if (out.byKey.has(key)) return null;
const node: ProjectNode = {
key,
path: key,
chain: [],
label: extra.label,
dir: true,
root: !parentKey,
entry: null,
ignored: false,
excluded: false,
symlink: false,
icon: extra.icon,
detail: extra.detail,
group: true,
readOnly: true,
};
out.byKey.set(key, node);
const kids = (extra.children ?? []).map((c) => add(c, key, ro)).filter((n): n is ProjectNode => !!n);
out.children.set(key, kids);
return node;
};
for (const extra of extras) {
if (out.byKey.has(extra.path)) continue;
const sub = buildModel({ path: extra.path, name: extra.label }, listings, opts, placeholder);
const rootNode = { ...sub.roots[0]!, icon: extra.icon };
sub.byKey.set(rootNode.key, rootNode);
out.roots.push(rootNode);
for (const [k, v] of sub.children) out.children.set(k, v);
for (const [k, v] of sub.byKey) out.byKey.set(k, v);
for (const [k, v] of sub.keyOfPath) out.keyOfPath.set(k, v);
const node = add(extra, null, false);
if (node) out.roots.push(node);
}
return out;
}
/**
* Marks project nodes inside read-only library `folders` (a `.venv`'s
* site-packages listed under External Libraries) read-only too.
*/
export function withReadOnlyFolders(model: TreeModel, folders: readonly string[]): TreeModel {
if (!folders.length) return model;
const byKey = new Map(model.byKey);
let changed = false;
for (const [k, n] of model.byKey)
if (!n.readOnly && !n.group && !isLibKey(k) && !n.root && folders.some((f) => isSameOrInside(n.path, f))) {
byKey.set(k, { ...n, readOnly: true });
changed = true;
}
if (!changed) return model;
const children = new Map<string, ProjectNode[]>();
for (const [k, kids] of model.children) children.set(k, kids.map((n) => byKey.get(n.key) ?? n));
return { roots: model.roots.map((n) => byKey.get(n.key) ?? n), children, byKey, keyOfPath: model.keyOfPath };
}
/** Folder roots of contributed entries (groups flattened), in display order. */
export function extraFolders(extras: readonly ExtraRoot[], readOnly = false): ExtraRoot[] {
return extras.flatMap((e) =>
e.path !== undefined && !e.children
? [{ ...e, readOnly: readOnly || !!e.readOnly }]
: extraFolders(e.children ?? [], readOnly || !!e.readOnly),
);
}
/**
* A tree of just `paths` (IDEA's "Open Files" / "All Changed Files" views):
* each file below the innermost of `roots` containing it, through its folders.
* Files outside every root are grouped under their folder. Roots without
* files are left out.
*/
export function buildPathsModel(roots: readonly ExtraRoot[], paths: readonly string[], foldersFirst = true): TreeModel {
const children = new Map<string, ProjectNode[]>();
const byKey = new Map<string, ProjectNode>();
const keyOfPath = new Map<string, string>();
const rootNodes: Array<[number, ProjectNode]> = [];
const node = (path: string, label: string, dir: boolean, extra: Partial<ProjectNode> = {}): ProjectNode => {
const n: ProjectNode = {
key: path,
path,
chain: [path],
label,
dir,
root: false,
entry: null,
ignored: false,
excluded: false,
symlink: false,
...extra,
};
byKey.set(path, n);
keyOfPath.set(path, path);
if (dir) children.set(path, []);
return n;
};
const ensureRoot = (r: ExtraRoot & { path: string }, rank: number) => {
const existing = byKey.get(r.path);
if (existing) return existing;
const n = node(r.path, r.label, true, { root: true, icon: r.icon || undefined, detail: r.detail, readOnly: r.readOnly });
rootNodes.push([rank, n]);
return n;
};
const folders = roots.filter((r): r is ExtraRoot & { path: string } => r.path !== undefined);
for (const path of new Set(paths)) {
if (byKey.has(path)) continue;
let rank = -1;
for (let i = 0; i < folders.length; i++)
if (isInside(path, folders[i]!.path) && (rank < 0 || folders[i]!.path.length > folders[rank]!.path.length)) rank = i;
const root =
rank >= 0
? ensureRoot(folders[rank]!, rank)
: ensureRoot({ path: parentOf(path), label: parentOf(path), icon: "folder" }, folders.length);
let parent = root;
for (const dir of ancestorsWithin(root.path, path).slice(1)) {
let d = byKey.get(dir);
if (!d) {
d = node(dir, nameOf(dir), true, { readOnly: root.readOnly });
children.get(parent.key)!.push(d);
}
parent = d;
}
children.get(parent.key)!.push(node(path, nameOf(path), false, { readOnly: root.readOnly }));
}
const order = (a: ProjectNode, b: ProjectNode) =>
(foldersFirst ? Number(b.dir) - Number(a.dir) : 0) || collator.compare(a.label, b.label);
for (const kids of children.values()) kids.sort(order);
rootNodes.sort((a, b) => a[0] - b[0] || collator.compare(a[1].label, b[1].label));
return { roots: rootNodes.map(([, n]) => n), children, byKey, keyOfPath };
}
export interface VisibleInfo {
/** Node keys currently rendered (ancestors expanded). */
visible: Set<string>;
+107
Переглянути файл
@@ -0,0 +1,107 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { executeAction, registerActions } from "@/platform/actions";
import { useProject } from "@/platform/project";
import type { MenuItem } from "@/ui";
import { registerMenuGroup } from "@/workbench/contributions";
import { newMenuItems } from "./contextMenu";
import { closeNewPopup, newPopupRows, openNewPopup, useNewPopup } from "./newPopupModel";
import { useProjectTree } from "./store";
vi.mock("@/platform/actions", async (importOriginal) => {
const mod = await importOriginal<typeof import("@/platform/actions")>();
return { ...mod, executeAction: vi.fn(mod.executeAction) };
});
const ROOT = "/p";
const labels = (items: MenuItem[], q = "") => newPopupRows(items, q).map((r) => (r.separator ? "-" : r.label));
let disposers: Array<() => void> = [];
beforeEach(() => {
useProjectTree.setState({ root: ROOT });
useProject.setState({
project: { name: "p", root: ROOT, isGit: false, settings: {} } as never,
settings: {} as never,
});
disposers = [
registerActions([
{ id: "templates.new.file", title: "New Empty File…", run: () => undefined },
{ id: "templates.new.python", title: "New Python File…", run: () => undefined },
{
id: "templates.new.stylesheet",
title: "New Stylesheet…",
keywords: ["new", "css", "scss", "less"],
run: () => undefined,
},
{ id: "http.newRequest", title: "New HTTP Request", run: () => undefined },
]),
registerMenuGroup({
id: "test.templates",
menus: ["new"],
order: 10,
items: (_menu, ctx) => [
{ kind: "action", action: "templates.new.python", arg: { directory: ctx.paths[0] }, label: "Python File" },
{ kind: "action", action: "templates.new.stylesheet", arg: { directory: ctx.paths[0] }, label: "Stylesheet" },
],
}),
registerMenuGroup({
id: "test.http",
menus: ["new"],
order: 20,
items: (_menu, { paths: [directory] }) =>
directory ? [{ kind: "action", action: "http.newRequest", arg: { directory }, label: "HTTP Request" }] : [],
}),
];
});
afterEach(() => {
disposers.forEach((d) => d());
closeNewPopup();
vi.mocked(executeAction).mockClear();
useProject.setState({ project: null, settings: null });
useProjectTree.setState({ root: null });
});
describe("New popup", () => {
it("lists File, Directory and the contributed file kinds", () => {
expect(labels(newMenuItems(`${ROOT}/src`))).toEqual([
"File",
"Directory",
"-",
"Python File",
"Stylesheet",
"-",
"HTTP Request",
]);
});
it("passes the target directory to contributed kinds", () => {
const http = newMenuItems(`${ROOT}/api`).find((i) => i.kind === "action" && i.action === "http.newRequest");
expect(http).toMatchObject({ arg: { directory: `${ROOT}/api` } });
});
it("filters by label, then by keyword, without separators", () => {
const items = newMenuItems(ROOT);
expect(labels(items, "py")).toEqual(["Python File"]);
expect(labels(items, "css")).toEqual(["Stylesheet"]);
expect(labels(items, "http")).toEqual(["HTTP Request"]);
expect(labels(items, "zzz")).toEqual([]);
expect(newPopupRows(items, "py")[0]!.matches).toEqual([0, 1]);
});
it("asks for the file name in a dialog outside the project view", () => {
const file = newMenuItems(`${ROOT}/src`, { outsideTree: true })[0] as { label: string; run: () => void };
expect(file.label).toBe("File");
file.run();
expect(executeAction).toHaveBeenCalledWith("templates.new.file", { directory: `${ROOT}/src` });
});
it("opens with a fresh request each time", () => {
openNewPopup({ x: 1, y: 2 }, []);
const first = useNewPopup.getState().request!.seq;
openNewPopup({ x: 1, y: 2 }, []);
expect(useNewPopup.getState().request!.seq).toBe(first + 1);
closeNewPopup();
expect(useNewPopup.getState().request).toBeNull();
});
});
+66
Переглянути файл
@@ -0,0 +1,66 @@
/** IDEA's "New" popup (⌘N): the New › items under a "Search for file type" field. */
import { create } from "zustand";
import { getAction } from "@/platform/actions";
import { fuzzyMatch, resolveMenuItems, type MenuItem } from "@/ui";
export interface NewPopupRow {
key: string;
separator?: boolean;
label: string;
icon?: string;
shortcut?: string;
run?: () => void;
/** Matched label indices while filtering. */
matches?: number[] | null;
}
interface NewPopupState {
request: { at: { x: number; y: number }; items: MenuItem[]; seq: number } | null;
}
export const useNewPopup = create<NewPopupState>(() => ({ request: null }));
let seq = 0;
export function openNewPopup(at: { x: number; y: number }, items: MenuItem[]) {
useNewPopup.setState({ request: { at, items, seq: ++seq } });
}
export const closeNewPopup = () => useNewPopup.setState({ request: null });
/** Generic action keywords that would match every template. */
const GENERIC = new Set(["new", "create", "template", "file"]);
/**
* Enabled entries, filtered by `query`: label matches (highlighted), then
* entries whose action has a matching keyword (`css` finds Stylesheet).
* Separators are kept only without a query.
*/
export function newPopupRows(items: MenuItem[], query: string): NewPopupRow[] {
const q = query.trim();
const rows: NewPopupRow[] = [];
const byKeyword: NewPopupRow[] = [];
for (const r of resolveMenuItems(items)) {
if (r.kind === "separator") {
if (!q && rows.length && !rows[rows.length - 1]!.separator) rows.push({ key: r.key, separator: true, label: "" });
continue;
}
if (r.kind !== "item" || r.disabled || !r.run) continue;
const row: NewPopupRow = { key: r.key, label: r.label, icon: r.icon, shortcut: r.shortcut, run: r.run };
if (!q) {
rows.push(row);
continue;
}
const matches = fuzzyMatch(r.label, q);
if (matches) rows.push({ ...row, matches });
else if (keywordMatch(r.key, q)) byKeyword.push(row);
}
while (rows.length && rows[rows.length - 1]!.separator) rows.pop();
return [...rows, ...byKeyword];
}
function keywordMatch(actionId: string, q: string): boolean {
const lower = q.toLowerCase().replace(/^\./, "");
return !!getAction(actionId)?.keywords?.some(
(k) => !GENERIC.has(k) && k.toLowerCase().replace(/^\./, "").startsWith(lower),
);
}
+21 -10
Переглянути файл
@@ -26,14 +26,20 @@ import {
import {
currentModel,
type Editing,
isFilteredMode,
isReadOnlyPath,
loadDir,
revealPath,
setClipboard,
setExpanded,
setViewMode,
startEditing,
useProjectTree,
} from "./store";
/** Nodes that may be renamed, moved or deleted (not roots, groups or library sources). */
const mutable = (n: ProjectNode) => !n.root && !n.placeholder && !n.readOnly;
const get = () => useProjectTree.getState();
const ua = typeof navigator !== "undefined" ? navigator.userAgent : "";
@@ -122,7 +128,9 @@ export function openSelection() {
// New / rename
export async function beginNew(nodeKind: "file" | "dir", parent = targetFolder(), pkg?: PackageKind) {
if (!parent) return;
if (!parent || isReadOnlyPath(parent)) return;
// The inline editor needs the folder's listing: the filtered panes show none.
if (isFilteredMode(get().mode)) setViewMode("project");
expandFolder(parent);
await loadDir(parent);
startEditing(pkg ? { kind: "new", parent, nodeKind: "dir", pkg } : { kind: "new", parent, nodeKind });
@@ -140,13 +148,13 @@ function newRelPath(editing: Extract<Editing, { kind: "new" }>, name: string): s
}
export function beginRename(node: ProjectNode | null = leadNode()) {
if (!node || node.root || node.placeholder) return;
if (!node || !mutable(node)) return;
startEditing({ kind: "rename", key: node.key, path: node.path });
}
/** Rename… (⇧F6): IDEA's Rename dialog, updating references. */
export async function openRenameDialog(node: ProjectNode | null = leadNode()) {
if (!node || node.root || node.placeholder) return;
if (!node || !mutable(node)) return;
const parent = parentOf(node.path);
await loadDir(parent);
openFileRefactorDialog({
@@ -160,7 +168,7 @@ export async function openRenameDialog(node: ProjectNode | null = leadNode()) {
/** Move… (F6): IDEA's Move dialog for the selection, defaulting to `target` or the current folder. */
export function openMoveDialog(nodes: ProjectNode[] = selectedNodes(), target?: string) {
const movable = nodes.filter((n) => !n.root && !n.placeholder);
const movable = nodes.filter(mutable);
const paths = topLevelPaths(movable);
openMoveDialogFor(paths, target, paths.length === 1 && movable.some((n) => n.path === paths[0] && n.dir));
}
@@ -394,7 +402,7 @@ export async function reloadFromDisk(nodes: ProjectNode[] = selectedNodes()) {
export async function deleteSelection(nodes: ProjectNode[] = selectedNodes()) {
const { root } = get();
const paths = topLevelPaths(nodes.filter((n) => !n.root));
const paths = topLevelPaths(nodes.filter(mutable));
if (!root || !paths.length) return;
// Invoked from the tree (or its context menu): give the tree its focus back afterwards.
const refocus = typeof document !== "undefined" && !!document.activeElement?.closest('[role="tree"], [role="menu"]');
@@ -446,7 +454,7 @@ export async function deleteSelection(nodes: ProjectNode[] = selectedNodes()) {
// Clipboard / move / copy
export function copySelection(cut: boolean) {
const paths = topLevelPaths(selectedNodes().filter((n) => !n.root));
const paths = topLevelPaths(selectedNodes().filter((n) => (cut ? mutable(n) : !n.root && !n.group)));
if (paths.length) setClipboard({ paths, cut });
}
@@ -457,7 +465,7 @@ export function canPaste(): boolean {
/** Paste: cut items go through the Move dialog, copied ones through the Copy dialog (IDEA). */
export async function paste(target = targetFolder()) {
const { clipboard } = get();
if (!clipboard || !target) return;
if (!clipboard || !target || isReadOnlyPath(target)) return;
const isDir = (p: string) => get().listings.has(p) || currentModel().byKey.get(currentModel().keyOfPath.get(p) ?? p)?.dir === true;
const single = clipboard.paths.length === 1 && isDir(clipboard.paths[0]!);
if (!clipboard.cut) return openCopyDialogFor(clipboard.paths, target, single);
@@ -512,7 +520,7 @@ export async function transfer(
errors.push(`${name}: cannot copy a folder into itself`);
continue;
}
const srcIsDir = get().listings.has(src) || currentModel().byKey.get(src)?.dir === true;
const srcIsDir = get().listings.has(src) || currentModel().byKey.get(currentModel().keyOfPath.get(src) ?? src)?.dir === true;
const finalName = paths.length === 1 && newName ? newName : uniqueName(name, isTaken, srcIsDir);
const to = joinPath(target, finalName);
try {
@@ -562,8 +570,11 @@ async function moveInto(paths: string[], target: string): Promise<boolean> {
/** Drag & drop inside the tree: the Move dialog (or a confirmed copy with ⌥). */
export async function dropPaths(paths: string[], target: string, copy: boolean) {
if (isReadOnlyPath(target)) return;
const movable = topLevelPaths(
paths.map((p) => currentModel().byKey.get(currentModel().keyOfPath.get(p) ?? p)).filter((n): n is ProjectNode => !!n),
paths
.map((p) => currentModel().byKey.get(currentModel().keyOfPath.get(p) ?? p))
.filter((n): n is ProjectNode => !!n && (copy || !n.readOnly)),
).filter((p) => copy || parentOf(p) !== target);
if (!movable.length) return;
// IDEA opens the Move refactoring dialog for drops inside the project.
@@ -578,7 +589,7 @@ export async function dropPaths(paths: string[], target: string, copy: boolean)
/** Files dropped from the OS: copied into `target` (never moved). */
export async function importExternal(paths: string[], target: string) {
const { root } = get();
if (!root || !paths.length) return;
if (!root || !paths.length || isReadOnlyPath(target)) return;
const inside = paths.filter((p) => isSameOrInside(p, root));
const outside = paths.filter((p) => !isSameOrInside(p, root));
if (inside.length) await dropPaths(inside, target, false);
+197 -30
Переглянути файл
@@ -1,6 +1,6 @@
/**
* Project view state: lazily loaded listings, expansion / selection (keyed by
* node key = path), view options, inline editing and the internal clipboard.
* node key: the path, namespaced below External Libraries), view options, inline editing and the internal clipboard.
* Kept outside React so actions (keyboard, Find Action, context menu) share it.
*/
import { create } from "zustand";
@@ -8,8 +8,16 @@ import type { FileEntry } from "@/bindings";
import { IpcError } from "@/platform/ipc";
import {
DEFAULT_TREE_OPTIONS,
GROUP_PREFIX,
buildModel,
buildPathsModel,
extraFolders,
isGroupKey,
isLibKey,
keyLike,
pathOfKey,
withExtraRoots,
withReadOnlyFolders,
dropListings,
isDirEntry,
mergeListing,
@@ -43,6 +51,20 @@ export const DEFAULT_VIEW_OPTIONS: ViewOptions = {
previewTab: false,
};
/** IDEA's Project view panes ("Project ▾" in the tool window header). */
export type ViewMode = "project" | "projectFiles" | "openFiles" | "changedFiles" | "scratches";
export const VIEW_MODES: ReadonlyArray<[ViewMode, string]> = [
["project", "Project"],
["projectFiles", "Project Files"],
["openFiles", "Open Files"],
["changedFiles", "All Changed Files"],
["scratches", "Scratches and Consoles"],
];
/** Modes listing just some files (expanded as they appear). */
export const isFilteredMode = (mode: ViewMode) => mode === "openFiles" || mode === "changedFiles";
export type Editing =
| { kind: "new"; parent: string; nodeKind: "file" | "dir"; pkg?: PackageKind }
| { kind: "rename"; key: string; path: string };
@@ -66,9 +88,15 @@ interface ProjectTreeState {
revealRequest: { path: string; focus: boolean; seq: number } | null;
/** Folder key highlighted as drop target. */
dropTarget: string | null;
mode: ViewMode;
/** Files open in editor tabs (Open Files mode). */
openFiles: readonly string[];
/** Files with VCS changes (All Changed Files mode). */
changedFiles: readonly string[];
}
const OPTIONS_KEY = "openidea.projectTree.options";
const MODE_KEY = "openidea.projectTree.mode";
const expandedKey = (root: string) => `openidea.projectTree.expanded:${root}`;
const MAX_PERSISTED = 3000;
@@ -101,8 +129,16 @@ export const useProjectTree = create<ProjectTreeState>(() => ({
clipboard: null,
revealRequest: null,
dropTarget: null,
mode: readMode(),
openFiles: [],
changedFiles: [],
}));
function readMode(): ViewMode {
const m = readJson<string>(MODE_KEY);
return VIEW_MODES.find(([id]) => id === m)?.[0] ?? "project";
}
const get = () => useProjectTree.getState();
const set = (patch: Partial<ProjectTreeState>) => useProjectTree.setState(patch);
@@ -115,13 +151,19 @@ let memo: {
listings: ProjectTreeState["listings"];
options: ViewOptions;
editing: Editing | null;
mode: ViewMode;
files: readonly string[] | null;
extras: ReadonlyMap<string, ProjectViewRootContribution>;
model: TreeModel;
} | null = null;
const EMPTY_MODEL: TreeModel = { roots: [], children: new Map(), byKey: new Map(), keyOfPath: new Map() };
const filesOf = (s: ProjectTreeState) =>
s.mode === "openFiles" ? s.openFiles : s.mode === "changedFiles" ? s.changedFiles : null;
export function selectModel(s: ProjectTreeState): TreeModel {
const files = filesOf(s);
if (
memo &&
memo.root === s.root &&
@@ -129,16 +171,30 @@ export function selectModel(s: ProjectTreeState): TreeModel {
memo.listings === s.listings &&
memo.options === s.options &&
memo.editing === s.editing &&
memo.mode === s.mode &&
memo.files === files &&
memo.extras === useProjectViewRoots.getState().roots
)
return memo.model;
const placeholder: Placeholder | null =
s.editing?.kind === "new" ? { parent: s.editing.parent, kind: s.editing.nodeKind } : null;
const extras = useProjectViewRoots.getState().roots;
const model = s.root
? withExtraRoots(buildModel({ path: s.root, name: s.name }, s.listings, s.options, placeholder), projectViewRoots(extras), s.listings, s.options, placeholder)
: EMPTY_MODEL;
memo = { root: s.root, name: s.name, listings: s.listings, options: s.options, editing: s.editing, extras, model };
const contributed = projectViewRoots(extras);
const project = () => buildModel({ path: s.root!, name: s.name }, s.listings, s.options, placeholder);
let model = EMPTY_MODEL;
if (s.root) {
if (s.mode === "projectFiles") model = project();
else if (s.mode === "scratches")
model = withExtraRoots(EMPTY_MODEL, contributed.filter((r) => r.scope === "scratches"), s.listings, s.options, placeholder);
else if (files)
model = buildPathsModel([{ path: s.root, label: s.name, icon: "" }, ...extraFolders(contributed)], files, s.options.foldersFirst);
else model = withExtraRoots(project(), contributed, s.listings, s.options, placeholder);
const libraries = extraFolders(contributed).flatMap((r) =>
r.readOnly && r.path !== undefined && isSameOrInside(r.path, s.root!) ? [r.path] : [],
);
model = withReadOnlyFolders(model, libraries);
}
memo = { root: s.root, name: s.name, listings: s.listings, options: s.options, editing: s.editing, mode: s.mode, files, extras, model };
return model;
}
@@ -156,14 +212,13 @@ export function initProjectTree(root: string | null, name = "") {
generation++;
inflight.clear();
clearTimeout(persistTimer);
const persisted = root ? (readJson<string[]>(expandedKey(root)) ?? []) : [];
const expanded = new Set(persisted.filter((p) => root && isSameOrInside(p, root)));
if (root) expanded.add(root);
modeExpanded.clear();
autoExpanded.clear();
set({
root,
name,
listings: new Map(),
expanded,
expanded: initialExpanded(root, get().mode),
selected: new Set(),
lead: null,
editing: null,
@@ -173,14 +228,70 @@ export function initProjectTree(root: string | null, name = "") {
});
}
/** Expansion of the modes not shown (each pane keeps its own, like IDEA's). */
const modeExpanded = new Map<ViewMode, ReadonlySet<string>>();
/** Folders of a filtered mode already expanded once (collapsing them sticks). */
const autoExpanded = new Set<string>();
/** "Project" restores the persisted expansion; other panes start with their roots open. */
function initialExpanded(root: string | null, mode: ViewMode): Set<string> {
if (!root || isFilteredMode(mode)) return new Set();
if (mode === "scratches") return new Set(projectViewRoots(useProjectViewRoots.getState().roots).flatMap((r) => (r.scope === "scratches" && r.path ? [r.path] : [])));
const persisted = mode === "project" ? (readJson<string[]>(expandedKey(root)) ?? []) : [];
const expanded = new Set(persisted.filter((p) => isSameOrInside(p, root)));
expanded.add(root);
return expanded;
}
function persistExpanded() {
if (get().mode !== "project") return;
clearTimeout(persistTimer);
persistTimer = setTimeout(() => {
const { root, expanded } = get();
if (root) writeJson(expandedKey(root), [...expanded].slice(0, MAX_PERSISTED));
const { root, expanded, mode } = get();
if (root && mode === "project") writeJson(expandedKey(root), [...expanded].slice(0, MAX_PERSISTED));
}, 300);
}
/** Switches the pane ("Project ▾"), remembered across sessions. */
export function setViewMode(mode: ViewMode) {
const s = get();
if (s.mode === mode) return;
modeExpanded.set(s.mode, s.expanded);
autoExpanded.clear();
writeJson(MODE_KEY, mode);
set({
mode,
expanded: modeExpanded.get(mode) ?? initialExpanded(s.root, mode),
selected: new Set(),
lead: null,
editing: null,
dropTarget: null,
});
}
/** Open Files / All Changed Files: expands folders the first time they appear. */
export function autoExpand(model: TreeModel) {
const s = get();
if (!isFilteredMode(s.mode)) return;
let next: Set<string> | null = null;
for (const [key, node] of model.byKey) {
if (!node.dir || autoExpanded.has(key)) continue;
autoExpanded.add(key);
if (!s.expanded.has(key)) (next ??= new Set(s.expanded)).add(key);
}
if (next) set({ expanded: next });
}
export function setOpenFiles(paths: readonly string[]) {
const prev = get().openFiles;
if (prev.length !== paths.length || prev.some((p, i) => p !== paths[i])) set({ openFiles: [...paths] });
}
export function setChangedFiles(paths: readonly string[]) {
const prev = get().changedFiles;
if (prev.length !== paths.length || prev.some((p, i) => p !== paths[i])) set({ changedFiles: [...paths] });
}
export function setExpanded(expanded: ReadonlySet<string>) {
set({ expanded });
persistExpanded();
@@ -209,7 +320,7 @@ function putListing(path: string, entries: readonly FileEntry[]) {
/** Loads (or reloads) one folder. Concurrent calls for the same folder share a request. */
export function loadDir(path: string, force = false): Promise<void> {
const { root, listings } = get();
if (!root) return Promise.resolve();
if (!root || isGroupKey(path)) return Promise.resolve();
if (!force && listings.has(path)) return Promise.resolve();
const running = inflight.get(path);
if (running) return running;
@@ -232,7 +343,7 @@ export function loadDir(path: string, force = false): Promise<void> {
if (e instanceof IpcError && (e.kind === "notFound" || e.kind === "invalidInput")) {
const listings = new Map(s.listings);
dropListings(listings, [path]);
const expanded = new Set([...s.expanded].filter((k) => !isSameOrInside(k, path) || k === s.root));
const expanded = new Set([...s.expanded].filter((k) => !isSameOrInside(pathOfKey(k), path) || k === s.root));
set({ listings, expanded });
if (path === s.root) set({ listings: new Map(listings).set(path, []) });
} else {
@@ -259,7 +370,7 @@ export async function applyFsChange(event: { created: string[]; modified: string
if (plan.drop.length) {
const listings = new Map(s.listings);
dropListings(listings, plan.drop);
const gone = (k: string) => plan.drop.some((d) => isSameOrInside(k, d));
const gone = (k: string) => plan.drop.some((d) => isSameOrInside(pathOfKey(k), d));
const selected = [...s.selected].filter((k) => !gone(k));
set({
listings,
@@ -271,10 +382,11 @@ export async function applyFsChange(event: { created: string[]; modified: string
if (event.removed.length) {
const cur = get();
const removed = new Set(event.removed);
if ([...cur.selected].some((k) => removed.has(k))) {
const isRemoved = (k: string) => removed.has(pathOfKey(k));
if ([...cur.selected].some(isRemoved)) {
set({
selected: new Set([...cur.selected].filter((k) => !removed.has(k))),
lead: cur.lead && removed.has(cur.lead) ? null : cur.lead,
selected: new Set([...cur.selected].filter((k) => !isRemoved(k))),
lead: cur.lead && isRemoved(cur.lead) ? null : cur.lead,
});
}
}
@@ -298,7 +410,11 @@ export function revealPath(path: string, focus = false) {
const project = get().root;
const root = project && isSameOrInside(path, project) ? project : project ? extraRootOf(path) : null;
if (!root) return false;
const ancestors = ancestorsWithin(root, path);
// Library folders sit below group nodes (External Libraries › SDK), keyed in their own namespace.
const model = currentModel();
const rootKey = root === project ? root : (model.keyOfPath.get(root) ?? root);
const folders = ancestorsWithin(root, path);
const ancestors = [...groupAncestors(model, rootKey), ...folders.map((a) => keyLike(rootKey, a))];
const next = new Set(expanded);
let changed = false;
for (const a of ancestors)
@@ -308,26 +424,72 @@ export function revealPath(path: string, focus = false) {
}
if (changed) setExpanded(next);
set({ revealRequest: { path, focus, seq: ++revealSeq } });
for (const a of ancestors) void loadDir(a);
for (const a of folders) void loadDir(a);
return true;
}
/** The contributed root (scratches…) containing `path`, if any. */
/** The contributed folder (scratches, a library root…) containing `path`, if any. */
export function extraRootOf(path: string): string | null {
for (const r of useProjectViewRoots.getState().roots.values()) if (isSameOrInside(path, r.path)) return r.path;
for (const r of extraFolders(projectViewRoots(useProjectViewRoots.getState().roots)))
if (r.path !== undefined && isSameOrInside(path, r.path)) return r.path;
return null;
}
/** Whether the current pane can show `path` (Select In switches to "Project" otherwise). */
export function modeShows(path: string): boolean {
const { mode, root } = get();
if (!root) return false;
switch (mode) {
case "project":
return true;
case "projectFiles":
return isSameOrInside(path, root);
case "scratches":
return projectViewRoots(useProjectViewRoots.getState().roots).some(
(r) => r.scope === "scratches" && r.path !== undefined && isSameOrInside(path, r.path),
);
default:
return currentModel().keyOfPath.has(path);
}
}
/** Whether `path` lies in read-only library sources. */
export function isReadOnlyPath(path: string): boolean {
if (isGroupKey(path) || isLibKey(path)) return true;
return extraFolders(projectViewRoots(useProjectViewRoots.getState().roots)).some(
(r) => r.readOnly && r.path !== undefined && isSameOrInside(path, r.path),
);
}
/** Group node keys above the node `key`, outermost first. */
function groupAncestors(model: TreeModel, key: string): string[] {
const out: string[] = [];
for (let child = key; ; ) {
let parent: string | null = null;
for (const [k, kids] of model.children)
if (k.startsWith(GROUP_PREFIX) && kids.some((n) => n.key === child)) {
parent = k;
break;
}
if (!parent) return out;
out.unshift(parent);
child = parent;
}
}
export function clearRevealRequest(seq: number) {
if (get().revealRequest?.seq === seq) set({ revealRequest: null });
}
/** Expands every folder below `from` (skipping excluded ones), loading as needed; capped. */
/** Expands every folder below the node keyed `from` (skipping excluded ones), loading as needed; capped. */
export async function expandAll(from?: string, limit = 400) {
const { root } = get();
const { root, mode } = get();
if (!root) return;
const start = from ?? root;
const queue = [start];
const model = currentModel();
// Filtered panes hold every folder already.
if (isFilteredMode(mode))
return setExpanded(new Set([...model.byKey.values()].filter((n) => n.dir).map((n) => n.key)));
const queue = [from ?? model.roots[0]?.key ?? root];
const toExpand = new Set(get().expanded);
let count = 0;
const gen = generation;
@@ -335,18 +497,23 @@ export async function expandAll(from?: string, limit = 400) {
const dir = queue.shift()!;
toExpand.add(dir);
count++;
await loadDir(dir);
if (isGroupKey(dir)) {
for (const n of model.children.get(dir) ?? []) queue.push(n.key);
continue;
}
const path = pathOfKey(dir);
await loadDir(path);
if (gen !== generation) return;
const s = get();
for (const e of visibleEntries(s.listings.get(dir) ?? [], s.options))
if (isDirEntry(e) && !e.ignored && e.kind !== "symlink") queue.push(e.path);
for (const e of visibleEntries(s.listings.get(path) ?? [], s.options))
if (isDirEntry(e) && !e.ignored && e.kind !== "symlink") queue.push(keyLike(dir, e.path));
}
setExpanded(toExpand);
}
export function collapseAll() {
const { root } = get();
if (root) setExpanded(new Set([root]));
const { root, mode } = get();
if (root) setExpanded(new Set(mode === "project" || mode === "projectFiles" ? [root] : []));
}
// ---------------------------------------------------------------------------
+272
Переглянути файл
@@ -0,0 +1,272 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { localChangedPaths, registerProjectViewRoot } from "@/workbench/contributions";
vi.mock("@/workbench/messages", () => ({
confirm: async () => true,
confirmChecked: async () => ({ checked: false }),
ask: async () => ({ button: "cancel", checked: false }),
}));
const { MemoryFs, setProjectFsForTests } = await import("./fs");
const { DEFAULT_TREE_OPTIONS, buildModel, buildPathsModel, libKey, pathOfKey, withExtraRoots } =
await import("./model");
const store = await import("./store");
const {
autoExpand,
currentModel,
initProjectTree,
isReadOnlyPath,
loadDir,
modeShows,
revealPath,
setOpenFiles,
setChangedFiles,
setViewMode,
useProjectTree,
} = store;
const { beginRename, deleteSelection } = await import("./operations");
const { contextMenuItems } = await import("./contextMenu");
const LIBS = {
id: "externalLibraries",
label: "External Libraries",
icon: "library",
order: 5,
readOnly: true,
children: [
{
label: "< Python 3.12 >",
icon: "file-python",
children: [
{ label: "python3.12", icon: "folder-library", path: "/py/lib/python3.12", detail: "library root" },
{ label: "site-packages", icon: "folder-library", path: "/p/.venv/lib/site-packages" },
],
},
],
};
/** Key of a library folder / file below the Python SDK group of `LIBS`. */
const pyKey = (path: string) => libKey("externalLibraries/< Python 3.12 >", path);
describe("withExtraRoots groups", () => {
it("nests group nodes and marks library folders read-only", () => {
const listings = new Map([
["/p", []],
[
"/py/lib/python3.12",
[
{
name: "os.py",
path: "/py/lib/python3.12/os.py",
kind: "file" as const,
size: 1,
mtimeMs: 1,
ignored: false,
excluded: false,
hidden: false,
},
],
],
]);
const base = buildModel({ path: "/p", name: "proj" }, listings, DEFAULT_TREE_OPTIONS);
const m = withExtraRoots(base, [LIBS], listings, DEFAULT_TREE_OPTIONS);
expect(m.roots.map((r) => [r.label, r.group ?? false])).toEqual([
["proj", false],
["External Libraries", true],
]);
const group = m.children.get(m.roots[1]!.key)!;
expect(group.map((n) => n.label)).toEqual(["< Python 3.12 >"]);
// Library keys have their own namespace: site-packages inside the project is listed too.
const folders = m.children.get(group[0]!.key)!;
expect(folders.map((n) => [n.label, n.detail, n.readOnly, n.root, n.key, n.path])).toEqual([
["python3.12", "library root", true, false, pyKey("/py/lib/python3.12"), "/py/lib/python3.12"],
["site-packages", undefined, true, false, pyKey("/p/.venv/lib/site-packages"), "/p/.venv/lib/site-packages"],
]);
const os = m.byKey.get(pyKey("/py/lib/python3.12/os.py"))!;
expect([os.readOnly, os.path, pathOfKey(os.key)]).toEqual([true, "/py/lib/python3.12/os.py", "/py/lib/python3.12/os.py"]);
expect(m.children.get(pyKey("/py/lib/python3.12"))![0]).toBe(os);
expect(m.keyOfPath.get("/py/lib/python3.12/os.py")).toBe(os.key);
});
});
describe("buildPathsModel", () => {
it("builds the folders down to each file below its innermost root", () => {
const m = buildPathsModel(
[
{ path: "/p", label: "proj", icon: "" },
{ path: "/data/scratches", label: "Scratches", icon: "scratches" },
],
["/p/src/b.ts", "/p/src/a/x.ts", "/p/README.md", "/data/scratches/s.py", "/tmp/out.log"],
);
expect(m.roots.map((r) => [r.label, r.root, r.icon])).toEqual([
["proj", true, undefined],
["Scratches", true, "scratches"],
["/tmp", true, "folder"],
]);
expect(m.children.get("/p")!.map((n) => n.label)).toEqual(["src", "README.md"]);
expect(m.children.get("/p/src")!.map((n) => n.label)).toEqual(["a", "b.ts"]);
expect(m.children.get("/p/src/a")!.map((n) => n.path)).toEqual(["/p/src/a/x.ts"]);
expect(m.keyOfPath.get("/data/scratches/s.py")).toBe("/data/scratches/s.py");
});
it("leaves out roots without files", () => {
expect(buildPathsModel([{ path: "/p", label: "proj", icon: "" }], []).roots).toEqual([]);
});
});
describe("localChangedPaths", () => {
it("lists change-list files, all lists or one, optionally without deleted ones", () => {
const lists = [
{ id: "default", name: "Changes", active: true, files: [{ path: "/p/b.ts" }, { path: "/p/gone.ts", deleted: true }] },
{ id: "ui", name: "UI", active: false, files: [{ path: "/p/a.ts" }] },
];
expect(localChangedPaths(lists, null, true)).toEqual(["/p/a.ts", "/p/b.ts"]);
expect(localChangedPaths(lists)).toEqual(["/p/a.ts", "/p/b.ts", "/p/gone.ts"]);
expect(localChangedPaths(lists, "ui")).toEqual(["/p/a.ts"]);
expect(localChangedPaths([])).toEqual([]);
});
});
describe("project view modes", () => {
let disposers: Array<() => void> = [];
beforeEach(() => {
localStorage.clear();
const fs = new MemoryFs(
{
"/p/src/App.kt": "x",
"/p/README.md": "x",
"/data/scratches/s.py": "x",
"/py/lib/python3.12/os.py": "x",
"/p/.venv/pyvenv.cfg": "x",
"/p/.venv/lib/site-packages/req/api.py": "x",
},
"/p",
);
setProjectFsForTests(fs, "/p");
initProjectTree("/p", "proj");
disposers = [
registerProjectViewRoot({
id: "scratches",
label: "Scratches",
icon: "scratches",
path: "/data/scratches",
order: 10,
scope: "scratches",
}),
registerProjectViewRoot(LIBS),
];
});
afterEach(() => {
setViewMode("project");
setOpenFiles([]);
setChangedFiles([]);
disposers.forEach((d) => d());
setProjectFsForTests(null);
initProjectTree(null);
});
const labels = () => currentModel().roots.map((r) => r.label);
it("switches panes and remembers the choice", () => {
expect(labels()).toEqual(["proj", "External Libraries", "Scratches"]);
setViewMode("projectFiles");
expect(labels()).toEqual(["proj"]);
setViewMode("scratches");
expect(labels()).toEqual(["Scratches"]);
expect(useProjectTree.getState().expanded.has("/data/scratches")).toBe(true);
expect(localStorage.getItem("openidea.projectTree.mode")).toBe(JSON.stringify("scratches"));
});
it("keeps each pane's expansion", () => {
store.setExpanded(new Set(["/p", "/p/src"]));
setViewMode("projectFiles");
expect(useProjectTree.getState().expanded).toEqual(new Set(["/p"]));
setViewMode("project");
expect(useProjectTree.getState().expanded).toEqual(new Set(["/p", "/p/src"]));
});
it("lists open and changed files, expanded once", () => {
setOpenFiles(["/p/src/App.kt", "/data/scratches/s.py"]);
setChangedFiles(["/p/README.md"]);
setViewMode("openFiles");
expect(labels()).toEqual(["proj", "Scratches"]);
autoExpand(currentModel());
expect([...useProjectTree.getState().expanded].sort()).toEqual(["/data/scratches", "/p", "/p/src"]);
// A collapsed folder stays collapsed.
store.setExpanded(new Set(["/p"]));
autoExpand(currentModel());
expect(useProjectTree.getState().expanded.has("/p/src")).toBe(false);
expect(modeShows("/p/src/App.kt")).toBe(true);
expect(modeShows("/p/README.md")).toBe(false);
setViewMode("changedFiles");
expect(
currentModel()
.children.get("/p")!
.map((n) => n.label),
).toEqual(["README.md"]);
});
it("reveals library files through their group nodes", async () => {
await loadDir("/py/lib/python3.12");
expect(revealPath("/py/lib/python3.12/os.py")).toBe(true);
const expanded = useProjectTree.getState().expanded;
expect(expanded.has("\u0000group:externalLibraries")).toBe(true);
expect(expanded.has("\u0000group:externalLibraries/< Python 3.12 >")).toBe(true);
expect(expanded.has(pyKey("/py/lib/python3.12"))).toBe(true);
expect(useProjectTree.getState().revealRequest?.path).toBe("/py/lib/python3.12/os.py");
});
it("keeps library sources read-only", async () => {
await loadDir("/py/lib/python3.12");
const file = currentModel().byKey.get(pyKey("/py/lib/python3.12/os.py"))!;
expect(isReadOnlyPath(file.path)).toBe(true);
expect(isReadOnlyPath("/p/src")).toBe(false);
beginRename(file);
expect(useProjectTree.getState().editing).toBeNull();
await deleteSelection([file]);
await expect((await import("./fs")).projectFs("/p").readDir("/py/lib/python3.12")).resolves.toHaveLength(1);
const ids = contextMenuItems([file]).map((i) => ("id" in i ? i.id : i.kind));
expect(ids).not.toContain("delete");
expect(ids).not.toContain("new");
expect(ids).toContain("copyPath");
});
it("shows library folders inside the project in both places, read-only", async () => {
const site = "/p/.venv/lib/site-packages";
for (const dir of ["/p", "/p/.venv", "/p/.venv/lib", site, `${site}/req`]) await loadDir(dir);
const m = currentModel();
// Under the project: path keys, read-only from the library root down.
const venvKids = m.children.get("/p/.venv")!;
expect(venvKids.map((n) => [n.label, n.key, n.readOnly ?? false])).toEqual([
["lib/site-packages/req", "/p/.venv/lib", true],
["pyvenv.cfg", "/p/.venv/pyvenv.cfg", false],
]);
expect(m.byKey.get("/p/.venv")?.readOnly).toBeUndefined();
const projectFile = m.byKey.get(`${site}/req/api.py`)!;
expect(projectFile.readOnly).toBe(true);
// Under External Libraries: the same folders in the library namespace.
const libFile = m.byKey.get(pyKey(`${site}/req/api.py`))!;
expect([libFile.path, libFile.readOnly]).toEqual([`${site}/req/api.py`, true]);
expect(m.children.get(pyKey(site))!.map((n) => n.key)).toEqual([pyKey(`${site}/req`)]);
// Paths resolve to the project node (Select In, autoscroll from source).
expect(m.keyOfPath.get(`${site}/req/api.py`)).toBe(`${site}/req/api.py`);
expect(isReadOnlyPath(site)).toBe(true);
beginRename(projectFile);
expect(useProjectTree.getState().editing).toBeNull();
await deleteSelection([projectFile, libFile]);
await expect((await import("./fs")).projectFs("/p").readDir(`${site}/req`)).resolves.toHaveLength(1);
const ids = contextMenuItems([projectFile]).map((i) => ("id" in i ? i.id : i.kind));
expect(ids).not.toContain("delete");
expect(ids).not.toContain("rename");
expect(contextMenuItems([m.byKey.get("/p/.venv/pyvenv.cfg")!]).map((i) => ("id" in i ? i.id : i.kind))).toContain("delete");
});
it("expands library folders in their own namespace", async () => {
await store.expandAll(pyKey("/p/.venv/lib/site-packages"));
const expanded = useProjectTree.getState().expanded;
expect(expanded.has(pyKey("/p/.venv/lib/site-packages/req"))).toBe(true);
expect(expanded.has("/p/.venv/lib/site-packages/req")).toBe(false);
});
});
+98
Переглянути файл
@@ -0,0 +1,98 @@
.popup {
width: 620px;
max-width: calc(100vw - 32px);
overflow: hidden;
}
.body {
display: flex;
flex-direction: column;
}
.header {
display: flex;
align-items: center;
gap: 8px;
height: var(--h-row);
padding: 4px 10px 0;
font-size: var(--fs-small);
color: var(--fg-subtle);
}
.title {
flex: 1;
font-weight: 600;
color: var(--fg-strong);
}
.link {
padding: 0;
border: 0;
background: none;
color: var(--link);
font: inherit;
cursor: pointer;
}
.link:hover {
color: var(--link-hover);
}
.inputRow {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
border-bottom: 1px solid var(--border-popup);
}
.inputIcon {
flex: none;
color: var(--fg-subtle);
}
.input {
flex: 1;
font-family: var(--font-mono);
}
.rows {
max-height: 360px;
height: auto;
}
.section {
padding: 4px 10px 0;
font-size: var(--fs-small);
color: var(--fg-subtle);
}
.row {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
flex: 1;
}
.rowIcon {
flex: none;
color: var(--fg-muted);
}
.label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: pre;
font-family: var(--font-mono);
font-size: var(--fs-code);
color: var(--fg-emphasis);
}
.activeRow .label {
color: var(--fg-strong);
}
.detail {
margin-left: auto;
color: var(--fg-subtle);
font-size: var(--fs-small);
white-space: nowrap;
}
.empty {
padding: 6px 10px;
color: var(--fg-subtle);
font-size: var(--fs-small);
}
.footer {
padding: 6px 10px;
border-top: 1px solid var(--border-popup);
font-size: var(--fs-small);
color: var(--fg-subtle);
}
+176
Переглянути файл
@@ -0,0 +1,176 @@
/**
* The Run Anything popup: a command line with the recent commands and the
* providers' entries (run configurations, `mvn` / `gradle` goals…). Enter runs
* the selected entry, or the line itself: a provider that claims it, else the
* shell in the project directory, its output in the Run tool window.
*/
import { useMemo, useState, type KeyboardEvent } from "react";
import clsx from "clsx";
import { create } from "zustand";
import { executeAction } from "@/platform/actions";
import { usePlatform } from "@/platform/platformInfo";
import { useProject } from "@/platform/project";
import { Icon, Input, List, Popup, T, listNavigate } from "@/ui";
import { runAnythingProviderList } from "@/workbench/contributions";
import { buildRows, clearHistory, pushHistory, readHistory, shellRunConfig, type Row } from "./model";
import styles from "./RunAnything.module.css";
export const useRunAnything = create<{ open: boolean }>(() => ({ open: false }));
export const showRunAnything = () => useRunAnything.setState({ open: true });
const close = () => useRunAnything.setState({ open: false });
/** Runs a command line: a provider that claims it, else the platform shell. */
export async function runCommandLine(line: string): Promise<void> {
const text = line.trim();
const root = useProject.getState().project?.root;
if (!text || !root) return;
pushHistory(root, text);
for (const p of runAnythingProviderList()) {
try {
if (p.execute && (await p.execute(text))) return;
} catch (e) {
console.error(`run anything provider ${p.id} failed`, e);
}
}
await executeAction("run.runConfig", shellRunConfig(text, root, usePlatform.getState().os === "windows"));
}
export function RunAnythingHost() {
const open = useRunAnything((s) => s.open);
const root = useProject((s) => s.project?.root);
if (!open || !root) return null;
return <RunAnythingPopup root={root} />;
}
const isSection = (r: Row) => r.kind === "section";
function RunAnythingPopup({ root }: { root: string }) {
const [line, setLine] = useState("");
const [active, setActive] = useState(-1);
const [history, setHistory] = useState(() => readHistory(root));
const rows = useMemo(
() =>
buildRows(line.trim(), history, runAnythingProviderList(), (h) => {
close();
void runCommandLine(h);
}),
[line, history],
);
const run = (row: Row | undefined) => {
if (!row || row.kind !== "item") return;
close();
if (row.group !== "recent") pushHistory(root, row.item.completion ?? row.item.label);
void row.item.run();
};
const runLine = () => {
if (!line.trim()) return;
close();
void runCommandLine(line);
};
const complete = (row: Row | undefined) => {
if (!row || row.kind !== "item") return;
setLine(row.item.completion ?? row.item.label);
setActive(-1);
};
const onKeyDown = (e: KeyboardEvent) => {
if (["ArrowDown", "ArrowUp", "PageDown", "PageUp"].includes(e.key)) {
const next = listNavigate(e.key, { items: rows, active: Math.max(active, 0), wrap: true, isSection });
if (next !== null) {
e.preventDefault();
setActive(active < 0 && e.key === "ArrowDown" ? rows.findIndex((r) => !isSection(r)) : next);
}
return;
}
if (e.key === "Tab") {
e.preventDefault();
complete(rows[active >= 0 ? active : rows.findIndex((r) => !isSection(r))]);
} else if (e.key === "Enter") {
e.preventDefault();
if (active >= 0) run(rows[active]);
else runLine();
}
};
return (
<Popup
open
anchor={{ x: window.innerWidth / 2, y: 72 }}
placement="bottom"
onClose={close}
role="dialog"
aria-label="Run Anything"
className={styles.popup}
trapFocus
>
<div className={styles.body}>
<div className={styles.header}>
<span className={styles.title}>
<T>Run Anything</T>
</span>
{history.length > 0 && (
<button
type="button"
className={styles.link}
onClick={() => {
clearHistory(root);
setHistory([]);
}}
>
<T>Clear History</T>
</button>
)}
</div>
<div className={styles.inputRow}>
<Icon name="run-anything" size={16} className={styles.inputIcon} />
<Input
data-autofocus
variant="bare"
className={styles.input}
value={line}
placeholder="Run a command, configuration or goal"
aria-label="Command line"
onChange={(e) => {
setLine(e.target.value);
setActive(-1);
}}
onKeyDown={onKeyDown}
/>
</div>
<List
items={rows}
getKey={(r) => r.key}
isSection={isSection}
renderSection={(r) => <div className={styles.section}>{r.kind === "section" && <T>{r.label}</T>}</div>}
activeIndex={active}
onActiveChange={setActive}
onActivate={(r) => run(r)}
focusable={false}
hoverSelects={false}
rowHeight={24}
className={styles.rows}
aria-label="Run Anything entries"
empty={
<div className={styles.empty}>
{line.trim() ? <T>Press Enter to run the command in the project directory</T> : <T>Type a command</T>}
</div>
}
renderItem={(r, state) =>
r.kind === "item" ? (
<div className={clsx(styles.row, state.active && styles.activeRow)}>
<Icon name={r.item.icon ?? "run"} size={16} className={styles.rowIcon} />
<span className={styles.label}>{r.item.label}</span>
{r.item.detail && <span className={styles.detail}>{r.item.detail}</span>}
</div>
) : null
}
/>
<div className={styles.footer}>
<T>Enter to run, Tab to complete. Other commands run in the project directory.</T>
</div>
</div>
</Popup>
);
}
+26
Переглянути файл
@@ -0,0 +1,26 @@
/**
* Run Anything (⌃⌃, IDEA's RunAnything): one popup to run a shell command in
* the project directory, a run configuration or a build tool goal. Features
* add entries with `registerRunAnythingProvider`.
*/
import { registerActions } from "@/platform/actions";
import { registerOverlay } from "@/workbench/contributions";
import { RunAnythingHost, showRunAnything } from "./RunAnything";
export function activate(): () => void {
const disposers = [
registerOverlay({ id: "runAnything.popup", component: RunAnythingHost }),
registerActions([
{
id: "runAnything.show",
title: "Run Anything…",
category: "Run",
icon: "run-anything",
keywords: ["command", "shell", "execute", "npm", "mvn", "gradle"],
when: "projectOpen",
run: showRunAnything,
},
]),
];
return () => disposers.reverse().forEach((d) => d());
}
+63
Переглянути файл
@@ -0,0 +1,63 @@
import { beforeEach, describe, expect, it } from "vitest";
import type { RunAnythingProvider } from "@/workbench/contributions";
import { buildRows, clearHistory, MAX_HISTORY, matches, pushHistory, readHistory, shellCommand, shellRunConfig } from "./model";
beforeEach(() => localStorage.clear());
describe("run anything", () => {
it("keeps a per-project history, newest first and without duplicates", () => {
pushHistory("/a", "ls");
pushHistory("/a", "make test");
pushHistory("/a", " ls ");
pushHistory("/b", "npm run dev");
expect(readHistory("/a")).toEqual(["ls", "make test"]);
expect(readHistory("/b")).toEqual(["npm run dev"]);
for (let i = 0; i < MAX_HISTORY + 5; i++) pushHistory("/a", `cmd ${i}`);
expect(readHistory("/a")).toHaveLength(MAX_HISTORY);
clearHistory("/a");
expect(readHistory("/a")).toEqual([]);
});
it("matches every word of the query", () => {
expect(matches("mvn clean install", "inst mvn")).toBe(true);
expect(matches("mvn clean install", "gradle")).toBe(false);
expect(matches("anything", "")).toBe(true);
});
it("lists recent commands, then each provider's group; a failing provider is skipped", () => {
const ran: string[] = [];
const providers: RunAnythingProvider[] = [
{
id: "npm",
group: "npm scripts",
items: (q) => [{ key: "dev", label: "npm run dev", icon: "run", run: () => {} }].filter((i) => matches(i.label, q)),
},
{
id: "broken",
group: "Broken",
items: () => {
throw new Error("nope");
},
},
];
const rows = buildRows("run", ["npm run dev", "ls"], providers, (l) => ran.push(l));
expect(rows.map((r) => (r.kind === "section" ? `# ${r.label}` : r.item.label))).toEqual([
"# Recent",
"npm run dev",
"# npm scripts",
"npm run dev",
]);
const recent = rows[1]!;
if (recent.kind === "item") recent.item.run();
expect(ran).toEqual(["npm run dev"]);
});
it("runs a plain line through the platform shell in the project directory", () => {
expect(shellCommand("echo hi", false)).toEqual({ command: "/bin/sh", args: ["-c", "echo hi"] });
expect(shellCommand("dir", true)).toEqual({ command: "cmd.exe", args: ["/d", "/s", "/c", "dir"] });
const cfg = shellRunConfig("x".repeat(80), "/work", false);
expect(cfg.kind).toBe("external");
expect(cfg.cwd).toBe("/work");
expect(cfg.name).toHaveLength(58);
});
});
+109
Переглянути файл
@@ -0,0 +1,109 @@
/**
* Run Anything (⌃⌃): command-line history, the rows offered for the typed
* text (recent commands, then each provider's group) and the shell command
* a plain line runs as.
*/
import type { RunConfig } from "@/platform/ipc";
import type { RunAnythingItem, RunAnythingProvider } from "@/workbench/contributions";
const HISTORY_PREFIX = "openidea.runAnything.history.v1:";
export const MAX_HISTORY = 30;
/** Rows per provider group. */
const GROUP_LIMIT = 8;
export function readHistory(scope: string): string[] {
try {
const v: unknown = JSON.parse(localStorage.getItem(HISTORY_PREFIX + scope) ?? "[]");
return Array.isArray(v) ? v.filter((x): x is string => typeof x === "string" && !!x.trim()).slice(0, MAX_HISTORY) : [];
} catch {
return [];
}
}
/** Puts `line` first (once) in the history of `scope`. */
export function pushHistory(scope: string, line: string): string[] {
const text = line.trim();
if (!text) return readHistory(scope);
const next = [text, ...readHistory(scope).filter((h) => h !== text)].slice(0, MAX_HISTORY);
try {
localStorage.setItem(HISTORY_PREFIX + scope, JSON.stringify(next));
} catch {
/* storage unavailable */
}
return next;
}
export function clearHistory(scope: string) {
try {
localStorage.removeItem(HISTORY_PREFIX + scope);
} catch {
/* storage unavailable */
}
}
export type Row = { kind: "section"; key: string; label: string } | { kind: "item"; key: string; item: RunAnythingItem; group: string };
/** Case-insensitive: every word of `query` occurs in `text`. */
export function matches(text: string, query: string): boolean {
const t = text.toLowerCase();
return query
.toLowerCase()
.split(/\s+/)
.filter(Boolean)
.every((w) => t.includes(w));
}
/** Rows for `query`: matching recent commands, then each provider's items under its group. */
export function buildRows(
query: string,
history: readonly string[],
providers: readonly RunAnythingProvider[],
runLine: (line: string) => void,
): Row[] {
const rows: Row[] = [];
const section = (key: string, label: string, items: RunAnythingItem[]) => {
if (!items.length) return;
rows.push({ kind: "section", key: `s:${key}`, label });
for (const item of items) rows.push({ kind: "item", key: `${key}:${item.key}`, item, group: key });
};
section(
"recent",
"Recent",
history
.filter((h) => matches(h, query))
.slice(0, GROUP_LIMIT)
.map((h) => ({ key: h, label: h, icon: "history", completion: h, run: () => runLine(h) })),
);
for (const p of providers) {
let items: RunAnythingItem[] = [];
try {
items = p.items(query).slice(0, GROUP_LIMIT);
} catch (e) {
console.error(`run anything provider ${p.id} failed`, e);
}
section(p.id, p.group, items);
}
return rows;
}
/** The program and arguments that run `line` through the platform shell. */
export function shellCommand(line: string, windows: boolean): { command: string; args: string[] } {
return windows ? { command: "cmd.exe", args: ["/d", "/s", "/c", line] } : { command: "/bin/sh", args: ["-c", line] };
}
/** A temporary run configuration for a shell command line (shown in the Run tool window). */
export function shellRunConfig(line: string, cwd: string, windows: boolean): RunConfig {
const { command, args } = shellCommand(line, windows);
return {
id: `runAnything:${line}`,
name: line.length > 60 ? `${line.slice(0, 57)}…` : line,
kind: "external",
command,
args,
cwd,
env: {},
detected: false,
isTest: false,
debugAdapter: null,
};
}
+151
Переглянути файл
@@ -0,0 +1,151 @@
import { useMemo, useState, type KeyboardEvent } from "react";
import clsx from "clsx";
import { HighlightedText, Icon, List, Popup, SearchField, fuzzyMatch, listNavigate, T, type PopupAnchor } from "@/ui";
import { useRunConfigTypes, runConfigTypes, type RunConfigTypeContribution } from "@/workbench/contributions";
import styles from "./Run.module.css";
export type TypeRow =
| { kind: "group"; key: string; label: string }
| { kind: "type"; key: string; type: RunConfigTypeContribution; indices: number[] | null; nested: boolean };
const ROW = 24;
/** "Add New Configuration" rows: types by label, grouped ones under their group; filtered by `query`. */
export function typeRows(types: readonly RunConfigTypeContribution[], query: string): TypeRow[] {
const q = query.trim();
const match = (t: RunConfigTypeContribution) =>
q ? (fuzzyMatch(t.label, q) ?? (t.group && fuzzyMatch(t.group, q) ? [] : null)) : null;
const rows: TypeRow[] = [];
const groups = new Map<string, RunConfigTypeContribution[]>();
const entries: Array<{ label: string; group?: string; type?: RunConfigTypeContribution }> = [];
for (const t of types) {
if (!t.group) {
entries.push({ label: t.label, type: t });
continue;
}
let g = groups.get(t.group);
if (!g) {
groups.set(t.group, (g = []));
entries.push({ label: t.group, group: t.group });
}
g.push(t);
}
entries.sort((a, b) => a.label.localeCompare(b.label));
for (const e of entries) {
if (e.type) {
const indices = match(e.type);
if (!q || indices) rows.push({ kind: "type", key: e.type.id, type: e.type, indices, nested: false });
continue;
}
const members = groups
.get(e.group!)!
.map((t) => ({ t, indices: match(t) }))
.filter((x) => !q || x.indices);
if (!members.length) continue;
rows.push({ kind: "group", key: `group:${e.group}`, label: e.group! });
for (const { t, indices } of members) rows.push({ kind: "type", key: t.id, type: t, indices, nested: true });
}
return rows;
}
/** IDEA's "Add New Configuration" popup: a searchable list of configuration types. */
export function AddConfigPopup({
anchor,
onPick,
onClose,
}: {
anchor: PopupAnchor;
onPick: (type: RunConfigTypeContribution) => void;
onClose: () => void;
}) {
const types = useRunConfigTypes((s) => s.types);
const [query, setQuery] = useState("");
const rows = useMemo(() => typeRows(runConfigTypes(types), query), [types, query]);
const isSection = (r: TypeRow) => r.kind === "group";
const first = Math.max(
0,
rows.findIndex((r) => r.kind === "type"),
);
const [active, setActive] = useState(first);
const clamped = Math.min(active, rows.length - 1);
const pick = (row: TypeRow | undefined) => {
if (row?.kind !== "type") return;
onClose();
onPick(row.type);
};
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault();
pick(rows[clamped]);
return;
}
const next = listNavigate(e.key, { items: rows, active: clamped, isSection, wrap: true });
if (next !== null) {
e.preventDefault();
setActive(next);
}
};
return (
<Popup
open
anchor={anchor}
onClose={onClose}
placement="bottom-start"
aria-label="Add New Configuration"
className={styles.chooser}
trapFocus
>
<div className={styles.chooserCaption}>
<T>Add New Configuration</T>
</div>
<div className={styles.chooserSearch}>
<SearchField
value={query}
onChange={(v) => {
setQuery(v);
setActive(0);
}}
placeholder="Search types"
aria-label="Search configuration types"
data-autofocus=""
onKeyDown={onKeyDown}
variant="bare"
/>
</div>
<div style={{ height: Math.max(ROW, Math.min(rows.length * ROW, 14 * ROW)) }}>
<List<TypeRow>
aria-label="Configuration types"
className={styles.chooserList}
items={rows}
getKey={(r) => r.key}
isSection={isSection}
renderSection={(r) => (r.kind === "group" ? r.label : null)}
activeIndex={rows[clamped]?.kind === "type" ? clamped : first}
onActiveChange={setActive}
focusable={false}
hoverSelects
rowHeight={ROW}
onActivate={(r) => pick(r)}
empty={
<span className={styles.chooserMuted}>
<T>Nothing found</T>
</span>
}
renderItem={(r) =>
r.kind === "type" ? (
<span className={clsx(styles.chooserRow, r.nested && styles.typeNested)} title={r.type.description}>
<Icon name={r.type.icon} size={13} className={styles.chooserIcon} />
<span className={styles.chooserName}>
{r.indices?.length ? <HighlightedText text={r.type.label} indices={r.indices} /> : r.type.label}
</span>
</span>
) : null
}
/>
</div>
</Popup>
);
}
+25 -2
Переглянути файл
@@ -5,16 +5,18 @@ import { shortcutLabel } from "@/platform/keymap";
import { HighlightedText, Icon, IconButton, List, Popup, SearchField, fuzzyMatch, listNavigate, T } from "@/ui";
import type { RunConfig } from "@/platform/ipc";
import { commandLine, configIcon, groupConfigs } from "./configs";
import { canDeleteConfig, deleteConfig, saveTemporary, selectConfig, startRun, useRun } from "./store";
import { canDeleteConfig, CURRENT_FILE, deleteConfig, saveTemporary, selectConfig, startRun, useRun } from "./store";
import styles from "./Run.module.css";
type Row =
| { type: "section"; key: string; label: string }
| { type: "config"; key: string; config: RunConfig; indices: number[] | null }
| { type: "edit"; key: string }
| { type: "current"; key: string; indices: number[] | null }
| { type: "save"; key: string; config: RunConfig };
const ROW = 24;
const CURRENT_FILE_LABEL = "Current File";
export function buildRows(
configs: readonly RunConfig[],
@@ -25,6 +27,9 @@ export function buildRows(
const rows: Row[] = [];
// IDEA: "Save '<name>' Configuration" first while a temporary configuration is selected.
if (!query && selected && temporary.includes(selected.id)) rows.push({ type: "save", key: "save", config: selected });
// IDEA: the permanent "Current File" entry runs the active file's context.
const currentIndices = query ? fuzzyMatch(CURRENT_FILE_LABEL, query) : null;
if (!query || currentIndices) rows.push({ type: "current", key: "current", indices: currentIndices });
for (const group of groupConfigs(configs)) {
const matched = group.configs
.map((c) => ({ c, indices: query ? fuzzyMatch(c.name, query) : null }))
@@ -84,7 +89,9 @@ function ConfigChooser() {
const isSection = (r: Row) => r.type === "section";
const initial = Math.max(
0,
rows.findIndex((r) => r.type === "config" && r.config.id === selectedId),
rows.findIndex(
(r) => (r.type === "config" && r.config.id === selectedId) || (r.type === "current" && selectedId === CURRENT_FILE),
),
);
const [active, setActive] = useState(initial);
const [anchor] = useState(anchorElement);
@@ -124,6 +131,12 @@ function ConfigChooser() {
void saveTemporary(row.config.id);
return;
}
if (row.type === "current") {
selectConfig(CURRENT_FILE);
if (how === "run" || how === "debug")
void executeAction(how === "run" ? "run.runContext" : "run.debugContext").then(() => selectConfig(CURRENT_FILE));
return;
}
if (row.type !== "config") return;
selectConfig(row.config.id);
if (how === "edit") void executeAction("run.editConfigs", row.config.id);
@@ -225,6 +238,16 @@ function ConfigChooser() {
</span>
);
}
if (r.type === "current") {
return (
<span className={styles.chooserRow}>
<Icon name="file" size={13} className={styles.chooserIcon} />
<span className={clsx(styles.chooserName, selectedId === CURRENT_FILE && styles.chooserSelected)}>
{r.indices ? <HighlightedText text={CURRENT_FILE_LABEL} indices={r.indices} /> : <T>{CURRENT_FILE_LABEL}</T>}
</span>
</span>
);
}
if (r.type === "edit") {
return (
<span className={clsx(styles.chooserRow, styles.chooserEdit)}>
+716
Переглянути файл
@@ -0,0 +1,716 @@
/**
* The right side of Run/Debug Configurations: the type's own fields, the
* common ones, the options added through "Modify options" (⌥M) and the
* Before launch list.
*/
import { useRef, useState, type ReactNode } from "react";
import clsx from "clsx";
import type { BeforeLaunchTask, RunConfig } from "@/platform/ipc";
import { useWorkflowSettings } from "@/platform/workflowSettings";
import { useTx, useTxTemplate } from "@/platform/i18n";
import { Button, Checkbox, Icon, IconButton, Input, Menu, Select, T, type MenuItem, type SelectOption } from "@/ui";
import { useContributions, type RunConfigTypeContribution } from "@/workbench/contributions";
import { TASK_LABELS, taskLabel, type BeforeLaunchKind } from "./beforeLaunch";
import {
commandLine,
compoundCycle,
configIcon,
formatArgs,
formatEnv,
kindLabel,
parseArgs,
parseEnv,
type ConfigErrors,
} from "./configs";
import { EnvDialog } from "./EnvDialog";
import { COMPOUND, formParams, terminalApplies, withParams } from "./types";
import styles from "./Run.module.css";
/**
* Debug adapters with a real launch template (`debugger/launch.ts`, `debugger/java.ts`),
* verified against small programs. Others (netcoredbg, dart, php-debug, …) only got
* generic launch arguments and are left out until they have one; a configuration that
* already names one keeps it.
*/
const ADAPTERS: ReadonlyArray<{ id: string; label: string }> = [
{ id: "java-debug", label: "java-debug (Java)" },
{ id: "debugpy", label: "debugpy (Python)" },
{ id: "js-debug", label: "js-debug (Node.js / TypeScript)" },
{ id: "delve", label: "Delve (Go)" },
{ id: "codelldb", label: "CodeLLDB (Rust, C, C++)" },
{ id: "lldb-dap", label: "lldb-dap (C, C++, Swift)" },
];
/** Options "Modify options" adds to the form: value fields (shown once added) and flags (chips). */
export type OptionId =
| "allowParallel"
| "stdinFile"
| "envFiles"
| "outputFile"
| "showOnStdout"
| "showOnStderr"
| "beforeLaunch"
| "openToolWindow"
| "focusToolWindow"
| "isTest"
| "emulateTerminal";
const VALUE_OPTIONS: ReadonlySet<OptionId> = new Set(["stdinFile", "envFiles", "outputFile", "beforeLaunch"]);
const OPTION_LABELS: Record<OptionId, string> = {
allowParallel: "Allow multiple instances",
stdinFile: "Redirect input",
envFiles: 'Paths to ".env" files',
outputFile: "Save console output to file",
showOnStdout: "Show console when a message is printed to stdout",
showOnStderr: "Show console when a message is printed to stderr",
beforeLaunch: "Add before launch task",
openToolWindow: "Open run/debug tool window when started",
focusToolWindow: "Focus run/debug tool window when started",
isTest: "Show results in the test runner",
emulateTerminal: "Emulate terminal in output console",
};
const OPTION_GROUPS: ReadonlyArray<{ label: string; options: OptionId[]; process: boolean }> = [
{ label: "Operating System", options: ["allowParallel", "stdinFile", "envFiles", "emulateTerminal"], process: true },
{ label: "Logs", options: ["outputFile", "showOnStdout", "showOnStderr"], process: true },
{ label: "Test", options: ["isTest"], process: true },
{ label: "Before Launch", options: ["beforeLaunch", "openToolWindow", "focusToolWindow"], process: false },
];
/** Whether the option currently has a (non-default) value. */
export function optionSet(config: RunConfig, id: OptionId, openByDefault: boolean): boolean {
switch (id) {
case "allowParallel":
return !!config.allowParallel;
case "stdinFile":
return !!config.stdinFile;
case "envFiles":
return !!config.envFiles?.length;
case "outputFile":
return !!config.outputFile;
case "showOnStdout":
return !!config.showOnStdout;
case "showOnStderr":
return !!config.showOnStderr;
case "beforeLaunch":
return !!config.beforeLaunch?.length;
case "openToolWindow":
return config.openToolWindow ?? openByDefault;
case "focusToolWindow":
return !!config.focusToolWindow;
case "isTest":
return config.isTest;
case "emulateTerminal":
return !!config.emulateTerminal;
}
}
/** The patch that turns option `id` on or off. */
export function optionPatch(id: OptionId, on: boolean): Partial<RunConfig> {
switch (id) {
case "allowParallel":
return { allowParallel: on };
case "stdinFile":
return { stdinFile: null };
case "envFiles":
return { envFiles: [] };
case "outputFile":
return { outputFile: null };
case "beforeLaunch":
return { beforeLaunch: [] };
case "showOnStdout":
return { showOnStdout: on };
case "showOnStderr":
return { showOnStderr: on };
case "openToolWindow":
return { openToolWindow: on };
case "focusToolWindow":
return { focusToolWindow: on };
case "isTest":
return on ? { isTest: true, emulateTerminal: false } : { isTest: false };
case "emulateTerminal":
return { emulateTerminal: on };
}
}
export interface ConfigFormProps {
config: RunConfig;
/** Every configuration of the dialog (compound members, before launch targets, name checks). */
all: readonly RunConfig[];
type: RunConfigTypeContribution | undefined;
errors: ConfigErrors;
/** Editing a configuration template (no name, storage or compound members). */
template?: boolean;
onChange: (next: RunConfig) => void;
/** Bumped by ⌥M to open the Modify options menu. */
modifyRequest: number;
}
export function ConfigForm({ config, all, type, errors, template, onChange, modifyRequest }: ConfigFormProps) {
const openByDefault = useWorkflowSettings((s) => s.run.showOnStart);
const externalTools = useWorkflowSettings((s) => s.externalTools);
const toolWindows = useContributions((s) => s.toolWindows);
const tx = useTx();
const txTemplate = useTxTemplate();
const [shown, setShown] = useState<ReadonlySet<OptionId>>(() => new Set());
const [argsText, setArgsText] = useState<string | null>(null);
const [envText, setEnvText] = useState<string | null>(null);
const [envFilesText, setEnvFilesText] = useState<string | null>(null);
const [envOpen, setEnvOpen] = useState(false);
const [modifyOpen, setModifyOpen] = useState(false);
const [lastRequest, setLastRequest] = useState(modifyRequest);
const [taskMenu, setTaskMenu] = useState(false);
const modifyRef = useRef<HTMLButtonElement>(null);
const taskRef = useRef<HTMLButtonElement>(null);
if (modifyRequest !== lastRequest) {
setLastRequest(modifyRequest);
setModifyOpen(true);
}
const compound = config.kind === COMPOUND;
const params = compound ? {} : formParams(config, type);
const update = (patch: Partial<RunConfig>) => onChange({ ...config, ...patch });
const setParam = (key: string, value: string) => {
const next = { ...(params ?? {}) };
if (value) next[key] = value;
else delete next[key];
onChange(withParams(config, next, type));
};
const isShown = (id: OptionId) => shown.has(id) || optionSet(config, id, openByDefault);
const toggleOption = (id: OptionId) => {
const on = !isShown(id);
if (VALUE_OPTIONS.has(id)) {
setShown((s) => {
const next = new Set(s);
if (on) next.add(id);
else next.delete(id);
return next;
});
if (!on) update(optionPatch(id, false));
return;
}
update(optionPatch(id, on));
};
const terminal = terminalApplies(config, type);
// A terminal has no separate input file: the two options exclude each other.
const blocked = (id: OptionId) =>
(id === "emulateTerminal" && isShown("stdinFile")) || (id === "stdinFile" && !!config.emulateTerminal);
const menuItems: MenuItem[] = OPTION_GROUPS.filter((g) => !compound || !g.process).flatMap((g): MenuItem[] => [
{ kind: "header", label: g.label },
...g.options
.filter(
(id) =>
(!compound || id === "beforeLaunch") && (id !== "emulateTerminal" || terminal || !!config.emulateTerminal),
)
.map((id): MenuItem => ({
label: OPTION_LABELS[id],
checked: isShown(id),
disabled: !isShown(id) && blocked(id),
run: () => toggleOption(id),
})),
]);
const tasks = config.beforeLaunch ?? [];
const setTasks = (next: BeforeLaunchTask[]) => update({ beforeLaunch: next });
const addTask = (kind: BeforeLaunchKind, target = "") => setTasks([...tasks, { kind, target }]);
const runnable = all.filter((c) => c.id !== config.id && c.kind !== COMPOUND);
const taskItems: MenuItem[] = [
{
label: TASK_LABELS.run,
disabled: !runnable.length,
submenu: runnable.map((c) => ({
label: c.name,
verbatim: true,
icon: configIcon(c),
run: () => addTask("run", c.id),
})),
},
{ label: TASK_LABELS.build, icon: "build-project", run: () => addTask("build") },
{
label: TASK_LABELS.tool,
disabled: !externalTools.length,
submenu: externalTools.map((t) => ({ label: t.name, verbatim: true, run: () => addTask("tool", t.id) })),
},
{ label: TASK_LABELS.command, icon: "terminal", run: () => addTask("command") },
{
label: TASK_LABELS.toolWindow,
submenu: [...toolWindows.values()]
.sort((a, b) => a.title.localeCompare(b.title))
.map((w) => ({ label: w.title, icon: w.icon, run: () => addTask("toolWindow", w.id) })),
},
];
const moveTask = (i: number, d: -1 | 1) => {
const j = i + d;
if (j < 0 || j >= tasks.length) return;
const next = [...tasks];
[next[i], next[j]] = [next[j]!, next[i]!];
setTasks(next);
};
const adapterOptions: SelectOption[] = [
{ value: "", label: "None" },
...ADAPTERS.map((a) => ({ value: a.id, label: a.label })),
];
if (config.debugAdapter && !ADAPTERS.some((a) => a.id === config.debugAdapter))
adapterOptions.push({ value: config.debugAdapter, label: txTemplate(`${config.debugAdapter} (no launch template)`) });
const chips = (
[
"allowParallel",
"emulateTerminal",
"showOnStdout",
"showOnStderr",
"openToolWindow",
"focusToolWindow",
"isTest",
] as const
).filter(
(id) => (!compound || id === "openToolWindow" || id === "focusToolWindow") && optionSet(config, id, openByDefault),
);
return (
<>
{config.detected && !template && (
<div className={styles.notice}>
<T>Detected from the project. Changes are saved as a user configuration that overrides it.</T>
</div>
)}
{!template && (
<div className={styles.formHeader}>
<Field label="Name" error={errors.name}>
<Input
value={config.name}
invalid={!!errors.name}
aria-label="Name"
onChange={(e) => update({ name: e.target.value })}
/>
</Field>
<Checkbox
checked={!config.local}
onChange={(shared) => update({ local: !shared })}
label="Store as project file"
aria-label="Store as project file (.openidea/run.toml); otherwise in the workspace (.openidea/run.local.toml)"
/>
</div>
)}
<div className={styles.formSection}>
<span className={styles.formSectionTitle}>{tx(type?.label ?? kindLabel(config.kind))}</span>
<button
ref={modifyRef}
type="button"
className={styles.noticeLink}
aria-haspopup="menu"
aria-expanded={modifyOpen}
onClick={() => setModifyOpen((v) => !v)}
>
<T>Modify options</T> <Icon name="chevron-down" size={12} />
</button>
<span className={styles.chooserMuted}>⌥M</span>
<Menu
open={modifyOpen}
anchor={modifyRef}
items={menuItems}
placement="bottom-end"
onClose={() => setModifyOpen(false)}
/>
</div>
{compound ? (
template ? null : (
<CompoundMembers
config={config}
all={all}
error={errors.compound}
onChange={(compound) => update({ compound })}
/>
)
) : params && type ? (
type.fields
.filter((f) => f.visible?.(params) ?? true)
.map((f) => (
<Field key={f.key} label={f.label} error={errors[f.key]} top={f.control === "textarea"}>
<TypeField
field={f}
value={params[f.key] ?? ""}
params={params}
all={all}
invalid={!!errors[f.key]}
onChange={(v) => setParam(f.key, v)}
/>
</Field>
))
) : (
<>
{type && (
<div className={styles.notice}>
<T>This command line doesn't fit the type's form; it is edited as a command.</T>
</div>
)}
<Field label="Command" error={errors.command}>
<Input
mono
value={config.command}
invalid={!!errors.command}
aria-label="Command"
placeholder="e.g. ./gradlew or cargo"
onChange={(e) => update({ command: e.target.value, params: {} })}
/>
</Field>
<Field label="Arguments">
<Input
mono
aria-label="Arguments"
value={argsText ?? formatArgs(config.args)}
placeholder="Space separated; quote with ' or &quot;"
onChange={(e) => {
setArgsText(e.target.value);
update({ args: parseArgs(e.target.value), params: {} });
}}
/>
</Field>
</>
)}
{!compound && (
<>
{!type?.ownsWorkingDirectory && (
<Field label="Working directory" error={errors.cwd}>
<Input
mono
aria-label="Working directory"
value={config.cwd ?? ""}
placeholder="Project root"
onChange={(e) => update({ cwd: e.target.value.trim() ? e.target.value : null })}
/>
</Field>
)}
<Field
label="Environment variables"
error={errors.env}
hint="Separate variables with semicolon: VAR=value; VAR1=value1"
>
<div className={styles.inputWithButton}>
<Input
mono
aria-label="Environment variables"
value={envText ?? formatEnv(config.env)}
invalid={!!errors.env}
onChange={(e) => {
setEnvText(e.target.value);
update({ env: Object.fromEntries(parseEnv(e.target.value).map((r) => [r.key, r.value])) });
}}
onBlur={() => setEnvText(null)}
/>
<IconButton
icon="list"
size="small"
iconSize={14}
title="Edit Environment Variables"
onClick={() => setEnvOpen(true)}
/>
</div>
{config.includeSystemEnv === false && (
<span className={styles.chooserMuted}>
<T>System environment variables are not passed</T>
</span>
)}
</Field>
{isShown("envFiles") && (
<Field label='Paths to ".env" files' hint="Relative to the project root; separate with semicolon">
<Input
mono
aria-label='Paths to ".env" files'
value={envFilesText ?? (config.envFiles ?? []).join(";")}
placeholder=".env"
onChange={(e) => {
setEnvFilesText(e.target.value);
update({
envFiles: e.target.value
.split(";")
.map((p) => p.trim())
.filter(Boolean),
});
}}
onBlur={() => setEnvFilesText(null)}
/>
</Field>
)}
{isShown("stdinFile") && (
<Field label="Redirect input from">
<Input
mono
aria-label="Redirect input from"
value={config.stdinFile ?? ""}
placeholder="File relative to the project root"
onChange={(e) => update({ stdinFile: e.target.value || null })}
/>
</Field>
)}
{isShown("outputFile") && (
<Field label="Save console output to file">
<Input
mono
aria-label="Save console output to file"
value={config.outputFile ?? ""}
placeholder="File relative to the project root"
onChange={(e) => update({ outputFile: e.target.value || null })}
/>
</Field>
)}
<Field label="Debug adapter">
<Select
value={config.debugAdapter ?? ""}
options={adapterOptions}
onChange={(v) => update({ debugAdapter: v || null })}
aria-label="Debug adapter"
/>
</Field>
</>
)}
{isShown("beforeLaunch") && (
<Field label="Before launch" error={errors.beforeLaunch} top>
<div className={styles.taskList} role="list" aria-label={tx("Before launch tasks")}>
{tasks.map((t, i) => (
<div key={i} role="listitem" className={styles.taskRow}>
{t.kind === "command" ? (
<Input
mono
aria-label={txTemplate(`Before launch command ${i + 1}`)}
value={t.target ?? ""}
placeholder="Shell command, e.g. make generate"
onChange={(e) => setTasks(tasks.map((x, j) => (j === i ? { ...x, target: e.target.value } : x)))}
/>
) : (
<span className={styles.taskLabel}>{txTemplate(taskLabel(t, all))}</span>
)}
<IconButton
icon="arrow-up"
size="small"
iconSize={12}
title="Move Up"
disabled={i === 0}
onClick={() => moveTask(i, -1)}
/>
<IconButton
icon="arrow-down"
size="small"
iconSize={12}
title="Move Down"
disabled={i === tasks.length - 1}
onClick={() => moveTask(i, 1)}
/>
<IconButton
icon="minus"
size="small"
iconSize={12}
title="Remove Task"
onClick={() => setTasks(tasks.filter((_, j) => j !== i))}
/>
</div>
))}
<Button
ref={taskRef}
size="small"
variant="ghost"
icon="plus"
aria-haspopup="menu"
onClick={() => setTaskMenu(true)}
>
<T>Add task</T>
</Button>
<Menu open={taskMenu} anchor={taskRef} items={taskItems} onClose={() => setTaskMenu(false)} />
</div>
</Field>
)}
{chips.length > 0 && (
<div className={styles.chips}>
{chips.map((id) => (
<span key={id} className={styles.chip}>
<T>{OPTION_LABELS[id]}</T>
<IconButton
icon="close"
size="small"
iconSize={10}
title={`Remove '${OPTION_LABELS[id]}'`}
onClick={() => update(optionPatch(id, false))}
/>
</span>
))}
</div>
)}
{!compound && !template && (
<div className={styles.editPreview}>
<span className={styles.chooserMuted}>
<T>Command line</T>
</span>
<code>{commandLine(config) || "—"}</code>
</div>
)}
{envOpen && (
<EnvDialog
env={config.env}
includeSystemEnv={config.includeSystemEnv !== false}
onClose={() => setEnvOpen(false)}
onOk={(env, includeSystemEnv) => {
setEnvText(null);
update({ env, includeSystemEnv });
}}
/>
)}
</>
);
}
function TypeField({
field,
value,
params,
all,
invalid,
onChange,
}: {
field: RunConfigTypeContribution["fields"][number];
value: string;
params: Readonly<Record<string, string>>;
all: readonly RunConfig[];
invalid: boolean;
onChange: (value: string) => void;
}) {
const tx = useTx();
const listId = `run-field-${field.key}`;
if (field.control === "select") {
const options = [...(field.options ?? [])];
if (value && !options.some((o) => o.value === value)) options.push({ value, label: value });
return (
<Select
value={value || (options[0]?.value ?? "")}
options={options}
onChange={onChange}
aria-label={field.label}
/>
);
}
if (field.control === "checkbox")
return <Checkbox checked={value === "true"} onChange={(on) => onChange(on ? "true" : "")} label={field.label} />;
if (field.control === "textarea")
return (
<textarea
className={clsx(styles.textarea, invalid && styles.textareaInvalid)}
aria-label={tx(field.label)}
spellCheck={false}
value={value}
placeholder={tx(field.placeholder)}
rows={4}
onChange={(e) => onChange(e.target.value)}
/>
);
const suggestions = field.suggestions?.(all, params) ?? [];
return (
<>
<Input
mono={field.mono}
aria-label={field.label}
value={value}
invalid={invalid}
placeholder={field.placeholder}
list={suggestions.length ? listId : undefined}
onChange={(e) => onChange(e.target.value)}
/>
{suggestions.length > 0 && (
<datalist id={listId}>
{suggestions.map((s) => (
<option key={s} value={s} />
))}
</datalist>
)}
</>
);
}
function CompoundMembers({
config,
all,
error,
onChange,
}: {
config: RunConfig;
all: readonly RunConfig[];
error?: string;
onChange: (ids: string[]) => void;
}) {
const tx = useTx();
const members = config.compound ?? [];
const candidates = all.filter(
(c) =>
c.id !== config.id && !members.includes(c.id) && !compoundCycle({ ...config, compound: [...members, c.id] }, all),
);
return (
<Field label="Configurations" error={error} top>
<div className={styles.taskList} role="list" aria-label={tx("Configurations of the compound")}>
{members.map((id) => {
const c = all.find((x) => x.id === id);
return (
<div key={id} role="listitem" className={styles.taskRow}>
<Icon name={c ? configIcon(c) : "warning"} size={13} className={styles.chooserIcon} />
<span className={styles.taskLabel}>{c?.name ?? id}</span>
<IconButton
icon="minus"
size="small"
iconSize={12}
title="Remove"
onClick={() => onChange(members.filter((m) => m !== id))}
/>
</div>
);
})}
<Select
value={null}
placeholder="Add configuration…"
options={candidates.map((c) => ({ value: c.id, label: c.name }))}
onChange={(id) => onChange([...members, id])}
disabled={!candidates.length}
aria-label="Add configuration"
/>
</div>
</Field>
);
}
export function Field({
label,
error,
hint,
top,
children,
}: {
label: string;
error?: string;
hint?: string;
top?: boolean;
children: ReactNode;
}) {
const txTemplate = useTxTemplate();
return (
<div className={clsx(styles.field, top && styles.fieldTop)}>
<span className={styles.fieldLabel}>{txTemplate(label)}</span>
<div className={styles.fieldControl}>
{children}
{error ? (
<span className={styles.fieldError} role="alert">
{txTemplate(error)}
</span>
) : (
hint && <span className={styles.chooserMuted}>{txTemplate(hint)}</span>
)}
</div>
</div>
);
}
+503 -270
Переглянути файл
@@ -1,18 +1,23 @@
import { useMemo, useState, type ReactNode } from "react";
import { useEffect, useMemo, useRef, useState, type DragEvent, type KeyboardEvent } from "react";
import clsx from "clsx";
import type { RunConfig } from "@/platform/ipc";
import { Button, Checkbox, Dialog, Icon, IconButton, Input, Select, type SelectOption, T } from "@/ui";
import { notifications } from "@/workbench/contributions";
import { executeAction } from "@/platform/actions";
import { call, commands, isTauri, type RunConfig } from "@/platform/ipc";
import { Button, Dialog, Icon, IconButton, Input, T } from "@/ui";
import {
notifications,
useRunConfigTypes,
runConfigTypes,
type RunConfigTypeContribution,
} from "@/workbench/contributions";
import { AddConfigPopup } from "./AddConfigPopup";
import { ConfigForm, Field } from "./ConfigForm";
import {
KIND_OPTIONS,
commandLine,
configIcon,
duplicateConfig,
formatArgs,
groupConfigs,
hasErrors,
newConfig,
parseArgs,
kindLabel,
removeConfig,
upsertConfig,
userConfigs,
@@ -20,42 +25,10 @@ import {
type ConfigErrors,
} from "./configs";
import { saveUserConfigs, selectConfig, useRun } from "./store";
import { blankTemplate, COMPOUND, missingParams, newConfigOfType, templateId, typeFor } from "./types";
import styles from "./Run.module.css";
/**
* Debug adapters with a real launch template (`debugger/launch.ts`, `debugger/java.ts`),
* verified against small programs. Others (netcoredbg, dart, php-debug, …) only got
* generic launch arguments and are left out until they have one; a configuration that
* already names one keeps it.
*/
const ADAPTERS: ReadonlyArray<{ id: string; label: string }> = [
{ id: "java-debug", label: "java-debug (Java)" },
{ id: "debugpy", label: "debugpy (Python)" },
{ id: "js-debug", label: "js-debug (Node.js / TypeScript)" },
{ id: "delve", label: "Delve (Go)" },
{ id: "codelldb", label: "CodeLLDB (Rust, C, C++)" },
{ id: "lldb-dap", label: "lldb-dap (C, C++, Swift)" },
];
interface EnvRow {
key: string;
value: string;
}
const envRows = (env: RunConfig["env"]): EnvRow[] => Object.entries(env).map(([key, value]) => ({ key, value }));
/** Env map from table rows; blank rows are ignored. */
export function envFromRows(rows: readonly EnvRow[]): { env: RunConfig["env"]; error?: string } {
const env: RunConfig["env"] = {};
for (const r of rows) {
const key = r.key.trim();
if (!key && !r.value) continue;
if (!key) return { env, error: "Environment variable names can't be empty" };
if (key in env) return { env, error: `Duplicate environment variable: ${key}` };
env[key] = r.value;
}
return { env };
}
export { envFromRows } from "./EnvDialog";
export function EditConfigsOverlay() {
const open = useRun((s) => s.editorOpen);
@@ -63,81 +36,236 @@ export function EditConfigsOverlay() {
return <EditConfigsDialog />;
}
type Selection =
{ type: "config"; id: string } | { type: "folder"; kind: string; name: string } | { type: "template"; kind: string };
export type TreeRow =
| { type: "group"; key: string; kind: string; label: string; icon: string }
| { type: "folder"; key: string; kind: string; name: string }
| { type: "config"; key: string; config: RunConfig; depth: 1 | 2 };
/** Sidebar rows: type groups, their folders (first), then loose configurations; collapsed nodes hide their children. */
export function treeRows(
configs: readonly RunConfig[],
extraFolders: ReadonlyArray<{ kind: string; name: string }> = [],
collapsed: ReadonlySet<string> = new Set(),
): TreeRow[] {
const rows: TreeRow[] = [];
const groups = groupConfigs(configs);
for (const f of extraFolders)
if (!groups.some((g) => g.kind === f.kind)) groups.push({ kind: f.kind, label: kindLabel(f.kind), configs: [] });
for (const g of groups) {
const groupKey = `g:${g.kind}`;
rows.push({
type: "group",
key: groupKey,
kind: g.kind,
label: g.label,
icon: configIcon({ kind: g.kind, isTest: false }),
});
if (collapsed.has(groupKey)) continue;
const folders: string[] = [];
for (const c of g.configs) if (c.folder && !folders.includes(c.folder)) folders.push(c.folder);
for (const f of extraFolders) if (f.kind === g.kind && !folders.includes(f.name)) folders.push(f.name);
for (const name of folders) {
const folderKey = `f:${g.kind}:${name}`;
rows.push({ type: "folder", key: folderKey, kind: g.kind, name });
if (collapsed.has(folderKey)) continue;
for (const c of g.configs)
if (c.folder === name) rows.push({ type: "config", key: `c:${c.id}`, config: c, depth: 2 });
}
for (const c of g.configs) if (!c.folder) rows.push({ type: "config", key: `c:${c.id}`, config: c, depth: 1 });
}
return rows;
}
/** Moves `id` before / after its neighbour among the configurations of the same type and folder. */
export function moveConfig(configs: readonly RunConfig[], id: string, delta: -1 | 1): RunConfig[] {
const current = configs.find((c) => c.id === id);
if (!current) return [...configs];
const siblings = configs.filter((c) => c.kind === current.kind && (c.folder ?? "") === (current.folder ?? ""));
const at = siblings.indexOf(current);
const other = siblings[at + delta];
if (!other) return [...configs];
const out = [...configs];
const i = out.indexOf(current);
const j = out.indexOf(other);
[out[i], out[j]] = [out[j]!, out[i]!];
return out;
}
function uniqueFolder(
kind: string,
configs: readonly RunConfig[],
extra: ReadonlyArray<{ kind: string; name: string }>,
): string {
const taken = new Set([
...configs.filter((c) => c.kind === kind).map((c) => c.folder),
...extra.filter((f) => f.kind === kind).map((f) => f.name),
]);
if (!taken.has("New Folder")) return "New Folder";
for (let i = 2; ; i++) if (!taken.has(`New Folder (${i})`)) return `New Folder (${i})`;
}
const isMac = () => typeof navigator !== "undefined" && /mac/i.test(navigator.platform);
function EditConfigsDialog() {
const initialConfigs = useRun((s) => s.configs);
const temporary = useRun((s) => s.temporary);
const target = useRun((s) => s.editTarget ?? s.selectedId);
const [draft, setDraft] = useState<RunConfig[]>(() => initialConfigs.map((c) => ({ ...c })));
const [selected, setSelected] = useState<string | null>(() =>
initialConfigs.some((c) => c.id === target) ? target : (initialConfigs[0]?.id ?? null),
);
const [argsText, setArgsText] = useState<Record<string, string>>({});
const [envText, setEnvText] = useState<Record<string, EnvRow[]>>({});
const types = useRunConfigTypes((s) => s.types);
const [draft, setDraft] = useState<RunConfig[]>(() => initialConfigs.map((c) => structuredClone(c)));
const [selection, setSelection] = useState<Selection | null>(() => {
const id = initialConfigs.some((c) => c.id === target) ? target : initialConfigs[0]?.id;
return id ? { type: "config", id } : null;
});
const [mode, setMode] = useState<"configs" | "templates">("configs");
const [templates, setTemplates] = useState<RunConfig[] | null>(() => (isTauri() ? null : []));
const [templatesDirty, setTemplatesDirty] = useState(false);
const [extraFolders, setExtraFolders] = useState<Array<{ kind: string; name: string }>>([]);
const [collapsed, setCollapsed] = useState<ReadonlySet<string>>(() => new Set());
// "Modify Run Configuration…": the would-be configuration is saved on OK even when unedited.
const [dirty, setDirty] = useState(() => useRun.getState().editDirty);
const [saving, setSaving] = useState(false);
const [adding, setAdding] = useState<{ x: number; y: number } | null>(null);
const [modifyRequest, setModifyRequest] = useState(0);
const addRef = useRef<HTMLButtonElement>(null);
const dragged = useRef<string | null>(null);
useEffect(() => {
if (!isTauri()) return;
let live = true;
call(commands.runTemplates())
.then((t) => live && setTemplates(t))
.catch(() => live && setTemplates([]));
return () => {
live = false;
};
}, []);
const close = () => useRun.setState({ editorOpen: false, editTarget: null });
const current = draft.find((c) => c.id === selected) ?? null;
const current = selection?.type === "config" ? (draft.find((c) => c.id === selection.id) ?? null) : null;
const templateFor = (kind: string): RunConfig | null => {
const t = typeFor(kind);
return templates?.find((x) => x.kind === kind) ?? (t ? blankTemplate(t) : null);
};
const currentTemplate = selection?.type === "template" ? templateFor(selection.kind) : null;
const errorsById = useMemo(() => {
const out: Record<string, ConfigErrors> = {};
for (const c of draft) {
if (c.detected) continue;
const e = validateConfig(c, draft);
const rows = envText[c.id];
const envError = rows ? envFromRows(rows).error : undefined;
if (envError) e.env = envError;
const e: ConfigErrors = { ...validateConfig(c, draft), ...missingParams(c) };
if (Object.keys(missingParams(c)).length) delete e.command;
if (hasErrors(e)) out[c.id] = e;
}
return out;
}, [draft, envText]);
}, [draft]);
const invalid = Object.keys(errorsById).length > 0;
const errors = (current && errorsById[current.id]) ?? {};
const update = (patch: Partial<RunConfig>) => {
if (!current) return;
setDraft((d) => upsertConfig(d, { ...current, ...patch }));
const replace = (next: RunConfig) => {
setDraft((d) => upsertConfig(d, next));
setDirty(true);
};
const add = () => {
const c = newConfig(draft);
const add = (type: RunConfigTypeContribution) => {
const folder = selection?.type === "folder" && selection.kind === type.id ? selection.name : null;
const c = newConfigOfType(type, draft, templateFor(type.id), folder);
setDraft((d) => [...d, c]);
setSelected(c.id);
setSelection({ type: "config", id: c.id });
setMode("configs");
setDirty(true);
};
const openAdd = (anchor?: HTMLElement | null) => {
const r = (anchor ?? addRef.current)?.getBoundingClientRect();
setAdding(r ? { x: r.left, y: r.bottom + 2 } : { x: window.innerWidth / 2 - 120, y: 120 });
};
const duplicate = () => {
if (!current) return;
const c = duplicateConfig(current, draft);
setDraft((d) => [...d, c]);
setSelected(c.id);
setSelection({ type: "config", id: c.id });
setDirty(true);
};
const remove = () => {
if (selection?.type === "folder") {
// Removing a folder keeps its configurations (they move up a level), as in IDEA.
setDraft((d) =>
d.map((c) =>
c.kind === selection.kind && c.folder === selection.name ? { ...c, folder: null, detected: false } : c,
),
);
setExtraFolders((f) => f.filter((x) => !(x.kind === selection.kind && x.name === selection.name)));
setSelection(null);
setDirty(true);
return;
}
if (!current || current.detected) return;
const index = draft.findIndex((c) => c.id === current.id);
const next = removeConfig(draft, current.id);
setDraft(next);
setSelected(next[Math.min(index, next.length - 1)]?.id ?? null);
const neighbour = next[Math.min(index, next.length - 1)];
setSelection(neighbour ? { type: "config", id: neighbour.id } : null);
setDirty(true);
};
const save = async (andClose: boolean) => {
const newFolder = () => {
const kind = current?.kind ?? (selection?.type === "folder" ? selection.kind : null);
if (!kind) return;
const name = uniqueFolder(kind, draft, extraFolders);
setExtraFolders((f) => [...f, { kind, name }]);
// IDEA moves the selected configuration into the new folder.
if (current) replace({ ...current, folder: name });
setSelection({ type: "folder", kind, name });
};
const renameFolder = (kind: string, from: string, to: string) => {
setDraft((d) =>
d.map((c) => (c.kind === kind && c.folder === from ? { ...c, folder: to || null, detected: false } : c)),
);
setExtraFolders((f) => f.map((x) => (x.kind === kind && x.name === from ? { kind, name: to } : x)));
setSelection({ type: "folder", kind, name: to });
setDirty(true);
};
const move = (delta: -1 | 1) => {
if (!current) return;
setDraft((d) => moveConfig(d, current.id, delta));
setDirty(true);
};
const updateTemplate = (next: RunConfig) => {
setTemplates((list) => [
...(list ?? []).filter((t) => t.kind !== next.kind),
{ ...next, id: templateId(next.kind), detected: false },
]);
setTemplatesDirty(true);
};
const save = async (andClose: boolean): Promise<boolean> => {
if (invalid) {
const firstBad = Object.keys(errorsById)[0];
if (firstBad) setSelected(firstBad);
return;
if (firstBad) {
setMode("configs");
setSelection({ type: "config", id: firstBad });
}
return false;
}
setSaving(true);
try {
if (dirty) await saveUserConfigs(userConfigs(draft));
if (templatesDirty && templates) await call(commands.runSaveTemplates(templates));
setDirty(false);
setTemplatesDirty(false);
if (andClose) {
if (selected) selectConfig(selected);
if (current) selectConfig(current.id);
close();
}
return true;
} catch (e) {
notifications().notify({
group: "run",
@@ -145,249 +273,354 @@ function EditConfigsDialog() {
title: "Cannot save run configurations",
body: e instanceof Error ? e.message : String(e),
});
return false;
} finally {
setSaving(false);
}
};
const groups = groupConfigs(draft);
const isOverride = current && !current.detected && !current.id.startsWith("user:");
const kindOptions: SelectOption[] = KIND_OPTIONS.map((o) => ({ value: o.value, label: o.label }));
if (current && !kindOptions.some((o) => o.value === current.kind))
kindOptions.push({ value: current.kind, label: current.kind });
const adapterOptions: SelectOption[] = [
{ value: "", label: "None" },
...ADAPTERS.map((a) => ({ value: a.id, label: a.label })),
];
if (current?.debugAdapter && !ADAPTERS.some((a) => a.id === current.debugAdapter))
adapterOptions.push({ value: current.debugAdapter, label: `${current.debugAdapter} (no launch template)` });
const rows = current ? (envText[current.id] ?? envRows(current.env)) : [];
const setRows = (next: EnvRow[]) => {
if (!current) return;
setEnvText((m) => ({ ...m, [current.id]: next }));
const { env, error } = envFromRows(next);
if (!error) update({ env });
else setDirty(true);
/** The dialog's Run / Debug: apply, close and launch the selected configuration. */
const launch = async (debug: boolean) => {
if (!current || !(await save(true))) return;
void executeAction(debug ? "run.debugConfig" : "run.runConfig", current.id);
};
const rows = treeRows(draft, extraFolders, collapsed);
const toggle = (key: string) =>
setCollapsed((s) => {
const next = new Set(s);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
const onDrop = (e: DragEvent, kind: string, folder: string | null) => {
const id = dragged.current;
dragged.current = null;
const c = draft.find((x) => x.id === id);
if (!c || c.kind !== kind) return;
e.preventDefault();
replace({ ...c, folder });
};
// IDEA: ⌘N / Alt+Insert adds a configuration, ⌥M opens "Modify options".
const onKeyDown = (e: KeyboardEvent) => {
const add =
(e.code === "KeyN" && (isMac() ? e.metaKey || e.ctrlKey : e.ctrlKey)) || (e.altKey && e.key === "Insert");
if (add && mode === "configs") {
e.preventDefault();
openAdd();
return;
}
if (e.altKey && !e.metaKey && !e.ctrlKey && e.code === "KeyM" && (current || currentTemplate)) {
e.preventDefault();
setModifyRequest((n) => n + 1);
}
};
const templateTypes = runConfigTypes(types).filter((t) => t.id !== COMPOUND);
const canRemove = selection?.type === "folder" || (!!current && !current.detected);
return (
<Dialog
open
onClose={close}
title="Run/Debug Configurations"
width={820}
width={860}
className={styles.editDialog}
footerStart={
invalid && (
<span className={styles.footerError}>
<Icon name="error" size={13} /> <T>Fix the highlighted fields</T>
</span>
)
<span className={styles.footerRun}>
<Button
icon="run"
disabled={!current || saving || invalid || mode !== "configs"}
onClick={() => void launch(false)}
>
<T>Run</T>
</Button>
<Button
icon="debug"
disabled={!current || saving || invalid || mode !== "configs"}
onClick={() => void launch(true)}
>
<T>Debug</T>
</Button>
{invalid && (
<span className={styles.footerError}>
<Icon name="error" size={13} /> <T>Fix the highlighted fields</T>
</span>
)}
</span>
}
buttons={[
{ label: "OK", role: "default", disabled: saving || invalid, onClick: () => void save(true) },
{ label: "Cancel", role: "cancel" },
{ label: "Apply", disabled: !dirty || saving || invalid, onClick: () => void save(false) },
{ label: "Apply", disabled: !(dirty || templatesDirty) || saving || invalid, onClick: () => void save(false) },
]}
>
<div className={styles.editBody}>
<div className={styles.editBody} onKeyDown={onKeyDown}>
<div className={styles.editSidebar}>
<div className={styles.editToolbar} role="toolbar" aria-label="Configurations">
<IconButton icon="plus" size="small" iconSize={14} title="Add New Configuration" onClick={add} />
<IconButton
icon="minus"
size="small"
iconSize={14}
title={isOverride ? "Remove Override (restore detected)" : "Remove Configuration"}
disabled={!current || current.detected}
onClick={remove}
/>
<IconButton
icon="copy"
size="small"
iconSize={13}
title="Copy Configuration"
disabled={!current}
onClick={duplicate}
/>
</div>
<div className={styles.editList} role="listbox" aria-label="Run configurations">
{groups.map((g) => (
<div key={g.kind} role="group" aria-label={g.label}>
<div className={styles.editGroup}>{g.label}</div>
{g.configs.map((c) => (
<button
key={c.id}
type="button"
role="option"
aria-selected={c.id === selected}
className={clsx(styles.editItem, c.id === selected && styles.editItemSelected)}
onClick={() => setSelected(c.id)}
title={commandLine(c)}
>
<Icon name={configIcon(c)} size={13} className={styles.chooserIcon} />
<span className={styles.chooserName}>{c.name || "Unnamed"}</span>
{errorsById[c.id] && <Icon name="error" size={12} className={styles.failed} label="Invalid" />}
{!c.detected && <span className={styles.chooserMuted}>user</span>}
</button>
))}
</div>
))}
{draft.length === 0 && (
<div className={styles.testEmpty}>
<T>No configurations. Click + to add one.</T>
</div>
)}
</div>
</div>
<div className={styles.editForm}>
{current ? (
{mode === "configs" ? (
<>
{current.detected && (
<div className={styles.notice}>
<T>Detected from the project. Changes are saved as a user configuration that overrides it.</T>
</div>
)}
<Field label="Name" error={errors.name}>
<Input
value={current.name}
invalid={!!errors.name}
aria-label="Name"
onChange={(e) => update({ name: e.target.value })}
<div className={styles.editToolbar} role="toolbar" aria-label="Configurations">
<IconButton
ref={addRef}
icon="plus"
size="small"
iconSize={14}
title="Add New Configuration"
onClick={() => openAdd()}
/>
</Field>
<Field label="Kind">
<Select
value={current.kind}
options={kindOptions}
onChange={(kind) => update({ kind })}
aria-label="Kind"
<IconButton
icon="minus"
size="small"
iconSize={14}
title={
selection?.type === "folder"
? "Remove Folder"
: current && !current.detected && !current.id.startsWith("user:")
? "Remove Override (restore detected)"
: "Remove Configuration"
}
disabled={!canRemove}
onClick={remove}
/>
</Field>
<Field label="Command" error={errors.command}>
<Input
mono
value={current.command}
invalid={!!errors.command}
aria-label="Command"
placeholder="e.g. ./gradlew or cargo"
onChange={(e) => update({ command: e.target.value })}
<IconButton
icon="copy"
size="small"
iconSize={13}
title="Copy Configuration"
disabled={!current}
onClick={duplicate}
/>
</Field>
<Field label="Arguments">
<Input
mono
aria-label="Arguments"
value={argsText[current.id] ?? formatArgs(current.args)}
placeholder="Space separated; quote with ' or &quot;"
onChange={(e) => {
const text = e.target.value;
setArgsText((m) => ({ ...m, [current.id]: text }));
update({ args: parseArgs(text) });
}}
<IconButton
icon="folder"
size="small"
iconSize={13}
title="Create New Folder"
disabled={!current && selection?.type !== "folder"}
onClick={newFolder}
/>
</Field>
<Field label="Working directory" error={errors.cwd}>
<Input
mono
aria-label="Working directory"
value={current.cwd ?? ""}
placeholder="Project root"
onChange={(e) => update({ cwd: e.target.value.trim() ? e.target.value : null })}
<IconButton
icon="arrow-up"
size="small"
iconSize={13}
title="Move Up"
disabled={!current}
onClick={() => move(-1)}
/>
</Field>
<Field label="Environment" error={errors.env} top>
<div className={styles.envTable} role="table" aria-label="Environment variables">
{rows.map((r, i) => (
<div key={i} className={styles.envRow} role="row">
<Input
mono
aria-label={`Variable ${i + 1} name`}
value={r.key}
placeholder="NAME"
onChange={(e) => setRows(rows.map((x, j) => (j === i ? { ...x, key: e.target.value } : x)))}
/>
<Input
mono
aria-label={`Variable ${i + 1} value`}
value={r.value}
placeholder="value"
onChange={(e) => setRows(rows.map((x, j) => (j === i ? { ...x, value: e.target.value } : x)))}
/>
<IconButton
icon="minus"
size="small"
iconSize={12}
title="Remove variable"
onClick={() => setRows(rows.filter((_, j) => j !== i))}
/>
</div>
))}
<Button
size="small"
variant="ghost"
icon="plus"
onClick={() => setRows([...rows, { key: "", value: "" }])}
>
<T>Add variable</T>
</Button>
</div>
</Field>
<Field label="">
<Checkbox
checked={current.isTest}
onChange={(isTest) => update({ isTest })}
label="Show results in the test runner"
<IconButton
icon="arrow-down"
size="small"
iconSize={13}
title="Move Down"
disabled={!current}
onClick={() => move(1)}
/>
</Field>
<Field label="">
<Checkbox
checked={!!current.allowParallel}
onChange={(allowParallel) => update({ allowParallel })}
label="Allow multiple instances"
/>
</Field>
<Field label="Debug adapter">
<Select
value={current.debugAdapter ?? ""}
options={adapterOptions}
onChange={(v) => update({ debugAdapter: v || null })}
aria-label="Debug adapter"
/>
</Field>
<div className={styles.editPreview}>
<span className={styles.chooserMuted}>
<T>Command line</T>
</span>
<code>{commandLine(current) || "—"}</code>
</div>
<div className={styles.editList} role="tree" aria-label="Run configurations">
{rows.map((r) => {
if (r.type === "group")
return (
<button
key={r.key}
type="button"
role="treeitem"
aria-expanded={!collapsed.has(r.key)}
className={styles.editItem}
style={{ paddingLeft: 6 }}
onClick={() => toggle(r.key)}
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => onDrop(e, r.kind, null)}
>
<Icon name={collapsed.has(r.key) ? "chevron-right" : "chevron-down"} size={12} />
<Icon name={r.icon} size={13} className={styles.chooserIcon} />
<span className={clsx(styles.chooserName, styles.editGroupName)}>{r.label}</span>
</button>
);
if (r.type === "folder") {
const sel = selection?.type === "folder" && selection.kind === r.kind && selection.name === r.name;
return (
<button
key={r.key}
type="button"
role="treeitem"
aria-selected={sel}
aria-expanded={!collapsed.has(r.key)}
className={clsx(styles.editItem, sel && styles.editItemSelected)}
style={{ paddingLeft: 22 }}
onClick={() => setSelection({ type: "folder", kind: r.kind, name: r.name })}
onDoubleClick={() => toggle(r.key)}
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => onDrop(e, r.kind, r.name)}
>
<Icon name={collapsed.has(r.key) ? "chevron-right" : "chevron-down"} size={12} />
<Icon name="folder" size={13} className={styles.chooserIcon} />
<span className={styles.chooserName}>{r.name}</span>
</button>
);
}
const c = r.config;
return (
<button
key={r.key}
type="button"
role="treeitem"
aria-selected={current?.id === c.id}
className={clsx(styles.editItem, current?.id === c.id && styles.editItemSelected)}
style={{ paddingLeft: r.depth === 2 ? 50 : 34 }}
onClick={() => setSelection({ type: "config", id: c.id })}
title={commandLine(c)}
draggable
onDragStart={(e) => {
dragged.current = c.id;
e.dataTransfer.effectAllowed = "move";
}}
>
<Icon
name={configIcon(c)}
size={13}
className={clsx(styles.chooserIcon, temporary.includes(c.id) && styles.chooserTemporary)}
/>
<span className={styles.chooserName}>{c.name || <T>Unnamed</T>}</span>
{errorsById[c.id] && <Icon name="error" size={12} className={styles.failed} label="Invalid" />}
{!c.detected && <span className={styles.chooserMuted}>{c.local ? "local" : "user"}</span>}
</button>
);
})}
{draft.length === 0 && (
<div className={styles.editEmpty}>
<T>No run configurations added.</T>
<span>
<button type="button" className={styles.noticeLink} onClick={(e) => openAdd(e.currentTarget)}>
<T>Add new…</T>
</button>{" "}
<span className={styles.chooserMuted}>{isMac() ? "⌘N" : "Alt+Insert"}</span>
</span>
</div>
)}
</div>
<button
type="button"
className={clsx(styles.noticeLink, styles.editFooterLink)}
onClick={() => {
setMode("templates");
const first = templateTypes[0];
setSelection(first ? { type: "template", kind: first.id } : null);
}}
>
<T>Edit configuration templates…</T>
</button>
</>
) : (
<div className={styles.testEmpty}>
<T>Select a configuration</T>
<>
<div className={styles.editToolbar}>
<span className={styles.formSectionTitle}>
<T>Templates</T>
</span>
</div>
<div className={styles.editList} role="listbox" aria-label="Configuration templates">
{templateTypes.map((t) => {
const sel = selection?.type === "template" && selection.kind === t.id;
return (
<button
key={t.id}
type="button"
role="option"
aria-selected={sel}
className={clsx(styles.editItem, sel && styles.editItemSelected)}
onClick={() => setSelection({ type: "template", kind: t.id })}
>
<Icon name={t.icon} size={13} className={styles.chooserIcon} />
<span className={styles.chooserName}>{t.label}</span>
</button>
);
})}
</div>
<button
type="button"
className={clsx(styles.noticeLink, styles.editFooterLink)}
onClick={() => {
setMode("configs");
setSelection(draft[0] ? { type: "config", id: draft[0].id } : null);
}}
>
<T>Back to configurations</T>
</button>
</>
)}
</div>
<div className={styles.editForm}>
{mode === "templates" ? (
currentTemplate ? (
<>
<div className={styles.notice}>
<T>New configurations of this type start with these values.</T>
</div>
<ConfigForm
key={`template:${currentTemplate.kind}`}
config={currentTemplate}
all={draft}
type={typeFor(currentTemplate.kind)}
errors={{}}
template
modifyRequest={modifyRequest}
onChange={updateTemplate}
/>
</>
) : (
<div className={styles.editEmpty}>
<T>Select a configuration type</T>
</div>
)
) : current ? (
<ConfigForm
key={current.id}
config={current}
all={draft}
type={typeFor(current.kind)}
errors={errors}
modifyRequest={modifyRequest}
onChange={replace}
/>
) : selection?.type === "folder" ? (
<FolderName
key={`${selection.kind}:${selection.name}`}
name={selection.name}
onRename={(to) => renameFolder(selection.kind, selection.name, to)}
/>
) : (
<div className={styles.editEmpty}>
<button type="button" className={styles.noticeLink} onClick={(e) => openAdd(e.currentTarget)}>
<T>Add new run configuration…</T>
</button>
<T>or select run configuration to edit</T>
</div>
)}
</div>
</div>
{adding && <AddConfigPopup anchor={adding} onPick={add} onClose={() => setAdding(null)} />}
</Dialog>
);
}
function Field({ label, error, top, children }: { label: string; error?: string; top?: boolean; children: ReactNode }) {
/** Renames on every edit that leaves a name (an emptied field keeps the old one). */
function FolderName({ name, onRename }: { name: string; onRename: (to: string) => void }) {
const [text, setText] = useState(name);
return (
<div className={clsx(styles.field, top && styles.fieldTop)}>
<span className={styles.fieldLabel}>{label}</span>
<div className={styles.fieldControl}>
{children}
{error && (
<span className={styles.fieldError} role="alert">
{error}
</span>
)}
</div>
</div>
<Field label="Folder name" error={text.trim() ? undefined : "Name is required"}>
<Input
aria-label="Folder name"
value={text}
autoFocus
onChange={(e) => setText(e.target.value)}
onBlur={() => text.trim() && text.trim() !== name && onRename(text.trim())}
onKeyDown={(e) => {
if (e.key === "Enter" && text.trim() && text.trim() !== name) {
e.preventDefault();
onRename(text.trim());
}
}}
/>
</Field>
);
}
+227
Переглянути файл
@@ -0,0 +1,227 @@
import { useEffect, useState } from "react";
import clsx from "clsx";
import { commands, isTauri, type RunConfig } from "@/platform/ipc";
import { useTx, useTxTemplate } from "@/platform/i18n";
import { Checkbox, Dialog, IconButton, Input, T } from "@/ui";
import { parseEnv } from "./configs";
import styles from "./Run.module.css";
export interface EnvRow {
key: string;
value: string;
}
export const envRows = (env: RunConfig["env"]): EnvRow[] => Object.entries(env).map(([key, value]) => ({ key, value }));
/** Env map from table rows; blank rows are ignored. */
export function envFromRows(rows: readonly EnvRow[]): { env: RunConfig["env"]; error?: string } {
const env: RunConfig["env"] = {};
for (const r of rows) {
const key = r.key.trim();
if (!key && !r.value) continue;
if (!key) return { env, error: "Environment variable names can't be empty" };
if (key in env) return { env, error: `Duplicate environment variable: ${key}` };
env[key] = r.value;
}
return { env };
}
/** Rows as `NAME=value` lines (what Copy puts on the clipboard). */
export const rowsText = (rows: readonly EnvRow[]) => rows.map((r) => `${r.key}=${r.value}`).join("\n");
/** Pasted variables replace rows with the same name and are appended otherwise. */
export function pasteRows(rows: readonly EnvRow[], text: string): EnvRow[] {
const out = rows.filter((r) => r.key || r.value).map((r) => ({ ...r }));
for (const p of parseEnv(text)) {
const existing = out.find((r) => r.key === p.key);
if (existing) existing.value = p.value;
else out.push(p);
}
return out;
}
async function writeClipboard(text: string) {
try {
await navigator.clipboard.writeText(text);
} catch {
/* clipboard unavailable */
}
}
/** IDEA's Environment Variables dialog: user variables, and the inherited system ones (read-only). */
export function EnvDialog({
env,
includeSystemEnv,
onOk,
onClose,
}: {
env: RunConfig["env"];
includeSystemEnv: boolean;
onOk: (env: RunConfig["env"], includeSystemEnv: boolean) => void;
onClose: () => void;
}) {
const [rows, setRows] = useState<EnvRow[]>(() => envRows(env));
const [selected, setSelected] = useState<number | null>(null);
const [include, setInclude] = useState(includeSystemEnv);
const [system, setSystem] = useState<Array<[string, string]> | null>(() => (isTauri() ? null : []));
const { error } = envFromRows(rows);
const tx = useTx();
const txTemplate = useTxTemplate();
useEffect(() => {
if (!isTauri()) return;
let live = true;
commands
.runSystemEnv()
.then((vars) => live && setSystem(Object.entries(vars).sort(([a], [b]) => a.localeCompare(b))))
.catch(() => live && setSystem([]));
return () => {
live = false;
};
}, []);
const set = (i: number, patch: Partial<EnvRow>) =>
setRows((r) => r.map((x, j) => (j === i ? { ...x, ...patch } : x)));
const add = () => {
setRows((r) => [...r, { key: "", value: "" }]);
setSelected(rows.length);
};
const remove = () => {
if (selected === null) return;
setRows((r) => r.filter((_, j) => j !== selected));
setSelected(null);
};
const paste = async () => {
try {
const text = await navigator.clipboard.readText();
setRows((r) => pasteRows(r, text));
} catch {
/* clipboard unavailable */
}
};
const overridden = new Set(rows.map((r) => r.key));
return (
<Dialog
open
title="Environment Variables"
onClose={onClose}
width={560}
footerStart={error && <span className={styles.footerError}>{txTemplate(error)}</span>}
buttons={[
{
label: "OK",
role: "default",
disabled: !!error,
onClick: () => {
onOk(envFromRows(rows).env, include);
onClose();
},
},
{ label: "Cancel", role: "cancel" },
]}
>
<div className={styles.envDialog}>
<span className={styles.fieldLabel}>
<T>User environment variables:</T>
</span>
<div className={styles.envPanel}>
<div className={styles.editToolbar} role="toolbar" aria-label={tx("User environment variables")}>
<IconButton icon="plus" size="small" iconSize={14} title="Add" onClick={add} />
<IconButton
icon="minus"
size="small"
iconSize={14}
title="Remove"
disabled={selected === null}
onClick={remove}
/>
<IconButton
icon="copy"
size="small"
iconSize={13}
title="Copy"
disabled={!rows.length}
onClick={() =>
void writeClipboard(rowsText(selected === null ? rows : rows.slice(selected, selected + 1)))
}
/>
<IconButton icon="paste" size="small" iconSize={13} title="Paste" onClick={() => void paste()} />
</div>
<div className={styles.envGrid} role="table" aria-label={tx("User environment variables")}>
<div className={styles.envHeader} role="row">
<span role="columnheader">
<T>Name</T>
</span>
<span role="columnheader">
<T>Value</T>
</span>
</div>
{rows.map((r, i) => (
<div
key={i}
role="row"
className={clsx(styles.envGridRow, selected === i && styles.editItemSelected)}
onFocus={() => setSelected(i)}
>
<Input
mono
variant="bare"
aria-label={txTemplate(`Variable ${i + 1} name`)}
value={r.key}
onChange={(e) => set(i, { key: e.target.value })}
/>
<Input
mono
variant="bare"
aria-label={txTemplate(`Variable ${i + 1} value`)}
value={r.value}
onChange={(e) => set(i, { value: e.target.value })}
/>
</div>
))}
</div>
</div>
<Checkbox checked={include} onChange={setInclude} label="Include system environment variables" />
<div className={clsx(styles.envPanel, !include && styles.envDisabled)}>
<div className={styles.editToolbar} role="toolbar" aria-label={tx("System environment variables")}>
<IconButton
icon="copy"
size="small"
iconSize={13}
title="Copy"
disabled={!system?.length}
onClick={() => void writeClipboard((system ?? []).map(([k, v]) => `${k}=${v}`).join("\n"))}
/>
</div>
<div className={styles.envGrid} role="table" aria-label={tx("System environment variables")}>
<div className={styles.envHeader} role="row">
<span role="columnheader">
<T>Name</T>
</span>
<span role="columnheader">
<T>Value</T>
</span>
</div>
{system === null && (
<div className={styles.chooserMuted}>
<T>Loading…</T>
</div>
)}
{system?.map(([k, v]) => (
<div
key={k}
role="row"
className={clsx(styles.envGridRow, styles.envSystemRow, overridden.has(k) && styles.envOverridden)}
title={`${k}=${v}`}
>
<span className={styles.envCell}>{k}</span>
<span className={styles.envCell}>{v}</span>
</div>
))}
</div>
</div>
</div>
</Dialog>
);
}
+182 -9
Переглянути файл
@@ -493,18 +493,182 @@
color: var(--danger);
font-size: var(--fs-small);
}
.envTable {
.editGroupName {
color: var(--fg-strong);
}
.editEmpty {
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
align-items: flex-start;
}
.envRow {
display: grid;
grid-template-columns: 2fr 3fr auto;
gap: 4px;
width: 100%;
align-items: center;
justify-content: center;
gap: 6px;
padding: 24px 12px;
color: var(--fg-subtle);
text-align: center;
}
.editFooterLink {
flex: none;
align-self: flex-start;
margin: 8px 12px;
}
.footerRun {
display: inline-flex;
align-items: center;
gap: 8px;
}
.formHeader {
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
gap: 16px;
padding-bottom: 10px;
border-bottom: 1px solid var(--border-popup);
}
.formSection {
display: flex;
align-items: center;
gap: 8px;
}
.formSectionTitle {
flex: 1;
font-weight: 600;
color: var(--fg-strong);
}
.formSection .noticeLink {
display: inline-flex;
align-items: center;
gap: 2px;
}
.inputWithButton {
display: flex;
align-items: center;
gap: 4px;
}
.inputWithButton > :first-child {
flex: 1;
}
.textarea {
min-height: 72px;
padding: 6px 8px;
resize: vertical;
border: 0;
border-radius: var(--radius-control, 4px);
background: var(--bg-field, var(--bg-button));
box-shadow: inset 0 0 0 1px var(--border-button);
color: var(--fg-strong);
font-family: var(--font-mono);
font-size: var(--fs-ui);
outline: none;
}
.textarea:focus {
box-shadow: inset 0 0 0 1px var(--focus-ring);
}
.textareaInvalid {
box-shadow: inset 0 0 0 1px var(--danger);
}
.taskList {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 4px;
}
.taskList > button:last-of-type {
align-self: flex-start;
}
.taskRow {
display: flex;
align-items: center;
gap: 4px;
min-height: var(--h-row);
}
.taskRow > :first-child {
flex: 1;
min-width: 0;
}
.taskLabel {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.chip {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 4px 2px 10px;
border-radius: 999px;
background: var(--bg-elev);
color: var(--fg);
font-size: var(--fs-small);
}
.typeNested {
padding-left: 16px;
}
.emptyHints {
text-align: left;
color: var(--fg-subtle);
}
.emptyHints ul {
margin: 4px 0 0;
padding: 0;
list-style: none;
}
.emptyHints li::before {
content: "— ";
}
.envDialog {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px 16px;
}
.envPanel {
display: flex;
flex-direction: column;
border: 1px solid var(--border-popup);
border-radius: var(--radius-small);
}
.envDisabled {
opacity: 0.55;
}
.envGrid {
height: 140px;
overflow: auto;
}
.envHeader,
.envGridRow {
display: grid;
grid-template-columns: 2fr 3fr;
gap: 4px;
align-items: center;
padding: 0 8px;
min-height: var(--h-row);
}
.envHeader {
position: sticky;
top: 0;
background: var(--bg-popup, var(--bg));
color: var(--fg-subtle);
font-size: var(--fs-small);
border-bottom: 1px solid var(--border-popup);
}
.envSystemRow {
color: var(--fg-subtle);
}
.envOverridden {
text-decoration: line-through;
}
.envCell {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--font-mono);
font-size: var(--fs-small);
}
.editPreview {
display: flex;
@@ -610,3 +774,12 @@
background: transparent;
}
}
/* "Emulate terminal in output console": the terminal widget fills the console. */
.terminalConsole {
flex: 1;
min-width: 0;
min-height: 0;
padding: 4px 0 0 8px;
background: var(--bg-island);
}
+31
Переглянути файл
@@ -0,0 +1,31 @@
/* Run configurations in the Services tool window */
.detail {
height: 100%;
overflow: auto;
}
.facts {
display: grid;
grid-template-columns: max-content 1fr;
gap: 4px 12px;
padding: 12px;
margin: 0;
}
.facts dt {
color: var(--fg-secondary);
}
.facts dd {
margin: 0;
min-width: 0;
overflow-wrap: anywhere;
-webkit-user-select: text;
user-select: text;
}
.mono {
font-family: var(--font-mono);
font-size: var(--fs-code);
}
.actions {
display: flex;
gap: 6px;
padding: 0 12px 12px;
}
+66 -30
Переглянути файл
@@ -5,6 +5,7 @@ import { executeAction } from "@/platform/actions";
import { shortcutLabel } from "@/platform/keymap";
import { ConsoleModel } from "./console";
import { ConsoleView } from "./ConsoleView";
import { TerminalConsole } from "./TerminalConsole";
import { runDirectory } from "./navigate";
import {
acceptsInput,
@@ -14,12 +15,14 @@ import {
failedTests,
rerunFailed,
rerunSession,
resizeTerminal,
runSelectedTests,
runtime,
sendStdin,
setPendingInput,
stopSession,
useRun,
writeTerminal,
type RunSession,
} from "./store";
import { TestTreeView, formatDuration } from "./TestTreeView";
@@ -72,26 +75,36 @@ function SessionPanel({ session }: { session: RunSession }) {
</button>
</div>
)}
<ConsoleView
model={testModel ?? rt.console}
version={version}
softWrap={softWrap}
follow={testModel ? false : follow}
onFollowChange={setFollow}
cwd={cwd}
onClear={() => clearConsole(session.id)}
input={
!testModel && acceptsInput(session)
? {
pending: rt.input,
onChange: (text) => setPendingInput(session.id, text),
onSubmit: (line) => void sendStdin(session.id, line),
onEof: () => void closeStdin(session.id),
}
: undefined
}
aria-label={`${session.config.name} output`}
/>
{rt.terminal && !testModel ? (
<TerminalConsole
output={rt.terminal}
cwd={cwd}
onData={(data) => writeTerminal(session.id, data)}
onResize={(cols, rows) => resizeTerminal(session.id, cols, rows)}
aria-label={`${session.config.name} terminal`}
/>
) : (
<ConsoleView
model={testModel ?? rt.console}
version={version}
softWrap={softWrap}
follow={testModel ? false : follow}
onFollowChange={setFollow}
cwd={cwd}
onClear={() => clearConsole(session.id)}
input={
!testModel && acceptsInput(session)
? {
pending: rt.input,
onChange: (text) => setPendingInput(session.id, text),
onSubmit: (line) => void sendStdin(session.id, line),
onEof: () => void closeStdin(session.id),
}
: undefined
}
aria-label={`${session.config.name} output`}
/>
)}
</div>
);
@@ -125,14 +138,18 @@ function SessionPanel({ session }: { session: RunSession }) {
onClick={() => void stopSession(session.id)}
/>
<div className={styles.toolbarSep} />
<IconButton
icon="arrow-down"
iconSize={14}
title="Scroll to End"
pressed={follow}
onClick={() => setFollow((v) => !v)}
/>
<IconButton icon="soft-wrap" iconSize={14} title="Soft-Wrap" pressed={softWrap} onClick={toggleWrap} />
{!rt.terminal && (
<>
<IconButton
icon="arrow-down"
iconSize={14}
title="Scroll to End"
pressed={follow}
onClick={() => setFollow((v) => !v)}
/>
<IconButton icon="soft-wrap" iconSize={14} title="Soft-Wrap" pressed={softWrap} onClick={toggleWrap} />
</>
)}
<IconButton icon="trash" iconSize={14} title="Clear All" onClick={() => clearConsole(session.id)} />
<div className={styles.toolbarSep} />
<IconButton
@@ -230,9 +247,28 @@ export function RunView() {
const current = currentTab(sessions, traces, active);
if (!current) {
// IDEA's empty Run tool window: the three ways to run something.
const chooser = shortcutLabel("run.chooseConfig");
return (
<EmptyState icon="run" title="Nothing is running">
<T>Run a configuration with</T> <kbd className={styles.kbd}>⌃R</kbd> <T>or pick one in the toolbar.</T>
<EmptyState>
<div className={styles.emptyHints}>
<T>To run your code, do one of the following:</T>
<ul>
<li>
<T>Click the Run icon in the editor gutter</T>
</li>
<li>
<T>{'Select "Run…" in the editor context menu'}</T>
</li>
<li>
<T>Launch a</T>{" "}
<button type="button" className={styles.noticeLink} onClick={() => void executeAction("run.editConfigs")}>
<T>run configuration</T>
</button>
{chooser && <span className={styles.chooserMuted}> ({chooser})</span>}
</li>
</ul>
</div>
</EmptyState>
);
}
+27 -16
Переглянути файл
@@ -7,7 +7,7 @@ import { useTx } from "@/platform/i18n";
import { shortcutLabel } from "@/platform/keymap";
import { runExecutors, useRunExecutors } from "@/workbench/contributions";
import { configIcon } from "./configs";
import { rerunSession, stopSession, useRun } from "./store";
import { CURRENT_FILE, rerunSession, sessionsOf, startRun, stopSession, useRun } from "./store";
import styles from "./Run.module.css";
/** Title bar: configuration chooser, Run / Debug, and Rerun / Stop while running. */
@@ -18,8 +18,11 @@ export function RunWidget() {
const sessions = useRun((s) => s.sessions);
const chooserOpen = useRun((s) => s.chooserOpen);
const selected = configs.find((c) => c.id === selectedId);
const currentFile = selectedId === CURRENT_FILE;
const running = sessions.filter((s) => s.status === "running" || s.status === "starting");
const runningSelected = running.find((s) => s.config.id === selectedId) ?? null;
// A compound is running while any of its configurations is; Stop stops them all.
const compoundRunning = selected?.kind === "compound" ? sessionsOf(selected, running) : [];
const runningSelected = compoundRunning[0] ?? running.find((s) => s.config.id === selectedId) ?? null;
const target = runningSelected ?? running[running.length - 1] ?? null;
// Debug sessions belong to the debugger feature; like IDEA the widget offers their Rerun / Stop.
const debugging = useContextKeys((c) => !!c.debugSessionActive);
@@ -55,11 +58,7 @@ export function RunWidget() {
aria-haspopup="listbox"
aria-expanded={chooserOpen}
data-run-chooser=""
onClick={() =>
void (selected || configs.length
? executeAction("run.chooseConfig", "select")
: executeAction("run.editConfigs"))
}
onClick={() => void executeAction("run.chooseConfig", "select")}
>
{selected && (
// IDEA: the configuration's type icon, with a status dot on its top-right corner while active.
@@ -71,7 +70,7 @@ export function RunWidget() {
</span>
)}
<span className={styles.widgetName}>
{selected?.name ?? tx(configs.length ? "Select Configuration" : "Add Configuration…")}
{selected?.name ?? tx(currentFile ? "Current File" : "Select Configuration")}
</span>
<Icon name="chevron-down" size={14} className={styles.widgetChevron} />
</BarButton>
@@ -83,18 +82,30 @@ export function RunWidget() {
size="large"
iconSize={16}
className={clsx(styles.widgetButton, styles.widgetFilled, styles.widgetFilledRun)}
title={`Rerun '${target.config.name}'`}
title={`Rerun '${compoundRunning.length && selected ? selected.name : target.config.name}'`}
shortcut={shortcutLabel("run.run")}
onClick={() => rerunSession(target.id)}
onClick={() => (compoundRunning.length && selected ? void startRun(selected) : rerunSession(target.id))}
/>
<IconButton
icon="stop-filled"
size="large"
iconSize={16}
className={clsx(styles.widgetButton, styles.widgetFilled, styles.widgetFilledStop)}
title={running.length > 1 ? `Stop (${running.length} running)` : `Stop '${target.config.name}'`}
title={
compoundRunning.length && selected
? `Stop '${selected.name}'`
: running.length > 1
? `Stop (${running.length} running)`
: `Stop '${target.config.name}'`
}
shortcut={shortcutLabel("run.stop")}
onClick={() => (running.length > 1 ? void executeAction("run.stop") : void stopSession(target.id))}
onClick={() =>
compoundRunning.length
? compoundRunning.forEach((s) => void stopSession(s.id))
: running.length > 1
? void executeAction("run.stop")
: void stopSession(target.id)
}
/>
</>
) : debugging ? (
@@ -138,9 +149,9 @@ export function RunWidget() {
size="large"
iconSize={18}
className={clsx(styles.widgetButton, styles.widgetRun)}
title={selected ? `Run '${selected.name}'` : "Run"}
title={selected ? `Run '${selected.name}'` : currentFile ? "Run Current File" : "Run"}
shortcut={shortcutLabel("run.run")}
disabled={!selected}
disabled={!selected && !currentFile}
onClick={() => void executeAction("run.run")}
/>
<IconButton
@@ -148,9 +159,9 @@ export function RunWidget() {
size="large"
iconSize={18}
className={styles.widgetButton}
title={selected ? `Debug '${selected.name}'` : "Debug"}
title={selected ? `Debug '${selected.name}'` : currentFile ? "Debug Current File" : "Debug"}
shortcut={shortcutLabel("run.debug")}
disabled={!selected}
disabled={!selected && !currentFile}
onClick={() => void executeAction("run.debug")}
/>
</>
+66
Переглянути файл
@@ -0,0 +1,66 @@
/**
* The Run console of a configuration with "Emulate terminal in output console":
* the terminal widget (provided by the terminal feature) shows the raw output
* and sends keystrokes and its size to the process.
*/
import { useEffect, useRef } from "react";
import { T } from "@/ui";
import { embeddedTerminal } from "@/workbench/contributions";
import type { TerminalOutput } from "./terminalOutput";
import styles from "./Run.module.css";
export function TerminalConsole({
output,
cwd,
onData,
onResize,
"aria-label": ariaLabel,
}: {
output: TerminalOutput;
cwd: string;
onData: (data: string) => void;
onResize: (cols: number, rows: number) => void;
"aria-label": string;
}) {
const host = useRef<HTMLDivElement>(null);
const handlers = useRef({ onData, onResize });
useEffect(() => {
handlers.current = { onData, onResize };
});
const factory = embeddedTerminal();
useEffect(() => {
const el = host.current;
if (!el || !factory) return;
let disposed = false;
let cleanup = () => {};
void factory(el, {
cwd,
"aria-label": ariaLabel,
onData: (d) => handlers.current.onData(d),
onResize: (c, r) => handlers.current.onResize(c, r),
}).then((term) => {
if (disposed) return term.dispose();
const detach = output.attach(
(d) => term.write(d),
() => term.clear(),
);
cleanup = () => {
detach();
term.dispose();
};
});
return () => {
disposed = true;
cleanup();
};
}, [factory, output, cwd, ariaLabel]);
if (!factory)
return (
<div className={styles.notice}>
<T>The terminal is not available; turn off "Emulate terminal in output console" to see the output.</T>
</div>
);
return <div ref={host} className={styles.terminalConsole} />;
}
+126
Переглянути файл
@@ -0,0 +1,126 @@
/**
* Before launch tasks (IDEA's "Before launch" list): run in order before a
* configuration starts; the first failure aborts the launch.
*/
import { executeAction, getAction } from "@/platform/actions";
import { getContext } from "@/platform/context";
import { tx, txTemplate } from "@/platform/i18n";
import type { BeforeLaunchTask, RunConfig } from "@/platform/ipc";
import { workflowSettings } from "@/platform/workflowSettings";
import { notifications, toolWindowService, useContributions } from "@/workbench/contributions";
import { externalToolConfig } from "./externalTools";
import { startRun, useRun } from "./store";
export const BUILD_ACTION = "build.buildProject";
const BUILD_STATUS = "build.lastStatus";
export type BeforeLaunchKind = "run" | "build" | "tool" | "command" | "toolWindow";
export const TASK_LABELS: Record<BeforeLaunchKind, string> = {
run: "Run Another Configuration",
build: "Build Project",
tool: "Run External Tool",
command: "Run Shell Command",
toolWindow: "Activate Tool Window",
};
/** One line for the task list, e.g. `Run 'migrate'`. */
export function taskLabel(task: BeforeLaunchTask, configs: readonly RunConfig[] = useRun.getState().configs): string {
const target = task.target ?? "";
switch (task.kind) {
case "run":
return `Run '${configs.find((c) => c.id === target)?.name ?? target}'`;
case "build":
return "Build Project";
case "tool":
return `External tool '${workflowSettings().externalTools.find((t) => t.id === target)?.name ?? target}'`;
case "command":
return target ? `Run '${target}'` : "Run shell command";
case "toolWindow":
return `Activate '${useContributions.getState().toolWindows.get(target)?.title ?? target}' tool window`;
default:
return task.kind;
}
}
/** Runs `config` to completion: true when it exited with code 0. */
async function runToEnd(config: RunConfig, chain: readonly string[]): Promise<boolean> {
let done!: (ok: boolean) => void;
const exited = new Promise<boolean>((resolve) => (done = resolve));
const session = await startRun(config, undefined, undefined, {
chain,
onExit: (code, stopped) => done(code === 0 && !stopped),
});
if (!session || session.status === "failed") return false;
return exited;
}
async function runTask(task: BeforeLaunchTask, config: RunConfig, chain: readonly string[]): Promise<string | null> {
const target = task.target ?? "";
switch (task.kind) {
case "build": {
if (!getAction(BUILD_ACTION)) return "Building is not available";
if (!(await executeAction(BUILD_ACTION))) return "A build is already running";
return getContext()[BUILD_STATUS] === "success" ? null : "Build failed";
}
case "run": {
if (chain.includes(target)) return "The before launch tasks run in a cycle";
const other = useRun.getState().configs.find((c) => c.id === target);
if (!other) return `Configuration '${target}' no longer exists`;
return (await runToEnd(other, chain)) ? null : `'${other.name}' failed`;
}
case "tool": {
const tool = workflowSettings().externalTools.find((t) => t.id === target);
if (!tool) return `External tool '${target}' no longer exists`;
const run = await externalToolConfig(tool);
if (!run) return `'${tool.name}' was not run`;
return (await runToEnd({ ...run, id: `${run.id}:before:${config.id}` }, chain)) ? null : `'${tool.name}' failed`;
}
case "command": {
if (!target.trim()) return "No command";
const run: RunConfig = {
id: `before-launch:${config.id}:${target}`,
name: target,
kind: "shell",
command: target,
args: [],
cwd: config.cwd,
env: {},
detected: true,
isTest: false,
debugAdapter: null,
};
return (await runToEnd(run, chain)) ? null : `'${target}' failed`;
}
case "toolWindow": {
if (!useContributions.getState().toolWindows.has(target)) return `Tool window '${target}' is not available`;
toolWindowService().show(target, true);
return null;
}
default:
return `Unknown before launch task '${task.kind}'`;
}
}
/** The installed runner: every task in order; a failure is reported and aborts. */
export async function runBeforeLaunch(config: RunConfig, chain: readonly string[]): Promise<boolean> {
for (const task of config.beforeLaunch ?? []) {
let error: string | null;
try {
error = await runTask(task, config, chain);
} catch (e) {
error = e instanceof Error ? e.message : String(e);
}
if (error) {
notifications().notify({
group: "run",
level: "error",
title: `'${config.name}' was not started`,
body: `${tx("Before launch")}: ${txTemplate(taskLabel(task))}: ${txTemplate(error)}`,
key: `run-before-launch-${config.id}`,
});
return false;
}
}
return true;
}
+1 -1
Переглянути файл
@@ -14,7 +14,7 @@ import {
validateConfig,
} from "./configs";
import { contextConfig, fileRole } from "./context";
import { envFromRows } from "./EditConfigsDialog";
import { envFromRows } from "./EnvDialog";
import { defaultSelection } from "./store";
const cfg = (over: Partial<RunConfig> & { id: string }): RunConfig => ({
+107 -8
Переглянути файл
@@ -1,5 +1,6 @@
/** Run configuration helpers: grouping, overriding, validation and argument parsing. */
import type { RunConfig } from "@/platform/ipc";
import { useRunConfigTypes } from "@/workbench/contributions";
const KIND_LABELS: Record<string, string> = {
npm: "npm",
@@ -14,6 +15,8 @@ const KIND_LABELS: Record<string, string> = {
make: "Make",
just: "Just",
shell: "Shell Script",
program: "Program",
compound: "Compound",
application: "Application",
"spring-boot": "Spring Boot",
node: "Node.js",
@@ -37,12 +40,20 @@ const KIND_LABELS: Record<string, string> = {
export const KIND_OPTIONS = Object.entries(KIND_LABELS).map(([value, label]) => ({ value, label }));
export function kindLabel(kind: string): string {
return KIND_LABELS[kind] ?? (kind ? kind[0]!.toUpperCase() + kind.slice(1) : "Other");
return (
useRunConfigTypes.getState().types.get(kind)?.label ??
KIND_LABELS[kind] ??
(kind ? kind[0]!.toUpperCase() + kind.slice(1) : "Other")
);
}
export function configIcon(config: Pick<RunConfig, "kind" | "isTest">): string {
if (config.isTest) return "check";
switch (config.kind) {
case "compound":
return "gutter-run-all";
case "program":
return "file-binary";
case "spring-boot":
return "spring-boot";
case "npm":
@@ -98,7 +109,7 @@ export function configIcon(config: Pick<RunConfig, "kind" | "isTest">): string {
case "pytest":
return "file-python";
default:
return "terminal";
return useRunConfigTypes.getState().types.get(config.kind)?.icon ?? "terminal";
}
}
@@ -168,7 +179,7 @@ function sortedEnv(env: RunConfig["env"]) {
}
let counter = 0;
function uid(): string {
export function uid(): string {
const rnd =
typeof crypto !== "undefined" && "randomUUID" in crypto
? crypto.randomUUID().slice(0, 8)
@@ -202,23 +213,34 @@ export function newConfig(configs: readonly RunConfig[]): RunConfig {
export function duplicateConfig(config: RunConfig, configs: readonly RunConfig[]): RunConfig {
return {
...config,
env: { ...config.env },
args: [...config.args],
...structuredClone(config),
id: uid(),
name: uniqueName(`${config.name} (copy)`, configs),
detected: false,
};
}
export type ConfigErrors = Partial<Record<"name" | "command" | "env" | "cwd", string>>;
/** Field errors: `name`, `command`, `env`, `cwd`, `compound`, `beforeLaunch` or a type param key. */
export type ConfigErrors = Partial<Record<string, string>>;
export function validateConfig(config: RunConfig, all: readonly RunConfig[] = []): ConfigErrors {
const errors: ConfigErrors = {};
if (!config.name.trim()) errors.name = "Name is required";
else if (all.some((c) => c.id !== config.id && c.name.trim() === config.name.trim()))
errors.name = "Another configuration has this name";
if (!config.command.trim()) errors.command = "Command is required";
if (config.kind === "compound") {
const children = config.compound ?? [];
if (!children.length) errors.compound = "Add at least one configuration";
else if (compoundCycle(config, all)) errors.compound = "A compound configuration can't include itself";
else if (children.some((id) => !all.some((c) => c.id === id)))
errors.compound = "Some configurations no longer exist";
} else if (!config.command.trim()) errors.command = "Command is required";
for (const t of config.beforeLaunch ?? []) {
if (t.kind === "run" && (t.target === config.id || !all.some((c) => c.id === t.target)))
errors.beforeLaunch = "A before launch task runs a missing configuration or this one";
else if (t.kind !== "build" && t.kind !== "run" && !(t.target ?? "").trim())
errors.beforeLaunch = "A before launch task is incomplete";
}
for (const key of Object.keys(config.env)) {
if (!key.trim()) {
errors.env = "Environment variable names can't be empty";
@@ -276,3 +298,80 @@ export function formatArgs(args: readonly string[]): string {
export function commandLine(config: Pick<RunConfig, "command" | "args">): string {
return [config.command, formatArgs(config.args)].filter(Boolean).join(" ");
}
/** Whether compound `config` reaches itself through its children. */
export function compoundCycle(config: RunConfig, all: readonly RunConfig[]): boolean {
const byId = new Map(all.map((c) => [c.id, c]));
byId.set(config.id, config);
const seen = new Set<string>();
const visit = (id: string): boolean => {
if (seen.has(id)) return false;
seen.add(id);
return (byId.get(id)?.compound ?? []).some((child) => child === config.id || visit(child));
};
return visit(config.id);
}
/** The configurations a compound starts (nested compounds flattened, each once). */
export function compoundChildren(config: RunConfig, all: readonly RunConfig[]): RunConfig[] {
const out: RunConfig[] = [];
const seen = new Set<string>([config.id]);
const walk = (ids: readonly string[]) => {
for (const id of ids) {
if (seen.has(id)) continue;
seen.add(id);
const c = all.find((x) => x.id === id);
if (!c) continue;
if (c.kind === "compound") walk(c.compound ?? []);
else out.push(c);
}
};
walk(config.compound ?? []);
return out;
}
/** IDEA's one-line environment field: `A=1;B=2` (`;` and `\` in values escaped). */
export function formatEnv(env: RunConfig["env"]): string {
return Object.entries(env)
.map(([k, v]) => `${k}=${v.replace(/[\\;]/g, (c) => `\\${c}`)}`)
.join(";");
}
/**
* Parses `A=1;B=2`, or one `NAME=value` per line as pasted from a shell or a
* `.env` file (`export` prefixes, comments and surrounding quotes dropped).
*/
export function parseEnv(text: string): Array<{ key: string; value: string }> {
const multiline = /\r?\n/.test(text.trim());
const entries: string[] = [];
if (multiline) entries.push(...text.split(/\r?\n/));
else {
let cur = "";
for (let i = 0; i < text.length; i++) {
const ch = text[i]!;
if (ch === "\\" && (text[i + 1] === ";" || text[i + 1] === "\\")) cur += text[++i];
else if (ch === ";") {
entries.push(cur);
cur = "";
} else cur += ch;
}
entries.push(cur);
}
const out: Array<{ key: string; value: string }> = [];
for (const raw of entries) {
let line = multiline ? raw.trim() : raw.trimStart();
if (!line || (multiline && line.startsWith("#"))) continue;
if (multiline) line = line.replace(/^export\s+/, "");
const eq = line.indexOf("=");
if (eq <= 0) continue;
const key = line.slice(0, eq).trim();
let value = line.slice(eq + 1);
if (multiline) {
value = value.trim();
const q = value[0];
if ((q === '"' || q === "'") && value.length > 1 && value.endsWith(q)) value = value.slice(1, -1);
}
out.push({ key, value });
}
return out;
}
+2 -1
Переглянути файл
@@ -5,11 +5,12 @@
*/
import type { RunConfig } from "@/platform/ipc";
import { notifications, useRunExecutors, type ExecutorLaunch } from "@/workbench/contributions";
import { startRun } from "./store";
import { prepareLaunch, startRun } from "./store";
export async function runWithExecutor(config: RunConfig, executorId: string): Promise<void> {
const executor = useRunExecutors.getState().executors.get(executorId);
if (!executor) return;
if (!(await prepareLaunch(config))) return;
let plan: ExecutorLaunch | null;
try {
plan = await executor.prepare(config);
+13 -6
Переглянути файл
@@ -109,25 +109,32 @@ export function toolRunConfig(tool: ExternalToolValid, ctx: MacroContext, prompt
};
}
export async function runExternalTool(tool: ExternalToolValid) {
/** The configuration running `tool` now (macros expanded, `$Prompt$` asked); null when cancelled or not possible. */
export async function externalToolConfig(tool: ExternalToolValid): Promise<RunConfig | null> {
const ctx = currentContext();
if (!ctx) return;
if (!ctx) return null;
if (!tool.program.trim()) {
notifications().notify({ level: "warning", title: `External tool '${tool.name}' has no program` });
return;
return null;
}
const uses = (m: string) => [tool.program, tool.arguments, tool.workingDirectory].some((t) => t.includes(m));
let prompt = "";
if (uses("$Prompt$")) {
const value = await promptValue(tool.name);
if (value === null) return;
if (value === null) return null;
prompt = value;
}
if (!ctx.filePath && ["$FilePath$", "$FileDir$", "$FileName$", "$FileRelativePath$"].some(uses)) {
notifications().notify({ level: "warning", title: `'${tool.name}' needs an open file` });
return;
return null;
}
const session = await startRun(toolRunConfig(tool, ctx, prompt), undefined, undefined, { show: tool.openConsole });
return toolRunConfig(tool, ctx, prompt);
}
export async function runExternalTool(tool: ExternalToolValid) {
const config = await externalToolConfig(tool);
if (!config) return;
const session = await startRun(config, undefined, undefined, { show: tool.openConsole });
if (!session || !tool.synchronizeAfter) return;
// "Synchronize files after execution": reload the project from disk once the tool exits.
let done = false;
+48 -3
Переглянути файл
@@ -14,6 +14,8 @@ import {
notifications,
registerMenuGroup,
registerOverlay,
registerRunAnythingProvider,
registerServiceProvider,
registerTitleItem,
registerToolWindow,
} from "@/workbench/contributions";
@@ -33,12 +35,22 @@ import { RunDialogsOverlay } from "./dialogs";
import { registerExternalTools } from "./externalTools";
import { EditConfigsOverlay } from "./EditConfigsDialog";
import { RunWidget } from "./RunWidget";
import { runConfigsRunAnything } from "./runAnything";
import { runServiceProvider } from "./services";
import { runBeforeLaunch } from "./beforeLaunch";
import { compoundChildren } from "./configs";
import { registerBuiltinTypes } from "./types";
import { runGutter } from "./runGutter";
import { activateStackTrace, useStackTraces } from "./stackTrace";
import { clearRuntimeCache } from "./runtimes";
import {
adoptTemporary,
applyConsoleSettings,
CURRENT_FILE,
currentFileSelected,
prepareLaunch,
sessionsOf,
setBeforeLaunchRunner,
loadConfigs,
failedTests,
onRunExited,
@@ -64,6 +76,7 @@ async function ensureConfigs() {
async function runSelected() {
await ensureConfigs();
if (currentFileSelected()) return runCurrentFile(false);
const config = selectedConfig();
if (!config) {
useRun.setState({ chooserOpen: true, chooserMode: "run" });
@@ -72,7 +85,14 @@ async function runSelected() {
await startRun(config);
}
async function debugConfig(config: RunConfig) {
async function debugConfig(config: RunConfig, chain: readonly string[] = []) {
if (config.kind === "compound") {
if (!(await prepareLaunch(config, chain))) return;
const children = compoundChildren(config, useRun.getState().configs);
await Promise.all(children.map((c) => debugConfig(c, [...chain, config.id])));
return;
}
if (!(await prepareLaunch(config, chain))) return;
// A single compiled file (rustc / clang): build with debug info, then debug the binary.
const compiled = await compiledDebugPlan(config);
if (compiled?.debug) {
@@ -95,11 +115,12 @@ async function debugConfig(config: RunConfig) {
body: `Running '${config.name}' without the debugger.`,
key: "run-no-debugger",
});
await startRun(config);
await startRun(config, undefined, undefined, { prepared: true });
}
async function debugSelected() {
await ensureConfigs();
if (currentFileSelected()) return runCurrentFile(true);
const config = selectedConfig();
if (!config) {
useRun.setState({ chooserOpen: true, chooserMode: "debug" });
@@ -112,6 +133,12 @@ async function runContext(debug: boolean, arg?: unknown) {
if (!(await runContextAction(debug, arg))) nothingToRun();
}
/** "Current File" stays selected after running the file's context (which adopts a temporary configuration). */
async function runCurrentFile(debug: boolean) {
await runContext(debug);
selectConfig(CURRENT_FILE);
}
function nothingToRun() {
notifications().notify({
group: "run",
@@ -156,6 +183,15 @@ function stopAction() {
return;
}
}
// A selected compound stops all of its configurations.
const selected = selectedConfig();
if (selected?.kind === "compound") {
const own = sessionsOf(selected, running);
if (own.length) {
own.forEach((s) => void stopSession(s.id));
return;
}
}
const items = [
...running.map((s): MenuItem => ({ label: s.config.name, verbatim: true, icon: "run", run: () => void stopSession(s.id) })),
...menuGroupItems("stopProcess", { paths: [] }),
@@ -208,10 +244,13 @@ export function activate(): () => void {
headerContent: lazyComponent(() => import("./RunView").then((m) => m.RunTabs)),
useBadge: useRunningCount,
}),
registerTitleItem({ id: "run.widget", align: "right", priority: 0, component: RunWidget }),
registerTitleItem({ id: "run.widget", title: "Run Widget", align: "right", priority: 0, component: RunWidget }),
registerRunAnythingProvider(runConfigsRunAnything),
registerBuiltinTypes(),
registerOverlay({ id: "run.chooser", component: ConfigChooserOverlay }),
registerOverlay({ id: "run.editConfigs", component: EditConfigsOverlay }),
registerOverlay({ id: "run.dialogs", component: RunDialogsOverlay }),
registerServiceProvider(runServiceProvider),
onWorkflowSettingsChange((w) => w.console, applyConsoleSettings),
registerExternalTools(),
activateStackTrace(),
@@ -380,6 +419,10 @@ export function activate(): () => void {
return;
}
await ensureConfigs();
if (config === undefined && currentFileSelected()) {
if (!(await executeContext(executor, undefined))) nothingToRun();
return;
}
const target = config !== undefined ? configFromArg(config) : selectedConfig();
if (!target) {
useRun.setState({ chooserOpen: true, chooserMode: "run" });
@@ -418,6 +461,8 @@ export function activate(): () => void {
}),
];
setBeforeLaunchRunner(runBeforeLaunch);
disposers.push(() => setBeforeLaunchRunner(null));
if (hasProject()) void loadConfigs();
// Reload when run.toml or build files change on disk (cheap; detection is fast).
+39
Переглянути файл
@@ -0,0 +1,39 @@
/** Run Anything entries for run configurations (npm scripts, cargo targets, user configurations…). */
import { executeAction } from "@/platform/actions";
import type { RunConfig } from "@/platform/ipc";
import type { RunAnythingItem, RunAnythingProvider } from "@/workbench/contributions";
import { useRun } from "./store";
const KIND_ICON: Record<string, string> = { npm: "file-npm", maven: "file-maven", gradle: "file-gradle" };
function item(c: RunConfig): RunAnythingItem {
return {
key: c.id,
label: c.name,
detail: c.kind,
icon: KIND_ICON[c.kind] ?? "run",
run: () => void executeAction("run.runConfig", c.id),
};
}
/** Configurations whose name contains every word of `query`, best (prefix) matches first. */
export function matchingConfigs(configs: readonly RunConfig[], query: string): RunConfig[] {
const words = query.toLowerCase().split(/\s+/).filter(Boolean);
const q = query.trim().toLowerCase();
return configs
.filter((c) => words.every((w) => c.name.toLowerCase().includes(w)))
.sort((a, b) => Number(b.name.toLowerCase().startsWith(q)) - Number(a.name.toLowerCase().startsWith(q)));
}
export const runConfigsRunAnything: RunAnythingProvider = {
id: "run.configs",
group: "Run Configurations",
order: 10,
items: (query) => matchingConfigs(useRun.getState().configs, query).map(item),
execute(line) {
const config = useRun.getState().configs.find((c) => c.name === line.trim());
if (!config) return false;
void executeAction("run.runConfig", config.id);
return true;
},
};
+304
Переглянути файл
@@ -0,0 +1,304 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import { registerActions } from "@/platform/actions";
import { setContext } from "@/platform/context";
import type { RunConfig } from "@/platform/ipc";
import {
provideNotificationService,
provideToolWindowService,
registerToolWindow,
runConfigTypes,
type ToolWindowService,
} from "@/workbench/contributions";
import { typeRows } from "./AddConfigPopup";
import { runBeforeLaunch } from "./beforeLaunch";
import { buildRows } from "./ConfigChooser";
import { compoundChildren, compoundCycle, formatEnv, parseEnv, validateConfig } from "./configs";
import { EditConfigsOverlay, moveConfig, treeRows } from "./EditConfigsDialog";
import { pasteRows } from "./EnvDialog";
import { CURRENT_FILE, resetRuns, selectConfig, sessionsOf, startCompound, useRun } from "./store";
import {
BUILTIN_TYPES,
formParams,
missingParams,
newConfigOfType,
registerBuiltinTypes,
typeFor,
withParams,
} from "./types";
vi.mock("./dialogs", () => ({
confirmRerun: vi.fn(async () => true),
confirmTerminate: vi.fn(),
promptValue: vi.fn(),
RunDialogsOverlay: () => null,
}));
const cfg = (id: string, extra: Partial<RunConfig> = {}): RunConfig => ({
id,
name: id,
kind: "shell",
command: "x",
args: [],
cwd: null,
env: {},
detected: false,
isTest: false,
debugAdapter: null,
...extra,
});
let disposeTypes: () => void;
const notify = vi.fn();
const show = vi.fn();
beforeEach(() => {
resetRuns();
disposeTypes = registerBuiltinTypes();
notify.mockClear();
show.mockClear();
provideToolWindowService({ show, isVisible: () => true } as unknown as ToolWindowService);
provideNotificationService({ notify });
});
afterEach(() => disposeTypes());
describe("configuration types", () => {
it("are contributed through the registry", () => {
expect(runConfigTypes().map((t) => t.id)).toEqual(expect.arrayContaining(BUILTIN_TYPES.map((t) => t.id)));
});
it("old run.toml entries edit as a Shell Script command line", () => {
const old = cfg("user:server", { command: "cargo", args: ["run", "--bin", "my app"] });
const params = formParams(old)!;
expect(params).toEqual({ mode: "command", command: "cargo", args: "run --bin 'my app'" });
expect(withParams(old, params)).toMatchObject({ command: "cargo", args: ["run", "--bin", "my app"] });
});
it("shell script files and text derive the command line", () => {
const c = cfg("s");
expect(
withParams(c, {
mode: "file",
path: "scripts/build.sh",
options: "--fast",
interpreter: "/bin/bash",
interpreterOptions: "-e",
}),
).toMatchObject({
command: "/bin/bash",
args: ["-e", "scripts/build.sh", "--fast"],
});
expect(withParams(c, { mode: "file", path: "./run.sh" })).toMatchObject({ command: "./run.sh", args: [] });
expect(withParams(c, { mode: "text", script: "echo a && echo b" })).toMatchObject({
command: "echo a && echo b",
args: [],
});
});
it("detected npm / Gradle configurations open in their forms when they fit", () => {
const npm = cfg("npm:dev", {
kind: "npm",
command: "pnpm",
args: ["run", "dev", "--port", "3000"],
cwd: "web",
detected: true,
});
expect(formParams(npm)).toEqual({
packageManager: "pnpm",
command: "run",
script: "dev",
arguments: "--port 3000",
packageDir: "web",
});
const gradle = cfg("gradle:test", { kind: "gradle", command: "./gradlew", args: ["test"], detected: true });
expect(formParams(gradle)).toMatchObject({ tasks: "test", wrapper: "true" });
// A toolchain-specific command line the form can't reproduce stays a plain command.
expect(formParams(cfg("m", { kind: "maven", command: "/opt/maven/bin/mvn", args: ["package"] }))).toBeNull();
expect(withParams(npm, { packageManager: "npm", command: "test", packageDir: "" })).toMatchObject({
command: "npm",
args: ["test"],
cwd: null,
});
});
it("new configurations get the type's defaults, then the template", () => {
const template = cfg("template:npm", {
kind: "npm",
env: { NODE_ENV: "development" },
params: { packageManager: "pnpm", command: "run", script: "dev" },
beforeLaunch: [{ kind: "build", target: "" }],
allowParallel: true,
});
const c = newConfigOfType(typeFor("npm")!, [], template, "Web");
expect(c).toMatchObject({
kind: "npm",
name: "Unnamed",
command: "pnpm",
args: ["run", "dev"],
env: { NODE_ENV: "development" },
allowParallel: true,
folder: "Web",
local: true,
beforeLaunch: [{ kind: "build", target: "" }],
});
expect(c.id.startsWith("user:")).toBe(true);
expect(missingParams(newConfigOfType(typeFor("program")!, []))).toEqual({ program: "Executable is required" });
});
it("Add New Configuration lists, groups and filters types", () => {
const types = [
...runConfigTypes(),
{ ...BUILTIN_TYPES[0]!, id: "docker-image", label: "Docker Image", group: "Docker" },
];
const rows = typeRows(types, "");
expect(rows.find((r) => r.kind === "group")).toMatchObject({ label: "Docker" });
expect(rows.filter((r) => r.kind === "type").map((r) => (r.kind === "type" ? r.type.label : ""))).toContain(
"Compound",
);
expect(typeRows(types, "grad").map((r) => r.key)).toEqual(["gradle"]);
expect(typeRows(types, "dock").map((r) => r.key)).toEqual(["group:Docker", "docker-image"]);
});
});
describe("compound configurations", () => {
const a = cfg("a");
const b = cfg("b");
const inner = cfg("inner", { kind: "compound", command: "", compound: ["b", "a"] });
const outer = cfg("outer", { kind: "compound", command: "", compound: ["a", "inner"] });
it("flatten their members and reject cycles", () => {
expect(compoundChildren(outer, [a, b, inner, outer]).map((c) => c.id)).toEqual(["a", "b"]);
const loop = { ...inner, compound: ["outer"] };
expect(compoundCycle(loop, [a, b, loop, outer])).toBe(true);
expect(validateConfig(loop, [a, b, loop, outer]).compound).toMatch(/itself/);
expect(validateConfig(cfg("empty", { kind: "compound", command: "" })).compound).toMatch(/at least one/);
expect(validateConfig(outer, [a, b, inner, outer])).toEqual({});
});
it("run all members; the compound's sessions are theirs", async () => {
useRun.setState({ configs: [a, b, inner, outer] });
const sessions = await startCompound(outer);
expect(sessions.map((s) => s.config.id).sort()).toEqual(["a", "b"]);
expect(
sessionsOf(outer)
.map((s) => s.config.id)
.sort(),
).toEqual(["a", "b"]);
});
});
describe("before launch tasks", () => {
it("run in order and a failure aborts", async () => {
const dispose = registerToolWindow({
id: "todo",
title: "TODO",
icon: "todo",
anchor: "bottom",
component: () => null,
});
const build = vi.fn(async () => setContext("build.lastStatus", "success"));
const disposeBuild = registerActions([{ id: "build.buildProject", title: "Build Project", run: build }]);
const app = cfg("app", {
beforeLaunch: [
{ kind: "build", target: "" },
{ kind: "toolWindow", target: "todo" },
],
});
expect(await runBeforeLaunch(app, [app.id])).toBe(true);
expect(build).toHaveBeenCalledOnce();
expect(show).toHaveBeenCalledWith("todo", true);
build.mockImplementation(async () => setContext("build.lastStatus", "failed"));
show.mockClear();
expect(await runBeforeLaunch(app, [app.id])).toBe(false);
expect(show).not.toHaveBeenCalled();
expect(notify).toHaveBeenCalledWith(expect.objectContaining({ title: "'app' was not started" }));
// Another configuration that can't run (no desktop app in tests) fails the launch; cycles are refused.
const other = cfg("other");
useRun.setState({ configs: [app, other] });
expect(await runBeforeLaunch(cfg("x", { beforeLaunch: [{ kind: "run", target: "other" }] }), ["x"])).toBe(false);
expect(await runBeforeLaunch(cfg("x", { beforeLaunch: [{ kind: "run", target: "x" }] }), ["x"])).toBe(false);
dispose();
disposeBuild();
});
});
describe("environment variables", () => {
it("format and parse the one-line field and pasted lines", () => {
const env = { A: "1", B: "x;y\\z" };
expect(formatEnv(env)).toBe("A=1;B=x\\;y\\\\z");
expect(parseEnv(formatEnv(env))).toEqual([
{ key: "A", value: "1" },
{ key: "B", value: "x;y\\z" },
]);
expect(parseEnv('# comment\nexport A=1\nB = "two words"\n\nbad\n')).toEqual([
{ key: "A", value: "1" },
{ key: "B", value: "two words" },
]);
expect(pasteRows([{ key: "A", value: "0" }], "A=1;C=3")).toEqual([
{ key: "A", value: "1" },
{ key: "C", value: "3" },
]);
});
});
describe("Run/Debug Configurations tree", () => {
const configs = [
cfg("a", { kind: "npm", folder: "Web" }),
cfg("b", { kind: "npm" }),
cfg("c", { kind: "npm", folder: "Web" }),
cfg("d", { kind: "shell" }),
];
it("groups by type with folders first", () => {
expect(treeRows(configs, [{ kind: "shell", name: "Empty" }]).map((r) => r.key)).toEqual([
"g:npm",
"f:npm:Web",
"c:a",
"c:c",
"c:b",
"g:shell",
"f:shell:Empty",
"c:d",
]);
expect(treeRows(configs, [], new Set(["f:npm:Web"])).map((r) => r.key)).toEqual([
"g:npm",
"f:npm:Web",
"c:b",
"g:shell",
"c:d",
]);
});
it("moves within the same type and folder", () => {
expect(moveConfig(configs, "c", -1).map((c) => c.id)).toEqual(["c", "b", "a", "d"]);
expect(moveConfig(configs, "b", -1).map((c) => c.id)).toEqual(["a", "b", "c", "d"]);
});
it("adds a configuration of the picked type and edits it", async () => {
useRun.setState({ configs: [], loaded: true, editorOpen: true });
render(<EditConfigsOverlay />);
expect(screen.getByText("No run configurations added.")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Add New Configuration" }));
fireEvent.click(await screen.findByText("Program"));
expect((screen.getByLabelText("Name") as HTMLInputElement).value).toBe("Unnamed");
fireEvent.change(screen.getByLabelText("Executable"), { target: { value: "./server" } });
fireEvent.change(screen.getByLabelText("Program arguments"), { target: { value: "--port 1" } });
expect(screen.getByText("./server --port 1")).toBeTruthy();
// Modify options adds "Redirect input".
fireEvent.click(screen.getByRole("button", { name: /Modify options/ }));
fireEvent.click(await screen.findByText("Redirect input"));
expect(screen.getByLabelText("Redirect input from")).toBeTruthy();
});
});
describe("Current File", () => {
it("is always offered in the chooser", () => {
expect(buildRows([], "")[0]).toMatchObject({ type: "current" });
expect(buildRows([cfg("a")], "cur").map((r) => r.type)).toEqual(["current", "edit"]);
selectConfig(CURRENT_FILE);
expect(useRun.getState().selectedId).toBe(CURRENT_FILE);
});
});
+76
Переглянути файл
@@ -0,0 +1,76 @@
import { describe, expect, it } from "vitest";
import type { RunConfig } from "@/platform/ipc";
import { runServiceItems, sessionStatus } from "./services";
import type { RunSession } from "./store";
const config = (id: string, kind: string): RunConfig => ({
id,
name: id,
kind,
command: kind,
args: [],
cwd: null,
env: {},
detected: true,
isTest: false,
debugAdapter: null,
});
const session = (
id: string,
c: RunConfig,
status: RunSession["status"],
startedAt: number,
extra: Partial<RunSession> = {},
): RunSession => ({
id,
config: c,
runId: null,
status,
exitCode: null,
startedAt,
endedAt: null,
stdinClosed: false,
...extra,
});
describe("run configurations in Services", () => {
const build = config("build", "cargo");
const test = config("test", "cargo");
const dev = config("dev", "npm");
const scratch = config("context:file", "python");
it("lists added types fully and other types only with their runs", () => {
const sessions = [
session("s1", dev, "finished", 1),
session("s2", dev, "running", 2),
session("s3", scratch, "failed", 3, { exitCode: 2 }),
];
const items = runServiceItems([build, test, dev], sessions, ["cargo"]);
expect(items.map((i) => [i.key, i.label, i.detail])).toEqual([
["run:type:cargo", "Cargo", undefined],
["run:type:npm", "npm", "1 running"],
["run:type:python", "Python", undefined],
]);
expect(items[0]!.children!.map((c) => [c.key, c.detail, c.dim])).toEqual([
["run:config:build", undefined, true],
["run:config:test", undefined, true],
]);
// The newest session of a configuration decides its state.
expect(items[1]!.children!.map((c) => [c.label, c.detail, c.dim, c.icon])).toEqual([
["dev", "Running", false, "run"],
]);
expect(items[2]!.children!.map((c) => [c.key, c.detail])).toEqual([["run:config:context:file", "Failed (exit 2)"]]);
});
it("has no groups without added types or sessions", () => {
expect(runServiceItems([build], [], [])).toEqual([]);
});
it("describes session states", () => {
expect(sessionStatus(undefined)).toBeUndefined();
expect(sessionStatus(session("a", build, "starting", 0))).toBe("Starting");
expect(sessionStatus(session("a", build, "failed", 0, { stopped: true }))).toBe("Stopped");
expect(sessionStatus(session("a", build, "finished", 0))).toBe("Finished");
});
});
+318
Переглянути файл
@@ -0,0 +1,318 @@
/**
* Run configurations in the Services tool window (IDEA's Run Dashboard): the
* configuration types added there ("Add Service › Run Configuration Type") and
* the types of this window's run sessions, one group each, with run / stop /
* rerun / show output per configuration.
*/
import { useEffect, useMemo } from "react";
import { create } from "zustand";
import { executeAction } from "@/platform/actions";
import type { RunConfig } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { useT, useTxTemplate } from "@/platform/i18n";
import { Button, EmptyState, T } from "@/ui";
import type { MenuItem } from "@/ui";
import {
toolWindowService,
type ServiceItemAction,
type ServiceProviderContribution,
type ServiceTreeItem,
} from "@/workbench/contributions";
import { commandLine, configIcon, KIND_OPTIONS, kindLabel } from "./configs";
import { rerunSession, stopSession, useRun, type RunSession } from "./store";
import styles from "./RunServices.module.css";
export const RUN_PROVIDER = "runConfigurations";
const TYPE_PREFIX = "run:type:";
const CONFIG_PREFIX = "run:config:";
const STORAGE_KEY = "openidea.services.runTypes:";
// ---------------------------------------------------------------------------
// Types added to Services, per project
const useAddedTypes = create<{ root: string | null; types: string[] }>(() => ({ root: null, types: [] }));
function readTypes(root: string): string[] {
try {
const v: unknown = JSON.parse(localStorage.getItem(STORAGE_KEY + root) ?? "[]");
return Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : [];
} catch {
return [];
}
}
function addedTypes(root: string | null): string[] {
const s = useAddedTypes.getState();
return s.root === root ? s.types : root ? readTypes(root) : [];
}
function setAddedTypes(types: string[]) {
const root = useProject.getState().project?.root ?? null;
useAddedTypes.setState({ root, types });
if (!root) return;
try {
localStorage.setItem(STORAGE_KEY + root, JSON.stringify(types));
} catch {
/* storage unavailable */
}
}
export function addType(kind: string) {
const types = addedTypes(useProject.getState().project?.root ?? null);
if (!types.includes(kind)) setAddedTypes([...types, kind]);
}
export function removeType(kind: string) {
setAddedTypes(addedTypes(useProject.getState().project?.root ?? null).filter((t) => t !== kind));
}
// ---------------------------------------------------------------------------
// Tree
const busy = (s: RunSession) => s.status === "running" || s.status === "starting";
/** The newest session of each configuration id. */
function latestSessions(sessions: readonly RunSession[]): Map<string, RunSession> {
const out = new Map<string, RunSession>();
for (const s of sessions) {
const prev = out.get(s.config.id);
if (!prev || s.startedAt >= prev.startedAt) out.set(s.config.id, s);
}
return out;
}
export function sessionStatus(s: RunSession | undefined): string | undefined {
if (!s) return undefined;
if (busy(s)) return s.status === "starting" ? "Starting" : "Running";
if (s.stopped) return "Stopped";
if (s.status === "failed") return s.exitCode === null ? "Failed" : `Failed (exit ${s.exitCode})`;
return "Finished";
}
/** Groups by configuration type: added types list all their configurations, other types only those that ran. */
export function runServiceItems(
configs: readonly RunConfig[],
sessions: readonly RunSession[],
added: readonly string[],
): ServiceTreeItem[] {
const latest = latestSessions(sessions);
const kinds = [...added];
for (const s of sessions) if (!kinds.includes(s.config.kind)) kinds.push(s.config.kind);
return kinds.map((kind) => {
const entries: RunConfig[] = added.includes(kind) ? configs.filter((c) => c.kind === kind) : [];
for (const s of latest.values())
if (s.config.kind === kind && !entries.some((c) => c.id === s.config.id)) entries.push(s.config);
const children = entries.map((c): ServiceTreeItem => {
const s = latest.get(c.id);
return {
key: CONFIG_PREFIX + c.id,
label: c.name,
icon: s && busy(s) ? "run" : configIcon(c),
detail: sessionStatus(s),
dim: !s || !busy(s),
};
});
const running = entries.filter((c) => {
const s = latest.get(c.id);
return !!s && busy(s);
}).length;
return {
key: TYPE_PREFIX + kind,
label: kindLabel(kind),
icon: configIcon({ kind, isTest: false }),
detail: running ? `${running} running` : undefined,
children,
};
});
}
function useRoots(): ServiceTreeItem[] {
const root = useProject((s) => s.project?.root ?? null);
const configs = useRun((s) => s.configs);
const sessions = useRun((s) => s.sessions);
const types = useAddedTypes((s) => s.types);
const typesRoot = useAddedTypes((s) => s.root);
useEffect(() => {
if (typesRoot !== root) useAddedTypes.setState({ root, types: root ? readTypes(root) : [] });
}, [root, typesRoot]);
return useMemo(
() => runServiceItems(configs, sessions, typesRoot === root ? types : []),
[configs, sessions, types, typesRoot, root],
);
}
interface Resolved {
kind: string | null;
config: RunConfig | null;
session: RunSession | undefined;
/** Sessions of a group's configurations. */
sessions: RunSession[];
}
function resolve(item: ServiceTreeItem): Resolved {
const { configs, sessions } = useRun.getState();
const latest = latestSessions(sessions);
if (item.key.startsWith(TYPE_PREFIX)) {
const kind = item.key.slice(TYPE_PREFIX.length);
return { kind, config: null, session: undefined, sessions: sessions.filter((s) => s.config.kind === kind) };
}
const id = item.key.slice(CONFIG_PREFIX.length);
const session = latest.get(id);
const config = configs.find((c) => c.id === id) ?? session?.config ?? null;
return { kind: config?.kind ?? null, config, session, sessions: session ? [session] : [] };
}
function showOutput(s: RunSession) {
useRun.setState({ activeSession: s.id });
toolWindowService().show("run", true);
}
function run(r: Resolved) {
if (r.session && r.session.config.id === r.config?.id) rerunSession(r.session.id);
else if (r.config) void executeAction("run.runConfig", r.config);
}
function actions(item: ServiceTreeItem): ServiceItemAction[] {
const r = resolve(item);
const running = r.sessions.filter(busy);
const stop = {
id: "stop",
title: "Stop",
icon: "stop",
disabled: !running.length,
run: () => running.forEach((s) => void stopSession(s.id)),
};
if (!r.config) return [stop];
const live = !!r.session && busy(r.session);
return [
{ id: "run", title: live ? "Rerun" : "Run", icon: live ? "rerun" : "run", run: () => run(r) },
stop,
{
id: "output",
title: "Show Output",
icon: "toolwindow-run",
disabled: !r.session,
run: () => r.session && showOutput(r.session),
},
];
}
function menu(item: ServiceTreeItem): MenuItem[] {
const items: MenuItem[] = actions(item).map((a) => ({
label: a.title,
icon: a.icon,
disabled: a.disabled,
run: a.run,
}));
const r = resolve(item);
if (!r.config && r.kind && addedTypes(useProject.getState().project?.root ?? null).includes(r.kind)) {
const kind = r.kind;
items.push({ kind: "separator" }, { label: "Remove from Services", icon: "close", run: () => removeType(kind) });
}
if (r.config) {
const config = r.config;
items.push(
{ kind: "separator" },
{ label: "Edit Configuration…", icon: "edit", run: () => void executeAction("run.editConfigs", config.id) },
);
}
return items;
}
function RunDetail({ item }: { item: ServiceTreeItem }) {
// Re-render on session changes.
useRun((s) => s.sessions);
const t = useT();
const txTemplate = useTxTemplate();
const r = resolve(item);
if (!r.config)
return (
<EmptyState title={kindLabel(r.kind ?? "")}>
<div>
{item.children?.length
? t("run.services.configurationCount", { count: item.children.length })
: txTemplate("No configurations of this type")}
</div>
</EmptyState>
);
const s = r.session;
const live = !!s && busy(s);
return (
<div className={styles.detail}>
<dl className={styles.facts}>
<dt>
<T>Configuration</T>
</dt>
<dd>{r.config.name}</dd>
<dt>
<T>Type</T>
</dt>
<dd>
<T>{kindLabel(r.config.kind)}</T>
</dd>
<dt>
<T>Command</T>
</dt>
<dd className={styles.mono}>{commandLine(r.config)}</dd>
<dt>
<T>Status</T>
</dt>
<dd>{txTemplate(sessionStatus(s) ?? "Not started")}</dd>
{s && (
<>
<dt>
<T>Started</T>
</dt>
<dd>{new Date(s.startedAt).toLocaleTimeString()}</dd>
</>
)}
</dl>
<div className={styles.actions}>
<Button icon={live ? "rerun" : "run"} onClick={() => run(r)}>
{live ? "Rerun" : "Run"}
</Button>
<Button icon="stop" disabled={!live} onClick={() => s && void stopSession(s.id)}>
Stop
</Button>
<Button icon="toolwindow-run" disabled={!s} onClick={() => s && showOutput(s)}>
Show Output
</Button>
</div>
</div>
);
}
/** "Add Service › Run Configuration Type": the project's types first, then the rest. */
function addMenu(): MenuItem[] {
const added = addedTypes(useProject.getState().project?.root ?? null);
const used = [...new Set(useRun.getState().configs.map((c) => c.kind))];
const kinds = [...used, ...KIND_OPTIONS.map((o) => o.value).filter((k) => !used.includes(k))].filter(
(k) => !added.includes(k),
);
const item = (kind: string): MenuItem => ({
label: kindLabel(kind),
icon: configIcon({ kind, isTest: false }),
run: () => addType(kind),
});
const submenu: MenuItem[] = [
...kinds.filter((k) => used.includes(k)).map(item),
...(used.some((k) => kinds.includes(k)) ? [{ kind: "separator" } as MenuItem] : []),
...kinds.filter((k) => !used.includes(k)).map(item),
];
return [{ label: "Run Configuration Type", icon: "toolwindow-run", disabled: !submenu.length, submenu }];
}
export const runServiceProvider: ServiceProviderContribution = {
id: RUN_PROVIDER,
order: 0,
useRoots,
actions,
menu,
activate: (item) => {
const r = resolve(item);
if (r.session) showOutput(r.session);
else if (r.config) run(r);
},
detail: RunDetail,
addMenu,
};
+112 -5
Переглянути файл
@@ -17,6 +17,12 @@ import { confirmRerun, confirmTerminate } from "./dialogs";
import { createTestParser, frameworkFor, TestResults, withTestReporting, type TestEvent, type TestOutputParser } from "./testing";
import { recordTestStates } from "./testing/states";
import { failedTestPaths, leafTestPaths, rerunFailedConfig, testsConfig } from "./testing/rerun";
import { compoundChildren } from "./configs";
import { TerminalOutput } from "./terminalOutput";
import { typeFor } from "./types";
/** The permanent "Current File" pseudo-configuration of the chooser (runs the active file's context). */
export const CURRENT_FILE = "current-file";
export type SessionStatus = "starting" | "running" | "finished" | "failed";
@@ -33,6 +39,8 @@ export interface RunSession {
stopped?: boolean;
/** The unfiltered configuration when this run was started by "Rerun Failed Tests". */
baseConfig?: RunConfig;
/** "Emulate terminal in output console": the output is a terminal. */
terminal?: boolean;
}
interface SessionRuntime {
@@ -49,6 +57,10 @@ interface SessionRuntime {
onExit?: (exitCode: number | null, stopped: boolean) => void;
/** User input typed into the console, not yet sent (IDEA: Enter sends the line). */
input: string;
/** Output of a run that emulates a terminal (instead of `console`). */
terminal: TerminalOutput | null;
/** Last size the terminal console reported (sent once the process has an id). */
terminalSize: { cols: number; rows: number } | null;
}
interface RunState {
@@ -137,7 +149,10 @@ export async function loadConfigs(): Promise<void> {
const temps = readTemporary(root).filter((t) => !saved.some((c) => c.id === t.id));
const configs = [...saved, ...temps];
const stored = useRun.getState().selectedId ?? readSelected(root);
const selectedId = configs.some((c) => c.id === stored) ? stored : (defaultSelection(saved)?.id ?? null);
const selectedId =
stored === CURRENT_FILE || configs.some((c) => c.id === stored)
? stored
: (defaultSelection(saved)?.id ?? CURRENT_FILE);
useRun.setState({ configs, temporary: temps.map((t) => t.id), loaded: true, loadError: null, selectedId });
} catch (e) {
useRun.setState({ loaded: true, loadError: e instanceof Error ? e.message : String(e) });
@@ -162,6 +177,11 @@ export function selectConfig(id: string | null) {
if (root) writeSelected(root, id);
}
/** Whether the chooser's "Current File" is selected. */
export function currentFileSelected(): boolean {
return useRun.getState().selectedId === CURRENT_FILE;
}
export function selectedConfig(): RunConfig | undefined {
const { configs, selectedId } = useRun.getState();
return configs.find((c) => c.id === selectedId);
@@ -298,6 +318,10 @@ function flush(sessionId: string) {
function enqueue(sessionId: string, chunk: OutputChunk) {
const rt = runtimes.get(sessionId);
if (!rt) return;
if (rt.terminal) {
rt.terminal.append(chunk);
return;
}
rt.queue.push(chunk);
if (rt.flushHandle === null) rt.flushHandle = requestAnimationFrame(() => flush(sessionId));
}
@@ -335,10 +359,41 @@ function newRuntime(config: RunConfig): SessionRuntime {
generation: 0,
lineListeners: [],
input: "",
terminal: null,
terminalSize: null,
};
return runtime;
}
/** Runs a configuration's before launch tasks; false aborts the launch. Installed by the feature. */
export type BeforeLaunchRunner = (config: RunConfig, chain: readonly string[]) => Promise<boolean>;
let beforeLaunch: BeforeLaunchRunner | null = null;
export function setBeforeLaunchRunner(runner: BeforeLaunchRunner | null) {
beforeLaunch = runner;
}
/** Runs `config`'s before launch tasks (none: true). `chain` holds the configurations already launching. */
export async function prepareLaunch(config: RunConfig, chain: readonly string[] = []): Promise<boolean> {
if (!config.beforeLaunch?.length || !beforeLaunch) return true;
return beforeLaunch(config, [...chain, config.id]);
}
/** Starts every configuration of a compound (after the compound's own before launch tasks). */
export async function startCompound(config: RunConfig, chain: readonly string[] = []): Promise<RunSession[]> {
if (!(await prepareLaunch(config, chain))) return [];
const children = compoundChildren(config, useRun.getState().configs);
const sessions = await Promise.all(children.map((c) => startRun(c, undefined, undefined, { chain: [...chain, config.id] })));
return sessions.filter((s): s is RunSession => !!s);
}
/** Sessions of `config`: its own, or for a compound those of its configurations. */
export function sessionsOf(config: RunConfig, sessions = useRun.getState().sessions): RunSession[] {
if (config.kind !== "compound") return sessions.filter((s) => s.config.id === config.id);
const ids = new Set(compoundChildren(config, useRun.getState().configs).map((c) => c.id));
return sessions.filter((s) => ids.has(s.config.id));
}
/** Starts `config`, reusing the tab of a finished (or the given) session for the same config. */
export async function startRun(
config: RunConfig,
@@ -346,14 +401,23 @@ export async function startRun(
baseConfig?: RunConfig,
options: {
show?: boolean;
/** Launch this instead of `config` (e.g. with a debug agent); the tab still shows `config`. */
/** Launch this instead of `config` (e.g. with a debug agent); the tab still shows `config`. Skips before launch tasks. */
launch?: RunConfig;
/** Every output line of this run. */
onLine?: (text: string) => void;
/** The process exited. */
onExit?: (exitCode: number | null, stopped: boolean) => void;
/** Before launch tasks already ran (or must not run). */
prepared?: boolean;
/** Configurations whose launch led to this one (before launch tasks, compounds): breaks cycles. */
chain?: readonly string[];
} = {},
): Promise<RunSession | null> {
if (config.kind === "compound") {
await startCompound(config, options.chain);
return null;
}
if (!options.launch && !options.prepared && !(await prepareLaunch(config, options.chain))) return null;
const state = useRun.getState();
const busy = (s: RunSession) => s.status === "running" || s.status === "starting";
let session = reuse ? state.sessions.find((s) => s.id === reuse) : undefined;
@@ -419,7 +483,8 @@ export async function startRun(
if (options.onLine) rt.lineListeners.push(options.onLine);
rt.onExit = options.onExit;
try {
if (options.show ?? workflowSettings().run.showOnStart) toolWindowService().show("run");
if (config.focusToolWindow) toolWindowService().show("run", true);
else if (options.show ?? config.openToolWindow ?? workflowSettings().run.showOnStart) toolWindowService().show("run");
} catch {
/* no workbench (tests) */
}
@@ -433,7 +498,10 @@ export async function startRun(
const channel = new Channel<OutputChunk>();
channel.onmessage = (chunk) => {
if (runtimes.get(sessionId)?.generation === generation) enqueue(sessionId, chunk);
if (runtimes.get(sessionId)?.generation !== generation) return;
enqueue(sessionId, chunk);
if ((chunk.kind === "stdout" && config.showOnStdout) || (chunk.kind === "stderr" && config.showOnStderr))
revealSession(sessionId);
};
try {
let launch = options.launch ?? config;
@@ -446,11 +514,22 @@ export async function startRun(
}
launch = resolved;
}
const prepare = typeFor(launch.kind)?.prepareLaunch;
if (prepare) {
launch = await prepare(launch);
if (runtimes.get(sessionId)?.generation !== generation) return getSession(sessionId) ?? null;
}
// Debug launches read the agent's output lines: never through a terminal.
if ((options.launch || launch.isTest) && launch.emulateTerminal) launch = { ...launch, emulateTerminal: false };
if (launch.emulateTerminal) rt.terminal = new TerminalOutput();
patchSession(sessionId, { terminal: !!launch.emulateTerminal });
const info = await call(commands.runStart(launch.isTest ? withTestReporting(launch) : launch, channel));
if (runtimes.get(sessionId)?.generation !== generation) return getSession(sessionId) ?? null;
if (info.pty && rt.terminalSize) void commands.runResize(info.id, rt.terminalSize.cols, rt.terminalSize.rows);
// The process may already have exited (fast commands) before we got its id.
const exited = pendingExits.get(info.id);
patchSession(sessionId, { runId: info.id, status: "running" });
// "Redirect input from": stdin is the file, the console takes no input.
patchSession(sessionId, { runId: info.id, status: "running", stdinClosed: !!launch.stdinFile });
if (exited !== undefined) {
pendingExits.delete(info.id);
finishSession(sessionId, exited);
@@ -465,6 +544,16 @@ export async function startRun(
return getSession(sessionId) ?? null;
}
/** "Show console when a message is printed to stdout / stderr". */
function revealSession(sessionId: string) {
try {
if (useRun.getState().activeSession !== sessionId) useRun.setState({ activeSession: sessionId });
if (!toolWindowService().isVisible("run")) toolWindowService().show("run");
} catch {
/* no workbench (tests) */
}
}
const pathSeparator = () => (typeof navigator !== "undefined" && /win/i.test(navigator.platform) ? ";" : ":");
/**
@@ -684,6 +773,23 @@ export async function sendStdin(sessionId: string, text: string): Promise<void>
}
}
/** Keystrokes typed into a console that emulates a terminal, sent as they are. */
export function writeTerminal(sessionId: string, data: string) {
const session = getSession(sessionId);
if (!session || session.runId === null || session.status !== "running") return;
void call(commands.runStdin(session.runId, data)).catch(() => undefined);
}
/** The terminal console's size in cells; the process's terminal follows it. */
export function resizeTerminal(sessionId: string, cols: number, rows: number) {
const rt = runtimes.get(sessionId);
if (!rt || cols < 1 || rows < 1) return;
rt.terminalSize = { cols, rows };
const session = getSession(sessionId);
if (session?.runId != null && session.status === "running" && isTauri())
void call(commands.runResize(session.runId, cols, rows)).catch(() => undefined);
}
export async function closeStdin(sessionId: string): Promise<void> {
const session = getSession(sessionId);
if (!session || session.runId === null || session.status !== "running") return;
@@ -697,6 +803,7 @@ export async function closeStdin(sessionId: string): Promise<void> {
export function clearConsole(sessionId: string) {
runtimes.get(sessionId)?.console.clear();
runtimes.get(sessionId)?.terminal?.clear();
bump(sessionId);
}
+149
Переглянути файл
@@ -0,0 +1,149 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { render } from "@testing-library/react";
const ipc = vi.hoisted(() => ({
runStart: vi.fn((_config: unknown, _channel: unknown) =>
Promise.resolve({
status: "ok" as const,
data: { id: 9, configId: "c", name: "c", pid: 1, running: true, pty: true },
}),
),
runStdin: vi.fn((_id: unknown, _data: unknown) => Promise.resolve({ status: "ok" as const, data: null })),
runResize: vi.fn((_id: unknown, _c: unknown, _r: unknown) => Promise.resolve({ status: "ok" as const, data: null })),
}));
vi.mock("@/platform/ipc", async (importOriginal) => {
const real = await importOriginal<typeof import("@/platform/ipc")>();
return { ...real, isTauri: () => true, commands: { ...real.commands, ...ipc } };
});
vi.mock("@tauri-apps/api/core", async (importOriginal) => ({
...(await importOriginal<typeof import("@tauri-apps/api/core")>()),
Channel: class {
onmessage: ((m: unknown) => void) | null = null;
},
}));
import type { OutputChunk, RunConfig } from "@/platform/ipc";
import {
provideEmbeddedTerminal,
provideNotificationService,
provideToolWindowService,
registerRunConfigType,
type EmbeddedTerminalOptions,
type ToolWindowService,
} from "@/workbench/contributions";
import { optionPatch } from "./ConfigForm";
import { resetRuns, resizeTerminal, runtime, startRun, useRun, writeTerminal } from "./store";
import { TerminalConsole } from "./TerminalConsole";
import { TerminalOutput, terminalText } from "./terminalOutput";
import { terminalApplies, withParams } from "./types";
const config = (extra: Partial<RunConfig> = {}): RunConfig => ({
id: "app",
name: "app",
kind: "shell",
command: "top",
args: [],
cwd: null,
env: {},
detected: false,
isTest: false,
debugAdapter: null,
...extra,
});
const lastChannel = () => ipc.runStart.mock.calls.at(-1)![1] as { onmessage: (m: OutputChunk) => void };
beforeEach(() => {
resetRuns();
for (const f of Object.values(ipc)) f.mockClear();
provideToolWindowService({ show: vi.fn(), isVisible: () => true } as unknown as ToolWindowService);
provideNotificationService({ notify: () => undefined });
});
afterEach(() => provideEmbeddedTerminal(undefined));
describe("Emulate terminal in output console", () => {
it("routes the output to the terminal and sends keystrokes and size as they are", async () => {
const session = (await startRun(config({ emulateTerminal: true })))!;
expect(session.terminal).toBe(true);
const rt = runtime(session.id)!;
lastChannel().onmessage({ kind: "stdout", text: "\x1b[1mhi\x1b[0m" });
expect(rt.terminal!.text).toBe("\x1b[1mhi\x1b[0m");
expect(rt.console.lines.length).toBe(0);
writeTerminal(session.id, "q");
expect(ipc.runStdin).toHaveBeenCalledWith(9, "q");
resizeTerminal(session.id, 100, 40);
expect(ipc.runResize).toHaveBeenCalledWith(9, 100, 40);
});
it("is never used for debug launches, and plain runs keep the console", async () => {
const debug = (await startRun(config({ emulateTerminal: true }), undefined, undefined, {
launch: config({ emulateTerminal: true }),
}))!;
expect(ipc.runStart.mock.calls[0]![0]).toMatchObject({ emulateTerminal: false });
expect(debug.terminal).toBe(false);
resetRuns();
const plain = (await startRun(config({ id: "plain" })))!;
expect(plain.terminal).toBe(false);
expect(runtime(plain.id)!.terminal).toBeNull();
});
it("applies the type's launch preparation", async () => {
const dispose = registerRunConfigType({
id: "prepared",
label: "Prepared",
icon: "run",
fields: [],
commandLine: () => ({ command: "x", args: [] }),
prepareLaunch: async (c) => ({ ...c, env: { ...c.env, DOCKER_HOST: "tcp://box:2375" } }),
});
await startRun(config({ kind: "prepared" }));
expect(ipc.runStart.mock.calls[0]![0]).toMatchObject({ env: { DOCKER_HOST: "tcp://box:2375" } });
dispose();
});
it("applies to process types only, not tests or compounds", () => {
expect(terminalApplies(config())).toBe(true);
expect(terminalApplies(config({ isTest: true }))).toBe(false);
expect(terminalApplies(config({ kind: "compound" }))).toBe(false);
expect(optionPatch("isTest", true)).toEqual({ isTest: true, emulateTerminal: false });
});
it("keeps the type's stop command with the command line", () => {
const type = {
id: "t",
label: "T",
icon: "run",
fields: [],
commandLine: (p: Readonly<Record<string, string>>) => ({ command: "run", args: [], stop: p.stop || null }),
};
const stopped = withParams(config(), { stop: "docker stop app" }, type);
expect(stopped.stopCommand).toBe("docker stop app");
expect("stopCommand" in withParams(stopped, {}, type)).toBe(false);
});
it("replays the output into a terminal console and forwards its input", async () => {
const written: string[] = [];
let opts: EmbeddedTerminalOptions | null = null;
provideEmbeddedTerminal(async (_host, o) => {
opts = o;
return { write: (d) => written.push(d), clear: vi.fn(), focus: vi.fn(), fit: vi.fn(), dispose: vi.fn() };
});
const output = new TerminalOutput();
output.append({ kind: "system", text: "top\n" });
const onData = vi.fn();
render(<TerminalConsole output={output} cwd="/p" onData={onData} onResize={vi.fn()} aria-label="app terminal" />);
await vi.waitFor(() => expect(written).toEqual(["\x1b[2mtop\r\n\x1b[0m"]));
output.append({ kind: "stdout", text: "x" });
expect(written.at(-1)).toBe("x");
opts!.onData("\r");
expect(onData).toHaveBeenCalledWith("\r");
expect(useRun.getState().sessions).toEqual([]);
});
it("dims IDE messages and colours errors", () => {
expect(terminalText({ kind: "stderr", text: "bad\n" })).toBe("\x1b[31mbad\r\n\x1b[0m");
expect(terminalText({ kind: "stdout", text: "raw\r\n" })).toBe("raw\r\n");
});
});
+58
Переглянути файл
@@ -0,0 +1,58 @@
/**
* Output of a run that emulates a terminal: raw terminal data kept (bounded) so
* a console re-mounted later replays it, and forwarded to the mounted terminal.
*/
import type { OutputChunk } from "@/platform/ipc";
/** Replay buffer bound, in characters (the terminal keeps its own scrollback). */
const MAX_CHARS = 2_000_000;
const DIM = "\x1b[2m";
const RED = "\x1b[31m";
const RESET = "\x1b[0m";
/** What the terminal shows for a chunk: IDE messages dimmed, on their own lines. */
export function terminalText(chunk: OutputChunk): string {
if (chunk.kind === "stdout") return chunk.text;
const text = chunk.text.replace(/\r?\n/g, "\r\n");
return chunk.kind === "system" ? `${DIM}${text}${RESET}` : `${RED}${text}${RESET}`;
}
export class TerminalOutput {
private chunks: string[] = [];
private size = 0;
private sink: ((data: string) => void) | null = null;
private onClear: (() => void) | null = null;
append(chunk: OutputChunk) {
const data = terminalText(chunk);
if (!data) return;
this.chunks.push(data);
this.size += data.length;
while (this.size > MAX_CHARS && this.chunks.length > 1) this.size -= this.chunks.shift()!.length;
this.sink?.(data);
}
/** Attaches a terminal: it gets the output so far, then everything new. Returns the detach function. */
attach(write: (data: string) => void, clear: () => void): () => void {
for (const c of this.chunks) write(c);
this.sink = write;
this.onClear = clear;
return () => {
if (this.sink === write) {
this.sink = null;
this.onClear = null;
}
};
}
clear() {
this.chunks = [];
this.size = 0;
this.onClear?.();
}
get text(): string {
return this.chunks.join("");
}
}
+367
Переглянути файл
@@ -0,0 +1,367 @@
/**
* Built-in run configuration types (Shell Script, Program, npm, Gradle, Maven,
* Compound) and the helpers that turn a type's params into the launched
* command line. Other features contribute types with `registerRunConfigType`.
*/
import type { RunConfig } from "@/platform/ipc";
import {
registerRunConfigType,
runConfigTypes,
useRunConfigTypes,
type RunConfigTypeContribution,
} from "@/workbench/contributions";
import { formatArgs, parseArgs, uid, uniqueName } from "./configs";
export const COMPOUND = "compound";
const isWindows = () => typeof navigator !== "undefined" && /win/i.test(navigator.platform);
const on = (v: string | undefined) => v === "true";
const baseName = (p: string) => p.replace(/\\/g, "/").split("/").pop() ?? p;
const dirOf = (cwd: string | null | undefined) => cwd ?? "";
/** Values of `args[index]` across detected configurations of `kind`. */
function argsOf(configs: readonly RunConfig[], kind: string, index: number): string[] {
const out = new Set<string>();
for (const c of configs) if (c.kind === kind && c.args[index]) out.add(c.args[index]);
return [...out];
}
const shell: RunConfigTypeContribution = {
id: "shell",
label: "Shell Script",
icon: "file-shell",
description: "Run a script file, script text or command line",
defaults: { mode: "file" },
fields: [
{
key: "mode",
label: "Execute",
control: "select",
options: [
{ value: "file", label: "Script file" },
{ value: "text", label: "Script text" },
{ value: "command", label: "Command line" },
],
},
{ key: "path", label: "Script path", mono: true, required: true, visible: (p) => p.mode === "file" },
{ key: "options", label: "Script options", mono: true, visible: (p) => p.mode === "file" },
{
key: "interpreter",
label: "Interpreter path",
mono: true,
placeholder: "Run the script directly",
visible: (p) => p.mode === "file",
},
{
key: "interpreterOptions",
label: "Interpreter options",
mono: true,
visible: (p) => p.mode === "file" && !!p.interpreter,
},
{
key: "script",
label: "Script text",
control: "textarea",
mono: true,
required: true,
visible: (p) => p.mode === "text",
},
{
key: "command",
label: "Command",
mono: true,
required: true,
placeholder: "e.g. ./gradlew or cargo",
visible: (p) => p.mode === "command",
},
{
key: "args",
label: "Arguments",
mono: true,
placeholder: "Space separated; quote with ' or \"",
visible: (p) => p.mode === "command",
},
],
commandLine(p) {
if (p.mode === "text") return { command: p.script ?? "", args: [] };
if (p.mode === "file") {
const path = p.path ?? "";
if (!p.interpreter?.trim()) return { command: path, args: parseArgs(p.options ?? "") };
return {
command: p.interpreter.trim(),
args: [...parseArgs(p.interpreterOptions ?? ""), path, ...parseArgs(p.options ?? "")],
};
}
return { command: p.command ?? "", args: parseArgs(p.args ?? "") };
},
paramsFrom: (c) => ({ mode: "command", command: c.command, args: formatArgs(c.args) }),
};
const program: RunConfigTypeContribution = {
id: "program",
label: "Program",
icon: "file-binary",
description: "Run an executable with arguments",
fields: [
{ key: "program", label: "Executable", mono: true, required: true, placeholder: "Path or command on PATH" },
{ key: "arguments", label: "Program arguments", mono: true },
],
commandLine: (p) => ({ command: p.program ?? "", args: parseArgs(p.arguments ?? "") }),
paramsFrom: (c) =>
c.args.length || !/\s/.test(c.command) ? { program: c.command, arguments: formatArgs(c.args) } : null,
};
const PACKAGE_MANAGERS = ["npm", "pnpm", "yarn", "bun"];
const NPM_COMMANDS = ["run", "test", "start", "install", "ci", "publish", "pack"];
const npm: RunConfigTypeContribution = {
id: "npm",
label: "npm",
icon: "file-npm",
description: "Run a package.json script",
defaults: { packageManager: "npm", command: "run" },
ownsWorkingDirectory: true,
fields: [
{ key: "packageDir", label: "package.json directory", mono: true, placeholder: "Project root" },
{ key: "command", label: "Command", control: "select", options: NPM_COMMANDS.map((v) => ({ value: v, label: v })) },
{
key: "script",
label: "Script",
mono: true,
required: true,
visible: (p) => p.command === "run",
suggestions: (configs) => argsOf(configs, "npm", 1),
},
{ key: "arguments", label: "Arguments", mono: true },
{
key: "packageManager",
label: "Package manager",
control: "select",
options: PACKAGE_MANAGERS.map((v) => ({ value: v, label: v })),
},
],
commandLine(p) {
const command = p.command || "run";
const args = [command, ...(command === "run" ? [p.script ?? ""] : []), ...parseArgs(p.arguments ?? "")];
return { command: p.packageManager || "npm", args, cwd: p.packageDir?.trim() || null };
},
paramsFrom(c) {
const [command, ...rest] = c.args;
if (!PACKAGE_MANAGERS.includes(c.command) || !command || !NPM_COMMANDS.includes(command)) return null;
const script = command === "run" ? (rest.shift() ?? "") : "";
return { packageManager: c.command, command, script, arguments: formatArgs(rest), packageDir: dirOf(c.cwd) };
},
};
function wrapperCommand(unix: string, windows: string, plain: string, wrapper: boolean): string {
if (!wrapper) return plain;
return isWindows() ? windows : `./${unix}`;
}
function buildToolType(
id: string,
label: string,
icon: string,
tasksLabel: string,
wrapper: { unix: string; windows: string; plain: string; label: string; byDefault: boolean },
): RunConfigTypeContribution {
return {
id,
label,
icon,
description: `Run ${label} ${tasksLabel.toLowerCase()}`,
defaults: wrapper.byDefault ? { wrapper: "true" } : {},
ownsWorkingDirectory: true,
fields: [
{ key: "projectDir", label: `${label} project`, mono: true, placeholder: "Project root" },
{
key: "tasks",
label: tasksLabel,
mono: true,
required: true,
suggestions: (configs) => argsOf(configs, id, 0),
},
{ key: "arguments", label: "Arguments", mono: true },
{ key: "wrapper", label: wrapper.label, control: "checkbox" },
],
commandLine: (p) => ({
command: wrapperCommand(wrapper.unix, wrapper.windows, wrapper.plain, on(p.wrapper)),
args: [...parseArgs(p.tasks ?? ""), ...parseArgs(p.arguments ?? "")],
cwd: p.projectDir?.trim() || null,
}),
paramsFrom(c) {
const name = baseName(c.command);
const usesWrapper = name === wrapper.unix || name === wrapper.windows;
if (!usesWrapper && name !== wrapper.plain) return null;
return {
projectDir: dirOf(c.cwd),
tasks: formatArgs(c.args),
arguments: "",
...(usesWrapper ? { wrapper: "true" } : {}),
};
},
};
}
const gradle = buildToolType("gradle", "Gradle", "file-gradle", "Tasks", {
unix: "gradlew",
windows: "gradlew.bat",
plain: "gradle",
label: "Use the Gradle wrapper",
byDefault: true,
});
const maven = buildToolType("maven", "Maven", "file-maven", "Goals", {
unix: "mvnw",
windows: "mvnw.cmd",
plain: "mvn",
label: "Use the Maven wrapper",
byDefault: false,
});
const compound: RunConfigTypeContribution = {
id: COMPOUND,
label: "Compound",
icon: "gutter-run-all",
description: "Run or debug several configurations together",
fields: [],
commandLine: () => null,
};
export const BUILTIN_TYPES: readonly RunConfigTypeContribution[] = [shell, program, npm, gradle, maven, compound];
export function registerBuiltinTypes(): () => void {
const disposers = BUILTIN_TYPES.map(registerRunConfigType);
return () => disposers.forEach((d) => d());
}
export function typeFor(kind: string): RunConfigTypeContribution | undefined {
return useRunConfigTypes.getState().types.get(kind);
}
export { runConfigTypes };
export const isCompound = (c: Pick<RunConfig, "kind">) => c.kind === COMPOUND;
/**
* The params the type form edits for `config`: its own, else derived from its command
* line; null when the type can't reproduce that command line (edited as a plain command).
*/
export function formParams(config: RunConfig, type = typeFor(config.kind)): Record<string, string> | null {
if (!type) return null;
if (config.params && Object.keys(config.params).length) return { ...config.params };
if (type.id === COMPOUND) return {};
const derived = type.paramsFrom?.(config);
if (!derived) return null;
// Only when the form reproduces exactly what runs today.
const line = type.commandLine(derived);
if (!line) return null;
const sameCwd = !type.ownsWorkingDirectory || (line.cwd ?? null) === (config.cwd ?? null);
return line.command === config.command && formatArgs(line.args) === formatArgs(config.args) && sameCwd
? derived
: null;
}
/** `config` with `params` and the command line (and stop command) they produce. */
export function withParams(config: RunConfig, params: Record<string, string>, type = typeFor(config.kind)): RunConfig {
const line = type?.commandLine(params);
const { stopCommand: _previous, ...rest } = config;
if (!line) return { ...rest, params, command: "", args: [] };
return {
...rest,
params,
command: line.command,
args: line.args,
...(type?.ownsWorkingDirectory ? { cwd: line.cwd ?? null } : {}),
...(line.stop ? { stopCommand: line.stop } : {}),
};
}
/** Whether "Emulate terminal in output console" applies to `config`. */
export function terminalApplies(config: RunConfig, type = typeFor(config.kind)): boolean {
return config.kind !== COMPOUND && !config.isTest && type?.terminal !== false;
}
/** Required params that are empty, by key. */
export function missingParams(config: RunConfig, type = typeFor(config.kind)): Record<string, string> {
const out: Record<string, string> = {};
const params = formParams(config, type);
if (!type || !params) return out;
for (const f of type.fields) {
if (f.required && (f.visible?.(params) ?? true) && !params[f.key]?.trim()) out[f.key] = `${f.label} is required`;
}
return out;
}
/** Template keys copied onto a new configuration ("Edit configuration templates…"). */
const TEMPLATE_KEYS = [
"env",
"cwd",
"isTest",
"debugAdapter",
"allowParallel",
"includeSystemEnv",
"envFiles",
"stdinFile",
"outputFile",
"beforeLaunch",
"showOnStdout",
"showOnStderr",
"openToolWindow",
"focusToolWindow",
"emulateTerminal",
] as const satisfies ReadonlyArray<keyof RunConfig>;
export const templateId = (kind: string) => `template:${kind}`;
/** An empty template for `type` (what a new configuration gets without one). */
export function blankTemplate(type: RunConfigTypeContribution): RunConfig {
return withParams(
{
id: templateId(type.id),
name: type.label,
kind: type.id,
command: "",
args: [],
cwd: null,
env: {},
detected: false,
isTest: false,
debugAdapter: null,
},
{ ...type.defaults },
type,
);
}
/** A new configuration of `type`, with the type's defaults and then its template applied. */
export function newConfigOfType(
type: RunConfigTypeContribution,
configs: readonly RunConfig[],
template?: RunConfig | null,
folder?: string | null,
): RunConfig {
const base: RunConfig = {
id: uid(),
name: uniqueName("Unnamed", configs),
kind: type.id,
command: "",
args: [],
cwd: null,
env: {},
detected: false,
isTest: false,
debugAdapter: null,
local: true,
...(folder ? { folder } : {}),
};
const copied: Partial<RunConfig> = {};
if (template) {
for (const key of TEMPLATE_KEYS) {
const v = template[key];
if (v !== undefined) (copied as Record<string, unknown>)[key] = structuredClone(v);
}
}
const params = { ...type.defaults, ...(template?.params ?? {}) };
return withParams({ ...base, ...copied }, params, type);
}
+9 -2
Переглянути файл
@@ -5,7 +5,7 @@
import { registerActions } from "@/platform/actions";
import { onFileOperation } from "@/platform/fileOps";
import { emitLocalIdeEvent } from "@/platform/ipc";
import { registerOverlay, registerProjectViewRoot } from "@/workbench/contributions";
import { registerMenuGroup, registerOverlay, registerProjectViewRoot } from "@/workbench/contributions";
import { ScratchChooserHost } from "./ScratchChooser";
import { openChooser } from "./store";
import { scratchBackend, scratchesAvailable, scratchesDir } from "./scratches";
@@ -25,7 +25,7 @@ export function activate(): () => void {
.then((path) => {
if (disposed) return;
dir = path;
unregisterRoot = registerProjectViewRoot({ id: "scratches", label: SCRATCHES_ROOT_LABEL, icon: "scratches", path, order: 10 });
unregisterRoot = registerProjectViewRoot({ id: "scratches", label: SCRATCHES_ROOT_LABEL, icon: "scratches", path, order: 10, scope: "scratches" });
})
.catch((e: unknown) => console.warn("scratch files: no scratches folder", e));
const disposers = [
@@ -35,6 +35,13 @@ export function activate(): () => void {
if (removed.length) emitLocalIdeEvent({ type: "fsChanged", created: [], modified: [], removed });
}),
registerOverlay({ id: "scratch.chooser", component: ScratchChooserHost }),
// IDEA lists it near the top of the New popup.
registerMenuGroup({
id: "scratch.new",
menus: ["new"],
order: 0,
items: () => [{ kind: "action", action: "file.newScratchFile", label: "Scratch File" }],
}),
registerActions([
{
id: "file.newScratchFile",
+148
Переглянути файл
@@ -0,0 +1,148 @@
import { beforeEach, describe, expect, it } from "vitest";
import { buildBookmarkTree } from "../bookmarks/BookmarksView";
import {
DEFAULT_LIST_ID,
createList,
deleteList,
loadBookmarks,
moveBookmarks,
renameList,
renamePathBookmarks,
setDefaultList,
toggleBookmark,
togglePathBookmarks,
updateBookmarkData,
updateBookmarks,
useBookmarks,
validateListName,
type BookmarkData,
} from "../bookmarks/store";
const data = (): BookmarkData => ({
items: [{ path: "/p/a.ts", line: 1, text: "a", created: 1, list: DEFAULT_LIST_ID }],
paths: [],
lists: [{ id: DEFAULT_LIST_ID, name: "p" }],
defaultList: DEFAULT_LIST_ID,
});
describe("bookmark lists", () => {
it("creates, renames and validates lists", () => {
const { data: d, id } = createList(data(), " Review ");
expect(d.lists).toEqual([
{ id: DEFAULT_LIST_ID, name: "p" },
{ id, name: "Review" },
]);
expect(validateListName(d.lists, "Review")).toMatch(/already exists/);
expect(validateListName(d.lists, "Review", id)).toBeNull();
expect(validateListName(d.lists, " ")).toMatch(/empty/);
expect(renameList(d, id, "Later").lists[1]!.name).toBe("Later");
});
it("moves bookmarks between lists and changes the default", () => {
const { data: d0, id } = createList(data(), "Two");
let d = togglePathBookmarks(d0, [{ path: "/p/src", dir: true }], 5);
d = moveBookmarks(d, [{ path: "/p/a.ts", line: 1 }, { path: "/p/src" }], id);
expect(d.items[0]!.list).toBe(id);
expect(d.paths[0]!.list).toBe(id);
// Unknown target lists are ignored.
expect(moveBookmarks(d, [{ path: "/p/src" }], "nope")).toBe(d);
d = setDefaultList(d, id);
expect(d.defaultList).toBe(id);
d = togglePathBookmarks(d, [{ path: "/p/b.ts", dir: false }], 6);
expect(d.paths.find((p) => p.path === "/p/b.ts")!.list).toBe(id);
});
it("deletes a list with its bookmarks, keeping a default", () => {
const { data: d0, id } = createList(data(), "Two");
const d = deleteList(setDefaultList(moveBookmarks(d0, [{ path: "/p/a.ts", line: 1 }], id), id), id, "p");
expect(d.items).toEqual([]);
expect(d.lists.map((l) => l.id)).toEqual([DEFAULT_LIST_ID]);
expect(d.defaultList).toBe(DEFAULT_LIST_ID);
const last = deleteList(d, DEFAULT_LIST_ID, "fresh");
expect(last.lists).toEqual([{ id: DEFAULT_LIST_ID, name: "fresh" }]);
});
it("toggles file / folder bookmarks together and follows removals", () => {
let d = togglePathBookmarks(data(), [{ path: "/p/x.ts", dir: false }], 1);
d = togglePathBookmarks(
d,
[
{ path: "/p/x.ts", dir: false },
{ path: "/p/dir", dir: true },
],
2,
);
expect(d.paths.map((p) => p.path)).toEqual(["/p/dir", "/p/x.ts"]);
d = togglePathBookmarks(d, [
{ path: "/p/x.ts", dir: false },
{ path: "/p/dir", dir: true },
]);
expect(d.paths).toEqual([]);
const paths = togglePathBookmarks(data(), [{ path: "/p/dir/f.ts", dir: false }]).paths;
expect(renamePathBookmarks(paths, "/p/dir", "/p/new").map((p) => p.path)).toEqual(["/p/new/f.ts"]);
expect(renamePathBookmarks(paths, "/p/dir", null)).toEqual([]);
});
it("groups a list's folders, bookmarked files and line bookmarks", () => {
const d = togglePathBookmarks(
data(),
[
{ path: "/p/a.ts", dir: false },
{ path: "/p/lib", dir: true },
],
1,
);
const [list] = buildBookmarkTree(d);
expect(list).toMatchObject({ kind: "list", isDefault: true, count: 3 });
const children = list!.kind === "list" ? list!.children : [];
expect(children.map((n) => n.kind)).toEqual(["folder", "file"]);
expect(children[1]).toMatchObject({ kind: "file", bookmarked: true });
});
});
describe("bookmark list persistence", () => {
beforeEach(() => localStorage.clear());
it("reads bookmarks saved before lists into the default list", () => {
localStorage.setItem(
"openidea.bookmarks.v1:/proj",
JSON.stringify([{ path: "/proj/a", line: 2, text: "", created: 1 }]),
);
loadBookmarks("/proj");
const s = useBookmarks.getState();
expect(s.lists).toEqual([{ id: DEFAULT_LIST_ID, name: "proj" }]);
expect(s.items[0]!.list).toBe(DEFAULT_LIST_ID);
});
it("persists lists, the default and file bookmarks; new line bookmarks join the default list", () => {
loadBookmarks("/q");
let id = "";
updateBookmarkData((d) => {
const made = createList(d, "Two");
id = made.id;
return togglePathBookmarks(setDefaultList(made.data, made.id), [{ path: "/q/f", dir: false }]);
});
updateBookmarks((l) => toggleBookmark(l, "/q/a", 0, "x"));
loadBookmarks("/other");
loadBookmarks("/q");
const s = useBookmarks.getState();
expect(s.lists.map((l) => l.name)).toEqual(["q", "Two"]);
expect(s.defaultList).toBe(id);
expect(s.paths).toMatchObject([{ path: "/q/f", list: id }]);
expect(s.items).toMatchObject([{ path: "/q/a", list: id }]);
// Line bookmarks stay readable by older versions (same key, plain array).
expect(JSON.parse(localStorage.getItem("openidea.bookmarks.v1:/q")!)).toHaveLength(1);
});
it("ignores corrupt list storage", () => {
localStorage.setItem(
"openidea.bookmarkLists.v1:/c",
JSON.stringify({ lists: [{ id: 3 }], defaultList: "x", paths: [1] }),
);
loadBookmarks("/c");
const s = useBookmarks.getState();
expect(s.lists).toEqual([{ id: DEFAULT_LIST_ID, name: "c" }]);
expect(s.defaultList).toBe(DEFAULT_LIST_ID);
expect(s.paths).toEqual([]);
});
});
+53 -1
Переглянути файл
@@ -4,7 +4,14 @@ import { EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { BookmarkPopupsOverlay, bookmarkRows } from "../bookmarks/BookmarkPopups";
import { bookmarkGutter } from "../bookmarks/gutter";
import { loadBookmarks, updateBookmarks, useBookmarkPopups, useBookmarks, type Bookmark } from "../bookmarks/store";
import {
loadBookmarks,
updateBookmarkData,
updateBookmarks,
useBookmarkPopups,
useBookmarks,
type Bookmark,
} from "../bookmarks/store";
beforeEach(() => {
localStorage.clear();
@@ -41,6 +48,8 @@ describe("Bookmarks popup", () => {
{ path: "/p/b", line: 0, text: "zero", created: 3 },
]);
expect(bookmarkRows(useBookmarks.getState().items).map((r) => r.kind)).toEqual(["file", "mark", "mark", "file", "mark"]);
const { items, lists } = useBookmarks.getState();
expect(bookmarkRows(items, [], lists).map((r) => r.kind)).toEqual(["list", "file", "mark", "mark", "file", "mark"]);
render(<BookmarkPopupsOverlay />);
act(() => useBookmarkPopups.setState({ list: true }));
const list = screen.getByRole("listbox", { name: "Bookmarks" });
@@ -56,6 +65,49 @@ describe("Bookmarks popup", () => {
});
});
describe("Bookmarks popup with file and folder bookmarks", () => {
it("groups by list: folders, then files with their line bookmarks; folders select in the project view", async () => {
const actions = await import("@/platform/actions");
const run = vi.spyOn(actions, "executeAction").mockResolvedValue(undefined as never);
act(() =>
updateBookmarkData((d) => ({
...d,
lists: [...d.lists, { id: "list2", name: "Review" }],
items: [
{ path: "/p/a.ts", line: 3, text: "three", created: 1, list: "default" },
{ path: "/p/c.ts", line: 0, text: "zero", created: 1, list: "list2" },
],
paths: [
{ path: "/p/src", dir: true, list: "default", created: 1 },
{ path: "/p/a.ts", dir: false, list: "default", created: 1 },
{ path: "/p/b.ts", dir: false, list: "list2", created: 1 },
],
})),
);
const { items, paths, lists } = useBookmarks.getState();
expect(bookmarkRows(items, paths, lists).map((r) => r.key)).toEqual([
"l:default",
"d:default:/p/src",
"p:default:/p/a.ts",
"b:/p/a.ts:3",
"l:list2",
"p:list2:/p/b.ts",
"f:list2:/p/c.ts",
"b:/p/c.ts:0",
]);
render(<BookmarkPopupsOverlay />);
act(() => useBookmarkPopups.setState({ list: true }));
expect(screen.getByText("Review")).toBeInTheDocument();
// The folder is the first selectable row: Enter selects it in the project view.
vi.useFakeTimers();
fireEvent.keyDown(screen.getByRole("listbox", { name: "Bookmarks" }), { key: "Enter" });
vi.runAllTimers();
vi.useRealTimers();
expect(run).toHaveBeenCalledWith("navigate.selectInProjectView", "/p/src");
run.mockRestore();
});
});
describe("bookmark gutter", () => {
it("⌘/Ctrl-click in the gutter toggles a bookmark before other gutter handlers see it", () => {
const toggle = vi.fn();
+49 -3
Переглянути файл
@@ -4,7 +4,8 @@ import { registerAction } from "@/platform/actions";
import type { SeDeps } from "../everywhere/controller";
const fakeDeps = (): SeDeps => ({
findFiles: async (q) => (q ? [{ path: "/p/src/OrderController.kt", relPath: "src/OrderController.kt", score: 1, indices: [4, 5, 6] }] : []),
findFiles: async (q) =>
q ? [{ path: "/p/src/OrderController.kt", relPath: "src/OrderController.kt", score: 1, indices: [4, 5, 6] }] : [],
findSymbols: async () => [],
searchText: async () => {},
cancel: () => {},
@@ -17,7 +18,13 @@ const fakeDeps = (): SeDeps => ({
now: () => 0,
});
vi.mock("../everywhere/deps", () => ({ ipcDeps: () => fakeDeps() }));
vi.mock("../everywhere/deps", () => ({
ipcDeps: () => fakeDeps(),
loadLanguages: async () => [
{ id: "kotlin", name: "Kotlin", extensions: ["kt"] },
{ id: "markdown", name: "Markdown", extensions: ["md"] },
],
}));
vi.mock("../find/windowStore", async (orig) => ({
...(await orig<typeof import("../find/windowStore")>()),
ipcFindDeps: () => ({
@@ -142,6 +149,45 @@ describe("Search Everywhere dialog", () => {
expect(useSearchEverywhere.getState().includeNonProject).toBe(true);
});
it("labels the Actions tab's checkbox and assigns a shortcut with ⌥↵", async () => {
const assign = vi.fn();
const disposers = [
registerAction({ id: "test.pizza", title: "Order Pizza", category: "Test", run: () => {} }),
registerAction({ id: "settings.assignShortcut", title: "Assign", hidden: true, run: ({ arg }) => assign(arg) }),
];
render(<SearchEverywhereOverlay />);
act(() => showSearchEverywhere("actions", { query: "order pizza" }));
await tick();
expect(screen.getByLabelText("Include disabled actions")).toBeInTheDocument();
expect(screen.queryByLabelText("Include non-project items")).toBeNull();
fireEvent.keyDown(screen.getByLabelText("Search query"), { key: "Enter", altKey: true });
await tick();
expect(assign).toHaveBeenCalledWith("test.pizza");
expect(useSearchEverywhere.getState().open).toBe(false);
disposers.forEach((d) => d());
});
it("filters Files by file type from the funnel, remembering the choice", async () => {
render(<SearchEverywhereOverlay />);
act(() => showSearchEverywhere("files", { query: "order" }));
await tick();
expect(document.getElementById("se-results")!.textContent).toContain("OrderController");
fireEvent.click(screen.getByRole("button", { name: "Filter" }));
await tick();
fireEvent.click(screen.getByLabelText("Kotlin"));
await tick();
expect(useSearchEverywhere.getState().filters.files.languages).toEqual(["kotlin"]);
expect(JSON.parse(localStorage.getItem("openidea.search.everywhereFilters.v1")!).files.languages).toEqual([
"kotlin",
]);
expect(document.getElementById("se-results")!.textContent).not.toContain("OrderController");
act(() =>
useSearchEverywhere.setState({
filters: { ...useSearchEverywhere.getState().filters, files: { languages: [] } },
}),
);
});
it("searches everywhere when nothing is found in the project", async () => {
render(<SearchEverywhereOverlay />);
act(() => showSearchEverywhere("symbols", { query: "zzz" }));
@@ -160,7 +206,7 @@ describe("Find in Files dialog", () => {
await act(() => new Promise((r) => setTimeout(r, 250)));
expect(screen.getByText("1 match in 1 file")).toBeInTheDocument();
fireEvent.change(screen.getByLabelText("Replace with"), { target: { value: "item" } });
expect(screen.getByText("item")).toBeInTheDocument();
expect(screen.getByText("item", { ignore: "script, style, textarea" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Replace All" })).toBeEnabled();
});
});
+196
Переглянути файл
@@ -0,0 +1,196 @@
import { describe, expect, it, vi } from "vitest";
import type { SearchQuery, SymbolMatch } from "@/platform/ipc";
import { SeController, type SeDeps, type SeSnapshot } from "../everywhere/controller";
import {
DEFAULT_FILTERS,
extensionIndex,
filterActive,
languageOf,
passesFilter,
resolveFilter,
sanitizeFilters,
} from "../everywhere/filters";
import { matchActions, matchSettings, mergeActionResults } from "../everywhere/model";
const flush = () => new Promise((r) => setTimeout(r, 0));
function setup(overrides: Partial<SeDeps> = {}) {
let id = 0;
const deps: SeDeps = {
findFiles: vi.fn(async () => []),
findSymbols: vi.fn(async () => []),
searchText: vi.fn(async () => {}),
cancel: vi.fn(),
nextRequestId: () => ++id,
actions: () => [],
isEnabled: () => true,
recentFiles: async () => [],
recentActions: () => [],
root: () => "/p",
now: () => 0,
...overrides,
};
const snaps: SeSnapshot[] = [];
const c = new SeController(deps, (s) => snaps.push(s));
return { c, deps, last: () => snaps[snaps.length - 1]! };
}
const byExt = extensionIndex([
{ id: "typescript", extensions: ["ts", "d.ts"] },
{ id: "rust", extensions: ["rs"] },
]);
const lang = (p: string) => languageOf(p, byExt);
const fm = (rel: string) => ({ path: `/p/${rel}`, relPath: rel, score: 1, indices: [] });
const sym = (name: string, kind: SymbolMatch["kind"], rel: string): SymbolMatch => ({
name,
kind,
container: null,
path: `/p/${rel}`,
relPath: rel,
range: { start: { line: 0, col: 0 }, end: { line: 0, col: 1 } },
score: 1,
indices: [],
});
describe("Search Everywhere filters", () => {
it("sanitizes stored filters and reports which tabs are filtered", () => {
expect(sanitizeFilters(null)).toEqual(DEFAULT_FILTERS);
const f = sanitizeFilters({ files: { languages: ["rust", 3, "rust"] }, text: { mask: "*.ts" }, extra: 1 });
expect(f.files.languages).toEqual(["rust"]);
expect(f.symbols).toEqual({ languages: [], kinds: [] });
expect(filterActive(f, "files")).toBe(true);
expect(filterActive(f, "text")).toBe(true);
expect(filterActive(f, "classes")).toBe(false);
expect(filterActive(f, "all")).toBe(false);
});
it("maps paths to languages by their longest known extension", () => {
expect(lang("/p/a/main.RS")).toBe("rust");
expect(lang("/p/types.d.ts")).toBe("typescript");
expect(lang("/p/README")).toBeNull();
});
it("keeps unknown file types and buckets unlisted kinds", () => {
const f = { languageOf: lang, excludedLanguages: ["rust"], excludedKinds: ["module"] };
expect(passesFilter(f, "/p/a.rs")).toBe(false);
expect(passesFilter(f, "/p/a.txt")).toBe(true);
expect(passesFilter(f, "/p/a.ts", "package")).toBe(false);
expect(passesFilter(f, "/p/a.ts", "function")).toBe(true);
});
it("filters Files by file type", async () => {
const { c, last } = setup({ findFiles: async () => [fm("a.rs"), fm("b.ts"), fm("c.md")] });
const filters = { ...DEFAULT_FILTERS, files: { languages: ["rust"] } };
c.run({
query: "a",
tab: "files",
includeNonProject: false,
filter: resolveFilter(filters, "files", lang),
extra: {},
});
await flush();
expect(
last()
.sections.get("files")!
.items.map((i) => i.key),
).toEqual(["f:/p/b.ts", "f:/p/c.md"]);
});
it("filters Symbols by kind and language", async () => {
const findSymbols = vi.fn(async () => [
sym("run", "function", "a.rs"),
sym("Run", "class", "b.ts"),
sym("runner", "variable", "c.ts"),
]);
const { c, last } = setup({ findSymbols });
const filters = { ...DEFAULT_FILTERS, symbols: { languages: ["rust"], kinds: ["variable"] } };
c.run({
query: "run",
tab: "symbols",
includeNonProject: false,
filter: resolveFilter(filters, "symbols", lang),
extra: {},
});
await flush();
expect(
last()
.sections.get("symbols")!
.items.map((i) => (i.kind === "symbol" ? i.name : "")),
).toEqual(["Run"]);
expect(findSymbols).toHaveBeenLastCalledWith("run", "all", 4 * 50 + 1);
});
it("passes the Text tab's file mask to the search", async () => {
const searchText = vi.fn(async (_r: number, _q: SearchQuery) => {});
const { c } = setup({ searchText });
const filters = { ...DEFAULT_FILTERS, text: { mask: " *.ts, !*.test.* " } };
c.run({
query: "needle",
tab: "text",
includeNonProject: false,
filter: resolveFilter(filters, "text", undefined),
extra: {},
});
await flush();
expect(searchText.mock.calls.at(-1)![1].include).toEqual(["*.ts, !*.test.*"]);
});
it("hides disabled actions unless included", async () => {
const actions = [
{ id: "a.on", title: "Reformat Code", run: () => {} },
{ id: "a.off", title: "Reformat File", run: () => {} },
];
const { c, last } = setup({ actions: () => actions, isEnabled: (a) => a.id === "a.on" });
c.run({ query: "reformat", tab: "actions", includeNonProject: false, includeDisabled: false, extra: {} });
await flush();
expect(
last()
.sections.get("actions")!
.items.map((i) => i.key),
).toEqual(["a:a.on"]);
c.run({ query: "reformat", tab: "actions", includeNonProject: true, includeDisabled: true, extra: {} });
await flush();
expect(
last()
.sections.get("actions")!
.items.map((i) => i.key),
).toEqual(["a:a.on", "a:a.off"]);
});
});
describe("Settings in Search Everywhere", () => {
const entries = [
{ pageId: "editor.general", title: "General", path: ["Editor"] },
{ pageId: "editor.general", title: "Show line numbers", path: ["Editor", "General"] },
{ pageId: "appearance", title: "Appearance", path: [], keywords: ["theme"] },
];
it("matches option labels, page titles and keywords", () => {
expect(matchSettings(entries, "line num").map((s) => s.title)).toEqual(["Show line numbers"]);
expect(matchSettings(entries, "theme").map((s) => s.pageId)).toEqual(["appearance"]);
expect(matchSettings(entries, "")).toEqual([]);
});
it("ranks settings below equally good actions", () => {
const actions = matchActions([{ id: "x", title: "Show Line Numbers", run: () => {} }], "show line", {
isEnabled: () => true,
recent: [],
});
const merged = mergeActionResults(actions, matchSettings(entries, "show line"));
expect(merged.map((i) => i.kind)).toEqual(["action", "setting"]);
});
it("lists settings with actions once the index answers", async () => {
const { c, last } = setup({
actions: () => [{ id: "x", title: "Toggle Line Numbers", run: () => {} }],
settings: async () => entries,
});
c.run({ query: "line numbers", tab: "actions", includeNonProject: false, extra: {} });
await flush();
await flush();
const items = last().sections.get("actions")!.items;
expect(items.map((i) => i.kind)).toEqual(["action", "setting"]);
expect(last().sections.get("actions")!.loading).toBe(false);
});
});
+15
Переглянути файл
@@ -66,4 +66,19 @@ describe("File Structure popup", () => {
fireEvent.keyDown(input, { key: "Escape" });
expect(useFileStructurePopup.getState().open).toBe(false);
});
it("offers Show inherited members only when the server knows type hierarchies", () => {
open();
expect(screen.queryByLabelText("Show inherited members")).toBeNull();
const inherited = [{ item: item("toString", "method", 40), path: "/lib/Object.kt", owner: "Any" }];
act(() => useOutline.setState({ inheritedSupported: true, inherited: new Map([["0:4", inherited]]) }));
expect(rows()).toEqual(["OrderController", "list", "create", "helper"]);
fireEvent.click(screen.getByLabelText("Show inherited members"));
expect(useStructureOptions.getState().showInherited).toBe(true);
expect(rows()).toEqual(["OrderController", "list", "create", "toString↑Any", "helper"]);
act(() => {
useStructureOptions.setState({ showInherited: false });
useOutline.setState({ inheritedSupported: false, inherited: null });
});
});
});
+50 -1
Переглянути файл
@@ -1,5 +1,17 @@
import { describe, expect, it } from "vitest";
import { FILE_SET_SCOPES, buildQuery, describeQuery, findHistory, rememberFindString, useFindPopup } from "../find/store";
import { useCustomScopes } from "@/platform/search";
import {
FILE_SET_SCOPES,
buildQuery,
customScopeValue,
describeQuery,
findHistory,
rememberFindString,
resolveFindScope,
useFindPopup,
type NamedScope,
} from "../find/store";
import { scopesProblem } from "../find/ScopesDialog";
import { selectRow, selectedRows, type RowSelection } from "../find/rowSelection";
describe("Find in Files named scopes", () => {
@@ -15,6 +27,43 @@ describe("Find in Files named scopes", () => {
});
});
describe("Find in Files module and custom scopes", () => {
const inputs = { root: "/p", openPaths: () => ["/p/a.ts"], recentPaths: null };
const base = useFindPopup.getState();
it("a module searches its folder, the root module the project", () => {
expect(resolveFindScope({ ...base, scopeTab: "module", module: "web" }, inputs)).toEqual({
kind: "directory",
path: "/p/web",
});
expect(resolveFindScope({ ...base, scopeTab: "module", module: "" }, inputs)).toEqual({ kind: "project" });
});
it("named scopes resolve to file sets, files and custom patterns", () => {
useCustomScopes.setState({ root: null, scopes: [{ name: "No tests", pattern: "!file:*test*" }] });
const scope = (namedScope: NamedScope) => resolveFindScope({ ...base, scopeTab: "scope", namedScope }, inputs);
expect(scope("productionFiles")).toEqual({ kind: "fileSet", set: "production" });
expect(scope("openFiles")).toEqual({ kind: "files", paths: ["/p/a.ts"] });
expect(scope(customScopeValue("No tests"))).toEqual({ kind: "pattern", pattern: "!file:*test*" });
expect(scope(customScopeValue("gone"))).toEqual({ kind: "project" });
const q = buildQuery({ ...base, pattern: "x" }, scope(customScopeValue("No tests")), 0);
expect(describeQuery(q, "/p")).toBe("'x' in Scope 'No tests'");
useCustomScopes.setState({ root: null, scopes: [] });
});
it("the scope editor needs unique names and a pattern", () => {
expect(scopesProblem([{ name: "a", pattern: "*.ts" }])).toBeNull();
expect(scopesProblem([{ name: " ", pattern: "*.ts" }])).toBe("Every scope needs a name");
expect(
scopesProblem([
{ name: "a", pattern: "*.ts" },
{ name: "a ", pattern: "*.rs" },
]),
).toBe("Duplicate scope name 'a'");
expect(scopesProblem([{ name: "a", pattern: "" }])).toBe("Scope 'a' has no pattern");
});
});
describe("Find in Files search context and history", () => {
it("sends the context filter only when it narrows the search", () => {
const base = useFindPopup.getState();
Loaded 100 of 426 files, more files were not shown because too many files have changed in this diff. Show more