feat: improve session terminals (#185)
This commit is contained in:
@@ -0,0 +1,330 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useLayoutEffect, useState, useCallback, useRef, useMemo } from 'react';
|
||||
import { getSessionStorage } from '@/lib/local-storage';
|
||||
import { useAppStore } from '@/components/state-provider';
|
||||
import { stopProcess } from '@/lib/api';
|
||||
import { stopUserShell, createUserShell } from '@/lib/api/domains/user-shell-api';
|
||||
import { useUserShells } from './use-user-shells';
|
||||
import type { RepositoryScript } from '@/lib/types/http';
|
||||
import type { MouseEvent } from 'react';
|
||||
|
||||
export type TerminalType = 'dev-server' | 'shell' | 'script';
|
||||
|
||||
export type Terminal = {
|
||||
id: string;
|
||||
type: TerminalType;
|
||||
label: string;
|
||||
closable: boolean; // Whether the terminal can be closed (backend is source of truth)
|
||||
};
|
||||
|
||||
interface UseTerminalsOptions {
|
||||
sessionId: string | null;
|
||||
initialTerminals?: Terminal[]; // Initial terminals from SSR
|
||||
}
|
||||
|
||||
interface UseTerminalsReturn {
|
||||
terminals: Terminal[];
|
||||
activeTab: string | undefined;
|
||||
terminalTabValue: string;
|
||||
addTerminal: () => void;
|
||||
removeTerminal: (id: string) => void;
|
||||
handleCloseDevTab: (event: MouseEvent) => Promise<void>;
|
||||
handleCloseTab: (event: MouseEvent, terminalId: string) => void;
|
||||
handleRunCommand: (script: RepositoryScript) => void;
|
||||
isStoppingDev: boolean;
|
||||
devProcessId: string | undefined;
|
||||
devOutput: string;
|
||||
}
|
||||
|
||||
export function useTerminals({ sessionId, initialTerminals }: UseTerminalsOptions): UseTerminalsReturn {
|
||||
// Initialize terminals from SSR props if provided, otherwise empty
|
||||
const [terminals, setTerminals] = useState<Terminal[]>(() => initialTerminals ?? []);
|
||||
const [isStoppingDev, setIsStoppingDev] = useState(false);
|
||||
|
||||
// Track refs
|
||||
const tabRestoredRef = useRef(false);
|
||||
const prevSessionIdRef = useRef(sessionId);
|
||||
// Track if sessionId just changed - used to ignore stale store values during transition
|
||||
const sessionJustChanged = sessionId !== prevSessionIdRef.current;
|
||||
|
||||
// Store selectors
|
||||
const activeTab = useAppStore((state) =>
|
||||
sessionId ? state.rightPanel.activeTabBySessionId[sessionId] : undefined
|
||||
);
|
||||
const setRightPanelActiveTab = useAppStore((state) => state.setRightPanelActiveTab);
|
||||
const devProcessId = useAppStore((state) =>
|
||||
sessionId ? state.processes.devProcessBySessionId[sessionId] : undefined
|
||||
);
|
||||
const devOutput = useAppStore((state) =>
|
||||
devProcessId ? state.processes.outputsByProcessId[devProcessId] ?? '' : ''
|
||||
);
|
||||
const previewOpen = useAppStore((state) =>
|
||||
sessionId ? state.previewPanel.openBySessionId[sessionId] ?? false : false
|
||||
);
|
||||
const setPreviewOpen = useAppStore((state) => state.setPreviewOpen);
|
||||
const setPreviewStage = useAppStore((state) => state.setPreviewStage);
|
||||
|
||||
// Use the user shells hook - this fetches running shells from backend
|
||||
// Backend now returns label and initialCommand for each shell
|
||||
const { shells: userShells, isLoaded: userShellsLoaded } = useUserShells(sessionId);
|
||||
|
||||
// Reset terminals and refs when sessionId changes
|
||||
useEffect(() => {
|
||||
if (prevSessionIdRef.current === sessionId) return;
|
||||
|
||||
prevSessionIdRef.current = sessionId;
|
||||
|
||||
// Reset refs
|
||||
tabRestoredRef.current = false;
|
||||
|
||||
// Clear terminals - will be populated by user shells effect
|
||||
setTerminals([]);
|
||||
|
||||
// Clear active tab from store so sessionStorage restoration can set the correct value
|
||||
// This prevents stale activeTab from store being used when switching back to a session
|
||||
if (sessionId) {
|
||||
setRightPanelActiveTab(sessionId, '');
|
||||
}
|
||||
}, [sessionId, setRightPanelActiveTab]);
|
||||
|
||||
// Populate terminals from user shells hook
|
||||
// Backend is source of truth - always returns at least one "Terminal" entry
|
||||
useEffect(() => {
|
||||
if (!sessionId || !userShellsLoaded) return;
|
||||
|
||||
setTerminals(prev => {
|
||||
// Keep dev-server terminal if it exists
|
||||
const devTerminal = prev.find(t => t.type === 'dev-server');
|
||||
|
||||
// Build terminals from backend data, preserving the order returned by backend
|
||||
// Backend returns them sorted by CreatedAt for stable ordering
|
||||
const userTerminals: Terminal[] = userShells.map((shell) => {
|
||||
const isScript = shell.terminalId.startsWith('script-');
|
||||
return {
|
||||
id: shell.terminalId,
|
||||
type: isScript ? 'script' as const : 'shell' as const,
|
||||
label: shell.label || (isScript ? 'Script' : 'Terminal'),
|
||||
closable: shell.closable,
|
||||
};
|
||||
});
|
||||
|
||||
const result: Terminal[] = [];
|
||||
if (devTerminal) result.push(devTerminal);
|
||||
result.push(...userTerminals);
|
||||
|
||||
return result;
|
||||
});
|
||||
}, [sessionId, userShells, userShellsLoaded]);
|
||||
|
||||
// Sync dev server terminal with preview state
|
||||
useEffect(() => {
|
||||
if (!sessionId) return;
|
||||
|
||||
setTerminals((prev) => {
|
||||
const hasDevTerminal = prev.some((t) => t.type === 'dev-server');
|
||||
|
||||
if (previewOpen && !hasDevTerminal) {
|
||||
return [{ id: 'dev-server', type: 'dev-server' as const, label: 'Dev Server', closable: true }, ...prev];
|
||||
}
|
||||
|
||||
if (!previewOpen && hasDevTerminal) {
|
||||
return prev.filter((t) => t.type !== 'dev-server');
|
||||
}
|
||||
|
||||
return prev;
|
||||
});
|
||||
}, [previewOpen, sessionId]);
|
||||
|
||||
// Sync saved tab to store - useLayoutEffect runs before paint
|
||||
// This ensures the store has the correct value for subsequent interactions
|
||||
useLayoutEffect(() => {
|
||||
// Treat empty string as no active tab (we clear it on session change)
|
||||
const hasActiveTab = activeTab && activeTab !== '';
|
||||
|
||||
if (!sessionId || tabRestoredRef.current || hasActiveTab) {
|
||||
return;
|
||||
}
|
||||
|
||||
const savedTab = getSessionStorage<string | null>(`rightPanel-tab-${sessionId}`, null);
|
||||
if (!savedTab) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Restore if the terminal exists
|
||||
const exists = terminals.some(t => t.id === savedTab);
|
||||
|
||||
if (exists) {
|
||||
setRightPanelActiveTab(sessionId, savedTab);
|
||||
tabRestoredRef.current = true;
|
||||
}
|
||||
}, [sessionId, terminals, activeTab, setRightPanelActiveTab]);
|
||||
|
||||
// Validate active tab exists in terminals
|
||||
// IMPORTANT: Only validate after terminals have been loaded for this session
|
||||
// Skip validation if terminals are empty (still loading) or if tab hasn't been restored yet
|
||||
useEffect(() => {
|
||||
if (!sessionId || !activeTab || activeTab === '') return;
|
||||
|
||||
// Skip validation if terminals haven't been loaded yet (avoid validating against stale data)
|
||||
if (terminals.length === 0) return;
|
||||
|
||||
// Skip validation if we haven't restored from sessionStorage yet
|
||||
if (!tabRestoredRef.current) return;
|
||||
|
||||
const tabExists =
|
||||
activeTab === 'commands' ||
|
||||
terminals.some((t) => t.id === activeTab);
|
||||
|
||||
if (!tabExists) {
|
||||
const fallbackShell = terminals.find((t) => t.type === 'shell');
|
||||
if (fallbackShell) {
|
||||
setRightPanelActiveTab(sessionId, fallbackShell.id);
|
||||
}
|
||||
}
|
||||
}, [activeTab, sessionId, terminals, setRightPanelActiveTab]);
|
||||
|
||||
const addTerminal = useCallback(async () => {
|
||||
if (!sessionId) return;
|
||||
|
||||
try {
|
||||
// Ask backend to create terminal - backend assigns ID, label, and closable
|
||||
const result = await createUserShell(sessionId);
|
||||
|
||||
const newTerminal: Terminal = {
|
||||
id: result.terminalId,
|
||||
type: 'shell',
|
||||
label: result.label,
|
||||
closable: result.closable,
|
||||
};
|
||||
|
||||
setTerminals((prev) => [...prev, newTerminal]);
|
||||
setRightPanelActiveTab(sessionId, result.terminalId);
|
||||
} catch (error) {
|
||||
console.error('Failed to create user shell:', error);
|
||||
}
|
||||
}, [sessionId, setRightPanelActiveTab]);
|
||||
|
||||
const removeTerminal = useCallback(
|
||||
(id: string) => {
|
||||
setTerminals((prev) => {
|
||||
const indexToRemove = prev.findIndex((t) => t.id === id);
|
||||
if (indexToRemove === -1) return prev;
|
||||
|
||||
const terminalToRemove = prev[indexToRemove];
|
||||
|
||||
// Don't allow removing non-closable terminals (backend is source of truth)
|
||||
if (!terminalToRemove.closable) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
const nextTerminals = prev.filter((t) => t.id !== id);
|
||||
|
||||
// If we're closing the active tab, focus the one to the left (or right if leftmost)
|
||||
if (activeTab === id && sessionId) {
|
||||
// Find the terminal to the left, or to the right if there's none on the left
|
||||
const leftIndex = indexToRemove - 1;
|
||||
const newFocusTerminal = leftIndex >= 0 ? prev[leftIndex] : nextTerminals[0];
|
||||
if (newFocusTerminal) {
|
||||
setRightPanelActiveTab(sessionId, newFocusTerminal.id);
|
||||
}
|
||||
}
|
||||
|
||||
return nextTerminals;
|
||||
});
|
||||
},
|
||||
[activeTab, sessionId, setRightPanelActiveTab]
|
||||
);
|
||||
|
||||
const handleCloseDevTab = useCallback(
|
||||
async (event: MouseEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (!sessionId) return;
|
||||
if (devProcessId) {
|
||||
setIsStoppingDev(true);
|
||||
try {
|
||||
await stopProcess(sessionId, { process_id: devProcessId });
|
||||
} finally {
|
||||
setIsStoppingDev(false);
|
||||
}
|
||||
}
|
||||
const fallbackShell = terminals.find((t) => t.type === 'shell');
|
||||
if (fallbackShell) {
|
||||
setRightPanelActiveTab(sessionId, fallbackShell.id);
|
||||
}
|
||||
setPreviewOpen(sessionId, false);
|
||||
setPreviewStage(sessionId, 'closed');
|
||||
},
|
||||
[sessionId, devProcessId, terminals, setRightPanelActiveTab, setPreviewOpen, setPreviewStage]
|
||||
);
|
||||
|
||||
// Handle running a custom command - creates a script terminal via backend
|
||||
// Backend looks up the script by ID, assigns terminal ID, label, and registers it
|
||||
const handleRunCommand = useCallback(async (script: RepositoryScript) => {
|
||||
if (!sessionId) return;
|
||||
|
||||
try {
|
||||
const result = await createUserShell(sessionId, script.id);
|
||||
|
||||
const newTerminal: Terminal = {
|
||||
id: result.terminalId,
|
||||
type: 'script',
|
||||
label: result.label,
|
||||
closable: result.closable,
|
||||
};
|
||||
|
||||
setTerminals(prev => [...prev, newTerminal]);
|
||||
setRightPanelActiveTab(sessionId, result.terminalId);
|
||||
} catch (error) {
|
||||
console.error('Failed to create script terminal:', error);
|
||||
}
|
||||
}, [sessionId, setRightPanelActiveTab]);
|
||||
|
||||
// Handle closing a terminal tab (shell or script) - stops the user shell process
|
||||
const handleCloseTab = useCallback((event: MouseEvent, terminalId: string) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
removeTerminal(terminalId);
|
||||
|
||||
if (sessionId) {
|
||||
stopUserShell(sessionId, terminalId).catch((error) => {
|
||||
console.error('Failed to stop terminal:', error);
|
||||
});
|
||||
}
|
||||
}, [sessionId, removeTerminal]);
|
||||
|
||||
// Read saved tab from sessionStorage synchronously to avoid flicker on initial render
|
||||
const savedTabFromStorage = useMemo(() => {
|
||||
if (!sessionId) return null;
|
||||
return getSessionStorage<string | null>(`rightPanel-tab-${sessionId}`, null);
|
||||
}, [sessionId]);
|
||||
|
||||
// Check if saved tab exists in current terminals
|
||||
const savedTabExists = savedTabFromStorage && terminals.some(t => t.id === savedTabFromStorage);
|
||||
|
||||
// Use saved tab as fallback before store is updated - this prevents flicker
|
||||
// Trust the saved tab if terminals haven't loaded yet (length === 0) or if it exists
|
||||
// Treat empty string activeTab as falsy (we clear it on session change)
|
||||
// IMPORTANT: Ignore store's activeTab if session just changed - it may be stale from before switching away
|
||||
const effectiveActiveTab = (!sessionJustChanged && activeTab && activeTab !== '') ? activeTab : null;
|
||||
const terminalTabValue = effectiveActiveTab
|
||||
?? (savedTabFromStorage && (terminals.length === 0 || savedTabExists) ? savedTabFromStorage : null)
|
||||
?? terminals.find((t) => t.type === 'shell')?.id
|
||||
?? 'commands';
|
||||
|
||||
return {
|
||||
terminals,
|
||||
activeTab,
|
||||
terminalTabValue,
|
||||
addTerminal,
|
||||
removeTerminal,
|
||||
handleCloseDevTab,
|
||||
handleCloseTab,
|
||||
handleRunCommand,
|
||||
isStoppingDev,
|
||||
devProcessId,
|
||||
devOutput,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { getWebSocketClient } from '@/lib/ws/connection';
|
||||
import { useAppStore, useAppStoreApi } from '@/components/state-provider';
|
||||
import type { UserShellInfo } from '@/lib/state/slices';
|
||||
|
||||
interface UseUserShellsReturn {
|
||||
shells: UserShellInfo[];
|
||||
isLoading: boolean;
|
||||
isLoaded: boolean;
|
||||
addShell: (shell: UserShellInfo) => void;
|
||||
removeShell: (terminalId: string) => void;
|
||||
}
|
||||
|
||||
const EMPTY_SHELLS: UserShellInfo[] = [];
|
||||
|
||||
/**
|
||||
* Hook to fetch and manage user shell terminals for a session.
|
||||
*
|
||||
* Follows the data fetching pattern:
|
||||
* 1. Read from store first
|
||||
* 2. Fetch from backend if not loaded
|
||||
* 3. Track loading/loaded state
|
||||
*/
|
||||
export function useUserShells(sessionId: string | null): UseUserShellsReturn {
|
||||
const store = useAppStoreApi();
|
||||
|
||||
// Read from store
|
||||
const shells = useAppStore((state) =>
|
||||
sessionId ? state.userShells.bySessionId[sessionId] ?? EMPTY_SHELLS : EMPTY_SHELLS
|
||||
);
|
||||
const isLoading = useAppStore((state) =>
|
||||
sessionId ? state.userShells.loading[sessionId] ?? false : false
|
||||
);
|
||||
const isLoaded = useAppStore((state) =>
|
||||
sessionId ? state.userShells.loaded[sessionId] ?? false : false
|
||||
);
|
||||
const connectionStatus = useAppStore((state) => state.connection.status);
|
||||
|
||||
// Guard refs to prevent duplicate fetches
|
||||
const lastFetchedSessionIdRef = useRef<string | null>(null);
|
||||
const prevSessionIdRef = useRef<string | null>(null);
|
||||
|
||||
// Reset refs when session clears
|
||||
useEffect(() => {
|
||||
if (!sessionId) {
|
||||
lastFetchedSessionIdRef.current = null;
|
||||
}
|
||||
}, [sessionId]);
|
||||
|
||||
// Fetch user shells from backend
|
||||
useEffect(() => {
|
||||
if (!sessionId) return;
|
||||
if (connectionStatus !== 'connected') return;
|
||||
|
||||
// Detect session change to force refetch
|
||||
const sessionChanged = prevSessionIdRef.current !== null &&
|
||||
prevSessionIdRef.current !== sessionId;
|
||||
prevSessionIdRef.current = sessionId;
|
||||
|
||||
// If session changed, reset fetch guard
|
||||
if (sessionChanged) {
|
||||
lastFetchedSessionIdRef.current = null;
|
||||
}
|
||||
|
||||
// Check if already loaded (unless session just changed)
|
||||
if (isLoaded && !sessionChanged) {
|
||||
lastFetchedSessionIdRef.current = sessionId;
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't fetch again if already fetched for this session
|
||||
if (lastFetchedSessionIdRef.current === sessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchShells = async () => {
|
||||
const client = getWebSocketClient();
|
||||
if (!client) return;
|
||||
|
||||
store.getState().setUserShellsLoading(sessionId, true);
|
||||
|
||||
try {
|
||||
const response = await client.request<{ shells?: Array<{
|
||||
terminal_id: string;
|
||||
process_id: string;
|
||||
running: boolean;
|
||||
label: string;
|
||||
closable: boolean;
|
||||
initial_command?: string;
|
||||
}> }>(
|
||||
'user_shell.list',
|
||||
{ session_id: sessionId },
|
||||
10000
|
||||
);
|
||||
|
||||
// Transform backend format (snake_case) to frontend format (camelCase)
|
||||
const shells: UserShellInfo[] = (response.shells ?? []).map((s) => ({
|
||||
terminalId: s.terminal_id,
|
||||
processId: s.process_id,
|
||||
running: s.running,
|
||||
label: s.label || 'Terminal',
|
||||
closable: s.closable,
|
||||
initialCommand: s.initial_command,
|
||||
}));
|
||||
|
||||
store.getState().setUserShells(sessionId, shells);
|
||||
lastFetchedSessionIdRef.current = sessionId;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch user shells:', error);
|
||||
// Set empty shells on error so we don't keep retrying
|
||||
store.getState().setUserShells(sessionId, []);
|
||||
lastFetchedSessionIdRef.current = sessionId;
|
||||
}
|
||||
};
|
||||
|
||||
fetchShells();
|
||||
}, [sessionId, connectionStatus, isLoaded, store]);
|
||||
|
||||
// Actions
|
||||
const addShell = (shell: UserShellInfo) => {
|
||||
if (sessionId) {
|
||||
store.getState().addUserShell(sessionId, shell);
|
||||
}
|
||||
};
|
||||
|
||||
const removeShell = (terminalId: string) => {
|
||||
if (sessionId) {
|
||||
store.getState().removeUserShell(sessionId, terminalId);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
shells,
|
||||
isLoading,
|
||||
isLoaded,
|
||||
addShell,
|
||||
removeShell,
|
||||
};
|
||||
}
|
||||
@@ -41,7 +41,8 @@ export function useRepositoryScripts(repositoryId: string | null, enabled = true
|
||||
if (cancelled) return;
|
||||
setRepositoryScripts(repositoryId, response.scripts ?? []);
|
||||
})
|
||||
.catch(() => {
|
||||
.catch((error) => {
|
||||
console.error('[useRepositoryScripts] Fetch error:', { repositoryId, error });
|
||||
if (cancelled) return;
|
||||
setRepositoryScripts(repositoryId, []);
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user