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