loads of UI improvements, also improves the console UI
This commit is contained in:
@@ -14,12 +14,41 @@ export type DiffMode = 'original' | 'updated' | 'diff'
|
|||||||
/** Soft wrap of long lines: never, always, or only in Markdown files. */
|
/** Soft wrap of long lines: never, always, or only in Markdown files. */
|
||||||
export type WordWrap = 'off' | 'on' | 'markdown'
|
export type WordWrap = 'off' | 'on' | 'markdown'
|
||||||
|
|
||||||
|
/** xterm's colour table. Every value is a CSS colour; the selection entries
|
||||||
|
* may carry alpha, the rest may not. */
|
||||||
|
export interface TerminalTheme {
|
||||||
|
background: string; foreground: string; cursor: string; cursorAccent: string
|
||||||
|
selectionBackground: string; selectionInactiveBackground: string
|
||||||
|
black: string; red: string; green: string; yellow: string
|
||||||
|
blue: string; magenta: string; cyan: string; white: string
|
||||||
|
brightBlack: string; brightRed: string; brightGreen: string; brightYellow: string
|
||||||
|
brightBlue: string; brightMagenta: string; brightCyan: string; brightWhite: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface HelderConfig {
|
export interface HelderConfig {
|
||||||
ai: { command: string; autoLaunch: boolean }
|
ai: { command: string; autoLaunch: boolean }
|
||||||
editor: { autoSave: boolean; tabSize: number; wordWrap: WordWrap }
|
editor: { autoSave: boolean; tabSize: number; wordWrap: WordWrap }
|
||||||
git: { confirmDiscard: boolean; confirmStage: boolean; confirmUnstage: boolean; defaultDiffMode: DiffMode; refreshInterval: number }
|
git: { confirmDiscard: boolean; confirmStage: boolean; confirmUnstage: boolean; defaultDiffMode: DiffMode; refreshInterval: number }
|
||||||
files: { exclude: string[]; followGitignore: boolean }
|
files: { exclude: string[]; followGitignore: boolean }
|
||||||
terminal: { shell: string | null }
|
terminal: {
|
||||||
|
/** Login shell for both panes. null = $SHELL. */
|
||||||
|
shell: string | null
|
||||||
|
/** null = follow the CSS vars (--code-font / --term-size in theme.css). */
|
||||||
|
fontFamily: string | null
|
||||||
|
fontSize: number | null
|
||||||
|
lineHeight: number
|
||||||
|
letterSpacing: number
|
||||||
|
cursorStyle: 'bar' | 'block' | 'underline'
|
||||||
|
cursorBlink: boolean
|
||||||
|
/** Paint bold text in the bright colour. Off keeps bold in its own hue,
|
||||||
|
* which stops a CLI's bold labels from washing out. */
|
||||||
|
boldIsBright: boolean
|
||||||
|
scrollback: number
|
||||||
|
/** macOS: send Option as Meta. Needed for a CLI's ⌥↵ binding; it also stops
|
||||||
|
* Option from typing accented characters, so it is off by default. */
|
||||||
|
optionIsMeta: boolean
|
||||||
|
theme: TerminalTheme
|
||||||
|
}
|
||||||
session: { restoreOnLaunch: boolean }
|
session: { restoreOnLaunch: boolean }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,7 +57,33 @@ export const DEFAULTS: HelderConfig = {
|
|||||||
editor: { autoSave: false, tabSize: 4, wordWrap: 'markdown' },
|
editor: { autoSave: false, tabSize: 4, wordWrap: 'markdown' },
|
||||||
git: { confirmDiscard: true, confirmStage: false, confirmUnstage: false, defaultDiffMode: 'diff', refreshInterval: 10000 },
|
git: { confirmDiscard: true, confirmStage: false, confirmUnstage: false, defaultDiffMode: 'diff', refreshInterval: 10000 },
|
||||||
files: { exclude: [], followGitignore: false },
|
files: { exclude: [], followGitignore: false },
|
||||||
terminal: { shell: null },
|
terminal: {
|
||||||
|
shell: null,
|
||||||
|
fontFamily: null,
|
||||||
|
fontSize: null,
|
||||||
|
lineHeight: 1.35,
|
||||||
|
letterSpacing: 0,
|
||||||
|
cursorStyle: 'bar',
|
||||||
|
cursorBlink: true,
|
||||||
|
boldIsBright: false,
|
||||||
|
scrollback: 8000,
|
||||||
|
optionIsMeta: false,
|
||||||
|
// The app's own palette: charcoal ground, the accent on the caret and the
|
||||||
|
// selection, and the syntax colours reused for the ANSI table so a diff in
|
||||||
|
// the terminal reads like a diff in the editor.
|
||||||
|
theme: {
|
||||||
|
background: '#24272c',
|
||||||
|
foreground: '#dde1e7',
|
||||||
|
cursor: '#f19f3f',
|
||||||
|
cursorAccent: '#24272c',
|
||||||
|
selectionBackground: 'rgba(241,159,63,0.32)',
|
||||||
|
selectionInactiveBackground: 'rgba(241,159,63,0.18)',
|
||||||
|
black: '#2b2e34', red: '#e0696a', green: '#5cbd6b', yellow: '#d8a85c',
|
||||||
|
blue: '#6aa6f0', magenta: '#c98bdb', cyan: '#6ec0c0', white: '#b0b6bf',
|
||||||
|
brightBlack: '#7a828d', brightRed: '#ef8385', brightGreen: '#77d186', brightYellow: '#edc077',
|
||||||
|
brightBlue: '#86bbf5', brightMagenta: '#dba6e9', brightCyan: '#8ad6d6', brightWhite: '#fbfcfd',
|
||||||
|
},
|
||||||
|
},
|
||||||
session: { restoreOnLaunch: true },
|
session: { restoreOnLaunch: true },
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,7 +96,11 @@ const THEME_TEMPLATE = `/* Helder theme — custom CSS applied OVER the built-in
|
|||||||
/* Code surfaces (editor + terminals) */
|
/* Code surfaces (editor + terminals) */
|
||||||
/* --code-font: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; */
|
/* --code-font: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; */
|
||||||
/* --code-size: 13px; */ /* editor font size */
|
/* --code-size: 13px; */ /* editor font size */
|
||||||
/* --term-size: 12.5px; */ /* terminal font size */
|
/* --term-size: 12.5px; */ /* terminal font size, unless config.json sets one */
|
||||||
|
|
||||||
|
/* The terminal's colours, cursor and scrollback live in config.json, under
|
||||||
|
"terminal" — the palette is JS options, not CSS, because xterm paints to a
|
||||||
|
canvas. Edit either file and the running terminals restyle themselves. */
|
||||||
|
|
||||||
/* Example accent override: */
|
/* Example accent override: */
|
||||||
/* --accent: #4d8dff; */
|
/* --accent: #4d8dff; */
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { commit, discard, load, push, stage, unstage } from './git-service'
|
|||||||
import { createPty, killAllPtys, killPty, ptyAvailable, resizePty, writePty } from './pty-service'
|
import { createPty, killAllPtys, killPty, ptyAvailable, resizePty, writePty } from './pty-service'
|
||||||
import { getConfig, getRecent, getThemeCss, resolveConfig, setRecent } from './config'
|
import { getConfig, getRecent, getThemeCss, resolveConfig, setRecent } from './config'
|
||||||
import { listFiles, searchContent } from './search-service'
|
import { listFiles, searchContent } from './search-service'
|
||||||
|
import { invalidateSymbols, lookupSymbol, symbolNames } from './symbols-service'
|
||||||
import { readNote, writeNote } from './notes-service'
|
import { readNote, writeNote } from './notes-service'
|
||||||
import { initDiagnostics, openLog, revealLog, watchWindow } from './diagnostics'
|
import { initDiagnostics, openLog, revealLog, watchWindow } from './diagnostics'
|
||||||
import { getLogPath, log, logger, type LogLevel } from './logger'
|
import { getLogPath, log, logger, type LogLevel } from './logger'
|
||||||
@@ -220,6 +221,7 @@ function startWatcher(): void {
|
|||||||
ignored: (p: string) => p.split(sep).some((seg) => WATCH_IGNORE.has(seg)),
|
ignored: (p: string) => p.split(sep).some((seg) => WATCH_IGNORE.has(seg)),
|
||||||
})
|
})
|
||||||
const ping = (): void => {
|
const ping = (): void => {
|
||||||
|
invalidateSymbols() // a changed file may add or remove a class
|
||||||
if (watchTimer) clearTimeout(watchTimer)
|
if (watchTimer) clearTimeout(watchTimer)
|
||||||
watchTimer = setTimeout(() => broadcast('project:changed'), 250)
|
watchTimer = setTimeout(() => broadcast('project:changed'), 250)
|
||||||
}
|
}
|
||||||
@@ -322,6 +324,14 @@ function registerIpc(): void {
|
|||||||
handle('search:content', (_e, query: string) => { const r = getRoot(); return r ? searchContent(r, query) : [] })
|
handle('search:content', (_e, query: string) => { const r = getRoot(); return r ? searchContent(r, query) : [] })
|
||||||
handle('search:files', () => { const r = getRoot(); return r ? listFiles(r) : [] })
|
handle('search:files', () => { const r = getRoot(); return r ? listFiles(r) : [] })
|
||||||
|
|
||||||
|
// PHP symbols: the declared-name list feeds the ⌘-click underline, the lookup
|
||||||
|
// answers one click. Both build the index on demand — never at window open.
|
||||||
|
handle('symbols:names', () => { const r = getRoot(); return r ? symbolNames(r) : [] })
|
||||||
|
handle('symbols:lookup', (_e, name: string) => {
|
||||||
|
const r = getRoot()
|
||||||
|
return r ? lookupSymbol(r, name) : { name, defs: [], refs: [], refCount: 0 }
|
||||||
|
})
|
||||||
|
|
||||||
// The renderer's window into the same log file (see renderer/src/log.ts): its
|
// The renderer's window into the same log file (see renderer/src/log.ts): its
|
||||||
// uncaught errors, promise rejections and ErrorBoundary catches land here, so
|
// uncaught errors, promise rejections and ErrorBoundary catches land here, so
|
||||||
// main-process and renderer failures interleave in ONE chronological file.
|
// main-process and renderer failures interleave in ONE chronological file.
|
||||||
|
|||||||
250
src/main/symbols-service.ts
Normal file
250
src/main/symbols-service.ts
Normal 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 }
|
||||||
|
}
|
||||||
@@ -84,6 +84,11 @@ const api = {
|
|||||||
files: (): Promise<string[]> => ipcRenderer.invoke('search:files'),
|
files: (): Promise<string[]> => ipcRenderer.invoke('search:files'),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
symbols: {
|
||||||
|
names: (): Promise<string[]> => ipcRenderer.invoke('symbols:names'),
|
||||||
|
lookup: (name: string) => ipcRenderer.invoke('symbols:lookup', name),
|
||||||
|
},
|
||||||
|
|
||||||
dialog: {
|
dialog: {
|
||||||
unsavedClose: (path: string): Promise<'save' | 'discard' | 'cancel'> =>
|
unsavedClose: (path: string): Promise<'save' | 'discard' | 'cancel'> =>
|
||||||
ipcRenderer.invoke('dialog:unsavedClose', path),
|
ipcRenderer.invoke('dialog:unsavedClose', path),
|
||||||
|
|||||||
@@ -5,12 +5,13 @@ import type { ContextTarget } from './components'
|
|||||||
import { Editor, SplitView } from './editor'
|
import { Editor, SplitView } from './editor'
|
||||||
import type { Cursor, Mode, Selection } from './editor'
|
import type { Cursor, Mode, Selection } from './editor'
|
||||||
import { Terminal, lid } from './terminals'
|
import { Terminal, lid } from './terminals'
|
||||||
import { ConfirmModal, ContextMenu, HelpModal, HistoryModal, NamePopup, NotesModal, PassPopup, ProjectsModal, SearchModal, Toasts } from './overlays'
|
import { ConfirmModal, ContextMenu, HelpModal, HistoryModal, NamePopup, NotesModal, PassPopup, ProjectsModal, SearchModal, SymbolPopup, Toasts } from './overlays'
|
||||||
import { ProjectLauncher } from './launcher'
|
import { ProjectLauncher } from './launcher'
|
||||||
import type { Menu, Toast } from './overlays'
|
import type { Menu, Toast } from './overlays'
|
||||||
import type { DiffSide, FileNode, GitStatus } from './types'
|
import type { DiffSide, FileNode, GitStatus, SymbolLookup } from './types'
|
||||||
import { useProject, useProjectActions } from './project'
|
import { useProject, useProjectActions } from './project'
|
||||||
import { HL } from './highlight'
|
import { HL } from './highlight'
|
||||||
|
import { loadSymbols } from './symbols'
|
||||||
import { rlog } from './log'
|
import { rlog } from './log'
|
||||||
import { loadJson, loadNum, saveJson, saveNum } from './persist'
|
import { loadJson, loadNum, saveJson, saveNum } from './persist'
|
||||||
|
|
||||||
@@ -290,7 +291,7 @@ export function App(): React.ReactElement {
|
|||||||
wasBlurred = false
|
wasBlurred = false
|
||||||
setShowFlash(true)
|
setShowFlash(true)
|
||||||
clearTimeout(timer)
|
clearTimeout(timer)
|
||||||
timer = setTimeout(() => setShowFlash(false), 2000)
|
timer = setTimeout(() => setShowFlash(false), 500)
|
||||||
}
|
}
|
||||||
function onBlur(): void { wasBlurred = true }
|
function onBlur(): void { wasBlurred = true }
|
||||||
window.addEventListener('focus', flash)
|
window.addEventListener('focus', flash)
|
||||||
@@ -519,6 +520,36 @@ export function App(): React.ReactElement {
|
|||||||
actions.unstage(p)
|
actions.unstage(p)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The declared-class list, fetched off the startup path: 1.5 s after the
|
||||||
|
// project is ready, and again 2 s after the last file change. Both are late on
|
||||||
|
// purpose — the list only decides which tokens may be underlined, so it can
|
||||||
|
// never be worth competing with the tree, git and terminals for the launch.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!proj.ready || !proj.root) return
|
||||||
|
const t = setTimeout(() => loadSymbols(), 1500)
|
||||||
|
return () => clearTimeout(t)
|
||||||
|
}, [proj.ready, proj.root])
|
||||||
|
useEffect(() => {
|
||||||
|
if (!proj.ready || !proj.root) return
|
||||||
|
const t = setTimeout(() => loadSymbols(true), 2000)
|
||||||
|
return () => clearTimeout(t)
|
||||||
|
}, [proj.changes, proj.tree, proj.ready, proj.root])
|
||||||
|
|
||||||
|
// ⌘-click on a PHP class name: the popup shows where it is declared and every
|
||||||
|
// reference to it. The lookup runs in main (one ripgrep pass) and the popup
|
||||||
|
// opens right away with a "Searching…" body, so the click never feels stuck.
|
||||||
|
const [symName, setSymName] = useState<string | null>(null)
|
||||||
|
const [symData, setSymData] = useState<SymbolLookup | null>(null)
|
||||||
|
function openSymbol(name: string): void {
|
||||||
|
setSymName(name)
|
||||||
|
setSymData(null)
|
||||||
|
const bridge = window.helder
|
||||||
|
if (!bridge) return
|
||||||
|
bridge.symbols.lookup(name)
|
||||||
|
.then((d) => setSymData((cur) => (cur === null ? d : cur)))
|
||||||
|
.catch((e) => { rlog.error('symbols', 'lookup failed', e, { name }); setSymName(null) })
|
||||||
|
}
|
||||||
|
|
||||||
// A search result opens the file at its line and paints that line orange for a
|
// A search result opens the file at its line and paints that line orange for a
|
||||||
// second, so the eye finds it after the scroll. `id` restarts a repeat jump.
|
// second, so the eye finds it after the scroll. `id` restarts a repeat jump.
|
||||||
const [lineFlash, setLineFlash] = useState<{ path: string; line: number; id: number } | null>(null)
|
const [lineFlash, setLineFlash] = useState<{ path: string; line: number; id: number } | null>(null)
|
||||||
@@ -1054,7 +1085,7 @@ export function App(): React.ReactElement {
|
|||||||
onSplit={(p) => setSplitFor(p)} splitOpen={splitFor === active}
|
onSplit={(p) => setSplitFor(p)} splitOpen={splitFor === active}
|
||||||
cursor={cursor} selection={selection} setCursor={setCursor} setSelection={setSelection}
|
cursor={cursor} selection={selection} setCursor={setCursor} setSelection={setSelection}
|
||||||
flash={lineFlash && lineFlash.path === active ? lineFlash : null}
|
flash={lineFlash && lineFlash.path === active ? lineFlash : null}
|
||||||
bufferText={bufferText(active)} onEdit={onEdit} />
|
bufferText={bufferText(active)} onEdit={onEdit} onSymbol={openSymbol} />
|
||||||
</div>
|
</div>
|
||||||
<Splitter onDelta={(dx) => { setAutoResize(false); setRightW((w) => {
|
<Splitter onDelta={(dx) => { setAutoResize(false); setRightW((w) => {
|
||||||
// grow until the editor would drop below ~280px (rather than a fixed cap)
|
// grow until the editor would drop below ~280px (rather than a fixed cap)
|
||||||
@@ -1093,6 +1124,9 @@ export function App(): React.ReactElement {
|
|||||||
{overlay === 'help' && <HelpModal onClose={() => setOverlay(null)} />}
|
{overlay === 'help' && <HelpModal onClose={() => setOverlay(null)} />}
|
||||||
{overlay === 'notes' && <NotesModal text={note} onChange={setNote} onClose={() => { setOverlay(null); saveNote() }} />}
|
{overlay === 'notes' && <NotesModal text={note} onChange={setNote} onClose={() => { setOverlay(null); saveNote() }} />}
|
||||||
{confirm && <ConfirmModal title={confirm.title} body={confirm.body} confirmLabel={confirm.confirmLabel} danger onConfirm={confirm.onConfirm} onClose={() => setConfirm(null)} />}
|
{confirm && <ConfirmModal title={confirm.title} body={confirm.body} confirmLabel={confirm.confirmLabel} danger onConfirm={confirm.onConfirm} onClose={() => setConfirm(null)} />}
|
||||||
|
{symName && <SymbolPopup name={symName} data={symData}
|
||||||
|
onOpenAt={(p, n) => openFile(p, { line: n })} onSymbol={openSymbol}
|
||||||
|
onClose={() => { setSymName(null); setSymData(null) }} />}
|
||||||
{menu && <ContextMenu menu={menu} onClose={() => setMenu(null)} />}
|
{menu && <ContextMenu menu={menu} onClose={() => setMenu(null)} />}
|
||||||
<Toasts toasts={toasts} />
|
<Toasts toasts={toasts} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,10 +4,46 @@ import type { Diff, DiffSide, ViewLine } from './types'
|
|||||||
import { rowId } from './types'
|
import { rowId } from './types'
|
||||||
import { useProject } from './project'
|
import { useProject } from './project'
|
||||||
import { HL } from './highlight'
|
import { HL } from './highlight'
|
||||||
|
import { isKnownSymbol, useSymbols } from './symbols'
|
||||||
import { renderMarkdown } from './markdown'
|
import { renderMarkdown } from './markdown'
|
||||||
import { FileIcon, Icon } from './components'
|
import { FileIcon, Icon } from './components'
|
||||||
import type { OnContext } from './components'
|
import type { OnContext } from './components'
|
||||||
|
|
||||||
|
/** ⌘-click on a class name declared in this project. */
|
||||||
|
export type OnSymbol = (name: string) => void
|
||||||
|
|
||||||
|
/** The identifier around `pos` in `text`, or '' when the caret is not on one. */
|
||||||
|
export function wordAt(text: string, pos: number): string {
|
||||||
|
if (pos < 0 || pos > text.length) return ''
|
||||||
|
const word = /\w/
|
||||||
|
let a = pos
|
||||||
|
while (a > 0 && word.test(text[a - 1])) a--
|
||||||
|
let b = pos
|
||||||
|
while (b < text.length && word.test(text[b])) b++
|
||||||
|
const w = text.slice(a, b)
|
||||||
|
return /^[A-Za-z_]\w*$/.test(w) ? w : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True while ⌘ (or Ctrl) is held. Drives the underline, so the affordance
|
||||||
|
* shows exactly when the click would do something. */
|
||||||
|
function useMetaKey(): boolean {
|
||||||
|
const [down, setDown] = useState(false)
|
||||||
|
useEffect(() => {
|
||||||
|
const on = (e: KeyboardEvent): void => { if (e.metaKey || e.ctrlKey) setDown(true) }
|
||||||
|
const off = (e: KeyboardEvent): void => { if (!e.metaKey && !e.ctrlKey) setDown(false) }
|
||||||
|
const clear = (): void => setDown(false)
|
||||||
|
window.addEventListener('keydown', on)
|
||||||
|
window.addEventListener('keyup', off)
|
||||||
|
window.addEventListener('blur', clear)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('keydown', on)
|
||||||
|
window.removeEventListener('keyup', off)
|
||||||
|
window.removeEventListener('blur', clear)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
return down
|
||||||
|
}
|
||||||
|
|
||||||
export interface Cursor { path: string; line: number; col: number }
|
export interface Cursor { path: string; line: number; col: number }
|
||||||
/** A one-second line highlight after a jump. `id` changes per jump, so the same
|
/** A one-second line highlight after a jump. `id` changes per jump, so the same
|
||||||
* line twice restarts the animation. */
|
* line twice restarts the animation. */
|
||||||
@@ -32,7 +68,7 @@ function climbToLine(node: Node | null): HTMLElement | null {
|
|||||||
* the number is a CSS counter on `::before`, which keeps it on the first
|
* the number is a CSS counter on `::before`, which keeps it on the first
|
||||||
* visual row and leaves the folded rows unnumbered. Highlighting is then per
|
* visual row and leaves the folded rows unnumbered. Highlighting is then per
|
||||||
* line (like the diff views), so multi-line tokens don't carry over. */
|
* line (like the diff views), so multi-line tokens don't carry over. */
|
||||||
function CodeEditor({ path, text, lang, wrap, flash, onChange, onContext }: {
|
function CodeEditor({ path, text, lang, wrap, flash, onChange, onContext, onSymbol }: {
|
||||||
path: string
|
path: string
|
||||||
text: string
|
text: string
|
||||||
lang: string | null
|
lang: string | null
|
||||||
@@ -41,16 +77,22 @@ function CodeEditor({ path, text, lang, wrap, flash, onChange, onContext }: {
|
|||||||
flash: FlashLine | null
|
flash: FlashLine | null
|
||||||
onChange: (text: string) => void
|
onChange: (text: string) => void
|
||||||
onContext: OnContext
|
onContext: OnContext
|
||||||
|
onSymbol: OnSymbol
|
||||||
}): React.ReactElement {
|
}): React.ReactElement {
|
||||||
const scrollRef = useRef<HTMLDivElement>(null)
|
const scrollRef = useRef<HTMLDivElement>(null)
|
||||||
const gutterRef = useRef<HTMLDivElement>(null)
|
const gutterRef = useRef<HTMLDivElement>(null)
|
||||||
const preRef = useRef<HTMLPreElement>(null)
|
const preRef = useRef<HTMLPreElement>(null)
|
||||||
const tabSize = useProject().config.editor.tabSize
|
const tabSize = useProject().config.editor.tabSize
|
||||||
|
const symVer = useSymbols() // re-highlight once the declared-name list lands
|
||||||
|
const metaDown = useMetaKey()
|
||||||
const html = useMemo(
|
const html = useMemo(
|
||||||
() => (wrap
|
() => (wrap
|
||||||
? text.split('\n').map((l) => `<div class="ce-line">${HL.hlLine(l, lang)}</div>`).join('')
|
? text.split('\n').map((l) => `<div class="ce-line">${HL.hlLine(l, lang)}</div>`).join('')
|
||||||
: HL.hlText(text, lang) + '\n'),
|
: HL.hlText(text, lang) + '\n'),
|
||||||
[text, lang, wrap],
|
// symVer is not read here: HL marks the known class tokens from a module-level
|
||||||
|
// set, so this is what recomputes the HTML when that set lands.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[text, lang, wrap, symVer],
|
||||||
)
|
)
|
||||||
const count = useMemo(() => text.split('\n').length, [text])
|
const count = useMemo(() => text.split('\n').length, [text])
|
||||||
// The band sits behind the text, so it needs the geometry of the line. Unwrapped
|
// The band sits behind the text, so it needs the geometry of the line. Unwrapped
|
||||||
@@ -122,6 +164,26 @@ function CodeEditor({ path, text, lang, wrap, flash, onChange, onContext }: {
|
|||||||
function onKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>): void {
|
function onKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>): void {
|
||||||
if (e.key === 'Tab' && !e.metaKey && !e.ctrlKey && !e.altKey) handleTab(e, e.shiftKey)
|
if (e.key === 'Tab' && !e.metaKey && !e.ctrlKey && !e.altKey) handleTab(e, e.shiftKey)
|
||||||
}
|
}
|
||||||
|
/* Two ways in. While ⌘ is held the marked tokens take the mouse (see the
|
||||||
|
* .sym-live rules), so a click on one arrives here with the token as target
|
||||||
|
* and its text is the name. Anywhere else the click lands on the textarea,
|
||||||
|
* which has already moved the caret, so the word around `selectionStart` is
|
||||||
|
* the word under the pointer — no token geometry either way. */
|
||||||
|
function handleTokenClick(e: React.MouseEvent): void {
|
||||||
|
if (!e.metaKey && !e.ctrlKey) return
|
||||||
|
const el = e.target as HTMLElement
|
||||||
|
if (!el.classList?.contains('sym')) return
|
||||||
|
e.preventDefault()
|
||||||
|
onSymbol(el.textContent || '')
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClick(e: React.MouseEvent<HTMLTextAreaElement>): void {
|
||||||
|
ensureCaretVisible(e.currentTarget)
|
||||||
|
if (!e.metaKey && !e.ctrlKey) return
|
||||||
|
const name = wordAt(e.currentTarget.value, e.currentTarget.selectionStart)
|
||||||
|
if (name && isKnownSymbol(name)) { e.preventDefault(); onSymbol(name) }
|
||||||
|
}
|
||||||
|
|
||||||
function handleContext(e: React.MouseEvent<HTMLTextAreaElement>): void {
|
function handleContext(e: React.MouseEvent<HTMLTextAreaElement>): void {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const ta = e.currentTarget
|
const ta = e.currentTarget
|
||||||
@@ -135,7 +197,7 @@ function CodeEditor({ path, text, lang, wrap, flash, onChange, onContext }: {
|
|||||||
onContext(e, info)
|
onContext(e, info)
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className={'code-edit' + (wrap ? ' wrap' : '')}>
|
<div className={'code-edit' + (wrap ? ' wrap' : '') + (metaDown ? ' sym-live' : '')}>
|
||||||
{!wrap && (
|
{!wrap && (
|
||||||
<div className="ce-gutterwrap">
|
<div className="ce-gutterwrap">
|
||||||
<div className="ce-gutter" ref={gutterRef}>
|
<div className="ce-gutter" ref={gutterRef}>
|
||||||
@@ -143,7 +205,7 @@ function CodeEditor({ path, text, lang, wrap, flash, onChange, onContext }: {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="ce-scroll" ref={scrollRef} onScroll={onScroll}>
|
<div className="ce-scroll" ref={scrollRef} onScroll={onScroll} onClick={handleTokenClick}>
|
||||||
<div className="ce-inner">
|
<div className="ce-inner">
|
||||||
{flash && flashBox && <div key={flash.id} className="ce-flash" style={{ top: flashBox.top, height: flashBox.height }} />}
|
{flash && flashBox && <div key={flash.id} className="ce-flash" style={{ top: flashBox.top, height: flashBox.height }} />}
|
||||||
<textarea className="ce-ta" value={text} spellCheck={false} autoComplete="off"
|
<textarea className="ce-ta" value={text} spellCheck={false} autoComplete="off"
|
||||||
@@ -151,7 +213,7 @@ function CodeEditor({ path, text, lang, wrap, flash, onChange, onContext }: {
|
|||||||
onChange={(e) => { onChange(e.target.value); ensureCaretVisible(e.target) }}
|
onChange={(e) => { onChange(e.target.value); ensureCaretVisible(e.target) }}
|
||||||
onKeyDown={onKeyDown}
|
onKeyDown={onKeyDown}
|
||||||
onKeyUp={(e) => ensureCaretVisible(e.currentTarget)}
|
onKeyUp={(e) => ensureCaretVisible(e.currentTarget)}
|
||||||
onClick={(e) => ensureCaretVisible(e.currentTarget)}
|
onClick={handleClick}
|
||||||
onContextMenu={handleContext} />
|
onContextMenu={handleContext} />
|
||||||
<pre className="ce-pre" ref={preRef} aria-hidden style={{ tabSize }} dangerouslySetInnerHTML={{ __html: html }} />
|
<pre className="ce-pre" ref={preRef} aria-hidden style={{ tabSize }} dangerouslySetInnerHTML={{ __html: html }} />
|
||||||
</div>
|
</div>
|
||||||
@@ -197,7 +259,7 @@ function ImageView({ path, onContext }: { path: string; onContext: OnContext }):
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Generic pane: renders an array of line descriptors with selection + caret + context. */
|
/* Generic pane: renders an array of line descriptors with selection + caret + context. */
|
||||||
function PaneView({ cacheKey, path, lines, lang, showSign, wrap, cursor, selection, setCursor, setSelection, onContext }: {
|
function PaneView({ cacheKey, path, lines, lang, showSign, wrap, cursor, selection, setCursor, setSelection, onContext, onSymbol }: {
|
||||||
cacheKey: string
|
cacheKey: string
|
||||||
path: string
|
path: string
|
||||||
lines: ViewLine[]
|
lines: ViewLine[]
|
||||||
@@ -210,9 +272,20 @@ function PaneView({ cacheKey, path, lines, lang, showSign, wrap, cursor, selecti
|
|||||||
setCursor: (c: Cursor) => void
|
setCursor: (c: Cursor) => void
|
||||||
setSelection: (s: Selection | null) => void
|
setSelection: (s: Selection | null) => void
|
||||||
onContext: OnContext
|
onContext: OnContext
|
||||||
|
onSymbol: OnSymbol
|
||||||
}): React.ReactElement {
|
}): React.ReactElement {
|
||||||
const anchorRef = useRef<number | null>(null)
|
const anchorRef = useRef<number | null>(null)
|
||||||
const html = useMemo(() => lines.map((l) => HL.hlLine(l.text, lang)), [cacheKey])
|
const symVer = useSymbols()
|
||||||
|
const metaDown = useMetaKey()
|
||||||
|
const html = useMemo(() => lines.map((l) => HL.hlLine(l.text, lang)), [cacheKey, symVer])
|
||||||
|
/* These rows are real spans, so the marked token is the click target itself. */
|
||||||
|
function onSymbolClick(e: React.MouseEvent): void {
|
||||||
|
if (!e.metaKey && !e.ctrlKey) return
|
||||||
|
const el = e.target as HTMLElement
|
||||||
|
if (!el.classList?.contains('sym')) return
|
||||||
|
e.preventDefault()
|
||||||
|
onSymbol(el.textContent || '')
|
||||||
|
}
|
||||||
|
|
||||||
function gutterClick(e: React.MouseEvent, no: number | null): void {
|
function gutterClick(e: React.MouseEvent, no: number | null): void {
|
||||||
if (no == null) return
|
if (no == null) return
|
||||||
@@ -283,7 +356,8 @@ function PaneView({ cacheKey, path, lines, lang, showSign, wrap, cursor, selecti
|
|||||||
const sel = selection && selection.path === path ? selection : null
|
const sel = selection && selection.path === path ? selection : null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={'editor' + (showSign ? ' diff' : '') + (wrap ? ' wrap' : '')} onMouseUp={onMouseUp} onContextMenu={handleContext}>
|
<div className={'editor' + (showSign ? ' diff' : '') + (wrap ? ' wrap' : '') + (metaDown ? ' sym-live' : '')}
|
||||||
|
onMouseUp={onMouseUp} onClick={onSymbolClick} onContextMenu={handleContext}>
|
||||||
{lines.map((l, i) => {
|
{lines.map((l, i) => {
|
||||||
const no = l.no
|
const no = l.no
|
||||||
const inSel = sel && no != null && no >= sel.start && no <= sel.end
|
const inSel = sel && no != null && no >= sel.start && no <= sel.end
|
||||||
@@ -327,7 +401,7 @@ function segmentsFor(hasDiff: boolean, isMarkdown: boolean): { id: Mode; label:
|
|||||||
return segs
|
return segs
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Editor({ active, mode, side, setMode, onContext, onSplit, splitOpen, cursor, selection, setCursor, setSelection, flash, bufferText, onEdit }: {
|
export function Editor({ active, mode, side, setMode, onContext, onSplit, splitOpen, cursor, selection, setCursor, setSelection, flash, bufferText, onEdit, onSymbol }: {
|
||||||
active: string | null
|
active: string | null
|
||||||
mode: Mode
|
mode: Mode
|
||||||
/** Which git row opened this tab. Only Diff and Split follow it. */
|
/** Which git row opened this tab. Only Diff and Split follow it. */
|
||||||
@@ -344,6 +418,8 @@ export function Editor({ active, mode, side, setMode, onContext, onSplit, splitO
|
|||||||
flash: FlashLine | null
|
flash: FlashLine | null
|
||||||
bufferText: string
|
bufferText: string
|
||||||
onEdit: (text: string) => void
|
onEdit: (text: string) => void
|
||||||
|
/** ⌘-click on a class name declared in this project. */
|
||||||
|
onSymbol: OnSymbol
|
||||||
}): React.ReactElement {
|
}): React.ReactElement {
|
||||||
const PROJECT = useProject()
|
const PROJECT = useProject()
|
||||||
const tab = active ? { path: active } : null
|
const tab = active ? { path: active } : null
|
||||||
@@ -451,11 +527,11 @@ export function Editor({ active, mode, side, setMode, onContext, onSplit, splitO
|
|||||||
) : emptyOriginal ? (
|
) : emptyOriginal ? (
|
||||||
<div className="empty-ed"><div className="big" style={{ color: 'var(--add)' }}>No original version</div><div style={{ fontFamily: 'var(--mono)', fontSize: 12, color: 'var(--fg-3)' }}>This file is new in the change.</div></div>
|
<div className="empty-ed"><div className="big" style={{ color: 'var(--add)' }}>No original version</div><div style={{ fontFamily: 'var(--mono)', fontSize: 12, color: 'var(--fg-3)' }}>This file is new in the change.</div></div>
|
||||||
) : editable ? (
|
) : editable ? (
|
||||||
<CodeEditor path={tab.path} text={bufferText} lang={lang} wrap={wrap} flash={flash} onChange={onEdit} onContext={onContext} />
|
<CodeEditor path={tab.path} text={bufferText} lang={lang} wrap={wrap} flash={flash} onChange={onEdit} onContext={onContext} onSymbol={onSymbol} />
|
||||||
) : (
|
) : (
|
||||||
built && <PaneView cacheKey={tab.path + ':' + effMode + ':' + (effMode === 'diff' ? side ?? '' : '')} path={tab.path} lines={built.lines}
|
built && <PaneView cacheKey={tab.path + ':' + effMode + ':' + (effMode === 'diff' ? side ?? '' : '')} path={tab.path} lines={built.lines}
|
||||||
lang={lang} showSign={built.showSign} wrap={wrap} cursor={cursor} selection={selection}
|
lang={lang} showSign={built.showSign} wrap={wrap} cursor={cursor} selection={selection}
|
||||||
setCursor={setCursor} setSelection={setSelection} onContext={onContext} />
|
setCursor={setCursor} setSelection={setSelection} onContext={onContext} onSymbol={onSymbol} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
6
src/renderer/src/env.d.ts
vendored
6
src/renderer/src/env.d.ts
vendored
@@ -1,5 +1,5 @@
|
|||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
import type { FileNode, GitStatus, HelderConfig } from './types'
|
import type { FileNode, GitStatus, HelderConfig, SymbolLookup } from './types'
|
||||||
|
|
||||||
interface GitChangeRaw {
|
interface GitChangeRaw {
|
||||||
path: string
|
path: string
|
||||||
@@ -66,6 +66,10 @@ interface HelderBridge {
|
|||||||
content: (query: string) => Promise<{ path: string; hits: { no: number; ln: string; ix: number }[] }[]>
|
content: (query: string) => Promise<{ path: string; hits: { no: number; ln: string; ix: number }[] }[]>
|
||||||
files: () => Promise<string[]>
|
files: () => Promise<string[]>
|
||||||
}
|
}
|
||||||
|
symbols: {
|
||||||
|
names: () => Promise<string[]>
|
||||||
|
lookup: (name: string) => Promise<SymbolLookup>
|
||||||
|
}
|
||||||
dialog: {
|
dialog: {
|
||||||
unsavedClose: (path: string) => Promise<'save' | 'discard' | 'cancel'>
|
unsavedClose: (path: string) => Promise<'save' | 'discard' | 'cancel'>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
* prism-markup-templating to be loaded FIRST, or every Prism.highlight() call
|
* prism-markup-templating to be loaded FIRST, or every Prism.highlight() call
|
||||||
* throws and silently falls back to plain text. */
|
* throws and silently falls back to plain text. */
|
||||||
import Prism from 'prismjs'
|
import Prism from 'prismjs'
|
||||||
|
import { isKnownSymbol } from './symbols'
|
||||||
import 'prismjs/components/prism-markup-templating'
|
import 'prismjs/components/prism-markup-templating'
|
||||||
import 'prismjs/components/prism-php'
|
import 'prismjs/components/prism-php'
|
||||||
import 'prismjs/components/prism-python'
|
import 'prismjs/components/prism-python'
|
||||||
@@ -59,12 +60,29 @@ function escapeHtml(s: string): string {
|
|||||||
return s.replace(/[&<>]/g, (c) => ({ '&': '&', '<': '<', '>': '>' }[c] as string))
|
return s.replace(/[&<>]/g, (c) => ({ '&': '&', '<': '<', '>': '>' }[c] as string))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Mark the class tokens this project actually declares, so ⌘-click has
|
||||||
|
* something honest to underline. Prism already tells us which tokens are class
|
||||||
|
* names; the index tells us which of those we can open. A token whose text is
|
||||||
|
* not a bare identifier (a namespaced `App\Models\Agent`) carries nested spans
|
||||||
|
* and never matches, which is right — the name to click sits inside it. */
|
||||||
|
const TOKEN_RE = /<span class="token ([^"]*)">([A-Za-z_]\w*)<\/span>/g
|
||||||
|
function markKnown(html: string): string {
|
||||||
|
return html.replace(TOKEN_RE, (whole, cls: string, name: string) => (
|
||||||
|
/\b(class-name|package)\b/.test(cls) && isKnownSymbol(name)
|
||||||
|
? `<span class="token ${cls} sym">${name}</span>`
|
||||||
|
: whole
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
// highlight a single line independently (keeps line numbering robust)
|
// highlight a single line independently (keeps line numbering robust)
|
||||||
function hlLine(line: string, lang: string | null): string {
|
function hlLine(line: string, lang: string | null): string {
|
||||||
if (line === '') return ' '
|
if (line === '') return ' '
|
||||||
try {
|
try {
|
||||||
const grammar = lang ? Prism.languages[lang] : null
|
const grammar = lang ? Prism.languages[lang] : null
|
||||||
if (grammar) return Prism.highlight(line, grammar, lang as string)
|
if (grammar) {
|
||||||
|
const html = Prism.highlight(line, grammar, lang as string)
|
||||||
|
return lang === 'php' ? markKnown(html) : html
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
/* fall through */
|
/* fall through */
|
||||||
}
|
}
|
||||||
@@ -75,7 +93,10 @@ function hlLine(line: string, lang: string | null): string {
|
|||||||
function hlText(text: string, lang: string | null): string {
|
function hlText(text: string, lang: string | null): string {
|
||||||
try {
|
try {
|
||||||
const grammar = lang ? Prism.languages[lang] : null
|
const grammar = lang ? Prism.languages[lang] : null
|
||||||
if (grammar) return Prism.highlight(text, grammar, lang as string)
|
if (grammar) {
|
||||||
|
const html = Prism.highlight(text, grammar, lang as string)
|
||||||
|
return lang === 'php' ? markKnown(html) : html
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
/* fall through */
|
/* fall through */
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ import React, { Fragment, useEffect, useMemo, useRef, useState } from 'react'
|
|||||||
import { useProject } from './project'
|
import { useProject } from './project'
|
||||||
import type { RecentProject } from './project'
|
import type { RecentProject } from './project'
|
||||||
import { fuzzy } from './fuzzy'
|
import { fuzzy } from './fuzzy'
|
||||||
|
import { isKnownSymbol } from './symbols'
|
||||||
import { FileIcon, Icon } from './components'
|
import { FileIcon, Icon } from './components'
|
||||||
import { HL } from './highlight'
|
import { HL } from './highlight'
|
||||||
import type { OpenFile } from './components'
|
import type { OpenFile } from './components'
|
||||||
|
import type { SymbolDef, SymbolLookup } from './types'
|
||||||
|
|
||||||
export interface MenuItem {
|
export interface MenuItem {
|
||||||
sep?: boolean
|
sep?: boolean
|
||||||
@@ -66,6 +68,8 @@ export function SearchModal({ initialQuery, onOpen, onOpenAt, onClose, changeSet
|
|||||||
type Col = 'infile' | 'content' | 'files'
|
type Col = 'infile' | 'content' | 'files'
|
||||||
const cols: Col[] = hasInFile ? ['infile', 'content', 'files'] : ['content', 'files']
|
const cols: Col[] = hasInFile ? ['infile', 'content', 'files'] : ['content', 'files']
|
||||||
const [col, setCol] = useState<Col>('content') // active result column (⌘← / ⌘→ cycle)
|
const [col, setCol] = useState<Col>('content') // active result column (⌘← / ⌘→ cycle)
|
||||||
|
// While the arrows drive, the parked pointer must stop painting its own row.
|
||||||
|
const [kbdNav, setKbdNav] = useState(true)
|
||||||
const inputRef = useRef<HTMLInputElement>(null)
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
const inFileRef = useRef<HTMLDivElement>(null)
|
const inFileRef = useRef<HTMLDivElement>(null)
|
||||||
const leftRef = useRef<HTMLDivElement>(null)
|
const leftRef = useRef<HTMLDivElement>(null)
|
||||||
@@ -199,11 +203,13 @@ export function SearchModal({ initialQuery, onOpen, onOpenAt, onClose, changeSet
|
|||||||
}
|
}
|
||||||
if (e.key === 'ArrowDown') {
|
if (e.key === 'ArrowDown') {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
setKbdNav(true)
|
||||||
if (col === 'files') setFileSel((s) => Math.min(s + 1, fileCount - 1))
|
if (col === 'files') setFileSel((s) => Math.min(s + 1, fileCount - 1))
|
||||||
else if (col === 'infile') setInFileSel((s) => Math.min(s + 1, inFileCount - 1))
|
else if (col === 'infile') setInFileSel((s) => Math.min(s + 1, inFileCount - 1))
|
||||||
else setSel((s) => Math.min(s + 1, flat.length - 1))
|
else setSel((s) => Math.min(s + 1, flat.length - 1))
|
||||||
} else if (e.key === 'ArrowUp') {
|
} else if (e.key === 'ArrowUp') {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
setKbdNav(true)
|
||||||
if (col === 'files') setFileSel((s) => Math.max(s - 1, 0))
|
if (col === 'files') setFileSel((s) => Math.max(s - 1, 0))
|
||||||
else if (col === 'infile') setInFileSel((s) => Math.max(s - 1, 0))
|
else if (col === 'infile') setInFileSel((s) => Math.max(s - 1, 0))
|
||||||
else setSel((s) => Math.max(s - 1, 0))
|
else setSel((s) => Math.max(s - 1, 0))
|
||||||
@@ -248,7 +254,7 @@ export function SearchModal({ initialQuery, onOpen, onOpenAt, onClose, changeSet
|
|||||||
)}
|
)}
|
||||||
<span className="mode-chip">{hasInFile && <>{inFile.length} here · </>}{totalHits} hit{totalHits === 1 ? '' : 's'} · {files.length} file{files.length === 1 ? '' : 's'}</span>
|
<span className="mode-chip">{hasInFile && <>{inFile.length} here · </>}{totalHits} hit{totalHits === 1 ? '' : 's'} · {files.length} file{files.length === 1 ? '' : 's'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="search-cols">
|
<div className={'search-cols' + (kbdNav ? ' kbd-nav' : '')} onMouseMove={() => setKbdNav(false)}>
|
||||||
{hasInFile && (
|
{hasInFile && (
|
||||||
<div className={'sc-infile' + (col === 'infile' ? ' active' : '')} ref={inFileRef}>
|
<div className={'sc-infile' + (col === 'infile' ? ' active' : '')} ref={inFileRef}>
|
||||||
<div className="sc-head">
|
<div className="sc-head">
|
||||||
@@ -335,11 +341,21 @@ export function HistoryModal({ history, initialSel, onOpen, onClose, changeSet }
|
|||||||
const [sel, setSel] = useState(() => Math.min(Math.max(initialSel, 0), Math.max(history.length - 1, 0)))
|
const [sel, setSel] = useState(() => Math.min(Math.max(initialSel, 0), Math.max(history.length - 1, 0)))
|
||||||
const selRef = useRef(sel); selRef.current = sel
|
const selRef = useRef(sel); selRef.current = sel
|
||||||
const listRef = useRef<HTMLDivElement>(null)
|
const listRef = useRef<HTMLDivElement>(null)
|
||||||
|
// While the keyboard drives, the pointer sits parked on whatever row it last
|
||||||
|
// touched and CSS :hover would keep painting it beside the selected row. The
|
||||||
|
// class tells the list to ignore hover until the mouse actually moves again.
|
||||||
|
const [kbdNav, setKbdNav] = useState(true)
|
||||||
|
function onMouseMove(e: React.MouseEvent): void {
|
||||||
|
setKbdNav(false)
|
||||||
|
const row = (e.target as HTMLElement).closest?.('.hist-row') as HTMLElement | null
|
||||||
|
const i = row?.dataset.i
|
||||||
|
if (i != null) setSel(+i)
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function onKey(e: KeyboardEvent): void {
|
function onKey(e: KeyboardEvent): void {
|
||||||
if (e.key === 'ArrowDown') { e.preventDefault(); setSel((s) => Math.min(s + 1, history.length - 1)) }
|
if (e.key === 'ArrowDown') { e.preventDefault(); setKbdNav(true); setSel((s) => Math.min(s + 1, history.length - 1)) }
|
||||||
else if (e.key === 'ArrowUp') { e.preventDefault(); setSel((s) => Math.max(s - 1, 0)) }
|
else if (e.key === 'ArrowUp') { e.preventDefault(); setKbdNav(true); setSel((s) => Math.max(s - 1, 0)) }
|
||||||
else if (e.key === 'Enter') { e.preventDefault(); const p = history[selRef.current]; if (p) { onOpen(p); onClose() } }
|
else if (e.key === 'Enter') { e.preventDefault(); const p = history[selRef.current]; if (p) { onOpen(p); onClose() } }
|
||||||
else if (e.key === 'Escape') { e.preventDefault(); onClose() }
|
else if (e.key === 'Escape') { e.preventDefault(); onClose() }
|
||||||
}
|
}
|
||||||
@@ -360,14 +376,13 @@ export function HistoryModal({ history, initialSel, onOpen, onClose, changeSet }
|
|||||||
<span className="hist-title">Recent files</span>
|
<span className="hist-title">Recent files</span>
|
||||||
<span className="mode-chip">{history.length} file{history.length === 1 ? '' : 's'} · <kbd>⌘↓</kbd> <kbd>⌘↑</kbd> <kbd>↵</kbd></span>
|
<span className="mode-chip">{history.length} file{history.length === 1 ? '' : 's'} · <kbd>⌘↓</kbd> <kbd>⌘↑</kbd> <kbd>↵</kbd></span>
|
||||||
</div>
|
</div>
|
||||||
<div className="hist-list" ref={listRef}>
|
<div className={'hist-list' + (kbdNav ? ' kbd-nav' : '')} ref={listRef} onMouseMove={onMouseMove}>
|
||||||
{history.length === 0 && <div className="pempty">No files opened yet</div>}
|
{history.length === 0 && <div className="pempty">No files opened yet</div>}
|
||||||
{history.map((p, i) => {
|
{history.map((p, i) => {
|
||||||
const name = p.split('/').pop() as string
|
const name = p.split('/').pop() as string
|
||||||
const dir = p.split('/').slice(0, -1).join('/')
|
const dir = p.split('/').slice(0, -1).join('/')
|
||||||
return (
|
return (
|
||||||
<div key={p} className={'hist-row' + (i === sel ? ' sel' : '')} title={p}
|
<div key={p} className={'hist-row' + (i === sel ? ' sel' : '')} title={p} data-i={i}
|
||||||
onMouseEnter={() => setSel(i)}
|
|
||||||
onClick={() => { onOpen(p); onClose() }}>
|
onClick={() => { onOpen(p); onClose() }}>
|
||||||
<FileIcon path={p} />
|
<FileIcon path={p} />
|
||||||
<div className="hist-txt">
|
<div className="hist-txt">
|
||||||
@@ -622,6 +637,147 @@ export function ContextMenu({ menu, onClose }: { menu: Menu | null; onClose: ()
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ⌘-click on a PHP class name: where it is declared, then every reference to
|
||||||
|
* it. Both sections open a file at a line, so the whole popup is one flat list
|
||||||
|
* for the keyboard: ↑↓ move, ↵ opens, Esc closes.
|
||||||
|
*
|
||||||
|
* Centred on the window rather than anchored to the click: the list is as long
|
||||||
|
* as the class is popular, and a click near the bottom edge would cut it off. */
|
||||||
|
export function SymbolPopup({ name, data, onOpenAt, onSymbol, onClose }: {
|
||||||
|
name: string
|
||||||
|
/** null while the lookup runs. */
|
||||||
|
data: SymbolLookup | null
|
||||||
|
onOpenAt: (path: string, line: number) => void
|
||||||
|
/** Follow a parent class or interface without leaving the popup. */
|
||||||
|
onSymbol: (name: string) => void
|
||||||
|
onClose: () => void
|
||||||
|
}): React.ReactElement {
|
||||||
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
|
const [hi, setHi] = useState(0)
|
||||||
|
const targets = useMemo(() => {
|
||||||
|
const out: { path: string; line: number }[] = []
|
||||||
|
data?.defs.forEach((d) => out.push({ path: d.path, line: d.line }))
|
||||||
|
data?.refs.forEach((g) => g.hits.forEach((h) => out.push({ path: g.path, line: h.no })))
|
||||||
|
return out
|
||||||
|
}, [data])
|
||||||
|
const hiRef = useRef(hi); hiRef.current = hi
|
||||||
|
const tRef = useRef(targets); tRef.current = targets
|
||||||
|
|
||||||
|
useEffect(() => { setHi(0) }, [data])
|
||||||
|
useEffect(() => {
|
||||||
|
const key = (e: KeyboardEvent): void => {
|
||||||
|
if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); onClose() }
|
||||||
|
else if (e.key === 'ArrowDown') { e.preventDefault(); e.stopPropagation(); setHi((i) => Math.min(i + 1, tRef.current.length - 1)) }
|
||||||
|
else if (e.key === 'ArrowUp') { e.preventDefault(); e.stopPropagation(); setHi((i) => Math.max(i - 1, 0)) }
|
||||||
|
else if (e.key === 'Enter') {
|
||||||
|
e.preventDefault(); e.stopPropagation()
|
||||||
|
const t = tRef.current[hiRef.current]
|
||||||
|
if (t) { onOpenAt(t.path, t.line); onClose() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('keydown', key, true)
|
||||||
|
return () => { document.removeEventListener('keydown', key, true) }
|
||||||
|
}, [onClose, onOpenAt])
|
||||||
|
useEffect(() => {
|
||||||
|
const el = ref.current?.querySelector('.sym-row.hi')
|
||||||
|
if (el) el.scrollIntoView({ block: 'nearest' })
|
||||||
|
}, [hi])
|
||||||
|
|
||||||
|
// One declaration: name it in full, the way the file does. Several: the short
|
||||||
|
// name is the only thing they share.
|
||||||
|
const only = data && data.defs.length === 1 ? data.defs[0] : null
|
||||||
|
const title = only && only.ns ? only.ns + '\\' + name : name
|
||||||
|
let ix = -1
|
||||||
|
return (
|
||||||
|
<div className="scrim" onMouseDown={onClose}>
|
||||||
|
<div className="sym-popup" ref={ref} onMouseDown={(e) => e.stopPropagation()}>
|
||||||
|
<div className="sym-head">
|
||||||
|
<span className="sym-name">{title}</span>
|
||||||
|
{data && <span className="sym-count">{data.refCount} reference{data.refCount === 1 ? '' : 's'}</span>}
|
||||||
|
</div>
|
||||||
|
{!data && <div className="pempty sm">Searching…</div>}
|
||||||
|
{data && (
|
||||||
|
<div className="sym-body">
|
||||||
|
<div className="sym-sec">Defined in</div>
|
||||||
|
{data.defs.length === 0 && <div className="pempty sm">No declaration in this project</div>}
|
||||||
|
{data.defs.map((d) => {
|
||||||
|
ix++
|
||||||
|
const me = ix
|
||||||
|
return (
|
||||||
|
<div key={d.path + d.line} className={'sym-row def' + (me === hi ? ' hi' : '')}
|
||||||
|
onMouseEnter={() => setHi(me)}
|
||||||
|
onClick={() => { onOpenAt(d.path, d.line); onClose() }}>
|
||||||
|
<FileIcon path={d.path} />
|
||||||
|
<span className="sym-path">{d.path}</span>
|
||||||
|
<span className="sym-kind">{d.kind}</span>
|
||||||
|
<span className="no">{d.line}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{data.defs.map((d) => (
|
||||||
|
<SymbolRelations key={'rel' + d.path + d.line} def={d} onSymbol={onSymbol} />
|
||||||
|
))}
|
||||||
|
{data.refs.length > 0 && <div className="sym-sec">Used in</div>}
|
||||||
|
{data.refs.map((g) => (
|
||||||
|
<Fragment key={g.path}>
|
||||||
|
<div className="sr-file" onClick={() => { onOpenAt(g.path, g.hits[0].no); onClose() }}>
|
||||||
|
<FileIcon path={g.path} />
|
||||||
|
<span className="srf-name">{g.path}</span>
|
||||||
|
<span className="cnt">{g.hits.length}</span>
|
||||||
|
</div>
|
||||||
|
{g.hits.map((h) => {
|
||||||
|
ix++
|
||||||
|
const me = ix
|
||||||
|
return (
|
||||||
|
<div key={h.no} className={'sym-row sr-line' + (me === hi ? ' hi sel' : '')}
|
||||||
|
onMouseEnter={() => setHi(me)}
|
||||||
|
onClick={() => { onOpenAt(g.path, h.no); onClose() }}>
|
||||||
|
<span className="no">{h.no}</span>
|
||||||
|
<span className="tx">{h.ln.slice(0, 200)}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* What a declaration extends and implements, read off its own line. A parent
|
||||||
|
* this project declares is a chip you can follow; a vendor or built-in parent
|
||||||
|
* stays plain text, for the same reason a token only underlines when we can
|
||||||
|
* actually open it. */
|
||||||
|
function SymbolRelations({ def, onSymbol }: { def: SymbolDef; onSymbol: (name: string) => void }): React.ReactElement | null {
|
||||||
|
// Read defensively: this object crossed IPC, and a main process from before
|
||||||
|
// these fields existed (a dev session that did not restart, a cached index)
|
||||||
|
// must degrade to "no relations", never take the window down.
|
||||||
|
const parents = def.parents ?? []
|
||||||
|
const interfaces = def.interfaces ?? []
|
||||||
|
const traits = def.traits ?? []
|
||||||
|
if (!parents.length && !interfaces.length && !traits.length) return null
|
||||||
|
const chip = (n: string): React.ReactElement => (
|
||||||
|
isKnownSymbol(n)
|
||||||
|
? <button key={n} className="sym-chip known" onClick={() => onSymbol(n)}>{n}</button>
|
||||||
|
: <span key={n} className="sym-chip">{n}</span>
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
<div className="sym-rel">
|
||||||
|
{parents.length > 0 && (
|
||||||
|
<div className="sym-rel-row"><span className="sym-rel-key">extends</span>{parents.map(chip)}</div>
|
||||||
|
)}
|
||||||
|
{interfaces.length > 0 && (
|
||||||
|
<div className="sym-rel-row"><span className="sym-rel-key">implements</span>{interfaces.map(chip)}</div>
|
||||||
|
)}
|
||||||
|
{traits.length > 0 && (
|
||||||
|
<div className="sym-rel-row"><span className="sym-rel-key">uses</span>{traits.map(chip)}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function Toasts({ toasts }: { toasts: Toast[] }): React.ReactElement {
|
export function Toasts({ toasts }: { toasts: Toast[] }): React.ReactElement {
|
||||||
return (
|
return (
|
||||||
<div className="toast-wrap">
|
<div className="toast-wrap">
|
||||||
|
|||||||
@@ -195,16 +195,35 @@ kbd { flex:0 0 auto; font-family:var(--mono); font-size:11.5px; color:var(--fg-3
|
|||||||
.git-row {
|
.git-row {
|
||||||
display:flex; align-items:center; gap:8px; padding:3px 12px 3px 14px; cursor:pointer; position:relative;
|
display:flex; align-items:center; gap:8px; padding:3px 12px 3px 14px; cursor:pointer; position:relative;
|
||||||
}
|
}
|
||||||
/* Row states, one language for the Git list and the Explorer.
|
/* ===== Row states — one language for every list that offers a row to click ====
|
||||||
open — the file the viewer shows: lighter tint, 3px accent bar.
|
hover / focus — "a click or ↵ acts here": --row-soft, 3px --accent-dim bar.
|
||||||
focus — "↵ acts here": the same pair at 30%.
|
open — the file the viewer shows: --sel, 3px solid --accent bar.
|
||||||
hover — the same as focus, because the pointer moves the cursor onto the row.
|
Hover and focus look alike on purpose: the pointer moves the row cursor onto
|
||||||
Order matters: open is listed last, so it wins on a row that is also focused. */
|
whatever it touches, so only one row ever carries the pair. Open wins over
|
||||||
.git-row:hover, .git-row.ctx, .git-row.kbd { background:var(--row-soft); }
|
both, which is why its rules come last.
|
||||||
.git-row:hover::before, .git-row.ctx::before, .git-row.kbd::before { content:""; position:absolute; left:0; top:0; bottom:0; width:3px; background:var(--accent-dim); }
|
Lists covered: Git, Explorer, Recent files, the search overlay (file names,
|
||||||
|
file headers, content lines), the project palette, the ⌘-click popup.
|
||||||
|
A context menu is deliberately left out — it is a menu, not a list of state. */
|
||||||
|
.git-row, .tree-row, .hist-row, .fres, .sr-file, .sr-line, .pres, .sym-row { position:relative; }
|
||||||
|
.git-row:hover, .git-row.ctx, .git-row.kbd,
|
||||||
|
.tree-row:hover, .tree-row.ctx, .tree-row.kbd,
|
||||||
|
.hist-row:hover, .hist-row.sel,
|
||||||
|
.fres:hover, .fres.sel,
|
||||||
|
.sr-file:hover, .sr-line:hover, .sr-line.sel,
|
||||||
|
.pres:hover, .pres.sel,
|
||||||
|
.sym-row:hover, .sym-row.hi { background:var(--row-soft); }
|
||||||
|
.git-row:hover::before, .git-row.ctx::before, .git-row.kbd::before,
|
||||||
|
.tree-row:hover::before, .tree-row.ctx::before, .tree-row.kbd::before,
|
||||||
|
.hist-row:hover::before, .hist-row.sel::before,
|
||||||
|
.fres:hover::before, .fres.sel::before,
|
||||||
|
.sr-file:hover::before, .sr-line:hover::before, .sr-line.sel::before,
|
||||||
|
.pres:hover::before, .pres.sel::before,
|
||||||
|
.sym-row:hover::before, .sym-row.hi::before {
|
||||||
|
content:""; position:absolute; left:0; top:0; bottom:0; width:3px; background:var(--accent-dim);
|
||||||
|
}
|
||||||
|
.git-row.active, .tree-row.active { background:var(--sel); }
|
||||||
|
.git-row.active::before, .tree-row.active::before { content:""; position:absolute; left:0; top:0; bottom:0; width:3px; background:var(--accent); }
|
||||||
.git-row.ctx .git-act, .git-row.kbd .git-act { visibility:visible; }
|
.git-row.ctx .git-act, .git-row.kbd .git-act { visibility:visible; }
|
||||||
.git-row.active { background:var(--sel); }
|
|
||||||
.git-row.active::before { content:""; position:absolute; left:0; top:0; bottom:0; width:3px; background:var(--accent); }
|
|
||||||
.git-stat { width:13px; text-align:center; font-family:var(--mono); font-size:11px; font-weight:600; flex:0 0 13px; }
|
.git-stat { width:13px; text-align:center; font-family:var(--mono); font-size:11px; font-weight:600; flex:0 0 13px; }
|
||||||
.git-stat.M{color:var(--mod);} .git-stat.A{color:var(--add);} .git-stat.D{color:var(--del);} .git-stat.R{color:var(--ren);} .git-stat.U{color:var(--add);}
|
.git-stat.M{color:var(--mod);} .git-stat.A{color:var(--add);} .git-stat.D{color:var(--del);} .git-stat.R{color:var(--ren);} .git-stat.U{color:var(--add);}
|
||||||
.git-name { font-size:12.5px; color:var(--fg-1); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
.git-name { font-size:12.5px; color:var(--fg-1); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||||
@@ -224,12 +243,8 @@ kbd { flex:0 0 auto; font-family:var(--mono); font-size:11.5px; color:var(--fg-3
|
|||||||
/* ============ file tree ============ */
|
/* ============ file tree ============ */
|
||||||
.tree-body { overflow:auto; flex:1; padding:4px 0 14px; }
|
.tree-body { overflow:auto; flex:1; padding:4px 0 14px; }
|
||||||
.tree-row { display:flex; align-items:center; gap:6px; padding:2px 10px 2px 0; cursor:pointer; white-space:nowrap; position:relative; height:23px; }
|
.tree-row { display:flex; align-items:center; gap:6px; padding:2px 10px 2px 0; cursor:pointer; white-space:nowrap; position:relative; height:23px; }
|
||||||
/* Same three states as the git list. A folder never reaches `active` — it opens
|
/* A folder never reaches `active` — it opens in the tree, not in the viewer —
|
||||||
in the tree, not in the viewer — but it takes focus and hover like a file. */
|
but it takes focus and hover like a file. See the row-state block above. */
|
||||||
.tree-row:hover, .tree-row.ctx, .tree-row.kbd { background:var(--row-soft); }
|
|
||||||
.tree-row:hover::before, .tree-row.ctx::before, .tree-row.kbd::before { content:""; position:absolute; left:0; top:0; bottom:0; width:3px; background:var(--accent-dim); }
|
|
||||||
.tree-row.active { background:var(--sel); }
|
|
||||||
.tree-row.active::before { content:""; position:absolute; left:0; top:0; bottom:0; width:3px; background:var(--accent); }
|
|
||||||
.tw { display:inline-flex; align-items:center; justify-content:center; width:14px; flex:0 0 14px; color:var(--fg-3); font-size:10px; }
|
.tw { display:inline-flex; align-items:center; justify-content:center; width:14px; flex:0 0 14px; color:var(--fg-3); font-size:10px; }
|
||||||
.tree-label { font-size:12.5px; color:var(--fg-1); overflow:hidden; text-overflow:ellipsis; }
|
.tree-label { font-size:12.5px; color:var(--fg-1); overflow:hidden; text-overflow:ellipsis; }
|
||||||
.tree-row.active .tree-label { color:var(--fg-0); }
|
.tree-row.active .tree-label { color:var(--fg-0); }
|
||||||
@@ -424,8 +439,7 @@ kbd { flex:0 0 auto; font-family:var(--mono); font-size:11.5px; color:var(--fg-3
|
|||||||
.search-modal .ext-chip:hover { opacity:1; }
|
.search-modal .ext-chip:hover { opacity:1; }
|
||||||
.search-modal .ext-chip.on { opacity:1; color:var(--accent); border-color:var(--accent-line); background:var(--accent-soft); }
|
.search-modal .ext-chip.on { opacity:1; color:var(--accent); border-color:var(--accent-line); background:var(--accent-soft); }
|
||||||
.palette .results { max-height:380px; overflow:auto; padding:6px; }
|
.palette .results { max-height:380px; overflow:auto; padding:6px; }
|
||||||
.pres { display:flex; align-items:center; gap:10px; padding:7px 10px; border-radius:7px; cursor:pointer; }
|
.pres { display:flex; align-items:center; gap:10px; padding:7px 10px; cursor:pointer; }
|
||||||
.pres.sel { background:var(--accent-soft); }
|
|
||||||
.pres .pn { font-size:13px; color:var(--fg-0); }
|
.pres .pn { font-size:13px; color:var(--fg-0); }
|
||||||
.pres .pn b { color:var(--accent); font-weight:700; }
|
.pres .pn b { color:var(--accent); font-weight:700; }
|
||||||
.pres .pp { font-size:11px; color:var(--fg-3); margin-left:auto; font-family:var(--mono); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:55%; direction:rtl; }
|
.pres .pp { font-size:11px; color:var(--fg-3); margin-left:auto; font-family:var(--mono); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:55%; direction:rtl; }
|
||||||
@@ -450,7 +464,6 @@ kbd { flex:0 0 auto; font-family:var(--mono); font-size:11.5px; color:var(--fg-3
|
|||||||
.srf-name { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
.srf-name { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||||
.pempty.sm { padding:18px 14px; text-align:left; font-size:11.5px; }
|
.pempty.sm { padding:18px 14px; text-align:left; font-size:11.5px; }
|
||||||
.fres { display:flex; align-items:center; gap:9px; padding:6px 12px; cursor:pointer; }
|
.fres { display:flex; align-items:center; gap:9px; padding:6px 12px; cursor:pointer; }
|
||||||
.fres:hover { background:var(--hover); }
|
|
||||||
.fres-txt { min-width:0; display:flex; flex-direction:column; line-height:1.25; }
|
.fres-txt { min-width:0; display:flex; flex-direction:column; line-height:1.25; }
|
||||||
.fres-txt .fn { font-size:12.5px; color:var(--fg-0); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
.fres-txt .fn { font-size:12.5px; color:var(--fg-0); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||||
.fres-txt .fn b { color:var(--accent); font-weight:700; }
|
.fres-txt .fn b { color:var(--accent); font-weight:700; }
|
||||||
@@ -462,11 +475,16 @@ kbd { flex:0 0 auto; font-family:var(--mono); font-size:11.5px; color:var(--fg-3
|
|||||||
.history-modal .pi svg { flex:0 0 auto; }
|
.history-modal .pi svg { flex:0 0 auto; }
|
||||||
.history-modal .hist-title { flex:1; min-width:0; color:var(--fg-1); font-size:14px; }
|
.history-modal .hist-title { flex:1; min-width:0; color:var(--fg-1); font-size:14px; }
|
||||||
.history-modal .mode-chip { flex:0 0 auto; white-space:nowrap; font-size:10px; color:var(--fg-3); border:1px solid var(--border-2); border-radius:5px; padding:2px 7px; font-family:var(--mono); display:flex; align-items:center; gap:4px; }
|
.history-modal .mode-chip { flex:0 0 auto; white-space:nowrap; font-size:10px; color:var(--fg-3); border:1px solid var(--border-2); border-radius:5px; padding:2px 7px; font-family:var(--mono); display:flex; align-items:center; gap:4px; }
|
||||||
.hist-list { max-height:460px; overflow:auto; padding:5px 0; }
|
/* stable gutter: without it the scrollbar eats into the rows, so the change dot
|
||||||
.hist-row { display:flex; align-items:center; gap:9px; padding:6px 13px; cursor:pointer; }
|
sits at a different distance from the edge than the header above it. */
|
||||||
.hist-row.sel { background:var(--accent-dim, rgba(241,159,63,0.14)); box-shadow:inset 2px 0 0 var(--accent); }
|
.hist-list { max-height:460px; overflow-y:auto; scrollbar-gutter:stable; padding:5px 0; }
|
||||||
.hist-row:hover { background:var(--hover); }
|
.hist-row { display:flex; align-items:center; gap:9px; padding:6px 13px; cursor:pointer; position:relative; }
|
||||||
.hist-row.sel:hover { background:rgba(241,159,63,0.20); }
|
/* ⌘↑/⌘↓ moves the selection while the pointer stays put, so hover has to give
|
||||||
|
way until the mouse moves again — otherwise two rows read as focused. */
|
||||||
|
.hist-list.kbd-nav .hist-row:hover:not(.sel) { background:transparent; }
|
||||||
|
.hist-list.kbd-nav .hist-row:hover:not(.sel)::before { content:none; }
|
||||||
|
.search-cols.kbd-nav .sr-line:hover:not(.sel), .search-cols.kbd-nav .fres:hover:not(.sel) { background:transparent; }
|
||||||
|
.search-cols.kbd-nav .sr-line:hover:not(.sel)::before, .search-cols.kbd-nav .fres:hover:not(.sel)::before { content:none; }
|
||||||
.hist-txt { min-width:0; display:flex; flex-direction:column; line-height:1.25; }
|
.hist-txt { min-width:0; display:flex; flex-direction:column; line-height:1.25; }
|
||||||
.hist-txt .fn { font-size:12.5px; color:var(--fg-0); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
.hist-txt .fn { font-size:12.5px; color:var(--fg-0); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||||
.hist-txt .fd { font-size:10.5px; color:var(--fg-3); font-family:var(--mono); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
.hist-txt .fd { font-size:10.5px; color:var(--fg-3); font-family:var(--mono); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||||
@@ -477,8 +495,6 @@ kbd { flex:0 0 auto; font-family:var(--mono); font-size:11.5px; color:var(--fg-3
|
|||||||
.sr-file:hover { color:var(--fg-0); }
|
.sr-file:hover { color:var(--fg-0); }
|
||||||
.sr-file .cnt { margin-left:auto; color:var(--fg-3); font-family:var(--mono); font-size:10.5px; }
|
.sr-file .cnt { margin-left:auto; color:var(--fg-3); font-family:var(--mono); font-size:10.5px; }
|
||||||
.sr-line { display:flex; gap:12px; padding:2px 14px 2px 38px; font-family:var(--mono); font-size:12px; cursor:pointer; color:var(--fg-1); }
|
.sr-line { display:flex; gap:12px; padding:2px 14px 2px 38px; font-family:var(--mono); font-size:12px; cursor:pointer; color:var(--fg-1); }
|
||||||
.sr-line:hover { background:var(--hover); }
|
|
||||||
.sr-line.sel { background:var(--accent-soft); }
|
|
||||||
.sr-line .no { color:var(--fg-3); min-width:34px; text-align:right; }
|
.sr-line .no { color:var(--fg-3); min-width:34px; text-align:right; }
|
||||||
.sr-line .tx { white-space:pre; overflow:hidden; text-overflow:ellipsis; }
|
.sr-line .tx { white-space:pre; overflow:hidden; text-overflow:ellipsis; }
|
||||||
.sr-line mark { background:rgba(216,168,92,.28); color:var(--fg-0); border-radius:2px; }
|
.sr-line mark { background:rgba(216,168,92,.28); color:var(--fg-0); border-radius:2px; }
|
||||||
@@ -508,6 +524,42 @@ kbd { flex:0 0 auto; font-family:var(--mono); font-size:11.5px; color:var(--fg-3
|
|||||||
position:fixed rule can never collide with the `.tree-row.ctx` / `.git-row.ctx`
|
position:fixed rule can never collide with the `.tree-row.ctx` / `.git-row.ctx`
|
||||||
highlight class — that collision pulled the highlighted row out of flow and
|
highlight class — that collision pulled the highlighted row out of flow and
|
||||||
made the row beneath it appear to vanish while the menu was open. */
|
made the row beneath it appear to vanish while the menu was open. */
|
||||||
|
/* ⌘-click on a PHP class: declarations on top, references under them. Sized to
|
||||||
|
read a line of code, capped so it never covers the whole viewer. */
|
||||||
|
.sym-popup { width:min(700px, 82vw); max-height:min(72vh, 640px); display:flex; flex-direction:column;
|
||||||
|
background:#23272d; border:1px solid var(--border-2); border-left:3px solid var(--accent); border-radius:4px; box-shadow:0 16px 44px rgba(0,0,0,.5); overflow:hidden; }
|
||||||
|
.sym-head { display:flex; align-items:center; gap:9px; padding:9px 13px; border-bottom:1px solid var(--border); background:var(--bg-3); }
|
||||||
|
.sym-name { font-family:var(--mono); font-size:13px; color:var(--fg-0); font-weight:600; }
|
||||||
|
.sym-count { margin-left:auto; font-family:var(--mono); font-size:10.5px; color:var(--fg-3); }
|
||||||
|
.sym-body { overflow:auto; padding-bottom:6px; }
|
||||||
|
.sym-sec { padding:8px 13px 4px; font-size:10px; letter-spacing:.07em; text-transform:uppercase; color:var(--fg-3); }
|
||||||
|
.sym-row { cursor:pointer; }
|
||||||
|
.sym-row.def { display:flex; align-items:center; gap:8px; padding:4px 13px; font-size:12px; color:var(--fg-1); }
|
||||||
|
.sym-row.def .sym-path { font-family:var(--mono); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||||
|
.sym-row.def .sym-kind { font-size:10px; color:var(--fg-3); border:1px solid var(--border-2); border-radius:3px; padding:0 5px; }
|
||||||
|
.sym-row.def .no { margin-left:auto; font-family:var(--mono); font-size:11px; color:var(--fg-3); }
|
||||||
|
.sym-popup .sr-line { padding-left:26px; }
|
||||||
|
/* What the declaration is built from: extends / implements / uses. A name this
|
||||||
|
project declares is a button that reloads the popup on it. */
|
||||||
|
.sym-rel { padding:2px 13px 6px 34px; display:flex; flex-direction:column; gap:3px; }
|
||||||
|
.sym-rel-row { display:flex; align-items:center; gap:6px; flex-wrap:wrap; }
|
||||||
|
.sym-rel-key { font-size:10px; letter-spacing:.06em; text-transform:uppercase; color:var(--fg-3); min-width:66px; }
|
||||||
|
.sym-chip { font-family:var(--mono); font-size:11px; line-height:17px; padding:0 7px; border:1px solid var(--border); border-radius:3px; background:transparent; color:var(--fg-3); }
|
||||||
|
.sym-chip.known { color:var(--fg-1); border-color:var(--border-2); cursor:pointer; }
|
||||||
|
.sym-chip.known:hover { color:var(--accent); border-color:var(--accent-line); background:var(--accent-soft); }
|
||||||
|
|
||||||
|
/* A class name this project declares, underlined only while ⌘ is held — the
|
||||||
|
affordance shows exactly when the click would do something.
|
||||||
|
The highlight layer is pointer-events:none so the textarea below it keeps the
|
||||||
|
caret; while ⌘ is held these tokens alone take the mouse back, which is what
|
||||||
|
gives them a real :hover, a pointer cursor and a click target carrying the
|
||||||
|
name. Everything else on the line still falls through to the textarea. */
|
||||||
|
.code-edit.sym-live .token.sym, .editor.sym-live .token.sym {
|
||||||
|
pointer-events:auto; cursor:pointer;
|
||||||
|
text-decoration:underline; text-decoration-color:var(--accent); text-underline-offset:3px;
|
||||||
|
}
|
||||||
|
.code-edit.sym-live .token.sym:hover, .editor.sym-live .token.sym:hover { filter:brightness(1.25); }
|
||||||
|
|
||||||
.ctx-menu { position:fixed; z-index:80; background:#23272d; border:1px solid var(--border-2); border-radius:9px; padding:5px; min-width:248px; box-shadow:0 16px 44px rgba(0,0,0,.5); }
|
.ctx-menu { position:fixed; z-index:80; background:#23272d; border:1px solid var(--border-2); border-radius:9px; padding:5px; min-width:248px; box-shadow:0 16px 44px rgba(0,0,0,.5); }
|
||||||
.ctx-item { display:flex; align-items:center; gap:10px; padding:7px 10px; border-radius:6px; cursor:pointer; font-size:12.5px; color:var(--fg-1); }
|
.ctx-item { display:flex; align-items:center; gap:10px; padding:7px 10px; border-radius:6px; cursor:pointer; font-size:12.5px; color:var(--fg-1); }
|
||||||
.ctx-item:hover, .ctx-item.hi { background:var(--accent-soft); color:var(--fg-0); }
|
.ctx-item:hover, .ctx-item.hi { background:var(--accent-soft); color:var(--fg-0); }
|
||||||
@@ -569,9 +621,14 @@ kbd { flex:0 0 auto; font-family:var(--mono); font-size:11.5px; color:var(--fg-3
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* xterm.js host (real terminals) */
|
/* xterm.js host (real terminals) */
|
||||||
.term-xterm { flex:1; min-height:0; overflow:hidden; padding:6px 4px 6px 8px; background:var(--bg-1); }
|
/* The pane carries the colour, taken from the terminal palette in config.json,
|
||||||
|
so the padding around the canvas can never sit on a different ground. */
|
||||||
|
.term-xterm { flex:1; min-height:0; overflow:hidden; padding:6px 4px 6px 8px; background:transparent; }
|
||||||
.term-xterm .xterm { height:100%; }
|
.term-xterm .xterm { height:100%; }
|
||||||
.term-xterm .xterm-viewport { background:transparent !important; }
|
.term-xterm .xterm-viewport { background:transparent !important; }
|
||||||
|
.term-xterm .xterm-viewport::-webkit-scrollbar { width:9px; }
|
||||||
|
.term-xterm .xterm-viewport::-webkit-scrollbar-thumb { background:rgba(255,255,255,0.13); border-radius:5px; border:2px solid transparent; background-clip:content-box; }
|
||||||
|
.term-xterm .xterm-viewport::-webkit-scrollbar-thumb:hover { background:rgba(255,255,255,0.22); background-clip:content-box; }
|
||||||
|
|
||||||
/* ============ Electron chrome integration ============ */
|
/* ============ Electron chrome integration ============ */
|
||||||
/* macOS shows native traffic lights (titleBarStyle: hiddenInset); the prototype's
|
/* macOS shows native traffic lights (titleBarStyle: hiddenInset); the prototype's
|
||||||
@@ -659,4 +716,3 @@ kbd { flex:0 0 auto; font-family:var(--mono); font-size:11.5px; color:var(--fg-3
|
|||||||
.sc-left.active .sc-head, .sc-right.active .sc-head, .sc-infile.active .sc-head { color:var(--accent); }
|
.sc-left.active .sc-head, .sc-right.active .sc-head, .sc-infile.active .sc-head { color:var(--accent); }
|
||||||
.sc-left.active .sc-head .col-kbd, .sc-right.active .sc-head .col-kbd, .sc-infile.active .sc-head .col-kbd { color:var(--accent); border-color:var(--accent-line); opacity:1; }
|
.sc-left.active .sc-head .col-kbd, .sc-right.active .sc-head .col-kbd, .sc-infile.active .sc-head .col-kbd { color:var(--accent); border-color:var(--accent-line); opacity:1; }
|
||||||
.sc-infile.active .sc-head .scf-name { color:var(--accent); }
|
.sc-infile.active .sc-head .scf-name { color:var(--accent); }
|
||||||
.fres.sel { background:var(--accent-soft); box-shadow:inset 2px 0 0 var(--accent); }
|
|
||||||
|
|||||||
61
src/renderer/src/symbols.ts
Normal file
61
src/renderer/src/symbols.ts
Normal 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)
|
||||||
|
}
|
||||||
@@ -11,29 +11,49 @@ import '@xterm/xterm/css/xterm.css'
|
|||||||
import { ContextMenu } from './overlays'
|
import { ContextMenu } from './overlays'
|
||||||
import type { Menu } from './overlays'
|
import type { Menu } from './overlays'
|
||||||
import { Icon } from './components'
|
import { Icon } from './components'
|
||||||
|
import { useProject } from './project'
|
||||||
|
import type { HelderConfig } from './types'
|
||||||
|
|
||||||
let _lid = 0
|
let _lid = 0
|
||||||
export const lid = (): number => ++_lid
|
export const lid = (): number => ++_lid
|
||||||
|
|
||||||
const MONO = '"JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace'
|
const MONO = '"JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace'
|
||||||
|
|
||||||
// ANSI palette mapped onto Helder's charcoal tokens.
|
/* Everything visual comes from `terminal` in config.json — palette, font,
|
||||||
const THEME = {
|
* cursor, scrollback — so the console is themed from the same file as the rest
|
||||||
background: '#1a1c1f',
|
* of the project. Font family and size fall back to the CSS vars (--code-font /
|
||||||
foreground: '#e6e8ea',
|
* --term-size in theme.css) when the config leaves them null, which keeps the
|
||||||
cursor: '#4d8dff',
|
* old "one place for the code font" rule intact. */
|
||||||
cursorAccent: '#1a1c1f',
|
function xtermOptions(cfg: HelderConfig['terminal']): {
|
||||||
selectionBackground: 'rgba(77,141,255,0.55)',
|
fontFamily: string; fontSize: number; lineHeight: number; letterSpacing: number
|
||||||
selectionInactiveBackground: 'rgba(77,141,255,0.40)',
|
cursorStyle: 'bar' | 'block' | 'underline'; cursorBlink: boolean
|
||||||
black: '#16171a', red: '#e0696a', green: '#5cbd6b', yellow: '#d8a85c',
|
drawBoldTextInBrightColors: boolean; scrollback: number; macOptionIsMeta: boolean
|
||||||
blue: '#4d8dff', magenta: '#c98bdb', cyan: '#6ec0c0', white: '#b4bac2',
|
theme: HelderConfig['terminal']['theme']
|
||||||
brightBlack: '#5d636c', brightRed: '#e0696a', brightGreen: '#5cbd6b', brightYellow: '#d8a85c',
|
} {
|
||||||
brightBlue: '#6aa6f0', brightMagenta: '#c98bdb', brightCyan: '#6ec0c0', brightWhite: '#e6e8ea',
|
const css = getComputedStyle(document.documentElement)
|
||||||
|
return {
|
||||||
|
fontFamily: cfg.fontFamily || css.getPropertyValue('--code-font').trim() || MONO,
|
||||||
|
fontSize: cfg.fontSize || parseFloat(css.getPropertyValue('--term-size')) || 12.5,
|
||||||
|
lineHeight: cfg.lineHeight,
|
||||||
|
letterSpacing: cfg.letterSpacing,
|
||||||
|
cursorStyle: cfg.cursorStyle,
|
||||||
|
cursorBlink: cfg.cursorBlink,
|
||||||
|
drawBoldTextInBrightColors: cfg.boldIsBright,
|
||||||
|
scrollback: cfg.scrollback,
|
||||||
|
macOptionIsMeta: cfg.optionIsMeta,
|
||||||
|
theme: cfg.theme,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Terminal({ kind }: { kind: 'agent' | 'shell' }): React.ReactElement {
|
export function Terminal({ kind }: { kind: 'agent' | 'shell' }): React.ReactElement {
|
||||||
const hostRef = useRef<HTMLDivElement>(null)
|
const hostRef = useRef<HTMLDivElement>(null)
|
||||||
const termRef = useRef<XTerm | null>(null)
|
const termRef = useRef<XTerm | null>(null)
|
||||||
|
const fitRef = useRef<FitAddon | null>(null)
|
||||||
|
// The terminal is built once and lives as long as its PTY, so the config is
|
||||||
|
// read through a ref on creation and re-applied by the effect below — editing
|
||||||
|
// config.json restyles the running session instead of restarting it.
|
||||||
|
const cfg = useProject().config.terminal
|
||||||
|
const cfgRef = useRef(cfg); cfgRef.current = cfg
|
||||||
const [, setLive] = useState(kind === 'agent')
|
const [, setLive] = useState(kind === 'agent')
|
||||||
const [menu, setMenu] = useState<Menu | null>(null)
|
const [menu, setMenu] = useState<Menu | null>(null)
|
||||||
// Best-effort "is the agent composer non-empty?" flag. A passed reference must
|
// Best-effort "is the agent composer non-empty?" flag. A passed reference must
|
||||||
@@ -47,20 +67,10 @@ export function Terminal({ kind }: { kind: 'agent' | 'shell' }): React.ReactElem
|
|||||||
const host = hostRef.current
|
const host = hostRef.current
|
||||||
if (!host) return
|
if (!host) return
|
||||||
|
|
||||||
const css = getComputedStyle(document.documentElement)
|
const term = new XTerm({ ...xtermOptions(cfgRef.current), allowProposedApi: true })
|
||||||
const fontFamily = css.getPropertyValue('--code-font').trim() || MONO
|
|
||||||
const fontSize = parseFloat(css.getPropertyValue('--term-size')) || 12.5
|
|
||||||
const term = new XTerm({
|
|
||||||
fontFamily,
|
|
||||||
fontSize,
|
|
||||||
lineHeight: 1.4,
|
|
||||||
cursorBlink: true,
|
|
||||||
theme: THEME,
|
|
||||||
scrollback: 5000,
|
|
||||||
allowProposedApi: true,
|
|
||||||
})
|
|
||||||
termRef.current = term
|
termRef.current = term
|
||||||
const fit = new FitAddon()
|
const fit = new FitAddon()
|
||||||
|
fitRef.current = fit
|
||||||
term.loadAddon(fit)
|
term.loadAddon(fit)
|
||||||
term.open(host)
|
term.open(host)
|
||||||
|
|
||||||
@@ -140,11 +150,21 @@ export function Terminal({ kind }: { kind: 'agent' | 'shell' }): React.ReactElem
|
|||||||
if (kind === 'agent') window.removeEventListener('agentPaste', onPaste)
|
if (kind === 'agent') window.removeEventListener('agentPaste', onPaste)
|
||||||
if (bridge && id >= 0) bridge.pty.kill(id)
|
if (bridge && id >= 0) bridge.pty.kill(id)
|
||||||
termRef.current = null
|
termRef.current = null
|
||||||
|
fitRef.current = null
|
||||||
term.dispose()
|
term.dispose()
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Live restyle: config.json (or theme.css) changed, so push the new options
|
||||||
|
// into the running terminal and refit — the buffer and the PTY are untouched.
|
||||||
|
useEffect(() => {
|
||||||
|
const term = termRef.current
|
||||||
|
if (!term) return
|
||||||
|
Object.assign(term.options, xtermOptions(cfg))
|
||||||
|
fitRef.current?.fit()
|
||||||
|
}, [cfg])
|
||||||
|
|
||||||
// Both panes (D1 agent + D2 shell): right-click → copy selection / paste from clipboard.
|
// Both panes (D1 agent + D2 shell): right-click → copy selection / paste from clipboard.
|
||||||
function onContextMenu(e: React.MouseEvent): void {
|
function onContextMenu(e: React.MouseEvent): void {
|
||||||
const bridge = window.helder
|
const bridge = window.helder
|
||||||
@@ -166,7 +186,7 @@ export function Terminal({ kind }: { kind: 'agent' | 'shell' }): React.ReactElem
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="term-pane" style={{ flex: 1, minHeight: 0 }}
|
<div className="term-pane" style={{ flex: 1, minHeight: 0, background: cfg.theme.background }}
|
||||||
onMouseDown={() => hostRef.current?.querySelector('textarea')?.focus()}
|
onMouseDown={() => hostRef.current?.querySelector('textarea')?.focus()}
|
||||||
onContextMenu={onContextMenu}>
|
onContextMenu={onContextMenu}>
|
||||||
<div className="term-xterm" ref={hostRef} />
|
<div className="term-xterm" ref={hostRef} />
|
||||||
|
|||||||
@@ -84,13 +84,42 @@ export type DiffMode = 'original' | 'updated' | 'diff'
|
|||||||
/** Soft wrap of long lines: never, always, or only in Markdown files. */
|
/** Soft wrap of long lines: never, always, or only in Markdown files. */
|
||||||
export type WordWrap = 'off' | 'on' | 'markdown'
|
export type WordWrap = 'off' | 'on' | 'markdown'
|
||||||
|
|
||||||
|
/** xterm's colour table. Every value is a CSS colour; the selection entries
|
||||||
|
* may carry alpha, the rest may not. */
|
||||||
|
export interface TerminalTheme {
|
||||||
|
background: string; foreground: string; cursor: string; cursorAccent: string
|
||||||
|
selectionBackground: string; selectionInactiveBackground: string
|
||||||
|
black: string; red: string; green: string; yellow: string
|
||||||
|
blue: string; magenta: string; cyan: string; white: string
|
||||||
|
brightBlack: string; brightRed: string; brightGreen: string; brightYellow: string
|
||||||
|
brightBlue: string; brightMagenta: string; brightCyan: string; brightWhite: string
|
||||||
|
}
|
||||||
|
|
||||||
/** Effective project settings (mirrors src/main/config.ts). */
|
/** Effective project settings (mirrors src/main/config.ts). */
|
||||||
export interface HelderConfig {
|
export interface HelderConfig {
|
||||||
ai: { command: string; autoLaunch: boolean }
|
ai: { command: string; autoLaunch: boolean }
|
||||||
editor: { autoSave: boolean; tabSize: number; wordWrap: WordWrap }
|
editor: { autoSave: boolean; tabSize: number; wordWrap: WordWrap }
|
||||||
git: { confirmDiscard: boolean; confirmStage: boolean; confirmUnstage: boolean; defaultDiffMode: DiffMode; refreshInterval: number }
|
git: { confirmDiscard: boolean; confirmStage: boolean; confirmUnstage: boolean; defaultDiffMode: DiffMode; refreshInterval: number }
|
||||||
files: { exclude: string[]; followGitignore: boolean }
|
files: { exclude: string[]; followGitignore: boolean }
|
||||||
terminal: { shell: string | null }
|
terminal: {
|
||||||
|
/** Login shell for both panes. null = $SHELL. */
|
||||||
|
shell: string | null
|
||||||
|
/** null = follow the CSS vars (--code-font / --term-size in theme.css). */
|
||||||
|
fontFamily: string | null
|
||||||
|
fontSize: number | null
|
||||||
|
lineHeight: number
|
||||||
|
letterSpacing: number
|
||||||
|
cursorStyle: 'bar' | 'block' | 'underline'
|
||||||
|
cursorBlink: boolean
|
||||||
|
/** Paint bold text in the bright colour. Off keeps bold in its own hue,
|
||||||
|
* which stops a CLI's bold labels from washing out. */
|
||||||
|
boldIsBright: boolean
|
||||||
|
scrollback: number
|
||||||
|
/** macOS: send Option as Meta. Needed for a CLI's ⌥↵ binding; it also stops
|
||||||
|
* Option from typing accented characters, so it is off by default. */
|
||||||
|
optionIsMeta: boolean
|
||||||
|
theme: TerminalTheme
|
||||||
|
}
|
||||||
session: { restoreOnLaunch: boolean }
|
session: { restoreOnLaunch: boolean }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,6 +128,49 @@ export const DEFAULT_CONFIG: HelderConfig = {
|
|||||||
editor: { autoSave: false, tabSize: 4, wordWrap: 'markdown' },
|
editor: { autoSave: false, tabSize: 4, wordWrap: 'markdown' },
|
||||||
git: { confirmDiscard: true, confirmStage: false, confirmUnstage: false, defaultDiffMode: 'diff', refreshInterval: 10000 },
|
git: { confirmDiscard: true, confirmStage: false, confirmUnstage: false, defaultDiffMode: 'diff', refreshInterval: 10000 },
|
||||||
files: { exclude: [], followGitignore: true },
|
files: { exclude: [], followGitignore: true },
|
||||||
terminal: { shell: null },
|
terminal: {
|
||||||
|
shell: null,
|
||||||
|
fontFamily: null,
|
||||||
|
fontSize: null,
|
||||||
|
lineHeight: 1.35,
|
||||||
|
letterSpacing: 0,
|
||||||
|
cursorStyle: 'bar',
|
||||||
|
cursorBlink: true,
|
||||||
|
boldIsBright: false,
|
||||||
|
scrollback: 8000,
|
||||||
|
optionIsMeta: false,
|
||||||
|
// The app's own palette: charcoal ground, the accent on the caret and the
|
||||||
|
// selection, and the syntax colours reused for the ANSI table so a diff in
|
||||||
|
// the terminal reads like a diff in the editor.
|
||||||
|
theme: {
|
||||||
|
background: '#24272c',
|
||||||
|
foreground: '#dde1e7',
|
||||||
|
cursor: '#f19f3f',
|
||||||
|
cursorAccent: '#24272c',
|
||||||
|
selectionBackground: 'rgba(241,159,63,0.32)',
|
||||||
|
selectionInactiveBackground: 'rgba(241,159,63,0.18)',
|
||||||
|
black: '#2b2e34', red: '#e0696a', green: '#5cbd6b', yellow: '#d8a85c',
|
||||||
|
blue: '#6aa6f0', magenta: '#c98bdb', cyan: '#6ec0c0', white: '#b0b6bf',
|
||||||
|
brightBlack: '#7a828d', brightRed: '#ef8385', brightGreen: '#77d186', brightYellow: '#edc077',
|
||||||
|
brightBlue: '#86bbf5', brightMagenta: '#dba6e9', brightCyan: '#8ad6d6', brightWhite: '#fbfcfd',
|
||||||
|
},
|
||||||
|
},
|
||||||
session: { restoreOnLaunch: true },
|
session: { restoreOnLaunch: true },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One class/interface/trait/enum declaration in the project. */
|
||||||
|
export interface SymbolDef {
|
||||||
|
name: string; path: string; line: number; kind: string
|
||||||
|
/** The file's namespace, for the fully qualified name. */
|
||||||
|
ns: string
|
||||||
|
/** `extends` on the declaration line. */
|
||||||
|
parents: string[]
|
||||||
|
/** `implements` on the declaration line. */
|
||||||
|
interfaces: string[]
|
||||||
|
/** Traits mixed in by an indented `use` in the body. */
|
||||||
|
traits: string[]
|
||||||
|
}
|
||||||
|
/** References to one name, grouped by file. */
|
||||||
|
export interface RefGroup { path: string; hits: { no: number; ln: string; ix: number }[] }
|
||||||
|
/** What a ⌘-click on a class name returns: where it lives, and where it is used. */
|
||||||
|
export interface SymbolLookup { name: string; defs: SymbolDef[]; refs: RefGroup[]; refCount: number }
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { tmpdir } from 'node:os'
|
|||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
import { afterEach, describe, expect, it } from 'vitest'
|
import { afterEach, describe, expect, it } from 'vitest'
|
||||||
import { DEFAULTS, getConfig, getThemeCss, resolveConfig } from '../src/main/config'
|
import { DEFAULTS, getConfig, getThemeCss, resolveConfig } from '../src/main/config'
|
||||||
|
import { DEFAULT_CONFIG as RENDERER_DEFAULTS } from '../src/renderer/src/types'
|
||||||
|
|
||||||
let dir = ''
|
let dir = ''
|
||||||
afterEach(async () => { if (dir) await rm(dir, { recursive: true, force: true }) })
|
afterEach(async () => { if (dir) await rm(dir, { recursive: true, force: true }) })
|
||||||
@@ -52,3 +53,18 @@ describe('resolveConfig', () => {
|
|||||||
expect(getThemeCss()).toContain('#ff0000')
|
expect(getThemeCss()).toContain('#ff0000')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('terminal defaults', () => {
|
||||||
|
it('mirrors the main-process schema in the renderer', () => {
|
||||||
|
// Two copies of one schema drift silently; the terminal block is the one
|
||||||
|
// the user edits by hand, so it is worth asserting they still agree.
|
||||||
|
expect(RENDERER_DEFAULTS.terminal).toEqual(DEFAULTS.terminal)
|
||||||
|
})
|
||||||
|
it('carries a full ANSI table, so a partial config.json still resolves', () => {
|
||||||
|
const t = DEFAULTS.terminal.theme
|
||||||
|
for (const key of ['black', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white'] as const) {
|
||||||
|
expect(t[key]).toMatch(/^#[0-9a-f]{6}$/i)
|
||||||
|
expect(t[`bright${key[0].toUpperCase()}${key.slice(1)}` as keyof typeof t]).toMatch(/^#[0-9a-f]{6}$/i)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
79
test/symbol-popup.test.tsx
Normal file
79
test/symbol-popup.test.tsx
Normal 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
108
test/symbols.test.ts
Normal 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('')
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user