feat(workspace): preserve terminal sessions across view changes
This commit is contained in:
+15
-6
@@ -977,7 +977,7 @@ function buildCustomModuleFiles(input: CustomModuleScaffoldInput): Record<string
|
||||
" maintainer_email='student@autonomy-studio.local',",
|
||||
` description='${module.name}',`,
|
||||
" license='Apache-2.0',",
|
||||
` entry_points={{'console_scripts': ['${executable} = ${module.packageName}.node:main']}},`,
|
||||
` entry_points={'console_scripts': ['${executable} = ${module.packageName}.node:main']},`,
|
||||
')',
|
||||
'',
|
||||
].join('\n'),
|
||||
@@ -4287,7 +4287,13 @@ function buildNav2DockerBuildScript(): string {
|
||||
'fi',
|
||||
'',
|
||||
'MODULE_PATH="$MODULE_ROOT/$MODULE_FOLDER"',
|
||||
'test -d "$MODULE_PATH/src" || { echo "Module source is missing: $MODULE_FOLDER" >&2; exit 1; }',
|
||||
'if [[ "$MODULE_FOLDER" == *lms-nav2-custom* ]]; then',
|
||||
' test -f "$MODULE_PATH/package.xml" || { echo "Custom module source is missing: $MODULE_FOLDER" >&2; exit 1; }',
|
||||
' SOURCE_BASE_PATH=.',
|
||||
'else',
|
||||
' test -d "$MODULE_PATH/src" || { echo "Module source is missing: $MODULE_FOLDER" >&2; exit 1; }',
|
||||
' SOURCE_BASE_PATH=src',
|
||||
'fi',
|
||||
'BUILD_CONTAINER="nav2-build-${TARGET}"',
|
||||
'cleanup() {',
|
||||
' docker run --rm -v "$MODULE_ROOT:/modules" "$IMAGE" bash -lc "chown -R $(id -u):$(id -g) \'/modules/$MODULE_FOLDER/build\' \'/modules/$MODULE_FOLDER/install\' \'/modules/$MODULE_FOLDER/log\' 2>/dev/null || true" >/dev/null 2>&1 || true',
|
||||
@@ -4302,10 +4308,13 @@ function buildNav2DockerBuildScript(): string {
|
||||
" \"$IMAGE\" bash -lc '",
|
||||
' set -e',
|
||||
' source /opt/ros/humble/setup.bash',
|
||||
' mapfile -t paths < <(colcon list --base-paths src --paths-only)',
|
||||
' mapfile -t paths < <(colcon list --base-paths "$3" --paths-only)',
|
||||
' ROSDEP_SKIP_ARGS=()',
|
||||
' if [[ "$1" == *lms-nav2-custom* ]]; then',
|
||||
' ROSDEP_SKIP_ARGS+=(--skip-keys ament_python)',
|
||||
' fi',
|
||||
' if ! apt-cache show ros-humble-gazebo-ros >/dev/null 2>&1; then',
|
||||
' ROSDEP_SKIP_ARGS=(--skip-keys gazebo_ros)',
|
||||
' ROSDEP_SKIP_ARGS+=(--skip-keys gazebo_ros)',
|
||||
' fi',
|
||||
" rosdep install --from-paths \"${paths[@]}\" --ignore-src \"${ROSDEP_SKIP_ARGS[@]}\" \\",
|
||||
" --dependency-types buildtool --dependency-types build \\",
|
||||
@@ -4314,9 +4323,9 @@ function buildNav2DockerBuildScript(): string {
|
||||
' if [[ "$1" == *lms-nav2-gazebo-slam* && "$1" != *lms-nav2-gazebo-slam3d* ]]; then',
|
||||
' BUILD_TARGETS=(slam_pkg slam2d_pkg slam3d_pkg)',
|
||||
' fi',
|
||||
" colcon --log-base log build --base-paths src --build-base build --install-base install \\",
|
||||
" colcon --log-base log build --base-paths \"$3\" --build-base build --install-base install \\",
|
||||
' --executor sequential --cmake-args -DBUILD_TESTING=OFF --packages-up-to "${BUILD_TARGETS[@]}"',
|
||||
"' bash \"$MODULE_FOLDER\" \"$TARGET\"",
|
||||
"' bash \"$MODULE_FOLDER\" \"$TARGET\" \"$SOURCE_BASE_PATH\"",
|
||||
''
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
@@ -2081,10 +2081,12 @@ function LocalPcWorkspaceMockup({
|
||||
fitTerminal,
|
||||
copyTerminalSelection,
|
||||
pasteTerminalClipboard,
|
||||
startTerminalResize
|
||||
startTerminalResize,
|
||||
stopAllTerminalSessions
|
||||
} = useWorkspaceTerminal({
|
||||
moduleName: selectedModule?.name,
|
||||
moduleFolderName: selectedModule?.folderName,
|
||||
openModuleNames: moduleTabs.map((module) => module.name),
|
||||
clonePathPreview,
|
||||
remoteWorkspace,
|
||||
getRemoteWorkspaceTarget,
|
||||
@@ -2950,6 +2952,7 @@ function LocalPcWorkspaceMockup({
|
||||
setPendingEditorCloseRequest({ kind: 'workspace' })
|
||||
return
|
||||
}
|
||||
stopAllTerminalSessions()
|
||||
onClose()
|
||||
}
|
||||
const performPendingEditorCloseRequest = (): void => {
|
||||
@@ -2995,6 +2998,7 @@ function LocalPcWorkspaceMockup({
|
||||
}
|
||||
|
||||
discardDirtyFiles(Object.keys(dirtyEditorFiles).filter((file) => dirtyEditorFiles[file]))
|
||||
stopAllTerminalSessions()
|
||||
onClose()
|
||||
}
|
||||
const revealSearchMatch = (lineNumber: number, query: string): boolean => {
|
||||
|
||||
@@ -10,9 +10,19 @@ type TerminalContextMenu = {
|
||||
y: number
|
||||
}
|
||||
|
||||
type ManagedTerminalSession = {
|
||||
moduleName: string
|
||||
remoteWorkspace: boolean
|
||||
attachedTerminal: XTerm | null
|
||||
suppressedData: string[]
|
||||
removeDataListener: () => void
|
||||
removeSuppressListener: () => void
|
||||
}
|
||||
|
||||
type UseWorkspaceTerminalOptions = {
|
||||
moduleName?: string | null
|
||||
moduleFolderName?: string | null
|
||||
openModuleNames: string[]
|
||||
clonePathPreview: string
|
||||
remoteWorkspace: boolean
|
||||
getRemoteWorkspaceTarget: () => RemoteWorkspaceTarget
|
||||
@@ -32,11 +42,13 @@ type UseWorkspaceTerminalResult = {
|
||||
copyTerminalSelection: () => void
|
||||
pasteTerminalClipboard: () => void
|
||||
startTerminalResize: (event: React.MouseEvent<HTMLDivElement>) => void
|
||||
stopAllTerminalSessions: () => void
|
||||
}
|
||||
|
||||
export function useWorkspaceTerminal({
|
||||
moduleName,
|
||||
moduleFolderName,
|
||||
openModuleNames,
|
||||
clonePathPreview,
|
||||
remoteWorkspace,
|
||||
getRemoteWorkspaceTarget,
|
||||
@@ -52,12 +64,31 @@ export function useWorkspaceTerminal({
|
||||
const terminalFitRef = useRef<FitAddon | null>(null)
|
||||
const terminalSessionIdRef = useRef<string | null>(null)
|
||||
const remoteTargetResolverRef = useRef(getRemoteWorkspaceTarget)
|
||||
const terminalSuppressQueueRef = useRef<string[]>([])
|
||||
const terminalOutputBufferRef = useRef<Record<string, string>>({})
|
||||
const terminalSessionsRef = useRef<Map<string, ManagedTerminalSession>>(new Map())
|
||||
const resetKeyRef = useRef(resetKey)
|
||||
const [terminalPanelHeight, setTerminalPanelHeight] = useState(235)
|
||||
|
||||
remoteTargetResolverRef.current = getRemoteWorkspaceTarget
|
||||
|
||||
const stopTerminalSession = (sessionId: string): void => {
|
||||
const session = terminalSessionsRef.current.get(sessionId)
|
||||
if (!session) return
|
||||
|
||||
session.attachedTerminal = null
|
||||
session.removeDataListener()
|
||||
session.removeSuppressListener()
|
||||
terminalSessionsRef.current.delete(sessionId)
|
||||
delete terminalOutputBufferRef.current[sessionId]
|
||||
|
||||
if (session.remoteWorkspace) window.remoteWorkspace.terminal.stop(sessionId)
|
||||
else window.localWorkspace.terminal.stop(sessionId)
|
||||
}
|
||||
|
||||
const stopAllTerminalSessions = (): void => {
|
||||
Array.from(terminalSessionsRef.current.keys()).forEach(stopTerminalSession)
|
||||
}
|
||||
|
||||
const fitTerminal = (): void => {
|
||||
terminalFitRef.current?.fit()
|
||||
}
|
||||
@@ -115,6 +146,21 @@ export function useWorkspaceTerminal({
|
||||
document.addEventListener('mouseup', onMouseUp)
|
||||
}
|
||||
|
||||
const openModuleNamesKey = openModuleNames.join('\u0000')
|
||||
|
||||
useEffect(() => {
|
||||
const openModuleNameSet = new Set(openModuleNames)
|
||||
Array.from(terminalSessionsRef.current.entries()).forEach(([sessionId, session]) => {
|
||||
if (!openModuleNameSet.has(session.moduleName)) stopTerminalSession(sessionId)
|
||||
})
|
||||
}, [openModuleNamesKey])
|
||||
|
||||
useEffect(() => {
|
||||
if (resetKeyRef.current === resetKey) return
|
||||
resetKeyRef.current = resetKey
|
||||
stopAllTerminalSessions()
|
||||
}, [resetKey])
|
||||
|
||||
useEffect(() => {
|
||||
if (!moduleName || !terminalActive || !terminalHostRef.current || terminalRef.current) return
|
||||
const terminalSessionId = getModuleTerminalSessionId(moduleName)
|
||||
@@ -164,22 +210,36 @@ export function useWorkspaceTerminal({
|
||||
terminalFitRef.current = fitAddon
|
||||
|
||||
const terminalApi = remoteWorkspace ? window.remoteWorkspace.terminal : window.localWorkspace.terminal
|
||||
const removeDataListener = terminalApi.onData((data) => {
|
||||
const suppressQueue = terminalSuppressQueueRef.current
|
||||
if (suppressQueue.length && suppressQueue[0] === data) {
|
||||
suppressQueue.shift()
|
||||
return
|
||||
}
|
||||
let terminalSession = terminalSessionsRef.current.get(terminalSessionId)
|
||||
if (!terminalSession || terminalSession.remoteWorkspace !== remoteWorkspace) {
|
||||
if (terminalSession) stopTerminalSession(terminalSessionId)
|
||||
|
||||
const nextData = stripInternalTerminalCommands(data)
|
||||
if (nextData) {
|
||||
const suppressedData: string[] = []
|
||||
const removeDataListener = terminalApi.onData((data) => {
|
||||
if (suppressedData.length && suppressedData[0] === data) {
|
||||
suppressedData.shift()
|
||||
return
|
||||
}
|
||||
|
||||
const nextData = stripInternalTerminalCommands(data)
|
||||
if (!nextData) return
|
||||
terminalOutputBufferRef.current[terminalSessionId] = `${terminalOutputBufferRef.current[terminalSessionId] ?? ''}${nextData}`.slice(-60000)
|
||||
term.write(nextData)
|
||||
terminalSessionsRef.current.get(terminalSessionId)?.attachedTerminal?.write(nextData)
|
||||
}, terminalSessionId)
|
||||
const removeSuppressListener = terminalApi.onSuppressData((data) => {
|
||||
suppressedData.push(data)
|
||||
}, terminalSessionId)
|
||||
terminalSession = {
|
||||
moduleName,
|
||||
remoteWorkspace,
|
||||
attachedTerminal: null,
|
||||
suppressedData,
|
||||
removeDataListener,
|
||||
removeSuppressListener
|
||||
}
|
||||
}, terminalSessionId)
|
||||
const removeSuppressListener = terminalApi.onSuppressData((data) => {
|
||||
terminalSuppressQueueRef.current.push(data)
|
||||
}, terminalSessionId)
|
||||
terminalSessionsRef.current.set(terminalSessionId, terminalSession)
|
||||
}
|
||||
terminalSession.attachedTerminal = term
|
||||
const inputDisposable = term.onData((data) => terminalApi.write(data, terminalSessionId))
|
||||
|
||||
let sizeSyncTimer: number | null = null
|
||||
@@ -215,9 +275,10 @@ export function useWorkspaceTerminal({
|
||||
})
|
||||
})
|
||||
void startTerminal.then((started) => {
|
||||
if (disposed || !started) return
|
||||
fitAddon.fit()
|
||||
if (!started) return
|
||||
sizeSyncTimer = window.setTimeout(() => {
|
||||
if (disposed) return
|
||||
fitAddon.fit()
|
||||
if (remoteWorkspace) {
|
||||
void window.remoteWorkspace.terminal.resize(remoteTargetResolverRef.current(), term.cols, term.rows, terminalSessionId)
|
||||
@@ -237,19 +298,14 @@ export function useWorkspaceTerminal({
|
||||
window.removeEventListener('resize', onResize)
|
||||
terminalElement.removeEventListener('contextmenu', handleTerminalContextMenu)
|
||||
terminalElement.removeEventListener('mousedown', focusTerminal)
|
||||
removeDataListener()
|
||||
removeSuppressListener()
|
||||
const currentSession = terminalSessionsRef.current.get(terminalSessionId)
|
||||
if (currentSession?.attachedTerminal === term) currentSession.attachedTerminal = null
|
||||
inputDisposable.dispose()
|
||||
if (sizeSyncTimer !== null) window.clearTimeout(sizeSyncTimer)
|
||||
// Full-screen programs such as vi cannot be restored from a partial raw output buffer.
|
||||
delete terminalOutputBufferRef.current[terminalSessionId]
|
||||
if (remoteWorkspace) window.remoteWorkspace.terminal.stop(terminalSessionId)
|
||||
else window.localWorkspace.terminal.stop(terminalSessionId)
|
||||
term.dispose()
|
||||
terminalRef.current = null
|
||||
terminalFitRef.current = null
|
||||
terminalSessionIdRef.current = null
|
||||
terminalSuppressQueueRef.current = []
|
||||
}
|
||||
}, [clonePathPreview, dark, moduleFolderName, moduleName, remoteWorkspace, resetKey, terminalActive])
|
||||
|
||||
@@ -262,6 +318,7 @@ export function useWorkspaceTerminal({
|
||||
fitTerminal,
|
||||
copyTerminalSelection,
|
||||
pasteTerminalClipboard,
|
||||
startTerminalResize
|
||||
startTerminalResize,
|
||||
stopAllTerminalSessions
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user