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 { 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 }) => {

View File

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

View File

@@ -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} />

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

View File

@@ -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 &amp; content</span><kbd>F</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>Project note</span><kbd>N</kbd></div>
<div><span>Pass on to Agent</span><kbd>right-click</kbd></div>
</div> </div>
</div> </div>
) : ( ) : (

View File

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

View File

@@ -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 }: {

View File

@@ -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); }

View File

@@ -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 () => ({