refactor: migrate extension to TypeScript and add Material-UI components
This commit is contained in:
557
hero_vault_extension/src/pages/ScriptPage.tsx
Normal file
557
hero_vault_extension/src/pages/ScriptPage.tsx
Normal file
@@ -0,0 +1,557 @@
|
||||
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;
|
Reference in New Issue
Block a user