adds the launcher
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
import { join, sep } from 'node:path'
|
import { join, sep } from 'node:path'
|
||||||
import { app, dialog, shell, BrowserWindow, ipcMain, Menu } from 'electron'
|
import { app, dialog, shell, BrowserWindow, ipcMain, Menu } from 'electron'
|
||||||
import { watch, type FSWatcher } from 'chokidar'
|
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 { readAll, readProjectFile, readTree, writeProjectFile } from './fs-service'
|
||||||
import { commit, discard, load, stage, unstage } from './git-service'
|
import { commit, discard, load, stage, unstage } from './git-service'
|
||||||
import { createPty, killAllPtys, killPty, ptyAvailable, resizePty, writePty } from './pty-service'
|
import { createPty, killAllPtys, killPty, ptyAvailable, resizePty, writePty } from './pty-service'
|
||||||
@@ -39,7 +39,7 @@ function startConfigWatcher(): void {
|
|||||||
async function openFolderFlow(win: BrowserWindow | null): Promise<boolean> {
|
async function openFolderFlow(win: BrowserWindow | null): Promise<boolean> {
|
||||||
const next = await openDialog(win)
|
const next = await openDialog(win)
|
||||||
if (!next) return false
|
if (!next) return false
|
||||||
await resolveConfig(getRoot())
|
await resolveConfig(next)
|
||||||
startWatcher()
|
startWatcher()
|
||||||
startConfigWatcher()
|
startConfigWatcher()
|
||||||
return true
|
return true
|
||||||
@@ -91,17 +91,25 @@ function registerIpc(): void {
|
|||||||
await openFolderFlow(BrowserWindow.fromWebContents(e.sender))
|
await openFolderFlow(BrowserWindow.fromWebContents(e.sender))
|
||||||
return { root: getRoot(), name: getName() }
|
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:tree', () => { const r = getRoot(); return r ? readTree(r) : null })
|
||||||
ipcMain.handle('fs:files', () => readAll(getRoot()))
|
ipcMain.handle('fs:files', () => { const r = getRoot(); return r ? readAll(r) : {} })
|
||||||
ipcMain.handle('fs:read', (_e, rel: string) => readProjectFile(getRoot(), rel))
|
ipcMain.handle('fs:read', (_e, rel: string) => { const r = getRoot(); return r ? readProjectFile(r, rel) : '' })
|
||||||
ipcMain.handle('fs:write', (_e, rel: string, content: string) => writeProjectFile(getRoot(), rel, content))
|
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:load', () => { const r = getRoot(); return r ? load(r) : null })
|
||||||
ipcMain.handle('git:stage', (_e, paths: string[]) => stage(getRoot(), paths))
|
ipcMain.handle('git:stage', (_e, paths: string[]) => { const r = getRoot(); if (r) return stage(r, paths) })
|
||||||
ipcMain.handle('git:unstage', (_e, paths: string[]) => unstage(getRoot(), paths))
|
ipcMain.handle('git:unstage', (_e, paths: string[]) => { const r = getRoot(); if (r) return unstage(r, paths) })
|
||||||
ipcMain.handle('git:commit', (_e, message: string) => commit(getRoot(), message))
|
ipcMain.handle('git:commit', (_e, message: string) => { const r = getRoot(); if (r) return commit(r, message) })
|
||||||
ipcMain.handle('git:discard', (_e, paths: string[]) => discard(getRoot(), paths))
|
ipcMain.handle('git:discard', (_e, paths: string[]) => { const r = getRoot(); if (r) return discard(r, paths) })
|
||||||
|
|
||||||
ipcMain.handle('pty:available', () => ptyAvailable())
|
ipcMain.handle('pty:available', () => ptyAvailable())
|
||||||
ipcMain.handle('pty:create', (e, kind: 'agent' | 'shell', cols: number, rows: number) => createPty(e.sender, kind, cols, rows))
|
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:get', () => getConfig())
|
||||||
ipcMain.handle('config:theme', () => getThemeCss())
|
ipcMain.handle('config:theme', () => getThemeCss())
|
||||||
|
|
||||||
ipcMain.handle('recent:get', () => getRecent(getRoot()))
|
ipcMain.handle('recent:get', () => { const r = getRoot(); return r ? getRecent(r) : [] })
|
||||||
ipcMain.handle('recent:set', (_e, list: string[]) => setRecent(getRoot(), list))
|
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:content', (_e, query: string) => { const r = getRoot(); return r ? searchContent(r, query) : [] })
|
||||||
ipcMain.handle('search:files', () => listFiles(getRoot()))
|
ipcMain.handle('search:files', () => { const r = getRoot(); return r ? listFiles(r) : [] })
|
||||||
|
|
||||||
ipcMain.handle('dialog:unsavedClose', async (e, path: string) => {
|
ipcMain.handle('dialog:unsavedClose', async (e, path: string) => {
|
||||||
const win = BrowserWindow.fromWebContents(e.sender)
|
const win = BrowserWindow.fromWebContents(e.sender)
|
||||||
@@ -169,9 +177,13 @@ app.whenReady().then(async () => {
|
|||||||
app.setName('Helder')
|
app.setName('Helder')
|
||||||
Menu.setApplicationMenu(buildAppMenu())
|
Menu.setApplicationMenu(buildAppMenu())
|
||||||
registerIpc()
|
registerIpc()
|
||||||
await resolveConfig(getRoot())
|
const initialRoot = getRoot()
|
||||||
|
if (initialRoot) {
|
||||||
|
await resolveConfig(initialRoot)
|
||||||
|
await addRecentProject(initialRoot)
|
||||||
startWatcher()
|
startWatcher()
|
||||||
startConfigWatcher()
|
startConfigWatcher()
|
||||||
|
}
|
||||||
createWindow()
|
createWindow()
|
||||||
|
|
||||||
app.on('activate', () => {
|
app.on('activate', () => {
|
||||||
|
|||||||
@@ -1,40 +1,43 @@
|
|||||||
import { basename, resolve } from 'node:path'
|
import { basename, join, resolve } from 'node:path'
|
||||||
import { existsSync, statSync } from 'node:fs'
|
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,
|
* One project per window. The root is resolved (in order) from $HELDER_PROJECT or
|
||||||
* a directory passed on argv, or the process working directory — then it can be
|
* a directory passed on argv; otherwise it starts as `null` — the app then shows
|
||||||
* changed at runtime via the Open Folder dialog.
|
* 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
|
* The bare "." that `electron .` passes in dev is intentionally ignored, so dev
|
||||||
* argv carries a bare "." — `basename(".")` is "." (the repo name would show as a
|
* launches land on the launcher too (and `basename(".")` never leaks as a name).
|
||||||
* lone dot). `resolve()` turns it into the real directory before we name it.
|
|
||||||
*/
|
*/
|
||||||
function resolveInitialRoot(): string {
|
function resolveInitialRoot(): string | null {
|
||||||
const envRoot = process.env.HELDER_PROJECT
|
const envRoot = process.env.HELDER_PROJECT
|
||||||
if (envRoot && existsSync(envRoot) && statSync(envRoot).isDirectory()) return resolve(envRoot)
|
if (envRoot && safeIsDir(envRoot)) return resolve(envRoot)
|
||||||
const argDir = process.argv.slice(1).find((a) => !a.startsWith('-') && existsSync(a) && safeIsDir(a))
|
const argDir = process.argv.slice(1).find((a) => a !== '.' && !a.startsWith('-') && safeIsDir(a))
|
||||||
if (argDir) return resolve(argDir)
|
if (argDir) return resolve(argDir)
|
||||||
return process.cwd()
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
function safeIsDir(p: string): boolean {
|
function safeIsDir(p: string): boolean {
|
||||||
try { return statSync(p).isDirectory() } catch { return false }
|
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
|
return root
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getName(): string {
|
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 {
|
export function setRoot(next: string): void {
|
||||||
root = resolve(next)
|
root = resolve(next)
|
||||||
|
addRecentProject(root).catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function openDialog(win: BrowserWindow | null): Promise<string | null> {
|
export async function openDialog(win: BrowserWindow | null): Promise<string | null> {
|
||||||
@@ -42,8 +45,41 @@ export async function openDialog(win: BrowserWindow | null): Promise<string | nu
|
|||||||
? await dialog.showOpenDialog(win, { properties: ['openDirectory'] })
|
? await dialog.showOpenDialog(win, { properties: ['openDirectory'] })
|
||||||
: await dialog.showOpenDialog({ properties: ['openDirectory'] })
|
: await dialog.showOpenDialog({ properties: ['openDirectory'] })
|
||||||
if (!res.canceled && res.filePaths[0]) {
|
if (!res.canceled && res.filePaths[0]) {
|
||||||
root = res.filePaths[0]
|
setRoot(res.filePaths[0])
|
||||||
return root
|
return root
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- recent projects (global, machine-wide — stored in Electron userData) ------
|
||||||
|
export interface RecentProject { path: string; name: string }
|
||||||
|
const MAX_PROJECTS = 20
|
||||||
|
|
||||||
|
function projectsFile(): string {
|
||||||
|
return join(app.getPath('userData'), 'recent-projects.json')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getRecentProjects(): Promise<RecentProject[]> {
|
||||||
|
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<void> {
|
||||||
|
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 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ const api = {
|
|||||||
project: {
|
project: {
|
||||||
current: () => ipcRenderer.invoke('project:current'),
|
current: () => ipcRenderer.invoke('project:current'),
|
||||||
open: () => ipcRenderer.invoke('project:open'),
|
open: () => ipcRenderer.invoke('project:open'),
|
||||||
|
openPath: (path: string) => ipcRenderer.invoke('project:openPath', path),
|
||||||
|
recent: (): Promise<{ path: string; name: string }[]> => ipcRenderer.invoke('projects:recent'),
|
||||||
},
|
},
|
||||||
|
|
||||||
fs: {
|
fs: {
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import type { ContextTarget } from './components'
|
|||||||
import { Editor, SplitView } from './editor'
|
import { Editor, SplitView } from './editor'
|
||||||
import type { Cursor, Mode, Selection } from './editor'
|
import type { Cursor, Mode, Selection } from './editor'
|
||||||
import { Terminal, lid } from './terminals'
|
import { Terminal, lid } from './terminals'
|
||||||
import { 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 { Menu, Toast } from './overlays'
|
||||||
import type { FileNode, GitStatus } from './types'
|
import type { FileNode, GitStatus } from './types'
|
||||||
import { useProject, useProjectActions } from './project'
|
import { useProject, useProjectActions } from './project'
|
||||||
@@ -84,7 +85,8 @@ export function App(): React.ReactElement {
|
|||||||
const [openDirs, setOpenDirs] = useState<Set<string>>(new Set())
|
const [openDirs, setOpenDirs] = useState<Set<string>>(new Set())
|
||||||
const [cursor, setCursor] = useState<Cursor | null>(null)
|
const [cursor, setCursor] = useState<Cursor | null>(null)
|
||||||
const [selection, setSelection] = useState<Selection | null>(null)
|
const [selection, setSelection] = useState<Selection | null>(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.
|
// Most-recently-opened files, newest first, de-duplicated. Drives the ⌘↓/⌘↑ navigator.
|
||||||
const [history, setHistory] = useState<string[]>([])
|
const [history, setHistory] = useState<string[]>([])
|
||||||
const [histInitSel, setHistInitSel] = useState(0)
|
const [histInitSel, setHistInitSel] = useState(0)
|
||||||
@@ -280,18 +282,33 @@ export function App(): React.ReactElement {
|
|||||||
setTabMode((m) => ({ ...m, [path]: openMode }))
|
setTabMode((m) => ({ ...m, [path]: openMode }))
|
||||||
reveal(path)
|
reveal(path)
|
||||||
if (opts.line) {
|
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 <pre>),
|
||||||
|
// 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)
|
setSplitFor(null)
|
||||||
setTabMode((m) => ({ ...m, [path]: changed ? 'updated' : 'code' }))
|
setTabMode((m) => ({ ...m, [path]: changed ? 'updated' : 'code' }))
|
||||||
setCursor({ path, line: opts.line, col: 1 })
|
setCursor({ path, line: opts.line, col: 1 })
|
||||||
setSelection(null)
|
setSelection(null)
|
||||||
setTimeout(() => {
|
scrollEditorToLine(opts.line)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function scrollEditorToLine(line: number): void {
|
||||||
|
const top = 6 + (line - 1) * 20
|
||||||
|
let tries = 0
|
||||||
|
const place = (): void => {
|
||||||
|
const sc = document.querySelector<HTMLElement>('.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
|
// 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.
|
// them). The file stays in history; ⌘W just clears the editor after a dirty check.
|
||||||
async function closeTab(path: string): Promise<void> {
|
async function closeTab(path: string): Promise<void> {
|
||||||
@@ -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<HTMLTextAreaElement>('.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 ----
|
// ---- shortcuts ----
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function onKey(e: KeyboardEvent): void {
|
function onKey(e: KeyboardEvent): void {
|
||||||
|
if (!proj.root) return // launcher screen owns its own keyboard
|
||||||
const meta = e.metaKey || e.ctrlKey
|
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).
|
// The history navigator owns the keyboard while open (it listens in capture phase).
|
||||||
if (overlay === 'history') return
|
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.
|
// ⌘↓/⌘↑ from the file viewer opens the recent-files navigator.
|
||||||
if (meta && (e.key === 'ArrowDown' || e.key === 'ArrowUp') && focusZone === 'editor' && history.length > 0) {
|
if (meta && (e.key === 'ArrowDown' || e.key === 'ArrowUp') && focusZone === 'editor' && history.length > 0) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setHistInitSel(e.key === 'ArrowDown' ? Math.min(1, history.length - 1) : 0)
|
setHistInitSel(e.key === 'ArrowDown' ? Math.min(1, history.length - 1) : 0)
|
||||||
setOverlay('history')
|
setOverlay('history')
|
||||||
}
|
}
|
||||||
// ⌘F and ⌘P both open the unified search (it covers file names too).
|
// ⌘F and ⌘P both open the unified search (it covers file names too), seeded
|
||||||
else if (meta && (e.key.toLowerCase() === 'f' || e.key.toLowerCase() === 'p')) { e.preventDefault(); setOverlay('search') }
|
// 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() === 's') { e.preventDefault(); saveActive() }
|
||||||
else if (meta && e.key.toLowerCase() === 'w') { e.preventDefault(); if (active) closeTab(active) }
|
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)
|
window.addEventListener('keydown', onKey)
|
||||||
return () => window.removeEventListener('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 mode: Mode = (active && tabMode[active]) || (active && proj.diffs[active] ? defaultMode : 'code')
|
||||||
|
|
||||||
const crumb = active ? active.split('/') : []
|
const crumb = active ? active.split('/') : []
|
||||||
|
|
||||||
|
// No project yet (launched via Spotlight / bare) → show the project launcher.
|
||||||
|
if (proj.ready && !proj.root) {
|
||||||
|
return <ProjectLauncher onOpenNew={() => actions.openFolder()} onOpenPath={(p) => actions.openProjectPath(p)} />
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app">
|
<div className="app">
|
||||||
{/* title bar */}
|
{/* title bar */}
|
||||||
@@ -395,11 +503,12 @@ export function App(): React.ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
<div className="tb-spacer" />
|
<div className="tb-spacer" />
|
||||||
<div className="tb-actions">
|
<div className="tb-actions">
|
||||||
<button className="tb-btn" onClick={() => setOverlay('search')}>{Icon.search()} Search <kbd>⌘F</kbd></button>
|
<button className="tb-btn" onClick={() => { setSearchInit(''); setOverlay('search') }}>{Icon.search()} Search <kbd>⌘F</kbd></button>
|
||||||
<button className={'tb-btn tb-toggle' + (autoResize ? ' on' : '')} onClick={() => setAutoResize((v) => !v)}
|
<button className={'tb-btn tb-toggle' + (autoResize ? ' on' : '')} onClick={() => setAutoResize((v) => !v)}
|
||||||
title={autoResize ? 'Auto-fit panels: on — columns re-fit on resize/focus. Click to lock current sizes.' : 'Auto-fit panels: off — sizes locked. Click to re-enable.'}>
|
title={autoResize ? 'Auto-fit panels: on — columns re-fit on resize/focus. Click to lock current sizes.' : 'Auto-fit panels: off — sizes locked. Click to re-enable.'}>
|
||||||
{Icon.layout()} Auto-fit <span className="tb-state">{autoResize ? 'On' : 'Off'}</span>
|
{Icon.layout()} Auto-fit <span className="tb-state">{autoResize ? 'On' : 'Off'}</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button className="tb-btn tb-icon" onClick={() => setOverlay('help')} title="Keyboard shortcuts">{Icon.help()}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -463,8 +572,9 @@ export function App(): React.ReactElement {
|
|||||||
toast('Passed to agent', passPopup.ref)
|
toast('Passed to agent', passPopup.ref)
|
||||||
}}
|
}}
|
||||||
onCancel={() => setPassPopup(null)} />}
|
onCancel={() => setPassPopup(null)} />}
|
||||||
{overlay === 'search' && <SearchModal onOpen={openFile} onOpenAt={(p, n) => openFile(p, { line: n })} onClose={() => setOverlay(null)} changeSet={changeSet} />}
|
{overlay === 'search' && <SearchModal initialQuery={searchInit} 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} />}
|
{overlay === 'history' && <HistoryModal history={history} initialSel={histInitSel} onOpen={openFile} onClose={() => setOverlay(null)} changeSet={changeSet} />}
|
||||||
|
{overlay === 'help' && <HelpModal onClose={() => setOverlay(null)} />}
|
||||||
{menu && <ContextMenu menu={menu} onClose={() => setMenu(null)} />}
|
{menu && <ContextMenu menu={menu} onClose={() => setMenu(null)} />}
|
||||||
<Toasts toasts={toasts} />
|
<Toasts toasts={toasts} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export const Icon: Record<string, (p?: SvgProps) => React.ReactElement> = {
|
|||||||
check: (p) => (<svg width="13" height="13" viewBox="0 0 14 14" fill="none" {...p}><path d="M2.5 7.5l2.8 3L11.5 3.5" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" /></svg>),
|
check: (p) => (<svg width="13" height="13" viewBox="0 0 14 14" fill="none" {...p}><path d="M2.5 7.5l2.8 3L11.5 3.5" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" /></svg>),
|
||||||
discard: (p) => (<svg width="13" height="13" viewBox="0 0 16 16" fill="none" {...p}><path d="M12.5 5.5A5 5 0 1 0 13 9" stroke="currentColor" strokeWidth="1.3" fill="none" strokeLinecap="round" /><path d="M12.5 2.5v3h-3" stroke="currentColor" strokeWidth="1.3" fill="none" strokeLinecap="round" strokeLinejoin="round" /></svg>),
|
discard: (p) => (<svg width="13" height="13" viewBox="0 0 16 16" fill="none" {...p}><path d="M12.5 5.5A5 5 0 1 0 13 9" stroke="currentColor" strokeWidth="1.3" fill="none" strokeLinecap="round" /><path d="M12.5 2.5v3h-3" stroke="currentColor" strokeWidth="1.3" fill="none" strokeLinecap="round" strokeLinejoin="round" /></svg>),
|
||||||
layout: (p) => (<svg width="13" height="13" viewBox="0 0 16 16" fill="none" {...p}><rect x="2" y="3" width="12" height="10" rx="1.5" stroke="currentColor" strokeWidth="1.3" /><path d="M6 3v10M10 3v10" stroke="currentColor" strokeWidth="1.3" /></svg>),
|
layout: (p) => (<svg width="13" height="13" viewBox="0 0 16 16" fill="none" {...p}><rect x="2" y="3" width="12" height="10" rx="1.5" stroke="currentColor" strokeWidth="1.3" /><path d="M6 3v10M10 3v10" stroke="currentColor" strokeWidth="1.3" /></svg>),
|
||||||
|
help: (p) => (<svg width="14" height="14" viewBox="0 0 16 16" fill="none" {...p}><circle cx="8" cy="8" r="6.2" stroke="currentColor" strokeWidth="1.3" /><path d="M6.3 6.2a1.7 1.7 0 1 1 2.3 1.6c-.5.25-.8.6-.8 1.2v.3" stroke="currentColor" strokeWidth="1.3" fill="none" strokeLinecap="round" /><circle cx="8" cy="11.4" r=".75" fill="currentColor" /></svg>),
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Chevron = ({ open }: { open: boolean }): React.ReactElement => (
|
export const Chevron = ({ open }: { open: boolean }): React.ReactElement => (
|
||||||
|
|||||||
2
src/renderer/src/env.d.ts
vendored
2
src/renderer/src/env.d.ts
vendored
@@ -15,6 +15,8 @@ interface HelderBridge {
|
|||||||
project: {
|
project: {
|
||||||
current: () => Promise<{ root: string | null; name: string }>
|
current: () => Promise<{ root: string | null; name: string }>
|
||||||
open: () => 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: {
|
fs: {
|
||||||
tree: () => Promise<FileNode | null>
|
tree: () => Promise<FileNode | null>
|
||||||
|
|||||||
78
src/renderer/src/launcher.tsx
Normal file
78
src/renderer/src/launcher.tsx
Normal file
@@ -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<RecentProject[]>([])
|
||||||
|
const [sel, setSel] = useState(0)
|
||||||
|
const selRef = useRef(sel); selRef.current = sel
|
||||||
|
const listRef = useRef<HTMLDivElement>(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 (
|
||||||
|
<div className="launcher">
|
||||||
|
<div className="launcher-drag" />
|
||||||
|
<div className="launcher-card">
|
||||||
|
<div className="lp-head">
|
||||||
|
{Icon.spark({ width: 22, height: 22, style: { color: 'var(--accent)' } })}
|
||||||
|
<div className="lp-title"><b>Helder</b><span>Open a project to begin</span></div>
|
||||||
|
</div>
|
||||||
|
<div className="lp-list" ref={listRef}>
|
||||||
|
<div className={'lp-row lp-new' + (sel === 0 ? ' sel' : '')}
|
||||||
|
onMouseEnter={() => setSel(0)} onClick={() => activate(0)}>
|
||||||
|
<span className="lp-ic">{Icon.plus()}</span>
|
||||||
|
<div className="lp-txt"><span className="lp-name">Open new project…</span><span className="lp-path">Choose a folder</span></div>
|
||||||
|
<kbd>↵</kbd>
|
||||||
|
</div>
|
||||||
|
{recents.length > 0 && <div className="lp-sec">Recent</div>}
|
||||||
|
{recents.map((p, i) => {
|
||||||
|
const idx = i + 1
|
||||||
|
return (
|
||||||
|
<div key={p.path} className={'lp-row' + (sel === idx ? ' sel' : '')} title={p.path}
|
||||||
|
onMouseEnter={() => setSel(idx)} onClick={() => activate(idx)}>
|
||||||
|
<span className="lp-ic">{Icon.reveal()}</span>
|
||||||
|
<div className="lp-txt"><span className="lp-name">{p.name}</span><span className="lp-path">{p.path}</span></div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="lp-foot"><kbd>⌘↓</kbd> <kbd>⌘↑</kbd> navigate · <kbd>↵</kbd> open</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -24,7 +24,8 @@ function Highlight({ text, idx }: { text: string; idx: number[] | null }): React
|
|||||||
return <span>{text.split('').map((ch, i) => set.has(i) ? <b key={i}>{ch}</b> : <Fragment key={i}>{ch}</Fragment>)}</span>
|
return <span>{text.split('').map((ch, i) => set.has(i) ? <b key={i}>{ch}</b> : <Fragment key={i}>{ch}</Fragment>)}</span>
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SearchModal({ onOpen, onOpenAt, onClose, changeSet }: {
|
export function SearchModal({ initialQuery, onOpen, onOpenAt, onClose, changeSet }: {
|
||||||
|
initialQuery?: string
|
||||||
onOpen: OpenFile
|
onOpen: OpenFile
|
||||||
onOpenAt: (path: string, line: number) => void
|
onOpenAt: (path: string, line: number) => void
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
@@ -32,15 +33,18 @@ export function SearchModal({ onOpen, onOpenAt, onClose, changeSet }: {
|
|||||||
}): React.ReactElement {
|
}): React.ReactElement {
|
||||||
const PROJECT = useProject()
|
const PROJECT = useProject()
|
||||||
const bridge = window.helder
|
const bridge = window.helder
|
||||||
const [q, setQ] = useState('')
|
const [q, setQ] = useState(() => initialQuery ?? '')
|
||||||
const [sel, setSel] = useState(0)
|
const [sel, setSel] = useState(0)
|
||||||
|
const [fileSel, setFileSel] = useState(0)
|
||||||
|
const [col, setCol] = useState<'content' | 'files'>('content') // active result column (⌘← / ⌘→)
|
||||||
const inputRef = useRef<HTMLInputElement>(null)
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
const leftRef = useRef<HTMLDivElement>(null)
|
const leftRef = useRef<HTMLDivElement>(null)
|
||||||
|
const rightRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
// file-name list: ripgrep `--files` when available, else the in-memory index keys
|
// file-name list: ripgrep `--files` when available, else the in-memory index keys
|
||||||
const [allPaths, setAllPaths] = useState<string[]>(() => (bridge ? [] : Object.keys(PROJECT.files)))
|
const [allPaths, setAllPaths] = useState<string[]>(() => (bridge ? [] : Object.keys(PROJECT.files)))
|
||||||
useEffect(() => {
|
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)))
|
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])
|
}, [content])
|
||||||
|
|
||||||
const totalHits = flat.length
|
const totalHits = flat.length
|
||||||
useEffect(() => { setSel(0) }, [q])
|
const fileCount = Math.min(files.length, 40)
|
||||||
|
useEffect(() => { setSel(0); setFileSel(0) }, [q])
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = leftRef.current && leftRef.current.querySelector('.sr-line.sel')
|
const el = leftRef.current && leftRef.current.querySelector('.sr-line.sel')
|
||||||
if (el) el.scrollIntoView({ block: 'nearest' })
|
if (el) el.scrollIntoView({ block: 'nearest' })
|
||||||
}, [sel])
|
}, [sel])
|
||||||
|
useEffect(() => {
|
||||||
|
const el = rightRef.current && rightRef.current.querySelector('.fres.sel')
|
||||||
|
if (el) el.scrollIntoView({ block: 'nearest' })
|
||||||
|
}, [fileSel])
|
||||||
|
|
||||||
function onKey(e: React.KeyboardEvent): void {
|
function onKey(e: React.KeyboardEvent): void {
|
||||||
if (e.key === 'ArrowDown') { e.preventDefault(); setSel((s) => Math.min(s + 1, flat.length - 1)) }
|
if ((e.metaKey || e.ctrlKey) && e.key === 'ArrowLeft') { e.preventDefault(); setCol('content'); return }
|
||||||
else if (e.key === 'ArrowUp') { e.preventDefault(); setSel((s) => Math.max(s - 1, 0)) }
|
if ((e.metaKey || e.ctrlKey) && e.key === 'ArrowRight') { e.preventDefault(); setCol('files'); return }
|
||||||
else if (e.key === 'Enter') {
|
if (e.key === 'ArrowDown') {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
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() }
|
if (flat[sel]) { onOpenAt(flat[sel].path, flat[sel].no); onClose() }
|
||||||
else if (files[0]) { onOpen(files[0].path); onClose() }
|
else if (files[0]) { onOpen(files[0].path); onClose() }
|
||||||
|
}
|
||||||
} else if (e.key === 'Escape') { e.preventDefault(); onClose() }
|
} else if (e.key === 'Escape') { e.preventDefault(); onClose() }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,8 +151,8 @@ export function SearchModal({ onOpen, onOpenAt, onClose, changeSet }: {
|
|||||||
<span className="mode-chip">{totalHits} hit{totalHits === 1 ? '' : 's'} · {files.length} file{files.length === 1 ? '' : 's'}</span>
|
<span className="mode-chip">{totalHits} hit{totalHits === 1 ? '' : 's'} · {files.length} file{files.length === 1 ? '' : 's'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="search-cols">
|
<div className="search-cols">
|
||||||
<div className="sc-left" ref={leftRef}>
|
<div className={'sc-left' + (col === 'content' ? ' active' : '')} ref={leftRef}>
|
||||||
<div className="sc-head">Content {totalHits > 0 && <span className="sc-ct">{totalHits}</span>}</div>
|
<div className="sc-head">Content {totalHits > 0 && <span className="sc-ct">{totalHits}</span>} <kbd className="col-kbd">⌘←</kbd></div>
|
||||||
{term.length < 2 && <div className="pempty">Type at least 2 characters</div>}
|
{term.length < 2 && <div className="pempty">Type at least 2 characters</div>}
|
||||||
{term.length >= 2 && content.length === 0 && <div className="pempty">No content matches</div>}
|
{term.length >= 2 && content.length === 0 && <div className="pempty">No content matches</div>}
|
||||||
{content.map((g) => (
|
{content.map((g) => (
|
||||||
@@ -144,8 +166,8 @@ export function SearchModal({ onOpen, onOpenAt, onClose, changeSet }: {
|
|||||||
flatIx++
|
flatIx++
|
||||||
const me = flatIx
|
const me = flatIx
|
||||||
return (
|
return (
|
||||||
<div key={h.no} className={'sr-line' + (me === sel ? ' sel' : '')}
|
<div key={h.no} className={'sr-line' + (me === sel && col === 'content' ? ' sel' : '')}
|
||||||
onMouseEnter={() => setSel(me)}
|
onMouseEnter={() => { setCol('content'); setSel(me) }}
|
||||||
onClick={() => { onOpenAt(g.path, h.no); onClose() }}>
|
onClick={() => { onOpenAt(g.path, h.no); onClose() }}>
|
||||||
<span className="no">{h.no}</span>
|
<span className="no">{h.no}</span>
|
||||||
{renderLine(h.ln, h.ix, term.length)}
|
{renderLine(h.ln, h.ix, term.length)}
|
||||||
@@ -155,15 +177,17 @@ export function SearchModal({ onOpen, onOpenAt, onClose, changeSet }: {
|
|||||||
</Fragment>
|
</Fragment>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="sc-right">
|
<div className={'sc-right' + (col === 'files' ? ' active' : '')} ref={rightRef}>
|
||||||
<div className="sc-head">Files {files.length > 0 && <span className="sc-ct">{files.length}</span>}</div>
|
<div className="sc-head">Files {files.length > 0 && <span className="sc-ct">{files.length}</span>} <kbd className="col-kbd">⌘→</kbd></div>
|
||||||
{!term && <div className="pempty sm">Start typing…</div>}
|
{!term && <div className="pempty sm">Start typing…</div>}
|
||||||
{term && files.length === 0 && <div className="pempty sm">No file names match</div>}
|
{term && files.length === 0 && <div className="pempty sm">No file names match</div>}
|
||||||
{files.slice(0, 40).map((r) => {
|
{files.slice(0, 40).map((r, i) => {
|
||||||
const name = r.path.split('/').pop() as string
|
const name = r.path.split('/').pop() as string
|
||||||
const dir = r.path.split('/').slice(0, -1).join('/')
|
const dir = r.path.split('/').slice(0, -1).join('/')
|
||||||
return (
|
return (
|
||||||
<div key={r.path} className="fres" onClick={() => { onOpen(r.path); onClose() }} title={r.path}>
|
<div key={r.path} className={'fres' + (col === 'files' && i === fileSel ? ' sel' : '')}
|
||||||
|
onMouseEnter={() => { setCol('files'); setFileSel(i) }}
|
||||||
|
onClick={() => { onOpen(r.path); onClose() }} title={r.path}>
|
||||||
<FileIcon path={r.path} />
|
<FileIcon path={r.path} />
|
||||||
<div className="fres-txt">
|
<div className="fres-txt">
|
||||||
<span className="fn"><Highlight text={name} idx={r.idx} /></span>
|
<span className="fn"><Highlight text={name} idx={r.idx} /></span>
|
||||||
@@ -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 (
|
||||||
|
<div className="scrim" onMouseDown={onClose}>
|
||||||
|
<div className="help-modal" onMouseDown={(e) => e.stopPropagation()}>
|
||||||
|
<div className="pi">
|
||||||
|
{Icon.help({ style: { color: 'var(--fg-3)' } })}
|
||||||
|
<span className="hist-title">Keyboard shortcuts</span>
|
||||||
|
<span className="mode-chip"><kbd>esc</kbd></span>
|
||||||
|
</div>
|
||||||
|
<div className="help-list">
|
||||||
|
{SHORTCUTS.map((s, i) => (
|
||||||
|
<div key={i} className="help-row">
|
||||||
|
<span className="help-keys">{s.keys.map((k, j) => <kbd key={j}>{k}</kbd>)}</span>
|
||||||
|
<span className="help-label">{s.label}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function ContextMenu({ menu, onClose }: { menu: Menu | null; onClose: () => void }): React.ReactElement | null {
|
export function ContextMenu({ menu, onClose }: { menu: Menu | null; onClose: () => void }): React.ReactElement | null {
|
||||||
const ref = useRef<HTMLDivElement>(null)
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ function applyTheme(css: string): void {
|
|||||||
|
|
||||||
export interface ProjectActions {
|
export interface ProjectActions {
|
||||||
openFolder: () => void
|
openFolder: () => void
|
||||||
|
openProjectPath: (path: string) => void
|
||||||
refresh: () => void
|
refresh: () => void
|
||||||
stage: (path: string) => void
|
stage: (path: string) => void
|
||||||
unstage: (path: string) => void
|
unstage: (path: string) => void
|
||||||
@@ -49,7 +50,8 @@ const MOCK_STAGED = ['src/Service/PaymentService.php', 'config/app.json']
|
|||||||
|
|
||||||
function mockData(): ProjectData {
|
function mockData(): ProjectData {
|
||||||
return {
|
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,
|
tree: MOCK.tree, files: MOCK.files, changes: MOCK.changes, diffs: MOCK.diffs,
|
||||||
staged: new Set(MOCK_STAGED), config: DEFAULT_CONFIG, isRepo: true, ready: true,
|
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)) }))
|
setData((d) => ({ ...d, staged: fn(new Set(d.staged)) }))
|
||||||
return {
|
return {
|
||||||
openFolder: () => {},
|
openFolder: () => {},
|
||||||
|
openProjectPath: () => {},
|
||||||
refresh: () => setData(mockData()),
|
refresh: () => setData(mockData()),
|
||||||
stage: (p) => setStaged((s) => (s.add(p), s)),
|
stage: (p) => setStaged((s) => (s.add(p), s)),
|
||||||
unstage: (p) => setStaged((s) => (s.delete(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<unknown>): void => { op.then(() => loadReal()).catch(() => {}) }
|
const after = (op: Promise<unknown>): void => { op.then(() => loadReal()).catch(() => {}) }
|
||||||
return {
|
return {
|
||||||
openFolder: () => { bridge.project.open().then(() => loadReal()).catch(() => {}) },
|
openFolder: () => { bridge.project.open().then(() => loadReal()).catch(() => {}) },
|
||||||
|
openProjectPath: (path) => { bridge.project.openPath(path).then(() => loadReal()).catch(() => {}) },
|
||||||
refresh: () => { loadReal().catch(() => {}) },
|
refresh: () => { loadReal().catch(() => {}) },
|
||||||
stage: (p) => after(bridge.git.stage([p])),
|
stage: (p) => after(bridge.git.stage([p])),
|
||||||
unstage: (p) => after(bridge.git.unstage([p])),
|
unstage: (p) => after(bridge.git.unstage([p])),
|
||||||
|
|||||||
@@ -439,3 +439,47 @@ body {
|
|||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
* { animation-duration: 0.001ms !important; animation-iteration-count: 1 !important; transition-duration: 0.001ms !important; }
|
* { 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); }
|
||||||
|
|||||||
Reference in New Issue
Block a user