Skip to content

Commit 1cbd04e

Browse files
committed
[green] require recovery target consent and serialize editor replacements
Signed-off-by: Viwat Vchirawongkwin <viwat.v@chula.ac.th>
1 parent 8a03bf9 commit 1cbd04e

6 files changed

Lines changed: 171 additions & 11 deletions

File tree

‎app/lib/blocks/blocks_document.dart‎

Lines changed: 42 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ import 'package:flutter/foundation.dart' show immutable;
1616
import 'package:flutter_riverpod/flutter_riverpod.dart';
1717

1818
import 'package:pyble/editor/editor.dart';
19+
import 'package:pyble/editor/board_target_guard.dart';
20+
import 'package:pyble/editor/draft_recovery.dart';
1921

2022
import 'blocks_companion.dart';
2123
import 'blocks_examples.dart';
@@ -1038,22 +1040,57 @@ class BlocksDocumentController extends Notifier<BlocksDocument> {
10381040
/// Copies a freshly acknowledged revision into an independent editor document.
10391041
///
10401042
/// Callers must explicitly resolve a dirty-buffer conflict.
1041-
Future<void> openInEditor({bool replaceDirty = false}) async {
1043+
Future<void> openInEditor({
1044+
bool replaceDirty = false,
1045+
int? expectedEditorEpoch,
1046+
}) async {
1047+
final int editorEpoch =
1048+
expectedEditorEpoch ?? ref.read(editorDocumentEpochProvider);
1049+
if (ref.read(editorDocumentEpochProvider) != editorEpoch) {
1050+
throw BlocksEditorConflict(ref.read(editorDocumentProvider).name);
1051+
}
10421052
_beginAction();
10431053
try {
10441054
final GeneratedProgram program = await _freshProgramForAction();
1055+
final int? capturedHostId = _activeHostId;
1056+
if (ref.read(editorDocumentEpochProvider) != editorEpoch) {
1057+
throw BlocksEditorConflict(ref.read(editorDocumentProvider).name);
1058+
}
10451059
final EditorDocument editor = ref.read(editorDocumentProvider);
10461060
final String generatedName = state.targetPath.substring(
10471061
state.targetPath.lastIndexOf('/') + 1,
10481062
);
10491063
final bool sameGeneratedDocument =
10501064
editor.name == generatedName && editor.content == program.source;
1051-
if (editor.dirty && !sameGeneratedDocument && !replaceDirty) {
1065+
final recoveryEnabled =
1066+
ref.read(draftRecoveryProvider).status !=
1067+
DraftRecoveryStatus.disabled;
1068+
if (!recoveryEnabled &&
1069+
editor.dirty &&
1070+
!sameGeneratedDocument &&
1071+
!replaceDirty) {
10521072
throw BlocksEditorConflict(editor.name);
10531073
}
1054-
ref
1055-
.read(editorDocumentProvider.notifier)
1056-
.openGenerated(name: generatedName, content: program.source);
1074+
if (recoveryEnabled &&
1075+
!await confirmEditorReplacement(ref, expectedEpoch: editorEpoch)) {
1076+
throw const BoardActionCancelled();
1077+
}
1078+
final adopted = await ref
1079+
.read(draftRecoveryProvider.notifier)
1080+
.replaceDocument(
1081+
EditorDocument(
1082+
name: generatedName,
1083+
content: program.source,
1084+
dirty: true,
1085+
),
1086+
expectedEpoch: editorEpoch,
1087+
isStillValid: () =>
1088+
capturedHostId == _activeHostId &&
1089+
state.program?.revision == program.revision &&
1090+
state.retainedWorkspaceRevision == program.revision &&
1091+
state.workspaceError == null,
1092+
);
1093+
if (!adopted) throw const BoardActionCancelled();
10571094
} finally {
10581095
_endAction();
10591096
}

‎app/lib/blocks/blocks_view.dart‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
1212
import 'package:pyble/app/pages/surface_placeholder.dart';
1313
import 'package:pyble/app/providers.dart';
1414
import 'package:pyble/editor/editor.dart';
15+
import 'package:pyble/editor/board_target_guard.dart';
1516
import 'package:pyble/localization/localization.dart';
1617
import 'package:pyble/pble/pble.dart';
1718
import 'package:pyble/theme/theme.dart';
@@ -464,9 +465,10 @@ class BlocksView extends ConsumerWidget {
464465
final BlocksDocumentController controller = ref.read(
465466
blocksDocumentProvider.notifier,
466467
);
468+
final int editorEpoch = ref.read(editorDocumentEpochProvider);
467469
try {
468470
try {
469-
await controller.openInEditor();
471+
await controller.openInEditor(expectedEditorEpoch: editorEpoch);
470472
} on BlocksEditorConflict catch (conflict) {
471473
if (!context.mounted) return;
472474
final bool replace =
@@ -491,10 +493,15 @@ class BlocksView extends ConsumerWidget {
491493
) ??
492494
false;
493495
if (!replace || !context.mounted) return;
494-
await controller.openInEditor(replaceDirty: true);
496+
await controller.openInEditor(
497+
replaceDirty: true,
498+
expectedEditorEpoch: editorEpoch,
499+
);
495500
}
496501
if (!context.mounted) return;
497502
ref.read(selectedSurfaceProvider.notifier).state = AppSurface.editor;
503+
} on BoardActionCancelled {
504+
return;
498505
} catch (error) {
499506
if (!context.mounted) return;
500507
ScaffoldMessenger.of(context).showSnackBar(
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
// SPDX-License-Identifier: MIT
2+
// Part of PyBLE (https://pyble.dev) — see /LICENSE.
3+
4+
/// Local recovery consent and replacement seams. No transport implementation.
5+
library;
6+
7+
import 'package:flutter_riverpod/flutter_riverpod.dart';
8+
import 'package:pyble/app/providers.dart';
9+
import 'package:pyble/pble/pble.dart';
10+
import 'draft_recovery.dart';
11+
import 'editor_document.dart';
12+
13+
class BoardTargetRequest {
14+
const BoardTargetRequest({
15+
required this.path,
16+
required this.run,
17+
required this.docName,
18+
});
19+
final String path;
20+
final bool run;
21+
final String docName;
22+
}
23+
24+
/// Production injects ARB-backed dialogs through the root navigator. Missing
25+
/// confirmation fails closed; controller tests can provide a recording choice.
26+
final boardTargetConfirmationProvider =
27+
Provider<Future<bool> Function(BoardTargetRequest)?>((ref) => null);
28+
final editorReplacementConfirmationProvider =
29+
Provider<Future<bool> Function()?>((ref) => null);
30+
31+
class BoardActionCancelled implements Exception {
32+
const BoardActionCancelled();
33+
}
34+
35+
/// A recovered path is historical context, never permission to overwrite a
36+
/// board. Every explicit action is confirmed against its current destination.
37+
Future<bool> confirmBoardTarget(
38+
Ref ref, {
39+
required String path,
40+
required bool run,
41+
}) async {
42+
if (!ref.read(editorRecoveredDraftProvider)) return true;
43+
final int epoch = ref.read(editorDocumentEpochProvider);
44+
final connection = ref.read(connectionProvider);
45+
final stamp = connectionSessionStampOf(connection);
46+
final confirm = ref.read(boardTargetConfirmationProvider);
47+
if (confirm == null) return false;
48+
final accepted = await confirm(
49+
BoardTargetRequest(
50+
path: path,
51+
run: run,
52+
docName: ref.read(editorDocumentProvider).name,
53+
),
54+
);
55+
return accepted &&
56+
ref.read(editorDocumentEpochProvider) == epoch &&
57+
identical(ref.read(connectionProvider), connection) &&
58+
identical(connectionSessionStampOf(connection), stamp);
59+
}
60+
61+
Future<bool> confirmEditorReplacement(
62+
Ref ref, {
63+
required int expectedEpoch,
64+
}) async {
65+
if (ref.read(editorDocumentEpochProvider) != expectedEpoch) return false;
66+
final recovery = ref.read(draftRecoveryProvider);
67+
if (recovery.status == DraftRecoveryStatus.disabled) return true;
68+
final document = ref.read(editorDocumentProvider);
69+
final meaningful =
70+
document.content.isNotEmpty ||
71+
document.name != kDefaultDocumentName ||
72+
document.boardPath != null;
73+
final needsChoice =
74+
recovery.status == DraftRecoveryStatus.decision ||
75+
(meaningful &&
76+
(document.dirty || recovery.status != DraftRecoveryStatus.saved));
77+
if (!needsChoice) return true;
78+
final confirm = ref.read(editorReplacementConfirmationProvider);
79+
if (confirm == null || !await confirm()) return false;
80+
return ref.read(editorDocumentEpochProvider) == expectedEpoch;
81+
}

‎app/lib/editor/run_controller.dart‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import 'package:pyble/app/providers.dart';
2424
import 'package:pyble/pble/pble.dart';
2525

2626
import 'editor_document.dart';
27+
import 'board_target_guard.dart';
2728
import 'program_actions.dart';
2829
import 'smart_punctuation.dart';
2930

@@ -120,6 +121,12 @@ class RunController {
120121
final int epoch = _ref.read(editorDocumentEpochProvider);
121122
final Connection connection = _connection;
122123
final Object stamp = connectionSessionStampOf(connection);
124+
if (!await confirmBoardTarget(_ref, path: path, run: true) ||
125+
_ref.read(editorDocumentEpochProvider) != epoch ||
126+
!identical(_ref.read(connectionProvider), connection) ||
127+
!identical(connectionSessionStampOf(connection), stamp)) {
128+
throw const BoardActionCancelled();
129+
}
123130
await _ref
124131
.read(programActionsProvider)
125132
.runSourceFile(path: path, source: cleaned);

‎app/lib/editor/save_controller.dart‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import 'package:pyble/pble/pble.dart';
2828
import 'package:pyble/app/providers.dart';
2929

3030
import 'editor_document.dart';
31+
import 'board_target_guard.dart';
3132
import 'program_actions.dart';
3233
import 'smart_punctuation.dart';
3334

@@ -77,6 +78,12 @@ class SaveController {
7778
final int epoch = _ref.read(editorDocumentEpochProvider);
7879
final Connection connection = _ref.read(connectionProvider);
7980
final Object stamp = connectionSessionStampOf(connection);
81+
if (!await confirmBoardTarget(_ref, path: path, run: false) ||
82+
_ref.read(editorDocumentEpochProvider) != epoch ||
83+
!identical(_ref.read(connectionProvider), connection) ||
84+
!identical(connectionSessionStampOf(connection), stamp)) {
85+
throw const BoardActionCancelled();
86+
}
8087
await _ref
8188
.read(programActionsProvider)
8289
.saveSource(path: path, source: cleaned, onProgress: onProgress);

‎app/lib/files/file_explorer_controller.dart‎

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
2525

2626
import 'package:pyble/app/providers.dart';
2727
import 'package:pyble/editor/editor.dart';
28+
import 'package:pyble/editor/board_target_guard.dart';
29+
import 'package:pyble/editor/draft_recovery.dart';
2830
import 'package:pyble/pble/pble.dart';
2931

3032
/// The neutral, localizable classification of a file-operation failure
@@ -511,10 +513,25 @@ class FileExplorerController extends Notifier<FileExplorerState> {
511513
}
512514
state = state.copyWith(clearProgress: true);
513515
final String content = utf8.decode(bytes, allowMalformed: true);
514-
ref
515-
.read(editorDocumentProvider.notifier)
516-
.openFromBoard(path: path, content: content);
517-
ref.read(selectedSurfaceProvider.notifier).state = AppSurface.editor;
516+
if (!await confirmEditorReplacement(ref, expectedEpoch: documentEpoch))
517+
return;
518+
final adopted = await ref
519+
.read(draftRecoveryProvider.notifier)
520+
.replaceDocument(
521+
EditorDocument(
522+
name: fileName,
523+
content: content,
524+
dirty: false,
525+
boardPath: path,
526+
),
527+
expectedEpoch: documentEpoch,
528+
isStillValid: () =>
529+
!_disposed &&
530+
epoch == _openEpoch &&
531+
_isCapturedSessionCurrent(connection: connection, stamp: stamp),
532+
);
533+
if (adopted)
534+
ref.read(selectedSurfaceProvider.notifier).state = AppSurface.editor;
518535
} on PbleException catch (e) {
519536
if (epoch == _openEpoch) _fail(e, path);
520537
}
@@ -570,6 +587,10 @@ class FileExplorerController extends Notifier<FileExplorerState> {
570587
final int epoch = ref.read(editorDocumentEpochProvider);
571588
final Connection connection = _conn;
572589
final Object stamp = connectionSessionStampOf(connection);
590+
if (!await confirmBoardTarget(ref, path: path, run: false) ||
591+
ref.read(editorDocumentEpochProvider) != epoch ||
592+
!_isCapturedSessionCurrent(connection: connection, stamp: stamp))
593+
return;
573594
try {
574595
await connection.putFile(
575596
path,

0 commit comments

Comments
 (0)