loads of UI improvements, also improves the console UI
This commit is contained in:
+62
-3
@@ -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; */
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
Reference in New Issue
Block a user