Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 0 additions & 37 deletions TASK.md

This file was deleted.

55 changes: 54 additions & 1 deletion web/src/components/dashboard/config-diff.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
'use client';

import { diffLines } from 'diff';
import { RotateCcw } from 'lucide-react';
import { useMemo } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';

interface ConfigDiffProps {
Expand All @@ -11,6 +13,15 @@ interface ConfigDiffProps {
modified: object;
/** Optional title override. */
title?: string;
/**
* Top-level config section keys that have pending changes.
* When provided alongside `onRevertSection`, per-section revert buttons
* are rendered in the card header so users can cherry-pick reverts without
* opening the full diff modal.
*/
changedSections?: string[];
/** Called with the section key when the user clicks a per-section revert button. */
onRevertSection?: (section: string) => void;
}

interface DiffLine {
Expand All @@ -26,12 +37,25 @@ interface DiffLine {
* is rendered. Otherwise a card is rendered showing counts of added and removed lines
* and a scrollable, color-coded diff where each line is prefixed with `+`, `-`, or a space.
*
* When `changedSections` and `onRevertSection` are both provided, a strip of
* per-section badge + revert-button pairs is rendered in the card header,
* letting users cherry-pick which sections to roll back without opening the
* full diff modal.
*
* @param original - The original configuration object to compare.
* @param modified - The modified configuration object to compare.
* @param title - Optional title for the card; defaults to "Pending Changes".
* @param changedSections - Top-level section keys with pending changes.
* @param onRevertSection - Called with a section key when the user reverts it.
* @returns A React element containing either a "no changes" card or a color-coded, line-by-line diff view with added/removed counts.
*/
export function ConfigDiff({ original, modified, title = 'Pending Changes' }: ConfigDiffProps) {
export function ConfigDiff({
original,
modified,
title = 'Pending Changes',
changedSections,
onRevertSection,
}: ConfigDiffProps) {
const { lines, addedCount, removedCount } = useMemo(() => {
const originalText = JSON.stringify(original, null, 2);
const modifiedText = JSON.stringify(modified, null, 2);
Expand Down Expand Up @@ -85,6 +109,35 @@ export function ConfigDiff({ original, modified, title = 'Pending Changes' }: Co
<span className="text-red-400">-{removedCount}</span>
</div>
</div>

{/* Per-section revert buttons — shown when caller provides both props */}
{changedSections && changedSections.length > 0 && onRevertSection && (
<fieldset
aria-label="Revert individual sections"
className="flex flex-wrap items-center gap-2 rounded-md border bg-muted/30 p-3"
>
<legend className="sr-only">Revert individual sections</legend>
<span className="text-xs text-muted-foreground" aria-hidden="true">
Revert section:
</span>
{changedSections.map((section) => (
<div key={section} className="flex items-center gap-1">
<span className="rounded border border-yellow-500/30 bg-yellow-500/20 px-2 py-0.5 text-xs capitalize text-yellow-300">
{section}
</span>
<Button
variant="ghost"
size="sm"
className="h-6 px-1.5 text-xs text-muted-foreground hover:text-destructive"
onClick={() => onRevertSection(section)}
aria-label={`Revert ${section} changes`}
>
<RotateCcw className="h-3 w-3" aria-hidden="true" />
</Button>
</div>
))}
</fieldset>
)}
</CardHeader>
<CardContent>
<section
Expand Down
42 changes: 35 additions & 7 deletions web/src/components/dashboard/config-editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -545,14 +545,35 @@ export function ConfigEditor() {
toast.info('Reverted to previous saved state. Save again to apply.');
}, [prevSavedConfig, guildId]);

// ── Auto-dismiss "Undo last save" button after 30 s ───────────
useEffect(() => {
if (!prevSavedConfig) return;
const timer = window.setTimeout(() => {
setPrevSavedConfig(null);
}, 30_000);
return () => window.clearTimeout(timer);
}, [prevSavedConfig]);

// ── Keyboard shortcut: Ctrl/Cmd+S → open diff preview ─────────
useEffect(() => {
function onKeyDown(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key === 's') {
e.preventDefault();
if (hasChanges && !saving && !hasValidationErrors) {
openDiffModal();
}
if (!(e.metaKey || e.ctrlKey) || e.key !== 's') return;

// Don't intercept the browser's native save when the user is typing in a
// form field — e.g. if they genuinely want to save a <textarea> selection
// via the OS, or simply don't expect a config-save to fire mid-edit.
const target = e.target as HTMLElement | null;
const isTyping =
target?.tagName === 'INPUT' ||
target?.tagName === 'TEXTAREA' ||
target?.tagName === 'SELECT' ||
target?.isContentEditable;

if (isTyping) return;

e.preventDefault();
if (hasChanges && !saving && !hasValidationErrors) {
openDiffModal();
}
}
window.addEventListener('keydown', onKeyDown);
Expand Down Expand Up @@ -944,7 +965,7 @@ export function ConfigEditor() {
<span
className="absolute -right-1 -top-1 h-2.5 w-2.5 rounded-full bg-yellow-400 ring-2 ring-background"
aria-hidden="true"
title="Unsaved changes"
title={`Unsaved changes in ${changedSections.length} section${changedSections.length === 1 ? '' : 's'}: ${changedSections.join(', ')}`}
/>
)}
</div>
Expand Down Expand Up @@ -2080,7 +2101,14 @@ export function ConfigEditor() {
</div>
</div>

{hasChanges && savedConfig && <ConfigDiff original={savedConfig} modified={draftConfig} />}
{hasChanges && savedConfig && (
<ConfigDiff
original={savedConfig}
modified={draftConfig}
changedSections={changedSections}
onRevertSection={revertSection}
/>
)}

{savedConfig && (
<ConfigDiffModal
Expand Down
Loading