@@ -1,6 +1,6 @@
|
|||||||
import { mkdir, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises'
|
import { mkdir, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises'
|
||||||
import { dirname, join, relative, sep } from 'node:path'
|
import { dirname, join, relative, sep } from 'node:path'
|
||||||
import { listFiles } from './search-service'
|
import { listFiles, rgAvailable } from './search-service'
|
||||||
|
|
||||||
export interface FileNode {
|
export interface FileNode {
|
||||||
name: string
|
name: string
|
||||||
@@ -40,31 +40,43 @@ function sortTree(node: FileNode): void {
|
|||||||
for (const c of node.children) sortTree(c)
|
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 root: FileNode = { name: rootName, type: 'dir', path: '', open: true, children: [] }
|
||||||
const dirs = new Map<string, FileNode>([['', root]])
|
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)
|
const parts = rel.split('/').filter(Boolean)
|
||||||
let parentPath = ''
|
let parentPath = ''
|
||||||
let parent = root
|
let parent = root
|
||||||
for (let i = 0; i < parts.length; i++) {
|
for (let i = 0; i < parts.length; i++) {
|
||||||
const isFile = i === parts.length - 1
|
|
||||||
const curPath = parentPath ? `${parentPath}/${parts[i]}` : parts[i]
|
const curPath = parentPath ? `${parentPath}/${parts[i]}` : parts[i]
|
||||||
if (isFile) {
|
let dir = dirs.get(curPath)
|
||||||
parent.children!.push({ name: parts[i], type: 'file', path: curPath })
|
if (!dir) {
|
||||||
} else {
|
dir = { name: parts[i], type: 'dir', path: curPath, open: i === 0, children: [] }
|
||||||
let dir = dirs.get(curPath)
|
dirs.set(curPath, dir)
|
||||||
if (!dir) {
|
parent.children!.push(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
|
|
||||||
}
|
}
|
||||||
|
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)
|
sortTree(root)
|
||||||
return root
|
return root
|
||||||
}
|
}
|
||||||
@@ -73,14 +85,89 @@ function rootName(root: string): string {
|
|||||||
return root.split(sep).filter(Boolean).pop() || root
|
return root.split(sep).filter(Boolean).pop() || root
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Project tree. Primary: rg file list (honors gitignore + excludes). Fallback:
|
/** Project tree. Primary: rg file list (honors gitignore + excludes) plus the
|
||||||
* a plain recursive walk (when ripgrep is unavailable). */
|
* 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> {
|
export async function readTree(root: string): Promise<FileNode> {
|
||||||
const paths = await listFiles(root).catch(() => [] as string[])
|
const [paths, emptyDirs] = await Promise.all([
|
||||||
if (paths.length) return buildTreeFromPaths(rootName(root), paths)
|
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) }
|
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[]> {
|
async function readDir(abs: string, root: string, depth: number): Promise<FileNode[]> {
|
||||||
let entries: import('node:fs').Dirent[]
|
let entries: import('node:fs').Dirent[]
|
||||||
try {
|
try {
|
||||||
@@ -134,6 +221,20 @@ export async function createProjectFile(root: string, rel: string): Promise<void
|
|||||||
await writeFile(target, '', { encoding: 'utf8', flag: 'wx' })
|
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. */
|
/** Delete a project file or folder (relative path). Stays inside the project root. */
|
||||||
export async function deleteProjectFile(root: string, rel: string): Promise<void> {
|
export async function deleteProjectFile(root: string, rel: string): Promise<void> {
|
||||||
const target = join(root, rel)
|
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 { app, dialog, shell, BrowserWindow, ipcMain, Menu } from 'electron'
|
||||||
import { watch, type FSWatcher } from 'chokidar'
|
import { watch, type FSWatcher } from 'chokidar'
|
||||||
import { addRecentProject, getName, getRecentProjects, getRoot, openDialog, setRoot } from './project'
|
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 { commit, discard, load, stage, unstage } from './git-service'
|
||||||
import { createPty, killAllPtys, killPty, ptyAvailable, resizePty, writePty } from './pty-service'
|
import { createPty, killAllPtys, killPty, ptyAvailable, resizePty, writePty } from './pty-service'
|
||||||
import { getConfig, getRecent, getThemeCss, resolveConfig, setRecent } from './config'
|
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:tree', () => { const r = getRoot(); return r ? readTree(r) : null })
|
||||||
ipcMain.handle('fs:files', () => { const r = getRoot(); return r ? readAll(r) : {} })
|
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: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: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: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: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('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 })
|
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
|
/** All project files (relative paths), honoring gitignore + excludes. Includes
|
||||||
* dotfiles (--hidden) so .env etc. show up unless ignored. */
|
* dotfiles (--hidden) so .env etc. show up unless ignored. Pass `scope` (an
|
||||||
export async function listFiles(root: string): Promise<string[]> {
|
* 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
|
const rgPath = await rgPathPromise
|
||||||
if (!rgPath) return []
|
if (!rgPath) return []
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const child = spawn(rgPath, ['--files', '--hidden', ...ignoreArgs(), '--', root])
|
const child = spawn(rgPath, ['--files', '--hidden', ...ignoreArgs(), '--', scope || root])
|
||||||
let buf = ''
|
let buf = ''
|
||||||
const out: string[] = []
|
const out: string[] = []
|
||||||
let done = false
|
let done = false
|
||||||
|
|||||||
@@ -22,11 +22,13 @@ const api = {
|
|||||||
|
|
||||||
fs: {
|
fs: {
|
||||||
tree: () => ipcRenderer.invoke('fs:tree'),
|
tree: () => ipcRenderer.invoke('fs:tree'),
|
||||||
|
readDir: (path: string) => ipcRenderer.invoke('fs:readDir', path),
|
||||||
files: () => ipcRenderer.invoke('fs:files'),
|
files: () => ipcRenderer.invoke('fs:files'),
|
||||||
read: (path: string) => ipcRenderer.invoke('fs:read', path),
|
read: (path: string) => ipcRenderer.invoke('fs:read', path),
|
||||||
write: (path: string, content: string): Promise<void> => ipcRenderer.invoke('fs:write', path, content),
|
write: (path: string, content: string): Promise<void> => ipcRenderer.invoke('fs:write', path, content),
|
||||||
delete: (path: string): Promise<void> => ipcRenderer.invoke('fs:delete', path),
|
delete: (path: string): Promise<void> => ipcRenderer.invoke('fs:delete', path),
|
||||||
create: (path: string): Promise<void> => ipcRenderer.invoke('fs:create', path),
|
create: (path: string): Promise<void> => ipcRenderer.invoke('fs:create', path),
|
||||||
|
mkdir: (path: string): Promise<void> => ipcRenderer.invoke('fs:mkdir', path),
|
||||||
},
|
},
|
||||||
|
|
||||||
shell: {
|
shell: {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { ProjectLauncher } from './launcher'
|
|||||||
import type { Menu, Toast } from './overlays'
|
import type { Menu, Toast } from './overlays'
|
||||||
import type { FileNode, GitStatus } from './types'
|
import type { FileNode, GitStatus } from './types'
|
||||||
import { useProject, useProjectActions } from './project'
|
import { useProject, useProjectActions } from './project'
|
||||||
|
import { HL } from './highlight'
|
||||||
import { loadJson, loadNum, saveJson, saveNum } from './persist'
|
import { loadJson, loadNum, saveJson, saveNum } from './persist'
|
||||||
|
|
||||||
const NO_COMMITTED = new Set<string>()
|
const NO_COMMITTED = new Set<string>()
|
||||||
@@ -89,6 +90,7 @@ export function App(): React.ReactElement {
|
|||||||
const [commitMsg, setCommitMsg] = useState('')
|
const [commitMsg, setCommitMsg] = useState('')
|
||||||
const [passPopup, setPassPopup] = useState<{ x: number; y: number; ref: string } | null>(null)
|
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 [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)
|
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
|
// Brief full-screen "branch - repository" flash whenever the window gains focus
|
||||||
// (handy when juggling several project windows).
|
// (handy when juggling several project windows).
|
||||||
@@ -108,9 +110,20 @@ export function App(): React.ReactElement {
|
|||||||
if (!window.helder) return
|
if (!window.helder) return
|
||||||
// A save flips the file's working-tree state (clean → modified, etc.), so
|
// 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
|
// 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)
|
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))
|
.catch(() => toast('Save failed', path))
|
||||||
}
|
}
|
||||||
function saveActive(): void {
|
function saveActive(): void {
|
||||||
@@ -129,6 +142,16 @@ export function App(): React.ReactElement {
|
|||||||
saveTimer.current = setTimeout(() => writeToDisk(path, text), 600)
|
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 {
|
function doDiscard(path: string): void {
|
||||||
if (proj.config.git.confirmDiscard &&
|
if (proj.config.git.confirmDiscard &&
|
||||||
!window.confirm(`Discard changes to ${path}?\nThis reverts the file to the last commit and cannot be undone.`)) return
|
!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) => {
|
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 })
|
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 {
|
function reveal(path: string): void {
|
||||||
setOpenDirs((s) => { const n = new Set(s); ancestors(path).forEach((a) => n.add(a)); return n })
|
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)
|
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 {
|
function askDelete(path: string, isDir: boolean): void {
|
||||||
setConfirm({
|
setConfirm({
|
||||||
title: isDir ? 'Delete folder?' : 'Delete file?',
|
title: isDir ? 'Delete folder?' : 'Delete file?',
|
||||||
@@ -321,7 +359,7 @@ export function App(): React.ReactElement {
|
|||||||
const changed = !!proj.diffs[path]
|
const changed = !!proj.diffs[path]
|
||||||
setFocusZone('editor')
|
setFocusZone('editor')
|
||||||
setHistory((h) => [path, ...h.filter((p) => p !== path)].slice(0, 100))
|
setHistory((h) => [path, ...h.filter((p) => p !== path)].slice(0, 100))
|
||||||
actions.ensureFile(path)
|
reloadFromDisk(path)
|
||||||
setActive(path)
|
setActive(path)
|
||||||
// Git rows open the diff; explorer / recent-files open the updated view.
|
// Git rows open the diff; explorer / recent-files open the updated view.
|
||||||
// Unchanged files only have the plain editable "code" view.
|
// Unchanged files only have the plain editable "code" view.
|
||||||
@@ -376,7 +414,7 @@ export function App(): React.ReactElement {
|
|||||||
// ---- context menus ----
|
// ---- context menus ----
|
||||||
function openMenu(e: React.MouseEvent, target: ContextTarget): void {
|
function openMenu(e: React.MouseEvent, target: ContextTarget): void {
|
||||||
e.preventDefault(); e.stopPropagation()
|
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') {
|
if (target.kind === 'editor') {
|
||||||
const ref = target.sel ? `${target.path}:${target.sel.start}-${target.sel.end}` : `${target.path}:${target.line}`
|
const ref = target.sel ? `${target.path}:${target.sel.start}-${target.sel.end}` : `${target.path}:${target.line}`
|
||||||
const mx = e.clientX, my = e.clientY
|
const mx = e.clientX, my = e.clientY
|
||||||
@@ -384,7 +422,7 @@ export function App(): React.ReactElement {
|
|||||||
x: mx, y: my, note: ref,
|
x: mx, y: my, note: ref,
|
||||||
items: [
|
items: [
|
||||||
{ primary: true, icon: Icon.copy(), label: 'Copy reference', onClick: () => copyText(ref) },
|
{ 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 {
|
} else {
|
||||||
@@ -394,41 +432,56 @@ export function App(): React.ReactElement {
|
|||||||
const items: Menu['items'] = [
|
const items: Menu['items'] = [
|
||||||
{ primary: true, icon: Icon.copy(), label: 'Copy reference', onClick: () => copyText(ref) },
|
{ primary: true, icon: Icon.copy(), label: 'Copy reference', onClick: () => copyText(ref) },
|
||||||
sparkSend(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) {
|
if (isDir) {
|
||||||
const mx = e.clientX, my = e.clientY
|
const mx = e.clientX, my = e.clientY
|
||||||
items.push({ sep: true })
|
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) {
|
if (!isDir) {
|
||||||
items.push({ sep: true })
|
items.push({ sep: true })
|
||||||
if (target.kind === 'git') {
|
if (target.kind === 'git') {
|
||||||
const isStaged = proj.staged.has(target.path)
|
const isStaged = proj.staged.has(target.path)
|
||||||
items.push(isStaged
|
items.push(isStaged
|
||||||
? { icon: Icon.minus(), label: 'Unstage changes', onClick: () => unstageGuarded(target.path) }
|
? { icon: Icon.minus({ style: { color: 'var(--mod)' } }), label: 'Unstage changes', onClick: () => unstageGuarded(target.path) }
|
||||||
: { icon: Icon.plus(), label: 'Stage changes', onClick: () => stageGuarded(target.path) })
|
: { icon: Icon.plus({ style: { color: 'var(--add)' } }), 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.diff({ style: { color: 'var(--ren)' } }), label: 'Open diff', onClick: () => openFile(target.path, { diff: true }) })
|
||||||
items.push({ icon: Icon.discard(), label: 'Discard changes', onClick: () => doDiscard(target.path) })
|
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).
|
// Show in Finder + delete — for explorer files and folders (not git rows).
|
||||||
if (isDir || target.kind === 'file') {
|
if (isDir || target.kind === 'file') {
|
||||||
items.push({ sep: true })
|
items.push({ sep: true })
|
||||||
items.push({ icon: Icon.finder(), label: 'Show in Finder', onClick: () => revealInFinder(target.path) })
|
items.push({ icon: Icon.finder({ style: { color: 'var(--mod)' } }), 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.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 {
|
function cycleMode(): void {
|
||||||
if (!active || !proj.diffs[active]) return
|
if (!active) return
|
||||||
const order: (Mode | 'split')[] = ['updated', 'original', 'diff', 'split']
|
reloadFromDisk(active)
|
||||||
const cur = splitFor === active ? 'split' : (tabMode[active] || defaultMode)
|
const md = HL.langFor(active) === 'markdown'
|
||||||
const next = order[(order.indexOf(cur) + 1) % order.length]
|
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)
|
if (next === 'split') setSplitFor(active)
|
||||||
else { setTabMode((m) => ({ ...m, [active]: next as Mode })); setSplitFor(null) }
|
else { setTabMode((m) => ({ ...m, [active]: next as Mode })); setSplitFor(null) }
|
||||||
}
|
}
|
||||||
@@ -529,6 +582,10 @@ export function App(): React.ReactElement {
|
|||||||
if (inField) return
|
if (inField) return
|
||||||
e.preventDefault(); setAutoResize((v) => !v)
|
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)
|
window.addEventListener('keydown', onKey)
|
||||||
return () => window.removeEventListener('keydown', onKey)
|
return () => window.removeEventListener('keydown', onKey)
|
||||||
@@ -560,14 +617,17 @@ export function App(): React.ReactElement {
|
|||||||
)}
|
)}
|
||||||
<div className="tb-spacer" />
|
<div className="tb-spacer" />
|
||||||
<div className="tb-actions">
|
<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)}
|
<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.'}>
|
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>
|
||||||
<button className={'tb-btn tb-toggle' + (showHidden ? ' on' : '')} onClick={() => setShowHidden((v) => !v)}
|
<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.'}>
|
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>
|
||||||
<button className="tb-btn tb-icon" onClick={() => setOverlay('help')} title="Keyboard shortcuts">{Icon.help()}</button>
|
<button className="tb-btn tb-icon" onClick={() => setOverlay('help')} title="Keyboard shortcuts">{Icon.help()}</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -591,14 +651,14 @@ export function App(): React.ReactElement {
|
|||||||
<GitPanel branch={proj.branch} changes={proj.changes} staged={proj.staged} committed={NO_COMMITTED}
|
<GitPanel branch={proj.branch} changes={proj.changes} staged={proj.staged} committed={NO_COMMITTED}
|
||||||
commitMsg={commitMsg} setCommitMsg={setCommitMsg}
|
commitMsg={commitMsg} setCommitMsg={setCommitMsg}
|
||||||
onStage={stageGuarded} onUnstage={unstageGuarded} onStageAll={actions.stageAll} onUnstageAll={actions.unstageAll} onCommit={commit}
|
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>
|
</div>
|
||||||
<Splitter onDelta={(dx) => { setAutoResize(false); setGitW((w) => clamp(w + dx, 160, 460)) }} />
|
<Splitter onDelta={(dx) => { setAutoResize(false); setGitW((w) => clamp(w + dx, 160, 460)) }} />
|
||||||
|
|
||||||
<div className="col" style={{ width: treeW, flex: '0 0 ' + treeW + 'px' }}>
|
<div className="col" style={{ width: treeW, flex: '0 0 ' + treeW + 'px' }}>
|
||||||
{proj.tree ? (
|
{proj.tree ? (
|
||||||
<FileTree tree={proj.tree} openDirs={openDirs} toggleDir={toggleDir} onOpen={openFile}
|
<FileTree tree={proj.tree} openDirs={openDirs} toggleDir={toggleDir} onOpen={openFile}
|
||||||
onContext={openMenu} activePath={active} 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" />
|
<div className="tree-body" />
|
||||||
)}
|
)}
|
||||||
@@ -607,7 +667,7 @@ export function App(): React.ReactElement {
|
|||||||
|
|
||||||
<div className="col editor-col" onMouseDownCapture={() => setFocusZone('editor')}>
|
<div className="col editor-col" onMouseDownCapture={() => setFocusZone('editor')}>
|
||||||
<Editor active={active} mode={mode}
|
<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}
|
onContext={openMenu}
|
||||||
onSplit={(p) => setSplitFor(p)} splitOpen={splitFor === active}
|
onSplit={(p) => setSplitFor(p)} splitOpen={splitFor === active}
|
||||||
cursor={cursor} selection={selection} setCursor={setCursor} setSelection={setSelection}
|
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}
|
{newFilePopup && <NamePopup x={newFilePopup.x} y={newFilePopup.y} dir={newFilePopup.dir}
|
||||||
onConfirm={(name) => { createFile(newFilePopup.dir, name); setNewFilePopup(null) }}
|
onConfirm={(name) => { createFile(newFilePopup.dir, name); setNewFilePopup(null) }}
|
||||||
onCancel={() => 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 === '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 === 'history' && <HistoryModal history={history} initialSel={histInitSel} onOpen={openFile} onClose={() => setOverlay(null)} changeSet={changeSet} />}
|
||||||
{overlay === 'help' && <HelpModal onClose={() => setOverlay(null)} />}
|
{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>),
|
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>),
|
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>),
|
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>),
|
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>),
|
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>),
|
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
|
export type OnContext = (e: React.MouseEvent, target: ContextTarget) => void
|
||||||
|
|
||||||
/* ============ Git / Source Control panel ============ */
|
/* ============ 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
|
c: Change
|
||||||
staged: boolean
|
staged: boolean
|
||||||
activePath: string | null
|
activePath: string | null
|
||||||
|
ctxPath: string | null
|
||||||
showDir: boolean
|
showDir: boolean
|
||||||
onOpen: OpenFile
|
onOpen: OpenFile
|
||||||
onContext: OnContext
|
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 dir = c.path.split('/').slice(0, -1).join('/')
|
||||||
const dirShown = showDir && !!dir
|
const dirShown = showDir && !!dir
|
||||||
return (
|
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 })}
|
onClick={() => onOpen(c.path, { diff: true })}
|
||||||
onContextMenu={(e) => onContext(e, { path: c.path, kind: 'git', staged })}
|
onContextMenu={(e) => onContext(e, { path: c.path, kind: 'git', staged })}
|
||||||
title={c.path}>
|
title={c.path}>
|
||||||
@@ -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
|
branch: string
|
||||||
changes: Change[]
|
changes: Change[]
|
||||||
staged: Set<string>
|
staged: Set<string>
|
||||||
@@ -108,6 +110,7 @@ export function GitPanel({ branch, changes, staged, committed, commitMsg, setCom
|
|||||||
onOpen: OpenFile
|
onOpen: OpenFile
|
||||||
onContext: OnContext
|
onContext: OnContext
|
||||||
activePath: string | null
|
activePath: string | null
|
||||||
|
ctxPath: string | null
|
||||||
showDir: boolean
|
showDir: boolean
|
||||||
}): React.ReactElement {
|
}): React.ReactElement {
|
||||||
const visible = changes.filter((c) => !committed.has(c.path))
|
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>}
|
{stagedList.length > 0 && <button className="grp-act" title="Unstage all" onClick={onUnstageAll}>{Icon.minus()}</button>}
|
||||||
</div>
|
</div>
|
||||||
{stagedList.length > 0 ? stagedList.map((c) => (
|
{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} />
|
onOpen={onOpen} onContext={onContext} onToggleStage={onUnstage} />
|
||||||
)) : (
|
)) : (
|
||||||
<div className="git-none">Nothing staged — use <span className="key">+</span> to stage a file</div>
|
<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>}
|
{changesList.length > 0 && <button className="grp-act" title="Stage all" onClick={onStageAll}>{Icon.plus()}</button>}
|
||||||
</div>
|
</div>
|
||||||
{changesList.length > 0 ? changesList.map((c) => (
|
{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} />
|
onOpen={onOpen} onContext={onContext} onToggleStage={onStage} />
|
||||||
)) : (
|
)) : (
|
||||||
<div className="git-none">All changes staged</div>
|
<div className="git-none">All changes staged</div>
|
||||||
@@ -169,7 +172,7 @@ export function GitPanel({ branch, changes, staged, committed, commitMsg, setCom
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ============ File Tree ============ */
|
/* ============ 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
|
node: FileNode
|
||||||
depth: number
|
depth: number
|
||||||
openDirs: Set<string>
|
openDirs: Set<string>
|
||||||
@@ -177,6 +180,7 @@ function TreeNode({ node, depth, openDirs, toggleDir, onOpen, onContext, activeP
|
|||||||
onOpen: OpenFile
|
onOpen: OpenFile
|
||||||
onContext: OnContext
|
onContext: OnContext
|
||||||
activePath: string | null
|
activePath: string | null
|
||||||
|
ctxPath: string | null
|
||||||
changeMap: Record<string, GitStatus>
|
changeMap: Record<string, GitStatus>
|
||||||
committed: Set<string>
|
committed: Set<string>
|
||||||
showHidden: boolean
|
showHidden: boolean
|
||||||
@@ -187,7 +191,7 @@ function TreeNode({ node, depth, openDirs, toggleDir, onOpen, onContext, activeP
|
|||||||
return (
|
return (
|
||||||
<Fragment>
|
<Fragment>
|
||||||
{node.path !== '' && (
|
{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)}
|
onClick={() => toggleDir(node.path)}
|
||||||
onContextMenu={(e) => onContext(e, { path: node.path, kind: 'dir' })}>
|
onContextMenu={(e) => onContext(e, { path: node.path, kind: 'dir' })}>
|
||||||
<span className="tw"><Chevron open={isOpen} /></span>
|
<span className="tw"><Chevron open={isOpen} /></span>
|
||||||
@@ -200,14 +204,14 @@ function TreeNode({ node, depth, openDirs, toggleDir, onOpen, onContext, activeP
|
|||||||
.map((c) => (
|
.map((c) => (
|
||||||
<TreeNode key={c.path} node={c} depth={node.path === '' ? 0 : depth + 1}
|
<TreeNode key={c.path} node={c} depth={node.path === '' ? 0 : depth + 1}
|
||||||
openDirs={openDirs} toggleDir={toggleDir} onOpen={onOpen}
|
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>
|
</Fragment>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
const status = committed && committed.has(node.path) ? null : changeMap[node.path]
|
const status = committed && committed.has(node.path) ? null : changeMap[node.path]
|
||||||
return (
|
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 }}
|
style={{ paddingLeft: pad + 2 }}
|
||||||
onClick={() => onOpen(node.path)}
|
onClick={() => onOpen(node.path)}
|
||||||
onContextMenu={(e) => onContext(e, { path: node.path, kind: 'file' })}
|
onContextMenu={(e) => onContext(e, { path: node.path, kind: 'file' })}
|
||||||
@@ -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
|
tree: FileNode
|
||||||
openDirs: Set<string>
|
openDirs: Set<string>
|
||||||
toggleDir: (path: string) => void
|
toggleDir: (path: string) => void
|
||||||
onOpen: OpenFile
|
onOpen: OpenFile
|
||||||
onContext: OnContext
|
onContext: OnContext
|
||||||
activePath: string | null
|
activePath: string | null
|
||||||
|
ctxPath: string | null
|
||||||
changeMap: Record<string, GitStatus>
|
changeMap: Record<string, GitStatus>
|
||||||
committed: Set<string>
|
committed: Set<string>
|
||||||
showHidden: boolean
|
showHidden: boolean
|
||||||
@@ -235,7 +240,7 @@ export function FileTree({ tree, openDirs, toggleDir, onOpen, onContext, activeP
|
|||||||
<Fragment>
|
<Fragment>
|
||||||
<div className="tree-body">
|
<div className="tree-body">
|
||||||
<TreeNode node={tree} depth={0} openDirs={openDirs} toggleDir={toggleDir}
|
<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>
|
</div>
|
||||||
</Fragment>
|
</Fragment>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,12 +3,13 @@ import React, { Fragment, useMemo, useRef } from 'react'
|
|||||||
import type { Diff, ViewLine } from './types'
|
import type { Diff, ViewLine } from './types'
|
||||||
import { useProject } from './project'
|
import { useProject } from './project'
|
||||||
import { HL } from './highlight'
|
import { HL } from './highlight'
|
||||||
|
import { renderMarkdown } from './markdown'
|
||||||
import { FileIcon, Icon } from './components'
|
import { FileIcon, Icon } from './components'
|
||||||
import type { OnContext } from './components'
|
import type { OnContext } from './components'
|
||||||
|
|
||||||
export interface Cursor { path: string; line: number; col: number }
|
export interface Cursor { path: string; line: number; col: number }
|
||||||
export interface Selection { path: string; start: number; end: number; anchor: 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 {
|
function climbToLine(node: Node | null): HTMLElement | null {
|
||||||
let el: HTMLElement | null = node && node.nodeType === 3 ? (node.parentElement as HTMLElement) : (node as 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. */
|
/* Generic pane: renders an array of line descriptors with selection + caret + context. */
|
||||||
function PaneView({ cacheKey, path, lines, lang, showSign, cursor, selection, setCursor, setSelection, onContext }: {
|
function PaneView({ cacheKey, path, lines, lang, showSign, cursor, selection, setCursor, setSelection, onContext }: {
|
||||||
cacheKey: string
|
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 }
|
return { lines: arr.map((t, i) => ({ no: i + 1, text: t })), showSign: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
const SEGMENTS: { id: Mode; label: string }[] = [
|
/** View options available for a file, given whether it has a diff and whether
|
||||||
{ id: 'updated', label: 'Updated' },
|
* it's markdown. Unchanged files only have the plain editable "Code" view; a
|
||||||
{ id: 'original', label: 'Original' },
|
* changed file gets the Updated/Original/Diff trio; markdown adds "Preview". */
|
||||||
{ id: 'diff', label: 'Diff' },
|
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 }: {
|
export function Editor({ active, mode, setMode, onContext, onSplit, splitOpen, cursor, selection, setCursor, setSelection, bufferText, onEdit }: {
|
||||||
active: string | null
|
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 change = tab ? PROJECT.changes.find((c) => c.path === tab.path) : null
|
||||||
const diff = tab ? PROJECT.diffs[tab.path] : null
|
const diff = tab ? PROJECT.diffs[tab.path] : null
|
||||||
const lang = tab ? HL.langFor(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
|
let built: { lines: ViewLine[]; showSign: boolean } | null = null
|
||||||
if (tab) {
|
if (tab && effMode !== 'preview') {
|
||||||
if (change && diff) built = buildLines(effMode, diff, PROJECT.files[tab.path])
|
if (hasDiff) built = buildLines(effMode, diff, PROJECT.files[tab.path])
|
||||||
else built = buildLines('code', null, 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 activeSeg = splitOpen ? 'split' : effMode
|
||||||
const emptyUpdated = effMode === 'updated' && built && built.lines.length === 0
|
const emptyUpdated = effMode === 'updated' && built && built.lines.length === 0
|
||||||
const emptyOriginal = effMode === 'original' && 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'
|
const editable = effMode === 'code' || effMode === 'updated'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -245,23 +271,33 @@ export function Editor({ active, mode, setMode, onContext, onSplit, splitOpen, c
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="editor-wrap">
|
<div className="editor-wrap">
|
||||||
{change && (
|
{/* The view bar is always present; what it offers depends on the file
|
||||||
<div className="diff-bar">
|
state (changed → diff views, markdown → Preview, else just Code). */}
|
||||||
<span className={'git-stat ' + change.status} style={{ width: 'auto' }}>{statusWord}</span>
|
<div className="diff-bar">
|
||||||
{change.add > 0 && <span className="a">+{change.add}</span>}
|
{change ? (
|
||||||
{change.del > 0 && <span className="d">−{change.del}</span>}
|
<Fragment>
|
||||||
<div className="seg">
|
<span className={'git-stat ' + change.status} style={{ width: 'auto' }}>{statusWord}</span>
|
||||||
{SEGMENTS.map((s) => (
|
{change.add > 0 && <span className="a">+{change.add}</span>}
|
||||||
<button key={s.id} className={activeSeg === s.id ? 'on' : ''} onClick={() => setMode(s.id)}>{s.label}</button>
|
{change.del > 0 && <span className="d">−{change.del}</span>}
|
||||||
))}
|
</Fragment>
|
||||||
|
) : (
|
||||||
|
<span className="db-lang">{HL.langLabel(tab.path)}</span>
|
||||||
|
)}
|
||||||
|
<div className="seg">
|
||||||
|
{segments.map((s) => (
|
||||||
|
<button key={s.id} className={activeSeg === s.id ? 'on' : ''} onClick={() => setMode(s.id)}>{s.label}</button>
|
||||||
|
))}
|
||||||
|
{hasDiff && (
|
||||||
<button className={'split-btn' + (activeSeg === 'split' ? ' on' : '')} onClick={() => onSplit(tab.path)} title="Split — full screen side-by-side">
|
<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>
|
<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
|
Split
|
||||||
</button>
|
</button>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
{emptyUpdated ? (
|
{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>
|
<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 ? (
|
) : 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>
|
<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: {
|
fs: {
|
||||||
tree: () => Promise<FileNode | null>
|
tree: () => Promise<FileNode | null>
|
||||||
|
readDir: (path: string) => Promise<FileNode[]>
|
||||||
files: () => Promise<Record<string, string>>
|
files: () => Promise<Record<string, string>>
|
||||||
read: (path: string) => Promise<string>
|
read: (path: string) => Promise<string>
|
||||||
write: (path: string, content: string) => Promise<void>
|
write: (path: string, content: string) => Promise<void>
|
||||||
delete: (path: string) => Promise<void>
|
delete: (path: string) => Promise<void>
|
||||||
create: (path: string) => Promise<void>
|
create: (path: string) => Promise<void>
|
||||||
|
mkdir: (path: string) => Promise<void>
|
||||||
}
|
}
|
||||||
shell: {
|
shell: {
|
||||||
reveal: (path: string) => void
|
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
|
kbd?: string
|
||||||
onClick?: () => void
|
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 }
|
export interface Toast { id: number; title: string; ref?: string }
|
||||||
interface ContentHit { no: number; ln: string; ix: number }
|
interface ContentHit { no: number; ln: string; ix: number }
|
||||||
interface ContentGroup { path: string; hits: ContentHit[] }
|
interface ContentGroup { path: string; hits: ContentHit[] }
|
||||||
@@ -348,6 +348,7 @@ const SHORTCUTS: { keys: string[]; label: string }[] = [
|
|||||||
{ keys: ['⌘', 'C'], label: 'Focus the commit message' },
|
{ keys: ['⌘', 'C'], label: 'Focus the commit message' },
|
||||||
{ keys: ['⌘', '↵'], label: 'Commit the staged files' },
|
{ keys: ['⌘', '↵'], label: 'Commit the staged files' },
|
||||||
{ keys: ['⌘', 'A'], label: 'Toggle auto-fit panels' },
|
{ keys: ['⌘', 'A'], label: 'Toggle auto-fit panels' },
|
||||||
|
{ keys: ['⌘', '.'], label: 'Toggle hidden (dot)files' },
|
||||||
{ keys: ['⌘', 'S'], label: 'Save the current file' },
|
{ keys: ['⌘', 'S'], label: 'Save the current file' },
|
||||||
{ keys: ['⌘', 'W'], label: 'Close the current file' },
|
{ keys: ['⌘', 'W'], label: 'Close the current file' },
|
||||||
{ keys: ['⌘', 'D'], label: 'Delete the current file (confirm)' },
|
{ 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
|
x: number
|
||||||
y: number
|
y: number
|
||||||
dir: string
|
dir: string
|
||||||
|
kind?: 'file' | 'folder'
|
||||||
onConfirm: (name: string) => void
|
onConfirm: (name: string) => void
|
||||||
onCancel: () => void
|
onCancel: () => void
|
||||||
}): React.ReactElement {
|
}): React.ReactElement {
|
||||||
|
const isFolder = kind === 'folder'
|
||||||
const [name, setName] = useState('')
|
const [name, setName] = useState('')
|
||||||
const inputRef = useRef<HTMLInputElement>(null)
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
const boxRef = useRef<HTMLDivElement>(null)
|
const boxRef = useRef<HTMLDivElement>(null)
|
||||||
@@ -513,16 +516,16 @@ export function NamePopup({ x, y, dir, onConfirm, onCancel }: {
|
|||||||
const target = (dir ? dir + '/' : '') + trimmed
|
const target = (dir ? dir + '/' : '') + trimmed
|
||||||
return (
|
return (
|
||||||
<div className="pass-pop" ref={boxRef} style={{ left, top }}>
|
<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}
|
<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)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter') { e.preventDefault(); if (trimmed) onConfirm(trimmed) }
|
if (e.key === 'Enter') { e.preventDefault(); if (trimmed) onConfirm(trimmed) }
|
||||||
else if (e.key === 'Escape') { e.preventDefault(); onCancel() }
|
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-preview"><span className="pp-lbl">creates</span><code>{target ? target + (isFolder ? '/' : '') : '…'}</code></div>
|
||||||
<div className="pass-foot"><kbd>↵</kbd> create file · <kbd>esc</kbd> cancel</div>
|
<div className="pass-foot"><kbd>↵</kbd> create {isFolder ? 'folder' : 'file'} · <kbd>esc</kbd> cancel</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,14 @@ export interface ProjectData {
|
|||||||
recents: RecentProject[]
|
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. */
|
/** Inject the project's theme.css over the built-in dark theme. */
|
||||||
function applyTheme(css: string): void {
|
function applyTheme(css: string): void {
|
||||||
let el = document.getElementById('helder-theme') as HTMLStyleElement | null
|
let el = document.getElementById('helder-theme') as HTMLStyleElement | null
|
||||||
@@ -42,6 +50,10 @@ export interface ProjectActions {
|
|||||||
openFolder: () => void
|
openFolder: () => void
|
||||||
openProjectPath: (path: string) => void
|
openProjectPath: (path: string) => void
|
||||||
refresh: () => 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
|
refreshGit: () => void
|
||||||
stage: (path: string) => void
|
stage: (path: string) => void
|
||||||
unstage: (path: string) => void
|
unstage: (path: string) => void
|
||||||
@@ -50,6 +62,11 @@ export interface ProjectActions {
|
|||||||
commit: (message: string) => Promise<number>
|
commit: (message: string) => Promise<number>
|
||||||
discard: (path: string) => void
|
discard: (path: string) => void
|
||||||
ensureFile: (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']
|
const MOCK_STAGED = ['src/Service/PaymentService.php', 'config/app.json']
|
||||||
@@ -187,6 +204,7 @@ export function ProjectProvider({ children }: { children: React.ReactNode }): Re
|
|||||||
openFolder: () => {},
|
openFolder: () => {},
|
||||||
openProjectPath: () => {},
|
openProjectPath: () => {},
|
||||||
refresh: () => setData(mockData()),
|
refresh: () => setData(mockData()),
|
||||||
|
refreshDir: () => {},
|
||||||
refreshGit: () => {},
|
refreshGit: () => {},
|
||||||
stage: (p) => setStaged((s) => (s.add(p), s)),
|
stage: (p) => setStaged((s) => (s.add(p), s)),
|
||||||
unstage: (p) => setStaged((s) => (s.delete(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 })(),
|
staged: (() => { const s = new Set(d.staged); s.delete(p); return s })(),
|
||||||
})),
|
})),
|
||||||
ensureFile: () => {},
|
ensureFile: () => {},
|
||||||
|
reloadFile: async (p) => dataRef.current.files[p] ?? '',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// ---- real git-backed actions ----
|
// ---- real git-backed actions ----
|
||||||
@@ -212,6 +231,11 @@ export function ProjectProvider({ children }: { children: React.ReactNode }): Re
|
|||||||
openFolder: () => { bridge.project.open().then(() => loadReal()).catch(() => {}) },
|
openFolder: () => { bridge.project.open().then(() => loadReal()).catch(() => {}) },
|
||||||
openProjectPath: (path) => { bridge.project.openPath(path).then(() => loadReal()).catch(() => {}) },
|
openProjectPath: (path) => { bridge.project.openPath(path).then(() => loadReal()).catch(() => {}) },
|
||||||
refresh: () => { 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(() => {}) },
|
refreshGit: () => { loadGit().catch(() => {}) },
|
||||||
stage: (p) => after(bridge.git.stage([p])),
|
stage: (p) => after(bridge.git.stage([p])),
|
||||||
unstage: (p) => after(bridge.git.unstage([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 } }))
|
setData((d) => (d.files[path] != null ? d : { ...d, files: { ...d.files, [path]: txt } }))
|
||||||
}).catch(() => {})
|
}).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
|
// 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;
|
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: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 .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 { color:var(--fg-1); border-color:var(--border-2); }
|
||||||
.tb-toggle.on .tb-state { background:var(--accent-soft); color:var(--accent); }
|
.tb-toggle.on .tb-state { background:var(--accent-soft); color:var(--accent); }
|
||||||
@@ -160,7 +161,8 @@ body {
|
|||||||
.git-row {
|
.git-row {
|
||||||
display:flex; align-items:center; gap:8px; padding:3px 12px 3px 14px; cursor:pointer; position:relative;
|
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 { background:var(--sel); }
|
||||||
.git-row.active::before { content:""; position:absolute; left:0; top:0; bottom:0; width:2px; background:var(--accent); }
|
.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; }
|
.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 ============ */
|
/* ============ file tree ============ */
|
||||||
.tree-body { overflow:auto; flex:1; padding:4px 0 14px; }
|
.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 { 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 { background:var(--sel); }
|
||||||
.tree-row.active::before { content:""; position:absolute; left:0; top:0; bottom:0; width:2px; background:var(--accent); }
|
.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; }
|
.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; }
|
.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 { 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 .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 { 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; }
|
.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:hover { color:var(--fg-0); background:var(--hover); }
|
||||||
.diff-bar .seg button.on { background:var(--accent-soft); color:var(--fg-0); }
|
.diff-bar .seg button.on { background:var(--accent-soft); color:var(--fg-0); }
|
||||||
.diff-bar .seg .split-btn svg { opacity:.85; }
|
.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) */
|
/* gutter change bars (Original / Updated / Split) */
|
||||||
.ln-row.bar-del { box-shadow:inset 2px 0 0 var(--del); }
|
.ln-row.bar-del { box-shadow:inset 2px 0 0 var(--del); }
|
||||||
@@ -245,7 +267,7 @@ body {
|
|||||||
.split-head .git-stat { font-size:11px; }
|
.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 { 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: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-body { flex:1; display:flex; min-height:0; }
|
||||||
.split-pane { flex:1; min-width:0; display:flex; flex-direction:column; }
|
.split-pane { flex:1; min-width:0; display:flex; flex-direction:column; }
|
||||||
.split-pane.left { border-right:1px solid var(--border-2); }
|
.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 .pi svg { flex:0 0 auto; }
|
||||||
.history-modal .hist-title { flex:1; min-width:0; color:var(--fg-1); font-size:14px; }
|
.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 { 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-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 { 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); }
|
.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 .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-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 { 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 */
|
/* terminal multi-line input */
|
||||||
.term-input { align-items:flex-start; }
|
.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-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-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-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 { 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 ============ */
|
/* ============ 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; }
|
.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 { display:flex; align-items:center; gap:14px; padding:6px 12px; border-radius:7px; }
|
||||||
.help-row:hover { background:var(--hover); }
|
.help-row:hover { background:var(--hover); }
|
||||||
.help-keys { flex:0 0 96px; display:flex; gap:4px; justify-content:flex-end; }
|
.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); }
|
.help-label { font-size:12.5px; color:var(--fg-2); }
|
||||||
|
|
||||||
/* title-bar icon-only button (help ?) */
|
/* 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-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 { 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: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 { background:var(--accent); color:#201608; border-color:transparent; font-weight:600; }
|
||||||
.cf-yes:hover { background:#f6b35f; color:#201608; }
|
.cf-yes:hover { background:#f6b35f; color:#201608; }
|
||||||
.cf-yes kbd { color:#201608; border-color:rgba(0,0,0,.25); }
|
.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); }
|
.cf-yes.danger kbd { color:#fff; border-color:rgba(255,255,255,.4); }
|
||||||
|
|
||||||
/* search: active result column + file-name selection */
|
/* 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, .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-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); }
|
.sc-infile.active .sc-head .scf-name { color:var(--accent); }
|
||||||
|
|||||||
@@ -34,9 +34,9 @@ async function openChanged(): Promise<HTMLElement> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('Editor view modes', () => {
|
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()
|
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())
|
await waitFor(() => expect(c.querySelector('.ce-ta')).toBeTruthy())
|
||||||
|
|
||||||
fireEvent.click(find(c, '.seg button', 'Original')!)
|
fireEvent.click(find(c, '.seg button', 'Original')!)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { tmpdir } from 'node:os'
|
|||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
import { afterEach, describe, expect, it } from 'vitest'
|
import { afterEach, describe, expect, it } from 'vitest'
|
||||||
import { simpleGit } from 'simple-git'
|
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 = ''
|
let dir = ''
|
||||||
afterEach(async () => { if (dir) await rm(dir, { recursive: true, force: true }) })
|
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('README.md'))
|
||||||
expect(top.indexOf('src')).toBeLessThan(top.indexOf('logo.bin'))
|
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', () => {
|
describe('readAll', () => {
|
||||||
@@ -67,6 +112,15 @@ describe('buildTreeFromPaths', () => {
|
|||||||
expect((src.children || []).map((c) => c.name)).toEqual(['util', 'a.ts', 'b.ts'])
|
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')
|
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', () => {
|
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