| import 'dart:async'; |
| import 'dart:convert'; |
| |
| import 'package:dartpad/dartpad.dart'; |
| import 'package:logging/logging.dart'; |
| import 'package:path/path.dart' as p; |
| import 'package:web/web.dart' as web; |
| |
| import '../../bottompanel/view_models/diagnostics_view_model.dart'; |
| import '../../shared/logic/app_event_bus.dart'; |
| import '../../shared/safe_change_notifier.dart'; |
| import '../../workspace/data/workspace_repository.dart'; |
| import '../models/preview_state.dart'; |
| |
| class PreviewViewModel extends SafeChangeNotifier { |
| PreviewViewModel({ |
| required this.workspaceRepository, |
| required this.commandBus, |
| required this.diagnosticsViewModel, |
| }) { |
| _controlSubscription = commandBus.on<ControlPreviewCommand>().listen((cmd) async { |
| try { |
| final entry = cmd.entrypoint ?? currentEntrypoint ?? 'lib/main.dart'; |
| switch (cmd.action) { |
| case PreviewAction.start: |
| await runCode(entry, skipValidation: true); |
| case PreviewAction.restart: |
| await runCode(entry, skipRecompilation: true, skipValidation: true); |
| case PreviewAction.rebuild: |
| await runCode(entry, skipValidation: true); |
| case PreviewAction.hotReload: |
| await hotReloadCode(entry); |
| case PreviewAction.stop: |
| await stopCode(); |
| } |
| // runCode / hotReloadCode catch compilation failures internally and |
| // set state to PreviewCompileError without re-throwing. Detect that |
| // here so the error propagates back through the command bus. |
| final s = state; |
| if (s is PreviewCompileError) { |
| throw Exception(s.message); |
| } |
| cmd.complete(); |
| } catch (e, st) { |
| cmd.completeError(e, st); |
| } |
| }); |
| |
| _invokeSubscription = commandBus.on<InvokePreviewExtensionCommand>().listen((cmd) async { |
| try { |
| final res = await invokeExtension(cmd.method, cmd.args); |
| cmd.complete(res); |
| } catch (e, st) { |
| cmd.completeError(e, st); |
| } |
| }); |
| |
| _stateRequestSubscription = commandBus.on<RequestPreviewStateCommand>().listen((cmd) { |
| cmd.complete(_createStateData()); |
| }); |
| } |
| |
| final WorkspaceRepository workspaceRepository; |
| final AppCommandBus commandBus; |
| final DiagnosticsViewModel diagnosticsViewModel; |
| |
| late final StreamSubscription<ControlPreviewCommand> _controlSubscription; |
| late final StreamSubscription<InvokePreviewExtensionCommand> _invokeSubscription; |
| late final StreamSubscription<RequestPreviewStateCommand> _stateRequestSubscription; |
| |
| Sandbox? _sandbox; |
| HotReloadCompiler? _hotReloadCompiler; |
| StreamSubscription<dynamic>? _sandboxConsoleSubscription; |
| StreamSubscription<dynamic>? _sandboxFlutterEventSubscription; |
| StreamSubscription<dynamic>? _sandboxErrorSubscription; |
| StreamSubscription<dynamic>? _sandboxRejectionSubscription; |
| final _flutterEventController = StreamController<Map<String, dynamic>>.broadcast(); |
| Stream<Map<String, dynamic>> get onFlutterEvent => _flutterEventController.stream; |
| |
| bool _busy = false; |
| |
| // Monotonically increasing token for preview operations. |
| // |
| // Async start/hot-reload steps must still own the current token before mutating state, |
| // so stale work cannot revive the preview after a stop or newer run. |
| int _operationId = 0; |
| String? _pendingReloadEntrypoint; |
| String? _currentEntrypoint; |
| String? _lastCompiledCode; |
| int _runtimeObservationSequence = 0; |
| PreviewRuntimeObservation? _latestRuntimeObservation; |
| Timer? _validationDismissTimer; |
| |
| web.Element get containerElement => _container; |
| final web.Element _container = web.document.createElement('div')..className = 'preview'; |
| |
| PreviewState get state => _state; |
| PreviewState _state = PreviewInitial(); |
| |
| bool get hasActiveRuntime => _sandbox != null; |
| String? get currentEntrypoint => _currentEntrypoint; |
| |
| bool get canStart => |
| !_busy && (_state is PreviewInitial || _state is PreviewCompileError || _state is PreviewValidationError); |
| bool get canRestart => !_busy && _state is PreviewRunning; |
| bool get canHotReload => !_busy && _state is PreviewRunning; |
| bool get canStop => _state is! PreviewStopping && (_busy || hasActiveRuntime); |
| |
| Future<void> start(String entrypoint) async { |
| await runCode(entrypoint); |
| final s = state; |
| if (s is PreviewCompileError) { |
| throw Exception(s.message); |
| } |
| } |
| |
| Future<void> restart(String entrypoint) async { |
| await runCode(entrypoint, skipRecompilation: true); |
| final s = state; |
| if (s is PreviewCompileError) { |
| throw Exception(s.message); |
| } |
| } |
| |
| Future<void> rebuild(String entrypoint) async { |
| await runCode(entrypoint); |
| final s = state; |
| if (s is PreviewCompileError) { |
| throw Exception(s.message); |
| } |
| } |
| |
| Future<void> hotReload(String entrypoint) async { |
| await hotReloadCode(entrypoint); |
| final s = state; |
| if (s is PreviewCompileError) { |
| throw Exception(s.message); |
| } |
| } |
| |
| Future<void> _attachSandboxConsole(Sandbox sandbox) async { |
| await _detachSandboxConsole(); |
| _sandboxConsoleSubscription = sandbox.onConsole.listen((event) { |
| final level = switch (event.level.name) { |
| 'warn' => Level.WARNING, |
| 'error' => Level.SEVERE, |
| 'info' || 'log' => Level.INFO, |
| _ => Level.INFO, |
| }; |
| |
| commandBus.dispatch(LogConsoleMessageCommand('[app] ${event.message}', level: level)); |
| if (level == Level.SEVERE) { |
| _recordRuntimeObservation( |
| kind: PreviewRuntimeObservationKind.consoleError, |
| message: event.message.toString(), |
| level: level, |
| blocking: true, |
| source: 'console.error', |
| ); |
| } |
| }); |
| _sandboxErrorSubscription = sandbox.onError.listen((event) { |
| _recordRuntimeObservation( |
| kind: PreviewRuntimeObservationKind.unhandledError, |
| message: event.message, |
| level: Level.SEVERE, |
| blocking: true, |
| source: 'error', |
| ); |
| }); |
| _sandboxRejectionSubscription = sandbox.onUnhandledRejection.listen((event) { |
| _recordRuntimeObservation( |
| kind: PreviewRuntimeObservationKind.unhandledRejection, |
| message: event.message, |
| level: Level.SEVERE, |
| blocking: true, |
| source: 'unhandledRejection', |
| ); |
| }); |
| } |
| |
| Future<void> _detachSandboxConsole() async { |
| await _sandboxConsoleSubscription?.cancel(); |
| _sandboxConsoleSubscription = null; |
| await _sandboxErrorSubscription?.cancel(); |
| _sandboxErrorSubscription = null; |
| await _sandboxRejectionSubscription?.cancel(); |
| _sandboxRejectionSubscription = null; |
| } |
| |
| int _beginOperation() => ++_operationId; |
| |
| bool _isCurrentOperation(int operationId) => _operationId == operationId; |
| |
| void _finishOperation(int operationId) { |
| if (!_isCurrentOperation(operationId)) { |
| return; |
| } |
| _busy = false; |
| notifyListeners(); |
| _checkPendingReload(); |
| } |
| |
| Future<void> _disposeCurrentSandbox(Sandbox sandbox) async { |
| if (!identical(_sandbox, sandbox)) { |
| return; |
| } |
| await _detachSandboxConsole(); |
| await _sandboxFlutterEventSubscription?.cancel(); |
| _sandboxFlutterEventSubscription = null; |
| _sandbox = null; |
| sandbox.dispose(); |
| } |
| |
| /// Shows a validation error toast that auto-dismisses after 5 seconds. |
| void showValidationError(PreviewValidationResult validation) { |
| assert(!validation.isValid); |
| _state = PreviewValidationError( |
| validation.message!, |
| actionLabel: validation.hasAnalyzerErrors ? 'Open Problems' : null, |
| actionPanelType: validation.hasAnalyzerErrors ? PanelType.problems : null, |
| ); |
| notifyListeners(); |
| _validationDismissTimer?.cancel(); |
| _validationDismissTimer = Timer(const Duration(seconds: 5), () { |
| if (_state is PreviewValidationError) { |
| _state = PreviewInitial(); |
| notifyListeners(); |
| } |
| }); |
| } |
| |
| /// Starts the preview for [entrypoint]. |
| /// |
| /// By default this creates a fresh compiler session, compiles the current |
| /// sources, loads the resulting module into a new sandbox, and runs the app. |
| /// |
| /// If [skipRecompilation] is `true`, this skips recompilation and reuses the |
| /// last successfully compiled module from [_lastCompiledCode] instead. This is |
| /// used for restart semantics where we want to recreate the sandbox and reset |
| /// runtime state without recompiling unchanged code. |
| /// |
| /// If no cached artifact is available yet, the code is compiled even when |
| /// [skipRecompilation] is `true`. |
| /// [skipValidation] is used for when the app gets not started by pressing the button but when the /project pages opens, |
| /// because when the preview get started immediately the LSP still shows errors |
| Future<void> runCode(String entrypoint, {bool skipRecompilation = false, bool skipValidation = false}) async { |
| if (_busy) { |
| return; |
| } |
| |
| // Pre-flight validation (only for manual starts, not programmatic or restarts). |
| if (!skipValidation && !skipRecompilation) { |
| final validation = await workspaceRepository.validateForPreview( |
| diagnosticsViewModel.allDiagnostics, |
| ); |
| if (!validation.isValid) { |
| showValidationError(validation); |
| return; |
| } |
| } |
| |
| final operationId = _beginOperation(); |
| _busy = true; |
| _currentEntrypoint = entrypoint; |
| _pendingReloadEntrypoint = null; |
| _resetRuntimeObservations(); |
| |
| final compileNeeded = !skipRecompilation || _lastCompiledCode == null; |
| commandBus.dispatch(LogConsoleMessageCommand('Run $entrypoint')); |
| if (compileNeeded) { |
| commandBus.dispatch(const LogConsoleMessageCommand('Starting compiler...')); |
| } |
| |
| if (_state is PreviewRunning || |
| _state is PreviewRestarting || |
| _state is PreviewHotReloading || |
| _state is PreviewStopping) { |
| _state = PreviewRestarting(); |
| } else { |
| _state = PreviewStarting(); |
| } |
| notifyListeners(); |
| |
| try { |
| final String codeToLoad; |
| |
| if (compileNeeded) { |
| final previousCompiler = _hotReloadCompiler; |
| if (previousCompiler != null) { |
| commandBus.dispatch(const LogConsoleMessageCommand('Closing previous compiler session...')); |
| _hotReloadCompiler = null; |
| await previousCompiler.close(); |
| if (!_isCurrentOperation(operationId)) { |
| return; |
| } |
| } |
| |
| commandBus.dispatch(const LogConsoleMessageCommand('Creating hot reload compiler...')); |
| final compiler = await workspaceRepository.startHotReloadCompiler( |
| Uri.parse(entrypoint), |
| ); |
| if (!_isCurrentOperation(operationId)) { |
| await compiler.close(); |
| return; |
| } |
| _hotReloadCompiler = compiler; |
| |
| commandBus.dispatch(const LogConsoleMessageCommand('Compiling application...')); |
| final result = await compiler.compile(); |
| if (!_isCurrentOperation(operationId)) { |
| return; |
| } |
| commandBus.dispatch(LogConsoleMessageCommand(result.log)); |
| commandBus.dispatch(const LogConsoleMessageCommand('Compilation succeeded.')); |
| _lastCompiledCode = result.code; |
| codeToLoad = result.code!; |
| } else { |
| codeToLoad = _lastCompiledCode!; |
| } |
| |
| final assetBaseUrl = Uri.parse(web.document.baseURI).resolve('flutter/'); |
| |
| final previousSandbox = _sandbox; |
| if (previousSandbox != null) { |
| commandBus.dispatch(const LogConsoleMessageCommand('Disposing previous preview sandbox...')); |
| await _disposeCurrentSandbox(previousSandbox); |
| if (!_isCurrentOperation(operationId)) { |
| return; |
| } |
| } |
| |
| commandBus.dispatch(const LogConsoleMessageCommand('Creating preview sandbox...')); |
| final sandbox = await Sandbox.createIFrame( |
| _container, |
| assetBaseUrl: assetBaseUrl, |
| ); |
| if (!_isCurrentOperation(operationId)) { |
| sandbox.dispose(); |
| return; |
| } |
| _sandbox = sandbox; |
| _sandboxFlutterEventSubscription = sandbox.onExtensionEvent.listen((event) { |
| if (event.kind == 'vibepad.readAsset') { |
| _handleAssetEvent(sandbox, event.data); |
| } |
| if (!_flutterEventController.isClosed) { |
| _flutterEventController.add({ |
| 'eventKind': event.kind, |
| 'eventData': event.data, |
| }); |
| } |
| }); |
| await _attachSandboxConsole(sandbox); |
| if (!_isCurrentOperation(operationId)) { |
| await _disposeCurrentSandbox(sandbox); |
| return; |
| } |
| |
| commandBus.dispatch(const LogConsoleMessageCommand('Loading compiled module...')); |
| await sandbox.loadModule(code: codeToLoad); |
| if (!_isCurrentOperation(operationId)) { |
| await _disposeCurrentSandbox(sandbox); |
| return; |
| } |
| |
| final packageName = await workspaceRepository.getPackageName(); |
| final libraryUri = Uri( |
| scheme: 'package', |
| path: p.join(packageName, p.relative(entrypoint, from: 'lib')), |
| ); |
| |
| commandBus.dispatch(const LogConsoleMessageCommand('Running application...')); |
| await sandbox.runApp(libraryUri); |
| if (!_isCurrentOperation(operationId)) { |
| await _disposeCurrentSandbox(sandbox); |
| return; |
| } |
| commandBus.dispatch(const LogConsoleMessageCommand('App is running.')); |
| |
| _state = PreviewRunning(entrypoint); |
| } on CompilationFailedException catch (e, st) { |
| if (_isCurrentOperation(operationId)) { |
| commandBus.dispatch( |
| LogConsoleMessageCommand('Compilation failed', level: Level.SEVERE, error: e, stackTrace: st), |
| ); |
| _state = PreviewCompileError(entrypoint, e.message); |
| } |
| } catch (e, st) { |
| if (_isCurrentOperation(operationId)) { |
| commandBus.dispatch(LogConsoleMessageCommand('Run failed', level: Level.SEVERE, error: e, stackTrace: st)); |
| _state = PreviewCompileError(entrypoint, e.toString()); |
| } |
| } finally { |
| _finishOperation(operationId); |
| } |
| } |
| |
| Future<void> hotReloadCode(String entrypoint) async { |
| if (_busy) { |
| if (_sandbox != null) { |
| _pendingReloadEntrypoint = entrypoint; |
| } |
| return; |
| } |
| final operationId = _beginOperation(); |
| _busy = true; |
| _currentEntrypoint = entrypoint; |
| _pendingReloadEntrypoint = null; |
| _resetRuntimeObservations(); |
| final sandbox = _sandbox; |
| if (sandbox == null) { |
| _busy = false; |
| return; |
| } |
| commandBus.dispatch(LogConsoleMessageCommand('Hot reload $entrypoint')); |
| commandBus.dispatch(const LogConsoleMessageCommand('Starting hot reload...')); |
| |
| _state = PreviewHotReloading(); |
| notifyListeners(); |
| |
| try { |
| commandBus.dispatch(const LogConsoleMessageCommand('Preparing compiler...')); |
| var compiler = _hotReloadCompiler; |
| if (compiler == null) { |
| final newCompiler = await workspaceRepository.startHotReloadCompiler( |
| Uri.parse(entrypoint), |
| ); |
| if (!_isCurrentOperation(operationId)) { |
| await newCompiler.close(); |
| return; |
| } |
| _hotReloadCompiler = newCompiler; |
| compiler = newCompiler; |
| } |
| |
| commandBus.dispatch(const LogConsoleMessageCommand('Compiling changes...')); |
| final result = await compiler.compile(); |
| if (!_isCurrentOperation(operationId)) { |
| return; |
| } |
| commandBus.dispatch(LogConsoleMessageCommand(result.log)); |
| |
| commandBus.dispatch(const LogConsoleMessageCommand('Applying hot reload...')); |
| await sandbox.hotReload( |
| code: result.code, |
| librariesToReload: result.compiledLibraryUris.map(Uri.parse).toList(), |
| ); |
| if (!_isCurrentOperation(operationId)) { |
| return; |
| } |
| |
| commandBus.dispatch(const LogConsoleMessageCommand('Hot reload completed successfully.')); |
| _lastCompiledCode = result.code; |
| _state = PreviewRunning(entrypoint); |
| } on HotReloadRejectedException catch (e, st) { |
| if (_isCurrentOperation(operationId)) { |
| commandBus.dispatch( |
| LogConsoleMessageCommand('Hot reload rejected', level: Level.WARNING, error: e, stackTrace: st), |
| ); |
| _state = PreviewCompileError(entrypoint, e.message); |
| } |
| } catch (e, st) { |
| if (_isCurrentOperation(operationId)) { |
| commandBus.dispatch( |
| LogConsoleMessageCommand('Hot reload failed', level: Level.SEVERE, error: e, stackTrace: st), |
| ); |
| _state = PreviewCompileError(entrypoint, e.toString()); |
| } |
| } finally { |
| _finishOperation(operationId); |
| } |
| } |
| |
| void _checkPendingReload() { |
| final pending = _pendingReloadEntrypoint; |
| if (pending != null && _sandbox != null) { |
| _pendingReloadEntrypoint = null; |
| unawaited(hotReloadCode(pending)); |
| } |
| } |
| |
| Future<void> stopCode() async { |
| if (_state is PreviewStopping) { |
| return; |
| } |
| final operationId = _beginOperation(); |
| _busy = true; |
| _pendingReloadEntrypoint = null; |
| _currentEntrypoint = null; |
| _resetRuntimeObservations(); |
| commandBus.dispatch(const LogConsoleMessageCommand('Stopping app...')); |
| _state = PreviewStopping(); |
| _lastCompiledCode = null; |
| notifyListeners(); |
| var operationStillCurrent = true; |
| |
| try { |
| final sandbox = _sandbox; |
| if (sandbox != null) { |
| await _disposeCurrentSandbox(sandbox); |
| } else { |
| await _detachSandboxConsole(); |
| } |
| final hotReloadCompiler = _hotReloadCompiler; |
| _hotReloadCompiler = null; |
| if (hotReloadCompiler != null) { |
| await hotReloadCompiler.close(); |
| } |
| commandBus.dispatch(const LogConsoleMessageCommand('Stopped app.')); |
| } finally { |
| operationStillCurrent = _isCurrentOperation(operationId); |
| } |
| |
| if (!operationStillCurrent) { |
| return; |
| } |
| |
| _busy = false; |
| _state = PreviewInitial(); |
| notifyListeners(); |
| } |
| |
| void openInNewTab() async { |
| final externalWindow = web.window.open('preview.html', '_blank'); |
| if (externalWindow == null) { |
| return; |
| } |
| |
| await web.EventStreamProviders.loadEvent.forTarget(externalWindow).first; |
| |
| commandBus.dispatch(const LogConsoleMessageCommand('External window loaded.')); |
| |
| final assetBaseUrl = Uri.parse(web.document.baseURI).resolve('flutter/'); |
| |
| final sandbox = await Sandbox.createIFrame( |
| externalWindow.document.body!, |
| assetBaseUrl: assetBaseUrl, |
| ); |
| |
| sandbox.onExtensionEvent.listen((event) { |
| if (event.kind == 'vibepad.readAsset') { |
| _handleAssetEvent(sandbox, event.data); |
| } |
| }); |
| |
| if (_lastCompiledCode != null) { |
| await sandbox.loadModule(code: _lastCompiledCode!); |
| |
| final packageName = await workspaceRepository.getPackageName(); |
| final libraryUri = Uri( |
| scheme: 'package', |
| path: p.join(packageName, p.relative(_currentEntrypoint!, from: 'lib')), |
| ); |
| |
| await sandbox.runApp(libraryUri); |
| } |
| } |
| |
| Future<String?> invokeExtension(String method, Map<String, String> args) async { |
| final sandbox = _sandbox; |
| if (sandbox == null) { |
| return null; |
| } |
| try { |
| final rawResult = await sandbox.invokeExtension(method, args); |
| |
| final decoded = jsonDecode(rawResult); |
| if (decoded is Map<String, dynamic>) { |
| if (decoded.containsKey('error') || decoded.containsKey('errorCode')) { |
| final errorCode = decoded['errorCode'] ?? decoded['error']; |
| final errorDetail = decoded['errorDetail'] ?? decoded['message'] ?? 'Unknown error'; |
| throw Exception('Extension error ($errorCode): $errorDetail'); |
| } |
| if (decoded.containsKey('result')) { |
| final res = decoded['result']; |
| if (res is String) { |
| return res; |
| } else { |
| return jsonEncode(res); |
| } |
| } |
| } |
| return rawResult; |
| } catch (e) { |
| commandBus.dispatch(LogConsoleMessageCommand('Failed to invoke extension $method: $e', level: Level.WARNING)); |
| rethrow; |
| } |
| } |
| |
| void _handleAssetEvent(Sandbox sandbox, Map<String, dynamic> event) async { |
| final key = event['key'] as String?; |
| final id = event['id'] as String?; |
| if (key != null && id != null) { |
| try { |
| final bytes = await workspaceRepository.workspaceResourceApi.readFileAsBytes(key); |
| final base64Data = base64Encode(bytes); |
| await sandbox.invokeExtension('ext.vibepad.setAsset', { |
| 'id': id, |
| 'data': base64Data, |
| }); |
| } catch (e) { |
| try { |
| await sandbox.invokeExtension('ext.vibepad.setAsset', { |
| 'id': id, |
| 'error': e.toString(), |
| }); |
| } catch (_) {} |
| } |
| } |
| } |
| |
| void _resetRuntimeObservations() { |
| _latestRuntimeObservation = null; |
| } |
| |
| void _recordRuntimeObservation({ |
| required PreviewRuntimeObservationKind kind, |
| required String message, |
| required Level level, |
| required bool blocking, |
| String? source, |
| }) { |
| final observation = PreviewRuntimeObservation( |
| kind: kind, |
| message: message, |
| severity: level.name, |
| sequence: ++_runtimeObservationSequence, |
| blocking: blocking, |
| source: source ?? 'preview', |
| ); |
| _latestRuntimeObservation = observation; |
| notifyListeners(); |
| } |
| |
| PreviewStateData _createStateData() { |
| return PreviewStateData( |
| hasActiveRuntime: hasActiveRuntime, |
| isBusy: _busy, |
| currentEntrypoint: currentEntrypoint, |
| latestRuntimeObservation: _latestRuntimeObservation, |
| ); |
| } |
| |
| @override |
| void notifyListeners() { |
| super.notifyListeners(); |
| commandBus.dispatch(PreviewStateChangedEvent(_createStateData())); |
| } |
| |
| @override |
| void dispose() { |
| _controlSubscription.cancel(); |
| _invokeSubscription.cancel(); |
| _stateRequestSubscription.cancel(); |
| _validationDismissTimer?.cancel(); |
| _operationId++; |
| _pendingReloadEntrypoint = null; |
| unawaited(_detachSandboxConsole()); |
| unawaited(_sandboxFlutterEventSubscription?.cancel()); |
| _flutterEventController.close(); |
| _sandbox?.dispose(); |
| _sandbox = null; |
| final hotReloadCompiler = _hotReloadCompiler; |
| if (hotReloadCompiler != null) { |
| unawaited(hotReloadCompiler.close()); |
| _hotReloadCompiler = null; |
| } |
| super.dispose(); |
| } |
| } |