improvements to the ui
This commit is contained in:
2
.helder/.gitignore
vendored
Normal file
2
.helder/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# Helder — local, machine-specific state (do not commit)
|
||||||
|
recent.json
|
||||||
@@ -46,9 +46,44 @@ const THEME_TEMPLATE = `/* Helder theme — custom CSS applied OVER the built-in
|
|||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
|
||||||
|
/** Recently-opened files, newest first. Local machine state — git-ignored. */
|
||||||
|
const RECENT_FILE = 'recent.json'
|
||||||
|
const MAX_RECENT = 100
|
||||||
|
const GITIGNORE_BODY = `# Helder — local, machine-specific state (do not commit)\n${RECENT_FILE}\n`
|
||||||
|
|
||||||
let current: HelderConfig = DEFAULTS
|
let current: HelderConfig = DEFAULTS
|
||||||
let themeCss = ''
|
let themeCss = ''
|
||||||
|
|
||||||
|
/** Create `.helder/.gitignore` (ignoring recent.json) only when it's missing. */
|
||||||
|
async function ensureGitignore(dir: string): Promise<void> {
|
||||||
|
const path = join(dir, '.gitignore')
|
||||||
|
try {
|
||||||
|
await readFile(path, 'utf8')
|
||||||
|
} catch {
|
||||||
|
await writeFile(path, GITIGNORE_BODY)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getRecent(root: string): Promise<string[]> {
|
||||||
|
try {
|
||||||
|
const arr = JSON.parse(await readFile(join(root, '.helder', RECENT_FILE), 'utf8'))
|
||||||
|
return Array.isArray(arr) ? arr.filter((p): p is string => typeof p === 'string').slice(0, MAX_RECENT) : []
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setRecent(root: string, list: string[]): Promise<void> {
|
||||||
|
try {
|
||||||
|
const dir = join(root, '.helder')
|
||||||
|
await mkdir(dir, { recursive: true })
|
||||||
|
await ensureGitignore(dir)
|
||||||
|
await writeFile(join(dir, RECENT_FILE), JSON.stringify(list.slice(0, MAX_RECENT), null, 2) + '\n')
|
||||||
|
} catch {
|
||||||
|
/* read-only / inaccessible root — recents just won't persist */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function isPlainObject(v: unknown): v is Record<string, unknown> {
|
function isPlainObject(v: unknown): v is Record<string, unknown> {
|
||||||
return !!v && typeof v === 'object' && !Array.isArray(v)
|
return !!v && typeof v === 'object' && !Array.isArray(v)
|
||||||
}
|
}
|
||||||
@@ -83,6 +118,8 @@ export async function resolveConfig(root: string): Promise<void> {
|
|||||||
themeCss = THEME_TEMPLATE
|
themeCss = THEME_TEMPLATE
|
||||||
await writeFile(join(dir, 'theme.css'), THEME_TEMPLATE)
|
await writeFile(join(dir, 'theme.css'), THEME_TEMPLATE)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await ensureGitignore(dir)
|
||||||
} catch {
|
} catch {
|
||||||
// Read-only / inaccessible root: fall back to built-in defaults.
|
// Read-only / inaccessible root: fall back to built-in defaults.
|
||||||
current = DEFAULTS
|
current = DEFAULTS
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { getName, getRoot, openDialog } from './project'
|
|||||||
import { readAll, readProjectFile, readTree, writeProjectFile } from './fs-service'
|
import { readAll, 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, getThemeCss, resolveConfig } from './config'
|
import { getConfig, getRecent, getThemeCss, resolveConfig, setRecent } from './config'
|
||||||
import { listFiles, searchContent } from './search-service'
|
import { listFiles, searchContent } from './search-service'
|
||||||
|
|
||||||
const isDev = !!process.env['ELECTRON_RENDERER_URL']
|
const isDev = !!process.env['ELECTRON_RENDERER_URL']
|
||||||
@@ -112,6 +112,9 @@ function registerIpc(): void {
|
|||||||
ipcMain.handle('config:get', () => getConfig())
|
ipcMain.handle('config:get', () => getConfig())
|
||||||
ipcMain.handle('config:theme', () => getThemeCss())
|
ipcMain.handle('config:theme', () => getThemeCss())
|
||||||
|
|
||||||
|
ipcMain.handle('recent:get', () => getRecent(getRoot()))
|
||||||
|
ipcMain.handle('recent:set', (_e, list: string[]) => setRecent(getRoot(), list))
|
||||||
|
|
||||||
ipcMain.handle('search:content', (_e, query: string) => searchContent(getRoot(), query))
|
ipcMain.handle('search:content', (_e, query: string) => searchContent(getRoot(), query))
|
||||||
ipcMain.handle('search:files', () => listFiles(getRoot()))
|
ipcMain.handle('search:files', () => listFiles(getRoot()))
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { basename } from 'node:path'
|
import { basename, resolve } from 'node:path'
|
||||||
import { existsSync, statSync } from 'node:fs'
|
import { existsSync, statSync } from 'node:fs'
|
||||||
import { dialog, BrowserWindow } from 'electron'
|
import { dialog, BrowserWindow } from 'electron'
|
||||||
|
|
||||||
@@ -6,12 +6,16 @@ import { dialog, BrowserWindow } from 'electron'
|
|||||||
* One project per window. The root is resolved (in order) from $HELDER_PROJECT,
|
* One project per window. The root is resolved (in order) from $HELDER_PROJECT,
|
||||||
* a directory passed on argv, or the process working directory — then it can be
|
* a directory passed on argv, or the process working directory — then it can be
|
||||||
* changed at runtime via the Open Folder dialog.
|
* changed at runtime via the Open Folder dialog.
|
||||||
|
*
|
||||||
|
* Always store an absolute path: in dev the app is launched as `electron .`, so
|
||||||
|
* argv carries a bare "." — `basename(".")` is "." (the repo name would show as a
|
||||||
|
* lone dot). `resolve()` turns it into the real directory before we name it.
|
||||||
*/
|
*/
|
||||||
function resolveInitialRoot(): string {
|
function resolveInitialRoot(): string {
|
||||||
const envRoot = process.env.HELDER_PROJECT
|
const envRoot = process.env.HELDER_PROJECT
|
||||||
if (envRoot && existsSync(envRoot) && statSync(envRoot).isDirectory()) return envRoot
|
if (envRoot && existsSync(envRoot) && statSync(envRoot).isDirectory()) return resolve(envRoot)
|
||||||
const argDir = process.argv.slice(1).find((a) => !a.startsWith('-') && existsSync(a) && safeIsDir(a))
|
const argDir = process.argv.slice(1).find((a) => !a.startsWith('-') && existsSync(a) && safeIsDir(a))
|
||||||
if (argDir) return argDir
|
if (argDir) return resolve(argDir)
|
||||||
return process.cwd()
|
return process.cwd()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,7 +34,7 @@ export function getName(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function setRoot(next: string): void {
|
export function setRoot(next: string): void {
|
||||||
root = next
|
root = resolve(next)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function openDialog(win: BrowserWindow | null): Promise<string | null> {
|
export async function openDialog(win: BrowserWindow | null): Promise<string | null> {
|
||||||
|
|||||||
@@ -56,6 +56,11 @@ const api = {
|
|||||||
theme: (): Promise<string> => ipcRenderer.invoke('config:theme'),
|
theme: (): Promise<string> => ipcRenderer.invoke('config:theme'),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
recent: {
|
||||||
|
get: (): Promise<string[]> => ipcRenderer.invoke('recent:get'),
|
||||||
|
set: (list: string[]): Promise<void> => ipcRenderer.invoke('recent:set', list),
|
||||||
|
},
|
||||||
|
|
||||||
search: {
|
search: {
|
||||||
content: (query: string) => ipcRenderer.invoke('search:content', query),
|
content: (query: string) => ipcRenderer.invoke('search:content', query),
|
||||||
files: (): Promise<string[]> => ipcRenderer.invoke('search:files'),
|
files: (): Promise<string[]> => ipcRenderer.invoke('search:files'),
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
/* App shell: 4 resizable columns, keyboard shortcuts, copy-reference, status bar */
|
/* App shell: 4 resizable columns, keyboard shortcuts, copy-reference */
|
||||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { HL } from './highlight'
|
|
||||||
import { FileTree, GitPanel, Icon } from './components'
|
import { FileTree, GitPanel, Icon } from './components'
|
||||||
import type { ContextTarget } from './components'
|
import type { ContextTarget } from './components'
|
||||||
import { Editor, SplitView } from './editor'
|
import { Editor, SplitView } from './editor'
|
||||||
import type { Cursor, Mode, Selection } from './editor'
|
import type { Cursor, Mode, Selection } from './editor'
|
||||||
import { Terminal, lid } from './terminals'
|
import { Terminal, lid } from './terminals'
|
||||||
import { ContextMenu, PassPopup, SearchModal, Toasts } from './overlays'
|
import { ContextMenu, HistoryModal, PassPopup, SearchModal, Toasts } from './overlays'
|
||||||
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'
|
||||||
@@ -80,18 +79,23 @@ export function App(): React.ReactElement {
|
|||||||
const changeMap = useMemo(() => Object.fromEntries(proj.changes.map((c) => [c.path, c.status])) as Record<string, GitStatus>, [proj.changes])
|
const changeMap = useMemo(() => Object.fromEntries(proj.changes.map((c) => [c.path, c.status])) as Record<string, GitStatus>, [proj.changes])
|
||||||
const changeSet = useMemo(() => new Set(proj.changes.map((c) => c.path)), [proj.changes])
|
const changeSet = useMemo(() => new Set(proj.changes.map((c) => c.path)), [proj.changes])
|
||||||
|
|
||||||
const [tabs, setTabs] = useState<{ path: string }[]>([])
|
|
||||||
const [active, setActive] = useState<string | null>(null)
|
const [active, setActive] = useState<string | null>(null)
|
||||||
const [tabMode, setTabMode] = useState<Record<string, Mode>>({})
|
const [tabMode, setTabMode] = useState<Record<string, Mode>>({})
|
||||||
const [openDirs, setOpenDirs] = useState<Set<string>>(new Set())
|
const [openDirs, setOpenDirs] = useState<Set<string>>(new Set())
|
||||||
const [cursor, setCursor] = useState<Cursor | null>(null)
|
const [cursor, setCursor] = useState<Cursor | null>(null)
|
||||||
const [selection, setSelection] = useState<Selection | null>(null)
|
const [selection, setSelection] = useState<Selection | null>(null)
|
||||||
const [overlay, setOverlay] = useState<'search' | null>(null)
|
const [overlay, setOverlay] = useState<'search' | 'history' | null>(null)
|
||||||
|
// Most-recently-opened files, newest first, de-duplicated. Drives the ⌘↓/⌘↑ navigator.
|
||||||
|
const [history, setHistory] = useState<string[]>([])
|
||||||
|
const [histInitSel, setHistInitSel] = useState(0)
|
||||||
const [menu, setMenu] = useState<Menu | null>(null)
|
const [menu, setMenu] = useState<Menu | null>(null)
|
||||||
const [toasts, setToasts] = useState<Toast[]>([])
|
const [toasts, setToasts] = useState<Toast[]>([])
|
||||||
const [splitFor, setSplitFor] = useState<string | null>(null)
|
const [splitFor, setSplitFor] = useState<string | null>(null)
|
||||||
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)
|
||||||
|
// Brief full-screen "branch - repository" flash whenever the window gains focus
|
||||||
|
// (handy when juggling several project windows).
|
||||||
|
const [showFlash, setShowFlash] = useState(false)
|
||||||
|
|
||||||
// Editable buffers: path → current text (absent = clean, showing on-disk content).
|
// Editable buffers: path → current text (absent = clean, showing on-disk content).
|
||||||
const [buffers, setBuffers] = useState<Record<string, string>>({})
|
const [buffers, setBuffers] = useState<Record<string, string>>({})
|
||||||
@@ -167,6 +171,19 @@ export function App(): React.ReactElement {
|
|||||||
return () => window.removeEventListener('resize', apply)
|
return () => window.removeEventListener('resize', apply)
|
||||||
}, [focusZone, autoResize])
|
}, [focusZone, autoResize])
|
||||||
|
|
||||||
|
// Flash the branch · repository banner for ~2s each time the window gains focus.
|
||||||
|
useEffect(() => {
|
||||||
|
let timer: ReturnType<typeof setTimeout>
|
||||||
|
function flash(): void {
|
||||||
|
setShowFlash(true)
|
||||||
|
clearTimeout(timer)
|
||||||
|
timer = setTimeout(() => setShowFlash(false), 2000)
|
||||||
|
}
|
||||||
|
if (document.hasFocus()) flash()
|
||||||
|
window.addEventListener('focus', flash)
|
||||||
|
return () => { window.removeEventListener('focus', flash); clearTimeout(timer) }
|
||||||
|
}, [])
|
||||||
|
|
||||||
// Seed explorer expansion from the tree's `open` flags once per opened project.
|
// Seed explorer expansion from the tree's `open` flags once per opened project.
|
||||||
const seededRoot = useRef<string | null | undefined>(undefined)
|
const seededRoot = useRef<string | null | undefined>(undefined)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -176,25 +193,36 @@ export function App(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
}, [proj.tree, proj.root])
|
}, [proj.tree, proj.root])
|
||||||
|
|
||||||
// Session restore (session.restoreOnLaunch): bring back the open tabs, active
|
// Per project: load the recent-files history (.helder/recent.json) and the last
|
||||||
// tab and per-tab view modes for this project, then keep them persisted.
|
// active file + view modes (localStorage), then keep both persisted. Recent files
|
||||||
|
// persist regardless of restoreOnLaunch; only the active/view-mode restore is gated.
|
||||||
const sessionRoot = useRef<string | null | undefined>(undefined)
|
const sessionRoot = useRef<string | null | undefined>(undefined)
|
||||||
|
const recentReady = useRef(false)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!proj.ready || sessionRoot.current === proj.root) return
|
if (!proj.ready || sessionRoot.current === proj.root) return
|
||||||
sessionRoot.current = proj.root
|
sessionRoot.current = proj.root
|
||||||
if (!proj.config.session.restoreOnLaunch) return
|
recentReady.current = false
|
||||||
const saved = loadJson<{ tabs: string[]; active: string | null; tabMode: Record<string, Mode> } | null>(`helder.session:${proj.root}`, null)
|
setHistory([]); setActive(null); setTabMode({})
|
||||||
if (saved && Array.isArray(saved.tabs)) {
|
if (proj.config.session.restoreOnLaunch) {
|
||||||
setTabs(saved.tabs.map((p) => ({ path: p })))
|
const saved = loadJson<{ active: string | null; tabMode: Record<string, Mode> } | null>(`helder.session:${proj.root}`, null)
|
||||||
setActive(saved.active ?? null)
|
if (saved) { setActive(saved.active ?? null); setTabMode(saved.tabMode ?? {}) }
|
||||||
setTabMode(saved.tabMode ?? {})
|
|
||||||
}
|
}
|
||||||
|
const bridge = window.helder
|
||||||
|
if (bridge) bridge.recent.get().then((list) => { setHistory(list); recentReady.current = true }).catch(() => { recentReady.current = true })
|
||||||
}, [proj.ready, proj.root, proj.config.session.restoreOnLaunch])
|
}, [proj.ready, proj.root, proj.config.session.restoreOnLaunch])
|
||||||
|
|
||||||
|
// Persist the history to .helder/recent.json (newest first, capped to 100 in main),
|
||||||
|
// but only once it's been loaded for this project (so we never clobber it with []).
|
||||||
|
useEffect(() => {
|
||||||
|
if (sessionRoot.current !== proj.root || !recentReady.current) return
|
||||||
|
const bridge = window.helder
|
||||||
|
if (bridge) bridge.recent.set(history).catch(() => {})
|
||||||
|
}, [history, proj.root])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (sessionRoot.current !== proj.root || !proj.config.session.restoreOnLaunch) return
|
if (sessionRoot.current !== proj.root || !proj.config.session.restoreOnLaunch) return
|
||||||
saveJson(`helder.session:${proj.root}`, { tabs: tabs.map((t) => t.path), active, tabMode })
|
saveJson(`helder.session:${proj.root}`, { active, tabMode })
|
||||||
}, [tabs, active, tabMode, proj.root, proj.config.session.restoreOnLaunch])
|
}, [active, tabMode, proj.root, proj.config.session.restoreOnLaunch])
|
||||||
|
|
||||||
function toast(title: string, ref?: string): void {
|
function toast(title: string, ref?: string): void {
|
||||||
const id = lid()
|
const id = lid()
|
||||||
@@ -243,8 +271,8 @@ export function App(): React.ReactElement {
|
|||||||
function openFile(path: string, opts: { diff?: boolean; line?: number } = {}): void {
|
function openFile(path: string, opts: { diff?: boolean; line?: number } = {}): void {
|
||||||
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))
|
||||||
actions.ensureFile(path)
|
actions.ensureFile(path)
|
||||||
setTabs((t) => t.some((x) => x.path === path) ? t : [...t, { path }])
|
|
||||||
setActive(path)
|
setActive(path)
|
||||||
setTabMode((m) => ({ ...m, [path]: opts.diff && changed ? defaultMode : (m[path] || (changed ? defaultMode : 'code')) }))
|
setTabMode((m) => ({ ...m, [path]: opts.diff && changed ? defaultMode : (m[path] || (changed ? defaultMode : 'code')) }))
|
||||||
reveal(path)
|
reveal(path)
|
||||||
@@ -261,19 +289,8 @@ export function App(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeTab(path: string): void {
|
// Close the current file view (no tabs anymore — the recent-files list replaces
|
||||||
setBuffers((b) => { if (b[path] == null) return b; const n = { ...b }; delete n[path]; return n })
|
// them). The file stays in history; ⌘W just clears the editor after a dirty check.
|
||||||
setTabs((t) => {
|
|
||||||
const ix = t.findIndex((x) => x.path === path)
|
|
||||||
const next = t.filter((x) => x.path !== path)
|
|
||||||
if (path === active) {
|
|
||||||
const fallback = next[ix] || next[ix - 1] || next[next.length - 1]
|
|
||||||
setActive(fallback ? fallback.path : null)
|
|
||||||
}
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function closeTab(path: string): Promise<void> {
|
async function closeTab(path: string): Promise<void> {
|
||||||
const buf = buffersRef.current[path]
|
const buf = buffersRef.current[path]
|
||||||
const dirtyNow = buf != null && buf !== (projRef.current.files[path] ?? '')
|
const dirtyNow = buf != null && buf !== (projRef.current.files[path] ?? '')
|
||||||
@@ -285,7 +302,8 @@ export function App(): React.ReactElement {
|
|||||||
if (choice === 'cancel') return
|
if (choice === 'cancel') return
|
||||||
if (choice === 'save') writeToDisk(path, buf as string)
|
if (choice === 'save') writeToDisk(path, buf as string)
|
||||||
}
|
}
|
||||||
removeTab(path)
|
setBuffers((b) => { if (b[path] == null) return b; const n = { ...b }; delete n[path]; return n })
|
||||||
|
if (path === active) setActive(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- context menus ----
|
// ---- context menus ----
|
||||||
@@ -332,30 +350,27 @@ export function App(): React.ReactElement {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function onKey(e: KeyboardEvent): void {
|
function onKey(e: KeyboardEvent): void {
|
||||||
const meta = e.metaKey || e.ctrlKey
|
const meta = e.metaKey || e.ctrlKey
|
||||||
|
// The history navigator owns the keyboard while open (it listens in capture phase).
|
||||||
|
if (overlay === 'history') return
|
||||||
|
// ⌘↓/⌘↑ from the file viewer opens the recent-files navigator.
|
||||||
|
if (meta && (e.key === 'ArrowDown' || e.key === 'ArrowUp') && focusZone === 'editor' && history.length > 0) {
|
||||||
|
e.preventDefault()
|
||||||
|
setHistInitSel(e.key === 'ArrowDown' ? Math.min(1, history.length - 1) : 0)
|
||||||
|
setOverlay('history')
|
||||||
|
}
|
||||||
// ⌘F and ⌘P both open the unified search (it covers file names too).
|
// ⌘F and ⌘P both open the unified search (it covers file names too).
|
||||||
if (meta && (e.key.toLowerCase() === 'f' || e.key.toLowerCase() === 'p')) { e.preventDefault(); setOverlay('search') }
|
else if (meta && (e.key.toLowerCase() === 'f' || e.key.toLowerCase() === 'p')) { e.preventDefault(); setOverlay('search') }
|
||||||
else if (meta && e.key.toLowerCase() === 's') { e.preventDefault(); saveActive() }
|
else if (meta && e.key.toLowerCase() === 's') { e.preventDefault(); saveActive() }
|
||||||
else if (meta && e.key.toLowerCase() === 'w') { e.preventDefault(); if (active) closeTab(active) }
|
else if (meta && e.key.toLowerCase() === 'w') { e.preventDefault(); if (active) closeTab(active) }
|
||||||
else if (e.key === 'Escape') { if (splitFor) setSplitFor(null); else { setOverlay(null); setMenu(null) } }
|
else if (e.key === 'Escape') { if (splitFor) setSplitFor(null); else { setOverlay(null); setMenu(null) } }
|
||||||
}
|
}
|
||||||
window.addEventListener('keydown', onKey)
|
window.addEventListener('keydown', onKey)
|
||||||
return () => window.removeEventListener('keydown', onKey)
|
return () => window.removeEventListener('keydown', onKey)
|
||||||
}, [active, splitFor])
|
}, [active, splitFor, overlay, focusZone, history])
|
||||||
|
|
||||||
const MODE_LABEL: Record<string, string> = { original: 'orig', updated: 'upd', diff: 'diff', code: '' }
|
|
||||||
const MODE_WORD: Record<string, string> = { original: 'Original', updated: 'Updated', diff: 'Diff' }
|
|
||||||
const resolvedTabs = tabs.map((t) => {
|
|
||||||
const changed = !!proj.diffs[t.path]
|
|
||||||
const m = tabMode[t.path] || (changed ? defaultMode : 'code')
|
|
||||||
return { ...t, changed, modeLabel: splitFor === t.path ? 'split' : MODE_LABEL[m], dirty: isDirty(t.path) }
|
|
||||||
})
|
|
||||||
const mode: Mode = (active && tabMode[active]) || (active && proj.diffs[active] ? defaultMode : 'code')
|
const mode: Mode = (active && tabMode[active]) || (active && proj.diffs[active] ? defaultMode : 'code')
|
||||||
const totals = proj.changes.reduce((a, c) => ({ add: a.add + c.add, del: a.del + c.del }), { add: 0, del: 0 })
|
|
||||||
const activeLang = active ? HL.langLabel(active) : ''
|
|
||||||
|
|
||||||
const crumb = active ? active.split('/') : []
|
const crumb = active ? active.split('/') : []
|
||||||
const curLine = cursor && active && cursor.path === active ? cursor.line : 1
|
|
||||||
const curCol = cursor && active && cursor.path === active ? cursor.col : 1
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app">
|
<div className="app">
|
||||||
@@ -368,8 +383,13 @@ export function App(): React.ReactElement {
|
|||||||
{active && (
|
{active && (
|
||||||
<div className="tb-crumb">
|
<div className="tb-crumb">
|
||||||
{crumb.map((s, i) => (<React.Fragment key={i}>{i > 0 && <span className="seg"> › </span>}<span style={i === crumb.length - 1 ? { color: 'var(--fg-1)' } : undefined}>{s}</span></React.Fragment>))}
|
{crumb.map((s, i) => (<React.Fragment key={i}>{i > 0 && <span className="seg"> › </span>}<span style={i === crumb.length - 1 ? { color: 'var(--fg-1)' } : undefined}>{s}</span></React.Fragment>))}
|
||||||
|
{isDirty(active) && <span className="tb-dirty" title="Unsaved changes">●</span>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<div className="tb-repo" title={proj.branch + ' - ' + proj.name}>
|
||||||
|
{Icon.branch()}<span className="tb-repo-branch">{proj.branch}</span>
|
||||||
|
<span className="tb-repo-sep">-</span><span className="tb-repo-name">{proj.name}</span>
|
||||||
|
</div>
|
||||||
<div className="tb-spacer" />
|
<div className="tb-spacer" />
|
||||||
<div className="tb-actions">
|
<div className="tb-actions">
|
||||||
<button className="tb-btn" onClick={() => setOverlay('search')}>{Icon.search()} Search <kbd>⌘F</kbd></button>
|
<button className="tb-btn" onClick={() => setOverlay('search')}>{Icon.search()} Search <kbd>⌘F</kbd></button>
|
||||||
@@ -380,6 +400,18 @@ export function App(): React.ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* focus flash — branch - repository, centered, ~1s (inline, header styling) */}
|
||||||
|
{showFlash && (
|
||||||
|
<div className="focus-flash" key={proj.branch + proj.name}>
|
||||||
|
<div className="ff-card">
|
||||||
|
{Icon.branch({ width: 20, height: 20 })}
|
||||||
|
<span className="tb-repo-branch">{proj.branch}</span>
|
||||||
|
<span className="tb-repo-sep">-</span>
|
||||||
|
<span className="tb-repo-name">{proj.name}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* workbench */}
|
{/* workbench */}
|
||||||
<div className="workbench">
|
<div className="workbench">
|
||||||
<div className="col" style={{ width: gitW, flex: '0 0 ' + gitW + 'px' }}>
|
<div className="col" style={{ width: gitW, flex: '0 0 ' + gitW + 'px' }}>
|
||||||
@@ -401,9 +433,9 @@ export function App(): React.ReactElement {
|
|||||||
<Splitter onDelta={(dx) => { setAutoResize(false); setTreeW((w) => clamp(w + dx, 160, 520)) }} />
|
<Splitter onDelta={(dx) => { setAutoResize(false); setTreeW((w) => clamp(w + dx, 160, 520)) }} />
|
||||||
|
|
||||||
<div className="col editor-col" onMouseDownCapture={() => setFocusZone('editor')}>
|
<div className="col editor-col" onMouseDownCapture={() => setFocusZone('editor')}>
|
||||||
<Editor tabs={resolvedTabs} 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) } }}
|
||||||
onActivate={setActive} onClose={closeTab} 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}
|
||||||
bufferText={bufferText(active)} onEdit={onEdit} />
|
bufferText={bufferText(active)} onEdit={onEdit} />
|
||||||
@@ -418,18 +450,6 @@ export function App(): React.ReactElement {
|
|||||||
{proj.ready && <RightColumn key={proj.root ?? 'none'} width={rightW} onFocus={() => setFocusZone('terminal')} />}
|
{proj.ready && <RightColumn key={proj.root ?? 'none'} width={rightW} onFocus={() => setFocusZone('terminal')} />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* status bar */}
|
|
||||||
<div className="statusbar">
|
|
||||||
<div className="sb accent">{Icon.branch({ width: 12, height: 12 })}<span style={{ color: '#0c1320' }}>{proj.branch}</span></div>
|
|
||||||
<div className="sb"><span className="a">+{totals.add}</span> <span className="d">−{totals.del}</span></div>
|
|
||||||
<div className="sb spacer" />
|
|
||||||
{active && <div className="sb">{selection && selection.path === active && selection.start !== selection.end ? `${selection.end - selection.start + 1} lines selected` : `Ln ${curLine}, Col ${curCol}`}</div>}
|
|
||||||
{active && <div className="sb">Spaces: {proj.config.editor.tabSize}</div>}
|
|
||||||
{active && <div className="sb">UTF-8</div>}
|
|
||||||
{active && <div className="sb"><b>{activeLang}</b></div>}
|
|
||||||
{active && proj.diffs[active] && <div className="sb">{splitFor === active ? 'Split' : (MODE_WORD[mode] || '')}</div>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* overlays */}
|
{/* overlays */}
|
||||||
{splitFor && <SplitView path={splitFor} onClose={() => setSplitFor(null)} onContext={openMenu} />}
|
{splitFor && <SplitView path={splitFor} onClose={() => setSplitFor(null)} onContext={openMenu} />}
|
||||||
{passPopup && <PassPopup x={passPopup.x} y={passPopup.y} refStr={passPopup.ref}
|
{passPopup && <PassPopup x={passPopup.x} y={passPopup.y} refStr={passPopup.ref}
|
||||||
@@ -441,6 +461,7 @@ export function App(): React.ReactElement {
|
|||||||
}}
|
}}
|
||||||
onCancel={() => setPassPopup(null)} />}
|
onCancel={() => setPassPopup(null)} />}
|
||||||
{overlay === 'search' && <SearchModal onOpen={openFile} onOpenAt={(p, n) => openFile(p, { line: n })} onClose={() => setOverlay(null)} changeSet={changeSet} />}
|
{overlay === 'search' && <SearchModal onOpen={openFile} onOpenAt={(p, n) => openFile(p, { line: n })} onClose={() => setOverlay(null)} changeSet={changeSet} />}
|
||||||
|
{overlay === 'history' && <HistoryModal history={history} initialSel={histInitSel} onOpen={openFile} onClose={() => setOverlay(null)} changeSet={changeSet} />}
|
||||||
{menu && <ContextMenu menu={menu} onClose={() => setMenu(null)} />}
|
{menu && <ContextMenu menu={menu} onClose={() => setMenu(null)} />}
|
||||||
<Toasts toasts={toasts} />
|
<Toasts toasts={toasts} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -31,8 +31,14 @@ export const Chevron = ({ open }: { open: boolean }): React.ReactElement => (
|
|||||||
|
|
||||||
export const FolderIcon = ({ open }: { open: boolean }): React.ReactElement => (
|
export const FolderIcon = ({ open }: { open: boolean }): React.ReactElement => (
|
||||||
<svg className="folder-ic" width="14" height="14" viewBox="0 0 16 16" fill="none">
|
<svg className="folder-ic" width="14" height="14" viewBox="0 0 16 16" fill="none">
|
||||||
<path d={open ? 'M1.5 4.5h4l1.2 1.4H14V13H2V4.5z' : 'M1.5 4.5h4l1.2 1.4H14V13H1.5V4.5z'}
|
{open ? (
|
||||||
fill={open ? 'rgba(122,131,140,.18)' : 'rgba(122,131,140,.12)'} stroke="currentColor" strokeWidth="1.1" />
|
<Fragment>
|
||||||
|
<path d="M2 12.5V4.5h3.6l1.2 1.4h6.7V7.4" fill="rgba(122,131,140,.16)" stroke="currentColor" strokeWidth="1.1" strokeLinejoin="round" />
|
||||||
|
<path d="M1.4 12.6l1.9-5.1h11.4l-1.9 5.1H1.4z" fill="rgba(122,131,140,.22)" stroke="currentColor" strokeWidth="1.1" strokeLinejoin="round" />
|
||||||
|
</Fragment>
|
||||||
|
) : (
|
||||||
|
<path d="M1.5 4.5h4l1.2 1.4H14V13H1.5V4.5z" fill="rgba(122,131,140,.12)" stroke="currentColor" strokeWidth="1.1" strokeLinejoin="round" />
|
||||||
|
)}
|
||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -53,16 +59,18 @@ 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, onOpen, onContext, onToggleStage }: {
|
function GitRow({ c, staged, activePath, showDir, onOpen, onContext, onToggleStage }: {
|
||||||
c: Change
|
c: Change
|
||||||
staged: boolean
|
staged: boolean
|
||||||
activePath: string | null
|
activePath: string | null
|
||||||
|
showDir: boolean
|
||||||
onOpen: OpenFile
|
onOpen: OpenFile
|
||||||
onContext: OnContext
|
onContext: OnContext
|
||||||
onToggleStage: (path: string) => void
|
onToggleStage: (path: string) => void
|
||||||
}): React.ReactElement {
|
}): React.ReactElement {
|
||||||
const name = c.path.split('/').pop()
|
const name = c.path.split('/').pop()
|
||||||
const dir = c.path.split('/').slice(0, -1).join('/')
|
const dir = c.path.split('/').slice(0, -1).join('/')
|
||||||
|
const dirShown = showDir && !!dir
|
||||||
return (
|
return (
|
||||||
<div className={'git-row' + (activePath === c.path ? ' active' : '')}
|
<div className={'git-row' + (activePath === c.path ? ' active' : '')}
|
||||||
onClick={() => onOpen(c.path, { diff: true })}
|
onClick={() => onOpen(c.path, { diff: true })}
|
||||||
@@ -71,20 +79,16 @@ function GitRow({ c, staged, activePath, onOpen, onContext, onToggleStage }: {
|
|||||||
<span className={'git-stat ' + c.status}>{c.status}</span>
|
<span className={'git-stat ' + c.status}>{c.status}</span>
|
||||||
<FileIcon path={c.path} />
|
<FileIcon path={c.path} />
|
||||||
<span className={'git-name' + (c.deleted ? ' del' : '')}>{name}</span>
|
<span className={'git-name' + (c.deleted ? ' del' : '')}>{name}</span>
|
||||||
{dir && <span className="git-dir">{dir}/</span>}
|
{dirShown && <span className="git-dir">{dir}/</span>}
|
||||||
<button className="git-act" title={staged ? 'Unstage changes' : 'Stage changes'}
|
<button className={'git-act' + (dirShown ? '' : ' push')} title={staged ? 'Unstage changes' : 'Stage changes'}
|
||||||
onClick={(e) => { e.stopPropagation(); onToggleStage(c.path) }}>
|
onClick={(e) => { e.stopPropagation(); onToggleStage(c.path) }}>
|
||||||
{staged ? Icon.minus() : Icon.plus()}
|
{staged ? Icon.minus() : Icon.plus()}
|
||||||
</button>
|
</button>
|
||||||
<span className="git-delta">
|
|
||||||
{c.add > 0 && <span className="a">+{c.add}</span>}
|
|
||||||
{c.del > 0 && <span className="d">-{c.del}</span>}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GitPanel({ branch, changes, staged, committed, commitMsg, setCommitMsg, onStage, onUnstage, onStageAll, onUnstageAll, onCommit, onOpen, onContext, activePath }: {
|
export function GitPanel({ branch, changes, staged, committed, commitMsg, setCommitMsg, onStage, onUnstage, onStageAll, onUnstageAll, onCommit, onOpen, onContext, activePath, showDir }: {
|
||||||
branch: string
|
branch: string
|
||||||
changes: Change[]
|
changes: Change[]
|
||||||
staged: Set<string>
|
staged: Set<string>
|
||||||
@@ -99,6 +103,7 @@ export function GitPanel({ branch, changes, staged, committed, commitMsg, setCom
|
|||||||
onOpen: OpenFile
|
onOpen: OpenFile
|
||||||
onContext: OnContext
|
onContext: OnContext
|
||||||
activePath: string | null
|
activePath: string | null
|
||||||
|
showDir: boolean
|
||||||
}): React.ReactElement {
|
}): React.ReactElement {
|
||||||
const visible = changes.filter((c) => !committed.has(c.path))
|
const visible = changes.filter((c) => !committed.has(c.path))
|
||||||
const stagedList = visible.filter((c) => staged.has(c.path))
|
const stagedList = visible.filter((c) => staged.has(c.path))
|
||||||
@@ -125,7 +130,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}
|
<GitRow key={c.path} c={c} staged={true} activePath={activePath} 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>
|
||||||
@@ -138,7 +143,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}
|
<GitRow key={c.path} c={c} staged={false} activePath={activePath} 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>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/* Editor: tabs + four view modes (Original / Updated / Diff / Split) + line selection */
|
/* Editor: four view modes (Original / Updated / Diff / Split) + line selection */
|
||||||
import React, { Fragment, useEffect, useMemo, useRef } from 'react'
|
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'
|
||||||
@@ -16,8 +16,6 @@ function climbToLine(node: Node | null): HTMLElement | null {
|
|||||||
return el || null
|
return el || null
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ResolvedTab { path: string; changed: boolean; modeLabel: string; dirty?: boolean }
|
|
||||||
|
|
||||||
/* Editable buffer: a transparent textarea over a Prism-highlighted <pre>, with a
|
/* Editable buffer: a transparent textarea over a Prism-highlighted <pre>, with a
|
||||||
* scroll-synced line-number gutter. Live highlighting while typing. */
|
* scroll-synced line-number gutter. Live highlighting while typing. */
|
||||||
function CodeEditor({ path, text, lang, onChange, onContext }: {
|
function CodeEditor({ path, text, lang, onChange, onContext }: {
|
||||||
@@ -81,41 +79,6 @@ function CodeEditor({ path, text, lang, onChange, onContext }: {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function EditorTabs({ tabs, active, onActivate, onClose }: {
|
|
||||||
tabs: ResolvedTab[]
|
|
||||||
active: string | null
|
|
||||||
onActivate: (path: string) => void
|
|
||||||
onClose: (path: string) => void
|
|
||||||
}): React.ReactElement {
|
|
||||||
const ref = useRef<HTMLDivElement>(null)
|
|
||||||
useEffect(() => {
|
|
||||||
const el = ref.current && ref.current.querySelector('.tab.active')
|
|
||||||
if (el) el.scrollIntoView({ block: 'nearest', inline: 'nearest' })
|
|
||||||
}, [active])
|
|
||||||
return (
|
|
||||||
<div className="tabs" ref={ref}>
|
|
||||||
{tabs.map((t) => {
|
|
||||||
const name = t.path.split('/').pop()
|
|
||||||
return (
|
|
||||||
<div key={t.path}
|
|
||||||
className={'tab' + (active === t.path ? ' active' : '') + (t.dirty ? ' dirtyclose' : '')}
|
|
||||||
onClick={() => onActivate(t.path)}
|
|
||||||
onAuxClick={(e) => { if (e.button === 1) { e.preventDefault(); onClose(t.path) } }}
|
|
||||||
title={t.path}>
|
|
||||||
<FileIcon path={t.path} />
|
|
||||||
<span className="tname">{name}</span>
|
|
||||||
{t.changed && <span className="tab-mode">{t.modeLabel}</span>}
|
|
||||||
<span className="tclose" onClick={(e) => { e.stopPropagation(); onClose(t.path) }}>
|
|
||||||
{Icon.close()}
|
|
||||||
</span>
|
|
||||||
{t.dirty && <span className="tdot" title="Unsaved changes" />}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</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
|
||||||
@@ -228,18 +191,15 @@ function buildLines(mode: Mode, diff: Diff | null, fileText: string | undefined)
|
|||||||
}
|
}
|
||||||
|
|
||||||
const SEGMENTS: { id: Mode; label: string }[] = [
|
const SEGMENTS: { id: Mode; label: string }[] = [
|
||||||
{ id: 'original', label: 'Original' },
|
|
||||||
{ id: 'updated', label: 'Updated' },
|
{ id: 'updated', label: 'Updated' },
|
||||||
|
{ id: 'original', label: 'Original' },
|
||||||
{ id: 'diff', label: 'Diff' },
|
{ id: 'diff', label: 'Diff' },
|
||||||
]
|
]
|
||||||
|
|
||||||
export function Editor({ tabs, active, mode, setMode, onActivate, onClose, onContext, onSplit, splitOpen, cursor, selection, setCursor, setSelection, bufferText, onEdit }: {
|
export function Editor({ active, mode, setMode, onContext, onSplit, splitOpen, cursor, selection, setCursor, setSelection, bufferText, onEdit }: {
|
||||||
tabs: ResolvedTab[]
|
|
||||||
active: string | null
|
active: string | null
|
||||||
mode: Mode
|
mode: Mode
|
||||||
setMode: (m: Mode) => void
|
setMode: (m: Mode) => void
|
||||||
onActivate: (path: string) => void
|
|
||||||
onClose: (path: string) => void
|
|
||||||
onContext: OnContext
|
onContext: OnContext
|
||||||
onSplit: (path: string) => void
|
onSplit: (path: string) => void
|
||||||
splitOpen: boolean
|
splitOpen: boolean
|
||||||
@@ -251,7 +211,7 @@ export function Editor({ tabs, active, mode, setMode, onActivate, onClose, onCon
|
|||||||
onEdit: (text: string) => void
|
onEdit: (text: string) => void
|
||||||
}): React.ReactElement {
|
}): React.ReactElement {
|
||||||
const PROJECT = useProject()
|
const PROJECT = useProject()
|
||||||
const tab = tabs.find((t) => t.path === active)
|
const tab = active ? { path: active } : null
|
||||||
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
|
||||||
@@ -272,7 +232,6 @@ export function Editor({ tabs, active, mode, setMode, onActivate, onClose, onCon
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Fragment>
|
<Fragment>
|
||||||
<EditorTabs tabs={tabs} active={active} onActivate={onActivate} onClose={onClose} />
|
|
||||||
{!tab ? (
|
{!tab ? (
|
||||||
<div className="empty-ed">
|
<div className="empty-ed">
|
||||||
<div style={{ opacity: 0.5 }}>{Icon.file({ width: 30, height: 30 })}</div>
|
<div style={{ opacity: 0.5 }}>{Icon.file({ width: 30, height: 30 })}</div>
|
||||||
|
|||||||
4
src/renderer/src/env.d.ts
vendored
4
src/renderer/src/env.d.ts
vendored
@@ -42,6 +42,10 @@ interface HelderBridge {
|
|||||||
get: () => Promise<HelderConfig>
|
get: () => Promise<HelderConfig>
|
||||||
theme: () => Promise<string>
|
theme: () => Promise<string>
|
||||||
}
|
}
|
||||||
|
recent: {
|
||||||
|
get: () => Promise<string[]>
|
||||||
|
set: (list: string[]) => Promise<void>
|
||||||
|
}
|
||||||
search: {
|
search: {
|
||||||
content: (query: string) => Promise<{ path: string; hits: { no: number; ln: string; ix: number }[] }[]>
|
content: (query: string) => Promise<{ path: string; hits: { no: number; ln: string; ix: number }[] }[]>
|
||||||
files: () => Promise<string[]>
|
files: () => Promise<string[]>
|
||||||
|
|||||||
@@ -180,6 +180,68 @@ export function SearchModal({ onOpen, onOpenAt, onClose, changeSet }: {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Recently-opened-files navigator. Looks like the search modal but is a single
|
||||||
|
* keyboard-driven list (most-recent first). ⌘↓/⌘↑ move the selection, ↵ opens.
|
||||||
|
* Listens in the capture phase so it owns the keyboard while open. */
|
||||||
|
export function HistoryModal({ history, initialSel, onOpen, onClose, changeSet }: {
|
||||||
|
history: string[]
|
||||||
|
initialSel: number
|
||||||
|
onOpen: OpenFile
|
||||||
|
onClose: () => void
|
||||||
|
changeSet: Set<string>
|
||||||
|
}): React.ReactElement {
|
||||||
|
const [sel, setSel] = useState(() => Math.min(Math.max(initialSel, 0), Math.max(history.length - 1, 0)))
|
||||||
|
const selRef = useRef(sel); selRef.current = sel
|
||||||
|
const listRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function onKey(e: KeyboardEvent): void {
|
||||||
|
if (e.key === 'ArrowDown') { e.preventDefault(); setSel((s) => Math.min(s + 1, history.length - 1)) }
|
||||||
|
else if (e.key === 'ArrowUp') { e.preventDefault(); setSel((s) => Math.max(s - 1, 0)) }
|
||||||
|
else if (e.key === 'Enter') { e.preventDefault(); const p = history[selRef.current]; if (p) { onOpen(p); onClose() } }
|
||||||
|
else if (e.key === 'Escape') { e.preventDefault(); onClose() }
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', onKey, true)
|
||||||
|
return () => window.removeEventListener('keydown', onKey, true)
|
||||||
|
}, [history])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = listRef.current && listRef.current.querySelector('.hist-row.sel')
|
||||||
|
if (el) el.scrollIntoView({ block: 'nearest' })
|
||||||
|
}, [sel])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="scrim" onMouseDown={onClose}>
|
||||||
|
<div className="history-modal" onMouseDown={(e) => e.stopPropagation()}>
|
||||||
|
<div className="pi">
|
||||||
|
{Icon.file({ style: { color: 'var(--fg-3)' } })}
|
||||||
|
<span className="hist-title">Recent files</span>
|
||||||
|
<span className="mode-chip">{history.length} file{history.length === 1 ? '' : 's'} · <kbd>⌘↓</kbd> <kbd>⌘↑</kbd> <kbd>↵</kbd></span>
|
||||||
|
</div>
|
||||||
|
<div className="hist-list" ref={listRef}>
|
||||||
|
{history.length === 0 && <div className="pempty">No files opened yet</div>}
|
||||||
|
{history.map((p, i) => {
|
||||||
|
const name = p.split('/').pop() as string
|
||||||
|
const dir = p.split('/').slice(0, -1).join('/')
|
||||||
|
return (
|
||||||
|
<div key={p} className={'hist-row' + (i === sel ? ' sel' : '')} title={p}
|
||||||
|
onMouseEnter={() => setSel(i)}
|
||||||
|
onClick={() => { onOpen(p); onClose() }}>
|
||||||
|
<FileIcon path={p} />
|
||||||
|
<div className="hist-txt">
|
||||||
|
<span className="fn">{name}</span>
|
||||||
|
<span className="fd">{dir ? dir + '/' : ''}</span>
|
||||||
|
</div>
|
||||||
|
{changeSet.has(p) && <span className="tree-badge M" style={{ fontFamily: 'var(--mono)', fontSize: 10 }}>•</span>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function ContextMenu({ menu, onClose }: { menu: Menu | null; onClose: () => void }): React.ReactElement | null {
|
export function ContextMenu({ menu, onClose }: { menu: Menu | null; onClose: () => void }): React.ReactElement | null {
|
||||||
const ref = useRef<HTMLDivElement>(null)
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -13,9 +13,12 @@
|
|||||||
--fg-1:#b4bac2;
|
--fg-1:#b4bac2;
|
||||||
--fg-2:#838a94;
|
--fg-2:#838a94;
|
||||||
--fg-3:#5d636c;
|
--fg-3:#5d636c;
|
||||||
--accent:#4d8dff;
|
--accent:#f19f3f;
|
||||||
--accent-soft:rgba(77,141,255,0.16);
|
|
||||||
--accent-line:rgba(77,141,255,0.55);
|
|
||||||
|
|
||||||
|
--accent-soft:rgba(241,159,63,0.16);
|
||||||
|
--accent-line:rgba(241,159,63,0.55);
|
||||||
--add:#5cbd6b;
|
--add:#5cbd6b;
|
||||||
--del:#e0696a;
|
--del:#e0696a;
|
||||||
--mod:#d8a85c;
|
--mod:#d8a85c;
|
||||||
@@ -50,7 +53,7 @@ body {
|
|||||||
overflow:hidden; -webkit-font-smoothing:antialiased;
|
overflow:hidden; -webkit-font-smoothing:antialiased;
|
||||||
}
|
}
|
||||||
#root { height:100vh; }
|
#root { height:100vh; }
|
||||||
::selection { background:rgba(77,141,255,0.32); }
|
::selection { background:rgba(241,159,63,0.30); }
|
||||||
|
|
||||||
/* scrollbars */
|
/* scrollbars */
|
||||||
::-webkit-scrollbar { width:11px; height:11px; }
|
::-webkit-scrollbar { width:11px; height:11px; }
|
||||||
@@ -62,17 +65,45 @@ body {
|
|||||||
.app { display:flex; flex-direction:column; height:100vh; }
|
.app { display:flex; flex-direction:column; height:100vh; }
|
||||||
|
|
||||||
.titlebar {
|
.titlebar {
|
||||||
height:36px; flex:0 0 36px; display:flex; align-items:center;
|
height:36px; flex:0 0 36px; display:flex; align-items:center; position:relative;
|
||||||
background:var(--bg-3); border-bottom:1px solid var(--border);
|
background:var(--bg-3); border-bottom:1px solid var(--border);
|
||||||
padding:0 12px; gap:14px; user-select:none;
|
padding:0 12px; gap:14px; user-select:none;
|
||||||
}
|
}
|
||||||
|
/* branch - repository, centered in the bar */
|
||||||
|
.tb-repo {
|
||||||
|
position:absolute; left:50%; transform:translateX(-50%);
|
||||||
|
display:flex; align-items:center; gap:6px; pointer-events:none;
|
||||||
|
font-family:var(--mono); font-size:11.5px; color:var(--fg-2);
|
||||||
|
max-width:46%; white-space:nowrap; overflow:hidden;
|
||||||
|
}
|
||||||
|
.tb-repo svg { color:var(--accent); flex:0 0 auto; }
|
||||||
|
.tb-repo-branch { color:var(--fg-3); }
|
||||||
|
.tb-repo-sep { color:var(--fg-3); }
|
||||||
|
.tb-repo-name { color:var(--fg-0); font-weight:600; overflow:hidden; text-overflow:ellipsis; }
|
||||||
|
/* focus flash — branch - repository, briefly centered; same inline style as the header label, scaled up */
|
||||||
|
.focus-flash {
|
||||||
|
position:fixed; inset:0; z-index:200; pointer-events:none;
|
||||||
|
display:flex; align-items:center; justify-content:center;
|
||||||
|
animation:ff-fade 2s ease forwards;
|
||||||
|
}
|
||||||
|
.ff-card {
|
||||||
|
display:flex; align-items:center; gap:9px;
|
||||||
|
font-family:var(--mono); font-size:24px; line-height:1;
|
||||||
|
padding:20px 34px; border-radius:14px;
|
||||||
|
background:rgba(22,23,26,0.82); border:1px solid var(--border-2);
|
||||||
|
backdrop-filter:blur(8px); box-shadow:0 18px 60px rgba(0,0,0,0.5);
|
||||||
|
}
|
||||||
|
.ff-card svg { color:var(--accent); flex:0 0 auto; }
|
||||||
|
@keyframes ff-fade { 0%{opacity:0;} 12%{opacity:1;} 72%{opacity:1;} 100%{opacity:0;} }
|
||||||
|
@media (prefers-reduced-motion:reduce) { .focus-flash { animation:ff-fade-rm 2s steps(1) forwards; } @keyframes ff-fade-rm { 0%{opacity:1;} 99%{opacity:1;} 100%{opacity:0;} } }
|
||||||
.traffic { display:flex; gap:8px; }
|
.traffic { display:flex; gap:8px; }
|
||||||
.traffic i { width:12px; height:12px; border-radius:50%; display:block; }
|
.traffic i { width:12px; height:12px; border-radius:50%; display:block; }
|
||||||
.traffic .r{background:#e0696a;} .traffic .y{background:#d8a85c;} .traffic .g{background:#5cbd6b;}
|
.traffic .r{background:#e0696a;} .traffic .y{background:#d8a85c;} .traffic .g{background:#5cbd6b;}
|
||||||
.tb-title { font-size:12px; color:var(--fg-1); display:flex; align-items:center; gap:7px; }
|
.tb-title { font-size:12px; color:var(--fg-1); display:flex; align-items:center; gap:7px; }
|
||||||
.tb-title b { color:var(--fg-0); font-weight:600; }
|
.tb-title b { color:var(--fg-0); font-weight:600; }
|
||||||
.tb-crumb { color:var(--fg-3); font-size:11.5px; font-family:var(--mono); }
|
.tb-crumb { color:var(--fg-3); font-size:11.5px; font-family:var(--mono); display:flex; align-items:center; gap:2px; }
|
||||||
.tb-crumb .seg{color:var(--fg-2);}
|
.tb-crumb .seg{color:var(--fg-2);}
|
||||||
|
.tb-crumb .tb-dirty { color:var(--mod); font-size:10px; margin-left:4px; }
|
||||||
.tb-spacer { flex:1; }
|
.tb-spacer { flex:1; }
|
||||||
.tb-actions { display:flex; gap:6px; align-items:center; }
|
.tb-actions { display:flex; gap:6px; align-items:center; }
|
||||||
.tb-btn {
|
.tb-btn {
|
||||||
@@ -83,7 +114,7 @@ body {
|
|||||||
.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:10px; color:var(--fg-3); border:1px solid var(--border-2); border-radius:4px; padding:1px 5px; }
|
||||||
.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:rgba(77,141,255,0.16); color:var(--accent); }
|
.tb-toggle.on .tb-state { background:var(--accent-soft); color:var(--accent); }
|
||||||
|
|
||||||
.workbench { flex:1; display:flex; min-height:0; }
|
.workbench { flex:1; display:flex; min-height:0; }
|
||||||
|
|
||||||
@@ -112,8 +143,8 @@ body {
|
|||||||
.commit-input { flex:1; min-width:0; resize:none; background:var(--bg-0); border:1px solid var(--border-2); border-radius:7px; color:var(--fg-0); font-family:var(--ui); font-size:12px; line-height:16px; padding:7px 9px; outline:none; min-height:32px; }
|
.commit-input { flex:1; min-width:0; resize:none; background:var(--bg-0); border:1px solid var(--border-2); border-radius:7px; color:var(--fg-0); font-family:var(--ui); font-size:12px; line-height:16px; padding:7px 9px; outline:none; min-height:32px; }
|
||||||
.commit-input:focus { border-color:var(--accent-line); }
|
.commit-input:focus { border-color:var(--accent-line); }
|
||||||
.commit-input::placeholder { color:var(--fg-3); }
|
.commit-input::placeholder { color:var(--fg-3); }
|
||||||
.commit-btn { flex:0 0 auto; display:flex; align-items:center; gap:6px; background:var(--accent); color:#0c1320; border:0; border-radius:7px; font:inherit; font-size:12px; font-weight:600; padding:0 11px; height:32px; cursor:pointer; }
|
.commit-btn { flex:0 0 auto; display:flex; align-items:center; gap:6px; background:var(--accent); color:#201608; border:0; border-radius:7px; font:inherit; font-size:12px; font-weight:600; padding:0 11px; height:32px; cursor:pointer; }
|
||||||
.commit-btn:hover:not(:disabled) { background:#5d97ff; }
|
.commit-btn:hover:not(:disabled) { background:#f6b35f; }
|
||||||
.commit-btn:disabled { background:var(--bg-3); color:var(--fg-3); cursor:not-allowed; }
|
.commit-btn:disabled { background:var(--bg-3); color:var(--fg-3); cursor:not-allowed; }
|
||||||
.git-body { overflow:auto; flex:1; padding:4px 0 10px; }
|
.git-body { overflow:auto; flex:1; padding:4px 0 10px; }
|
||||||
.git-group { padding:8px 12px 3px; font-size:10px; letter-spacing:.06em; text-transform:uppercase; color:var(--fg-3); display:flex; align-items:center; gap:6px; }
|
.git-group { padding:8px 12px 3px; font-size:10px; letter-spacing:.06em; text-transform:uppercase; color:var(--fg-3); display:flex; align-items:center; gap:6px; }
|
||||||
@@ -138,11 +169,10 @@ body {
|
|||||||
.git-row.active .git-name { color:var(--fg-0); }
|
.git-row.active .git-name { color:var(--fg-0); }
|
||||||
.git-name.del { text-decoration:line-through; color:var(--fg-3); }
|
.git-name.del { text-decoration:line-through; color:var(--fg-3); }
|
||||||
.git-dir { color:var(--fg-3); font-size:11px; margin-left:auto; padding-left:8px; white-space:nowrap; max-width:42%; overflow:hidden; text-overflow:ellipsis; direction:rtl; }
|
.git-dir { color:var(--fg-3); font-size:11px; margin-left:auto; padding-left:8px; white-space:nowrap; max-width:42%; overflow:hidden; text-overflow:ellipsis; direction:rtl; }
|
||||||
.git-act { flex:0 0 auto; display:none; align-items:center; justify-content:center; width:20px; height:20px; padding:0; background:transparent; border:0; border-radius:5px; color:var(--fg-2); cursor:pointer; margin-left:4px; }
|
.git-act { flex:0 0 auto; display:flex; visibility:hidden; align-items:center; justify-content:center; width:20px; height:20px; padding:0; background:transparent; border:0; border-radius:5px; color:var(--fg-2); cursor:pointer; margin-left:4px; }
|
||||||
.git-row:hover .git-act { display:flex; }
|
.git-act.push { margin-left:auto; } /* right-align when the dir column is hidden */
|
||||||
|
.git-row:hover .git-act { visibility:visible; }
|
||||||
.git-act:hover { background:var(--active); color:var(--fg-0); }
|
.git-act:hover { background:var(--active); color:var(--fg-0); }
|
||||||
.git-delta { font-family:var(--mono); font-size:10.5px; display:flex; gap:6px; flex:0 0 auto; }
|
|
||||||
.git-delta .a{color:var(--add);} .git-delta .d{color:var(--del);}
|
|
||||||
.git-foot { border-top:1px solid var(--border); padding:8px 12px; display:flex; align-items:center; gap:8px; font-size:11px; color:var(--fg-2); }
|
.git-foot { border-top:1px solid var(--border); padding:8px 12px; display:flex; align-items:center; gap:8px; font-size:11px; color:var(--fg-2); }
|
||||||
.branch-chip { display:flex; align-items:center; gap:6px; color:var(--fg-1); }
|
.branch-chip { display:flex; align-items:center; gap:6px; color:var(--fg-1); }
|
||||||
.branch-chip b { font-weight:600; color:var(--fg-0); }
|
.branch-chip b { font-weight:600; color:var(--fg-0); }
|
||||||
@@ -167,26 +197,6 @@ body {
|
|||||||
.folder-ic { width:15px; height:15px; flex:0 0 15px; display:inline-flex; align-items:center; justify-content:center; color:var(--fg-2); }
|
.folder-ic { width:15px; height:15px; flex:0 0 15px; display:inline-flex; align-items:center; justify-content:center; color:var(--fg-2); }
|
||||||
|
|
||||||
/* ============ editor ============ */
|
/* ============ editor ============ */
|
||||||
.tabs { height:35px; flex:0 0 35px; display:flex; align-items:stretch; background:var(--bg-3); border-bottom:1px solid var(--border); overflow-x:auto; overflow-y:hidden; }
|
|
||||||
.tabs::-webkit-scrollbar { height:0; }
|
|
||||||
.tab {
|
|
||||||
display:flex; align-items:center; gap:7px; padding:0 9px 0 13px; cursor:pointer;
|
|
||||||
border-right:1px solid var(--border); color:var(--fg-2); font-size:12.5px; white-space:nowrap;
|
|
||||||
background:var(--bg-3); position:relative; max-width:230px;
|
|
||||||
}
|
|
||||||
.tab:hover { background:#272b31; }
|
|
||||||
.tab.active { background:var(--bg-0); color:var(--fg-0); }
|
|
||||||
.tab.active::after { content:""; position:absolute; left:0; right:0; top:0; height:2px; background:var(--accent); }
|
|
||||||
.tab .tname { overflow:hidden; text-overflow:ellipsis; }
|
|
||||||
.tab.dirty .tname::after { content:" ●"; color:var(--mod); font-size:10px; }
|
|
||||||
.tab .tclose { width:17px; height:17px; border-radius:4px; display:flex; align-items:center; justify-content:center; color:var(--fg-3); flex:0 0 17px; }
|
|
||||||
.tab .tclose:hover { background:var(--active); color:var(--fg-0); }
|
|
||||||
.tab .tdot { display:none; width:7px; height:7px; border-radius:50%; background:var(--fg-2); }
|
|
||||||
.tab.dirtyclose .tclose { display:none; }
|
|
||||||
.tab.dirtyclose:hover .tclose { display:flex; }
|
|
||||||
.tab.dirtyclose:hover .tdot { display:none; }
|
|
||||||
.tab.dirtyclose .tdot { display:block; }
|
|
||||||
.tab-mode { margin-left:6px; font-size:9.5px; letter-spacing:.05em; text-transform:uppercase; color:var(--fg-3); border:1px solid var(--border-2); border-radius:4px; padding:0 5px; line-height:14px; }
|
|
||||||
|
|
||||||
.editor-wrap { flex:1; min-height:0; display:flex; flex-direction:column; position:relative; }
|
.editor-wrap { flex:1; min-height:0; display:flex; flex-direction:column; position:relative; }
|
||||||
.editor { flex:1; overflow:auto; font-family:var(--code-font); font-size:var(--code-size); line-height:20px; padding:6px 0 40px; }
|
.editor { flex:1; overflow:auto; font-family:var(--code-font); font-size:var(--code-size); line-height:20px; padding:6px 0 40px; }
|
||||||
@@ -323,6 +333,22 @@ body {
|
|||||||
.fres-txt .fn b { color:var(--accent); font-weight:700; }
|
.fres-txt .fn b { color:var(--accent); font-weight:700; }
|
||||||
.fres-txt .fd { font-size:10.5px; color:var(--fg-3); font-family:var(--mono); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
.fres-txt .fd { font-size:10.5px; color:var(--fg-3); font-family:var(--mono); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||||
|
|
||||||
|
/* recent-files navigator — single-column, keyboard-driven, search-modal styling */
|
||||||
|
.history-modal { width:560px; 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; }
|
||||||
|
.history-modal .pi { display:flex; align-items:center; gap:10px; padding:12px 15px; border-bottom:1px solid var(--border); }
|
||||||
|
.history-modal .pi svg { flex:0 0 auto; }
|
||||||
|
.history-modal .hist-title { flex:1; min-width:0; color:var(--fg-1); font-size:14px; }
|
||||||
|
.history-modal .mode-chip { flex:0 0 auto; white-space:nowrap; font-size:10px; color:var(--fg-3); border:1px solid var(--border-2); border-radius:5px; padding:2px 7px; font-family:var(--mono); display:flex; align-items:center; gap:4px; }
|
||||||
|
.history-modal .mode-chip kbd { font-family:var(--mono); font-size:10px; color:var(--fg-2); border:1px solid var(--border-2); border-radius:4px; padding:0 4px; }
|
||||||
|
.hist-list { max-height:460px; overflow:auto; padding:5px 0; }
|
||||||
|
.hist-row { display:flex; align-items:center; gap:9px; padding:6px 13px; cursor:pointer; }
|
||||||
|
.hist-row.sel { background:var(--accent-dim, rgba(241,159,63,0.14)); box-shadow:inset 2px 0 0 var(--accent); }
|
||||||
|
.hist-row:hover { background:var(--hover); }
|
||||||
|
.hist-row.sel:hover { background:rgba(241,159,63,0.20); }
|
||||||
|
.hist-txt { min-width:0; display:flex; flex-direction:column; line-height:1.25; }
|
||||||
|
.hist-txt .fn { font-size:12.5px; color:var(--fg-0); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||||
|
.hist-txt .fd { font-size:10.5px; color:var(--fg-3); font-family:var(--mono); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||||
|
|
||||||
/* content search */
|
/* content search */
|
||||||
.search-results { max-height:420px; overflow:auto; padding:4px 0 8px; }
|
.search-results { max-height:420px; overflow:auto; padding:4px 0 8px; }
|
||||||
.sr-file { padding:7px 14px 3px; font-size:11.5px; color:var(--fg-2); display:flex; align-items:center; gap:8px; cursor:pointer; }
|
.sr-file { padding:7px 14px 3px; font-size:11.5px; color:var(--fg-2); display:flex; align-items:center; gap:8px; cursor:pointer; }
|
||||||
@@ -374,14 +400,6 @@ body {
|
|||||||
.toast .tref { font-family:var(--mono); font-size:11.5px; color:var(--accent); background:var(--accent-soft); border-radius:5px; padding:2px 8px; }
|
.toast .tref { font-family:var(--mono); font-size:11.5px; color:var(--accent); background:var(--accent-soft); border-radius:5px; padding:2px 8px; }
|
||||||
|
|
||||||
/* ============ status bar ============ */
|
/* ============ status bar ============ */
|
||||||
.statusbar { height:23px; flex:0 0 23px; display:flex; align-items:center; gap:0; background:var(--bg-3); border-top:1px solid var(--border); font-size:11px; color:var(--fg-2); user-select:none; }
|
|
||||||
.sb { display:flex; align-items:center; gap:6px; padding:0 11px; height:100%; }
|
|
||||||
.sb:hover { background:var(--hover); }
|
|
||||||
.sb.accent { background:var(--accent); color:#0c1320; }
|
|
||||||
.sb.accent:hover { background:#5d97ff; }
|
|
||||||
.sb.spacer { flex:1; }
|
|
||||||
.sb .a{color:var(--add);} .sb .d{color:var(--del);}
|
|
||||||
.sb b { font-weight:600; color:var(--fg-1); }
|
|
||||||
|
|
||||||
/* editable buffer — transparent textarea over a highlighted <pre>, synced gutter */
|
/* editable buffer — transparent textarea over a highlighted <pre>, synced gutter */
|
||||||
.code-edit { flex:1; min-height:0; display:flex; overflow:hidden; }
|
.code-edit { flex:1; min-height:0; display:flex; overflow:hidden; }
|
||||||
@@ -400,7 +418,7 @@ body {
|
|||||||
position:absolute; inset:0; resize:none; outline:none; overflow:hidden;
|
position:absolute; inset:0; resize:none; outline:none; overflow:hidden;
|
||||||
background:transparent; color:transparent; caret-color:var(--accent);
|
background:transparent; color:transparent; caret-color:var(--accent);
|
||||||
}
|
}
|
||||||
.ce-ta::selection { background:rgba(77,141,255,0.32); }
|
.ce-ta::selection { background:rgba(241,159,63,0.30); }
|
||||||
|
|
||||||
/* xterm.js host (real terminals) */
|
/* xterm.js host (real terminals) */
|
||||||
.term-xterm { flex:1; min-height:0; overflow:hidden; padding:6px 4px 6px 8px; background:var(--bg-1); }
|
.term-xterm { flex:1; min-height:0; overflow:hidden; padding:6px 4px 6px 8px; background:var(--bg-1); }
|
||||||
|
|||||||
@@ -39,13 +39,13 @@ describe('App (mock data, jsdom)', () => {
|
|||||||
const c = renderApp()
|
const c = renderApp()
|
||||||
await waitFor(() => expect(rowWithText(c, '.git-row', 'UserController.php')).toBeTruthy())
|
await waitFor(() => expect(rowWithText(c, '.git-row', 'UserController.php')).toBeTruthy())
|
||||||
expect(c.querySelector('.workbench')).toBeTruthy()
|
expect(c.querySelector('.workbench')).toBeTruthy()
|
||||||
expect(c.textContent).toContain('Source Control')
|
expect(c.querySelector('.commit-box')).toBeTruthy() // git panel
|
||||||
expect(c.textContent).toContain('Explorer')
|
expect(c.querySelector('.tree-body')).toBeTruthy() // explorer
|
||||||
// no file open yet
|
// no file open yet
|
||||||
expect(c.textContent).toContain('No file open')
|
expect(c.textContent).toContain('No file open')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('opens a changed file from the git panel into a diff tab', async () => {
|
it('opens a changed file from the git panel into the diff view', async () => {
|
||||||
const c = renderApp()
|
const c = renderApp()
|
||||||
const row = await waitFor(() => {
|
const row = await waitFor(() => {
|
||||||
const r = rowWithText(c, '.git-row', 'UserController.php')
|
const r = rowWithText(c, '.git-row', 'UserController.php')
|
||||||
@@ -53,12 +53,14 @@ describe('App (mock data, jsdom)', () => {
|
|||||||
return r
|
return r
|
||||||
})
|
})
|
||||||
fireEvent.click(row)
|
fireEvent.click(row)
|
||||||
await waitFor(() => expect(rowWithText(c, '.tab', 'UserController.php')).toBeTruthy())
|
// no tabs anymore — the file opens straight into the editor's diff toolbar,
|
||||||
// changed file → diff toolbar with a status word
|
// and its path shows in the title-bar breadcrumb.
|
||||||
|
await waitFor(() => expect(c.querySelector('.diff-bar')).toBeTruthy())
|
||||||
expect(c.querySelector('.diff-bar')?.textContent).toContain('Modified')
|
expect(c.querySelector('.diff-bar')?.textContent).toContain('Modified')
|
||||||
|
expect(rowWithText(c, '.tb-crumb', 'UserController.php')).toBeTruthy()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('makes an edited buffer dirty (tab dot)', async () => {
|
it('makes an edited buffer dirty (breadcrumb dot)', async () => {
|
||||||
const c = renderApp()
|
const c = renderApp()
|
||||||
const treeRow = await waitFor(() => {
|
const treeRow = await waitFor(() => {
|
||||||
const r = rowWithText(c, '.tree-row', 'store.js')
|
const r = rowWithText(c, '.tree-row', 'store.js')
|
||||||
@@ -71,9 +73,9 @@ describe('App (mock data, jsdom)', () => {
|
|||||||
if (!t) throw new Error('editor not ready')
|
if (!t) throw new Error('editor not ready')
|
||||||
return t
|
return t
|
||||||
})
|
})
|
||||||
expect(c.querySelector('.tab.dirtyclose')).toBeNull()
|
expect(c.querySelector('.tb-dirty')).toBeNull()
|
||||||
fireEvent.change(ta, { target: { value: '// edited\n' } })
|
fireEvent.change(ta, { target: { value: '// edited\n' } })
|
||||||
await waitFor(() => expect(c.querySelector('.tab.dirtyclose')).toBeTruthy())
|
await waitFor(() => expect(c.querySelector('.tb-dirty')).toBeTruthy())
|
||||||
})
|
})
|
||||||
|
|
||||||
it('searches file contents from the search modal', async () => {
|
it('searches file contents from the search modal', async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user