feat: add terminal close functionality and cleanup

- Implement terminal detach method in store to kill processes
- Add getTerminal method to Terminal component
- Add close button and cleanup logic in TerminalTabs
- Set initial terminal count to 0

- Remove redundant file-watcher.ts as this file is no longer used.
This commit is contained in:
KevIsDev 2025-06-25 12:39:23 +01:00
parent 29a9f0bc79
commit e205b9a147
5 changed files with 76 additions and 230 deletions

View File

@ -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<string>(),
callbacks: new Map<string, Set<() => 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);
},
};
}
}

View File

@ -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;
},
};
}, []);

View File

@ -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(() => {
>
<div className="i-ph:terminal-window-duotone text-lg" />
Terminal {terminalCount > 1 && index}
<button
className="bg-transparent text-bolt-elements-textTertiary hover:text-bolt-elements-textPrimary hover:bg-transparent rounded"
onClick={(e) => {
e.stopPropagation();
closeTerminal(index);
}}
>
<div className="i-ph:x text-xs" />
</button>
</button>
</React.Fragment>
)}

View File

@ -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;

View File

@ -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);