Skip to content

Conversation

@lollipopkit
Copy link
Owner

@lollipopkit lollipopkit commented Aug 31, 2025

Fixes #871

Summary by Sourcery

Enable importing SSH servers by parsing ~/.ssh/config, integrating a manual import button and auto-import prompt, deduplicating and resolving conflicts, extending the Spi model, and covering the new workflow with unit tests

New Features:

  • Add SSH config import functionality to server edit page with desktop-only manual import button
  • Automatically prompt users to import servers from ~/.ssh/config on first server creation

Enhancements:

  • Introduce SSHConfig utility to detect, read, and parse SSH config files into Spi objects
  • Add ServerDeduplication utility to filter duplicates, resolve name conflicts, and generate import summaries
  • Extend Spi model with helper methods for ID parsing, serialization, comparison, URL parsing, and root user check
  • Add setting and toggle in application settings to control first-time SSH config import behavior

Documentation:

  • Add localization entries and translations for SSH config import prompts and messages

Tests:

  • Add unit tests for SSHConfig parsing across varied SSH config scenarios
  • Add unit tests for ServerDeduplication logic covering deduplication, name conflict resolution, and summary calculation
  • Add unit tests for server edit page logic including SSH import visibility, permission prompts, and form data processing

Chores:

  • Add new asset to GitHub contributor list

@sourcery-ai
Copy link

sourcery-ai bot commented Aug 31, 2025

Reviewer's Guide

Implements SSH config import by adding an SSHConfig parser, server deduplication utilities, UI integration for triggering imports (both initial prompt and manual import in the server edit form), corresponding settings and localization, and extensive tests covering the new import workflow and core utilities.

Sequence diagram for manual SSH config import in server edit page

sequenceDiagram
    actor User
    participant ServerEditPage
    participant SSHConfig
    participant ServerDeduplication
    participant ServerStore
    User->>ServerEditPage: Tap "Import from SSH config" button
    ServerEditPage->>SSHConfig: parseConfig()
    SSHConfig-->>ServerEditPage: List<Spi> (parsed servers)
    ServerEditPage->>ServerDeduplication: deduplicateServers(servers)
    ServerDeduplication->>ServerStore: fetch() (existing servers)
    ServerStore-->>ServerDeduplication: List<Spi> (existing)
    ServerDeduplication-->>ServerEditPage: deduplicated servers
    ServerEditPage->>ServerDeduplication: resolveNameConflicts(deduplicated)
    ServerDeduplication-->>ServerEditPage: resolved servers
    ServerEditPage->>User: Show import summary dialog
    User->>ServerEditPage: Confirm import
    loop For each resolved server
        ServerEditPage->>ServerStore: addServer(server)
    end
    ServerEditPage->>User: Show success message
Loading

Entity relationship diagram for server deduplication and import

erDiagram
    SERVER {
      id string
      name string
      ip string
      port int
      user string
      keyId string
      jumpId string
    }
    IMPORT_SUMMARY {
      total int
      duplicates int
      toImport int
    }
    SERVER ||--o{ IMPORT_SUMMARY: "imported servers"
    SERVER ||--o{ SERVER: "deduplication (existing vs imported)"
Loading

Class diagram for SSHConfig and ServerDeduplication utilities

classDiagram
    class SSHConfig {
      +parseConfig(configPath)
      +configExists(configPath)
      -_parseSSHConfig(content)
      -_extractJumpHost(value)
    }
    class ServerDeduplication {
      +deduplicateServers(importedServers)
      +resolveNameConflicts(importedServers)
      +getImportSummary(originalList, deduplicatedList)
      -_isDuplicate(imported, existing)
    }
    class ImportSummary {
      +total: int
      +duplicates: int
      +toImport: int
      +hasDuplicates: bool
      +hasItemsToImport: bool
    }
    SSHConfig ..> Spi
    ServerDeduplication ..> Spi
    ServerDeduplication ..> ImportSummary
Loading

Class diagram for Spi changes (server_private_info.dart)

classDiagram
    class Spi {
      +parseId(id)
      +name: String
      +ip: String
      +port: int
      +user: String
      +keyId: String
      +jumpId: String
      +alterUrl: String
      +custom: Custom
    }
    class Spix {
      +isSameAs(other: Spi): bool
      +shouldReconnect(old: Spi): bool
      +parseAlterUrl(): (String, String, int)
      +isRoot: bool
      +toJsonString(): String
    }
    Spi <|.. Spix
Loading

File-Level Changes

Change Details Files
Integrate SSH config import into ServerEditPage and settings
  • Added desktop‐only SSH import button in server edit form
  • Implemented _onTapSSHImport and _checkSSHConfigImport methods for manual and automatic import
  • Modified afterFirstLayout to trigger initial import prompt for new servers
  • Extended settings page with a firstTimeReadSSHCfg toggle
lib/view/page/server/edit.dart
lib/view/page/setting/entries/server.dart
lib/data/store/setting.dart
Introduce SSHConfig utility for parsing ~/.ssh/config
  • Implemented configExists check and parseConfig entry point
  • Parsed SSH config content with support for Host, HostName, User, Port, IdentityFile, ProxyJump
  • Handled quoted values, comments, wildcards, and invalid entries
lib/core/utils/ssh_config.dart
test/ssh_config_test.dart
Add ServerDeduplication utility for import de-duplication
  • Implemented deduplicateServers to filter exact duplicates
  • Provided resolveNameConflicts to append suffixes on name collisions
  • Added getImportSummary to report total, duplicates, and toImport counts
lib/core/utils/server_dedup.dart
test/server_dedup_test.dart
Enhance Spi model with new utility methods
  • Added parseId, toJsonString, isSameAs, parseAlterUrl and isRoot methods
  • Refactored shouldReconnect to leverage isSameAs
  • Documented new APIs
lib/data/model/server/server_private_info.dart
Update localization for SSH import feature
  • Added new l10n keys for import prompts and messages
  • Regenerated all localization files and ARB resources
lib/generated/l10n/*
lib/l10n/*.arb
Expand test coverage for server edit and import logic
  • Added server_edit_logic_test.dart to validate import visibility, permission prompt conditions, form processing and validation
  • Covered error handling, name conflict resolution, and summary formatting
test/server_edit_logic_test.dart

Assessment against linked issues

Issue Objective Addressed Explanation
#871 Allow importing servers from a unified SSH config file (e.g., ~/.ssh/config) on desktop platforms (Win/Mac/Linux).
#871 Provide a user interface to trigger SSH config import and handle deduplication and name conflicts for imported servers.
#871 Add a setting to control whether SSH config is read/imported automatically on first use.

Possibly linked issues


Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

Copy link

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

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

Hey there - I've reviewed your changes and they look great!

Prompt for AI Agents
Please address the comments from this code review:
## Individual Comments

### Comment 1
<location> `lib/core/utils/server_dedup.dart:12` </location>
<code_context>
+    final deduplicated = <Spi>[];
+    
+    for (final imported in importedServers) {
+      if (!_isDuplicate(imported, existingServers)) {
+        deduplicated.add(imported);
+      }
+    }
+    
</code_context>

<issue_to_address>
Deduplication logic may not handle case sensitivity in server names and users.

Normalization of case for server names and usernames during comparison is recommended to ensure accurate deduplication.

Suggested implementation:

```
    for (final imported in importedServers) {
      if (!_isDuplicateNormalized(imported, existingServers)) {
        deduplicated.add(imported);

```

```
  static List<Spi> deduplicateServers(List<Spi> importedServers) {
    final existingServers = ServerStore.instance.fetch();
    final deduplicated = <Spi>[];

    for (final imported in importedServers) {
      if (!_isDuplicateNormalized(imported, existingServers)) {
        deduplicated.add(imported);
      }
    }

  }

  /// Checks for duplicates by normalizing case for server name and username
  static bool _isDuplicateNormalized(Spi imported, List<Spi> existingServers) {
    final importedName = imported.serverName.toLowerCase();
    final importedUser = imported.username.toLowerCase();
    for (final existing in existingServers) {
      final existingName = existing.serverName.toLowerCase();
      final existingUser = existing.username.toLowerCase();
      if (importedName == existingName && importedUser == existingUser) {
        return true;
      }
    }
    return false;

```

If the `Spi` class uses different property names for server name or username, adjust `serverName` and `username` accordingly.
Remove or update the original `_isDuplicate` method if it exists and is no longer used.
</issue_to_address>

### Comment 2
<location> `lib/view/page/server/edit.dart:760` </location>
<code_context>
+  void _checkSSHConfigImport() async {
</code_context>

<issue_to_address>
First-time SSH config import logic does not persist user choice.

Currently, the user's decision is not saved, leading to repeated prompts. If this behavior is intended, please document the reasoning; otherwise, update the logic to persist the user's choice.
</issue_to_address>

### Comment 3
<location> `lib/data/model/server/server_private_info.dart:91` </location>
<code_context>
   String toJsonString() => json.encode(toJson());

+  /// Returns true if the connection info is the same as [other].
+  bool isSameAs(Spi other) {
+    return user == other.user &&
+        ip == other.ip &&
+        port == other.port &&
+        pwd == other.pwd &&
+        keyId == other.keyId &&
+        jumpId == other.jumpId;
+  }
+
</code_context>

<issue_to_address>
isSameAs does not compare alterUrl or custom commands.

This difference may cause inconsistent behavior if isSameAs is used in contexts where alterUrl or custom.cmds matter. Please document this or update the comparison logic for consistency.
</issue_to_address>

<suggested_fix>
<<<<<<< SEARCH
  /// Returns true if the connection info is the same as [other].
  bool isSameAs(Spi other) {
    return user == other.user &&
        ip == other.ip &&
        port == other.port &&
        pwd == other.pwd &&
        keyId == other.keyId &&
        jumpId == other.jumpId;
  }
=======
  /// Returns true if the connection info is the same as [other].
  /// Compares user, ip, port, pwd, keyId, jumpId, alterUrl, and custom.cmds.
  bool isSameAs(Spi other) {
    return user == other.user &&
        ip == other.ip &&
        port == other.port &&
        pwd == other.pwd &&
        keyId == other.keyId &&
        jumpId == other.jumpId &&
        alterUrl == other.alterUrl &&
        _customCmdsEquals(custom, other.custom);
  }

  /// Helper to compare custom.cmds lists for equality.
  bool _customCmdsEquals(Custom? a, Custom? b) {
    if (a?.cmds == null && b?.cmds == null) return true;
    if (a?.cmds == null || b?.cmds == null) return false;
    if (a!.cmds.length != b!.cmds.length) return false;
    for (int i = 0; i < a.cmds.length; i++) {
      if (a.cmds[i] != b.cmds[i]) return false;
    }
    return true;
  }
>>>>>>> REPLACE

</suggested_fix>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines +12 to +14
if (!_isDuplicate(imported, existingServers)) {
deduplicated.add(imported);
}
Copy link

Choose a reason for hiding this comment

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

suggestion: Deduplication logic may not handle case sensitivity in server names and users.

Normalization of case for server names and usernames during comparison is recommended to ensure accurate deduplication.

Suggested implementation:

    for (final imported in importedServers) {
      if (!_isDuplicateNormalized(imported, existingServers)) {
        deduplicated.add(imported);

  static List<Spi> deduplicateServers(List<Spi> importedServers) {
    final existingServers = ServerStore.instance.fetch();
    final deduplicated = <Spi>[];

    for (final imported in importedServers) {
      if (!_isDuplicateNormalized(imported, existingServers)) {
        deduplicated.add(imported);
      }
    }

  }

  /// Checks for duplicates by normalizing case for server name and username
  static bool _isDuplicateNormalized(Spi imported, List<Spi> existingServers) {
    final importedName = imported.serverName.toLowerCase();
    final importedUser = imported.username.toLowerCase();
    for (final existing in existingServers) {
      final existingName = existing.serverName.toLowerCase();
      final existingUser = existing.username.toLowerCase();
      if (importedName == existingName && importedUser == existingUser) {
        return true;
      }
    }
    return false;

If the Spi class uses different property names for server name or username, adjust serverName and username accordingly.
Remove or update the original _isDuplicate method if it exists and is no longer used.

Comment on lines +760 to +769
void _checkSSHConfigImport() async {
final prop = Stores.setting.firstTimeReadSSHCfg;
// Only check if it's first time and user hasn't disabled it
if (!prop.fetch()) return;

try {
// Check if SSH config exists
final (_, configExists) = SSHConfig.configExists();
if (!configExists) return;

Copy link

Choose a reason for hiding this comment

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

issue (bug_risk): First-time SSH config import logic does not persist user choice.

Currently, the user's decision is not saved, leading to repeated prompts. If this behavior is intended, please document the reasoning; otherwise, update the logic to persist the user's choice.

Comment on lines +90 to +98
/// Returns true if the connection info is the same as [other].
bool isSameAs(Spi other) {
return user == other.user &&
ip == other.ip &&
port == other.port &&
pwd == other.pwd &&
keyId == other.keyId &&
jumpId == other.jumpId;
}
Copy link

Choose a reason for hiding this comment

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

suggestion: isSameAs does not compare alterUrl or custom commands.

This difference may cause inconsistent behavior if isSameAs is used in contexts where alterUrl or custom.cmds matter. Please document this or update the comparison logic for consistency.

Suggested change
/// Returns true if the connection info is the same as [other].
bool isSameAs(Spi other) {
return user == other.user &&
ip == other.ip &&
port == other.port &&
pwd == other.pwd &&
keyId == other.keyId &&
jumpId == other.jumpId;
}
/// Returns true if the connection info is the same as [other].
/// Compares user, ip, port, pwd, keyId, jumpId, alterUrl, and custom.cmds.
bool isSameAs(Spi other) {
return user == other.user &&
ip == other.ip &&
port == other.port &&
pwd == other.pwd &&
keyId == other.keyId &&
jumpId == other.jumpId &&
alterUrl == other.alterUrl &&
_customCmdsEquals(custom, other.custom);
}
/// Helper to compare custom.cmds lists for equality.
bool _customCmdsEquals(Custom? a, Custom? b) {
if (a?.cmds == null && b?.cmds == null) return true;
if (a?.cmds == null || b?.cmds == null) return false;
if (a!.cmds.length != b!.cmds.length) return false;
for (int i = 0; i < a.cmds.length; i++) {
if (a.cmds[i] != b.cmds[i]) return false;
}
return true;
}

@lollipopkit
Copy link
Owner Author

@claude review

@claude
Copy link

claude bot commented Aug 31, 2025

Claude finished @lollipopkit's task —— View job


Code Review: SSH Config Import Feature

Executive Summary

This is a well-implemented feature that successfully addresses issue #871. The implementation demonstrates strong engineering practices with comprehensive test coverage (1,083 lines), proper error handling, and thoughtful user experience design.

Key Strengths ✅

1. Robust SSH Config Parsing (ssh_config.dart)

  • Cross-platform path resolution: Handles Windows (USERPROFILE) vs Unix (HOME) environments
  • macOS-specific fallbacks: Tries both $HOME/.ssh/config and /Users/$username/.ssh/config
  • Comprehensive SSH directive support: Host, HostName, User, Port, IdentityFile, ProxyJump/ProxyCommand
  • Smart filtering: Properly skips wildcards (*), comments, and multi-pattern hosts
  • Quoted value handling: Correctly processes both single and double quotes

2. Intelligent Deduplication (server_dedup.dart)

  • Clean separation of concerns: Deduplication → name conflict resolution → summary generation
  • Smart conflict resolution: Appends suffixes like (1), (2) to resolve name collisions
  • Comprehensive summary: Provides total/duplicates/toImport counts for user feedback

3. Excellent User Experience

  • First-time import prompt: Automatically offers to import SSH config on first server creation
  • Desktop-only manual import: Appropriately restricts feature to platforms where it makes sense
  • Comprehensive error handling: Graceful fallbacks with informative messages
  • Permission-based approach: Asks user consent before accessing SSH config

4. Outstanding Test Coverage

  • 354 lines testing SSH config parsing with varied scenarios
  • 413 lines testing deduplication logic including edge cases
  • 316 lines testing UI logic integration
  • Total: 1,083 lines of comprehensive test coverage

Issues to Address ⚠️

1. Critical: First-time Import Choice Not Persisted (sourcery-ai issue #2)

Location: edit.dart:815-842

void _checkSSHConfigImport() async {
  final prop = Stores.setting.firstTimeReadSSHCfg;
  if (!prop.fetch()) return;
  
  // ... show dialog ...
  prop.put(false); // ❌ Always sets to false regardless of user choice
}

Problem: The setting is set to false whether user accepts or declines, but the logic suggests it should only be set when user declines.

Recommendation:

if (hasPermission == true) {
  // Import logic...
} else {
  // User declined - don't ask again
  prop.put(false);
}

2. Medium: Case Sensitivity in Deduplication (sourcery-ai issue #1)

Location: server_dedup.dart:21-29

The isSameAs method performs exact string matches, which could miss duplicates like:

  • User@server.com vs user@server.com
  • MyServer vs myserver

Recommendation: Consider case-insensitive comparison for hostnames and usernames.

3. Medium: Incomplete isSameAs Implementation (sourcery-ai issue #3)

Location: server_private_info.dart:91-98

The isSameAs method doesn't compare alterUrl or custom.cmds, but shouldReconnect depends on these fields:

bool shouldReconnect(Spi old) {
  return !isSameAs(old) || alterUrl != old.alterUrl || custom?.cmds != old.custom?.cmds;
}

Recommendation: Either:

  1. Document the limitation clearly in isSameAs comments
  2. Or expand isSameAs to include these fields for consistency

4. Minor: Security - Path Traversal Prevention

Location: ssh_config.dart:168

final expandedPath = configPath.replaceFirst('~', homePath);

Recommendation: Add validation to prevent path traversal attacks if configPath comes from untrusted sources.

Additional Observations

Positive Design Decisions

  • Platform-appropriate UX: Desktop-only import button makes sense
  • Progressive disclosure: First-time prompt → manual import option
  • Proper error boundaries: Each operation has appropriate error handling
  • Clean architecture: Utils → Models → UI separation is well-maintained

Code Quality

  • Consistent error handling throughout the codebase
  • Proper use of Dart language features (pattern matching, null safety)
  • Good logging for debugging (dprint, Loggers.app.info)
  • Idiomatic Flutter/Dart code style

Final Recommendation

This is a high-quality implementation that successfully delivers the requested functionality. The issues identified are relatively minor and don't prevent the feature from working correctly.

Priority for fixes:

  1. High: Fix first-time import persistence logic (functional issue)
  2. Medium: Consider case-insensitive deduplication (user experience)
  3. Medium: Clarify isSameAs behavior (maintainability)
  4. Low: Add path validation (security hardening)

The comprehensive test coverage and thoughtful error handling demonstrate this feature is ready for production with the above minor fixes.

Overall Rating: ✅ Approved with Minor Issues


@lollipopkit lollipopkit merged commit 12a243d into main Aug 31, 2025
1 check passed
@lollipopkit lollipopkit deleted the lollipopkit/issue871 branch August 31, 2025 11:46
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.

导入服务器适配通用 ssh config

2 participants