loads of UI improvements, also improves the console UI

This commit is contained in:
2026-09-03 09:14:30 +02:00
parent 37d15a29f0
commit 03e1f90515
16 changed files with 1108 additions and 81 deletions

View File

@@ -14,12 +14,41 @@ export type DiffMode = 'original' | 'updated' | 'diff'
/** Soft wrap of long lines: never, always, or only in Markdown files. */ /** Soft wrap of long lines: never, always, or only in Markdown files. */
export type WordWrap = 'off' | 'on' | 'markdown' 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 { export interface HelderConfig {
ai: { command: string; autoLaunch: boolean } ai: { command: string; autoLaunch: boolean }
editor: { autoSave: boolean; tabSize: number; wordWrap: WordWrap } editor: { autoSave: boolean; tabSize: number; wordWrap: WordWrap }
git: { confirmDiscard: boolean; confirmStage: boolean; confirmUnstage: boolean; defaultDiffMode: DiffMode; refreshInterval: number } git: { confirmDiscard: boolean; confirmStage: boolean; confirmUnstage: boolean; defaultDiffMode: DiffMode; refreshInterval: number }
files: { exclude: string[]; followGitignore: boolean } 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 } session: { restoreOnLaunch: boolean }
} }
@@ -28,7 +57,33 @@ export const DEFAULTS: HelderConfig = {
editor: { autoSave: false, tabSize: 4, wordWrap: 'markdown' }, editor: { autoSave: false, tabSize: 4, wordWrap: 'markdown' },
git: { confirmDiscard: true, confirmStage: false, confirmUnstage: false, defaultDiffMode: 'diff', refreshInterval: 10000 }, git: { confirmDiscard: true, confirmStage: false, confirmUnstage: false, defaultDiffMode: 'diff', refreshInterval: 10000 },
files: { exclude: [], followGitignore: false }, 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 }, session: { restoreOnLaunch: true },
} }
@@ -41,7 +96,11 @@ const THEME_TEMPLATE = `/* Helder theme — custom CSS applied OVER the built-in
/* Code surfaces (editor + terminals) */ /* Code surfaces (editor + terminals) */
/* --code-font: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; */ /* --code-font: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; */
/* --code-size: 13px; */ /* editor font size */ /* --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: */ /* Example accent override: */
/* --accent: #4d8dff; */ /* --accent: #4d8dff; */

View File

@@ -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 { createPty, killAllPtys, killPty, ptyAvailable, resizePty, writePty } from './pty-service'
import { getConfig, getRecent, getThemeCss, resolveConfig, setRecent } from './config' import { getConfig, getRecent, getThemeCss, resolveConfig, setRecent } from './config'
import { listFiles, searchContent } from './search-service' import { listFiles, searchContent } from './search-service'
import { invalidateSymbols, lookupSymbol, symbolNames } from './symbols-service'
import { readNote, writeNote } from './notes-service' import { readNote, writeNote } from './notes-service'
import { initDiagnostics, openLog, revealLog, watchWindow } from './diagnostics' import { initDiagnostics, openLog, revealLog, watchWindow } from './diagnostics'
import { getLogPath, log, logger, type LogLevel } from './logger' 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)), ignored: (p: string) => p.split(sep).some((seg) => WATCH_IGNORE.has(seg)),
}) })
const ping = (): void => { const ping = (): void => {
invalidateSymbols() // a changed file may add or remove a class
if (watchTimer) clearTimeout(watchTimer) if (watchTimer) clearTimeout(watchTimer)
watchTimer = setTimeout(() => broadcast('project:changed'), 250) 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:content', (_e, query: string) => { const r = getRoot(); return r ? searchContent(r, query) : [] })
handle('search:files', () => { const r = getRoot(); return r ? listFiles(r) : [] }) 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 // 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 // uncaught errors, promise rejections and ErrorBoundary catches land here, so
// main-process and renderer failures interleave in ONE chronological file. // main-process and renderer failures interleave in ONE chronological file.

250
src/main/symbols-service.ts Normal file
View File

@@ -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<string | null> = (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<void> {
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<string, SymbolDef[]> }
let index: Index | null = null
let building: Promise<Index> | null = null
async function build(root: string): Promise<Index> {
const t0 = Date.now()
const byName = new Map<string, SymbolDef[]>()
const byFile = new Map<string, SymbolDef[]>()
const nsByFile = new Map<string, string>()
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<Index> {
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<string[]> {
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<SymbolLookup> {
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<string, RefGroup>()
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 }
}

View File

@@ -84,6 +84,11 @@ const api = {
files: (): Promise<string[]> => ipcRenderer.invoke('search:files'), files: (): Promise<string[]> => ipcRenderer.invoke('search:files'),
}, },
symbols: {
names: (): Promise<string[]> => ipcRenderer.invoke('symbols:names'),
lookup: (name: string) => ipcRenderer.invoke('symbols:lookup', name),
},
dialog: { dialog: {
unsavedClose: (path: string): Promise<'save' | 'discard' | 'cancel'> => unsavedClose: (path: string): Promise<'save' | 'discard' | 'cancel'> =>
ipcRenderer.invoke('dialog:unsavedClose', path), ipcRenderer.invoke('dialog:unsavedClose', path),

View File

@@ -5,12 +5,13 @@ import type { ContextTarget } from './components'
import { Editor, SplitView } from './editor' import { Editor, SplitView } from './editor'
import type { Cursor, Mode, Selection } from './editor' import type { Cursor, Mode, Selection } from './editor'
import { Terminal, lid } from './terminals' 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 { ProjectLauncher } from './launcher'
import type { Menu, Toast } from './overlays' 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 { useProject, useProjectActions } from './project'
import { HL } from './highlight' import { HL } from './highlight'
import { loadSymbols } from './symbols'
import { rlog } from './log' import { rlog } from './log'
import { loadJson, loadNum, saveJson, saveNum } from './persist' import { loadJson, loadNum, saveJson, saveNum } from './persist'
@@ -290,7 +291,7 @@ export function App(): React.ReactElement {
wasBlurred = false wasBlurred = false
setShowFlash(true) setShowFlash(true)
clearTimeout(timer) clearTimeout(timer)
timer = setTimeout(() => setShowFlash(false), 2000) timer = setTimeout(() => setShowFlash(false), 500)
} }
function onBlur(): void { wasBlurred = true } function onBlur(): void { wasBlurred = true }
window.addEventListener('focus', flash) window.addEventListener('focus', flash)
@@ -519,6 +520,36 @@ export function App(): React.ReactElement {
actions.unstage(p) 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<string | null>(null)
const [symData, setSymData] = useState<SymbolLookup | null>(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 // 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. // 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) 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} onSplit={(p) => setSplitFor(p)} splitOpen={splitFor === active}
cursor={cursor} selection={selection} setCursor={setCursor} setSelection={setSelection} cursor={cursor} selection={selection} setCursor={setCursor} setSelection={setSelection}
flash={lineFlash && lineFlash.path === active ? lineFlash : null} flash={lineFlash && lineFlash.path === active ? lineFlash : null}
bufferText={bufferText(active)} onEdit={onEdit} /> bufferText={bufferText(active)} onEdit={onEdit} onSymbol={openSymbol} />
</div> </div>
<Splitter onDelta={(dx) => { setAutoResize(false); setRightW((w) => { <Splitter onDelta={(dx) => { setAutoResize(false); setRightW((w) => {
// grow until the editor would drop below ~280px (rather than a fixed cap) // grow until the editor would drop below ~280px (rather than a fixed cap)
@@ -1093,6 +1124,9 @@ export function App(): React.ReactElement {
{overlay === 'help' && <HelpModal onClose={() => setOverlay(null)} />} {overlay === 'help' && <HelpModal onClose={() => setOverlay(null)} />}
{overlay === 'notes' && <NotesModal text={note} onChange={setNote} onClose={() => { setOverlay(null); saveNote() }} />} {overlay === 'notes' && <NotesModal text={note} onChange={setNote} onClose={() => { setOverlay(null); saveNote() }} />}
{confirm && <ConfirmModal title={confirm.title} body={confirm.body} confirmLabel={confirm.confirmLabel} danger onConfirm={confirm.onConfirm} onClose={() => setConfirm(null)} />} {confirm && <ConfirmModal title={confirm.title} body={confirm.body} confirmLabel={confirm.confirmLabel} danger onConfirm={confirm.onConfirm} onClose={() => setConfirm(null)} />}
{symName && <SymbolPopup name={symName} data={symData}
onOpenAt={(p, n) => openFile(p, { line: n })} onSymbol={openSymbol}
onClose={() => { setSymName(null); setSymData(null) }} />}
{menu && <ContextMenu menu={menu} onClose={() => setMenu(null)} />} {menu && <ContextMenu menu={menu} onClose={() => setMenu(null)} />}
<Toasts toasts={toasts} /> <Toasts toasts={toasts} />
</div> </div>

View File

@@ -4,10 +4,46 @@ import type { Diff, DiffSide, ViewLine } from './types'
import { rowId } from './types' import { rowId } from './types'
import { useProject } from './project' import { useProject } from './project'
import { HL } from './highlight' import { HL } from './highlight'
import { isKnownSymbol, useSymbols } from './symbols'
import { renderMarkdown } from './markdown' import { renderMarkdown } from './markdown'
import { FileIcon, Icon } from './components' import { FileIcon, Icon } from './components'
import type { OnContext } 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 } export interface Cursor { path: string; line: number; col: number }
/** A one-second line highlight after a jump. `id` changes per jump, so the same /** A one-second line highlight after a jump. `id` changes per jump, so the same
* line twice restarts the animation. */ * 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 * 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 * 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. */ * 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 path: string
text: string text: string
lang: string | null lang: string | null
@@ -41,16 +77,22 @@ function CodeEditor({ path, text, lang, wrap, flash, onChange, onContext }: {
flash: FlashLine | null flash: FlashLine | null
onChange: (text: string) => void onChange: (text: string) => void
onContext: OnContext onContext: OnContext
onSymbol: OnSymbol
}): React.ReactElement { }): React.ReactElement {
const scrollRef = useRef<HTMLDivElement>(null) const scrollRef = useRef<HTMLDivElement>(null)
const gutterRef = useRef<HTMLDivElement>(null) const gutterRef = useRef<HTMLDivElement>(null)
const preRef = useRef<HTMLPreElement>(null) const preRef = useRef<HTMLPreElement>(null)
const tabSize = useProject().config.editor.tabSize const tabSize = useProject().config.editor.tabSize
const symVer = useSymbols() // re-highlight once the declared-name list lands
const metaDown = useMetaKey()
const html = useMemo( const html = useMemo(
() => (wrap () => (wrap
? text.split('\n').map((l) => `<div class="ce-line">${HL.hlLine(l, lang)}</div>`).join('') ? text.split('\n').map((l) => `<div class="ce-line">${HL.hlLine(l, lang)}</div>`).join('')
: HL.hlText(text, lang) + '\n'), : 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]) const count = useMemo(() => text.split('\n').length, [text])
// The band sits behind the text, so it needs the geometry of the line. Unwrapped // 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<HTMLTextAreaElement>): void { function onKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>): void {
if (e.key === 'Tab' && !e.metaKey && !e.ctrlKey && !e.altKey) handleTab(e, e.shiftKey) 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<HTMLTextAreaElement>): 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<HTMLTextAreaElement>): void { function handleContext(e: React.MouseEvent<HTMLTextAreaElement>): void {
e.preventDefault() e.preventDefault()
const ta = e.currentTarget const ta = e.currentTarget
@@ -135,7 +197,7 @@ function CodeEditor({ path, text, lang, wrap, flash, onChange, onContext }: {
onContext(e, info) onContext(e, info)
} }
return ( return (
<div className={'code-edit' + (wrap ? ' wrap' : '')}> <div className={'code-edit' + (wrap ? ' wrap' : '') + (metaDown ? ' sym-live' : '')}>
{!wrap && ( {!wrap && (
<div className="ce-gutterwrap"> <div className="ce-gutterwrap">
<div className="ce-gutter" ref={gutterRef}> <div className="ce-gutter" ref={gutterRef}>
@@ -143,7 +205,7 @@ function CodeEditor({ path, text, lang, wrap, flash, onChange, onContext }: {
</div> </div>
</div> </div>
)} )}
<div className="ce-scroll" ref={scrollRef} onScroll={onScroll}> <div className="ce-scroll" ref={scrollRef} onScroll={onScroll} onClick={handleTokenClick}>
<div className="ce-inner"> <div className="ce-inner">
{flash && flashBox && <div key={flash.id} className="ce-flash" style={{ top: flashBox.top, height: flashBox.height }} />} {flash && flashBox && <div key={flash.id} className="ce-flash" style={{ top: flashBox.top, height: flashBox.height }} />}
<textarea className="ce-ta" value={text} spellCheck={false} autoComplete="off" <textarea className="ce-ta" value={text} spellCheck={false} autoComplete="off"
@@ -151,7 +213,7 @@ function CodeEditor({ path, text, lang, wrap, flash, onChange, onContext }: {
onChange={(e) => { onChange(e.target.value); ensureCaretVisible(e.target) }} onChange={(e) => { onChange(e.target.value); ensureCaretVisible(e.target) }}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
onKeyUp={(e) => ensureCaretVisible(e.currentTarget)} onKeyUp={(e) => ensureCaretVisible(e.currentTarget)}
onClick={(e) => ensureCaretVisible(e.currentTarget)} onClick={handleClick}
onContextMenu={handleContext} /> onContextMenu={handleContext} />
<pre className="ce-pre" ref={preRef} aria-hidden style={{ tabSize }} dangerouslySetInnerHTML={{ __html: html }} /> <pre className="ce-pre" ref={preRef} aria-hidden style={{ tabSize }} dangerouslySetInnerHTML={{ __html: html }} />
</div> </div>
@@ -197,7 +259,7 @@ function ImageView({ path, onContext }: { path: string; onContext: OnContext }):
} }
/* Generic pane: renders an array of line descriptors with selection + caret + context. */ /* Generic pane: renders an array of line descriptors with selection + caret + context. */
function PaneView({ cacheKey, path, lines, lang, showSign, wrap, cursor, selection, setCursor, setSelection, onContext }: { function PaneView({ cacheKey, path, lines, lang, showSign, wrap, cursor, selection, setCursor, setSelection, onContext, onSymbol }: {
cacheKey: string cacheKey: string
path: string path: string
lines: ViewLine[] lines: ViewLine[]
@@ -210,9 +272,20 @@ function PaneView({ cacheKey, path, lines, lang, showSign, wrap, cursor, selecti
setCursor: (c: Cursor) => void setCursor: (c: Cursor) => void
setSelection: (s: Selection | null) => void setSelection: (s: Selection | null) => void
onContext: OnContext onContext: OnContext
onSymbol: OnSymbol
}): React.ReactElement { }): React.ReactElement {
const anchorRef = useRef<number | null>(null) const anchorRef = useRef<number | null>(null)
const html = useMemo(() => lines.map((l) => HL.hlLine(l.text, lang)), [cacheKey]) const symVer = useSymbols()
const metaDown = useMetaKey()
const html = useMemo(() => lines.map((l) => HL.hlLine(l.text, lang)), [cacheKey, symVer])
/* These rows are real spans, so the marked token is the click target itself. */
function onSymbolClick(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 gutterClick(e: React.MouseEvent, no: number | null): void { function gutterClick(e: React.MouseEvent, no: number | null): void {
if (no == null) return if (no == null) return
@@ -283,7 +356,8 @@ function PaneView({ cacheKey, path, lines, lang, showSign, wrap, cursor, selecti
const sel = selection && selection.path === path ? selection : null const sel = selection && selection.path === path ? selection : null
return ( return (
<div className={'editor' + (showSign ? ' diff' : '') + (wrap ? ' wrap' : '')} onMouseUp={onMouseUp} onContextMenu={handleContext}> <div className={'editor' + (showSign ? ' diff' : '') + (wrap ? ' wrap' : '') + (metaDown ? ' sym-live' : '')}
onMouseUp={onMouseUp} onClick={onSymbolClick} onContextMenu={handleContext}>
{lines.map((l, i) => { {lines.map((l, i) => {
const no = l.no const no = l.no
const inSel = sel && no != null && no >= sel.start && no <= sel.end const inSel = sel && no != null && no >= sel.start && no <= sel.end
@@ -327,7 +401,7 @@ function segmentsFor(hasDiff: boolean, isMarkdown: boolean): { id: Mode; label:
return segs return segs
} }
export function Editor({ active, mode, side, setMode, onContext, onSplit, splitOpen, cursor, selection, setCursor, setSelection, flash, bufferText, onEdit }: { export function Editor({ active, mode, side, setMode, onContext, onSplit, splitOpen, cursor, selection, setCursor, setSelection, flash, bufferText, onEdit, onSymbol }: {
active: string | null active: string | null
mode: Mode mode: Mode
/** Which git row opened this tab. Only Diff and Split follow it. */ /** Which git row opened this tab. Only Diff and Split follow it. */
@@ -344,6 +418,8 @@ export function Editor({ active, mode, side, setMode, onContext, onSplit, splitO
flash: FlashLine | null flash: FlashLine | null
bufferText: string bufferText: string
onEdit: (text: string) => void onEdit: (text: string) => void
/** ⌘-click on a class name declared in this project. */
onSymbol: OnSymbol
}): React.ReactElement { }): React.ReactElement {
const PROJECT = useProject() const PROJECT = useProject()
const tab = active ? { path: active } : null const tab = active ? { path: active } : null
@@ -451,11 +527,11 @@ export function Editor({ active, mode, side, setMode, onContext, onSplit, splitO
) : emptyOriginal ? ( ) : emptyOriginal ? (
<div className="empty-ed"><div className="big" style={{ color: 'var(--add)' }}>No original version</div><div style={{ fontFamily: 'var(--mono)', fontSize: 12, color: 'var(--fg-3)' }}>This file is new in the change.</div></div> <div className="empty-ed"><div className="big" style={{ color: 'var(--add)' }}>No original version</div><div style={{ fontFamily: 'var(--mono)', fontSize: 12, color: 'var(--fg-3)' }}>This file is new in the change.</div></div>
) : editable ? ( ) : editable ? (
<CodeEditor path={tab.path} text={bufferText} lang={lang} wrap={wrap} flash={flash} onChange={onEdit} onContext={onContext} /> <CodeEditor path={tab.path} text={bufferText} lang={lang} wrap={wrap} flash={flash} onChange={onEdit} onContext={onContext} onSymbol={onSymbol} />
) : ( ) : (
built && <PaneView cacheKey={tab.path + ':' + effMode + ':' + (effMode === 'diff' ? side ?? '' : '')} path={tab.path} lines={built.lines} built && <PaneView cacheKey={tab.path + ':' + effMode + ':' + (effMode === 'diff' ? side ?? '' : '')} path={tab.path} lines={built.lines}
lang={lang} showSign={built.showSign} wrap={wrap} cursor={cursor} selection={selection} lang={lang} showSign={built.showSign} wrap={wrap} cursor={cursor} selection={selection}
setCursor={setCursor} setSelection={setSelection} onContext={onContext} /> setCursor={setCursor} setSelection={setSelection} onContext={onContext} onSymbol={onSymbol} />
)} )}
</div> </div>
)} )}

View File

@@ -1,5 +1,5 @@
/// <reference types="vite/client" /> /// <reference types="vite/client" />
import type { FileNode, GitStatus, HelderConfig } from './types' import type { FileNode, GitStatus, HelderConfig, SymbolLookup } from './types'
interface GitChangeRaw { interface GitChangeRaw {
path: string path: string
@@ -66,6 +66,10 @@ interface HelderBridge {
content: (query: string) => Promise<{ path: string; hits: { no: number; ln: string; ix: number }[] }[]> content: (query: string) => Promise<{ path: string; hits: { no: number; ln: string; ix: number }[] }[]>
files: () => Promise<string[]> files: () => Promise<string[]>
} }
symbols: {
names: () => Promise<string[]>
lookup: (name: string) => Promise<SymbolLookup>
}
dialog: { dialog: {
unsavedClose: (path: string) => Promise<'save' | 'discard' | 'cancel'> unsavedClose: (path: string) => Promise<'save' | 'discard' | 'cancel'>
} }

View File

@@ -5,6 +5,7 @@
* prism-markup-templating to be loaded FIRST, or every Prism.highlight() call * prism-markup-templating to be loaded FIRST, or every Prism.highlight() call
* throws and silently falls back to plain text. */ * throws and silently falls back to plain text. */
import Prism from 'prismjs' import Prism from 'prismjs'
import { isKnownSymbol } from './symbols'
import 'prismjs/components/prism-markup-templating' import 'prismjs/components/prism-markup-templating'
import 'prismjs/components/prism-php' import 'prismjs/components/prism-php'
import 'prismjs/components/prism-python' import 'prismjs/components/prism-python'
@@ -59,12 +60,29 @@ function escapeHtml(s: string): string {
return s.replace(/[&<>]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[c] as string)) return s.replace(/[&<>]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[c] as string))
} }
/* Mark the class tokens this project actually declares, so ⌘-click has
* something honest to underline. Prism already tells us which tokens are class
* names; the index tells us which of those we can open. A token whose text is
* not a bare identifier (a namespaced `App\Models\Agent`) carries nested spans
* and never matches, which is right — the name to click sits inside it. */
const TOKEN_RE = /<span class="token ([^"]*)">([A-Za-z_]\w*)<\/span>/g
function markKnown(html: string): string {
return html.replace(TOKEN_RE, (whole, cls: string, name: string) => (
/\b(class-name|package)\b/.test(cls) && isKnownSymbol(name)
? `<span class="token ${cls} sym">${name}</span>`
: whole
))
}
// highlight a single line independently (keeps line numbering robust) // highlight a single line independently (keeps line numbering robust)
function hlLine(line: string, lang: string | null): string { function hlLine(line: string, lang: string | null): string {
if (line === '') return '&nbsp;' if (line === '') return '&nbsp;'
try { try {
const grammar = lang ? Prism.languages[lang] : null const grammar = lang ? Prism.languages[lang] : null
if (grammar) return Prism.highlight(line, grammar, lang as string) if (grammar) {
const html = Prism.highlight(line, grammar, lang as string)
return lang === 'php' ? markKnown(html) : html
}
} catch { } catch {
/* fall through */ /* fall through */
} }
@@ -75,7 +93,10 @@ function hlLine(line: string, lang: string | null): string {
function hlText(text: string, lang: string | null): string { function hlText(text: string, lang: string | null): string {
try { try {
const grammar = lang ? Prism.languages[lang] : null const grammar = lang ? Prism.languages[lang] : null
if (grammar) return Prism.highlight(text, grammar, lang as string) if (grammar) {
const html = Prism.highlight(text, grammar, lang as string)
return lang === 'php' ? markKnown(html) : html
}
} catch { } catch {
/* fall through */ /* fall through */
} }

View File

@@ -3,9 +3,11 @@ import React, { Fragment, useEffect, useMemo, useRef, useState } from 'react'
import { useProject } from './project' import { useProject } from './project'
import type { RecentProject } from './project' import type { RecentProject } from './project'
import { fuzzy } from './fuzzy' import { fuzzy } from './fuzzy'
import { isKnownSymbol } from './symbols'
import { FileIcon, Icon } from './components' import { FileIcon, Icon } from './components'
import { HL } from './highlight' import { HL } from './highlight'
import type { OpenFile } from './components' import type { OpenFile } from './components'
import type { SymbolDef, SymbolLookup } from './types'
export interface MenuItem { export interface MenuItem {
sep?: boolean sep?: boolean
@@ -66,6 +68,8 @@ export function SearchModal({ initialQuery, onOpen, onOpenAt, onClose, changeSet
type Col = 'infile' | 'content' | 'files' type Col = 'infile' | 'content' | 'files'
const cols: Col[] = hasInFile ? ['infile', 'content', 'files'] : ['content', 'files'] const cols: Col[] = hasInFile ? ['infile', 'content', 'files'] : ['content', 'files']
const [col, setCol] = useState<Col>('content') // active result column (⌘← / ⌘→ cycle) const [col, setCol] = useState<Col>('content') // active result column (⌘← / ⌘→ cycle)
// While the arrows drive, the parked pointer must stop painting its own row.
const [kbdNav, setKbdNav] = useState(true)
const inputRef = useRef<HTMLInputElement>(null) const inputRef = useRef<HTMLInputElement>(null)
const inFileRef = useRef<HTMLDivElement>(null) const inFileRef = useRef<HTMLDivElement>(null)
const leftRef = useRef<HTMLDivElement>(null) const leftRef = useRef<HTMLDivElement>(null)
@@ -199,11 +203,13 @@ export function SearchModal({ initialQuery, onOpen, onOpenAt, onClose, changeSet
} }
if (e.key === 'ArrowDown') { if (e.key === 'ArrowDown') {
e.preventDefault() e.preventDefault()
setKbdNav(true)
if (col === 'files') setFileSel((s) => Math.min(s + 1, fileCount - 1)) if (col === 'files') setFileSel((s) => Math.min(s + 1, fileCount - 1))
else if (col === 'infile') setInFileSel((s) => Math.min(s + 1, inFileCount - 1)) else if (col === 'infile') setInFileSel((s) => Math.min(s + 1, inFileCount - 1))
else setSel((s) => Math.min(s + 1, flat.length - 1)) else setSel((s) => Math.min(s + 1, flat.length - 1))
} else if (e.key === 'ArrowUp') { } else if (e.key === 'ArrowUp') {
e.preventDefault() e.preventDefault()
setKbdNav(true)
if (col === 'files') setFileSel((s) => Math.max(s - 1, 0)) if (col === 'files') setFileSel((s) => Math.max(s - 1, 0))
else if (col === 'infile') setInFileSel((s) => Math.max(s - 1, 0)) else if (col === 'infile') setInFileSel((s) => Math.max(s - 1, 0))
else setSel((s) => Math.max(s - 1, 0)) else setSel((s) => Math.max(s - 1, 0))
@@ -248,7 +254,7 @@ export function SearchModal({ initialQuery, onOpen, onOpenAt, onClose, changeSet
)} )}
<span className="mode-chip">{hasInFile && <>{inFile.length} here · </>}{totalHits} hit{totalHits === 1 ? '' : 's'} · {files.length} file{files.length === 1 ? '' : 's'}</span> <span className="mode-chip">{hasInFile && <>{inFile.length} here · </>}{totalHits} hit{totalHits === 1 ? '' : 's'} · {files.length} file{files.length === 1 ? '' : 's'}</span>
</div> </div>
<div className="search-cols"> <div className={'search-cols' + (kbdNav ? ' kbd-nav' : '')} onMouseMove={() => setKbdNav(false)}>
{hasInFile && ( {hasInFile && (
<div className={'sc-infile' + (col === 'infile' ? ' active' : '')} ref={inFileRef}> <div className={'sc-infile' + (col === 'infile' ? ' active' : '')} ref={inFileRef}>
<div className="sc-head"> <div className="sc-head">
@@ -335,11 +341,21 @@ export function HistoryModal({ history, initialSel, onOpen, onClose, changeSet }
const [sel, setSel] = useState(() => Math.min(Math.max(initialSel, 0), Math.max(history.length - 1, 0))) const [sel, setSel] = useState(() => Math.min(Math.max(initialSel, 0), Math.max(history.length - 1, 0)))
const selRef = useRef(sel); selRef.current = sel const selRef = useRef(sel); selRef.current = sel
const listRef = useRef<HTMLDivElement>(null) const listRef = useRef<HTMLDivElement>(null)
// While the keyboard drives, the pointer sits parked on whatever row it last
// touched and CSS :hover would keep painting it beside the selected row. The
// class tells the list to ignore hover until the mouse actually moves again.
const [kbdNav, setKbdNav] = useState(true)
function onMouseMove(e: React.MouseEvent): void {
setKbdNav(false)
const row = (e.target as HTMLElement).closest?.('.hist-row') as HTMLElement | null
const i = row?.dataset.i
if (i != null) setSel(+i)
}
useEffect(() => { useEffect(() => {
function onKey(e: KeyboardEvent): void { function onKey(e: KeyboardEvent): void {
if (e.key === 'ArrowDown') { e.preventDefault(); setSel((s) => Math.min(s + 1, history.length - 1)) } if (e.key === 'ArrowDown') { e.preventDefault(); setKbdNav(true); setSel((s) => Math.min(s + 1, history.length - 1)) }
else if (e.key === 'ArrowUp') { e.preventDefault(); setSel((s) => Math.max(s - 1, 0)) } else if (e.key === 'ArrowUp') { e.preventDefault(); setKbdNav(true); setSel((s) => Math.max(s - 1, 0)) }
else if (e.key === 'Enter') { e.preventDefault(); const p = history[selRef.current]; if (p) { onOpen(p); onClose() } } else if (e.key === 'Enter') { e.preventDefault(); const p = history[selRef.current]; if (p) { onOpen(p); onClose() } }
else if (e.key === 'Escape') { e.preventDefault(); onClose() } else if (e.key === 'Escape') { e.preventDefault(); onClose() }
} }
@@ -360,14 +376,13 @@ export function HistoryModal({ history, initialSel, onOpen, onClose, changeSet }
<span className="hist-title">Recent files</span> <span className="hist-title">Recent files</span>
<span className="mode-chip">{history.length} file{history.length === 1 ? '' : 's'} · <kbd></kbd> <kbd></kbd> <kbd></kbd></span> <span className="mode-chip">{history.length} file{history.length === 1 ? '' : 's'} · <kbd></kbd> <kbd></kbd> <kbd></kbd></span>
</div> </div>
<div className="hist-list" ref={listRef}> <div className={'hist-list' + (kbdNav ? ' kbd-nav' : '')} ref={listRef} onMouseMove={onMouseMove}>
{history.length === 0 && <div className="pempty">No files opened yet</div>} {history.length === 0 && <div className="pempty">No files opened yet</div>}
{history.map((p, i) => { {history.map((p, i) => {
const name = p.split('/').pop() as string const name = p.split('/').pop() as string
const dir = p.split('/').slice(0, -1).join('/') const dir = p.split('/').slice(0, -1).join('/')
return ( return (
<div key={p} className={'hist-row' + (i === sel ? ' sel' : '')} title={p} <div key={p} className={'hist-row' + (i === sel ? ' sel' : '')} title={p} data-i={i}
onMouseEnter={() => setSel(i)}
onClick={() => { onOpen(p); onClose() }}> onClick={() => { onOpen(p); onClose() }}>
<FileIcon path={p} /> <FileIcon path={p} />
<div className="hist-txt"> <div className="hist-txt">
@@ -622,6 +637,147 @@ export function ContextMenu({ menu, onClose }: { menu: Menu | null; onClose: ()
) )
} }
/* ⌘-click on a PHP class name: where it is declared, then every reference to
* it. Both sections open a file at a line, so the whole popup is one flat list
* for the keyboard: ↑↓ move, ↵ opens, Esc closes.
*
* Centred on the window rather than anchored to the click: the list is as long
* as the class is popular, and a click near the bottom edge would cut it off. */
export function SymbolPopup({ name, data, onOpenAt, onSymbol, onClose }: {
name: string
/** null while the lookup runs. */
data: SymbolLookup | null
onOpenAt: (path: string, line: number) => void
/** Follow a parent class or interface without leaving the popup. */
onSymbol: (name: string) => void
onClose: () => void
}): React.ReactElement {
const ref = useRef<HTMLDivElement>(null)
const [hi, setHi] = useState(0)
const targets = useMemo(() => {
const out: { path: string; line: number }[] = []
data?.defs.forEach((d) => out.push({ path: d.path, line: d.line }))
data?.refs.forEach((g) => g.hits.forEach((h) => out.push({ path: g.path, line: h.no })))
return out
}, [data])
const hiRef = useRef(hi); hiRef.current = hi
const tRef = useRef(targets); tRef.current = targets
useEffect(() => { setHi(0) }, [data])
useEffect(() => {
const key = (e: KeyboardEvent): void => {
if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); onClose() }
else if (e.key === 'ArrowDown') { e.preventDefault(); e.stopPropagation(); setHi((i) => Math.min(i + 1, tRef.current.length - 1)) }
else if (e.key === 'ArrowUp') { e.preventDefault(); e.stopPropagation(); setHi((i) => Math.max(i - 1, 0)) }
else if (e.key === 'Enter') {
e.preventDefault(); e.stopPropagation()
const t = tRef.current[hiRef.current]
if (t) { onOpenAt(t.path, t.line); onClose() }
}
}
document.addEventListener('keydown', key, true)
return () => { document.removeEventListener('keydown', key, true) }
}, [onClose, onOpenAt])
useEffect(() => {
const el = ref.current?.querySelector('.sym-row.hi')
if (el) el.scrollIntoView({ block: 'nearest' })
}, [hi])
// One declaration: name it in full, the way the file does. Several: the short
// name is the only thing they share.
const only = data && data.defs.length === 1 ? data.defs[0] : null
const title = only && only.ns ? only.ns + '\\' + name : name
let ix = -1
return (
<div className="scrim" onMouseDown={onClose}>
<div className="sym-popup" ref={ref} onMouseDown={(e) => e.stopPropagation()}>
<div className="sym-head">
<span className="sym-name">{title}</span>
{data && <span className="sym-count">{data.refCount} reference{data.refCount === 1 ? '' : 's'}</span>}
</div>
{!data && <div className="pempty sm">Searching</div>}
{data && (
<div className="sym-body">
<div className="sym-sec">Defined in</div>
{data.defs.length === 0 && <div className="pempty sm">No declaration in this project</div>}
{data.defs.map((d) => {
ix++
const me = ix
return (
<div key={d.path + d.line} className={'sym-row def' + (me === hi ? ' hi' : '')}
onMouseEnter={() => setHi(me)}
onClick={() => { onOpenAt(d.path, d.line); onClose() }}>
<FileIcon path={d.path} />
<span className="sym-path">{d.path}</span>
<span className="sym-kind">{d.kind}</span>
<span className="no">{d.line}</span>
</div>
)
})}
{data.defs.map((d) => (
<SymbolRelations key={'rel' + d.path + d.line} def={d} onSymbol={onSymbol} />
))}
{data.refs.length > 0 && <div className="sym-sec">Used in</div>}
{data.refs.map((g) => (
<Fragment key={g.path}>
<div className="sr-file" onClick={() => { onOpenAt(g.path, g.hits[0].no); onClose() }}>
<FileIcon path={g.path} />
<span className="srf-name">{g.path}</span>
<span className="cnt">{g.hits.length}</span>
</div>
{g.hits.map((h) => {
ix++
const me = ix
return (
<div key={h.no} className={'sym-row sr-line' + (me === hi ? ' hi sel' : '')}
onMouseEnter={() => setHi(me)}
onClick={() => { onOpenAt(g.path, h.no); onClose() }}>
<span className="no">{h.no}</span>
<span className="tx">{h.ln.slice(0, 200)}</span>
</div>
)
})}
</Fragment>
))}
</div>
)}
</div>
</div>
)
}
/* What a declaration extends and implements, read off its own line. A parent
* this project declares is a chip you can follow; a vendor or built-in parent
* stays plain text, for the same reason a token only underlines when we can
* actually open it. */
function SymbolRelations({ def, onSymbol }: { def: SymbolDef; onSymbol: (name: string) => void }): React.ReactElement | null {
// Read defensively: this object crossed IPC, and a main process from before
// these fields existed (a dev session that did not restart, a cached index)
// must degrade to "no relations", never take the window down.
const parents = def.parents ?? []
const interfaces = def.interfaces ?? []
const traits = def.traits ?? []
if (!parents.length && !interfaces.length && !traits.length) return null
const chip = (n: string): React.ReactElement => (
isKnownSymbol(n)
? <button key={n} className="sym-chip known" onClick={() => onSymbol(n)}>{n}</button>
: <span key={n} className="sym-chip">{n}</span>
)
return (
<div className="sym-rel">
{parents.length > 0 && (
<div className="sym-rel-row"><span className="sym-rel-key">extends</span>{parents.map(chip)}</div>
)}
{interfaces.length > 0 && (
<div className="sym-rel-row"><span className="sym-rel-key">implements</span>{interfaces.map(chip)}</div>
)}
{traits.length > 0 && (
<div className="sym-rel-row"><span className="sym-rel-key">uses</span>{traits.map(chip)}</div>
)}
</div>
)
}
export function Toasts({ toasts }: { toasts: Toast[] }): React.ReactElement { export function Toasts({ toasts }: { toasts: Toast[] }): React.ReactElement {
return ( return (
<div className="toast-wrap"> <div className="toast-wrap">

View File

@@ -195,16 +195,35 @@ kbd { flex:0 0 auto; font-family:var(--mono); font-size:11.5px; color:var(--fg-3
.git-row { .git-row {
display:flex; align-items:center; gap:8px; padding:3px 12px 3px 14px; cursor:pointer; position:relative; display:flex; align-items:center; gap:8px; padding:3px 12px 3px 14px; cursor:pointer; position:relative;
} }
/* Row states, one language for the Git list and the Explorer. /* ===== Row states one language for every list that offers a row to click ====
open — the file the viewer shows: lighter tint, 3px accent bar. hover / focus — "a click or ↵ acts here": --row-soft, 3px --accent-dim bar.
focus — "↵ acts here": the same pair at 30%. open — the file the viewer shows: --sel, 3px solid --accent bar.
hover — the same as focus, because the pointer moves the cursor onto the row. Hover and focus look alike on purpose: the pointer moves the row cursor onto
Order matters: open is listed last, so it wins on a row that is also focused. */ whatever it touches, so only one row ever carries the pair. Open wins over
.git-row:hover, .git-row.ctx, .git-row.kbd { background:var(--row-soft); } both, which is why its rules come last.
.git-row:hover::before, .git-row.ctx::before, .git-row.kbd::before { content:""; position:absolute; left:0; top:0; bottom:0; width:3px; background:var(--accent-dim); } Lists covered: Git, Explorer, Recent files, the search overlay (file names,
file headers, content lines), the project palette, the ⌘-click popup.
A context menu is deliberately left out — it is a menu, not a list of state. */
.git-row, .tree-row, .hist-row, .fres, .sr-file, .sr-line, .pres, .sym-row { position:relative; }
.git-row:hover, .git-row.ctx, .git-row.kbd,
.tree-row:hover, .tree-row.ctx, .tree-row.kbd,
.hist-row:hover, .hist-row.sel,
.fres:hover, .fres.sel,
.sr-file:hover, .sr-line:hover, .sr-line.sel,
.pres:hover, .pres.sel,
.sym-row:hover, .sym-row.hi { background:var(--row-soft); }
.git-row:hover::before, .git-row.ctx::before, .git-row.kbd::before,
.tree-row:hover::before, .tree-row.ctx::before, .tree-row.kbd::before,
.hist-row:hover::before, .hist-row.sel::before,
.fres:hover::before, .fres.sel::before,
.sr-file:hover::before, .sr-line:hover::before, .sr-line.sel::before,
.pres:hover::before, .pres.sel::before,
.sym-row:hover::before, .sym-row.hi::before {
content:""; position:absolute; left:0; top:0; bottom:0; width:3px; background:var(--accent-dim);
}
.git-row.active, .tree-row.active { background:var(--sel); }
.git-row.active::before, .tree-row.active::before { content:""; position:absolute; left:0; top:0; bottom:0; width:3px; background:var(--accent); }
.git-row.ctx .git-act, .git-row.kbd .git-act { visibility:visible; } .git-row.ctx .git-act, .git-row.kbd .git-act { visibility:visible; }
.git-row.active { background:var(--sel); }
.git-row.active::before { content:""; position:absolute; left:0; top:0; bottom:0; width:3px; background:var(--accent); }
.git-stat { width:13px; text-align:center; font-family:var(--mono); font-size:11px; font-weight:600; flex:0 0 13px; } .git-stat { width:13px; text-align:center; font-family:var(--mono); font-size:11px; font-weight:600; flex:0 0 13px; }
.git-stat.M{color:var(--mod);} .git-stat.A{color:var(--add);} .git-stat.D{color:var(--del);} .git-stat.R{color:var(--ren);} .git-stat.U{color:var(--add);} .git-stat.M{color:var(--mod);} .git-stat.A{color:var(--add);} .git-stat.D{color:var(--del);} .git-stat.R{color:var(--ren);} .git-stat.U{color:var(--add);}
.git-name { font-size:12.5px; color:var(--fg-1); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } .git-name { font-size:12.5px; color:var(--fg-1); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
@@ -224,12 +243,8 @@ kbd { flex:0 0 auto; font-family:var(--mono); font-size:11.5px; color:var(--fg-3
/* ============ file tree ============ */ /* ============ file tree ============ */
.tree-body { overflow:auto; flex:1; padding:4px 0 14px; } .tree-body { overflow:auto; flex:1; padding:4px 0 14px; }
.tree-row { display:flex; align-items:center; gap:6px; padding:2px 10px 2px 0; cursor:pointer; white-space:nowrap; position:relative; height:23px; } .tree-row { display:flex; align-items:center; gap:6px; padding:2px 10px 2px 0; cursor:pointer; white-space:nowrap; position:relative; height:23px; }
/* Same three states as the git list. A folder never reaches `active` — it opens /* A folder never reaches `active` — it opens in the tree, not in the viewer —
in the tree, not in the viewer — but it takes focus and hover like a file. */ but it takes focus and hover like a file. See the row-state block above. */
.tree-row:hover, .tree-row.ctx, .tree-row.kbd { background:var(--row-soft); }
.tree-row:hover::before, .tree-row.ctx::before, .tree-row.kbd::before { content:""; position:absolute; left:0; top:0; bottom:0; width:3px; background:var(--accent-dim); }
.tree-row.active { background:var(--sel); }
.tree-row.active::before { content:""; position:absolute; left:0; top:0; bottom:0; width:3px; background:var(--accent); }
.tw { display:inline-flex; align-items:center; justify-content:center; width:14px; flex:0 0 14px; color:var(--fg-3); font-size:10px; } .tw { display:inline-flex; align-items:center; justify-content:center; width:14px; flex:0 0 14px; color:var(--fg-3); font-size:10px; }
.tree-label { font-size:12.5px; color:var(--fg-1); overflow:hidden; text-overflow:ellipsis; } .tree-label { font-size:12.5px; color:var(--fg-1); overflow:hidden; text-overflow:ellipsis; }
.tree-row.active .tree-label { color:var(--fg-0); } .tree-row.active .tree-label { color:var(--fg-0); }
@@ -424,8 +439,7 @@ kbd { flex:0 0 auto; font-family:var(--mono); font-size:11.5px; color:var(--fg-3
.search-modal .ext-chip:hover { opacity:1; } .search-modal .ext-chip:hover { opacity:1; }
.search-modal .ext-chip.on { opacity:1; color:var(--accent); border-color:var(--accent-line); background:var(--accent-soft); } .search-modal .ext-chip.on { opacity:1; color:var(--accent); border-color:var(--accent-line); background:var(--accent-soft); }
.palette .results { max-height:380px; overflow:auto; padding:6px; } .palette .results { max-height:380px; overflow:auto; padding:6px; }
.pres { display:flex; align-items:center; gap:10px; padding:7px 10px; border-radius:7px; cursor:pointer; } .pres { display:flex; align-items:center; gap:10px; padding:7px 10px; cursor:pointer; }
.pres.sel { background:var(--accent-soft); }
.pres .pn { font-size:13px; color:var(--fg-0); } .pres .pn { font-size:13px; color:var(--fg-0); }
.pres .pn b { color:var(--accent); font-weight:700; } .pres .pn b { color:var(--accent); font-weight:700; }
.pres .pp { font-size:11px; color:var(--fg-3); margin-left:auto; font-family:var(--mono); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:55%; direction:rtl; } .pres .pp { font-size:11px; color:var(--fg-3); margin-left:auto; font-family:var(--mono); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:55%; direction:rtl; }
@@ -450,7 +464,6 @@ kbd { flex:0 0 auto; font-family:var(--mono); font-size:11.5px; color:var(--fg-3
.srf-name { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } .srf-name { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.pempty.sm { padding:18px 14px; text-align:left; font-size:11.5px; } .pempty.sm { padding:18px 14px; text-align:left; font-size:11.5px; }
.fres { display:flex; align-items:center; gap:9px; padding:6px 12px; cursor:pointer; } .fres { display:flex; align-items:center; gap:9px; padding:6px 12px; cursor:pointer; }
.fres:hover { background:var(--hover); }
.fres-txt { min-width:0; display:flex; flex-direction:column; line-height:1.25; } .fres-txt { min-width:0; display:flex; flex-direction:column; line-height:1.25; }
.fres-txt .fn { font-size:12.5px; color:var(--fg-0); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } .fres-txt .fn { font-size:12.5px; color:var(--fg-0); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.fres-txt .fn b { color:var(--accent); font-weight:700; } .fres-txt .fn b { color:var(--accent); font-weight:700; }
@@ -462,11 +475,16 @@ kbd { flex:0 0 auto; font-family:var(--mono); font-size:11.5px; color:var(--fg-3
.history-modal .pi svg { flex:0 0 auto; } .history-modal .pi svg { flex:0 0 auto; }
.history-modal .hist-title { flex:1; min-width:0; color:var(--fg-1); font-size:14px; } .history-modal .hist-title { flex:1; min-width:0; color:var(--fg-1); font-size:14px; }
.history-modal .mode-chip { flex:0 0 auto; white-space:nowrap; font-size:10px; color:var(--fg-3); border:1px solid var(--border-2); border-radius:5px; padding:2px 7px; font-family:var(--mono); display:flex; align-items:center; gap:4px; } .history-modal .mode-chip { flex:0 0 auto; white-space:nowrap; font-size:10px; color:var(--fg-3); border:1px solid var(--border-2); border-radius:5px; padding:2px 7px; font-family:var(--mono); display:flex; align-items:center; gap:4px; }
.hist-list { max-height:460px; overflow:auto; padding:5px 0; } /* stable gutter: without it the scrollbar eats into the rows, so the change dot
.hist-row { display:flex; align-items:center; gap:9px; padding:6px 13px; cursor:pointer; } sits at a different distance from the edge than the header above it. */
.hist-row.sel { background:var(--accent-dim, rgba(241,159,63,0.14)); box-shadow:inset 2px 0 0 var(--accent); } .hist-list { max-height:460px; overflow-y:auto; scrollbar-gutter:stable; padding:5px 0; }
.hist-row:hover { background:var(--hover); } .hist-row { display:flex; align-items:center; gap:9px; padding:6px 13px; cursor:pointer; position:relative; }
.hist-row.sel:hover { background:rgba(241,159,63,0.20); } /* ⌘↑/⌘↓ moves the selection while the pointer stays put, so hover has to give
way until the mouse moves again — otherwise two rows read as focused. */
.hist-list.kbd-nav .hist-row:hover:not(.sel) { background:transparent; }
.hist-list.kbd-nav .hist-row:hover:not(.sel)::before { content:none; }
.search-cols.kbd-nav .sr-line:hover:not(.sel), .search-cols.kbd-nav .fres:hover:not(.sel) { background:transparent; }
.search-cols.kbd-nav .sr-line:hover:not(.sel)::before, .search-cols.kbd-nav .fres:hover:not(.sel)::before { content:none; }
.hist-txt { min-width:0; display:flex; flex-direction:column; line-height:1.25; } .hist-txt { min-width:0; display:flex; flex-direction:column; line-height:1.25; }
.hist-txt .fn { font-size:12.5px; color:var(--fg-0); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } .hist-txt .fn { font-size:12.5px; color:var(--fg-0); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.hist-txt .fd { font-size:10.5px; color:var(--fg-3); font-family:var(--mono); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } .hist-txt .fd { font-size:10.5px; color:var(--fg-3); font-family:var(--mono); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
@@ -477,8 +495,6 @@ kbd { flex:0 0 auto; font-family:var(--mono); font-size:11.5px; color:var(--fg-3
.sr-file:hover { color:var(--fg-0); } .sr-file:hover { color:var(--fg-0); }
.sr-file .cnt { margin-left:auto; color:var(--fg-3); font-family:var(--mono); font-size:10.5px; } .sr-file .cnt { margin-left:auto; color:var(--fg-3); font-family:var(--mono); font-size:10.5px; }
.sr-line { display:flex; gap:12px; padding:2px 14px 2px 38px; font-family:var(--mono); font-size:12px; cursor:pointer; color:var(--fg-1); } .sr-line { display:flex; gap:12px; padding:2px 14px 2px 38px; font-family:var(--mono); font-size:12px; cursor:pointer; color:var(--fg-1); }
.sr-line:hover { background:var(--hover); }
.sr-line.sel { background:var(--accent-soft); }
.sr-line .no { color:var(--fg-3); min-width:34px; text-align:right; } .sr-line .no { color:var(--fg-3); min-width:34px; text-align:right; }
.sr-line .tx { white-space:pre; overflow:hidden; text-overflow:ellipsis; } .sr-line .tx { white-space:pre; overflow:hidden; text-overflow:ellipsis; }
.sr-line mark { background:rgba(216,168,92,.28); color:var(--fg-0); border-radius:2px; } .sr-line mark { background:rgba(216,168,92,.28); color:var(--fg-0); border-radius:2px; }
@@ -508,6 +524,42 @@ kbd { flex:0 0 auto; font-family:var(--mono); font-size:11.5px; color:var(--fg-3
position:fixed rule can never collide with the `.tree-row.ctx` / `.git-row.ctx` position:fixed rule can never collide with the `.tree-row.ctx` / `.git-row.ctx`
highlight class — that collision pulled the highlighted row out of flow and highlight class — that collision pulled the highlighted row out of flow and
made the row beneath it appear to vanish while the menu was open. */ made the row beneath it appear to vanish while the menu was open. */
/* ⌘-click on a PHP class: declarations on top, references under them. Sized to
read a line of code, capped so it never covers the whole viewer. */
.sym-popup { width:min(700px, 82vw); max-height:min(72vh, 640px); display:flex; flex-direction:column;
background:#23272d; border:1px solid var(--border-2); border-left:3px solid var(--accent); border-radius:4px; box-shadow:0 16px 44px rgba(0,0,0,.5); overflow:hidden; }
.sym-head { display:flex; align-items:center; gap:9px; padding:9px 13px; border-bottom:1px solid var(--border); background:var(--bg-3); }
.sym-name { font-family:var(--mono); font-size:13px; color:var(--fg-0); font-weight:600; }
.sym-count { margin-left:auto; font-family:var(--mono); font-size:10.5px; color:var(--fg-3); }
.sym-body { overflow:auto; padding-bottom:6px; }
.sym-sec { padding:8px 13px 4px; font-size:10px; letter-spacing:.07em; text-transform:uppercase; color:var(--fg-3); }
.sym-row { cursor:pointer; }
.sym-row.def { display:flex; align-items:center; gap:8px; padding:4px 13px; font-size:12px; color:var(--fg-1); }
.sym-row.def .sym-path { font-family:var(--mono); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.sym-row.def .sym-kind { font-size:10px; color:var(--fg-3); border:1px solid var(--border-2); border-radius:3px; padding:0 5px; }
.sym-row.def .no { margin-left:auto; font-family:var(--mono); font-size:11px; color:var(--fg-3); }
.sym-popup .sr-line { padding-left:26px; }
/* What the declaration is built from: extends / implements / uses. A name this
project declares is a button that reloads the popup on it. */
.sym-rel { padding:2px 13px 6px 34px; display:flex; flex-direction:column; gap:3px; }
.sym-rel-row { display:flex; align-items:center; gap:6px; flex-wrap:wrap; }
.sym-rel-key { font-size:10px; letter-spacing:.06em; text-transform:uppercase; color:var(--fg-3); min-width:66px; }
.sym-chip { font-family:var(--mono); font-size:11px; line-height:17px; padding:0 7px; border:1px solid var(--border); border-radius:3px; background:transparent; color:var(--fg-3); }
.sym-chip.known { color:var(--fg-1); border-color:var(--border-2); cursor:pointer; }
.sym-chip.known:hover { color:var(--accent); border-color:var(--accent-line); background:var(--accent-soft); }
/* A class name this project declares, underlined only while ⌘ is held — the
affordance shows exactly when the click would do something.
The highlight layer is pointer-events:none so the textarea below it keeps the
caret; while ⌘ is held these tokens alone take the mouse back, which is what
gives them a real :hover, a pointer cursor and a click target carrying the
name. Everything else on the line still falls through to the textarea. */
.code-edit.sym-live .token.sym, .editor.sym-live .token.sym {
pointer-events:auto; cursor:pointer;
text-decoration:underline; text-decoration-color:var(--accent); text-underline-offset:3px;
}
.code-edit.sym-live .token.sym:hover, .editor.sym-live .token.sym:hover { filter:brightness(1.25); }
.ctx-menu { position:fixed; z-index:80; background:#23272d; border:1px solid var(--border-2); border-radius:9px; padding:5px; min-width:248px; box-shadow:0 16px 44px rgba(0,0,0,.5); } .ctx-menu { position:fixed; z-index:80; background:#23272d; border:1px solid var(--border-2); border-radius:9px; padding:5px; min-width:248px; box-shadow:0 16px 44px rgba(0,0,0,.5); }
.ctx-item { display:flex; align-items:center; gap:10px; padding:7px 10px; border-radius:6px; cursor:pointer; font-size:12.5px; color:var(--fg-1); } .ctx-item { display:flex; align-items:center; gap:10px; padding:7px 10px; border-radius:6px; cursor:pointer; font-size:12.5px; color:var(--fg-1); }
.ctx-item:hover, .ctx-item.hi { background:var(--accent-soft); color:var(--fg-0); } .ctx-item:hover, .ctx-item.hi { background:var(--accent-soft); color:var(--fg-0); }
@@ -569,9 +621,14 @@ kbd { flex:0 0 auto; font-family:var(--mono); font-size:11.5px; color:var(--fg-3
} }
/* xterm.js host (real terminals) */ /* xterm.js host (real terminals) */
.term-xterm { flex:1; min-height:0; overflow:hidden; padding:6px 4px 6px 8px; background:var(--bg-1); } /* The pane carries the colour, taken from the terminal palette in config.json,
so the padding around the canvas can never sit on a different ground. */
.term-xterm { flex:1; min-height:0; overflow:hidden; padding:6px 4px 6px 8px; background:transparent; }
.term-xterm .xterm { height:100%; } .term-xterm .xterm { height:100%; }
.term-xterm .xterm-viewport { background:transparent !important; } .term-xterm .xterm-viewport { background:transparent !important; }
.term-xterm .xterm-viewport::-webkit-scrollbar { width:9px; }
.term-xterm .xterm-viewport::-webkit-scrollbar-thumb { background:rgba(255,255,255,0.13); border-radius:5px; border:2px solid transparent; background-clip:content-box; }
.term-xterm .xterm-viewport::-webkit-scrollbar-thumb:hover { background:rgba(255,255,255,0.22); background-clip:content-box; }
/* ============ Electron chrome integration ============ */ /* ============ Electron chrome integration ============ */
/* macOS shows native traffic lights (titleBarStyle: hiddenInset); the prototype's /* macOS shows native traffic lights (titleBarStyle: hiddenInset); the prototype's
@@ -659,4 +716,3 @@ kbd { flex:0 0 auto; font-family:var(--mono); font-size:11.5px; color:var(--fg-3
.sc-left.active .sc-head, .sc-right.active .sc-head, .sc-infile.active .sc-head { color:var(--accent); } .sc-left.active .sc-head, .sc-right.active .sc-head, .sc-infile.active .sc-head { color:var(--accent); }
.sc-left.active .sc-head .col-kbd, .sc-right.active .sc-head .col-kbd, .sc-infile.active .sc-head .col-kbd { color:var(--accent); border-color:var(--accent-line); opacity:1; } .sc-left.active .sc-head .col-kbd, .sc-right.active .sc-head .col-kbd, .sc-infile.active .sc-head .col-kbd { color:var(--accent); border-color:var(--accent-line); opacity:1; }
.sc-infile.active .sc-head .scf-name { color:var(--accent); } .sc-infile.active .sc-head .scf-name { color:var(--accent); }
.fres.sel { background:var(--accent-soft); box-shadow:inset 2px 0 0 var(--accent); }

View File

@@ -0,0 +1,61 @@
/* Which PHP class names this project declares.
*
* A module-level Set rather than React state: `HL.hlText` marks the tokens
* while it builds the HTML, and it is called from four memos that have no
* business carrying a prop for it. Components that render code subscribe with
* `useSymbols()` and put the version in their memo deps, so highlighting
* recomputes once when the list lands — and never again while it is unchanged.
*
* The list is fetched on demand and the main process builds it lazily, so
* nothing here is on the startup path. Until it arrives the Set is empty, which
* simply means no token is underlined yet.
*/
import { useSyncExternalStore } from 'react'
import { rlog } from './log'
let known = new Set<string>()
let version = 0
let pending: Promise<void> | null = null
const listeners = new Set<() => void>()
function emit(): void {
version++
listeners.forEach((l) => l())
}
export function isKnownSymbol(name: string): boolean {
return known.has(name)
}
export function symbolsVersion(): number {
return version
}
/** Ask main for the declared names. Cheap to call repeatedly: one flight at a
* time, and a `force` refresh after the project changed. */
export function loadSymbols(force = false): void {
const bridge = window.helder
if (!bridge || pending) return
if (known.size > 0 && !force) return
pending = bridge.symbols.names()
.then((names) => { known = new Set(names); emit() })
.catch((e) => { rlog.error('symbols', 'name list failed', e) })
.finally(() => { pending = null })
}
/** Drop the list so the next `loadSymbols()` refetches (a file changed). */
export function invalidateSymbols(): void {
known = new Set()
emit()
}
function subscribe(cb: () => void): () => void {
listeners.add(cb)
return () => { listeners.delete(cb) }
}
/** Re-renders the caller when the name list changes. Returns the version, for
* the dependency array of a highlight memo. */
export function useSymbols(): number {
return useSyncExternalStore(subscribe, symbolsVersion, symbolsVersion)
}

View File

@@ -11,29 +11,49 @@ import '@xterm/xterm/css/xterm.css'
import { ContextMenu } from './overlays' import { ContextMenu } from './overlays'
import type { Menu } from './overlays' import type { Menu } from './overlays'
import { Icon } from './components' import { Icon } from './components'
import { useProject } from './project'
import type { HelderConfig } from './types'
let _lid = 0 let _lid = 0
export const lid = (): number => ++_lid export const lid = (): number => ++_lid
const MONO = '"JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace' const MONO = '"JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace'
// ANSI palette mapped onto Helder's charcoal tokens. /* Everything visual comes from `terminal` in config.json — palette, font,
const THEME = { * cursor, scrollback — so the console is themed from the same file as the rest
background: '#1a1c1f', * of the project. Font family and size fall back to the CSS vars (--code-font /
foreground: '#e6e8ea', * --term-size in theme.css) when the config leaves them null, which keeps the
cursor: '#4d8dff', * old "one place for the code font" rule intact. */
cursorAccent: '#1a1c1f', function xtermOptions(cfg: HelderConfig['terminal']): {
selectionBackground: 'rgba(77,141,255,0.55)', fontFamily: string; fontSize: number; lineHeight: number; letterSpacing: number
selectionInactiveBackground: 'rgba(77,141,255,0.40)', cursorStyle: 'bar' | 'block' | 'underline'; cursorBlink: boolean
black: '#16171a', red: '#e0696a', green: '#5cbd6b', yellow: '#d8a85c', drawBoldTextInBrightColors: boolean; scrollback: number; macOptionIsMeta: boolean
blue: '#4d8dff', magenta: '#c98bdb', cyan: '#6ec0c0', white: '#b4bac2', theme: HelderConfig['terminal']['theme']
brightBlack: '#5d636c', brightRed: '#e0696a', brightGreen: '#5cbd6b', brightYellow: '#d8a85c', } {
brightBlue: '#6aa6f0', brightMagenta: '#c98bdb', brightCyan: '#6ec0c0', brightWhite: '#e6e8ea', const css = getComputedStyle(document.documentElement)
return {
fontFamily: cfg.fontFamily || css.getPropertyValue('--code-font').trim() || MONO,
fontSize: cfg.fontSize || parseFloat(css.getPropertyValue('--term-size')) || 12.5,
lineHeight: cfg.lineHeight,
letterSpacing: cfg.letterSpacing,
cursorStyle: cfg.cursorStyle,
cursorBlink: cfg.cursorBlink,
drawBoldTextInBrightColors: cfg.boldIsBright,
scrollback: cfg.scrollback,
macOptionIsMeta: cfg.optionIsMeta,
theme: cfg.theme,
}
} }
export function Terminal({ kind }: { kind: 'agent' | 'shell' }): React.ReactElement { export function Terminal({ kind }: { kind: 'agent' | 'shell' }): React.ReactElement {
const hostRef = useRef<HTMLDivElement>(null) const hostRef = useRef<HTMLDivElement>(null)
const termRef = useRef<XTerm | null>(null) const termRef = useRef<XTerm | null>(null)
const fitRef = useRef<FitAddon | null>(null)
// The terminal is built once and lives as long as its PTY, so the config is
// read through a ref on creation and re-applied by the effect below — editing
// config.json restyles the running session instead of restarting it.
const cfg = useProject().config.terminal
const cfgRef = useRef(cfg); cfgRef.current = cfg
const [, setLive] = useState(kind === 'agent') const [, setLive] = useState(kind === 'agent')
const [menu, setMenu] = useState<Menu | null>(null) const [menu, setMenu] = useState<Menu | null>(null)
// Best-effort "is the agent composer non-empty?" flag. A passed reference must // Best-effort "is the agent composer non-empty?" flag. A passed reference must
@@ -47,20 +67,10 @@ export function Terminal({ kind }: { kind: 'agent' | 'shell' }): React.ReactElem
const host = hostRef.current const host = hostRef.current
if (!host) return if (!host) return
const css = getComputedStyle(document.documentElement) const term = new XTerm({ ...xtermOptions(cfgRef.current), allowProposedApi: true })
const fontFamily = css.getPropertyValue('--code-font').trim() || MONO
const fontSize = parseFloat(css.getPropertyValue('--term-size')) || 12.5
const term = new XTerm({
fontFamily,
fontSize,
lineHeight: 1.4,
cursorBlink: true,
theme: THEME,
scrollback: 5000,
allowProposedApi: true,
})
termRef.current = term termRef.current = term
const fit = new FitAddon() const fit = new FitAddon()
fitRef.current = fit
term.loadAddon(fit) term.loadAddon(fit)
term.open(host) term.open(host)
@@ -140,11 +150,21 @@ export function Terminal({ kind }: { kind: 'agent' | 'shell' }): React.ReactElem
if (kind === 'agent') window.removeEventListener('agentPaste', onPaste) if (kind === 'agent') window.removeEventListener('agentPaste', onPaste)
if (bridge && id >= 0) bridge.pty.kill(id) if (bridge && id >= 0) bridge.pty.kill(id)
termRef.current = null termRef.current = null
fitRef.current = null
term.dispose() term.dispose()
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []) }, [])
// Live restyle: config.json (or theme.css) changed, so push the new options
// into the running terminal and refit — the buffer and the PTY are untouched.
useEffect(() => {
const term = termRef.current
if (!term) return
Object.assign(term.options, xtermOptions(cfg))
fitRef.current?.fit()
}, [cfg])
// Both panes (D1 agent + D2 shell): right-click → copy selection / paste from clipboard. // Both panes (D1 agent + D2 shell): right-click → copy selection / paste from clipboard.
function onContextMenu(e: React.MouseEvent): void { function onContextMenu(e: React.MouseEvent): void {
const bridge = window.helder const bridge = window.helder
@@ -166,7 +186,7 @@ export function Terminal({ kind }: { kind: 'agent' | 'shell' }): React.ReactElem
} }
return ( return (
<div className="term-pane" style={{ flex: 1, minHeight: 0 }} <div className="term-pane" style={{ flex: 1, minHeight: 0, background: cfg.theme.background }}
onMouseDown={() => hostRef.current?.querySelector('textarea')?.focus()} onMouseDown={() => hostRef.current?.querySelector('textarea')?.focus()}
onContextMenu={onContextMenu}> onContextMenu={onContextMenu}>
<div className="term-xterm" ref={hostRef} /> <div className="term-xterm" ref={hostRef} />

View File

@@ -84,13 +84,42 @@ export type DiffMode = 'original' | 'updated' | 'diff'
/** Soft wrap of long lines: never, always, or only in Markdown files. */ /** Soft wrap of long lines: never, always, or only in Markdown files. */
export type WordWrap = 'off' | 'on' | 'markdown' 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
}
/** Effective project settings (mirrors src/main/config.ts). */ /** Effective project settings (mirrors src/main/config.ts). */
export interface HelderConfig { export interface HelderConfig {
ai: { command: string; autoLaunch: boolean } ai: { command: string; autoLaunch: boolean }
editor: { autoSave: boolean; tabSize: number; wordWrap: WordWrap } editor: { autoSave: boolean; tabSize: number; wordWrap: WordWrap }
git: { confirmDiscard: boolean; confirmStage: boolean; confirmUnstage: boolean; defaultDiffMode: DiffMode; refreshInterval: number } git: { confirmDiscard: boolean; confirmStage: boolean; confirmUnstage: boolean; defaultDiffMode: DiffMode; refreshInterval: number }
files: { exclude: string[]; followGitignore: boolean } 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 } session: { restoreOnLaunch: boolean }
} }
@@ -99,6 +128,49 @@ export const DEFAULT_CONFIG: HelderConfig = {
editor: { autoSave: false, tabSize: 4, wordWrap: 'markdown' }, editor: { autoSave: false, tabSize: 4, wordWrap: 'markdown' },
git: { confirmDiscard: true, confirmStage: false, confirmUnstage: false, defaultDiffMode: 'diff', refreshInterval: 10000 }, git: { confirmDiscard: true, confirmStage: false, confirmUnstage: false, defaultDiffMode: 'diff', refreshInterval: 10000 },
files: { exclude: [], followGitignore: true }, files: { exclude: [], followGitignore: true },
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 }, session: { restoreOnLaunch: true },
} }
/** One class/interface/trait/enum declaration in the project. */
export interface SymbolDef {
name: string; path: string; line: number; kind: string
/** The file's namespace, for the fully qualified name. */
ns: string
/** `extends` on the declaration line. */
parents: string[]
/** `implements` on the declaration line. */
interfaces: string[]
/** Traits mixed in by an indented `use` in the body. */
traits: string[]
}
/** References to one name, grouped by file. */
export interface RefGroup { path: string; hits: { no: number; ln: string; ix: number }[] }
/** What a ⌘-click on a class name returns: where it lives, and where it is used. */
export interface SymbolLookup { name: string; defs: SymbolDef[]; refs: RefGroup[]; refCount: number }

View File

@@ -3,6 +3,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path' import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest' import { afterEach, describe, expect, it } from 'vitest'
import { DEFAULTS, getConfig, getThemeCss, resolveConfig } from '../src/main/config' import { DEFAULTS, getConfig, getThemeCss, resolveConfig } from '../src/main/config'
import { DEFAULT_CONFIG as RENDERER_DEFAULTS } from '../src/renderer/src/types'
let dir = '' let dir = ''
afterEach(async () => { if (dir) await rm(dir, { recursive: true, force: true }) }) afterEach(async () => { if (dir) await rm(dir, { recursive: true, force: true }) })
@@ -52,3 +53,18 @@ describe('resolveConfig', () => {
expect(getThemeCss()).toContain('#ff0000') expect(getThemeCss()).toContain('#ff0000')
}) })
}) })
describe('terminal defaults', () => {
it('mirrors the main-process schema in the renderer', () => {
// Two copies of one schema drift silently; the terminal block is the one
// the user edits by hand, so it is worth asserting they still agree.
expect(RENDERER_DEFAULTS.terminal).toEqual(DEFAULTS.terminal)
})
it('carries a full ANSI table, so a partial config.json still resolves', () => {
const t = DEFAULTS.terminal.theme
for (const key of ['black', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white'] as const) {
expect(t[key]).toMatch(/^#[0-9a-f]{6}$/i)
expect(t[`bright${key[0].toUpperCase()}${key.slice(1)}` as keyof typeof t]).toMatch(/^#[0-9a-f]{6}$/i)
}
})
})

View File

@@ -0,0 +1,79 @@
// @vitest-environment jsdom
//
// The ⌘-click popup: declarations, what they are built from, and the references.
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import React from 'react'
import { SymbolPopup } from '../src/renderer/src/overlays'
import type { SymbolLookup } from '../src/renderer/src/types'
// jsdom has no layout, so the popup's "keep the highlighted row in view" effect
// needs a stub — the same one the other jsdom tests install.
if (!Element.prototype.scrollIntoView) Element.prototype.scrollIntoView = (): void => {}
afterEach(cleanup)
const noop = (): void => {}
function lookup(over: Partial<SymbolLookup> = {}): SymbolLookup {
return {
name: 'VaultSearch',
defs: [{
name: 'VaultSearch', path: 'app/Services/VaultSearch.php', line: 12, kind: 'class', ns: 'App\\Services',
parents: ['Base'], interfaces: ['Searchable', 'Arrayable'], traits: ['HasVault'],
}],
refs: [{ path: 'app/Mcp/Tools/Find.php', hits: [{ no: 8, ln: 'new VaultSearch($x)', ix: 4 }] }],
refCount: 1,
...over,
}
}
describe('SymbolPopup', () => {
it('lists the declaration, its relations and the references', () => {
render(<SymbolPopup name="VaultSearch" data={lookup()} onOpenAt={noop} onSymbol={noop} onClose={noop} />)
expect(screen.getByText('app/Services/VaultSearch.php')).toBeTruthy()
expect(screen.getByText('extends')).toBeTruthy()
expect(screen.getByText('Base')).toBeTruthy()
expect(screen.getByText('Searchable')).toBeTruthy()
expect(screen.getByText('Arrayable')).toBeTruthy()
expect(screen.getByText('HasVault')).toBeTruthy()
expect(screen.getByText('1 reference')).toBeTruthy()
expect(screen.getByText('app/Mcp/Tools/Find.php')).toBeTruthy()
})
it('opens the file at the clicked line', () => {
const onOpenAt = vi.fn()
render(<SymbolPopup name="VaultSearch" data={lookup()} onOpenAt={onOpenAt} onSymbol={noop} onClose={noop} />)
fireEvent.click(screen.getByText('app/Services/VaultSearch.php'))
expect(onOpenAt).toHaveBeenCalledWith('app/Services/VaultSearch.php', 12)
})
it('survives a declaration with no relation fields', () => {
// A def can arrive from a main process that predates those fields (a dev
// session that never restarted). It must render, not take the window down.
const bare = { name: 'A', path: 'app/A.php', line: 1, kind: 'class' } as never
const data = lookup({ defs: [bare], refs: [], refCount: 0 })
render(<SymbolPopup name="A" data={data} onOpenAt={noop} onSymbol={noop} onClose={noop} />)
expect(screen.getByText('app/A.php')).toBeTruthy()
expect(screen.queryByText('extends')).toBeNull()
})
it('names one declaration in full, and drops an empty Used in section', () => {
render(<SymbolPopup name="VaultSearch" data={lookup({ refs: [], refCount: 0 })} onOpenAt={noop} onSymbol={noop} onClose={noop} />)
expect(screen.getByText('App\\Services\\VaultSearch')).toBeTruthy()
expect(screen.queryByText('Used in')).toBeNull()
})
it('falls back to the short name when several classes share it', () => {
const two = lookup()
two.defs = [two.defs[0], { ...two.defs[0], path: 'app/Other/VaultSearch.php', ns: 'App\\Other' }]
render(<SymbolPopup name="VaultSearch" data={two} onOpenAt={noop} onSymbol={noop} onClose={noop} />)
expect(screen.getByText('VaultSearch')).toBeTruthy()
})
it('shows a searching state until the lookup lands', () => {
render(<SymbolPopup name="VaultSearch" data={null} onOpenAt={noop} onSymbol={noop} onClose={noop} />)
expect(screen.getByText('Searching…')).toBeTruthy()
})
})

108
test/symbols.test.ts Normal file
View File

@@ -0,0 +1,108 @@
import { describe, it, expect } from 'vitest'
import { isImportLine, parseDeclaration, parseNamespace, parseTraitUse } from '../src/main/symbols-service'
import { wordAt } from '../src/renderer/src/editor'
describe('parseDeclaration', () => {
it('reads every declaration keyword', () => {
expect(parseDeclaration('class Agent')).toMatchObject({ kind: 'class', name: 'Agent' })
expect(parseDeclaration('interface Searchable {')).toMatchObject({ kind: 'interface', name: 'Searchable' })
expect(parseDeclaration('trait HasVault')).toMatchObject({ kind: 'trait', name: 'HasVault' })
expect(parseDeclaration('enum Status: string')).toMatchObject({ kind: 'enum', name: 'Status' })
})
it('reads the parents off the declaration line, however many', () => {
const d = parseDeclaration('final class VaultSearch extends Base implements Searchable, Arrayable, Jsonable {')
expect(d?.parents).toEqual(['Base'])
expect(d?.interfaces).toEqual(['Searchable', 'Arrayable', 'Jsonable'])
})
it('drops the namespace of a fully qualified parent', () => {
const d = parseDeclaration('class A extends \\App\\Base implements \\App\\Contracts\\Runs')
expect(d?.parents).toEqual(['Base'])
expect(d?.interfaces).toEqual(['Runs'])
})
it('reads an interface extending several interfaces, and an enum contract', () => {
expect(parseDeclaration('interface A extends B, C')?.parents).toEqual(['B', 'C'])
expect(parseDeclaration('enum Status: string implements HasLabel')?.interfaces).toEqual(['HasLabel'])
})
it('leaves the lists empty when there is no clause', () => {
const d = parseDeclaration('class Plain {')
expect(d?.parents).toEqual([])
expect(d?.interfaces).toEqual([])
})
it('accepts the modifiers PHP allows in front', () => {
expect(parseDeclaration('final class A extends B')?.name).toBe('A')
expect(parseDeclaration('abstract class A')?.name).toBe('A')
expect(parseDeclaration('final readonly class A')?.name).toBe('A')
expect(parseDeclaration(' class Nested')?.name).toBe('Nested')
})
it('ignores a mention that is not a declaration', () => {
expect(parseDeclaration('$x = Agent::class;')).toBeNull()
expect(parseDeclaration('// class Agent lives here')).toBeNull()
expect(parseDeclaration('use App\\Models\\Agent;')).toBeNull()
expect(parseDeclaration('return $a instanceof Agent;')).toBeNull()
})
})
describe('isImportLine', () => {
it('drops a namespace import', () => {
expect(isImportLine('use App\\Models\\Agent;')).toBe(true)
expect(isImportLine('use App\\Support\\Str as S;')).toBe(true)
expect(isImportLine('use function App\\Support\\tap;')).toBe(true)
})
it('keeps a trait mixed into a class body', () => {
// The indent is the whole difference: a trait `use` is inside the class.
expect(isImportLine(' use HasFactory;')).toBe(false)
expect(isImportLine('\tuse HasFactory;')).toBe(false)
})
it('keeps real code that merely contains the word', () => {
expect(isImportLine('$f = function () use ($agent) {};')).toBe(false)
expect(isImportLine('// use Agent for this')).toBe(false)
})
})
describe('parseNamespace', () => {
it('reads the namespace of a file', () => {
expect(parseNamespace('namespace App\\Console\\Commands;')).toBe('App\\Console\\Commands')
expect(parseNamespace('namespace App;')).toBe('App')
})
it('returns null for anything else', () => {
expect(parseNamespace('use App\\Models\\Agent;')).toBeNull()
expect(parseNamespace('class A {')).toBeNull()
})
})
describe('parseTraitUse', () => {
it('reads one or several traits from an indented use', () => {
expect(parseTraitUse(' use HasFactory;')).toEqual(['HasFactory'])
expect(parseTraitUse(' use HasFactory, Notifiable, SoftDeletes;')).toEqual(['HasFactory', 'Notifiable', 'SoftDeletes'])
expect(parseTraitUse('\tuse App\\Support\\HasVault;')).toEqual(['HasVault'])
})
it('reads a use with a conflict-resolution block', () => {
expect(parseTraitUse(' use A, B { A::run insteadof B; }')).toEqual(['A', 'B'])
})
it('ignores what is not a trait', () => {
expect(parseTraitUse('use App\\Models\\Agent;')).toEqual([]) // import, not indented
expect(parseTraitUse(' use function App\\tap;')).toEqual([])
expect(parseTraitUse(' $f = function () use ($x) {};')).toEqual([])
})
})
describe('wordAt', () => {
const line = 'return new Agent($x);'
it('reads the identifier under the caret', () => {
expect(wordAt(line, 12)).toBe('Agent') // inside
expect(wordAt(line, 11)).toBe('Agent') // left edge
expect(wordAt(line, 16)).toBe('Agent') // right edge
})
it('returns empty off an identifier', () => {
expect(wordAt(line, 17)).toBe('') // inside "($x"
expect(wordAt(' ', 1)).toBe('')
})
it('rejects a word that cannot be a class name', () => {
expect(wordAt('$x = 42;', 6)).toBe('') // digits only
})
it('handles a caret at the string edges', () => {
expect(wordAt('Agent', 0)).toBe('Agent')
expect(wordAt('Agent', 5)).toBe('Agent')
expect(wordAt('Agent', 9)).toBe('')
})
})