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", "name": "lms",
"version": "1.1.19", "version": "1.1.20",
"description": "Autonomy Studio LMS Electron app", "description": "Autonomy Studio LMS Electron app",
"main": "./out/main/index.js", "main": "./out/main/index.js",
"author": { "author": {
+15 -6
View File
@@ -977,7 +977,7 @@ function buildCustomModuleFiles(input: CustomModuleScaffoldInput): Record<string
" maintainer_email='student@autonomy-studio.local',", " maintainer_email='student@autonomy-studio.local',",
` description='${module.name}',`, ` description='${module.name}',`,
" license='Apache-2.0',", " license='Apache-2.0',",
` entry_points={{'console_scripts': ['${executable} = ${module.packageName}.node:main']}},`, ` entry_points={'console_scripts': ['${executable} = ${module.packageName}.node:main']},`,
')', ')',
'', '',
].join('\n'), ].join('\n'),
@@ -4287,7 +4287,13 @@ function buildNav2DockerBuildScript(): string {
'fi', 'fi',
'', '',
'MODULE_PATH="$MODULE_ROOT/$MODULE_FOLDER"', '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}"', 'BUILD_CONTAINER="nav2-build-${TARGET}"',
'cleanup() {', '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', ' 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 '", " \"$IMAGE\" bash -lc '",
' set -e', ' set -e',
' source /opt/ros/humble/setup.bash', ' 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=()', ' 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', ' 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', ' fi',
" rosdep install --from-paths \"${paths[@]}\" --ignore-src \"${ROSDEP_SKIP_ARGS[@]}\" \\", " rosdep install --from-paths \"${paths[@]}\" --ignore-src \"${ROSDEP_SKIP_ARGS[@]}\" \\",
" --dependency-types buildtool --dependency-types build \\", " --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', ' if [[ "$1" == *lms-nav2-gazebo-slam* && "$1" != *lms-nav2-gazebo-slam3d* ]]; then',
' BUILD_TARGETS=(slam_pkg slam2d_pkg slam3d_pkg)', ' BUILD_TARGETS=(slam_pkg slam2d_pkg slam3d_pkg)',
' fi', ' 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[@]}"', ' --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') ].join('\n')
} }
+5 -1
View File
@@ -2081,10 +2081,12 @@ function LocalPcWorkspaceMockup({
fitTerminal, fitTerminal,
copyTerminalSelection, copyTerminalSelection,
pasteTerminalClipboard, pasteTerminalClipboard,
startTerminalResize startTerminalResize,
stopAllTerminalSessions
} = useWorkspaceTerminal({ } = useWorkspaceTerminal({
moduleName: selectedModule?.name, moduleName: selectedModule?.name,
moduleFolderName: selectedModule?.folderName, moduleFolderName: selectedModule?.folderName,
openModuleNames: moduleTabs.map((module) => module.name),
clonePathPreview, clonePathPreview,
remoteWorkspace, remoteWorkspace,
getRemoteWorkspaceTarget, getRemoteWorkspaceTarget,
@@ -2950,6 +2952,7 @@ function LocalPcWorkspaceMockup({
setPendingEditorCloseRequest({ kind: 'workspace' }) setPendingEditorCloseRequest({ kind: 'workspace' })
return return
} }
stopAllTerminalSessions()
onClose() onClose()
} }
const performPendingEditorCloseRequest = (): void => { const performPendingEditorCloseRequest = (): void => {
@@ -2995,6 +2998,7 @@ function LocalPcWorkspaceMockup({
} }
discardDirtyFiles(Object.keys(dirtyEditorFiles).filter((file) => dirtyEditorFiles[file])) discardDirtyFiles(Object.keys(dirtyEditorFiles).filter((file) => dirtyEditorFiles[file]))
stopAllTerminalSessions()
onClose() onClose()
} }
const revealSearchMatch = (lineNumber: number, query: string): boolean => { const revealSearchMatch = (lineNumber: number, query: string): boolean => {
@@ -10,9 +10,19 @@ type TerminalContextMenu = {
y: number y: number
} }
type ManagedTerminalSession = {
moduleName: string
remoteWorkspace: boolean
attachedTerminal: XTerm | null
suppressedData: string[]
removeDataListener: () => void
removeSuppressListener: () => void
}
type UseWorkspaceTerminalOptions = { type UseWorkspaceTerminalOptions = {
moduleName?: string | null moduleName?: string | null
moduleFolderName?: string | null moduleFolderName?: string | null
openModuleNames: string[]
clonePathPreview: string clonePathPreview: string
remoteWorkspace: boolean remoteWorkspace: boolean
getRemoteWorkspaceTarget: () => RemoteWorkspaceTarget getRemoteWorkspaceTarget: () => RemoteWorkspaceTarget
@@ -32,11 +42,13 @@ type UseWorkspaceTerminalResult = {
copyTerminalSelection: () => void copyTerminalSelection: () => void
pasteTerminalClipboard: () => void pasteTerminalClipboard: () => void
startTerminalResize: (event: React.MouseEvent<HTMLDivElement>) => void startTerminalResize: (event: React.MouseEvent<HTMLDivElement>) => void
stopAllTerminalSessions: () => void
} }
export function useWorkspaceTerminal({ export function useWorkspaceTerminal({
moduleName, moduleName,
moduleFolderName, moduleFolderName,
openModuleNames,
clonePathPreview, clonePathPreview,
remoteWorkspace, remoteWorkspace,
getRemoteWorkspaceTarget, getRemoteWorkspaceTarget,
@@ -52,12 +64,31 @@ export function useWorkspaceTerminal({
const terminalFitRef = useRef<FitAddon | null>(null) const terminalFitRef = useRef<FitAddon | null>(null)
const terminalSessionIdRef = useRef<string | null>(null) const terminalSessionIdRef = useRef<string | null>(null)
const remoteTargetResolverRef = useRef(getRemoteWorkspaceTarget) const remoteTargetResolverRef = useRef(getRemoteWorkspaceTarget)
const terminalSuppressQueueRef = useRef<string[]>([])
const terminalOutputBufferRef = useRef<Record<string, string>>({}) const terminalOutputBufferRef = useRef<Record<string, string>>({})
const terminalSessionsRef = useRef<Map<string, ManagedTerminalSession>>(new Map())
const resetKeyRef = useRef(resetKey)
const [terminalPanelHeight, setTerminalPanelHeight] = useState(235) const [terminalPanelHeight, setTerminalPanelHeight] = useState(235)
remoteTargetResolverRef.current = getRemoteWorkspaceTarget 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 => { const fitTerminal = (): void => {
terminalFitRef.current?.fit() terminalFitRef.current?.fit()
} }
@@ -115,6 +146,21 @@ export function useWorkspaceTerminal({
document.addEventListener('mouseup', onMouseUp) 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(() => { useEffect(() => {
if (!moduleName || !terminalActive || !terminalHostRef.current || terminalRef.current) return if (!moduleName || !terminalActive || !terminalHostRef.current || terminalRef.current) return
const terminalSessionId = getModuleTerminalSessionId(moduleName) const terminalSessionId = getModuleTerminalSessionId(moduleName)
@@ -164,22 +210,36 @@ export function useWorkspaceTerminal({
terminalFitRef.current = fitAddon terminalFitRef.current = fitAddon
const terminalApi = remoteWorkspace ? window.remoteWorkspace.terminal : window.localWorkspace.terminal const terminalApi = remoteWorkspace ? window.remoteWorkspace.terminal : window.localWorkspace.terminal
let terminalSession = terminalSessionsRef.current.get(terminalSessionId)
if (!terminalSession || terminalSession.remoteWorkspace !== remoteWorkspace) {
if (terminalSession) stopTerminalSession(terminalSessionId)
const suppressedData: string[] = []
const removeDataListener = terminalApi.onData((data) => { const removeDataListener = terminalApi.onData((data) => {
const suppressQueue = terminalSuppressQueueRef.current if (suppressedData.length && suppressedData[0] === data) {
if (suppressQueue.length && suppressQueue[0] === data) { suppressedData.shift()
suppressQueue.shift()
return return
} }
const nextData = stripInternalTerminalCommands(data) const nextData = stripInternalTerminalCommands(data)
if (nextData) { if (!nextData) return
terminalOutputBufferRef.current[terminalSessionId] = `${terminalOutputBufferRef.current[terminalSessionId] ?? ''}${nextData}`.slice(-60000) terminalOutputBufferRef.current[terminalSessionId] = `${terminalOutputBufferRef.current[terminalSessionId] ?? ''}${nextData}`.slice(-60000)
term.write(nextData) terminalSessionsRef.current.get(terminalSessionId)?.attachedTerminal?.write(nextData)
}
}, terminalSessionId) }, terminalSessionId)
const removeSuppressListener = terminalApi.onSuppressData((data) => { const removeSuppressListener = terminalApi.onSuppressData((data) => {
terminalSuppressQueueRef.current.push(data) suppressedData.push(data)
}, terminalSessionId) }, terminalSessionId)
terminalSession = {
moduleName,
remoteWorkspace,
attachedTerminal: null,
suppressedData,
removeDataListener,
removeSuppressListener
}
terminalSessionsRef.current.set(terminalSessionId, terminalSession)
}
terminalSession.attachedTerminal = term
const inputDisposable = term.onData((data) => terminalApi.write(data, terminalSessionId)) const inputDisposable = term.onData((data) => terminalApi.write(data, terminalSessionId))
let sizeSyncTimer: number | null = null let sizeSyncTimer: number | null = null
@@ -215,9 +275,10 @@ export function useWorkspaceTerminal({
}) })
}) })
void startTerminal.then((started) => { void startTerminal.then((started) => {
if (disposed || !started) return
fitAddon.fit() fitAddon.fit()
if (!started) return
sizeSyncTimer = window.setTimeout(() => { sizeSyncTimer = window.setTimeout(() => {
if (disposed) return
fitAddon.fit() fitAddon.fit()
if (remoteWorkspace) { if (remoteWorkspace) {
void window.remoteWorkspace.terminal.resize(remoteTargetResolverRef.current(), term.cols, term.rows, terminalSessionId) void window.remoteWorkspace.terminal.resize(remoteTargetResolverRef.current(), term.cols, term.rows, terminalSessionId)
@@ -237,19 +298,14 @@ export function useWorkspaceTerminal({
window.removeEventListener('resize', onResize) window.removeEventListener('resize', onResize)
terminalElement.removeEventListener('contextmenu', handleTerminalContextMenu) terminalElement.removeEventListener('contextmenu', handleTerminalContextMenu)
terminalElement.removeEventListener('mousedown', focusTerminal) terminalElement.removeEventListener('mousedown', focusTerminal)
removeDataListener() const currentSession = terminalSessionsRef.current.get(terminalSessionId)
removeSuppressListener() if (currentSession?.attachedTerminal === term) currentSession.attachedTerminal = null
inputDisposable.dispose() inputDisposable.dispose()
if (sizeSyncTimer !== null) window.clearTimeout(sizeSyncTimer) 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() term.dispose()
terminalRef.current = null terminalRef.current = null
terminalFitRef.current = null terminalFitRef.current = null
terminalSessionIdRef.current = null terminalSessionIdRef.current = null
terminalSuppressQueueRef.current = []
} }
}, [clonePathPreview, dark, moduleFolderName, moduleName, remoteWorkspace, resetKey, terminalActive]) }, [clonePathPreview, dark, moduleFolderName, moduleName, remoteWorkspace, resetKey, terminalActive])
@@ -262,6 +318,7 @@ export function useWorkspaceTerminal({
fitTerminal, fitTerminal,
copyTerminalSelection, copyTerminalSelection,
pasteTerminalClipboard, pasteTerminalClipboard,
startTerminalResize startTerminalResize,
stopAllTerminalSessions
} }
} }