Compare commits

...

3 Commits

2 changed files with 144 additions and 18 deletions
+60 -16
View File
@@ -26,6 +26,14 @@ func Open(driver, dsn string) (*Store, error) {
var err error var err error
drv = driverName(driver) drv = driverName(driver)
if drv == "sqlite" {
dsn = appendSQLitePragmas(dsn, map[string]string{
"busy_timeout": "10000",
"journal_mode": "WAL",
"foreign_keys": "1",
})
}
fmt.Printf("CONNECTING TO [[[%s]]]\n", dsn)
db, err = sql.Open(drv, dsn) db, err = sql.Open(drv, dsn)
if err != nil { if err != nil {
goto oops goto oops
@@ -41,22 +49,6 @@ func Open(driver, dsn string) (*Store, error) {
goto oops goto oops
} }
if drv == "sqlite" {
_, err = db.Exec(`PRAGMA busy_timeout = 10000`)
if err != nil {
goto oops
}
_, err = db.Exec(`PRAGMA journal_mode = WAL`)
if err != nil {
goto oops
}
_, err = db.Exec(`PRAGMA foreign_keys = 1`)
if err != nil {
goto oops
}
}
return &Store{DB: db}, nil return &Store{DB: db}, nil
oops: oops:
@@ -66,6 +58,58 @@ oops:
return nil, err return nil, err
} }
// appendSQLitePragmas appends _pragma=name(value) URI parameters to the DSN
// for any pragma not already present. A plain file path is promoted to a
// file: URI so it can carry query parameters.
func appendSQLitePragmas(dsn string, pragmas map[string]string) string {
var base string
var existing string
var sep string
var idx int
var name string
var value string
var result string
if strings.HasPrefix(dsn, "file:") {
idx = strings.Index(dsn, "?")
if idx < 0 {
base = dsn
existing = ""
} else {
base = dsn[:idx]
existing = dsn[idx+1:]
}
} else if strings.Contains(dsn, "?") {
idx = strings.Index(dsn, "?")
base = dsn[:idx]
existing = dsn[idx+1:]
} else if dsn != ":memory:" {
base = "file:" + dsn
existing = ""
} else {
base = dsn
existing = ""
}
result = base
if existing != "" {
result = base + "?" + existing
sep = "&"
} else {
sep = "?"
}
for name, value = range pragmas {
// [NOTE] this existence check isn't accurate but is practically good
if strings.Contains(existing, "_pragma="+name+"(") {
continue
}
result = result + sep + "_pragma=" + name + "(" + value + ")"
sep = "&"
}
return result
}
func driverName(driver string) string { func driverName(driver string) string {
switch strings.ToLower(strings.TrimSpace(driver)) { switch strings.ToLower(strings.TrimSpace(driver)) {
case "sqlite", "sqlite3": case "sqlite", "sqlite3":
+84 -2
View File
@@ -15,6 +15,8 @@ import Typography from '@mui/material/Typography'
import useMediaQuery from '@mui/material/useMediaQuery' import useMediaQuery from '@mui/material/useMediaQuery'
import { useTheme } from '@mui/material/styles' import { useTheme } from '@mui/material/styles'
import MoreVertIcon from '@mui/icons-material/MoreVert' import MoreVertIcon from '@mui/icons-material/MoreVert'
import SyncAltIcon from '@mui/icons-material/SyncAlt'
import Tooltip from '@mui/material/Tooltip'
import { MutableRefObject, PointerEvent as ReactPointerEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { MutableRefObject, PointerEvent as ReactPointerEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useBlocker, useLocation, useNavigate, useParams } from 'react-router-dom' import { useBlocker, useLocation, useNavigate, useParams } from 'react-router-dom'
import { FitAddon, ITerminalDimensions } from '@xterm/addon-fit' import { FitAddon, ITerminalDimensions } from '@xterm/addon-fit'
@@ -70,6 +72,9 @@ type SessionPanelProps = {
onReplace: (sessionID: string, nextSessionID: string) => void onReplace: (sessionID: string, nextSessionID: string) => void
onSummaryChange: (sessionID: string, summary: SessionSummary) => void onSummaryChange: (sessionID: string, summary: SessionSummary) => void
onCheckCanAddPanel: () => boolean onCheckCanAddPanel: () => boolean
isSynced: boolean
syncedSessionIDs: string[]
onToggleSync: (sessionID: string) => void
} }
type TerminalInfo = { type TerminalInfo = {
@@ -178,7 +183,7 @@ function isSSHSessionStreamActive(status: string): boolean {
} }
function SessionTerminalPanel(props: SessionPanelProps) { function SessionTerminalPanel(props: SessionPanelProps) {
const { sessionID, openSessionIDs, layoutToken, registerStream, unregisterStream, sendStreamInput, sendStreamResize, onDuplicate, onClose, onReplace, onSummaryChange, onCheckCanAddPanel } = props const { sessionID, openSessionIDs, layoutToken, registerStream, unregisterStream, sendStreamInput, sendStreamResize, onDuplicate, onClose, onReplace, onSummaryChange, onCheckCanAddPanel, isSynced, syncedSessionIDs, onToggleSync } = props
const [session, setSession] = useState<SSHSession | null>(null) const [session, setSession] = useState<SSHSession | null>(null)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
@@ -199,6 +204,7 @@ function SessionTerminalPanel(props: SessionPanelProps) {
const fitAddonRef = useRef<FitAddon | null>(null) const fitAddonRef = useRef<FitAddon | null>(null)
const unicodeAddonRef = useRef<Unicode11Addon | null>(null) const unicodeAddonRef = useRef<Unicode11Addon | null>(null)
const streamActiveRef: MutableRefObject<boolean> = useRef<boolean>(true) const streamActiveRef: MutableRefObject<boolean> = useRef<boolean>(true)
const syncedSessionIDsRef = useRef<string[]>(syncedSessionIDs)
const sessionReady = Boolean(session) const sessionReady = Boolean(session)
const sessionTitle = useMemo(() => buildSessionTitle(session), [session]) const sessionTitle = useMemo(() => buildSessionTitle(session), [session])
const canReconnect = status === 'closed' || status === 'error' const canReconnect = status === 'closed' || status === 'error'
@@ -267,6 +273,19 @@ function SessionTerminalPanel(props: SessionPanelProps) {
onSummaryChange(sessionID, { title: sessionTitle, status }) onSummaryChange(sessionID, { title: sessionTitle, status })
}, [onSummaryChange, sessionID, sessionTitle, status]) }, [onSummaryChange, sessionID, sessionTitle, status])
useEffect(() => {
syncedSessionIDsRef.current = syncedSessionIDs
}, [syncedSessionIDs])
useEffect(() => {
if (status !== 'pending' && status !== 'connecting') { return }
const timer = window.setTimeout(() => {
setError('Connection timed out. Click Connect to try again.')
setStatus('error')
}, 30000)
return () => { window.clearTimeout(timer) }
}, [status, sessionID])
useEffect(() => { useEffect(() => {
let animationFrameID: number let animationFrameID: number
let settleTimerOne: number let settleTimerOne: number
@@ -358,6 +377,9 @@ function SessionTerminalPanel(props: SessionPanelProps) {
term.onData((data: string) => { term.onData((data: string) => {
if (!streamActiveRef.current) { return } if (!streamActiveRef.current) { return }
sendStreamInput(sessionID, data) sendStreamInput(sessionID, data)
syncedSessionIDsRef.current.forEach((id: string) => {
if (id !== sessionID) sendStreamInput(id, data)
})
}) })
registerStream(sessionID, { registerStream(sessionID, {
@@ -637,7 +659,7 @@ function SessionTerminalPanel(props: SessionPanelProps) {
gap: 0.75, gap: 0.75,
overflow: 'hidden', overflow: 'hidden',
minWidth: 0, minWidth: 0,
outline: (terminalFocused && openSessionIDs.length > 1) ? (theme) => `3px solid ${theme.palette.primary.main}` : '3px solid transparent', outline: (openSessionIDs.length > 1 && isSynced) ? (theme) => `3px solid ${theme.palette.warning.main}` : (openSessionIDs.length > 1 && terminalFocused) ? (theme) => `3px solid ${theme.palette.primary.main}` : '3px solid transparent',
outlineOffset: '-3px', outlineOffset: '-3px',
transition: 'outline-color 0.15s ease' transition: 'outline-color 0.15s ease'
}} }}
@@ -671,6 +693,15 @@ function SessionTerminalPanel(props: SessionPanelProps) {
{actionPending ? (canReconnect ? 'Connecting...' : 'Disconnecting...') : sessionConnectOrDisconnectLabel} {actionPending ? (canReconnect ? 'Connecting...' : 'Disconnecting...') : sessionConnectOrDisconnectLabel}
</Button> </Button>
{openSessionIDs.length > 1 ? (
<Tooltip title={isSynced ? 'Leave sync group' : 'Join sync group — share keystrokes with other synced terminals'}>
<span>
<IconButton size="small" onClick={() => onToggleSync(sessionID)} color={isSynced ? 'warning' : 'default'} disabled={!isSynced && status !== 'connected'}>
<SyncAltIcon fontSize="small" />
</IconButton>
</span>
</Tooltip>
) : null}
<IconButton size="small" onClick={handleOpenActionMenu}> <IconButton size="small" onClick={handleOpenActionMenu}>
<MoreVertIcon fontSize="small" /> <MoreVertIcon fontSize="small" />
</IconButton> </IconButton>
@@ -820,6 +851,7 @@ export default function SSHSessionPage() {
const [newSessionServersLoading, setNewSessionServersLoading] = useState(false) const [newSessionServersLoading, setNewSessionServersLoading] = useState(false)
const [newSessionError, setNewSessionError] = useState<string | null>(null) const [newSessionError, setNewSessionError] = useState<string | null>(null)
const [creatingSession, setCreatingSession] = useState(false) const [creatingSession, setCreatingSession] = useState(false)
const [syncedSessionIDs, setSyncedSessionIDs] = useState<Set<string>>(new Set())
const [splitRatio, setSplitRatio] = useState(0.5) const [splitRatio, setSplitRatio] = useState(0.5)
const [splitDragging, setSplitDragging] = useState(false) const [splitDragging, setSplitDragging] = useState(false)
const [rowWeights, setRowWeights] = useState<number[]>([1]) const [rowWeights, setRowWeights] = useState<number[]>([1])
@@ -830,6 +862,8 @@ export default function SSHSessionPage() {
const [streamWSCheck, setStreamWSCheck] = useState<number>(0) const [streamWSCheck, setStreamWSCheck] = useState<number>(0)
const streamHandlersRef = useRef<Record<string, SessionStreamHandlers>>({}) const streamHandlersRef = useRef<Record<string, SessionStreamHandlers>>({})
const attachedSessionIDsRef = useRef<Record<string, boolean>>({}) const attachedSessionIDsRef = useRef<Record<string, boolean>>({})
const wsReconnectAttemptRef = useRef<number>(0)
const wsReconnectTimerRef = useRef<number>(0)
const stackedLayout = useMediaQuery(theme.breakpoints.down('lg')) const stackedLayout = useMediaQuery(theme.breakpoints.down('lg'))
const historyDialogMode: boolean = useMediaQuery(theme.breakpoints.down('md')) const historyDialogMode: boolean = useMediaQuery(theme.breakpoints.down('md'))
const rowCount = useMemo(() => { const rowCount = useMemo(() => {
@@ -1037,6 +1071,31 @@ export default function SSHSessionPage() {
}) })
}, []) }, [])
const handleToggleSync = useCallback((id: string) => {
setSyncedSessionIDs((prev) => {
const next = new Set(prev)
if (next.has(id)) { next.delete(id) } else { next.add(id) }
return next
})
}, [])
const syncedSessionIDsArray = useMemo(() => [...syncedSessionIDs].filter((id) => sessionSummaries[id]?.status === 'connected'), [syncedSessionIDs, sessionSummaries])
useEffect(() => {
setSyncedSessionIDs((prev) => {
if (prev.size === 0) { return prev }
var next = new Set(prev)
var changed = false
prev.forEach((id) => {
if (sessionSummaries[id]?.status !== 'connected') {
next.delete(id)
changed = true
}
})
return changed ? next : prev
})
}, [sessionSummaries])
const handleCloseSession = useCallback((id: string) => { const handleCloseSession = useCallback((id: string) => {
setOpenSessionIDs((prev) => prev.filter((item) => item !== id)) setOpenSessionIDs((prev) => prev.filter((item) => item !== id))
setSessionSummaries((prev) => { setSessionSummaries((prev) => {
@@ -1044,6 +1103,12 @@ export default function SSHSessionPage() {
delete next[id] delete next[id]
return next return next
}) })
setSyncedSessionIDs((prev) => {
if (!prev.has(id)) { return prev }
const next = new Set(prev)
next.delete(id)
return next
})
}, []) }, [])
const handleReplaceSession = useCallback((currentID: string, nextID: string) => { const handleReplaceSession = useCallback((currentID: string, nextID: string) => {
@@ -1280,6 +1345,7 @@ export default function SSHSessionPage() {
streamWSRef.current = ws streamWSRef.current = ws
ws.onopen = () => { ws.onopen = () => {
let i: number let i: number
wsReconnectAttemptRef.current = 0
attachedSessionIDsRef.current = {} attachedSessionIDsRef.current = {}
for (i = 0; i < openSessionIDs.length; i += 1) { for (i = 0; i < openSessionIDs.length; i += 1) {
attachStreamSession(openSessionIDs[i]) attachStreamSession(openSessionIDs[i])
@@ -1320,6 +1386,12 @@ export default function SSHSessionPage() {
handlers = streamHandlersRef.current[ids[i]] handlers = streamHandlersRef.current[ids[i]]
if (handlers) handlers.onTransportClosed('SSH workspace stream closed') if (handlers) handlers.onTransportClosed('SSH workspace stream closed')
} }
// schedule reconnect with exponential backoff; immediate on first attempt
const delay: number = Math.min(Math.pow(2, wsReconnectAttemptRef.current + 1) * 1000, 30000)
wsReconnectAttemptRef.current += 1
wsReconnectTimerRef.current = window.setTimeout(() => {
setStreamWSCheck((prev) => prev + 1)
}, delay)
} }
ws.onerror = () => { ws.onerror = () => {
setPageError((prev) => prev || 'SSH workspace stream error') setPageError((prev) => prev || 'SSH workspace stream error')
@@ -1329,6 +1401,10 @@ export default function SSHSessionPage() {
useEffect(() => { useEffect(() => {
// close the workspace websocket upon unmount // close the workspace websocket upon unmount
return () => { return () => {
if (wsReconnectTimerRef.current) {
window.clearTimeout(wsReconnectTimerRef.current)
wsReconnectTimerRef.current = 0
}
if (streamWSRef.current) { if (streamWSRef.current) {
streamWSRef.current.close() streamWSRef.current.close()
streamWSRef.current = null streamWSRef.current = null
@@ -1987,6 +2063,9 @@ export default function SSHSessionPage() {
onReplace={handleReplaceSession} onReplace={handleReplaceSession}
onSummaryChange={handleSummaryChange} onSummaryChange={handleSummaryChange}
onCheckCanAddPanel={wouldExceedMinHeight} onCheckCanAddPanel={wouldExceedMinHeight}
isSynced={syncedSessionIDs.has(id)}
syncedSessionIDs={syncedSessionIDsArray}
onToggleSync={handleToggleSync}
/> />
</Box> </Box>
))} ))}
@@ -2077,6 +2156,9 @@ export default function SSHSessionPage() {
onReplace={handleReplaceSession} onReplace={handleReplaceSession}
onSummaryChange={handleSummaryChange} onSummaryChange={handleSummaryChange}
onCheckCanAddPanel={wouldExceedMinHeight} onCheckCanAddPanel={wouldExceedMinHeight}
isSynced={syncedSessionIDs.has(id)}
syncedSessionIDs={syncedSessionIDsArray}
onToggleSync={handleToggleSync}
/> />
</Box> </Box>
))} ))}