Skip to content

Diff between AI Clean arch branch and main#21

Open
theSoberSobber wants to merge 12 commits into
mainfrom
clean-arch
Open

Diff between AI Clean arch branch and main#21
theSoberSobber wants to merge 12 commits into
mainfrom
clean-arch

Conversation

@theSoberSobber

@theSoberSobber theSoberSobber commented Nov 13, 2025

Copy link
Copy Markdown
Owner

Summary by CodeRabbit

  • Chores

    • Removed native iOS/macOS project and asset/config files.
    • Added dependency injection (GetIt) and initialized it at startup.
    • Bumped app version.
  • New Features

    • Centralized Docker CLI configuration UI/persistence.
    • New services for container creation, image management, and general Docker operations.
    • Shared base screen pattern and reusable resource-state widgets for lists.
  • Bug Fixes / UX

    • Improved Docker error parsing and friendlier error states/notifications.

@coderabbitai

coderabbitai Bot commented Nov 19, 2025

Copy link
Copy Markdown

Walkthrough

Removes iOS and macOS native project/workspace/resources and many Xcode config/includes; adds GetIt service locator and registers services; introduces Docker domain interfaces and implementations, CLI config and error-state models; refactors presentation to a BaseResourceScreen pattern and updates screens to use DI and domain services.

Changes

Cohort / File(s) Summary
Platform ignores & xcconfig includes
ios/.gitignore, macos/.gitignore, ios/Flutter/Debug.xcconfig, ios/Flutter/Release.xcconfig, macos/Flutter/Flutter-Debug.xcconfig, macos/Flutter/Flutter-Release.xcconfig
Removed large .gitignore ignore blocks and removed include directives referencing generated/ephemeral xcconfig files.
iOS Xcode project & workspace removals
ios/Runner.xcodeproj/project.pbxproj, ios/Runner.xcodeproj/project.xcworkspace/..., ios/Runner.xcworkspace/...
Deleted the entire iOS Xcode project file(s), workspace contents, schemes and related workspace settings.
macOS Xcode project & workspace removals
macos/Runner.xcodeproj/project.pbxproj, macos/Runner.xcodeproj/..., macos/Runner.xcworkspace/...
Deleted the entire macOS Xcode project file(s), workspace contents, schemes and related workspace settings.
iOS runtime/resources removed
ios/Flutter/AppFrameworkInfo.plist, ios/Runner/AppDelegate.swift, ios/Runner/Info.plist, ios/Runner/Runner-Bridging-Header.h, ios/Runner/Base.lproj/*, ios/Runner/Assets.xcassets/*, ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md
Removed Flutter app framework plist, AppDelegate, Info.plist, bridging header, storyboards, asset manifests and related iOS resources.
macOS runtime/resources removed
macos/Runner/AppDelegate.swift, macos/Runner/MainFlutterWindow.swift, macos/Runner/Info.plist, macos/Runner/Base.lproj/MainMenu.xib, macos/Runner/Assets.xcassets/*, macos/Runner/Configs/*, macos/Runner/*entitlements
Removed macOS AppDelegate, main window, Info.plist, menu XIB, asset manifests, configs, warning flags and entitlements.
Test files removed
ios/RunnerTests/RunnerTests.swift, macos/RunnerTests/RunnerTests.swift
Deleted placeholder XCTest files.
Dependency injection & app bootstrap
lib/core/di/service_locator.dart, lib/main.dart, pubspec.yaml
Added GetIt service locator, call to setupServiceLocator() in main, added get_it dependency and registered SSH/registry/repositories and domain service implementations.
Core utilities
lib/core/utils/docker_cli_config.dart, lib/core/utils/error_state.dart
Added DockerCliConfig (persistent CLI path + command builder) and ErrorState UI model with preset factories.
Domain interfaces & models
lib/domain/services/container_creation_service.dart, lib/domain/services/docker_operations_service.dart, lib/domain/services/image_management_service.dart
Added domain service interfaces, ContainerCreationConfig/ImageBuildConfig and supporting value objects and method contracts.
Data/service implementations
lib/data/services/container_creation_service_impl.dart, lib/data/services/docker_operations_service_impl.dart, lib/data/services/image_management_service_impl.dart, lib/data/services/ssh_connection_service.dart
New implementations using SSH and DockerCliConfig; SSHConnectionService adds didServerChange/currentServer behavior; container/image/operations logic implemented.
Repository updates
lib/data/repositories/docker_repository_impl.dart
Replaced SharedPreferences usage with DockerCliConfig.getCliPath(), added _parseDockerError to surface friendlier errors, and improved command result checks and error handling.
Presentation: base & widgets
lib/presentation/screens/base/base_resource_screen.dart, lib/presentation/widgets/resource_state_widgets.dart
Added BaseResourceScreen abstraction (loading, search, filtering, error handling, server-change detection) and reusable resource-state widgets.
Presentation: screen refactors
lib/presentation/screens/containers_screen.dart, lib/presentation/screens/images_screen.dart, lib/presentation/screens/networks_screen.dart, lib/presentation/screens/volumes_screen.dart, lib/presentation/screens/containers/create_container_screen.dart, lib/presentation/screens/images/*, lib/presentation/screens/server_list_screen.dart, lib/presentation/screens/home_screen.dart, lib/presentation/screens/shell_screen.dart, lib/presentation/screens/settings_screen.dart
Many screens migrated to BaseResourceScreen or switched to DI-provided domain services (ContainerCreationService, ImageManagementService, DockerRegistryService, DockerCliConfig); removed direct SharedPreferences/SSH usages and local command-building logic.

Sequence Diagram(s)

sequenceDiagram
    autonumber
    participant App as main()
    participant DI as Service Locator (GetIt)
    participant UI as UI Screens
    participant Domain as Domain Services
    participant Repo as Repositories
    participant SSH as SSHConnectionService

    App->>DI: setupServiceLocator()
    DI->>Repo: register repositories (Docker, Server, Registry)
    DI->>SSH: register SSHConnectionService
    DI->>Domain: register domain impls (Container/Image/Operations)

    UI->>DI: getIt<T>()
    DI-->>UI: instance
    UI->>Domain: fetchItems / createContainer / pullImage
    Domain->>Repo: construct/resolve command
    Repo->>SSH: executeCommand(command)
    SSH-->>Repo: stdout / stderr
    Repo-->>Domain: parsed result or error
    Domain-->>UI: data or error
    UI->>UI: BaseResourceScreen renders loading / success / error
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Areas to focus during review:

  • lib/core/di/service_locator.dart — registration correctness (singletons vs. factories) and missing/incorrect bindings.
  • lib/presentation/screens/base/base_resource_screen.dart — lifecycle, concurrency, server-change detection, error propagation and retry semantics.
  • lib/data/services/* and lib/data/repositories/docker_repository_impl.dart — command construction/escaping, temp-dir handling, SSH execution error parsing, and edge-case handling.
  • Large deletions of iOS/macOS project files — verify intended removal and CI/build implications.
  • Async usage of DockerCliConfig.getCliPath() — ensure all call-sites await results and handle defaults/errors.

Poem

🐇
I nibbled native files at dawn's first light,
wove GetIt threads to set services right.
Base screens hop in tidy shared rows,
Docker commands now tidy as a nose.
A carrot clap — the code springs bright! 🥕

Pre-merge checks and finishing touches

❌ Failed checks (1 inconclusive)
Check name Status Explanation Resolution
Title check ❓ Inconclusive The title 'Diff between AI Clean arch branch and main' is vague and generic, describing a process rather than the substantive changes. It uses non-descriptive language ('Diff between') that doesn't convey meaningful information about what was actually changed. Replace with a specific title that summarizes the main architectural change, such as 'Implement clean architecture with dependency injection' or 'Restructure app with service locator and base screen patterns'.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch clean-arch

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
lib/presentation/screens/server_list_screen.dart (1)

20-60: Guard async setState calls with mounted to avoid setState-after-dispose

_loadCurrentServerId and _loadServers both await repository calls and then call setState without checking mounted, so if the screen is popped while those futures are in flight you can hit setState() after dispose. The new DI wiring is fine; this is just an existing async safety gap worth tightening.

You can harden both methods like this:

   Future<void> _loadCurrentServerId() async {
     try {
-      final currentServerId = await _serverRepository.getLastUsedServerId();
-      setState(() {
-        _currentServerId = currentServerId;
-      });
+      final currentServerId = await _serverRepository.getLastUsedServerId();
+      if (!mounted) return;
+      setState(() {
+        _currentServerId = currentServerId;
+      });
     } catch (e) {
       // Handle error silently
     }
   }

   Future<void> _loadServers() async {
     try {
       setState(() => _isLoading = true);
       final servers = await _serverRepository.getServers();
-      setState(() {
-        _servers = servers;
-        _isLoading = false;
-      });
+      if (!mounted) return;
+      setState(() {
+        _servers = servers;
+        _isLoading = false;
+      });
     } catch (e) {
-      setState(() => _isLoading = false);
+      if (!mounted) return;
+      setState(() => _isLoading = false);
       if (mounted) {
         ScaffoldMessenger.of(context).showSnackBar(
           SnackBar(content: Text('Failed to load servers: $e')),
         );
       }
     }
   }
lib/presentation/screens/images/build_image_screen.dart (1)

19-138: Add mounted checks around async setState in _buildImage

The new service-based build flow looks good, but _buildImage calls setState after awaiting _imageManagementService.buildImage and again in the catch block without checking mounted, which can trigger setState() after dispose if the user navigates away mid-build.

A minimal fix:

   Future<void> _buildImage() async {
@@
-      // Use service to build image
-      final result = await _imageManagementService.buildImage(config);
-
-      setState(() {
-        _buildLogs += result;
-      });
-      _scrollToBottom();
+      // Use service to build image
+      final result = await _imageManagementService.buildImage(config);
+
+      if (!mounted) return;
+
+      setState(() {
+        _buildLogs += result;
+      });
+      _scrollToBottom();
@@
-    } catch (e) {
-      setState(() {
-        _buildLogs += '\nError: $e\n';
-        _isBuilding = false;
-      });
-      _scrollToBottom();
-      
-      if (mounted) {
+    } catch (e) {
+      if (!mounted) return;
+      setState(() {
+        _buildLogs += '\nError: $e\n';
+        _isBuilding = false;
+      });
+      _scrollToBottom();
+      
+      if (mounted) {
         ScaffoldMessenger.of(context).showSnackBar(
           SnackBar(
             content: Text('Error: $e'),
🧹 Nitpick comments (22)
lib/data/services/ssh_connection_service.dart (1)

41-42: Consider making the setter private or removing it.

The public setter allows external code to set _currentServer without establishing an actual connection, which could lead to inconsistent state where _currentServer is set but _currentConnection and _status don't match.

If the setter is needed for specific use cases, consider documenting when and why it should be used, or restrict its visibility:

-  // Setter for currentServer (used when server is selected before connection)
-  set currentServer(Server? server) => _currentServer = server;

Alternatively, if this is truly needed, add validation or documentation explaining the intended usage pattern.

lib/presentation/screens/containers/create_container_screen.dart (1)

638-651: Consider reusing domain models for UI state.

The local data classes PortMapping, EnvVariable, and VolumeMount (lines 638-651) duplicate the structure of domain models defined in lib/domain/services/container_creation_service.dart. The only difference is mutability for UI binding.

Consider one of these approaches to reduce duplication:

  1. Make domain models mutable (if appropriate for your architecture)
  2. Use a ValueNotifier or similar pattern to wrap immutable domain models
  3. Keep the current approach if the separation between mutable UI models and immutable domain models is intentional

The current implementation works but creates maintenance overhead when domain models change.

lib/data/services/docker_operations_service_impl.dart (1)

15-99: Commands and wiring look correct; consider small DRY helper later

The per-method command strings and use of DockerCliConfig.getCliPath() and sshService.executeCommand all look correct and consistent. There is some repetition in fetching the CLI and interpolating commands, but that’s a minor DRY opportunity you can defer (e.g., a small private helper that returns '$dockerCli $subcommand').

lib/data/repositories/docker_repository_impl.dart (2)

12-42: Error parsing helper is useful but has some platform-specific assumptions

The _parseDockerError heuristics provide much more user-friendly messages, which is great. A couple of caveats to keep in mind:

  • Matching on very broad substrings like 'no such file or directory' may misclassify unrelated file errors as “Docker not found”.
  • The remediation text (docker group, sudo systemctl start docker) assumes a Linux/systemd environment and correct group-based permissions; if you ever support non-Linux or non-systemd targets, you may want to gate these messages on detected platform/connection metadata or generalize the wording.

52-73: CLI path centralization and error wrapping look good; consider a small shared helper

Using DockerCliConfig.getCliPath() everywhere and routing all exceptions through _parseDockerError is a solid improvement for consistency and UX. The only minor thing you might consider later is extracting a small helper like Future<String> _runDockerCommand(String args) that:

  • Resolves the CLI path once.
  • Executes the command.
  • Applies the common “null/empty output” and error wrapping logic.

This would remove a bit of repetition across getContainers, getContainerStats, getImages, getVolumes, and getNetworks, and make it easier to adjust quoting/escaping of dockerCli in one place if needed.

Also applies to: 83-115, 124-135, 144-155, 164-175

lib/presentation/screens/images/pull_image_screen.dart (1)

161-200: Pull flow delegation is clean; consider future reuse of error presentation

Delegating the pull to _imageManagementService.pullImage(fullImageName) and handling success via a green SnackBar + Navigator.of(context).pop(true) is straightforward and keeps the UI logic simple. As you roll out the shared error/empty-state widgets elsewhere, you might eventually want to align the error handling here with that pattern (e.g., surfacing richer messages from the domain service), but the current flow is perfectly fine.

lib/core/utils/error_state.dart (1)

3-76: ErrorState is a solid central model; just be aware empty is server-specific

This is a nice, compact way to standardize error/empty/permission configurations across screens. One small semantic detail: ErrorState.empty currently hard-codes the headline to 'No Servers Found', which makes it tightly coupled to the server use case. If you later want to reuse this for other “no data” resources, consider either:

  • Renaming this factory to something server-specific, or
  • Making the headline a parameter with a sensible default.
lib/presentation/widgets/resource_state_widgets.dart (1)

4-193: Reusable state widgets are clear; consider surfacing the search query

The loading, error, and empty widgets are concise and will help keep individual screens lean and consistent. In NoSearchResultsWidget, you already accept a query string but don’t show it in the UI; it could be helpful to users to see something like No results found for "foo" instead of the generic text, or else drop the parameter if you don’t plan to use it.

lib/domain/services/docker_operations_service.dart (1)

1-25: Domain service contract looks coherent and matches the operations you need

The DockerOperationsService abstraction cleanly groups container/image/volume/network operations and gives the UI a single domain-level entry point. Returning Future<String> for the “get*Command” methods makes sense if implementations need to compose commands with async config (e.g., CLI path). Just ensure call sites clearly treat those as shell commands (vs. logs/JSON content) so the naming doesn’t cause confusion later.

lib/presentation/screens/base/base_resource_screen.dart (4)

64-181: Connection/error classification works but relies on a magic message marker

The _loadItems / _isConnectionError / _isPermissionError flow is a nice centralization of error handling, but a couple of implementation details are brittle:

  • Using ErrorState.connection(message: '__RECONNECT__', ...) and then checking error?.message == '__RECONNECT__' in multiple places overloads the message field with control semantics. It would be more robust to model this explicitly (e.g., a dedicated enum/flag on ErrorState or a small sealed result type like LoadResult.success/connectionError/otherError).
  • The string heuristics ('connection', 'ssh', 'timeout', 'refused', 'closed' / 'permission denied', 'docker group', etc.) are reasonable, but they are broad; keep in mind that they may catch unrelated messages in edge cases. Centralizing them here mitigates that risk, though.

If this logic starts to grow, extracting a small “error classifier” helper or type would make it easier to reason about and test.

Also applies to: 183-201


296-397: Build-triggered connect/load is fine, but the debug SnackBar is easy to forget

The needsConnection/needsLoad checks inside build combined with isLoading gating are a pragmatic way to bootstrap connection + loading without extra lifecycle hooks, and the async chaining looks sound.

For the debug section:

  • The SnackBar with a random number is helpful while diagnosing the state machine, but because it’s wired from build via addPostFrameCallback, it will fire on every rebuild in debug and can flood the UI. It’d be safer to either guard it behind an additional debug flag or remove it once you’re confident in the flow.

455-507: Connection failure / no-server UX could offer a direct settings/action affordance

When _connectToServer finds no stored server, it returns ErrorState.empty with a message instructing the user to add a server via the settings menu, but onRetry is null, so the error UI (Lines 414–449) ends up with no primary action. Consider:

  • Providing an onRetry (or dedicated onAction) callback that navigates to SettingsScreen, or
  • Using a more specialized ErrorState variant for this case that the UI can render with a “Go to Settings” button.

This would restore the direct affordance that existed in the older, commented-out buildErrorState logic.

Also applies to: 414-449


203-245: Legacy stubs and commented blocks are fine short-term but worth cleaning up later

loadItems, refreshItems, _startServerChangeDetection, didChangeAppLifecycleState, buildBody, and buildErrorState are effectively dead code now (either no-op or entirely commented). Keeping them during migration is understandable, but once the new flow is stable it’d be good to either:

  • Remove them, or
  • Reintroduce them in terms of the new errorState + _loadItems/_connectToServer model.

That will reduce confusion for future readers trying to understand which paths are actually in use.

Also applies to: 254-293, 509-641

lib/presentation/screens/containers_screen.dart (4)

22-65: Background stats loading can race with repeated refreshes

fetchItems schedules _loadContainerStats() on a delayed future, and _loadContainerStats then merges stats into whatever items happens to be at execution time. If loadItems() is triggered again while a previous stats fetch is in flight, an older stats result can overwrite newer items/filteredItems.

Consider capturing the containers list (or a version/token) inside fetchItems and having _loadContainerStats operate only if the state still corresponds to that fetch, to avoid stale overwrites.


67-109: Filtering and stack-chip behavior look solid; consider clarifying the 'no-stack' sentinel

The combined stack + text filtering logic is straightforward and efficient, and _getAvailableStacks correctly derives sorted unique stack names from items. One minor UX edge case is overloading the literal 'no-stack' as a sentinel; if a real stack were ever named "no-stack", it would be indistinguishable from the synthetic “No Stack” filter.

If that’s a plausible scenario, consider using a private constant or enum-like type for the sentinel instead of a raw string.


117-359: Item card implementation is robust; think about failure-state handling for stats

The card rendering is well-structured, with defensive ID truncation (substring guarded by length > 12), clear stack labeling, and a good use of helper builders for detail rows and stats columns.

When stats loading fails, though, running containers will show the “Loading stats...” spinner indefinitely. If stats failures are common (e.g., remote daemon issues), it may be worth tracking a “statsFailed” flag (per list or per container) so you can either hide the stats section or show an error message instead of a permanent spinner.


537-661: Action routing via stringly-typed action.command is brittle; loading SnackBar duration may be short

The overall flow—special-casing logs/inspect/exec, then showing a progress SnackBar and delegating state changes to operationsService—is clean and well-guarded with mounted checks.

Two improvement points:

  • Using action.command string literals ('docker stop', 'docker rm', etc.) as the switch key is fragile: adding a new DockerAction without updating this switch will silently result in a no-op but still show success UI. Consider switching on an enum or dedicated action.type field, and/or adding a default case that logs unexpected commands.
  • The loading SnackBar uses a fixed 2-second duration; long-running operations may complete after it disappears, leaving a period with no visual feedback. You could make it longer or use an indefinite SnackBar and explicitly dismiss it once the operation finishes (before showing success/error).
lib/presentation/screens/images_screen.dart (2)

42-219: Guard image ID truncation and unify display naming for unnamed images

Two small improvements in the card builder:

  • 'ID: ${image.imageId.substring(0, 12)}' assumes image.imageId.length >= 12. Docker IDs are typically long enough, but for consistency with the containers screen (where you already guard on length) and to avoid potential RangeErrors from unexpected data, consider:
- 'ID: ${image.imageId.substring(0, 12)}',
+ 'ID: ${image.imageId.length > 12 ? image.imageId.substring(0, 12) : image.imageId}',
  • For unnamed images (repository == '<none>'), the card uses 'Unnamed Image', but the action resourceName and the inspect ShellScreen title display ${image.repository}:${image.tag}, which will show <none>:<none>. You may want to reuse the same human-friendly name used in the card to keep titles and dialogs consistent.

305-374: Image action handler is solid; consider reducing reliance on command strings

The inspect/delete flows are correctly funneled through operationsService, with confirmation for deletions and mounted guarding around SnackBars. Behavior-wise this looks good.

Similar to the containers screen, routing based on action.command string literals ('docker image inspect', 'docker image rm') is somewhat brittle. Introducing an enum/actionType on DockerAction and switching on that would make adding new actions safer and avoid silent no-ops if command strings drift.

lib/presentation/screens/volumes_screen.dart (1)

212-277: Volume action handler is correct; consider standardizing refresh behavior and action routing

The inspect and delete flows correctly use operationsService, guard SnackBars with mounted, and refresh the list after deletion via loadItems().

Two small consistency improvements:

  • Here you call loadItems(); without await, whereas _handleContainerAction awaits loadItems(). Since callers don’t currently await _handleVolumeAction, this isn’t a bug, but standardizing on either awaiting or fire-and-forget across all handlers will make behavior more predictable (and tests easier to reason about).
  • As with other screens, switching on action.command string values ('docker volume inspect', 'docker volume rm') is a bit fragile; a typed actionType would be more robust.
lib/presentation/screens/networks_screen.dart (2)

41-201: Guard network ID truncation and centralize system-network detection

The card rendering is clear, especially the visual distinction for system networks.

Two small robustness improvements:

  • 'ID: ${network.networkId.substring(0, 12)}' assumes network.networkId.length >= 12. For defensive coding and consistency with the containers screen, consider guarding the substring:
- 'ID: ${network.networkId.substring(0, 12)}',
+ 'ID: ${network.networkId.length > 12 ? network.networkId.substring(0, 12) : network.networkId}',
  • The list of system networks ['bridge', 'host', 'none'] is defined here to color the icon and duplicated in _handleNetworkAction to block deletions. Extracting this into a shared helper/constant (e.g., bool isSystemNetwork(String name)) would avoid drift if you ever adjust that list.

287-363: Network actions correctly guard system networks; minor refactor opportunities

The action handler appropriately:

  • Uses operationsService for inspect/delete.
  • Prevents deletion of system networks with an immediate SnackBar.
  • Confirms destructive deletes and refreshes the list via loadItems().
  • Wraps everything in a try/catch with mounted-guarded error SnackBars.

As in the other screens, you might consider:

  • Sharing the system-network detection logic with the card (isSystemNetwork helper/constant).
  • Replacing the string-based action.command switch with a typed discriminator on DockerAction to avoid silent no-ops if commands change.

Functionally, though, this is solid as-is.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between a3ede12 and fe75ce8.

⛔ Files ignored due to path filters (32)
  • ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png is excluded by !**/*.png
  • ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png is excluded by !**/*.png
  • ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png is excluded by !**/*.png
  • ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png is excluded by !**/*.png
  • ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png is excluded by !**/*.png
  • ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png is excluded by !**/*.png
  • ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png is excluded by !**/*.png
  • ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png is excluded by !**/*.png
  • ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png is excluded by !**/*.png
  • ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png is excluded by !**/*.png
  • ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png is excluded by !**/*.png
  • ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png is excluded by !**/*.png
  • ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png is excluded by !**/*.png
  • ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png is excluded by !**/*.png
  • ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png is excluded by !**/*.png
  • ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png is excluded by !**/*.png
  • ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png is excluded by !**/*.png
  • ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png is excluded by !**/*.png
  • ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png is excluded by !**/*.png
  • ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png is excluded by !**/*.png
  • ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png is excluded by !**/*.png
  • ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png is excluded by !**/*.png
  • ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png is excluded by !**/*.png
  • ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png is excluded by !**/*.png
  • macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png is excluded by !**/*.png
  • macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png is excluded by !**/*.png
  • macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png is excluded by !**/*.png
  • macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png is excluded by !**/*.png
  • macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png is excluded by !**/*.png
  • macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png is excluded by !**/*.png
  • macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png is excluded by !**/*.png
  • pubspec.lock is excluded by !**/*.lock
📒 Files selected for processing (68)
  • ios/.gitignore (0 hunks)
  • ios/Flutter/AppFrameworkInfo.plist (0 hunks)
  • ios/Flutter/Debug.xcconfig (0 hunks)
  • ios/Flutter/Release.xcconfig (0 hunks)
  • ios/Runner.xcodeproj/project.pbxproj (0 hunks)
  • ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata (0 hunks)
  • ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist (0 hunks)
  • ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings (0 hunks)
  • ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme (0 hunks)
  • ios/Runner.xcworkspace/contents.xcworkspacedata (0 hunks)
  • ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist (0 hunks)
  • ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings (0 hunks)
  • ios/Runner/AppDelegate.swift (0 hunks)
  • ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json (0 hunks)
  • ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json (0 hunks)
  • ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md (0 hunks)
  • ios/Runner/Base.lproj/LaunchScreen.storyboard (0 hunks)
  • ios/Runner/Base.lproj/Main.storyboard (0 hunks)
  • ios/Runner/Info.plist (0 hunks)
  • ios/Runner/Runner-Bridging-Header.h (0 hunks)
  • ios/RunnerTests/RunnerTests.swift (0 hunks)
  • lib/core/di/service_locator.dart (1 hunks)
  • lib/core/utils/docker_cli_config.dart (1 hunks)
  • lib/core/utils/error_state.dart (1 hunks)
  • lib/data/repositories/docker_repository_impl.dart (11 hunks)
  • lib/data/services/container_creation_service_impl.dart (1 hunks)
  • lib/data/services/docker_operations_service_impl.dart (1 hunks)
  • lib/data/services/image_management_service_impl.dart (1 hunks)
  • lib/data/services/ssh_connection_service.dart (4 hunks)
  • lib/domain/services/container_creation_service.dart (1 hunks)
  • lib/domain/services/docker_operations_service.dart (1 hunks)
  • lib/domain/services/image_management_service.dart (1 hunks)
  • lib/main.dart (1 hunks)
  • lib/presentation/screens/base/base_resource_screen.dart (1 hunks)
  • lib/presentation/screens/containers/create_container_screen.dart (4 hunks)
  • lib/presentation/screens/containers_screen.dart (7 hunks)
  • lib/presentation/screens/home_screen.dart (5 hunks)
  • lib/presentation/screens/images/build_image_screen.dart (3 hunks)
  • lib/presentation/screens/images/pull_image_screen.dart (4 hunks)
  • lib/presentation/screens/images_screen.dart (5 hunks)
  • lib/presentation/screens/networks_screen.dart (5 hunks)
  • lib/presentation/screens/server_list_screen.dart (2 hunks)
  • lib/presentation/screens/settings_screen.dart (4 hunks)
  • lib/presentation/screens/shell_screen.dart (2 hunks)
  • lib/presentation/screens/volumes_screen.dart (4 hunks)
  • lib/presentation/widgets/resource_state_widgets.dart (1 hunks)
  • macos/.gitignore (0 hunks)
  • macos/Flutter/Flutter-Debug.xcconfig (0 hunks)
  • macos/Flutter/Flutter-Release.xcconfig (0 hunks)
  • macos/Flutter/GeneratedPluginRegistrant.swift (0 hunks)
  • macos/Runner.xcodeproj/project.pbxproj (0 hunks)
  • macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist (0 hunks)
  • macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme (0 hunks)
  • macos/Runner.xcworkspace/contents.xcworkspacedata (0 hunks)
  • macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist (0 hunks)
  • macos/Runner/AppDelegate.swift (0 hunks)
  • macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json (0 hunks)
  • macos/Runner/Base.lproj/MainMenu.xib (0 hunks)
  • macos/Runner/Configs/AppInfo.xcconfig (0 hunks)
  • macos/Runner/Configs/Debug.xcconfig (0 hunks)
  • macos/Runner/Configs/Release.xcconfig (0 hunks)
  • macos/Runner/Configs/Warnings.xcconfig (0 hunks)
  • macos/Runner/DebugProfile.entitlements (0 hunks)
  • macos/Runner/Info.plist (0 hunks)
  • macos/Runner/MainFlutterWindow.swift (0 hunks)
  • macos/Runner/Release.entitlements (0 hunks)
  • macos/RunnerTests/RunnerTests.swift (0 hunks)
  • pubspec.yaml (2 hunks)
💤 Files with no reviewable changes (42)
  • macos/Runner/Configs/Debug.xcconfig
  • macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
  • ios/Runner/Base.lproj/LaunchScreen.storyboard
  • macos/RunnerTests/RunnerTests.swift
  • macos/.gitignore
  • ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata
  • ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md
  • ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json
  • macos/Runner/DebugProfile.entitlements
  • macos/Flutter/GeneratedPluginRegistrant.swift
  • macos/Runner/Configs/Warnings.xcconfig
  • macos/Runner.xcworkspace/contents.xcworkspacedata
  • ios/RunnerTests/RunnerTests.swift
  • macos/Runner/Info.plist
  • ios/Runner/Runner-Bridging-Header.h
  • macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json
  • macos/Runner/Configs/AppInfo.xcconfig
  • ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
  • macos/Flutter/Flutter-Debug.xcconfig
  • macos/Runner/AppDelegate.swift
  • macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme
  • ios/Runner/Info.plist
  • ios/Runner.xcodeproj/project.pbxproj
  • ios/Runner/AppDelegate.swift
  • macos/Flutter/Flutter-Release.xcconfig
  • ios/Runner.xcworkspace/contents.xcworkspacedata
  • ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme
  • ios/Flutter/Release.xcconfig
  • macos/Runner.xcodeproj/project.pbxproj
  • ios/Runner/Base.lproj/Main.storyboard
  • ios/Flutter/Debug.xcconfig
  • ios/.gitignore
  • ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
  • ios/Flutter/AppFrameworkInfo.plist
  • macos/Runner/Base.lproj/MainMenu.xib
  • ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings
  • ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings
  • macos/Runner/Release.entitlements
  • macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
  • macos/Runner/MainFlutterWindow.swift
  • ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json
  • macos/Runner/Configs/Release.xcconfig
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Build Windows Release
🔇 Additional comments (20)
lib/domain/services/container_creation_service.dart (1)

1-72: LGTM!

Clean domain service abstraction with well-structured configuration models. The separation of concerns between domain interfaces and implementation is good architecture.

pubspec.yaml (1)

19-19: LGTM!

Version bump and GetIt dependency addition are appropriate for introducing dependency injection infrastructure.

Also applies to: 40-40

lib/presentation/screens/settings_screen.dart (1)

33-43: LGTM!

Clean migration to centralized Docker CLI configuration. The consistent use of DockerCliConfig instead of direct SharedPreferences access improves maintainability.

Also applies to: 62-78, 136-146

lib/presentation/screens/shell_screen.dart (1)

8-8: LGTM on DockerCliConfig usage, but verify command escaping.

The migration to DockerCliConfig.getCliPath() is consistent with the broader refactoring.

However, verify that line 375's command construction is safe:

actualCommand = '$dockerCli exec $containerId $executable -c "$command"';

The $command variable contains user input that's wrapped in double quotes. If the user enters a command containing double quotes or other shell metacharacters, this could lead to command injection. For example, input like "; rm -rf /; " would break out of the quotes.

Consider using proper shell escaping or parameterized command execution if available in your SSH library.

Also applies to: 370-376

lib/main.dart (1)

6-11: No changes required — DI setup is confirmed synchronous.

Verification confirms that setupServiceLocator() contains only synchronous operations with no async/await patterns or Future returns. All service registrations use direct instantiation, so services are immediately available after the function completes. The original concern about potential race conditions does not apply to this implementation.

lib/core/di/service_locator.dart (1)

15-53: DI wiring via GetIt is cohesive and consistent

The service locator setup cleanly registers core infrastructure, repositories, and domain services as singletons, and the dependency graph (e.g., DockerOperationsServiceImpl needing DockerRepository + SSHConnectionService) is wired correctly. The “call once at startup” contract is clear and appropriate for this pattern.

lib/domain/services/image_management_service.dart (1)

1-22: Image management domain interface is clean and focused

The ImageManagementService contract and ImageBuildConfig shape the image APIs nicely and should keep the UI decoupled from SSH/CLI details. Good separation of concerns.

lib/core/utils/docker_cli_config.dart (1)

3-32: Centralized Docker CLI configuration utility looks solid

DockerCliConfig cleanly encapsulates persistence of the CLI path and command construction logic, which should make future switches (e.g., to podman) localized and easy to manage.

lib/presentation/screens/home_screen.dart (1)

26-102: HomeScreen DI and server-selection flow look well-structured

Resolving ServerRepository and SSHConnectionService via the service locator in initState, reloading the last used server on resume, and explicitly updating _sshService.currentServer / didServerChange in _selectServer give you a clear, DI-friendly lifecycle. The _pages getter using fresh screen instances on rebuild also plays nicely with the shared SSH state.

lib/presentation/screens/images/pull_image_screen.dart (1)

3-5: DI usage for registry and image management services looks consistent

Resolving DockerRegistryService and ImageManagementService via getIt in initState aligns with the new DI setup and keeps the widget free of direct SSH/CLI concerns. The late final pattern here is appropriate and plays nicely with hot reload and testability (you can override registrations in tests as needed).

Also applies to: 14-15, 24-29

lib/presentation/screens/base/base_resource_screen.dart (1)

19-37: Base state and DI wiring look aligned with the new architecture

The base state class cleanly injects SSHConnectionService, DockerRepository, DockerOperationsService, and ServerRepository via getIt, and the abstract methods (fetchItems, filterItems, getResourceName, getEmptyIcon, buildItemCard) give concrete screens a clear contract. This should significantly reduce per-screen boilerplate once consumers are migrated.

Also applies to: 55-60

lib/presentation/screens/containers_screen.dart (2)

361-535: Empty / no-results / list composition is consistent and leverages the base API

The empty state, no-results state, and main item list all consistently reuse SearchBarWithSettings, the stack filter chips, and the base loadItems/onSearchChanged wiring. The composition looks correct and matches the BaseResourceScreen contract; no issues from a behavior or UX standpoint.


663-762: Shell executable dialog is nicely designed; controller lifecycle is acceptable here

The shell selection dialog with pre-populated common executables and integration with _openInteractiveShell provides flexible UX and clean navigation (closing the dialog before pushing a new route). Creating the TextEditingController inside _showShellExecutableDialog is fine given the dialog’s short lifetime; there’s no pressing need to promote it to state just to dispose it.

lib/presentation/screens/images_screen.dart (2)

18-33: Filtering implementation is straightforward and correct

The image filtering logic (repository/tag/ID/size, all lowercased) is clear and efficient, and returning the original list when query.isEmpty avoids unnecessary allocations. No issues here.


221-303: Empty, no-results, and list states align well with the base pattern

The empty state, no-search-results state, and main buildItemList implementation are consistent with other screens and correctly hook into onSearchChanged and filteredItems. The “Pull down to refresh” hint is clear given a pull-to-refresh container; no functional concerns.

lib/presentation/screens/volumes_screen.dart (3)

18-31: Volume fetching and filtering are straightforward and correct

fetchItems delegating to dockerRepository.getVolumes() and filterItems matching on volumeName and driver (with early return for empty query) are both idiomatic and efficient. No functional issues here.


40-126: Volume card UI is clear and idiomatic

The volume card cleanly displays the name and driver with appropriate theming and makes good use of DockerResourceActions. The use of const Icon and const TextStyle where possible is a nice touch for small performance wins. No changes needed.


128-210: Empty/no-results/list implementations are consistent with other resource screens

The volume-specific empty state, filtered list, and search bar wiring correctly follow the BaseResourceScreen pattern and reuse SearchBarWithSettings. UX and behavior look consistent with images/networks.

lib/presentation/screens/networks_screen.dart (2)

18-32: Network fetching and filtering logic is sound

The networks are fetched via dockerRepository.getNetworks(), and filterItems correctly matches on name, driver, or ID with an early return when the query is empty. No issues here.


203-285: Empty/no-results/list implementations are consistent and correct

The networks empty state, no-results variant, and buildItemList correctly reuse SearchBarWithSettings, filteredItems, and the base callbacks. UX and wiring look aligned with the other resource screens.

Comment thread lib/data/services/container_creation_service_impl.dart
Comment thread lib/data/services/container_creation_service_impl.dart
Comment thread lib/data/services/image_management_service_impl.dart
Comment thread lib/data/services/ssh_connection_service.dart
Comment thread lib/presentation/screens/containers/create_container_screen.dart

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🧹 Nitpick comments (5)
lib/presentation/screens/base/base_resource_screen.dart (3)

18-18: Unused mixin: AutomaticKeepAliveClientMixin.

The AutomaticKeepAliveClientMixin is included but wantKeepAlive returns false (line 34), which means the mixin has no effect. This mixin is typically used to keep the state alive when a tab or page is not visible.

If you don't need to keep the state alive when navigating away, remove the mixin:

-abstract class BaseResourceScreenState<T, W extends BaseResourceScreen<T>> 
-    extends State<W> with AutomaticKeepAliveClientMixin, WidgetsBindingObserver {
+abstract class BaseResourceScreenState<T, W extends BaseResourceScreen<T>> 
+    extends State<W> with WidgetsBindingObserver {

And remove the override:

-  @override
-  bool get wantKeepAlive => false;

39-39: Unused WidgetsBindingObserver registration.

WidgetsBindingObserver is added in initState and removed in dispose, but no observer methods (like didChangeAppLifecycleState) are overridden in the class. This registration has no effect.

If you don't need app lifecycle observation, remove the mixin and registration:

 abstract class BaseResourceScreenState<T, W extends BaseResourceScreen<T>> 
-    extends State<W> with AutomaticKeepAliveClientMixin, WidgetsBindingObserver {
+    extends State<W> {
   @override
   void initState() {
     super.initState();
-    WidgetsBinding.instance.addObserver(this);
     _initializeScreen();
   }

   @override
   void dispose() {
-    WidgetsBinding.instance.removeObserver(this);
     super.dispose();
   }

Also applies to: 46-46


133-136: State mutation outside setState.

Lines 134-135 mutate items and filteredItems directly without wrapping in setState. While this is technically allowed for fields that don't affect the current widget tree, it's inconsistent with Flutter best practices and can lead to confusion.

Wrap state mutations in setState for consistency:

       if (mounted) {
+        setState(() {
           items = fetchedItems;
           filteredItems = filterItems(fetchedItems, searchQuery);
+        });
       }

Note: This is safe to do here because the caller (_loadItemsAndUpdateState) already handles the setState for isLoading and errorState after this method returns.

lib/presentation/screens/containers_screen.dart (2)

26-30: Potential race condition with delayed stats loading.

The delayed stats loading (lines 26-30) only checks mounted but doesn't verify that the screen hasn't been refreshed or reinitialized. If the user triggers a refresh before the delay completes, _loadContainerStats will execute with potentially stale data.

Add a generation counter to track refresh cycles:

+  int _dataGeneration = 0;
+
   @override
   Future<List<DockerContainer>> fetchItems() async {
     final containers = await dockerRepository.getContainers();
     
+    final currentGeneration = ++_dataGeneration;
     // Fetch stats asynchronously in the background after showing containers
     Future.delayed(const Duration(milliseconds: 100), () {
-      if (mounted) {
+      if (mounted && _dataGeneration == currentGeneration) {
         _loadContainerStats();
       }
     });

591-609: Loading SnackBar not cleared on error.

Lines 591-609 show a loading SnackBar with a 2-second duration. However, if the operation fails quickly (within 2 seconds), the loading SnackBar will overlap with the error SnackBar shown at lines 648-661. While clearSnackBars is called at line 648, this creates visual noise.

Show the loading SnackBar with an indefinite duration and clear it explicitly:

       if (mounted) {
         ScaffoldMessenger.of(context).showSnackBar(
           SnackBar(
             content: Row(
               children: [
                 const SizedBox(
                   width: 16,
                   height: 16,
                   child: CircularProgressIndicator(strokeWidth: 2),
                 ),
                 const SizedBox(width: 12),
                 Text('${action.label}...'),
               ],
             ),
-            duration: const Duration(seconds: 2),
+            duration: const Duration(days: 365), // Indefinite
           ),
         );
       }
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between fe75ce8 and 75b43d5.

📒 Files selected for processing (5)
  • lib/presentation/screens/base/base_resource_screen.dart (1 hunks)
  • lib/presentation/screens/containers_screen.dart (8 hunks)
  • lib/presentation/screens/images_screen.dart (5 hunks)
  • lib/presentation/screens/networks_screen.dart (5 hunks)
  • lib/presentation/screens/volumes_screen.dart (4 hunks)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (2)
  • GitHub Check: build
  • GitHub Check: Build Windows Release

Comment on lines +51 to +91
Future<void> _initializeScreen() async {
setState(() {
isLoading = true;
errorState = null;
});

// Check if we need to connect
final needsConnection = sshService.didServerChange || !sshService.isConnected;

if (needsConnection) {
// Reset server change flag
sshService.didServerChange = false;

// Try to connect
final connectionError = await _connectToServer();

if (connectionError != null) {
// Connection failed
if (mounted) {
setState(() {
isLoading = false;
errorState = connectionError;
});
}
return;
}
}

// Connection good (or already connected), load data
await _loadItemsAndUpdateState();
}

// Public method for child classes to trigger refresh
void refresh() {
_initializeScreen();
}

// Common refresh logic for pull-to-refresh
Future<void> refreshItems() async {
await _initializeScreen();
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Missing concurrency guard for initialization.

_initializeScreen can be called multiple times concurrently from:

  • initState (line 41)
  • refresh (line 85)
  • refreshItems (line 90)
  • Server change detection in build (line 244)

This can lead to race conditions where multiple connection attempts and data fetches occur simultaneously, potentially causing inconsistent state or duplicate operations.

Add a guard to prevent concurrent initialization:

+  bool _isInitializing = false;
+
   Future<void> _initializeScreen() async {
+    if (_isInitializing) return;
+    _isInitializing = true;
+    
     setState(() {
       isLoading = true;
       errorState = null;
     });

     // ... rest of method
     
     // Connection good (or already connected), load data
     await _loadItemsAndUpdateState();
+    _isInitializing = false;
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
Future<void> _initializeScreen() async {
setState(() {
isLoading = true;
errorState = null;
});
// Check if we need to connect
final needsConnection = sshService.didServerChange || !sshService.isConnected;
if (needsConnection) {
// Reset server change flag
sshService.didServerChange = false;
// Try to connect
final connectionError = await _connectToServer();
if (connectionError != null) {
// Connection failed
if (mounted) {
setState(() {
isLoading = false;
errorState = connectionError;
});
}
return;
}
}
// Connection good (or already connected), load data
await _loadItemsAndUpdateState();
}
// Public method for child classes to trigger refresh
void refresh() {
_initializeScreen();
}
// Common refresh logic for pull-to-refresh
Future<void> refreshItems() async {
await _initializeScreen();
}
bool _isInitializing = false;
Future<void> _initializeScreen() async {
if (_isInitializing) return;
_isInitializing = true;
try {
setState(() {
isLoading = true;
errorState = null;
});
// Check if we need to connect
final needsConnection = sshService.didServerChange || !sshService.isConnected;
if (needsConnection) {
// Reset server change flag
sshService.didServerChange = false;
// Try to connect
final connectionError = await _connectToServer();
if (connectionError != null) {
// Connection failed
if (mounted) {
setState(() {
isLoading = false;
errorState = connectionError;
});
}
return;
}
}
// Connection good (or already connected), load data
await _loadItemsAndUpdateState();
} finally {
_isInitializing = false;
}
}
// Public method for child classes to trigger refresh
void refresh() {
_initializeScreen();
}
// Common refresh logic for pull-to-refresh
Future<void> refreshItems() async {
await _initializeScreen();
}
🤖 Prompt for AI Agents
In lib/presentation/screens/base/base_resource_screen.dart around lines 51 to
91, _initializeScreen lacks a concurrency guard and can be re-entered from
initState, refresh, refreshItems, or build, causing race conditions; add a
private boolean flag (e.g., _isInitializing) checked at the top to immediately
return if true, set it to true before any async work starts, and clear it in a
finally block so it always resets even on errors; ensure setState calls still
check mounted and that the flag prevents overlapping connection attempts and
data loads.

Comment on lines +100 to +102
if (loadError?.message == '__RECONNECT__') {
// Connection error detected, retry initialization
_initializeScreen();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Potential infinite reconnection loop.

When a connection error is detected, _loadItems returns a special '__RECONNECT__' message (line 154), which triggers a recursive call to _initializeScreen (line 102). Without a retry limit or backoff strategy, this can lead to an infinite loop if the connection continues to fail.

Add retry limits and exponential backoff:

+  int _reconnectAttempts = 0;
+  static const int _maxReconnectAttempts = 3;
+
   Future<void> _loadItemsAndUpdateState() async {
     final loadError = await _loadItems();
     
     if (!mounted) return;
     
     // Check if it's a reconnect signal
     if (loadError?.message == '__RECONNECT__') {
+      if (_reconnectAttempts >= _maxReconnectAttempts) {
+        setState(() {
+          isLoading = false;
+          errorState = ErrorState.connection(
+            message: 'Connection failed after $_maxReconnectAttempts attempts',
+            onRetry: () {
+              _reconnectAttempts = 0;
+              _initializeScreen();
+            },
+          );
+        });
+        return;
+      }
+      _reconnectAttempts++;
       // Connection error detected, retry initialization
       _initializeScreen();
     } else {
+      _reconnectAttempts = 0;
       setState(() {
         isLoading = false;
         errorState = loadError;
       });
     }
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (loadError?.message == '__RECONNECT__') {
// Connection error detected, retry initialization
_initializeScreen();
int _reconnectAttempts = 0;
static const int _maxReconnectAttempts = 3;
Future<void> _loadItemsAndUpdateState() async {
final loadError = await _loadItems();
if (!mounted) return;
// Check if it's a reconnect signal
if (loadError?.message == '__RECONNECT__') {
if (_reconnectAttempts >= _maxReconnectAttempts) {
setState(() {
isLoading = false;
errorState = ErrorState.connection(
message: 'Connection failed after $_maxReconnectAttempts attempts',
onRetry: () {
_reconnectAttempts = 0;
_initializeScreen();
},
);
});
return;
}
_reconnectAttempts++;
// Connection error detected, retry initialization
_initializeScreen();
} else {
_reconnectAttempts = 0;
setState(() {
isLoading = false;
errorState = loadError;
});
}
}
🤖 Prompt for AI Agents
In lib/presentation/screens/base/base_resource_screen.dart around lines 100 to
102, the code calls _initializeScreen() unconditionally when loadError?.message
== '__RECONNECT__', which can create an infinite recursive reconnect loop;
modify this flow to track retry attempts (e.g., an int _reconnectAttempts field)
and a maxRetries constant, and replace the direct recursive call with a
scheduled retry using exponential backoff (e.g., delay = baseDelay *
2^_reconnectAttempts) via Future.delayed before calling _initializeScreen();
increment _reconnectAttempts on each retry, reset it on a successful init, and
when _reconnectAttempts >= maxRetries stop retrying and surface a persistent
error state or notification so the UI stops looping.

Comment on lines +220 to +237
// Debug toast with random number
if (kDebugMode) {
final randomNum = Random().nextInt(10000);
final serverName = sshService.currentServer?.name ?? 'NULL';
final isConnected = sshService.isConnected;

WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('[DEBUG] ${getResourceName()} - Random: $randomNum | Server: $serverName | Connected: $isConnected | Loading: $isLoading'),
duration: const Duration(seconds: 3),
behavior: SnackBarBehavior.floating,
),
);
}
});
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Remove debug code or make it optional.

Lines 220-237 show a debug SnackBar on every build with a random number. This creates UI noise during development and should either be removed or made optional via a flag.

Remove the debug toast or add a flag to control it:

     super.build(context);
     
-    // Debug toast with random number
-    if (kDebugMode) {
-      final randomNum = Random().nextInt(10000);
-      final serverName = sshService.currentServer?.name ?? 'NULL';
-      final isConnected = sshService.isConnected;
-      
-      WidgetsBinding.instance.addPostFrameCallback((_) {
-        if (mounted) {
-          ScaffoldMessenger.of(context).showSnackBar(
-            SnackBar(
-              content: Text('[DEBUG] ${getResourceName()} - Random: $randomNum | Server: $serverName | Connected: $isConnected | Loading: $isLoading'),
-              duration: const Duration(seconds: 3),
-              behavior: SnackBarBehavior.floating,
-            ),
-          );
-        }
-      });
-    }
-    
     // CHECK: Server change detection
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Debug toast with random number
if (kDebugMode) {
final randomNum = Random().nextInt(10000);
final serverName = sshService.currentServer?.name ?? 'NULL';
final isConnected = sshService.isConnected;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('[DEBUG] ${getResourceName()} - Random: $randomNum | Server: $serverName | Connected: $isConnected | Loading: $isLoading'),
duration: const Duration(seconds: 3),
behavior: SnackBarBehavior.floating,
),
);
}
});
}
super.build(context);
// CHECK: Server change detection
🤖 Prompt for AI Agents
lib/presentation/screens/base/base_resource_screen.dart lines 220-237: the
current code shows a debug SnackBar on every build with a random number causing
UI noise; either remove the debug block entirely or make it opt-in by
introducing a boolean flag (e.g., showDebugSnackBar) read from a debug/config
value, only show the SnackBar when that flag is true, and move the SnackBar
logic out of the build path (e.g., trigger it in initState or behind an explicit
user action) to avoid firing on every rebuild; also remove the per-build
Random() usage or generate the debug value once when the debug notification is
created.

Comment on lines +666 to 750
void _showShellExecutableDialog(DockerContainer container) {
final TextEditingController executableController = TextEditingController(text: '/bin/bash');

final commonExecutables = [
'/bin/bash',
'/bin/sh',
'/bin/ash',
'/bin/zsh',
'/bin/fish',
'redis-cli',
'mysql',
'psql',
'mongo',
'python',
'node',
];

showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: Text('Choose Shell - ${container.names}'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: executableController,
autofocus: true,
decoration: const InputDecoration(
labelText: 'Executable',
hintText: 'Enter executable path or command',
border: OutlineInputBorder(),
),
onSubmitted: (value) {
if (value.trim().isNotEmpty) {
Navigator.of(context).pop();
_openInteractiveShell(container, value.trim());
}
},
),
const SizedBox(height: 16),
const Text(
'Common executables:',
style: TextStyle(fontWeight: FontWeight.w500),
),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.blue.withOpacity(0.05),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.blue.withOpacity(0.2)),
SizedBox(
height: 150,
child: SingleChildScrollView(
child: Wrap(
spacing: 8,
runSpacing: 4,
children: commonExecutables.map((executable) {
return ActionChip(
label: Text(executable),
onPressed: () {
executableController.text = executable;
},
);
}).toList(),
),
),
child: container.hasStats
? Row(
children: [
Expanded(
child: _buildStatColumn(
icon: Icons.speed,
label: 'CPU',
value: container.cpuPerc ?? 'N/A',
),
),
Expanded(
child: _buildStatColumn(
icon: Icons.memory,
label: 'Memory',
value: container.memPerc ?? 'N/A',
),
),
Expanded(
child: _buildStatColumn(
icon: Icons.cloud_queue,
label: 'Network',
value: container.netIO ?? 'N/A',
),
),
Expanded(
child: _buildStatColumn(
icon: Icons.format_list_numbered,
label: 'PIDs',
value: container.pids ?? 'N/A',
),
),
],
)
: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(
Colors.blue[700]!,
),
),
),
const SizedBox(width: 8),
Text(
'Loading stats...',
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
),
),
],
),
),
],
],
),
),
);
}

Widget _buildDetailRow(String label, String value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 80,
child: Text(
'$label:',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
fontWeight: FontWeight.w500,
color: Colors.grey[600],
),
),
),
Expanded(
child: Text(
value,
style: Theme.of(context).textTheme.bodySmall,
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
),
],
),
ElevatedButton(
onPressed: () {
final executable = executableController.text.trim();
if (executable.isNotEmpty) {
Navigator.of(context).pop();
_openInteractiveShell(container, executable);
}
},
child: const Text('Connect'),
),
],
);
},
);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Memory leak: TextEditingController not disposed.

Line 667 creates a TextEditingController that is never disposed. This causes a memory leak each time the dialog is shown.

Dispose the controller when the dialog is dismissed:

   void _showShellExecutableDialog(DockerContainer container) {
     final TextEditingController executableController = TextEditingController(text: '/bin/bash');
     
     final commonExecutables = [
       '/bin/bash',
       '/bin/sh',
       '/bin/ash',
       '/bin/zsh',
       '/bin/fish',
       'redis-cli',
       'mysql',
       'psql',
       'mongo',
       'python',
       'node',
     ];

     showDialog(
       context: context,
       builder: (BuildContext context) {
         return AlertDialog(
           title: Text('Choose Shell - ${container.names}'),
           content: Column(
             mainAxisSize: MainAxisSize.min,
             children: [
               TextField(
                 controller: executableController,
                 autofocus: true,
                 decoration: const InputDecoration(
                   labelText: 'Executable',
                   hintText: 'Enter executable path or command',
                   border: OutlineInputBorder(),
                 ),
                 onSubmitted: (value) {
                   if (value.trim().isNotEmpty) {
                     Navigator.of(context).pop();
                     _openInteractiveShell(container, value.trim());
                   }
                 },
               ),
               // ... rest of dialog
             ],
           ),
           actions: [
             TextButton(
               onPressed: () => Navigator.of(context).pop(),
               child: const Text('Cancel'),
             ),
             ElevatedButton(
               onPressed: () {
                 final executable = executableController.text.trim();
                 if (executable.isNotEmpty) {
                   Navigator.of(context).pop();
                   _openInteractiveShell(container, executable);
                 }
               },
               child: const Text('Connect'),
             ),
           ],
         );
       },
-    );
+    ).then((_) => executableController.dispose());
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
void _showShellExecutableDialog(DockerContainer container) {
final TextEditingController executableController = TextEditingController(text: '/bin/bash');
final commonExecutables = [
'/bin/bash',
'/bin/sh',
'/bin/ash',
'/bin/zsh',
'/bin/fish',
'redis-cli',
'mysql',
'psql',
'mongo',
'python',
'node',
];
showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: Text('Choose Shell - ${container.names}'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: executableController,
autofocus: true,
decoration: const InputDecoration(
labelText: 'Executable',
hintText: 'Enter executable path or command',
border: OutlineInputBorder(),
),
onSubmitted: (value) {
if (value.trim().isNotEmpty) {
Navigator.of(context).pop();
_openInteractiveShell(container, value.trim());
}
},
),
const SizedBox(height: 16),
const Text(
'Common executables:',
style: TextStyle(fontWeight: FontWeight.w500),
),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.blue.withOpacity(0.05),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.blue.withOpacity(0.2)),
SizedBox(
height: 150,
child: SingleChildScrollView(
child: Wrap(
spacing: 8,
runSpacing: 4,
children: commonExecutables.map((executable) {
return ActionChip(
label: Text(executable),
onPressed: () {
executableController.text = executable;
},
);
}).toList(),
),
),
child: container.hasStats
? Row(
children: [
Expanded(
child: _buildStatColumn(
icon: Icons.speed,
label: 'CPU',
value: container.cpuPerc ?? 'N/A',
),
),
Expanded(
child: _buildStatColumn(
icon: Icons.memory,
label: 'Memory',
value: container.memPerc ?? 'N/A',
),
),
Expanded(
child: _buildStatColumn(
icon: Icons.cloud_queue,
label: 'Network',
value: container.netIO ?? 'N/A',
),
),
Expanded(
child: _buildStatColumn(
icon: Icons.format_list_numbered,
label: 'PIDs',
value: container.pids ?? 'N/A',
),
),
],
)
: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(
Colors.blue[700]!,
),
),
),
const SizedBox(width: 8),
Text(
'Loading stats...',
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
),
),
],
),
),
],
],
),
),
);
}
Widget _buildDetailRow(String label, String value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 80,
child: Text(
'$label:',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
fontWeight: FontWeight.w500,
color: Colors.grey[600],
),
),
),
Expanded(
child: Text(
value,
style: Theme.of(context).textTheme.bodySmall,
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
),
],
),
ElevatedButton(
onPressed: () {
final executable = executableController.text.trim();
if (executable.isNotEmpty) {
Navigator.of(context).pop();
_openInteractiveShell(container, executable);
}
},
child: const Text('Connect'),
),
],
);
},
);
}
void _showShellExecutableDialog(DockerContainer container) {
final TextEditingController executableController = TextEditingController(text: '/bin/bash');
final commonExecutables = [
'/bin/bash',
'/bin/sh',
'/bin/ash',
'/bin/zsh',
'/bin/fish',
'redis-cli',
'mysql',
'psql',
'mongo',
'python',
'node',
];
showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: Text('Choose Shell - ${container.names}'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: executableController,
autofocus: true,
decoration: const InputDecoration(
labelText: 'Executable',
hintText: 'Enter executable path or command',
border: OutlineInputBorder(),
),
onSubmitted: (value) {
if (value.trim().isNotEmpty) {
Navigator.of(context).pop();
_openInteractiveShell(container, value.trim());
}
},
),
const SizedBox(height: 16),
const Text(
'Common executables:',
style: TextStyle(fontWeight: FontWeight.w500),
),
const SizedBox(height: 8),
SizedBox(
height: 150,
child: SingleChildScrollView(
child: Wrap(
spacing: 8,
runSpacing: 4,
children: commonExecutables.map((executable) {
return ActionChip(
label: Text(executable),
onPressed: () {
executableController.text = executable;
},
);
}).toList(),
),
),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
ElevatedButton(
onPressed: () {
final executable = executableController.text.trim();
if (executable.isNotEmpty) {
Navigator.of(context).pop();
_openInteractiveShell(container, executable);
}
},
child: const Text('Connect'),
),
],
);
},
).then((_) => executableController.dispose());
}
🤖 Prompt for AI Agents
In lib/presentation/screens/containers_screen.dart around lines 666 to 750 a
TextEditingController is created for the dialog and never disposed, causing a
memory leak; fix by ensuring the controller is disposed after the dialog is
dismissed (for example, call showDialog(...) and then dispose the controller in
the returned Future's then/catchError or use async/await with try/finally), i.e.
create the controller as now, pass it to the dialog, and after showDialog
completes always call executableController.dispose().

child: Row(
mainAxisSize: MainAxisSize.min,
children: [
children: const [

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Incorrect const on children list.

Line 194 marks the children list as const, but the list contains SizedBox widgets that are not compile-time constants. This will cause a compilation error or inconsistent behavior.

Remove the const keyword:

                     child: Row(
                       mainAxisSize: MainAxisSize.min,
-                      children: const [
+                      children: [
                         Icon(
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
children: const [
children: [
🤖 Prompt for AI Agents
In lib/presentation/screens/images_screen.dart around line 194, the children
list is incorrectly declared as const even though it contains non-constant
widgets (SizedBox), which causes compile errors; remove the const keyword before
the children list so the list and its widget entries are evaluated at runtime,
then run a build to verify the error is resolved.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (1)
lib/presentation/screens/base/base_resource_screen.dart (1)

270-287: Debug SnackBar on every build is noisy even in debug

The debug block shows a SnackBar with a random number on every build while kDebugMode is true. This can easily spam SnackBars on rebuilds and makes it harder to see more meaningful messages.

Consider either removing this entirely, or guarding it behind a dedicated debug flag / developer setting and triggering it from a one‑off action (e.g., a button) rather than every build.

If you want to keep some form of debug feedback, you might switch to simple debugPrint calls or a single SnackBar triggered from initState instead of every rebuild.

🧹 Nitpick comments (2)
lib/presentation/screens/base/base_resource_screen.dart (2)

202-240: Clean up or re‑introduce legacy helpers to avoid confusion

loadItems, refreshItems, didChangeAppLifecycleState, buildBody, and buildErrorState are currently effectively dead code (logic commented out or replaced with Container()), while the new flow is entirely driven by _loadItems, _connectToServer, and the build method.

This is harmless at runtime but increases cognitive load for anyone extending the base class.

Options to simplify:

  • If the new CHECK‑based flow is the only supported path, remove these unused methods (or at least mark them @deprecated) to avoid accidental use.
  • If you plan to re‑enable them (e.g., for pull‑to‑refresh or lifecycle reload), consider wiring them to the new _loadItems/errorState model instead of leaving commented blocks in place.

It’d be good to grep for refreshItems() and buildBody() usages in the project to ensure nothing still depends on the old behaviour before removing or refactoring them.

Also applies to: 253-263, 479-546


289-382: Avoid starting async work and mutating state directly in build

The build method currently:

  • Computes needsConnection / needsLoad.
  • Mutates isLoading and errorState directly.
  • Starts async operations (_connectToServer, _loadItems) from within build.

Functionally this works due to the !isLoading guard and the completion handlers calling setState, but triggering side‑effects from build is a Flutter anti‑pattern and can make behaviour harder to reason about as the widget tree evolves.

A more robust structure would be to:

  • Move the “connect & load if needed” orchestration into a private method (e.g., _ensureConnectedAndLoaded()) invoked from initState and from explicit events (server change, manual refresh).
  • Let build read isLoading, errorState, and items only, without starting new work.

This keeps rendering and side‑effects separated and reduces the chances of subtle re‑entrancy issues as more logic is added.

If you refactor this, please confirm that server‑change detection (via sshService.didServerChange) still reliably triggers a reload path without relying on build side‑effects.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 75b43d5 and 516681f.

📒 Files selected for processing (1)
  • lib/presentation/screens/base/base_resource_screen.dart (1 hunks)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (2)
  • GitHub Check: build
  • GitHub Check: Build Windows Release
🔇 Additional comments (1)
lib/presentation/screens/base/base_resource_screen.dart (1)

18-36: Base abstraction & DI wiring look coherent

The generic BaseResourceScreenState<T, W> plus GetIt‑backed service fields provide a clear, reusable foundation for resource screens; state fields cover loading, error, and filtering concerns cleanly. No functional issues spotted here.

Please just confirm that service_locator.dart always initializes GetIt before any BaseResourceScreen is created (e.g., in main()), so these late fields are never accessed before registration.

Comment on lines +63 to +190
Future<ErrorState?> _loadItems() async {
if (kDebugMode) {
print('[CHECK 2] Loading ${getResourceName()}...');
}

try {
final fetchedItems = await fetchItems();

if (kDebugMode) {
print('[CHECK 2] Loaded ${fetchedItems.length} ${getResourceName()}');
}

if (mounted) {
items = fetchedItems;
filteredItems = filterItems(fetchedItems, searchQuery);
hasTriedLoading = true;
}

return null; // Success
} catch (e) {
if (kDebugMode) {
print('[CHECK 2] Error loading ${getResourceName()}: $e');
}

final errorMessage = e.toString();

// Check if it's a connection error
if (_isConnectionError(errorMessage)) {
if (kDebugMode) {
print('[CHECK 2.1] Connection error detected - forcing reconnection');
}

// Force reconnection by resetting flag AND keeping isLoading true
// Caller will call setState which triggers CHECK 1 on next build
if (mounted) {
hasTriedConnecting = false;
// Return special marker so caller knows to keep loading
return ErrorState.connection(
message: '__RECONNECT__', // Special marker
onRetry: null,
);
}
}

// Check if it's a permission error
if (_isPermissionError(errorMessage)) {
if (kDebugMode) {
print('[CHECK 2.2] Permission error detected');
}

if (mounted) {
hasTriedLoading = true; // Mark as tried to prevent infinite loop
}

return ErrorState.permission(
message: errorMessage,
onRetry: () {
// For permission errors, just retry loading (don't reconnect)
setState(() {
errorState = null;
isLoading = true;
});

// Reset flag and load again
hasTriedLoading = false;
_loadItems().then((error) {
if (mounted) {
// Check if it's a reconnect signal
if (error?.message == '__RECONNECT__') {
setState(() {
isLoading = false;
});
} else {
setState(() {
isLoading = false;
errorState = error;
});
}
}
});
},
);
}

// Other error - return error state
if (mounted) {
hasTriedLoading = true; // Mark as tried to prevent infinite loop
}

return ErrorState.general(
message: errorMessage,
onRetry: () {
// For general errors, retry loading directly
setState(() {
errorState = null;
isLoading = true;
});

hasTriedLoading = false;
_loadItems().then((error) {
if (mounted) {
// Check if it's a reconnect signal
if (error?.message == '__RECONNECT__') {
setState(() {
isLoading = false;
});
} else {
setState(() {
isLoading = false;
errorState = error;
});
}
}
});
},
);
}
}

// Helper to detect connection errors
bool _isConnectionError(String error) {
final lowerError = error.toLowerCase();
return lowerError.contains('connection') ||
lowerError.contains('ssh') ||
lowerError.contains('timeout') ||
lowerError.contains('refused') ||
lowerError.contains('closed');
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

Unbounded reconnect/reload loop risk with '__RECONNECT__' marker

When _loadItems classifies an error as a connection error, it returns an ErrorState.connection whose message is '__RECONNECT__'. All callers treat this as a special “retry” signal and simply clear isLoading, letting the build logic re‑enter the connect/load path on the next frame. If fetchItems() continues to throw connection‑style errors (e.g., remote Docker down, flaky SSH tunnel), this creates an effectively infinite reconnect/reload loop with no max attempt cap or backoff.

This can:

  • Hammer the remote server / network.
  • Keep the UI stuck in a loading cycle with no stable error screen.
  • Make it hard for users to intervene (no surfaced error once the loop starts).

I’d strongly recommend introducing a bounded retry strategy here, for example:

  • Track a _reconnectAttempts counter and a _maxReconnectAttempts constant on the state.
  • Increment the counter in the _isConnectionError branch; only return the '__RECONNECT__' marker while _reconnectAttempts < _maxReconnectAttempts.
  • Once the limit is hit, set hasTriedLoading = true and return a normal ErrorState.connection with a user‑facing message and an onRetry that resets the counter and triggers a single explicit reconnect + load cycle (without further auto‑looping).

You might also consider replacing the string sentinel with a dedicated flag/type on ErrorState to avoid brittle message == '__RECONNECT__' checks.

Please verify behaviour manually by forcing fetchItems() to always throw a connection‑like error (e.g., by mocking DockerRepository/DockerOperationsService) and observe that the screen doesn’t loop indefinitely after adding a retry cap. I can help sketch a concrete diff if you want to move to a counter‑based approach.


I'll verify the reconnect loop issue by examining the codebase. Let me start by exploring the relevant files.

Now let me examine the actual codebase to verify the reconnect loop concern:

I'll now examine the codebase directly to verify the reconnect loop issue:


Now searching the codebase:


[/run_scripts]

---

🏁 Script executed:

```shell
#!/bin/bash
# Search for base_resource_screen.dart and examine the full file
fd -t f 'base_resource_screen.dart' -exec head -300 {} \;

Length of output: 230


🏁 Script executed:

#!/bin/bash
# Find the file and examine it
find . -name 'base_resource_screen.dart' 2>/dev/null | head -1

Length of output: 57


[/run_scripts]


🏁 Script executed:

#!/bin/bash
# Read the full file to understand the complete structure
cat ./lib/presentation/screens/base/base_resource_screen.dart

Length of output: 18833


🏁 Script executed:

#!/bin/bash
# Also search for ErrorState definition
rg -n 'class ErrorState' --type=dart

Length of output: 157


🏁 Script executed:

#!/bin/bash
# Search for __RECONNECT__ usage patterns
rg -n '__RECONNECT__' --type=dart -A 2 -B 2

Length of output: 2581


🏁 Script executed:

#!/bin/bash
# Search for any existing attempt counters
rg -n '_reconnectAttempts|_loadAttempts|_maxRetry|maxAttempts' --type=dart

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Search for the build method in base_resource_screen
rg -n 'Widget build' ./lib/presentation/screens/base/base_resource_screen.dart -A 50

Length of output: 7715


The infinite reconnect loop issue is confirmed and accurately described

The code exhibits the exact unbounded retry problem identified in the review. The control flow confirms:

  1. Loop mechanism verified: When _loadItems() detects a connection error, it resets hasTriedConnecting = false (line 98) and returns the __RECONNECT__ marker. The next build cycle's CHECK 1 logic (line 290-291) evaluates needsConnection = !sshService.isConnected && !hasTriedConnecting as true, retriggering the load cycle indefinitely if fetchItems() continues throwing connection errors.

  2. No attempt counters exist: Search confirms no _reconnectAttempts, _loadAttempts, _maxRetry, or maxAttempts counters in the codebase.

  3. No user intervention possible: When the __RECONNECT__ marker is returned, errorState is cleared (line 302) and isLoading set to false briefly. The next build has no error UI to display (no errorState object), so users see only a loading spinner and cannot tap a retry button.

  4. String sentinel is problematic: The '__RECONNECT__' marker is checked via string equality at 5 locations (lines 131, 165, 327, 352, and implicit in retry callbacks). This pattern is fragile and couples internal logic to a magic string.

The recommendation to add a _reconnectAttempts counter with a _maxReconnectAttempts cap is appropriate and necessary to prevent indefinite server hammering and UI hangs.

🤖 Prompt for AI Agents
In lib/presentation/screens/base/base_resource_screen.dart around lines 63-190,
the connection-reconnect flow uses a magic string '__RECONNECT__' and resets
hasTriedConnecting to false without any attempt cap, causing an infinite
reconnect loop; add a private int _reconnectAttempts and a const int
_maxReconnectAttempts (e.g. 3-5), replace the string sentinel with a typed
signal (add a dedicated ErrorState.reconnect factory or boolean flag on
ErrorState) and increment _reconnectAttempts each time you return the reconnect
ErrorState; when _reconnectAttempts >= _maxReconnectAttempts, stop forcing
reconnection (set hasTriedConnecting = true and return a normal connection
ErrorState with a retry callback that allows the user to manually retry), and
reset _reconnectAttempts to 0 on a successful load or when the user manually
retries so the cycle is bounded and no longer depends on a fragile string check.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

♻️ Duplicate comments (2)
lib/presentation/screens/base/base_resource_screen.dart (2)

229-246: Per-build debug SnackBar is noisy and should be removed or gated

build currently shows a debug SnackBar with a random number on every rebuild in kDebugMode. This is very noisy once multiple resource screens are in use and tightly couples UI rebuilds to side effects.

I’d either:

  • Remove this block entirely, or
  • Gate it behind a dedicated debug/config flag and trigger it only on explicit user action or a one-off lifecycle hook (not on every build).

If you opt to remove it now:

-import 'dart:math';
@@
-    // Debug toast with random number
-    if (kDebugMode) {
-      final randomNum = Random().nextInt(10000);
-      final serverName = sshService.currentServer?.name ?? 'NULL';
-      final isConnected = sshService.isConnected;
-      
-      WidgetsBinding.instance.addPostFrameCallback((_) {
-        if (mounted) {
-          ScaffoldMessenger.of(context).showSnackBar(
-            SnackBar(
-              content: Text('[DEBUG] ${getResourceName()} - Random: $randomNum | Server: $serverName | Connected: $isConnected | Loading: $isLoading'),
-              duration: const Duration(seconds: 3),
-              behavior: SnackBarBehavior.floating,
-            ),
-          );
-        }
-      });
-    }
-    

62-104: Unbounded reconnect loop and fragile '__RECONNECT__' sentinel are still present

The current reconnect flow still relies on the magic string '__RECONNECT__' and continuously resets hasTriedConnecting without any attempt cap or backoff. If fetchItems() keeps producing connection-style errors, the screen will keep auto-retrying via CHECK 1 with no upper bound, potentially hammering the server and keeping the UI in a loop with no stable error state.

I recommend:

  • Tracking reconnect attempts with a counter and a max limit.
  • Returning a normal ErrorState.connection (with user-facing message + onRetry) once the limit is reached, so the UI stops auto-looping.
  • Resetting the counter on a successful load or when the user explicitly retries.
  • (Optional) Replacing the magic '__RECONNECT__' string with a typed signal on ErrorState in a follow-up.

Example of how you could bound the reconnects inside this class:

@@
   bool hasTriedConnecting = false;  // Track if we've attempted connection to prevent infinite retry loops
   bool hasTriedLoading = false;
+  int _reconnectAttempts = 0;
+  static const int _maxReconnectAttempts = 3;
@@
-      // Check if it's a connection error
-      if (_isConnectionError(errorMessage)) {
-        if (kDebugMode) {
-          print('[CHECK 2.1] Connection error detected - forcing reconnection');
-        }
-        
-        // Force reconnection by resetting flag AND keeping isLoading true
-        // Caller will call setState which triggers CHECK 1 on next build
-        if (mounted) {
-          hasTriedConnecting = false;
-          // Return special marker so caller knows to keep loading
-          return ErrorState.connection(
-            message: '__RECONNECT__', // Special marker
-            onRetry: null,
-          );
-        }
-      }
+      // Check if it's a connection error
+      if (_isConnectionError(errorMessage)) {
+        if (kDebugMode) {
+          print('[CHECK 2.1] Connection error detected - forcing reconnection');
+        }
+
+        if (mounted) {
+          if (_reconnectAttempts >= _maxReconnectAttempts) {
+            hasTriedLoading = true; // stop auto-looping
+            return ErrorState.connection(
+              message:
+                  'Failed to load ${getResourceName()} after $_maxReconnectAttempts attempts. Please check your connection and try again.',
+              onRetry: () {
+                setState(() {
+                  errorState = null;
+                  isLoading = true;
+                  hasTriedConnecting = false; // allow CHECK 1 to reconnect
+                  hasTriedLoading = false;
+                  _reconnectAttempts = 0;
+                });
+              },
+            );
+          }
+
+          _reconnectAttempts++;
+          hasTriedConnecting = false;
+
+          // Return special marker so caller knows to keep loading
+          return ErrorState.connection(
+            message: '__RECONNECT__', // Special marker
+            onRetry: null,
+          );
+        }
+      }
@@
-      if (mounted) {
+      if (mounted) {
         items = fetchedItems;
         filteredItems = filterItems(fetchedItems, searchQuery);
         hasTriedLoading = true;
+        _reconnectAttempts = 0; // reset on success
       }

You’d also want to reset _reconnectAttempts in any other “hard stop” branches where you surface a non-retrying ErrorState so future manual retries start clean.

Also applies to: 126-140, 160-176, 283-299, 308-323

🧹 Nitpick comments (1)
lib/presentation/screens/base/base_resource_screen.dart (1)

201-222: Clean up or formally deprecate no-op “deprecated” helpers

loadItems, refreshItems, didChangeAppLifecycleState, buildBody, and buildErrorState are left as empty “deprecated” stubs. Keeping them around without annotations makes the base API surface larger and less clear for consumers.

Either:

  • Remove these methods if they’re no longer used anywhere, or
  • Mark them with @Deprecated('Use _loadItems / buildItemList / errorState UI instead') and plan their removal in a later breaking change.

This keeps the base class focused and reduces confusion for implementers.

Also applies to: 438-447

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 516681f and af213fb.

📒 Files selected for processing (1)
  • lib/presentation/screens/base/base_resource_screen.dart (1 hunks)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant