improvements

This commit is contained in:
2026-07-29 14:17:56 +02:00
parent 03e16d49a1
commit daf8945da7
9 changed files with 204 additions and 29 deletions

View File

@@ -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 { getConfig, getRecent, getThemeCss, resolveConfig, setRecent } from './config'
import { listFiles, searchContent } from './search-service'
import { readNote, writeNote } from './notes-service'
import { initDiagnostics, openLog, revealLog, watchWindow } from './diagnostics'
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: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) })
// 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('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.
win.on('page-title-updated', (e) => e.preventDefault())
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)
win.webContents.setWindowOpenHandler(({ url }) => {

View File

@@ -36,6 +36,10 @@ const api = {
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: {
load: () => ipcRenderer.invoke('git:load'),
stage: (paths: string[]) => ipcRenderer.invoke('git:stage', paths),
@@ -110,6 +114,13 @@ const api = {
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. */
onRefresh: (cb: () => void): (() => void) => {
const handler = (): void => cb()

View File

@@ -5,7 +5,7 @@ import type { ContextTarget } from './components'
import { Editor, SplitView } from './editor'
import type { Cursor, Mode, Selection } from './editor'
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 type { Menu, Toast } from './overlays'
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 [cursor, setCursor] = useState<Cursor | 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
// 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.
const [history, setHistory] = useState<string[]>([])
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
// (handy when juggling several project windows).
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).
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
// 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.
@@ -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 })
}, [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),
// but only once it's been loaded for this project (so we never clobber it with []).
useEffect(() => {
@@ -661,6 +708,18 @@ export function App(): React.ReactElement {
}
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 {
if ((window.getSelection()?.toString() ?? '') !== '') return true
const ae = document.activeElement as HTMLInputElement | HTMLTextAreaElement | null
@@ -695,10 +754,15 @@ export function App(): React.ReactElement {
}
if (e.key === 'Escape') {
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 setMenu(null)
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).
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') }
// ⌘P pushes the current branch to its remote.
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() === 'w') { e.preventDefault(); if (active) closeTab(active) }
// ⌘D deletes the current file (with confirmation).
@@ -818,7 +884,7 @@ export function App(): React.ReactElement {
return (
<div className="app">
{/* 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="tb-title">
<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">
<button className={'tb-btn tb-toggle' + (overlay === 'search' ? ' on' : '')} onClick={() => { setSearchInit(''); setOverlay('search') }}
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 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.'}>
{Icon.layout()} Auto-fit <span className="tb-state">{autoResize ? 'On' : 'Off'}</span> <kbd>A</kbd>
{Icon.layout()} Auto-fit <kbd>A</kbd>
</button>
<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.'}>
{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 className="tb-btn tb-icon" onClick={() => setOverlay('help')} title="Keyboard shortcuts">{Icon.help()}</button>
</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 === 'projects' && <ProjectsModal recents={proj.recents} currentRoot={proj.root} onOpen={(p) => actions.openProjectPath(p)} 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)} />}
{menu && <ContextMenu menu={menu} onClose={() => setMenu(null)} />}
<Toasts toasts={toasts} />

View File

@@ -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>),
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>),
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>),
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>),

View File

@@ -313,11 +313,13 @@ export function Editor({ active, mode, side, setMode, onContext, onSplit, splitO
<div className="empty-ed">
<div style={{ opacity: 0.5 }}>{Icon.file({ width: 30, height: 30 })}</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><span>Open folder</span><kbd>O</kbd></div>
<div><span>Recent projects</span><kbd>O</kbd></div>
<div><span>Search files &amp; content</span><kbd>F</kbd></div>
<div><span>Copy reference</span><kbd>right-click</kbd></div>
<div><span>Pass on to Agent</span><kbd>right-click</kbd></div>
<div><span>Project note</span><kbd>N</kbd></div>
</div>
</div>
) : (

View File

@@ -32,6 +32,10 @@ interface HelderBridge {
shell: {
reveal: (path: string) => void
}
notes: {
read: () => Promise<string>
write: (text: string) => Promise<void>
}
git: {
load: () => Promise<{ branch: string; changes: GitChangeRaw[] } | null>
stage: (paths: string[]) => Promise<void>
@@ -70,6 +74,7 @@ interface HelderBridge {
open: () => Promise<void>
reveal: () => Promise<void>
}
onFullscreen: (cb: (on: boolean) => void) => () => void
onProjectChanged: (cb: () => void) => () => void
onConfigChanged: (cb: () => void) => () => void
onRefresh: (cb: () => void) => () => void

View File

@@ -426,6 +426,8 @@ const SHORTCUTS: { keys: string[]; label: string }[] = [
{ keys: ['⌘', 'S'], label: 'Save the current file' },
{ keys: ['⌘', 'W'], label: 'Close the current file' },
{ 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 recent project (history picker)' },
{ keys: ['Esc'], label: 'Close an overlay / split view' },
@@ -438,7 +440,7 @@ export function HelpModal({ onClose }: { onClose: () => void }): React.ReactElem
<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>
<kbd>esc</kbd>
</div>
<div className="help-list">
{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
* it owns the keyboard while open. */
export function ConfirmModal({ title, body, confirmLabel, danger, onConfirm, onClose }: {

View File

@@ -55,6 +55,12 @@ body {
#root { height:100vh; }
::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 */
::-webkit-scrollbar { width:11px; height:11px; }
::-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-spacer { flex:1; }
.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 {
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;
}
.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 { border-color:var(--border); }
.tb-toggle .tb-state { font-family:var(--mono); font-size:10px; border-radius:4px; padding:1px 5px; background:var(--bg-1); color:var(--fg-3); }
.tb-toggle.on { color:var(--fg-1); border-color:var(--border-2); }
.tb-toggle.on .tb-state { background:var(--accent-soft); color:var(--accent); }
.tb-toggle.on, .tb-toggle.on:hover { color:var(--accent); }
.tb-toggle.on kbd { color:var(--accent); border-color:var(--accent-line); }
.tb-toggle.on:hover { background:var(--accent-soft); }
.workbench { flex:1; display:flex; min-height:0; }
@@ -224,7 +230,17 @@ body {
/* ============ editor ============ */
.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.cursor { background:rgba(255,255,255,0.035); }
.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-row.add .ln-sign { color:var(--add); }
.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; }
.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 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 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-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 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-pane { flex:1; min-width:0; display:flex; flex-direction:column; }
.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 .hist-title { flex:1; min-width:0; color:var(--fg-1); font-size:14px; }
.history-modal .mode-chip { flex:0 0 auto; white-space:nowrap; font-size:10px; color:var(--fg-3); border:1px solid var(--border-2); border-radius:5px; padding:2px 7px; font-family:var(--mono); display:flex; align-items:center; gap:4px; }
.history-modal .mode-chip kbd { font-family:var(--mono); font-size: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-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); }
@@ -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.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 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 */
.term-input { align-items:flex-start; }
@@ -506,6 +521,8 @@ body {
decorative dots are hidden and the bar is made draggable. Interactive controls
opt back out of the drag region. */
.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 button,
.titlebar input,
@@ -534,21 +551,33 @@ body {
.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: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 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 ============ */
/* 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 .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: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); }
/* 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-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 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:hover { background:#f6b35f; color:#201608; }
.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); }
/* 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 .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); }

View File

@@ -44,6 +44,7 @@ function stubBridge(): void {
mkdir: async () => {},
},
shell: { reveal: noop },
notes: { read: async () => '', write: async () => {} },
git: {
// Exactly what git-service now returns for porcelain "MM".
load: async () => ({