frontend_nyla/src/pages/views/workspace/WorkspaceEditorPage.tsx
2026-03-17 22:51:36 +01:00

278 lines
9.9 KiB
TypeScript

/**
* WorkspaceEditorPage -- Diff editor for reviewing AI agent file edit proposals.
*
* Full-page layout with:
* - Header: back-to-dashboard, accept-all / reject-all
* - Tab bar: one tab per pending edit
* - Center: Monaco DiffEditor (original vs. modified)
* - Footer: status bar with counts and file metadata
*/
import React, { useMemo, useState, useEffect, useRef } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { DiffEditor } from '@monaco-editor/react';
import type { editor as monacoEditor } from 'monaco-editor';
import { useInstanceId } from '../../../hooks/useCurrentInstance';
import { useWorkspaceEditor, type EditorFileEdit } from './useWorkspaceEditor';
import { FaArrowLeft, FaCheck, FaTimes, FaCheckDouble, FaBan, FaSync } from 'react-icons/fa';
function _getMonacoLanguage(fileName: string): string {
const ext = fileName.split('.').pop()?.toLowerCase() || '';
const langMap: Record<string, string> = {
js: 'javascript', jsx: 'javascript', ts: 'typescript', tsx: 'typescript',
py: 'python', json: 'json', html: 'html', css: 'css', md: 'markdown',
xml: 'xml', yaml: 'yaml', yml: 'yaml', sh: 'shell', sql: 'sql',
txt: 'plaintext', csv: 'plaintext', log: 'plaintext',
};
return langMap[ext] || 'plaintext';
}
function _formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
export const WorkspaceEditorPage: React.FC = () => {
const instanceId = useInstanceId() || '';
const navigate = useNavigate();
const { mandateId, featureCode, instanceId: routeInstanceId } = useParams<{
mandateId: string; featureCode: string; instanceId: string;
}>();
const editor = useWorkspaceEditor(instanceId);
const activeEdit = useMemo(
() => editor.edits.find(e => e.id === editor.activeEditId) || null,
[editor.edits, editor.activeEditId],
);
const pendingEdits = useMemo(
() => editor.edits.filter(e => e.status === 'pending'),
[editor.edits],
);
const _goBack = () => navigate(`/mandates/${mandateId}/${featureCode}/${routeInstanceId}/dashboard`);
if (!instanceId) {
return (
<div style={{ padding: 32, textAlign: 'center', color: '#999' }}>
Keine Workspace-Instanz ausgewaehlt.
</div>
);
}
return (
<div style={{
display: 'flex', flexDirection: 'column', height: '100%',
background: 'var(--bg-primary, #fff)',
}}>
{/* Header */}
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '8px 16px', borderBottom: '1px solid var(--border-color, #e0e0e0)',
background: 'var(--bg-secondary, #f8f9fa)', flexShrink: 0,
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<button onClick={_goBack} style={_btnStyle} title="Zurueck zum Dashboard">
<FaArrowLeft size={14} />
</button>
<span style={{ fontWeight: 600, fontSize: 15 }}>
File Edit Review
</span>
<span style={{ fontSize: 13, color: '#888' }}>
{editor.pendingCount} pending
</span>
<button onClick={editor.refresh} style={_btnStyle} title="Aktualisieren">
<FaSync size={12} />
</button>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button
onClick={editor.acceptAll}
disabled={editor.pendingCount === 0}
style={{ ..._actionBtnStyle, background: 'var(--success-color, #4caf50)', color: '#fff' }}
>
<FaCheckDouble size={12} /> Accept All
</button>
<button
onClick={editor.rejectAll}
disabled={editor.pendingCount === 0}
style={{ ..._actionBtnStyle, background: 'transparent', border: '1px solid var(--border-color, #ccc)' }}
>
<FaBan size={12} /> Reject All
</button>
</div>
</div>
{/* Tab bar */}
{pendingEdits.length > 0 && (
<div style={{
display: 'flex', overflowX: 'auto', flexShrink: 0,
borderBottom: '1px solid var(--border-color, #e0e0e0)',
background: 'var(--bg-secondary, #f8f9fa)',
}}>
{pendingEdits.map(edit => (
<_EditorTab
key={edit.id}
edit={edit}
isActive={edit.id === editor.activeEditId}
onClick={() => editor.setActiveEditId(edit.id)}
/>
))}
</div>
)}
{/* Main content */}
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
{editor.isLoading ? (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%', color: '#888' }}>
Lade Aenderungsvorschlaege...
</div>
) : pendingEdits.length === 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', color: '#888', gap: 12 }}>
<span style={{ fontSize: 48, opacity: 0.3 }}></span>
<span style={{ fontSize: 16 }}>Keine offenen Aenderungsvorschlaege</span>
<button onClick={_goBack} style={{ ..._actionBtnStyle, marginTop: 8 }}>
Zurueck zum Dashboard
</button>
</div>
) : activeEdit ? (
<_SafeDiffEditor
key={activeEdit.id}
original={activeEdit.oldContent}
modified={activeEdit.newContent}
language={_getMonacoLanguage(activeEdit.fileName)}
/>
) : null}
</div>
{/* Footer / action bar for active edit */}
{activeEdit && activeEdit.status === 'pending' && (
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '8px 16px', borderTop: '1px solid var(--border-color, #e0e0e0)',
background: 'var(--bg-secondary, #f8f9fa)', flexShrink: 0,
}}>
<div style={{ fontSize: 12, color: '#888', display: 'flex', gap: 16 }}>
<span>{activeEdit.fileName}</span>
<span>Original: {_formatBytes(activeEdit.oldContent.length)}</span>
<span>Geaendert: {_formatBytes(activeEdit.newContent.length)}</span>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button
onClick={() => editor.acceptEdit(activeEdit.id)}
style={{ ..._actionBtnStyle, background: 'var(--success-color, #4caf50)', color: '#fff' }}
>
<FaCheck size={12} /> Accept
</button>
<button
onClick={() => editor.rejectEdit(activeEdit.id)}
style={{ ..._actionBtnStyle, border: '1px solid var(--error-color, #f44336)', color: 'var(--error-color, #f44336)' }}
>
<FaTimes size={12} /> Reject
</button>
</div>
</div>
)}
</div>
);
};
// ---------------------------------------------------------------------------
// Safe DiffEditor wrapper -- prevents "TextModel got disposed" errors
// by tracking the editor ref and skipping disposal when already torn down.
// ---------------------------------------------------------------------------
const _SafeDiffEditor: React.FC<{
original: string;
modified: string;
language: string;
}> = ({ original, modified, language }) => {
const editorRef = useRef<monacoEditor.IDiffEditor | null>(null);
const [ready, setReady] = useState(false);
useEffect(() => {
setReady(true);
return () => {
if (editorRef.current) {
try {
editorRef.current.dispose();
} catch { /* already disposed */ }
editorRef.current = null;
}
};
}, []);
if (!ready) return null;
return (
<DiffEditor
original={original}
modified={modified}
language={language}
theme="vs-dark"
onMount={(diffEditor) => { editorRef.current = diffEditor; }}
options={{
readOnly: true,
renderSideBySide: true,
minimap: { enabled: false },
fontSize: 13,
lineNumbers: 'on',
scrollBeyondLastLine: false,
wordWrap: 'on',
originalEditable: false,
}}
/>
);
};
// ---------------------------------------------------------------------------
// Sub-components
// ---------------------------------------------------------------------------
const _EditorTab: React.FC<{
edit: EditorFileEdit;
isActive: boolean;
onClick: () => void;
}> = ({ edit, isActive, onClick }) => (
<button
onClick={onClick}
style={{
padding: '6px 16px',
fontSize: 13,
border: 'none',
borderBottom: isActive ? '2px solid var(--primary-color, #1976d2)' : '2px solid transparent',
background: isActive ? 'var(--bg-primary, #fff)' : 'transparent',
cursor: 'pointer',
whiteSpace: 'nowrap',
color: isActive ? 'var(--text-primary, #333)' : 'var(--text-secondary, #888)',
fontWeight: isActive ? 600 : 400,
display: 'flex', alignItems: 'center', gap: 6,
}}
>
<span style={{
width: 8, height: 8, borderRadius: '50%',
background: edit.status === 'pending' ? '#ff9800'
: edit.status === 'accepted' ? '#4caf50' : '#f44336',
flexShrink: 0,
}} />
{edit.fileName}
</button>
);
// ---------------------------------------------------------------------------
// Shared styles
// ---------------------------------------------------------------------------
const _btnStyle: React.CSSProperties = {
padding: '6px 8px', borderRadius: 4, border: '1px solid var(--border-color, #ddd)',
background: 'transparent', cursor: 'pointer', display: 'flex', alignItems: 'center',
};
const _actionBtnStyle: React.CSSProperties = {
padding: '5px 14px', borderRadius: 4, border: 'none',
cursor: 'pointer', fontSize: 12, fontWeight: 600,
display: 'flex', alignItems: 'center', gap: 6,
};
export default WorkspaceEditorPage;