Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
80341ce
fix(macos): load run configurations without waiting for the Spring index
Farewell0375 Aug 28, 2026
7749d98
fix(macos): 让被取代的 Spring 索引调度真正提前退出,并改用确定性测试同步
Farewell0375 Aug 28, 2026
4937710
Merge branch 'preview' into fix/300-run-config-load-ordering
Farewell0375 Aug 28, 2026
e4b49c1
test(macos): 覆盖 Run 与 Debug 入口在快照绑定项目之前的加载行为
Farewell0375 Aug 28, 2026
a53ee94
fix(macos): 生成运行配置前要求工作区快照已就绪
Farewell0375 Aug 29, 2026
4dbd8b6
Merge branch 'preview' into fix/300-run-config-load-ordering
1lck Aug 29, 2026
651e5e5
Merge branch 'preview' into fix/300-run-config-load-ordering
Farewell0375 Aug 29, 2026
131e3ae
fix(macos): 就绪判断纳入快照标识,并让未就绪的运行推迟而非降级执行
Farewell0375 Aug 29, 2026
a63d39a
test(macos): 覆盖已有配置且快照阻塞时运行必须推迟
Farewell0375 Aug 29, 2026
9ec8947
fix(macos): 快照标识与文件清单同源传递,并修好入口加载竞态
Farewell0375 Aug 29, 2026
2601e9c
Merge branch 'preview' into fix/300-run-config-load-ordering
1lck Aug 29, 2026
fa807e1
fix(macos): 收口快照回调身份与未就绪的生成/启动路径
Farewell0375 Aug 29, 2026
8216954
fix(macos): Restart 与跨工程入口共用就绪漏斗并保护 pending
Farewell0375 Aug 29, 2026
ef55aee
fix(macos): 用工作区世代闭环同路径重开的身份判定
Farewell0375 Aug 31, 2026
d4f152f
Merge remote-tracking branch 'upstream/preview' into fix/300-run-conf…
Farewell0375 Aug 31, 2026
9d661a0
fix(macos): ignore stale watcher context after workspace reopen
1lck Aug 31, 2026
fb04c11
Merge remote-tracking branch 'origin/fix/300-run-config-load-ordering…
Farewell0375 Aug 31, 2026
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 macos/Resources/zh-Hans.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -944,6 +944,8 @@
"Run configurations may be out of date" = "运行配置可能已过期";
"Project toolchain needs attention" = "项目工具链需要处理";
"Different JDK vendor selected" = "选择了不同的 JDK 发行版";
"Project is still loading" = "项目仍在加载中";
"Wait for the project to finish loading, then identify it again." = "请等待项目加载完成,然后重新识别。";
"Project identification complete" = "项目识别完成";
"Generated 1 runnable project entry." = "已生成 1 个可运行的项目入口。";
"Generated %lld runnable project entries." = "已生成 %lld 个可运行的项目入口。";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,30 @@ final class SpringFeatureModel: ObservableObject {
isIndexing = false
}

/// Starts a workspace index without making the caller wait for it. Opening a
/// project must not block build-system and run state behind Spring indexing,
/// which scales with the number of Java sources in the workspace.
func scheduleLoad(
workspaceURL: URL,
files: [URL],
textOverrides: [URL: String] = [:],
refreshDependencyMetadata: Bool = true
) {
reloadTask?.cancel()
Comment thread
1lck marked this conversation as resolved.
reloadTask = Task { @MainActor [weak self] in
// Cancellation is cooperative, so a schedule that was superseded
// before it started must return here instead of running a second
// full workspace index whose result the generation token discards.
guard !Task.isCancelled, let self else { return }
await self.load(
workspaceURL: workspaceURL,
files: files,
textOverrides: textOverrides,
refreshDependencyMetadata: refreshDependencyMetadata
)
}
}

func reset() {
reloadTask?.cancel()
reloadTask = nil
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ extension LitheWorkspaceModule.WorkspaceFeatureModel {
reloadProjectServices: @escaping @MainActor @Sendable () async -> Void,
refreshGit: @escaping @MainActor @Sendable () async -> Void,
updateHistoryVisibilityRules: @escaping @MainActor @Sendable (FileVisibilityRules) async -> Void,
onSnapshotLoaded: @escaping @MainActor @Sendable (WorkspaceSnapshot, Bool) async -> Void
onSnapshotLoaded: @escaping @MainActor @Sendable (URL, WorkspaceSnapshot, Bool) async -> Void
) {
configureProjection(
documentsProvider: {
Expand Down
337 changes: 322 additions & 15 deletions macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ extension AppModel {
}

func activateExecutionModule() async -> ExecutionFeatureAccess? {
// Run and Debug activate on demand, so they can arrive while the previous
// session's module graph is still being torn down. Activating first would
// hand back a run feature that teardown releases moments later, and the
// deferred action waiting on it would never be resumed.
await awaitModuleRuntimeShutdown()
if let mavenFeature = mavenFeatureIfActive,
let runFeature = runFeatureIfActive,
let tests = languageTestServiceIfActive,
Expand Down Expand Up @@ -54,6 +59,7 @@ extension AppModel {
}

func activateDebugModule() async -> DebugFeatureAccess? {
await awaitModuleRuntimeShutdown()
if let javaFeature = debugFeatureIfActive,
let genericFeature = genericDebugFeatureIfActive {
return DebugFeatureAccess(javaFeature: javaFeature, genericFeature: genericFeature)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ extension AppModel {
var isIndexingSpring: Bool { springFeature.isIndexing }
var rootNode: FileNode? { workspaceFeature.rootNode }
var projectFiles: [URL] { workspaceFeature.projectFiles }
/// Identifies the scan `projectFiles` came from, and is `nil` until one has
/// been applied. Callers that also need the file list read
/// `workspaceFeature.appliedSnapshot` once instead of combining the two.
var workspaceSnapshotID: UUID? { workspaceFeature.appliedSnapshot?.id }
var javaEnvironmentReport: JavaEnvironmentReport? {
runtimeFeature.javaEnvironmentReport
}
Expand Down
105 changes: 58 additions & 47 deletions macos/Sources/Lithe/Models/AppModel/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,9 @@ final class AppModel: ObservableObject, Identifiable {
private var workbenchBackgroundFeatureObservation: AnyCancellable?
private var isProjectSessionActive = true
private var fileVisibilityRulesObserverID: UUID?
/// Run or Debug that arrived before the workspace snapshot reached the run
/// feature. `loadProjectServices` resumes it once the inventory matches.
var pendingRunAction: PendingRunAction?
private var requestProjectOpen: ((URL) -> Void)?
private var didCloseProject: (() -> Void)?
private var securityScopedWorkspaceURL: URL?
Expand Down Expand Up @@ -499,7 +502,7 @@ final class AppModel: ObservableObject, Identifiable {
},
reloadProjectServices: { [weak self] in
guard let self, let workspaceURL = self.workspaceURL else { return }
await self.loadProjectServices(at: workspaceURL, files: self.projectFiles)
await self.loadProjectServicesForAppliedSnapshot(at: workspaceURL)
},
refreshGit: { [weak self] in
guard let feature = self?.gitFeatureIfActive else { return }
Expand All @@ -509,10 +512,17 @@ final class AppModel: ObservableObject, Identifiable {
guard let feature = await self?.activateHistoryModule() else { return }
await feature.updateVisibilityRules(rules.localHistoryRules)
},
onSnapshotLoaded: { [weak self] snapshot, isInitialLoad in
guard let self, let workspaceURL = self.workspaceURL else { return }
// WorkspaceFeatureModel requests the single Git refresh after this callback.
await self.loadProjectServices(at: workspaceURL, files: snapshot.files)
onSnapshotLoaded: { [weak self] workspaceURL, snapshot, isInitialLoad in
guard let self else { return }
// The rebuild already rejected a stale workspace before calling
// us, and it passes that workspace with the snapshot so this
// load cannot pair A.files with B's URL after a project switch.
await self.loadProjectServices(
at: workspaceURL,
Comment thread
1lck marked this conversation as resolved.
files: snapshot.files,
snapshotID: snapshot.id,
resumesDeferredRunAction: true
)
if isInitialLoad {
self.projectHistoryFeatureIfActive?.seed(files: snapshot.files)
}
Expand Down Expand Up @@ -723,22 +733,39 @@ final class AppModel: ObservableObject, Identifiable {
await shutdownModuleRuntime()
}

/// Starts this session's module-graph teardown, or joins the one already in
/// flight, and records it before returning.
///
/// Recording the task synchronously is what lets an on-demand activation
/// that follows a project switch see the teardown at all: the caller does not
/// block the switch on it, so an activation can otherwise start first and be
/// released moments later by this shutdown.
private func beginModuleRuntimeShutdown() {
guard moduleRuntimeShutdownTask == nil else { return }
let moduleRuntime = services.moduleRuntime
moduleRuntimeShutdownTask = Task { @MainActor [weak self] in
await moduleRuntime.shutdownAll()
guard let self else { return }
self.moduleRuntimeShutdownTask = nil
self.clearModuleBindings(for: .database)
}
}

/// Shuts down this session's module graph once, even when multiple
/// lifecycle paths request cleanup at the same time.
private func shutdownModuleRuntime() async {
if let moduleRuntimeShutdownTask {
await moduleRuntimeShutdownTask.value
return
}
beginModuleRuntimeShutdown()
await moduleRuntimeShutdownTask?.value
}

let moduleRuntime = services.moduleRuntime
let shutdownTask = Task { @MainActor in
await moduleRuntime.shutdownAll()
/// Lets an on-demand activation resume after a session teardown finishes.
///
/// A shutdown that starts while this wait is suspended is joined as well, so
/// activation never returns a capability the runtime is about to release.
func awaitModuleRuntimeShutdown() async {
while let shutdownTask = moduleRuntimeShutdownTask {
await shutdownTask.value
}
moduleRuntimeShutdownTask = shutdownTask
await shutdownTask.value
moduleRuntimeShutdownTask = nil
clearModuleBindings(for: .database)
}

private func reloadJavaRuntimeServices() {
Expand All @@ -754,32 +781,11 @@ final class AppModel: ObservableObject, Identifiable {
}
Task { [weak self] in
guard let self else { return }
await self.loadProjectServices(at: workspaceURL, files: self.projectFiles)
await self.loadProjectServicesForAppliedSnapshot(at: workspaceURL)
}
}
}

/// 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 {
let execution = await activateExecutionModule()
if let execution {
await execution.projectDevelopment.loadProject(at: workspaceURL, files: files)
}
prepareJavaLanguageServerForWorkspaceIfNeeded(
at: workspaceURL,
files: files
)
await springFeature.load(
workspaceURL: workspaceURL,
files: files,
textOverrides: Dictionary(uniqueKeysWithValues: openDocuments.map {
($0.url.standardizedFileURL, $0.text)
})
)
execution?.tests.discover(workspaceURL: workspaceURL, files: files)
}

var projectName: String {
workspaceURL?.lastPathComponent ?? "Lithe"
}
Expand Down Expand Up @@ -922,10 +928,7 @@ final class AppModel: ObservableObject, Identifiable {

func openProjectDirectly(_ url: URL) {
let normalizedURL = url.standardizedFileURL
Task { [weak self] in
guard let self else { return }
await self.shutdownModuleRuntime()
}
beginModuleRuntimeShutdown()
if let previousWorkspaceURL = workspaceURL {
workspaceFeature.persistWorkspaceSession(for: previousWorkspaceURL)
}
Expand All @@ -942,6 +945,8 @@ final class AppModel: ObservableObject, Identifiable {
runtimeFeature.openProject(at: normalizedURL)
mavenFeatureIfActive?.reset()
runFeatureIfActive?.reset()
pendingRunAction = nil
scheduleObjectWillChangeRelay()
debugFeatureIfActive?.reset()
genericDebugFeatureIfActive?.reset()
clearLanguageNavigationProjection()
Expand Down Expand Up @@ -976,11 +981,18 @@ final class AppModel: ObservableObject, Identifiable {
pendingProjectItemDeletion = nil
recentProjects = recentProjectsStore.record(normalizedURL, in: recentProjects)

// The rebuild belongs to this opening. Reopening the same path advances
// the generation, so a rebuild left over from the previous opening stops
// instead of publishing its snapshot into this one.
let generation = workspaceFeature.workspaceGeneration
Task {
_ = await workspaceFeature.rebuild(
at: normalizedURL,
rules: visibilityRules,
isCurrent: { [weak self] in self?.workspaceURL == normalizedURL }
isCurrent: { [weak self] in
self?.workspaceURL == normalizedURL
&& self?.workspaceFeature.workspaceGeneration == generation
}
)
}
}
Expand All @@ -1007,10 +1019,7 @@ final class AppModel: ObservableObject, Identifiable {

private func performCloseProject() {
cancelJavaLanguageServerPreparation()
Task { [weak self] in
guard let self else { return }
await self.shutdownModuleRuntime()
}
beginModuleRuntimeShutdown()
if let workspaceURL {
workspaceFeature.persistWorkspaceSession(for: workspaceURL)
}
Expand Down Expand Up @@ -1049,6 +1058,8 @@ final class AppModel: ObservableObject, Identifiable {
runtimeFeature.closeProject()
mavenFeatureIfActive?.reset()
runFeatureIfActive?.reset()
pendingRunAction = nil
scheduleObjectWillChangeRelay()
debugFeatureIfActive?.reset()
genericDebugFeatureIfActive?.reset()
javaFeature.stop()
Expand Down
41 changes: 30 additions & 11 deletions macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ final class MacServiceContainer {
processRegistry: ManagedProcessRegistry = ManagedProcessRegistry(),
moduleLaunchMode: ModuleLaunchMode = .normal,
moduleStore providedModuleStore: MacModuleConfigurationStore? = nil,
workspaceOperations providedWorkspaceOperations: (any WorkspaceOperations)? = nil,
runConfigurationOperations providedRunConfigurationOperations: (any RunConfigurationOperations)? = nil,
gitWatchContextProvider providedGitWatchContextProvider: (any GitWatchContextProviding)? = nil,
runExecutableResolver providedRunExecutableResolver: (any RunExecutableResolving)? = nil,
pluginRuntimeRecovery: MacPluginRuntimeRecoveryCoordinator? = nil,
authorizationCallbackRouter providedAuthorizationCallbackRouter: MacExternalAuthorizationCallbackRouter? = nil
) {
Expand All @@ -73,6 +77,10 @@ final class MacServiceContainer {
preferences: store
)
self.runConfigurationStore = runConfigurationStore
// Only the run-configuration boundary is overridable. The concrete store
// stays available for callers that need the macOS adapter itself.
let runConfigurationOperations: any RunConfigurationOperations =
providedRunConfigurationOperations ?? runConfigurationStore
let fileOperations = MacWorkspaceFileOperations()
let processRunner = MacProcessRunner()
let secureStore = MacLocalSecretStore()
Expand Down Expand Up @@ -301,11 +309,16 @@ final class MacServiceContainer {
do {
try moduleRegistry.register(ModuleFactory(manifest: ExecutionModule.moduleManifest, contributions: ExecutionModule.moduleContributions) {
ExecutionModule(makeGraph: {
let executableResolver = RunExecutableResolver(
runtimeService: runtimeService,
toolchainRegistry: runToolchainRegistry,
metadataResolver: ProcessRunToolchainMetadataResolver(processRunner: processRunner)
)
// Toolchain discovery inspects installed JDKs through
// processes, so it is overridable for the same reason the
// other ports here are: a caller that does not exercise
// toolchain resolution should not depend on the machine.
let executableResolver: any RunExecutableResolving = providedRunExecutableResolver
?? RunExecutableResolver(
runtimeService: runtimeService,
toolchainRegistry: runToolchainRegistry,
metadataResolver: ProcessRunToolchainMetadataResolver(processRunner: processRunner)
)
let graph = ExecutionFeatureGraph(
maven: MavenService(
runtimeService: runtimeService,
Expand All @@ -320,7 +333,7 @@ final class MacServiceContainer {
fileAccess: MacRunFileAccess(storage: fileStorage),
preferences: MacRunPreferenceStore(store: store),
serverPortParser: javaMavenOperations,
runConfigurationOperations: runConfigurationStore,
runConfigurationOperations: runConfigurationOperations,
executableResolver: executableResolver,
languageProviderCatalog: languagePackRegistry.catalog,
languageRunProviders: languagePackRegistry.runProviders,
Expand Down Expand Up @@ -395,7 +408,7 @@ final class MacServiceContainer {
processFactory: { MacStreamingProcess(processRegistry: processRegistry, moduleID: .debug) },
fileStorage: fileStorage,
javaMavenOperations: javaMavenOperations,
runConfigurationOperations: runConfigurationStore
runConfigurationOperations: runConfigurationOperations
),
adapterSessions: adapterSessions
)
Expand All @@ -406,7 +419,12 @@ final class MacServiceContainer {
preconditionFailure("Invalid execution/debug module graph: \(error.localizedDescription)")
}
let gitOperations = RustGitOperations(core: rustCore)
let workspaceOperations = RustWorkspaceOperations(core: rustCore)
let rustWorkspaceOperations = RustWorkspaceOperations(core: rustCore)
// Only the workspace snapshot boundary is overridable. Search and local
// history need the concrete Rust operations, which carry their own
// capabilities beyond WorkspaceOperations.
let workspaceOperations: any WorkspaceOperations =
providedWorkspaceOperations ?? rustWorkspaceOperations
let localHistoryOperations = RustLocalHistoryOperations(core: rustCore)
let markdownRenderer = RustMarkdownRendering(core: rustCore)
let markdownImageImporter = MarkdownImageImportService(storage: fileStorage)
Expand All @@ -418,11 +436,11 @@ final class MacServiceContainer {
)
})
try moduleRegistry.register(ModuleFactory(manifest: SearchModule.moduleManifest, contributions: SearchModule.moduleContributions) {
SearchModule(operations: workspaceOperations)
SearchModule(operations: rustWorkspaceOperations)
})
try moduleRegistry.register(ModuleFactory(manifest: HistoryModule.moduleManifest, contributions: HistoryModule.moduleContributions) {
HistoryModule(
workspaceAccess: MacLocalHistoryWorkspaceAccess(workspaceOperations: workspaceOperations, fileOperations: fileOperations),
workspaceAccess: MacLocalHistoryWorkspaceAccess(workspaceOperations: rustWorkspaceOperations, fileOperations: fileOperations),
storage: MacLocalHistoryStorage(storage: fileStorage),
operations: localHistoryOperations
)
Expand Down Expand Up @@ -493,7 +511,8 @@ final class MacServiceContainer {
fileOperations: fileOperations,
binaryFileViewerRegistry: binaryFileViewerRegistry,
projectRuntimeService: runtimeService,
gitWatchContextProvider: RustGitWatchContextProvider(core: rustCore),
gitWatchContextProvider: providedGitWatchContextProvider
?? RustGitWatchContextProvider(core: rustCore),
githubService: githubService,
secureStore: secureStore,
databaseSecureStore: databaseSecureStore,
Expand Down
6 changes: 6 additions & 0 deletions macos/Sources/Lithe/Views/Run/RunView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,12 @@ struct RunView: View {
)
}
switch feature.generationState {
case .projectNotReady:
return (
String(localized: "Project is still loading"),
String(localized: "Wait for the project to finish loading, then identify it again."),
"clock.fill"
)
case .succeeded(let entryCount):
return (
String(localized: "Project identification complete"),
Expand Down
Loading
Loading