improvements
This commit is contained in:
@@ -9,6 +9,7 @@ import { commit, discard, load, push, stage, unstage } from './git-service'
|
|||||||
import { createPty, killAllPtys, killPty, ptyAvailable, resizePty, writePty } from './pty-service'
|
import { createPty, killAllPtys, killPty, ptyAvailable, resizePty, writePty } from './pty-service'
|
||||||
import { getConfig, getRecent, getThemeCss, resolveConfig, setRecent } from './config'
|
import { getConfig, getRecent, getThemeCss, resolveConfig, setRecent } from './config'
|
||||||
import { listFiles, searchContent } from './search-service'
|
import { listFiles, searchContent } from './search-service'
|
||||||
|
import { readNote, writeNote } from './notes-service'
|
||||||
import { initDiagnostics, openLog, revealLog, watchWindow } from './diagnostics'
|
import { initDiagnostics, openLog, revealLog, watchWindow } from './diagnostics'
|
||||||
import { getLogPath, log, logger, type LogLevel } from './logger'
|
import { getLogPath, log, logger, type LogLevel } from './logger'
|
||||||
|
|
||||||
@@ -283,6 +284,10 @@ function registerIpc(): void {
|
|||||||
handle('fs:delete', (_e, rel: string) => { const r = getRoot(); if (r) return deleteProjectFile(r, rel) })
|
handle('fs:delete', (_e, rel: string) => { const r = getRoot(); if (r) return deleteProjectFile(r, rel) })
|
||||||
handle('fs:create', (_e, rel: string) => { const r = getRoot(); if (r) return createProjectFile(r, rel) })
|
handle('fs:create', (_e, rel: string) => { const r = getRoot(); if (r) return createProjectFile(r, rel) })
|
||||||
handle('fs:mkdir', (_e, rel: string) => { const r = getRoot(); if (r) return createProjectDir(r, rel) })
|
handle('fs:mkdir', (_e, rel: string) => { const r = getRoot(); if (r) return createProjectDir(r, rel) })
|
||||||
|
// Scratch note: <project>/.notes.txt, saved when the window loses focus.
|
||||||
|
handle('notes:read', () => { const r = getRoot(); return r ? readNote(r) : '' })
|
||||||
|
handle('notes:write', (_e, text: string) => { const r = getRoot(); if (r) return writeNote(r, text) })
|
||||||
|
|
||||||
handle('shell:reveal', (_e, rel: string) => { const r = getRoot(); if (r) shell.showItemInFolder(join(r, rel)) })
|
handle('shell:reveal', (_e, rel: string) => { const r = getRoot(); if (r) shell.showItemInFolder(join(r, rel)) })
|
||||||
|
|
||||||
handle('git:load', () => { const r = getRoot(); return r ? load(r) : null })
|
handle('git:load', () => { const r = getRoot(); return r ? load(r) : null })
|
||||||
@@ -361,6 +366,17 @@ function createWindow(): void {
|
|||||||
// Keep the renderer's <title>Helder</title> from clobbering the folder name.
|
// Keep the renderer's <title>Helder</title> from clobbering the folder name.
|
||||||
win.on('page-title-updated', (e) => e.preventDefault())
|
win.on('page-title-updated', (e) => e.preventDefault())
|
||||||
win.on('ready-to-show', () => win.show())
|
win.on('ready-to-show', () => win.show())
|
||||||
|
|
||||||
|
// macOS hides the traffic lights in fullscreen, so the title bar can drop the
|
||||||
|
// 82px it reserves for them. Only the main process knows this state, hence IPC.
|
||||||
|
function sendFullscreen(): void {
|
||||||
|
if (win.isDestroyed()) return
|
||||||
|
win.webContents.send('window:fullscreen', win.isFullScreen())
|
||||||
|
}
|
||||||
|
win.on('enter-full-screen', sendFullscreen)
|
||||||
|
win.on('leave-full-screen', sendFullscreen)
|
||||||
|
win.webContents.on('did-finish-load', sendFullscreen)
|
||||||
|
|
||||||
watchWindow(win)
|
watchWindow(win)
|
||||||
|
|
||||||
win.webContents.setWindowOpenHandler(({ url }) => {
|
win.webContents.setWindowOpenHandler(({ url }) => {
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ const api = {
|
|||||||
reveal: (path: string): void => { ipcRenderer.invoke('shell:reveal', path) },
|
reveal: (path: string): void => { ipcRenderer.invoke('shell:reveal', path) },
|
||||||
},
|
},
|
||||||
|
|
||||||
|
notes: {
|
||||||
|
read: (): Promise<string> => ipcRenderer.invoke('notes:read'),
|
||||||
|
write: (text: string): Promise<void> => ipcRenderer.invoke('notes:write', text),
|
||||||
|
},
|
||||||
git: {
|
git: {
|
||||||
load: () => ipcRenderer.invoke('git:load'),
|
load: () => ipcRenderer.invoke('git:load'),
|
||||||
stage: (paths: string[]) => ipcRenderer.invoke('git:stage', paths),
|
stage: (paths: string[]) => ipcRenderer.invoke('git:stage', paths),
|
||||||
@@ -110,6 +114,13 @@ const api = {
|
|||||||
return () => ipcRenderer.removeListener('config:changed', handler)
|
return () => ipcRenderer.removeListener('config:changed', handler)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Subscribe to the window entering/leaving fullscreen. Returns an unsubscribe. */
|
||||||
|
onFullscreen: (cb: (on: boolean) => void): (() => void) => {
|
||||||
|
const handler = (_e: unknown, on: boolean): void => cb(on)
|
||||||
|
ipcRenderer.on('window:fullscreen', handler)
|
||||||
|
return () => ipcRenderer.removeListener('window:fullscreen', handler)
|
||||||
|
},
|
||||||
|
|
||||||
/** Subscribe to explicit ⌘R refresh requests (git + tree + viewer). Returns an unsubscribe. */
|
/** Subscribe to explicit ⌘R refresh requests (git + tree + viewer). Returns an unsubscribe. */
|
||||||
onRefresh: (cb: () => void): (() => void) => {
|
onRefresh: (cb: () => void): (() => void) => {
|
||||||
const handler = (): void => cb()
|
const handler = (): void => cb()
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import type { ContextTarget } from './components'
|
|||||||
import { Editor, SplitView } from './editor'
|
import { Editor, SplitView } from './editor'
|
||||||
import type { Cursor, Mode, Selection } from './editor'
|
import type { Cursor, Mode, Selection } from './editor'
|
||||||
import { Terminal, lid } from './terminals'
|
import { Terminal, lid } from './terminals'
|
||||||
import { ConfirmModal, ContextMenu, HelpModal, HistoryModal, NamePopup, PassPopup, ProjectsModal, SearchModal, Toasts } from './overlays'
|
import { ConfirmModal, ContextMenu, HelpModal, HistoryModal, NamePopup, NotesModal, PassPopup, ProjectsModal, SearchModal, Toasts } from './overlays'
|
||||||
import { ProjectLauncher } from './launcher'
|
import { ProjectLauncher } from './launcher'
|
||||||
import type { Menu, Toast } from './overlays'
|
import type { Menu, Toast } from './overlays'
|
||||||
import type { DiffSide, FileNode, GitStatus } from './types'
|
import type { DiffSide, FileNode, GitStatus } from './types'
|
||||||
@@ -83,8 +83,13 @@ 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' | 'help' | 'projects' | null>(null)
|
const [overlay, setOverlay] = useState<'search' | 'history' | 'help' | 'projects' | 'notes' | null>(null)
|
||||||
const [searchInit, setSearchInit] = useState('') // seed query for ⌘F-with-selection
|
const [searchInit, setSearchInit] = useState('') // seed query for ⌘F-with-selection
|
||||||
|
// Project scratch note (.notes.txt). savedNote tracks what is on disk, so a
|
||||||
|
// blur with no edits does not rewrite the file (and wake the fs watcher).
|
||||||
|
const [note, setNote] = useState('')
|
||||||
|
const noteRef = useRef(note); noteRef.current = note
|
||||||
|
const savedNote = useRef('')
|
||||||
// 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)
|
||||||
@@ -99,6 +104,9 @@ export function App(): React.ReactElement {
|
|||||||
// Brief full-screen "branch - repository" flash whenever the window gains focus
|
// Brief full-screen "branch - repository" flash whenever the window gains focus
|
||||||
// (handy when juggling several project windows).
|
// (handy when juggling several project windows).
|
||||||
const [showFlash, setShowFlash] = useState(false)
|
const [showFlash, setShowFlash] = useState(false)
|
||||||
|
// Fullscreen on macOS hides the traffic lights, so the title bar reclaims the
|
||||||
|
// space they reserve. Main tells us; the browser preview simply stays false.
|
||||||
|
const [fullscreen, setFullscreen] = useState(false)
|
||||||
|
|
||||||
// Editable buffers: path → current text (absent = clean, showing on-disk content).
|
// Editable buffers: path → current text (absent = clean, showing on-disk content).
|
||||||
const [buffers, setBuffers] = useState<Record<string, string>>({})
|
const [buffers, setBuffers] = useState<Record<string, string>>({})
|
||||||
@@ -270,6 +278,13 @@ export function App(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Follow the window's fullscreen state (see the title-bar padding in styles.css).
|
||||||
|
useEffect(() => {
|
||||||
|
const subscribe = window.helder?.onFullscreen
|
||||||
|
if (!subscribe) return
|
||||||
|
return subscribe((on) => setFullscreen(on))
|
||||||
|
}, [])
|
||||||
|
|
||||||
// Open/reopen a project with a fully collapsed tree: seed the expansion set
|
// Open/reopen a project with a fully collapsed tree: seed the expansion set
|
||||||
// empty once per opened project (the root row is always shown regardless).
|
// empty once per opened project (the root row is always shown regardless).
|
||||||
// A refresh keeps the user's expansion since seededRoot guards on proj.root.
|
// A refresh keeps the user's expansion since seededRoot guards on proj.root.
|
||||||
@@ -299,6 +314,38 @@ export function App(): React.ReactElement {
|
|||||||
if (bridge) bridge.recent.get().then((list) => { setHistory(list); recentReady.current = true }).catch(() => { recentReady.current = true })
|
if (bridge) bridge.recent.get().then((list) => { setHistory(list); recentReady.current = true }).catch(() => { recentReady.current = true })
|
||||||
}, [proj.ready, proj.root, proj.config.session.restoreOnLaunch])
|
}, [proj.ready, proj.root, proj.config.session.restoreOnLaunch])
|
||||||
|
|
||||||
|
// Write the note to <project>/.notes.txt. Skipped when nothing changed, so a
|
||||||
|
// plain alt-tab does not touch the file or wake the project watcher.
|
||||||
|
const saveNote = useCallback((): void => {
|
||||||
|
const bridge = window.helder
|
||||||
|
if (!bridge || !projRef.current.root) return
|
||||||
|
const text = noteRef.current
|
||||||
|
if (text === savedNote.current) return
|
||||||
|
savedNote.current = text
|
||||||
|
bridge.notes.write(text).catch((e) => rlog.error('notes', 'save failed', e))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// The note is saved when the window loses focus. beforeunload covers the other
|
||||||
|
// way out — closing the window or quitting, which never fires a blur.
|
||||||
|
useEffect(() => {
|
||||||
|
window.addEventListener('blur', saveNote)
|
||||||
|
window.addEventListener('beforeunload', saveNote)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('blur', saveNote)
|
||||||
|
window.removeEventListener('beforeunload', saveNote)
|
||||||
|
}
|
||||||
|
}, [saveNote])
|
||||||
|
|
||||||
|
// Load this project's note. Each window holds one project, so this runs once
|
||||||
|
// per project change.
|
||||||
|
useEffect(() => {
|
||||||
|
const bridge = window.helder
|
||||||
|
if (!bridge || !proj.root) { setNote(''); savedNote.current = ''; return }
|
||||||
|
bridge.notes.read()
|
||||||
|
.then((t) => { setNote(t); savedNote.current = t })
|
||||||
|
.catch((e) => rlog.error('notes', 'load failed', e))
|
||||||
|
}, [proj.root])
|
||||||
|
|
||||||
// Persist the history to .helder/recent.json (newest first, capped to 100 in main),
|
// 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 []).
|
// but only once it's been loaded for this project (so we never clobber it with []).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -661,6 +708,18 @@ export function App(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
// ⌘→ with the note open hands the whole note to the agent. Same route as the
|
||||||
|
// editor's Pass on to Agent: bracketed paste, so nothing is submitted. The note
|
||||||
|
// is saved and closed, so you see the text land in the agent composer.
|
||||||
|
function passNote(): boolean {
|
||||||
|
const text = noteRef.current.trim()
|
||||||
|
if (!text) return false
|
||||||
|
window.dispatchEvent(new CustomEvent('agentPaste', { detail: text }))
|
||||||
|
setOverlay(null)
|
||||||
|
saveNote()
|
||||||
|
toast('Note passed to agent', '.notes.txt')
|
||||||
|
return true
|
||||||
|
}
|
||||||
function hasSelection(): boolean {
|
function hasSelection(): boolean {
|
||||||
if ((window.getSelection()?.toString() ?? '') !== '') return true
|
if ((window.getSelection()?.toString() ?? '') !== '') return true
|
||||||
const ae = document.activeElement as HTMLInputElement | HTMLTextAreaElement | null
|
const ae = document.activeElement as HTMLInputElement | HTMLTextAreaElement | null
|
||||||
@@ -695,10 +754,15 @@ export function App(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
if (splitFor) setSplitFor(null)
|
if (splitFor) setSplitFor(null)
|
||||||
|
// Closing the note saves it there and then, rather than leaving the text
|
||||||
|
// to wait for the next blur.
|
||||||
|
else if (overlay === 'notes') { setOverlay(null); saveNote() }
|
||||||
else if (overlay) setOverlay(null)
|
else if (overlay) setOverlay(null)
|
||||||
else setMenu(null)
|
else setMenu(null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// ⌘→ with the note open passes the note text to the agent.
|
||||||
|
if (overlay === 'notes' && meta && e.key === 'ArrowRight') { e.preventDefault(); passNote(); return }
|
||||||
// Search / help modals own the keyboard while open (they handle their own keys).
|
// Search / help modals own the keyboard while open (they handle their own keys).
|
||||||
if (overlay) return
|
if (overlay) return
|
||||||
|
|
||||||
@@ -716,6 +780,8 @@ export function App(): React.ReactElement {
|
|||||||
else if (meta && e.key.toLowerCase() === 'f') { e.preventDefault(); setSearchInit(selectedSearchText()); setOverlay('search') }
|
else if (meta && e.key.toLowerCase() === 'f') { e.preventDefault(); setSearchInit(selectedSearchText()); setOverlay('search') }
|
||||||
// ⌘P pushes the current branch to its remote.
|
// ⌘P pushes the current branch to its remote.
|
||||||
else if (meta && e.key.toLowerCase() === 'p') { e.preventDefault(); push() }
|
else if (meta && e.key.toLowerCase() === 'p') { e.preventDefault(); push() }
|
||||||
|
// ⌘N opens the project note.
|
||||||
|
else if (meta && e.key.toLowerCase() === 'n') { e.preventDefault(); setOverlay('notes') }
|
||||||
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) }
|
||||||
// ⌘D deletes the current file (with confirmation).
|
// ⌘D deletes the current file (with confirmation).
|
||||||
@@ -818,7 +884,7 @@ export function App(): React.ReactElement {
|
|||||||
return (
|
return (
|
||||||
<div className="app">
|
<div className="app">
|
||||||
{/* title bar */}
|
{/* title bar */}
|
||||||
<div className="titlebar">
|
<div className={'titlebar' + (fullscreen ? ' fullscreen' : '')}>
|
||||||
<div className="traffic"><i className="r" /><i className="y" /><i className="g" /></div>
|
<div className="traffic"><i className="r" /><i className="y" /><i className="g" /></div>
|
||||||
<div className="tb-title">
|
<div className="tb-title">
|
||||||
<b style={{ color: 'var(--accent)', cursor: 'pointer', textTransform: 'uppercase' }} title="Open folder…" onClick={() => actions.openFolder()}>{proj.name}</b>
|
<b style={{ color: 'var(--accent)', cursor: 'pointer', textTransform: 'uppercase' }} title="Open folder…" onClick={() => actions.openFolder()}>{proj.name}</b>
|
||||||
@@ -834,15 +900,19 @@ export function App(): React.ReactElement {
|
|||||||
<div className="tb-actions">
|
<div className="tb-actions">
|
||||||
<button className={'tb-btn tb-toggle' + (overlay === 'search' ? ' on' : '')} onClick={() => { setSearchInit(''); setOverlay('search') }}
|
<button className={'tb-btn tb-toggle' + (overlay === 'search' ? ' on' : '')} onClick={() => { setSearchInit(''); setOverlay('search') }}
|
||||||
title="Search contents & names">
|
title="Search contents & names">
|
||||||
{Icon.search()} Search <span className="tb-state">{overlay === 'search' ? 'On' : 'Off'}</span> <kbd>⌘F</kbd>
|
{Icon.search()} Search <kbd>⌘F</kbd>
|
||||||
</button>
|
</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> <kbd>⌘A</kbd>
|
{Icon.layout()} Auto-fit <kbd>⌘A</kbd>
|
||||||
</button>
|
</button>
|
||||||
<button className={'tb-btn tb-toggle' + (showHidden ? ' on' : '')} onClick={() => setShowHidden((v) => !v)}
|
<button className={'tb-btn tb-toggle' + (showHidden ? ' on' : '')} onClick={() => setShowHidden((v) => !v)}
|
||||||
title={showHidden ? 'Hidden files: shown — dotfiles appear in the tree and search. Click to hide.' : 'Hidden files: hidden — dotfiles excluded from the tree and search. Click to show.'}>
|
title={showHidden ? 'Hidden files: shown — dotfiles appear in the tree and search. Click to hide.' : 'Hidden files: hidden — dotfiles excluded from the tree and search. Click to show.'}>
|
||||||
{Icon.eye()} Hidden <span className="tb-state">{showHidden ? 'On' : 'Off'}</span> <kbd>⌘.</kbd>
|
{Icon.eye()} Hidden <kbd>⌘.</kbd>
|
||||||
|
</button>
|
||||||
|
<button className={'tb-btn tb-toggle' + (overlay === 'notes' ? ' on' : '')} onClick={() => setOverlay('notes')}
|
||||||
|
title="Project note (.notes.txt) — kept next to this project">
|
||||||
|
{Icon.note({ width: 13, height: 13 })} Note <kbd>⌘N</kbd>
|
||||||
</button>
|
</button>
|
||||||
<button className="tb-btn tb-icon" onClick={() => setOverlay('help')} title="Keyboard shortcuts">{Icon.help()}</button>
|
<button className="tb-btn tb-icon" onClick={() => setOverlay('help')} title="Keyboard shortcuts">{Icon.help()}</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -922,6 +992,7 @@ export function App(): React.ReactElement {
|
|||||||
{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 === 'projects' && <ProjectsModal recents={proj.recents} currentRoot={proj.root} onOpen={(p) => actions.openProjectPath(p)} onClose={() => setOverlay(null)} />}
|
{overlay === 'projects' && <ProjectsModal recents={proj.recents} currentRoot={proj.root} onOpen={(p) => actions.openProjectPath(p)} onClose={() => setOverlay(null)} />}
|
||||||
{overlay === 'help' && <HelpModal onClose={() => setOverlay(null)} />}
|
{overlay === 'help' && <HelpModal onClose={() => setOverlay(null)} />}
|
||||||
|
{overlay === 'notes' && <NotesModal text={note} onChange={setNote} onClose={() => { setOverlay(null); saveNote() }} />}
|
||||||
{confirm && <ConfirmModal title={confirm.title} body={confirm.body} confirmLabel={confirm.confirmLabel} danger onConfirm={confirm.onConfirm} onClose={() => setConfirm(null)} />}
|
{confirm && <ConfirmModal title={confirm.title} body={confirm.body} confirmLabel={confirm.confirmLabel} danger onConfirm={confirm.onConfirm} onClose={() => setConfirm(null)} />}
|
||||||
{menu && <ContextMenu menu={menu} onClose={() => setMenu(null)} />}
|
{menu && <ContextMenu menu={menu} onClose={() => setMenu(null)} />}
|
||||||
<Toasts toasts={toasts} />
|
<Toasts toasts={toasts} />
|
||||||
|
|||||||
@@ -23,6 +23,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>),
|
||||||
|
note: (p) => (<svg width="14" height="14" viewBox="0 0 16 16" fill="none" {...p}><path d="M3.5 2.5h9v11h-9v-11z" stroke="currentColor" strokeWidth="1.2" fill="none" /><path d="M5.5 5.5h5M5.5 8h5M5.5 10.5h3" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" /></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>),
|
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>),
|
||||||
trash: (p) => (<svg width="13" height="13" viewBox="0 0 16 16" fill="none" {...p}><path d="M3 4.5h10M6.5 4.5V3h3v1.5M4.5 4.5l.6 8.5h5.8l.6-8.5" stroke="currentColor" strokeWidth="1.2" fill="none" strokeLinecap="round" strokeLinejoin="round" /></svg>),
|
trash: (p) => (<svg width="13" height="13" viewBox="0 0 16 16" fill="none" {...p}><path d="M3 4.5h10M6.5 4.5V3h3v1.5M4.5 4.5l.6 8.5h5.8l.6-8.5" stroke="currentColor" strokeWidth="1.2" fill="none" strokeLinecap="round" strokeLinejoin="round" /></svg>),
|
||||||
finder: (p) => (<svg width="13" height="13" viewBox="0 0 16 16" fill="none" {...p}><rect x="2" y="3.5" width="12" height="9" rx="1.5" stroke="currentColor" strokeWidth="1.2" /><path d="M9 7l3-3M12 4v2.6M12 4H9.4" stroke="currentColor" strokeWidth="1.2" fill="none" strokeLinecap="round" strokeLinejoin="round" /></svg>),
|
finder: (p) => (<svg width="13" height="13" viewBox="0 0 16 16" fill="none" {...p}><rect x="2" y="3.5" width="12" height="9" rx="1.5" stroke="currentColor" strokeWidth="1.2" /><path d="M9 7l3-3M12 4v2.6M12 4H9.4" stroke="currentColor" strokeWidth="1.2" fill="none" strokeLinecap="round" strokeLinejoin="round" /></svg>),
|
||||||
|
|||||||
@@ -313,11 +313,13 @@ export function Editor({ active, mode, side, setMode, onContext, onSplit, splitO
|
|||||||
<div className="empty-ed">
|
<div className="empty-ed">
|
||||||
<div style={{ opacity: 0.5 }}>{Icon.file({ width: 30, height: 30 })}</div>
|
<div style={{ opacity: 0.5 }}>{Icon.file({ width: 30, height: 30 })}</div>
|
||||||
<div className="big">No file open</div>
|
<div className="big">No file open</div>
|
||||||
|
{/* Only what works with no file open — Copy reference and Pass on to
|
||||||
|
Agent need a file, so they are not advertised here. */}
|
||||||
<div className="klist">
|
<div className="klist">
|
||||||
<div><span>Open folder</span><kbd>⌘O</kbd></div>
|
<div><span>Open folder</span><kbd>⌘O</kbd></div>
|
||||||
|
<div><span>Recent projects</span><kbd>⇧⌘O</kbd></div>
|
||||||
<div><span>Search files & content</span><kbd>⌘F</kbd></div>
|
<div><span>Search files & content</span><kbd>⌘F</kbd></div>
|
||||||
<div><span>Copy reference</span><kbd>right-click</kbd></div>
|
<div><span>Project note</span><kbd>⌘N</kbd></div>
|
||||||
<div><span>Pass on to Agent</span><kbd>right-click</kbd></div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
5
src/renderer/src/env.d.ts
vendored
5
src/renderer/src/env.d.ts
vendored
@@ -32,6 +32,10 @@ interface HelderBridge {
|
|||||||
shell: {
|
shell: {
|
||||||
reveal: (path: string) => void
|
reveal: (path: string) => void
|
||||||
}
|
}
|
||||||
|
notes: {
|
||||||
|
read: () => Promise<string>
|
||||||
|
write: (text: string) => Promise<void>
|
||||||
|
}
|
||||||
git: {
|
git: {
|
||||||
load: () => Promise<{ branch: string; changes: GitChangeRaw[] } | null>
|
load: () => Promise<{ branch: string; changes: GitChangeRaw[] } | null>
|
||||||
stage: (paths: string[]) => Promise<void>
|
stage: (paths: string[]) => Promise<void>
|
||||||
@@ -70,6 +74,7 @@ interface HelderBridge {
|
|||||||
open: () => Promise<void>
|
open: () => Promise<void>
|
||||||
reveal: () => Promise<void>
|
reveal: () => Promise<void>
|
||||||
}
|
}
|
||||||
|
onFullscreen: (cb: (on: boolean) => void) => () => void
|
||||||
onProjectChanged: (cb: () => void) => () => void
|
onProjectChanged: (cb: () => void) => () => void
|
||||||
onConfigChanged: (cb: () => void) => () => void
|
onConfigChanged: (cb: () => void) => () => void
|
||||||
onRefresh: (cb: () => void) => () => void
|
onRefresh: (cb: () => void) => () => void
|
||||||
|
|||||||
@@ -426,6 +426,8 @@ const SHORTCUTS: { keys: string[]; label: string }[] = [
|
|||||||
{ keys: ['⌘', 'S'], label: 'Save the current file' },
|
{ keys: ['⌘', 'S'], label: 'Save the current file' },
|
||||||
{ keys: ['⌘', 'W'], label: 'Close the current file' },
|
{ keys: ['⌘', 'W'], label: 'Close the current file' },
|
||||||
{ keys: ['⌘', 'D'], label: 'Delete the current file (confirm)' },
|
{ keys: ['⌘', 'D'], label: 'Delete the current file (confirm)' },
|
||||||
|
{ keys: ['⌘', 'N'], label: 'Open the project note (.notes.txt, saved on focus loss)' },
|
||||||
|
{ keys: ['⌘', '→'], label: 'Note: pass the whole note to the agent' },
|
||||||
{ keys: ['⌘', 'O'], label: 'Open a project folder' },
|
{ keys: ['⌘', 'O'], label: 'Open a project folder' },
|
||||||
{ keys: ['⇧', '⌘', 'O'], label: 'Open a recent project (history picker)' },
|
{ keys: ['⇧', '⌘', 'O'], label: 'Open a recent project (history picker)' },
|
||||||
{ keys: ['Esc'], label: 'Close an overlay / split view' },
|
{ keys: ['Esc'], label: 'Close an overlay / split view' },
|
||||||
@@ -438,7 +440,7 @@ export function HelpModal({ onClose }: { onClose: () => void }): React.ReactElem
|
|||||||
<div className="pi">
|
<div className="pi">
|
||||||
{Icon.help({ style: { color: 'var(--fg-3)' } })}
|
{Icon.help({ style: { color: 'var(--fg-3)' } })}
|
||||||
<span className="hist-title">Keyboard shortcuts</span>
|
<span className="hist-title">Keyboard shortcuts</span>
|
||||||
<span className="mode-chip"><kbd>esc</kbd></span>
|
<kbd>esc</kbd>
|
||||||
</div>
|
</div>
|
||||||
<div className="help-list">
|
<div className="help-list">
|
||||||
{SHORTCUTS.map((s, i) => (
|
{SHORTCUTS.map((s, i) => (
|
||||||
@@ -453,6 +455,44 @@ export function HelpModal({ onClose }: { onClose: () => void }): React.ReactElem
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scratch note for the project, stored as plain text in `.notes.txt`.
|
||||||
|
*
|
||||||
|
* The overlay only edits the text. Saving is the App's job, because the note
|
||||||
|
* must also be written when the window loses focus with the overlay shut.
|
||||||
|
* ⌘→ (pass the note to the agent) is the App's job too — it owns the shortcut.
|
||||||
|
*/
|
||||||
|
export function NotesModal({ text, onChange, onClose }: {
|
||||||
|
text: string
|
||||||
|
onChange: (text: string) => void
|
||||||
|
onClose: () => void
|
||||||
|
}): React.ReactElement {
|
||||||
|
const ref = useRef<HTMLTextAreaElement>(null)
|
||||||
|
useEffect(() => {
|
||||||
|
const el = ref.current
|
||||||
|
if (!el) return
|
||||||
|
el.focus()
|
||||||
|
// Caret at the end, so you carry on writing instead of overtyping.
|
||||||
|
el.setSelectionRange(el.value.length, el.value.length)
|
||||||
|
}, [])
|
||||||
|
return (
|
||||||
|
<div className="scrim" onMouseDown={onClose}>
|
||||||
|
<div className="notes-modal" onMouseDown={(e) => e.stopPropagation()}>
|
||||||
|
<div className="pi">
|
||||||
|
{Icon.note({ style: { color: 'var(--fg-3)' } })}
|
||||||
|
<span className="hist-title">Note</span>
|
||||||
|
<span className="notes-file">.notes.txt</span>
|
||||||
|
<span className="notes-hint">{Icon.spark()} To agent <kbd>⌘→</kbd></span>
|
||||||
|
<kbd>esc</kbd>
|
||||||
|
</div>
|
||||||
|
<textarea ref={ref} className="notes-input" spellCheck={false}
|
||||||
|
placeholder="Anything you want to keep next to this project…"
|
||||||
|
value={text} onChange={(e) => onChange(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/* Generic confirm dialog — ↵ confirms, Esc cancels. Listens in capture phase so
|
/* Generic confirm dialog — ↵ confirms, Esc cancels. Listens in capture phase so
|
||||||
* it owns the keyboard while open. */
|
* it owns the keyboard while open. */
|
||||||
export function ConfirmModal({ title, body, confirmLabel, danger, onConfirm, onClose }: {
|
export function ConfirmModal({ title, body, confirmLabel, danger, onConfirm, onClose }: {
|
||||||
|
|||||||
@@ -55,6 +55,12 @@ body {
|
|||||||
#root { height:100vh; }
|
#root { height:100vh; }
|
||||||
::selection { background:rgba(241,159,63,0.30); }
|
::selection { background:rgba(241,159,63,0.30); }
|
||||||
|
|
||||||
|
/* One key chip, used by every shortcut hint in the app — title bar, modal
|
||||||
|
headers, empty editor, context hints. Components may only add layout
|
||||||
|
(flex, min-width, alignment) or a colour that their own surface demands. */
|
||||||
|
kbd { flex:0 0 auto; font-family:var(--mono); font-size:11.5px; color:var(--fg-3);
|
||||||
|
background:transparent; border:1px solid var(--border-2); border-radius:4px; padding:1px 5px; }
|
||||||
|
|
||||||
/* scrollbars */
|
/* scrollbars */
|
||||||
::-webkit-scrollbar { width:11px; height:11px; }
|
::-webkit-scrollbar { width:11px; height:11px; }
|
||||||
::-webkit-scrollbar-thumb { background:#393e46; border-radius:6px; border:3px solid transparent; background-clip:content-box; }
|
::-webkit-scrollbar-thumb { background:#393e46; border-radius:6px; border:3px solid transparent; background-clip:content-box; }
|
||||||
@@ -106,16 +112,16 @@ body {
|
|||||||
.tb-crumb .tb-dirty { color:var(--mod); font-size:10px; margin-left:4px; }
|
.tb-crumb .tb-dirty { color:var(--mod); font-size:10px; margin-left:4px; }
|
||||||
.tb-spacer { flex:1; }
|
.tb-spacer { flex:1; }
|
||||||
.tb-actions { display:flex; gap:6px; align-items:center; }
|
.tb-actions { display:flex; gap:6px; align-items:center; }
|
||||||
|
/* Title-bar actions are borderless — the accent alone says "on", so no On/Off
|
||||||
|
badge is needed. Hover is the only other surface they get. */
|
||||||
.tb-btn {
|
.tb-btn {
|
||||||
font-size:11.5px; color:var(--fg-2); background:transparent; border:1px solid transparent;
|
font-size:11.5px; color:var(--fg-2); background:transparent; border:0;
|
||||||
border-radius:6px; padding:4px 9px; cursor:pointer; display:flex; align-items:center; gap:6px;
|
border-radius:6px; padding:4px 9px; cursor:pointer; display:flex; align-items:center; gap:6px;
|
||||||
}
|
}
|
||||||
.tb-btn:hover { background:var(--hover); color:var(--fg-0); }
|
.tb-btn:hover { background:var(--hover); color:var(--fg-0); }
|
||||||
.tb-btn kbd { font-family:var(--mono); font-size:11.5px; color:var(--fg-3); border:1px solid var(--border-2); border-radius:4px; padding:1px 5px; }
|
.tb-toggle.on, .tb-toggle.on:hover { color:var(--accent); }
|
||||||
.tb-toggle { border-color:var(--border); }
|
.tb-toggle.on kbd { color:var(--accent); border-color:var(--accent-line); }
|
||||||
.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:hover { background:var(--accent-soft); }
|
||||||
.tb-toggle.on { color:var(--fg-1); border-color:var(--border-2); }
|
|
||||||
.tb-toggle.on .tb-state { background:var(--accent-soft); color:var(--accent); }
|
|
||||||
|
|
||||||
.workbench { flex:1; display:flex; min-height:0; }
|
.workbench { flex:1; display:flex; min-height:0; }
|
||||||
|
|
||||||
@@ -224,7 +230,17 @@ body {
|
|||||||
/* ============ editor ============ */
|
/* ============ editor ============ */
|
||||||
|
|
||||||
.editor-wrap { flex:1; min-height:0; display:flex; flex-direction:column; position:relative; }
|
.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; }
|
/* One grid column, minmax(max-content, 1fr). The track's base is the longest
|
||||||
|
line, so every row stretches to it and keeps painting its add/del background
|
||||||
|
all the way to the right edge. Plain block rows stop at the viewport, so a
|
||||||
|
changed line lost its colour the moment you scrolled right.
|
||||||
|
The 1fr max handles the other direction: when the file is narrower than the
|
||||||
|
pane the track grows to fill it. Do not flip this to minmax(100%, max-content)
|
||||||
|
— a track only grows past its base into free space, and a scrolled pane has
|
||||||
|
none, so it would pin every row to the viewport width again.
|
||||||
|
align-content:start stops a short file from stretching rows vertically. */
|
||||||
|
.editor { flex:1; overflow:auto; font-family:var(--code-font); font-size:var(--code-size); line-height:20px; padding:6px 0 40px;
|
||||||
|
display:grid; grid-template-columns:minmax(max-content, 1fr); align-content:start; }
|
||||||
.ln-row { display:flex; align-items:flex-start; min-height:20px; }
|
.ln-row { display:flex; align-items:flex-start; min-height:20px; }
|
||||||
.ln-row.cursor { background:rgba(255,255,255,0.035); }
|
.ln-row.cursor { background:rgba(255,255,255,0.035); }
|
||||||
.ln-row.add { background:var(--add-bg); }
|
.ln-row.add { background:var(--add-bg); }
|
||||||
@@ -236,11 +252,13 @@ body {
|
|||||||
.ln-sign { flex:0 0 14px; width:14px; text-align:center; user-select:none; color:var(--fg-3); }
|
.ln-sign { flex:0 0 14px; width:14px; text-align:center; user-select:none; color:var(--fg-3); }
|
||||||
.ln-row.add .ln-sign { color:var(--add); }
|
.ln-row.add .ln-sign { color:var(--add); }
|
||||||
.ln-row.del .ln-sign { color:var(--del); }
|
.ln-row.del .ln-sign { color:var(--del); }
|
||||||
.ln-code { flex:1; white-space:pre; padding:0 16px 0 6px; min-width:0; }
|
/* flex-basis auto (not 0) so the line's real width counts towards the row's
|
||||||
|
intrinsic size. With basis 0 the grid track above collapses to the viewport
|
||||||
|
and the add/del background stops at the fold again. */
|
||||||
|
.ln-code { flex:1 0 auto; white-space:pre; padding:0 16px 0 6px; min-width:0; }
|
||||||
.editor.diff .ln-code { padding-left:6px; }
|
.editor.diff .ln-code { padding-left:6px; }
|
||||||
.empty-ed { flex:1; display:flex; flex-direction:column; align-items:center; justify-content:center; color:var(--fg-3); gap:14px; }
|
.empty-ed { flex:1; display:flex; flex-direction:column; align-items:center; justify-content:center; color:var(--fg-3); gap:14px; }
|
||||||
.empty-ed .big { font-size:13px; }
|
.empty-ed .big { font-size:13px; }
|
||||||
.empty-ed kbd { font-family:var(--mono); font-size:12px; color:var(--fg-2); border:1px solid var(--border-2); border-radius:5px; padding:2px 7px; }
|
|
||||||
.empty-ed .klist { display:flex; flex-direction:column; gap:9px; font-size:12px; }
|
.empty-ed .klist { display:flex; flex-direction:column; gap:9px; font-size:12px; }
|
||||||
.empty-ed .klist div { display:flex; gap:10px; align-items:center; justify-content:space-between; min-width:230px; }
|
.empty-ed .klist div { display:flex; gap:10px; align-items:center; justify-content:space-between; min-width:230px; }
|
||||||
|
|
||||||
@@ -310,7 +328,6 @@ body {
|
|||||||
.split-head .git-stat { font-size:11px; }
|
.split-head .git-stat { font-size:11px; }
|
||||||
.split-exit { margin-left:auto; display:flex; align-items:center; gap:7px; background:transparent; border:1px solid var(--border-2); border-radius:7px; color:var(--fg-1); font:inherit; font-size:12px; padding:5px 11px; cursor:pointer; }
|
.split-exit { margin-left:auto; display:flex; align-items:center; gap:7px; background:transparent; border:1px solid var(--border-2); border-radius:7px; color:var(--fg-1); font:inherit; font-size:12px; padding:5px 11px; cursor:pointer; }
|
||||||
.split-exit:hover { background:var(--hover); color:var(--fg-0); }
|
.split-exit:hover { background:var(--hover); color:var(--fg-0); }
|
||||||
.split-exit kbd { font-family:var(--mono); font-size:11.5px; color:var(--fg-3); border:1px solid var(--border-2); border-radius:4px; padding:1px 5px; }
|
|
||||||
.split-body { flex:1; display:flex; min-height:0; }
|
.split-body { flex:1; display:flex; min-height:0; }
|
||||||
.split-pane { flex:1; min-width:0; display:flex; flex-direction:column; }
|
.split-pane { flex:1; min-width:0; display:flex; flex-direction:column; }
|
||||||
.split-pane.left { border-right:1px solid var(--border-2); }
|
.split-pane.left { border-right:1px solid var(--border-2); }
|
||||||
@@ -408,7 +425,6 @@ body {
|
|||||||
.history-modal .pi svg { flex:0 0 auto; }
|
.history-modal .pi svg { flex:0 0 auto; }
|
||||||
.history-modal .hist-title { flex:1; min-width:0; color:var(--fg-1); font-size:14px; }
|
.history-modal .hist-title { flex:1; min-width:0; color:var(--fg-1); font-size:14px; }
|
||||||
.history-modal .mode-chip { flex:0 0 auto; white-space:nowrap; font-size:10px; color:var(--fg-3); border:1px solid var(--border-2); border-radius:5px; padding:2px 7px; font-family:var(--mono); display:flex; align-items:center; gap:4px; }
|
.history-modal .mode-chip { flex:0 0 auto; white-space:nowrap; font-size:10px; color:var(--fg-3); border:1px solid var(--border-2); border-radius:5px; padding:2px 7px; font-family:var(--mono); display:flex; align-items:center; gap:4px; }
|
||||||
.history-modal .mode-chip kbd { font-family:var(--mono); font-size:11.5px; 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-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 { 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.sel { background:var(--accent-dim, rgba(241,159,63,0.14)); box-shadow:inset 2px 0 0 var(--accent); }
|
||||||
@@ -444,7 +460,6 @@ body {
|
|||||||
.pass-preview code { font-family:var(--mono); font-size:11.5px; color:var(--accent); background:var(--accent-soft); border-radius:5px; padding:3px 7px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
.pass-preview code { font-family:var(--mono); font-size:11.5px; color:var(--accent); background:var(--accent-soft); border-radius:5px; padding:3px 7px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||||
.pass-preview code.multiline { white-space:pre-wrap; text-overflow:clip; max-height:132px; overflow:auto; word-break:break-word; }
|
.pass-preview code.multiline { white-space:pre-wrap; text-overflow:clip; max-height:132px; overflow:auto; word-break:break-word; }
|
||||||
.pass-foot { margin-top:9px; font-size:10.5px; color:var(--fg-3); }
|
.pass-foot { margin-top:9px; font-size:10.5px; color:var(--fg-3); }
|
||||||
.pass-foot kbd { font-family:var(--mono); font-size:11.5px; color:var(--fg-2); border:1px solid var(--border-2); border-radius:4px; padding:0 5px; }
|
|
||||||
|
|
||||||
/* terminal multi-line input */
|
/* terminal multi-line input */
|
||||||
.term-input { align-items:flex-start; }
|
.term-input { align-items:flex-start; }
|
||||||
@@ -506,6 +521,8 @@ body {
|
|||||||
decorative dots are hidden and the bar is made draggable. Interactive controls
|
decorative dots are hidden and the bar is made draggable. Interactive controls
|
||||||
opt back out of the drag region. */
|
opt back out of the drag region. */
|
||||||
.titlebar { -webkit-app-region: drag; padding-left: 82px; }
|
.titlebar { -webkit-app-region: drag; padding-left: 82px; }
|
||||||
|
/* Fullscreen: no traffic lights, so the project name moves back to the edge. */
|
||||||
|
.titlebar.fullscreen { padding-left: 12px; }
|
||||||
.titlebar .traffic { display: none; }
|
.titlebar .traffic { display: none; }
|
||||||
.titlebar button,
|
.titlebar button,
|
||||||
.titlebar input,
|
.titlebar input,
|
||||||
@@ -534,21 +551,33 @@ body {
|
|||||||
.lp-txt { min-width:0; display:flex; flex-direction:column; line-height:1.3; flex:1; }
|
.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-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-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:11.5px; 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 { 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:11.5px; color:var(--fg-2); border:1px solid var(--border-2); border-radius:4px; padding:0 5px; }
|
|
||||||
|
|
||||||
/* ============ keyboard-shortcuts (help) modal ============ */
|
/* ============ keyboard-shortcuts (help) modal ============ */
|
||||||
|
/* Project note (.notes.txt). Capped at 1000px so the text stays readable on a
|
||||||
|
wide screen; the height fills nearly the whole window, with a floor for small
|
||||||
|
ones, because a note is usually long. */
|
||||||
|
.notes-modal { width:1000px; max-width:92vw; height:calc(100vh - 116px); min-height:260px;
|
||||||
|
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; }
|
||||||
|
.notes-modal .pi { display:flex; align-items:center; gap:10px; padding:12px 15px; border-bottom:1px solid var(--border); }
|
||||||
|
.notes-modal .pi svg { flex:0 0 auto; }
|
||||||
|
.notes-modal .hist-title { color:var(--fg-1); font-size:14px; }
|
||||||
|
.notes-modal .notes-file { flex:1; min-width:0; font-family:var(--mono); font-size:10.5px; color:var(--fg-3); }
|
||||||
|
.notes-modal .notes-hint { flex:0 0 auto; display:flex; align-items:center; gap:6px; font-size:11.5px; color:var(--fg-2); }
|
||||||
|
.notes-input { flex:1; min-height:0; width:100%; resize:none; background:transparent; border:0; outline:0;
|
||||||
|
padding:14px 16px; color:var(--fg-1); font-family:var(--code-font); font-size:var(--code-size); line-height:20px; }
|
||||||
|
.notes-input::placeholder { color:var(--fg-3); }
|
||||||
|
|
||||||
.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 { 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 { 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 .pi svg { flex:0 0 auto; }
|
||||||
.help-modal .hist-title { flex:1; min-width:0; color:var(--fg-1); font-size:14px; }
|
.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-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 { display:flex; align-items:center; gap:14px; padding:6px 12px; border-radius:7px; }
|
||||||
.help-row:hover { background:var(--hover); }
|
.help-row:hover { background:var(--hover); }
|
||||||
.help-keys { flex:0 0 96px; display:flex; gap:4px; justify-content:flex-end; }
|
.help-keys { flex:0 0 96px; display:flex; gap:4px; justify-content:flex-end; }
|
||||||
.help-keys kbd { font-family:var(--mono); font-size:12px; 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-keys kbd { min-width:20px; text-align:center; }
|
||||||
.help-label { font-size:12.5px; color:var(--fg-2); }
|
.help-label { font-size:12.5px; color:var(--fg-2); }
|
||||||
|
|
||||||
/* title-bar icon-only button (help ?) */
|
/* title-bar icon-only button (help ?) */
|
||||||
@@ -561,7 +590,6 @@ body {
|
|||||||
.cf-actions { margin-top:18px; display:flex; justify-content:flex-end; gap:9px; }
|
.cf-actions { margin-top:18px; display:flex; justify-content:flex-end; gap:9px; }
|
||||||
.cf-btn { display:flex; align-items:center; gap:7px; font-size:12.5px; color:var(--fg-1); background:var(--bg-2); border:1px solid var(--border-2); border-radius:7px; padding:7px 13px; cursor:pointer; }
|
.cf-btn { display:flex; align-items:center; gap:7px; font-size:12.5px; color:var(--fg-1); background:var(--bg-2); border:1px solid var(--border-2); border-radius:7px; padding:7px 13px; cursor:pointer; }
|
||||||
.cf-btn:hover { background:var(--hover); color:var(--fg-0); }
|
.cf-btn:hover { background:var(--hover); color:var(--fg-0); }
|
||||||
.cf-btn kbd { font-family:var(--mono); font-size:11.5px; color:var(--fg-3); border:1px solid var(--border-2); border-radius:4px; padding:0 5px; }
|
|
||||||
.cf-yes { background:var(--accent); color:#201608; border-color:transparent; font-weight:600; }
|
.cf-yes { background:var(--accent); color:#201608; border-color:transparent; font-weight:600; }
|
||||||
.cf-yes:hover { background:#f6b35f; color:#201608; }
|
.cf-yes:hover { background:#f6b35f; color:#201608; }
|
||||||
.cf-yes kbd { color:#201608; border-color:rgba(0,0,0,.25); }
|
.cf-yes kbd { color:#201608; border-color:rgba(0,0,0,.25); }
|
||||||
@@ -570,7 +598,7 @@ body {
|
|||||||
.cf-yes.danger kbd { color:#fff; border-color:rgba(255,255,255,.4); }
|
.cf-yes.danger kbd { color:#fff; border-color:rgba(255,255,255,.4); }
|
||||||
|
|
||||||
/* search: active result column + file-name selection */
|
/* search: active result column + file-name selection */
|
||||||
.sc-head .col-kbd { margin-left:auto; font-family:var(--mono); font-size:11px; color:var(--fg-3); border:1px solid var(--border-2); border-radius:4px; padding:0 4px; opacity:.55; }
|
.sc-head .col-kbd { margin-left:auto; opacity:.55; }
|
||||||
.sc-left.active .sc-head, .sc-right.active .sc-head, .sc-infile.active .sc-head { color:var(--accent); }
|
.sc-left.active .sc-head, .sc-right.active .sc-head, .sc-infile.active .sc-head { color:var(--accent); }
|
||||||
.sc-left.active .sc-head .col-kbd, .sc-right.active .sc-head .col-kbd, .sc-infile.active .sc-head .col-kbd { color:var(--accent); border-color:var(--accent-line); opacity:1; }
|
.sc-left.active .sc-head .col-kbd, .sc-right.active .sc-head .col-kbd, .sc-infile.active .sc-head .col-kbd { color:var(--accent); border-color:var(--accent-line); opacity:1; }
|
||||||
.sc-infile.active .sc-head .scf-name { color:var(--accent); }
|
.sc-infile.active .sc-head .scf-name { color:var(--accent); }
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ function stubBridge(): void {
|
|||||||
mkdir: async () => {},
|
mkdir: async () => {},
|
||||||
},
|
},
|
||||||
shell: { reveal: noop },
|
shell: { reveal: noop },
|
||||||
|
notes: { read: async () => '', write: async () => {} },
|
||||||
git: {
|
git: {
|
||||||
// Exactly what git-service now returns for porcelain "MM".
|
// Exactly what git-service now returns for porcelain "MM".
|
||||||
load: async () => ({
|
load: async () => ({
|
||||||
|
|||||||
Reference in New Issue
Block a user