Better folder selection with git repo discovery (#274)

* added folder selection with git repo discovery

* Increase touch target for repository and folder selection buttons

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Jan Remeš 2025-07-09 09:24:30 +02:00 committed by GitHub
parent 43c98cac4b
commit ac2f3da586
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 459 additions and 18 deletions

View file

@ -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) ?? ""
}
}

View file

@ -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)
}
}

View file

@ -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)
)
}
}

View file

@ -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)
}

View file

@ -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 {

View file

@ -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
}
}
}
}

View file

@ -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
)
}
}