From d5dbf7187dd97593da23a1cbda56b9eef3ba55a0 Mon Sep 17 00:00:00 2001 From: Jonathan van Rij Date: Tue, 16 Jun 2026 09:59:50 +0200 Subject: [PATCH] adds the launcher --- src/main/index.ts | 48 ++++++----- src/main/project.ts | 68 ++++++++++++---- src/preload/index.ts | 2 + src/renderer/src/App.tsx | 136 +++++++++++++++++++++++++++++--- src/renderer/src/components.tsx | 1 + src/renderer/src/env.d.ts | 2 + src/renderer/src/launcher.tsx | 78 ++++++++++++++++++ src/renderer/src/overlays.tsx | 99 +++++++++++++++++++---- src/renderer/src/project.tsx | 6 +- src/renderer/src/styles.css | 44 +++++++++++ 10 files changed, 419 insertions(+), 65 deletions(-) create mode 100644 src/renderer/src/launcher.tsx diff --git a/src/main/index.ts b/src/main/index.ts index 1916ccd..7591962 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,7 +1,7 @@ import { join, sep } from 'node:path' import { app, dialog, shell, BrowserWindow, ipcMain, Menu } from 'electron' import { watch, type FSWatcher } from 'chokidar' -import { getName, getRoot, openDialog } from './project' +import { addRecentProject, getName, getRecentProjects, getRoot, openDialog, setRoot } 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' @@ -39,7 +39,7 @@ function startConfigWatcher(): void { async function openFolderFlow(win: BrowserWindow | null): Promise { const next = await openDialog(win) if (!next) return false - await resolveConfig(getRoot()) + await resolveConfig(next) startWatcher() startConfigWatcher() return true @@ -91,17 +91,25 @@ function registerIpc(): void { await openFolderFlow(BrowserWindow.fromWebContents(e.sender)) return { root: getRoot(), name: getName() } }) + ipcMain.handle('projects:recent', () => getRecentProjects()) + ipcMain.handle('project:openPath', async (_e, path: string) => { + setRoot(path) + const r = getRoot() + if (r) { await resolveConfig(r); startWatcher(); startConfigWatcher() } + broadcast('project:changed') + return { root: getRoot(), name: getName() } + }) - ipcMain.handle('fs:tree', () => readTree(getRoot())) - ipcMain.handle('fs:files', () => readAll(getRoot())) - ipcMain.handle('fs:read', (_e, rel: string) => readProjectFile(getRoot(), rel)) - ipcMain.handle('fs:write', (_e, rel: string, content: string) => writeProjectFile(getRoot(), rel, content)) + ipcMain.handle('fs:tree', () => { const r = getRoot(); return r ? readTree(r) : null }) + ipcMain.handle('fs:files', () => { const r = getRoot(); return r ? readAll(r) : {} }) + ipcMain.handle('fs:read', (_e, rel: string) => { const r = getRoot(); return r ? readProjectFile(r, rel) : '' }) + ipcMain.handle('fs:write', (_e, rel: string, content: string) => { const r = getRoot(); if (r) return writeProjectFile(r, rel, content) }) - ipcMain.handle('git:load', () => load(getRoot())) - ipcMain.handle('git:stage', (_e, paths: string[]) => stage(getRoot(), paths)) - ipcMain.handle('git:unstage', (_e, paths: string[]) => unstage(getRoot(), paths)) - ipcMain.handle('git:commit', (_e, message: string) => commit(getRoot(), message)) - ipcMain.handle('git:discard', (_e, paths: string[]) => discard(getRoot(), paths)) + ipcMain.handle('git:load', () => { const r = getRoot(); return r ? load(r) : null }) + ipcMain.handle('git:stage', (_e, paths: string[]) => { const r = getRoot(); if (r) return stage(r, paths) }) + ipcMain.handle('git:unstage', (_e, paths: string[]) => { const r = getRoot(); if (r) return unstage(r, paths) }) + ipcMain.handle('git:commit', (_e, message: string) => { const r = getRoot(); if (r) return commit(r, message) }) + ipcMain.handle('git:discard', (_e, paths: string[]) => { const r = getRoot(); if (r) return discard(r, paths) }) ipcMain.handle('pty:available', () => ptyAvailable()) ipcMain.handle('pty:create', (e, kind: 'agent' | 'shell', cols: number, rows: number) => createPty(e.sender, kind, cols, rows)) @@ -112,11 +120,11 @@ 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('recent:get', () => { const r = getRoot(); return r ? getRecent(r) : [] }) + ipcMain.handle('recent:set', (_e, list: string[]) => { const r = getRoot(); if (r) return setRecent(r, list) }) - ipcMain.handle('search:content', (_e, query: string) => searchContent(getRoot(), query)) - ipcMain.handle('search:files', () => listFiles(getRoot())) + ipcMain.handle('search:content', (_e, query: string) => { const r = getRoot(); return r ? searchContent(r, query) : [] }) + ipcMain.handle('search:files', () => { const r = getRoot(); return r ? listFiles(r) : [] }) ipcMain.handle('dialog:unsavedClose', async (e, path: string) => { const win = BrowserWindow.fromWebContents(e.sender) @@ -169,9 +177,13 @@ app.whenReady().then(async () => { app.setName('Helder') Menu.setApplicationMenu(buildAppMenu()) registerIpc() - await resolveConfig(getRoot()) - startWatcher() - startConfigWatcher() + const initialRoot = getRoot() + if (initialRoot) { + await resolveConfig(initialRoot) + await addRecentProject(initialRoot) + startWatcher() + startConfigWatcher() + } createWindow() app.on('activate', () => { diff --git a/src/main/project.ts b/src/main/project.ts index d4025b5..42e1561 100644 --- a/src/main/project.ts +++ b/src/main/project.ts @@ -1,40 +1,43 @@ -import { basename, resolve } from 'node:path' +import { basename, join, resolve } from 'node:path' import { existsSync, statSync } from 'node:fs' -import { dialog, BrowserWindow } from 'electron' +import { readFile, writeFile } from 'node:fs/promises' +import { app, 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. + * One project per window. The root is resolved (in order) from $HELDER_PROJECT or + * a directory passed on argv; otherwise it starts as `null` — the app then shows + * the project launcher (Spotlight / bare launch with no folder). It can be set at + * runtime from the launcher or 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. + * The bare "." that `electron .` passes in dev is intentionally ignored, so dev + * launches land on the launcher too (and `basename(".")` never leaks as a name). */ -function resolveInitialRoot(): string { +function resolveInitialRoot(): string | null { const envRoot = process.env.HELDER_PROJECT - 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 (envRoot && safeIsDir(envRoot)) return resolve(envRoot) + const argDir = process.argv.slice(1).find((a) => a !== '.' && !a.startsWith('-') && safeIsDir(a)) if (argDir) return resolve(argDir) - return process.cwd() + return null } function safeIsDir(p: string): boolean { try { return statSync(p).isDirectory() } catch { return false } } -let root = resolveInitialRoot() +let root: string | null = resolveInitialRoot() -export function getRoot(): string { +export function getRoot(): string | null { return root } export function getName(): string { - return root ? basename(root) || root : 'no project' + return root ? basename(root) || root : '' } +/** Point the window at a project root (absolute) and remember it in recents. */ export function setRoot(next: string): void { root = resolve(next) + addRecentProject(root).catch(() => {}) } export async function openDialog(win: BrowserWindow | null): Promise { @@ -42,8 +45,41 @@ export async function openDialog(win: BrowserWindow | null): Promise { + try { + const arr = JSON.parse(await readFile(projectsFile(), 'utf8')) + if (!Array.isArray(arr)) return [] + return arr + .filter((p): p is RecentProject => !!p && typeof p.path === 'string') + .filter((p) => existsSync(p.path)) // drop projects that no longer exist + .slice(0, MAX_PROJECTS) + } catch { + return [] + } +} + +export async function addRecentProject(path: string): Promise { + try { + const abs = resolve(path) + const list = await getRecentProjects() + const entry: RecentProject = { path: abs, name: basename(abs) || abs } + const next = [entry, ...list.filter((x) => x.path !== abs)].slice(0, MAX_PROJECTS) + await writeFile(projectsFile(), JSON.stringify(next, null, 2) + '\n') + } catch { + /* userData unwritable — recents just won't persist */ + } +} diff --git a/src/preload/index.ts b/src/preload/index.ts index a48f0bc..ec3af41 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -15,6 +15,8 @@ const api = { project: { current: () => ipcRenderer.invoke('project:current'), open: () => ipcRenderer.invoke('project:open'), + openPath: (path: string) => ipcRenderer.invoke('project:openPath', path), + recent: (): Promise<{ path: string; name: string }[]> => ipcRenderer.invoke('projects:recent'), }, fs: { diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 3c717fc..362d0bf 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -5,7 +5,8 @@ import type { ContextTarget } from './components' import { Editor, SplitView } from './editor' import type { Cursor, Mode, Selection } from './editor' import { Terminal, lid } from './terminals' -import { ContextMenu, HistoryModal, PassPopup, SearchModal, Toasts } from './overlays' +import { ContextMenu, HelpModal, HistoryModal, PassPopup, SearchModal, Toasts } from './overlays' +import { ProjectLauncher } from './launcher' import type { Menu, Toast } from './overlays' import type { FileNode, GitStatus } from './types' import { useProject, useProjectActions } from './project' @@ -84,7 +85,8 @@ export function App(): React.ReactElement { const [openDirs, setOpenDirs] = useState>(new Set()) const [cursor, setCursor] = useState(null) const [selection, setSelection] = useState(null) - const [overlay, setOverlay] = useState<'search' | 'history' | null>(null) + const [overlay, setOverlay] = useState<'search' | 'history' | 'help' | null>(null) + const [searchInit, setSearchInit] = useState('') // seed query for ⌘F-with-selection // Most-recently-opened files, newest first, de-duplicated. Drives the ⌘↓/⌘↑ navigator. const [history, setHistory] = useState([]) const [histInitSel, setHistInitSel] = useState(0) @@ -280,18 +282,33 @@ export function App(): React.ReactElement { setTabMode((m) => ({ ...m, [path]: openMode })) reveal(path) if (opts.line) { - // show the current/updated file so line numbers map to search hits + // The updated/code views render in the CodeEditor (a textarea over a
),
+      // so scroll its container to centre the target line. Line height is 20px with
+      // a 6px top pad; retry across a few frames until the content has rendered (the
+      // file may still be loading for a freshly-opened unchanged file).
       setSplitFor(null)
       setTabMode((m) => ({ ...m, [path]: changed ? 'updated' : 'code' }))
       setCursor({ path, line: opts.line, col: 1 })
       setSelection(null)
-      setTimeout(() => {
-        const row = document.querySelector('.editor .ln-row[data-line="' + opts.line + '"]') as HTMLElement | null
-        if (row) { const ed = row.closest('.editor') as HTMLElement; const er = ed.getBoundingClientRect(), rr = row.getBoundingClientRect(); ed.scrollTop += (rr.top - er.top) - ed.clientHeight / 2 }
-      }, 70)
+      scrollEditorToLine(opts.line)
     }
   }
 
+  function scrollEditorToLine(line: number): void {
+    const top = 6 + (line - 1) * 20
+    let tries = 0
+    const place = (): void => {
+      const sc = document.querySelector('.ce-scroll')
+      if (sc) {
+        sc.scrollTop = Math.max(0, top - sc.clientHeight / 2)
+        // stop once the content is tall enough to actually reach the line
+        if (sc.scrollHeight >= top + 4 || tries >= 40) return
+      }
+      if (tries++ < 40) requestAnimationFrame(place)
+    }
+    requestAnimationFrame(place)
+  }
+
   // 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 {
@@ -349,32 +366,123 @@ export function App(): React.ReactElement {
     }
   }
 
+  // ⌘M cycles the active changed file through the four view modes.
+  function cycleMode(): void {
+    if (!active || !proj.diffs[active]) return
+    const order: (Mode | 'split')[] = ['updated', 'original', 'diff', 'split']
+    const cur = splitFor === active ? 'split' : (tabMode[active] || defaultMode)
+    const next = order[(order.indexOf(cur) + 1) % order.length]
+    if (next === 'split') setSplitFor(active)
+    else { setTabMode((m) => ({ ...m, [active]: next as Mode })); setSplitFor(null) }
+  }
+  function focusCommit(): void {
+    document.querySelector('.commit-input')?.focus()
+  }
+  // Current selection as a single-line search seed (textarea selection or DOM selection).
+  function selectedSearchText(): string {
+    const ae = document.activeElement as HTMLTextAreaElement | null
+    const raw = (ae && (ae.tagName === 'TEXTAREA' || ae.tagName === 'INPUT') && ae.selectionStart !== ae.selectionEnd)
+      ? ae.value.slice(ae.selectionStart, ae.selectionEnd)
+      : (window.getSelection()?.toString() ?? '')
+    return raw.split('\n')[0].trim()
+  }
+  // ⌘→ with a selection: open Pass-on-to-Agent for the selected line range.
+  // Returns true when a selection was found (so we can swallow the key).
+  function passSelection(): boolean {
+    if (!active) return false
+    const ae = document.activeElement as HTMLTextAreaElement | null
+    // CodeEditor (updated / code) — the live selection lives in the textarea.
+    if (ae && ae.classList.contains('ce-ta') && ae.selectionStart !== ae.selectionEnd) {
+      const v = ae.value
+      const s = v.slice(0, ae.selectionStart).split('\n').length
+      const en = v.slice(0, ae.selectionEnd).split('\n').length
+      const ref = s === en ? `${active}:${s}` : `${active}:${s}-${en}`
+      const r = ae.getBoundingClientRect()
+      setPassPopup({ x: r.left + 60, y: r.top + 70, ref })
+      return true
+    }
+    // Diff / Original (PaneView) — line range tracked in `selection` state.
+    if (selection && selection.path === active && selection.start !== selection.end) {
+      const ref = `${active}:${selection.start}-${selection.end}`
+      const dom = window.getSelection()
+      let x = window.innerWidth / 2, y = 150
+      if (dom && dom.rangeCount && !dom.isCollapsed) {
+        const rr = dom.getRangeAt(0).getBoundingClientRect()
+        if (rr.width || rr.height) { x = rr.left; y = rr.bottom + 6 }
+      }
+      setPassPopup({ x, y, ref })
+      return true
+    }
+    return false
+  }
+  function hasSelection(): boolean {
+    if ((window.getSelection()?.toString() ?? '') !== '') return true
+    const ae = document.activeElement as HTMLInputElement | HTMLTextAreaElement | null
+    return !!ae && (ae.tagName === 'TEXTAREA' || ae.tagName === 'INPUT') && ae.selectionStart !== ae.selectionEnd
+  }
+
   // ---- shortcuts ----
   useEffect(() => {
     function onKey(e: KeyboardEvent): void {
+      if (!proj.root) return // launcher screen owns its own keyboard
       const meta = e.metaKey || e.ctrlKey
+      const ae = document.activeElement as HTMLElement | null
+      const inField = !!ae && (ae.tagName === 'INPUT' || ae.tagName === 'TEXTAREA')
       // The history navigator owns the keyboard while open (it listens in capture phase).
       if (overlay === 'history') return
+      if (e.key === 'Escape') {
+        if (splitFor) setSplitFor(null)
+        else if (overlay) setOverlay(null)
+        else setMenu(null)
+        return
+      }
+      // Search / help modals own the keyboard while open (they handle their own keys).
+      if (overlay) 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).
-      else if (meta && (e.key.toLowerCase() === 'f' || e.key.toLowerCase() === 'p')) { e.preventDefault(); setOverlay('search') }
+      // ⌘F and ⌘P both open the unified search (it covers file names too), seeded
+      // with the current selection when there is one.
+      else if (meta && (e.key.toLowerCase() === 'f' || e.key.toLowerCase() === 'p')) { e.preventDefault(); setSearchInit(selectedSearchText()); 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) } }
+      else if (meta && e.key.toLowerCase() === 'm') { e.preventDefault(); cycleMode() }
+      // ⌘→ with a text selection passes that selection to the agent (else native nav).
+      else if (meta && e.key === 'ArrowRight') { if (passSelection()) e.preventDefault() }
+      // ⌘↵ commits the staged files (unless the commit box has focus — it handles ⇧/⌘↵ itself).
+      else if (meta && e.key === 'Enter') {
+        if (ae && ae.classList.contains('commit-input')) return
+        e.preventDefault()
+        if (commitMsg.trim() && proj.changes.some((c) => proj.staged.has(c.path))) commit()
+      }
+      // ⌘C focuses the commit message (but let native copy run when there's a selection).
+      else if (meta && e.key.toLowerCase() === 'c') {
+        if (hasSelection()) return
+        e.preventDefault(); focusCommit()
+      }
+      // ⌘A toggles auto-fit (but let native select-all run inside text fields).
+      else if (meta && e.key.toLowerCase() === 'a') {
+        if (inField) return
+        e.preventDefault(); setAutoResize((v) => !v)
+      }
     }
     window.addEventListener('keydown', onKey)
     return () => window.removeEventListener('keydown', onKey)
-  }, [active, splitFor, overlay, focusZone, history])
+  }, [active, splitFor, overlay, focusZone, history, tabMode, commitMsg, proj, selection])
 
   const mode: Mode = (active && tabMode[active]) || (active && proj.diffs[active] ? defaultMode : 'code')
 
   const crumb = active ? active.split('/') : []
 
+  // No project yet (launched via Spotlight / bare) → show the project launcher.
+  if (proj.ready && !proj.root) {
+    return  actions.openFolder()} onOpenPath={(p) => actions.openProjectPath(p)} />
+  }
+
   return (
     
{/* title bar */} @@ -395,11 +503,12 @@ export function App(): React.ReactElement {
- + +
@@ -463,8 +572,9 @@ export function App(): React.ReactElement { toast('Passed to agent', passPopup.ref) }} onCancel={() => setPassPopup(null)} />} - {overlay === 'search' && openFile(p, { line: n })} onClose={() => setOverlay(null)} changeSet={changeSet} />} + {overlay === 'search' && openFile(p, { line: n })} onClose={() => setOverlay(null)} changeSet={changeSet} />} {overlay === 'history' && setOverlay(null)} changeSet={changeSet} />} + {overlay === 'help' && setOverlay(null)} />} {menu && setMenu(null)} />} diff --git a/src/renderer/src/components.tsx b/src/renderer/src/components.tsx index 978576f..6048fff 100644 --- a/src/renderer/src/components.tsx +++ b/src/renderer/src/components.tsx @@ -21,6 +21,7 @@ export const Icon: Record React.ReactElement> = { check: (p) => (), discard: (p) => (), layout: (p) => (), + help: (p) => (), } export const Chevron = ({ open }: { open: boolean }): React.ReactElement => ( diff --git a/src/renderer/src/env.d.ts b/src/renderer/src/env.d.ts index 07a2e52..25d5d62 100644 --- a/src/renderer/src/env.d.ts +++ b/src/renderer/src/env.d.ts @@ -15,6 +15,8 @@ interface HelderBridge { project: { current: () => Promise<{ root: string | null; name: string }> open: () => Promise<{ root: string | null; name: string }> + openPath: (path: string) => Promise<{ root: string | null; name: string }> + recent: () => Promise<{ path: string; name: string }[]> } fs: { tree: () => Promise diff --git a/src/renderer/src/launcher.tsx b/src/renderer/src/launcher.tsx new file mode 100644 index 0000000..22a7d60 --- /dev/null +++ b/src/renderer/src/launcher.tsx @@ -0,0 +1,78 @@ +/* Project launcher — shown when Helder starts without a project (Spotlight / bare + * launch). Lists the recent projects (max 20, newest first); the first row opens a + * folder picker for a new project. Keyboard: ⌘↑/⌘↓ (or plain arrows) move the + * selection, ↵ opens it — same model as the recent-files navigator. */ +import React, { useEffect, useRef, useState } from 'react' +import { Icon } from './components' + +interface RecentProject { path: string; name: string } + +export function ProjectLauncher({ onOpenNew, onOpenPath }: { + onOpenNew: () => void + onOpenPath: (path: string) => void +}): React.ReactElement { + const [recents, setRecents] = useState([]) + const [sel, setSel] = useState(0) + const selRef = useRef(sel); selRef.current = sel + const listRef = useRef(null) + + // rows = [new project, ...recents]; total selectable count + const count = recents.length + 1 + + useEffect(() => { + const bridge = window.helder + if (bridge) bridge.project.recent().then(setRecents).catch(() => setRecents([])) + }, []) + + function activate(i: number): void { + if (i <= 0) onOpenNew() + else if (recents[i - 1]) onOpenPath(recents[i - 1].path) + } + + useEffect(() => { + function onKey(e: KeyboardEvent): void { + if (e.key === 'ArrowDown') { e.preventDefault(); setSel((s) => Math.min(s + 1, count - 1)) } + else if (e.key === 'ArrowUp') { e.preventDefault(); setSel((s) => Math.max(s - 1, 0)) } + else if (e.key === 'Enter') { e.preventDefault(); activate(selRef.current) } + } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, [count, recents]) + + useEffect(() => { + const el = listRef.current && listRef.current.querySelector('.lp-row.sel') + if (el) el.scrollIntoView({ block: 'nearest' }) + }, [sel]) + + return ( +
+
+
+
+ {Icon.spark({ width: 22, height: 22, style: { color: 'var(--accent)' } })} +
HelderOpen a project to begin
+
+
+
setSel(0)} onClick={() => activate(0)}> + {Icon.plus()} +
Open new project…Choose a folder
+ +
+ {recents.length > 0 &&
Recent
} + {recents.map((p, i) => { + const idx = i + 1 + return ( +
setSel(idx)} onClick={() => activate(idx)}> + {Icon.reveal()} +
{p.name}{p.path}
+
+ ) + })} +
+
⌘↓ ⌘↑ navigate · open
+
+
+ ) +} diff --git a/src/renderer/src/overlays.tsx b/src/renderer/src/overlays.tsx index 735c42d..eca0481 100644 --- a/src/renderer/src/overlays.tsx +++ b/src/renderer/src/overlays.tsx @@ -24,7 +24,8 @@ function Highlight({ text, idx }: { text: string; idx: number[] | null }): React return {text.split('').map((ch, i) => set.has(i) ? {ch} : {ch})} } -export function SearchModal({ onOpen, onOpenAt, onClose, changeSet }: { +export function SearchModal({ initialQuery, onOpen, onOpenAt, onClose, changeSet }: { + initialQuery?: string onOpen: OpenFile onOpenAt: (path: string, line: number) => void onClose: () => void @@ -32,15 +33,18 @@ export function SearchModal({ onOpen, onOpenAt, onClose, changeSet }: { }): React.ReactElement { const PROJECT = useProject() const bridge = window.helder - const [q, setQ] = useState('') + const [q, setQ] = useState(() => initialQuery ?? '') const [sel, setSel] = useState(0) + const [fileSel, setFileSel] = useState(0) + const [col, setCol] = useState<'content' | 'files'>('content') // active result column (⌘← / ⌘→) const inputRef = useRef(null) const leftRef = useRef(null) + const rightRef = useRef(null) // file-name list: ripgrep `--files` when available, else the in-memory index keys const [allPaths, setAllPaths] = useState(() => (bridge ? [] : Object.keys(PROJECT.files))) useEffect(() => { - if (inputRef.current) inputRef.current.focus() + if (inputRef.current) { inputRef.current.focus(); inputRef.current.select() } // select seed so typing replaces it if (bridge) bridge.search.files().then((f) => setAllPaths(f.length ? f : Object.keys(PROJECT.files))).catch(() => setAllPaths(Object.keys(PROJECT.files))) }, []) @@ -95,19 +99,37 @@ export function SearchModal({ onOpen, onOpenAt, onClose, changeSet }: { }, [content]) const totalHits = flat.length - useEffect(() => { setSel(0) }, [q]) + const fileCount = Math.min(files.length, 40) + useEffect(() => { setSel(0); setFileSel(0) }, [q]) useEffect(() => { const el = leftRef.current && leftRef.current.querySelector('.sr-line.sel') if (el) el.scrollIntoView({ block: 'nearest' }) }, [sel]) + useEffect(() => { + const el = rightRef.current && rightRef.current.querySelector('.fres.sel') + if (el) el.scrollIntoView({ block: 'nearest' }) + }, [fileSel]) function onKey(e: React.KeyboardEvent): void { - if (e.key === 'ArrowDown') { e.preventDefault(); setSel((s) => Math.min(s + 1, flat.length - 1)) } - else if (e.key === 'ArrowUp') { e.preventDefault(); setSel((s) => Math.max(s - 1, 0)) } - else if (e.key === 'Enter') { + if ((e.metaKey || e.ctrlKey) && e.key === 'ArrowLeft') { e.preventDefault(); setCol('content'); return } + if ((e.metaKey || e.ctrlKey) && e.key === 'ArrowRight') { e.preventDefault(); setCol('files'); return } + if (e.key === 'ArrowDown') { e.preventDefault() - if (flat[sel]) { onOpenAt(flat[sel].path, flat[sel].no); onClose() } - else if (files[0]) { onOpen(files[0].path); onClose() } + if (col === 'files') setFileSel((s) => Math.min(s + 1, fileCount - 1)) + else setSel((s) => Math.min(s + 1, flat.length - 1)) + } else if (e.key === 'ArrowUp') { + e.preventDefault() + if (col === 'files') setFileSel((s) => Math.max(s - 1, 0)) + else setSel((s) => Math.max(s - 1, 0)) + } else if (e.key === 'Enter') { + e.preventDefault() + if (col === 'files') { + if (files[fileSel]) { onOpen(files[fileSel].path); onClose() } + else if (flat[sel]) { onOpenAt(flat[sel].path, flat[sel].no); onClose() } + } else { + if (flat[sel]) { onOpenAt(flat[sel].path, flat[sel].no); onClose() } + else if (files[0]) { onOpen(files[0].path); onClose() } + } } else if (e.key === 'Escape') { e.preventDefault(); onClose() } } @@ -129,8 +151,8 @@ export function SearchModal({ onOpen, onOpenAt, onClose, changeSet }: { {totalHits} hit{totalHits === 1 ? '' : 's'} · {files.length} file{files.length === 1 ? '' : 's'}
-
-
Content {totalHits > 0 && {totalHits}}
+
+
Content {totalHits > 0 && {totalHits}} ⌘←
{term.length < 2 &&
Type at least 2 characters
} {term.length >= 2 && content.length === 0 &&
No content matches
} {content.map((g) => ( @@ -144,8 +166,8 @@ export function SearchModal({ onOpen, onOpenAt, onClose, changeSet }: { flatIx++ const me = flatIx return ( -
setSel(me)} +
{ setCol('content'); setSel(me) }} onClick={() => { onOpenAt(g.path, h.no); onClose() }}> {h.no} {renderLine(h.ln, h.ix, term.length)} @@ -155,15 +177,17 @@ export function SearchModal({ onOpen, onOpenAt, onClose, changeSet }: { ))}
-
-
Files {files.length > 0 && {files.length}}
+
+
Files {files.length > 0 && {files.length}} ⌘→
{!term &&
Start typing…
} {term && files.length === 0 &&
No file names match
} - {files.slice(0, 40).map((r) => { + {files.slice(0, 40).map((r, i) => { const name = r.path.split('/').pop() as string const dir = r.path.split('/').slice(0, -1).join('/') return ( -
{ onOpen(r.path); onClose() }} title={r.path}> +
{ setCol('files'); setFileSel(i) }} + onClick={() => { onOpen(r.path); onClose() }} title={r.path}>
@@ -242,6 +266,47 @@ export function HistoryModal({ history, initialSel, onOpen, onClose, changeSet } ) } +/* Keyboard-shortcuts reference (opened from the title-bar ? button). */ +const SHORTCUTS: { keys: string[]; label: string }[] = [ + { keys: ['⌘', 'F'], label: 'Search contents & names (seeded by selection)' }, + { keys: ['⌘', '↑'], label: 'Navigate a list up' }, + { keys: ['⌘', '↓'], label: 'Navigate a list down' }, + { keys: ['↵'], label: 'Open the selected list item' }, + { keys: ['⌘', '←'], label: 'Search: focus the content results' }, + { keys: ['⌘', '→'], label: 'Search: focus the file-name results' }, + { keys: ['⌘', '→'], label: 'Pass the selected text to the agent' }, + { keys: ['⌘', 'M'], label: 'Cycle view: Updated · Original · Diff · Split' }, + { keys: ['⌘', 'C'], label: 'Focus the commit message' }, + { keys: ['⌘', '↵'], label: 'Commit the staged files' }, + { keys: ['⌘', 'A'], label: 'Toggle auto-fit panels' }, + { keys: ['⌘', 'S'], label: 'Save the current file' }, + { keys: ['⌘', 'W'], label: 'Close the current file' }, + { keys: ['⌘', 'O'], label: 'Open a project folder' }, + { keys: ['Esc'], label: 'Close an overlay / split view' }, +] + +export function HelpModal({ onClose }: { onClose: () => void }): React.ReactElement { + return ( +
+
e.stopPropagation()}> +
+ {Icon.help({ style: { color: 'var(--fg-3)' } })} + Keyboard shortcuts + esc +
+
+ {SHORTCUTS.map((s, i) => ( +
+ {s.keys.map((k, j) => {k})} + {s.label} +
+ ))} +
+
+
+ ) +} + export function ContextMenu({ menu, onClose }: { menu: Menu | null; onClose: () => void }): React.ReactElement | null { const ref = useRef(null) useEffect(() => { diff --git a/src/renderer/src/project.tsx b/src/renderer/src/project.tsx index 7439135..8c01508 100644 --- a/src/renderer/src/project.tsx +++ b/src/renderer/src/project.tsx @@ -35,6 +35,7 @@ function applyTheme(css: string): void { export interface ProjectActions { openFolder: () => void + openProjectPath: (path: string) => void refresh: () => void stage: (path: string) => void unstage: (path: string) => void @@ -49,7 +50,8 @@ const MOCK_STAGED = ['src/Service/PaymentService.php', 'config/app.json'] function mockData(): ProjectData { return { - name: MOCK.name, root: null, branch: MOCK.branch, + // non-null root so browser-preview shows the workbench, not the launcher + name: MOCK.name, root: '/mock/' + MOCK.name, branch: MOCK.branch, tree: MOCK.tree, files: MOCK.files, changes: MOCK.changes, diffs: MOCK.diffs, staged: new Set(MOCK_STAGED), config: DEFAULT_CONFIG, isRepo: true, ready: true, } @@ -129,6 +131,7 @@ export function ProjectProvider({ children }: { children: React.ReactNode }): Re setData((d) => ({ ...d, staged: fn(new Set(d.staged)) })) return { openFolder: () => {}, + openProjectPath: () => {}, refresh: () => setData(mockData()), stage: (p) => setStaged((s) => (s.add(p), s)), unstage: (p) => setStaged((s) => (s.delete(p), s)), @@ -152,6 +155,7 @@ export function ProjectProvider({ children }: { children: React.ReactNode }): Re const after = (op: Promise): void => { op.then(() => loadReal()).catch(() => {}) } return { openFolder: () => { bridge.project.open().then(() => loadReal()).catch(() => {}) }, + openProjectPath: (path) => { bridge.project.openPath(path).then(() => loadReal()).catch(() => {}) }, refresh: () => { loadReal().catch(() => {}) }, stage: (p) => after(bridge.git.stage([p])), unstage: (p) => after(bridge.git.unstage([p])), diff --git a/src/renderer/src/styles.css b/src/renderer/src/styles.css index 5b7b545..e642554 100644 --- a/src/renderer/src/styles.css +++ b/src/renderer/src/styles.css @@ -439,3 +439,47 @@ body { @media (prefers-reduced-motion: reduce) { * { animation-duration: 0.001ms !important; animation-iteration-count: 1 !important; transition-duration: 0.001ms !important; } } + +/* ============ project launcher (no project open) ============ */ +.launcher { position:fixed; inset:0; background:var(--bg-0); display:flex; align-items:center; justify-content:center; } +.launcher-drag { position:absolute; top:0; left:0; right:0; height:40px; -webkit-app-region:drag; } +.launcher-card { width:560px; max-width:92vw; max-height:80vh; display:flex; flex-direction:column; background:var(--bg-2); border:1px solid var(--border-2); border-radius:14px; box-shadow:0 28px 80px rgba(0,0,0,.55); overflow:hidden; -webkit-app-region:no-drag; } +.lp-head { display:flex; align-items:center; gap:12px; padding:18px 20px; border-bottom:1px solid var(--border); } +.lp-title { display:flex; flex-direction:column; line-height:1.3; } +.lp-title b { font-size:16px; color:var(--fg-0); font-weight:600; } +.lp-title span { font-size:12px; color:var(--fg-3); } +.lp-list { overflow:auto; padding:6px; flex:1; min-height:0; } +.lp-sec { padding:10px 10px 4px; font-size:10px; letter-spacing:.07em; text-transform:uppercase; color:var(--fg-3); } +.lp-row { display:flex; align-items:center; gap:11px; padding:9px 10px; border-radius:8px; cursor:pointer; } +.lp-row:hover { background:var(--hover); } +.lp-row.sel { background:var(--accent-soft); box-shadow:inset 2px 0 0 var(--accent); } +.lp-ic { flex:0 0 auto; width:22px; height:22px; display:flex; align-items:center; justify-content:center; color:var(--fg-2); } +.lp-new .lp-ic { color:var(--accent); } +.lp-txt { min-width:0; display:flex; flex-direction:column; line-height:1.3; flex:1; } +.lp-name { font-size:13px; color:var(--fg-0); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } +.lp-path { font-size:11px; color:var(--fg-3); font-family:var(--mono); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; direction:rtl; text-align:left; } +.lp-row kbd { font-family:var(--mono); font-size:10px; color:var(--fg-3); border:1px solid var(--border-2); border-radius:4px; padding:1px 5px; flex:0 0 auto; } +.lp-foot { padding:10px 20px; border-top:1px solid var(--border); font-size:10.5px; color:var(--fg-3); } +.lp-foot kbd { font-family:var(--mono); font-size:10px; color:var(--fg-2); border:1px solid var(--border-2); border-radius:4px; padding:0 5px; } + +/* ============ keyboard-shortcuts (help) modal ============ */ +.help-modal { width:520px; 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; } +.help-modal .pi { display:flex; align-items:center; gap:10px; padding:12px 15px; border-bottom:1px solid var(--border); } +.help-modal .pi svg { flex:0 0 auto; } +.help-modal .hist-title { flex:1; min-width:0; color:var(--fg-1); font-size:14px; } +.help-modal .mode-chip { flex:0 0 auto; font-family:var(--mono); font-size:10px; color:var(--fg-3); border:1px solid var(--border-2); border-radius:5px; padding:2px 7px; } +.help-list { max-height:62vh; overflow:auto; padding:8px 6px; } +.help-row { display:flex; align-items:center; gap:14px; padding:6px 12px; border-radius:7px; } +.help-row:hover { background:var(--hover); } +.help-keys { flex:0 0 96px; display:flex; gap:4px; justify-content:flex-end; } +.help-keys kbd { font-family:var(--mono); font-size:11px; color:var(--fg-1); background:var(--bg-1); border:1px solid var(--border-2); border-radius:5px; padding:2px 7px; min-width:20px; text-align:center; } +.help-label { font-size:12.5px; color:var(--fg-2); } + +/* title-bar icon-only button (help ?) */ +.tb-icon { padding:5px 7px; } + +/* search: active result column + file-name selection */ +.sc-head .col-kbd { margin-left:auto; font-family:var(--mono); font-size:9.5px; color:var(--fg-3); border:1px solid var(--border-2); border-radius:4px; padding:0 4px; opacity:.55; } +.sc-left.active .sc-head, .sc-right.active .sc-head { color:var(--accent); } +.sc-left.active .sc-head .col-kbd, .sc-right.active .sc-head .col-kbd { color:var(--accent); border-color:var(--accent-line); opacity:1; } +.fres.sel { background:var(--accent-soft); box-shadow:inset 2px 0 0 var(--accent); }