diff --git a/.helder/config.default.json b/.helder/config.default.json index c6e29c5..1f201ad 100644 --- a/.helder/config.default.json +++ b/.helder/config.default.json @@ -5,7 +5,9 @@ }, "editor": { "autoSave": false, - "tabSize": 4 + "tabSize": 4, + "wordWrap": "markdown", + "copyOnSelect": true }, "git": { "confirmDiscard": true, @@ -19,7 +21,40 @@ "followGitignore": false }, "terminal": { - "shell": null + "shell": null, + "fontFamily": null, + "fontSize": null, + "lineHeight": 1.7, + "letterSpacing": 0, + "cursorStyle": "bar", + "cursorBlink": true, + "boldIsBright": false, + "scrollback": 8000, + "optionIsMeta": false, + "theme": { + "background": "#101720", + "foreground": "#E4E7E6", + "cursor": "#E8913A", + "cursorAccent": "#101720", + "selectionBackground": "rgba(232,145,58,0.22)", + "selectionInactiveBackground": "rgba(232,145,58,0.12)", + "black": "#232C39", + "red": "#C4741F", + "green": "#8FBFB4", + "yellow": "#F0B476", + "blue": "#8FA9C4", + "magenta": "#C3A6CE", + "cyan": "#8FC4C4", + "white": "#BAC0C0", + "brightBlack": "#6C7783", + "brightRed": "#E8913A", + "brightGreen": "#A6D2C7", + "brightYellow": "#F5C79A", + "brightBlue": "#A9BFD6", + "brightMagenta": "#D6BFDF", + "brightCyan": "#A9D6D6", + "brightWhite": "#F4F5F4" + } }, "session": { "restoreOnLaunch": true diff --git a/docs/design/Helder IDE.dc.html b/docs/design/Helder IDE.dc.html index e536ab7..e7b060b 100644 --- a/docs/design/Helder IDE.dc.html +++ b/docs/design/Helder IDE.dc.html @@ -70,7 +70,7 @@
Search ⌘F - Auto-fit ⌘A + Fluid ⌘L Hidden ⌘. Note ⌘N Git ⌘G @@ -558,7 +558,7 @@
Search ⌘F - Auto-fit ⌘A + Fluid ⌘L Hidden ⌘. Note ⌘N Git ⌘G @@ -566,7 +566,7 @@
Hidden files: hidden — dotfiles excluded from the tree and the search. Click to show.
-
Auto-fit panels: on — columns re-fit on resize and focus. Click to lock the current sizes.
+
Column widths: fluid — the columns re-fit on resize and focus. Click to lock them.
Project note (.notes.txt) — kept next to this project.
diff --git a/docs/design/README.md b/docs/design/README.md index e052c2a..eadb410 100644 --- a/docs/design/README.md +++ b/docs/design/README.md @@ -168,7 +168,7 @@ toolbar, right-aligned, gap 4px. Toolbar toggle: label Sans 12px + keycap. Off = label `#BAC0C0`, no fill. On = fill `rgba(232,145,58,.10)`, label `#F4F5F4` 500, keycap border `rgba(232,145,58,.35)`. Hidden-files-off is the muted case: label and keycap both `#6C7783`. Toggles: -Search ⌘F · Auto-fit ⌘A · Hidden ⌘. · Note ⌘N · Git ⌘G · `?` (26px square, 1px border). +Search ⌘F · Fluid ⌘L · Hidden ⌘. · Note ⌘N · Git ⌘G · `?` (26px square, 1px border). **Source control column** (236px, `#18202B`, 1px right border) Commit box: 52px `#101720` field, 1px `#232C39`, radius 2, placeholder @@ -291,7 +291,7 @@ with amber glyphs. **Tooltip** — `#232C39` surface, 1px `#3A424C`, radius 2, padding 8px 10px, max 300px, Sans 12px/1.5 `#E4E7E6`. No arrow, no shadow. The current state is named inside the -sentence in amber ("Hidden files: *hidden* — …", "Auto-fit panels: *on* — …"), then one +sentence in amber ("Hidden files: *hidden* — …", "Column widths: *fluid* — …"), then one sentence saying what a click does. **Markdown Actual / Preview** — same 34px header on both halves; the segmented control's diff --git a/releases/Helder-0.1.0-arm64.dmg b/releases/Helder-0.1.0-arm64.dmg new file mode 100644 index 0000000..638c2c0 Binary files /dev/null and b/releases/Helder-0.1.0-arm64.dmg differ diff --git a/releases/helder-win-v0.1.0.zip b/releases/helder-win-v0.1.0.zip new file mode 100644 index 0000000..d073331 Binary files /dev/null and b/releases/helder-win-v0.1.0.zip differ diff --git a/src/main/config.ts b/src/main/config.ts index 49a115d..d435076 100644 --- a/src/main/config.ts +++ b/src/main/config.ts @@ -27,7 +27,7 @@ export interface TerminalTheme { export interface HelderConfig { ai: { command: string; autoLaunch: boolean } - editor: { autoSave: boolean; tabSize: number; wordWrap: WordWrap } + editor: { autoSave: boolean; tabSize: number; wordWrap: WordWrap; copyOnSelect: boolean } git: { confirmDiscard: boolean; confirmStage: boolean; confirmUnstage: boolean; defaultDiffMode: DiffMode; refreshInterval: number } files: { exclude: string[]; followGitignore: boolean } terminal: { @@ -54,7 +54,7 @@ export interface HelderConfig { export const DEFAULTS: HelderConfig = { ai: { command: 'claude', autoLaunch: true }, - editor: { autoSave: false, tabSize: 4, wordWrap: 'markdown' }, + editor: { autoSave: false, tabSize: 4, wordWrap: 'markdown', copyOnSelect: true }, git: { confirmDiscard: true, confirmStage: false, confirmUnstage: false, defaultDiffMode: 'diff', refreshInterval: 10000 }, files: { exclude: [], followGitignore: false }, terminal: { diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index b48ac6b..6da2447 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1,5 +1,5 @@ /* App shell: 4 resizable columns, keyboard shortcuts, copy-reference */ -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { FileTree, GitPanel, Icon, Tip, groupByDir } from './components' import type { ContextTarget } from './components' import { Editor, SplitView } from './editor' @@ -10,6 +10,7 @@ import { ProjectLauncher } from './launcher' import type { Menu, Toast } from './overlays' import type { DiffSide, FileNode, GitStatus, SymbolLookup } from './types' import { useProject, useProjectActions } from './project' +import { useCopyOnSelect } from './copy-on-select' import { HL } from './highlight' import { loadSymbols } from './symbols' import { rlog } from './log' @@ -17,6 +18,24 @@ import { loadJson, loadNum, saveJson, saveNum } from './persist' const NO_COMMITTED = new Set() +/* Read-only panes carry no caret, so ⌘A has to name its own target: the pane + * that holds the current selection, else the file viewer. The gutter and the + * diff sign are user-select:none, so they stay out of the copied text. */ +const SURFACES = '.editor, .md-view' +function selectAllSurface(): void { + const node = window.getSelection()?.anchorNode ?? null + const from = node && (node.nodeType === 1 ? (node as HTMLElement) : node.parentElement) + const el = from?.closest(SURFACES) + ?? document.querySelector('.split-overlay .editor') + ?? document.querySelector('.editor-wrap .editor, .editor-wrap .md-view') + if (!el) return + const range = document.createRange() + range.selectNodeContents(el) + const sel = window.getSelection() + sel?.removeAllRanges() + sel?.addRange(range) +} + function Splitter({ orientation = 'v', onDelta }: { orientation?: 'v' | 'h'; onDelta: (dx: number, dy: number) => void }): React.ReactElement { const [drag, setDrag] = useState(false) function down(e: React.MouseEvent): void { @@ -48,7 +67,7 @@ function RightColumn({ width, active, onFocus }: { width: number; active: boolea setTopFrac((f) => Math.max(0.18, Math.min(0.82, (f * h + dy) / h))) } return ( -
+
@@ -188,6 +207,8 @@ export function App(): React.ReactElement { // focus agent/terminal → Editor 20% / Right 50% // Re-applied on resize + focus change; dragging still works in between. const FOCUS_RESIZE_BELOW = 1600 + const SPLITTER_W = 1 // .splitter is a 1px flex track + const MIN_EDITOR_W = 280 // Col C never drops below this const [focusZone, setFocusZone] = useState<'default' | 'editor' | 'terminal'>('default') // Which column currently has focus — drives the active-panel tint and keyboard // navigation (arrows move a row cursor in Git/Explorer, ⌘→ opens its menu). @@ -200,7 +221,7 @@ export function App(): React.ReactElement { // Auto panel management: re-fit columns on resize/focus. Manually dragging a // splitter switches it off (the user took control); the title-bar toggle // turns it back on (and immediately re-fits). - const [autoResize, setAutoResize] = useState(true) + const [fluid, setFluid] = useState(true) const [showHidden, setShowHidden] = useState(false) // Col A (Git) visibility. On by default; the title-bar GIT toggle hides the // whole column and its splitter, and the editor takes the free width. @@ -209,7 +230,7 @@ export function App(): React.ReactElement { const [treeW, setTreeW] = useState(() => Math.round(window.innerWidth * 0.15)) const [rightW, setRightW] = useState(() => Math.round(window.innerWidth * 0.3)) useEffect(() => { - if (!autoResize) return + if (!fluid) return function apply(): void { const w = window.innerWidth if (w >= FOCUS_RESIZE_BELOW) { @@ -226,7 +247,29 @@ export function App(): React.ReactElement { apply() window.addEventListener('resize', apply) return () => window.removeEventListener('resize', apply) - }, [focusZone, autoResize]) + }, [focusZone, fluid]) + + // Keep the stored widths inside the window. Col A, B and D are pixel tracks and + // only Col C absorbs the remainder, so a total wider than the window leaves the + // state disagreeing with what CSS shows (the columns carry flex-shrink 1 as the + // net) and the next splitter drag jumps. The budget reads window.innerWidth + // live, never a state copy: a resize sets the widths and the window size in the + // same batch, and a stale copy would scale the new widths against the old size. + const [winBump, bumpWin] = useState(0) + useEffect(() => { + const onResize = (): void => bumpWin((n) => n + 1) + window.addEventListener('resize', onResize) + return () => window.removeEventListener('resize', onResize) + }, []) + useLayoutEffect(() => { + const budget = window.innerWidth - (showGit ? 3 : 2) * SPLITTER_W - MIN_EDITOR_W + const total = (showGit ? gitW : 0) + treeW + rightW + if (total <= budget || total <= 0) return + const k = Math.max(0, budget) / total + if (showGit) setGitW((w) => Math.floor(w * k)) + setTreeW((w) => Math.floor(w * k)) + setRightW((w) => Math.floor(w * k)) + }, [winBump, showGit, gitW, treeW, rightW]) // A hidden Col A must not keep the focus ring or the arrow-key cursor. useEffect(() => { @@ -387,6 +430,8 @@ export function App(): React.ReactElement { saveJson(`helder.session:${proj.root}`, { active, tabMode, tabSide }) }, [active, tabMode, tabSide, proj.root, proj.config.session.restoreOnLaunch]) + useCopyOnSelect(proj.config.editor.copyOnSelect) + function toast(title: string, ref?: string): void { const id = lid() setToasts((t) => [...t, { id, title, ref }]) @@ -613,23 +658,6 @@ export function App(): React.ReactElement { requestAnimationFrame(place) } - // Close the current file view (no tabs anymore — the recent-files list replaces - // them). The file stays in history; ⌘W just clears the editor after a dirty check. - async function closeTab(path: string): Promise { - const buf = buffersRef.current[path] - const dirtyNow = buf != null && buf !== (projRef.current.files[path] ?? '') - if (dirtyNow) { - const bridge = window.helder - const choice = bridge - ? await bridge.dialog.unsavedClose(path) - : (window.confirm(`Discard unsaved changes to ${path}?`) ? 'discard' : 'cancel') - if (choice === 'cancel') return - if (choice === 'save') writeToDisk(path, buf as string) - } - setBuffers((b) => { if (b[path] == null) return b; const n = { ...b }; delete n[path]; return n }) - if (path === active) setActive(null) - } - // ---- context menus ---- // Naming contract: every "Pass on …" action opens the input popup (so the user // can attach a note), and its "Copy …" twin sits directly below it. Pass first, @@ -898,9 +926,6 @@ export function App(): React.ReactElement { // ⌘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). - else if (meta && e.key.toLowerCase() === 'd') { e.preventDefault(); if (active) askDelete(active, false) } else if (meta && e.key.toLowerCase() === 'm') { e.preventDefault(); cycleMode() } // ⌘→ in Git/Explorer opens the selected row's menu; in the editor it passes // the current text selection to the agent (else native nav). @@ -919,10 +944,16 @@ export function App(): React.ReactElement { if (hasSelection()) return e.preventDefault(); focusCommit() } - // ⌘A toggles auto-fit (but let native select-all run inside text fields). + // ⌘A selects all of one surface. A text field has its own select-all; + // outside one we build the range ourselves, because the browser default + // would mark every panel and label in the window. else if (meta && e.key.toLowerCase() === 'a') { if (inField) return - e.preventDefault(); setAutoResize((v) => !v) + e.preventDefault(); selectAllSurface() + } + // ⌘L makes the columns fluid again, or locks them at their current width. + else if (meta && e.key.toLowerCase() === 'l') { + e.preventDefault(); setFluid((v) => !v) } // ⌘. toggles hidden files. Match on e.code so it fires regardless of layout. else if (meta && e.code === 'Period') { @@ -1025,11 +1056,11 @@ export function App(): React.ReactElement { Search ⌘F - Auto-fit panels: on — columns re-fit on resize and focus. Click to lock the current sizes. - : <>Auto-fit panels: off — the sizes are locked. Click to re-enable.}> - {showGit && (<> -
{ setActivePanel('git'); syncGitSel(e.target) }} onMouseOver={(e) => syncGitSel(e.target)}>
- { setAutoResize(false); setGitW((w) => clamp(w + dx, 160, 460)) }} /> + { setFluid(false); setGitW((w) => clamp(w + dx, 160, 460)) }} /> )} -
{ setActivePanel('tree'); syncTreeSel(e.target) }} onMouseOver={(e) => syncTreeSel(e.target)}>
Explorer{proj.name}
@@ -1096,7 +1127,7 @@ export function App(): React.ReactElement {
)}
- { setAutoResize(false); setTreeW((w) => clamp(w + dx, 160, 520)) }} /> + { setFluid(false); setTreeW((w) => clamp(w + dx, 160, 520)) }} />
{ setFocusZone('editor'); setActivePanel('editor') }}>
- { setAutoResize(false); setRightW((w) => { + { setFluid(false); setRightW((w) => { // grow until the editor would drop below ~280px (rather than a fixed cap) - const max = Math.max(280, window.innerWidth - (showGit ? gitW : 0) - treeW - 280) - return clamp(w - dx, 280, max) + const max = Math.max(MIN_EDITOR_W, window.innerWidth - (showGit ? 3 : 2) * SPLITTER_W + - (showGit ? gitW : 0) - treeW - MIN_EDITOR_W) + return clamp(w - dx, MIN_EDITOR_W, max) }) }} /> {/* keyed by root so the PTYs respawn in the new cwd when the project switches */} diff --git a/src/renderer/src/components.tsx b/src/renderer/src/components.tsx index f05b5e8..3be8b82 100644 --- a/src/renderer/src/components.tsx +++ b/src/renderer/src/components.tsx @@ -133,15 +133,18 @@ function GitRow({ c, activePath, activeSide, ctxPath, kbdId, onOpen, onContext, ) } -/** Split a change list into folder groups, keeping the incoming order. The - * keyboard cursor walks the same order, so App must flatten with this too. */ +/** Split a change list into folder groups, in order of first appearance. The + * keyboard cursor walks the same order, so App must flatten with this too. + * git status is not path-sorted, so a folder comes back in separated runs. One + * group per folder keeps the header unique, and with it the React key. */ export function groupByDir(list: Change[]): { dir: string; rows: Change[] }[] { const out: { dir: string; rows: Change[] }[] = [] + const byDir = new Map() for (const c of list) { const dir = c.path.split('/').slice(0, -1).join('/') - const last = out[out.length - 1] - if (last && last.dir === dir) last.rows.push(c) - else out.push({ dir, rows: [c] }) + const group = byDir.get(dir) + if (group) group.rows.push(c) + else { const g = { dir, rows: [c] }; byDir.set(dir, g); out.push(g) } } return out } diff --git a/src/renderer/src/copy-on-select.ts b/src/renderer/src/copy-on-select.ts new file mode 100644 index 0000000..36cce61 --- /dev/null +++ b/src/renderer/src/copy-on-select.ts @@ -0,0 +1,52 @@ +/* Copy-on-select: a settled selection lands on the clipboard with no keystroke. */ +import { useEffect } from 'react' +import { rlog } from './log' + +/** Text of the settled selection, or '' when there is nothing to copy. + * A focused input or textarea keeps its own selection out of the DOM + * selection, so read it directly — but only for the code editor. Elsewhere + * (search box, commit message) a selection is the start of an edit, and the + * clipboard must stay as it is. */ +function selectedText(): string { + const el = document.activeElement as HTMLTextAreaElement | HTMLInputElement | null + if (el && (el.tagName === 'TEXTAREA' || el.tagName === 'INPUT')) { + if (!el.classList.contains('ce-ta')) return '' + const from = el.selectionStart ?? 0 + const to = el.selectionEnd ?? 0 + return from === to ? '' : el.value.slice(from, to) + } + return window.getSelection()?.toString() ?? '' +} + +function write(text: string): void { + const bridge = window.helder + if (bridge && bridge.clipboard) { bridge.clipboard.writeText(text); return } + navigator.clipboard?.writeText(text).catch((e) => rlog.warn('copy-on-select', 'clipboard write failed', String(e))) +} + +/** Copies whatever the user selects, the way both terminal panes already do. + * The mouse and the keyboard both settle a selection, so listen for the end of + * the gesture instead of `selectionchange`, which fires on every pixel of a + * drag. */ +export function useCopyOnSelect(enabled: boolean): void { + useEffect(() => { + if (!enabled) return + let last = '' + function settle(): void { + const text = selectedText() + if (!text.trim() || text === last) return + last = text + write(text) + } + function onKeyUp(e: KeyboardEvent): void { + const selectAll = (e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'a' + if (e.shiftKey || selectAll) settle() + } + document.addEventListener('mouseup', settle) + document.addEventListener('keyup', onKeyUp) + return () => { + document.removeEventListener('mouseup', settle) + document.removeEventListener('keyup', onKeyUp) + } + }, [enabled]) +} diff --git a/src/renderer/src/overlays.tsx b/src/renderer/src/overlays.tsx index e5c28bd..d6ead79 100644 --- a/src/renderer/src/overlays.tsx +++ b/src/renderer/src/overlays.tsx @@ -501,10 +501,9 @@ const SHORTCUTS: { group: string; rows: { keys: string[]; label: string }[] }[] group: 'File', rows: [ { keys: ['⌘', 'S'], label: 'Save this file' }, - { keys: ['⌘', 'W'], label: 'Close this file' }, - { keys: ['⌘', 'D'], label: 'Delete this file' }, { keys: ['⌘', '.'], label: 'Hidden files on or off' }, - { keys: ['⌘', 'A'], label: 'Auto-fit panels on or off' }, + { keys: ['⌘', 'A'], label: 'Select all in this pane' }, + { keys: ['⌘', 'L'], label: 'Fluid columns on or off' }, { keys: ['Esc'], label: 'Close an overlay or a split view' }, ], }, diff --git a/src/renderer/src/styles.css b/src/renderer/src/styles.css index 8765c88..34506f7 100644 --- a/src/renderer/src/styles.css +++ b/src/renderer/src/styles.css @@ -169,7 +169,7 @@ kbd { flex:0 0 auto; font-family:var(--mono); font-size:12px; font-weight:600; c .tb-toggle.muted, .tb-toggle.muted kbd { color:var(--fg-3); } .tb-toggle.muted:hover { color:var(--fg-2); } -.workbench { flex:1; display:flex; min-height:0; } +.workbench { flex:1; display:flex; min-height:0; overflow:hidden; } /* --col-bg holds each column's resting background so the ⌘R flash below can animate back to it, whichever state the column is in. */ @@ -500,7 +500,10 @@ kbd { flex:0 0 auto; font-family:var(--mono); font-size:12px; font-weight:600; c .ln-code .token.deleted,.ce-pre .token.deleted{color:var(--del);} .ln-code .token.inserted,.ce-pre .token.inserted{color:var(--add);} /* ============ terminals (right column) ============ */ -.term-pane { display:flex; flex-direction:column; min-height:0; background:var(--bg-1); } +/* min-width:0 is load-bearing: the pane is a row-flex item, so min-width:auto + would be the xterm's own rendered width. The pane could then grow with the + column but never shrink, and the terminal would keep the wider cols. */ +.term-pane { display:flex; flex-direction:column; min-width:0; min-height:0; background:var(--bg-1); } /* 30px panel header, same label as every other column. A live pane marks itself with an amber dot; nothing pulses. */ .term-head { height:30px; flex:0 0 30px; display:flex; align-items:center; gap:8px; padding:0 12px; background:var(--bg-2); border-bottom:1px solid var(--border); diff --git a/src/renderer/src/terminals.tsx b/src/renderer/src/terminals.tsx index 81b4106..a207e00 100644 --- a/src/renderer/src/terminals.tsx +++ b/src/renderer/src/terminals.tsx @@ -81,6 +81,15 @@ export function Terminal({ kind }: { kind: 'agent' | 'shell' }): React.ReactElem if (sel) bridge.clipboard.writeText(sel) }) } + // ⌘A marks the whole buffer. xterm types on a hidden textarea, so the + // browser default would select that instead of the terminal's own text. + term.attachCustomKeyEventHandler((e) => { + if (e.type !== 'keydown' || !e.metaKey || e.ctrlKey || e.altKey) return true + if (e.key.toLowerCase() !== 'a') return true + e.preventDefault() + term.selectAll() + return false + }) try { fit.fit() } catch { /* host not measured yet */ } let disposed = false @@ -119,6 +128,11 @@ export function Terminal({ kind }: { kind: 'agent' | 'shell' }): React.ReactElem bridge.pty.write(id, d) }) term.onResize(({ cols, rows }) => bridge.pty.resize(id, cols, rows)) + // create() carried the size measured before this promise resolved. A fit + // in between changed xterm only, and onResize fires on a change, never on + // the current value, so the PTY would keep the stale width and the agent + // would wrap at more columns than the pane shows. + bridge.pty.resize(id, term.cols, term.rows) if (kind === 'agent') window.addEventListener('agentPaste', onPaste) }) } else { diff --git a/src/renderer/src/types.ts b/src/renderer/src/types.ts index c8e80d0..4cf088a 100644 --- a/src/renderer/src/types.ts +++ b/src/renderer/src/types.ts @@ -98,7 +98,7 @@ export interface TerminalTheme { /** Effective project settings (mirrors src/main/config.ts). */ export interface HelderConfig { ai: { command: string; autoLaunch: boolean } - editor: { autoSave: boolean; tabSize: number; wordWrap: WordWrap } + editor: { autoSave: boolean; tabSize: number; wordWrap: WordWrap; copyOnSelect: boolean } git: { confirmDiscard: boolean; confirmStage: boolean; confirmUnstage: boolean; defaultDiffMode: DiffMode; refreshInterval: number } files: { exclude: string[]; followGitignore: boolean } terminal: { @@ -125,7 +125,7 @@ export interface HelderConfig { export const DEFAULT_CONFIG: HelderConfig = { ai: { command: 'claude', autoLaunch: true }, - editor: { autoSave: false, tabSize: 4, wordWrap: 'markdown' }, + editor: { autoSave: false, tabSize: 4, wordWrap: 'markdown', copyOnSelect: true }, git: { confirmDiscard: true, confirmStage: false, confirmUnstage: false, defaultDiffMode: 'diff', refreshInterval: 10000 }, files: { exclude: [], followGitignore: true }, terminal: { diff --git a/test/group-by-dir.test.ts b/test/group-by-dir.test.ts new file mode 100644 index 0000000..e5dcf3b --- /dev/null +++ b/test/group-by-dir.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest' +import { groupByDir } from '../src/renderer/src/components' +import type { Change } from '../src/renderer/src/types' + +function change(path: string): Change { + return { path, status: 'M', add: 1, del: 0, deleted: false, staged: false, id: path } +} + +describe('groupByDir', () => { + it('gives one group per folder when git returns the folder in separated runs', () => { + const groups = groupByDir([ + change('tests/Feature/AlphaTest.php'), + change('app/Models/User.php'), + change('tests/Feature/BetaTest.php'), + ]) + expect(groups.map((g) => g.dir)).toEqual(['tests/Feature', 'app/Models']) + expect(groups[0].rows.map((r) => r.path)).toEqual([ + 'tests/Feature/AlphaTest.php', + 'tests/Feature/BetaTest.php', + ]) + }) + + it('keeps every row, so the keyboard cursor walks the full list', () => { + const list = ['a/one.ts', 'b/two.ts', 'a/three.ts', 'four.ts'].map(change) + const flat = groupByDir(list).flatMap((g) => g.rows) + expect(flat).toHaveLength(list.length) + expect(new Set(flat.map((r) => r.id)).size).toBe(list.length) + }) + + it('puts a project-root file in the empty-string group', () => { + expect(groupByDir([change('README.md')])).toEqual([ + { dir: '', rows: [change('README.md')] }, + ]) + }) +})