improvements
Some checks failed
CI / check (push) Has been cancelled

This commit is contained in:
2026-06-19 09:59:03 +02:00
parent 0a90ab822f
commit 5e5fc53dde
15 changed files with 592 additions and 105 deletions

View File

@@ -1,6 +1,6 @@
import { mkdir, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises'
import { dirname, join, relative, sep } from 'node:path'
import { listFiles } from './search-service'
import { listFiles, rgAvailable } from './search-service'
export interface FileNode {
name: string
@@ -40,31 +40,43 @@ function sortTree(node: FileNode): void {
for (const c of node.children) sortTree(c)
}
/** Build a nested tree from relative file paths (dirs first, alphabetical). */
export function buildTreeFromPaths(rootName: string, paths: string[]): FileNode {
/**
* Build a nested tree from relative file paths (dirs first, alphabetical).
* `dirPaths` are directories to force into the tree even when they hold no
* files — empty folders that `rg --files` can never emit (see `listEmptyDirs`).
*/
export function buildTreeFromPaths(rootName: string, paths: string[], dirPaths: string[] = []): FileNode {
const root: FileNode = { name: rootName, type: 'dir', path: '', open: true, children: [] }
const dirs = new Map<string, FileNode>([['', root]])
for (const rel of paths) {
/** Ensure a directory node (and all its ancestors) exist; return the node. */
function ensureDir(rel: string): FileNode {
const existing = dirs.get(rel)
if (existing) return existing
const parts = rel.split('/').filter(Boolean)
let parentPath = ''
let parent = root
for (let i = 0; i < parts.length; i++) {
const isFile = i === parts.length - 1
const curPath = parentPath ? `${parentPath}/${parts[i]}` : parts[i]
if (isFile) {
parent.children!.push({ name: parts[i], type: 'file', path: curPath })
} else {
let dir = dirs.get(curPath)
if (!dir) {
dir = { name: parts[i], type: 'dir', path: curPath, open: parts.slice(0, i + 1).length <= 1, children: [] }
dirs.set(curPath, dir)
parent.children!.push(dir)
}
parent = dir
parentPath = curPath
let dir = dirs.get(curPath)
if (!dir) {
dir = { name: parts[i], type: 'dir', path: curPath, open: i === 0, children: [] }
dirs.set(curPath, dir)
parent.children!.push(dir)
}
parent = dir
parentPath = curPath
}
return parent
}
for (const rel of paths) {
const parts = rel.split('/').filter(Boolean)
if (!parts.length) continue
const parent = ensureDir(parts.slice(0, -1).join('/'))
parent.children!.push({ name: parts[parts.length - 1], type: 'file', path: rel })
}
for (const rel of dirPaths) if (rel) ensureDir(rel)
sortTree(root)
return root
}
@@ -73,14 +85,89 @@ function rootName(root: string): string {
return root.split(sep).filter(Boolean).pop() || root
}
/** Project tree. Primary: rg file list (honors gitignore + excludes). Fallback:
* a plain recursive walk (when ripgrep is unavailable). */
/** Project tree. Primary: rg file list (honors gitignore + excludes) plus the
* empty folders rg can't emit. Fallback: a plain recursive walk (when ripgrep
* is unavailable) — that already lists empty dirs. */
export async function readTree(root: string): Promise<FileNode> {
const paths = await listFiles(root).catch(() => [] as string[])
if (paths.length) return buildTreeFromPaths(rootName(root), paths)
const [paths, emptyDirs] = await Promise.all([
listFiles(root).catch(() => [] as string[]),
listEmptyDirs(root).catch(() => [] as string[]),
])
if (paths.length || emptyDirs.length) return buildTreeFromPaths(rootName(root), paths, emptyDirs)
return { name: rootName(root), type: 'dir', path: '', open: true, children: await readDir(root, root, 0) }
}
/**
* Relative paths of directories whose entire subtree holds no files
* ("file-empty" folders). `rg --files` lists files only, so an empty folder has
* nothing for it to emit and the Explorer would never show it until its first
* file lands (and not even after a restart). We inject these alongside the rg
* list so a freshly created folder shows up immediately.
*
* Only *file-empty* dirs are injected, never a dir that contains files: a dir
* with files is already represented by those files (gitignore-filtered by rg),
* so this can never resurrect a gitignored content directory. Traversal honors
* the same IGNORE_DIRS as the fallback walk. `scope` (an absolute dir inside
* root) restricts the walk; returned paths stay relative to root.
*/
export async function listEmptyDirs(root: string, scope?: string): Promise<string[]> {
const out: string[] = []
/** Walk `abs`; return whether its subtree contains at least one file. */
async function walk(abs: string, depth: number): Promise<boolean> {
let entries: import('node:fs').Dirent[]
try {
entries = await readdir(abs, { withFileTypes: true })
} catch {
return false
}
let hasFile = false
const subdirs: string[] = []
for (const e of entries) {
if (ignored(e.name)) continue
if (e.isFile()) hasFile = true
else if (e.isDirectory()) subdirs.push(join(abs, e.name))
}
for (const childAbs of subdirs) {
const childHasFile = depth < 12 ? await walk(childAbs, depth + 1) : false
if (childHasFile) hasFile = true
else out.push(relative(root, childAbs).split(sep).join('/'))
}
return hasFile
}
await walk(scope || root, 0)
return out
}
/** Find the node at a root-relative path inside a tree ('' is the root). */
function findNode(tree: FileNode, rel: string): FileNode | null {
if (rel === '') return tree
let node: FileNode | null = tree
for (const part of rel.split('/').filter(Boolean)) {
node = node?.children?.find((c) => c.name === part) ?? null
if (!node) return null
}
return node
}
/**
* Fresh children for a single directory (root-relative path; '' = root). Used to
* re-read a folder on expand/collapse so newly added/removed files show up
* without a full tree walk. Stays consistent with the initial tree: rg-backed
* (honors gitignore + excludes), with the recursive-walk fallback only when
* ripgrep is unavailable.
*/
export async function readDirChildren(root: string, rel: string): Promise<FileNode[]> {
const abs = rel ? join(root, rel) : root
if (await rgAvailable()) {
const [paths, emptyDirs] = await Promise.all([
listFiles(root, abs).catch(() => [] as string[]),
listEmptyDirs(root, abs).catch(() => [] as string[]),
])
return findNode(buildTreeFromPaths(rootName(root), paths, emptyDirs), rel)?.children ?? []
}
return readDir(abs, root, 0)
}
async function readDir(abs: string, root: string, depth: number): Promise<FileNode[]> {
let entries: import('node:fs').Dirent[]
try {
@@ -134,6 +221,20 @@ export async function createProjectFile(root: string, rel: string): Promise<void
await writeFile(target, '', { encoding: 'utf8', flag: 'wx' })
}
/**
* Create a new, empty folder (relative path). Creates parent folders as needed,
* refuses to escape the project root, and throws if the folder already exists so
* a name collision is surfaced rather than silently swallowed. The empty folder
* shows up in the tree immediately (see `listEmptyDirs`).
*/
export async function createProjectDir(root: string, rel: string): Promise<void> {
const target = join(root, rel)
if (relative(root, target).startsWith('..')) throw new Error('outside project root')
const existing = await stat(target).catch(() => null)
if (existing) throw new Error('folder already exists')
await mkdir(target, { recursive: true })
}
/** Delete a project file or folder (relative path). Stays inside the project root. */
export async function deleteProjectFile(root: string, rel: string): Promise<void> {
const target = join(root, rel)

View File

@@ -4,7 +4,7 @@ import { spawn } from 'node:child_process'
import { app, dialog, shell, BrowserWindow, ipcMain, Menu } from 'electron'
import { watch, type FSWatcher } from 'chokidar'
import { addRecentProject, getName, getRecentProjects, getRoot, openDialog, setRoot } from './project'
import { createProjectFile, deleteProjectFile, readAll, readProjectFile, readTree, writeProjectFile } from './fs-service'
import { createProjectDir, createProjectFile, deleteProjectFile, readAll, readDirChildren, readProjectFile, readTree, writeProjectFile } from './fs-service'
import { commit, discard, load, stage, unstage } from './git-service'
import { createPty, killAllPtys, killPty, ptyAvailable, resizePty, writePty } from './pty-service'
import { getConfig, getRecent, getThemeCss, resolveConfig, setRecent } from './config'
@@ -203,10 +203,12 @@ function registerIpc(): void {
ipcMain.handle('fs:tree', () => { const r = getRoot(); return r ? readTree(r) : null })
ipcMain.handle('fs:files', () => { const r = getRoot(); return r ? readAll(r) : {} })
ipcMain.handle('fs:readDir', (_e, rel: string) => { const r = getRoot(); return r ? readDirChildren(r, rel) : [] })
ipcMain.handle('fs:read', (_e, rel: string) => { const r = getRoot(); return r ? readProjectFile(r, rel) : '' })
ipcMain.handle('fs:write', (_e, rel: string, content: string) => { const r = getRoot(); if (r) return writeProjectFile(r, rel, content) })
ipcMain.handle('fs:delete', (_e, rel: string) => { const r = getRoot(); if (r) return deleteProjectFile(r, rel) })
ipcMain.handle('fs:create', (_e, rel: string) => { const r = getRoot(); if (r) return createProjectFile(r, rel) })
ipcMain.handle('fs:mkdir', (_e, rel: string) => { const r = getRoot(); if (r) return createProjectDir(r, rel) })
ipcMain.handle('shell:reveal', (_e, rel: string) => { const r = getRoot(); if (r) shell.showItemInFolder(join(r, rel)) })
ipcMain.handle('git:load', () => { const r = getRoot(); return r ? load(r) : null })

View File

@@ -94,12 +94,14 @@ export async function searchContent(root: string, query: string): Promise<Conten
}
/** All project files (relative paths), honoring gitignore + excludes. Includes
* dotfiles (--hidden) so .env etc. show up unless ignored. */
export async function listFiles(root: string): Promise<string[]> {
* dotfiles (--hidden) so .env etc. show up unless ignored. Pass `scope` (an
* absolute dir inside root) to list only that subtree; paths stay relative to
* root either way. */
export async function listFiles(root: string, scope?: string): Promise<string[]> {
const rgPath = await rgPathPromise
if (!rgPath) return []
return new Promise((resolve) => {
const child = spawn(rgPath, ['--files', '--hidden', ...ignoreArgs(), '--', root])
const child = spawn(rgPath, ['--files', '--hidden', ...ignoreArgs(), '--', scope || root])
let buf = ''
const out: string[] = []
let done = false

View File

@@ -22,11 +22,13 @@ const api = {
fs: {
tree: () => ipcRenderer.invoke('fs:tree'),
readDir: (path: string) => ipcRenderer.invoke('fs:readDir', path),
files: () => ipcRenderer.invoke('fs:files'),
read: (path: string) => ipcRenderer.invoke('fs:read', path),
write: (path: string, content: string): Promise<void> => ipcRenderer.invoke('fs:write', path, content),
delete: (path: string): Promise<void> => ipcRenderer.invoke('fs:delete', path),
create: (path: string): Promise<void> => ipcRenderer.invoke('fs:create', path),
mkdir: (path: string): Promise<void> => ipcRenderer.invoke('fs:mkdir', path),
},
shell: {

View File

@@ -10,6 +10,7 @@ import { ProjectLauncher } from './launcher'
import type { Menu, Toast } from './overlays'
import type { FileNode, GitStatus } from './types'
import { useProject, useProjectActions } from './project'
import { HL } from './highlight'
import { loadJson, loadNum, saveJson, saveNum } from './persist'
const NO_COMMITTED = new Set<string>()
@@ -89,6 +90,7 @@ export function App(): React.ReactElement {
const [commitMsg, setCommitMsg] = useState('')
const [passPopup, setPassPopup] = useState<{ x: number; y: number; ref: string } | null>(null)
const [newFilePopup, setNewFilePopup] = useState<{ x: number; y: number; dir: string } | null>(null)
const [newFolderPopup, setNewFolderPopup] = useState<{ x: number; y: number; dir: string } | null>(null)
const [confirm, setConfirm] = useState<{ title: string; body?: string; confirmLabel: string; onConfirm: () => void } | null>(null)
// Brief full-screen "branch - repository" flash whenever the window gains focus
// (handy when juggling several project windows).
@@ -108,9 +110,20 @@ export function App(): React.ReactElement {
if (!window.helder) return
// A save flips the file's working-tree state (clean → modified, etc.), so
// refresh the git column directly the moment the write lands rather than
// waiting on the FS watcher's debounce.
// waiting on the FS watcher's debounce. Crucially also re-sync our on-disk
// snapshot (proj.files = diskText) to what's now on disk: without it the save
// guard compares against stale content, so an undo back to the original
// followed by ⌘S is skipped — the file stays modified on disk and the app
// keeps showing it as "changed". Git status then decides the state.
window.helder.fs.write(path, text)
.then(() => actions.refreshGit())
.then(() => {
actions.reloadFile(path).then((disk) => {
// Buffer is redundant once it's on disk — but only drop it if nothing
// was typed during the write (autosave debounce); never clobber newer edits.
setBuffers((b) => { if (b[path] !== disk) return b; const n = { ...b }; delete n[path]; return n })
}).catch(() => {})
actions.refreshGit()
})
.catch(() => toast('Save failed', path))
}
function saveActive(): void {
@@ -129,6 +142,16 @@ export function App(): React.ReactElement {
saveTimer.current = setTimeout(() => writeToDisk(path, text), 600)
}
}
// Re-read a file from disk and drop any in-memory buffer for it, so the
// editable "Updated"/code view always shows on-disk truth. Called whenever a
// file is opened or the view mode changes — the agent (or an external tool)
// may have rewritten the file since it was last loaded. On-disk content wins:
// an unsaved local edit is replaced by what's actually on disk.
function reloadFromDisk(path: string): void {
actions.reloadFile(path).then(() => {
setBuffers((b) => { if (b[path] == null) return b; const n = { ...b }; delete n[path]; return n })
}).catch(() => {})
}
function doDiscard(path: string): void {
if (proj.config.git.confirmDiscard &&
!window.confirm(`Discard changes to ${path}?\nThis reverts the file to the last commit and cannot be undone.`)) return
@@ -249,7 +272,8 @@ export function App(): React.ReactElement {
const toggleDir = useCallback((p: string) => {
setOpenDirs((s) => { const n = new Set(s); if (n.has(p)) n.delete(p); else n.add(p); return n })
}, [])
actions.refreshDir(p) // re-read the folder on every open/close so its children stay fresh
}, [actions])
function reveal(path: string): void {
setOpenDirs((s) => { const n = new Set(s); ancestors(path).forEach((a) => n.add(a)); return n })
@@ -298,6 +322,20 @@ export function App(): React.ReactElement {
openFile(rel)
}
// Create a new empty folder inside `dir` (project-relative, '' = root). The
// empty folder shows up in the tree at once (listEmptyDirs); expand the parent
// and the new folder so it's visible right away.
async function createFolder(dir: string, name: string): Promise<void> {
const rel = (dir ? dir + '/' : '') + name.replace(/^\/+/, '').replace(/\/+$/, '')
const bridge = window.helder
if (bridge) {
try { await bridge.fs.mkdir(rel) } catch { toast('Create failed', rel); return }
}
setOpenDirs((d) => { const n = new Set(d); if (dir) n.add(dir); n.add(rel); return n })
actions.refresh()
toast('Created folder', rel)
}
function askDelete(path: string, isDir: boolean): void {
setConfirm({
title: isDir ? 'Delete folder?' : 'Delete file?',
@@ -321,7 +359,7 @@ export function App(): React.ReactElement {
const changed = !!proj.diffs[path]
setFocusZone('editor')
setHistory((h) => [path, ...h.filter((p) => p !== path)].slice(0, 100))
actions.ensureFile(path)
reloadFromDisk(path)
setActive(path)
// Git rows open the diff; explorer / recent-files open the updated view.
// Unchanged files only have the plain editable "code" view.
@@ -376,7 +414,7 @@ export function App(): React.ReactElement {
// ---- context menus ----
function openMenu(e: React.MouseEvent, target: ContextTarget): void {
e.preventDefault(); e.stopPropagation()
const sparkSend = (ref: string): Menu['items'][number] => ({ icon: Icon.spark(), label: 'Send reference to agent', onClick: () => { window.dispatchEvent(new CustomEvent('agentPaste', { detail: ref })); toast('Passed to agent', ref) } })
const sparkSend = (ref: string): Menu['items'][number] => ({ icon: Icon.spark({ style: { color: 'var(--ren)' } }), label: 'Send reference to agent', onClick: () => { window.dispatchEvent(new CustomEvent('agentPaste', { detail: ref })); toast('Passed to agent', ref) } })
if (target.kind === 'editor') {
const ref = target.sel ? `${target.path}:${target.sel.start}-${target.sel.end}` : `${target.path}:${target.line}`
const mx = e.clientX, my = e.clientY
@@ -384,7 +422,7 @@ export function App(): React.ReactElement {
x: mx, y: my, note: ref,
items: [
{ primary: true, icon: Icon.copy(), label: 'Copy reference', onClick: () => copyText(ref) },
{ icon: Icon.spark(), label: 'Pass on to Agent', onClick: () => setPassPopup({ x: mx, y: my, ref }) },
{ icon: Icon.spark({ style: { color: 'var(--ren)' } }), label: 'Pass on to Agent', onClick: () => setPassPopup({ x: mx, y: my, ref }) },
],
})
} else {
@@ -394,41 +432,56 @@ export function App(): React.ReactElement {
const items: Menu['items'] = [
{ primary: true, icon: Icon.copy(), label: 'Copy reference', onClick: () => copyText(ref) },
sparkSend(ref),
{ icon: Icon.copy(), label: isDir ? 'Copy folder path' : 'Copy file name', onClick: () => copyText(isDir ? target.path : name, 'Copied') },
]
// For a folder, "Copy reference" already yields the path — so only files get
// the extra "Copy file name" (just the basename, distinct from the path).
if (!isDir) {
items.push({ icon: Icon.copy({ style: { color: 'var(--mod)' } }), label: 'Copy file name', onClick: () => copyText(name, 'Copied') })
}
if (isDir) {
const mx = e.clientX, my = e.clientY
items.push({ sep: true })
items.push({ icon: Icon.file(), label: 'New file', onClick: () => setNewFilePopup({ x: mx, y: my, dir: target.path }) })
items.push({ icon: Icon.file({ style: { color: 'var(--add)' } }), label: 'New file', onClick: () => setNewFilePopup({ x: mx, y: my, dir: target.path }) })
items.push({ icon: Icon.folder({ style: { color: 'var(--add)' } }), label: 'New folder', onClick: () => setNewFolderPopup({ x: mx, y: my, dir: target.path }) })
}
if (!isDir) {
items.push({ sep: true })
if (target.kind === 'git') {
const isStaged = proj.staged.has(target.path)
items.push(isStaged
? { icon: Icon.minus(), label: 'Unstage changes', onClick: () => unstageGuarded(target.path) }
: { icon: Icon.plus(), label: 'Stage changes', onClick: () => stageGuarded(target.path) })
items.push({ icon: Icon.diff(), label: 'Open diff', onClick: () => openFile(target.path, { diff: true }) })
items.push({ icon: Icon.discard(), label: 'Discard changes', onClick: () => doDiscard(target.path) })
? { icon: Icon.minus({ style: { color: 'var(--mod)' } }), label: 'Unstage changes', onClick: () => unstageGuarded(target.path) }
: { icon: Icon.plus({ style: { color: 'var(--add)' } }), label: 'Stage changes', onClick: () => stageGuarded(target.path) })
items.push({ icon: Icon.diff({ style: { color: 'var(--ren)' } }), label: 'Open diff', onClick: () => openFile(target.path, { diff: true }) })
items.push({ icon: Icon.discard({ style: { color: 'var(--del)' } }), label: 'Discard changes', onClick: () => doDiscard(target.path) })
}
items.push({ icon: Icon.file(), label: 'Open file', onClick: () => openFile(target.path) })
items.push({ icon: Icon.file({ style: { color: 'var(--ren)' } }), label: 'Open file', onClick: () => openFile(target.path) })
}
// Show in Finder + delete — for explorer files and folders (not git rows).
if (isDir || target.kind === 'file') {
items.push({ sep: true })
items.push({ icon: Icon.finder(), label: 'Show in Finder', onClick: () => revealInFinder(target.path) })
items.push({ icon: Icon.trash(), label: isDir ? 'Delete folder' : 'Delete file', onClick: () => askDelete(target.path, isDir) })
items.push({ icon: Icon.finder({ style: { color: 'var(--mod)' } }), label: 'Show in Finder', onClick: () => revealInFinder(target.path) })
items.push({ icon: Icon.trash({ style: { color: 'var(--del)' } }), label: isDir ? 'Delete folder' : 'Delete file', onClick: () => askDelete(target.path, isDir) })
}
setMenu({ x: e.clientX, y: e.clientY, note: ref, items })
setMenu({ x: e.clientX, y: e.clientY, note: ref, path: target.path, items })
}
}
// ⌘M cycles the active changed file through the four view modes.
// ⌘M cycles the active file through whatever views it supports: a changed file
// runs Updated → Original → Diff → Split (+ Preview for markdown); an unchanged
// markdown file toggles Code ↔ Preview. Plain unchanged files have one view, so
// there's nothing to cycle.
function cycleMode(): void {
if (!active || !proj.diffs[active]) return
const order: (Mode | 'split')[] = ['updated', 'original', 'diff', 'split']
const cur = splitFor === active ? 'split' : (tabMode[active] || defaultMode)
const next = order[(order.indexOf(cur) + 1) % order.length]
if (!active) return
reloadFromDisk(active)
const md = HL.langFor(active) === 'markdown'
const hasDiff = !!proj.diffs[active]
const order: (Mode | 'split')[] = hasDiff
? ['updated', 'original', 'diff', 'split', ...(md ? (['preview'] as const) : [])]
: md ? ['code', 'preview'] : ['code']
if (order.length < 2) return
const cur = splitFor === active ? 'split' : (tabMode[active] || (hasDiff ? defaultMode : 'code'))
const idx = order.indexOf(cur)
const next = order[(idx < 0 ? 0 : idx + 1) % order.length]
if (next === 'split') setSplitFor(active)
else { setTabMode((m) => ({ ...m, [active]: next as Mode })); setSplitFor(null) }
}
@@ -529,6 +582,10 @@ export function App(): React.ReactElement {
if (inField) return
e.preventDefault(); setAutoResize((v) => !v)
}
// ⌘. toggles hidden files. Match on e.code so it fires regardless of layout.
else if (meta && e.code === 'Period') {
e.preventDefault(); setShowHidden((v) => !v)
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
@@ -560,14 +617,17 @@ export function App(): React.ReactElement {
)}
<div className="tb-spacer" />
<div className="tb-actions">
<button className="tb-btn" onClick={() => { setSearchInit(''); setOverlay('search') }}>{Icon.search()} Search <kbd>F</kbd></button>
<button className={'tb-btn tb-toggle' + (overlay === 'search' ? ' on' : '')} onClick={() => { setSearchInit(''); setOverlay('search') }}
title="Search contents & names">
{Icon.search()} Search <span className="tb-state">{overlay === 'search' ? 'On' : 'Off'}</span> <kbd>F</kbd>
</button>
<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.'}>
{Icon.layout()} Auto-fit <span className="tb-state">{autoResize ? 'On' : 'Off'}</span>
{Icon.layout()} Auto-fit <span className="tb-state">{autoResize ? 'On' : 'Off'}</span> <kbd>A</kbd>
</button>
<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.'}>
{Icon.eye()} Hidden <span className="tb-state">{showHidden ? 'On' : 'Off'}</span>
{Icon.eye()} Hidden <span className="tb-state">{showHidden ? 'On' : 'Off'}</span> <kbd>.</kbd>
</button>
<button className="tb-btn tb-icon" onClick={() => setOverlay('help')} title="Keyboard shortcuts">{Icon.help()}</button>
</div>
@@ -591,14 +651,14 @@ export function App(): React.ReactElement {
<GitPanel branch={proj.branch} changes={proj.changes} staged={proj.staged} committed={NO_COMMITTED}
commitMsg={commitMsg} setCommitMsg={setCommitMsg}
onStage={stageGuarded} onUnstage={unstageGuarded} onStageAll={actions.stageAll} onUnstageAll={actions.unstageAll} onCommit={commit}
onOpen={openFile} onContext={openMenu} activePath={active} showDir={gitW > 300} />
onOpen={openFile} onContext={openMenu} activePath={active} ctxPath={menu?.path ?? null} showDir={gitW > 300} />
</div>
<Splitter onDelta={(dx) => { setAutoResize(false); setGitW((w) => clamp(w + dx, 160, 460)) }} />
<div className="col" style={{ width: treeW, flex: '0 0 ' + treeW + 'px' }}>
{proj.tree ? (
<FileTree tree={proj.tree} openDirs={openDirs} toggleDir={toggleDir} onOpen={openFile}
onContext={openMenu} activePath={active} changeMap={changeMap} committed={NO_COMMITTED} showHidden={showHidden} />
onContext={openMenu} activePath={active} ctxPath={menu?.path ?? null} changeMap={changeMap} committed={NO_COMMITTED} showHidden={showHidden} />
) : (
<div className="tree-body" />
)}
@@ -607,7 +667,7 @@ export function App(): React.ReactElement {
<div className="col editor-col" onMouseDownCapture={() => setFocusZone('editor')}>
<Editor active={active} mode={mode}
setMode={(m) => { if (active) { setTabMode((mm) => ({ ...mm, [active]: m })); setSplitFor(null) } }}
setMode={(m) => { if (active) { setTabMode((mm) => ({ ...mm, [active]: m })); setSplitFor(null); reloadFromDisk(active) } }}
onContext={openMenu}
onSplit={(p) => setSplitFor(p)} splitOpen={splitFor === active}
cursor={cursor} selection={selection} setCursor={setCursor} setSelection={setSelection}
@@ -636,6 +696,9 @@ export function App(): React.ReactElement {
{newFilePopup && <NamePopup x={newFilePopup.x} y={newFilePopup.y} dir={newFilePopup.dir}
onConfirm={(name) => { createFile(newFilePopup.dir, name); setNewFilePopup(null) }}
onCancel={() => setNewFilePopup(null)} />}
{newFolderPopup && <NamePopup x={newFolderPopup.x} y={newFolderPopup.y} dir={newFolderPopup.dir} kind="folder"
onConfirm={(name) => { createFolder(newFolderPopup.dir, name); setNewFolderPopup(null) }}
onCancel={() => setNewFolderPopup(null)} />}
{overlay === 'search' && <SearchModal initialQuery={searchInit} onOpen={openFile} onOpenAt={(p, n) => openFile(p, { line: n })} onClose={() => setOverlay(null)} changeSet={changeSet} activePath={active} activeText={bufferText(active)} showHidden={showHidden} />}
{overlay === 'history' && <HistoryModal history={history} initialSel={histInitSel} onOpen={openFile} onClose={() => setOverlay(null)} changeSet={changeSet} />}
{overlay === 'help' && <HelpModal onClose={() => setOverlay(null)} />}

View File

@@ -16,6 +16,7 @@ export const Icon: Record<string, (p?: SvgProps) => React.ReactElement> = {
spark: (p) => (<svg width="13" height="13" viewBox="0 0 16 16" fill="none" {...p}><path d="M8 1.5l1.6 4.9L14.5 8l-4.9 1.6L8 14.5 6.4 9.6 1.5 8l4.9-1.6L8 1.5z" stroke="currentColor" strokeWidth="1.1" fill="none" strokeLinejoin="round" /></svg>),
file: (p) => (<svg width="13" height="13" viewBox="0 0 16 16" fill="none" {...p}><path d="M4 2h5l3 3v9H4V2z" stroke="currentColor" strokeWidth="1.2" fill="none" /><path d="M9 2v3h3" stroke="currentColor" strokeWidth="1.2" fill="none" /></svg>),
reveal: (p) => (<svg width="13" height="13" viewBox="0 0 16 16" fill="none" {...p}><path d="M2 4.5h4l1.3 1.5H14V13H2V4.5z" stroke="currentColor" strokeWidth="1.2" fill="none" /></svg>),
folder: (p) => (<svg width="13" height="13" viewBox="0 0 16 16" fill="none" {...p}><path d="M2 4.5h4l1.3 1.5H14V13H2V4.5z" stroke="currentColor" strokeWidth="1.2" fill="none" strokeLinejoin="round" /></svg>),
diff: (p) => (<svg width="13" height="13" viewBox="0 0 16 16" fill="none" {...p}><path d="M4 2v8M4 12.5v1.5M2 4h4M2 8h4" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" /><path d="M12 14V6M12 3.5V2M10 12h4M10 8h4" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" /></svg>),
plus: (p) => (<svg width="13" height="13" viewBox="0 0 14 14" fill="none" {...p}><path d="M7 2.5v9M2.5 7h9" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" /></svg>),
minus: (p) => (<svg width="13" height="13" viewBox="0 0 14 14" fill="none" {...p}><path d="M2.5 7h9" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" /></svg>),
@@ -64,10 +65,11 @@ export interface ContextTarget {
export type OnContext = (e: React.MouseEvent, target: ContextTarget) => void
/* ============ Git / Source Control panel ============ */
function GitRow({ c, staged, activePath, showDir, onOpen, onContext, onToggleStage }: {
function GitRow({ c, staged, activePath, ctxPath, showDir, onOpen, onContext, onToggleStage }: {
c: Change
staged: boolean
activePath: string | null
ctxPath: string | null
showDir: boolean
onOpen: OpenFile
onContext: OnContext
@@ -77,7 +79,7 @@ function GitRow({ c, staged, activePath, showDir, onOpen, onContext, onToggleSta
const dir = c.path.split('/').slice(0, -1).join('/')
const dirShown = showDir && !!dir
return (
<div className={'git-row' + (activePath === c.path ? ' active' : '')}
<div className={'git-row' + (activePath === c.path ? ' active' : '') + (ctxPath === c.path ? ' ctx' : '')}
onClick={() => onOpen(c.path, { diff: true })}
onContextMenu={(e) => onContext(e, { path: c.path, kind: 'git', staged })}
title={c.path}>
@@ -93,7 +95,7 @@ function GitRow({ c, staged, activePath, showDir, onOpen, onContext, onToggleSta
)
}
export function GitPanel({ branch, changes, staged, committed, commitMsg, setCommitMsg, onStage, onUnstage, onStageAll, onUnstageAll, onCommit, onOpen, onContext, activePath, showDir }: {
export function GitPanel({ branch, changes, staged, committed, commitMsg, setCommitMsg, onStage, onUnstage, onStageAll, onUnstageAll, onCommit, onOpen, onContext, activePath, ctxPath, showDir }: {
branch: string
changes: Change[]
staged: Set<string>
@@ -108,6 +110,7 @@ export function GitPanel({ branch, changes, staged, committed, commitMsg, setCom
onOpen: OpenFile
onContext: OnContext
activePath: string | null
ctxPath: string | null
showDir: boolean
}): React.ReactElement {
const visible = changes.filter((c) => !committed.has(c.path))
@@ -135,7 +138,7 @@ export function GitPanel({ branch, changes, staged, committed, commitMsg, setCom
{stagedList.length > 0 && <button className="grp-act" title="Unstage all" onClick={onUnstageAll}>{Icon.minus()}</button>}
</div>
{stagedList.length > 0 ? stagedList.map((c) => (
<GitRow key={c.path} c={c} staged={true} activePath={activePath} showDir={showDir}
<GitRow key={c.path} c={c} staged={true} activePath={activePath} ctxPath={ctxPath} showDir={showDir}
onOpen={onOpen} onContext={onContext} onToggleStage={onUnstage} />
)) : (
<div className="git-none">Nothing staged use <span className="key">+</span> to stage a file</div>
@@ -148,7 +151,7 @@ export function GitPanel({ branch, changes, staged, committed, commitMsg, setCom
{changesList.length > 0 && <button className="grp-act" title="Stage all" onClick={onStageAll}>{Icon.plus()}</button>}
</div>
{changesList.length > 0 ? changesList.map((c) => (
<GitRow key={c.path} c={c} staged={false} activePath={activePath} showDir={showDir}
<GitRow key={c.path} c={c} staged={false} activePath={activePath} ctxPath={ctxPath} showDir={showDir}
onOpen={onOpen} onContext={onContext} onToggleStage={onStage} />
)) : (
<div className="git-none">All changes staged</div>
@@ -169,7 +172,7 @@ export function GitPanel({ branch, changes, staged, committed, commitMsg, setCom
}
/* ============ File Tree ============ */
function TreeNode({ node, depth, openDirs, toggleDir, onOpen, onContext, activePath, changeMap, committed, showHidden }: {
function TreeNode({ node, depth, openDirs, toggleDir, onOpen, onContext, activePath, ctxPath, changeMap, committed, showHidden }: {
node: FileNode
depth: number
openDirs: Set<string>
@@ -177,6 +180,7 @@ function TreeNode({ node, depth, openDirs, toggleDir, onOpen, onContext, activeP
onOpen: OpenFile
onContext: OnContext
activePath: string | null
ctxPath: string | null
changeMap: Record<string, GitStatus>
committed: Set<string>
showHidden: boolean
@@ -187,7 +191,7 @@ function TreeNode({ node, depth, openDirs, toggleDir, onOpen, onContext, activeP
return (
<Fragment>
{node.path !== '' && (
<div className="tree-row folder" style={{ paddingLeft: pad }}
<div className={'tree-row folder' + (ctxPath === node.path ? ' ctx' : '')} style={{ paddingLeft: pad }}
onClick={() => toggleDir(node.path)}
onContextMenu={(e) => onContext(e, { path: node.path, kind: 'dir' })}>
<span className="tw"><Chevron open={isOpen} /></span>
@@ -200,14 +204,14 @@ function TreeNode({ node, depth, openDirs, toggleDir, onOpen, onContext, activeP
.map((c) => (
<TreeNode key={c.path} node={c} depth={node.path === '' ? 0 : depth + 1}
openDirs={openDirs} toggleDir={toggleDir} onOpen={onOpen}
onContext={onContext} activePath={activePath} changeMap={changeMap} committed={committed} showHidden={showHidden} />
onContext={onContext} activePath={activePath} ctxPath={ctxPath} changeMap={changeMap} committed={committed} showHidden={showHidden} />
))}
</Fragment>
)
}
const status = committed && committed.has(node.path) ? null : changeMap[node.path]
return (
<div className={'tree-row' + (activePath === node.path ? ' active' : '')}
<div className={'tree-row' + (activePath === node.path ? ' active' : '') + (ctxPath === node.path ? ' ctx' : '')}
style={{ paddingLeft: pad + 2 }}
onClick={() => onOpen(node.path)}
onContextMenu={(e) => onContext(e, { path: node.path, kind: 'file' })}
@@ -220,13 +224,14 @@ function TreeNode({ node, depth, openDirs, toggleDir, onOpen, onContext, activeP
)
}
export function FileTree({ tree, openDirs, toggleDir, onOpen, onContext, activePath, changeMap, committed, showHidden }: {
export function FileTree({ tree, openDirs, toggleDir, onOpen, onContext, activePath, ctxPath, changeMap, committed, showHidden }: {
tree: FileNode
openDirs: Set<string>
toggleDir: (path: string) => void
onOpen: OpenFile
onContext: OnContext
activePath: string | null
ctxPath: string | null
changeMap: Record<string, GitStatus>
committed: Set<string>
showHidden: boolean
@@ -235,7 +240,7 @@ export function FileTree({ tree, openDirs, toggleDir, onOpen, onContext, activeP
<Fragment>
<div className="tree-body">
<TreeNode node={tree} depth={0} openDirs={openDirs} toggleDir={toggleDir}
onOpen={onOpen} onContext={onContext} activePath={activePath} changeMap={changeMap} committed={committed} showHidden={showHidden} />
onOpen={onOpen} onContext={onContext} activePath={activePath} ctxPath={ctxPath} changeMap={changeMap} committed={committed} showHidden={showHidden} />
</div>
</Fragment>
)

View File

@@ -3,12 +3,13 @@ import React, { Fragment, useMemo, useRef } from 'react'
import type { Diff, ViewLine } from './types'
import { useProject } from './project'
import { HL } from './highlight'
import { renderMarkdown } from './markdown'
import { FileIcon, Icon } from './components'
import type { OnContext } from './components'
export interface Cursor { path: string; line: number; col: number }
export interface Selection { path: string; start: number; end: number; anchor: number }
export type Mode = 'original' | 'updated' | 'diff' | 'code'
export type Mode = 'original' | 'updated' | 'diff' | 'code' | 'preview'
function climbToLine(node: Node | null): HTMLElement | null {
let el: HTMLElement | null = node && node.nodeType === 3 ? (node.parentElement as HTMLElement) : (node as HTMLElement | null)
@@ -79,6 +80,16 @@ function CodeEditor({ path, text, lang, onChange, onContext }: {
)
}
/* Rendered-markdown preview: read-only, derived from the live buffer text. */
function MarkdownView({ path, text, onContext }: { path: string; text: string; onContext: OnContext }): React.ReactElement {
const html = useMemo(() => renderMarkdown(text), [text])
return (
<div className="md-view" onContextMenu={(e) => { e.preventDefault(); onContext(e, { path, kind: 'editor', line: 1 }) }}>
<div className="md-body" dangerouslySetInnerHTML={{ __html: html }} />
</div>
)
}
/* Generic pane: renders an array of line descriptors with selection + caret + context. */
function PaneView({ cacheKey, path, lines, lang, showSign, cursor, selection, setCursor, setSelection, onContext }: {
cacheKey: string
@@ -190,11 +201,16 @@ function buildLines(mode: Mode, diff: Diff | null, fileText: string | undefined)
return { lines: arr.map((t, i) => ({ no: i + 1, text: t })), showSign: false }
}
const SEGMENTS: { id: Mode; label: string }[] = [
{ id: 'updated', label: 'Updated' },
{ id: 'original', label: 'Original' },
{ id: 'diff', label: 'Diff' },
]
/** View options available for a file, given whether it has a diff and whether
* it's markdown. Unchanged files only have the plain editable "Code" view; a
* changed file gets the Updated/Original/Diff trio; markdown adds "Preview". */
function segmentsFor(hasDiff: boolean, isMarkdown: boolean): { id: Mode; label: string }[] {
const segs: { id: Mode; label: string }[] = hasDiff
? [{ id: 'updated', label: 'Actual' }, { id: 'original', label: 'Original' }, { id: 'diff', label: 'Diff' }]
: [{ id: 'code', label: isMarkdown ? 'Actual' : 'Code' }]
if (isMarkdown) segs.push({ id: 'preview', label: 'Preview' })
return segs
}
export function Editor({ active, mode, setMode, onContext, onSplit, splitOpen, cursor, selection, setCursor, setSelection, bufferText, onEdit }: {
active: string | null
@@ -215,11 +231,21 @@ export function Editor({ active, mode, setMode, onContext, onSplit, splitOpen, c
const change = tab ? PROJECT.changes.find((c) => c.path === tab.path) : null
const diff = tab ? PROJECT.diffs[tab.path] : null
const lang = tab ? HL.langFor(tab.path) : null
const effMode: Mode = change ? mode : 'code'
const hasDiff = !!(change && diff)
const isMarkdown = lang === 'markdown'
const segments = segmentsFor(hasDiff, isMarkdown)
// Resolve the requested mode against what this file actually supports, so a
// mode carried over from another file (or an unchanged file asked for a diff
// view) falls back sensibly instead of rendering blank.
let effMode: Mode
if (mode === 'preview' && isMarkdown) effMode = 'preview'
else if (hasDiff) effMode = mode === 'code' || mode === 'preview' ? 'updated' : mode
else effMode = 'code'
let built: { lines: ViewLine[]; showSign: boolean } | null = null
if (tab) {
if (change && diff) built = buildLines(effMode, diff, PROJECT.files[tab.path])
if (tab && effMode !== 'preview') {
if (hasDiff) built = buildLines(effMode, diff, PROJECT.files[tab.path])
else built = buildLines('code', null, PROJECT.files[tab.path])
}
@@ -227,7 +253,7 @@ export function Editor({ active, mode, setMode, onContext, onSplit, splitOpen, c
const activeSeg = splitOpen ? 'split' : effMode
const emptyUpdated = effMode === 'updated' && built && built.lines.length === 0
const emptyOriginal = effMode === 'original' && built && built.lines.length === 0
// Editable in the live-buffer modes; Original/Diff stay read-only review views.
// Editable in the live-buffer modes; Original/Diff/Preview stay read-only views.
const editable = effMode === 'code' || effMode === 'updated'
return (
@@ -245,23 +271,33 @@ export function Editor({ active, mode, setMode, onContext, onSplit, splitOpen, c
</div>
) : (
<div className="editor-wrap">
{change && (
<div className="diff-bar">
<span className={'git-stat ' + change.status} style={{ width: 'auto' }}>{statusWord}</span>
{change.add > 0 && <span className="a">+{change.add}</span>}
{change.del > 0 && <span className="d">{change.del}</span>}
<div className="seg">
{SEGMENTS.map((s) => (
<button key={s.id} className={activeSeg === s.id ? 'on' : ''} onClick={() => setMode(s.id)}>{s.label}</button>
))}
{/* The view bar is always present; what it offers depends on the file
state (changed → diff views, markdown → Preview, else just Code). */}
<div className="diff-bar">
{change ? (
<Fragment>
<span className={'git-stat ' + change.status} style={{ width: 'auto' }}>{statusWord}</span>
{change.add > 0 && <span className="a">+{change.add}</span>}
{change.del > 0 && <span className="d">{change.del}</span>}
</Fragment>
) : (
<span className="db-lang">{HL.langLabel(tab.path)}</span>
)}
<div className="seg">
{segments.map((s) => (
<button key={s.id} className={activeSeg === s.id ? 'on' : ''} onClick={() => setMode(s.id)}>{s.label}</button>
))}
{hasDiff && (
<button className={'split-btn' + (activeSeg === 'split' ? ' on' : '')} onClick={() => onSplit(tab.path)} title="Split — full screen side-by-side">
<svg width="11" height="11" viewBox="0 0 12 12" fill="none"><rect x="1" y="1.5" width="10" height="9" rx="1.5" stroke="currentColor" strokeWidth="1.2" /><line x1="6" y1="1.5" x2="6" y2="10.5" stroke="currentColor" strokeWidth="1.2" /></svg>
Split
</button>
</div>
)}
</div>
)}
{emptyUpdated ? (
</div>
{effMode === 'preview' ? (
<MarkdownView path={tab.path} text={bufferText} onContext={onContext} />
) : emptyUpdated ? (
<div className="empty-ed"><div className="big" style={{ color: 'var(--del)' }}>No updated version</div><div style={{ fontFamily: 'var(--mono)', fontSize: 12, color: 'var(--fg-3)' }}>This file was deleted in the change.</div></div>
) : emptyOriginal ? (
<div className="empty-ed"><div className="big" style={{ color: 'var(--add)' }}>No original version</div><div style={{ fontFamily: 'var(--mono)', fontSize: 12, color: 'var(--fg-3)' }}>This file is new in the change.</div></div>

View File

@@ -20,11 +20,13 @@ interface HelderBridge {
}
fs: {
tree: () => Promise<FileNode | null>
readDir: (path: string) => Promise<FileNode[]>
files: () => Promise<Record<string, string>>
read: (path: string) => Promise<string>
write: (path: string, content: string) => Promise<void>
delete: (path: string) => Promise<void>
create: (path: string) => Promise<void>
mkdir: (path: string) => Promise<void>
}
shell: {
reveal: (path: string) => void

View File

@@ -0,0 +1,118 @@
/* Minimal, self-contained Markdown → HTML renderer for the file viewer's
* "Preview" mode. A block-level line parser plus an inline pass. All text is
* HTML-escaped; only a safe subset of inline tags is emitted (no raw HTML
* passthrough). Fenced code blocks are highlighted with Prism via HL. */
import { HL } from './highlight'
/** Fence info-string → Prism language id (HL.hlText expects an id, not an alias). */
const FENCE_LANG: Record<string, string> = {
php: 'php', js: 'javascript', javascript: 'javascript', mjs: 'javascript',
jsx: 'jsx', ts: 'typescript', typescript: 'typescript', tsx: 'tsx',
py: 'python', python: 'python', html: 'markup', xml: 'markup', vue: 'markup',
css: 'css', scss: 'css', json: 'json', sh: 'bash', bash: 'bash', shell: 'bash',
yml: 'yaml', yaml: 'yaml', md: 'markdown', markdown: 'markdown',
}
// Sentinel wrapping protected code-span placeholders. A private-use code point
// that never occurs in real markdown and isn't touched by escapeHtml, so it
// can't collide with prose (a plain " 5 " would) nor trip control-char rules.
const SENT = ''
const SENT_RE = /(\d+)/g
/** Allow http(s), mailto, in-page anchors and relative paths; drop anything
* else (e.g. `javascript:`) so a previewed file can't smuggle a live URL. */
function safeUrl(url: string): string {
const u = url.trim()
if (/^(https?:|mailto:|#|\.?\.?\/)/i.test(u)) return u
if (/^[a-z][a-z0-9+.-]*:/i.test(u)) return '' // some other scheme → drop
return u // bare relative (e.g. `images/x.png`)
}
/** Inline markdown on one already-untrusted text run. */
function inline(src: string): string {
// Pull code spans out first so their literal content is never re-processed.
const codes: string[] = []
let s = src.replace(/`([^`]+)`/g, (_m, c) => {
codes.push('<code>' + HL.escapeHtml(c) + '</code>')
return SENT + (codes.length - 1) + SENT
})
s = HL.escapeHtml(s)
s = s.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_m, alt, url) => {
const u = safeUrl(url)
return u ? `<img alt="${alt}" src="${u}" />` : alt
})
s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_m, t, url) => {
const u = safeUrl(url)
return u ? `<a href="${u}" target="_blank" rel="noreferrer">${t}</a>` : t
})
s = s.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
s = s.replace(/__([^_]+)__/g, '<strong>$1</strong>')
s = s.replace(/\*([^*]+)\*/g, '<em>$1</em>')
s = s.replace(/(^|[^a-zA-Z0-9_])_([^_]+)_(?=[^a-zA-Z0-9_]|$)/g, '$1<em>$2</em>')
s = s.replace(/~~([^~]+)~~/g, '<del>$1</del>')
// Restore the protected code spans.
return s.replace(SENT_RE, (_m, i) => codes[+i])
}
export function renderMarkdown(text: string): string {
const lines = text.replace(/\r\n?/g, '\n').split('\n')
const out: string[] = []
let para: string[] = []
const flushPara = (): void => {
if (para.length) { out.push('<p>' + inline(para.join(' ')) + '</p>'); para = [] }
}
let i = 0
while (i < lines.length) {
const line = lines[i]
const fence = line.match(/^```\s*([\w+-]*)\s*$/)
if (fence) {
flushPara()
const lang = FENCE_LANG[fence[1].toLowerCase()] || null
const buf: string[] = []
i++
while (i < lines.length && !/^```\s*$/.test(lines[i])) { buf.push(lines[i]); i++ }
i++ // skip closing fence
const code = buf.join('\n')
out.push('<pre class="md-code"><code>' + (lang ? HL.hlText(code, lang) : HL.escapeHtml(code)) + '</code></pre>')
continue
}
if (/^\s*$/.test(line)) { flushPara(); i++; continue }
const h = line.match(/^(#{1,6})\s+(.*)$/)
if (h) { flushPara(); const n = h[1].length; out.push(`<h${n}>` + inline(h[2].trim()) + `</h${n}>`); i++; continue }
if (/^\s*([-*_])(\s*\1){2,}\s*$/.test(line)) { flushPara(); out.push('<hr />'); i++; continue }
if (/^\s*>/.test(line)) {
flushPara()
const buf: string[] = []
while (i < lines.length && /^\s*>/.test(lines[i])) { buf.push(lines[i].replace(/^\s*>\s?/, '')); i++ }
out.push('<blockquote>' + renderMarkdown(buf.join('\n')) + '</blockquote>')
continue
}
if (/^\s*[-*+]\s+/.test(line)) {
flushPara()
const items: string[] = []
while (i < lines.length && /^\s*[-*+]\s+/.test(lines[i])) { items.push(lines[i].replace(/^\s*[-*+]\s+/, '')); i++ }
out.push('<ul>' + items.map((it) => '<li>' + inline(it) + '</li>').join('') + '</ul>')
continue
}
if (/^\s*\d+[.)]\s+/.test(line)) {
flushPara()
const items: string[] = []
while (i < lines.length && /^\s*\d+[.)]\s+/.test(lines[i])) { items.push(lines[i].replace(/^\s*\d+[.)]\s+/, '')); i++ }
out.push('<ol>' + items.map((it) => '<li>' + inline(it) + '</li>').join('') + '</ol>')
continue
}
para.push(line.trim())
i++
}
flushPara()
return out.join('\n')
}

View File

@@ -13,7 +13,7 @@ export interface MenuItem {
kbd?: string
onClick?: () => void
}
export interface Menu { x: number; y: number; note?: string; items: MenuItem[] }
export interface Menu { x: number; y: number; note?: string; path?: string; items: MenuItem[] }
export interface Toast { id: number; title: string; ref?: string }
interface ContentHit { no: number; ln: string; ix: number }
interface ContentGroup { path: string; hits: ContentHit[] }
@@ -348,6 +348,7 @@ const SHORTCUTS: { keys: string[]; label: string }[] = [
{ keys: ['⌘', 'C'], label: 'Focus the commit message' },
{ keys: ['⌘', '↵'], label: 'Commit the staged files' },
{ keys: ['⌘', 'A'], label: 'Toggle auto-fit panels' },
{ keys: ['⌘', '.'], label: 'Toggle hidden (dot)files' },
{ keys: ['⌘', 'S'], label: 'Save the current file' },
{ keys: ['⌘', 'W'], label: 'Close the current file' },
{ keys: ['⌘', 'D'], label: 'Delete the current file (confirm)' },
@@ -489,13 +490,15 @@ export function PassPopup({ x, y, refStr, onConfirm, onCancel }: {
)
}
export function NamePopup({ x, y, dir, onConfirm, onCancel }: {
export function NamePopup({ x, y, dir, kind = 'file', onConfirm, onCancel }: {
x: number
y: number
dir: string
kind?: 'file' | 'folder'
onConfirm: (name: string) => void
onCancel: () => void
}): React.ReactElement {
const isFolder = kind === 'folder'
const [name, setName] = useState('')
const inputRef = useRef<HTMLInputElement>(null)
const boxRef = useRef<HTMLDivElement>(null)
@@ -513,16 +516,16 @@ export function NamePopup({ x, y, dir, onConfirm, onCancel }: {
const target = (dir ? dir + '/' : '') + trimmed
return (
<div className="pass-pop" ref={boxRef} style={{ left, top }}>
<div className="pass-head">{Icon.file()}<span>New file</span><span className="pass-esc">esc</span></div>
<div className="pass-head">{isFolder ? Icon.folder() : Icon.file()}<span>{isFolder ? 'New folder' : 'New file'}</span><span className="pass-esc">esc</span></div>
<input ref={inputRef} className="pass-input" value={name} spellCheck={false}
placeholder="file name…"
placeholder={isFolder ? 'folder name…' : 'file name…'}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') { e.preventDefault(); if (trimmed) onConfirm(trimmed) }
else if (e.key === 'Escape') { e.preventDefault(); onCancel() }
}} />
<div className="pass-preview"><span className="pp-lbl">creates</span><code>{target || '…'}</code></div>
<div className="pass-foot"><kbd></kbd> create file · <kbd>esc</kbd> cancel</div>
<div className="pass-preview"><span className="pp-lbl">creates</span><code>{target ? target + (isFolder ? '/' : '') : '…'}</code></div>
<div className="pass-foot"><kbd></kbd> create {isFolder ? 'folder' : 'file'} · <kbd>esc</kbd> cancel</div>
</div>
)
}

View File

@@ -27,6 +27,14 @@ export interface ProjectData {
recents: RecentProject[]
}
/** Immutable copy of the tree with the children of the node at `path` replaced.
* Root is path '' . Returns the tree unchanged when the path isn't found. */
function replaceChildren(tree: FileNode, path: string, children: FileNode[]): FileNode {
if (tree.path === path) return { ...tree, children }
if (!tree.children) return tree
return { ...tree, children: tree.children.map((c) => replaceChildren(c, path, children)) }
}
/** Inject the project's theme.css over the built-in dark theme. */
function applyTheme(css: string): void {
let el = document.getElementById('helder-theme') as HTMLStyleElement | null
@@ -42,6 +50,10 @@ export interface ProjectActions {
openFolder: () => void
openProjectPath: (path: string) => void
refresh: () => void
/** Re-read a single folder's children from disk and splice them into the tree.
* Called on every folder expand/collapse so the row reflects on-disk truth
* (files added/removed by the agent or an external tool) without a full walk. */
refreshDir: (path: string) => void
refreshGit: () => void
stage: (path: string) => void
unstage: (path: string) => void
@@ -50,6 +62,11 @@ export interface ProjectActions {
commit: (message: string) => Promise<number>
discard: (path: string) => void
ensureFile: (path: string) => void
/** Force re-read a file from disk into the content index, returning the fresh
* text. Unlike ensureFile (which only fills a gap), this always overwrites the
* cached copy — so the editable view reflects on-disk truth after the agent or
* an external tool rewrites the file. */
reloadFile: (path: string) => Promise<string>
}
const MOCK_STAGED = ['src/Service/PaymentService.php', 'config/app.json']
@@ -187,6 +204,7 @@ export function ProjectProvider({ children }: { children: React.ReactNode }): Re
openFolder: () => {},
openProjectPath: () => {},
refresh: () => setData(mockData()),
refreshDir: () => {},
refreshGit: () => {},
stage: (p) => setStaged((s) => (s.add(p), s)),
unstage: (p) => setStaged((s) => (s.delete(p), s)),
@@ -204,6 +222,7 @@ export function ProjectProvider({ children }: { children: React.ReactNode }): Re
staged: (() => { const s = new Set(d.staged); s.delete(p); return s })(),
})),
ensureFile: () => {},
reloadFile: async (p) => dataRef.current.files[p] ?? '',
}
}
// ---- real git-backed actions ----
@@ -212,6 +231,11 @@ export function ProjectProvider({ children }: { children: React.ReactNode }): Re
openFolder: () => { bridge.project.open().then(() => loadReal()).catch(() => {}) },
openProjectPath: (path) => { bridge.project.openPath(path).then(() => loadReal()).catch(() => {}) },
refresh: () => { loadReal().catch(() => {}) },
refreshDir: (path) => {
bridge.fs.readDir(path).then((children) => {
setData((d) => (d.tree ? { ...d, tree: replaceChildren(d.tree, path, children) } : d))
}).catch(() => {})
},
refreshGit: () => { loadGit().catch(() => {}) },
stage: (p) => after(bridge.git.stage([p])),
unstage: (p) => after(bridge.git.unstage([p])),
@@ -238,6 +262,15 @@ export function ProjectProvider({ children }: { children: React.ReactNode }): Re
setData((d) => (d.files[path] != null ? d : { ...d, files: { ...d.files, [path]: txt } }))
}).catch(() => {})
},
reloadFile: async (path) => {
try {
const txt = await bridge.fs.read(path)
setData((d) => ({ ...d, files: { ...d.files, [path]: txt } }))
return txt
} catch {
return dataRef.current.files[path] ?? ''
}
},
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])

View File

@@ -111,7 +111,8 @@ body {
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 kbd { font-family:var(--mono); font-size:10px; color:var(--fg-3); border:1px solid var(--border-2); border-radius:4px; padding:1px 5px; }
.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 { border-color:var(--border); }
.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 { color:var(--fg-1); border-color:var(--border-2); }
.tb-toggle.on .tb-state { background:var(--accent-soft); color:var(--accent); }
@@ -160,7 +161,8 @@ body {
.git-row {
display:flex; align-items:center; gap:8px; padding:3px 12px 3px 14px; cursor:pointer; position:relative;
}
.git-row:hover { background:var(--hover); }
.git-row:hover, .git-row.ctx { background:var(--hover); }
.git-row.ctx .git-act { visibility:visible; }
.git-row.active { background:var(--sel); }
.git-row.active::before { content:""; position:absolute; left:0; top:0; bottom:0; width:2px; background:var(--accent); }
.git-stat { width:13px; text-align:center; font-family:var(--mono); font-size:11px; font-weight:600; flex:0 0 13px; }
@@ -180,7 +182,7 @@ body {
/* ============ file tree ============ */
.tree-body { overflow:auto; flex:1; padding:4px 0 14px; }
.tree-row { display:flex; align-items:center; gap:6px; padding:2px 10px 2px 0; cursor:pointer; white-space:nowrap; position:relative; height:23px; }
.tree-row:hover { background:var(--hover); }
.tree-row:hover, .tree-row.ctx { background:var(--hover); }
.tree-row.active { background:var(--sel); }
.tree-row.active::before { content:""; position:absolute; left:0; top:0; bottom:0; width:2px; background:var(--accent); }
.tw { display:inline-flex; align-items:center; justify-content:center; width:14px; flex:0 0 14px; color:var(--fg-3); font-size:10px; }
@@ -215,7 +217,7 @@ body {
.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 .big { font-size:13px; }
.empty-ed kbd { font-family:var(--mono); font-size:11px; color:var(--fg-2); border:1px solid var(--border-2); border-radius:5px; padding:2px 7px; }
.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 div { display:flex; gap:10px; align-items:center; justify-content:space-between; min-width:230px; }
@@ -232,6 +234,26 @@ body {
.diff-bar .seg button:hover { color:var(--fg-0); background:var(--hover); }
.diff-bar .seg button.on { background:var(--accent-soft); color:var(--fg-0); }
.diff-bar .seg .split-btn svg { opacity:.85; }
.diff-bar .db-lang { font-family:var(--mono); font-size:10.5px; color:var(--fg-3); letter-spacing:.02em; }
/* rendered-markdown preview (Preview view option) */
.md-view { flex:1; overflow:auto; padding:8px 0 48px; }
.md-body { max-width:860px; margin:0 auto; padding:14px 40px 40px; color:var(--fg-1); font-family:var(--ui); font-size:14px; line-height:1.65; }
.md-body h1, .md-body h2, .md-body h3, .md-body h4, .md-body h5, .md-body h6 { color:var(--fg-0); font-weight:600; line-height:1.3; margin:1.4em 0 .55em; }
.md-body h1 { font-size:1.7em; padding-bottom:.3em; border-bottom:1px solid var(--border); }
.md-body h2 { font-size:1.4em; padding-bottom:.25em; border-bottom:1px solid var(--border); }
.md-body h3 { font-size:1.18em; } .md-body h4 { font-size:1.02em; }
.md-body h1:first-child, .md-body h2:first-child, .md-body h3:first-child { margin-top:.2em; }
.md-body p { margin:.7em 0; }
.md-body a { color:var(--accent); text-decoration:none; } .md-body a:hover { text-decoration:underline; }
.md-body ul, .md-body ol { margin:.6em 0; padding-left:1.6em; } .md-body li { margin:.25em 0; }
.md-body blockquote { margin:.8em 0; padding:.1em 1em; border-left:3px solid var(--border-2); color:var(--fg-2); }
.md-body hr { border:0; border-top:1px solid var(--border); margin:1.4em 0; }
.md-body img { max-width:100%; border-radius:6px; }
.md-body code { font-family:var(--code-font); font-size:.88em; background:var(--bg-1); border:1px solid var(--border); border-radius:4px; padding:.1em .35em; }
.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 strong { color:var(--fg-0); font-weight:600; }
/* gutter change bars (Original / Updated / Split) */
.ln-row.bar-del { box-shadow:inset 2px 0 0 var(--del); }
@@ -245,7 +267,7 @@ body {
.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:hover { background:var(--hover); color:var(--fg-0); }
.split-exit kbd { font-family:var(--mono); font-size:10px; color:var(--fg-3); border:1px solid var(--border-2); border-radius:4px; padding:1px 5px; }
.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-pane { flex:1; min-width:0; display:flex; flex-direction:column; }
.split-pane.left { border-right:1px solid var(--border-2); }
@@ -343,7 +365,7 @@ body {
.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 .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:10px; color:var(--fg-2); border:1px solid var(--border-2); border-radius:4px; padding:0 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-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); }
@@ -378,7 +400,7 @@ body {
.pass-preview .pp-lbl { font-size:10px; text-transform:uppercase; letter-spacing:.06em; color:var(--fg-3); flex:0 0 auto; }
.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-foot { margin-top:9px; font-size:10.5px; color:var(--fg-3); }
.pass-foot kbd { font-family:var(--mono); font-size:10px; color:var(--fg-2); border:1px solid var(--border-2); border-radius:4px; padding:0 5px; }
.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 */
.term-input { align-items:flex-start; }
@@ -462,9 +484,9 @@ body {
.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-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:10px; color:var(--fg-3); border:1px solid var(--border-2); border-radius:4px; padding:1px 5px; flex:0 0 auto; }
.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 kbd { font-family:var(--mono); font-size:10px; color:var(--fg-2); border:1px solid var(--border-2); border-radius:4px; padding:0 5px; }
.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 ============ */
.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; }
@@ -476,7 +498,7 @@ body {
.help-row { display:flex; align-items:center; gap:14px; padding:6px 12px; border-radius:7px; }
.help-row:hover { background:var(--hover); }
.help-keys { flex:0 0 96px; display:flex; gap:4px; justify-content:flex-end; }
.help-keys kbd { font-family:var(--mono); font-size:11px; 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 { 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-label { font-size:12.5px; color:var(--fg-2); }
/* title-bar icon-only button (help ?) */
@@ -489,7 +511,7 @@ body {
.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:hover { background:var(--hover); color:var(--fg-0); }
.cf-btn kbd { font-family:var(--mono); font-size:10px; color:var(--fg-3); border:1px solid var(--border-2); border-radius:4px; padding:0 5px; }
.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:hover { background:#f6b35f; color:#201608; }
.cf-yes kbd { color:#201608; border-color:rgba(0,0,0,.25); }
@@ -498,7 +520,7 @@ body {
.cf-yes.danger kbd { color:#fff; border-color:rgba(255,255,255,.4); }
/* search: active result column + file-name selection */
.sc-head .col-kbd { margin-left:auto; font-family:var(--mono); font-size:9.5px; 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; 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-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-infile.active .sc-head .scf-name { color:var(--accent); }

View File

@@ -34,9 +34,9 @@ async function openChanged(): Promise<HTMLElement> {
}
describe('Editor view modes', () => {
it('Updated mode is an editable buffer; Original is read-only', async () => {
it('Actual mode is an editable buffer; Original is read-only', async () => {
const c = await openChanged()
fireEvent.click(find(c, '.seg button', 'Updated')!)
fireEvent.click(find(c, '.seg button', 'Actual')!)
await waitFor(() => expect(c.querySelector('.ce-ta')).toBeTruthy())
fireEvent.click(find(c, '.seg button', 'Original')!)

View File

@@ -3,7 +3,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { simpleGit } from 'simple-git'
import { buildTreeFromPaths, createProjectFile, readAll, readProjectFile, readTree, writeProjectFile } from '../src/main/fs-service'
import { buildTreeFromPaths, createProjectFile, readAll, readDirChildren, readProjectFile, readTree, writeProjectFile } from '../src/main/fs-service'
let dir = ''
afterEach(async () => { if (dir) await rm(dir, { recursive: true, force: true }) })
@@ -33,6 +33,51 @@ describe('readTree', () => {
expect(top.indexOf('src')).toBeLessThan(top.indexOf('README.md'))
expect(top.indexOf('src')).toBeLessThan(top.indexOf('logo.bin'))
})
it('shows an empty folder (no files yet) that rg --files can not emit', async () => {
dir = await fixture()
await mkdir(join(dir, 'empty'), { recursive: true })
await mkdir(join(dir, 'src', 'nested', 'deep'), { recursive: true }) // file-empty nested chain
const tree = await readTree(dir)
const top = (tree.children || []).map((c) => c.name)
expect(top).toContain('empty')
const empty = (tree.children || []).find((c) => c.name === 'empty')!
expect(empty.type).toBe('dir')
// the nested empty chain shows under the (file-bearing) src folder
const src = (tree.children || []).find((c) => c.name === 'src')!
const nested = (src.children || []).find((c) => c.name === 'nested')!
expect(nested.type).toBe('dir')
expect((nested.children || []).map((c) => c.name)).toEqual(['deep'])
})
})
describe('readDirChildren', () => {
it('returns a single folder\'s children (root and subdir), ignoring node_modules', async () => {
dir = await fixture()
const top = (await readDirChildren(dir, '')).map((c) => c.name)
expect(top).toContain('src')
expect(top).toContain('README.md')
expect(top).not.toContain('node_modules')
const src = await readDirChildren(dir, 'src')
expect(src.map((c) => c.name)).toEqual(['a.ts'])
expect(src[0].path).toBe('src/a.ts')
})
it('shows an empty subfolder created since the initial tree read', async () => {
dir = await fixture()
await mkdir(join(dir, 'src', 'fresh'), { recursive: true })
const src = await readDirChildren(dir, 'src')
const fresh = src.find((c) => c.name === 'fresh')
expect(fresh?.type).toBe('dir')
expect(fresh?.path).toBe('src/fresh')
})
it('reflects files added/removed since the initial tree read', async () => {
dir = await fixture()
await writeFile(join(dir, 'src', 'b.ts'), 'export const b = 2\n')
await rm(join(dir, 'src', 'a.ts'))
expect((await readDirChildren(dir, 'src')).map((c) => c.name)).toEqual(['b.ts'])
})
})
describe('readAll', () => {
@@ -67,6 +112,15 @@ describe('buildTreeFromPaths', () => {
expect((src.children || []).map((c) => c.name)).toEqual(['util', 'a.ts', 'b.ts'])
expect((src.children || []).find((c) => c.name === 'util')!.path).toBe('src/util')
})
it('forces dirPaths in as empty folders, deduped against file-derived dirs', () => {
const t = buildTreeFromPaths('proj', ['src/a.ts'], ['empty', 'src/sub', 'src'])
const top = (t.children || []).map((c) => c.name)
expect(top).toEqual(['empty', 'src']) // dirs alphabetical, both present once
const src = (t.children || []).find((c) => c.name === 'src')!
expect((src.children || []).map((c) => c.name)).toEqual(['sub', 'a.ts'])
expect((src.children || []).find((c) => c.name === 'sub')!.type).toBe('dir')
})
})
describe('read/write round-trip', () => {

44
test/markdown.test.ts Normal file
View File

@@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest'
import { renderMarkdown } from '../src/renderer/src/markdown'
describe('renderMarkdown', () => {
it('renders headings, bold, italic and links', () => {
const html = renderMarkdown('# Title\n\nSome **bold** and *italic* and [a](https://x.com).')
expect(html).toContain('<h1>Title</h1>')
expect(html).toContain('<strong>bold</strong>')
expect(html).toContain('<em>italic</em>')
expect(html).toContain('<a href="https://x.com" target="_blank" rel="noreferrer">a</a>')
})
it('restores code spans without colliding with surrounding digits', () => {
// " 0 " around the text used to clash with the placeholder index — guard it.
const html = renderMarkdown('I have 0 cats and `code` and 1 dog.')
expect(html).toContain('<code>code</code>')
expect(html).toContain('I have 0 cats')
expect(html).toContain('1 dog.')
expect(html).not.toContain('undefined')
})
it('escapes HTML and never passes through raw tags', () => {
const html = renderMarkdown('A <script>alert(1)</script> tag.')
expect(html).toContain('&lt;script&gt;')
expect(html).not.toContain('<script>')
})
it('drops dangerous link schemes but keeps the text', () => {
const html = renderMarkdown('[click](javascript:alert(1))')
expect(html).not.toContain('javascript:')
expect(html).toContain('click')
})
it('highlights fenced code blocks', () => {
const html = renderMarkdown('```js\nconst a = 1\n```')
expect(html).toContain('<pre class="md-code">')
expect(html).toContain('const')
})
it('renders unordered and ordered lists', () => {
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>')
})
})