Files
Netcatty/components/TextEditorModal.tsx
2026-05-18 20:00:10 +08:00

265 lines
8.5 KiB
TypeScript

/**
* 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<void>;
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<TextEditorModalProps> = ({
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<string | null>(null);
const [languageId, setLanguageId] = useState(() => getLanguageId(fileName));
const contentRef = useRef(initialContent);
const baselineContentRef = useRef(initialContent);
const savingRef = useRef(false);
const closePromptRef = useRef<Promise<void> | null>(null);
const onSaveRef = useRef(onSave);
const tRef = useRef(t);
const saveCoordinatorRef = useRef<TextEditorSaveCoordinator | null>(null);
// Latest view state captured from Pane's onContentChange — used by handlePromote
const viewStateRef = useRef<Monaco.editor.ICodeEditorViewState | null>(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<boolean> => {
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 (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && handleClose()}>
<DialogContent
className="max-w-5xl h-[85vh] flex flex-col p-0 gap-0"
hideCloseButton
>
{/* 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. */}
<DialogTitle className="sr-only">{fileName}</DialogTitle>
<TextEditorPane
chrome="modal"
fileName={`${fileName}${hasChanges ? ' *' : ''}`}
content={content}
languageId={languageId}
wordWrap={editorWordWrap}
saving={saving}
saveError={saveError}
hotkeyScheme={hotkeyScheme}
keyBindings={keyBindings}
onContentChange={handleContentChange}
onLanguageChange={setLanguageId}
onToggleWordWrap={onToggleWordWrap}
onSave={handleSave}
onRequestClose={handleClose}
onPromoteToTab={onPromoteToTab ? handlePromote : undefined}
/>
</DialogContent>
</Dialog>
);
};
export default TextEditorModal;