diff --git a/Copilot for Xcode.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Copilot for Xcode.xcworkspace/xcshareddata/swiftpm/Package.resolved index 3a57f6e9..60d6595b 100644 --- a/Copilot for Xcode.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Copilot for Xcode.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -23,8 +23,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/combine-schedulers", "state" : { - "revision" : "9dc9cbe4bc45c65164fa653a563d8d8db61b09bb", - "version" : "1.0.0" + "revision" : "5928286acce13def418ec36d05a001a9641086f2", + "version" : "1.0.3" } }, { @@ -167,8 +167,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-case-paths", "state" : { - "revision" : "8d712376c99fc0267aa0e41fea732babe365270a", - "version" : "1.3.3" + "revision" : "41b89b8b68d8c56c622dbb7132258f1a3e638b25", + "version" : "1.7.0" } }, { @@ -176,8 +176,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-clocks", "state" : { - "revision" : "a8421d68068d8f45fbceb418fbf22c5dad4afd33", - "version" : "1.0.2" + "revision" : "cc46202b53476d64e824e0b6612da09d84ffde8e", + "version" : "1.0.6" } }, { @@ -194,8 +194,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-composable-architecture", "state" : { - "revision" : "433a23118f739078644ebeb4009e23d307af694a", - "version" : "1.10.4" + "revision" : "b1d27a82b498b8e697acabd8cc01b585d26aab19", + "version" : "1.19.1" } }, { @@ -203,8 +203,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-concurrency-extras", "state" : { - "revision" : "bb5059bde9022d69ac516803f4f227d8ac967f71", - "version" : "1.1.0" + "revision" : "82a4ae7170d98d8538ec77238b7eb8e7199ef2e8", + "version" : "1.3.1" } }, { @@ -212,8 +212,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-custom-dump", "state" : { - "revision" : "f01efb26f3a192a0e88dcdb7c3c391ec2fc25d9c", - "version" : "1.3.0" + "revision" : "82645ec760917961cfa08c9c0c7104a57a0fa4b1", + "version" : "1.3.3" } }, { @@ -221,8 +221,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-dependencies", "state" : { - "revision" : "350e1e119babe8525f9bd155b76640a5de270184", - "version" : "1.3.0" + "revision" : "4c90d6b2b9bf0911af87b103bb40f41771891596", + "version" : "1.9.2" } }, { @@ -230,8 +230,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-identified-collections", "state" : { - "revision" : "d533cd18b0b456b106694a9899f917ee595f2666", - "version" : "1.0.2" + "revision" : "322d9ffeeba85c9f7c4984b39422ec7cc3c56597", + "version" : "1.1.1" } }, { @@ -243,6 +243,15 @@ "version" : "2.4.0" } }, + { + "identity" : "swift-navigation", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-navigation", + "state" : { + "revision" : "db6bc9dbfed001f21e6728fd36413d9342c235b4", + "version" : "2.3.0" + } + }, { "identity" : "swift-parsing", "kind" : "remoteSourceControl", @@ -257,35 +266,35 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-perception", "state" : { - "revision" : "64f7f6c28c6a4d3c4b9da2ba02383e29ab48a8cf", - "version" : "1.2.2" + "revision" : "d924c62a70fca5f43872f286dbd7cef0957f1c01", + "version" : "1.6.0" } }, { - "identity" : "swift-syntax", + "identity" : "swift-sharing", "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-syntax.git", + "location" : "https://github.com/pointfreeco/swift-sharing", "state" : { - "revision" : "6ad4ea24b01559dde0773e3d091f1b9e36175036", - "version" : "509.0.2" + "revision" : "75e846ee3159dc75b3a29bfc24b6ce5a557ddca9", + "version" : "2.5.2" } }, { - "identity" : "swiftui-flow-layout", + "identity" : "swift-syntax", "kind" : "remoteSourceControl", - "location" : "https://github.com/globulus/swiftui-flow-layout", + "location" : "https://github.com/apple/swift-syntax", "state" : { - "revision" : "de7da3440c3b87ba94adfa98c698828d7746a76d", - "version" : "1.0.5" + "revision" : "303e5c5c36d6a558407d364878df131c3546fad8", + "version" : "510.0.2" } }, { - "identity" : "swiftui-navigation", + "identity" : "swiftui-flow-layout", "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swiftui-navigation", + "location" : "https://github.com/globulus/swiftui-flow-layout", "state" : { - "revision" : "7ab04c6e2e6a73d34d5a762970ef88bf0aedb084", - "version" : "1.4.0" + "revision" : "de7da3440c3b87ba94adfa98c698828d7746a76d", + "version" : "1.0.5" } }, { @@ -293,8 +302,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", "state" : { - "revision" : "6f30bdba373bbd7fbfe241dddd732651f2fbd1e2", - "version" : "1.1.2" + "revision" : "39de59b2d47f7ef3ca88a039dff3084688fe27f4", + "version" : "1.5.2" } } ], diff --git a/Core/Package.swift b/Core/Package.swift index 3de53aeb..c37b0a6d 100644 --- a/Core/Package.swift +++ b/Core/Package.swift @@ -23,7 +23,7 @@ let package = Package( .library( name: "Client", targets: [ - "Client", + "Client" ] ), .library( @@ -50,15 +50,17 @@ let package = Package( ), // quick hack to support custom UserDefaults // https://github.com/sindresorhus/KeyboardShortcuts - .package(url: "https://github.com/devm33/KeyboardShortcuts", branch: "main"), - .package(url: "https://github.com/devm33/CGEventOverride", branch: "devm33/fix-stale-AXIsProcessTrusted"), + .package(url: "https://github.com/devm33/KeyboardShortcuts", branch: "main"), + .package( + url: "https://github.com/devm33/CGEventOverride", + branch: "devm33/fix-stale-AXIsProcessTrusted"), .package(url: "https://github.com/devm33/Highlightr", branch: "master"), - .package(url: "https://github.com/globulus/swiftui-flow-layout", - from: "1.0.5") + .package(url: "https://github.com/globulus/swiftui-flow-layout", from: "1.0.5"), + .package(url: "https://github.com/apple/swift-syntax", exact: "510.0.2"), ], targets: [ // MARK: - Main - + .target( name: "Client", dependencies: [ @@ -79,6 +81,7 @@ let package = Package( "ConversationTab", "KeyBindingManager", "XcodeThemeController", + "SuggestionInjector", .product(name: "TelemetryService", package: "Tool"), .product(name: "XPCShared", package: "Tool"), .product(name: "SuggestionProvider", package: "Tool"), @@ -98,6 +101,8 @@ let package = Package( .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), .product(name: "Dependencies", package: "swift-dependencies"), .product(name: "KeyboardShortcuts", package: "KeyboardShortcuts"), + .product(name: "SwiftSyntax", package: "swift-syntax"), + .product(name: "SwiftParser", package: "swift-syntax"), ]), .testTarget( name: "ServiceTests", @@ -112,39 +117,40 @@ let package = Package( .product(name: "ConversationServiceProvider", package: "Tool"), ] ), - + // MARK: - Host App - - .target( - name: "HostApp", - dependencies: [ - "Client", - "LaunchAgentManager", - "GitHubCopilotViewModel", - .product(name: "SuggestionProvider", package: "Tool"), - .product(name: "Toast", package: "Tool"), - .product(name: "SharedUIComponents", package: "Tool"), - .product(name: "SuggestionBasic", package: "Tool"), - .product(name: "MarkdownUI", package: "swift-markdown-ui"), - .product(name: "ChatAPIService", package: "Tool"), - .product(name: "Preferences", package: "Tool"), - .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), - .product(name: "KeyboardShortcuts", package: "KeyboardShortcuts"), - .product(name: "GitHubCopilotService", package: "Tool"), - ]), - + + .target( + name: "HostApp", + dependencies: [ + "Service", + "Client", + "LaunchAgentManager", + "GitHubCopilotViewModel", + .product(name: "SuggestionProvider", package: "Tool"), + .product(name: "Toast", package: "Tool"), + .product(name: "SharedUIComponents", package: "Tool"), + .product(name: "SuggestionBasic", package: "Tool"), + .product(name: "MarkdownUI", package: "swift-markdown-ui"), + .product(name: "ChatAPIService", package: "Tool"), + .product(name: "Preferences", package: "Tool"), + .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), + .product(name: "KeyboardShortcuts", package: "KeyboardShortcuts"), + .product(name: "GitHubCopilotService", package: "Tool"), + ]), + // MARK: - Suggestion Service - - .target( - name: "SuggestionService", - dependencies: [ - .product(name: "UserDefaultsObserver", package: "Tool"), - .product(name: "Preferences", package: "Tool"), - .product(name: "SuggestionBasic", package: "Tool"), - .product(name: "SuggestionProvider", package: "Tool"), - .product(name: "BuiltinExtension", package: "Tool"), - .product(name: "GitHubCopilotService", package: "Tool"), - ]), + + .target( + name: "SuggestionService", + dependencies: [ + .product(name: "UserDefaultsObserver", package: "Tool"), + .product(name: "Preferences", package: "Tool"), + .product(name: "SuggestionBasic", package: "Tool"), + .product(name: "SuggestionProvider", package: "Tool"), + .product(name: "BuiltinExtension", package: "Tool"), + .product(name: "GitHubCopilotService", package: "Tool"), + ]), .target( name: "SuggestionInjector", dependencies: [.product(name: "SuggestionBasic", package: "Tool")] @@ -153,89 +159,89 @@ let package = Package( name: "SuggestionInjectorTests", dependencies: ["SuggestionInjector"] ), - + // MARK: - Prompt To Code - - .target( - name: "PromptToCodeService", - dependencies: [ - .product(name: "SuggestionBasic", package: "Tool"), - .product(name: "ChatAPIService", package: "Tool"), - .product(name: "AppMonitoring", package: "Tool"), - .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), - ]), - + + .target( + name: "PromptToCodeService", + dependencies: [ + .product(name: "SuggestionBasic", package: "Tool"), + .product(name: "ChatAPIService", package: "Tool"), + .product(name: "AppMonitoring", package: "Tool"), + .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), + ]), + // MARK: - Chat - - .target( - name: "ChatService", - dependencies: [ - "PersistMiddleware", - .product(name: "AppMonitoring", package: "Tool"), - .product(name: "Parsing", package: "swift-parsing"), - .product(name: "ChatAPIService", package: "Tool"), - .product(name: "Preferences", package: "Tool"), - .product(name: "AXHelper", package: "Tool"), - .product(name: "ConversationServiceProvider", package: "Tool"), - .product(name: "GitHubCopilotService", package: "Tool"), - .product(name: "Workspace", package: "Tool"), - .product(name: "Terminal", package: "Tool") - ]), - .testTarget( - name: "ChatServiceTests", - dependencies: ["ChatService"] - ), - .target( - name: "ConversationTab", - dependencies: [ - "ChatService", - .product(name: "SharedUIComponents", package: "Tool"), - .product(name: "ChatAPIService", package: "Tool"), - .product(name: "Logger", package: "Tool"), - .product(name: "ChatTab", package: "Tool"), - .product(name: "Terminal", package: "Tool"), - .product(name: "Cache", package: "Tool"), - .product(name: "MarkdownUI", package: "swift-markdown-ui"), - .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), - .product(name: "SwiftUIFlowLayout", package: "swiftui-flow-layout"), - .product(name: "Persist", package: "Tool"), - .product(name: "Terminal", package: "Tool") - ] - ), - + .target( + name: "ChatService", + dependencies: [ + "PersistMiddleware", + .product(name: "AppMonitoring", package: "Tool"), + .product(name: "Parsing", package: "swift-parsing"), + .product(name: "ChatAPIService", package: "Tool"), + .product(name: "Preferences", package: "Tool"), + .product(name: "AXHelper", package: "Tool"), + .product(name: "ConversationServiceProvider", package: "Tool"), + .product(name: "GitHubCopilotService", package: "Tool"), + .product(name: "Workspace", package: "Tool"), + .product(name: "Terminal", package: "Tool"), + ]), + .testTarget( + name: "ChatServiceTests", + dependencies: ["ChatService"] + ), + + .target( + name: "ConversationTab", + dependencies: [ + "ChatService", + .product(name: "SharedUIComponents", package: "Tool"), + .product(name: "ChatAPIService", package: "Tool"), + .product(name: "Logger", package: "Tool"), + .product(name: "ChatTab", package: "Tool"), + .product(name: "Terminal", package: "Tool"), + .product(name: "Cache", package: "Tool"), + .product(name: "MarkdownUI", package: "swift-markdown-ui"), + .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), + .product(name: "SwiftUIFlowLayout", package: "swiftui-flow-layout"), + .product(name: "Persist", package: "Tool"), + .product(name: "Terminal", package: "Tool"), + ] + ), + // MARK: - UI - - .target( - name: "SuggestionWidget", - dependencies: [ - "PromptToCodeService", - "ConversationTab", - "GitHubCopilotViewModel", - "PersistMiddleware", - .product(name: "GitHubCopilotService", package: "Tool"), - .product(name: "Toast", package: "Tool"), - .product(name: "UserDefaultsObserver", package: "Tool"), - .product(name: "SharedUIComponents", package: "Tool"), - .product(name: "AppMonitoring", package: "Tool"), - .product(name: "ChatTab", package: "Tool"), - .product(name: "Logger", package: "Tool"), - .product(name: "CustomAsyncAlgorithms", package: "Tool"), - .product(name: "HostAppActivator", package: "Tool"), - .product(name: "AsyncAlgorithms", package: "swift-async-algorithms"), - .product(name: "MarkdownUI", package: "swift-markdown-ui"), - .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), - ] - ), + + .target( + name: "SuggestionWidget", + dependencies: [ + "PromptToCodeService", + "ConversationTab", + "GitHubCopilotViewModel", + "PersistMiddleware", + .product(name: "GitHubCopilotService", package: "Tool"), + .product(name: "Toast", package: "Tool"), + .product(name: "UserDefaultsObserver", package: "Tool"), + .product(name: "SharedUIComponents", package: "Tool"), + .product(name: "AppMonitoring", package: "Tool"), + .product(name: "ChatTab", package: "Tool"), + .product(name: "Logger", package: "Tool"), + .product(name: "CustomAsyncAlgorithms", package: "Tool"), + .product(name: "HostAppActivator", package: "Tool"), + .product(name: "AsyncAlgorithms", package: "swift-async-algorithms"), + .product(name: "MarkdownUI", package: "swift-markdown-ui"), + .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), + ] + ), .testTarget(name: "SuggestionWidgetTests", dependencies: ["SuggestionWidget"]), - + // MARK: - Helpers - + .target(name: "FileChangeChecker"), .target( name: "LaunchAgentManager", dependencies: [ - .product(name: "Logger", package: "Tool"), + .product(name: "Logger", package: "Tool") ] ), .target( @@ -284,7 +290,7 @@ let package = Package( .product(name: "Highlightr", package: "Highlightr"), ] ), - + // MARK: Persist Middleware .target( name: "PersistMiddleware", @@ -292,9 +298,8 @@ let package = Package( .product(name: "Persist", package: "Tool"), .product(name: "ChatTab", package: "Tool"), .product(name: "ChatAPIService", package: "Tool"), - .product(name: "ConversationServiceProvider", package: "Tool") + .product(name: "ConversationServiceProvider", package: "Tool"), ] - ) + ), ] ) - diff --git a/Core/Sources/ConversationTab/ConversationTab.swift b/Core/Sources/ConversationTab/ConversationTab.swift index 5d6d5014..1b698ffb 100644 --- a/Core/Sources/ConversationTab/ConversationTab.swift +++ b/Core/Sources/ConversationTab/ConversationTab.swift @@ -156,6 +156,7 @@ public class ConversationTab: ChatTab { self.isRestored = true } + @MainActor public func start() { observer = .init() cancellable = [] diff --git a/Core/Sources/HostApp/Benchmark/DI/BenchmarkModule.swift b/Core/Sources/HostApp/Benchmark/DI/BenchmarkModule.swift new file mode 100644 index 00000000..0cffc32f --- /dev/null +++ b/Core/Sources/HostApp/Benchmark/DI/BenchmarkModule.swift @@ -0,0 +1,68 @@ +protocol BenchmarkModuleType { + func provide() -> BenchmarkViewModel + func provide(directory: BenchmarkDirectory) -> BenchmarkDirectoryEntryViewModel + func provide() -> ConfigurationViewModel + func provide() -> AddDirectoryViewModel +} + +class BenchmarkModule: BenchmarkModuleType { + private var benchmarkSettingsRepository: BenchmarkSettingsRepository? + private var benchmarkManager: BenchmarkManager? + + static let shared: BenchmarkModuleType = BenchmarkModule() + + private func component() -> LocalStorageManager { + UserDefaultsLocalStorageManager() + } + + private func component() -> BenchmarkSettingsRepository { + if let repository = benchmarkSettingsRepository { + return repository + } else { + let repository = LocalBenchmarkSettingsRepository( + localStorageManager: component() + ) + benchmarkSettingsRepository = repository + return repository + } + } + + private func component() -> BenchmarkManager { + if let manager = benchmarkManager { + return manager + } else { + let manager = MultiFileContextBenchmarkManager( + benchmarkSettingsRepository: component() + ) + benchmarkManager = manager + return manager + } + } + + func provide() -> BenchmarkViewModel { + BenchmarkViewModel( + benchmarkSettingsRepository: component(), + benchmarkManager: component() + ) + } + + func provide(directory: BenchmarkDirectory) -> BenchmarkDirectoryEntryViewModel { + BenchmarkDirectoryEntryViewModel( + directory: directory, + benchmarkSettingsRepository: component(), + benchmarkManager: component() + ) + } + + func provide() -> ConfigurationViewModel { + ConfigurationViewModel( + benchmarkSettingsRepository: component() + ) + } + + func provide() -> AddDirectoryViewModel { + AddDirectoryViewModel( + benchmarkSettingsRepository: component() + ) + } +} diff --git a/Core/Sources/HostApp/Benchmark/Data/Entity/BenchmarkDirectoryDTO.swift b/Core/Sources/HostApp/Benchmark/Data/Entity/BenchmarkDirectoryDTO.swift new file mode 100644 index 00000000..9538e9c6 --- /dev/null +++ b/Core/Sources/HostApp/Benchmark/Data/Entity/BenchmarkDirectoryDTO.swift @@ -0,0 +1,6 @@ +import Foundation + +struct BenchmarkDirectoryDTO: Codable { + let name: String + let url: URL +} diff --git a/Core/Sources/HostApp/Benchmark/Data/Entity/MetadataDTO.swift b/Core/Sources/HostApp/Benchmark/Data/Entity/MetadataDTO.swift new file mode 100644 index 00000000..07211271 --- /dev/null +++ b/Core/Sources/HostApp/Benchmark/Data/Entity/MetadataDTO.swift @@ -0,0 +1,21 @@ +struct MetadataDTO: Codable { + let taskId: Int + let entrypoint: EntrypointDTO + let files: [String] + + private enum CodingKeys: String, CodingKey { + case taskId = "task_id" + case entrypoint + case files + } + + struct EntrypointDTO: Codable { + let filename: String + let cursor: CursorPositionDTO + } + + struct CursorPositionDTO: Codable { + let line: Int + let character: Int + } +} diff --git a/Core/Sources/HostApp/Benchmark/Data/Entity/StoredSuggestionDTO.swift b/Core/Sources/HostApp/Benchmark/Data/Entity/StoredSuggestionDTO.swift new file mode 100644 index 00000000..e553e782 --- /dev/null +++ b/Core/Sources/HostApp/Benchmark/Data/Entity/StoredSuggestionDTO.swift @@ -0,0 +1,59 @@ +import Foundation + +struct StoredSuggestionDTO: Encodable { + let fileURL: String + let id: String + let suggestionText: String + let position: CursorPositionDTO + let range: CursorRangeDTO + let createdAt: String + let relevantSymbols: [RelevantSymbolsDTO] + var relevantSymbolCount: Int { + relevantSymbols.count + } + let model: String + let relevantFileScanningDurationInSeconds: Double? + + struct CursorPositionDTO: Codable { + let line: Int + let character: Int + } + + struct CursorRangeDTO: Codable { + let start: CursorPositionDTO + let end: CursorPositionDTO + } + + struct RelevantSymbolsDTO: Codable { + let fileURL: String + let name: String + let content: String + let startLine: Int + let endLine: Int + let kind: String + } + + enum CodingKeys: String, CodingKey { + case fileURL, id, suggestionText, position, range, createdAt, relevantSymbols, model + case relevantFileScanningDurationInSeconds + case relevantSymbolCount // write, but ignore on decoding + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(fileURL, forKey: .fileURL) + try container.encode(id, forKey: .id) + try container.encode(suggestionText, forKey: .suggestionText) + try container.encode(position, forKey: .position) + try container.encode(range, forKey: .range) + try container.encode(createdAt, forKey: .createdAt) + try container.encode(relevantSymbols, forKey: .relevantSymbols) + try container.encode(relevantSymbolCount, forKey: .relevantSymbolCount) + try container.encode(model, forKey: .model) + + if let value = relevantFileScanningDurationInSeconds { + let rounded = Double(round(100 * value) / 100) + try container.encode(rounded, forKey: .relevantFileScanningDurationInSeconds) + } + } +} diff --git a/Core/Sources/HostApp/Benchmark/Data/Manager/MultiFileContextBenchmarkManager.swift b/Core/Sources/HostApp/Benchmark/Data/Manager/MultiFileContextBenchmarkManager.swift new file mode 100644 index 00000000..d0214534 --- /dev/null +++ b/Core/Sources/HostApp/Benchmark/Data/Manager/MultiFileContextBenchmarkManager.swift @@ -0,0 +1,634 @@ +import Foundation +import Combine +import Dependencies +import Workspace +import Service +import SuggestionBasic +import SuggestionProvider +import SuggestionService +import WorkspaceSuggestionService +import CopilotForXcodeKit +import BuiltinExtension +import GitHubCopilotService + +class MultiFileContextBenchmarkManager: BenchmarkManager { + private let benchmarkSettingsRepository: BenchmarkSettingsRepository + @WorkspaceActor + private let workspacePool: WorkspacePool + + private let isMultiFileEnabledSubject = CurrentValueSubject(true) + var isMultiFileEnabled: AnyPublisher { + isMultiFileEnabledSubject.eraseToAnyPublisher() + } + private let taskStatesSubject = CurrentValueSubject<[BenchmarkDirectory: [TaskStatus]], Never>([:]) + var taskStates: AnyPublisher<[BenchmarkDirectory: [TaskStatus]], Never> { + taskStatesSubject.eraseToAnyPublisher() + } + private var openAIKey: String? + private let selectedGenAIModelSubject = CurrentValueSubject(.defaultModel) + var selectedGenAIModel: AnyPublisher { + selectedGenAIModelSubject.eraseToAnyPublisher() + } + + private let contextLevelLimitSubject = CurrentValueSubject(.firstLevel) + var contextLevelLimit: AnyPublisher { + contextLevelLimitSubject.eraseToAnyPublisher() + } + + private var contextFileAmountLimitSubject = CurrentValueSubject(nil) + var contextFileAmountLimit: AnyPublisher { + contextFileAmountLimitSubject.eraseToAnyPublisher() + } + + private var cancellables = Set() + + init(benchmarkSettingsRepository: BenchmarkSettingsRepository) { + @Dependency(\.workspacePool) var workspacePool + BuiltinExtensionManager.shared.setupExtensions([ + GitHubCopilotExtension(workspacePool: workspacePool) + ]) + self.workspacePool = workspacePool + self.benchmarkSettingsRepository = benchmarkSettingsRepository + + benchmarkSettingsRepository.benchmarkDirectories.sink { [weak self] benchmarkDirectories in + for directory in benchmarkDirectories { + guard let taskPaths: [URL] = self?.getTaskFolders(in: directory.url) else { + return + } + let initialTaskStates = taskPaths.map { _ in TaskStatus.notStarted } + Task { await self?.updateTaskStates(in: directory, newValue: initialTaskStates) } + } + }.store(in: &cancellables) + + benchmarkSettingsRepository.openAIKey.sink { [weak self] key in + self?.openAIKey = key + }.store(in: &cancellables) + } + + @WorkspaceActor + private func registerPlugins() { + workspacePool.registerPlugin { + SuggestionServiceWorkspacePlugin(workspace: $0) { SuggestionService.service() } + } + workspacePool.registerPlugin { + // contains nested GitHubCopilotBaseService which runs the LSP on constructor call + GitHubCopilotWorkspacePlugin(workspace: $0) + } + workspacePool.registerPlugin { + BuiltinExtensionWorkspacePlugin(workspace: $0) + } + } + + @WorkspaceActor + private func unregisterPlugins() { + workspacePool.unregisterPlugin(SuggestionServiceWorkspacePlugin.self) + workspacePool.unregisterPlugin(GitHubCopilotWorkspacePlugin.self) + workspacePool.unregisterPlugin(BuiltinExtensionWorkspacePlugin.self) + } + + func getCodeSuggestions(at benchmarkDirectory: BenchmarkDirectory) async throws { + let taskPaths: [URL] = getTaskFolders(in: benchmarkDirectory.url) + let initialTaskStates = taskPaths.map { _ in TaskStatus.scheduled } + await updateTaskStates(in: benchmarkDirectory, newValue: initialTaskStates) + for (index, _) in taskPaths.enumerated() { + await runTask(at: index, in: benchmarkDirectory) + try await Task.sleep(nanoseconds: 3_000_000_000) + } + } + + private func getCodeSuggestionFromService(at directory: URL, from benchmarkDirectory: URL) async -> SuggestionResponse? { + guard let metadata: MetadataDTO = readMetadata(at: directory), + let xcodeWorkspaceFileURL = findXcodeWorkspace(in: benchmarkDirectory), + let workspace = try? await workspacePool.fetchOrCreateWorkspace(workspaceURL: xcodeWorkspaceFileURL) + else { return nil } + let entrypoint = metadata.mapToEntrypoint(prefixing: benchmarkDirectory.path) + let content: String = (try? String(contentsOf: entrypoint.fileURL, encoding: .utf8)) ?? "" + + let relevantSymbolsSummary: RelevantSymbolsSummary? = await retrieveRelevantSymbolsForFileContent( + file: FileContent(fileURL: entrypoint.fileURL.path, content: content), + workspace: workspace + ) + let relevantSymbols = relevantSymbolsSummary?.symbolsAtLevel.flatMap { $0 } ?? [] + let suggestionRequest = SuggestionProvider.SuggestionRequest( + fileURL: entrypoint.fileURL, + relativePath: entrypoint.fileURL.path.replacingOccurrences(of: benchmarkDirectory.path, with: ""), + content: content, + originalContent: content, + lines: content.linesWithNewlineSuffix, + cursorPosition: entrypoint.cursor, + cursorOffset: content.cursorOffset(line: entrypoint.cursor.line, character: entrypoint.cursor.character) ?? 0, + tabSize: 4, + indentSize: 4, + usesTabsForIndentation: false, + relevantCodeSnippets: relevantSymbols.mapToRelevantCodeSnippets() + ) + let workspaceInfo = WorkspaceInfo(workspaceURL: xcodeWorkspaceFileURL, projectURL: benchmarkDirectory) + await openFilespaces(entrypoint: entrypoint, relevantSymbols: relevantSymbols, in: workspace) + await simulateInitialEditorChange(entrypoint: entrypoint, content: content, in: workspace) + do { + let suggestions = try await workspace.suggestionService?.getSuggestions(suggestionRequest, workspaceInfo: workspaceInfo) + await saveFilespace(entrypoint: entrypoint, in: workspace) + await closeFilespaces(entrypoint: entrypoint, relevantSymbols: relevantSymbols, in: workspace) + guard let suggestions, let firstSuggestion = suggestions.first else { return nil } + return .init( + suggestion: firstSuggestion, + fileURL: entrypoint.fileURL, + relevantSymbolsFromRequest: relevantSymbols, + model: selectedGenAIModelSubject.value, + relevantFileScanningDurationInSeconds: relevantSymbolsSummary?.durationInSeconds + ) + } catch { + return nil + } + } + + private func openFilespaces(entrypoint: EntryPoint, relevantSymbols: [SymbolContent], in workspace: Workspace) async { + for symbol in relevantSymbols { + if let fileURL = symbol.fileURL.toFileURL(), + let filespace = try? await workspace.createFilespaceIfNeeded(fileURL: fileURL) { + await workspace.didOpenFilespace(filespace) + } + } + if let filespace = try? await workspace.createFilespaceIfNeeded(fileURL: entrypoint.fileURL) { + await workspace.didOpenFilespace(filespace) + } + } + + private func closeFilespaces(entrypoint: EntryPoint, relevantSymbols: [SymbolContent], in workspace: Workspace) async { + for symbol in relevantSymbols { + if let fileURL = symbol.fileURL.toFileURL() { + await workspace.didCloseFilespace(fileURL) + } + } + await workspace.didCloseFilespace(entrypoint.fileURL) + } + + private func saveFilespace(entrypoint: EntryPoint, in workspace: Workspace) async { + if let filespace = try? await workspace.createFilespaceIfNeeded(fileURL: entrypoint.fileURL) { + await workspace.didSaveFilespace(filespace) + } + } + + private func simulateInitialEditorChange(entrypoint: EntryPoint, content: String, in workspace: Workspace) async { + await workspace.didUpdateFilespace(fileURL: entrypoint.fileURL, content: content, version: 1) + } + + func runTask(at index: Int, in benchmarkDirectory: BenchmarkDirectory) async { + let taskPath = getTaskFolders(in: benchmarkDirectory.url)[index] + await updateTaskStatus(in: benchmarkDirectory, state: .running, at: index) + if case .githubCopilot = selectedGenAIModelSubject.value { + await registerPlugins() + if let suggestion = await getCodeSuggestionFromService(at: taskPath, from: benchmarkDirectory.url) { + await applyCodeSuggestion(suggestion: suggestion.suggestion, at: suggestion.fileURL) + await storeContentInOutputDirectory(suggestion, for: index+1, in: benchmarkDirectory) + await updateTaskStatus(in: benchmarkDirectory, state: .success, at: index) + } + await unregisterPlugins() + } else if let openAIKey = openAIKey, + case let .openAI(model) = selectedGenAIModelSubject.value, + let suggestion = await getCodeSuggestionFromOpenAI( + at: taskPath, + from: benchmarkDirectory.url, + key: openAIKey, + model: model.rawValue + ) { + await applyCodeSuggestion(suggestion: suggestion.suggestion, at: suggestion.fileURL) + await storeContentInOutputDirectory(suggestion, for: index+1, in: benchmarkDirectory) + await updateTaskStatus(in: benchmarkDirectory, state: .success, at: index) + } else { + await updateTaskStatus(in: benchmarkDirectory, state: .failure, at: index) + } + await cleanUp() + } + + private func getCodeSuggestionFromOpenAI(at directory: URL, from benchmarkDirectory: URL, key: String, model: String) async -> SuggestionResponse? { + guard let metadata: MetadataDTO = readMetadata(at: directory), + let xcodeWorkspaceFileURL = findXcodeWorkspace(in: benchmarkDirectory), + let workspace = try? await workspacePool.fetchOrCreateWorkspace(workspaceURL: xcodeWorkspaceFileURL) + else { return nil } + let entrypoint = metadata.mapToEntrypoint(prefixing: benchmarkDirectory.path) + let content: String = (try? String(contentsOf: entrypoint.fileURL, encoding: .utf8)) ?? "" + + let relevantSymbolsSummary: RelevantSymbolsSummary? = await retrieveRelevantSymbolsForFileContent( + file: FileContent(fileURL: entrypoint.fileURL.path, content: content), + workspace: workspace + ) + let relevantSymbols = relevantSymbolsSummary?.symbolsAtLevel.flatMap { $0 } ?? [] + let limitedRelevantSymbols = Array(relevantSymbols.prefix(10)) + + let suggestionRequest = SuggestionRequest( + fileURL: entrypoint.fileURL, + relativePath: entrypoint.fileURL.path.replacingOccurrences(of: benchmarkDirectory.path, with: ""), + content: content, + originalContent: content, + lines: content.linesWithNewlineSuffix, + cursorPosition: .init(line: entrypoint.cursor.line, character: entrypoint.cursor.character), + cursorOffset: content.cursorOffset(line: entrypoint.cursor.line, character: entrypoint.cursor.character) ?? 0, + tabSize: 4, + indentSize: 4, + usesTabsForIndentation: false, + relevantCodeSnippets: limitedRelevantSymbols.mapToRelevantCodeSnippets() + ) + let repository = OpenAICompletionRepository(config: .init(apiKey: key, model: model)) + do { + let suggestion = try await repository.structuredEdit(for: suggestionRequest) + return .init( + suggestion: suggestion.mapToCodeSuggestion( + requestPosition: SuggestionRequest.CursorPosition( + line: entrypoint.cursor.line, + character: entrypoint.cursor.character + ) + ), + fileURL: entrypoint.fileURL, + relevantSymbolsFromRequest: relevantSymbols, + model: selectedGenAIModelSubject.value, + relevantFileScanningDurationInSeconds: relevantSymbolsSummary?.durationInSeconds + ) + } catch { + print("CK \(error)") + return nil + } + } + + private func retrieveRelevantSymbolsForFileContent( + file: FileContent, + workspace: Workspace + ) async -> RelevantSymbolsSummary? { + guard isMultiFileEnabledSubject.value else { return nil } + + let manager = MultiFileContextManager( + workspaceProvider: ManualWorkspaceProvider(workspace: workspace), + parser: SwiftProgrammingLanguageSyntaxParser() + ) + + return await manager.expandRelevantSymbols( + from: file, + ignoreWithinPaths: ["/Benchmark/"], + maxDepth: contextLevelLimitSubject.value.indexLimit, // e.g. First=0, Second=1, …, nil=no limit + maxFiles: contextFileAmountLimitSubject.value // Int?, nil=no cap + ) + } + + private func applyCodeSuggestion(suggestion: SuggestionBasic.CodeSuggestion, at fileURL: URL) async { + do { + let content = try String(contentsOf: fileURL, encoding: .utf8) + var lines = content.components(separatedBy: .newlines) + + let start = suggestion.range.start + let end = suggestion.range.end + + // Check range bounds + guard start.line < lines.count, end.line < lines.count else { + print("Invalid range for suggestion in file: \(fileURL)") + return + } + + // Modify the content between start and end + if start.line == end.line { + var line = lines[start.line] + let startIndex = line.index(line.startIndex, offsetBy: start.character) + let endIndex = line.index(line.startIndex, offsetBy: end.character) + line.replaceSubrange(startIndex.. MetadataDTO? { + let metadataURL = taskFolder.appendingPathComponent("metadata.json") + do { + let data = try Data(contentsOf: metadataURL) + + let metadata = try JSONDecoder().decode(MetadataDTO.self, from: data) + return metadata + } catch { + print("Failed to read metadata at \(metadataURL):", error) + return nil + } + } + + private func getTaskFolders(in rootDirectory: URL) -> [URL] { + let fileManager = FileManager.default + var taskFolders: [URL] = [] + + let regex = try! NSRegularExpression(pattern: #"Task-\d+"#) + + let enumerator = fileManager.enumerator(at: rootDirectory, includingPropertiesForKeys: [.isDirectoryKey], options: [.skipsHiddenFiles, .skipsPackageDescendants]) + + while let fileURL = enumerator?.nextObject() as? URL { + do { + let resourceValues = try fileURL.resourceValues(forKeys: [.isDirectoryKey]) + if resourceValues.isDirectory == true { + let folderName = fileURL.lastPathComponent + let range = NSRange(location: 0, length: folderName.utf16.count) + if regex.firstMatch(in: folderName, options: [], range: range) != nil { + guard !fileURL.path.contains("/Tests/") else { continue } + taskFolders.append(fileURL) + } + } + } catch { + print("Failed to read directory attributes for \(fileURL):", error) + } + } + + return taskFolders.sorted { lhs, rhs in + let lhsNumber = extractTaskNumber(from: lhs.lastPathComponent) ?? 0 + let rhsNumber = extractTaskNumber(from: rhs.lastPathComponent) ?? 0 + return lhsNumber < rhsNumber + } + } + + private func extractTaskNumber(from name: String) -> Int? { + let regex = try! NSRegularExpression(pattern: #"Task-(\d+)"#) + let range = NSRange(location: 0, length: name.utf16.count) + if let match = regex.firstMatch(in: name, options: [], range: range), + let numberRange = Range(match.range(at: 1), in: name) { + return Int(name[numberRange]) + } + return nil + } + + private func findXcodeWorkspace(in directory: URL) -> URL? { + let fileManager = FileManager.default + let enumerator = fileManager.enumerator(at: directory, includingPropertiesForKeys: [.isDirectoryKey], options: [.skipsHiddenFiles, .skipsPackageDescendants]) + + while let fileURL = enumerator?.nextObject() as? URL { + if fileURL.pathExtension == "xcworkspace" { + return fileURL + } + } + + return nil + } + + @MainActor + func changeGenAIModel(to newModel: GenAILanguageModel) { + selectedGenAIModelSubject.send(newModel) + } + + @MainActor + func updateMultiFileContextState(_ newValue: Bool) { + isMultiFileEnabledSubject.send(newValue) + } + + @MainActor + private func updateTaskStates(in directory: BenchmarkDirectory, newValue: [TaskStatus]) { + var currentStates = taskStatesSubject.value + currentStates[directory] = newValue + taskStatesSubject.send(currentStates) + } + + @MainActor + private func updateTaskStatus(in directory: BenchmarkDirectory, state: TaskStatus, at index: Int) { + var currentStates = taskStatesSubject.value + guard var currentStatesInDirectory = currentStates[directory] else { return } + currentStatesInDirectory[index] = state + currentStates[directory] = currentStatesInDirectory + taskStatesSubject.send(currentStates) + } + + @MainActor + func saveContextLevelLimit(_ limit: ContextLevelLimit) { + contextLevelLimitSubject.send(limit) + } + + @MainActor + func saveContextFileAmountLimit(_ limit: Int?) { + contextFileAmountLimitSubject.send(limit) + } +} + +extension MetadataDTO { + func mapToEntrypoint(prefixing baseUrl: String) -> EntryPoint { + // Ensure there's exactly one "/" between the base and the filename + let cleanedBase = baseUrl.hasSuffix("/") ? baseUrl : baseUrl + "/" + let cleanedFilename = entrypoint.filename.hasPrefix("/") ? String(entrypoint.filename.dropFirst()) : entrypoint.filename + let fullPath = cleanedBase + cleanedFilename + + return EntryPoint( + cursor: CursorPosition( + line: entrypoint.cursor.line, + character: entrypoint.cursor.character + ), + fileURL: URL(fileURLWithPath: fullPath) + ) + } +} + +extension String { + /// Splits the string by line and appends `\n` to each non-empty line + var linesWithNewlineSuffix: [String] { + self.split(separator: "\n", omittingEmptySubsequences: false) + .map { String($0) + "\n" } + } + + /// Calculates the character offset in the string from a line number and character index within that line + func cursorOffset(line: Int, character: Int) -> Int? { + let lines = self.split(separator: "\n", omittingEmptySubsequences: false) + guard line < lines.count else { return nil } + + // Sum up the lengths of all previous lines (+1 for each '\n') + let offsetBeforeLine = lines.prefix(line).reduce(0) { $0 + $1.count + 1 } + + // Ensure the character index doesn't exceed the current line length + guard character <= lines[line].count else { return nil } + + return offsetBeforeLine + character + } +} + +extension AnyPublisher where Failure == Never { + /// Awaits the first emitted value of the publisher (only for Failure == Never) + func firstValue() async -> Output? { + await withCheckedContinuation { continuation in + var cancellable: AnyCancellable? + cancellable = self.first().sink { value in + continuation.resume(returning: value) + cancellable?.cancel() + } + } + } +} + +extension SuggestionResponse { + func toStoredDTO(timestamp: String) -> StoredSuggestionDTO { + StoredSuggestionDTO( + fileURL: fileURL.path, + id: suggestion.id, + suggestionText: suggestion.text, + position: .init( + line: suggestion.position.line, + character: suggestion.position.character + ), + range: .init( + start: .init( + line: suggestion.range.start.line, + character: suggestion.range.start.character + ), + end: .init( + line: suggestion.range.end.line, + character: suggestion.range.end.character + ) + ), + createdAt: timestamp, + relevantSymbols: relevantSymbolsFromRequest.map { $0.toStoredDTO() }, + model: model.id, + relevantFileScanningDurationInSeconds: relevantFileScanningDurationInSeconds + ) + } +} + +extension SymbolContent { + func toStoredDTO() -> StoredSuggestionDTO.RelevantSymbolsDTO { + .init( + fileURL: fileURL, + name: symbol.name, + content: content, + startLine: symbol.startLine, + endLine: symbol.endLine, + kind: symbol.kind.rawValue + ) + } +} + +extension Array where Element == SymbolContent { + func mapToRelevantCodeSnippets() -> [SuggestionProvider.RelevantCodeSnippet] { + map { symbol in + .init( + content: symbol.content, + priority: 0, + filePath: symbol.fileURL + ) + } + } +} + +extension Array where Element == SymbolContent { + func mapToRelevantCodeSnippets() -> [SuggestionRequest.RelevantCodeSnippet] { + map { symbol in + .init( + path: symbol.fileURL, + language: "Swift", + code: symbol.content + ) + } + } +} + +extension CodeEdit { + func mapToCodeSuggestion(requestPosition: SuggestionRequest.CursorPosition) -> SuggestionBasic.CodeSuggestion { + let start = range.start + let end = range.end + return SuggestionBasic.CodeSuggestion( + id: UUID().uuidString, + text: text, + position: SuggestionBasic.CursorPosition( + line: requestPosition.line, + character: requestPosition.character + ), + range: SuggestionBasic.CursorRange( + start: .init(line: start.line, character: start.character), + end: .init(line: end.line, character: end.character) + ) + ) + } +} + +extension String { + func toFileURL() -> URL? { + hasPrefix("file://") ? URL(string: self) : URL(fileURLWithPath: self) + } +} diff --git a/Core/Sources/HostApp/Benchmark/Data/Repository/LocalBenchmarkSettingsRepository.swift b/Core/Sources/HostApp/Benchmark/Data/Repository/LocalBenchmarkSettingsRepository.swift new file mode 100644 index 00000000..7b6e1aa6 --- /dev/null +++ b/Core/Sources/HostApp/Benchmark/Data/Repository/LocalBenchmarkSettingsRepository.swift @@ -0,0 +1,108 @@ +import Foundation +import Combine + +class LocalBenchmarkSettingsRepository: BenchmarkSettingsRepository { + private let localStorageManager: LocalStorageManager + private let benchmarkDirectoriesKey = "benchmarkDirectories" + private let benchmarkOutputDirectoryKey = "benchmarkOutputDirectory" + private let openAIKeyDefaultsKey = "openAIKey" + + private var defaultOutputDirectory: URL { + FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Desktop/benchmark_output") + } + + private let currentBenchmarkDirectories: CurrentValueSubject<[BenchmarkDirectory], Never> = CurrentValueSubject([]) + var benchmarkDirectories: AnyPublisher<[BenchmarkDirectory], Never> { + currentBenchmarkDirectories.eraseToAnyPublisher() + } + + private let benchmarkOutputDirectory: CurrentValueSubject = CurrentValueSubject("") + var outputDirectory: AnyPublisher { + benchmarkOutputDirectory.eraseToAnyPublisher() + } + + private let currentOpenAIKey: CurrentValueSubject = CurrentValueSubject("") + var openAIKey: AnyPublisher { + currentOpenAIKey.eraseToAnyPublisher() + } + + init(localStorageManager: LocalStorageManager) { + self.localStorageManager = localStorageManager + if let benchmarkDirectories = try? retrieveBenchmarkDirectories() { + currentBenchmarkDirectories.send(benchmarkDirectories) + } + if let outputDirectory = try? loadBenchmarkOutputDirectory() { + benchmarkOutputDirectory.send(outputDirectory) + } + if let openAIKey = try? loadOpenAIKey() { + currentOpenAIKey.send(openAIKey) + } + } + + func saveBenchmarkDirectory(_ directory: BenchmarkDirectory) throws { + let currentEntries = currentBenchmarkDirectories.value + let updatedEntries = currentEntries + [directory] + let data = updatedEntries.mapToDTOs() + try localStorageManager.save(codable: data, key: benchmarkDirectoriesKey) + currentBenchmarkDirectories.send(updatedEntries) + } + + func deleteBenchmarkDirectory(_ directory: BenchmarkDirectory) throws { + var currentEntries = currentBenchmarkDirectories.value + currentEntries.removeAll { $0 == directory } + let data = currentEntries.mapToDTOs() + try localStorageManager.save(codable: data, key: benchmarkDirectoriesKey) + currentBenchmarkDirectories.send(currentEntries) + } + + private func retrieveBenchmarkDirectories() throws -> [BenchmarkDirectory] { + let data: [BenchmarkDirectoryDTO] = try localStorageManager.load(key: benchmarkDirectoriesKey) + return data.map { $0.mapToDomain() } + } + + func saveBenchmarkOutputDirectory(_ directory: String) throws { + try localStorageManager.save(codable: directory, key: benchmarkOutputDirectoryKey) + benchmarkOutputDirectory.send(directory) + } + + private func loadBenchmarkOutputDirectory() throws -> String { + do { + return try localStorageManager.load(key: benchmarkOutputDirectoryKey) + } catch LocalStorageError.noDataForKey { + let defaultPath = defaultOutputDirectory.path + try saveBenchmarkOutputDirectory(defaultPath) + return defaultPath + } + } + + private func deleteBenchmarkOutputDirectory() { + } + + private func loadOpenAIKey() throws -> String? { + return try? localStorageManager.load(key: openAIKeyDefaultsKey) + } + + func saveOpenAIKey(_ key: String) throws { + try localStorageManager.save(codable: key, key: openAIKeyDefaultsKey) + currentOpenAIKey.send(key) + } +} + +extension Array where Element == BenchmarkDirectory { + func mapToDTOs() -> [BenchmarkDirectoryDTO] { + return self.map { $0.mapToDTO() } + } +} + +extension BenchmarkDirectory { + func mapToDTO() -> BenchmarkDirectoryDTO { + return BenchmarkDirectoryDTO(name: name, url: url) + } +} + +extension BenchmarkDirectoryDTO { + func mapToDomain() -> BenchmarkDirectory { + return BenchmarkDirectory(name: name, url: url) + } +} diff --git a/Core/Sources/HostApp/Benchmark/Domain/Entity/BenchmarkDirectory.swift b/Core/Sources/HostApp/Benchmark/Domain/Entity/BenchmarkDirectory.swift new file mode 100644 index 00000000..16de0d01 --- /dev/null +++ b/Core/Sources/HostApp/Benchmark/Domain/Entity/BenchmarkDirectory.swift @@ -0,0 +1,6 @@ +import Foundation + +struct BenchmarkDirectory: Hashable { + let name: String + let url: URL +} diff --git a/Core/Sources/HostApp/Benchmark/Domain/Entity/ContextLevelLimit.swift b/Core/Sources/HostApp/Benchmark/Domain/Entity/ContextLevelLimit.swift new file mode 100644 index 00000000..6ecaa19a --- /dev/null +++ b/Core/Sources/HostApp/Benchmark/Domain/Entity/ContextLevelLimit.swift @@ -0,0 +1,21 @@ +enum ContextLevelLimit: CaseIterable { + case firstLevel + case secondLevel + case thirdLevel + + var name: String { + switch self { + case .firstLevel: return "First Level" + case .secondLevel: return "Second Level" + case .thirdLevel: return "Third Level" + } + } + + var indexLimit: Int? { + switch self { + case .firstLevel: return 0 + case .secondLevel: return 1 + case .thirdLevel: return 2 + } + } +} diff --git a/Core/Sources/HostApp/Benchmark/Domain/Entity/SuggestionRequest.swift b/Core/Sources/HostApp/Benchmark/Domain/Entity/SuggestionRequest.swift new file mode 100644 index 00000000..c5a96f76 --- /dev/null +++ b/Core/Sources/HostApp/Benchmark/Domain/Entity/SuggestionRequest.swift @@ -0,0 +1,27 @@ +import Foundation + +struct SuggestionRequest { + + struct CursorPosition { + var line: Int + var character: Int + } + + struct RelevantCodeSnippet { + var path: String + var language: String? + var code: String + } + + var fileURL: URL + var relativePath: String + var content: String + var originalContent: String + var lines: [String] + var cursorPosition: CursorPosition + var cursorOffset: Int + var tabSize: Int + var indentSize: Int + var usesTabsForIndentation: Bool + var relevantCodeSnippets: [RelevantCodeSnippet] +} diff --git a/Core/Sources/HostApp/Benchmark/Domain/Entity/SuggestionResponse.swift b/Core/Sources/HostApp/Benchmark/Domain/Entity/SuggestionResponse.swift new file mode 100644 index 00000000..50ffb0ef --- /dev/null +++ b/Core/Sources/HostApp/Benchmark/Domain/Entity/SuggestionResponse.swift @@ -0,0 +1,11 @@ +import Foundation +import SuggestionBasic +import Service + +struct SuggestionResponse { + let suggestion: SuggestionBasic.CodeSuggestion + let fileURL: URL + let relevantSymbolsFromRequest: [SymbolContent] + let model: GenAILanguageModel + let relevantFileScanningDurationInSeconds: Double? +} diff --git a/Core/Sources/HostApp/Benchmark/Domain/Entity/TaskStatus.swift b/Core/Sources/HostApp/Benchmark/Domain/Entity/TaskStatus.swift new file mode 100644 index 00000000..82969163 --- /dev/null +++ b/Core/Sources/HostApp/Benchmark/Domain/Entity/TaskStatus.swift @@ -0,0 +1,7 @@ +enum TaskStatus { + case success + case failure + case notStarted + case running + case scheduled +} diff --git a/Core/Sources/HostApp/Benchmark/Domain/Manager/BenchmarkManager.swift b/Core/Sources/HostApp/Benchmark/Domain/Manager/BenchmarkManager.swift new file mode 100644 index 00000000..f04c1583 --- /dev/null +++ b/Core/Sources/HostApp/Benchmark/Domain/Manager/BenchmarkManager.swift @@ -0,0 +1,21 @@ +import Combine + +protocol BenchmarkManager { + var isMultiFileEnabled: AnyPublisher { get } + var taskStates: AnyPublisher<[BenchmarkDirectory: [TaskStatus]], Never> { get } + var selectedGenAIModel: AnyPublisher { get } + func getCodeSuggestions(at benchmarkDirectory: BenchmarkDirectory) async throws + func runTask(at index: Int, in benchmarkDirectory: BenchmarkDirectory) async + @MainActor + func changeGenAIModel(to newModel: GenAILanguageModel) + @MainActor + func updateMultiFileContextState(_ newValue: Bool) + + var contextLevelLimit: AnyPublisher { get } + @MainActor + func saveContextLevelLimit(_ limit: ContextLevelLimit) + + var contextFileAmountLimit: AnyPublisher { get } + @MainActor + func saveContextFileAmountLimit(_ limit: Int?) +} diff --git a/Core/Sources/HostApp/Benchmark/Domain/Repository/BenchmarkSettingsRepository.swift b/Core/Sources/HostApp/Benchmark/Domain/Repository/BenchmarkSettingsRepository.swift new file mode 100644 index 00000000..50d35efc --- /dev/null +++ b/Core/Sources/HostApp/Benchmark/Domain/Repository/BenchmarkSettingsRepository.swift @@ -0,0 +1,11 @@ +import Combine + +protocol BenchmarkSettingsRepository { + var benchmarkDirectories: AnyPublisher<[BenchmarkDirectory], Never> { get } + var outputDirectory: AnyPublisher { get } + func saveBenchmarkDirectory(_ directory: BenchmarkDirectory) throws + func deleteBenchmarkDirectory(_ directory: BenchmarkDirectory) throws + func saveBenchmarkOutputDirectory(_ directory: String) throws + var openAIKey: AnyPublisher { get } + func saveOpenAIKey(_ key: String) throws +} diff --git a/Core/Sources/HostApp/Benchmark/GenAICodeCompletion/Data/Entity/ChatPayload.swift b/Core/Sources/HostApp/Benchmark/GenAICodeCompletion/Data/Entity/ChatPayload.swift new file mode 100644 index 00000000..4613974c --- /dev/null +++ b/Core/Sources/HostApp/Benchmark/GenAICodeCompletion/Data/Entity/ChatPayload.swift @@ -0,0 +1,44 @@ +struct ChatPayload: Encodable { + let model: String + let messages: [ChatMessage] + let temperature: Double + let stream: Bool + let response_format: RF? + init( + model: String, + messages: [ChatMessage], + temperature: Double, + stream: Bool, + response_format: RF? = nil + ) { + self.model = model + self.messages = messages + self.temperature = temperature + self.stream = stream + self.response_format = response_format + } +} + +struct ChatMessage: Codable { + let role: String + let content: String +} + +struct ChatResponse: Decodable { + struct Choice: Decodable { + struct Message: Decodable { + let role: String + let content: String? + } + let index: Int + let message: Message + let finishReason: String? + + private enum CodingKeys: String, CodingKey { + case index, message + case finishReason = "finish_reason" + } + } + let id: String + let choices: [Choice] +} diff --git a/Core/Sources/HostApp/Benchmark/GenAICodeCompletion/Data/Entity/CodeEdit.swift b/Core/Sources/HostApp/Benchmark/GenAICodeCompletion/Data/Entity/CodeEdit.swift new file mode 100644 index 00000000..1c81fd8e --- /dev/null +++ b/Core/Sources/HostApp/Benchmark/GenAICodeCompletion/Data/Entity/CodeEdit.swift @@ -0,0 +1,14 @@ +struct CodeEdit: Codable { + struct Position: Codable { + let line: Int + let character: Int + } + struct TextRange: Codable { + let start: Position + let end: Position + } + + /// Replacement text that MUST include the full (updated) content of the cursor's line. + let text: String + let range: TextRange +} diff --git a/Core/Sources/HostApp/Benchmark/GenAICodeCompletion/Data/Repository/OpenAICompletionRepository.swift b/Core/Sources/HostApp/Benchmark/GenAICodeCompletion/Data/Repository/OpenAICompletionRepository.swift new file mode 100644 index 00000000..311016e8 --- /dev/null +++ b/Core/Sources/HostApp/Benchmark/GenAICodeCompletion/Data/Repository/OpenAICompletionRepository.swift @@ -0,0 +1,258 @@ +import Foundation + +struct OpenAICompletionRepository: CodeCompletionRepository { + + enum OpenAIError: Error { + case badResponse(status: Int, body: String) + case decoding + case emptyChoice + } + + struct Config { + var apiKey: String + /// e.g. "gpt-4o-mini" + var model: String + /// Optional org header + var organization: String? + /// API base, keep default unless using a proxy + var baseURL: URL + + init( + apiKey: String, + model: String, + organization: String? = nil, + baseURL: URL = URL(string: "https://api.openai.com/v1")! + ) { + self.apiKey = apiKey + self.model = model + self.organization = organization + self.baseURL = baseURL + } + } + + struct JSONOnly: Encodable { let type: String = "json_object" } + + private let config: Config + private let urlSession: URLSession + + init(config: Config) { + self.config = config + + let configuration = URLSessionConfiguration.default + configuration.timeoutIntervalForRequest = 300 // 5 minutes + configuration.timeoutIntervalForResource = 300 // 5 minutes + + self.urlSession = URLSession(configuration: configuration) + } + + func structuredEdit(for request: SuggestionRequest) async throws -> CodeEdit { + var urlRequest = URLRequest(url: config.baseURL.appendingPathComponent("chat/completions")) + urlRequest.httpMethod = "POST" + urlRequest.setValue("Bearer \(config.apiKey)", forHTTPHeaderField: "Authorization") + if let org = config.organization { urlRequest.setValue(org, forHTTPHeaderField: "OpenAI-Organization") } + urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") + + let payload = ChatPayload( + model: config.model, + messages: buildMessagesForStructuredEdit(from: request), + temperature: config.model == "gpt-5" ? 1 : 0, // gpt-5 only supports temperature 1 + stream: false, + response_format: JSONOnly() // ask for a single JSON object + ) + urlRequest.httpBody = try JSONEncoder().encode(payload) + + let (data, resp) = try await urlSession.data(for: urlRequest) + guard let http = resp as? HTTPURLResponse, 200..<300 ~= http.statusCode else { + let body = String(data: data, encoding: .utf8) ?? "" + throw OpenAIError.badResponse(status: (resp as? HTTPURLResponse)?.statusCode ?? -1, body: body) + } + + let decoded = try JSONDecoder().decode(ChatResponse.self, from: data) + guard let content = decoded.choices.first?.message.content, !content.isEmpty else { + throw OpenAIError.emptyChoice + } + + // The model's entire reply is a JSON object. Decode it. + let edit = try JSONDecoder().decode(CodeEdit.self, from: Data(content.utf8)) + let fixedSuggestionText = removeClosingBraceIfNeeded( + suggestion: edit.text, + promptCode: request.content, + line: request.cursorPosition.line + ) + let fixedEdit = CodeEdit(text: fixedSuggestionText, range: edit.range) + return fixedEdit + } + + private func removeClosingBraceIfNeeded(suggestion: String, promptCode: String, line: Int) -> String { + // Defensive: find the first line AFTER the cursor line + let promptLines = promptCode.components(separatedBy: "\n") + guard line + 1 < promptLines.count else { return suggestion } + let afterFirstLineTrimmed = promptLines[line + 1].trimmingCharacters(in: .whitespacesAndNewlines) + + // Work on line array of the suggestion + var lines = suggestion.components(separatedBy: "\n") + + // Find the last *non-empty* line (ignoring pure whitespace) + var lastNonEmptyIndex: Int? + for i in lines.indices.reversed() { + if !lines[i].trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + lastNonEmptyIndex = i + break + } + } + guard let idx = lastNonEmptyIndex else { return suggestion } // all whitespace? leave as-is + + let tailTrimmed = lines[idx].trimmingCharacters(in: .whitespacesAndNewlines) + + // If the model echoed the first AFTER line (e.g. a closing brace), drop it + if !afterFirstLineTrimmed.isEmpty, tailTrimmed == afterFirstLineTrimmed { + // Remove that line and any trailing blank lines after it + lines.removeSubrange(idx.. String { + switch (path as NSString).pathExtension.lowercased() { + case "swift": return "Swift" + default: return "Unknown" + } + } + + private func buildMessagesForStructuredEdit(from req: SuggestionRequest) -> [ChatMessage] { + let language = detectLanguage(from: req.relativePath) + + let before = String(req.content.prefix(req.cursorOffset)) + let after = String(req.content.suffix(max(0, req.content.count - req.cursorOffset))) + + // Defensive: if the line index is in range, grab the full original line text + let lineIndex = max(0, min(req.cursorPosition.line, max(0, req.lines.count - 1))) + let currentLine = lineIndex < req.lines.count ? req.lines[lineIndex] : "" + + let indentDescriptor: String = req.usesTabsForIndentation + ? "tabs=\(req.tabSize)" + : "spaces=\(req.indentSize)" + + let related = req.relevantCodeSnippets.map { s in + """ + PATH: \(s.path) + LANG: \(s.language ?? "unknown") + ---- + \(s.code) + """ + }.joined(separator: "\n\n========\n\n") + + let system = ChatMessage(role: "system", content: """ + You are a \(language) inline code completion engine. + + Complete the body of only the current function. Produce meaningful, compilable code using identifiers in scope. No placeholders (e.g., "TODO", "// Implementation goes here"). + Return ONE compact JSON object and nothing else (no code fences, no prose). + + Semantics: + - Range describes from what line and character to what line and character the output text should be replaced + - Ranges use 0-based LSP-style coordinates: (start, end). + - Coordinates are relative to the current (pre-edit) buffer. + - The replacement range MUST cover the entire CURRENT LINE (from character 0). + - You can extend the range into following lines to complete the construct. + - The output text will be inserted in the given range. + + Output requirements: + - The "text" must include the full updated CURRENT LINE (where the cursor is) including the function definition and any additional lines needed to complete the implementation. + - IMPORTANT: As can be seen in AFTER, the closing brace `}` of the function to complete is already existing. It MUST not be included in output text. Otherwise a duplicated brace will lead to a compilation error. + - Do NOT begin the text with the first bytes of AFTER. + - No placeholders or comments like "TODO" or "// Implementation goes here". + - Respect indentation \(indentDescriptor) and file style. + - Tabs are represented as spaces in the input; preserve that. + - Preserve newlines; avoid trailing whitespace. + + Respond only with JSON of shape: + { + "text": "STRING", + "range": { + "start": { "line": INT, "character": INT }, + "end": { "line": INT, "character": INT } + } + } + """) + + // Few-shot example teaches the model that we want a body, not just "}" + let exampleUser = ChatMessage(role: "user", content: + """ + EXAMPLE ONLY — DO NOT SOLVE THIS: + FILE: Example.swift + LANGUAGE_HINT: Swift + CURRENT_LINE_INDEX: 1 + CURRENT_LINE_TEXT: + ««« + func greet() {\n + »»» + CURSOR_CHARACTER: 18 + BEFORE: + ««« + struct Greeter {\n func greet() { + »»» + + AFTER: + ««« + \n }\n} + »»» + Respond with JSON: + { + "text": "func greet() {\n print(\"Hello\")\n", + "range": { "start": { "line": 1, "character": 0 }, "end": { "line": 1, "character": 18 } } + } + """ + ) + + // Instruct exact JSON shape + let user = ChatMessage(role: "user", content: + """ + FILE: \(req.relativePath) + LANGUAGE_HINT: \(language) + + CURRENT_LINE_INDEX: \(lineIndex) + CURRENT_LINE_TEXT: + ««« + \(currentLine) + »»» + CURSOR_CHARACTER: \(req.cursorPosition.character) + + BEFORE: + ««« + \(before) + »»» + + + + AFTER: + ««« + \(after) + »»» + + OPTIONAL RELEVANT CONTEXT: + ««« + \(related) + »»» + + Respond with **only** a compact JSON object of this shape (no code fences, no trailing text): + { + "text": "STRING — the replacement text (must include the full updated CURRENT LINE as it should appear after the edit; may include multiple lines if needed)", + "range": { + "start": { "line": INT, "character": INT }, + "end": { "line": INT, "character": INT } + } + } + Rules: + - Choose a minimal range that replaces from the start you need to change up to the end you need to change. + - The range MUST at least span the entire CURRENT LINE (i.e., covers from (CURRENT_LINE_INDEX, 0) to some end on the same or later line), so that the returned `text` fully defines that line after the edit. + - Do not include explanations or formatting fences. + """ + ) + + return [system, exampleUser, user] + } +} diff --git a/Core/Sources/HostApp/Benchmark/GenAICodeCompletion/Domain/Entity/GenAILanguageModel.swift b/Core/Sources/HostApp/Benchmark/GenAICodeCompletion/Domain/Entity/GenAILanguageModel.swift new file mode 100644 index 00000000..bde8ad49 --- /dev/null +++ b/Core/Sources/HostApp/Benchmark/GenAICodeCompletion/Domain/Entity/GenAILanguageModel.swift @@ -0,0 +1,38 @@ +enum GenAILanguageModel: Hashable, CaseIterable, Identifiable { + case githubCopilot + case openAI(OpenAIModel) + + static var allCases: [GenAILanguageModel] { + [.githubCopilot] + OpenAIModel.allCases.map { .openAI($0) } + } + + var id: String { + switch self { + case .githubCopilot: return "githubCopilot" + case .openAI(let m): return "openAI:\(m.rawValue)" + } + } + + var name: String { + switch self { + case .githubCopilot: return "GitHub Copilot" + case .openAI(let m): return m.displayName + } + } + + static let defaultModel: GenAILanguageModel = .githubCopilot +} + +enum OpenAIModel: String, CaseIterable, Hashable { + case gpt4oMini = "gpt-4o-mini" + case gpt4o = "gpt-4o" + case gpt5 = "gpt-5" + + var displayName: String { + switch self { + case .gpt4oMini: return "GPT-4o mini" + case .gpt4o: return "GPT-4o" + case .gpt5: return "GPT-5" + } + } +} diff --git a/Core/Sources/HostApp/Benchmark/GenAICodeCompletion/Domain/Repository/CodeCompletionRepository.swift b/Core/Sources/HostApp/Benchmark/GenAICodeCompletion/Domain/Repository/CodeCompletionRepository.swift new file mode 100644 index 00000000..25d6935a --- /dev/null +++ b/Core/Sources/HostApp/Benchmark/GenAICodeCompletion/Domain/Repository/CodeCompletionRepository.swift @@ -0,0 +1,3 @@ +protocol CodeCompletionRepository { + func structuredEdit(for request: SuggestionRequest) async throws -> CodeEdit +} diff --git a/Core/Sources/HostApp/Benchmark/LocalStorage/Data/Entity/LocalStorageError.swift b/Core/Sources/HostApp/Benchmark/LocalStorage/Data/Entity/LocalStorageError.swift new file mode 100644 index 00000000..e6b646eb --- /dev/null +++ b/Core/Sources/HostApp/Benchmark/LocalStorage/Data/Entity/LocalStorageError.swift @@ -0,0 +1,5 @@ +enum LocalStorageError: Error { + case encodingError + case noDataForKey + case decodingError +} diff --git a/Core/Sources/HostApp/Benchmark/LocalStorage/Data/Manager/UserDefaultsLocalStorageManager.swift b/Core/Sources/HostApp/Benchmark/LocalStorage/Data/Manager/UserDefaultsLocalStorageManager.swift new file mode 100644 index 00000000..c689a4de --- /dev/null +++ b/Core/Sources/HostApp/Benchmark/LocalStorage/Data/Manager/UserDefaultsLocalStorageManager.swift @@ -0,0 +1,30 @@ +import Foundation + +class UserDefaultsLocalStorageManager: LocalStorageManager { + + private let userDefaults: UserDefaults = UserDefaults() + + func save(codable: T, key: String) throws { + let encoder = JSONEncoder() + guard let encoded = try? encoder.encode(codable) else { + throw LocalStorageError.encodingError + } + userDefaults.set(encoded, forKey: key) + } + + func load(key: String) throws -> T { + guard let data = userDefaults.data(forKey: key) else { + throw LocalStorageError.noDataForKey + } + let decoder = JSONDecoder() + guard let decoded = try? decoder.decode(T.self, from: data) else { + throw LocalStorageError.decodingError + } + return decoded + } + + func delete(key: String) { + userDefaults.removeObject(forKey: key) + } + +} diff --git a/Core/Sources/HostApp/Benchmark/LocalStorage/Domain/Manager/LocalStorageManager.swift b/Core/Sources/HostApp/Benchmark/LocalStorage/Domain/Manager/LocalStorageManager.swift new file mode 100644 index 00000000..47e4c64d --- /dev/null +++ b/Core/Sources/HostApp/Benchmark/LocalStorage/Domain/Manager/LocalStorageManager.swift @@ -0,0 +1,5 @@ +protocol LocalStorageManager { + func save(codable: T, key: String) throws + func load(key: String) throws -> T + func delete(key: String) +} diff --git a/Core/Sources/HostApp/Benchmark/Presentation/View/AddDirectoryButtonView.swift b/Core/Sources/HostApp/Benchmark/Presentation/View/AddDirectoryButtonView.swift new file mode 100644 index 00000000..7799d57d --- /dev/null +++ b/Core/Sources/HostApp/Benchmark/Presentation/View/AddDirectoryButtonView.swift @@ -0,0 +1,22 @@ +import SwiftUI + +struct AddDirectoryButtonView: View { + private let module: BenchmarkModuleType + @State private var isShowingSheet = false + + init(module: BenchmarkModuleType) { + self.module = module + } + + var body: some View { + Button { + isShowingSheet.toggle() + } label : { + Label("add directory", systemImage: "plus") + .foregroundStyle(.foreground) // to remove green accent color from system image + } + .sheet(isPresented: $isShowingSheet) { + AddDirectoryView(module: module) + } + } +} diff --git a/Core/Sources/HostApp/Benchmark/Presentation/View/AddDirectoryView.swift b/Core/Sources/HostApp/Benchmark/Presentation/View/AddDirectoryView.swift new file mode 100644 index 00000000..77b34296 --- /dev/null +++ b/Core/Sources/HostApp/Benchmark/Presentation/View/AddDirectoryView.swift @@ -0,0 +1,44 @@ +import SwiftUI + +struct AddDirectoryView: View { + @State private var currentDirectoryName: String = "" + @State private var currentDirectory: String = "" + @StateObject var viewModel: AddDirectoryViewModel + @Environment(\.dismiss) private var dismiss + + private let labelWidth: CGFloat = 100 + + init(module: BenchmarkModuleType) { + _viewModel = StateObject(wrappedValue: module.provide()) + } + + var body: some View { + VStack { + HStack { + Text("Name") + .frame(width: labelWidth, alignment: .leading) + TextField("Name", text: $currentDirectoryName, prompt: Text("Project X")) + .textFieldStyle(PlainTextFieldStyle()) + } + HStack { + Text("Directory") + .frame(width: labelWidth, alignment: .leading) + TextField("Directory", text: $currentDirectory, prompt: Text("/path/to/directory")) + .textFieldStyle(PlainTextFieldStyle()) + } + + } + .padding() + + HStack { + Button("Cancel") { + dismiss() + } + Button("Save") { + viewModel.saveNewDirectory(name: currentDirectoryName, directory: currentDirectory) + dismiss() + } + } + .padding([.horizontal, .bottom]) + } +} diff --git a/Core/Sources/HostApp/Benchmark/Presentation/View/BenchmarkDirectoryEntryView.swift b/Core/Sources/HostApp/Benchmark/Presentation/View/BenchmarkDirectoryEntryView.swift new file mode 100644 index 00000000..0eb20293 --- /dev/null +++ b/Core/Sources/HostApp/Benchmark/Presentation/View/BenchmarkDirectoryEntryView.swift @@ -0,0 +1,99 @@ +import SwiftUI + +struct BenchmarkDirectoryEntryView: View { + @StateObject private var viewModel: BenchmarkDirectoryEntryViewModel + @State private var isShowingTaskList = false + + init(directory: BenchmarkDirectory, module: BenchmarkModuleType) { + _viewModel = StateObject(wrappedValue: module.provide(directory: directory)) + } + + + var body: some View { + VStack { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 10) { + DirectoryNameView(directory: viewModel.directory) + .frame(maxWidth: .infinity, alignment: .leading) + Button { + Task { + try? await viewModel.runBenchmark(for: viewModel.directory) + } + } label: { + Label("Run all Tasks", systemImage: "play") + } + Button { + NSWorkspace.shared.open(viewModel.directory.url) + } label: { + Image(systemName: "folder") + } + Button { + viewModel.deleteDirectory(viewModel.directory) + } label: { + Image(systemName: "trash") + .foregroundColor(.red) + } + Button { + withAnimation { + isShowingTaskList.toggle() + } + } label: { + Image(systemName: isShowingTaskList ? "chevron.up.circle" : "chevron.down.circle") + } + .buttonStyle(.borderless) + .accessibilityLabel(isShowingTaskList ? "Hide tasks" : "Show tasks") + .accessibilityHint("Tap to toggle task list visibility") + .padding(.horizontal, 10) + } + } + if isShowingTaskList { + taskList(directory: viewModel.directory) + } + } + } + + func taskList(directory: BenchmarkDirectory) -> some View { + VStack(alignment: .leading, spacing: 8) { + LazyVStack(alignment: .leading, spacing: 8) { + ForEach(Array(viewModel.taskStates.enumerated()), id: \.offset) { index, taskStatus in + HStack(spacing: 20) { + Button { + Task { + await viewModel.runTask(index: index, in: directory) + } + } label: { + Image(systemName: "play") + } + + Text("Task \(index + 1)") + .frame(maxWidth: .infinity, alignment: .leading) + + Group { + switch taskStatus { + case .notStarted: + Text("") + .accessibilityLabel("Not started") + case .scheduled: + Text("🕒") + .accessibilityLabel("Scheduled") + case .running: + ProgressView() + .scaleEffect(0.6) + .accessibilityLabel("Running") + case .success: + Text("✅") + .accessibilityLabel("Success") + case .failure: + Text("❌") + .accessibilityLabel("Failed") + } + } + .frame(width: 24, height: 24, alignment: .center) + } + .padding(.vertical, 2) + } + } + } + .padding(.leading, 20) + } +} diff --git a/Core/Sources/HostApp/Benchmark/Presentation/View/BenchmarkView.swift b/Core/Sources/HostApp/Benchmark/Presentation/View/BenchmarkView.swift new file mode 100644 index 00000000..c70f1c13 --- /dev/null +++ b/Core/Sources/HostApp/Benchmark/Presentation/View/BenchmarkView.swift @@ -0,0 +1,71 @@ +import SwiftUI + +struct BenchmarkView: View { + private let module: BenchmarkModuleType + @StateObject private var viewModel: BenchmarkViewModel + + private let taskStatusInfoHeight: CGFloat = 150.0 + + init(module: BenchmarkModuleType) { + self.module = module + _viewModel = StateObject(wrappedValue: module.provide()) + } + + var body: some View { + VStack { + ScrollView { + VStack { + HStack(spacing: 20) { + Text("Run Benchmark") + .font(.title) + .frame(maxWidth: .infinity, alignment: .leading) + Toggle("Multi File Context Enabled", isOn: $viewModel.isMultiFileContextEnabled) + Picker("Language Model: ", selection: $viewModel.selectedLanguageModel) { + ForEach(GenAILanguageModel.allCases, id: \.self) { model in + Text(model.name).tag(model) + } + } + ConfigurationButtonView(module: module) + } + .padding(.vertical) + + HStack(spacing: 20) { + Spacer() + Picker("Context Level Limit: ", selection: $viewModel.contextLevelLimit) { + ForEach(ContextLevelLimit.allCases, id: \.self) { limit in + Text(limit.name).tag(limit) + } + } + .fixedSize(horizontal: true, vertical: false) + + Text("Context File Amount Limit:") + let numberFormatter: NumberFormatter = { + let f = NumberFormatter() + f.numberStyle = .none + return f + }() + TextField("Optional", value: $viewModel.contextFileAmountLimit, formatter: numberFormatter) + .textFieldStyle(RoundedBorderTextFieldStyle()) + .frame(width: 100) + } + .padding(.bottom) + + ForEach(viewModel.benchmarkDirectories, id: \.self) { directory in + BenchmarkDirectoryEntryView( + directory: directory, + module: module + ) + } + AddDirectoryButtonView(module: module) + .padding(.vertical, 10) + .frame(maxWidth: .infinity, alignment: .center) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .padding() + } + } + .onAppear { + viewModel.loadBenchmarkDirectories() + } + } +} diff --git a/Core/Sources/HostApp/Benchmark/Presentation/View/ConfigurationButtonView.swift b/Core/Sources/HostApp/Benchmark/Presentation/View/ConfigurationButtonView.swift new file mode 100644 index 00000000..ff0dcd68 --- /dev/null +++ b/Core/Sources/HostApp/Benchmark/Presentation/View/ConfigurationButtonView.swift @@ -0,0 +1,22 @@ +import SwiftUI + +struct ConfigurationButtonView: View { + private let module: BenchmarkModuleType + @State private var isShowingSheet = false + + init(module: BenchmarkModuleType) { + self.module = module + } + + var body: some View { + Button { + isShowingSheet.toggle() + } label : { + Label("Configure", systemImage: "gear") + } + .sheet(isPresented: $isShowingSheet) { + ConfigurationView(module: module) + } + } +} + diff --git a/Core/Sources/HostApp/Benchmark/Presentation/View/ConfigurationView.swift b/Core/Sources/HostApp/Benchmark/Presentation/View/ConfigurationView.swift new file mode 100644 index 00000000..a1516fc8 --- /dev/null +++ b/Core/Sources/HostApp/Benchmark/Presentation/View/ConfigurationView.swift @@ -0,0 +1,70 @@ +import SwiftUI + +struct ConfigurationView: View { + @State private var currentOutputDirectory: String = "" + @State private var currentOpenAIKey: String = "" + @StateObject var viewModel: ConfigurationViewModel + @Environment(\.dismiss) private var dismiss + + private let labelWidth: CGFloat = 120 + + init(module: BenchmarkModuleType) { + self._viewModel = StateObject(wrappedValue: module.provide()) + } + + var body: some View { + VStack { + HStack(alignment: .top) { + Text("OpenAI API Key:") + .frame(width: labelWidth, alignment: .leading) + TextField("Key", text: $currentOpenAIKey, prompt: Text("Key")) + .textFieldStyle(PlainTextFieldStyle()) + } + .padding([.top, .horizontal]) + HStack(alignment: .top) { + Text("Output Directory:") + .frame(width: labelWidth, alignment: .leading) + VStack { + TextField("Output Directory", text: $currentOutputDirectory, prompt: Text("Directory")) + .textFieldStyle(PlainTextFieldStyle()) + Button { + selectDirectory() + } label: { + Label("Select...", systemImage: "folder") + } + .padding() + } + } + .padding() + HStack { + Button("Cancel") { + dismiss() + } + Button("Save") { + viewModel.saveOutputDirectory(currentOutputDirectory) + viewModel.saveOpenAIKey(currentOpenAIKey) + dismiss() + } + } + .padding([.horizontal, .bottom]) + } + .onAppear { + currentOutputDirectory = viewModel.outputDirectory + currentOpenAIKey = viewModel.openAIKey + } + } + + private func selectDirectory() { + let panel = NSOpenPanel() + panel.canChooseFiles = false + panel.canChooseDirectories = true + panel.allowsMultipleSelection = false + panel.directoryURL = URL(fileURLWithPath: currentOutputDirectory) + + if panel.runModal() == .OK { + if let url = panel.url { + currentOutputDirectory = url.path + } + } + } +} diff --git a/Core/Sources/HostApp/Benchmark/Presentation/View/DirectoryNameView.swift b/Core/Sources/HostApp/Benchmark/Presentation/View/DirectoryNameView.swift new file mode 100644 index 00000000..0236cbbe --- /dev/null +++ b/Core/Sources/HostApp/Benchmark/Presentation/View/DirectoryNameView.swift @@ -0,0 +1,17 @@ +import SwiftUI + +struct DirectoryNameView: View { + let directory: BenchmarkDirectory + @State private var isPressed = false + @Environment(\.colorScheme) var colorScheme + + var body: some View { + VStack(alignment: .leading, spacing: 5) { + Text(directory.name) + + Text(directory.url.path) + .font(.subheadline) + .foregroundStyle(Color.gray) + } + } +} diff --git a/Core/Sources/HostApp/Benchmark/Presentation/ViewModel/AddDirectoryViewModel.swift b/Core/Sources/HostApp/Benchmark/Presentation/ViewModel/AddDirectoryViewModel.swift new file mode 100644 index 00000000..6dc9a8f5 --- /dev/null +++ b/Core/Sources/HostApp/Benchmark/Presentation/ViewModel/AddDirectoryViewModel.swift @@ -0,0 +1,25 @@ +import Foundation +import Toast + +class AddDirectoryViewModel: ObservableObject { + private let benchmarkSettingsRepository: BenchmarkSettingsRepository + private var toast: ToastController { ToastControllerDependencyKey.liveValue } + + init(benchmarkSettingsRepository: BenchmarkSettingsRepository) { + self.benchmarkSettingsRepository = benchmarkSettingsRepository + } + + func saveNewDirectory(name: String, directory: String) { + do { + try benchmarkSettingsRepository.saveBenchmarkDirectory( + BenchmarkDirectory( + name: name, + url: URL(fileURLWithPath: directory) + ) + ) + toast.toast(content: "Directory added successfully.", level: .info) + } catch { + toast.toast(content: "Failed adding directory.", level: .error) + } + } +} diff --git a/Core/Sources/HostApp/Benchmark/Presentation/ViewModel/BenchmarkDirectoryEntryViewModel.swift b/Core/Sources/HostApp/Benchmark/Presentation/ViewModel/BenchmarkDirectoryEntryViewModel.swift new file mode 100644 index 00000000..2d9b3579 --- /dev/null +++ b/Core/Sources/HostApp/Benchmark/Presentation/ViewModel/BenchmarkDirectoryEntryViewModel.swift @@ -0,0 +1,45 @@ +import Combine +import Toast + +class BenchmarkDirectoryEntryViewModel: ObservableObject { + @Published private(set) var taskStates: [TaskStatus] = [] + + private(set) var directory: BenchmarkDirectory + private let benchmarkSettingsRepository: BenchmarkSettingsRepository + private let benchmarkManager: BenchmarkManager + private var cancellables = Set() + private var toast: ToastController { ToastControllerDependencyKey.liveValue } + + init( + directory: BenchmarkDirectory, + benchmarkSettingsRepository: BenchmarkSettingsRepository, + benchmarkManager: BenchmarkManager + ) { + self.directory = directory + self.benchmarkSettingsRepository = benchmarkSettingsRepository + self.benchmarkManager = benchmarkManager + benchmarkManager.taskStates + .map { $0[directory] ?? [] } + .assign(to: \.taskStates, on: self) + .store(in: &cancellables) + } + + func runBenchmark(for directory: BenchmarkDirectory) async throws { + print("Running benchmark for \(directory.url.path)") + try await benchmarkManager.getCodeSuggestions(at: directory) + } + + + func deleteDirectory(_ directory: BenchmarkDirectory) { + do { + try benchmarkSettingsRepository.deleteBenchmarkDirectory(directory) + toast.toast(content: "Directory deleted successfully.", level: .info) + } catch { + toast.toast(content: "Failed deleting directory.", level: .error) + } + } + + func runTask(index: Int, in directory: BenchmarkDirectory) async { + await benchmarkManager.runTask(at: index, in: directory) + } +} diff --git a/Core/Sources/HostApp/Benchmark/Presentation/ViewModel/BenchmarkViewModel.swift b/Core/Sources/HostApp/Benchmark/Presentation/ViewModel/BenchmarkViewModel.swift new file mode 100644 index 00000000..f395d7d0 --- /dev/null +++ b/Core/Sources/HostApp/Benchmark/Presentation/ViewModel/BenchmarkViewModel.swift @@ -0,0 +1,63 @@ +import Combine +import Toast + +class BenchmarkViewModel: ObservableObject { + @Published var benchmarkDirectories: [BenchmarkDirectory] = [] + @Published var isMultiFileContextEnabled: Bool = true { + didSet { + guard oldValue != isMultiFileContextEnabled else { return } + Task { await benchmarkManager.updateMultiFileContextState(isMultiFileContextEnabled) } + } + } + @Published private(set) var taskStates: [BenchmarkDirectory: [TaskStatus]] = [:] + @Published var selectedLanguageModel: GenAILanguageModel = .defaultModel { + didSet { + guard oldValue != selectedLanguageModel else { return } + Task { await benchmarkManager.changeGenAIModel(to: selectedLanguageModel) } + } + } + @Published var contextLevelLimit: ContextLevelLimit = .firstLevel { + didSet { + guard oldValue != contextLevelLimit else { return } + Task { await benchmarkManager.saveContextLevelLimit(contextLevelLimit) } + } + } + @Published var contextFileAmountLimit: Int? = nil { + didSet { + guard oldValue != contextFileAmountLimit else { return } + Task { await benchmarkManager.saveContextFileAmountLimit(contextFileAmountLimit) } + } + } + + private let benchmarkSettingsRepository: BenchmarkSettingsRepository + private let benchmarkManager: BenchmarkManager + private var cancellables = Set() + private var toast: ToastController { ToastControllerDependencyKey.liveValue } + + init( + benchmarkSettingsRepository: BenchmarkSettingsRepository, + benchmarkManager: BenchmarkManager + ) { + self.benchmarkSettingsRepository = benchmarkSettingsRepository + self.benchmarkManager = benchmarkManager + benchmarkManager.isMultiFileEnabled + .assign(to: \.isMultiFileContextEnabled, on: self) + .store(in: &cancellables) + benchmarkManager.taskStates.assign(to: \.taskStates, on: self).store(in: &cancellables) + benchmarkManager.selectedGenAIModel + .assign(to: \.selectedLanguageModel, on: self) + .store(in: &cancellables) + benchmarkManager.contextLevelLimit + .assign(to: \.contextLevelLimit, on: self) + .store(in: &cancellables) + benchmarkManager.contextFileAmountLimit + .assign(to: \.contextFileAmountLimit, on: self) + .store(in: &cancellables) + } + + func loadBenchmarkDirectories() { + benchmarkSettingsRepository.benchmarkDirectories + .assign(to: \.benchmarkDirectories, on: self) + .store(in: &cancellables) + } +} diff --git a/Core/Sources/HostApp/Benchmark/Presentation/ViewModel/ConfigurationViewModel.swift b/Core/Sources/HostApp/Benchmark/Presentation/ViewModel/ConfigurationViewModel.swift new file mode 100644 index 00000000..c9657eee --- /dev/null +++ b/Core/Sources/HostApp/Benchmark/Presentation/ViewModel/ConfigurationViewModel.swift @@ -0,0 +1,40 @@ +import Combine +import Toast + +class ConfigurationViewModel: ObservableObject { + @Published var outputDirectory: String = "" + @Published var openAIKey: String = "" + private var cancellables = Set() + private var toast: ToastController { ToastControllerDependencyKey.liveValue } + + private let benchmarkSettingsRepository: BenchmarkSettingsRepository + + init(benchmarkSettingsRepository: BenchmarkSettingsRepository) { + self.benchmarkSettingsRepository = benchmarkSettingsRepository + benchmarkSettingsRepository.outputDirectory + .assign(to: \.outputDirectory, on: self) + .store(in: &cancellables) + benchmarkSettingsRepository.openAIKey + .assign(to: \.openAIKey, on: self) + .store(in: &cancellables) + } + + func saveOutputDirectory(_ directory: String) { + do { + try benchmarkSettingsRepository.saveBenchmarkOutputDirectory(directory) + toast.toast(content: "Output Directory changed.", level: .info) + } catch { + toast.toast(content: "Failed changing output directory.", level: .error) + } + } + + func saveOpenAIKey(_ key: String) { + do { + try benchmarkSettingsRepository.saveOpenAIKey(key) + toast.toast(content: "OpenAI Key changed.", level: .info) + } catch { + toast.toast(content: "Failed changing OpenAI Key.", level: .error) + } + } +} + diff --git a/Core/Sources/HostApp/TabContainer.swift b/Core/Sources/HostApp/TabContainer.swift index 3a4bb494..d068eb6f 100644 --- a/Core/Sources/HostApp/TabContainer.swift +++ b/Core/Sources/HostApp/TabContainer.swift @@ -14,6 +14,9 @@ public struct TabContainer: View { @ObservedObject var toastController: ToastController @State private var tabBarItems = [TabBarItem]() @Binding var tag: Int + /// currently needs to be a Singleton as the containing code appears to have a bug, what might also + /// be the reason why `hostAppStore` is declared on script level + private let benchmarkModule: BenchmarkModuleType = BenchmarkModule.shared public init() { toastController = ToastControllerDependencyKey.liveValue @@ -56,6 +59,11 @@ public struct TabContainer: View { title: "MCP", image: "wrench.and.screwdriver.fill" ) + BenchmarkView(module: benchmarkModule).tabBarItem( + tag: 3, + title: "Benchmark", + image: "testtube.2" + ) } .environment(\.tabBarTabTag, tag) .frame(minHeight: 400) diff --git a/Core/Sources/Service/MultiFileContext/Entity/ClassificationKeywords.swift b/Core/Sources/Service/MultiFileContext/Entity/ClassificationKeywords.swift new file mode 100644 index 00000000..8da1aa93 --- /dev/null +++ b/Core/Sources/Service/MultiFileContext/Entity/ClassificationKeywords.swift @@ -0,0 +1,12 @@ +public enum ClassificationKeywords: String { + case classWord = "class" + case structWord = "struct" + case enumWord = "enum" + case actorWord = "actor" + case protocolWord = "protocol" + case funcWord = "func" + case varWord = "var" + case letWord = "let" + case extensionWord = "extension" + case typealiasWord = "typealias" +} diff --git a/Core/Sources/Service/MultiFileContext/Entity/FileContent.swift b/Core/Sources/Service/MultiFileContext/Entity/FileContent.swift new file mode 100644 index 00000000..cf608b2b --- /dev/null +++ b/Core/Sources/Service/MultiFileContext/Entity/FileContent.swift @@ -0,0 +1,9 @@ +public struct FileContent { + let fileURL: String + let content: String + + public init(fileURL: String, content: String) { + self.fileURL = fileURL + self.content = content + } +} diff --git a/Core/Sources/Service/MultiFileContext/Entity/RegisteredDependencyValue.swift b/Core/Sources/Service/MultiFileContext/Entity/RegisteredDependencyValue.swift new file mode 100644 index 00000000..90d1f199 --- /dev/null +++ b/Core/Sources/Service/MultiFileContext/Entity/RegisteredDependencyValue.swift @@ -0,0 +1,4 @@ +struct RegisteredDependencyValue: Hashable { + let variableName: String + let symbolName: String +} diff --git a/Core/Sources/Service/MultiFileContext/Entity/RelevantSymbolsSummary.swift b/Core/Sources/Service/MultiFileContext/Entity/RelevantSymbolsSummary.swift new file mode 100644 index 00000000..3bad56e0 --- /dev/null +++ b/Core/Sources/Service/MultiFileContext/Entity/RelevantSymbolsSummary.swift @@ -0,0 +1,4 @@ +public struct RelevantSymbolsSummary { + public let symbolsAtLevel: [[SymbolContent]] + public let durationInSeconds: Double +} diff --git a/Core/Sources/Service/MultiFileContext/Entity/SymbolContent.swift b/Core/Sources/Service/MultiFileContext/Entity/SymbolContent.swift new file mode 100644 index 00000000..bf1e3d6b --- /dev/null +++ b/Core/Sources/Service/MultiFileContext/Entity/SymbolContent.swift @@ -0,0 +1,5 @@ +public struct SymbolContent { + public let fileURL: String + public let content: String + public internal(set) var symbol: SymbolInfo +} diff --git a/Core/Sources/Service/MultiFileContext/Entity/SymbolInfo.swift b/Core/Sources/Service/MultiFileContext/Entity/SymbolInfo.swift new file mode 100644 index 00000000..0917dd7f --- /dev/null +++ b/Core/Sources/Service/MultiFileContext/Entity/SymbolInfo.swift @@ -0,0 +1,8 @@ +public struct SymbolInfo { + public let name: String + public let kind: ClassificationKeywords + public let startLine: Int + public let endLine: Int + public let content: String + public internal(set) var extensions: [SymbolContent] = [] +} diff --git a/Core/Sources/Service/MultiFileContext/MultiFileContextManager.swift b/Core/Sources/Service/MultiFileContext/MultiFileContextManager.swift new file mode 100644 index 00000000..0caf99cc --- /dev/null +++ b/Core/Sources/Service/MultiFileContext/MultiFileContextManager.swift @@ -0,0 +1,304 @@ +import Foundation + +public class MultiFileContextManager { + private let workspaceProvider: WorkspaceProvider + private let parser: ProgrammingLanguageSyntaxParser + + public init(workspaceProvider: WorkspaceProvider, parser: ProgrammingLanguageSyntaxParser) { + self.workspaceProvider = workspaceProvider + self.parser = parser + } + + /// List files within workspace recursively + /// Retrieved from: https://stackoverflow.com/a/57640445 + public func listFilesInWorkspace(ignoreWithinPaths: [String]) async -> [String] { + guard let workspaceURL = try? await workspaceProvider.getProjectRootURL() + else { return [] } + var files = [String]() + if let enumerator = FileManager.default.enumerator( + at: workspaceURL, + includingPropertiesForKeys: [.isRegularFileKey], + options: [.skipsHiddenFiles, .skipsPackageDescendants] + ) { + for case let fileURL as URL in enumerator { + do { + let fileAttributes = try fileURL.resourceValues(forKeys: [.isRegularFileKey]) + if fileAttributes.isRegularFile ?? false, fileURL.pathExtension.lowercased() == "swift" { + if ignoreWithinPaths.contains(where: { fileURL.absoluteString.contains($0) }) { continue } + files.append(fileURL.absoluteString) + } + } catch { print(error, fileURL) } + } + } + return files + } + + public func readFileContents(ignoreWithinPaths: [String]) async -> [FileContent] { + let workspaceURLs = await listFilesInWorkspace(ignoreWithinPaths: ignoreWithinPaths) + let fileURLs = workspaceURLs + return fileURLs.compactMap { fileURLString in + guard let fileURL = URL(string: fileURLString) else { return nil } + do { + let content = try String(contentsOf: fileURL, encoding: .utf8) + return FileContent(fileURL: fileURLString, content: content) + } catch { + print("Failed to read \(fileURL):", error) + return nil + } + } + } + + public func classifyContentWithinFiles(files: [FileContent]) async -> [String: SymbolContent] { + var result: [String: SymbolContent] = [:] + + for file in files { + var symbols = parser.parse(file: file) + mergeExtensionsIntoBaseDeclarations(&symbols) + for symbol in symbols { + result[symbol.symbol.name] = symbol + } + } + + return result + } + + private func readFile(_ urlString: String) -> FileContent? { + guard let url = URL(string: urlString) else { return nil } + guard let content = try? String(contentsOf: url, encoding: .utf8) else { return nil } + return FileContent(fileURL: urlString, content: content) + } + + public func expandRelevantSymbols( + from root: FileContent, + ignoreWithinPaths: [String] = [], + maxDepth: Int? = nil, + maxFiles: Int? = nil + ) async -> RelevantSymbolsSummary { + @inline(__always) + func isIgnored(_ url: String) -> Bool { + ignoreWithinPaths.contains { url.contains($0) } + } + + let start = Date() + + let level0 = await retrieveRelevantSymbolsForFileContent(file: root, ignoreWithinPaths: ignoreWithinPaths) + var level0Unique: [SymbolContent] = [] + var includedFiles = Set() + for s in level0.values { + if includedFiles.insert(s.fileURL).inserted { + level0Unique.append(s) + } + } + var levels: [[SymbolContent]] = [level0Unique] + + var visited = Set([root.fileURL]) + var depth = 0 + var remainingFiles = maxFiles + + while maxDepth == nil || depth < maxDepth! { + let prev = levels.last ?? [] + var nextURLs = Set(prev.map(\.fileURL)) + nextURLs.subtract(visited) + nextURLs = nextURLs.filter { !isIgnored($0) } + + if nextURLs.isEmpty { break } + + if let remaining = remainingFiles, nextURLs.count > remaining { + nextURLs = Set(nextURLs.prefix(remaining)) + } + + var rawNextLevel: [SymbolContent] = [] + for url in nextURLs { + visited.insert(url) + guard let fc = readFile(url) else { continue } + let dict = await retrieveRelevantSymbolsForFileContent(file: fc, ignoreWithinPaths: ignoreWithinPaths) + rawNextLevel.append(contentsOf: dict.values) + } + + var nextLevel: [SymbolContent] = [] + var seenThisLevel = Set() + for s in rawNextLevel { + guard !includedFiles.contains(s.fileURL) else { continue } + if seenThisLevel.insert(s.fileURL).inserted { + nextLevel.append(s) + } + } + + if nextLevel.isEmpty { break } + + includedFiles.formUnion(nextLevel.map(\.fileURL)) + + levels.append(nextLevel) + depth += 1 + + if remainingFiles != nil { + remainingFiles! -= nextURLs.count + if remainingFiles! <= 0 { break } + } + } + + return RelevantSymbolsSummary( + symbolsAtLevel: levels, + durationInSeconds: Date().timeIntervalSince(start) + ) + } + + public func retrieveRelevantSymbolsForFileContent( + file: FileContent, + ignoreWithinPaths: [String] = [] + ) async -> [String: SymbolContent] { + let currentSymbolName = parser.parse(file: file).first?.symbol.name + let files = await readFileContents(ignoreWithinPaths: ignoreWithinPaths) + let allSymbols = await classifyContentWithinFiles(files: files) + + var relevant: [String: SymbolContent] = [:] + + for (name, content) in allSymbols { + if let current = currentSymbolName, current == name { continue } + + if file.content.containsExactIdentifier(name) { + if relevant[name] == nil { + relevant[name] = content + } + } + } + + let usedDependencyKeys = await retrieveUsedDependencyKeys(file: file, allFiles: files) + for dependency in usedDependencyKeys { + if let content = allSymbols[dependency.symbolName], !relevant.keys.contains(dependency.symbolName) { + relevant[dependency.symbolName] = content + } + } + + return relevant + } + + private func retrieveUsedDependencyKeys(file: FileContent, allFiles: [FileContent]) async -> [RegisteredDependencyValue] { + let dependencyValuesExtensions = await scanProjectForDependencyKeys(files: allFiles) + let dependecyVariablesAndTypes = dependencyValuesExtensions.flatMap { getDependencySymbolsFromExtensionContent($0.symbol.content) } + let usedDependencyKeys = file.content.extractDependencyReferences(dependencies: dependecyVariablesAndTypes) + return usedDependencyKeys + } + + private func mergeExtensionsIntoBaseDeclarations(_ symbols: inout [SymbolContent]) { + var indexesToRemove: [Int] = [] + + for (index, symbol) in symbols.enumerated() { + guard symbol.symbol.kind == .extensionWord else { continue } + + if let targetIndex = symbols.firstIndex(where: { + $0.symbol.name == symbol.symbol.name && + $0.symbol.kind != .extensionWord + }) { + var target = symbols[targetIndex] + target.symbol.extensions.append(symbol) + symbols[targetIndex] = target + + indexesToRemove.append(index) + } + } + + for index in indexesToRemove.sorted(by: >) { + symbols.remove(at: index) + } + } + + private func scanProjectForDependencyKeys(files: [FileContent]) async -> [SymbolContent] { + let allSymbols: [SymbolContent] = files.flatMap { parser.parse(file: $0) } + let dependencyValueExtensions = allSymbols.filter { + $0.symbol.kind == .extensionWord && $0.symbol.name == "DependencyValues" + } + return dependencyValueExtensions + } + + /// Extract every `var : { ... }` and/or `self[.self]` from an + /// `extension DependencyValues { ... }` source string. + private func getDependencySymbolsFromExtensionContent(_ content: String) -> [RegisteredDependencyValue] { + var results = Set() + + // 1) Typed property form: `var fooBar: TypeName {` + if let typed = try? NSRegularExpression( + pattern: #"\bvar\s+([A-Za-z_][A-Za-z0-9_]*)\s*:\s*([A-Za-z_][A-Za-z0-9_?.<>]+)\s*\{"#, + options: [] + ) { + let range = NSRange(content.startIndex..., in: content) + typed.enumerateMatches(in: content, options: [], range: range) { m, _, _ in + guard + let m, + let nameR = Range(m.range(at: 1), in: content), + let typeR = Range(m.range(at: 2), in: content) + else { return } + let varName = String(content[nameR]) + let typeName = String(content[typeR]) + results.insert(.init(variableName: varName, symbolName: typeName)) + } + } + + return Array(results)//.flatMap { $0 } + } +} + +extension String { + /// Checks if the string contains an exact symbol match. + /// Allows any character besides letters, numbers, and underscores as boundaries + func containsExactIdentifier(_ name: String) -> Bool { + guard !name.isEmpty else { return false } + + @inline(__always) + func isIdentChar(_ c: Character) -> Bool { c.isLetter || c.isNumber || c == "_" } + + var search = startIndex..)` and returning the matching entries from `dependencies`. + /// - Returns: Ordered, de-duplicated list of RegisteredDependencyValue, in order of first appearance. + func extractDependencyReferences(dependencies: [RegisteredDependencyValue]) -> [RegisteredDependencyValue] { + // Build quick lookup: variableName -> RegisteredDependencyValue + let byName = Dictionary(uniqueKeysWithValues: dependencies.map { ($0.variableName, $0) }) + + // Regex: @Dependency(\.fooBar) + let pattern = #"@Dependency\s*\(\s*\\\.\s*([A-Za-z_][A-Za-z0-9_]*)\s*\)"# + guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { return [] } + + let ns = self as NSString + let range = NSRange(location: 0, length: ns.length) + + // Collect (key, location) to preserve order; de-dupe by key + var seen = Set() + var orderedKeys: [(key: String, location: Int)] = [] + + regex.enumerateMatches(in: self, options: [], range: range) { match, _, _ in + guard + let match, + match.numberOfRanges >= 2 + else { return } + let keyRange = match.range(at: 1) + guard keyRange.location != NSNotFound else { return } + let key = ns.substring(with: keyRange) + if !seen.contains(key) { + seen.insert(key) + orderedKeys.append((key, match.range.location)) + } + } + + // Sort by first occurrence in the file + orderedKeys.sort { $0.location < $1.location } + + // Map to known dependencies; unknown keys are ignored + return orderedKeys.compactMap { byName[$0.key] } + } +} diff --git a/Core/Sources/Service/MultiFileContext/ProgrammingLanguageSyntaxParser.swift b/Core/Sources/Service/MultiFileContext/ProgrammingLanguageSyntaxParser.swift new file mode 100644 index 00000000..3df10d1a --- /dev/null +++ b/Core/Sources/Service/MultiFileContext/ProgrammingLanguageSyntaxParser.swift @@ -0,0 +1,21 @@ +import SwiftSyntax +import SwiftParser + +public protocol ProgrammingLanguageSyntaxParser { + func parse(file: FileContent) -> [SymbolContent] +} + +public class SwiftProgrammingLanguageSyntaxParser: ProgrammingLanguageSyntaxParser { + + public init() { } + + public func parse(file: FileContent) -> [SymbolContent] { + let sourceFile = Parser.parse(source: file.content) + let converter = SourceLocationConverter(fileName: file.fileURL, tree: sourceFile) + let collector = SwiftDeclarationCollector(sourceLocationConverter: converter, sourceText: file.content) + collector.walk(sourceFile) + return collector.symbols.map { symbol in + SymbolContent(fileURL: file.fileURL, content: file.content, symbol: symbol) + } + } +} diff --git a/Core/Sources/Service/MultiFileContext/SwiftDeclarationCollector.swift b/Core/Sources/Service/MultiFileContext/SwiftDeclarationCollector.swift new file mode 100644 index 00000000..922c864c --- /dev/null +++ b/Core/Sources/Service/MultiFileContext/SwiftDeclarationCollector.swift @@ -0,0 +1,103 @@ +import SwiftSyntax + +class SwiftDeclarationCollector: SyntaxVisitor { + var symbols: [SymbolInfo] = [] + let sourceLocationConverter: SourceLocationConverter + let sourceText: String + + init(sourceLocationConverter: SourceLocationConverter, sourceText: String) { + self.sourceLocationConverter = sourceLocationConverter + self.sourceText = sourceText + super.init(viewMode: .all) + } + + override func visit(_ node: ClassDeclSyntax) -> SyntaxVisitorContinueKind { + guard !isPrivateOrFilePrivate(node.modifiers) else { return .skipChildren } + recordSymbol(name: node.name.text, kind: ClassificationKeywords.classWord, node: node) + return .skipChildren + } + + override func visit(_ node: StructDeclSyntax) -> SyntaxVisitorContinueKind { + guard !isPrivateOrFilePrivate(node.modifiers) else { return .skipChildren } + recordSymbol(name: node.name.text, kind: ClassificationKeywords.structWord, node: node) + return .skipChildren + } + + override func visit(_ node: EnumDeclSyntax) -> SyntaxVisitorContinueKind { + guard !isPrivateOrFilePrivate(node.modifiers) else { return .skipChildren } + recordSymbol(name: node.name.text, kind: ClassificationKeywords.enumWord, node: node) + return .skipChildren + } + + override func visit(_ node: ProtocolDeclSyntax) -> SyntaxVisitorContinueKind { + guard !isPrivateOrFilePrivate(node.modifiers) else { return .skipChildren } + recordSymbol(name: node.name.text, kind: ClassificationKeywords.protocolWord, node: node) + return .skipChildren + } + + override func visit(_ node: ActorDeclSyntax) -> SyntaxVisitorContinueKind { + guard !isPrivateOrFilePrivate(node.modifiers) else { return .skipChildren } + recordSymbol(name: node.name.text, kind: ClassificationKeywords.actorWord, node: node) + return .skipChildren + } + + override func visit(_ node: FunctionDeclSyntax) -> SyntaxVisitorContinueKind { + guard !isPrivateOrFilePrivate(node.modifiers) else { return .skipChildren } + recordSymbol(name: node.name.text, kind: ClassificationKeywords.funcWord, node: node) + return .skipChildren + } + + override func visit(_ node: VariableDeclSyntax) -> SyntaxVisitorContinueKind { + guard !isPrivateOrFilePrivate(node.modifiers) else { return .skipChildren } + guard let binding = node.bindings.first, + let pattern = binding.pattern.as(IdentifierPatternSyntax.self), + let keyword: ClassificationKeywords = ClassificationKeywords(rawValue: node.bindingSpecifier.text) else { + return .skipChildren + } + recordSymbol(name: pattern.identifier.text, kind: keyword, node: node) + return .skipChildren + } + + override func visit(_ node: ExtensionDeclSyntax) -> SyntaxVisitorContinueKind { + let name = node.extendedType.trimmedDescription + guard !isPrivateOrFilePrivate(node.modifiers) && name != "View" else { return .skipChildren } + + recordSymbol(name: name, kind: ClassificationKeywords.extensionWord, node: node) + return .skipChildren + } + + override func visit(_ node: TypeAliasDeclSyntax) -> SyntaxVisitorContinueKind { + guard !isPrivateOrFilePrivate(node.modifiers) else { return .skipChildren } + recordSymbol(name: node.name.text, kind: ClassificationKeywords.typealiasWord, node: node) + return .skipChildren + } + + private func recordSymbol(name: String, kind: ClassificationKeywords, node: SyntaxProtocol) { + let startLoc = sourceLocationConverter.location(for: node.positionAfterSkippingLeadingTrivia) + let endLoc = sourceLocationConverter.location(for: node.endPositionBeforeTrailingTrivia) + let startLineIndex = startLoc.line - 1 + let endLineIndex = endLoc.line - 1 + + let lines = sourceText.split(separator: "\n", omittingEmptySubsequences: false) + + let contentLines = lines[startLineIndex...min(endLineIndex, lines.count - 1)] + let content = contentLines.joined(separator: "\n") + + let symbol = SymbolInfo( + name: name, + kind: kind, + startLine: startLoc.line, + endLine: endLoc.line, + content: content + ) + symbols.append(symbol) + } + + private func isPrivateOrFilePrivate(_ modifiers: DeclModifierListSyntax?) -> Bool { + guard let modifiers else { return false } + return modifiers.contains { modifier in + let text = modifier.name.text + return text == "private" || text == "fileprivate" + } + } +} diff --git a/Core/Sources/Service/MultiFileContext/WorkspaceProvider.swift b/Core/Sources/Service/MultiFileContext/WorkspaceProvider.swift new file mode 100644 index 00000000..2f1b0346 --- /dev/null +++ b/Core/Sources/Service/MultiFileContext/WorkspaceProvider.swift @@ -0,0 +1,56 @@ +import Foundation +import Workspace +import XcodeInspector + +public protocol WorkspaceProvider { + func workspace() async throws -> Workspace? + func getProjectRootURL() async throws -> URL +} + +class XcodeInspectorWorkspaceProvider: WorkspaceProvider { + private func getFileURL() async -> URL? { + await XcodeInspector.shared.safe.realtimeActiveDocumentURL + } + + @WorkspaceActor + private func getFilespace() async -> Filespace? { + guard + let fileURL = await getFileURL(), + let (_, filespace) = try? await Service.shared.workspacePool + .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL) + else { return nil } + return filespace + } + + func workspace() async throws -> Workspace? { + guard let filespace = await getFilespace() else { return nil } + let workspacePool: WorkspacePool = await Service.shared.workspacePool + let tuple: (workspace: Workspace, _: Filespace)? = try await workspacePool.fetchOrCreateWorkspaceAndFilespace(fileURL: filespace.fileURL) + return tuple?.workspace + } + + func getProjectRootURL() async throws -> URL { + guard let workspace = try await workspace() else { throw WorkspaceError.errorGettingWorkspace } + return workspace.projectRootURL + } +} + +enum WorkspaceError: Error { + case errorGettingWorkspace +} + +public class ManualWorkspaceProvider: WorkspaceProvider { + private let workspace: Workspace + + public init(workspace: Workspace) { + self.workspace = workspace + } + + public func workspace() async throws -> Workspace? { + workspace + } + + public func getProjectRootURL() async throws -> URL { + workspace.projectRootURL + } +} diff --git a/Core/Sources/Service/ScheduledCleaner.swift b/Core/Sources/Service/ScheduledCleaner.swift index 2178ba50..4a0a3d0c 100644 --- a/Core/Sources/Service/ScheduledCleaner.swift +++ b/Core/Sources/Service/ScheduledCleaner.swift @@ -10,9 +10,9 @@ import XcodeInspector public final class ScheduledCleaner { weak var service: Service? - init() {} + public init() {} - func start() { + public func start() { Task { @ServiceActor in while !Task.isCancelled { try await Task.sleep(nanoseconds: 10 * 60 * 1_000_000_000) @@ -31,7 +31,7 @@ public final class ScheduledCleaner { } @ServiceActor - func cleanUp() async { + public func cleanUp() async { guard let service else { return } let workspaceInfos = XcodeInspector.shared.xcodes.reduce( diff --git a/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift b/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift index 2ad3e765..a374e9e6 100644 --- a/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift +++ b/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift @@ -14,11 +14,19 @@ import AXHelper /// It's used to run some commands without really triggering the menu bar item. /// /// For example, we can use it to generate real-time suggestions without Apple Scripts. -struct PseudoCommandHandler { +public struct PseudoCommandHandler { static var lastTimeCommandFailedToTriggerWithAccessibilityAPI = Date(timeIntervalSince1970: 0) static var lastBundleNotFoundTime = Date(timeIntervalSince1970: 0) static var lastBundleDisabledTime = Date(timeIntervalSince1970: 0) private var toast: ToastController { ToastControllerDependencyKey.liveValue } + + public init() { } + + private func workspace(for filespace: Filespace) async -> Workspace? { + let workspacePool: WorkspacePool = await Service.shared.workspacePool + let tuple: (workspace: Workspace, _: Filespace)? = try? await workspacePool.fetchOrCreateWorkspaceAndFilespace(fileURL: filespace.fileURL) + return tuple?.workspace + } func presentPreviousSuggestion() async { let handler = WindowBaseCommandHandler() @@ -51,15 +59,16 @@ struct PseudoCommandHandler { } @WorkspaceActor - func generateRealtimeSuggestions(sourceEditor: SourceEditor?) async { - guard let filespace = await getFilespace(), + public func generateRealtimeSuggestions(sourceEditor: SourceEditor?, entrypoint: EntryPoint? = nil) async { + // Copilot Suggestions + guard let filespace = await getFilespace(for: entrypoint?.fileURL), let (workspace, _) = try? await Service.shared.workspacePool .fetchOrCreateWorkspaceAndFilespace(fileURL: filespace.fileURL) else { return } if Task.isCancelled { return } // Can't use handler if content is not available. - guard let editor = await getEditorContent(sourceEditor: sourceEditor) + guard let editor = await getEditorContent(sourceEditor: sourceEditor, entrypoint: entrypoint) else { return } let fileURL = filespace.fileURL @@ -399,9 +408,15 @@ extension PseudoCommandHandler { } @WorkspaceActor - func getFilespace() async -> Filespace? { + func getFilespace(for file: URL? = nil) async -> Filespace? { guard - let fileURL = await getFileURL(), + let fileURL = await { + if let file { + return file + } else { + return await getFileURL() + } + }(), let (_, filespace) = try? await Service.shared.workspacePool .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL) else { return nil } @@ -409,15 +424,17 @@ extension PseudoCommandHandler { } @WorkspaceActor - func getEditorContent(sourceEditor: SourceEditor?) async -> EditorContent? { - guard let filespace = await getFilespace(), + public func getEditorContent(sourceEditor: SourceEditor?, entrypoint: EntryPoint? = nil) async -> EditorContent? { + guard let filespace = await getFilespace(for: entrypoint?.fileURL), let sourceEditor = await { if let sourceEditor { sourceEditor } else { await XcodeInspector.shared.safe.focusedEditor } }() else { return nil } + print("CK filespace \(filespace)") + print("CK sourceEditor \(sourceEditor )") if Task.isCancelled { return nil } - let content = sourceEditor.getContent() + var content = sourceEditor.getContent() let uti = filespace.codeMetadata.uti ?? "" let tabSize = filespace.codeMetadata.tabSize ?? 4 let indentSize = filespace.codeMetadata.indentSize ?? 4 @@ -438,3 +455,12 @@ extension PseudoCommandHandler { } } +public struct EntryPoint { + public let cursor: CursorPosition + public let fileURL: URL + + public init(cursor: CursorPosition, fileURL: URL) { + self.cursor = cursor + self.fileURL = fileURL + } +} diff --git a/Core/Sources/Service/WorkspaceExtension/Workspace+Cleanup.swift b/Core/Sources/Service/WorkspaceExtension/Workspace+Cleanup.swift index d154aade..2f0bb047 100644 --- a/Core/Sources/Service/WorkspaceExtension/Workspace+Cleanup.swift +++ b/Core/Sources/Service/WorkspaceExtension/Workspace+Cleanup.swift @@ -5,7 +5,7 @@ import WorkspaceSuggestionService extension Workspace { @WorkspaceActor - func cleanUp(availableTabs: Set) { + public func cleanUp(availableTabs: Set) { for (fileURL, _) in filespaces { if isFilespaceExpired(fileURL: fileURL, availableTabs: availableTabs) { openedFileRecoverableStorage.closeFile(fileURL: fileURL) @@ -14,7 +14,7 @@ extension Workspace { } } - func isFilespaceExpired(fileURL: URL, availableTabs: Set) -> Bool { + public func isFilespaceExpired(fileURL: URL, availableTabs: Set) -> Bool { let filename = fileURL.lastPathComponent if availableTabs.contains(filename) { return false } guard let filespace = filespaces[fileURL] else { return true } diff --git a/Core/Tests/ServiceTests/MultiFileContextManagerTests.swift b/Core/Tests/ServiceTests/MultiFileContextManagerTests.swift new file mode 100644 index 00000000..be94d8c8 --- /dev/null +++ b/Core/Tests/ServiceTests/MultiFileContextManagerTests.swift @@ -0,0 +1,72 @@ +import XCTest + +@testable import Service +@testable import Workspace + +class MultiFileContextManagerTests: XCTestCase { + + var sut: MultiFileContextManager { + MultiFileContextManager( + workspaceProvider: WorkspaceProviderMock(), + parser: SwiftProgrammingLanguageSyntaxParser() + ) + } + +// func testListingFiles() async { +// let sut = sut +// let files = await sut.listFilesInWorkspace() +// +// XCTAssertNotEqual(files.count, 0) +// } + +// func testRetrievingFileContent() async { +// let sut = sut +// let files = await sut.readFileContents() +// +// XCTAssertNotEqual(files.count, 0) +// } + +// func testClassifyingCode() async { +// let sut = sut +// let classifiedSymbols = await sut.classifyContentWithinFiles() +// // symbols +// XCTAssertNotEqual(classifiedSymbols.count, 0) +// } + +// func testScanningForDependencyKeys() async { +// let sut = sut +// let dependencyKeys = await sut.scanProjectForDependencyKeys() +// let registeredDependencies = dependencyKeys.map { dependencyKey in +// sut.getDependencySymbolsFromExtensionContent(dependencyKey.symbol.content) +// } +// XCTAssertNotEqual(dependencyKeys.count, 0) +// } +} + + +class WorkspaceProviderMock: WorkspaceProvider { + func workspace() async throws -> Workspace? { +// let workspaceURL = URL(filePath: "/Users/christopherknapp/repos/CopilotForXcode-Fork/Copilot for Xcode.xcworkspace") +// let workspaceURL = URL(filePath: "/Users/christopherknapp/repos/clean-architecture-swiftui-fork") +// let workspaceURL = URL(filePath: "/Users/christopherknapp/repos/ios-minttv") +// let workspaceURL = URL(filePath: "/Users/christopherknapp/repos/ios-minttv/Pod/Classes/Twitch/TwitchEndpoint.swift") +// let workspaceURL = URL(filePath: "/Users/christopherknapp/repos/ios-minttv/Pod/Classes/Twitch/Chat/IRC") +// let workspaceURL = URL(filePath: "/Users/christopherknapp/repos/ios-minttv/Pod/Classes/Twitch/Chat/Presentation/View")projectRootURL Foundation.URL "file:///Users/christopherknapp/repos/ModernCleanArchitectureSwiftUI/" + let workspaceURL = URL(filePath: "/Users/christopherknapp/repos/ModernCleanArchitectureSwiftUI/") + return Workspace(workspaceURL: workspaceURL) + } + + func getProjectRootURL() async throws -> URL { + URL(filePath: "/Users/christopherknapp/repos/ModernCleanArchitectureSwiftUI/") + } +} + +//private func mockFilespace() -> Filespace { +// let fileURL = URL(filePath: "/Users/christopherknapp/repos/CopilotForXcode-Fork/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift") +// return Filespace(fileURL: fileURL) { filespace in +// return +// } onClose: { url in +// return +// } +// +//} diff --git a/ExtensionService/AppDelegate.swift b/ExtensionService/AppDelegate.swift index dc81bcd6..6514381d 100644 --- a/ExtensionService/AppDelegate.swift +++ b/ExtensionService/AppDelegate.swift @@ -56,13 +56,13 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { func applicationDidFinishLaunching(_: Notification) { if ProcessInfo.processInfo.environment["IS_UNIT_TEST"] == "YES" { return } - _ = XcodeInspector.shared - service.start() - AXIsProcessTrustedWithOptions([ - kAXTrustedCheckOptionPrompt.takeRetainedValue() as NSString: true, - ] as CFDictionary) - setupQuitOnUpdate() - setupQuitOnUserTerminated() +// _ = XcodeInspector.shared +// service.start() +// AXIsProcessTrustedWithOptions([ +// kAXTrustedCheckOptionPrompt.takeRetainedValue() as NSString: true, +// ] as CFDictionary) +// setupQuitOnUpdate() +// setupQuitOnUserTerminated() xpcController = .init() Logger.service.info("XPC Service started.") NSApp.setActivationPolicy(.accessory) diff --git a/README.md b/README.md index 9d6a8d72..71f9bef5 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,36 @@ # GitHub Copilot for Xcode +## Note on this Fork + +This repository is a **fork** of [github/CopilotForXcode](https://github.com/github/CopilotForXcode). +It has been extended as part of my Master’s Thesis at HTW Berlin: +*“Enhancing a Generative AI Tool for Improved Code Generation and Automated Application in an IDE”*. + +### Additions in this Fork + +- **Multi-file Inline Completions**: Extended support to allow Copilot suggestions across multiple files, not just the opened tabs. +- **Benchmark Tool**: Added functionality to test code completion quality across several large language models (GitHub Copilot, multiple GPT variants from OpenAI) to be used together with a specifically designed dataset. +- **Evaluation Dataset**: Inspired by [HumanEval](https://arxiv.org/abs/2107.03374), but adapted to reflect realistic multi-file development scenarios. + +These extensions are research prototypes and not intended for merging upstream. They provide the basis for benchmarking and evaluating IDE integration with generative AI. + +### Related Repository + +This project is used in combination with another fork created for the thesis: +- [Modern Clean ArchitectureSwiftUI](https://github.com/chrisknapp98/ModernCleanArchitectureSwiftUI) – extended with a `Benchmark` module containing 15 tasks for reproducible evaluation. + +### Default Branch + +The default branch of this fork has been changed from `main` to `integration/multi-file-context-benchmark`. + +Reason: +This fork was extended with **SwiftSyntax** support and upgraded to **Swift 5.10**, while the upstream project is still on **Swift 5.9**. +To avoid constant “ping-pong” merge conflicts in `Package.swift` and related build files, this fork maintains its own stable branch. + +If you want the extended research prototype features (multi-file completions, benchmarks, dataset), use this fork’s default branch. + +--- + [GitHub Copilot](https://github.com/features/copilot) is an AI pair programmer tool that helps you write code faster and smarter. Copilot for Xcode is an Xcode extension that provides inline coding suggestions as you type and a chat assistant to answer your coding questions. diff --git a/Tool/Sources/BuiltinExtension/BuiltinExtension.swift b/Tool/Sources/BuiltinExtension/BuiltinExtension.swift index 525b5c54..7db6f126 100644 --- a/Tool/Sources/BuiltinExtension/BuiltinExtension.swift +++ b/Tool/Sources/BuiltinExtension/BuiltinExtension.swift @@ -21,5 +21,13 @@ public protocol BuiltinExtension: CopilotForXcodeCapability { /// It's usually called when the app is about to quit, /// you should clean up all the resources here. func terminate() + + /// To manually set the document version to 1 in case service is not connected to editor + func workspace( + _ workspace: WorkspaceInfo, + didUpdateDocumentAt documentURL: URL, + content: String?, + version: Int + ) } diff --git a/Tool/Sources/BuiltinExtension/BuiltinExtensionWorkspacePlugin.swift b/Tool/Sources/BuiltinExtension/BuiltinExtensionWorkspacePlugin.swift index a03c34d1..d9a47a19 100644 --- a/Tool/Sources/BuiltinExtension/BuiltinExtensionWorkspacePlugin.swift +++ b/Tool/Sources/BuiltinExtension/BuiltinExtensionWorkspacePlugin.swift @@ -20,6 +20,10 @@ public final class BuiltinExtensionWorkspacePlugin: WorkspacePlugin { override public func didUpdateFilespace(_ filespace: Filespace, content: String) { notifyUpdateFile(filespace: filespace, content: content) } + + override public func didUpdateFilespace(_ filespace: Filespace, content: String, version: Int) { + notifyUpdateFile(filespace: filespace, content: content, version: version) + } override public func didCloseFilespace(_ fileURL: URL) { Task { @@ -56,6 +60,20 @@ public final class BuiltinExtensionWorkspacePlugin: WorkspacePlugin { } } } + + public func notifyUpdateFile(filespace: Filespace, content: String, version: Int) { + Task { + guard filespace.isTextReadable else { return } + for ext in extensionManager.extensions { + ext.workspace( + .init(workspaceURL: workspaceURL, projectURL: projectRootURL), + didUpdateDocumentAt: filespace.fileURL, + content: content, + version: version + ) + } + } + } public func notifySaveFile(filespace: Filespace) { Task { diff --git a/Tool/Sources/ChatTab/ChatTab.swift b/Tool/Sources/ChatTab/ChatTab.swift index 54bc5781..e99905a5 100644 --- a/Tool/Sources/ChatTab/ChatTab.swift +++ b/Tool/Sources/ChatTab/ChatTab.swift @@ -108,6 +108,7 @@ open class BaseChatTab { private var didStart = false private let storeObserver = NSObject() + @MainActor public init(store: StoreOf) { chatTabStore = store @@ -232,7 +233,7 @@ public class EmptyChatTab: ChatTab { struct Builder: ChatTabBuilder { let title: String func build(store: StoreOf) async -> (any ChatTab)? { - EmptyChatTab(store: store) + await EmptyChatTab(store: store) } } @@ -274,6 +275,7 @@ public class EmptyChatTab: ChatTab { return Builder(title: "Empty") } + @MainActor public convenience init(id: String) { self.init(store: .init( initialState: .init(id: id, title: "Empty-\(id)", workspacePath: "", username: ""), diff --git a/Tool/Sources/GitHubCopilotService/GitHubCopilotExtension.swift b/Tool/Sources/GitHubCopilotService/GitHubCopilotExtension.swift index 119278ee..cd59b285 100644 --- a/Tool/Sources/GitHubCopilotService/GitHubCopilotExtension.swift +++ b/Tool/Sources/GitHubCopilotService/GitHubCopilotExtension.swift @@ -129,6 +129,44 @@ public final class GitHubCopilotExtension: BuiltinExtension { } } } + + public func workspace( + _ workspace: WorkspaceInfo, + didUpdateDocumentAt documentURL: URL, + content: String?, + version: Int + ) { + guard isLanguageServerInUse else { return } + // check if file size is larger than 15MB, if so, return immediately + if let attrs = try? FileManager.default + .attributesOfItem(atPath: documentURL.path), + let fileSize = attrs[FileAttributeKey.size] as? UInt64, + fileSize > 15 * 1024 * 1024 + { return } + + Task { + guard let content else { return } + guard let service = await serviceLocator.getService(from: workspace) else { return } + do { + try await service.notifyChangeTextDocument( + fileURL: documentURL, + content: content, + version: version + ) + } catch let error as ServerError { + switch error { + case .serverError(-32602, _, _): // parameter incorrect + Logger.gitHubCopilot.error(error.localizedDescription) + // Reopen document if it's not found in the language server + self.workspace(workspace, didOpenDocumentAt: documentURL) + default: + Logger.gitHubCopilot.info(error.localizedDescription) + } + } catch { + Logger.gitHubCopilot.info(error.localizedDescription) + } + } + } public func extensionUsageDidChange(_ usage: ExtensionUsage) { extensionUsage = usage diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift index 310ab959..a4e2dab8 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift @@ -730,7 +730,7 @@ public final class GitHubCopilotService: textDocument: .init( uri: uri, languageId: languageId.rawValue, - version: 0, + version: 1, text: content ) ) diff --git a/Tool/Sources/Workspace/OpenedFileRocoverableStorage.swift b/Tool/Sources/Workspace/OpenedFileRocoverableStorage.swift index 06833867..b47dc13a 100644 --- a/Tool/Sources/Workspace/OpenedFileRocoverableStorage.swift +++ b/Tool/Sources/Workspace/OpenedFileRocoverableStorage.swift @@ -31,9 +31,10 @@ public final class OpenedFileRecoverableStorage { } public var openedFiles: [URL] { - let dict = userDefault.dictionary(forKey: key) ?? [:] - let openedFiles = dict[projectRootURL.path] as? [String] ?? [] - return openedFiles.map { URL(fileURLWithPath: $0) } +// let dict = userDefault.dictionary(forKey: key) ?? [:] +// let openedFiles = dict[projectRootURL.path] as? [String] ?? [] +// return openedFiles.map { URL(fileURLWithPath: $0) } + [] } } diff --git a/Tool/Sources/Workspace/Workspace.swift b/Tool/Sources/Workspace/Workspace.swift index 82248822..1db2a755 100644 --- a/Tool/Sources/Workspace/Workspace.swift +++ b/Tool/Sources/Workspace/Workspace.swift @@ -46,6 +46,7 @@ open class WorkspacePlugin { open func didOpenFilespace(_: Filespace) {} open func didSaveFilespace(_: Filespace) {} open func didUpdateFilespace(_: Filespace, content: String) {} + open func didUpdateFilespace(_: Filespace, content: String, version: Int) {} open func didCloseFilespace(_: URL) {} } @@ -178,18 +179,18 @@ public final class Workspace { } @WorkspaceActor - public func didUpdateFilespace(fileURL: URL, content: String) { + public func didUpdateFilespace(fileURL: URL, content: String, version: Int = 0) { refreshUpdateTime() guard let filespace = filespaces[fileURL] else { return } filespace.bumpVersion() filespace.refreshUpdateTime() for plugin in plugins.values { - plugin.didUpdateFilespace(filespace, content: content) + plugin.didUpdateFilespace(filespace, content: content, version: version) } } @WorkspaceActor - func didOpenFilespace(_ filespace: Filespace) { + public func didOpenFilespace(_ filespace: Filespace) { refreshUpdateTime() openedFileRecoverableStorage.openFile(fileURL: filespace.fileURL) for plugin in plugins.values { @@ -198,14 +199,14 @@ public final class Workspace { } @WorkspaceActor - func didCloseFilespace(_ fileURL: URL) { + public func didCloseFilespace(_ fileURL: URL) { for plugin in self.plugins.values { plugin.didCloseFilespace(fileURL) } } @WorkspaceActor - func didSaveFilespace(_ filespace: Filespace) { + public func didSaveFilespace(_ filespace: Filespace) { refreshUpdateTime() filespace.refreshUpdateTime() for plugin in plugins.values {