Files
maestro/ui/src/components/userfolder/ScriptDiffReview.tsx
T

164 lines
5.9 KiB
TypeScript

/**
* ScriptDiffReview — side-by-side diff view for a pending .next.js patch.
*
* Design choice: .next.js files appear as sibling rows in the FileTree just like
* any other file. Clicking a .next.js file opens this component (instead of
* MonacoFileEditor). The diff view shows the current .js on the left (original)
* and the candidate .next.js on the right (modified), read-only.
*
* Accept: archives scripts/{name}.js to trash, renames .next.js into place.
* Reject: moves .next.js to trash; original is untouched.
* Both actions invalidate the scripts listing and navigate back to scripts/{name}.js.
*/
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { DiffEditor } from '@monaco-editor/react';
type ShowToast = (message: string, variant?: 'success' | 'error') => void;
interface ScriptDiffReviewProps {
/** The bare script name without extension, e.g. "myscript" */
scriptName: string;
onClose: (acceptedScript?: string) => void;
showToast?: ShowToast;
}
interface DiffResponse {
current: string | null;
candidate: string;
candidateMtime: string;
}
async function fetchDiff(name: string): Promise<DiffResponse> {
const res = await fetch(`/api/users/me/browser-macros/${encodeURIComponent(name)}/diff`, {
credentials: 'include',
});
if (res.status === 404) throw new Error('No pending patch found.');
if (!res.ok) throw new Error(`Diff fetch failed: ${res.status}`);
return res.json() as Promise<DiffResponse>;
}
async function postAccept(name: string): Promise<void> {
const res = await fetch(`/api/users/me/browser-macros/${encodeURIComponent(name)}/accept`, {
method: 'POST',
credentials: 'include',
});
if (!res.ok) throw new Error(`Accept failed: ${res.status}`);
}
async function postReject(name: string): Promise<void> {
const res = await fetch(`/api/users/me/browser-macros/${encodeURIComponent(name)}/reject`, {
method: 'POST',
credentials: 'include',
});
if (!res.ok) throw new Error(`Reject failed: ${res.status}`);
}
export function ScriptDiffReview({ scriptName, onClose, showToast }: ScriptDiffReviewProps) {
const qc = useQueryClient();
const notifyError = (label: string, err: unknown) => {
const msg = `${label}: ${err instanceof Error ? err.message : 'Unknown error'}`;
if (showToast) showToast(msg, 'error');
else console.error(msg);
};
const diffQuery = useQuery<DiffResponse, Error>({
queryKey: ['userfolder', 'diff', scriptName],
queryFn: () => fetchDiff(scriptName),
staleTime: 10_000,
refetchOnWindowFocus: false,
});
const candidateMtimeLabel = diffQuery.data?.candidateMtime
? new Date(diffQuery.data.candidateMtime).toLocaleString()
: '';
async function handleAccept() {
try {
await postAccept(scriptName);
// Invalidate browser-macros listing so the .next.js row disappears
qc.invalidateQueries({ queryKey: ['userfolder', 'list', 'browser-macros'] });
qc.removeQueries({ queryKey: ['userfolder', 'diff', scriptName] });
// Navigate back to the now-accepted script
onClose(`${scriptName}.js`);
} catch (err) {
notifyError('Accept failed', err);
}
}
async function handleReject() {
try {
await postReject(scriptName);
qc.invalidateQueries({ queryKey: ['userfolder', 'list', 'browser-macros'] });
qc.removeQueries({ queryKey: ['userfolder', 'diff', scriptName] });
// Navigate back to the original (unchanged) script if it exists; otherwise close
const hasOriginal = diffQuery.data?.current !== null;
onClose(hasOriginal ? `${scriptName}.js` : undefined);
} catch (err) {
notifyError('Reject failed', err);
}
}
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="flex-shrink-0 flex items-center gap-3 px-4 py-2.5 border-b border-hairline bg-surface-2/50">
<div className="flex-1 min-w-0">
<span className="text-xs font-semibold text-slate-700">Patch review: </span>
<span className="font-mono text-xs text-slate-600">{scriptName}.next.js</span>
{candidateMtimeLabel && (
<span className="ml-2 text-2xs text-slate-400">{candidateMtimeLabel}</span>
)}
</div>
<div className="flex items-center gap-2">
<span className="text-2xs text-slate-400 font-mono">original patch</span>
<button
type="button"
onClick={handleReject}
disabled={diffQuery.isLoading}
className="px-3 py-1 rounded-md text-2xs font-semibold bg-red-500 text-white hover:bg-red-600 disabled:opacity-50 transition-colors"
>
Reject
</button>
<button
type="button"
onClick={handleAccept}
disabled={diffQuery.isLoading}
className="px-3 py-1 rounded-md text-2xs font-semibold bg-green-600 text-white hover:bg-green-700 disabled:opacity-50 transition-colors"
>
Accept
</button>
</div>
</div>
{/* Body */}
<div className="flex-1 min-h-0 overflow-hidden">
{diffQuery.isLoading && (
<div className="h-full flex items-center justify-center text-[13px] text-slate-400">
Loading diff
</div>
)}
{diffQuery.isError && (
<div className="h-full flex items-center justify-center text-[13px] text-red-500">
{diffQuery.error?.message ?? 'Failed to load diff.'}
</div>
)}
{diffQuery.data && (
<DiffEditor
height="100%"
language="javascript"
original={diffQuery.data.current ?? ''}
modified={diffQuery.data.candidate}
options={{
readOnly: true,
renderSideBySide: true,
minimap: { enabled: false },
scrollBeyondLastLine: false,
fontSize: 12,
}}
/>
)}
</div>
</div>
);
}