From 8fc6478bb1082247edb14f2d41fa28cbbf8aa917 Mon Sep 17 00:00:00 2001 From: Jonathan van Rij Date: Tue, 16 Jun 2026 09:36:18 +0200 Subject: [PATCH] improvements to the ui --- .helder/.gitignore | 2 + src/main/config.ts | 37 +++++++++ src/main/index.ts | 5 +- src/main/project.ts | 12 ++- src/preload/index.ts | 5 ++ src/renderer/src/App.tsx | 135 ++++++++++++++++++-------------- src/renderer/src/components.tsx | 29 ++++--- src/renderer/src/editor.tsx | 51 ++---------- src/renderer/src/env.d.ts | 4 + src/renderer/src/overlays.tsx | 62 +++++++++++++++ src/renderer/src/styles.css | 102 ++++++++++++++---------- test/app.test.tsx | 18 +++-- 12 files changed, 292 insertions(+), 170 deletions(-) create mode 100644 .helder/.gitignore diff --git a/.helder/.gitignore b/.helder/.gitignore new file mode 100644 index 0000000..b436930 --- /dev/null +++ b/.helder/.gitignore @@ -0,0 +1,2 @@ +# Helder — local, machine-specific state (do not commit) +recent.json diff --git a/src/main/config.ts b/src/main/config.ts index 8127812..216c520 100644 --- a/src/main/config.ts +++ b/src/main/config.ts @@ -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 { + const path = join(dir, '.gitignore') + try { + await readFile(path, 'utf8') + } catch { + await writeFile(path, GITIGNORE_BODY) + } +} + +export async function getRecent(root: string): Promise { + 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 { + 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 { return !!v && typeof v === 'object' && !Array.isArray(v) } @@ -83,6 +118,8 @@ export async function resolveConfig(root: string): Promise { 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 diff --git a/src/main/index.ts b/src/main/index.ts index adcbd09..1916ccd 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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())) diff --git a/src/main/project.ts b/src/main/project.ts index a32921f..d4025b5 100644 --- a/src/main/project.ts +++ b/src/main/project.ts @@ -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 { diff --git a/src/preload/index.ts b/src/preload/index.ts index 2ad4289..a48f0bc 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -56,6 +56,11 @@ const api = { theme: (): Promise => ipcRenderer.invoke('config:theme'), }, + recent: { + get: (): Promise => ipcRenderer.invoke('recent:get'), + set: (list: string[]): Promise => ipcRenderer.invoke('recent:set', list), + }, + search: { content: (query: string) => ipcRenderer.invoke('search:content', query), files: (): Promise => ipcRenderer.invoke('search:files'), diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index d776bff..c881d67 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -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, [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(null) const [tabMode, setTabMode] = useState>({}) const [openDirs, setOpenDirs] = useState>(new Set()) const [cursor, setCursor] = useState(null) const [selection, setSelection] = useState(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([]) + const [histInitSel, setHistInitSel] = useState(0) const [menu, setMenu] = useState(null) const [toasts, setToasts] = useState([]) const [splitFor, setSplitFor] = useState(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>({}) @@ -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 + 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(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(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 } | 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 } | 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 { 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 = { original: 'orig', updated: 'upd', diff: 'diff', code: '' } - const MODE_WORD: Record = { 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 (
@@ -368,8 +383,13 @@ export function App(): React.ReactElement { {active && (
{crumb.map((s, i) => ({i > 0 && }{s}))} + {isDirty(active) && }
)} +
+ {Icon.branch()}{proj.branch} + -{proj.name} +
@@ -380,6 +400,18 @@ export function App(): React.ReactElement {
+ {/* focus flash — branch - repository, centered, ~1s (inline, header styling) */} + {showFlash && ( +
+
+ {Icon.branch({ width: 20, height: 20 })} + {proj.branch} + - + {proj.name} +
+
+ )} + {/* workbench */}
@@ -401,9 +433,9 @@ export function App(): React.ReactElement { { setAutoResize(false); setTreeW((w) => clamp(w + dx, 160, 520)) }} />
setFocusZone('editor')}> - { 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 && setFocusZone('terminal')} />}
- {/* status bar */} -
-
{Icon.branch({ width: 12, height: 12 })}{proj.branch}
-
+{totals.add} −{totals.del}
-
- {active &&
{selection && selection.path === active && selection.start !== selection.end ? `${selection.end - selection.start + 1} lines selected` : `Ln ${curLine}, Col ${curCol}`}
} - {active &&
Spaces: {proj.config.editor.tabSize}
} - {active &&
UTF-8
} - {active &&
{activeLang}
} - {active && proj.diffs[active] &&
{splitFor === active ? 'Split' : (MODE_WORD[mode] || '')}
} -
- {/* overlays */} {splitFor && setSplitFor(null)} onContext={openMenu} />} {passPopup && setPassPopup(null)} />} {overlay === 'search' && openFile(p, { line: n })} onClose={() => setOverlay(null)} changeSet={changeSet} />} + {overlay === 'history' && setOverlay(null)} changeSet={changeSet} />} {menu && setMenu(null)} />}
diff --git a/src/renderer/src/components.tsx b/src/renderer/src/components.tsx index 2edce2d..978576f 100644 --- a/src/renderer/src/components.tsx +++ b/src/renderer/src/components.tsx @@ -31,8 +31,14 @@ export const Chevron = ({ open }: { open: boolean }): React.ReactElement => ( export const FolderIcon = ({ open }: { open: boolean }): React.ReactElement => ( - + {open ? ( + + + + + ) : ( + + )} ) @@ -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 (
onOpen(c.path, { diff: true })} @@ -71,20 +79,16 @@ function GitRow({ c, staged, activePath, onOpen, onContext, onToggleStage }: { {c.status} {name} - {dir && {dir}/} - - - {c.add > 0 && +{c.add}} - {c.del > 0 && -{c.del}} -
) } -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 @@ -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 && }
{stagedList.length > 0 ? stagedList.map((c) => ( - )) : (
Nothing staged — use + to stage a file
@@ -138,7 +143,7 @@ export function GitPanel({ branch, changes, staged, committed, commitMsg, setCom {changesList.length > 0 && }
{changesList.length > 0 ? changesList.map((c) => ( - )) : (
All changes staged
diff --git a/src/renderer/src/editor.tsx b/src/renderer/src/editor.tsx index e726417..5cde8ea 100644 --- a/src/renderer/src/editor.tsx +++ b/src/renderer/src/editor.tsx @@ -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
, 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(null)
-  useEffect(() => {
-    const el = ref.current && ref.current.querySelector('.tab.active')
-    if (el) el.scrollIntoView({ block: 'nearest', inline: 'nearest' })
-  }, [active])
-  return (
-    
- {tabs.map((t) => { - const name = t.path.split('/').pop() - return ( -
onActivate(t.path)} - onAuxClick={(e) => { if (e.button === 1) { e.preventDefault(); onClose(t.path) } }} - title={t.path}> - - {name} - {t.changed && {t.modeLabel}} - { e.stopPropagation(); onClose(t.path) }}> - {Icon.close()} - - {t.dirty && } -
- ) - })} -
- ) -} - /* 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 ( - {!tab ? (
{Icon.file({ width: 30, height: 30 })}
diff --git a/src/renderer/src/env.d.ts b/src/renderer/src/env.d.ts index bb943e6..07a2e52 100644 --- a/src/renderer/src/env.d.ts +++ b/src/renderer/src/env.d.ts @@ -42,6 +42,10 @@ interface HelderBridge { get: () => Promise theme: () => Promise } + recent: { + get: () => Promise + set: (list: string[]) => Promise + } search: { content: (query: string) => Promise<{ path: string; hits: { no: number; ln: string; ix: number }[] }[]> files: () => Promise diff --git a/src/renderer/src/overlays.tsx b/src/renderer/src/overlays.tsx index 3a3a2be..735c42d 100644 --- a/src/renderer/src/overlays.tsx +++ b/src/renderer/src/overlays.tsx @@ -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 +}): 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(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 ( +
+
e.stopPropagation()}> +
+ {Icon.file({ style: { color: 'var(--fg-3)' } })} + Recent files + {history.length} file{history.length === 1 ? '' : 's'} · ⌘↓ ⌘↑ +
+
+ {history.length === 0 &&
No files opened yet
} + {history.map((p, i) => { + const name = p.split('/').pop() as string + const dir = p.split('/').slice(0, -1).join('/') + return ( +
setSel(i)} + onClick={() => { onOpen(p); onClose() }}> + +
+ {name} + {dir ? dir + '/' : ''} +
+ {changeSet.has(p) && } +
+ ) + })} +
+
+
+ ) +} + export function ContextMenu({ menu, onClose }: { menu: Menu | null; onClose: () => void }): React.ReactElement | null { const ref = useRef(null) useEffect(() => { diff --git a/src/renderer/src/styles.css b/src/renderer/src/styles.css index cd51716..5b7b545 100644 --- a/src/renderer/src/styles.css +++ b/src/renderer/src/styles.css @@ -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
, 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); }
diff --git a/test/app.test.tsx b/test/app.test.tsx
index bdf75b7..8ed9521 100644
--- a/test/app.test.tsx
+++ b/test/app.test.tsx
@@ -39,13 +39,13 @@ describe('App (mock data, jsdom)', () => {
     const c = renderApp()
     await waitFor(() => expect(rowWithText(c, '.git-row', 'UserController.php')).toBeTruthy())
     expect(c.querySelector('.workbench')).toBeTruthy()
-    expect(c.textContent).toContain('Source Control')
-    expect(c.textContent).toContain('Explorer')
+    expect(c.querySelector('.commit-box')).toBeTruthy() // git panel
+    expect(c.querySelector('.tree-body')).toBeTruthy()  // explorer
     // no file open yet
     expect(c.textContent).toContain('No file open')
   })
 
-  it('opens a changed file from the git panel into a diff tab', async () => {
+  it('opens a changed file from the git panel into the diff view', async () => {
     const c = renderApp()
     const row = await waitFor(() => {
       const r = rowWithText(c, '.git-row', 'UserController.php')
@@ -53,12 +53,14 @@ describe('App (mock data, jsdom)', () => {
       return r
     })
     fireEvent.click(row)
-    await waitFor(() => expect(rowWithText(c, '.tab', 'UserController.php')).toBeTruthy())
-    // changed file → diff toolbar with a status word
+    // no tabs anymore — the file opens straight into the editor's diff toolbar,
+    // and its path shows in the title-bar breadcrumb.
+    await waitFor(() => expect(c.querySelector('.diff-bar')).toBeTruthy())
     expect(c.querySelector('.diff-bar')?.textContent).toContain('Modified')
+    expect(rowWithText(c, '.tb-crumb', 'UserController.php')).toBeTruthy()
   })
 
-  it('makes an edited buffer dirty (tab dot)', async () => {
+  it('makes an edited buffer dirty (breadcrumb dot)', async () => {
     const c = renderApp()
     const treeRow = await waitFor(() => {
       const r = rowWithText(c, '.tree-row', 'store.js')
@@ -71,9 +73,9 @@ describe('App (mock data, jsdom)', () => {
       if (!t) throw new Error('editor not ready')
       return t
     })
-    expect(c.querySelector('.tab.dirtyclose')).toBeNull()
+    expect(c.querySelector('.tb-dirty')).toBeNull()
     fireEvent.change(ta, { target: { value: '// edited\n' } })
-    await waitFor(() => expect(c.querySelector('.tab.dirtyclose')).toBeTruthy())
+    await waitFor(() => expect(c.querySelector('.tb-dirty')).toBeTruthy())
   })
 
   it('searches file contents from the search modal', async () => {