28
src/main/notes-service.ts
Normal file
28
src/main/notes-service.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
/**
|
||||||
|
* Project scratch note: a plain text file at `<project>/.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<string> {
|
||||||
|
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<void> {
|
||||||
|
await writeFile(join(root, NOTES_FILE), text, 'utf8')
|
||||||
|
}
|
||||||
@@ -247,6 +247,24 @@ export function App(): React.ReactElement {
|
|||||||
const gitSelPath = gitSelRow?.path ?? null
|
const gitSelPath = gitSelRow?.path ?? null
|
||||||
const treeSelItem = treeNav[treeSel] ?? 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.
|
// 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(() => { 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])
|
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
|
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
|
// 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.
|
// is saved and closed, so you see the text land in the agent composer.
|
||||||
function passNote(): boolean {
|
function passNote(): boolean {
|
||||||
@@ -761,8 +779,9 @@ export function App(): React.ReactElement {
|
|||||||
else setMenu(null)
|
else setMenu(null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// ⌘→ with the note open passes the note text to the agent.
|
// ⌘P with the note open passes the note text to the agent. This runs before
|
||||||
if (overlay === 'notes' && meta && e.key === 'ArrowRight') { e.preventDefault(); passNote(); return }
|
// 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).
|
// Search / help modals own the keyboard while open (they handle their own keys).
|
||||||
if (overlay) return
|
if (overlay) return
|
||||||
|
|
||||||
@@ -933,7 +952,7 @@ export function App(): React.ReactElement {
|
|||||||
{/* workbench */}
|
{/* workbench */}
|
||||||
<div className="workbench">
|
<div className="workbench">
|
||||||
<div className={'col' + (activePanel === 'git' ? ' panel-active' : '') + flashClass} style={{ width: gitW, flex: '0 0 ' + gitW + 'px' }}
|
<div className={'col' + (activePanel === 'git' ? ' panel-active' : '') + flashClass} style={{ width: gitW, flex: '0 0 ' + gitW + 'px' }}
|
||||||
onMouseDownCapture={() => setActivePanel('git')}>
|
onMouseDownCapture={(e) => { setActivePanel('git'); syncGitSel(e.target) }}>
|
||||||
<GitPanel branch={proj.branch} changes={proj.changes} committed={NO_COMMITTED}
|
<GitPanel branch={proj.branch} changes={proj.changes} committed={NO_COMMITTED}
|
||||||
commitMsg={commitMsg} setCommitMsg={setCommitMsg}
|
commitMsg={commitMsg} setCommitMsg={setCommitMsg}
|
||||||
onStage={stageGuarded} onUnstage={unstageGuarded} onStageAll={actions.stageAll} onUnstageAll={actions.unstageAll} onCommit={commit} onPush={push}
|
onStage={stageGuarded} onUnstage={unstageGuarded} onStageAll={actions.stageAll} onUnstageAll={actions.unstageAll} onCommit={commit} onPush={push}
|
||||||
@@ -943,7 +962,7 @@ export function App(): React.ReactElement {
|
|||||||
<Splitter onDelta={(dx) => { setAutoResize(false); setGitW((w) => clamp(w + dx, 160, 460)) }} />
|
<Splitter onDelta={(dx) => { setAutoResize(false); setGitW((w) => clamp(w + dx, 160, 460)) }} />
|
||||||
|
|
||||||
<div className={'col' + (activePanel === 'tree' ? ' panel-active' : '') + flashClass} style={{ width: treeW, flex: '0 0 ' + treeW + 'px' }}
|
<div className={'col' + (activePanel === 'tree' ? ' panel-active' : '') + flashClass} style={{ width: treeW, flex: '0 0 ' + treeW + 'px' }}
|
||||||
onMouseDownCapture={() => setActivePanel('tree')}>
|
onMouseDownCapture={(e) => { setActivePanel('tree'); syncTreeSel(e.target) }}>
|
||||||
{proj.tree ? (
|
{proj.tree ? (
|
||||||
<FileTree tree={proj.tree} openDirs={openDirs} toggleDir={toggleDir} onOpen={openFile}
|
<FileTree tree={proj.tree} openDirs={openDirs} toggleDir={toggleDir} onOpen={openFile}
|
||||||
onContext={openMenu} activePath={active} ctxPath={menu?.path ?? null}
|
onContext={openMenu} activePath={active} ctxPath={menu?.path ?? null}
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ function GitRow({ c, activePath, activeSide, ctxPath, kbdId, showDir, onOpen, on
|
|||||||
const isActive = activePath === c.path && (!activeSide || activeSide === side)
|
const isActive = activePath === c.path && (!activeSide || activeSide === side)
|
||||||
return (
|
return (
|
||||||
<div className={'git-row' + (isActive ? ' active' : '') + (ctxPath === c.path ? ' ctx' : '') + (kbdId === c.id ? ' kbd' : '')}
|
<div className={'git-row' + (isActive ? ' active' : '') + (ctxPath === c.path ? ' ctx' : '') + (kbdId === c.id ? ' kbd' : '')}
|
||||||
|
data-row-id={c.id}
|
||||||
onClick={() => onOpen(c.path, { diff: true, side })}
|
onClick={() => onOpen(c.path, { diff: true, side })}
|
||||||
onContextMenu={(e) => onContext(e, { path: c.path, kind: 'git', staged })}
|
onContextMenu={(e) => onContext(e, { path: c.path, kind: 'git', staged })}
|
||||||
title={c.path}>
|
title={c.path}>
|
||||||
@@ -204,6 +205,7 @@ function TreeNode({ node, depth, openDirs, toggleDir, onOpen, onContext, activeP
|
|||||||
<Fragment>
|
<Fragment>
|
||||||
{node.path !== '' && (
|
{node.path !== '' && (
|
||||||
<div className={'tree-row folder' + (ctxPath === node.path ? ' ctx' : '') + (kbdPath === node.path ? ' kbd' : '')} style={{ paddingLeft: pad }}
|
<div className={'tree-row folder' + (ctxPath === node.path ? ' ctx' : '') + (kbdPath === node.path ? ' kbd' : '')} style={{ paddingLeft: pad }}
|
||||||
|
data-row-path={node.path}
|
||||||
onClick={() => toggleDir(node.path)}
|
onClick={() => toggleDir(node.path)}
|
||||||
onContextMenu={(e) => onContext(e, { path: node.path, kind: 'dir' })}>
|
onContextMenu={(e) => onContext(e, { path: node.path, kind: 'dir' })}>
|
||||||
<span className="tw"><Chevron open={isOpen} /></span>
|
<span className="tw"><Chevron open={isOpen} /></span>
|
||||||
@@ -225,6 +227,7 @@ function TreeNode({ node, depth, openDirs, toggleDir, onOpen, onContext, activeP
|
|||||||
return (
|
return (
|
||||||
<div className={'tree-row' + (activePath === node.path ? ' active' : '') + (ctxPath === node.path ? ' ctx' : '') + (kbdPath === node.path ? ' kbd' : '')}
|
<div className={'tree-row' + (activePath === node.path ? ' active' : '') + (ctxPath === node.path ? ' ctx' : '') + (kbdPath === node.path ? ' kbd' : '')}
|
||||||
style={{ paddingLeft: pad + 2 }}
|
style={{ paddingLeft: pad + 2 }}
|
||||||
|
data-row-path={node.path}
|
||||||
onClick={() => onOpen(node.path)}
|
onClick={() => onOpen(node.path)}
|
||||||
onContextMenu={(e) => onContext(e, { path: node.path, kind: 'file' })}
|
onContextMenu={(e) => onContext(e, { path: node.path, kind: 'file' })}
|
||||||
title={node.path}>
|
title={node.path}>
|
||||||
|
|||||||
@@ -48,6 +48,48 @@ function CodeEditor({ path, text, lang, onChange, onContext }: {
|
|||||||
if (top < s.scrollTop) s.scrollTop = top - 20
|
if (top < s.scrollTop) s.scrollTop = top - 20
|
||||||
else if (bottom > s.scrollTop + s.clientHeight) s.scrollTop = bottom - s.clientHeight + 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<HTMLTextAreaElement>, 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<HTMLTextAreaElement>): void {
|
||||||
|
if (e.key === 'Tab' && !e.metaKey && !e.ctrlKey && !e.altKey) handleTab(e, e.shiftKey)
|
||||||
|
}
|
||||||
function handleContext(e: React.MouseEvent<HTMLTextAreaElement>): void {
|
function handleContext(e: React.MouseEvent<HTMLTextAreaElement>): void {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const ta = e.currentTarget
|
const ta = e.currentTarget
|
||||||
@@ -72,6 +114,7 @@ function CodeEditor({ path, text, lang, onChange, onContext }: {
|
|||||||
<textarea className="ce-ta" value={text} spellCheck={false} autoComplete="off"
|
<textarea className="ce-ta" value={text} spellCheck={false} autoComplete="off"
|
||||||
wrap="off" style={{ tabSize }}
|
wrap="off" style={{ tabSize }}
|
||||||
onChange={(e) => { onChange(e.target.value); ensureCaretVisible(e.target) }}
|
onChange={(e) => { onChange(e.target.value); ensureCaretVisible(e.target) }}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
onKeyUp={(e) => ensureCaretVisible(e.currentTarget)}
|
onKeyUp={(e) => ensureCaretVisible(e.currentTarget)}
|
||||||
onClick={(e) => ensureCaretVisible(e.currentTarget)}
|
onClick={(e) => ensureCaretVisible(e.currentTarget)}
|
||||||
onContextMenu={handleContext} />
|
onContextMenu={handleContext} />
|
||||||
|
|||||||
@@ -54,6 +54,40 @@ function inline(src: string): string {
|
|||||||
return s.replace(SENT_RE, (_m, i) => codes[+i])
|
return s.replace(SENT_RE, (_m, i) => codes[+i])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Split one GFM table row into cells. A `\|` is a literal pipe, not a divider. */
|
||||||
|
function splitRow(line: string): string[] {
|
||||||
|
const s = line.trim().replace(/^\|/, '').replace(/(?<!\\)\|\s*$/, '')
|
||||||
|
const cells: string[] = []
|
||||||
|
let cur = ''
|
||||||
|
for (let j = 0; j < s.length; j++) {
|
||||||
|
if (s[j] === '\\' && s[j + 1] === '|') { cur += '|'; j++; continue }
|
||||||
|
if (s[j] === '|') { cells.push(cur); cur = ''; continue }
|
||||||
|
cur += s[j]
|
||||||
|
}
|
||||||
|
cells.push(cur)
|
||||||
|
return cells.map((c) => c.trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The `---`/`:---:` row under a table header. Also fixes each column's align. */
|
||||||
|
function tableAligns(line: string): (string | null)[] | null {
|
||||||
|
if (!line.includes('|') && !/^\s*:?-+:?\s*$/.test(line)) return null
|
||||||
|
const cells = splitRow(line)
|
||||||
|
if (!cells.length) return null
|
||||||
|
const aligns: (string | null)[] = []
|
||||||
|
for (const c of cells) {
|
||||||
|
if (!/^:?-{1,}:?$/.test(c)) return null
|
||||||
|
const left = c.startsWith(':'), right = c.endsWith(':')
|
||||||
|
aligns.push(left && right ? 'center' : right ? 'right' : left ? 'left' : null)
|
||||||
|
}
|
||||||
|
return aligns
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One `<td>`/`<th>`, with the column's alignment when the header set one. */
|
||||||
|
function cell(tag: string, text: string, align: string | null): string {
|
||||||
|
const a = align ? ` style="text-align:${align}"` : ''
|
||||||
|
return `<${tag}${a}>` + inline(text) + `</${tag}>`
|
||||||
|
}
|
||||||
|
|
||||||
export function renderMarkdown(text: string): string {
|
export function renderMarkdown(text: string): string {
|
||||||
const lines = text.replace(/\r\n?/g, '\n').split('\n')
|
const lines = text.replace(/\r\n?/g, '\n').split('\n')
|
||||||
const out: string[] = []
|
const out: string[] = []
|
||||||
@@ -86,6 +120,28 @@ export function renderMarkdown(text: string): string {
|
|||||||
|
|
||||||
if (/^\s*([-*_])(\s*\1){2,}\s*$/.test(line)) { flushPara(); out.push('<hr />'); i++; continue }
|
if (/^\s*([-*_])(\s*\1){2,}\s*$/.test(line)) { flushPara(); out.push('<hr />'); i++; continue }
|
||||||
|
|
||||||
|
// GFM table: a header row with pipes, then a `---|---` row with the same
|
||||||
|
// column count. Body rows run until a blank line or a line without a pipe.
|
||||||
|
if (line.includes('|') && i + 1 < lines.length) {
|
||||||
|
const head = splitRow(line)
|
||||||
|
const aligns = tableAligns(lines[i + 1])
|
||||||
|
if (aligns && aligns.length === head.length) {
|
||||||
|
flushPara()
|
||||||
|
i += 2
|
||||||
|
const rows: string[][] = []
|
||||||
|
while (i < lines.length && lines[i].includes('|') && !/^\s*$/.test(lines[i])) {
|
||||||
|
rows.push(splitRow(lines[i])); i++
|
||||||
|
}
|
||||||
|
const body = rows.map((r) => '<tr>' + head.map((_c, n) => cell('td', r[n] ?? '', aligns[n])).join('') + '</tr>').join('')
|
||||||
|
out.push(
|
||||||
|
'<table class="md-table"><thead><tr>' +
|
||||||
|
head.map((c, n) => cell('th', c, aligns[n])).join('') +
|
||||||
|
'</tr></thead>' + (body ? '<tbody>' + body + '</tbody>' : '') + '</table>',
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (/^\s*>/.test(line)) {
|
if (/^\s*>/.test(line)) {
|
||||||
flushPara()
|
flushPara()
|
||||||
const buf: string[] = []
|
const buf: string[] = []
|
||||||
|
|||||||
@@ -460,7 +460,7 @@ export function HelpModal({ onClose }: { onClose: () => void }): React.ReactElem
|
|||||||
*
|
*
|
||||||
* The overlay only edits the text. Saving is the App's job, because the note
|
* 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.
|
* 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.
|
* ⌘P (pass the note to the agent) is the App's job too — it owns the shortcut.
|
||||||
*/
|
*/
|
||||||
export function NotesModal({ text, onChange, onClose }: {
|
export function NotesModal({ text, onChange, onClose }: {
|
||||||
text: string
|
text: string
|
||||||
@@ -482,7 +482,7 @@ export function NotesModal({ text, onChange, onClose }: {
|
|||||||
{Icon.note({ style: { color: 'var(--fg-3)' } })}
|
{Icon.note({ style: { color: 'var(--fg-3)' } })}
|
||||||
<span className="hist-title">Note</span>
|
<span className="hist-title">Note</span>
|
||||||
<span className="notes-file">.notes.txt</span>
|
<span className="notes-file">.notes.txt</span>
|
||||||
<span className="notes-hint">{Icon.spark()} To agent <kbd>⌘→</kbd></span>
|
<span className="notes-hint">{Icon.spark()} To agent <kbd>⌘P</kbd></span>
|
||||||
<kbd>esc</kbd>
|
<kbd>esc</kbd>
|
||||||
</div>
|
</div>
|
||||||
<textarea ref={ref} className="notes-input" spellCheck={false}
|
<textarea ref={ref} className="notes-input" spellCheck={false}
|
||||||
|
|||||||
@@ -315,6 +315,11 @@ kbd { flex:0 0 auto; font-family:var(--mono); font-size:11.5px; color:var(--fg-3
|
|||||||
.md-body pre.md-code { background:var(--bg-1); border:1px solid var(--border); border-radius:8px; padding:12px 14px; overflow:auto; margin:.9em 0; }
|
.md-body pre.md-code { background:var(--bg-1); border:1px solid var(--border); border-radius:8px; padding:12px 14px; overflow:auto; margin:.9em 0; }
|
||||||
.md-body pre.md-code code { font-size:var(--code-size); background:none; border:0; padding:0; white-space:pre; }
|
.md-body pre.md-code code { font-size:var(--code-size); background:none; border:0; padding:0; white-space:pre; }
|
||||||
.md-body strong { color:var(--fg-0); font-weight:600; }
|
.md-body strong { color:var(--fg-0); font-weight:600; }
|
||||||
|
/* tables scroll on their own so a wide one never widens the whole preview */
|
||||||
|
.md-body table.md-table { display:block; width:max-content; max-width:100%; overflow-x:auto; border-collapse:collapse; margin:.9em 0; font-size:.94em; }
|
||||||
|
.md-body table.md-table th, .md-body table.md-table td { border:1px solid var(--border); padding:5px 10px; text-align:left; vertical-align:top; }
|
||||||
|
.md-body table.md-table th { background:var(--bg-2); color:var(--fg-0); font-weight:600; white-space:nowrap; }
|
||||||
|
.md-body table.md-table tbody tr:nth-child(even) { background:var(--bg-1); }
|
||||||
|
|
||||||
/* gutter change bars (Original / Updated / Split) */
|
/* gutter change bars (Original / Updated / Split) */
|
||||||
.ln-row.bar-del { box-shadow:inset 2px 0 0 var(--del); }
|
.ln-row.bar-del { box-shadow:inset 2px 0 0 var(--del); }
|
||||||
|
|||||||
83
test/editor-tab.test.tsx
Normal file
83
test/editor-tab.test.tsx
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { cleanup, fireEvent, render, waitFor } from '@testing-library/react'
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
vi.mock('../src/renderer/src/terminals', () => {
|
||||||
|
let n = 0
|
||||||
|
return { Terminal: () => null, lid: () => ++n }
|
||||||
|
})
|
||||||
|
|
||||||
|
import { App } from '../src/renderer/src/App'
|
||||||
|
import { ProjectProvider } from '../src/renderer/src/project'
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
globalThis.ResizeObserver = class { observe() {} unobserve() {} disconnect() {} } as never
|
||||||
|
if (!Element.prototype.scrollIntoView) Element.prototype.scrollIntoView = () => {}
|
||||||
|
})
|
||||||
|
afterEach(() => { cleanup(); localStorage.clear() })
|
||||||
|
|
||||||
|
function find(c: HTMLElement, sel: string, text: string): HTMLElement | undefined {
|
||||||
|
return Array.from(c.querySelectorAll<HTMLElement>(sel)).find((el) => el.textContent?.trim() === text)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open a changed file and switch to the writable buffer.
|
||||||
|
async function openEditor(): Promise<HTMLTextAreaElement> {
|
||||||
|
const c = render(<ProjectProvider><App /></ProjectProvider>).container
|
||||||
|
const row = await waitFor(() => {
|
||||||
|
const r = Array.from(c.querySelectorAll<HTMLElement>('.git-row')).find((el) => el.textContent?.includes('UserController.php'))
|
||||||
|
if (!r) throw new Error('git not ready')
|
||||||
|
return r
|
||||||
|
})
|
||||||
|
fireEvent.click(row)
|
||||||
|
await waitFor(() => expect(c.querySelector('.diff-bar')).toBeTruthy())
|
||||||
|
fireEvent.click(find(c, '.seg button', 'Actual')!)
|
||||||
|
return await waitFor(() => {
|
||||||
|
const ta = c.querySelector<HTMLTextAreaElement>('.ce-ta')
|
||||||
|
if (!ta) throw new Error('no buffer')
|
||||||
|
return ta
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function tab(ta: HTMLTextAreaElement, shift = false): boolean {
|
||||||
|
return fireEvent.keyDown(ta, { key: 'Tab', shiftKey: shift })
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Tab in the code editor', () => {
|
||||||
|
it('inserts four spaces at the caret instead of moving focus', async () => {
|
||||||
|
const ta = await openEditor()
|
||||||
|
const before = ta.value
|
||||||
|
ta.setSelectionRange(0, 0)
|
||||||
|
|
||||||
|
// fireEvent returns false when a handler called preventDefault, which is
|
||||||
|
// what stops the browser tabbing focus over to the agent column.
|
||||||
|
expect(tab(ta)).toBe(false)
|
||||||
|
await waitFor(() => expect(ta.value).toBe(' ' + before))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('indents every line a multi-line selection touches', async () => {
|
||||||
|
const ta = await openEditor()
|
||||||
|
const lines = ta.value.split('\n')
|
||||||
|
// Select from inside line 1 to inside line 2.
|
||||||
|
ta.setSelectionRange(1, lines[0].length + 2)
|
||||||
|
|
||||||
|
tab(ta)
|
||||||
|
await waitFor(() => {
|
||||||
|
const now = ta.value.split('\n')
|
||||||
|
expect(now[0]).toBe(' ' + lines[0])
|
||||||
|
expect(now[1]).toBe(' ' + lines[1])
|
||||||
|
expect(now[2]).toBe(lines[2])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Shift+Tab outdents the current line', async () => {
|
||||||
|
const ta = await openEditor()
|
||||||
|
ta.setSelectionRange(0, 0)
|
||||||
|
tab(ta)
|
||||||
|
await waitFor(() => expect(ta.value.startsWith(' ')).toBe(true))
|
||||||
|
|
||||||
|
ta.setSelectionRange(6, 6)
|
||||||
|
expect(tab(ta, true)).toBe(false)
|
||||||
|
await waitFor(() => expect(ta.value.startsWith(' ')).toBe(false))
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -41,4 +41,33 @@ describe('renderMarkdown', () => {
|
|||||||
expect(renderMarkdown('- a\n- b')).toContain('<ul><li>a</li><li>b</li></ul>')
|
expect(renderMarkdown('- a\n- b')).toContain('<ul><li>a</li><li>b</li></ul>')
|
||||||
expect(renderMarkdown('1. a\n2. b')).toContain('<ol><li>a</li><li>b</li></ol>')
|
expect(renderMarkdown('1. a\n2. b')).toContain('<ol><li>a</li><li>b</li></ol>')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('renders a GFM table with header, body and inline markup', () => {
|
||||||
|
const html = renderMarkdown('| a | b |\n|---|---|\n| 1 | `x` |\n| 2 | **y** |')
|
||||||
|
expect(html).toContain('<table class="md-table">')
|
||||||
|
expect(html).toContain('<thead><tr><th>a</th><th>b</th></tr></thead>')
|
||||||
|
expect(html).toContain('<td>1</td><td><code>x</code></td>')
|
||||||
|
expect(html).toContain('<strong>y</strong>')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applies column alignment from the delimiter row', () => {
|
||||||
|
const html = renderMarkdown('| l | c | r |\n| :-- | :-: | --: |\n| 1 | 2 | 3 |')
|
||||||
|
expect(html).toContain('<th style="text-align:left">l</th>')
|
||||||
|
expect(html).toContain('<th style="text-align:center">c</th>')
|
||||||
|
expect(html).toContain('<th style="text-align:right">r</th>')
|
||||||
|
expect(html).toContain('<td style="text-align:center">2</td>')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles escaped pipes, ragged rows and pipe-less prose after the table', () => {
|
||||||
|
const html = renderMarkdown('| a | b |\n|---|---|\n| x \\| y | 2 |\n| short |\n\nAfter.')
|
||||||
|
expect(html).toContain('<td>x | y</td>')
|
||||||
|
expect(html).toContain('<td>short</td><td></td>')
|
||||||
|
expect(html).toContain('<p>After.</p>')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('leaves a pipe-bearing paragraph alone when no delimiter row follows', () => {
|
||||||
|
const html = renderMarkdown('a | b\nnot a table')
|
||||||
|
expect(html).not.toContain('<table')
|
||||||
|
expect(html).toContain('<p>a | b not a table</p>')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
200
test/notes.test.tsx
Normal file
200
test/notes.test.tsx
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
//
|
||||||
|
// The project note: ⌘N opens it, the text lands in <project>/.notes.txt, and it
|
||||||
|
// is written whenever the window loses focus.
|
||||||
|
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react'
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
vi.mock('../src/renderer/src/terminals', () => {
|
||||||
|
let n = 0
|
||||||
|
return { Terminal: () => null, lid: () => ++n }
|
||||||
|
})
|
||||||
|
|
||||||
|
import { App } from '../src/renderer/src/App'
|
||||||
|
import { ProjectProvider } from '../src/renderer/src/project'
|
||||||
|
|
||||||
|
let stored = ''
|
||||||
|
let writes: string[] = []
|
||||||
|
|
||||||
|
function stubBridge(): void {
|
||||||
|
const noop = (): void => {}
|
||||||
|
const off = (): (() => void) => noop
|
||||||
|
;(window as unknown as { helder: unknown }).helder = {
|
||||||
|
platform: 'darwin',
|
||||||
|
clipboard: { writeText: noop, readText: () => '' },
|
||||||
|
project: {
|
||||||
|
current: async () => ({ root: '/repo', name: 'repo' }),
|
||||||
|
open: async () => ({ root: '/repo', name: 'repo' }),
|
||||||
|
openPath: async () => ({ root: '/repo', name: 'repo' }),
|
||||||
|
recent: async () => [],
|
||||||
|
},
|
||||||
|
fs: {
|
||||||
|
tree: async () => ({ name: 'repo', type: 'dir', path: '', children: [] }),
|
||||||
|
readDir: async () => [], files: async () => ({}), read: async () => '',
|
||||||
|
imageDataUrl: async () => '', write: async () => {}, delete: async () => {},
|
||||||
|
create: async () => {}, mkdir: async () => {},
|
||||||
|
},
|
||||||
|
shell: { reveal: noop },
|
||||||
|
notes: {
|
||||||
|
read: async () => stored,
|
||||||
|
write: async (t: string) => { stored = t; writes.push(t) },
|
||||||
|
},
|
||||||
|
git: {
|
||||||
|
load: async () => ({ branch: 'main', changes: [] }),
|
||||||
|
stage: async () => {}, unstage: async () => {}, commit: async () => {},
|
||||||
|
push: async () => ({ ok: true, message: '' }), discard: async () => {},
|
||||||
|
},
|
||||||
|
pty: { available: async () => false, create: async () => 1, write: noop, resize: noop, kill: noop, onData: off, onExit: off },
|
||||||
|
config: { get: async () => (await import('../src/renderer/src/types')).DEFAULT_CONFIG, theme: async () => '' },
|
||||||
|
recent: { get: async () => [], set: async () => {} },
|
||||||
|
search: { content: async () => [], files: async () => [] },
|
||||||
|
dialog: { unsavedClose: async () => 'cancel' },
|
||||||
|
log: { write: noop, path: async () => null, open: async () => {}, reveal: async () => {} },
|
||||||
|
onProjectChanged: off, onConfigChanged: off, onRefresh: off,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
globalThis.ResizeObserver = class { observe(): void {} unobserve(): void {} disconnect(): void {} } as never
|
||||||
|
if (!Element.prototype.scrollIntoView) Element.prototype.scrollIntoView = (): void => {}
|
||||||
|
})
|
||||||
|
beforeEach(() => { stored = ''; writes = []; stubBridge() })
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup()
|
||||||
|
localStorage.clear()
|
||||||
|
delete (window as unknown as { helder?: unknown }).helder
|
||||||
|
})
|
||||||
|
|
||||||
|
async function boot(): Promise<HTMLElement> {
|
||||||
|
const c = render(<ProjectProvider><App /></ProjectProvider>).container
|
||||||
|
await waitFor(() => { if (!c.querySelector('.git-foot')) throw new Error('not ready') })
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
function pressCmdN(): void {
|
||||||
|
act(() => { window.dispatchEvent(new KeyboardEvent('keydown', { key: 'n', metaKey: true })) })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Open the note. The shortcut is registered in an effect, which React may not
|
||||||
|
* have flushed yet when the first paint lands, so keep pressing until it takes.
|
||||||
|
* A second ⌘N with the overlay already open is a no-op. */
|
||||||
|
async function openNote(c: HTMLElement): Promise<HTMLTextAreaElement> {
|
||||||
|
await waitFor(() => {
|
||||||
|
pressCmdN()
|
||||||
|
if (!c.querySelector('.notes-modal')) throw new Error('note not open')
|
||||||
|
})
|
||||||
|
return c.querySelector('.notes-input') as HTMLTextAreaElement
|
||||||
|
}
|
||||||
|
|
||||||
|
function blurWindow(): void {
|
||||||
|
act(() => { window.dispatchEvent(new Event('blur')) })
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('project note', () => {
|
||||||
|
it('⌘N opens the note overlay', async () => {
|
||||||
|
const c = await boot()
|
||||||
|
expect(c.querySelector('.notes-modal')).toBeNull()
|
||||||
|
await openNote(c)
|
||||||
|
expect(c.querySelector('.notes-modal .notes-file')?.textContent).toBe('.notes.txt')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('loads the note that is already on disk', async () => {
|
||||||
|
stored = 'earlier thoughts\n'
|
||||||
|
const c = await boot()
|
||||||
|
const ta = await openNote(c)
|
||||||
|
await waitFor(() => expect(ta.value).toBe('earlier thoughts\n'))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('writes the note when the window loses focus', async () => {
|
||||||
|
const c = await boot()
|
||||||
|
const ta = await openNote(c)
|
||||||
|
fireEvent.change(ta, { target: { value: 'buy milk' } })
|
||||||
|
|
||||||
|
expect(writes).toEqual([]) // nothing written while typing
|
||||||
|
blurWindow()
|
||||||
|
await waitFor(() => expect(writes).toEqual(['buy milk']))
|
||||||
|
expect(stored).toBe('buy milk')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not rewrite the file when nothing changed', async () => {
|
||||||
|
stored = 'unchanged'
|
||||||
|
await boot()
|
||||||
|
await waitFor(() => expect(stored).toBe('unchanged'))
|
||||||
|
blurWindow()
|
||||||
|
blurWindow()
|
||||||
|
expect(writes).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Esc closes the note and saves it right away', async () => {
|
||||||
|
const c = await boot()
|
||||||
|
const ta = await openNote(c)
|
||||||
|
fireEvent.change(ta, { target: { value: 'quick capture' } })
|
||||||
|
|
||||||
|
act(() => { window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })) })
|
||||||
|
await waitFor(() => expect(c.querySelector('.notes-modal')).toBeNull())
|
||||||
|
await waitFor(() => expect(writes).toEqual(['quick capture']))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('⌘P passes the note to the agent, then saves and closes it', async () => {
|
||||||
|
const c = await boot()
|
||||||
|
const ta = await openNote(c)
|
||||||
|
fireEvent.change(ta, { target: { value: 'refactor the policy' } })
|
||||||
|
|
||||||
|
const seen: string[] = []
|
||||||
|
const onPaste = (e: Event): void => { seen.push((e as CustomEvent<string>).detail) }
|
||||||
|
window.addEventListener('agentPaste', onPaste)
|
||||||
|
act(() => { window.dispatchEvent(new KeyboardEvent('keydown', { key: 'p', metaKey: true })) })
|
||||||
|
window.removeEventListener('agentPaste', onPaste)
|
||||||
|
|
||||||
|
expect(seen).toEqual(['refactor the policy'])
|
||||||
|
await waitFor(() => expect(c.querySelector('.notes-modal')).toBeNull())
|
||||||
|
await waitFor(() => expect(writes).toEqual(['refactor the policy']))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('⌘P does nothing when the note is empty', async () => {
|
||||||
|
const c = await boot()
|
||||||
|
await openNote(c)
|
||||||
|
|
||||||
|
const seen: string[] = []
|
||||||
|
const onPaste = (e: Event): void => { seen.push((e as CustomEvent<string>).detail) }
|
||||||
|
window.addEventListener('agentPaste', onPaste)
|
||||||
|
act(() => { window.dispatchEvent(new KeyboardEvent('keydown', { key: 'p', metaKey: true })) })
|
||||||
|
window.removeEventListener('agentPaste', onPaste)
|
||||||
|
|
||||||
|
expect(seen).toEqual([])
|
||||||
|
expect(c.querySelector('.notes-modal')).not.toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('the header shows the pass-to-agent hint', async () => {
|
||||||
|
const c = await boot()
|
||||||
|
await openNote(c)
|
||||||
|
const hint = c.querySelector('.notes-modal .notes-hint')
|
||||||
|
expect(hint?.textContent).toContain('To agent')
|
||||||
|
expect(hint?.querySelector('kbd')?.textContent).toBe('⌘P')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('the title bar carries the ⌘N note action', async () => {
|
||||||
|
const c = await boot()
|
||||||
|
const btn = [...c.querySelectorAll('.titlebar .tb-btn')]
|
||||||
|
.find((b) => b.textContent?.includes('Note')) as HTMLButtonElement | undefined
|
||||||
|
expect(btn?.querySelector('kbd')?.textContent).toBe('⌘N')
|
||||||
|
expect(btn?.className).not.toContain('on')
|
||||||
|
|
||||||
|
act(() => { btn?.click() })
|
||||||
|
await waitFor(() => expect(c.querySelector('.notes-modal')).not.toBeNull())
|
||||||
|
// The action reads as "on" while the note is open, like the other toggles.
|
||||||
|
expect(btn?.className).toContain('on')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the text when reopened', async () => {
|
||||||
|
const c = await boot()
|
||||||
|
const ta = await openNote(c)
|
||||||
|
fireEvent.change(ta, { target: { value: 'still here' } })
|
||||||
|
act(() => { window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })) })
|
||||||
|
await waitFor(() => expect(c.querySelector('.notes-modal')).toBeNull())
|
||||||
|
|
||||||
|
const again = await openNote(c)
|
||||||
|
expect(again.value).toBe('still here')
|
||||||
|
})
|
||||||
|
})
|
||||||
105
test/titlebar.test.tsx
Normal file
105
test/titlebar.test.tsx
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
//
|
||||||
|
// The title bar: borderless actions that go accent when on, and the fullscreen
|
||||||
|
// shift (macOS hides the traffic lights, so the project name moves to the edge).
|
||||||
|
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { act, cleanup, render, waitFor } from '@testing-library/react'
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
vi.mock('../src/renderer/src/terminals', () => {
|
||||||
|
let n = 0
|
||||||
|
return { Terminal: () => null, lid: () => ++n }
|
||||||
|
})
|
||||||
|
|
||||||
|
import { App } from '../src/renderer/src/App'
|
||||||
|
import { ProjectProvider } from '../src/renderer/src/project'
|
||||||
|
|
||||||
|
/** Fires the fullscreen callbacks the App subscribed to. */
|
||||||
|
let fullscreenCbs: ((on: boolean) => void)[] = []
|
||||||
|
|
||||||
|
function stubBridge(): void {
|
||||||
|
const noop = (): void => {}
|
||||||
|
const off = (): (() => void) => noop
|
||||||
|
;(window as unknown as { helder: unknown }).helder = {
|
||||||
|
platform: 'darwin',
|
||||||
|
clipboard: { writeText: noop, readText: () => '' },
|
||||||
|
project: {
|
||||||
|
current: async () => ({ root: '/repo', name: 'repo' }),
|
||||||
|
open: async () => ({ root: '/repo', name: 'repo' }),
|
||||||
|
openPath: async () => ({ root: '/repo', name: 'repo' }),
|
||||||
|
recent: async () => [],
|
||||||
|
},
|
||||||
|
fs: {
|
||||||
|
tree: async () => ({ name: 'repo', type: 'dir', path: '', children: [] }),
|
||||||
|
readDir: async () => [], files: async () => ({}), read: async () => '',
|
||||||
|
imageDataUrl: async () => '', write: async () => {}, delete: async () => {},
|
||||||
|
create: async () => {}, mkdir: async () => {},
|
||||||
|
},
|
||||||
|
shell: { reveal: noop },
|
||||||
|
notes: { read: async () => '', write: async () => {} },
|
||||||
|
git: {
|
||||||
|
load: async () => ({ branch: 'main', changes: [] }),
|
||||||
|
stage: async () => {}, unstage: async () => {}, commit: async () => {},
|
||||||
|
push: async () => ({ ok: true, message: '' }), discard: async () => {},
|
||||||
|
},
|
||||||
|
pty: { available: async () => false, create: async () => 1, write: noop, resize: noop, kill: noop, onData: off, onExit: off },
|
||||||
|
config: { get: async () => (await import('../src/renderer/src/types')).DEFAULT_CONFIG, theme: async () => '' },
|
||||||
|
recent: { get: async () => [], set: async () => {} },
|
||||||
|
search: { content: async () => [], files: async () => [] },
|
||||||
|
dialog: { unsavedClose: async () => 'cancel' },
|
||||||
|
log: { write: noop, path: async () => null, open: async () => {}, reveal: async () => {} },
|
||||||
|
onFullscreen: (cb: (on: boolean) => void) => {
|
||||||
|
fullscreenCbs.push(cb)
|
||||||
|
return () => { fullscreenCbs = fullscreenCbs.filter((f) => f !== cb) }
|
||||||
|
},
|
||||||
|
onProjectChanged: off, onConfigChanged: off, onRefresh: off,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
globalThis.ResizeObserver = class { observe(): void {} unobserve(): void {} disconnect(): void {} } as never
|
||||||
|
if (!Element.prototype.scrollIntoView) Element.prototype.scrollIntoView = (): void => {}
|
||||||
|
})
|
||||||
|
beforeEach(() => { fullscreenCbs = []; stubBridge() })
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup()
|
||||||
|
localStorage.clear()
|
||||||
|
delete (window as unknown as { helder?: unknown }).helder
|
||||||
|
})
|
||||||
|
|
||||||
|
async function boot(): Promise<HTMLElement> {
|
||||||
|
const c = render(<ProjectProvider><App /></ProjectProvider>).container
|
||||||
|
await waitFor(() => { if (!c.querySelector('.git-foot')) throw new Error('not ready') })
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
function setFullscreen(on: boolean): void {
|
||||||
|
act(() => { fullscreenCbs.forEach((cb) => cb(on)) })
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('title bar', () => {
|
||||||
|
it('shifts left in fullscreen and back out again', async () => {
|
||||||
|
const c = await boot()
|
||||||
|
const bar = c.querySelector('.titlebar') as HTMLElement
|
||||||
|
expect(bar.className).not.toContain('fullscreen')
|
||||||
|
|
||||||
|
await waitFor(() => expect(fullscreenCbs.length).toBe(1))
|
||||||
|
setFullscreen(true)
|
||||||
|
expect(bar.className).toContain('fullscreen')
|
||||||
|
|
||||||
|
setFullscreen(false)
|
||||||
|
expect(bar.className).not.toContain('fullscreen')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows the state with the accent, not an On/Off badge', async () => {
|
||||||
|
const c = await boot()
|
||||||
|
const hidden = [...c.querySelectorAll<HTMLButtonElement>('.titlebar .tb-btn')]
|
||||||
|
.find((b) => b.textContent?.includes('Hidden')) as HTMLButtonElement
|
||||||
|
expect(c.querySelector('.titlebar .tb-state')).toBeNull()
|
||||||
|
expect(hidden.className).not.toContain('on')
|
||||||
|
|
||||||
|
act(() => { hidden.click() })
|
||||||
|
await waitFor(() => expect(hidden.className).toContain('on'))
|
||||||
|
expect(hidden.textContent).not.toContain('On')
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user