fixed duplicate include file badges

This commit is contained in:
Steven Hugg committed 2026-10-21 14:11:30 +02:00
1 parent 5811892262
commit e6d7ee5ca5
4 files changed
+81 -19

No files matched your search

+1
View File
@@ -41,6 +41,7 @@ See [extension/CHANGELOG.md](extension/CHANGELOG.md).
- Editor moved from CodeMirror 5 to CodeMirror 6 with Lezer grammars for 6502, Z80, 6809, Verilog, BASIC, batari BASIC, FastBasic, Inform 6 and Wiz; new Cobalt and MBO themes and better indent handling.
- Rust syntax highlighting for `.rs` files, via `@codemirror/lang-rust`; the urust toolchain now claims the `rust` editor style instead of falling back to C.
- A `mod foo;` line shows one include-link arrow instead of two (it had linked to both `foo.rs` and `foo/mod.rs`); the arrow now points at whichever file exists.
- Source search across the project and toolchain headers (F1 for help), with arrow-key navigation, plus symbol search without source.
- Shortcut bar and keyboard shortcut help, F8 pause/resume, Cmd+Shift+G go to offset.
- Settings menu with developer options (highlight whitespace, highlight executed lines, line numbers off by default).
+3
View File
@@ -364,6 +364,9 @@ export class SourceEditor implements ProjectView {
// filesystem via the read-only viewer
openHeaderFile(filename);
},
// one directive can offer several candidates (`mod foo;` -> foo.rs
// or foo/mod.rs); link to whichever file exists
(filename: string) => resolveIncludeFile(filename) != null,
),
textTransformFilterCompartment.of([]),
+62 -18
View File
@@ -1,3 +1,4 @@
import { EditorState, Text } from "@codemirror/state";
import { Decoration, DecorationSet, EditorView, ViewPlugin, ViewUpdate, WidgetType } from "@codemirror/view";
import { ToolIncludePattern } from "../../common/toolmeta";
@@ -26,12 +27,31 @@ class IncludeLinkWidget extends WidgetType {
ignoreEvent() { return false; }
}
function buildIncludeLinkDecorations(view: EditorView, patterns: (RegExp | ToolIncludePattern)[], handleClick: (filename: string, system: boolean) => void): DecorationSet {
const widgets: any[] = [];
const doc = view.state.doc;
const seen = new Set<string>(); // dedupe (line number, filename)
for (let { from, to } of view.visibleRanges) {
const text = view.state.sliceDoc(from, to);
/** One include badge: the document position to draw it at (end of its line). */
export interface IncludeLink {
pos: number;
filename: string;
system: boolean;
}
/**
* Find the include badges for the given document ranges. A single directive
* can match several patterns -- urust's `mod foo;` resolves to both foo.rs
* and foo/mod.rs -- and gets one badge, not one per pattern, so `mod` lines
* no longer show two arrows. When `resolveFile` is given, the candidate it
* names is preferred, so the badge links to the file that exists. Pure: no
* editor or DOM needed.
*/
export function findIncludeLinks(
doc: Text,
ranges: readonly { from: number, to: number }[],
patterns: (RegExp | ToolIncludePattern)[],
resolveFile?: (filename: string) => boolean,
): IncludeLink[] {
// group matches by the directive that produced them: line number + match offset
const groups = new Map<string, { line: number, candidates: { filename: string, system: boolean }[] }>();
for (let { from, to } of ranges) {
const text = doc.sliceString(from, to);
for (let pat of patterns) {
const p: ToolIncludePattern = pat instanceof RegExp ? { re: pat } : pat;
p.re.lastIndex = 0; // patterns are shared and global, so rewind first
@@ -42,29 +62,53 @@ function buildIncludeLinkDecorations(view: EditorView, patterns: (RegExp | ToolI
// apply any implicit extension (e.g. wiz `import "nes"` -> "nes.wiz"),
// same as matchDependencyPatterns() does for build deps
const filename = p.suffix ? matched + p.suffix : matched;
// place widget at end of the line containing the match
const matchEnd = Math.min(from + m.index + m[0].length, doc.length);
const line = doc.lineAt(matchEnd);
const key = line.number + ':' + filename;
if (seen.has(key)) continue;
seen.add(key);
widgets.push(
Decoration.widget({
widget: new IncludeLinkWidget(filename, !!p.system, handleClick),
side: 1,
}).range(line.to)
);
const key = line.number + ':' + (from + m.index);
let group = groups.get(key);
if (!group) {
group = { line: line.number, candidates: [] };
groups.set(key, group);
}
group.candidates.push({ filename, system: !!p.system });
}
}
}
const links: IncludeLink[] = [];
for (let group of groups.values()) {
let chosen = group.candidates[0];
if (resolveFile) {
chosen = group.candidates.find(c => resolveFile(c.filename)) || chosen;
}
links.push({ pos: doc.line(group.line).to, filename: chosen.filename, system: chosen.system });
}
return links;
}
function buildIncludeLinkDecorations(
state: EditorState,
ranges: readonly { from: number, to: number }[],
patterns: (RegExp | ToolIncludePattern)[],
handleClick: (filename: string, system: boolean) => void,
resolveFile?: (filename: string) => boolean,
): DecorationSet {
const widgets = findIncludeLinks(state.doc, ranges, patterns, resolveFile).map(link =>
Decoration.widget({
widget: new IncludeLinkWidget(link.filename, link.system, handleClick),
side: 1,
}).range(link.pos));
return Decoration.set(widgets, true);
}
export function createIncludeLinkPlugin(getPatterns: () => (RegExp | ToolIncludePattern)[], onClick: (filename: string, system: boolean) => void) {
export function createIncludeLinkPlugin(
getPatterns: () => (RegExp | ToolIncludePattern)[],
onClick: (filename: string, system: boolean) => void,
resolveFile?: (filename: string) => boolean,
) {
function build(view: EditorView): DecorationSet {
const patterns = getPatterns();
if (!patterns || !patterns.length) return Decoration.none;
return buildIncludeLinkDecorations(view, patterns, onClick);
return buildIncludeLinkDecorations(view.state, view.visibleRanges, patterns, onClick, resolveFile);
}
return ViewPlugin.fromClass(class {
decorations: DecorationSet;
+15 -1
View File
@@ -3,7 +3,8 @@ import { describe, it } from "mocha";
import { rust } from "@codemirror/lang-rust";
import { syntaxTree } from "@codemirror/language";
import { EditorState } from "@codemirror/state";
import { TOOL_META, getToolMetaForFilename } from "../../src/common/toolmeta";
import { TOOL_META, URUST_INCLUDE_PATTERNS, getToolMetaForFilename } from "../../src/common/toolmeta";
import { findIncludeLinks } from "../../src/ide/views/includedecorations";
import { extractSymbols } from "../../src/common/symbols";
describe('Rust editor support', function () {
@@ -22,6 +23,19 @@ describe('Rust editor support', function () {
}
});
it('shows one include badge per mod line, preferring the file that exists', function () {
const doc = EditorState.create({ doc: 'mod audio;\nmod bcd;\n' }).doc;
const ranges = [{ from: 0, to: doc.length }];
// both foo.rs and foo/mod.rs patterns match each mod line; keep one badge
assert.deepStrictEqual(
findIncludeLinks(doc, ranges, URUST_INCLUDE_PATTERNS).map(l => l.filename),
['audio.rs', 'bcd.rs']);
// if only the subdirectory form exists, link to that one
assert.deepStrictEqual(
findIncludeLinks(doc, ranges, URUST_INCLUDE_PATTERNS, fn => fn === 'audio/mod.rs').map(l => l.filename),
['audio/mod.rs', 'bcd.rs']);
});
it('extracts Rust fn declarations as symbols', function () {
const records = extractSymbols('fn draw(x: u8) {}\nfn main() {}\n', 'main.rs', 'rust', 'project');
assert.deepStrictEqual(records.filter(r => r.kind === 'func').map(r => r.name), ['draw', 'main']);