265 lines
8.5 KiB
TypeScript
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;
|