fix(editor): keep gutter implementation markers across tab switches (#1321)

Switching tabs remounts the editor view and the marker cache lived in its plugin; focusing an editor dropped the cache too. So every visit re-ran an implementation search per declaration in view, and on jdtls each one walks the whole workspace (#879: java at ~40% CPU while moving between classes). Answers are now cached per file and dropped only when other files change (edits, or source changes on disk; build output ignored) or a language server settles, through one window-wide subscription whose echo guard survives remounts.

Fixes #879
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-11-05 00:31:12 +08:00
1 джерело c2dd466305
коміт c399ad2598
2 files changed
+119 -28

No files matched your search

+87 -28
Переглянути файл
@@ -9,6 +9,9 @@
* document symbols (as Super Method, ⌘U). Only declarations in and near the
* viewport are asked, in one debounced pass after edits / scrolling; results
* are cached per document text, and markers follow their lines in between.
* The cache outlives the editor view (switching tabs remounts it) until other
* files change or a language server settles: on some servers (jdtls) every
* implementation search walks the whole workspace, so asking again is costly.
*/
import { RangeSet, StateEffect, StateField, type Extension, type Text } from "@codemirror/state";
import { EditorView, GutterMarker, ViewPlugin, gutter, type ViewUpdate } from "@codemirror/view";
@@ -22,6 +25,7 @@ import {
commands,
isCancelled,
nextRequestId,
onIdeEvent,
type HierarchyItem,
type Location,
type OutlineItem,
@@ -50,6 +54,69 @@ const MARGIN_LINES = 60;
const MAX_PER_PASS = 60;
const DEBOUNCE_MS = 500;
const gutterBackoff = featureBackoff();
const MAX_CACHED_DOCS = 32;
interface DocMarkers {
doc: Text;
cache: MarkerCache;
outline: { items: OutlineItem[]; candidates: MarkerCandidate[] } | null;
/** `changes` when the cache was started. */
changes: number;
}
/** Marker caches by path, most recently used last. */
const docMarkers = new Map<string, DocMarkers>();
/**
* Bumped when other files may have changed (edited in an editor; saved, created or removed
* on disk): every document's cached answers may be stale then.
*/
let changes = 0;
const live = new Set<MarkersPlugin>();
let watching = false;
/** Build output and tool folders: their changes add no implementations. */
const OUTPUT_PATH =
/(?:^|[\\/])(?:\.git|\.gradle|\.idea|\.openidea|node_modules|target|build|out|bin)(?:[\\/]|$)|\.class$/;
/** Whether changes to `paths` can invalidate cached markers. */
export function affectsMarkers(paths: readonly string[]): boolean {
return paths.some((p) => !OUTPUT_PATH.test(p));
}
/** Subscribes once per window and stays subscribed, so the settle echo guard survives remounts. */
function watchChanges() {
if (watching) return;
watching = true;
onIdeEvent("fsChanged", (e) => {
if (affectsMarkers([...e.created, ...e.modified, ...e.removed])) changes++;
});
// Answers (outlines too) given while a server was starting or indexing may be incomplete.
onServerSettled(() => {
docMarkers.clear();
for (const plugin of live) plugin.refreshSoon();
});
}
/** The cache for `doc` at `path`: kept while its text and other files are unchanged. */
export function markersOf(path: string, doc: Text): DocMarkers {
let entry = docMarkers.get(path);
if (!entry || entry.doc !== doc) entry = { doc, cache: emptyCache(), outline: null, changes };
else if (entry.changes !== changes) entry = { ...entry, cache: emptyCache(), changes };
docMarkers.delete(path);
docMarkers.set(path, entry);
if (docMarkers.size > MAX_CACHED_DOCS) docMarkers.delete(docMarkers.keys().next().value!);
return entry;
}
function markersStale(path: string, doc: Text): boolean {
const entry = docMarkers.get(path);
return !entry || entry.doc !== doc || entry.changes !== changes;
}
/** Records that other files may have changed (tests). */
export function noteOtherChanges() {
changes++;
}
/** Settings › Editor › General › Gutter Icons groups that are on. */
export function enabledGroups(prefs: Pick<EditorExtras, "showGutterIcons" | "gutterIconsDisabled">): Set<MarkerGroup> {
@@ -227,40 +294,36 @@ function markerSet(placed: readonly PlacedMarker[]): RangeSet<GutterMarker> {
class MarkersPlugin {
private refresh = debounce(() => void this.run(), DEBOUNCE_MS);
private cache = emptyCache();
private cacheDoc: Text | null = null;
private outline: { doc: Text; items: OutlineItem[]; candidates: MarkerCandidate[] } | null = null;
private generation = 0;
private request: RequestId | null = null;
private offSettings: () => void;
private offServers: () => void;
constructor(private view: EditorView) {
live.add(this);
watchChanges();
this.refresh();
this.offSettings = useProject.subscribe((s, prev) => {
if (s.settings !== prev.settings) this.refresh();
});
// Answers given while a server was starting or indexing may be incomplete.
this.offServers = onServerSettled(() => {
this.invalidate();
this.refresh();
});
}
refreshSoon() {
this.refresh();
}
update(u: ViewUpdate) {
if (u.docChanged || u.viewportChanged) this.refresh();
if (u.docChanged) {
// Other documents' markers may depend on this one.
changes++;
this.refresh();
} else if (u.viewportChanged) this.refresh();
// Other files may have changed meanwhile (a new implementation).
else if (u.focusChanged && u.view.hasFocus) {
this.invalidate();
this.refresh();
const ctx = docOf(u.view);
if (ctx && markersStale(ctx.path, u.view.state.doc)) this.refresh();
}
}
private invalidate() {
this.cacheDoc = null;
this.outline = null;
}
private cancelRequest() {
if (this.request === null) return;
void commands.cancelRequest(this.request).catch(() => {});
@@ -286,30 +349,26 @@ class MarkersPlugin {
}
const text = view.state.doc;
const stale = () => gen !== this.generation || view.state.doc !== text;
if (this.cacheDoc !== text) {
this.cache = emptyCache();
this.cacheDoc = text;
}
const entry = markersOf(ctx.path, text);
try {
if (this.outline?.doc !== text) {
if (!entry.outline) {
const items = await lspRequest(() => commands.docOutline(ctx.doc));
if (stale()) return;
this.outline = {
doc: text,
entry.outline = {
items,
candidates: markerCandidates(items, (l) => (l < text.lines ? text.line(l + 1).text : "")),
};
}
const { candidates, items } = this.outline;
const { candidates, items } = entry.outline;
const { from, to } = view.viewport;
const first = text.lineAt(from).number - 1 - MARGIN_LINES;
const last = text.lineAt(to).number - 1 + MARGIN_LINES;
const visible = candidates.filter((c) => c.line >= first && c.line <= last).slice(0, MAX_PER_PASS);
const request = (this.request = nextRequestId());
await fillMarkers(ctx.path, visible, groups, markerDeps(ctx, items, request, this.cache), this.cache);
await fillMarkers(ctx.path, visible, groups, markerDeps(ctx, items, request, entry.cache), entry.cache);
if (this.request === request) this.request = null;
if (stale()) return;
this.show(place(text, cachedMarkers(candidates, groups, this.cache)));
this.show(place(text, cachedMarkers(candidates, groups, entry.cache)));
} catch (e) {
if (isUnavailable(e)) gutterBackoff.mark(ctx.doc);
else if (!isCancelled(e)) console.debug("[lineMarkers]", e);
@@ -320,8 +379,8 @@ class MarkersPlugin {
this.refresh.cancel();
this.cancelRequest();
this.offSettings();
this.offServers();
this.generation++;
live.delete(this);
}
}
+32
Переглянути файл
@@ -20,11 +20,15 @@ vi.mock("@/editor/lsp/popupStore", async (orig) => ({
}));
import { candidateKey, markerCandidates, type MarkerCandidate } from "./lineMarkers";
import { Text } from "@codemirror/state";
import {
affectsMarkers,
cachedMarkers,
emptyCache,
enabledGroups,
fillMarkers,
markersOf,
noteOtherChanges,
openMarkerTargets,
type MarkerDeps,
} from "./implementationGutter";
@@ -299,6 +303,34 @@ describe("markers from server answers", () => {
});
});
describe("marker cache across views", () => {
it("keeps a document's answers until its text or other files change", () => {
const doc = Text.of(["interface Shape {", " double area();", "}"]);
const first = markersOf("/p/Shape.java", doc);
first.cache.entries.set("k", { inheritors: null });
// A remounted editor (tab switch) with the same text reuses the answers.
expect(markersOf("/p/Shape.java", doc).cache.entries.has("k")).toBe(true);
// Edited text starts over.
const edited = Text.of(["interface Shape {", " double area();", " double perimeter();", "}"]);
expect(markersOf("/p/Shape.java", edited).cache.entries.size).toBe(0);
// Other files changed: the answers are asked again, the outline is kept.
const again = markersOf("/p/Shape.java", edited);
again.cache.entries.set("k", { inheritors: null });
again.outline = { items: [], candidates: [] };
noteOtherChanges();
const after = markersOf("/p/Shape.java", edited);
expect(after.cache.entries.size).toBe(0);
expect(after.outline).toBe(again.outline);
});
it("ignores changes to build output", () => {
expect(affectsMarkers(["/p/target/classes/a/Shape.class"])).toBe(false);
expect(affectsMarkers(["/p/build/tmp/x.txt", "/p/.git/index", "C:\\p\\out\\Main.class"])).toBe(false);
expect(affectsMarkers(["/p/target/classes/a/Shape.class", "/p/src/main/java/a/Circle.java"])).toBe(true);
expect(affectsMarkers(["/p/src/builder/Shape.java"])).toBe(true);
});
});
describe("settings", () => {
it("follows Settings › Gutter Icons", () => {
expect([...enabledGroups({ showGutterIcons: true, gutterIconsDisabled: [] })]).toEqual(["inheritors", "supers"]);