import { useState } from 'react'; import { AlertTriangle, GitBranch, Loader2 } from 'lucide-react'; import { Modal, ModalHeader, ModalFooter } from '@/components/ui/modal'; import { Button } from '@/components/ui/button'; import { Checkbox } from '@/components/ui/checkbox'; import { Label } from '@/components/ui/label'; import { ScrollArea } from '@/components/ui/scroll-area'; export type GitChangePlanOp = | 'add' | 'modify' | 'delete' | 'rename' | 'unchanged' | 'local-modified' | 'local-missing' | 'type-changed' | 'unmanaged-collision' | 'invocation'; export interface PublicGitChangePlanOperation { path: string | null; op: GitChangePlanOp; role: string; fromPath?: string | null; } export interface GitChangePlanCounts { add: number; modify: number; delete: number; rename: number; unchanged: number; localModified: number; localMissing: number; typeChanged: number; unmanagedCollision: number; invocation: number; } export interface PublicGitChangePlan { blocked: boolean; counts: GitChangePlanCounts; operations: PublicGitChangePlanOperation[]; invocation: { candidateChanged: boolean; liveDiverged: boolean; }; } export interface PublicPendingPlan { fingerprint: string; blocked: boolean; counts: GitChangePlanCounts; operations: PublicGitChangePlanOperation[]; } export interface PullResult { commitSha: string; validation: { ok: boolean; error?: string }; refusals?: Array<{ sourcePath: string | null; kind: string; reason: string; actionable: boolean }>; warnings?: string[]; plan: PublicGitChangePlan | null; planFingerprint: string | null; } interface GitSourceDiffDialogProps { open: boolean; onOpenChange: (open: boolean) => void; stackName: string; pull: PullResult | null; autoDeployDefault: boolean; applying: boolean; onApply: (commitSha: string, deploy: boolean, planFingerprint: string) => Promise; onDismiss: () => Promise; } const OP_LABEL: Record = { add: 'Add', modify: 'Modify', delete: 'Remove', rename: 'Rename', unchanged: 'Unchanged', 'local-modified': 'Locally modified', 'local-missing': 'Missing on disk', 'type-changed': 'Type changed', 'unmanaged-collision': 'Unmanaged file in the way', invocation: 'Compose invocation', }; const BLOCKING_OPS = new Set([ 'local-modified', 'local-missing', 'type-changed', 'unmanaged-collision', ]); function opPathLabel(op: PublicGitChangePlanOperation): string { if (op.op === 'invocation') return 'Compose command line'; if (op.op === 'rename' && op.fromPath) { return `${op.fromPath} → ${op.path ?? 'secret-bearing path'}`; } return op.path ?? 'secret-bearing managed path'; } export function GitSourceDiffDialog({ open, onOpenChange, stackName, pull, autoDeployDefault, applying, onApply, onDismiss, }: GitSourceDiffDialogProps) { const [deployAfter, setDeployAfter] = useState(autoDeployDefault); if (!pull) return null; const shortSha = pull.commitSha.slice(0, 7); const missingPlan = !pull.plan || !pull.planFingerprint; // plan.blocked is file conflicts only; invocation.liveDiverged does not disable Apply. const blocked = missingPlan || pull.plan?.blocked === true || !pull.validation.ok; const ops = pull.plan?.operations ?? []; const unchanged = pull.plan?.counts.unchanged ?? 0; const apply = async () => { if (!pull.planFingerprint || blocked) return; await onApply(pull.commitSha, deployAfter, pull.planFingerprint); }; return (
{!pull.validation.ok && (

Incoming compose failed validation

{pull.validation.error}
)} {missingPlan && (

Change plan unavailable

This node did not return a classified plan. Pull again after updating the remote instance.

)} {pull.plan?.blocked && (

Local conflicts block apply

Resolve locally modified, missing, or colliding files, then pull again. Sencho will not overwrite them.

)} {pull.plan?.invocation.liveDiverged && (

Live Compose invocation changed

The Compose command line on disk no longer matches the last applied generation, for example a .env file was added or removed outside Git. Apply records the incoming invocation as the new baseline. Unmanaged files stay on disk.

)}
    {ops.map((op, i) => (
  • {OP_LABEL[op.op]} {BLOCKING_OPS.has(op.op) ? ' (blocks apply)' : ''}

    {opPathLabel(op)}

  • ))} {unchanged > 0 && (
  • {unchanged} unchanged file{unchanged === 1 ? '' : 's'}
  • )} {ops.length === 0 && unchanged === 0 && !missingPlan && (
  • No file operations in this plan.
  • )}
setDeployAfter(checked === true)} disabled={applying || blocked} /> } secondary={ } primary={ } />
); }