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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ CHANGELOG.md
# Security policy and docs must be tracked
!SECURITY.md
!docs/security/security-process.md
# Session work-summary plans must be tracked
!docs/plans/*.md
# Desktop (Tauri) build artifacts
apps/desktop/src-tauri/target/
apps/desktop/src-tauri/gen/
Expand Down
154 changes: 123 additions & 31 deletions apps/web/src/frontend/components/ObjectDetailPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import React, { useState, useMemo, Suspense, lazy } from 'react';
import { useSyncStore } from '../store/useSyncStore';
import { Code, Play, RefreshCw, FileText, CheckCircle2, ChevronRight, ChevronDown, KeyRound, Copy, GitCompareArrows, AlertTriangle } from 'lucide-react';
import { SqlGeneratorModule } from '../lib/sql-generator';
import { findDropDependencies, type DropDependency } from '../lib/dependency-scan';
import { findDropDependencies } from '../lib/dependency-scan';
import { diffLines } from '../utils/lineDiff';
import { highlightMatch } from '../utils/highlight';
import { formatSql } from '../utils/formatSql';
Expand Down Expand Up @@ -33,6 +33,7 @@ export const ObjectDetailPanel: React.FC = () => {
isComparing,
sourceConfig,
targetConfig,
targetConnected,
compareResult,
browseMode,
syncSelection,
Expand All @@ -59,7 +60,27 @@ export const ObjectDetailPanel: React.FC = () => {
const [showConfirm, setShowConfirm] = useState(false);
const [dontAskAgain, setDontAskAgain] = useState(false);
// Pre-deploy warning: dropped tables/columns still referenced by views/functions/procedures.
const [dropDeps, setDropDeps] = useState<DropDependency[]>([]);
// The dialog always renders the live scan (below) β€” this only tracks open/closed.
const [showDepsDialog, setShowDepsDialog] = useState(false);
// Explicit acknowledgment for destructive drops / MySQL binlog risk β€” keyed to the
// exact generatedSql that was acknowledged, so any change to the plan (new selection,
// toggling non-destructive, etc.) silently invalidates a stale checkbox instead of
// carrying forward consent for a plan the user never actually saw.
const [destructiveAckSql, setDestructiveAckSql] = useState<string | null>(null);
const [mysqlAckSql, setMysqlAckSql] = useState<string | null>(null);

// Live dependency scan β€” recomputed on every selection/nonDestructive change, not just
// on Execute click, so the button can stay disabled until conflicts are resolved.
const liveDropDeps = useMemo(
() => (compareResult ? findDropDependencies(compareResult.tables, syncSelection, { nonDestructive }) : []),
[compareResult, syncSelection, nonDestructive]
);
const hasUnresolvedDropDeps = liveDropDeps.length > 0;

// Destructive drops (DROP TABLE/COLUMN/INDEX) in the generated plan while non-destructive
// mode is off β€” require an explicit checkbox acknowledgment before Execute unlocks.
const hasDestructiveDrops = !nonDestructive && !!generatedSql && /\bDROP\s+(TABLE|COLUMN|INDEX)\b/i.test(generatedSql);
const destructiveDropsAcked = destructiveAckSql !== null && destructiveAckSql === generatedSql;

const toggleTriggerDdl = (name: string) =>
setExpandedTriggers((prev) => ({ ...prev, [name]: !prev[name] }));
Expand Down Expand Up @@ -96,26 +117,18 @@ export const ObjectDetailPanel: React.FC = () => {
}
};

// Before deploying, warn when a selected drop (table/column) would break a
// view/function/procedure that still references it. Recommend handling those
// dependents first; the user can include them in the deploy or proceed anyway.
// Execute is disabled whenever hasUnresolvedDropDeps is true (see the disabled
// expression on the button below), so reaching this handler means the dependency
// scan is already clean. Kept as a defensive re-check rather than trusting that
// every call site respects the disabled state.
const handleExecuteClick = () => {
const deps = compareResult
? findDropDependencies(compareResult.tables, syncSelection, { nonDestructive })
: [];
if (deps.length > 0) {
setDropDeps(deps);
if (liveDropDeps.length > 0) {
setShowDepsDialog(true);
return;
}
proceedToConfirm();
};

// "Deploy anyway" from the dependency warning β€” dismiss it and run the normal flow.
const proceedDespiteDeps = () => {
setDropDeps([]);
proceedToConfirm();
};

const confirmExecute = () => {
if (dontAskAgain) localStorage.setItem(SKIP_DEPLOY_CONFIRM_KEY, 'true');
setShowConfirm(false);
Expand Down Expand Up @@ -164,15 +177,25 @@ export const ObjectDetailPanel: React.FC = () => {
<span className="text-xs font-mono text-slate-400 flex items-center gap-2">
<span className="text-slate-300">{selectedTable.tableName}</span>
<span className="text-slate-600">β€”</span>
{/* Orientation: target (left, current state) β†’ source (right, desired state).
Monaco computes "what transforms original into modified" β€” so target is
original and source is modified. Green = added to target, Red = removed from target. */}
<span className="text-slate-500 text-[10px] italic">Target (current)</span>
{/* Orientation: source (left, original) β†’ target (right, destination) β€”
matches the Source/Target connection panel layout in the top toolbar.
Monaco computes "what transforms original into modified" β€” so source is
original and target is modified. Diff highlight color signals what the
migration will DO to this object: green = new object, amber = existing
object's definition changed, red = object is being dropped. */}
<span className="text-slate-500 text-[10px] italic">Source (original)</span>
<span className="text-slate-600">β†’</span>
<span className="text-slate-500 text-[10px] italic">Source (desired)</span>
<span className="text-slate-500 text-[10px] italic">Target (destination)</span>
<span className="ml-1 flex items-center gap-2 text-[10px]">
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-sm bg-emerald-500/70"></span><span className="text-emerald-300/80">add to target</span></span>
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-sm bg-rose-500/70"></span><span className="text-rose-300/80">remove from target</span></span>
{selectedTable.status === 'ADDED' && (
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-sm bg-emerald-500/70"></span><span className="text-emerald-300/80">new object</span></span>
)}
{selectedTable.status === 'REMOVED' && (
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-sm bg-red-500/70"></span><span className="text-red-300/80">dropped</span></span>
)}
{(selectedTable.status === 'MODIFIED' || selectedTable.status === 'UNCHANGED') && (
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-sm bg-amber-500/70"></span><span className="text-amber-300/80">modified</span></span>
)}
</span>
</span>
<div className="flex items-center gap-3">
Expand All @@ -198,12 +221,13 @@ export const ObjectDetailPanel: React.FC = () => {
<div className="absolute inset-0">
<Suspense fallback={<EditorFallback />}>
<SqlDiffEditor
original={targetDdl}
modified={sourceDdl}
original={sourceDdl}
modified={targetDdl}
dialect={targetConfig.dialect}
inline={inlineDiff}
ignoreCase={ignoreCase}
highlight={searchTerm}
status={selectedTable.status === 'ADDED' || selectedTable.status === 'REMOVED' ? selectedTable.status : 'MODIFIED'}
/>
</Suspense>
</div>
Expand Down Expand Up @@ -965,6 +989,17 @@ export const ObjectDetailPanel: React.FC = () => {
}
return (t.triggerDiffs ?? []).some((d) => d.status === 'ADDED' || d.status === 'MODIFIED');
});
const mysqlRiskAcked = mysqlAckSql !== null && mysqlAckSql === generatedSql;

// Single source of truth for why Execute is disabled, checked in priority order β€”
// most severe / least self-service first. `null` means nothing is blocking.
const executeBlockReason: string | null =
includedCount === 0 ? 'No objects selected for deployment'
: !targetConnected ? 'Target connection is not healthy β€” reconnect before deploying'
: hasUnresolvedDropDeps ? `${liveDropDeps.length} dependent object(s) would break β€” resolve the conflicts below`
: hasDestructiveDrops && !destructiveDropsAcked ? 'Acknowledge the destructive drops below before deploying'
: deploysRoutineToMySql && !mysqlRiskAcked ? 'Acknowledge the MySQL binlog privilege risk below before deploying'
: null;

return (
<div className="flex-1 flex flex-col min-w-0 bg-slate-900 h-full">
Expand Down Expand Up @@ -1023,12 +1058,17 @@ export const ObjectDetailPanel: React.FC = () => {
<button
data-testid="execute-btn"
onClick={handleExecuteClick}
disabled={isComparing || isMigrating || migrationExecuted || includedCount === 0}
title={includedCount === 0 ? 'No objects selected for deployment' : `Deploy ${includedCount} object(s) to target`}
disabled={
isComparing || isMigrating || migrationExecuted || includedCount === 0 ||
!targetConnected || hasUnresolvedDropDeps ||
(hasDestructiveDrops && !destructiveDropsAcked) ||
(deploysRoutineToMySql && !mysqlRiskAcked)
}
title={executeBlockReason ?? `Deploy ${includedCount} object(s) to target`}
className={`flex items-center gap-1.5 px-4 py-1.5 rounded text-xs font-bold transition shadow ${
migrationExecuted
? 'bg-emerald-950/40 text-emerald-400 border border-emerald-500/25 cursor-default'
: includedCount === 0
: executeBlockReason
? 'bg-slate-800 text-slate-500 border border-slate-700/50 cursor-not-allowed'
: 'bg-gradient-to-r from-emerald-600 to-teal-600 hover:from-emerald-500 hover:to-teal-500 on-accent-fg cursor-pointer shadow-emerald-500/5'
}`}
Expand All @@ -1050,11 +1090,10 @@ export const ObjectDetailPanel: React.FC = () => {
)}

<DependencyWarningDialog
deps={dropDeps}
deps={showDepsDialog ? liveDropDeps : []}
syncSelection={syncSelection}
toggleSyncSelection={toggleSyncSelection}
onCancel={() => setDropDeps([])}
onDeployAnyway={proceedDespiteDeps}
onCancel={() => setShowDepsDialog(false)}
/>

<DeployConfirmDialog
Expand All @@ -1069,6 +1108,59 @@ export const ObjectDetailPanel: React.FC = () => {
</div>
</div>

{/* Safety gate banner β€” anything that currently blocks Execute, with the
action needed to clear it. Visible on every tab, not just Migration SQL,
since Execute lives in the toolbar above regardless of active tab. */}
{!browseMode && (!targetConnected || hasUnresolvedDropDeps || (hasDestructiveDrops && !destructiveDropsAcked) || (deploysRoutineToMySql && !mysqlRiskAcked)) && (
<div className="border-b border-slate-800 bg-slate-950/60 divide-y divide-slate-800/60">
{!targetConnected && (
<div className="flex items-center gap-2.5 px-4 py-2 text-[11px] text-rose-300">
<AlertTriangle className="w-3.5 h-3.5 shrink-0 text-rose-400" />
Target connection is not healthy β€” reconnect the target before deploying.
</div>
)}
{hasUnresolvedDropDeps && (
<div className="flex items-center justify-between gap-2.5 px-4 py-2 text-[11px] text-amber-200">
<span className="flex items-center gap-2.5">
<AlertTriangle className="w-3.5 h-3.5 shrink-0 text-amber-400" />
{liveDropDeps.length} dependent object{liveDropDeps.length === 1 ? '' : 's'} would break from a drop in this deploy.
</span>
<button
onClick={() => setShowDepsDialog(true)}
className="shrink-0 text-[10px] font-semibold rounded px-2 py-1 text-amber-200 bg-amber-950/50 border border-amber-500/40 hover:bg-amber-900/50 transition"
>
Review conflicts
</button>
</div>
)}
{hasDestructiveDrops && (
<label className="flex items-center gap-2.5 px-4 py-2 text-[11px] text-amber-200 cursor-pointer">
<input
type="checkbox"
checked={destructiveDropsAcked}
onChange={(e) => setDestructiveAckSql(e.target.checked ? generatedSql : null)}
className="w-3 h-3 accent-amber-500 cursor-pointer shrink-0"
/>
<AlertTriangle className="w-3.5 h-3.5 shrink-0 text-amber-400" />
This migration drops table(s), column(s), or index(es) that cannot be recovered β€” I understand and want to proceed.
</label>
)}
{deploysRoutineToMySql && (
<label className="flex items-center gap-2.5 px-4 py-2 text-[11px] text-amber-200 cursor-pointer">
<input
type="checkbox"
checked={mysqlRiskAcked}
onChange={(e) => setMysqlAckSql(e.target.checked ? generatedSql : null)}
className="w-3 h-3 accent-amber-500 cursor-pointer shrink-0"
/>
<AlertTriangle className="w-3.5 h-3.5 shrink-0 text-amber-400" />
This deploy creates/updates a MySQL function, procedure, or trigger β€” the connecting user needs SUPER or{' '}
<code className="text-amber-100">log_bin_trust_function_creators = 1</code> or it will fail. I've confirmed this.
</label>
)}
</div>
)}

{/* Main Panel Content Panel */}
<div className="flex-1 flex flex-col min-h-0">
{browseMode || activeTab === 'DIFF' ? (
Expand Down
9 changes: 6 additions & 3 deletions apps/web/src/frontend/components/SqlEditor.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React, { useRef, useEffect, useCallback } from 'react';
import Editor, { DiffEditor } from '@monaco-editor/react';
import { MONACO_THEME, MONACO_THEME_LIGHT, monacoLanguage } from '../monaco-setup';
import { MONACO_THEME, MONACO_THEME_LIGHT, MONACO_DIFF_THEME, MONACO_DIFF_THEME_LIGHT, monacoLanguage } from '../monaco-setup';
import { useUiStore } from '../store/uiStore';

const BASE_OPTIONS = {
Expand Down Expand Up @@ -84,18 +84,21 @@ interface SqlDiffProps {
height?: string | number;
/** Highlight occurrences of this term on both sides of the diff. */
highlight?: string;
/** Object status β€” picks the diff highlight color (green/amber/red). Defaults to MODIFIED (amber). */
status?: 'ADDED' | 'MODIFIED' | 'REMOVED';
}

/** Side-by-side (or inline) SQL diff backed by Monaco's DiffEditor. */
export const SqlDiffEditor: React.FC<SqlDiffProps> = ({ original, modified, dialect, inline = false, ignoreCase = false, height = '100%', highlight }) => {
export const SqlDiffEditor: React.FC<SqlDiffProps> = ({ original, modified, dialect, inline = false, ignoreCase = false, height = '100%', highlight, status = 'MODIFIED' }) => {
// Monaco's diff is case-sensitive; normalize both sides to collapse case-only differences
const orig = ignoreCase ? original.toUpperCase() : original;
const mod = ignoreCase ? modified.toUpperCase() : modified;

const diffRef = useRef<any>(null);
const decoModRef = useRef<any>(null);
const decoOrigRef = useRef<any>(null);
const monacoTheme = useUiStore((s) => s.resolvedMode) === 'light' ? MONACO_THEME_LIGHT : MONACO_THEME;
const isLight = useUiStore((s) => s.resolvedMode) === 'light';
const monacoTheme = isLight ? MONACO_DIFF_THEME_LIGHT[status] : MONACO_DIFF_THEME[status];

const apply = useCallback(() => {
const diff = diffRef.current;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,22 +1,23 @@
import React from 'react';
import { createPortal } from 'react-dom';
import { AlertCircle, FileText, Play } from 'lucide-react';
import { AlertCircle, FileText } from 'lucide-react';
import type { DropDependency } from '../../lib/dependency-scan';

interface Props {
deps: DropDependency[];
syncSelection: Record<string, boolean>;
toggleSyncSelection: (name: string) => void;
onCancel: () => void;
onDeployAnyway: () => void;
}

/**
* Pre-deploy warning shown when a selected drop (table/column) is still
* referenced by a view/function/procedure in the target. Offers a per-dependent
* "Include in deploy" quick-fix or "Deploy anyway".
* referenced by a view/function/procedure in the target. Execute stays disabled
* while this list is non-empty β€” there is no bypass. Resolve each entry either
* by including the dependent in the deploy, or by deselecting the object that
* drops the referenced column/table.
*/
export const DependencyWarningDialog: React.FC<Props> = ({ deps, syncSelection, toggleSyncSelection, onCancel, onDeployAnyway }) => {
export const DependencyWarningDialog: React.FC<Props> = ({ deps, syncSelection, toggleSyncSelection, onCancel }) => {
if (deps.length === 0) return null;
return createPortal(
<div
Expand Down Expand Up @@ -54,7 +55,7 @@ export const DependencyWarningDialog: React.FC<Props> = ({ deps, syncSelection,
<span className="font-mono text-slate-300">{d.dropped}</span>
</p>
</div>
{d.deployable && (
{d.deployable ? (
<button
onClick={() => {
if (!syncSelection[d.dependentName]) toggleSyncSelection(d.dependentName);
Expand All @@ -64,6 +65,10 @@ export const DependencyWarningDialog: React.FC<Props> = ({ deps, syncSelection,
>
{syncSelection[d.dependentName] ? 'Included' : 'Include in deploy'}
</button>
) : (
<span className="shrink-0 text-[10px] text-slate-500 italic max-w-[140px] text-right">
deselect the drop of {d.dropped} to resolve
</span>
)}
</li>
))}
Expand All @@ -74,13 +79,7 @@ export const DependencyWarningDialog: React.FC<Props> = ({ deps, syncSelection,
onClick={onCancel}
className="px-4 py-2 text-xs font-semibold text-slate-400 hover:text-slate-200 hover:bg-slate-850/50 rounded transition"
>
Cancel
</button>
<button
onClick={onDeployAnyway}
className="px-4 py-2 text-xs font-bold bg-gradient-to-r from-amber-600 to-orange-600 hover:from-amber-500 hover:to-orange-500 on-accent-fg rounded transition shadow flex items-center gap-1.5"
>
<Play className="w-3.5 h-3.5 fill-current" /> Deploy anyway
Close
</button>
</div>
</div>
Expand Down
Loading
Loading