feat: Polish review tab — viewed tracking, file tree, syntax highlighting, better UX

- Add file "viewed" checkmarks with progress tracking (X/26 viewed in header + sidebar)
- Add directory-grouped file tree in sidebar with review progress bar
- Add sticky file headers that stay visible when scrolling through long diffs
- Add file change type badges (NEW/DELETED/RENAMED) with colored left borders
- Add syntax highlighting via shiki with lazy loading and progressive enhancement
- Add merge confirmation dropdown showing unresolved comments + viewed progress
- Make Approve & Merge button prominently green, Request Changes styled with error tokens
- Show full branch names instead of aggressively truncated text
- Add always-visible comment dots on lines with comments, subtle hover on others
- Improve hunk headers with two-tone @@ display and context function highlighting
- Add review progress bar to sidebar with file-level viewed state
This commit is contained in:
Lukas May
2026-03-05 11:30:48 +01:00
parent 173c7f7916
commit 06b768e358
12 changed files with 1207 additions and 103 deletions

View File

@@ -12,6 +12,9 @@ interface DiffViewerProps {
) => void;
onResolveComment: (commentId: string) => void;
onUnresolveComment: (commentId: string) => void;
viewedFiles?: Set<string>;
onToggleViewed?: (filePath: string) => void;
onRegisterRef?: (filePath: string, el: HTMLDivElement | null) => void;
}
export function DiffViewer({
@@ -20,18 +23,24 @@ export function DiffViewer({
onAddComment,
onResolveComment,
onUnresolveComment,
viewedFiles,
onToggleViewed,
onRegisterRef,
}: DiffViewerProps) {
return (
<div className="space-y-4">
{files.map((file) => (
<FileCard
key={file.newPath}
file={file}
comments={comments.filter((c) => c.filePath === file.newPath)}
onAddComment={onAddComment}
onResolveComment={onResolveComment}
onUnresolveComment={onUnresolveComment}
/>
<div key={file.newPath} ref={(el) => onRegisterRef?.(file.newPath, el)}>
<FileCard
file={file}
comments={comments.filter((c) => c.filePath === file.newPath)}
onAddComment={onAddComment}
onResolveComment={onResolveComment}
onUnresolveComment={onUnresolveComment}
isViewed={viewedFiles?.has(file.newPath) ?? false}
onToggleViewed={() => onToggleViewed?.(file.newPath)}
/>
</div>
))}
</div>
);

View File

@@ -1,8 +1,45 @@
import { useState } from "react";
import { ChevronDown, ChevronRight, Plus, Minus } from "lucide-react";
import { useState, useMemo } from "react";
import {
ChevronDown,
ChevronRight,
Plus,
Minus,
CheckCircle2,
Circle,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import type { FileDiff, DiffLine, ReviewComment } from "./types";
import type { FileDiff, FileChangeType, DiffLine, ReviewComment } from "./types";
import { HunkRows } from "./HunkRows";
import { useHighlightedFile } from "./use-syntax-highlight";
const changeTypeBadge: Record<
FileChangeType,
{ label: string; classes: string } | null
> = {
added: {
label: "NEW",
classes:
"bg-status-success-bg text-status-success-fg border-status-success-border",
},
deleted: {
label: "DELETED",
classes:
"bg-status-error-bg text-status-error-fg border-status-error-border",
},
renamed: {
label: "RENAMED",
classes:
"bg-status-active-bg text-status-active-fg border-status-active-border",
},
modified: null,
};
const leftBorderClass: Record<FileChangeType, string> = {
added: "border-l-2 border-l-status-success-fg",
deleted: "border-l-2 border-l-status-error-fg",
renamed: "border-l-2 border-l-status-active-fg",
modified: "border-l-2 border-l-primary/40",
};
interface FileCardProps {
file: FileDiff;
@@ -15,6 +52,8 @@ interface FileCardProps {
) => void;
onResolveComment: (commentId: string) => void;
onUnresolveComment: (commentId: string) => void;
isViewed?: boolean;
onToggleViewed?: () => void;
}
export function FileCard({
@@ -23,15 +62,25 @@ export function FileCard({
onAddComment,
onResolveComment,
onUnresolveComment,
isViewed = false,
onToggleViewed = () => {},
}: FileCardProps) {
const [expanded, setExpanded] = useState(true);
const commentCount = comments.length;
const badge = changeTypeBadge[file.changeType];
// Flatten all hunk lines for syntax highlighting
const allLines = useMemo(
() => file.hunks.flatMap((h) => h.lines),
[file.hunks],
);
const tokenMap = useHighlightedFile(file.newPath, allLines);
return (
<div className="rounded-lg border border-border overflow-hidden">
{/* File header */}
{/* File header — sticky so it stays visible when scrolling */}
<button
className="flex w-full items-center gap-2 px-3 py-2 bg-muted/50 hover:bg-muted text-left text-sm font-mono transition-colors"
className={`sticky top-0 z-10 flex w-full items-center gap-2 px-3 py-2 bg-muted hover:bg-muted/90 text-left text-sm font-mono transition-colors ${leftBorderClass[file.changeType]}`}
onClick={() => setExpanded(!expanded)}
>
{expanded ? (
@@ -39,8 +88,41 @@ export function FileCard({
) : (
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
)}
<span className="truncate flex-1">{file.newPath}</span>
<span className="truncate flex-1 flex items-center gap-2">
{file.newPath}
{badge && (
<span
className={`inline-flex text-[9px] font-semibold uppercase px-1.5 py-0 rounded border leading-4 ${badge.classes}`}
>
{badge.label}
</span>
)}
</span>
<span className="flex items-center gap-2 shrink-0 text-xs">
{/* Viewed toggle */}
<span
role="checkbox"
aria-checked={isViewed}
tabIndex={0}
className="cursor-pointer"
onClick={(e) => {
e.stopPropagation();
onToggleViewed();
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.stopPropagation();
e.preventDefault();
onToggleViewed();
}
}}
>
{isViewed ? (
<CheckCircle2 className="h-4 w-4 text-status-success-fg" />
) : (
<Circle className="h-4 w-4 text-muted-foreground" />
)}
</span>
{file.additions > 0 && (
<span className="flex items-center gap-0.5 text-diff-add-fg">
<Plus className="h-3 w-3" />
@@ -75,6 +157,7 @@ export function FileCard({
onAddComment={onAddComment}
onResolveComment={onResolveComment}
onUnresolveComment={onUnresolveComment}
tokenMap={tokenMap}
/>
))}
</tbody>

View File

@@ -1,6 +1,7 @@
import { useState, useCallback } from "react";
import type { DiffLine, ReviewComment } from "./types";
import { LineWithComments } from "./LineWithComments";
import type { LineTokenMap } from "./use-syntax-highlight";
interface HunkRowsProps {
hunk: { header: string; lines: DiffLine[] };
@@ -14,6 +15,7 @@ interface HunkRowsProps {
) => void;
onResolveComment: (commentId: string) => void;
onUnresolveComment: (commentId: string) => void;
tokenMap?: LineTokenMap | null;
}
export function HunkRows({
@@ -23,6 +25,7 @@ export function HunkRows({
onAddComment,
onResolveComment,
onUnresolveComment,
tokenMap,
}: HunkRowsProps) {
const [commentingLine, setCommentingLine] = useState<{
lineNumber: number;
@@ -49,9 +52,26 @@ export function HunkRows({
<tr>
<td
colSpan={3}
className="px-3 py-1 text-muted-foreground bg-diff-hunk-bg text-[11px] select-none"
className="px-3 py-1 text-muted-foreground bg-diff-hunk-bg text-[11px] select-none border-l-2 border-l-primary/20"
>
{hunk.header}
{(() => {
const match = hunk.header.match(/^(@@[^@]+@@)(.*)$/);
if (match) {
const prefix = match[1];
const context = match[2].trim();
return (
<>
<span className="text-muted-foreground/60">{prefix}</span>
{context && (
<span className="ml-1 text-muted-foreground/80 font-medium">
{context}
</span>
)}
</>
);
}
return hunk.header;
})()}
</td>
</tr>
@@ -78,6 +98,11 @@ export function HunkRows({
onSubmitComment={handleSubmitComment}
onResolveComment={onResolveComment}
onUnresolveComment={onUnresolveComment}
tokens={
line.newLineNumber !== null
? tokenMap?.get(line.newLineNumber) ?? undefined
: undefined
}
/>
);
})}

View File

@@ -3,6 +3,7 @@ import { MessageSquarePlus } from "lucide-react";
import type { DiffLine, ReviewComment } from "./types";
import { CommentThread } from "./CommentThread";
import { CommentForm } from "./CommentForm";
import type { TokenizedLine } from "./use-syntax-highlight";
interface LineWithCommentsProps {
line: DiffLine;
@@ -14,11 +15,13 @@ interface LineWithCommentsProps {
onSubmitComment: (body: string) => void;
onResolveComment: (commentId: string) => void;
onUnresolveComment: (commentId: string) => void;
/** Syntax-highlighted tokens for this line (if available) */
tokens?: TokenizedLine;
}
export function LineWithComments({
line,
lineKey,
lineKey: _lineKey,
lineComments,
isCommenting,
onStartComment,
@@ -26,6 +29,7 @@ export function LineWithComments({
onSubmitComment,
onResolveComment,
onUnresolveComment,
tokens,
}: LineWithCommentsProps) {
const formRef = useRef<HTMLTextAreaElement>(null);
@@ -62,7 +66,7 @@ export function LineWithComments({
return (
<>
<tr
className={`group ${bgClass} hover:brightness-95 dark:hover:brightness-110`}
className={`group ${bgClass} hover:ring-1 hover:ring-inset hover:ring-primary/10`}
>
{/* Line numbers */}
<td
@@ -80,24 +84,48 @@ export function LineWithComments({
{/* Comment button gutter */}
<td className={`w-6 min-w-6 ${gutterBgClass} align-top`}>
<button
className="opacity-0 group-hover:opacity-100 transition-opacity p-0.5 hover:text-primary"
onClick={onStartComment}
title="Add comment"
>
<MessageSquarePlus className="h-3.5 w-3.5" />
</button>
{lineComments.length > 0 ? (
<div className="relative flex items-center justify-center h-5">
<button
className="flex items-center justify-center group/comment"
onClick={onStartComment}
title={`${lineComments.length} comment${lineComments.length > 1 ? "s" : ""} — click to reply`}
>
<span className="h-2 w-2 rounded-full bg-primary group-hover/comment:hidden" />
<MessageSquarePlus className="h-3.5 w-3.5 text-primary hidden group-hover/comment:block" />
{lineComments.length > 1 && (
<span className="absolute -top-0.5 -right-0.5 text-[8px] text-primary font-bold leading-none">
{lineComments.length}
</span>
)}
</button>
</div>
) : (
<button
className="opacity-0 group-hover:opacity-50 transition-opacity p-0.5 hover:!opacity-100 hover:text-primary text-muted-foreground"
onClick={onStartComment}
title="Add comment"
>
<MessageSquarePlus className="h-3.5 w-3.5" />
</button>
)}
</td>
{/* Code content */}
<td className="pl-1 pr-3 align-top">
<pre
className={`leading-5 whitespace-pre-wrap break-all ${textColorClass}`}
className={`leading-5 whitespace-pre-wrap break-all ${tokens ? "" : textColorClass}`}
>
<span className="select-none text-muted-foreground/60">
{prefix}
</span>
{line.content}
{tokens
? tokens.map((token, i) => (
<span key={i} style={{ color: token.color }}>
{token.content}
</span>
))
: line.content}
</pre>
</td>
</tr>

View File

@@ -1,3 +1,4 @@
import { useState, useRef, useEffect } from "react";
import {
Check,
X,
@@ -11,6 +12,9 @@ import {
CircleDot,
RotateCcw,
ArrowRight,
Eye,
AlertCircle,
GitMerge,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
@@ -43,6 +47,8 @@ interface ReviewHeaderProps {
onApprove: () => void;
onRequestChanges: () => void;
preview: PreviewState | null;
viewedCount?: number;
totalCount?: number;
}
export function ReviewHeader({
@@ -58,9 +64,31 @@ export function ReviewHeader({
onApprove,
onRequestChanges,
preview,
viewedCount,
totalCount,
}: ReviewHeaderProps) {
const totalAdditions = files.reduce((s, f) => s + f.additions, 0);
const totalDeletions = files.reduce((s, f) => s + f.deletions, 0);
const [showConfirmation, setShowConfirmation] = useState(false);
const confirmRef = useRef<HTMLDivElement>(null);
// Click-outside handler to dismiss confirmation
useEffect(() => {
if (!showConfirmation) return;
function handleClickOutside(e: MouseEvent) {
if (
confirmRef.current &&
!confirmRef.current.contains(e.target as Node)
) {
setShowConfirmation(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [showConfirmation]);
const viewed = viewedCount ?? 0;
const total = totalCount ?? 0;
return (
<div className="border-b border-border bg-card/80 backdrop-blur-sm">
@@ -108,14 +136,14 @@ export function ReviewHeader({
</h2>
{sourceBranch && (
<div className="flex items-center gap-1 text-[11px] text-muted-foreground font-mono shrink-0">
<div className="flex items-center gap-1 text-[11px] text-muted-foreground font-mono min-w-0">
<GitBranch className="h-3 w-3 shrink-0" />
<span className="truncate max-w-[140px]" title={sourceBranch}>
{truncateBranch(sourceBranch)}
<span className="truncate" title={sourceBranch}>
{sourceBranch}
</span>
<ArrowRight className="h-2.5 w-2.5 shrink-0 text-muted-foreground/50" />
<span className="truncate max-w-[140px]" title={targetBranch}>
{truncateBranch(targetBranch)}
<span className="truncate" title={targetBranch}>
{targetBranch}
</span>
</div>
)}
@@ -136,8 +164,18 @@ export function ReviewHeader({
</div>
</div>
{/* Center: review progress */}
{total > 0 && (
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground shrink-0">
<Eye className="h-3 w-3" />
<span>
{viewed}/{total} viewed
</span>
</div>
)}
{/* Right: preview + actions */}
<div className="flex items-center gap-2 shrink-0">
<div className="flex items-center gap-3 shrink-0">
{/* Preview controls */}
{preview && <PreviewControls preview={preview} />}
@@ -148,22 +186,82 @@ export function ReviewHeader({
variant="outline"
size="sm"
onClick={onRequestChanges}
className="h-7 text-xs"
className="h-8 text-xs px-3 border-status-error-border/50 text-status-error-fg hover:bg-status-error-bg/50 hover:border-status-error-border"
>
<X className="h-3 w-3" />
Request Changes
</Button>
<Button
size="sm"
onClick={onApprove}
disabled={unresolvedCount > 0}
className="h-7 text-xs"
>
<Check className="h-3 w-3" />
{unresolvedCount > 0
? `${unresolvedCount} unresolved`
: "Approve & Merge"}
</Button>
<div className="relative" ref={confirmRef}>
<Button
size="sm"
onClick={() => {
if (unresolvedCount > 0) return;
setShowConfirmation(true);
}}
disabled={unresolvedCount > 0}
className={
unresolvedCount > 0
? "h-9 px-5 text-sm font-semibold"
: "bg-status-success-fg text-white hover:opacity-90 h-9 px-5 text-sm font-semibold shadow-sm"
}
>
{unresolvedCount > 0 ? (
<>
<AlertCircle className="h-3.5 w-3.5" />
{unresolvedCount} unresolved
</>
) : (
<>
<GitMerge className="h-3.5 w-3.5" />
Approve & Merge
</>
)}
</Button>
{/* Merge confirmation dropdown */}
{showConfirmation && (
<div className="absolute right-0 top-full mt-1 z-20 w-64 rounded-lg border border-border bg-card shadow-lg p-4">
<p className="text-sm font-semibold mb-3">
Ready to merge?
</p>
<div className="space-y-1.5 mb-4">
<div className="flex items-center gap-2 text-xs">
<Check className="h-3.5 w-3.5 text-status-success-fg" />
<span className="text-muted-foreground">
0 unresolved comments
</span>
</div>
<div className="flex items-center gap-2 text-xs">
<Eye className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-muted-foreground">
{viewed}/{total} files viewed
</span>
</div>
</div>
<div className="flex items-center justify-end gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => setShowConfirmation(false)}
className="h-8 text-xs"
>
Cancel
</Button>
<Button
size="sm"
onClick={() => {
setShowConfirmation(false);
onApprove();
}}
className="bg-status-success-fg text-white hover:opacity-90 h-8 px-4 text-xs font-semibold shadow-sm"
>
<GitMerge className="h-3.5 w-3.5" />
Merge Now
</Button>
</div>
</div>
)}
</div>
</>
)}
{status === "approved" && (
@@ -247,9 +345,3 @@ function PreviewControls({ preview }: { preview: PreviewState }) {
</Button>
);
}
function truncateBranch(branch: string): string {
const parts = branch.split("/");
if (parts.length <= 2) return branch;
return parts.slice(-2).join("/");
}

View File

@@ -1,7 +1,8 @@
import { useState } from "react";
import { useMemo, useState } from "react";
import {
MessageSquare,
FileCode,
FolderOpen,
Plus,
Minus,
Circle,
@@ -21,6 +22,7 @@ interface ReviewSidebarProps {
activeFiles: FileDiff[];
commits: CommitInfo[];
onSelectCommit: (hash: string | null) => void;
viewedFiles?: Set<string>;
}
export function ReviewSidebar({
@@ -31,6 +33,7 @@ export function ReviewSidebar({
activeFiles,
commits,
onSelectCommit,
viewedFiles = new Set(),
}: ReviewSidebarProps) {
const [view, setView] = useState<SidebarView>("files");
@@ -45,6 +48,7 @@ export function ReviewSidebar({
onFileClick={onFileClick}
selectedCommit={selectedCommit}
activeFiles={activeFiles}
viewedFiles={viewedFiles}
/>
) : (
<CommitsView
@@ -115,25 +119,96 @@ function IconTab({
/* ─── Files View ───────────────────────────────────────── */
interface DirectoryGroup {
directory: string;
files: FileDiff[];
}
function groupFilesByDirectory(files: FileDiff[]): DirectoryGroup[] {
const groups = new Map<string, FileDiff[]>();
for (const file of files) {
const lastSlash = file.newPath.lastIndexOf("/");
const directory = lastSlash >= 0 ? file.newPath.slice(0, lastSlash + 1) : "";
const existing = groups.get(directory);
if (existing) {
existing.push(file);
} else {
groups.set(directory, [file]);
}
}
// Sort directories alphabetically, sort files within each directory
const sorted = Array.from(groups.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([directory, dirFiles]) => ({
directory,
files: dirFiles.sort((a, b) => {
const aName = a.newPath.slice(a.newPath.lastIndexOf("/") + 1);
const bName = b.newPath.slice(b.newPath.lastIndexOf("/") + 1);
return aName.localeCompare(bName);
}),
}));
return sorted;
}
function getFileName(path: string): string {
const lastSlash = path.lastIndexOf("/");
return lastSlash >= 0 ? path.slice(lastSlash + 1) : path;
}
const changeTypeDotColor: Record<string, string> = {
added: "bg-status-success-fg",
deleted: "bg-status-error-fg",
renamed: "bg-status-active-fg",
};
function FilesView({
files,
comments,
onFileClick,
selectedCommit,
activeFiles,
viewedFiles,
}: {
files: FileDiff[];
comments: ReviewComment[];
onFileClick: (filePath: string) => void;
selectedCommit: string | null;
activeFiles: FileDiff[];
viewedFiles: Set<string>;
}) {
const unresolvedCount = comments.filter((c) => !c.resolved).length;
const resolvedCount = comments.filter((c) => c.resolved).length;
const activeFilePaths = new Set(activeFiles.map((f) => f.newPath));
const directoryGroups = useMemo(() => groupFilesByDirectory(files), [files]);
const viewedCount = files.filter((f) => viewedFiles.has(f.newPath)).length;
const totalCount = files.length;
const progressPercent = totalCount > 0 ? (viewedCount / totalCount) * 100 : 0;
return (
<div className="space-y-4">
{/* Review progress */}
{totalCount > 0 && (
<div className="space-y-1.5">
<h4 className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">
Review Progress
</h4>
<div className="h-1 rounded-full bg-muted w-full">
<div
className="h-full rounded-full bg-status-success-fg transition-all duration-300"
style={{ width: `${progressPercent}%` }}
/>
</div>
<span className="text-[10px] text-muted-foreground">
{viewedCount}/{totalCount} files viewed
</span>
</div>
)}
{/* Comment summary */}
{comments.length > 0 && (
<div className="space-y-1.5">
@@ -161,8 +236,8 @@ function FilesView({
</div>
)}
{/* File list */}
<div className="space-y-0.5">
{/* Directory-grouped file tree */}
<div>
<h4 className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider mb-1.5">
Files
{selectedCommit && (
@@ -171,46 +246,74 @@ function FilesView({
</span>
)}
</h4>
{files.map((file) => {
const fileCommentCount = comments.filter(
(c) => c.filePath === file.newPath,
).length;
const isInView = activeFilePaths.has(file.newPath);
const dimmed = selectedCommit && !isInView;
{directoryGroups.map((group) => (
<div key={group.directory}>
{/* Directory header */}
{group.directory && (
<div className="text-[10px] font-mono text-muted-foreground/70 mt-2 first:mt-0 px-2 py-0.5 flex items-center gap-1">
<FolderOpen className="h-3 w-3 shrink-0" />
<span className="truncate">{group.directory}</span>
</div>
)}
{/* Files in directory */}
<div className="space-y-0.5">
{group.files.map((file) => {
const fileCommentCount = comments.filter(
(c) => c.filePath === file.newPath,
).length;
const isInView = activeFilePaths.has(file.newPath);
const dimmed = selectedCommit && !isInView;
const isViewed = viewedFiles.has(file.newPath);
const dotColor = changeTypeDotColor[file.changeType];
return (
<button
key={file.newPath}
className={`
flex w-full items-center gap-1.5 rounded px-2 py-1 text-left text-[11px]
hover:bg-accent/50 transition-colors group
${dimmed ? "opacity-35" : ""}
`}
onClick={() => onFileClick(file.newPath)}
>
<FileCode className="h-3 w-3 text-muted-foreground shrink-0" />
<span className="truncate flex-1 font-mono">
{formatFilePath(file.newPath)}
</span>
<span className="flex items-center gap-1 shrink-0">
{fileCommentCount > 0 && (
<span className="flex items-center gap-0.5 text-muted-foreground">
<MessageSquare className="h-2.5 w-2.5" />
{fileCommentCount}
</span>
)}
<span className="text-diff-add-fg text-[10px]">
<Plus className="h-2.5 w-2.5 inline" />
{file.additions}
</span>
<span className="text-diff-remove-fg text-[10px]">
<Minus className="h-2.5 w-2.5 inline" />
{file.deletions}
</span>
</span>
</button>
);
})}
return (
<button
key={file.newPath}
className={`
flex w-full items-center gap-1.5 rounded py-1 text-left text-[11px]
hover:bg-accent/50 transition-colors group
${group.directory ? "pl-4 pr-2" : "px-2"}
${dimmed ? "opacity-35" : ""}
`}
onClick={() => onFileClick(file.newPath)}
>
{isViewed ? (
<CheckCircle2 className="h-3 w-3 text-status-success-fg shrink-0" />
) : (
<FileCode className="h-3 w-3 text-muted-foreground shrink-0" />
)}
{dotColor && (
<span className={`h-1.5 w-1.5 rounded-full shrink-0 ${dotColor}`} />
)}
<span className="truncate flex-1 font-mono">
{getFileName(file.newPath)}
</span>
<span className="flex items-center gap-1 shrink-0">
{fileCommentCount > 0 && (
<span className="flex items-center gap-0.5 text-muted-foreground">
<MessageSquare className="h-2.5 w-2.5" />
{fileCommentCount}
</span>
)}
{file.additions > 0 && (
<span className="text-diff-add-fg text-[10px]">
<Plus className="h-2.5 w-2.5 inline" />
{file.additions}
</span>
)}
{file.deletions > 0 && (
<span className="text-diff-remove-fg text-[10px]">
<Minus className="h-2.5 w-2.5 inline" />
{file.deletions}
</span>
)}
</span>
</button>
);
})}
</div>
</div>
))}
</div>
</div>
);
@@ -304,12 +407,6 @@ function CommitsView({
/* ─── Helpers ──────────────────────────────────────────── */
function formatFilePath(path: string): string {
const parts = path.split("/");
if (parts.length <= 2) return path;
return parts.slice(-2).join("/");
}
function truncateMessage(msg: string): string {
const firstLine = msg.split("\n")[0];
return firstLine.length > 50 ? firstLine.slice(0, 47) + "..." : firstLine;

View File

@@ -15,8 +15,29 @@ interface ReviewTabProps {
export function ReviewTab({ initiativeId }: ReviewTabProps) {
const [status, setStatus] = useState<ReviewStatus>("pending");
const [selectedCommit, setSelectedCommit] = useState<string | null>(null);
const [viewedFiles, setViewedFiles] = useState<Set<string>>(new Set());
const fileRefs = useRef<Map<string, HTMLDivElement>>(new Map());
const toggleViewed = useCallback((filePath: string) => {
setViewedFiles(prev => {
const next = new Set(prev);
if (next.has(filePath)) {
next.delete(filePath);
} else {
next.add(filePath);
}
return next;
});
}, []);
const registerFileRef = useCallback((filePath: string, el: HTMLDivElement | null) => {
if (el) {
fileRefs.current.set(filePath, el);
} else {
fileRefs.current.delete(filePath);
}
}, []);
// Fetch phases for this initiative
const phasesQuery = trpc.listPhases.useQuery({ initiativeId });
const pendingReviewPhases = useMemo(
@@ -129,7 +150,7 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
lineType: c.lineType as "added" | "removed" | "context",
body: c.body,
author: c.author,
createdAt: c.createdAt instanceof Date ? c.createdAt.toISOString() : String(c.createdAt),
createdAt: typeof c.createdAt === 'string' ? c.createdAt : String(c.createdAt),
resolved: c.resolved,
}));
}, [commentsQuery.data]);
@@ -222,6 +243,7 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
setSelectedPhaseId(id);
setSelectedCommit(null);
setStatus("pending");
setViewedFiles(new Set());
}, []);
const unresolvedCount = comments.filter((c) => !c.resolved).length;
@@ -261,6 +283,8 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
onApprove={handleApprove}
onRequestChanges={handleRequestChanges}
preview={previewState}
viewedCount={viewedFiles.size}
totalCount={allFiles.length}
/>
{/* Main content area — sidebar always rendered to preserve state */}
@@ -285,6 +309,9 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
onAddComment={handleAddComment}
onResolveComment={handleResolveComment}
onUnresolveComment={handleUnresolveComment}
viewedFiles={viewedFiles}
onToggleViewed={toggleViewed}
onRegisterRef={registerFileRef}
/>
)}
</div>
@@ -300,6 +327,7 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
activeFiles={files}
commits={commits}
onSelectCommit={setSelectedCommit}
viewedFiles={viewedFiles}
/>
</div>
</div>

View File

@@ -1,4 +1,4 @@
import type { FileDiff, DiffHunk, DiffLine } from "./types";
import type { FileDiff, FileChangeType, DiffHunk, DiffLine } from "./types";
/**
* Parse a unified diff string into structured FileDiff objects.
@@ -20,9 +20,13 @@ export function parseUnifiedDiff(raw: string): FileDiff[] {
let additions = 0;
let deletions = 0;
// Scan header lines (between "diff --git" and first "@@") for /dev/null markers
let hasOldDevNull = false;
let hasNewDevNull = false;
let i = 1;
// Skip to first hunk header
while (i < lines.length && !lines[i].startsWith("@@")) {
if (lines[i].startsWith("--- /dev/null")) hasOldDevNull = true;
if (lines[i].startsWith("+++ /dev/null")) hasNewDevNull = true;
i++;
}
@@ -86,7 +90,19 @@ export function parseUnifiedDiff(raw: string): FileDiff[] {
hunks.push({ header, oldStart, oldCount, newStart, newCount, lines: hunkLines });
}
files.push({ oldPath, newPath, hunks, additions, deletions });
// Derive changeType from header markers and path comparison
let changeType: FileChangeType;
if (hasOldDevNull) {
changeType = "added";
} else if (hasNewDevNull) {
changeType = "deleted";
} else if (oldPath !== newPath) {
changeType = "renamed";
} else {
changeType = "modified";
}
files.push({ oldPath, newPath, hunks, additions, deletions, changeType });
}
return files;

View File

@@ -14,12 +14,15 @@ export interface DiffLine {
newLineNumber: number | null;
}
export type FileChangeType = 'added' | 'modified' | 'deleted' | 'renamed';
export interface FileDiff {
oldPath: string;
newPath: string;
hunks: DiffHunk[];
additions: number;
deletions: number;
changeType: FileChangeType;
}
export interface ReviewComment {

View File

@@ -0,0 +1,163 @@
import { useState, useEffect, useMemo } from "react";
import type { ThemedToken } from "shiki";
/* ── Lazy singleton highlighter ─────────────────────────── */
let highlighterPromise: Promise<Awaited<
ReturnType<typeof import("shiki")["createHighlighter"]>
> | null> | null = null;
const LANGS = [
"typescript",
"javascript",
"tsx",
"jsx",
"json",
"css",
"html",
"yaml",
"markdown",
"bash",
"python",
"go",
"rust",
"sql",
"toml",
"xml",
] as const;
function getHighlighter() {
if (!highlighterPromise) {
highlighterPromise = import("shiki")
.then(({ createHighlighter }) =>
createHighlighter({
themes: ["github-dark-default"],
langs: [...LANGS],
}),
)
.catch(() => null);
}
return highlighterPromise;
}
// Pre-warm on module load (non-blocking)
getHighlighter();
/* ── Language detection ──────────────────────────────────── */
const EXT_TO_LANG: Record<string, string> = {
ts: "typescript",
tsx: "tsx",
js: "javascript",
jsx: "jsx",
mjs: "javascript",
cjs: "javascript",
json: "json",
css: "css",
scss: "css",
html: "html",
htm: "html",
yml: "yaml",
yaml: "yaml",
md: "markdown",
mdx: "markdown",
sh: "bash",
bash: "bash",
zsh: "bash",
py: "python",
go: "go",
rs: "rust",
sql: "sql",
toml: "toml",
xml: "xml",
};
function detectLang(path: string): string | null {
const ext = path.split(".").pop()?.toLowerCase() ?? "";
return EXT_TO_LANG[ext] ?? null;
}
/* ── Types ───────────────────────────────────────────────── */
export type TokenizedLine = ThemedToken[];
/** Maps newLineNumber → highlighted tokens for that line */
export type LineTokenMap = Map<number, TokenizedLine>;
interface DiffLineInput {
content: string;
newLineNumber: number | null;
type: "added" | "removed" | "context";
}
/* ── Hook ────────────────────────────────────────────────── */
/**
* Highlights the "new-side" content of a file diff.
* Returns null until highlighting is ready (progressive enhancement).
* Only context + added lines are highlighted (removed lines fall back to plain text).
*/
export function useHighlightedFile(
filePath: string,
allLines: DiffLineInput[],
): LineTokenMap | null {
const [tokenMap, setTokenMap] = useState<LineTokenMap | null>(null);
// Build stable code string + line number mapping
const { code, lineNums, cacheKey } = useMemo(() => {
const entries: { lineNum: number; content: string }[] = [];
for (const line of allLines) {
if (
line.newLineNumber !== null &&
(line.type === "context" || line.type === "added")
) {
entries.push({ lineNum: line.newLineNumber, content: line.content });
}
}
entries.sort((a, b) => a.lineNum - b.lineNum);
const c = entries.map((e) => e.content).join("\n");
return {
code: c,
lineNums: entries.map((e) => e.lineNum),
cacheKey: `${filePath}:${c.length}:${entries.length}`,
};
}, [filePath, allLines]);
useEffect(() => {
const lang = detectLang(filePath);
if (!lang || !code) {
setTokenMap(null);
return;
}
let cancelled = false;
getHighlighter().then((highlighter) => {
if (cancelled || !highlighter) return;
try {
const result = highlighter.codeToTokens(code, {
lang: lang as Parameters<typeof highlighter.codeToTokens>[1]["lang"],
theme: "github-dark-default",
});
const map: LineTokenMap = new Map();
result.tokens.forEach((lineTokens: ThemedToken[], idx: number) => {
if (idx < lineNums.length) {
map.set(lineNums[idx], lineTokens);
}
});
if (!cancelled) setTokenMap(map);
} catch {
// Language not loaded or parse error — no highlighting
}
});
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [cacheKey]);
return tokenMap;
}

559
package-lock.json generated
View File

@@ -28,6 +28,7 @@
"motion": "^12.34.5",
"nanoid": "^5.1.6",
"pino": "^10.3.0",
"shiki": "^4.0.1",
"simple-git": "^3.30.0",
"unique-names-generator": "^4.7.1",
"zod": "^4.3.6"
@@ -3974,6 +3975,106 @@
"integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==",
"license": "MIT"
},
"node_modules/@shikijs/core": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@shikijs/core/-/core-4.0.1.tgz",
"integrity": "sha512-vWvqi9JNgz1dRL9Nvog5wtx7RuNkf7MEPl2mU/cyUUxJeH1CAr3t+81h8zO8zs7DK6cKLMoU9TvukWIDjP4Lzg==",
"license": "MIT",
"dependencies": {
"@shikijs/primitive": "4.0.1",
"@shikijs/types": "4.0.1",
"@shikijs/vscode-textmate": "^10.0.2",
"@types/hast": "^3.0.4",
"hast-util-to-html": "^9.0.5"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@shikijs/engine-javascript": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-4.0.1.tgz",
"integrity": "sha512-DJK9NiwtGYqMuKCRO4Ip0FKNDQpmaiS+K5bFjJ7DWFn4zHueDWgaUG8kAofkrnXF6zPPYYQY7J5FYVW9MbZyBg==",
"license": "MIT",
"dependencies": {
"@shikijs/types": "4.0.1",
"@shikijs/vscode-textmate": "^10.0.2",
"oniguruma-to-es": "^4.3.4"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@shikijs/engine-oniguruma": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-4.0.1.tgz",
"integrity": "sha512-oCWdCTDch3J8Kc0OZJ98KuUPC02O1VqIE3W/e2uvrHqTxYRR21RGEJMtchrgrxhsoJJCzmIciKsqG+q/yD+Cxg==",
"license": "MIT",
"dependencies": {
"@shikijs/types": "4.0.1",
"@shikijs/vscode-textmate": "^10.0.2"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@shikijs/langs": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-4.0.1.tgz",
"integrity": "sha512-v/mluaybWdnGJR4GqAR6zh8qAZohW9k+cGYT28Y7M8+jLbC0l4yG085O1A+WkseHTn+awd+P3UBymb2+MXFc8w==",
"license": "MIT",
"dependencies": {
"@shikijs/types": "4.0.1"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@shikijs/primitive": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@shikijs/primitive/-/primitive-4.0.1.tgz",
"integrity": "sha512-ns0hHZc5eWZuvuIEJz2pTx3Qecz0aRVYumVQJ8JgWY2tq/dH8WxdcVM49Fc2NsHEILNIT6vfdW9MF26RANWiTA==",
"license": "MIT",
"dependencies": {
"@shikijs/types": "4.0.1",
"@shikijs/vscode-textmate": "^10.0.2",
"@types/hast": "^3.0.4"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@shikijs/themes": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-4.0.1.tgz",
"integrity": "sha512-FW41C/D6j/yKQkzVdjrRPiJCtgeDaYRJFEyCKFCINuRJRj9WcmubhP4KQHPZ4+9eT87jruSrYPyoblNRyDFzvA==",
"license": "MIT",
"dependencies": {
"@shikijs/types": "4.0.1"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@shikijs/types": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@shikijs/types/-/types-4.0.1.tgz",
"integrity": "sha512-EaygPEn57+jJ76mw+nTLvIpJMAcMPokFbrF8lufsZP7Ukk+ToJYEcswN1G0e49nUZAq7aCQtoeW219A8HK1ZOw==",
"license": "MIT",
"dependencies": {
"@shikijs/vscode-textmate": "^10.0.2",
"@types/hast": "^3.0.4"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@shikijs/vscode-textmate": {
"version": "10.0.2",
"resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz",
"integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==",
"license": "MIT"
},
"node_modules/@sindresorhus/merge-streams": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz",
@@ -4905,6 +5006,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/hast": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz",
"integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==",
"license": "MIT",
"dependencies": {
"@types/unist": "*"
}
},
"node_modules/@types/js-yaml": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz",
@@ -4928,6 +5038,15 @@
"@types/mdurl": "^2"
}
},
"node_modules/@types/mdast": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz",
"integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==",
"license": "MIT",
"dependencies": {
"@types/unist": "*"
}
},
"node_modules/@types/mdurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz",
@@ -4961,6 +5080,12 @@
"@types/react": "^19.2.0"
}
},
"node_modules/@types/unist": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
"license": "MIT"
},
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
@@ -4984,6 +5109,12 @@
"@types/node": "*"
}
},
"node_modules/@ungap/structured-clone": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz",
"integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==",
"license": "ISC"
},
"node_modules/@vitejs/plugin-react": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
@@ -5514,6 +5645,16 @@
],
"license": "CC-BY-4.0"
},
"node_modules/ccount": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz",
"integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/chai": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
@@ -5524,6 +5665,26 @@
"node": ">=18"
}
},
"node_modules/character-entities-html4": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz",
"integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/character-entities-legacy": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz",
"integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/chokidar": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
@@ -5603,6 +5764,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/comma-separated-tokens": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz",
"integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/commander": {
"version": "12.1.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz",
@@ -5715,6 +5886,15 @@
"node": ">=4.0.0"
}
},
"node_modules/dequal": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -5730,6 +5910,19 @@
"integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
"license": "MIT"
},
"node_modules/devlop": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz",
"integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==",
"license": "MIT",
"dependencies": {
"dequal": "^2.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/didyoumean": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
@@ -6930,6 +7123,42 @@
"node": ">= 0.4"
}
},
"node_modules/hast-util-to-html": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz",
"integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"@types/unist": "^3.0.0",
"ccount": "^2.0.0",
"comma-separated-tokens": "^2.0.0",
"hast-util-whitespace": "^3.0.0",
"html-void-elements": "^3.0.0",
"mdast-util-to-hast": "^13.0.0",
"property-information": "^7.0.0",
"space-separated-tokens": "^2.0.0",
"stringify-entities": "^4.0.0",
"zwitch": "^2.0.4"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/hast-util-whitespace": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz",
"integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/help-me": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz",
@@ -6944,6 +7173,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/html-void-elements": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz",
"integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/human-signals": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-8.0.1.tgz",
@@ -7341,6 +7580,27 @@
"node": ">= 20"
}
},
"node_modules/mdast-util-to-hast": {
"version": "13.2.1",
"resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz",
"integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"@types/mdast": "^4.0.0",
"@ungap/structured-clone": "^1.0.0",
"devlop": "^1.0.0",
"micromark-util-sanitize-uri": "^2.0.0",
"trim-lines": "^3.0.0",
"unist-util-position": "^5.0.0",
"unist-util-visit": "^5.0.0",
"vfile": "^6.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz",
@@ -7357,6 +7617,95 @@
"node": ">= 8"
}
},
"node_modules/micromark-util-character": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz",
"integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==",
"funding": [
{
"type": "GitHub Sponsors",
"url": "https://github.com/sponsors/unifiedjs"
},
{
"type": "OpenCollective",
"url": "https://opencollective.com/unified"
}
],
"license": "MIT",
"dependencies": {
"micromark-util-symbol": "^2.0.0",
"micromark-util-types": "^2.0.0"
}
},
"node_modules/micromark-util-encode": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz",
"integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==",
"funding": [
{
"type": "GitHub Sponsors",
"url": "https://github.com/sponsors/unifiedjs"
},
{
"type": "OpenCollective",
"url": "https://opencollective.com/unified"
}
],
"license": "MIT"
},
"node_modules/micromark-util-sanitize-uri": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz",
"integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==",
"funding": [
{
"type": "GitHub Sponsors",
"url": "https://github.com/sponsors/unifiedjs"
},
{
"type": "OpenCollective",
"url": "https://opencollective.com/unified"
}
],
"license": "MIT",
"dependencies": {
"micromark-util-character": "^2.0.0",
"micromark-util-encode": "^2.0.0",
"micromark-util-symbol": "^2.0.0"
}
},
"node_modules/micromark-util-symbol": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz",
"integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==",
"funding": [
{
"type": "GitHub Sponsors",
"url": "https://github.com/sponsors/unifiedjs"
},
{
"type": "OpenCollective",
"url": "https://opencollective.com/unified"
}
],
"license": "MIT"
},
"node_modules/micromark-util-types": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz",
"integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==",
"funding": [
{
"type": "GitHub Sponsors",
"url": "https://github.com/sponsors/unifiedjs"
},
{
"type": "OpenCollective",
"url": "https://opencollective.com/unified"
}
],
"license": "MIT"
},
"node_modules/micromatch": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
@@ -7728,6 +8077,23 @@
"wrappy": "1"
}
},
"node_modules/oniguruma-parser": {
"version": "0.12.1",
"resolved": "https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.1.tgz",
"integrity": "sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w==",
"license": "MIT"
},
"node_modules/oniguruma-to-es": {
"version": "4.3.4",
"resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-4.3.4.tgz",
"integrity": "sha512-3VhUGN3w2eYxnTzHn+ikMI+fp/96KoRSVK9/kMTcFqj1NRDh2IhQCKvYxDnWePKRXY/AqH+Fuiyb7VHSzBjHfA==",
"license": "MIT",
"dependencies": {
"oniguruma-parser": "^0.12.1",
"regex": "^6.0.1",
"regex-recursion": "^6.0.2"
}
},
"node_modules/orderedmap": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz",
@@ -8176,6 +8542,16 @@
],
"license": "MIT"
},
"node_modules/property-information": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz",
"integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/prosemirror-changeset": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.3.1.tgz",
@@ -8608,6 +8984,30 @@
"node": ">= 4"
}
},
"node_modules/regex": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz",
"integrity": "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==",
"license": "MIT",
"dependencies": {
"regex-utilities": "^2.3.0"
}
},
"node_modules/regex-recursion": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/regex-recursion/-/regex-recursion-6.0.2.tgz",
"integrity": "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==",
"license": "MIT",
"dependencies": {
"regex-utilities": "^2.3.0"
}
},
"node_modules/regex-utilities": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/regex-utilities/-/regex-utilities-2.3.0.tgz",
"integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==",
"license": "MIT"
},
"node_modules/resolve": {
"version": "1.22.11",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
@@ -8910,6 +9310,25 @@
"node": ">=8"
}
},
"node_modules/shiki": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/shiki/-/shiki-4.0.1.tgz",
"integrity": "sha512-EkAEhDTN5WhpoQFXFw79OHIrSAfHhlImeCdSyg4u4XvrpxKEmdo/9x/HWSowujAnUrFsGOwWiE58a6GVentMnQ==",
"license": "MIT",
"dependencies": {
"@shikijs/core": "4.0.1",
"@shikijs/engine-javascript": "4.0.1",
"@shikijs/engine-oniguruma": "4.0.1",
"@shikijs/langs": "4.0.1",
"@shikijs/themes": "4.0.1",
"@shikijs/types": "4.0.1",
"@shikijs/vscode-textmate": "^10.0.2",
"@types/hast": "^3.0.4"
},
"engines": {
"node": ">=20"
}
},
"node_modules/siginfo": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
@@ -9038,6 +9457,16 @@
"source-map": "^0.6.0"
}
},
"node_modules/space-separated-tokens": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz",
"integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
@@ -9076,6 +9505,20 @@
"safe-buffer": "~5.2.0"
}
},
"node_modules/stringify-entities": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz",
"integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==",
"license": "MIT",
"dependencies": {
"character-entities-html4": "^2.0.0",
"character-entities-legacy": "^3.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/strip-bom-string": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/strip-bom-string/-/strip-bom-string-1.0.0.tgz",
@@ -9402,6 +9845,16 @@
"node": ">=8.0"
}
},
"node_modules/trim-lines": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz",
"integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/ts-interface-checker": {
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
@@ -9493,6 +9946,74 @@
"node": ">=8"
}
},
"node_modules/unist-util-is": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz",
"integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==",
"license": "MIT",
"dependencies": {
"@types/unist": "^3.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/unist-util-position": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz",
"integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==",
"license": "MIT",
"dependencies": {
"@types/unist": "^3.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/unist-util-stringify-position": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz",
"integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==",
"license": "MIT",
"dependencies": {
"@types/unist": "^3.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/unist-util-visit": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz",
"integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==",
"license": "MIT",
"dependencies": {
"@types/unist": "^3.0.0",
"unist-util-is": "^6.0.0",
"unist-util-visit-parents": "^6.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/unist-util-visit-parents": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz",
"integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==",
"license": "MIT",
"dependencies": {
"@types/unist": "^3.0.0",
"unist-util-is": "^6.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/unplugin": {
"version": "2.3.11",
"resolved": "https://registry.npmjs.org/unplugin/-/unplugin-2.3.11.tgz",
@@ -9598,6 +10119,34 @@
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/vfile": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz",
"integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==",
"license": "MIT",
"dependencies": {
"@types/unist": "^3.0.0",
"vfile-message": "^4.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/vfile-message": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz",
"integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==",
"license": "MIT",
"dependencies": {
"@types/unist": "^3.0.0",
"unist-util-stringify-position": "^4.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/vite": {
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
@@ -9862,6 +10411,16 @@
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/zwitch": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz",
"integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"packages/shared": {
"name": "@codewalk-district/shared",
"version": "0.0.1"

View File

@@ -44,6 +44,7 @@
"motion": "^12.34.5",
"nanoid": "^5.1.6",
"pino": "^10.3.0",
"shiki": "^4.0.1",
"simple-git": "^3.30.0",
"unique-names-generator": "^4.7.1",
"zod": "^4.3.6"