mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-18 06:23:18 +00:00
feat(editor): opt-in diff preview before save (#855)
* feat(editor): add useComposeDiffPreviewEnabled hook * feat(editor): add ComposeDiffPreviewDialog component * fix(editor): replace HTML entity with Unicode arrow in ComposeDiffPreviewDialog * feat(editor): add diff preview toggle to Appearance settings Added a new 'Diff preview before save' toggle in the Display section of the Appearance settings panel. Users can now enable or disable the side-by-side diff view before compose and env file edits are saved to disk. * feat(editor): wire diff preview dialog into compose save flow * fix(editor): snapshot diff content at open time and fix event name - Fix useComposeDiffPreviewEnabled and useDeployFeedbackEnabled to use the canonical SENCHO_SETTINGS_CHANGED constant from @/lib/events instead of the hardcoded string literal (wrong value) - Snapshot language, original, modified, and fileName into diffPreview state at click time to prevent tab-switching from corrupting dialog content mid-review - Remove React.MouseEvent from diffPreview state; pass a no-op stub to deployStack in the confirm path (preventDefault/stopPropagation are no-ops on an already-settled event anyway) - Add diff-modal screenshot and document the feature in editor.mdx and settings.mdx * docs(editor): add settings-toggle screenshot for diff preview feature
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
import { Suspense } from 'react';
|
||||
import { DiffEditor } from '@/lib/monacoLoader';
|
||||
import { FileDiff, Loader2 } from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
export interface ComposeDiffPreviewDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
stackName: string;
|
||||
fileName: string;
|
||||
language: 'yaml' | 'ini';
|
||||
original: string;
|
||||
modified: string;
|
||||
actionLabel: 'Save' | 'Save & deploy';
|
||||
confirming: boolean;
|
||||
isDarkMode: boolean;
|
||||
onConfirm: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
export function ComposeDiffPreviewDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
stackName,
|
||||
fileName,
|
||||
language,
|
||||
original,
|
||||
modified,
|
||||
actionLabel,
|
||||
confirming,
|
||||
isDarkMode,
|
||||
onConfirm,
|
||||
}: ComposeDiffPreviewDialogProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-5xl w-[95vw] p-0 gap-0">
|
||||
<DialogHeader className="px-6 pt-6 pb-4 border-b border-glass-border">
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<FileDiff className="w-4 h-4" strokeWidth={1.5} />
|
||||
<span>Review changes to {stackName}</span>
|
||||
<span className="font-mono tabular-nums text-xs text-stat-subtitle">{fileName}</span>
|
||||
</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
Review the diff between on-disk content and unsaved editor changes before saving.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="px-6 pb-4 pt-3">
|
||||
<div className="h-[55vh] border border-glass-border rounded-md overflow-hidden">
|
||||
<Suspense fallback={<div className="w-full h-full" aria-busy="true" />}>
|
||||
<DiffEditor
|
||||
height="100%"
|
||||
language={language}
|
||||
theme={isDarkMode ? 'vs-dark' : 'vs'}
|
||||
original={original}
|
||||
modified={modified}
|
||||
options={{
|
||||
readOnly: true,
|
||||
renderSideBySide: true,
|
||||
minimap: { enabled: false },
|
||||
scrollBeyondLastLine: false,
|
||||
fontFamily: "'Geist Mono', monospace",
|
||||
fontSize: 12,
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="px-6 py-4 border-t border-glass-border flex flex-row items-center justify-between sm:justify-between gap-4">
|
||||
<span className="font-mono text-xs text-stat-subtitle">ON DISK → UNSAVED</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={confirming}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => onConfirm()}
|
||||
disabled={confirming}
|
||||
>
|
||||
{confirming && (
|
||||
<Loader2 className="w-4 h-4 mr-1.5 animate-spin" strokeWidth={1.5} />
|
||||
)}
|
||||
{actionLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -78,6 +78,8 @@ import type { FilterChip, StackMenuCtx } from '@/components/sidebar/sidebar-type
|
||||
import { useBulkStackActions, type BulkAction } from '@/hooks/useBulkStackActions';
|
||||
import { isInputFocused, isPaletteOpen } from '@/lib/keyboard-guards';
|
||||
import { StackFileExplorer } from '@/components/files/StackFileExplorer';
|
||||
import { useComposeDiffPreviewEnabled } from '@/hooks/use-compose-diff-preview-enabled';
|
||||
import { ComposeDiffPreviewDialog } from '@/components/ComposeDiffPreviewDialog';
|
||||
|
||||
interface ContainerInfo {
|
||||
Id: string;
|
||||
@@ -328,6 +330,7 @@ export default function EditorLayout() {
|
||||
window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
);
|
||||
const isDarkMode = theme === 'dark' || (theme === 'auto' && systemDark);
|
||||
const [diffPreviewEnabled] = useComposeDiffPreviewEnabled();
|
||||
const [activeView, setActiveView] = useState<'dashboard' | 'editor' | 'host-console' | 'resources' | 'templates' | 'global-observability' | 'fleet' | 'audit-log' | 'scheduled-ops' | 'auto-updates' | 'settings'>('dashboard');
|
||||
const [settingsSection, setSettingsSection] = useState<SectionId>('appearance');
|
||||
const [securityHistoryOpen, setSecurityHistoryOpen] = useState(false);
|
||||
@@ -335,6 +338,14 @@ export default function EditorLayout() {
|
||||
const [schedulePrefill, setSchedulePrefill] = useState<ScheduleTaskPrefill | null>(null);
|
||||
const handlePrefillConsumed = useCallback(() => setSchedulePrefill(null), []);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [diffPreview, setDiffPreview] = useState<{
|
||||
mode: 'save' | 'save-and-deploy';
|
||||
language: 'yaml' | 'ini';
|
||||
original: string;
|
||||
modified: string;
|
||||
fileName: string;
|
||||
} | null>(null);
|
||||
const [diffPreviewConfirming, setDiffPreviewConfirming] = useState(false);
|
||||
const [editingCompose, setEditingCompose] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [stackStatuses, setStackStatuses] = useState<StackStatus>({});
|
||||
@@ -1260,6 +1271,40 @@ export default function EditorLayout() {
|
||||
}
|
||||
};
|
||||
|
||||
const requestSave = () => {
|
||||
const isCompose = activeTab === 'compose';
|
||||
const orig = isCompose ? originalContent : originalEnvContent;
|
||||
const curr = isCompose ? content : envContent;
|
||||
if (diffPreviewEnabled && activeTab !== 'files' && curr !== orig) {
|
||||
setDiffPreview({
|
||||
mode: 'save',
|
||||
language: isCompose ? 'yaml' : 'ini',
|
||||
original: orig,
|
||||
modified: curr,
|
||||
fileName: isCompose ? 'compose.yaml' : (selectedEnvFile || '.env'),
|
||||
});
|
||||
} else {
|
||||
void saveFile();
|
||||
}
|
||||
};
|
||||
|
||||
const requestSaveAndDeploy = (e: React.MouseEvent) => {
|
||||
const isCompose = activeTab === 'compose';
|
||||
const orig = isCompose ? originalContent : originalEnvContent;
|
||||
const curr = isCompose ? content : envContent;
|
||||
if (diffPreviewEnabled && activeTab !== 'files' && curr !== orig) {
|
||||
setDiffPreview({
|
||||
mode: 'save-and-deploy',
|
||||
language: isCompose ? 'yaml' : 'ini',
|
||||
original: orig,
|
||||
modified: curr,
|
||||
fileName: isCompose ? 'compose.yaml' : (selectedEnvFile || '.env'),
|
||||
});
|
||||
} else {
|
||||
void handleSaveAndDeploy(e);
|
||||
}
|
||||
};
|
||||
|
||||
const rollbackStack = async () => {
|
||||
if (!selectedFile || isStackBusy(selectedFile)) return;
|
||||
const stackFile = selectedFile;
|
||||
@@ -2866,7 +2911,7 @@ export default function EditorLayout() {
|
||||
</Button>
|
||||
) : (
|
||||
<div className="flex items-center">
|
||||
<Button size="sm" variant="default" className="rounded-l-lg rounded-r-none" onClick={handleSaveAndDeploy} disabled={loadingAction === 'deploy'}>
|
||||
<Button size="sm" variant="default" className="rounded-l-lg rounded-r-none" onClick={requestSaveAndDeploy} disabled={loadingAction === 'deploy'}>
|
||||
<Rocket className="w-4 h-4 mr-2" strokeWidth={1.5} />
|
||||
Save & Deploy
|
||||
</Button>
|
||||
@@ -2877,7 +2922,7 @@ export default function EditorLayout() {
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={saveFile}>
|
||||
<DropdownMenuItem onClick={requestSave}>
|
||||
<Save className="w-4 h-4 mr-2" strokeWidth={1.5} />
|
||||
Save Only
|
||||
</DropdownMenuItem>
|
||||
@@ -3199,6 +3244,36 @@ export default function EditorLayout() {
|
||||
onClose={() => setStackMisconfigScanId(null)}
|
||||
/>
|
||||
|
||||
{/* Compose diff preview */}
|
||||
<ComposeDiffPreviewDialog
|
||||
open={diffPreview !== null}
|
||||
onOpenChange={(open) => { if (!open && !diffPreviewConfirming) setDiffPreview(null); }}
|
||||
stackName={selectedFile ? selectedFile.replace(/\.(yml|yaml)$/, '') : ''}
|
||||
fileName={diffPreview?.fileName ?? ''}
|
||||
language={diffPreview?.language ?? 'yaml'}
|
||||
original={diffPreview?.original ?? ''}
|
||||
modified={diffPreview?.modified ?? ''}
|
||||
actionLabel={diffPreview?.mode === 'save-and-deploy' ? 'Save & deploy' : 'Save'}
|
||||
confirming={diffPreviewConfirming}
|
||||
isDarkMode={isDarkMode}
|
||||
onConfirm={async () => {
|
||||
const snapshot = diffPreview;
|
||||
setDiffPreviewConfirming(true);
|
||||
try {
|
||||
if (snapshot?.mode === 'save-and-deploy') {
|
||||
await saveFile();
|
||||
// e.preventDefault/stopPropagation are no-ops here; no browser event is in flight
|
||||
await deployStack({ preventDefault() {}, stopPropagation() {} } as unknown as React.MouseEvent);
|
||||
} else {
|
||||
await saveFile();
|
||||
}
|
||||
} finally {
|
||||
setDiffPreviewConfirming(false);
|
||||
setDiffPreview(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Scan history overlay */}
|
||||
<SecurityHistoryView
|
||||
open={securityHistoryOpen}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { useDensity } from '@/hooks/use-density';
|
||||
import type { Density } from '@/hooks/use-density';
|
||||
import { useDeployFeedbackEnabled } from '@/hooks/use-deploy-feedback-enabled';
|
||||
import { useComposeDiffPreviewEnabled } from '@/hooks/use-compose-diff-preview-enabled';
|
||||
import { SettingsSection } from './SettingsSection';
|
||||
import { SettingsField } from './SettingsField';
|
||||
|
||||
@@ -19,6 +20,7 @@ const DENSITY_DESCRIPTIONS: Record<Density, string> = {
|
||||
export function AppearanceSection() {
|
||||
const [density, setDensity] = useDensity();
|
||||
const [isEnabled, setEnabled] = useDeployFeedbackEnabled();
|
||||
const [diffPreviewEnabled, setDiffPreviewEnabled] = useComposeDiffPreviewEnabled();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-10">
|
||||
@@ -55,6 +57,25 @@ export function AppearanceSection() {
|
||||
</label>
|
||||
</div>
|
||||
</SettingsField>
|
||||
|
||||
<SettingsField
|
||||
label="Diff preview before save"
|
||||
helper="Show a side-by-side diff of compose and env edits before they reach disk."
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="compose-diff-preview"
|
||||
checked={diffPreviewEnabled}
|
||||
onCheckedChange={(v) => setDiffPreviewEnabled(v === true)}
|
||||
/>
|
||||
<label
|
||||
htmlFor="compose-diff-preview"
|
||||
className="text-sm text-stat-value cursor-pointer select-none"
|
||||
>
|
||||
{diffPreviewEnabled ? 'Enabled' : 'Disabled'}
|
||||
</label>
|
||||
</div>
|
||||
</SettingsField>
|
||||
</SettingsSection>
|
||||
|
||||
<p className="font-mono text-[10px] leading-3 uppercase tracking-[0.18em] text-stat-subtitle/70">
|
||||
|
||||
Reference in New Issue
Block a user