diff --git a/src/main/notes-service.ts b/src/main/notes-service.ts new file mode 100644 index 0000000..f8bcb76 --- /dev/null +++ b/src/main/notes-service.ts @@ -0,0 +1,28 @@ +/** + * Project scratch note: a plain text file at `/.notes.txt`. + * + * Deliberately not JSON and not part of `.helder/`. It is a note the user + * writes by hand, so it must stay readable and editable outside Helder. + */ +import { readFile, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { logger } from './logger' + +export const NOTES_FILE = '.notes.txt' + +/** The note's text. Empty string when the project has no note yet. */ +export async function readNote(root: string): Promise { + try { + return await readFile(join(root, NOTES_FILE), 'utf8') + } catch (err) { + // ENOENT is the normal "no note yet" case, anything else is worth knowing. + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { + logger.warn('notes', 'read failed', { root, err: String(err) }) + } + return '' + } +} + +export async function writeNote(root: string, text: string): Promise { + await writeFile(join(root, NOTES_FILE), text, 'utf8') +} diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 0e1ca6b..065ba40 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -247,6 +247,24 @@ export function App(): React.ReactElement { const gitSelPath = gitSelRow?.path ?? null const treeSelItem = treeNav[treeSel] ?? null + // Clicking a row moves the keyboard cursor onto it. Without this the cursor + // stays at index 0, so the top row of the list keeps its highlight next to + // whichever row the click actually selected. + const syncGitSel = (target: EventTarget): void => { + const row = (target as HTMLElement).closest?.('.git-row') as HTMLElement | null + const id = row?.dataset.rowId + if (!id) return + const i = gitNav.findIndex((r) => r.id === id) + if (i >= 0) setGitSel(i) + } + const syncTreeSel = (target: EventTarget): void => { + const row = (target as HTMLElement).closest?.('.tree-row') as HTMLElement | null + const path = row?.dataset.rowPath + if (path == null) return + const i = treeNav.findIndex((r) => r.path === path) + if (i >= 0) setTreeSel(i) + } + // Keep the row cursors in range as the lists shrink/grow. useEffect(() => { setGitSel((s) => Math.min(s, Math.max(0, gitNav.length - 1))) }, [gitNav.length]) useEffect(() => { setTreeSel((s) => Math.min(s, Math.max(0, treeNav.length - 1))) }, [treeNav.length]) @@ -708,7 +726,7 @@ export function App(): React.ReactElement { } return false } - // ⌘→ with the note open hands the whole note to the agent. Same route as the + // ⌘P 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 { @@ -761,8 +779,9 @@ export function App(): React.ReactElement { 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 } + // ⌘P with the note open passes the note text to the agent. This runs before + // the global ⌘P (push), so the note wins while its overlay is up. + if (overlay === 'notes' && meta && e.key.toLowerCase() === 'p') { e.preventDefault(); passNote(); return } // Search / help modals own the keyboard while open (they handle their own keys). if (overlay) return @@ -933,7 +952,7 @@ export function App(): React.ReactElement { {/* workbench */}
setActivePanel('git')}> + onMouseDownCapture={(e) => { setActivePanel('git'); syncGitSel(e.target) }}> { setAutoResize(false); setGitW((w) => clamp(w + dx, 160, 460)) }} />
setActivePanel('tree')}> + onMouseDownCapture={(e) => { setActivePanel('tree'); syncTreeSel(e.target) }}> {proj.tree ? ( onOpen(c.path, { diff: true, side })} onContextMenu={(e) => onContext(e, { path: c.path, kind: 'git', staged })} title={c.path}> @@ -204,6 +205,7 @@ function TreeNode({ node, depth, openDirs, toggleDir, onOpen, onContext, activeP {node.path !== '' && (
toggleDir(node.path)} onContextMenu={(e) => onContext(e, { path: node.path, kind: 'dir' })}> @@ -225,6 +227,7 @@ function TreeNode({ node, depth, openDirs, toggleDir, onOpen, onContext, activeP return (
onOpen(node.path)} onContextMenu={(e) => onContext(e, { path: node.path, kind: 'file' })} title={node.path}> diff --git a/src/renderer/src/editor.tsx b/src/renderer/src/editor.tsx index 3ed73b3..89653c5 100644 --- a/src/renderer/src/editor.tsx +++ b/src/renderer/src/editor.tsx @@ -48,6 +48,48 @@ function CodeEditor({ path, text, lang, onChange, onContext }: { if (top < s.scrollTop) s.scrollTop = top - 20 else if (bottom > s.scrollTop + s.clientHeight) s.scrollTop = bottom - s.clientHeight + 20 } + /* Tab indents, it does not move focus out of the editor. + * Plain Tab on one line inserts spaces. Tab over a multi-line selection + * indents every line it touches. Shift+Tab outdents. + * We write through execCommand so the browser keeps its own undo history. */ + function replace(ta: HTMLTextAreaElement, from: number, to: number, text: string): void { + ta.setSelectionRange(from, to) + if (document.execCommand?.('insertText', false, text)) return + // No execCommand (jsdom): splice by hand. Costs the native undo step. + onChange(ta.value.slice(0, from) + text + ta.value.slice(to)) + } + function handleTab(e: React.KeyboardEvent, out: boolean): void { + e.preventDefault() + const ta = e.currentTarget + const pad = ' '.repeat(tabSize) + const from = ta.selectionStart + const to = ta.selectionEnd + if (!out && !ta.value.slice(from, to).includes('\n')) { + replace(ta, from, to, pad) + ta.setSelectionRange(from + pad.length, from + pad.length) + ensureCaretVisible(ta) + return + } + // Rewrite whole lines, so grow the range to the line edges first. A + // selection that stops at column 0 leaves that last line alone. + const start = ta.value.lastIndexOf('\n', from - 1) + 1 + const tail = to > from && ta.value[to - 1] === '\n' ? to - 1 : to + const nl = ta.value.indexOf('\n', tail) + const end = nl === -1 ? ta.value.length : nl + const lines = ta.value.slice(start, end).split('\n') + const lead = new RegExp(`^(\t| {1,${tabSize}})`) + const cut = (line: string): number => (out ? (lead.exec(line)?.[0].length ?? 0) : 0) + const next = lines.map((line) => (out ? line.slice(cut(line)) : pad + line)).join('\n') + if (next === ta.value.slice(start, end)) return + const head = out ? -cut(lines[0]) : pad.length + const total = out ? -lines.reduce((n, line) => n + cut(line), 0) : pad.length * lines.length + replace(ta, start, end, next) + ta.setSelectionRange(Math.max(start, from + head), Math.max(start, to + total)) + ensureCaretVisible(ta) + } + function onKeyDown(e: React.KeyboardEvent): void { + if (e.key === 'Tab' && !e.metaKey && !e.ctrlKey && !e.altKey) handleTab(e, e.shiftKey) + } function handleContext(e: React.MouseEvent): void { e.preventDefault() const ta = e.currentTarget @@ -72,6 +114,7 @@ function CodeEditor({ path, text, lang, onChange, onContext }: {