Files
xslt-tools/src/App.tsx

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;