sal-modular/hero_vault_extension/src/pages/ScriptPage.tsx

558 lines
18 KiB
TypeScript

import { useState, useEffect } from 'react';
import { getChromeApi } from '../utils/chromeApi';
import {
Box,
Typography,
Button,
TextField,
Paper,
Alert,
CircularProgress,
Divider,
Tabs,
Tab,
List,
ListItem,
ListItemText,
ListItemSecondaryAction,
IconButton,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Chip
} from '@mui/material';
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
import VisibilityIcon from '@mui/icons-material/Visibility';
// DeleteIcon removed as it's not used
import { useNavigate } from 'react-router-dom';
import { useSessionStore } from '../store/sessionStore';
interface ScriptResult {
id: string;
timestamp: number;
script: string;
result: string;
success: boolean;
}
interface PendingScript {
id: string;
title: string;
description: string;
script: string;
tags: string[];
timestamp: number;
}
const ScriptPage = () => {
const navigate = useNavigate();
const { isSessionUnlocked, currentKeypair } = useSessionStore();
const [tabValue, setTabValue] = useState<number>(0);
const [scriptInput, setScriptInput] = useState<string>('');
const [isExecuting, setIsExecuting] = useState<boolean>(false);
const [executionResult, setExecutionResult] = useState<string | null>(null);
const [executionSuccess, setExecutionSuccess] = useState<boolean | null>(null);
const [scriptResults, setScriptResults] = useState<ScriptResult[]>([]);
const [pendingScripts, setPendingScripts] = useState<PendingScript[]>([]);
const [selectedPendingScript, setSelectedPendingScript] = useState<PendingScript | null>(null);
const [scriptDialogOpen, setScriptDialogOpen] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
// Redirect if not unlocked
useEffect(() => {
if (!isSessionUnlocked) {
navigate('/');
}
}, [isSessionUnlocked, navigate]);
// Load pending scripts from storage
useEffect(() => {
const loadPendingScripts = async () => {
try {
const chromeApi = getChromeApi();
const data = await chromeApi.storage.local.get('pendingScripts');
if (data.pendingScripts) {
setPendingScripts(data.pendingScripts);
}
} catch (err) {
console.error('Failed to load pending scripts:', err);
}
};
if (isSessionUnlocked) {
loadPendingScripts();
}
}, [isSessionUnlocked]);
// Load script history from storage
useEffect(() => {
const loadScriptResults = async () => {
try {
const chromeApi = getChromeApi();
const data = await chromeApi.storage.local.get('scriptResults');
if (data.scriptResults) {
setScriptResults(data.scriptResults);
}
} catch (err) {
console.error('Failed to load script results:', err);
}
};
if (isSessionUnlocked) {
loadScriptResults();
}
}, [isSessionUnlocked]);
const handleTabChange = (_: React.SyntheticEvent, newValue: number) => {
setTabValue(newValue);
};
const handleExecuteScript = async () => {
if (!scriptInput.trim()) return;
setIsExecuting(true);
setError(null);
setExecutionResult(null);
setExecutionSuccess(null);
try {
// Call the WASM run_rhai function via our store
const result = await useSessionStore.getState().executeScript(scriptInput);
setExecutionResult(result);
setExecutionSuccess(true);
// Save to history
const newResult: ScriptResult = {
id: `script-${Date.now()}`,
timestamp: Date.now(),
script: scriptInput,
result,
success: true
};
const updatedResults = [newResult, ...scriptResults].slice(0, 20); // Keep last 20
setScriptResults(updatedResults);
// Save to storage
const chromeApi = getChromeApi();
await chromeApi.storage.local.set({ scriptResults: updatedResults });
} catch (err) {
setError((err as Error).message || 'Failed to execute script');
setExecutionSuccess(false);
setExecutionResult('Execution failed');
} finally {
setIsExecuting(false);
}
};
const handleViewPendingScript = (script: PendingScript) => {
setSelectedPendingScript(script);
setScriptDialogOpen(true);
};
const handleApprovePendingScript = async () => {
if (!selectedPendingScript) return;
setScriptDialogOpen(false);
setScriptInput(selectedPendingScript.script);
setTabValue(0); // Switch to execute tab
// Remove from pending list
const updatedPendingScripts = pendingScripts.filter(
script => script.id !== selectedPendingScript.id
);
setPendingScripts(updatedPendingScripts);
const chromeApi = getChromeApi();
await chromeApi.storage.local.set({ pendingScripts: updatedPendingScripts });
setSelectedPendingScript(null);
};
const handleRejectPendingScript = async () => {
if (!selectedPendingScript) return;
// Remove from pending list
const updatedPendingScripts = pendingScripts.filter(
script => script.id !== selectedPendingScript.id
);
setPendingScripts(updatedPendingScripts);
const chromeApi = getChromeApi();
await chromeApi.storage.local.set({ pendingScripts: updatedPendingScripts });
setScriptDialogOpen(false);
setSelectedPendingScript(null);
};
const handleClearHistory = async () => {
setScriptResults([]);
const chromeApi = getChromeApi();
await chromeApi.storage.local.set({ scriptResults: [] });
};
if (!isSessionUnlocked) {
return null; // Will redirect via useEffect
}
return (
<Box sx={{ height: '100%', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<Box sx={{ borderBottom: 1, borderColor: 'divider' }}>
<Tabs
value={tabValue}
onChange={handleTabChange}
aria-label="script tabs"
variant="scrollable"
scrollButtons="auto"
allowScrollButtonsMobile
sx={{ minHeight: '48px' }}
>
<Tab label="Execute" sx={{ minHeight: '48px', py: 0 }} />
<Tab
label={
<Box sx={{ display: 'flex', alignItems: 'center' }}>
Pending
{pendingScripts.length > 0 && (
<Chip
label={pendingScripts.length}
size="small"
color="primary"
sx={{ ml: 1 }}
/>
)}
</Box>
}
sx={{ minHeight: '48px', py: 0 }}
/>
<Tab label="History" sx={{ minHeight: '48px', py: 0 }} />
</Tabs>
</Box>
{/* Execute Tab */}
{tabValue === 0 && (
<Box sx={{
p: 2,
flexGrow: 1,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
height: 'calc(100% - 48px)' // Subtract tab height
}}>
<Box sx={{
display: 'flex',
flexDirection: 'column',
overflow: 'auto',
height: '100%',
pb: 2 // Add padding at bottom for scrolling
}}>
{!currentKeypair && (
<Alert severity="warning" sx={{ mb: 2 }}>
No keypair selected. Select a keypair to enable script execution with signing capabilities.
</Alert>
)}
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
<TextField
label="Rhai Script"
multiline
rows={6} // Reduced from 8 to leave more space for results
value={scriptInput}
onChange={(e) => setScriptInput(e.target.value)}
fullWidth
variant="outlined"
placeholder="Enter your Rhai script here..."
sx={{ mb: 2 }}
disabled={isExecuting}
/>
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mb: 2 }}>
<Button
variant="contained"
color="primary"
startIcon={<PlayArrowIcon />}
onClick={handleExecuteScript}
disabled={isExecuting || !scriptInput.trim()}
>
{isExecuting ? <CircularProgress size={24} /> : 'Execute'}
</Button>
</Box>
{executionResult && (
<Paper
variant="outlined"
sx={{
p: 2,
bgcolor: executionSuccess ? 'success.dark' : 'error.dark',
color: 'white',
overflowY: 'auto',
mb: 2, // Add margin at bottom
minHeight: '100px', // Ensure minimum height for visibility
maxHeight: '200px' // Limit maximum height
}}
>
<Typography variant="subtitle2" gutterBottom>
Execution Result:
</Typography>
<Typography
variant="body2"
component="pre"
sx={{
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
fontFamily: 'monospace'
}}
>
{executionResult}
</Typography>
</Paper>
)}
</Box>
</Box>
)}
{/* Pending Scripts Tab */}
{tabValue === 1 && (
<Box sx={{ p: 2, flexGrow: 1, display: 'flex', flexDirection: 'column' }}>
{pendingScripts.length === 0 ? (
<Paper sx={{ p: 3, textAlign: 'center' }}>
<Typography variant="body1" color="text.secondary">
No pending scripts. Incoming scripts from connected WebSocket servers will appear here.
</Typography>
</Paper>
) : (
<Paper variant="outlined" sx={{ flexGrow: 1, overflow: 'auto' }}>
<List disablePadding>
{pendingScripts.map((script, index) => (
<Box key={script.id}>
{index > 0 && <Divider />}
<ListItem>
<ListItemText
primary={script.title}
secondary={
<>
<Typography variant="body2" color="text.secondary">
{script.description || 'No description'}
</Typography>
<Box sx={{ mt: 0.5 }}>
{script.tags.map(tag => (
<Chip
key={tag}
label={tag}
size="small"
color={tag === 'remote' ? 'secondary' : 'primary'}
variant="outlined"
sx={{ mr: 0.5 }}
/>
))}
</Box>
</>
}
/>
<ListItemSecondaryAction>
<IconButton
edge="end"
onClick={() => handleViewPendingScript(script)}
aria-label="view script"
>
<VisibilityIcon />
</IconButton>
</ListItemSecondaryAction>
</ListItem>
</Box>
))}
</List>
</Paper>
)}
</Box>
)}
{/* History Tab */}
{tabValue === 2 && (
<Box sx={{
p: 2,
flexGrow: 1,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
height: 'calc(100% - 48px)' // Subtract tab height
}}>
<Box sx={{
display: 'flex',
flexDirection: 'column',
overflow: 'auto',
height: '100%',
pb: 2 // Add padding at bottom for scrolling
}}>
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mb: 2 }}>
<Button
variant="outlined"
color="error"
size="small"
onClick={handleClearHistory}
disabled={scriptResults.length === 0}
>
Clear History
</Button>
</Box>
{scriptResults.length === 0 ? (
<Paper sx={{ p: 3, textAlign: 'center' }}>
<Typography variant="body1" color="text.secondary">
No script execution history yet.
</Typography>
</Paper>
) : (
<Paper variant="outlined" sx={{ flexGrow: 1, overflow: 'auto' }}>
<List disablePadding>
{scriptResults.map((result, index) => (
<Box key={result.id}>
{index > 0 && <Divider />}
<ListItem>
<ListItemText
primary={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="subtitle2">
{new Date(result.timestamp).toLocaleString()}
</Typography>
<Chip
label={result.success ? 'Success' : 'Failed'}
size="small"
color={result.success ? 'success' : 'error'}
variant="outlined"
/>
</Box>
}
secondary={
<Typography
variant="body2"
color="text.secondary"
sx={{
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
maxWidth: '280px'
}}
>
{result.script}
</Typography>
}
/>
<ListItemSecondaryAction>
<IconButton
edge="end"
onClick={() => {
setScriptInput(result.script);
setTabValue(0);
}}
aria-label="reuse script"
>
<PlayArrowIcon />
</IconButton>
</ListItemSecondaryAction>
</ListItem>
</Box>
))}
</List>
</Paper>
)}
</Box>
</Box>
)}
{/* Pending Script Dialog */}
<Dialog
open={scriptDialogOpen}
onClose={() => setScriptDialogOpen(false)}
maxWidth="md"
fullWidth
>
<DialogTitle>
{selectedPendingScript?.title || 'Script Details'}
</DialogTitle>
<DialogContent>
{selectedPendingScript && (
<>
<Typography variant="subtitle2" gutterBottom>
Description:
</Typography>
<Typography variant="body2" paragraph>
{selectedPendingScript.description || 'No description provided'}
</Typography>
<Box sx={{ mb: 2 }}>
{selectedPendingScript.tags.map(tag => (
<Chip
key={tag}
label={tag}
size="small"
color={tag === 'remote' ? 'secondary' : 'primary'}
sx={{ mr: 0.5 }}
/>
))}
</Box>
<Typography variant="subtitle2" gutterBottom>
Script Content:
</Typography>
<Paper
variant="outlined"
sx={{
p: 2,
bgcolor: 'background.paper',
maxHeight: '300px',
overflow: 'auto'
}}
>
<Typography
variant="body2"
component="pre"
sx={{
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
fontFamily: 'monospace'
}}
>
{selectedPendingScript.script}
</Typography>
</Paper>
<Alert severity="warning" sx={{ mt: 2 }}>
<Typography variant="body2">
{selectedPendingScript.tags.includes('remote')
? 'This is a remote script. If approved, your signature will be sent to the server and the script may execute remotely.'
: 'This script will execute locally in your browser extension if approved.'}
</Typography>
</Alert>
</>
)}
</DialogContent>
<DialogActions>
<Button
onClick={handleRejectPendingScript}
color="error"
variant="outlined"
>
Reject
</Button>
<Button
onClick={handleApprovePendingScript}
color="primary"
variant="contained"
>
Approve
</Button>
</DialogActions>
</Dialog>
</Box>
);
};
export default ScriptPage;