improvements to the ui

This commit is contained in:
2026-06-16 09:36:18 +02:00
parent 7bac3fe7c5
commit 8fc6478bb1
12 changed files with 292 additions and 170 deletions

View File

@@ -46,9 +46,44 @@ const THEME_TEMPLATE = `/* Helder theme — custom CSS applied OVER the built-in
}
`
/** Recently-opened files, newest first. Local machine state — git-ignored. */
const RECENT_FILE = 'recent.json'
const MAX_RECENT = 100
const GITIGNORE_BODY = `# Helder — local, machine-specific state (do not commit)\n${RECENT_FILE}\n`
let current: HelderConfig = DEFAULTS
let themeCss = ''
/** Create `.helder/.gitignore` (ignoring recent.json) only when it's missing. */
async function ensureGitignore(dir: string): Promise<void> {
const path = join(dir, '.gitignore')
try {
await readFile(path, 'utf8')
} catch {
await writeFile(path, GITIGNORE_BODY)
}
}
export async function getRecent(root: string): Promise<string[]> {
try {
const arr = JSON.parse(await readFile(join(root, '.helder', RECENT_FILE), 'utf8'))
return Array.isArray(arr) ? arr.filter((p): p is string => typeof p === 'string').slice(0, MAX_RECENT) : []
} catch {
return []
}
}
export async function setRecent(root: string, list: string[]): Promise<void> {
try {
const dir = join(root, '.helder')
await mkdir(dir, { recursive: true })
await ensureGitignore(dir)
await writeFile(join(dir, RECENT_FILE), JSON.stringify(list.slice(0, MAX_RECENT), null, 2) + '\n')
} catch {
/* read-only / inaccessible root — recents just won't persist */
}
}
function isPlainObject(v: unknown): v is Record<string, unknown> {
return !!v && typeof v === 'object' && !Array.isArray(v)
}
@@ -83,6 +118,8 @@ export async function resolveConfig(root: string): Promise<void> {
themeCss = THEME_TEMPLATE
await writeFile(join(dir, 'theme.css'), THEME_TEMPLATE)
}
await ensureGitignore(dir)
} catch {
// Read-only / inaccessible root: fall back to built-in defaults.
current = DEFAULTS

View File

@@ -5,7 +5,7 @@ import { getName, getRoot, openDialog } from './project'
import { readAll, readProjectFile, readTree, writeProjectFile } from './fs-service'
import { commit, discard, load, stage, unstage } from './git-service'
import { createPty, killAllPtys, killPty, ptyAvailable, resizePty, writePty } from './pty-service'
import { getConfig, getThemeCss, resolveConfig } from './config'
import { getConfig, getRecent, getThemeCss, resolveConfig, setRecent } from './config'
import { listFiles, searchContent } from './search-service'
const isDev = !!process.env['ELECTRON_RENDERER_URL']
@@ -112,6 +112,9 @@ function registerIpc(): void {
ipcMain.handle('config:get', () => getConfig())
ipcMain.handle('config:theme', () => getThemeCss())
ipcMain.handle('recent:get', () => getRecent(getRoot()))
ipcMain.handle('recent:set', (_e, list: string[]) => setRecent(getRoot(), list))
ipcMain.handle('search:content', (_e, query: string) => searchContent(getRoot(), query))
ipcMain.handle('search:files', () => listFiles(getRoot()))

View File

@@ -1,4 +1,4 @@
import { basename } from 'node:path'
import { basename, resolve } from 'node:path'
import { existsSync, statSync } from 'node:fs'
import { dialog, BrowserWindow } from 'electron'
@@ -6,12 +6,16 @@ import { dialog, BrowserWindow } from 'electron'
* One project per window. The root is resolved (in order) from $HELDER_PROJECT,
* a directory passed on argv, or the process working directory — then it can be
* changed at runtime via the Open Folder dialog.
*
* Always store an absolute path: in dev the app is launched as `electron .`, so
* argv carries a bare "." — `basename(".")` is "." (the repo name would show as a
* lone dot). `resolve()` turns it into the real directory before we name it.
*/
function resolveInitialRoot(): string {
const envRoot = process.env.HELDER_PROJECT
if (envRoot && existsSync(envRoot) && statSync(envRoot).isDirectory()) return envRoot
if (envRoot && existsSync(envRoot) && statSync(envRoot).isDirectory()) return resolve(envRoot)
const argDir = process.argv.slice(1).find((a) => !a.startsWith('-') && existsSync(a) && safeIsDir(a))
if (argDir) return argDir
if (argDir) return resolve(argDir)
return process.cwd()
}
@@ -30,7 +34,7 @@ export function getName(): string {
}
export function setRoot(next: string): void {
root = next
root = resolve(next)
}
export async function openDialog(win: BrowserWindow | null): Promise<string | null> {

View File

@@ -56,6 +56,11 @@ const api = {
theme: (): Promise<string> => ipcRenderer.invoke('config:theme'),
},
recent: {
get: (): Promise<string[]> => ipcRenderer.invoke('recent:get'),
set: (list: string[]): Promise<void> => ipcRenderer.invoke('recent:set', list),
},
search: {
content: (query: string) => ipcRenderer.invoke('search:content', query),
files: (): Promise<string[]> => ipcRenderer.invoke('search:files'),

View File

@@ -1,12 +1,11 @@
/* App shell: 4 resizable columns, keyboard shortcuts, copy-reference, status bar */
/* App shell: 4 resizable columns, keyboard shortcuts, copy-reference */
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { HL } from './highlight'
import { FileTree, GitPanel, Icon } from './components'
import type { ContextTarget } from './components'
import { Editor, SplitView } from './editor'
import type { Cursor, Mode, Selection } from './editor'
import { Terminal, lid } from './terminals'
import { ContextMenu, PassPopup, SearchModal, Toasts } from './overlays'
import { ContextMenu, HistoryModal, PassPopup, SearchModal, Toasts } from './overlays'
import type { Menu, Toast } from './overlays'
import type { FileNode, GitStatus } from './types'
import { useProject, useProjectActions } from './project'
@@ -80,18 +79,23 @@ export function App(): React.ReactElement {
const changeMap = useMemo(() => Object.fromEntries(proj.changes.map((c) => [c.path, c.status])) as Record<string, GitStatus>, [proj.changes])
const changeSet = useMemo(() => new Set(proj.changes.map((c) => c.path)), [proj.changes])
const [tabs, setTabs] = useState<{ path: string }[]>([])
const [active, setActive] = useState<string | null>(null)
const [tabMode, setTabMode] = useState<Record<string, Mode>>({})
const [openDirs, setOpenDirs] = useState<Set<string>>(new Set())
const [cursor, setCursor] = useState<Cursor | null>(null)
const [selection, setSelection] = useState<Selection | null>(null)
const [overlay, setOverlay] = useState<'search' | null>(null)
const [overlay, setOverlay] = useState<'search' | 'history' | null>(null)
// Most-recently-opened files, newest first, de-duplicated. Drives the ⌘↓/⌘↑ navigator.
const [history, setHistory] = useState<string[]>([])
const [histInitSel, setHistInitSel] = useState(0)
const [menu, setMenu] = useState<Menu | null>(null)
const [toasts, setToasts] = useState<Toast[]>([])
const [splitFor, setSplitFor] = useState<string | null>(null)
const [commitMsg, setCommitMsg] = useState('')
const [passPopup, setPassPopup] = useState<{ x: number; y: number; ref: string } | null>(null)
// Brief full-screen "branch - repository" flash whenever the window gains focus
// (handy when juggling several project windows).
const [showFlash, setShowFlash] = useState(false)
// Editable buffers: path → current text (absent = clean, showing on-disk content).
const [buffers, setBuffers] = useState<Record<string, string>>({})
@@ -167,6 +171,19 @@ export function App(): React.ReactElement {
return () => window.removeEventListener('resize', apply)
}, [focusZone, autoResize])
// Flash the branch · repository banner for ~2s each time the window gains focus.
useEffect(() => {
let timer: ReturnType<typeof setTimeout>
function flash(): void {
setShowFlash(true)
clearTimeout(timer)
timer = setTimeout(() => setShowFlash(false), 2000)
}
if (document.hasFocus()) flash()
window.addEventListener('focus', flash)
return () => { window.removeEventListener('focus', flash); clearTimeout(timer) }
}, [])
// Seed explorer expansion from the tree's `open` flags once per opened project.
const seededRoot = useRef<string | null | undefined>(undefined)
useEffect(() => {
@@ -176,25 +193,36 @@ export function App(): React.ReactElement {
}
}, [proj.tree, proj.root])
// Session restore (session.restoreOnLaunch): bring back the open tabs, active
// tab and per-tab view modes for this project, then keep them persisted.
// Per project: load the recent-files history (.helder/recent.json) and the last
// active file + view modes (localStorage), then keep both persisted. Recent files
// persist regardless of restoreOnLaunch; only the active/view-mode restore is gated.
const sessionRoot = useRef<string | null | undefined>(undefined)
const recentReady = useRef(false)
useEffect(() => {
if (!proj.ready || sessionRoot.current === proj.root) return
sessionRoot.current = proj.root
if (!proj.config.session.restoreOnLaunch) return
const saved = loadJson<{ tabs: string[]; active: string | null; tabMode: Record<string, Mode> } | null>(`helder.session:${proj.root}`, null)
if (saved && Array.isArray(saved.tabs)) {
setTabs(saved.tabs.map((p) => ({ path: p })))
setActive(saved.active ?? null)
setTabMode(saved.tabMode ?? {})
recentReady.current = false
setHistory([]); setActive(null); setTabMode({})
if (proj.config.session.restoreOnLaunch) {
const saved = loadJson<{ active: string | null; tabMode: Record<string, Mode> } | null>(`helder.session:${proj.root}`, null)
if (saved) { setActive(saved.active ?? null); setTabMode(saved.tabMode ?? {}) }
}
const bridge = window.helder
if (bridge) bridge.recent.get().then((list) => { setHistory(list); recentReady.current = true }).catch(() => { recentReady.current = true })
}, [proj.ready, proj.root, proj.config.session.restoreOnLaunch])
// Persist the history to .helder/recent.json (newest first, capped to 100 in main),
// but only once it's been loaded for this project (so we never clobber it with []).
useEffect(() => {
if (sessionRoot.current !== proj.root || !recentReady.current) return
const bridge = window.helder
if (bridge) bridge.recent.set(history).catch(() => {})
}, [history, proj.root])
useEffect(() => {
if (sessionRoot.current !== proj.root || !proj.config.session.restoreOnLaunch) return
saveJson(`helder.session:${proj.root}`, { tabs: tabs.map((t) => t.path), active, tabMode })
}, [tabs, active, tabMode, proj.root, proj.config.session.restoreOnLaunch])
saveJson(`helder.session:${proj.root}`, { active, tabMode })
}, [active, tabMode, proj.root, proj.config.session.restoreOnLaunch])
function toast(title: string, ref?: string): void {
const id = lid()
@@ -243,8 +271,8 @@ export function App(): React.ReactElement {
function openFile(path: string, opts: { diff?: boolean; line?: number } = {}): void {
const changed = !!proj.diffs[path]
setFocusZone('editor')
setHistory((h) => [path, ...h.filter((p) => p !== path)].slice(0, 100))
actions.ensureFile(path)
setTabs((t) => t.some((x) => x.path === path) ? t : [...t, { path }])
setActive(path)
setTabMode((m) => ({ ...m, [path]: opts.diff && changed ? defaultMode : (m[path] || (changed ? defaultMode : 'code')) }))
reveal(path)
@@ -261,19 +289,8 @@ export function App(): React.ReactElement {
}
}
function removeTab(path: string): void {
setBuffers((b) => { if (b[path] == null) return b; const n = { ...b }; delete n[path]; return n })
setTabs((t) => {
const ix = t.findIndex((x) => x.path === path)
const next = t.filter((x) => x.path !== path)
if (path === active) {
const fallback = next[ix] || next[ix - 1] || next[next.length - 1]
setActive(fallback ? fallback.path : null)
}
return next
})
}
// Close the current file view (no tabs anymore — the recent-files list replaces
// them). The file stays in history; ⌘W just clears the editor after a dirty check.
async function closeTab(path: string): Promise<void> {
const buf = buffersRef.current[path]
const dirtyNow = buf != null && buf !== (projRef.current.files[path] ?? '')
@@ -285,7 +302,8 @@ export function App(): React.ReactElement {
if (choice === 'cancel') return
if (choice === 'save') writeToDisk(path, buf as string)
}
removeTab(path)
setBuffers((b) => { if (b[path] == null) return b; const n = { ...b }; delete n[path]; return n })
if (path === active) setActive(null)
}
// ---- context menus ----
@@ -332,30 +350,27 @@ export function App(): React.ReactElement {
useEffect(() => {
function onKey(e: KeyboardEvent): void {
const meta = e.metaKey || e.ctrlKey
// The history navigator owns the keyboard while open (it listens in capture phase).
if (overlay === 'history') return
// ⌘↓/⌘↑ from the file viewer opens the recent-files navigator.
if (meta && (e.key === 'ArrowDown' || e.key === 'ArrowUp') && focusZone === 'editor' && history.length > 0) {
e.preventDefault()
setHistInitSel(e.key === 'ArrowDown' ? Math.min(1, history.length - 1) : 0)
setOverlay('history')
}
// ⌘F and ⌘P both open the unified search (it covers file names too).
if (meta && (e.key.toLowerCase() === 'f' || e.key.toLowerCase() === 'p')) { e.preventDefault(); setOverlay('search') }
else if (meta && (e.key.toLowerCase() === 'f' || e.key.toLowerCase() === 'p')) { e.preventDefault(); setOverlay('search') }
else if (meta && e.key.toLowerCase() === 's') { e.preventDefault(); saveActive() }
else if (meta && e.key.toLowerCase() === 'w') { e.preventDefault(); if (active) closeTab(active) }
else if (e.key === 'Escape') { if (splitFor) setSplitFor(null); else { setOverlay(null); setMenu(null) } }
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [active, splitFor])
}, [active, splitFor, overlay, focusZone, history])
const MODE_LABEL: Record<string, string> = { original: 'orig', updated: 'upd', diff: 'diff', code: '' }
const MODE_WORD: Record<string, string> = { original: 'Original', updated: 'Updated', diff: 'Diff' }
const resolvedTabs = tabs.map((t) => {
const changed = !!proj.diffs[t.path]
const m = tabMode[t.path] || (changed ? defaultMode : 'code')
return { ...t, changed, modeLabel: splitFor === t.path ? 'split' : MODE_LABEL[m], dirty: isDirty(t.path) }
})
const mode: Mode = (active && tabMode[active]) || (active && proj.diffs[active] ? defaultMode : 'code')
const totals = proj.changes.reduce((a, c) => ({ add: a.add + c.add, del: a.del + c.del }), { add: 0, del: 0 })
const activeLang = active ? HL.langLabel(active) : ''
const crumb = active ? active.split('/') : []
const curLine = cursor && active && cursor.path === active ? cursor.line : 1
const curCol = cursor && active && cursor.path === active ? cursor.col : 1
return (
<div className="app">
@@ -368,8 +383,13 @@ export function App(): React.ReactElement {
{active && (
<div className="tb-crumb">
{crumb.map((s, i) => (<React.Fragment key={i}>{i > 0 && <span className="seg"> </span>}<span style={i === crumb.length - 1 ? { color: 'var(--fg-1)' } : undefined}>{s}</span></React.Fragment>))}
{isDirty(active) && <span className="tb-dirty" title="Unsaved changes"></span>}
</div>
)}
<div className="tb-repo" title={proj.branch + ' - ' + proj.name}>
{Icon.branch()}<span className="tb-repo-branch">{proj.branch}</span>
<span className="tb-repo-sep">-</span><span className="tb-repo-name">{proj.name}</span>
</div>
<div className="tb-spacer" />
<div className="tb-actions">
<button className="tb-btn" onClick={() => setOverlay('search')}>{Icon.search()} Search <kbd>F</kbd></button>
@@ -380,6 +400,18 @@ export function App(): React.ReactElement {
</div>
</div>
{/* focus flash — branch - repository, centered, ~1s (inline, header styling) */}
{showFlash && (
<div className="focus-flash" key={proj.branch + proj.name}>
<div className="ff-card">
{Icon.branch({ width: 20, height: 20 })}
<span className="tb-repo-branch">{proj.branch}</span>
<span className="tb-repo-sep">-</span>
<span className="tb-repo-name">{proj.name}</span>
</div>
</div>
)}
{/* workbench */}
<div className="workbench">
<div className="col" style={{ width: gitW, flex: '0 0 ' + gitW + 'px' }}>
@@ -401,9 +433,9 @@ export function App(): React.ReactElement {
<Splitter onDelta={(dx) => { setAutoResize(false); setTreeW((w) => clamp(w + dx, 160, 520)) }} />
<div className="col editor-col" onMouseDownCapture={() => setFocusZone('editor')}>
<Editor tabs={resolvedTabs} active={active} mode={mode}
<Editor active={active} mode={mode}
setMode={(m) => { if (active) { setTabMode((mm) => ({ ...mm, [active]: m })); setSplitFor(null) } }}
onActivate={setActive} onClose={closeTab} onContext={openMenu}
onContext={openMenu}
onSplit={(p) => setSplitFor(p)} splitOpen={splitFor === active}
cursor={cursor} selection={selection} setCursor={setCursor} setSelection={setSelection}
bufferText={bufferText(active)} onEdit={onEdit} />
@@ -418,18 +450,6 @@ export function App(): React.ReactElement {
{proj.ready && <RightColumn key={proj.root ?? 'none'} width={rightW} onFocus={() => setFocusZone('terminal')} />}
</div>
{/* status bar */}
<div className="statusbar">
<div className="sb accent">{Icon.branch({ width: 12, height: 12 })}<span style={{ color: '#0c1320' }}>{proj.branch}</span></div>
<div className="sb"><span className="a">+{totals.add}</span> <span className="d">{totals.del}</span></div>
<div className="sb spacer" />
{active && <div className="sb">{selection && selection.path === active && selection.start !== selection.end ? `${selection.end - selection.start + 1} lines selected` : `Ln ${curLine}, Col ${curCol}`}</div>}
{active && <div className="sb">Spaces: {proj.config.editor.tabSize}</div>}
{active && <div className="sb">UTF-8</div>}
{active && <div className="sb"><b>{activeLang}</b></div>}
{active && proj.diffs[active] && <div className="sb">{splitFor === active ? 'Split' : (MODE_WORD[mode] || '')}</div>}
</div>
{/* overlays */}
{splitFor && <SplitView path={splitFor} onClose={() => setSplitFor(null)} onContext={openMenu} />}
{passPopup && <PassPopup x={passPopup.x} y={passPopup.y} refStr={passPopup.ref}
@@ -441,6 +461,7 @@ export function App(): React.ReactElement {
}}
onCancel={() => setPassPopup(null)} />}
{overlay === 'search' && <SearchModal onOpen={openFile} onOpenAt={(p, n) => openFile(p, { line: n })} onClose={() => setOverlay(null)} changeSet={changeSet} />}
{overlay === 'history' && <HistoryModal history={history} initialSel={histInitSel} onOpen={openFile} onClose={() => setOverlay(null)} changeSet={changeSet} />}
{menu && <ContextMenu menu={menu} onClose={() => setMenu(null)} />}
<Toasts toasts={toasts} />
</div>

View File

@@ -31,8 +31,14 @@ export const Chevron = ({ open }: { open: boolean }): React.ReactElement => (
export const FolderIcon = ({ open }: { open: boolean }): React.ReactElement => (
<svg className="folder-ic" width="14" height="14" viewBox="0 0 16 16" fill="none">
<path d={open ? 'M1.5 4.5h4l1.2 1.4H14V13H2V4.5z' : 'M1.5 4.5h4l1.2 1.4H14V13H1.5V4.5z'}
fill={open ? 'rgba(122,131,140,.18)' : 'rgba(122,131,140,.12)'} stroke="currentColor" strokeWidth="1.1" />
{open ? (
<Fragment>
<path d="M2 12.5V4.5h3.6l1.2 1.4h6.7V7.4" fill="rgba(122,131,140,.16)" stroke="currentColor" strokeWidth="1.1" strokeLinejoin="round" />
<path d="M1.4 12.6l1.9-5.1h11.4l-1.9 5.1H1.4z" fill="rgba(122,131,140,.22)" stroke="currentColor" strokeWidth="1.1" strokeLinejoin="round" />
</Fragment>
) : (
<path d="M1.5 4.5h4l1.2 1.4H14V13H1.5V4.5z" fill="rgba(122,131,140,.12)" stroke="currentColor" strokeWidth="1.1" strokeLinejoin="round" />
)}
</svg>
)
@@ -53,16 +59,18 @@ export interface ContextTarget {
export type OnContext = (e: React.MouseEvent, target: ContextTarget) => void
/* ============ Git / Source Control panel ============ */
function GitRow({ c, staged, activePath, onOpen, onContext, onToggleStage }: {
function GitRow({ c, staged, activePath, showDir, onOpen, onContext, onToggleStage }: {
c: Change
staged: boolean
activePath: string | null
showDir: boolean
onOpen: OpenFile
onContext: OnContext
onToggleStage: (path: string) => void
}): React.ReactElement {
const name = c.path.split('/').pop()
const dir = c.path.split('/').slice(0, -1).join('/')
const dirShown = showDir && !!dir
return (
<div className={'git-row' + (activePath === c.path ? ' active' : '')}
onClick={() => onOpen(c.path, { diff: true })}
@@ -71,20 +79,16 @@ function GitRow({ c, staged, activePath, onOpen, onContext, onToggleStage }: {
<span className={'git-stat ' + c.status}>{c.status}</span>
<FileIcon path={c.path} />
<span className={'git-name' + (c.deleted ? ' del' : '')}>{name}</span>
{dir && <span className="git-dir">{dir}/</span>}
<button className="git-act" title={staged ? 'Unstage changes' : 'Stage changes'}
{dirShown && <span className="git-dir">{dir}/</span>}
<button className={'git-act' + (dirShown ? '' : ' push')} title={staged ? 'Unstage changes' : 'Stage changes'}
onClick={(e) => { e.stopPropagation(); onToggleStage(c.path) }}>
{staged ? Icon.minus() : Icon.plus()}
</button>
<span className="git-delta">
{c.add > 0 && <span className="a">+{c.add}</span>}
{c.del > 0 && <span className="d">-{c.del}</span>}
</span>
</div>
)
}
export function GitPanel({ branch, changes, staged, committed, commitMsg, setCommitMsg, onStage, onUnstage, onStageAll, onUnstageAll, onCommit, onOpen, onContext, activePath }: {
export function GitPanel({ branch, changes, staged, committed, commitMsg, setCommitMsg, onStage, onUnstage, onStageAll, onUnstageAll, onCommit, onOpen, onContext, activePath, showDir }: {
branch: string
changes: Change[]
staged: Set<string>
@@ -99,6 +103,7 @@ export function GitPanel({ branch, changes, staged, committed, commitMsg, setCom
onOpen: OpenFile
onContext: OnContext
activePath: string | null
showDir: boolean
}): React.ReactElement {
const visible = changes.filter((c) => !committed.has(c.path))
const stagedList = visible.filter((c) => staged.has(c.path))
@@ -125,7 +130,7 @@ export function GitPanel({ branch, changes, staged, committed, commitMsg, setCom
{stagedList.length > 0 && <button className="grp-act" title="Unstage all" onClick={onUnstageAll}>{Icon.minus()}</button>}
</div>
{stagedList.length > 0 ? stagedList.map((c) => (
<GitRow key={c.path} c={c} staged={true} activePath={activePath}
<GitRow key={c.path} c={c} staged={true} activePath={activePath} showDir={showDir}
onOpen={onOpen} onContext={onContext} onToggleStage={onUnstage} />
)) : (
<div className="git-none">Nothing staged use <span className="key">+</span> to stage a file</div>
@@ -138,7 +143,7 @@ export function GitPanel({ branch, changes, staged, committed, commitMsg, setCom
{changesList.length > 0 && <button className="grp-act" title="Stage all" onClick={onStageAll}>{Icon.plus()}</button>}
</div>
{changesList.length > 0 ? changesList.map((c) => (
<GitRow key={c.path} c={c} staged={false} activePath={activePath}
<GitRow key={c.path} c={c} staged={false} activePath={activePath} showDir={showDir}
onOpen={onOpen} onContext={onContext} onToggleStage={onStage} />
)) : (
<div className="git-none">All changes staged</div>

View File

@@ -1,5 +1,5 @@
/* Editor: tabs + four view modes (Original / Updated / Diff / Split) + line selection */
import React, { Fragment, useEffect, useMemo, useRef } from 'react'
/* Editor: four view modes (Original / Updated / Diff / Split) + line selection */
import React, { Fragment, useMemo, useRef } from 'react'
import type { Diff, ViewLine } from './types'
import { useProject } from './project'
import { HL } from './highlight'
@@ -16,8 +16,6 @@ function climbToLine(node: Node | null): HTMLElement | null {
return el || null
}
interface ResolvedTab { path: string; changed: boolean; modeLabel: string; dirty?: boolean }
/* Editable buffer: a transparent textarea over a Prism-highlighted <pre>, with a
* scroll-synced line-number gutter. Live highlighting while typing. */
function CodeEditor({ path, text, lang, onChange, onContext }: {
@@ -81,41 +79,6 @@ function CodeEditor({ path, text, lang, onChange, onContext }: {
)
}
function EditorTabs({ tabs, active, onActivate, onClose }: {
tabs: ResolvedTab[]
active: string | null
onActivate: (path: string) => void
onClose: (path: string) => void
}): React.ReactElement {
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
const el = ref.current && ref.current.querySelector('.tab.active')
if (el) el.scrollIntoView({ block: 'nearest', inline: 'nearest' })
}, [active])
return (
<div className="tabs" ref={ref}>
{tabs.map((t) => {
const name = t.path.split('/').pop()
return (
<div key={t.path}
className={'tab' + (active === t.path ? ' active' : '') + (t.dirty ? ' dirtyclose' : '')}
onClick={() => onActivate(t.path)}
onAuxClick={(e) => { if (e.button === 1) { e.preventDefault(); onClose(t.path) } }}
title={t.path}>
<FileIcon path={t.path} />
<span className="tname">{name}</span>
{t.changed && <span className="tab-mode">{t.modeLabel}</span>}
<span className="tclose" onClick={(e) => { e.stopPropagation(); onClose(t.path) }}>
{Icon.close()}
</span>
{t.dirty && <span className="tdot" title="Unsaved changes" />}
</div>
)
})}
</div>
)
}
/* Generic pane: renders an array of line descriptors with selection + caret + context. */
function PaneView({ cacheKey, path, lines, lang, showSign, cursor, selection, setCursor, setSelection, onContext }: {
cacheKey: string
@@ -228,18 +191,15 @@ function buildLines(mode: Mode, diff: Diff | null, fileText: string | undefined)
}
const SEGMENTS: { id: Mode; label: string }[] = [
{ id: 'original', label: 'Original' },
{ id: 'updated', label: 'Updated' },
{ id: 'original', label: 'Original' },
{ id: 'diff', label: 'Diff' },
]
export function Editor({ tabs, active, mode, setMode, onActivate, onClose, onContext, onSplit, splitOpen, cursor, selection, setCursor, setSelection, bufferText, onEdit }: {
tabs: ResolvedTab[]
export function Editor({ active, mode, setMode, onContext, onSplit, splitOpen, cursor, selection, setCursor, setSelection, bufferText, onEdit }: {
active: string | null
mode: Mode
setMode: (m: Mode) => void
onActivate: (path: string) => void
onClose: (path: string) => void
onContext: OnContext
onSplit: (path: string) => void
splitOpen: boolean
@@ -251,7 +211,7 @@ export function Editor({ tabs, active, mode, setMode, onActivate, onClose, onCon
onEdit: (text: string) => void
}): React.ReactElement {
const PROJECT = useProject()
const tab = tabs.find((t) => t.path === active)
const tab = active ? { path: active } : null
const change = tab ? PROJECT.changes.find((c) => c.path === tab.path) : null
const diff = tab ? PROJECT.diffs[tab.path] : null
const lang = tab ? HL.langFor(tab.path) : null
@@ -272,7 +232,6 @@ export function Editor({ tabs, active, mode, setMode, onActivate, onClose, onCon
return (
<Fragment>
<EditorTabs tabs={tabs} active={active} onActivate={onActivate} onClose={onClose} />
{!tab ? (
<div className="empty-ed">
<div style={{ opacity: 0.5 }}>{Icon.file({ width: 30, height: 30 })}</div>

View File

@@ -42,6 +42,10 @@ interface HelderBridge {
get: () => Promise<HelderConfig>
theme: () => Promise<string>
}
recent: {
get: () => Promise<string[]>
set: (list: string[]) => Promise<void>
}
search: {
content: (query: string) => Promise<{ path: string; hits: { no: number; ln: string; ix: number }[] }[]>
files: () => Promise<string[]>

View File

@@ -180,6 +180,68 @@ export function SearchModal({ onOpen, onOpenAt, onClose, changeSet }: {
)
}
/* Recently-opened-files navigator. Looks like the search modal but is a single
* keyboard-driven list (most-recent first). ⌘↓/⌘↑ move the selection, ↵ opens.
* Listens in the capture phase so it owns the keyboard while open. */
export function HistoryModal({ history, initialSel, onOpen, onClose, changeSet }: {
history: string[]
initialSel: number
onOpen: OpenFile
onClose: () => void
changeSet: Set<string>
}): React.ReactElement {
const [sel, setSel] = useState(() => Math.min(Math.max(initialSel, 0), Math.max(history.length - 1, 0)))
const selRef = useRef(sel); selRef.current = sel
const listRef = useRef<HTMLDivElement>(null)
useEffect(() => {
function onKey(e: KeyboardEvent): void {
if (e.key === 'ArrowDown') { e.preventDefault(); setSel((s) => Math.min(s + 1, history.length - 1)) }
else if (e.key === 'ArrowUp') { e.preventDefault(); setSel((s) => Math.max(s - 1, 0)) }
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() }
}
window.addEventListener('keydown', onKey, true)
return () => window.removeEventListener('keydown', onKey, true)
}, [history])
useEffect(() => {
const el = listRef.current && listRef.current.querySelector('.hist-row.sel')
if (el) el.scrollIntoView({ block: 'nearest' })
}, [sel])
return (
<div className="scrim" onMouseDown={onClose}>
<div className="history-modal" onMouseDown={(e) => e.stopPropagation()}>
<div className="pi">
{Icon.file({ style: { color: 'var(--fg-3)' } })}
<span className="hist-title">Recent files</span>
<span className="mode-chip">{history.length} file{history.length === 1 ? '' : 's'} · <kbd></kbd> <kbd></kbd> <kbd></kbd></span>
</div>
<div className="hist-list" ref={listRef}>
{history.length === 0 && <div className="pempty">No files opened yet</div>}
{history.map((p, i) => {
const name = p.split('/').pop() as string
const dir = p.split('/').slice(0, -1).join('/')
return (
<div key={p} className={'hist-row' + (i === sel ? ' sel' : '')} title={p}
onMouseEnter={() => setSel(i)}
onClick={() => { onOpen(p); onClose() }}>
<FileIcon path={p} />
<div className="hist-txt">
<span className="fn">{name}</span>
<span className="fd">{dir ? dir + '/' : ''}</span>
</div>
{changeSet.has(p) && <span className="tree-badge M" style={{ fontFamily: 'var(--mono)', fontSize: 10 }}></span>}
</div>
)
})}
</div>
</div>
</div>
)
}
export function ContextMenu({ menu, onClose }: { menu: Menu | null; onClose: () => void }): React.ReactElement | null {
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {

View File

@@ -13,9 +13,12 @@
--fg-1:#b4bac2;
--fg-2:#838a94;
--fg-3:#5d636c;
--accent:#4d8dff;
--accent-soft:rgba(77,141,255,0.16);
--accent-line:rgba(77,141,255,0.55);
--accent:#f19f3f;
--accent-soft:rgba(241,159,63,0.16);
--accent-line:rgba(241,159,63,0.55);
--add:#5cbd6b;
--del:#e0696a;
--mod:#d8a85c;
@@ -50,7 +53,7 @@ body {
overflow:hidden; -webkit-font-smoothing:antialiased;
}
#root { height:100vh; }
::selection { background:rgba(77,141,255,0.32); }
::selection { background:rgba(241,159,63,0.30); }
/* scrollbars */
::-webkit-scrollbar { width:11px; height:11px; }
@@ -62,17 +65,45 @@ body {
.app { display:flex; flex-direction:column; height:100vh; }
.titlebar {
height:36px; flex:0 0 36px; display:flex; align-items:center;
height:36px; flex:0 0 36px; display:flex; align-items:center; position:relative;
background:var(--bg-3); border-bottom:1px solid var(--border);
padding:0 12px; gap:14px; user-select:none;
}
/* branch - repository, centered in the bar */
.tb-repo {
position:absolute; left:50%; transform:translateX(-50%);
display:flex; align-items:center; gap:6px; pointer-events:none;
font-family:var(--mono); font-size:11.5px; color:var(--fg-2);
max-width:46%; white-space:nowrap; overflow:hidden;
}
.tb-repo svg { color:var(--accent); flex:0 0 auto; }
.tb-repo-branch { color:var(--fg-3); }
.tb-repo-sep { color:var(--fg-3); }
.tb-repo-name { color:var(--fg-0); font-weight:600; overflow:hidden; text-overflow:ellipsis; }
/* focus flash — branch - repository, briefly centered; same inline style as the header label, scaled up */
.focus-flash {
position:fixed; inset:0; z-index:200; pointer-events:none;
display:flex; align-items:center; justify-content:center;
animation:ff-fade 2s ease forwards;
}
.ff-card {
display:flex; align-items:center; gap:9px;
font-family:var(--mono); font-size:24px; line-height:1;
padding:20px 34px; border-radius:14px;
background:rgba(22,23,26,0.82); border:1px solid var(--border-2);
backdrop-filter:blur(8px); box-shadow:0 18px 60px rgba(0,0,0,0.5);
}
.ff-card svg { color:var(--accent); flex:0 0 auto; }
@keyframes ff-fade { 0%{opacity:0;} 12%{opacity:1;} 72%{opacity:1;} 100%{opacity:0;} }
@media (prefers-reduced-motion:reduce) { .focus-flash { animation:ff-fade-rm 2s steps(1) forwards; } @keyframes ff-fade-rm { 0%{opacity:1;} 99%{opacity:1;} 100%{opacity:0;} } }
.traffic { display:flex; gap:8px; }
.traffic i { width:12px; height:12px; border-radius:50%; display:block; }
.traffic .r{background:#e0696a;} .traffic .y{background:#d8a85c;} .traffic .g{background:#5cbd6b;}
.tb-title { font-size:12px; color:var(--fg-1); display:flex; align-items:center; gap:7px; }
.tb-title b { color:var(--fg-0); font-weight:600; }
.tb-crumb { color:var(--fg-3); font-size:11.5px; font-family:var(--mono); }
.tb-crumb { color:var(--fg-3); font-size:11.5px; font-family:var(--mono); display:flex; align-items:center; gap:2px; }
.tb-crumb .seg{color:var(--fg-2);}
.tb-crumb .tb-dirty { color:var(--mod); font-size:10px; margin-left:4px; }
.tb-spacer { flex:1; }
.tb-actions { display:flex; gap:6px; align-items:center; }
.tb-btn {
@@ -83,7 +114,7 @@ body {
.tb-btn kbd { font-family:var(--mono); font-size:10px; color:var(--fg-3); border:1px solid var(--border-2); border-radius:4px; padding:1px 5px; }
.tb-toggle .tb-state { font-family:var(--mono); font-size:10px; border-radius:4px; padding:1px 5px; background:var(--bg-1); color:var(--fg-3); }
.tb-toggle.on { color:var(--fg-1); border-color:var(--border-2); }
.tb-toggle.on .tb-state { background:rgba(77,141,255,0.16); color:var(--accent); }
.tb-toggle.on .tb-state { background:var(--accent-soft); color:var(--accent); }
.workbench { flex:1; display:flex; min-height:0; }
@@ -112,8 +143,8 @@ body {
.commit-input { flex:1; min-width:0; resize:none; background:var(--bg-0); border:1px solid var(--border-2); border-radius:7px; color:var(--fg-0); font-family:var(--ui); font-size:12px; line-height:16px; padding:7px 9px; outline:none; min-height:32px; }
.commit-input:focus { border-color:var(--accent-line); }
.commit-input::placeholder { color:var(--fg-3); }
.commit-btn { flex:0 0 auto; display:flex; align-items:center; gap:6px; background:var(--accent); color:#0c1320; border:0; border-radius:7px; font:inherit; font-size:12px; font-weight:600; padding:0 11px; height:32px; cursor:pointer; }
.commit-btn:hover:not(:disabled) { background:#5d97ff; }
.commit-btn { flex:0 0 auto; display:flex; align-items:center; gap:6px; background:var(--accent); color:#201608; border:0; border-radius:7px; font:inherit; font-size:12px; font-weight:600; padding:0 11px; height:32px; cursor:pointer; }
.commit-btn:hover:not(:disabled) { background:#f6b35f; }
.commit-btn:disabled { background:var(--bg-3); color:var(--fg-3); cursor:not-allowed; }
.git-body { overflow:auto; flex:1; padding:4px 0 10px; }
.git-group { padding:8px 12px 3px; font-size:10px; letter-spacing:.06em; text-transform:uppercase; color:var(--fg-3); display:flex; align-items:center; gap:6px; }
@@ -138,11 +169,10 @@ body {
.git-row.active .git-name { color:var(--fg-0); }
.git-name.del { text-decoration:line-through; color:var(--fg-3); }
.git-dir { color:var(--fg-3); font-size:11px; margin-left:auto; padding-left:8px; white-space:nowrap; max-width:42%; overflow:hidden; text-overflow:ellipsis; direction:rtl; }
.git-act { flex:0 0 auto; display:none; align-items:center; justify-content:center; width:20px; height:20px; padding:0; background:transparent; border:0; border-radius:5px; color:var(--fg-2); cursor:pointer; margin-left:4px; }
.git-row:hover .git-act { display:flex; }
.git-act { flex:0 0 auto; display:flex; visibility:hidden; align-items:center; justify-content:center; width:20px; height:20px; padding:0; background:transparent; border:0; border-radius:5px; color:var(--fg-2); cursor:pointer; margin-left:4px; }
.git-act.push { margin-left:auto; } /* right-align when the dir column is hidden */
.git-row:hover .git-act { visibility:visible; }
.git-act:hover { background:var(--active); color:var(--fg-0); }
.git-delta { font-family:var(--mono); font-size:10.5px; display:flex; gap:6px; flex:0 0 auto; }
.git-delta .a{color:var(--add);} .git-delta .d{color:var(--del);}
.git-foot { border-top:1px solid var(--border); padding:8px 12px; display:flex; align-items:center; gap:8px; font-size:11px; color:var(--fg-2); }
.branch-chip { display:flex; align-items:center; gap:6px; color:var(--fg-1); }
.branch-chip b { font-weight:600; color:var(--fg-0); }
@@ -167,26 +197,6 @@ body {
.folder-ic { width:15px; height:15px; flex:0 0 15px; display:inline-flex; align-items:center; justify-content:center; color:var(--fg-2); }
/* ============ editor ============ */
.tabs { height:35px; flex:0 0 35px; display:flex; align-items:stretch; background:var(--bg-3); border-bottom:1px solid var(--border); overflow-x:auto; overflow-y:hidden; }
.tabs::-webkit-scrollbar { height:0; }
.tab {
display:flex; align-items:center; gap:7px; padding:0 9px 0 13px; cursor:pointer;
border-right:1px solid var(--border); color:var(--fg-2); font-size:12.5px; white-space:nowrap;
background:var(--bg-3); position:relative; max-width:230px;
}
.tab:hover { background:#272b31; }
.tab.active { background:var(--bg-0); color:var(--fg-0); }
.tab.active::after { content:""; position:absolute; left:0; right:0; top:0; height:2px; background:var(--accent); }
.tab .tname { overflow:hidden; text-overflow:ellipsis; }
.tab.dirty .tname::after { content:" ●"; color:var(--mod); font-size:10px; }
.tab .tclose { width:17px; height:17px; border-radius:4px; display:flex; align-items:center; justify-content:center; color:var(--fg-3); flex:0 0 17px; }
.tab .tclose:hover { background:var(--active); color:var(--fg-0); }
.tab .tdot { display:none; width:7px; height:7px; border-radius:50%; background:var(--fg-2); }
.tab.dirtyclose .tclose { display:none; }
.tab.dirtyclose:hover .tclose { display:flex; }
.tab.dirtyclose:hover .tdot { display:none; }
.tab.dirtyclose .tdot { display:block; }
.tab-mode { margin-left:6px; font-size:9.5px; letter-spacing:.05em; text-transform:uppercase; color:var(--fg-3); border:1px solid var(--border-2); border-radius:4px; padding:0 5px; line-height:14px; }
.editor-wrap { flex:1; min-height:0; display:flex; flex-direction:column; position:relative; }
.editor { flex:1; overflow:auto; font-family:var(--code-font); font-size:var(--code-size); line-height:20px; padding:6px 0 40px; }
@@ -323,6 +333,22 @@ body {
.fres-txt .fn b { color:var(--accent); font-weight:700; }
.fres-txt .fd { font-size:10.5px; color:var(--fg-3); font-family:var(--mono); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
/* recent-files navigator — single-column, keyboard-driven, search-modal styling */
.history-modal { width:560px; max-width:92vw; background:#212429; border:1px solid var(--border-2); border-radius:11px; box-shadow:0 24px 70px rgba(0,0,0,.55); overflow:hidden; display:flex; flex-direction:column; }
.history-modal .pi { display:flex; align-items:center; gap:10px; padding:12px 15px; border-bottom:1px solid var(--border); }
.history-modal .pi svg { flex:0 0 auto; }
.history-modal .hist-title { flex:1; min-width:0; color:var(--fg-1); font-size:14px; }
.history-modal .mode-chip { flex:0 0 auto; white-space:nowrap; font-size:10px; color:var(--fg-3); border:1px solid var(--border-2); border-radius:5px; padding:2px 7px; font-family:var(--mono); display:flex; align-items:center; gap:4px; }
.history-modal .mode-chip kbd { font-family:var(--mono); font-size:10px; color:var(--fg-2); border:1px solid var(--border-2); border-radius:4px; padding:0 4px; }
.hist-list { max-height:460px; overflow:auto; padding:5px 0; }
.hist-row { display:flex; align-items:center; gap:9px; padding:6px 13px; cursor:pointer; }
.hist-row.sel { background:var(--accent-dim, rgba(241,159,63,0.14)); box-shadow:inset 2px 0 0 var(--accent); }
.hist-row:hover { background:var(--hover); }
.hist-row.sel:hover { background:rgba(241,159,63,0.20); }
.hist-txt { min-width:0; display:flex; flex-direction:column; line-height:1.25; }
.hist-txt .fn { font-size:12.5px; color:var(--fg-0); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.hist-txt .fd { font-size:10.5px; color:var(--fg-3); font-family:var(--mono); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
/* content search */
.search-results { max-height:420px; overflow:auto; padding:4px 0 8px; }
.sr-file { padding:7px 14px 3px; font-size:11.5px; color:var(--fg-2); display:flex; align-items:center; gap:8px; cursor:pointer; }
@@ -374,14 +400,6 @@ body {
.toast .tref { font-family:var(--mono); font-size:11.5px; color:var(--accent); background:var(--accent-soft); border-radius:5px; padding:2px 8px; }
/* ============ status bar ============ */
.statusbar { height:23px; flex:0 0 23px; display:flex; align-items:center; gap:0; background:var(--bg-3); border-top:1px solid var(--border); font-size:11px; color:var(--fg-2); user-select:none; }
.sb { display:flex; align-items:center; gap:6px; padding:0 11px; height:100%; }
.sb:hover { background:var(--hover); }
.sb.accent { background:var(--accent); color:#0c1320; }
.sb.accent:hover { background:#5d97ff; }
.sb.spacer { flex:1; }
.sb .a{color:var(--add);} .sb .d{color:var(--del);}
.sb b { font-weight:600; color:var(--fg-1); }
/* editable buffer — transparent textarea over a highlighted <pre>, synced gutter */
.code-edit { flex:1; min-height:0; display:flex; overflow:hidden; }
@@ -400,7 +418,7 @@ body {
position:absolute; inset:0; resize:none; outline:none; overflow:hidden;
background:transparent; color:transparent; caret-color:var(--accent);
}
.ce-ta::selection { background:rgba(77,141,255,0.32); }
.ce-ta::selection { background:rgba(241,159,63,0.30); }
/* xterm.js host (real terminals) */
.term-xterm { flex:1; min-height:0; overflow:hidden; padding:6px 4px 6px 8px; background:var(--bg-1); }