blob: 665b941ae84818794cca832a4d4fab75a065b523 [file]
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();
}
}