diff --git a/.github/workflows/release-macos.yml b/.github/workflows/release-macos.yml index 14b26ec19..2308d5a9f 100644 --- a/.github/workflows/release-macos.yml +++ b/.github/workflows/release-macos.yml @@ -121,6 +121,15 @@ jobs: /usr/libexec/PlistBuddy \ -c "Print :CFBundleVersion" \ "$app_path/Contents/Info.plist" + for build_key in \ + LitheBuildGitRevision \ + LitheBuildGitBranch \ + LitheBuildGitDirty \ + LitheBuildTimestamp; do + /usr/libexec/PlistBuddy \ + -c "Print :${build_key}" \ + "$app_path/Contents/Info.plist" + done lipo "$app_path/Contents/MacOS/Lithe" -verify_arch "$LITHE_ARCH" hdiutil imageinfo "$dmg_path" > /dev/null test -s "$dmg_path" diff --git a/Resources/zh-Hans.lproj/Localizable.strings b/Resources/zh-Hans.lproj/Localizable.strings index 804e3ee0f..5543b8b8e 100644 --- a/Resources/zh-Hans.lproj/Localizable.strings +++ b/Resources/zh-Hans.lproj/Localizable.strings @@ -30,6 +30,14 @@ "Save the active document" = "保存当前文档"; "Move to the next match in the active editor" = "跳转到当前编辑器中的下一个匹配项"; "Move to the previous match in the active editor" = "跳转到当前编辑器中的上一个匹配项"; +"Back" = "后退"; +"Navigate to the previous editor location" = "跳转到上一个编辑器位置"; +"Forward" = "前进"; +"Navigate to the next editor location" = "跳转到下一个编辑器位置"; +"Go to Definition" = "跳转到定义"; +"Navigate to the declaration of the selected symbol" = "跳转到所选符号的声明"; +"Spring Endpoints" = "Spring 接口"; +"Show indexed Spring MVC routes" = "显示已索引的 Spring MVC 路由"; "Navigate to a call site of the selected symbol" = "跳转到所选符号的调用位置"; "Navigate to an implementation of the selected symbol" = "跳转到所选符号的实现"; "Find references to the selected symbol" = "查找所选符号的引用"; @@ -1149,6 +1157,10 @@ "Uninstall" = "卸载"; "Overview" = "概览"; "No Plugins" = "暂无插件"; +"More Language Support" = "扩展更多语言"; +"%lld languages · %lld enabled" = "%lld 种语言 · 已启用 %lld 个"; +"Expanded" = "已展开"; +"Collapsed" = "已收起"; "Database" = "数据库连接"; "Enabled" = "已启用"; "Disabled" = "已禁用"; diff --git a/Sources/Lithe/Application/Features/NavigationHistoryFeatureModel.swift b/Sources/Lithe/Application/Features/NavigationHistoryFeatureModel.swift new file mode 100644 index 000000000..1196dc253 --- /dev/null +++ b/Sources/Lithe/Application/Features/NavigationHistoryFeatureModel.swift @@ -0,0 +1,104 @@ +import Combine +import Foundation + +struct EditorNavigationLocation: Hashable, Sendable { + let url: URL + let line: Int + let utf16Column: Int + let isReadOnly: Bool + let displayPath: String? + let virtualProviderID: String? + + init( + url: URL, + line: Int, + utf16Column: Int, + isReadOnly: Bool = false, + displayPath: String? = nil, + virtualProviderID: String? = nil + ) { + self.url = url.isFileURL ? url.standardizedFileURL : url + self.line = max(0, line) + self.utf16Column = max(0, utf16Column) + self.isReadOnly = isReadOnly + self.displayPath = displayPath + self.virtualProviderID = virtualProviderID + } +} + +struct NavigationHistorySnapshot: Equatable, Sendable { + let backLocations: [EditorNavigationLocation] + let forwardLocations: [EditorNavigationLocation] +} + +/// Owns bounded editor-location history independently from document tabs. +/// A jump records the live departure location so caret movement since the last +/// navigation is preserved when the user returns. +@MainActor +final class NavigationHistoryFeatureModel: ObservableObject { + @Published private(set) var backLocations: [EditorNavigationLocation] = [] + @Published private(set) var forwardLocations: [EditorNavigationLocation] = [] + + private let maximumEntryCount: Int + + init(maximumEntryCount: Int = 100) { + self.maximumEntryCount = max(1, maximumEntryCount) + } + + var canNavigateBack: Bool { !backLocations.isEmpty } + var canNavigateForward: Bool { !forwardLocations.isEmpty } + + func recordJump( + from departure: EditorNavigationLocation?, + to destination: EditorNavigationLocation + ) { + guard let departure, departure != destination else { return } + append(departure, to: &backLocations) + forwardLocations.removeAll() + } + + func navigateBack(from current: EditorNavigationLocation?) -> EditorNavigationLocation? { + guard let destination = backLocations.popLast() else { return nil } + if let current, current != destination { + append(current, to: &forwardLocations) + } + return destination + } + + func navigateForward(from current: EditorNavigationLocation?) -> EditorNavigationLocation? { + guard let destination = forwardLocations.popLast() else { return nil } + if let current, current != destination { + append(current, to: &backLocations) + } + return destination + } + + func reset() { + backLocations.removeAll() + forwardLocations.removeAll() + } + + func snapshot() -> NavigationHistorySnapshot { + NavigationHistorySnapshot( + backLocations: backLocations, + forwardLocations: forwardLocations + ) + } + + func restore(_ snapshot: NavigationHistorySnapshot) { + backLocations = snapshot.backLocations + forwardLocations = snapshot.forwardLocations + } + + private func append( + _ location: EditorNavigationLocation, + to locations: inout [EditorNavigationLocation] + ) { + if locations.last != location { + locations.append(location) + } + if locations.count > maximumEntryCount { + locations.removeFirst(locations.count - maximumEntryCount) + } + } +} diff --git a/Sources/Lithe/Application/Features/SpringFeatureModel.swift b/Sources/Lithe/Application/Features/SpringFeatureModel.swift new file mode 100644 index 000000000..2bd9a0d6c --- /dev/null +++ b/Sources/Lithe/Application/Features/SpringFeatureModel.swift @@ -0,0 +1,267 @@ +import Combine +import Foundation +import LitheCoreContracts + +/// Owns the workspace-level Spring semantic projection produced by Rust Core. +@MainActor +final class SpringFeatureModel: ObservableObject { + @Published private(set) var properties: [SpringProperty] = [] + @Published private(set) var values: [SpringConfigurationValue] = [] + @Published private(set) var propertyReferences: [SpringPropertyReference] = [] + @Published private(set) var diagnostics: [SpringDiagnostic] = [] + @Published private(set) var beans: [SpringBean] = [] + @Published private(set) var injections: [SpringInjection] = [] + @Published private(set) var endpoints: [SpringEndpoint] = [] + @Published private(set) var isIndexing = false + + private let operations: any JavaMavenOperations + private var generation = UUID() + private var reloadTask: Task? + + init(operations: any JavaMavenOperations) { + self.operations = operations + } + + func load( + workspaceURL: URL, + files: [URL], + textOverrides: [URL: String] = [:], + refreshDependencyMetadata: Bool = true + ) async { + generation = UUID() + let currentGeneration = generation + isIndexing = true + let operations = self.operations + let result = await Task.detached(priority: .utility) { + operations.springIndex( + at: workspaceURL, + files: files, + textOverrides: textOverrides, + refreshDependencyMetadata: refreshDependencyMetadata + ) + }.value ?? .empty + guard generation == currentGeneration else { return } + properties = result.properties + values = result.values + propertyReferences = result.propertyReferences + diagnostics = result.diagnostics + beans = result.beans + injections = result.injections + endpoints = result.endpoints + isIndexing = false + } + + func reset() { + reloadTask?.cancel() + reloadTask = nil + generation = UUID() + properties = [] + values = [] + propertyReferences = [] + diagnostics = [] + beans = [] + injections = [] + endpoints = [] + isIndexing = false + } + + func scheduleReload( + changedDocument: EditorDocument, + workspaceURL: URL, + files: [URL], + openDocuments: [EditorDocument] + ) { + let name = changedDocument.url.lastPathComponent + guard handles(changedDocument.url) + || changedDocument.url.pathExtension.lowercased() == "java" + || name == "spring-configuration-metadata.json" + || name == "additional-spring-configuration-metadata.json" else { return } + reloadTask?.cancel() + reloadTask = Task { @MainActor [weak self] in + try? await Task.sleep(for: .milliseconds(300)) + guard !Task.isCancelled, let self else { return } + let overrides = Dictionary(uniqueKeysWithValues: openDocuments.map { + ($0.url.standardizedFileURL, $0.text) + }) + await self.load( + workspaceURL: workspaceURL, + files: files, + textOverrides: overrides, + refreshDependencyMetadata: false + ) + } + } + + func handles(_ url: URL) -> Bool { + let name = url.lastPathComponent.lowercased() + return name == "application.properties" + || (name.hasPrefix("application-") && name.hasSuffix(".properties")) + || ((name == "application.yml" || name == "application.yaml") + || (name.hasPrefix("application-") && ["yml", "yaml"].contains(url.pathExtension.lowercased()))) + } + + func completions( + document: EditorDocument, + line: Int, + utf16Column: Int + ) -> [LanguageServerCompletionItem] { + guard handles(document.url), + let context = completionContext( + text: document.text, + extensionName: document.url.pathExtension.lowercased(), + line: line, + utf16Column: utf16Column + ) else { return [] } + return properties.compactMap { property in + guard property.name.hasPrefix(context.parentPrefix) else { return nil } + let insertText = String(property.name.dropFirst(context.parentPrefix.count)) + guard insertText.localizedCaseInsensitiveContains(context.typedPrefix) else { return nil } + let detail = [property.typeName, property.defaultValue.map { "default: \($0)" }] + .compactMap { $0 }.joined(separator: " · ") + return LanguageServerCompletionItem( + label: property.name, + detail: detail.isEmpty ? "Spring Boot property" : detail, + documentation: property.documentation, + insertText: insertText, + sortText: property.name, + filterText: property.name, + kind: 10, + textEdit: LanguageServerTextEdit( + range: LanguageServerRange( + start: LanguageServerPosition(line: line, utf16Column: context.replacementStart), + end: LanguageServerPosition(line: line, utf16Column: utf16Column) + ), + newText: insertText + ), + additionalTextEdits: [], + data: nil + ) + } + } + + func hover(for url: URL, line: Int) -> LanguageServerHover? { + guard let value = values.first(where: { + $0.url.standardizedFileURL == url.standardizedFileURL && $0.line == line + 1 + }), let property = properties.first(where: { $0.name == value.key }) else { return nil } + var parts = ["`\(property.name)`"] + if let typeName = property.typeName { parts.append("Type: `\(typeName)`") } + if let defaultValue = property.defaultValue { parts.append("Default: `\(defaultValue)`") } + if let documentation = property.documentation { parts.append(documentation) } + if let profile = value.profile { parts.append("Profile: `\(profile)`") } + if value.overridesBaseValue { parts.append("Overrides the base application value.") } + return LanguageServerHover(contents: parts.joined(separator: "\n\n"), isMarkdown: true, range: nil) + } + + func navigationLocations(for url: URL, line: Int) -> [LanguageServerLocation] { + if let value = values.first(where: { + $0.url.standardizedFileURL == url.standardizedFileURL && $0.line == line + 1 + }) { + var locations: [LanguageServerLocation] = [] + if let targetURL = value.targetURL { + locations.append(location( + targetURL, + line: value.targetLine, + column: value.targetColumn + )) + } + locations.append(contentsOf: propertyReferences.filter { $0.key == value.key }.map { + location($0.url, line: $0.line, column: $0.column) + }) + if !locations.isEmpty { return unique(locations) } + } + if let reference = propertyReferences.first(where: { + $0.url.standardizedFileURL == url.standardizedFileURL && $0.line == line + 1 + }) { + return unique(values.filter { $0.key == reference.key }.map { + location($0.url, line: $0.line, column: $0.column) + }) + } + if let injection = injections.first(where: { + $0.url.standardizedFileURL == url.standardizedFileURL && abs($0.line - (line + 1)) <= 1 + }) { + return injection.beanIDs.compactMap { id in + beans.first(where: { $0.id == id }).map { + location($0.url, line: $0.line, column: $0.column) + } + } + } + let matchingProperties = properties.filter { + $0.sourceURL?.standardizedFileURL == url.standardizedFileURL + && $0.sourceLine.map { abs($0 - (line + 1)) <= 1 } == true + } + return matchingProperties.flatMap { property in + values.filter { $0.key == property.name }.map { + location($0.url, line: $0.line, column: $0.column) + } + } + } + + var languageDiagnostics: [URL: [LanguageServerDiagnostic]] { + Dictionary(grouping: diagnostics, by: { $0.url.standardizedFileURL }).mapValues { values in + values.map { value in + LanguageServerDiagnostic( + range: LanguageServerRange( + start: LanguageServerPosition(line: max(0, value.line - 1), utf16Column: max(0, value.column - 1)), + end: LanguageServerPosition(line: max(0, value.line - 1), utf16Column: max(0, value.column)) + ), + severity: value.severity == "error" ? 1 : 2, + message: value.message, + source: "Spring", + code: "spring.configuration" + ) + } + } + } + + private func location(_ url: URL, line: Int?, column: Int?) -> LanguageServerLocation { + let position = LanguageServerPosition( + line: max(0, (line ?? 1) - 1), + utf16Column: max(0, (column ?? 1) - 1) + ) + return LanguageServerLocation( + url: url, + range: LanguageServerRange(start: position, end: position) + ) + } + + private func unique(_ locations: [LanguageServerLocation]) -> [LanguageServerLocation] { + var seen = Set() + return locations.filter { location in + let key = "\(location.url.standardizedFileURL.path):\(location.range.start.line):\(location.range.start.utf16Column)" + return seen.insert(key).inserted + } + } + + private func completionContext( + text: String, + extensionName: String, + line: Int, + utf16Column: Int + ) -> (parentPrefix: String, typedPrefix: String, replacementStart: Int)? { + let lines = text.components(separatedBy: .newlines) + guard lines.indices.contains(line) else { return nil } + let current = lines[line] as NSString + let column = min(max(0, utf16Column), current.length) + var start = column + let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: ".-_")) + while start > 0, + let scalar = UnicodeScalar(current.character(at: start - 1)), + allowed.contains(scalar) { + start -= 1 + } + let typed = current.substring(with: NSRange(location: start, length: column - start)) + guard extensionName != "properties" else { return ("", typed, start) } + let indent = lines[line].prefix { $0 == " " || $0 == "\t" }.count + var stack: [(indent: Int, key: String)] = [] + for previous in lines.prefix(line) { + let trimmed = previous.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty, !trimmed.hasPrefix("#"), trimmed.hasSuffix(":") else { continue } + let previousIndent = previous.prefix { $0 == " " || $0 == "\t" }.count + while stack.last.map({ $0.indent >= previousIndent }) == true { stack.removeLast() } + stack.append((previousIndent, String(trimmed.dropLast()).trimmingCharacters(in: CharacterSet(charactersIn: "\"'")))) + } + while stack.last.map({ $0.indent >= indent }) == true { stack.removeLast() } + let parent = stack.map(\.key).joined(separator: ".") + return (parent.isEmpty ? "" : parent + ".", typed, start) + } +} diff --git a/Sources/Lithe/Core/Rust/RustCoreBridge.swift b/Sources/Lithe/Core/Rust/RustCoreBridge.swift index c30aebfbf..0eb627541 100644 --- a/Sources/Lithe/Core/Rust/RustCoreBridge.swift +++ b/Sources/Lithe/Core/Rust/RustCoreBridge.swift @@ -316,6 +316,78 @@ struct RustCoreBridge: Sendable { let configurations: [Configuration] } + struct SpringIndexPayload: Decodable, Sendable { + struct Property: Decodable, Sendable { + let name: String + let typeName: String? + let description: String? + let defaultValue: String? + let sourcePath: String? + let sourceLine: Int? + let sourceColumn: Int? + } + struct ConfigurationValue: Decodable, Sendable { + let key: String + let value: String + let path: String + let line: Int + let column: Int + let profile: String? + let overridesBaseValue: Bool + let targetPath: String? + let targetLine: Int? + let targetColumn: Int? + } + struct PropertyReference: Decodable, Sendable { + let key: String + let path: String + let line: Int + let column: Int + } + struct Diagnostic: Decodable, Sendable { + let path: String + let line: Int + let column: Int + let severity: String + let message: String + } + struct Bean: Decodable, Sendable { + let id: String + let name: String + let typeName: String + let path: String + let line: Int + let column: Int + let kind: String + } + struct Injection: Decodable, Sendable { + let path: String + let line: Int + let column: Int + let typeName: String + let qualifier: String? + let beanIds: [String] + } + struct Endpoint: Decodable, Sendable { + let id: String + let httpMethods: [String] + let route: String + let controller: String + let method: String + let path: String + let line: Int + let column: Int + } + + let properties: [Property] + let values: [ConfigurationValue] + let propertyReferences: [PropertyReference] + let diagnostics: [Diagnostic] + let beans: [Bean] + let injections: [Injection] + let endpoints: [Endpoint] + } + struct RunConfigurationPayload: Codable, Sendable { struct Generator: Codable, Sendable { let fingerprint: String @@ -796,6 +868,8 @@ struct RustCoreBridge: Sendable { let references: [Reference] let commits: [Commit] let hasMore: Bool + let userName: String? + let userEmail: String? func makeSnapshot() -> GitHistorySnapshot { GitHistorySnapshot( @@ -821,7 +895,10 @@ struct RustCoreBridge: Sendable { decorations: commit.decorations ) }, - hasMore: hasMore + hasMore: hasMore, + identity: (userName == nil && userEmail == nil) + ? nil + : GitIdentity(name: userName, email: userEmail) ) } } @@ -1425,6 +1502,14 @@ struct RustCoreBridge: Sendable { let declarationSources: [String] } + private struct SpringIndexRequest: Encodable { + let root: String + let paths: [String] + let metadataRepositories: [String] + let refreshDependencyMetadata: Bool + let textOverrides: [String: String] + } + private struct JavaCodeVisionRequest: Encodable { let root: String let targetPath: String @@ -2222,6 +2307,27 @@ struct RustCoreBridge: Sendable { ) } + func springIndex( + at rootURL: URL, + paths: [String], + metadataRepositoryURLs: [URL] = [], + refreshDependencyMetadata: Bool = false, + textOverrides: [String: String] = [:] + ) -> SpringIndexPayload? { + execute( + command: "spring.index", + payload: SpringIndexRequest( + root: rootURL.standardizedFileURL.path, + paths: paths, + metadataRepositories: metadataRepositoryURLs.map { + $0.standardizedFileURL.path + }, + refreshDependencyMetadata: refreshDependencyMetadata, + textOverrides: textOverrides + ) + ) + } + func gitStatus(at rootURL: URL) -> GitStatusPayload? { execute( command: "git.status", diff --git a/Sources/Lithe/Core/Rust/RustJavaMavenOperations.swift b/Sources/Lithe/Core/Rust/RustJavaMavenOperations.swift index db1ead766..6be8589ad 100644 --- a/Sources/Lithe/Core/Rust/RustJavaMavenOperations.swift +++ b/Sources/Lithe/Core/Rust/RustJavaMavenOperations.swift @@ -24,6 +24,21 @@ protocol JavaMavenOperations: MavenProjectOperations, RunServerPortParsing, Send source: String, declarationSources: [String] ) -> JavaStructureResult? + func springIndex( + at rootURL: URL, + files: [URL], + textOverrides: [URL: String], + refreshDependencyMetadata: Bool + ) -> SpringIndexResult? +} + +extension JavaMavenOperations { + func springIndex( + at rootURL: URL, + files: [URL], + textOverrides: [URL: String] = [:], + refreshDependencyMetadata: Bool = false + ) -> SpringIndexResult? { nil } } struct JavaStructureResult: Sendable { @@ -41,6 +56,21 @@ struct JavaCodeVisionValue: Sendable { struct RustJavaMavenOperations: JavaMavenOperations, Sendable { let core: RustCoreBridge + let metadataRepositoryURLs: [URL] + + init( + core: RustCoreBridge, + metadataRepositoryURL: URL? = nil, + metadataRepositoryURLs: [URL] = [] + ) { + self.core = core + self.metadataRepositoryURLs = ([metadataRepositoryURL].compactMap { $0 } + + metadataRepositoryURLs) + .reduce(into: [URL]()) { values, url in + let standardized = url.standardizedFileURL + if !values.contains(standardized) { values.append(standardized) } + } + } func scanMavenProject(at rootURL: URL, files: [URL]) -> MavenProject? { let root = rootURL.standardizedFileURL @@ -172,4 +202,86 @@ struct RustJavaMavenOperations: JavaMavenOperations, Sendable { inlayHints: payload.makeInlayHints() ) } + + func springIndex( + at rootURL: URL, + files: [URL], + textOverrides: [URL: String] = [:], + refreshDependencyMetadata: Bool = false + ) -> SpringIndexResult? { + let root = rootURL.standardizedFileURL + let paths = files.compactMap { workspaceRelativePath(for: $0, root: root) } + guard let payload = core.springIndex( + at: root, + paths: paths, + metadataRepositoryURLs: metadataRepositoryURLs, + refreshDependencyMetadata: refreshDependencyMetadata, + textOverrides: Dictionary(uniqueKeysWithValues: textOverrides.compactMap { url, text in + workspaceRelativePath(for: url, root: root).map { ($0, text) } + }) + ) else { return nil } + func url(_ path: String?) -> URL? { + path.map { root.appendingPathComponent($0).standardizedFileURL } + } + return SpringIndexResult( + properties: payload.properties.map { value in + SpringProperty( + name: value.name, + typeName: value.typeName, + documentation: value.description, + defaultValue: value.defaultValue, + sourceURL: url(value.sourcePath), + sourceLine: value.sourceLine, + sourceColumn: value.sourceColumn + ) + }, + values: payload.values.map { value in + SpringConfigurationValue( + key: value.key, + value: value.value, + url: url(value.path)!, + line: value.line, + column: value.column, + profile: value.profile, + overridesBaseValue: value.overridesBaseValue, + targetURL: url(value.targetPath), + targetLine: value.targetLine, + targetColumn: value.targetColumn + ) + }, + propertyReferences: payload.propertyReferences.map { value in + SpringPropertyReference( + key: value.key, + url: url(value.path)!, + line: value.line, + column: value.column + ) + }, + diagnostics: payload.diagnostics.map { value in + SpringDiagnostic( + url: url(value.path)!, line: value.line, column: value.column, + severity: value.severity, message: value.message + ) + }, + beans: payload.beans.map { value in + SpringBean( + id: value.id, name: value.name, typeName: value.typeName, + url: url(value.path)!, line: value.line, column: value.column, kind: value.kind + ) + }, + injections: payload.injections.map { value in + SpringInjection( + url: url(value.path)!, line: value.line, column: value.column, + typeName: value.typeName, qualifier: value.qualifier, beanIDs: value.beanIds + ) + }, + endpoints: payload.endpoints.map { value in + SpringEndpoint( + id: value.id, httpMethods: value.httpMethods, route: value.route, + controller: value.controller, method: value.method, + url: url(value.path)!, line: value.line, column: value.column + ) + } + ) + } } diff --git a/Sources/Lithe/LitheApp.swift b/Sources/Lithe/LitheApp.swift index e9d783d99..a5195b053 100644 --- a/Sources/Lithe/LitheApp.swift +++ b/Sources/Lithe/LitheApp.swift @@ -176,6 +176,24 @@ struct LitheApp: App { CommandMenu("Navigate") { Group { + Button("Back") { + model.navigateBack() + } + .litheKeyboardShortcut( + model.keyboardShortcutFeature.primaryKeyPress(for: "navigate-back") + ) + .disabled(!model.canNavigateBack) + + Button("Forward") { + model.navigateForward() + } + .litheKeyboardShortcut( + model.keyboardShortcutFeature.primaryKeyPress(for: "navigate-forward") + ) + .disabled(!model.canNavigateForward) + + Divider() + Button("Search Everywhere…") { model.toggleSearchEverywhere() } @@ -205,11 +223,13 @@ struct LitheApp: App { Divider() - Button("Go to Usage") { - model.goToUsages() + Button("Go to Definition") { + model.goToDefinition() } - .litheKeyboardShortcut(model.keyboardShortcutFeature.primaryKeyPress(for: "go-to-usage")) - .disabled(!model.supportsLanguageServerFeature(.references)) + .litheKeyboardShortcut( + model.keyboardShortcutFeature.primaryKeyPress(for: "go-to-definition") + ) + .disabled(!model.canPerformShortcutCommand(id: "go-to-definition")) Button("Go to Implementation") { model.goToImplementation() diff --git a/Sources/Lithe/Models/AppModel/AppModel+Development.swift b/Sources/Lithe/Models/AppModel/AppModel+Development.swift index f8986a6da..07ff3ba36 100644 --- a/Sources/Lithe/Models/AppModel/AppModel+Development.swift +++ b/Sources/Lithe/Models/AppModel/AppModel+Development.swift @@ -5,6 +5,27 @@ import LitheModuleAPI @MainActor extension AppModel { + func toggleSpringEndpoints() { + isSpringVisible.toggle() + guard isSpringVisible else { return } + isTestsVisible = false + isGitLogVisible = false + isTerminalVisible = false + isReferencesVisible = false + isProblemsVisible = false + isMavenVisible = false + isRunVisible = false + isDebugVisible = false + } + + func openSpringEndpoint(_ endpoint: SpringEndpoint) { + navigateToEditorLocation( + url: endpoint.url, + line: max(0, endpoint.line - 1), + utf16Column: max(0, endpoint.column - 1) + ) + } + func toggleRun() { isRunVisible.toggle() guard isRunVisible else { return } @@ -79,8 +100,7 @@ extension AppModel { func openMavenIssue(_ issue: MavenBuildIssue) { guard let fileURL = issue.fileURL, workspaceFeature.fileExists(at: fileURL) else { return } - openFile(fileURL) - editorNavigationTarget = EditorNavigationTarget( + navigateToEditorLocation( url: fileURL.standardizedFileURL, line: max(0, (issue.line ?? 1) - 1), utf16Column: max(0, (issue.column ?? 1) - 1) @@ -90,8 +110,7 @@ extension AppModel { /// 打开源码文件并定位到指定行/列(供构建输出、运行堆栈等可点击文本跳转)。 func openSourceLocation(url: URL, line: Int, column: Int?) { guard workspaceFeature.fileExists(at: url) else { return } - openFile(url) - editorNavigationTarget = EditorNavigationTarget( + navigateToEditorLocation( url: url.standardizedFileURL, line: max(0, line - 1), utf16Column: max(0, (column ?? 1) - 1) @@ -112,8 +131,7 @@ extension AppModel { func openDiagnostic(_ diagnostic: EditorDiagnostic) { guard workspaceFeature.fileExists(at: diagnostic.fileURL) else { return } - openFile(diagnostic.fileURL) - editorNavigationTarget = EditorNavigationTarget( + navigateToEditorLocation( url: diagnostic.fileURL.standardizedFileURL, line: diagnostic.line, utf16Column: diagnostic.utf16Column @@ -569,6 +587,20 @@ extension AppModel { } func goToDefinition() { + if let document = activeDocument, let caret = editorCaret { + let springLocations = springFeature.navigationLocations( + for: document.url, + line: caret.line + ) + if !springLocations.isEmpty { + presentGenericNavigationValues( + springLocations, + kind: .definitions, + navigateToSingleResult: true + ) + return + } + } guard supportsLanguageServerFeature(.definition) else { showNotification("Definition navigation is not supported by this language server") return @@ -645,14 +677,96 @@ extension AppModel { func navigate(to location: LanguageNavigationLocation) { isImplementationChooserVisible = false + navigate( + to: EditorNavigationLocation( + url: location.url, + line: location.line, + utf16Column: location.utf16Column, + isReadOnly: location.isReadOnly, + displayPath: location.displayPath, + virtualProviderID: location.url.isFileURL ? nil : languageNavigationProviderID + ), + recordsHistory: true + ) + } + + var canNavigateBack: Bool { navigationHistoryFeature.canNavigateBack } + var canNavigateForward: Bool { navigationHistoryFeature.canNavigateForward } + + func navigateBack() { + let historySnapshot = navigationHistoryFeature.snapshot() + guard let location = navigationHistoryFeature.navigateBack( + from: currentEditorNavigationLocation() + ) else { return } + navigate(to: location, recordsHistory: false) { [weak self] in + self?.navigationHistoryFeature.restore(historySnapshot) + } + } + + func navigateForward() { + let historySnapshot = navigationHistoryFeature.snapshot() + guard let location = navigationHistoryFeature.navigateForward( + from: currentEditorNavigationLocation() + ) else { return } + navigate(to: location, recordsHistory: false) { [weak self] in + self?.navigationHistoryFeature.restore(historySnapshot) + } + } + + func navigateToEditorLocation( + url: URL, + line: Int, + utf16Column: Int, + isReadOnly: Bool = false, + displayPath: String? = nil + ) { + navigate( + to: EditorNavigationLocation( + url: url, + line: line, + utf16Column: utf16Column, + isReadOnly: isReadOnly, + displayPath: displayPath, + virtualProviderID: nil + ), + recordsHistory: true + ) + } + + private func navigate( + to location: EditorNavigationLocation, + recordsHistory: Bool, + onFailure: (() -> Void)? = nil + ) { + let departure = recordsHistory ? currentEditorNavigationLocation() : nil guard location.url.isFileURL else { - guard let providerID = languageNavigationProviderID else { + if let existing = openDocuments.first(where: { $0.url == location.url }) { + activeDocumentID = existing.id + if recordsHistory { + navigationHistoryFeature.recordJump(from: departure, to: location) + } + editorNavigationTarget = EditorNavigationTarget( + url: location.url, + line: location.line, + utf16Column: location.utf16Column + ) + return + } + guard let providerID = location.virtualProviderID + ?? virtualDocumentProviderIDs[location.url] + ?? languageNavigationProviderID else { showNotification("The virtual source provider is no longer available") + onFailure?() + return + } + guard let languageToolingSessions = languageToolingSessionsIfActive else { + showNotification("The language source provider is not running") + onFailure?() return } isLoadingLanguageNavigation = true do { - try languageToolingSessionsIfActive?.resolveVirtualDocument( + try languageToolingSessions.resolveVirtualDocument( providerID: providerID, uri: location.url ) { [weak self] result in @@ -660,6 +774,10 @@ extension AppModel { self.isLoadingLanguageNavigation = false switch result { case .success(let text): + self.virtualDocumentProviderIDs[location.url] = providerID + if recordsHistory { + self.navigationHistoryFeature.recordJump(from: departure, to: location) + } self.documentFeature.openVirtualDocument( location.url, text: text, @@ -671,15 +789,20 @@ extension AppModel { utf16Column: location.utf16Column ) case .failure(let error): + onFailure?() self.showNotification(error.localizedDescription) } } } catch { isLoadingLanguageNavigation = false + onFailure?() showNotification(error.localizedDescription) } return } + if recordsHistory { + navigationHistoryFeature.recordJump(from: departure, to: location) + } openFile( location.url, isReadOnly: location.isReadOnly, @@ -692,6 +815,23 @@ extension AppModel { ) } + private func currentEditorNavigationLocation() -> EditorNavigationLocation? { + guard let document = activeDocument else { return nil } + let documentURL = document.url.isFileURL ? document.url.standardizedFileURL : document.url + let caret = editorCaret.flatMap { caret -> EditorCaret? in + let caretURL = caret.url.isFileURL ? caret.url.standardizedFileURL : caret.url + return caretURL == documentURL ? caret : nil + } + return EditorNavigationLocation( + url: documentURL, + line: caret?.line ?? 0, + utf16Column: caret?.utf16Column ?? 0, + isReadOnly: document.isReadOnly, + displayPath: document.displayPath, + virtualProviderID: virtualDocumentProviderIDs[documentURL] + ) + } + func closeLanguageNavigationResults() { isReferencesVisible = false isImplementationChooserVisible = false @@ -709,6 +849,11 @@ extension AppModel { utf16Column: Int, completion: @escaping (LanguageServerHover?) -> Void ) { + if let document = activeDocument, + let hover = springFeature.hover(for: document.url, line: line) { + completion(hover) + return + } guard let document = activeDocument, (languageToolingSessionsIfActive?.features(for: document.url).contains(.hover) == true), let workspaceURL else { @@ -743,10 +888,19 @@ extension AppModel { utf16Column: Int, completion: @escaping ([LanguageServerCompletionItem]) -> Void ) { - guard let document = activeDocument, + guard let document = activeDocument else { + completion([]) + return + } + let springCompletions = springFeature.completions( + document: document, + line: line, + utf16Column: utf16Column + ) + guard (languageToolingSessionsIfActive?.features(for: document.url).contains(.completion) == true), let workspaceURL else { - completion([]) + completion(springCompletions) return } do { @@ -760,15 +914,17 @@ extension AppModel { rootURL: workspaceURL ) { [weak self] result in switch result { - case .success(let values): completion(values) + case .success(let values): + var seen = Set() + completion((springCompletions + values).filter { seen.insert($0.label).inserted }) case .failure(let error): self?.showNotification(error.localizedDescription) - completion([]) + completion(springCompletions) } } } catch { showNotification(error.localizedDescription) - completion([]) + completion(springCompletions) } } diff --git a/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift b/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift index 9816e55f9..0f8b0548f 100644 --- a/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift +++ b/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift @@ -4,6 +4,9 @@ import LitheLocalHistoryModule import LitheSearchModule extension AppModel { + var springEndpoints: [SpringEndpoint] { springFeature.endpoints } + var springBeans: [SpringBean] { springFeature.beans } + var isIndexingSpring: Bool { springFeature.isIndexing } var rootNode: FileNode? { workspaceFeature.rootNode } var projectFiles: [URL] { workspaceFeature.projectFiles } var javaEnvironmentReport: JavaEnvironmentReport? { @@ -46,6 +49,23 @@ extension AppModel { var isPendingProjectClose: Bool { documentFeature.isPendingProjectClose } var gitChanges: [GitChange] { gitFeatureIfActive?.gitChanges ?? [] } + func gitChange(for url: URL) -> GitChange? { + guard let root = gitRepositoryRoot, + let relativePath = workspaceRelativePath(for: url, root: root) else { return nil } + return GitTreeStatusProjection(changes: gitChanges).change(relativePath: relativePath) + } + + func gitTreeStatus(for url: URL, isDirectory: Bool) -> GitChangeKind? { + guard let root = gitRepositoryRoot, + let relativePath = workspaceRelativePath(for: url, root: root) else { return nil } + return GitTreeStatusProjection(changes: gitChanges).kind( + relativePath: relativePath, + isDirectory: isDirectory + ) + } + func gitLineChangeMarkers(for url: URL) -> [GitLineChangeMarker]? { + gitFeatureIfActive?.gitLineChangeMarkers[url.standardizedFileURL] + } func effectiveStagingState(for change: GitChange) -> Bool { gitFeatureIfActive?.effectiveStagingState(for: change) ?? change.isStaged } @@ -112,6 +132,10 @@ extension AppModel { var gitBlameLines: [URL: [GitBlameLine]] { gitFeatureIfActive?.gitBlameLines ?? [:] } var gitReferences: [GitReference] { gitFeatureIfActive?.gitReferences ?? [] } var gitCommits: [GitCommit] { gitFeatureIfActive?.gitCommits ?? [] } + var gitLogMatchedCommitHashes: Set? { + gitFeatureIfActive?.gitLogMatchedCommitHashes + } + var isFilteringGitLog: Bool { gitFeatureIfActive?.isFilteringGitLog ?? false } var selectedGitReference: GitReference? { get { gitFeatureIfActive?.selectedGitReference } set { gitFeatureIfActive?.selectedGitReference = newValue } @@ -203,6 +227,10 @@ extension AppModel { saveActiveDocument() case "search-everywhere": toggleSearchEverywhere() + case "navigate-back": + navigateBack() + case "navigate-forward": + navigateForward() case "find-next": navigateFind(offset: 1) case "find-previous": @@ -222,7 +250,14 @@ extension AppModel { activeDocument != nil case "find-next", "find-previous": isFindBarVisible && findMatchCount > 0 - case "go-to-usage", "find-usages": + case "navigate-back": + canNavigateBack + case "navigate-forward": + canNavigateForward + case "go-to-definition": + activeDocument.map { springFeature.handles($0.url) } == true + || supportsLanguageServerFeature(.definition) + case "find-usages": supportsLanguageServerFeature(.references) case "go-to-implementation": supportsLanguageServerFeature(.implementation) @@ -230,7 +265,7 @@ extension AppModel { "replace-in-project", "project-local-history", "run", "debug", "stop-run", "stop-debug", "toggle-terminal", "toggle-problems", "toggle-maven", "toggle-git-log", "toggle-run", "toggle-tests", - "toggle-debug": + "toggle-debug", "spring-endpoints": workspaceURL != nil default: false diff --git a/Sources/Lithe/Models/AppModel/AppModel+GitModule.swift b/Sources/Lithe/Models/AppModel/AppModel+GitModule.swift index 891e21dc5..c3d9956dc 100644 --- a/Sources/Lithe/Models/AppModel/AppModel+GitModule.swift +++ b/Sources/Lithe/Models/AppModel/AppModel+GitModule.swift @@ -21,6 +21,7 @@ extension AppModel { onStateRefreshed: { [weak self] in guard let self, let document = self.activeDocument else { return } await self.refreshCodeVision(for: document.url) + await self.loadGitLineChanges(for: document.url) }, saveChangesPolicy: { [weak self] in self?.settings.gitSaveChangesPolicy ?? .stash }, onGitOperationBegan: { [weak self] in diff --git a/Sources/Lithe/Models/AppModel/AppModel+GitOperations.swift b/Sources/Lithe/Models/AppModel/AppModel+GitOperations.swift index aa74a9fa9..27f3fd63f 100644 --- a/Sources/Lithe/Models/AppModel/AppModel+GitOperations.swift +++ b/Sources/Lithe/Models/AppModel/AppModel+GitOperations.swift @@ -2,6 +2,37 @@ import Foundation import LitheGitModule extension AppModel { + func showGitDirectoryDiff(for directoryURL: URL) async { + activeDocumentID = nil + guard let feature = await activateGitModule() else { return } + await feature.showDirectoryDiff(at: directoryURL) + } + + func loadGitLineChanges(for fileURL: URL) async { + guard let feature = await activateGitModule() else { return } + await feature.loadLineChanges(for: fileURL) + } + + func showGitLineChange(_ marker: GitLineChangeMarker, for fileURL: URL) async { + guard let feature = await activateGitModule() else { return } + await feature.showLineChange(marker, for: fileURL) + } + + func stageGitLineChange(_ marker: GitLineChangeMarker, for fileURL: URL) async { + guard let feature = await activateGitModule() else { return } + await feature.stageLineChange(marker, for: fileURL) + } + + func unstageGitLineChange(_ marker: GitLineChangeMarker, for fileURL: URL) async { + guard let feature = await activateGitModule() else { return } + await feature.unstageLineChange(marker, for: fileURL) + } + + func requestDiscardGitLineChange(_ marker: GitLineChangeMarker, for fileURL: URL) async { + guard let feature = await activateGitModule() else { return } + feature.requestDiscardLineChange(marker, for: fileURL) + } + func stashWorkingTree(message: String, includeUntracked: Bool) async { guard let gitFeature = await activateGitModule() else { return } await gitFeature.stashWorkingTree(message: message, includeUntracked: includeUntracked) @@ -86,4 +117,72 @@ extension AppModel { guard let gitFeature = await activateGitModule() else { return } await gitFeature.dropShelf(shelf) } + + func selectChange(_ change: GitChange) { + activeDocumentID = nil + Task { [weak self] in + guard let gitFeature = await self?.activateGitModule() else { return } + await gitFeature.selectChange(change) + } + } + + func reloadSelectedChangeDiff(whitespace: GitDiffWhitespaceMode) async { + guard let gitFeature = await activateGitModule() else { return } + await gitFeature.reloadSelectedChangeDiff(whitespace: whitespace) + } + + func refreshGit() async { + guard let gitFeature = await activateGitModule() else { return } + await gitFeature.refreshGit() + } + + func stageSelectedChange() async { + guard let gitFeature = await activateGitModule() else { return } + await gitFeature.stageSelectedChange() + } + + func unstageSelectedChange() async { + guard let gitFeature = await activateGitModule() else { return } + await gitFeature.unstageSelectedChange() + } + + func stageDiffHunk(_ hunk: DiffHunk, in change: GitChange) async { + guard let gitFeature = await activateGitModule() else { return } + await gitFeature.stageDiffHunk(hunk, in: change) + } + + func unstageDiffHunk(_ hunk: DiffHunk, in change: GitChange) async { + guard let gitFeature = await activateGitModule() else { return } + await gitFeature.unstageDiffHunk(hunk, in: change) + } + + func requestDiscardHunk(_ hunk: DiffHunk, in change: GitChange) { + gitFeatureIfActive?.requestDiscardHunk(hunk, in: change) + } + + func confirmDiscardHunk() async { + guard let gitFeature = await activateGitModule() else { return } + await gitFeature.confirmDiscardHunk() + } + + func cancelDiscardHunk() { + gitFeatureIfActive?.cancelDiscardHunk() + } + + func requestDiscardSelectedChange() { + gitFeatureIfActive?.requestDiscardSelectedChange() + } + + func requestDiscardChange(_ change: GitChange) { + gitFeatureIfActive?.requestDiscardChange(change) + } + + func confirmDiscardChange() async { + guard let gitFeature = await activateGitModule() else { return } + await gitFeature.confirmDiscardChange() + } + + func cancelDiscardChange() { + gitFeatureIfActive?.cancelDiscardChange() + } } diff --git a/Sources/Lithe/Models/AppModel/AppModel+SearchModule.swift b/Sources/Lithe/Models/AppModel/AppModel+SearchModule.swift index 4465f82fb..b3d059736 100644 --- a/Sources/Lithe/Models/AppModel/AppModel+SearchModule.swift +++ b/Sources/Lithe/Models/AppModel/AppModel+SearchModule.swift @@ -133,9 +133,10 @@ extension AppModel { func performSearchEverywhereAction(_ action: LitheAction) { dismissSearchEverywhere(); action.perform() } func openSearchResult(_ result: FileSearchResult) { - openFile(result.url) if let line = result.line { - editorNavigationTarget = EditorNavigationTarget(url: result.url, line: line - 1, utf16Column: 0) + navigateToEditorLocation(url: result.url, line: line - 1, utf16Column: 0) + } else { + openFile(result.url) } } diff --git a/Sources/Lithe/Models/AppModel/AppModel.swift b/Sources/Lithe/Models/AppModel/AppModel.swift index 2e57553be..c4eff7bb3 100644 --- a/Sources/Lithe/Models/AppModel/AppModel.swift +++ b/Sources/Lithe/Models/AppModel/AppModel.swift @@ -98,6 +98,7 @@ final class AppModel: ObservableObject, Identifiable { @Published var isReferencesVisible = false @Published var isProblemsVisible = false @Published var isMavenVisible = false + @Published var isSpringVisible = false @Published var isDebugVisible = false @Published var isDiscourseCommunityVisible = false @Published var isImplementationChooserVisible = false @@ -109,6 +110,8 @@ final class AppModel: ObservableObject, Identifiable { @Published var isLoadingLanguageNavigation = false @Published var editorCaret: EditorCaret? @Published var editorNavigationTarget: EditorNavigationTarget? + let navigationHistoryFeature: NavigationHistoryFeatureModel + var virtualDocumentProviderIDs: [URL: String] = [:] var javaCodeVisionHints: [URL: [JavaCodeVisionHint]] { javaFeature.javaCodeVisionHints } @@ -183,6 +186,7 @@ final class AppModel: ObservableObject, Identifiable { } let documentFeature: DocumentFeatureModel let javaFeature: JavaFeatureModel + let springFeature: SpringFeatureModel private var activeDatabaseFeature: DatabaseFeatureModel? { let capability: LitheDatabaseModule.DatabaseModuleCapability? = cachedModuleCapability(.databaseWorkspace) return capability?.feature @@ -233,7 +237,11 @@ final class AppModel: ObservableObject, Identifiable { executionCapability?.testService as? LanguageTestService } var languageDiagnostics: [URL: [LanguageServerDiagnostic]] { - languageToolingSessionsIfActive?.diagnostics ?? [:] + var combined = languageToolingSessionsIfActive?.diagnostics ?? [:] + for (url, diagnostics) in springFeature.languageDiagnostics { + combined[url, default: []].append(contentsOf: diagnostics) + } + return combined } var editorDiagnostics: [URL: [EditorDiagnostic]] { EditorDiagnostic.fromLanguageServerDiagnostics(languageDiagnostics) @@ -325,6 +333,8 @@ final class AppModel: ObservableObject, Identifiable { private var documentFeatureObservation: AnyCancellable? private var javaFeatureObservation: AnyCancellable? + private var springFeatureObservation: AnyCancellable? + private var navigationHistoryFeatureObservation: AnyCancellable? private var isObjectWillChangeRelayScheduled = false private var languageToolingObservation: AnyCancellable? private var recentProjectsStore: RecentProjectsStore { services.recentProjectsStore } @@ -416,10 +426,12 @@ final class AppModel: ObservableObject, Identifiable { fileStorage: services.fileStorage, binaryFileViewerRegistry: services.binaryFileViewerRegistry ) + navigationHistoryFeature = NavigationHistoryFeatureModel() javaFeature = JavaFeatureModel( operations: services.javaMavenOperations, workspaceOperations: services.workspaceOperations ) + springFeature = SpringFeatureModel(operations: services.javaMavenOperations) recentProjects = services.recentProjectsStore.load() languageToolingFeature.configureSessions { [weak self] in self?.languageToolingSessionsIfActive @@ -447,6 +459,9 @@ final class AppModel: ObservableObject, Identifiable { runtimeFeatureObservation = runtimeFeature.objectWillChange.sink { [weak self] _ in self?.scheduleObjectWillChangeRelay() } + navigationHistoryFeatureObservation = navigationHistoryFeature.objectWillChange.sink { [weak self] _ in + self?.scheduleObjectWillChangeRelay() + } Task { [weak self] in guard let self else { return } await self.githubFeature.restore(workspaceURL: self.workspaceURL) @@ -607,6 +622,9 @@ final class AppModel: ObservableObject, Identifiable { javaFeatureObservation = javaFeature.objectWillChange.sink { [weak self] _ in self?.scheduleObjectWillChangeRelay() } + springFeatureObservation = springFeature.objectWillChange.sink { [weak self] _ in + self?.scheduleObjectWillChangeRelay() + } fileVisibilityRulesObserverID = settings.addFileVisibilityRulesObserver { [weak self] in guard let self else { return } self.workspaceFeature.updateVisibilityRules(self.settings.fileVisibilityRules) @@ -735,6 +753,7 @@ final class AppModel: ObservableObject, Identifiable { mavenFeatureIfActive?.stop() languageToolingSessionsIfActive?.stopLanguageServer(providerID: "java") javaFeature.stop() + springFeature.reset() if let workspaceURL { if let document = activeDocument, document.url.pathExtension.lowercased() == "java" { @@ -750,6 +769,13 @@ final class AppModel: ObservableObject, Identifiable { /// Loads build-system and run state at the workspace boundary. The generic /// run lifecycle is intentionally not owned by JavaFeatureModel. func loadProjectServices(at workspaceURL: URL, files: [URL]) async { + await springFeature.load( + workspaceURL: workspaceURL, + files: files, + textOverrides: Dictionary(uniqueKeysWithValues: openDocuments.map { + ($0.url.standardizedFileURL, $0.text) + }) + ) guard let execution = await activateExecutionModule() else { return } execution.tests.discover(workspaceURL: workspaceURL, files: files) await execution.projectDevelopment.loadProject(at: workspaceURL, files: files) @@ -922,17 +948,21 @@ final class AppModel: ObservableObject, Identifiable { genericDebugFeatureIfActive?.reset() clearLanguageNavigationProjection() javaFeature.stop() + springFeature.reset() workspaceFeature.reset() searchFeatureIfActive?.reset() isTerminalVisible = false isReferencesVisible = false isProblemsVisible = false isMavenVisible = false + isSpringVisible = false isRunVisible = false isTestsVisible = false isDebugVisible = false editorCaret = nil editorNavigationTarget = nil + navigationHistoryFeature.reset() + virtualDocumentProviderIDs.removeAll() blameVisibleURL = nil gitFeatureIfActive?.reset() documentFeature.reset() @@ -1004,6 +1034,7 @@ final class AppModel: ObservableObject, Identifiable { isReferencesVisible = false isProblemsVisible = false isMavenVisible = false + isSpringVisible = false isRunVisible = false isTestsVisible = false isDebugVisible = false @@ -1016,8 +1047,11 @@ final class AppModel: ObservableObject, Identifiable { debugFeatureIfActive?.reset() genericDebugFeatureIfActive?.reset() javaFeature.stop() + springFeature.reset() editorCaret = nil editorNavigationTarget = nil + navigationHistoryFeature.reset() + virtualDocumentProviderIDs.removeAll() blameVisibleURL = nil gitLogSearchQuery = "" projectItemEditRequest = nil @@ -1226,6 +1260,14 @@ final class AppModel: ObservableObject, Identifiable { private func handleDocumentChanged(_ document: EditorDocument) { activateLanguageServerIfAvailable(for: document) + if let workspaceURL { + springFeature.scheduleReload( + changedDocument: document, + workspaceURL: workspaceURL, + files: projectFiles, + openDocuments: openDocuments + ) + } Task { @MainActor [weak self, weak document] in try? await Task.sleep(for: .milliseconds(450)) guard !Task.isCancelled, let self, let document else { return } @@ -1386,78 +1428,11 @@ final class AppModel: ObservableObject, Identifiable { } func updateFindState(currentIndex: Int, count: Int) { + guard currentFindMatchIndex != currentIndex || findMatchCount != count else { return } findMatchCount = count currentFindMatchIndex = currentIndex } - func selectChange(_ change: GitChange) { - activeDocumentID = nil - Task { [weak self] in - guard let gitFeature = await self?.activateGitModule() else { return } - await gitFeature.selectChange(change) - } - } - - func reloadSelectedChangeDiff(whitespace: GitDiffWhitespaceMode) async { - guard let gitFeature = await activateGitModule() else { return } - await gitFeature.reloadSelectedChangeDiff(whitespace: whitespace) - } - - func refreshGit() async { - guard let gitFeature = await activateGitModule() else { return } - await gitFeature.refreshGit() - } - - func stageSelectedChange() async { - guard let gitFeature = await activateGitModule() else { return } - await gitFeature.stageSelectedChange() - } - - func unstageSelectedChange() async { - guard let gitFeature = await activateGitModule() else { return } - await gitFeature.unstageSelectedChange() - } - - func stageDiffHunk(_ hunk: DiffHunk, in change: GitChange) async { - guard let gitFeature = await activateGitModule() else { return } - await gitFeature.stageDiffHunk(hunk, in: change) - } - - func unstageDiffHunk(_ hunk: DiffHunk, in change: GitChange) async { - guard let gitFeature = await activateGitModule() else { return } - await gitFeature.unstageDiffHunk(hunk, in: change) - } - - func requestDiscardHunk(_ hunk: DiffHunk, in change: GitChange) { - gitFeatureIfActive?.requestDiscardHunk(hunk, in: change) - } - - func confirmDiscardHunk() async { - guard let gitFeature = await activateGitModule() else { return } - await gitFeature.confirmDiscardHunk() - } - - func cancelDiscardHunk() { - gitFeatureIfActive?.cancelDiscardHunk() - } - - func requestDiscardSelectedChange() { - gitFeatureIfActive?.requestDiscardSelectedChange() - } - - func requestDiscardChange(_ change: GitChange) { - gitFeatureIfActive?.requestDiscardChange(change) - } - - func confirmDiscardChange() async { - guard let gitFeature = await activateGitModule() else { return } - await gitFeature.confirmDiscardChange() - } - - func cancelDiscardChange() { - gitFeatureIfActive?.cancelDiscardChange() - } - func commitStagedChanges() async { guard let gitFeature = await activateGitModule() else { return } if await gitFeature.commitStagedChanges(message: commitMessage, amend: amendCommit) { @@ -1612,6 +1587,11 @@ final class AppModel: ObservableObject, Identifiable { await gitFeature.selectGitCommit(commit) } + func applyGitLogFilter(_ query: String) async { + guard let gitFeature = await activateGitModule() else { return } + await gitFeature.applyGitLogFilter(query) + } + func showGitCommitDiff(for file: GitCommitFile) { activeDocumentID = nil Task { [weak self] in @@ -1645,6 +1625,12 @@ final class AppModel: ObservableObject, Identifiable { await gitFeature.showComparisonWithWorkingTree(for: reference) } + func showComparison(from reference: GitReference, to target: GitReference) async { + activeDocumentID = nil + guard let gitFeature = await activateGitModule() else { return } + await gitFeature.showComparison(from: reference, to: target) + } + func selectBranchComparisonFile(_ file: GitBranchComparisonFile) async { guard let gitFeature = await activateGitModule() else { return } await gitFeature.selectBranchComparisonFile(file) diff --git a/Sources/Lithe/Models/Java/SpringModels.swift b/Sources/Lithe/Models/Java/SpringModels.swift new file mode 100644 index 000000000..e9df859f6 --- /dev/null +++ b/Sources/Lithe/Models/Java/SpringModels.swift @@ -0,0 +1,94 @@ +import Foundation + +struct SpringProperty: Identifiable, Hashable, Sendable { + let name: String + let typeName: String? + let documentation: String? + let defaultValue: String? + let sourceURL: URL? + let sourceLine: Int? + let sourceColumn: Int? + + var id: String { name } +} + +struct SpringConfigurationValue: Identifiable, Hashable, Sendable { + let key: String + let value: String + let url: URL + let line: Int + let column: Int + let profile: String? + let overridesBaseValue: Bool + let targetURL: URL? + let targetLine: Int? + let targetColumn: Int? + + var id: String { "\(url.path):\(line):\(key)" } +} + +struct SpringPropertyReference: Identifiable, Hashable, Sendable { + let key: String + let url: URL + let line: Int + let column: Int + + var id: String { "\(url.path):\(line):\(column):\(key)" } +} + +struct SpringDiagnostic: Identifiable, Hashable, Sendable { + let url: URL + let line: Int + let column: Int + let severity: String + let message: String + + var id: String { "\(url.path):\(line):\(column):\(message)" } +} + +struct SpringBean: Identifiable, Hashable, Sendable { + let id: String + let name: String + let typeName: String + let url: URL + let line: Int + let column: Int + let kind: String +} + +struct SpringInjection: Identifiable, Hashable, Sendable { + let url: URL + let line: Int + let column: Int + let typeName: String + let qualifier: String? + let beanIDs: [String] + + var id: String { "\(url.path):\(line):\(column):\(typeName)" } +} + +struct SpringEndpoint: Identifiable, Hashable, Sendable { + let id: String + let httpMethods: [String] + let route: String + let controller: String + let method: String + let url: URL + let line: Int + let column: Int +} + +struct SpringIndexResult: Sendable { + let properties: [SpringProperty] + let values: [SpringConfigurationValue] + let propertyReferences: [SpringPropertyReference] + let diagnostics: [SpringDiagnostic] + let beans: [SpringBean] + let injections: [SpringInjection] + let endpoints: [SpringEndpoint] + + static let empty = SpringIndexResult( + properties: [], values: [], propertyReferences: [], diagnostics: [], beans: [], + injections: [], endpoints: [] + ) +} diff --git a/Sources/Lithe/Models/Keymap/LitheCommandCatalog.swift b/Sources/Lithe/Models/Keymap/LitheCommandCatalog.swift index 707ec2025..da7b93278 100644 --- a/Sources/Lithe/Models/Keymap/LitheCommandCatalog.swift +++ b/Sources/Lithe/Models/Keymap/LitheCommandCatalog.swift @@ -31,14 +31,17 @@ enum LitheCommandCatalog { .keyPress(key: "o", modifiers: [.shift, .command]) ] ), + command("navigate-back", "Back", "Navigate to the previous editor location", .navigation, "[", [.command]), + command("navigate-forward", "Forward", "Navigate to the next editor location", .navigation, "]", [.command]), command("find-in-file", "Find in File", "Search within the active editor", .navigation, "f", [.command]), command("find-next", "Find Next", "Move to the next match in the active editor", .navigation, "g", [.command]), command("find-previous", "Find Previous", "Move to the previous match in the active editor", .navigation, "g", [.shift, .command]), - command("go-to-usage", "Go to Usage", "Navigate to a call site of the selected symbol", .navigation, "b", [.command]), + command("go-to-definition", "Go to Definition", "Navigate to the declaration of the selected symbol", .navigation, "b", [.command]), command("go-to-implementation", "Go to Implementation", "Navigate to an implementation of the selected symbol", .navigation, "b", [.option, .command]), command("find-usages", "Find Usages", "Find references to the selected symbol", .navigation, "u", [.option, .command]), command("search-in-project", "Find in Files", "Search text across the workspace", .navigation, "f", [.shift, .command]), command("replace-in-project", "Replace in Files", "Replace text across the workspace", .navigation, "r", [.shift, .command]), + command("spring-endpoints", "Spring Endpoints", "Show indexed Spring MVC routes", .navigation), command("toggle-terminal", "Toggle Terminal", "Show or hide the Terminal tool window", .window), command("toggle-problems", "Toggle Problems", "Show or hide language diagnostics", .window), diff --git a/Sources/Lithe/Models/LitheAction.swift b/Sources/Lithe/Models/LitheAction.swift index 97a5dc079..086cf0688 100644 --- a/Sources/Lithe/Models/LitheAction.swift +++ b/Sources/Lithe/Models/LitheAction.swift @@ -64,6 +64,8 @@ enum LitheActionRegistry { action("settings", model: model) { model.showSettings() }, action("save", model: model) { model.saveActiveDocument() }, action("search-everywhere", model: model) { model.toggleSearchEverywhere() }, + action("navigate-back", model: model) { model.navigateBack() }, + action("navigate-forward", model: model) { model.navigateForward() }, action("find-next", model: model) { model.navigateFind(offset: 1) }, action("find-previous", model: model) { model.navigateFind(offset: -1) }, action("go-to-implementation", model: model) { model.goToImplementation() }, @@ -77,8 +79,9 @@ enum LitheActionRegistry { action("search-in-project", model: model) { model.openProjectSearch() }, action("replace-in-project", model: model) { model.openProjectReplace() }, action("find-in-file", model: model) { model.showFindBar() }, - action("go-to-usage", model: model) { model.goToUsages() }, + action("go-to-definition", model: model) { model.goToDefinition() }, action("find-usages", model: model) { model.findReferences() }, + action("spring-endpoints", model: model) { model.toggleSpringEndpoints() }, action("local-history", model: model) { if let url = model.activeDocument?.url { model.showLocalHistory(for: url) } }, diff --git a/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift b/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift index a897138a2..0d88d68f1 100644 --- a/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift +++ b/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift @@ -50,7 +50,14 @@ final class MacServiceContainer { let authorizationCallbackRouter = providedAuthorizationCallbackRouter ?? MacExternalAuthorizationCallbackRouter() let rustCore = RustCoreBridge() - let javaMavenOperations = RustJavaMavenOperations(core: rustCore) + let mavenRepositoryURL = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".m2/repository", isDirectory: true) + let gradleRepositoryURL = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".gradle/caches/modules-2/files-2.1", isDirectory: true) + let javaMavenOperations = RustJavaMavenOperations( + core: rustCore, + metadataRepositoryURLs: [mavenRepositoryURL, gradleRepositoryURL] + ) let fileStorage = MacFileStorage() let runConfigurationStore = MacRunConfigurationStore( core: rustCore, diff --git a/Sources/Lithe/Platform/MacOS/Persistence/MacKeychainSecureStore.swift b/Sources/Lithe/Platform/MacOS/Persistence/MacKeychainSecureStore.swift index 5f86a32aa..c96f55d17 100644 --- a/Sources/Lithe/Platform/MacOS/Persistence/MacKeychainSecureStore.swift +++ b/Sources/Lithe/Platform/MacOS/Persistence/MacKeychainSecureStore.swift @@ -1,4 +1,5 @@ import Foundation +import LocalAuthentication import Security /// Stores secrets in the current user's macOS Keychain. A legacy store can be @@ -26,10 +27,16 @@ final class MacKeychainSecureStore: SecureStore, @unchecked Sendable { } func read(key: String) -> String? { + let authenticationContext = LAContext() + authenticationContext.interactionNotAllowed = true var result: CFTypeRef? let status = SecItemCopyMatching(baseQuery(key: key).merging([ kSecReturnData as String: true, - kSecMatchLimit as String: kSecMatchLimitOne + kSecMatchLimit as String: kSecMatchLimitOne, + // Credential restoration runs automatically during app startup. + // A re-signed development or preview build must fail closed instead + // of presenting a login-Keychain password prompt without a user action. + kSecUseAuthenticationContext as String: authenticationContext ]) { _, new in new } as CFDictionary, &result) if status == errSecSuccess, diff --git a/Sources/Lithe/Platform/MacOS/UI/MacShortcutDetector.swift b/Sources/Lithe/Platform/MacOS/UI/MacShortcutDetector.swift index 05c5c9da2..c819489d6 100644 --- a/Sources/Lithe/Platform/MacOS/UI/MacShortcutDetector.swift +++ b/Sources/Lithe/Platform/MacOS/UI/MacShortcutDetector.swift @@ -147,16 +147,19 @@ enum MacKeyboardShortcutMatcher { /// Matches ordinary key presses and double-modifier taps for application commands. private final class MacShortcutDetector: ShortcutDetector, @unchecked Sendable { private static let doubleTapThreshold: TimeInterval = 0.35 + private var registrations: [KeyboardShortcutRegistration] = [] private var isSuspended = false - private var shiftWasDown = false - private var lastShiftPress = Date.distantPast + private var doubleShiftRecognizer: DoubleShiftGestureRecognizer private let onCommand: @MainActor (String) -> Void private var keyMonitor: Any? private var flagsMonitor: Any? init(onCommand: @escaping @MainActor (String) -> Void) { self.onCommand = onCommand + doubleShiftRecognizer = DoubleShiftGestureRecognizer( + threshold: Self.doubleTapThreshold + ) } func update(registrations: [KeyboardShortcutRegistration]) { @@ -166,15 +169,16 @@ private final class MacShortcutDetector: ShortcutDetector, @unchecked Sendable { func setSuspended(_ suspended: Bool) { isSuspended = suspended if suspended { - shiftWasDown = false - lastShiftPress = .distantPast + resetDoubleShiftRecognizer() } } func start() { guard keyMonitor == nil, flagsMonitor == nil else { return } keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in - guard let self, !self.isSuspended, + guard let self else { return event } + self.doubleShiftRecognizer.handleKeyDown() + guard !self.isSuspended, let binding = MacKeyboardShortcutEventMapper.binding( keyCode: event.keyCode, charactersIgnoringModifiers: event.charactersIgnoringModifiers, @@ -193,27 +197,25 @@ private final class MacShortcutDetector: ShortcutDetector, @unchecked Sendable { } flagsMonitor = NSEvent.addLocalMonitorForEvents(matching: .flagsChanged) { [weak self] event in - let isShiftDown = event.modifierFlags - .intersection(.deviceIndependentFlagsMask) - .contains(.shift) guard let self, !self.isSuspended else { return event } - if isShiftDown && !self.shiftWasDown { - let now = Date() - if now.timeIntervalSince(self.lastShiftPress) < Self.doubleTapThreshold { - self.lastShiftPress = .distantPast - if let commandID = MacKeyboardShortcutMatcher.commandID( - for: .doubleTap(.shift), - registrations: self.registrations - ) { - Task { @MainActor in - self.onCommand(commandID) - } - } - } else { - self.lastShiftPress = now - } + let modifiers = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + let shouldTrigger = self.doubleShiftRecognizer.handleFlagsChanged( + isShiftDown: modifiers.contains(.shift), + hasOtherModifiers: !modifiers.intersection([ + .command, .control, .option, .function + ]).isEmpty, + timestamp: event.timestamp + ) + guard shouldTrigger, + let commandID = MacKeyboardShortcutMatcher.commandID( + for: .doubleTap(.shift), + registrations: self.registrations + ) else { + return event + } + Task { @MainActor in + self.onCommand(commandID) } - self.shiftWasDown = isShiftDown return event } } @@ -227,7 +229,71 @@ private final class MacShortcutDetector: ShortcutDetector, @unchecked Sendable { NSEvent.removeMonitor(flagsMonitor) self.flagsMonitor = nil } - shiftWasDown = false - lastShiftPress = .distantPast + resetDoubleShiftRecognizer() + } + + private func resetDoubleShiftRecognizer() { + doubleShiftRecognizer = DoubleShiftGestureRecognizer( + threshold: Self.doubleTapThreshold + ) + } +} + +/// Recognizes two standalone Shift taps while rejecting Shift-modified typing. +struct DoubleShiftGestureRecognizer { + let threshold: TimeInterval + private(set) var shiftWasDown = false + private var currentPressIsStandalone = false + private var lastStandaloneTap: TimeInterval? + + init(threshold: TimeInterval) { + self.threshold = threshold + } + + mutating func handleKeyDown() { + currentPressIsStandalone = false + lastStandaloneTap = nil + } + + mutating func handleFlagsChanged( + isShiftDown: Bool, + hasOtherModifiers: Bool, + timestamp: TimeInterval + ) -> Bool { + if isShiftDown, !shiftWasDown { + currentPressIsStandalone = !hasOtherModifiers + shiftWasDown = true + return false + } + + if isShiftDown, shiftWasDown { + if hasOtherModifiers { + currentPressIsStandalone = false + lastStandaloneTap = nil + } + return false + } + + if !isShiftDown, shiftWasDown { + shiftWasDown = false + defer { currentPressIsStandalone = false } + guard currentPressIsStandalone, !hasOtherModifiers else { + lastStandaloneTap = nil + return false + } + if let lastStandaloneTap, + timestamp - lastStandaloneTap >= 0, + timestamp - lastStandaloneTap < threshold { + self.lastStandaloneTap = nil + return true + } + lastStandaloneTap = timestamp + return false + } + + if hasOtherModifiers { + lastStandaloneTap = nil + } + return false } } diff --git a/Sources/Lithe/Views/App/PluginManagementView.swift b/Sources/Lithe/Views/App/PluginManagementView.swift index 029de81b5..d805627a1 100644 --- a/Sources/Lithe/Views/App/PluginManagementView.swift +++ b/Sources/Lithe/Views/App/PluginManagementView.swift @@ -8,6 +8,7 @@ struct PluginManagementView: View { @State private var hoveredPluginID: PluginID? @State private var pendingEnabledStates: [PluginID: Bool] = [:] @State private var isApplyingChanges = false + @State private var isLanguageExtensionsExpanded = false private var filteredPlugins: [PluginManagementSnapshot] { let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() @@ -22,6 +23,18 @@ struct PluginManagementView: View { filteredPlugins.first { $0.id == selectedPluginID } ?? filteredPlugins.first } + private var listContent: PluginManagementListContent { + PluginManagementListContent(plugins: filteredPlugins) + } + + private var isSearching: Bool { + !searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + private var showsLanguageExtensions: Bool { + isLanguageExtensionsExpanded || isSearching + } + private var enabledPluginCount: Int { model.pluginSnapshots.filter { effectiveEnabledState(for: $0) }.count } @@ -39,7 +52,14 @@ struct PluginManagementView: View { .frame(minWidth: 820, minHeight: 560) .background(LitheTheme.window) .onAppear { - selectedPluginID = model.pluginSnapshots.first?.id + let initialContent = PluginManagementListContent(plugins: model.pluginSnapshots) + selectedPluginID = initialContent.standalonePlugins.first?.id + ?? initialContent.languageExtensions.first?.id + } + .onChange(of: searchText) { newValue in + if !newValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + isLanguageExtensionsExpanded = true + } } } @@ -86,9 +106,17 @@ struct PluginManagementView: View { .padding(.horizontal, 14).frame(height: 38).background(LitheTheme.raised) ScrollView { LazyVStack(spacing: 0) { - ForEach(filteredPlugins) { plugin in + ForEach(listContent.standalonePlugins) { plugin in pluginRow(plugin) } + if !listContent.languageExtensions.isEmpty { + languageExtensionsDisclosure + if showsLanguageExtensions { + ForEach(listContent.languageExtensions) { plugin in + pluginRow(plugin, isNested: true) + } + } + } } } } @@ -96,7 +124,58 @@ struct PluginManagementView: View { .background(LitheTheme.sidebar) } - private func pluginRow(_ plugin: PluginManagementSnapshot) -> some View { + private var languageExtensionsDisclosure: some View { + let plugins = listContent.languageExtensions + let enabledCount = plugins.filter { effectiveEnabledState(for: $0) }.count + return Button { + toggleLanguageExtensions() + } label: { + HStack(spacing: 10) { + Image(systemName: showsLanguageExtensions ? "chevron.down" : "chevron.right") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(LitheTheme.secondaryText) + .frame(width: 14) + Image(systemName: "chevron.left.forwardslash.chevron.right") + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(LitheTheme.accent) + .frame(width: 30, height: 30) + VStack(alignment: .leading, spacing: 3) { + Text(LocalizedStringKey("More Language Support")) + .font(.system(size: 13, weight: .semibold)) + Text(LocalizedStringKey("\(plugins.count) languages · \(enabledCount) enabled")) + .font(LitheTheme.smallFont) + .foregroundStyle(LitheTheme.secondaryText) + } + Spacer() + Text("\(plugins.count)") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(LitheTheme.secondaryText) + .padding(.horizontal, 7) + .padding(.vertical, 3) + .background(LitheTheme.raised) + .clipShape(Capsule()) + } + .padding(.horizontal, 14) + .padding(.vertical, 10) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityValue(showsLanguageExtensions ? Text("Expanded") : Text("Collapsed")) + .lithePointer() + } + + private func toggleLanguageExtensions() { + guard !isSearching else { return } + if isLanguageExtensionsExpanded, + let selectedPluginID, + listContent.languageExtensions.contains(where: { $0.id == selectedPluginID }) { + self.selectedPluginID = listContent.standalonePlugins.first?.id + } + isLanguageExtensionsExpanded.toggle() + } + + private func pluginRow(_ plugin: PluginManagementSnapshot, isNested: Bool = false) -> some View { let presentation = presentation(for: plugin) let isSelected = selectedPlugin?.id == plugin.id let isHovered = hoveredPluginID == plugin.id @@ -115,7 +194,9 @@ struct PluginManagementView: View { Image(systemName: effectiveEnabledState(for: plugin) ? "checkmark.square.fill" : "square") .foregroundStyle(effectiveEnabledState(for: plugin) ? LitheTheme.accent : LitheTheme.secondaryText) } - .padding(.horizontal, 14).padding(.vertical, 10) + .padding(.leading, isNested ? 32 : 14) + .padding(.trailing, 14) + .padding(.vertical, 10) .frame(maxWidth: .infinity, alignment: .leading) .background( isSelected @@ -302,6 +383,20 @@ struct PluginManagementView: View { } } +struct PluginManagementListContent { + let standalonePlugins: [PluginManagementSnapshot] + let languageExtensions: [PluginManagementSnapshot] + + init(plugins: [PluginManagementSnapshot]) { + standalonePlugins = plugins.filter { plugin in + plugin.manifest.languageSupports?.isEmpty != false + } + languageExtensions = plugins.filter { plugin in + plugin.manifest.languageSupports?.isEmpty == false + } + } +} + private struct PluginPresentation { let systemImage: String let tint: Color diff --git a/Sources/Lithe/Views/Editor/CodeEditorView.swift b/Sources/Lithe/Views/Editor/CodeEditorView.swift index 729a1e163..40993b9f0 100644 --- a/Sources/Lithe/Views/Editor/CodeEditorView.swift +++ b/Sources/Lithe/Views/Editor/CodeEditorView.swift @@ -26,6 +26,9 @@ fileprivate struct CodeEditorPalette { var foldIndicator: NSColor { color(light: (0.28, 0.30, 0.34, 0.58), dark: (0.62, 0.62, 0.62, 0.46)) } var foldIndicatorHover: NSColor { color(light: (0.12, 0.14, 0.17, 0.90), dark: (0.86, 0.86, 0.86, 0.96)) } var blameText: NSColor { color(light: (0.38, 0.40, 0.44, 1), dark: (0.53, 0.53, 0.53, 1)) } + var gitAdded: NSColor { color(light: (0.15, 0.62, 0.31, 1), dark: (0.31, 0.78, 0.45, 1)) } + var gitModified: NSColor { color(light: (0.16, 0.48, 0.86, 1), dark: (0.31, 0.64, 0.96, 1)) } + var gitDeleted: NSColor { color(light: (0.82, 0.22, 0.25, 1), dark: (0.94, 0.34, 0.37, 1)) } var keyword: NSColor { themeColor(.skill) } var annotation: NSColor { themeColor(.warning) } @@ -199,6 +202,7 @@ struct CodeEditorView: NSViewRepresentable { container.gutter = gutter container.gutterWidthConstraint = gutterWidthConstraint context.coordinator.updateCodeVisionAndBlame() + context.coordinator.updateGitLineChanges() context.coordinator.updateDiagnostics() context.coordinator.shouldFocus = shouldFocus context.coordinator.requestInitialFocusIfNeeded() @@ -255,6 +259,7 @@ struct CodeEditorView: NSViewRepresentable { (textView as? CodeTextView)?.updateEditorDecorations() } context.coordinator.updateCodeVisionAndBlame() + context.coordinator.updateGitLineChanges() context.coordinator.updateDiagnostics() context.coordinator.applyNavigationTargetIfNeeded() if let codeTextView = textView as? CodeTextView { @@ -287,6 +292,7 @@ struct CodeEditorView: NSViewRepresentable { private var markdownScrollObserver: NSObjectProtocol? private var isApplyingSynchronizedMarkdownScroll = false private var lastObservedMarkdownScrollRevision: UInt64? + private var isLoadingGitLineChanges = false init( document: EditorDocument, @@ -591,6 +597,41 @@ struct CodeEditorView: NSViewRepresentable { } } + func updateGitLineChanges() { + guard let document, let model, let gutter else { return } + let url = document.url.standardizedFileURL + if let markers = model.gitLineChangeMarkers(for: url) { + isLoadingGitLineChanges = false + let change = model.gitChange(for: url) + gutter.updateGitLineChanges( + markers, + onShow: { [weak model] marker in + Task { await model?.showGitLineChange(marker, for: url) } + }, + onStage: change?.hasWorkingTreeChange == true ? { [weak model] marker in + Task { await model?.stageGitLineChange(marker, for: url) } + } : nil, + onUnstage: change?.isStaged == true && change?.hasWorkingTreeChange == false + ? { [weak model] marker in + Task { await model?.unstageGitLineChange(marker, for: url) } + } + : nil, + onDiscard: change?.hasWorkingTreeChange == true ? { [weak model] marker in + Task { await model?.requestDiscardGitLineChange(marker, for: url) } + } : nil + ) + return + } + + gutter.updateGitLineChanges([], onShow: { _ in }) + guard !isLoadingGitLineChanges else { return } + isLoadingGitLineChanges = true + Task { @MainActor [weak self, weak model] in + await model?.loadGitLineChanges(for: url) + self?.isLoadingGitLineChanges = false + } + } + func updateDiagnostics() { guard let document, let model, let textView = textView as? CodeTextView else { return } @@ -959,9 +1000,14 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate { reportFindState(index: currentFindMatchIndex, count: total) } + /// Publishes only meaningful find-state transitions so SwiftUI updates do + /// not create a feedback loop through `updateNSView`. private func reportFindState(index: Int, count: Int) { - guard lastReportedFindState?.index != index - || lastReportedFindState?.count != count else { return } + if let lastReportedFindState, + lastReportedFindState.index == index, + lastReportedFindState.count == count { + return + } lastReportedFindState = (index, count) onFindStateChange?(index, count) } @@ -1976,6 +2022,12 @@ final class LineNumberGutterView: NSView { private var hoveredFoldID: String? private var trackingArea: NSTrackingArea? private var palette = CodeEditorPalette.dark + private var gitLineChangeMarkersByLine: [Int: GitLineChangeMarker] = [:] + private var onShowGitLineChange: ((GitLineChangeMarker) -> Void)? + private var onStageGitLineChange: ((GitLineChangeMarker) -> Void)? + private var onUnstageGitLineChange: ((GitLineChangeMarker) -> Void)? + private var onDiscardGitLineChange: ((GitLineChangeMarker) -> Void)? + private var contextGitLineChange: GitLineChangeMarker? override var isFlipped: Bool { true } @@ -2089,6 +2141,21 @@ final class LineNumberGutterView: NSView { needsDisplay = true } + func updateGitLineChanges( + _ markers: [GitLineChangeMarker], + onShow: @escaping (GitLineChangeMarker) -> Void, + onStage: ((GitLineChangeMarker) -> Void)? = nil, + onUnstage: ((GitLineChangeMarker) -> Void)? = nil, + onDiscard: ((GitLineChangeMarker) -> Void)? = nil + ) { + gitLineChangeMarkersByLine = Dictionary(uniqueKeysWithValues: markers.map { ($0.line, $0) }) + onShowGitLineChange = onShow + onStageGitLineChange = onStage + onUnstageGitLineChange = onUnstage + onDiscardGitLineChange = onDiscard + needsDisplay = true + } + private func layoutBlameButtons() { guard isBlameVisible, let textView, @@ -2193,6 +2260,9 @@ final class LineNumberGutterView: NSView { if !isBlameVisible, debugBreakpointLines.contains(lineNumber - 1) { drawDebugBreakpoint(y: y, height: lineRect.height) } + if let marker = gitLineChangeMarkersByLine[lineNumber - 1] { + drawGitLineChange(marker, y: y, height: lineRect.height) + } drawLineNumber(lineNumber, y: y + 1) let nextGlyph = NSMaxRange(lineGlyphRange) @@ -2296,6 +2366,31 @@ final class LineNumberGutterView: NSView { ).fill() } + private func drawGitLineChange( + _ marker: GitLineChangeMarker, + y: CGFloat, + height: CGFloat + ) { + let color: NSColor + switch marker.kind { + case .added: color = palette.gitAdded + case .modified: color = palette.gitModified + case .deleted: color = palette.gitDeleted + } + color.setFill() + let markerHeight = marker.kind == .deleted ? 3 : max(4, height - 2) + NSBezierPath( + roundedRect: NSRect( + x: bounds.width - 4, + y: y + max(1, (height - markerHeight) / 2), + width: 3, + height: markerHeight + ), + xRadius: 1.5, + yRadius: 1.5 + ).fill() + } + private var centeredParagraphStyle: NSParagraphStyle { let style = NSMutableParagraphStyle() style.alignment = .center @@ -2374,7 +2469,9 @@ final class LineNumberGutterView: NSView { let source = textView.string as NSString let line = (textView as? CodeTextView)?.lineNumber(at: characterIndex, in: source) ?? source.substring(to: min(characterIndex, source.length)).reduce(0) { $1 == "\n" ? $0 + 1 : $0 } - if point.x <= 16, let region = foldRegions.first(where: { $0.startLine == line }) { + if point.x >= bounds.width - 8, let marker = gitLineChangeMarkersByLine[line] { + onShowGitLineChange?(marker) + } else if point.x <= 16, let region = foldRegions.first(where: { $0.startLine == line }) { onToggleFold?(region) } else if point.x <= 34, let marker = implementationMarkers.first(where: { $0.line == line }) { @@ -2389,6 +2486,67 @@ final class LineNumberGutterView: NSView { } } + override func menu(for event: NSEvent) -> NSMenu? { + let point = convert(event.locationInWindow, from: nil) + guard point.x >= bounds.width - 10, + let line = editorLine(at: point), + let marker = gitLineChangeMarkersByLine[line] else { + return super.menu(for: event) + } + contextGitLineChange = marker + let menu = NSMenu(title: "Git Line Change") + menu.addItem(withTitle: "Show Git Diff", action: #selector(showGitLineChangeFromMenu), keyEquivalent: "") + menu.items.last?.target = self + if onStageGitLineChange != nil { + menu.addItem(withTitle: "Stage Change Block", action: #selector(stageGitLineChangeFromMenu), keyEquivalent: "") + menu.items.last?.target = self + } + if onUnstageGitLineChange != nil { + menu.addItem(withTitle: "Unstage Change Block", action: #selector(unstageGitLineChangeFromMenu), keyEquivalent: "") + menu.items.last?.target = self + } + if onDiscardGitLineChange != nil { + menu.addItem(.separator()) + menu.addItem(withTitle: "Discard Change Block…", action: #selector(discardGitLineChangeFromMenu), keyEquivalent: "") + menu.items.last?.target = self + } + return menu + } + + private func editorLine(at point: NSPoint) -> Int? { + guard let textView, + let scrollView, + let layoutManager = textView.layoutManager, + let textContainer = textView.textContainer, + layoutManager.numberOfGlyphs > 0 else { return nil } + let documentY = point.y + scrollView.documentVisibleRect.minY - textView.textContainerOrigin.y + let glyphIndex = layoutManager.glyphIndex( + for: NSPoint(x: textView.textContainerInset.width, y: documentY), + in: textContainer + ) + guard glyphIndex < layoutManager.numberOfGlyphs else { return nil } + let characterIndex = layoutManager.characterIndexForGlyph(at: glyphIndex) + let source = textView.string as NSString + return (textView as? CodeTextView)?.lineNumber(at: characterIndex, in: source) + ?? source.substring(to: min(characterIndex, source.length)).reduce(0) { $1 == "\n" ? $0 + 1 : $0 } + } + + @objc private func showGitLineChangeFromMenu() { + if let contextGitLineChange { onShowGitLineChange?(contextGitLineChange) } + } + + @objc private func stageGitLineChangeFromMenu() { + if let contextGitLineChange { onStageGitLineChange?(contextGitLineChange) } + } + + @objc private func unstageGitLineChangeFromMenu() { + if let contextGitLineChange { onUnstageGitLineChange?(contextGitLineChange) } + } + + @objc private func discardGitLineChangeFromMenu() { + if let contextGitLineChange { onDiscardGitLineChange?(contextGitLineChange) } + } + deinit { if let boundsObserver { NotificationCenter.default.removeObserver(boundsObserver) diff --git a/Sources/Lithe/Views/Git/BranchComparisonView.swift b/Sources/Lithe/Views/Git/BranchComparisonView.swift index 58dcff6a1..a708e589b 100644 --- a/Sources/Lithe/Views/Git/BranchComparisonView.swift +++ b/Sources/Lithe/Views/Git/BranchComparisonView.swift @@ -9,6 +9,8 @@ struct BranchComparisonView: View { VStack(spacing: 0) { header Rectangle().fill(LitheTheme.divider).frame(height: 1) + comparisonToolbar + Rectangle().fill(LitheTheme.divider).frame(height: 1) HStack(spacing: 0) { filePane @@ -25,7 +27,7 @@ struct BranchComparisonView: View { Image(systemName: "arrow.left.arrow.right") .font(.system(size: 11.5)) .foregroundStyle(LitheTheme.accent) - Text("Diff: \(comparison.reference.shortName) with Working Tree") + Text("Diff: \(comparison.reference.shortName) with \(comparison.targetTitle)") .font(.system(size: 12.5, weight: .medium)) .foregroundStyle(LitheTheme.primaryText) .lineLimit(1) @@ -33,13 +35,12 @@ struct BranchComparisonView: View { Text(comparison.files.count == 1 ? "1 file" : "\(comparison.files.count) files") .font(.system(size: 11.5)) .foregroundStyle(LitheTheme.secondaryText) - Button { + Button("Close") { model.closeBranchComparison() - } label: { - Image(systemName: "xmark") - .font(.system(size: 9, weight: .semibold)) } - .litheIconButton() + .buttonStyle(.bordered) + .controlSize(.small) + .lithePointer() .help("Close comparison") } .padding(.leading, 12) @@ -51,6 +52,55 @@ struct BranchComparisonView: View { } } + private var comparisonToolbar: some View { + HStack(spacing: 7) { + Button { + refreshComparison() + } label: { + Label("Refresh", systemImage: "arrow.clockwise") + } + .buttonStyle(.bordered) + .controlSize(.small) + .lithePointer() + .disabled(model.isLoadingBranchComparison) + + Button { + moveFileSelection(by: -1) + } label: { + Label("Previous File", systemImage: "chevron.up") + } + .buttonStyle(.bordered) + .controlSize(.small) + .lithePointer() + .disabled(previousFile == nil || model.isLoadingBranchComparison) + + Button { + moveFileSelection(by: 1) + } label: { + Label("Next File", systemImage: "chevron.down") + } + .buttonStyle(.bordered) + .controlSize(.small) + .lithePointer() + .disabled(nextFile == nil || model.isLoadingBranchComparison) + + Spacer() + + if let selectedFileIndex { + Text("File \(selectedFileIndex + 1) of \(comparison.files.count)") + .font(.system(size: 11.5, weight: .medium)) + .foregroundStyle(LitheTheme.secondaryText) + } else { + Text(comparison.files.isEmpty ? "No changed files" : "Select a file") + .font(.system(size: 11.5)) + .foregroundStyle(LitheTheme.secondaryText) + } + } + .padding(.horizontal, 10) + .frame(height: 38) + .background(LitheTheme.toolHeader) + } + private var filePane: some View { VStack(spacing: 0) { HStack { @@ -143,7 +193,7 @@ struct BranchComparisonView: View { .foregroundStyle(LitheTheme.secondaryText) .frame(maxWidth: .infinity, maxHeight: .infinity) } else if model.selectedBranchComparisonFile == nil { - Text(comparison.files.isEmpty ? "Working tree matches this reference" : "Select a file") + Text(comparison.files.isEmpty ? "The selected versions match" : "Select a file") .font(LitheTheme.uiFont) .foregroundStyle(LitheTheme.secondaryText) .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -170,7 +220,10 @@ struct BranchComparisonView: View { Rectangle().fill(LitheTheme.divider).frame(width: 1) } .frame(width: 34) - versionTitle("Working Tree", icon: "folder") + versionTitle( + comparison.targetTitle, + icon: comparison.targetReference == nil ? "folder" : "lock" + ) } .frame(height: 34) .background(LitheTheme.window) @@ -201,6 +254,39 @@ struct BranchComparisonView: View { return URL(fileURLWithPath: file.path).pathExtension } + private var selectedFileIndex: Int? { + guard let selected = model.selectedBranchComparisonFile else { return nil } + return comparison.files.firstIndex(where: { $0.id == selected.id }) + } + + private var previousFile: GitBranchComparisonFile? { + guard let selectedFileIndex, selectedFileIndex > comparison.files.startIndex else { return nil } + return comparison.files[comparison.files.index(before: selectedFileIndex)] + } + + private var nextFile: GitBranchComparisonFile? { + guard let selectedFileIndex else { return comparison.files.first } + let nextIndex = comparison.files.index(after: selectedFileIndex) + guard nextIndex < comparison.files.endIndex else { return nil } + return comparison.files[nextIndex] + } + + private func refreshComparison() { + Task { + if let target = comparison.targetReference { + await model.showComparison(from: comparison.reference, to: target) + } else { + await model.showComparisonWithWorkingTree(for: comparison.reference) + } + } + } + + private func moveFileSelection(by offset: Int) { + let file = offset < 0 ? previousFile : nextFile + guard let file else { return } + Task { await model.selectBranchComparisonFile(file) } + } + private func statusColor(_ status: String) -> Color { if status.hasPrefix("A") { return LitheTheme.success } if status.hasPrefix("D") { return .red.opacity(0.85) } diff --git a/Sources/Lithe/Views/Git/GitLogView.swift b/Sources/Lithe/Views/Git/GitLogView.swift index a79de4b76..2f6544f74 100644 --- a/Sources/Lithe/Views/Git/GitLogView.swift +++ b/Sources/Lithe/Views/Git/GitLogView.swift @@ -18,6 +18,7 @@ struct GitLogView: View { @State private var pendingPushReference: GitReference? @State private var pendingCommitOperation: GitCommitOperationRequest? @State private var pendingBranchOperation: GitBranchOperationRequest? + @State private var comparisonSourceReference: GitReference? @State private var showCommitDecorations = true @State private var graphLayout = GitGraphLayout( rows: [], @@ -45,6 +46,7 @@ struct GitLogView: View { var body: some View { VStack(spacing: 0) { toolWindowHeader + primaryActionBar GeometryReader { geometry in let minimumReferencePaneWidth: CGFloat = 220 @@ -124,6 +126,14 @@ struct GitLogView: View { guard model.gitCommits == commits else { return } graphLayout = updatedLayout } + .task(id: gitLogFilterTaskIdentity) { + do { + try await Task.sleep(for: .milliseconds(180)) + } catch { + return + } + await model.applyGitLogFilter(model.gitLogSearchQuery) + } .sheet(item: $branchDialogRequest) { request in GitBranchNameDialog(request: request) { name, checkout in Task { @@ -323,6 +333,68 @@ struct GitLogView: View { } } + private var primaryActionBar: some View { + HStack(spacing: 7) { + Button { + Task { await model.fetchGit() } + } label: { + Label("Fetch", systemImage: "arrow.down.circle") + } + .buttonStyle(.bordered) + .controlSize(.small) + .lithePointer() + .disabled(model.isPerformingBranchOperation) + + Button { + showPrimaryComparison() + } label: { + Label("Compare", systemImage: "arrow.left.arrow.right") + } + .buttonStyle(.bordered) + .controlSize(.small) + .lithePointer() + .disabled(currentReference == nil || model.isLoadingBranchComparison) + + Divider() + .frame(height: 18) + + Button { + guard let reference = checkoutReference else { return } + Task { await model.checkoutReference(reference) } + } label: { + Label("Checkout", systemImage: "arrow.right.circle") + } + .buttonStyle(.bordered) + .controlSize(.small) + .lithePointer() + .disabled(checkoutReference == nil || model.isPerformingBranchOperation) + + Button { + guard let commit = model.selectedGitCommit else { return } + pendingCommitOperation = GitCommitOperationRequest(kind: .cherryPick, commit: commit) + } label: { + Label("Cherry-pick", systemImage: "arrow.triangle.branch") + } + .buttonStyle(.bordered) + .controlSize(.small) + .lithePointer() + .disabled(model.selectedGitCommit == nil || model.isPerformingBranchOperation) + + Spacer(minLength: 8) + + Text(primaryComparisonDescription) + .font(GitVisual.meta) + .foregroundStyle(LitheTheme.secondaryText) + .lineLimit(1) + } + .padding(.horizontal, 10) + .frame(height: GitVisual.toolbarHeight) + .background(LitheTheme.toolHeader) + .overlay(alignment: .bottom) { + Rectangle().fill(LitheTheme.divider).frame(height: 1) + } + } + private var referencePane: some View { VStack(spacing: 0) { HStack(spacing: 4) { @@ -512,6 +584,17 @@ struct GitLogView: View { Task { await model.showComparisonWithWorkingTree(for: reference) } } + if let source = comparisonSourceReference, source.id != reference.id { + Button("Compare '\(source.shortName)' with '\(reference.shortName)'") { + comparisonSourceReference = nil + Task { await model.showComparison(from: source, to: reference) } + } + } else { + Button("Select for Compare") { + comparisonSourceReference = reference + } + } + if reference.kind == .local { Divider() @@ -570,7 +653,7 @@ struct GitLogView: View { HStack(spacing: 6) { LitheIDEAIcon(resourcePath: "actions/search.svg", size: 14, fallbackSystemImage: "magnifyingglass") .foregroundStyle(LitheTheme.secondaryText) - TextField("Text or hash", text: $model.gitLogSearchQuery) + TextField("Text, me, author:, branch:, path:", text: $model.gitLogSearchQuery) .textFieldStyle(.plain) .font(GitVisual.toolbar) .focused($gitLogSearchFocused) @@ -803,11 +886,31 @@ struct GitLogView: View { } private var filteredCommits: [GitCommit] { - let query = model.gitLogSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines) - guard !query.isEmpty else { return model.gitCommits } - return model.gitCommits.filter { commit in - [commit.subject, commit.hash, commit.shortHash, commit.authorName, commit.authorEmail, commit.decorations] - .contains { $0.localizedCaseInsensitiveContains(query) } + guard let hashes = model.gitLogMatchedCommitHashes else { return model.gitCommits } + return model.gitCommits.filter { hashes.contains($0.hash) } + } + + private var checkoutReference: GitReference? { + guard let reference = model.selectedGitReference, + reference.kind == .local, + !reference.isCurrent else { return nil } + return reference + } + + private var primaryComparisonDescription: String { + guard let currentReference else { return "No current branch" } + if let target = model.selectedGitReference, target.id != currentReference.id { + return "\(currentReference.shortName) → \(target.shortName)" + } + return "\(currentReference.shortName) ↔ Working Tree" + } + + private func showPrimaryComparison() { + guard let currentReference else { return } + if let target = model.selectedGitReference, target.id != currentReference.id { + Task { await model.showComparison(from: currentReference, to: target) } + } else { + Task { await model.showComparisonWithWorkingTree(for: currentReference) } } } @@ -834,7 +937,12 @@ struct GitLogView: View { private var visibleCommitHashes: Set? { let query = model.gitLogSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines) guard !query.isEmpty else { return nil } - return Set(filteredCommits.map(\.hash)) + return model.gitLogMatchedCommitHashes + } + + private var gitLogFilterTaskIdentity: String { + let commits = model.gitCommits.map(\.hash).joined(separator: ",") + return "\(model.gitLogSearchQuery)|\(commits)" } private var commitFileTree: GitCommitFileTreeNode { diff --git a/Sources/Lithe/Views/Run/SpringEndpointsView.swift b/Sources/Lithe/Views/Run/SpringEndpointsView.swift new file mode 100644 index 000000000..d88223cf0 --- /dev/null +++ b/Sources/Lithe/Views/Run/SpringEndpointsView.swift @@ -0,0 +1,86 @@ +import SwiftUI + +struct SpringEndpointsView: View { + @EnvironmentObject private var model: AppModel + @State private var query = "" + + var body: some View { + VStack(spacing: 0) { + LitheToolWindowHeader( + title: "Spring Endpoints", + systemImage: "point.3.connected.trianglepath.dotted", + subtitle: "\(filteredEndpoints.count) routes", + onMinimize: { model.isSpringVisible = false } + ) + HStack(spacing: 7) { + Image(systemName: "magnifyingglass") + .foregroundStyle(LitheTheme.secondaryText) + TextField("Filter route, controller, or method", text: $query) + .textFieldStyle(.plain) + } + .padding(.horizontal, 10) + .frame(height: 34) + .background(LitheTheme.editor) + if model.isIndexingSpring { + ProgressView("Indexing Spring project…") + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if filteredEndpoints.isEmpty { + Text("No Spring MVC endpoints found") + .font(LitheTheme.uiFont) + .foregroundStyle(LitheTheme.secondaryText) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + ScrollView(.vertical) { + LazyVStack(spacing: 1) { + ForEach(filteredEndpoints) { endpoint in + Button { model.openSpringEndpoint(endpoint) } label: { + HStack(spacing: 9) { + Text(endpoint.httpMethods.joined(separator: ",")) + .font(.system(size: 10.5, weight: .bold, design: .monospaced)) + .foregroundStyle(methodColor(endpoint.httpMethods.first)) + .frame(width: 58, alignment: .leading) + Text(endpoint.route) + .font(.system(size: 12.5, weight: .medium, design: .monospaced)) + Text("\(endpoint.controller).\(endpoint.method)") + .font(.system(size: 10.5)) + .foregroundStyle(LitheTheme.secondaryText) + Spacer() + Text(model.relativePath(for: endpoint.url)) + .font(.system(size: 10.5)) + .foregroundStyle(LitheTheme.secondaryText) + .lineLimit(1) + } + .foregroundStyle(LitheTheme.primaryText) + .padding(.horizontal, 10) + .frame(maxWidth: .infinity, minHeight: 30) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .lithePointer() + } + } + .padding(6) + } + } + } + .background(LitheTheme.sidebar) + } + + private var filteredEndpoints: [SpringEndpoint] { + let value = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard !value.isEmpty else { return model.springEndpoints } + return model.springEndpoints.filter { + [$0.route, $0.controller, $0.method, $0.httpMethods.joined(separator: " ")] + .contains { $0.localizedCaseInsensitiveContains(value) } + } + } + + private func methodColor(_ method: String?) -> Color { + switch method { + case "GET": LitheTheme.success + case "POST": LitheTheme.accent + case "DELETE": LitheTheme.error + default: LitheTheme.warning + } + } +} diff --git a/Sources/Lithe/Views/Workbench/WorkbenchView.swift b/Sources/Lithe/Views/Workbench/WorkbenchView.swift index 945fc4b70..6bc02bc8f 100644 --- a/Sources/Lithe/Views/Workbench/WorkbenchView.swift +++ b/Sources/Lithe/Views/Workbench/WorkbenchView.swift @@ -776,6 +776,8 @@ struct WorkbenchView: View { Group { if model.isReferencesVisible { LanguageReferencesView() + } else if model.isSpringVisible { + SpringEndpointsView() } else { moduleUIRegistry.selectedToolContent( from: model.activityBarContributions, @@ -816,7 +818,7 @@ struct WorkbenchView: View { } private var isBottomToolVisible: Bool { - model.isGitLogVisible || model.isTerminalVisible || model.isReferencesVisible || model.isProblemsVisible || model.isMavenVisible || model.isDebugVisible || model.isRunVisible || model.isTestsVisible + model.isGitLogVisible || model.isTerminalVisible || model.isReferencesVisible || model.isProblemsVisible || model.isMavenVisible || model.isSpringVisible || model.isDebugVisible || model.isRunVisible || model.isTestsVisible } private var statusBar: some View { diff --git a/Sources/Lithe/Views/Workspace/ProjectSidebarView.swift b/Sources/Lithe/Views/Workspace/ProjectSidebarView.swift index e8dc22761..20b36de95 100644 --- a/Sources/Lithe/Views/Workspace/ProjectSidebarView.swift +++ b/Sources/Lithe/Views/Workspace/ProjectSidebarView.swift @@ -41,6 +41,7 @@ struct ProjectSidebarView: View { guard expandedTreeRootPath != root.url.path else { return } expandedTreeRootPath = root.url.path expandedDirectoryPaths = [root.url.path] + await model.refreshGit() } .contextMenu { Button("New File…") { @@ -209,7 +210,7 @@ private struct FileNodeRow: View { .frame(width: LitheTheme.Metrics.treeIconSize, height: LitheTheme.Metrics.treeIconSize) Text(node.name) .font(.system(size: LitheTheme.Metrics.treeFontSize, weight: depth == 0 ? .semibold : .regular)) - .foregroundStyle(LitheTheme.primaryText) + .foregroundStyle(gitStatusColor ?? LitheTheme.primaryText) .lineLimit(1) .truncationMode(.middle) .layoutPriority(1) @@ -242,11 +243,17 @@ private struct FileNodeRow: View { .frame(width: LitheTheme.Metrics.treeIconSize) Text(node.name) .font(.system(size: LitheTheme.Metrics.treeFontSize)) - .foregroundStyle(LitheTheme.primaryText) + .foregroundStyle(gitStatusColor ?? LitheTheme.primaryText) .lineLimit(1) .truncationMode(.middle) .layoutPriority(1) Spacer(minLength: 4) + if let status = model.gitChange(for: node.url) { + Text(status.displayStatus) + .font(.system(size: 9, weight: .bold, design: .monospaced)) + .foregroundStyle(gitStatusColor ?? LitheTheme.secondaryText) + .accessibilityLabel(status.kind.title) + } } .padding(.leading, CGFloat(depth * 14 + 8)) .padding(.trailing, 8) @@ -273,6 +280,13 @@ private struct FileNodeRow: View { @ViewBuilder private var directoryContextMenu: some View { + if model.gitTreeStatus(for: node.url, isDirectory: true) != nil { + Button("Show Git Diff") { + Task { await model.showGitDirectoryDiff(for: node.url) } + } + Divider() + } + Button("New File…") { model.requestCreateFile(in: node.url) } @@ -319,6 +333,12 @@ private struct FileNodeRow: View { model.openFile(node.url) } + if let change = model.gitChange(for: node.url) { + Button("Show Git Diff") { + model.selectChange(change) + } + } + Divider() Button("Duplicate") { @@ -347,6 +367,19 @@ private struct FileNodeRow: View { } } + private var gitStatusColor: Color? { + guard let kind = model.gitTreeStatus(for: node.url, isDirectory: node.isDirectory) else { + return nil + } + switch kind { + case .modified: return LitheTheme.accent + case .added, .copied: return LitheTheme.success + case .deleted: return LitheTheme.error + case .moved: return LitheTheme.skill + case .conflicted: return LitheTheme.warning + } + } + } private struct ProjectItemNameDialog: View { diff --git a/Sources/LitheGitModule/Application/GitFeatureModel.swift b/Sources/LitheGitModule/Application/GitFeatureModel.swift index 3ac5526db..0b200a1dc 100644 --- a/Sources/LitheGitModule/Application/GitFeatureModel.swift +++ b/Sources/LitheGitModule/Application/GitFeatureModel.swift @@ -37,8 +37,11 @@ package final class GitFeatureModel: ObservableObject { @Published package var isResolvingGitOperation = false @Published package private(set) var isCommitting = false @Published package private(set) var gitBlameLines: [URL: [GitBlameLine]] = [:] + @Published package private(set) var gitLineChangeMarkers: [URL: [GitLineChangeMarker]] = [:] @Published package private(set) var gitReferences: [GitReference] = [] @Published package private(set) var gitCommits: [GitCommit] = [] + @Published package private(set) var gitLogMatchedCommitHashes: Set? + @Published package private(set) var isFilteringGitLog = false @Published package var selectedGitReference: GitReference? @Published package var selectedGitCommit: GitCommit? @Published package private(set) var selectedGitCommitFiles: [GitCommitFile] = [] @@ -55,6 +58,9 @@ package final class GitFeatureModel: ObservableObject { @Published package private(set) var isCloningRepository = false private let service: GitService + private var gitIdentity: GitIdentity? + private var commitPathsByHash: [String: Set] = [:] + private var gitLogFilterGeneration = UUID() private let shelveService: ShelveService? private let snapshotProvider: @Sendable (URL) async -> GitSnapshot? private let stashesProvider: @Sendable (URL) async -> [GitStash] @@ -71,6 +77,8 @@ package final class GitFeatureModel: ObservableObject { private var gitHistoryLimit = 300 private var deferredSavedChanges: GitDeferredSavedChanges? private var refreshRequestedWhileRunning = false + private var loadingLineChangeURLs: Set = [] + private var lineChangeHunks: [URL: [String: DiffHunk]] = [:] package init( @@ -163,8 +171,16 @@ package final class GitFeatureModel: ObservableObject { pendingDiscardHunk = nil isCommitting = false gitBlameLines = [:] + gitLineChangeMarkers = [:] + loadingLineChangeURLs = [] + lineChangeHunks = [:] gitReferences = [] gitCommits = [] + gitIdentity = nil + gitLogMatchedCommitHashes = nil + isFilteringGitLog = false + commitPathsByHash = [:] + gitLogFilterGeneration = UUID() gitHistoryLimit = 300 isLoadingGitHistory = false isLoadingMoreGitHistory = false @@ -216,6 +232,8 @@ package final class GitFeatureModel: ObservableObject { } if changesChanged { gitChanges = snapshot.changes + gitLineChangeMarkers = [:] + lineChangeHunks = [:] didChange = true } reconcilePendingStagingStates(with: snapshot.changes) @@ -309,11 +327,118 @@ package final class GitFeatureModel: ObservableObject { isLoadingDiff = false } + package func showDirectoryDiff(at directoryURL: URL) async { + guard let repositoryRoot = gitRepositoryRoot else { return } + let rootPath = repositoryRoot.standardizedFileURL.path + let directoryPath = directoryURL.standardizedFileURL.path + guard directoryPath == rootPath || directoryPath.hasPrefix(rootPath + "/") else { return } + let relativePath = directoryPath == rootPath + ? "" + : String(directoryPath.dropFirst(rootPath.count + 1)) + let prefix = relativePath.isEmpty ? "" : relativePath + "/" + let changes = gitChanges.filter { + relativePath.isEmpty || $0.path == relativePath || $0.path.hasPrefix(prefix) + }.sorted { $0.path.localizedStandardCompare($1.path) == .orderedAscending } + guard !changes.isEmpty else { return } + + let hasWorkingTreeChange = changes.contains(where: \.hasWorkingTreeChange) + let isEntirelyUntracked = changes.allSatisfy(\.isUntracked) + let summary = GitChange( + repositoryRoot: repositoryRoot, + path: relativePath.isEmpty ? "." : relativePath, + originalPath: nil, + indexStatus: isEntirelyUntracked ? "?" : (hasWorkingTreeChange ? " " : "M"), + workTreeStatus: isEntirelyUntracked ? "?" : (hasWorkingTreeChange ? "M" : " ") + ) + + closeBranchComparison() + selectedGitCommitDiffContext = nil + selectedChange = summary + selectedDiffPatch = "" + diffRows = [] + diffHunks = [] + isLoadingDiff = true + var documents: [DiffDocument] = [] + for change in changes { + documents.append( + await service.diffDocumentAgainstHead( + for: change, + whitespace: gitDiffWhitespaceMode + ) + ) + } + guard selectedChange?.id == summary.id else { return } + selectedDiffPatch = documents.map(\.patch).filter { !$0.isEmpty }.joined(separator: "\n") + diffRows = documents.flatMap(\.rows) + diffHunks = documents.flatMap(\.hunks) + isLoadingDiff = false + } + package func selectConflictPath(_ path: String) async { guard let change = gitChanges.first(where: { $0.path == path }) else { return } await selectChange(change) } + package func loadLineChanges(for fileURL: URL) async { + let normalizedURL = fileURL.standardizedFileURL + guard !loadingLineChangeURLs.contains(normalizedURL) else { return } + guard let change = gitChanges.first(where: { + $0.url.standardizedFileURL == normalizedURL + }) else { + gitLineChangeMarkers[normalizedURL] = [] + lineChangeHunks[normalizedURL] = [:] + return + } + loadingLineChangeURLs.insert(normalizedURL) + defer { loadingLineChangeURLs.remove(normalizedURL) } + + let document = await service.diffDocument(for: change) + guard gitChanges.contains(change) else { return } + gitLineChangeMarkers[normalizedURL] = GitLineChangeProjection.markers(from: document.rows) + lineChangeHunks[normalizedURL] = Dictionary( + uniqueKeysWithValues: document.hunks.map { ($0.id, $0) } + ) + } + + package func showLineChange(_ marker: GitLineChangeMarker, for fileURL: URL) async { + guard let change = gitChanges.first(where: { + $0.url.standardizedFileURL == fileURL.standardizedFileURL + }) else { return } + await selectChange(change) + _ = marker + } + + package func stageLineChange(_ marker: GitLineChangeMarker, for fileURL: URL) async { + guard let (change, hunk) = lineChangeContext(marker, fileURL: fileURL), + change.hasWorkingTreeChange else { return } + await stageDiffHunk(hunk, in: change) + } + + package func unstageLineChange(_ marker: GitLineChangeMarker, for fileURL: URL) async { + guard let (change, hunk) = lineChangeContext(marker, fileURL: fileURL), + change.isStaged, !change.hasWorkingTreeChange else { return } + await unstageDiffHunk(hunk, in: change) + } + + package func requestDiscardLineChange(_ marker: GitLineChangeMarker, for fileURL: URL) { + guard let (change, hunk) = lineChangeContext(marker, fileURL: fileURL), + change.hasWorkingTreeChange else { return } + requestDiscardHunk(hunk, in: change) + } + + private func lineChangeContext( + _ marker: GitLineChangeMarker, + fileURL: URL + ) -> (GitChange, DiffHunk)? { + let normalizedURL = fileURL.standardizedFileURL + guard let hunkID = marker.hunkID, + let hunk = lineChangeHunks[normalizedURL]?[hunkID], + let change = gitChanges.first(where: { + $0.url.standardizedFileURL == normalizedURL + }) else { return nil } + return (change, hunk) + } + private var selectedSaveChangesPolicy: GitSaveChangesPolicy { guard saveChangesPolicy?() != .shelve || shelveService != nil else { return .stash } return saveChangesPolicy?() ?? .stash @@ -962,6 +1087,7 @@ package final class GitFeatureModel: ObservableObject { ) gitReferences = snapshot.references gitCommits = snapshot.commits + gitIdentity = snapshot.identity canLoadMoreGitHistory = snapshot.hasMore let nextCommit = snapshot.commits.first(where: { $0.hash == previousCommitHash }) @@ -981,6 +1107,62 @@ package final class GitFeatureModel: ObservableObject { } } + package func applyGitLogFilter(_ rawQuery: String) async { + let query = GitLogQuery.parse(rawQuery) + gitLogFilterGeneration = UUID() + let generation = gitLogFilterGeneration + guard !query.isEmpty else { + gitLogMatchedCommitHashes = nil + isFilteringGitLog = false + return + } + + isFilteringGitLog = true + var candidates = gitCommits.filter { query.matchesMetadata($0, identity: gitIdentity) } + + for branchFilter in query.branches { + guard let repositoryRoot = gitRepositoryRoot else { + candidates = [] + break + } + let references = gitReferences.filter { + $0.shortName.localizedCaseInsensitiveContains(branchFilter) + || $0.fullName.localizedCaseInsensitiveContains(branchFilter) + } + var hashes: Set = [] + for reference in references { + let snapshot = await service.history( + at: repositoryRoot, + reference: reference, + limit: 5_000 + ) + guard gitLogFilterGeneration == generation else { return } + hashes.formUnion(snapshot.commits.map(\.hash)) + } + candidates.removeAll { !hashes.contains($0.hash) } + } + + if !query.paths.isEmpty, let repositoryRoot = gitRepositoryRoot { + var pathMatched: [GitCommit] = [] + for commit in candidates { + let paths: Set + if let cached = commitPathsByHash[commit.hash] { + paths = cached + } else { + paths = Set(await service.files(in: commit, at: repositoryRoot).map(\.path)) + guard gitLogFilterGeneration == generation else { return } + commitPathsByHash[commit.hash] = paths + } + if query.matchesPaths(paths) { pathMatched.append(commit) } + } + candidates = pathMatched + } + + guard gitLogFilterGeneration == generation else { return } + gitLogMatchedCommitHashes = Set(candidates.map(\.hash)) + isFilteringGitLog = false + } + package func loadMoreGitHistory() async { guard canLoadMoreGitHistory, !isLoadingGitHistory else { return } isLoadingMoreGitHistory = true @@ -1085,17 +1267,54 @@ package final class GitFeatureModel: ObservableObject { isLoadingBranchComparison = false } + package func showComparison(from reference: GitReference, to target: GitReference) async { + guard let gitRepositoryRoot, reference.id != target.id else { return } + selectedGitCommitDiffContext = nil + selectedChange = nil + selectedDiffPatch = "" + isLoadingBranchComparison = true + branchComparisonRows = [] + let comparison = await service.comparison( + from: reference, + to: target, + at: gitRepositoryRoot + ) + branchComparison = comparison + selectedBranchComparisonFile = comparison.files.first + if let firstFile = comparison.files.first { + branchComparisonRows = await service.diff( + for: firstFile, + from: reference, + to: target, + at: gitRepositoryRoot, + whitespace: gitDiffWhitespaceMode + ) + } + isLoadingBranchComparison = false + } + package func selectBranchComparisonFile(_ file: GitBranchComparisonFile) async { guard let gitRepositoryRoot, let comparison = branchComparison else { return } selectedBranchComparisonFile = file branchComparisonRows = [] isLoadingBranchComparison = true - let rows = await service.diff( - for: file, - against: comparison.reference, - at: gitRepositoryRoot, - whitespace: gitDiffWhitespaceMode - ) + let rows: [DiffRow] + if let target = comparison.targetReference { + rows = await service.diff( + for: file, + from: comparison.reference, + to: target, + at: gitRepositoryRoot, + whitespace: gitDiffWhitespaceMode + ) + } else { + rows = await service.diff( + for: file, + against: comparison.reference, + at: gitRepositoryRoot, + whitespace: gitDiffWhitespaceMode + ) + } guard selectedBranchComparisonFile?.id == file.id else { return } branchComparisonRows = rows isLoadingBranchComparison = false diff --git a/Sources/LitheGitModule/Models/GitModels.swift b/Sources/LitheGitModule/Models/GitModels.swift index 8d0e142b6..333edd1df 100644 --- a/Sources/LitheGitModule/Models/GitModels.swift +++ b/Sources/LitheGitModule/Models/GitModels.swift @@ -186,24 +186,168 @@ package struct GitBlameLine: Identifiable, Hashable, Sendable { package struct GitBranchComparisonFile: Identifiable, Hashable, Sendable { package let status: String package let path: String - package init(status: String, path: String) { self.status = status; self.path = path } + package let isUntracked: Bool + package init(status: String, path: String, isUntracked: Bool = false) { + self.status = status + self.path = path + self.isUntracked = isUntracked + } - package var id: String { "\(status):\(path)" } + package var id: String { "\(status):\(path):\(isUntracked)" } } package struct GitBranchComparison: Identifiable, Sendable { package let reference: GitReference + package let targetReference: GitReference? package let files: [GitBranchComparisonFile] - package init(reference: GitReference, files: [GitBranchComparisonFile]) { self.reference = reference; self.files = files } + package init( + reference: GitReference, + targetReference: GitReference? = nil, + files: [GitBranchComparisonFile] + ) { + self.reference = reference + self.targetReference = targetReference + self.files = files + } - package var id: String { reference.id } + package var id: String { "\(reference.id)..\(targetReference?.id ?? "working-tree")" } + package var targetTitle: String { targetReference?.shortName ?? "Working Tree" } } package struct GitHistorySnapshot: Sendable { package let references: [GitReference] package let commits: [GitCommit] package let hasMore: Bool - package init(references: [GitReference], commits: [GitCommit], hasMore: Bool) { self.references = references; self.commits = commits; self.hasMore = hasMore } + package let identity: GitIdentity? + package init( + references: [GitReference], + commits: [GitCommit], + hasMore: Bool, + identity: GitIdentity? = nil + ) { + self.references = references + self.commits = commits + self.hasMore = hasMore + self.identity = identity + } +} + +package struct GitIdentity: Hashable, Sendable { + package let name: String? + package let email: String? + + package init(name: String?, email: String?) { + self.name = name?.nilIfBlank + self.email = email?.nilIfBlank + } + + package var isEmpty: Bool { name == nil && email == nil } +} + +package struct GitLogQuery: Equatable, Sendable { + package let textTerms: [String] + package let authors: [String] + package let branches: [String] + package let paths: [String] + package let currentUserOnly: Bool + + package var isEmpty: Bool { + textTerms.isEmpty && authors.isEmpty && branches.isEmpty && paths.isEmpty && !currentUserOnly + } + + package static func parse(_ rawValue: String) -> GitLogQuery { + var textTerms: [String] = [] + var authors: [String] = [] + var branches: [String] = [] + var paths: [String] = [] + var currentUserOnly = false + + for token in tokenize(rawValue) { + if token.caseInsensitiveCompare("me") == .orderedSame { + currentUserOnly = true + continue + } + let pieces = token.split(separator: ":", maxSplits: 1, omittingEmptySubsequences: false) + guard pieces.count == 2, !pieces[1].isEmpty else { + textTerms.append(token) + continue + } + let value = String(pieces[1]) + switch pieces[0].lowercased() { + case "author": authors.append(value) + case "branch": branches.append(value) + case "path": paths.append(value.replacingOccurrences(of: "\\", with: "/")) + default: textTerms.append(token) + } + } + return GitLogQuery( + textTerms: textTerms, + authors: authors, + branches: branches, + paths: paths, + currentUserOnly: currentUserOnly + ) + } + + package func matchesMetadata(_ commit: GitCommit, identity: GitIdentity?) -> Bool { + if currentUserOnly { + guard let identity, !identity.isEmpty else { return false } + let matchesName = identity.name.map { + commit.authorName.caseInsensitiveCompare($0) == .orderedSame + } ?? false + let matchesEmail = identity.email.map { + commit.authorEmail.caseInsensitiveCompare($0) == .orderedSame + } ?? false + guard matchesName || matchesEmail else { return false } + } + if !authors.isEmpty { + guard authors.contains(where: { author in + commit.authorName.localizedCaseInsensitiveContains(author) + || commit.authorEmail.localizedCaseInsensitiveContains(author) + }) else { return false } + } + let searchable = [ + commit.subject, commit.hash, commit.shortHash, + commit.authorName, commit.authorEmail, commit.decorations + ] + return textTerms.allSatisfy { term in + searchable.contains { $0.localizedCaseInsensitiveContains(term) } + } + } + + package func matchesPaths(_ changedPaths: Set) -> Bool { + paths.allSatisfy { filter in + changedPaths.contains { path in + path.localizedCaseInsensitiveContains(filter) + } + } + } + + private static func tokenize(_ rawValue: String) -> [String] { + var tokens: [String] = [] + var current = "" + var quote: Character? + for character in rawValue { + if character == "\"" || character == "'" { + if quote == character { quote = nil } + else if quote == nil { quote = character } + else { current.append(character) } + } else if character.isWhitespace, quote == nil { + if !current.isEmpty { tokens.append(current); current = "" } + } else { + current.append(character) + } + } + if !current.isEmpty { tokens.append(current) } + return tokens + } +} + +private extension String { + var nilIfBlank: String? { + let value = trimmingCharacters(in: .whitespacesAndNewlines) + return value.isEmpty ? nil : value + } } package struct GitChange: Identifiable, Hashable, Sendable { @@ -284,6 +428,51 @@ package enum GitChangeKind: String, Sendable { } } +/// Projects repository-relative Git changes onto file and directory rows. +/// Directory status uses the most urgent descendant state so conflicts and +/// deletions are never hidden behind a lower-priority modification. +package struct GitTreeStatusProjection: Sendable { + private let changes: [GitChange] + + package init(changes: [GitChange]) { + self.changes = changes + } + + package func change(relativePath: String) -> GitChange? { + let normalized = Self.normalized(relativePath) + return changes.first { Self.normalized($0.path) == normalized } + } + + package func kind(relativePath: String, isDirectory: Bool) -> GitChangeKind? { + let normalized = Self.normalized(relativePath) + if !isDirectory { + return change(relativePath: normalized)?.kind + } + let prefix = normalized.isEmpty ? "" : normalized + "/" + return changes + .filter { normalized.isEmpty || Self.normalized($0.path).hasPrefix(prefix) } + .map(\.kind) + .max { priority($0) < priority($1) } + } + + private func priority(_ kind: GitChangeKind) -> Int { + switch kind { + case .modified: 0 + case .copied: 1 + case .moved: 2 + case .added: 3 + case .deleted: 4 + case .conflicted: 5 + } + } + + private static func normalized(_ path: String) -> String { + path + .replacingOccurrences(of: "\\", with: "/") + .trimmingCharacters(in: CharacterSet(charactersIn: "/")) + } +} + package extension GitChangeKind { var commitMessageKind: CommitMessageChangeKind { switch self { @@ -404,6 +593,91 @@ package struct DiffDocument: Sendable { } } +package enum GitLineChangeKind: String, Sendable { + case added + case modified + case deleted +} + +package struct GitLineChangeMarker: Identifiable, Hashable, Sendable { + package let line: Int + package let kind: GitLineChangeKind + package let hunkID: String? + + package var id: String { "\(line):\(kind.rawValue):\(hunkID ?? "")" } +} + +/// Converts right-side diff rows into zero-based editor gutter markers. +/// Removed rows anchor to the following surviving line, or the final line when +/// the deletion occurs at end of file, matching conventional IDE gutters. +package enum GitLineChangeProjection { + package static func markers(from rows: [DiffRow]) -> [GitLineChangeMarker] { + var markersByLine: [Int: GitLineChangeMarker] = [:] + var lastNewLine: Int? + + for (index, row) in rows.enumerated() { + switch row.kind { + case .addition: + if let newLine = row.newLine { + insert( + GitLineChangeMarker(line: max(0, newLine - 1), kind: .added, hunkID: row.hunkID), + into: &markersByLine + ) + lastNewLine = newLine + } + case .changed: + if let newLine = row.newLine { + insert( + GitLineChangeMarker(line: max(0, newLine - 1), kind: .modified, hunkID: row.hunkID), + into: &markersByLine + ) + lastNewLine = newLine + } + case .removal: + let nextNewLine = rows[(index + 1)...] + .lazy + .compactMap(\.newLine) + .first + let anchor = max(0, (nextNewLine ?? lastNewLine ?? 1) - 1) + insert( + GitLineChangeMarker(line: anchor, kind: .deleted, hunkID: row.hunkID), + into: &markersByLine + ) + case .context: + if let newLine = row.newLine { lastNewLine = newLine } + case .information: + break + } + } + + return markersByLine.values.sorted { + ($0.line, priority($0.kind), $0.hunkID ?? "") + < ($1.line, priority($1.kind), $1.hunkID ?? "") + } + } + + private static func insert( + _ marker: GitLineChangeMarker, + into markersByLine: inout [Int: GitLineChangeMarker] + ) { + guard let current = markersByLine[marker.line] else { + markersByLine[marker.line] = marker + return + } + if priority(marker.kind) > priority(current.kind) { + markersByLine[marker.line] = marker + } + } + + private static func priority(_ kind: GitLineChangeKind) -> Int { + switch kind { + case .added: 0 + case .deleted: 1 + case .modified: 2 + } + } +} + package struct DiffHunkRequest: Identifiable { package let id = UUID() package let change: GitChange diff --git a/Sources/LitheGitModule/Services/GitService.swift b/Sources/LitheGitModule/Services/GitService.swift index 43f3e2723..640b51a43 100644 --- a/Sources/LitheGitModule/Services/GitService.swift +++ b/Sources/LitheGitModule/Services/GitService.swift @@ -149,6 +149,23 @@ package struct GitService: Sendable { } ?? DiffDocument(rows: [], hunks: []) } + func diffDocumentAgainstHead( + for change: GitChange, + whitespace: GitDiffWhitespaceMode = .doNotIgnore + ) async -> DiffDocument { + if change.isUntracked { + return await diffDocument(for: change, whitespace: whitespace) + } + return await read { + $0.comparisonDiffDocument( + at: change.repositoryRoot, + reference: "HEAD", + pathspecs: change.pathspecs, + whitespace: whitespace + ) + } ?? DiffDocument(rows: [], hunks: []) + } + func diffPatch( for change: GitChange, whitespace: GitDiffWhitespaceMode = .doNotIgnore @@ -320,9 +337,49 @@ package struct GitService: Sendable { for reference: GitReference, at repositoryRoot: URL ) async -> GitBranchComparison { - await read(priority: .utility) { + async let trackedComparison: GitBranchComparison? = read(priority: .utility) { $0.comparison(for: reference, at: repositoryRoot) - } ?? GitBranchComparison(reference: reference, files: []) + } + async let workingTreeSnapshot: GitSnapshot? = read(priority: .utility) { + $0.snapshot(at: repositoryRoot) + } + + let (comparison, snapshot) = await (trackedComparison, workingTreeSnapshot) + var filesByPath: [String: GitBranchComparisonFile] = [:] + for file in comparison?.files ?? [] { + filesByPath[file.path] = file + } + for change in snapshot?.changes ?? [] where change.isUntracked { + if filesByPath[change.path] == nil { + filesByPath[change.path] = GitBranchComparisonFile( + status: "A", + path: change.path, + isUntracked: true + ) + } + } + + let files = filesByPath.values.sorted { lhs, rhs in + if lhs.path == rhs.path { return lhs.status < rhs.status } + return lhs.path < rhs.path + } + return GitBranchComparison(reference: reference, files: files) + } + + func comparison( + from reference: GitReference, + to target: GitReference, + at repositoryRoot: URL + ) async -> GitBranchComparison { + let range = comparisonRange(from: reference, to: target) + let payload = await read(priority: .utility) { + $0.comparison(for: range, at: repositoryRoot) + } + return GitBranchComparison( + reference: reference, + targetReference: target, + files: payload?.files ?? [] + ) } func diff( @@ -331,7 +388,18 @@ package struct GitService: Sendable { at repositoryRoot: URL, whitespace: GitDiffWhitespaceMode = .doNotIgnore ) async -> [DiffRow] { - await read { + if file.isUntracked { + return await read { + $0.diffDocument( + at: repositoryRoot, + pathspecs: [file.path], + staged: false, + untracked: true, + whitespace: whitespace + ) + }?.rows ?? [] + } + return await read { $0.comparisonDiffDocument( at: repositoryRoot, reference: reference.fullName, @@ -341,6 +409,37 @@ package struct GitService: Sendable { }?.rows ?? [] } + func diff( + for file: GitBranchComparisonFile, + from reference: GitReference, + to target: GitReference, + at repositoryRoot: URL, + whitespace: GitDiffWhitespaceMode = .doNotIgnore + ) async -> [DiffRow] { + let range = comparisonRange(from: reference, to: target) + return await read { + $0.comparisonDiffDocument( + at: repositoryRoot, + reference: range.fullName, + pathspecs: [file.path], + whitespace: whitespace + ) + }?.rows ?? [] + } + + private func comparisonRange( + from reference: GitReference, + to target: GitReference + ) -> GitReference { + GitReference( + fullName: "\(reference.fullName)..\(target.fullName)", + shortName: "\(reference.shortName)..\(target.shortName)", + kind: reference.kind, + isCurrent: false, + upstreamShortName: nil + ) + } + func createBranch( named name: String, from reference: GitReference, diff --git a/Tests/LitheGitModuleTests/GitModuleTests.swift b/Tests/LitheGitModuleTests/GitModuleTests.swift index aa714425d..a4596bf53 100644 --- a/Tests/LitheGitModuleTests/GitModuleTests.swift +++ b/Tests/LitheGitModuleTests/GitModuleTests.swift @@ -6,6 +6,272 @@ import Testing @MainActor struct GitModuleTests { + @Test + func treeStatusProjectsExactFilesAndHighestPriorityDirectories() { + let root = URL(fileURLWithPath: "/workspace") + let projection = GitTreeStatusProjection(changes: [ + GitChange( + repositoryRoot: root, + path: "Sources/Modified.swift", + originalPath: nil, + indexStatus: " ", + workTreeStatus: "M" + ), + GitChange( + repositoryRoot: root, + path: "Sources/Feature/Added.swift", + originalPath: nil, + indexStatus: "?", + workTreeStatus: "?" + ), + GitChange( + repositoryRoot: root, + path: "Sources/Feature/Conflict.swift", + originalPath: nil, + indexStatus: "U", + workTreeStatus: "U" + ) + ]) + + #expect(projection.kind(relativePath: "Sources/Modified.swift", isDirectory: false) == .modified) + #expect(projection.kind(relativePath: "Sources/Feature", isDirectory: true) == .conflicted) + #expect(projection.kind(relativePath: "Sources", isDirectory: true) == .conflicted) + #expect(projection.kind(relativePath: "Tests", isDirectory: true) == nil) + } + + @Test + func treeStatusNormalizesSeparatorsWithoutMatchingSiblingPrefixes() { + let root = URL(fileURLWithPath: "/workspace") + let change = GitChange( + repositoryRoot: root, + path: "src/main/App.java", + originalPath: nil, + indexStatus: "A", + workTreeStatus: " " + ) + let projection = GitTreeStatusProjection(changes: [change]) + + #expect(projection.change(relativePath: "\\src\\main\\App.java") == change) + #expect(projection.kind(relativePath: "src/mai", isDirectory: true) == nil) + } + + @Test + func lineChangeProjectionMapsAdditionsChangesAndMiddleDeletions() { + let markers = GitLineChangeProjection.markers(from: [ + DiffRow(oldLine: 1, newLine: 1, left: "same", right: "same", kind: .context, hunkID: "h1"), + DiffRow(oldLine: nil, newLine: 2, left: nil, right: "added", kind: .addition, hunkID: "h1"), + DiffRow(oldLine: 2, newLine: 3, left: "old", right: "new", kind: .changed, hunkID: "h1"), + DiffRow(oldLine: 3, newLine: nil, left: "removed", right: nil, kind: .removal, hunkID: "h2"), + DiffRow(oldLine: 4, newLine: 4, left: "next", right: "next", kind: .context, hunkID: "h2") + ]) + + #expect(markers == [ + GitLineChangeMarker(line: 1, kind: .added, hunkID: "h1"), + GitLineChangeMarker(line: 2, kind: .modified, hunkID: "h1"), + GitLineChangeMarker(line: 3, kind: .deleted, hunkID: "h2") + ]) + } + + @Test + func lineChangeProjectionAnchorsEndDeletionAndUsesSameLinePriority() { + let markers = GitLineChangeProjection.markers(from: [ + DiffRow(oldLine: 1, newLine: 1, left: "same", right: "same", kind: .context, hunkID: "context"), + DiffRow(oldLine: nil, newLine: 2, left: nil, right: "added", kind: .addition, hunkID: "added"), + DiffRow(oldLine: 2, newLine: 2, left: "old", right: "new", kind: .changed, hunkID: "modified"), + DiffRow(oldLine: 3, newLine: nil, left: "removed", right: nil, kind: .removal, hunkID: "deleted") + ]) + + #expect(markers == [ + GitLineChangeMarker(line: 1, kind: .modified, hunkID: "modified") + ]) + } + + @Test + func gitLogQueryParsesStructuredFiltersAndQuotedValues() { + let query = GitLogQuery.parse( + #"fix login me author:"Ada Lovelace" branch:origin/main path:'Sources/Auth Flow'"# + ) + + #expect(query.textTerms == ["fix", "login"]) + #expect(query.currentUserOnly) + #expect(query.authors == ["Ada Lovelace"]) + #expect(query.branches == ["origin/main"]) + #expect(query.paths == ["Sources/Auth Flow"]) + } + + @Test + func gitLogQueryMatchesIdentityAuthorTextAndPaths() { + let commit = GitCommit( + hash: "0123456789abcdef", + shortHash: "0123456", + parentHashes: [], + authorName: "Ada Lovelace", + authorEmail: "ada@example.com", + date: "2026/08/16 00:00", + subject: "Fix login redirect", + decorations: "HEAD -> main" + ) + let query = GitLogQuery.parse("me author:ada fix path:AuthController") + + #expect(query.matchesMetadata( + commit, + identity: GitIdentity(name: nil, email: "ada@example.com") + )) + #expect(query.matchesPaths(["src/main/java/demo/AuthController.java"])) + #expect(!query.matchesPaths(["src/main/java/demo/HomeController.java"])) + #expect(!query.matchesMetadata( + commit, + identity: GitIdentity(name: "Grace Hopper", email: "grace@example.com") + )) + } + + @Test + func workingTreeComparisonMergesTrackedAndUntrackedFiles() async { + let root = URL(fileURLWithPath: "/workspace") + let reference = GitReference( + fullName: "refs/heads/main", + shortName: "main", + kind: .local, + isCurrent: true, + upstreamShortName: "origin/main" + ) + let snapshot = GitSnapshot(repositoryRoot: root, branch: "main", changes: [ + GitChange( + repositoryRoot: root, + path: "README.md", + originalPath: nil, + indexStatus: " ", + workTreeStatus: "M" + ), + GitChange( + repositoryRoot: root, + path: "src/UserRepository.java", + originalPath: nil, + indexStatus: " ", + workTreeStatus: "D" + ), + GitChange( + repositoryRoot: root, + path: "qa-untracked.txt", + originalPath: nil, + indexStatus: "?", + workTreeStatus: "?" + ) + ]) + let trackedComparison = GitBranchComparison(reference: reference, files: [ + GitBranchComparisonFile(status: "D", path: "src/UserRepository.java"), + GitBranchComparisonFile(status: "M", path: "README.md") + ]) + let service = GitService(operations: TestGitOperations( + snapshotValue: snapshot, + comparisonValue: trackedComparison + )) + + let comparison = await service.comparisonWithWorkingTree(for: reference, at: root) + + #expect(comparison.files.map(\.path) == [ + "README.md", + "qa-untracked.txt", + "src/UserRepository.java" + ]) + #expect(comparison.files.count == 3) + #expect(comparison.files.first(where: { $0.path == "qa-untracked.txt" })?.isUntracked == true) + #expect(comparison.files.first(where: { $0.path == "README.md" })?.isUntracked == false) + } + + @Test + func untrackedComparisonFileUsesUntrackedDiffDocument() async throws { + let root = URL(fileURLWithPath: "/workspace") + let reference = GitReference( + fullName: "refs/heads/main", + shortName: "main", + kind: .local, + isCurrent: true, + upstreamShortName: nil + ) + let untrackedDocument = DiffDocument(rows: [ + DiffRow( + oldLine: nil, + newLine: 1, + left: nil, + right: "untracked contents", + kind: .addition + ) + ], hunks: []) + let comparisonDocument = DiffDocument(rows: [ + DiffRow( + oldLine: 1, + newLine: 1, + left: "before", + right: "tracked contents", + kind: .changed + ) + ], hunks: []) + let service = GitService(operations: TestGitOperations( + untrackedDiffDocumentValue: untrackedDocument, + comparisonDiffDocumentValue: comparisonDocument + )) + + let untrackedRows = await service.diff( + for: GitBranchComparisonFile( + status: "A", + path: "qa-untracked.txt", + isUntracked: true + ), + against: reference, + at: root + ) + let trackedRows = await service.diff( + for: GitBranchComparisonFile(status: "M", path: "README.md"), + against: reference, + at: root + ) + + #expect(try #require(untrackedRows.first).rightText == "untracked contents") + #expect(try #require(untrackedRows.first).kind == .addition) + #expect(try #require(trackedRows.first).rightText == "tracked contents") + } + + @Test + func referenceComparisonDoesNotIncludeWorkingTreeUntrackedFiles() async { + let root = URL(fileURLWithPath: "/workspace") + let source = GitReference( + fullName: "refs/heads/main", + shortName: "main", + kind: .local, + isCurrent: true, + upstreamShortName: nil + ) + let target = GitReference( + fullName: "refs/remotes/origin/main", + shortName: "origin/main", + kind: .remote, + isCurrent: false, + upstreamShortName: nil + ) + let snapshot = GitSnapshot(repositoryRoot: root, branch: "main", changes: [ + GitChange( + repositoryRoot: root, + path: "qa-untracked.txt", + originalPath: nil, + indexStatus: "?", + workTreeStatus: "?" + ) + ]) + let payload = GitBranchComparison(reference: source, files: [ + GitBranchComparisonFile(status: "M", path: "src/Tracked.java") + ]) + let service = GitService(operations: TestGitOperations( + snapshotValue: snapshot, + comparisonValue: payload + )) + + let comparison = await service.comparison(from: source, to: target, at: root) + + #expect(comparison.files.map(\.path) == ["src/Tracked.java"]) + #expect(comparison.targetReference == target) + } + @Test func disabledGitDoesNotConstructFactoryOrServiceGraph() async throws { let recorder = Recorder() @@ -86,17 +352,36 @@ private struct TestShelfStorage: GitShelfStorage { } private struct TestGitOperations: GitOperations { - func snapshot(at rootURL: URL) -> GitSnapshot? { nil } + private let snapshotValue: GitSnapshot? + private let comparisonValue: GitBranchComparison? + private let untrackedDiffDocumentValue: DiffDocument? + private let comparisonDiffDocumentValue: DiffDocument? + + init( + snapshotValue: GitSnapshot? = nil, + comparisonValue: GitBranchComparison? = nil, + untrackedDiffDocumentValue: DiffDocument? = nil, + comparisonDiffDocumentValue: DiffDocument? = nil + ) { + self.snapshotValue = snapshotValue + self.comparisonValue = comparisonValue + self.untrackedDiffDocumentValue = untrackedDiffDocumentValue + self.comparisonDiffDocumentValue = comparisonDiffDocumentValue + } + + func snapshot(at rootURL: URL) -> GitSnapshot? { snapshotValue } func watchContext(at rootURL: URL) -> GitWatchContext? { nil } - func diffDocument(at rootURL: URL, pathspecs: [String], staged: Bool, untracked: Bool, whitespace: GitDiffWhitespaceMode) -> DiffDocument? { nil } + func diffDocument(at rootURL: URL, pathspecs: [String], staged: Bool, untracked: Bool, whitespace: GitDiffWhitespaceMode) -> DiffDocument? { + untracked ? untrackedDiffDocumentValue : nil + } func diffPatch(at rootURL: URL, pathspecs: [String], staged: Bool, untracked: Bool, whitespace: GitDiffWhitespaceMode) -> String? { nil } func commitDiffDocument(at rootURL: URL, commit: String, pathspecs: [String], whitespace: GitDiffWhitespaceMode) -> DiffDocument? { nil } - func comparisonDiffDocument(at rootURL: URL, reference: String, pathspecs: [String], whitespace: GitDiffWhitespaceMode) -> DiffDocument? { nil } + func comparisonDiffDocument(at rootURL: URL, reference: String, pathspecs: [String], whitespace: GitDiffWhitespaceMode) -> DiffDocument? { comparisonDiffDocumentValue } func applyPatch(_ patch: String, at rootURL: URL, mode: String) -> GitProcessResult? { nil } func history(at rootURL: URL, reference: GitReference?, limit: Int) -> GitHistorySnapshot? { nil } func files(in commit: GitCommit, at rootURL: URL) -> [GitCommitFile]? { nil } func commit(at rootURL: URL, hash: String) -> GitCommit? { nil } - func comparison(for reference: GitReference, at rootURL: URL) -> GitBranchComparison? { nil } + func comparison(for reference: GitReference, at rootURL: URL) -> GitBranchComparison? { comparisonValue } func stashes(at rootURL: URL) -> [GitStash]? { nil } func blame(at rootURL: URL, relativePath: String) -> [GitBlameLine]? { nil } func stage(_ change: GitChange) -> GitProcessResult? { nil } diff --git a/Tests/LitheTests/AppLocalizationTests.swift b/Tests/LitheTests/AppLocalizationTests.swift index d745d7d58..6bb5d4b2f 100644 --- a/Tests/LitheTests/AppLocalizationTests.swift +++ b/Tests/LitheTests/AppLocalizationTests.swift @@ -87,6 +87,19 @@ struct AppLocalizationTests { } } + @Test + func simplifiedChineseResourcesCoverPluginLanguageGrouping() throws { + let translations = try simplifiedChineseTranslations() + + #expect(translations["More Language Support"] == "扩展更多语言") + #expect( + translations["%lld languages · %lld enabled"] + == "%lld 种语言 · 已启用 %lld 个" + ) + #expect(translations["Expanded"] == "已展开") + #expect(translations["Collapsed"] == "已收起") + } + private func simplifiedChineseTranslations() throws -> [String: String] { let repositoryRoot = URL(fileURLWithPath: #filePath) .deletingLastPathComponent() diff --git a/Tests/LitheTests/KeyboardShortcutTests.swift b/Tests/LitheTests/KeyboardShortcutTests.swift index 91cc3583e..c73136e17 100644 --- a/Tests/LitheTests/KeyboardShortcutTests.swift +++ b/Tests/LitheTests/KeyboardShortcutTests.swift @@ -8,7 +8,7 @@ struct KeyboardShortcutTests { @Test func catalogHasStableUniqueCommandsAndConflictFreeDefaults() { let commands = LitheCommandCatalog.commands - #expect(commands.count == 27) + #expect(commands.count == 30) #expect(Set(commands.map(\.id)).count == commands.count) let owners = commands.flatMap { command in @@ -43,7 +43,11 @@ struct KeyboardShortcutTests { #expect(actionIDs.contains("search-everywhere")) #expect(actionIDs.contains("find-next")) #expect(actionIDs.contains("find-previous")) + #expect(actionIDs.contains("navigate-back")) + #expect(actionIDs.contains("navigate-forward")) + #expect(actionIDs.contains("go-to-definition")) #expect(actionIDs.contains("go-to-implementation")) + #expect(actionIDs.contains("spring-endpoints")) } @Test diff --git a/Tests/LitheTests/LitheCoreLogicTests.swift b/Tests/LitheTests/LitheCoreLogicTests.swift index b3b4b8211..7aef674b9 100644 --- a/Tests/LitheTests/LitheCoreLogicTests.swift +++ b/Tests/LitheTests/LitheCoreLogicTests.swift @@ -2119,6 +2119,106 @@ struct LitheCoreLogicTests { #expect(updates.first?.count == 0) } + @Test + @MainActor + func codeEditorReportsEachFindStateOnlyOnce() { + let textView = CodeTextView(frame: .zero) + textView.string = "alpha beta alpha" + var reportedStates: [String] = [] + textView.onFindStateChange = { index, count in + reportedStates.append("\(index):\(count)") + } + + textView.syncFindState(isVisible: true, query: "") + textView.syncFindState(isVisible: true, query: "alpha") + textView.syncFindState(isVisible: true, query: "alpha") + + #expect(reportedStates == ["-1:0", "0:2"]) + } + + @Test + func doubleShiftRecognizerRequiresTwoStandaloneTaps() { + var recognizer = DoubleShiftGestureRecognizer(threshold: 0.35) + + var triggered = recognizer.handleFlagsChanged( + isShiftDown: true, + hasOtherModifiers: false, + timestamp: 1.00 + ) + #expect(!triggered) + triggered = recognizer.handleFlagsChanged( + isShiftDown: false, + hasOtherModifiers: false, + timestamp: 1.05 + ) + #expect(!triggered) + triggered = recognizer.handleFlagsChanged( + isShiftDown: true, + hasOtherModifiers: false, + timestamp: 1.20 + ) + #expect(!triggered) + triggered = recognizer.handleFlagsChanged( + isShiftDown: false, + hasOtherModifiers: false, + timestamp: 1.25 + ) + #expect(triggered) + } + + @Test + func doubleShiftRecognizerRejectsUppercaseTypingAndInterveningKeys() { + var recognizer = DoubleShiftGestureRecognizer(threshold: 0.35) + + _ = recognizer.handleFlagsChanged( + isShiftDown: true, + hasOtherModifiers: false, + timestamp: 1.00 + ) + recognizer.handleKeyDown() + var triggered = recognizer.handleFlagsChanged( + isShiftDown: false, + hasOtherModifiers: false, + timestamp: 1.05 + ) + #expect(!triggered) + _ = recognizer.handleFlagsChanged( + isShiftDown: true, + hasOtherModifiers: false, + timestamp: 1.20 + ) + recognizer.handleKeyDown() + triggered = recognizer.handleFlagsChanged( + isShiftDown: false, + hasOtherModifiers: false, + timestamp: 1.25 + ) + #expect(!triggered) + + _ = recognizer.handleFlagsChanged( + isShiftDown: true, + hasOtherModifiers: false, + timestamp: 2.00 + ) + _ = recognizer.handleFlagsChanged( + isShiftDown: false, + hasOtherModifiers: false, + timestamp: 2.05 + ) + recognizer.handleKeyDown() + _ = recognizer.handleFlagsChanged( + isShiftDown: true, + hasOtherModifiers: false, + timestamp: 2.20 + ) + triggered = recognizer.handleFlagsChanged( + isShiftDown: false, + hasOtherModifiers: false, + timestamp: 2.25 + ) + #expect(!triggered) + } + @Test func markdownImageInsertionSeparatesTheReferenceFromRawHTML() { let source = "\n
\n" diff --git a/Tests/LitheTests/NavigationHistoryFeatureModelTests.swift b/Tests/LitheTests/NavigationHistoryFeatureModelTests.swift new file mode 100644 index 000000000..1e5fe1188 --- /dev/null +++ b/Tests/LitheTests/NavigationHistoryFeatureModelTests.swift @@ -0,0 +1,113 @@ +import Foundation +import Testing +@testable import Lithe + +@Suite("Navigation history") +@MainActor +struct NavigationHistoryFeatureModelTests { + @Test + func backAndForwardPreserveLiveCaretLocations() throws { + let model = NavigationHistoryFeatureModel() + let first = location("First.java", line: 4, column: 2) + let second = location("Second.java", line: 9, column: 7) + let movedSecond = location("Second.java", line: 14, column: 3) + + model.recordJump(from: first, to: second) + + #expect(model.canNavigateBack) + #expect(model.navigateBack(from: movedSecond) == first) + #expect(model.canNavigateForward) + #expect(model.navigateForward(from: first) == movedSecond) + } + + @Test + func newJumpClearsForwardHistory() { + let model = NavigationHistoryFeatureModel() + let first = location("First.java", line: 1) + let second = location("Second.java", line: 2) + let third = location("Third.java", line: 3) + + model.recordJump(from: first, to: second) + #expect(model.navigateBack(from: second) == first) + model.recordJump(from: first, to: third) + + #expect(!model.canNavigateForward) + #expect(model.navigateBack(from: third) == first) + } + + @Test + func historyIsDeduplicatedAndBounded() { + let model = NavigationHistoryFeatureModel(maximumEntryCount: 2) + let first = location("First.java", line: 1) + let second = location("Second.java", line: 2) + let third = location("Third.java", line: 3) + let fourth = location("Fourth.java", line: 4) + + model.recordJump(from: first, to: second) + model.recordJump(from: second, to: third) + model.recordJump(from: third, to: fourth) + + #expect(model.backLocations == [second, third]) + #expect(model.navigateBack(from: fourth) == third) + #expect(model.navigateBack(from: third) == second) + #expect(!model.canNavigateBack) + } + + @Test + func resetClearsBothDirections() { + let model = NavigationHistoryFeatureModel() + let first = location("First.java", line: 1) + let second = location("Second.java", line: 2) + model.recordJump(from: first, to: second) + _ = model.navigateBack(from: second) + + model.reset() + + #expect(!model.canNavigateBack) + #expect(!model.canNavigateForward) + } + + @Test + func failedNavigationCanRestoreBothStacks() { + let model = NavigationHistoryFeatureModel() + let first = location("First.java", line: 1) + let second = location("Second.java", line: 2) + model.recordJump(from: first, to: second) + let snapshot = model.snapshot() + + _ = model.navigateBack(from: second) + model.restore(snapshot) + + #expect(model.backLocations == [first]) + #expect(model.forwardLocations.isEmpty) + } + + @Test + func virtualLocationsKeepTheirOwningProvider() throws { + let url = try #require(URL(string: "jdt://contents/java.base/java/lang/String.class")) + let location = EditorNavigationLocation( + url: url, + line: 8, + utf16Column: 4, + isReadOnly: true, + displayPath: "java.base/java/lang/String.class", + virtualProviderID: "java" + ) + + #expect(location.url == url) + #expect(location.virtualProviderID == "java") + #expect(location.isReadOnly) + } + + private func location( + _ name: String, + line: Int, + column: Int = 0 + ) -> EditorNavigationLocation { + EditorNavigationLocation( + url: URL(fileURLWithPath: "/workspace/\(name)"), + line: line, + utf16Column: column + ) + } +} diff --git a/Tests/LitheTests/PluginManagementPresentationTests.swift b/Tests/LitheTests/PluginManagementPresentationTests.swift new file mode 100644 index 000000000..8000b277f --- /dev/null +++ b/Tests/LitheTests/PluginManagementPresentationTests.swift @@ -0,0 +1,51 @@ +import Testing +import LitheModuleAPI +@testable import Lithe + +@Suite("Plugin management presentation") +struct PluginManagementPresentationTests { + @Test + func languagePluginsAreGroupedSeparatelyFromStandalonePlugins() throws { + let databaseManifest = try #require(BuiltInPluginCatalog.manifest(forModule: .database)) + let pythonManifest = try #require( + BundledLanguagePluginCatalog.manifests.first { $0.languageSupports?.first?.id == "python" } + ) + let rustManifest = try #require( + BundledLanguagePluginCatalog.manifests.first { $0.languageSupports?.first?.id == "rust" } + ) + let content = PluginManagementListContent(plugins: [ + snapshot(databaseManifest), + snapshot(pythonManifest), + snapshot(rustManifest) + ]) + + #expect(content.standalonePlugins.map(\.id) == [databaseManifest.id]) + #expect(content.languageExtensions.map(\.id) == [pythonManifest.id, rustManifest.id]) + } + + @Test + func everyBundledLanguagePluginUsesTheLanguageExtensionGroup() { + let content = PluginManagementListContent( + plugins: BundledLanguagePluginCatalog.manifests.map(snapshot) + ) + + #expect(content.standalonePlugins.isEmpty) + #expect(content.languageExtensions.count == BundledLanguagePluginCatalog.manifests.count) + } + + private func snapshot(_ manifest: PluginManifest) -> PluginManagementSnapshot { + PluginManagementSnapshot( + manifest: manifest, + origin: .bundled, + installationStatus: .installed, + isEnabled: false, + isRequired: false, + isRunning: false, + isQuarantined: false, + isSuppressedBySafeMode: false, + requiresRestart: false, + canRollback: false, + statusMessage: "Disabled" + ) + } +} diff --git a/Tests/LitheTests/SpringFeatureModelTests.swift b/Tests/LitheTests/SpringFeatureModelTests.swift new file mode 100644 index 000000000..48d557610 --- /dev/null +++ b/Tests/LitheTests/SpringFeatureModelTests.swift @@ -0,0 +1,121 @@ +import Foundation +import Testing +@testable import Lithe + +@Suite("Spring feature model") +@MainActor +struct SpringFeatureModelTests { + @Test + func configurationCompletionHoverDiagnosticsAndNavigationUseTheSharedIndex() async throws { + let root = URL(fileURLWithPath: "/workspace") + let configURL = root.appendingPathComponent("src/main/resources/application.yml") + let sourceURL = root.appendingPathComponent("src/main/java/demo/DemoProperties.java") + let valueReferenceURL = root.appendingPathComponent("src/main/java/demo/RetryService.java") + let result = SpringIndexResult( + properties: [SpringProperty( + name: "demo.retry-count", + typeName: "int", + documentation: "Maximum retry count.", + defaultValue: "3", + sourceURL: sourceURL, + sourceLine: 5, + sourceColumn: 15 + )], + values: [SpringConfigurationValue( + key: "demo.retry-count", + value: "5", + url: configURL, + line: 2, + column: 3, + profile: "dev", + overridesBaseValue: true, + targetURL: sourceURL, + targetLine: 5, + targetColumn: 15 + )], + propertyReferences: [SpringPropertyReference( + key: "demo.retry-count", + url: valueReferenceURL, + line: 9, + column: 14 + )], + diagnostics: [SpringDiagnostic( + url: configURL, + line: 2, + column: 3, + severity: "warning", + message: "Example warning" + )], + beans: [], + injections: [], + endpoints: [] + ) + let feature = SpringFeatureModel(operations: SpringTestOperations(result: result)) + await feature.load(workspaceURL: root, files: [configURL, sourceURL]) + let document = EditorDocument( + url: configURL, + text: "demo:\n ret", + modificationDate: nil + ) + + let completions = feature.completions(document: document, line: 1, utf16Column: 5) + let completion = try #require(completions.first) + #expect(completion.label == "demo.retry-count") + #expect(completion.textEdit?.newText == "retry-count") + #expect(feature.hover(for: configURL, line: 1)?.contents.contains("Maximum retry count") == true) + #expect(feature.languageDiagnostics[configURL]?.first?.severity == 2) + let location = try #require(feature.navigationLocations(for: configURL, line: 1).first) + #expect(location.url == sourceURL) + #expect(location.range.start.line == 4) + #expect(location.range.start.utf16Column == 14) + let referenceLocation = try #require( + feature.navigationLocations(for: valueReferenceURL, line: 8).first + ) + #expect(referenceLocation.url == configURL) + #expect(referenceLocation.range.start.line == 1) + } + + @Test + func injectionNavigationReturnsEveryMatchingBean() async { + let root = URL(fileURLWithPath: "/workspace") + let injectionURL = root.appendingPathComponent("Controller.java") + let firstURL = root.appendingPathComponent("FirstService.java") + let secondURL = root.appendingPathComponent("SecondService.java") + let beans = [ + SpringBean(id: "first", name: "first", typeName: "Service", url: firstURL, line: 3, column: 7, kind: "component"), + SpringBean(id: "second", name: "second", typeName: "Service", url: secondURL, line: 4, column: 7, kind: "component") + ] + let result = SpringIndexResult( + properties: [], values: [], propertyReferences: [], diagnostics: [], beans: beans, + injections: [SpringInjection( + url: injectionURL, line: 8, column: 11, + typeName: "Service", qualifier: nil, beanIDs: ["first", "second"] + )], + endpoints: [] + ) + let feature = SpringFeatureModel(operations: SpringTestOperations(result: result)) + await feature.load(workspaceURL: root, files: [injectionURL, firstURL, secondURL]) + + let locations = feature.navigationLocations(for: injectionURL, line: 7) + #expect(locations.map(\.url) == [firstURL, secondURL]) + } +} + +private struct SpringTestOperations: JavaMavenOperations { + let result: SpringIndexResult + + func springIndex( + at rootURL: URL, + files: [URL], + textOverrides: [URL: String], + refreshDependencyMetadata: Bool + ) -> SpringIndexResult? { result } + func scanMavenProject(at rootURL: URL, files: [URL]) -> MavenProject? { nil } + func mavenDiagnostics(output: String, projectRoot: URL) -> [MavenBuildIssue] { [] } + func codeVision(at rootURL: URL, targetPath: String, paths: [String]) -> [JavaCodeVisionValue] { [] } + func className(source: String, simpleName: String) -> String? { nil } + func sourceDefinition(source: String, declarationName: String, memberName: String?) -> (line: Int, utf16Column: Int)? { nil } + func serverPort(content: String, fileExtension: String) -> Int? { nil } + func scanRunConfigurations(at rootURL: URL, files: [URL], mavenProject: MavenProject?) -> [JavaRunConfiguration] { [] } + func structure(source: String, declarationSources: [String]) -> JavaStructureResult? { nil } +} diff --git a/Tests/LitheTests/WorkbenchRenderingSafetyTests.swift b/Tests/LitheTests/WorkbenchRenderingSafetyTests.swift new file mode 100644 index 000000000..4b2d89d91 --- /dev/null +++ b/Tests/LitheTests/WorkbenchRenderingSafetyTests.swift @@ -0,0 +1,22 @@ +import Foundation +import Testing + +@Suite("Workbench rendering safety") +struct WorkbenchRenderingSafetyTests { + @Test + func workbenchDoesNotFlattenPlatformBackedViews() throws { + let repositoryRoot = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + let workbenchURL = repositoryRoot.appendingPathComponent( + "Sources/Lithe/Views/Workbench/WorkbenchView.swift" + ) + let source = try String(contentsOf: workbenchURL, encoding: .utf8) + + #expect( + source.range(of: #"\.drawingGroup\b"#, options: .regularExpression) == nil, + "WorkbenchView contains NSViewRepresentable content and must not be flattened with drawingGroup()." + ) + } +} diff --git a/rust/Cargo.lock b/rust/Cargo.lock index e2c33f16d..a55beb2f5 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "ahash" version = "0.7.8" @@ -62,6 +68,15 @@ dependencies = [ "libc", ] +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + [[package]] name = "arc-swap" version = "1.9.2" @@ -453,6 +468,15 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossbeam-queue" version = "0.3.13" @@ -629,6 +653,17 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "derive_more" version = "0.99.20" @@ -812,6 +847,16 @@ version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5" +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "flume" version = "0.11.1" @@ -1540,6 +1585,7 @@ dependencies = [ "sha2", "toml", "url", + "zip", ] [[package]] @@ -1686,6 +1732,16 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.2.2" @@ -2853,6 +2909,12 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + [[package]] name = "simdutf8" version = "0.1.5" @@ -4199,8 +4261,37 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "zip" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +dependencies = [ + "arbitrary", + "crc32fast", + "crossbeam-utils", + "displaydoc", + "flate2", + "indexmap 2.14.0", + "memchr", + "thiserror 2.0.20", + "zopfli", +] + [[package]] name = "zmij" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] diff --git a/rust/lithe-core/Cargo.toml b/rust/lithe-core/Cargo.toml index 8639b582f..8f444566a 100644 --- a/rust/lithe-core/Cargo.toml +++ b/rust/lithe-core/Cargo.toml @@ -23,4 +23,5 @@ sha1 = "0.10" sha2 = "0.10" toml = { version = "1.1.4", default-features = false, features = ["parse", "serde"] } serde_yaml_ng = "0.10.0" +zip = { version = "2.4", default-features = false, features = ["deflate"] } url = "2.5" diff --git a/rust/lithe-core/src/git/mod.rs b/rust/lithe-core/src/git/mod.rs index 41503efe0..88a9a0d76 100644 --- a/rust/lithe-core/src/git/mod.rs +++ b/rust/lithe-core/src/git/mod.rs @@ -733,6 +733,8 @@ pub fn apply(request: GitApplyRequest) -> Result pub fn history(request: GitHistoryRequest) -> Result { let limit = request.limit.clamp(1, 5_000); let root = validate_root(&request.root)?; + let user_name = git_config_value(&root, "user.name"); + let user_email = git_config_value(&root, "user.email"); let reference_output = readonly_command(GitCommandRequest { root: root.clone(), arguments: vec![ @@ -800,9 +802,27 @@ pub fn history(request: GitHistoryRequest) -> Result Option { + let response = readonly_command(GitCommandRequest { + root: root.to_string(), + arguments: vec!["config".to_string(), "--get".to_string(), key.to_string()], + input: None, + }) + .ok()?; + if response.exit_code != 0 { + return None; + } + let value = response.output.trim(); + (!value.is_empty()).then(|| value.to_string()) +} + /// Resolves one commit and its parent metadata. pub fn commit(request: GitCommitRequest) -> Result { let root = validate_root(&request.root)?; diff --git a/rust/lithe-core/src/languages/mod.rs b/rust/lithe-core/src/languages/mod.rs index e315d2c4c..dc6a2a902 100644 --- a/rust/lithe-core/src/languages/mod.rs +++ b/rust/lithe-core/src/languages/mod.rs @@ -1,5 +1,7 @@ //! Language-specific project inspection that is independent from LSP transport. mod java; +mod spring; pub(crate) use java::*; +pub(crate) use spring::*; diff --git a/rust/lithe-core/src/languages/spring.rs b/rust/lithe-core/src/languages/spring.rs new file mode 100644 index 000000000..f5b773253 --- /dev/null +++ b/rust/lithe-core/src/languages/spring.rs @@ -0,0 +1,1483 @@ +//! Deterministic Spring Boot configuration and source semantic indexing. + +use crate::protocol::{ + CoreError, ErrorCode, SpringBeanResponse, SpringConfigurationValueResponse, + SpringDiagnosticResponse, SpringEndpointResponse, SpringIndexResponse, SpringInjectionResponse, + SpringPropertyReferenceResponse, SpringPropertyResponse, +}; +use regex::Regex; +use serde::Deserialize; +use serde_json::Value; +use std::collections::{HashMap, HashSet}; +use std::fs::{self, File}; +use std::io::Read; +use std::path::{Component, Path, PathBuf}; +use std::sync::{Mutex, OnceLock}; +use zip::ZipArchive; + +const MAX_METADATA_ARCHIVES: usize = 20_000; + +static REPOSITORY_METADATA_CACHE: OnceLock>>> = + OnceLock::new(); + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Workspace paths and an optional trusted dependency repository to index. +pub struct SpringIndexRequest { + pub root: String, + #[serde(default)] + pub paths: Vec, + #[serde(default)] + pub metadata_repository: Option, + /// Trusted dependency repositories whose Spring metadata may be indexed. + #[serde(default)] + pub metadata_repositories: Vec, + /// Forces a dependency metadata rescan. Interactive document updates keep + /// this false so they reuse the process-local repository snapshot. + #[serde(default)] + pub refresh_dependency_metadata: bool, + #[serde(default)] + pub text_overrides: HashMap, +} + +/// Builds one cross-file Spring index without starting an additional server. +pub fn spring_index(request: SpringIndexRequest) -> Result { + let root = existing_directory(&request.root)?; + let paths = request + .paths + .into_iter() + .filter_map(|path| normalize_relative(&path)) + .collect::>(); + let mut properties = built_in_properties(); + for path in &paths { + if is_metadata_path(path) { + if let Some(content) = source_content(&root, path, &request.text_overrides) { + append_metadata(&content, None, &mut properties); + } + } + } + let mut repositories = request.metadata_repositories; + if let Some(repository) = request.metadata_repository { + repositories.push(repository); + } + repositories.sort(); + repositories.dedup(); + for repository in repositories { + properties.extend(repository_metadata( + Path::new(&repository), + request.refresh_dependency_metadata, + )); + } + + let mut java_sources = Vec::new(); + for path in &paths { + if path.extension().and_then(|value| value.to_str()) == Some("java") { + if let Some(source) = source_content(&root, path, &request.text_overrides) { + java_sources.push((slash_path(path), source)); + } + } + } + append_configuration_properties(&java_sources, &mut properties); + deduplicate_properties(&mut properties); + + let mut values = Vec::new(); + for path in &paths { + let relative = slash_path(path); + let name = path + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or_default(); + if !is_application_configuration(name) { + continue; + } + let Some(content) = source_content(&root, path, &request.text_overrides) else { + continue; + }; + if name.ends_with(".properties") { + values.extend(parse_properties(&relative, &content)); + } else { + values.extend(parse_yaml(&relative, &content)); + } + } + attach_property_targets(&properties, &mut values); + mark_profile_overrides(&mut values); + let mut diagnostics = configuration_diagnostics(&properties, &values); + let property_references = property_reference_index(&java_sources); + let (beans, injections, injection_diagnostics) = bean_index(&java_sources); + diagnostics.extend(injection_diagnostics); + diagnostics.sort_by(|left, right| { + left.path + .cmp(&right.path) + .then_with(|| left.line.cmp(&right.line)) + .then_with(|| left.column.cmp(&right.column)) + .then_with(|| left.message.cmp(&right.message)) + }); + let endpoints = endpoint_index(&java_sources); + + Ok(SpringIndexResponse { + properties, + values, + property_references, + diagnostics, + beans, + injections, + endpoints, + }) +} + +fn property_reference_index(sources: &[(String, String)]) -> Vec { + let annotation = + Regex::new(r#"@Value\s*\(\s*[\"']\$\{\s*([^}:\s]+)(?::[^}]*)?\s*\}[\"']\s*\)"#).unwrap(); + let mut references = Vec::new(); + for (path, source) in sources { + for (index, line) in source.lines().enumerate() { + for capture in annotation.captures_iter(line) { + let Some(key) = capture.get(1) else { continue }; + references.push(SpringPropertyReferenceResponse { + key: canonical_property_name(key.as_str()), + path: path.clone(), + line: index + 1, + column: key.start() + 1, + }); + } + } + } + references.sort_by(|left, right| { + left.path + .cmp(&right.path) + .then_with(|| left.line.cmp(&right.line)) + .then_with(|| left.column.cmp(&right.column)) + }); + references +} + +fn source_content(root: &Path, path: &Path, overrides: &HashMap) -> Option { + overrides + .get(&slash_path(path)) + .cloned() + .or_else(|| fs::read_to_string(root.join(path)).ok()) +} + +fn existing_directory(value: &str) -> Result { + let path = PathBuf::from(value); + if !path.is_absolute() || !path.is_dir() { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Spring index root must be an existing absolute directory", + )); + } + Ok(path) +} + +fn normalize_relative(value: &str) -> Option { + let path = Path::new(value); + if path.is_absolute() || value.contains('\0') { + return None; + } + let mut normalized = PathBuf::new(); + for component in path.components() { + match component { + Component::Normal(value) => normalized.push(value), + Component::CurDir => {} + _ => return None, + } + } + (!normalized.as_os_str().is_empty()).then_some(normalized) +} + +fn slash_path(path: &Path) -> String { + path.components() + .filter_map(|part| match part { + Component::Normal(value) => value.to_str(), + _ => None, + }) + .collect::>() + .join("/") +} + +fn is_metadata_path(path: &Path) -> bool { + matches!( + path.file_name().and_then(|value| value.to_str()), + Some("spring-configuration-metadata.json") + | Some("additional-spring-configuration-metadata.json") + ) +} + +fn repository_metadata(repository: &Path, refresh: bool) -> Vec { + if !repository.is_absolute() || !repository.is_dir() { + return Vec::new(); + } + let key = repository + .canonicalize() + .unwrap_or_else(|_| repository.to_path_buf()); + let cache = REPOSITORY_METADATA_CACHE.get_or_init(|| Mutex::new(HashMap::new())); + if !refresh { + if let Some(properties) = cache + .lock() + .ok() + .and_then(|values| values.get(&key).cloned()) + { + return properties; + } + } + + let properties = scan_repository_metadata(&key); + if let Ok(mut values) = cache.lock() { + values.insert(key, properties.clone()); + } + properties +} + +fn scan_repository_metadata(repository: &Path) -> Vec { + let mut properties = Vec::new(); + let mut pending = vec![repository.to_path_buf()]; + let mut archive_count = 0usize; + while let Some(directory) = pending.pop() { + let Ok(entries) = fs::read_dir(directory) else { + continue; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + pending.push(path); + } else if path.extension().and_then(|value| value.to_str()) == Some("jar") { + archive_count += 1; + if archive_count > MAX_METADATA_ARCHIVES { + return properties; + } + append_archive_metadata(&path, &mut properties); + } + } + } + deduplicate_properties(&mut properties); + properties +} + +fn append_archive_metadata(path: &Path, properties: &mut Vec) { + let Ok(file) = File::open(path) else { return }; + let Ok(mut archive) = ZipArchive::new(file) else { + return; + }; + for name in [ + "META-INF/spring-configuration-metadata.json", + "META-INF/additional-spring-configuration-metadata.json", + ] { + let Ok(mut entry) = archive.by_name(name) else { + continue; + }; + let mut content = String::new(); + if entry.read_to_string(&mut content).is_ok() { + append_metadata(&content, None, properties); + } + } +} + +fn append_metadata( + content: &str, + source_path: Option<&str>, + properties: &mut Vec, +) { + let Ok(document) = serde_json::from_str::(content) else { + return; + }; + let Some(items) = document.get("properties").and_then(Value::as_array) else { + return; + }; + for item in items { + let Some(name) = item.get("name").and_then(Value::as_str) else { + continue; + }; + properties.push(SpringPropertyResponse { + name: name.to_string(), + type_name: item.get("type").and_then(Value::as_str).map(String::from), + description: item + .get("description") + .and_then(Value::as_str) + .map(String::from), + default_value: item.get("defaultValue").map(json_scalar), + source_path: source_path.map(String::from), + source_line: None, + source_column: None, + }); + } +} + +fn json_scalar(value: &Value) -> String { + value + .as_str() + .map(String::from) + .unwrap_or_else(|| value.to_string()) +} + +fn built_in_properties() -> Vec { + [ + ( + "server.port", + "java.lang.Integer", + "HTTP server port.", + "8080", + ), + ( + "spring.application.name", + "java.lang.String", + "Application name.", + "", + ), + ( + "spring.profiles.active", + "java.util.List", + "Active profiles.", + "", + ), + ( + "spring.config.activate.on-profile", + "java.lang.String", + "Profile expression for this document.", + "", + ), + ( + "spring.datasource.url", + "java.lang.String", + "JDBC URL of the database.", + "", + ), + ( + "spring.datasource.username", + "java.lang.String", + "Database login username.", + "", + ), + ( + "spring.datasource.password", + "java.lang.String", + "Database login password.", + "", + ), + ( + "spring.jpa.hibernate.ddl-auto", + "java.lang.String", + "Hibernate schema generation mode.", + "none", + ), + ( + "logging.level.root", + "java.lang.String", + "Root logger level.", + "info", + ), + ( + "management.endpoints.web.exposure.include", + "java.util.Set", + "Exposed actuator endpoints.", + "", + ), + ] + .into_iter() + .map( + |(name, type_name, description, default_value)| SpringPropertyResponse { + name: name.to_string(), + type_name: Some(type_name.to_string()), + description: Some(description.to_string()), + default_value: (!default_value.is_empty()).then(|| default_value.to_string()), + source_path: None, + source_line: None, + source_column: None, + }, + ) + .collect() +} + +fn append_configuration_properties( + sources: &[(String, String)], + properties: &mut Vec, +) { + let annotation = Regex::new( + r#"(?s)@ConfigurationProperties\s*\(\s*(?:prefix\s*=\s*)?[\"']([^\"']+)[\"'][^)]*\).*?\b(?:class|record)\s+([A-Za-z_$][A-Za-z0-9_$]*)"#, + ) + .unwrap(); + let mut types = HashMap::new(); + for (path, source) in sources { + for value in parse_configuration_types(path, source) { + types.insert(value.name.clone(), value); + } + } + for (_, source) in sources { + for capture in annotation.captures_iter(source) { + let Some(prefix) = capture.get(1) else { + continue; + }; + let Some(type_name) = capture.get(2) else { + continue; + }; + append_configuration_type( + canonical_property_name(prefix.as_str()), + type_name.as_str(), + &types, + &mut HashSet::new(), + properties, + ); + } + } +} + +#[derive(Clone)] +struct ConfigurationField { + name: String, + type_name: String, + path: String, + line: usize, + column: usize, + default_value: Option, +} + +struct ConfigurationType { + name: String, + fields: Vec, + body_depth: isize, +} + +fn parse_configuration_types(path: &str, source: &str) -> Vec { + let declaration = + Regex::new(r"\b(?:class|record)\s+([A-Za-z_$][A-Za-z0-9_$]*)(?:\s*\(([^)]*)\))?").unwrap(); + let field = Regex::new( + r"(?:private|protected|public)\s+(?:static\s+)?(?:final\s+)?([A-Za-z0-9_$.<>?, ]+)\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*(?:=\s*([^;]+))?;", + ) + .unwrap(); + let mut types = Vec::::new(); + let mut stack = Vec::::new(); + let mut depth = 0isize; + for (index, line) in source.lines().enumerate() { + if let Some(capture) = declaration.captures(line) { + let name = capture.get(1).unwrap(); + let body_depth = depth + brace_delta(line); + let mut value = ConfigurationType { + name: name.as_str().to_string(), + fields: Vec::new(), + body_depth, + }; + if let Some(components) = capture.get(2) { + value.fields.extend(parse_record_components( + path, + index + 1, + line, + components.as_str(), + )); + } + types.push(value); + stack.push(types.len() - 1); + } else if let Some(type_index) = stack.last().copied() { + if depth == types[type_index].body_depth { + if let Some(capture) = field.captures(line) { + let name = capture.get(2).unwrap(); + types[type_index].fields.push(ConfigurationField { + name: name.as_str().to_string(), + type_name: capture.get(1).unwrap().as_str().trim().to_string(), + path: path.to_string(), + line: index + 1, + column: name.start() + 1, + default_value: capture.get(3).map(|value| { + value.as_str().trim().trim_matches(['\'', '"']).to_string() + }), + }); + } + } + } + depth += brace_delta(line); + while stack + .last() + .is_some_and(|type_index| depth < types[*type_index].body_depth) + { + stack.pop(); + } + } + types +} + +fn parse_record_components( + path: &str, + line_number: usize, + line: &str, + components: &str, +) -> Vec { + let component = Regex::new( + r"(?:@[A-Za-z0-9_$.]+(?:\([^)]*\))?\s+)*(?:final\s+)?([A-Za-z0-9_$.<>?]+)\s+([A-Za-z_$][A-Za-z0-9_$]*)$", + ) + .unwrap(); + split_parameters(components) + .into_iter() + .filter_map(|value| { + let capture = component.captures(value.trim())?; + let name = capture.get(2)?; + Some(ConfigurationField { + name: name.as_str().to_string(), + type_name: capture.get(1)?.as_str().trim().to_string(), + path: path.to_string(), + line: line_number, + column: line.find(name.as_str()).unwrap_or(0) + 1, + default_value: None, + }) + }) + .collect() +} + +fn brace_delta(line: &str) -> isize { + line.chars().fold(0, |value, character| match character { + '{' => value + 1, + '}' => value - 1, + _ => value, + }) +} + +fn append_configuration_type( + prefix: String, + type_name: &str, + types: &HashMap, + visiting: &mut HashSet, + properties: &mut Vec, +) { + let type_name = simple_type(type_name); + if !visiting.insert(type_name.clone()) { + return; + } + let Some(value) = types.get(&type_name) else { + visiting.remove(&type_name); + return; + }; + for field in &value.fields { + let name = format!("{}.{}", prefix, kebab_case(&field.name)); + let nested_type = simple_type(&field.type_name); + if types.contains_key(&nested_type) { + append_configuration_type(name, &nested_type, types, visiting, properties); + } else { + properties.push(SpringPropertyResponse { + name, + type_name: Some(field.type_name.clone()), + description: Some(format!("Binds to `{}`.", field.name)), + default_value: field.default_value.clone(), + source_path: Some(field.path.clone()), + source_line: Some(field.line), + source_column: Some(field.column), + }); + } + } + visiting.remove(&type_name); +} + +fn kebab_case(value: &str) -> String { + let mut result = String::new(); + for character in value.chars() { + if character.is_uppercase() { + if !result.is_empty() { + result.push('-'); + } + result.extend(character.to_lowercase()); + } else if character == '_' { + result.push('-'); + } else { + result.push(character); + } + } + result +} + +fn canonical_property_name(value: &str) -> String { + value + .split('.') + .map(|part| kebab_case(part.trim()).to_ascii_lowercase()) + .collect::>() + .join(".") +} + +fn deduplicate_properties(properties: &mut Vec) { + properties.sort_by(|left, right| { + left.name + .cmp(&right.name) + .then_with(|| left.source_path.is_none().cmp(&right.source_path.is_none())) + }); + properties.dedup_by(|right, left| right.name == left.name); +} + +fn is_application_configuration(name: &str) -> bool { + name == "application.properties" + || (name.starts_with("application") + && (name.ends_with(".yml") || name.ends_with(".yaml") || name.ends_with(".properties"))) +} + +fn profile_from_path(path: &str) -> Option { + let name = Path::new(path).file_name()?.to_str()?; + let stem = name + .strip_suffix(".properties") + .or_else(|| name.strip_suffix(".yaml")) + .or_else(|| name.strip_suffix(".yml"))?; + stem.strip_prefix("application-") + .filter(|value| !value.is_empty()) + .map(String::from) +} + +fn parse_properties(path: &str, content: &str) -> Vec { + let profile = profile_from_path(path); + logical_property_lines(content) + .into_iter() + .filter_map(|(index, line)| { + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with('!') { + return None; + } + let separator = property_separator(trimmed)?; + let key = trimmed[..separator].trim(); + if key.is_empty() { + return None; + } + Some(SpringConfigurationValueResponse { + key: canonical_property_name(&unescape_property(key)), + value: unescape_property(trimmed[separator + 1..].trim()), + path: path.to_string(), + line: index, + column: line.find(key).unwrap_or(0) + 1, + profile: profile.clone(), + overrides_base_value: false, + target_path: None, + target_line: None, + target_column: None, + }) + }) + .collect() +} + +fn logical_property_lines(content: &str) -> Vec<(usize, String)> { + let mut values = Vec::new(); + let mut pending: Option<(usize, String)> = None; + for (index, line) in content.lines().enumerate() { + let start_line = index + 1; + let mut part = line.to_string(); + let continued = part + .chars() + .rev() + .take_while(|value| *value == '\\') + .count() + % 2 + == 1; + if continued { + part.pop(); + } + if let Some((_, value)) = pending.as_mut() { + value.push_str(part.trim_start()); + } else { + pending = Some((start_line, part)); + } + if !continued { + if let Some(value) = pending.take() { + values.push(value); + } + } + } + if let Some(value) = pending { + values.push(value); + } + values +} + +fn property_separator(value: &str) -> Option { + let mut escaped = false; + for (index, character) in value.char_indices() { + if escaped { + escaped = false; + continue; + } + if character == '\\' { + escaped = true; + } else if character == '=' || character == ':' || character.is_whitespace() { + return Some(index); + } + } + None +} + +fn unescape_property(value: &str) -> String { + value + .replace("\\:", ":") + .replace("\\=", "=") + .replace("\\ ", " ") + .replace("\\\\", "\\") +} + +fn parse_yaml(path: &str, content: &str) -> Vec { + let file_profile = profile_from_path(path); + let lines = content.lines().collect::>(); + let mut values = Vec::new(); + let mut document_start = 0usize; + for index in 0..=lines.len() { + if index == lines.len() || lines[index].trim() == "---" { + values.extend(parse_yaml_document( + path, + &lines[document_start..index], + document_start, + file_profile.clone(), + )); + document_start = index + 1; + } + } + values +} + +fn parse_yaml_document( + path: &str, + lines: &[&str], + line_offset: usize, + file_profile: Option, +) -> Vec { + let mut stack: Vec<(usize, String)> = Vec::new(); + let mut values = Vec::new(); + for (index, line) in lines.iter().enumerate() { + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') { + continue; + } + if let Some(item) = trimmed + .strip_prefix('-') + .map(str::trim) + .filter(|value| !value.is_empty()) + { + let key = stack + .iter() + .map(|(_, part)| part.as_str()) + .collect::>() + .join("."); + if !key.is_empty() { + values.push(SpringConfigurationValueResponse { + key: canonical_property_name(&key), + value: item.trim_matches(['\'', '"']).to_string(), + path: path.to_string(), + line: line_offset + index + 1, + column: line.find('-').unwrap_or(0) + 1, + profile: None, + overrides_base_value: false, + target_path: None, + target_line: None, + target_column: None, + }); + } + continue; + } + let Some(separator) = trimmed.find(':') else { + continue; + }; + let key = trimmed[..separator].trim().trim_matches(['\'', '"']); + if key.is_empty() { + continue; + } + let indent = line + .chars() + .take_while(|value| value.is_whitespace()) + .count(); + while stack.last().is_some_and(|(level, _)| *level >= indent) { + stack.pop(); + } + let value = trimmed[separator + 1..].trim().trim_matches(['\'', '"']); + let mut parts = stack + .iter() + .map(|(_, part)| part.clone()) + .collect::>(); + parts.push(key.to_string()); + let full_key = canonical_property_name(&parts.join(".")); + if value.is_empty() { + stack.push((indent, key.to_string())); + continue; + } + values.push(SpringConfigurationValueResponse { + key: full_key, + value: value.to_string(), + path: path.to_string(), + line: line_offset + index + 1, + column: line.find(key).unwrap_or(0) + 1, + profile: None, + overrides_base_value: false, + target_path: None, + target_line: None, + target_column: None, + }); + } + let profile = file_profile.or_else(|| { + values + .iter() + .find(|value| value.key == "spring.config.activate.on-profile") + .map(|value| value.value.clone()) + }); + for value in &mut values { + value.profile = profile.clone(); + } + values +} + +fn attach_property_targets( + properties: &[SpringPropertyResponse], + values: &mut [SpringConfigurationValueResponse], +) { + let by_name = properties + .iter() + .map(|property| (canonical_property_name(&property.name), property)) + .collect::>(); + for value in values { + if let Some(property) = by_name.get(&canonical_property_name(&value.key)) { + value.target_path = property.source_path.clone(); + value.target_line = property.source_line; + value.target_column = property.source_column; + } + } +} + +fn mark_profile_overrides(values: &mut [SpringConfigurationValueResponse]) { + let base_keys = values + .iter() + .filter(|value| value.profile.is_none()) + .map(|value| value.key.clone()) + .collect::>(); + for value in values { + value.overrides_base_value = value.profile.is_some() && base_keys.contains(&value.key); + } +} + +fn configuration_diagnostics( + properties: &[SpringPropertyResponse], + values: &[SpringConfigurationValueResponse], +) -> Vec { + let known = properties + .iter() + .map(|property| canonical_property_name(&property.name)) + .collect::>(); + let types = properties + .iter() + .filter_map(|property| { + property + .type_name + .as_deref() + .map(|type_name| (canonical_property_name(&property.name), type_name)) + }) + .collect::>(); + let mut diagnostics = Vec::new(); + for value in values { + let canonical_key = canonical_property_name(&value.key); + if !known.contains(&canonical_key) { + diagnostics.push(SpringDiagnosticResponse { + path: value.path.clone(), + line: value.line, + column: value.column, + severity: "warning".to_string(), + message: format!("Unknown Spring configuration property `{}`", value.key), + }); + continue; + } + let Some(type_name) = types.get(&canonical_key) else { + continue; + }; + let valid = if type_name.contains("Boolean") || *type_name == "boolean" { + matches!(value.value.as_str(), "true" | "false") + } else if type_name.contains("Integer") || *type_name == "int" || *type_name == "long" { + value.value.parse::().is_ok() + } else { + true + }; + if !valid { + diagnostics.push(SpringDiagnosticResponse { + path: value.path.clone(), + line: value.line, + column: value.column, + severity: "error".to_string(), + message: format!("`{}` is not a valid value for `{}`", value.value, type_name), + }); + } + } + diagnostics +} + +#[derive(Clone)] +struct IndexedBean { + response: SpringBeanResponse, + names: HashSet, + assignable_types: HashSet, + primary: bool, +} + +struct RawInjection { + path: String, + line: usize, + column: usize, + type_name: String, + qualifier: Option, +} + +fn bean_index( + sources: &[(String, String)], +) -> ( + Vec, + Vec, + Vec, +) { + let type_declaration = + Regex::new(r"\b(class|interface|record)\s+([A-Za-z_$][A-Za-z0-9_$]*)([^\{]*)").unwrap(); + let method = Regex::new( + r"(?:public|protected|private)?\s*(?:static\s+)?(?:final\s+)?([A-Za-z0-9_$.<>?]+)\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*\(", + ) + .unwrap(); + let field = Regex::new( + r"(?:private|protected|public)\s+(?:static\s+)?(?:final\s+)?([A-Za-z0-9_$.<>?]+)\s+([A-Za-z_$][A-Za-z0-9_$]*)", + ) + .unwrap(); + let mut supertypes = HashMap::>::new(); + for (_, source) in sources { + for capture in type_declaration.captures_iter(source) { + let Some(name) = capture.get(2) else { continue }; + let tail = capture + .get(3) + .map(|value| value.as_str()) + .unwrap_or_default(); + supertypes.insert(name.as_str().to_string(), declared_supertypes(tail)); + } + } + + let mut indexed_beans = Vec::new(); + let mut raw_injections = Vec::new(); + for (path, source) in sources { + let lines = source.lines().collect::>(); + let source_type = type_declaration + .captures(source) + .and_then(|capture| capture.get(2)) + .map(|value| value.as_str().to_string()); + let constructor_count = source_type.as_deref().map_or(0, |name| { + constructor_regex(name) + .map(|pattern| pattern.captures_iter(source).count()) + .unwrap_or(0) + }); + for (index, line) in lines.iter().enumerate() { + let context = annotation_context(&lines, index); + if let Some(capture) = type_declaration.captures(line) { + if has_component_annotation(&context) { + let name = capture.get(2).unwrap(); + let default_name = lower_camel(name.as_str()); + let bean_name = component_name(&context).unwrap_or(default_name); + let mut names = HashSet::from([bean_name.clone()]); + names.extend(qualifier_names(&context)); + indexed_beans.push(IndexedBean { + response: SpringBeanResponse { + id: format!("{}:{}", path, bean_name), + name: bean_name, + type_name: name.as_str().to_string(), + path: path.clone(), + line: index + 1, + column: name.start() + 1, + kind: "component".to_string(), + }, + names, + assignable_types: assignable_types(name.as_str(), &supertypes), + primary: has_annotation(&context, "Primary"), + }); + } + } + if has_annotation(&context, "Bean") { + if let Some(capture) = method.captures(line) { + let type_name = simple_type(capture.get(1).unwrap().as_str()); + let declaration_name = capture.get(2).unwrap(); + let aliases = bean_names(&context); + let bean_name = aliases + .first() + .cloned() + .unwrap_or_else(|| declaration_name.as_str().to_string()); + let mut names = aliases.into_iter().collect::>(); + names.insert(bean_name.clone()); + names.extend(qualifier_names(&context)); + indexed_beans.push(IndexedBean { + response: SpringBeanResponse { + id: format!("{}:{}", path, bean_name), + name: bean_name, + type_name: type_name.clone(), + path: path.clone(), + line: index + 1, + column: declaration_name.start() + 1, + kind: "beanMethod".to_string(), + }, + names, + assignable_types: assignable_types(&type_name, &supertypes), + primary: has_annotation(&context, "Primary"), + }); + } + } + if is_injection_context(&context) { + if let Some(capture) = field.captures(line) { + let type_name = simple_type(capture.get(1).unwrap().as_str()); + let name = capture.get(2).unwrap(); + raw_injections.push(RawInjection { + path: path.clone(), + line: index + 1, + column: name.start() + 1, + type_name, + qualifier: injection_qualifier(&context), + }); + } + } + if let Some(type_name) = source_type.as_deref() { + let Some(pattern) = constructor_regex(type_name) else { + continue; + }; + let Some(opening) = pattern.find(line).map(|value| value.end() - 1) else { + continue; + }; + if !is_injection_context(&context) && constructor_count != 1 { + continue; + } + let Some(closing) = line.rfind(')').filter(|value| *value > opening) else { + continue; + }; + raw_injections.extend(parse_constructor_injections( + path, + index + 1, + line, + &line[opening + 1..closing], + )); + } + } + } + + indexed_beans.sort_by(|left, right| { + left.response + .name + .cmp(&right.response.name) + .then_with(|| left.response.path.cmp(&right.response.path)) + }); + let mut diagnostics = Vec::new(); + let mut injections = raw_injections + .into_iter() + .map(|injection| { + let mut candidates = indexed_beans + .iter() + .filter(|bean| bean.assignable_types.contains(&injection.type_name)) + .collect::>(); + if let Some(qualifier) = injection.qualifier.as_deref() { + candidates.retain(|bean| bean.names.contains(qualifier)); + } else { + let primary = candidates + .iter() + .copied() + .filter(|bean| bean.primary) + .collect::>(); + if !primary.is_empty() { + candidates = primary; + } + } + if candidates.is_empty() { + diagnostics.push(SpringDiagnosticResponse { + path: injection.path.clone(), + line: injection.line, + column: injection.column, + severity: "warning".to_string(), + message: format!( + "No Spring bean satisfies injection type `{}`{}", + injection.type_name, + injection + .qualifier + .as_deref() + .map(|value| format!(" with qualifier `{value}`")) + .unwrap_or_default() + ), + }); + } else if candidates.len() > 1 { + diagnostics.push(SpringDiagnosticResponse { + path: injection.path.clone(), + line: injection.line, + column: injection.column, + severity: "warning".to_string(), + message: format!( + "Multiple Spring beans satisfy injection type `{}`", + injection.type_name + ), + }); + } + SpringInjectionResponse { + path: injection.path, + line: injection.line, + column: injection.column, + type_name: injection.type_name, + qualifier: injection.qualifier, + bean_ids: candidates + .into_iter() + .map(|bean| bean.response.id.clone()) + .collect(), + } + }) + .collect::>(); + injections.sort_by(|left, right| { + left.path + .cmp(&right.path) + .then_with(|| left.line.cmp(&right.line)) + .then_with(|| left.column.cmp(&right.column)) + }); + let beans = indexed_beans + .into_iter() + .map(|bean| bean.response) + .collect(); + (beans, injections, diagnostics) +} + +fn annotation_context(lines: &[&str], index: usize) -> String { + let mut values = vec![lines[index].trim().to_string()]; + for previous in lines[..index].iter().rev().take(8) { + let trimmed = previous.trim(); + if trimmed.is_empty() { + continue; + } + if trimmed.starts_with('@') + || trimmed.starts_with("value") + || trimmed.starts_with("name") + || trimmed == ")" + || trimmed == "}" + { + values.insert(0, trimmed.to_string()); + } else { + break; + } + } + values.join(" ") +} + +fn has_annotation(context: &str, name: &str) -> bool { + Regex::new(&format!(r"@{}(?:\s|\(|$)", regex::escape(name))) + .unwrap() + .is_match(context) +} + +fn has_component_annotation(context: &str) -> bool { + [ + "Component", + "Service", + "Repository", + "Controller", + "RestController", + "Configuration", + ] + .iter() + .any(|name| has_annotation(context, name)) +} + +fn component_name(context: &str) -> Option { + let annotation = Regex::new( + r#"@(Component|Service|Repository|Controller|RestController|Configuration)\s*\([^\)]*[\"']([^\"']+)[\"']"#, + ) + .unwrap(); + annotation + .captures(context) + .and_then(|capture| capture.get(2)) + .map(|value| value.as_str().to_string()) +} + +fn qualifier_names(context: &str) -> Vec { + let pattern = Regex::new(r#"@Qualifier\s*\(\s*[\"']([^\"']+)[\"']\s*\)"#).unwrap(); + pattern + .captures_iter(context) + .filter_map(|capture| capture.get(1).map(|value| value.as_str().to_string())) + .collect() +} + +fn bean_names(context: &str) -> Vec { + let Some(start) = context.find("@Bean") else { + return Vec::new(); + }; + let remaining = &context[start..]; + let end = remaining.find(')').unwrap_or(remaining.len()); + quoted_values(&remaining[..end]) +} + +fn quoted_values(value: &str) -> Vec { + let pattern = Regex::new(r#"[\"']([^\"']*)[\"']"#).unwrap(); + pattern + .captures_iter(value) + .filter_map(|capture| capture.get(1).map(|item| item.as_str().to_string())) + .collect() +} + +fn declared_supertypes(tail: &str) -> Vec { + let mut values = Vec::new(); + for keyword in ["extends", "implements"] { + let pattern = Regex::new(&format!( + r"\b{}\s+([^\{{]+?)(?:\b(?:extends|implements)\b|$)", + keyword + )) + .unwrap(); + if let Some(capture) = pattern.captures(tail) { + values.extend( + capture[1] + .split(',') + .map(simple_type) + .filter(|value| !value.is_empty()), + ); + } + } + values +} + +fn assignable_types(type_name: &str, supertypes: &HashMap>) -> HashSet { + let mut values = HashSet::new(); + let mut pending = vec![simple_type(type_name)]; + while let Some(value) = pending.pop() { + if !values.insert(value.clone()) { + continue; + } + if let Some(parents) = supertypes.get(&value) { + pending.extend(parents.iter().cloned()); + } + } + values +} + +fn is_injection_context(context: &str) -> bool { + ["Autowired", "Inject", "Resource"] + .iter() + .any(|name| has_annotation(context, name)) +} + +fn injection_qualifier(context: &str) -> Option { + qualifier_names(context).into_iter().next().or_else(|| { + let resource = Regex::new(r#"@Resource\s*\([^\)]*name\s*=\s*[\"']([^\"']+)[\"']"#).unwrap(); + resource + .captures(context) + .and_then(|capture| capture.get(1)) + .map(|value| value.as_str().to_string()) + }) +} + +fn constructor_regex(type_name: &str) -> Option { + Regex::new(&format!( + r"(?:public|protected|private)?\s*{}\s*\(", + regex::escape(type_name) + )) + .ok() +} + +fn parse_constructor_injections( + path: &str, + line_number: usize, + line: &str, + parameters: &str, +) -> Vec { + split_parameters(parameters) + .into_iter() + .filter_map(|parameter| { + let declaration = Regex::new( + r"(?:@[A-Za-z0-9_$.]+(?:\([^)]*\))?\s+)*(?:final\s+)?([A-Za-z0-9_$.<>?]+)\s+([A-Za-z_$][A-Za-z0-9_$]*)$", + ) + .unwrap(); + let capture = declaration.captures(parameter.trim())?; + let variable = capture.get(2)?; + Some(RawInjection { + path: path.to_string(), + line: line_number, + column: line.find(variable.as_str()).unwrap_or(0) + 1, + type_name: simple_type(capture.get(1)?.as_str()), + qualifier: injection_qualifier(parameter), + }) + }) + .collect() +} + +fn split_parameters(value: &str) -> Vec<&str> { + let mut values = Vec::new(); + let mut start = 0usize; + let mut depth = 0usize; + for (index, character) in value.char_indices() { + match character { + '<' | '(' | '{' | '[' => depth += 1, + '>' | ')' | '}' | ']' => depth = depth.saturating_sub(1), + ',' if depth == 0 => { + values.push(&value[start..index]); + start = index + 1; + } + _ => {} + } + } + if start < value.len() { + values.push(&value[start..]); + } + values +} + +fn endpoint_index(sources: &[(String, String)]) -> Vec { + let class = Regex::new(r"\bclass\s+([A-Za-z_$][A-Za-z0-9_$]*)").unwrap(); + let method = Regex::new(r"[A-Za-z0-9_$.<>?]+\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*\(").unwrap(); + let mut endpoints = Vec::new(); + for (path, source) in sources { + if !has_annotation(source, "Controller") && !has_annotation(source, "RestController") { + continue; + } + let controller = class + .captures(source) + .and_then(|capture| capture.get(1)) + .map(|value| value.as_str().to_string()) + .unwrap_or_else(|| "Controller".to_string()); + let lines = source.lines().collect::>(); + let mut base_routes = vec![String::new()]; + let mut index = 0usize; + while index < lines.len() { + if !lines[index].trim_start().starts_with('@') { + index += 1; + continue; + } + let (annotation, annotation_end) = annotation_block(&lines, index); + let Some((methods, routes)) = mapping(&annotation) else { + index = annotation_end + 1; + continue; + }; + let declaration_index = next_declaration_index(&lines, annotation_end + 1); + let declaration = declaration_index + .and_then(|value| lines.get(value).copied()) + .unwrap_or_default(); + if annotation.contains("@RequestMapping") && class.is_match(declaration) { + base_routes = routes; + index = annotation_end + 1; + continue; + } + let method_name = method + .captures(declaration) + .and_then(|capture| capture.get(1)) + .map(|value| value.as_str()) + .unwrap_or("handler"); + for base_route in &base_routes { + for route in &routes { + let joined = join_route(base_route, route); + endpoints.push(SpringEndpointResponse { + id: format!("{}:{}:{}:{}", path, index + 1, methods.join(","), joined), + http_methods: methods.clone(), + route: joined, + controller: controller.clone(), + method: method_name.to_string(), + path: path.clone(), + line: index + 1, + column: lines[index].find('@').unwrap_or(0) + 1, + }); + } + } + index = annotation_end + 1; + } + } + endpoints.sort_by(|left, right| { + left.route + .cmp(&right.route) + .then_with(|| left.http_methods.cmp(&right.http_methods)) + }); + endpoints +} + +fn mapping(annotation_text: &str) -> Option<(Vec, Vec)> { + for (annotation, method) in [ + ("@GetMapping", "GET"), + ("@PostMapping", "POST"), + ("@PutMapping", "PUT"), + ("@DeleteMapping", "DELETE"), + ("@PatchMapping", "PATCH"), + ] { + if annotation_text.contains(annotation) { + return Some((vec![method.to_string()], annotation_routes(annotation_text))); + } + } + if annotation_text.contains("@RequestMapping") { + let method_pattern = + Regex::new(r"RequestMethod\.(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS|TRACE)").unwrap(); + let mut methods = method_pattern + .captures_iter(annotation_text) + .filter_map(|capture| capture.get(1).map(|value| value.as_str().to_string())) + .collect::>(); + if methods.is_empty() { + methods.push("ANY".to_string()); + } + methods.sort(); + methods.dedup(); + return Some((methods, annotation_routes(annotation_text))); + } + None +} + +fn annotation_routes(annotation: &str) -> Vec { + let named = Regex::new(r#"(?:value|path)\s*=\s*(\{[^}]*\}|[\"'][^\"']*[\"'])"#).unwrap(); + let expression = named + .captures(annotation) + .and_then(|capture| capture.get(1)) + .map(|value| value.as_str()) + .or_else(|| { + let start = annotation.find('(')? + 1; + let end = annotation.rfind(')')?; + let value = annotation[start..end].trim(); + (value.starts_with(['\'', '"', '{'])).then_some(value) + }); + let mut routes = expression.map(quoted_values).unwrap_or_default(); + if routes.is_empty() { + routes.push(String::new()); + } + routes.sort(); + routes.dedup(); + routes +} + +fn annotation_block(lines: &[&str], start: usize) -> (String, usize) { + let mut value = String::new(); + let mut depth = 0isize; + let mut saw_parenthesis = false; + let mut end = start; + for (index, line) in lines.iter().enumerate().skip(start) { + if !value.is_empty() { + value.push(' '); + } + value.push_str(line.trim()); + for character in line.chars() { + if character == '(' { + depth += 1; + saw_parenthesis = true; + } else if character == ')' { + depth -= 1; + } + } + end = index; + if !saw_parenthesis || depth <= 0 { + break; + } + } + (value, end) +} + +fn next_declaration_index(lines: &[&str], start: usize) -> Option { + lines + .iter() + .enumerate() + .skip(start) + .take(12) + .find_map(|(index, line)| { + let trimmed = line.trim(); + (!trimmed.is_empty() && !trimmed.starts_with('@')).then_some(index) + }) +} + +fn join_route(base: &str, route: &str) -> String { + let value = format!("{}/{}", base.trim_matches('/'), route.trim_matches('/')); + let trimmed = value.trim_matches('/'); + if trimmed.is_empty() { + "/".to_string() + } else { + format!("/{trimmed}") + } +} + +fn simple_type(value: &str) -> String { + value + .split('<') + .next() + .unwrap_or(value) + .rsplit('.') + .next() + .unwrap_or(value) + .trim() + .to_string() +} + +fn lower_camel(value: &str) -> String { + let mut characters = value.chars(); + characters + .next() + .map(|first| first.to_lowercase().collect::() + characters.as_str()) + .unwrap_or_default() +} diff --git a/rust/lithe-core/src/lsp/languages/jdt.rs b/rust/lithe-core/src/lsp/languages/jdt.rs index b5cd746d5..3642592df 100644 --- a/rust/lithe-core/src/lsp/languages/jdt.rs +++ b/rust/lithe-core/src/lsp/languages/jdt.rs @@ -206,6 +206,12 @@ fn without_jdt_owned_arguments(arguments: &[String]) -> Vec { fn java_settings() -> Value { json!({ "java": { + "eclipse": { + "downloadSources": true + }, + "maven": { + "downloadSources": true + }, "inlayHints": { "parameterNames": { "enabled": "all" @@ -218,6 +224,12 @@ fn java_settings() -> Value { fn java_configuration_for_section(section: Option<&str>) -> Value { match section { Some("java") => json!({ + "eclipse": { + "downloadSources": true + }, + "maven": { + "downloadSources": true + }, "inlayHints": { "parameterNames": { "enabled": "all" @@ -231,6 +243,10 @@ fn java_configuration_for_section(section: Option<&str>) -> Value { }), Some("java.inlayHints.parameterNames") => json!({ "enabled": "all" }), Some("java.inlayHints.parameterNames.enabled") => json!("all"), + Some("java.eclipse") => json!({ "downloadSources": true }), + Some("java.eclipse.downloadSources") => json!(true), + Some("java.maven") => json!({ "downloadSources": true }), + Some("java.maven.downloadSources") => json!(true), _ => Value::Null, } } @@ -467,6 +483,8 @@ mod tests { fn java_workspace_configuration_matches_each_section_shape() { let items = [ "java", + "java.eclipse.downloadSources", + "java.maven.downloadSources", "java.inlayHints", "java.inlayHints.parameterNames", "java.inlayHints.parameterNames.enabled", @@ -479,10 +497,14 @@ mod tests { let values = workspace_configuration("java", &items).unwrap(); assert_eq!(values[0]["inlayHints"]["parameterNames"]["enabled"], "all"); - assert_eq!(values[1]["parameterNames"]["enabled"], "all"); - assert_eq!(values[2]["enabled"], "all"); - assert_eq!(values[3], "all"); - assert_eq!(values[4], Value::Null); + assert_eq!(values[0]["eclipse"]["downloadSources"], true); + assert_eq!(values[0]["maven"]["downloadSources"], true); + assert_eq!(values[1], true); + assert_eq!(values[2], true); + assert_eq!(values[3]["parameterNames"]["enabled"], "all"); + assert_eq!(values[4]["enabled"], "all"); + assert_eq!(values[5], "all"); + assert_eq!(values[6], Value::Null); } #[test] @@ -494,6 +516,14 @@ mod tests { notification.params["settings"]["java"]["inlayHints"]["parameterNames"]["enabled"], "all" ); + assert_eq!( + notification.params["settings"]["java"]["eclipse"]["downloadSources"], + true + ); + assert_eq!( + notification.params["settings"]["java"]["maven"]["downloadSources"], + true + ); } #[test] diff --git a/rust/lithe-core/src/protocol/command.rs b/rust/lithe-core/src/protocol/command.rs index 0c8223d1f..79ba169ca 100644 --- a/rust/lithe-core/src/protocol/command.rs +++ b/rust/lithe-core/src/protocol/command.rs @@ -131,6 +131,8 @@ pub enum CoreCommand { JavaServerPort, /// Computes lightweight Java structure features (`java.structure`). JavaStructure, + /// Builds Spring configuration, bean, injection, and endpoint indexes (`spring.index`). + SpringIndex, /// Reads normalized repository and working-tree state (`git.status`). GitStatus, /// Resolves paths a Git-aware watcher must observe (`git.watchContext`). @@ -231,6 +233,7 @@ impl CoreCommand { "java.sourceDefinition" => Some(Self::JavaSourceDefinition), "java.serverPort" => Some(Self::JavaServerPort), "java.structure" => Some(Self::JavaStructure), + "spring.index" => Some(Self::SpringIndex), "git.status" => Some(Self::GitStatus), "git.watchContext" => Some(Self::GitWatchContext), "git.pullRequestContext" => Some(Self::GitPullRequestContext), diff --git a/rust/lithe-core/src/protocol/contracts.rs b/rust/lithe-core/src/protocol/contracts.rs index f544b8c96..95380d1f0 100644 --- a/rust/lithe-core/src/protocol/contracts.rs +++ b/rust/lithe-core/src/protocol/contracts.rs @@ -386,6 +386,8 @@ pub struct GitHistoryResponse { pub references: Vec, pub commits: Vec, pub has_more: bool, + pub user_name: Option, + pub user_email: Option, } #[derive(Debug, Clone, Serialize)] @@ -549,3 +551,105 @@ pub struct GitDiffResponse { pub rows: Vec, pub hunks: Vec, } + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// One Spring configuration property and its optional Java declaration. +pub struct SpringPropertyResponse { + pub name: String, + pub type_name: Option, + pub description: Option, + pub default_value: Option, + pub source_path: Option, + pub source_line: Option, + pub source_column: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// One key/value occurrence from a Spring application configuration document. +pub struct SpringConfigurationValueResponse { + pub key: String, + pub value: String, + pub path: String, + pub line: usize, + pub column: usize, + pub profile: Option, + pub overrides_base_value: bool, + pub target_path: Option, + pub target_line: Option, + pub target_column: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// Java `@Value` reference to a Spring configuration property. +pub struct SpringPropertyReferenceResponse { + pub key: String, + pub path: String, + pub line: usize, + pub column: usize, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// Spring configuration problem projected onto one source location. +pub struct SpringDiagnosticResponse { + pub path: String, + pub line: usize, + pub column: usize, + pub severity: String, + pub message: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// Component or `@Bean` declaration available for dependency injection. +pub struct SpringBeanResponse { + pub id: String, + pub name: String, + pub type_name: String, + pub path: String, + pub line: usize, + pub column: usize, + pub kind: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// Injection point and the bean declarations that satisfy it. +pub struct SpringInjectionResponse { + pub path: String, + pub line: usize, + pub column: usize, + pub type_name: String, + pub qualifier: Option, + pub bean_ids: Vec, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// HTTP endpoint declared by a Spring MVC controller method. +pub struct SpringEndpointResponse { + pub id: String, + pub http_methods: Vec, + pub route: String, + pub controller: String, + pub method: String, + pub path: String, + pub line: usize, + pub column: usize, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// Complete deterministic Spring semantic index for one workspace snapshot. +pub struct SpringIndexResponse { + pub properties: Vec, + pub values: Vec, + pub property_references: Vec, + pub diagnostics: Vec, + pub beans: Vec, + pub injections: Vec, + pub endpoints: Vec, +} diff --git a/rust/lithe-core/src/runtime/dispatcher.rs b/rust/lithe-core/src/runtime/dispatcher.rs index b8a79cf2a..c870f8c76 100644 --- a/rust/lithe-core/src/runtime/dispatcher.rs +++ b/rust/lithe-core/src/runtime/dispatcher.rs @@ -15,7 +15,7 @@ use crate::git::{ use crate::github::{NormalizeResponseRequest, ParseRemoteRequest, RequestPlanRequest}; use crate::languages::{ JavaClassNameRequest, JavaCodeVisionRequest, JavaRunConfigurationsRequest, - JavaServerPortRequest, JavaSourceDefinitionRequest, JavaStructureRequest, + JavaServerPortRequest, JavaSourceDefinitionRequest, JavaStructureRequest, SpringIndexRequest, }; use crate::project::{ self, FileReadRequest, FileWriteRequest, ReplacementPreviewRequest, SearchIndexRequest, @@ -863,6 +863,21 @@ fn execute(request: &str) -> CoreResponse { Err(error) => CoreResponse::failure(id, error), } } + CoreCommand::SpringIndex => { + match serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid Spring index request") + .with_details(error.to_string()) + }) + .and_then(crate::languages::spring_index) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("Spring index response should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } CoreCommand::GitStatus => match serde_json::from_value::(parsed.payload) .map_err(|error| { CoreError::new(ErrorCode::InvalidRequest, "Invalid Git status request") diff --git a/rust/lithe-core/src/tests/git.rs b/rust/lithe-core/src/tests/git.rs index 4261d6ec3..dc7158a12 100644 --- a/rust/lithe-core/src/tests/git.rs +++ b/rust/lithe-core/src/tests/git.rs @@ -1096,6 +1096,8 @@ fn git_history_returns_references_and_commit_graph_fields() { .expect("history response should be JSON"); assert_eq!(response["ok"], true); assert_eq!(response["data"]["commits"][0]["subject"], "initial"); + assert_eq!(response["data"]["userName"], "Lithe Test"); + assert_eq!(response["data"]["userEmail"], "test@example.com"); assert!( response["data"]["commits"][0]["hash"] .as_str() diff --git a/rust/lithe-core/src/tests/mod.rs b/rust/lithe-core/src/tests/mod.rs index b9a93b382..310247e5c 100644 --- a/rust/lithe-core/src/tests/mod.rs +++ b/rust/lithe-core/src/tests/mod.rs @@ -6,4 +6,5 @@ mod plugins; mod project; mod protocol; mod run_configuration; +mod spring; mod support; diff --git a/rust/lithe-core/src/tests/spring.rs b/rust/lithe-core/src/tests/spring.rs new file mode 100644 index 000000000..592b6c3c8 --- /dev/null +++ b/rust/lithe-core/src/tests/spring.rs @@ -0,0 +1,364 @@ +use super::support::temporary_root; +use crate::execute_json; +use serde_json::Value; +use std::fs::{self, File}; +use std::io::Write; + +#[test] +fn spring_index_links_configuration_profiles_beans_and_endpoints() { + let root = temporary_root("spring-index"); + let java = root.join("src/main/java/demo"); + let resources = root.join("src/main/resources"); + fs::create_dir_all(&java).expect("Java fixture directory should be creatable"); + fs::create_dir_all(resources.join("META-INF")) + .expect("resource fixture directory should be creatable"); + fs::write( + java.join("DemoProperties.java"), + r#"package demo; +@ConfigurationProperties(prefix = "demo") +public class DemoProperties { + private boolean enabled; + private int retryCount; + private Security security; + public static class Security { + private java.time.Duration timeout; + } +} +"#, + ) + .expect("configuration properties fixture should be writable"); + fs::write( + java.join("RecordProperties.java"), + r#"package demo; +@ConfigurationProperties(prefix = "recorded") +public record RecordProperties(boolean enabled, int retryCount) {} +"#, + ) + .expect("record configuration properties fixture should be writable"); + fs::write( + java.join("GreetingService.java"), + "package demo;\n@Service\npublic class GreetingService {}\n", + ) + .expect("service fixture should be writable"); + fs::write( + java.join("GreetingController.java"), + r#"package demo; +@RestController +@RequestMapping("/api") +public class GreetingController { + @Autowired + private GreetingService service; + @GetMapping("/greet") + public String greet() { return "hi"; } +} +"#, + ) + .expect("controller fixture should be writable"); + fs::write( + resources.join("application.yml"), + "demo:\n enabled: true\n retry-count: 3\n", + ) + .expect("base configuration fixture should be writable"); + fs::write( + resources.join("application-dev.yml"), + "demo:\n retry-count: nope\n", + ) + .expect("profile configuration fixture should be writable"); + fs::write( + resources.join("META-INF/spring-configuration-metadata.json"), + r#"{"properties":[{"name":"demo.title","type":"java.lang.String","description":"Display title."}]}"#, + ) + .expect("metadata fixture should be writable"); + + let paths = [ + "src/main/java/demo/DemoProperties.java", + "src/main/java/demo/RecordProperties.java", + "src/main/java/demo/GreetingService.java", + "src/main/java/demo/GreetingController.java", + "src/main/resources/application.yml", + "src/main/resources/application-dev.yml", + "src/main/resources/META-INF/spring-configuration-metadata.json", + ]; + let response = execute_spring(&root, &paths, serde_json::json!({})); + + assert_eq!(response["ok"], true, "{response}"); + let properties = response["data"]["properties"].as_array().unwrap(); + assert!(properties + .iter() + .any(|value| value["name"] == "demo.retry-count")); + assert!(properties.iter().any(|value| value["name"] == "demo.title")); + assert!(properties + .iter() + .any(|value| value["name"] == "demo.security.timeout")); + assert!(properties + .iter() + .any(|value| value["name"] == "recorded.retry-count")); + let profile_value = response["data"]["values"] + .as_array() + .unwrap() + .iter() + .find(|value| value["path"].as_str().unwrap().contains("application-dev")) + .unwrap(); + assert_eq!(profile_value["profile"], "dev"); + assert_eq!(profile_value["overridesBaseValue"], true); + assert!(profile_value["targetPath"] + .as_str() + .unwrap() + .ends_with("DemoProperties.java")); + assert!(response["data"]["diagnostics"] + .as_array() + .unwrap() + .iter() + .any(|value| value["severity"] == "error")); + assert!(response["data"]["beans"] + .as_array() + .unwrap() + .iter() + .any(|value| value["typeName"] == "GreetingService")); + assert!( + response["data"]["injections"][0]["beanIds"] + .as_array() + .unwrap() + .len() + == 1, + "{response}" + ); + assert_eq!(response["data"]["endpoints"][0]["route"], "/api/greet"); + assert_eq!(response["data"]["endpoints"][0]["httpMethods"][0], "GET"); + + fs::remove_dir_all(root).expect("Spring fixture should be removable"); +} + +#[test] +fn spring_index_resolves_qualifiers_primary_interfaces_and_constructors() { + let root = temporary_root("spring-injection"); + let java = root.join("src/main/java/demo"); + fs::create_dir_all(&java).expect("Java fixture directory should be creatable"); + fs::write( + java.join("PaymentService.java"), + "package demo;\npublic interface PaymentService {}\n", + ) + .expect("interface fixture should be writable"); + fs::write( + java.join("StripePaymentService.java"), + r#"package demo; +@Service("stripe") +public class StripePaymentService implements PaymentService {} +"#, + ) + .expect("qualified service fixture should be writable"); + fs::write( + java.join("PaypalPaymentService.java"), + r#"package demo; +@Service +@Primary +public class PaypalPaymentService implements PaymentService {} +"#, + ) + .expect("primary service fixture should be writable"); + fs::write( + java.join("CheckoutController.java"), + r#"package demo; +@RestController +public class CheckoutController { + private final PaymentService paymentService; + public CheckoutController(@Qualifier("stripe") PaymentService paymentService) { + this.paymentService = paymentService; + } +} +"#, + ) + .expect("qualified constructor fixture should be writable"); + fs::write( + java.join("ReportController.java"), + r#"package demo; +@Controller +public class ReportController { + public ReportController(PaymentService paymentService) {} +} +"#, + ) + .expect("primary constructor fixture should be writable"); + + let paths = [ + "src/main/java/demo/PaymentService.java", + "src/main/java/demo/StripePaymentService.java", + "src/main/java/demo/PaypalPaymentService.java", + "src/main/java/demo/CheckoutController.java", + "src/main/java/demo/ReportController.java", + ]; + let response = execute_spring(&root, &paths, serde_json::json!({})); + assert_eq!(response["ok"], true, "{response}"); + let injections = response["data"]["injections"].as_array().unwrap(); + let qualified = injections + .iter() + .find(|value| { + value["path"] + .as_str() + .unwrap() + .contains("CheckoutController") + }) + .unwrap_or_else(|| panic!("missing qualified injection: {response}")); + assert_eq!(qualified["qualifier"], "stripe"); + assert_eq!(qualified["beanIds"].as_array().unwrap().len(), 1); + assert!(qualified["beanIds"][0].as_str().unwrap().contains("stripe")); + let primary = injections + .iter() + .find(|value| value["path"].as_str().unwrap().contains("ReportController")) + .unwrap(); + assert_eq!(primary["beanIds"].as_array().unwrap().len(), 1); + assert!(primary["beanIds"][0] + .as_str() + .unwrap() + .contains("paypalPaymentService")); + assert!(response["data"]["diagnostics"] + .as_array() + .unwrap() + .is_empty()); + + fs::remove_dir_all(root).expect("Spring fixture should be removable"); +} + +#[test] +fn spring_index_links_value_references_profiles_and_mapping_variants() { + let root = temporary_root("spring-web-config"); + let java = root.join("src/main/java/demo"); + let resources = root.join("src/main/resources/META-INF"); + fs::create_dir_all(&java).expect("Java fixture directory should be creatable"); + fs::create_dir_all(&resources).expect("resource fixture directory should be creatable"); + fs::write( + java.join("ApiController.java"), + r#"package demo; +@RestController +@RequestMapping(path = {"/api", "/v2"}) +public class ApiController { + @Value("${demo.retryCount:3}") + private int retryCount; + @GetMapping(path = {"/a", "/b"}) + public String get() { return "ok"; } + @RequestMapping(path = "/multi", method = {RequestMethod.GET, RequestMethod.POST}) + public String multi() { return "ok"; } +} +"#, + ) + .expect("controller fixture should be writable"); + fs::write( + root.join("src/main/resources/application.yml"), + "demo:\n retryCount: 2\n---\ndemo:\n retry-count:\n - 3\nspring:\n config:\n activate:\n on-profile: dev\n", + ) + .expect("multi-document YAML fixture should be writable"); + fs::write( + resources.join("spring-configuration-metadata.json"), + r#"{"properties":[{"name":"demo.retry-count","type":"java.lang.Integer"}]}"#, + ) + .expect("metadata fixture should be writable"); + + let paths = [ + "src/main/java/demo/ApiController.java", + "src/main/resources/application.yml", + "src/main/resources/META-INF/spring-configuration-metadata.json", + ]; + let response = execute_spring(&root, &paths, serde_json::json!({})); + assert_eq!(response["ok"], true, "{response}"); + assert_eq!( + response["data"]["propertyReferences"][0]["key"], + "demo.retry-count" + ); + let values = response["data"]["values"].as_array().unwrap(); + assert!(values + .iter() + .any(|value| { value["key"] == "demo.retry-count" && value["profile"] == "dev" })); + assert!(!response["data"]["diagnostics"] + .as_array() + .unwrap() + .iter() + .any(|value| value["message"].as_str().unwrap().contains("Unknown"))); + let endpoints = response["data"]["endpoints"].as_array().unwrap(); + assert_eq!( + endpoints + .iter() + .filter(|value| value["route"].as_str().unwrap().ends_with("/a")) + .count(), + 2 + ); + assert!(endpoints.iter().any(|value| { + value["route"] == "/api/multi" && value["httpMethods"] == serde_json::json!(["GET", "POST"]) + })); + + fs::remove_dir_all(root).expect("Spring fixture should be removable"); +} + +#[test] +fn spring_dependency_metadata_cache_refresh_is_explicit() { + let root = temporary_root("spring-metadata-cache"); + let repository = root.join("repository"); + fs::create_dir_all(&repository).expect("metadata repository should be creatable"); + let archive = repository.join("fixture.jar"); + write_metadata_archive(&archive, "cache.first"); + + let refreshed = execute_spring( + &root, + &[], + serde_json::json!({ + "metadataRepository": repository, + "refreshDependencyMetadata": true + }), + ); + assert!(has_property(&refreshed, "cache.first")); + write_metadata_archive(&archive, "cache.second"); + let cached = execute_spring( + &root, + &[], + serde_json::json!({"metadataRepository": repository}), + ); + assert!(has_property(&cached, "cache.first")); + assert!(!has_property(&cached, "cache.second")); + let refreshed = execute_spring( + &root, + &[], + serde_json::json!({ + "metadataRepository": repository, + "refreshDependencyMetadata": true + }), + ); + assert!(has_property(&refreshed, "cache.second")); + + fs::remove_dir_all(root).expect("Spring fixture should be removable"); +} + +fn execute_spring(root: &std::path::Path, paths: &[&str], extra: Value) -> Value { + let mut payload = serde_json::json!({"root": root, "paths": paths}); + payload + .as_object_mut() + .unwrap() + .extend(extra.as_object().cloned().unwrap_or_default()); + let request = serde_json::json!({ + "id": "spring", + "command": "spring.index", + "payload": payload + }); + serde_json::from_str(&execute_json(&request.to_string())) + .expect("Spring response should be JSON") +} + +fn write_metadata_archive(path: &std::path::Path, property: &str) { + let file = File::create(path).expect("metadata archive should be creatable"); + let mut archive = zip::ZipWriter::new(file); + archive + .start_file( + "META-INF/spring-configuration-metadata.json", + zip::write::SimpleFileOptions::default(), + ) + .expect("metadata entry should be creatable"); + write!(archive, r#"{{"properties":[{{"name":"{property}"}}]}}"#) + .expect("metadata should be writable"); + archive.finish().expect("metadata archive should close"); +} + +fn has_property(response: &Value, name: &str) -> bool { + response["data"]["properties"] + .as_array() + .unwrap() + .iter() + .any(|value| value["name"] == name) +} diff --git a/scripts/build-macos.sh b/scripts/build-macos.sh index 0b08e4391..21e6c3b1c 100755 --- a/scripts/build-macos.sh +++ b/scripts/build-macos.sh @@ -18,6 +18,8 @@ if [[ "$CONFIGURATION" != "debug" && "$CONFIGURATION" != "release" ]]; then fi cd "$ROOT_DIR" +"$ROOT_DIR/scripts/verify-macos-app-build-safety.sh" + RUST_TARGET="" if [[ -n "$TRIPLE" ]]; then case "$TRIPLE" in diff --git a/scripts/package-app.sh b/scripts/package-app.sh index d25d1144b..77934041b 100755 --- a/scripts/package-app.sh +++ b/scripts/package-app.sh @@ -128,6 +128,7 @@ fi cp "$INFO_PLIST" "$APP_DIR/Contents/Info.plist" /usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $VERSION" "$APP_DIR/Contents/Info.plist" /usr/libexec/PlistBuddy -c "Set :CFBundleVersion $BUILD_NUMBER" "$APP_DIR/Contents/Info.plist" +"$ROOT_DIR/scripts/stamp-macos-app-build-info.sh" "$APP_DIR/Contents/Info.plist" cp "$ROOT_DIR/Resources/AppIcon.icns" "$APP_DIR/Contents/Resources/AppIcon.icns" cp -R "$ROOT_DIR/Resources/IDEAIcons" "$APP_DIR/Contents/Resources/IDEAIcons" cp -R "$ROOT_DIR/Resources/DatabaseIcons" "$APP_DIR/Contents/Resources/DatabaseIcons" diff --git a/scripts/preview.sh b/scripts/preview.sh index 71e24531e..9a1a32039 100755 --- a/scripts/preview.sh +++ b/scripts/preview.sh @@ -41,6 +41,7 @@ MACOSX_DEPLOYMENT_TARGET=13.0 \ cargo build --manifest-path "$ROOT_DIR/rust/Cargo.toml" -p lithe-db-mcp --target "$RUST_TARGET" cp "rust/target/macos/$RUST_TARGET/debug/lithe-db-mcp" "$APP_DIR/Contents/Helpers/lithe-db-mcp" cp Resources/Info.plist "$APP_DIR/Contents/Info.plist" +"$ROOT_DIR/scripts/stamp-macos-app-build-info.sh" "$APP_DIR/Contents/Info.plist" cp Resources/AppIcon.icns "$APP_DIR/Contents/Resources/AppIcon.icns" cp -R Resources/IDEAIcons "$APP_DIR/Contents/Resources/IDEAIcons" cp -R Resources/DatabaseIcons "$APP_DIR/Contents/Resources/DatabaseIcons" diff --git a/scripts/stamp-macos-app-build-info.sh b/scripts/stamp-macos-app-build-info.sh new file mode 100755 index 000000000..b64738b45 --- /dev/null +++ b/scripts/stamp-macos-app-build-info.sh @@ -0,0 +1,69 @@ +#!/bin/zsh +set -euo pipefail + +ROOT_DIR="${0:A:h:h}" + +if (( $# != 1 )); then + print -u2 -- "Usage: $0 path/to/Info.plist" + exit 2 +fi + +info_plist="$1" +if [[ ! -f "$info_plist" ]]; then + print -u2 -- "Missing app Info.plist: $info_plist" + exit 1 +fi + +cd "$ROOT_DIR" + +revision="${LITHE_BUILD_GIT_REVISION:-}" +if [[ -z "$revision" ]]; then + revision=$(git rev-parse --verify HEAD 2>/dev/null || true) +fi +[[ -n "$revision" ]] || revision="unknown" + +branch="${LITHE_BUILD_GIT_BRANCH:-${GITHUB_HEAD_REF:-${GITHUB_REF_NAME:-}}}" +if [[ -z "$branch" ]]; then + branch=$(git symbolic-ref --quiet --short HEAD 2>/dev/null || true) +fi +[[ -n "$branch" ]] || branch="detached" + +dirty="${LITHE_BUILD_GIT_DIRTY:-}" +if [[ -z "$dirty" ]]; then + if git rev-parse --is-inside-work-tree >/dev/null 2>&1 \ + && [[ -n "$(git status --porcelain --untracked-files=normal)" ]]; then + dirty="true" + else + dirty="false" + fi +fi +if [[ "$dirty" != "true" && "$dirty" != "false" ]]; then + print -u2 -- "LITHE_BUILD_GIT_DIRTY must be true or false" + exit 2 +fi + +build_timestamp="${LITHE_BUILD_TIMESTAMP:-$(date -u '+%Y-%m-%dT%H:%M:%SZ')}" + +set_plist_value() { + local key="$1" + local type="$2" + local value="$3" + + if /usr/libexec/PlistBuddy -c "Print :$key" "$info_plist" >/dev/null 2>&1; then + /usr/libexec/PlistBuddy -c "Set :$key $value" "$info_plist" + else + /usr/libexec/PlistBuddy -c "Add :$key $type $value" "$info_plist" + fi +} + +set_plist_value LitheBuildGitRevision string "$revision" +set_plist_value LitheBuildGitBranch string "$branch" +set_plist_value LitheBuildGitDirty bool "$dirty" +set_plist_value LitheBuildTimestamp string "$build_timestamp" + +display_revision="$revision" +if (( ${#display_revision} > 12 )); then + display_revision="${display_revision[1,12]}" +fi +print -u2 -- "Lithe build source: $ROOT_DIR" +print -u2 -- "Lithe build identity: revision=$display_revision branch=$branch dirty=$dirty timestamp=$build_timestamp" diff --git a/scripts/verify-macos-app-build-safety.sh b/scripts/verify-macos-app-build-safety.sh new file mode 100755 index 000000000..c0852fd07 --- /dev/null +++ b/scripts/verify-macos-app-build-safety.sh @@ -0,0 +1,65 @@ +#!/bin/zsh +set -euo pipefail + +ROOT_DIR="${0:A:h:h}" +cd "$ROOT_DIR" + +workbench_path="Sources/Lithe/Views/Workbench/WorkbenchView.swift" +drawing_group_pattern='\.drawingGroup[[:space:]]*\(' +workbench_rasterization_violations=$( + /usr/bin/grep -En -- "$drawing_group_pattern" "$workbench_path" || true +) + +if ! print -r -- 'content.drawingGroup()' | /usr/bin/grep -Eq -- "$drawing_group_pattern"; then + print -u2 -- "The Workbench drawing-group safety pattern is not detecting the known failure form" + exit 1 +fi + +if [[ -n "$workbench_rasterization_violations" ]]; then + print -u2 -- "Workbench rendering safety violation:" + print -u2 -- "WorkbenchView contains AppKit-backed controls and must not use drawingGroup()." + print -u2 -- "SwiftUI otherwise fails with 'Unable to render flattened version' and displays yellow error tiles." + print -u2 -- "$workbench_rasterization_violations" + exit 1 +fi + +if ! /usr/bin/grep -Fq -- 'verify-macos-app-build-safety.sh' scripts/build-macos.sh; then + print -u2 -- "macOS builds must run the rendering safety gate" + exit 1 +fi + +for packaging_script in scripts/package-app.sh scripts/preview.sh; do + if ! /usr/bin/grep -Fq -- 'stamp-macos-app-build-info.sh' "$packaging_script"; then + print -u2 -- "$packaging_script must stamp traceable build metadata" + exit 1 + fi +done + +temporary_directory=$(mktemp -d "${TMPDIR:-/tmp}/lithe-build-info-verification.XXXXXX") +trap 'rm -rf -- "$temporary_directory"' EXIT +test_plist="$temporary_directory/Info.plist" +cp Resources/Info.plist "$test_plist" + +LITHE_BUILD_GIT_REVISION="0123456789abcdef0123456789abcdef01234567" \ +LITHE_BUILD_GIT_BRANCH="test/rendering-safety" \ +LITHE_BUILD_GIT_DIRTY="true" \ +LITHE_BUILD_TIMESTAMP="2026-01-02T03:04:05Z" \ + scripts/stamp-macos-app-build-info.sh "$test_plist" >/dev/null 2>&1 + +assert_plist_value() { + local key="$1" + local expected="$2" + local actual + actual=$(/usr/bin/plutil -extract "$key" raw "$test_plist") + if [[ "$actual" != "$expected" ]]; then + print -u2 -- "Unexpected $key in stamped app metadata: $actual" + exit 1 + fi +} + +assert_plist_value LitheBuildGitRevision "0123456789abcdef0123456789abcdef01234567" +assert_plist_value LitheBuildGitBranch "test/rendering-safety" +assert_plist_value LitheBuildGitDirty "true" +assert_plist_value LitheBuildTimestamp "2026-01-02T03:04:05Z" + +print -- "macOS app build safety verification passed" diff --git a/scripts/verify-service-boundaries.sh b/scripts/verify-service-boundaries.sh index 45b5cc0e4..edc986707 100755 --- a/scripts/verify-service-boundaries.sh +++ b/scripts/verify-service-boundaries.sh @@ -3,6 +3,7 @@ set -euo pipefail ROOT_DIR="${0:A:h:h}" cd "$ROOT_DIR" +"$ROOT_DIR/scripts/verify-macos-app-build-safety.sh" core_pattern='import (SwiftUI|AppKit|CoreServices)|\b(FileManager|UserDefaults|NSWorkspace|NSApp)\b|(^|[^A-Za-z])Process\(|(^|[^A-Za-z])Pipe\(|FileHandle' service_pattern='import (SwiftUI|AppKit)|\b(FileManager|UserDefaults|NSWorkspace|NSApp)\b|(^|[^A-Za-z])Process\(|(^|[^A-Za-z])Pipe\(|FileHandle|String\(contentsOf:|Data\(contentsOf:|write\(to:.*encoding:|\bMac[A-Z][A-Za-z]+\b|/opt/homebrew|/usr/local|/usr/bin' diff --git a/shared/contracts/application-boundary.md b/shared/contracts/application-boundary.md index f55ba1585..b7c73e92e 100644 --- a/shared/contracts/application-boundary.md +++ b/shared/contracts/application-boundary.md @@ -29,7 +29,7 @@ verification scripts are the executable source of boundary checks. | GitHub | remote parsing, trusted request plans, normalized branch comparisons and pull requests/reviews/comments, deterministic ordering, and stable errors | OAuth configuration, HTTPS, browser opening, and operating-system credential storage | | Runtime | Java/Maven requirements, normalized candidates, and effective toolchain references | JDK/Maven probing and executable paths | | Language tooling | provider catalog, local fallback results, complete LSP process/session runtime, capabilities, diagnostics, UTF-16 edits, and normalized feature results | executable/environment discovery and UI provider routing | -| Java/Maven | deterministic Maven-root selection, project structure, modules and profiles; compiler diagnostic parsing; Java source structure, symbols, code vision, run-configuration detection, and JDTLS adapter policy | JDK/Maven discovery, Java/Maven child processes, sockets, and JDB transport | +| Java/Maven/Spring | deterministic Maven-root selection, project structure, modules and profiles; compiler diagnostic parsing; Java source structure, symbols, code vision, run-configuration detection, Spring configuration/bean/endpoint indexing, and JDTLS adapter policy | JDK/Maven discovery, local dependency-repository selection, Java/Maven child processes, sockets, and JDB transport | | Run/Debug | versioned configuration documents, three-layer resolution, diagnostics, and platform-neutral launch plans | project file persistence, child processes, sockets, and JDB transport | | Terminal | input bytes, output bytes, lifecycle | PTY/ConPTY, shell and environment | | Local History | revision metadata, text content, restore result | persistence location and file operations | diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index fb02e5ca6..f4c3788d5 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -101,6 +101,7 @@ stable error code and a user-facing message: | `java.sourceDefinition` | Locate a Java type, method, or field declaration in source text | | `java.serverPort` | Parse Spring server port settings from properties or YAML text | | `java.structure` | Parse Java editor structure, implementation candidates, and inlay hints | +| `spring.index` | Build a deterministic Spring configuration, bean, injection, and endpoint index | | `runConfig.inspect` | Inspect `.lithe` run documents, versions, and staleness without writing files | | `runConfig.generate` | Generate deterministic Java/Maven configurations and toolchain requirements | | `runConfig.resolve` | Merge generated, project, and local layers and return diagnostics | @@ -219,9 +220,11 @@ worktree. Pathspecs must be workspace-relative and must not contain absolute paths or `..` components. `git.history` accepts `root`, an optional full `reference`, and `limit` (the -core clamps it to `1...5000`). It returns `references`, `commits`, and -`hasMore`; commit parents are explicit so clients can render merge topology -without re-parsing Git output. +core clamps it to `1...5000`). It returns `references`, `commits`, `hasMore`, +and the optional effective `userName` and `userEmail` from repository Git +configuration. Commit parents are explicit so clients can render merge +topology without re-parsing Git output. The identity fields let clients +implement a stable `me` filter without guessing from recent commits. `git.commit` accepts `root` and a revision, returning one `commit` object. `git.blame` accepts `root` and a workspace-relative `path`; its line numbers @@ -381,3 +384,27 @@ returns `foldRegions`, `implementationMarkers`, and `inlayHints`. Line numbers are zero-based because these values are editor offsets; UTF-16 columns and hidden ranges match the native text editor coordinate system. The parser is platform-independent and does not start a Java process or contact JDT. + +`spring.index` accepts `root`, workspace-relative `paths`, optional trusted +absolute `metadataRepositories` (and the legacy singular `metadataRepository`), +optional `textOverrides` keyed by relative path, and +`refreshDependencyMetadata`. The command reads Spring configuration +metadata from workspace JSON files and dependency JARs, indexes application +configuration documents and Java source, and returns deterministically ordered +`properties`, `values`, `propertyReferences`, `diagnostics`, `beans`, +`injections`, and `endpoints` collections. Locations use relative paths and +one-based lines and columns. + +`properties` include type, documentation, default value, and an optional Java +declaration. `values` include profile/override state and an optional declaration +target. `propertyReferences` represent Java `@Value` uses. Bean resolution +accounts for component names, `@Bean` aliases, interfaces, `@Qualifier`, +`@Resource`, `@Primary`, field injection, and constructor injection. Endpoint +entries expand multiple controller/method paths and retain the exact declared +HTTP method set. + +Dependency metadata is cached in the Rust process. Project-open indexing sets +`refreshDependencyMetadata` to `true`; debounced unsaved-buffer indexing leaves +it `false`, so editing Java or configuration files does not repeatedly traverse +and open the local dependency repository. The repository path is selected by +the platform composition layer and is never persisted in shared results. diff --git a/shared/fixtures/spring/basic.json b/shared/fixtures/spring/basic.json new file mode 100644 index 000000000..1d5c134c3 --- /dev/null +++ b/shared/fixtures/spring/basic.json @@ -0,0 +1,45 @@ +{ + "name": "Spring workspace semantic index", + "request": { + "command": "spring.index", + "payload": { + "root": "/fixture/workspace", + "paths": [ + "src/main/java/example/DemoProperties.java", + "src/main/java/example/GreetingController.java", + "src/main/resources/application-dev.yml" + ], + "metadataRepositories": [ + "/fixture/gradle-repository", + "/fixture/maven-repository" + ], + "refreshDependencyMetadata": true, + "textOverrides": { + "src/main/resources/application-dev.yml": "demo:\n enabled: true\n" + } + } + }, + "expected": { + "property": { + "name": "demo.enabled", + "sourcePath": "src/main/java/example/DemoProperties.java", + "sourceLine": 5 + }, + "value": { + "path": "src/main/resources/application-dev.yml", + "profile": "dev", + "overridesBaseValue": false + }, + "propertyReference": { + "key": "demo.enabled" + }, + "injection": { + "typeName": "GreetingService", + "qualifier": "primaryGreeting" + }, + "endpoint": { + "httpMethods": ["GET"], + "route": "/api/greeting" + } + } +}