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.
@@ -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