Compare commits
3 Commits
95ff68ebec
...
487eb57e28
| Author | SHA1 | Date | |
|---|---|---|---|
| 487eb57e28 | |||
| 50bbd25c3e | |||
| 402916e37a |
+60
-16
@@ -26,6 +26,14 @@ func Open(driver, dsn string) (*Store, error) {
|
||||
var err error
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
goto oops
|
||||
@@ -41,22 +49,6 @@ func Open(driver, dsn string) (*Store, error) {
|
||||
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
|
||||
|
||||
oops:
|
||||
@@ -66,6 +58,58 @@ oops:
|
||||
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 {
|
||||
switch strings.ToLower(strings.TrimSpace(driver)) {
|
||||
case "sqlite", "sqlite3":
|
||||
|
||||
@@ -15,6 +15,8 @@ import Typography from '@mui/material/Typography'
|
||||
import useMediaQuery from '@mui/material/useMediaQuery'
|
||||
import { useTheme } from '@mui/material/styles'
|
||||
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 { useBlocker, useLocation, useNavigate, useParams } from 'react-router-dom'
|
||||
import { FitAddon, ITerminalDimensions } from '@xterm/addon-fit'
|
||||
@@ -70,6 +72,9 @@ type SessionPanelProps = {
|
||||
onReplace: (sessionID: string, nextSessionID: string) => void
|
||||
onSummaryChange: (sessionID: string, summary: SessionSummary) => void
|
||||
onCheckCanAddPanel: () => boolean
|
||||
isSynced: boolean
|
||||
syncedSessionIDs: string[]
|
||||
onToggleSync: (sessionID: string) => void
|
||||
}
|
||||
|
||||
type TerminalInfo = {
|
||||
@@ -178,7 +183,7 @@ function isSSHSessionStreamActive(status: string): boolean {
|
||||
}
|
||||
|
||||
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 [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
@@ -199,6 +204,7 @@ function SessionTerminalPanel(props: SessionPanelProps) {
|
||||
const fitAddonRef = useRef<FitAddon | null>(null)
|
||||
const unicodeAddonRef = useRef<Unicode11Addon | null>(null)
|
||||
const streamActiveRef: MutableRefObject<boolean> = useRef<boolean>(true)
|
||||
const syncedSessionIDsRef = useRef<string[]>(syncedSessionIDs)
|
||||
const sessionReady = Boolean(session)
|
||||
const sessionTitle = useMemo(() => buildSessionTitle(session), [session])
|
||||
const canReconnect = status === 'closed' || status === 'error'
|
||||
@@ -267,6 +273,19 @@ function SessionTerminalPanel(props: SessionPanelProps) {
|
||||
onSummaryChange(sessionID, { title: 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(() => {
|
||||
let animationFrameID: number
|
||||
let settleTimerOne: number
|
||||
@@ -358,6 +377,9 @@ function SessionTerminalPanel(props: SessionPanelProps) {
|
||||
term.onData((data: string) => {
|
||||
if (!streamActiveRef.current) { return }
|
||||
sendStreamInput(sessionID, data)
|
||||
syncedSessionIDsRef.current.forEach((id: string) => {
|
||||
if (id !== sessionID) sendStreamInput(id, data)
|
||||
})
|
||||
})
|
||||
|
||||
registerStream(sessionID, {
|
||||
@@ -637,7 +659,7 @@ function SessionTerminalPanel(props: SessionPanelProps) {
|
||||
gap: 0.75,
|
||||
overflow: 'hidden',
|
||||
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',
|
||||
transition: 'outline-color 0.15s ease'
|
||||
}}
|
||||
@@ -671,6 +693,15 @@ function SessionTerminalPanel(props: SessionPanelProps) {
|
||||
{actionPending ? (canReconnect ? 'Connecting...' : 'Disconnecting...') : sessionConnectOrDisconnectLabel}
|
||||
</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}>
|
||||
<MoreVertIcon fontSize="small" />
|
||||
</IconButton>
|
||||
@@ -820,6 +851,7 @@ export default function SSHSessionPage() {
|
||||
const [newSessionServersLoading, setNewSessionServersLoading] = useState(false)
|
||||
const [newSessionError, setNewSessionError] = useState<string | null>(null)
|
||||
const [creatingSession, setCreatingSession] = useState(false)
|
||||
const [syncedSessionIDs, setSyncedSessionIDs] = useState<Set<string>>(new Set())
|
||||
const [splitRatio, setSplitRatio] = useState(0.5)
|
||||
const [splitDragging, setSplitDragging] = useState(false)
|
||||
const [rowWeights, setRowWeights] = useState<number[]>([1])
|
||||
@@ -830,6 +862,8 @@ export default function SSHSessionPage() {
|
||||
const [streamWSCheck, setStreamWSCheck] = useState<number>(0)
|
||||
const streamHandlersRef = useRef<Record<string, SessionStreamHandlers>>({})
|
||||
const attachedSessionIDsRef = useRef<Record<string, boolean>>({})
|
||||
const wsReconnectAttemptRef = useRef<number>(0)
|
||||
const wsReconnectTimerRef = useRef<number>(0)
|
||||
const stackedLayout = useMediaQuery(theme.breakpoints.down('lg'))
|
||||
const historyDialogMode: boolean = useMediaQuery(theme.breakpoints.down('md'))
|
||||
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) => {
|
||||
setOpenSessionIDs((prev) => prev.filter((item) => item !== id))
|
||||
setSessionSummaries((prev) => {
|
||||
@@ -1044,6 +1103,12 @@ export default function SSHSessionPage() {
|
||||
delete next[id]
|
||||
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) => {
|
||||
@@ -1280,6 +1345,7 @@ export default function SSHSessionPage() {
|
||||
streamWSRef.current = ws
|
||||
ws.onopen = () => {
|
||||
let i: number
|
||||
wsReconnectAttemptRef.current = 0
|
||||
attachedSessionIDsRef.current = {}
|
||||
for (i = 0; i < openSessionIDs.length; i += 1) {
|
||||
attachStreamSession(openSessionIDs[i])
|
||||
@@ -1320,6 +1386,12 @@ export default function SSHSessionPage() {
|
||||
handlers = streamHandlersRef.current[ids[i]]
|
||||
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 = () => {
|
||||
setPageError((prev) => prev || 'SSH workspace stream error')
|
||||
@@ -1329,6 +1401,10 @@ export default function SSHSessionPage() {
|
||||
useEffect(() => {
|
||||
// close the workspace websocket upon unmount
|
||||
return () => {
|
||||
if (wsReconnectTimerRef.current) {
|
||||
window.clearTimeout(wsReconnectTimerRef.current)
|
||||
wsReconnectTimerRef.current = 0
|
||||
}
|
||||
if (streamWSRef.current) {
|
||||
streamWSRef.current.close()
|
||||
streamWSRef.current = null
|
||||
@@ -1987,6 +2063,9 @@ export default function SSHSessionPage() {
|
||||
onReplace={handleReplaceSession}
|
||||
onSummaryChange={handleSummaryChange}
|
||||
onCheckCanAddPanel={wouldExceedMinHeight}
|
||||
isSynced={syncedSessionIDs.has(id)}
|
||||
syncedSessionIDs={syncedSessionIDsArray}
|
||||
onToggleSync={handleToggleSync}
|
||||
/>
|
||||
</Box>
|
||||
))}
|
||||
@@ -2077,6 +2156,9 @@ export default function SSHSessionPage() {
|
||||
onReplace={handleReplaceSession}
|
||||
onSummaryChange={handleSummaryChange}
|
||||
onCheckCanAddPanel={wouldExceedMinHeight}
|
||||
isSynced={syncedSessionIDs.has(id)}
|
||||
syncedSessionIDs={syncedSessionIDsArray}
|
||||
onToggleSync={handleToggleSync}
|
||||
/>
|
||||
</Box>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user