Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion lib/app/bootstrap.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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),
Expand Down
39 changes: 39 additions & 0 deletions lib/app/workspace_session_reset.dart
Original file line number Diff line number Diff line change
@@ -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 鈱榋 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;
}
}
4 changes: 4 additions & 0 deletions lib/core/services/logs_repository.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> getLogs(LogChannel channel) => List.of(_logs[channel] ?? []);

void listen(LogChannel channel, VoidCallback onChanged) {
Expand Down
7 changes: 7 additions & 0 deletions lib/features/canvas/managers/history_manager.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
151 changes: 151 additions & 0 deletions test/app/workspace_session_reset_test.dart
Original file line number Diff line number Diff line change
@@ -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<void> 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<void> drainLogNotifications(WidgetTester tester) =>
tester.runAsync(() => Future<void>.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);
});
}
Loading