The walk-away endgame — review a diff on your phone, then land it without typing
git into a mobile terminal. MVP bounded to per-file stage/unstage, commit, and
push the current branch; discard/checkout/reset deliberately deferred (nothing here
mutates working-tree file contents).
- src/http/git-ops.ts (new): stageFiles/commit/push (execFile, no shell, never
throws). Path containment: every files[] entry realpath-contained under the repo,
argv after `--`, capped at diffMaxFiles. Commit message: empty/over-5000 → 400,
single -m argv. Push: current branch only — has-upstream → plain `git push`;
no-upstream + one remote → `git push -u <remote> <branch>`; 0 remotes → 400,
≥2 → 409, detached HEAD → 400. Remote AND branch read from the repo, never the
client. NEVER --force / +refspec. GIT_TERMINAL_PROMPT=0 + ssh BatchMode fail auth
fast (401). Errors classified to fixed safe strings (raw stderr never surfaced).
- POST /projects/git/{stage,commit,push} — all behind requireAllowedOrigin +
GIT_OPS_ENABLED kill-switch + per-IP rate limits (stage/commit 30/min, push 6/min)
+ isValidGitDir. public/diff.ts: per-file Stage/Unstage + a commit/push bar
(all text via textContent; re-loads the diff on success).
Verified: typecheck + build:web clean, git-ops tests 213 pass (unit covers escape
rejection / empty+overlong commit / push argv upstream-vs-no-upstream / classified
errors), full suite green at --test-timeout=30000.
597 lines
21 KiB
TypeScript
597 lines
21 KiB
TypeScript
/**
|
|
* public/diff.ts — N-diff-ui: diff viewer (render-only).
|
|
*
|
|
* This module is RENDER-ONLY: it receives pre-parsed DiffResult/DiffFile/DiffLine
|
|
* structures from the server (GET /projects/diff) and renders them as DOM elements.
|
|
* Zero diff parsing lives here (parsing is in src/http/diff.ts, review #2).
|
|
*
|
|
* Security: SEC-H4 — ALL diff content is set via textContent / el(). Zero innerHTML
|
|
* anywhere in this file. <script>, & and ANSI escape sequences appear as plain text.
|
|
*/
|
|
|
|
import type { DiffResult, DiffFile, DiffHunk, DiffLine } from '../src/types.js'
|
|
|
|
/* ── constants ───────────────────────────────────────────────────────────────── */
|
|
|
|
const EMPTY_MESSAGE = 'No changes'
|
|
|
|
/* ── DOM helper ──────────────────────────────────────────────────────────────── */
|
|
|
|
/** Create an element with an optional CSS class and text content. */
|
|
function el<K extends keyof HTMLElementTagNameMap>(
|
|
tag: K,
|
|
cls?: string,
|
|
text?: string,
|
|
): HTMLElementTagNameMap[K] {
|
|
const node = document.createElement(tag)
|
|
if (cls) node.className = cls
|
|
if (text !== undefined) node.textContent = text
|
|
return node
|
|
}
|
|
|
|
/* ── normalizeDiffResult ─────────────────────────────────────────────────────── */
|
|
|
|
/**
|
|
* Coerce an untrusted API response into a DiffResult, or return null.
|
|
* Never throws. Filters out any file entries that are not valid objects.
|
|
*/
|
|
export function normalizeDiffResult(raw: unknown): DiffResult | null {
|
|
if (raw === null || typeof raw !== 'object') return null
|
|
const o = raw as Record<string, unknown>
|
|
|
|
if (!Array.isArray(o['files'])) return null
|
|
if (typeof o['staged'] !== 'boolean') return null
|
|
if (typeof o['truncated'] !== 'boolean') return null
|
|
|
|
const files = (o['files'] as unknown[])
|
|
.map(normalizeFile)
|
|
.filter((f): f is DiffFile => f !== null)
|
|
|
|
return {
|
|
files,
|
|
staged: o['staged'],
|
|
truncated: o['truncated'],
|
|
base: typeof o['base'] === 'string' ? o['base'] : undefined,
|
|
}
|
|
}
|
|
|
|
function normalizeFile(raw: unknown): DiffFile | null {
|
|
if (raw === null || typeof raw !== 'object') return null
|
|
const o = raw as Record<string, unknown>
|
|
|
|
if (typeof o['oldPath'] !== 'string') return null
|
|
if (typeof o['newPath'] !== 'string') return null
|
|
if (typeof o['status'] !== 'string') return null
|
|
if (typeof o['added'] !== 'number') return null
|
|
if (typeof o['removed'] !== 'number') return null
|
|
if (typeof o['binary'] !== 'boolean') return null
|
|
if (!Array.isArray(o['hunks'])) return null
|
|
|
|
const hunks = (o['hunks'] as unknown[])
|
|
.map(normalizeHunk)
|
|
.filter((h): h is DiffHunk => h !== null)
|
|
|
|
return {
|
|
oldPath: o['oldPath'],
|
|
newPath: o['newPath'],
|
|
status: o['status'] as DiffFile['status'],
|
|
added: o['added'],
|
|
removed: o['removed'],
|
|
binary: o['binary'],
|
|
hunks,
|
|
}
|
|
}
|
|
|
|
function normalizeHunk(raw: unknown): DiffHunk | null {
|
|
if (raw === null || typeof raw !== 'object') return null
|
|
const o = raw as Record<string, unknown>
|
|
|
|
if (typeof o['header'] !== 'string') return null
|
|
if (!Array.isArray(o['lines'])) return null
|
|
|
|
const lines = (o['lines'] as unknown[])
|
|
.map(normalizeLine)
|
|
.filter((l): l is DiffLine => l !== null)
|
|
|
|
return { header: o['header'], lines }
|
|
}
|
|
|
|
function normalizeLine(raw: unknown): DiffLine | null {
|
|
if (raw === null || typeof raw !== 'object') return null
|
|
const o = raw as Record<string, unknown>
|
|
|
|
if (typeof o['kind'] !== 'string') return null
|
|
if (typeof o['text'] !== 'string') return null
|
|
|
|
return { kind: o['kind'] as DiffLine['kind'], text: o['text'] }
|
|
}
|
|
|
|
/* ── fetchDiff ───────────────────────────────────────────────────────────────── */
|
|
|
|
/** Options for {@link fetchDiff}: a `base` revision (branch/tag/sha) takes
|
|
* precedence over `staged` — the server ignores `staged` when `base` is set. */
|
|
export interface FetchDiffOpts {
|
|
staged?: boolean
|
|
base?: string
|
|
}
|
|
|
|
/**
|
|
* Fetch a diff from the server for the given repo path. When `opts.base` is set
|
|
* the URL carries `&base=<rev>` (working-tree/staged are omitted); otherwise it
|
|
* carries `&staged=<bool>`. Returns null on any error or invalid response.
|
|
*/
|
|
export async function fetchDiff(repoPath: string, opts: FetchDiffOpts = {}): Promise<DiffResult | null> {
|
|
try {
|
|
let url = `/projects/diff?path=${encodeURIComponent(repoPath)}`
|
|
if (opts.base !== undefined && opts.base !== '') {
|
|
url += `&base=${encodeURIComponent(opts.base)}`
|
|
} else {
|
|
url += `&staged=${opts.staged === true}`
|
|
}
|
|
const res = await fetch(url)
|
|
if (!res.ok) return null
|
|
const data: unknown = await res.json()
|
|
return normalizeDiffResult(data)
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
/* ── W4 git write: stage / commit / push helpers ─────────────────────────────── */
|
|
|
|
/** Server response shape for the three W4 git-write routes (mirrors GitOpResult).
|
|
* Only `ok` is guaranteed; the rest are route-specific success/failure fields. */
|
|
export interface GitOpResponse {
|
|
ok: boolean
|
|
status?: number
|
|
error?: string
|
|
staged?: boolean
|
|
count?: number
|
|
commit?: string
|
|
branch?: string
|
|
remote?: string
|
|
}
|
|
|
|
/** Narrow an untrusted JSON body to a GitOpResponse (mirrors isWorktreeResult). */
|
|
export function isGitOpResult(v: unknown): v is GitOpResponse {
|
|
return v !== null && typeof v === 'object' && typeof (v as Record<string, unknown>)['ok'] === 'boolean'
|
|
}
|
|
|
|
/** POST JSON to a same-origin git-write route; returns the parsed GitOpResponse
|
|
* or null on any transport/parse error. Same-origin, so the browser attaches the
|
|
* Origin header the server's CSRF guard checks — no manual header needed. */
|
|
async function postGitOp(url: string, body: unknown): Promise<GitOpResponse | null> {
|
|
try {
|
|
const res = await fetch(url, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify(body),
|
|
})
|
|
let data: unknown
|
|
try {
|
|
data = await res.json()
|
|
} catch {
|
|
data = null
|
|
}
|
|
if (isGitOpResult(data)) return data
|
|
// Non-JSON / unexpected body (e.g. a 403/429 with no body): synthesize a
|
|
// failure carrying the HTTP status so callers can show a message.
|
|
return { ok: false, status: res.status }
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
/** Stage (`stage=true`) or unstage the given repo-relative files. */
|
|
export function postStage(
|
|
repoPath: string,
|
|
files: string[],
|
|
stage: boolean,
|
|
): Promise<GitOpResponse | null> {
|
|
return postGitOp('/projects/git/stage', { path: repoPath, files, stage })
|
|
}
|
|
|
|
/** Commit the staged changes with `message`. */
|
|
export function postCommit(repoPath: string, message: string): Promise<GitOpResponse | null> {
|
|
return postGitOp('/projects/git/commit', { path: repoPath, message })
|
|
}
|
|
|
|
/** Push the current branch to its upstream (or `-u <sole-remote> <branch>`). */
|
|
export function postPush(repoPath: string): Promise<GitOpResponse | null> {
|
|
return postGitOp('/projects/git/push', { path: repoPath })
|
|
}
|
|
|
|
/* ── renderDiffFile ──────────────────────────────────────────────────────────── */
|
|
|
|
/** CSS class prefix for diff line kinds. */
|
|
const LINE_KIND_CLASS: Record<DiffLine['kind'], string> = {
|
|
added: 'df-added',
|
|
removed: 'df-removed',
|
|
context: 'df-context',
|
|
hunk: 'df-hunk',
|
|
meta: 'df-meta',
|
|
}
|
|
|
|
/** Optional per-file stage/unstage control (W4). `staged` picks the button label
|
|
* (Unstage in the staged view, Stage otherwise); `onToggle` fires on click. */
|
|
export interface StageControl {
|
|
staged: boolean
|
|
onToggle: (file: DiffFile) => void
|
|
}
|
|
|
|
/**
|
|
* Render a single DiffFile into an HTMLElement. When `stageCtl` is provided (W4,
|
|
* working/staged views only) a Stage/Unstage button is added to the file header.
|
|
*
|
|
* Security: ALL text content is set via textContent — zero innerHTML.
|
|
* <script>, ANSI sequences, & etc. are rendered as literal characters.
|
|
*/
|
|
export function renderDiffFile(file: DiffFile, stageCtl?: StageControl): HTMLElement {
|
|
const section = el('div', 'df-file')
|
|
|
|
// ── file header ──────────────────────────────────────────────────────────
|
|
const header = el('div', 'df-file-header')
|
|
|
|
const pathEl = el('span', 'df-path')
|
|
if (file.status === 'renamed' && file.oldPath !== file.newPath) {
|
|
pathEl.textContent = `${file.oldPath} → ${file.newPath}`
|
|
} else {
|
|
pathEl.textContent = file.newPath
|
|
}
|
|
|
|
const statsEl = el('span', 'df-stats')
|
|
const addedEl = el('span', 'df-stat-added', `+${file.added}`)
|
|
const removedEl = el('span', 'df-stat-removed', `-${file.removed}`)
|
|
statsEl.append(addedEl, removedEl)
|
|
|
|
const statusEl = el('span', `df-status df-status-${file.status}`, file.status)
|
|
|
|
header.append(pathEl, statsEl, statusEl)
|
|
|
|
if (stageCtl !== undefined) {
|
|
const stageBtn = el('button', 'df-file-stage', stageCtl.staged ? 'Unstage' : 'Stage')
|
|
stageBtn.addEventListener('click', () => stageCtl.onToggle(file))
|
|
header.append(stageBtn)
|
|
}
|
|
|
|
section.append(header)
|
|
|
|
// ── binary indicator ─────────────────────────────────────────────────────
|
|
if (file.binary) {
|
|
section.append(el('div', 'df-binary', 'Binary file'))
|
|
return section
|
|
}
|
|
|
|
// ── hunks ─────────────────────────────────────────────────────────────────
|
|
for (const hunk of file.hunks) {
|
|
section.append(renderHunk(hunk))
|
|
}
|
|
|
|
return section
|
|
}
|
|
|
|
function renderHunk(hunk: DiffHunk): HTMLElement {
|
|
const hunkEl = el('div', 'df-hunk-block')
|
|
|
|
// Hunk header (e.g. "@@ -1,3 +1,4 @@")
|
|
hunkEl.append(el('div', 'df-hunk-header', hunk.header))
|
|
|
|
const linesEl = el('div', 'df-lines')
|
|
for (const line of hunk.lines) {
|
|
linesEl.append(renderLine(line))
|
|
}
|
|
hunkEl.append(linesEl)
|
|
|
|
return hunkEl
|
|
}
|
|
|
|
function renderLine(line: DiffLine): HTMLElement {
|
|
const cls = LINE_KIND_CLASS[line.kind] ?? 'df-context'
|
|
return el('div', `df-line ${cls}`, line.text)
|
|
}
|
|
|
|
/* ── renderDiff ──────────────────────────────────────────────────────────────── */
|
|
|
|
/**
|
|
* Render a full DiffResult: all files grouped, with empty state and truncated warning.
|
|
* When `onToggleStage` is provided (W4, working/staged views), each file row gets a
|
|
* Stage/Unstage button whose direction is derived from `result.staged`.
|
|
*
|
|
* Security: ALL content via textContent — zero innerHTML.
|
|
*/
|
|
export function renderDiff(
|
|
result: DiffResult,
|
|
onToggleStage?: (file: DiffFile) => void,
|
|
): HTMLElement {
|
|
const container = el('div', 'df-result')
|
|
|
|
// Truncated warning
|
|
if (result.truncated) {
|
|
container.append(
|
|
el('div', 'df-truncated-warning', 'Result truncated — diff is too large to display fully.'),
|
|
)
|
|
}
|
|
|
|
// Empty state
|
|
if (result.files.length === 0) {
|
|
container.append(el('div', 'df-empty', EMPTY_MESSAGE))
|
|
return container
|
|
}
|
|
|
|
const stageCtl: StageControl | undefined =
|
|
onToggleStage !== undefined ? { staged: result.staged, onToggle: onToggleStage } : undefined
|
|
|
|
for (const file of result.files) {
|
|
container.append(renderDiffFile(file, stageCtl))
|
|
}
|
|
|
|
return container
|
|
}
|
|
|
|
/* ── mountDiffViewer ─────────────────────────────────────────────────────────── */
|
|
|
|
/** Handle returned by mountDiffViewer for programmatic control. */
|
|
export interface DiffViewerHandle {
|
|
/** Switch to the working-tree (unstaged) diff. */
|
|
showWorking(): void
|
|
/** Switch to the staged diff. */
|
|
showStaged(): void
|
|
/** Programmatically trigger the onClose callback. */
|
|
close(): void
|
|
/** Remove all DOM content from the container (cleanup). */
|
|
destroy(): void
|
|
}
|
|
|
|
export interface MountDiffViewerOpts {
|
|
/** Called when the viewer's close button or close() is invoked. */
|
|
onClose?: () => void
|
|
/** Base revisions (branches) offered in the "compare base" picker. When
|
|
* empty/omitted no picker is rendered (backward-compatible). */
|
|
bases?: string[]
|
|
}
|
|
|
|
/**
|
|
* Mount a diff viewer into `container`.
|
|
*
|
|
* Renders a toolbar (Working / Staged toggle + Close), then fetches and renders
|
|
* the diff below it. The toggle re-fetches when switched.
|
|
*
|
|
* Security: SEC-H4 — all content via textContent/el(). Zero innerHTML.
|
|
*/
|
|
export function mountDiffViewer(
|
|
container: HTMLElement,
|
|
repoPath: string,
|
|
opts: MountDiffViewerOpts,
|
|
): DiffViewerHandle {
|
|
let destroyed = false
|
|
let staged = false
|
|
let base: string | null = null // null → working-tree/staged mode; string → base-diff
|
|
|
|
// ── skeleton ──────────────────────────────────────────────────────────────
|
|
const root = el('div', 'df-viewer')
|
|
|
|
// Toolbar
|
|
const toolbar = el('div', 'df-toolbar')
|
|
|
|
const workingBtn = el('button', 'df-tab df-tab-active', 'Working')
|
|
const stagedBtn = el('button', 'df-tab', 'Staged')
|
|
const closeBtn = el('button', 'df-close', '✕ Close')
|
|
|
|
toolbar.append(workingBtn, stagedBtn)
|
|
|
|
// Optional "compare against base" picker: Working tree + one option per base.
|
|
const bases = opts.bases ?? []
|
|
let baseSelect: HTMLSelectElement | null = null
|
|
if (bases.length > 0) {
|
|
baseSelect = document.createElement('select')
|
|
baseSelect.className = 'df-base-select'
|
|
baseSelect.setAttribute('aria-label', 'Compare against base')
|
|
const wtOpt = el('option', undefined, 'Working tree')
|
|
wtOpt.value = ''
|
|
baseSelect.append(wtOpt)
|
|
for (const b of bases) {
|
|
const opt = el('option', undefined, b) // textContent — inert (SEC-H4)
|
|
opt.value = b
|
|
baseSelect.append(opt)
|
|
}
|
|
baseSelect.addEventListener('change', () => {
|
|
const v = baseSelect?.value ?? ''
|
|
base = v === '' ? null : v
|
|
updateTabState()
|
|
void loadDiff()
|
|
})
|
|
toolbar.append(baseSelect)
|
|
}
|
|
|
|
toolbar.append(closeBtn)
|
|
root.append(toolbar)
|
|
|
|
// In base mode the Working/Staged tabs are inert (a base diff can't be staged)
|
|
// and the commit/push bar is hidden (you can only commit the working index).
|
|
function updateTabState(): void {
|
|
const inBase = base !== null
|
|
workingBtn.disabled = inBase
|
|
stagedBtn.disabled = inBase
|
|
workingBtn.classList.toggle('df-tab-disabled', inBase)
|
|
stagedBtn.classList.toggle('df-tab-disabled', inBase)
|
|
commitBar.style.display = inBase ? 'none' : ''
|
|
}
|
|
|
|
// Content area
|
|
const content = el('div', 'df-content')
|
|
root.append(content)
|
|
|
|
// ── W4 commit / push bar (working/staged mode only) ─────────────────────────
|
|
// Message textarea + Commit + Push. Hidden in base-compare mode. All rendered
|
|
// status/error text goes through textContent (SEC-H4) — zero innerHTML.
|
|
const commitBar = el('div', 'df-commitbar')
|
|
const commitMsg = el('textarea', 'df-commit-msg')
|
|
commitMsg.placeholder = 'Commit message'
|
|
commitMsg.rows = 2
|
|
const commitBtn = el('button', 'df-commit-btn', 'Commit')
|
|
commitBtn.disabled = true // enabled once the message is non-empty
|
|
const pushBtn = el('button', 'df-push-btn', 'Push')
|
|
const opStatus = el('div', 'df-op-status')
|
|
opStatus.style.display = 'none'
|
|
commitBar.append(commitMsg, commitBtn, pushBtn, opStatus)
|
|
root.append(commitBar)
|
|
|
|
container.append(root)
|
|
|
|
let busy = false
|
|
|
|
/** Reflect an in-flight git-write op: disable buttons, mark the root busy. */
|
|
function setBusy(v: boolean): void {
|
|
busy = v
|
|
root.classList.toggle('df-op-busy', v)
|
|
pushBtn.disabled = v
|
|
commitBtn.disabled = v || commitMsg.value.trim() === ''
|
|
}
|
|
|
|
/** Show a status line (error or notice) via textContent only (SEC-H4). */
|
|
function showOp(msg: string, kind: 'error' | 'notice'): void {
|
|
opStatus.textContent = msg
|
|
opStatus.className = kind === 'error' ? 'df-op-status df-op-error' : 'df-op-status df-op-notice'
|
|
opStatus.style.display = ''
|
|
}
|
|
function clearOp(): void {
|
|
opStatus.textContent = ''
|
|
opStatus.style.display = 'none'
|
|
}
|
|
|
|
/** Stage (working view) or unstage (staged view) one file, then reload. */
|
|
async function toggleStage(file: DiffFile): Promise<void> {
|
|
if (busy || destroyed) return
|
|
const files =
|
|
file.status === 'renamed' && file.oldPath !== file.newPath
|
|
? [file.oldPath, file.newPath]
|
|
: [file.newPath]
|
|
setBusy(true)
|
|
clearOp()
|
|
const r = await postStage(repoPath, files, !staged) // working → add; staged → unstage
|
|
if (destroyed) return
|
|
setBusy(false)
|
|
if (r === null || !r.ok) {
|
|
showOp(r?.error ?? 'Stage failed.', 'error')
|
|
return
|
|
}
|
|
void loadDiff()
|
|
}
|
|
|
|
/** Commit the staged changes with the textarea message, then reload. */
|
|
async function doCommit(): Promise<void> {
|
|
if (busy || destroyed) return
|
|
const message = commitMsg.value
|
|
if (message.trim() === '') return
|
|
setBusy(true)
|
|
clearOp()
|
|
const r = await postCommit(repoPath, message)
|
|
if (destroyed) return
|
|
setBusy(false)
|
|
if (r === null || !r.ok) {
|
|
showOp(r?.error ?? 'Commit failed.', 'error')
|
|
return
|
|
}
|
|
commitMsg.value = ''
|
|
commitBtn.disabled = true
|
|
showOp(r.commit !== undefined && r.commit !== '' ? `Committed ${r.commit}` : 'Committed.', 'notice')
|
|
void loadDiff()
|
|
}
|
|
|
|
/** Push the current branch; surface a safe success/error notice (no reload). */
|
|
async function doPush(): Promise<void> {
|
|
if (busy || destroyed) return
|
|
setBusy(true)
|
|
clearOp()
|
|
const r = await postPush(repoPath)
|
|
if (destroyed) return
|
|
setBusy(false)
|
|
if (r === null || !r.ok) {
|
|
showOp(r?.error ?? 'Push failed.', 'error')
|
|
return
|
|
}
|
|
const to = [r.branch, r.remote].filter((x): x is string => typeof x === 'string' && x !== '').join(' → ')
|
|
showOp(to !== '' ? `Pushed ${to}` : 'Pushed.', 'notice')
|
|
}
|
|
|
|
commitMsg.addEventListener('input', () => {
|
|
if (!busy) commitBtn.disabled = commitMsg.value.trim() === ''
|
|
})
|
|
commitBtn.addEventListener('click', () => {
|
|
void doCommit()
|
|
})
|
|
pushBtn.addEventListener('click', () => {
|
|
void doPush()
|
|
})
|
|
|
|
// ── event handlers ────────────────────────────────────────────────────────
|
|
workingBtn.addEventListener('click', () => {
|
|
if (!staged) return
|
|
staged = false
|
|
workingBtn.className = 'df-tab df-tab-active'
|
|
stagedBtn.className = 'df-tab'
|
|
void loadDiff()
|
|
})
|
|
|
|
stagedBtn.addEventListener('click', () => {
|
|
if (staged) return
|
|
staged = true
|
|
stagedBtn.className = 'df-tab df-tab-active'
|
|
workingBtn.className = 'df-tab'
|
|
void loadDiff()
|
|
})
|
|
|
|
closeBtn.addEventListener('click', () => {
|
|
opts.onClose?.()
|
|
})
|
|
|
|
// ── fetch + render ────────────────────────────────────────────────────────
|
|
async function loadDiff(): Promise<void> {
|
|
if (destroyed) return
|
|
|
|
content.textContent = 'Loading…'
|
|
|
|
const result = await fetchDiff(repoPath, base !== null ? { base } : { staged })
|
|
|
|
if (destroyed) return
|
|
|
|
content.textContent = ''
|
|
if (result === null) {
|
|
content.append(el('div', 'df-error', 'Failed to load diff.'))
|
|
} else {
|
|
// Stage toggles only in working/staged mode (never for a base-compare diff).
|
|
const onToggle = base !== null ? undefined : (file: DiffFile) => void toggleStage(file)
|
|
content.append(renderDiff(result, onToggle))
|
|
}
|
|
}
|
|
|
|
void loadDiff()
|
|
|
|
// ── handle ────────────────────────────────────────────────────────────────
|
|
return {
|
|
showWorking() {
|
|
if (staged) {
|
|
staged = false
|
|
workingBtn.className = 'df-tab df-tab-active'
|
|
stagedBtn.className = 'df-tab'
|
|
void loadDiff()
|
|
}
|
|
},
|
|
showStaged() {
|
|
if (!staged) {
|
|
staged = true
|
|
stagedBtn.className = 'df-tab df-tab-active'
|
|
workingBtn.className = 'df-tab'
|
|
void loadDiff()
|
|
}
|
|
},
|
|
close() {
|
|
opts.onClose?.()
|
|
},
|
|
destroy() {
|
|
destroyed = true
|
|
container.textContent = ''
|
|
},
|
|
}
|
|
}
|