diff --git a/mac/VibeTunnel/Core/Models/Worktree.swift b/mac/VibeTunnel/Core/Models/Worktree.swift index 0e674d0d..7fdf65dd 100644 --- a/mac/VibeTunnel/Core/Models/Worktree.swift +++ b/mac/VibeTunnel/Core/Models/Worktree.swift @@ -179,19 +179,23 @@ struct FollowModeStatus: Codable { /// /// ```swift /// let request = CreateWorktreeRequest( +/// repoPath: "/path/to/repo", /// branch: "feature/new-feature", -/// createBranch: true, +/// path: "/path/to/worktree", /// baseBranch: "main" /// ) /// ``` struct CreateWorktreeRequest: Codable { + /// The repository path where the worktree will be created. + let repoPath: String + /// The branch name for the new worktree. let branch: String + + /// The file system path where the worktree will be created. + let path: String - /// Whether to create the branch if it doesn't exist. - let createBranch: Bool - - /// The base branch to create from when `createBranch` is true. + /// The base branch to create from when creating a new branch. /// /// If nil, uses the repository's default branch. let baseBranch: String? @@ -202,11 +206,11 @@ struct CreateWorktreeRequest: Codable { /// This allows changing the checked-out branch without creating /// a new worktree, useful for quick context switches. struct SwitchBranchRequest: Codable { + /// The repository path where the branch switch will occur. + let repoPath: String + /// The branch to switch to. let branch: String - - /// Whether to create the branch if it doesn't exist. - let createBranch: Bool } /// Request payload for toggling follow mode. @@ -215,19 +219,22 @@ struct SwitchBranchRequest: Codable { /// /// ```swift /// // Enable follow mode -/// let enableRequest = FollowModeRequest(enabled: true, targetBranch: "develop") +/// let enableRequest = FollowModeRequest(repoPath: "/path/to/repo", branch: "develop", enable: true) /// /// // Disable follow mode -/// let disableRequest = FollowModeRequest(enabled: false, targetBranch: nil) +/// let disableRequest = FollowModeRequest(repoPath: "/path/to/repo", branch: nil, enable: false) /// ``` struct FollowModeRequest: Codable { - /// Whether to enable or disable follow mode. - let enabled: Bool - + /// The repository path where follow mode will be configured. + let repoPath: String + /// The branch to follow when enabling. /// - /// Required when `enabled` is true, ignored otherwise. - let targetBranch: String? + /// Required when `enable` is true, ignored otherwise. + let branch: String? + + /// Whether to enable or disable follow mode. + let enable: Bool } /// Represents a Git branch in the repository. diff --git a/mac/VibeTunnel/Core/Services/ConfigManager.swift b/mac/VibeTunnel/Core/Services/ConfigManager.swift index 691be29e..709d6cdd 100644 --- a/mac/VibeTunnel/Core/Services/ConfigManager.swift +++ b/mac/VibeTunnel/Core/Services/ConfigManager.swift @@ -224,7 +224,7 @@ final class ConfigManager { private func useDefaults() { self.quickStartCommands = defaultCommands self.repositoryBasePath = FilePathConstants.defaultRepositoryBasePath - + // Set notification defaults to match TypeScript defaults self.notificationsEnabled = true self.notificationSessionStart = true @@ -235,7 +235,7 @@ final class ConfigManager { self.notificationClaudeTurn = false self.notificationSoundEnabled = true self.notificationVibrationEnabled = true - + saveConfiguration() } diff --git a/mac/VibeTunnel/Core/Services/GitRepositoryMonitor.swift b/mac/VibeTunnel/Core/Services/GitRepositoryMonitor.swift index a91ee498..555cc2c9 100644 --- a/mac/VibeTunnel/Core/Services/GitRepositoryMonitor.swift +++ b/mac/VibeTunnel/Core/Services/GitRepositoryMonitor.swift @@ -218,17 +218,29 @@ public final class GitRepositoryMonitor { return nil } + // Check if this path was recently confirmed as non-Git + if let nonGitCheck = nonGitPathCache[filePath], + Date().timeIntervalSince(nonGitCheck) < 600.0 + { // 10 minutes for non-Git paths + logger.debug("⏭️ Skipping known non-Git path: \(filePath)") + return nil + } + // Check cache first if let cached = getCachedRepository(for: filePath) { logger.debug("📦 Found cached repository for: \(filePath)") - // Check if this was recently checked (within 30 seconds) + // Use longer cache duration for common parent directories + let cacheThreshold = isCommonParentDirectory(filePath) ? 300.0 : + recentCheckThreshold // 5 minutes vs 30 seconds + + // Check if this was recently checked if let lastCheck = recentRepositoryChecks[filePath], - Date().timeIntervalSince(lastCheck) < recentCheckThreshold + Date().timeIntervalSince(lastCheck) < cacheThreshold { logger .debug( - "⏭️ Skipping redundant check for: \(filePath) (checked \(Int(Date().timeIntervalSince(lastCheck)))s ago)" + "⏭️ Skipping redundant check for: \(filePath) (checked \(Int(Date().timeIntervalSince(lastCheck)))s ago, threshold: \(Int(cacheThreshold))s)" ) return cached } @@ -248,8 +260,14 @@ public final class GitRepositoryMonitor { guard let repoPath = await self.findGitRoot(from: filePath) else { logger.info("❌ No Git root found for: \(filePath)") // Mark as recently checked even for non-git paths to avoid repeated checks + // Use longer cache for common parent directories that aren't Git repos await MainActor.run { self.recentRepositoryChecks[filePath] = Date() + // Cache the negative result for parent directories longer + if self.isCommonParentDirectory(filePath) { + // Store as a "non-git" marker for 10 minutes + self.nonGitPathCache[filePath] = Date() + } } return nil } @@ -305,6 +323,7 @@ public final class GitRepositoryMonitor { githubURLFetchesInProgress.removeAll() pendingRepositoryRequests.removeAll() recentRepositoryChecks.removeAll() + nonGitPathCache.removeAll() } /// Start monitoring and refreshing all cached repositories @@ -346,7 +365,17 @@ public final class GitRepositoryMonitor { recentRepositoryChecks = recentRepositoryChecks.filter { _, checkDate in checkDate > cutoffDate } - logger.debug("🧹 Cleaned up recent checks cache, \(self.recentRepositoryChecks.count) entries remaining") + + // Also cleanup non-Git path cache (remove entries older than 10 minutes) + let nonGitCutoff = Date().addingTimeInterval(-600.0) + nonGitPathCache = nonGitPathCache.filter { _, checkDate in + checkDate > nonGitCutoff + } + + logger + .debug( + "🧹 Cleaned up caches: \(self.recentRepositoryChecks.count) recent checks, \(self.nonGitPathCache.count) non-Git paths" + ) } // MARK: - Private Properties @@ -372,11 +401,39 @@ public final class GitRepositoryMonitor { /// Tracks recent repository checks with timestamps to skip redundant checks private var recentRepositoryChecks: [String: Date] = [:] + /// Cache for paths that are confirmed NOT to be Git repositories (to avoid repeated filesystem checks) + private var nonGitPathCache: [String: Date] = [:] + /// Duration to consider a repository check as "recent" (30 seconds) private let recentCheckThreshold: TimeInterval = 30.0 // MARK: - Private Methods + /// Check if a path is a common parent directory that should have longer cache duration + private func isCommonParentDirectory(_ path: String) -> Bool { + let pathComponents = path.split(separator: "/").map(String.init) + + // Check if this is a common development parent directory + let commonDevPaths = ["Projects", "Development", "Developer", "Code", "Work", "Source", "Workspace", "Repos"] + + // If the path ends with one of these common directory names, it's likely a parent + if let lastComponent = pathComponents.last, + commonDevPaths.contains(lastComponent) + { + return true + } + + // Also check for patterns like /Users/username/Projects + if pathComponents.count == 3, + pathComponents[0] == "Users", + commonDevPaths.contains(pathComponents[2]) + { + return true + } + + return false + } + private func cacheRepository(_ repository: GitRepository, originalFilePath: String? = nil) { repositoryCache[repository.path] = repository diff --git a/mac/VibeTunnel/Core/Services/NotificationService.swift b/mac/VibeTunnel/Core/Services/NotificationService.swift index 1d02249c..4ad7761b 100644 --- a/mac/VibeTunnel/Core/Services/NotificationService.swift +++ b/mac/VibeTunnel/Core/Services/NotificationService.swift @@ -227,7 +227,7 @@ final class NotificationService: NSObject { vibrationEnabled: prefs.vibrationEnabled ) } - + /// Get notification sound based on user preferences private func getNotificationSound(critical: Bool = false) -> UNNotificationSound? { guard preferences.soundEnabled else { return nil } diff --git a/mac/VibeTunnel/Core/Services/SessionMonitor.swift b/mac/VibeTunnel/Core/Services/SessionMonitor.swift index 840ef759..c55cb092 100644 --- a/mac/VibeTunnel/Core/Services/SessionMonitor.swift +++ b/mac/VibeTunnel/Core/Services/SessionMonitor.swift @@ -195,14 +195,9 @@ final class SessionMonitor { // Update WindowTracker WindowTracker.shared.updateFromSessions(sessionsArray) - // Pre-cache Git data for all sessions + // Pre-cache Git data for all sessions (deduplicated by repository) if let gitMonitor = gitRepositoryMonitor { - for session in sessionsArray where gitMonitor.getCachedRepository(for: session.workingDir) == nil { - Task { - // This will cache the data for immediate access later - _ = await gitMonitor.findRepository(for: session.workingDir) - } - } + await preCacheGitRepositories(for: sessionsArray, using: gitMonitor) } } catch { // Only update error if it's not a simple connection error @@ -215,6 +210,60 @@ final class SessionMonitor { } } + /// Pre-cache Git repositories for sessions, deduplicating by repository root + private func preCacheGitRepositories(for sessions: [ServerSessionInfo], using gitMonitor: GitRepositoryMonitor) async { + // Track unique directories we need to check + var uniqueDirectoriesToCheck = Set() + + // First, collect all unique directories that don't have cached data + for session in sessions { + // Skip if we already have cached data for this exact path + if gitMonitor.getCachedRepository(for: session.workingDir) != nil { + continue + } + + // Add this directory to check + uniqueDirectoriesToCheck.insert(session.workingDir) + + // Smart detection: Also check common parent directories + // This helps when multiple sessions are in subdirectories of the same project + let pathComponents = session.workingDir.split(separator: "/").map(String.init) + + // Check if this looks like a project directory pattern + // Common patterns: /Users/*/Projects/*, /Users/*/Development/*, etc. + if pathComponents.count >= 4 { + // Check if we're in a common development directory + let commonDevPaths = ["Projects", "Development", "Developer", "Code", "Work", "Source"] + + for (index, component) in pathComponents.enumerated() { + if commonDevPaths.contains(component) && index < pathComponents.count - 1 { + // This might be a parent project directory + // Add the immediate child of the development directory + let potentialProjectPath = "/" + pathComponents[0...index + 1].joined(separator: "/") + + // Only add if we don't have cached data for it + if gitMonitor.getCachedRepository(for: potentialProjectPath) == nil { + uniqueDirectoriesToCheck.insert(potentialProjectPath) + } + } + } + } + } + + // Now check each unique directory only once + for directory in uniqueDirectoriesToCheck { + Task { + // This will cache the data for immediate access later + _ = await gitMonitor.findRepository(for: directory) + } + } + + logger + .debug( + "Pre-caching Git data for \(uniqueDirectoriesToCheck.count) unique directories (from \(sessions.count) sessions)" + ) + } + /// Start periodic refresh of sessions private func startPeriodicRefresh() { // Clean up any existing timer diff --git a/mac/VibeTunnel/Core/Services/WorktreeService.swift b/mac/VibeTunnel/Core/Services/WorktreeService.swift index 6e8a46ea..42dcc3a6 100644 --- a/mac/VibeTunnel/Core/Services/WorktreeService.swift +++ b/mac/VibeTunnel/Core/Services/WorktreeService.swift @@ -30,7 +30,7 @@ final class WorktreeService { let worktreeResponse = try await serverManager.performRequest( endpoint: "/api/worktrees", method: "GET", - queryItems: [URLQueryItem(name: "gitRepoPath", value: gitRepoPath)], + queryItems: [URLQueryItem(name: "repoPath", value: gitRepoPath)], responseType: WorktreeListResponse.self ) self.worktrees = worktreeResponse.worktrees @@ -48,17 +48,21 @@ final class WorktreeService { func createWorktree( gitRepoPath: String, branch: String, - createBranch: Bool, + worktreePath: String, baseBranch: String? = nil ) async throws { - let request = CreateWorktreeRequest(branch: branch, createBranch: createBranch, baseBranch: baseBranch) + let request = CreateWorktreeRequest( + repoPath: gitRepoPath, + branch: branch, + path: worktreePath, + baseBranch: baseBranch + ) try await serverManager.performVoidRequest( endpoint: "/api/worktrees", method: "POST", - body: request, - queryItems: [URLQueryItem(name: "gitRepoPath", value: gitRepoPath)] + body: request ) // Refresh the worktree list @@ -71,7 +75,7 @@ final class WorktreeService { endpoint: "/api/worktrees/\(branch)", method: "DELETE", queryItems: [ - URLQueryItem(name: "gitRepoPath", value: gitRepoPath), + URLQueryItem(name: "repoPath", value: gitRepoPath), URLQueryItem(name: "force", value: String(force)) ] ) @@ -81,13 +85,12 @@ final class WorktreeService { } /// Switch to a different branch - func switchBranch(gitRepoPath: String, branch: String, createBranch: Bool = false) async throws { - let request = SwitchBranchRequest(branch: branch, createBranch: createBranch) + func switchBranch(gitRepoPath: String, branch: String) async throws { + let request = SwitchBranchRequest(repoPath: gitRepoPath, branch: branch) try await serverManager.performVoidRequest( endpoint: "/api/worktrees/switch", method: "POST", - body: request, - queryItems: [URLQueryItem(name: "gitRepoPath", value: gitRepoPath)] + body: request ) // Refresh the worktree list @@ -96,12 +99,11 @@ final class WorktreeService { /// Toggle follow mode func toggleFollowMode(gitRepoPath: String, enabled: Bool, targetBranch: String? = nil) async throws { - let request = FollowModeRequest(enabled: enabled, targetBranch: targetBranch) + let request = FollowModeRequest(repoPath: gitRepoPath, branch: targetBranch, enable: enabled) try await serverManager.performVoidRequest( endpoint: "/api/worktrees/follow", method: "POST", - body: request, - queryItems: [URLQueryItem(name: "gitRepoPath", value: gitRepoPath)] + body: request ) // Refresh the worktree list diff --git a/mac/VibeTunnel/Presentation/Components/NewSessionForm.swift b/mac/VibeTunnel/Presentation/Components/NewSessionForm.swift index 519f9d4c..9eba2f19 100644 --- a/mac/VibeTunnel/Presentation/Components/NewSessionForm.swift +++ b/mac/VibeTunnel/Presentation/Components/NewSessionForm.swift @@ -209,11 +209,22 @@ struct NewSessionForm: View { } }, onCreateWorktree: { branchName, baseBranch in + // Generate worktree path by slugifying branch name + let slugifiedBranch = branchName + .replacingOccurrences(of: "/", with: "-") + .replacingOccurrences(of: " ", with: "-") + .lowercased() + + // Create worktree path in a 'worktrees' subdirectory + let repoURL = URL(fileURLWithPath: repoPath) + let worktreesDir = repoURL.appendingPathComponent("worktrees") + let worktreePath = worktreesDir.appendingPathComponent(slugifiedBranch).path + // Create the worktree try await service.createWorktree( gitRepoPath: repoPath, branch: branchName, - createBranch: true, + worktreePath: worktreePath, baseBranch: baseBranch )