diff --git a/src/main/index.ts b/src/main/index.ts index 0328d2a..5aab7eb 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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: /.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 Helder 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 }) => { diff --git a/src/preload/index.ts b/src/preload/index.ts index ef273bc..0ebc848 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -36,6 +36,10 @@ const api = { reveal: (path: string): void => { ipcRenderer.invoke('shell:reveal', path) }, }, + notes: { + read: (): Promise => ipcRenderer.invoke('notes:read'), + write: (text: string): Promise => 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() diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index f2777ac..0e1ca6b 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -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>(new Set()) const [cursor, setCursor] = useState(null) const [selection, setSelection] = useState(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([]) 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>({}) @@ -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 /.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 (
{/* title bar */} -
+
actions.openFolder()}>{proj.name} @@ -834,15 +900,19 @@ export function App(): React.ReactElement {
+
@@ -922,6 +992,7 @@ export function App(): React.ReactElement { {overlay === 'history' && setOverlay(null)} changeSet={changeSet} />} {overlay === 'projects' && actions.openProjectPath(p)} onClose={() => setOverlay(null)} />} {overlay === 'help' && setOverlay(null)} />} + {overlay === 'notes' && { setOverlay(null); saveNote() }} />} {confirm && setConfirm(null)} />} {menu && setMenu(null)} />} diff --git a/src/renderer/src/components.tsx b/src/renderer/src/components.tsx index 0dbc12a..8cb0f68 100644 --- a/src/renderer/src/components.tsx +++ b/src/renderer/src/components.tsx @@ -23,6 +23,7 @@ export const Icon: Record React.ReactElement> = { check: (p) => (), discard: (p) => (), layout: (p) => (), + note: (p) => (), help: (p) => (), trash: (p) => (), finder: (p) => (), diff --git a/src/renderer/src/editor.tsx b/src/renderer/src/editor.tsx index f3dc0b8..3ed73b3 100644 --- a/src/renderer/src/editor.tsx +++ b/src/renderer/src/editor.tsx @@ -313,11 +313,13 @@ export function Editor({ active, mode, side, setMode, onContext, onSplit, splitO
{Icon.file({ width: 30, height: 30 })}
No file open
+ {/* Only what works with no file open — Copy reference and Pass on to + Agent need a file, so they are not advertised here. */}
-
Open folder⌘ O
-
Search files & content⌘ F
-
Copy referenceright-click
-
Pass on to Agentright-click
+
Open folder⌘O
+
Recent projects⇧⌘O
+
Search files & content⌘F
+
Project note⌘N
) : ( diff --git a/src/renderer/src/env.d.ts b/src/renderer/src/env.d.ts index e437877..4c3f7e9 100644 --- a/src/renderer/src/env.d.ts +++ b/src/renderer/src/env.d.ts @@ -32,6 +32,10 @@ interface HelderBridge { shell: { reveal: (path: string) => void } + notes: { + read: () => Promise + write: (text: string) => Promise + } git: { load: () => Promise<{ branch: string; changes: GitChangeRaw[] } | null> stage: (paths: string[]) => Promise @@ -70,6 +74,7 @@ interface HelderBridge { open: () => Promise reveal: () => Promise } + onFullscreen: (cb: (on: boolean) => void) => () => void onProjectChanged: (cb: () => void) => () => void onConfigChanged: (cb: () => void) => () => void onRefresh: (cb: () => void) => () => void diff --git a/src/renderer/src/overlays.tsx b/src/renderer/src/overlays.tsx index 313c24e..fa98a48 100644 --- a/src/renderer/src/overlays.tsx +++ b/src/renderer/src/overlays.tsx @@ -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
{Icon.help({ style: { color: 'var(--fg-3)' } })} Keyboard shortcuts - esc + esc
{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(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 ( +
+
e.stopPropagation()}> +
+ {Icon.note({ style: { color: 'var(--fg-3)' } })} + Note + .notes.txt + {Icon.spark()} To agent ⌘→ + esc +
+