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:
Anso
2026-04-30 22:01:17 -04:00
committed by GitHub
parent 3e01daf76f
commit a25acbec7c
9 changed files with 290 additions and 5 deletions
+77 -2
View File
@@ -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}