diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e85bbb1b..7ae8fd91 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -220,7 +220,12 @@ jobs: php artisan key:generate touch database/database.sqlite php artisan migrate:fresh --force - php artisan rfa:benchmark-perf --compare=/tmp/rfa-perf-baseline.json --max-regression=5 --min-absolute-ms=10 --max-memory-regression=10 --min-absolute-memory-mb=3 --max-retained-memory-regression=10 --min-absolute-retained-memory-mb=3 + # --min-absolute-ms=15: the heaviest scenarios (e.g. review-page-100-files ~180ms) + # carry ~5-7ms of run-to-run median variance even on a quiet box, so the + # base-snapshot vs head-compare delta swings past 10ms on a contended runner. A + # 10ms floor sat inside that noise band and false-failed; 15ms clears it while + # still failing on a real regression (>15ms is >8% on the ~180ms scenario). + php artisan rfa:benchmark-perf --compare=/tmp/rfa-perf-baseline.json --max-regression=5 --min-absolute-ms=15 --max-memory-regression=10 --min-absolute-memory-mb=3 --max-retained-memory-regression=10 --min-absolute-retained-memory-mb=3 - if: github.event_name != 'pull_request' name: Report benchmark on main run: php artisan rfa:benchmark-perf diff --git a/config/theme.php b/config/theme.php index 4d23f545..99429226 100644 --- a/config/theme.php +++ b/config/theme.php @@ -42,19 +42,22 @@ // Full color values - no opacity modifier needed 'raw' => [ 'light' => [ - 'add-bg' => 'rgba(22,163,74,0.08)', - 'add-line' => 'rgba(22,163,74,0.25)', - 'del-bg' => 'rgba(220,38,38,0.08)', - 'del-line' => 'rgba(220,38,38,0.25)', + // Row fills stay light so syntax highlighting reads through; the + // change-marker stripe (add-line/del-line) carries the signal at a + // confident alpha so adds/dels segment pre-attentively. + 'add-bg' => 'rgba(22,163,74,0.10)', + 'add-line' => 'rgba(22,163,74,0.60)', + 'del-bg' => 'rgba(220,38,38,0.10)', + 'del-line' => 'rgba(220,38,38,0.60)', 'hunk-bg' => 'rgba(9,9,11,0.03)', 'hover-bg' => 'rgba(9,9,11,0.04)', 'selected-bg' => 'rgba(9,9,11,0.08)', ], 'dark' => [ - 'add-bg' => 'rgba(74,222,128,0.10)', - 'add-line' => 'rgba(74,222,128,0.30)', - 'del-bg' => 'rgba(248,113,113,0.10)', - 'del-line' => 'rgba(248,113,113,0.30)', + 'add-bg' => 'rgba(74,222,128,0.12)', + 'add-line' => 'rgba(74,222,128,0.65)', + 'del-bg' => 'rgba(248,113,113,0.12)', + 'del-line' => 'rgba(248,113,113,0.65)', 'hunk-bg' => 'rgba(250,250,250,0.04)', 'hover-bg' => 'rgba(250,250,250,0.05)', 'selected-bg' => 'rgba(250,250,250,0.10)', diff --git a/public/js/diff-file.js b/public/js/diff-file.js index f4294691..920e1055 100644 --- a/public/js/diff-file.js +++ b/public/js/diff-file.js @@ -44,6 +44,40 @@ return lines.length ? lines.join('\n').trimEnd() : null; } + // The expander to refocus after a gap expand re-renders, keyed by hunk index. + // A keyboard-activated expander loses focus when its node is replaced by the + // post-expand morph; this hands focus to the expander that now occupies the + // same gap. Returns null when the gap fully closed (the "all N hidden lines" + // target) — nothing remains to focus there, so focus is left to fall back. + function expanderToRefocus(root, gapKey) { + if (!root || gapKey === null || gapKey === undefined || gapKey === '') { + return null; + } + return root.querySelector(`[data-expand-gap="${gapKey}"]`); + } + + function createLinePoint(lineNumber, side) { + if (lineNumber == null || (side !== 'left' && side !== 'right')) return null; + const line = Number(lineNumber); + if (!Number.isFinite(line)) return null; + + return { line, side }; + } + + function areLinePointsEqual(first, second) { + if (first == null || second == null) return first === second; + + return first.line === second.line && first.side === second.side; + } + + function rowContainsLinePoint(rowSide, oldLineNumber, newLineNumber, point) { + if (point == null) return false; + if (point.side === 'left') return oldLineNumber === point.line && (rowSide === 'left' || rowSide === 'context'); + if (point.side === 'right') return newLineNumber === point.line && (rowSide === 'right' || rowSide === 'context'); + + return false; + } + function createDiffFile({ fileId, filePath, oldPath = null, status = 'modified', isReviewed, singleFile = false }) { const pending = window.__rfaPendingExpandFiles; const wantsExpand = pending && pending.has(fileId); @@ -60,8 +94,10 @@ formLine: null, formEndLine: null, formSide: 'right', + formStartPoint: null, + formEndPoint: null, formBody: '', - lastClickedLine: null, + lastClickedPoint: null, showForm: false, editingCommentId: null, escHint: false, @@ -70,7 +106,7 @@ // Line-drag state isDragging: false, - dragStartLine: null, + dragStartPoint: null, dragSide: null, // Markdown heading fold state (id -> true when collapsed) @@ -87,6 +123,37 @@ }); }, + // Hunk index of a keyboard-activated gap expander, remembered so focus + // can return to that gap after the expand re-render replaces the + // activated node. Null for mouse clicks (focus shouldn't jump) and for + // the master "full file" expander (no gap remains to focus). + _refocusExpandKey: null, + _onDiffActionCompleted: null, + + armExpandRefocus(event, gapKey) { + // Keyboard activation of a diff --git a/resources/views/components/diff/expand-control.blade.php b/resources/views/components/diff/expand-control.blade.php new file mode 100644 index 00000000..7c0b68e4 --- /dev/null +++ b/resources/views/components/diff/expand-control.blade.php @@ -0,0 +1,57 @@ +{{-- + Unified chrome for every collapsed-content affordance in a diff: the + "show full file" master expander, per-gap tiered expanders, the trailing + gap, and the (non-expandable) section-context label. + + Coherence contract: the verb ("Show") is rendered here, once, as a muted + lead-in — never inside a button. Everything blue (gh-link) is clickable and + lives in the slot: "full file", "8 hidden lines", the "15 · 50 · 100" chips. + So every expander reads `↕ Show ` with identical styling. + + Expandable bands carry the `expand-all` (↕) icon, a dashed top/bottom rule + (dashed = collapsed content lives here), and own a shared `loading` Alpine + flag so any button in the slot can swap the row to a spinner with + `@click="loading = true"`. The flag clears itself when the diff action + settles (`rfa:diff-action-completed`) rather than relying on the post-expand + morph to replace this row — so the spinner can't get stuck when expandGap + early-returns on a no-op (diff changed underneath, nothing to morph). The + section-context label passes `:icon="false"` (and `align="start"`) — no + affordance, no verb, no rule, just the context. +--}} +@props([ + 'icon' => 'expand-all', + 'align' => 'center', +]) + +@php + $hasIcon = $icon !== false && $icon !== null; +@endphp + +
class([ + 'diff-fullspan bg-gh-hunk-bg px-4 py-1.5 text-xs font-mono flex items-center gap-2', + 'border-y border-dashed border-gh-border/20' => $hasIcon, + 'justify-center select-none' => $align === 'center', + 'justify-start' => $align === 'start', + ]) }} + @if($hasIcon) + x-data="{ loading: false }" + {{-- Scope the clear to THIS file: the event is a global window dispatch, so + without the fileId guard a sibling file's expand completing would clear + this row's in-flight spinner. String() both sides — the dispatch casts + fileId to string while the Alpine fileId may be numeric (see diff-file.js). --}} + @rfa:diff-action-completed.window="if (String($event.detail.fileId) === String(fileId)) loading = false" + @endif +> + @if($hasIcon) + + + Show + {{ $slot }} + + + Loading… + @else + {{ $slot }} + @endif +
diff --git a/resources/views/components/diff/file-header.blade.php b/resources/views/components/diff/file-header.blade.php index 25b322b4..4f8c282c 100644 --- a/resources/views/components/diff/file-header.blade.php +++ b/resources/views/components/diff/file-header.blade.php @@ -44,7 +44,7 @@ class="text-sm"
-
+
- + Copy diff diff --git a/resources/views/components/diff/hunk.blade.php b/resources/views/components/diff/hunk.blade.php index 9b5643ff..ee777c96 100644 --- a/resources/views/components/diff/hunk.blade.php +++ b/resources/views/components/diff/hunk.blade.php @@ -9,21 +9,20 @@ {{-- Each hunk is its own subgrid so split-mode `grid-auto-flow: dense` pairs remove+add only within this hunk — never across hunk boundaries. --}}
- {{-- Gap with expand controls (or hunk header when no preceding gap). --}} + {{-- Gap with expand controls (or hunk section-context label when no preceding gap). --}} @if($hunkIndex > 0 || $hunk['newStart'] > 1) -
- @php - $hiddenCount = $hunkIndex > 0 - ? $hunk['newStart'] - ($prevHunk['newStart'] + $prevHunk['newCount']) - : $hunk['newStart'] - 1; - @endphp + @php + $hiddenCount = $hunkIndex > 0 + ? $hunk['newStart'] - ($prevHunk['newStart'] + $prevHunk['newCount']) + : $hunk['newStart'] - 1; + @endphp + -
+ @elseif($hunk['header'] !== '') -
- @@ -{{ $hunk['oldStart'] }} +{{ $hunk['newStart'] }} @@ - {{ $hunk['header'] }} -
+ + {{ $hunk['header'] }} + @endif @foreach($hunk['lines'] as $line) diff --git a/resources/views/components/diff/line.blade.php b/resources/views/components/diff/line.blade.php index 5244c4f9..d6e367b2 100644 --- a/resources/views/components/diff/line.blade.php +++ b/resources/views/components/diff/line.blade.php @@ -1,6 +1,6 @@ -{{-- Parent Alpine scope contract: isLineInSelection(), onDragOver(), handleLineMousedown(), +{{-- Parent Alpine scope contract: isRowInSelection(), onDragOver(), handleLineMousedown(), onLineContextmenu(), toggleHeadingFold(), foldedHeadings, isLineFolded(), - showForm, formEndLine, formSide, editingCommentId. --}} + shouldShowLineCommentForm(), editingCommentId. --}} @props([ 'line', 'hasRemote' => false, @@ -41,7 +41,7 @@
-
+ diff --git a/resources/views/components/divergence/marker.blade.php b/resources/views/components/divergence/marker.blade.php new file mode 100644 index 00000000..6ffdd50b --- /dev/null +++ b/resources/views/components/divergence/marker.blade.php @@ -0,0 +1,91 @@ +{{-- + Branch-divergence marker for the header branch control. + + Renders a small dot on the branch pill when the repo's checked-out HEAD has + drifted from the review target, with a popover offering the choice. Replaces + the old full-width warning band so the canvas stays calm — the signal lives + where its cause does. Only shows for the quiet, recoverable states + (Diverged / Detached); MissingTarget is a blocking bar (). + + Buttons call ReviewPage actions directly (this is rendered inside its DOM). +--}} +@props(['state', 'context' => []]) + +@php + $isDiverged = $state === \App\Enums\DivergenceState::Diverged; + $isDetached = $state === \App\Enums\DivergenceState::Detached; +@endphp + +@if($isDiverged || $isDetached) + @php + $target = $context['target'] ?? ''; + $currentBranch = $context['currentBranch'] ?? null; + $shortSha = $context['shortSha'] ?? ''; + $commentCount = (int) ($context['commentCount'] ?? 0); + + // Literal class strings so Tailwind's source scan keeps them in the build. + $dotClasses = $isDiverged ? 'bg-gh-attention ring-gh-attention/30' : 'bg-gh-link ring-gh-link/30'; + $testid = $isDiverged ? 'divergence-banner-diverged' : 'divergence-banner-detached'; + @endphp + +
+ + + +
+@endif diff --git a/resources/views/components/divergence/missing-bar.blade.php b/resources/views/components/divergence/missing-bar.blade.php new file mode 100644 index 00000000..c4c0fdda --- /dev/null +++ b/resources/views/components/divergence/missing-bar.blade.php @@ -0,0 +1,38 @@ +{{-- + Branch-divergence "missing target" bar. + + The one genuinely blocking divergence state: the branch being reviewed no + longer exists (deleted / renamed / mid-rebase). Unlike Diverged/Detached + (quiet header marker), this stays a full-width bar — but on-brand (house + pattern from update-banner, gh-* tokens) rather than a stock-Flux callout, + and now dismissible. Buttons call ReviewPage actions directly. +--}} +@props(['state', 'context' => []]) + +@if($state === \App\Enums\DivergenceState::MissingTarget) + @php + $target = $context['target'] ?? ''; + $currentBranch = $context['currentBranch'] ?? null; + @endphp + + +@endif diff --git a/resources/views/components/empty-state.blade.php b/resources/views/components/empty-state.blade.php new file mode 100644 index 00000000..ec42a694 --- /dev/null +++ b/resources/views/components/empty-state.blade.php @@ -0,0 +1,40 @@ +{{-- + Shared full-page empty / zero state — one visual language across the app. + + Renders the rfa wordmark watermark (or a Flux icon), a display heading, a + free body slot, and an optional actions row. Pass role / aria-live through + as attributes for alert-grade states (e.g. git errors). + + Props: + glyph wordmark text (default "rfa"); set to null to omit + glyphClass color for the wordmark watermark (default muted/20) + icon Flux icon name to use instead of the wordmark + size "lg" (default, ~60vh centered) or "sm" (py-16) + Slots: heading, default (body), actions +--}} +@props([ + 'glyph' => 'rfa', + 'glyphClass' => 'text-gh-muted/20', + 'icon' => null, + 'size' => 'lg', +]) + +
class(['flex items-center justify-center', $size === 'sm' ? 'py-16' : 'h-[60vh]']) }}> +
+ @if($icon) + + @elseif($glyph) + + @endif + + @isset($heading) +

{{ $heading }}

+ @endisset + + {{ $slot }} + + @isset($actions) +
{{ $actions }}
+ @endisset +
+
diff --git a/resources/views/components/feedback-submit-bar.blade.php b/resources/views/components/feedback-submit-bar.blade.php index e8b872b4..454f14ed 100644 --- a/resources/views/components/feedback-submit-bar.blade.php +++ b/resources/views/components/feedback-submit-bar.blade.php @@ -68,6 +68,7 @@ @else
+ {{-- Drafts won't be included: arm-to-confirm inline (no native confirm() dialog). --}} - {{ $submitLabel }} + {{ $submitLabel }} +
diff --git a/resources/views/components/file-path.blade.php b/resources/views/components/file-path.blade.php index f41d1d0c..3ea68f4c 100644 --- a/resources/views/components/file-path.blade.php +++ b/resources/views/components/file-path.blade.php @@ -17,11 +17,28 @@ @props([ 'path', 'oldPath' => null, + // Opt-in: collapse a deep directory to a middle-ellipsis breadcrumb + // (app/Domains/BulkOperations/Jobs/ → app/…/Jobs/) so the basename — the + // thing being scanned for — survives in narrow lists. Default off; the full + // path stays in `title`. Use only where width is genuinely tight (sidebar). + 'collapse' => false, ]) @php $pos = strrpos($path, '/'); [$dir, $base] = $pos === false ? ['', $path] : [substr($path, 0, $pos + 1), substr($path, $pos + 1)]; + + if ($collapse && $dir !== '') { + // Preserve a leading slash for absolute paths: trimming both ends keeps + // the first real segment as $segments[0] (an absolute path would otherwise + // explode to a leading '' and collapse to '/…/c/', dropping the root dir). + $prefix = str_starts_with($dir, '/') ? '/' : ''; + $segments = explode('/', trim($dir, '/')); + if (count($segments) > 2) { + $dir = $prefix.$segments[0].'/…/'.end($segments).'/'; + } + } + $hasOldPath = $oldPath !== null && $oldPath !== ''; $defaultTitle = $hasOldPath ? $oldPath.' → '.$path : $path; @endphp diff --git a/resources/views/components/mode-toggle.blade.php b/resources/views/components/mode-toggle.blade.php index c78bc60a..29ebb5e6 100644 --- a/resources/views/components/mode-toggle.blade.php +++ b/resources/views/components/mode-toggle.blade.php @@ -14,7 +14,7 @@ $reviewHref = route('review-page', ['slug' => $projectSlug]); $contextHref = route('context-page', ['slug' => $projectSlug]); - $baseSide = 'group relative inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-display tracking-tight transition-colors cursor-pointer'; + $baseSide = 'group relative inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-display tracking-brutal transition-colors cursor-pointer'; $activeSide = 'bg-gh-surface text-gh-text ring-1 ring-gh-border'; $inactiveSide = 'text-gh-muted hover:text-gh-text hover:bg-gh-border/25'; diff --git a/resources/views/components/project-list.blade.php b/resources/views/components/project-list.blade.php index a31d107d..623c60d3 100644 --- a/resources/views/components/project-list.blade.php +++ b/resources/views/components/project-list.blade.php @@ -98,7 +98,7 @@ 'text-gh-link' => $isCurrent, ]) title="{{ $project['name'] }}">{{ $project['name'] }} @if($project['is_worktree']) - worktree + worktree @endif @if($project['branch']) {{ $project['branch'] }} @@ -124,7 +124,7 @@ wire:click.stop="removeProject({{ $project['id'] }})" wire:confirm="{{ $confirmMessage }}" @class([ - 'text-gh-muted hover:text-red-500 transition-opacity', + 'text-gh-muted hover:text-gh-red transition-opacity', 'opacity-0 group-hover:opacity-100 focus-visible:opacity-100' => $isPicker, 'opacity-60 hover:opacity-100' => ! $isPicker, ]) diff --git a/resources/views/components/remote-link-menu.blade.php b/resources/views/components/remote-link-menu.blade.php index cb5eaa5c..b209006b 100644 --- a/resources/views/components/remote-link-menu.blade.php +++ b/resources/views/components/remote-link-menu.blade.php @@ -50,7 +50,12 @@
-{{ collect($sourceFiles)->sum('deletions') }} @if(count($reviewPairs) > 0) - {{ count($reviewPairs) }} {{ Str::plural('review', count($reviewPairs)) }} + {{ count($reviewPairs) }} {{ Str::plural('review', count($reviewPairs)) }} @endif
diff --git a/resources/views/components/tiered-expand-gap.blade.php b/resources/views/components/tiered-expand-gap.blade.php index a204d511..5ddcffe8 100644 --- a/resources/views/components/tiered-expand-gap.blade.php +++ b/resources/views/components/tiered-expand-gap.blade.php @@ -1,3 +1,10 @@ +{{-- + The blue (clickable) targets inside : a single + "N hidden lines" button for small gaps, or tiered chips (15 · 50 · 100) + plus an "all N hidden lines" button for larger ones. The "Show" verb, ↕ + icon, band chrome, and `loading` flag are owned by the wrapping shell — + buttons here just flip `loading = true`. +--}} @props(['hunkIndex', 'hiddenCount']) @php @@ -7,48 +14,21 @@ $applicableTiers = array_values(array_filter([15, 50, 100], fn ($t) => $t < $hiddenCount)); @endphp - - @if(empty($applicableTiers)) - - @else - - Expand - - @foreach($applicableTiers as $tier) - @if(!$loop->first) - - @endif - - @endforeach - - {{-- Always plural here: this branch only renders when hiddenCount > 15. --}} - - - @endif - - - Expanding... +@if(empty($applicableTiers)) + {{-- Inline ternary instead of Str::plural for the same hot-path reason. --}} + + {{ $hiddenCount }} hidden {{ $hiddenCount === 1 ? 'line' : 'lines' }} + +@else + + @foreach($applicableTiers as $tier) + @if(!$loop->first) + + @endif + {{ $tier }} + @endforeach - + {{-- Always plural here: this branch only renders when hiddenCount > 15. The + full "N hidden lines" target reads identically to the single-gap button. --}} + {{ $hiddenCount }} hidden lines +@endif diff --git a/resources/views/layouts/app.blade.php b/resources/views/layouts/app.blade.php index 2d2ae844..fbcd320c 100644 --- a/resources/views/layouts/app.blade.php +++ b/resources/views/layouts/app.blade.php @@ -235,6 +235,12 @@ > {{-- Find-in-page search bar (Cmd/Ctrl+F) --}}
+ {{-- Single defined home for Flux toasts (bottom-right), clear of the undo-toast + and submit bar. Surface styling is brought onto gh-* tokens in app.css. --}} + {{ $slot }}