VSCode-like code-structure breadcrumbs for CodeMirror 6, built directly on the Lezer syntax tree — with correct traversal into nested languages (
<script>/<style>and any mixed parsing).
A plug-and-play CodeMirror 6 extension. Mount breadcrumbs() and you get a
breadcrumb bar above the editor that follows the cursor through the code's
outline — filename > class > method, namespace > interface > enum,
div#app > script > onClick, main > block > for > if, and so on.
- Real syntax-tree breadcrumbs — the path is derived from the active
language's Lezer tree (
@lezer/javascript,@lezer/python, ...), not from regexes. Only nodes you opt into (via anodeHandlersmap) become segments; generic statements/blocks are skipped, matching VSCode. - Nested-language aware —
resolveInnerdives through mounted sub-trees, so a cursor inside the JS of an HTML file correctly readsdiv#app > script > myFunctionacross the language boundary.splitChainByLanguagesplits the ancestor chain on each tree top (Document,Script,StyleSheet). - Follows the cursor, not just edits —
selectionSetanddocChangedboth trigger recomputation, coalesced throughrequestAnimationFrame(throttled, plus an inner-node guard) so dragging or clicking never causes jank. - Sibling dropdown — click a segment to list its siblings (other methods of the class, other properties at the same depth); click a sibling to jump.
- Responsive truncation — when the bar overflows, the middle collapses into an ellipsis that expands on click.
- VSCode-style icons — kind-specific glyphs (class, method, function, namespace, tag, property, ...) rendered as inline SVG, no icon library needed.
- Theme-agnostic — inherited colours and CSS variables
(
--cm-breadcrumbs-*) for easy overrides. - Zero runtime dependencies —
@lezer/commonis the only dependency. ESM + CJS dual build, tree-shakeable.
NOTICE: This package replicates the breadcrumbs feature of VSCode (conceptual architecture of
editorBreadcrumbs.ts). It is written from scratch on top of CodeMirror 6 primitives and does not copy VSCode code.
- Installation
- Quick start
- File-path breadcrumbs (explorer dropdown)
- Configuration options
- Customization
- Default language mappings
- Commands & keybindings
- Reading state (host integration)
- Public API reference
- Playground
- Scripts
- License
npm install @fazelstudio/codemirror-breadcrumbsPeer dependencies (installed automatically by npm ≥ 7, or manually):
npm install @codemirror/state @codemirror/view @codemirror/languageimport { EditorView, basicSetup } from 'codemirror';
import { javascript } from '@codemirror/lang-javascript';
import { breadcrumbs } from '@fazelstudio/codemirror-breadcrumbs';
const view = new EditorView({
doc: sourceCode,
extensions: [
basicSetup,
javascript(),
breadcrumbs({ fileLabel: 'app.ts' }),
],
parent: document.querySelector('#editor'),
});That's it. Move the cursor around — the bar updates in real time. Click a segment to open its sibling dropdown.
When your host knows the open file's path, pass filePath (and optionally
workspaceRoot) plus a readDirectory provider. The bar then leads with
clickable filesystem segments — src > app > file.js — and clicking any of
them opens an explorer-style dropdown listing that segment's containing
directory: folders expand/collapse inline and can be navigated into, and files
are opened through onOpenFile.
import { EditorView } from '@codemirror/view';
import { javascript } from '@codemirror/lang-javascript';
import { breadcrumbs } from '@fazelstudio/codemirror-breadcrumbs';
const view = new EditorView({
doc: sourceCode,
extensions: [
javascript(),
breadcrumbs({
filePath: '/workspace/src/app/file.js',
workspaceRoot: '/workspace',
readDirectory: async (dir) => {
// list the directory (filesystem backend of your host)
return [
{ name: 'app', path: '/workspace/src/app', isDir: true, hasChildren: true },
{ name: 'file.js', path: '/workspace/src/app/file.js', isDir: false },
];
},
onOpenFile: (path) => openFileInEditor(path),
}),
],
parent: document.querySelector('#editor'),
});Symbol segments (functions, classes, blocks) follow the file path and keep their sibling dropdowns; clicking a row in that dropdown jumps to the block.
The main entry point.
| Option | Type | Default | Description |
|---|---|---|---|
languages |
LanguageBreadcrumbConfig[] |
built-ins | Breadcrumb mapping overrides for specific languages. Highest priority (wins over both built-ins and registerLanguageMapping). |
showIcons |
boolean |
true |
Show the symbol-kind icons next to each segment. Set false for a text-only bar. |
showLanguageSeparators |
boolean |
true |
Show the </> marker between segments of different embedded languages. |
maxVisibleSegments |
number |
— | Hard cap on visible segments before truncation kicks in (overrides the width-based ellipsis). |
fileLabel |
string |
— | Optional static leading "file" segment (VSCode shows the file name first). Ignored when filePath is provided. |
onSegmentClick |
(segment, view) => void |
jump | Replace the default click behaviour (e.g. route to a command palette). When set, the default jump is skipped. |
filePath |
string |
— | Full OS path of the open file. When provided, the bar renders clickable filesystem segments (src > app > file.js) instead of the static fileLabel. |
workspaceRoot |
string |
— | Workspace root; when filePath lives under it, the shown path is relative to it and each segment's dropdown lists the correct parent directory. |
readDirectory |
(dir) => Promise<DirectoryEntry[]> |
— | Provider for the explorer dropdown: clicking a path segment lists the containing directory; folders expand/collapse inline, files call onOpenFile. |
onOpenFile |
(path) => void |
— | Called when a file is picked from a path-dropdown explorer (open it in your host). |
renderPathIcon |
({ name, isDir }, expanded) => string | null |
— | Host icon renderer for file/folder items in the explorer dropdown. Return raw HTML (e.g. <svg>) or null to fall back to the built-in icons. |
showPathHeader |
boolean |
true |
Show the current-directory title bar inside the explorer dropdown. VSCode-style dropdowns omit it; set false to match. |
Every visual and behavioural aspect can be customized without forking the package. The surfaces are: CSS variables, per-element CSS classes, behavior options (above), custom mappings, and custom icons.
All colours are declared as CSS variables scoped to .cm-editor, with the
VSCode-dark defaults below. Override them anywhere that wins the cascade — e.g.
in your own stylesheet with a higher specificity, or inside a theme class.
.cm-editor {
/* bar */
--cm-breadcrumbs-bg: #252526;
--cm-breadcrumbs-fg: #cccccc;
--cm-breadcrumbs-border: color-mix(in srgb, currentColor 10%, transparent);
--cm-breadcrumbs-hover-bg: #2a2d2e;
--cm-breadcrumbs-focus: #0e639c;
--cm-breadcrumbs-separator-fg: #6b6b6b; /* the "›" between segments */
--cm-breadcrumbs-language-fg: #569cd6; /* the "</>" language marker */
/* dropdown (symbols + path explorer) */
--cm-breadcrumbs-dropdown-bg: #252526;
--cm-breadcrumbs-dropdown-border: color-mix(in srgb, currentColor 18%, transparent);
--cm-breadcrumbs-dropdown-title-fg: #8b8b8b;
--cm-breadcrumbs-dropdown-hover-bg: #04395e;
/* per-kind symbol tint (see below) */
--cm-breadcrumbs-kind-class: #ee9d28;
}A light-theme example:
.cm-editor {
--cm-breadcrumbs-bg: #f3f3f3;
--cm-breadcrumbs-fg: #333333;
--cm-breadcrumbs-hover-bg: #e4e4e4;
--cm-breadcrumbs-dropdown-bg: #ffffff;
--cm-breadcrumbs-dropdown-hover-bg: #e8f0fe;
--cm-breadcrumbs-separator-fg: #9a9a9a;
}You can also override styles with a CodeMirror theme — EditorView.baseTheme
has the lowest precedence, so anything in EditorView.theme({ ... }) or plain
CSS wins:
EditorView.theme({
'.cm-breadcrumbs': { fontSize: '12px', padding: '0 12px' },
'.cm-breadcrumbs-segment': { textTransform: 'none' },
});The symbol dropdown (.cm-breadcrumbs-dropdown) and the filesystem explorer
dropdown (.cm-breadcrumbs-path-dropdown) each define min-width / max-width
(and max-height) in the base theme. Override them per project or per editor:
.cm-editor .cm-breadcrumbs-dropdown {
min-width: 360px;
max-width: 840px;
max-height: 480px;
}
.cm-editor .cm-breadcrumbs-path-dropdown {
min-width: 440px;
max-width: 680px;
}Or via a CodeMirror theme extension for a more targeted (per-language) scope:
EditorView.theme({
'.cm-breadcrumbs-dropdown': { maxWidth: '90vw' },
});Each SymbolKind has its own CSS variable and a dedicated class
.cm-breadcrumbs-kind-<kind>:
| Kind | Variable | Default |
|---|---|---|
file |
--cm-breadcrumbs-kind-file |
#9d9d9d |
folder |
--cm-breadcrumbs-kind-folder |
#dcb67a |
namespace |
--cm-breadcrumbs-kind-namespace |
#ce9178 |
class |
--cm-breadcrumbs-kind-class |
#ee9d28 |
interface |
--cm-breadcrumbs-kind-interface |
#11a8cd |
function |
--cm-breadcrumbs-kind-function |
#c586c0 |
method |
--cm-breadcrumbs-kind-method |
#c586c0 |
variable |
--cm-breadcrumbs-kind-variable |
#9cdcfe |
constant |
--cm-breadcrumbs-kind-constant |
#4fc1ff |
enum |
--cm-breadcrumbs-kind-enum |
#b180d7 |
property |
--cm-breadcrumbs-kind-property |
#ce9178 |
heading |
--cm-breadcrumbs-kind-heading |
#4ec9b0 |
block |
--cm-breadcrumbs-kind-block |
#dcdcaa |
tag |
--cm-breadcrumbs-kind-tag |
#11a8cd |
other |
--cm-breadcrumbs-kind-other |
#bbbbbb |
.cm-editor {
--cm-breadcrumbs-kind-class: #ff9900; /* recolor all classes */
--cm-breadcrumbs-kind-function: #e36209; /* and all functions */
}
/* or target a single language's segments */
.cm-editor.cm-language-markdown .cm-breadcrumbs-kind-heading { color: #56b6c2; }Two mechanisms:
-
Replace a built-in glyph globally —
symbolIcon(kind)returns an SVG string for anySymbolKind; use it in your own rendering or inrenderPathIconfor path rows:import { symbolIcon } from '@fazelstudio/codemirror-breadcrumbs'; const svg = symbolIcon('folder'); // "<svg ...>…</svg>"
-
Path-explorer icons per host —
renderPathIconreceives the entry name- whether it is a folder + the expanded state, and returns raw HTML or
nullto fall back to the built-ins:
breadcrumbs({ filePath, workspaceRoot, readDirectory, renderPathIcon: ({ name, isDir }, expanded) => isDir ? `<svg …folder icon…>…</svg>` : null, });
- whether it is a folder + the expanded state, and returns raw HTML or
The folderIcon(open) and languageSeparatorIcon() helpers are exported too,
so you can reuse the built-in art in your own UI.
-
Click handling —
onSegmentClickreplaces the default "jump to node" behaviour entirely (e.g. route to a command palette instead):breadcrumbs({ onSegmentClick: (segment, view) => { if (segment.kind === 'function') { openCommandPalette(segment.label); // custom behaviour } // default jump is skipped when onSegmentClick is set }, });
-
Icon / separator toggles —
showIcons: false,showLanguageSeparators: false. -
Fixed width —
maxVisibleSegments: 5caps the bar at 5 segments regardless of available width. -
Keymap —
breadcrumbKeymap(Escapecloses the dropdown), mounted withkeymap.of(breadcrumbKeymap). The panel also listens toEscapeitself.
The rule is opt-in: a node only becomes a segment when its Lezer node name
is present in nodeHandlers. Every other node (Statement, Block, Expression,
...) is skipped, which keeps the breadcrumb clean.
import { registerLanguageMapping } from '@fazelstudio/codemirror-breadcrumbs';
const unregister = registerLanguageMapping({
languageName: 'rust',
nodeHandlers: {
FunctionItem: { kind: 'function', extractLabel: (node, state) => ... },
StructItem: { kind: 'class', extractLabel: (node, state) => ... },
},
});
// later: unregister()Returns the segment text (or null to skip the node, e.g. an anonymous
function without a name). To find the exact node names of a grammar, parse a
sample file and dump tree.toString() — everything that isn't in your
nodeHandlers map is silently skipped.
Returns the symbols nested directly inside a node (e.g. the members of an
interface/class). The return value is copied onto the segment as members and
drives the "members" dropdown shown when the segment is clicked. Return null
(or an empty array) when the node has no members — the segment then opens no
dropdown.
Used when a language's "containers" are siblings of the cursor's node rather
than ancestors — e.g. a Markdown heading above the cursor's paragraph, a
Go/Java package statement, or a Liquid/Jinja directive whose content is a
mounted HTML parse. Return extra leading segments (outermost → innermost), or
null when the ancestor chain alone captures the location:
import { syntaxTree } from '@codemirror/language';
import type { EditorState } from '@codemirror/state';
import type { SyntaxNode } from '@lezer/common';
import type { BreadcrumbSegment } from '@fazelstudio/codemirror-breadcrumbs';
const myMapping = {
languageName: 'jinja',
nodeHandlers: { /* … */ },
extraSegments(state: EditorState, pos: number): BreadcrumbSegment[] | null {
// walk the OUTER tree (tree.resolve does not descend into mounts)
const directives: SyntaxNode[] = [];
let cur: SyntaxNode | null = syntaxTree(state).resolve(pos, -1);
while (cur) {
if (cur.name === 'ForStatement') directives.push(cur);
cur = cur.parent;
}
if (!directives.length) return null;
return directives.map((n) => ({
label: state.doc.sliceString(n.from, n.to),
kind: 'block',
from: n.from,
to: n.to,
nodeName: n.name,
languageName: 'jinja',
}));
},
};Priority is per languageName (highest wins):
built-in defaults < registerLanguageMapping() < breadcrumbs({ languages })
- Per editor instance — pass
languagestobreadcrumbs(). Ideal for per-file overrides (e.g. a dialect's peculiar node names). - Global — call
registerLanguageMapping()once and it applies to every editor using the plugin in the app. Returns an unregister function.
defaultLanguageConfigs() returns the built-ins, getRegisteredLanguageConfigs()
returns the globally registered ones, and computeBreadcrumbs(state, pos, configs)
is exported as a pure utility (e.g. for a minimap overlay).
Exact Lezer node names were verified against each real grammar:
- JavaScript / TypeScript (
@lezer/javascript):ClassDeclaration,ClassExpression,MethodDeclaration,FunctionDeclaration,FunctionExpression,ArrowFunction,VariableDeclaration(container object/array values only),Property, and TS'sInterfaceDeclaration,TypeAliasDeclaration,EnumDeclaration,NamespaceDeclaration. The TS dialect reportsLanguage.name === 'typescript', so handlers are registered under both names. - Python (
@lezer/python):ClassDefinition,FunctionDefinition. - HTML (
@lezer/html): everyElement→tag#id.class; nested JS/CSS handled by their own mappings. - CSS (
@lezer/css):RuleSet(selector),MediaStatement,KeyframesStatement. - JSON (
@lezer/json):Propertykey path. - C++ (
@lezer/cpp):ClassSpecifier,StructSpecifier,EnumSpecifier,NamespaceDefinition,FunctionDefinition(name insideFunctionDeclarator). - Go (
@lezer/go):PackageClause(main > …),StructType/InterfaceType(name via the enclosingTypeSpec),MethodDecl,FunctionDecl, top-levelConstSpec. - Java (
@lezer/java):PackageDeclaration(com.example > …),ClassDeclaration,InterfaceDeclaration,EnumDeclaration,MethodDeclaration— all name-carryingDefinitionchildren. - Rust (
@lezer/rust):StructItem,EnumItem,TraitItem,TypeItem,ImplItem,FunctionItem(covers free functions & methods),ModItem. - PHP (
@lezer/php):NamespaceDefinition(App\Controllers > …),ClassDeclaration,InterfaceDeclaration,TraitDeclaration,MethodDeclaration,FunctionDefinition. - SQL (
@lezer/sql): the object name ofCREATE/ALTERstatements (CREATE TABLE users …→users);SELECT/DML/tx keys are skipped. - XML (
@lezer/xml):Element(+SelfClosingTag) →tag#id.class. - Markdown (
@lezer/markdown):ATXHeading1..6,SetextHeading1/2. A cursor in text below a heading picks up the nearest preceding headings, so a paragraph under### Bunder## AreadsA > B. - Less (
@lezer/less) & Sass (@lezer/sass):RuleSet,MediaStatement,KeyframesStatement; Sass also usesMixinStatement(covers@mixinand@function). - Liquid (
@lezer/liquid):IfDirective,UnlessDirective,ForDirective,CaseDirective(nested path resolved from the outer template tree, since the block content is a mounted HTML parse). - Vue (
@codemirror/lang-vue): templateElementtags; mounted script/CSS regions fall through to the JS/TS/CSS mappings. - Angular templates (
@codemirror/lang-angular):Elementtags. - Svelte (
codemirror-lang-svelte):Element(plain tags, components like<Button />, and<svelte:head>), plus control blocksIfBlock,EachBlock,AwaitBlock,KeyBlock,SnippetBlock,RawHTMLBlock,ConstBlock,RenderBlock— block labels read the block's opening directive text ({#if count > 0}). Mounted<script>/<style>fall through to the JS/CSS mappings. - Jinja (
@codemirror/lang-jinja):BlockStatement,ForStatement,IfStatement({% block content %},{% for user in users %}, ...) resolved from the outer template tree; the mounted HTML body falls through to the HTML mapping. - YAML (
@codemirror/lang-yaml):Pairkey path (metadata > labels > tier,spec > template > spec > containers > image). - Lezer grammars (
@codemirror/lang-lezer):RuleDeclaration. - WAST (
@lezer/wast): no handlers — the grammar is a flat list of S-expressions with nothing to summarise.
import { jumpToSegment, toggleBreadcrumbDropdown, closeBreadcrumbDropdown, breadcrumbKeymap } from '@fazelstudio/codemirror-breadcrumbs';jumpToSegment(view, segment)— move the cursor to a segment's node.toggleBreadcrumbDropdown(view, index)— open/close the sibling dropdown (used by the panel internally; callable from a host command palette).closeBreadcrumbDropdown(view)— close the open sibling dropdown, if any.breadcrumbKeymap— optional key bindings (Escapecloses the dropdown; the panel already listens toEscapeitself). Mount withkeymap.of(breadcrumbKeymap).
import { breadcrumbField } from '@fazelstudio/codemirror-breadcrumbs';
const { segments, dropdown } = view.state.field(breadcrumbField, false) ?? { segments: [] };segments— the current breadcrumb path asBreadcrumbSegment[].dropdown—{ index, siblings }when a sibling dropdown is open, elsenull.
setBreadcrumbsEffect and setDropdownEffect let you drive the state
programmatically (e.g. from a minimap or a host palette):
import { setBreadcrumbsEffect } from '@fazelstudio/codemirror-breadcrumbs';
view.dispatch({ effects: setBreadcrumbsEffect.of(newSegments) });| Export | Kind | Purpose |
|---|---|---|
breadcrumbs(config) |
extension | Main entry point. |
registerLanguageMapping(config) |
fn | Register/override a global mapping; returns an unregister fn. |
defaultLanguageConfigs() |
fn | Built-in language mappings. |
getRegisteredLanguageConfigs() |
fn | Globally registered mappings. |
computeBreadcrumbs(state, pos, configs) |
fn | Pure path computation (outermost → innermost). |
computeFullBreadcrumbs(...) |
fn | Full path without truncation. |
buildPathSegments(...) / parentDir(...) |
fn | Filesystem path segment helpers for hosts. |
symbolIcon(kind) / folderIcon(open) / languageSeparatorIcon() |
fn | Inline SVG helpers. |
symbolKindClass(kind) |
fn | CSS class name for a kind (cm-breadcrumbs-kind-…). |
jumpToSegment / toggleBreadcrumbDropdown / closeBreadcrumbDropdown / breadcrumbKeymap |
commands | Programmatic navigation. |
breadcrumbField |
state field | Read the current path + open dropdown. |
setBreadcrumbsEffect / setDropdownEffect |
state effects | Drive the state from outside. |
breadcrumbsConfigFacet |
facet | The resolved config facet (hosts can read/override). |
breadcrumbNodeProp / breadcrumbKindOf |
node prop | Read the segment data attached to Lezer nodes. |
| Types | BreadcrumbsConfig, LanguageBreadcrumbConfig, BreadcrumbSegment, SymbolKind, DirectoryEntry |
TS types for authoring mappings/configs. |
npm install
npm run dev # open http://localhost:5173The playground has three tabs — JavaScript, Python, and HTML+embedded JS — to try the navigation and nested-language breadcrumbs live.
| Script | Description |
|---|---|
npm run build |
Build ESM + CJS + types via tsup into dist/ (core ≈ 10 KB gzipped) |
npm test |
Run unit & integration tests (vitest) |
npm run lint / npm run typecheck |
Type-check with strict TypeScript |
npm run dev |
Run the Vite playground |
npm run publish:dry |
Dry-run publish to npm |
MIT — see LICENSE.