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. */
export type WordWrap = 'off' | 'on' | 'markdown'
/** xterm's colour table. Every value is a CSS colour; the selection entries
* may carry alpha, the rest may not. */
export interface TerminalTheme {
background: string; foreground: string; cursor: string; cursorAccent: string
selectionBackground: string; selectionInactiveBackground: string
black: string; red: string; green: string; yellow: string
blue: string; magenta: string; cyan: string; white: string
brightBlack: string; brightRed: string; brightGreen: string; brightYellow: string
brightBlue: string; brightMagenta: string; brightCyan: string; brightWhite: string
}
export interface HelderConfig {
ai: { command: string; autoLaunch: boolean }
editor: { autoSave: boolean; tabSize: number; wordWrap: WordWrap }
git: { confirmDiscard: boolean; confirmStage: boolean; confirmUnstage: boolean; defaultDiffMode: DiffMode; refreshInterval: number }
files: { exclude: string[]; followGitignore: boolean }
terminal: { shell: string | null }
terminal: {
/** Login shell for both panes. null = $SHELL. */
shell: string | null
/** null = follow the CSS vars (--code-font / --term-size in theme.css). */
fontFamily: string | null
fontSize: number | null
lineHeight: number
letterSpacing: number
cursorStyle: 'bar' | 'block' | 'underline'
cursorBlink: boolean
/** Paint bold text in the bright colour. Off keeps bold in its own hue,
* which stops a CLI's bold labels from washing out. */
boldIsBright: boolean
scrollback: number
/** macOS: send Option as Meta. Needed for a CLI's ⌥↵ binding; it also stops
* Option from typing accented characters, so it is off by default. */
optionIsMeta: boolean
theme: TerminalTheme
}
session: { restoreOnLaunch: boolean }
}
@@ -28,7 +57,33 @@ export const DEFAULTS: HelderConfig = {
editor: { autoSave: false, tabSize: 4, wordWrap: 'markdown' },
git: { confirmDiscard: true, confirmStage: false, confirmUnstage: false, defaultDiffMode: 'diff', refreshInterval: 10000 },
files: { exclude: [], followGitignore: false },
terminal: { shell: null },
terminal: {
shell: null,
fontFamily: null,
fontSize: null,
lineHeight: 1.35,
letterSpacing: 0,
cursorStyle: 'bar',
cursorBlink: true,
boldIsBright: false,
scrollback: 8000,
optionIsMeta: false,
// The app's own palette: charcoal ground, the accent on the caret and the
// selection, and the syntax colours reused for the ANSI table so a diff in
// the terminal reads like a diff in the editor.
theme: {
background: '#24272c',
foreground: '#dde1e7',
cursor: '#f19f3f',
cursorAccent: '#24272c',
selectionBackground: 'rgba(241,159,63,0.32)',
selectionInactiveBackground: 'rgba(241,159,63,0.18)',
black: '#2b2e34', red: '#e0696a', green: '#5cbd6b', yellow: '#d8a85c',
blue: '#6aa6f0', magenta: '#c98bdb', cyan: '#6ec0c0', white: '#b0b6bf',
brightBlack: '#7a828d', brightRed: '#ef8385', brightGreen: '#77d186', brightYellow: '#edc077',
brightBlue: '#86bbf5', brightMagenta: '#dba6e9', brightCyan: '#8ad6d6', brightWhite: '#fbfcfd',
},
},
session: { restoreOnLaunch: true },
}
@@ -41,7 +96,11 @@ const THEME_TEMPLATE = `/* Helder theme — custom CSS applied OVER the built-in
/* Code surfaces (editor + terminals) */
/* --code-font: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; */
/* --code-size: 13px; */ /* editor font size */
/* --term-size: 12.5px; */ /* terminal font size */
/* --term-size: 12.5px; */ /* terminal font size, unless config.json sets one */
/* The terminal's colours, cursor and scrollback live in config.json, under
"terminal" — the palette is JS options, not CSS, because xterm paints to a
canvas. Edit either file and the running terminals restyle themselves. */
/* Example accent override: */
/* --accent: #4d8dff; */

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 { getConfig, getRecent, getThemeCss, resolveConfig, setRecent } from './config'
import { listFiles, searchContent } from './search-service'
import { invalidateSymbols, lookupSymbol, symbolNames } from './symbols-service'
import { readNote, writeNote } from './notes-service'
import { initDiagnostics, openLog, revealLog, watchWindow } from './diagnostics'
import { getLogPath, log, logger, type LogLevel } from './logger'
@@ -220,6 +221,7 @@ function startWatcher(): void {
ignored: (p: string) => p.split(sep).some((seg) => WATCH_IGNORE.has(seg)),
})
const ping = (): void => {
invalidateSymbols() // a changed file may add or remove a class
if (watchTimer) clearTimeout(watchTimer)
watchTimer = setTimeout(() => broadcast('project:changed'), 250)
}
@@ -322,6 +324,14 @@ function registerIpc(): void {
handle('search:content', (_e, query: string) => { const r = getRoot(); return r ? searchContent(r, query) : [] })
handle('search:files', () => { const r = getRoot(); return r ? listFiles(r) : [] })
// PHP symbols: the declared-name list feeds the ⌘-click underline, the lookup
// answers one click. Both build the index on demand — never at window open.
handle('symbols:names', () => { const r = getRoot(); return r ? symbolNames(r) : [] })
handle('symbols:lookup', (_e, name: string) => {
const r = getRoot()
return r ? lookupSymbol(r, name) : { name, defs: [], refs: [], refCount: 0 }
})
// The renderer's window into the same log file (see renderer/src/log.ts): its
// uncaught errors, promise rejections and ErrorBoundary catches land here, so
// main-process and renderer failures interleave in ONE chronological file.

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'),
},
symbols: {
names: (): Promise<string[]> => ipcRenderer.invoke('symbols:names'),
lookup: (name: string) => ipcRenderer.invoke('symbols:lookup', name),
},
dialog: {
unsavedClose: (path: string): Promise<'save' | 'discard' | 'cancel'> =>
ipcRenderer.invoke('dialog:unsavedClose', path),

View File

@@ -5,12 +5,13 @@ import type { ContextTarget } from './components'
import { Editor, SplitView } from './editor'
import type { Cursor, Mode, Selection } from './editor'
import { Terminal, lid } from './terminals'
import { ConfirmModal, ContextMenu, HelpModal, HistoryModal, NamePopup, NotesModal, PassPopup, ProjectsModal, SearchModal, Toasts } from './overlays'
import { ConfirmModal, ContextMenu, HelpModal, HistoryModal, NamePopup, NotesModal, PassPopup, ProjectsModal, SearchModal, SymbolPopup, Toasts } from './overlays'
import { ProjectLauncher } from './launcher'
import type { Menu, Toast } from './overlays'
import type { DiffSide, FileNode, GitStatus } from './types'
import type { DiffSide, FileNode, GitStatus, SymbolLookup } from './types'
import { useProject, useProjectActions } from './project'
import { HL } from './highlight'
import { loadSymbols } from './symbols'
import { rlog } from './log'
import { loadJson, loadNum, saveJson, saveNum } from './persist'
@@ -290,7 +291,7 @@ export function App(): React.ReactElement {
wasBlurred = false
setShowFlash(true)
clearTimeout(timer)
timer = setTimeout(() => setShowFlash(false), 2000)
timer = setTimeout(() => setShowFlash(false), 500)
}
function onBlur(): void { wasBlurred = true }
window.addEventListener('focus', flash)
@@ -519,6 +520,36 @@ export function App(): React.ReactElement {
actions.unstage(p)
}
// The declared-class list, fetched off the startup path: 1.5 s after the
// project is ready, and again 2 s after the last file change. Both are late on
// purpose — the list only decides which tokens may be underlined, so it can
// never be worth competing with the tree, git and terminals for the launch.
useEffect(() => {
if (!proj.ready || !proj.root) return
const t = setTimeout(() => loadSymbols(), 1500)
return () => clearTimeout(t)
}, [proj.ready, proj.root])
useEffect(() => {
if (!proj.ready || !proj.root) return
const t = setTimeout(() => loadSymbols(true), 2000)
return () => clearTimeout(t)
}, [proj.changes, proj.tree, proj.ready, proj.root])
// ⌘-click on a PHP class name: the popup shows where it is declared and every
// reference to it. The lookup runs in main (one ripgrep pass) and the popup
// opens right away with a "Searching…" body, so the click never feels stuck.
const [symName, setSymName] = useState<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
// second, so the eye finds it after the scroll. `id` restarts a repeat jump.
const [lineFlash, setLineFlash] = useState<{ path: string; line: number; id: number } | null>(null)
@@ -1054,7 +1085,7 @@ export function App(): React.ReactElement {
onSplit={(p) => setSplitFor(p)} splitOpen={splitFor === active}
cursor={cursor} selection={selection} setCursor={setCursor} setSelection={setSelection}
flash={lineFlash && lineFlash.path === active ? lineFlash : null}
bufferText={bufferText(active)} onEdit={onEdit} />
bufferText={bufferText(active)} onEdit={onEdit} onSymbol={openSymbol} />
</div>
<Splitter onDelta={(dx) => { setAutoResize(false); setRightW((w) => {
// grow until the editor would drop below ~280px (rather than a fixed cap)
@@ -1093,6 +1124,9 @@ export function App(): React.ReactElement {
{overlay === 'help' && <HelpModal onClose={() => setOverlay(null)} />}
{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)} />}
{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)} />}
<Toasts toasts={toasts} />
</div>

View File

@@ -4,10 +4,46 @@ import type { Diff, DiffSide, ViewLine } from './types'
import { rowId } from './types'
import { useProject } from './project'
import { HL } from './highlight'
import { isKnownSymbol, useSymbols } from './symbols'
import { renderMarkdown } from './markdown'
import { FileIcon, Icon } from './components'
import type { OnContext } from './components'
/** ⌘-click on a class name declared in this project. */
export type OnSymbol = (name: string) => void
/** The identifier around `pos` in `text`, or '' when the caret is not on one. */
export function wordAt(text: string, pos: number): string {
if (pos < 0 || pos > text.length) return ''
const word = /\w/
let a = pos
while (a > 0 && word.test(text[a - 1])) a--
let b = pos
while (b < text.length && word.test(text[b])) b++
const w = text.slice(a, b)
return /^[A-Za-z_]\w*$/.test(w) ? w : ''
}
/** True while ⌘ (or Ctrl) is held. Drives the underline, so the affordance
* shows exactly when the click would do something. */
function useMetaKey(): boolean {
const [down, setDown] = useState(false)
useEffect(() => {
const on = (e: KeyboardEvent): void => { if (e.metaKey || e.ctrlKey) setDown(true) }
const off = (e: KeyboardEvent): void => { if (!e.metaKey && !e.ctrlKey) setDown(false) }
const clear = (): void => setDown(false)
window.addEventListener('keydown', on)
window.addEventListener('keyup', off)
window.addEventListener('blur', clear)
return () => {
window.removeEventListener('keydown', on)
window.removeEventListener('keyup', off)
window.removeEventListener('blur', clear)
}
}, [])
return down
}
export interface Cursor { path: string; line: number; col: number }
/** A one-second line highlight after a jump. `id` changes per jump, so the same
* line twice restarts the animation. */
@@ -32,7 +68,7 @@ function climbToLine(node: Node | null): HTMLElement | null {
* the number is a CSS counter on `::before`, which keeps it on the first
* visual row and leaves the folded rows unnumbered. Highlighting is then per
* line (like the diff views), so multi-line tokens don't carry over. */
function CodeEditor({ path, text, lang, wrap, flash, onChange, onContext }: {
function CodeEditor({ path, text, lang, wrap, flash, onChange, onContext, onSymbol }: {
path: string
text: string
lang: string | null
@@ -41,16 +77,22 @@ function CodeEditor({ path, text, lang, wrap, flash, onChange, onContext }: {
flash: FlashLine | null
onChange: (text: string) => void
onContext: OnContext
onSymbol: OnSymbol
}): React.ReactElement {
const scrollRef = useRef<HTMLDivElement>(null)
const gutterRef = useRef<HTMLDivElement>(null)
const preRef = useRef<HTMLPreElement>(null)
const tabSize = useProject().config.editor.tabSize
const symVer = useSymbols() // re-highlight once the declared-name list lands
const metaDown = useMetaKey()
const html = useMemo(
() => (wrap
? text.split('\n').map((l) => `<div class="ce-line">${HL.hlLine(l, lang)}</div>`).join('')
: HL.hlText(text, lang) + '\n'),
[text, lang, wrap],
// symVer is not read here: HL marks the known class tokens from a module-level
// set, so this is what recomputes the HTML when that set lands.
// eslint-disable-next-line react-hooks/exhaustive-deps
[text, lang, wrap, symVer],
)
const count = useMemo(() => text.split('\n').length, [text])
// The band sits behind the text, so it needs the geometry of the line. Unwrapped
@@ -122,6 +164,26 @@ function CodeEditor({ path, text, lang, wrap, flash, onChange, onContext }: {
function onKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>): void {
if (e.key === 'Tab' && !e.metaKey && !e.ctrlKey && !e.altKey) handleTab(e, e.shiftKey)
}
/* Two ways in. While ⌘ is held the marked tokens take the mouse (see the
* .sym-live rules), so a click on one arrives here with the token as target
* and its text is the name. Anywhere else the click lands on the textarea,
* which has already moved the caret, so the word around `selectionStart` is
* the word under the pointer — no token geometry either way. */
function handleTokenClick(e: React.MouseEvent): void {
if (!e.metaKey && !e.ctrlKey) return
const el = e.target as HTMLElement
if (!el.classList?.contains('sym')) return
e.preventDefault()
onSymbol(el.textContent || '')
}
function handleClick(e: React.MouseEvent<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 {
e.preventDefault()
const ta = e.currentTarget
@@ -135,7 +197,7 @@ function CodeEditor({ path, text, lang, wrap, flash, onChange, onContext }: {
onContext(e, info)
}
return (
<div className={'code-edit' + (wrap ? ' wrap' : '')}>
<div className={'code-edit' + (wrap ? ' wrap' : '') + (metaDown ? ' sym-live' : '')}>
{!wrap && (
<div className="ce-gutterwrap">
<div className="ce-gutter" ref={gutterRef}>
@@ -143,7 +205,7 @@ function CodeEditor({ path, text, lang, wrap, flash, onChange, onContext }: {
</div>
</div>
)}
<div className="ce-scroll" ref={scrollRef} onScroll={onScroll}>
<div className="ce-scroll" ref={scrollRef} onScroll={onScroll} onClick={handleTokenClick}>
<div className="ce-inner">
{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"
@@ -151,7 +213,7 @@ function CodeEditor({ path, text, lang, wrap, flash, onChange, onContext }: {
onChange={(e) => { onChange(e.target.value); ensureCaretVisible(e.target) }}
onKeyDown={onKeyDown}
onKeyUp={(e) => ensureCaretVisible(e.currentTarget)}
onClick={(e) => ensureCaretVisible(e.currentTarget)}
onClick={handleClick}
onContextMenu={handleContext} />
<pre className="ce-pre" ref={preRef} aria-hidden style={{ tabSize }} dangerouslySetInnerHTML={{ __html: html }} />
</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. */
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
path: string
lines: ViewLine[]
@@ -210,9 +272,20 @@ function PaneView({ cacheKey, path, lines, lang, showSign, wrap, cursor, selecti
setCursor: (c: Cursor) => void
setSelection: (s: Selection | null) => void
onContext: OnContext
onSymbol: OnSymbol
}): React.ReactElement {
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 {
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
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) => {
const no = l.no
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
}
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
mode: Mode
/** 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
bufferText: string
onEdit: (text: string) => void
/** ⌘-click on a class name declared in this project. */
onSymbol: OnSymbol
}): React.ReactElement {
const PROJECT = useProject()
const tab = active ? { path: active } : null
@@ -451,11 +527,11 @@ export function Editor({ active, mode, side, setMode, onContext, onSplit, splitO
) : 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>
) : 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}
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>
)}

View File

@@ -1,5 +1,5 @@
/// <reference types="vite/client" />
import type { FileNode, GitStatus, HelderConfig } from './types'
import type { FileNode, GitStatus, HelderConfig, SymbolLookup } from './types'
interface GitChangeRaw {
path: string
@@ -66,6 +66,10 @@ interface HelderBridge {
content: (query: string) => Promise<{ path: string; hits: { no: number; ln: string; ix: number }[] }[]>
files: () => Promise<string[]>
}
symbols: {
names: () => Promise<string[]>
lookup: (name: string) => Promise<SymbolLookup>
}
dialog: {
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
* throws and silently falls back to plain text. */
import Prism from 'prismjs'
import { isKnownSymbol } from './symbols'
import 'prismjs/components/prism-markup-templating'
import 'prismjs/components/prism-php'
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))
}
/* 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)
function hlLine(line: string, lang: string | null): string {
if (line === '') return '&nbsp;'
try {
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 {
/* fall through */
}
@@ -75,7 +93,10 @@ function hlLine(line: string, lang: string | null): string {
function hlText(text: string, lang: string | null): string {
try {
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 {
/* fall through */
}

View File

@@ -3,9 +3,11 @@ import React, { Fragment, useEffect, useMemo, useRef, useState } from 'react'
import { useProject } from './project'
import type { RecentProject } from './project'
import { fuzzy } from './fuzzy'
import { isKnownSymbol } from './symbols'
import { FileIcon, Icon } from './components'
import { HL } from './highlight'
import type { OpenFile } from './components'
import type { SymbolDef, SymbolLookup } from './types'
export interface MenuItem {
sep?: boolean
@@ -66,6 +68,8 @@ export function SearchModal({ initialQuery, onOpen, onOpenAt, onClose, changeSet
type Col = 'infile' | 'content' | 'files'
const cols: Col[] = hasInFile ? ['infile', 'content', 'files'] : ['content', 'files']
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 inFileRef = useRef<HTMLDivElement>(null)
const leftRef = useRef<HTMLDivElement>(null)
@@ -199,11 +203,13 @@ export function SearchModal({ initialQuery, onOpen, onOpenAt, onClose, changeSet
}
if (e.key === 'ArrowDown') {
e.preventDefault()
setKbdNav(true)
if (col === 'files') setFileSel((s) => Math.min(s + 1, fileCount - 1))
else if (col === 'infile') setInFileSel((s) => Math.min(s + 1, inFileCount - 1))
else setSel((s) => Math.min(s + 1, flat.length - 1))
} else if (e.key === 'ArrowUp') {
e.preventDefault()
setKbdNav(true)
if (col === 'files') setFileSel((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))
@@ -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>
</div>
<div className="search-cols">
<div className={'search-cols' + (kbdNav ? ' kbd-nav' : '')} onMouseMove={() => setKbdNav(false)}>
{hasInFile && (
<div className={'sc-infile' + (col === 'infile' ? ' active' : '')} ref={inFileRef}>
<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 selRef = useRef(sel); selRef.current = sel
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(() => {
function onKey(e: KeyboardEvent): void {
if (e.key === 'ArrowDown') { e.preventDefault(); setSel((s) => Math.min(s + 1, history.length - 1)) }
else if (e.key === 'ArrowUp') { e.preventDefault(); setSel((s) => Math.max(s - 1, 0)) }
if (e.key === 'ArrowDown') { e.preventDefault(); setKbdNav(true); setSel((s) => Math.min(s + 1, history.length - 1)) }
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 === '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="mode-chip">{history.length} file{history.length === 1 ? '' : 's'} · <kbd></kbd> <kbd></kbd> <kbd></kbd></span>
</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.map((p, i) => {
const name = p.split('/').pop() as string
const dir = p.split('/').slice(0, -1).join('/')
return (
<div key={p} className={'hist-row' + (i === sel ? ' sel' : '')} title={p}
onMouseEnter={() => setSel(i)}
<div key={p} className={'hist-row' + (i === sel ? ' sel' : '')} title={p} data-i={i}
onClick={() => { onOpen(p); onClose() }}>
<FileIcon path={p} />
<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 {
return (
<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 {
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.
open — the file the viewer shows: lighter tint, 3px accent bar.
focus — "↵ acts here": the same pair at 30%.
hover — the same as focus, because the pointer moves the cursor onto the row.
Order matters: open is listed last, so it wins on a row that is also focused. */
.git-row:hover, .git-row.ctx, .git-row.kbd { background:var(--row-soft); }
.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); }
/* ===== Row states one language for every list that offers a row to click ====
hover / focus — "a click or ↵ acts here": --row-soft, 3px --accent-dim bar.
open — the file the viewer shows: --sel, 3px solid --accent bar.
Hover and focus look alike on purpose: the pointer moves the row cursor onto
whatever it touches, so only one row ever carries the pair. Open wins over
both, which is why its rules come last.
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.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.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; }
@@ -224,12 +243,8 @@ kbd { flex:0 0 auto; font-family:var(--mono); font-size:11.5px; color:var(--fg-3
/* ============ file tree ============ */
.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; }
/* Same three states as the git list. A folder never reaches `active` — it opens
in the tree, not in the viewer — but it takes focus and hover like a file. */
.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); }
/* A folder never reaches `active` — it opens in the tree, not in the viewer —
but it takes focus and hover like a file. See the row-state block above. */
.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-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.on { opacity:1; color:var(--accent); border-color:var(--accent-line); background:var(--accent-soft); }
.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.sel { background:var(--accent-soft); }
.pres { display:flex; align-items:center; gap:10px; padding:7px 10px; cursor:pointer; }
.pres .pn { font-size:13px; color:var(--fg-0); }
.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; }
@@ -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; }
.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:hover { background:var(--hover); }
.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 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 .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; }
.hist-list { max-height:460px; overflow:auto; padding:5px 0; }
.hist-row { display:flex; align-items:center; gap:9px; padding:6px 13px; cursor:pointer; }
.hist-row.sel { background:var(--accent-dim, rgba(241,159,63,0.14)); box-shadow:inset 2px 0 0 var(--accent); }
.hist-row:hover { background:var(--hover); }
.hist-row.sel:hover { background:rgba(241,159,63,0.20); }
/* stable gutter: without it the scrollbar eats into the rows, so the change dot
sits at a different distance from the edge than the header above it. */
.hist-list { max-height:460px; overflow-y:auto; scrollbar-gutter:stable; padding:5px 0; }
.hist-row { display:flex; align-items:center; gap:9px; padding:6px 13px; cursor:pointer; position:relative; }
/* ⌘↑/⌘↓ 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 .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; }
@@ -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 .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: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 .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; }
@@ -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`
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. */
/* ⌘-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-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); }
@@ -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) */
.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-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 ============ */
/* 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 .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); }
.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 type { Menu } from './overlays'
import { Icon } from './components'
import { useProject } from './project'
import type { HelderConfig } from './types'
let _lid = 0
export const lid = (): number => ++_lid
const MONO = '"JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace'
// ANSI palette mapped onto Helder's charcoal tokens.
const THEME = {
background: '#1a1c1f',
foreground: '#e6e8ea',
cursor: '#4d8dff',
cursorAccent: '#1a1c1f',
selectionBackground: 'rgba(77,141,255,0.55)',
selectionInactiveBackground: 'rgba(77,141,255,0.40)',
black: '#16171a', red: '#e0696a', green: '#5cbd6b', yellow: '#d8a85c',
blue: '#4d8dff', magenta: '#c98bdb', cyan: '#6ec0c0', white: '#b4bac2',
brightBlack: '#5d636c', brightRed: '#e0696a', brightGreen: '#5cbd6b', brightYellow: '#d8a85c',
brightBlue: '#6aa6f0', brightMagenta: '#c98bdb', brightCyan: '#6ec0c0', brightWhite: '#e6e8ea',
/* Everything visual comes from `terminal` in config.json — palette, font,
* cursor, scrollback — so the console is themed from the same file as the rest
* of the project. Font family and size fall back to the CSS vars (--code-font /
* --term-size in theme.css) when the config leaves them null, which keeps the
* old "one place for the code font" rule intact. */
function xtermOptions(cfg: HelderConfig['terminal']): {
fontFamily: string; fontSize: number; lineHeight: number; letterSpacing: number
cursorStyle: 'bar' | 'block' | 'underline'; cursorBlink: boolean
drawBoldTextInBrightColors: boolean; scrollback: number; macOptionIsMeta: boolean
theme: HelderConfig['terminal']['theme']
} {
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 {
const hostRef = useRef<HTMLDivElement>(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 [menu, setMenu] = useState<Menu | null>(null)
// 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
if (!host) return
const css = getComputedStyle(document.documentElement)
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,
})
const term = new XTerm({ ...xtermOptions(cfgRef.current), allowProposedApi: true })
termRef.current = term
const fit = new FitAddon()
fitRef.current = fit
term.loadAddon(fit)
term.open(host)
@@ -140,11 +150,21 @@ export function Terminal({ kind }: { kind: 'agent' | 'shell' }): React.ReactElem
if (kind === 'agent') window.removeEventListener('agentPaste', onPaste)
if (bridge && id >= 0) bridge.pty.kill(id)
termRef.current = null
fitRef.current = null
term.dispose()
}
// 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.
function onContextMenu(e: React.MouseEvent): void {
const bridge = window.helder
@@ -166,7 +186,7 @@ export function Terminal({ kind }: { kind: 'agent' | 'shell' }): React.ReactElem
}
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()}
onContextMenu={onContextMenu}>
<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. */
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). */
export interface HelderConfig {
ai: { command: string; autoLaunch: boolean }
editor: { autoSave: boolean; tabSize: number; wordWrap: WordWrap }
git: { confirmDiscard: boolean; confirmStage: boolean; confirmUnstage: boolean; defaultDiffMode: DiffMode; refreshInterval: number }
files: { exclude: string[]; followGitignore: boolean }
terminal: { shell: string | null }
terminal: {
/** Login shell for both panes. null = $SHELL. */
shell: string | null
/** null = follow the CSS vars (--code-font / --term-size in theme.css). */
fontFamily: string | null
fontSize: number | null
lineHeight: number
letterSpacing: number
cursorStyle: 'bar' | 'block' | 'underline'
cursorBlink: boolean
/** Paint bold text in the bright colour. Off keeps bold in its own hue,
* which stops a CLI's bold labels from washing out. */
boldIsBright: boolean
scrollback: number
/** macOS: send Option as Meta. Needed for a CLI's ⌥↵ binding; it also stops
* Option from typing accented characters, so it is off by default. */
optionIsMeta: boolean
theme: TerminalTheme
}
session: { restoreOnLaunch: boolean }
}
@@ -99,6 +128,49 @@ export const DEFAULT_CONFIG: HelderConfig = {
editor: { autoSave: false, tabSize: 4, wordWrap: 'markdown' },
git: { confirmDiscard: true, confirmStage: false, confirmUnstage: false, defaultDiffMode: 'diff', refreshInterval: 10000 },
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 },
}
/** 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 { afterEach, describe, expect, it } from 'vitest'
import { DEFAULTS, getConfig, getThemeCss, resolveConfig } from '../src/main/config'
import { DEFAULT_CONFIG as RENDERER_DEFAULTS } from '../src/renderer/src/types'
let dir = ''
afterEach(async () => { if (dir) await rm(dir, { recursive: true, force: true }) })
@@ -52,3 +53,18 @@ describe('resolveConfig', () => {
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('')
})
})