neoagent 2.4.1-beta.28 → 2.4.1-beta.29
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/flutter_app/lib/main_controller.dart +102 -0
- package/flutter_app/lib/main_devices.dart +305 -1
- package/flutter_app/lib/main_integrations.dart +254 -7
- package/flutter_app/lib/src/backend_client.dart +35 -0
- package/package.json +1 -1
- package/server/http/routes.js +1 -0
- package/server/public/.last_build_id +1 -1
- package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
- package/server/public/flutter_bootstrap.js +1 -1
- package/server/public/main.dart.js +76175 -75500
- package/server/routes/workspace.js +86 -0
- package/server/services/integrations/home_assistant/constants.js +88 -0
- package/server/services/integrations/home_assistant/network.js +207 -0
- package/server/services/integrations/home_assistant/provider.js +249 -0
- package/server/services/integrations/home_assistant/snapshot.js +98 -0
- package/server/services/integrations/home_assistant/tools.js +101 -0
- package/server/services/integrations/registry.js +2 -0
- package/server/services/workspace/manager.js +116 -0
|
@@ -93,6 +93,8 @@ class NeoAgentController extends ChangeNotifier {
|
|
|
93
93
|
bool isSavingReleaseChannel = false;
|
|
94
94
|
bool isSyncingHealth = false;
|
|
95
95
|
bool isRunningDeviceAction = false;
|
|
96
|
+
bool isLoadingWorkspaceFiles = false;
|
|
97
|
+
bool isSavingWorkspaceFile = false;
|
|
96
98
|
bool isPreparingQrLogin = false;
|
|
97
99
|
bool isApprovingQrLogin = false;
|
|
98
100
|
bool isCheckingAppUpdate = false;
|
|
@@ -190,6 +192,10 @@ class NeoAgentController extends ChangeNotifier {
|
|
|
190
192
|
String? androidLastResult;
|
|
191
193
|
String? desktopLastResult;
|
|
192
194
|
String? androidUiDumpPath;
|
|
195
|
+
String workspaceCurrentPath = '';
|
|
196
|
+
String? workspaceSelectedFilePath;
|
|
197
|
+
String workspaceEditorContent = '';
|
|
198
|
+
List<Map<String, dynamic>> workspaceEntries = const <Map<String, dynamic>>[];
|
|
193
199
|
final Map<String, RunDetailSnapshot> _runDetailsCache =
|
|
194
200
|
<String, RunDetailSnapshot>{};
|
|
195
201
|
String? _selectedWidgetId;
|
|
@@ -3488,6 +3494,102 @@ class NeoAgentController extends ChangeNotifier {
|
|
|
3488
3494
|
);
|
|
3489
3495
|
}
|
|
3490
3496
|
|
|
3497
|
+
String workspaceDownloadUrl(String path) {
|
|
3498
|
+
return '${_socketOrigin()}/${_backendClient.workspaceDownloadPath(path).replaceFirst(RegExp(r'^/'), '')}';
|
|
3499
|
+
}
|
|
3500
|
+
|
|
3501
|
+
Future<void> refreshWorkspaceFiles({String? path}) async {
|
|
3502
|
+
if (!isAuthenticated || isLoadingWorkspaceFiles) {
|
|
3503
|
+
return;
|
|
3504
|
+
}
|
|
3505
|
+
isLoadingWorkspaceFiles = true;
|
|
3506
|
+
errorMessage = null;
|
|
3507
|
+
notifyListeners();
|
|
3508
|
+
try {
|
|
3509
|
+
final response = await _backendClient.fetchWorkspaceDirectory(
|
|
3510
|
+
backendUrl,
|
|
3511
|
+
path: path ?? workspaceCurrentPath,
|
|
3512
|
+
);
|
|
3513
|
+
workspaceCurrentPath = response['path']?.toString() ?? '';
|
|
3514
|
+
workspaceEntries = _jsonMapList(
|
|
3515
|
+
response['entries'],
|
|
3516
|
+
fallbackToMapValues: true,
|
|
3517
|
+
);
|
|
3518
|
+
} catch (error) {
|
|
3519
|
+
errorMessage = _friendlyErrorMessage(error);
|
|
3520
|
+
} finally {
|
|
3521
|
+
isLoadingWorkspaceFiles = false;
|
|
3522
|
+
notifyListeners();
|
|
3523
|
+
}
|
|
3524
|
+
}
|
|
3525
|
+
|
|
3526
|
+
Future<void> openWorkspaceDirectory(String path) async {
|
|
3527
|
+
workspaceSelectedFilePath = null;
|
|
3528
|
+
workspaceEditorContent = '';
|
|
3529
|
+
await refreshWorkspaceFiles(path: path);
|
|
3530
|
+
}
|
|
3531
|
+
|
|
3532
|
+
Future<void> openWorkspaceFile(String path) async {
|
|
3533
|
+
if (isLoadingWorkspaceFiles) {
|
|
3534
|
+
return;
|
|
3535
|
+
}
|
|
3536
|
+
isLoadingWorkspaceFiles = true;
|
|
3537
|
+
errorMessage = null;
|
|
3538
|
+
notifyListeners();
|
|
3539
|
+
try {
|
|
3540
|
+
final response = await _backendClient.fetchWorkspaceFile(
|
|
3541
|
+
backendUrl,
|
|
3542
|
+
path: path,
|
|
3543
|
+
);
|
|
3544
|
+
workspaceSelectedFilePath = response['path']?.toString() ?? path;
|
|
3545
|
+
workspaceEditorContent = response['content']?.toString() ?? '';
|
|
3546
|
+
} catch (error) {
|
|
3547
|
+
errorMessage = _friendlyErrorMessage(error);
|
|
3548
|
+
} finally {
|
|
3549
|
+
isLoadingWorkspaceFiles = false;
|
|
3550
|
+
notifyListeners();
|
|
3551
|
+
}
|
|
3552
|
+
}
|
|
3553
|
+
|
|
3554
|
+
Future<void> saveWorkspaceFile(String content) async {
|
|
3555
|
+
final path = workspaceSelectedFilePath?.trim() ?? '';
|
|
3556
|
+
if (path.isEmpty || isSavingWorkspaceFile) {
|
|
3557
|
+
return;
|
|
3558
|
+
}
|
|
3559
|
+
isSavingWorkspaceFile = true;
|
|
3560
|
+
errorMessage = null;
|
|
3561
|
+
notifyListeners();
|
|
3562
|
+
try {
|
|
3563
|
+
await _backendClient.saveWorkspaceFile(
|
|
3564
|
+
backendUrl,
|
|
3565
|
+
path: path,
|
|
3566
|
+
content: content,
|
|
3567
|
+
);
|
|
3568
|
+
workspaceEditorContent = content;
|
|
3569
|
+
await refreshWorkspaceFiles(path: workspaceCurrentPath);
|
|
3570
|
+
} catch (error) {
|
|
3571
|
+
errorMessage = _friendlyErrorMessage(error);
|
|
3572
|
+
} finally {
|
|
3573
|
+
isSavingWorkspaceFile = false;
|
|
3574
|
+
notifyListeners();
|
|
3575
|
+
}
|
|
3576
|
+
}
|
|
3577
|
+
|
|
3578
|
+
Future<void> downloadWorkspaceFile(String path) async {
|
|
3579
|
+
final normalized = path.trim();
|
|
3580
|
+
if (normalized.isEmpty) {
|
|
3581
|
+
return;
|
|
3582
|
+
}
|
|
3583
|
+
final result = await _oauthLauncher.openExternal(
|
|
3584
|
+
url: workspaceDownloadUrl(normalized),
|
|
3585
|
+
label: 'neoagent_workspace_file_download',
|
|
3586
|
+
);
|
|
3587
|
+
if (!result.launched) {
|
|
3588
|
+
errorMessage = result.error ?? 'Could not open workspace file download.';
|
|
3589
|
+
notifyListeners();
|
|
3590
|
+
}
|
|
3591
|
+
}
|
|
3592
|
+
|
|
3491
3593
|
Uri resolveRuntimeAsset(String path) {
|
|
3492
3594
|
final separator = path.contains('?') ? '&' : '?';
|
|
3493
3595
|
return _backendClient.resolveAssetUri(
|
|
@@ -28,6 +28,7 @@ class _DevicesPanelState extends State<DevicesPanel> {
|
|
|
28
28
|
late final TextEditingController _androidLaunchController;
|
|
29
29
|
late final TextEditingController _desktopLaunchController;
|
|
30
30
|
late final TextEditingController _textEntryController;
|
|
31
|
+
late final TextEditingController _workspaceEditorController;
|
|
31
32
|
Timer? _surfaceFrameTimer;
|
|
32
33
|
_DeviceSurface _surface = _DeviceSurface.browser;
|
|
33
34
|
_DeviceSurface? _runningSurface;
|
|
@@ -41,6 +42,7 @@ class _DevicesPanelState extends State<DevicesPanel> {
|
|
|
41
42
|
);
|
|
42
43
|
_desktopLaunchController = TextEditingController();
|
|
43
44
|
_textEntryController = TextEditingController();
|
|
45
|
+
_workspaceEditorController = TextEditingController();
|
|
44
46
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
45
47
|
if (!mounted) {
|
|
46
48
|
return;
|
|
@@ -59,6 +61,7 @@ class _DevicesPanelState extends State<DevicesPanel> {
|
|
|
59
61
|
_androidLaunchController,
|
|
60
62
|
_desktopLaunchController,
|
|
61
63
|
_textEntryController,
|
|
64
|
+
_workspaceEditorController,
|
|
62
65
|
]) {
|
|
63
66
|
controller.dispose();
|
|
64
67
|
}
|
|
@@ -68,6 +71,7 @@ class _DevicesPanelState extends State<DevicesPanel> {
|
|
|
68
71
|
|
|
69
72
|
bool get _isBrowser => _surface == _DeviceSurface.browser;
|
|
70
73
|
bool get _isDesktop => _surface == _DeviceSurface.desktop;
|
|
74
|
+
bool get _isFiles => _surface == _DeviceSurface.files;
|
|
71
75
|
|
|
72
76
|
bool get _isCurrentSurfaceBusy =>
|
|
73
77
|
widget.controller.isRunningDeviceAction &&
|
|
@@ -182,6 +186,13 @@ class _DevicesPanelState extends State<DevicesPanel> {
|
|
|
182
186
|
return;
|
|
183
187
|
}
|
|
184
188
|
|
|
189
|
+
if (_isFiles) {
|
|
190
|
+
if (controller.workspaceEntries.isEmpty) {
|
|
191
|
+
await controller.refreshWorkspaceFiles();
|
|
192
|
+
}
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
|
|
185
196
|
if (_androidOnline && (controller.androidScreenshotPath ?? '').isEmpty) {
|
|
186
197
|
await controller.screenshotAndroidRuntime();
|
|
187
198
|
}
|
|
@@ -203,6 +214,9 @@ class _DevicesPanelState extends State<DevicesPanel> {
|
|
|
203
214
|
await widget.controller.refreshDesktopFrameRuntime();
|
|
204
215
|
return;
|
|
205
216
|
}
|
|
217
|
+
if (_isFiles) {
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
206
220
|
if (_androidStarting) {
|
|
207
221
|
await widget.controller.refreshDevices();
|
|
208
222
|
if (_androidOnline &&
|
|
@@ -260,6 +274,11 @@ class _DevicesPanelState extends State<DevicesPanel> {
|
|
|
260
274
|
return;
|
|
261
275
|
}
|
|
262
276
|
|
|
277
|
+
if (_isFiles) {
|
|
278
|
+
await controller.refreshWorkspaceFiles();
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
|
|
263
282
|
if (!_androidOnline) {
|
|
264
283
|
await controller.startAndroidRuntime();
|
|
265
284
|
await widget.controller.refreshDevices();
|
|
@@ -301,6 +320,9 @@ class _DevicesPanelState extends State<DevicesPanel> {
|
|
|
301
320
|
}
|
|
302
321
|
return;
|
|
303
322
|
}
|
|
323
|
+
if (_isFiles) {
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
304
326
|
if (!_androidOnline) {
|
|
305
327
|
return;
|
|
306
328
|
}
|
|
@@ -316,6 +338,8 @@ class _DevicesPanelState extends State<DevicesPanel> {
|
|
|
316
338
|
await widget.controller.typeBrowserTextRuntime(text, pressEnter: true);
|
|
317
339
|
} else if (_isDesktop) {
|
|
318
340
|
await widget.controller.typeDesktopRuntime(text, pressEnter: true);
|
|
341
|
+
} else if (_isFiles) {
|
|
342
|
+
return;
|
|
319
343
|
} else {
|
|
320
344
|
await widget.controller.typeAndroidRuntime(<String, dynamic>{
|
|
321
345
|
'text': text,
|
|
@@ -515,6 +539,18 @@ class _DevicesPanelState extends State<DevicesPanel> {
|
|
|
515
539
|
browserExtensionActive: usingExtension,
|
|
516
540
|
browserFallbackLabel: browserFallbackLabel,
|
|
517
541
|
),
|
|
542
|
+
if (_isFiles) ...<Widget>[
|
|
543
|
+
const SizedBox(height: 14),
|
|
544
|
+
_WorkspaceExplorer(
|
|
545
|
+
controller: controller,
|
|
546
|
+
editorController: _workspaceEditorController,
|
|
547
|
+
),
|
|
548
|
+
const SizedBox(height: 14),
|
|
549
|
+
_SurfaceSwitcher(
|
|
550
|
+
surface: _surface,
|
|
551
|
+
onSelect: _selectSurface,
|
|
552
|
+
),
|
|
553
|
+
] else ...<Widget>[
|
|
518
554
|
if (_isBrowser && prefersExtension) ...<Widget>[
|
|
519
555
|
const SizedBox(height: 14),
|
|
520
556
|
if (_browserExtensionDevices.isNotEmpty) ...<Widget>[
|
|
@@ -742,6 +778,7 @@ class _DevicesPanelState extends State<DevicesPanel> {
|
|
|
742
778
|
surface: _surface,
|
|
743
779
|
onSelect: _selectSurface,
|
|
744
780
|
),
|
|
781
|
+
],
|
|
745
782
|
],
|
|
746
783
|
),
|
|
747
784
|
),
|
|
@@ -753,13 +790,14 @@ class _DevicesPanelState extends State<DevicesPanel> {
|
|
|
753
790
|
}
|
|
754
791
|
}
|
|
755
792
|
|
|
756
|
-
enum _DeviceSurface { browser, android, desktop }
|
|
793
|
+
enum _DeviceSurface { browser, android, desktop, files }
|
|
757
794
|
|
|
758
795
|
extension _DeviceSurfaceX on _DeviceSurface {
|
|
759
796
|
String get label => switch (this) {
|
|
760
797
|
_DeviceSurface.browser => 'Browser',
|
|
761
798
|
_DeviceSurface.android => 'Phone',
|
|
762
799
|
_DeviceSurface.desktop => 'Desktop',
|
|
800
|
+
_DeviceSurface.files => 'Files',
|
|
763
801
|
};
|
|
764
802
|
|
|
765
803
|
String get helper => switch (this) {
|
|
@@ -767,12 +805,14 @@ extension _DeviceSurfaceX on _DeviceSurface {
|
|
|
767
805
|
_DeviceSurface.android => 'Tap to touch. Drag to swipe.',
|
|
768
806
|
_DeviceSurface.desktop =>
|
|
769
807
|
'Tap to click. Drag to drag windows or selections.',
|
|
808
|
+
_DeviceSurface.files => 'Browse files created by the agent.',
|
|
770
809
|
};
|
|
771
810
|
|
|
772
811
|
IconData get icon => switch (this) {
|
|
773
812
|
_DeviceSurface.browser => Icons.language_outlined,
|
|
774
813
|
_DeviceSurface.android => Icons.smartphone_outlined,
|
|
775
814
|
_DeviceSurface.desktop => Icons.computer_outlined,
|
|
815
|
+
_DeviceSurface.files => Icons.folder_outlined,
|
|
776
816
|
};
|
|
777
817
|
}
|
|
778
818
|
|
|
@@ -848,6 +888,7 @@ class _DeviceSurfaceHeader extends StatelessWidget {
|
|
|
848
888
|
selectedDesktop?['label']?.toString().trim().isNotEmpty == true
|
|
849
889
|
? selectedDesktop!['label'].toString()
|
|
850
890
|
: 'Desktop Companion',
|
|
891
|
+
_DeviceSurface.files => 'File Explorer',
|
|
851
892
|
};
|
|
852
893
|
final subtitle = switch (surface) {
|
|
853
894
|
_DeviceSurface.browser =>
|
|
@@ -889,6 +930,8 @@ class _DeviceSurfaceHeader extends StatelessWidget {
|
|
|
889
930
|
? 'One desktop companion is online. Open the surface to fetch the latest frame.'
|
|
890
931
|
: 'No desktop companion is online. Enable Companion Mode on a signed-in desktop app.'
|
|
891
932
|
: '${selectedDesktop['platform'] ?? 'desktop'} · ${selectedDesktop['hostname'] ?? 'unknown host'}',
|
|
933
|
+
_DeviceSurface.files =>
|
|
934
|
+
'Secure per-user workspace inside the isolated VM.',
|
|
892
935
|
};
|
|
893
936
|
final statusLabel = surface == _DeviceSurface.browser
|
|
894
937
|
? browserExtensionPreferred && selectedExtension == null
|
|
@@ -906,6 +949,8 @@ class _DeviceSurfaceHeader extends StatelessWidget {
|
|
|
906
949
|
: (selectedDesktop['online'] == true
|
|
907
950
|
? 'Live'
|
|
908
951
|
: 'Offline'))
|
|
952
|
+
: surface == _DeviceSurface.files
|
|
953
|
+
? 'Isolated'
|
|
909
954
|
: (androidOnline
|
|
910
955
|
? 'Live'
|
|
911
956
|
: androidStarting
|
|
@@ -917,6 +962,8 @@ class _DeviceSurfaceHeader extends StatelessWidget {
|
|
|
917
962
|
? (selectedDesktop?['paused'] == true
|
|
918
963
|
? _warning
|
|
919
964
|
: (selectedDesktop?['online'] == true ? _success : _warning))
|
|
965
|
+
: surface == _DeviceSurface.files
|
|
966
|
+
? _success
|
|
920
967
|
: (androidOnline
|
|
921
968
|
? _success
|
|
922
969
|
: (androidStarting ? _accent : _warning));
|
|
@@ -1023,16 +1070,19 @@ class _DeviceLaunchBar extends StatelessWidget {
|
|
|
1023
1070
|
_DeviceSurface.android => _packageOrUrlHint,
|
|
1024
1071
|
_DeviceSurface.desktop =>
|
|
1025
1072
|
'Launch an app on the selected desktop (optional)',
|
|
1073
|
+
_DeviceSurface.files => 'Workspace path',
|
|
1026
1074
|
};
|
|
1027
1075
|
final buttonLabel = switch (surface) {
|
|
1028
1076
|
_DeviceSurface.browser => 'Open',
|
|
1029
1077
|
_DeviceSurface.android => starting ? 'Starting...' : 'Launch',
|
|
1030
1078
|
_DeviceSurface.desktop => 'Refresh',
|
|
1079
|
+
_DeviceSurface.files => 'Refresh',
|
|
1031
1080
|
};
|
|
1032
1081
|
final sleepLabel = switch (surface) {
|
|
1033
1082
|
_DeviceSurface.browser => 'Sleep Browser',
|
|
1034
1083
|
_DeviceSurface.android => 'Sleep Phone',
|
|
1035
1084
|
_DeviceSurface.desktop => 'Pause Desktop',
|
|
1085
|
+
_DeviceSurface.files => 'Close',
|
|
1036
1086
|
};
|
|
1037
1087
|
final narrow = MediaQuery.sizeOf(context).width < 720;
|
|
1038
1088
|
|
|
@@ -1046,6 +1096,8 @@ class _DeviceLaunchBar extends StatelessWidget {
|
|
|
1046
1096
|
? Icons.travel_explore
|
|
1047
1097
|
: surface == _DeviceSurface.desktop
|
|
1048
1098
|
? Icons.apps_outlined
|
|
1099
|
+
: surface == _DeviceSurface.files
|
|
1100
|
+
? Icons.folder_outlined
|
|
1049
1101
|
: Icons.open_in_new,
|
|
1050
1102
|
),
|
|
1051
1103
|
),
|
|
@@ -1058,6 +1110,8 @@ class _DeviceLaunchBar extends StatelessWidget {
|
|
|
1058
1110
|
? Icons.arrow_forward
|
|
1059
1111
|
: surface == _DeviceSurface.desktop
|
|
1060
1112
|
? Icons.desktop_windows_outlined
|
|
1113
|
+
: surface == _DeviceSurface.files
|
|
1114
|
+
? Icons.refresh_rounded
|
|
1061
1115
|
: Icons.play_arrow,
|
|
1062
1116
|
),
|
|
1063
1117
|
label: Text(buttonLabel),
|
|
@@ -1115,6 +1169,7 @@ class _DeviceTypeDock extends StatelessWidget {
|
|
|
1115
1169
|
_DeviceSurface.browser => 'Type into the currently focused field',
|
|
1116
1170
|
_DeviceSurface.android => 'Type into the current phone field',
|
|
1117
1171
|
_DeviceSurface.desktop => 'Type into the focused desktop field',
|
|
1172
|
+
_DeviceSurface.files => 'Edit the selected file',
|
|
1118
1173
|
};
|
|
1119
1174
|
final narrow = MediaQuery.sizeOf(context).width < 720;
|
|
1120
1175
|
|
|
@@ -1188,6 +1243,9 @@ class _DeviceQuickActions extends StatelessWidget {
|
|
|
1188
1243
|
_DeviceSurface.android => const <MapEntry<String, IconData>>[
|
|
1189
1244
|
MapEntry<String, IconData>('surface_refresh', Icons.refresh_rounded),
|
|
1190
1245
|
],
|
|
1246
|
+
_DeviceSurface.files => const <MapEntry<String, IconData>>[
|
|
1247
|
+
MapEntry<String, IconData>('surface_refresh', Icons.refresh_rounded),
|
|
1248
|
+
],
|
|
1191
1249
|
};
|
|
1192
1250
|
|
|
1193
1251
|
return Wrap(
|
|
@@ -1570,6 +1628,8 @@ class _InteractiveSurfacePreviewState
|
|
|
1570
1628
|
case _DeviceSurface.desktop:
|
|
1571
1629
|
await widget.controller.screenshotDesktopRuntime();
|
|
1572
1630
|
break;
|
|
1631
|
+
case _DeviceSurface.files:
|
|
1632
|
+
break;
|
|
1573
1633
|
}
|
|
1574
1634
|
}
|
|
1575
1635
|
|
|
@@ -1678,6 +1738,7 @@ class _InteractiveSurfacePreviewState
|
|
|
1678
1738
|
_DeviceSurface.browser => 16 / 10,
|
|
1679
1739
|
_DeviceSurface.android => 10 / 16,
|
|
1680
1740
|
_DeviceSurface.desktop => 16 / 10,
|
|
1741
|
+
_DeviceSurface.files => 16 / 10,
|
|
1681
1742
|
};
|
|
1682
1743
|
|
|
1683
1744
|
return Container(
|
|
@@ -1943,6 +2004,7 @@ class _EmptySurfaceState extends StatelessWidget {
|
|
|
1943
2004
|
connectRequired
|
|
1944
2005
|
? 'Select Desktop'
|
|
1945
2006
|
: (busy ? 'Refreshing Desktop...' : 'Refresh Desktop'),
|
|
2007
|
+
_DeviceSurface.files => busy ? 'Loading Files...' : 'Refresh Files',
|
|
1946
2008
|
};
|
|
1947
2009
|
final message = switch ((surface, busy, isLoadingPreview)) {
|
|
1948
2010
|
(_DeviceSurface.browser, true, _) =>
|
|
@@ -1957,6 +2019,8 @@ class _EmptySurfaceState extends StatelessWidget {
|
|
|
1957
2019
|
'Waking the phone and downloading the first preview. This can take a little while.',
|
|
1958
2020
|
(_DeviceSurface.android, false, true) =>
|
|
1959
2021
|
'Downloading the latest phone preview...',
|
|
2022
|
+
(_DeviceSurface.files, true, _) => 'Loading workspace files...',
|
|
2023
|
+
(_DeviceSurface.files, false, true) => 'Loading workspace files...',
|
|
1960
2024
|
_ =>
|
|
1961
2025
|
surface == _DeviceSurface.browser
|
|
1962
2026
|
? connectRequired
|
|
@@ -2195,6 +2259,246 @@ class _ExtensionStatusBar extends StatelessWidget {
|
|
|
2195
2259
|
}
|
|
2196
2260
|
}
|
|
2197
2261
|
|
|
2262
|
+
class _WorkspaceExplorer extends StatefulWidget {
|
|
2263
|
+
const _WorkspaceExplorer({
|
|
2264
|
+
required this.controller,
|
|
2265
|
+
required this.editorController,
|
|
2266
|
+
});
|
|
2267
|
+
|
|
2268
|
+
final NeoAgentController controller;
|
|
2269
|
+
final TextEditingController editorController;
|
|
2270
|
+
|
|
2271
|
+
@override
|
|
2272
|
+
State<_WorkspaceExplorer> createState() => _WorkspaceExplorerState();
|
|
2273
|
+
}
|
|
2274
|
+
|
|
2275
|
+
class _WorkspaceExplorerState extends State<_WorkspaceExplorer> {
|
|
2276
|
+
String? _syncedPath;
|
|
2277
|
+
|
|
2278
|
+
@override
|
|
2279
|
+
void initState() {
|
|
2280
|
+
super.initState();
|
|
2281
|
+
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
2282
|
+
if (!mounted || widget.controller.workspaceEntries.isNotEmpty) {
|
|
2283
|
+
return;
|
|
2284
|
+
}
|
|
2285
|
+
unawaited(widget.controller.refreshWorkspaceFiles());
|
|
2286
|
+
});
|
|
2287
|
+
}
|
|
2288
|
+
|
|
2289
|
+
@override
|
|
2290
|
+
void didUpdateWidget(covariant _WorkspaceExplorer oldWidget) {
|
|
2291
|
+
super.didUpdateWidget(oldWidget);
|
|
2292
|
+
final selectedPath = widget.controller.workspaceSelectedFilePath;
|
|
2293
|
+
if (selectedPath != _syncedPath) {
|
|
2294
|
+
_syncedPath = selectedPath;
|
|
2295
|
+
widget.editorController.text = widget.controller.workspaceEditorContent;
|
|
2296
|
+
}
|
|
2297
|
+
}
|
|
2298
|
+
|
|
2299
|
+
Future<void> _openEntry(Map<String, dynamic> entry) async {
|
|
2300
|
+
final path = entry['path']?.toString() ?? '';
|
|
2301
|
+
if (path.isEmpty && entry['name']?.toString() != '') {
|
|
2302
|
+
return;
|
|
2303
|
+
}
|
|
2304
|
+
if (entry['type']?.toString() == 'directory') {
|
|
2305
|
+
await widget.controller.openWorkspaceDirectory(path);
|
|
2306
|
+
return;
|
|
2307
|
+
}
|
|
2308
|
+
await widget.controller.openWorkspaceFile(path);
|
|
2309
|
+
}
|
|
2310
|
+
|
|
2311
|
+
@override
|
|
2312
|
+
Widget build(BuildContext context) {
|
|
2313
|
+
final controller = widget.controller;
|
|
2314
|
+
final entries = controller.workspaceEntries;
|
|
2315
|
+
final currentPath = controller.workspaceCurrentPath;
|
|
2316
|
+
final selectedPath = controller.workspaceSelectedFilePath;
|
|
2317
|
+
final busy =
|
|
2318
|
+
controller.isLoadingWorkspaceFiles || controller.isSavingWorkspaceFile;
|
|
2319
|
+
final canGoUp = currentPath.trim().isNotEmpty;
|
|
2320
|
+
final parentPath = currentPath.contains('/')
|
|
2321
|
+
? currentPath.substring(0, currentPath.lastIndexOf('/'))
|
|
2322
|
+
: '';
|
|
2323
|
+
|
|
2324
|
+
return Container(
|
|
2325
|
+
width: double.infinity,
|
|
2326
|
+
padding: const EdgeInsets.all(14),
|
|
2327
|
+
decoration: BoxDecoration(
|
|
2328
|
+
color: _bgSecondary,
|
|
2329
|
+
borderRadius: BorderRadius.circular(18),
|
|
2330
|
+
border: Border.all(color: _borderLight),
|
|
2331
|
+
),
|
|
2332
|
+
child: Column(
|
|
2333
|
+
crossAxisAlignment: CrossAxisAlignment.start,
|
|
2334
|
+
children: <Widget>[
|
|
2335
|
+
Row(
|
|
2336
|
+
children: <Widget>[
|
|
2337
|
+
Expanded(
|
|
2338
|
+
child: Text(
|
|
2339
|
+
currentPath.isEmpty ? '/' : '/$currentPath',
|
|
2340
|
+
maxLines: 1,
|
|
2341
|
+
overflow: TextOverflow.ellipsis,
|
|
2342
|
+
style: TextStyle(
|
|
2343
|
+
color: _textPrimary,
|
|
2344
|
+
fontWeight: FontWeight.w700,
|
|
2345
|
+
),
|
|
2346
|
+
),
|
|
2347
|
+
),
|
|
2348
|
+
IconButton.filledTonal(
|
|
2349
|
+
tooltip: 'Parent folder',
|
|
2350
|
+
onPressed: busy || !canGoUp
|
|
2351
|
+
? null
|
|
2352
|
+
: () => controller.openWorkspaceDirectory(parentPath),
|
|
2353
|
+
icon: const Icon(Icons.arrow_upward_rounded),
|
|
2354
|
+
),
|
|
2355
|
+
const SizedBox(width: 8),
|
|
2356
|
+
IconButton.filledTonal(
|
|
2357
|
+
tooltip: 'Refresh',
|
|
2358
|
+
onPressed: busy ? null : controller.refreshWorkspaceFiles,
|
|
2359
|
+
icon: const Icon(Icons.refresh_rounded),
|
|
2360
|
+
),
|
|
2361
|
+
],
|
|
2362
|
+
),
|
|
2363
|
+
const SizedBox(height: 12),
|
|
2364
|
+
Container(
|
|
2365
|
+
constraints: const BoxConstraints(maxHeight: 260),
|
|
2366
|
+
decoration: BoxDecoration(
|
|
2367
|
+
color: _bgCard,
|
|
2368
|
+
borderRadius: BorderRadius.circular(14),
|
|
2369
|
+
border: Border.all(color: _borderLight),
|
|
2370
|
+
),
|
|
2371
|
+
child: entries.isEmpty
|
|
2372
|
+
? Center(
|
|
2373
|
+
child: Padding(
|
|
2374
|
+
padding: const EdgeInsets.all(24),
|
|
2375
|
+
child: Text(
|
|
2376
|
+
controller.isLoadingWorkspaceFiles
|
|
2377
|
+
? 'Loading files...'
|
|
2378
|
+
: 'No files in this folder.',
|
|
2379
|
+
style: TextStyle(color: _textSecondary),
|
|
2380
|
+
),
|
|
2381
|
+
),
|
|
2382
|
+
)
|
|
2383
|
+
: ListView.separated(
|
|
2384
|
+
shrinkWrap: true,
|
|
2385
|
+
itemCount: entries.length,
|
|
2386
|
+
separatorBuilder: (_, __) => Divider(
|
|
2387
|
+
height: 1,
|
|
2388
|
+
color: _borderLight,
|
|
2389
|
+
),
|
|
2390
|
+
itemBuilder: (context, index) {
|
|
2391
|
+
final entry = entries[index];
|
|
2392
|
+
final type = entry['type']?.toString() ?? 'file';
|
|
2393
|
+
final path = entry['path']?.toString() ?? '';
|
|
2394
|
+
final selected = selectedPath == path;
|
|
2395
|
+
return ListTile(
|
|
2396
|
+
dense: true,
|
|
2397
|
+
selected: selected,
|
|
2398
|
+
leading: Icon(
|
|
2399
|
+
type == 'directory'
|
|
2400
|
+
? Icons.folder_outlined
|
|
2401
|
+
: Icons.insert_drive_file_outlined,
|
|
2402
|
+
color: type == 'directory' ? _accent : _textSecondary,
|
|
2403
|
+
),
|
|
2404
|
+
title: Text(
|
|
2405
|
+
entry['name']?.toString() ?? path,
|
|
2406
|
+
maxLines: 1,
|
|
2407
|
+
overflow: TextOverflow.ellipsis,
|
|
2408
|
+
),
|
|
2409
|
+
subtitle: type == 'file'
|
|
2410
|
+
? Text(_formatWorkspaceBytes(entry['size']))
|
|
2411
|
+
: null,
|
|
2412
|
+
trailing: type == 'file'
|
|
2413
|
+
? IconButton(
|
|
2414
|
+
tooltip: 'Download',
|
|
2415
|
+
onPressed: busy || path.isEmpty
|
|
2416
|
+
? null
|
|
2417
|
+
: () => controller.downloadWorkspaceFile(
|
|
2418
|
+
path,
|
|
2419
|
+
),
|
|
2420
|
+
icon: const Icon(Icons.download_outlined),
|
|
2421
|
+
)
|
|
2422
|
+
: const Icon(Icons.chevron_right_rounded),
|
|
2423
|
+
onTap: busy ? null : () => _openEntry(entry),
|
|
2424
|
+
);
|
|
2425
|
+
},
|
|
2426
|
+
),
|
|
2427
|
+
),
|
|
2428
|
+
const SizedBox(height: 14),
|
|
2429
|
+
Row(
|
|
2430
|
+
children: <Widget>[
|
|
2431
|
+
Expanded(
|
|
2432
|
+
child: Text(
|
|
2433
|
+
selectedPath ?? 'No file selected',
|
|
2434
|
+
maxLines: 1,
|
|
2435
|
+
overflow: TextOverflow.ellipsis,
|
|
2436
|
+
style: TextStyle(
|
|
2437
|
+
color: selectedPath == null ? _textSecondary : _textPrimary,
|
|
2438
|
+
fontWeight: FontWeight.w700,
|
|
2439
|
+
),
|
|
2440
|
+
),
|
|
2441
|
+
),
|
|
2442
|
+
OutlinedButton.icon(
|
|
2443
|
+
onPressed: busy || selectedPath == null
|
|
2444
|
+
? null
|
|
2445
|
+
: () => controller.downloadWorkspaceFile(selectedPath),
|
|
2446
|
+
icon: const Icon(Icons.download_outlined, size: 18),
|
|
2447
|
+
label: const Text('Download'),
|
|
2448
|
+
),
|
|
2449
|
+
const SizedBox(width: 8),
|
|
2450
|
+
FilledButton.icon(
|
|
2451
|
+
onPressed: busy || selectedPath == null
|
|
2452
|
+
? null
|
|
2453
|
+
: () => controller.saveWorkspaceFile(
|
|
2454
|
+
widget.editorController.text,
|
|
2455
|
+
),
|
|
2456
|
+
icon: controller.isSavingWorkspaceFile
|
|
2457
|
+
? const SizedBox(
|
|
2458
|
+
width: 16,
|
|
2459
|
+
height: 16,
|
|
2460
|
+
child: CircularProgressIndicator(strokeWidth: 2),
|
|
2461
|
+
)
|
|
2462
|
+
: const Icon(Icons.save_outlined, size: 18),
|
|
2463
|
+
label: const Text('Save'),
|
|
2464
|
+
),
|
|
2465
|
+
],
|
|
2466
|
+
),
|
|
2467
|
+
const SizedBox(height: 10),
|
|
2468
|
+
TextField(
|
|
2469
|
+
controller: widget.editorController,
|
|
2470
|
+
enabled: selectedPath != null && !controller.isSavingWorkspaceFile,
|
|
2471
|
+
minLines: 12,
|
|
2472
|
+
maxLines: 18,
|
|
2473
|
+
style: const TextStyle(
|
|
2474
|
+
fontFamily: 'monospace',
|
|
2475
|
+
fontSize: 13,
|
|
2476
|
+
height: 1.35,
|
|
2477
|
+
),
|
|
2478
|
+
decoration: InputDecoration(
|
|
2479
|
+
hintText: selectedPath == null
|
|
2480
|
+
? 'Select a file to view or edit it.'
|
|
2481
|
+
: 'File contents',
|
|
2482
|
+
alignLabelWithHint: true,
|
|
2483
|
+
),
|
|
2484
|
+
),
|
|
2485
|
+
],
|
|
2486
|
+
),
|
|
2487
|
+
);
|
|
2488
|
+
}
|
|
2489
|
+
}
|
|
2490
|
+
|
|
2491
|
+
String _formatWorkspaceBytes(Object? value) {
|
|
2492
|
+
final bytes = value is num ? value.toDouble() : double.tryParse('$value') ?? 0;
|
|
2493
|
+
if (bytes >= 1024 * 1024) {
|
|
2494
|
+
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
|
|
2495
|
+
}
|
|
2496
|
+
if (bytes >= 1024) {
|
|
2497
|
+
return '${(bytes / 1024).toStringAsFixed(1)} KB';
|
|
2498
|
+
}
|
|
2499
|
+
return '${bytes.round()} B';
|
|
2500
|
+
}
|
|
2501
|
+
|
|
2198
2502
|
class _ResultBlock extends StatelessWidget {
|
|
2199
2503
|
const _ResultBlock({required this.label, required this.value});
|
|
2200
2504
|
|