feat(cargo): dependency version inspections and updates (#684)

Cargo.toml had no way to tell that a dependency had a newer release.

Dependencies in [dependencies], [dev-dependencies], [build-dependencies], target tables and [workspace.dependencies] now get a weak warning when the newest stable release is outside the version requirement, with a quick fix that rewrites the requirement at its original precision ("0.7" → "0.10") in both string and inline-table forms. Versions complete inside the version string, newest first, without yanked releases. Path, git and workspace-inherited dependencies are skipped, prereleases are only suggested to prerelease requirements, and failures stay silent.

Versions come from the crates.io sparse index through the backend's HTTP client, with an in-memory and on-disk cache and coalesced requests, behind a dependency version source shared by file-type parsers so other manifests can be added later. semver is now used directly; it was already linked into the app.

Fixes #479
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-11-01 19:30:28 +08:00
1 джерело 4034853f91
коміт b5dbd3c020
26 files changed
+1257 -25

No files matched your search

+20
Переглянути файл
@@ -327,6 +327,7 @@ export const commands = {
pathCompletions: (from: IpcPath, prefix: string) => typedError<PathCompletion[], IdeError>(__TAURI_INVOKE("path_completions", { from, prefix })),
/** Paths in an HTML document or stylesheet that name no existing file. */
webUnresolvedReferences: (doc: DocId) => typedError<FileReference[], IdeError>(__TAURI_INVOKE("web_unresolved_references", { doc })),
dependencyVersions: (doc: DocId, offset: number | null, request: RequestId) => typedError<DependencyVersions[], IdeError>(__TAURI_INVOKE("dependency_versions", { doc, offset, request })),
/**
* Misspelt words in the comments, string literals and declared names of
* `doc` that `scope` includes (all of its text for prose). Cancel with
@@ -2468,6 +2469,17 @@ export type DeletedBranch = {
unmerged: boolean,
};
export type DependencyUpdate = {
latest: string,
replacement: string,
};
export type DependencyVersions = {
dependency: ManifestDependency,
versions: string[],
update: DependencyUpdate | null,
};
/** Where a local file is deployed. */
export type DeployedPath = {
server: string,
@@ -4289,6 +4301,14 @@ export type MainToolbarSettings = {
hidden?: string[],
};
export type ManifestDependency = {
package: string,
requirement: string,
/** UTF-16 offsets of the string's contents (quotes excluded). */
from: number,
to: number,
};
export type Markup = {
/** `markdown` or `plaintext`. */
kind: string,
+13 -1
Переглянути файл
@@ -21,6 +21,7 @@ import {
Annotation,
Compartment,
Facet,
StateEffect,
type EditorState,
type Extension,
type RangeSet,
@@ -332,14 +333,25 @@ export function contributedAt<T>(state: EditorState, key: string, pos: number):
return state.languageDataAt<T>(key, pos)[0];
}
/** A context action a feature offers at the caret, listed by Show Context Actions (⌥↵) before the language server's. */
/** A context action a feature offers at the caret, listed by Show Context Actions (⌥↵). */
export interface ContributedIntention {
id: string;
label: string;
icon?: string;
quickFix?: boolean;
run: () => void | Promise<void>;
}
/** A background provider has new context actions for the current document. */
export const intentionsChanged = StateEffect.define<null>();
/** Local fixes of a diagnostic range (hover link and Apply Quick Fix). */
export const quickFixProviders = Facet.define<(view: EditorView, from: number, to: number) => ContributedIntention[]>();
export function contributedQuickFixes(view: EditorView, from: number, to: number): ContributedIntention[] {
return view.state.facet(quickFixProviders).flatMap((provider) => provider(view, from, to));
}
/** Providers of `ContributedIntention`s for the caret of `view` (cheap and synchronous: also drives the light bulb). */
export const intentionProviders = Facet.define<(view: EditorView) => ContributedIntention[]>();
+5 -2
Переглянути файл
@@ -12,7 +12,7 @@ import type { Diagnostic, Severity } from "@/bindings";
import { commands, ideEventsReady, isTauri, onIdeEvent } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { publishLocalDiagnostics } from "@/platform/localDiagnostics";
import { scrollbarMarks, type ScrollbarMark } from "@/editor/api";
import { intentionsChanged, scrollbarMarks, type ScrollbarMark } from "@/editor/api";
import { rangeToOffsets } from "./positions";
import { lspDoc } from "./lsp";
import { showHint } from "./hint";
@@ -199,7 +199,10 @@ const diagnosticsSync = ViewPlugin.fromClass(
if (!path || !this.view.dom.isConnected) return;
const lint = toLintDiagnostics(this.view.state.doc, diagnosticsFor(path));
const tr = setDiagnostics(this.view.state, lint);
this.view.dispatch({ ...tr, effects: [...[tr.effects ?? []].flat(), setMarks.of(diagnosticMarks(lint))] });
this.view.dispatch({
...tr,
effects: [...[tr.effects ?? []].flat(), setMarks.of(diagnosticMarks(lint)), intentionsChanged.of(null)],
});
});
}
destroy() {
+1 -1
Переглянути файл
@@ -98,7 +98,7 @@ function renderDiagnostics(view: EditorView, diags: PlacedDiagnostic[]): HTMLEle
fix.className = "cm-lsp-link-button";
const keys = shortcutLabel("code.showIntentions");
fix.textContent = keys ? `More actions… ${keys}` : "More actions…";
if (d.severity === "error" || d.severity === "warning") offerQuickFix(view, meta, fix, from, to);
if (d.severity !== "hint") offerQuickFix(view, meta, fix, from, to);
fix.addEventListener("mousedown", (e) => e.preventDefault());
fix.addEventListener("click", () => {
view.dispatch({ selection: EditorSelection.cursor(from) });
+23
Переглянути файл
@@ -1,6 +1,7 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { setDiagnostics } from "@codemirror/lint";
import type { CodeAction } from "@/bindings";
const resolved: CodeAction = {
@@ -35,6 +36,7 @@ const {
intentionPreview,
lightsBulb,
listedCodeActions,
listedIntentions,
withInspectionControls,
} = await import("./intentions");
const { setFileDiagnostics } = await import("./diagnostics");
@@ -90,6 +92,27 @@ describe("inspection controls in ⌥↵ (issue #42)", () => {
setFileDiagnostics("/p/two.py", "ruff", []);
expect(withInspectionControls(view, [])).toEqual([]);
});
it.each(["info", "warning", "error"] as const)(
"uses the effective %s severity for local and LSP fixes",
(severity) => {
const path = `/p/severity-${severity}.py`;
setFileDiagnostics(path, "ruff", [{ ...unused, severity }]);
const view = editorAt(path);
view.dispatch(setDiagnostics(view.state, [{ from: 7, to: 9, severity, message: unused.message }]));
const items = listedIntentions(view, [
{ id: "refactor", label: "Extract method", icon: "bulb", run: vi.fn() },
{ id: "local", label: "Local fix", quickFix: true, run: vi.fn() },
{ id: "lsp", label: "LSP fix", quickFix: true, emphasis: true, run: vi.fn() },
]);
expect(items.map((i) => i.id)).toEqual(["lsp", "local", "refactor"]);
expect(items.slice(0, 2).map((i) => i.icon)).toEqual(Array(2).fill(severity === "error" ? "bulb-fix" : "bulb"));
const controls = withInspectionControls(view, items);
expect(controls.slice(0, 2).every((i) => i.submenu?.length === 2)).toBe(true);
expect(controls[2]!.submenu).toBeUndefined();
setFileDiagnostics(path, "ruff", []);
},
);
});
describe("intention preview", () => {
+73 -21
Переглянути файл
@@ -9,7 +9,13 @@ import { forEachDiagnostic } from "@codemirror/lint";
import { indentUnit } from "@codemirror/language";
import { diff } from "@codemirror/merge";
import { commands, isCancelled, type CodeAction } from "@/platform/ipc";
import { contributedIntentions, intentionProviders, whenSynced } from "@/editor/api";
import {
contributedIntentions,
contributedQuickFixes,
intentionProviders,
intentionsChanged,
whenSynced,
} from "@/editor/api";
import { shortcutLabel } from "@/platform/keymap";
import { debounce, docOf, isUnavailable, lspRequest, reportLspError } from "./lsp";
import { offsetsToRange } from "./positions";
@@ -32,6 +38,27 @@ const codeActionBackoff = featureBackoff();
const isQuickFix = (a: CodeAction) => !!a.kind && (a.kind === "quickfix" || a.kind.startsWith("quickfix."));
/** The red bulb is reserved for errors; other inspections use the ordinary bulb. */
export function quickFixIcon(view: EditorView): "bulb" | "bulb-fix" {
const sel = view.state.selection.main;
const line = view.state.doc.lineAt(sel.head);
let error = false;
forEachDiagnostic(view.state, (d, from, to) => {
if (d.severity === "error" && from <= line.to && to >= line.from) error = true;
});
return error ? "bulb-fix" : "bulb";
}
const isFixItem = (item: ListPopupItem) => item.quickFix ?? item.icon === "bulb-fix";
/** Local and language-server fixes share icons and the same stable ordering. */
export function listedIntentions(view: EditorView, items: ListPopupItem[]): ListPopupItem[] {
const rank = (item: ListPopupItem) => (item.disabled ? 4 : 0) + (isFixItem(item) ? 0 : 2) + (item.emphasis ? 0 : 1);
return items
.map((item) => (isFixItem(item) ? { ...item, quickFix: true, icon: quickFixIcon(view) } : item))
.sort((a, b) => rank(a) - rank(b));
}
/** IDEA ordering: quick fixes first, preferred before others, disabled last; stable otherwise. */
export function sortCodeActions(actions: readonly CodeAction[]): CodeAction[] {
const rank = (a: CodeAction) => (a.disabledReason ? 4 : 0) + (isQuickFix(a) ? 0 : 2) + (a.isPreferred ? 0 : 1);
@@ -104,15 +131,23 @@ async function fetchIntentions(view: EditorView): Promise<CodeAction[]> {
return actions;
}
/** Enabled quick fixes of the problem at `from..to`, preferred first. */
export async function quickFixesAt(view: EditorView, from: number, to: number): Promise<CodeAction[]> {
export type EditorQuickFix = CodeAction | { title: string; run: () => void | Promise<void> };
/** Enabled quick fixes of the problem at `from..to`, including local inspections. */
export async function quickFixesAt(view: EditorView, from: number, to: number): Promise<EditorQuickFix[]> {
const local = contributedQuickFixes(view, from, to).map((fix) => ({ title: fix.label, run: fix.run }));
const doc = docOf(view);
if (!doc) return [];
await whenSynced(view);
const actions = await lspRequest(() =>
commands.lspCodeActions(doc.doc, offsetsToRange(view.state.doc, from, to), ["quickfix"]),
);
return sortCodeActions(actions.filter((a) => isQuickFix(a) && !a.disabledReason));
if (!doc) return local;
try {
await whenSynced(view);
const actions = await lspRequest(() =>
commands.lspCodeActions(doc.doc, offsetsToRange(view.state.doc, from, to), ["quickfix"]),
);
return [...local, ...sortCodeActions(actions.filter((a) => isQuickFix(a) && !a.disabledReason))];
} catch (e) {
if (local.length) return local;
throw e;
}
}
/**
@@ -144,7 +179,8 @@ export async function applyQuickFix(view: EditorView) {
showHint(view, "No quick fixes available");
}
export async function applyCodeAction(view: EditorView, action: CodeAction) {
export async function applyCodeAction(view: EditorView, action: EditorQuickFix) {
if ("run" in action) return action.run();
const doc = docOf(view);
if (!doc) return;
if (action.disabledReason) {
@@ -276,9 +312,10 @@ function actionItems(
resolved?: Map<CodeAction, Promise<CodeAction>>,
): ListPopupItem[] {
return listedCodeActions(actions).map((a, i) => ({
id: `${i}`,
id: `lsp:${i}`,
label: a.title,
icon: isQuickFix(a) ? "bulb-fix" : "bulb",
icon: isQuickFix(a) ? quickFixIcon(view) : "bulb",
quickFix: isQuickFix(a),
emphasis: a.isPreferred,
disabled: a.disabledReason,
// The edit the preview already resolved is applied as is.
@@ -344,7 +381,7 @@ export function withInspectionControls(view: EditorView, items: ListPopupItem[])
(i) => i.id !== SPELLING_INSPECTION,
);
if (!inspections.length) return items;
const fixes = items.filter((i) => i.icon === "bulb-fix" && !i.submenu);
const fixes = items.filter((i) => isFixItem(i) && !i.submenu);
if (inspections.length === 1 && fixes.length) {
const submenu = intentionControls(inspections[0]!);
return items.map((i) => (fixes.includes(i) ? { ...i, submenu } : i));
@@ -364,7 +401,7 @@ export function withInspectionControls(view: EditorView, items: ListPopupItem[])
/** ⌥↵ */
export async function showIntentions(view: EditorView) {
try {
// Typo fixes and feature-contributed actions come first and need no language server.
// Local inspections need no language server.
const local: ListPopupItem[] = [...spellingIntentions(view), ...contributedIntentions(view)];
let actions: CodeAction[] = [];
let failure: { error: unknown } | null = null;
@@ -376,7 +413,9 @@ export async function showIntentions(view: EditorView) {
}
const sorted = listedCodeActions(actions);
const resolved = new Map<CodeAction, Promise<CodeAction>>();
const items = withInspectionControls(view, [...local, ...actionItems(view, sorted, resolved)]);
const serverItems = actionItems(view, sorted, resolved);
const previews = new Map(serverItems.map((item, i) => [item.id, sorted[i]!]));
const items = withInspectionControls(view, listedIntentions(view, [...local, ...serverItems]));
if (!items.length) {
if (failure) throw failure.error;
showHint(view, "No intentions available");
@@ -388,7 +427,7 @@ export async function showIntentions(view: EditorView) {
title: "Intentions",
items,
preview: (item, signal) => {
const action = sorted[items.indexOf(item) - local.length];
const action = previews.get(item.id);
return action ? intentionPreview(view, action, resolved, signal) : Promise.resolve(null);
},
});
@@ -500,7 +539,13 @@ const bulbPlugin = ViewPlugin.fromClass(
this.lookup();
}
update(u: ViewUpdate) {
if (u.docChanged || u.selectionSet || (u.focusChanged && u.view.hasFocus)) this.lookup();
if (
u.docChanged ||
u.selectionSet ||
(u.focusChanged && u.view.hasFocus) ||
u.transactions.some((tr) => tr.effects.some((e) => e.is(intentionsChanged)))
)
this.lookup();
else if (u.focusChanged) {
this.lookup.cancel();
// A blur dispatched from inside the update is not allowed.
@@ -516,16 +561,16 @@ const bulbPlugin = ViewPlugin.fromClass(
if (!doc || doc.readOnly || !this.view.dom.isConnected || !this.view.hasFocus) return;
const state = this.view.state;
const typoFix = spellingIntentions(this.view).length > 0;
const local = typoFix || contributedIntentions(this.view).length > 0;
const selection = state.selection.main;
const ownFix = contributedQuickFixes(this.view, selection.from, selection.to).length > 0;
const local = typoFix || ownFix || contributedIntentions(this.view).length > 0;
const backoff = codeActionBackoff.active(doc.doc);
if (backoff && !local) return;
let actions: CodeAction[] = [];
if (!backoff) {
try {
actions = await fetchIntentions(this.view);
} catch (e) {
if (isUnavailable(e)) codeActionBackoff.mark(doc.doc);
if (!local) return;
}
}
if (this.view.state.doc !== state.doc || !this.view.state.selection.eq(state.selection) || !this.view.hasFocus)
@@ -533,7 +578,14 @@ const bulbPlugin = ViewPlugin.fromClass(
const line = state.doc.lineAt(state.selection.main.head).number;
const any = actions.some(lightsBulb) || local;
this.view.dispatch({
effects: setBulb.of(any ? { line, quickFix: typoFix || actions.some(isQuickFix) } : null),
effects: setBulb.of(
any
? {
line,
quickFix: (typoFix || ownFix || actions.some(isQuickFix)) && quickFixIcon(this.view) === "bulb-fix",
}
: null,
),
});
}
destroy() {
+2
Переглянути файл
@@ -18,6 +18,8 @@ export interface ListPopupItem {
/** Second line / muted text after the label (code preview). */
preview?: string;
icon?: string;
/** Inspection fix, independent of the icon chosen for its severity. */
quickFix?: boolean;
/** Symbol badge kind (`ui/icons` `SymbolBadge`) shown instead of an icon. */
symbol?: string;
emphasis?: boolean;
+210
Переглянути файл
@@ -0,0 +1,210 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { CompletionContext } from "@codemirror/autocomplete";
import type { DependencyVersions } from "@/bindings";
const dependencyVersions = vi.fn();
const cancelRequest = vi.fn(async () => {});
const lspCodeActions = vi.fn(async () => ({ status: "error", error: { kind: "unavailable", message: "No server" } }));
vi.mock("@/platform/ipc", async (orig) => {
const actual = await orig<typeof import("@/platform/ipc")>();
return {
...actual,
isTauri: () => true,
commands: { ...actual.commands, dependencyVersions, cancelRequest, lspCodeActions },
};
});
const { activate, versionCompletions } = await import("./index");
const { editorContributions, contributedIntentions } = await import("@/editor/api");
const { diagnosticsFor } = await import("@/editor/lsp/diagnostics");
const { lspDoc } = await import("@/editor/lsp/lsp");
const { quickFixesAt, applyCodeAction, showIntentions } = await import("@/editor/lsp/intentions");
const { useLspPopup, closePopup } = await import("@/editor/lsp/popupStore");
const { useProject } = await import("@/platform/project");
const { DEPENDENCY_INSPECTION } = await import("@/platform/inspections");
const text = '# 中文 😀\n[dependencies]\ntokio = { version = "0.2", features = ["full"] }\n';
const from = text.indexOf('"0.2"') + 1;
const item: DependencyVersions = {
dependency: { package: "tokio", requirement: "0.2", from, to: from + 3 },
versions: ["2.0.0-beta.1", "1.10.0", "1.9.0", "0.2.1"],
update: { latest: "1.10.0", replacement: "1.10" },
};
let deactivate: () => void;
const views: EditorView[] = [];
beforeEach(() => {
vi.useFakeTimers();
dependencyVersions.mockResolvedValue({ status: "ok", data: [item] });
useProject.setState({ project: { root: "/p" } as never, settings: {} as never });
deactivate = activate();
});
afterEach(() => {
views.splice(0).forEach((view) => view.destroy());
deactivate();
closePopup();
useProject.setState({ project: null, settings: null });
vi.clearAllMocks();
vi.useRealTimers();
});
function editor(path = "/p/Cargo.toml", readOnly = false) {
const ctx = { doc: 7, path, language: "toml", uiMode: "TOML", readOnly };
const contribution = editorContributions().find((c) => c.id === "dependency-versions")!;
const view = new EditorView({
parent: document.body,
state: EditorState.create({
doc: text,
selection: { anchor: from },
extensions: [lspDoc.of(ctx), contribution.extensions(ctx)],
}),
});
views.push(view);
return view;
}
async function settle() {
await vi.advanceTimersByTimeAsync(550);
}
describe("Cargo dependency versions", () => {
it("publishes a weak warning and fixes only the version through context actions", async () => {
const view = editor();
await settle();
expect(dependencyVersions).toHaveBeenCalledWith(7, null, expect.any(Number));
expect(diagnosticsFor("/p/Cargo.toml")).toMatchObject([
{
severity: "info",
message: "A newer version is available: 1.10.0",
range: { start: { line: 2, col: 21 }, end: { line: 2, col: 24 } },
},
]);
const [fix] = contributedIntentions(view);
expect(fix?.label).toBe("Update to 1.10");
await fix!.run();
expect(view.state.doc.toString()).toBe(text.replace('"0.2"', '"1.10"'));
expect(diagnosticsFor("/p/Cargo.toml")).toEqual([]);
});
it("provides direct and hover quick fixes even without a language server", async () => {
const view = editor();
await settle();
const [fix] = await quickFixesAt(view, from, from + 3);
expect(fix?.title).toBe("Update to 1.10");
await applyCodeAction(view, fix!);
expect(view.state.doc.toString()).toContain('version = "1.10", features = ["full"]');
});
it.each(["0.10", "^0.10", "=0.10.3"])(
"shows the exact replacement %s with weak-warning inspection controls",
async (replacement) => {
dependencyVersions.mockResolvedValue({
status: "ok",
data: [{ ...item, update: { latest: "0.10.3", replacement } }],
});
const view = editor();
await settle();
await showIntentions(view);
const popup = useLspPopup.getState().popup;
expect(popup?.kind).toBe("list");
if (popup?.kind !== "list") throw new Error("Missing intentions");
expect(popup.title).toBe("Intentions");
expect(popup.items).toHaveLength(1);
expect(popup.items[0]).toMatchObject({ label: `Update to ${replacement}`, icon: "bulb", quickFix: true });
expect(popup.items[0]!.submenu).toHaveLength(2);
await popup.items[0]!.run();
expect(view.state.doc.toString()).toBe(text.replace('"0.2"', `"${replacement}"`));
},
);
it("keeps diagnostics when one of two split editors closes", async () => {
editor();
const split = editor();
await settle();
split.destroy();
views.pop();
expect(diagnosticsFor("/p/Cargo.toml")).toHaveLength(1);
});
it("invalidates fixes immediately after edits and rejects a stale fix callback", async () => {
const view = editor();
await settle();
const [fix] = contributedIntentions(view);
view.dispatch({ changes: { from: 0, insert: "# edit\n" } });
expect(contributedIntentions(view)).toEqual([]);
await fix!.run();
expect(view.state.doc.toString()).toBe("# edit\n" + text);
});
it("discards delayed responses after an edit, project switch or editor close", async () => {
let resolve!: (value: unknown) => void;
dependencyVersions.mockImplementation(
() =>
new Promise((r) => {
resolve = r;
}),
);
const view = editor();
await settle();
view.dispatch({ changes: { from: 0, insert: "# edit\n" } });
resolve({ status: "ok", data: [item] });
await vi.advanceTimersByTimeAsync(0);
expect(diagnosticsFor("/p/Cargo.toml")).toEqual([]);
await settle();
useProject.setState({ project: { root: "/other" } as never });
resolve({ status: "ok", data: [item] });
await settle();
expect(diagnosticsFor("/p/Cargo.toml")).toEqual([]);
expect(cancelRequest).toHaveBeenCalled();
const other = editor("/other/Cargo.toml");
await settle();
other.destroy();
views.pop();
resolve({ status: "ok", data: [item] });
await settle();
expect(diagnosticsFor("/other/Cargo.toml")).toEqual([]);
});
it("stays silent offline, with compatible versions and for non-Cargo or readonly files", async () => {
dependencyVersions.mockRejectedValue(new Error("offline"));
editor();
await settle();
expect(diagnosticsFor("/p/Cargo.toml")).toEqual([]);
dependencyVersions.mockResolvedValue({ status: "ok", data: [{ ...item, update: null }] });
editor("/p/sub/Cargo.toml");
await settle();
expect(diagnosticsFor("/p/sub/Cargo.toml")).toEqual([]);
dependencyVersions.mockClear();
editor("/p/other.toml");
editor("/p/readonly/Cargo.toml", true);
await settle();
expect(dependencyVersions).not.toHaveBeenCalled();
});
it("honours the inspection profile and cleans up on close", async () => {
const view = editor();
await settle();
useProject.setState({
settings: { inspections: { default: { [DEPENDENCY_INSPECTION]: { enabled: false } } } } as never,
});
expect(diagnosticsFor("/p/Cargo.toml")).toEqual([]);
expect(contributedIntentions(view)).toEqual([]);
view.destroy();
views.pop();
await settle();
expect(diagnosticsFor("/p/Cargo.toml")).toEqual([]);
});
it("completes only at a parsed dependency version, preserving descending source order", async () => {
const view = editor();
const source = view.state.languageDataAt<(ctx: CompletionContext) => Promise<unknown>>(
"openideaCompletion",
from,
)[0]!;
const result = await source(new CompletionContext(view.state, from + 3, true, view));
expect(result).toMatchObject({
from,
to: from + 3,
filter: false,
options: item.versions.map((label) => ({ label })),
});
expect(dependencyVersions).toHaveBeenLastCalledWith(7, from + 3, expect.any(Number));
dependencyVersions.mockResolvedValue({ status: "ok", data: [] });
expect(await source(new CompletionContext(view.state, 0, true, view))).toBeNull();
});
it("replaces the whole version and retains a simple requirement operator", () => {
const state = EditorState.create({ doc: "^1.2" });
const context = new CompletionContext(state, 4, true);
const result = versionCompletions({ ...item, dependency: { ...item.dependency, from: 0, to: 4 } }, context);
expect(result.options[0]).toMatchObject({ label: "2.0.0-beta.1", apply: "^2.0.0-beta.1" });
});
});
+208
Переглянути файл
@@ -0,0 +1,208 @@
import { EditorState, StateEffect, StateField, type Extension } from "@codemirror/state";
import { ViewPlugin, type EditorView } from "@codemirror/view";
import type { CompletionContext, CompletionResult } from "@codemirror/autocomplete";
import {
intentionProviders,
intentionsChanged,
quickFixProviders,
registerEditorContribution,
whenSynced,
type ContributedIntention,
type EditorDocContext,
} from "@/editor/api";
import { setFileDiagnostics } from "@/editor/lsp/diagnostics";
import { offsetsToRange } from "@/editor/lsp/positions";
import { call, commands, isTauri, nextRequestId, type DependencyVersions, type RequestId } from "@/platform/ipc";
import {
DEPENDENCY_INSPECTION,
DEPENDENCY_PRODUCER,
inspectionProfile,
subscribeInspectionProfile,
} from "@/platform/inspections";
import { useProject } from "@/platform/project";
import { tx } from "@/platform/i18n";
const setVersions = StateEffect.define<DependencyVersions[]>();
const versionsField = StateField.define<DependencyVersions[]>({
create: () => [],
update(value, tr) {
if (tr.docChanged) return [];
for (const effect of tr.effects) if (effect.is(setVersions)) return effect.value;
return value;
},
});
const viewers = new Map<string, number>();
const enabled = () => inspectionProfile()[DEPENDENCY_INSPECTION]?.enabled !== false;
export function dependencyFixes(view: EditorView, from: number, to: number): ContributedIntention[] {
if (!enabled() || view.state.readOnly) return [];
const text = view.state.doc;
return (view.state.field(versionsField, false) ?? []).flatMap(({ dependency, update }) => {
if (!update || from > dependency.to || to < dependency.from) return [];
const { replacement } = update;
return [
{
id: `dependency.update:${dependency.from}`,
label: tx`Update to ${replacement}`,
quickFix: true,
run: () => {
if (view.state.doc !== text) return;
view.dispatch({
changes: { from: dependency.from, to: dependency.to, insert: replacement },
userEvent: "input.dependency",
});
},
},
];
});
}
export function versionCompletions(item: DependencyVersions, ctx: CompletionContext): CompletionResult {
const { dependency, versions } = item;
const typed = ctx.state.sliceDoc(dependency.from, ctx.pos);
const prefix = /^[\s^~<>=]*/.exec(typed)?.[0] ?? "";
const filter = typed.slice(prefix.length);
return {
from: dependency.from,
to: dependency.to,
filter: false,
options: versions
.filter((v) => ctx.explicit || v.startsWith(filter))
.map((version) => ({
label: version,
type: "constant",
detail: dependency.package,
apply: prefix + version,
})),
};
}
function extension(doc: EditorDocContext): Extension {
const plugin = ViewPlugin.define((view) => {
if (!isTauri()) return {};
viewers.set(doc.path, (viewers.get(doc.path) ?? 0) + 1);
let timer: ReturnType<typeof setTimeout> | undefined;
let request: RequestId | null = null;
let sequence = 0;
let disposed = false;
const clear = () => setFileDiagnostics(doc.path, DEPENDENCY_PRODUCER, []);
const cancel = () => {
sequence++;
if (request !== null) void commands.cancelRequest(request);
request = null;
};
const run = async () => {
if (disposed || !enabled()) return;
const mine = sequence;
const text = view.state.doc;
const root = useProject.getState().project?.root;
const id = nextRequestId();
request = id;
try {
await whenSynced(view);
if (mine !== sequence || disposed) return;
const items = await call(commands.dependencyVersions(doc.doc, null, id));
if (mine !== sequence || disposed || view.state.doc !== text || useProject.getState().project?.root !== root)
return;
view.dispatch({ effects: [setVersions.of(items), intentionsChanged.of(null)] });
setFileDiagnostics(
doc.path,
DEPENDENCY_PRODUCER,
items.flatMap(({ dependency, update }) => {
if (!update) return [];
const { latest } = update;
return [
{
range: offsetsToRange(text, dependency.from, dependency.to),
severity: "info",
message: tx`A newer version is available: ${latest}`,
source: "Cargo",
code: null,
},
];
}),
);
} catch {
// Offline registries and cancelled checks never interrupt editing.
} finally {
if (request === id) request = null;
}
};
const schedule = () => {
cancel();
clear();
clearTimeout(timer);
timer = setTimeout(() => void run(), 500);
};
schedule();
const offProfile = subscribeInspectionProfile(() => {
schedule();
view.dispatch({ effects: intentionsChanged.of(null) });
});
const offProject = useProject.subscribe((state, previous) => {
if (state.project?.root !== previous.project?.root) {
disposed = true;
cancel();
clearTimeout(timer);
clear();
}
});
return {
update: (u) => {
if (u.docChanged) schedule();
},
destroy: () => {
disposed = true;
cancel();
clearTimeout(timer);
offProfile();
offProject();
const remaining = (viewers.get(doc.path) ?? 1) - 1;
if (remaining) viewers.set(doc.path, remaining);
else {
viewers.delete(doc.path);
clear();
}
},
};
});
const completion = async (ctx: CompletionContext): Promise<CompletionResult | null> => {
if (!isTauri()) return null;
const id = nextRequestId();
const text = ctx.state.doc;
const root = useProject.getState().project?.root;
ctx.addEventListener("abort", () => void commands.cancelRequest(id));
try {
// The completion context is immutable; only use it while its document is current.
const view = ctx.view;
if (!view) return null;
await whenSynced(view);
if (ctx.aborted || view.state.doc !== text) return null;
const items = await call(commands.dependencyVersions(doc.doc, ctx.pos, id));
if (ctx.aborted || view.state.doc !== text || useProject.getState().project?.root !== root) return null;
const item = items.find(({ dependency }) => dependency.from <= ctx.pos && ctx.pos <= dependency.to);
return item ? versionCompletions(item, ctx) : null;
} catch {
return null;
}
};
return [
versionsField,
plugin,
intentionProviders.of((view) => {
const selection = view.state.selection.main;
return dependencyFixes(view, selection.from, selection.to);
}),
quickFixProviders.of(dependencyFixes),
EditorState.languageData.of(() => [{ openideaCompletion: completion }]),
];
}
export function activate(): () => void {
return registerEditorContribution({
id: "dependency-versions",
extensions: (doc) => (!doc.virtual && !doc.readOnly && /(?:^|\/)Cargo\.toml$/.test(doc.path) ? extension(doc) : []),
});
}
+2
Переглянути файл
@@ -10,6 +10,7 @@ import { activate as editorLsp } from "@/editor/lsp";
import { activate as welcome } from "./welcome";
import { activate as projectTree } from "./project-tree";
import { activate as search } from "./search";
import { activate as dependencies } from "./dependencies";
import { activate as codeInsight } from "./code-insight";
import { activate as vcs, activateSettingsPages as vcsPages } from "./vcs";
import { activate as terminal } from "./terminal";
@@ -55,6 +56,7 @@ const features: Array<[string, Activate]> = [
["project-tree", projectTree],
["search", search],
["code-insight", codeInsight],
["dependencies", dependencies],
["vcs", vcs],
["terminal", terminal],
["run", run],
+11
Переглянути файл
@@ -222,6 +222,9 @@ export interface BuiltinInspection {
export const SPELLING_PRODUCER = "spelling";
export const SPELLING_INSPECTION = "builtin:spelling";
export const DEPENDENCY_PRODUCER = "dependency-versions";
export const DEPENDENCY_INSPECTION = "builtin:dependency-versions";
/** IDEA's Typo options: which parts of source files are checked (plain text always is). */
export const SPELLING_OPTIONS = {
code: "processCode",
@@ -231,6 +234,14 @@ export const SPELLING_OPTIONS = {
/** Checks the IDE runs itself (not a language server). */
export const BUILTIN_INSPECTIONS: readonly BuiltinInspection[] = [
{
id: DEPENDENCY_INSPECTION,
producer: DEPENDENCY_PRODUCER,
name: "Newer dependency version available",
group: "Cargo",
description: "Reports newer crate versions that Cargo.toml requirements do not already allow.",
defaultLevel: "weakWarning",
},
{
id: "builtin:file-references",
producer: "file-references",
+4
Переглянути файл
@@ -1,4 +1,8 @@
{
"Newer dependency version available": "有更新的依赖版本可用",
"Reports newer crate versions that Cargo.toml requirements do not already allow.": "报告 Cargo.toml 版本要求尚未允许的较新 crate 版本。",
"Update to {latest}": "更新至 {latest}",
"A newer version is available: {latest}": "有新版本可用:{latest}",
"Kotlin bridge requires a Maven build root": "Kotlin 桥接需要 Maven 构建根目录",
"Kotlin bridge bundle is unavailable": "Kotlin 桥接扩展不可用",
"Kotlin bridge command did not acknowledge its library snapshot": "Kotlin 桥接命令未确认类库更新",
+1
Переглянути файл
@@ -80,6 +80,7 @@ const BUILT_AT_RUNTIME = new Set<string>([]);
/** Text that stays as written in every locale: names, code, formats, shortcuts. */
const UNTRANSLATED = new Set<string>([
// Product names
"Cargo",
"CMake",
"Prettier",
"IntelliJ IDEA",