feat(workspace): preserve terminal sessions across view changes

This commit is contained in:
2026-09-04 14:54:05 +09:00
parent cb1106f319
commit 01017f03fe
4 changed files with 101 additions and 31 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "lms",
"version": "1.1.19",
"version": "1.1.20",
"description": "Autonomy Studio LMS Electron app",
"main": "./out/main/index.js",
"author": {
+15 -6
View File
@@ -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')
}
+5 -1
View File
@@ -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
}
}