164 lines
5.9 KiB
TypeScript
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>
|
|
);
|
|
}
|