|
|
@@ -4,7 +4,11 @@ import Button from '@mui/material/Button'
|
|
|
|
import Dialog from './ModalDialog'
|
|
|
|
import Dialog from './ModalDialog'
|
|
|
|
import DialogActions from '@mui/material/DialogActions'
|
|
|
|
import DialogActions from '@mui/material/DialogActions'
|
|
|
|
import DialogTitle from '@mui/material/DialogTitle'
|
|
|
|
import DialogTitle from '@mui/material/DialogTitle'
|
|
|
|
|
|
|
|
import FormControlLabel from '@mui/material/FormControlLabel'
|
|
|
|
|
|
|
|
import Switch from '@mui/material/Switch'
|
|
|
|
|
|
|
|
import TextField from '@mui/material/TextField'
|
|
|
|
import Typography from '@mui/material/Typography'
|
|
|
|
import Typography from '@mui/material/Typography'
|
|
|
|
|
|
|
|
import { ChangeEvent as ReactChangeEvent, KeyboardEvent as ReactKeyboardEvent, useEffect, useMemo, useRef, useState } from 'react'
|
|
|
|
import FormDialogContent from './FormDialogContent'
|
|
|
|
import FormDialogContent from './FormDialogContent'
|
|
|
|
|
|
|
|
|
|
|
|
type SSHTranscriptDialogProps = {
|
|
|
|
type SSHTranscriptDialogProps = {
|
|
|
@@ -19,8 +23,155 @@ type SSHTranscriptDialogProps = {
|
|
|
|
onDownload: () => void
|
|
|
|
onDownload: () => void
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
type TranscriptMatch = {
|
|
|
|
|
|
|
|
start: number
|
|
|
|
|
|
|
|
end: number
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function cleanTerminalTranscript(text: string): string {
|
|
|
|
|
|
|
|
let out: string
|
|
|
|
|
|
|
|
let i: number
|
|
|
|
|
|
|
|
let code: number
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
out = text
|
|
|
|
|
|
|
|
out = out.replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, '')
|
|
|
|
|
|
|
|
out = out.replace(/\x1b[P^_].*?\x1b\\/gs, '')
|
|
|
|
|
|
|
|
out = out.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '')
|
|
|
|
|
|
|
|
out = out.replace(/\x1b[()][A-Za-z0-9]/g, '')
|
|
|
|
|
|
|
|
out = out.replace(/\x1b[=>78DMHc]/g, '')
|
|
|
|
|
|
|
|
out = out.replace(/\r\n/g, '\n')
|
|
|
|
|
|
|
|
out = out.split('\n').map((line: string) => {
|
|
|
|
|
|
|
|
const parts: string[] = line.split('\r')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
return parts[parts.length - 1]
|
|
|
|
|
|
|
|
}).join('\n')
|
|
|
|
|
|
|
|
while (out.includes('\b')) {
|
|
|
|
|
|
|
|
out = out.replace(/[^\n]\x08/g, '')
|
|
|
|
|
|
|
|
out = out.replace(/^\x08/gm, '')
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
out = out.replace(/\x07/g, '')
|
|
|
|
|
|
|
|
out = out.replace(/\x0b|\x0c/g, '\n')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
let cleaned: string = ''
|
|
|
|
|
|
|
|
for (i = 0; i < out.length; i += 1) {
|
|
|
|
|
|
|
|
code = out.charCodeAt(i)
|
|
|
|
|
|
|
|
if (code === 9 || code === 10) {
|
|
|
|
|
|
|
|
cleaned += out[i]
|
|
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
if (code < 32 || code === 127) {
|
|
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
cleaned += out[i]
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
return cleaned
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function escapeRegExp(text: string): string {
|
|
|
|
|
|
|
|
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function findTranscriptMatches(text: string, query: string, regexMode: boolean): { matches: TranscriptMatch[], error: string | null } {
|
|
|
|
|
|
|
|
var matches: TranscriptMatch[]
|
|
|
|
|
|
|
|
var pattern: RegExp
|
|
|
|
|
|
|
|
var match: RegExpExecArray | null
|
|
|
|
|
|
|
|
var source: string
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
matches = []
|
|
|
|
|
|
|
|
if (query === '') {
|
|
|
|
|
|
|
|
return { matches, error: null }
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
|
|
source = regexMode ? query : escapeRegExp(query)
|
|
|
|
|
|
|
|
pattern = new RegExp(source, 'gi')
|
|
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
|
|
return { matches, error: err instanceof Error ? err.message : 'Invalid regular expression' }
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
for (;;) {
|
|
|
|
|
|
|
|
match = pattern.exec(text)
|
|
|
|
|
|
|
|
if (!match) {
|
|
|
|
|
|
|
|
break
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
matches.push({ start: match.index, end: match.index + match[0].length })
|
|
|
|
|
|
|
|
if (match[0].length === 0) {
|
|
|
|
|
|
|
|
pattern.lastIndex += 1
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
return { matches, error: null }
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export default function SSHTranscriptDialog(props: SSHTranscriptDialogProps) {
|
|
|
|
export default function SSHTranscriptDialog(props: SSHTranscriptDialogProps) {
|
|
|
|
const downloadDisabled: boolean = Boolean(props.loading) || props.text === '' || Boolean(props.downloadDisabled)
|
|
|
|
const downloadDisabled: boolean = Boolean(props.loading) || props.text === '' || Boolean(props.downloadDisabled)
|
|
|
|
|
|
|
|
const [cleanMode, setCleanMode] = useState(true)
|
|
|
|
|
|
|
|
const [searchText, setSearchText] = useState('')
|
|
|
|
|
|
|
|
const [regexMode, setRegexMode] = useState(false)
|
|
|
|
|
|
|
|
const [matchIndex, setMatchIndex] = useState(0)
|
|
|
|
|
|
|
|
const transcriptRef = useRef<HTMLPreElement | null>(null)
|
|
|
|
|
|
|
|
const displayedText = useMemo(() => {
|
|
|
|
|
|
|
|
if (!cleanMode) {
|
|
|
|
|
|
|
|
return props.text
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
return cleanTerminalTranscript(props.text)
|
|
|
|
|
|
|
|
}, [cleanMode, props.text])
|
|
|
|
|
|
|
|
const searchResult = useMemo(() => {
|
|
|
|
|
|
|
|
return findTranscriptMatches(displayedText, searchText, regexMode)
|
|
|
|
|
|
|
|
}, [displayedText, regexMode, searchText])
|
|
|
|
|
|
|
|
const currentMatch: TranscriptMatch | null = searchResult.matches.length > 0 ? searchResult.matches[Math.min(matchIndex, searchResult.matches.length - 1)] : null
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
|
|
if (!props.open || props.loading || props.error) {
|
|
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
window.setTimeout(() => {
|
|
|
|
|
|
|
|
transcriptRef.current?.focus({ preventScroll: true })
|
|
|
|
|
|
|
|
}, 0)
|
|
|
|
|
|
|
|
}, [props.error, props.loading, props.open])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
|
|
setMatchIndex(0)
|
|
|
|
|
|
|
|
}, [cleanMode, props.text, regexMode, searchText])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
|
|
var element: HTMLPreElement | null
|
|
|
|
|
|
|
|
var textNode: ChildNode | undefined
|
|
|
|
|
|
|
|
var range: Range
|
|
|
|
|
|
|
|
var rect: DOMRect
|
|
|
|
|
|
|
|
var hostRect: DOMRect
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
element = transcriptRef.current
|
|
|
|
|
|
|
|
if (!element || !currentMatch) {
|
|
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
textNode = element.childNodes[0]
|
|
|
|
|
|
|
|
if (!textNode || textNode.nodeType !== Node.TEXT_NODE) {
|
|
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
range = document.createRange()
|
|
|
|
|
|
|
|
range.setStart(textNode, currentMatch.start)
|
|
|
|
|
|
|
|
range.setEnd(textNode, currentMatch.end)
|
|
|
|
|
|
|
|
rect = range.getBoundingClientRect()
|
|
|
|
|
|
|
|
hostRect = element.getBoundingClientRect()
|
|
|
|
|
|
|
|
if (rect.top < hostRect.top || rect.bottom > hostRect.bottom) {
|
|
|
|
|
|
|
|
element.scrollTop += rect.top - hostRect.top - 24
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}, [currentMatch])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const moveSearch = (direction: 1 | -1) => {
|
|
|
|
|
|
|
|
const count: number = searchResult.matches.length
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if (count === 0) {
|
|
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
setMatchIndex((prev: number) => (prev + direction + count) % count)
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const handleSearchKeyDown = (event: ReactKeyboardEvent<HTMLInputElement>) => {
|
|
|
|
|
|
|
|
if (event.key !== 'Enter') {
|
|
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
event.preventDefault()
|
|
|
|
|
|
|
|
moveSearch(event.shiftKey ? -1 : 1)
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
return (
|
|
|
|
<Dialog open={props.open} onClose={props.onClose} fullWidth maxWidth="md">
|
|
|
|
<Dialog open={props.open} onClose={props.onClose} fullWidth maxWidth="md">
|
|
|
@@ -33,24 +184,62 @@ export default function SSHTranscriptDialog(props: SSHTranscriptDialogProps) {
|
|
|
|
</Typography>
|
|
|
|
</Typography>
|
|
|
|
) : null}
|
|
|
|
) : null}
|
|
|
|
{!props.loading && !props.error ? (
|
|
|
|
{!props.loading && !props.error ? (
|
|
|
|
<Box
|
|
|
|
<>
|
|
|
|
component="pre"
|
|
|
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', gap: 1, alignItems: 'center', flexWrap: 'wrap' }}>
|
|
|
|
sx={{
|
|
|
|
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center', flexWrap: 'wrap', flex: '1 1 420px' }}>
|
|
|
|
m: 0,
|
|
|
|
<TextField
|
|
|
|
p: 1.5,
|
|
|
|
size="small"
|
|
|
|
borderRadius: 1,
|
|
|
|
label="Search"
|
|
|
|
bgcolor: 'background.default',
|
|
|
|
value={searchText}
|
|
|
|
border: (theme) => `1px solid ${theme.palette.divider}`,
|
|
|
|
onChange={(event: ReactChangeEvent<HTMLInputElement>) => setSearchText(event.target.value)}
|
|
|
|
overflow: 'auto',
|
|
|
|
onKeyDown={handleSearchKeyDown}
|
|
|
|
maxHeight: '70vh',
|
|
|
|
error={Boolean(searchResult.error)}
|
|
|
|
fontFamily: 'monospace',
|
|
|
|
sx={{ minWidth: 260, flex: '1 1 260px' }}
|
|
|
|
fontSize: 13,
|
|
|
|
/>
|
|
|
|
whiteSpace: 'pre-wrap',
|
|
|
|
<Typography>
|
|
|
|
wordBreak: 'break-word'
|
|
|
|
{searchResult.error || (searchText ? `${searchResult.matches.length ? matchIndex + 1 : 0}/${searchResult.matches.length}` : '')}
|
|
|
|
}}
|
|
|
|
</Typography>
|
|
|
|
>
|
|
|
|
<Button size="small" variant="outlined" onClick={() => moveSearch(-1)} disabled={searchResult.matches.length === 0}>
|
|
|
|
{props.text || '(empty transcript)'}
|
|
|
|
Prev
|
|
|
|
</Box>
|
|
|
|
</Button>
|
|
|
|
|
|
|
|
<Button size="small" variant="outlined" onClick={() => moveSearch(1)} disabled={searchResult.matches.length === 0}>
|
|
|
|
|
|
|
|
Next
|
|
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
|
|
<FormControlLabel
|
|
|
|
|
|
|
|
control={<Switch checked={regexMode} onChange={(event: ReactChangeEvent<HTMLInputElement>) => setRegexMode(event.target.checked)} />}
|
|
|
|
|
|
|
|
label="Regex"
|
|
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
</Box>
|
|
|
|
|
|
|
|
<FormControlLabel
|
|
|
|
|
|
|
|
control={<Switch checked={cleanMode} onChange={(event: ReactChangeEvent<HTMLInputElement>) => setCleanMode(event.target.checked)} />}
|
|
|
|
|
|
|
|
label={cleanMode ? 'Clean' : 'Raw'}
|
|
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
</Box>
|
|
|
|
|
|
|
|
<Box
|
|
|
|
|
|
|
|
component="pre"
|
|
|
|
|
|
|
|
ref={transcriptRef}
|
|
|
|
|
|
|
|
tabIndex={0}
|
|
|
|
|
|
|
|
sx={{
|
|
|
|
|
|
|
|
m: 0,
|
|
|
|
|
|
|
|
p: 1.5,
|
|
|
|
|
|
|
|
borderRadius: 1,
|
|
|
|
|
|
|
|
bgcolor: 'background.default',
|
|
|
|
|
|
|
|
border: (theme) => `1px solid ${theme.palette.divider}`,
|
|
|
|
|
|
|
|
overflow: 'auto',
|
|
|
|
|
|
|
|
maxHeight: '70vh',
|
|
|
|
|
|
|
|
fontFamily: 'monospace',
|
|
|
|
|
|
|
|
fontSize: 'body2.fontSize',
|
|
|
|
|
|
|
|
whiteSpace: 'pre-wrap',
|
|
|
|
|
|
|
|
wordBreak: 'break-word',
|
|
|
|
|
|
|
|
'&:focus': {
|
|
|
|
|
|
|
|
outline: (theme) => `2px solid ${theme.palette.primary.main}`,
|
|
|
|
|
|
|
|
outlineOffset: 2
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}}
|
|
|
|
|
|
|
|
>
|
|
|
|
|
|
|
|
{displayedText || '(empty transcript)'}
|
|
|
|
|
|
|
|
</Box>
|
|
|
|
|
|
|
|
</>
|
|
|
|
) : null}
|
|
|
|
) : null}
|
|
|
|
</FormDialogContent>
|
|
|
|
</FormDialogContent>
|
|
|
|
<DialogActions>
|
|
|
|
<DialogActions>
|
|
|
|