mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-27 15:17:38 +00:00
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:
parent
43c98cac4b
commit
ac2f3da586
7 changed files with 459 additions and 18 deletions
|
|
@ -15,6 +15,7 @@ enum AppConstants {
|
||||||
static let welcomeVersion = "welcomeVersion"
|
static let welcomeVersion = "welcomeVersion"
|
||||||
static let preventSleepWhenRunning = "preventSleepWhenRunning"
|
static let preventSleepWhenRunning = "preventSleepWhenRunning"
|
||||||
static let enableScreencapService = "enableScreencapService"
|
static let enableScreencapService = "enableScreencapService"
|
||||||
|
static let repositoryBasePath = "repositoryBasePath"
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Default values for UserDefaults
|
/// Default values for UserDefaults
|
||||||
|
|
@ -23,6 +24,8 @@ enum AppConstants {
|
||||||
static let preventSleepWhenRunning = true
|
static let preventSleepWhenRunning = true
|
||||||
/// Screencap service is enabled by default for screen sharing
|
/// Screencap service is enabled by default for screen sharing
|
||||||
static let enableScreencapService = true
|
static let enableScreencapService = true
|
||||||
|
/// Default repository base path for auto-discovery
|
||||||
|
static let repositoryBasePath = "~/"
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Helper to get boolean value with proper default
|
/// Helper to get boolean value with proper default
|
||||||
|
|
@ -40,4 +43,24 @@ enum AppConstants {
|
||||||
}
|
}
|
||||||
return UserDefaults.standard.bool(forKey: key)
|
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) ?? ""
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
240
mac/VibeTunnel/Core/Services/RepositoryDiscoveryService.swift
Normal file
240
mac/VibeTunnel/Core/Services/RepositoryDiscoveryService.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -13,6 +13,8 @@ struct NewSessionForm: View {
|
||||||
private var sessionMonitor
|
private var sessionMonitor
|
||||||
@Environment(SessionService.self)
|
@Environment(SessionService.self)
|
||||||
private var sessionService
|
private var sessionService
|
||||||
|
@Environment(RepositoryDiscoveryService.self)
|
||||||
|
private var repositoryDiscovery
|
||||||
|
|
||||||
// Form fields
|
// Form fields
|
||||||
@State private var command = "zsh"
|
@State private var command = "zsh"
|
||||||
|
|
@ -26,6 +28,7 @@ struct NewSessionForm: View {
|
||||||
@State private var showError = false
|
@State private var showError = false
|
||||||
@State private var errorMessage = ""
|
@State private var errorMessage = ""
|
||||||
@State private var isHoveringCreate = false
|
@State private var isHoveringCreate = false
|
||||||
|
@State private var showingRepositoryDropdown = false
|
||||||
@FocusState private var focusedField: Field?
|
@FocusState private var focusedField: Field?
|
||||||
|
|
||||||
enum Field: Hashable {
|
enum Field: Hashable {
|
||||||
|
|
@ -142,18 +145,45 @@ struct NewSessionForm: View {
|
||||||
.font(.system(size: 11, weight: .medium))
|
.font(.system(size: 11, weight: .medium))
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
|
|
||||||
HStack(spacing: 8) {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
TextField("~/", text: $workingDirectory)
|
HStack(spacing: 8) {
|
||||||
.textFieldStyle(.roundedBorder)
|
TextField("~/", text: $workingDirectory)
|
||||||
.focused($focusedField, equals: .directory)
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.focused($focusedField, equals: .directory)
|
||||||
|
|
||||||
Button(action: selectDirectory) {
|
Button(action: selectDirectory) {
|
||||||
Image(systemName: "folder")
|
Image(systemName: "folder")
|
||||||
.font(.system(size: 12))
|
.font(.system(size: 12))
|
||||||
.foregroundColor(.secondary)
|
.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 {
|
.onAppear {
|
||||||
loadPreferences()
|
loadPreferences()
|
||||||
focusedField = .name
|
focusedField = .name
|
||||||
|
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
let repositoryBasePath = AppConstants.stringValue(for: AppConstants.UserDefaultsKeys.repositoryBasePath)
|
||||||
|
await repositoryDiscovery.discoverRepositories(in: repositoryBasePath)
|
||||||
}
|
}
|
||||||
.alert("Error", isPresented: $showError) {
|
.alert("Error", isPresented: $showError) {
|
||||||
Button("OK") {}
|
Button("OK") {}
|
||||||
|
|
@ -442,9 +477,8 @@ struct NewSessionForm: View {
|
||||||
if let savedCommand = UserDefaults.standard.string(forKey: "NewSession.command") {
|
if let savedCommand = UserDefaults.standard.string(forKey: "NewSession.command") {
|
||||||
command = savedCommand
|
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
|
// Check if spawn window preference has been explicitly set
|
||||||
if UserDefaults.standard.object(forKey: "NewSession.spawnWindow") != nil {
|
if UserDefaults.standard.object(forKey: "NewSession.spawnWindow") != nil {
|
||||||
|
|
@ -468,3 +502,73 @@ struct NewSessionForm: View {
|
||||||
UserDefaults.standard.set(titleMode.rawValue, forKey: "NewSession.titleMode")
|
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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ final class StatusBarController: NSObject {
|
||||||
private let tailscaleService: TailscaleService
|
private let tailscaleService: TailscaleService
|
||||||
private let terminalLauncher: TerminalLauncher
|
private let terminalLauncher: TerminalLauncher
|
||||||
private let gitRepositoryMonitor: GitRepositoryMonitor
|
private let gitRepositoryMonitor: GitRepositoryMonitor
|
||||||
|
private let repositoryDiscovery: RepositoryDiscoveryService
|
||||||
|
|
||||||
// MARK: - State Tracking
|
// MARK: - State Tracking
|
||||||
|
|
||||||
|
|
@ -41,7 +42,8 @@ final class StatusBarController: NSObject {
|
||||||
ngrokService: NgrokService,
|
ngrokService: NgrokService,
|
||||||
tailscaleService: TailscaleService,
|
tailscaleService: TailscaleService,
|
||||||
terminalLauncher: TerminalLauncher,
|
terminalLauncher: TerminalLauncher,
|
||||||
gitRepositoryMonitor: GitRepositoryMonitor
|
gitRepositoryMonitor: GitRepositoryMonitor,
|
||||||
|
repositoryDiscovery: RepositoryDiscoveryService
|
||||||
) {
|
) {
|
||||||
self.sessionMonitor = sessionMonitor
|
self.sessionMonitor = sessionMonitor
|
||||||
self.serverManager = serverManager
|
self.serverManager = serverManager
|
||||||
|
|
@ -49,7 +51,8 @@ final class StatusBarController: NSObject {
|
||||||
self.tailscaleService = tailscaleService
|
self.tailscaleService = tailscaleService
|
||||||
self.terminalLauncher = terminalLauncher
|
self.terminalLauncher = terminalLauncher
|
||||||
self.gitRepositoryMonitor = gitRepositoryMonitor
|
self.gitRepositoryMonitor = gitRepositoryMonitor
|
||||||
|
self.repositoryDiscovery = repositoryDiscovery
|
||||||
|
|
||||||
self.menuManager = StatusBarMenuManager()
|
self.menuManager = StatusBarMenuManager()
|
||||||
|
|
||||||
super.init()
|
super.init()
|
||||||
|
|
@ -90,7 +93,8 @@ final class StatusBarController: NSObject {
|
||||||
ngrokService: ngrokService,
|
ngrokService: ngrokService,
|
||||||
tailscaleService: tailscaleService,
|
tailscaleService: tailscaleService,
|
||||||
terminalLauncher: terminalLauncher,
|
terminalLauncher: terminalLauncher,
|
||||||
gitRepositoryMonitor: gitRepositoryMonitor
|
gitRepositoryMonitor: gitRepositoryMonitor,
|
||||||
|
repositoryDiscovery: repositoryDiscovery
|
||||||
)
|
)
|
||||||
menuManager.setup(with: configuration)
|
menuManager.setup(with: configuration)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,7 @@ final class StatusBarMenuManager: NSObject {
|
||||||
private var tailscaleService: TailscaleService?
|
private var tailscaleService: TailscaleService?
|
||||||
private var terminalLauncher: TerminalLauncher?
|
private var terminalLauncher: TerminalLauncher?
|
||||||
private var gitRepositoryMonitor: GitRepositoryMonitor?
|
private var gitRepositoryMonitor: GitRepositoryMonitor?
|
||||||
|
private var repositoryDiscovery: RepositoryDiscoveryService?
|
||||||
|
|
||||||
// Custom window management
|
// Custom window management
|
||||||
fileprivate var customWindow: CustomMenuWindow?
|
fileprivate var customWindow: CustomMenuWindow?
|
||||||
|
|
@ -78,6 +79,7 @@ final class StatusBarMenuManager: NSObject {
|
||||||
let tailscaleService: TailscaleService
|
let tailscaleService: TailscaleService
|
||||||
let terminalLauncher: TerminalLauncher
|
let terminalLauncher: TerminalLauncher
|
||||||
let gitRepositoryMonitor: GitRepositoryMonitor
|
let gitRepositoryMonitor: GitRepositoryMonitor
|
||||||
|
let repositoryDiscovery: RepositoryDiscoveryService
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Setup
|
// MARK: - Setup
|
||||||
|
|
@ -89,6 +91,7 @@ final class StatusBarMenuManager: NSObject {
|
||||||
self.tailscaleService = configuration.tailscaleService
|
self.tailscaleService = configuration.tailscaleService
|
||||||
self.terminalLauncher = configuration.terminalLauncher
|
self.terminalLauncher = configuration.terminalLauncher
|
||||||
self.gitRepositoryMonitor = configuration.gitRepositoryMonitor
|
self.gitRepositoryMonitor = configuration.gitRepositoryMonitor
|
||||||
|
self.repositoryDiscovery = configuration.repositoryDiscovery
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - State Management
|
// MARK: - State Management
|
||||||
|
|
@ -143,6 +146,7 @@ final class StatusBarMenuManager: NSObject {
|
||||||
.environment(terminalLauncher)
|
.environment(terminalLauncher)
|
||||||
.environment(sessionService)
|
.environment(sessionService)
|
||||||
.environment(gitRepositoryMonitor)
|
.environment(gitRepositoryMonitor)
|
||||||
|
.environment(repositoryDiscovery)
|
||||||
|
|
||||||
// Wrap in custom container for proper styling
|
// Wrap in custom container for proper styling
|
||||||
let containerView = CustomMenuContainer {
|
let containerView = CustomMenuContainer {
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,8 @@ struct AdvancedSettingsView: View {
|
||||||
private var cleanupOnStartup = true
|
private var cleanupOnStartup = true
|
||||||
@AppStorage("showInDock")
|
@AppStorage("showInDock")
|
||||||
private var showInDock = true
|
private var showInDock = true
|
||||||
|
@AppStorage("repositoryBasePath")
|
||||||
|
private var repositoryBasePath = AppConstants.Defaults.repositoryBasePath
|
||||||
@State private var cliInstaller = CLIInstaller()
|
@State private var cliInstaller = CLIInstaller()
|
||||||
@State private var showingVtConflictAlert = false
|
@State private var showingVtConflictAlert = false
|
||||||
|
|
||||||
|
|
@ -25,6 +27,9 @@ struct AdvancedSettingsView: View {
|
||||||
// Apps preference section
|
// Apps preference section
|
||||||
TerminalPreferenceSection()
|
TerminalPreferenceSection()
|
||||||
|
|
||||||
|
// Repository section
|
||||||
|
RepositorySettingsSection(repositoryBasePath: $repositoryBasePath)
|
||||||
|
|
||||||
// Integration section
|
// Integration section
|
||||||
Section {
|
Section {
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ struct VibeTunnelApp: App {
|
||||||
@State var permissionManager = SystemPermissionManager.shared
|
@State var permissionManager = SystemPermissionManager.shared
|
||||||
@State var terminalLauncher = TerminalLauncher.shared
|
@State var terminalLauncher = TerminalLauncher.shared
|
||||||
@State var gitRepositoryMonitor = GitRepositoryMonitor()
|
@State var gitRepositoryMonitor = GitRepositoryMonitor()
|
||||||
|
@State var repositoryDiscoveryService = RepositoryDiscoveryService()
|
||||||
@State var screencapService: ScreencapService?
|
@State var screencapService: ScreencapService?
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
|
|
@ -47,6 +48,7 @@ struct VibeTunnelApp: App {
|
||||||
.environment(permissionManager)
|
.environment(permissionManager)
|
||||||
.environment(terminalLauncher)
|
.environment(terminalLauncher)
|
||||||
.environment(gitRepositoryMonitor)
|
.environment(gitRepositoryMonitor)
|
||||||
|
.environment(repositoryDiscoveryService)
|
||||||
}
|
}
|
||||||
.windowResizability(.contentSize)
|
.windowResizability(.contentSize)
|
||||||
.defaultSize(width: 580, height: 480)
|
.defaultSize(width: 580, height: 480)
|
||||||
|
|
@ -65,6 +67,7 @@ struct VibeTunnelApp: App {
|
||||||
.environment(permissionManager)
|
.environment(permissionManager)
|
||||||
.environment(terminalLauncher)
|
.environment(terminalLauncher)
|
||||||
.environment(gitRepositoryMonitor)
|
.environment(gitRepositoryMonitor)
|
||||||
|
.environment(repositoryDiscoveryService)
|
||||||
} else {
|
} else {
|
||||||
Text("Session not found")
|
Text("Session not found")
|
||||||
.frame(width: 400, height: 300)
|
.frame(width: 400, height: 300)
|
||||||
|
|
@ -83,6 +86,7 @@ struct VibeTunnelApp: App {
|
||||||
.environment(permissionManager)
|
.environment(permissionManager)
|
||||||
.environment(terminalLauncher)
|
.environment(terminalLauncher)
|
||||||
.environment(gitRepositoryMonitor)
|
.environment(gitRepositoryMonitor)
|
||||||
|
.environment(repositoryDiscoveryService)
|
||||||
}
|
}
|
||||||
.commands {
|
.commands {
|
||||||
CommandGroup(after: .appInfo) {
|
CommandGroup(after: .appInfo) {
|
||||||
|
|
@ -273,7 +277,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser
|
||||||
let ngrokService = app?.ngrokService,
|
let ngrokService = app?.ngrokService,
|
||||||
let tailscaleService = app?.tailscaleService,
|
let tailscaleService = app?.tailscaleService,
|
||||||
let terminalLauncher = app?.terminalLauncher,
|
let terminalLauncher = app?.terminalLauncher,
|
||||||
let gitRepositoryMonitor = app?.gitRepositoryMonitor
|
let gitRepositoryMonitor = app?.gitRepositoryMonitor,
|
||||||
|
let repositoryDiscoveryService = app?.repositoryDiscoveryService
|
||||||
{
|
{
|
||||||
// Connect GitRepositoryMonitor to SessionMonitor for pre-caching
|
// Connect GitRepositoryMonitor to SessionMonitor for pre-caching
|
||||||
sessionMonitor.gitRepositoryMonitor = gitRepositoryMonitor
|
sessionMonitor.gitRepositoryMonitor = gitRepositoryMonitor
|
||||||
|
|
@ -284,7 +289,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser
|
||||||
ngrokService: ngrokService,
|
ngrokService: ngrokService,
|
||||||
tailscaleService: tailscaleService,
|
tailscaleService: tailscaleService,
|
||||||
terminalLauncher: terminalLauncher,
|
terminalLauncher: terminalLauncher,
|
||||||
gitRepositoryMonitor: gitRepositoryMonitor
|
gitRepositoryMonitor: gitRepositoryMonitor,
|
||||||
|
repositoryDiscovery: repositoryDiscoveryService
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue