diff --git a/app/shared/utils/file-watcher.ts b/app/shared/utils/file-watcher.ts deleted file mode 100644 index 6917efb3..00000000 --- a/app/shared/utils/file-watcher.ts +++ /dev/null @@ -1,229 +0,0 @@ -import type { WebContainer } from '@webcontainer/api'; -import { WORK_DIR } from './constants'; - -// Global object to track watcher state -const watcherState = { - fallbackEnabled: tryLoadFallbackState(), - watchingPaths: new Set(), - callbacks: new Map void>>(), - pollingInterval: null as NodeJS.Timeout | null, -}; - -// Try to load the fallback state from localStorage -function tryLoadFallbackState(): boolean { - try { - if (typeof localStorage !== 'undefined') { - const state = localStorage.getItem('bolt-file-watcher-fallback'); - return state === 'true'; - } - } catch { - console.warn('[FileWatcher] Failed to load fallback state from localStorage'); - } - return false; -} - -// Save the fallback state to localStorage -function saveFallbackState(state: boolean) { - try { - if (typeof localStorage !== 'undefined') { - localStorage.setItem('bolt-file-watcher-fallback', state ? 'true' : 'false'); - } - } catch { - console.warn('[FileWatcher] Failed to save fallback state to localStorage'); - } -} - -/** - * Safe file watcher that falls back to polling when native file watching fails - * - * @param webcontainer The WebContainer instance - * @param pattern File pattern to watch - * @param callback Function to call when files change - * @returns An object with a close method - */ -export async function safeWatch(webcontainer: WebContainer, pattern: string = '**/*', callback: () => void) { - // Register the callback - if (!watcherState.callbacks.has(pattern)) { - watcherState.callbacks.set(pattern, new Set()); - } - - watcherState.callbacks.get(pattern)!.add(callback); - - // If we're already using fallback mode, don't try native watchers again - if (watcherState.fallbackEnabled) { - // Make sure polling is active - ensurePollingActive(); - - // Return a cleanup function - return { - close: () => { - const callbacks = watcherState.callbacks.get(pattern); - - if (callbacks) { - callbacks.delete(callback); - - if (callbacks.size === 0) { - watcherState.callbacks.delete(pattern); - } - } - }, - }; - } - - // Try to use native file watching - try { - const watcher = await webcontainer.fs.watch(pattern, { persistent: true }); - watcherState.watchingPaths.add(pattern); - - // Use the native watch events - (watcher as any).addEventListener('change', () => { - // Call all callbacks for this pattern - const callbacks = watcherState.callbacks.get(pattern); - - if (callbacks) { - callbacks.forEach((cb) => cb()); - } - }); - - // Return an object with a close method - return { - close: () => { - try { - watcher.close(); - watcherState.watchingPaths.delete(pattern); - - const callbacks = watcherState.callbacks.get(pattern); - - if (callbacks) { - callbacks.delete(callback); - - if (callbacks.size === 0) { - watcherState.callbacks.delete(pattern); - } - } - } catch (error) { - console.warn('[FileWatcher] Error closing watcher:', error); - } - }, - }; - } catch (error) { - console.warn('[FileWatcher] Native file watching failed:', error); - console.info('[FileWatcher] Falling back to polling mechanism for file changes'); - - // Switch to fallback mode for all future watches - watcherState.fallbackEnabled = true; - saveFallbackState(true); - - // Start polling - ensurePollingActive(); - - // Return a mock watcher object - return { - close: () => { - const callbacks = watcherState.callbacks.get(pattern); - - if (callbacks) { - callbacks.delete(callback); - - if (callbacks.size === 0) { - watcherState.callbacks.delete(pattern); - } - } - - // If no more callbacks, stop polling - if (watcherState.callbacks.size === 0 && watcherState.pollingInterval) { - clearInterval(watcherState.pollingInterval); - watcherState.pollingInterval = null; - } - }, - }; - } -} - -// Ensure polling is active -function ensurePollingActive() { - if (watcherState.pollingInterval) { - return; - } - - // Set up a polling interval that calls all callbacks - watcherState.pollingInterval = setInterval(() => { - // Call all registered callbacks - for (const [, callbacks] of watcherState.callbacks.entries()) { - callbacks.forEach((callback) => callback()); - } - }, 3000); // Poll every 3 seconds - - // Clean up interval when window unloads - if (typeof window !== 'undefined') { - window.addEventListener('beforeunload', () => { - if (watcherState.pollingInterval) { - clearInterval(watcherState.pollingInterval); - watcherState.pollingInterval = null; - } - }); - } -} - -// SafeWatchPaths mimics the webcontainer.internal.watchPaths method but with fallback -export function safeWatchPaths( - webcontainer: WebContainer, - config: { include: string[]; exclude?: string[]; includeContent?: boolean }, - callback: any, -) { - // Create a valid mock event to prevent undefined errors - const createMockEvent = () => ({ - type: 'change', - path: `${WORK_DIR}/mock-path.txt`, - buffer: new Uint8Array(0), - }); - - // Start with polling if we already know native watching doesn't work - if (watcherState.fallbackEnabled) { - console.info('[FileWatcher] Using fallback polling for watchPaths'); - ensurePollingActive(); - - const interval = setInterval(() => { - // Use our helper to create a valid event - const mockEvent = createMockEvent(); - - // Wrap in the expected structure of nested arrays - callback([[mockEvent]]); - }, 3000); - - return { - close: () => { - clearInterval(interval); - }, - }; - } - - // Try native watching - try { - return webcontainer.internal.watchPaths(config, callback); - } catch (error) { - console.warn('[FileWatcher] Native watchPaths failed:', error); - console.info('[FileWatcher] Using fallback polling for watchPaths'); - - // Mark as using fallback - watcherState.fallbackEnabled = true; - saveFallbackState(true); - - // Set up polling - ensurePollingActive(); - - const interval = setInterval(() => { - // Use our helper to create a valid event - const mockEvent = createMockEvent(); - - // Wrap in the expected structure of nested arrays - callback([[mockEvent]]); - }, 3000); - - return { - close: () => { - clearInterval(interval); - }, - }; - } -} diff --git a/app/workbench/components/terminal/Terminal.tsx b/app/workbench/components/terminal/Terminal.tsx index e1da7b85..8306eae3 100644 --- a/app/workbench/components/terminal/Terminal.tsx +++ b/app/workbench/components/terminal/Terminal.tsx @@ -10,6 +10,7 @@ const logger = createScopedLogger('Terminal'); export interface TerminalRef { reloadStyles: () => void; + getTerminal: () => XTerm | undefined; } export interface TerminalProps { @@ -80,6 +81,9 @@ export const Terminal = memo( const terminal = terminalRef.current!; terminal.options.theme = getTerminalTheme(readonly ? { cursor: '#00000000' } : {}); }, + getTerminal: () => { + return terminalRef.current; + }, }; }, []); diff --git a/app/workbench/components/terminal/TerminalTabs.tsx b/app/workbench/components/terminal/TerminalTabs.tsx index 35cda84f..71db32e6 100644 --- a/app/workbench/components/terminal/TerminalTabs.tsx +++ b/app/workbench/components/terminal/TerminalTabs.tsx @@ -23,7 +23,7 @@ export const TerminalTabs = memo(() => { const terminalToggledByShortcut = useRef(false); const [activeTerminal, setActiveTerminal] = useState(0); - const [terminalCount, setTerminalCount] = useState(1); + const [terminalCount, setTerminalCount] = useState(0); const addTerminal = () => { if (terminalCount < MAX_TERMINALS) { @@ -32,6 +32,48 @@ export const TerminalTabs = memo(() => { } }; + const closeTerminal = (index: number) => { + if (index === 0) { + return; + } // Can't close bolt terminal + + const terminalRef = terminalRefs.current[index]; + + if (terminalRef?.getTerminal) { + const terminal = terminalRef.getTerminal(); + + if (terminal) { + workbenchStore.detachTerminal(terminal); + } + } + + // Remove the terminal from refs + terminalRefs.current.splice(index, 1); + + // Adjust terminal count and active terminal + setTerminalCount(terminalCount - 1); + + if (activeTerminal === index) { + setActiveTerminal(Math.max(0, index - 1)); + } else if (activeTerminal > index) { + setActiveTerminal(activeTerminal - 1); + } + }; + + useEffect(() => { + return () => { + terminalRefs.current.forEach((ref, index) => { + if (index > 0 && ref?.getTerminal) { + const terminal = ref.getTerminal(); + + if (terminal) { + workbenchStore.detachTerminal(terminal); + } + } + }); + }; + }, []); + useEffect(() => { const { current: terminal } = terminalPanelRef; @@ -125,6 +167,15 @@ export const TerminalTabs = memo(() => { >
Terminal {terminalCount > 1 && index} + )} diff --git a/app/workbench/stores/terminal.ts b/app/workbench/stores/terminal.ts index 6ff70b57..3c2ae616 100644 --- a/app/workbench/stores/terminal.ts +++ b/app/workbench/stores/terminal.ts @@ -25,6 +25,22 @@ export class TerminalStore { toggleTerminal(value?: boolean) { this.showTerminal.set(value !== undefined ? value : !this.showTerminal.get()); } + + async detachTerminal(terminal: ITerminal) { + const terminalIndex = this.#terminals.findIndex((t) => t.terminal === terminal); + + if (terminalIndex !== -1) { + const { process } = this.#terminals[terminalIndex]; + + try { + process.kill(); + } catch (error) { + console.warn('Failed to kill terminal process:', error); + } + this.#terminals.splice(terminalIndex, 1); + } + } + async attachBoltTerminal(terminal: ITerminal) { try { const wc = await this.#webcontainer; diff --git a/app/workbench/stores/workbench.ts b/app/workbench/stores/workbench.ts index ac23faf8..50cd5a92 100644 --- a/app/workbench/stores/workbench.ts +++ b/app/workbench/stores/workbench.ts @@ -151,6 +151,10 @@ export class WorkbenchStore { this.#terminalStore.onTerminalResize(cols, rows); } + detachTerminal(terminal: ITerminal) { + this.#terminalStore.detachTerminal(terminal); + } + setDocuments(files: FileMap) { this.#editorStore.setDocuments(files);