diff --git a/src/main/config.ts b/src/main/config.ts index 0f0afab..bbb8a86 100644 --- a/src/main/config.ts +++ b/src/main/config.ts @@ -14,12 +14,41 @@ export type DiffMode = 'original' | 'updated' | 'diff' /** Soft wrap of long lines: never, always, or only in Markdown files. */ export type WordWrap = 'off' | 'on' | 'markdown' +/** xterm's colour table. Every value is a CSS colour; the selection entries + * may carry alpha, the rest may not. */ +export interface TerminalTheme { + background: string; foreground: string; cursor: string; cursorAccent: string + selectionBackground: string; selectionInactiveBackground: string + black: string; red: string; green: string; yellow: string + blue: string; magenta: string; cyan: string; white: string + brightBlack: string; brightRed: string; brightGreen: string; brightYellow: string + brightBlue: string; brightMagenta: string; brightCyan: string; brightWhite: string +} + export interface HelderConfig { ai: { command: string; autoLaunch: boolean } editor: { autoSave: boolean; tabSize: number; wordWrap: WordWrap } git: { confirmDiscard: boolean; confirmStage: boolean; confirmUnstage: boolean; defaultDiffMode: DiffMode; refreshInterval: number } files: { exclude: string[]; followGitignore: boolean } - terminal: { shell: string | null } + terminal: { + /** Login shell for both panes. null = $SHELL. */ + shell: string | null + /** null = follow the CSS vars (--code-font / --term-size in theme.css). */ + fontFamily: string | null + fontSize: number | null + lineHeight: number + letterSpacing: number + cursorStyle: 'bar' | 'block' | 'underline' + cursorBlink: boolean + /** Paint bold text in the bright colour. Off keeps bold in its own hue, + * which stops a CLI's bold labels from washing out. */ + boldIsBright: boolean + scrollback: number + /** macOS: send Option as Meta. Needed for a CLI's ⌥↵ binding; it also stops + * Option from typing accented characters, so it is off by default. */ + optionIsMeta: boolean + theme: TerminalTheme + } session: { restoreOnLaunch: boolean } } @@ -28,7 +57,33 @@ export const DEFAULTS: HelderConfig = { editor: { autoSave: false, tabSize: 4, wordWrap: 'markdown' }, git: { confirmDiscard: true, confirmStage: false, confirmUnstage: false, defaultDiffMode: 'diff', refreshInterval: 10000 }, files: { exclude: [], followGitignore: false }, - terminal: { shell: null }, + terminal: { + shell: null, + fontFamily: null, + fontSize: null, + lineHeight: 1.35, + letterSpacing: 0, + cursorStyle: 'bar', + cursorBlink: true, + boldIsBright: false, + scrollback: 8000, + optionIsMeta: false, + // The app's own palette: charcoal ground, the accent on the caret and the + // selection, and the syntax colours reused for the ANSI table so a diff in + // the terminal reads like a diff in the editor. + theme: { + background: '#24272c', + foreground: '#dde1e7', + cursor: '#f19f3f', + cursorAccent: '#24272c', + selectionBackground: 'rgba(241,159,63,0.32)', + selectionInactiveBackground: 'rgba(241,159,63,0.18)', + black: '#2b2e34', red: '#e0696a', green: '#5cbd6b', yellow: '#d8a85c', + blue: '#6aa6f0', magenta: '#c98bdb', cyan: '#6ec0c0', white: '#b0b6bf', + brightBlack: '#7a828d', brightRed: '#ef8385', brightGreen: '#77d186', brightYellow: '#edc077', + brightBlue: '#86bbf5', brightMagenta: '#dba6e9', brightCyan: '#8ad6d6', brightWhite: '#fbfcfd', + }, + }, session: { restoreOnLaunch: true }, } @@ -41,7 +96,11 @@ const THEME_TEMPLATE = `/* Helder theme — custom CSS applied OVER the built-in /* Code surfaces (editor + terminals) */ /* --code-font: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; */ /* --code-size: 13px; */ /* editor font size */ - /* --term-size: 12.5px; */ /* terminal font size */ + /* --term-size: 12.5px; */ /* terminal font size, unless config.json sets one */ + + /* The terminal's colours, cursor and scrollback live in config.json, under + "terminal" — the palette is JS options, not CSS, because xterm paints to a + canvas. Edit either file and the running terminals restyle themselves. */ /* Example accent override: */ /* --accent: #4d8dff; */ diff --git a/src/main/index.ts b/src/main/index.ts index 2c4511c..2c7c3f2 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -9,6 +9,7 @@ import { commit, discard, load, push, stage, unstage } from './git-service' import { createPty, killAllPtys, killPty, ptyAvailable, resizePty, writePty } from './pty-service' import { getConfig, getRecent, getThemeCss, resolveConfig, setRecent } from './config' import { listFiles, searchContent } from './search-service' +import { invalidateSymbols, lookupSymbol, symbolNames } from './symbols-service' import { readNote, writeNote } from './notes-service' import { initDiagnostics, openLog, revealLog, watchWindow } from './diagnostics' import { getLogPath, log, logger, type LogLevel } from './logger' @@ -220,6 +221,7 @@ function startWatcher(): void { ignored: (p: string) => p.split(sep).some((seg) => WATCH_IGNORE.has(seg)), }) const ping = (): void => { + invalidateSymbols() // a changed file may add or remove a class if (watchTimer) clearTimeout(watchTimer) watchTimer = setTimeout(() => broadcast('project:changed'), 250) } @@ -322,6 +324,14 @@ function registerIpc(): void { handle('search:content', (_e, query: string) => { const r = getRoot(); return r ? searchContent(r, query) : [] }) handle('search:files', () => { const r = getRoot(); return r ? listFiles(r) : [] }) + // PHP symbols: the declared-name list feeds the ⌘-click underline, the lookup + // answers one click. Both build the index on demand — never at window open. + handle('symbols:names', () => { const r = getRoot(); return r ? symbolNames(r) : [] }) + handle('symbols:lookup', (_e, name: string) => { + const r = getRoot() + return r ? lookupSymbol(r, name) : { name, defs: [], refs: [], refCount: 0 } + }) + // The renderer's window into the same log file (see renderer/src/log.ts): its // uncaught errors, promise rejections and ErrorBoundary catches land here, so // main-process and renderer failures interleave in ONE chronological file. diff --git a/src/main/symbols-service.ts b/src/main/symbols-service.ts new file mode 100644 index 0000000..ba3e8a7 --- /dev/null +++ b/src/main/symbols-service.ts @@ -0,0 +1,250 @@ +/* PHP symbol index + reference lookup. + * + * Two jobs, both on ripgrep: + * - the index: every class/interface/trait/enum DECLARED in the project, as + * name → {path, line}. The renderer needs the bare name list to decide which + * tokens it may underline, and that decision is per visible token per frame, + * so it cannot be a search. One rg pass answers it for every token at once. + * - the lookup: on ⌘-click, the declarations of one name plus every reference + * to it, found there and then. Nothing about usages is cached. + * + * The index is built lazily and never on the startup path: the first caller + * starts it and later callers join the same promise. A file change drops it, so + * the next caller pays for the rebuild (~100 ms) instead of the window opening. + */ +import { spawn } from 'node:child_process' +import { relative, sep } from 'node:path' +import { getConfig } from './config' +import { log } from './logger' + +export interface SymbolDef { + name: string; path: string; line: number; kind: string + /** The file's `namespace`, so the popup can name the class in full. */ + ns: string + /** Parents named on the declaration line: `extends A`, `implements B, C`. */ + parents: string[] + interfaces: string[] + /** Traits mixed in by an indented `use A, B;` inside this declaration's body. */ + traits: string[] +} +export interface RefHit { no: number; ln: string; ix: number } +export interface RefGroup { path: string; hits: RefHit[] } +export interface SymbolLookup { name: string; defs: SymbolDef[]; refs: RefGroup[]; refCount: number } + +/** Same resolution dance as search-service: @vscode/ripgrep is ESM, and the + * packaged binary lives outside the asar. */ +const rgPathPromise: Promise = (async () => { + try { + const mod = await import('@vscode/ripgrep') + let p = (mod as { rgPath: string }).rgPath + if (p) p = p.replace(/\bapp\.asar\b/, 'app.asar.unpacked') + return p || null + } catch (e) { + log('error', 'symbols', 'ripgrep unavailable', (e as Error).message) + return null + } +})() + +const BASE_IGNORE = [ + 'node_modules', '.git', 'out', 'dist', 'build', '.cache', + 'vendor', 'coverage', '.helder', '.next', '.nuxt', '.turbo', '.idea', '.vscode', +] + +/** Declarations only. `readonly`/`final`/`abstract` may precede the keyword, and + * an enum may carry a backing type. Anchored at the line start (with optional + * indent) so `$x instanceof class` shaped text cannot match. */ +const DECL_RE = /^[ \t]*(?:(?:final|abstract|readonly)[ \t]+)*(class|interface|trait|enum)[ \t]+([A-Za-z_]\w*)(.*)$/ +/** Declarations and the file's namespace in one pass: ripgrep emits a file's + * matches together and in line order, and `namespace` always precedes the + * declarations it covers, so the last one seen is the right one. */ +const DECL_RG = '^\\s*(namespace\\s+[A-Za-z_\\\\][\\w\\\\]*\\s*;|(final\\s+|abstract\\s+|readonly\\s+)*(class|interface|trait|enum)\\s+\\w+)' +const NS_RE = /^[ \t]*namespace[ \t]+([A-Za-z_\\][\w\\]*)[ \t]*;/ +/** An indented `use` — a trait mixed into a class body, not a namespace import. */ +const TRAIT_RE = /^[ \t]+use[ \t]+([A-Za-z_\\][\w\\ \t,]*?)[ \t]*[;{]/ +const TRAIT_RG = '^\\s+use\\s+[A-Za-z_\\\\][\\w\\\\ \t,]*[;{]' + +const MAX_REF_FILES = 300 +const MAX_LINE = 1000 + +function ignoreArgs(): string[] { + const cfg = getConfig() + const args: string[] = [] + for (const d of BASE_IGNORE) args.push('--glob', `!${d}`) + for (const g of cfg.files.exclude) if (g) args.push('--glob', `!${g}`) + if (!cfg.files.followGitignore) args.push('--no-ignore') + return args +} + +function toRel(root: string, p: string): string { + return relative(root, p).split(sep).join('/') +} + +/** Bare class names out of an `extends`/`implements` clause: split on commas and + * drop the namespace, since the index is keyed on the short name. */ +function clauseNames(clause: string): string[] { + return clause.split(',') + .map((part) => (part.trim().split('\\').pop() ?? '').trim()) + .filter((n) => /^[A-Za-z_]\w*$/.test(n)) +} + +/** The namespace a `namespace X;` line declares, or null. */ +export function parseNamespace(text: string): string | null { + const m = NS_RE.exec(text) + return m ? m[1].replace(/^\\/, '') : null +} + +/** The traits an indented `use` line mixes in, or [] for anything else. A + * `use function …` or a closure's `use ($x)` is not a trait. */ +export function parseTraitUse(text: string): string[] { + const m = TRAIT_RE.exec(text) + if (!m || /^\s*use\s+(function|const)\b/.test(text)) return [] + return clauseNames(m[1]) +} + +/** The declaration on one line, or null. Exported for the unit tests. + * Only this line is read, so an `implements` list wrapped onto the next line is + * not seen — the popup then shows fewer parents, never wrong ones. */ +export function parseDeclaration(text: string): { kind: string; name: string; parents: string[]; interfaces: string[] } | null { + const m = DECL_RE.exec(text) + if (!m) return null + const rest = m[3] ?? '' + const ext = /\bextends\s+([^{]*?)(?:\s+implements\b|\s*\{|$)/.exec(rest) + const impl = /\bimplements\s+([^{]*?)(?:\s*\{|$)/.exec(rest) + return { + kind: m[1], + name: m[2], + parents: ext ? clauseNames(ext[1]) : [], + interfaces: impl ? clauseNames(impl[1]) : [], + } +} + +/** A namespace import — `use App\Models\Agent;` at column 0, with an optional + * alias or a group body. A `use` INSIDE a class body is indented and mixes a + * trait in, which is a real reference, so the indent is what separates them. */ +export function isImportLine(text: string): boolean { + return /^use[ \t]+[^;]*;?[ \t\r]*$/.test(text) +} + +/** Run rg with --json and hand every match line to `onMatch`. */ +function rgJson(args: string[], onMatch: (path: string, line: number, text: string, col: number) => void): Promise { + return new Promise((resolve) => { + rgPathPromise.then((rgPath) => { + if (!rgPath) { resolve(); return } + const child = spawn(rgPath, args) + let buf = '' + let done = false + const finish = (): void => { if (done) return; done = true; resolve() } + child.stdout.on('data', (chunk: Buffer) => { + buf += chunk.toString() + let nl: number + while ((nl = buf.indexOf('\n')) >= 0) { + const line = buf.slice(0, nl); buf = buf.slice(nl + 1) + if (!line) continue + let msg: { type: string; data: { path?: { text?: string }; lines?: { text?: string }; line_number?: number; submatches?: { start: number }[] } } + try { msg = JSON.parse(line) } catch { continue } + if (msg.type !== 'match') continue + const abs = msg.data.path?.text + const text = msg.data.lines?.text + if (!abs || text == null) continue + onMatch(abs, msg.data.line_number || 0, text.replace(/\n$/, ''), msg.data.submatches?.[0]?.start ?? 0) + } + }) + child.on('close', finish) + child.on('error', (e) => { log('error', 'symbols', 'ripgrep failed', (e as Error).message); finish() }) + }) + }) +} + +interface Index { root: string; byName: Map } +let index: Index | null = null +let building: Promise | null = null + +async function build(root: string): Promise { + const t0 = Date.now() + const byName = new Map() + const byFile = new Map() + const nsByFile = new Map() + await rgJson( + ['--json', '--hidden', '--glob', '*.php', ...ignoreArgs(), '-e', DECL_RG, '--', root], + (abs, line, text) => { + const rel = toRel(root, abs) + const ns = parseNamespace(text) + if (ns != null) { nsByFile.set(rel, ns); return } + const d = parseDeclaration(text) + if (!d) return + const def: SymbolDef = { + name: d.name, path: rel, line, kind: d.kind, ns: nsByFile.get(rel) ?? '', + parents: d.parents, interfaces: d.interfaces, traits: [], + } + const list = byName.get(d.name) + if (list) list.push(def) + else byName.set(d.name, [def]) + const inFile = byFile.get(def.path) + if (inFile) inFile.push(def) + else byFile.set(def.path, [def]) + }, + ) + // Traits live in the body, not on the declaration line, so they need their own + // pass. A `use` belongs to the last declaration above it in the same file — + // which is also why the declaration pass has to run first. + await rgJson( + ['--json', '--hidden', '--glob', '*.php', ...ignoreArgs(), '-e', TRAIT_RG, '--', root], + (abs, line, text) => { + const names = parseTraitUse(text) + if (!names.length) return + const decls = byFile.get(toRel(root, abs)) + if (!decls) return + let owner: SymbolDef | null = null + for (const d of decls) if (d.line < line && (!owner || d.line > owner.line)) owner = d + if (owner) for (const n of names) if (!owner.traits.includes(n)) owner.traits.push(n) + }, + ) + log('info', 'symbols', 'index built', { classes: byName.size, ms: Date.now() - t0 }) + return { root, byName } +} + +/** The index for `root`, built on first use. Concurrent callers share one build. */ +function getIndex(root: string): Promise { + if (index && index.root === root) return Promise.resolve(index) + if (building) return building + building = build(root).then((ix) => { index = ix; building = null; return ix }) + .catch((e) => { building = null; throw e }) + return building +} + +/** Drop the index after a file change. The next caller rebuilds it. */ +export function invalidateSymbols(): void { + index = null +} + +/** Every declared name. The renderer keeps this as a Set to mark tokens. */ +export async function symbolNames(root: string): Promise { + const ix = await getIndex(root) + return [...ix.byName.keys()] +} + +/** Declarations of `name` plus every reference to it, grouped by file. Namespace + * imports are dropped (noise), and so are the declaration lines themselves — + * they are already the first section of the popup. */ +export async function lookupSymbol(root: string, name: string): Promise { + if (!/^[A-Za-z_]\w*$/.test(name)) return { name, defs: [], refs: [], refCount: 0 } + const ix = await getIndex(root) + const defs = ix.byName.get(name) ?? [] + const declared = new Set(defs.map((d) => d.path + ':' + d.line)) + const order: string[] = [] + const groups = new Map() + let refCount = 0 + await rgJson( + ['--json', '--hidden', '--word-regexp', '--glob', '*.php', ...ignoreArgs(), '-e', name, '--', root], + (abs, line, text, col) => { + const rel = toRel(root, abs) + if (declared.has(rel + ':' + line) || isImportLine(text)) return + let g = groups.get(rel) + if (!g) { if (groups.size >= MAX_REF_FILES) return; g = { path: rel, hits: [] }; groups.set(rel, g); order.push(rel) } + const ln = text.slice(0, MAX_LINE) + g.hits.push({ no: line, ln, ix: Math.min(col, ln.length) }) + refCount++ + }, + ) + return { name, defs, refs: order.map((p) => groups.get(p) as RefGroup), refCount } +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 45aac48..883d199 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -84,6 +84,11 @@ const api = { files: (): Promise => ipcRenderer.invoke('search:files'), }, + symbols: { + names: (): Promise => ipcRenderer.invoke('symbols:names'), + lookup: (name: string) => ipcRenderer.invoke('symbols:lookup', name), + }, + dialog: { unsavedClose: (path: string): Promise<'save' | 'discard' | 'cancel'> => ipcRenderer.invoke('dialog:unsavedClose', path), diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 95ddf15..19690ee 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -5,12 +5,13 @@ import type { ContextTarget } from './components' import { Editor, SplitView } from './editor' import type { Cursor, Mode, Selection } from './editor' import { Terminal, lid } from './terminals' -import { ConfirmModal, ContextMenu, HelpModal, HistoryModal, NamePopup, NotesModal, PassPopup, ProjectsModal, SearchModal, Toasts } from './overlays' +import { ConfirmModal, ContextMenu, HelpModal, HistoryModal, NamePopup, NotesModal, PassPopup, ProjectsModal, SearchModal, SymbolPopup, Toasts } from './overlays' import { ProjectLauncher } from './launcher' import type { Menu, Toast } from './overlays' -import type { DiffSide, FileNode, GitStatus } from './types' +import type { DiffSide, FileNode, GitStatus, SymbolLookup } from './types' import { useProject, useProjectActions } from './project' import { HL } from './highlight' +import { loadSymbols } from './symbols' import { rlog } from './log' import { loadJson, loadNum, saveJson, saveNum } from './persist' @@ -290,7 +291,7 @@ export function App(): React.ReactElement { wasBlurred = false setShowFlash(true) clearTimeout(timer) - timer = setTimeout(() => setShowFlash(false), 2000) + timer = setTimeout(() => setShowFlash(false), 500) } function onBlur(): void { wasBlurred = true } window.addEventListener('focus', flash) @@ -519,6 +520,36 @@ export function App(): React.ReactElement { actions.unstage(p) } + // The declared-class list, fetched off the startup path: 1.5 s after the + // project is ready, and again 2 s after the last file change. Both are late on + // purpose — the list only decides which tokens may be underlined, so it can + // never be worth competing with the tree, git and terminals for the launch. + useEffect(() => { + if (!proj.ready || !proj.root) return + const t = setTimeout(() => loadSymbols(), 1500) + return () => clearTimeout(t) + }, [proj.ready, proj.root]) + useEffect(() => { + if (!proj.ready || !proj.root) return + const t = setTimeout(() => loadSymbols(true), 2000) + return () => clearTimeout(t) + }, [proj.changes, proj.tree, proj.ready, proj.root]) + + // ⌘-click on a PHP class name: the popup shows where it is declared and every + // reference to it. The lookup runs in main (one ripgrep pass) and the popup + // opens right away with a "Searching…" body, so the click never feels stuck. + const [symName, setSymName] = useState(null) + const [symData, setSymData] = useState(null) + function openSymbol(name: string): void { + setSymName(name) + setSymData(null) + const bridge = window.helder + if (!bridge) return + bridge.symbols.lookup(name) + .then((d) => setSymData((cur) => (cur === null ? d : cur))) + .catch((e) => { rlog.error('symbols', 'lookup failed', e, { name }); setSymName(null) }) + } + // A search result opens the file at its line and paints that line orange for a // second, so the eye finds it after the scroll. `id` restarts a repeat jump. const [lineFlash, setLineFlash] = useState<{ path: string; line: number; id: number } | null>(null) @@ -1054,7 +1085,7 @@ export function App(): React.ReactElement { onSplit={(p) => setSplitFor(p)} splitOpen={splitFor === active} cursor={cursor} selection={selection} setCursor={setCursor} setSelection={setSelection} flash={lineFlash && lineFlash.path === active ? lineFlash : null} - bufferText={bufferText(active)} onEdit={onEdit} /> + bufferText={bufferText(active)} onEdit={onEdit} onSymbol={openSymbol} /> { setAutoResize(false); setRightW((w) => { // grow until the editor would drop below ~280px (rather than a fixed cap) @@ -1093,6 +1124,9 @@ export function App(): React.ReactElement { {overlay === 'help' && setOverlay(null)} />} {overlay === 'notes' && { setOverlay(null); saveNote() }} />} {confirm && setConfirm(null)} />} + {symName && openFile(p, { line: n })} onSymbol={openSymbol} + onClose={() => { setSymName(null); setSymData(null) }} />} {menu && setMenu(null)} />} diff --git a/src/renderer/src/editor.tsx b/src/renderer/src/editor.tsx index 769c98f..01af19d 100644 --- a/src/renderer/src/editor.tsx +++ b/src/renderer/src/editor.tsx @@ -4,10 +4,46 @@ import type { Diff, DiffSide, ViewLine } from './types' import { rowId } from './types' import { useProject } from './project' import { HL } from './highlight' +import { isKnownSymbol, useSymbols } from './symbols' import { renderMarkdown } from './markdown' import { FileIcon, Icon } from './components' import type { OnContext } from './components' +/** ⌘-click on a class name declared in this project. */ +export type OnSymbol = (name: string) => void + +/** The identifier around `pos` in `text`, or '' when the caret is not on one. */ +export function wordAt(text: string, pos: number): string { + if (pos < 0 || pos > text.length) return '' + const word = /\w/ + let a = pos + while (a > 0 && word.test(text[a - 1])) a-- + let b = pos + while (b < text.length && word.test(text[b])) b++ + const w = text.slice(a, b) + return /^[A-Za-z_]\w*$/.test(w) ? w : '' +} + +/** True while ⌘ (or Ctrl) is held. Drives the underline, so the affordance + * shows exactly when the click would do something. */ +function useMetaKey(): boolean { + const [down, setDown] = useState(false) + useEffect(() => { + const on = (e: KeyboardEvent): void => { if (e.metaKey || e.ctrlKey) setDown(true) } + const off = (e: KeyboardEvent): void => { if (!e.metaKey && !e.ctrlKey) setDown(false) } + const clear = (): void => setDown(false) + window.addEventListener('keydown', on) + window.addEventListener('keyup', off) + window.addEventListener('blur', clear) + return () => { + window.removeEventListener('keydown', on) + window.removeEventListener('keyup', off) + window.removeEventListener('blur', clear) + } + }, []) + return down +} + export interface Cursor { path: string; line: number; col: number } /** A one-second line highlight after a jump. `id` changes per jump, so the same * line twice restarts the animation. */ @@ -32,7 +68,7 @@ function climbToLine(node: Node | null): HTMLElement | null { * the number is a CSS counter on `::before`, which keeps it on the first * visual row and leaves the folded rows unnumbered. Highlighting is then per * line (like the diff views), so multi-line tokens don't carry over. */ -function CodeEditor({ path, text, lang, wrap, flash, onChange, onContext }: { +function CodeEditor({ path, text, lang, wrap, flash, onChange, onContext, onSymbol }: { path: string text: string lang: string | null @@ -41,16 +77,22 @@ function CodeEditor({ path, text, lang, wrap, flash, onChange, onContext }: { flash: FlashLine | null onChange: (text: string) => void onContext: OnContext + onSymbol: OnSymbol }): React.ReactElement { const scrollRef = useRef(null) const gutterRef = useRef(null) const preRef = useRef(null) const tabSize = useProject().config.editor.tabSize + const symVer = useSymbols() // re-highlight once the declared-name list lands + const metaDown = useMetaKey() const html = useMemo( () => (wrap ? text.split('\n').map((l) => `
${HL.hlLine(l, lang)}
`).join('') : HL.hlText(text, lang) + '\n'), - [text, lang, wrap], + // symVer is not read here: HL marks the known class tokens from a module-level + // set, so this is what recomputes the HTML when that set lands. + // eslint-disable-next-line react-hooks/exhaustive-deps + [text, lang, wrap, symVer], ) const count = useMemo(() => text.split('\n').length, [text]) // The band sits behind the text, so it needs the geometry of the line. Unwrapped @@ -122,6 +164,26 @@ function CodeEditor({ path, text, lang, wrap, flash, onChange, onContext }: { function onKeyDown(e: React.KeyboardEvent): void { if (e.key === 'Tab' && !e.metaKey && !e.ctrlKey && !e.altKey) handleTab(e, e.shiftKey) } + /* Two ways in. While ⌘ is held the marked tokens take the mouse (see the + * .sym-live rules), so a click on one arrives here with the token as target + * and its text is the name. Anywhere else the click lands on the textarea, + * which has already moved the caret, so the word around `selectionStart` is + * the word under the pointer — no token geometry either way. */ + function handleTokenClick(e: React.MouseEvent): void { + if (!e.metaKey && !e.ctrlKey) return + const el = e.target as HTMLElement + if (!el.classList?.contains('sym')) return + e.preventDefault() + onSymbol(el.textContent || '') + } + + function handleClick(e: React.MouseEvent): void { + ensureCaretVisible(e.currentTarget) + if (!e.metaKey && !e.ctrlKey) return + const name = wordAt(e.currentTarget.value, e.currentTarget.selectionStart) + if (name && isKnownSymbol(name)) { e.preventDefault(); onSymbol(name) } + } + function handleContext(e: React.MouseEvent): void { e.preventDefault() const ta = e.currentTarget @@ -135,7 +197,7 @@ function CodeEditor({ path, text, lang, wrap, flash, onChange, onContext }: { onContext(e, info) } return ( -
+
{!wrap && (
@@ -143,7 +205,7 @@ function CodeEditor({ path, text, lang, wrap, flash, onChange, onContext }: {
)} -
+
{flash && flashBox &&
}