Files
backnotprop__plannotator/packages/review-editor/components/CommitsPanel.tsx
Michael Ramos eb34960da9 fix(review): remember the last-used panel view; full-width toggle and cleaner panel chrome (#1273)
* fix(review): remember the last-used panel view across sessions

- Add cookie-only reviewPanelViewLastUsed config (sections | tree, never
  commits); the header toggle records it and new sessions resolve
  session state, then last-used, then the persisted reviewPanelView.
  Explicit Settings/setup-dialog writes through the reviewView setters
  sync last-used so a stale memo cannot shadow them.
- Wire the FileTree fallback PanelViewToggle to the real panel-view
  selection and route Tree clicks through the shared select funnel
  instead of rendering a hardcoded, no-op tree segment.
- Mark the review-setup-seen cookie when the first-run reset runs, not
  only on the Got it click, so closing the tab without dismissing no
  longer re-stomps preferences every session.

* fix(review): full-width panel toggle, relocated tree controls, click-to-copy diff count

- Give the Git status | Tree | Commits toggle the entire top row in all
  three left panels; segments stretch to fill the row width.
- Move the controls that shared that row (staged count, search,
  collapse-all, hide-viewed, viewed counter) into a shared
  PanelControlsRow rendered below the All files entry, directly above
  the file list; the Commits panel's loaded-commit count moves to its
  own sub-row.
- Replace the footer Copy button with the diff count itself as the copy
  trigger (shared CopyDiffFooter): a keyboard-reachable button with the
  "click to copy the entire diff set" tooltip and transient
  Copied/Failed feedback in the old button's slot.
- Add a tooltip to the file-row stage button using the same Tooltip
  mechanism as the viewed control ("Stage file (git add)").

* fix(review): put Tree first in the panel view toggle

* fix(review): resolve effective panel view for the toggle; keep self-heal off the last-used memo

- Add resolvePanelView() and pass the EFFECTIVE view to FileTree so a
  latent sections/commits selection the session cannot offer highlights
  Tree instead of a hidden or wrong segment; allFilesOrder reuses it.
  Unit test covers the no-segment and wrong-segment cases.
- setReviewPanelView() gains { recordLastUsed?: boolean } (default
  true); the App self-heal passes false so repairing a conflicted
  persisted pair no longer overwrites the user's last-used memo.
- Update the AGENTS.md since-base section to the new toggle semantics
  (last-used memo, resolution order, setter sync, one-time first-run).
- Hardening: fold the diff counts into the copy button's aria-label,
  skip the tooltip while the copy button is disabled (hover never fires
  on a disabled native button in several browsers), let toggle segments
  shrink (min-w-0 + truncate), and rework the config test onto a fresh
  ConfigStoreForTest instead of resolving the singleton against a
  throwaway backend.
2026-08-11 23:54:22 -07:00

202 lines
8.1 KiB
TypeScript

import React from 'react';
import type { CommitListEntry } from '@plannotator/shared/types';
import { PanelViewToggle, type ReviewPanelView } from './PanelViewToggle';
import { Avatar } from './Avatar';
import { OverlayScrollArea } from '@plannotator/ui/components/OverlayScrollArea';
import { formatRelativeTime } from '@plannotator/ui/utils/aiChatFormat';
/**
* The Commits panel — a pure linear history rail (`git log --first-parent`,
* newest first) rendered as compact overview cards. It never becomes a file
* list: clicking a commit opens that commit's own diff (vs its first parent)
* in the center dock as the all-files view.
*
* Two labeled groups replace a bare ref divider: "On this branch" (commits not
* yet reachable from the base) and "In <base>" (shared history) — the split is
* the same merge boundary the since-base review compares against.
*/
interface CommitsPanelProps {
width?: number;
commits: CommitListEntry[];
/** Base ref the group boundary represents (e.g. `origin/main`). */
base: string | null;
hasMore: boolean;
isLoading: boolean;
isLoadingMore: boolean;
error: string | null;
/** Full sha of the commit whose diff is on screen, if any. */
activeCommitSha: string | null;
onSelectCommit: (sha: string) => void;
onShowMore: () => void;
onRetry: () => void;
/** View switcher, same header slot as the other panels. */
onSelectPanelView: (view: ReviewPanelView) => void;
/** Whether the Git status segment is offered (since-base capable repos). */
showSectionsOption: boolean;
}
const CommitRow: React.FC<{
commit: CommitListEntry;
isActive: boolean;
onSelect: () => void;
}> = ({ commit, isActive, onSelect }) => (
<button
onClick={onSelect}
className={`w-full text-left px-2 py-1.5 transition-colors ${
isActive ? 'bg-primary/10' : 'hover:bg-muted/50'
}`}
title={`${commit.sha}\n${commit.author} <${commit.authorEmail}>\n${commit.subject}`}
>
{/* Subject — always one line, ellipsized. */}
<div className="flex items-center gap-1.5 min-w-0">
<span className="text-xs truncate flex-1">{commit.subject}</span>
{commit.isHead && (
<span className="text-[9px] leading-none px-1 py-0.5 rounded bg-primary/15 text-primary font-medium flex-shrink-0">
HEAD
</span>
)}
<span className="text-[10px] text-muted-foreground/70 tabular-nums flex-shrink-0">
{formatRelativeTime(commit.committedAt)}
</span>
</div>
{/* Meta — avatar + author (always shown) + sha. */}
<div className="mt-0.5 flex items-center gap-1.5 min-w-0">
<Avatar src={commit.avatarUrl} name={commit.author} size={14} />
<span className="text-[11px] text-muted-foreground truncate">{commit.author}</span>
<span className="flex-1" />
<span className="font-mono text-[10px] text-muted-foreground/70 flex-shrink-0">{commit.shortSha}</span>
</div>
</button>
);
/** The base boundary — commits below are already part of the base. Rendered
* as a prominent labeled rule so the split reads at a glance. */
const BaseBoundary: React.FC<{ base: string }> = ({ base }) => (
<div
className="flex items-center gap-2 px-2 py-2"
title={`Commits from here down are already part of ${base} — shared history, not branch work.`}
>
<span className="h-px flex-1 bg-foreground/30" />
<span className="text-[11px] font-semibold text-foreground/80 truncate max-w-[160px]">
In {base}
</span>
<span className="h-px flex-1 bg-foreground/30" />
</div>
);
const GroupHeader: React.FC<{ label: string; title: string }> = ({ label, title }) => (
<div className="px-2 pt-1 pb-1 text-[11px] font-medium text-muted-foreground" title={title}>
{label}
</div>
);
export const CommitsPanel: React.FC<CommitsPanelProps> = ({
width,
commits,
base,
hasMore,
isLoading,
isLoadingMore,
error,
activeCommitSha,
onSelectCommit,
onShowMore,
onRetry,
onSelectPanelView,
showSectionsOption,
}) => {
// isPastBase is a suffix of the linear walk (reachability from the base is
// monotone along first parents), so one boundary is exhaustive.
const boundaryIndex = commits.findIndex((c) => c.isPastBase);
const showGroups = boundaryIndex !== -1 && !!base;
return (
<aside
className="border-r border-border/50 bg-card/30 flex flex-col flex-shrink-0 overflow-hidden"
style={{ width: width ?? 256 }}
>
{/* Header — the view toggle owns the entire top row (full width), same
layout as the other panel views. The loaded-commit count moves to its
own sub-row since nothing else shares the toggle row. */}
<div className="px-3 flex items-center border-b border-border/50 flex-shrink-0" style={{ height: 'var(--panel-header-h)' }}>
<PanelViewToggle
view="commits"
onSelect={onSelectPanelView}
showSections={showSectionsOption}
showCommits
/>
</div>
{commits.length > 0 && (
<div className="px-3 py-1 border-b border-border/30 flex items-center justify-end flex-shrink-0">
<span className="text-xs text-muted-foreground tabular-nums">
{commits.length} {commits.length === 1 ? 'commit' : 'commits'}
</span>
</div>
)}
<OverlayScrollArea className="flex-1 min-h-0">
<div className="py-1">
{error && commits.length === 0 ? (
// Full-panel error only when there's nothing to show — an error
// with a populated list renders inline below it instead, so a
// failed page/refresh never wipes the rail the user is reading.
<div className="px-2 py-4 text-center space-y-2">
<div className="text-xs text-destructive break-words">{error}</div>
<button
onClick={onRetry}
className="text-[11px] text-primary/80 underline underline-offset-2 decoration-primary/40 hover:text-primary transition-colors"
>
Retry
</button>
</div>
) : isLoading && commits.length === 0 ? (
<div className="py-6 text-center text-xs text-muted-foreground/50">Loading commits…</div>
) : commits.length === 0 ? (
<div className="py-6 text-center text-xs text-muted-foreground/50">No commits</div>
) : (
<>
{commits.map((commit, index) => (
<React.Fragment key={commit.sha}>
{showGroups && index === 0 && boundaryIndex > 0 && (
<GroupHeader
label="On this branch"
title={`Commits that exist only on this branch — not yet part of ${base}.`}
/>
)}
{showGroups && index === boundaryIndex && <BaseBoundary base={base!} />}
<CommitRow
commit={commit}
isActive={commit.sha === activeCommitSha}
onSelect={() => onSelectCommit(commit.sha)}
/>
</React.Fragment>
))}
{hasMore && (
<button
onClick={onShowMore}
disabled={isLoadingMore}
className="w-full text-left px-2 py-1 text-[11px] text-primary/80 underline underline-offset-2 decoration-primary/40 hover:text-primary hover:decoration-primary transition-colors disabled:opacity-50"
>
{isLoadingMore ? 'Loading…' : 'Show more'}
</button>
)}
{error && (
<div className="px-2 py-1.5 flex items-center gap-2 text-[11px] text-destructive">
<span className="truncate flex-1" title={error}>{error}</span>
<button
onClick={onRetry}
className="flex-shrink-0 text-primary/80 underline underline-offset-2 decoration-primary/40 hover:text-primary transition-colors"
>
Retry
</button>
</div>
)}
</>
)}
</div>
</OverlayScrollArea>
</aside>
);
};