@@ -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: [] }
|
||||
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)
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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)} />}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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 && (
|
||||
{/* 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) => (
|
||||
{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>
|
||||
</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>
|
||||
|
||||
2
src/renderer/src/env.d.ts
vendored
2
src/renderer/src/env.d.ts
vendored
@@ -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
|
||||
|
||||
118
src/renderer/src/markdown.ts
Normal file
118
src/renderer/src/markdown.ts
Normal 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')
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}, [])
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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')!)
|
||||
|
||||
@@ -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
44
test/markdown.test.ts
Normal 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('<script>')
|
||||
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>')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user