Skip to content

Commit 9772e21

Browse files
committed
feat: improve console controls and navigation
1 parent 402627c commit 9772e21

25 files changed

Lines changed: 1720 additions & 471 deletions

lib/controllers/connection_controller.dart

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import 'package:admincraft/models/model.dart';
66
import 'package:admincraft/services/connection_failure.dart';
77
import 'package:admincraft/services/connection_platform_capabilities.dart';
88
import 'package:admincraft/services/connection_service.dart';
9+
import 'package:admincraft/services/console_output_formatter.dart';
910
import 'package:admincraft/services/retry_policy.dart';
1011
import 'package:admincraft/utils/toast_utils.dart';
1112
import 'package:flutter/material.dart';
@@ -314,6 +315,10 @@ class ConnectionController with ChangeNotifier, WidgetsBindingObserver {
314315
String command, {
315316
String source = 'terminal',
316317
}) async {
318+
// User commands carry a private marker while stored in the transcript.
319+
// Never allow that presentation metadata to cross the command boundary,
320+
// even if a saved transcript row or pasted value reaches this method.
321+
command = ConsoleOutputFormatter.stripUserCommandMarker(command);
317322
final bridgeDiagnostic = command.toLowerCase().startsWith('admincraft ');
318323
if (!bridgeDiagnostic &&
319324
!model.connectionSecurity.isDirectRcon &&
@@ -330,7 +335,7 @@ class ConnectionController with ChangeNotifier, WidgetsBindingObserver {
330335
}
331336
await model.addUserCommand(command);
332337
await model.recordCommandUsage(command);
333-
model.appendOutputCommand(command);
338+
model.appendOutputCommand(command, isUserCommand: true);
334339
final sent = connectionService.executeCommand(command);
335340
await model.recordCommandAudit(
336341
command,

lib/data/minecraft_commands.dart

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -26,17 +26,9 @@ class MinecraftCommands {
2626
.toList();
2727
return BedrockCommand(
2828
'admincraft',
29-
'Inspect and manage the Admincraft bridge and Minecraft server',
29+
'Inspect and manage the bridge; logs accepts an optional line count',
3030
'Server',
31-
args: [
32-
CommandArg('action', ArgType.literal, options: actions),
33-
const CommandArg(
34-
'count',
35-
ArgType.number,
36-
required: false,
37-
options: ['50', '100', '250', '500', '1000'],
38-
),
39-
],
31+
args: [CommandArg('action', ArgType.literal, options: actions)],
4032
);
4133
}
4234

lib/models/model.dart

Lines changed: 97 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import 'package:admincraft/models/minecraft_edition.dart';
77
import 'package:admincraft/models/server_profile.dart';
88
import 'package:admincraft/models/world_state.dart';
99
import 'package:admincraft/services/console_parser.dart';
10+
import 'package:admincraft/services/console_output_formatter.dart';
1011
import 'package:admincraft/services/android_widget_service.dart';
1112
import 'package:admincraft/services/persistence_service.dart';
1213
import 'package:flutter/material.dart';
@@ -16,6 +17,8 @@ class Model with ChangeNotifier {
1617
String _output = '';
1718
final Set<String> _seenConsoleEventIds = {};
1819
bool _consoleHistoryLoading = false;
20+
int _gameruleRefreshDepth = 0;
21+
bool _gameruleRefreshDirty = false;
1922
List<CommandAuditEntry> _commandAudit = [];
2023
final Set<String> _bridgeCapabilities = {};
2124
int? _bridgeProtocol;
@@ -130,13 +133,15 @@ class Model with ChangeNotifier {
130133
int? playersOnline,
131134
int? playerLimit,
132135
Iterable<String>? onlinePlayers,
136+
String? difficulty,
133137
}) {
134138
_serverRuntimeState = state;
135139
_lastServerStateAt = observedAt ?? DateTime.now();
136140
_world = _world.copyWith(
137141
daytime: daytime,
138142
playersOnline: playersOnline,
139143
playerLimit: playerLimit,
144+
lastDifficulty: difficulty,
140145
);
141146
if (onlinePlayers != null) {
142147
_onlinePlayers
@@ -166,9 +171,40 @@ class Model with ChangeNotifier {
166171
void completeConsoleHistoryLoad() {
167172
if (!_consoleHistoryLoading) return;
168173
_consoleHistoryLoading = false;
174+
_gameruleRefreshDirty = false;
169175
notifyListeners();
170176
}
171177

178+
/// Keeps automatic gamerule discovery out of the user-facing console and
179+
/// coalesces its many state updates into one rebuild at the end.
180+
void beginGameruleRefresh() {
181+
_gameruleRefreshDepth++;
182+
if (_gameruleRefreshDepth != 1) return;
183+
184+
final lines = _output.split('\n');
185+
final cleaned = lines
186+
.where(
187+
(line) =>
188+
!ConsoleParser.isGameruleReply(line, edition: minecraftEdition),
189+
)
190+
.join('\n');
191+
if (cleaned == _output) return;
192+
_output = cleaned;
193+
_gameruleRefreshDirty = true;
194+
unawaited(
195+
_persistenceService.saveConsoleOutput(_selectedServerId, _output),
196+
);
197+
}
198+
199+
void completeGameruleRefresh() {
200+
if (_gameruleRefreshDepth == 0) return;
201+
_gameruleRefreshDepth--;
202+
if (_gameruleRefreshDepth == 0 && _gameruleRefreshDirty) {
203+
_gameruleRefreshDirty = false;
204+
if (!_consoleHistoryLoading) notifyListeners();
205+
}
206+
}
207+
172208
List<ServerProfile> get servers => List.unmodifiable(_servers);
173209
DateTime? get serversUpdatedAt => _persistenceService.serversUpdatedAt;
174210
int get serverRevision => _serverRevision;
@@ -207,6 +243,7 @@ class Model with ChangeNotifier {
207243
String get consoleTimestampMode => _persistenceService.consoleTimestampMode;
208244
String get consoleFilterPattern => _persistenceService.consoleFilterPattern;
209245
bool get hideCommonConsoleNoise => _persistenceService.hideCommonConsoleNoise;
246+
String get workspaceDestination => _persistenceService.workspaceDestination;
210247

211248
// Provide read-only access to collections
212249
Set<String> get userCommands =>
@@ -271,14 +308,25 @@ class Model with ChangeNotifier {
271308
return created;
272309
}
273310

274-
/// Removes a server. The last one is kept: with none left there would be
275-
/// nothing for the connection getters to read.
311+
/// Removes a server. The model keeps an internal blank profile when the last
312+
/// real server is removed so connection getters remain safe while the UI
313+
/// returns to onboarding.
276314
Future<void> deleteServer(String id) async {
277-
if (_servers.length <= 1) return;
278-
_servers = _servers.where((server) => server.id != id).toList();
315+
if (!_servers.any((server) => server.id == id)) return;
279316
await _persistenceService.forgetServerSecrets(id);
280317
await _persistenceService.forgetConsoleOutput(id);
281-
if (_selectedServerId == id) {
318+
319+
if (_servers.length == 1) {
320+
final blank = ServerProfile.empty(_newId());
321+
_servers = [blank];
322+
_selectedServerId = blank.id;
323+
_resetSession();
324+
await _persistenceService.saveSelectedServerId(_selectedServerId);
325+
await _persistenceService.resetOnboarding();
326+
} else {
327+
_servers = _servers.where((server) => server.id != id).toList();
328+
}
329+
if (!_servers.any((server) => server.id == _selectedServerId)) {
282330
_selectedServerId = _servers.first.id;
283331
_resetSession();
284332
await _persistenceService.saveSelectedServerId(_selectedServerId);
@@ -453,6 +501,13 @@ class Model with ChangeNotifier {
453501
notifyListeners();
454502
}
455503

504+
/// Applies a time selected in Controls immediately. The server's later
505+
/// state event remains authoritative and will correct this if necessary.
506+
void recordDaytime(int daytime) {
507+
_world = _world.copyWith(daytime: daytime % 24000);
508+
notifyListeners();
509+
}
510+
456511
/// Resets live state when the selected server changes. Each profile keeps
457512
/// its own transcript, so locally echoed commands do not disappear between
458513
/// app launches or server switches.
@@ -560,6 +615,7 @@ class Model with ChangeNotifier {
560615
String command, {
561616
bool visible = true,
562617
String? eventId,
618+
bool isUserCommand = false,
563619
}) {
564620
if (eventId != null && !_seenConsoleEventIds.add(eventId)) return;
565621
while (_seenConsoleEventIds.length > 2000) {
@@ -573,8 +629,15 @@ class Model with ChangeNotifier {
573629
_world.playerLimit != previousLimit) {
574630
unawaited(AndroidWidgetService.update(selectedServer, _world));
575631
}
576-
if (visible) {
577-
_output += "$command\n";
632+
final showInConsole =
633+
visible &&
634+
!(_gameruleRefreshDepth > 0 &&
635+
ConsoleParser.isGameruleReply(command, edition: minecraftEdition));
636+
if (showInConsole) {
637+
final stored = isUserCommand
638+
? ConsoleOutputFormatter.markUserCommand(command)
639+
: command;
640+
_output += "$stored\n";
578641
final lines = _output.split('\n');
579642
if (lines.length > _persistenceService.maxOutLines) {
580643
_output = lines
@@ -593,7 +656,15 @@ class Model with ChangeNotifier {
593656
),
594657
);
595658
}
596-
notifyListeners();
659+
// Protocol-v2 history can contain hundreds of lines. Rebuilding the
660+
// terminal for every one paints it repeatedly at intermediate heights and
661+
// produces the visible top-to-bottom bounce. The completion event emits
662+
// one notification after the bounded snapshot has arrived.
663+
if (_consoleHistoryLoading || _gameruleRefreshDepth > 0) {
664+
_gameruleRefreshDirty = true;
665+
} else {
666+
notifyListeners();
667+
}
597668
}
598669

599670
Future<void> addCommandToHistory(String command) async {
@@ -656,6 +727,9 @@ class Model with ChangeNotifier {
656727
);
657728
}
658729

730+
Future<void> setWorkspaceDestination(String value) =>
731+
_persistenceService.saveWorkspaceDestination(value);
732+
659733
Future<void> addFavoriteCommand(String command) async {
660734
final normalized = command.trim();
661735
if (normalized.isEmpty || favoriteCommands.contains(normalized)) return;
@@ -674,4 +748,19 @@ class Model with ChangeNotifier {
674748
),
675749
);
676750
}
751+
752+
Future<void> updateFavoriteCommand(String previous, String command) async {
753+
final normalized = command.trim();
754+
if (normalized.isEmpty) return;
755+
final favorites = favoriteCommands.toList();
756+
final index = favorites.indexOf(previous);
757+
if (index < 0) return;
758+
if (favorites.any((value) => value == normalized && value != previous)) {
759+
return;
760+
}
761+
favorites[index] = normalized;
762+
await _updatePersistenceService(
763+
() => _persistenceService.saveFavoriteCommands(favorites),
764+
);
765+
}
677766
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export 'browser_unload_guard_stub.dart'
2+
if (dart.library.js_interop) 'browser_unload_guard_web.dart';
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
void setBrowserUnloadGuard(bool enabled) {}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import 'dart:js_interop';
2+
3+
import 'package:web/web.dart' as web;
4+
5+
web.EventListener? _beforeUnloadListener;
6+
7+
/// Uses the browser's own leave-page confirmation because a Flutter dialog
8+
/// cannot run after a tab reload or close has already started.
9+
void setBrowserUnloadGuard(bool enabled) {
10+
if (enabled && _beforeUnloadListener == null) {
11+
void warnBeforeUnload(web.Event event) {
12+
event.preventDefault();
13+
(event as web.BeforeUnloadEvent).returnValue = '';
14+
}
15+
16+
_beforeUnloadListener = warnBeforeUnload.toJS;
17+
web.window.addEventListener('beforeunload', _beforeUnloadListener);
18+
return;
19+
}
20+
21+
if (!enabled && _beforeUnloadListener != null) {
22+
web.window.removeEventListener('beforeunload', _beforeUnloadListener);
23+
_beforeUnloadListener = null;
24+
}
25+
}

lib/services/connection_service.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,7 @@ class ConnectionService {
238238
(player) => player.toString(),
239239
)
240240
: null,
241+
difficulty: decoded['difficulty']?.toString(),
241242
);
242243
}
243244
return;

lib/services/console_output_formatter.dart

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,18 @@ import 'package:admincraft/services/console_parser.dart';
33
/// Applies the user's console presentation settings consistently everywhere
44
/// server output is shown.
55
class ConsoleOutputFormatter {
6+
/// Stored before locally echoed commands so identical text returned by the
7+
/// server is not mistaken for something the user typed. A private-use
8+
/// character survives persistence but is removed before anything renders.
9+
static const String userCommandMarker = '\uE000';
10+
11+
static String markUserCommand(String line) => '$userCommandMarker$line';
12+
13+
static bool isUserCommand(String line) => line.startsWith(userCommandMarker);
14+
15+
static String stripUserCommandMarker(String line) =>
16+
isUserCommand(line) ? line.substring(userCommandMarker.length) : line;
17+
618
/// Removes terminal decoration emitted by container supervisors and shell
719
/// wrappers before text reaches Flutter. Minecraft's output is plain text,
820
/// so rendering raw ANSI escape bytes only exposes fragments such as
@@ -28,9 +40,10 @@ class ConsoleOutputFormatter {
2840

2941
static bool isCommonNoise(String line) {
3042
final message = ConsoleParser.stripPrefix(
31-
sanitize(line),
43+
stripUserCommandMarker(sanitize(line)),
3244
).trim().toLowerCase();
3345
return commonNoiseFragments.any(message.contains) ||
46+
ConsoleParser.isGameruleReply(message) ||
3447
RegExp(r'^daytime is \d+$').hasMatch(message) ||
3548
RegExp(r'^the time is \d+$').hasMatch(message) ||
3649
RegExp(
@@ -52,7 +65,7 @@ class ConsoleOutputFormatter {
5265
}
5366

5467
static String formatLine(String line, String timestampMode) {
55-
line = sanitize(line);
68+
line = stripUserCommandMarker(sanitize(line));
5669
if (timestampMode == 'hidden') return ConsoleParser.stripPrefix(line);
5770
if (timestampMode != 'short') return line;
5871

lib/services/console_parser.dart

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,15 @@ class ConsoleParser {
5454
return line;
5555
}
5656

57+
static bool isGameruleReply(
58+
String line, {
59+
MinecraftEdition edition = MinecraftEdition.bedrock,
60+
}) {
61+
final normalized = stripPrefix(line).trim();
62+
return (edition == MinecraftEdition.java ? _javaGamerule : _gamerule)
63+
.hasMatch(normalized);
64+
}
65+
5766
/// Applies everything [chunk] says about the world to [state].
5867
static WorldState apply(
5968
WorldState state,

lib/services/persistence_service.dart

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ class PersistenceService {
3434
static const _consoleTimestampModeKey = 'consoleTimestampMode';
3535
static const _consoleFilterPatternKey = 'consoleFilterPattern';
3636
static const _hideCommonConsoleNoiseKey = 'hideCommonConsoleNoise';
37+
static const _workspaceDestinationKey = 'workspaceDestination';
3738
static const _consoleOutputKeyPrefix = 'consoleOutput.';
3839
static const _consoleEventIdsKeyPrefix = 'consoleEventIds.';
3940
static const _commandAuditKeyPrefix = 'commandAudit.';
@@ -284,6 +285,10 @@ class PersistenceService {
284285
await _set(_onboardedKey, true);
285286
}
286287

288+
Future<void> resetOnboarding() async {
289+
await _set(_onboardedKey, false);
290+
}
291+
287292
String? get selectedServerId => _prefs.getString(_selectedServerKey);
288293

289294
Future<void> saveSelectedServerId(String id) async {
@@ -312,6 +317,8 @@ class PersistenceService {
312317
_prefs.getString(_consoleFilterPatternKey) ?? '';
313318
bool get hideCommonConsoleNoise =>
314319
_prefs.getBool(_hideCommonConsoleNoiseKey) ?? true;
320+
String get workspaceDestination =>
321+
_prefs.getString(_workspaceDestinationKey) ?? 'overview';
315322

316323
Future<void> saveTerminalFont(String value) => _set(_terminalFontKey, value);
317324
Future<void> saveTerminalFontSize(double value) =>
@@ -324,6 +331,8 @@ class PersistenceService {
324331
_set(_consoleFilterPatternKey, value);
325332
Future<void> saveHideCommonConsoleNoise(bool value) =>
326333
_set(_hideCommonConsoleNoiseKey, value);
334+
Future<void> saveWorkspaceDestination(String value) =>
335+
_set(_workspaceDestinationKey, value);
327336

328337
List<String> get commandHistory =>
329338
_prefs.getStringList(_commandHistoryKey) ?? [];

0 commit comments

Comments
 (0)