diff --git a/src/main/fs-service.ts b/src/main/fs-service.ts index 2ac60bf..3cf6ab9 100644 --- a/src/main/fs-service.ts +++ b/src/main/fs-service.ts @@ -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([['', 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 { - 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 { + const out: string[] = [] + /** Walk `abs`; return whether its subtree contains at least one file. */ + async function walk(abs: string, depth: number): Promise { + 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 { + 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 { let entries: import('node:fs').Dirent[] try { @@ -134,6 +221,20 @@ export async function createProjectFile(root: string, rel: string): Promise { + 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 { const target = join(root, rel) diff --git a/src/main/index.ts b/src/main/index.ts index bafcadb..360bf83 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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 }) diff --git a/src/main/search-service.ts b/src/main/search-service.ts index 1c21123..2b15be6 100644 --- a/src/main/search-service.ts +++ b/src/main/search-service.ts @@ -94,12 +94,14 @@ export async function searchContent(root: string, query: string): Promise { + * 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 { 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 diff --git a/src/preload/index.ts b/src/preload/index.ts index f431048..faffe9e 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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 => ipcRenderer.invoke('fs:write', path, content), delete: (path: string): Promise => ipcRenderer.invoke('fs:delete', path), create: (path: string): Promise => ipcRenderer.invoke('fs:create', path), + mkdir: (path: string): Promise => ipcRenderer.invoke('fs:mkdir', path), }, shell: { diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index c38250a..46ee572 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -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() @@ -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 { + 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 { )}
- +
@@ -591,14 +651,14 @@ export function App(): React.ReactElement { 300} /> + onOpen={openFile} onContext={openMenu} activePath={active} ctxPath={menu?.path ?? null} showDir={gitW > 300} />
{ setAutoResize(false); setGitW((w) => clamp(w + dx, 160, 460)) }} />
{proj.tree ? ( + onContext={openMenu} activePath={active} ctxPath={menu?.path ?? null} changeMap={changeMap} committed={NO_COMMITTED} showHidden={showHidden} /> ) : (
)} @@ -607,7 +667,7 @@ export function App(): React.ReactElement {
setFocusZone('editor')}> { 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 && { createFile(newFilePopup.dir, name); setNewFilePopup(null) }} onCancel={() => setNewFilePopup(null)} />} + {newFolderPopup && { createFolder(newFolderPopup.dir, name); setNewFolderPopup(null) }} + onCancel={() => setNewFolderPopup(null)} />} {overlay === 'search' && openFile(p, { line: n })} onClose={() => setOverlay(null)} changeSet={changeSet} activePath={active} activeText={bufferText(active)} showHidden={showHidden} />} {overlay === 'history' && setOverlay(null)} changeSet={changeSet} />} {overlay === 'help' && setOverlay(null)} />} diff --git a/src/renderer/src/components.tsx b/src/renderer/src/components.tsx index 65242d9..5307356 100644 --- a/src/renderer/src/components.tsx +++ b/src/renderer/src/components.tsx @@ -16,6 +16,7 @@ export const Icon: Record React.ReactElement> = { spark: (p) => (), file: (p) => (), reveal: (p) => (), + folder: (p) => (), diff: (p) => (), plus: (p) => (), minus: (p) => (), @@ -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 ( -
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 @@ -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 && }
{stagedList.length > 0 ? stagedList.map((c) => ( - )) : (
Nothing staged — use + to stage a file
@@ -148,7 +151,7 @@ export function GitPanel({ branch, changes, staged, committed, commitMsg, setCom {changesList.length > 0 && }
{changesList.length > 0 ? changesList.map((c) => ( - )) : (
All changes staged
@@ -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 @@ -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 committed: Set showHidden: boolean @@ -187,7 +191,7 @@ function TreeNode({ node, depth, openDirs, toggleDir, onOpen, onContext, activeP return ( {node.path !== '' && ( -
toggleDir(node.path)} onContextMenu={(e) => onContext(e, { path: node.path, kind: 'dir' })}> @@ -200,14 +204,14 @@ function TreeNode({ node, depth, openDirs, toggleDir, onOpen, onContext, activeP .map((c) => ( + onContext={onContext} activePath={activePath} ctxPath={ctxPath} changeMap={changeMap} committed={committed} showHidden={showHidden} /> ))} ) } const status = committed && committed.has(node.path) ? null : changeMap[node.path] return ( -
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 toggleDir: (path: string) => void onOpen: OpenFile onContext: OnContext activePath: string | null + ctxPath: string | null changeMap: Record committed: Set showHidden: boolean @@ -235,7 +240,7 @@ export function FileTree({ tree, openDirs, toggleDir, onOpen, onContext, activeP
+ onOpen={onOpen} onContext={onContext} activePath={activePath} ctxPath={ctxPath} changeMap={changeMap} committed={committed} showHidden={showHidden} />
) diff --git a/src/renderer/src/editor.tsx b/src/renderer/src/editor.tsx index 5cde8ea..0070929 100644 --- a/src/renderer/src/editor.tsx +++ b/src/renderer/src/editor.tsx @@ -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 ( +
{ e.preventDefault(); onContext(e, { path, kind: 'editor', line: 1 }) }}> +
+
+ ) +} + /* 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
) : (
- {change && ( -
- {statusWord} - {change.add > 0 && +{change.add}} - {change.del > 0 && −{change.del}} -
- {SEGMENTS.map((s) => ( - - ))} + {/* The view bar is always present; what it offers depends on the file + state (changed → diff views, markdown → Preview, else just Code). */} +
+ {change ? ( + + {statusWord} + {change.add > 0 && +{change.add}} + {change.del > 0 && −{change.del}} + + ) : ( + {HL.langLabel(tab.path)} + )} +
+ {segments.map((s) => ( + + ))} + {hasDiff && ( -
+ )}
- )} - {emptyUpdated ? ( +
+ {effMode === 'preview' ? ( + + ) : emptyUpdated ? (
No updated version
This file was deleted in the change.
) : emptyOriginal ? (
No original version
This file is new in the change.
diff --git a/src/renderer/src/env.d.ts b/src/renderer/src/env.d.ts index d11eaee..222b87d 100644 --- a/src/renderer/src/env.d.ts +++ b/src/renderer/src/env.d.ts @@ -20,11 +20,13 @@ interface HelderBridge { } fs: { tree: () => Promise + readDir: (path: string) => Promise files: () => Promise> read: (path: string) => Promise write: (path: string, content: string) => Promise delete: (path: string) => Promise create: (path: string) => Promise + mkdir: (path: string) => Promise } shell: { reveal: (path: string) => void diff --git a/src/renderer/src/markdown.ts b/src/renderer/src/markdown.ts new file mode 100644 index 0000000..9f8c82d --- /dev/null +++ b/src/renderer/src/markdown.ts @@ -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 = { + 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('' + HL.escapeHtml(c) + '') + return SENT + (codes.length - 1) + SENT + }) + s = HL.escapeHtml(s) + s = s.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_m, alt, url) => { + const u = safeUrl(url) + return u ? `${alt}` : alt + }) + s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_m, t, url) => { + const u = safeUrl(url) + return u ? `${t}` : t + }) + s = s.replace(/\*\*([^*]+)\*\*/g, '$1') + s = s.replace(/__([^_]+)__/g, '$1') + s = s.replace(/\*([^*]+)\*/g, '$1') + s = s.replace(/(^|[^a-zA-Z0-9_])_([^_]+)_(?=[^a-zA-Z0-9_]|$)/g, '$1$2') + s = s.replace(/~~([^~]+)~~/g, '$1') + // 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('

' + inline(para.join(' ')) + '

'); 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('
' + (lang ? HL.hlText(code, lang) : HL.escapeHtml(code)) + '
') + 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(`` + inline(h[2].trim()) + ``); i++; continue } + + if (/^\s*([-*_])(\s*\1){2,}\s*$/.test(line)) { flushPara(); out.push('
'); 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('
' + renderMarkdown(buf.join('\n')) + '
') + 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('
    ' + items.map((it) => '
  • ' + inline(it) + '
  • ').join('') + '
') + 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('
    ' + items.map((it) => '
  1. ' + inline(it) + '
  2. ').join('') + '
') + continue + } + + para.push(line.trim()) + i++ + } + flushPara() + return out.join('\n') +} diff --git a/src/renderer/src/overlays.tsx b/src/renderer/src/overlays.tsx index eac9020..c5e8980 100644 --- a/src/renderer/src/overlays.tsx +++ b/src/renderer/src/overlays.tsx @@ -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(null) const boxRef = useRef(null) @@ -513,16 +516,16 @@ export function NamePopup({ x, y, dir, onConfirm, onCancel }: { const target = (dir ? dir + '/' : '') + trimmed return (
-
{Icon.file()}New fileesc
+
{isFolder ? Icon.folder() : Icon.file()}{isFolder ? 'New folder' : 'New file'}esc
setName(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); if (trimmed) onConfirm(trimmed) } else if (e.key === 'Escape') { e.preventDefault(); onCancel() } }} /> -
creates{target || '…'}
-
create file · esc cancel
+
creates{target ? target + (isFolder ? '/' : '') : '…'}
+
create {isFolder ? 'folder' : 'file'} · esc cancel
) } diff --git a/src/renderer/src/project.tsx b/src/renderer/src/project.tsx index b2c206c..8003927 100644 --- a/src/renderer/src/project.tsx +++ b/src/renderer/src/project.tsx @@ -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 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 } 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 }, []) diff --git a/src/renderer/src/styles.css b/src/renderer/src/styles.css index 8481d27..ecec687 100644 --- a/src/renderer/src/styles.css +++ b/src/renderer/src/styles.css @@ -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); } diff --git a/test/editor-modes.test.tsx b/test/editor-modes.test.tsx index 583ec61..c680349 100644 --- a/test/editor-modes.test.tsx +++ b/test/editor-modes.test.tsx @@ -34,9 +34,9 @@ async function openChanged(): Promise { } 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')!) diff --git a/test/fs.test.ts b/test/fs.test.ts index 080ba00..f0c6fd8 100644 --- a/test/fs.test.ts +++ b/test/fs.test.ts @@ -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', () => { diff --git a/test/markdown.test.ts b/test/markdown.test.ts new file mode 100644 index 0000000..41fc4e7 --- /dev/null +++ b/test/markdown.test.ts @@ -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('

Title

') + expect(html).toContain('bold') + expect(html).toContain('italic') + expect(html).toContain('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') + 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 tag.') + expect(html).toContain('<script>') + expect(html).not.toContain('