diff --git a/mac/VibeTunnel/Core/Models/AppConstants.swift b/mac/VibeTunnel/Core/Models/AppConstants.swift index fb5e4c68..ab27ab74 100644 --- a/mac/VibeTunnel/Core/Models/AppConstants.swift +++ b/mac/VibeTunnel/Core/Models/AppConstants.swift @@ -15,6 +15,7 @@ enum AppConstants { static let welcomeVersion = "welcomeVersion" static let preventSleepWhenRunning = "preventSleepWhenRunning" static let enableScreencapService = "enableScreencapService" + static let repositoryBasePath = "repositoryBasePath" } /// Default values for UserDefaults @@ -23,6 +24,8 @@ enum AppConstants { static let preventSleepWhenRunning = true /// Screencap service is enabled by default for screen sharing static let enableScreencapService = true + /// Default repository base path for auto-discovery + static let repositoryBasePath = "~/" } /// Helper to get boolean value with proper default @@ -40,4 +43,24 @@ enum AppConstants { } return UserDefaults.standard.bool(forKey: key) } + + /// Helper to get string value with proper default + static func stringValue(for key: String) -> String { + // If the key doesn't exist in UserDefaults, return our default + if UserDefaults.standard.object(forKey: key) == nil { + switch key { + case UserDefaultsKeys.repositoryBasePath: + // return last used path if it's exists + if let value = UserDefaults.standard.value(forKey: "NewSession.workingDirectory") as? String { + return value + } else { + return Defaults.repositoryBasePath + } + + default: + return "" + } + } + return UserDefaults.standard.string(forKey: key) ?? "" + } } diff --git a/mac/VibeTunnel/Core/Services/RepositoryDiscoveryService.swift b/mac/VibeTunnel/Core/Services/RepositoryDiscoveryService.swift new file mode 100644 index 00000000..bd2fb1f8 --- /dev/null +++ b/mac/VibeTunnel/Core/Services/RepositoryDiscoveryService.swift @@ -0,0 +1,240 @@ +import Foundation +import OSLog +import Observation +// MARK: - Logger + +extension Logger { + fileprivate static let repositoryDiscovery = Logger( + subsystem: "sh.vibetunnel.vibetunnel", + category: "RepositoryDiscovery" + ) +} + +/// Service for discovering Git repositories in a specified directory +/// +/// Provides functionality to scan a base directory for Git repositories and +/// return them in a format suitable for display in the New Session form. +/// Includes caching and performance optimizations for large directory trees. +@MainActor +@Observable +public final class RepositoryDiscoveryService { + + // MARK: - Properties + + /// Published array of discovered repositories + public private(set) var repositories: [DiscoveredRepository] = [] + + /// Whether discovery is currently in progress + public private(set) var isDiscovering = false + + /// Last error encountered during discovery + public private(set) var lastError: String? + + /// Cache of discovered repositories by base path + private var repositoryCache: [String: [DiscoveredRepository]] = [:] + + /// Maximum depth to search for repositories (prevents infinite recursion) + private let maxSearchDepth = 3 + + + // MARK: - Lifecycle + + public init() {} + + // MARK: - Public Methods + + /// Discover repositories in the specified base path + /// - Parameter basePath: The base directory to search (supports ~ expansion) + public func discoverRepositories(in basePath: String) async { + guard !isDiscovering else { + Logger.repositoryDiscovery.debug("Discovery already in progress, skipping") + return + } + + isDiscovering = true + lastError = nil + + let expandedPath = NSString(string: basePath).expandingTildeInPath + Logger.repositoryDiscovery.info("Starting repository discovery in: \(expandedPath)") + + // Check cache first + if let cachedRepositories = repositoryCache[expandedPath] { + Logger.repositoryDiscovery.debug("Using cached repositories for path: \(expandedPath)") + repositories = cachedRepositories + isDiscovering = false + return + } + + Task.detached { [weak self] in + // Perform discovery in background + let discoveredRepos = await self?.performDiscovery(in: expandedPath) + + guard let discoveredRepos else { + return + } + + await MainActor.run { [weak self] in + // Cache and update results + self?.repositoryCache[expandedPath] = discoveredRepos + self?.repositories = discoveredRepos + + Logger.repositoryDiscovery.info("Discovered \(discoveredRepos.count) repositories in: \(expandedPath)") + + self?.isDiscovering = false + } + } + } + + /// Clear the repository cache + public func clearCache() { + repositoryCache.removeAll() + Logger.repositoryDiscovery.debug("Repository cache cleared") + } + + // MARK: - Private Methods + + /// Perform the actual discovery work + private nonisolated func performDiscovery(in basePath: String) async -> [DiscoveredRepository] { + return await withTaskGroup(of: [DiscoveredRepository].self) { taskGroup in + var allRepositories: [DiscoveredRepository] = [] + + // Submit discovery task + taskGroup.addTask { [weak self] in + await self?.scanDirectory(basePath, depth: 0) ?? [] + } + + // Collect results + for await repositories in taskGroup { + allRepositories.append(contentsOf: repositories) + } + + // Sort by folder name for consistent display + return allRepositories.sorted { $0.folderName < $1.folderName } + } + } + + /// Recursively scan a directory for Git repositories + private nonisolated func scanDirectory(_ path: String, depth: Int) async -> [DiscoveredRepository] { + guard depth < maxSearchDepth else { + Logger.repositoryDiscovery.debug("Max depth reached at: \(path)") + return [] + } + + do { + let fileManager = FileManager.default + let url = URL(fileURLWithPath: path) + + // Check if directory is accessible + guard fileManager.isReadableFile(atPath: path) else { + Logger.repositoryDiscovery.debug("Directory not readable: \(path)") + return [] + } + + // Get directory contents + let contents = try fileManager.contentsOfDirectory( + at: url, + includingPropertiesForKeys: [.isDirectoryKey, .isHiddenKey], + options: [.skipsSubdirectoryDescendants] + ) + + var repositories: [DiscoveredRepository] = [] + + for itemURL in contents { + let resourceValues = try itemURL.resourceValues(forKeys: [.isDirectoryKey, .isHiddenKey]) + + // Skip files and hidden directories (except .git) + guard resourceValues.isDirectory == true else { continue } + if resourceValues.isHidden == true && itemURL.lastPathComponent != ".git" { + continue + } + + let itemPath = itemURL.path + + // Check if this directory is a Git repository + if isGitRepository(at: itemPath) { + let repository = await createDiscoveredRepository(at: itemPath) + repositories.append(repository) + } else { + // Recursively scan subdirectories + let subdirectoryRepos = await scanDirectory(itemPath, depth: depth + 1) + repositories.append(contentsOf: subdirectoryRepos) + } + } + + return repositories + + } catch { + Logger.repositoryDiscovery.error("Error scanning directory \(path): \(error)") + return [] + } + } + + /// Check if a directory is a Git repository + private nonisolated func isGitRepository(at path: String) -> Bool { + let gitPath = URL(fileURLWithPath: path).appendingPathComponent(".git").path + return FileManager.default.fileExists(atPath: gitPath) + } + + /// Create a DiscoveredRepository from a path + private nonisolated func createDiscoveredRepository(at path: String) async -> DiscoveredRepository { + let url = URL(fileURLWithPath: path) + let folderName = url.lastPathComponent + + // Get last modified date + let lastModified = getLastModifiedDate(at: path) + + // Get GitHub URL (this might be slow, so we do it in background) + let githubURL = GitRepository.getGitHubURL(for: path) + + return DiscoveredRepository( + path: path, + folderName: folderName, + lastModified: lastModified, + githubURL: githubURL + ) + } + + /// Get the last modified date of a repository + nonisolated private func getLastModifiedDate(at path: String) -> Date { + do { + let attributes = try FileManager.default.attributesOfItem(atPath: path) + return attributes[.modificationDate] as? Date ?? Date.distantPast + } catch { + Logger.repositoryDiscovery.debug("Could not get modification date for \(path): \(error)") + return Date.distantPast + } + } +} + +// MARK: - DiscoveredRepository + +/// A lightweight repository representation for discovery purposes +public struct DiscoveredRepository: Identifiable, Hashable, Sendable { + public let id = UUID() + public let path: String + public let folderName: String + public let lastModified: Date + public let githubURL: URL? + + /// Display name for the repository + public var displayName: String { + folderName + } + + /// Relative path from home directory if applicable + public var relativePath: String { + let homeDir = NSHomeDirectory() + if path.hasPrefix(homeDir) { + return "~" + path.dropFirst(homeDir.count) + } + return path + } + + /// Formatted last modified date + public var formattedLastModified: String { + let formatter = DateFormatter() + formatter.dateStyle = .short + formatter.timeStyle = .none + return formatter.string(from: lastModified) + } +} diff --git a/mac/VibeTunnel/Presentation/Components/NewSessionForm.swift b/mac/VibeTunnel/Presentation/Components/NewSessionForm.swift index 2cc39d4c..d913acf7 100644 --- a/mac/VibeTunnel/Presentation/Components/NewSessionForm.swift +++ b/mac/VibeTunnel/Presentation/Components/NewSessionForm.swift @@ -13,6 +13,8 @@ struct NewSessionForm: View { private var sessionMonitor @Environment(SessionService.self) private var sessionService + @Environment(RepositoryDiscoveryService.self) + private var repositoryDiscovery // Form fields @State private var command = "zsh" @@ -26,6 +28,7 @@ struct NewSessionForm: View { @State private var showError = false @State private var errorMessage = "" @State private var isHoveringCreate = false + @State private var showingRepositoryDropdown = false @FocusState private var focusedField: Field? enum Field: Hashable { @@ -142,18 +145,45 @@ struct NewSessionForm: View { .font(.system(size: 11, weight: .medium)) .foregroundColor(.secondary) - HStack(spacing: 8) { - TextField("~/", text: $workingDirectory) - .textFieldStyle(.roundedBorder) - .focused($focusedField, equals: .directory) + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 8) { + TextField("~/", text: $workingDirectory) + .textFieldStyle(.roundedBorder) + .focused($focusedField, equals: .directory) - Button(action: selectDirectory) { - Image(systemName: "folder") - .font(.system(size: 12)) - .foregroundColor(.secondary) + Button(action: selectDirectory) { + Image(systemName: "folder") + .font(.system(size: 12)) + .foregroundColor(.secondary) + .frame(width: 20, height: 20) + .contentShape(Rectangle()) + } + .buttonStyle(.borderless) + .help("Choose directory") + + Button(action: { showingRepositoryDropdown.toggle() }) { + Image(systemName: "arrow.trianglehead.pull") + .font(.system(size: 12)) + .foregroundColor(.secondary) + .animation(.easeInOut(duration: 0.2), value: showingRepositoryDropdown) + .frame(width: 20, height: 20) + .contentShape(Rectangle()) + } + .buttonStyle(.borderless) + .help("Choose from repositories") + .disabled(repositoryDiscovery.repositories.isEmpty || repositoryDiscovery.isDiscovering) + } + + // Repository dropdown + if showingRepositoryDropdown && !repositoryDiscovery.repositories.isEmpty { + RepositoryDropdownList( + repositories: repositoryDiscovery.repositories, + isDiscovering: repositoryDiscovery.isDiscovering, + selectedPath: $workingDirectory, + isShowing: $showingRepositoryDropdown + ) + .padding(.top, 4) } - .buttonStyle(.borderless) - .help("Choose directory") } } @@ -319,6 +349,11 @@ struct NewSessionForm: View { .onAppear { loadPreferences() focusedField = .name + + } + .task { + let repositoryBasePath = AppConstants.stringValue(for: AppConstants.UserDefaultsKeys.repositoryBasePath) + await repositoryDiscovery.discoverRepositories(in: repositoryBasePath) } .alert("Error", isPresented: $showError) { Button("OK") {} @@ -442,9 +477,8 @@ struct NewSessionForm: View { if let savedCommand = UserDefaults.standard.string(forKey: "NewSession.command") { command = savedCommand } - if let savedDir = UserDefaults.standard.string(forKey: "NewSession.workingDirectory") { - workingDirectory = savedDir - } + + workingDirectory = AppConstants.stringValue(for: AppConstants.UserDefaultsKeys.repositoryBasePath) // Check if spawn window preference has been explicitly set if UserDefaults.standard.object(forKey: "NewSession.spawnWindow") != nil { @@ -468,3 +502,73 @@ struct NewSessionForm: View { UserDefaults.standard.set(titleMode.rawValue, forKey: "NewSession.titleMode") } } + +// MARK: - Repository Dropdown List + +private struct RepositoryDropdownList: View { + let repositories: [DiscoveredRepository] + let isDiscovering: Bool + @Binding var selectedPath: String + @Binding var isShowing: Bool + + var body: some View { + VStack(spacing: 0) { + ScrollView { + LazyVStack(spacing: 0) { + ForEach(repositories) { repository in + Button(action: { + selectedPath = repository.path + isShowing = false + }) { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(repository.displayName) + .font(.system(size: 11, weight: .medium)) + .foregroundColor(.primary) + + Text(repository.relativePath) + .font(.system(size: 10)) + .foregroundColor(.secondary) + } + + Spacer() + + Text(repository.formattedLastModified) + .font(.system(size: 10)) + .foregroundColor(.secondary) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: 4) + .fill(Color.clear) + ) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .onHover { hovering in + if hovering { + // Add hover effect if needed + } + } + + if repository.id != repositories.last?.id { + Divider() + .padding(.horizontal, 8) + } + } + } + } + .frame(maxHeight: 200) + } + .padding(.vertical, 4) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(.regularMaterial) + ) + .overlay( + RoundedRectangle(cornerRadius: 6) + .stroke(Color.primary.opacity(0.1), lineWidth: 1) + ) + } +} diff --git a/mac/VibeTunnel/Presentation/Components/StatusBarController.swift b/mac/VibeTunnel/Presentation/Components/StatusBarController.swift index 7e42a541..9c4f759d 100644 --- a/mac/VibeTunnel/Presentation/Components/StatusBarController.swift +++ b/mac/VibeTunnel/Presentation/Components/StatusBarController.swift @@ -24,6 +24,7 @@ final class StatusBarController: NSObject { private let tailscaleService: TailscaleService private let terminalLauncher: TerminalLauncher private let gitRepositoryMonitor: GitRepositoryMonitor + private let repositoryDiscovery: RepositoryDiscoveryService // MARK: - State Tracking @@ -41,7 +42,8 @@ final class StatusBarController: NSObject { ngrokService: NgrokService, tailscaleService: TailscaleService, terminalLauncher: TerminalLauncher, - gitRepositoryMonitor: GitRepositoryMonitor + gitRepositoryMonitor: GitRepositoryMonitor, + repositoryDiscovery: RepositoryDiscoveryService ) { self.sessionMonitor = sessionMonitor self.serverManager = serverManager @@ -49,7 +51,8 @@ final class StatusBarController: NSObject { self.tailscaleService = tailscaleService self.terminalLauncher = terminalLauncher self.gitRepositoryMonitor = gitRepositoryMonitor - + self.repositoryDiscovery = repositoryDiscovery + self.menuManager = StatusBarMenuManager() super.init() @@ -90,7 +93,8 @@ final class StatusBarController: NSObject { ngrokService: ngrokService, tailscaleService: tailscaleService, terminalLauncher: terminalLauncher, - gitRepositoryMonitor: gitRepositoryMonitor + gitRepositoryMonitor: gitRepositoryMonitor, + repositoryDiscovery: repositoryDiscovery ) menuManager.setup(with: configuration) } diff --git a/mac/VibeTunnel/Presentation/Components/StatusBarMenuManager.swift b/mac/VibeTunnel/Presentation/Components/StatusBarMenuManager.swift index 2c8998c1..7de3e873 100644 --- a/mac/VibeTunnel/Presentation/Components/StatusBarMenuManager.swift +++ b/mac/VibeTunnel/Presentation/Components/StatusBarMenuManager.swift @@ -43,6 +43,7 @@ final class StatusBarMenuManager: NSObject { private var tailscaleService: TailscaleService? private var terminalLauncher: TerminalLauncher? private var gitRepositoryMonitor: GitRepositoryMonitor? + private var repositoryDiscovery: RepositoryDiscoveryService? // Custom window management fileprivate var customWindow: CustomMenuWindow? @@ -78,6 +79,7 @@ final class StatusBarMenuManager: NSObject { let tailscaleService: TailscaleService let terminalLauncher: TerminalLauncher let gitRepositoryMonitor: GitRepositoryMonitor + let repositoryDiscovery: RepositoryDiscoveryService } // MARK: - Setup @@ -89,6 +91,7 @@ final class StatusBarMenuManager: NSObject { self.tailscaleService = configuration.tailscaleService self.terminalLauncher = configuration.terminalLauncher self.gitRepositoryMonitor = configuration.gitRepositoryMonitor + self.repositoryDiscovery = configuration.repositoryDiscovery } // MARK: - State Management @@ -143,6 +146,7 @@ final class StatusBarMenuManager: NSObject { .environment(terminalLauncher) .environment(sessionService) .environment(gitRepositoryMonitor) + .environment(repositoryDiscovery) // Wrap in custom container for proper styling let containerView = CustomMenuContainer { diff --git a/mac/VibeTunnel/Presentation/Views/Settings/AdvancedSettingsView.swift b/mac/VibeTunnel/Presentation/Views/Settings/AdvancedSettingsView.swift index 1d02d500..9eda9591 100644 --- a/mac/VibeTunnel/Presentation/Views/Settings/AdvancedSettingsView.swift +++ b/mac/VibeTunnel/Presentation/Views/Settings/AdvancedSettingsView.swift @@ -16,6 +16,8 @@ struct AdvancedSettingsView: View { private var cleanupOnStartup = true @AppStorage("showInDock") private var showInDock = true + @AppStorage("repositoryBasePath") + private var repositoryBasePath = AppConstants.Defaults.repositoryBasePath @State private var cliInstaller = CLIInstaller() @State private var showingVtConflictAlert = false @@ -25,6 +27,9 @@ struct AdvancedSettingsView: View { // Apps preference section TerminalPreferenceSection() + // Repository section + RepositorySettingsSection(repositoryBasePath: $repositoryBasePath) + // Integration section Section { VStack(alignment: .leading, spacing: 4) { @@ -569,3 +574,58 @@ private struct WindowHighlightSettingsSection: View { } } } + +// MARK: - Repository Settings Section + +private struct RepositorySettingsSection: View { + @Binding var repositoryBasePath: String + + var body: some View { + Section { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 8) { + TextField("Default base path", text: $repositoryBasePath) + .textFieldStyle(.roundedBorder) + + Button(action: selectDirectory) { + Image(systemName: "folder") + .font(.system(size: 12)) + .foregroundColor(.secondary) + } + .buttonStyle(.borderless) + .help("Choose directory") + } + + Text("Base path where VibeTunnel will search for Git repositories to show in the New Session form.") + .font(.caption) + .foregroundStyle(.secondary) + } + } header: { + Text("Repository Discovery") + .font(.headline) + } footer: { + Text("Git repositories found in this directory will appear in the New Session form for quick access.") + .font(.caption) + .frame(maxWidth: .infinity) + .multilineTextAlignment(.center) + } + } + + private func selectDirectory() { + let panel = NSOpenPanel() + panel.canChooseFiles = false + panel.canChooseDirectories = true + panel.allowsMultipleSelection = false + panel.directoryURL = URL(fileURLWithPath: NSString(string: repositoryBasePath).expandingTildeInPath) + + if panel.runModal() == .OK, let url = panel.url { + let path = url.path + let homeDir = NSHomeDirectory() + if path.hasPrefix(homeDir) { + repositoryBasePath = "~" + path.dropFirst(homeDir.count) + } else { + repositoryBasePath = path + } + } + } +} diff --git a/mac/VibeTunnel/VibeTunnelApp.swift b/mac/VibeTunnel/VibeTunnelApp.swift index 68baa677..1c3e0451 100644 --- a/mac/VibeTunnel/VibeTunnelApp.swift +++ b/mac/VibeTunnel/VibeTunnelApp.swift @@ -19,6 +19,7 @@ struct VibeTunnelApp: App { @State var permissionManager = SystemPermissionManager.shared @State var terminalLauncher = TerminalLauncher.shared @State var gitRepositoryMonitor = GitRepositoryMonitor() + @State var repositoryDiscoveryService = RepositoryDiscoveryService() @State var screencapService: ScreencapService? init() { @@ -47,6 +48,7 @@ struct VibeTunnelApp: App { .environment(permissionManager) .environment(terminalLauncher) .environment(gitRepositoryMonitor) + .environment(repositoryDiscoveryService) } .windowResizability(.contentSize) .defaultSize(width: 580, height: 480) @@ -65,6 +67,7 @@ struct VibeTunnelApp: App { .environment(permissionManager) .environment(terminalLauncher) .environment(gitRepositoryMonitor) + .environment(repositoryDiscoveryService) } else { Text("Session not found") .frame(width: 400, height: 300) @@ -83,6 +86,7 @@ struct VibeTunnelApp: App { .environment(permissionManager) .environment(terminalLauncher) .environment(gitRepositoryMonitor) + .environment(repositoryDiscoveryService) } .commands { CommandGroup(after: .appInfo) { @@ -273,7 +277,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser let ngrokService = app?.ngrokService, let tailscaleService = app?.tailscaleService, let terminalLauncher = app?.terminalLauncher, - let gitRepositoryMonitor = app?.gitRepositoryMonitor + let gitRepositoryMonitor = app?.gitRepositoryMonitor, + let repositoryDiscoveryService = app?.repositoryDiscoveryService { // Connect GitRepositoryMonitor to SessionMonitor for pre-caching sessionMonitor.gitRepositoryMonitor = gitRepositoryMonitor @@ -284,7 +289,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser ngrokService: ngrokService, tailscaleService: tailscaleService, terminalLauncher: terminalLauncher, - gitRepositoryMonitor: gitRepositoryMonitor + gitRepositoryMonitor: gitRepositoryMonitor, + repositoryDiscovery: repositoryDiscoveryService ) } }