Skip to content

Commit 29455b6

Browse files
committed
[green] preserve live drafts through recovery retries and compact errors
Signed-off-by: Viwat Vchirawongkwin <viwat.v@chula.ac.th>
1 parent c760320 commit 29455b6

8 files changed

Lines changed: 191 additions & 34 deletions

File tree

‎app/lib/app/shell.dart‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,11 @@ class _DisconnectedShellState extends ConsumerState<_DisconnectedShell> {
168168
final bool recovered = ref.watch(editorRecoveredDraftProvider);
169169
final EditorDocument document = ref.watch(editorDocumentProvider);
170170
final bool previewAvailable =
171-
recovery.enabled && (recovered || document.content.isNotEmpty);
171+
recovery.enabled &&
172+
(recovered ||
173+
document.content.isNotEmpty ||
174+
document.name != kDefaultDocumentName ||
175+
document.boardPath != null);
172176
final AppLocalizations l10n = AppLocalizations.of(context);
173177
return Scaffold(
174178
body: SafeArea(

‎app/lib/editor/editor_view.dart‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -348,7 +348,13 @@ class _EditorHeader extends ConsumerWidget {
348348
}
349349
final bool replaced = await ref
350350
.read(draftRecoveryProvider.notifier)
351-
.replaceDocument(const EditorDocument.untitled(), expectedEpoch: epoch);
351+
.replaceDocument(
352+
const EditorDocument.untitled(),
353+
expectedEpoch: epoch,
354+
allowVolatile:
355+
ref.read(draftRecoveryProvider).status ==
356+
DraftRecoveryStatus.volatile,
357+
);
352358
if (!replaced && context.mounted) {
353359
if (ref.read(editorDocumentEpochProvider) != epoch) {
354360
ScaffoldMessenger.of(

‎app/lib/editor/recovery_gate.dart‎

Lines changed: 70 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -66,13 +66,19 @@ class _RecoveryGateState extends ConsumerState<RecoveryGate>
6666
if (recovery.blocksStartup) {
6767
final DraftSnapshot? candidate = recovery.candidate;
6868
final EditorDocument current = ref.watch(editorDocumentProvider);
69+
final bool currentMeaningful =
70+
current.content.isNotEmpty ||
71+
current.name != kDefaultDocumentName ||
72+
current.boardPath != null;
6973
if (candidate != null && recovery.status != DraftRecoveryStatus.loading) {
7074
return Scaffold(
7175
body: PopScope(
7276
canPop: false,
7377
child: RecoveryPanel(
74-
currentContent: current.content.isNotEmpty
75-
? current.content
78+
currentContent: currentMeaningful ? current.content : null,
79+
currentName: currentMeaningful ? current.name : null,
80+
onKeepCurrent: currentMeaningful
81+
? controller.continueWithoutRecovery
7682
: null,
7783
name: candidate.name,
7884
content: candidate.content,
@@ -163,12 +169,30 @@ class _RecoveryGateState extends ConsumerState<RecoveryGate>
163169
runSpacing: 4,
164170
crossAxisAlignment: WrapCrossAlignment.center,
165171
children: <Widget>[
166-
Text(l10n.recoveryVolatile),
172+
Text(l10n.recoveryVolatileShort),
167173
TextButton(
168174
key: kRecoveryRetryKey,
169175
onPressed: () => controller.retry(),
170176
child: Text(l10n.recoveryRetry),
171177
),
178+
IconButton(
179+
tooltip: l10n.recoveryVolatile,
180+
icon: const Icon(Icons.info_outline),
181+
onPressed: () => showDialog<void>(
182+
context: context,
183+
builder: (BuildContext context) => AlertDialog(
184+
scrollable: true,
185+
title: Text(l10n.recoveryTitle),
186+
content: Text(l10n.recoveryVolatile),
187+
actions: <Widget>[
188+
TextButton(
189+
onPressed: () => Navigator.of(context).pop(),
190+
child: Text(l10n.recoveryClose),
191+
),
192+
],
193+
),
194+
),
195+
),
172196
],
173197
),
174198
),
@@ -195,7 +219,7 @@ class RecoveryStatusBar extends ConsumerWidget {
195219
DraftRecoveryStatus.saved => l10n.recoverySaved,
196220
DraftRecoveryStatus.saving => l10n.recoverySaving,
197221
DraftRecoveryStatus.pending => l10n.recoveryPending,
198-
DraftRecoveryStatus.volatile => l10n.recoveryVolatile,
222+
DraftRecoveryStatus.volatile => l10n.recoveryVolatileShort,
199223
DraftRecoveryStatus.loading => l10n.recoveryLoading,
200224
DraftRecoveryStatus.decision => l10n.recoveryTitle,
201225
DraftRecoveryStatus.failed =>
@@ -216,31 +240,49 @@ class RecoveryStatusBar extends ConsumerWidget {
216240
spacing: 12,
217241
crossAxisAlignment: WrapCrossAlignment.center,
218242
children: <Widget>[
219-
Semantics(liveRegion: true, child: Text(label)),
220-
TextButton.icon(
221-
key: kRecoverySaveKey,
222-
onPressed: canSave
223-
? () async {
224-
final ScaffoldMessengerState messenger =
225-
ScaffoldMessenger.of(context);
226-
final bool saved = await ref
227-
.read(draftRecoveryProvider.notifier)
228-
.saveRecoveryCopy();
229-
if (!messenger.mounted) return;
230-
messenger.showSnackBar(
231-
SnackBar(
232-
content: Text(
233-
saved
234-
? l10n.recoverySavedFeedback
235-
: l10n.recoverySaveFailed,
243+
if (recovery.status != DraftRecoveryStatus.volatile)
244+
Semantics(liveRegion: true, child: Text(label)),
245+
if (recovery.status == DraftRecoveryStatus.failed) ...<Widget>[
246+
TextButton(
247+
key: kRecoveryRetryKey,
248+
onPressed: () =>
249+
ref.read(draftRecoveryProvider.notifier).retry(),
250+
child: Text(l10n.recoveryRetry),
251+
),
252+
TextButton(
253+
key: kRecoveryContinueKey,
254+
onPressed: () => ref
255+
.read(draftRecoveryProvider.notifier)
256+
.continueWithoutRecovery(),
257+
child: Text(l10n.recoveryContinue),
258+
),
259+
],
260+
if (recovery.status != DraftRecoveryStatus.volatile &&
261+
recovery.status != DraftRecoveryStatus.failed)
262+
TextButton.icon(
263+
key: kRecoverySaveKey,
264+
onPressed: canSave
265+
? () async {
266+
final ScaffoldMessengerState messenger =
267+
ScaffoldMessenger.of(context);
268+
final bool saved = await ref
269+
.read(draftRecoveryProvider.notifier)
270+
.saveRecoveryCopy();
271+
if (!messenger.mounted) return;
272+
messenger.showSnackBar(
273+
SnackBar(
274+
content: Text(
275+
saved
276+
? l10n.recoverySavedFeedback
277+
: l10n.recoverySaveFailed,
278+
),
236279
),
237-
),
238-
);
239-
}
240-
: null,
241-
icon: const Icon(Icons.save_alt),
242-
label: Text(l10n.recoverySave),
243-
),
280+
);
281+
}
282+
: null,
283+
icon: const Icon(Icons.save_alt),
284+
label: Text(l10n.recoverySave),
285+
),
244286
TextButton(
245287
onPressed: () => showModalBottomSheet<void>(
246288
context: context,

‎app/lib/editor/recovery_panel.dart‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ const Key kRecoveryCopyKey = ValueKey<String>('recoveryCopy');
1919
const Key kRecoveryRetryKey = ValueKey<String>('recoveryRetry');
2020
const Key kRecoveryContinueKey = ValueKey<String>('recoveryContinue');
2121
const Key kRecoverySaveKey = ValueKey<String>('recoverySave');
22+
const Key kRecoveryKeepCurrentKey = ValueKey<String>('recoveryKeepCurrent');
2223

2324
/// A scrollable, selectable source preview. Merely mounting or dismissing it
2425
/// never restores, discards, copies, connects to a board, or runs code.
@@ -35,6 +36,8 @@ class RecoveryPanel extends StatelessWidget {
3536
this.failure,
3637
this.onContinue,
3738
this.currentContent,
39+
this.currentName,
40+
this.onKeepCurrent,
3841
});
3942

4043
final String name;
@@ -47,6 +50,8 @@ class RecoveryPanel extends StatelessWidget {
4750
final String? failure;
4851
final VoidCallback? onContinue;
4952
final String? currentContent;
53+
final String? currentName;
54+
final VoidCallback? onKeepCurrent;
5055

5156
@override
5257
Widget build(BuildContext context) {
@@ -79,6 +84,7 @@ class RecoveryPanel extends StatelessWidget {
7984
l10n.recoveryCurrentText,
8085
style: theme.textTheme.titleMedium,
8186
),
87+
if (currentName != null) SelectableText(currentName!),
8288
Container(
8389
constraints: const BoxConstraints(maxHeight: 160),
8490
padding: const EdgeInsets.all(12),
@@ -178,6 +184,12 @@ class RecoveryPanel extends StatelessWidget {
178184
},
179185
child: Text(l10n.recoveryDiscard),
180186
),
187+
if (onKeepCurrent != null)
188+
OutlinedButton(
189+
key: kRecoveryKeepCurrentKey,
190+
onPressed: busy ? null : onKeepCurrent,
191+
child: Text(l10n.recoveryKeepCurrent),
192+
),
181193
if (onContinue != null)
182194
FilledButton(
183195
onPressed: busy ? null : onContinue,

‎app/lib/localization/arb/app_en.arb‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2577,5 +2577,11 @@
25772577
"@runSourceSessionChanged": {
25782578
"description": "Truthful partial result when a board attachment changes after PUT but before Run.",
25792579
"placeholders": {"path": {"type": "String", "example": "/main.py"}}
2580-
}
2580+
},
2581+
"recoveryVolatileShort": "Recovery off: edits stay in memory.",
2582+
"@recoveryVolatileShort": {"description": "Concise persistent volatile warning for compact layouts."},
2583+
"recoveryClose": "Close",
2584+
"@recoveryClose": {"description": "Closes local recovery details without changing storage or text."},
2585+
"recoveryKeepCurrent": "Keep current text without recovery",
2586+
"@recoveryKeepCurrent": {"description": "Keeps the current volatile text and existing stored copy after retry, with no replacement."}
25812587
}

‎app/lib/localization/gen/app_localizations.dart‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3266,6 +3266,24 @@ abstract class AppLocalizations {
32663266
/// In en, this message translates to:
32673267
/// **'The upload to {path} may have completed on the previous board. Run was not sent because the connection changed.'**
32683268
String runSourceSessionChanged(String path);
3269+
3270+
/// Concise persistent volatile warning for compact layouts.
3271+
///
3272+
/// In en, this message translates to:
3273+
/// **'Recovery off: edits stay in memory.'**
3274+
String get recoveryVolatileShort;
3275+
3276+
/// Closes local recovery details without changing storage or text.
3277+
///
3278+
/// In en, this message translates to:
3279+
/// **'Close'**
3280+
String get recoveryClose;
3281+
3282+
/// Keeps the current volatile text and existing stored copy after retry, with no replacement.
3283+
///
3284+
/// In en, this message translates to:
3285+
/// **'Keep current text without recovery'**
3286+
String get recoveryKeepCurrent;
32693287
}
32703288

32713289
class _AppLocalizationsDelegate

‎app/lib/localization/gen/app_localizations_en.dart‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2008,4 +2008,13 @@ class AppLocalizationsEn extends AppLocalizations {
20082008
String runSourceSessionChanged(String path) {
20092009
return 'The upload to $path may have completed on the previous board. Run was not sent because the connection changed.';
20102010
}
2011+
2012+
@override
2013+
String get recoveryVolatileShort => 'Recovery off: edits stay in memory.';
2014+
2015+
@override
2016+
String get recoveryClose => 'Close';
2017+
2018+
@override
2019+
String get recoveryKeepCurrent => 'Keep current text without recovery';
20112020
}

‎app/test/widget/recovery_gate_test.dart‎

Lines changed: 63 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@ void main() {
121121
await tester.tap(find.byKey(kRecoveryContinueKey));
122122
await tester.pump();
123123
expect(find.text('connection workflow mounted'), findsOneWidget);
124-
expect(find.textContaining('Recovery is off'), findsOneWidget);
124+
expect(find.textContaining('Recovery off:'), findsOneWidget);
125125
containerOf(
126126
tester,
127127
).read(editorDocumentProvider.notifier).setContent('live text');
@@ -158,6 +158,17 @@ void main() {
158158
containerOf(tester).read(editorDocumentProvider).content,
159159
'live text',
160160
);
161+
await tester.tap(find.byKey(kRecoveryKeepCurrentKey));
162+
await tester.pump();
163+
expect(
164+
containerOf(tester).read(editorDocumentProvider).content,
165+
'live text',
166+
);
167+
expect(store.slot.snapshot!.content, 'prior text');
168+
expect(
169+
containerOf(tester).read(draftRecoveryProvider).status,
170+
DraftRecoveryStatus.volatile,
171+
);
161172
},
162173
);
163174
testWidgets('local acknowledgement stays distinct from board dirty state', (
@@ -228,7 +239,12 @@ void main() {
228239
await tester.pumpAndSettle();
229240
await tester.tap(find.text('Replace draft'));
230241
await tester.pumpAndSettle();
231-
await tester.tap(find.text('Continue without recovery'));
242+
await tester.tap(
243+
find.descendant(
244+
of: find.byType(AlertDialog),
245+
matching: find.text('Continue without recovery'),
246+
),
247+
);
232248
await tester.pumpAndSettle();
233249
expect(containerOf(tester).read(editorDocumentProvider).content, isEmpty);
234250
expect(store.slot.snapshot!.content, 'prior text');
@@ -308,6 +324,16 @@ void main() {
308324
expect(find.text('Your current text draft'), findsOneWidget);
309325
expect(find.byType(ConnectScreen), findsNothing);
310326
expect(scanner.scanStartCount, 0);
327+
final Future<bool> answer = confirmRecoveryBoardTarget(
328+
appNavigatorKey.currentContext!,
329+
path: '/main.py',
330+
run: true,
331+
);
332+
await tester.pumpAndSettle();
333+
expect(find.byType(AlertDialog), findsOneWidget);
334+
await tester.tap(find.text('Cancel'));
335+
await tester.pumpAndSettle();
336+
expect(await answer, isFalse);
311337
expect(tester.takeException(), isNull);
312338
},
313339
);
@@ -439,7 +465,41 @@ void main() {
439465
).read(draftRecoveryProvider.notifier).continueWithoutRecovery();
440466
await tester.pump();
441467
expect(tester.takeException(), isNull);
442-
expect(tester.getSize(find.byType(EditableText)).height, greaterThan(80));
468+
expect(
469+
tester
470+
.getSize(find.byKey(const ValueKey<String>('editorRichSurface')))
471+
.height,
472+
greaterThan(80),
473+
);
474+
},
475+
);
476+
testWidgets(
477+
'retry can keep an empty named live draft without deleting stored text',
478+
(WidgetTester tester) async {
479+
final _Store store = _Store()..delayed = Completer<DraftRead>();
480+
await _pump(tester, store);
481+
await tester.pump(const Duration(seconds: 10));
482+
await tester.tap(find.byKey(kRecoveryContinueKey));
483+
await tester.pump();
484+
containerOf(tester)
485+
.read(editorDocumentProvider.notifier)
486+
.openGenerated(name: 'empty.py', content: '');
487+
store.delayed!.complete(store.slot);
488+
await tester.pump();
489+
store.delayed = null;
490+
await tester.tap(find.byKey(kRecoveryRetryKey));
491+
await tester.pump();
492+
await tester.pump();
493+
expect(find.text('empty.py'), findsOneWidget);
494+
expect(find.text('prior text'), findsOneWidget);
495+
await tester.tap(find.byKey(kRecoveryKeepCurrentKey));
496+
await tester.pump();
497+
final EditorDocument document = containerOf(
498+
tester,
499+
).read(editorDocumentProvider);
500+
expect(document.name, 'empty.py');
501+
expect(document.content, isEmpty);
502+
expect(store.slot.snapshot!.content, 'prior text');
443503
},
444504
);
445505
}

0 commit comments

Comments
 (0)