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
|
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":
|
||||||
|
|||||||
@@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
Reference in New Issue
Block a user