This was an update of which I cannot remember what it did but it was surely something that I needed so let's commit it. YOLO
CI / check (push) Waiting to run
CI / check (push) Waiting to run
This commit is contained in:
+29
-5
@@ -33,6 +33,10 @@ export interface GitChange {
|
||||
|
||||
export interface GitLoad {
|
||||
branch: string
|
||||
/** Commits that the branch holds and the remote does not. 0 when unknown. */
|
||||
ahead: number
|
||||
/** True when a push would send something. Drives the lit push button. */
|
||||
canPush: boolean
|
||||
changes: GitChange[]
|
||||
}
|
||||
|
||||
@@ -125,19 +129,39 @@ function parseBranch(header: string): string {
|
||||
return head.split(' ')[0].trim() || 'HEAD'
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the push state from a `## ...` porcelain branch header.
|
||||
*
|
||||
* A branch without an upstream reports no ahead count, but its first push still
|
||||
* sends every commit, so it counts as pushable with an unknown count.
|
||||
*/
|
||||
export function parseAhead(header: string): { ahead: number; canPush: boolean } {
|
||||
if (header.startsWith('No commits yet on ') || header.startsWith('HEAD ')) return { ahead: 0, canPush: false }
|
||||
const m = header.match(/\[[^\]]*\bahead (\d+)/)
|
||||
if (m) return { ahead: Number(m[1]), canPush: true }
|
||||
return { ahead: 0, canPush: !header.includes('...') }
|
||||
}
|
||||
|
||||
interface StatusEntry { index: string; working: string; path: string }
|
||||
|
||||
interface ParsedStatus { branch: string; files: StatusEntry[] }
|
||||
interface ParsedStatus { branch: string; ahead: number; canPush: boolean; files: StatusEntry[] }
|
||||
|
||||
/** Parse `git status --porcelain -b -z` output. NUL-separated, never quoted. */
|
||||
export function parseStatus(raw: string): ParsedStatus {
|
||||
const parts = raw.split('\0')
|
||||
let branch = 'HEAD'
|
||||
let ahead = 0
|
||||
let canPush = false
|
||||
const files: StatusEntry[] = []
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const p = parts[i]
|
||||
if (!p) continue
|
||||
if (p.startsWith('## ')) { branch = parseBranch(p.slice(3)); continue }
|
||||
if (p.startsWith('## ')) {
|
||||
const header = p.slice(3)
|
||||
branch = parseBranch(header)
|
||||
;({ ahead, canPush } = parseAhead(header))
|
||||
continue
|
||||
}
|
||||
const index = p[0]
|
||||
const working = p[1]
|
||||
const path = p.slice(3) // skip "XY "
|
||||
@@ -146,7 +170,7 @@ export function parseStatus(raw: string): ParsedStatus {
|
||||
if (index === 'R' || index === 'C' || working === 'R' || working === 'C') i++
|
||||
files.push({ index, working, path })
|
||||
}
|
||||
return { branch, files }
|
||||
return { branch, ahead, canPush, files }
|
||||
}
|
||||
|
||||
async function headText(root: string, path: string): Promise<string> {
|
||||
@@ -239,7 +263,7 @@ async function doLoad(root: string): Promise<GitLoad | null> {
|
||||
}
|
||||
if (raw == null) return null
|
||||
|
||||
const { branch, files } = parseStatus(raw)
|
||||
const { branch, ahead, canPush, files } = parseStatus(raw)
|
||||
// Each changed file needs its HEAD blob (a `git show` spawn) + its disk text.
|
||||
// Done serially this is O(files) subprocess spawns in a row — staging one file
|
||||
// re-reads ALL of them, which is the dominant cost of a reload. Run them with
|
||||
@@ -271,7 +295,7 @@ async function doLoad(root: string): Promise<GitLoad | null> {
|
||||
return out
|
||||
})).flat()
|
||||
|
||||
return { branch, changes }
|
||||
return { branch, ahead, canPush, changes }
|
||||
}
|
||||
|
||||
export async function stage(root: string, paths: string[]): Promise<void> {
|
||||
|
||||
+3
-1
@@ -91,7 +91,8 @@ function gitDir(root: string): string {
|
||||
* done by external tools (Sublime Merge, the CLI, …) would never refresh the
|
||||
* git column. Watch the few git files that mark those events so the renderer
|
||||
* re-reads status: HEAD (branch switch), index (staging), refs/heads + logs
|
||||
* (commits), MERGE_HEAD (in-progress merge). */
|
||||
* (commits), refs/remotes (a push, which clears the ahead count), MERGE_HEAD
|
||||
* (in-progress merge). */
|
||||
function startGitWatcher(): void {
|
||||
if (gitWatcher) { gitWatcher.close(); gitWatcher = null }
|
||||
const root = getRoot()
|
||||
@@ -102,6 +103,7 @@ function startGitWatcher(): void {
|
||||
join(dir, 'HEAD'),
|
||||
join(dir, 'index'),
|
||||
join(dir, 'refs', 'heads'),
|
||||
join(dir, 'refs', 'remotes'),
|
||||
join(dir, 'logs', 'HEAD'),
|
||||
join(dir, 'MERGE_HEAD'),
|
||||
],
|
||||
|
||||
@@ -1037,6 +1037,7 @@ export function App(): React.ReactElement {
|
||||
const crumb = active ? active.split('/') : []
|
||||
// Whole-project line counts, shown in the status bar next to the branch.
|
||||
const totals = proj.changes.reduce((a, c) => ({ add: a.add + c.add, del: a.del + c.del }), { add: 0, del: 0 })
|
||||
const stagedCount = proj.changes.filter((c) => c.staged).length
|
||||
|
||||
// No project yet (launched via Spotlight / bare) → show the project launcher.
|
||||
if (proj.ready && !proj.root) {
|
||||
@@ -1116,7 +1117,7 @@ export function App(): React.ReactElement {
|
||||
<div className={'col' + (activePanel === 'git' ? ' panel-active' : '') + flashClass} style={{ width: gitW, flex: '0 1 ' + gitW + 'px' }}
|
||||
onMouseDownCapture={(e) => { setActivePanel('git'); syncGitSel(e.target) }}
|
||||
onMouseOver={(e) => syncGitSel(e.target)}>
|
||||
<GitPanel branch={proj.branch} changes={proj.changes} committed={NO_COMMITTED}
|
||||
<GitPanel branch={proj.branch} ahead={proj.ahead} canPush={proj.canPush} changes={proj.changes} committed={NO_COMMITTED}
|
||||
commitMsg={commitMsg} setCommitMsg={setCommitMsg}
|
||||
onStage={stageGuarded} onUnstage={unstageGuarded} onStageAll={actions.stageAll} onUnstageAll={actions.unstageAll} onCommit={commit} onPush={push}
|
||||
onOpen={openFile} onContext={openMenu} activePath={active} activeSide={activeSide} ctxPath={menu?.path ?? null}
|
||||
@@ -1165,6 +1166,7 @@ export function App(): React.ReactElement {
|
||||
<span className="sb-branch">⑂ {proj.branch}</span>
|
||||
<span className="sb-add">+{totals.add}</span>
|
||||
<span className="sb-del">−{totals.del}</span>
|
||||
<span className="sb-staged">{stagedCount} staged · {proj.changes.length - stagedCount} unstaged</span>
|
||||
<span className="sb-spacer" />
|
||||
{active && <>
|
||||
<span className="sb-path">{active}</span>
|
||||
|
||||
@@ -30,6 +30,7 @@ export const Icon: Record<string, (p?: SvgProps) => React.ReactElement> = {
|
||||
finder: (p) => (<svg width="13" height="13" viewBox="0 0 16 16" fill="none" {...p}><rect x="2" y="3.5" width="12" height="9" rx="1.5" stroke="currentColor" strokeWidth="1.2" /><path d="M9 7l3-3M12 4v2.6M12 4H9.4" stroke="currentColor" strokeWidth="1.2" fill="none" strokeLinecap="round" strokeLinejoin="round" /></svg>),
|
||||
eye: (p) => (<svg width="13" height="13" viewBox="0 0 16 16" fill="none" {...p}><path d="M1.5 8S4 3.5 8 3.5 14.5 8 14.5 8 12 12.5 8 12.5 1.5 8 1.5 8z" stroke="currentColor" strokeWidth="1.2" fill="none" /><circle cx="8" cy="8" r="2" stroke="currentColor" strokeWidth="1.2" /></svg>),
|
||||
push: (p) => (<svg width="13" height="13" viewBox="0 0 16 16" fill="none" {...p}><path d="M8 13V4M8 4 4.5 7.5M8 4l3.5 3.5M3.5 2.5h9" stroke="currentColor" strokeWidth="1.4" fill="none" strokeLinecap="round" strokeLinejoin="round" /></svg>),
|
||||
arrowUp: (p) => (<svg width="13" height="13" viewBox="0 0 16 16" fill="none" {...p}><path d="M8 13V3.5M8 3.5 4.5 7M8 3.5 11.5 7" stroke="currentColor" strokeWidth="1.4" fill="none" strokeLinecap="round" strokeLinejoin="round" /></svg>),
|
||||
}
|
||||
|
||||
/** Hover tooltip. One surface, no arrow, no shadow — the current state is named
|
||||
@@ -174,8 +175,22 @@ function GitList({ list, ...row }: {
|
||||
)
|
||||
}
|
||||
|
||||
export function GitPanel({ branch, changes, committed, commitMsg, setCommitMsg, onStage, onUnstage, onStageAll, onUnstageAll, onCommit, onPush, onOpen, onContext, activePath, activeSide, ctxPath, kbdId }: {
|
||||
/** Push button tooltip. The count is absent on a branch with no upstream. */
|
||||
function pushTitle(canPush: boolean, ahead: number): string {
|
||||
if (!canPush) return 'Nothing to push (⌘P)'
|
||||
if (ahead > 0) return `Push ${ahead} commit${ahead > 1 ? 's' : ''} to remote (⌘P)`
|
||||
return 'Push the branch to remote (⌘P)'
|
||||
}
|
||||
|
||||
/** Dictation tools capitalize and add line breaks; a commit subject is one lowercase line. */
|
||||
export function normalizeCommitMsg(value: string): string {
|
||||
return value.toLowerCase().replace(/[ \t]*[\r\n]+[ \t]*/g, ' ')
|
||||
}
|
||||
|
||||
export function GitPanel({ branch, ahead, canPush, changes, committed, commitMsg, setCommitMsg, onStage, onUnstage, onStageAll, onUnstageAll, onCommit, onPush, onOpen, onContext, activePath, activeSide, ctxPath, kbdId }: {
|
||||
branch: string
|
||||
ahead: number
|
||||
canPush: boolean
|
||||
changes: Change[]
|
||||
committed: Set<string>
|
||||
commitMsg: string
|
||||
@@ -200,14 +215,6 @@ export function GitPanel({ branch, changes, committed, commitMsg, setCommitMsg,
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<div className="commit-box">
|
||||
<textarea className="commit-input" rows={1} value={commitMsg} spellCheck={false}
|
||||
placeholder="Shift+Enter to commit"
|
||||
onChange={(e) => setCommitMsg(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' && (e.shiftKey || e.metaKey || e.ctrlKey) && canCommit) { e.preventDefault(); onCommit() } }} />
|
||||
<button className="push-btn" title="Push to remote (⌘P)" onClick={onPush}>{Icon.push()}</button>
|
||||
</div>
|
||||
|
||||
<div className="git-body">
|
||||
{visible.length === 0 ? (
|
||||
<div className="git-empty">— No changes. The working tree is clean.</div>
|
||||
@@ -242,9 +249,15 @@ export function GitPanel({ branch, changes, committed, commitMsg, setCommitMsg,
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* The line counts live in the status bar; this footer only counts files. */}
|
||||
<div className="git-foot">
|
||||
<span>{stagedList.length} staged · {changesList.length} unstaged</span>
|
||||
<div className="commit-box">
|
||||
<textarea className="commit-input" rows={1} value={commitMsg} spellCheck={false}
|
||||
placeholder="Shift+Enter to commit"
|
||||
onChange={(e) => setCommitMsg(normalizeCommitMsg(e.target.value))}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' && (e.shiftKey || e.metaKey || e.ctrlKey) && canCommit) { e.preventDefault(); onCommit() } }} />
|
||||
<div className="commit-actions">
|
||||
<button className="commit-btn" disabled={stagedList.length === 0} title="Commit the staged files (⌘↵)" onClick={onCommit}>Commit {Icon.arrowUp()}</button>
|
||||
<button className={'push-btn' + (canPush ? ' lit' : '')} title={pushTitle(canPush, ahead)} onClick={onPush}>{Icon.push()}</button>
|
||||
</div>
|
||||
</div>
|
||||
</Fragment>
|
||||
)
|
||||
|
||||
@@ -71,6 +71,8 @@ interface Band extends ChangeMark { top: number; height: number }
|
||||
/** The line box of `.ce-pre` and its top padding — the unwrapped arithmetic. */
|
||||
const LINE_H = 20
|
||||
const PAD_TOP = 10
|
||||
/** Pointer travel that still counts as a click and not as a text selection. */
|
||||
const DRAG_SLOP = 4
|
||||
|
||||
/* Editable buffer: a transparent textarea over a Prism-highlighted <pre>, with a
|
||||
* scroll-synced line-number gutter. Live highlighting while typing.
|
||||
@@ -92,14 +94,17 @@ function CodeEditor({ path, text, lang, wrap, flash, marks, onPick, onChange, on
|
||||
flash: FlashLine | null
|
||||
/** Changed lines of this file, from the HEAD-vs-disk diff. */
|
||||
marks: ChangeMark[]
|
||||
/** A click landed on a marked line: open the full-screen Diff there. */
|
||||
onPick: (line: number) => void
|
||||
/** A click landed on a marked line: open the full-screen Diff there. Null on
|
||||
* a new file, where every line is marked and the diff has no left side. */
|
||||
onPick: ((line: number) => void) | null
|
||||
onChange: (text: string) => void
|
||||
onContext: OnContext
|
||||
onSymbol: OnSymbol
|
||||
}): React.ReactElement {
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const innerRef = useRef<HTMLDivElement>(null)
|
||||
const taRef = useRef<HTMLTextAreaElement>(null)
|
||||
const downRef = useRef<{ x: number; y: number; hadSel: boolean } | null>(null)
|
||||
const gutterRef = useRef<HTMLDivElement>(null)
|
||||
const preRef = useRef<HTMLPreElement>(null)
|
||||
const tabSize = useProject().config.editor.tabSize
|
||||
@@ -159,11 +164,29 @@ function CodeEditor({ path, text, lang, wrap, flash, marks, onPick, onChange, on
|
||||
}))
|
||||
}, [marks, wrap, text, paneW])
|
||||
|
||||
/* A click on a marked line opens the full-screen Diff at that line. The click
|
||||
* lands on the textarea rather than on a row, so the line comes from the
|
||||
* bands — the same geometry that drew the tint. */
|
||||
/* A click on a marked line opens the full-screen Diff at that line. The line
|
||||
* comes from the bands, because the click lands on the textarea.
|
||||
* A drag and a click that clears a selection both end with a click event. Open
|
||||
* the Diff only for a plain click that moved almost nothing, held no selection
|
||||
* and cleared none, or the overlay steals every attempt to select code. The
|
||||
* browser collapses a selection first, thus the flag comes from mouse-down. */
|
||||
function hasSelection(): boolean {
|
||||
const ta = taRef.current
|
||||
if (ta && ta.selectionEnd !== ta.selectionStart) return true
|
||||
const sel = window.getSelection()
|
||||
return !!sel && !sel.isCollapsed
|
||||
}
|
||||
|
||||
function isPlainClick(e: React.MouseEvent): boolean {
|
||||
if (e.detail > 1 || e.shiftKey || e.altKey) return false
|
||||
const down = downRef.current
|
||||
if (down && Math.abs(e.clientX - down.x) + Math.abs(e.clientY - down.y) > DRAG_SLOP) return false
|
||||
if (down?.hadSel) return false
|
||||
return !hasSelection()
|
||||
}
|
||||
|
||||
function pickAt(e: React.MouseEvent): void {
|
||||
if (!bands.length) return
|
||||
if (!onPick || !bands.length || !isPlainClick(e)) return
|
||||
const box = innerRef.current?.getBoundingClientRect()
|
||||
if (!box) return
|
||||
const y = e.clientY - box.top
|
||||
@@ -272,7 +295,8 @@ function CodeEditor({ path, text, lang, wrap, flash, marks, onPick, onChange, on
|
||||
)}
|
||||
{/* No marks, no pick: an unchanged file pays nothing for the reveal. */}
|
||||
<div className="ce-scroll" ref={scrollRef} onScroll={onScroll}
|
||||
onClick={(e) => { if (e.metaKey || e.ctrlKey) handleTokenClick(e); else if (marks.length) pickAt(e) }}>
|
||||
onMouseDown={(e) => { downRef.current = { x: e.clientX, y: e.clientY, hadSel: hasSelection() } }}
|
||||
onClick={(e) => { if (e.metaKey || e.ctrlKey) handleTokenClick(e); else if (onPick && marks.length) pickAt(e) }}>
|
||||
<div className="ce-inner" ref={innerRef}>
|
||||
{bands.map((b) => (
|
||||
<Fragment key={b.line}>
|
||||
@@ -281,7 +305,7 @@ function CodeEditor({ path, text, lang, wrap, flash, marks, onPick, onChange, on
|
||||
</Fragment>
|
||||
))}
|
||||
{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"
|
||||
<textarea className="ce-ta" ref={taRef} value={text} spellCheck={false} autoComplete="off"
|
||||
wrap={wrap ? 'soft' : 'off'} style={{ tabSize }}
|
||||
onChange={(e) => { onChange(e.target.value); ensureCaretVisible(e.target) }}
|
||||
onKeyDown={onKeyDown}
|
||||
@@ -668,7 +692,7 @@ export function Editor({ active, mode, side, setMode, onContext, onOpenDiff, cur
|
||||
<NoOriginal />
|
||||
) : editable ? (
|
||||
<CodeEditor path={tab.path} text={bufferText} lang={lang} wrap={wrap} flash={flash} marks={marks}
|
||||
onPick={onOpenDiff} onChange={onEdit} onContext={onContext} onSymbol={onSymbol} />
|
||||
onPick={fileStatus === 'A' ? null : onOpenDiff} onChange={onEdit} onContext={onContext} onSymbol={onSymbol} />
|
||||
) : (
|
||||
built && <PaneView cacheKey={tab.path + ':' + paneMode} path={tab.path} lines={built.lines}
|
||||
lang={lang} showSign={built.showSign} wrap={wrap} cursor={cursor} selection={selection}
|
||||
|
||||
Vendored
+1
-1
@@ -38,7 +38,7 @@ interface HelderBridge {
|
||||
write: (text: string) => Promise<void>
|
||||
}
|
||||
git: {
|
||||
load: () => Promise<{ branch: string; changes: GitChangeRaw[] } | null>
|
||||
load: () => Promise<{ branch: string; ahead: number; canPush: boolean; changes: GitChangeRaw[] } | null>
|
||||
stage: (paths: string[]) => Promise<void>
|
||||
unstage: (paths: string[]) => Promise<void>
|
||||
commit: (message: string) => Promise<void>
|
||||
|
||||
@@ -15,6 +15,10 @@ export interface ProjectData {
|
||||
name: string
|
||||
root: string | null
|
||||
branch: string
|
||||
/** Commits that the remote does not have yet. 0 when the count is unknown. */
|
||||
ahead: number
|
||||
/** True when a push would send something. Lights the push button. */
|
||||
canPush: boolean
|
||||
tree: FileNode | null
|
||||
files: Record<string, string>
|
||||
/** Git rows. One file can appear twice: staged and unstaged (see Change.id). */
|
||||
@@ -98,7 +102,7 @@ function mockData(): ProjectData {
|
||||
const changes = mockChanges(staged)
|
||||
return {
|
||||
// non-null root so browser-preview shows the workbench, not the launcher
|
||||
name: MOCK.name, root: '/mock/' + MOCK.name, branch: MOCK.branch,
|
||||
name: MOCK.name, root: '/mock/' + MOCK.name, branch: MOCK.branch, ahead: 2, canPush: true,
|
||||
tree: MOCK.tree, files: MOCK.files, changes, diffs: MOCK.diffs,
|
||||
rowDiffs: mockRowDiffs(changes),
|
||||
staged, config: DEFAULT_CONFIG, isRepo: true, ready: true, recents: [],
|
||||
@@ -106,7 +110,7 @@ function mockData(): ProjectData {
|
||||
}
|
||||
|
||||
const emptyData: ProjectData = {
|
||||
name: 'Loading…', root: null, branch: '—', tree: null, files: {},
|
||||
name: 'Loading…', root: null, branch: '—', ahead: 0, canPush: false, tree: null, files: {},
|
||||
changes: [], diffs: {}, rowDiffs: {}, staged: new Set(), config: DEFAULT_CONFIG, isRepo: false, ready: false, recents: [],
|
||||
}
|
||||
|
||||
@@ -118,7 +122,7 @@ const Ctx = createContext<{ data: ProjectData; actions: ProjectActions }>({
|
||||
/** Map a git.load() result into the git-derived slice of ProjectData. Shared by
|
||||
* the full reload and the git-only fast path so the two stay in lockstep. */
|
||||
type GitLoadResult = Awaited<ReturnType<NonNullable<typeof window.helder>['git']['load']>>
|
||||
function deriveGit(git: GitLoadResult): Pick<ProjectData, 'branch' | 'changes' | 'diffs' | 'rowDiffs' | 'staged' | 'isRepo'> {
|
||||
function deriveGit(git: GitLoadResult): Pick<ProjectData, 'branch' | 'ahead' | 'canPush' | 'changes' | 'diffs' | 'rowDiffs' | 'staged' | 'isRepo'> {
|
||||
const changes: Change[] = []
|
||||
const rowDiffs: Record<string, Diff> = {}
|
||||
const diffs: Record<string, Diff> = {}
|
||||
@@ -143,7 +147,12 @@ function deriveGit(git: GitLoadResult): Pick<ProjectData, 'branch' | 'changes' |
|
||||
}
|
||||
}
|
||||
for (const [path, w] of whole) diffs[path] = makeDiff(w.status, w.head, w.disk)
|
||||
return { branch: git ? git.branch : '—', changes, diffs, rowDiffs, staged, isRepo: !!git }
|
||||
return {
|
||||
branch: git ? git.branch : '—',
|
||||
ahead: git ? git.ahead ?? 0 : 0,
|
||||
canPush: git ? !!git.canPush : false,
|
||||
changes, diffs, rowDiffs, staged, isRepo: !!git,
|
||||
}
|
||||
}
|
||||
|
||||
export function useProject(): ProjectData {
|
||||
@@ -197,7 +206,7 @@ export function ProjectProvider({ children }: { children: React.ReactNode }): Re
|
||||
setData((d) => ({
|
||||
name: cur.name, root: cur.root,
|
||||
tree, files: d.root === cur.root ? d.files : {}, config, ready: true, recents: d.recents,
|
||||
...(gitFresh ? deriveGit(git) : { branch: d.branch, changes: d.changes, diffs: d.diffs, rowDiffs: d.rowDiffs, staged: d.staged, isRepo: d.isRepo }),
|
||||
...(gitFresh ? deriveGit(git) : { branch: d.branch, ahead: d.ahead, canPush: d.canPush, changes: d.changes, diffs: d.diffs, rowDiffs: d.rowDiffs, staged: d.staged, isRepo: d.isRepo }),
|
||||
}))
|
||||
// The whole-repo content index is only a fallback (real viewing/search go
|
||||
// through fs.read + ripgrep), and reading every file serially costs seconds.
|
||||
|
||||
+17
-12
@@ -219,18 +219,24 @@ kbd { flex:0 0 auto; font-family:var(--mono); font-size:12px; font-weight:600; c
|
||||
.phead .ico-btn:hover { background:var(--hover); color:var(--fg-1); }
|
||||
|
||||
/* ============ git panel ============ */
|
||||
/* One line at rest, so this bar matches the Explorer head and the view strip.
|
||||
Focus grows the box downward inside the git column only. */
|
||||
.commit-box { height:var(--bar-h); flex:0 0 auto; padding:0 12px; border-bottom:1px solid var(--border); display:flex; gap:8px; align-items:center; }
|
||||
.commit-box:focus-within { height:auto; padding:8px 12px; align-items:flex-start; }
|
||||
.commit-input { flex:1; min-width:0; resize:none; background:var(--bg-0); border:1px solid var(--border-2); border-radius:var(--r-sm); color:var(--fg-1); font-family:var(--ui); font-size:12px; line-height:1.5; padding:3px 9px; outline:none; height:26px; }
|
||||
/* Focus is the same everywhere: 2px amber border plus the 22% ring. */
|
||||
.commit-input:focus { border-color:var(--accent); border-width:2px; padding:6px 8px; box-shadow:0 0 0 2px var(--accent-ring); height:72px; }
|
||||
/* The commit box sits at the foot of the column: the message over the action
|
||||
row, both edge to edge. The message keeps one height, so focus never moves
|
||||
the file list. The file counts live in the status bar. */
|
||||
.commit-box { flex:0 0 auto; display:flex; flex-direction:column; align-items:stretch; }
|
||||
.commit-input { width:100%; height:72px; box-sizing:border-box; resize:none; background:var(--bg-0); border:0; border-top:1px solid var(--border); border-radius:0; color:var(--fg-1); font-family:var(--ui); font-size:12px; line-height:1.5; padding:9px 12px; outline:none; }
|
||||
/* Focus is the same everywhere: 2px amber border plus the 22% ring. The
|
||||
padding drops 2px against the border, so the text does not move. */
|
||||
.commit-input:focus { border:2px solid var(--accent); padding:8px 10px; box-shadow:inset 0 0 0 2px var(--accent-ring); }
|
||||
.commit-input::placeholder { color:var(--fg-3); }
|
||||
.push-btn { flex:0 0 26px; display:flex; align-items:center; justify-content:center; width:26px; height:26px; background:transparent; border:1px solid var(--border-2); border-radius:var(--r-sm); color:var(--fg-2); cursor:pointer; }
|
||||
.commit-actions { display:flex; align-items:stretch; gap:0; height:32px; }
|
||||
.push-btn { flex:0 0 36px; display:flex; align-items:center; justify-content:center; background:var(--bg-2); border:0; border-top:1px solid var(--border); border-left:1px solid var(--border); border-radius:0; color:var(--fg-2); cursor:pointer; }
|
||||
.push-btn:hover { background:var(--hover); color:var(--accent); }
|
||||
.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:#f6b35f; }
|
||||
/* Lit: the branch is ahead of its remote. Amber marks the pending action,
|
||||
without a second filled block next to the amber Commit button. */
|
||||
.push-btn.lit { color:var(--accent); background:var(--accent-soft); }
|
||||
.push-btn.lit:hover { background:var(--accent-soft); color:var(--accent-lite); }
|
||||
.commit-btn { flex:1; min-width:0; display:flex; align-items:center; justify-content:center; gap:6px; background:var(--accent); color:#201608; border:0; border-radius:0; font-family:var(--ui); font-size:12px; font-weight:600; padding:0 11px; cursor:pointer; }
|
||||
.commit-btn:hover:not(:disabled) { background:var(--accent-lite); }
|
||||
.commit-btn:disabled { background:var(--bg-3); color:var(--fg-3); cursor:not-allowed; }
|
||||
.git-body { overflow:auto; flex:1; padding:0 0 8px; }
|
||||
/* Group header: the standard 10px mono label, count right in amber. */
|
||||
@@ -293,8 +299,6 @@ kbd { flex:0 0 auto; font-family:var(--mono); font-size:12px; font-weight:600; c
|
||||
.git-act.push { margin-left:auto; } /* right-align when the dir column is hidden */
|
||||
.git-row:hover .git-act { visibility:visible; }
|
||||
.git-act:hover { color:var(--accent); }
|
||||
.git-foot { border-top:1px solid var(--border); padding:12px; display:flex; align-items:center; gap:8px;
|
||||
font-family:var(--mono); font-size:11px; line-height:1.5; color:var(--fg-3); }
|
||||
.branch-chip { display:flex; align-items:center; gap:6px; color:var(--fg-3); }
|
||||
.branch-chip svg { display:none; }
|
||||
.branch-chip b { font-weight:400; color:var(--fg-3); }
|
||||
@@ -744,6 +748,7 @@ kbd { flex:0 0 auto; font-family:var(--mono); font-size:12px; font-weight:600; c
|
||||
.statusbar .sb-branch { color:var(--accent); }
|
||||
.statusbar .sb-add { color:var(--add); }
|
||||
.statusbar .sb-del { color:var(--del); }
|
||||
.statusbar .sb-staged { color:var(--fg-3); }
|
||||
.statusbar .sb-spacer { flex:1; }
|
||||
.statusbar .sb-path { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; max-width:52%; }
|
||||
.statusbar .sb-lang { color:var(--fg-2); }
|
||||
|
||||
@@ -10,6 +10,7 @@ vi.mock('../src/renderer/src/terminals', () => {
|
||||
|
||||
import { App } from '../src/renderer/src/App'
|
||||
import { ProjectProvider } from '../src/renderer/src/project'
|
||||
import { normalizeCommitMsg } from '../src/renderer/src/components'
|
||||
|
||||
beforeAll(() => {
|
||||
globalThis.ResizeObserver = class { observe() {} unobserve() {} disconnect() {} } as never
|
||||
@@ -78,7 +79,7 @@ describe('Pass on to Agent', () => {
|
||||
})
|
||||
|
||||
describe('Stage + commit', () => {
|
||||
it('stages a file and commits via Shift+Enter (no commit button)', async () => {
|
||||
it('stages a file and commits via Shift+Enter', async () => {
|
||||
const c = renderApp()
|
||||
const row = await waitFor(() => {
|
||||
const r = find(c, '.git-row', 'UserController.php')
|
||||
@@ -86,13 +87,26 @@ describe('Stage + commit', () => {
|
||||
return r
|
||||
})
|
||||
fireEvent.click(row.querySelector<HTMLButtonElement>('button[title="Stage changes"]')!)
|
||||
// the commit button is intentionally gone — committing is keyboard-only
|
||||
expect(c.querySelector('.commit-btn')).toBeNull()
|
||||
const msg = c.querySelector<HTMLTextAreaElement>('.commit-input')!
|
||||
fireEvent.change(msg, { target: { value: 'wire up balance' } })
|
||||
fireEvent.keyDown(msg, { key: 'Enter', shiftKey: true })
|
||||
await waitFor(() => expect(find(c, '.toast', 'Committed')).toBeTruthy())
|
||||
})
|
||||
|
||||
it('commits from the button once a file is staged', async () => {
|
||||
const c = renderApp()
|
||||
const row = await waitFor(() => {
|
||||
const r = find(c, '.git-row', 'UserController.php')
|
||||
if (!r) throw new Error('git not ready')
|
||||
return r
|
||||
})
|
||||
fireEvent.click(row.querySelector<HTMLButtonElement>('button[title="Stage changes"]')!)
|
||||
await waitFor(() => expect(c.querySelector<HTMLButtonElement>('.commit-btn')!.disabled).toBe(false))
|
||||
const msg = c.querySelector<HTMLTextAreaElement>('.commit-input')!
|
||||
fireEvent.change(msg, { target: { value: 'wire up balance' } })
|
||||
fireEvent.click(c.querySelector<HTMLButtonElement>('.commit-btn')!)
|
||||
await waitFor(() => expect(find(c, '.toast', 'Committed')).toBeTruthy())
|
||||
})
|
||||
})
|
||||
|
||||
describe('Explorer background menu', () => {
|
||||
@@ -126,3 +140,9 @@ describe('Explorer background menu', () => {
|
||||
expect(find(c, '.ctx-item', 'Copy file name')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Commit message normalization', () => {
|
||||
it('lowercases the text and folds the line breaks into one line', () => {
|
||||
expect(normalizeCommitMsg('Fix Login\nAnd Logout\r\n Flow')).toBe('fix login and logout flow')
|
||||
})
|
||||
})
|
||||
|
||||
+19
-1
@@ -5,7 +5,25 @@ import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { simpleGit } from 'simple-git'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { classify, discard, load, stage } from '../src/main/git-service'
|
||||
import { classify, discard, load, parseAhead, stage } from '../src/main/git-service'
|
||||
|
||||
describe('parseAhead', () => {
|
||||
it('reads the ahead count from the porcelain header', () => {
|
||||
expect(parseAhead('main...origin/main [ahead 3]')).toEqual({ ahead: 3, canPush: true })
|
||||
expect(parseAhead('main...origin/main [ahead 1, behind 2]')).toEqual({ ahead: 1, canPush: true })
|
||||
})
|
||||
it('reports nothing to push on a branch in sync', () => {
|
||||
expect(parseAhead('main...origin/main')).toEqual({ ahead: 0, canPush: false })
|
||||
expect(parseAhead('main...origin/main [behind 2]')).toEqual({ ahead: 0, canPush: false })
|
||||
})
|
||||
it('treats a branch without an upstream as pushable, count unknown', () => {
|
||||
expect(parseAhead('feature/x')).toEqual({ ahead: 0, canPush: true })
|
||||
})
|
||||
it('reports nothing to push on an empty repo or a detached HEAD', () => {
|
||||
expect(parseAhead('No commits yet on main')).toEqual({ ahead: 0, canPush: false })
|
||||
expect(parseAhead('HEAD (no branch)')).toEqual({ ahead: 0, canPush: false })
|
||||
})
|
||||
})
|
||||
|
||||
describe('classify', () => {
|
||||
it('reads the index code as staged, working code as unstaged', () => {
|
||||
|
||||
+2
-2
@@ -41,7 +41,7 @@ function stubBridge(): void {
|
||||
write: async (t: string) => { stored = t; writes.push(t) },
|
||||
},
|
||||
git: {
|
||||
load: async () => ({ branch: 'main', changes: [] }),
|
||||
load: async () => ({ branch: 'main', ahead: 0, canPush: false, changes: [] }),
|
||||
stage: async () => {}, unstage: async () => {}, commit: async () => {},
|
||||
push: async () => ({ ok: true, message: '' }), discard: async () => {},
|
||||
},
|
||||
@@ -68,7 +68,7 @@ afterEach(() => {
|
||||
|
||||
async function boot(): Promise<HTMLElement> {
|
||||
const c = render(<ProjectProvider><App /></ProjectProvider>).container
|
||||
await waitFor(() => { if (!c.querySelector('.git-foot')) throw new Error('not ready') })
|
||||
await waitFor(() => { if (!c.querySelector('.commit-box')) throw new Error('not ready') })
|
||||
return c
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -40,7 +40,7 @@ export function installBridge(rows: StubRow[], files: Record<string, string>): v
|
||||
shell: { reveal: noop },
|
||||
notes: { read: async () => '', write: async () => {} },
|
||||
git: {
|
||||
load: async () => ({ branch: 'main', changes: rows }),
|
||||
load: async () => ({ branch: 'main', ahead: 0, canPush: false, changes: rows }),
|
||||
stage: async () => {}, unstage: async () => {}, commit: async () => {},
|
||||
push: async () => ({ ok: true, message: '' }), discard: async () => {},
|
||||
},
|
||||
|
||||
@@ -38,7 +38,7 @@ function stubBridge(): void {
|
||||
shell: { reveal: noop },
|
||||
notes: { read: async () => '', write: async () => {} },
|
||||
git: {
|
||||
load: async () => ({ branch: 'main', changes: [] }),
|
||||
load: async () => ({ branch: 'main', ahead: 0, canPush: false, changes: [] }),
|
||||
stage: async () => {}, unstage: async () => {}, commit: async () => {},
|
||||
push: async () => ({ ok: true, message: '' }), discard: async () => {},
|
||||
},
|
||||
@@ -69,7 +69,7 @@ afterEach(() => {
|
||||
|
||||
async function boot(): Promise<HTMLElement> {
|
||||
const c = render(<ProjectProvider><App /></ProjectProvider>).container
|
||||
await waitFor(() => { if (!c.querySelector('.git-foot')) throw new Error('not ready') })
|
||||
await waitFor(() => { if (!c.querySelector('.commit-box')) throw new Error('not ready') })
|
||||
return c
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user