diff --git a/lib/app/bootstrap.dart b/lib/app/bootstrap.dart index c6e7096..d7d034d 100644 --- a/lib/app/bootstrap.dart +++ b/lib/app/bootstrap.dart @@ -40,6 +40,7 @@ import '../shell/menus/global_menu_wrapper.dart'; import 'canvas_sync_bindings.dart'; import 'chrome_bindings.dart'; import 'edition_host_bindings.dart'; +import 'workspace_session_reset.dart'; import 'simulation/simulation_bindings.dart'; /// Everything `main()` needs to bring up before the first frame: telemetry, @@ -219,7 +220,9 @@ ProviderScope Function(Widget child) buildGlobalScope({ required FeatureFlags featureFlags, }) => (child) => ProviderScope( - child: GlobalMenuWrapper(child: TelemetryListener(child: child)), + child: GlobalMenuWrapper( + child: TelemetryListener(child: WorkspaceSessionReset(child: child)), + ), overrides: [ sharedPreferencesProvider.overrideWithValue(sharedPreferences), appTempDirProvider.overrideWithValue(appTempDir), diff --git a/lib/app/workspace_session_reset.dart b/lib/app/workspace_session_reset.dart new file mode 100644 index 0000000..406ff06 --- /dev/null +++ b/lib/app/workspace_session_reset.dart @@ -0,0 +1,39 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../features/canvas/controller/canvas_controller.dart'; +import '../features/workspace/providers/log_providers.dart'; +import '../features/workspace/providers/problems_provider.dart'; +import '../features/workspace/providers/workspace_files_provider.dart'; + +/// Clears what belongs to one workspace when another replaces it. +/// +/// The bottom panes and the canvas outlive the workspace they show: the logs, +/// Problems and the undo history are app-wide state, so opening a template +/// after running another left its serial output on screen, its compile errors +/// listed, and ⌘Z ready to replay its edits onto the new circuit. +/// +/// Keyed on the workspace *path*, which every way of switching changes: a +/// template or a blank project opens in a fresh temporary folder, another +/// folder has its own path, and Close Folder sets it to null. Reopening the +/// folder already open changes nothing, and nothing needs clearing then. +/// +/// Here, at the app layer, because the state it clears belongs to three +/// features that may not import one another (`test/architecture/`). Already +/// handled elsewhere, so not repeated: a running simulation stops itself on a +/// workspace change (`simulation_bindings.dart`), the editor's tabs close as +/// the workspace opens (`openWorkspace`), and the Serial Plotter is drawn from +/// the serial log. The terminal is deliberately left alone: its shell is not +/// tied to the workspace, and ending it would end whatever runs in it. +class const WorkspaceSessionReset({required final Widget child, super.key}) extends ConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + ref.listen(workspaceFilesProvider.select((s) => s.workspacePath), (previous, next) { + if (previous == next) return; + ref.read(logsRepositoryProvider).clearAll(); + ref.read(problemsProvider.notifier).clearAll(); + ref.read(canvasControllerProvider.notifier).historyManager.clear(); + }); + return child; + } +} diff --git a/lib/core/services/logs_repository.dart b/lib/core/services/logs_repository.dart index 4cd2c31..158b461 100644 --- a/lib/core/services/logs_repository.dart +++ b/lib/core/services/logs_repository.dart @@ -29,6 +29,10 @@ class LogsRepository { _notify(channel); } + /// Empties every channel, for when the workspace whose output they hold is + /// replaced by another. + void clearAll() => LogChannel.values.forEach(clear); + List getLogs(LogChannel channel) => List.of(_logs[channel] ?? []); void listen(LogChannel channel, VoidCallback onChanged) { diff --git a/lib/features/canvas/managers/history_manager.dart b/lib/features/canvas/managers/history_manager.dart index c0f9543..da56be1 100644 --- a/lib/features/canvas/managers/history_manager.dart +++ b/lib/features/canvas/managers/history_manager.dart @@ -34,6 +34,13 @@ class HistoryManager { _redoStack.add(command); } + /// Forgets every command. For a canvas that now shows another workspace's + /// circuit: undoing what was done to the last one would replay it here. + void clear() { + _undoStack.clear(); + _redoStack.clear(); + } + void redo(CanvasContext controller) { if (!canRedo) return; final command = _redoStack.removeLast(); diff --git a/test/app/workspace_session_reset_test.dart b/test/app/workspace_session_reset_test.dart new file mode 100644 index 0000000..2f4a07e --- /dev/null +++ b/test/app/workspace_session_reset_test.dart @@ -0,0 +1,151 @@ +import 'dart:io'; + +import 'package:flutter/widgets.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:pinbench/app/canvas_sync_bindings.dart'; +import 'package:pinbench/app/workspace_session_reset.dart'; +import 'package:pinbench/core/services/logs_repository.dart'; +import 'package:pinbench/core/utils/shared_preferences_provider.dart'; +import 'package:pinbench/features/canvas/controller/canvas_controller.dart'; +import 'package:pinbench/features/canvas/managers/canvas_commands.dart'; +import 'package:pinbench/features/canvas/managers/canvas_context.dart'; +import 'package:pinbench/features/workspace/providers/log_providers.dart'; +import 'package:pinbench/features/workspace/providers/problems_provider.dart'; +import 'package:pinbench/features/workspace/providers/workspace_files_provider.dart'; + +import '../support/chrome_commands.dart'; + +/// Does nothing, so the undo history can be filled without a circuit. +class _NoOpCommand implements CanvasCommand { + @override + void execute(CanvasContext controller) {} + + @override + void undo(CanvasContext controller) {} +} + +void main() { + late Directory first; + late Directory second; + late ProviderContainer container; + + setUp(() async { + first = Directory.systemTemp.createTempSync('workspace_session_reset_a_'); + second = Directory.systemTemp.createTempSync('workspace_session_reset_b_'); + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + container = ProviderContainer( + overrides: [ + sharedPreferencesProvider.overrideWithValue(prefs), + ...fakeChromeBindings(), + ...canvasSyncBindings, + ], + ); + }); + + tearDown(() { + container.dispose(); + for (final dir in [first, second]) { + if (dir.existsSync()) dir.deleteSync(recursive: true); + } + }); + + Future mount(WidgetTester tester) => tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const WorkspaceSessionReset(child: SizedBox()), + ), + ); + + /// What a run of the first workspace leaves behind: output in every log + /// pane, a compile error, and an edit to undo. + void leaveOutputBehind() { + final logs = container.read(logsRepositoryProvider); + for (final channel in LogChannel.values) { + logs.addLog(channel, 'Hello World'); + } + container.read(problemsProvider.notifier).setForSource(ProblemSource.compiler, [ + const Problem( + severity: ProblemSeverity.error, + source: ProblemSource.compiler, + message: 'Sketch failed to compile', + ), + ]); + container + .read(canvasControllerProvider.notifier) + .historyManager + .execute(_NoOpCommand(), container.read(canvasControllerProvider.notifier)); + } + + /// The log repository throttles its notifications on the *real* clock, so + /// under the test's fake clock a throttled notification reschedules itself + /// for ever and the test fails on a pending timer. Everything that writes or + /// clears a log therefore runs on real time ([WidgetTester.runAsync]), and + /// this lets the last throttled notification fire before the test ends. + Future drainLogNotifications(WidgetTester tester) => + tester.runAsync(() => Future.delayed(const Duration(milliseconds: 250))); + + void expectNothingLeft() { + final logs = container.read(logsRepositoryProvider); + for (final channel in LogChannel.values) { + expect(logs.getLogs(channel), isEmpty, reason: '$channel log'); + } + expect(container.read(problemsProvider), isEmpty); + expect(container.read(canvasControllerProvider.notifier).historyManager.canUndo, isFalse); + } + + testWidgets("opening another workspace clears the last one's output, problems and undo history", ( + tester, + ) async { + await mount(tester); + final workspace = container.read(workspaceFilesProvider.notifier); + await tester.runAsync( + () => workspace.setWorkspace(workspacePath: first.path, isTemporary: true), + ); + await tester.runAsync(() async => leaveOutputBehind()); + + await tester.runAsync( + () => workspace.setWorkspace(workspacePath: second.path, isTemporary: true), + ); + await tester.pump(); + + expectNothingLeft(); + await drainLogNotifications(tester); + }); + + testWidgets('closing the folder clears them too', (tester) async { + await mount(tester); + final workspace = container.read(workspaceFilesProvider.notifier); + await tester.runAsync( + () => workspace.setWorkspace(workspacePath: first.path, isTemporary: true), + ); + await tester.runAsync(() async => leaveOutputBehind()); + + await tester.runAsync(() async => workspace.clearWorkspaceState()); + await tester.pump(); + + expectNothingLeft(); + await drainLogNotifications(tester); + }); + + testWidgets('work inside the same workspace keeps its output', (tester) async { + await mount(tester); + final workspace = container.read(workspaceFilesProvider.notifier); + await tester.runAsync( + () => workspace.setWorkspace(workspacePath: first.path, isTemporary: true), + ); + await tester.runAsync(() async => leaveOutputBehind()); + + // Switching files is not switching workspaces. + await tester.runAsync(() async => workspace.setActiveFile('${first.path}/sketch.ino')); + await tester.pump(); + + expect(container.read(logsRepositoryProvider).getLogs(LogChannel.serial), ['Hello World']); + expect(container.read(problemsProvider), hasLength(1)); + expect(container.read(canvasControllerProvider.notifier).historyManager.canUndo, isTrue); + await drainLogNotifications(tester); + }); +}