diff --git a/src/app.css b/src/app.css
index c68df99..414f39b 100644
--- a/src/app.css
+++ b/src/app.css
@@ -6,6 +6,7 @@
@import "./styles/chrome/breadcrumb.css";
@import "./styles/chrome/statusbar.css";
@import "./styles/editor/panes.css";
+@import "./styles/editor/heading-bar.css";
@import "./styles/editor/prose.css";
@import "./styles/editor/reading-find.css";
@import "./styles/files/sidebar.css";
diff --git a/src/app.tsx b/src/app.tsx
index 4508166..1e560fc 100644
--- a/src/app.tsx
+++ b/src/app.tsx
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
import type { EditorView } from "@codemirror/view";
import { Breadcrumb, StatusBar, TitleBar, type VimMode } from "@/components/chrome";
-import { Editor, OpenTabs, Preview, ReadingFind, Splitter } from "@/components/editor";
+import { EditorPane, OpenTabs, Preview, ReadingFind, Splitter } from "@/components/editor";
import { ContextMenu, Sidebar, type ContextMenuItem } from "@/components/files";
import { AboutOverlay, CommandPalette, DropOverlay, HelpOverlay, Toast, WelcomeOverlay } from "@/components/overlays";
import { TooltipRoot } from "@/components/primitives";
@@ -985,11 +985,11 @@ export function App() {
/>
{editorOnly ? (
-
+
) : (
}
+ left={}
right={}
/>
)}
diff --git a/src/components/editor/editor-pane.tsx b/src/components/editor/editor-pane.tsx
new file mode 100644
index 0000000..deae9f4
--- /dev/null
+++ b/src/components/editor/editor-pane.tsx
@@ -0,0 +1,30 @@
+import { useState, type RefObject } from "react";
+import { EditorView } from "@codemirror/view";
+import { Editor } from "./editor";
+import { HeadingBreadcrumb } from "./heading-breadcrumb";
+
+type EditorPaneProps = {
+ value: string;
+ onChange: (next: string) => void;
+ vimOn?: boolean;
+ onVimMode?: (mode: "normal" | "insert" | "visual" | "replace" | null) => void;
+ viewRef: RefObject;
+};
+
+export function EditorPane({ value, onChange, vimOn, onVimMode, viewRef }: EditorPaneProps) {
+ const [cursorLine, setCursorLine] = useState(1);
+
+ return (
+
+
+
+
+ );
+}
diff --git a/src/components/editor/editor.tsx b/src/components/editor/editor.tsx
index 91fcc79..b9add56 100644
--- a/src/components/editor/editor.tsx
+++ b/src/components/editor/editor.tsx
@@ -34,6 +34,8 @@ type EditorProps = {
onVimMode?: (mode: "normal" | "insert" | "visual" | "replace" | null) => void;
/** shared ref populated with the EditorView once it mounts */
viewRef?: RefObject;
+ /** fired with the 1-based line number whenever the cursor moves */
+ onCursorLine?: (line: number) => void;
};
function buildTheme() {
@@ -87,11 +89,13 @@ function buildTheme() {
);
}
-export function Editor({ value, onChange, vimOn = false, onVimMode, viewRef: externalViewRef }: EditorProps) {
+export function Editor({ value, onChange, vimOn = false, onVimMode, viewRef: externalViewRef, onCursorLine }: EditorProps) {
const hostRef = useRef(null);
const viewRef = useRef(null);
const onChangeRef = useRef(onChange);
onChangeRef.current = onChange;
+ const onCursorLineRef = useRef(onCursorLine);
+ onCursorLineRef.current = onCursorLine;
// Compartment lets us swap the vim extension at runtime without rebuilding
// the EditorState (preserves doc, history, selection, undo stack).
const vimCompartment = useRef(new Compartment());
@@ -119,6 +123,10 @@ export function Editor({ value, onChange, vimOn = false, onVimMode, viewRef: ext
if (update.docChanged) {
onChangeRef.current(update.state.doc.toString());
}
+ if (update.selectionSet || update.docChanged) {
+ const line = update.state.doc.lineAt(update.state.selection.main.head).number;
+ onCursorLineRef.current?.(line);
+ }
}),
],
});
diff --git a/src/components/editor/heading-breadcrumb.tsx b/src/components/editor/heading-breadcrumb.tsx
new file mode 100644
index 0000000..a9c4f53
--- /dev/null
+++ b/src/components/editor/heading-breadcrumb.tsx
@@ -0,0 +1,235 @@
+import { useState, useMemo, useRef, useEffect, type RefObject } from "react";
+import { ChevronRight, ChevronDown } from "lucide-react";
+import { EditorView } from "@codemirror/view";
+
+interface Heading {
+ level: number;
+ text: string;
+ line: number;
+}
+
+interface HeadingNode {
+ heading: Heading;
+ children: HeadingNode[];
+}
+
+function parseHeadings(source: string): Heading[] {
+ const lines = source.split("\n");
+ const result: Heading[] = [];
+ let inFence = false;
+ for (let i = 0; i < lines.length; i++) {
+ const line = lines[i];
+ if (/^```/.test(line)) { inFence = !inFence; continue; }
+ if (inFence) continue;
+ const m = line.match(/^(#{1,6})\s+(.+)/);
+ if (m) result.push({ level: m[1].length, text: m[2].trim(), line: i + 1 });
+ }
+ return result;
+}
+
+function buildTree(headings: Heading[]): HeadingNode[] {
+ const roots: HeadingNode[] = [];
+ const stack: HeadingNode[] = [];
+ for (const h of headings) {
+ const node: HeadingNode = { heading: h, children: [] };
+ while (stack.length > 0 && stack[stack.length - 1].heading.level >= h.level) {
+ stack.pop();
+ }
+ if (stack.length === 0) {
+ roots.push(node);
+ } else {
+ stack[stack.length - 1].children.push(node);
+ }
+ stack.push(node);
+ }
+ return roots;
+}
+
+function getCurrentPath(headings: Heading[], cursorLine: number): Heading[] {
+ const path: Heading[] = [];
+ for (const h of headings) {
+ if (h.line > cursorLine) break;
+ while (path.length > 0 && path[path.length - 1].level >= h.level) path.pop();
+ path.push(h);
+ }
+ return path;
+}
+
+function trunc(text: string, max = 15): string {
+ return text.length > max ? text.slice(0, max) + "…" : text;
+}
+
+type TocNodeProps = {
+ node: HeadingNode;
+ activeLine: number;
+ ancestorLines: Set;
+ collapsed: Set;
+ onToggleCollapse: (line: number) => void;
+ onJump: (line: number) => void;
+ activeRef: RefObject;
+};
+
+function TocNode({ node, activeLine, ancestorLines, collapsed, onToggleCollapse, onJump, activeRef }: TocNodeProps) {
+ const { heading, children } = node;
+ const isActive = heading.line === activeLine;
+ const isAncestor = ancestorLines.has(heading.line);
+ const hasChildren = children.length > 0;
+ const isCollapsed = collapsed.has(heading.line);
+
+ return (
+
+
+
+
+
+ {hasChildren && !isCollapsed && (
+
+ {children.map((child) => (
+
+ ))}
+
+ )}
+
+ );
+}
+
+type Props = {
+ source: string;
+ cursorLine: number;
+ viewRef: RefObject;
+};
+
+export function HeadingBreadcrumb({ source, cursorLine, viewRef }: Props) {
+ const [tocOpen, setTocOpen] = useState(false);
+ const [collapsed, setCollapsed] = useState>(new Set());
+ const rootRef = useRef(null);
+ const activeRef = useRef(null);
+
+ const headings = useMemo(() => parseHeadings(source), [source]);
+ const tree = useMemo(() => buildTree(headings), [headings]);
+ const path = useMemo(() => getCurrentPath(headings, cursorLine), [headings, cursorLine]);
+ const ancestorLines = useMemo(() => new Set(path.map((h) => h.line)), [path]);
+
+ // scroll active item into view when TOC opens
+ useEffect(() => {
+ if (tocOpen && activeRef.current) {
+ activeRef.current.scrollIntoView({ block: "nearest" });
+ }
+ }, [tocOpen]);
+
+ // close on outside click
+ useEffect(() => {
+ if (!tocOpen) return;
+ const onDown = (e: MouseEvent) => {
+ if (rootRef.current && !rootRef.current.contains(e.target as Node)) {
+ setTocOpen(false);
+ }
+ };
+ document.addEventListener("mousedown", onDown);
+ return () => document.removeEventListener("mousedown", onDown);
+ }, [tocOpen]);
+
+ if (headings.length === 0) return null;
+
+ const jumpTo = (line: number) => {
+ const view = viewRef.current;
+ if (!view) return;
+ const lineObj = view.state.doc.line(Math.min(line, view.state.doc.lines));
+ view.dispatch({
+ selection: { anchor: lineObj.from },
+ effects: EditorView.scrollIntoView(lineObj.from, { y: "start", yMargin: 40 }),
+ });
+ view.focus();
+ setTocOpen(false);
+ };
+
+ const toggleCollapse = (line: number) => {
+ setCollapsed((prev) => {
+ const next = new Set(prev);
+ if (next.has(line)) next.delete(line);
+ else next.add(line);
+ return next;
+ });
+ };
+
+ const activeLine = path.length > 0 ? path[path.length - 1].line : -1;
+
+ return (
+
+
+
+ {tocOpen && (
+
+
+ {tree.map((node) => (
+
+ ))}
+
+
+ )}
+
+ );
+}
diff --git a/src/components/editor/index.ts b/src/components/editor/index.ts
index 85a772d..345bdfa 100644
--- a/src/components/editor/index.ts
+++ b/src/components/editor/index.ts
@@ -1,4 +1,5 @@
export { Editor } from "./editor";
+export { EditorPane } from "./editor-pane";
export { CsvPreview } from "./csv-preview";
export { OpenTabs } from "./open-tabs";
export { Preview } from "./preview";
diff --git a/src/styles/editor/heading-bar.css b/src/styles/editor/heading-bar.css
new file mode 100644
index 0000000..7779838
--- /dev/null
+++ b/src/styles/editor/heading-bar.css
@@ -0,0 +1,208 @@
+/* ── EditorPane layout ── */
+
+.mdv-editor-pane {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ overflow: hidden;
+}
+
+.mdv-editor-pane .mdv-editor {
+ flex: 1;
+ min-height: 0;
+ height: auto;
+}
+
+/* ── Heading breadcrumb bar ── */
+
+.mdv-hbar {
+ position: relative;
+ flex-shrink: 0;
+ border-bottom: 1px solid var(--border);
+ background: var(--surface);
+ z-index: 10;
+}
+
+.mdv-hbar__trigger {
+ display: flex;
+ align-items: center;
+ flex-wrap: nowrap;
+ gap: 2px;
+ width: 100%;
+ padding: 0 12px;
+ height: 26px;
+ font-family: var(--font-ui);
+ font-size: 11px;
+ font-weight: 600;
+ color: #d97706;
+ text-align: left;
+ overflow: hidden;
+ cursor: pointer;
+ transition: background-color var(--dur-fast) var(--easing);
+}
+
+.mdv-hbar__trigger:hover {
+ background: color-mix(in srgb, var(--fg) 4%, transparent);
+}
+
+.mdv-hbar__trigger.is-open {
+ background: color-mix(in srgb, var(--fg) 6%, transparent);
+}
+
+.mdv-hbar__crumb-row {
+ display: inline-flex;
+ align-items: center;
+ gap: 2px;
+ flex-shrink: 0;
+}
+
+.mdv-hbar__crumb {
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ max-width: 160px;
+}
+
+.mdv-hbar__chevron {
+ display: inline-flex;
+ align-items: center;
+ color: color-mix(in srgb, #d97706 55%, transparent);
+ flex-shrink: 0;
+}
+
+.mdv-hbar__empty {
+ color: var(--muted);
+ font-weight: 400;
+}
+
+/* ── TOC dropdown panel ── */
+
+.mdv-hbar__toc {
+ position: absolute;
+ top: 100%;
+ left: 0;
+ right: 0;
+ max-height: 340px;
+ overflow-y: auto;
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-top: none;
+ box-shadow: 0 8px 24px -6px color-mix(in srgb, var(--fg) 20%, transparent);
+ z-index: 100;
+ animation: mdv-hbar-slide 110ms var(--easing);
+}
+
+@keyframes mdv-hbar-slide {
+ from { opacity: 0; transform: translateY(-4px); }
+ to { opacity: 1; transform: translateY(0); }
+}
+
+.mdv-hbar__toc-list,
+.mdv-hbar__toc-children {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+
+.mdv-hbar__toc-list {
+ padding: 4px 0;
+}
+
+.mdv-hbar__toc-node {
+ /* tree node container */
+}
+
+.mdv-hbar__toc-row {
+ display: flex;
+ align-items: center;
+ padding-right: 12px;
+ transition: background-color var(--dur-fast) var(--easing);
+}
+
+.mdv-hbar__toc-row:hover {
+ background: color-mix(in srgb, var(--fg) 5%, transparent);
+}
+
+.mdv-hbar__toc-row.is-ancestor {
+ color: color-mix(in srgb, #d97706 80%, var(--fg));
+}
+
+.mdv-hbar__toc-row.is-active {
+ background: color-mix(in srgb, #d97706 8%, transparent);
+}
+
+.mdv-hbar__toc-row.is-active:hover {
+ background: color-mix(in srgb, #d97706 13%, transparent);
+}
+
+/* fold toggle button */
+.mdv-hbar__toc-fold {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ width: 18px;
+ height: 26px;
+ color: var(--muted);
+ cursor: pointer;
+ border-radius: 3px;
+ transition: color var(--dur-fast) var(--easing);
+}
+
+.mdv-hbar__toc-fold:hover {
+ color: var(--fg);
+}
+
+.mdv-hbar__toc-row.is-active .mdv-hbar__toc-fold,
+.mdv-hbar__toc-row.is-ancestor .mdv-hbar__toc-fold {
+ color: color-mix(in srgb, #d97706 60%, transparent);
+}
+
+.mdv-hbar__toc-leaf-dot {
+ display: block;
+ width: 4px;
+ height: 4px;
+ border-radius: 50%;
+ background: var(--border);
+ margin: auto;
+}
+
+/* heading label button */
+.mdv-hbar__toc-label {
+ display: flex;
+ align-items: baseline;
+ gap: 5px;
+ flex: 1;
+ min-width: 0;
+ padding: 5px 0;
+ font-family: var(--font-ui);
+ font-size: 12px;
+ color: inherit;
+ text-align: left;
+ cursor: pointer;
+ overflow: hidden;
+}
+
+.mdv-hbar__toc-row.is-active .mdv-hbar__toc-label {
+ color: #d97706;
+ font-weight: 600;
+}
+
+.mdv-hbar__toc-marker {
+ font-family: var(--font-mono);
+ font-size: 10px;
+ color: var(--muted);
+ flex-shrink: 0;
+ letter-spacing: -0.05em;
+}
+
+.mdv-hbar__toc-row.is-active .mdv-hbar__toc-marker,
+.mdv-hbar__toc-row.is-ancestor .mdv-hbar__toc-marker {
+ color: color-mix(in srgb, #d97706 65%, transparent);
+}
+
+.mdv-hbar__toc-text {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}