278 lines
9.9 KiB
TypeScript
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;
|