feat(cockpit): quick wins — sync chip, cost budget guard, digest, recent commits (W3)
Four small, high-delight features that turn passive capture into glanceable signals.
- Sync chip on project cards: ahead/behind vs upstream + last-commit time, folded
into the existing concurrent per-repo metadata pass (git rev-list --count
--left-right @{u}...HEAD + git log -1 --format=%ct; no upstream → undefined, no route).
- Cost budget guard: COST_BUDGET_USD env (0 = off); a per-session one-shot latch
(Session.budgetNotified, cost is monotonic so never re-armed) fires a single push
on threshold crossing in manager.handleStatusLine; the already-broadcast telemetry
frame carries the warn (tg-cost-warn styling derived from costUsd>=budget via
/config/ui — no new ServerMessage). web-push title added to sw-push.js.
- "While you were away" digest: GET /digest?since= → {finished, needsInput, stuck,
totalCostUsd, sessions[]} aggregate over manager.list(); FE banner on reconnect.
- Recent commits per project: src/http/git-log.ts (NUL-delimited git log → CommitInfo[]),
GET /projects/log?path= (isValidGitDir), textContent-inert render in project detail.
All git via execFile (no shell) + validated cwd; new routes read-only; commit
messages rendered via textContent. Verified: typecheck + build:web clean, 1904 pass
at --test-timeout=30000 (two default-5s failures are slow-sandbox real-subprocess
timeout flakes — the known ring-buffer test + a new real-git-clone sync test — not
logic regressions).
This commit is contained in:
156
public/digest.ts
Normal file
156
public/digest.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* public/digest.ts (W3 quick-wins c) — "while you were away" reconnect banner.
|
||||
*
|
||||
* On (re)connect, fetch GET /digest?since=<last-seen> and, if anything happened
|
||||
* while away (finished / waiting / stuck), show ONE compact dismissible banner.
|
||||
* The last-seen watermark is stored per-device in localStorage and advanced to
|
||||
* the digest's generatedAt after each render so it never re-nags for old news.
|
||||
*
|
||||
* Best-effort: any fetch/parse failure → no banner (never throws). All text is
|
||||
* set via textContent (SEC-H5) — session titles are attacker-influenced.
|
||||
*/
|
||||
|
||||
import type { DigestResult } from '../src/types.js'
|
||||
|
||||
const LAST_SEEN_KEY = 'web-terminal:digest-last-seen'
|
||||
|
||||
/* ── DOM helper ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
/* ── last-seen watermark (per-device) ────────────────────────────────────────── */
|
||||
|
||||
/** Read the stored last-seen epoch-ms, or 0 (everything is new). Never throws. */
|
||||
export function getLastSeen(): number {
|
||||
try {
|
||||
const raw = localStorage.getItem(LAST_SEEN_KEY)
|
||||
if (raw === null) return 0
|
||||
const n = Number(raw)
|
||||
return Number.isFinite(n) && n >= 0 ? n : 0
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/** Persist the last-seen epoch-ms watermark. Best-effort. */
|
||||
export function setLastSeen(ms: number): void {
|
||||
try {
|
||||
localStorage.setItem(LAST_SEEN_KEY, String(Math.floor(ms)))
|
||||
} catch {
|
||||
// storage unavailable (private mode) — the banner just re-shows next time
|
||||
}
|
||||
}
|
||||
|
||||
/* ── normalize (never trust the API shape) ───────────────────────────────────── */
|
||||
|
||||
function num(o: Record<string, unknown>, key: string): number {
|
||||
const v = o[key]
|
||||
return typeof v === 'number' && Number.isFinite(v) ? v : 0
|
||||
}
|
||||
|
||||
/** Coerce an untrusted GET /digest response into a DigestResult, or null. */
|
||||
export function normalizeDigest(raw: unknown): DigestResult | null {
|
||||
if (raw === null || typeof raw !== 'object') return null
|
||||
const o = raw as Record<string, unknown>
|
||||
if (typeof o['generatedAt'] !== 'number' || !Number.isFinite(o['generatedAt'])) return null
|
||||
return {
|
||||
since: num(o, 'since'),
|
||||
generatedAt: o['generatedAt'],
|
||||
total: num(o, 'total'),
|
||||
finished: num(o, 'finished'),
|
||||
needsInput: num(o, 'needsInput'),
|
||||
stuck: num(o, 'stuck'),
|
||||
working: num(o, 'working'),
|
||||
totalCostUsd: num(o, 'totalCostUsd'),
|
||||
sessions: Array.isArray(o['sessions']) ? (o['sessions'] as DigestResult['sessions']) : [],
|
||||
}
|
||||
}
|
||||
|
||||
/* ── fetch ───────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** Fetch the digest since `since`. null on any error (best-effort). */
|
||||
export async function fetchDigest(since: number): Promise<DigestResult | null> {
|
||||
try {
|
||||
if (typeof fetch === 'undefined') return null
|
||||
const res = await fetch(`/digest?since=${encodeURIComponent(String(since))}`)
|
||||
if (!res.ok) return null
|
||||
return normalizeDigest(await res.json())
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/* ── render (pure) ───────────────────────────────────────────────────────────── */
|
||||
|
||||
/** Count of things worth surfacing (finished / waiting / stuck). */
|
||||
export function digestHighlightCount(d: DigestResult): number {
|
||||
return d.finished + d.needsInput + d.stuck
|
||||
}
|
||||
|
||||
/** Compact human summary, e.g. "2 finished · 1 waiting · 1 stuck". */
|
||||
export function digestSummary(d: DigestResult): string {
|
||||
const parts: string[] = []
|
||||
if (d.finished > 0) parts.push(`${d.finished} finished`)
|
||||
if (d.needsInput > 0) parts.push(`${d.needsInput} waiting for input`)
|
||||
if (d.stuck > 0) parts.push(`${d.stuck} stuck`)
|
||||
return parts.join(' · ')
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the banner element for a digest, or null when nothing is worth showing.
|
||||
* `onDismiss` is wired to the × button. All text via textContent (SEC-H5).
|
||||
*/
|
||||
export function renderDigestBanner(d: DigestResult, onDismiss: () => void): HTMLElement | null {
|
||||
if (digestHighlightCount(d) === 0) return null
|
||||
|
||||
const banner = el('div', 'wya-banner')
|
||||
banner.setAttribute('role', 'status')
|
||||
banner.append(el('span', 'wya-title', 'While you were away'))
|
||||
banner.append(el('span', 'wya-summary', digestSummary(d)))
|
||||
if (d.totalCostUsd > 0) {
|
||||
banner.append(el('span', 'wya-cost', `$${d.totalCostUsd.toFixed(2)} total`))
|
||||
}
|
||||
|
||||
const dismiss = el('button', 'wya-dismiss', '✕')
|
||||
dismiss.title = 'Dismiss'
|
||||
dismiss.setAttribute('aria-label', 'Dismiss')
|
||||
dismiss.addEventListener('click', onDismiss)
|
||||
banner.append(dismiss)
|
||||
|
||||
return banner
|
||||
}
|
||||
|
||||
/* ── mount ───────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Fetch the digest since the stored last-seen and, if anything happened, prepend
|
||||
* a dismissible banner to `host`. Advances the last-seen watermark to the
|
||||
* digest's generatedAt so it doesn't re-nag on the next reconnect. Best-effort:
|
||||
* a fetch failure shows no banner. Returns the banner element (or null).
|
||||
*/
|
||||
export async function mountDigest(host: HTMLElement): Promise<HTMLElement | null> {
|
||||
const since = getLastSeen()
|
||||
const d = await fetchDigest(since)
|
||||
if (d === null) return null // best-effort — no banner on failure
|
||||
|
||||
// Advance the watermark now so a refresh (without a dismiss) doesn't re-nag.
|
||||
setLastSeen(d.generatedAt)
|
||||
|
||||
const banner = renderDigestBanner(d, () => {
|
||||
setLastSeen(d.generatedAt)
|
||||
banner?.remove()
|
||||
})
|
||||
if (banner === null) return null
|
||||
|
||||
host.prepend(banner)
|
||||
return banner
|
||||
}
|
||||
135
public/git-log.ts
Normal file
135
public/git-log.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* public/git-log.ts (W3 quick-wins d) — render-only recent-commit list.
|
||||
*
|
||||
* Fetches GET /projects/log for a repo and renders each commit as an inert row.
|
||||
* Zero parsing lives here (parsing is in src/http/git-log.ts). It NEVER throws
|
||||
* and degrades to a short inert message on any failure.
|
||||
*
|
||||
* Security: SEC-H5 — ALL text is set via textContent / el(). Zero innerHTML. A
|
||||
* commit subject is attacker-influenced (anyone who can push to a repo the host
|
||||
* can read), so it appears strictly as literal text.
|
||||
*/
|
||||
|
||||
import type { CommitLogEntry, GitLogResult } from '../src/types.js'
|
||||
|
||||
/* ── 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
|
||||
}
|
||||
|
||||
/* ── normalize (never trust the API shape) ───────────────────────────────────── */
|
||||
|
||||
/** Coerce one untrusted /projects/log element into a safe CommitLogEntry, or null. */
|
||||
function normalizeCommit(raw: unknown): CommitLogEntry | null {
|
||||
if (raw === null || typeof raw !== 'object') return null
|
||||
const o = raw as Record<string, unknown>
|
||||
if (typeof o['hash'] !== 'string' || typeof o['subject'] !== 'string') return null
|
||||
if (typeof o['at'] !== 'number' || !Number.isFinite(o['at'])) return null
|
||||
return { hash: o['hash'], at: o['at'], subject: o['subject'] }
|
||||
}
|
||||
|
||||
/** Coerce an untrusted GET /projects/log response into a GitLogResult, or null. */
|
||||
export function normalizeGitLog(raw: unknown): GitLogResult | null {
|
||||
if (raw === null || typeof raw !== 'object') return null
|
||||
const o = raw as Record<string, unknown>
|
||||
if (!Array.isArray(o['commits'])) return null
|
||||
const commits = o['commits']
|
||||
.map(normalizeCommit)
|
||||
.filter((c): c is CommitLogEntry => c !== null)
|
||||
return { commits, truncated: o['truncated'] === true }
|
||||
}
|
||||
|
||||
/* ── fetch ───────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** Fetch the recent-commit log for a repo path. null on any error (best-effort). */
|
||||
export async function fetchGitLog(repoPath: string): Promise<GitLogResult | null> {
|
||||
try {
|
||||
if (typeof fetch === 'undefined') return null
|
||||
const res = await fetch(`/projects/log?path=${encodeURIComponent(repoPath)}`)
|
||||
if (!res.ok) return null
|
||||
return normalizeGitLog(await res.json())
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/* ── render ──────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** Coarse "Ns / Nm / Nh / Nd ago" formatter (local copy — avoids importing xterm). */
|
||||
function relTime(ms: number): string {
|
||||
const s = Math.max(0, (Date.now() - ms) / 1000)
|
||||
if (s < 60) return `${Math.floor(s)}s`
|
||||
if (s < 3600) return `${Math.floor(s / 60)}m`
|
||||
if (s < 86400) return `${Math.floor(s / 3600)}h`
|
||||
return `${Math.floor(s / 86400)}d`
|
||||
}
|
||||
|
||||
/** One commit row: short hash · relative time · subject (all inert text). */
|
||||
function renderCommitRow(c: CommitLogEntry): HTMLElement {
|
||||
const row = el('div', 'proj-commit-row')
|
||||
row.append(el('span', 'proj-commit-hash', c.hash))
|
||||
row.append(el('span', 'proj-commit-time', `${relTime(c.at)} ago`))
|
||||
row.append(el('span', 'proj-commit-subject', c.subject)) // attacker-influenced → textContent
|
||||
return row
|
||||
}
|
||||
|
||||
/** Render a GitLogResult into a container (clears first). Empty → an inert note. */
|
||||
export function renderGitLog(container: HTMLElement, log: GitLogResult): void {
|
||||
container.textContent = ''
|
||||
if (log.commits.length === 0) {
|
||||
container.append(el('div', 'proj-empty', 'No commits yet.'))
|
||||
return
|
||||
}
|
||||
const list = el('div', 'proj-commitlog-list')
|
||||
for (const c of log.commits) list.append(renderCommitRow(c))
|
||||
container.append(list)
|
||||
if (log.truncated) {
|
||||
container.append(el('div', 'proj-commit-more', `Showing the latest ${log.commits.length} commits.`))
|
||||
}
|
||||
}
|
||||
|
||||
/* ── mount ───────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** Handle returned by mountGitLog for cleanup. */
|
||||
export interface GitLogHandle {
|
||||
destroy(): void
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount a recent-commit list into `container`: show a loading placeholder, fetch
|
||||
* the log, then swap in the rows. A fetch failure degrades to a short inert
|
||||
* message (never throws). destroy() removes the node and cancels the swap.
|
||||
*/
|
||||
export function mountGitLog(container: HTMLElement, repoPath: string): GitLogHandle {
|
||||
let destroyed = false
|
||||
|
||||
container.textContent = ''
|
||||
container.append(el('div', 'proj-commitlog-loading', 'Loading commits…'))
|
||||
|
||||
void (async () => {
|
||||
const log = await fetchGitLog(repoPath)
|
||||
if (destroyed) return
|
||||
if (log === null) {
|
||||
container.textContent = ''
|
||||
container.append(el('div', 'proj-empty', 'Could not read recent commits.'))
|
||||
return
|
||||
}
|
||||
renderGitLog(container, log)
|
||||
})()
|
||||
|
||||
return {
|
||||
destroy() {
|
||||
destroyed = true
|
||||
container.textContent = ''
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import { mountShortcuts } from './shortcuts.js'
|
||||
import { mountShareSession } from './share.js'
|
||||
import { mountGridToggle, matchFocusCycleKey } from './grid-layout.js'
|
||||
import { mountGridPresets } from './grid-presets.js'
|
||||
import { mountDigest } from './digest.js'
|
||||
|
||||
const paneHost = document.getElementById('term')
|
||||
const tabs = document.getElementById('tabs')
|
||||
@@ -118,6 +119,11 @@ mountGridPresets(toolbar, {
|
||||
|
||||
mountQrConnect(toolbar)
|
||||
|
||||
// W3(c): "while you were away" reconnect digest — one compact dismissible banner
|
||||
// summarising what finished / needs input / got stuck since this device's last
|
||||
// visit. Best-effort (no banner on fetch failure); advances its own last-seen.
|
||||
void mountDigest(document.body)
|
||||
|
||||
// PWA: register the service worker (installable + offline shell, M4).
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', () => {
|
||||
|
||||
@@ -188,7 +188,9 @@ export async function fetchLiveSessions(): Promise<LiveSessionInfo[]> {
|
||||
*
|
||||
* Shows: context-usage bar (>80% = warning colour), $cost chip, model chip,
|
||||
* and a PR badge. When `telemetry.at` is older than `staleTtlMs` the container
|
||||
* receives the class `tg-stale` so CSS can grey it out.
|
||||
* receives the class `tg-stale` so CSS can grey it out. When `costBudgetUsd` is
|
||||
* set (>0) and `costUsd >= costBudgetUsd`, the cost chip gets `tg-cost-warn`
|
||||
* (W3 quick-wins b), mirroring the ctx>80% warn path.
|
||||
*
|
||||
* Security: all telemetry strings are set via `textContent` (SEC-H5); the PR
|
||||
* link href is only set when `url.protocol === 'https:'` (SEC-L5).
|
||||
@@ -198,6 +200,7 @@ export function renderTelemetryGauge(
|
||||
container: HTMLElement,
|
||||
telemetry: StatusTelemetry | null,
|
||||
staleTtlMs: number,
|
||||
costBudgetUsd?: number,
|
||||
): void {
|
||||
// Clear existing children
|
||||
while (container.firstChild) container.removeChild(container.firstChild)
|
||||
@@ -221,9 +224,13 @@ export function renderTelemetryGauge(
|
||||
container.append(bar)
|
||||
}
|
||||
|
||||
// Cost chip
|
||||
// Cost chip — W3(b): warn-styled once cost crosses the configured budget.
|
||||
if (telemetry.costUsd !== undefined) {
|
||||
container.append(el('span', 'tg-cost', `$${telemetry.costUsd.toFixed(4)}`))
|
||||
const cost = el('span', 'tg-cost', `$${telemetry.costUsd.toFixed(4)}`)
|
||||
if (costBudgetUsd !== undefined && costBudgetUsd > 0 && telemetry.costUsd >= costBudgetUsd) {
|
||||
cost.classList.add('tg-cost-warn')
|
||||
}
|
||||
container.append(cost)
|
||||
}
|
||||
|
||||
// Model chip
|
||||
|
||||
Binary file not shown.
107
public/style.css
107
public/style.css
@@ -1534,6 +1534,113 @@ body {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
/* W3(a): ahead/behind sync chip (mirrors the .proj-branch chip look). */
|
||||
.proj-sync {
|
||||
font-size: 11px;
|
||||
color: var(--amber);
|
||||
background: var(--accent-soft);
|
||||
border-radius: 5px;
|
||||
padding: 2px 7px;
|
||||
white-space: nowrap;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
/* W3(b): cost chip in the per-tab telemetry gauge, warn-styled over budget. */
|
||||
.tg-cost-warn {
|
||||
color: var(--red);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* W3(d): recent-commit list in the project detail. */
|
||||
.proj-commitlog {
|
||||
margin: 4px 0 10px;
|
||||
}
|
||||
.proj-commitlog-loading {
|
||||
font-size: 12px;
|
||||
color: var(--text-faint);
|
||||
}
|
||||
.proj-commitlog-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.proj-commit-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
.proj-commit-hash {
|
||||
font-family: Menlo, Consolas, monospace;
|
||||
color: var(--accent);
|
||||
flex: none;
|
||||
}
|
||||
.proj-commit-time {
|
||||
color: var(--text-faint);
|
||||
flex: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.proj-commit-subject {
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
min-width: 0;
|
||||
}
|
||||
.proj-commit-more {
|
||||
font-size: 11px;
|
||||
color: var(--text-faint);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* W3(c): "while you were away" reconnect banner (compact, dismissible top bar). */
|
||||
.wya-banner {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 14px;
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
background: var(--accent-soft);
|
||||
border-bottom: 1px solid var(--accent);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
.wya-title {
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
flex: none;
|
||||
}
|
||||
.wya-summary {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.wya-cost {
|
||||
color: var(--text-faint);
|
||||
flex: none;
|
||||
}
|
||||
.wya-dismiss {
|
||||
flex: none;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-faint);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
padding: 2px 6px;
|
||||
}
|
||||
.wya-dismiss:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* W3: PR + CI status chip (mirrors the .proj-branch chip look). */
|
||||
.proj-pr-host {
|
||||
display: inline-flex;
|
||||
|
||||
@@ -16,6 +16,7 @@ const TITLES = {
|
||||
'needs-input': 'Approval Needed',
|
||||
done: 'Task Complete',
|
||||
stuck: 'Task Stuck',
|
||||
budget: 'Cost Budget Reached',
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -141,6 +141,9 @@ export class TabApp {
|
||||
// B4: mirrors the server ALLOW_AUTO_MODE gate (from /config/ui); when false the
|
||||
// high-risk 'auto' permission mode is hidden/refused (SEC-M5).
|
||||
private allowAutoMode = false
|
||||
// W3(b): the server COST_BUDGET_USD (from /config/ui); 0 = disabled. The per-tab
|
||||
// gauge warn-styles the cost chip once costUsd >= this budget.
|
||||
private costBudgetUsd = 0
|
||||
private pushHost!: HTMLElement // A1: 🔔 host, mounted once, re-parented per rebuild
|
||||
private timelinePanel!: HTMLElement // A4: shared timeline panel (one mounted at a time)
|
||||
private timelineOpen = false
|
||||
@@ -275,6 +278,11 @@ export class TabApp {
|
||||
) {
|
||||
this.allowAutoMode = (data as UiConfig).allowAutoMode
|
||||
}
|
||||
// W3(b): read the cost budget (optional, present only when > 0 server-side).
|
||||
const budget = (data as Record<string, unknown>)?.['costBudgetUsd']
|
||||
if (typeof budget === 'number' && Number.isFinite(budget) && budget > 0) {
|
||||
this.costBudgetUsd = budget
|
||||
}
|
||||
} catch {
|
||||
// best-effort — leave allowAutoMode false (auto hidden) on any failure
|
||||
}
|
||||
@@ -1441,7 +1449,7 @@ export class TabApp {
|
||||
queue.textContent = n > 0 ? `⧗${n}` : ''
|
||||
}
|
||||
const gauge = el.querySelector<HTMLElement>('.tab-gauge')
|
||||
if (gauge) renderTelemetryGauge(gauge, entry.session.telemetry, STATUSLINE_TTL_MS) // B2
|
||||
if (gauge) renderTelemetryGauge(gauge, entry.session.telemetry, STATUSLINE_TTL_MS, this.costBudgetUsd) // B2 + W3(b)
|
||||
}
|
||||
|
||||
/** Full rebuild — ONLY for structural changes (add/close/reorder/rename). */
|
||||
|
||||
Reference in New Issue
Block a user