mirror of
https://github.com/backnotprop/plannotator.git
synced 2026-09-14 14:17:26 +08:00
4139999526
* feat(plan-diff): word-level inline diff rendering Two-pass hierarchical diff (diffLines outer + diffWordsWithSpace inner) so modified plan blocks render with inline insertions/deletions in context instead of showing the whole old block struck-through above the whole new block. Resolves #560. Engine (packages/ui/utils/planDiffEngine.ts): - computeInlineDiff runs a second-pass word diff on modified blocks that pass a whitelist gate (paragraph/heading/list-item with matching structural fields). - Sentinel substitution atomizes inline-code spans, markdown links, and fenced code blocks before diffWordsWithSpace runs, so diff markers never land inside backticks, link hrefs, or across fence boundaries. Fence regex uses a backreference so variable-length (e.g., 4-backtick wrapping 3-backtick) fences are matched atomically. - Annotation context for an inline-diffed modified block now captures both old and new content so comments on struck-through words preserve that text in the exported feedback. Renderer (packages/ui/components/plan-diff/PlanCleanDiffView.tsx): - New InlineModifiedBlock component renders a modified block as one structural wrapper with <ins>/<del> wrappers inside, parsed through the local InlineMarkdown in a single pass so markdown delimiter pairs survive across token boundaries. - InlineMarkdown extended to recognize <ins>/<del> tag passthrough (with recursive parsing of the wrapped content) and to recursively parse link anchor text so diff markers inside links render correctly. - Plain-text stop-char scanner includes '<' so <ins>/<del> dispatch re-enters the loop instead of swallowing tag text. - Click-to-annotate works in every editor mode (not just comment), with the block-level onClick opening the popover directly. Mode switcher (packages/ui/components/plan-diff/PlanDiffModeSwitcher.tsx): - Adds a third "Classic" tab between Rendered and Raw. Rendered is the new word-level default (labeled "exp"); Classic forces the legacy block-level stacked fallback for every modified block. Styling (packages/ui/theme.css, packages/editor/index.css): - plan-diff-word-added / plan-diff-word-removed utility classes for inline highlights with box-decoration-break: clone across line wraps. - Inline <code> inside the diff wrappers picks up a tinted background so code-pill changes read unambiguously green/red. - New plan-diff-modified class (amber border) for inline-diff modified blocks, matching the GitHub/VSCode convention of green=add, red=remove, yellow=both. Tests (packages/ui/utils/planDiffEngine.test.ts): - 18 tests covering the engine's qualification gate, structural-field matching, sentinel round-trip (inline code / links / fences), token content for common edit patterns. For provenance purposes, this commit was AI assisted. * chore(demo): restructure default demo, add VITE_DIFF_DEMO stress test Demo content changes that support the word-level diff work but do not alter shipped app behavior — only what other devs see running dev:hook. packages/editor/demoPlan.ts (default V3 editor content): - Added a "Context" section at the top of the plan with prose that showcases the word-level engine in V2→V3 diff: bold phrase swap, inline-code pill swaps, a link URL change, and a single-line code edit inside a config block. - Moved the mermaid architecture diagram and graphviz service map to an "Appendix: Diagrams" section at the end of the plan; they were rendering ugly mid-document. apps/hook/dev-mock-api.ts (Vite mock for the diff API): - PLAN_V1 / PLAN_V2 split into *_DEFAULT (original Real-time Collaboration plan — preserved identically from pre-branch state) and *_DIFF_TEST (the 20-case Auth Service Refactor diff-engine stress test, kept as an opt-in tool). - Resolves which pair to serve based on VITE_DIFF_DEMO env var. Matches the V2 Context section to the new V3 Context, with differences that produce rich word-level inline diffs on first load. - Diagrams moved to Appendix in V2_DEFAULT to match V3. packages/editor/App.tsx: - Both demo imports are active. VITE_DIFF_DEMO=1 swaps DIFF_DEMO_PLAN_CONTENT into the editor's default; unset renders the original Real-time Collaboration plan as before. packages/editor/demoPlanDiffDemo.ts (new): - 20-case stress test (paragraphs, headings, lists, tables, fences, blockquotes, known limitations). Each case has an identical "What to watch for" blockquote label in both V2 and V3 so the diff view cleanly isolates each case. Opt-in only. .gitignore: - Ignore .claude/ runtime lock/state files. Machine-specific content that should not be tracked. For provenance purposes, this commit was AI assisted. * style(plan-diff): refine modified-block visual — amber gutter, no fill Drop the yellow background fill from .plan-diff-modified and keep only a softened amber left border. Added/removed blocks remain loud (full fill + strong border) because add/remove are block-scope events — the whole block matters. Modify is a word-scope event — the individual changed words carry loud inline red/green highlights, and a block-level fill would compete with that inline work. The amber gutter at 75% opacity now reads as a quiet "look inside, the change is in the text" marker that sits coherently with the rest of the palette. For provenance purposes, this commit was AI assisted. * fix(plan-diff): sanitize link hrefs against javascript: / data: schemes PlanCleanDiffView has its own local copy of InlineMarkdown (separate from the one in Viewer.tsx). The link-rendering branch was passing the captured URL directly to href with no validation, so a plan containing [click me](javascript:alert(document.cookie)) would render as a live clickable anchor in the diff view. Plan content is attacker-influenced — Claude pulls from source comments, READMEs, fetched URLs — so this is a real exploit path in the diff flow. Port the same guard Viewer.tsx already has: sanitizeLinkUrl() rejects javascript:, data:, vbscript:, and file: schemes (case-insensitive, with optional leading whitespace). Rejected links render their anchor text as plain text instead of a clickable <a>, so the content is still visible to the reader but no longer dangerous. For provenance purposes, this commit was AI assisted.
104 lines
3.1 KiB
TypeScript
104 lines
3.1 KiB
TypeScript
/**
|
|
* PlanDiffModeSwitcher — Toggle between clean/rendered and raw diff views
|
|
*
|
|
* Follows the same visual pattern as ModeSwitcher.
|
|
*/
|
|
|
|
import React from "react";
|
|
|
|
export type PlanDiffMode = "clean" | "classic" | "raw";
|
|
|
|
interface PlanDiffModeSwitcherProps {
|
|
mode: PlanDiffMode;
|
|
onChange: (mode: PlanDiffMode) => void;
|
|
}
|
|
|
|
export const PlanDiffModeSwitcher: React.FC<PlanDiffModeSwitcherProps> = ({
|
|
mode,
|
|
onChange,
|
|
}) => {
|
|
return (
|
|
<div className="inline-flex items-center bg-muted/50 rounded-lg p-0.5 border border-border/30">
|
|
<button
|
|
onClick={() => onChange("clean")}
|
|
title="Word-level inline diff (experimental)"
|
|
className={`flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-medium transition-all ${
|
|
mode === "clean"
|
|
? "bg-background text-foreground shadow-sm"
|
|
: "text-muted-foreground hover:text-foreground"
|
|
}`}
|
|
>
|
|
<svg
|
|
className="w-3.5 h-3.5"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
strokeWidth={2}
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z"
|
|
/>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
|
/>
|
|
</svg>
|
|
Rendered
|
|
<span className="text-[9px] uppercase tracking-wider opacity-60 ml-0.5">
|
|
exp
|
|
</span>
|
|
</button>
|
|
<button
|
|
onClick={() => onChange("classic")}
|
|
title="Block-level stacked diff (old above new)"
|
|
className={`flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-medium transition-all ${
|
|
mode === "classic"
|
|
? "bg-background text-foreground shadow-sm"
|
|
: "text-muted-foreground hover:text-foreground"
|
|
}`}
|
|
>
|
|
<svg
|
|
className="w-3.5 h-3.5"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
strokeWidth={2}
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
d="M4 6h16M4 12h16M4 18h16"
|
|
/>
|
|
</svg>
|
|
Classic
|
|
</button>
|
|
<button
|
|
onClick={() => onChange("raw")}
|
|
className={`flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-medium transition-all ${
|
|
mode === "raw"
|
|
? "bg-background text-foreground shadow-sm"
|
|
: "text-muted-foreground hover:text-foreground"
|
|
}`}
|
|
>
|
|
<svg
|
|
className="w-3.5 h-3.5"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
strokeWidth={2}
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
d="M17.25 6.75L22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3l-4.5 16.5"
|
|
/>
|
|
</svg>
|
|
Raw
|
|
</button>
|
|
</div>
|
|
);
|
|
};
|