дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 11:15:40 +02:00
Switching between file tabs destroyed the hidden tab's CodeMirror view and built a new one on return: every switch re-ran the view's plugins, re-measured the height map (the scrollbar jumped) and re-requested inlay hints, usages and diagnostics, which popped in and shifted the text up to a second later. The eight most recently hidden views are now kept detached and re-attached on return, with their decorations, height map and plugin state. Edits while hidden go to the kept view; plugins that skip work while their view is detached catch up when it is shown again (`viewShown`). Switching also forces fewer layouts: the Switcher no longer gives focus back to the editor left behind when it opens another tab, the tab strip only measures the active tab when it isn't known to be in view, and focus moving between editors no longer drops the old editor's DOM caret. The tabs perf scenario counts views rebuilt when switching back (budget 0).
453 рядки
16 KiB
TypeScript
453 рядки
16 KiB
TypeScript
/**
|
||
* Gutter icons for inheritance: "is implemented / overridden" (↓) on types and
|
||
* methods with implementations or overriders, "implements / overrides" (↑) on
|
||
* methods with a super method. Clicking one navigates to its single target or
|
||
* opens the targets chooser.
|
||
*
|
||
* Generic over language servers: ↓ comes from implementation requests (one
|
||
* cancellable batch per pass), ↑ from the type hierarchy and the supertypes'
|
||
* 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";
|
||
import { shownAgain, type EditorDocContext } from "@/editor/api";
|
||
import { docOf, debounce, isUnavailable, lspRequest } from "@/editor/lsp/lsp";
|
||
import { featureBackoff, onServerSettled } from "@/editor/lsp/availability";
|
||
import { readManyFileLines, withDocument } from "@/editor/lsp/files";
|
||
import { locationItems, navigateTo, pinToFind } from "@/editor/lsp/navigation";
|
||
import { openPopup } from "@/editor/lsp/popupStore";
|
||
import {
|
||
commands,
|
||
isCancelled,
|
||
nextRequestId,
|
||
onIdeEvent,
|
||
type HierarchyItem,
|
||
type Location,
|
||
type OutlineItem,
|
||
type Pos,
|
||
type RequestId,
|
||
} from "@/platform/ipc";
|
||
import { editorPrefs, type EditorExtras } from "@/platform/editorSettings";
|
||
import { useProject } from "@/platform/project";
|
||
import { gutterIconMarkup } from "@/ui";
|
||
import {
|
||
MARKER_ICONS,
|
||
candidateKey,
|
||
inheritorsMarker,
|
||
markerCandidates,
|
||
supersMarker,
|
||
withoutSelf,
|
||
type LineMarker,
|
||
type MarkerCandidate,
|
||
type MarkerGroup,
|
||
} from "./lineMarkers";
|
||
import { superMethodTargets, type SuperDeps, type SuperMethod } from "./structureNav";
|
||
import styles from "./LineMarkers.module.css";
|
||
|
||
/** Lines above / below the viewport that are covered too, so scrolling feels instant. */
|
||
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> {
|
||
if (!prefs.showGutterIcons) return new Set();
|
||
return new Set((["inheritors", "supers"] as const).filter((g) => !prefs.gutterIconsDisabled.includes(g)));
|
||
}
|
||
|
||
export interface MarkerDeps {
|
||
/** Implementations of the symbols at `positions`, one list each (rejects when the server cannot answer). */
|
||
implementations: (positions: Pos[]) => Promise<Location[][]>;
|
||
supers: SuperDeps;
|
||
readFiles: (paths: readonly string[]) => Promise<Map<string, string[] | null>>;
|
||
}
|
||
|
||
interface CacheEntry {
|
||
inheritors?: LineMarker | null;
|
||
supers?: LineMarker | null;
|
||
}
|
||
|
||
/** Results for one document text; `unsupported` remembers a server that cannot answer a group. */
|
||
export interface MarkerCache {
|
||
entries: Map<string, CacheEntry>;
|
||
unsupported: Set<MarkerGroup>;
|
||
}
|
||
|
||
export const emptyCache = (): MarkerCache => ({ entries: new Map(), unsupported: new Set() });
|
||
|
||
/**
|
||
* Asks the server about the `candidates` not in `cache` yet (only the enabled
|
||
* `groups`) and stores the answers. Cancellation propagates; an unavailable
|
||
* server marks its group unsupported for this cache.
|
||
*/
|
||
export async function fillMarkers(
|
||
path: string,
|
||
candidates: readonly MarkerCandidate[],
|
||
groups: ReadonlySet<MarkerGroup>,
|
||
deps: MarkerDeps,
|
||
cache: MarkerCache,
|
||
): Promise<void> {
|
||
const entry = (c: MarkerCandidate) => {
|
||
const key = candidateKey(c);
|
||
let e = cache.entries.get(key);
|
||
if (!e) cache.entries.set(key, (e = {}));
|
||
return e;
|
||
};
|
||
const down =
|
||
groups.has("inheritors") && !cache.unsupported.has("inheritors")
|
||
? candidates.filter((c) => c.inheritable && cache.entries.get(candidateKey(c))?.inheritors === undefined)
|
||
: [];
|
||
const up =
|
||
groups.has("supers") && !cache.unsupported.has("supers")
|
||
? candidates.filter((c) => c.overriding && c.owner && cache.entries.get(candidateKey(c))?.supers === undefined)
|
||
: [];
|
||
|
||
let implementations: Location[][] = [];
|
||
if (down.length) {
|
||
try {
|
||
implementations = (await deps.implementations(down.map((c) => ({ line: c.line, col: c.col })))).map((locs, i) =>
|
||
withoutSelf(path, down[i]!, locs),
|
||
);
|
||
} catch (e) {
|
||
if (isCancelled(e)) throw e;
|
||
if (isUnavailable(e)) cache.unsupported.add("inheritors");
|
||
down.length = 0;
|
||
}
|
||
}
|
||
const supers: SuperMethod[][] = [];
|
||
for (const c of up) supers.push(await superMethodTargets(path, c.owner!, c.symbol, deps.supers));
|
||
|
||
const files = await deps.readFiles([
|
||
...implementations.flat().map((l) => l.path),
|
||
...supers.flat().map((s) => s.location.path),
|
||
]);
|
||
down.forEach((c, i) => (entry(c).inheritors = inheritorsMarker(c, implementations[i] ?? [], files)));
|
||
up.forEach((c, i) => (entry(c).supers = supersMarker(c, supers[i]!, files)));
|
||
}
|
||
|
||
/** Markers known for `candidates`, in document order (↓ before ↑ on a line). */
|
||
export function cachedMarkers(
|
||
candidates: readonly MarkerCandidate[],
|
||
groups: ReadonlySet<MarkerGroup>,
|
||
cache: MarkerCache,
|
||
): LineMarker[] {
|
||
const out: LineMarker[] = [];
|
||
for (const c of candidates) {
|
||
const e = cache.entries.get(candidateKey(c));
|
||
if (!e) continue;
|
||
if (groups.has("inheritors") && e.inheritors) out.push(e.inheritors);
|
||
if (groups.has("supers") && e.supers) out.push(e.supers);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/** Opens the single target, or the chooser over several. */
|
||
export async function openMarkerTargets(marker: LineMarker, anchor: { x: number; y: number }) {
|
||
if (marker.targets.length === 1) {
|
||
await navigateTo(marker.targets[0]!);
|
||
return;
|
||
}
|
||
openPopup({
|
||
kind: "list",
|
||
anchor,
|
||
title: marker.chooserTitle,
|
||
items: await locationItems(marker.targets),
|
||
pin: pinToFind(marker.name, marker.targets),
|
||
});
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// CodeMirror
|
||
|
||
interface PlacedMarker {
|
||
marker: LineMarker;
|
||
/** Line start offset in the current state. */
|
||
pos: number;
|
||
}
|
||
|
||
const setMarkers = StateEffect.define<PlacedMarker[]>();
|
||
|
||
const markersField = StateField.define<PlacedMarker[]>({
|
||
create: () => [],
|
||
update(value, tr) {
|
||
for (const e of tr.effects) if (e.is(setMarkers)) return e.value;
|
||
if (!tr.docChanged || !value.length) return value;
|
||
return value.map((m) => ({ ...m, pos: tr.state.doc.lineAt(tr.changes.mapPos(m.pos, -1)).from }));
|
||
},
|
||
});
|
||
|
||
function place(doc: Text, markers: readonly LineMarker[]): PlacedMarker[] {
|
||
return markers.filter((m) => m.line < doc.lines).map((marker) => ({ marker, pos: doc.line(marker.line + 1).from }));
|
||
}
|
||
|
||
const sameMarkers = (a: readonly PlacedMarker[], b: readonly PlacedMarker[]) =>
|
||
a.length === b.length &&
|
||
a.every(
|
||
(m, i) => m.pos === b[i]!.pos && m.marker.kind === b[i]!.marker.kind && m.marker.tooltip === b[i]!.marker.tooltip,
|
||
);
|
||
|
||
class InheritanceMarker extends GutterMarker {
|
||
constructor(readonly marker: LineMarker) {
|
||
super();
|
||
}
|
||
override eq(other: GutterMarker): boolean {
|
||
return (
|
||
other instanceof InheritanceMarker &&
|
||
other.marker.kind === this.marker.kind &&
|
||
other.marker.tooltip === this.marker.tooltip
|
||
);
|
||
}
|
||
override toDOM(): Node {
|
||
const el = document.createElement("div");
|
||
el.className = `${styles.marker} cm-openidea-lineMarker-${this.marker.kind}`;
|
||
el.dataset.kind = this.marker.kind;
|
||
el.title = this.marker.tooltip;
|
||
el.setAttribute("role", "button");
|
||
el.setAttribute("aria-label", this.marker.tooltip);
|
||
el.innerHTML = gutterIconMarkup(MARKER_ICONS[this.marker.kind]) ?? "";
|
||
return el;
|
||
}
|
||
}
|
||
|
||
const gutterMarkers = new WeakMap<readonly PlacedMarker[], RangeSet<GutterMarker>>();
|
||
|
||
function markerSet(placed: readonly PlacedMarker[]): RangeSet<GutterMarker> {
|
||
let set = gutterMarkers.get(placed);
|
||
if (!set) {
|
||
set = RangeSet.of(
|
||
placed.map((m) => new InheritanceMarker(m.marker).range(m.pos)),
|
||
true,
|
||
);
|
||
gutterMarkers.set(placed, set);
|
||
}
|
||
return set;
|
||
}
|
||
|
||
class MarkersPlugin {
|
||
private refresh = debounce(() => void this.run(), DEBOUNCE_MS);
|
||
private generation = 0;
|
||
private skipped = false;
|
||
private request: RequestId | null = null;
|
||
private offSettings: () => void;
|
||
|
||
constructor(private view: EditorView) {
|
||
live.add(this);
|
||
watchChanges();
|
||
this.refresh();
|
||
this.offSettings = useProject.subscribe((s, prev) => {
|
||
if (s.settings !== prev.settings) this.refresh();
|
||
});
|
||
}
|
||
|
||
refreshSoon() {
|
||
this.refresh();
|
||
}
|
||
|
||
update(u: ViewUpdate) {
|
||
if (u.docChanged) {
|
||
// Other documents' markers may depend on this one.
|
||
changes++;
|
||
this.refresh();
|
||
} else if (u.viewportChanged || (this.skipped && shownAgain(u))) this.refresh();
|
||
// Other files may have changed meanwhile (a new implementation).
|
||
else if (u.focusChanged && u.view.hasFocus) {
|
||
const ctx = docOf(u.view);
|
||
if (ctx && markersStale(ctx.path, u.view.state.doc)) this.refresh();
|
||
}
|
||
}
|
||
|
||
private cancelRequest() {
|
||
if (this.request === null) return;
|
||
void commands.cancelRequest(this.request).catch(() => {});
|
||
this.request = null;
|
||
}
|
||
|
||
private show(placed: PlacedMarker[]) {
|
||
if (!sameMarkers(this.view.state.field(markersField), placed))
|
||
this.view.dispatch({ effects: setMarkers.of(placed) });
|
||
}
|
||
|
||
private async run() {
|
||
const view = this.view;
|
||
this.skipped = !view.dom.isConnected;
|
||
if (this.skipped) return;
|
||
const ctx = docOf(view);
|
||
if (!ctx) return;
|
||
const gen = ++this.generation;
|
||
this.cancelRequest();
|
||
const groups = enabledGroups(editorPrefs());
|
||
if (!groups.size || gutterBackoff.active(ctx.doc)) {
|
||
if (!groups.size) this.show([]);
|
||
return;
|
||
}
|
||
const text = view.state.doc;
|
||
const stale = () => gen !== this.generation || view.state.doc !== text;
|
||
const entry = markersOf(ctx.path, text);
|
||
try {
|
||
if (!entry.outline) {
|
||
const items = await lspRequest(() => commands.docOutline(ctx.doc));
|
||
if (stale()) return;
|
||
entry.outline = {
|
||
items,
|
||
candidates: markerCandidates(items, (l) => (l < text.lines ? text.line(l + 1).text : "")),
|
||
};
|
||
}
|
||
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, entry.cache), entry.cache);
|
||
if (this.request === request) this.request = null;
|
||
if (stale()) return;
|
||
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);
|
||
}
|
||
}
|
||
|
||
destroy() {
|
||
this.refresh.cancel();
|
||
this.cancelRequest();
|
||
this.offSettings();
|
||
this.generation++;
|
||
live.delete(this);
|
||
}
|
||
}
|
||
|
||
/** LSP access for one pass: this document's outline as known, others' opened on demand, each asked once. */
|
||
function markerDeps(ctx: EditorDocContext, items: OutlineItem[], request: RequestId, cache: MarkerCache): MarkerDeps {
|
||
const supertypes = new Map<string, Promise<HierarchyItem[]>>();
|
||
const outlines = new Map<string, Promise<OutlineItem[]>>([[ctx.path, Promise.resolve(items)]]);
|
||
const hierarchy = (doc: number, pos: Pos) => lspRequest(() => commands.lspTypeHierarchy(doc, pos, "incoming"));
|
||
return {
|
||
implementations: (positions) => lspRequest(() => commands.lspImplementations(request, ctx.doc, positions)),
|
||
supers: {
|
||
supertypes: (path, pos) => {
|
||
const key = `${path}:${pos.line}:${pos.col}`;
|
||
let p = supertypes.get(key);
|
||
if (!p) {
|
||
p =
|
||
path === ctx.path
|
||
? hierarchy(ctx.doc, pos).catch((e) => {
|
||
// No type hierarchy for this file: no ↑ markers until the server changes.
|
||
if (isUnavailable(e)) cache.unsupported.add("supers");
|
||
throw e;
|
||
})
|
||
: withDocument(path, (doc) => hierarchy(doc, pos));
|
||
supertypes.set(key, p);
|
||
}
|
||
return p;
|
||
},
|
||
outline: (path) => {
|
||
let p = outlines.get(path);
|
||
if (!p) outlines.set(path, (p = withDocument(path, (doc) => lspRequest(() => commands.docOutline(doc)))));
|
||
return p;
|
||
},
|
||
},
|
||
readFiles: readManyFileLines,
|
||
};
|
||
}
|
||
|
||
function markerAt(view: EditorView, lineFrom: number, el: Element | null): LineMarker | null {
|
||
const onLine = view.state.field(markersField).filter((m) => m.pos === lineFrom);
|
||
const kind = (el?.closest(`.${styles.marker}`) as HTMLElement | null)?.dataset.kind;
|
||
return (onLine.find((m) => m.marker.kind === kind) ?? (onLine.length === 1 ? onLine[0] : null))?.marker ?? null;
|
||
}
|
||
|
||
export function implementationGutter(ctx: EditorDocContext): Extension {
|
||
if (ctx.doc < 0) return [];
|
||
return [
|
||
markersField,
|
||
ViewPlugin.fromClass(MarkersPlugin),
|
||
gutter({
|
||
class: `${styles.gutter} cm-openidea-gutter-lineMarkers`,
|
||
markers: (view) => markerSet(view.state.field(markersField)),
|
||
domEventHandlers: {
|
||
mousedown: (view, block, event) => {
|
||
const e = event as MouseEvent;
|
||
if (e.button !== 0) return false;
|
||
const target = e.target instanceof Element ? e.target : null;
|
||
const marker = markerAt(view, block.from, target);
|
||
if (!marker) return false;
|
||
e.preventDefault();
|
||
const rect = (target?.closest(`.${styles.marker}`) ?? target)?.getBoundingClientRect();
|
||
void openMarkerTargets(marker, rect ? { x: rect.left, y: rect.bottom + 2 } : { x: e.clientX, y: e.clientY });
|
||
return true;
|
||
},
|
||
},
|
||
}),
|
||
];
|
||
}
|