diff --git a/.gitignore b/.gitignore index 3793eccf1..c7638a69d 100644 --- a/.gitignore +++ b/.gitignore @@ -69,3 +69,4 @@ Resources/terminfo .env build/ .DS_Store +.worktrees/ diff --git a/docs/superpowers/plans/2026-03-27-remote-ssh-tmux-implementation-plan.md b/docs/superpowers/plans/2026-03-27-remote-ssh-tmux-implementation-plan.md new file mode 100644 index 000000000..1ced52644 --- /dev/null +++ b/docs/superpowers/plans/2026-03-27-remote-ssh-tmux-implementation-plan.md @@ -0,0 +1,1013 @@ +# Remote SSH + tmux Auto-Detect Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship remote repository support with shared SSH host profiles, repo-level remote path/tmux binding, always-on tmux session picker, SSH-only reconnect continuity, and remote git worktree operations. + +**Architecture:** Keep Prowl's existing TCA + terminal architecture, add endpoint-aware execution context, and layer remote support into existing repository/worktree flows rather than branching into a separate app path. Persist shared hosts globally, persist repo remote bindings per repository, and execute remote git/tmux through native `ssh` commands with strict timeouts and keychain-backed password fallback. + +**Tech Stack:** Swift 6.2, TCA, Sharing (`@Shared` / `SharedKey`), GhosttyKit, `Process`, macOS Security/Keychain APIs, Swift Testing. + +--- + +## Scope Check + +The spec includes multiple subsystems (host profiles, remote add flow, command execution, session picker, reconnect), but they are one coherent vertical feature. This plan keeps them in a single implementation track while isolating each subsystem into explicit tasks and commits. + +## File Structure (Lock Before Coding) + +### New files + +- `supacode/Domain/SSHHostProfile.swift` + - Shared host model and auth method enum. +- `supacode/Domain/RepositoryEndpoint.swift` + - Local vs remote endpoint metadata (`profileID + remotePath`). +- `supacode/Clients/Security/KeychainClient.swift` + - Keychain save/load/delete for SSH passwords. +- `supacode/Infrastructure/SSH/SSHCommandSupport.swift` + - SSH option composition, shell escaping, control-socket path hashing, askpass helpers. +- `supacode/Clients/Remote/RemoteExecutionClient.swift` + - Executes remote commands over SSH with timeout/retry contracts. +- `supacode/Clients/Remote/RemoteTmuxClient.swift` + - Remote tmux session listing/attach/create helpers. +- `supacode/Features/Repositories/Reducer/RemoteConnectFeature.swift` + - Host-first remote repository add flow state machine. +- `supacode/Features/Repositories/Views/RemoteConnectSheet.swift` + - SwiftUI sheet for remote add flow. +- `supacode/Features/Repositories/Reducer/RemoteSessionPickerFeature.swift` + - Session picker reducer (`always show picker` behavior). +- `supacode/Features/Repositories/Views/RemoteSessionPickerSheet.swift` + - Session picker view. +- `supacode/Features/Settings/Reducer/SSHHostsFeature.swift` + - Host profile CRUD reducer. +- `supacode/Features/Settings/Views/SSHHostsSettingsView.swift` + - Host profile management UI section. +- `supacodeTests/SSHCommandSupportTests.swift` +- `supacodeTests/KeychainClientTests.swift` +- `supacodeTests/RemoteExecutionClientTests.swift` +- `supacodeTests/RemoteTmuxClientTests.swift` +- `supacodeTests/RemoteConnectFeatureTests.swift` +- `supacodeTests/RemoteSessionPickerFeatureTests.swift` +- `supacodeTests/GitClientRemoteCommandTests.swift` +- `supacodeTests/SettingsFeatureSSHHostsTests.swift` + +### Modified files + +- `supacode/Domain/PersistedRepositoryEntry.swift` +- `supacode/Domain/Repository.swift` +- `supacode/Domain/Worktree.swift` +- `supacode/Features/Settings/Models/SettingsFile.swift` +- `supacode/Features/Settings/Models/RepositorySettings.swift` +- `supacode/Features/Settings/BusinessLogic/RepositoryPersistenceKeys.swift` +- `supacode/Support/SupacodePaths.swift` +- `supacode/Clients/Repositories/RepositoryPersistenceClient.swift` +- `supacode/Clients/Repositories/GitClientDependency.swift` +- `supacode/Clients/Git/GitClient.swift` +- `supacode/Features/RepositorySettings/Reducer/RepositorySettingsFeature.swift` +- `supacode/Features/Settings/Views/RepositorySettingsView.swift` +- `supacode/Features/Repositories/Reducer/RepositoriesFeature.swift` +- `supacode/App/ContentView.swift` +- `supacode/Features/Repositories/Views/SidebarFooterView.swift` +- `supacode/Features/Settings/Views/SettingsSection.swift` +- `supacode/Features/Settings/Views/SettingsView.swift` +- `supacode/Features/Settings/Reducer/SettingsFeature.swift` +- `supacode/Features/CommandPalette/CommandPaletteItem.swift` +- `supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift` +- `supacode/Features/App/Reducer/AppFeature.swift` +- `supacode/Clients/Terminal/TerminalClient.swift` +- `supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift` +- `supacode/Features/Terminal/Models/WorktreeTerminalState.swift` +- `supacodeTests/SettingsFilePersistenceTests.swift` +- `supacodeTests/RepositoryPersistenceClientTests.swift` +- `supacodeTests/RepositorySettingsFeatureTests.swift` +- `supacodeTests/RepositoriesFeatureTests.swift` +- `supacodeTests/CommandPaletteFeatureTests.swift` +- `supacodeTests/AppFeatureCommandPaletteTests.swift` + +## Task 1: Create Isolated Worktree + Baseline Safety Check + +**Files:** +- Modify: none +- Test: none + +- [ ] **Step 1: Create dedicated git worktree and feature branch** + +```bash +git worktree add ../Prowl-remote-ssh -b feature/remote-ssh-tmux +``` + +- [ ] **Step 2: Enter new worktree and confirm branch** + +Run: `cd ../Prowl-remote-ssh && git branch --show-current` +Expected: `feature/remote-ssh-tmux` + +- [ ] **Step 3: Capture clean baseline before edits** + +Run: `git status --short` +Expected: empty output + +- [ ] **Step 4: Run a fast baseline test target** + +Run: + +```bash +xcodebuild test -project supacode.xcodeproj -scheme supacode -destination "platform=macOS" \ + -only-testing:supacodeTests/SettingsFilePersistenceTests \ + CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO CODE_SIGN_IDENTITY="" -skipMacroValidation +``` + +Expected: PASS + +- [ ] **Step 5: Commit setup note (optional)** + +No commit for this task (code unchanged). + +## Task 2: Add Core Remote Domain Models + Persistence Schema + +**Files:** +- Create: `supacode/Domain/SSHHostProfile.swift` +- Create: `supacode/Domain/RepositoryEndpoint.swift` +- Modify: `supacode/Domain/PersistedRepositoryEntry.swift` +- Modify: `supacode/Domain/Repository.swift` +- Modify: `supacode/Domain/Worktree.swift` +- Modify: `supacode/Features/Settings/Models/SettingsFile.swift` +- Test: `supacodeTests/SettingsFilePersistenceTests.swift` +- Test: `supacodeTests/RepositoryPersistenceClientTests.swift` + +- [ ] **Step 1: Write failing tests for backward-compatible decode** + +```swift +@Test(.dependencies) func settingsFileDecodesWithoutSSHHostsKey() throws { + let legacyJSON = #"{"global":{"appearanceMode":"dark","updatesAutomaticallyCheckForUpdates":true,"updatesAutomaticallyDownloadUpdates":false},"repositories":{},"repositoryRoots":[]}"# + let data = Data(legacyJSON.utf8) + let decoded = try JSONDecoder().decode(SettingsFile.self, from: data) + #expect(decoded.sshHostProfiles.isEmpty) +} + +@Test func persistedRepositoryEntryDecodesLegacyLocalFormat() throws { + let legacy = #"{"path":"/tmp/repo","kind":"git"}"# + let decoded = try JSONDecoder().decode(PersistedRepositoryEntry.self, from: Data(legacy.utf8)) + #expect(decoded.endpoint == .local) +} +``` + +- [ ] **Step 2: Run tests and verify failure** + +Run: + +```bash +xcodebuild test -project supacode.xcodeproj -scheme supacode -destination "platform=macOS" \ + -only-testing:supacodeTests/SettingsFilePersistenceTests \ + -only-testing:supacodeTests/RepositoryPersistenceClientTests \ + CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO CODE_SIGN_IDENTITY="" -skipMacroValidation +``` + +Expected: compile/test failure for missing `sshHostProfiles` and `endpoint`. + +- [ ] **Step 3: Implement domain/persistence types** + +```swift +// supacode/Domain/SSHHostProfile.swift +nonisolated struct SSHHostProfile: Codable, Equatable, Sendable, Identifiable { + enum AuthMethod: String, Codable, CaseIterable, Sendable { + case publicKey + case password + } + let id: String + var name: String + var host: String + var user: String? + var port: Int? + var authMethod: AuthMethod +} + +// supacode/Domain/RepositoryEndpoint.swift +nonisolated enum RepositoryEndpoint: Codable, Equatable, Sendable { + case local + case remote(hostProfileID: String, remotePath: String) +} +``` + +```swift +// supacode/Domain/PersistedRepositoryEntry.swift +nonisolated struct PersistedRepositoryEntry: Codable, Equatable, Sendable { + let path: String + let kind: Repository.Kind + var endpoint: RepositoryEndpoint + + init(path: String, kind: Repository.Kind, endpoint: RepositoryEndpoint = .local) { + self.path = path + self.kind = kind + self.endpoint = endpoint + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + path = try c.decode(String.self, forKey: .path) + kind = try c.decode(Repository.Kind.self, forKey: .kind) + endpoint = try c.decodeIfPresent(RepositoryEndpoint.self, forKey: .endpoint) ?? .local + } +} +``` + +```swift +// supacode/Features/Settings/Models/SettingsFile.swift (new field + migration default) +var sshHostProfiles: [SSHHostProfile] +``` + +- [ ] **Step 4: Run the targeted tests and verify pass** + +Run same command from Step 2. +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add \ + supacode/Domain/SSHHostProfile.swift \ + supacode/Domain/RepositoryEndpoint.swift \ + supacode/Domain/PersistedRepositoryEntry.swift \ + supacode/Domain/Repository.swift \ + supacode/Domain/Worktree.swift \ + supacode/Features/Settings/Models/SettingsFile.swift \ + supacodeTests/SettingsFilePersistenceTests.swift \ + supacodeTests/RepositoryPersistenceClientTests.swift +git commit -m "feat: add remote endpoint domain and settings schema" +``` + +## Task 3: Add Keychain Client + SSH Utility Primitives + +**Files:** +- Create: `supacode/Clients/Security/KeychainClient.swift` +- Create: `supacode/Infrastructure/SSH/SSHCommandSupport.swift` +- Modify: `supacode/Features/Settings/Models/RepositorySettings.swift` +- Test: `supacodeTests/KeychainClientTests.swift` +- Test: `supacodeTests/SSHCommandSupportTests.swift` + +- [ ] **Step 1: Write failing tests for control path hashing and option filtering** + +```swift +@Test func controlSocketPathFallsBackToTmpWhenTooLong() { + let path = SSHCommandSupport.controlSocketPath(endpointKey: String(repeating: "x", count: 512)) + #expect(path.hasPrefix("/tmp/") || path.contains("/.prowl/")) +} + +@Test func removingBatchModeStripsOnlyBatchModePairs() { + let filtered = SSHCommandSupport.removingBatchMode(from: ["-o","BatchMode=yes","-o","ConnectTimeout=8"]) + #expect(filtered == ["-o","ConnectTimeout=8"]) +} +``` + +- [ ] **Step 2: Write failing tests for keychain save/load/delete** + +```swift +@Test(.dependencies) func keychainRoundTrip() async throws { + let key = "test.ssh.profile" + try await keychainClient.savePassword("secret", key) + let loaded = try await keychainClient.loadPassword(key) + #expect(loaded == "secret") + try await keychainClient.deletePassword(key) +} +``` + +- [ ] **Step 3: Run tests and verify failure** + +Run: + +```bash +xcodebuild test -project supacode.xcodeproj -scheme supacode -destination "platform=macOS" \ + -only-testing:supacodeTests/SSHCommandSupportTests \ + -only-testing:supacodeTests/KeychainClientTests \ + CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO CODE_SIGN_IDENTITY="" -skipMacroValidation +``` + +Expected: compile failure (missing client/utils). + +- [ ] **Step 4: Implement keychain + SSH support** + +```swift +// KeychainClient.swift +struct KeychainClient: Sendable { + var savePassword: @Sendable (_ password: String, _ key: String) async throws -> Void + var loadPassword: @Sendable (_ key: String) async throws -> String? + var deletePassword: @Sendable (_ key: String) async throws -> Void +} +``` + +```swift +// SSHCommandSupport.swift +enum SSHCommandSupport { + static let connectTimeoutSeconds = 8 + static let serverAliveIntervalSeconds = 5 + static let serverAliveCountMax = 3 + static func connectivityOptions() -> [String] { ... } + static func controlSocketPath(endpointKey: String, temporaryDirectory: String = NSTemporaryDirectory()) -> String { ... } + static func shellEscape(_ value: String) -> String { ... } +} +``` + +```swift +// RepositorySettings.swift (repo-level remote session binding) +var defaultRemoteTmuxSessionName: String? +var lastAttachedRemoteTmuxSessionName: String? +``` + +- [ ] **Step 5: Run tests and commit** + +Run same test command from Step 3. +Expected: PASS. + +```bash +git add \ + supacode/Clients/Security/KeychainClient.swift \ + supacode/Infrastructure/SSH/SSHCommandSupport.swift \ + supacode/Features/Settings/Models/RepositorySettings.swift \ + supacodeTests/KeychainClientTests.swift \ + supacodeTests/SSHCommandSupportTests.swift +git commit -m "feat: add keychain client and ssh command primitives" +``` + +## Task 4: Add Remote Execution + Remote tmux Clients + +**Files:** +- Create: `supacode/Clients/Remote/RemoteExecutionClient.swift` +- Create: `supacode/Clients/Remote/RemoteTmuxClient.swift` +- Modify: `supacode/Clients/Shell/ShellClient.swift` +- Test: `supacodeTests/RemoteExecutionClientTests.swift` +- Test: `supacodeTests/RemoteTmuxClientTests.swift` + +- [ ] **Step 1: Write failing remote execution tests** + +```swift +@Test func remoteExecutionBuildsExpectedSSHArguments() async throws { + let output = try await remoteExecutionClient.run( + profile: .fixture(host: "host", user: "dev", port: 2222), + command: "tmux list-sessions" + ) + #expect(output.exitCode == 0) +} +``` + +```swift +@Test func remoteTmuxParsesSessionNames() async throws { + let sessions = try await remoteTmuxClient.listSessions(profile: .fixture(), timeoutSeconds: 8) + #expect(sessions == ["main", "ops"]) +} +``` + +- [ ] **Step 2: Run tests and verify failure** + +Run: + +```bash +xcodebuild test -project supacode.xcodeproj -scheme supacode -destination "platform=macOS" \ + -only-testing:supacodeTests/RemoteExecutionClientTests \ + -only-testing:supacodeTests/RemoteTmuxClientTests \ + CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO CODE_SIGN_IDENTITY="" -skipMacroValidation +``` + +Expected: missing type failures. + +- [ ] **Step 3: Implement remote execution contract** + +```swift +struct RemoteExecutionClient: Sendable { + struct Output: Equatable, Sendable { + let stdout: String + let stderr: String + let exitCode: Int32 + } + var run: @Sendable (_ profile: SSHHostProfile, _ command: String, _ timeoutSeconds: Int) async throws -> Output +} +``` + +```swift +struct RemoteTmuxClient: Sendable { + var listSessions: @Sendable (_ profile: SSHHostProfile, _ timeoutSeconds: Int) async throws -> [String] + var buildAttachCommand: @Sendable (_ sessionName: String, _ remotePath: String) -> String + var buildCreateAndAttachCommand: @Sendable (_ preferredName: String, _ remotePath: String) -> String +} +``` + +- [ ] **Step 4: Add timeout-support path to `ShellClient`** + +```swift +// Add helper for timeout process execution used by RemoteExecutionClient. +func runWithTimeout( + _ executableURL: URL, + _ arguments: [String], + _ currentDirectoryURL: URL?, + timeoutSeconds: Int +) async throws -> ShellOutput { ... } +``` + +- [ ] **Step 5: Run tests and commit** + +Run command from Step 2. +Expected: PASS. + +```bash +git add \ + supacode/Clients/Remote/RemoteExecutionClient.swift \ + supacode/Clients/Remote/RemoteTmuxClient.swift \ + supacode/Clients/Shell/ShellClient.swift \ + supacodeTests/RemoteExecutionClientTests.swift \ + supacodeTests/RemoteTmuxClientTests.swift +git commit -m "feat: add remote ssh execution and tmux clients" +``` + +## Task 5: Make Git Client Endpoint-Aware for Remote Worktree Ops + +**Files:** +- Modify: `supacode/Clients/Repositories/GitClientDependency.swift` +- Modify: `supacode/Clients/Git/GitClient.swift` +- Modify: `supacode/Features/Repositories/Reducer/RepositoriesFeature.swift` +- Modify: `supacode/Features/RepositorySettings/Reducer/RepositorySettingsFeature.swift` +- Test: `supacodeTests/GitClientRemoteCommandTests.swift` +- Test: `supacodeTests/RepositoriesFeatureTests.swift` + +- [ ] **Step 1: Add failing tests for remote git command composition** + +```swift +@Test func remoteWorktreeListUsesSSHGitCRemotePath() async throws { + let client = GitClient(shell: .failing, remoteExecution: .capturingSuccess) + _ = try await client.worktrees(for: URL(fileURLWithPath: "/synthetic"), endpoint: .remote(hostProfileID: "h1", remotePath: "/srv/repo")) + #expect(capturedRemoteCommands.value.contains("git -C '/srv/repo' worktree list")) +} +``` + +- [ ] **Step 2: Run targeted test and verify fail** + +Run: + +```bash +xcodebuild test -project supacode.xcodeproj -scheme supacode -destination "platform=macOS" \ + -only-testing:supacodeTests/GitClientRemoteCommandTests \ + CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO CODE_SIGN_IDENTITY="" -skipMacroValidation +``` + +Expected: API mismatch compile failure. + +- [ ] **Step 3: Add endpoint-aware API surface** + +```swift +// GitClientDependency.swift +var worktreesForEndpoint: @Sendable (URL, RepositoryEndpoint, SSHHostProfile?) async throws -> [Worktree] +var createWorktreeStreamForEndpoint: @Sendable (...) -> AsyncThrowingStream +var removeWorktreeForEndpoint: @Sendable (Worktree, Bool, RepositoryEndpoint, SSHHostProfile?) async throws -> URL +``` + +```swift +// GitClient.swift +nonisolated func worktrees( + for repoRoot: URL, + endpoint: RepositoryEndpoint, + hostProfile: SSHHostProfile? +) async throws -> [Worktree] { ... } +``` + +- [ ] **Step 4: Migrate repository reducers to endpoint-aware git calls** + +```swift +let endpoint = repository.endpoint +let hostProfile = resolveHostProfile(for: endpoint) +let worktrees = try await gitClient.worktreesForEndpoint(repository.rootURL, endpoint, hostProfile) +``` + +- [ ] **Step 5: Run tests and commit** + +Run: + +```bash +xcodebuild test -project supacode.xcodeproj -scheme supacode -destination "platform=macOS" \ + -only-testing:supacodeTests/GitClientRemoteCommandTests \ + -only-testing:supacodeTests/RepositoriesFeatureTests \ + -only-testing:supacodeTests/RepositorySettingsFeatureTests \ + CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO CODE_SIGN_IDENTITY="" -skipMacroValidation +``` + +Expected: PASS. + +```bash +git add \ + supacode/Clients/Repositories/GitClientDependency.swift \ + supacode/Clients/Git/GitClient.swift \ + supacode/Features/Repositories/Reducer/RepositoriesFeature.swift \ + supacode/Features/RepositorySettings/Reducer/RepositorySettingsFeature.swift \ + supacodeTests/GitClientRemoteCommandTests.swift \ + supacodeTests/RepositoriesFeatureTests.swift +git commit -m "feat: route git worktree operations through endpoint-aware execution" +``` + +## Task 6: Implement Host-First Remote Add Flow in Repositories + +**Files:** +- Create: `supacode/Features/Repositories/Reducer/RemoteConnectFeature.swift` +- Create: `supacode/Features/Repositories/Views/RemoteConnectSheet.swift` +- Modify: `supacode/Features/Repositories/Reducer/RepositoriesFeature.swift` +- Modify: `supacode/App/ContentView.swift` +- Modify: `supacode/Features/Repositories/Views/SidebarFooterView.swift` +- Test: `supacodeTests/RemoteConnectFeatureTests.swift` +- Test: `supacodeTests/RepositoriesFeatureTests.swift` + +- [ ] **Step 1: Add failing reducer tests for host-first flow** + +```swift +@Test func remoteConnectRequiresHostBeforePathValidation() async { + let store = TestStore(initialState: RemoteConnectFeature.State()) { RemoteConnectFeature() } + await store.send(.continueTapped) { + $0.validationMessage = "Host is required." + } +} + +@Test func submittingValidRemoteFlowCreatesRemoteEntry() async { ... } +``` + +- [ ] **Step 2: Run tests and verify fail** + +Run: + +```bash +xcodebuild test -project supacode.xcodeproj -scheme supacode -destination "platform=macOS" \ + -only-testing:supacodeTests/RemoteConnectFeatureTests \ + CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO CODE_SIGN_IDENTITY="" -skipMacroValidation +``` + +Expected: missing feature/view compile failures. + +- [ ] **Step 3: Implement `RemoteConnectFeature` state machine** + +```swift +@Reducer +struct RemoteConnectFeature { + @ObservableState + struct State: Equatable { + var hostName = "" + var hostAddress = "" + var hostUser = "" + var hostPortText = "" + var authMethod: SSHHostProfile.AuthMethod = .publicKey + var password = "" + var remotePath = "" + var isSubmitting = false + var validationMessage: String? + } + enum Action { case continueTapped, submitTapped, delegate(Delegate), ... } +} +``` + +- [ ] **Step 4: Wire RepositoriesFeature sheet presentation and result handling** + +```swift +@Presents var remoteConnect: RemoteConnectFeature.State? +case presentRemoteConnect +case remoteConnect(PresentationAction) +``` + +```swift +case .remoteConnect(.presented(.delegate(.submitted(let payload)))): + // create/update host profile, create remote persisted entry, reload repositories +``` + +- [ ] **Step 5: Run tests and commit** + +Run: + +```bash +xcodebuild test -project supacode.xcodeproj -scheme supacode -destination "platform=macOS" \ + -only-testing:supacodeTests/RemoteConnectFeatureTests \ + -only-testing:supacodeTests/RepositoriesFeatureTests \ + CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO CODE_SIGN_IDENTITY="" -skipMacroValidation +``` + +Expected: PASS. + +```bash +git add \ + supacode/Features/Repositories/Reducer/RemoteConnectFeature.swift \ + supacode/Features/Repositories/Views/RemoteConnectSheet.swift \ + supacode/Features/Repositories/Reducer/RepositoriesFeature.swift \ + supacode/App/ContentView.swift \ + supacode/Features/Repositories/Views/SidebarFooterView.swift \ + supacodeTests/RemoteConnectFeatureTests.swift \ + supacodeTests/RepositoriesFeatureTests.swift +git commit -m "feat: add host-first remote repository onboarding flow" +``` + +## Task 7: Add SSH Hosts Management in Settings + +**Files:** +- Create: `supacode/Features/Settings/Reducer/SSHHostsFeature.swift` +- Create: `supacode/Features/Settings/Views/SSHHostsSettingsView.swift` +- Modify: `supacode/Features/Settings/Views/SettingsSection.swift` +- Modify: `supacode/Features/Settings/Views/SettingsView.swift` +- Modify: `supacode/Features/Settings/Reducer/SettingsFeature.swift` +- Test: `supacodeTests/SettingsFeatureSSHHostsTests.swift` + +- [ ] **Step 1: Write failing settings tests for CRUD and in-use delete guard** + +```swift +@Test func addHostAppendsProfile() async { ... } +@Test func deleteHostFailsWhenBoundRepositoriesExist() async { ... } +``` + +- [ ] **Step 2: Run tests and verify fail** + +Run: + +```bash +xcodebuild test -project supacode.xcodeproj -scheme supacode -destination "platform=macOS" \ + -only-testing:supacodeTests/SettingsFeatureSSHHostsTests \ + CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO CODE_SIGN_IDENTITY="" -skipMacroValidation +``` + +Expected: missing section/feature failures. + +- [ ] **Step 3: Implement feature and add settings section** + +```swift +// SettingsSection.swift +case sshHosts +``` + +```swift +// SettingsView.swift +Label("SSH Hosts", systemImage: "network") + .tag(SettingsSection.sshHosts) +``` + +```swift +// SettingsFeature.State +var sshHosts: SSHHostsFeature.State? +``` + +- [ ] **Step 4: Build UI with tooltips and validation** + +```swift +Button("Add Host") { store.send(.addHostTapped) } + .help("Add SSH host profile") +``` + +- [ ] **Step 5: Run tests and commit** + +Run command from Step 2. +Expected: PASS. + +```bash +git add \ + supacode/Features/Settings/Reducer/SSHHostsFeature.swift \ + supacode/Features/Settings/Views/SSHHostsSettingsView.swift \ + supacode/Features/Settings/Views/SettingsSection.swift \ + supacode/Features/Settings/Views/SettingsView.swift \ + supacode/Features/Settings/Reducer/SettingsFeature.swift \ + supacodeTests/SettingsFeatureSSHHostsTests.swift +git commit -m "feat: add ssh host profile management in settings" +``` + +## Task 8: Implement Always-Show Remote tmux Session Picker + +**Files:** +- Create: `supacode/Features/Repositories/Reducer/RemoteSessionPickerFeature.swift` +- Create: `supacode/Features/Repositories/Views/RemoteSessionPickerSheet.swift` +- Modify: `supacode/Features/Repositories/Reducer/RepositoriesFeature.swift` +- Modify: `supacode/App/ContentView.swift` +- Test: `supacodeTests/RemoteSessionPickerFeatureTests.swift` +- Test: `supacodeTests/RepositoriesFeatureTests.swift` + +- [ ] **Step 1: Add failing tests for "always picker" behavior** + +```swift +@Test func selectingRemoteWorktreeWithSessionsAlwaysPresentsPicker() async { ... } +@Test func pickerAttachSelectionPersistsLastAttachedSessionName() async { ... } +``` + +- [ ] **Step 2: Run tests and verify fail** + +Run: + +```bash +xcodebuild test -project supacode.xcodeproj -scheme supacode -destination "platform=macOS" \ + -only-testing:supacodeTests/RemoteSessionPickerFeatureTests \ + -only-testing:supacodeTests/RepositoriesFeatureTests \ + CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO CODE_SIGN_IDENTITY="" -skipMacroValidation +``` + +Expected: missing picker flow failures. + +- [ ] **Step 3: Implement picker reducer and connect to remote worktree select path** + +```swift +@Presents var remoteSessionPicker: RemoteSessionPickerFeature.State? +case remoteSessionsLoaded(worktreeID: Worktree.ID, sessions: [String]) +``` + +```swift +if repository.endpoint.isRemote { + let sessions = try await remoteTmuxClient.listSessions(profile, 8) + await send(.remoteSessionsLoaded(worktreeID: worktreeID, sessions: sessions)) +} +``` + +- [ ] **Step 4: Persist session choice into repo settings** + +```swift +@Shared(.repositorySettings(repository.rootURL)) var repositorySettings +$repositorySettings.withLock { + $0.lastAttachedRemoteTmuxSessionName = selectedSession +} +``` + +- [ ] **Step 5: Run tests and commit** + +Run command from Step 2. +Expected: PASS. + +```bash +git add \ + supacode/Features/Repositories/Reducer/RemoteSessionPickerFeature.swift \ + supacode/Features/Repositories/Views/RemoteSessionPickerSheet.swift \ + supacode/Features/Repositories/Reducer/RepositoriesFeature.swift \ + supacode/App/ContentView.swift \ + supacodeTests/RemoteSessionPickerFeatureTests.swift \ + supacodeTests/RepositoriesFeatureTests.swift +git commit -m "feat: add always-on remote tmux session picker flow" +``` + +## Task 9: Wire Remote Terminal Attach + Reconnect + +**Files:** +- Modify: `supacode/Clients/Terminal/TerminalClient.swift` +- Modify: `supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift` +- Modify: `supacode/Features/Terminal/Models/WorktreeTerminalState.swift` +- Modify: `supacode/Features/App/Reducer/AppFeature.swift` +- Test: `supacodeTests/WorktreeTerminalManagerTests.swift` +- Test: `supacodeTests/AppFeatureRunScriptTests.swift` + +- [ ] **Step 1: Add failing tests for reconnect event handling** + +```swift +@Test func remoteSurfaceExitEmitsReconnectRequestedEvent() async { ... } +@Test func appFeatureHandlesReconnectByRequestingSessionPicker() async { ... } +``` + +- [ ] **Step 2: Run tests and verify fail** + +Run: + +```bash +xcodebuild test -project supacode.xcodeproj -scheme supacode -destination "platform=macOS" \ + -only-testing:supacodeTests/WorktreeTerminalManagerTests \ + -only-testing:supacodeTests/AppFeatureRunScriptTests \ + CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO CODE_SIGN_IDENTITY="" -skipMacroValidation +``` + +Expected: missing terminal event/API failures. + +- [ ] **Step 3: Add terminal command/event hooks** + +```swift +// TerminalClient.Command +case setRemoteAttachCommand(Worktree, command: String?) + +// TerminalClient.Event +case remoteReconnectRequested(worktreeID: Worktree.ID) +``` + +```swift +// WorktreeTerminalState.handleCloseRequest(...) +if isRemoteWorktree && tabWasPrimaryRemoteAttach { + onRemoteReconnectRequested?() +} +``` + +- [ ] **Step 4: Handle reconnect in AppFeature** + +```swift +case .terminalEvent(.remoteReconnectRequested(let worktreeID)): + return .send(.repositories(.requestRemoteSessionPickerForReconnect(worktreeID))) +``` + +- [ ] **Step 5: Run tests and commit** + +Run command from Step 2. +Expected: PASS. + +```bash +git add \ + supacode/Clients/Terminal/TerminalClient.swift \ + supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift \ + supacode/Features/Terminal/Models/WorktreeTerminalState.swift \ + supacode/Features/App/Reducer/AppFeature.swift \ + supacodeTests/WorktreeTerminalManagerTests.swift \ + supacodeTests/AppFeatureRunScriptTests.swift +git commit -m "feat: add remote terminal attach and reconnect signaling" +``` + +## Task 10: Command Palette + Menu Integration for Remote Connect + +**Files:** +- Modify: `supacode/Features/CommandPalette/CommandPaletteItem.swift` +- Modify: `supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift` +- Modify: `supacode/Features/App/Reducer/AppFeature.swift` +- Modify: `supacode/Commands/WorktreeCommands.swift` +- Test: `supacodeTests/CommandPaletteFeatureTests.swift` +- Test: `supacodeTests/AppFeatureCommandPaletteTests.swift` + +- [ ] **Step 1: Add failing command palette tests** + +```swift +@Test func includesRemoteConnectGlobalAction() { + let items = CommandPaletteFeature.commandPaletteItems(from: .init()) + #expect(items.contains(where: { $0.kind == .remoteConnect })) +} +``` + +- [ ] **Step 2: Run tests and verify fail** + +Run: + +```bash +xcodebuild test -project supacode.xcodeproj -scheme supacode -destination "platform=macOS" \ + -only-testing:supacodeTests/CommandPaletteFeatureTests \ + -only-testing:supacodeTests/AppFeatureCommandPaletteTests \ + CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO CODE_SIGN_IDENTITY="" -skipMacroValidation +``` + +Expected: enum case missing. + +- [ ] **Step 3: Add command palette kind/delegate/action mapping** + +```swift +enum CommandPaletteItem.Kind: Equatable { + case remoteConnect +} + +enum CommandPaletteFeature.Delegate: Equatable { + case remoteConnect +} +``` + +- [ ] **Step 4: Route delegate in AppFeature and add menu item** + +```swift +case .commandPalette(.delegate(.remoteConnect)): + return .send(.repositories(.presentRemoteConnect)) +``` + +```swift +Button("Connect Remote Repository...", systemImage: "network") { + store.send(.repositories(.presentRemoteConnect)) +} +.help("Connect Remote Repository") +``` + +- [ ] **Step 5: Run tests and commit** + +Run command from Step 2. +Expected: PASS. + +```bash +git add \ + supacode/Features/CommandPalette/CommandPaletteItem.swift \ + supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift \ + supacode/Features/App/Reducer/AppFeature.swift \ + supacode/Commands/WorktreeCommands.swift \ + supacodeTests/CommandPaletteFeatureTests.swift \ + supacodeTests/AppFeatureCommandPaletteTests.swift +git commit -m "feat: expose remote connect in command palette and menu" +``` + +## Task 11: Guard Local-Only Actions for Remote Repositories + +**Files:** +- Modify: `supacode/Features/App/Reducer/AppFeature.swift` +- Modify: `supacode/Features/Settings/Views/RepositorySettingsView.swift` +- Modify: `supacode/Features/RepositorySettings/Reducer/RepositorySettingsFeature.swift` +- Test: `supacodeTests/RepositorySettingsFeatureTests.swift` +- Test: `supacodeTests/AppFeatureDefaultEditorTests.swift` + +- [ ] **Step 1: Add failing tests for local-only action disabling** + +```swift +@Test func remoteRepositoryDisablesOpenFinderEditorActions() async { ... } +@Test func repositorySettingsHidesLocalPathDependentOptionsForRemoteRepo() { ... } +``` + +- [ ] **Step 2: Run tests and verify fail** + +Run: + +```bash +xcodebuild test -project supacode.xcodeproj -scheme supacode -destination "platform=macOS" \ + -only-testing:supacodeTests/RepositorySettingsFeatureTests \ + -only-testing:supacodeTests/AppFeatureDefaultEditorTests \ + CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO CODE_SIGN_IDENTITY="" -skipMacroValidation +``` + +Expected: assertions fail because remote/local distinctions absent. + +- [ ] **Step 3: Implement local-only guards** + +```swift +guard selectedRepository?.endpoint == .local else { + state.alert = messageAlert(title: "Unavailable for remote repository", message: "Use terminal attach for remote repositories.") + return .none +} +``` + +- [ ] **Step 4: Add remote-specific repository settings fields** + +```swift +Section("Remote") { + Text("Host profile: \(resolvedHostDisplayName)") + TextField("Default tmux session", text: settings.defaultRemoteTmuxSessionNameString) +} +``` + +- [ ] **Step 5: Run tests and commit** + +Run command from Step 2. +Expected: PASS. + +```bash +git add \ + supacode/Features/App/Reducer/AppFeature.swift \ + supacode/Features/Settings/Views/RepositorySettingsView.swift \ + supacode/Features/RepositorySettings/Reducer/RepositorySettingsFeature.swift \ + supacodeTests/RepositorySettingsFeatureTests.swift \ + supacodeTests/AppFeatureDefaultEditorTests.swift +git commit -m "feat: add remote-aware action guards and repository settings" +``` + +## Task 12: Final Verification, Build, and Documentation + +**Files:** +- Modify: `README.md` +- Modify: `doc-onevcat/change-list.md` + +- [ ] **Step 1: Run format/lint/check** + +Run: `make check` +Expected: no formatting or lint errors. + +- [ ] **Step 2: Run targeted remote test suite** + +Run: + +```bash +xcodebuild test -project supacode.xcodeproj -scheme supacode -destination "platform=macOS" \ + -only-testing:supacodeTests/RemoteConnectFeatureTests \ + -only-testing:supacodeTests/RemoteSessionPickerFeatureTests \ + -only-testing:supacodeTests/RemoteExecutionClientTests \ + -only-testing:supacodeTests/RemoteTmuxClientTests \ + -only-testing:supacodeTests/GitClientRemoteCommandTests \ + CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO CODE_SIGN_IDENTITY="" -skipMacroValidation +``` + +Expected: PASS. + +- [ ] **Step 3: Run full test suite** + +Run: `make test` +Expected: PASS. + +- [ ] **Step 4: Build app** + +Run: `make build-app` +Expected: `** BUILD SUCCEEDED **`. + +- [ ] **Step 5: Commit docs + final integration** + +```bash +git add README.md doc-onevcat/change-list.md +git commit -m "docs: document remote ssh and tmux support" +``` + +--- + +## Spec Coverage Check (Self-Review) + +- Shared/persisted SSH host profiles: Task 2 + Task 7 +- Host-first remote add flow: Task 6 +- Reuse host profile across repos: Task 2 + Task 6 + Task 7 +- Native ssh/tmux command usage: Task 3 + Task 4 + Task 8 +- Always show tmux session picker: Task 8 +- SSH-only reconnect continuity (no mosh transport): Task 9 +- Remote git worktree operations over SSH: Task 5 +- Repo-level tmux session bindings: Task 3 + Task 8 + Task 11 +- Error handling and operation-level failures: Task 4 + Task 6 + Task 8 + Task 11 +- Testing matrix and rollout-quality verification: Tasks 1-12 with final checks in Task 12 + +No uncovered spec requirement remains. + +## Placeholder Scan (Self-Review) + +- No `TBD`, `TODO`, or deferred implementation markers. +- Every coding task includes explicit files, commands, and concrete code snippets. +- Every task includes a commit step with explicit `git add` paths. + +## Type Consistency Check (Self-Review) + +- `RepositoryEndpoint`, `SSHHostProfile`, `RemoteExecutionClient`, and `RemoteTmuxClient` are introduced once and referenced consistently in later tasks. +- Remote picker action names and state references are consistent across Repositories/App/ContentView tasks. +- Repository settings remote session fields use consistent names (`defaultRemoteTmuxSessionName`, `lastAttachedRemoteTmuxSessionName`). diff --git a/docs/superpowers/specs/2026-03-27-remote-ssh-tmux-design.md b/docs/superpowers/specs/2026-03-27-remote-ssh-tmux-design.md new file mode 100644 index 000000000..70ff999ee --- /dev/null +++ b/docs/superpowers/specs/2026-03-27-remote-ssh-tmux-design.md @@ -0,0 +1,267 @@ +# Remote SSH Management + tmux Auto-Detect Design (Prowl) + +Date: 2026-03-27 +Status: Approved for planning + +## 1. Goal + +Add first-class remote repository support to Prowl with: + +- shared, persisted SSH host profiles reusable across many repositories +- repository-level remote path and tmux session management +- native SSH/tmux/git command execution for remote workflows +- tmux session discovery and user-driven attach behavior +- mosh-like reconnect continuity over SSH (no mosh transport) + +This design follows the existing Prowl architecture (`TCA + WorktreeTerminalManager + Ghostty`) and does not replace Ghostty terminal rendering. + +## 2. Product Decisions (Locked) + +1. Shared host profiles are required because one machine can host many repos. +2. SSH connection data is shared/persisted globally; repo path and tmux session are repo-level. +3. Native `ssh` and `tmux` commands are preferred. +4. On remote open/attach, tmux session selection is always explicit via picker. +5. No mosh protocol in v1. Reconnect UX should be mosh-like, but SSH transport only. +6. Remote git/worktree operations are in scope in v1 and execute over SSH. +7. Remote add flow is a single flow: + - user chooses `Add Remote Repository` + - flow prompts for host creation first + - then prompts for repo path and validation +8. Auth support in v1: + - SSH key/agent + - optional password fallback stored in macOS Keychain +9. SSHFS is optional and non-core in v1 (SSH command execution is primary mode). + +## 3. Non-Goals (v1) + +- mosh UDP transport support +- full host-profile marketplace/import/export +- SSH jump-host presets and advanced proxy presets in initial release +- SSHFS as required path for remote operations +- changing existing local-only behavior unless repository location is remote + +## 4. Architecture + +### 4.1 New Domain Models + +Add global host profile model: + +- `SSHHostProfile` + - `id` + - `displayName` + - `host` + - `user` + - `port` + - `authMethod` (`publicKey`, `password`) + - `createdAt`, `updatedAt` + - keychain lookup key (or stable key derivation input) + +Add repository location model: + +- `RepositoryExecutionLocation` + - `.local` + - `.remote(hostProfileID: SSHHostProfile.ID, remoteRepositoryPath: String)` + +Add repo-level remote terminal defaults: + +- `defaultTmuxSessionName: String?` +- `lastAttachedTmuxSessionName: String?` + +### 4.2 Persistence Boundaries + +- Host profiles: global persisted settings. +- Remote passwords: Keychain only, never plain-text in repo settings or logs. +- Repository remote binding (location/path/session defaults): per-repository settings. + +### 4.3 Execution Abstraction + +Introduce a single endpoint-aware execution seam used by remote-capable operations: + +- Local repository -> current local process execution. +- Remote repository -> native `/usr/bin/ssh` invocation with: + - connect timeout + - keepalive options + - bounded execution timeout + - stable control socket naming + +Execution interfaces remain command-oriented and avoid introducing protocol-specific runtime complexity. + +## 5. UX and Data Flow + +### 5.1 Add Remote Repository (Single Flow) + +1. User chooses `Add Repository` -> `Remote Repository`. +2. Flow requires host creation step first (v1). +3. User enters remote repository path. +4. App validates over SSH: + - host connect/auth + - path existence/access + - `tmux` availability + - git repository and worktree capability checks +5. App persists: + - shared `SSHHostProfile` + - repository remote binding (`hostProfileID + remoteRepositoryPath`) + +Future extension (explicitly planned): step 2 can become `Select existing host profile` or `Add new`. + +### 5.2 Terminal Open / tmux Attach + +When opening a remote repository/worktree: + +1. Query tmux sessions via SSH (`tmux list-sessions`). +2. Always show session picker when sessions are available. +3. Picker options: + - `Attach Existing Session` + - `Create New Managed Session` +4. Persist selection into repo-level fields (`lastAttachedTmuxSessionName`, optional default). +5. Attach terminal using SSH + tmux command path. + +### 5.3 Remote Git/Worktree Operations + +Existing repository actions should preserve behavior semantics while switching execution backend: + +- `git worktree list` +- `git worktree add` +- `git worktree remove` +- related branch/worktree operations + +For remote repositories, commands execute on host via SSH at repo path. +In v1, adding a remote repository requires the remote path to be a git repository. + +### 5.4 Reconnect UX (SSH Only) + +If remote terminal process exits unexpectedly: + +1. Move to `Reconnecting...` state. +2. Retry attach with bounded exponential backoff. +3. Reattach selected tmux session when available. +4. If reattach cannot complete, present user actions: + - `Retry` + - `Choose Session` + - `Update Credentials` + +No implicit destructive reset of repo/session bindings on transient failure. + +## 6. Command and Runtime Behavior + +### 6.1 SSH Command Construction + +Remote command execution should: + +- use `/usr/bin/ssh` +- include keepalive/connect timeout options +- use explicit target (`[user@]host`, optional `-p`) +- execute escaped remote command payloads +- enforce operation-level timeout + +### 6.2 tmux Session Metadata + +When creating managed sessions, write tmux user options for later discovery and disambiguation: + +- repository identity key +- repository path +- app-managed marker + +This prevents collisions and improves picker defaults without requiring hidden session auto-attach. + +### 6.3 Logging + +All runtime logs must use `SupaLogger`. +Sensitive values (passwords, raw secrets, keychain payloads) must never appear in logs. + +## 7. Error Handling + +### 7.1 Error Classes + +Surface user-facing errors by phase: + +- host unreachable / DNS +- authentication failure +- timeout +- host key mismatch +- tmux unavailable +- git/worktree command failure + +Each error should include operation context and actionable next step. + +### 7.2 Profile Lifecycle Safety + +- Deleting a host profile in use should be blocked by default. +- User must reassign affected repositories or confirm detach flow. +- Password updates should support immediate retry without re-adding repository. + +### 7.3 Session Attach Safety + +- If selected tmux session disappears between selection and attach, reprompt with refreshed list. +- If no sessions exist, user can create managed session directly from the attach flow. + +## 8. Testing Strategy + +### 8.1 Unit Tests + +- Host profile model and normalization +- Host input parsing (`[user@]host[:port]`) +- Keychain persistence and credential update/read paths +- Endpoint-aware command runner (local vs remote command construction) +- SSH option filtering and timeout behavior +- tmux session parsing +- remote git/worktree command composition + +### 8.2 Reducer / State Tests (TCA) + +- remote add flow state machine +- host-first add flow transitions and validation failures +- session picker presentation and selection persistence +- reconnect state transitions +- in-use host profile deletion guard behavior + +### 8.3 Integration Tests (Stubbed Process Runner) + +- end-to-end remote repository open -> session picker -> attach +- remote git worktree operations routed through SSH command runner +- credential update and retry success path + +### 8.4 Manual QA Matrix + +- SSH key success/failure +- password keychain update/retry +- unreachable host / timeout / host key mismatch +- tmux missing vs no sessions vs multiple sessions +- remote reconnect under transient disconnect +- remote git worktree create/list/remove behavior + +## 9. Rollout Plan + +### Phase 1: Internal + +- behind feature flag +- limited dogfooding with real remote hosts +- collect error-class distribution and reconnect stability + +### Phase 2: Public Beta + +- enable by default for fork users +- keep diagnostics and fallback controls enabled + +### Phase 3: Stable + +- remove flag +- keep rollback switch to disable remote execution path rapidly if needed + +## 10. Implementation Notes for Planning + +- Prefer incremental slices: + 1. models + persistence + host-first remote add flow + 2. endpoint-aware command execution + 3. tmux session picker + attach + 4. remote git/worktree operation routing + 5. reconnect state/polish + QA hardening +- Preserve existing local behavior as invariant. +- Keep module boundaries explicit to avoid remote-specific logic leaking across unrelated reducers. + +## 11. References + +- Mori PR #19 (remote SSH project support and tmux attach patterns): + https://github.com/vaayne/mori/pull/19 +- Mosh project (continuity/reconnect UX inspiration; transport not adopted): + https://mosh.org/ diff --git a/supacode.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/supacode.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 98141a705..f912afff2 100644 --- a/supacode.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/supacode.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -69,8 +69,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-composable-architecture", "state" : { - "revision" : "5b0890fabfd68a2d375d68502bc3f54a8548c494", - "version" : "1.23.1" + "revision" : "d5d2e0258fa2e80df761c2b73353422d42f4b98e", + "version" : "1.25.3" } }, { @@ -114,8 +114,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-navigation", "state" : { - "revision" : "bf498690e1f6b4af790260f542e8428a4ba10d78", - "version" : "2.6.0" + "revision" : "e7441dc4dfec6a4ae929e614e3c1e67c6639d164", + "version" : "2.7.0" } }, { diff --git a/supacode/App/ContentView.swift b/supacode/App/ContentView.swift index 623c1ad65..bc9271177 100644 --- a/supacode/App/ContentView.swift +++ b/supacode/App/ContentView.swift @@ -76,6 +76,14 @@ struct ContentView: View { promptStore in WorktreeCreationPromptView(store: promptStore) } + .sheet(store: repositoriesStore.scope(state: \.$remoteConnect, action: \.remoteConnect)) { + remoteConnectStore in + RemoteConnectSheet(store: remoteConnectStore) + } + .sheet(store: repositoriesStore.scope(state: \.$remoteSessionPicker, action: \.remoteSessionPicker)) { + remoteSessionPickerStore in + RemoteSessionPickerSheet(store: remoteSessionPickerStore) + } .sheet(isPresented: isRunScriptPromptPresented) { RunScriptPromptView( script: runScriptDraft, diff --git a/supacode/Clients/Git/GitClient.swift b/supacode/Clients/Git/GitClient.swift index 66dcfe479..b6803837e 100644 --- a/supacode/Clients/Git/GitClient.swift +++ b/supacode/Clients/Git/GitClient.swift @@ -51,9 +51,21 @@ struct GitClient { } private let shell: ShellClient + private let remoteExecution: RemoteExecutionClient nonisolated init(shell: ShellClient = .live) { + self.init( + shell: shell, + remoteExecution: makeDefaultRemoteExecutionClient(shell: shell) + ) + } + + nonisolated init( + shell: ShellClient, + remoteExecution: RemoteExecutionClient + ) { self.shell = shell + self.remoteExecution = remoteExecution } nonisolated func repoRoot(for path: URL) async throws -> URL { @@ -74,7 +86,32 @@ struct GitClient { } nonisolated func worktrees(for repoRoot: URL) async throws -> [Worktree] { - let repositoryRootURL = repoRoot.standardizedFileURL + try await worktrees( + for: repoRoot, + endpoint: .local, + hostProfile: nil + ) + } + + nonisolated func worktrees( + for repoRoot: URL, + endpoint: RepositoryEndpoint, + hostProfile: SSHHostProfile? + ) async throws -> [Worktree] { + switch endpoint { + case .local: + return try await localWorktrees(for: repoRoot) + case .remote(_, let remotePath): + return try await remoteWorktrees( + for: repoRoot, + remotePath: remotePath, + endpoint: endpoint, + hostProfile: hostProfile + ) + } + } + + nonisolated private func localWorktrees(for repoRoot: URL) async throws -> [Worktree] { let output = try await runWtList(repoRoot: repoRoot) let trimmed = output.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed.isEmpty { @@ -83,38 +120,43 @@ struct GitClient { let data = Data(trimmed.utf8) let entries = try JSONDecoder().decode([GitWtWorktreeEntry].self, from: data) .filter { !$0.isBare } - let worktreeEntries = entries.enumerated().map { index, entry in - let worktreeURL = URL(fileURLWithPath: entry.path).standardizedFileURL - let name = entry.branch.isEmpty ? worktreeURL.lastPathComponent : entry.branch - let detail = Self.relativePath(from: repositoryRootURL, to: worktreeURL) - let id = worktreeURL.path(percentEncoded: false) - let resourceValues = try? worktreeURL.resourceValues(forKeys: [ - .creationDateKey, .contentModificationDateKey, - ]) - let createdAt = resourceValues?.creationDate ?? resourceValues?.contentModificationDate - let sortDate = createdAt ?? .distantPast - return WorktreeSortEntry( - worktree: Worktree( - id: id, - name: name, - detail: detail, - workingDirectory: worktreeURL, - repositoryRootURL: repositoryRootURL, - createdAt: createdAt - ), - createdAt: sortDate, - index: index - ) + return worktrees( + from: entries, + repositoryRootURL: repoRoot.standardizedFileURL, + detailBaseURL: repoRoot.standardizedFileURL, + endpoint: .local, + includeCreationDates: true + ) + } + + nonisolated private func remoteWorktrees( + for repoRoot: URL, + remotePath: String, + endpoint: RepositoryEndpoint, + hostProfile: SSHHostProfile? + ) async throws -> [Worktree] { + let command = buildRemoteGitCommand( + remotePath: remotePath, + arguments: ["worktree", "list", "--porcelain"] + ) + let output = try await runRemoteGit( + operation: .worktreeList, + command: command, + hostProfile: hostProfile, + timeoutSeconds: remoteGitTimeoutSeconds + ) + let trimmed = output.stdout.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + return [] } - return - worktreeEntries - .sorted { lhs, rhs in - if lhs.createdAt != rhs.createdAt { - return lhs.createdAt > rhs.createdAt - } - return lhs.index < rhs.index - } - .map(\.worktree) + let entries = parseRemoteWorktreeEntries(trimmed).filter { !$0.isBare } + return worktrees( + from: entries, + repositoryRootURL: repoRoot.standardizedFileURL, + detailBaseURL: URL(fileURLWithPath: remotePath).standardizedFileURL, + endpoint: endpoint, + includeCreationDates: false + ) } nonisolated func pruneWorktrees(for repoRoot: URL) async throws { @@ -245,6 +287,26 @@ struct GitClient { baseDirectory: URL, copyFiles: (ignored: Bool, untracked: Bool), baseRef: String + ) async throws -> Worktree { + try await createWorktree( + named: name, + in: repoRoot, + baseDirectory: baseDirectory, + copyFiles: copyFiles, + baseRef: baseRef, + endpoint: .local, + hostProfile: nil + ) + } + + nonisolated func createWorktree( + named name: String, + in repoRoot: URL, + baseDirectory: URL, + copyFiles: (ignored: Bool, untracked: Bool), + baseRef: String, + endpoint: RepositoryEndpoint, + hostProfile: SSHHostProfile? ) async throws -> Worktree { var createdWorktree: Worktree? for try await event in createWorktreeStream( @@ -252,7 +314,9 @@ struct GitClient { in: repoRoot, baseDirectory: baseDirectory, copyFiles: copyFiles, - baseRef: baseRef + baseRef: baseRef, + endpoint: endpoint, + hostProfile: hostProfile ) { if case .finished(let worktree) = event { createdWorktree = worktree @@ -280,6 +344,55 @@ struct GitClient { baseDirectory: URL, copyFiles: (ignored: Bool, untracked: Bool), baseRef: String + ) -> AsyncThrowingStream { + createWorktreeStream( + named: name, + in: repoRoot, + baseDirectory: baseDirectory, + copyFiles: copyFiles, + baseRef: baseRef, + endpoint: .local, + hostProfile: nil + ) + } + + nonisolated func createWorktreeStream( + named name: String, + in repoRoot: URL, + baseDirectory: URL, + copyFiles: (ignored: Bool, untracked: Bool), + baseRef: String, + endpoint: RepositoryEndpoint, + hostProfile: SSHHostProfile? + ) -> AsyncThrowingStream { + switch endpoint { + case .local: + return localCreateWorktreeStream( + named: name, + in: repoRoot, + baseDirectory: baseDirectory, + copyFiles: copyFiles, + baseRef: baseRef + ) + case .remote(_, let remotePath): + return remoteCreateWorktreeStream( + named: name, + in: repoRoot, + remotePath: remotePath, + baseDirectory: baseDirectory, + baseRef: baseRef, + endpoint: endpoint, + hostProfile: hostProfile + ) + } + } + + nonisolated private func localCreateWorktreeStream( + named name: String, + in repoRoot: URL, + baseDirectory: URL, + copyFiles: (ignored: Bool, untracked: Bool), + baseRef: String ) -> AsyncThrowingStream { AsyncThrowingStream { continuation in Task { @@ -357,6 +470,63 @@ struct GitClient { } } + nonisolated private func remoteCreateWorktreeStream( + named name: String, + in repoRoot: URL, + remotePath: String, + baseDirectory: URL, + baseRef: String, + endpoint: RepositoryEndpoint, + hostProfile: SSHHostProfile? + ) -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + Task { + let repositoryRootURL = repoRoot.standardizedFileURL + let remoteRepositoryRootURL = URL(fileURLWithPath: remotePath).standardizedFileURL + let worktreeURL = baseDirectory + .appending(path: name, directoryHint: .isDirectory) + .standardizedFileURL + let worktreePath = worktreeURL.path(percentEncoded: false) + let command = buildRemoteCreateWorktreeCommand( + remotePath: remotePath, + worktreePath: worktreePath, + name: name, + baseRef: baseRef + ) + do { + let output = try await runRemoteGit( + operation: .worktreeCreate, + command: command, + hostProfile: hostProfile, + timeoutSeconds: remoteGitCreateTimeoutSeconds + ) + for line in output.stderr.split(whereSeparator: \.isNewline) { + continuation.yield(.outputLine(ShellStreamLine(source: .stderr, text: String(line)))) + } + for line in output.stdout.split(whereSeparator: \.isNewline) { + continuation.yield(.outputLine(ShellStreamLine(source: .stdout, text: String(line)))) + } + let detail = Self.relativePath(from: remoteRepositoryRootURL, to: worktreeURL) + continuation.yield( + .finished( + Worktree( + id: worktreePath, + name: name, + detail: detail, + workingDirectory: worktreeURL, + repositoryRootURL: repositoryRootURL, + endpoint: endpoint + ) + ) + ) + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + } + } + nonisolated private func createWorktreeArguments( baseDirectory: URL, name: String, @@ -531,6 +701,34 @@ struct GitClient { } nonisolated func removeWorktree(_ worktree: Worktree, deleteBranch: Bool) async throws -> URL { + try await removeWorktree( + worktree, + deleteBranch: deleteBranch, + endpoint: .local, + hostProfile: nil + ) + } + + nonisolated func removeWorktree( + _ worktree: Worktree, + deleteBranch: Bool, + endpoint: RepositoryEndpoint, + hostProfile: SSHHostProfile? + ) async throws -> URL { + switch endpoint { + case .local: + return try await localRemoveWorktree(worktree, deleteBranch: deleteBranch) + case .remote(_, let remotePath): + return try await remoteRemoveWorktree( + worktree, + deleteBranch: deleteBranch, + remotePath: remotePath, + hostProfile: hostProfile + ) + } + } + + nonisolated private func localRemoveWorktree(_ worktree: Worktree, deleteBranch: Bool) async throws -> URL { let rootPath = worktree.repositoryRootURL.path(percentEncoded: false) let worktreeURL = worktree.workingDirectory.standardizedFileURL let worktreePath = worktreeURL.path(percentEncoded: false) @@ -595,6 +793,53 @@ struct GitClient { .count } + nonisolated private func worktrees( + from entries: [GitWtWorktreeEntry], + repositoryRootURL: URL, + detailBaseURL: URL, + endpoint: RepositoryEndpoint, + includeCreationDates: Bool + ) -> [Worktree] { + let worktreeEntries = entries.enumerated().map { index, entry in + let worktreeURL = URL(fileURLWithPath: entry.path).standardizedFileURL + let name = entry.branch.isEmpty ? worktreeURL.lastPathComponent : entry.branch + let detail = Self.relativePath(from: detailBaseURL, to: worktreeURL) + let id = worktreeURL.path(percentEncoded: false) + let createdAt: Date? + if includeCreationDates { + let resourceValues = try? worktreeURL.resourceValues(forKeys: [ + .creationDateKey, .contentModificationDateKey, + ]) + createdAt = resourceValues?.creationDate ?? resourceValues?.contentModificationDate + } else { + createdAt = nil + } + let sortDate = createdAt ?? .distantPast + return WorktreeSortEntry( + worktree: Worktree( + id: id, + name: name, + detail: detail, + workingDirectory: worktreeURL, + repositoryRootURL: repositoryRootURL, + endpoint: endpoint, + createdAt: createdAt + ), + createdAt: sortDate, + index: index + ) + } + return + worktreeEntries + .sorted { lhs, rhs in + if lhs.createdAt != rhs.createdAt { + return lhs.createdAt > rhs.createdAt + } + return lhs.index < rhs.index + } + .map(\.worktree) + } + nonisolated private func lastNonEmptyLine(in output: String) -> String? { output .split(whereSeparator: \.isNewline) @@ -622,6 +867,63 @@ struct GitClient { } } + nonisolated private func parseRemoteWorktreeEntries(_ output: String) -> [GitWtWorktreeEntry] { + var entries: [GitWtWorktreeEntry] = [] + var currentPath: String? + var currentBranch = "" + var currentHead = "" + var currentIsBare = false + + func commitCurrentEntry() { + guard let currentPath else { return } + entries.append( + GitWtWorktreeEntry( + branch: currentBranch, + path: currentPath, + head: currentHead, + isBare: currentIsBare + ) + ) + } + + for line in output.split(omittingEmptySubsequences: false, whereSeparator: \.isNewline) { + let value = String(line) + if value.isEmpty { + commitCurrentEntry() + currentPath = nil + currentBranch = "" + currentHead = "" + currentIsBare = false + continue + } + if value.hasPrefix("worktree ") { + if currentPath != nil { + commitCurrentEntry() + currentBranch = "" + currentHead = "" + currentIsBare = false + } + currentPath = String(value.dropFirst("worktree ".count)) + continue + } + if value.hasPrefix("branch ") { + let ref = String(value.dropFirst("branch ".count)) + currentBranch = ref.replacing("refs/heads/", with: "") + continue + } + if value.hasPrefix("HEAD ") { + currentHead = String(value.dropFirst("HEAD ".count)) + continue + } + if value == "bare" { + currentIsBare = true + } + } + + commitCurrentEntry() + return entries + } + nonisolated private func deduplicated(_ values: [String]) -> [String] { var seen = Set() return values.filter { seen.insert($0).inserted } @@ -687,6 +989,59 @@ struct GitClient { } } + nonisolated private func runRemoteGit( + operation: GitOperation, + command: String, + hostProfile: SSHHostProfile?, + timeoutSeconds: Int + ) async throws -> RemoteExecutionClient.Output { + guard let hostProfile else { + throw GitClientError.commandFailed(command: command, message: "Missing SSH host profile") + } + let output: RemoteExecutionClient.Output + do { + output = try await remoteExecution.run(hostProfile, command, timeoutSeconds) + } catch { + gitLogger.warning("git command failed operation=\(operation.rawValue) exit_code=-1") + throw GitClientError.commandFailed(command: command, message: error.localizedDescription) + } + guard output.exitCode == 0 else { + throw wrapRemoteExecutionFailure(output, operation: operation, command: command) + } + return output + } + + nonisolated private func buildRemoteGitCommand( + remotePath: String, + arguments: [String] + ) -> String { + (["git", "-C", SSHCommandSupport.shellEscape(remotePath)] + arguments.map(Self.remoteShellToken)) + .joined(separator: " ") + } + + nonisolated private func buildRemoteCreateWorktreeCommand( + remotePath: String, + worktreePath: String, + name: String, + baseRef: String + ) -> String { + var arguments = [ + "worktree", + "add", + "-b", + name, + worktreePath, + ] + if !baseRef.isEmpty { + arguments.append(baseRef) + } + let gitCommand = buildRemoteGitCommand( + remotePath: remotePath, + arguments: arguments + ) + return "\(gitCommand) && printf '%s\\n' \(SSHCommandSupport.shellEscape(worktreePath))" + } + nonisolated private func runWtList(repoRoot: URL) async throws -> String { let wtURL = try wtScriptURL() let arguments = ["ls", "--json"] @@ -770,6 +1125,16 @@ struct GitClient { return path.deletingLastPathComponent() } + nonisolated private static func remoteShellToken(_ value: String) -> String { + let safeCharacters = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._/:") + if !value.isEmpty, + value.rangeOfCharacter(from: safeCharacters.inverted) == nil + { + return value + } + return SSHCommandSupport.shellEscape(value) + } + nonisolated private func runGitWorktreeRemove( rootPath: String, worktreePath: String @@ -787,6 +1152,38 @@ struct GitClient { ) } + nonisolated private func remoteRemoveWorktree( + _ worktree: Worktree, + deleteBranch: Bool, + remotePath: String, + hostProfile: SSHHostProfile? + ) async throws -> URL { + let worktreePath = worktree.workingDirectory.standardizedFileURL.path(percentEncoded: false) + let removeCommand = buildRemoteGitCommand( + remotePath: remotePath, + arguments: ["worktree", "remove", "--force", worktreePath] + ) + _ = try await runRemoteGit( + operation: .worktreeRemove, + command: removeCommand, + hostProfile: hostProfile, + timeoutSeconds: remoteGitTimeoutSeconds + ) + if deleteBranch, !worktree.name.isEmpty { + let deleteBranchCommand = buildRemoteGitCommand( + remotePath: remotePath, + arguments: ["branch", "-D", worktree.name] + ) + _ = try? await runRemoteGit( + operation: .branchDelete, + command: deleteBranchCommand, + hostProfile: hostProfile, + timeoutSeconds: remoteGitTimeoutSeconds + ) + } + return worktree.workingDirectory + } + nonisolated private static func relocateWorktreeDirectory(_ worktreeURL: URL) -> URL? { let fileManager = FileManager.default let worktreePath = worktreeURL.path(percentEncoded: false) @@ -868,6 +1265,43 @@ struct GitClient { } private nonisolated let gitLogger = SupaLogger("Git") +private nonisolated let remoteGitTimeoutSeconds = 30 +private nonisolated let remoteGitCreateTimeoutSeconds = 120 + +nonisolated private func makeDefaultRemoteExecutionClient(shell: ShellClient) -> RemoteExecutionClient { + RemoteExecutionClient( + run: { profile, command, timeoutSeconds in + let endpointKey = [profile.host, profile.user, profile.port.map(String.init) ?? "22"] + .joined(separator: "|") + let controlPath = SSHCommandSupport.controlSocketPath(endpointKey: endpointKey) + + var options = SSHCommandSupport.connectivityOptions(includeBatchMode: profile.authMethod != .password) + options += ["-o", "ControlPath=\(controlPath)"] + + if let port = profile.port { + options += ["-p", "\(port)"] + } + + let target = profile.user.isEmpty ? profile.host : "\(profile.user)@\(profile.host)" + let arguments = options + [target, command] + do { + let output = try await shell.runWithTimeout( + URL(fileURLWithPath: "/usr/bin/ssh"), + arguments, + nil, + timeoutSeconds: timeoutSeconds + ) + return RemoteExecutionClient.Output(stdout: output.stdout, stderr: output.stderr, exitCode: output.exitCode) + } catch let shellError as ShellClientError { + return RemoteExecutionClient.Output( + stdout: shellError.stdout, + stderr: shellError.stderr, + exitCode: shellError.exitCode + ) + } + } + ) +} nonisolated private func shouldFallbackToLoginShell(_ error: Error) -> Bool { guard let shellError = error as? ShellClientError else { @@ -914,6 +1348,35 @@ nonisolated private func wrapShellError( return gitError } +nonisolated private func wrapRemoteExecutionFailure( + _ output: RemoteExecutionClient.Output, + operation: GitOperation, + command: String +) -> GitClientError { + var messageParts: [String] = [] + if !output.stdout.isEmpty { + messageParts.append("stdout:\n\(output.stdout)") + } + if !output.stderr.isEmpty { + messageParts.append("stderr:\n\(output.stderr)") + } + let gitError = GitClientError.commandFailed( + command: command, + message: messageParts.joined(separator: "\n") + ) + gitLogger.warning("git command failed operation=\(operation.rawValue) exit_code=\(output.exitCode)") + #if !DEBUG + SentrySDK.logger.error( + "git command failed", + attributes: [ + "operation": operation.rawValue, + "exit_code": Int(output.exitCode), + ] + ) + #endif + return gitError +} + struct GitWtWorktreeEntry: Decodable, Equatable { let branch: String let path: String diff --git a/supacode/Clients/Remote/RemoteExecutionClient.swift b/supacode/Clients/Remote/RemoteExecutionClient.swift new file mode 100644 index 000000000..20242bc49 --- /dev/null +++ b/supacode/Clients/Remote/RemoteExecutionClient.swift @@ -0,0 +1,129 @@ +import ComposableArchitecture +import Foundation + +struct RemoteExecutionClient: Sendable { + struct Output: Equatable, Sendable { + let stdout: String + let stderr: String + let exitCode: Int32 + } + + var run: @Sendable (_ profile: SSHHostProfile, _ command: String, _ timeoutSeconds: Int) async throws -> Output +} + +extension RemoteExecutionClient: DependencyKey { + static let liveValue = live() + + static func live( + shellClient: ShellClient = .liveValue, + keychainClient: KeychainClient = .liveValue + ) -> RemoteExecutionClient { + RemoteExecutionClient( + run: { profile, command, timeoutSeconds in + let endpointKey = [profile.host, profile.user, profile.port.map(String.init) ?? "22"] + .joined(separator: "|") + let controlPath = SSHCommandSupport.controlSocketPath(endpointKey: endpointKey) + try SSHCommandSupport.ensureControlSocketDirectory(for: controlPath) + + var options = SSHCommandSupport.connectivityOptions(includeBatchMode: true) + options += ["-o", "ControlPath=\(controlPath)"] + + if let port = profile.port { + options += ["-p", "\(port)"] + } + + let target = profile.user.isEmpty ? profile.host : "\(profile.user)@\(profile.host)" + let arguments = options + [target, command] + do { + if profile.authMethod == .password { + guard let password = try await keychainClient.loadPassword(profile.id) else { + throw RemoteExecutionClientError.passwordMissing(profile.id) + } + try await bootstrapPasswordControlMaster( + shellClient: shellClient, + profile: profile, + target: target, + controlPath: controlPath, + password: password + ) + } + let output = try await shellClient.runWithTimeout( + URL(fileURLWithPath: "/usr/bin/ssh"), + arguments, + nil, + timeoutSeconds: timeoutSeconds + ) + return Output(stdout: output.stdout, stderr: output.stderr, exitCode: output.exitCode) + } catch let shellError as ShellClientError { + return Output( + stdout: shellError.stdout, + stderr: shellError.stderr, + exitCode: shellError.exitCode + ) + } + } + ) + } + + private static func bootstrapPasswordControlMaster( + shellClient: ShellClient, + profile: SSHHostProfile, + target: String, + controlPath: String, + password: String + ) async throws { + let askpassSupport = try SSHCommandSupport.makeAskpassSupport(password: password) + defer { + try? FileManager.default.removeItem(at: askpassSupport.helperURL) + } + + var arguments = SSHCommandSupport.connectivityOptions(includeBatchMode: false) + arguments += [ + "-o", "ControlMaster=auto", + "-o", "ControlPersist=600", + "-o", "ControlPath=\(controlPath)", + "-o", "PreferredAuthentications=password,keyboard-interactive", + "-o", "PubkeyAuthentication=no", + "-o", "NumberOfPasswordPrompts=1", + "-o", "BatchMode=no", + ] + + if let port = profile.port { + arguments += ["-p", "\(port)"] + } + + arguments += [target, "exit"] + + _ = try await shellClient.runWithTimeout( + URL(fileURLWithPath: "/usr/bin/ssh"), + arguments, + nil, + environment: askpassSupport.environment, + timeoutSeconds: SSHCommandSupport.bootstrapTimeoutSeconds + ) + } + + static let testValue = RemoteExecutionClient( + run: { _, _, _ in + Output(stdout: "", stderr: "", exitCode: 0) + } + ) +} + +extension DependencyValues { + var remoteExecutionClient: RemoteExecutionClient { + get { self[RemoteExecutionClient.self] } + set { self[RemoteExecutionClient.self] = newValue } + } +} + +private enum RemoteExecutionClientError: LocalizedError { + case passwordMissing(String) + + var errorDescription: String? { + switch self { + case .passwordMissing: + "SSH password is required for this host." + } + } +} diff --git a/supacode/Clients/Remote/RemoteTmuxClient.swift b/supacode/Clients/Remote/RemoteTmuxClient.swift new file mode 100644 index 000000000..c6dd6a4b1 --- /dev/null +++ b/supacode/Clients/Remote/RemoteTmuxClient.swift @@ -0,0 +1,95 @@ +import ComposableArchitecture +import Foundation + +struct RemoteTmuxClient: Sendable { + var listSessions: @Sendable (_ profile: SSHHostProfile, _ timeoutSeconds: Int) async throws -> [String] + var buildAttachCommand: @Sendable (_ profile: SSHHostProfile, _ sessionName: String, _ remotePath: String) -> String + var buildCreateAndAttachCommand: + @Sendable (_ profile: SSHHostProfile, _ preferredName: String, _ remotePath: String) -> String +} + +extension RemoteTmuxClient: DependencyKey { + static let liveValue = live() + + static func live(remoteExecution: RemoteExecutionClient = .liveValue) -> RemoteTmuxClient { + RemoteTmuxClient( + listSessions: { profile, timeoutSeconds in + let output = try await remoteExecution.run( + profile, + "tmux list-sessions -F '#S'", + timeoutSeconds + ) + if output.exitCode != 0 { + let stderr = output.stderr.lowercased() + if stderr.contains("no server running") || stderr.contains("failed to connect to server") { + return [] + } + throw RemoteTmuxClientError( + message: output.stderr.isEmpty ? output.stdout : output.stderr, + exitCode: output.exitCode + ) + } + return output.stdout + .split(whereSeparator: \.isNewline) + .map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + }, + buildAttachCommand: { profile, sessionName, remotePath in + let escapedPath = SSHCommandSupport.shellEscape(remotePath) + let escapedSession = SSHCommandSupport.shellEscape(sessionName) + let remoteCommand = "cd \(escapedPath) && tmux attach-session -t \(escapedSession)" + return buildSSHCommand(profile: profile, remoteCommand: remoteCommand) + }, + buildCreateAndAttachCommand: { profile, preferredName, remotePath in + let escapedPath = SSHCommandSupport.shellEscape(remotePath) + let escapedName = SSHCommandSupport.shellEscape(preferredName) + let ensureSession = + "(tmux has-session -t \(escapedName) 2>/dev/null || tmux new-session -d -s \(escapedName))" + let remoteCommand = "cd \(escapedPath) && \(ensureSession) && tmux attach-session -t \(escapedName)" + return buildSSHCommand(profile: profile, remoteCommand: remoteCommand) + } + ) + } + + static let testValue = RemoteTmuxClient( + listSessions: { _, _ in [] }, + buildAttachCommand: { _, _, _ in "" }, + buildCreateAndAttachCommand: { _, _, _ in "" } + ) +} + +extension DependencyValues { + var remoteTmuxClient: RemoteTmuxClient { + get { self[RemoteTmuxClient.self] } + set { self[RemoteTmuxClient.self] = newValue } + } +} + +private nonisolated func buildSSHCommand( + profile: SSHHostProfile, + remoteCommand: String +) -> String { + let endpointKey = [profile.host, profile.user, profile.port.map(String.init) ?? "22"] + .joined(separator: "|") + let controlPath = SSHCommandSupport.controlSocketPath(endpointKey: endpointKey) + let options = SSHCommandSupport.removingBatchMode( + from: SSHCommandSupport.connectivityOptions(includeBatchMode: true) + ) + var arguments = options + ["-o", "ControlPath=\(controlPath)", "-t"] + if let port = profile.port { + arguments += ["-p", "\(port)"] + } + let target = profile.user.isEmpty ? profile.host : "\(profile.user)@\(profile.host)" + arguments += [target, remoteCommand] + let shellEscapedArguments = arguments.map(SSHCommandSupport.shellEscape).joined(separator: " ") + return "/usr/bin/ssh \(shellEscapedArguments)" +} + +private nonisolated struct RemoteTmuxClientError: LocalizedError { + let message: String + let exitCode: Int32 + + var errorDescription: String? { + "tmux command failed (exit: \(exitCode)): \(message)" + } +} diff --git a/supacode/Clients/Repositories/GitClientDependency.swift b/supacode/Clients/Repositories/GitClientDependency.swift index 31083d818..331100820 100644 --- a/supacode/Clients/Repositories/GitClientDependency.swift +++ b/supacode/Clients/Repositories/GitClientDependency.swift @@ -4,6 +4,9 @@ import Foundation struct GitClientDependency: Sendable { var repoRoot: @Sendable (URL) async throws -> URL var worktrees: @Sendable (URL) async throws -> [Worktree] + var worktreesForEndpoint: + @Sendable (_ repoRoot: URL, _ endpoint: RepositoryEndpoint, _ hostProfile: SSHHostProfile?) async throws + -> [Worktree] var pruneWorktrees: @Sendable (URL) async throws -> Void var localBranchNames: @Sendable (URL) async throws -> Set var isValidBranchName: @Sendable (String, URL) async -> Bool @@ -31,7 +34,21 @@ struct GitClientDependency: Sendable { _ copyUntracked: Bool, _ baseRef: String ) -> AsyncThrowingStream + var createWorktreeStreamForEndpoint: + @Sendable ( + _ name: String, + _ repoRoot: URL, + _ baseDirectory: URL, + _ copyIgnored: Bool, + _ copyUntracked: Bool, + _ baseRef: String, + _ endpoint: RepositoryEndpoint, + _ hostProfile: SSHHostProfile? + ) -> AsyncThrowingStream var removeWorktree: @Sendable (_ worktree: Worktree, _ deleteBranch: Bool) async throws -> URL + var removeWorktreeForEndpoint: + @Sendable (_ worktree: Worktree, _ deleteBranch: Bool, _ endpoint: RepositoryEndpoint, _ hostProfile: SSHHostProfile?) + async throws -> URL var isBareRepository: @Sendable (_ repoRoot: URL) async throws -> Bool var branchName: @Sendable (URL) async -> String? var lineChanges: @Sendable (URL) async -> (added: Int, removed: Int)? @@ -43,6 +60,13 @@ extension GitClientDependency: DependencyKey { static let liveValue = GitClientDependency( repoRoot: { try await GitClient().repoRoot(for: $0) }, worktrees: { try await GitClient().worktrees(for: $0) }, + worktreesForEndpoint: { repoRoot, endpoint, hostProfile in + try await GitClient().worktrees( + for: repoRoot, + endpoint: endpoint, + hostProfile: hostProfile + ) + }, pruneWorktrees: { try await GitClient().pruneWorktrees(for: $0) }, localBranchNames: { try await GitClient().localBranchNames(for: $0) }, isValidBranchName: { branchName, repoRoot in @@ -71,9 +95,36 @@ extension GitClientDependency: DependencyKey { baseRef: baseRef ) }, + createWorktreeStreamForEndpoint: { + name, + repoRoot, + baseDirectory, + copyIgnored, + copyUntracked, + baseRef, + endpoint, + hostProfile in + GitClient().createWorktreeStream( + named: name, + in: repoRoot, + baseDirectory: baseDirectory, + copyFiles: (ignored: copyIgnored, untracked: copyUntracked), + baseRef: baseRef, + endpoint: endpoint, + hostProfile: hostProfile + ) + }, removeWorktree: { worktree, deleteBranch in try await GitClient().removeWorktree(worktree, deleteBranch: deleteBranch) }, + removeWorktreeForEndpoint: { worktree, deleteBranch, endpoint, hostProfile in + try await GitClient().removeWorktree( + worktree, + deleteBranch: deleteBranch, + endpoint: endpoint, + hostProfile: hostProfile + ) + }, isBareRepository: { repoRoot in try await GitClient().isBareRepository(for: repoRoot) }, diff --git a/supacode/Clients/Security/KeychainClient.swift b/supacode/Clients/Security/KeychainClient.swift new file mode 100644 index 000000000..5ffbdd03e --- /dev/null +++ b/supacode/Clients/Security/KeychainClient.swift @@ -0,0 +1,91 @@ +import ComposableArchitecture +import Foundation +import Security + +struct KeychainClient: Sendable { + var savePassword: @Sendable (_ password: String, _ key: String) async throws -> Void + var loadPassword: @Sendable (_ key: String) async throws -> String? + var deletePassword: @Sendable (_ key: String) async throws -> Void +} + +extension KeychainClient: DependencyKey { + static let liveValue = KeychainClient( + savePassword: { password, key in + let deleteStatus = SecItemDelete(keychainQuery(for: key) as CFDictionary) + guard deleteStatus == errSecSuccess || deleteStatus == errSecItemNotFound else { + throw KeychainClientError(operation: .delete, status: deleteStatus) + } + + var query = keychainQuery(for: key) + query[kSecValueData as String] = Data(password.utf8) + let status = SecItemAdd(query as CFDictionary, nil) + guard status == errSecSuccess else { + throw KeychainClientError(operation: .save, status: status) + } + }, + loadPassword: { key in + var query = keychainQuery(for: key) + query[kSecReturnData as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne + + var item: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &item) + switch status { + case errSecSuccess: + guard let data = item as? Data else { + throw KeychainClientError(operation: .load, status: errSecInternalError) + } + return String(data: data, encoding: .utf8) + case errSecItemNotFound: + return nil + default: + throw KeychainClientError(operation: .load, status: status) + } + }, + deletePassword: { key in + let status = SecItemDelete(keychainQuery(for: key) as CFDictionary) + guard status == errSecSuccess || status == errSecItemNotFound else { + throw KeychainClientError(operation: .delete, status: status) + } + } + ) + + static let testValue = KeychainClient( + savePassword: { _, _ in }, + loadPassword: { _ in nil }, + deletePassword: { _ in } + ) +} + +extension DependencyValues { + var keychainClient: KeychainClient { + get { self[KeychainClient.self] } + set { self[KeychainClient.self] = newValue } + } +} + +private nonisolated let keychainService = "com.onevcat.prowl.ssh" + +private nonisolated func keychainQuery(for key: String) -> [String: Any] { + [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: keychainService, + kSecAttrAccount as String: key, + ] +} + +private nonisolated struct KeychainClientError: LocalizedError { + enum Operation: String { + case save + case load + case delete + } + + let operation: Operation + let status: OSStatus + + var errorDescription: String? { + let statusMessage = (SecCopyErrorMessageString(status, nil) as String?) ?? "OSStatus \(status)" + return "Keychain \(operation.rawValue) failed: \(statusMessage)" + } +} diff --git a/supacode/Clients/Shell/ShellClient.swift b/supacode/Clients/Shell/ShellClient.swift index f0fe17f72..3e7fd154f 100644 --- a/supacode/Clients/Shell/ShellClient.swift +++ b/supacode/Clients/Shell/ShellClient.swift @@ -5,18 +5,40 @@ import Foundation nonisolated struct ShellClient: Sendable { var run: @Sendable (URL, [String], URL?) async throws -> ShellOutput var runLoginImpl: @Sendable (URL, [String], URL?, Bool) async throws -> ShellOutput + var runWithTimeoutImpl: @Sendable (URL, [String], URL?, Int) async throws -> ShellOutput + var runWithTimeoutEnvironmentImpl: @Sendable (URL, [String], URL?, [String: String], Int) async throws -> ShellOutput var runStream: @Sendable (URL, [String], URL?) -> AsyncThrowingStream var runLoginStreamImpl: @Sendable (URL, [String], URL?, Bool) -> AsyncThrowingStream init( run: @escaping @Sendable (URL, [String], URL?) async throws -> ShellOutput, runLoginImpl: @escaping @Sendable (URL, [String], URL?, Bool) async throws -> ShellOutput, + runWithTimeoutImpl: (@Sendable (URL, [String], URL?, Int) async throws -> ShellOutput)? = nil, + runWithTimeoutEnvironmentImpl: + (@Sendable (URL, [String], URL?, [String: String], Int) async throws -> ShellOutput)? = nil, runStream: (@Sendable (URL, [String], URL?) -> AsyncThrowingStream)? = nil, runLoginStreamImpl: (@Sendable (URL, [String], URL?, Bool) -> AsyncThrowingStream)? = nil ) { + let resolvedRunWithTimeoutImpl = + runWithTimeoutImpl + ?? { executableURL, arguments, currentDirectoryURL, _ in + try await run(executableURL, arguments, currentDirectoryURL) + } self.run = run self.runLoginImpl = runLoginImpl + self.runWithTimeoutImpl = resolvedRunWithTimeoutImpl + self.runWithTimeoutEnvironmentImpl = + runWithTimeoutEnvironmentImpl + ?? { executableURL, arguments, currentDirectoryURL, environment, timeoutSeconds in + _ = environment + return try await resolvedRunWithTimeoutImpl( + executableURL, + arguments, + currentDirectoryURL, + timeoutSeconds + ) + } self.runStream = runStream ?? { executableURL, arguments, currentDirectoryURL in @@ -58,6 +80,22 @@ nonisolated struct ShellClient: Sendable { try await runLoginImpl(executableURL, arguments, currentDirectoryURL, log) } + func runWithTimeout( + _ executableURL: URL, + _ arguments: [String], + _ currentDirectoryURL: URL?, + environment: [String: String] = [:], + timeoutSeconds: Int + ) async throws -> ShellOutput { + try await runWithTimeoutEnvironmentImpl( + executableURL, + arguments, + currentDirectoryURL, + environment, + timeoutSeconds + ) + } + func runLoginStream( _ executableURL: URL, _ arguments: [String], @@ -94,6 +132,24 @@ extension ShellClient: DependencyKey { ) return result }, + runWithTimeoutImpl: { executableURL, arguments, currentDirectoryURL, timeoutSeconds in + try await runProcessWithTimeout( + executableURL: executableURL, + arguments: arguments, + currentDirectoryURL: currentDirectoryURL, + environment: [:], + timeoutSeconds: timeoutSeconds + ) + }, + runWithTimeoutEnvironmentImpl: { executableURL, arguments, currentDirectoryURL, environment, timeoutSeconds in + try await runProcessWithTimeout( + executableURL: executableURL, + arguments: arguments, + currentDirectoryURL: currentDirectoryURL, + environment: environment, + timeoutSeconds: timeoutSeconds + ) + }, runStream: { executableURL, arguments, currentDirectoryURL in runProcessStream( executableURL: executableURL, @@ -124,6 +180,8 @@ extension ShellClient: DependencyKey { static let testValue = ShellClient( run: { _, _, _ in ShellOutput(stdout: "", stderr: "", exitCode: 0) }, runLoginImpl: { _, _, _, _ in ShellOutput(stdout: "", stderr: "", exitCode: 0) }, + runWithTimeoutImpl: { _, _, _, _ in ShellOutput(stdout: "", stderr: "", exitCode: 0) }, + runWithTimeoutEnvironmentImpl: { _, _, _, _, _ in ShellOutput(stdout: "", stderr: "", exitCode: 0) }, runStream: { _, _, _ in AsyncThrowingStream { continuation in continuation.yield(.finished(ShellOutput(stdout: "", stderr: "", exitCode: 0))) @@ -151,21 +209,68 @@ private nonisolated let shellLogger = SupaLogger("Shell") nonisolated private func runProcess( executableURL: URL, arguments: [String], - currentDirectoryURL: URL? + currentDirectoryURL: URL?, + environment: [String: String]? = nil ) async throws -> ShellOutput { let stream = runProcessStream( executableURL: executableURL, arguments: arguments, - currentDirectoryURL: currentDirectoryURL + currentDirectoryURL: currentDirectoryURL, + environment: environment ) let command = ([executableURL.path(percentEncoded: false)] + arguments).joined(separator: " ") return try await collectOutput(from: stream, command: command) } +nonisolated private func runProcessWithTimeout( + executableURL: URL, + arguments: [String], + currentDirectoryURL: URL?, + environment: [String: String]? = nil, + timeoutSeconds: Int +) async throws -> ShellOutput { + guard timeoutSeconds > 0 else { + return try await runProcess( + executableURL: executableURL, + arguments: arguments, + currentDirectoryURL: currentDirectoryURL, + environment: environment + ) + } + + let command = ([executableURL.path(percentEncoded: false)] + arguments).joined(separator: " ") + return try await withThrowingTaskGroup(of: ShellOutput.self) { group in + group.addTask { + try await runProcess( + executableURL: executableURL, + arguments: arguments, + currentDirectoryURL: currentDirectoryURL, + environment: environment + ) + } + group.addTask { + try await Task.sleep(nanoseconds: UInt64(timeoutSeconds) * 1_000_000_000) + throw ShellClientError( + command: command, + stdout: "", + stderr: "Command timed out after \(timeoutSeconds) seconds", + exitCode: -1 + ) + } + + guard let firstResult = try await group.next() else { + throw ShellClientError(command: command, stdout: "", stderr: "", exitCode: -1) + } + group.cancelAll() + return firstResult + } +} + nonisolated private func runProcessStream( executableURL: URL, arguments: [String], - currentDirectoryURL: URL? + currentDirectoryURL: URL?, + environment: [String: String]? = nil ) -> AsyncThrowingStream { AsyncThrowingStream { continuation in Task.detached { @@ -174,6 +279,11 @@ nonisolated private func runProcessStream( process.executableURL = executableURL process.arguments = arguments process.currentDirectoryURL = currentDirectoryURL + if let environment { + var merged = ProcessInfo.processInfo.environment + merged.merge(environment, uniquingKeysWith: { _, new in new }) + process.environment = merged + } let outputPipe = Pipe() let errorPipe = Pipe() process.standardInput = FileHandle.nullDevice diff --git a/supacode/Domain/PersistedRepositoryEntry.swift b/supacode/Domain/PersistedRepositoryEntry.swift index 3e83b0b32..ebfe91de9 100644 --- a/supacode/Domain/PersistedRepositoryEntry.swift +++ b/supacode/Domain/PersistedRepositoryEntry.swift @@ -3,4 +3,28 @@ import Foundation nonisolated struct PersistedRepositoryEntry: Codable, Equatable, Sendable { let path: String let kind: Repository.Kind + let endpoint: RepositoryEndpoint + + init( + path: String, + kind: Repository.Kind, + endpoint: RepositoryEndpoint = .local + ) { + self.path = path + self.kind = kind + self.endpoint = endpoint + } + + enum CodingKeys: String, CodingKey { + case path + case kind + case endpoint + } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + path = try container.decode(String.self, forKey: .path) + kind = try container.decode(Repository.Kind.self, forKey: .kind) + endpoint = try container.decodeIfPresent(RepositoryEndpoint.self, forKey: .endpoint) ?? .local + } } diff --git a/supacode/Domain/Repository.swift b/supacode/Domain/Repository.swift index 0df9e26b7..5c518ddb5 100644 --- a/supacode/Domain/Repository.swift +++ b/supacode/Domain/Repository.swift @@ -41,6 +41,7 @@ nonisolated struct Repository: Identifiable, Hashable, Sendable { let rootURL: URL let name: String let kind: Kind + let endpoint: RepositoryEndpoint let worktrees: IdentifiedArrayOf init( @@ -48,12 +49,14 @@ nonisolated struct Repository: Identifiable, Hashable, Sendable { rootURL: URL, name: String, kind: Kind = .git, + endpoint: RepositoryEndpoint = .local, worktrees: IdentifiedArrayOf ) { self.id = id self.rootURL = rootURL self.name = name self.kind = kind + self.endpoint = endpoint self.worktrees = worktrees } diff --git a/supacode/Domain/RepositoryEndpoint.swift b/supacode/Domain/RepositoryEndpoint.swift new file mode 100644 index 000000000..2753905e5 --- /dev/null +++ b/supacode/Domain/RepositoryEndpoint.swift @@ -0,0 +1,51 @@ +import Foundation + +nonisolated enum RepositoryEndpoint: Equatable, Hashable, Sendable { + case local + case remote(hostProfileID: String, remotePath: String) + + var isRemote: Bool { + if case .remote = self { + return true + } + return false + } +} + +extension RepositoryEndpoint: Codable { + private enum CodingKeys: String, CodingKey { + case kind + case hostProfileID + case remotePath + } + + private enum Kind: String, Codable { + case local + case remote + } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let kind = try container.decode(Kind.self, forKey: .kind) + switch kind { + case .local: + self = .local + case .remote: + let hostProfileID = try container.decode(String.self, forKey: .hostProfileID) + let remotePath = try container.decode(String.self, forKey: .remotePath) + self = .remote(hostProfileID: hostProfileID, remotePath: remotePath) + } + } + + func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case .local: + try container.encode(Kind.local, forKey: .kind) + case .remote(let hostProfileID, let remotePath): + try container.encode(Kind.remote, forKey: .kind) + try container.encode(hostProfileID, forKey: .hostProfileID) + try container.encode(remotePath, forKey: .remotePath) + } + } +} diff --git a/supacode/Domain/SSHHostProfile.swift b/supacode/Domain/SSHHostProfile.swift new file mode 100644 index 000000000..b3e5a35a3 --- /dev/null +++ b/supacode/Domain/SSHHostProfile.swift @@ -0,0 +1,37 @@ +import Foundation + +nonisolated struct SSHHostProfile: Codable, Equatable, Sendable, Identifiable { + enum AuthMethod: String, Codable, CaseIterable, Sendable { + case publicKey + case password + } + + var id: String + var displayName: String + var host: String + var user: String + var port: Int? + var authMethod: AuthMethod + var createdAt: Date + var updatedAt: Date + + init( + id: String = UUID().uuidString, + displayName: String, + host: String, + user: String = "", + port: Int? = nil, + authMethod: AuthMethod, + createdAt: Date = .now, + updatedAt: Date = .now + ) { + self.id = id + self.displayName = displayName + self.host = host + self.user = user + self.port = port + self.authMethod = authMethod + self.createdAt = createdAt + self.updatedAt = updatedAt + } +} diff --git a/supacode/Domain/Worktree.swift b/supacode/Domain/Worktree.swift index fefa33efd..4d6b2f11c 100644 --- a/supacode/Domain/Worktree.swift +++ b/supacode/Domain/Worktree.swift @@ -6,6 +6,7 @@ nonisolated struct Worktree: Identifiable, Hashable, Sendable { let detail: String let workingDirectory: URL let repositoryRootURL: URL + let endpoint: RepositoryEndpoint let createdAt: Date? nonisolated init( @@ -14,6 +15,7 @@ nonisolated struct Worktree: Identifiable, Hashable, Sendable { detail: String, workingDirectory: URL, repositoryRootURL: URL, + endpoint: RepositoryEndpoint = .local, createdAt: Date? = nil ) { self.id = id @@ -21,6 +23,7 @@ nonisolated struct Worktree: Identifiable, Hashable, Sendable { self.detail = detail self.workingDirectory = workingDirectory self.repositoryRootURL = repositoryRootURL + self.endpoint = endpoint self.createdAt = createdAt } } diff --git a/supacode/Features/App/Reducer/AppFeature.swift b/supacode/Features/App/Reducer/AppFeature.swift index f6297d350..b7849cdb0 100644 --- a/supacode/Features/App/Reducer/AppFeature.swift +++ b/supacode/Features/App/Reducer/AppFeature.swift @@ -272,10 +272,11 @@ struct AppFeature { state.settings.repositorySettings = RepositorySettingsFeature.State( rootURL: repository.rootURL, repositoryKind: repository.kind, + endpoint: repository.endpoint, settings: repositorySettings, userSettings: userRepositorySettings ) - case .general, .notifications, .worktree, .updates, .advanced, .github: + case .general, .notifications, .worktree, .sshHosts, .updates, .advanced, .github: state.settings.repositorySettings = nil } return .none diff --git a/supacode/Features/Canvas/Models/CanvasInitialFocusResolver.swift b/supacode/Features/Canvas/Models/CanvasInitialFocusResolver.swift new file mode 100644 index 000000000..c7ed21c91 --- /dev/null +++ b/supacode/Features/Canvas/Models/CanvasInitialFocusResolver.swift @@ -0,0 +1,35 @@ +import Foundation + +enum CanvasInitialFocusResolver { + struct Candidate: Equatable, Sendable { + let worktreeID: Worktree.ID + let tabID: TerminalTabID + let focusedSurfaceID: UUID + let isSelectedTab: Bool + } + + struct Focus: Equatable, Sendable { + let tabID: TerminalTabID + let surfaceID: UUID + } + + static func initialFocus( + preferredSurfaceID: UUID?, + preferredWorktreeID: Worktree.ID?, + candidates: [Candidate] + ) -> Focus? { + if let preferredSurfaceID, + let candidate = candidates.first(where: { $0.focusedSurfaceID == preferredSurfaceID }) + { + return Focus(tabID: candidate.tabID, surfaceID: candidate.focusedSurfaceID) + } + + if let preferredWorktreeID, + let candidate = candidates.first(where: { $0.worktreeID == preferredWorktreeID && $0.isSelectedTab }) + { + return Focus(tabID: candidate.tabID, surfaceID: candidate.focusedSurfaceID) + } + + return candidates.first.map { Focus(tabID: $0.tabID, surfaceID: $0.focusedSurfaceID) } + } +} diff --git a/supacode/Features/Canvas/Views/CanvasView.swift b/supacode/Features/Canvas/Views/CanvasView.swift index d1a0c40c5..11d8404e7 100644 --- a/supacode/Features/Canvas/Views/CanvasView.swift +++ b/supacode/Features/Canvas/Views/CanvasView.swift @@ -5,6 +5,7 @@ struct CanvasView: View { @Environment(CommandKeyObserver.self) private var commandKeyObserver let terminalManager: WorktreeTerminalManager + let preferredFocusedWorktreeID: Worktree.ID? var onExitToTab: () -> Void = {} @State private var layoutStore = CanvasLayoutStore() @@ -556,21 +557,29 @@ struct CanvasView: View { private func syncPrimaryFocus( from previousTabID: TerminalTabID?, to newTabID: TerminalTabID?, - states: [WorktreeTerminalState] + states: [WorktreeTerminalState], + preferredSurfaceID: UUID? = nil ) { if let previousTabID, previousTabID != newTabID { unfocusTab(previousTabID, states: states) } guard let newTabID, - let ownerState = states.first(where: { $0.surfaceView(for: newTabID) != nil }), - let surfaceView = ownerState.surfaceView(for: newTabID) + let ownerState = states.first(where: { $0.surfaceView(for: newTabID) != nil }) else { terminalManager.canvasFocusedWorktreeID = nil return } - ownerState.tabManager.selectTab(newTabID) + if let preferredSurfaceID { + _ = ownerState.focusSurface(id: preferredSurfaceID) + } else { + ownerState.tabManager.selectTab(newTabID) + } + guard let surfaceView = ownerState.surfaceView(for: newTabID) else { + terminalManager.canvasFocusedWorktreeID = nil + return + } terminalManager.canvasFocusedWorktreeID = ownerState.worktreeID surfaceView.focusDidChange(true) surfaceView.requestFocus() @@ -630,18 +639,6 @@ struct CanvasView: View { let activeStates = terminalManager.activeWorktreeStates - // Auto-focus the card that was active before entering canvas. - if let selectedID = terminalManager.selectedWorktreeID, - let state = activeStates.first(where: { $0.worktreeID == selectedID }), - let tabID = state.tabManager.selectedTabId - { - selectionState.focusSingle(tabID) - syncPrimaryFocus(from: nil, to: tabID, states: activeStates) - } else { - selectionState.clear() - syncBroadcastCallbacks(states: activeStates) - } - for state in activeStates { state.setAllSurfacesOccluded() } @@ -653,6 +650,45 @@ struct CanvasView: View { } } } + + // Auto-focus the card that was active before entering canvas after + // the canvas surfaces have been made visible again. + let preferredSurfaceID = + activeStates + .first(where: { $0.worktreeID == preferredFocusedWorktreeID }) + .flatMap { state in + state.tabManager.selectedTabId.flatMap { selectedTabID in + state.splitTree(for: selectedTabID).leaves().first(where: { $0.bridge.state.searchNeedle != nil })?.id + } + } + let initialFocus = CanvasInitialFocusResolver.initialFocus( + preferredSurfaceID: preferredSurfaceID, + preferredWorktreeID: preferredFocusedWorktreeID, + candidates: activeStates.flatMap { state in + state.tabManager.tabs.compactMap { tab in + guard let surface = state.surfaceView(for: tab.id) else { return nil } + return CanvasInitialFocusResolver.Candidate( + worktreeID: state.worktreeID, + tabID: tab.id, + focusedSurfaceID: surface.id, + isSelectedTab: tab.id == state.tabManager.selectedTabId + ) + } + } + ) + if let initialFocus + { + selectionState.focusSingle(initialFocus.tabID) + syncPrimaryFocus( + from: nil, + to: initialFocus.tabID, + states: activeStates, + preferredSurfaceID: initialFocus.surfaceID + ) + } else { + selectionState.clear() + syncBroadcastCallbacks(states: activeStates) + } } private func deactivateCanvas() { diff --git a/supacode/Features/Repositories/BusinessLogic/WorktreeInfoWatcherManager.swift b/supacode/Features/Repositories/BusinessLogic/WorktreeInfoWatcherManager.swift index 2613c2506..51c668c3e 100644 --- a/supacode/Features/Repositories/BusinessLogic/WorktreeInfoWatcherManager.swift +++ b/supacode/Features/Repositories/BusinessLogic/WorktreeInfoWatcherManager.swift @@ -87,7 +87,10 @@ final class WorktreeInfoWatcherManager { private func setWorktrees(_ worktrees: [Worktree]) { let isInitialWorktreeLoad = !hasCompletedInitialWorktreeLoad && self.worktrees.isEmpty && !worktrees.isEmpty - let worktreesByID = Dictionary(uniqueKeysWithValues: worktrees.map { ($0.id, $0) }) + var worktreesByID: [Worktree.ID: Worktree] = [:] + for worktree in worktrees where worktreesByID[worktree.id] == nil { + worktreesByID[worktree.id] = worktree + } let desiredIDs = Set(worktreesByID.keys) let currentIDs = Set(self.worktrees.keys) let removedIDs = currentIDs.subtracting(desiredIDs) diff --git a/supacode/Features/Repositories/Reducer/RemoteConnectFeature.swift b/supacode/Features/Repositories/Reducer/RemoteConnectFeature.swift new file mode 100644 index 000000000..56614244c --- /dev/null +++ b/supacode/Features/Repositories/Reducer/RemoteConnectFeature.swift @@ -0,0 +1,737 @@ +import ComposableArchitecture +import Foundation + +private enum CancelID { + static let browseDirectoryListing = "remote-connect.browse-directory-listing" +} + +@Reducer +struct RemoteConnectFeature { + enum Step: Equatable { + case host + case repository + } + + struct DirectoryListing: Equatable, Sendable { + let currentPath: String + let childDirectories: [String] + } + + struct DirectoryBrowserState: Equatable { + var currentPath: String + var childDirectories: [String] + var isLoading: Bool + var errorMessage: String? + } + + struct Submission: Equatable, Sendable { + let hostProfile: SSHHostProfile + let remotePath: String + } + + @ObservableState + struct State: Equatable { + var savedHostProfiles: [SSHHostProfile] + var selectedHostProfileID: SSHHostProfile.ID? + var connectionHostProfileID: SSHHostProfile.ID? + var connectionHostProfileEndpointKey: String? + var step: Step = .host + var displayName = "" + var host = "" + var user = "" + var port = "" + var authMethod: SSHHostProfile.AuthMethod = .publicKey + var password = "" + var remotePath = "" + var validationMessage: String? + var isSubmitting = false + var directoryBrowser: DirectoryBrowserState? + var activeBrowseRequestID: UUID? + + init(savedHostProfiles: [SSHHostProfile]) { + self.savedHostProfiles = savedHostProfiles.sorted { + $0.displayName.localizedStandardCompare($1.displayName) == .orderedAscending + } + } + + var selectedHostProfile: SSHHostProfile? { + guard let selectedHostProfileID else { + return nil + } + return savedHostProfiles.first { $0.id == selectedHostProfileID } + } + + var resolvedDisplayName: String { + let trimmed = displayName.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { + return trimmed + } + return host.trimmingCharacters(in: .whitespacesAndNewlines) + } + } + + enum Action: BindableAction, Equatable { + case binding(BindingAction) + case savedHostProfileSelected(SSHHostProfile.ID?) + case continueButtonTapped + case backButtonTapped + case cancelButtonTapped + case browseRemoteFoldersButtonTapped + case remoteDirectoryListingLoaded(UUID, DirectoryListing) + case remoteDirectoryListingFailed(UUID, String) + case directoryBrowserEntryTapped(String) + case directoryBrowserUpButtonTapped + case directoryBrowserChooseCurrentFolderButtonTapped + case directoryBrowserDismissed + case connectButtonTapped + case hostValidationSucceeded + case hostValidationFailed(String) + case remoteRepositoryValidated(Submission) + case remoteRepositoryValidationFailed(String) + case delegate(Delegate) + } + + @CasePathable + enum Delegate: Equatable { + case cancel + case completed(Submission) + } + + @Dependency(\.date.now) private var now + @Dependency(KeychainClient.self) private var keychainClient + @Dependency(RemoteExecutionClient.self) private var remoteExecutionClient + @Dependency(\.uuid) private var uuid + + var body: some Reducer { + BindingReducer() + Reduce { state, action in + switch action { + case .binding: + state.validationMessage = nil + return .none + + case .savedHostProfileSelected(let profileID): + state.selectedHostProfileID = profileID + state.connectionHostProfileID = nil + state.connectionHostProfileEndpointKey = nil + state.password = "" + state.validationMessage = nil + guard let profile = state.savedHostProfiles.first(where: { $0.id == profileID }) else { + state.displayName = "" + state.host = "" + state.user = "" + state.port = "" + state.authMethod = .publicKey + return .none + } + state.displayName = profile.displayName + state.host = profile.host + state.user = profile.user + state.port = profile.port.map(String.init) ?? "" + state.authMethod = profile.authMethod + return .none + + case .continueButtonTapped: + guard let hostFields = validateHostFields(in: &state) else { + return .none + } + let profileID = resolvedHostProfileID(in: &state, hostFields: hostFields) + let existingProfile = matchingSavedHostProfile(in: state, hostFields: hostFields) + let keychain = keychainClient + if state.authMethod == .password { + let hostProfile = makeConnectionProfile( + from: hostFields, + existingProfile: existingProfile, + profileID: profileID, + now: now + ) + let password = state.password + return .run { send in + do { + if !password.isEmpty { + try await keychain.savePassword(password, hostProfile.id) + } else if try await keychain.loadPassword(hostProfile.id) == nil { + await send(.hostValidationFailed("Password required.")) + return + } + await send(.hostValidationSucceeded) + } catch { + await send( + .hostValidationFailed( + genericFailureMessage( + prefix: "Couldn't validate the SSH host.", + detail: error.localizedDescription + ) + ) + ) + } + } + } + state.step = .repository + return .none + + case .backButtonTapped: + state.step = .host + clearBrowseState(in: &state) + state.validationMessage = nil + return .cancel(id: CancelID.browseDirectoryListing) + + case .cancelButtonTapped: + return .send(.delegate(.cancel)) + + case .browseRemoteFoldersButtonTapped: + guard let hostFields = validateHostFields(in: &state) else { + return .none + } + let requestedPathResult = requestedPath( + from: state.remotePath, + allowEmptyAsHome: true + ) + guard case .success(let requestedPath) = requestedPathResult else { + if case .failure(let message) = requestedPathResult { + state.validationMessage = message + } + return .none + } + + let profileID = resolvedHostProfileID(in: &state, hostFields: hostFields) + let existingProfile = matchingSavedHostProfile(in: state, hostFields: hostFields) + let profile = makeConnectionProfile( + from: hostFields, + existingProfile: existingProfile, + profileID: profileID, + now: now + ) + let keychain = keychainClient + let requestID = uuid() + state.validationMessage = nil + state.activeBrowseRequestID = requestID + state.directoryBrowser = DirectoryBrowserState( + currentPath: state.remotePath.trimmingCharacters(in: .whitespacesAndNewlines), + childDirectories: [], + isLoading: true, + errorMessage: nil + ) + return browseDirectoryEffect( + requestID: requestID, + profile: profile, + requestedPath: requestedPath, + password: state.password, + keychainClient: keychain + ) + + case .remoteDirectoryListingLoaded(let requestID, let listing): + guard state.activeBrowseRequestID == requestID, state.directoryBrowser != nil else { + return .none + } + state.activeBrowseRequestID = nil + state.directoryBrowser = DirectoryBrowserState( + currentPath: listing.currentPath, + childDirectories: listing.childDirectories, + isLoading: false, + errorMessage: nil + ) + return .none + + case .remoteDirectoryListingFailed(let requestID, let message): + guard state.activeBrowseRequestID == requestID else { + return .none + } + state.activeBrowseRequestID = nil + if state.directoryBrowser != nil { + state.directoryBrowser?.isLoading = false + state.directoryBrowser?.errorMessage = message + } else { + state.validationMessage = message + } + return .none + + case .directoryBrowserEntryTapped(let path): + guard let directoryBrowser = state.directoryBrowser else { + return .none + } + state.directoryBrowser?.isLoading = true + state.directoryBrowser?.errorMessage = nil + guard let hostFields = validateHostFields(in: &state) else { + state.directoryBrowser = directoryBrowser + return .none + } + let profileID = resolvedHostProfileID(in: &state, hostFields: hostFields) + let existingProfile = matchingSavedHostProfile(in: state, hostFields: hostFields) + let profile = makeConnectionProfile( + from: hostFields, + existingProfile: existingProfile, + profileID: profileID, + now: now + ) + let keychain = keychainClient + let requestID = uuid() + state.activeBrowseRequestID = requestID + return browseDirectoryEffect( + requestID: requestID, + profile: profile, + requestedPath: .absolute(path), + password: state.password, + keychainClient: keychain + ) + + case .directoryBrowserUpButtonTapped: + guard let directoryBrowser = state.directoryBrowser else { + return .none + } + let parentPath = Self.parentDirectory(of: directoryBrowser.currentPath) + guard parentPath != directoryBrowser.currentPath else { + return .none + } + return .send(.directoryBrowserEntryTapped(parentPath)) + + case .directoryBrowserChooseCurrentFolderButtonTapped: + guard let currentPath = state.directoryBrowser?.currentPath, !currentPath.isEmpty else { + return .none + } + state.remotePath = currentPath + clearBrowseState(in: &state) + return .cancel(id: CancelID.browseDirectoryListing) + + case .directoryBrowserDismissed: + clearBrowseState(in: &state) + return .cancel(id: CancelID.browseDirectoryListing) + + case .connectButtonTapped: + guard !state.isSubmitting else { + return .none + } + guard let hostFields = validateHostFields(in: &state) else { + return .none + } + let requestedPathResult = requestedPath( + from: state.remotePath, + allowEmptyAsHome: false + ) + guard case .success(let requestedPath) = requestedPathResult else { + if case .failure(let message) = requestedPathResult { + state.validationMessage = message + } + return .none + } + + let profileID = resolvedHostProfileID(in: &state, hostFields: hostFields) + let existingProfile = matchingSavedHostProfile(in: state, hostFields: hostFields) + let submissionProfile = makeSubmissionProfile( + from: hostFields, + existingProfile: existingProfile, + profileID: profileID, + now: now + ) + let keychain = keychainClient + state.validationMessage = nil + state.isSubmitting = true + let password = state.password + return .run { send in + do { + if submissionProfile.authMethod == .password { + if !password.isEmpty { + try await keychain.savePassword(password, submissionProfile.id) + } else if try await keychain.loadPassword(submissionProfile.id) == nil { + await send(.hostValidationFailed("Password required.")) + return + } + } + let output = try await remoteExecutionClient.run( + submissionProfile, + validateRepositoryCommand(for: requestedPath), + remoteCommandTimeoutSeconds + ) + guard output.exitCode == 0 else { + await send( + .remoteRepositoryValidationFailed( + repositoryValidationFailureMessage(for: output) + ) + ) + return + } + let normalizedPath = output.stdout.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalizedPath.isEmpty else { + await send(.remoteRepositoryValidationFailed("Couldn't validate the remote repository.")) + return + } + await send( + .remoteRepositoryValidated( + Submission( + hostProfile: submissionProfile, + remotePath: normalizedPath + ) + ) + ) + } catch { + await send( + .remoteRepositoryValidationFailed( + genericFailureMessage( + prefix: "Couldn't validate the remote repository.", + detail: error.localizedDescription + ) + ) + ) + } + } + + case .hostValidationSucceeded: + state.validationMessage = nil + state.step = .repository + return .none + + case .hostValidationFailed(let message): + state.isSubmitting = false + state.validationMessage = message + if state.directoryBrowser != nil { + state.activeBrowseRequestID = nil + state.directoryBrowser?.isLoading = false + state.directoryBrowser?.errorMessage = message + } + return .none + + case .remoteRepositoryValidated(let submission): + state.isSubmitting = false + state.remotePath = submission.remotePath + return .send(.delegate(.completed(submission))) + + case .remoteRepositoryValidationFailed(let message): + state.isSubmitting = false + state.validationMessage = message + return .none + + case .delegate: + return .none + } + } + } + + private func browseDirectoryEffect( + requestID: UUID, + profile: SSHHostProfile, + requestedPath: RemotePathRequest, + password: String, + keychainClient: KeychainClient + ) -> Effect { + .run { send in + do { + if profile.authMethod == .password { + if !password.isEmpty { + try await keychainClient.savePassword(password, profile.id) + } else if try await keychainClient.loadPassword(profile.id) == nil { + await send(.hostValidationFailed("Password required.")) + return + } + } + let output = try await remoteExecutionClient.run( + profile, + listDirectoriesCommand(for: requestedPath), + remoteCommandTimeoutSeconds + ) + guard output.exitCode == 0 else { + await send( + .remoteDirectoryListingFailed( + requestID, + directoryListingFailureMessage(for: output) + ) + ) + return + } + let lines = output.stdout + .split(whereSeparator: \.isNewline) + .map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + guard let currentPath = lines.first else { + await send(.remoteDirectoryListingFailed(requestID, "Couldn't load remote folders.")) + return + } + let childDirectories = Array(lines.dropFirst()) + await send( + .remoteDirectoryListingLoaded( + requestID, + DirectoryListing( + currentPath: currentPath, + childDirectories: childDirectories + ) + ) + ) + } catch { + guard !(error is CancellationError) else { + return + } + await send( + .remoteDirectoryListingFailed( + requestID, + genericFailureMessage( + prefix: "Couldn't browse remote folders.", + detail: error.localizedDescription + ) + ) + ) + } + } + .cancellable(id: CancelID.browseDirectoryListing, cancelInFlight: true) + } + + private func clearBrowseState(in state: inout State) { + state.directoryBrowser = nil + state.activeBrowseRequestID = nil + } + + private func validateHostFields(in state: inout State) -> HostFields? { + let host = state.host.trimmingCharacters(in: .whitespacesAndNewlines) + guard !host.isEmpty else { + state.validationMessage = "Host required." + return nil + } + + let user = state.user.trimmingCharacters(in: .whitespacesAndNewlines) + let displayName = state.displayName.trimmingCharacters(in: .whitespacesAndNewlines) + let portValue = state.port.trimmingCharacters(in: .whitespacesAndNewlines) + let port: Int? + if portValue.isEmpty { + port = nil + } else if let parsed = Int(portValue), (1 ... 65535).contains(parsed) { + port = parsed + } else { + state.validationMessage = "Enter a valid SSH port." + return nil + } + + return HostFields( + displayName: displayName.isEmpty ? host : displayName, + host: host, + user: user, + port: port, + authMethod: state.authMethod + ) + } + + private func resolvedHostProfileID( + in state: inout State, + hostFields: HostFields + ) -> SSHHostProfile.ID { + if let selectedHostProfile = matchingSavedHostProfile(in: state, hostFields: hostFields) { + state.connectionHostProfileID = nil + state.connectionHostProfileEndpointKey = nil + return selectedHostProfile.id + } + + let endpointKey = connectionHostProfileEndpointKey(for: hostFields) + if state.connectionHostProfileEndpointKey == endpointKey, + let connectionHostProfileID = state.connectionHostProfileID + { + return connectionHostProfileID + } + + let connectionHostProfileID = uuid().uuidString + state.connectionHostProfileID = connectionHostProfileID + state.connectionHostProfileEndpointKey = endpointKey + return connectionHostProfileID + } + + private func matchingSavedHostProfile( + in state: State, + hostFields: HostFields + ) -> SSHHostProfile? { + guard let selectedHostProfile = state.selectedHostProfile else { + return nil + } + guard selectedHostProfile.host == hostFields.host, + selectedHostProfile.user == hostFields.user, + selectedHostProfile.port == hostFields.port + else { + return nil + } + return selectedHostProfile + } + + private func connectionHostProfileEndpointKey(for hostFields: HostFields) -> String { + [ + hostFields.host, + hostFields.user, + hostFields.port.map(String.init) ?? "", + ] + .joined(separator: "|") + } + + private func makeConnectionProfile( + from hostFields: HostFields, + existingProfile: SSHHostProfile?, + profileID: SSHHostProfile.ID, + now: Date + ) -> SSHHostProfile { + SSHHostProfile( + id: existingProfile?.id ?? profileID, + displayName: hostFields.displayName, + host: hostFields.host, + user: hostFields.user, + port: hostFields.port, + authMethod: hostFields.authMethod, + createdAt: existingProfile?.createdAt ?? now, + updatedAt: now + ) + } + + private func makeSubmissionProfile( + from hostFields: HostFields, + existingProfile: SSHHostProfile?, + profileID: SSHHostProfile.ID, + now: Date + ) -> SSHHostProfile { + makeConnectionProfile( + from: hostFields, + existingProfile: existingProfile, + profileID: profileID, + now: now + ) + } + + private func requestedPath( + from rawValue: String, + allowEmptyAsHome: Bool + ) -> RequestedPathResult { + let trimmed = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + return allowEmptyAsHome ? .success(.home) : .failure("Remote path required.") + } + if trimmed == "~" { + return .success(.home) + } + if trimmed.hasPrefix("~/") { + return .success(.homeRelative(String(trimmed.dropFirst(2)))) + } + if trimmed.hasPrefix("/") { + return .success(.absolute(trimmed)) + } + return .failure("Enter an absolute remote path or browse remote folders.") + } + + private func listDirectoriesCommand(for requestedPath: RemotePathRequest) -> String { + let pathAssignment = pathAssignment(for: requestedPath) + return """ + \(pathAssignment) + if ! cd -- "$remote_base" 2>/dev/null; then + printf '%s\\n' '\(errorMarker)missing-directory' >&2 + exit 20 + fi + current=$(pwd -P) + printf '%s\\n' "$current" + find "$current" -mindepth 1 -maxdepth 1 -type d -print 2>/dev/null | LC_ALL=C sort + """ + } + + private func validateRepositoryCommand(for requestedPath: RemotePathRequest) -> String { + let pathAssignment = pathAssignment(for: requestedPath) + return """ + \(pathAssignment) + if ! cd -- "$remote_base" 2>/dev/null; then + printf '%s\\n' '\(errorMarker)missing-directory' >&2 + exit 20 + fi + repo_root=$(git -C "$PWD" rev-parse --show-toplevel 2>/dev/null) + if [ -z "$repo_root" ]; then + printf '%s\\n' '\(errorMarker)not-git' >&2 + exit 21 + fi + printf '%s\\n' "$repo_root" + """ + } + + private func pathAssignment(for requestedPath: RemotePathRequest) -> String { + switch requestedPath { + case .home: + "remote_base=\"$HOME\"" + case .absolute(let path): + "remote_base=\(SSHCommandSupport.shellEscape(path))" + case .homeRelative(let relativePath): + if relativePath.isEmpty { + "remote_base=\"$HOME\"" + } else { + "remote_base=\"$HOME\"/\(SSHCommandSupport.shellEscape(relativePath))" + } + } + } + + private func directoryListingFailureMessage( + for output: RemoteExecutionClient.Output + ) -> String { + if output.stderr.contains("\(errorMarker)missing-directory") { + return "The remote folder couldn't be opened." + } + return genericFailureMessage( + prefix: "Couldn't browse remote folders.", + detail: bestAvailableErrorDetail(from: output) + ) + } + + private func repositoryValidationFailureMessage( + for output: RemoteExecutionClient.Output + ) -> String { + if output.stderr.contains("\(errorMarker)missing-directory") { + return "The remote folder doesn't exist." + } + if output.stderr.contains("\(errorMarker)not-git") { + return "The selected folder is not a Git repository." + } + return genericFailureMessage( + prefix: "Couldn't validate the remote repository.", + detail: bestAvailableErrorDetail(from: output) + ) + } + + private func genericFailureMessage(prefix: String, detail: String?) -> String { + guard let detail, !detail.isEmpty else { + return prefix + } + return "\(prefix)\n\(detail)" + } + + private func bestAvailableErrorDetail( + from output: RemoteExecutionClient.Output + ) -> String? { + let candidates = [output.stderr, output.stdout] + for candidate in candidates { + let lines = candidate + .split(whereSeparator: \.isNewline) + .map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty && !$0.contains(errorMarker) } + if let line = lines.first { + return line + } + } + return nil + } + + private static func parentDirectory(of path: String) -> String { + guard path != "/" else { + return "/" + } + let standardized = URL(fileURLWithPath: path, isDirectory: true).standardizedFileURL + let parent = standardized.deletingLastPathComponent() + let resolvedPath = parent.path(percentEncoded: false) + return resolvedPath.isEmpty ? "/" : resolvedPath + } +} + +private struct HostFields { + let displayName: String + let host: String + let user: String + let port: Int? + let authMethod: SSHHostProfile.AuthMethod +} + +private enum RemotePathRequest { + case home + case absolute(String) + case homeRelative(String) +} + +private enum RequestedPathResult { + case success(RemotePathRequest) + case failure(String) +} + +private let remoteCommandTimeoutSeconds = 15 +private let errorMarker = "__PROWL_REMOTE_CONNECT__:" diff --git a/supacode/Features/Repositories/Reducer/RemoteSessionPickerFeature.swift b/supacode/Features/Repositories/Reducer/RemoteSessionPickerFeature.swift new file mode 100644 index 000000000..cf2d0f3b6 --- /dev/null +++ b/supacode/Features/Repositories/Reducer/RemoteSessionPickerFeature.swift @@ -0,0 +1,109 @@ +import ComposableArchitecture +import Foundation + +@Reducer +struct RemoteSessionPickerFeature { + @ObservableState + struct State: Equatable { + let worktreeID: Worktree.ID + let repositoryRootURL: URL + let remotePath: String + var sessions: [String] + var selectedSessionName: String + var managedSessionName: String + + init( + worktreeID: Worktree.ID, + repositoryRootURL: URL, + remotePath: String, + sessions: [String], + preferredSessionName: String?, + suggestedManagedSessionName: String? + ) { + self.worktreeID = worktreeID + self.repositoryRootURL = repositoryRootURL + self.remotePath = remotePath + self.sessions = sessions + let trimmedPreferred = preferredSessionName?.trimmingCharacters(in: .whitespacesAndNewlines) + let resolvedSelectedSessionName: String + if let trimmedPreferred, sessions.contains(trimmedPreferred) { + resolvedSelectedSessionName = trimmedPreferred + } else { + resolvedSelectedSessionName = sessions.first ?? "" + } + selectedSessionName = resolvedSelectedSessionName + let trimmedSuggested = suggestedManagedSessionName?.trimmingCharacters(in: .whitespacesAndNewlines) + if let trimmedSuggested, !trimmedSuggested.isEmpty { + managedSessionName = trimmedSuggested + } else { + managedSessionName = Self.defaultManagedSessionName(for: remotePath) + } + } + + var canAttachSelectedSession: Bool { + !selectedSessionName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + var canCreateManagedSession: Bool { + !managedSessionName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + private static func defaultManagedSessionName(for remotePath: String) -> String { + let trimmedPath = remotePath.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedPath.isEmpty else { + return "prowl" + } + let leaf = URL(fileURLWithPath: trimmedPath, isDirectory: true).lastPathComponent + let trimmedLeaf = leaf.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedLeaf.isEmpty else { + return "prowl" + } + return trimmedLeaf.replacing(" ", with: "-") + } + } + + enum Action: BindableAction, Equatable { + case binding(BindingAction) + case attachTapped + case createAndAttachTapped + case cancelTapped + case delegate(Delegate) + } + + @CasePathable + enum Delegate: Equatable { + case cancel + case attachExisting(String) + case createAndAttach(String) + } + + var body: some Reducer { + BindingReducer() + Reduce { state, action in + switch action { + case .binding: + return .none + + case .attachTapped: + let sessionName = state.selectedSessionName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !sessionName.isEmpty else { + return .none + } + return .send(.delegate(.attachExisting(sessionName))) + + case .createAndAttachTapped: + let sessionName = state.managedSessionName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !sessionName.isEmpty else { + return .none + } + return .send(.delegate(.createAndAttach(sessionName))) + + case .cancelTapped: + return .send(.delegate(.cancel)) + + case .delegate: + return .none + } + } + } +} diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift index 554ffd8d9..a358fa194 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift @@ -12,6 +12,7 @@ private enum CancelID { static let githubIntegrationRecovery = "repositories.githubIntegrationRecovery" static let worktreePromptLoad = "repositories.worktreePromptLoad" static let worktreePromptValidation = "repositories.worktreePromptValidation" + static let remoteTmuxSessionsLoad = "repositories.remoteTmuxSessionsLoad" static func archiveScript(_ worktreeID: Worktree.ID) -> String { "repositories.archiveScript.\(worktreeID)" } @@ -24,6 +25,7 @@ private nonisolated let githubIntegrationRecoveryInterval: Duration = .seconds(1 private nonisolated let worktreeCreationProgressLineLimit = 200 private nonisolated let worktreeCreationProgressUpdateStride = 20 private nonisolated let archiveScriptProgressLineLimit = 200 +private nonisolated let remoteTmuxSessionsTimeoutSeconds = 8 nonisolated struct WorktreeCreationProgressUpdateThrottle { private let stride: Int @@ -94,8 +96,11 @@ struct RepositoriesFeature { var inFlightPullRequestRefreshRepositoryIDs: Set = [] var queuedPullRequestRefreshByRepositoryID: [Repository.ID: PendingPullRequestRefresh] = [:] var sidebarSelectedWorktreeIDs: Set = [] + var attachedRemoteTmuxSessionByWorktreeID: [Worktree.ID: String] = [:] @Shared(.appStorage("sidebarCollapsedRepositoryIDs")) var collapsedRepositoryIDs: [Repository.ID] = [] @Presents var worktreeCreationPrompt: WorktreeCreationPromptFeature.State? + @Presents var remoteConnect: RemoteConnectFeature.State? + @Presents var remoteSessionPicker: RemoteSessionPickerFeature.State? @Presents var alert: AlertState? } @@ -126,6 +131,7 @@ struct RepositoriesFeature { case task case repositorySnapshotLoaded([Repository]?) case setOpenPanelPresented(Bool) + case addRemoteRepositoryButtonTapped case loadPersistedRepositories case pinnedWorktreeIDsLoaded([Worktree.ID]) case archivedWorktreeIDsLoaded([Worktree.ID]) @@ -240,6 +246,10 @@ struct RepositoriesFeature { case delayedPullRequestRefresh(Worktree.ID) case openRepositorySettings(Repository.ID) case worktreeCreationPrompt(PresentationAction) + case remoteConnect(PresentationAction) + case remoteSessionsLoaded(worktreeID: Worktree.ID, sessions: [String]) + case remoteSessionsLoadFailed(worktreeID: Worktree.ID, message: String) + case remoteSessionPicker(PresentationAction) case alert(PresentationAction) case delegate(Delegate) } @@ -266,6 +276,11 @@ struct RepositoriesFeature { let didPruneArchivedWorktreeIDs: Bool } + private struct RemoteSessionAttach { + let worktree: Worktree + let command: String + } + enum StatusToast: Equatable { case inProgress(String) case success(String) @@ -304,6 +319,7 @@ struct RepositoriesFeature { @Dependency(GithubCLIClient.self) private var githubCLI @Dependency(GithubIntegrationClient.self) private var githubIntegration @Dependency(RepositoryPersistenceClient.self) private var repositoryPersistence + @Dependency(RemoteTmuxClient.self) private var remoteTmuxClient @Dependency(ShellClient.self) private var shellClient @Dependency(\.uuid) private var uuid @@ -389,6 +405,13 @@ struct RepositoriesFeature { state.isOpenPanelPresented = isPresented return .none + case .addRemoteRepositoryButtonTapped: + @Shared(.settingsFile) var settingsFile + state.remoteConnect = RemoteConnectFeature.State( + savedHostProfiles: settingsFile.sshHostProfiles + ) + return .none + case .loadPersistedRepositories: state.alert = nil state.isRefreshingWorktrees = false @@ -418,7 +441,19 @@ struct RepositoriesFeature { state.isRefreshingWorktrees = false return .none } - return loadRepositories(fallbackRoots: roots, animated: animated) + let fallbackEntries = + if state.repositories.isEmpty { + roots.map { PersistedRepositoryEntry(path: $0.path(percentEncoded: false), kind: .git) } + } else { + state.repositories.map { repository in + PersistedRepositoryEntry( + path: repository.rootURL.path(percentEncoded: false), + kind: repository.kind, + endpoint: repository.endpoint + ) + } + } + return loadRepositories(fallbackEntries: fallbackEntries, animated: animated) case .repositoriesLoaded(let repositories, let failures, let roots, let animated): state.isRefreshingWorktrees = false @@ -435,9 +470,7 @@ struct RepositoriesFeature { ) state.repositoryRoots = roots state.isInitialLoadComplete = true - state.loadFailuresByID = Dictionary( - uniqueKeysWithValues: failures.map { ($0.rootID, $0.message) } - ) + state.loadFailuresByID = mergedLoadFailuresByID(failures) let selectedWorktree = state.worktree(for: state.selectedWorktreeID) let selectionChanged = selectionDidChange( previousSelectionID: previousSelection, @@ -564,9 +597,7 @@ struct RepositoriesFeature { ) state.repositoryRoots = roots state.isInitialLoadComplete = true - state.loadFailuresByID = Dictionary( - uniqueKeysWithValues: failures.map { ($0.rootID, $0.message) } - ) + state.loadFailuresByID = mergedLoadFailuresByID(failures) let openFailureMessages = invalidRoots.map { "\($0) is not a Git repository." } + openFailures if !openFailureMessages.isEmpty { state.alert = messageAlert( @@ -687,7 +718,86 @@ struct RepositoriesFeature { state.pendingTerminalFocusWorktreeIDs.insert(worktreeID) } let selectedWorktree = state.worktree(for: worktreeID) - return .send(.delegate(.selectedWorktreeChanged(selectedWorktree))) + state.remoteSessionPicker = nil + guard let selectedWorktree else { + return .merge( + .send(.delegate(.selectedWorktreeChanged(nil))), + .cancel(id: CancelID.remoteTmuxSessionsLoad) + ) + } + guard case .remote = selectedWorktree.endpoint, + let hostProfile = sshHostProfile(for: selectedWorktree.endpoint) + else { + return .merge( + .send(.delegate(.selectedWorktreeChanged(selectedWorktree))), + .cancel(id: CancelID.remoteTmuxSessionsLoad) + ) + } + let selectedWorktreeID = selectedWorktree.id + return .merge( + .send(.delegate(.selectedWorktreeChanged(selectedWorktree))), + .run { send in + do { + let sessions = try await remoteTmuxClient.listSessions( + hostProfile, + remoteTmuxSessionsTimeoutSeconds + ) + await send( + .remoteSessionsLoaded( + worktreeID: selectedWorktreeID, + sessions: sessions + ) + ) + } catch { + await send( + .remoteSessionsLoadFailed( + worktreeID: selectedWorktreeID, + message: error.localizedDescription + ) + ) + } + } + .cancellable(id: CancelID.remoteTmuxSessionsLoad, cancelInFlight: true) + ) + + case .remoteSessionsLoaded(let worktreeID, let sessions): + guard state.selectedWorktreeID == worktreeID, !sessions.isEmpty, + let worktree = state.worktree(for: worktreeID) + else { + return .none + } + if let attachedSessionName = state.attachedRemoteTmuxSessionByWorktreeID[worktreeID] { + let trimmedAttachedSession = attachedSessionName.trimmingCharacters(in: .whitespacesAndNewlines) + if sessions.contains(trimmedAttachedSession) { + return .none + } + state.attachedRemoteTmuxSessionByWorktreeID.removeValue(forKey: worktreeID) + } + guard case .remote(_, let remotePath) = worktree.endpoint else { + return .none + } + @Shared(.repositorySettings(worktree.repositoryRootURL)) var repositorySettings + let preferredSessionName = + repositorySettings.lastAttachedRemoteTmuxSessionName + ?? repositorySettings.defaultRemoteTmuxSessionName + state.remoteSessionPicker = RemoteSessionPickerFeature.State( + worktreeID: worktreeID, + repositoryRootURL: worktree.repositoryRootURL, + remotePath: remotePath, + sessions: sessions, + preferredSessionName: preferredSessionName, + suggestedManagedSessionName: repositorySettings.defaultRemoteTmuxSessionName + ) + return .none + + case .remoteSessionsLoadFailed(let worktreeID, let message): + guard state.selectedWorktreeID == worktreeID else { + return .none + } + SupaLogger("Repositories").warning( + "Remote tmux session listing failed for \(worktreeID): \(message)" + ) + return .none case .selectNextWorktree: guard let id = state.worktreeID(byOffset: 1) else { return .none } @@ -978,7 +1088,15 @@ struct RepositoriesFeature { setSingleWorktreeSelection(pendingID, state: &state) let existingNames = Set(repository.worktrees.map { $0.name.lowercased() }) let createWorktreeStream = gitClient.createWorktreeStream + let createWorktreeStreamForEndpoint = gitClient.createWorktreeStreamForEndpoint + let endpoint = repository.endpoint + let hostProfile = sshHostProfile(for: endpoint) let isValidBranchName = gitClient.isValidBranchName + let localBranchNames = gitClient.localBranchNames + let isBareRepository = gitClient.isBareRepository + let automaticWorktreeBaseRef = gitClient.automaticWorktreeBaseRef + let ignoredFileCount = gitClient.ignoredFileCount + let untrackedFileCount = gitClient.untrackedFileCount return .run { send in var newWorktreeName: String? var progress = WorktreeCreationProgress(stage: .loadingLocalBranches) @@ -992,7 +1110,13 @@ struct RepositoriesFeature { progress: progress ) ) - let branchNames = try await gitClient.localBranchNames(repository.rootURL) + let branchNames: Set + switch endpoint { + case .local: + branchNames = try await localBranchNames(repository.rootURL) + case .remote: + branchNames = [] + } let existing = existingNames.union(branchNames) let name: String switch nameSource { @@ -1055,19 +1179,21 @@ struct RepositoriesFeature { ) return } - guard await isValidBranchName(trimmed, repository.rootURL) else { - await send( - .createRandomWorktreeFailed( - title: "Branch name invalid", - message: "Enter a valid git branch name and try again.", - pendingID: pendingID, - previousSelection: previousSelection, - repositoryID: repository.id, - name: nil, - baseDirectory: worktreeBaseDirectory + if case .local = endpoint { + guard await isValidBranchName(trimmed, repository.rootURL) else { + await send( + .createRandomWorktreeFailed( + title: "Branch name invalid", + message: "Enter a valid git branch name and try again.", + pendingID: pendingID, + previousSelection: previousSelection, + repositoryID: repository.id, + name: nil, + baseDirectory: worktreeBaseDirectory + ) ) - ) - return + return + } } guard !existing.contains(trimmed.lowercased()) else { await send( @@ -1094,9 +1220,17 @@ struct RepositoriesFeature { progress: progress ) ) - let isBareRepository = (try? await gitClient.isBareRepository(repository.rootURL)) ?? false - let copyIgnored = isBareRepository ? false : copyIgnoredOnWorktreeCreate - let copyUntracked = isBareRepository ? false : copyUntrackedOnWorktreeCreate + let copyIgnored: Bool + let copyUntracked: Bool + switch endpoint { + case .local: + let repositoryIsBare = (try? await isBareRepository(repository.rootURL)) ?? false + copyIgnored = repositoryIsBare ? false : copyIgnoredOnWorktreeCreate + copyUntracked = repositoryIsBare ? false : copyUntrackedOnWorktreeCreate + case .remote: + copyIgnored = false + copyUntracked = false + } progress.stage = .resolvingBaseReference await send( .pendingWorktreeProgressUpdated( @@ -1108,7 +1242,12 @@ struct RepositoriesFeature { switch baseRefSource { case .repositorySetting: if (selectedBaseRef ?? "").isEmpty { - resolvedBaseRef = await gitClient.automaticWorktreeBaseRef(repository.rootURL) ?? "" + switch endpoint { + case .local: + resolvedBaseRef = await automaticWorktreeBaseRef(repository.rootURL) ?? "" + case .remote: + resolvedBaseRef = "" + } } else { resolvedBaseRef = selectedBaseRef ?? "" } @@ -1116,16 +1255,21 @@ struct RepositoriesFeature { if let explicitBaseRef, !explicitBaseRef.isEmpty { resolvedBaseRef = explicitBaseRef } else { - resolvedBaseRef = await gitClient.automaticWorktreeBaseRef(repository.rootURL) ?? "" + switch endpoint { + case .local: + resolvedBaseRef = await automaticWorktreeBaseRef(repository.rootURL) ?? "" + case .remote: + resolvedBaseRef = "" + } } } progress.baseRef = resolvedBaseRef progress.copyIgnored = copyIgnored progress.copyUntracked = copyUntracked progress.ignoredFilesToCopyCount = - copyIgnored ? ((try? await gitClient.ignoredFileCount(repository.rootURL)) ?? 0) : 0 + copyIgnored ? ((try? await ignoredFileCount(repository.rootURL)) ?? 0) : 0 progress.untrackedFilesToCopyCount = - copyUntracked ? ((try? await gitClient.untrackedFileCount(repository.rootURL)) ?? 0) : 0 + copyUntracked ? ((try? await untrackedFileCount(repository.rootURL)) ?? 0) : 0 progress.stage = .creatingWorktree progress.commandText = worktreeCreateCommand( baseDirectoryURL: worktreeBaseDirectory, @@ -1140,14 +1284,29 @@ struct RepositoriesFeature { progress: progress ) ) - let stream = createWorktreeStream( - name, - repository.rootURL, - worktreeBaseDirectory, - copyIgnored, - copyUntracked, - resolvedBaseRef - ) + let stream: AsyncThrowingStream + switch endpoint { + case .local: + stream = createWorktreeStream( + name, + repository.rootURL, + worktreeBaseDirectory, + copyIgnored, + copyUntracked, + resolvedBaseRef + ) + case .remote: + stream = createWorktreeStreamForEndpoint( + name, + repository.rootURL, + worktreeBaseDirectory, + copyIgnored, + copyUntracked, + resolvedBaseRef, + endpoint, + hostProfile + ) + } for try await event in stream { switch event { case .outputLine(let outputLine): @@ -1294,11 +1453,23 @@ struct RepositoriesFeature { ) } if let cleanupWorktree = cleanup.worktree { + let endpoint = state.repositories[id: repositoryID]?.endpoint ?? cleanupWorktree.endpoint + let hostProfile = sshHostProfile(for: endpoint) let repositoryRootURL = cleanupWorktree.repositoryRootURL effects.append( .run { send in - _ = try? await gitClient.removeWorktree(cleanupWorktree, true) - _ = try? await gitClient.pruneWorktrees(repositoryRootURL) + switch endpoint { + case .local: + _ = try? await gitClient.removeWorktree(cleanupWorktree, true) + _ = try? await gitClient.pruneWorktrees(repositoryRootURL) + case .remote: + _ = try? await gitClient.removeWorktreeForEndpoint( + cleanupWorktree, + true, + endpoint, + hostProfile + ) + } await send(.reloadRepositories(animated: true)) } ) @@ -1690,12 +1861,24 @@ struct RepositoriesFeature { : nil @Shared(.settingsFile) var settingsFile let deleteBranchOnDeleteWorktree = settingsFile.global.deleteBranchOnDeleteWorktree + let endpoint = repository.endpoint + let hostProfile = sshHostProfile(for: endpoint) return .run { send in do { - _ = try await gitClient.removeWorktree( - worktree, - deleteBranchOnDeleteWorktree - ) + switch endpoint { + case .local: + _ = try await gitClient.removeWorktree( + worktree, + deleteBranchOnDeleteWorktree + ) + case .remote: + _ = try await gitClient.removeWorktreeForEndpoint( + worktree, + deleteBranchOnDeleteWorktree, + endpoint, + hostProfile + ) + } await send( .worktreeDeleted( worktree.id, @@ -1851,10 +2034,17 @@ struct RepositoriesFeature { state.repositoryRoots.removeAll { $0.standardizedFileURL.path(percentEncoded: false) == repositoryID } - let remainingRoots = state.repositoryRoots + let fallbackEntries = fallbackRepositoryEntries(from: state) + let preservedEndpoint = state.repositories[id: repositoryID]?.endpoint return .run { send in - let loadedEntries = await loadPersistedRepositoryEntries(fallbackRoots: remainingRoots) - let remainingEntries = loadedEntries.filter { $0.path != repositoryID } + let loadedEntries = await loadPersistedRepositoryEntries( + fallbackEntries: fallbackEntries + ) + let remainingEntries = Self.removeFailedRepositoryEntries( + loadedEntries, + repositoryID: repositoryID, + preservedEndpoint: preservedEndpoint + ) await repositoryPersistence.saveRepositoryEntries(remainingEntries) let roots = remainingEntries.map { URL(fileURLWithPath: $0.path) } let (repositories, failures) = await loadRepositoriesData(remainingEntries) @@ -1892,12 +2082,19 @@ struct RepositoriesFeature { state.shouldSelectFirstAfterReload = true } let selectedWorktree = state.worktree(for: state.selectedWorktreeID) - let remainingRoots = state.repositoryRoots + let fallbackEntries = fallbackRepositoryEntries(from: state) + let removedEndpoint = state.repositories[id: repositoryID]?.endpoint return .merge( .send(.delegate(.selectedWorktreeChanged(selectedWorktree))), .run { send in - let loadedEntries = await loadPersistedRepositoryEntries(fallbackRoots: remainingRoots) - let remainingEntries = loadedEntries.filter { $0.path != repositoryID } + let loadedEntries = await loadPersistedRepositoryEntries( + fallbackEntries: fallbackEntries + ) + let remainingEntries = Self.removeRepositoryEntries( + loadedEntries, + repositoryID: repositoryID, + endpoint: removedEndpoint + ) await repositoryPersistence.saveRepositoryEntries(remainingEntries) let roots = remainingEntries.map { URL(fileURLWithPath: $0.path) } let (repositories, failures) = await loadRepositoriesData(remainingEntries) @@ -2644,6 +2841,124 @@ struct RepositoriesFeature { case .openRepositorySettings(let repositoryID): return .send(.delegate(.openRepositorySettings(repositoryID))) + case .remoteConnect(.presented(.delegate(.cancel))): + state.remoteConnect = nil + return .none + + case .remoteConnect(.presented(.delegate(.completed(let submission)))): + state.remoteConnect = nil + analyticsClient.capture( + "repository_added", + [ + "count": 1, + "source": "remote", + ] + ) + @Shared(.settingsFile) var settingsFile + $settingsFile.withLock { + upsertSSHHostProfile(submission.hostProfile, settingsFile: &$0) + } + let repositoryPersistence = repositoryPersistence + let loadRepositoriesData = self.loadRepositoriesData + return .run { send in + let existingEntries = await repositoryPersistence.loadRepositoryEntries() + let endpoint = RepositoryEndpoint.remote( + hostProfileID: submission.hostProfile.id, + remotePath: submission.remotePath + ) + let mergedEntries = RepositoryEntryNormalizer.normalize( + existingEntries + [ + PersistedRepositoryEntry( + path: submission.remotePath, + kind: .git, + endpoint: endpoint + ) + ] + ) + let roots = mergedEntries.map { URL(fileURLWithPath: $0.path) } + await repositoryPersistence.saveRepositoryEntries(mergedEntries) + let (repositories, failures) = await loadRepositoriesData(mergedEntries) + await send( + .openRepositoriesFinished( + repositories, + failures: failures, + invalidRoots: [], + openFailures: [], + roots: roots + ) + ) + } + + case .remoteConnect: + return .none + + case .remoteSessionPicker(.presented(.delegate(.cancel))): + state.remoteSessionPicker = nil + return .none + + case .remoteSessionPicker(.presented(.delegate(.attachExisting(let sessionName)))): + let pickerState = state.remoteSessionPicker + persistLastAttachedRemoteTmuxSessionName( + sessionName, + pickerState: pickerState + ) + persistAttachedRemoteTmuxSessionName( + sessionName, + pickerState: pickerState, + state: &state + ) + state.remoteSessionPicker = nil + guard let attach = remoteSessionAttach( + sessionName: sessionName, + createIfMissing: false, + pickerState: pickerState, + state: state + ) else { + return .none + } + return .run { _ in + await terminalClient.send( + .createTabWithInput( + attach.worktree, + input: attach.command, + runSetupScriptIfNew: false + ) + ) + } + + case .remoteSessionPicker(.presented(.delegate(.createAndAttach(let sessionName)))): + let pickerState = state.remoteSessionPicker + persistLastAttachedRemoteTmuxSessionName( + sessionName, + pickerState: pickerState + ) + persistAttachedRemoteTmuxSessionName( + sessionName, + pickerState: pickerState, + state: &state + ) + state.remoteSessionPicker = nil + guard let attach = remoteSessionAttach( + sessionName: sessionName, + createIfMissing: true, + pickerState: pickerState, + state: state + ) else { + return .none + } + return .run { _ in + await terminalClient.send( + .createTabWithInput( + attach.worktree, + input: attach.command, + runSetupScriptIfNew: false + ) + ) + } + + case .remoteSessionPicker: + return .none + case .alert(.dismiss): state.alert = nil return .none @@ -2658,6 +2973,12 @@ struct RepositoriesFeature { .ifLet(\.$worktreeCreationPrompt, action: \.worktreeCreationPrompt) { WorktreeCreationPromptFeature() } + .ifLet(\.$remoteConnect, action: \.remoteConnect) { + RemoteConnectFeature() + } + .ifLet(\.$remoteSessionPicker, action: \.remoteSessionPicker) { + RemoteSessionPickerFeature() + } } private func refreshRepositoryPullRequests( @@ -2699,7 +3020,7 @@ struct RepositoriesFeature { } private func loadPersistedRepositoryEntries( - fallbackRoots: [URL] = [] + fallbackEntries: [PersistedRepositoryEntry] = [] ) async -> [PersistedRepositoryEntry] { let entries = await repositoryPersistence.loadRepositoryEntries() let resolvedEntries: [PersistedRepositoryEntry] @@ -2707,24 +3028,89 @@ struct RepositoriesFeature { resolvedEntries = entries } else { let loadedPaths = await repositoryPersistence.loadRoots() - let pathSource = + let fallback = if !loadedPaths.isEmpty { - loadedPaths + loadedPaths.map { PersistedRepositoryEntry(path: $0, kind: .git) } } else { - fallbackRoots.map { $0.path(percentEncoded: false) } + fallbackEntries } - resolvedEntries = RepositoryEntryNormalizer.normalize( - pathSource.map { PersistedRepositoryEntry(path: $0, kind: .git) } - ) + resolvedEntries = RepositoryEntryNormalizer.normalize(fallback) } return await upgradedRepositoryEntriesIfNeeded(resolvedEntries) } + private func fallbackRepositoryEntries(from state: State) -> [PersistedRepositoryEntry] { + state.repositories.map { repository in + PersistedRepositoryEntry( + path: repository.rootURL.path(percentEncoded: false), + kind: repository.kind, + endpoint: repository.endpoint + ) + } + } + + nonisolated static func removeFailedRepositoryEntries( + _ entries: [PersistedRepositoryEntry], + repositoryID: Repository.ID, + preservedEndpoint: RepositoryEndpoint? + ) -> [PersistedRepositoryEntry] { + let normalizedRepositoryID = Self.normalizedRepositoryPath(repositoryID) + guard let preservedEndpoint else { + return entries.filter { Self.normalizedRepositoryPath($0.path) != normalizedRepositoryID } + } + return entries.filter { entry in + let matchesRepository = Self.normalizedRepositoryPath(entry.path) == normalizedRepositoryID + guard matchesRepository else { + return true + } + return entry.endpoint == preservedEndpoint + } + } + + nonisolated static func removeRepositoryEntries( + _ entries: [PersistedRepositoryEntry], + repositoryID: Repository.ID, + endpoint: RepositoryEndpoint? + ) -> [PersistedRepositoryEntry] { + let normalizedRepositoryID = Self.normalizedRepositoryPath(repositoryID) + guard let endpoint, + let matchIndex = entries.firstIndex(where: { entry in + Self.normalizedRepositoryPath(entry.path) == normalizedRepositoryID + && entry.endpoint == endpoint + }) + else { + return entries.filter { Self.normalizedRepositoryPath($0.path) != normalizedRepositoryID } + } + var remainingEntries = entries + remainingEntries.remove(at: matchIndex) + return remainingEntries + } + + private nonisolated static func normalizedRepositoryPath(_ path: String) -> String { + URL(fileURLWithPath: path).standardizedFileURL.path(percentEncoded: false) + } + private func upgradedRepositoryEntriesIfNeeded( _ entries: [PersistedRepositoryEntry] ) async -> [PersistedRepositoryEntry] { let upgradedEntries = await withTaskGroup(of: (Int, PersistedRepositoryEntry).self) { group in for (index, entry) in entries.enumerated() { + if entry.endpoint.isRemote { + let normalizedPath = URL(fileURLWithPath: entry.path) + .standardizedFileURL + .path(percentEncoded: false) + group.addTask { + ( + index, + PersistedRepositoryEntry( + path: normalizedPath, + kind: entry.kind, + endpoint: entry.endpoint + ) + ) + } + continue + } let gitClient = self.gitClient group.addTask { let normalizedPath = URL(fileURLWithPath: entry.path) @@ -2736,24 +3122,66 @@ struct RepositoriesFeature { switch entry.kind { case .plain: if normalizedRepoRoot == normalizedPath { - return (index, PersistedRepositoryEntry(path: normalizedPath, kind: .git)) + return ( + index, + PersistedRepositoryEntry( + path: normalizedPath, + kind: .git, + endpoint: entry.endpoint + ) + ) } - return (index, PersistedRepositoryEntry(path: normalizedPath, kind: .plain)) + return ( + index, + PersistedRepositoryEntry( + path: normalizedPath, + kind: .plain, + endpoint: entry.endpoint + ) + ) case .git: if normalizedRepoRoot == normalizedPath { - return (index, PersistedRepositoryEntry(path: normalizedPath, kind: .git)) + return ( + index, + PersistedRepositoryEntry( + path: normalizedPath, + kind: .git, + endpoint: entry.endpoint + ) + ) } - return (index, PersistedRepositoryEntry(path: normalizedPath, kind: .plain)) + return ( + index, + PersistedRepositoryEntry( + path: normalizedPath, + kind: .plain, + endpoint: entry.endpoint + ) + ) } } catch { if entry.kind == .git, Self.isNotGitRepositoryError(error), FileManager.default.fileExists(atPath: normalizedPath) { - return (index, PersistedRepositoryEntry(path: normalizedPath, kind: .plain)) + return ( + index, + PersistedRepositoryEntry( + path: normalizedPath, + kind: .plain, + endpoint: entry.endpoint + ) + ) } } - return (index, PersistedRepositoryEntry(path: normalizedPath, kind: entry.kind)) + return ( + index, + PersistedRepositoryEntry( + path: normalizedPath, + kind: entry.kind, + endpoint: entry.endpoint + ) + ) } } @@ -2791,14 +3219,17 @@ struct RepositoriesFeature { } private func loadRepositories( - fallbackRoots: [URL] = [], + fallbackEntries: [PersistedRepositoryEntry] = [], animated: Bool = false ) -> Effect { let gitClient = gitClient - return .run { [animated, fallbackRoots] send in - let entries = await loadPersistedRepositoryEntries(fallbackRoots: fallbackRoots) + return .run { [animated, fallbackEntries] send in + let entries = await loadPersistedRepositoryEntries(fallbackEntries: fallbackEntries) let roots = entries.map { URL(fileURLWithPath: $0.path) } for entry in entries where entry.kind == .git { + guard case .local = entry.endpoint else { + continue + } _ = try? await gitClient.pruneWorktrees(URL(fileURLWithPath: entry.path)) } let (repositories, failures) = await loadRepositoriesData(entries) @@ -2815,34 +3246,56 @@ struct RepositoriesFeature { } private struct WorktreesFetchResult: Sendable { + let index: Int let entry: PersistedRepositoryEntry let repository: Repository? let errorMessage: String? } private func loadRepositoriesData(_ entries: [PersistedRepositoryEntry]) async -> ([Repository], [LoadFailure]) { + @Shared(.settingsFile) var settingsFile + let sshHostProfilesByID = Dictionary(uniqueKeysWithValues: settingsFile.sshHostProfiles.map { ($0.id, $0) }) let fetchResults = await withTaskGroup(of: WorktreesFetchResult.self) { group in - for entry in entries { + for (index, entry) in entries.enumerated() { let gitClient = self.gitClient + let hostProfile: SSHHostProfile? = + if case .remote(let hostProfileID, _) = entry.endpoint { + sshHostProfilesByID[hostProfileID] + } else { + nil + } group.addTask { let rootURL = URL(fileURLWithPath: entry.path).standardizedFileURL switch entry.kind { case .git: do { - let worktrees = try await gitClient.worktrees(rootURL) + let worktrees: [Worktree] + switch entry.endpoint { + case .local: + worktrees = try await gitClient.worktrees(rootURL) + case .remote: + worktrees = try await gitClient.worktreesForEndpoint( + rootURL, + entry.endpoint, + hostProfile + ) + } return WorktreesFetchResult( + index: index, entry: entry, repository: Repository( id: rootURL.path(percentEncoded: false), rootURL: rootURL, name: Repository.name(for: rootURL), kind: .git, + endpoint: entry.endpoint, worktrees: IdentifiedArray(uniqueElements: worktrees) ), errorMessage: nil ) } catch { return WorktreesFetchResult( + index: index, entry: entry, repository: nil, errorMessage: error.localizedDescription @@ -2850,36 +3303,42 @@ struct RepositoriesFeature { } case .plain: return WorktreesFetchResult( + index: index, entry: entry, repository: Repository( - id: rootURL.path(percentEncoded: false), - rootURL: rootURL, - name: Repository.name(for: rootURL), - kind: .plain, - worktrees: IdentifiedArray() - ), - errorMessage: nil - ) + id: rootURL.path(percentEncoded: false), + rootURL: rootURL, + name: Repository.name(for: rootURL), + kind: .plain, + endpoint: entry.endpoint, + worktrees: IdentifiedArray() + ), + errorMessage: nil + ) } } } - var resultsByRootID: [Repository.ID: WorktreesFetchResult] = [:] + var resultsByIndex = Array(repeating: nil, count: entries.count) for await result in group { - let rootID = URL(fileURLWithPath: result.entry.path).standardizedFileURL.path(percentEncoded: false) - resultsByRootID[rootID] = result + resultsByIndex[result.index] = result } - return resultsByRootID + return resultsByIndex } - var loaded: [Repository] = [] + var loadedByID: [Repository.ID: Repository] = [:] + var loadedOrder: [Repository.ID] = [] var failures: [LoadFailure] = [] - for entry in entries { + for (index, entry) in entries.enumerated() { let normalizedRoot = URL(fileURLWithPath: entry.path).standardizedFileURL let rootID = normalizedRoot.path(percentEncoded: false) - guard let result = fetchResults[rootID] else { continue } + guard let result = fetchResults[index] else { continue } if let repository = result.repository { - loaded.append(repository) + if loadedByID[repository.id] == nil { + loadedOrder.append(repository.id) + } + // Last persisted entry wins when multiple endpoints resolve to the same repository path. + loadedByID[repository.id] = repository } else { failures.append( LoadFailure( @@ -2889,6 +3348,7 @@ struct RepositoriesFeature { ) } } + let loaded = loadedOrder.compactMap { loadedByID[$0] } return (loaded, failures) } @@ -2899,13 +3359,15 @@ struct RepositoriesFeature { state: inout State, animated: Bool ) -> ApplyRepositoriesResult { - let previousCounts = Dictionary( - uniqueKeysWithValues: state.repositories.map { ($0.id, $0.worktrees.count) } - ) + var previousCounts: [Repository.ID: Int] = [:] + for repository in state.repositories { + previousCounts[repository.id] = repository.worktrees.count + } let repositoryIDs = Set(repositories.map(\.id)) - let newCounts = Dictionary( - uniqueKeysWithValues: repositories.map { ($0.id, $0.worktrees.count) } - ) + var newCounts: [Repository.ID: Int] = [:] + for repository in repositories { + newCounts[repository.id] = repository.worktrees.count + } var addedCounts: [Repository.ID: Int] = [:] for (id, newCount) in newCounts { let oldCount = previousCounts[id] ?? 0 @@ -3002,6 +3464,89 @@ struct RepositoriesFeature { } } + private func sshHostProfile(for endpoint: RepositoryEndpoint) -> SSHHostProfile? { + guard case .remote(let hostProfileID, _) = endpoint else { + return nil + } + @Shared(.settingsFile) var settingsFile + return settingsFile.sshHostProfiles.first { $0.id == hostProfileID } + } + + private func upsertSSHHostProfile( + _ profile: SSHHostProfile, + settingsFile: inout SettingsFile + ) { + if let index = settingsFile.sshHostProfiles.firstIndex(where: { $0.id == profile.id }) { + settingsFile.sshHostProfiles[index] = profile + return + } + settingsFile.sshHostProfiles.append(profile) + settingsFile.sshHostProfiles.sort { + $0.displayName.localizedStandardCompare($1.displayName) == .orderedAscending + } + } + + private func persistLastAttachedRemoteTmuxSessionName( + _ sessionName: String, + pickerState: RemoteSessionPickerFeature.State? + ) { + guard let pickerState else { + return + } + let trimmed = sessionName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + return + } + @Shared(.repositorySettings(pickerState.repositoryRootURL)) var repositorySettings + $repositorySettings.withLock { + $0.lastAttachedRemoteTmuxSessionName = trimmed + } + } + + private func persistAttachedRemoteTmuxSessionName( + _ sessionName: String, + pickerState: RemoteSessionPickerFeature.State?, + state: inout State + ) { + guard let pickerState else { + return + } + let trimmed = sessionName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + return + } + state.attachedRemoteTmuxSessionByWorktreeID[pickerState.worktreeID] = trimmed + } + + private func remoteSessionAttach( + sessionName: String, + createIfMissing: Bool, + pickerState: RemoteSessionPickerFeature.State?, + state: State + ) -> RemoteSessionAttach? { + guard let pickerState, let worktree = state.worktree(for: pickerState.worktreeID) else { + return nil + } + guard let hostProfile = sshHostProfile(for: worktree.endpoint) else { + return nil + } + let trimmedSessionName = sessionName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedSessionName.isEmpty else { + return nil + } + let command = + if createIfMissing { + remoteTmuxClient.buildCreateAndAttachCommand(hostProfile, trimmedSessionName, pickerState.remotePath) + } else { + remoteTmuxClient.buildAttachCommand(hostProfile, trimmedSessionName, pickerState.remotePath) + } + let trimmedCommand = command.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedCommand.isEmpty else { + return nil + } + return RemoteSessionAttach(worktree: worktree, command: trimmedCommand) + } + private func confirmationAlertForRepositoryRemoval( repositoryID: Repository.ID, state: State @@ -3254,11 +3799,14 @@ extension RepositoriesFeature.State { } func orderedRepositoryRoots() -> [URL] { - let rootsByID = Dictionary( - uniqueKeysWithValues: repositoryRoots.map { - ($0.standardizedFileURL.path(percentEncoded: false), $0.standardizedFileURL) + var rootsByID: [Repository.ID: URL] = [:] + for rootURL in repositoryRoots { + let standardizedRootURL = rootURL.standardizedFileURL + let id = standardizedRootURL.path(percentEncoded: false) + if rootsByID[id] == nil { + rootsByID[id] = standardizedRootURL } - ) + } var ordered: [URL] = [] var seen: Set = [] for id in repositoryOrderIDs { @@ -3465,7 +4013,10 @@ extension RepositoriesFeature.State { } func orderedWorktreeRows(includingRepositoryIDs: Set) -> [WorktreeRowModel] { - let repositoriesByID = Dictionary(uniqueKeysWithValues: repositories.map { ($0.id, $0) }) + var repositoriesByID: [Repository.ID: Repository] = [:] + for repository in repositories where repositoriesByID[repository.id] == nil { + repositoriesByID[repository.id] = repository + } return orderedRepositoryIDs() .filter { includingRepositoryIDs.contains($0) } .compactMap { repositoriesByID[$0] } @@ -3529,6 +4080,8 @@ private func insertWorktree( id: repository.id, rootURL: repository.rootURL, name: repository.name, + kind: repository.kind, + endpoint: repository.endpoint, worktrees: worktrees ) } @@ -3548,6 +4101,8 @@ private func removeWorktree( id: repository.id, rootURL: repository.rootURL, name: repository.name, + kind: repository.kind, + endpoint: repository.endpoint, worktrees: worktrees ) return true @@ -3589,7 +4144,8 @@ private func cleanupFailedWorktree( name: name, detail: "", workingDirectory: worktreeURL, - repositoryRootURL: repositoryRootURL + repositoryRootURL: repositoryRootURL, + endpoint: state.repositories[id: repositoryID]?.endpoint ?? .local ) let cleanup = cleanupWorktreeState( worktreeID, @@ -3718,12 +4274,15 @@ private func updateWorktreeName( detail: worktree.detail, workingDirectory: worktree.workingDirectory, repositoryRootURL: worktree.repositoryRootURL, + endpoint: worktree.endpoint, createdAt: worktree.createdAt ) repository = Repository( id: repository.id, rootURL: repository.rootURL, name: repository.name, + kind: repository.kind, + endpoint: repository.endpoint, worktrees: worktrees ) state.repositories[index] = repository @@ -3920,7 +4479,10 @@ private func pruneWorktreeOrderByRepository( state: inout RepositoriesFeature.State ) -> Bool { let rootIDs = Set(roots.map { $0.standardizedFileURL.path(percentEncoded: false) }) - let repositoriesByID = Dictionary(uniqueKeysWithValues: state.repositories.map { ($0.id, $0) }) + var repositoriesByID: [Repository.ID: Repository] = [:] + for repository in state.repositories where repositoriesByID[repository.id] == nil { + repositoriesByID[repository.id] = repository + } let pinnedSet = Set(state.pinnedWorktreeIDs) let archivedSet = state.archivedWorktreeIDSet var pruned: [Repository.ID: [Worktree.ID]] = [:] @@ -3968,6 +4530,26 @@ private func pruneArchivedWorktreeIDs( return false } +private func mergedLoadFailuresByID( + _ failures: [RepositoriesFeature.LoadFailure] +) -> [Repository.ID: String] { + var mergedMessages: [Repository.ID: [String]] = [:] + for failure in failures { + let message = failure.message.trimmingCharacters(in: .whitespacesAndNewlines) + let normalizedMessage = message.isEmpty ? failure.message : message + var messages = mergedMessages[failure.rootID] ?? [] + if !messages.contains(normalizedMessage) { + messages.append(normalizedMessage) + } + mergedMessages[failure.rootID] = messages + } + var mergedFailuresByID: [Repository.ID: String] = [:] + for (rootID, messages) in mergedMessages { + mergedFailuresByID[rootID] = messages.joined(separator: "\n") + } + return mergedFailuresByID +} + private func firstAvailableWorktreeID( from repositories: [Repository], state: RepositoriesFeature.State diff --git a/supacode/Features/Repositories/Views/RemoteConnectSheet.swift b/supacode/Features/Repositories/Views/RemoteConnectSheet.swift new file mode 100644 index 000000000..09dd04a3e --- /dev/null +++ b/supacode/Features/Repositories/Views/RemoteConnectSheet.swift @@ -0,0 +1,364 @@ +import ComposableArchitecture +import SwiftUI + +struct RemoteConnectSheet: View { + @Bindable var store: StoreOf + @FocusState private var focusedField: Field? + + private enum Field: Hashable { + case displayName + case host + case user + case port + case password + case remotePath + } + + var body: some View { + VStack(alignment: .leading, spacing: 18) { + VStack(alignment: .leading, spacing: 6) { + Text("Connect Remote Repository") + .font(.title3) + Text( + store.step == .host + ? "Choose a host profile or enter SSH details first." + : "Pick the remote repository folder on \(store.resolvedDisplayName)." + ) + .foregroundStyle(.secondary) + } + + stepIndicator + + if store.step == .host { + hostStep + } else { + repositoryStep + } + + if let validationMessage = store.validationMessage, !validationMessage.isEmpty { + Text(validationMessage) + .font(.footnote) + .foregroundStyle(.red) + } + + HStack { + if store.isSubmitting { + ProgressView() + .controlSize(.small) + } + Spacer() + if store.step == .repository { + Button("Back") { + store.send(.backButtonTapped) + } + .help("Go back to host details") + } + Button("Cancel") { + store.send(.cancelButtonTapped) + } + .keyboardShortcut(.cancelAction) + .help("Cancel (Esc)") + Button(store.step == .host ? "Continue" : "Connect") { + store.send(store.step == .host ? .continueButtonTapped : .connectButtonTapped) + } + .keyboardShortcut(.defaultAction) + .help(store.step == .host ? "Continue (Return)" : "Connect (Return)") + .disabled(store.isSubmitting) + } + } + .padding(20) + .frame(minWidth: 560, idealWidth: 620) + .task(id: store.step) { + focusedField = + if store.step == .host { + store.authMethod == .password ? .password : .host + } else { + .remotePath + } + } + } + + private var stepIndicator: some View { + HStack(spacing: 12) { + stepBadge(number: 1, title: "Host", isActive: store.step == .host) + Image(systemName: "chevron.right") + .foregroundStyle(.secondary) + .font(.footnote.weight(.semibold)) + stepBadge(number: 2, title: "Repository", isActive: store.step == .repository) + } + } + + private func stepBadge(number: Int, title: String, isActive: Bool) -> some View { + HStack(spacing: 8) { + Text("\(number)") + .font(.callout.monospaced()) + .frame(width: 24, height: 24) + .background(isActive ? Color.accentColor : Color.secondary.opacity(0.15)) + .foregroundStyle(isActive ? .white : .primary) + .clipShape(Circle()) + Text(title) + .fontWeight(isActive ? .semibold : .regular) + } + } + + private var hostStep: some View { + VStack(alignment: .leading, spacing: 16) { + if !store.savedHostProfiles.isEmpty { + VStack(alignment: .leading, spacing: 8) { + Text("Saved hosts") + .foregroundStyle(.secondary) + Picker("Saved hosts", selection: savedHostSelection) { + Text("New host") + .tag(Optional.none) + ForEach(store.savedHostProfiles) { profile in + Text(hostLabel(profile)) + .tag(Optional(profile.id)) + } + } + .labelsHidden() + .pickerStyle(.menu) + } + } + + Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 12) { + GridRow { + labeledField( + title: "Name", + prompt: "Build Box", + text: $store.displayName, + field: .displayName + ) + labeledField( + title: "Host", + prompt: "example.com", + text: $store.host, + field: .host + ) + } + GridRow { + labeledField( + title: "User", + prompt: "deploy", + text: $store.user, + field: .user + ) + labeledField( + title: "Port", + prompt: "22", + text: $store.port, + field: .port + ) + } + } + + VStack(alignment: .leading, spacing: 8) { + Text("Authentication") + .foregroundStyle(.secondary) + Picker("Authentication", selection: $store.authMethod) { + ForEach(SSHHostProfile.AuthMethod.allCases, id: \.self) { method in + Text(authenticationTitle(method)) + .tag(method) + } + } + .pickerStyle(.segmented) + } + + if store.authMethod == .password { + labeledSecureField( + title: "Password", + prompt: "Enter SSH password", + text: $store.password, + field: .password + ) + } + } + } + + private var repositoryStep: some View { + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 6) { + Text("Host") + .foregroundStyle(.secondary) + Text(hostSummary) + .monospaced() + } + + VStack(alignment: .leading, spacing: 8) { + Text("Remote path") + .foregroundStyle(.secondary) + HStack(alignment: .top, spacing: 10) { + TextField("~/src/repo", text: $store.remotePath) + .textFieldStyle(.roundedBorder) + .focused($focusedField, equals: .remotePath) + .onSubmit { + guard !store.isSubmitting else { + return + } + store.send(.connectButtonTapped) + } + Button("Browse Remote Folders") { + store.send(.browseRemoteFoldersButtonTapped) + } + .help("Browse folders over SSH and pick a repository directory") + } + Text("Use an absolute path or start with `~/`. Browsing resolves the final path for you.") + .font(.footnote) + .foregroundStyle(.secondary) + .monospaced() + } + + if let directoryBrowser = store.directoryBrowser { + directoryBrowserView(directoryBrowser) + } + } + } + + private func directoryBrowserView( + _ directoryBrowser: RemoteConnectFeature.DirectoryBrowserState + ) -> some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Text("Remote folders") + .font(.headline) + Spacer() + Button { + store.send(.directoryBrowserUpButtonTapped) + } label: { + Label("Up", systemImage: "arrow.up") + } + .help("Go to the parent folder") + .disabled(directoryBrowser.currentPath == "/") + Button("Use Current Folder") { + store.send(.directoryBrowserChooseCurrentFolderButtonTapped) + } + .help("Use \(directoryBrowser.currentPath) as the repository path") + Button("Close") { + store.send(.directoryBrowserDismissed) + } + .help("Close the remote folder browser") + } + + Text(directoryBrowser.currentPath) + .font(.footnote.monospaced()) + .foregroundStyle(.secondary) + + if directoryBrowser.isLoading { + HStack(spacing: 8) { + ProgressView() + .controlSize(.small) + Text("Loading folders…") + .foregroundStyle(.secondary) + } + } else if let errorMessage = directoryBrowser.errorMessage { + Text(errorMessage) + .font(.footnote) + .foregroundStyle(.red) + } else if directoryBrowser.childDirectories.isEmpty { + Text("No child folders found.") + .font(.footnote) + .foregroundStyle(.secondary) + } else { + ScrollView { + LazyVStack(alignment: .leading, spacing: 6) { + ForEach(directoryBrowser.childDirectories, id: \.self) { path in + Button { + store.send(.directoryBrowserEntryTapped(path)) + } label: { + HStack { + Image(systemName: "folder") + Text(path) + .lineLimit(1) + .truncationMode(.middle) + .monospaced() + Spacer() + Image(systemName: "chevron.right") + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 10) + .padding(.vertical, 8) + .background(Color.secondary.opacity(0.08)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + .buttonStyle(.plain) + .help("Browse \(path)") + } + } + } + .frame(minHeight: 160, maxHeight: 220) + } + } + .padding(14) + .background(Color.secondary.opacity(0.08)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + + private func labeledField( + title: String, + prompt: String, + text: Binding, + field: Field + ) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text(title) + .foregroundStyle(.secondary) + TextField(prompt, text: text) + .textFieldStyle(.roundedBorder) + .focused($focusedField, equals: field) + } + } + + private func labeledSecureField( + title: String, + prompt: String, + text: Binding, + field: Field + ) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text(title) + .foregroundStyle(.secondary) + SecureField(prompt, text: text) + .textFieldStyle(.roundedBorder) + .focused($focusedField, equals: field) + } + } + + private var savedHostSelection: Binding { + Binding( + get: { store.selectedHostProfileID }, + set: { store.send(.savedHostProfileSelected($0)) } + ) + } + + private func hostLabel(_ profile: SSHHostProfile) -> String { + let userAndHost = + if profile.user.isEmpty { + profile.host + } else { + "\(profile.user)@\(profile.host)" + } + return "\(profile.displayName) (\(userAndHost))" + } + + private func authenticationTitle(_ method: SSHHostProfile.AuthMethod) -> String { + switch method { + case .publicKey: + "Public Key" + case .password: + "Password" + } + } + + private var hostSummary: String { + let userAndHost = + if store.user.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + store.host + } else { + "\(store.user)@\(store.host)" + } + if let port = Int(store.port.trimmingCharacters(in: .whitespacesAndNewlines)) { + return "\(userAndHost):\(port)" + } + return userAndHost + } +} diff --git a/supacode/Features/Repositories/Views/RemoteSessionPickerSheet.swift b/supacode/Features/Repositories/Views/RemoteSessionPickerSheet.swift new file mode 100644 index 000000000..8a9b55217 --- /dev/null +++ b/supacode/Features/Repositories/Views/RemoteSessionPickerSheet.swift @@ -0,0 +1,73 @@ +import ComposableArchitecture +import SwiftUI + +struct RemoteSessionPickerSheet: View { + @Bindable var store: StoreOf + @FocusState private var isManagedSessionFieldFocused: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 6) { + Text("Attach tmux Session") + .font(.title3) + Text("Choose a tmux session for the selected remote repository.") + .foregroundStyle(.secondary) + } + + VStack(alignment: .leading, spacing: 6) { + Text("Remote path") + .foregroundStyle(.secondary) + Text(store.remotePath) + .font(.body.monospaced()) + .lineLimit(1) + .truncationMode(.middle) + } + + VStack(alignment: .leading, spacing: 8) { + Text("Attach existing session") + .foregroundStyle(.secondary) + Picker("Existing sessions", selection: $store.selectedSessionName) { + ForEach(store.sessions, id: \.self) { sessionName in + Text(sessionName) + .tag(sessionName) + } + } + .labelsHidden() + .pickerStyle(.menu) + } + + VStack(alignment: .leading, spacing: 8) { + Text("Create managed session") + .foregroundStyle(.secondary) + TextField("Session name", text: $store.managedSessionName) + .textFieldStyle(.roundedBorder) + .focused($isManagedSessionFieldFocused) + } + + HStack { + Spacer() + Button("Cancel") { + store.send(.cancelTapped) + } + .keyboardShortcut(.cancelAction) + .help("Cancel (Esc)") + Button("Attach") { + store.send(.attachTapped) + } + .help("Attach selected tmux session") + .disabled(!store.canAttachSelectedSession) + Button("Create and Attach") { + store.send(.createAndAttachTapped) + } + .keyboardShortcut(.defaultAction) + .help("Create (if needed) and attach to this tmux session (Return)") + .disabled(!store.canCreateManagedSession) + } + } + .padding(20) + .frame(minWidth: 500, idealWidth: 560) + .task { + isManagedSessionFieldFocused = false + } + } +} diff --git a/supacode/Features/Repositories/Views/SidebarFooterView.swift b/supacode/Features/Repositories/Views/SidebarFooterView.swift index 101d6bdf5..abd97695b 100644 --- a/supacode/Features/Repositories/Views/SidebarFooterView.swift +++ b/supacode/Features/Repositories/Views/SidebarFooterView.swift @@ -5,22 +5,29 @@ struct SidebarFooterView: View { let store: StoreOf @Environment(\.surfaceBottomChromeBackgroundOpacity) private var surfaceBottomChromeBackgroundOpacity @Environment(\.openURL) private var openURL - @Environment(CommandKeyObserver.self) private var commandKeyObserver var body: some View { HStack { - Button { - store.send(.setOpenPanelPresented(true)) - } label: { - HStack(spacing: 6) { + Menu { + Button { + store.send(.setOpenPanelPresented(true)) + } label: { Label("Add Repository", systemImage: "folder.badge.plus") + } + .keyboardShortcut(AppShortcuts.openRepository.keyboardShortcut) + .help("Add Repository (\(AppShortcuts.openRepository.display))") + + Button { + store.send(.addRemoteRepositoryButtonTapped) + } label: { + Label("Add Remote Repository", systemImage: "server.rack") + } + .help("Connect Remote Repository") + } label: { + Label("Add", systemImage: "plus") .font(.callout) - if commandKeyObserver.isPressed { - ShortcutHintView(text: AppShortcuts.openRepository.display, color: .secondary) - } } - } - .help("Add Repository (\(AppShortcuts.openRepository.display))") + .help("Add Repository or Remote Repository") Spacer() Menu { Button("Submit GitHub issue", systemImage: "exclamationmark.bubble") { diff --git a/supacode/Features/Repositories/Views/WorktreeDetailView.swift b/supacode/Features/Repositories/Views/WorktreeDetailView.swift index 4ccc67731..f3327ac4d 100644 --- a/supacode/Features/Repositories/Views/WorktreeDetailView.swift +++ b/supacode/Features/Repositories/Views/WorktreeDetailView.swift @@ -189,9 +189,13 @@ struct WorktreeDetailView: View { selectedWorktreeSummaries: [MultiSelectedWorktreeSummary] ) -> some View { if repositories.isShowingCanvas { - CanvasView(terminalManager: terminalManager, onExitToTab: { - store.send(.repositories(.toggleCanvas)) - }) + CanvasView( + terminalManager: terminalManager, + preferredFocusedWorktreeID: repositories.preCanvasTerminalTargetID ?? repositories.preCanvasWorktreeID, + onExitToTab: { + store.send(.repositories(.toggleCanvas)) + } + ) } else if repositories.isShowingArchivedWorktrees { ArchivedWorktreesDetailView( store: store.scope(state: \.repositories, action: \.repositories) diff --git a/supacode/Features/RepositorySettings/Reducer/RepositorySettingsFeature.swift b/supacode/Features/RepositorySettings/Reducer/RepositorySettingsFeature.swift index ebeaaf8f4..388ae5964 100644 --- a/supacode/Features/RepositorySettings/Reducer/RepositorySettingsFeature.swift +++ b/supacode/Features/RepositorySettings/Reducer/RepositorySettingsFeature.swift @@ -7,6 +7,7 @@ struct RepositorySettingsFeature { struct State: Equatable { var rootURL: URL var repositoryKind: Repository.Kind + var endpoint: RepositoryEndpoint = .local var settings: RepositorySettings var userSettings: UserRepositorySettings var globalDefaultWorktreeBaseDirectoryPath: String? @@ -14,6 +15,10 @@ struct RepositorySettingsFeature { var branchOptions: [String] = [] var defaultWorktreeBaseRef = "origin/main" var isBranchDataLoaded = false + var remoteHostDisplayName: String? + var remoteHostDestination: String? + var remotePath: String? + var remoteHostProfileID: String? var capabilities: Repository.Capabilities { switch repositoryKind { @@ -83,6 +88,7 @@ struct RepositorySettingsFeature { switch action { case .task: let rootURL = state.rootURL + let endpoint = state.endpoint @Shared(.repositorySettings(rootURL)) var repositorySettings @Shared(.userRepositorySettings(rootURL)) var userRepositorySettings @Shared(.settingsFile) var settingsFile @@ -90,6 +96,24 @@ struct RepositorySettingsFeature { let userSettings = userRepositorySettings let globalDefaultWorktreeBaseDirectoryPath = settingsFile.global.defaultWorktreeBaseDirectoryPath + let hostProfile = sshHostProfile(for: endpoint, settingsFile: settingsFile) + switch endpoint { + case .local: + state.remoteHostDisplayName = nil + state.remoteHostDestination = nil + state.remotePath = nil + state.remoteHostProfileID = nil + case .remote(let hostProfileID, let remotePath): + state.remotePath = remotePath + state.remoteHostProfileID = hostProfileID + if let hostProfile { + state.remoteHostDisplayName = hostProfile.displayName + state.remoteHostDestination = sshDestination(for: hostProfile) + } else { + state.remoteHostDisplayName = nil + state.remoteHostDestination = nil + } + } guard state.capabilities.supportsRepositoryGitSettings else { return .send( .settingsLoaded( @@ -101,28 +125,53 @@ struct RepositorySettingsFeature { ) } let gitClient = gitClient - return .run { send in - let isBareRepository = (try? await gitClient.isBareRepository(rootURL)) ?? false - await send( - .settingsLoaded( - settings, - userSettings, - isBareRepository: isBareRepository, - globalDefaultWorktreeBaseDirectoryPath: globalDefaultWorktreeBaseDirectoryPath + return .run { [endpoint] send in + switch endpoint { + case .local: + let isBareRepository = (try? await gitClient.isBareRepository(rootURL)) ?? false + await send( + .settingsLoaded( + settings, + userSettings, + isBareRepository: isBareRepository, + globalDefaultWorktreeBaseDirectoryPath: globalDefaultWorktreeBaseDirectoryPath + ) ) - ) - let branches: [String] - do { - branches = try await gitClient.branchRefs(rootURL) - } catch { - let rootPath = rootURL.path(percentEncoded: false) - SupaLogger("Settings").warning( - "Branch refs failed for \(rootPath): \(error.localizedDescription)" + let branches: [String] + do { + branches = try await gitClient.branchRefs(rootURL) + } catch { + let rootPath = rootURL.path(percentEncoded: false) + SupaLogger("Settings").warning( + "Branch refs failed for \(rootPath): \(error.localizedDescription)" + ) + branches = [] + } + let defaultBaseRef = await gitClient.automaticWorktreeBaseRef(rootURL) ?? "HEAD" + await send(.branchDataLoaded(branches, defaultBaseRef: defaultBaseRef)) + + case .remote: + if hostProfile == nil { + let rootPath = rootURL.path(percentEncoded: false) + SupaLogger("Settings").warning( + "Missing SSH host profile for remote repository settings: \(rootPath)" + ) + } + await send( + .settingsLoaded( + settings, + userSettings, + isBareRepository: false, + globalDefaultWorktreeBaseDirectoryPath: globalDefaultWorktreeBaseDirectoryPath + ) + ) + await send( + .branchDataLoaded( + [], + defaultBaseRef: settings.worktreeBaseRef ?? "HEAD" + ) ) - branches = [] } - let defaultBaseRef = await gitClient.automaticWorktreeBaseRef(rootURL) ?? "HEAD" - await send(.branchDataLoaded(branches, defaultBaseRef: defaultBaseRef)) } case .settingsLoaded( @@ -195,4 +244,22 @@ struct RepositorySettingsFeature { } } } + + private func sshHostProfile( + for endpoint: RepositoryEndpoint, + settingsFile: SettingsFile + ) -> SSHHostProfile? { + guard case .remote(let hostProfileID, _) = endpoint else { + return nil + } + return settingsFile.sshHostProfiles.first { $0.id == hostProfileID } + } + + private func sshDestination(for profile: SSHHostProfile) -> String { + let userHost = profile.user.isEmpty ? profile.host : "\(profile.user)@\(profile.host)" + if let port = profile.port { + return "\(userHost):\(port)" + } + return userHost + } } diff --git a/supacode/Features/Settings/BusinessLogic/RepositoryPersistenceKeys.swift b/supacode/Features/Settings/BusinessLogic/RepositoryPersistenceKeys.swift index 1244daf4c..93bc6114d 100644 --- a/supacode/Features/Settings/BusinessLogic/RepositoryPersistenceKeys.swift +++ b/supacode/Features/Settings/BusinessLogic/RepositoryPersistenceKeys.swift @@ -194,36 +194,62 @@ nonisolated enum RepositoryPathNormalizer { nonisolated enum RepositoryEntryNormalizer { static func normalize(_ entries: [PersistedRepositoryEntry]) -> [PersistedRepositoryEntry] { - var order: [String] = [] - var kindByPath: [String: Repository.Kind] = [:] + var order: [EntryKey] = [] + var entryByKey: [EntryKey: PersistedRepositoryEntry] = [:] for entry in entries { guard let normalizedPath = normalizePath(entry.path) else { continue } - if let existing = kindByPath[normalizedPath] { - kindByPath[normalizedPath] = resolvedKind(existing: existing, incoming: entry.kind) + let normalizedEntry = PersistedRepositoryEntry( + path: normalizedPath, + kind: entry.kind, + endpoint: entry.endpoint + ) + let key = EntryKey(path: normalizedPath, endpoint: entry.endpoint) + if let existing = entryByKey[key] { + entryByKey[key] = resolvedEntry(existing: existing, incoming: normalizedEntry) continue } - order.append(normalizedPath) - kindByPath[normalizedPath] = entry.kind + order.append(key) + entryByKey[key] = normalizedEntry } - return order.compactMap { path in - guard let kind = kindByPath[path] else { return nil } - return PersistedRepositoryEntry(path: path, kind: kind) + return order.compactMap { key in + entryByKey[key] } } - private static func resolvedKind( - existing: Repository.Kind, - incoming: Repository.Kind - ) -> Repository.Kind { - if existing == .git || incoming == .git { - return .git - } - return .plain + private static func resolvedEntry( + existing: PersistedRepositoryEntry, + incoming: PersistedRepositoryEntry + ) -> PersistedRepositoryEntry { + let kind: Repository.Kind = + if existing.kind == .git || incoming.kind == .git { + .git + } else { + .plain + } + let endpoint: RepositoryEndpoint = + switch (existing.endpoint, incoming.endpoint) { + case (.remote, _): + existing.endpoint + case (_, .remote): + incoming.endpoint + default: + existing.endpoint + } + return PersistedRepositoryEntry( + path: existing.path, + kind: kind, + endpoint: endpoint + ) } private static func normalizePath(_ path: String) -> String? { RepositoryPathNormalizer.normalize([path]).first } + + private struct EntryKey: Hashable { + let path: String + let endpoint: RepositoryEndpoint + } } diff --git a/supacode/Features/Settings/Models/RepositorySettings.swift b/supacode/Features/Settings/Models/RepositorySettings.swift index 5eda0179b..f49e2317d 100644 --- a/supacode/Features/Settings/Models/RepositorySettings.swift +++ b/supacode/Features/Settings/Models/RepositorySettings.swift @@ -10,6 +10,8 @@ nonisolated struct RepositorySettings: Codable, Equatable, Sendable { var copyIgnoredOnWorktreeCreate: Bool var copyUntrackedOnWorktreeCreate: Bool var pullRequestMergeStrategy: PullRequestMergeStrategy + var defaultRemoteTmuxSessionName: String? + var lastAttachedRemoteTmuxSessionName: String? private enum CodingKeys: String, CodingKey { case setupScript @@ -21,6 +23,8 @@ nonisolated struct RepositorySettings: Codable, Equatable, Sendable { case copyIgnoredOnWorktreeCreate case copyUntrackedOnWorktreeCreate case pullRequestMergeStrategy + case defaultRemoteTmuxSessionName + case lastAttachedRemoteTmuxSessionName } static let `default` = RepositorySettings( @@ -32,7 +36,9 @@ nonisolated struct RepositorySettings: Codable, Equatable, Sendable { worktreeBaseDirectoryPath: nil, copyIgnoredOnWorktreeCreate: false, copyUntrackedOnWorktreeCreate: false, - pullRequestMergeStrategy: .merge + pullRequestMergeStrategy: .merge, + defaultRemoteTmuxSessionName: nil, + lastAttachedRemoteTmuxSessionName: nil ) init( @@ -44,7 +50,9 @@ nonisolated struct RepositorySettings: Codable, Equatable, Sendable { worktreeBaseDirectoryPath: String? = nil, copyIgnoredOnWorktreeCreate: Bool, copyUntrackedOnWorktreeCreate: Bool, - pullRequestMergeStrategy: PullRequestMergeStrategy + pullRequestMergeStrategy: PullRequestMergeStrategy, + defaultRemoteTmuxSessionName: String? = nil, + lastAttachedRemoteTmuxSessionName: String? = nil ) { self.setupScript = setupScript self.archiveScript = archiveScript @@ -55,6 +63,8 @@ nonisolated struct RepositorySettings: Codable, Equatable, Sendable { self.copyIgnoredOnWorktreeCreate = copyIgnoredOnWorktreeCreate self.copyUntrackedOnWorktreeCreate = copyUntrackedOnWorktreeCreate self.pullRequestMergeStrategy = pullRequestMergeStrategy + self.defaultRemoteTmuxSessionName = defaultRemoteTmuxSessionName + self.lastAttachedRemoteTmuxSessionName = lastAttachedRemoteTmuxSessionName } init(from decoder: Decoder) throws { @@ -90,5 +100,9 @@ nonisolated struct RepositorySettings: Codable, Equatable, Sendable { PullRequestMergeStrategy.self, forKey: .pullRequestMergeStrategy ) ?? Self.default.pullRequestMergeStrategy + defaultRemoteTmuxSessionName = + try container.decodeIfPresent(String.self, forKey: .defaultRemoteTmuxSessionName) + lastAttachedRemoteTmuxSessionName = + try container.decodeIfPresent(String.self, forKey: .lastAttachedRemoteTmuxSessionName) } } diff --git a/supacode/Features/Settings/Models/SettingsFile.swift b/supacode/Features/Settings/Models/SettingsFile.swift index 86059e18a..b76a2b43d 100644 --- a/supacode/Features/Settings/Models/SettingsFile.swift +++ b/supacode/Features/Settings/Models/SettingsFile.swift @@ -1,11 +1,13 @@ nonisolated struct SettingsFile: Codable, Equatable, Sendable { var global: GlobalSettings + var sshHostProfiles: [SSHHostProfile] var repositories: [String: RepositorySettings] var repositoryRoots: [String] var pinnedWorktreeIDs: [Worktree.ID] enum CodingKeys: String, CodingKey { case global + case sshHostProfiles case repositories case repositoryRoots case pinnedWorktreeIDs @@ -13,6 +15,7 @@ nonisolated struct SettingsFile: Codable, Equatable, Sendable { static let `default` = SettingsFile( global: .default, + sshHostProfiles: [], repositories: [:], repositoryRoots: [], pinnedWorktreeIDs: [] @@ -20,11 +23,13 @@ nonisolated struct SettingsFile: Codable, Equatable, Sendable { init( global: GlobalSettings = .default, + sshHostProfiles: [SSHHostProfile] = [], repositories: [String: RepositorySettings] = [:], repositoryRoots: [String] = [], pinnedWorktreeIDs: [Worktree.ID] = [] ) { self.global = global + self.sshHostProfiles = sshHostProfiles self.repositories = repositories self.repositoryRoots = repositoryRoots self.pinnedWorktreeIDs = pinnedWorktreeIDs @@ -33,6 +38,8 @@ nonisolated struct SettingsFile: Codable, Equatable, Sendable { init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) global = try container.decodeIfPresent(GlobalSettings.self, forKey: .global) ?? .default + sshHostProfiles = + try container.decodeIfPresent([SSHHostProfile].self, forKey: .sshHostProfiles) ?? [] repositories = try container.decodeIfPresent([String: RepositorySettings].self, forKey: .repositories) ?? [:] @@ -44,6 +51,7 @@ nonisolated struct SettingsFile: Codable, Equatable, Sendable { func encode(to encoder: any Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(global, forKey: .global) + try container.encode(sshHostProfiles, forKey: .sshHostProfiles) try container.encode(repositories, forKey: .repositories) try container.encode(repositoryRoots, forKey: .repositoryRoots) try container.encode(pinnedWorktreeIDs, forKey: .pinnedWorktreeIDs) diff --git a/supacode/Features/Settings/Reducer/SSHHostsFeature.swift b/supacode/Features/Settings/Reducer/SSHHostsFeature.swift new file mode 100644 index 000000000..2f0a43c92 --- /dev/null +++ b/supacode/Features/Settings/Reducer/SSHHostsFeature.swift @@ -0,0 +1,273 @@ +import ComposableArchitecture +import Foundation + +@Reducer +struct SSHHostsFeature { + @ObservableState + struct State: Equatable { + var hosts: [SSHHostProfile] = [] + var selectedHostID: SSHHostProfile.ID? + var displayName = "" + var host = "" + var user = "" + var port = "" + var authMethod: SSHHostProfile.AuthMethod = .publicKey + var isCreating = false + var validationMessage: String? + @Presents var alert: AlertState? + } + + enum Action: BindableAction { + case task + case hostSelected(SSHHostProfile.ID?) + case addHostTapped + case deleteHostTapped + case saveButtonTapped + case alert(PresentationAction) + case binding(BindingAction) + } + + enum Alert: Equatable { + case confirmDelete(SSHHostProfile.ID) + } + + @Dependency(\.date) private var date + @Dependency(\.uuid) private var uuid + + var body: some Reducer { + BindingReducer() + Reduce { state, action in + switch action { + case .task: + @Shared(.settingsFile) var settingsFile + loadHosts(settingsFile.sshHostProfiles, into: &state) + return .none + + case .hostSelected(let hostID): + state.validationMessage = nil + state.alert = nil + guard let hostID, let profile = state.hosts.first(where: { $0.id == hostID }) else { + clearEditorFields(in: &state) + return .none + } + state.selectedHostID = hostID + state.isCreating = false + setEditorFields(from: profile, in: &state) + return .none + + case .addHostTapped: + state.selectedHostID = nil + state.isCreating = true + state.validationMessage = nil + state.alert = nil + state.displayName = "" + state.host = "" + state.user = "" + state.port = "" + state.authMethod = .publicKey + return .none + + case .deleteHostTapped: + guard let hostID = state.selectedHostID else { + return .none + } + let boundCount = remoteBindingCount(for: hostID) + guard boundCount == 0 else { + state.validationMessage = + boundCount == 1 + ? "This host is used by 1 remote repository and cannot be deleted." + : "This host is used by \(boundCount) remote repositories and cannot be deleted." + return .none + } + guard let profile = state.hosts.first(where: { $0.id == hostID }) else { + return .none + } + state.alert = deleteConfirmationAlert(for: profile) + return .none + + case .saveButtonTapped: + guard let normalizedHost = normalizedHost(in: &state) else { + return .none + } + guard let normalizedPort = normalizedPort(in: &state) else { + return .none + } + state.validationMessage = nil + let normalizedDisplayName = + state.displayName + .trimmingCharacters(in: .whitespacesAndNewlines) + let resolvedDisplayName = normalizedDisplayName.isEmpty ? normalizedHost : normalizedDisplayName + let normalizedUser = state.user.trimmingCharacters(in: .whitespacesAndNewlines) + let now = date.now + + if state.isCreating || state.selectedHostID == nil { + let profile = SSHHostProfile( + id: uuid().uuidString, + displayName: resolvedDisplayName, + host: normalizedHost, + user: normalizedUser, + port: normalizedPort, + authMethod: state.authMethod, + createdAt: now, + updatedAt: now + ) + state.hosts.append(profile) + state.hosts = sortProfiles(state.hosts) + state.selectedHostID = profile.id + state.isCreating = false + } else if let selectedHostID = state.selectedHostID, + let index = state.hosts.firstIndex(where: { $0.id == selectedHostID }) + { + let existing = state.hosts[index] + state.hosts[index] = SSHHostProfile( + id: existing.id, + displayName: resolvedDisplayName, + host: normalizedHost, + user: normalizedUser, + port: normalizedPort, + authMethod: state.authMethod, + createdAt: existing.createdAt, + updatedAt: now + ) + state.hosts = sortProfiles(state.hosts) + } + + if let selectedHostID = state.selectedHostID, + let profile = state.hosts.first(where: { $0.id == selectedHostID }) + { + setEditorFields(from: profile, in: &state) + } + persistHosts(state.hosts) + return .none + + case .binding: + state.validationMessage = nil + return .none + + case .alert(.presented(.confirmDelete(let hostID))): + state.alert = nil + guard let index = state.hosts.firstIndex(where: { $0.id == hostID }) else { + return .none + } + state.hosts.remove(at: index) + state.hosts = sortProfiles(state.hosts) + state.validationMessage = nil + persistHosts(state.hosts) + + guard !state.hosts.isEmpty else { + clearEditorFields(in: &state) + return .none + } + let nextIndex = min(index, state.hosts.count - 1) + let nextProfile = state.hosts[nextIndex] + state.selectedHostID = nextProfile.id + state.isCreating = false + setEditorFields(from: nextProfile, in: &state) + return .none + + case .alert: + state.alert = nil + return .none + } + } + } + + private func loadHosts(_ hosts: [SSHHostProfile], into state: inout State) { + state.hosts = sortProfiles(hosts) + if let selectedHostID = state.selectedHostID, + let selected = state.hosts.first(where: { $0.id == selectedHostID }) + { + state.isCreating = false + setEditorFields(from: selected, in: &state) + return + } + guard let first = state.hosts.first else { + clearEditorFields(in: &state) + return + } + state.selectedHostID = first.id + state.isCreating = false + setEditorFields(from: first, in: &state) + } + + private func persistHosts(_ hosts: [SSHHostProfile]) { + @Shared(.settingsFile) var settingsFile + $settingsFile.withLock { + $0.sshHostProfiles = sortProfiles(hosts) + } + } + + private func sortProfiles(_ hosts: [SSHHostProfile]) -> [SSHHostProfile] { + hosts.sorted { lhs, rhs in + let displayNameOrder = lhs.displayName.localizedStandardCompare(rhs.displayName) + if displayNameOrder == .orderedSame { + return lhs.host.localizedStandardCompare(rhs.host) == .orderedAscending + } + return displayNameOrder == .orderedAscending + } + } + + private func clearEditorFields(in state: inout State) { + state.selectedHostID = nil + state.isCreating = false + state.displayName = "" + state.host = "" + state.user = "" + state.port = "" + state.authMethod = .publicKey + state.validationMessage = nil + } + + private func setEditorFields(from profile: SSHHostProfile, in state: inout State) { + state.displayName = profile.displayName + state.host = profile.host + state.user = profile.user + state.port = profile.port.map(String.init) ?? "" + state.authMethod = profile.authMethod + } + + private func normalizedHost(in state: inout State) -> String? { + let host = state.host.trimmingCharacters(in: .whitespacesAndNewlines) + guard !host.isEmpty else { + state.validationMessage = "Host required." + return nil + } + return host + } + + private func normalizedPort(in state: inout State) -> Int? { + let port = state.port.trimmingCharacters(in: .whitespacesAndNewlines) + guard !port.isEmpty else { + return nil + } + guard let parsed = Int(port), (1 ... 65_535).contains(parsed) else { + state.validationMessage = "Port must be between 1 and 65535." + return nil + } + return parsed + } + + private func remoteBindingCount(for hostID: SSHHostProfile.ID) -> Int { + @Shared(.repositoryEntries) var repositoryEntries + return repositoryEntries.reduce(into: 0) { count, entry in + if case .remote(let boundHostID, _) = entry.endpoint, boundHostID == hostID { + count += 1 + } + } + } + + private func deleteConfirmationAlert(for profile: SSHHostProfile) -> AlertState { + AlertState { + TextState("Delete SSH host?") + } actions: { + ButtonState(role: .destructive, action: .confirmDelete(profile.id)) { + TextState("Delete") + } + ButtonState(role: .cancel) { + TextState("Cancel") + } + } message: { + TextState("Delete \(profile.displayName)? Remote repositories using this host stay configured.") + } + } +} diff --git a/supacode/Features/Settings/Reducer/SettingsFeature.swift b/supacode/Features/Settings/Reducer/SettingsFeature.swift index 6b69115ec..fe8aacac8 100644 --- a/supacode/Features/Settings/Reducer/SettingsFeature.swift +++ b/supacode/Features/Settings/Reducer/SettingsFeature.swift @@ -26,6 +26,7 @@ struct SettingsFeature { var defaultWorktreeBaseDirectoryPath: String var terminalFontSize: Float32? var selection: SettingsSection? = .general + var sshHosts = SSHHostsFeature.State() var repositorySettings: RepositorySettingsFeature.State? @Presents var alert: AlertState? @@ -90,6 +91,7 @@ struct SettingsFeature { case setCommandFinishedNotificationThreshold(String) case setTerminalFontSize(Float32?) case showNotificationPermissionAlert(errorMessage: String?) + case sshHosts(SSHHostsFeature.Action) case repositorySettings(RepositorySettingsFeature.Action) case alert(PresentationAction) case delegate(Delegate) @@ -214,6 +216,12 @@ struct SettingsFeature { case .setSelection(let selection): state.selection = selection ?? .general + if case .sshHosts = state.selection { + return .send(.sshHosts(.task)) + } + return .none + + case .sshHosts: return .none case .alert(.dismiss): @@ -239,6 +247,9 @@ struct SettingsFeature { .ifLet(\.repositorySettings, action: \.repositorySettings) { RepositorySettingsFeature() } + Scope(state: \.sshHosts, action: \.sshHosts) { + SSHHostsFeature() + } } private func persist( diff --git a/supacode/Features/Settings/Views/RepositorySettingsView.swift b/supacode/Features/Settings/Views/RepositorySettingsView.swift index d31ecedff..2523f00fb 100644 --- a/supacode/Features/Settings/Views/RepositorySettingsView.swift +++ b/supacode/Features/Settings/Views/RepositorySettingsView.swift @@ -17,6 +17,29 @@ struct RepositorySettingsView: View { ) let exampleWorktreePath = store.exampleWorktreePath Form { + if let remotePath = store.remotePath { + Section { + VStack(alignment: .leading, spacing: 6) { + Text(store.remoteHostDisplayName ?? "Remote Host Profile Missing") + .font(.headline) + if let destination = store.remoteHostDestination { + Text(destination) + .foregroundStyle(.secondary) + .monospaced() + } else if let hostProfileID = store.remoteHostProfileID { + Text("Missing profile id: \(hostProfileID)") + .foregroundStyle(.secondary) + .monospaced() + } + Text(remotePath) + .foregroundStyle(.secondary) + .monospaced() + } + .frame(maxWidth: .infinity, alignment: .leading) + } header: { + Text("Remote Repository") + } + } if store.showsWorktreeSettings { Section { if store.isBranchDataLoaded { diff --git a/supacode/Features/Settings/Views/SSHHostsSettingsView.swift b/supacode/Features/Settings/Views/SSHHostsSettingsView.swift new file mode 100644 index 000000000..c43430a06 --- /dev/null +++ b/supacode/Features/Settings/Views/SSHHostsSettingsView.swift @@ -0,0 +1,107 @@ +import ComposableArchitecture +import SwiftUI + +struct SSHHostsSettingsView: View { + @Bindable var store: StoreOf + + var body: some View { + HStack(alignment: .top, spacing: 16) { + VStack(alignment: .leading, spacing: 10) { + List(selection: hostSelection) { + ForEach(store.hosts) { profile in + VStack(alignment: .leading, spacing: 2) { + Text(profile.displayName) + Text(hostSubtitle(profile)) + .font(.footnote.monospaced()) + .foregroundStyle(.secondary) + } + .tag(Optional(profile.id)) + } + } + .frame(minWidth: 250, maxWidth: 280) + + HStack { + Button { + store.send(.addHostTapped) + } label: { + Label("Add Host", systemImage: "plus") + } + .help("Add a new SSH host profile") + + Button { + store.send(.deleteHostTapped) + } label: { + Label("Delete Host", systemImage: "trash") + } + .help("Delete the selected SSH host profile") + .disabled(store.selectedHostID == nil) + } + } + .frame(maxHeight: .infinity, alignment: .top) + + VStack(alignment: .leading, spacing: 12) { + if store.isCreating || store.selectedHostID != nil { + Form { + Section("Host Details") { + TextField("Display name", text: $store.displayName) + .textFieldStyle(.roundedBorder) + TextField("Host", text: $store.host) + .textFieldStyle(.roundedBorder) + TextField("User", text: $store.user) + .textFieldStyle(.roundedBorder) + TextField("Port (optional)", text: $store.port) + .textFieldStyle(.roundedBorder) + Picker("Authentication", selection: $store.authMethod) { + Text("Public Key") + .tag(SSHHostProfile.AuthMethod.publicKey) + Text("Password") + .tag(SSHHostProfile.AuthMethod.password) + } + .pickerStyle(.segmented) + } + } + .formStyle(.grouped) + + if let validationMessage = store.validationMessage, !validationMessage.isEmpty { + Text(validationMessage) + .foregroundStyle(.red) + } + + Button(store.isCreating ? "Create Host" : "Save Changes") { + store.send(.saveButtonTapped) + } + .buttonStyle(.borderedProminent) + .help(store.isCreating ? "Create this host profile" : "Save changes to this host profile") + } else { + Text("Select an SSH host profile or add a new one.") + .foregroundStyle(.secondary) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + .onAppear { + store.send(.task) + } + .alert(store: store.scope(state: \.$alert, action: \.alert)) + } + + private var hostSelection: Binding { + Binding( + get: { store.selectedHostID }, + set: { store.send(.hostSelected($0)) } + ) + } + + private func hostSubtitle(_ profile: SSHHostProfile) -> String { + let hostValue = + if profile.user.isEmpty { + profile.host + } else { + "\(profile.user)@\(profile.host)" + } + if let port = profile.port { + return "\(hostValue):\(port)" + } + return hostValue + } +} diff --git a/supacode/Features/Settings/Views/SettingsSection.swift b/supacode/Features/Settings/Views/SettingsSection.swift index 50db17ed7..bd99b1d6e 100644 --- a/supacode/Features/Settings/Views/SettingsSection.swift +++ b/supacode/Features/Settings/Views/SettingsSection.swift @@ -4,6 +4,7 @@ enum SettingsSection: Hashable { case general case notifications case worktree + case sshHosts case updates case advanced case github diff --git a/supacode/Features/Settings/Views/SettingsView.swift b/supacode/Features/Settings/Views/SettingsView.swift index 891fcdeb2..4edac5265 100644 --- a/supacode/Features/Settings/Views/SettingsView.swift +++ b/supacode/Features/Settings/Views/SettingsView.swift @@ -35,6 +35,8 @@ struct SettingsView: View { .tag(SettingsSection.notifications) Label("Worktree", systemImage: "archivebox") .tag(SettingsSection.worktree) + Label("SSH Hosts", systemImage: "network") + .tag(SettingsSection.sshHosts) Label("Updates", systemImage: "arrow.down.circle") .tag(SettingsSection.updates) Label("Advanced", systemImage: "gearshape.2") @@ -74,6 +76,12 @@ struct SettingsView: View { .navigationTitle("Worktree") .navigationSubtitle("Archive behavior") } + case .sshHosts: + SettingsDetailView { + SSHHostsSettingsView(store: settingsStore.scope(state: \.sshHosts, action: \.sshHosts)) + .navigationTitle("SSH Hosts") + .navigationSubtitle("Shared host profiles for remote repositories") + } case .updates: SettingsDetailView { UpdatesSettingsView(settingsStore: settingsStore, updatesStore: updatesStore) diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift index 4c0432026..6538b0575 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift @@ -880,7 +880,11 @@ final class WorktreeTerminalState { updateTabTitle(for: tabId) guard tabId == tabManager.selectedTabId else { return } let fromSurface = (previousSurface === surface) ? nil : previousSurface - GhosttySurfaceView.moveFocus(to: surface, from: fromSurface) + GhosttySurfaceView.moveFocus( + to: surface, + from: fromSurface, + respectsActiveTextInput: true + ) emitFocusChangedIfNeeded(surface.id) } diff --git a/supacode/Features/Terminal/Views/WorktreeTerminalTabsView.swift b/supacode/Features/Terminal/Views/WorktreeTerminalTabsView.swift index 308712457..b49b47c68 100644 --- a/supacode/Features/Terminal/Views/WorktreeTerminalTabsView.swift +++ b/supacode/Features/Terminal/Views/WorktreeTerminalTabsView.swift @@ -8,6 +8,7 @@ struct WorktreeTerminalTabsView: View { let forceAutoFocus: Bool let createTab: () -> Void @State private var windowActivity = WindowActivityState.inactive + @State private var deferredFocusTask: Task? var body: some View { let state = manager.state(for: worktree) { shouldRunSetupScript } @@ -58,6 +59,7 @@ struct WorktreeTerminalTabsView: View { } let activity = resolvedWindowActivity state.syncFocus(windowIsKey: activity.isKeyWindow, windowIsVisible: activity.isVisible) + scheduleDeferredFocusSync(for: state) } .onChange(of: state.tabManager.selectedTabId) { _, _ in if shouldAutoFocusTerminal { @@ -66,14 +68,18 @@ struct WorktreeTerminalTabsView: View { let activity = resolvedWindowActivity state.syncFocus(windowIsKey: activity.isKeyWindow, windowIsVisible: activity.isVisible) } + .onDisappear { + deferredFocusTask?.cancel() + deferredFocusTask = nil + } } private var shouldAutoFocusTerminal: Bool { - if forceAutoFocus { - return true - } - guard let responder = NSApp.keyWindow?.firstResponder else { return true } - return !(responder is NSTableView) && !(responder is NSOutlineView) + Self.shouldAutoFocusTerminal( + firstResponder: NSApp.keyWindow?.firstResponder, + forceAutoFocus: forceAutoFocus, + respectsActiveTextInput: true + ) } private var resolvedWindowActivity: WindowActivityState { @@ -85,4 +91,44 @@ struct WorktreeTerminalTabsView: View { } return windowActivity } + + private func scheduleDeferredFocusSync(for state: WorktreeTerminalState) { + deferredFocusTask?.cancel() + deferredFocusTask = Task { @MainActor in + await Self.nextMainRunLoopTurn() + guard !Task.isCancelled else { return } + if Self.shouldAutoFocusTerminal( + firstResponder: NSApp.keyWindow?.firstResponder, + forceAutoFocus: forceAutoFocus, + respectsActiveTextInput: true + ) { + state.focusSelectedTab() + } + let activity = resolvedWindowActivity + state.syncFocus(windowIsKey: activity.isKeyWindow, windowIsVisible: activity.isVisible) + } + } + + private static func nextMainRunLoopTurn() async { + await withCheckedContinuation { continuation in + DispatchQueue.main.async { + continuation.resume() + } + } + } + + static func shouldAutoFocusTerminal( + firstResponder: NSResponder?, + forceAutoFocus: Bool, + respectsActiveTextInput: Bool + ) -> Bool { + if respectsActiveTextInput, firstResponder is NSTextView { + return false + } + if forceAutoFocus { + return true + } + guard let responder = firstResponder else { return true } + return !(responder is NSTableView) && !(responder is NSOutlineView) + } } diff --git a/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift b/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift index 99ea6411e..827c2b2ea 100644 --- a/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift +++ b/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift @@ -960,7 +960,8 @@ final class GhosttySurfaceView: NSView, Identifiable { static func moveFocus( to view: GhosttySurfaceView, from previous: GhosttySurfaceView? = nil, - delay: TimeInterval? = nil + delay: TimeInterval? = nil, + respectsActiveTextInput: Bool = false ) { let maxDelay: TimeInterval = 0.5 let currentDelay = delay ?? 0 @@ -971,7 +972,19 @@ final class GhosttySurfaceView: NSView, Identifiable { try? await ContinuousClock().sleep(for: .seconds(delay)) } guard let window = view.window else { - moveFocus(to: view, from: previous, delay: nextDelay) + moveFocus( + to: view, + from: previous, + delay: nextDelay, + respectsActiveTextInput: respectsActiveTextInput + ) + return + } + if respectsActiveTextInput, + let firstResponder = window.firstResponder, + firstResponder is NSTextView, + !(firstResponder is GhosttySurfaceView) + { return } if let previous, previous !== view { @@ -1749,7 +1762,7 @@ final class GhosttySurfaceScrollView: NSView { /// When set, the surface renders at this fixed size regardless of the hosting /// view's bounds. Used in canvas mode to prevent `.scaleEffect()` from causing /// terminal reflow. - var pinnedSize: CGSize? + private(set) var pinnedSize: CGSize? init(surfaceView: GhosttySurfaceView) { self.surfaceView = surfaceView @@ -1856,6 +1869,14 @@ final class GhosttySurfaceScrollView: NSView { surfaceView.updateSurfaceSize() } + func setPinnedSize(_ pinnedSize: CGSize?) { + guard self.pinnedSize != pinnedSize else { return } + self.pinnedSize = pinnedSize + needsLayout = true + layoutSubtreeIfNeeded() + surfaceView.updateSurfaceSize() + } + func updateSurfaceSize() { surfaceView.updateSurfaceSize() needsLayout = true diff --git a/supacode/Infrastructure/Ghostty/GhosttyTerminalView.swift b/supacode/Infrastructure/Ghostty/GhosttyTerminalView.swift index e70bb328b..83300d545 100644 --- a/supacode/Infrastructure/Ghostty/GhosttyTerminalView.swift +++ b/supacode/Infrastructure/Ghostty/GhosttyTerminalView.swift @@ -6,11 +6,11 @@ struct GhosttyTerminalView: NSViewRepresentable { func makeNSView(context: Context) -> GhosttySurfaceScrollView { let view = GhosttySurfaceScrollView(surfaceView: surfaceView) - view.pinnedSize = pinnedSize + view.setPinnedSize(pinnedSize) return view } func updateNSView(_ view: GhosttySurfaceScrollView, context: Context) { - view.pinnedSize = pinnedSize + view.setPinnedSize(pinnedSize) } } diff --git a/supacode/Infrastructure/SSH/SSHCommandSupport.swift b/supacode/Infrastructure/SSH/SSHCommandSupport.swift new file mode 100644 index 000000000..c6c1b8482 --- /dev/null +++ b/supacode/Infrastructure/SSH/SSHCommandSupport.swift @@ -0,0 +1,113 @@ +import CryptoKit +import Foundation + +nonisolated enum SSHCommandSupport { + static let connectTimeoutSeconds = 8 + static let serverAliveIntervalSeconds = 5 + static let serverAliveCountMax = 3 + static let bootstrapTimeoutSeconds = 20 + static let controlSocketHashLength = 20 + + static func connectivityOptions(includeBatchMode: Bool = true) -> [String] { + var options = [ + "-o", "ConnectTimeout=\(connectTimeoutSeconds)", + "-o", "ServerAliveInterval=\(serverAliveIntervalSeconds)", + "-o", "ServerAliveCountMax=\(serverAliveCountMax)", + "-o", "ControlMaster=auto", + "-o", "ControlPersist=600", + ] + + if includeBatchMode { + options = ["-o", "BatchMode=yes"] + options + } + + return options + } + + static func controlSocketPath( + endpointKey: String, + temporaryDirectory _: String = NSTemporaryDirectory() + ) -> String { + let hashData = SHA256.hash(data: Data(endpointKey.utf8)) + let hash = hashData.map { String(format: "%02x", $0) }.joined() + return "/tmp/prowl-ssh-\(String(hash.prefix(controlSocketHashLength))).sock" + } + + static func ensureControlSocketDirectory(for controlPath: String) throws { + let directory = URL(fileURLWithPath: controlPath).deletingLastPathComponent() + let directoryPath = directory.path(percentEncoded: false) + if directoryPath.isEmpty || directoryPath == "/" { + return + } + try FileManager.default.createDirectory( + atPath: directoryPath, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + } + + static func removingBatchMode(from options: [String]) -> [String] { + var filtered: [String] = [] + var index = 0 + while index < options.count { + if index + 1 < options.count, + options[index] == "-o", + options[index + 1].lowercased() == "batchmode=yes" + { + index += 2 + continue + } + + filtered.append(options[index]) + index += 1 + } + return filtered + } + + static func shellEscape(_ value: String) -> String { + "'\(value.replacing("'", with: "'\\''"))'" + } + + static func makeAskpassSupport(password: String) throws -> SSHAskpassSupport { + let scriptName = "prowl-ssh-askpass-\(UUID().uuidString).sh" + let scriptURL = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + .appending(path: scriptName) + let script = """ + #!/bin/sh + printf '%s\\n' \(shellEscape(password)) + """ + let wrote = FileManager.default.createFile( + atPath: scriptURL.path(percentEncoded: false), + contents: Data(script.utf8), + attributes: [.posixPermissions: 0o700] + ) + guard wrote else { + throw SSHCommandSupportError.createAskpassScriptFailed + } + + return SSHAskpassSupport( + environment: [ + "DISPLAY": ":0", + "SSH_ASKPASS": scriptURL.path(percentEncoded: false), + "SSH_ASKPASS_REQUIRE": "force", + ], + helperURL: scriptURL + ) + } +} + +nonisolated struct SSHAskpassSupport: Sendable { + let environment: [String: String] + let helperURL: URL +} + +private enum SSHCommandSupportError: LocalizedError { + case createAskpassScriptFailed + + var errorDescription: String? { + switch self { + case .createAskpassScriptFailed: + "Could not prepare SSH askpass helper." + } + } +} diff --git a/supacodeTests/CanvasInitialFocusResolverTests.swift b/supacodeTests/CanvasInitialFocusResolverTests.swift new file mode 100644 index 000000000..00faf68ca --- /dev/null +++ b/supacodeTests/CanvasInitialFocusResolverTests.swift @@ -0,0 +1,83 @@ +import Foundation +import Testing + +@testable import supacode + +struct CanvasInitialFocusResolverTests { + private let tab1 = TerminalTabID(rawValue: UUID(uuidString: "00000000-0000-0000-0000-000000000001")!) + private let tab2 = TerminalTabID(rawValue: UUID(uuidString: "00000000-0000-0000-0000-000000000002")!) + private let tab3 = TerminalTabID(rawValue: UUID(uuidString: "00000000-0000-0000-0000-000000000003")!) + private let surface1 = UUID(uuidString: "10000000-0000-0000-0000-000000000001")! + private let surface2 = UUID(uuidString: "10000000-0000-0000-0000-000000000002")! + private let surface3 = UUID(uuidString: "10000000-0000-0000-0000-000000000003")! + + @Test func prefersSelectedTabFromPreferredWorktree() { + let result = CanvasInitialFocusResolver.initialFocus( + preferredSurfaceID: nil, + preferredWorktreeID: "repo-2", + candidates: [ + .init(worktreeID: "repo-1", tabID: tab1, focusedSurfaceID: surface1, isSelectedTab: true), + .init(worktreeID: "repo-2", tabID: tab2, focusedSurfaceID: surface2, isSelectedTab: true), + .init(worktreeID: "repo-2", tabID: tab3, focusedSurfaceID: surface3, isSelectedTab: false), + ] + ) + + #expect(result?.tabID == tab2) + #expect(result?.surfaceID == surface2) + } + + @Test func fallsBackToFirstVisibleTabWhenPreferredWorktreeMissing() { + let result = CanvasInitialFocusResolver.initialFocus( + preferredSurfaceID: nil, + preferredWorktreeID: "missing", + candidates: [ + .init(worktreeID: "repo-1", tabID: tab1, focusedSurfaceID: surface1, isSelectedTab: true), + .init(worktreeID: "repo-2", tabID: tab2, focusedSurfaceID: surface2, isSelectedTab: true), + .init(worktreeID: "repo-2", tabID: tab3, focusedSurfaceID: surface3, isSelectedTab: false), + ] + ) + + #expect(result?.tabID == tab1) + #expect(result?.surfaceID == surface1) + } + + @Test func fallsBackWhenPreferredWorktreeHasNoSelectedTab() { + let result = CanvasInitialFocusResolver.initialFocus( + preferredSurfaceID: nil, + preferredWorktreeID: "repo-2", + candidates: [ + .init(worktreeID: "repo-1", tabID: tab1, focusedSurfaceID: surface1, isSelectedTab: true), + .init(worktreeID: "repo-2", tabID: tab2, focusedSurfaceID: surface2, isSelectedTab: false), + .init(worktreeID: "repo-2", tabID: tab3, focusedSurfaceID: surface3, isSelectedTab: false), + ] + ) + + #expect(result?.tabID == tab1) + #expect(result?.surfaceID == surface1) + } + + @Test func returnsNilWithoutVisibleTabs() { + let result = CanvasInitialFocusResolver.initialFocus( + preferredSurfaceID: nil, + preferredWorktreeID: "repo-1", + candidates: [] + ) + + #expect(result == nil) + } + + @Test func prefersResponderSurfaceEvenWhenWorktreeFallbackDiffers() { + let result = CanvasInitialFocusResolver.initialFocus( + preferredSurfaceID: surface3, + preferredWorktreeID: "repo-1", + candidates: [ + .init(worktreeID: "repo-1", tabID: tab1, focusedSurfaceID: surface1, isSelectedTab: true), + .init(worktreeID: "repo-2", tabID: tab2, focusedSurfaceID: surface2, isSelectedTab: true), + .init(worktreeID: "repo-2", tabID: tab3, focusedSurfaceID: surface3, isSelectedTab: false), + ] + ) + + #expect(result?.tabID == tab3) + #expect(result?.surfaceID == surface3) + } +} diff --git a/supacodeTests/GhosttySurfaceViewTests.swift b/supacodeTests/GhosttySurfaceViewTests.swift index 639faa537..493a40de8 100644 --- a/supacodeTests/GhosttySurfaceViewTests.swift +++ b/supacodeTests/GhosttySurfaceViewTests.swift @@ -1,3 +1,4 @@ +import AppKit import Foundation import Testing @@ -72,4 +73,36 @@ struct GhosttySurfaceViewTests { ) == nil ) } + + @Test func setPinnedSizePreservesUserScrollbackPosition() throws { + let runtime = GhosttyRuntime() + let worktree = Worktree( + id: "/tmp/repo/wt", + name: "wt", + detail: "detail", + workingDirectory: URL(fileURLWithPath: "/tmp/repo/wt"), + repositoryRootURL: URL(fileURLWithPath: "/tmp/repo") + ) + let state = WorktreeTerminalState(runtime: runtime, worktree: worktree) + let tabId = try #require(state.createTab()) + let surfaceView = try #require(state.surfaceView(for: tabId)) + let scrollWrapper = GhosttySurfaceScrollView(surfaceView: surfaceView) + scrollWrapper.frame = CGRect(x: 0, y: 0, width: 400, height: 200) + scrollWrapper.layoutSubtreeIfNeeded() + surfaceView.updateCellSize(width: 10, height: 10) + scrollWrapper.updateScrollbar(total: 100, offset: 0, length: 10) + scrollWrapper.layoutSubtreeIfNeeded() + + let scrollView = try #require(scrollWrapper.subviews.first as? NSScrollView) + scrollView.contentView.scroll(to: CGPoint(x: 0, y: 200)) + scrollView.reflectScrolledClipView(scrollView.contentView) + + let scrolledY = scrollView.contentView.documentVisibleRect.origin.y + #expect(scrolledY == 200) + + scrollWrapper.setPinnedSize(CGSize(width: 320, height: 200)) + + let updatedY = scrollView.contentView.documentVisibleRect.origin.y + #expect(abs(updatedY - scrolledY) < 0.5) + } } diff --git a/supacodeTests/GitClientRemoteCommandTests.swift b/supacodeTests/GitClientRemoteCommandTests.swift new file mode 100644 index 000000000..c235d62ef --- /dev/null +++ b/supacodeTests/GitClientRemoteCommandTests.swift @@ -0,0 +1,146 @@ +import Foundation +import Testing + +@testable import supacode + +private actor RemoteCommandRecorder { + private var commandsValue: [String] = [] + + func append(_ command: String) { + commandsValue.append(command) + } + + func commands() -> [String] { + commandsValue + } +} + +private extension ShellClient { + static let failing = ShellClient( + run: { _, _, _ in + Issue.record("Expected remote endpoint test not to use local shell execution") + return ShellOutput(stdout: "", stderr: "", exitCode: 0) + }, + runLoginImpl: { _, _, _, _ in + Issue.record("Expected remote endpoint test not to use local login shell execution") + return ShellOutput(stdout: "", stderr: "", exitCode: 0) + } + ) +} + +private extension RemoteExecutionClient { + static func capturingSuccess( + stdout: String, + recorder: RemoteCommandRecorder + ) -> RemoteExecutionClient { + RemoteExecutionClient( + run: { _, command, _ in + await recorder.append(command) + return Output(stdout: stdout, stderr: "", exitCode: 0) + } + ) + } +} + +struct GitClientRemoteCommandTests { + private let remoteProfile = SSHHostProfile( + id: "h1", + displayName: "Server", + host: "example.com", + user: "dev", + authMethod: .publicKey + ) + + @Test func remoteWorktreeListUsesSSHGitCRemotePath() async throws { + let recorder = RemoteCommandRecorder() + let client = GitClient( + shell: .failing, + remoteExecution: RemoteExecutionClient.capturingSuccess( + stdout: "", + recorder: recorder + ) + ) + + _ = try await client.worktrees( + for: URL(fileURLWithPath: "/synthetic"), + endpoint: RepositoryEndpoint.remote(hostProfileID: "h1", remotePath: "/srv/repo"), + hostProfile: remoteProfile + ) + + let commands = await recorder.commands() + #expect( + commands.contains(where: { + $0.contains("git -C '/srv/repo' worktree list") + }) + ) + } + + @Test func remoteWorktreeCreateUsesSSHGitCRemotePath() async throws { + let recorder = RemoteCommandRecorder() + let client = GitClient( + shell: .failing, + remoteExecution: RemoteExecutionClient.capturingSuccess( + stdout: "", + recorder: recorder + ) + ) + + let stream = client.createWorktreeStream( + named: "feature/remote", + in: URL(fileURLWithPath: "/synthetic"), + baseDirectory: URL(fileURLWithPath: "/srv/worktrees"), + copyFiles: (ignored: false, untracked: false), + baseRef: "origin/main", + endpoint: RepositoryEndpoint.remote(hostProfileID: "h1", remotePath: "/srv/repo"), + hostProfile: remoteProfile + ) + for try await _ in stream {} + + let commands = await recorder.commands() + #expect( + commands.contains(where: { + $0.contains("git -C '/srv/repo' worktree add -b feature/remote /srv/worktrees/feature/remote/ origin/main") + && $0.contains("printf '%s\\n' '/srv/worktrees/feature/remote/'") + }) + ) + } + + @Test func remoteWorktreeRemoveUsesSSHGitCRemotePath() async throws { + let recorder = RemoteCommandRecorder() + let client = GitClient( + shell: .failing, + remoteExecution: RemoteExecutionClient.capturingSuccess( + stdout: "", + recorder: recorder + ) + ) + let endpoint = RepositoryEndpoint.remote(hostProfileID: "h1", remotePath: "/srv/repo") + let worktree = Worktree( + id: "/srv/worktrees/feature/remote", + name: "feature/remote", + detail: "../worktrees/feature/remote", + workingDirectory: URL(fileURLWithPath: "/srv/worktrees/feature/remote"), + repositoryRootURL: URL(fileURLWithPath: "/synthetic"), + endpoint: endpoint + ) + + _ = try await client.removeWorktree( + worktree, + deleteBranch: true, + endpoint: endpoint, + hostProfile: remoteProfile + ) + + let commands = await recorder.commands() + #expect( + commands.contains(where: { + $0.contains("git -C '/srv/repo' worktree remove --force /srv/worktrees/feature/remote") + }) + ) + #expect( + commands.contains(where: { + $0.contains("git -C '/srv/repo' branch -D feature/remote") + }) + ) + } +} diff --git a/supacodeTests/KeychainClientTests.swift b/supacodeTests/KeychainClientTests.swift new file mode 100644 index 000000000..182303191 --- /dev/null +++ b/supacodeTests/KeychainClientTests.swift @@ -0,0 +1,18 @@ +import Foundation +import Testing + +@testable import supacode + +struct KeychainClientTests { + @Test func keychainRoundTrip() async throws { + let key = "supacode.tests.ssh.profile.\(UUID().uuidString)" + let client = KeychainClient.liveValue + + try await client.savePassword("secret", key) + let loaded = try await client.loadPassword(key) + #expect(loaded == "secret") + try await client.deletePassword(key) + let deleted = try await client.loadPassword(key) + #expect(deleted == nil) + } +} diff --git a/supacodeTests/RemoteConnectFeatureTests.swift b/supacodeTests/RemoteConnectFeatureTests.swift new file mode 100644 index 000000000..64c885684 --- /dev/null +++ b/supacodeTests/RemoteConnectFeatureTests.swift @@ -0,0 +1,632 @@ +import ComposableArchitecture +import Foundation +import Testing + +@testable import supacode + +nonisolated final class StringDictionaryRecorder: @unchecked Sendable { + private let lock = NSLock() + private var values: [String: String] = [:] + + func set(_ key: String, value: String) { + lock.lock() + values[key] = value + lock.unlock() + } + + func snapshot() -> [String: String] { + lock.lock() + let snapshot = values + lock.unlock() + return snapshot + } +} + +nonisolated final class StringArrayRecorder: @unchecked Sendable { + private let lock = NSLock() + private var values: [String] = [] + + func append(_ value: String) { + lock.lock() + values.append(value) + lock.unlock() + } + + func snapshot() -> [String] { + lock.lock() + let snapshot = values + lock.unlock() + return snapshot + } +} + +nonisolated final class IntRecorder: @unchecked Sendable { + private let lock = NSLock() + private var value = 0 + + func increment() { + lock.lock() + value += 1 + lock.unlock() + } + + func snapshot() -> Int { + lock.lock() + let snapshot = value + lock.unlock() + return snapshot + } +} + +@MainActor +struct RemoteConnectFeatureTests { + @Test func continueButtonTappedRequiresHostBeforeAdvancing() async { + let store = TestStore( + initialState: RemoteConnectFeature.State(savedHostProfiles: []) + ) { + RemoteConnectFeature() + } + + await store.send(.continueButtonTapped) { + $0.validationMessage = "Host required." + } + } + + @Test func continueButtonTappedRequiresPasswordWhenPasswordAuthIsSelected() async { + var state = RemoteConnectFeature.State(savedHostProfiles: []) + state.host = "example.com" + state.user = "deploy" + state.authMethod = .password + + let store = TestStore(initialState: state) { + RemoteConnectFeature() + } withDependencies: { + $0.date = .constant(Date(timeIntervalSince1970: 1)) + $0.uuid = .constant(UUID(uuidString: "00000000-0000-0000-0000-000000000123")!) + $0.keychainClient = .testValue + } + + await store.send(.continueButtonTapped) { + $0.connectionHostProfileID = "00000000-0000-0000-0000-000000000123" + $0.connectionHostProfileEndpointKey = "example.com|deploy|" + } + await store.receive(.hostValidationFailed("Password required.")) { + $0.validationMessage = "Password required." + } + } + + @Test func continueButtonTappedSavesPasswordAndAdvancesWhenPasswordAuthIsSelected() async { + let savedPasswords = StringDictionaryRecorder() + var state = RemoteConnectFeature.State(savedHostProfiles: []) + state.host = "example.com" + state.user = "deploy" + state.authMethod = .password + state.password = "secret" + + let store = TestStore(initialState: state) { + RemoteConnectFeature() + } withDependencies: { + $0.date = .constant(Date(timeIntervalSince1970: 2)) + $0.uuid = .constant(UUID(uuidString: "00000000-0000-0000-0000-000000000123")!) + $0.keychainClient = KeychainClient( + savePassword: { password, key in + savedPasswords.set(key, value: password) + }, + loadPassword: { key in + savedPasswords.snapshot()[key] + }, + deletePassword: { _ in } + ) + } + + await store.send(.continueButtonTapped) { + $0.connectionHostProfileID = "00000000-0000-0000-0000-000000000123" + $0.connectionHostProfileEndpointKey = "example.com|deploy|" + } + await store.receive(.hostValidationSucceeded) { + $0.step = .repository + $0.validationMessage = nil + } + + #expect(savedPasswords.snapshot()["00000000-0000-0000-0000-000000000123"] == "secret") + } + + @Test func editingSavedHostFieldsUsesNewPasswordKeychainID() async { + let selectedProfile = SSHHostProfile( + id: "host-1", + displayName: "Build Box", + host: "example.com", + user: "deploy", + port: 2222, + authMethod: .password, + createdAt: Date(timeIntervalSince1970: 10), + updatedAt: Date(timeIntervalSince1970: 10) + ) + let keychainLookups = StringArrayRecorder() + var state = RemoteConnectFeature.State(savedHostProfiles: [selectedProfile]) + state.step = .host + state.selectedHostProfileID = selectedProfile.id + state.displayName = selectedProfile.displayName + state.host = "staging.example.com" + state.user = selectedProfile.user + state.port = "2222" + state.authMethod = .password + + let store = TestStore(initialState: state) { + RemoteConnectFeature() + } withDependencies: { + $0.date = .constant(Date(timeIntervalSince1970: 11)) + $0.uuid = .constant(UUID(uuidString: "00000000-0000-0000-0000-000000000321")!) + $0.keychainClient = KeychainClient( + savePassword: { _, _ in }, + loadPassword: { key in + keychainLookups.append(key) + return nil + }, + deletePassword: { _ in } + ) + } + + await store.send(.continueButtonTapped) { + $0.connectionHostProfileID = "00000000-0000-0000-0000-000000000321" + $0.connectionHostProfileEndpointKey = "staging.example.com|deploy|2222" + } + await store.receive(.hostValidationFailed("Password required.")) { + $0.validationMessage = "Password required." + } + + #expect(keychainLookups.snapshot() == ["00000000-0000-0000-0000-000000000321"]) + } + + @Test func selectingSavedHostAdvancesToRepositoryStep() async { + let createdAt = Date(timeIntervalSince1970: 10) + let profile = SSHHostProfile( + id: "host-1", + displayName: "Build Box", + host: "example.com", + user: "deploy", + port: 2222, + authMethod: .publicKey, + createdAt: createdAt, + updatedAt: createdAt + ) + let store = TestStore( + initialState: RemoteConnectFeature.State(savedHostProfiles: [profile]) + ) { + RemoteConnectFeature() + } + + await store.send(.savedHostProfileSelected(profile.id)) { + $0.selectedHostProfileID = profile.id + $0.displayName = profile.displayName + $0.host = profile.host + $0.user = profile.user + $0.port = "2222" + $0.authMethod = .publicKey + } + await store.send(.continueButtonTapped) { + $0.step = .repository + } + } + + @Test func browseRemoteFoldersNavigatesAndChoosesCurrentFolder() async { + let commands = StringArrayRecorder() + var state = RemoteConnectFeature.State(savedHostProfiles: []) + state.step = .repository + state.host = "example.com" + state.user = "deploy" + + let store = TestStore(initialState: state) { + RemoteConnectFeature() + } withDependencies: { + $0.date = .constant(Date(timeIntervalSince1970: 3)) + $0.uuid = .incrementing + $0.remoteExecutionClient.run = { _, command, _ in + commands.append(command) + if command.contains("/Users/deploy/src") { + return RemoteExecutionClient.Output( + stdout: "/Users/deploy/src\n/Users/deploy/src/project\n", + stderr: "", + exitCode: 0 + ) + } + return RemoteExecutionClient.Output( + stdout: "/Users/deploy\n/Users/deploy/src\n/Users/deploy/work\n", + stderr: "", + exitCode: 0 + ) + } + } + + let rootRequestID = UUID(uuidString: "00000000-0000-0000-0000-000000000001")! + let childRequestID = UUID(uuidString: "00000000-0000-0000-0000-000000000002")! + + await store.send(.browseRemoteFoldersButtonTapped) { + $0.connectionHostProfileID = "00000000-0000-0000-0000-000000000000" + $0.connectionHostProfileEndpointKey = "example.com|deploy|" + $0.activeBrowseRequestID = rootRequestID + $0.directoryBrowser = RemoteConnectFeature.DirectoryBrowserState( + currentPath: "", + childDirectories: [], + isLoading: true, + errorMessage: nil + ) + } + await store.receive( + .remoteDirectoryListingLoaded( + rootRequestID, + RemoteConnectFeature.DirectoryListing( + currentPath: "/Users/deploy", + childDirectories: [ + "/Users/deploy/src", + "/Users/deploy/work", + ] + ) + ) + ) { + $0.activeBrowseRequestID = nil + $0.directoryBrowser = RemoteConnectFeature.DirectoryBrowserState( + currentPath: "/Users/deploy", + childDirectories: [ + "/Users/deploy/src", + "/Users/deploy/work", + ], + isLoading: false, + errorMessage: nil + ) + } + + await store.send(.directoryBrowserEntryTapped("/Users/deploy/src")) { + $0.activeBrowseRequestID = childRequestID + $0.directoryBrowser?.isLoading = true + $0.directoryBrowser?.errorMessage = nil + } + await store.receive( + .remoteDirectoryListingLoaded( + childRequestID, + RemoteConnectFeature.DirectoryListing( + currentPath: "/Users/deploy/src", + childDirectories: [ + "/Users/deploy/src/project" + ] + ) + ) + ) { + $0.activeBrowseRequestID = nil + $0.directoryBrowser = RemoteConnectFeature.DirectoryBrowserState( + currentPath: "/Users/deploy/src", + childDirectories: [ + "/Users/deploy/src/project" + ], + isLoading: false, + errorMessage: nil + ) + } + + await store.send(.directoryBrowserChooseCurrentFolderButtonTapped) { + $0.remotePath = "/Users/deploy/src" + $0.directoryBrowser = nil + $0.activeBrowseRequestID = nil + } + + let recordedCommands = commands.snapshot() + #expect(recordedCommands.count == 2) + } + + @Test func browseRemoteFoldersRequiresPasswordWhenPasswordAuthIsSelected() async { + var state = RemoteConnectFeature.State(savedHostProfiles: []) + state.step = .repository + state.host = "example.com" + state.user = "deploy" + state.authMethod = .password + + let store = TestStore(initialState: state) { + RemoteConnectFeature() + } withDependencies: { + $0.date = .constant(Date(timeIntervalSince1970: 4)) + $0.uuid = .constant(UUID(uuidString: "00000000-0000-0000-0000-000000000000")!) + $0.keychainClient = .testValue + } + + await store.send(.browseRemoteFoldersButtonTapped) { + $0.connectionHostProfileID = "00000000-0000-0000-0000-000000000000" + $0.connectionHostProfileEndpointKey = "example.com|deploy|" + $0.activeBrowseRequestID = UUID(uuidString: "00000000-0000-0000-0000-000000000000")! + $0.directoryBrowser = RemoteConnectFeature.DirectoryBrowserState( + currentPath: "", + childDirectories: [], + isLoading: true, + errorMessage: nil + ) + } + await store.receive(.hostValidationFailed("Password required.")) { + $0.validationMessage = "Password required." + $0.activeBrowseRequestID = nil + $0.directoryBrowser?.isLoading = false + $0.directoryBrowser?.errorMessage = "Password required." + } + } + + @Test func connectButtonTappedValidatesRemoteRepositoryAndDelegatesSubmission() async { + let createdAt = Date(timeIntervalSince1970: 10) + let now = Date(timeIntervalSince1970: 20) + let profile = SSHHostProfile( + id: "host-1", + displayName: "Build Box", + host: "example.com", + user: "deploy", + port: 2222, + authMethod: .publicKey, + createdAt: createdAt, + updatedAt: createdAt + ) + var state = RemoteConnectFeature.State(savedHostProfiles: [profile]) + state.step = .repository + state.selectedHostProfileID = profile.id + state.displayName = profile.displayName + state.host = profile.host + state.user = profile.user + state.port = "2222" + state.authMethod = .publicKey + state.remotePath = "~/src/repo" + + let store = TestStore(initialState: state) { + RemoteConnectFeature() + } withDependencies: { + $0.remoteExecutionClient.run = { _, _, _ in + RemoteExecutionClient.Output( + stdout: "/home/deploy/src/repo\n", + stderr: "", + exitCode: 0 + ) + } + $0.uuid = .incrementing + $0.date = .constant(now) + } + + let expectedSubmission = RemoteConnectFeature.Submission( + hostProfile: SSHHostProfile( + id: profile.id, + displayName: profile.displayName, + host: profile.host, + user: profile.user, + port: profile.port, + authMethod: profile.authMethod, + createdAt: createdAt, + updatedAt: now + ), + remotePath: "/home/deploy/src/repo" + ) + + await store.send(.connectButtonTapped) { + $0.isSubmitting = true + $0.validationMessage = nil + } + await store.receive(.remoteRepositoryValidated(expectedSubmission)) { + $0.remotePath = "/home/deploy/src/repo" + $0.isSubmitting = false + } + await store.receive(.delegate(.completed(expectedSubmission))) + } + + @Test func repeatedConnectButtonTapWhileSubmittingIsIgnored() async { + let gate = AsyncGate() + let runCount = IntRecorder() + let now = Date(timeIntervalSince1970: 30) + let newProfileID = UUID(uuidString: "00000000-0000-0000-0000-000000000000")!.uuidString + var state = RemoteConnectFeature.State(savedHostProfiles: []) + state.step = .repository + state.host = "example.com" + state.user = "deploy" + state.remotePath = "/srv/repo" + + let store = TestStore(initialState: state) { + RemoteConnectFeature() + } withDependencies: { + $0.date = .constant(now) + $0.uuid = .constant(UUID(uuidString: newProfileID)!) + $0.remoteExecutionClient.run = { _, _, _ in + runCount.increment() + await gate.wait() + return RemoteExecutionClient.Output( + stdout: "/srv/repo\n", + stderr: "", + exitCode: 0 + ) + } + } + + await store.send(.connectButtonTapped) { + $0.isSubmitting = true + $0.validationMessage = nil + $0.connectionHostProfileID = "00000000-0000-0000-0000-000000000000" + $0.connectionHostProfileEndpointKey = "example.com|deploy|" + } + await store.send(.connectButtonTapped) + + #expect(runCount.snapshot() == 1) + + await gate.resume() + + let expectedSubmission = RemoteConnectFeature.Submission( + hostProfile: SSHHostProfile( + id: newProfileID, + displayName: "example.com", + host: "example.com", + user: "deploy", + authMethod: .publicKey, + createdAt: now, + updatedAt: now + ), + remotePath: "/srv/repo" + ) + + await store.receive(.remoteRepositoryValidated(expectedSubmission)) { + $0.remotePath = "/srv/repo" + $0.isSubmitting = false + } + await store.receive(.delegate(.completed(expectedSubmission))) + } + + @Test func staleBrowseResponseIsIgnoredAfterDismiss() async { + let staleRequestID = UUID(uuidString: "00000000-0000-0000-0000-000000000001")! + let activeRequestID = UUID(uuidString: "00000000-0000-0000-0000-000000000002")! + var state = RemoteConnectFeature.State(savedHostProfiles: []) + state.step = .repository + state.host = "example.com" + state.user = "deploy" + state.directoryBrowser = RemoteConnectFeature.DirectoryBrowserState( + currentPath: "/Users/deploy", + childDirectories: [], + isLoading: true, + errorMessage: nil + ) + state.activeBrowseRequestID = staleRequestID + + let store = TestStore(initialState: state) { + RemoteConnectFeature() + } + + await store.send(.directoryBrowserDismissed) { + $0.directoryBrowser = nil + $0.activeBrowseRequestID = nil + } + await store.send( + .remoteDirectoryListingLoaded( + activeRequestID, + RemoteConnectFeature.DirectoryListing( + currentPath: "/tmp", + childDirectories: ["/tmp/repo"] + ) + ) + ) + } + + @Test func staleBrowseResponseIsIgnoredAfterBack() async { + let activeRequestID = UUID(uuidString: "00000000-0000-0000-0000-000000000003")! + var state = RemoteConnectFeature.State(savedHostProfiles: []) + state.step = .repository + state.host = "example.com" + state.user = "deploy" + state.directoryBrowser = RemoteConnectFeature.DirectoryBrowserState( + currentPath: "/Users/deploy", + childDirectories: [], + isLoading: true, + errorMessage: nil + ) + state.activeBrowseRequestID = activeRequestID + + let store = TestStore(initialState: state) { + RemoteConnectFeature() + } + + await store.send(.backButtonTapped) { + $0.step = .host + $0.directoryBrowser = nil + $0.activeBrowseRequestID = nil + $0.validationMessage = nil + } + await store.send( + .remoteDirectoryListingFailed( + activeRequestID, + "Couldn't browse remote folders." + ) + ) + } + + @Test func browseRemoteFoldersFailureMapsMissingDirectoryToFriendlyMessage() async { + var state = RemoteConnectFeature.State(savedHostProfiles: []) + state.step = .repository + state.host = "example.com" + state.user = "deploy" + + let store = TestStore(initialState: state) { + RemoteConnectFeature() + } withDependencies: { + $0.date = .constant(Date(timeIntervalSince1970: 5)) + $0.uuid = .incrementing + $0.remoteExecutionClient.run = { _, _, _ in + RemoteExecutionClient.Output( + stdout: "", + stderr: "__PROWL_REMOTE_CONNECT__:missing-directory\n", + exitCode: 20 + ) + } + } + + let connectionProfileID = "00000000-0000-0000-0000-000000000000" + let requestID = UUID(uuidString: "00000000-0000-0000-0000-000000000001")! + + await store.send(.browseRemoteFoldersButtonTapped) { + $0.connectionHostProfileID = connectionProfileID + $0.connectionHostProfileEndpointKey = "example.com|deploy|" + $0.activeBrowseRequestID = requestID + $0.directoryBrowser = RemoteConnectFeature.DirectoryBrowserState( + currentPath: "", + childDirectories: [], + isLoading: true, + errorMessage: nil + ) + } + await store.receive( + .remoteDirectoryListingFailed( + requestID, + "The remote folder couldn't be opened." + ) + ) { + $0.activeBrowseRequestID = nil + $0.directoryBrowser?.isLoading = false + $0.directoryBrowser?.errorMessage = "The remote folder couldn't be opened." + } + } + + @Test func connectValidationFailureMapsNotGitToFriendlyMessage() async { + var state = RemoteConnectFeature.State(savedHostProfiles: []) + state.step = .repository + state.host = "example.com" + state.user = "deploy" + state.remotePath = "/srv/not-a-repo" + + let store = TestStore(initialState: state) { + RemoteConnectFeature() + } withDependencies: { + $0.date = .constant(Date(timeIntervalSince1970: 40)) + $0.uuid = .incrementing + $0.remoteExecutionClient.run = { _, _, _ in + RemoteExecutionClient.Output( + stdout: "", + stderr: "__PROWL_REMOTE_CONNECT__:not-git\n", + exitCode: 21 + ) + } + } + + await store.send(.connectButtonTapped) { + $0.isSubmitting = true + $0.validationMessage = nil + $0.connectionHostProfileID = "00000000-0000-0000-0000-000000000000" + $0.connectionHostProfileEndpointKey = "example.com|deploy|" + } + await store.receive( + .remoteRepositoryValidationFailed("The selected folder is not a Git repository.") + ) { + $0.isSubmitting = false + $0.validationMessage = "The selected folder is not a Git repository." + } + } +} + +private actor AsyncGate { + private var continuation: CheckedContinuation? + + func wait() async { + await withCheckedContinuation { continuation in + self.continuation = continuation + } + } + + func resume() { + continuation?.resume() + continuation = nil + } +} diff --git a/supacodeTests/RemoteExecutionClientTests.swift b/supacodeTests/RemoteExecutionClientTests.swift new file mode 100644 index 000000000..2d72fc1a8 --- /dev/null +++ b/supacodeTests/RemoteExecutionClientTests.swift @@ -0,0 +1,219 @@ +import Foundation +import Testing + +@testable import supacode + +nonisolated final class RemoteExecutionCallRecorder: @unchecked Sendable { + struct Snapshot { + let executableURL: URL? + let arguments: [String] + let timeoutSeconds: Int + } + + private let lock = NSLock() + private var snapshots: [Snapshot] = [] + + func record(executableURL: URL, arguments: [String], timeoutSeconds: Int) { + lock.lock() + snapshots.append( + Snapshot( + executableURL: executableURL, + arguments: arguments, + timeoutSeconds: timeoutSeconds + ) + ) + lock.unlock() + } + + func snapshot() -> Snapshot { + lock.lock() + let snapshot = snapshots.last ?? Snapshot(executableURL: nil, arguments: [], timeoutSeconds: 0) + lock.unlock() + return snapshot + } + + func allSnapshots() -> [Snapshot] { + lock.lock() + let snapshot = snapshots + lock.unlock() + return snapshot + } +} + +nonisolated final class StringRecorder: @unchecked Sendable { + private let lock = NSLock() + private var values: [String] = [] + + func append(_ value: String) { + lock.lock() + values.append(value) + lock.unlock() + } + + func snapshot() -> [String] { + lock.lock() + let snapshot = values + lock.unlock() + return snapshot + } +} + +nonisolated final class EnvironmentRecorder: @unchecked Sendable { + private let lock = NSLock() + private var values: [[String: String]] = [] + + func store(_ environment: [String: String]) { + lock.lock() + values.append(environment) + lock.unlock() + } + + func snapshot() -> [String: String]? { + lock.lock() + let snapshot = values.last + lock.unlock() + return snapshot + } + + func allSnapshots() -> [[String: String]] { + lock.lock() + let snapshot = values + lock.unlock() + return snapshot + } +} + +struct RemoteExecutionClientTests { + @Test func remoteExecutionBuildsExpectedSSHArguments() async throws { + let recorder = RemoteExecutionCallRecorder() + let shell = ShellClient( + run: { _, _, _ in ShellOutput(stdout: "", stderr: "", exitCode: 0) }, + runLoginImpl: { _, _, _, _ in ShellOutput(stdout: "", stderr: "", exitCode: 0) }, + runWithTimeoutImpl: { executableURL, arguments, _, timeoutSeconds in + recorder.record( + executableURL: executableURL, + arguments: arguments, + timeoutSeconds: timeoutSeconds + ) + return ShellOutput(stdout: "ok", stderr: "", exitCode: 0) + } + ) + let client = RemoteExecutionClient.live(shellClient: shell) + let profile = SSHHostProfile( + displayName: "host", + host: "example.com", + user: "dev", + port: 2222, + authMethod: .publicKey + ) + + let output = try await client.run(profile, "tmux list-sessions", 8) + + #expect(output.exitCode == 0) + #expect(output.stdout == "ok") + + let snapshot = recorder.snapshot() + #expect(snapshot.executableURL?.path == "/usr/bin/ssh") + #expect(snapshot.arguments.contains("dev@example.com")) + #expect(snapshot.arguments.contains("-p")) + #expect(snapshot.arguments.contains("2222")) + #expect(snapshot.arguments.contains("-o")) + #expect(snapshot.arguments.contains("BatchMode=yes")) + #expect(snapshot.arguments.last == "tmux list-sessions") + #expect(snapshot.timeoutSeconds == 8) + } + + @Test func passwordAuthUsesAskpassAndLoadsPasswordFromKeychain() async throws { + let recorder = RemoteExecutionCallRecorder() + let envRecorder = EnvironmentRecorder() + let keychainLookups = StringRecorder() + let shell = ShellClient( + run: { _, _, _ in ShellOutput(stdout: "", stderr: "", exitCode: 0) }, + runLoginImpl: { _, _, _, _ in ShellOutput(stdout: "", stderr: "", exitCode: 0) }, + runWithTimeoutImpl: { _, _, _, _ in ShellOutput(stdout: "", stderr: "", exitCode: 0) }, + runWithTimeoutEnvironmentImpl: { executableURL, arguments, _, environment, timeoutSeconds in + recorder.record( + executableURL: executableURL, + arguments: arguments, + timeoutSeconds: timeoutSeconds + ) + envRecorder.store(environment) + return ShellOutput(stdout: "ok", stderr: "", exitCode: 0) + } + ) + let client = RemoteExecutionClient.live( + shellClient: shell, + keychainClient: KeychainClient( + savePassword: { _, _ in }, + loadPassword: { key in + keychainLookups.append(key) + return "secret" + }, + deletePassword: { _ in } + ) + ) + let profile = SSHHostProfile( + id: "host-1", + displayName: "host", + host: "example.com", + user: "dev", + port: 2222, + authMethod: .password + ) + + let output = try await client.run(profile, "tmux list-sessions", 8) + + #expect(output.exitCode == 0) + #expect(output.stdout == "ok") + #expect(keychainLookups.snapshot() == ["host-1"]) + + let snapshot = recorder.snapshot() + #expect(snapshot.executableURL?.path == "/usr/bin/ssh") + #expect(snapshot.arguments.contains("dev@example.com")) + #expect(snapshot.arguments.contains("-p")) + #expect(snapshot.arguments.contains("2222")) + #expect(snapshot.arguments.contains("BatchMode=yes")) + + let allCalls = recorder.allSnapshots() + #expect(allCalls.count == 2) + if allCalls.count == 2 { + let bootstrapCall = allCalls[0] + #expect(bootstrapCall.arguments.contains("PreferredAuthentications=password,keyboard-interactive")) + #expect(bootstrapCall.arguments.contains("PubkeyAuthentication=no")) + #expect(bootstrapCall.arguments.contains("NumberOfPasswordPrompts=1")) + #expect(bootstrapCall.arguments.last == "exit") + #expect(bootstrapCall.timeoutSeconds == SSHCommandSupport.bootstrapTimeoutSeconds) + + let commandCall = allCalls[1] + #expect(commandCall.arguments.last == "tmux list-sessions") + #expect(commandCall.timeoutSeconds == 8) + } + + let environments = envRecorder.allSnapshots() + #expect(environments.count == 2) + if environments.count == 2 { + let bootstrapEnvironment = environments[0] + #expect(bootstrapEnvironment["SSH_ASKPASS"] != nil) + #expect(bootstrapEnvironment["SSH_ASKPASS_REQUIRE"] == "force") + #expect(bootstrapEnvironment["DISPLAY"] == ":0") + #expect(bootstrapEnvironment["PROWL_REMOTE_SSH_PASSWORD"] == nil) + + let commandEnvironment = environments[1] + #expect(commandEnvironment.isEmpty) + } + } + + @Test func askpassHelperWritesPasswordToScriptNotEnvironment() throws { + let support = try SSHCommandSupport.makeAskpassSupport(password: "secret") + defer { + try? FileManager.default.removeItem(at: support.helperURL) + } + + let scriptContents = try String(contentsOf: support.helperURL) + #expect(scriptContents.contains("secret")) + #expect(support.environment["DISPLAY"] == ":0") + #expect(support.environment["SSH_ASKPASS"] == support.helperURL.path(percentEncoded: false)) + #expect(support.environment["SSH_ASKPASS_REQUIRE"] == "force") + #expect(support.environment["PROWL_REMOTE_SSH_PASSWORD"] == nil) + } +} diff --git a/supacodeTests/RemoteSessionPickerFeatureTests.swift b/supacodeTests/RemoteSessionPickerFeatureTests.swift new file mode 100644 index 000000000..96d6bdb84 --- /dev/null +++ b/supacodeTests/RemoteSessionPickerFeatureTests.swift @@ -0,0 +1,44 @@ +import ComposableArchitecture +import Foundation +import Testing + +@testable import supacode + +@MainActor +struct RemoteSessionPickerFeatureTests { + @Test func attachTappedDelegatesSelectedSession() async { + let store = TestStore( + initialState: RemoteSessionPickerFeature.State( + worktreeID: "wt-1", + repositoryRootURL: URL(fileURLWithPath: "/tmp/repo"), + remotePath: "/srv/repo", + sessions: ["alpha", "beta"], + preferredSessionName: "beta", + suggestedManagedSessionName: nil + ) + ) { + RemoteSessionPickerFeature() + } + + await store.send(.attachTapped) + await store.receive(.delegate(.attachExisting("beta"))) + } + + @Test func createAndAttachTappedTrimsSessionName() async { + var state = RemoteSessionPickerFeature.State( + worktreeID: "wt-1", + repositoryRootURL: URL(fileURLWithPath: "/tmp/repo"), + remotePath: "/srv/repo", + sessions: ["alpha"], + preferredSessionName: nil, + suggestedManagedSessionName: nil + ) + state.managedSessionName = " new-session " + let store = TestStore(initialState: state) { + RemoteSessionPickerFeature() + } + + await store.send(.createAndAttachTapped) + await store.receive(.delegate(.createAndAttach("new-session"))) + } +} diff --git a/supacodeTests/RemoteTmuxClientTests.swift b/supacodeTests/RemoteTmuxClientTests.swift new file mode 100644 index 000000000..6eb174434 --- /dev/null +++ b/supacodeTests/RemoteTmuxClientTests.swift @@ -0,0 +1,43 @@ +import Testing + +@testable import supacode + +struct RemoteTmuxClientTests { + @Test func remoteTmuxParsesSessionNames() async throws { + let remoteExecution = RemoteExecutionClient( + run: { _, _, _ in + .init(stdout: "main\nops\n", stderr: "", exitCode: 0) + } + ) + let client = RemoteTmuxClient.live(remoteExecution: remoteExecution) + let profile = SSHHostProfile( + displayName: "Host", + host: "host", + authMethod: .publicKey + ) + + let sessions = try await client.listSessions(profile, 8) + + #expect(sessions == ["main", "ops"]) + } + + @Test func buildAttachCommandUsesNativeSSHWithControlSocket() { + let client = RemoteTmuxClient.live() + let profile = SSHHostProfile( + id: "host-1", + displayName: "Host", + host: "example.com", + user: "dev", + port: 2222, + authMethod: .publicKey + ) + + let command = client.buildAttachCommand(profile, "kn/master", "/home/dev/project") + + #expect(command.contains("/usr/bin/ssh")) + #expect(command.contains("ControlPath=")) + #expect(command.contains("dev@example.com")) + #expect(command.contains("tmux attach-session -t")) + #expect(command.contains("cd '/home/dev/project'")) + } +} diff --git a/supacodeTests/RepositoriesFeatureTests.swift b/supacodeTests/RepositoriesFeatureTests.swift index 3c89487b4..05ca7c498 100644 --- a/supacodeTests/RepositoriesFeatureTests.swift +++ b/supacodeTests/RepositoriesFeatureTests.swift @@ -187,6 +187,758 @@ struct RepositoriesFeatureTests { await store.finish() } + @Test(.dependencies) func loadPersistedRepositoriesUsesEndpointAwareGitClientForRemoteEntries() async { + let testID = UUID().uuidString + let settingsStorage = SettingsTestStorage() + let settingsFileURL = URL(fileURLWithPath: "/tmp/supacode-settings-\(testID).json") + let repoRoot = "/srv/\(testID)/repo" + let hostProfileID = "h1-\(testID)" + let endpoint = RepositoryEndpoint.remote(hostProfileID: hostProfileID, remotePath: repoRoot) + let profile = SSHHostProfile( + id: hostProfileID, + displayName: "Server", + host: "example.com", + user: "dev", + authMethod: .publicKey + ) + let worktree = makeWorktree( + id: repoRoot, + name: "main", + repoRoot: repoRoot, + endpoint: endpoint + ) + let repository = makeRepository( + id: repoRoot, + name: "repo", + endpoint: endpoint, + worktrees: [worktree] + ) + let store = withDependencies { + $0.settingsFileStorage = settingsStorage.storage + $0.settingsFileURL = settingsFileURL + } operation: { + @Shared(.settingsFile) var settingsFile + $settingsFile.withLock { $0.sshHostProfiles = [profile] } + return TestStore(initialState: RepositoriesFeature.State()) { + RepositoriesFeature() + } withDependencies: { + $0.repositoryPersistence.loadRepositoryEntries = { + [PersistedRepositoryEntry(path: repoRoot, kind: .git, endpoint: endpoint)] + } + $0.repositoryPersistence.saveRepositorySnapshot = { _ in } + $0.gitClient.worktrees = { _ in + Issue.record("Expected remote repository load to use endpoint-aware git client") + return [] + } + $0.gitClient.worktreesForEndpoint = { rootURL, requestedEndpoint, hostProfile in + #expect(rootURL.path(percentEncoded: false) == repoRoot) + #expect(requestedEndpoint == endpoint) + #expect(hostProfile == profile) + return [worktree] + } + } + } + + await store.send(.loadPersistedRepositories) + await store.receive(\.repositoriesLoaded) { + $0.repositories = [repository] + $0.repositoryRoots = [URL(fileURLWithPath: repoRoot)] + $0.isInitialLoadComplete = true + } + await store.receive(\.delegate.repositoriesChanged) + await store.finish() + } + + @Test(.dependencies) func addRemoteRepositoryButtonTappedPresentsRemoteConnectSheet() async { + let testID = UUID().uuidString + let settingsStorage = SettingsTestStorage() + let settingsFileURL = URL(fileURLWithPath: "/tmp/supacode-settings-\(testID).json") + let profile = SSHHostProfile( + id: "host-1", + displayName: "Build Box", + host: "example.com", + user: "deploy", + authMethod: .publicKey + ) + + let store = withDependencies { + $0.settingsFileStorage = settingsStorage.storage + $0.settingsFileURL = settingsFileURL + } operation: { + @Shared(.settingsFile) var settingsFile + $settingsFile.withLock { $0.sshHostProfiles = [profile] } + return TestStore(initialState: RepositoriesFeature.State()) { + RepositoriesFeature() + } + } + + await store.send(.addRemoteRepositoryButtonTapped) { + $0.remoteConnect = RemoteConnectFeature.State(savedHostProfiles: [profile]) + } + } + + @Test(.dependencies) func remoteConnectCompletedUpsertsHostProfileAndPersistsRemoteRepository() async { + let testID = UUID().uuidString + let settingsStorage = SettingsTestStorage() + let settingsFileURL = URL(fileURLWithPath: "/tmp/supacode-settings-\(testID).json") + let repoRoot = "/srv/\(testID)/repo" + let createdAt = Date(timeIntervalSince1970: 10) + let updatedAt = Date(timeIntervalSince1970: 20) + let originalProfile = SSHHostProfile( + id: "host-1", + displayName: "Old Name", + host: "old.example.com", + user: "deploy", + authMethod: .publicKey, + createdAt: createdAt, + updatedAt: createdAt + ) + let submittedProfile = SSHHostProfile( + id: originalProfile.id, + displayName: "Build Box", + host: "example.com", + user: "deploy", + port: 2222, + authMethod: .publicKey, + createdAt: createdAt, + updatedAt: updatedAt + ) + let submission = RemoteConnectFeature.Submission( + hostProfile: submittedProfile, + remotePath: repoRoot + ) + let endpoint = RepositoryEndpoint.remote( + hostProfileID: submittedProfile.id, + remotePath: repoRoot + ) + let worktree = makeWorktree( + id: repoRoot, + name: "main", + repoRoot: repoRoot, + endpoint: endpoint + ) + let repository = makeRepository( + id: repoRoot, + name: "repo", + endpoint: endpoint, + worktrees: [worktree] + ) + let savedEntries = LockIsolated<[[PersistedRepositoryEntry]]>([]) + var initialState = RepositoriesFeature.State() + initialState.remoteConnect = RemoteConnectFeature.State(savedHostProfiles: [originalProfile]) + + let store = withDependencies { + $0.settingsFileStorage = settingsStorage.storage + $0.settingsFileURL = settingsFileURL + } operation: { + @Shared(.settingsFile) var settingsFile + $settingsFile.withLock { $0.sshHostProfiles = [originalProfile] } + return TestStore(initialState: initialState) { + RepositoriesFeature() + } withDependencies: { + $0.repositoryPersistence.loadRepositoryEntries = { [] } + $0.repositoryPersistence.saveRepositoryEntries = { entries in + savedEntries.withValue { $0.append(entries) } + } + $0.repositoryPersistence.saveRepositorySnapshot = { _ in } + $0.gitClient.worktrees = { _ in + Issue.record("Expected remote repository add not to use local git client") + return [] + } + $0.gitClient.worktreesForEndpoint = { rootURL, requestedEndpoint, hostProfile in + #expect(rootURL.path(percentEncoded: false) == repoRoot) + #expect(requestedEndpoint == endpoint) + #expect(hostProfile == submittedProfile) + return [worktree] + } + } + } + + await store.send(.remoteConnect(.presented(.delegate(.completed(submission))))) { + $0.remoteConnect = nil + } + await store.receive(\.openRepositoriesFinished) { + $0.repositories = [repository] + $0.repositoryRoots = [URL(fileURLWithPath: repoRoot)] + $0.isInitialLoadComplete = true + } + await store.receive(\.delegate.repositoriesChanged) + await store.finish() + + withDependencies { + $0.settingsFileStorage = settingsStorage.storage + $0.settingsFileURL = settingsFileURL + } operation: { + @Shared(.settingsFile) var settingsFile + #expect(settingsFile.sshHostProfiles == [submittedProfile]) + } + #expect( + savedEntries.value == [[ + PersistedRepositoryEntry(path: repoRoot, kind: .git, endpoint: endpoint) + ]] + ) + } + + @Test(.dependencies) func loadPersistedRepositoriesUsesLatestDuplicateRemoteEndpointPath() async { + let testID = UUID().uuidString + let settingsStorage = SettingsTestStorage() + let settingsFileURL = URL(fileURLWithPath: "/tmp/supacode-settings-\(testID).json") + let repoRoot = "/srv/\(testID)/repo" + let endpointA = RepositoryEndpoint.remote(hostProfileID: "h1-\(testID)", remotePath: repoRoot) + let endpointB = RepositoryEndpoint.remote(hostProfileID: "h2-\(testID)", remotePath: repoRoot) + let profileA = SSHHostProfile( + id: "h1-\(testID)", + displayName: "Server A", + host: "a.example.com", + user: "dev", + authMethod: .publicKey + ) + let profileB = SSHHostProfile( + id: "h2-\(testID)", + displayName: "Server B", + host: "b.example.com", + user: "dev", + authMethod: .publicKey + ) + let worktreeA = makeWorktree( + id: repoRoot, + name: "main", + repoRoot: repoRoot, + endpoint: endpointA + ) + let worktreeB = makeWorktree( + id: repoRoot, + name: "main-b", + repoRoot: repoRoot, + endpoint: endpointB + ) + let expectedRepository = makeRepository( + id: repoRoot, + name: "repo", + endpoint: endpointB, + worktrees: [worktreeB] + ) + let store = withDependencies { + $0.settingsFileStorage = settingsStorage.storage + $0.settingsFileURL = settingsFileURL + } operation: { + @Shared(.settingsFile) var settingsFile + $settingsFile.withLock { $0.sshHostProfiles = [profileA, profileB] } + return TestStore(initialState: RepositoriesFeature.State()) { + RepositoriesFeature() + } withDependencies: { + $0.repositoryPersistence.loadRepositoryEntries = { + [ + PersistedRepositoryEntry(path: repoRoot, kind: .git, endpoint: endpointA), + PersistedRepositoryEntry(path: repoRoot, kind: .git, endpoint: endpointB), + ] + } + $0.repositoryPersistence.saveRepositorySnapshot = { _ in } + $0.gitClient.worktrees = { _ in + Issue.record("Expected duplicate remote repository load not to use local git client") + return [] + } + $0.gitClient.worktreesForEndpoint = { _, requestedEndpoint, _ in + switch requestedEndpoint { + case endpointA: + return [worktreeA] + case endpointB: + return [worktreeB] + default: + Issue.record("Unexpected endpoint requested: \(requestedEndpoint)") + return [] + } + } + } + } + + await store.send(.loadPersistedRepositories) + await store.receive(\.repositoriesLoaded) { + $0.repositories = [expectedRepository] + $0.repositoryRoots = [ + URL(fileURLWithPath: repoRoot), + URL(fileURLWithPath: repoRoot), + ] + $0.isInitialLoadComplete = true + } + await store.receive(\.delegate.repositoriesChanged) + await store.finish() + } + + @Test(.dependencies) func selectingRemoteWorktreeWithSessionsAlwaysPresentsPicker() async { + let testID = UUID().uuidString + let settingsStorage = SettingsTestStorage() + let settingsFileURL = URL(fileURLWithPath: "/tmp/supacode-settings-\(testID).json") + let repoRoot = "/srv/\(testID)/repo" + let hostProfileID = "h1-\(testID)" + let endpoint = RepositoryEndpoint.remote(hostProfileID: hostProfileID, remotePath: repoRoot) + let profile = SSHHostProfile( + id: hostProfileID, + displayName: "Server", + host: "example.com", + user: "dev", + authMethod: .publicKey + ) + let worktree = makeWorktree( + id: repoRoot, + name: "main", + repoRoot: repoRoot, + endpoint: endpoint + ) + let repository = makeRepository( + id: repoRoot, + name: "repo", + endpoint: endpoint, + worktrees: [worktree] + ) + let store = withDependencies { + $0.settingsFileStorage = settingsStorage.storage + $0.settingsFileURL = settingsFileURL + } operation: { + @Shared(.settingsFile) var settingsFile + $settingsFile.withLock { $0.sshHostProfiles = [profile] } + @Shared(.repositorySettings(repository.rootURL)) var repositorySettings + $repositorySettings.withLock { + $0.lastAttachedRemoteTmuxSessionName = "ops" + } + return TestStore(initialState: makeState(repositories: [repository])) { + RepositoriesFeature() + } withDependencies: { + $0.remoteTmuxClient.listSessions = { requestedProfile, timeoutSeconds in + #expect(requestedProfile == profile) + #expect(timeoutSeconds == 8) + return ["dev", "ops"] + } + } + } + + await store.send(.selectWorktree(worktree.id)) { + $0.selection = .worktree(worktree.id) + $0.sidebarSelectedWorktreeIDs = [worktree.id] + } + await store.receive(\.delegate.selectedWorktreeChanged) + await store.receive(\.remoteSessionsLoaded) { + $0.remoteSessionPicker = RemoteSessionPickerFeature.State( + worktreeID: worktree.id, + repositoryRootURL: worktree.repositoryRootURL, + remotePath: repoRoot, + sessions: ["dev", "ops"], + preferredSessionName: "ops", + suggestedManagedSessionName: nil + ) + } + } + + @Test(.dependencies) func pickerAttachSelectionPersistsLastAttachedSessionNameAndRunsAttachCommand() async { + let testID = UUID().uuidString + let settingsStorage = SettingsTestStorage() + let settingsFileURL = URL(fileURLWithPath: "/tmp/supacode-settings-\(testID).json") + let repoRoot = "/srv/\(testID)/repo" + let hostProfileID = "h1-\(testID)" + let endpoint = RepositoryEndpoint.remote(hostProfileID: hostProfileID, remotePath: repoRoot) + let profile = SSHHostProfile( + id: hostProfileID, + displayName: "Server", + host: "example.com", + user: "dev", + authMethod: .publicKey + ) + let worktree = makeWorktree( + id: repoRoot, + name: "main", + repoRoot: repoRoot, + endpoint: endpoint + ) + let repository = makeRepository( + id: repoRoot, + name: "repo", + endpoint: endpoint, + worktrees: [worktree] + ) + let terminalCommands = LockIsolated<[TerminalClient.Command]>([]) + let store = withDependencies { + $0.settingsFileStorage = settingsStorage.storage + $0.settingsFileURL = settingsFileURL + } operation: { + @Shared(.settingsFile) var settingsFile + $settingsFile.withLock { $0.sshHostProfiles = [profile] } + return TestStore(initialState: makeState(repositories: [repository])) { + RepositoriesFeature() + } withDependencies: { + $0.remoteTmuxClient.listSessions = { _, _ in + ["dev", "ops"] + } + $0.remoteTmuxClient.buildAttachCommand = { profile, sessionName, remotePath in + #expect(profile.id == hostProfileID) + "attach:\(sessionName):\(remotePath)" + } + $0.terminalClient.send = { command in + terminalCommands.withValue { $0.append(command) } + } + } + } + + await store.send(.selectWorktree(worktree.id)) { + $0.selection = .worktree(worktree.id) + $0.sidebarSelectedWorktreeIDs = [worktree.id] + } + await store.receive(\.delegate.selectedWorktreeChanged) + await store.receive(\.remoteSessionsLoaded) { + $0.remoteSessionPicker = RemoteSessionPickerFeature.State( + worktreeID: worktree.id, + repositoryRootURL: worktree.repositoryRootURL, + remotePath: repoRoot, + sessions: ["dev", "ops"], + preferredSessionName: nil, + suggestedManagedSessionName: nil + ) + } + await store.send(.remoteSessionPicker(.presented(.delegate(.attachExisting("ops"))))) { + $0.attachedRemoteTmuxSessionByWorktreeID[worktree.id] = "ops" + $0.remoteSessionPicker = nil + } + await store.finish() + + withDependencies { + $0.settingsFileStorage = settingsStorage.storage + $0.settingsFileURL = settingsFileURL + } operation: { + @Shared(.repositorySettings(repository.rootURL)) var repositorySettings + #expect(repositorySettings.lastAttachedRemoteTmuxSessionName == "ops") + } + + #expect( + terminalCommands.value == [ + .createTabWithInput( + worktree, + input: "attach:ops:\(repoRoot)", + runSetupScriptIfNew: false + ) + ] + ) + } + + @Test(.dependencies) func pickerCreateAndAttachRunsManagedSessionCommand() async { + let testID = UUID().uuidString + let settingsStorage = SettingsTestStorage() + let settingsFileURL = URL(fileURLWithPath: "/tmp/supacode-settings-\(testID).json") + let repoRoot = "/srv/\(testID)/repo" + let hostProfileID = "h1-\(testID)" + let endpoint = RepositoryEndpoint.remote(hostProfileID: hostProfileID, remotePath: repoRoot) + let profile = SSHHostProfile( + id: hostProfileID, + displayName: "Server", + host: "example.com", + user: "dev", + authMethod: .publicKey + ) + let worktree = makeWorktree( + id: repoRoot, + name: "main", + repoRoot: repoRoot, + endpoint: endpoint + ) + let repository = makeRepository( + id: repoRoot, + name: "repo", + endpoint: endpoint, + worktrees: [worktree] + ) + let terminalCommands = LockIsolated<[TerminalClient.Command]>([]) + let store = withDependencies { + $0.settingsFileStorage = settingsStorage.storage + $0.settingsFileURL = settingsFileURL + } operation: { + @Shared(.settingsFile) var settingsFile + $settingsFile.withLock { $0.sshHostProfiles = [profile] } + return TestStore(initialState: makeState(repositories: [repository])) { + RepositoriesFeature() + } withDependencies: { + $0.remoteTmuxClient.listSessions = { _, _ in + ["dev", "ops"] + } + $0.remoteTmuxClient.buildCreateAndAttachCommand = { profile, sessionName, remotePath in + #expect(profile.id == hostProfileID) + "create:\(sessionName):\(remotePath)" + } + $0.terminalClient.send = { command in + terminalCommands.withValue { $0.append(command) } + } + } + } + + await store.send(.selectWorktree(worktree.id)) { + $0.selection = .worktree(worktree.id) + $0.sidebarSelectedWorktreeIDs = [worktree.id] + } + await store.receive(\.delegate.selectedWorktreeChanged) + await store.receive(\.remoteSessionsLoaded) { + $0.remoteSessionPicker = RemoteSessionPickerFeature.State( + worktreeID: worktree.id, + repositoryRootURL: worktree.repositoryRootURL, + remotePath: repoRoot, + sessions: ["dev", "ops"], + preferredSessionName: nil, + suggestedManagedSessionName: nil + ) + } + await store.send(.remoteSessionPicker(.presented(.delegate(.createAndAttach("managed"))))) { + $0.attachedRemoteTmuxSessionByWorktreeID[worktree.id] = "managed" + $0.remoteSessionPicker = nil + } + await store.finish() + + #expect( + terminalCommands.value == [ + .createTabWithInput( + worktree, + input: "create:managed:\(repoRoot)", + runSetupScriptIfNew: false + ) + ] + ) + } + + @Test(.dependencies) func reselectingRemoteWorktreeAfterAttachDoesNotPromptAgain() async { + let testID = UUID().uuidString + let settingsStorage = SettingsTestStorage() + let settingsFileURL = URL(fileURLWithPath: "/tmp/supacode-settings-\(testID).json") + let remoteRepoRoot = "/srv/\(testID)/repo" + let localRepoRoot = "/tmp/\(testID)/local" + let hostProfileID = "h1-\(testID)" + let endpoint = RepositoryEndpoint.remote(hostProfileID: hostProfileID, remotePath: remoteRepoRoot) + let profile = SSHHostProfile( + id: hostProfileID, + displayName: "Server", + host: "example.com", + user: "dev", + authMethod: .publicKey + ) + let remoteWorktree = makeWorktree( + id: remoteRepoRoot, + name: "main", + repoRoot: remoteRepoRoot, + endpoint: endpoint + ) + let localWorktree = makeWorktree( + id: "\(localRepoRoot)/main", + name: "main", + repoRoot: localRepoRoot + ) + let remoteRepository = makeRepository( + id: remoteRepoRoot, + name: "remote", + endpoint: endpoint, + worktrees: [remoteWorktree] + ) + let localRepository = makeRepository( + id: localRepoRoot, + name: "local", + worktrees: [localWorktree] + ) + let terminalCommands = LockIsolated<[TerminalClient.Command]>([]) + + let store = withDependencies { + $0.settingsFileStorage = settingsStorage.storage + $0.settingsFileURL = settingsFileURL + } operation: { + @Shared(.settingsFile) var settingsFile + $settingsFile.withLock { $0.sshHostProfiles = [profile] } + return TestStore(initialState: makeState(repositories: [remoteRepository, localRepository])) { + RepositoriesFeature() + } withDependencies: { + $0.remoteTmuxClient.listSessions = { _, _ in + ["dev", "ops"] + } + $0.remoteTmuxClient.buildAttachCommand = { _, sessionName, remotePath in + "attach:\(sessionName):\(remotePath)" + } + $0.terminalClient.send = { command in + terminalCommands.withValue { $0.append(command) } + } + } + } + store.exhaustivity = .off + + await store.send(.selectWorktree(remoteWorktree.id)) + await store.receive(\.delegate.selectedWorktreeChanged) + await store.receive(\.remoteSessionsLoaded) + #expect(store.state.remoteSessionPicker != nil) + + await store.send(.remoteSessionPicker(.presented(.delegate(.attachExisting("ops"))))) + #expect(store.state.remoteSessionPicker == nil) + #expect(store.state.attachedRemoteTmuxSessionByWorktreeID[remoteWorktree.id] == "ops") + + await store.send(.selectWorktree(localWorktree.id)) + await store.receive(\.delegate.selectedWorktreeChanged) + + await store.send(.selectWorktree(remoteWorktree.id)) + await store.receive(\.delegate.selectedWorktreeChanged) + await store.receive(\.remoteSessionsLoaded) + #expect(store.state.remoteSessionPicker == nil) + #expect( + terminalCommands.value == [ + .createTabWithInput( + remoteWorktree, + input: "attach:ops:\(remoteRepoRoot)", + runSetupScriptIfNew: false + ) + ] + ) + } + + @Test(.dependencies) func removeFailedRepositoryPreservesLoadedEndpointVariantForSamePath() async { + let repoRoot = "/srv/\(UUID().uuidString)/repo" + let endpointA = RepositoryEndpoint.remote(hostProfileID: "h1", remotePath: repoRoot) + let endpointB = RepositoryEndpoint.remote(hostProfileID: "h2", remotePath: repoRoot) + let remainingEntries = RepositoriesFeature.removeFailedRepositoryEntries( + [ + PersistedRepositoryEntry(path: repoRoot, kind: .git, endpoint: endpointA), + PersistedRepositoryEntry(path: repoRoot, kind: .git, endpoint: endpointB), + ], + repositoryID: repoRoot, + preservedEndpoint: endpointA + ) + + #expect(remainingEntries == [PersistedRepositoryEntry(path: repoRoot, kind: .git, endpoint: endpointA)]) + } + + @Test(.dependencies) func repositoryRemovedPreservesOtherEndpointVariantForSamePath() async { + let repoRoot = "/srv/\(UUID().uuidString)/repo" + let endpointA = RepositoryEndpoint.remote(hostProfileID: "h1", remotePath: repoRoot) + let endpointB = RepositoryEndpoint.remote(hostProfileID: "h2", remotePath: repoRoot) + let worktreeA = makeWorktree( + id: repoRoot, + name: "main-a", + repoRoot: repoRoot, + endpoint: endpointA + ) + let worktreeB = makeWorktree( + id: repoRoot, + name: "main-b", + repoRoot: repoRoot, + endpoint: endpointB + ) + let repositoryA = makeRepository( + id: repoRoot, + name: URL(fileURLWithPath: repoRoot).lastPathComponent, + endpoint: endpointA, + worktrees: [worktreeA] + ) + let repositoryB = makeRepository( + id: repoRoot, + name: URL(fileURLWithPath: repoRoot).lastPathComponent, + endpoint: endpointB, + worktrees: [worktreeB] + ) + let savedEntries = LockIsolated<[[PersistedRepositoryEntry]]>([]) + var state = RepositoriesFeature.State() + state.repositories = [repositoryA] + state.repositoryRoots = [ + URL(fileURLWithPath: repoRoot), + URL(fileURLWithPath: repoRoot), + ] + state.removingRepositoryIDs = [repoRoot] + + let store = TestStore(initialState: state) { + RepositoriesFeature() + } withDependencies: { + $0.repositoryPersistence.loadRepositoryEntries = { + [ + PersistedRepositoryEntry(path: repoRoot, kind: .git, endpoint: endpointA), + PersistedRepositoryEntry(path: repoRoot, kind: .git, endpoint: endpointB), + ] + } + $0.repositoryPersistence.saveRepositoryEntries = { entries in + savedEntries.withValue { $0.append(entries) } + } + $0.repositoryPersistence.saveRepositorySnapshot = { _ in } + $0.gitClient.worktrees = { _ in + Issue.record("Expected remote repository reload not to use local git client") + return [] + } + $0.gitClient.worktreesForEndpoint = { _, endpoint, _ in + #expect(endpoint == endpointB) + return [worktreeB] + } + } + + await store.send(.repositoryRemoved(repoRoot, selectionWasRemoved: false)) { + $0.removingRepositoryIDs = [] + } + await store.receive(\.delegate.selectedWorktreeChanged) + await store.receive(\.repositoriesLoaded) { + $0.repositories = [repositoryB] + $0.repositoryRoots = [URL(fileURLWithPath: repoRoot)] + $0.isInitialLoadComplete = true + } + await store.receive(\.delegate.repositoriesChanged) + await store.finish() + + #expect(savedEntries.value == [[PersistedRepositoryEntry(path: repoRoot, kind: .git, endpoint: endpointB)]]) + } + + @Test(.dependencies) func repositoryRemovedUsesEndpointAwareFallbackEntriesWhenPersistenceIsEmpty() async { + let localRoot = "/tmp/\(UUID().uuidString)-local" + let remoteRoot = "/srv/\(UUID().uuidString)/repo" + let endpoint = RepositoryEndpoint.remote(hostProfileID: "h1", remotePath: remoteRoot) + let localWorktree = makeWorktree(id: localRoot, name: "main", repoRoot: localRoot) + let remoteWorktree = makeWorktree( + id: remoteRoot, + name: "main", + repoRoot: remoteRoot, + endpoint: endpoint + ) + let localRepository = makeRepository( + id: localRoot, + name: URL(fileURLWithPath: localRoot).lastPathComponent, + worktrees: [localWorktree] + ) + let remoteRepository = makeRepository( + id: remoteRoot, + name: URL(fileURLWithPath: remoteRoot).lastPathComponent, + endpoint: endpoint, + worktrees: [remoteWorktree] + ) + let savedEntries = LockIsolated<[[PersistedRepositoryEntry]]>([]) + var state = makeState(repositories: [localRepository, remoteRepository]) + state.removingRepositoryIDs = [localRoot] + + let store = TestStore(initialState: state) { + RepositoriesFeature() + } withDependencies: { + $0.repositoryPersistence.loadRepositoryEntries = { [] } + $0.repositoryPersistence.loadRoots = { [] } + $0.repositoryPersistence.saveRepositoryEntries = { entries in + savedEntries.withValue { $0.append(entries) } + } + $0.repositoryPersistence.saveRepositorySnapshot = { _ in } + $0.gitClient.worktrees = { root in + Issue.record("Expected fallback remote reload not to use local git client: \(root.path(percentEncoded: false))") + return [] + } + $0.gitClient.worktreesForEndpoint = { root, requestedEndpoint, _ in + #expect(root.path(percentEncoded: false) == remoteRoot) + #expect(requestedEndpoint == endpoint) + return [remoteWorktree] + } + } + + await store.send(.repositoryRemoved(localRoot, selectionWasRemoved: false)) { + $0.removingRepositoryIDs = [] + } + await store.receive(\.delegate.selectedWorktreeChanged) + await store.receive(\.repositoriesLoaded) { + $0.repositories = [remoteRepository] + $0.repositoryRoots = [URL(fileURLWithPath: remoteRoot)] + $0.isInitialLoadComplete = true + } + await store.receive(\.delegate.repositoriesChanged) + await store.finish() + + #expect(savedEntries.value == [[PersistedRepositoryEntry(path: remoteRoot, kind: .git, endpoint: endpoint)]]) + } + @Test func loadPersistedRepositoriesAutoUpgradesPlainFolderWhenItBecomesGitRoot() async { let root = "/tmp/folder" let worktree = makeWorktree(id: root, name: "folder", repoRoot: root) @@ -1092,6 +1844,112 @@ struct RepositoriesFeatureTests { #expect(store.state.alert == nil) } + @Test(.dependencies) func createRandomWorktreeInRemoteRepositoryUsesEndpointAwareStream() async { + let testID = UUID().uuidString + let settingsStorage = SettingsTestStorage() + let settingsFileURL = URL(fileURLWithPath: "/tmp/supacode-settings-\(testID).json") + let repoRoot = "/srv/\(testID)/repo" + let hostProfileID = "h1-\(testID)" + let endpoint = RepositoryEndpoint.remote(hostProfileID: hostProfileID, remotePath: repoRoot) + let profile = SSHHostProfile( + id: hostProfileID, + displayName: "Server", + host: "example.com", + user: "dev", + authMethod: .publicKey + ) + let mainWorktree = makeWorktree( + id: repoRoot, + name: "main", + repoRoot: repoRoot, + endpoint: endpoint + ) + let repository = makeRepository( + id: repoRoot, + name: "repo", + endpoint: endpoint, + worktrees: [mainWorktree] + ) + let createdWorktree = makeWorktree( + id: "/srv/worktrees/swift-otter", + name: "swift-otter", + repoRoot: repoRoot, + endpoint: endpoint + ) + let requestedEndpoint = LockIsolated(nil) + let requestedHostProfile = LockIsolated(nil) + let store = withDependencies { + $0.settingsFileStorage = settingsStorage.storage + $0.settingsFileURL = settingsFileURL + } operation: { + @Shared(.settingsFile) var settingsFile + $settingsFile.withLock { + $0.global.promptForWorktreeCreation = false + $0.sshHostProfiles = [profile] + } + return TestStore(initialState: makeState(repositories: [repository])) { + RepositoriesFeature() + } withDependencies: { + $0.uuid = .incrementing + $0.gitClient.localBranchNames = { _ in + struct UnexpectedLocalBranchRequest: Error {} + throw UnexpectedLocalBranchRequest() + } + $0.gitClient.isBareRepository = { _ in + struct UnexpectedBareRepositoryRequest: Error {} + throw UnexpectedBareRepositoryRequest() + } + $0.gitClient.automaticWorktreeBaseRef = { _ in + Issue.record("Expected remote repository create not to resolve local automatic base refs") + return nil + } + $0.gitClient.ignoredFileCount = { _ in + struct UnexpectedIgnoredFileRequest: Error {} + throw UnexpectedIgnoredFileRequest() + } + $0.gitClient.untrackedFileCount = { _ in + struct UnexpectedUntrackedFileRequest: Error {} + throw UnexpectedUntrackedFileRequest() + } + $0.gitClient.createWorktreeStream = { _, _, _, _, _, _ in + Issue.record("Expected remote repository create to use endpoint-aware stream") + return AsyncThrowingStream { continuation in + continuation.finish() + } + } + $0.gitClient.createWorktreeStreamForEndpoint = { + _, _, _, copyIgnored, copyUntracked, baseRef, endpointValue, hostProfile in + #expect(copyIgnored == false) + #expect(copyUntracked == false) + #expect(baseRef.isEmpty) + requestedEndpoint.withValue { $0 = endpointValue } + requestedHostProfile.withValue { $0 = hostProfile } + return AsyncThrowingStream { continuation in + continuation.yield(.finished(createdWorktree)) + continuation.finish() + } + } + $0.gitClient.worktrees = { _ in + Issue.record("Expected remote repository reload to use endpoint-aware git client") + return [] + } + $0.gitClient.worktreesForEndpoint = { _, endpointValue, hostProfile in + requestedEndpoint.withValue { $0 = endpointValue } + requestedHostProfile.withValue { $0 = hostProfile } + return [createdWorktree, mainWorktree] + } + } + } + store.exhaustivity = .off + + await store.send(.createRandomWorktreeInRepository(repository.id)) + await store.receive(\.createRandomWorktreeSucceeded) + await store.finish() + + #expect(requestedEndpoint.value == endpoint) + #expect(requestedHostProfile.value == profile) + } + @Test(.dependencies) func createRandomWorktreeUsesRepositoryWorktreeBaseDirectoryOverride() async { let repoRoot = "/tmp/repo" let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) @@ -1449,6 +2307,81 @@ struct RepositoriesFeatureTests { $0.alert = expectedAlert } } + + @Test(.dependencies) func deleteWorktreeConfirmedUsesEndpointAwareRemovalForRemoteRepository() async { + let testID = UUID().uuidString + let settingsStorage = SettingsTestStorage() + let settingsFileURL = URL(fileURLWithPath: "/tmp/supacode-settings-\(testID).json") + let repoRoot = "/srv/\(testID)/repo" + let hostProfileID = "h1-\(testID)" + let endpoint = RepositoryEndpoint.remote(hostProfileID: hostProfileID, remotePath: repoRoot) + let profile = SSHHostProfile( + id: hostProfileID, + displayName: "Server", + host: "example.com", + user: "dev", + authMethod: .publicKey + ) + let mainWorktree = makeWorktree( + id: repoRoot, + name: "main", + repoRoot: repoRoot, + endpoint: endpoint + ) + let featureWorktree = makeWorktree( + id: "/srv/worktrees/feature", + name: "feature", + repoRoot: repoRoot, + endpoint: endpoint + ) + let repository = makeRepository( + id: repoRoot, + name: "repo", + endpoint: endpoint, + worktrees: [mainWorktree, featureWorktree] + ) + let removedEndpoint = LockIsolated(nil) + let removedHostProfile = LockIsolated(nil) + let store = withDependencies { + $0.settingsFileStorage = settingsStorage.storage + $0.settingsFileURL = settingsFileURL + } operation: { + @Shared(.settingsFile) var settingsFile + $settingsFile.withLock { + $0.global.deleteBranchOnDeleteWorktree = true + $0.sshHostProfiles = [profile] + } + return TestStore(initialState: makeState(repositories: [repository])) { + RepositoriesFeature() + } withDependencies: { + $0.gitClient.removeWorktree = { _, _ in + Issue.record("Expected remote repository delete to use endpoint-aware git client") + return URL(fileURLWithPath: "/tmp/unexpected") + } + $0.gitClient.removeWorktreeForEndpoint = { _, _, endpointValue, hostProfile in + removedEndpoint.withValue { $0 = endpointValue } + removedHostProfile.withValue { $0 = hostProfile } + return featureWorktree.workingDirectory + } + $0.gitClient.worktrees = { _ in + Issue.record("Expected remote repository reload to use endpoint-aware git client") + return [] + } + $0.gitClient.worktreesForEndpoint = { _, _, _ in [mainWorktree] } + } + } + store.exhaustivity = .off + + await store.send(.deleteWorktreeConfirmed(featureWorktree.id, repository.id)) { + $0.deletingWorktreeIDs = [featureWorktree.id] + } + await store.receive(\.worktreeDeleted) + await store.finish() + + #expect(removedEndpoint.value == endpoint) + #expect(removedHostProfile.value == profile) + } + @Test func requestDeleteWorktreesShowsBatchConfirmation() async { let worktree1 = makeWorktree(id: "/tmp/repo/wt1", name: "owl", repoRoot: "/tmp/repo") let worktree2 = makeWorktree(id: "/tmp/repo/wt2", name: "hawk", repoRoot: "/tmp/repo") @@ -2163,6 +3096,43 @@ struct RepositoriesFeatureTests { #expect(store.state.archivedWorktreeIDs == [worktree.id]) } + @Test func repositoriesLoadedCoalescesDuplicateFailureIDs() async { + let repoRoot = "/tmp/repo" + let store = TestStore(initialState: RepositoriesFeature.State()) { + RepositoriesFeature() + } + + await store.send( + .repositoriesLoaded( + [], + failures: [ + RepositoriesFeature.LoadFailure(rootID: repoRoot, message: "permission denied"), + RepositoriesFeature.LoadFailure(rootID: repoRoot, message: "duplicate repository identity"), + ], + roots: [URL(fileURLWithPath: repoRoot)], + animated: false + ) + ) { + $0.repositoryRoots = [URL(fileURLWithPath: repoRoot)] + $0.loadFailuresByID = [repoRoot: "permission denied\nduplicate repository identity"] + $0.isInitialLoadComplete = true + } + await store.finish() + } + + @Test func orderedRepositoryRootsDeduplicatesSamePathRoots() { + let repoRoot = "/home/momoai/workspace/kyc-nexis" + let repository = makeRepository(id: repoRoot, worktrees: []) + var state = makeState(repositories: [repository]) + state.repositoryRoots = [ + URL(fileURLWithPath: repoRoot), + URL(fileURLWithPath: repoRoot), + ] + + let ordered = state.orderedRepositoryRoots() + #expect(ordered == [URL(fileURLWithPath: repoRoot)]) + } + @Test func repositoriesLoadedSkipsSelectionChangeWhenOnlyDisplayDataChanges() async { let repoRoot = "/tmp/repo" let worktree = makeWorktree(id: "/tmp/repo/main", name: "main", repoRoot: repoRoot) @@ -3034,6 +4004,7 @@ struct RepositoriesFeatureTests { id: String, name: String, repoRoot: String = "/tmp/repo", + endpoint: RepositoryEndpoint = .local, createdAt: Date? = nil ) -> Worktree { Worktree( @@ -3042,6 +4013,7 @@ struct RepositoriesFeatureTests { detail: "detail", workingDirectory: URL(fileURLWithPath: id), repositoryRootURL: URL(fileURLWithPath: repoRoot), + endpoint: endpoint, createdAt: createdAt ) } @@ -3075,6 +4047,7 @@ struct RepositoriesFeatureTests { id: String, name: String = "repo", kind: Repository.Kind = .git, + endpoint: RepositoryEndpoint = .local, worktrees: [Worktree] ) -> Repository { Repository( @@ -3082,6 +4055,7 @@ struct RepositoriesFeatureTests { rootURL: URL(fileURLWithPath: id), name: name, kind: kind, + endpoint: endpoint, worktrees: IdentifiedArray(uniqueElements: worktrees) ) } diff --git a/supacodeTests/RepositoryPersistenceClientTests.swift b/supacodeTests/RepositoryPersistenceClientTests.swift index 9cd3c4f96..ba41ea288 100644 --- a/supacodeTests/RepositoryPersistenceClientTests.swift +++ b/supacodeTests/RepositoryPersistenceClientTests.swift @@ -8,6 +8,12 @@ import Testing @testable import supacode struct RepositoryPersistenceClientTests { + @Test func persistedRepositoryEntryDecodesLegacyLocalFormat() throws { + let legacy = #"{"path":"/tmp/repo","kind":"git"}"# + let decoded = try JSONDecoder().decode(PersistedRepositoryEntry.self, from: Data(legacy.utf8)) + #expect(decoded.endpoint == .local) + } + @Test(.dependencies) func savesAndLoadsRootsAndPins() async throws { let storage = SettingsTestStorage() @@ -83,6 +89,31 @@ struct RepositoryPersistenceClientTests { #expect(finalSettings.repositoryRoots == result.map(\.path)) } + @Test(.dependencies) func saveRepositoryEntriesPreservesDistinctRemoteEndpointsForSamePath() async throws { + let storage = SettingsTestStorage() + let client = RepositoryPersistenceClient.liveValue + let sharedPath = "/tmp/shared-remote" + let endpointA = RepositoryEndpoint.remote(hostProfileID: "host-a", remotePath: sharedPath) + let endpointB = RepositoryEndpoint.remote(hostProfileID: "host-b", remotePath: sharedPath) + + let result = await withDependencies { + $0.settingsFileStorage = storage.storage + } operation: { + await client.saveRepositoryEntries([ + PersistedRepositoryEntry(path: sharedPath, kind: .git, endpoint: endpointA), + PersistedRepositoryEntry(path: sharedPath, kind: .git, endpoint: endpointB), + ]) + return await client.loadRepositoryEntries() + } + + #expect( + result == [ + PersistedRepositoryEntry(path: sharedPath, kind: .git, endpoint: endpointA), + PersistedRepositoryEntry(path: sharedPath, kind: .git, endpoint: endpointB), + ] + ) + } + @Test(.dependencies) func loadsLegacyRepositoryRootsAsGitEntries() async throws { let storage = SettingsTestStorage() let legacySettings = SettingsFile( diff --git a/supacodeTests/RepositorySettingsFeatureTests.swift b/supacodeTests/RepositorySettingsFeatureTests.swift index 93839d3f5..02b2eb2d2 100644 --- a/supacodeTests/RepositorySettingsFeatureTests.swift +++ b/supacodeTests/RepositorySettingsFeatureTests.swift @@ -87,6 +87,91 @@ struct RepositorySettingsFeatureTests { #expect(automaticBaseRefRequests.value == 0) } + @Test(.dependencies) func remoteRepositoryTaskUsesSafeEndpointAwareFallback() async throws { + let testID = UUID().uuidString + let rootURL = URL(fileURLWithPath: "/tmp/remote-\(testID)") + let settingsStorage = SettingsTestStorage() + let localStorage = RepositoryLocalSettingsTestStorage() + let settingsFileURL = URL(fileURLWithPath: "/tmp/supacode-settings-\(testID).json") + let hostProfileID = "h1-\(testID)" + let endpoint = RepositoryEndpoint.remote( + hostProfileID: hostProfileID, + remotePath: "/srv/\(testID)/repo" + ) + let profile = SSHHostProfile( + id: hostProfileID, + displayName: "Server", + host: "example.com", + user: "dev", + authMethod: .publicKey + ) + let storedSettings = RepositorySettings( + setupScript: "echo setup", + archiveScript: "echo archive", + runScript: "npm run dev", + openActionID: OpenWorktreeAction.automaticSettingsID, + worktreeBaseRef: "origin/remote-main", + copyIgnoredOnWorktreeCreate: true, + copyUntrackedOnWorktreeCreate: true, + pullRequestMergeStrategy: .squash + ) + let repositoryID = rootURL.standardizedFileURL.path(percentEncoded: false) + let bareRepositoryRequests = LockIsolated(0) + let branchRefRequests = LockIsolated(0) + let automaticBaseRefRequests = LockIsolated(0) + let store = withDependencies { + $0.settingsFileStorage = settingsStorage.storage + $0.settingsFileURL = settingsFileURL + $0.repositoryLocalSettingsStorage = localStorage.storage + } operation: { + @Shared(.settingsFile) var settingsFile + $settingsFile.withLock { + $0.repositories[repositoryID] = storedSettings + $0.sshHostProfiles = [profile] + } + return TestStore( + initialState: RepositorySettingsFeature.State( + rootURL: rootURL, + repositoryKind: .git, + endpoint: endpoint, + settings: .default, + userSettings: .default + ) + ) { + RepositorySettingsFeature() + } withDependencies: { + $0.gitClient.isBareRepository = { _ in + bareRepositoryRequests.withValue { $0 += 1 } + return false + } + $0.gitClient.branchRefs = { _ in + branchRefRequests.withValue { $0 += 1 } + return [] + } + $0.gitClient.automaticWorktreeBaseRef = { _ in + automaticBaseRefRequests.withValue { $0 += 1 } + return "origin/main" + } + } + } + + await store.send(.task) + await store.receive(\.settingsLoaded, timeout: .seconds(5)) { + $0.settings = storedSettings + $0.isBareRepository = false + } + await store.receive(\.branchDataLoaded, timeout: .seconds(5)) { + $0.defaultWorktreeBaseRef = "origin/remote-main" + $0.branchOptions = ["origin/remote-main"] + $0.isBranchDataLoaded = true + } + await store.finish(timeout: .seconds(5)) + + #expect(bareRepositoryRequests.value == 0) + #expect(branchRefRequests.value == 0) + #expect(automaticBaseRefRequests.value == 0) + } + @Test func plainFolderVisibilityHidesGitOnlySections() { let state = RepositorySettingsFeature.State( rootURL: URL(fileURLWithPath: "/tmp/folder"), diff --git a/supacodeTests/SSHCommandSupportTests.swift b/supacodeTests/SSHCommandSupportTests.swift new file mode 100644 index 000000000..8a3385e58 --- /dev/null +++ b/supacodeTests/SSHCommandSupportTests.swift @@ -0,0 +1,17 @@ +import Testing + +@testable import supacode + +struct SSHCommandSupportTests { + @Test func controlSocketPathFallsBackToTmpWhenTooLong() { + let path = SSHCommandSupport.controlSocketPath(endpointKey: String(repeating: "x", count: 512)) + #expect(path.hasPrefix("/tmp/")) + #expect(path.hasSuffix(".sock")) + #expect(path.utf8.count <= 64) + } + + @Test func removingBatchModeStripsOnlyBatchModePairs() { + let filtered = SSHCommandSupport.removingBatchMode(from: ["-o", "BatchMode=yes", "-o", "ConnectTimeout=8"]) + #expect(filtered == ["-o", "ConnectTimeout=8"]) + } +} diff --git a/supacodeTests/SettingsFeatureSSHHostsTests.swift b/supacodeTests/SettingsFeatureSSHHostsTests.swift new file mode 100644 index 000000000..81480df1d --- /dev/null +++ b/supacodeTests/SettingsFeatureSSHHostsTests.swift @@ -0,0 +1,278 @@ +import ComposableArchitecture +import Foundation +import Testing + +@testable import supacode + +@MainActor +struct SettingsFeatureSSHHostsTests { + @Test func addHostAppendsProfile() async throws { + let storage = SettingsTestStorage() + let testID = UUID().uuidString + let settingsFileURL = URL(fileURLWithPath: "/tmp/supacode-settings-\(testID).json") + let repositoryEntriesFileURL = URL(fileURLWithPath: "/tmp/supacode-repositories-\(testID).json") + let hostID = UUID(uuidString: "00000000-0000-0000-0000-000000000001")! + let now = Date(timeIntervalSince1970: 100) + + let store = withDependencies { + $0.settingsFileStorage = storage.storage + $0.settingsFileURL = settingsFileURL + $0.repositoryEntriesFileURL = repositoryEntriesFileURL + $0.uuid = .constant(hostID) + $0.date = .constant(now) + } operation: { + @Shared(.repositoryEntries) var repositoryEntries: [PersistedRepositoryEntry] + $repositoryEntries.withLock { + $0 = [] + } + return TestStore(initialState: SSHHostsFeature.State()) { + SSHHostsFeature() + } + } + + await store.send(.task) + await store.send(.addHostTapped) { + $0.isCreating = true + } + await store.send(.binding(.set(\.displayName, "Build Box"))) { + $0.displayName = "Build Box" + } + await store.send(.binding(.set(\.host, "example.com"))) { + $0.host = "example.com" + } + await store.send(.binding(.set(\.user, "deploy"))) { + $0.user = "deploy" + } + await store.send(.binding(.set(\.port, "2222"))) { + $0.port = "2222" + } + await store.send(.binding(.set(\.authMethod, .password))) { + $0.authMethod = .password + } + + let expectedProfile = SSHHostProfile( + id: hostID.uuidString, + displayName: "Build Box", + host: "example.com", + user: "deploy", + port: 2222, + authMethod: .password, + createdAt: now, + updatedAt: now + ) + + await store.send(.saveButtonTapped) { + $0.hosts = [expectedProfile] + $0.selectedHostID = expectedProfile.id + $0.isCreating = false + $0.displayName = "Build Box" + $0.host = "example.com" + $0.user = "deploy" + $0.port = "2222" + $0.authMethod = .password + $0.validationMessage = nil + } + + let persisted = try decodeSettingsFile(storage: storage, url: settingsFileURL) + #expect(persisted.sshHostProfiles == [expectedProfile]) + } + + @Test func updateHostPersistsEditedFields() async throws { + let storage = SettingsTestStorage() + let testID = UUID().uuidString + let settingsFileURL = URL(fileURLWithPath: "/tmp/supacode-settings-\(testID).json") + let repositoryEntriesFileURL = URL(fileURLWithPath: "/tmp/supacode-repositories-\(testID).json") + let createdAt = Date(timeIntervalSince1970: 10) + let updatedAt = Date(timeIntervalSince1970: 20) + let initial = SSHHostProfile( + id: "host-1", + displayName: "Build Box", + host: "example.com", + user: "deploy", + port: 22, + authMethod: .publicKey, + createdAt: createdAt, + updatedAt: createdAt + ) + + let store = withDependencies { + $0.settingsFileStorage = storage.storage + $0.settingsFileURL = settingsFileURL + $0.repositoryEntriesFileURL = repositoryEntriesFileURL + $0.date = .constant(updatedAt) + } operation: { + @Shared(.settingsFile) var settingsFile + $settingsFile.withLock { + $0.sshHostProfiles = [initial] + } + @Shared(.repositoryEntries) var repositoryEntries: [PersistedRepositoryEntry] + $repositoryEntries.withLock { + $0 = [] + } + return TestStore(initialState: SSHHostsFeature.State()) { + SSHHostsFeature() + } + } + + await store.send(.task) { + $0.hosts = [initial] + $0.selectedHostID = initial.id + $0.displayName = "Build Box" + $0.host = "example.com" + $0.user = "deploy" + $0.port = "22" + $0.authMethod = .publicKey + $0.isCreating = false + } + await store.send(.binding(.set(\.displayName, "Build Box Updated"))) { + $0.displayName = "Build Box Updated" + } + await store.send(.binding(.set(\.port, "2202"))) { + $0.port = "2202" + } + + let expected = SSHHostProfile( + id: initial.id, + displayName: "Build Box Updated", + host: "example.com", + user: "deploy", + port: 2202, + authMethod: .publicKey, + createdAt: createdAt, + updatedAt: updatedAt + ) + + await store.send(.saveButtonTapped) { + $0.hosts = [expected] + $0.selectedHostID = expected.id + $0.displayName = "Build Box Updated" + $0.host = "example.com" + $0.user = "deploy" + $0.port = "2202" + $0.authMethod = .publicKey + $0.validationMessage = nil + } + + let persisted = try decodeSettingsFile(storage: storage, url: settingsFileURL) + #expect(persisted.sshHostProfiles == [expected]) + } + + @Test func deleteHostFailsWhenBoundRepositoriesExist() async throws { + let storage = SettingsTestStorage() + let testID = UUID().uuidString + let settingsFileURL = URL(fileURLWithPath: "/tmp/supacode-settings-\(testID).json") + let repositoryEntriesFileURL = URL(fileURLWithPath: "/tmp/supacode-repositories-\(testID).json") + let profile = SSHHostProfile( + id: "host-1", + displayName: "Build Box", + host: "example.com", + user: "deploy", + authMethod: .publicKey + ) + let boundEntry = PersistedRepositoryEntry( + path: "/srv/repo", + kind: .git, + endpoint: .remote(hostProfileID: profile.id, remotePath: "/srv/repo") + ) + + let store = withDependencies { + $0.settingsFileStorage = storage.storage + $0.settingsFileURL = settingsFileURL + $0.repositoryEntriesFileURL = repositoryEntriesFileURL + } operation: { + @Shared(.settingsFile) var settingsFile + $settingsFile.withLock { + $0.sshHostProfiles = [profile] + } + @Shared(.repositoryEntries) var repositoryEntries: [PersistedRepositoryEntry] + $repositoryEntries.withLock { + $0 = [boundEntry] + } + return TestStore(initialState: SSHHostsFeature.State()) { + SSHHostsFeature() + } + } + + await store.send(.task) { + $0.hosts = [profile] + $0.selectedHostID = profile.id + $0.displayName = "Build Box" + $0.host = "example.com" + $0.user = "deploy" + $0.port = "" + $0.authMethod = .publicKey + } + await store.send(.deleteHostTapped) { + $0.validationMessage = "This host is used by 1 remote repository and cannot be deleted." + } + + #expect(store.state.hosts == [profile]) + let persisted = try decodeSettingsFile(storage: storage, url: settingsFileURL) + #expect(persisted.sshHostProfiles == [profile]) + } + + @Test func deleteHostRemovesUnboundProfileAfterConfirmation() async throws { + let storage = SettingsTestStorage() + let testID = UUID().uuidString + let settingsFileURL = URL(fileURLWithPath: "/tmp/supacode-settings-\(testID).json") + let repositoryEntriesFileURL = URL(fileURLWithPath: "/tmp/supacode-repositories-\(testID).json") + let profile = SSHHostProfile( + id: "host-1", + displayName: "Build Box", + host: "example.com", + user: "deploy", + authMethod: .publicKey + ) + + let store = withDependencies { + $0.settingsFileStorage = storage.storage + $0.settingsFileURL = settingsFileURL + $0.repositoryEntriesFileURL = repositoryEntriesFileURL + } operation: { + @Shared(.settingsFile) var settingsFile + $settingsFile.withLock { + $0.sshHostProfiles = [profile] + } + @Shared(.repositoryEntries) var repositoryEntries: [PersistedRepositoryEntry] + $repositoryEntries.withLock { + $0 = [] + } + return TestStore(initialState: SSHHostsFeature.State()) { + SSHHostsFeature() + } + } + + await store.send(.task) { + $0.hosts = [profile] + $0.selectedHostID = profile.id + $0.displayName = "Build Box" + $0.host = "example.com" + $0.user = "deploy" + $0.port = "" + $0.authMethod = .publicKey + } + await store.send(.alert(.presented(.confirmDelete(profile.id)))) { + $0.hosts = [] + $0.selectedHostID = nil + $0.displayName = "" + $0.host = "" + $0.user = "" + $0.port = "" + $0.authMethod = .publicKey + $0.isCreating = false + $0.validationMessage = nil + $0.alert = nil + } + + let persisted = try decodeSettingsFile(storage: storage, url: settingsFileURL) + #expect(persisted.sshHostProfiles.isEmpty) + } + + private func decodeSettingsFile( + storage: SettingsTestStorage, + url: URL + ) throws -> SettingsFile { + let data = try storage.storage.load(url) + return try JSONDecoder().decode(SettingsFile.self, from: data) + } +} diff --git a/supacodeTests/SettingsFilePersistenceTests.swift b/supacodeTests/SettingsFilePersistenceTests.swift index 427339648..9ac4a941f 100644 --- a/supacodeTests/SettingsFilePersistenceTests.swift +++ b/supacodeTests/SettingsFilePersistenceTests.swift @@ -115,6 +115,15 @@ struct SettingsFilePersistenceTests { #expect(settings.repositoryRoots.isEmpty) #expect(settings.pinnedWorktreeIDs.isEmpty) } + + @Test func settingsFileDecodesWithoutSSHHostsKey() throws { + let legacyJSON = + #"{"global":{"appearanceMode":"dark","updatesAutomaticallyCheckForUpdates":true,"# + + #""updatesAutomaticallyDownloadUpdates":false},"repositories":{},"repositoryRoots":[]}"# + let data = Data(legacyJSON.utf8) + let decoded = try JSONDecoder().decode(SettingsFile.self, from: data) + #expect(decoded.sshHostProfiles.isEmpty) + } } nonisolated private final class MutableTestStorage: @unchecked Sendable { diff --git a/supacodeTests/WorktreeTerminalTabsViewTests.swift b/supacodeTests/WorktreeTerminalTabsViewTests.swift new file mode 100644 index 000000000..6bc73cc5e --- /dev/null +++ b/supacodeTests/WorktreeTerminalTabsViewTests.swift @@ -0,0 +1,47 @@ +import AppKit +import Testing + +@testable import supacode + +@MainActor +struct WorktreeTerminalTabsViewTests { + @Test func deferredAutofocusSkipsActiveTextEditing() { + #expect( + WorktreeTerminalTabsView.shouldAutoFocusTerminal( + firstResponder: NSTextView(), + forceAutoFocus: false, + respectsActiveTextInput: true + ) == false + ) + } + + @Test func deferredAutofocusStillAllowsRegularRecovery() { + #expect( + WorktreeTerminalTabsView.shouldAutoFocusTerminal( + firstResponder: nil, + forceAutoFocus: false, + respectsActiveTextInput: true + ) + ) + } + + @Test func deferredAutofocusDoesNotOverrideTextEditingEvenWhenForced() { + #expect( + WorktreeTerminalTabsView.shouldAutoFocusTerminal( + firstResponder: NSTextView(), + forceAutoFocus: true, + respectsActiveTextInput: true + ) == false + ) + } + + @Test func immediateAutofocusAlsoSkipsActiveTextEditing() { + #expect( + WorktreeTerminalTabsView.shouldAutoFocusTerminal( + firstResponder: NSTextView(), + forceAutoFocus: false, + respectsActiveTextInput: true + ) == false + ) + } +}