This commit is contained in:
@@ -190,8 +190,11 @@ export function App(): React.ReactElement {
|
||||
// Which column currently has focus — drives the active-panel tint and keyboard
|
||||
// navigation (arrows move a row cursor in Git/Explorer, ⌘→ opens its menu).
|
||||
const [activePanel, setActivePanel] = useState<'git' | 'tree' | 'editor' | 'terminal' | null>(null)
|
||||
const [gitSel, setGitSel] = useState(0)
|
||||
const [treeSel, setTreeSel] = useState(0)
|
||||
// The row cursors are kept as identities, not indexes: both lists reorder and
|
||||
// grow under the cursor (a git refresh, a folder that reveal() expands), and an
|
||||
// index then silently points at a different row.
|
||||
const [gitSelId, setGitSelId] = useState<string | null>(null)
|
||||
const [treeSelPath, setTreeSelPath] = useState<string | null>(null)
|
||||
// Auto panel management: re-fit columns on resize/focus. Manually dragging a
|
||||
// splitter switches it off (the user took control); the title-bar toggle
|
||||
// turns it back on (and immediately re-fits).
|
||||
@@ -252,6 +255,8 @@ export function App(): React.ReactElement {
|
||||
if (proj.tree) walk(proj.tree)
|
||||
return out
|
||||
}, [proj.tree, openDirs, showHidden])
|
||||
const gitSel = gitNav.findIndex((r) => r.id === gitSelId)
|
||||
const treeSel = treeNav.findIndex((r) => r.path === treeSelPath)
|
||||
const gitSelRow = gitNav[gitSel] ?? null
|
||||
const gitSelPath = gitSelRow?.path ?? null
|
||||
const treeSelItem = treeNav[treeSel] ?? null
|
||||
@@ -262,21 +267,13 @@ export function App(): React.ReactElement {
|
||||
const syncGitSel = (target: EventTarget): void => {
|
||||
const row = (target as HTMLElement).closest?.('.git-row') as HTMLElement | null
|
||||
const id = row?.dataset.rowId
|
||||
if (!id) return
|
||||
const i = gitNav.findIndex((r) => r.id === id)
|
||||
if (i >= 0) setGitSel(i)
|
||||
if (id) setGitSelId(id)
|
||||
}
|
||||
const syncTreeSel = (target: EventTarget): void => {
|
||||
const row = (target as HTMLElement).closest?.('.tree-row') as HTMLElement | null
|
||||
const path = row?.dataset.rowPath
|
||||
if (path == null) return
|
||||
const i = treeNav.findIndex((r) => r.path === path)
|
||||
if (i >= 0) setTreeSel(i)
|
||||
if (path != null) setTreeSelPath(path)
|
||||
}
|
||||
|
||||
// Keep the row cursors in range as the lists shrink/grow.
|
||||
useEffect(() => { setGitSel((s) => Math.min(s, Math.max(0, gitNav.length - 1))) }, [gitNav.length])
|
||||
useEffect(() => { setTreeSel((s) => Math.min(s, Math.max(0, treeNav.length - 1))) }, [treeNav.length])
|
||||
// Scroll the selected row into view when navigating with the keyboard.
|
||||
useEffect(() => {
|
||||
if (activePanel === 'git') document.querySelector('.git-row.kbd')?.scrollIntoView({ block: 'nearest' })
|
||||
@@ -522,6 +519,17 @@ export function App(): React.ReactElement {
|
||||
actions.unstage(p)
|
||||
}
|
||||
|
||||
// A search result opens the file at its line and paints that line orange for a
|
||||
// second, so the eye finds it after the scroll. `id` restarts a repeat jump.
|
||||
const [lineFlash, setLineFlash] = useState<{ path: string; line: number; id: number } | null>(null)
|
||||
const lineFlashTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
function flashLine(path: string, line: number): void {
|
||||
if (lineFlashTimer.current) clearTimeout(lineFlashTimer.current)
|
||||
setLineFlash({ path, line, id: Date.now() })
|
||||
lineFlashTimer.current = setTimeout(() => { lineFlashTimer.current = null; setLineFlash(null) }, 1000)
|
||||
}
|
||||
useEffect(() => () => { if (lineFlashTimer.current) clearTimeout(lineFlashTimer.current) }, [])
|
||||
|
||||
function openFile(path: string, opts: { diff?: boolean; line?: number; side?: DiffSide } = {}): void {
|
||||
const changed = !!proj.diffs[path]
|
||||
setFocusZone('editor')
|
||||
@@ -541,6 +549,7 @@ export function App(): React.ReactElement {
|
||||
return n
|
||||
})
|
||||
reveal(path)
|
||||
setTreeSelPath(path)
|
||||
if (opts.line) {
|
||||
// The updated/code views render in the CodeEditor (a textarea over a <pre>),
|
||||
// so scroll its container to centre the target line. Line height is 20px with
|
||||
@@ -551,6 +560,7 @@ export function App(): React.ReactElement {
|
||||
setCursor({ path, line: opts.line, col: 1 })
|
||||
setSelection(null)
|
||||
scrollEditorToLine(opts.line)
|
||||
flashLine(path, opts.line)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -809,10 +819,13 @@ export function App(): React.ReactElement {
|
||||
// Arrow up/down move the row cursor in the focused Git/Explorer panel.
|
||||
if (!meta && !inField && inPanel && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) {
|
||||
e.preventDefault()
|
||||
const len = activePanel === 'git' ? gitNav.length : treeNav.length
|
||||
if (len === 0) return
|
||||
const set = activePanel === 'git' ? setGitSel : setTreeSel
|
||||
set((s) => e.key === 'ArrowDown' ? Math.min(s + 1, len - 1) : Math.max(s - 1, 0))
|
||||
const list: { id?: string; path: string }[] = activePanel === 'git' ? gitNav : treeNav
|
||||
if (list.length === 0) return
|
||||
// A cursor whose row is gone (folder collapsed, file staged away) starts over.
|
||||
const cur = activePanel === 'git' ? gitSel : treeSel
|
||||
const next = cur < 0 ? 0 : e.key === 'ArrowDown' ? Math.min(cur + 1, list.length - 1) : Math.max(cur - 1, 0)
|
||||
if (activePanel === 'git') setGitSelId(gitNav[next].id)
|
||||
else setTreeSelPath(treeNav[next].path)
|
||||
return
|
||||
}
|
||||
// ↵ opens the selected row (git → diff, file → open, folder → toggle).
|
||||
@@ -1038,6 +1051,7 @@ export function App(): React.ReactElement {
|
||||
onContext={openMenu}
|
||||
onSplit={(p) => setSplitFor(p)} splitOpen={splitFor === active}
|
||||
cursor={cursor} selection={selection} setCursor={setCursor} setSelection={setSelection}
|
||||
flash={lineFlash && lineFlash.path === active ? lineFlash : null}
|
||||
bufferText={bufferText(active)} onEdit={onEdit} />
|
||||
</div>
|
||||
<Splitter onDelta={(dx) => { setAutoResize(false); setRightW((w) => {
|
||||
|
||||
@@ -71,9 +71,9 @@ export type OnContext = (e: React.MouseEvent, target: ContextTarget) => void
|
||||
|
||||
/* ============ Git / Source Control panel ============ */
|
||||
|
||||
/** True for files under the project root's `tests/` folder. The git list dims these. */
|
||||
/** True for files under the project root's `tests/` or `cypress/` folder. The git list dims these. */
|
||||
function isTestFile(path: string): boolean {
|
||||
return path.startsWith('tests/')
|
||||
return path.startsWith('tests/') || path.startsWith('cypress/')
|
||||
}
|
||||
|
||||
function GitRow({ c, activePath, activeSide, ctxPath, kbdId, showDir, onOpen, onContext, onToggleStage }: {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* Editor: four view modes (Original / Updated / Diff / Split) + line selection */
|
||||
import React, { Fragment, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import React, { Fragment, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { Diff, DiffSide, ViewLine } from './types'
|
||||
import { rowId } from './types'
|
||||
import { useProject } from './project'
|
||||
@@ -9,6 +9,9 @@ import { FileIcon, Icon } from './components'
|
||||
import type { OnContext } from './components'
|
||||
|
||||
export interface Cursor { path: string; line: number; col: number }
|
||||
/** A one-second line highlight after a jump. `id` changes per jump, so the same
|
||||
* line twice restarts the animation. */
|
||||
export interface FlashLine { line: number; id: number }
|
||||
export interface Selection { path: string; start: number; end: number; anchor: number }
|
||||
export type Mode = 'original' | 'updated' | 'diff' | 'code' | 'preview'
|
||||
|
||||
@@ -29,11 +32,13 @@ function climbToLine(node: Node | null): HTMLElement | null {
|
||||
* the number is a CSS counter on `::before`, which keeps it on the first
|
||||
* visual row and leaves the folded rows unnumbered. Highlighting is then per
|
||||
* line (like the diff views), so multi-line tokens don't carry over. */
|
||||
function CodeEditor({ path, text, lang, wrap, onChange, onContext }: {
|
||||
function CodeEditor({ path, text, lang, wrap, flash, onChange, onContext }: {
|
||||
path: string
|
||||
text: string
|
||||
lang: string | null
|
||||
wrap: boolean
|
||||
/** Line to paint orange for a second after a jump. */
|
||||
flash: FlashLine | null
|
||||
onChange: (text: string) => void
|
||||
onContext: OnContext
|
||||
}): React.ReactElement {
|
||||
@@ -48,6 +53,14 @@ function CodeEditor({ path, text, lang, wrap, onChange, onContext }: {
|
||||
[text, lang, wrap],
|
||||
)
|
||||
const count = useMemo(() => text.split('\n').length, [text])
|
||||
// The band sits behind the text, so it needs the geometry of the line. Unwrapped
|
||||
// that is arithmetic; wrapped, a line can be several rows tall, so measure it.
|
||||
const [flashBox, setFlashBox] = useState<{ top: number; height: number } | null>(null)
|
||||
useLayoutEffect(() => {
|
||||
if (!flash) { setFlashBox(null); return }
|
||||
const row = wrap ? (preRef.current?.children[flash.line - 1] as HTMLElement | undefined) : undefined
|
||||
setFlashBox(row ? { top: row.offsetTop, height: row.offsetHeight } : { top: 6 + (flash.line - 1) * 20, height: 20 })
|
||||
}, [flash, wrap, text])
|
||||
|
||||
function onScroll(): void {
|
||||
const s = scrollRef.current
|
||||
@@ -132,6 +145,7 @@ function CodeEditor({ path, text, lang, wrap, onChange, onContext }: {
|
||||
)}
|
||||
<div className="ce-scroll" ref={scrollRef} onScroll={onScroll}>
|
||||
<div className="ce-inner">
|
||||
{flash && flashBox && <div key={flash.id} className="ce-flash" style={{ top: flashBox.top, height: flashBox.height }} />}
|
||||
<textarea className="ce-ta" value={text} spellCheck={false} autoComplete="off"
|
||||
wrap={wrap ? 'soft' : 'off'} style={{ tabSize }}
|
||||
onChange={(e) => { onChange(e.target.value); ensureCaretVisible(e.target) }}
|
||||
@@ -313,7 +327,7 @@ function segmentsFor(hasDiff: boolean, isMarkdown: boolean): { id: Mode; label:
|
||||
return segs
|
||||
}
|
||||
|
||||
export function Editor({ active, mode, side, setMode, onContext, onSplit, splitOpen, cursor, selection, setCursor, setSelection, bufferText, onEdit }: {
|
||||
export function Editor({ active, mode, side, setMode, onContext, onSplit, splitOpen, cursor, selection, setCursor, setSelection, flash, bufferText, onEdit }: {
|
||||
active: string | null
|
||||
mode: Mode
|
||||
/** Which git row opened this tab. Only Diff and Split follow it. */
|
||||
@@ -326,6 +340,8 @@ export function Editor({ active, mode, side, setMode, onContext, onSplit, splitO
|
||||
selection: Selection | null
|
||||
setCursor: (c: Cursor) => void
|
||||
setSelection: (s: Selection | null) => void
|
||||
/** Line to flash after a jump, when it belongs to the open file. */
|
||||
flash: FlashLine | null
|
||||
bufferText: string
|
||||
onEdit: (text: string) => void
|
||||
}): React.ReactElement {
|
||||
@@ -435,7 +451,7 @@ export function Editor({ active, mode, side, setMode, onContext, onSplit, splitO
|
||||
) : 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>
|
||||
) : editable ? (
|
||||
<CodeEditor path={tab.path} text={bufferText} lang={lang} wrap={wrap} onChange={onEdit} onContext={onContext} />
|
||||
<CodeEditor path={tab.path} text={bufferText} lang={lang} wrap={wrap} flash={flash} onChange={onEdit} onContext={onContext} />
|
||||
) : (
|
||||
built && <PaneView cacheKey={tab.path + ':' + effMode + ':' + (effMode === 'diff' ? side ?? '' : '')} path={tab.path} lines={built.lines}
|
||||
lang={lang} showSign={built.showSign} wrap={wrap} cursor={cursor} selection={selection}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useProject } from './project'
|
||||
import type { RecentProject } from './project'
|
||||
import { fuzzy } from './fuzzy'
|
||||
import { FileIcon, Icon } from './components'
|
||||
import { HL } from './highlight'
|
||||
import type { OpenFile } from './components'
|
||||
|
||||
export interface MenuItem {
|
||||
@@ -60,6 +61,7 @@ export function SearchModal({ initialQuery, onOpen, onOpenAt, onClose, changeSet
|
||||
const [sel, setSel] = useState(0)
|
||||
const [fileSel, setFileSel] = useState(0)
|
||||
const [inFileSel, setInFileSel] = useState(0)
|
||||
const [extSel, setExtSel] = useState<string | null>(null) // null = every file type
|
||||
const hasInFile = !!activePath
|
||||
type Col = 'infile' | 'content' | 'files'
|
||||
const cols: Col[] = hasInFile ? ['infile', 'content', 'files'] : ['content', 'files']
|
||||
@@ -104,10 +106,14 @@ export function SearchModal({ initialQuery, onOpen, onOpenAt, onClose, changeSet
|
||||
}, [q])
|
||||
|
||||
// hide dotfile content hits unless the Hidden toggle is on
|
||||
const visibleContent = useMemo(
|
||||
const shownContent = useMemo(
|
||||
() => (showHidden ? content : content.filter((g) => !isHiddenPath(g.path))),
|
||||
[content, showHidden],
|
||||
)
|
||||
const visibleContent = useMemo(
|
||||
() => (extSel ? shownContent.filter((g) => HL.ext(g.path) === extSel) : shownContent),
|
||||
[shownContent, extSel],
|
||||
)
|
||||
|
||||
// in-file matches (leftmost): substring grep within the currently open file's buffer
|
||||
const inFile = useMemo(() => {
|
||||
@@ -123,7 +129,7 @@ export function SearchModal({ initialQuery, onOpen, onOpenAt, onClose, changeSet
|
||||
}, [q, activeText, hasInFile])
|
||||
|
||||
// file-name matches (right)
|
||||
const files = useMemo(() => {
|
||||
const allFiles = useMemo(() => {
|
||||
const term = q.trim()
|
||||
if (!term) return []
|
||||
const out: { path: string; idx: number[] | null; rank: number; pos: number }[] = []
|
||||
@@ -138,6 +144,26 @@ export function SearchModal({ initialQuery, onOpen, onOpenAt, onClose, changeSet
|
||||
out.sort((a, b) => a.rank - b.rank || a.pos - b.pos || a.path.length - b.path.length)
|
||||
return out
|
||||
}, [q, allPaths, showHidden])
|
||||
const files = useMemo(
|
||||
() => (extSel ? allFiles.filter((r) => HL.ext(r.path) === extSel) : allFiles),
|
||||
[allFiles, extSel],
|
||||
)
|
||||
|
||||
// file-type chips: every extension the unfiltered results carry, most matches first
|
||||
const extList = useMemo(() => {
|
||||
const n = new Map<string, number>()
|
||||
const add = (path: string): void => {
|
||||
const e = HL.ext(path)
|
||||
if (e) n.set(e, (n.get(e) ?? 0) + 1)
|
||||
}
|
||||
shownContent.forEach((g) => add(g.path))
|
||||
allFiles.forEach((r) => add(r.path))
|
||||
return [...n].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, 12)
|
||||
}, [shownContent, allFiles])
|
||||
// a chosen type that the new results no longer carry falls back to "all types"
|
||||
useEffect(() => {
|
||||
if (extSel && !extList.some(([e]) => e === extSel)) setExtSel(null)
|
||||
}, [extList, extSel])
|
||||
|
||||
// flat list of content hits for keyboard nav
|
||||
const flat = useMemo(() => {
|
||||
@@ -149,7 +175,7 @@ export function SearchModal({ initialQuery, onOpen, onOpenAt, onClose, changeSet
|
||||
const totalHits = flat.length
|
||||
const fileCount = Math.min(files.length, 40)
|
||||
const inFileCount = Math.min(inFile.length, 200)
|
||||
useEffect(() => { setSel(0); setFileSel(0); setInFileSel(0) }, [q])
|
||||
useEffect(() => { setSel(0); setFileSel(0); setInFileSel(0) }, [q, extSel])
|
||||
useEffect(() => {
|
||||
const el = leftRef.current && leftRef.current.querySelector('.sr-line.sel')
|
||||
if (el) el.scrollIntoView({ block: 'nearest' })
|
||||
@@ -210,6 +236,16 @@ export function SearchModal({ initialQuery, onOpen, onOpenAt, onClose, changeSet
|
||||
{Icon.search({ style: { color: 'var(--fg-3)' } })}
|
||||
<input ref={inputRef} value={q} onChange={(e) => setQ(e.target.value)} onKeyDown={onKey}
|
||||
placeholder="Search this file, the project, and file names…" spellCheck={false} />
|
||||
{extList.length > 1 && (
|
||||
<div className="ext-filters">
|
||||
{extList.map(([e, n]) => (
|
||||
<button key={e} className={'ext-chip' + (extSel === null || extSel === e ? ' on' : '')}
|
||||
title={`${n} result${n === 1 ? '' : 's'} · ${extSel === e ? 'click to show every type' : 'click to show only this type'}`}
|
||||
onMouseDown={(ev) => ev.preventDefault()}
|
||||
onClick={() => setExtSel((cur) => (cur === e ? null : e))}>{e}</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<span className="mode-chip">{hasInFile && <>{inFile.length} here · </>}{totalHits} hit{totalHits === 1 ? '' : 's'} · {files.length} file{files.length === 1 ? '' : 's'}</span>
|
||||
</div>
|
||||
<div className="search-cols">
|
||||
|
||||
@@ -191,12 +191,14 @@ kbd { flex:0 0 auto; font-family:var(--mono); font-size:11.5px; color:var(--fg-3
|
||||
.git-row.ctx .git-act { visibility:visible; }
|
||||
.git-row.active { background:var(--sel); }
|
||||
.git-row.active::before { content:""; position:absolute; left:0; top:0; bottom:0; width:2px; background:var(--accent); }
|
||||
.git-row.kbd { background:var(--hover); box-shadow:inset 2px 0 0 var(--accent-line); }
|
||||
/* Three states must stay apart: hover is background only, keyboard focus adds a
|
||||
1px orange rule top and bottom, and the open file keeps the left accent bar. */
|
||||
.git-row.kbd { background:var(--hover); box-shadow:inset 0 1px 0 var(--accent), inset 0 -1px 0 var(--accent); }
|
||||
.git-row.kbd .git-act { visibility:visible; }
|
||||
.git-stat { width:13px; text-align:center; font-family:var(--mono); font-size:11px; font-weight:600; flex:0 0 13px; }
|
||||
.git-stat.M{color:var(--mod);} .git-stat.A{color:var(--add);} .git-stat.D{color:var(--del);} .git-stat.R{color:var(--ren);} .git-stat.U{color:var(--add);}
|
||||
.git-name { font-size:12.5px; color:var(--fg-1); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||
/* Files in the root `tests/` folder stay a step greyer, so real source changes read first. Git list only. */
|
||||
/* Files in the root `tests/` or `cypress/` folder stay a step greyer, so real source changes read first. Git list only. */
|
||||
.git-row.test .git-name { color:var(--fg-3); }
|
||||
.git-row.active .git-name { color:var(--fg-0); }
|
||||
.git-name.del { text-decoration:line-through; color:var(--fg-3); }
|
||||
@@ -215,7 +217,7 @@ kbd { flex:0 0 auto; font-family:var(--mono); font-size:11.5px; color:var(--fg-3
|
||||
.tree-row:hover, .tree-row.ctx { background:var(--hover); }
|
||||
.tree-row.active { background:var(--sel); }
|
||||
.tree-row.active::before { content:""; position:absolute; left:0; top:0; bottom:0; width:2px; background:var(--accent); }
|
||||
.tree-row.kbd { background:var(--hover); box-shadow:inset 2px 0 0 var(--accent-line); }
|
||||
.tree-row.kbd { background:var(--hover); box-shadow:inset 0 1px 0 var(--accent), inset 0 -1px 0 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; }
|
||||
.tree-label { font-size:12.5px; color:var(--fg-1); overflow:hidden; text-overflow:ellipsis; }
|
||||
.tree-row.active .tree-label { color:var(--fg-0); }
|
||||
@@ -403,6 +405,12 @@ kbd { flex:0 0 auto; font-family:var(--mono); font-size:11.5px; color:var(--fg-3
|
||||
.palette .pi input::placeholder, .search-modal .pi input::placeholder { color:var(--fg-3); }
|
||||
.palette .pi svg, .search-modal .pi svg { flex:0 0 auto; }
|
||||
.palette .pi .mode-chip, .search-modal .pi .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); }
|
||||
/* File-type filter chips. All chips read as "on" while no type is chosen, so the
|
||||
* default state says "every type" without a separate All control. */
|
||||
.search-modal .pi .ext-filters { flex:0 1 auto; min-width:0; display:flex; align-items:center; gap:4px; overflow:hidden; }
|
||||
.search-modal .ext-chip { flex:0 0 auto; font-family:var(--mono); font-size:10px; line-height:16px; padding:1px 7px; border:1px solid var(--border-2); border-radius:5px; background:transparent; color:var(--fg-3); cursor:pointer; opacity:.5; }
|
||||
.search-modal .ext-chip:hover { opacity:1; }
|
||||
.search-modal .ext-chip.on { opacity:1; color:var(--accent); border-color:var(--accent-line); background:var(--accent-soft); }
|
||||
.palette .results { max-height:380px; overflow:auto; padding:6px; }
|
||||
.pres { display:flex; align-items:center; gap:10px; padding:7px 10px; border-radius:7px; cursor:pointer; }
|
||||
.pres.sel { background:var(--accent-soft); }
|
||||
@@ -414,9 +422,13 @@ kbd { flex:0 0 auto; font-family:var(--mono); font-size:11.5px; color:var(--fg-3
|
||||
/* combined search modal (content + files) */
|
||||
.search-modal { width:min(1680px, 92vw); 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; }
|
||||
.search-cols { display:flex; min-height:0; }
|
||||
.sc-infile { flex:0 0 20%; min-width:0; max-height:min(72vh, 720px); overflow:auto; border-right:1px solid var(--border); padding-bottom:8px; background:rgba(0,0,0,0.18); }
|
||||
.sc-left { flex:0 0 60%; min-width:0; max-height:min(72vh, 720px); overflow:auto; border-right:1px solid var(--border); padding-bottom:8px; }
|
||||
.sc-right { flex:0 0 20%; min-width:0; max-height:min(72vh, 720px); overflow:auto; padding-bottom:8px; background:rgba(0,0,0,0.12); }
|
||||
/* The column that holds the selection takes 70%; the rest divide what is left,
|
||||
* so two columns give 70/30 and three give 70/15/15. */
|
||||
.search-cols > div { flex:1 1 0; min-width:0; max-height:min(72vh, 720px); overflow:auto; padding-bottom:8px; transition:flex .14s ease; }
|
||||
.search-cols > div.active { flex:0 0 70%; }
|
||||
.sc-infile { border-right:1px solid var(--border); background:rgba(0,0,0,0.18); }
|
||||
.sc-left { border-right:1px solid var(--border); }
|
||||
.sc-right { background:rgba(0,0,0,0.12); }
|
||||
.sc-head { position:sticky; top:0; z-index:1; background:#212429; padding:9px 14px 6px; font-size:10px; letter-spacing:.07em; text-transform:uppercase; color:var(--fg-3); display:flex; align-items:center; gap:7px; }
|
||||
.sc-right .sc-head { background:#1e2024; }
|
||||
.sc-infile .sc-head { background:#1c1e22; text-transform:none; letter-spacing:0; }
|
||||
@@ -517,12 +529,16 @@ kbd { flex:0 0 auto; font-family:var(--mono); font-size:11.5px; color:var(--fg-3
|
||||
font-family:var(--code-font); font-size:var(--code-size); line-height:20px;
|
||||
white-space:pre; tab-size:4; -moz-tab-size:4; letter-spacing:0;
|
||||
}
|
||||
.ce-pre { display:block; pointer-events:none; color:var(--fg-0); }
|
||||
.ce-pre { display:block; pointer-events:none; color:var(--fg-0); position:relative; z-index:1; }
|
||||
.ce-ta {
|
||||
position:absolute; inset:0; resize:none; outline:none; overflow:hidden;
|
||||
background:transparent; color:transparent; caret-color:var(--accent);
|
||||
}
|
||||
.ce-ta::selection { background:rgba(241,159,63,0.30); }
|
||||
/* jump target — the line a search result opened, orange for a second.
|
||||
Behind both layers, so the code itself keeps its own colours. */
|
||||
.ce-flash { position:absolute; left:0; right:0; min-width:100%; z-index:0; pointer-events:none; background:var(--accent-soft); box-shadow:inset 2px 0 0 var(--accent); animation:ce-flash-fade 1s ease forwards; }
|
||||
@keyframes ce-flash-fade { 0%{opacity:1;} 65%{opacity:1;} 100%{opacity:0;} }
|
||||
|
||||
/* wrapped buffer — no separate gutter column: the layer must fold at the pane
|
||||
width (max-content would never break), so the number rides along as a counter
|
||||
|
||||
Reference in New Issue
Block a user