diff --git a/src/editor/navigation/implementationGutter.ts b/src/editor/navigation/implementationGutter.ts index dec5ce537..3a99aa62c 100644 --- a/src/editor/navigation/implementationGutter.ts +++ b/src/editor/navigation/implementationGutter.ts @@ -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(); +/** + * 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(); +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): Set { @@ -227,40 +294,36 @@ function markerSet(placed: readonly PlacedMarker[]): RangeSet { 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); } } diff --git a/src/editor/navigation/lineMarkers.test.ts b/src/editor/navigation/lineMarkers.test.ts index 11f3ecc09..2ee754081 100644 --- a/src/editor/navigation/lineMarkers.test.ts +++ b/src/editor/navigation/lineMarkers.test.ts @@ -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"]);