Skip to content

Repository files navigation

@fazelstudio/codemirror-breadcrumbs

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.

Highlights

  • 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 a nodeHandlers map) become segments; generic statements/blocks are skipped, matching VSCode.
  • Nested-language awareresolveInner dives through mounted sub-trees, so a cursor inside the JS of an HTML file correctly reads div#app > script > myFunction across the language boundary. splitChainByLanguage splits the ancestor chain on each tree top (Document, Script, StyleSheet).
  • Follows the cursor, not just editsselectionSet and docChanged both trigger recomputation, coalesced through requestAnimationFrame (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/common is 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.


Table of contents

Installation

npm install @fazelstudio/codemirror-breadcrumbs

Peer dependencies (installed automatically by npm ≥ 7, or manually):

npm install @codemirror/state @codemirror/view @codemirror/language

Quick start

import { 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.

File-path breadcrumbs (explorer 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.

Configuration options

breadcrumbs(config: BreadcrumbsConfig = {}): Extension

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.

Customization

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.

Styling: CSS variables

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' },
});

Dropdown size

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' },
});

Per-symbol colors

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; }

Custom icons

Two mechanisms:

  1. Replace a built-in glyph globallysymbolIcon(kind) returns an SVG string for any SymbolKind; use it in your own rendering or in renderPathIcon for path rows:

    import { symbolIcon } from '@fazelstudio/codemirror-breadcrumbs';
    const svg = symbolIcon('folder'); // "<svg ...>…</svg>"
  2. Path-explorer icons per hostrenderPathIcon receives the entry name

    • whether it is a folder + the expanded state, and returns raw HTML or null to fall back to the built-ins:
    breadcrumbs({
      filePath, workspaceRoot, readDirectory,
      renderPathIcon: ({ name, isDir }, expanded) =>
        isDir ? `<svg …folder icon…>…</svg>` : null,
    });

The folderIcon(open) and languageSeparatorIcon() helpers are exported too, so you can reuse the built-in art in your own UI.

Behavior overrides

  • Click handlingonSegmentClick replaces 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 togglesshowIcons: false, showLanguageSeparators: false.

  • Fixed widthmaxVisibleSegments: 5 caps the bar at 5 segments regardless of available width.

  • KeymapbreadcrumbKeymap (Escape closes the dropdown), mounted with keymap.of(breadcrumbKeymap). The panel also listens to Escape itself.

Custom language mappings

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()

extractLabel(node, state)

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.

getMembers(node, state)

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.

extraSegments(state, pos, config)

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',
    }));
  },
};

Overriding vs. registering

Priority is per languageName (highest wins):

built-in defaults  <  registerLanguageMapping()  <  breadcrumbs({ languages })
  • Per editor instance — pass languages to breadcrumbs(). 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).

Default language mappings

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's InterfaceDeclaration, TypeAliasDeclaration, EnumDeclaration, NamespaceDeclaration. The TS dialect reports Language.name === 'typescript', so handlers are registered under both names.
  • Python (@lezer/python): ClassDefinition, FunctionDefinition.
  • HTML (@lezer/html): every Elementtag#id.class; nested JS/CSS handled by their own mappings.
  • CSS (@lezer/css): RuleSet (selector), MediaStatement, KeyframesStatement.
  • JSON (@lezer/json): Property key path.
  • C++ (@lezer/cpp): ClassSpecifier, StructSpecifier, EnumSpecifier, NamespaceDefinition, FunctionDefinition (name inside FunctionDeclarator).
  • Go (@lezer/go): PackageClause (main > …), StructType/InterfaceType (name via the enclosing TypeSpec), MethodDecl, FunctionDecl, top-level ConstSpec.
  • Java (@lezer/java): PackageDeclaration (com.example > …), ClassDeclaration, InterfaceDeclaration, EnumDeclaration, MethodDeclaration — all name-carrying Definition children.
  • 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 of CREATE/ALTER statements (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 ### B under ## A reads A > B.
  • Less (@lezer/less) & Sass (@lezer/sass): RuleSet, MediaStatement, KeyframesStatement; Sass also uses MixinStatement (covers @mixin and @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): template Element tags; mounted script/CSS regions fall through to the JS/TS/CSS mappings.
  • Angular templates (@codemirror/lang-angular): Element tags.
  • Svelte (codemirror-lang-svelte): Element (plain tags, components like <Button />, and <svelte:head>), plus control blocks IfBlock, 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): Pair key 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.

Commands & keybindings

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 (Escape closes the dropdown; the panel already listens to Escape itself). Mount with keymap.of(breadcrumbKeymap).

Reading state (host integration)

import { breadcrumbField } from '@fazelstudio/codemirror-breadcrumbs';

const { segments, dropdown } = view.state.field(breadcrumbField, false) ?? { segments: [] };
  • segments — the current breadcrumb path as BreadcrumbSegment[].
  • dropdown{ index, siblings } when a sibling dropdown is open, else null.

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) });

Public API reference

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.

Playground

npm install
npm run dev   # open http://localhost:5173

The playground has three tabs — JavaScript, Python, and HTML+embedded JS — to try the navigation and nested-language breadcrumbs live.

Scripts

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

License

MIT — see LICENSE.

About

VSCode-like code structure breadcrumbs for CodeMirror 6, built on the Lezer syntax tree with correct nested-language traversal.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages