mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-05 16:37:46 +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:
@@ -41,6 +41,23 @@ The `.env` editor has the same save/discard controls as the compose editor. Chan
|
||||
Sencho reads the `env_file:` paths from your `compose.yaml` to discover available env files. If no `env_file:` is declared, a default `.env` in the stack directory is used.
|
||||
</Note>
|
||||
|
||||
## Diff preview before save
|
||||
|
||||
When the **Diff preview before save** toggle is enabled in **Settings → Appearance**, clicking **Save & Deploy** or **Save Only** opens a side-by-side diff modal before anything is written to disk. The left pane shows the current on-disk content; the right pane shows your unsaved edits, with additions highlighted in green.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/compose-diff-preview/diff-modal.png" alt="Diff preview modal showing side-by-side YAML diff with a new comment line highlighted in green on the right" />
|
||||
</Frame>
|
||||
|
||||
Review the diff, then:
|
||||
|
||||
- Click **Save & deploy** (or **Save**) to confirm and write the changes to disk.
|
||||
- Click **Cancel** to return to the editor without saving.
|
||||
|
||||
If there are no unsaved changes the modal is skipped and the save proceeds directly.
|
||||
|
||||
The toggle is off by default and saved per browser. Each device remembers its own setting independently. Enable it in **Settings → Appearance → Diff preview before save**.
|
||||
|
||||
## Container panel
|
||||
|
||||
The left column lists all containers that belong to the selected stack under the **CONTAINERS** heading. Each container shows:
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 48 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
@@ -71,6 +71,28 @@ Control how much information Sencho packs on screen. Each browser you sign in fr
|
||||
|
||||
Density affects the dashboard stack table, the resource gauge strip, the Settings Hub sidebar, the Schedules and Audit Log tables, and every other data table in Sencho. Typography, color, and layout structure stay the same; only vertical padding compresses.
|
||||
|
||||
### Deploy progress modal
|
||||
|
||||
When enabled, Sencho streams live output in a modal whenever you deploy, restart, update, install, or run a Git operation. The modal closes automatically on success or stays open on failure so you can read the error output.
|
||||
|
||||
| Value | Behavior |
|
||||
|-------|----------|
|
||||
| **Enabled** | A progress modal opens for every long-running operation |
|
||||
| **Disabled** (default) | Operations run silently; results surface via toast notifications only |
|
||||
|
||||
### Diff preview before save
|
||||
|
||||
When enabled, clicking **Save & Deploy** or **Save Only** in the compose or env editor opens a side-by-side diff modal before writing anything to disk. The left pane shows the current on-disk content; the right pane shows your unsaved edits, with additions highlighted green and removals highlighted red.
|
||||
|
||||
| Value | Behavior |
|
||||
|-------|----------|
|
||||
| **Enabled** | Diff modal opens on every save that has unsaved changes |
|
||||
| **Disabled** (default) | File is saved directly without a review step |
|
||||
|
||||
If there are no unsaved changes the modal is skipped and the save proceeds immediately.
|
||||
|
||||
See [Diff preview before save](/features/editor#diff-preview-before-save) in the Editor guide for screenshots and the full workflow.
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { SENCHO_SETTINGS_CHANGED } from '@/lib/events';
|
||||
|
||||
export const COMPOSE_DIFF_PREVIEW_KEY = 'sencho.compose-editor.diff-preview.enabled';
|
||||
|
||||
function readStored(): boolean {
|
||||
if (typeof window === 'undefined') return false;
|
||||
try {
|
||||
return window.localStorage.getItem(COMPOSE_DIFF_PREVIEW_KEY) === 'true';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function useComposeDiffPreviewEnabled(): [boolean, (next: boolean) => void] {
|
||||
const [enabled, setEnabledState] = useState<boolean>(readStored);
|
||||
|
||||
useEffect(() => {
|
||||
function onSettingsChanged() {
|
||||
setEnabledState(readStored());
|
||||
}
|
||||
window.addEventListener(SENCHO_SETTINGS_CHANGED, onSettingsChanged);
|
||||
return () => window.removeEventListener(SENCHO_SETTINGS_CHANGED, onSettingsChanged);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
function onStorage(event: StorageEvent) {
|
||||
if (event.key !== COMPOSE_DIFF_PREVIEW_KEY) return;
|
||||
setEnabledState(event.newValue === 'true');
|
||||
}
|
||||
window.addEventListener('storage', onStorage);
|
||||
return () => window.removeEventListener('storage', onStorage);
|
||||
}, []);
|
||||
|
||||
const setEnabled = useCallback((next: boolean) => {
|
||||
try {
|
||||
window.localStorage.setItem(COMPOSE_DIFF_PREVIEW_KEY, next ? 'true' : 'false');
|
||||
} catch {
|
||||
// ignore; localStorage may be unavailable (private mode, quota)
|
||||
}
|
||||
setEnabledState(next);
|
||||
window.dispatchEvent(new CustomEvent(SENCHO_SETTINGS_CHANGED));
|
||||
}, []);
|
||||
|
||||
return [enabled, setEnabled];
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { SENCHO_SETTINGS_CHANGED } from '@/lib/events';
|
||||
|
||||
export const DEPLOY_FEEDBACK_KEY = 'sencho.deploy-feedback.enabled';
|
||||
|
||||
@@ -18,8 +19,8 @@ export function useDeployFeedbackEnabled(): [boolean, (next: boolean) => void] {
|
||||
function onSettingsChanged() {
|
||||
setEnabledState(readStored());
|
||||
}
|
||||
window.addEventListener('SENCHO_SETTINGS_CHANGED', onSettingsChanged);
|
||||
return () => window.removeEventListener('SENCHO_SETTINGS_CHANGED', onSettingsChanged);
|
||||
window.addEventListener(SENCHO_SETTINGS_CHANGED, onSettingsChanged);
|
||||
return () => window.removeEventListener(SENCHO_SETTINGS_CHANGED, onSettingsChanged);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -38,7 +39,7 @@ export function useDeployFeedbackEnabled(): [boolean, (next: boolean) => void] {
|
||||
// ignore; localStorage may be unavailable (private mode, quota)
|
||||
}
|
||||
setEnabledState(next);
|
||||
window.dispatchEvent(new CustomEvent('SENCHO_SETTINGS_CHANGED'));
|
||||
window.dispatchEvent(new CustomEvent(SENCHO_SETTINGS_CHANGED));
|
||||
}, []);
|
||||
|
||||
return [enabled, setEnabled];
|
||||
|
||||
Reference in New Issue
Block a user