From 32a72cd7374c44df83f4d5efb519e03e61ff2191 Mon Sep 17 00:00:00 2001 From: burhankhanzada Date: Sun, 4 Oct 2026 18:29:57 +0200 Subject: [PATCH] Clear the last workspace's output, problems and undo history on a switch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Running Blink, going Home and starting a blank project left Blink's "Hello World" in the Serial Monitor. The bottom panes and the canvas outlive the workspace they show: the logs, Problems and the undo history are app-wide state that nothing reset when another workspace opened, so the new project also listed the old compile errors, and ⌘Z could replay the old circuit's edits onto the new one. WorkspaceSessionReset (lib/app/) watches the workspace path and, when it changes, clears every log channel (Serial Monitor, and the Serial Plotter drawn from it; SPICE Logs; Debug Console), Problems and the canvas's undo history. The path changes on every switch: templates and blank projects open in fresh temporary folders, another folder has its own path, and Close Folder sets it to null. It lives at the app layer because the state it clears belongs to features that may not import one another. Already reset elsewhere, so left alone: a running simulation stops on a workspace change, and the editor's tabs close as a workspace opens. The terminal is not tied to the workspace, and ending its shell would end whatever runs in it. Tested: switching or closing clears all of it; working within one workspace keeps it. With the reset disabled, the switch tests fail. Co-Authored-By: Claude Opus 5.5 (1M context) --- lib/app/bootstrap.dart | 5 +- lib/app/workspace_session_reset.dart | 39 +++++ lib/core/services/logs_repository.dart | 4 + .../canvas/managers/history_manager.dart | 7 + test/app/workspace_session_reset_test.dart | 151 ++++++++++++++++++ 5 files changed, 205 insertions(+), 1 deletion(-) create mode 100644 lib/app/workspace_session_reset.dart create mode 100644 test/app/workspace_session_reset_test.dart 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); + }); +}