Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Resources/zh-Hans.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -604,6 +604,8 @@
"Program arguments" = "程序参数";
"Active Maven Profiles" = "启用的 Maven Profiles";
"Reset" = "重置";
"Project paths must stay inside the current project." = "项目路径必须位于当前项目内。";
"Open a project before choosing project paths." = "请先打开项目,再选择项目路径。";
"Local History" = "本地历史";
"Project Local History" = "项目本地历史";
"Restore" = "恢复";
Expand Down
6 changes: 3 additions & 3 deletions Sources/Lithe/Core/Rust/RustCoreBridge.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2232,9 +2232,9 @@ struct RustCoreBridge: Sendable {
arguments: options.arguments,
environment: options.environment,
mavenProfiles: options.activeProfiles.sorted(),
javaHomePath: scope == .local ? options.javaHomePath : "",
mavenExecutablePath: scope == .local ? options.mavenExecutablePath : "",
mavenJavaHomePath: scope == .local ? options.mavenJavaHomePath : ""
javaHomePath: options.javaHomePath,
mavenExecutablePath: options.mavenExecutablePath,
mavenJavaHomePath: options.mavenJavaHomePath
)
)
}
Expand Down
30 changes: 22 additions & 8 deletions Sources/Lithe/Services/Java/ProjectRuntimeService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -114,9 +114,11 @@ final class ProjectRuntimeService: ObservableObject {
}

func javaHomeURL(overridePath: String? = nil) -> URL? {
if let overridePath,
!normalizedPath(overridePath).isEmpty {
return runtimeLocator.validJavaHome(path: normalizedPath(overridePath))
if let overridePath {
let normalizedPath = normalizedOverridePath(overridePath)
if !normalizedPath.isEmpty {
return runtimeLocator.validJavaHome(path: normalizedPath)
}
}
let paths = [runtimeLocator.environment()["JAVA_HOME"]]
for path in paths.compactMap({ $0 }).map(normalizedPath).filter({ !$0.isEmpty }) {
Expand All @@ -137,8 +139,9 @@ final class ProjectRuntimeService: ObservableObject {
/// probes `java -version`, so capability checks can remain inert.
func configuredJavaExecutableURL(overridePath: String? = nil) -> URL? {
let paths: [String?]
if let overridePath, !normalizedPath(overridePath).isEmpty {
paths = [overridePath]
if let overridePath {
let normalizedPath = normalizedOverridePath(overridePath)
paths = normalizedPath.isEmpty ? [runtimeLocator.environment()["JAVA_HOME"]] : [normalizedPath]
} else {
paths = [runtimeLocator.environment()["JAVA_HOME"]]
}
Expand Down Expand Up @@ -167,9 +170,11 @@ final class ProjectRuntimeService: ObservableObject {
}

func mavenJavaHomeURL(overridePath: String? = nil) -> URL? {
if let overridePath,
!normalizedPath(overridePath).isEmpty {
return runtimeLocator.validJavaHome(path: normalizedPath(overridePath))
if let overridePath {
let normalizedPath = normalizedOverridePath(overridePath)
if !normalizedPath.isEmpty {
return runtimeLocator.validJavaHome(path: normalizedPath)
}
}
let paths = [runtimeLocator.environment()["JAVA_HOME"]]
for path in paths.compactMap({ $0 }).map(normalizedPath).filter({ !$0.isEmpty }) {
Expand Down Expand Up @@ -387,6 +392,15 @@ final class ProjectRuntimeService: ObservableObject {
return result
}

private func normalizedOverridePath(_ path: String) -> String {
let trimmedPath = path.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedPath.isEmpty else { return "" }
let normalized = normalizedPath(trimmedPath)
guard !(normalized as NSString).isAbsolutePath,
let projectURL else { return normalized }
return projectURL.appendingPathComponent(normalized).standardizedFileURL.path
}

private func normalizedPath(_ path: String) -> String {
((path as NSString).expandingTildeInPath as NSString).standardizingPath
}
Expand Down
124 changes: 99 additions & 25 deletions Sources/Lithe/Views/Run/JavaRunConfigurationEditorView.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import AppKit
import Foundation
import UniformTypeIdentifiers
import SwiftUI

struct RunConfigurationEditorView: View {
Expand All @@ -10,6 +11,8 @@ struct RunConfigurationEditorView: View {
@State private var environmentText: String
@State private var saveScope: RunConfigurationSaveScope = .local
@State private var saveError: String?
@State private var activePathPicker: PathPicker?
@State private var isPathPickerPresented = false

init(feature: RunFeatureModel, configuration: RunConfiguration) {
self.feature = feature
Expand Down Expand Up @@ -59,7 +62,8 @@ struct RunConfigurationEditorView: View {
}
Button("Done") {
options.environment = Self.environment(from: environmentText)
if feature.updateOptions(options, for: configuration, scope: saveScope) {
guard let scopedOptions = scopedOptionsForSave() else { return }
if feature.updateOptions(scopedOptions, for: configuration, scope: saveScope) {
dismiss()
} else {
saveError = feature.configurationSaveError
Expand All @@ -75,6 +79,12 @@ struct RunConfigurationEditorView: View {
.frame(width: 520, height: 470)
.background(LitheTheme.window)
.preferredColorScheme(.dark)
.fileImporter(
isPresented: $isPathPickerPresented,
allowedContentTypes: activePathPicker?.allowedContentTypes ?? [.folder]
) { result in
selectPath(result)
}
}

private var effectiveCapabilities: RunConfigurationCapabilities {
Expand Down Expand Up @@ -161,23 +171,21 @@ struct RunConfigurationEditorView: View {
text: stringBinding(\.javaHomePath),
chooseDirectory: { chooseDirectory(for: \.javaHomePath) }
)
.disabled(saveScope == .project)
}
if configuration.kind.isMavenBacked {
pathRow(
title: "Maven executable",
placeholder: "Use mvnw or detected Maven",
text: stringBinding(\.mavenExecutablePath),
chooseDirectory: { chooseFileOrDirectory(for: \.mavenExecutablePath) }
chooseDirectory: { chooseFileOrDirectory(for: \.mavenExecutablePath) },
chooseHelp: "Choose Maven executable or home"
)
.disabled(saveScope == .project)
pathRow(
title: "Maven JDK Home",
placeholder: "Use service JDK",
text: stringBinding(\.mavenJavaHomePath),
chooseDirectory: { chooseDirectory(for: \.mavenJavaHomePath) }
)
.disabled(saveScope == .project)
}
pathRow(
title: "Working directory",
Expand Down Expand Up @@ -274,7 +282,8 @@ struct RunConfigurationEditorView: View {
title: String,
placeholder: String,
text: Binding<String>,
chooseDirectory: @escaping () -> Void
chooseDirectory: @escaping () -> Void,
chooseHelp: String = "Choose directory"
) -> some View {
HStack(spacing: 8) {
Text(LocalizedStringKey(title))
Expand All @@ -286,7 +295,7 @@ struct RunConfigurationEditorView: View {
LitheSystemIcon(systemImage: "folder")
}
.litheIconButton()
.help("Choose directory")
.help(chooseHelp)
}
.font(.system(size: 12))
}
Expand Down Expand Up @@ -326,26 +335,91 @@ struct RunConfigurationEditorView: View {
}

private func chooseDirectory(for keyPath: WritableKeyPath<RunOptions, String>) {
let panel = NSOpenPanel()
panel.title = "Choose Directory"
panel.prompt = "Choose"
panel.canChooseFiles = false
panel.canChooseDirectories = true
panel.allowsMultipleSelection = false
if panel.runModal() == .OK, let url = panel.url {
options[keyPath: keyPath] = url.path
}
presentPathPicker(.directory(keyPath))
}

private func chooseFileOrDirectory(for keyPath: WritableKeyPath<RunOptions, String>) {
let panel = NSOpenPanel()
panel.title = "Choose Maven Executable or Home"
panel.prompt = "Choose"
panel.canChooseFiles = true
panel.canChooseDirectories = true
panel.allowsMultipleSelection = false
if panel.runModal() == .OK, let url = panel.url {
options[keyPath: keyPath] = url.path
presentPathPicker(.fileOrDirectory(keyPath))
}

private func presentPathPicker(_ picker: PathPicker) {
activePathPicker = picker
isPathPickerPresented = true
}

private func selectPath(_ result: Result<URL, Error>) {
defer { activePathPicker = nil }
switch result {
case .success(let url):
guard let activePathPicker else { return }
if saveScope == .project {
guard let projectURL = model.workspaceURL,
let path = projectRelativePath(url.path, root: projectURL) else {
saveError = String(localized: "Project paths must stay inside the current project.")
return
}
options[keyPath: activePathPicker.keyPath] = path
} else {
options[keyPath: activePathPicker.keyPath] = url.path
}
saveError = nil
case .failure(let error):
let cocoaError = error as NSError
guard !(cocoaError.domain == NSCocoaErrorDomain && cocoaError.code == NSUserCancelledError) else { return }
saveError = error.localizedDescription
}
}

private func scopedOptionsForSave() -> RunOptions? {
guard saveScope == .project else { return options }
guard let projectURL = model.workspaceURL else {
saveError = String(localized: "Open a project before choosing project paths.")
return nil
}
var scopedOptions = options
for keyPath in [
\.javaHomePath,
\.mavenExecutablePath,
\.mavenJavaHomePath,
\.workingDirectoryPath
] as [WritableKeyPath<RunOptions, String>] {
let value = scopedOptions[keyPath: keyPath].trimmingCharacters(in: .whitespacesAndNewlines)
guard !value.isEmpty else { continue }
guard let relativePath = projectRelativePath(value, root: projectURL) else {
saveError = String(localized: "Project paths must stay inside the current project.")
return nil
}
scopedOptions[keyPath: keyPath] = relativePath
}
return scopedOptions
}

private func projectRelativePath(_ path: String, root: URL) -> String? {
let expandedPath = (path as NSString).expandingTildeInPath
guard (expandedPath as NSString).isAbsolutePath else { return path }
let rootPath = root.standardizedFileURL.path
let selectedPath = URL(fileURLWithPath: expandedPath).standardizedFileURL.path
if selectedPath == rootPath { return "." }
let prefix = rootPath.hasSuffix("/") ? rootPath : rootPath + "/"
guard selectedPath.hasPrefix(prefix) else { return nil }
return String(selectedPath.dropFirst(prefix.count))
}

private enum PathPicker {
case directory(WritableKeyPath<RunOptions, String>)
case fileOrDirectory(WritableKeyPath<RunOptions, String>)

var keyPath: WritableKeyPath<RunOptions, String> {
switch self {
case .directory(let keyPath), .fileOrDirectory(let keyPath): keyPath
}
}

var allowedContentTypes: [UTType] {
switch self {
case .directory: [.folder]
case .fileOrDirectory: [.item]
}
}
}

Expand Down
6 changes: 0 additions & 6 deletions Sources/LitheExecutionModule/Services/RunService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -300,12 +300,6 @@ package final class RunService: ObservableObject {
scope: RunConfigurationSaveScope = .local
) -> Bool {
configurationSaveError = nil
var options = options
if scope == .project {
options.javaHomePath = ""
options.mavenExecutablePath = ""
options.mavenJavaHomePath = ""
}
if configurationStatus == .ready, let projectURL {
do {
try runConfigurationOperations.saveOptions(
Expand Down
37 changes: 37 additions & 0 deletions Tests/LitheTests/MavenRuntimeTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,24 @@ struct MavenRuntimeTests {
#expect(modules.map { $0.module.relativePath } == ["service"])
}

@Test
@MainActor
func projectRelativeJavaOverridesResolveAgainstProjectRoot() {
let root = FileManager.default.temporaryDirectory
.appendingPathComponent("lithe-project-relative-runtime", isDirectory: true)
.standardizedFileURL
let javaHome = root.appendingPathComponent("toolchains/jdk", isDirectory: true).standardizedFileURL
let mavenJavaHome = root.appendingPathComponent("toolchains/maven-jdk", isDirectory: true).standardizedFileURL
let service = ProjectRuntimeService(
runtimeLocator: ProjectRelativeRuntimeLocator(validJavaHomes: [javaHome.path, mavenJavaHome.path]),
store: EmptyKeyValueStore()
)
service.openProject(at: root)

#expect(service.javaHomeURL(overridePath: "toolchains/jdk") == javaHome)
#expect(service.mavenJavaHomeURL(overridePath: "toolchains/maven-jdk") == mavenJavaHome)
}

@Test
@MainActor
func canceledRuntimeDiscoveryClearsDiscoveringState() async throws {
Expand All @@ -111,6 +129,25 @@ struct MavenRuntimeTests {
}
}

private struct ProjectRelativeRuntimeLocator: RuntimeLocator {
let validJavaHomes: Set<String>

func environment() -> [String: String] { [:] }
func discover() -> RuntimeDiscoveryResult {
RuntimeDiscoveryResult(javaRuntimes: [], mavenRuntimes: [])
}
func validJavaHome(path: String) -> URL? {
validJavaHomes.contains(path) ? URL(fileURLWithPath: path, isDirectory: true) : nil
}
func javaRuntime(at homeURL: URL) -> JavaRuntimeCandidate? { nil }
func isExecutable(at url: URL) -> Bool { false }
func systemMavenExecutable() -> URL? { nil }
func mavenExecutable(forHomePath path: String) -> URL? { nil }
func mavenRuntime(at executableURL: URL) -> MavenRuntimeCandidate? { nil }
func systemJDBExecutable() -> URL? { nil }
func javaLanguageServerExecutable() -> URL? { nil }
}

private final class BlockingRuntimeLocator: RuntimeLocator, @unchecked Sendable {
private let lock = NSLock()
private let releaseSemaphore = DispatchSemaphore(value: 0)
Expand Down
Loading
Loading