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
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ macOS 设置侧栏现在只有七个一级入口:外观与行为、插件、

插件如果带来了新的设置,默认放进“插件”页;只有它新增的是上表中某个分组本来就该有的能力(例如新的版本控制系统)时,才放进那个分组。

“运行环境”和“环境来源”是构建分组的工具链通用框架页,位于旧项目页和运行配置前;工具名称、提供方及使用方来自贡献快照,不是给某个插件开的专用设置页。提供方自己的配置继续从插件页注入。当前页面只读,旧项目 JDK/Maven 页面保留到实际消费者迁移后再删除,完整进度见[工具链提案](../../proposed/architecture/2026-10-31-plugin-injected-toolchains.md)。

AI 分组内按“AI 服务商 → Agent 管理 → 指令 → 技能 → Hooks → MCP 配置 → AI 与提交”排列:先放基础配置,再放行为规则和扩展能力,最后放具体应用功能。顺序仍只在 `SettingsGroup.categories` 中声明,搜索过滤后的侧栏也沿用这一顺序。

### 二级折叠:AI › 资源库
Expand Down

Large diffs are not rendered by default.

198 changes: 198 additions & 0 deletions macos/Resources/en.lproj/Localizable.strings

Large diffs are not rendered by default.

198 changes: 198 additions & 0 deletions macos/Resources/zh-Hans.lproj/Localizable.strings

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ final class AppModelFeatureGraph {
let workspaceSession: WorkspaceSessionCoordinator
let workbenchBackground: WorkbenchBackgroundFeatureModel
let runtime: RuntimeSettingsFeatureModel
let toolchainSettings: ToolchainSettingsFeatureModel
let languageTooling: LanguageToolingFeatureModel
let workspace: WorkspaceFeatureModel
let github: GitHubFeatureModel
Expand Down Expand Up @@ -60,6 +61,20 @@ final class AppModelFeatureGraph {
directoryMarkStore: services.directoryMarkStore
)
self.workspace = workspace
services.toolchainSettingsDirectories = { [weak workspace] in
guard let root = workspace?.rootNode else { return [] }
let prefix = root.url.standardizedFileURL.path + "/"
var pending = root.children ?? []
var paths: [String] = []
while !pending.isEmpty && paths.count < 1_024 {
let node = pending.removeLast()
guard node.isDirectory else { continue }
let path = node.url.standardizedFileURL.path
if path.hasPrefix(prefix) { paths.append(String(path.dropFirst(prefix.count))) }
pending.append(contentsOf: node.children ?? [])
}
return paths
}
workspaceSession = WorkspaceSessionCoordinator(
recentProjectsStore: services.recentProjectsStore,
platformUI: services.platformUI,
Expand All @@ -71,6 +86,10 @@ final class AppModelFeatureGraph {
platform: services.workbenchBackgroundPlatform
)
runtime = RuntimeSettingsFeatureModel(service: services.projectRuntimeService)
toolchainSettings = ToolchainSettingsFeatureModel(
loader: services.toolchainSettingsLoader, loginShell: services.loginShellEnvironmentInspector,
inputWatcher: services.toolchainInputWatcher
)
languageTooling = LanguageToolingFeatureModel(
catalogSource: services.languageProviderCatalogSource,
catalogSnapshot: services.languageProviderCatalogSnapshot,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,13 @@ final class AppServices {
/// Installed plugins with their enabled state, for the New Project
/// catalog and the plugins that provide a generator's requirements.
var projectGeneratorSources: (@MainActor () -> [ProjectGeneratorPluginSource])?
/// Read-only plugin toolchain projection; absent until platform composition.
var toolchainSettingsLoader: (any ToolchainSettingsLoading)?
var toolchainInputWatcher: (any ToolchainInputWatching)?
/// Existing workspace tree projection; settings must not start a second filesystem scan.
var toolchainSettingsDirectories: (@MainActor () -> [String])?
/// The startup login-shell read, for settings; absent on platforms that do not read one.
var loginShellEnvironmentInspector: (any LoginShellEnvironmentInspecting)?
/// Only enabled, registered plugins inject rows. Existing launch metadata
/// can supply names, but never creates rows or triggers executable discovery.
var languageServerRegistrations: (@MainActor (LanguageProviderCatalog) -> [LanguageServerRegistration])?
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
import Combine
import Darwin
import Foundation
import LitheCoreContracts

/// Owns only settings presentation and read lifecycle, not runtime discovery or launch selection.
@MainActor
final class ToolchainSettingsFeatureModel: ObservableObject {
enum State: Equatable {
case idle, loading, ready, failed
}

@Published private(set) var snapshot: ToolchainSettingsSnapshot?
@Published private(set) var state: State = .idle
@Published private(set) var loadError: (any Error)?
@Published private(set) var workspaceURL: URL?
@Published private(set) var selectedDirectoryID = "."
@Published private(set) var expandedToolIDs: Set<String> = []
@Published private(set) var loginShell: LoginShellEnvironmentSnapshot?
@Published private(set) var isRereadingLoginShell = false

private let loader: (any ToolchainSettingsLoading)?
private let loginShellInspector: (any LoginShellEnvironmentInspecting)?
private let inputWatcher: (any ToolchainInputWatching)?
private let reloadDelay: Duration
private var generation = UUID()
private var reloadTask: Task<Void, Never>?
private var inputObservation: AnyObject?

init(
loader: (any ToolchainSettingsLoading)? = nil,
loginShell: (any LoginShellEnvironmentInspecting)? = nil,
inputWatcher: (any ToolchainInputWatching)? = nil,
reloadDelay: Duration = .milliseconds(350)
) {
self.loader = loader
self.loginShellInspector = loginShell
self.inputWatcher = inputWatcher
self.reloadDelay = reloadDelay
}

var selectedDirectory: ToolchainSettingsSnapshot.Directory? {
snapshot?.directories.first { $0.id == selectedDirectoryID }
}

var tools: [ToolchainSettingsSnapshot.Tool] {
snapshot?.tools.filter { $0.directoryID == selectedDirectoryID } ?? []
}

var providers: [ToolchainSettingsSnapshot.Provider] { snapshot?.providers ?? [] }
var diagnostics: [String] { snapshot?.diagnostics ?? [] }
var isLoading: Bool { state == .loading }

/// Each load revokes older results; callers retain and cancel their structured UI task.
func load(workspace: URL?) async {
reloadTask?.cancel()
reloadTask = nil
await loadSnapshot(workspace: workspace)
// A read the app started at launch is still running: show its outcome when it lands.
if loginShell?.state == .reading, !Task.isCancelled { await rereadLoginShell() }
}

/// Reads the login shell again, then the snapshot, whose candidates use the refreshed variables.
func rereadLoginShell() async {
guard let loginShellInspector, !isRereadingLoginShell else { return }
isRereadingLoginShell = true
let next = await loginShellInspector.reread()
isRereadingLoginShell = false
loginShell = next
guard !Task.isCancelled else { return }
await loadSnapshot(workspace: workspaceURL)
}

/// Any provider input revokes the whole snapshot, so old installations never mix with
/// new requirements. Nothing runs before the pages first load one.
func workspaceFilesChanged(_ changes: [WorkspaceFileChange]) {
guard let root = snapshot?.workspaceURL?.standardizedFileURL.path else { return }
let patterns = providers.flatMap(\.watchFiles)
let touched = changes.contains { change in
let path = change.fileURL.standardizedFileURL.path
guard path.hasPrefix(root + "/") else { return false }
let parts = path.dropFirst(root.count + 1).split(separator: "/").map(String.init)
return patterns.contains { pattern in
let count = pattern.split(separator: "/").count
return count <= parts.count && fnmatch(pattern, parts.suffix(count).joined(separator: "/"), FNM_PATHNAME) == 0
}
}
if touched { scheduleReload() }
}

/// Bursts of file events collapse into one reload; the latest reload replaces earlier ones.
private func scheduleReload() {
guard snapshot != nil else { return }
reloadTask?.cancel()
let delay = reloadDelay
reloadTask = Task { [weak self] in
try? await Task.sleep(for: delay)
guard !Task.isCancelled, let self else { return }
await self.loadSnapshot(workspace: self.workspaceURL)
}
}

private func observeInputs(of snapshot: ToolchainSettingsSnapshot) {
inputObservation = inputWatcher?.watch(paths: snapshot.watch) { [weak self] in
Task { @MainActor [weak self] in self?.scheduleReload() }
}
}

private func loadSnapshot(workspace: URL?) async {
let request = UUID()
generation = request
let changedWorkspace = workspaceURL != workspace
workspaceURL = workspace
if changedWorkspace {
snapshot = nil
selectedDirectoryID = "."
expandedToolIDs = []
}
loginShell = loginShellInspector?.inspect()
loadError = nil
state = .loading
do {
guard let loader else { throw SnapshotError.loaderUnavailable }
let next = try await loader.load(workspace: workspace)
guard generation == request else { return }
try Task.checkCancellation()
guard next.workspaceURL == workspace else {
throw SnapshotError.workspaceMismatch
}
snapshot = next
observeInputs(of: next)
if !next.directories.contains(where: { $0.id == selectedDirectoryID }) {
selectedDirectoryID = next.directories.first?.id ?? "."
}
expandedToolIDs.formIntersection(Set(next.tools.map(\.id)))
state = .ready
} catch is CancellationError {
guard generation == request else { return }
state = .idle
} catch {
guard generation == request else { return }
loadError = error
state = .failed
}
}

func selectDirectory(_ id: String) {
guard snapshot?.directories.contains(where: { $0.id == id }) == true else { return }
selectedDirectoryID = id
}

func toggleExpanded(_ toolID: String) {
guard tools.contains(where: { $0.id == toolID }) else { return }
if expandedToolIDs.contains(toolID) {
expandedToolIDs.remove(toolID)
} else {
expandedToolIDs.insert(toolID)
}
}

/// Revocation happens before any suspended load can return to this actor.
func close() {
generation = UUID()
reloadTask?.cancel()
reloadTask = nil
inputObservation = nil
snapshot = nil
workspaceURL = nil
loadError = nil
state = .idle
selectedDirectoryID = "."
expandedToolIDs = []
}

private enum SnapshotError: Error {
case workspaceMismatch, loaderUnavailable
}
}
Loading
Loading