/** * TextEditorModal - Dialog shell for editing text files in SFTP. * Delegates all editor chrome to TextEditorPane. */ import type * as Monaco from 'monaco-editor'; import React, { useCallback, useEffect, useRef, useState } from 'react'; import { getLanguageId } from '../lib/sftpFileUtils'; import { Dialog, DialogContent, DialogTitle } from './ui/dialog'; import { toast } from './ui/toast'; import { TextEditorPane } from './editor/TextEditorPane'; import { promptUnsavedChanges } from './editor/UnsavedChangesDialog'; import { useI18n } from '../application/i18n/I18nProvider'; import { scheduleWindowInputFocus } from '../application/state/windowInputFocus'; import { createTextEditorSaveCoordinator, type TextEditorSaveCoordinator, } from '../application/state/textEditorSaveCoordinator'; import type { HotkeyScheme, KeyBinding } from '../domain/models'; /** Snapshot passed to `onPromoteToTab` when the user clicks the maximize button. */ export interface TextEditorModalSnapshot { /** The file name at the time of promotion (modal's fileName prop). */ fileName: string; /** The clean baseline content at the time of promotion. */ baselineContent: string; /** The current (possibly-dirty) editor content. */ content: string; /** The current language ID selected by the user (may differ from file-detected default). */ languageId: string; /** The current word-wrap state (carried over so the tab opens with the same setting). */ wordWrap: boolean; /** The latest Monaco view state (scroll position, cursor, etc.) — may be null before first edit. */ viewState: Monaco.editor.ICodeEditorViewState | null; } export interface TextEditorModalSnapshotSource { fileName: string; getBaselineContent: () => string; getContent: () => string; languageId: string; wordWrap: boolean; getViewState: () => Monaco.editor.ICodeEditorViewState | null; isSaving: () => boolean; } export const createTextEditorModalSnapshot = ( source: TextEditorModalSnapshotSource, ): TextEditorModalSnapshot | null => { if (source.isSaving()) return null; return { fileName: source.fileName, baselineContent: source.getBaselineContent(), content: source.getContent(), languageId: source.languageId, wordWrap: source.wordWrap, viewState: source.getViewState(), }; }; interface TextEditorModalProps { open: boolean; onClose: () => void; fileName: string; initialContent: string; onSave: (content: string) => Promise; editorWordWrap: boolean; onToggleWordWrap: () => void; hotkeyScheme: HotkeyScheme; keyBindings: KeyBinding[]; /** If provided, a maximize button is shown in the Pane header. */ onPromoteToTab?: (snapshot: TextEditorModalSnapshot) => void; } const TextEditorModal: React.FC = ({ open, onClose, fileName, initialContent, onSave, editorWordWrap, onToggleWordWrap, hotkeyScheme, keyBindings, onPromoteToTab, }) => { const { t } = useI18n(); const [content, setContent] = useState(initialContent); const [baselineContent, setBaselineContent] = useState(initialContent); const [saving, setSaving] = useState(false); const [saveError, setSaveError] = useState(null); const [languageId, setLanguageId] = useState(() => getLanguageId(fileName)); const contentRef = useRef(initialContent); const baselineContentRef = useRef(initialContent); const savingRef = useRef(false); const closePromptRef = useRef | null>(null); const onSaveRef = useRef(onSave); const tRef = useRef(t); const saveCoordinatorRef = useRef(null); // Latest view state captured from Pane's onContentChange — used by handlePromote const viewStateRef = useRef(null); // Derived: whether the current content differs from the clean baseline const hasChanges = content !== baselineContent; if (!saveCoordinatorRef.current) { saveCoordinatorRef.current = createTextEditorSaveCoordinator({ onSave: (contentToSave) => onSaveRef.current(contentToSave), onSaveStart: () => { setSaveError(null); }, onSaveSuccess: (savedContent) => { setBaselineContent(savedContent); baselineContentRef.current = savedContent; toast.success(tRef.current('sftp.editor.saved'), 'SFTP'); }, onSaveError: (error) => { const msg = error instanceof Error ? error.message : tRef.current('sftp.editor.saveFailed'); setSaveError(msg); toast.error(msg, 'SFTP'); }, onSavingChange: (nextSaving) => { savingRef.current = nextSaving; setSaving(nextSaving); }, }); } useEffect(() => { onSaveRef.current = onSave; }, [onSave]); useEffect(() => { tRef.current = t; }, [t]); // Reset all state when a new file is opened useEffect(() => { saveCoordinatorRef.current?.reset(); setContent(initialContent); setBaselineContent(initialContent); setSaveError(null); setSaving(false); setLanguageId(getLanguageId(fileName)); contentRef.current = initialContent; baselineContentRef.current = initialContent; savingRef.current = false; closePromptRef.current = null; viewStateRef.current = null; }, [initialContent, fileName]); const saveContent = useCallback(async (contentToSave = contentRef.current): Promise => { return saveCoordinatorRef.current?.save(contentToSave) ?? false; }, []); const handleSave = useCallback(async () => { await saveContent(); }, [saveContent]); const handleClose = useCallback(() => { if (closePromptRef.current) return; const closeTask = (async () => { if (contentRef.current !== baselineContentRef.current) { const choice = await promptUnsavedChanges(fileName); if (choice === 'cancel') return; if (choice === 'save') { const saved = await saveContent(); if (!saved) return; if (contentRef.current !== baselineContentRef.current) return; } } onClose(); scheduleWindowInputFocus(); })().finally(() => { closePromptRef.current = null; }); closePromptRef.current = closeTask; }, [fileName, onClose, saveContent]); useEffect(() => { contentRef.current = content; }, [content]); useEffect(() => { baselineContentRef.current = baselineContent; }, [baselineContent]); useEffect(() => { savingRef.current = saving; }, [saving]); useEffect(() => { if (!open) { closePromptRef.current = null; } }, [open]); useEffect(() => { if (open) scheduleWindowInputFocus(); }, [open]); const handleContentChange = useCallback( (nextContent: string, viewState: Monaco.editor.ICodeEditorViewState | null) => { setContent(nextContent); contentRef.current = nextContent; viewStateRef.current = viewState; }, [], ); const handlePromote = useCallback(() => { if (!onPromoteToTab) return; const snapshot = createTextEditorModalSnapshot({ fileName, getBaselineContent: () => baselineContentRef.current, getContent: () => contentRef.current, languageId, wordWrap: editorWordWrap, getViewState: () => viewStateRef.current, isSaving: () => savingRef.current, }); if (snapshot) onPromoteToTab(snapshot); }, [onPromoteToTab, fileName, languageId, editorWordWrap]); return ( !isOpen && handleClose()}> {/* Radix requires a DialogTitle inside every DialogContent for a11y. The Pane's own header already shows the filename visually, so we mirror it here inside an sr-only DialogTitle for screen readers. */} {fileName} ); }; export default TextEditorModal;