mirror of
https://github.com/backnotprop/plannotator.git
synced 2026-09-14 14:17:26 +08:00
94f8d45daa
* fix(guide-viewer): readable on phones and tablets, desktop untouched Every change is behind a breakpoint; 1440px and 1024px renders of the same guide are byte-identical before and after (screenshot MD5s match). - Split diffs below lg (1024px) are forced unified in the portable viewer's diff renderer (matchMedia; the setting is untouched, so a wider window gets split back). A phone has ~350px of pane and a portrait tablet ~430px, so two columns were under 220px each. - Padding scales: page px-3/sm:px-6/lg:px-10, chapter column px-4/md:px-6, diff column px-1.5/md:px-4. Code pane on a 390px phone: 276px → 352px. - Tablets: the chapter column is proportional (minmax(260px,36%)) from md and the fixed 440px only from lg. Pane at 768px: 214px → 426px. - Header actions (Download, theme) sit in a right-aligned row above the title below md instead of floating into it. Viewer rebuilt and published (viewer.dWt7KCum.js), manifest synced. * fix(guide-viewer): touch targets, labels, and no tap delay on coarse pointers Only under `pointer: coarse` (Tailwind's `pointer-coarse:` variant), so mouse layouts are unchanged: - Reviewed checkbox and the collapse chevron get an invisible ::before hit area (visual 15–17px, hit ≥ 44px); the "Reviewed" text button and file chips get taller padding; the theme toggle and hosted Download button grow to a 44px hit box. - `touch-action: manipulation` on controls in the portable viewer and the landing page (no double-tap-to-zoom delay; the page still pinch-zooms). - `aria-label` on the two icon-only buttons (theme toggle, collapse chevron). - Landing page: the GitHub link and the Copy button are 44px tall on touch. Tailwind v4 already gates `hover:` behind `@media (hover: hover)`, so no false hover states on tap. Viewer rebuilt and published, manifest synced. * guide-viewer: manifest for the combined build (labels + mobile), viewer.sFtOnb1i.js published
215 lines
8.4 KiB
TypeScript
215 lines
8.4 KiB
TypeScript
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
import type { CodeGuideData, GuideSection } from '@plannotator/core/guide';
|
|
import type { DiffFile } from './types';
|
|
import { useGuideHost } from './host';
|
|
import { renderInlineMarkdown } from './renderInlineMarkdown';
|
|
import { GuideSectionCard } from './GuideSectionCard';
|
|
import { GUIDE_EAGER_MOUNT_MAX_FILES, GuideViewportProvider } from './GuideViewportManager';
|
|
|
|
interface GuideViewProps {
|
|
guide: CodeGuideData;
|
|
reviewed: boolean[];
|
|
onToggleReviewed: (index: number) => void;
|
|
/** e.g. "Claude" / "Codex" — omitted when the generating job/engine is unknown. */
|
|
engineLabel?: string;
|
|
focusedFile: string | null;
|
|
onFocusFile: (filePath: string) => void;
|
|
/** Launch a fresh guide when a persisted guide no longer matches this branch. */
|
|
onRegenerate?: () => void;
|
|
/** Extra provenance line under the counts (portable exports: repo, PR link, changeset). */
|
|
sourceLine?: React.ReactNode;
|
|
/** Host-provided controls rendered top-right of the header (in-app: the export button). */
|
|
headerActions?: React.ReactNode;
|
|
}
|
|
|
|
export interface ResolvedGuideSections {
|
|
sectionFiles: DiffFile[][];
|
|
unplacedFiles: DiffFile[];
|
|
}
|
|
|
|
/** Resolve guide refs against the current patch while preserving generated
|
|
* order. First placement wins globally; stale refs remain in chapter chips but
|
|
* are omitted from CodeView items. */
|
|
export function resolveGuideSectionFiles(guide: CodeGuideData, files: DiffFile[]): ResolvedGuideSections {
|
|
const filesByPath = new Map(files.map((file) => [file.path, file]));
|
|
const seen = new Set<string>();
|
|
const sectionFiles = guide.sections.map((section) => {
|
|
const resolved: DiffFile[] = [];
|
|
for (const ref of section.diffs) {
|
|
if (seen.has(ref.file)) continue;
|
|
seen.add(ref.file);
|
|
const file = filesByPath.get(ref.file);
|
|
if (file) resolved.push(file);
|
|
}
|
|
return resolved;
|
|
});
|
|
|
|
const unplacedFiles: DiffFile[] = [];
|
|
for (const filePath of guide.unplacedFiles ?? []) {
|
|
if (seen.has(filePath)) continue;
|
|
seen.add(filePath);
|
|
const file = filesByPath.get(filePath);
|
|
if (file) unplacedFiles.push(file);
|
|
}
|
|
|
|
return { sectionFiles, unplacedFiles };
|
|
}
|
|
|
|
/**
|
|
* Generated guide with the original vertical chapter/file-card layout. Every
|
|
* lightweight file shell stays in document flow while GuideViewportProvider
|
|
* bounds the mounted one-file Pierre CodeViews across all chapters.
|
|
*/
|
|
export const GuideView: React.FC<GuideViewProps> = ({
|
|
guide,
|
|
reviewed,
|
|
onToggleReviewed,
|
|
engineLabel,
|
|
focusedFile,
|
|
onFocusFile,
|
|
onRegenerate,
|
|
sourceLine,
|
|
headerActions,
|
|
}) => {
|
|
const host = useGuideHost();
|
|
const resolved = useMemo(() => resolveGuideSectionFiles(guide, host.files), [guide, host.files]);
|
|
// Only the guide's own files register shells; in-app the review may hold many more.
|
|
const guideFileCount = resolved.sectionFiles.reduce((n, files) => n + files.length, 0) + resolved.unplacedFiles.length;
|
|
const hasUnplaced = (guide.unplacedFiles?.length ?? 0) > 0;
|
|
const cardTotal = guide.sections.length + (hasUnplaced ? 1 : 0);
|
|
const reviewedCount = reviewed.filter(Boolean).length;
|
|
const effectiveFocusedFile = focusedFile
|
|
?? resolved.sectionFiles.find((files) => files.length > 0)?.[0]?.path
|
|
?? resolved.unplacedFiles[0]?.path
|
|
?? null;
|
|
|
|
const localRevealTokenRef = useRef(0);
|
|
const [localRevealTarget, setLocalRevealTarget] = useState<{ filePath: string; token: number } | null>(null);
|
|
const externalRevealTarget = host.revealFile
|
|
? { filePath: host.revealFile.path, token: host.revealFile.token }
|
|
: null;
|
|
const revealTarget = externalRevealTarget ?? localRevealTarget;
|
|
|
|
useEffect(() => {
|
|
if (!revealTarget) return;
|
|
onFocusFile(revealTarget.filePath);
|
|
// Token identifies a navigation event; callback identity must not replay it.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [revealTarget?.filePath, revealTarget?.token]);
|
|
|
|
const handleRequestReveal = useCallback(
|
|
(filePath: string) => {
|
|
onFocusFile(filePath);
|
|
if (host.onRevealFile) {
|
|
host.onRevealFile(filePath);
|
|
} else {
|
|
localRevealTokenRef.current += 1;
|
|
setLocalRevealTarget({ filePath, token: localRevealTokenRef.current });
|
|
}
|
|
},
|
|
[onFocusFile, host.onRevealFile],
|
|
);
|
|
|
|
// Search results are global to the review, but an offscreen guide file has no
|
|
// CodeView to receive the active match. Route each match change through the
|
|
// same reveal channel as outline/sidebar jumps so its chapter opens, its shell
|
|
// mounts, and the target viewer becomes active before line navigation runs.
|
|
const activeSearchMatch = host.activeSearchMatch;
|
|
useEffect(() => {
|
|
if (!activeSearchMatch) return;
|
|
handleRequestReveal(activeSearchMatch.filePath);
|
|
}, [activeSearchMatch?.id, activeSearchMatch?.filePath, handleRequestReveal]);
|
|
|
|
const unplacedSection = useMemo<GuideSection | null>(
|
|
() =>
|
|
hasUnplaced
|
|
? {
|
|
title: 'Everything else',
|
|
overview: "Changed files the guide didn't place into a section — shown so nothing is silently left out.",
|
|
diffs: (guide.unplacedFiles ?? []).map((file) => ({ file })),
|
|
}
|
|
: null,
|
|
[guide.unplacedFiles, hasUnplaced],
|
|
);
|
|
|
|
return (
|
|
<GuideViewportProvider className="w-full px-3 py-5 sm:px-6 sm:py-6 lg:px-10 lg:py-8" eager={guideFileCount <= GUIDE_EAGER_MOUNT_MAX_FILES}>
|
|
{/* Header actions sit in a row above the title on narrow screens and float beside it from md up (desktop unchanged). */}
|
|
{headerActions && <div className="mb-3 flex items-center justify-end gap-2 md:float-right md:mb-0 md:ml-6">{headerActions}</div>}
|
|
<div className="max-w-[72ch]">
|
|
<h1 className="text-[22px] font-semibold tracking-tight text-foreground [text-wrap:balance]">{guide.title}</h1>
|
|
{guide.intent && (
|
|
<p className="mt-1.5 text-[13px] leading-relaxed text-muted-foreground">{renderInlineMarkdown(guide.intent)}</p>
|
|
)}
|
|
<p className="mt-2 flex items-center gap-1.5 font-mono text-[11px] text-muted-foreground/60">
|
|
<span>
|
|
{guide.sections.length} section{guide.sections.length !== 1 ? 's' : ''}
|
|
{reviewedCount > 0 && ` · ${reviewedCount}/${guide.sections.length} reviewed`}
|
|
{engineLabel && ` · generated by ${engineLabel}`}
|
|
</span>
|
|
{guide.saved && (
|
|
<span
|
|
className="rounded border border-border/50 bg-muted/40 px-1.5 py-px text-[10px] text-muted-foreground"
|
|
title="This guide is persisted and will be available the next time this review opens"
|
|
>
|
|
Saved
|
|
</span>
|
|
)}
|
|
</p>
|
|
{sourceLine && (
|
|
<p className="mt-1.5 font-mono text-[11px] text-muted-foreground/60">{sourceLine}</p>
|
|
)}
|
|
{guide.moved && (
|
|
<p className="mt-1.5 font-mono text-[11px] text-muted-foreground/50">
|
|
Generated on a different version of this branch
|
|
{onRegenerate && (
|
|
<>
|
|
{' · '}
|
|
<button
|
|
type="button"
|
|
onClick={onRegenerate}
|
|
className="underline-offset-2 hover:text-foreground hover:underline"
|
|
>
|
|
Regenerate
|
|
</button>
|
|
</>
|
|
)}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
<div className="mt-6 space-y-4">
|
|
{guide.sections.map((section, index) => (
|
|
<GuideSectionCard
|
|
key={`${section.title}:${index}`}
|
|
section={section}
|
|
files={resolved.sectionFiles[index] ?? []}
|
|
index={index}
|
|
total={cardTotal}
|
|
reviewed={!!reviewed[index]}
|
|
onToggleReviewed={() => onToggleReviewed(index)}
|
|
focusedFile={effectiveFocusedFile}
|
|
revealTarget={revealTarget}
|
|
onActivate={onFocusFile}
|
|
onRequestReveal={handleRequestReveal}
|
|
/>
|
|
))}
|
|
|
|
{unplacedSection && (
|
|
<GuideSectionCard
|
|
section={unplacedSection}
|
|
files={resolved.unplacedFiles}
|
|
index={guide.sections.length}
|
|
total={cardTotal}
|
|
showReviewed={false}
|
|
focusedFile={effectiveFocusedFile}
|
|
revealTarget={revealTarget}
|
|
onActivate={onFocusFile}
|
|
onRequestReveal={handleRequestReveal}
|
|
/>
|
|
)}
|
|
</div>
|
|
</GuideViewportProvider>
|
|
);
|
|
};
|