Compare commits

...

3 Commits

2 changed files with 208 additions and 18 deletions
+192 -3
View File
@@ -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,8 +184,41 @@ export default function SSHTranscriptDialog(props: SSHTranscriptDialogProps) {
</Typography> </Typography>
) : null} ) : null}
{!props.loading && !props.error ? ( {!props.loading && !props.error ? (
<>
<Box sx={{ display: 'flex', justifyContent: 'space-between', gap: 1, alignItems: 'center', flexWrap: 'wrap' }}>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center', flexWrap: 'wrap', flex: '1 1 420px' }}>
<TextField
size="small"
label="Search"
value={searchText}
onChange={(event: ReactChangeEvent<HTMLInputElement>) => setSearchText(event.target.value)}
onKeyDown={handleSearchKeyDown}
error={Boolean(searchResult.error)}
sx={{ minWidth: 260, flex: '1 1 260px' }}
/>
<Typography>
{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}>
Prev
</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 <Box
component="pre" component="pre"
ref={transcriptRef}
tabIndex={0}
sx={{ sx={{
m: 0, m: 0,
p: 1.5, p: 1.5,
@@ -44,13 +228,18 @@ export default function SSHTranscriptDialog(props: SSHTranscriptDialogProps) {
overflow: 'auto', overflow: 'auto',
maxHeight: '70vh', maxHeight: '70vh',
fontFamily: 'monospace', fontFamily: 'monospace',
fontSize: 13, fontSize: 'body2.fontSize',
whiteSpace: 'pre-wrap', whiteSpace: 'pre-wrap',
wordBreak: 'break-word' wordBreak: 'break-word',
'&:focus': {
outline: (theme) => `2px solid ${theme.palette.primary.main}`,
outlineOffset: 2
}
}} }}
> >
{props.text || '(empty transcript)'} {displayedText || '(empty transcript)'}
</Box> </Box>
</>
) : null} ) : null}
</FormDialogContent> </FormDialogContent>
<DialogActions> <DialogActions>
+1
View File
@@ -3,6 +3,7 @@ set -eu
IMAGE="${CODIT_DEB_IMAGE:-debian:stable}" IMAGE="${CODIT_DEB_IMAGE:-debian:stable}"
##DOCKER_RUN_XARGS="--platform linux/386" ##DOCKER_RUN_XARGS="--platform linux/386"
DOCKER_RUN_XARGS="${CODIT_DOCKER_RUN_XARGS:-}"
ROOT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)" ROOT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)"
PKG_DIR="${ROOT_DIR}/packaging" PKG_DIR="${ROOT_DIR}/packaging"