623 lines
19 KiB
TypeScript
623 lines
19 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
import Layout from './components/Layout';
|
|
import Toolbar from './components/Toolbar';
|
|
import EditorPanel from './components/EditorPanel';
|
|
import ActionDialog, {
|
|
type ActionDialogAction,
|
|
} from './components/ActionDialog';
|
|
import HelpDialog from './components/HelpDialog';
|
|
import SnippetToolbox from './components/SnippetToolbox';
|
|
import { useWorkbenchState } from './workspace/useWorkbenchState';
|
|
import type { WorkbenchDocumentKind } from './workspace/workspaceTypes';
|
|
import type { CodeMirrorEditorHandle } from './editor/editorTypes';
|
|
import { openTextFile, saveTextFile } from './file/fileService';
|
|
import { validateXml, formatXml } from './validation/xmlValidation';
|
|
import { validateXslt } from './validation/xsltValidation';
|
|
import {
|
|
hasErrors,
|
|
type DiagnosticMessage,
|
|
} from './validation/validationTypes';
|
|
import { runTransformation } from './transform/transformService';
|
|
import { createTransformationRun } from './transform/nativeXsltEngine';
|
|
import type { TransformEngineId } from './transform/transformTypes';
|
|
import { createApproximateTrace } from './transform/traceAnalyzer';
|
|
|
|
interface DialogState {
|
|
title: string;
|
|
content: React.ReactNode;
|
|
actions: ActionDialogAction[];
|
|
onCancel?: () => void;
|
|
}
|
|
|
|
const App: React.FC = () => {
|
|
const {
|
|
state,
|
|
setState,
|
|
updateDocumentText,
|
|
replaceDocument,
|
|
markDocumentSaved,
|
|
setLastTransformation,
|
|
resetWorkspace,
|
|
} = useWorkbenchState();
|
|
|
|
const xmlInputEditorRef = useRef<CodeMirrorEditorHandle | null>(null);
|
|
const xsltEditorRef = useRef<CodeMirrorEditorHandle | null>(null);
|
|
const outputEditorRef = useRef<CodeMirrorEditorHandle | null>(null);
|
|
|
|
const [dialog, setDialog] = useState<DialogState | null>(null);
|
|
const [helpOpen, setHelpOpen] = useState(false);
|
|
const [busy, setBusy] = useState(false);
|
|
const [statusMessage, setStatusMessage] = useState<string | null>(null);
|
|
const [runtimeDiagnostics, setRuntimeDiagnostics] = useState<
|
|
DiagnosticMessage[]
|
|
>([]);
|
|
const [validationTouched, setValidationTouched] = useState(false);
|
|
|
|
const xmlDiagnostics = useMemo(
|
|
() => validateXml(state.xmlInput.text, 'XML input'),
|
|
[state.xmlInput.text]
|
|
);
|
|
|
|
const xsltDiagnostics = useMemo(
|
|
() => validateXslt(state.xsltCode.text, state.selectedEngine),
|
|
[state.xsltCode.text, state.selectedEngine]
|
|
);
|
|
|
|
const outputDiagnostics = useMemo(() => {
|
|
if (!state.xmlOutput.text.trim()) return [];
|
|
return validateXml(state.xmlOutput.text, 'XML output');
|
|
}, [state.xmlOutput.text]);
|
|
|
|
const closeDialog = useCallback(() => {
|
|
dialog?.onCancel?.();
|
|
setDialog(null);
|
|
}, [dialog]);
|
|
|
|
const askForConfirmation = useCallback(
|
|
(options: {
|
|
title: string;
|
|
content: React.ReactNode;
|
|
proceedLabel?: string;
|
|
danger?: boolean;
|
|
}): Promise<boolean> => {
|
|
return new Promise((resolve) => {
|
|
const finish = (confirmed: boolean) => {
|
|
setDialog(null);
|
|
resolve(confirmed);
|
|
};
|
|
|
|
setDialog({
|
|
title: options.title,
|
|
content: options.content,
|
|
onCancel: () => resolve(false),
|
|
actions: [
|
|
{
|
|
label: 'Cancel',
|
|
variant: 'secondary',
|
|
autoFocus: true,
|
|
onClick: () => finish(false),
|
|
},
|
|
{
|
|
label: options.proceedLabel ?? 'Proceed',
|
|
variant: options.danger ? 'danger' : 'primary',
|
|
onClick: () => finish(true),
|
|
},
|
|
],
|
|
});
|
|
});
|
|
},
|
|
[]
|
|
);
|
|
|
|
const confirmOverwrite = useCallback(
|
|
async (kind: WorkbenchDocumentKind, reason: string): Promise<boolean> => {
|
|
const documentState = state[kind];
|
|
if (!documentState.text.trim() && !documentState.dirty) return true;
|
|
|
|
return askForConfirmation({
|
|
title: `Overwrite ${documentState.label}?`,
|
|
content: (
|
|
<p>
|
|
{reason} This will replace the current contents of{' '}
|
|
<strong>{documentState.label}</strong>.
|
|
</p>
|
|
),
|
|
proceedLabel: 'Overwrite',
|
|
danger: true,
|
|
});
|
|
},
|
|
[askForConfirmation, state]
|
|
);
|
|
|
|
const handleOpenHelp = useCallback(() => {
|
|
setHelpOpen(true);
|
|
}, []);
|
|
|
|
const handleCloseHelp = useCallback(() => {
|
|
setHelpOpen(false);
|
|
}, []);
|
|
|
|
const handleOpenFile = async (kind: WorkbenchDocumentKind) => {
|
|
try {
|
|
const opened = await openTextFile(kind);
|
|
if (!opened) return;
|
|
|
|
const documentState = state[kind];
|
|
|
|
if (documentState.dirty) {
|
|
const mayProceed = await askForConfirmation({
|
|
title: `Overwrite ${documentState.label}?`,
|
|
content: (
|
|
<p>
|
|
Opening <strong>{opened.name}</strong> will replace the current
|
|
unsaved contents of <strong>{documentState.label}</strong>.
|
|
</p>
|
|
),
|
|
proceedLabel: 'Overwrite',
|
|
danger: true,
|
|
});
|
|
|
|
if (!mayProceed) return;
|
|
}
|
|
|
|
replaceDocument(kind, opened.text, opened.name, false);
|
|
setStatusMessage(`Loaded ${opened.name}.`);
|
|
} catch (error) {
|
|
if (isAbortError(error)) return;
|
|
setStatusMessage(
|
|
error instanceof Error ? error.message : 'Failed to open file.'
|
|
);
|
|
}
|
|
};
|
|
|
|
const handleSaveFile = async (kind: WorkbenchDocumentKind) => {
|
|
try {
|
|
const documentState = state[kind];
|
|
const suggestedName = documentState.fileName ?? defaultFileName(kind);
|
|
const savedName = await saveTextFile({
|
|
suggestedName,
|
|
text: documentState.text,
|
|
kind,
|
|
});
|
|
|
|
if (savedName) {
|
|
markDocumentSaved(kind, savedName);
|
|
setStatusMessage(`Saved ${savedName}.`);
|
|
}
|
|
} catch (error) {
|
|
if (isAbortError(error)) return;
|
|
setStatusMessage(
|
|
error instanceof Error ? error.message : 'Failed to save file.'
|
|
);
|
|
}
|
|
};
|
|
|
|
const handleApplyTransformation = async () => {
|
|
setValidationTouched(true);
|
|
setRuntimeDiagnostics([]);
|
|
|
|
const diagnostics = [...xmlDiagnostics, ...xsltDiagnostics];
|
|
if (hasErrors(diagnostics)) {
|
|
setRuntimeDiagnostics(diagnostics);
|
|
setStatusMessage('Please fix validation errors before transforming.');
|
|
return;
|
|
}
|
|
|
|
if (state.options.askBeforeOverwritingOutput) {
|
|
const mayOverwriteOutput = await confirmOverwrite(
|
|
'xmlOutput',
|
|
'Applying the transformation writes a new result.'
|
|
);
|
|
if (!mayOverwriteOutput) return;
|
|
}
|
|
|
|
setBusy(true);
|
|
try {
|
|
const request = {
|
|
xmlText: state.xmlInput.text,
|
|
xsltText: state.xsltCode.text,
|
|
engine: state.selectedEngine,
|
|
};
|
|
|
|
const result = await runTransformation(request);
|
|
setRuntimeDiagnostics(result.diagnostics);
|
|
|
|
if (hasErrors(result.diagnostics)) {
|
|
setStatusMessage(
|
|
'Transformation failed because the input or stylesheet is invalid.'
|
|
);
|
|
return;
|
|
}
|
|
|
|
const output = state.options.prettifyOutputAfterTransform
|
|
? tryFormatXml(result.output)
|
|
: result.output;
|
|
|
|
replaceDocument('xmlOutput', output, state.xmlOutput.fileName, true);
|
|
const run = await createTransformationRun(request, output);
|
|
|
|
setLastTransformation(run);
|
|
setStatusMessage(`Transformation completed with ${result.engine}.`);
|
|
} catch (error) {
|
|
setRuntimeDiagnostics([
|
|
{
|
|
severity: 'error',
|
|
source: 'Transformation engine',
|
|
message:
|
|
error instanceof Error ? error.message : 'Transformation failed.',
|
|
},
|
|
]);
|
|
setStatusMessage('Transformation failed.');
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
const handleMoveOutputToInput = async () => {
|
|
if (!state.xmlOutput.text.trim()) {
|
|
setStatusMessage('The output editor is empty. Nothing to move.');
|
|
return;
|
|
}
|
|
|
|
const mayProceed = await confirmOverwrite(
|
|
'xmlInput',
|
|
'Moving output to input is useful for chained transformations.'
|
|
);
|
|
if (!mayProceed) return;
|
|
|
|
replaceDocument('xmlInput', state.xmlOutput.text, 'from-output.xml', true);
|
|
setStatusMessage('Moved output to XML input.');
|
|
};
|
|
|
|
const handleFormat = async (kind: WorkbenchDocumentKind) => {
|
|
const documentState = state[kind];
|
|
|
|
try {
|
|
const formatted = formatXml(documentState.text);
|
|
if (formatted === documentState.text) {
|
|
setStatusMessage(`${documentState.label} is already formatted.`);
|
|
return;
|
|
}
|
|
|
|
const mayProceed = await askForConfirmation({
|
|
title: `Format ${documentState.label}?`,
|
|
content: (
|
|
<p>
|
|
Formatting rewrites whitespace in{' '}
|
|
<strong>{documentState.label}</strong>.
|
|
</p>
|
|
),
|
|
proceedLabel: 'Format',
|
|
});
|
|
|
|
if (!mayProceed) return;
|
|
replaceDocument(kind, formatted, documentState.fileName, true);
|
|
setStatusMessage(`Formatted ${documentState.label}.`);
|
|
} catch (error) {
|
|
setStatusMessage(
|
|
error instanceof Error ? error.message : 'Could not format XML.'
|
|
);
|
|
}
|
|
};
|
|
|
|
const handleValidate = () => {
|
|
setValidationTouched(true);
|
|
const allDiagnostics = [
|
|
...xmlDiagnostics,
|
|
...xsltDiagnostics,
|
|
...outputDiagnostics,
|
|
];
|
|
setRuntimeDiagnostics(allDiagnostics);
|
|
|
|
if (allDiagnostics.length === 0) {
|
|
setStatusMessage('All current documents are well-formed.');
|
|
return;
|
|
}
|
|
|
|
setStatusMessage(
|
|
hasErrors(allDiagnostics)
|
|
? 'Validation found errors.'
|
|
: 'Validation completed with warnings.'
|
|
);
|
|
};
|
|
|
|
const handleReset = async () => {
|
|
const mayProceed = await askForConfirmation({
|
|
title: 'Reset workspace?',
|
|
content: (
|
|
<p>
|
|
This restores the example XML and XSLT, clears the output, and removes
|
|
the saved LocalStorage workspace.
|
|
</p>
|
|
),
|
|
proceedLabel: 'Reset workspace',
|
|
danger: true,
|
|
});
|
|
|
|
if (!mayProceed) return;
|
|
resetWorkspace();
|
|
setRuntimeDiagnostics([]);
|
|
setValidationTouched(false);
|
|
setStatusMessage('Workspace reset.');
|
|
};
|
|
|
|
const handleEngineChange = (engine: TransformEngineId) => {
|
|
setState((current) => ({ ...current, selectedEngine: engine }));
|
|
};
|
|
|
|
const handlePrettifyOutputAfterTransformChange = (enabled: boolean) => {
|
|
setState((current) => ({
|
|
...current,
|
|
options: {
|
|
...current.options,
|
|
prettifyOutputAfterTransform: enabled,
|
|
},
|
|
}));
|
|
};
|
|
|
|
const handleAskBeforeOverwritingOutputChange = (enabled: boolean) => {
|
|
setState((current) => ({
|
|
...current,
|
|
options: {
|
|
...current.options,
|
|
askBeforeOverwritingOutput: enabled,
|
|
},
|
|
}));
|
|
};
|
|
const traceItems = useMemo(() => {
|
|
if (!state.xmlOutput.text.trim()) return [];
|
|
return createApproximateTrace(
|
|
state.xmlInput.text,
|
|
state.xsltCode.text,
|
|
state.xmlOutput.text
|
|
);
|
|
}, [state.xmlInput.text, state.xsltCode.text, state.xmlOutput.text]);
|
|
|
|
const displayedRuntimeDiagnostics = validationTouched
|
|
? runtimeDiagnostics
|
|
: [];
|
|
|
|
useEffect(() => {
|
|
const handleGlobalKeyDown = (event: KeyboardEvent) => {
|
|
if (event.defaultPrevented || event.repeat || event.isComposing) {
|
|
return;
|
|
}
|
|
|
|
if (event.key === 'F1') {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
handleOpenHelp();
|
|
return;
|
|
}
|
|
|
|
const isApplyShortcut =
|
|
event.key === 'Enter' &&
|
|
event.ctrlKey &&
|
|
!event.altKey &&
|
|
!event.shiftKey &&
|
|
!event.metaKey;
|
|
|
|
if (isApplyShortcut) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
|
|
if (!busy) {
|
|
void handleApplyTransformation();
|
|
}
|
|
}
|
|
};
|
|
|
|
window.addEventListener('keydown', handleGlobalKeyDown, true);
|
|
|
|
return () => {
|
|
window.removeEventListener('keydown', handleGlobalKeyDown, true);
|
|
};
|
|
}, [busy, handleApplyTransformation, handleOpenHelp]);
|
|
|
|
return (
|
|
<Layout onOpenHelp={() => handleOpenHelp()}>
|
|
<div className="workspace-grid">
|
|
<EditorPanel
|
|
ref={xmlInputEditorRef}
|
|
title="XML input"
|
|
value={state.xmlInput.text}
|
|
fileName={state.xmlInput.fileName}
|
|
dirty={state.xmlInput.dirty}
|
|
diagnostics={xmlDiagnostics}
|
|
onChange={(value) => updateDocumentText('xmlInput', value)}
|
|
onOpen={() => void handleOpenFile('xmlInput')}
|
|
onSave={() => void handleSaveFile('xmlInput')}
|
|
onUndo={() => xmlInputEditorRef.current?.undo()}
|
|
onRedo={() => xmlInputEditorRef.current?.redo()}
|
|
onCopy={() => void xmlInputEditorRef.current?.copySelection()}
|
|
onCut={() => void xmlInputEditorRef.current?.cutSelection()}
|
|
onPaste={() => void xmlInputEditorRef.current?.pasteText()}
|
|
onPrettify={() => void handleFormat('xmlInput')}
|
|
placeholderText="Paste or open XML here."
|
|
/>
|
|
|
|
<EditorPanel
|
|
ref={xsltEditorRef}
|
|
title="XSL transformation code"
|
|
value={state.xsltCode.text}
|
|
fileName={state.xsltCode.fileName}
|
|
dirty={state.xsltCode.dirty}
|
|
diagnostics={xsltDiagnostics}
|
|
onChange={(value) => updateDocumentText('xsltCode', value)}
|
|
onOpen={() => void handleOpenFile('xsltCode')}
|
|
onSave={() => void handleSaveFile('xsltCode')}
|
|
onUndo={() => xsltEditorRef.current?.undo()}
|
|
onRedo={() => xsltEditorRef.current?.redo()}
|
|
onCopy={() => void xsltEditorRef.current?.copySelection()}
|
|
onCut={() => void xsltEditorRef.current?.cutSelection()}
|
|
onPaste={() => void xsltEditorRef.current?.pasteText()}
|
|
onPrettify={() => void handleFormat('xsltCode')}
|
|
placeholderText="Write or open XSLT here."
|
|
actions={
|
|
<SnippetToolbox
|
|
onInsertSnippet={(snippet) =>
|
|
xsltEditorRef.current?.insertText(snippet)
|
|
}
|
|
/>
|
|
}
|
|
/>
|
|
|
|
<EditorPanel
|
|
ref={outputEditorRef}
|
|
title="XML output"
|
|
value={state.xmlOutput.text}
|
|
fileName={state.xmlOutput.fileName}
|
|
dirty={state.xmlOutput.dirty}
|
|
readOnly={false}
|
|
diagnostics={outputDiagnostics}
|
|
onChange={(value) => updateDocumentText('xmlOutput', value)}
|
|
onOpen={null}
|
|
onSave={() => void handleSaveFile('xmlOutput')}
|
|
onUndo={() => outputEditorRef.current?.undo()}
|
|
onRedo={() => outputEditorRef.current?.redo()}
|
|
onCopy={() => void outputEditorRef.current?.copySelection()}
|
|
onCut={() => void outputEditorRef.current?.cutSelection()}
|
|
onPaste={() => void outputEditorRef.current?.pasteText()}
|
|
onPrettify={() => void handleFormat('xmlOutput')}
|
|
placeholderText="Transformation output appears here."
|
|
/>
|
|
</div>
|
|
|
|
<Toolbar
|
|
selectedEngine={state.selectedEngine}
|
|
onEngineChange={handleEngineChange}
|
|
onApplyTransformation={() => void handleApplyTransformation()}
|
|
onMoveOutputToInput={() => void handleMoveOutputToInput()}
|
|
onValidate={handleValidate}
|
|
onReset={() => void handleReset()}
|
|
prettifyOutputAfterTransform={state.options.prettifyOutputAfterTransform}
|
|
onPrettifyOutputAfterTransformChange={
|
|
handlePrettifyOutputAfterTransformChange
|
|
}
|
|
askBeforeOverwritingOutput={state.options.askBeforeOverwritingOutput}
|
|
onAskBeforeOverwritingOutputChange={
|
|
handleAskBeforeOverwritingOutputChange
|
|
}
|
|
busy={busy}
|
|
/>
|
|
|
|
{statusMessage && <div className="status-banner">{statusMessage}</div>}
|
|
|
|
{displayedRuntimeDiagnostics.length > 0 && (
|
|
<section className="card runtime-diagnostics">
|
|
<h2>Validation / runtime messages</h2>
|
|
<ul className="diagnostics-list">
|
|
{displayedRuntimeDiagnostics.map((diagnostic, index) => (
|
|
<li
|
|
key={`${diagnostic.source}-${diagnostic.message}-${index}`}
|
|
className={`diagnostic diagnostic-${diagnostic.severity}`}
|
|
>
|
|
<strong>{diagnostic.source}</strong>
|
|
<span>{diagnostic.message}</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</section>
|
|
)}
|
|
|
|
|
|
<section className="trace-panel card">
|
|
<div className="trace-header">
|
|
<div>
|
|
<h2>Explain transformation</h2>
|
|
<p>
|
|
MVP approximation: result nodes are compared with simple
|
|
stylesheet template matches. Full execution tracing requires an
|
|
instrumented engine later.
|
|
</p>
|
|
</div>
|
|
{state.lastTransformation && (
|
|
<small>
|
|
Last run:{' '}
|
|
{new Date(
|
|
state.lastTransformation.transformedAt
|
|
).toLocaleString()}
|
|
</small>
|
|
)}
|
|
</div>
|
|
|
|
{traceItems.length === 0 ? (
|
|
<p className="diagnostics-empty">
|
|
Run a transformation to see approximate result explanations.
|
|
</p>
|
|
) : (
|
|
<div className="trace-table-wrap">
|
|
<table className="trace-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Result</th>
|
|
<th>Likely source</th>
|
|
<th>Likely template</th>
|
|
<th>Confidence</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{traceItems.map((item) => (
|
|
<tr key={item.resultPath}>
|
|
<td>
|
|
<code>{item.resultPath}</code>
|
|
</td>
|
|
<td>
|
|
<code>{item.likelySourcePath ?? '—'}</code>
|
|
</td>
|
|
<td>
|
|
<code>{item.likelyTemplate ?? '—'}</code>
|
|
</td>
|
|
<td>{item.confidence}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</section>
|
|
|
|
<ActionDialog
|
|
open={Boolean(dialog)}
|
|
title={dialog?.title ?? ''}
|
|
actions={dialog?.actions ?? []}
|
|
onClose={closeDialog}
|
|
>
|
|
{dialog?.content}
|
|
</ActionDialog>
|
|
|
|
{helpOpen && (
|
|
<HelpDialog
|
|
open={helpOpen}
|
|
onClose={handleCloseHelp}
|
|
/>
|
|
)}
|
|
</Layout>
|
|
);
|
|
};
|
|
|
|
function tryFormatXml(text: string): string {
|
|
try {
|
|
return text.trim() ? formatXml(text) : text;
|
|
} catch {
|
|
return text;
|
|
}
|
|
}
|
|
|
|
function defaultFileName(kind: WorkbenchDocumentKind): string {
|
|
switch (kind) {
|
|
case 'xmlInput':
|
|
return 'input.xml';
|
|
case 'xsltCode':
|
|
return 'transform.xsl';
|
|
case 'xmlOutput':
|
|
return 'output.xml';
|
|
}
|
|
}
|
|
|
|
function isAbortError(error: unknown): boolean {
|
|
return error instanceof DOMException && error.name === 'AbortError';
|
|
}
|
|
|
|
export default App;
|