diff --git a/.github/workflows/ci-macos.yml b/.github/workflows/ci-macos.yml index e916f7e84..4a9f185a7 100644 --- a/.github/workflows/ci-macos.yml +++ b/.github/workflows/ci-macos.yml @@ -81,7 +81,7 @@ jobs: needs: changes if: needs.changes.outputs.full == 'true' runs-on: macos-14 - timeout-minutes: 20 + timeout-minutes: 30 steps: - name: Check out source @@ -102,8 +102,16 @@ jobs: key: swiftpm-${{ runner.os }}-6.2-${{ hashFiles('Package.resolved') }} - name: Run Swift tests + id: swift-tests-primary + continue-on-error: true + timeout-minutes: 12 run: ./scripts/test-macos.sh + - name: Retry Swift tests after a failed attempt + if: steps.swift-tests-primary.outcome == 'failure' + timeout-minutes: 12 + run: ./scripts/test-macos.sh --skip-build + rust-tests: name: Rust Core and database tests needs: changes diff --git a/.gitignore b/.gitignore index b76a6f4d3..b49666a61 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ rust/target/ DerivedData/ /windows/build*/ dist/ +.artifacts/ Fixtures/**/target/ *.xcuserstate *.xcuserdata/ diff --git a/.planning/2026-08-17-project-tab-bar/findings.md b/.planning/2026-08-17-project-tab-bar/findings.md new file mode 100644 index 000000000..cbba9071e --- /dev/null +++ b/.planning/2026-08-17-project-tab-bar/findings.md @@ -0,0 +1,6 @@ +# Findings + +- The macOS reference renders `projectSessions.openProjects` in a horizontal `projectTabBar` below the title area. Each tab has a folder icon, project name, active styling, and direct activation. +- Windows already persists the same concept in `useWorkspaceTabsStore.projectTabs` and exposes `useFileSystemStore.switchToProject` plus `isSwitchingProject`. +- Windows currently renders `TitleProjectMenu` inside the title bar; its dropdown already lists open projects and should remain for project-management actions. +- `MainLayout` places `TitleBarWithSettings` immediately before the workbench and is the correct ownership boundary for a full-width tab strip. diff --git a/.planning/2026-08-17-project-tab-bar/progress.md b/.planning/2026-08-17-project-tab-bar/progress.md new file mode 100644 index 000000000..0afbc03af --- /dev/null +++ b/.planning/2026-08-17-project-tab-bar/progress.md @@ -0,0 +1,15 @@ +# Progress + +## 2026-08-17 + +- Recorded the pre-existing dirty worktree and branch before editing. +- Confirmed the macOS `WorkbenchView.projectTabBar` as the visual and interaction reference. +- Confirmed Windows has project tab persistence and a switch action but no top-level project tab presentation. +- User approved direct implementation using the macOS pattern. +- Added and ran the red model test, then implemented `getProjectTabBarItems` and confirmed the green result. +- Added and ran the red tab-bar contract test, then implemented the accessible tab bar component and confirmed the green result. +- Mounted `ProjectTabBar` below `TitleBarWithSettings` in `MainLayout`. +- Built the Windows Release application successfully with `scripts/build-windows.ps1 -Configuration Release`. +- Started the updated Release executable and verified through WebView2 CDP that the Chinese "打开的项目" tab list renders six projects, switches `aria-selected`, and updates the title-bar project label. +- Re-ran focused Bun tests (`2 pass`), related regression tests (`9 pass`), TypeScript typecheck, targeted lint, and `git diff --check` successfully. +- Completed the five-axis implementation review with no Critical or Important findings; unrelated pre-existing changes remain unstaged. diff --git a/.planning/2026-08-17-project-tab-bar/task_plan.md b/.planning/2026-08-17-project-tab-bar/task_plan.md new file mode 100644 index 000000000..8c026ffb8 --- /dev/null +++ b/.planning/2026-08-17-project-tab-bar/task_plan.md @@ -0,0 +1,26 @@ +# Project Tab Bar + +## Goal + +Add a Mac-style project tab bar below the Windows title bar so projects open in the current window are visible and directly switchable. + +## Phases + +- [complete] Explore existing macOS and Windows project-session patterns. +- [complete] Add failing model and interaction tests. +- [complete] Implement the project tab bar and connect project switching. +- [complete] Run frontend verification and live Windows checks. +- [complete] Review and create one focused implementation commit. + +## Scope + +- Worktree: `D:\code\Lithe-IDEA-preview-0.3.0` +- Branch: `fix/windows-new-window-blank` +- Preserve all existing dirty files and stage only this task's files. +- Keep the existing title-bar project dropdown and project persistence behavior. + +## Errors Encountered + +| Error | Attempt | Resolution | +| --- | --- | --- | +| No project-tab component exists in the Windows layout | 1 | Use the macOS `projectTabBar` structure as the reference and create a focused Windows presentation component. | diff --git a/README.md b/README.md index 126207abe..a8271ace7 100644 --- a/README.md +++ b/README.md @@ -118,7 +118,7 @@ When an external AI tool changes a project, Lithe helps you locate the affected ## Use Lithe -Lithe requires macOS 13 or later. Java project features require a JDK; JDK 17 or JDK 21 is recommended. Maven projects need either a project `mvnw` or a system Maven installation. Lightweight completion does not start an external process; when a matching language server is installed, Lithe routes the capabilities it actually advertises through the shared Rust LSP Core. See the [language tooling and LSP architecture](./docs/architecture/language-tooling.md) for provider configuration and compatibility details. +Lithe requires macOS 13 or later. Java project features require JDK 17 or newer; JDK 17 or JDK 21 is recommended. Release packages include Eclipse JDT Language Server for Java completion, navigation, references, and diagnostics, so JDTLS does not need to be installed separately. Maven projects need either a project `mvnw` or a system Maven installation. Lightweight completion does not start an external process; Lithe routes the capabilities a running language server actually advertises through the shared Rust LSP Core. See the [language tooling and LSP architecture](./docs/architecture/language-tooling.md) for provider configuration and compatibility details. Download the latest macOS `.dmg` from [GitHub Releases](https://github.com/1lck/Lithe-IDEA/releases/latest). If a release provides architecture-specific installers, choose `arm64` for Apple silicon or `x86_64` for an Intel Mac. Open the disk image, drag `Lithe.app` into `/Applications`, and launch it. diff --git a/README.zh-CN.md b/README.zh-CN.md index 0a1415442..6e486b8fe 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -118,7 +118,7 @@ Lithe 是一款面向 AI 时代打造的轻量通用型 IDE。它面向多语言 ## 如何使用 -Lithe 需要 macOS 13 或更高版本。Java 项目功能需要 JDK,推荐使用 JDK 17 或 JDK 21;Maven 项目需要项目自带 `mvnw` 或系统中可用的 Maven。轻量补全无需启动外部进程;安装相应语言服务器后,Lithe 会通过共享 Rust LSP Core 按服务器实际声明的能力提供语义功能。详细设计与自定义 provider 配置见[语言工具与 LSP 架构](./docs/architecture/language-tooling.md)。 +Lithe 需要 macOS 13 或更高版本。Java 项目功能需要 JDK 17 或更高版本,推荐使用 JDK 17 或 JDK 21;正式安装包已包含 Eclipse JDT Language Server,可直接提供 Java 补全、跳转、引用和诊断,无需单独安装 JDTLS。Maven 项目需要项目自带 `mvnw` 或系统中可用的 Maven。轻量补全无需启动外部进程;Lithe 会通过共享 Rust LSP Core 按运行中服务器实际声明的能力提供语义功能。详细设计与自定义 provider 配置见[语言工具与 LSP 架构](./docs/architecture/language-tooling.md)。 从 [GitHub Releases](https://github.com/1lck/Lithe-IDEA/releases/latest) 下载最新的 macOS `.dmg`。如果该版本提供独立架构安装包,M 系列芯片选择 `arm64`,Intel 芯片选择 `x86_64`。打开磁盘映像,将 `Lithe.app` 拖入 `/Applications` 后启动。 diff --git a/Resources/Fonts/JetBrainsMono-Bold.ttf b/Resources/Fonts/JetBrainsMono-Bold.ttf new file mode 100644 index 000000000..8c93043de Binary files /dev/null and b/Resources/Fonts/JetBrainsMono-Bold.ttf differ diff --git a/Resources/Fonts/JetBrainsMono-BoldItalic.ttf b/Resources/Fonts/JetBrainsMono-BoldItalic.ttf new file mode 100644 index 000000000..1ddf216d1 Binary files /dev/null and b/Resources/Fonts/JetBrainsMono-BoldItalic.ttf differ diff --git a/Resources/Fonts/JetBrainsMono-Italic.ttf b/Resources/Fonts/JetBrainsMono-Italic.ttf new file mode 100644 index 000000000..ccc9d6a5b Binary files /dev/null and b/Resources/Fonts/JetBrainsMono-Italic.ttf differ diff --git a/Resources/Fonts/JetBrainsMono-Regular.ttf b/Resources/Fonts/JetBrainsMono-Regular.ttf new file mode 100644 index 000000000..dff66cc50 Binary files /dev/null and b/Resources/Fonts/JetBrainsMono-Regular.ttf differ diff --git a/Resources/Fonts/OFL.txt b/Resources/Fonts/OFL.txt new file mode 100644 index 000000000..23a3dca4c --- /dev/null +++ b/Resources/Fonts/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/Resources/Info.plist b/Resources/Info.plist index 2b467189d..4a9ee1237 100644 --- a/Resources/Info.plist +++ b/Resources/Info.plist @@ -23,6 +23,46 @@ Lithe CFBundleIconFile AppIcon + CFBundleDocumentTypes + + + CFBundleTypeName + Plain Text Document + CFBundleTypeRole + Editor + LSHandlerRank + Alternate + LSItemContentTypes + + public.text + public.plain-text + public.source-code + net.daringfireball.markdown + + CFBundleTypeExtensions + + txt + md + markdown + java + json + xml + yaml + yml + toml + rs + swift + kt + js + ts + tsx + jsx + css + html + sql + + + CFBundleIdentifier app.lithe.desktop CFBundleInfoDictionaryVersion @@ -35,6 +75,8 @@ 0.1.0 CFBundleVersion 3 + ATSApplicationFontsPath + Fonts LSMinimumSystemVersion 13.0 LitheGitHubOAuthClientID diff --git a/Resources/en.lproj/Localizable.strings b/Resources/en.lproj/Localizable.strings index e9f1f06cb..9876bdca8 100644 --- a/Resources/en.lproj/Localizable.strings +++ b/Resources/en.lproj/Localizable.strings @@ -1,4 +1,8 @@ /* English is the default UI language. */ +"Search settings" = "Search settings"; +"Clear search" = "Clear search"; +"No settings found" = "No settings found"; +"Try a different search term." = "Try a different search term."; "Plugins" = "Plugins"; "Marketplace" = "Marketplace"; "Installed" = "Installed"; diff --git a/Resources/zh-Hans.lproj/Localizable.strings b/Resources/zh-Hans.lproj/Localizable.strings index 5543b8b8e..e49be9c7f 100644 --- a/Resources/zh-Hans.lproj/Localizable.strings +++ b/Resources/zh-Hans.lproj/Localizable.strings @@ -1,6 +1,10 @@ /* Core interface strings for Simplified Chinese. */ "Settings" = "设置"; "Close Settings" = "关闭设置"; +"Search settings" = "搜索设置"; +"Clear search" = "清除搜索"; +"No settings found" = "未找到设置"; +"Try a different search term." = "请尝试其他搜索词。"; "Project" = "项目"; "General" = "通用"; "Appearance" = "外观"; @@ -174,6 +178,13 @@ "The interface language changes immediately. English is the default." = "界面语言会立即生效。默认语言为英文。"; "Files" = "文件"; "Save changed files automatically" = "自动保存已修改的文件"; +"Logs" = "日志"; +"Log directory" = "日志目录"; +"Default directory" = "默认目录"; +"Selected directory" = "当前选择的目录"; +"Choose Directory" = "选择目录"; +"Choose Log Directory" = "选择日志目录"; +"Restore Default" = "恢复默认"; "Save after" = "保存延迟"; "Hidden paths" = "隐藏路径"; "One entry per line. Directory names hide matching folders; file entries support * and ?." = "每行输入一项。目录名用于隐藏匹配的文件夹;文件条目支持 * 和 ?。"; @@ -600,6 +611,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" = "恢复"; diff --git a/Sources/Lithe/Application/Features/DocumentFeatureModel.swift b/Sources/Lithe/Application/Features/DocumentFeatureModel.swift index 208153181..c0286e333 100644 --- a/Sources/Lithe/Application/Features/DocumentFeatureModel.swift +++ b/Sources/Lithe/Application/Features/DocumentFeatureModel.swift @@ -1,14 +1,56 @@ import Combine import Foundation +enum StandaloneFileOpenFailure: Error, Equatable { + case unavailable + case directory + case tooLarge + case notText + case readFailed + + var title: String { + switch self { + case .unavailable: "File is not available" + case .directory: "Folders cannot be opened as text" + case .tooLarge: "File is too large to open" + case .notText: "This file cannot be displayed as text" + case .readFailed: "Could not read this file" + } + } + + var detail: String { + switch self { + case .unavailable: + "The file no longer exists or Lithe does not have access to it." + case .directory: + "Open a text file instead of a folder." + case .tooLarge: + "Standalone text files are limited to 32 MB." + case .notText: + "Only UTF-8 text files are supported in the standalone editor." + case .readFailed: + "The file could not be read. Check its permissions and try again." + } + } +} + +enum StandaloneFileLoadState: Equatable { + case idle + case loading + case loaded + case failed(StandaloneFileOpenFailure) +} + /// Owns editor document lifecycle and persistence-facing state. Java services, /// local history, and UI notifications are supplied as callbacks by AppModel. @MainActor final class DocumentFeatureModel: ObservableObject { @Published private(set) var openDocuments: [EditorDocument] = [] @Published var activeDocumentID: UUID? + @Published private(set) var standaloneFileLoadState: StandaloneFileLoadState = .idle @Published private(set) var pendingCloseDocument: EditorDocument? @Published private(set) var isPendingProjectClose = false + @Published private(set) var projectTreeRevealRequest: ProjectTreeRevealRequest? private let operations: any WorkspaceOperations private let fileOperations: any WorkspaceFileOperations @@ -31,6 +73,8 @@ final class DocumentFeatureModel: ObservableObject { private var latestFileOpenRequestID: UUID? private var pendingCloseQueue: [EditorDocument] = [] private var pendingClosePreferredDocumentID: UUID? + private var standaloneOpenRequestID: UUID? + private var standaloneOpenTask: Task? init( operations: any WorkspaceOperations, @@ -82,16 +126,21 @@ final class DocumentFeatureModel: ObservableObject { } func reset() { + standaloneOpenTask?.cancel() + standaloneOpenTask = nil + standaloneOpenRequestID = nil autoSaveTasks.values.forEach { $0.cancel() } autoSaveTasks.removeAll() pendingFileOpenRequests.removeAll() latestFileOpenRequestID = nil pendingCloseDocument = nil + projectTreeRevealRequest = nil pendingCloseQueue = [] pendingClosePreferredDocumentID = nil isPendingProjectClose = false openDocuments = [] activeDocumentID = nil + standaloneFileLoadState = .idle } func openFile( @@ -100,10 +149,13 @@ final class DocumentFeatureModel: ObservableObject { displayPath: String? = nil ) { let normalizedURL = url.standardizedFileURL + let filePath = normalizedURL.path // Switching to an already-open document does not require file I/O. // Apply that state change synchronously so repeated tree clicks feel immediate. - if let existing = openDocuments.first(where: { $0.url == normalizedURL }) { + if let existing = openDocuments.first(where: { + $0.url.standardizedFileURL.path == filePath + }) { latestFileOpenRequestID = UUID() activeDocumentID = existing.id if !isReadOnly { @@ -120,13 +172,93 @@ final class DocumentFeatureModel: ObservableObject { ) } } + func openStandaloneFile(_ url: URL) { + let normalizedURL = url.standardizedFileURL + if let existing = openDocuments.first(where: { $0.url == normalizedURL }) { + activeDocumentID = existing.id + standaloneFileLoadState = .loaded + return + } + + standaloneOpenTask?.cancel() + let requestID = UUID() + standaloneOpenRequestID = requestID + standaloneFileLoadState = .loading + openDocuments = [] + activeDocumentID = nil + let fileStorage = self.fileStorage + standaloneOpenTask = Task { [weak self] in + guard let self else { return } + let result = await Task.detached(priority: .userInitiated) { + Self.readStandaloneFile(at: normalizedURL, using: fileStorage) + }.value + + guard self.standaloneOpenRequestID == requestID else { return } + self.standaloneOpenTask = nil + + guard case let .success(text) = result else { + if case let .failure(failure) = result { + self.standaloneFileLoadState = .failed(failure) + } + return + } + + let document = EditorDocument( + url: normalizedURL, + text: text, + modificationDate: EditorDocument.modificationDate(for: normalizedURL), + isReadOnly: false + ) + self.openDocuments = [document] + self.activeDocumentID = document.id + self.standaloneFileLoadState = .loaded + self.onDocumentCollectionChanged?() + self.onDocumentOpened?(document) + } + } + + nonisolated private static func readStandaloneFile( + at url: URL, + using fileStorage: any FileStorage + ) -> Result { + guard let metadata = fileStorage.metadata(for: url) else { + return .failure(.unavailable) + } + guard !metadata.isDirectory else { return .failure(.directory) } + guard metadata.isRegularFile else { return .failure(.unavailable) } + if let byteCount = metadata.byteCount, + byteCount > WorkspaceTextFilePolicy.standaloneFileByteLimit { + return .failure(.tooLarge) + } + + let data: Data + do { + data = try fileStorage.readData(from: url, options: []) + } catch { + return .failure(.readFailed) + } + guard data.count <= WorkspaceTextFilePolicy.standaloneFileByteLimit else { + return .failure(.tooLarge) + } + guard let text = String(data: data, encoding: .utf8), + WorkspaceTextFilePolicy.isPlainText(text) else { + return .failure(.notText) + } + return .success(text) + } + func openFileAsync( - _ normalizedURL: URL, + _ url: URL, isReadOnly: Bool, displayPath: String?, activateWhenReady: Bool ) async { - if let existing = openDocuments.first(where: { $0.url == normalizedURL }) { + let normalizedURL = url.standardizedFileURL + let filePath = normalizedURL.path + + if let existing = openDocuments.first(where: { + $0.url.standardizedFileURL.path == filePath + }) { if activateWhenReady { let requestID = UUID() latestFileOpenRequestID = requestID @@ -139,14 +271,19 @@ final class DocumentFeatureModel: ObservableObject { } let requestID = UUID() - guard pendingFileOpenRequests[normalizedURL.path] == nil else { return } - pendingFileOpenRequests[normalizedURL.path] = requestID + if let pendingRequestID = pendingFileOpenRequests[filePath] { + if activateWhenReady { + latestFileOpenRequestID = pendingRequestID + } + return + } + pendingFileOpenRequests[filePath] = requestID if activateWhenReady { latestFileOpenRequestID = requestID } defer { - if pendingFileOpenRequests[normalizedURL.path] == requestID { - pendingFileOpenRequests[normalizedURL.path] = nil + if pendingFileOpenRequests[filePath] == requestID { + pendingFileOpenRequests[filePath] = nil } } @@ -192,15 +329,26 @@ final class DocumentFeatureModel: ObservableObject { isReadOnly: isReadOnly, displayPath: displayPath ) - guard !openDocuments.contains(where: { $0.url == normalizedURL }) else { return } + guard !openDocuments.contains(where: { + $0.url.standardizedFileURL.path == filePath + }) else { return } openDocuments.append(document) - if activateWhenReady, latestFileOpenRequestID == requestID { + if latestFileOpenRequestID == requestID { activeDocumentID = document.id } onDocumentCollectionChanged?() onDocumentOpened?(document) } + func requestProjectTreeReveal(for fileURL: URL) { + projectTreeRevealRequest = ProjectTreeRevealRequest(fileURL: fileURL) + } + + func consumeProjectTreeRevealRequest(id: UUID) { + guard projectTreeRevealRequest?.id == id else { return } + projectTreeRevealRequest = nil + } + func openVirtualDocument( _ url: URL, text: String, diff --git a/Sources/Lithe/Application/Features/RuntimeSettingsFeatureModel.swift b/Sources/Lithe/Application/Features/RuntimeSettingsFeatureModel.swift index bf081aec3..d32dc7ce9 100644 --- a/Sources/Lithe/Application/Features/RuntimeSettingsFeatureModel.swift +++ b/Sources/Lithe/Application/Features/RuntimeSettingsFeatureModel.swift @@ -12,6 +12,10 @@ final class RuntimeSettingsFeatureModel: ObservableObject { @Published private(set) var javaEnvironmentReport: JavaEnvironmentReport? @Published private(set) var isDiscovering: Bool + var javaLanguageServerRuntimes: [JavaRuntimeCandidate] { + service.javaLanguageServerRuntimes + } + init(service: ProjectRuntimeService) { self.service = service _javaRuntimes = Published(initialValue: service.javaRuntimes) diff --git a/Sources/Lithe/Core/Ports/LogDirectoryProviding.swift b/Sources/Lithe/Core/Ports/LogDirectoryProviding.swift new file mode 100644 index 000000000..836f960fd --- /dev/null +++ b/Sources/Lithe/Core/Ports/LogDirectoryProviding.swift @@ -0,0 +1,5 @@ +import Foundation + +protocol LogDirectoryProviding { + var defaultLogDirectory: URL { get } +} diff --git a/Sources/Lithe/Core/Rust/RustCoreBridge.swift b/Sources/Lithe/Core/Rust/RustCoreBridge.swift index 0eb627541..4b912198b 100644 --- a/Sources/Lithe/Core/Rust/RustCoreBridge.swift +++ b/Sources/Lithe/Core/Rust/RustCoreBridge.swift @@ -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 ) ) } diff --git a/Sources/Lithe/LitheApp.swift b/Sources/Lithe/LitheApp.swift index a5195b053..852eaf0d8 100644 --- a/Sources/Lithe/LitheApp.swift +++ b/Sources/Lithe/LitheApp.swift @@ -5,7 +5,15 @@ private let litheProcessLaunchDate = Date() @MainActor final class LitheAppDelegate: NSObject, NSApplicationDelegate { - weak var projectSessions: ProjectSessionManager? + private var pendingFileURLs: [URL] = [] + weak var projectSessions: ProjectSessionManager? { + didSet { + guard let projectSessions else { return } + let pendingURLs = pendingFileURLs + pendingFileURLs.removeAll() + pendingURLs.forEach { projectSessions.openStandaloneFile($0) } + } + } var recordCleanPluginShutdown: (() -> Void)? var authorizationCallbackRouter: MacExternalAuthorizationCallbackRouter? @@ -13,12 +21,27 @@ final class LitheAppDelegate: NSObject, NSApplicationDelegate { true } + func applicationWillFinishLaunching(_ notification: Notification) { + // SwiftUI normally forwards this event to the delegate methods below, + // but older Finder/AppKit launch paths can bypass that forwarding. + NSAppleEventManager.shared().setEventHandler( + self, + andSelector: #selector(handleOpenDocuments(_:withReplyEvent:)), + forEventClass: AEEventClass(kCoreEventClass), + andEventID: AEEventID(kAEOpenDocuments) + ) + } + func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { guard let projectSessions else { return .terminateNow } return Self.confirmUnsavedDocuments(for: projectSessions) ? .terminateNow : .terminateCancel } func applicationWillTerminate(_ notification: Notification) { + NSAppleEventManager.shared().removeEventHandler( + forEventClass: AEEventClass(kCoreEventClass), + andEventID: AEEventID(kAEOpenDocuments) + ) projectSessions?.stopAllSessions() recordCleanPluginShutdown?() } @@ -29,7 +52,55 @@ final class LitheAppDelegate: NSObject, NSApplicationDelegate { } func application(_ application: NSApplication, open urls: [URL]) { - urls.forEach { authorizationCallbackRouter?.route($0) } + handleOpenedURLs(urls) + } + + // Finder can deliver document-open Apple Events through these older + // delegate methods, depending on whether the app was already running. + func application(_ sender: NSApplication, openFile filename: String) -> Bool { + handleOpenedURLs([URL(fileURLWithPath: filename)]) + return true + } + + func application(_ sender: NSApplication, openFiles filenames: [String]) { + handleOpenedURLs(filenames.map(URL.init(fileURLWithPath:))) + sender.reply(toOpenOrPrint: .success) + } + + @objc private func handleOpenDocuments( + _ event: NSAppleEventDescriptor, + withReplyEvent replyEvent: NSAppleEventDescriptor? + ) { + guard let fileList = event.paramDescriptor(forKeyword: keyDirectObject) else { return } + + var urls: [URL] = [] + guard fileList.numberOfItems > 0 else { return } + for index in 1...fileList.numberOfItems { + guard let aliasDescriptor = fileList.atIndex(index), + let fileURLDescriptor = aliasDescriptor.coerce(toDescriptorType: typeFileURL), + let url = URL(dataRepresentation: fileURLDescriptor.data, relativeTo: nil) else { + continue + } + urls.append(url) + } + + handleOpenedURLs(urls) + } + + private func handleOpenedURLs(_ urls: [URL]) { + for url in urls { + if url.scheme == "lithe" { + authorizationCallbackRouter?.route(url) + } else if url.isFileURL { + if let projectSessions { + projectSessions.openStandaloneFile(url) + } else if !pendingFileURLs.contains(where: { + $0.standardizedFileURL == url.standardizedFileURL + }) { + pendingFileURLs.append(url.standardizedFileURL) + } + } + } } static func confirmUnsavedDocuments(for projectSessions: ProjectSessionManager) -> Bool { @@ -60,11 +131,31 @@ struct LitheApp: App { @StateObject private var settings: AppSettings @StateObject private var projectSessions: ProjectSessionManager @StateObject private var memoryUsageMonitor: MemoryUsageMonitor + @StateObject private var frameRateMonitor = FrameRateMonitor() @StateObject private var updateChecker = UpdateChecker() + private let applicationLogWriter: MacApplicationLogWriter init() { let store = MacUserDefaultsStore() - let settings = AppSettings(store: store) + let settings = AppSettings( + store: store, + logDirectoryProvider: MacServiceContainer.makeLogDirectoryProvider() + ) + let applicationLogWriter = MacServiceContainer.makeApplicationLogWriter() + if !Self.redirectApplicationLogs(applicationLogWriter, to: settings.logDirectory), + settings.customLogDirectory != nil { + settings.setCustomLogDirectory(nil) + _ = Self.redirectApplicationLogs(applicationLogWriter, to: settings.defaultLogDirectory) + } + settings.addLogDirectoryObserver { [weak settings] directory in + guard !Self.redirectApplicationLogs(applicationLogWriter, to: directory), + settings?.customLogDirectory != nil else { return } + settings?.setCustomLogDirectory(nil) + } + self.applicationLogWriter = applicationLogWriter + MacBundledFontRegistry.registerFonts { message in + Self.appendApplicationLog(applicationLogWriter, message: message) + } let processRegistry = ManagedProcessRegistry() let moduleStore = MacModuleConfigurationStore(store: store) let pluginRuntimeRecovery = MacPluginRuntimeRecoveryCoordinator() @@ -98,8 +189,7 @@ struct LitheApp: App { _memoryUsageMonitor = StateObject(wrappedValue: MemoryUsageMonitor( startedAt: litheProcessLaunchDate, baselineReporter: { marker in - guard let data = (marker + "\n").data(using: .utf8) else { return } - FileHandle.standardError.write(data) + Self.appendApplicationLog(applicationLogWriter, message: marker + "\n") }, logsPerformanceBaseline: ProcessInfo.processInfo.environment["LITHE_PERFORMANCE_BASELINE"] == "1", processRegistry: processRegistry, @@ -112,6 +202,36 @@ struct LitheApp: App { } } + private static func redirectApplicationLogs( + _ writer: MacApplicationLogWriter, + to directory: URL + ) -> Bool { + do { + try writer.redirect(to: directory) + return true + } catch { + let message = "Could not redirect Lithe logs to \(directory.path): \(error.localizedDescription)\n" + if let data = message.data(using: .utf8) { + FileHandle.standardError.write(data) + } + return false + } + } + + private static func appendApplicationLog( + _ writer: MacApplicationLogWriter, + message: String + ) { + do { + try writer.append(message) + } catch { + let fallback = "Could not write Lithe log: \(error.localizedDescription)\n" + if let data = fallback.data(using: .utf8) { + FileHandle.standardError.write(data) + } + } + } + private var model: AppModel { projectSessions.activeModel } var body: some Scene { @@ -121,6 +241,7 @@ struct LitheApp: App { .environmentObject(projectSessions) .environmentObject(settings) .environmentObject(memoryUsageMonitor) + .environmentObject(frameRateMonitor) .environmentObject(updateChecker) .environment(\.locale, settings.language.locale) // SwiftUI does not consistently re-resolve every existing @@ -131,6 +252,7 @@ struct LitheApp: App { .preferredColorScheme(settings.themePreference.preferredColorScheme) .task { memoryUsageMonitor.start() + frameRateMonitor.start() } } .defaultSize( @@ -158,6 +280,11 @@ struct LitheApp: App { } .litheKeyboardShortcut(model.keyboardShortcutFeature.primaryKeyPress(for: "close-project")) .disabled(model.workspaceURL == nil) + + Button("Close File") { + model.closeStandaloneFile() + } + .disabled(model.standaloneFileURL == nil) } CommandGroup(replacing: .appSettings) { @@ -272,6 +399,19 @@ struct LitheApp: App { .disabled(model.workspaceURL == nil) } } + + Window(settingsWindowTitle(for: settings.language), id: LitheWindowID.settings) { + SettingsWindow( + model: model, + settings: settings + ) + .environmentObject(settings) + .environmentObject(updateChecker) + .environment(\.locale, settings.language.locale) + .preferredColorScheme(settings.themePreference.preferredColorScheme) + } + .defaultSize(width: 1040, height: 720) + .windowResizability(.contentMinSize) } private static var startupProjectURL: URL? { @@ -295,6 +435,76 @@ struct LitheApp: App { } } +private struct SettingsWindow: View { + @ObservedObject var model: AppModel + @ObservedObject var settings: AppSettings + @StateObject private var windowReference = SettingsWindowReference() + + var body: some View { + SettingsView( + settings: settings, + initialCategory: model.requestedSettingsCategory, + onDismiss: close + ) + .environmentObject(model) + .background( + SettingsWindowAccessor( + reference: windowReference, + title: settingsWindowTitle(for: settings.language) + ) + ) + .onDisappear { + model.isSettingsPresented = false + } + } + + private func close() { + model.isSettingsPresented = false + windowReference.window?.performClose(nil) + } +} + +@MainActor +private final class SettingsWindowReference: ObservableObject { + weak var window: NSWindow? +} + +private struct SettingsWindowAccessor: NSViewRepresentable { + let reference: SettingsWindowReference + let title: String + + func makeNSView(context: Context) -> NSView { + let view = NSView(frame: .zero) + configureWindow(for: view) + return view + } + + func updateNSView(_ view: NSView, context: Context) { + configureWindow(for: view) + } + + private func configureWindow(for view: NSView) { + DispatchQueue.main.async { + guard let window = view.window else { return } + reference.window = window + window.title = title + window.titlebarAppearsTransparent = true + window.titleVisibility = .visible + window.backgroundColor = NSColor(LitheTheme.settingsSurface) + window.standardWindowButton(.miniaturizeButton)?.isEnabled = false + window.standardWindowButton(.zoomButton)?.isEnabled = true + } + } +} + +private func settingsWindowTitle(for language: AppLanguage) -> String { + String( + localized: "Settings", + bundle: .main, + locale: language.locale + ) +} + private extension AppThemePreference { var preferredColorScheme: ColorScheme? { switch self { diff --git a/Sources/Lithe/Models/AppModel/AppModel+EditorIntelligence.swift b/Sources/Lithe/Models/AppModel/AppModel+EditorIntelligence.swift index 61cf76243..3ec1a2db0 100644 --- a/Sources/Lithe/Models/AppModel/AppModel+EditorIntelligence.swift +++ b/Sources/Lithe/Models/AppModel/AppModel+EditorIntelligence.swift @@ -1,6 +1,30 @@ import Foundation +struct LanguageSessionChromeSignature: Equatable { + var features: [String: LanguageServerFeatureSet] + var states: [String: LanguageServerSessionState] + var infos: [String: LanguageServerInfo] +} + extension AppModel { + func handleLanguageSessionChange() { + refreshEditorDiagnosticsStore() + let signature = LanguageSessionChromeSignature( + features: languageToolingSessionsIfActive?.languageServerFeatures ?? [:], + states: languageToolingSessionsIfActive?.languageServerStates ?? [:], + infos: languageToolingSessionsIfActive?.languageServerInfos ?? [:] + ) + guard signature != languageSessionChromeSignature else { return } + languageSessionChromeSignature = signature + scheduleObjectWillChangeRelay() + } + + func refreshEditorDiagnosticsStore() { + editorDiagnosticsStore.replace( + EditorDiagnostic.fromLanguageServerDiagnostics(languageDiagnostics) + ) + } + func refreshCodeVision(for fileURL: URL) async { let normalizedURL = fileURL.standardizedFileURL guard normalizedURL.pathExtension.lowercased() == "java", diff --git a/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift b/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift index 0f8b0548f..02873f53f 100644 --- a/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift +++ b/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift @@ -28,6 +28,35 @@ extension AppModel { } var openDocuments: [EditorDocument] { documentFeature.openDocuments } + var standaloneFileLoadState: StandaloneFileLoadState { + documentFeature.standaloneFileLoadState + } + var projectTreeRevealRequest: ProjectTreeRevealRequest? { + documentFeature.projectTreeRevealRequest + } + + func canRevealInProjectTree(_ url: URL) -> Bool { + projectTreeURL(for: url) != nil + } + + func projectTreeURL(for url: URL) -> URL? { + guard url.isFileURL else { return nil } + return ProjectTreeLocator.matchingURL(for: url, among: projectFiles) + } + + func revealInProjectTree(_ url: URL) { + guard let treeURL = projectTreeURL(for: url) else { + showNotification("This file is not in the current workspace") + return + } + selectedSidebar = .project + documentFeature.requestProjectTreeReveal(for: treeURL) + } + + func consumeProjectTreeRevealRequest(id: UUID) { + documentFeature.consumeProjectTreeRevealRequest(id: id) + } + var activeDocumentID: UUID? { get { documentFeature.activeDocumentID } set { @@ -49,16 +78,19 @@ extension AppModel { var isPendingProjectClose: Bool { documentFeature.isPendingProjectClose } var gitChanges: [GitChange] { gitFeatureIfActive?.gitChanges ?? [] } + var gitTreeStatusProjection: GitTreeStatusProjection { + gitFeatureIfActive?.gitTreeStatus ?? GitTreeStatusProjection(changes: []) + } 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) + return gitFeatureIfActive?.gitTreeStatus.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( + return gitFeatureIfActive?.gitTreeStatus.kind( relativePath: relativePath, isDirectory: isDirectory ) diff --git a/Sources/Lithe/Models/AppModel/AppModel+LanguageServerRuntime.swift b/Sources/Lithe/Models/AppModel/AppModel+LanguageServerRuntime.swift new file mode 100644 index 000000000..2e5022f07 --- /dev/null +++ b/Sources/Lithe/Models/AppModel/AppModel+LanguageServerRuntime.swift @@ -0,0 +1,108 @@ +import Foundation + +@MainActor +extension AppModel { + func chooseLanguageServerExecutable(providerName: String) -> URL? { + platformUI.chooseFile( + title: settings.language == .simplifiedChinese + ? "选择 \(providerName) 语言服务器" + : "Choose \(providerName) language server", + prompt: settings.language == .simplifiedChinese ? "选择" : "Choose" + ) + } + + func openLanguageServerDownload(_ url: URL) { + platformUI.open(url) + } + + func languageServerToolConfigurationDidChange(providerID: String) { + languageToolingFeature.toolConfigurationDidChange(providerID: providerID) + } + + func isLanguageServerDisabledInCurrentWorkspace(providerID: String) -> Bool { + languageToolingFeature.isDisabled(providerID) + } + + func setLanguageServerEnabled(_ enabled: Bool, providerID: String) { + if enabled { + languageToolingFeature.setEnabled(true, providerID: providerID) + } else { + languageToolingFeature.setEnabled(false, providerID: providerID) + } + } + + var javaLanguageServerJDKPath: String { + settings.javaLanguageServerJDKPath + } + + var detectedJavaLanguageServerJDKs: [JavaRuntimeCandidate] { + runtimeFeature.javaLanguageServerRuntimes + } + + func selectJavaLanguageServerJDK(_ runtime: JavaRuntimeCandidate) { + applyJavaLanguageServerJDKPath(runtime.homePath) + } + + func refreshJavaLanguageServerJDKs() async { + await runtimeFeature.refreshAvailableRuntimes() + } + + func useAutomaticJavaLanguageServerJDK() { + applyJavaLanguageServerJDKPath("") + } + + func chooseJavaLanguageServerJDK() { + guard let url = platformUI.chooseDirectory( + title: settings.language == .simplifiedChinese ? "选择 LSP 运行 JDK" : "Choose LSP Runtime JDK", + prompt: settings.language == .simplifiedChinese ? "选择" : "Choose" + ) else { return } + Task { [weak self] in + guard let self else { return } + guard let runtime = await self.services.projectRuntimeService + .inspectJavaLanguageServerRuntime(atPath: url.path) else { + self.showNotification(self.settings.language == .simplifiedChinese + ? "所选目录不是有效的 JDK Home" + : "The selected directory is not a valid JDK Home") + return + } + guard runtime.supportsJDTLS else { + self.showNotification(self.settings.language == .simplifiedChinese + ? "JDTLS 需要 JDK 17 或更高版本;所选版本为 \(runtime.version)" + : "JDTLS requires JDK 17 or newer; the selected version is \(runtime.version)") + return + } + self.applyJavaLanguageServerJDKPath(url.standardizedFileURL.path) + } + } + + func disableLanguageServerForCurrentWorkspace(providerID: String) { + languageToolingFeature.setEnabled(false, providerID: providerID) + } + + func prepareJavaLanguageServerRuntimeIfNeeded(for document: EditorDocument) -> Bool { + let path = settings.javaLanguageServerJDKPath.trimmingCharacters(in: .whitespacesAndNewlines) + if services.projectRuntimeService.isJavaLanguageServerRuntimePrepared(overridePath: path) { + return true + } + guard javaLanguageServerRuntimePreparationPath != path else { return false } + + javaLanguageServerRuntimePreparationTask?.cancel() + javaLanguageServerRuntimePreparationPath = path + javaLanguageServerRuntimePreparationTask = Task { [weak self, weak document] in + guard let self, let document else { return } + await self.services.projectRuntimeService.prepareJavaLanguageServerRuntime( + overridePath: path + ) + guard !Task.isCancelled, + self.javaLanguageServerRuntimePreparationPath == path else { return } + self.javaLanguageServerRuntimePreparationTask = nil + self.javaLanguageServerRuntimePreparationPath = nil + _ = self.activateLanguageServerIfAvailable(for: document) + } + return false + } + + private func applyJavaLanguageServerJDKPath(_ path: String) { + languageToolingFeature.selectJavaJDK(path) + } +} diff --git a/Sources/Lithe/Models/AppModel/AppModel.swift b/Sources/Lithe/Models/AppModel/AppModel.swift index c4eff7bb3..fe268aabf 100644 --- a/Sources/Lithe/Models/AppModel/AppModel.swift +++ b/Sources/Lithe/Models/AppModel/AppModel.swift @@ -40,6 +40,7 @@ enum SettingsCategory: String, CaseIterable, Identifiable { final class AppModel: ObservableObject, Identifiable { let id = UUID() @Published private(set) var workspaceURL: URL? + @Published private(set) var standaloneFileURL: URL? @Published var selectedSidebar: SidebarDestination = .project { didSet { if selectedSidebar == .changes, oldValue != .changes { @@ -68,14 +69,25 @@ final class AppModel: ObservableObject, Identifiable { /// Replace in Project 面板的搜索选项(Preserve Case、文件掩码等)。 @Published var projectReplaceOptions = ProjectSearchOptions.default @Published var selectedProjectReplacementPaths: Set = [] + let editorChrome = EditorChromeModel() + let editorDiagnosticsStore = EditorDiagnosticsStore() /// 编辑器当前选中的单行文本,供 Find/Replace in Files 预填查询词。 - @Published var editorSelectedText = "" + var editorSelectedText: String { + get { editorChrome.selectedText } + set { editorChrome.update(selectedText: newValue) } + } /// 递增令牌:搜索侧栏观察它来把焦点移回输入框。 @Published var searchSidebarFocusRequest = 0 - @Published var isFindBarVisible = false - @Published var findBarQuery = "" - @Published private(set) var findMatchCount = 0 - @Published private(set) var currentFindMatchIndex = 0 + var isFindBarVisible: Bool { + get { editorChrome.isFindBarVisible } + set { editorChrome.setFindBarVisible(newValue) } + } + var findBarQuery: String { + get { editorChrome.findBarQuery } + set { editorChrome.setFindBarQuery(newValue) } + } + var findMatchCount: Int { editorChrome.findMatchCount } + var currentFindMatchIndex: Int { editorChrome.currentFindMatchIndex } var projectItemEditRequest: ProjectItemEditRequest? { get { workspaceFeature.projectItemEditRequest } set { workspaceFeature.projectItemEditRequest = newValue } @@ -108,7 +120,10 @@ final class AppModel: ObservableObject, Identifiable { @Published var languageNavigationLocations: [LanguageNavigationLocation] = [] @Published var languageNavigationResultKind: LanguageNavigationResultKind = .definitions @Published var isLoadingLanguageNavigation = false - @Published var editorCaret: EditorCaret? + var editorCaret: EditorCaret? { + get { editorChrome.caret } + set { editorChrome.update(caret: newValue) } + } @Published var editorNavigationTarget: EditorNavigationTarget? let navigationHistoryFeature: NavigationHistoryFeatureModel var virtualDocumentProviderIDs: [URL: String] = [:] @@ -244,8 +259,9 @@ final class AppModel: ObservableObject, Identifiable { return combined } var editorDiagnostics: [URL: [EditorDiagnostic]] { - EditorDiagnostic.fromLanguageServerDiagnostics(languageDiagnostics) + editorDiagnosticsStore.diagnosticsByURL } + var languageSessionChromeSignature: LanguageSessionChromeSignature? private var workspaceFeatureObservation: AnyCancellable? private var githubFeatureObservation: AnyCancellable? private var runtimeFeatureObservation: AnyCancellable? @@ -264,79 +280,14 @@ final class AppModel: ObservableObject, Identifiable { isSettingsPresented = true } - func chooseLanguageServerExecutable(providerName: String) -> URL? { - platformUI.chooseFile( - title: settings.language == .simplifiedChinese - ? "选择 \(providerName) 语言服务器" - : "Choose \(providerName) language server", - prompt: settings.language == .simplifiedChinese ? "选择" : "Choose" - ) - } - - func openLanguageServerDownload(_ url: URL) { - platformUI.open(url) - } - - func languageServerToolConfigurationDidChange(providerID: String) { - languageToolingFeature.toolConfigurationDidChange(providerID: providerID) - } - - func isLanguageServerDisabledInCurrentWorkspace(providerID: String) -> Bool { - languageToolingFeature.isDisabled(providerID) - } - - func setLanguageServerEnabled(_ enabled: Bool, providerID: String) { - if enabled { - languageToolingFeature.setEnabled(true, providerID: providerID) - } else { - languageToolingFeature.setEnabled(false, providerID: providerID) - } - } - - var javaLanguageServerJDKPath: String { - settings.javaLanguageServerJDKPath - } - - var detectedJavaLanguageServerJDKs: [JavaRuntimeCandidate] { - runtimeFeature.javaRuntimes - } - - func selectJavaLanguageServerJDK(_ runtime: JavaRuntimeCandidate) { - applyJavaLanguageServerJDKPath(runtime.homePath) - } - - func refreshJavaLanguageServerJDKs() async { - await runtimeFeature.refreshAvailableRuntimes() - } - - func chooseJavaLanguageServerJDK() { - guard let url = platformUI.chooseDirectory( - title: settings.language == .simplifiedChinese ? "选择 LSP 运行 JDK" : "Choose LSP Runtime JDK", - prompt: settings.language == .simplifiedChinese ? "选择" : "Choose" - ) else { return } - guard services.projectRuntimeService.configuredJavaExecutableURL(overridePath: url.path) != nil else { - showNotification(settings.language == .simplifiedChinese - ? "所选目录不是有效的 JDK Home" - : "The selected directory is not a valid JDK Home") - return - } - applyJavaLanguageServerJDKPath(url.standardizedFileURL.path) - } - - private func applyJavaLanguageServerJDKPath(_ path: String) { - languageToolingFeature.selectJavaJDK(path) - } - - func disableLanguageServerForCurrentWorkspace(providerID: String) { - languageToolingFeature.setEnabled(false, providerID: providerID) - } - private var documentFeatureObservation: AnyCancellable? private var javaFeatureObservation: AnyCancellable? private var springFeatureObservation: AnyCancellable? private var navigationHistoryFeatureObservation: AnyCancellable? private var isObjectWillChangeRelayScheduled = false private var languageToolingObservation: AnyCancellable? + var javaLanguageServerRuntimePreparationTask: Task? + var javaLanguageServerRuntimePreparationPath: String? private var recentProjectsStore: RecentProjectsStore { services.recentProjectsStore } private var workbenchLayoutStore: WorkbenchLayoutStore { services.workbenchLayoutStore } @@ -600,10 +551,16 @@ final class AppModel: ObservableObject, Identifiable { self?.withHistoryModule { $0.recordExternalChanges(paths) } }, onDocumentCollectionChanged: { [weak self] in - self?.workspaceFeature.scheduleWorkspaceSessionPersistence() + guard let self, self.workspaceURL != nil else { return } + self.workspaceFeature.scheduleWorkspaceSessionPersistence() }, onProjectCloseReady: { [weak self] in - self?.performCloseProject() + guard let self else { return } + if self.workspaceURL != nil { + self.performCloseProject() + } else if self.standaloneFileURL != nil { + self.performCloseStandaloneFile() + } } ) documentFeatureObservation = documentFeature.objectWillChange.sink { [weak self] _ in @@ -623,6 +580,7 @@ final class AppModel: ObservableObject, Identifiable { self?.scheduleObjectWillChangeRelay() } springFeatureObservation = springFeature.objectWillChange.sink { [weak self] _ in + self?.refreshEditorDiagnosticsStore() self?.scheduleObjectWillChangeRelay() } fileVisibilityRulesObserverID = settings.addFileVisibilityRulesObserver { [weak self] in @@ -959,7 +917,8 @@ final class AppModel: ObservableObject, Identifiable { isRunVisible = false isTestsVisible = false isDebugVisible = false - editorCaret = nil + editorChrome.reset() + editorDiagnosticsStore.reset() editorNavigationTarget = nil navigationHistoryFeature.reset() virtualDocumentProviderIDs.removeAll() @@ -969,6 +928,7 @@ final class AppModel: ObservableObject, Identifiable { gitLogSearchQuery = "" projectHistoryFeatureIfActive?.reset() workspaceURL = normalizedURL + standaloneFileURL = nil let visibilityRules = settings.fileVisibilityRules workspaceFeature.beginWorkspace(at: normalizedURL, visibilityRules: visibilityRules) selectedSidebar = .project @@ -997,6 +957,14 @@ final class AppModel: ObservableObject, Identifiable { } } + func closeStandaloneFile() { + guard standaloneFileURL != nil else { return } + guard documentFeature.beginProjectClose() else { + performCloseStandaloneFile() + return + } + } + private func performCloseProject() { Task { [weak self] in guard let self else { return } @@ -1010,6 +978,7 @@ final class AppModel: ObservableObject, Identifiable { } stopAccessingWorkspace() workspaceURL = nil + standaloneFileURL = nil reloadLanguageProviderCatalog(for: nil) selectedSidebar = .project workspaceFeature.reset() @@ -1022,10 +991,8 @@ final class AppModel: ObservableObject, Identifiable { projectReplaceQuery = "" projectReplaceText = "" selectedProjectReplacementPaths = [] - isFindBarVisible = false - findBarQuery = "" - findMatchCount = 0 - currentFindMatchIndex = 0 + editorChrome.resetFindBar() + editorDiagnosticsStore.reset() projectHistoryFeatureIfActive?.reset() workspaceFeature.reset() gitFeatureIfActive?.reset() @@ -1048,7 +1015,7 @@ final class AppModel: ObservableObject, Identifiable { genericDebugFeatureIfActive?.reset() javaFeature.stop() springFeature.reset() - editorCaret = nil + editorChrome.reset() editorNavigationTarget = nil navigationHistoryFeature.reset() virtualDocumentProviderIDs.removeAll() @@ -1060,6 +1027,13 @@ final class AppModel: ObservableObject, Identifiable { didCloseProject?() } + private func performCloseStandaloneFile() { + standaloneFileURL = nil + documentFeature.reset() + editorChrome.resetFindBar() + didCloseProject?() + } + private func stopAccessingWorkspace() { guard let securityScopedWorkspaceURL else { return } platformUI.stopAccessingProject(securityScopedWorkspaceURL) @@ -1093,9 +1067,20 @@ final class AppModel: ObservableObject, Identifiable { ) { selectedChange = nil closeBranchComparison() + editorNavigationTarget = nil documentFeature.openFile(url, isReadOnly: isReadOnly, displayPath: displayPath) } + func openStandaloneFile(_ url: URL) { + let normalizedURL = url.standardizedFileURL + workspaceURL = nil + standaloneFileURL = normalizedURL + documentFeature.reset() + isFindBarVisible = false + findBarQuery = "" + documentFeature.openStandaloneFile(normalizedURL) + } + func javaIconKind(for url: URL) async -> LitheIconKind? { await JavaFileIconResolver.resolve(for: url, storage: services.fileStorage) } @@ -1136,8 +1121,8 @@ final class AppModel: ObservableObject, Identifiable { workspaceFeature.cancelProjectItemDeletion() } - func confirmProjectItemDeletion() async { - await workspaceFeature.confirmProjectItemDeletion() + func confirmProjectItemDeletion(_ request: ProjectItemDeletionRequest) async { + await workspaceFeature.confirmProjectItemDeletion(request) } func revealProjectItemInFinder(_ url: URL) { @@ -1291,9 +1276,12 @@ final class AppModel: ObservableObject, Identifiable { } @discardableResult - private func activateLanguageServerIfAvailable(for document: EditorDocument) -> Bool { + func activateLanguageServerIfAvailable(for document: EditorDocument) -> Bool { guard let workspaceURL, let descriptor = languageProviderCatalog.provider(for: document.url) else { return false } + if descriptor.id == "java", !prepareJavaLanguageServerRuntimeIfNeeded(for: document) { + return false + } if let ownership = services.pluginCatalog.languageSupport(for: document.url), ownership.declaration.languageServerModuleID != nil { let support = ownership.declaration @@ -1338,7 +1326,7 @@ final class AppModel: ObservableObject, Identifiable { guard let capability = value as? LitheLanguageIntelligenceModule.LanguageIntelligenceCapability else { return } self.cacheModuleCapability(capability, id: .languageIntelligence, moduleID: .languageIntelligence) self.observeModuleFeature(.languageIntelligence, observation: capability.sessions.objectWillChange.sink { [weak self] _ in - self?.scheduleObjectWillChangeRelay() + self?.handleLanguageSessionChange() }) capability.tools.onCandidatesChanged = { [weak self] providerID in guard let self, @@ -1391,14 +1379,11 @@ final class AppModel: ObservableObject, Identifiable { func showFindBar() { guard activeDocument != nil else { return } - isFindBarVisible = true + editorChrome.setFindBarVisible(true) } func hideFindBar() { - isFindBarVisible = false - findBarQuery = "" - findMatchCount = 0 - currentFindMatchIndex = 0 + editorChrome.resetFindBar() NotificationCenter.default.post(name: .litheFindDismiss, object: nil) } @@ -1411,7 +1396,7 @@ final class AppModel: ObservableObject, Identifiable { } func setFindBarQuery(_ query: String) { - findBarQuery = query + editorChrome.setFindBarQuery(query) NotificationCenter.default.post( name: .litheFindQueryChanged, object: nil, @@ -1428,9 +1413,7 @@ final class AppModel: ObservableObject, Identifiable { } func updateFindState(currentIndex: Int, count: Int) { - guard currentFindMatchIndex != currentIndex || findMatchCount != count else { return } - findMatchCount = count - currentFindMatchIndex = currentIndex + editorChrome.updateFindState(currentIndex: currentIndex, count: count) } func commitStagedChanges() async { diff --git a/Sources/Lithe/Models/AppModel/AppModelSupportTypes.swift b/Sources/Lithe/Models/AppModel/AppModelSupportTypes.swift index 989e17d9a..17c66275a 100644 --- a/Sources/Lithe/Models/AppModel/AppModelSupportTypes.swift +++ b/Sources/Lithe/Models/AppModel/AppModelSupportTypes.swift @@ -52,3 +52,28 @@ extension Notification.Name { static let litheFindNavigate = Notification.Name("litheFindNavigate") static let litheFindDismiss = Notification.Name("litheFindDismiss") } + +struct ProjectTreeRevealRequest: Equatable { + let id = UUID() + let fileURL: URL +} + +enum ProjectTreeLocator { + static func matchingURL(for url: URL, among projectFiles: [URL]) -> URL? { + let standardizedPath = url.standardizedFileURL.path + return projectFiles.first(where: { + $0.standardizedFileURL.path == standardizedPath + }) + } + + static func expandedDirectoryPaths(for fileURL: URL, rootURL: URL) -> Set { + let root = rootURL.standardizedFileURL + var directory = fileURL.standardizedFileURL.deletingLastPathComponent() + var paths = Set([root.path]) + while directory.path != root.path, directory.path.hasPrefix(root.path + "/") { + paths.insert(directory.path) + directory.deleteLastPathComponent() + } + return paths + } +} diff --git a/Sources/Lithe/Models/Editor/EditorChromeModel.swift b/Sources/Lithe/Models/Editor/EditorChromeModel.swift new file mode 100644 index 000000000..9e5e76091 --- /dev/null +++ b/Sources/Lithe/Models/Editor/EditorChromeModel.swift @@ -0,0 +1,54 @@ +import Combine +import Foundation + +/// Caret, selection, and Find in File chrome. These values change on arrow +/// keys and query keystrokes, so they live off `AppModel` and must not +/// republish the workbench tree. +@MainActor +final class EditorChromeModel: ObservableObject { + @Published private(set) var caret: EditorCaret? + @Published private(set) var selectedText = "" + @Published private(set) var isFindBarVisible = false + @Published private(set) var findBarQuery = "" + private(set) var findMatchCount = 0 + private(set) var currentFindMatchIndex = 0 + + func update(caret: EditorCaret?) { + guard self.caret != caret else { return } + self.caret = caret + } + + func update(selectedText: String) { + guard self.selectedText != selectedText else { return } + self.selectedText = selectedText + } + + func setFindBarVisible(_ isVisible: Bool) { + guard isFindBarVisible != isVisible else { return } + isFindBarVisible = isVisible + } + + func setFindBarQuery(_ query: String) { + guard findBarQuery != query else { return } + findBarQuery = query + } + + func updateFindState(currentIndex: Int, count: Int) { + guard currentFindMatchIndex != currentIndex || findMatchCount != count else { return } + objectWillChange.send() + currentFindMatchIndex = currentIndex + findMatchCount = count + } + + func resetFindBar() { + setFindBarVisible(false) + setFindBarQuery("") + updateFindState(currentIndex: 0, count: 0) + } + + func reset() { + update(caret: nil) + update(selectedText: "") + resetFindBar() + } +} diff --git a/Sources/Lithe/Models/Editor/EditorDiagnosticsStore.swift b/Sources/Lithe/Models/Editor/EditorDiagnosticsStore.swift new file mode 100644 index 000000000..54445c6dc --- /dev/null +++ b/Sources/Lithe/Models/Editor/EditorDiagnosticsStore.swift @@ -0,0 +1,22 @@ +import Combine +import Foundation + +/// Latest editor diagnostics for the open workspace. Language-server publish +/// storms stay on this object so the workbench tree does not rebuild. +@MainActor +final class EditorDiagnosticsStore: ObservableObject { + @Published private(set) var diagnosticsByURL: [URL: [EditorDiagnostic]] = [:] + + func replace(_ diagnostics: [URL: [EditorDiagnostic]]) { + guard diagnosticsByURL != diagnostics else { return } + diagnosticsByURL = diagnostics + } + + func reset() { + replace([:]) + } + + func diagnostics(for url: URL) -> [EditorDiagnostic] { + diagnosticsByURL[url.standardizedFileURL] ?? [] + } +} diff --git a/Sources/Lithe/Models/Editor/EditorDocument.swift b/Sources/Lithe/Models/Editor/EditorDocument.swift index 83bf418a5..5be65e60b 100644 --- a/Sources/Lithe/Models/Editor/EditorDocument.swift +++ b/Sources/Lithe/Models/Editor/EditorDocument.swift @@ -17,7 +17,11 @@ final class EditorDocument: ObservableObject, Identifiable, @unchecked Sendable private(set) var url: URL let isReadOnly: Bool let displayPath: String? - @Published var text: String + private var storedText: String + var text: String { + get { storedText } + set { replaceText(newValue, publish: true) } + } @Published private(set) var savedText: String @Published var hasExternalConflict = false private(set) var lastKnownModificationDate: Date? @@ -32,7 +36,7 @@ final class EditorDocument: ObservableObject, Identifiable, @unchecked Sendable self.url = url self.isReadOnly = isReadOnly self.displayPath = displayPath - self.text = text + self.storedText = text self.savedText = text self.lastKnownModificationDate = modificationDate } @@ -41,7 +45,22 @@ final class EditorDocument: ObservableObject, Identifiable, @unchecked Sendable displayPath?.split(separator: "/").last.map(String.init) ?? url.lastPathComponent } - var isDirty: Bool { text != savedText } + var isDirty: Bool { storedText != savedText } + + /// Keep the live NSTextView buffer in sync without waking SwiftUI on every + /// already-dirty keystroke. The first edit still publishes so the tab dirty + /// mark can appear. + func applyLiveEditorText(_ newText: String) { + replaceText(newText, publish: isDirty != (newText != savedText)) + } + + private func replaceText(_ newText: String, publish: Bool) { + guard storedText != newText else { return } + if publish { + objectWillChange.send() + } + storedText = newText + } func save() throws { guard !isReadOnly else { throw DocumentError.readOnly } diff --git a/Sources/Lithe/Models/Runtime/ProjectRuntimeModels.swift b/Sources/Lithe/Models/Runtime/ProjectRuntimeModels.swift index d51678957..c898ae41c 100644 --- a/Sources/Lithe/Models/Runtime/ProjectRuntimeModels.swift +++ b/Sources/Lithe/Models/Runtime/ProjectRuntimeModels.swift @@ -26,6 +26,8 @@ struct ProjectRuntimeSettings: Codable, Hashable, Sendable { } struct JavaRuntimeCandidate: Identifiable, Hashable, Sendable { + static let minimumJDTLSMajorVersion = 17 + let homePath: String let version: String let vendor: String @@ -36,6 +38,24 @@ struct JavaRuntimeCandidate: Identifiable, Hashable, Sendable { let vendor = vendor.isEmpty ? "JDK" : vendor return "\(vendor) \(version)" } + + var majorVersion: Int? { + let components = version + .split(whereSeparator: { !$0.isNumber }) + .compactMap { Int($0) } + switch components.first { + case 1: + return components.count > 1 ? components[1] : nil + case let major?: + return major + case nil: + return nil + } + } + + var supportsJDTLS: Bool { + majorVersion.map { $0 >= Self.minimumJDTLSMajorVersion } ?? false + } } struct MavenRuntimeCandidate: Identifiable, Hashable, Sendable { diff --git a/Sources/Lithe/Models/Settings/AppSettings.swift b/Sources/Lithe/Models/Settings/AppSettings.swift index adb33ba0b..be7be26a0 100644 --- a/Sources/Lithe/Models/Settings/AppSettings.swift +++ b/Sources/Lithe/Models/Settings/AppSettings.swift @@ -22,6 +22,7 @@ final class AppSettings: ObservableObject { static let javaLanguageServerJDKPath = "settings.javaLanguageServerJDKPath" static let commitMessageAI = "settings.commitMessageAI" static let keyboardShortcutOverrides = "settings.keyboardShortcutOverrides" + static let customLogDirectory = "settings.customLogDirectory" } private struct KeyboardShortcutOverridesPayload: Codable { @@ -32,6 +33,7 @@ final class AppSettings: ObservableObject { } private let defaults: any KeyValueStore + private let logDirectoryProvider: any LogDirectoryProviding @Published var colorTheme: AppColorTheme { didSet { @@ -77,11 +79,17 @@ final class AppSettings: ObservableObject { didSet { saveCommitMessageAI() } } @Published private(set) var keyboardShortcutOverrides: [String: [KeyboardShortcutBinding]] + @Published private(set) var customLogDirectory: URL? private var fileVisibilityRulesObservers: [UUID: () -> Void] = [:] + private var logDirectoryObservers: [UUID: (URL) -> Void] = [:] - init(store: any KeyValueStore) { + init( + store: any KeyValueStore, + logDirectoryProvider: any LogDirectoryProviding + ) { self.defaults = store + self.logDirectoryProvider = logDirectoryProvider colorTheme = AppColorTheme( rawValue: defaults.string(forKey: Key.colorTheme) ?? "" ) ?? .lithe @@ -95,7 +103,7 @@ final class AppSettings: ObservableObject { rawValue: defaults.string(forKey: Key.editorTabLayoutMode) ?? "" ) ?? .singleLine showCodeVision = defaults.object(forKey: Key.showCodeVision) as? Bool ?? true - autoSave = defaults.object(forKey: Key.autoSave) as? Bool ?? false + autoSave = defaults.object(forKey: Key.autoSave) as? Bool ?? true autoSaveDelay = defaults.object(forKey: Key.autoSaveDelay) as? Double ?? 1.5 terminalShell = TerminalShell(rawValue: defaults.string(forKey: Key.terminalShell) ?? "") ?? .system hiddenDirectoryNames = defaults.stringArray(forKey: Key.hiddenDirectories) @@ -110,6 +118,10 @@ final class AppSettings: ObservableObject { ) ?? .ask javaLanguageServerJDKPath = defaults.string(forKey: Key.javaLanguageServerJDKPath) ?? "" keyboardShortcutOverrides = Self.loadKeyboardShortcutOverrides(from: defaults) + customLogDirectory = defaults.string(forKey: Key.customLogDirectory).flatMap { path in + guard !path.isEmpty else { return nil } + return URL(fileURLWithPath: path, isDirectory: true).standardizedFileURL + } if let data = defaults.data(forKey: Key.commitMessageAI), let saved = try? JSONDecoder().decode(CommitMessageAISettings.self, from: data) { commitMessageAI = saved @@ -121,6 +133,27 @@ final class AppSettings: ObservableObject { var terminalShellPath: String? { terminalShell.path } + var defaultLogDirectory: URL { + logDirectoryProvider.defaultLogDirectory + } + + var logDirectory: URL { customLogDirectory ?? defaultLogDirectory } + + func setCustomLogDirectory(_ url: URL?) { + customLogDirectory = url?.standardizedFileURL + defaults.set(customLogDirectory?.path, forKey: Key.customLogDirectory) + for observer in logDirectoryObservers.values { + observer(logDirectory) + } + } + + @discardableResult + func addLogDirectoryObserver(_ observer: @escaping (URL) -> Void) -> UUID { + let id = UUID() + logDirectoryObservers[id] = observer + return id + } + var fileVisibilityRules: FileVisibilityRules { FileVisibilityRules( hiddenDirectoryNames: hiddenDirectoryNames, @@ -153,7 +186,7 @@ final class AppSettings: ObservableObject { tabWidth = 4 editorTabLayoutMode = .singleLine showCodeVision = true - autoSave = false + autoSave = true autoSaveDelay = 1.5 terminalShell = .system hiddenDirectoryNames = FileVisibilityRules.default.hiddenDirectoryNames @@ -162,6 +195,7 @@ final class AppSettings: ObservableObject { projectOpenBehavior = .ask javaLanguageServerJDKPath = "" commitMessageAI = .default + setCustomLogDirectory(nil) setKeyboardShortcutOverrides([:]) } diff --git a/Sources/Lithe/Models/Workspace/ProjectSessionManager.swift b/Sources/Lithe/Models/Workspace/ProjectSessionManager.swift index 51f39b117..ca9d074db 100644 --- a/Sources/Lithe/Models/Workspace/ProjectSessionManager.swift +++ b/Sources/Lithe/Models/Workspace/ProjectSessionManager.swift @@ -65,6 +65,20 @@ final class ProjectSessionManager: ObservableObject { refreshRecentProjects() } + func openStandaloneFile(_ url: URL) { + let model: AppModel + if activeModel.workspaceURL == nil && activeModel.standaloneFileURL == nil { + model = activeModel + } else { + activeModel.setProjectSessionActive(false) + model = modelFactory() + sessions.append(model) + configure(model) + activeSessionID = model.id + } + model.openStandaloneFile(url.standardizedFileURL) + } + func requestOpenProject(_ url: URL, from sourceSessionID: UUID) { let normalizedURL = url.standardizedFileURL if let existing = openProjects.first(where: { @@ -130,6 +144,21 @@ final class ProjectSessionManager: ObservableObject { activeModel.closeProject() } + func requestCloseActiveSession() -> Bool { + if activeModel.workspaceURL != nil { + closeActiveProject() + return false + } + if activeModel.standaloneFileURL != nil { + if activeModel.hasUnsavedDocuments { + activeModel.closeStandaloneFile() + return false + } + return true + } + return true + } + func closeProject(_ id: UUID) { guard sessions.contains(where: { $0.id == id }) else { return } if id != activeSessionID { @@ -185,9 +214,14 @@ final class ProjectSessionManager: ObservableObject { self.removeClosedSession(model) } ) - modelObservations[model.id] = model.objectWillChange.sink { [weak self] _ in - self?.objectWillChange.send() - } + // Only workspace open/close should wake the window chrome. Relaying + // every AppModel tick rebuilds every mounted project session. + modelObservations[model.id] = model.$workspaceURL + .removeDuplicates() + .dropFirst() + .sink { [weak self] _ in + self?.objectWillChange.send() + } } private func removeClosedSession(_ model: AppModel) { diff --git a/Sources/Lithe/Models/Workspace/WorkspaceTextFilePolicy.swift b/Sources/Lithe/Models/Workspace/WorkspaceTextFilePolicy.swift index b111bbaa8..c49ab530d 100644 --- a/Sources/Lithe/Models/Workspace/WorkspaceTextFilePolicy.swift +++ b/Sources/Lithe/Models/Workspace/WorkspaceTextFilePolicy.swift @@ -1,6 +1,8 @@ import Foundation enum WorkspaceTextFilePolicy { + static let standaloneFileByteLimit = 32 * 1024 * 1024 + private static let extensions: Set = [ "c", "cc", "cpp", "css", "go", "h", "hpp", "html", "java", "js", "json", "jsx", "kt", "kts", "md", "m", "mm", "php", "plist", "properties", "py", "rb", diff --git a/Sources/Lithe/Platform/MacOS/Community/LinuxDoAnonymousWebView.swift b/Sources/Lithe/Platform/MacOS/Community/LinuxDoAnonymousWebView.swift index b7ca4ac38..6cce0b886 100644 --- a/Sources/Lithe/Platform/MacOS/Community/LinuxDoAnonymousWebView.swift +++ b/Sources/Lithe/Platform/MacOS/Community/LinuxDoAnonymousWebView.swift @@ -28,9 +28,10 @@ final class LinuxDoAnonymousWebSession: ObservableObject { releaseTask = nil } - func releaseAfterInactivity() { + @discardableResult + func releaseAfterInactivity() -> Task { releaseTask?.cancel() - releaseTask = Task { @MainActor [weak self] in + let task = Task { @MainActor [weak self] in guard let self else { return } try? await Task.sleep(nanoseconds: idleLifetimeNanoseconds) guard !Task.isCancelled else { return } @@ -40,6 +41,8 @@ final class LinuxDoAnonymousWebSession: ObservableObject { webView = nil releaseTask = nil } + releaseTask = task + return task } deinit { diff --git a/Sources/Lithe/Platform/MacOS/Logging/MacApplicationLogWriter.swift b/Sources/Lithe/Platform/MacOS/Logging/MacApplicationLogWriter.swift new file mode 100644 index 000000000..c40670940 --- /dev/null +++ b/Sources/Lithe/Platform/MacOS/Logging/MacApplicationLogWriter.swift @@ -0,0 +1,55 @@ +import Darwin +import Foundation + +final class MacApplicationLogWriter { + static let fileName = "lithe.log" + + private let lock = NSLock() + private var directory: URL? + + func redirect(to directory: URL) throws { + try FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: true + ) + let logURL = directory.appendingPathComponent(Self.fileName, isDirectory: false) + let descriptor: Int32 = logURL.withUnsafeFileSystemRepresentation { path in + guard let path else { return Int32(-1) } + return open(path, O_WRONLY | O_CREAT | O_APPEND, S_IRUSR | S_IWUSR) + } + guard descriptor >= 0 else { + throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) + } + close(descriptor) + + lock.lock() + self.directory = directory + lock.unlock() + } + + func append(_ message: String) throws { + lock.lock() + defer { lock.unlock() } + guard let directory else { + throw CocoaError(.fileNoSuchFile) + } + + let logURL = directory.appendingPathComponent(Self.fileName, isDirectory: false) + let descriptor: Int32 = logURL.withUnsafeFileSystemRepresentation { path in + guard let path else { return Int32(-1) } + return open(path, O_WRONLY | O_CREAT | O_APPEND, S_IRUSR | S_IWUSR) + } + guard descriptor >= 0 else { + throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) + } + defer { close(descriptor) } + + let data = Data(message.utf8) + let written = data.withUnsafeBytes { bytes in + Darwin.write(descriptor, bytes.baseAddress, bytes.count) + } + guard written == data.count else { + throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) + } + } +} diff --git a/Sources/Lithe/Platform/MacOS/Logging/MacLogDirectoryProvider.swift b/Sources/Lithe/Platform/MacOS/Logging/MacLogDirectoryProvider.swift new file mode 100644 index 000000000..98161e48c --- /dev/null +++ b/Sources/Lithe/Platform/MacOS/Logging/MacLogDirectoryProvider.swift @@ -0,0 +1,8 @@ +import Foundation + +struct MacLogDirectoryProvider: LogDirectoryProviding { + var defaultLogDirectory: URL { + FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask)[0] + .appendingPathComponent("Logs/Lithe", isDirectory: true) + } +} diff --git a/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift b/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift index 0d88d68f1..1ce2710dc 100644 --- a/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift +++ b/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift @@ -38,6 +38,14 @@ final class MacServiceContainer { let runConfigurationStore: MacRunConfigurationStore let moduleLifecycleCoordinator: ModuleLifecycleCoordinator + static func makeLogDirectoryProvider() -> any LogDirectoryProviding { + MacLogDirectoryProvider() + } + + static func makeApplicationLogWriter() -> MacApplicationLogWriter { + MacApplicationLogWriter() + } + init( store: any KeyValueStore, settings: AppSettings, @@ -210,11 +218,15 @@ final class MacServiceContainer { languageServerCore: rustCore, languageServerExecutableResolver: { tools.executableURL(for: $0) }, languageServerRuntimeResolver: { descriptor in - descriptor.id == "java" - ? runtimeService.configuredJavaExecutableURL( - overridePath: settings.javaLanguageServerJDKPath + guard descriptor.id == "java" else { return .notRequired } + guard let executableURL = runtimeService.javaLanguageServerExecutableURL( + overridePath: settings.javaLanguageServerJDKPath + ) else { + return .unavailable( + "JDTLS requires JDK 17 or newer. Configure a compatible JDK in Language Server settings." ) - : nil + } + return .available(executableURL) }, languageServerCacheDirectory: fileStorage.cacheDirectory() .appendingPathComponent("Lithe/language-servers", isDirectory: true), diff --git a/Sources/Lithe/Platform/MacOS/Process/MacProcessMemorySampler.swift b/Sources/Lithe/Platform/MacOS/Process/MacProcessMemorySampler.swift index 51c9de8c5..f086a2239 100644 --- a/Sources/Lithe/Platform/MacOS/Process/MacProcessMemorySampler.swift +++ b/Sources/Lithe/Platform/MacOS/Process/MacProcessMemorySampler.swift @@ -1,6 +1,23 @@ import Darwin import Foundation +// Command Line Tools SDKs ship libproc.h without exposing its functions through +// Swift's Darwin module, so bind the stable libSystem symbols locally. +@_silgen_name("proc_listallpids") +private func litheProcListAllPIDs( + _ buffer: UnsafeMutableRawPointer?, + _ bufferSize: Int32 +) -> Int32 + +@_silgen_name("proc_pidinfo") +private func litheProcPIDInfo( + _ processID: pid_t, + _ flavor: Int32, + _ argument: UInt64, + _ buffer: UnsafeMutableRawPointer?, + _ bufferSize: Int32 +) -> Int32 + /// macOS process-tree RSS sampler. The registry contains only roots owned by /// Lithe, so terminal and updater processes are never folded into the total. struct MacProcessMemorySampler: ManagedProcessMemorySampling { @@ -17,14 +34,14 @@ struct MacProcessMemorySampler: ManagedProcessMemorySampling { func residentMemoryBytes(for processIDs: Set) -> UInt64 { guard !processIDs.isEmpty else { return 0 } - let estimatedCount = max(1024, Int(proc_listallpids(nil, 0)) + 64) + let estimatedCount = max(1024, Int(litheProcListAllPIDs(nil, 0)) + 64) var all = [pid_t](repeating: 0, count: estimatedCount) - let count = Int(proc_listallpids(&all, Int32(all.count * MemoryLayout.stride))) + let count = Int(litheProcListAllPIDs(&all, Int32(all.count * MemoryLayout.stride))) guard count > 0 else { return direct(processIDs) } var parents: [pid_t: pid_t] = [:] for pid in all.prefix(count) where pid > 0 { var info = proc_bsdinfo() - let size = proc_pidinfo(pid, PROC_PIDTBSDINFO, 0, &info, Int32(MemoryLayout.size(ofValue: info))) + let size = litheProcPIDInfo(pid, PROC_PIDTBSDINFO, 0, &info, Int32(MemoryLayout.size(ofValue: info))) if size > 0 { parents[pid] = pid_t(info.pbi_ppid) } } let roots = Set(processIDs.map { pid_t($0) }) @@ -39,7 +56,7 @@ struct MacProcessMemorySampler: ManagedProcessMemorySampling { } guard belongs else { continue } var task = proc_taskinfo() - let size = proc_pidinfo(pid, PROC_PIDTASKINFO, 0, &task, Int32(MemoryLayout.size(ofValue: task))) + let size = litheProcPIDInfo(pid, PROC_PIDTASKINFO, 0, &task, Int32(MemoryLayout.size(ofValue: task))) if size > 0 { total += UInt64(task.pti_resident_size) } } return total @@ -48,7 +65,13 @@ struct MacProcessMemorySampler: ManagedProcessMemorySampling { private func direct(_ processIDs: Set) -> UInt64 { processIDs.reduce(0) { total, id in var task = proc_taskinfo() - let size = proc_pidinfo(pid_t(id), PROC_PIDTASKINFO, 0, &task, Int32(MemoryLayout.size(ofValue: task))) + let size = litheProcPIDInfo( + pid_t(id), + PROC_PIDTASKINFO, + 0, + &task, + Int32(MemoryLayout.size(ofValue: task)) + ) return total + (size > 0 ? UInt64(task.pti_resident_size) : 0) } } diff --git a/Sources/Lithe/Platform/MacOS/Runtime/MacRuntimeToolDiscovery.swift b/Sources/Lithe/Platform/MacOS/Runtime/MacRuntimeToolDiscovery.swift index 19598ab01..8c7c70bab 100644 --- a/Sources/Lithe/Platform/MacOS/Runtime/MacRuntimeToolDiscovery.swift +++ b/Sources/Lithe/Platform/MacOS/Runtime/MacRuntimeToolDiscovery.swift @@ -5,15 +5,18 @@ import Foundation /// package manager or installs anything on the user's behalf. struct MacRuntimeToolDiscovery: RuntimeToolDiscovery { private let homeDirectoryURL: URL + private let resourceDirectoryURL: URL? private let isExecutable: @Sendable (URL) -> Bool init( homeDirectoryURL: URL = FileManager.default.homeDirectoryForCurrentUser, + resourceDirectoryURL: URL? = Bundle.main.resourceURL, isExecutable: @escaping @Sendable (URL) -> Bool = { FileManager.default.isExecutableFile(atPath: $0.path) } ) { self.homeDirectoryURL = homeDirectoryURL.standardizedFileURL + self.resourceDirectoryURL = resourceDirectoryURL?.standardizedFileURL self.isExecutable = isExecutable } @@ -38,6 +41,14 @@ struct MacRuntimeToolDiscovery: RuntimeToolDiscovery { )) } + if command == "jdtls", let resourceDirectoryURL { + add( + resourceDirectoryURL.appendingPathComponent("LanguageServers/jdtls/bin/jdtls"), + source: .bundled, + detail: "Bundled with Lithe" + ) + } + // Project-local toolchains are preferred because they are reproducible // and do not alter the user's global environment. if let projectURL { diff --git a/Sources/Lithe/Platform/MacOS/UI/MacBundledFontRegistry.swift b/Sources/Lithe/Platform/MacOS/UI/MacBundledFontRegistry.swift new file mode 100644 index 000000000..0f734e1fb --- /dev/null +++ b/Sources/Lithe/Platform/MacOS/UI/MacBundledFontRegistry.swift @@ -0,0 +1,49 @@ +import AppKit +import CoreText +import Foundation + +enum MacBundledFontRegistry { + private static let fonts = [ + (resource: "JetBrainsMono-Regular", postScriptName: "JetBrainsMono-Regular"), + (resource: "JetBrainsMono-Italic", postScriptName: "JetBrainsMono-Italic"), + (resource: "JetBrainsMono-Bold", postScriptName: "JetBrainsMono-Bold"), + (resource: "JetBrainsMono-BoldItalic", postScriptName: "JetBrainsMono-BoldItalic") + ] + + static func registerFonts(bundle: Bundle = .main) { + registerFonts(bundle: bundle, reporter: report) + } + + static func registerFonts( + bundle: Bundle = .main, + reporter: (String) -> Void + ) { + guard bundle.url(forResource: "JetBrainsMono-Regular", withExtension: "ttf", subdirectory: "Fonts") != nil else { + return + } + + for font in fonts where NSFont(name: font.postScriptName, size: 13) == nil { + guard let url = bundle.url( + forResource: font.resource, + withExtension: "ttf", + subdirectory: "Fonts" + ) else { + reporter("Lithe font registration: Missing bundled font: \(font.resource).ttf\n") + continue + } + + var registrationError: Unmanaged? + guard CTFontManagerRegisterFontsForURL(url as CFURL, .process, ®istrationError) else { + let detail = registrationError?.takeRetainedValue().localizedDescription + ?? "Unknown CoreText error" + reporter("Lithe font registration: Could not register \(font.resource).ttf: \(detail)\n") + continue + } + } + } + + private static func report(_ message: String) { + guard let data = message.data(using: .utf8) else { return } + FileHandle.standardError.write(data) + } +} diff --git a/Sources/Lithe/Services/Java/ProjectRuntimeService.swift b/Sources/Lithe/Services/Java/ProjectRuntimeService.swift index 81da7e31e..a0173b86d 100644 --- a/Sources/Lithe/Services/Java/ProjectRuntimeService.swift +++ b/Sources/Lithe/Services/Java/ProjectRuntimeService.swift @@ -30,6 +30,11 @@ extension ProjectRuntimeService: MavenRuntimePort { @MainActor final class ProjectRuntimeService: ObservableObject { + private struct JavaLanguageServerRuntimePreparation { + let configuration: String + let runtime: JavaRuntimeCandidate? + } + @Published private(set) var projectURL: URL? @Published private(set) var javaRuntimes: [JavaRuntimeCandidate] = [] @Published private(set) var mavenRuntimes: [MavenRuntimeCandidate] = [] @@ -41,6 +46,7 @@ final class ProjectRuntimeService: ObservableObject { private let toolDiscovery: any RuntimeToolDiscovery private var discoveryTask: Task? private var activeDiscoveryID: UUID? + private var javaLanguageServerRuntimePreparation: JavaLanguageServerRuntimePreparation? init( runtimeLocator: any RuntimeLocator, @@ -114,9 +120,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 }) { @@ -137,8 +145,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"]] } @@ -150,6 +159,67 @@ final class ProjectRuntimeService: ObservableObject { return nil } + var javaLanguageServerRuntimes: [JavaRuntimeCandidate] { + javaRuntimes.filter(\.supportsJDTLS) + } + + func inspectJavaLanguageServerRuntime(atPath path: String) async -> JavaRuntimeCandidate? { + let normalized = normalizedPath(path.trimmingCharacters(in: .whitespacesAndNewlines)) + guard !normalized.isEmpty, (normalized as NSString).isAbsolutePath else { return nil } + let runtimeLocator = runtimeLocator + return await Task.detached(priority: .utility) { + guard let home = runtimeLocator.validJavaHome(path: normalized) else { return nil } + return runtimeLocator.javaRuntime(at: home) + }.value + } + + func isJavaLanguageServerRuntimePrepared(overridePath: String? = nil) -> Bool { + javaLanguageServerRuntimePreparation?.configuration + == normalizedJavaLanguageServerConfiguration(overridePath) + } + + // Session creation reads only this cache. Process-backed discovery and + // version probing must finish off the main actor before AppModel retries it. + func prepareJavaLanguageServerRuntime(overridePath: String? = nil) async { + let configuration = normalizedJavaLanguageServerConfiguration(overridePath) + guard javaLanguageServerRuntimePreparation?.configuration != configuration else { return } + + let runtime: JavaRuntimeCandidate? + if configuration.isEmpty { + if let cached = javaLanguageServerRuntimes.first { + runtime = cached + } else { + let runtimeLocator = runtimeLocator + runtime = await Task.detached(priority: .utility) { + runtimeLocator.discover().javaRuntimes.first(where: \.supportsJDTLS) + }.value + } + } else { + runtime = await inspectJavaLanguageServerRuntime(atPath: configuration) + } + + guard !Task.isCancelled else { return } + javaLanguageServerRuntimePreparation = JavaLanguageServerRuntimePreparation( + configuration: configuration, + runtime: runtime + ) + } + + func javaLanguageServerExecutableURL(overridePath: String? = nil) -> URL? { + let configuration = normalizedJavaLanguageServerConfiguration(overridePath) + guard let preparation = javaLanguageServerRuntimePreparation, + preparation.configuration == configuration, + let runtime = preparation.runtime, + runtime.supportsJDTLS, + let home = runtimeLocator.validJavaHome(path: runtime.homePath) else { return nil } + return home.appendingPathComponent("bin/java") + } + + private func normalizedJavaLanguageServerConfiguration(_ overridePath: String?) -> String { + let configuration = overridePath?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return configuration.isEmpty ? "" : normalizedPath(configuration) + } + func jdbExecutableURL( overridePath: String? = nil, for processKind: ProjectRuntimeProcessKind = .java @@ -167,9 +237,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 }) { @@ -387,6 +459,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 } diff --git a/Sources/Lithe/Services/Monitoring/FrameRateMonitor.swift b/Sources/Lithe/Services/Monitoring/FrameRateMonitor.swift new file mode 100644 index 000000000..6780727de --- /dev/null +++ b/Sources/Lithe/Services/Monitoring/FrameRateMonitor.swift @@ -0,0 +1,81 @@ +import Combine +import CoreVideo +import Foundation +import QuartzCore + +/// Counts vsync callbacks that reach the main thread so a hitch shows up as a +/// lower FPS. The workbench must not observe this object; only the status-bar +/// label should subscribe. +@MainActor +final class FrameRateMonitor: ObservableObject { + private(set) var framesPerSecond = 0 + + var framesPerSecondText: String { + "\(framesPerSecond) FPS" + } + + private var displayLink: CVDisplayLink? + private var framesInWindow: UInt64 = 0 + private var windowStartedAt: CFTimeInterval? + private var lastAcceptedFrameAt: CFTimeInterval? + private var displayedFramesPerSecond = -1 + private let sampleWindow: CFTimeInterval + + init(sampleWindow: TimeInterval = 0.5) { + self.sampleWindow = sampleWindow + } + + deinit { + if let displayLink { + CVDisplayLinkStop(displayLink) + } + } + + func start() { + guard displayLink == nil else { return } + windowStartedAt = CACurrentMediaTime() + lastAcceptedFrameAt = nil + var link: CVDisplayLink? + CVDisplayLinkCreateWithActiveCGDisplays(&link) + guard let link else { return } + displayLink = link + let context = Unmanaged.passUnretained(self).toOpaque() + CVDisplayLinkSetOutputCallback(link, { _, _, _, _, _, context in + guard let context else { return kCVReturnSuccess } + Task { @MainActor in + Unmanaged.fromOpaque(context).takeUnretainedValue() + .recordFrame(at: CACurrentMediaTime()) + } + return kCVReturnSuccess + }, context) + CVDisplayLinkStart(link) + } + + #if DEBUG + func recordFrameForTesting(at mediaTime: TimeInterval) { + recordFrame(at: mediaTime) + } + #endif + + /// Display-link callbacks can pile up behind a hitch. Collapse ticks that + /// land in the same frame so a stall is not hidden by a later burst. + private func recordFrame(at mediaTime: CFTimeInterval) { + if let lastAcceptedFrameAt, mediaTime - lastAcceptedFrameAt < 0.008 { + return + } + lastAcceptedFrameAt = mediaTime + let windowStart = windowStartedAt ?? mediaTime + windowStartedAt = windowStart + framesInWindow += 1 + let elapsed = mediaTime - windowStart + guard elapsed >= sampleWindow else { return } + + let fps = Int((Double(framesInWindow) / elapsed).rounded()) + framesInWindow = 0 + windowStartedAt = mediaTime + guard fps != displayedFramesPerSecond else { return } + displayedFramesPerSecond = fps + framesPerSecond = fps + objectWillChange.send() + } +} diff --git a/Sources/Lithe/Theme/LitheTheme.swift b/Sources/Lithe/Theme/LitheTheme.swift index 1a208846a..c69cdb837 100644 --- a/Sources/Lithe/Theme/LitheTheme.swift +++ b/Sources/Lithe/Theme/LitheTheme.swift @@ -45,6 +45,7 @@ enum LitheTheme { let window: RGBA let titlebar: RGBA let toolHeader: RGBA + let toolHeaderInactive: RGBA let sidebar: RGBA let editor: RGBA let raised: RGBA @@ -67,7 +68,10 @@ enum LitheTheme { let primaryText: RGBA let secondaryText: RGBA let tertiaryText: RGBA + let toolWindowText: RGBA + let toolWindowSelectedText: RGBA let accent: RGBA + let runAction: RGBA let success: RGBA let warning: RGBA let error: RGBA @@ -130,6 +134,7 @@ enum LitheTheme { window: surface, titlebar: surface.mixed(with: ink, amount: strongChromeAmount), toolHeader: surface.mixed(with: ink, amount: chromeAmount), + toolHeaderInactive: surface.mixed(with: ink, amount: strongChromeAmount), sidebar: isDark ? surface.mixed(with: RGBA(0x000000), amount: 0.10) : surface.mixed(with: ink, amount: chromeAmount), @@ -156,7 +161,10 @@ enum LitheTheme { primaryText: ink, secondaryText: ink.withAlpha(isDark ? 0.62 : 0.60), tertiaryText: ink.withAlpha(isDark ? 0.43 : 0.42), + toolWindowText: ink, + toolWindowSelectedText: RGBA(0xffffff), accent: accent, + runAction: isDark ? RGBA(0x59a869) : RGBA(0x2e7d32), success: diffAdded, warning: isDark ? RGBA(0xe6a23c) : RGBA(0xa96500), error: diffRemoved, @@ -175,32 +183,36 @@ enum LitheTheme { } return Palette( - window: adaptive(light: (0.965, 0.969, 0.976, 1), dark: (0.106, 0.113, 0.125, 1)), - titlebar: adaptive(light: (0.925, 0.933, 0.945, 1), dark: (0.145, 0.155, 0.169, 1)), - toolHeader: adaptive(light: (0.945, 0.949, 0.957, 1), dark: (0.122, 0.130, 0.142, 1)), - sidebar: adaptive(light: (0.925, 0.933, 0.945, 1), dark: (0.090, 0.096, 0.106, 1)), - editor: adaptive(light: (1, 1, 1, 1), dark: (0.074, 0.079, 0.088, 1)), + window: adaptive(light: (0.965, 0.969, 0.976, 1), dark: (0.157, 0.161, 0.173, 1)), + titlebar: adaptive(light: (0.925, 0.933, 0.945, 1), dark: (0.157, 0.161, 0.173, 1)), + toolHeader: adaptive(light: (0.925, 0.933, 0.945, 1), dark: (0.157, 0.161, 0.173, 1)), + toolHeaderInactive: adaptive(light: (0.902, 0.910, 0.925, 1), dark: (0.224, 0.231, 0.251, 1)), + sidebar: adaptive(light: (0.925, 0.933, 0.945, 1), dark: (0.157, 0.161, 0.173, 1)), + editor: adaptive(light: (1, 1, 1, 1), dark: (0.110, 0.114, 0.122, 1)), raised: adaptive(light: (1, 1, 1, 1), dark: (0.165, 0.175, 0.190, 1)), - selection: adaptive(light: (0.205, 0.435, 0.765, 1), dark: (0.170, 0.290, 0.490, 1)), + selection: adaptive(light: (0.275, 0.455, 0.945, 1), dark: (0.208, 0.455, 0.941, 1)), subtleSelection: adaptive(light: (0.855, 0.902, 0.973, 1), dark: (0.205, 0.218, 0.238, 1)), hoverBackground: adaptive(light: (0, 0, 0, 0.050), dark: (1, 1, 1, 0.055)), pressedBackground: adaptive(light: (0, 0, 0, 0.090), dark: (1, 1, 1, 0.095)), - activeTabBackground: adaptive(light: (1, 1, 1, 1), dark: (0.145, 0.155, 0.170, 1)), + activeTabBackground: adaptive(light: (1, 1, 1, 1), dark: (0.110, 0.114, 0.122, 1)), tabUnderline: adaptive(light: (0.180, 0.425, 0.790, 1), dark: (0.31, 0.58, 0.98, 1)), diffInformationBackground: adaptive(light: (0.895, 0.935, 0.990, 1), dark: (0.13, 0.20, 0.30, 1)), diffInformationText: adaptive(light: (0.105, 0.365, 0.680, 1), dark: (0.50, 0.72, 0.98, 1)), - divider: adaptive(light: (0, 0, 0, 0.100), dark: (1, 1, 1, 0.075)), - panelBorder: adaptive(light: (0, 0, 0, 0.145), dark: (1, 1, 1, 0.13)), + divider: adaptive(light: (0, 0, 0, 0.100), dark: (0.180, 0.188, 0.212, 1)), + panelBorder: adaptive(light: (0, 0, 0, 0.145), dark: (0.263, 0.271, 0.290, 1)), inputBackground: adaptive(light: (1, 1, 1, 1), dark: (0.065, 0.070, 0.078, 1)), inputBorder: adaptive(light: (0, 0, 0, 0.150), dark: (1, 1, 1, 0.12)), inputFocusBorder: adaptive(light: (0.180, 0.425, 0.790, 0.90), dark: (0.31, 0.58, 0.98, 0.85)), - popupBackground: adaptive(light: (1, 1, 1, 1), dark: (0.135, 0.143, 0.157, 1)), + popupBackground: adaptive(light: (1, 1, 1, 1), dark: (0.157, 0.161, 0.173, 1)), popupShadow: adaptive(light: (0, 0, 0, 0.20), dark: (0, 0, 0, 0.55)), badgeBackground: adaptive(light: (0, 0, 0, 0.075), dark: (1, 1, 1, 0.10)), - primaryText: adaptive(light: (0, 0, 0, 0.82), dark: (1, 1, 1, 0.86)), + primaryText: adaptive(light: (0, 0, 0, 0.82), dark: (0.875, 0.882, 0.898, 1)), secondaryText: adaptive(light: (0, 0, 0, 0.55), dark: (1, 1, 1, 0.50)), tertiaryText: adaptive(light: (0, 0, 0, 0.38), dark: (1, 1, 1, 0.34)), + toolWindowText: adaptive(light: (0, 0, 0, 0.82), dark: (0.875, 0.882, 0.898, 1)), + toolWindowSelectedText: adaptive(light: (1, 1, 1, 1), dark: (1, 1, 1, 1)), accent: adaptive(light: (0.180, 0.425, 0.790, 1), dark: (0.31, 0.58, 0.98, 1)), + runAction: adaptive(light: (0.180, 0.490, 0.196, 1), dark: (0.349, 0.659, 0.412, 1)), success: adaptive(light: (0.105, 0.545, 0.235, 1), dark: (0.28, 0.72, 0.39, 1)), warning: adaptive(light: (0.690, 0.410, 0.035, 1), dark: (0.91, 0.63, 0.20, 1)), error: adaptive(light: (0.780, 0.175, 0.175, 1), dark: (0.92, 0.33, 0.33, 1)), @@ -226,6 +238,7 @@ enum LitheTheme { case skill case guide case activeGuide + case divider } static func nsColor( @@ -246,13 +259,16 @@ enum LitheTheme { case .skill: palette.skill.nsColor case .guide: palette.guide.nsColor case .activeGuide: palette.activeGuide.nsColor + case .divider: palette.divider.nsColor } } // MARK: - 背景层次 static var window: Color { adaptive(\.window) } static var titlebar: Color { adaptive(\.titlebar) } + static var settingsSurface: Color { editor } static var toolHeader: Color { adaptive(\.toolHeader) } + static var toolHeaderInactive: Color { adaptive(\.toolHeaderInactive) } static var sidebar: Color { adaptive(\.sidebar) } static var editor: Color { adaptive(\.editor) } static var raised: Color { adaptive(\.raised) } @@ -288,9 +304,12 @@ enum LitheTheme { static var primaryText: Color { adaptive(\.primaryText) } static var secondaryText: Color { adaptive(\.secondaryText) } static var tertiaryText: Color { adaptive(\.tertiaryText) } + static var toolWindowText: Color { adaptive(\.toolWindowText) } + static var toolWindowSelectedText: Color { adaptive(\.toolWindowSelectedText) } // MARK: - 语义色 static var accent: Color { adaptive(\.accent) } + static var runAction: Color { adaptive(\.runAction) } static var success: Color { adaptive(\.success) } static var warning: Color { adaptive(\.warning) } static var error: Color { adaptive(\.error) } @@ -316,7 +335,23 @@ enum LitheTheme { static var uiFont: Font { uiFont(size: 14) } static var smallFont: Font { uiFont(size: 12) } - static let codeFont = Font.system(size: 13, design: .monospaced) + static let codeFont = Font.custom("JetBrainsMono-Regular", size: 13) + static let editorLineHeightMultiple: CGFloat = 1.2 + static let editorBaselineLift: CGFloat = 1.5 + + static func editorFont(size: CGFloat, weight: NSFont.Weight = .regular) -> NSFont { + let postScriptName = weight.rawValue >= NSFont.Weight.semibold.rawValue + ? "JetBrainsMono-Bold" + : "JetBrainsMono-Regular" + return NSFont(name: postScriptName, size: size) + ?? .monospacedSystemFont(ofSize: size, weight: weight) + } + + static var editorParagraphStyle: NSParagraphStyle { + let style = NSMutableParagraphStyle() + style.lineHeightMultiple = editorLineHeightMultiple + return style + } private static func uiFont(size: CGFloat) -> Font { if activeTheme != .lithe, NSFont(name: "Inter", size: size) != nil { @@ -379,7 +414,7 @@ struct LitheIconButtonStyle: ButtonStyle { func makeBody(configuration: Configuration) -> some View { configuration.label - .foregroundStyle(LitheTheme.secondaryText) + .foregroundStyle(LitheTheme.toolWindowText) .frame(width: 28, height: 28) .background( RoundedRectangle(cornerRadius: LitheTheme.Metrics.cornerRadius) diff --git a/Sources/Lithe/Views/App/RootView.swift b/Sources/Lithe/Views/App/RootView.swift index 84904a3f7..8e41081f7 100644 --- a/Sources/Lithe/Views/App/RootView.swift +++ b/Sources/Lithe/Views/App/RootView.swift @@ -1,8 +1,11 @@ import AppKit import SwiftUI +enum LitheWindowID { + static let settings = "settings" +} + struct RootView: View { - @EnvironmentObject private var model: AppModel @EnvironmentObject private var projectSessions: ProjectSessionManager @EnvironmentObject private var updateChecker: UpdateChecker @State private var didStartAutomaticUpdateCheck = false @@ -10,35 +13,18 @@ struct RootView: View { var body: some View { ZStack { ForEach(projectSessions.sessions) { session in - projectContent(for: session) - .opacity(session.id == projectSessions.activeSessionID ? 1 : 0) - .allowsHitTesting(session.id == projectSessions.activeSessionID) - .accessibilityHidden(session.id != projectSessions.activeSessionID) - .zIndex(session.id == projectSessions.activeSessionID ? 1 : 0) + ProjectSessionContent( + session: session, + isActive: session.id == projectSessions.activeSessionID + ) } + ActiveSessionChrome() } .frame( minWidth: windowLayout.minimumContentSize.width, minHeight: windowLayout.minimumContentSize.height ) .background(LitheTheme.window) - .background( - WindowCloseGuard( - projectSessions: projectSessions, - layout: windowLayout - ) - ) - .sheet(isPresented: $model.isSettingsPresented) { - SettingsView( - settings: model.settings, - initialCategory: model.requestedSettingsCategory - ) - .environmentObject(model) - } - .sheet(isPresented: $model.isCloneRepositoryPresented) { - CloneRepositoryView() - .environmentObject(model) - } .sheet(item: $projectSessions.pendingProjectOpen) { request in OpenProjectLocationDialog(request: request) { placement, doNotAskAgain in projectSessions.resolvePendingOpen( @@ -48,14 +34,6 @@ struct RootView: View { ) } } - .sheet(item: $model.localHistoryRequest) { request in - LocalHistoryView(request: request) - .environmentObject(model) - } - .sheet(item: $model.projectLocalHistoryRequest) { request in - ProjectLocalHistoryView(request: request) - .environmentObject(model) - } .alert(item: $updateChecker.notice) { notice in switch notice.action { case .install: @@ -112,17 +90,10 @@ struct RootView: View { } } - @ViewBuilder - private func projectContent(for session: AppModel) -> some View { - Group { - if session.workspaceURL == nil { - WelcomeView() - } else { - WorkbenchView() - .ignoresSafeArea(.container, edges: .top) - } - } - .environmentObject(session) + private var windowLayout: LitheWindowLayout { + let activeModel = projectSessions.activeModel + if activeModel.standaloneFileURL != nil { return .standalone } + return activeModel.workspaceURL == nil ? .welcome : .workspace } private var updatePromptPresented: Binding { @@ -135,15 +106,91 @@ struct RootView: View { } ) } +} + +private struct ProjectSessionContent: View { + @ObservedObject var session: AppModel + let isActive: Bool + + var body: some View { + Group { + if session.standaloneFileURL != nil { + StandaloneEditorView() + } else if session.workspaceURL == nil { + WelcomeView() + } else { + WorkbenchView() + .ignoresSafeArea(.container, edges: .top) + } + } + .environmentObject(session) + .environmentObject(session.editorChrome) + .environmentObject(session.editorDiagnosticsStore) + .opacity(isActive ? 1 : 0) + .allowsHitTesting(isActive) + .accessibilityHidden(!isActive) + .zIndex(isActive ? 1 : 0) + } +} + +private struct ActiveSessionChrome: View { + @Environment(\.openWindow) private var openWindow + @EnvironmentObject private var model: AppModel + @EnvironmentObject private var projectSessions: ProjectSessionManager + + var body: some View { + Color.clear + .frame(maxWidth: .infinity, maxHeight: .infinity) + .allowsHitTesting(false) + .accessibilityHidden(true) + .background( + WindowCloseGuard( + projectSessions: projectSessions, + layout: windowLayout, + title: windowTitle + ) + ) + .onReceive(model.$isSettingsPresented) { isPresented in + guard isPresented else { return } + openWindow(id: LitheWindowID.settings) + } + .sheet(isPresented: $model.isCloneRepositoryPresented) { + CloneRepositoryView() + .environmentObject(model) + } + .sheet(item: $model.localHistoryRequest) { request in + LocalHistoryView(request: request) + .environmentObject(model) + } + .sheet(item: $model.projectLocalHistoryRequest) { request in + ProjectLocalHistoryView(request: request) + .environmentObject(model) + } + } private var windowLayout: LitheWindowLayout { - projectSessions.activeModel.workspaceURL == nil ? .welcome : .workspace + let activeModel = projectSessions.activeModel + if activeModel.standaloneFileURL != nil { return .standalone } + return activeModel.workspaceURL == nil ? .welcome : .workspace + } + + private var windowTitle: String? { + if windowLayout == .standalone { + return projectSessions.activeModel.standaloneFileURL?.lastPathComponent ?? "Lithe" + } + guard windowLayout == .welcome else { return nil } + return String( + localized: "Welcome to Lithe", + bundle: .main, + locale: model.settings.language.locale + ) } } private struct WindowCloseGuard: NSViewRepresentable { let projectSessions: ProjectSessionManager let layout: LitheWindowLayout + let title: String? func makeCoordinator() -> LitheWindowCoordinator { LitheWindowCoordinator(projectSessions: projectSessions) @@ -152,7 +199,7 @@ private struct WindowCloseGuard: NSViewRepresentable { func makeNSView(context: Context) -> NSView { let view = NSView(frame: .zero) DispatchQueue.main.async { - context.coordinator.attach(to: view.window, layout: layout) + context.coordinator.attach(to: view.window, layout: layout, title: title) } return view } @@ -160,7 +207,7 @@ private struct WindowCloseGuard: NSViewRepresentable { func updateNSView(_ view: NSView, context: Context) { context.coordinator.projectSessions = projectSessions DispatchQueue.main.async { - context.coordinator.attach(to: view.window, layout: layout) + context.coordinator.attach(to: view.window, layout: layout, title: title) } } } @@ -168,15 +215,20 @@ private struct WindowCloseGuard: NSViewRepresentable { enum LitheWindowLayout: Equatable { case welcome case workspace + case standalone static let welcomeContentSize = NSSize(width: 900, height: 620) static let workspaceContentSize = NSSize(width: 1440, height: 900) + static let standaloneContentSize = NSSize(width: 1200, height: 760) + static let standaloneMinimumContentSize = NSSize(width: 760, height: 480) + static let standaloneMaximumContentSize = NSSize(width: 1200, height: 820) static let screenMargin: CGFloat = 12 var contentSize: NSSize { switch self { case .welcome: Self.welcomeContentSize case .workspace: Self.workspaceContentSize + case .standalone: Self.standaloneContentSize } } @@ -184,9 +236,22 @@ enum LitheWindowLayout: Equatable { switch self { case .welcome: NSSize(width: 820, height: 560) case .workspace: NSSize(width: 980, height: 640) + case .standalone: Self.standaloneMinimumContentSize } } + static func standaloneContentSize(fitting visibleFrame: NSRect) -> NSSize { + NSSize( + width: min( + max(visibleFrame.width * 0.65, standaloneMinimumContentSize.width), + standaloneMaximumContentSize.width + ), + height: min( + max(visibleFrame.height * 0.72, standaloneMinimumContentSize.height), + standaloneMaximumContentSize.height + ) + ) + } static func frame(_ targetFrame: NSRect, fitting visibleFrame: NSRect) -> NSRect { let availableFrame = visibleFrame.insetBy(dx: screenMargin, dy: screenMargin) var fittedFrame = targetFrame @@ -207,13 +272,19 @@ enum LitheWindowLayout: Equatable { @MainActor protocol ProjectWindowSessionHandling: AnyObject { var hasActiveProject: Bool { get } + var hasActiveStandaloneFile: Bool { get } func closeActiveProject() + func requestCloseActiveSession() -> Bool } extension ProjectSessionManager: ProjectWindowSessionHandling { var hasActiveProject: Bool { activeModel.workspaceURL != nil } + + var hasActiveStandaloneFile: Bool { + activeModel.standaloneFileURL != nil + } } @MainActor @@ -227,7 +298,7 @@ final class LitheWindowCoordinator: NSObject, NSWindowDelegate { self.projectSessions = projectSessions } - func attach(to window: NSWindow?, layout: LitheWindowLayout) { + func attach(to window: NSWindow?, layout: LitheWindowLayout, title: String? = nil) { guard let window else { return } if self.window !== window { self.window = window @@ -235,7 +306,7 @@ final class LitheWindowCoordinator: NSObject, NSWindowDelegate { self.layout = nil restoredWorkspaceFrame = nil } - apply(layout, to: window) + apply(layout, title: title, to: window) } func toggleWorkspaceZoom() { @@ -260,15 +331,22 @@ final class LitheWindowCoordinator: NSObject, NSWindowDelegate { } func windowShouldClose(_ sender: NSWindow) -> Bool { - if projectSessions.hasActiveProject { - projectSessions.closeActiveProject() - return false + if projectSessions.hasActiveProject || projectSessions.hasActiveStandaloneFile { + return projectSessions.requestCloseActiveSession() } return true } - private func apply(_ layout: LitheWindowLayout, to window: NSWindow) { + private func apply(_ layout: LitheWindowLayout, title: String?, to window: NSWindow) { window.contentMinSize = layout.minimumContentSize + if let title { + window.title = title + window.titlebarAppearsTransparent = true + window.titleVisibility = .visible + } else { + window.title = "" + window.titleVisibility = .hidden + } guard self.layout != layout else { return } let shouldAnimate = self.layout != nil && window.isVisible @@ -276,13 +354,20 @@ final class LitheWindowCoordinator: NSObject, NSWindowDelegate { restoredWorkspaceFrame = nil let currentFrame = window.frame - let targetContentRect = NSRect(origin: .zero, size: layout.contentSize) + let visibleFrame = (window.screen ?? NSScreen.main)?.visibleFrame + let targetContentSize: NSSize + if layout == .standalone, let visibleFrame { + targetContentSize = LitheWindowLayout.standaloneContentSize(fitting: visibleFrame) + } else { + targetContentSize = layout.contentSize + } + let targetContentRect = NSRect(origin: .zero, size: targetContentSize) var targetFrame = window.frameRect(forContentRect: targetContentRect) targetFrame.origin = NSPoint( x: currentFrame.midX - targetFrame.width / 2, y: currentFrame.midY - targetFrame.height / 2 ) - if let visibleFrame = (window.screen ?? NSScreen.main)?.visibleFrame { + if let visibleFrame { targetFrame = LitheWindowLayout.frame(targetFrame, fitting: visibleFrame) } window.setFrame(targetFrame, display: true, animate: shouldAnimate) diff --git a/Sources/Lithe/Views/App/SettingsView.swift b/Sources/Lithe/Views/App/SettingsView.swift index bddd826ff..111f2ee4e 100644 --- a/Sources/Lithe/Views/App/SettingsView.swift +++ b/Sources/Lithe/Views/App/SettingsView.swift @@ -1,4 +1,3 @@ -import AppKit import SwiftUI import LitheCoreContracts import LitheGitModule @@ -11,23 +10,28 @@ struct SettingsView: View { @EnvironmentObject private var updateChecker: UpdateChecker @ObservedObject var settings: AppSettings @State private var selection: SettingsCategory + @State private var searchQuery = "" @State private var hiddenDirectoriesDraft = "" @State private var hiddenFilePatternsDraft = "" @State private var aiAPIKeyDraft = "" @State private var isFormatPickerPresented = false + let initialCategory: SettingsCategory + private let onDismiss: (() -> Void)? + private static let footerActionLabelWidth: CGFloat = 52 init( settings: AppSettings, - initialCategory: SettingsCategory = .general + initialCategory: SettingsCategory = .general, + onDismiss: (() -> Void)? = nil ) { self.settings = settings + self.initialCategory = initialCategory + self.onDismiss = onDismiss _selection = State(initialValue: initialCategory) } var body: some View { VStack(spacing: 0) { - header - Rectangle().fill(LitheTheme.divider).frame(height: 1) HStack(spacing: 0) { categories Rectangle().fill(LitheTheme.divider).frame(width: 1) @@ -36,8 +40,8 @@ struct SettingsView: View { Rectangle().fill(LitheTheme.divider).frame(height: 1) footer } - .frame(width: 820, height: 620) - .background(LitheTheme.window) + .frame(minWidth: 820, minHeight: 620) + .background(LitheTheme.settingsSurface) .onAppear { syncVisibilityDrafts() model.refreshAIConfigurations() @@ -46,65 +50,161 @@ struct SettingsView: View { .onChange(of: settings.hiddenDirectoryNames) { _ in syncVisibilityDrafts() } .onChange(of: settings.hiddenFilePatterns) { _ in syncVisibilityDrafts() } .onChange(of: settings.commitMessageAI.activeProviderID) { _ in syncAIProviderDraft() } - // A sheet has its own SwiftUI presentation hierarchy on macOS. Own - // the locale here so every Settings presentation updates immediately. + .onChange(of: initialCategory) { category in + searchQuery = "" + selection = category + } + .onChange(of: searchQuery) { _ in + guard !filteredCategories.contains(selection), + let firstMatch = filteredCategories.first else { return } + selection = firstMatch + } .environment(\.locale, settings.language.locale) - .id(settings.language) } - private var header: some View { - HStack(spacing: 9) { - LitheSystemIcon(systemImage: "gearshape.fill") - .foregroundStyle(LitheTheme.secondaryText) - Text("Settings") - .font(.system(size: 14, weight: .semibold)) - Spacer() - Button { dismiss() } label: { - Image(systemName: "xmark") - .font(.system(size: 11, weight: .semibold)) + private var categories: some View { + VStack(spacing: 0) { + settingsSearchField + .padding(12) + + Rectangle().fill(LitheTheme.divider).frame(height: 1) + + ScrollView { + VStack(spacing: 1) { + ForEach(filteredCategories) { category in + categoryButton(category) + } + + if filteredCategories.isEmpty { + VStack(spacing: 8) { + Image(systemName: "magnifyingglass") + .font(.system(size: 18, weight: .light)) + Text("No settings found") + .font(.system(size: 12)) + } + .foregroundStyle(LitheTheme.tertiaryText) + .frame(maxWidth: .infinity) + .padding(.top, 28) + } + } + .padding(8) } - .litheIconButton() - .help("Close Settings") } - .foregroundStyle(LitheTheme.primaryText) - .padding(.horizontal, 14) - .frame(height: 44) - .background(LitheTheme.toolHeader) + .frame(width: 244) + .frame(maxHeight: .infinity) + .background(LitheTheme.settingsSurface) } - private var categories: some View { - VStack(spacing: 3) { - ForEach(SettingsCategory.allCases) { category in + private var settingsSearchField: some View { + HStack(spacing: 7) { + Image(systemName: "magnifyingglass") + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(LitheTheme.tertiaryText) + + TextField("Search settings", text: $searchQuery) + .textFieldStyle(.plain) + .font(.system(size: 12.5)) + + if !searchQuery.isEmpty { Button { - selection = category + searchQuery = "" } label: { - HStack(spacing: 9) { - Image(systemName: category.icon).frame(width: 18) - Text(LocalizedStringKey(category.rawValue)) - Spacer() - } - .padding(.horizontal, 10) - .frame(maxWidth: .infinity, alignment: .leading) - .frame(height: 32) - .background(selection == category ? LitheTheme.selection : .clear) - .clipShape(RoundedRectangle(cornerRadius: 4)) - .contentShape(Rectangle()) + Image(systemName: "xmark.circle.fill") + .font(.system(size: 11)) + .foregroundStyle(LitheTheme.tertiaryText) } .buttonStyle(.plain) .lithePointer() - .foregroundStyle(selection == category ? Color.white : LitheTheme.primaryText) + .help("Clear search") } - Spacer() } - .font(.system(size: 12.5)) - .padding(8) - .frame(width: 190) - .background(LitheTheme.sidebar) + .padding(.horizontal, 9) + .frame(height: 28) + .background(LitheTheme.inputBackground) + .clipShape(RoundedRectangle(cornerRadius: LitheTheme.Metrics.controlCornerRadius)) + .overlay { + RoundedRectangle(cornerRadius: LitheTheme.Metrics.controlCornerRadius) + .stroke(LitheTheme.inputBorder, lineWidth: 1) + } + } + + private func categoryButton(_ category: SettingsCategory) -> some View { + let isSelected = selection == category + return Button { + selection = category + } label: { + HStack(spacing: 10) { + Image(systemName: category.icon) + .font(.system(size: 12.5, weight: .medium)) + .frame(width: 18) + Text(LocalizedStringKey(category.rawValue)) + .font(.system(size: 12.5, weight: .regular)) + Spacer(minLength: 8) + } + .padding(.horizontal, 10) + .frame(maxWidth: .infinity, alignment: .leading) + .frame(height: LitheTheme.Metrics.treeRowHeight) + .background(isSelected ? LitheTheme.selection : .clear) + .clipShape(RoundedRectangle(cornerRadius: LitheTheme.Metrics.cornerRadius)) + .contentShape(Rectangle()) + } + .buttonStyle(LitheTreeRowButtonStyle()) + .foregroundStyle(isSelected ? Color.white : LitheTheme.primaryText) + } + + private var filteredCategories: [SettingsCategory] { + let query = searchQuery.trimmingCharacters(in: .whitespacesAndNewlines) + guard !query.isEmpty else { return SettingsCategory.allCases } + + return SettingsCategory.allCases.filter { category in + searchTerms(for: category).contains { term in + localizedSearchValue(term).localizedCaseInsensitiveContains(query) + || term.localizedCaseInsensitiveContains(query) + } + } + } + + private func searchTerms(for category: SettingsCategory) -> [String] { + switch category { + case .general: + ["General", "Appearance", "Color theme", "Appearance mode", "Language", "Projects", "Files", "Version control", "Logs", "Log directory"] + case .editor: + ["Editor", "Display", "Editor tabs", "Font size", "Indentation", "Tab width"] + case .keymap: + ["Keymap", "Keyboard shortcuts", "Shortcuts", "Actions"] + case .terminal: + ["Terminal", "Shell", "Default shell"] + case .lsp: + ["LSP", "Language server", "Java SDK", "JDK", "Maven"] + case .ai: + ["AI & Commit", "AI provider", "Model", "API key", "Commit message"] + case .updates: + ["Updates", "Application version", "Update status", "Check for Updates"] + } + } + + private func localizedSearchValue(_ key: String) -> String { + String( + localized: String.LocalizationValue(key), + bundle: .main, + locale: settings.language.locale + ) } @ViewBuilder private var content: some View { - if selection == .lsp { + if filteredCategories.isEmpty { + VStack(spacing: 10) { + Image(systemName: "magnifyingglass") + .font(.system(size: 28, weight: .light)) + Text("No settings found") + .font(.system(size: 15, weight: .medium)) + Text("Try a different search term.") + .font(LitheTheme.smallFont) + } + .foregroundStyle(LitheTheme.secondaryText) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if selection == .lsp { LSPControlCenterView() .frame(maxWidth: .infinity, maxHeight: .infinity) } else if selection == .keymap { @@ -115,10 +215,11 @@ struct SettingsView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) } else { ScrollView { - VStack(alignment: .leading, spacing: 20) { + VStack(alignment: .leading, spacing: 8) { Text(LocalizedStringKey(selection.rawValue)) - .font(.system(size: 20, weight: .semibold)) + .font(.system(size: 22, weight: .semibold)) .foregroundStyle(LitheTheme.primaryText) + .padding(.bottom, 8) switch selection { case .general: generalSettings @@ -130,7 +231,8 @@ struct SettingsView: View { case .updates: updatesSettings } } - .padding(24) + .padding(.horizontal, 28) + .padding(.vertical, 22) .frame(maxWidth: .infinity, alignment: .leading) } } @@ -140,27 +242,22 @@ struct SettingsView: View { VStack(alignment: .leading, spacing: 18) { group("Appearance") { row("Color theme") { - Picker("", selection: $settings.colorTheme) { - ForEach(AppColorTheme.allCases) { theme in - Text(LocalizedStringKey(theme.title)).tag(theme) - } - } - .labelsHidden() - .pickerStyle(.menu) - .frame(width: 180, alignment: .leading) - .lithePointer() + LitheSettingsSelect( + selection: $settings.colorTheme, + options: AppColorTheme.allCases, + width: 180, + accessibilityLabel: "Color theme", + title: \AppColorTheme.title + ) } row("Appearance mode") { - Picker("", selection: $settings.themePreference) { - ForEach(AppThemePreference.allCases) { preference in - Text(LocalizedStringKey(preference.title)).tag(preference) - } - } - .labelsHidden() - .pickerStyle(.segmented) - .frame(width: 260) - .lithePointer() + LitheSettingsSegmentedControl( + selection: $settings.themePreference, + options: AppThemePreference.allCases, + width: 260, + title: \AppThemePreference.title + ) } Text("Choose a color theme and whether Lithe follows the system appearance.") @@ -169,13 +266,15 @@ struct SettingsView: View { } group("Language") { - Picker("Language", selection: $settings.language) { - ForEach(AppLanguage.allCases) { language in - Text(LocalizedStringKey(language.title)).tag(language) - } + row("Language") { + LitheSettingsSelect( + selection: $settings.language, + options: AppLanguage.allCases, + width: 180, + accessibilityLabel: "Language", + title: \AppLanguage.title + ) } - .frame(maxWidth: 220, alignment: .leading) - .lithePointer() Text("The interface language changes immediately. English is the default.") .font(LitheTheme.smallFont) @@ -183,13 +282,15 @@ struct SettingsView: View { } group("Projects") { - Picker("Open projects in", selection: $settings.projectOpenBehavior) { - ForEach(ProjectOpenBehavior.allCases) { behavior in - Text(LocalizedStringKey(behavior.title)).tag(behavior) - } + row("Open projects in") { + LitheSettingsSelect( + selection: $settings.projectOpenBehavior, + options: ProjectOpenBehavior.allCases, + width: 180, + accessibilityLabel: "Open projects in", + title: \ProjectOpenBehavior.title + ) } - .frame(maxWidth: 220, alignment: .leading) - .lithePointer() Text("Choose whether opening another project asks first, stays in this window, or creates a new window.") .font(LitheTheme.smallFont) @@ -197,30 +298,33 @@ struct SettingsView: View { } group("Files") { - Toggle("Save changed files automatically", isOn: $settings.autoSave) - .lithePointer() + LitheSettingsCheckbox( + isOn: $settings.autoSave, + title: "Save changed files automatically" + ) if settings.autoSave { row("Save after") { - Picker("", selection: $settings.autoSaveDelay) { - Text("0.5 seconds").tag(0.5) - Text("1.5 seconds").tag(1.5) - Text("3 seconds").tag(3.0) - } - .labelsHidden() - .frame(width: 150) - .lithePointer() + LitheSettingsSelect( + selection: $settings.autoSaveDelay, + options: [0.5, 1.5, 3.0], + width: 150, + accessibilityLabel: "Save after", + title: autoSaveDelayTitle + ) } } } group("Git") { - Picker("Save local changes with", selection: $settings.gitSaveChangesPolicy) { - ForEach(GitSaveChangesPolicy.allCases) { policy in - Text(LocalizedStringKey(policy.title)).tag(policy) - } + row("Save local changes with") { + LitheSettingsSelect( + selection: $settings.gitSaveChangesPolicy, + options: GitSaveChangesPolicy.allCases, + width: 180, + accessibilityLabel: "Save local changes with", + title: \GitSaveChangesPolicy.title + ) } - .frame(maxWidth: 260, alignment: .leading) - .lithePointer() Text(LocalizedStringKey(settings.gitSaveChangesPolicy.description)) .font(LitheTheme.smallFont) @@ -260,50 +364,124 @@ struct SettingsView: View { HStack { Spacer() Button("Apply") { applyVisibilityDrafts() } - .buttonStyle(.borderedProminent) + .buttonStyle(LithePrimaryButtonStyle()) + } + } + + group("Logs") { + Text("Log directory") + .font(.system(size: 11.5, weight: .medium)) + + HStack(spacing: 10) { + Text(settings.logDirectory.path) + .font(.system(size: 13, weight: .medium)) + .lineLimit(1) + .truncationMode(.middle) + .textSelection(.enabled) + .help(settings.logDirectory.path) + + Spacer(minLength: 8) + + Button { + guard let directory = model.platformUI.chooseDirectory( + title: "Choose Log Directory", + prompt: "Choose" + ) else { return } + settings.setCustomLogDirectory(directory) + } label: { + Image(systemName: "folder") + .font(.system(size: 16, weight: .regular)) + .frame(width: 26, height: 26) + } + .buttonStyle(.plain) + .foregroundStyle(LitheTheme.secondaryText) + .contentShape(Rectangle()) + .lithePointer() + .help("Choose Directory") + } + .padding(.horizontal, 12) + .frame(maxWidth: .infinity, minHeight: 46, maxHeight: 46) + .background(LitheTheme.inputBackground) + .clipShape(RoundedRectangle(cornerRadius: 6)) + .overlay { + RoundedRectangle(cornerRadius: 6) + .stroke(LitheTheme.inputBorder, lineWidth: 1) + } + + HStack(spacing: 6) { + Text("Default directory") + .foregroundStyle(LitheTheme.secondaryText) + Text(settings.defaultLogDirectory.path) + .foregroundStyle(LitheTheme.tertiaryText) + .lineLimit(1) + .truncationMode(.middle) + .textSelection(.enabled) + .help(settings.defaultLogDirectory.path) + + Spacer(minLength: 8) + + if settings.customLogDirectory != nil { + Button { + settings.setCustomLogDirectory(nil) + } label: { + Text("Restore Default") + } + .buttonStyle(.plain) + .foregroundStyle(LitheTheme.accent) .lithePointer() - .tint(LitheTheme.accent) + } } + .font(LitheTheme.smallFont) } } } + private func autoSaveDelayTitle(_ delay: Double) -> String { + switch delay { + case 0.5: "0.5 seconds" + case 1.5: "1.5 seconds" + default: "3 seconds" + } + } + private var editorSettings: some View { VStack(alignment: .leading, spacing: 18) { group("Display") { row("Font size") { - Stepper(value: $settings.editorFontSize, in: 10...22, step: 1) { - Text("\(Int(settings.editorFontSize)) pt") - .monospacedDigit() - .frame(width: 42, alignment: .trailing) - } - .lithePointer() + LitheSettingsStepper( + value: $settings.editorFontSize, + in: 10...22, + step: 1, + width: 126, + accessibilityLabel: "Font size", + title: { "\(Int($0)) pt" } + ) } - Toggle("Show usages and Git author", isOn: $settings.showCodeVision) - .lithePointer() + LitheSettingsCheckbox( + isOn: $settings.showCodeVision, + title: "Show usages and Git author" + ) } group("Editor tabs") { row("Layout") { - Picker("", selection: $settings.editorTabLayoutMode) { - ForEach(EditorTabLayoutMode.allCases) { mode in - Text(LocalizedStringKey(mode.title)).tag(mode) - } - } - .labelsHidden() - .frame(width: 180) - .lithePointer() + LitheSettingsSelect( + selection: $settings.editorTabLayoutMode, + options: EditorTabLayoutMode.allCases, + width: 180, + accessibilityLabel: "Layout", + title: \EditorTabLayoutMode.title + ) } } group("Indentation") { row("Tab width") { - Picker("", selection: $settings.tabWidth) { - Text("2 spaces").tag(2) - Text("4 spaces").tag(4) - Text("8 spaces").tag(8) - } - .labelsHidden() - .frame(width: 130) - .lithePointer() + LitheSettingsSelect( + selection: $settings.tabWidth, + options: [2, 4, 8], + width: 130, + accessibilityLabel: "Tab width", + title: { "\($0) spaces" } + ) } } } @@ -312,14 +490,13 @@ struct SettingsView: View { private var terminalSettings: some View { group("Shell") { row("Default shell") { - Picker("", selection: $settings.terminalShell) { - ForEach(TerminalShell.allCases) { shell in - Text(LocalizedStringKey(shell.title)).tag(shell) - } - } - .labelsHidden() - .frame(width: 180) - .lithePointer() + LitheSettingsSelect( + selection: $settings.terminalShell, + options: TerminalShell.allCases, + width: 180, + accessibilityLabel: "Default shell", + title: \TerminalShell.title + ) .onChange(of: settings.terminalShell) { _ in guard model.activeTerminalSession?.isRunning == true else { return } model.restartActiveTerminal(using: model.activeTerminalShellPath) @@ -338,57 +515,58 @@ struct SettingsView: View { Text("No AI provider is configured yet.") .foregroundStyle(LitheTheme.secondaryText) } else { - Picker("Profile", selection: Binding( - get: { settings.commitMessageAI.activeProviderID ?? settings.commitMessageAI.providers[0].id }, - set: { settings.selectCommitMessageProvider($0) } - )) { - ForEach(settings.commitMessageAI.providers) { provider in - Text(provider.name.isEmpty ? "Unnamed provider" : provider.name) - .tag(provider.id) - } + row("Profile") { + LitheSettingsSelect( + selection: Binding( + get: { settings.commitMessageAI.activeProviderID ?? settings.commitMessageAI.providers[0].id }, + set: { settings.selectCommitMessageProvider($0) } + ), + options: settings.commitMessageAI.providers.map(\.id), + width: 240, + accessibilityLabel: "Profile", + title: providerTitle + ) } - .frame(maxWidth: 300, alignment: .leading) - .lithePointer() HStack(spacing: 8) { Button("Add Provider") { settings.addCommitMessageProvider() syncAIProviderDraft() } - .buttonStyle(.bordered) - .lithePointer() + .buttonStyle(LitheSecondaryButtonStyle()) Button("Remove") { settings.removeActiveCommitMessageProvider() syncAIProviderDraft() } - .buttonStyle(.bordered) + .buttonStyle(LitheSecondaryButtonStyle()) .disabled(settings.activeCommitMessageProvider == nil) - .lithePointer() } } if settings.activeCommitMessageProvider != nil { TextField("Provider name", text: activeProviderTextBinding(\.name)) - .textFieldStyle(.roundedBorder) + .litheSettingsTextField() + .disabled(model.activeCommitMessageCredentialIsConfigurationManaged) + row("API protocol") { + LitheSettingsSelect( + selection: activeProviderProtocolBinding(), + options: CommitMessageAPIProtocol.allCases, + width: 240, + accessibilityLabel: "API protocol", + title: \CommitMessageAPIProtocol.title + ) .disabled(model.activeCommitMessageCredentialIsConfigurationManaged) - Picker("API protocol", selection: activeProviderProtocolBinding()) { - ForEach(CommitMessageAPIProtocol.allCases) { apiProtocol in - Text(LocalizedStringKey(apiProtocol.title)).tag(apiProtocol) - } } - .frame(maxWidth: 300, alignment: .leading) - .lithePointer() - .disabled(model.activeCommitMessageCredentialIsConfigurationManaged) TextField("API URL", text: activeProviderTextBinding(\.endpoint)) - .textFieldStyle(.roundedBorder) + .litheSettingsTextField() .disabled(model.activeCommitMessageCredentialIsConfigurationManaged) if settings.activeCommitMessageProvider?.usesInsecureHTTP == true { - Toggle( - "Allow insecure HTTP", - isOn: activeProviderBoolBinding(\.allowsInsecureHTTP) + LitheSettingsCheckbox( + isOn: activeProviderBoolBinding(\.allowsInsecureHTTP), + title: "Allow insecure HTTP" ) - .lithePointer() + .disabled(model.activeCommitMessageCredentialIsConfigurationManaged) Label( settings.activeCommitMessageProvider?.allowsInsecureHTTP == true ? "HTTP sends the API credential without encryption. Use only a trusted endpoint." @@ -399,23 +577,24 @@ struct SettingsView: View { .foregroundStyle(LitheTheme.warning) } TextField("Model", text: activeProviderTextBinding(\.model)) - .textFieldStyle(.roundedBorder) + .litheSettingsTextField() .disabled(model.activeCommitMessageCredentialIsConfigurationManaged) HStack(spacing: 8) { SecureField("API key or token", text: $aiAPIKeyDraft) - .textFieldStyle(.roundedBorder) + .litheSettingsTextField() .disabled(model.activeCommitMessageCredentialIsConfigurationManaged) Button("Save Key") { model.saveActiveCommitMessageAPIKey(aiAPIKeyDraft) } - .buttonStyle(.bordered) - .lithePointer() + .buttonStyle(LitheSecondaryButtonStyle()) .disabled(model.activeCommitMessageCredentialIsConfigurationManaged) } - Toggle("Provider requires an API key", isOn: activeProviderBoolBinding(\.requiresAPIKey)) - .lithePointer() + LitheSettingsCheckbox( + isOn: activeProviderBoolBinding(\.requiresAPIKey), + title: "Provider requires an API key" + ) .disabled(model.activeCommitMessageCredentialIsConfigurationManaged) if model.activeCommitMessageCredentialIsConfigurationManaged, @@ -453,9 +632,7 @@ struct SettingsView: View { syncAIProviderDraft() } } - .buttonStyle(.borderedProminent) - .tint(LitheTheme.accent) - .lithePointer() + .buttonStyle(LithePrimaryButtonStyle()) } .padding(10) .background(LitheTheme.inputBackground) @@ -469,8 +646,7 @@ struct SettingsView: View { } label: { Label("Reload AI configurations", systemImage: "arrow.clockwise") } - .buttonStyle(.bordered) - .lithePointer() + .buttonStyle(LitheSecondaryButtonStyle()) } } } else { @@ -483,8 +659,7 @@ struct SettingsView: View { } label: { Label("Reload AI configurations", systemImage: "arrow.clockwise") } - .buttonStyle(.bordered) - .lithePointer() + .buttonStyle(LitheSecondaryButtonStyle()) } } @@ -496,41 +671,53 @@ struct SettingsView: View { } group("Commit message generation") { - Picker("Reasoning effort", selection: $settings.commitMessageAI.reasoningEffort) { - ForEach(CommitMessageReasoningEffort.allCases) { effort in - Text(LocalizedStringKey(effort.title)).tag(effort) - } + row("Reasoning effort") { + LitheSettingsSelect( + selection: $settings.commitMessageAI.reasoningEffort, + options: CommitMessageReasoningEffort.allCases, + width: 230, + accessibilityLabel: "Reasoning effort", + title: \CommitMessageReasoningEffort.title + ) } - .frame(maxWidth: 230, alignment: .leading) - .lithePointer() - Picker("Output language", selection: $settings.commitMessageAI.language) { - ForEach(CommitMessageLanguage.allCases) { language in - Text(LocalizedStringKey(language.title)).tag(language) - } + row("Output language") { + LitheSettingsSelect( + selection: $settings.commitMessageAI.language, + options: CommitMessageLanguage.allCases, + width: 230, + accessibilityLabel: "Output language", + title: \CommitMessageLanguage.title + ) } - .frame(maxWidth: 230, alignment: .leading) - .lithePointer() formatPicker - Toggle("Include a short body when useful", isOn: $settings.commitMessageAI.includeBody) - .lithePointer() + LitheSettingsCheckbox( + isOn: $settings.commitMessageAI.includeBody, + title: "Include a short body when useful" + ) row("Subject maximum length") { - Stepper(value: $settings.commitMessageAI.subjectMaximumLength, in: 40...120, step: 4) { - Text("\(settings.commitMessageAI.subjectMaximumLength) ") + Text("chars") - .monospacedDigit() - } - .lithePointer() + LitheSettingsStepper( + value: $settings.commitMessageAI.subjectMaximumLength, + in: 40...120, + step: 4, + width: 146, + accessibilityLabel: "Subject maximum length", + title: { "\($0) chars" } + ) } row("Diff character limit") { - Stepper(value: $settings.commitMessageAI.maximumDiffCharacters, in: 8_000...120_000, step: 4_000) { - Text("\(settings.commitMessageAI.maximumDiffCharacters)") - .monospacedDigit() - } - .lithePointer() + LitheSettingsStepper( + value: $settings.commitMessageAI.maximumDiffCharacters, + in: 8_000...120_000, + step: 4_000, + width: 146, + accessibilityLabel: "Diff character limit", + title: { "\($0)" } + ) } if settings.commitMessageAI.format == .custom { @@ -841,10 +1028,8 @@ struct SettingsView: View { systemImage: "arrow.clockwise" ) } - .buttonStyle(.borderedProminent) - .tint(LitheTheme.accent) + .buttonStyle(LithePrimaryButtonStyle()) .disabled(updateChecker.isBusy) - .lithePointer() if case .available(let version, _) = updateChecker.status { Button { @@ -852,9 +1037,8 @@ struct SettingsView: View { } label: { Label("Update \(version)", systemImage: "arrow.down.circle.fill") } - .buttonStyle(.bordered) + .buttonStyle(LitheSecondaryButtonStyle()) .disabled(updateChecker.isBusy) - .lithePointer() } } } @@ -928,11 +1112,11 @@ struct SettingsView: View { } .font(.system(size: 12.5)) .foregroundStyle(LitheTheme.primaryText) - .padding(16) + .padding(.vertical, 16) .frame(maxWidth: .infinity, alignment: .leading) - .background(LitheTheme.sidebar) - .clipShape(RoundedRectangle(cornerRadius: 6)) - .overlay { RoundedRectangle(cornerRadius: 6).stroke(LitheTheme.divider, lineWidth: 1) } + .overlay(alignment: .bottom) { + Rectangle().fill(LitheTheme.divider).frame(height: 1) + } } private func row(_ title: String, @ViewBuilder content: () -> Content) -> some View { @@ -948,6 +1132,13 @@ struct SettingsView: View { aiAPIKeyDraft = model.activeCommitMessageAPIKey } + private func providerTitle(_ id: UUID) -> String { + guard let provider = settings.commitMessageAI.providers.first(where: { $0.id == id }) else { + return "Unnamed provider" + } + return provider.name.isEmpty ? "Unnamed provider" : provider.name + } + private func reloadAIConfigurations() { model.refreshAIConfigurations() syncAIProviderDraft() @@ -994,17 +1185,26 @@ struct SettingsView: View { private var footer: some View { HStack { Button("Restore Defaults") { settings.restoreDefaults() } - .buttonStyle(.borderless) - .lithePointer() - .foregroundStyle(LitheTheme.secondaryText) + .buttonStyle(LitheSecondaryButtonStyle()) Spacer() - Button("Done") { dismiss() } - .keyboardShortcut(.defaultAction) - .lithePointer() + HStack(spacing: 10) { + Button { closeSettings() } label: { + Text("Cancel") + .frame(minWidth: Self.footerActionLabelWidth) + } + .buttonStyle(LitheSecondaryButtonStyle()) + .keyboardShortcut(.cancelAction) + Button { closeSettings() } label: { + Text("OK") + .frame(minWidth: Self.footerActionLabelWidth) + } + .buttonStyle(LithePrimaryButtonStyle()) + .keyboardShortcut(.defaultAction) + } } - .padding(.horizontal, 14) - .frame(height: 50) - .background(LitheTheme.toolHeader) + .padding(.horizontal, 16) + .frame(height: 52) + .background(LitheTheme.settingsSurface) } private func syncVisibilityDrafts() { @@ -1022,4 +1222,12 @@ struct SettingsView: View { .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } .filter { !$0.isEmpty } } + + private func closeSettings() { + if let onDismiss { + onDismiss() + } else { + dismiss() + } + } } diff --git a/Sources/Lithe/Views/App/WelcomeView.swift b/Sources/Lithe/Views/App/WelcomeView.swift index 5816f06e7..163ac8f30 100644 --- a/Sources/Lithe/Views/App/WelcomeView.swift +++ b/Sources/Lithe/Views/App/WelcomeView.swift @@ -10,21 +10,12 @@ struct WelcomeView: View { @FocusState private var searchFocused: Bool var body: some View { - VStack(spacing: 0) { - Text("Welcome to Lithe") - .font(.system(size: 16, weight: .semibold)) - .foregroundStyle(LitheTheme.primaryText) - .frame(maxWidth: .infinity) - .frame(height: 48) - .background(LitheTheme.window) - - HStack(spacing: 0) { - welcomeSidebar - Rectangle().fill(LitheTheme.divider).frame(width: 1) - projectsContent - } + HStack(spacing: 0) { + welcomeSidebar + Rectangle().fill(LitheTheme.divider.opacity(0.55)).frame(width: 1) + projectsContent } - .background(LitheTheme.window) + .background(LitheTheme.editor) .background(WelcomeInitialFocusReset()) } @@ -84,7 +75,10 @@ struct WelcomeView: View { .padding(.bottom, 14) } .frame(width: 240) - .background(LitheTheme.sidebar) + .background( + LitheTheme.sidebar + .ignoresSafeArea(.container, edges: .top) + ) } @ViewBuilder @@ -214,7 +208,7 @@ struct WelcomeView: View { } } } - .background(LitheTheme.window) + .background(LitheTheme.editor) } private var emptyProjectsState: some View { diff --git a/Sources/Lithe/Views/Components/LitheContextMenu.swift b/Sources/Lithe/Views/Components/LitheContextMenu.swift new file mode 100644 index 000000000..bcd299dd2 --- /dev/null +++ b/Sources/Lithe/Views/Components/LitheContextMenu.swift @@ -0,0 +1,303 @@ +import AppKit +import SwiftUI + +struct LitheContextMenuItem: Identifiable { + enum Kind { + case action + case separator + } + + enum Role { + case standard + case destructive + } + + let id = UUID() + let kind: Kind + let title: String + let systemImage: String? + let shortcut: String? + let role: Role + let isEnabled: Bool + let action: () -> Void + + static func action( + _ title: String, + systemImage: String? = nil, + shortcut: String? = nil, + role: Role = .standard, + isEnabled: Bool = true, + action: @escaping () -> Void + ) -> Self { + Self( + kind: .action, + title: title, + systemImage: systemImage, + shortcut: shortcut, + role: role, + isEnabled: isEnabled, + action: action + ) + } + + static var separator: Self { + Self( + kind: .separator, + title: "", + systemImage: nil, + shortcut: nil, + role: .standard, + isEnabled: false, + action: {} + ) + } +} + +private struct LitheContextMenuContent: View { + let items: [LitheContextMenuItem] + let width: CGFloat + let dismiss: () -> Void + + var body: some View { + VStack(spacing: 0) { + ForEach(items) { item in + switch item.kind { + case .action: + LitheContextMenuRow(item: item) { + dismiss() + item.action() + } + case .separator: + Rectangle() + .fill(LitheTheme.divider) + .frame(height: 1) + .padding(.horizontal, 8) + .padding(.vertical, 5) + } + } + } + .padding(.vertical, 6) + .frame(width: width) + .background { + RoundedRectangle(cornerRadius: 9, style: .continuous) + .fill(LitheTheme.sidebar) + } + .overlay { + RoundedRectangle(cornerRadius: 9, style: .continuous) + .stroke(LitheTheme.panelBorder, lineWidth: 1) + } + } +} + +private struct LitheContextMenuRow: View { + let item: LitheContextMenuItem + let action: () -> Void + @State private var isHovering = false + + var body: some View { + Button(action: action) { + HStack(spacing: 9) { + Group { + if let systemImage = item.systemImage { + Image(systemName: systemImage) + .font(.system(size: 13, weight: .regular)) + } else { + Color.clear + } + } + .frame(width: 16, height: 16) + .foregroundStyle( + item.role == .destructive + ? LitheTheme.error + : (isHovering ? LitheTheme.toolWindowSelectedText : LitheTheme.secondaryText) + ) + + Text(LocalizedStringKey(item.title)) + .font(.system(size: 13, weight: .regular)) + .foregroundStyle(isHovering ? LitheTheme.toolWindowSelectedText : LitheTheme.primaryText) + .lineLimit(1) + + Spacer(minLength: 14) + + if let shortcut = item.shortcut { + Text(shortcut) + .font(.system(size: 12, weight: .regular)) + .foregroundStyle(isHovering ? LitheTheme.toolWindowSelectedText.opacity(0.78) : LitheTheme.tertiaryText) + } + } + .padding(.horizontal, 9) + .frame(height: 28) + .contentShape(Rectangle()) + .background { + RoundedRectangle(cornerRadius: 5, style: .continuous) + .fill(isHovering ? LitheTheme.selection : .clear) + } + .padding(.horizontal, 5) + } + .buttonStyle(.plain) + .disabled(!item.isEnabled) + .opacity(item.isEnabled ? 1 : 0.45) + .onHover { isHovering = $0 } + } +} + +@MainActor +private final class LitheContextMenuPanel: NSPanel { + override var canBecomeKey: Bool { true } +} + +@MainActor +private final class LitheContextMenuPresenter: NSObject, NSWindowDelegate { + static let shared = LitheContextMenuPresenter() + + private let menuWidth: CGFloat = 252 + private var panel: LitheContextMenuPanel? + private var localEventMonitor: Any? + private var globalEventMonitor: Any? + + func show( + items: [LitheContextMenuItem], + at screenPoint: NSPoint, + appearance: NSAppearance?, + locale: Locale + ) { + dismiss() + guard !items.isEmpty else { return } + + let menuHeight = items.reduce(CGFloat(12)) { height, item in + height + (item.kind == .separator ? 11 : 28) + } + let content = LitheContextMenuContent( + items: items, + width: menuWidth, + dismiss: { [weak self] in self?.dismiss() } + ) + .environment(\.locale, locale) + .frame(width: menuWidth, height: menuHeight) + + let panel = LitheContextMenuPanel( + contentRect: NSRect(x: 0, y: 0, width: menuWidth, height: menuHeight), + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: false + ) + panel.contentViewController = NSHostingController(rootView: content) + panel.appearance = appearance + panel.backgroundColor = .clear + panel.isOpaque = false + panel.hasShadow = true + panel.level = .popUpMenu + panel.isFloatingPanel = true + panel.hidesOnDeactivate = true + panel.collectionBehavior = [.transient, .fullScreenAuxiliary] + panel.delegate = self + + let visibleFrame = NSScreen.screens + .first(where: { $0.frame.contains(screenPoint) })? + .visibleFrame ?? NSScreen.main?.visibleFrame ?? .zero + let preferredOrigin = NSPoint(x: screenPoint.x - 6, y: screenPoint.y - menuHeight + 6) + let origin = NSPoint( + x: min(max(preferredOrigin.x, visibleFrame.minX + 6), visibleFrame.maxX - menuWidth - 6), + y: min(max(preferredOrigin.y, visibleFrame.minY + 6), visibleFrame.maxY - menuHeight - 6) + ) + panel.setFrameOrigin(origin) + + self.panel = panel + installEventMonitors() + panel.orderFrontRegardless() + panel.makeKey() + } + + func dismiss() { + removeEventMonitors() + panel?.orderOut(nil) + panel?.close() + panel = nil + } + + func windowDidResignKey(_ notification: Notification) { + dismiss() + } + + private func installEventMonitors() { + localEventMonitor = NSEvent.addLocalMonitorForEvents( + matching: [.leftMouseDown, .rightMouseDown, .keyDown] + ) { [weak self] event in + guard let self else { return event } + if event.type == .keyDown, event.keyCode == 53 { + self.dismiss() + return nil + } + if event.type != .keyDown, event.window !== self.panel { + self.dismiss() + } + return event + } + globalEventMonitor = NSEvent.addGlobalMonitorForEvents( + matching: [.leftMouseDown, .rightMouseDown] + ) { [weak self] _ in + self?.dismiss() + } + } + + private func removeEventMonitors() { + if let localEventMonitor { + NSEvent.removeMonitor(localEventMonitor) + self.localEventMonitor = nil + } + if let globalEventMonitor { + NSEvent.removeMonitor(globalEventMonitor) + self.globalEventMonitor = nil + } + } +} + +@MainActor +private struct LitheContextMenuTrigger: NSViewRepresentable { + @Environment(\.locale) private var locale + let items: () -> [LitheContextMenuItem] + + func makeNSView(context: Context) -> LitheRightClickCaptureView { + let view = LitheRightClickCaptureView() + update(view) + return view + } + + func updateNSView(_ nsView: LitheRightClickCaptureView, context: Context) { + update(nsView) + } + + private func update(_ view: LitheRightClickCaptureView) { + view.onRightClick = { screenPoint, appearance in + LitheContextMenuPresenter.shared.show( + items: items(), + at: screenPoint, + appearance: appearance, + locale: locale + ) + } + } +} + +@MainActor +private final class LitheRightClickCaptureView: NSView { + var onRightClick: (@MainActor (NSPoint, NSAppearance?) -> Void)? + + override func hitTest(_ point: NSPoint) -> NSView? { + guard NSApp.currentEvent?.type == .rightMouseDown else { return nil } + return super.hitTest(point) + } + + override func rightMouseDown(with event: NSEvent) { + guard let window else { return } + onRightClick?(window.convertPoint(toScreen: event.locationInWindow), effectiveAppearance) + } +} + +extension View { + func litheContextMenu(items: @escaping () -> [LitheContextMenuItem]) -> some View { + overlay { + LitheContextMenuTrigger(items: items) + } + } +} diff --git a/Sources/Lithe/Views/Components/LitheSettingsControls.swift b/Sources/Lithe/Views/Components/LitheSettingsControls.swift new file mode 100644 index 000000000..04d7b13a5 --- /dev/null +++ b/Sources/Lithe/Views/Components/LitheSettingsControls.swift @@ -0,0 +1,300 @@ +import SwiftUI + +struct LitheSettingsSelect: View { + @Binding private var selection: Value + private let options: [Value] + private let width: CGFloat + private let accessibilityLabel: String + private let title: (Value) -> String + @State private var isPresented = false + + init( + selection: Binding, + options: [Value], + width: CGFloat, + accessibilityLabel: String, + title: @escaping (Value) -> String + ) { + _selection = selection + self.options = options + self.width = width + self.accessibilityLabel = accessibilityLabel + self.title = title + } + + var body: some View { + Button { + isPresented.toggle() + } label: { + HStack(spacing: 8) { + Text(LocalizedStringKey(title(selection))) + .font(.system(size: 12.5)) + .foregroundStyle(LitheTheme.primaryText) + .lineLimit(1) + + Spacer(minLength: 8) + + Image(systemName: "chevron.down") + .font(.system(size: 9, weight: .semibold)) + .foregroundStyle(LitheTheme.secondaryText) + .rotationEffect(.degrees(isPresented ? 180 : 0)) + } + .padding(.horizontal, 9) + .frame(width: width, height: 30, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: LitheTheme.Metrics.controlCornerRadius) + .fill(LitheTheme.inputBackground) + ) + .overlay { + RoundedRectangle(cornerRadius: LitheTheme.Metrics.controlCornerRadius) + .stroke(isPresented ? LitheTheme.inputFocusBorder : LitheTheme.inputBorder, lineWidth: 1) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .lithePointer() + .accessibilityLabel(Text(LocalizedStringKey(accessibilityLabel))) + .popover(isPresented: $isPresented, arrowEdge: .bottom) { + VStack(spacing: 2) { + ForEach(options, id: \.self) { option in + Button { + selection = option + isPresented = false + } label: { + HStack(spacing: 8) { + Image(systemName: "checkmark") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(LitheTheme.accent) + .frame(width: 14) + .opacity(selection == option ? 1 : 0) + + Text(LocalizedStringKey(title(option))) + .font(.system(size: 12.5)) + .foregroundStyle(LitheTheme.primaryText) + .lineLimit(1) + + Spacer(minLength: 8) + } + .padding(.horizontal, 8) + .frame(maxWidth: .infinity, minHeight: 28, alignment: .leading) + .litheRowHover( + isActive: selection == option, + activeBackground: LitheTheme.subtleSelection + ) + .contentShape(Rectangle()) + } + .buttonStyle(LitheTreeRowButtonStyle()) + .lithePointer() + } + } + .padding(5) + .frame(width: width) + .lithePopupChrome(cornerRadius: LitheTheme.Metrics.controlCornerRadius) + } + } +} + +struct LitheSettingsSegmentedControl: View { + @Binding private var selection: Value + private let options: [Value] + private let width: CGFloat + private let title: (Value) -> String + + init( + selection: Binding, + options: [Value], + width: CGFloat, + title: @escaping (Value) -> String + ) { + _selection = selection + self.options = options + self.width = width + self.title = title + } + + var body: some View { + HStack(spacing: 2) { + ForEach(options, id: \.self) { option in + Button { + selection = option + } label: { + Text(LocalizedStringKey(title(option))) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(selection == option ? Color.white : LitheTheme.secondaryText) + .frame(maxWidth: .infinity, minHeight: 26) + .contentShape(Rectangle()) + .litheRowHover( + isActive: selection == option, + cornerRadius: LitheTheme.Metrics.cornerRadius, + activeBackground: LitheTheme.selection + ) + } + .buttonStyle(LitheTreeRowButtonStyle()) + .lithePointer() + } + } + .padding(2) + .frame(width: width, height: 30) + .background( + RoundedRectangle(cornerRadius: LitheTheme.Metrics.controlCornerRadius) + .fill(LitheTheme.inputBackground) + ) + .overlay { + RoundedRectangle(cornerRadius: LitheTheme.Metrics.controlCornerRadius) + .stroke(LitheTheme.inputBorder, lineWidth: 1) + } + } +} + +struct LitheSettingsCheckbox: View { + @Binding var isOn: Bool + private let title: LocalizedStringKey? + private let accessibilityLabel: LocalizedStringKey + + init(isOn: Binding, title: LocalizedStringKey) { + _isOn = isOn + self.title = title + accessibilityLabel = title + } + + init(isOn: Binding, accessibilityLabel: LocalizedStringKey) { + _isOn = isOn + title = nil + self.accessibilityLabel = accessibilityLabel + } + + var body: some View { + Button { + isOn.toggle() + } label: { + HStack(spacing: 8) { + ZStack { + RoundedRectangle(cornerRadius: 4) + .fill(isOn ? LitheTheme.accent : LitheTheme.inputBackground) + RoundedRectangle(cornerRadius: 4) + .stroke(isOn ? LitheTheme.accent : LitheTheme.inputBorder, lineWidth: 1) + Image(systemName: "checkmark") + .font(.system(size: 9, weight: .bold)) + .foregroundStyle(Color.white) + .opacity(isOn ? 1 : 0) + } + .frame(width: 16, height: 16) + + if let title { + Text(title) + .font(.system(size: 12.5)) + .foregroundStyle(LitheTheme.primaryText) + } + } + .contentShape(Rectangle()) + } + .buttonStyle(LitheTreeRowButtonStyle()) + .lithePointer() + .accessibilityLabel(Text(accessibilityLabel)) + } +} + +struct LitheSettingsStepper: View where Value: Strideable & Comparable, Value.Stride: SignedNumeric & Comparable { + @Binding private var value: Value + private let range: ClosedRange + private let step: Value.Stride + private let width: CGFloat + private let accessibilityLabel: LocalizedStringKey + private let title: (Value) -> String + + init( + value: Binding, + in range: ClosedRange, + step: Value.Stride, + width: CGFloat, + accessibilityLabel: LocalizedStringKey, + title: @escaping (Value) -> String + ) { + _value = value + self.range = range + self.step = step + self.width = width + self.accessibilityLabel = accessibilityLabel + self.title = title + } + + var body: some View { + HStack(spacing: 0) { + Text(title(value)) + .font(.system(size: 12.5)) + .foregroundStyle(LitheTheme.primaryText) + .monospacedDigit() + .frame(maxWidth: .infinity, alignment: .trailing) + .padding(.horizontal, 8) + + Rectangle() + .fill(LitheTheme.inputBorder) + .frame(width: 1, height: 20) + + stepButton(systemImage: "minus", isDisabled: value <= range.lowerBound) { + value = max(range.lowerBound, value.advanced(by: -step)) + } + + stepButton(systemImage: "plus", isDisabled: value >= range.upperBound) { + value = min(range.upperBound, value.advanced(by: step)) + } + } + .frame(width: width, height: 30) + .background( + RoundedRectangle(cornerRadius: LitheTheme.Metrics.controlCornerRadius) + .fill(LitheTheme.inputBackground) + ) + .overlay { + RoundedRectangle(cornerRadius: LitheTheme.Metrics.controlCornerRadius) + .stroke(LitheTheme.inputBorder, lineWidth: 1) + } + .clipShape(RoundedRectangle(cornerRadius: LitheTheme.Metrics.controlCornerRadius)) + .accessibilityElement(children: .contain) + .accessibilityLabel(Text(accessibilityLabel)) + } + + private func stepButton( + systemImage: String, + isDisabled: Bool, + action: @escaping () -> Void + ) -> some View { + Button(action: action) { + Image(systemName: systemImage) + .font(.system(size: 9, weight: .semibold)) + .foregroundStyle(isDisabled ? LitheTheme.tertiaryText : LitheTheme.secondaryText) + .frame(width: 26, height: 28) + .contentShape(Rectangle()) + .litheRowHover(cornerRadius: 0) + } + .buttonStyle(LitheTreeRowButtonStyle()) + .disabled(isDisabled) + .lithePointer() + } +} + +private struct LitheSettingsTextFieldModifier: ViewModifier { + @Environment(\.isEnabled) private var isEnabled + + func body(content: Content) -> some View { + content + .textFieldStyle(.plain) + .font(.system(size: 12.5)) + .padding(.horizontal, 9) + .frame(height: 30) + .background( + RoundedRectangle(cornerRadius: LitheTheme.Metrics.controlCornerRadius) + .fill(LitheTheme.inputBackground) + ) + .overlay { + RoundedRectangle(cornerRadius: LitheTheme.Metrics.controlCornerRadius) + .stroke(LitheTheme.inputBorder, lineWidth: 1) + } + .opacity(isEnabled ? 1 : 0.55) + } +} + +extension View { + func litheSettingsTextField() -> some View { + modifier(LitheSettingsTextFieldModifier()) + } +} diff --git a/Sources/Lithe/Views/Components/LitheToolWindowHeader.swift b/Sources/Lithe/Views/Components/LitheToolWindowHeader.swift index 926b92bfa..13f46bccd 100644 --- a/Sources/Lithe/Views/Components/LitheToolWindowHeader.swift +++ b/Sources/Lithe/Views/Components/LitheToolWindowHeader.swift @@ -35,15 +35,15 @@ struct LitheToolWindowHeader: View { size: 13, fallbackSystemImage: systemImage ?? "circle" ) - .foregroundStyle(LitheTheme.secondaryText) + .foregroundStyle(LitheTheme.toolWindowText) } else if let systemImage { Image(systemName: systemImage) .font(.system(size: 12, weight: .medium)) - .foregroundStyle(LitheTheme.secondaryText) + .foregroundStyle(LitheTheme.toolWindowText) } Text(LocalizedStringKey(title)) .font(.system(size: 12.5, weight: .semibold)) - .foregroundStyle(LitheTheme.primaryText) + .foregroundStyle(LitheTheme.toolWindowText) if let subtitle, !subtitle.isEmpty { Text(LocalizedStringKey(subtitle)) .font(.system(size: 11.5, weight: .medium)) diff --git a/Sources/Lithe/Views/Debug/GenericDebugView.swift b/Sources/Lithe/Views/Debug/GenericDebugView.swift index aeb1931b9..a71e3e5fb 100644 --- a/Sources/Lithe/Views/Debug/GenericDebugView.swift +++ b/Sources/Lithe/Views/Debug/GenericDebugView.swift @@ -94,102 +94,110 @@ struct GenericDebugView: View { private var inspector: some View { ScrollView { LazyVStack(alignment: .leading, spacing: 0) { - sectionHeader("Breakpoints", count: feature.breakpoints.count) - if feature.breakpoints.isEmpty { - placeholder("Click the editor gutter to add a breakpoint") - } else { - ForEach(feature.breakpoints) { breakpoint in - HStack(spacing: 7) { - Image(systemName: breakpoint.verified ? "circle.fill" : "circle") - .font(.system(size: 8)) - .foregroundStyle(breakpoint.verified ? LitheTheme.error : LitheTheme.warning) - Text(breakpoint.title) - .font(.system(size: 11, design: .monospaced)) - .lineLimit(1) - Spacer(minLength: 0) + Group { + sectionHeader("Breakpoints", count: feature.breakpoints.count) + if feature.breakpoints.isEmpty { + placeholder("Click the editor gutter to add a breakpoint") + } else { + ForEach(feature.breakpoints) { breakpoint in + HStack(spacing: 7) { + Image(systemName: breakpoint.verified ? "circle.fill" : "circle") + .font(.system(size: 8)) + .foregroundStyle(breakpoint.verified ? LitheTheme.error : LitheTheme.warning) + Text(breakpoint.title) + .font(.system(size: 11, design: .monospaced)) + .lineLimit(1) + Spacer(minLength: 0) + } + .help(breakpoint.message ?? breakpoint.title) + .padding(.horizontal, 10) + .frame(height: 27) } - .help(breakpoint.message ?? breakpoint.title) - .padding(.horizontal, 10) - .frame(height: 27) } } - divider - sectionHeader("Threads", count: feature.threads.count) - if feature.threads.isEmpty { - Button("Load threads") { feature.inspectThreads() } - .buttonStyle(.plain) - .font(LitheTheme.smallFont) - .foregroundStyle(LitheTheme.accent) - .padding(10) - } else { - ForEach(feature.threads) { thread in - rowButton(selected: feature.selectedThreadID == thread.id) { - feature.selectThread(thread) - } label: { - Image(systemName: "circle") - Text(thread.name).lineLimit(1) + Group { + divider + sectionHeader("Threads", count: feature.threads.count) + if feature.threads.isEmpty { + Button("Load threads") { feature.inspectThreads() } + .buttonStyle(.plain) + .font(LitheTheme.smallFont) + .foregroundStyle(LitheTheme.accent) + .padding(10) + } else { + ForEach(feature.threads) { thread in + rowButton(selected: feature.selectedThreadID == thread.id) { + feature.selectThread(thread) + } label: { + Image(systemName: "circle") + Text(thread.name).lineLimit(1) + } } } } - divider - sectionHeader("Call Stack", count: feature.stackFrames.count) - if feature.stackFrames.isEmpty { - placeholder("Pause the process to inspect frames") - } else { - ForEach(feature.stackFrames) { frame in - rowButton(selected: feature.selectedFrameID == frame.id) { - feature.selectFrame(frame) - if let sourceURL = frame.sourceURL { - model.openSourceLocation( - url: sourceURL, - line: frame.line, - column: frame.column - ) - } - } label: { - Image(systemName: "chevron.right") - VStack(alignment: .leading, spacing: 1) { - Text(frame.name).lineLimit(1) + Group { + divider + sectionHeader("Call Stack", count: feature.stackFrames.count) + if feature.stackFrames.isEmpty { + placeholder("Pause the process to inspect frames") + } else { + ForEach(feature.stackFrames) { frame in + rowButton(selected: feature.selectedFrameID == frame.id) { + feature.selectFrame(frame) if let sourceURL = frame.sourceURL { - Text("\(sourceURL.lastPathComponent):\(frame.line)") - .font(.system(size: 9.5, design: .monospaced)) - .foregroundStyle(LitheTheme.secondaryText) + model.openSourceLocation( + url: sourceURL, + line: frame.line, + column: frame.column + ) + } + } label: { + Image(systemName: "chevron.right") + VStack(alignment: .leading, spacing: 1) { + Text(frame.name).lineLimit(1) + if let sourceURL = frame.sourceURL { + Text("\(sourceURL.lastPathComponent):\(frame.line)") + .font(.system(size: 9.5, design: .monospaced)) + .foregroundStyle(LitheTheme.secondaryText) + } } } } } } - divider - sectionHeader("Variables", count: feature.variables.count) - if feature.variables.isEmpty { - placeholder("Select a stack frame to inspect variables") - } else { - ForEach(feature.variables) { variable in - HStack(alignment: .firstTextBaseline, spacing: 6) { - Image(systemName: variable.isExpandable ? "chevron.right" : "circle.fill") - .font(.system(size: variable.isExpandable ? 8 : 4)) - .foregroundStyle(LitheTheme.secondaryText) - Text(variable.name) - .font(.system(size: 10.5, design: .monospaced)) - Text("=") - .foregroundStyle(LitheTheme.secondaryText) - Text(variable.value) - .font(.system(size: 10.5, design: .monospaced)) - .foregroundStyle(LitheTheme.accent) - .lineLimit(2) - Spacer(minLength: 0) - } - .contentShape(Rectangle()) - .onTapGesture { - if variable.isExpandable { - feature.loadVariables(reference: variable.variablesReference) + Group { + divider + sectionHeader("Variables", count: feature.variables.count) + if feature.variables.isEmpty { + placeholder("Select a stack frame to inspect variables") + } else { + ForEach(feature.variables) { variable in + HStack(alignment: .firstTextBaseline, spacing: 6) { + Image(systemName: variable.isExpandable ? "chevron.right" : "circle.fill") + .font(.system(size: variable.isExpandable ? 8 : 4)) + .foregroundStyle(LitheTheme.secondaryText) + Text(variable.name) + .font(.system(size: 10.5, design: .monospaced)) + Text("=") + .foregroundStyle(LitheTheme.secondaryText) + Text(variable.value) + .font(.system(size: 10.5, design: .monospaced)) + .foregroundStyle(LitheTheme.accent) + .lineLimit(2) + Spacer(minLength: 0) + } + .contentShape(Rectangle()) + .onTapGesture { + if variable.isExpandable { + feature.loadVariables(reference: variable.variablesReference) + } } + .padding(.horizontal, 10) + .padding(.vertical, 5) } - .padding(.horizontal, 10) - .padding(.vertical, 5) } } diff --git a/Sources/Lithe/Views/Editor/CodeEditorView.swift b/Sources/Lithe/Views/Editor/CodeEditorView.swift index 40993b9f0..343d6728f 100644 --- a/Sources/Lithe/Views/Editor/CodeEditorView.swift +++ b/Sources/Lithe/Views/Editor/CodeEditorView.swift @@ -9,8 +9,20 @@ fileprivate struct CodeEditorPalette { static let dark = CodeEditorPalette(isDark: true, theme: .lithe) var background: NSColor { themeColor(.editor) } - var gutterBackground: NSColor { themeColor(.sidebar) } - var text: NSColor { themeColor(.primaryText) } + var gutterBackground: NSColor { themeColor(.editor) } + var gutterDivider: NSColor { + color( + light: (0.78, 0.79, 0.81, 1), + dark: (0.204, 0.212, 0.231, 1) + ) + } + var text: NSColor { + guard theme == .lithe else { return themeColor(.primaryText) } + return color( + light: (0, 0, 0, 0.82), + dark: (0.737, 0.745, 0.769, 1) + ) + } var caret: NSColor { themeColor(.primaryText) } var selection: NSColor { themeColor(.accent).withAlphaComponent(isDark ? 0.42 : 0.24) } var selectionText: NSColor { themeColor(.primaryText) } @@ -55,24 +67,72 @@ fileprivate struct CodeEditorPalette { } } +private enum EditorLayoutMetrics { + static let standardGutterWidth: CGFloat = 45 + static let leadingInset: CGFloat = 0 + static let lineFragmentPadding: CGFloat = 4 + static let caretWidth: CGFloat = 2 +} + +struct EditorViewportState: Equatable { + var selectionLocation = 0 + var selectionLength = 0 + var verticalScrollOffset: CGFloat = 0 +} + +@MainActor +final class EditorViewportStore { + private var states: [UUID: EditorViewportState] = [:] + + func state(for documentID: UUID) -> EditorViewportState { + states[documentID] ?? EditorViewportState() + } + + func updateSelection(_ selection: NSRange, for documentID: UUID) { + guard selection.location != NSNotFound else { return } + var state = state(for: documentID) + state.selectionLocation = selection.location + state.selectionLength = selection.length + states[documentID] = state + } + + func updateScrollOffset(_ offset: CGFloat, for documentID: UUID) { + var state = state(for: documentID) + state.verticalScrollOffset = offset + states[documentID] = state + } + + func retain(documentIDs: Set) { + states = states.filter { documentIDs.contains($0.key) } + } +} + struct CodeEditorView: NSViewRepresentable { @Environment(\.colorScheme) private var colorScheme @EnvironmentObject private var model: AppModel + @EnvironmentObject private var chrome: EditorChromeModel + @EnvironmentObject private var diagnosticsStore: EditorDiagnosticsStore @EnvironmentObject private var settings: AppSettings @ObservedObject var document: EditorDocument var debugService: JavaDebugFeatureModel? var shouldFocus = true var markdownScrollPosition: Binding? = nil + let viewportStore: EditorViewportStore func makeCoordinator() -> Coordinator { Coordinator( document: document, model: model, debugService: debugService, - markdownScrollPosition: markdownScrollPosition + markdownScrollPosition: markdownScrollPosition, + viewportStore: viewportStore ) } + static func dismantleNSView(_ nsView: EditorContainerView, coordinator: Coordinator) { + coordinator.persistViewport() + } + func makeNSView(context: Context) -> EditorContainerView { let palette = CodeEditorPalette(isDark: colorScheme == .dark, theme: settings.colorTheme) let container = EditorContainerView() @@ -100,7 +160,9 @@ struct CodeEditorView: NSViewRepresentable { scrollView.topAnchor.constraint(equalTo: container.topAnchor), scrollView.bottomAnchor.constraint(equalTo: container.bottomAnchor) ]) - let gutterWidthConstraint = gutter.widthAnchor.constraint(equalToConstant: 52) + let gutterWidthConstraint = gutter.widthAnchor.constraint( + equalToConstant: EditorLayoutMetrics.standardGutterWidth + ) gutterWidthConstraint.isActive = true let textView = CodeTextView(frame: NSRect(x: 0, y: 0, width: 900, height: 700)) @@ -118,8 +180,10 @@ struct CodeEditorView: NSViewRepresentable { textView.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude) textView.textContainer?.containerSize = NSSize(width: scrollView.contentSize.width, height: CGFloat.greatestFiniteMagnitude) textView.textContainer?.widthTracksTextView = true - textView.textContainerInset = NSSize(width: 12, height: 10) - textView.font = .monospacedSystemFont(ofSize: settings.editorFontSize, weight: .regular) + textView.textContainerInset = NSSize(width: EditorLayoutMetrics.leadingInset, height: 0) + textView.textContainer?.lineFragmentPadding = EditorLayoutMetrics.lineFragmentPadding + textView.font = LitheTheme.editorFont(size: settings.editorFontSize) + textView.defaultParagraphStyle = LitheTheme.editorParagraphStyle textView.indentationWidth = settings.tabWidth textView.applyAppearance(palette) textView.isEditable = !document.isReadOnly @@ -185,9 +249,13 @@ struct CodeEditorView: NSViewRepresentable { gutter.attach(textView: textView, scrollView: scrollView) gutter.applyAppearance(palette) context.coordinator.attachMarkdownScrollSync(to: scrollView) + context.coordinator.attachViewportTracking(to: scrollView) context.coordinator.textView = textView context.coordinator.gutter = gutter + textView.onCaretPresentationChanged = { [weak gutter] in + gutter?.needsDisplay = true + } context.coordinator.container = container context.coordinator.attachMarkdownImagePasteMonitor(to: scrollView) context.coordinator.codeVisionOverlay = CodeVisionOverlayController(textView: textView) @@ -206,6 +274,7 @@ struct CodeEditorView: NSViewRepresentable { context.coordinator.updateDiagnostics() context.coordinator.shouldFocus = shouldFocus context.coordinator.requestInitialFocusIfNeeded() + context.coordinator.restoreViewportWhenReady() return container } @@ -220,29 +289,32 @@ struct CodeEditorView: NSViewRepresentable { context.coordinator.shouldFocus = shouldFocus context.coordinator.markdownScrollPosition = markdownScrollPosition if let scrollView = container.scrollView { - scrollView.backgroundColor = palette.background + if appearanceChanged { + scrollView.backgroundColor = palette.background + } context.coordinator.attachMarkdownScrollSync(to: scrollView) context.coordinator.attachMarkdownImagePasteMonitor(to: scrollView) } context.coordinator.isDarkAppearance = palette.isDark context.coordinator.colorTheme = settings.colorTheme context.coordinator.requestInitialFocusIfNeeded() - textView.font = .monospacedSystemFont(ofSize: settings.editorFontSize, weight: .regular) - if let codeTextView = textView as? CodeTextView { - codeTextView.indentationWidth = settings.tabWidth - codeTextView.languageServerFeatures = model.languageToolingSessionsIfActive?.features(for: document.url) ?? [] - codeTextView.isLanguageNavigationEnabled = !codeTextView.languageServerFeatures.intersection([ - .definition, .references, .implementation - ]).isEmpty - codeTextView.isLanguageIntelligenceEnabled = !codeTextView.languageServerFeatures.intersection([ - .hover, .completion, .rename, .formatting, .codeActions - ]).isEmpty - } - container.gutter?.applyAppearance(palette) - textView.isEditable = !document.isReadOnly - textView.isSelectable = true + + let languageFeatures = model.languageToolingSessionsIfActive?.features(for: document.url) ?? [] + let fontSize = settings.editorFontSize + let tabWidth = settings.tabWidth + let chromeChanged = context.coordinator.applyEditorChromeIfNeeded( + fontSize: fontSize, + tabWidth: tabWidth, + languageFeatures: languageFeatures, + isReadOnly: document.isReadOnly, + palette: palette, + textView: textView, + gutter: container.gutter + ) + // Keep IME marked text (for example, an active Chinese pinyin // composition) in the NSTextView until the input method commits it. + var textChanged = false if textView.string != document.text, !textView.hasMarkedText(), !context.coordinator.isApplyingEditorChange { @@ -253,17 +325,27 @@ struct CodeEditorView: NSViewRepresentable { context.coordinator.highlight() (textView as? CodeTextView)?.updateEditorDecorations() container.gutter?.needsDisplay = true + textChanged = true } if appearanceChanged { context.coordinator.highlight() (textView as? CodeTextView)?.updateEditorDecorations() + } else if chromeChanged, !textChanged { + (textView as? CodeTextView)?.updateEditorDecorations() } context.coordinator.updateCodeVisionAndBlame() context.coordinator.updateGitLineChanges() context.coordinator.updateDiagnostics() context.coordinator.applyNavigationTargetIfNeeded() if let codeTextView = textView as? CodeTextView { - codeTextView.syncFindState(isVisible: model.isFindBarVisible, query: model.findBarQuery) + let findVisible = chrome.isFindBarVisible + let findQuery = chrome.findBarQuery + if context.coordinator.lastFindVisible != findVisible + || context.coordinator.lastFindQuery != findQuery { + context.coordinator.lastFindVisible = findVisible + context.coordinator.lastFindQuery = findQuery + codeTextView.syncFindState(isVisible: findVisible, query: findQuery) + } } context.coordinator.applySynchronizedMarkdownScrollIfNeeded(to: container.scrollView) } @@ -287,33 +369,128 @@ struct CodeEditorView: NSViewRepresentable { var appliedNavigationTargetID: UUID? var foldRegions: [JavaFoldRegion] = [] var collapsedFoldIDs: Set = [] + var lastFindVisible = false + var lastFindQuery = "" + private var pendingHighlightRange: NSRange? + private var pendingReplacedRange: NSRange? + private var pendingReplacement: String? + private var foldRefreshTask: Task? + private var decorationRefreshTask: Task? + private var documentChangeTask: Task? + private var remainingHighlightTask: Task? + private var appliedFontSize: CGFloat? + private var appliedTabWidth: Int? + private var appliedLanguageFeatures: LanguageServerFeatureSet? + private var appliedReadOnly: Bool? + private var appliedCodeVisionHints: [JavaCodeVisionHint]? + private var appliedInlayHints: [JavaInlayHint]? + private var appliedBlameVisible = false + private var appliedBlameLines: [GitBlameLine] = [] + private var appliedDebugBreakpointLines = Set() + private var appliedGitMarkers: [GitLineChangeMarker]? + private var appliedDiagnostics: [EditorDiagnostic]? private var markdownImagePasteMonitor: Any? private weak var markdownScrollView: NSScrollView? private var markdownScrollObserver: NSObjectProtocol? + private var viewportScrollObserver: NSObjectProtocol? private var isApplyingSynchronizedMarkdownScroll = false + private var isRestoringViewport = true private var lastObservedMarkdownScrollRevision: UInt64? private var isLoadingGitLineChanges = false + private let viewportStore: EditorViewportStore init( document: EditorDocument, model: AppModel, debugService: JavaDebugFeatureModel?, - markdownScrollPosition: Binding? + markdownScrollPosition: Binding?, + viewportStore: EditorViewportStore ) { self.document = document self.model = model self.debugService = debugService self.markdownScrollPosition = markdownScrollPosition + self.viewportStore = viewportStore fileExtension = document.url.pathExtension } deinit { + foldRefreshTask?.cancel() + decorationRefreshTask?.cancel() + documentChangeTask?.cancel() + remainingHighlightTask?.cancel() if let markdownImagePasteMonitor { NSEvent.removeMonitor(markdownImagePasteMonitor) } if let markdownScrollObserver { NotificationCenter.default.removeObserver(markdownScrollObserver) } + if let viewportScrollObserver { + NotificationCenter.default.removeObserver(viewportScrollObserver) + } + } + + func attachViewportTracking(to scrollView: NSScrollView) { + guard viewportScrollObserver == nil else { return } + scrollView.contentView.postsBoundsChangedNotifications = true + viewportScrollObserver = NotificationCenter.default.addObserver( + forName: NSView.boundsDidChangeNotification, + object: scrollView.contentView, + queue: .main + ) { [weak self, weak scrollView] _ in + MainActor.assumeIsolated { + guard let self, let scrollView, !self.isRestoringViewport, + let document = self.document else { return } + self.viewportStore.updateScrollOffset( + scrollView.contentView.bounds.minY, + for: document.id + ) + } + } + } + + func restoreViewportWhenReady() { + DispatchQueue.main.async { [weak self] in + guard let self, let document, let textView, + let scrollView = textView.enclosingScrollView else { return } + if let target = self.model?.editorNavigationTarget, + target.url.standardizedFileURL == document.url.standardizedFileURL, + self.appliedNavigationTargetID == target.id { + self.isRestoringViewport = false + self.persistViewport() + return + } + let state = self.viewportStore.state(for: document.id) + let textLength = (textView.string as NSString).length + let location = min(state.selectionLocation, textLength) + let length = min(state.selectionLength, textLength - location) + textView.setSelectedRange(NSRange(location: location, length: length)) + let maximumOffset = max( + 0, + (scrollView.documentView?.frame.height ?? 0) + - scrollView.contentView.bounds.height + ) + scrollView.contentView.scroll( + to: NSPoint( + x: scrollView.contentView.bounds.minX, + y: min(max(0, state.verticalScrollOffset), maximumOffset) + ) + ) + scrollView.reflectScrolledClipView(scrollView.contentView) + self.isRestoringViewport = false + self.updateCaret() + } + } + + func persistViewport() { + guard let document, let textView else { return } + viewportStore.updateSelection(textView.selectedRange(), for: document.id) + if let scrollView = textView.enclosingScrollView { + viewportStore.updateScrollOffset( + scrollView.contentView.bounds.minY, + for: document.id + ) + } } func attachMarkdownImagePasteMonitor(to scrollView: NSScrollView) { @@ -465,43 +642,200 @@ struct CodeEditorView: NSViewRepresentable { return true } + func textView(_ textView: NSTextView, shouldChangeTextIn affectedCharRange: NSRange, replacementString: String?) -> Bool { + let inserted = replacementString ?? "" + pendingReplacement = inserted + pendingReplacedRange = affectedCharRange + pendingHighlightRange = NSRange(location: affectedCharRange.location, length: (inserted as NSString).length) + return true + } + func textDidChange(_ notification: Notification) { guard let textView else { return } guard document?.isReadOnly != true else { return } - (textView as? CodeTextView)?.rebuildLineIndex() + let codeTextView = textView as? CodeTextView + if let replacedRange = pendingReplacedRange, let replacement = pendingReplacement { + codeTextView?.applyLineIndexEdit(replacedRange: replacedRange, replacement: replacement) + } else { + codeTextView?.rebuildLineIndex() + } isApplyingEditorChange = true - document?.text = textView.string + document?.applyLiveEditorText(textView.string) if let document { - model?.documentDidChange(document) + scheduleDocumentChange(document) } - highlight() - let codeTextView = textView as? CodeTextView - if let codeTextView, let model, model.isFindBarVisible, !model.findBarQuery.isEmpty { - // 先按新文本重算匹配再统一刷新装饰,避免旧 range 越界 - codeTextView.updateFindMatches(query: model.findBarQuery) + highlight(in: pendingHighlightRange) + let findReplacedRange = pendingReplacedRange + let findInsertedLength = pendingHighlightRange?.length ?? 0 + pendingHighlightRange = nil + pendingReplacedRange = nil + pendingReplacement = nil + if let codeTextView, + let findReplacedRange, + model?.editorChrome.isFindBarVisible == true, + let query = model?.editorChrome.findBarQuery, + !query.isEmpty { + codeTextView.applyFindEdit( + replacedRange: findReplacedRange, + insertedLength: findInsertedLength, + query: query + ) + codeTextView.updateCaretDecorations() + } else if model?.editorChrome.isFindBarVisible == true, + !(model?.editorChrome.findBarQuery.isEmpty ?? true) { + scheduleDecorationRefresh() } else { - codeTextView?.updateEditorDecorations() + codeTextView?.updateCaretDecorations() + scheduleDecorationRefresh() } - refreshFoldRegions(useDefaultImportFold: false) + scheduleFoldRefresh() gutter?.needsDisplay = true isApplyingEditorChange = false updateCaret() } func textViewDidChangeSelection(_ notification: Notification) { - (textView as? CodeTextView)?.updateEditorDecorations() + // Typing already refreshed caret chrome in textDidChange. A second + // full pass here is what dropped the frame rate into the 30s. + guard !isApplyingEditorChange else { return } + if !isRestoringViewport, let document, let textView { + viewportStore.updateSelection( + textView.selectedRange(), + for: document.id + ) + } + (textView as? CodeTextView)?.updateCaretDecorations() textView?.needsDisplay = true gutter?.needsDisplay = true updateCaret() } - func highlight() { - guard let textStorage = textView?.textStorage else { return } + fileprivate func applyEditorChromeIfNeeded( + fontSize: CGFloat, + tabWidth: Int, + languageFeatures: LanguageServerFeatureSet, + isReadOnly: Bool, + palette: CodeEditorPalette, + textView: NSTextView, + gutter: LineNumberGutterView? + ) -> Bool { + var changed = false + if appliedFontSize != fontSize { + textView.font = LitheTheme.editorFont(size: fontSize) + textView.defaultParagraphStyle = LitheTheme.editorParagraphStyle + appliedFontSize = fontSize + changed = true + } + if let codeTextView = textView as? CodeTextView { + codeTextView.applyAppearance(palette) + if appliedTabWidth != tabWidth { + codeTextView.indentationWidth = tabWidth + appliedTabWidth = tabWidth + changed = true + } + if appliedLanguageFeatures != languageFeatures { + codeTextView.languageServerFeatures = languageFeatures + codeTextView.isLanguageNavigationEnabled = !languageFeatures.intersection([ + .definition, .references, .implementation + ]).isEmpty + codeTextView.isLanguageIntelligenceEnabled = !languageFeatures.intersection([ + .hover, .completion, .rename, .formatting, .codeActions + ]).isEmpty + appliedLanguageFeatures = languageFeatures + changed = true + } + } + gutter?.applyAppearance(palette) + if appliedReadOnly != isReadOnly { + textView.isEditable = !isReadOnly + textView.isSelectable = true + appliedReadOnly = isReadOnly + changed = true + } + return changed + } + + func highlight(in editedRange: NSRange? = nil) { + guard let textView, let textStorage = textView.textStorage else { return } + if let editedRange { + SyntaxHighlighter.apply( + to: textStorage, + font: textView.font ?? LitheTheme.editorFont(size: 13), + fileExtension: fileExtension, + isDark: isDarkAppearance, + range: editedRange + ) + return + } + let visible = (textView as? CodeTextView)?.visibleCharacterRange() + ?? NSRange(location: 0, length: min(8_192, textStorage.length)) SyntaxHighlighter.apply( to: textStorage, + font: textView.font ?? LitheTheme.editorFont(size: 13), fileExtension: fileExtension, - isDark: isDarkAppearance + isDark: isDarkAppearance, + range: visible ) + scheduleRemainingHighlight(skipping: visible) + } + + func scheduleRemainingHighlight(skipping alreadyColored: NSRange) { + remainingHighlightTask?.cancel() + remainingHighlightTask = Task { @MainActor [weak self] in + try? await Task.sleep(for: .milliseconds(16)) + guard !Task.isCancelled, let self, let textView = self.textView, + let storage = textView.textStorage else { return } + let font = textView.font ?? LitheTheme.editorFont(size: 13) + let chunk = 16_384 + var location = 0 + while location < storage.length { + if Task.isCancelled { return } + let length = min(chunk, storage.length - location) + let range = NSRange(location: location, length: length) + if NSIntersectionRange(range, alreadyColored) != range { + SyntaxHighlighter.apply( + to: storage, + font: font, + fileExtension: self.fileExtension, + isDark: self.isDarkAppearance, + range: range + ) + } + location += chunk + await Task.yield() + } + } + } + + func scheduleFoldRefresh() { + foldRefreshTask?.cancel() + foldRefreshTask = Task { @MainActor [weak self] in + try? await Task.sleep(for: .milliseconds(80)) + guard !Task.isCancelled, let self else { return } + self.refreshFoldRegions(useDefaultImportFold: false) + } + } + + func scheduleDecorationRefresh() { + decorationRefreshTask?.cancel() + decorationRefreshTask = Task { @MainActor [weak self] in + try? await Task.sleep(for: .milliseconds(80)) + guard !Task.isCancelled, let self, let textView = self.textView as? CodeTextView else { return } + if let model = self.model, model.isFindBarVisible, !model.findBarQuery.isEmpty { + textView.updateFindMatches(query: model.findBarQuery) + } else { + textView.updateEditorDecorations() + } + } + } + + func scheduleDocumentChange(_ document: EditorDocument) { + documentChangeTask?.cancel() + documentChangeTask = Task { @MainActor [weak self, weak document] in + try? await Task.sleep(for: .milliseconds(80)) + guard !Task.isCancelled, let document else { return } + self?.model?.documentDidChange(document) + } } func refreshFoldRegions(useDefaultImportFold: Bool) { @@ -565,19 +899,26 @@ struct CodeEditorView: NSViewRepresentable { guard let document, let model else { return } let url = document.url.standardizedFileURL let hints = model.settings.showCodeVision ? model.javaCodeVisionHints[url] ?? [] : [] - codeVisionOverlay?.update( - hints: hints, - onUsages: { [weak model] hint in model?.findUsages(for: hint, in: url) }, - onImplementations: { [weak model] hint in - model?.findJavaImplementations( - line: hint.line, - utf16Column: hint.utf16Column, - in: url - ) - }, - onAuthor: { [weak model] in model?.showBlame(for: url) } - ) - inlayHintOverlay?.update(hints: model.javaInlayHints[url] ?? []) + if appliedCodeVisionHints != hints { + appliedCodeVisionHints = hints + codeVisionOverlay?.update( + hints: hints, + onUsages: { [weak model] hint in model?.findUsages(for: hint, in: url) }, + onImplementations: { [weak model] hint in + model?.findJavaImplementations( + line: hint.line, + utf16Column: hint.utf16Column, + in: url + ) + }, + onAuthor: { [weak model] in model?.showBlame(for: url) } + ) + } + let inlayHints = model.javaInlayHints[url] ?? [] + if appliedInlayHints != inlayHints { + appliedInlayHints = inlayHints + inlayHintOverlay?.update(hints: inlayHints) + } let isBlameVisible = model.blameVisibleURL == url let blameLines = model.gitBlameLines[url] ?? [] @@ -588,12 +929,21 @@ struct CodeEditorView: NSViewRepresentable { $0.fileURL.standardizedFileURL == url }.map(\.line) let debugBreakpointLines = Set(javaBreakpointLines + genericBreakpointLines) - container?.gutterWidthConstraint?.constant = isBlameVisible ? 224 : 52 - gutter?.update(blameLines: blameLines, isVisible: isBlameVisible) { [weak model] blame in - Task { await model?.showGitCommit(blame.commitHash) } - } - gutter?.updateDebugBreakpointLines(debugBreakpointLines) { [weak model] line in - model?.toggleDebugBreakpoint(fileURL: url, line: line) + if appliedBlameVisible != isBlameVisible + || appliedBlameLines != blameLines + || appliedDebugBreakpointLines != debugBreakpointLines { + appliedBlameVisible = isBlameVisible + appliedBlameLines = blameLines + appliedDebugBreakpointLines = debugBreakpointLines + container?.gutterWidthConstraint?.constant = isBlameVisible + ? 224 + : EditorLayoutMetrics.standardGutterWidth + gutter?.update(blameLines: blameLines, isVisible: isBlameVisible) { [weak model] blame in + Task { await model?.showGitCommit(blame.commitHash) } + } + gutter?.updateDebugBreakpointLines(debugBreakpointLines) { [weak model] line in + model?.toggleDebugBreakpoint(fileURL: url, line: line) + } } } @@ -602,6 +952,8 @@ struct CodeEditorView: NSViewRepresentable { let url = document.url.standardizedFileURL if let markers = model.gitLineChangeMarkers(for: url) { isLoadingGitLineChanges = false + guard appliedGitMarkers != markers else { return } + appliedGitMarkers = markers let change = model.gitChange(for: url) gutter.updateGitLineChanges( markers, @@ -623,7 +975,10 @@ struct CodeEditorView: NSViewRepresentable { return } - gutter.updateGitLineChanges([], onShow: { _ in }) + if appliedGitMarkers != [] { + appliedGitMarkers = [] + gutter.updateGitLineChanges([], onShow: { _ in }) + } guard !isLoadingGitLineChanges else { return } isLoadingGitLineChanges = true Task { @MainActor [weak self, weak model] in @@ -633,11 +988,12 @@ struct CodeEditorView: NSViewRepresentable { } func updateDiagnostics() { - guard let document, let model, + guard let document, let textView = textView as? CodeTextView else { return } - textView.updateDiagnostics( - model.editorDiagnostics[document.url.standardizedFileURL] ?? [] - ) + let diagnostics = model?.editorDiagnosticsStore.diagnostics(for: document.url) ?? [] + guard appliedDiagnostics != diagnostics else { return } + appliedDiagnostics = diagnostics + textView.updateDiagnostics(diagnostics) } func applyNavigationTargetIfNeeded() { @@ -667,12 +1023,21 @@ struct CodeEditorView: NSViewRepresentable { let text = textView.string as NSString updateSelectedText(in: text, range: textView.selectedRange()) let location = min(textView.selectedRange().location, text.length) - let prefix = text.substring(to: location) as NSString - var line = 0 - var lineStart = 0 - for index in 0.. Bool { + let replacedEnd = NSMaxRange(replacedRange) + if starts.contains(where: { $0 > replacedRange.location && $0 <= replacedEnd }) { + return false + } + let delta = insertedLength - replacedRange.length + guard delta != 0 else { return true } + textLength = max(0, textLength + delta) + for index in starts.indices where starts[index] > replacedRange.location { + starts[index] += delta + } + return true + } + var lineCount: Int { guard textLength > 0, starts.last == textLength else { return starts.count } return max(1, starts.count - 1) @@ -752,6 +1133,7 @@ private struct TextLineIndex { } final class CodeTextView: NSTextView, NSLayoutManagerDelegate { + var onCaretPresentationChanged: (() -> Void)? var indentationWidth = 4 var isLanguageNavigationEnabled = false var isLanguageIntelligenceEnabled = false @@ -776,6 +1158,7 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate { private var findMatchRanges: [NSRange] = [] private var currentFindMatchIndex = 0 private var lastReportedFindState: (index: Int, count: Int)? + private var lastCaretBackgroundRanges: [NSRange] = [] private var completionItemsByID: [String: LanguageServerCompletionItem] = [:] private var languageHoverPopover: NSPopover? @@ -798,6 +1181,8 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate { private var hoveredFoldID: String? private var lineIndex = TextLineIndex(source: "" as NSString) nonisolated(unsafe) private var windowResignObserver: NSObjectProtocol? + private var caretVisible = true + private var caretPresentationGeneration = 0 fileprivate func applyAppearance(_ palette: CodeEditorPalette) { guard appliedDarkAppearance != palette.isDark @@ -826,6 +1211,54 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate { super.paste(sender) } + override func setSelectedRange(_ charRange: NSRange) { + super.setSelectedRange(charRange) + synchronizeCaretPresentation() + } + + override func setSelectedRange( + _ charRange: NSRange, + affinity: NSSelectionAffinity, + stillSelecting flag: Bool + ) { + super.setSelectedRange(charRange, affinity: affinity, stillSelecting: flag) + synchronizeCaretPresentation() + } + + private func synchronizeCaretPresentation() { + updateCaretDecorations() + needsDisplay = true + onCaretPresentationChanged?() + updateInsertionPointStateAndRestartTimer(true) + } + + override func updateInsertionPointStateAndRestartTimer(_ restartFlag: Bool) { + guard restartFlag else { return } + caretPresentationGeneration &+= 1 + let generation = caretPresentationGeneration + caretVisible = true + needsDisplay = true + + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(500)) { [weak self] in + self?.startCaretBlinking(for: generation) + } + } + + private func startCaretBlinking(for generation: Int) { + guard generation == caretPresentationGeneration else { return } + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(500)) { [weak self] in + guard let self, generation == self.caretPresentationGeneration else { return } + self.caretVisible.toggle() + self.needsDisplay = true + self.startCaretBlinking(for: generation) + } + } + + override func drawInsertionPoint(in rect: NSRect, color: NSColor, turnedOn _: Bool) { + // The editor paints the caret from draw(_:) so AppKit's independent + // insertion-point blink callbacks cannot overwrite its width or phase. + } + override func performKeyEquivalent(with event: NSEvent) -> Bool { if Self.isStandardPasteShortcut(event), onPasteImage?() == true { return true @@ -852,6 +1285,88 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate { lineIndex = TextLineIndex(source: string as NSString) } + func applyLineIndexEdit(replacedRange: NSRange, replacement: String) { + if replacement.contains("\n") || replacement.contains("\r") + || !lineIndex.applySingleLineEdit(replacedRange: replacedRange, insertedLength: (replacement as NSString).length) { + rebuildLineIndex() + } + } + + func visibleCharacterRange() -> NSRange? { + guard let layoutManager, + let textContainer, + let scrollView = enclosingScrollView else { return nil } + let visibleRect = scrollView.documentVisibleRect + let textContainerVisibleRect = NSRect( + x: visibleRect.minX - textContainerOrigin.x, + y: visibleRect.minY - textContainerOrigin.y, + width: visibleRect.width, + height: visibleRect.height + ) + let glyphRange = layoutManager.glyphRange( + forBoundingRect: textContainerVisibleRect, + in: textContainer + ) + guard glyphRange.length > 0 else { return nil } + return layoutManager.characterRange(forGlyphRange: glyphRange, actualGlyphRange: nil) + } + + #if DEBUG + var currentFindMatchCountForTesting: Int { findMatchRanges.count } + var findMatchLocationsForTesting: [Int] { findMatchRanges.map(\.location) } + #endif + + func applyFindEdit(replacedRange: NSRange, insertedLength: Int, query: String) { + guard !query.isEmpty else { + clearFindHighlights() + return + } + let source = string as NSString + let delta = insertedLength - replacedRange.length + let replacedEnd = NSMaxRange(replacedRange) + findMatchRanges = findMatchRanges.compactMap { range in + if NSMaxRange(range) <= replacedRange.location { return range } + if range.location >= replacedEnd { + return NSRange(location: range.location + delta, length: range.length) + } + return nil + } + let safeLocation = min(replacedRange.location, max(0, source.length - 1)) + let lineRange = source.length == 0 + ? NSRange(location: 0, length: 0) + : source.lineRange(for: NSRange(location: safeLocation, length: 0)) + let searchEnd = min(source.length, max(NSMaxRange(lineRange), replacedRange.location + insertedLength)) + let searchRange = NSRange( + location: lineRange.location, + length: max(0, searchEnd - lineRange.location) + ) + findMatchRanges.removeAll { range in + NSIntersectionRange(range, searchRange).length > 0 + || (range.location >= searchRange.location && range.location < NSMaxRange(searchRange)) + } + if searchRange.length > 0, !query.isEmpty { + var cursor = searchRange + while cursor.length > 0 { + let found = source.range( + of: query, + options: [.caseInsensitive, .diacriticInsensitive], + range: cursor + ) + if found.location == NSNotFound { break } + findMatchRanges.append(found) + let nextLocation = NSMaxRange(found) + cursor = NSRange(location: nextLocation, length: NSMaxRange(searchRange) - nextLocation) + } + findMatchRanges.sort { $0.location < $1.location } + } + currentFindMatchIndex = min(currentFindMatchIndex, max(0, findMatchRanges.count - 1)) + applyFindHighlights() + reportFindState( + index: findMatchRanges.isEmpty ? -1 : currentFindMatchIndex, + count: findMatchRanges.count + ) + } + func characterOffset(forLine targetLine: Int, in _: NSString) -> Int { lineIndex.characterOffset(forLine: targetLine) } @@ -869,6 +1384,53 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate { updateEditorDecorations() } + func updateCaretDecorations() { + guard let layoutManager else { return } + let fullLength = (string as NSString).length + for range in lastCaretBackgroundRanges where NSMaxRange(range) <= fullLength { + layoutManager.removeTemporaryAttribute(.backgroundColor, forCharacterRange: range) + } + lastCaretBackgroundRanges = [] + guard fullLength > 0 else { return } + + let source = string as NSString + let caret = min(selectedRange().location, source.length) + let lineRange = source.lineRange(for: NSRange(location: caret, length: 0)) + layoutManager.addTemporaryAttribute( + .backgroundColor, + value: currentLineColor, + forCharacterRange: lineRange + ) + lastCaretBackgroundRanges.append(lineRange) + + for range in matchingBracketRanges(in: source, caret: caret) { + layoutManager.addTemporaryAttribute(.backgroundColor, value: bracketColor, forCharacterRange: range) + lastCaretBackgroundRanges.append(range) + } + + if isLanguageNavigationEnabled, + let symbol = identifier(at: caret, in: source), + let scope = enclosingCodeScope(at: caret, in: source) { + let escaped = NSRegularExpression.escapedPattern(for: symbol.text) + if let expression = try? NSRegularExpression(pattern: "\\b\(escaped)\\b") { + expression.enumerateMatches(in: string, range: scope) { [weak layoutManager] match, _, _ in + guard let match else { return } + layoutManager?.addTemporaryAttribute( + .backgroundColor, + value: self.symbolColor, + forCharacterRange: match.range + ) + self.lastCaretBackgroundRanges.append(match.range) + } + } + } + + if !findMatchRanges.isEmpty { + applyFindHighlights() + } + applyLinkHighlight() + } + func updateEditorDecorations() { guard let layoutManager else { return } let fullRange = NSRange(location: 0, length: string.utf16.count) @@ -883,6 +1445,7 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate { return } + lastCaretBackgroundRanges = [] let source = string as NSString let caret = min(selectedRange().location, source.length) let lineRange = source.lineRange(for: NSRange(location: caret, length: 0)) @@ -891,9 +1454,11 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate { value: currentLineColor, forCharacterRange: lineRange ) + lastCaretBackgroundRanges.append(lineRange) for range in matchingBracketRanges(in: source, caret: caret) { layoutManager.addTemporaryAttribute(.backgroundColor, value: bracketColor, forCharacterRange: range) + lastCaretBackgroundRanges.append(range) } if isLanguageNavigationEnabled, @@ -908,6 +1473,7 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate { value: self.symbolColor, forCharacterRange: match.range ) + self.lastCaretBackgroundRanges.append(match.range) } } } @@ -1307,9 +1873,37 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate { override func drawBackground(in rect: NSRect) { super.drawBackground(in: rect) + drawCurrentLineBackground(in: rect) drawIndentGuides(in: rect) } + private func drawCurrentLineBackground(in rect: NSRect) { + let source = string as NSString + let caret = min(selectedRange().location, source.length) + let lineRange = source.lineRange(for: NSRange(location: caret, length: 0)) + guard let layoutManager, + layoutManager.numberOfGlyphs > 0 else { return } + + let glyphRange = layoutManager.glyphRange( + forCharacterRange: lineRange, + actualCharacterRange: nil + ) + guard glyphRange.location < layoutManager.numberOfGlyphs else { return } + let lineRect = layoutManager.lineFragmentRect( + forGlyphAt: glyphRange.location, + effectiveRange: nil + ) + let currentLineRect = NSRect( + x: 0, + y: textContainerOrigin.y + lineRect.minY, + width: bounds.width, + height: lineRect.height + ) + guard currentLineRect.intersects(rect) else { return } + currentLineColor.setFill() + currentLineRect.intersection(rect).fill() + } + private func lineFragmentRect( forLine line: Int, in source: NSString, @@ -1380,6 +1974,46 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate { ] ) } + drawCaret() + } + + private func drawCaret() { + guard caretVisible, + window?.firstResponder === self, + let layoutManager, + let textContainer else { return } + + let sourceLength = string.utf16.count + let location = min(selectedRange().location, sourceLength) + let caretRect: NSRect + if layoutManager.numberOfGlyphs == 0 { + let lineHeight = layoutManager.defaultLineHeight(for: font ?? .systemFont(ofSize: 13)) + caretRect = NSRect( + x: textContainerOrigin.x, + y: textContainerOrigin.y, + width: EditorLayoutMetrics.caretWidth, + height: lineHeight + ) + } else { + let isAtDocumentEnd = location == sourceLength + let glyphIndex = layoutManager.glyphIndexForCharacter( + at: min(location, sourceLength - 1) + ) + let glyphRect = layoutManager.boundingRect( + forGlyphRange: NSRange(location: glyphIndex, length: 1), + in: textContainer + ) + let lineRect = layoutManager.lineFragmentRect(forGlyphAt: glyphIndex, effectiveRange: nil) + caretRect = NSRect( + x: textContainerOrigin.x + (isAtDocumentEnd ? glyphRect.maxX : glyphRect.minX), + y: textContainerOrigin.y + lineRect.minY, + width: EditorLayoutMetrics.caretWidth, + height: lineRect.height + ) + } + + insertionPointColor.setFill() + caretRect.fill() } override func mouseDown(with event: NSEvent) { @@ -1479,6 +2113,14 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate { return super.resignFirstResponder() } + override func becomeFirstResponder() -> Bool { + let becameFirstResponder = super.becomeFirstResponder() + if becameFirstResponder { + updateInsertionPointStateAndRestartTimer(true) + } + return becameFirstResponder + } + private func updateFoldHover(at point: NSPoint?) { let nextID = point.flatMap { point in foldRegions.first(where: { @@ -2213,7 +2855,12 @@ final class LineNumberGutterView: NSView { in: textContainer ) guard layoutManager.numberOfGlyphs > 0 else { - drawLineNumber(1, y: textView.textContainerInset.height) + let lineHeight = max( + 18, + layoutManager.defaultLineHeight(for: textView.font ?? .systemFont(ofSize: 13)) + ) + drawLineNumber(1, y: textView.textContainerInset.height, height: lineHeight) + drawEditorDivider(in: dirtyRect) return } @@ -2263,7 +2910,7 @@ final class LineNumberGutterView: NSView { if let marker = gitLineChangeMarkersByLine[lineNumber - 1] { drawGitLineChange(marker, y: y, height: lineRect.height) } - drawLineNumber(lineNumber, y: y + 1) + drawLineNumber(lineNumber, y: y, height: lineRect.height) let nextGlyph = NSMaxRange(lineGlyphRange) glyphIndex = nextGlyph > glyphIndex ? nextGlyph : glyphIndex + 1 @@ -2276,6 +2923,17 @@ final class LineNumberGutterView: NSView { visibleRect: visibleRect, layoutManager: layoutManager ) + drawEditorDivider(in: dirtyRect) + } + + private func drawEditorDivider(in dirtyRect: NSRect) { + palette.gutterDivider.setFill() + NSRect( + x: bounds.width - 1, + y: dirtyRect.minY, + width: 1, + height: dirtyRect.height + ).fill() } private func drawFoldIndicators( @@ -2305,14 +2963,18 @@ final class LineNumberGutterView: NSView { } } - private func drawLineNumber(_ number: Int, y: CGFloat) { + private func drawLineNumber(_ number: Int, y: CGFloat, height: CGFloat) { let label = String(number) as NSString let attributes: [NSAttributedString.Key: Any] = [ .font: NSFont.monospacedDigitSystemFont(ofSize: 10.5, weight: .regular), .foregroundColor: palette.lineNumber ] let size = label.size(withAttributes: attributes) - label.draw(at: NSPoint(x: bounds.width - size.width - 9, y: y), withAttributes: attributes) + let centeredY = y + max(0, (height - size.height) / 2) + label.draw( + at: NSPoint(x: (bounds.width - size.width) / 2, y: centeredY), + withAttributes: attributes + ) } private func drawFoldIndicator(_ region: JavaFoldRegion, y: CGFloat, height: CGFloat) { @@ -2802,37 +3464,90 @@ private final class ClosureButton: NSButton { @MainActor private enum SyntaxHighlighter { - static func apply(to storage: NSTextStorage, fileExtension: String, isDark: Bool) { + private static let keywordExpression = try! NSRegularExpression( + pattern: #"\b(class|struct|enum|protocol|extension|func|let|var|if|else|guard|switch|case|for|while|return|throw|throws|try|catch|async|await|public|private|internal|protected|static|final|new|import|package|interface|implements|extends|void|boolean|int|long|const|function|def|in|from|as|true|false|null|nil|self|this)\b"# + ) + private static let annotationExpression = try! NSRegularExpression( + pattern: #"@[A-Za-z_][A-Za-z0-9_]*"# + ) + private static let typeExpression = try! NSRegularExpression( + pattern: #"\b[A-Z][A-Za-z0-9_]*\b"# + ) + private static let numberExpression = try! NSRegularExpression( + pattern: #"\b\d+(?:\.\d+)?\b"# + ) + private static let stringExpression = try! NSRegularExpression( + pattern: #"\"(?:\\.|[^\"\\])*\"|'(?:\\.|[^'\\])*'"# + ) + private static let commentExpression = try! NSRegularExpression( + pattern: #"//.*$|#.*$|/\*[\s\S]*?\*/"#, + options: [.anchorsMatchLines] + ) + + static func apply( + to storage: NSTextStorage, + font: NSFont, + fileExtension: String, + isDark: Bool, + range: NSRange? = nil + ) { let fullRange = NSRange(location: 0, length: storage.length) guard fullRange.length > 0 else { return } + let target = expandedRange(range, in: storage.string as NSString, limit: fullRange) + guard target.length > 0 else { return } let palette = CodeEditorPalette(isDark: isDark, theme: LitheTheme.activeTheme) storage.beginEditing() storage.setAttributes([ - .font: NSFont.monospacedSystemFont(ofSize: 13, weight: .regular), + .font: font, + .paragraphStyle: LitheTheme.editorParagraphStyle, + .ligature: 0, .foregroundColor: palette.text - ], range: fullRange) - - apply(pattern: #"\b(class|struct|enum|protocol|extension|func|let|var|if|else|guard|switch|case|for|while|return|throw|throws|try|catch|async|await|public|private|internal|protected|static|final|new|import|package|interface|implements|extends|void|boolean|int|long|const|function|def|in|from|as|true|false|null|nil|self|this)\b"#, color: palette.keyword, storage: storage) - apply(pattern: #"@[A-Za-z_][A-Za-z0-9_]*"#, color: palette.annotation, storage: storage) - apply(pattern: #"\b[A-Z][A-Za-z0-9_]*\b"#, color: palette.type, storage: storage) - apply(pattern: #"\b\d+(?:\.\d+)?\b"#, color: palette.number, storage: storage) - apply(pattern: #"\"(?:\\.|[^\"\\])*\"|'(?:\\.|[^'\\])*'"#, color: palette.string, storage: storage) - apply(pattern: #"//.*$|#.*$|/\*[\s\S]*?\*/"#, options: [.anchorsMatchLines], color: palette.comment, storage: storage) + ], range: target) + + apply(keywordExpression, color: palette.keyword, storage: storage, range: target) + apply(annotationExpression, color: palette.annotation, storage: storage, range: target) + apply(typeExpression, color: palette.type, storage: storage, range: target) + apply(numberExpression, color: palette.number, storage: storage, range: target) + apply(stringExpression, color: palette.string, storage: storage, range: target) + apply(commentExpression, color: palette.comment, storage: storage, range: target) storage.endEditing() } private static func apply( - pattern: String, - options: NSRegularExpression.Options = [], + _ expression: NSRegularExpression, color: NSColor, - storage: NSTextStorage + storage: NSTextStorage, + range: NSRange ) { - guard let expression = try? NSRegularExpression(pattern: pattern, options: options) else { return } - let range = NSRange(location: 0, length: storage.length) expression.enumerateMatches(in: storage.string, range: range) { match, _, _ in guard let match else { return } storage.addAttribute(.foregroundColor, value: color, range: match.range) } } + + /// Re-color the edited lines plus a small pad so a token that crosses the + /// caret, or a nearby block comment, is not left half-styled. + private static func expandedRange(_ range: NSRange?, in source: NSString, limit: NSRange) -> NSRange { + guard let range else { return limit } + let safe = NSIntersectionRange(range, limit) + guard source.length > 0 else { return safe } + let startLine = source.lineRange(for: NSRange(location: safe.location, length: 0)) + let endIndex = max(safe.location, NSMaxRange(safe) > 0 ? NSMaxRange(safe) - 1 : 0) + let endLine = source.lineRange(for: NSRange(location: min(endIndex, source.length - 1), length: 0)) + var combined = NSUnionRange(startLine, endLine) + if combined.location > 0 { + combined = NSUnionRange( + source.lineRange(for: NSRange(location: combined.location - 1, length: 0)), + combined + ) + } + if NSMaxRange(combined) < source.length { + combined = NSUnionRange( + combined, + source.lineRange(for: NSRange(location: NSMaxRange(combined), length: 0)) + ) + } + return NSIntersectionRange(combined, limit) + } } diff --git a/Sources/Lithe/Views/Editor/EditorAreaView.swift b/Sources/Lithe/Views/Editor/EditorAreaView.swift index 6c7f35766..4de8501f5 100644 --- a/Sources/Lithe/Views/Editor/EditorAreaView.swift +++ b/Sources/Lithe/Views/Editor/EditorAreaView.swift @@ -34,14 +34,20 @@ struct EditorAreaView: View { @State private var splitDocumentID: UUID? @State private var markdownViewModes: [UUID: MarkdownViewMode] = [:] @State private var markdownScrollPositions: [UUID: MarkdownScrollPosition] = [:] + @State private var editorViewportStore = EditorViewportStore() @State private var hoveredMarkdownMode: MarkdownViewMode? var body: some View { ZStack(alignment: .top) { Group { if model.selectedSidebar == .database { - DatabaseWorkspaceView() - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + if model.isDatabaseModuleActive { + DatabaseWorkspaceView() + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + } else { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } } else if let comparison = model.branchComparison { BranchComparisonView(comparison: comparison) } else if let commitDiff = model.selectedGitCommitDiffContext { @@ -73,6 +79,7 @@ struct EditorAreaView: View { } markdownViewModes = markdownViewModes.filter { ids.contains($0.key) } markdownScrollPositions = markdownScrollPositions.filter { ids.contains($0.key) } + editorViewportStore.retain(documentIDs: Set(ids)) if let draggedDocumentID = tabDragState.draggedDocumentID, !ids.contains(draggedDocumentID) { finishTabDrag() @@ -250,11 +257,7 @@ struct EditorAreaView: View { size: 13 ) editorTabTitle(document) - if document.isDirty { - Circle() - .fill(LitheTheme.primaryText) - .frame(width: 6, height: 6) - } + EditorTabDirtyIndicator(document: document) } .foregroundStyle(model.activeDocumentID == document.id ? LitheTheme.primaryText : LitheTheme.secondaryText) .padding(.leading, 11) @@ -289,8 +292,12 @@ struct EditorAreaView: View { } label: { Image(systemName: "xmark") .font(.system(size: 9, weight: .semibold)) + .frame(width: 20, height: 20) + .contentShape(Rectangle()) + .litheRowHover(cornerRadius: 10) } - .litheIconButton() + .buttonStyle(LitheTreeRowButtonStyle()) + .lithePointer() .foregroundStyle(LitheTheme.secondaryText) .opacity(model.activeDocumentID == document.id || hoveredTabID == document.id ? 1 : 0) .allowsHitTesting(model.activeDocumentID == document.id || hoveredTabID == document.id) @@ -512,7 +519,8 @@ struct EditorAreaView: View { CodeEditorView( document: document, debugService: model.debugFeatureIfActive, - shouldFocus: !showsHeader && document.id == model.activeDocumentID + shouldFocus: !showsHeader && document.id == model.activeDocumentID, + viewportStore: editorViewportStore ) .id(document.id) .clipped() @@ -580,6 +588,12 @@ struct EditorAreaView: View { } } + if model.canRevealInProjectTree(document.url) { + Button("Reveal in Project Tree") { + model.activeDocumentID = document.id + model.revealInProjectTree(document.url) + } + } Button("Show in Finder") { model.revealProjectItemInFinder(document.url) } @@ -632,12 +646,7 @@ struct EditorAreaView: View { ) -> some View { codeEditor(document, markdownScrollPosition: markdownScrollPosition) .overlay(alignment: .top) { - if model.isFindBarVisible { - FindBarView() - .padding(.top, 10) - .padding(.horizontal, 12) - .transition(.move(edge: .top).combined(with: .opacity)) - } + FindBarOverlay() } } @@ -649,7 +658,8 @@ struct EditorAreaView: View { document: document, debugService: model.debugFeatureIfActive, shouldFocus: true, - markdownScrollPosition: markdownScrollPosition + markdownScrollPosition: markdownScrollPosition, + viewportStore: editorViewportStore ) .id(document.id) .clipped() @@ -742,3 +752,28 @@ private struct EditorTabDropDelegate: DropDelegate { ) } } + +private struct EditorTabDirtyIndicator: View { + @ObservedObject var document: EditorDocument + + var body: some View { + if document.isDirty { + Circle() + .fill(LitheTheme.primaryText) + .frame(width: 6, height: 6) + } + } +} + +private struct FindBarOverlay: View { + @EnvironmentObject private var chrome: EditorChromeModel + + var body: some View { + if chrome.isFindBarVisible { + FindBarView() + .padding(.top, 10) + .padding(.horizontal, 12) + .transition(.move(edge: .top).combined(with: .opacity)) + } + } +} diff --git a/Sources/Lithe/Views/Editor/FindBarView.swift b/Sources/Lithe/Views/Editor/FindBarView.swift index 4302e4333..8f03a6ab6 100644 --- a/Sources/Lithe/Views/Editor/FindBarView.swift +++ b/Sources/Lithe/Views/Editor/FindBarView.swift @@ -3,11 +3,12 @@ import SwiftUI /// 编辑器内的单文件查找栏:实时高亮、上/下一个、Esc 关闭。 struct FindBarView: View { @EnvironmentObject private var model: AppModel + @EnvironmentObject private var chrome: EditorChromeModel @FocusState private var focused: Bool private var queryBinding: Binding { Binding( - get: { model.findBarQuery }, + get: { chrome.findBarQuery }, set: { model.setFindBarQuery($0) } ) } @@ -43,7 +44,7 @@ struct FindBarView: View { } .litheIconButton() .foregroundStyle(LitheTheme.secondaryText) - .disabled(model.findMatchCount == 0) + .disabled(chrome.findMatchCount == 0) .help("Previous match (Shift+Return)") Button { @@ -53,7 +54,7 @@ struct FindBarView: View { } .litheIconButton() .foregroundStyle(LitheTheme.secondaryText) - .disabled(model.findMatchCount == 0) + .disabled(chrome.findMatchCount == 0) .help("Next match (Return)") Button { @@ -76,8 +77,8 @@ struct FindBarView: View { } private var matchLabel: String { - guard model.findMatchCount > 0 else { return "" } - let current = max(0, model.currentFindMatchIndex + 1) - return "\(current)/\(model.findMatchCount)" + guard chrome.findMatchCount > 0 else { return "" } + let current = max(0, chrome.currentFindMatchIndex + 1) + return "\(current)/\(chrome.findMatchCount)" } } diff --git a/Sources/Lithe/Views/Editor/StandaloneEditorView.swift b/Sources/Lithe/Views/Editor/StandaloneEditorView.swift new file mode 100644 index 000000000..1b0e80976 --- /dev/null +++ b/Sources/Lithe/Views/Editor/StandaloneEditorView.swift @@ -0,0 +1,125 @@ +import SwiftUI + +struct StandaloneEditorView: View { + @EnvironmentObject private var model: AppModel + @State private var editorViewportStore = EditorViewportStore() + + var body: some View { + VStack(spacing: 0) { + header + Rectangle() + .fill(LitheTheme.divider) + .frame(height: 1) + + content + } + .background(LitheTheme.editor) + .confirmationDialog( + "Save changes before closing?", + isPresented: Binding( + get: { model.pendingCloseDocument != nil }, + set: { if !$0 { model.cancelPendingClose() } } + ), + titleVisibility: .visible + ) { + Button("Save") { model.closePendingDocument(discardingChanges: false) } + Button("Discard Changes", role: .destructive) { + model.closePendingDocument(discardingChanges: true) + } + Button("Cancel", role: .cancel) { model.cancelPendingClose() } + } message: { + Text(model.pendingCloseDocument?.url.lastPathComponent ?? "") + } + } + + @ViewBuilder + private var content: some View { + switch model.standaloneFileLoadState { + case .idle, .loading: + ProgressView("Opening file…") + .frame(maxWidth: .infinity, maxHeight: .infinity) + case .loaded: + if let document = model.activeDocument { + CodeEditorView( + document: document, + shouldFocus: true, + viewportStore: editorViewportStore + ) + .overlay(alignment: .top) { + if model.isFindBarVisible { + FindBarView() + .padding(.top, 10) + .padding(.horizontal, 12) + } + } + } else { + failureView(.readFailed) + } + case let .failed(failure): + failureView(failure) + } + } + + private func failureView(_ failure: StandaloneFileOpenFailure) -> some View { + VStack(spacing: 10) { + LitheSystemIcon(systemImage: "doc.text.magnifyingglass") + .font(.system(size: 26)) + .foregroundStyle(LitheTheme.secondaryText) + Text(failure.title) + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(LitheTheme.primaryText) + Text(failure.detail) + .font(.system(size: 12)) + .foregroundStyle(LitheTheme.secondaryText) + .multilineTextAlignment(.center) + .frame(maxWidth: 420) + HStack(spacing: 8) { + Button("Try Again") { + if let url = model.standaloneFileURL { + model.openStandaloneFile(url) + } + } + .buttonStyle(LitheSecondaryButtonStyle()) + Button("Close File") { + model.closeStandaloneFile() + } + .buttonStyle(LithePrimaryButtonStyle()) + } + .padding(.top, 4) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(32) + } + + private var header: some View { + HStack(spacing: 8) { + if let document = model.activeDocument { + LitheIcon( + kind: LitheIcons.kind(for: document.url, isDirectory: false), + size: 14 + ) + Text(document.displayName) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(LitheTheme.primaryText) + .lineLimit(1) + if document.isDirty { + Circle() + .fill(LitheTheme.accent) + .frame(width: 6, height: 6) + } + Spacer() + Text(document.url.path) + .font(.system(size: 10.5)) + .foregroundStyle(LitheTheme.tertiaryText) + .lineLimit(1) + } else { + Text(model.standaloneFileURL?.lastPathComponent ?? "Opening file…") + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(LitheTheme.secondaryText) + } + } + .padding(.horizontal, 12) + .frame(height: 34) + .background(LitheTheme.toolHeader) + } +} diff --git a/Sources/Lithe/Views/Language/JavaProblemsView.swift b/Sources/Lithe/Views/Language/JavaProblemsView.swift index 6a6652c25..11e9d4b7b 100644 --- a/Sources/Lithe/Views/Language/JavaProblemsView.swift +++ b/Sources/Lithe/Views/Language/JavaProblemsView.swift @@ -2,6 +2,7 @@ import SwiftUI struct ProblemsView: View { @EnvironmentObject private var model: AppModel + @EnvironmentObject private var diagnosticsStore: EditorDiagnosticsStore @State private var severityFilter = Set(DiagnosticSeverity.allCases) var body: some View { @@ -80,7 +81,7 @@ struct ProblemsView: View { } private var allDiagnostics: [EditorDiagnostic] { - model.editorDiagnostics.values + diagnosticsStore.diagnosticsByURL.values .flatMap { $0 } .sorted { let left = model.relativePath(for: $0.fileURL) diff --git a/Sources/Lithe/Views/Language/LSPControlCenterView.swift b/Sources/Lithe/Views/Language/LSPControlCenterView.swift index f42c9dcbf..df6eaef6a 100644 --- a/Sources/Lithe/Views/Language/LSPControlCenterView.swift +++ b/Sources/Lithe/Views/Language/LSPControlCenterView.swift @@ -27,9 +27,12 @@ struct LSPControlCenterView: View { .padding(20) .frame(maxWidth: .infinity, alignment: .leading) } - .background(LitheTheme.editor) + .background(LitheTheme.settingsSurface) + } + .background(LitheTheme.settingsSurface) + .task { + await model.refreshJavaLanguageServerJDKs() } - .background(LitheTheme.editor) } private var header: some View { @@ -41,7 +44,7 @@ struct LSPControlCenterView: View { } .padding(.horizontal, 16) .frame(height: 42) - .background(LitheTheme.toolHeader) + .background(LitheTheme.settingsSurface) } private var projectSummary: some View { @@ -93,16 +96,15 @@ struct LSPControlCenterView: View { ? "配置 \(descriptor.displayName) 语言服务器" : "Configure \(descriptor.displayName) language server")) } - Toggle("", isOn: Binding( - get: { isEnabled }, - set: { model.setLanguageServerEnabled($0, providerID: descriptor.id) } - )) - .labelsHidden() - .toggleStyle(.switch) - .lithePointer() - .accessibilityLabel(Text(usesChinese - ? "启用 \(descriptor.displayName) 语言服务器" - : "Enable \(descriptor.displayName) language server")) + LitheSettingsCheckbox( + isOn: Binding( + get: { isEnabled }, + set: { model.setLanguageServerEnabled($0, providerID: descriptor.id) } + ), + accessibilityLabel: LocalizedStringKey(usesChinese + ? "启用 \(descriptor.displayName) 语言服务器" + : "Enable \(descriptor.displayName) language server") + ) } if configuredProviderID == descriptor.id, descriptor.id == "java" { @@ -112,8 +114,18 @@ struct LSPControlCenterView: View { .font(.system(size: 11, weight: .semibold)) .foregroundStyle(LitheTheme.secondaryText) Menu { + Button { + model.useAutomaticJavaLanguageServerJDK() + } label: { + if model.javaLanguageServerJDKPath.isEmpty { + Label(usesChinese ? "自动检测" : "Automatic", systemImage: "checkmark") + } else { + Text(usesChinese ? "自动检测" : "Automatic") + } + } + Divider() if model.detectedJavaLanguageServerJDKs.isEmpty { - Text(usesChinese ? "未检测到 JDK" : "No JDKs detected") + Text(usesChinese ? "未检测到 JDK 17+" : "No JDK 17+ detected") } else { ForEach(model.detectedJavaLanguageServerJDKs) { runtime in Button { @@ -157,18 +169,21 @@ struct LSPControlCenterView: View { } .padding(.horizontal, 9) .frame(maxWidth: .infinity, minHeight: 34, alignment: .leading) - .background(RoundedRectangle(cornerRadius: 6).fill(LitheTheme.editor)) + .background( + RoundedRectangle(cornerRadius: LitheTheme.Metrics.controlCornerRadius) + .fill(LitheTheme.inputBackground) + ) .overlay { - RoundedRectangle(cornerRadius: 6) - .stroke(LitheTheme.panelBorder, lineWidth: 1) + RoundedRectangle(cornerRadius: LitheTheme.Metrics.controlCornerRadius) + .stroke(LitheTheme.inputBorder, lineWidth: 1) } } .menuStyle(.borderlessButton) .menuIndicator(.hidden) .lithePointer() Text(usesChinese - ? "仅用于启动 Java 语言服务器,不影响项目使用的 JDK。" - : "Used only to start the Java language server; it does not affect the project JDK.") + ? "自动检测 JDK 17+,或选择仅用于 Java 语言服务器的 JDK;不影响项目 JDK。" + : "Automatically detects JDK 17+, or uses a JDK only for the Java language server; project JDK settings are unchanged.") .font(.system(size: 10.5)) .foregroundStyle(LitheTheme.secondaryText) } @@ -176,12 +191,12 @@ struct LSPControlCenterView: View { } .padding(14) .background( - RoundedRectangle(cornerRadius: 7) - .fill(LitheTheme.sidebar) + RoundedRectangle(cornerRadius: LitheTheme.Metrics.cornerRadius) + .fill(LitheTheme.settingsSurface) ) .overlay { - RoundedRectangle(cornerRadius: 7) - .stroke(LitheTheme.panelBorder, lineWidth: 1) + RoundedRectangle(cornerRadius: LitheTheme.Metrics.cornerRadius) + .stroke(LitheTheme.divider, lineWidth: 1) } } @@ -223,7 +238,10 @@ struct LSPControlCenterView: View { private var javaJDKDisplayPath: String { let path = model.javaLanguageServerJDKPath.trimmingCharacters(in: .whitespacesAndNewlines) if !path.isEmpty { return path } - return usesChinese ? "未配置" : "Not configured" + if let runtime = model.detectedJavaLanguageServerJDKs.first { + return (usesChinese ? "自动:" : "Automatic: ") + javaRuntimeTitle(runtime) + } + return usesChinese ? "自动检测" : "Automatic" } private var selectedJavaRuntime: JavaRuntimeCandidate? { diff --git a/Sources/Lithe/Views/Run/JavaRunConfigurationEditorView.swift b/Sources/Lithe/Views/Run/JavaRunConfigurationEditorView.swift index b031f2c7d..e47d68637 100644 --- a/Sources/Lithe/Views/Run/JavaRunConfigurationEditorView.swift +++ b/Sources/Lithe/Views/Run/JavaRunConfigurationEditorView.swift @@ -1,4 +1,5 @@ -import AppKit +import Foundation +import UniformTypeIdentifiers import SwiftUI struct RunConfigurationEditorView: View { @@ -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 @@ -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 @@ -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 { @@ -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", @@ -274,7 +282,8 @@ struct RunConfigurationEditorView: View { title: String, placeholder: String, text: Binding, - chooseDirectory: @escaping () -> Void + chooseDirectory: @escaping () -> Void, + chooseHelp: String = "Choose directory" ) -> some View { HStack(spacing: 8) { Text(LocalizedStringKey(title)) @@ -286,7 +295,7 @@ struct RunConfigurationEditorView: View { LitheSystemIcon(systemImage: "folder") } .litheIconButton() - .help("Choose directory") + .help(chooseHelp) } .font(.system(size: 12)) } @@ -326,26 +335,91 @@ struct RunConfigurationEditorView: View { } private func chooseDirectory(for keyPath: WritableKeyPath) { - 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) { - 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) { + 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] { + 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) + case fileOrDirectory(WritableKeyPath) + + var keyPath: WritableKeyPath { + switch self { + case .directory(let keyPath), .fileOrDirectory(let keyPath): keyPath + } + } + + var allowedContentTypes: [UTType] { + switch self { + case .directory: [.folder] + case .fileOrDirectory: [.item] + } } } diff --git a/Sources/Lithe/Views/Run/RunView.swift b/Sources/Lithe/Views/Run/RunView.swift index 1d53047d8..82175a0ad 100644 --- a/Sources/Lithe/Views/Run/RunView.swift +++ b/Sources/Lithe/Views/Run/RunView.swift @@ -703,60 +703,67 @@ struct RunView: View { } VStack(spacing: 8) { - configurationDetailRow( - "Type", - value: String(localized: String.LocalizationValue(configuration.kind.title)) - ) - configurationDetailRow("Category", value: localizedExecution(configuration.execution)) - configurationDetailRow("Provider", value: configuration.kind.id, monospaced: true) - configurationDetailRow("Working directory", value: workingDirectory, monospaced: true) - - if session?.isRunning == true, - let serviceURL = feature.serviceURL(for: configuration) { - configurationLinkRow("Address", url: serviceURL) - } - - if let modulePath = nonEmpty(configuration.modulePath) { - configurationDetailRow("Module", value: modulePath, monospaced: true) - } - if let mainClass = nonEmpty(configuration.mainClass) { - configurationDetailRow("Main class", value: mainClass, monospaced: true) - } - if capabilities.contains(.javaRuntime), - let javaHome = nonEmpty(options.javaHomePath) { - configurationDetailRow("JDK home", value: javaHome, monospaced: true) - } - if configuration.kind.isMavenBacked, - let mavenExecutable = nonEmpty(options.mavenExecutablePath) { - configurationDetailRow("Maven", value: mavenExecutable, monospaced: true) - } - if configuration.kind.isMavenBacked, - let mavenJavaHome = nonEmpty(options.mavenJavaHomePath) { - configurationDetailRow("Maven JDK", value: mavenJavaHome, monospaced: true) - } - if capabilities.contains(.javaVMArguments), - let vmArguments = nonEmpty(options.vmArguments) { - configurationDetailRow("VM arguments", value: vmArguments, monospaced: true) - } - if let programArguments = nonEmpty(options.programArguments) { - configurationDetailRow("Program arguments", value: programArguments, monospaced: true) - } - if capabilities.contains(.mavenProfiles), !options.activeProfiles.isEmpty { + Group { configurationDetailRow( - "Active profiles", - value: options.activeProfiles.sorted().joined(separator: ", "), - monospaced: true + "Type", + value: String(localized: String.LocalizationValue(configuration.kind.title)) ) + configurationDetailRow("Category", value: localizedExecution(configuration.execution)) + configurationDetailRow("Provider", value: configuration.kind.id, monospaced: true) + configurationDetailRow("Working directory", value: workingDirectory, monospaced: true) } - if (!capabilities.contains(.javaRuntime) || options.javaHomePath.isEmpty), - (!capabilities.contains(.javaVMArguments) || options.vmArguments.isEmpty), - options.programArguments.isEmpty, - (!capabilities.contains(.mavenProfiles) || options.activeProfiles.isEmpty) { - configurationDetailRow("Options", value: String(localized: "Default options")) + + Group { + if session?.isRunning == true, + let serviceURL = feature.serviceURL(for: configuration) { + configurationLinkRow("Address", url: serviceURL) + } + + if let modulePath = nonEmpty(configuration.modulePath) { + configurationDetailRow("Module", value: modulePath, monospaced: true) + } + if let mainClass = nonEmpty(configuration.mainClass) { + configurationDetailRow("Main class", value: mainClass, monospaced: true) + } + if capabilities.contains(.javaRuntime), + let javaHome = nonEmpty(options.javaHomePath) { + configurationDetailRow("JDK home", value: javaHome, monospaced: true) + } + if configuration.kind.isMavenBacked, + let mavenExecutable = nonEmpty(options.mavenExecutablePath) { + configurationDetailRow("Maven", value: mavenExecutable, monospaced: true) + } + if configuration.kind.isMavenBacked, + let mavenJavaHome = nonEmpty(options.mavenJavaHomePath) { + configurationDetailRow("Maven JDK", value: mavenJavaHome, monospaced: true) + } + if capabilities.contains(.javaVMArguments), + let vmArguments = nonEmpty(options.vmArguments) { + configurationDetailRow("VM arguments", value: vmArguments, monospaced: true) + } + if let programArguments = nonEmpty(options.programArguments) { + configurationDetailRow("Program arguments", value: programArguments, monospaced: true) + } + if capabilities.contains(.mavenProfiles), !options.activeProfiles.isEmpty { + configurationDetailRow( + "Active profiles", + value: options.activeProfiles.sorted().joined(separator: ", "), + monospaced: true + ) + } } - configurationDetailRow("Source", value: localizedSource(feature.source(for: configuration))) - configurationDetailRow("Configuration ID", value: configuration.id, monospaced: true) + Group { + if (!capabilities.contains(.javaRuntime) || options.javaHomePath.isEmpty), + (!capabilities.contains(.javaVMArguments) || options.vmArguments.isEmpty), + options.programArguments.isEmpty, + (!capabilities.contains(.mavenProfiles) || options.activeProfiles.isEmpty) { + configurationDetailRow("Options", value: String(localized: "Default options")) + } + + configurationDetailRow("Source", value: localizedSource(feature.source(for: configuration))) + configurationDetailRow("Configuration ID", value: configuration.id, monospaced: true) + } } } .padding(.horizontal, 16) diff --git a/Sources/Lithe/Views/Workbench/SplitHandleView.swift b/Sources/Lithe/Views/Workbench/SplitHandleView.swift index 2cff80eae..49a1db92a 100644 --- a/Sources/Lithe/Views/Workbench/SplitHandleView.swift +++ b/Sources/Lithe/Views/Workbench/SplitHandleView.swift @@ -12,6 +12,8 @@ struct SplitHandleView: View { static let thickness: CGFloat = 10 let axis: LitheSplitAxis + let leadingBackground: Color + let trailingBackground: Color let onDragStarted: () -> Void let onDragChanged: (CGFloat) -> Void let onDragEnded: () -> Void @@ -20,8 +22,25 @@ struct SplitHandleView: View { @State private var isDragging = false @State private var lastTranslation: CGFloat = 0 + init( + axis: LitheSplitAxis, + leadingBackground: Color = .clear, + trailingBackground: Color = .clear, + onDragStarted: @escaping () -> Void, + onDragChanged: @escaping (CGFloat) -> Void, + onDragEnded: @escaping () -> Void + ) { + self.axis = axis + self.leadingBackground = leadingBackground + self.trailingBackground = trailingBackground + self.onDragStarted = onDragStarted + self.onDragChanged = onDragChanged + self.onDragEnded = onDragEnded + } + var body: some View { ZStack { + trackBackground Color.clear dividerLine } @@ -57,38 +76,50 @@ struct SplitHandleView: View { guard isInside != isHovering else { return } isHovering = isInside if isInside { - resizeCursor.push() + resizeCursor.set() } else { - NSCursor.pop() - } - } - .onDisappear { - if isHovering { - NSCursor.pop() + NSCursor.arrow.set() } } .help(axis == .horizontal ? "Drag left or right to resize" : "Drag up or down to resize") .accessibilityLabel(axis == .horizontal ? "Horizontal pane resize handle" : "Vertical pane resize handle") } + @ViewBuilder + private var trackBackground: some View { + if axis == .horizontal { + HStack(spacing: 0) { + leadingBackground + .frame(maxWidth: .infinity, maxHeight: .infinity) + trailingBackground + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } else { + VStack(spacing: 0) { + leadingBackground + .frame(maxWidth: .infinity, maxHeight: .infinity) + trailingBackground + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + } + @ViewBuilder private var dividerLine: some View { - if isHovering || isDragging { - let color = isDragging - ? LitheTheme.accent.opacity(0.72) - : LitheTheme.divider + let color = isDragging + ? LitheTheme.accent.opacity(0.72) + : LitheTheme.divider - if axis == .horizontal { - Rectangle() - .fill(color) - .frame(width: isDragging ? 3 : (isHovering ? 2 : 1)) - .frame(maxHeight: .infinity) - } else { - Rectangle() - .fill(color) - .frame(height: isDragging ? 3 : (isHovering ? 2 : 1)) - .frame(maxWidth: .infinity) - } + if axis == .horizontal { + Rectangle() + .fill(color) + .frame(width: isDragging ? 3 : (isHovering ? 2 : 1)) + .frame(maxHeight: .infinity) + } else { + Rectangle() + .fill(color) + .frame(height: isDragging ? 3 : (isHovering ? 2 : 1)) + .frame(maxWidth: .infinity) } } diff --git a/Sources/Lithe/Views/Workbench/WorkbenchStatusViews.swift b/Sources/Lithe/Views/Workbench/WorkbenchStatusViews.swift new file mode 100644 index 000000000..1dc9a7662 --- /dev/null +++ b/Sources/Lithe/Views/Workbench/WorkbenchStatusViews.swift @@ -0,0 +1,140 @@ +import SwiftUI + +struct EditorCaretPositionLabel: View { + @ObservedObject var chrome: EditorChromeModel + + var body: some View { + Text(chrome.caret.map { "\($0.line + 1):\($0.utf16Column + 1)" } ?? "1:1") + .monospacedDigit() + } +} + +struct MemoryUsageStatusView: View { + @EnvironmentObject private var memoryUsageMonitor: MemoryUsageMonitor + @State private var isMemoryUsagePopoverPresented = false + + var body: some View { + Button { + isMemoryUsagePopoverPresented.toggle() + } label: { + Label { + HStack(spacing: 4) { + Text("Total \(memoryUsageMonitor.totalText)") + Text("·") + Text("Lithe \(memoryUsageMonitor.litheText)") + } + .monospacedDigit() + } icon: { + Image(systemName: "memorychip") + } + } + .buttonStyle(.plain) + .lithePointer() + .help( + Text( + "Total managed memory: \(memoryUsageMonitor.totalText)\n" + + "Lithe: \(memoryUsageMonitor.litheText) · LSP: \(memoryUsageMonitor.lspText) · Services: \(memoryUsageMonitor.serviceText)" + ) + ) + .popover(isPresented: $isMemoryUsagePopoverPresented, arrowEdge: .top) { + memoryUsagePopover + } + .onChange(of: isMemoryUsagePopoverPresented) { isPresented in + memoryUsageMonitor.setDetailedUsageVisible(isPresented) + } + } + + private var memoryUsagePopover: some View { + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 8) { + Image(systemName: "memorychip") + .foregroundStyle(LitheTheme.accent) + Text("Managed Memory") + .font(.system(size: 12.5, weight: .semibold)) + .foregroundStyle(LitheTheme.primaryText) + Spacer(minLength: 8) + Button { + isMemoryUsagePopoverPresented = false + } label: { + Image(systemName: "xmark") + .font(.system(size: 10, weight: .semibold)) + } + .litheIconButton() + .help("Close") + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + + Rectangle() + .fill(LitheTheme.divider) + .frame(height: 1) + + VStack(spacing: 0) { + memoryMetric("Lithe", value: memoryUsageMonitor.litheText) + memoryMetric( + "Language servers", + value: memoryUsageMonitor.languageServerProcessCount == 0 + ? String(localized: "Not running") + : memoryUsageMonitor.lspText + ) + memoryMetric( + "Running services", + value: memoryUsageMonitor.serviceProcessCount == 0 + ? String(localized: "Not running") + : memoryUsageMonitor.serviceText + ) + memoryMetric("Total", value: memoryUsageMonitor.totalText) + memoryMetric("Average total", value: memoryUsageMonitor.averageText) + memoryMetric("Peak total", value: memoryUsageMonitor.peakText) + memoryMetric("Runtime", value: memoryUsageMonitor.runtimeText) + memoryMetric("Sample interval", value: memoryUsageMonitor.samplingIntervalText) + } + .padding(.horizontal, 12) + .padding(.vertical, 5) + + Rectangle() + .fill(LitheTheme.divider) + .frame(height: 1) + + HStack(alignment: .top, spacing: 6) { + Image(systemName: "info.circle") + Text("Resident memory of Lithe and its managed process trees") + .fixedSize(horizontal: false, vertical: true) + } + .font(LitheTheme.smallFont) + .foregroundStyle(LitheTheme.secondaryText) + .padding(12) + } + .frame(width: 280) + .background(LitheTheme.popupBackground) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + + private func memoryMetric(_ title: String, value: String) -> some View { + HStack(spacing: 8) { + Text(LocalizedStringKey(title)) + .foregroundStyle(LitheTheme.secondaryText) + Spacer(minLength: 8) + Text(value) + .font(.system(size: 11.5, design: .monospaced)) + .foregroundStyle(LitheTheme.primaryText) + .monospacedDigit() + } + .frame(minHeight: 27) + } +} + +struct FrameRateStatusView: View { + @EnvironmentObject private var frameRateMonitor: FrameRateMonitor + + var body: some View { + Label { + Text(frameRateMonitor.framesPerSecondText) + .monospacedDigit() + } icon: { + Image(systemName: "speedometer") + } + .help("Frames rendered per second") + .accessibilityLabel(Text("\(frameRateMonitor.framesPerSecond) frames per second")) + } +} diff --git a/Sources/Lithe/Views/Workbench/WorkbenchView.swift b/Sources/Lithe/Views/Workbench/WorkbenchView.swift index 6bc02bc8f..a3be0ea29 100644 --- a/Sources/Lithe/Views/Workbench/WorkbenchView.swift +++ b/Sources/Lithe/Views/Workbench/WorkbenchView.swift @@ -4,6 +4,7 @@ import LitheGitModule private enum ActivityBarMetrics { static let width: CGFloat = 38 + static let rightWidth: CGFloat = 40 static let buttonWidth: CGFloat = 30 static let buttonHeight: CGFloat = 30 static let spacing: CGFloat = 4 @@ -15,7 +16,6 @@ struct WorkbenchView: View { @EnvironmentObject private var model: AppModel @EnvironmentObject private var projectSessions: ProjectSessionManager @EnvironmentObject private var settings: AppSettings - @EnvironmentObject private var memoryUsageMonitor: MemoryUsageMonitor @Environment(\.accessibilityReduceMotion) private var reduceMotion @StateObject private var linuxDoWebSession = LinuxDoAnonymousWebSession() @State private var sidebarWidth: CGFloat = 320 @@ -29,7 +29,6 @@ struct WorkbenchView: View { @State private var isCheckoutRevisionPresented = false @State private var pendingTopBarPushReference: GitReference? @State private var isProjectSwitcherPresented = false - @State private var isMemoryUsagePopoverPresented = false @State private var isPluginPanelPresented = false @State private var didRestoreLayout = false @State private var hoveredProjectTabID: UUID? @@ -46,8 +45,10 @@ struct WorkbenchView: View { HStack(spacing: 0) { activityBar + Rectangle() + .fill(LitheTheme.divider) + .frame(width: 1) workspaceArea - Color.clear.frame(width: ActivityBarMetrics.width) } .frame(maxHeight: .infinity) .overlay(alignment: .trailing) { @@ -360,7 +361,7 @@ struct WorkbenchView: View { isProjectSwitcherPresented.toggle() } label: { HStack(spacing: 8) { - LitheLogo(size: 28) + LitheLogo(size: 24) Text(model.projectName) .font(.system(size: 13, weight: .semibold)) @@ -611,7 +612,7 @@ struct WorkbenchView: View { Spacer() } .padding(.top, ActivityBarMetrics.edgeInset) - .frame(width: ActivityBarMetrics.width) + .frame(width: ActivityBarMetrics.rightWidth) .background(LitheTheme.titlebar) } @@ -643,6 +644,9 @@ struct WorkbenchView: View { } } } + Rectangle() + .fill(LitheTheme.divider) + .frame(width: 1) pluginActivityBar } .fixedSize(horizontal: true, vertical: false) @@ -898,7 +902,7 @@ struct WorkbenchView: View { private var detailedStatusItems: some View { HStack(spacing: 14) { - caretPosition + EditorCaretPositionLabel(chrome: model.editorChrome) Text("UTF-8") Text("\(settings.tabWidth) spaces") Button { @@ -911,24 +915,21 @@ struct WorkbenchView: View { .help(LocalizedStringKey( model.activeDocument?.isReadOnly == true ? "Read-only document" : "Save" )) - memoryStatus + MemoryUsageStatusView() + FrameRateStatusView() gitStatus } } private var compactStatusItems: some View { HStack(spacing: 10) { - caretPosition - memoryStatus + EditorCaretPositionLabel(chrome: model.editorChrome) + MemoryUsageStatusView() + FrameRateStatusView() gitStatus } } - private var caretPosition: some View { - Text(model.editorCaret.map { "\($0.line + 1):\($0.utf16Column + 1)" } ?? "1:1") - .monospacedDigit() - } - private var gitStatus: some View { HStack(spacing: 7) { if model.isReferencesVisible { @@ -940,116 +941,6 @@ struct WorkbenchView: View { } } - private var memoryStatus: some View { - Button { - isMemoryUsagePopoverPresented.toggle() - } label: { - Label { - HStack(spacing: 4) { - Text("Total \(memoryUsageMonitor.totalText)") - Text("·") - Text("Lithe \(memoryUsageMonitor.litheText)") - } - .monospacedDigit() - } icon: { - Image(systemName: "memorychip") - } - } - .buttonStyle(.plain) - .lithePointer() - .help( - Text( - "Total managed memory: \(memoryUsageMonitor.totalText)\n" + - "Lithe: \(memoryUsageMonitor.litheText) · LSP: \(memoryUsageMonitor.lspText) · Services: \(memoryUsageMonitor.serviceText)" - ) - ) - .popover(isPresented: $isMemoryUsagePopoverPresented, arrowEdge: .top) { - memoryUsagePopover - } - .onChange(of: isMemoryUsagePopoverPresented) { isPresented in - memoryUsageMonitor.setDetailedUsageVisible(isPresented) - } - } - - private var memoryUsagePopover: some View { - VStack(alignment: .leading, spacing: 0) { - HStack(spacing: 8) { - Image(systemName: "memorychip") - .foregroundStyle(LitheTheme.accent) - Text("Managed Memory") - .font(.system(size: 12.5, weight: .semibold)) - .foregroundStyle(LitheTheme.primaryText) - Spacer(minLength: 8) - Button { - isMemoryUsagePopoverPresented = false - } label: { - Image(systemName: "xmark") - .font(.system(size: 10, weight: .semibold)) - } - .litheIconButton() - .help("Close") - } - .padding(.horizontal, 12) - .padding(.vertical, 10) - - Rectangle() - .fill(LitheTheme.divider) - .frame(height: 1) - - VStack(spacing: 0) { - memoryMetric("Lithe", value: memoryUsageMonitor.litheText) - memoryMetric( - "Language servers", - value: memoryUsageMonitor.languageServerProcessCount == 0 - ? String(localized: "Not running") - : memoryUsageMonitor.lspText - ) - memoryMetric( - "Running services", - value: memoryUsageMonitor.serviceProcessCount == 0 - ? String(localized: "Not running") - : memoryUsageMonitor.serviceText - ) - memoryMetric("Total", value: memoryUsageMonitor.totalText) - memoryMetric("Average total", value: memoryUsageMonitor.averageText) - memoryMetric("Peak total", value: memoryUsageMonitor.peakText) - memoryMetric("Runtime", value: memoryUsageMonitor.runtimeText) - memoryMetric("Sample interval", value: memoryUsageMonitor.samplingIntervalText) - } - .padding(.horizontal, 12) - .padding(.vertical, 5) - - Rectangle() - .fill(LitheTheme.divider) - .frame(height: 1) - - HStack(alignment: .top, spacing: 6) { - Image(systemName: "info.circle") - Text("Resident memory of Lithe and its managed process trees") - .fixedSize(horizontal: false, vertical: true) - } - .font(LitheTheme.smallFont) - .foregroundStyle(LitheTheme.secondaryText) - .padding(12) - } - .frame(width: 280) - .background(LitheTheme.popupBackground) - .clipShape(RoundedRectangle(cornerRadius: 8)) - } - - private func memoryMetric(_ title: String, value: String) -> some View { - HStack(spacing: 8) { - Text(LocalizedStringKey(title)) - .foregroundStyle(LitheTheme.secondaryText) - Spacer(minLength: 8) - Text(value) - .font(.system(size: 11.5, design: .monospaced)) - .foregroundStyle(LitheTheme.primaryText) - .monospacedDigit() - } - .frame(minHeight: 27) - } - private var projectInitials: String { let words = model.projectName.split(whereSeparator: { !$0.isLetter && !$0.isNumber }) let initials = words.prefix(2).compactMap(\.first) @@ -1122,7 +1013,7 @@ private struct WorkbenchWorkspaceSplitView GitChangeKind? { + guard let repositoryRoot, + let relative = Self.relativePath(for: url, root: repositoryRoot) else { return nil } + return projection.kind(relativePath: relative, isDirectory: isDirectory) + } + + func change(for url: URL) -> GitChange? { + guard let repositoryRoot, + let relative = Self.relativePath(for: url, root: repositoryRoot) else { return nil } + return projection.change(relativePath: relative) + } + + private static func relativePath(for url: URL, root: URL) -> String? { + let normalizedRoot = root.standardizedFileURL.path + let normalizedPath = url.standardizedFileURL.path + guard normalizedPath.hasPrefix(normalizedRoot + "/") else { return nil } + return String(normalizedPath.dropFirst(normalizedRoot.count + 1)) + } +} + +private final class ProjectTreeActions: @unchecked Sendable { + private let model: AppModel + + init(model: AppModel) { + self.model = model + } + + // Button and context-menu closures are not MainActor-isolated under the + // Swift 6 test/release check. Keep these methods synchronous and hop. + nonisolated func openFile(_ url: URL) { + Task { @MainActor in self.model.openFile(url) } + } + nonisolated func requestCreateFile(_ url: URL) { + Task { @MainActor in self.model.requestCreateFile(in: url) } + } + nonisolated func requestCreateDirectory(_ url: URL) { + Task { @MainActor in self.model.requestCreateDirectory(in: url) } + } + nonisolated func revealInFinder(_ url: URL) { + Task { @MainActor in self.model.revealProjectItemInFinder(url) } + } + nonisolated func copyPath(_ url: URL, relative: Bool) { + Task { @MainActor in self.model.copyProjectItemPath(url, relative: relative) } + } + nonisolated func duplicate(_ url: URL) { + Task { await self.model.duplicateProjectItem(at: url) } + } + nonisolated func requestRename(_ url: URL) { + Task { @MainActor in self.model.requestRenameProjectItem(at: url) } + } + nonisolated func requestDelete(_ url: URL, _ isDirectory: Bool) { + Task { @MainActor in + self.model.requestDeleteProjectItem(at: url, isDirectory: isDirectory) + } + } + nonisolated func refreshWorkspace() { + Task { await self.model.refreshWorkspace() } + } + nonisolated func showGitDirectoryDiff(_ url: URL) { + Task { await self.model.showGitDirectoryDiff(for: url) } + } + nonisolated func selectChange(_ change: GitChange) { + Task { @MainActor in self.model.selectChange(change) } + } + nonisolated func showLocalHistory(_ url: URL) { + Task { @MainActor in self.model.showLocalHistory(for: url) } + } + func javaIconKind(_ url: URL) async -> LitheIconKind? { + await model.javaIconKind(for: url) + } +} + +private struct ProjectFileTreeContent: View, Equatable { + let root: FileNode + let availableWidth: CGFloat + let activeDocumentURL: URL? + let gitStatus: ProjectGitStatusSnapshot + let actions: ProjectTreeActions + let expandedDirectoryPathsSnapshot: Set + @Binding var expandedDirectoryPaths: Set + + static func == (lhs: ProjectFileTreeContent, rhs: ProjectFileTreeContent) -> Bool { + lhs.root == rhs.root + && lhs.availableWidth == rhs.availableWidth + && lhs.activeDocumentURL == rhs.activeDocumentURL + && lhs.gitStatus == rhs.gitStatus + && lhs.expandedDirectoryPathsSnapshot == rhs.expandedDirectoryPathsSnapshot + } + + var body: some View { + FileNodeRow( + node: root, + depth: 0, + availableWidth: availableWidth, + activeDocumentURL: activeDocumentURL, + gitStatus: gitStatus, + actions: actions, + expandedDirectoryPaths: $expandedDirectoryPaths + ) + .id(root.url.standardizedFileURL.path) + } +} + private struct FileNodeRow: View { private static let horizontalInset: CGFloat = 10 - @EnvironmentObject private var model: AppModel let node: FileNode let depth: Int let availableWidth: CGFloat let activeDocumentURL: URL? + let gitStatus: ProjectGitStatusSnapshot + let actions: ProjectTreeActions @Binding var expandedDirectoryPaths: Set @State private var resolvedJavaIconKind: LitheIconKind? @@ -181,8 +331,11 @@ private struct FileNodeRow: View { depth: depth + 1, availableWidth: availableWidth, activeDocumentURL: activeDocumentURL, + gitStatus: gitStatus, + actions: actions, expandedDirectoryPaths: $expandedDirectoryPaths ) + .id(child.url.standardizedFileURL.path) } } } else { @@ -235,7 +388,7 @@ private struct FileNodeRow: View { private var fileRow: some View { Button { - model.openFile(node.url) + actions.openFile(node.url) } label: { HStack(spacing: 6) { Color.clear.frame(width: 10) @@ -248,7 +401,7 @@ private struct FileNodeRow: View { .truncationMode(.middle) .layoutPriority(1) Spacer(minLength: 4) - if let status = model.gitChange(for: node.url) { + if let status = gitStatus.change(for: node.url) { Text(status.displayStatus) .font(.system(size: 9, weight: .bold, design: .monospaced)) .foregroundStyle(gitStatusColor ?? LitheTheme.secondaryText) @@ -261,7 +414,8 @@ private struct FileNodeRow: View { .frame(height: LitheTheme.Metrics.treeRowHeight) .contentShape(Rectangle()) .litheRowHover( - isActive: activeDocumentURL == node.url, + isActive: activeDocumentURL?.standardizedFileURL.path + == node.url.standardizedFileURL.path, cornerRadius: 4, activeBackground: LitheTheme.subtleSelection, animation: nil @@ -274,101 +428,107 @@ private struct FileNodeRow: View { .contextMenu { fileContextMenu } .task(id: node.url.standardizedFileURL.path) { guard node.url.pathExtension.lowercased() == "java" else { return } - resolvedJavaIconKind = await model.javaIconKind(for: node.url) + resolvedJavaIconKind = await actions.javaIconKind(node.url) } } @ViewBuilder private var directoryContextMenu: some View { - if model.gitTreeStatus(for: node.url, isDirectory: true) != nil { + if gitStatus.kind(for: node.url, isDirectory: true) != nil { Button("Show Git Diff") { - Task { await model.showGitDirectoryDiff(for: node.url) } + actions.showGitDirectoryDiff(node.url) } Divider() } Button("New File…") { - model.requestCreateFile(in: node.url) + actions.requestCreateFile(node.url) } Button("New Directory…") { - model.requestCreateDirectory(in: node.url) + actions.requestCreateDirectory(node.url) } Divider() Button("Show in Finder") { - model.revealProjectItemInFinder(node.url) + actions.revealInFinder(node.url) } Button("Copy Path") { - model.copyProjectItemPath(node.url, relative: false) + actions.copyPath(node.url, relative: false) } Button("Copy Relative Path") { - model.copyProjectItemPath(node.url, relative: true) + actions.copyPath(node.url, relative: true) } if depth > 0 { Divider() Button("Duplicate") { - Task { await model.duplicateProjectItem(at: node.url) } + actions.duplicate(node.url) } Button("Rename…") { - model.requestRenameProjectItem(at: node.url) + actions.requestRename(node.url) } Button("Move to Trash", role: .destructive) { - model.requestDeleteProjectItem(at: node.url, isDirectory: true) + actions.requestDelete(node.url, true) } } Divider() Button("Refresh") { - Task { await model.refreshWorkspace() } + actions.refreshWorkspace() } } @ViewBuilder private var fileContextMenu: some View { - Button("Open") { - model.openFile(node.url) - } + Group { + Button("Open") { + actions.openFile(node.url) + } - if let change = model.gitChange(for: node.url) { - Button("Show Git Diff") { - model.selectChange(change) + if let change = gitStatus.change(for: node.url) { + Button("Show Git Diff") { + actions.selectChange(change) + } } } Divider() - Button("Duplicate") { - Task { await model.duplicateProjectItem(at: node.url) } - } - Button("Rename…") { - model.requestRenameProjectItem(at: node.url) - } - Button("Local History…") { - model.showLocalHistory(for: node.url) - } - Button("Move to Trash", role: .destructive) { - model.requestDeleteProjectItem(at: node.url, isDirectory: false) + Group { + Button("Duplicate") { + actions.duplicate(node.url) + } + Button("Rename…") { + actions.requestRename(node.url) + } + Button("Local History…") { + actions.showLocalHistory(node.url) + } + Button("Move to Trash", role: .destructive) { + actions.requestDelete(node.url, false) + } } Divider() - Button("Show in Finder") { - model.revealProjectItemInFinder(node.url) - } - Button("Copy Path") { - model.copyProjectItemPath(node.url, relative: false) - } - Button("Copy Relative Path") { - model.copyProjectItemPath(node.url, relative: true) + Group { + Button("Show in Finder") { + actions.revealInFinder(node.url) + } + Button("Copy Path") { + actions.copyPath(node.url, relative: false) + } + Button("Copy Relative Path") { + actions.copyPath(node.url, relative: true) + } } } private var gitStatusColor: Color? { - guard let kind = model.gitTreeStatus(for: node.url, isDirectory: node.isDirectory) else { + guard let kind = gitStatus.kind(for: node.url, isDirectory: node.isDirectory) else { return nil } switch kind { diff --git a/Sources/LitheCoreContracts/Language/LanguageToolServiceContracts.swift b/Sources/LitheCoreContracts/Language/LanguageToolServiceContracts.swift index 094dd47c2..b94bc672e 100644 --- a/Sources/LitheCoreContracts/Language/LanguageToolServiceContracts.swift +++ b/Sources/LitheCoreContracts/Language/LanguageToolServiceContracts.swift @@ -1,6 +1,7 @@ import Foundation package enum RuntimeToolSource: String, Codable, Hashable, Sendable { + case bundled case project case environment case path @@ -11,6 +12,7 @@ package enum RuntimeToolSource: String, Codable, Hashable, Sendable { package var displayName: String { switch self { + case .bundled: "Bundled" case .project: "Project" case .environment: "Environment" case .path: "PATH" diff --git a/Sources/LitheExecutionModule/Services/RunService.swift b/Sources/LitheExecutionModule/Services/RunService.swift index e39407bfd..16cc663fb 100644 --- a/Sources/LitheExecutionModule/Services/RunService.swift +++ b/Sources/LitheExecutionModule/Services/RunService.swift @@ -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( diff --git a/Sources/LitheGitModule/Application/GitFeatureModel.swift b/Sources/LitheGitModule/Application/GitFeatureModel.swift index 0b200a1dc..7d75b0b72 100644 --- a/Sources/LitheGitModule/Application/GitFeatureModel.swift +++ b/Sources/LitheGitModule/Application/GitFeatureModel.swift @@ -7,7 +7,10 @@ import LitheModuleAPI /// in AppModel. Git command construction and parsing remain in GitService/Core. @MainActor package final class GitFeatureModel: ObservableObject { - @Published package private(set) var gitChanges: [GitChange] = [] + @Published package private(set) var gitChanges: [GitChange] = [] { + didSet { gitTreeStatus = GitTreeStatusProjection(changes: gitChanges) } + } + package private(set) var gitTreeStatus = GitTreeStatusProjection(changes: []) @Published private var pendingStagingStates: [GitChange.ID: Bool] = [:] @Published package private(set) var gitStashes: [GitStash] = [] @Published package private(set) var gitShelves: [GitShelfEntry] = [] diff --git a/Sources/LitheGitModule/Models/GitModels.swift b/Sources/LitheGitModule/Models/GitModels.swift index 333edd1df..0c8915216 100644 --- a/Sources/LitheGitModule/Models/GitModels.swift +++ b/Sources/LitheGitModule/Models/GitModels.swift @@ -431,31 +431,56 @@ 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 struct GitTreeStatusProjection: Equatable, Sendable { + private let changesByPath: [String: GitChange] + private let directoryKinds: [String: GitChangeKind] package init(changes: [GitChange]) { - self.changes = changes + var changesByPath: [String: GitChange] = [:] + var directoryKinds: [String: GitChangeKind] = [:] + for change in changes { + let path = Self.normalized(change.path) + if changesByPath[path] == nil { + changesByPath[path] = change + } + var remainder = path + while let slash = remainder.lastIndex(of: "/") { + remainder = String(remainder[.. Self.priority(current) { + directoryKinds[remainder] = change.kind + } + } else { + directoryKinds[remainder] = change.kind + } + } + if !path.isEmpty { + if let current = directoryKinds[""] { + if Self.priority(change.kind) > Self.priority(current) { + directoryKinds[""] = change.kind + } + } else { + directoryKinds[""] = change.kind + } + } + } + self.changesByPath = changesByPath + self.directoryKinds = directoryKinds } package func change(relativePath: String) -> GitChange? { - let normalized = Self.normalized(relativePath) - return changes.first { Self.normalized($0.path) == normalized } + changesByPath[Self.normalized(relativePath)] } package func kind(relativePath: String, isDirectory: Bool) -> GitChangeKind? { let normalized = Self.normalized(relativePath) if !isDirectory { - return change(relativePath: normalized)?.kind + return changesByPath[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) } + return directoryKinds[normalized] } - private func priority(_ kind: GitChangeKind) -> Int { + private static func priority(_ kind: GitChangeKind) -> Int { switch kind { case .modified: 0 case .copied: 1 diff --git a/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageProviderRuntime.swift b/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageProviderRuntime.swift index 92c0d6c43..67273234d 100644 --- a/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageProviderRuntime.swift +++ b/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageProviderRuntime.swift @@ -2,6 +2,12 @@ import Foundation import LitheCoreContracts import LitheModuleAPI +package enum LanguageServerRuntimeResolution: Sendable, Equatable { + case notRequired + case available(URL) + case unavailable(String) +} + @MainActor package final class StdioLanguageProviderRuntime: LanguageProviderRuntime { package let descriptor: LanguageProviderDescriptor @@ -9,16 +15,18 @@ package final class StdioLanguageProviderRuntime: LanguageProviderRuntime { private let languageServerLaunch: LanguageServerLaunchDescriptor? private let languageServerCore: any LanguageServerRuntimeCore private let languageServerExecutableResolver: ((LanguageProviderDescriptor) -> URL?)? - private let languageServerRuntimeResolver: ((LanguageProviderDescriptor) -> URL?)? + private let languageServerRuntimeResolver: ((LanguageProviderDescriptor) -> LanguageServerRuntimeResolution)? private let languageServerCacheDirectory: URL? private weak var processRegistry: (any LanguageServerProcessRegistry)? private let moduleID: ModuleID + private var runtimeUnavailableMessage: String? package var supportsLanguageServerSession: Bool { languageServerLaunch != nil } package var unavailableToolingMessage: String? { + if let runtimeUnavailableMessage { return runtimeUnavailableMessage } guard let command = languageServerLaunch?.executableNames.first else { return nil } return runtimeService.missingLanguageToolMessage(command) } @@ -29,7 +37,7 @@ package final class StdioLanguageProviderRuntime: LanguageProviderRuntime { languageServerLaunch: LanguageServerLaunchDescriptor? = nil, languageServerCore: any LanguageServerRuntimeCore, languageServerExecutableResolver: ((LanguageProviderDescriptor) -> URL?)? = nil, - languageServerRuntimeResolver: ((LanguageProviderDescriptor) -> URL?)? = nil, + languageServerRuntimeResolver: ((LanguageProviderDescriptor) -> LanguageServerRuntimeResolution)? = nil, languageServerCacheDirectory: URL? = nil, processRegistry: (any LanguageServerProcessRegistry)? = nil, moduleID: ModuleID = .languageIntelligence @@ -46,6 +54,7 @@ package final class StdioLanguageProviderRuntime: LanguageProviderRuntime { } package func makeLanguageServerSession() -> (any LanguageServerSession)? { + runtimeUnavailableMessage = nil guard let languageServerLaunch else { return nil } let executableURL = if let languageServerExecutableResolver { languageServerExecutableResolver(descriptor) @@ -55,6 +64,16 @@ package final class StdioLanguageProviderRuntime: LanguageProviderRuntime { }).first } guard let executableURL else { return nil } + let runtimeExecutableURL: URL? + switch languageServerRuntimeResolver?(descriptor) ?? .notRequired { + case .notRequired: + runtimeExecutableURL = nil + case .available(let executableURL): + runtimeExecutableURL = executableURL + case .unavailable(let message): + runtimeUnavailableMessage = message + return nil + } var environment = runtimeService.languageToolProcessEnvironment() environment.merge(languageServerLaunch.environment) { _, configured in configured } return LanguageServerRuntimeSession( @@ -63,7 +82,7 @@ package final class StdioLanguageProviderRuntime: LanguageProviderRuntime { arguments: languageServerLaunch.arguments, environment: environment, initializationOptions: languageServerLaunch.initializationOptions, - runtimeExecutableURL: languageServerRuntimeResolver?(descriptor), + runtimeExecutableURL: runtimeExecutableURL, cacheDirectoryURL: languageServerCacheDirectory, core: languageServerCore, processRegistry: processRegistry, @@ -78,7 +97,7 @@ package final class StdioLanguageProviderRuntimeFactory: LanguageProviderRuntime private let runtimeService: any LanguageToolRuntimePort private let languageServerCore: any LanguageServerRuntimeCore private let languageServerExecutableResolver: ((LanguageProviderDescriptor) -> URL?)? - private let languageServerRuntimeResolver: ((LanguageProviderDescriptor) -> URL?)? + private let languageServerRuntimeResolver: ((LanguageProviderDescriptor) -> LanguageServerRuntimeResolution)? private let languageServerCacheDirectory: URL? private weak var processRegistry: (any LanguageServerProcessRegistry)? private let moduleID: ModuleID @@ -87,7 +106,7 @@ package final class StdioLanguageProviderRuntimeFactory: LanguageProviderRuntime runtimeService: any LanguageToolRuntimePort, languageServerCore: any LanguageServerRuntimeCore, languageServerExecutableResolver: ((LanguageProviderDescriptor) -> URL?)? = nil, - languageServerRuntimeResolver: ((LanguageProviderDescriptor) -> URL?)? = nil, + languageServerRuntimeResolver: ((LanguageProviderDescriptor) -> LanguageServerRuntimeResolution)? = nil, languageServerCacheDirectory: URL? = nil, processRegistry: (any LanguageServerProcessRegistry)? = nil, moduleID: ModuleID = .languageIntelligence diff --git a/Sources/LitheWorkspaceModule/Application/WorkspaceFeatureModel.swift b/Sources/LitheWorkspaceModule/Application/WorkspaceFeatureModel.swift index fe1f17b9a..c66d171de 100644 --- a/Sources/LitheWorkspaceModule/Application/WorkspaceFeatureModel.swift +++ b/Sources/LitheWorkspaceModule/Application/WorkspaceFeatureModel.swift @@ -528,11 +528,19 @@ package final class WorkspaceFeatureModel: ObservableObject { pendingProjectItemDeletion = nil } - package func confirmProjectItemDeletion() async { - guard let request = pendingProjectItemDeletion else { return } - pendingProjectItemDeletion = nil + package func confirmProjectItemDeletion(_ request: ProjectItemDeletionRequest) async { + if pendingProjectItemDeletion?.id == request.id { + pendingProjectItemDeletion = nil + } + guard !isPerformingProjectItemOperation, + isWorkspaceURL(request.url), + request.url.standardizedFileURL != workspaceURL?.standardizedFileURL else { return } isPerformingProjectItemOperation = true - await recordHistory?(request.url, .beforeDelete) + // Update the visible tree before waiting for the native Trash operation. + // A failed operation reloads the disk snapshot below to restore the item. + removeProjectItemFromSnapshot(request.url) + // The system Trash is the recovery boundary. Recording every descendant + // first would make deleting a directory scale with its entire file tree. let fileOperations = self.fileOperations let errorMessage = await Task.detached(priority: .userInitiated) { () -> String? in do { @@ -545,6 +553,7 @@ package final class WorkspaceFeatureModel: ObservableObject { isPerformingProjectItemOperation = false if let errorMessage { notify?(errorMessage) + await refreshCurrent() return } closeDocuments?(request.url) @@ -792,6 +801,23 @@ package final class WorkspaceFeatureModel: ObservableObject { return childPath == parentPath || childPath.hasPrefix(parentPath + "/") } + private func removeProjectItemFromSnapshot(_ targetURL: URL) { + projectFiles.removeAll { urlContains(targetURL, child: $0) } + rootNode = rootNode.flatMap { removingProjectItem(targetURL, from: $0) } + } + + private func removingProjectItem(_ targetURL: URL, from node: FileNode) -> FileNode? { + guard node.url.standardizedFileURL != targetURL.standardizedFileURL else { return nil } + guard let children = node.children else { return node } + return FileNode( + url: node.url, + isDirectory: node.isDirectory, + children: children.compactMap { removingProjectItem(targetURL, from: $0) }, + collapsedAncestorPaths: node.collapsedAncestorPaths, + isInsideSourceRoot: node.isInsideSourceRoot + ) + } + private func availableDuplicateURL(for sourceURL: URL) -> URL { let parent = sourceURL.deletingLastPathComponent() let fileExtension = sourceURL.pathExtension diff --git a/Tests/LitheGitModuleTests/GitModuleTests.swift b/Tests/LitheGitModuleTests/GitModuleTests.swift index a4596bf53..04766a71d 100644 --- a/Tests/LitheGitModuleTests/GitModuleTests.swift +++ b/Tests/LitheGitModuleTests/GitModuleTests.swift @@ -37,6 +37,7 @@ struct GitModuleTests { #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) + #expect(projection.kind(relativePath: "", isDirectory: true) == .conflicted) } @Test diff --git a/Tests/LitheTests/AppLocalizationTests.swift b/Tests/LitheTests/AppLocalizationTests.swift index 6bb5d4b2f..3ef5099e5 100644 --- a/Tests/LitheTests/AppLocalizationTests.swift +++ b/Tests/LitheTests/AppLocalizationTests.swift @@ -37,6 +37,19 @@ struct AppLocalizationTests { ) } + @Test + func simplifiedChineseResourcesCoverLogDirectorySettings() throws { + let translations = try simplifiedChineseTranslations() + + #expect(translations["Logs"] == "日志") + #expect(translations["Log directory"] == "日志目录") + #expect(translations["Default directory"] == "默认目录") + #expect(translations["Selected directory"] == "当前选择的目录") + #expect(translations["Choose Directory"] == "选择目录") + #expect(translations["Choose Log Directory"] == "选择日志目录") + #expect(translations["Restore Default"] == "恢复默认") + } + @Test func simplifiedChineseResourcesCoverGitHubPullRequests() throws { let translations = try simplifiedChineseTranslations() diff --git a/Tests/LitheTests/AppSettingsTests.swift b/Tests/LitheTests/AppSettingsTests.swift new file mode 100644 index 000000000..f7ae14f34 --- /dev/null +++ b/Tests/LitheTests/AppSettingsTests.swift @@ -0,0 +1,78 @@ +import Foundation +import Testing +@testable import Lithe + +@Suite("App settings") +@MainActor +struct AppSettingsTests { + @Test + func autoSaveDefaultsToEnabledAndPersistsDisabledSelection() { + let store = AppSettingsTestStore() + let settings = AppSettings(store: store) + + #expect(settings.autoSave) + + settings.autoSave = false + + #expect(AppSettings(store: store).autoSave == false) + } + + @Test + func restoringDefaultsEnablesAutoSave() { + let store = AppSettingsTestStore() + let settings = AppSettings(store: store) + settings.autoSave = false + + settings.restoreDefaults() + + #expect(settings.autoSave) + #expect(AppSettings(store: store).autoSave) + } + + @Test + func customLogDirectoryPersistsAndCanBeRestoredToDefault() { + let store = AppSettingsTestStore() + let settings = AppSettings(store: store) + let customDirectory = URL(fileURLWithPath: "/tmp/lithe-test-logs", isDirectory: true) + + #expect(settings.customLogDirectory == nil) + #expect(settings.logDirectory == settings.defaultLogDirectory) + + settings.setCustomLogDirectory(customDirectory) + + let restored = AppSettings(store: store) + #expect(restored.customLogDirectory == customDirectory.standardizedFileURL) + #expect(restored.logDirectory == customDirectory.standardizedFileURL) + + restored.restoreDefaults() + + #expect(restored.customLogDirectory == nil) + #expect(AppSettings(store: store).logDirectory == restored.defaultLogDirectory) + } + + @Test + func logDirectoryObserversReceiveCustomAndRestoredDirectories() { + let settings = AppSettings(store: AppSettingsTestStore()) + let customDirectory = URL(fileURLWithPath: "/tmp/lithe-observed-logs", isDirectory: true) + var observedDirectories: [URL] = [] + settings.addLogDirectoryObserver { observedDirectories.append($0) } + + settings.setCustomLogDirectory(customDirectory) + settings.setCustomLogDirectory(nil) + + #expect(observedDirectories == [ + customDirectory.standardizedFileURL, + settings.defaultLogDirectory + ]) + } +} + +private final class AppSettingsTestStore: KeyValueStore, @unchecked Sendable { + private var values: [String: Any] = [:] + + func data(forKey key: String) -> Data? { values[key] as? Data } + func object(forKey key: String) -> Any? { values[key] } + func string(forKey key: String) -> String? { values[key] as? String } + func stringArray(forKey key: String) -> [String]? { values[key] as? [String] } + func set(_ value: Any?, forKey key: String) { values[key] = value } +} diff --git a/Tests/LitheTests/EditorChromeModelTests.swift b/Tests/LitheTests/EditorChromeModelTests.swift new file mode 100644 index 000000000..797e17d9b --- /dev/null +++ b/Tests/LitheTests/EditorChromeModelTests.swift @@ -0,0 +1,75 @@ +import Combine +import Foundation +import Testing +@testable import Lithe + +@MainActor +struct EditorChromeModelTests { + @Test + func unchangedCaretAndSelectionDoNotPublish() { + let chrome = EditorChromeModel() + let caret = EditorCaret( + url: URL(fileURLWithPath: "/workspace/App.java"), + line: 3, + utf16Column: 8 + ) + chrome.update(caret: caret) + chrome.update(selectedText: "name") + + var publishCount = 0 + let observation = chrome.objectWillChange.sink { _ in publishCount += 1 } + defer { observation.cancel() } + + chrome.update(caret: caret) + chrome.update(selectedText: "name") + #expect(publishCount == 0) + + chrome.update(caret: EditorCaret(url: caret.url, line: 4, utf16Column: 0)) + #expect(publishCount == 1) + chrome.update(selectedText: "") + #expect(publishCount == 2) + } + + @Test + func resetClearsCaretAndSelectionOnceEach() { + let chrome = EditorChromeModel() + chrome.update( + caret: EditorCaret(url: URL(fileURLWithPath: "/workspace/App.java"), line: 0, utf16Column: 0) + ) + chrome.update(selectedText: "foo") + + var publishCount = 0 + let observation = chrome.objectWillChange.sink { _ in publishCount += 1 } + defer { observation.cancel() } + + chrome.reset() + #expect(chrome.caret == nil) + #expect(chrome.selectedText.isEmpty) + #expect(publishCount == 2) + + chrome.reset() + #expect(publishCount == 2) + } + + @Test + func findBarUpdatesDoNotPublishUnchangedValues() { + let chrome = EditorChromeModel() + chrome.setFindBarVisible(true) + chrome.setFindBarQuery("foo") + chrome.updateFindState(currentIndex: 1, count: 3) + + var publishCount = 0 + let observation = chrome.objectWillChange.sink { _ in publishCount += 1 } + defer { observation.cancel() } + + chrome.setFindBarVisible(true) + chrome.setFindBarQuery("foo") + chrome.updateFindState(currentIndex: 1, count: 3) + #expect(publishCount == 0) + + chrome.setFindBarQuery("bar") + #expect(publishCount == 1) + chrome.updateFindState(currentIndex: 0, count: 2) + #expect(publishCount == 2) + } +} diff --git a/Tests/LitheTests/EditorDiagnosticsStoreTests.swift b/Tests/LitheTests/EditorDiagnosticsStoreTests.swift new file mode 100644 index 000000000..18dc5903a --- /dev/null +++ b/Tests/LitheTests/EditorDiagnosticsStoreTests.swift @@ -0,0 +1,39 @@ +import Combine +import Foundation +import Testing +@testable import Lithe + +@MainActor +struct EditorDiagnosticsStoreTests { + @Test + func unchangedDiagnosticsDoNotPublish() { + let store = EditorDiagnosticsStore() + let url = URL(fileURLWithPath: "/workspace/App.java") + let diagnostic = EditorDiagnostic( + id: "d1", + fileURL: url, + line: 1, + utf16Column: 0, + endLine: 1, + endUTF16Column: 4, + severity: .warning, + message: "unused", + source: nil, + code: nil, + tags: [], + relatedInformation: [] + ) + store.replace([url: [diagnostic]]) + + var publishCount = 0 + let observation = store.objectWillChange.sink { _ in publishCount += 1 } + defer { observation.cancel() } + + store.replace([url: [diagnostic]]) + #expect(publishCount == 0) + + store.replace([:]) + #expect(publishCount == 1) + #expect(store.diagnostics(for: url).isEmpty) + } +} diff --git a/Tests/LitheTests/FrameRateMonitorTests.swift b/Tests/LitheTests/FrameRateMonitorTests.swift new file mode 100644 index 000000000..3c056a5d3 --- /dev/null +++ b/Tests/LitheTests/FrameRateMonitorTests.swift @@ -0,0 +1,37 @@ +import Combine +import Testing +@testable import Lithe + +@MainActor +struct FrameRateMonitorTests { + @Test + func publishesWhenDisplayedIntegerChangesAndIgnoresRepeats() { + let monitor = FrameRateMonitor(sampleWindow: 0.5) + monitor.recordFrameForTesting(at: 0) + monitor.recordFrameForTesting(at: 0.5) + #expect(monitor.framesPerSecond == 4) + #expect(monitor.framesPerSecondText == "4 FPS") + + var publishCount = 0 + let observation = monitor.objectWillChange.sink { _ in publishCount += 1 } + defer { observation.cancel() } + + monitor.recordFrameForTesting(at: 1.0) + #expect(monitor.framesPerSecond == 2) + #expect(publishCount == 1) + + monitor.recordFrameForTesting(at: 1.5) + #expect(monitor.framesPerSecond == 2) + #expect(publishCount == 1) + } + + @Test + func coalescesBurstsAfterAHitch() { + let monitor = FrameRateMonitor(sampleWindow: 0.5) + monitor.recordFrameForTesting(at: 0) + monitor.recordFrameForTesting(at: 0.001) + monitor.recordFrameForTesting(at: 0.002) + monitor.recordFrameForTesting(at: 0.5) + #expect(monitor.framesPerSecond == 4) + } +} diff --git a/Tests/LitheTests/JavaLanguageServerRuntimeTests.swift b/Tests/LitheTests/JavaLanguageServerRuntimeTests.swift new file mode 100644 index 000000000..043321d99 --- /dev/null +++ b/Tests/LitheTests/JavaLanguageServerRuntimeTests.swift @@ -0,0 +1,153 @@ +import Foundation +import Testing +@testable import Lithe + +@Suite("Java language server runtime") +struct JavaLanguageServerRuntimeTests { + @Test + func parsesModernAndLegacyJavaVersions() { + #expect(javaRuntime("/jdk-17", "17.0.18").majorVersion == 17) + #expect(javaRuntime("/jdk-21", "21-ea").majorVersion == 21) + #expect(javaRuntime("/jdk-8", "1.8.0_442").majorVersion == 8) + #expect(javaRuntime("/jdk-unknown", "unknown").majorVersion == nil) + } + + @Test + func jdtlsCompatibilityRequiresJava17OrNewer() { + #expect(!javaRuntime("/jdk-11", "11.0.26").supportsJDTLS) + #expect(javaRuntime("/jdk-17", "17.0.18").supportsJDTLS) + #expect(javaRuntime("/jdk-21", "21.0.10").supportsJDTLS) + } + + @Test + @MainActor + func automaticJdtlsRuntimeIgnoresProjectRunJDK() async { + let projectRuntime = javaRuntime("/project/jdk-11", "11.0.26") + let jdtlsRuntime = javaRuntime("/language-server/jdk-21", "21.0.10") + let service = ProjectRuntimeService( + runtimeLocator: JavaLanguageServerTestRuntimeLocator( + runtimes: [projectRuntime, jdtlsRuntime] + ), + store: JavaLanguageServerTestStore() + ) + + #expect( + service.javaHomeURL(overridePath: projectRuntime.homePath)?.path + == projectRuntime.homePath + ) + #expect(service.javaLanguageServerExecutableURL() == nil) + await service.prepareJavaLanguageServerRuntime() + #expect( + service.javaLanguageServerExecutableURL()?.path + == "/language-server/jdk-21/bin/java" + ) + } + + @Test + @MainActor + func manualJdtlsRuntimeRejectsOldJavaAndAcceptsJava17() async { + let oldRuntime = javaRuntime("/jdk-11", "11.0.26") + let supportedRuntime = javaRuntime("/jdk-17", "17.0.18") + let service = ProjectRuntimeService( + runtimeLocator: JavaLanguageServerTestRuntimeLocator( + runtimes: [supportedRuntime, oldRuntime] + ), + store: JavaLanguageServerTestStore() + ) + + #expect(await service.inspectJavaLanguageServerRuntime(atPath: oldRuntime.homePath) == oldRuntime) + await service.prepareJavaLanguageServerRuntime(overridePath: oldRuntime.homePath) + #expect(service.javaLanguageServerExecutableURL(overridePath: oldRuntime.homePath) == nil) + await service.prepareJavaLanguageServerRuntime(overridePath: supportedRuntime.homePath) + #expect( + service.javaLanguageServerExecutableURL(overridePath: supportedRuntime.homePath)?.path + == "/jdk-17/bin/java" + ) + } + + @Test + @MainActor + func automaticJdtlsRuntimeDiscoveryRunsOffTheMainThread() async { + let recorder = JavaLanguageServerDiscoveryThreadRecorder() + let runtime = javaRuntime("/jdk-21", "21.0.10") + let service = ProjectRuntimeService( + runtimeLocator: JavaLanguageServerTestRuntimeLocator( + runtimes: [runtime], + threadRecorder: recorder + ), + store: JavaLanguageServerTestStore() + ) + + await service.prepareJavaLanguageServerRuntime() + + #expect(recorder.discoveryWasOnMainThread == false) + #expect(service.javaLanguageServerExecutableURL()?.path == "/jdk-21/bin/java") + } + + private func javaRuntime(_ homePath: String, _ version: String) -> JavaRuntimeCandidate { + JavaRuntimeCandidate(homePath: homePath, version: version, vendor: "Test JDK") + } +} + +private struct JavaLanguageServerTestRuntimeLocator: RuntimeLocator { + let runtimes: [JavaRuntimeCandidate] + var threadRecorder: JavaLanguageServerDiscoveryThreadRecorder? + + init( + runtimes: [JavaRuntimeCandidate], + threadRecorder: JavaLanguageServerDiscoveryThreadRecorder? = nil + ) { + self.runtimes = runtimes + self.threadRecorder = threadRecorder + } + + func environment() -> [String: String] { [:] } + + func discover() -> RuntimeDiscoveryResult { + threadRecorder?.recordDiscoveryThread() + return RuntimeDiscoveryResult(javaRuntimes: runtimes, mavenRuntimes: []) + } + + func validJavaHome(path: String) -> URL? { + runtimes.contains(where: { $0.homePath == path }) + ? URL(fileURLWithPath: path, isDirectory: true) + : nil + } + + func javaRuntime(at homeURL: URL) -> JavaRuntimeCandidate? { + runtimes.first(where: { $0.homePath == homeURL.standardizedFileURL.path }) + } + + 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 JavaLanguageServerDiscoveryThreadRecorder: @unchecked Sendable { + private let lock = NSLock() + private var discoveryWasOnMainThreadValue: Bool? + + var discoveryWasOnMainThread: Bool? { + lock.lock() + defer { lock.unlock() } + return discoveryWasOnMainThreadValue + } + + func recordDiscoveryThread() { + lock.lock() + discoveryWasOnMainThreadValue = Thread.isMainThread + lock.unlock() + } +} + +private struct JavaLanguageServerTestStore: KeyValueStore { + func data(forKey key: String) -> Data? { nil } + func object(forKey key: String) -> Any? { nil } + func string(forKey key: String) -> String? { nil } + func stringArray(forKey key: String) -> [String]? { nil } + func set(_ value: Any?, forKey key: String) {} + func removeObject(forKey key: String) {} +} diff --git a/Tests/LitheTests/LinuxDoAnonymousWebSessionTests.swift b/Tests/LitheTests/LinuxDoAnonymousWebSessionTests.swift index f89dfcadf..72eb8dabe 100644 --- a/Tests/LitheTests/LinuxDoAnonymousWebSessionTests.swift +++ b/Tests/LitheTests/LinuxDoAnonymousWebSessionTests.swift @@ -5,28 +5,25 @@ import Testing @MainActor struct LinuxDoAnonymousWebSessionTests { @Test - func shortPanelAbsenceKeepsTheCurrentWebView() async throws { + func shortPanelAbsenceKeepsTheCurrentWebView() async { let session = LinuxDoAnonymousWebSession(idleLifetimeNanoseconds: 50_000_000) let webView = WKWebView() session.webView = webView - session.releaseAfterInactivity() + let releaseTask = session.releaseAfterInactivity() session.resume() - try await Task.sleep(nanoseconds: 80_000_000) + await releaseTask.value #expect(session.webView === webView) } @Test - func inactiveSessionReleasesItsWebView() async throws { + func inactiveSessionReleasesItsWebView() async { let session = LinuxDoAnonymousWebSession(idleLifetimeNanoseconds: 20_000_000) session.webView = WKWebView() - session.releaseAfterInactivity() - let deadline = ContinuousClock.now + .seconds(1) - while session.webView != nil, ContinuousClock.now < deadline { - try await Task.sleep(for: .milliseconds(10)) - } + let releaseTask = session.releaseAfterInactivity() + await releaseTask.value #expect(session.webView == nil) } diff --git a/Tests/LitheTests/LitheCoreLogicTests.swift b/Tests/LitheTests/LitheCoreLogicTests.swift index 7aef674b9..4a487a753 100644 --- a/Tests/LitheTests/LitheCoreLogicTests.swift +++ b/Tests/LitheTests/LitheCoreLogicTests.swift @@ -1,4 +1,5 @@ import AppKit +import Combine import CoreServices import Foundation @testable import LitheDatabaseModule @@ -86,6 +87,26 @@ struct LitheCoreLogicTests { #expect(fittedFrame.maxY <= visibleFrame.maxY) } + @Test + func standaloneWindowUsesScreenRatioWithinSizeLimits() { + let regularScreen = NSRect(x: 0, y: 0, width: 1440, height: 900) + let compactScreen = NSRect(x: 0, y: 0, width: 900, height: 600) + let largeScreen = NSRect(x: 0, y: 0, width: 2560, height: 1600) + + #expect( + LitheWindowLayout.standaloneContentSize(fitting: regularScreen) + == NSSize(width: 936, height: 648) + ) + #expect( + LitheWindowLayout.standaloneContentSize(fitting: compactScreen) + == LitheWindowLayout.standaloneMinimumContentSize + ) + #expect( + LitheWindowLayout.standaloneContentSize(fitting: largeScreen) + == LitheWindowLayout.standaloneMaximumContentSize + ) + } + @Test @MainActor func workspaceTitleBarZoomsToTheVisibleScreenAndRestores() { @@ -1323,6 +1344,51 @@ struct LitheCoreLogicTests { #expect(!WorkspaceTextFilePolicy.isPlainText(Data([0x00, 0x01, 0x02]))) } + @Test + @MainActor + func standaloneEditorLoadsUtf8TextAndLeavesBinaryFilesInFailedState() async { + let storage = InMemoryFileStorage() + let textURL = URL(fileURLWithPath: "/in-memory/notes.txt") + let binaryURL = URL(fileURLWithPath: "/in-memory/archive.bin") + storage.seed(Data("let answer = 42\n".utf8), at: textURL) + storage.seed(Data([0x00, 0x01, 0x02]), at: binaryURL) + + let feature = DocumentFeatureModel( + operations: EmptyWorkspaceOperations(readFileValue: nil), + fileOperations: EmptyWorkspaceFileOperations(), + fileStorage: storage, + binaryFileViewerRegistry: BinaryFileViewerRegistry() + ) + feature.configure( + workspaceURLProvider: { nil }, + autoSaveEnabledProvider: { false }, + autoSaveDelayProvider: { 0 }, + notify: { _ in }, + onDocumentOpened: { _ in }, + onDocumentChanged: { _ in }, + onDocumentClosed: { _ in }, + onRecordSave: { _, _ in }, + onRecordDiscard: { _ in }, + onRecordExternalChanges: { _ in }, + onDocumentCollectionChanged: {}, + onProjectCloseReady: {} + ) + + feature.openStandaloneFile(textURL) + for _ in 0..<100 where feature.standaloneFileLoadState == .loading { + await Task.yield() + } + #expect(feature.standaloneFileLoadState == .loaded) + #expect(feature.activeDocument?.text == "let answer = 42\n") + + feature.openStandaloneFile(binaryURL) + for _ in 0..<100 where feature.standaloneFileLoadState == .loading { + await Task.yield() + } + #expect(feature.standaloneFileLoadState == .failed(.notText)) + #expect(feature.activeDocument == nil) + } + @Test @MainActor func binaryFileViewerRegistryPrefersMagicAndDefaultsToDeny() async { let registry = BinaryFileViewerRegistry() @@ -2119,6 +2185,38 @@ struct LitheCoreLogicTests { #expect(updates.first?.count == 0) } + @Test + @MainActor + func codeEditorLineIndexKeepsLineNumbersAfterSingleLineEdit() { + let textView = CodeTextView(frame: .zero) + textView.string = "one\ntwo\nthree" + textView.rebuildLineIndex() + #expect(textView.lineNumber(at: 4, in: textView.string as NSString) == 1) + + textView.string = "oneX\ntwo\nthree" + textView.applyLineIndexEdit(replacedRange: NSRange(location: 3, length: 0), replacement: "X") + let source = textView.string as NSString + #expect(textView.lineNumber(at: 0, in: source) == 0) + #expect(textView.lineNumber(at: 5, in: source) == 1) + #expect(textView.lineNumber(at: 9, in: source) == 2) + #expect(textView.characterOffset(forLine: 2, in: source) == 9) + } + + @Test + @MainActor + func codeEditorShiftsFindMatchesAcrossASingleLineEdit() { + let textView = CodeTextView(frame: .zero) + textView.string = "alpha beta alpha" + textView.rebuildLineIndex() + textView.updateFindMatches(query: "alpha") + #expect(textView.currentFindMatchCountForTesting == 2) + + textView.string = "Xalpha beta alpha" + textView.applyFindEdit(replacedRange: NSRange(location: 0, length: 0), insertedLength: 1, query: "alpha") + #expect(textView.currentFindMatchCountForTesting == 2) + #expect(textView.findMatchLocationsForTesting == [1, 12]) + } + @Test @MainActor func codeEditorReportsEachFindStateOnlyOnce() { @@ -2489,6 +2587,7 @@ struct LitheCoreLogicTests { @MainActor private final class TestProjectWindowSessions: ProjectWindowSessionHandling { var hasActiveProject: Bool + var hasActiveStandaloneFile = false private(set) var closeActiveProjectCallCount = 0 init(hasActiveProject: Bool) { @@ -2498,6 +2597,11 @@ private final class TestProjectWindowSessions: ProjectWindowSessionHandling { func closeActiveProject() { closeActiveProjectCallCount += 1 } + + func requestCloseActiveSession() -> Bool { + closeActiveProject() + return false + } } private final class RecordingProcessRunner: ProcessRunner, DatabaseProcessRunning, @unchecked Sendable { @@ -2592,6 +2696,34 @@ struct EditorDocumentTests { #expect(try String(contentsOf: url, encoding: .utf8) == "after") } + @Test + @MainActor + func liveEditorTextPublishesOnlyWhenDirtyStateChanges() { + let document = EditorDocument( + url: URL(fileURLWithPath: "/tmp/live-editor.txt"), + text: "before", + modificationDate: nil + ) + var publishCount = 0 + let observation = document.objectWillChange.sink { _ in publishCount += 1 } + defer { observation.cancel() } + + document.applyLiveEditorText("before") + #expect(publishCount == 0) + + document.applyLiveEditorText("after") + #expect(document.isDirty) + #expect(publishCount == 1) + + document.applyLiveEditorText("after more") + #expect(document.isDirty) + #expect(publishCount == 1) + + document.applyLiveEditorText("before") + #expect(!document.isDirty) + #expect(publishCount == 2) + } + @Test func readOnlyDocumentRejectsSave() { let url = FileManager.default.temporaryDirectory @@ -2958,6 +3090,83 @@ struct EditorDocumentTests { #expect(gitRefreshCount == 1) } + @Test + @MainActor + func capturedProjectDeletionSurvivesConfirmationDialogDismissal() async throws { + let workspace = URL(fileURLWithPath: "/tmp/lithe-delete-confirmation") + let target = workspace.appendingPathComponent("obsolete.swift") + let fileOperations = RecordingTrashWorkspaceFileOperations() + let operations = SequencedWorkspaceOperations( + snapshotAvailability: [true, false], + files: [target] + ) + var historyRecordCount = 0 + let model = makeWorkspaceObservationUnitModel( + operations: operations, + fileOperations: fileOperations, + provider: SequencedGitWatchContextProvider([nil]), + watcherFactory: TestDirectoryWatcherFactory(), + refreshGit: {}, + recordHistory: { _, _ in historyRecordCount += 1 } + ) + model.beginWorkspace(at: workspace, visibilityRules: .default) + _ = await model.rebuild(at: workspace, rules: .default, isCurrent: { true }) + model.requestDeleteProjectItem(at: target, isDirectory: false) + let request = try #require(model.pendingProjectItemDeletion) + + // SwiftUI dismisses the confirmation dialog before its asynchronous + // action runs, so the captured request must not depend on pending state. + model.cancelProjectItemDeletion() + let deletionTask = Task { await model.confirmProjectItemDeletion(request) } + + for _ in 0..<100 where !fileOperations.hasStarted { + try? await Task.sleep(for: .milliseconds(10)) + } + + #expect(fileOperations.hasStarted) + #expect(model.projectFiles.isEmpty) + #expect(model.rootNode?.children?.isEmpty == true) + + fileOperations.release() + await deletionTask.value + + #expect(model.pendingProjectItemDeletion == nil) + #expect(fileOperations.trashedURLs == [target.standardizedFileURL]) + #expect(historyRecordCount == 0) + #expect(model.projectFiles.isEmpty) + #expect(model.rootNode?.children?.isEmpty == true) + } + + @Test + @MainActor + func failedProjectDeletionRestoresOptimisticallyRemovedItem() async { + let workspace = URL(fileURLWithPath: "/tmp/lithe-delete-failure") + let target = workspace.appendingPathComponent("still-here.swift") + let operations = SequencedWorkspaceOperations( + snapshotAvailability: [true, true], + files: [target] + ) + let model = makeWorkspaceObservationUnitModel( + operations: operations, + fileOperations: FailingTrashWorkspaceFileOperations(), + provider: SequencedGitWatchContextProvider([nil]), + watcherFactory: TestDirectoryWatcherFactory(), + refreshGit: {} + ) + model.beginWorkspace(at: workspace, visibilityRules: .default) + _ = await model.rebuild(at: workspace, rules: .default, isCurrent: { true }) + model.requestDeleteProjectItem(at: target, isDirectory: false) + guard let request = model.pendingProjectItemDeletion else { + Issue.record("The deletion request should be available") + return + } + + await model.confirmProjectItemDeletion(request) + + #expect(model.projectFiles == [target]) + #expect(model.rootNode?.children?.map(\.url) == [target]) + } + @Test func workspaceFilesystemFallbackBuildsAVisibleTreeAndHonorsHiddenRules() throws { let fileManager = FileManager.default @@ -3154,7 +3363,9 @@ struct EditorDocumentTests { source?.emit(DirectoryChangeBatch(gitStateMayHaveChanged: true)) source?.emit(DirectoryChangeBatch(gitStateMayHaveChanged: true)) source?.emit(DirectoryChangeBatch(gitStateMayHaveChanged: true)) - let refreshed = await waitForWorkspaceObservation { refreshCount == 2 } + let refreshed = await waitForWorkspaceObservation(timeout: .seconds(15)) { + refreshCount == 2 + } #expect(refreshed) #expect(refreshCount == 2) @@ -3384,6 +3595,157 @@ struct EditorDocumentTests { await pendingA.value #expect(model.activeDocumentID == documentB.id) } + + @Test + @MainActor + func foregroundRequestActivatesAnEquivalentPendingBackgroundOpen() async { + let workspace = URL(fileURLWithPath: "/tmp/lithe-equivalent-pending-open-tests") + let fileA = workspace.appendingPathComponent("A.swift") + let operations = BlockingWorkspaceOperations() + let model = DocumentFeatureModel( + operations: operations, + fileOperations: EmptyWorkspaceFileOperations(), + fileStorage: InMemoryFileStorage(), + binaryFileViewerRegistry: BinaryFileViewerRegistry() + ) + model.configure( + workspaceURLProvider: { workspace }, + autoSaveEnabledProvider: { false }, + autoSaveDelayProvider: { 0 }, + notify: { _ in }, + onDocumentOpened: { _ in }, + onDocumentChanged: { _ in }, + onDocumentClosed: { _ in }, + onRecordSave: { _, _ in }, + onRecordDiscard: { _ in }, + onRecordExternalChanges: { _ in }, + onDocumentCollectionChanged: {}, + onProjectCloseReady: {} + ) + + let pendingA = Task { @MainActor in + await model.openFileAsync( + fileA, + isReadOnly: false, + displayPath: nil, + activateWhenReady: false + ) + } + for _ in 0..<100 where !operations.didStartReadingA { + await Task.yield() + } + #expect(operations.didStartReadingA) + + await model.openFileAsync( + workspace.appendingPathComponent("nested/../A.swift"), + isReadOnly: false, + displayPath: nil, + activateWhenReady: true + ) + operations.releaseA() + await pendingA.value + + #expect(model.openDocuments.count == 1) + #expect(model.activeDocumentID == model.openDocuments.first?.id) + } + + @Test + @MainActor + func standardizedFilePathsReuseTheExistingDirtyDocument() async throws { + let workspace = URL(fileURLWithPath: "/tmp/lithe-standardized-path-tests") + let featureDirectory = workspace.appendingPathComponent("Sources/Feature") + let fileURL = featureDirectory.appendingPathComponent("Example.swift") + + let model = DocumentFeatureModel( + operations: EmptyWorkspaceOperations(readFileValue: "original"), + fileOperations: EmptyWorkspaceFileOperations(), + fileStorage: InMemoryFileStorage(), + binaryFileViewerRegistry: BinaryFileViewerRegistry() + ) + model.configure( + workspaceURLProvider: { workspace }, + autoSaveEnabledProvider: { false }, + autoSaveDelayProvider: { 0 }, + notify: { _ in }, + onDocumentOpened: { _ in }, + onDocumentChanged: { _ in }, + onDocumentClosed: { _ in }, + onRecordSave: { _, _ in }, + onRecordDiscard: { _ in }, + onRecordExternalChanges: { _ in }, + onDocumentCollectionChanged: {}, + onProjectCloseReady: {} + ) + + await model.openFileAsync( + fileURL, + isReadOnly: false, + displayPath: nil, + activateWhenReady: true + ) + let originalDocument = try #require(model.openDocuments.first) + originalDocument.text = "unsaved change" + + await model.openFileAsync( + workspace.appendingPathComponent("Sources/Nested/../Feature/Example.swift"), + isReadOnly: false, + displayPath: nil, + activateWhenReady: true + ) + + #expect(model.openDocuments.count == 1) + #expect(model.openDocuments.first === originalDocument) + #expect(originalDocument.text == "unsaved change") + #expect(originalDocument.isDirty) + } + + @Test + func projectTreeLocatorMatchesStandardizedPathsAndExpandsParents() { + let root = URL(fileURLWithPath: "/tmp/lithe-tree-locator-tests") + let featureDirectory = root.appendingPathComponent("Sources/Feature") + let fileURL = featureDirectory.appendingPathComponent("Example.swift") + + let equivalentFile = root.appendingPathComponent("Sources/Nested/../Feature/Example.swift") + #expect(ProjectTreeLocator.matchingURL(for: equivalentFile, among: [fileURL]) == fileURL) + #expect( + ProjectTreeLocator.expandedDirectoryPaths(for: fileURL, rootURL: root) + == Set([ + root.standardizedFileURL.path, + root.appendingPathComponent("Sources").standardizedFileURL.path, + featureDirectory.standardizedFileURL.path + ]) + ) + #expect( + ProjectTreeLocator.matchingURL( + for: root.deletingLastPathComponent().appendingPathComponent("Outside.swift"), + among: [fileURL] + ) == nil + ) + } + + @Test + @MainActor + func editorViewportStoreRetainsStateForOpenDocuments() { + let retainedID = UUID() + let closedID = UUID() + let store = EditorViewportStore() + store.updateSelection(NSRange(location: 18, length: 4), for: retainedID) + store.updateScrollOffset(240, for: retainedID) + store.updateSelection(NSRange(location: 7, length: 0), for: closedID) + + #expect( + store.state(for: retainedID) + == EditorViewportState( + selectionLocation: 18, + selectionLength: 4, + verticalScrollOffset: 240 + ) + ) + + store.retain(documentIDs: [retainedID]) + #expect(store.state(for: retainedID).selectionLocation == 18) + #expect(store.state(for: closedID) == EditorViewportState()) + } } @MainActor @@ -3394,7 +3756,8 @@ private func makeWorkspaceObservationUnitModel( watcherFactory: TestDirectoryWatcherFactory, refreshGit: @escaping @MainActor () async -> Void, processExternalChanges: @escaping @MainActor ([URL]) -> Bool = { _ in false }, - reloadProjectServices: @escaping @MainActor () async -> Void = {} + reloadProjectServices: @escaping @MainActor () async -> Void = {}, + recordHistory: @escaping @MainActor (URL, LocalHistoryReason) async -> Void = { _, _ in } ) -> WorkspaceFeatureModel { let model = WorkspaceFeatureModel( operations: operations, @@ -3412,7 +3775,7 @@ private func makeWorkspaceObservationUnitModel( restoreSession: { _, _ in }, openFile: { _ in }, notify: { _ in }, - recordHistory: { _, _ in }, + recordHistory: recordHistory, relocateHistory: { _, _ in }, relocateOpenDocuments: { _, _ in }, closeDocuments: { _ in }, @@ -3503,7 +3866,33 @@ private final class InMemoryFileStorage: FileStorage, GitShelfStorage, DatabaseF func cacheDirectory() -> URL { support } func applicationSupportDirectory() -> URL { support } func temporaryDirectory() -> URL { support } - func metadata(for url: URL) -> FileMetadata? { nil } + func metadata(for url: URL) -> FileMetadata? { + lock.lock() + defer { lock.unlock() } + if let data = files[url.path] { + return FileMetadata( + byteCount: data.count, + modificationDate: nil, + isRegularFile: true, + isDirectory: false + ) + } + if directories.contains(url.path) { + return FileMetadata( + byteCount: nil, + modificationDate: nil, + isRegularFile: false, + isDirectory: true + ) + } + return nil + } + + func seed(_ data: Data, at url: URL) { + lock.lock() + files[url.path] = data + lock.unlock() + } func fileExists(at url: URL) -> Bool { lock.lock() @@ -3725,9 +4114,11 @@ private final class BlockingWorkspaceOperations: WorkspaceOperations, @unchecked private final class SequencedWorkspaceOperations: WorkspaceOperations, @unchecked Sendable { private let lock = NSLock() private var snapshotAvailability: [Bool] + private let files: [URL] - init(snapshotAvailability: [Bool]) { + init(snapshotAvailability: [Bool], files: [URL] = []) { self.snapshotAvailability = snapshotAvailability + self.files = files } func snapshot(at rootURL: URL, visibilityRules: FileVisibilityRules) -> WorkspaceSnapshot? { @@ -3736,8 +4127,12 @@ private final class SequencedWorkspaceOperations: WorkspaceOperations, @unchecke lock.unlock() guard isAvailable else { return nil } return WorkspaceSnapshot( - root: FileNode(url: rootURL, isDirectory: true, children: []), - files: [] + root: FileNode( + url: rootURL, + isDirectory: true, + children: files.map { FileNode(url: $0, isDirectory: false, children: nil) } + ), + files: files ) } @@ -3801,6 +4196,57 @@ private struct EmptyWorkspaceFileOperations: WorkspaceFileOperations { func readText(from url: URL) throws -> String { "" } } +private final class RecordingTrashWorkspaceFileOperations: WorkspaceFileOperations, @unchecked Sendable { + private let lock = NSLock() + private let releaseSemaphore = DispatchSemaphore(value: 0) + private var recordedTrashedURLs: [URL] = [] + private var startedValue = false + + var trashedURLs: [URL] { + lock.withLock { recordedTrashedURLs } + } + + var hasStarted: Bool { + lock.withLock { startedValue } + } + + func release() { + releaseSemaphore.signal() + } + + func fileExists(at url: URL) -> Bool { true } + func isDirectory(at url: URL) -> Bool { false } + func createFile(at url: URL) throws {} + func createDirectory(at url: URL, withIntermediateDirectories: Bool) throws {} + func copyItem(at sourceURL: URL, to destinationURL: URL) throws {} + func moveItem(at sourceURL: URL, to destinationURL: URL) throws {} + func removeItem(at url: URL) throws {} + func trashItem(at url: URL) throws { + lock.withLock { + startedValue = true + } + releaseSemaphore.wait() + lock.withLock { + recordedTrashedURLs.append(url.standardizedFileURL) + } + } + func writeText(_ text: String, to url: URL) throws {} + func readText(from url: URL) throws -> String { "" } +} + +private struct FailingTrashWorkspaceFileOperations: WorkspaceFileOperations { + func fileExists(at url: URL) -> Bool { true } + func isDirectory(at url: URL) -> Bool { false } + func createFile(at url: URL) throws {} + func createDirectory(at url: URL, withIntermediateDirectories: Bool) throws {} + func copyItem(at sourceURL: URL, to destinationURL: URL) throws {} + func moveItem(at sourceURL: URL, to destinationURL: URL) throws {} + func removeItem(at url: URL) throws {} + func trashItem(at url: URL) throws { throw CocoaError(.fileWriteNoPermission) } + func writeText(_ text: String, to url: URL) throws {} + func readText(from url: URL) throws -> String { "" } +} + private final class TestDirectoryChangeSource: DirectoryChangeSource { private let onChange: @Sendable (DirectoryChangeBatch) -> Void diff --git a/Tests/LitheTests/MacApplicationLogWriterTests.swift b/Tests/LitheTests/MacApplicationLogWriterTests.swift new file mode 100644 index 000000000..bf1eb68ba --- /dev/null +++ b/Tests/LitheTests/MacApplicationLogWriterTests.swift @@ -0,0 +1,45 @@ +import Foundation +import Testing +@testable import Lithe + +@Suite("macOS application log writer") +struct MacApplicationLogWriterTests { + @Test + func changingDirectoryMovesSubsequentApplicationLogOutputToTheSelection() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { + try? FileManager.default.removeItem(at: root) + } + + let writer = MacApplicationLogWriter() + let defaultDirectory = root.appendingPathComponent("default", isDirectory: true) + let selectedDirectory = root.appendingPathComponent("selected", isDirectory: true) + + try writer.redirect(to: defaultDirectory) + try writer.append("default log line\n") + + try writer.redirect(to: selectedDirectory) + try writer.append("selected log line\n") + + let defaultContents = try String( + contentsOf: defaultDirectory.appendingPathComponent("lithe.log"), + encoding: .utf8 + ) + let selectedContents = try String( + contentsOf: selectedDirectory.appendingPathComponent("lithe.log"), + encoding: .utf8 + ) + #expect(defaultContents == "default log line\n") + #expect(selectedContents == "selected log line\n") + } + + @Test + func defaultDirectoryProviderUsesTheMacUserLogsDirectory() { + let directory = MacLogDirectoryProvider().defaultLogDirectory + + #expect(directory.path.hasSuffix("/Library/Logs/Lithe")) + } + +} diff --git a/Tests/LitheTests/MavenRuntimeTests.swift b/Tests/LitheTests/MavenRuntimeTests.swift index e99e3eec2..9b6c4cdbd 100644 --- a/Tests/LitheTests/MavenRuntimeTests.swift +++ b/Tests/LitheTests/MavenRuntimeTests.swift @@ -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 { @@ -111,6 +129,25 @@ struct MavenRuntimeTests { } } +private struct ProjectRelativeRuntimeLocator: RuntimeLocator { + let validJavaHomes: Set + + 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) diff --git a/Tests/LitheTests/RunConfigurationIntegrationTests.swift b/Tests/LitheTests/RunConfigurationIntegrationTests.swift index b22a2a149..613b8036e 100644 --- a/Tests/LitheTests/RunConfigurationIntegrationTests.swift +++ b/Tests/LitheTests/RunConfigurationIntegrationTests.swift @@ -347,6 +347,24 @@ struct RunConfigurationIntegrationTests { #expect(candidates.first?.source == .environment) } + @Test + func macToolDiscoveryPrefersBundledJDTLS() { + let resources = URL(fileURLWithPath: "/Applications/Lithe.app/Contents/Resources", isDirectory: true) + let root = URL(fileURLWithPath: "/tmp/mac-java-project", isDirectory: true) + let bundled = resources.appendingPathComponent("LanguageServers/jdtls/bin/jdtls") + let project = root.appendingPathComponent(".lithe/toolchains/bin/jdtls") + let discovery = MacRuntimeToolDiscovery( + homeDirectoryURL: URL(fileURLWithPath: "/tmp/home", isDirectory: true), + resourceDirectoryURL: resources, + isExecutable: { $0 == bundled || $0 == project } + ) + + let candidates = discovery.candidates(for: "jdtls", projectURL: root, environment: [:]) + + #expect(candidates.map(\.source) == [.bundled, .project]) + #expect(candidates.first?.executableURL == bundled) + } + @Test func legacyJavaDoesNotAcceptGenericDAPBreakpointsWithoutAnAdapter() throws { let source = URL(fileURLWithPath: "/tmp/Main.java") @@ -1137,6 +1155,35 @@ struct RunConfigurationIntegrationTests { #expect(manager.activeLanguageServerIDs.isEmpty) } + @Test + func languageToolingRejectsUnavailableRequiredRuntime() { + let descriptor = LanguageProviderDescriptor( + id: "java", + displayName: "Java", + fileExtensions: ["java"], + capabilities: [.languageServer], + activationPolicy: .onDemand, + languageIdentifier: "java", + languageServerLaunch: LanguageServerLaunchDescriptor(executableNames: ["jdtls"]) + ) + let runtimeService = ProjectRuntimeService( + runtimeLocator: RunTestRuntimeLocator(), + store: RunTestKeyValueStore() + ) + let message = "JDTLS requires JDK 17 or newer." + let runtime = StdioLanguageProviderRuntime( + descriptor: descriptor, + runtimeService: runtimeService, + languageServerLaunch: descriptor.languageServerLaunch, + languageServerCore: TestLanguageServerRuntimeCore(providerID: "java"), + languageServerExecutableResolver: { _ in URL(fileURLWithPath: "/usr/bin/jdtls") }, + languageServerRuntimeResolver: { _ in .unavailable(message) } + ) + + #expect(runtime.makeLanguageServerSession() == nil) + #expect(runtime.unavailableToolingMessage == message) + } + @Test func languageServerFailureClearsActiveSessionState() async throws { let descriptor = LanguageProviderDescriptor( @@ -2987,6 +3034,47 @@ struct RunConfigurationIntegrationTests { #expect(options.mavenJavaHomePath == "/test/maven-jdk") } + @Test + func projectToolchainSelectionsPersistAsProjectRelativePaths() throws { + let core = RustCoreBridge() + guard core.isAvailable else { return } + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("lithe-project-toolchains-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + for path in [".lithe/run", "toolchains/jdk", "toolchains/maven/bin", "toolchains/maven-jdk"] { + try FileManager.default.createDirectory( + at: root.appendingPathComponent(path, isDirectory: true), + withIntermediateDirectories: true + ) + } + try Data("#!/bin/sh\n".utf8) + .write(to: root.appendingPathComponent("toolchains/maven/bin/mvn")) + try Data(#"{"version":2,"configurations":[{"id":"spring","name":"Spring","provider":"spring-boot.maven","execution":"service","toolchains":{"java":"project-jdk","maven":"project-maven"},"extensions":{"maven":{"module":"."}}}]}"#.utf8) + .write(to: root.appendingPathComponent(".lithe/run/generated.json")) + let store = MacRunConfigurationStore( + core: core, + storage: MacFileStorage(), + preferences: RunTestKeyValueStore() + ) + + try store.saveOptions( + RunOptions( + javaHomePath: root.appendingPathComponent("toolchains/jdk").path, + mavenExecutablePath: root.appendingPathComponent("toolchains/maven/bin/mvn").path, + mavenJavaHomePath: root.appendingPathComponent("toolchains/maven-jdk").path + ), + configurationID: "spring", + scope: .project, + at: root + ) + + let resolved = try store.resolve(at: root, toolchainCandidates: []) + let options = try #require(resolved.configurations.first { $0.configuration.id == "spring" }?.options) + #expect(options.javaHomePath == "toolchains/jdk") + #expect(options.mavenExecutablePath == "toolchains/maven/bin/mvn") + #expect(options.mavenJavaHomePath == "toolchains/maven-jdk") + } + @Test func goRuntimeToolchainFlowsFromSharedJSONIntoProcessRequest() async throws { let core = RustCoreBridge() @@ -3391,6 +3479,39 @@ struct RunConfigurationIntegrationTests { ) == nil) } + @Test + func projectOptionUpdateForwardsSelectedToolchainPaths() async { + let configuration = JavaRunConfiguration( + id: "spring", + name: "Spring", + kind: .springBoot, + modulePath: ".", + mainClass: nil + ) + let fixture = makeFixture( + status: .ready, + effective: [EffectiveRunConfiguration( + configuration: configuration, + options: RunOptions(), + source: .generated + )] + ) + await fixture.service.loadProject( + at: fixture.root, + files: [], + mavenProject: fixture.mavenProject + ) + let options = RunOptions( + javaHomePath: "toolchains/jdk", + mavenExecutablePath: "toolchains/maven/bin/mvn", + mavenJavaHomePath: "toolchains/maven-jdk" + ) + + #expect(fixture.service.updateOptions(options, for: configuration, scope: .project)) + #expect(fixture.operations.savedOptions == [options]) + #expect(fixture.operations.savedScopes == [.project]) + } + @Test func appOwnedFileEventSuppressionIsOneShotAndExpires() { let first = URL(fileURLWithPath: "/tmp/lithe-self-write-one.json") @@ -3570,6 +3691,8 @@ private final class RecordingRunConfigurationOperations: RunConfigurationOperati private(set) var debugPorts: [Int?] = [] private(set) var createdDrafts: [RunConfigurationDraft] = [] private(set) var lastToolchainCandidates: [ProjectToolchainCandidate] = [] + private(set) var savedOptions: [RunOptions] = [] + private(set) var savedScopes: [RunConfigurationSaveScope] = [] init( status: ProjectRunConfigurationStatus, @@ -3622,7 +3745,10 @@ private final class RecordingRunConfigurationOperations: RunConfigurationOperati configurationID: String, scope: RunConfigurationSaveScope, at projectURL: URL - ) throws {} + ) throws { + savedOptions.append(options) + savedScopes.append(scope) + } func createConfiguration(_ draft: RunConfigurationDraft, at projectURL: URL) throws -> String { createdDrafts.append(draft) let slug = draft.name.lowercased().replacingOccurrences(of: " ", with: "-") diff --git a/Tests/LitheTests/TestLogDirectoryProvider.swift b/Tests/LitheTests/TestLogDirectoryProvider.swift new file mode 100644 index 000000000..05bf59205 --- /dev/null +++ b/Tests/LitheTests/TestLogDirectoryProvider.swift @@ -0,0 +1,12 @@ +import Foundation +@testable import Lithe + +struct TestLogDirectoryProvider: LogDirectoryProviding { + let defaultLogDirectory = URL(fileURLWithPath: "/test/default-logs", isDirectory: true) +} + +extension AppSettings { + convenience init(store: any KeyValueStore) { + self.init(store: store, logDirectoryProvider: TestLogDirectoryProvider()) + } +} diff --git a/docs/architecture/language-tooling.md b/docs/architecture/language-tooling.md index f9d46d355..6c7da10c0 100644 --- a/docs/architecture/language-tooling.md +++ b/docs/architecture/language-tooling.md @@ -125,6 +125,10 @@ Rust Core 的 `lsp.builtinCompletions`、`lsp.builtinHover` 和 macOS discovery 的查找顺序包括项目 `.lithe` 工具目录、`LITHE__PATH`/`LITHE_TOOL__PATH`、`PATH` 和常见系统目录;`gopls` 等 Go 工具还会检查 `GOBIN`、`GOPATH/bin`、`~/go/bin` 和 `~/.go/bin`。discovery 只查找,不自动安装软件。 +正式 macOS 与 Windows 安装包包含 JDTLS。发布构建根据 `third_party/jdtls/manifest.json` 下载固定版本,同时校验归档与 EPL-2.0 许可证的 SHA-256,再将产物放入应用资源目录的 `LanguageServers/jdtls`。平台 adapter 优先使用这个包内启动器;开发环境仍保留项目工具目录、显式覆盖和 `PATH` 等外部候选作为回退。下载只发生在构建阶段,应用运行时不会联网安装 JDTLS;Java 语义功能仍要求系统提供 JDK 17 或更高版本。 + +JDTLS 使用独立于项目运行/调试配置的全局 JDK 偏好。空路径表示自动模式:平台探测本机 JDK,并只选择主版本 17 或更高的候选。用户也可以在语言服务器设置中选择单独的 JDK Home;平台会在保存时探测 `java -version`,并在每次启动 JDTLS 前再次校验路径和版本。这个偏好不会读取、覆盖或写回项目的 Java/Maven JDK 设置。 + LSP 控制中心标题栏的工具设置会在用户偏好中保存每个 provider 的可执行文件覆盖路径。session 创建时先验证并使用该路径,路径失效时继续使用 catalog 候选进行自动探测。Homebrew formula 和官方兜底地址都来自 `languageServerInstallation`,Swift 不维护 provider ID 映射。安装仍由平台层以参数数组直接执行 `brew install`,不经过 shell;没有 Homebrew/formula 时只打开对应项目的 HTTPS 官方发布或安装页面,避免用一套不安全的通用解压逻辑处理不同项目的签名和包结构。 项目配置是可执行工具配置,只有打开受信任项目时才应启用。JSON 可以声明 executable name 和参数,但不能声明 shell、任意安装命令或关闭路径/URL 校验;进程创建、超时、可执行文件验证、Homebrew 调用方式和 HTTPS 限制仍属于平台安全边界。 diff --git a/docs/architecture/windows-development-plan.md b/docs/architecture/windows-development-plan.md index 3b15fe30a..510055b60 100644 --- a/docs/architecture/windows-development-plan.md +++ b/docs/architecture/windows-development-plan.md @@ -33,8 +33,11 @@ platform contract and enabled in the UI only when the capability exists. ## Remaining product work 1. Align each Git feature API with the stable `git.*` command DTOs. -2. Route workspace search, Local History, LSP, Java/Maven, and run - configurations through the same dispatcher. +2. Route workspace search, Local History, remaining non-Java LSP, Java/Maven, + and run configurations through the same dispatcher. Built-in Java LSP now + starts through the Windows host (`jdtls` + JDK discovery) and + `lsp.startServer` with `providerId: "java"`. Spring configuration, `@Value`, + and bean-injection navigation uses `spring.index` before falling back to LSP. 3. Implement Windows-owned process, debug, update, and secure-storage flows in Rust where the current UI exposes them. 4. Hide or capability-gate future feature surfaces until their shared backend diff --git a/docs/assets/screenshots/windows-git-added-single-column.png b/docs/assets/screenshots/windows-git-added-single-column.png new file mode 100644 index 000000000..6202167f6 Binary files /dev/null and b/docs/assets/screenshots/windows-git-added-single-column.png differ diff --git a/docs/assets/screenshots/windows-git-compact-split.png b/docs/assets/screenshots/windows-git-compact-split.png new file mode 100644 index 000000000..8f1c4dd1d Binary files /dev/null and b/docs/assets/screenshots/windows-git-compact-split.png differ diff --git a/docs/assets/screenshots/windows-git-deleted-single-column.png b/docs/assets/screenshots/windows-git-deleted-single-column.png new file mode 100644 index 000000000..afe253b11 Binary files /dev/null and b/docs/assets/screenshots/windows-git-deleted-single-column.png differ diff --git a/docs/assets/screenshots/windows-git-log-tool-window.png b/docs/assets/screenshots/windows-git-log-tool-window.png new file mode 100644 index 000000000..1159a12be Binary files /dev/null and b/docs/assets/screenshots/windows-git-log-tool-window.png differ diff --git a/docs/assets/screenshots/windows-git-split-addition.png b/docs/assets/screenshots/windows-git-split-addition.png new file mode 100644 index 000000000..c83287138 Binary files /dev/null and b/docs/assets/screenshots/windows-git-split-addition.png differ diff --git a/docs/assets/screenshots/windows-git-split-deletion.png b/docs/assets/screenshots/windows-git-split-deletion.png new file mode 100644 index 000000000..b57616015 Binary files /dev/null and b/docs/assets/screenshots/windows-git-split-deletion.png differ diff --git a/docs/assets/screenshots/windows-zh-localization.png b/docs/assets/screenshots/windows-zh-localization.png new file mode 100644 index 000000000..4a16862e2 Binary files /dev/null and b/docs/assets/screenshots/windows-zh-localization.png differ diff --git a/docs/superpowers/plans/2026-08-17-project-tab-bar.md b/docs/superpowers/plans/2026-08-17-project-tab-bar.md new file mode 100644 index 000000000..1c561176a --- /dev/null +++ b/docs/superpowers/plans/2026-08-17-project-tab-bar.md @@ -0,0 +1,177 @@ +# Mac-Style Project Tab Bar Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task with verification checkpoints. + +**Goal:** Add a horizontal project tab bar below the Windows title bar so every project open in the current window is visible and can be activated with one click. + +**Architecture:** Keep project persistence and switching in the existing Zustand/file-system stores. Add a small pure model helper to normalize the active tab for deterministic tests, a focused `ProjectTabBar` presentation component for rendering and activation, and mount it from `MainLayout` directly below `TitleBarWithSettings`. The existing title-bar project dropdown remains unchanged as the project-management menu. + +**Tech Stack:** React 19, TypeScript, Zustand selectors, Base UI-compatible buttons, Tailwind semantic tokens, Bun tests, Vite Plus. + +--- + +### Task 1: Normalize Project Tab Data + +**Files:** +- Create: `windows/tauri/src/features/window/utils/project-tab-bar-model.ts` +- Create: `windows/tauri/src/features/window/utils/project-tab-bar-model.test.ts` + +- [ ] **Step 1: Write the failing test** + +Add a fixture with two project tabs and assert that `getProjectTabBarItems` preserves order, marks only the first active project, and repairs an invalid multiple-active input by keeping the first active tab. + +```ts +test("preserves project order and exposes one active tab", () => { + const result = getProjectTabBarItems([ + { id: "a", name: "Alpha", path: "D:/alpha", isActive: true, lastOpened: 1 }, + { id: "b", name: "Beta", path: "D:/beta", isActive: true, lastOpened: 2 }, + ]); + + expect(result.map((tab) => tab.name)).toEqual(["Alpha", "Beta"]); + expect(result.map((tab) => tab.isActive)).toEqual([true, false]); +}); +``` + +- [ ] **Step 2: Run the focused test and confirm the expected failure** + +Run from `windows/tauri`: + +```powershell +bun test src/features/window/utils/project-tab-bar-model.test.ts +``` + +Expected: the test fails because `project-tab-bar-model.ts` does not exist yet. + +- [ ] **Step 3: Implement the minimal model helper** + +Implement `getProjectTabBarItems(projectTabs)` by finding the first `isActive` tab and mapping the input in its original order, setting `isActive` only for that ID while preserving the other display fields. + +- [ ] **Step 4: Run the focused test and confirm it passes** + +Run the same Bun test. Expected: 1 test passes, 0 failures. + +### Task 2: Build The Project Tab Bar + +**Files:** +- Create: `windows/tauri/src/features/window/components/project-tab-bar.tsx` +- Create: `windows/tauri/src/features/window/components/project-tab-bar.test.ts` + +- [ ] **Step 1: Write the failing component contract test** + +Add a source-level contract test that requires the component to expose `role="tablist"`, `role="tab"`, `aria-selected`, `isSwitchingProject`, and `switchToProject`. This protects the accessibility and switching contract without introducing a new renderer test dependency. + +- [ ] **Step 2: Run the focused test and confirm the expected failure** + +Run: + +```powershell +bun test src/features/window/components/project-tab-bar.test.ts +``` + +Expected: the test fails because the component file does not exist yet. + +- [ ] **Step 3: Implement the component** + +Implement the component with these exact behaviors: + +```tsx +const projectTabs = useWorkspaceTabsStore.use.projectTabs(); +const switchToProject = useFileSystemStore((state) => state.switchToProject); +const isSwitchingProject = useFileSystemStore((state) => state.isSwitchingProject); +const projects = getProjectTabBarItems(projectTabs); + +if (projects.length === 0) return null; + +return ( +
+ {projects.map((project) => ( + + ))} +
+); +``` + +Use the existing `FolderOpenIcon`, semantic surface/border/selected tokens, fixed 30px tab height, horizontal overflow, visible focus styles, and truncated names. Do not add a native drag region or duplicate project-management actions. + +- [ ] **Step 4: Run the focused component contract test** + +Run the same Bun test. Expected: 1 test passes, 0 failures. + +### Task 3: Mount The Bar In The Workbench + +**Files:** +- Modify: `windows/tauri/src/features/layout/components/main-layout.tsx` + +- [ ] **Step 1: Add the import and mount point** + +Import `ProjectTabBar` from the window feature and render `` immediately after ``, before the root-folder conditional. The component itself returns `null` when no project is open, preserving the welcome screen layout. + +- [ ] **Step 2: Run focused tests and typecheck** + +Run: + +```powershell +bun test src/features/window/utils/project-tab-bar-model.test.ts src/features/window/components/project-tab-bar.test.ts +bun run typecheck +``` + +Expected: all focused tests pass and TypeScript exits with code 0. + +### Task 4: Verify The User Workflow + +**Files:** +- No additional source files. + +- [ ] **Step 1: Run lint and frontend build** + +```powershell +bunx vp lint src/features/layout/components/main-layout.tsx src/features/window/components/project-tab-bar.tsx src/features/window/components/project-tab-bar.test.ts src/features/window/utils/project-tab-bar-model.ts src/features/window/utils/project-tab-bar-model.test.ts +bun run build +git diff --check +``` + +Expected: all commands exit 0. Existing dependency warnings are acceptable if they do not introduce errors. + +- [ ] **Step 2: Rebuild and launch Windows Release** + +Stop the current preview process, run `.\scripts\build-windows.ps1 -Configuration Release` from the repository root, and launch `windows/tauri/src-tauri/target/x86_64-pc-windows-msvc/release/lithe-windows.exe`. + +- [ ] **Step 3: Verify the live tab interaction through CDP** + +Open two projects in the current window, assert a visible `[role="tablist"]` contains both project names, click the inactive `[role="tab"]`, and assert its `aria-selected` becomes `true` while the previous tab becomes `false`. Confirm the title-bar project label changes to the newly active project. + +- [ ] **Step 4: Run the final focused checks** + +```powershell +bun test src/features/window/utils/project-tab-bar-model.test.ts src/features/window/components/project-tab-bar.test.ts +bun run typecheck +git diff --check +``` + +Expected: all tests pass, typecheck passes, and no whitespace errors are reported. + +### Task 5: Review And Commit + +**Files:** +- Stage only the new project-tab-bar source/tests, `main-layout.tsx`, and the task plan/progress files. + +- [ ] **Step 1: Inspect the task diff and run an independent review** + +Check `git diff` and confirm unrelated pre-existing changes remain unstaged. Resolve any Critical or Important review findings before committing. + +- [ ] **Step 2: Create one focused implementation commit** + +```powershell +git add -- windows/tauri/src/features/layout/components/main-layout.tsx windows/tauri/src/features/window/components/project-tab-bar.tsx windows/tauri/src/features/window/components/project-tab-bar.test.ts windows/tauri/src/features/window/utils/project-tab-bar-model.ts windows/tauri/src/features/window/utils/project-tab-bar-model.test.ts .planning/2026-08-17-project-tab-bar/task_plan.md .planning/2026-08-17-project-tab-bar/findings.md .planning/2026-08-17-project-tab-bar/progress.md docs/superpowers/plans/2026-08-17-project-tab-bar.md +git commit -m "feat: add mac-style project tabs" +``` diff --git a/docs/superpowers/specs/2026-08-17-cross-platform-logging-requirements.md b/docs/superpowers/specs/2026-08-17-cross-platform-logging-requirements.md new file mode 100644 index 000000000..b688006ea --- /dev/null +++ b/docs/superpowers/specs/2026-08-17-cross-platform-logging-requirements.md @@ -0,0 +1,60 @@ +# Lithe macOS 日志目录设置需求 + +## 背景 + +macOS 设置界面没有告诉用户日志默认保存在哪里,也不能修改日志保存目录。 + +本需求只完善 macOS 日志目录的查看和配置,并将应用现有的字体注册和性能基线诊断写入该目录,不涉及 Windows 端或业务日志扩展。 + +## 目标 + +在设置中增加日志目录设置,让用户能够: + +- 查看系统默认日志目录; +- 查看当前选定的日志目录; +- 选择新的日志目录; +- 恢复默认日志目录; +- 复制完整目录路径。 + +## 目录行为 + +### 默认目录 + +默认目录是 macOS 用户日志目录下的 `Lithe` 目录。设置页面展示系统解析后的真实绝对路径,不能硬编码开发机路径。 + +### 自定义目录 + +用户通过系统目录选择器选择目录。选择成功后保存配置,应用诊断输出立即切换到该目录中的 `lithe.log`,应用重启后继续使用该选择。该设置不影响其他配置。 + +如果自定义目录无法创建或打开,应用清除该选择并恢复默认目录,界面不得继续把失败目录显示为当前日志目录。 + +“恢复默认目录”只清除自定义日志目录。“恢复全部默认设置”也应清除该配置。 + +## 界面要求 + +日志设置页面显示默认目录和当前选定目录,并提供“选择目录”“恢复默认”操作。 + +长路径可以换行或缩略显示,但必须支持复制完整路径。 + +## 不在范围内 + +本次不做: + +- 新建结构化日志协议; +- 新建结构化日志 writer、业务 sink 或导出逻辑; +- 新增文件轮换和 ownership manifest; +- 新增 Run、Build、Test、Debug、LSP、Terminal 生命周期日志; +- 新增应用、窗口、工作区或文件事件日志; +- 新增帧率采样或性能日志; +- 修改日志格式、日志级别或日志内容; +- 自动上传、云端查看或崩溃转储。 + +## 验收标准 + +1. 设置页显示真实默认目录和当前选定目录。 +2. 用户可以选择自定义目录,重启后配置仍然存在。 +3. 用户可以恢复默认目录。 +4. “恢复全部默认设置”会清除自定义日志目录。 +5. 用户可以复制完整路径。 +6. 现有字体注册和性能基线诊断写入当前目录的 `lithe.log`,切换目录后新输出写入新目录。 +7. 没有引入新的结构化日志协议、业务埋点或帧率监控。 diff --git a/docs/superpowers/specs/2026-08-17-project-tab-bar-design.md b/docs/superpowers/specs/2026-08-17-project-tab-bar-design.md new file mode 100644 index 000000000..d32ef35d8 --- /dev/null +++ b/docs/superpowers/specs/2026-08-17-project-tab-bar-design.md @@ -0,0 +1,28 @@ +# Mac-Style Project Tab Bar + +## Goal + +Show every project opened in the current Windows workbench in a compact tab bar below the title bar, matching the macOS workbench pattern. Clicking a tab activates that project immediately while the existing title-bar project menu remains the entry point for creating, opening, cloning, and selecting recent projects. + +## Design + +- Add a `ProjectTabBar` presentation component between `TitleBarWithSettings` and the workbench content in `MainLayout`. +- Read project order and active state from `useWorkspaceTabsStore`; use `useFileSystemStore.switchToProject` for activation. +- Render one semantic button per open project with its project badge/icon, name, active styling, and a full-path tooltip/accessible label. +- Use a horizontal overflow container so the bar remains stable when many projects are open. Do not resize the title bar or use the tab bar as a native drag region. +- Disable project buttons while `isSwitchingProject` is true, preserving the existing stale-switch protection. +- Hide the bar when no project is open, so the welcome screen keeps its current vertical layout. +- Preserve the existing `TitleProjectMenu` dropdown and all of its actions. + +## Accessibility And Visual Behavior + +- Use `role="tablist"` on the strip and `role="tab"` plus `aria-selected` on each project button. +- Keep a visible focus ring and selected background/border using existing semantic design tokens. +- Use the existing project badge/icon rules and Lucide-style folder icon; no new color palette or decorative artwork. +- Keep the tab height and spacing fixed to prevent layout shift, and use text truncation with a tooltip for long names. + +## Testing + +- Add a pure model helper test that preserves project order and exposes exactly one active tab. +- Add component-level source/behavior coverage for the tab button activation callback where the existing frontend test setup permits it. +- Run focused Bun tests, typecheck, lint, frontend build, and a live Windows CDP check that opens two projects and activates the second tab. diff --git a/rust/lithe-core/src/execution/configuration.rs b/rust/lithe-core/src/execution/configuration.rs index 7e71ebe8b..49da3aab5 100644 --- a/rust/lithe-core/src/execution/configuration.rs +++ b/rust/lithe-core/src/execution/configuration.rs @@ -22,6 +22,9 @@ const SIDECAR_VERSION: u32 = 1; /// Request to validate the layered configuration documents for a workspace. pub struct InspectRequest { pub root: String, + /// Host-owned local layer. When present, Core validates it instead of `.lithe/run/local.json`. + #[serde(default)] + pub local_document: Option, } #[derive(Debug, Deserialize)] @@ -42,6 +45,9 @@ pub struct ResolveRequest { pub root: String, #[serde(default)] pub toolchain_candidates: Vec, + /// Host-owned local layer. When present, Core uses it instead of `.lithe/run/local.json`. + #[serde(default)] + pub local_document: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -71,6 +77,21 @@ pub struct LaunchPlanRequest { pub class_path: Option, #[serde(default)] pub debug_port: Option, + /// Host-owned local layer. When present, Core uses it instead of `.lithe/run/local.json`. + #[serde(default)] + pub local_document: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Global machine toolchain stored at document level in the local layer. +pub struct ToolchainPaths { + #[serde(default)] + pub java_home_path: String, + #[serde(default)] + pub maven_executable_path: String, + #[serde(default)] + pub maven_java_home_path: String, } #[derive(Debug, Deserialize)] @@ -99,6 +120,13 @@ pub struct UpdateOptionsRequest { pub maven_executable_path: String, #[serde(default)] pub maven_java_home_path: String, + /// When present, `updateOptions` writes the document-level global toolchain + /// into the local layer instead of patching one configuration. + #[serde(default)] + pub toolchain: Option, + /// Host-owned local layer used when `scope` is `local` and when resolving first. + #[serde(default)] + pub local_document: Option, } #[derive(Debug, Deserialize)] @@ -318,6 +346,15 @@ pub fn inspect(request: InspectRequest) -> Result { "toolchains/local.json", "project.json", ] { + if relative == "run/local.json" { + if let Some(document) = request.local_document.as_ref() { + let mut migrated = document.clone(); + migrate_document_value(&mut migrated); + validate_version_value(&migrated)?; + configuration_ids(&migrated)?; + continue; + } + } if let Some(document) = read_document_value(&root, relative)? { if relative.starts_with("run/") { validate_version_value(&document)?; @@ -414,6 +451,11 @@ pub fn generate(request: GenerateRequest) -> Result { .filter(|value| value.is_spring_boot) .map(|value| (value.path.clone(), value.qualified_name.clone())) .collect::>(); + let main_class_sources = scanned + .main_classes + .iter() + .map(|value| (value.qualified_name.clone(), value.path.clone())) + .collect::>(); let configurations = scanned .configurations .into_iter() @@ -431,9 +473,27 @@ pub fn generate(request: GenerateRequest) -> Result { .map(|path| maven_module_path(maven_relative_path, path)) .unwrap_or_else(|| ".".to_string()); maven.insert("module".to_string(), json!(module_path)); - if let Some(main_class) = value.main_class { + if let Some(main_class) = value.main_class.as_ref() { maven.insert("mainClass".to_string(), json!(main_class)); } + let source_path = value + .main_class + .as_ref() + .and_then(|name| main_class_sources.get(name)) + .cloned(); + // A workspace without Maven still produces java.main entries. Binding + // project-maven there would force every plain Java class through mvn. + let uses_maven_toolchain = has_maven_project && provider != "java.current-file"; + let mut toolchains = BTreeMap::new(); + toolchains.insert("java".to_string(), "project-jdk".to_string()); + if uses_maven_toolchain { + toolchains.insert("maven".to_string(), "project-maven".to_string()); + } + let mut extensions = BTreeMap::new(); + extensions.insert("maven".to_string(), Value::Object(maven)); + if let Some(path) = source_path.as_ref() { + extensions.insert("java".to_string(), json!({ "source": path })); + } RunConfiguration { id, name: value.name, @@ -451,27 +511,14 @@ pub fn generate(request: GenerateRequest) -> Result { }, env: BTreeMap::new(), confidence: Confidence::Native, - toolchains: if provider == "java.current-file" { - [("java".to_string(), "project-jdk".to_string())] - .into_iter() - .collect() - } else { - [ - ("java".to_string(), "project-jdk".to_string()), - ("maven".to_string(), "project-maven".to_string()), - ] - .into_iter() - .collect() - }, + toolchains, debug: (provider != "java.main").then(|| DebugCapability { adapter: "jdwp".to_string(), }), members: Vec::new(), - extensions: [("maven".to_string(), Value::Object(maven))] - .into_iter() - .collect(), + extensions, disabled: false, - source: None, + source: source_path, } }) .collect::>(); @@ -765,8 +812,7 @@ pub fn resolve(request: ResolveRequest) -> Result { })?; let team = read_document_value(&root, "run/configurations.json")? .unwrap_or_else(|| json!({"version": VERSION, "configurations": []})); - let local = read_document_value(&root, "run/local.json")? - .unwrap_or_else(|| json!({"version": VERSION, "configurations": []})); + let local = local_layer_document(&root, request.local_document)?; let manifest = read_document_value(&root, "project.json")?; validate_version_value(&generated)?; validate_version_value(&team)?; @@ -789,6 +835,10 @@ pub fn resolve(request: ResolveRequest) -> Result { } } let mut configurations = merge_values(&generated, &team, &local)?; + let global_toolchain = local.get("toolchain").cloned(); + if let Some(toolchain) = global_toolchain.as_ref() { + apply_global_toolchain(&mut configurations, toolchain); + } for configuration in &mut configurations { validate_configuration(configuration)?; if configuration.disabled { @@ -853,17 +903,61 @@ pub fn resolve(request: ResolveRequest) -> Result { "version": VERSION, "configurations": configurations, "diagnostics": diagnostics, - "defaultRunConfiguration": default_run_configuration + "defaultRunConfiguration": default_run_configuration, + "toolchain": global_toolchain })) } +fn apply_global_toolchain(configurations: &mut [RunConfiguration], toolchain: &Value) { + let java_home = toolchain["java"]["homePath"].as_str().unwrap_or(""); + let maven_executable = toolchain["maven"]["executablePath"].as_str().unwrap_or(""); + let maven_java_home = toolchain["maven"]["javaHomePath"].as_str().unwrap_or(""); + for configuration in configurations { + if !configuration.toolchains.contains_key("java") + && !configuration.toolchains.contains_key("maven") + { + continue; + } + let java = configuration + .extensions + .entry("java".to_string()) + .or_insert_with(|| json!({})); + if let Some(object) = java.as_object_mut() { + object.insert("homePath".to_string(), json!(java_home)); + object.insert("mavenExecutablePath".to_string(), json!(maven_executable)); + object.insert("mavenJavaHomePath".to_string(), json!(maven_java_home)); + } + } +} + /// Persists editable configuration options in the requested ownership layer. pub fn update_options(request: UpdateOptionsRequest) -> Result { let root = existing_root(&request.root)?; + if let Some(toolchain) = request.toolchain { + if request.scope != "local" { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Toolchain paths can only be saved in the local layer", + )); + } + let mut document = local_layer_document(&root, request.local_document)?; + validate_version_value(&document)?; + document["toolchain"] = json!({ + "java": { "homePath": toolchain.java_home_path }, + "maven": { + "executablePath": toolchain.maven_executable_path, + "javaHomePath": toolchain.maven_java_home_path + } + }); + return Ok(json!({ + "document": serde_json::to_string_pretty(&document).expect("document should encode") + })); + } let relative = scope_document(&request.scope)?; let resolved = resolve(ResolveRequest { root: request.root.clone(), toolchain_candidates: Vec::new(), + local_document: request.local_document.clone(), })?; let provider = resolved["configurations"] .as_array() @@ -877,6 +971,12 @@ pub fn update_options(request: UpdateOptionsRequest) -> Result CoreError::new(ErrorCode::InvalidRequest, "Run configuration was not found") })?; let uses_maven_capability = is_maven_backed(provider); + let java_home_path = + normalize_scoped_toolchain_path(&root, &request.scope, &request.java_home_path)?; + let maven_executable_path = + normalize_scoped_toolchain_path(&root, &request.scope, &request.maven_executable_path)?; + let maven_java_home_path = + normalize_scoped_toolchain_path(&root, &request.scope, &request.maven_java_home_path)?; let working_directory = normalize_project_directory( &root, if request.working_directory.trim().is_empty() { @@ -886,8 +986,12 @@ pub fn update_options(request: UpdateOptionsRequest) -> Result }, false, )?; - let mut document = read_document_value(&root, relative)? - .unwrap_or_else(|| json!({"version": VERSION, "configurations": []})); + let mut document = if request.scope == "local" { + local_layer_document(&root, request.local_document)? + } else { + read_document_value(&root, relative)? + .unwrap_or_else(|| json!({"version": VERSION, "configurations": []})) + }; validate_version_value(&document)?; let configurations = document["configurations"] .as_array_mut() @@ -897,6 +1001,19 @@ pub fn update_options(request: UpdateOptionsRequest) -> Result "cwd": working_directory, "env": request.environment }); + let mut java_extension = serde_json::Map::new(); + if !java_home_path.is_empty() { + java_extension.insert("homePath".to_string(), json!(java_home_path)); + } + if !maven_executable_path.is_empty() { + java_extension.insert( + "mavenExecutablePath".to_string(), + json!(maven_executable_path), + ); + } + if !maven_java_home_path.is_empty() { + java_extension.insert("mavenJavaHomePath".to_string(), json!(maven_java_home_path)); + } if uses_maven_capability { patch["extensions"] = json!({ "maven": { @@ -904,16 +1021,10 @@ pub fn update_options(request: UpdateOptionsRequest) -> Result "programArguments": split_arguments(&request.arguments), "profiles": request.maven_profiles.into_iter().collect::>() }, - "java": { - "homePath": request.java_home_path, - "mavenExecutablePath": request.maven_executable_path, - "mavenJavaHomePath": request.maven_java_home_path - } - }); - } else if !request.java_home_path.is_empty() { - patch["extensions"] = json!({ - "java": { "homePath": request.java_home_path } + "java": java_extension }); + } else if !java_extension.is_empty() { + patch["extensions"] = json!({ "java": java_extension }); } else { patch["args"] = json!(split_arguments(&request.arguments)); } @@ -1056,6 +1167,7 @@ pub fn create_launch_plan(request: LaunchPlanRequest) -> Result Result Result Result Result Result<(), CoreEr Ok(()) } +fn local_layer_document(root: &Path, provided: Option) -> Result { + if let Some(mut document) = provided { + // A host-owned local layer may still carry the v1 shape, mirroring the + // migration applied to the on-disk document below. + migrate_document_value(&mut document); + validate_version_value(&document)?; + configuration_ids(&document)?; + return Ok(document); + } + Ok(read_document_value(root, "run/local.json")? + .unwrap_or_else(|| json!({"version": VERSION, "configurations": []}))) +} + fn scope_document(scope: &str) -> Result<&'static str, CoreError> { match scope { "local" => Ok("run/local.json"), @@ -1612,6 +1763,22 @@ fn scope_document(scope: &str) -> Result<&'static str, CoreError> { } } +fn normalize_scoped_toolchain_path( + root: &Path, + scope: &str, + value: &str, +) -> Result { + let value = value.trim(); + if value.is_empty() { + return Ok(String::new()); + } + if scope == "project" { + normalize_project_directory(root, value, true) + } else { + Ok(value.to_string()) + } +} + fn normalize_project_directory( root: &Path, value: &str, diff --git a/rust/lithe-core/src/git/mod.rs b/rust/lithe-core/src/git/mod.rs index 88a9a0d76..a17e28088 100644 --- a/rust/lithe-core/src/git/mod.rs +++ b/rust/lithe-core/src/git/mod.rs @@ -12,6 +12,8 @@ use crate::protocol::{ use serde::{Deserialize, Serialize}; use std::io::Read; use std::io::Write; +#[cfg(target_os = "windows")] +use std::os::windows::process::CommandExt; use std::path::{Path, PathBuf}; use std::process::Command; use std::thread; @@ -526,7 +528,7 @@ fn execute_git_with_options( disable_optional_locks: bool, ) -> Result { crate::protocol::cancellation::check()?; - let mut process = Command::new("git"); + let mut process = git_process(); process.args(arguments).current_dir(root); if disable_optional_locks { process.env("GIT_OPTIONAL_LOCKS", "0"); @@ -599,6 +601,21 @@ fn execute_git_with_options( }) } +fn git_process() -> Command { + let mut process = Command::new("git"); + #[cfg(target_os = "windows")] + process.creation_flags(git_process_creation_flags()); + process +} + +#[cfg(target_os = "windows")] +fn git_process_creation_flags() -> u32 { + // Git runs as an IDE background task; attaching a console can briefly open + // the user's default terminal whenever status or repository data refreshes. + const CREATE_NO_WINDOW: u32 = 0x08000000; + CREATE_NO_WINDOW +} + /// Builds a structured working-tree, staged, untracked, or commit diff. pub fn diff(request: GitDiffRequest) -> Result { if request.pathspecs.is_empty() || request.pathspecs.iter().any(|path| !is_safe_pathspec(path)) @@ -2752,7 +2769,7 @@ fn run_git(directory: &Path, arguments: &[&str]) -> Result Vec { texts .iter() diff --git a/rust/lithe-core/src/lsp/interface/process.rs b/rust/lithe-core/src/lsp/interface/process.rs index 2f59aa1dc..d557d1305 100644 --- a/rust/lithe-core/src/lsp/interface/process.rs +++ b/rust/lithe-core/src/lsp/interface/process.rs @@ -9,10 +9,15 @@ use crate::protocol::{CoreError, ErrorCode}; use std::collections::BTreeMap; use std::io::{Read, Write}; +#[cfg(target_os = "windows")] +use std::path::Path; use std::path::PathBuf; use std::process::{Child, ChildStdin, Command, Stdio}; use std::sync::{Arc, Mutex}; +#[cfg(target_os = "windows")] +use std::os::windows::process::CommandExt; + /// Everything needed to start a language server, after provider adaptation has /// already rewritten the arguments. pub struct LspProcessSpec { @@ -67,14 +72,13 @@ pub struct SystemProcessLauncher; impl LspProcessLauncher for SystemProcessLauncher { fn launch(&self, spec: LspProcessSpec) -> Result { - let mut command = Command::new(&spec.executable); + let mut command = language_server_command(&spec); command - .args(&spec.arguments) .current_dir(&spec.working_directory) - .envs(&spec.environment) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()); + apply_language_server_creation_flags(&mut command); let mut child = command.spawn().map_err(|error| { CoreError::new( ErrorCode::ProcessStartFailed, @@ -102,6 +106,98 @@ impl LspProcessLauncher for SystemProcessLauncher { } } +fn language_server_command(spec: &LspProcessSpec) -> Command { + #[cfg(target_os = "windows")] + if is_windows_batch_script(&spec.executable) { + let mut command = Command::new("cmd.exe"); + command.envs(&spec.environment); + // Environment expansion is single-pass, so quoted values reach the batch + // file without cmd.exe interpreting path metacharacters or percent pairs. + command.env( + WINDOWS_BATCH_EXECUTABLE_ENV, + windows_batch_env_value(&spec.executable.to_string_lossy()), + ); + for (index, argument) in spec.arguments.iter().enumerate() { + command.env( + windows_batch_argument_env(index), + windows_batch_env_value(argument), + ); + } + command.raw_arg("/D"); + command.raw_arg("/S"); + command.raw_arg("/V:OFF"); + command.raw_arg("/C"); + command.raw_arg(windows_batch_command_line(spec.arguments.len())); + return command; + } + + let mut command = Command::new(&spec.executable); + command.args(&spec.arguments).envs(&spec.environment); + command +} + +#[cfg(target_os = "windows")] +fn is_windows_batch_script(executable: &Path) -> bool { + matches!( + executable.extension().and_then(|extension| extension.to_str()), + Some(extension) if extension.eq_ignore_ascii_case("bat") || extension.eq_ignore_ascii_case("cmd") + ) +} + +#[cfg(target_os = "windows")] +const WINDOWS_BATCH_EXECUTABLE_ENV: &str = "LITHE_LSP_BATCH_EXECUTABLE"; + +#[cfg(target_os = "windows")] +fn windows_batch_argument_env(index: usize) -> String { + format!("LITHE_LSP_BATCH_ARGUMENT_{index}") +} + +#[cfg(target_os = "windows")] +fn windows_batch_command_line(argument_count: usize) -> String { + let mut command_line = format!("\"%{WINDOWS_BATCH_EXECUTABLE_ENV}%\""); + for index in 0..argument_count { + command_line.push_str(&format!(" \"%{}%\"", windows_batch_argument_env(index))); + } + format!("\"{command_line}\"") +} + +#[cfg(target_os = "windows")] +fn windows_batch_env_value(value: &str) -> String { + let mut escaped = String::new(); + let mut backslashes = 0; + for character in value.chars() { + match character { + '\\' => backslashes += 1, + '"' => { + escaped.push_str(&"\\".repeat(backslashes * 2 + 1)); + escaped.push('"'); + backslashes = 0; + } + _ => { + escaped.push_str(&"\\".repeat(backslashes)); + escaped.push(character); + backslashes = 0; + } + } + } + escaped.push_str(&"\\".repeat(backslashes * 2)); + escaped +} + +fn apply_language_server_creation_flags(command: &mut Command) { + #[cfg(target_os = "windows")] + command.creation_flags(language_server_process_creation_flags()); + + #[cfg(not(target_os = "windows"))] + let _ = command; +} + +#[cfg(target_os = "windows")] +fn language_server_process_creation_flags() -> u32 { + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + CREATE_NO_WINDOW +} + struct SystemProcess { input: Mutex>, child: Mutex, @@ -166,3 +262,91 @@ fn missing_stream(stream: &str) -> CoreError { ) .with_details(stream) } + +#[cfg(all(test, target_os = "windows"))] +mod tests { + use super::{ + language_server_command, windows_batch_command_line, LspProcessLauncher, LspProcessSpec, + SystemProcessLauncher, WINDOWS_BATCH_EXECUTABLE_ENV, + }; + use std::collections::BTreeMap; + use std::ffi::OsStr; + use std::fs; + use std::io::Read; + use std::path::PathBuf; + use std::time::{SystemTime, UNIX_EPOCH}; + + #[test] + fn background_language_servers_do_not_create_windows_console() { + assert_eq!(super::language_server_process_creation_flags(), 0x0800_0000); + } + + #[test] + fn batch_language_server_uses_cmd_exe() { + let spec = LspProcessSpec { + executable: PathBuf::from(r"C:\Program Files\Lithe\jdtls.bat"), + arguments: vec!["-data".to_string(), r"C:\workspace data".to_string()], + working_directory: PathBuf::from(r"C:\workspace"), + environment: BTreeMap::new(), + }; + + let command = language_server_command(&spec); + + assert_eq!(command.get_program(), OsStr::new("cmd.exe")); + assert_eq!( + windows_batch_command_line(spec.arguments.len()), + r#"""%LITHE_LSP_BATCH_EXECUTABLE%" "%LITHE_LSP_BATCH_ARGUMENT_0%" "%LITHE_LSP_BATCH_ARGUMENT_1%"""# + ); + assert!(command.get_envs().any(|(name, value)| { + name == OsStr::new(WINDOWS_BATCH_EXECUTABLE_ENV) + && value == Some(spec.executable.as_os_str()) + })); + } + + #[test] + fn batch_language_server_preserves_spaced_paths_and_shell_metacharacters() { + let stamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock") + .as_nanos(); + let root = std::env::temp_dir().join(format!("lithe lsp batch {stamp}")); + fs::create_dir_all(&root).expect("temp directory"); + let executable = root.join("scripted server.cmd"); + let argument_writer = root.join("write-argument.ps1"); + fs::write( + &argument_writer, + "[Console]::Out.Write(($args -join [Environment]::NewLine))\r\n", + ) + .expect("PowerShell argument writer"); + fs::write( + &executable, + "@echo off\r\npowershell.exe -NoLogo -NoProfile -File \"%~dp0write-argument.ps1\" %*\r\n", + ) + .expect("batch script"); + let arguments = vec![ + "workspace&echo_injected".to_string(), + "percent%PATH%value".to_string(), + "bang!value".to_string(), + "caret^value".to_string(), + "paren(value)".to_string(), + "quoted\"value".to_string(), + r"C:\workspace data\".to_string(), + ]; + let spec = LspProcessSpec { + executable, + arguments: arguments.clone(), + working_directory: root.clone(), + environment: BTreeMap::new(), + }; + + let mut streams = SystemProcessLauncher + .launch(spec) + .expect("launch batch script"); + streams.handle.close_input(); + let mut output = String::new(); + streams.output.read_to_string(&mut output).expect("stdout"); + + assert_eq!(output, arguments.join("\r\n")); + fs::remove_dir_all(root).ok(); + } +} diff --git a/rust/lithe-core/src/tests/run_configuration.rs b/rust/lithe-core/src/tests/run_configuration.rs index 2ccaf38ae..0f0e6ba00 100644 --- a/rust/lithe-core/src/tests/run_configuration.rs +++ b/rust/lithe-core/src/tests/run_configuration.rs @@ -423,6 +423,247 @@ fn ordinary_java_main_uses_an_application_launch_plan() { fs::remove_dir_all(root).unwrap(); } +#[test] +fn plain_java_main_uses_the_jdk_without_maven() { + let root = temporary_root("run-config-plain-java-main"); + let source = "src/com/example/WorkerMain.java"; + fs::create_dir_all(root.join("src/com/example")).unwrap(); + fs::write( + root.join(source), + "package com.example; class WorkerMain { public static void main(String[] args) {} }", + ) + .unwrap(); + + let generated_response: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id": "generate-plain-java-main", + "command": "runConfig.generate", + "payload": {"root": root, "paths": [source], "modulePaths": []} + }) + .to_string(), + )) + .unwrap(); + let generated = &generated_response["data"]["generated"]; + let java_main = generated["configurations"] + .as_array() + .unwrap() + .iter() + .find(|value| value["provider"] == "java.main") + .unwrap(); + assert_eq!(java_main["toolchains"]["java"], "project-jdk"); + assert!(java_main["toolchains"]["maven"].is_null()); + assert_eq!(java_main["source"], source); + + fs::create_dir_all(root.join(".lithe/run")).unwrap(); + fs::write( + root.join(".lithe/run/generated.json"), + serde_json::to_string(generated).unwrap(), + ) + .unwrap(); + let plan: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id": "plan-plain-java-main", + "command": "runConfig.createLaunchPlan", + "payload": { + "root": root, + "configurationId": "java-main:com.example.WorkerMain" + } + }) + .to_string(), + )) + .unwrap(); + assert_eq!(plan["ok"], true, "{plan}"); + assert_eq!(plan["data"]["executable"]["toolchain"], "project-jdk"); + assert_eq!(plan["data"]["arguments"], serde_json::json!([source])); + + fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn resolve_prefers_a_host_provided_local_document() { + let root = temporary_root("run-config-host-local"); + fs::create_dir_all(root.join(".lithe/run")).unwrap(); + fs::write( + root.join(".lithe/run/generated.json"), + r#"{"version":2,"configurations":[{"id":"current-file","name":"Current File","provider":"java.current-file","execution":"application","toolchains":{"java":"project-jdk"}}]}"#, + ) + .unwrap(); + fs::write( + root.join(".lithe/run/local.json"), + r#"{"version":2,"configurations":[{"id":"current-file","name":"Project Local","provider":"java.current-file","cwd":"."}]}"#, + ) + .unwrap(); + + let resolve: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id": "resolve-host-local", + "command": "runConfig.resolve", + "payload": { + "root": root, + "localDocument": { + "version": 2, + "configurations": [{ + "id": "current-file", + "name": "This PC", + "provider": "java.current-file", + "cwd": "." + }] + } + } + }) + .to_string(), + )) + .unwrap(); + assert_eq!(resolve["ok"], true, "{resolve}"); + let current = resolve["data"]["configurations"] + .as_array() + .unwrap() + .iter() + .find(|value| value["id"] == "current-file") + .unwrap(); + assert_eq!(current["name"], "This PC"); + + // A legacy v1 local layer supplied by the host migrates like the on-disk + // document, so an old `.lithe/run/local.json` read by the adapter still works. + let legacy: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id": "resolve-host-local-v1", + "command": "runConfig.resolve", + "payload": { + "root": root, + "localDocument": { + "version": 1, + "configurations": [{ + "id": "current-file", + "name": "Legacy This PC", + "type": "java.current-file", + "programArguments": ["--dev"] + }] + } + } + }) + .to_string(), + )) + .unwrap(); + assert_eq!(legacy["ok"], true, "{legacy}"); + let legacy_current = legacy["data"]["configurations"] + .as_array() + .unwrap() + .iter() + .find(|value| value["id"] == "current-file") + .unwrap(); + assert_eq!(legacy_current["name"], "Legacy This PC"); + + fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn resolve_applies_a_global_toolchain_to_every_configuration() { + let root = temporary_root("run-config-global-toolchain"); + fs::create_dir_all(root.join(".lithe/run")).unwrap(); + fs::write( + root.join(".lithe/run/generated.json"), + r#"{"version":2,"configurations":[ + {"id":"spring","name":"Spring","provider":"spring-boot.maven","execution":"service","toolchains":{"java":"project-jdk","maven":"project-maven"},"extensions":{"maven":{"module":"."}}}, + {"id":"plain","name":"Plain","provider":"java.main","execution":"application","toolchains":{"java":"project-jdk"},"extensions":{"java":{"source":"src/App.java"}}} + ]}"#, + ) + .unwrap(); + fs::write( + root.join(".lithe/run/local.json"), + r#"{"version":2,"toolchain":{"java":{"homePath":"C:/custom-jdk"},"maven":{"executablePath":"C:/mvn.cmd","javaHomePath":"C:/maven-jdk"}},"configurations":[]}"#, + ) + .unwrap(); + + let resolved: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id": "resolve-global-toolchain", + "command": "runConfig.resolve", + "payload": {"root": root} + }) + .to_string(), + )) + .unwrap(); + assert_eq!(resolved["ok"], true, "{resolved}"); + assert_eq!( + resolved["data"]["toolchain"]["java"]["homePath"], + "C:/custom-jdk" + ); + let plain = resolved["data"]["configurations"] + .as_array() + .unwrap() + .iter() + .find(|value| value["id"] == "plain") + .unwrap(); + assert_eq!(plain["extensions"]["java"]["homePath"], "C:/custom-jdk"); + assert_eq!( + plain["extensions"]["java"]["mavenExecutablePath"], + "C:/mvn.cmd" + ); + // The global toolchain replaces runtime paths but never the source path. + assert_eq!(plain["extensions"]["java"]["source"], "src/App.java"); + + fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn global_toolchain_updates_only_in_the_local_layer() { + let root = temporary_root("run-config-toolchain-update"); + fs::create_dir_all(root.join(".lithe/run")).unwrap(); + fs::write( + root.join(".lithe/run/local.json"), + r#"{"version":2,"configurations":[]}"#, + ) + .unwrap(); + + let updated: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id": "update-global-toolchain", + "command": "runConfig.updateOptions", + "payload": { + "root": root, + "scope": "local", + "configurationId": "unused", + "toolchain": { + "javaHomePath": "C:/jdk-21", + "mavenExecutablePath": "C:/apache-maven/bin/mvn.cmd", + "mavenJavaHomePath": "C:/jdk-17" + } + } + }) + .to_string(), + )) + .unwrap(); + assert_eq!(updated["ok"], true, "{updated}"); + let document: Value = + serde_json::from_str(updated["data"]["document"].as_str().unwrap()).unwrap(); + assert_eq!(document["toolchain"]["java"]["homePath"], "C:/jdk-21"); + assert_eq!( + document["toolchain"]["maven"]["executablePath"], + "C:/apache-maven/bin/mvn.cmd" + ); + assert_eq!(document["toolchain"]["maven"]["javaHomePath"], "C:/jdk-17"); + + // Project scope must never accept toolchain paths. + let rejected: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id": "update-global-toolchain-project", + "command": "runConfig.updateOptions", + "payload": { + "root": root, + "scope": "project", + "configurationId": "unused", + "toolchain": {"javaHomePath": "C:/jdk-21"} + } + }) + .to_string(), + )) + .unwrap(); + assert_eq!(rejected["ok"], false, "{rejected}"); + + fs::remove_dir_all(root).unwrap(); +} + #[test] fn run_configuration_inspect_reports_malformed_and_unsupported_documents() { let root = temporary_root("run-config-errors"); @@ -557,6 +798,66 @@ fn run_configuration_mutations_are_shared_and_validated() { fs::remove_dir_all(root).unwrap(); } +#[test] +fn project_scoped_toolchain_paths_are_relative_and_stay_inside_the_project() { + let root = temporary_root("run-config-project-toolchains"); + let outside = temporary_root("run-config-outside-toolchain"); + fs::create_dir_all(root.join(".lithe/run")).unwrap(); + fs::create_dir_all(root.join("toolchains/jdk")).unwrap(); + fs::create_dir_all(root.join("toolchains/maven/bin")).unwrap(); + fs::create_dir_all(root.join("toolchains/maven-jdk")).unwrap(); + fs::create_dir_all(&outside).unwrap(); + fs::write(root.join("toolchains/maven/bin/mvn"), "#!/bin/sh\n").unwrap(); + fs::write( + root.join(".lithe/run/generated.json"), + r#"{"version":2,"configurations":[{"id":"spring","name":"Spring","provider":"spring-boot.maven","execution":"service","toolchains":{"java":"project-jdk","maven":"project-maven"},"extensions":{"maven":{"module":"."}}}]}"#, + ) + .unwrap(); + + let updated: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id": "project-toolchains", + "command": "runConfig.updateOptions", + "payload": { + "root": root, + "scope": "project", + "configurationId": "spring", + "javaHomePath": root.join("toolchains/jdk"), + "mavenExecutablePath": root.join("toolchains/maven/bin/mvn"), + "mavenJavaHomePath": root.join("toolchains/maven-jdk") + } + }) + .to_string(), + )) + .unwrap(); + assert_eq!(updated["ok"], true, "{updated}"); + let document: Value = + serde_json::from_str(updated["data"]["document"].as_str().unwrap()).unwrap(); + let java = &document["configurations"][0]["extensions"]["java"]; + assert_eq!(java["homePath"], "toolchains/jdk"); + assert_eq!(java["mavenExecutablePath"], "toolchains/maven/bin/mvn"); + assert_eq!(java["mavenJavaHomePath"], "toolchains/maven-jdk"); + + let rejected: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id": "outside-project-toolchain", + "command": "runConfig.updateOptions", + "payload": { + "root": root, + "scope": "project", + "configurationId": "spring", + "javaHomePath": outside + } + }) + .to_string(), + )) + .unwrap(); + assert_eq!(rejected["ok"], false, "{rejected}"); + + fs::remove_dir_all(root).unwrap(); + fs::remove_dir_all(outside).unwrap(); +} + #[test] fn run_configuration_generation_detects_declared_toolchain_versions() { let root = temporary_root("run-config-toolchains"); diff --git a/scripts/build-windows.ps1 b/scripts/build-windows.ps1 index b898ac77d..b427cddd3 100644 --- a/scripts/build-windows.ps1 +++ b/scripts/build-windows.ps1 @@ -8,6 +8,9 @@ param( $ErrorActionPreference = "Stop" $root = Split-Path -Parent $PSScriptRoot $windowsApp = Join-Path $root "windows/tauri" +if ($Configuration -eq "Release") { + & (Join-Path $root "scripts/prepare-jdtls.ps1") | Out-Null +} Set-Location $windowsApp if ($null -eq (Get-Command bun -ErrorAction SilentlyContinue)) { @@ -29,7 +32,11 @@ $tauriArgs = @( "--config", "src-tauri/tauri.windows.conf.json", "--target", $RustTarget ) -if ($Configuration -eq "Debug") { $tauriArgs += "--debug" } +if ($Configuration -eq "Debug") { + $tauriArgs += "--debug" +} else { + $tauriArgs += @("--config", "src-tauri/tauri.jdtls.conf.json") +} & bunx @tauriArgs if ($LASTEXITCODE -ne 0) { throw "Windows Tauri build failed" } diff --git a/scripts/package-app.sh b/scripts/package-app.sh index 77934041b..385a95242 100755 --- a/scripts/package-app.sh +++ b/scripts/package-app.sh @@ -20,6 +20,7 @@ case "$ARCH" in esac cd "$ROOT_DIR" +JDTLS_ROOT=$("$ROOT_DIR/scripts/prepare-jdtls.sh") if [[ "$ARCH" == "universal" ]]; then scripts/build-macos.sh --configuration release --triple "$ARM64_TRIPLE" scripts/build-macos.sh --configuration release --triple "$X86_64_TRIPLE" @@ -89,6 +90,8 @@ if [[ ! -d "$resource_bundle" ]]; then exit 1 fi cp -R "$resource_bundle" "$APP_DIR/Contents/Resources/Lithe_Lithe.bundle" +mkdir -p "$APP_DIR/Contents/Resources/LanguageServers" +cp -R "$JDTLS_ROOT" "$APP_DIR/Contents/Resources/LanguageServers/jdtls" OFFICIAL_PLUGIN_DESTINATION="$APP_DIR/Contents/Resources/OfficialPlugins" mkdir -p "$OFFICIAL_PLUGIN_DESTINATION" @@ -132,6 +135,7 @@ cp "$INFO_PLIST" "$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" +cp -R "$ROOT_DIR/Resources/Fonts" "$APP_DIR/Contents/Resources/Fonts" for localization in en.lproj zh-Hans.lproj; do if [[ -d "$ROOT_DIR/Resources/$localization" ]]; then cp -R "$ROOT_DIR/Resources/$localization" "$APP_DIR/Contents/Resources/$localization" diff --git a/scripts/package-windows.ps1 b/scripts/package-windows.ps1 index 601bd8a2a..a45d43ea9 100644 --- a/scripts/package-windows.ps1 +++ b/scripts/package-windows.ps1 @@ -15,6 +15,7 @@ $windowsApp = Join-Path $root "windows/tauri" $output = Join-Path $root $OutputDirectory $versionConfig = Join-Path $env:RUNNER_TEMP "lithe-tauri-version.json" +& (Join-Path $root "scripts/prepare-jdtls.ps1") | Out-Null @{ version = $Version } | ConvertTo-Json | Set-Content -Encoding utf8 $versionConfig Set-Location $windowsApp & bun install --frozen-lockfile @@ -23,6 +24,7 @@ if ($LASTEXITCODE -ne 0) { throw "Windows frontend dependency installation faile $tauriArgs = @( "tauri", "build", "--config", "src-tauri/tauri.windows.conf.json", + "--config", "src-tauri/tauri.jdtls.conf.json", "--config", $versionConfig, "--bundles", "nsis" ) diff --git a/scripts/prepare-jdtls.ps1 b/scripts/prepare-jdtls.ps1 new file mode 100644 index 000000000..820382e69 --- /dev/null +++ b/scripts/prepare-jdtls.ps1 @@ -0,0 +1,125 @@ +[CmdletBinding()] +param( + [string]$OutputDirectory = "" +) + +$ErrorActionPreference = "Stop" +$root = Split-Path -Parent $PSScriptRoot +$manifestPath = Join-Path $root "third_party/jdtls/manifest.json" +$manifest = Get-Content -Raw -LiteralPath $manifestPath | ConvertFrom-Json +$usesExistingRoot = [string]::IsNullOrWhiteSpace($OutputDirectory) -and + -not [string]::IsNullOrWhiteSpace($env:LITHE_JDTLS_ROOT) +$requestedOutput = if ([string]::IsNullOrWhiteSpace($OutputDirectory)) { + if ($usesExistingRoot) { $env:LITHE_JDTLS_ROOT } else { Join-Path $root ".artifacts/jdtls" } +} else { + $OutputDirectory +} +$output = [System.IO.Path]::GetFullPath($requestedOutput) +$artifactsRoot = [System.IO.Path]::GetFullPath((Join-Path $root ".artifacts")) +$artifactsPrefix = $artifactsRoot.TrimEnd([System.IO.Path]::DirectorySeparatorChar) + [System.IO.Path]::DirectorySeparatorChar +if (-not $usesExistingRoot -and + -not $output.StartsWith($artifactsPrefix, [System.StringComparison]::OrdinalIgnoreCase)) { + throw "JDTLS output must be inside the repository .artifacts directory: $output" +} +$cache = Join-Path $root ".artifacts/jdtls-downloads" +$archiveUsesOverride = -not [string]::IsNullOrWhiteSpace($env:LITHE_JDTLS_ARCHIVE) +$archiveHash = $manifest.archiveSHA256.ToLowerInvariant() +$licenseHash = $manifest.licenseSHA256.ToLowerInvariant() +$safeVersion = ([string]$manifest.version) -replace '[^A-Za-z0-9._-]', '_' +$archive = if ($archiveUsesOverride) { + $env:LITHE_JDTLS_ARCHIVE +} else { + Join-Path $cache "jdtls-$safeVersion-$archiveHash.tar.gz" +} +$license = Join-Path $cache "EPL-2.0-$licenseHash.txt" + +function Get-FileSHA256 { + param([Parameter(Mandatory)][string]$Path) + + (Get-FileHash -Algorithm SHA256 -LiteralPath $Path).Hash.ToLowerInvariant() +} + +function Get-VerifiedDownload { + param( + [Parameter(Mandatory)][string]$Uri, + [Parameter(Mandatory)][string]$ExpectedSHA256, + [Parameter(Mandatory)][string]$Destination, + [Parameter(Mandatory)][string]$Description + ) + + if (Test-Path -LiteralPath $Destination -PathType Leaf) { + $actualHash = Get-FileSHA256 -Path $Destination + if ($actualHash -eq $ExpectedSHA256) { return } + Write-Warning "$Description cache checksum mismatch; removing it before retrying the download" + Remove-Item -Force -LiteralPath $Destination + } + + $temporaryPath = "$Destination.download-$PID" + try { + if (Test-Path -LiteralPath $temporaryPath) { Remove-Item -Force -LiteralPath $temporaryPath } + Invoke-WebRequest -Uri $Uri -OutFile $temporaryPath + $actualHash = Get-FileSHA256 -Path $temporaryPath + if ($actualHash -ne $ExpectedSHA256) { + throw "$Description checksum mismatch: expected $ExpectedSHA256, got $actualHash" + } + Move-Item -Force -LiteralPath $temporaryPath -Destination $Destination + } finally { + if (Test-Path -LiteralPath $temporaryPath) { Remove-Item -Force -LiteralPath $temporaryPath } + } +} + +function Assert-JdtlsOutput { + if (-not (Test-Path -LiteralPath (Join-Path $output "plugins") -PathType Container)) { throw "JDTLS plugins directory is missing: $output" } + if (-not (Test-Path -LiteralPath (Join-Path $output "config_win") -PathType Container)) { throw "JDTLS Windows configuration is missing: $output" } + if (-not (Test-Path -LiteralPath (Join-Path $output "bin/jdtls.ps1") -PathType Leaf)) { throw "JDTLS PowerShell launcher is missing: $output" } + if (-not (Test-Path -LiteralPath (Join-Path $output "bin/jdtls.bat") -PathType Leaf)) { throw "JDTLS batch launcher is missing: $output" } +} + +if ($usesExistingRoot) { + Assert-JdtlsOutput + Write-Output $output + exit 0 +} + +New-Item -ItemType Directory -Force -Path $cache | Out-Null +if ($archiveUsesOverride) { + if (-not (Test-Path -LiteralPath $archive -PathType Leaf)) { throw "JDTLS archive was not found: $archive" } + $actualArchiveHash = Get-FileSHA256 -Path $archive + if ($actualArchiveHash -ne $archiveHash) { throw "JDTLS archive checksum mismatch: expected $archiveHash, got $actualArchiveHash" } +} else { + Get-VerifiedDownload -Uri $manifest.archiveURL -ExpectedSHA256 $archiveHash -Destination $archive -Description "JDTLS archive" +} +Get-VerifiedDownload -Uri $manifest.licenseURL -ExpectedSHA256 $licenseHash -Destination $license -Description "EPL-2.0 license" + +if (Test-Path -LiteralPath $output) { Remove-Item -Recurse -Force -LiteralPath $output } +New-Item -ItemType Directory -Force -Path $output | Out-Null +tar.exe -xzf $archive -C $output +Copy-Item -LiteralPath $license -Destination (Join-Path $output "LICENSE-EPL-2.0.txt") -Force + +$windowsLauncher = @' +$ErrorActionPreference = "Stop" +$javaExecutable = if ($env:JAVA_HOME) { Join-Path $env:JAVA_HOME "bin\java.exe" } else { "java" } +$jvmArguments = [System.Collections.Generic.List[string]]::new() +$jvmArguments.Add("--add-modules=ALL-SYSTEM") +$jvmArguments.Add("--add-opens=java.base/java.util=ALL-UNNAMED") +$jvmArguments.Add("--add-opens=java.base/java.lang=ALL-UNNAMED") +$serverArguments = [System.Collections.Generic.List[string]]::new() +for ($index = 0; $index -lt $args.Count; $index++) { + $argument = [string]$args[$index] + if ($argument -eq "--java-executable") { if ($index + 1 -ge $args.Count) { throw "--java-executable requires a path" }; $javaExecutable = [string]$args[++$index] } + elseif ($argument.StartsWith("--jvm-arg=")) { $jvmArguments.Add($argument.Substring("--jvm-arg=".Length)) } + elseif ($argument -eq "--jvm-arg") { if ($index + 1 -ge $args.Count) { throw "--jvm-arg requires a value" }; $jvmArguments.Add([string]$args[++$index]) } + else { $serverArguments.Add($argument) } +} +$launcherJar = Get-ChildItem -LiteralPath (Join-Path $PSScriptRoot "..\plugins") -Filter "org.eclipse.equinox.launcher_*.jar" | Sort-Object Name | Select-Object -First 1 +if ($null -eq $launcherJar) { throw "JDTLS Equinox launcher was not found" } +$configuration = Join-Path $PSScriptRoot "..\config_win" +& $javaExecutable @jvmArguments "-Declipse.application=org.eclipse.jdt.ls.core.id1" "-Declipse.product=org.eclipse.jdt.ls.core.product" "-Dosgi.bundles.defaultStartLevel=4" "-Dlog.protocol=true" "-Dlog.level=ALL" "-jar" $launcherJar.FullName "-configuration" $configuration @serverArguments +exit $LASTEXITCODE +'@ +Set-Content -LiteralPath (Join-Path $output "bin/jdtls.ps1") -Value $windowsLauncher -Encoding ascii + +$batchLauncher = "@echo off`r`npowershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File `"%~dp0jdtls.ps1`" %*`r`nexit /b %ERRORLEVEL%`r`n" +Set-Content -LiteralPath (Join-Path $output "bin/jdtls.bat") -Value $batchLauncher -Encoding ascii +Assert-JdtlsOutput +Write-Output $output diff --git a/scripts/prepare-jdtls.sh b/scripts/prepare-jdtls.sh new file mode 100755 index 000000000..b82133c60 --- /dev/null +++ b/scripts/prepare-jdtls.sh @@ -0,0 +1,200 @@ +#!/bin/zsh + +set -euo pipefail + +ROOT_DIR="${0:A:h:h}" +MANIFEST="$ROOT_DIR/third_party/jdtls/manifest.json" +OUTPUT_DIR="${LITHE_JDTLS_ROOT:-$ROOT_DIR/.artifacts/jdtls}" +CACHE_DIR="$ROOT_DIR/.artifacts/jdtls-downloads" + +manifest_value() { + /usr/bin/plutil -extract "$1" raw -o - "$MANIFEST" +} + +archive_url="$(manifest_value archiveURL)" +archive_sha256="$(manifest_value archiveSHA256)" +license_url="$(manifest_value licenseURL)" +license_sha256="$(manifest_value licenseSHA256)" +jdtls_version="$(manifest_value version)" +archive_path="${LITHE_JDTLS_ARCHIVE:-$CACHE_DIR/jdtls-$jdtls_version-$archive_sha256.tar.gz}" +license_path="$CACHE_DIR/EPL-2.0-$license_sha256.txt" + +file_sha256() { + shasum -a 256 "$1" | awk '{print tolower($1)}' +} + +download_verified_file() { + local url="$1" + local expected_sha256="$2" + local destination="$3" + local description="$4" + local actual_sha256 + local temporary_path="$destination.download.$$" + + if [[ -f "$destination" ]]; then + actual_sha256="$(file_sha256 "$destination")" + if [[ "$actual_sha256" == "$expected_sha256" ]]; then + return 0 + fi + print -u2 -- "$description cache checksum mismatch; removing it before retrying the download" + rm -f -- "$destination" + fi + + rm -f -- "$temporary_path" + if ! curl --fail --location --retry 3 --output "$temporary_path" "$url"; then + rm -f -- "$temporary_path" + return 1 + fi + actual_sha256="$(file_sha256 "$temporary_path")" + if [[ "$actual_sha256" != "$expected_sha256" ]]; then + print -u2 -- "$description checksum mismatch: expected $expected_sha256, got $actual_sha256" + rm -f -- "$temporary_path" + return 1 + fi + mv -f -- "$temporary_path" "$destination" +} + +validate_output() { + [[ -d "$OUTPUT_DIR/plugins" ]] || { print -u2 -- "JDTLS plugins directory is missing: $OUTPUT_DIR"; exit 1; } + [[ -d "$OUTPUT_DIR/config_mac" ]] || { print -u2 -- "JDTLS macOS configuration is missing: $OUTPUT_DIR"; exit 1; } + [[ -d "$OUTPUT_DIR/config_win" ]] || { print -u2 -- "JDTLS Windows configuration is missing: $OUTPUT_DIR"; exit 1; } + [[ -x "$OUTPUT_DIR/bin/jdtls" ]] || { print -u2 -- "JDTLS launcher is missing: $OUTPUT_DIR/bin/jdtls"; exit 1; } + [[ -f "$OUTPUT_DIR/bin/jdtls.ps1" ]] || { print -u2 -- "JDTLS Windows launcher is missing: $OUTPUT_DIR"; exit 1; } +} + +if [[ -n "${LITHE_JDTLS_ROOT:-}" ]]; then + validate_output + print -r -- "$OUTPUT_DIR" + exit 0 +fi + +mkdir -p "$CACHE_DIR" +if [[ -n "${LITHE_JDTLS_ARCHIVE:-}" ]]; then + [[ -f "$archive_path" ]] || { print -u2 -- "JDTLS archive was not found: $archive_path"; exit 1; } + actual_archive_sha256="$(file_sha256 "$archive_path")" + if [[ "$actual_archive_sha256" != "$archive_sha256" ]]; then + print -u2 -- "JDTLS archive checksum mismatch: expected $archive_sha256, got $actual_archive_sha256" + exit 1 + fi +else + download_verified_file "$archive_url" "$archive_sha256" "$archive_path" "JDTLS archive" +fi +download_verified_file "$license_url" "$license_sha256" "$license_path" "EPL-2.0 license" + +rm -rf "$OUTPUT_DIR" +mkdir -p "$OUTPUT_DIR" +tar -xzf "$archive_path" -C "$OUTPUT_DIR" +cp "$license_path" "$OUTPUT_DIR/LICENSE-EPL-2.0.txt" + +cat > "$OUTPUT_DIR/bin/jdtls" <<'EOF' +#!/bin/zsh + +set -euo pipefail + +SCRIPT_DIR="${0:A:h}" +JAVA_EXECUTABLE="${JAVA_HOME:-}/bin/java" +if [[ ! -x "$JAVA_EXECUTABLE" ]]; then + JAVA_EXECUTABLE="${JAVA:-java}" +fi + +JVM_ARGUMENTS=( + "--add-modules=ALL-SYSTEM" + "--add-opens=java.base/java.util=ALL-UNNAMED" + "--add-opens=java.base/java.lang=ALL-UNNAMED" +) +SERVER_ARGUMENTS=() +while [[ $# -gt 0 ]]; do + case "$1" in + --java-executable) + [[ $# -ge 2 ]] || { print -u2 -- "--java-executable requires a path"; exit 2; } + JAVA_EXECUTABLE="$2" + shift 2 + ;; + --jvm-arg=*) + JVM_ARGUMENTS+=("${1#--jvm-arg=}") + shift + ;; + --jvm-arg) + [[ $# -ge 2 ]] || { print -u2 -- "--jvm-arg requires a value"; exit 2; } + JVM_ARGUMENTS+=("$2") + shift 2 + ;; + *) + SERVER_ARGUMENTS+=("$1") + shift + ;; + esac +done + +LAUNCHER_JAR=$(find "$SCRIPT_DIR/../plugins" -maxdepth 1 -name 'org.eclipse.equinox.launcher_*.jar' -print | sort | head -n 1) +[[ -n "$LAUNCHER_JAR" ]] || { print -u2 -- "JDTLS Equinox launcher was not found"; exit 1; } +if [[ "$(uname -m)" == "arm64" && -d "$SCRIPT_DIR/../config_mac_arm" ]]; then + CONFIGURATION="$SCRIPT_DIR/../config_mac_arm" +else + CONFIGURATION="$SCRIPT_DIR/../config_mac" +fi + +exec "$JAVA_EXECUTABLE" \ + "${JVM_ARGUMENTS[@]}" \ + -Declipse.application=org.eclipse.jdt.ls.core.id1 \ + -Declipse.product=org.eclipse.jdt.ls.core.product \ + -Dosgi.bundles.defaultStartLevel=4 \ + -Dlog.protocol=true \ + -Dlog.level=ALL \ + -jar "$LAUNCHER_JAR" \ + -configuration "$CONFIGURATION" \ + "${SERVER_ARGUMENTS[@]}" +EOF + +cat > "$OUTPUT_DIR/bin/jdtls.ps1" <<'EOF' +$ErrorActionPreference = "Stop" + +$javaExecutable = if ($env:JAVA_HOME) { Join-Path $env:JAVA_HOME "bin\java.exe" } else { "java" } +$jvmArguments = [System.Collections.Generic.List[string]]::new() +$jvmArguments.Add("--add-modules=ALL-SYSTEM") +$jvmArguments.Add("--add-opens=java.base/java.util=ALL-UNNAMED") +$jvmArguments.Add("--add-opens=java.base/java.lang=ALL-UNNAMED") +$serverArguments = [System.Collections.Generic.List[string]]::new() + +for ($index = 0; $index -lt $args.Count; $index++) { + $argument = [string]$args[$index] + if ($argument -eq "--java-executable") { + if ($index + 1 -ge $args.Count) { throw "--java-executable requires a path" } + $javaExecutable = [string]$args[++$index] + } elseif ($argument.StartsWith("--jvm-arg=")) { + $jvmArguments.Add($argument.Substring("--jvm-arg=".Length)) + } elseif ($argument -eq "--jvm-arg") { + if ($index + 1 -ge $args.Count) { throw "--jvm-arg requires a value" } + $jvmArguments.Add([string]$args[++$index]) + } else { + $serverArguments.Add($argument) + } +} + +$launcherJar = Get-ChildItem -LiteralPath (Join-Path $PSScriptRoot "..\plugins") -Filter "org.eclipse.equinox.launcher_*.jar" | + Sort-Object Name | + Select-Object -First 1 +if ($null -eq $launcherJar) { throw "JDTLS Equinox launcher was not found" } +$configuration = Join-Path $PSScriptRoot "..\config_win" + +& $javaExecutable @jvmArguments ` + "-Declipse.application=org.eclipse.jdt.ls.core.id1" ` + "-Declipse.product=org.eclipse.jdt.ls.core.product" ` + "-Dosgi.bundles.defaultStartLevel=4" ` + "-Dlog.protocol=true" ` + "-Dlog.level=ALL" ` + "-jar" $launcherJar.FullName ` + "-configuration" $configuration ` + @serverArguments +exit $LASTEXITCODE +EOF + +cat > "$OUTPUT_DIR/bin/jdtls.bat" <<'EOF' +@echo off +powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%~dp0jdtls.ps1" %* +exit /b %ERRORLEVEL% +EOF + +chmod +x "$OUTPUT_DIR/bin/jdtls" +validate_output +print -r -- "$OUTPUT_DIR" diff --git a/scripts/preview.sh b/scripts/preview.sh index 9a1a32039..4321e3cc3 100755 --- a/scripts/preview.sh +++ b/scripts/preview.sh @@ -45,6 +45,7 @@ cp Resources/Info.plist "$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" +cp -R Resources/Fonts "$APP_DIR/Contents/Resources/Fonts" for localization in en.lproj zh-Hans.lproj; do if [[ -d "Resources/$localization" ]]; then cp -R "Resources/$localization" "$APP_DIR/Contents/Resources/$localization" diff --git a/shared/contracts/application-boundary.md b/shared/contracts/application-boundary.md index b7c73e92e..cf2318ffb 100644 --- a/shared/contracts/application-boundary.md +++ b/shared/contracts/application-boundary.md @@ -159,6 +159,8 @@ Platform clients coordinate inspection, generation, resolution, typed document edits, and launch planning, but must not implement a second JSON merger, toolchain matcher, ID generator, argument parser, or Java/Maven argument builder. Opening a project inspects existing files without writing; generation -is an explicit user action. Local absolute paths belong only in -`.lithe/**/local.json` and are excluded from project visibility and Git by -default. +is an explicit user action. Shared project overrides stay in +`.lithe/run/configurations.json`. Machine-local overrides may live in +`.lithe/run/local.json` or in a host-owned document supplied as +`localDocument`; absolute toolchain paths belong only in that local layer and +are excluded from project visibility and Git by default. diff --git a/shared/contracts/run-configuration-v2.schema.json b/shared/contracts/run-configuration-v2.schema.json index 9bf361325..8178e7c28 100644 --- a/shared/contracts/run-configuration-v2.schema.json +++ b/shared/contracts/run-configuration-v2.schema.json @@ -19,6 +19,28 @@ }, "additionalProperties": false }, + "toolchain": { + "type": "object", + "description": "Machine-local global toolchain applied to every configuration. Stored only in the local layer.", + "properties": { + "java": { + "type": "object", + "properties": { + "homePath": { "type": "string" } + }, + "additionalProperties": false + }, + "maven": { + "type": "object", + "properties": { + "executablePath": { "type": "string" }, + "javaHomePath": { "type": "string" } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, "configurations": { "type": "array", "items": { "$ref": "#/$defs/configuration" } diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index f4c3788d5..1cc6fd1ab 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -120,6 +120,11 @@ stable error code and a user-facing message: | `git.commitFiles` | Return files changed by one commit | | `git.comparison` | Return files changed between a reference and the working tree | | `git.stashes` | Return structured stash references and messages | +| `git.checkoutPreflight` | Return local paths that would block switching to a reference | +| `git.pullPreflight` | Report the configured upstream, ahead/behind counts, divergence, and tracked local changes without fetching | +| `git.integrationPreflight` | Return local paths that block a merge, rebase, cherry-pick, or revert | +| `git.conflictMarkers` | Return staged text files that still contain conflict markers | +| `git.operationState` | Report an interrupted merge, rebase, cherry-pick, or revert and its conflicted paths | | `git.blame` | Return structured line blame metadata | | `github.parseRemote` | Parse a canonical GitHub HTTPS or SSH remote into owner/name | | `github.requestPlan` | Validate one GitHub operation and produce a trusted platform HTTP request plan | @@ -185,9 +190,10 @@ standard error envelope. `stage`, `unstage`, `discard`, `discardAll`, `stageAll`, `commit`, `cherryPick`, `revert`, `reset`, `createBranch`, `publishBranch`, `renameBranch`, `deleteBranch`, `merge`, `rebase`, `fetch`, `pull`, `push`, `checkout`, `checkoutRevision`, `clone`, `stashPush`, -`stashApply`, `stashPop`, and `stashDrop`. Optional fields are `paths`, -`reference`, `referenceKind`, `revision`, `name`, `message`, `remote`, -`destination`, `mode`, `includeUntracked`, `checkout`, and `amend`. +`stashApply`, `stashPop`, `stashDrop`, `operationContinue`, `operationAbort`, and +`operationSkip`. Optional fields are `paths`, `reference`, `referenceKind`, +`revision`, `name`, `message`, `remote`, `destination`, `mode`, +`includeUntracked`, `checkout`, and `amend`. The core validates pathspecs, revisions, branch names, references, reset modes, stash references, and operation-specific required fields before invoking Git. @@ -200,6 +206,45 @@ and checks out that branch at a detached HEAD when needed, then pushes it with an upstream. If the push fails, the local branch is intentionally retained so the user can fix credentials or connectivity and retry without losing commits. +`operationContinue`, `operationAbort`, and `operationSkip` inspect Git metadata +to select the active merge, rebase, cherry-pick, or revert instead of accepting +an operation kind from the caller. Continue is rejected while conflicted paths +remain, and skip is supported only for a rebase. All three return the normal +`{ "output": string, "exitCode": number }` process result when Git is invoked; +an absent or unsupported operation state uses the `invalid_request` envelope. + +`git.checkoutPreflight` accepts `{ "root": string, "reference": string }` and +returns `{ "blockingPaths": string[] }`. The sorted, de-duplicated result +contains tracked paths that are both locally modified and different between +HEAD and the target, plus untracked paths that the target reference tracks. + +`git.pullPreflight` accepts `{ "root": string }` and returns `upstream` as a +string or `null`, numeric `ahead` and `behind` counts, `diverged`, and +`hasLocalChanges`. It reads the existing tracking reference without fetching; +`diverged` is true only when both counts are non-zero. `hasLocalChanges` checks +tracked changes and excludes untracked files. A branch with no configured +upstream returns `null`, zero counts, and false for both booleans. + +`git.integrationPreflight` accepts `{ "root": string, "reference": string, +"operation": string }`, where `operation` is `merge`, `rebase`, `cherryPick`, +or `revert`. It returns sorted, de-duplicated `blockingPaths` and +`blocksEntirely`. Merge, cherry-pick, and revert report only dirty tracked paths +that overlap files the operation would write. Rebase reports every dirty +tracked path and sets `blocksEntirely` to true when that set is non-empty. + +`git.conflictMarkers` accepts `{ "root": string }` and returns +`{ "paths": string[] }`. Paths are sorted and de-duplicated staged text files +whose staged content has a line beginning with an opening, closing, or diff3 +conflict marker. A bare +`=======` line is not treated as a conflict marker. + +`git.operationState` accepts `{ "root": string }` and returns `kind`, +`reference`, `step`, `total`, and sorted, de-duplicated `conflictedPaths`. +`kind` is an empty string when no operation is active; otherwise it is `merge`, +`rebase`, `cherryPick`, or `revert`. `reference`, `step`, and `total` are +nullable, and the progress counters are populated only for a rebase. State is +read from Git's own metadata, so operations started outside Lithe are reported. + `git.diff` accepts `root`, `pathspecs`, optional `reference` or `commit`, `staged`, `untracked`, `contextLines`, and `ignoreAllWhitespace`, and returns `{ "patch": string, "rows": [], "hunks": [] }`. Rows contain one-based `oldLine`/`newLine` values where @@ -348,26 +393,43 @@ never writes files. `runConfig.generate` accepts `root`, relative Java `paths`, and relative `modulePaths`; it returns generated configuration and toolchain requirement documents for the platform adapter to write atomically. -`runConfig.resolve` accepts `root` and optional local `toolchainCandidates`. -It merges configurations by stable ID using this precedence: +`runConfig.resolve` accepts `root`, optional local `toolchainCandidates`, and +optional `localDocument`. When `localDocument` is present, Core uses that JSON +object as the local layer instead of reading `.lithe/run/local.json`. It merges +configurations by stable ID using this precedence: `local.json > configurations.json > generated.json`. Scalars and arrays are replaced by the higher layer, while toolchain maps merge by key. It returns -effective configurations, their source, the team default, and structured +effective configurations, their source, the team default, structured diagnostics for stale, orphaned, missing, disabled, and toolchain mismatch -states. +states, and the effective global `toolchain`. A document-level `toolchain` +object in the local layer (e.g. +`{ "java": { "homePath": ... }, "maven": { "executablePath": ..., "javaHomePath": ... } }`) +is applied to every configuration's `extensions.java.*` and is authoritative +over per-configuration toolchain paths. `runConfig.updateOptions` and `runConfig.createUserConfiguration` are pure document transformations. They validate scope, paths, supported types, stable IDs, main classes, modules, and argument parsing, then return UTF-8 JSON in the `document` field. The platform adapter selects the target project or local file and performs the atomic write. These commands never write files. +For project-scoped option updates, selected toolchain paths must resolve inside +`root` and are persisted with `/`-separated project-relative paths. Local-scoped +updates may carry host absolute paths. `runConfig.updateOptions` and +`runConfig.inspect` accept the same optional `localDocument` override. +When `updateOptions` carries a `toolchain` object (`javaHomePath`, +`mavenExecutablePath`, `mavenJavaHomePath`), it writes the document-level +global toolchain into the local layer instead of patching a configuration; +project scope rejects this payload because toolchain paths are machine-local. `runConfig.createLaunchPlan` accepts `root`, `configurationId`, optional -`currentFile` and `classPath`, and optional `debugPort`. It returns a toolchain +`currentFile` and `classPath`, optional `debugPort`, and optional +`localDocument`. It returns a toolchain reference, argument array, project-relative working directory, and structured environment references. It does not return a shell command or platform executable path. All project paths use `/`, reject absolute paths and `..` -traversal, and remain relative to `root`. +traversal, and remain relative to `root`. A `java.main` configuration without a +Maven toolchain launches through `project-jdk` and the configuration's Java +source path. `java.codeVision` accepts a workspace root, a target Java path, and Java source paths. It returns declaration locations and usage counts; Git blame attribution diff --git a/third_party/jdtls/manifest.json b/third_party/jdtls/manifest.json new file mode 100644 index 000000000..2c526d0ed --- /dev/null +++ b/third_party/jdtls/manifest.json @@ -0,0 +1,8 @@ +{ + "version": "1.38.0", + "archiveURL": "https://download.eclipse.org/jdtls/milestones/1.38.0/jdt-language-server-1.38.0-202408011337.tar.gz", + "archiveSHA256": "ba697788a19f2ba57b16302aba6b343c649928c95f76b0d170494ac12d17ac78", + "licenseURL": "https://www.eclipse.org/org/documents/epl-2.0/EPL-2.0.txt", + "licenseSHA256": "0becf16567beb77fa252b7664631dd177c8f9a1889e48995b45379c7130e5303", + "minimumJavaVersion": 17 +} diff --git a/windows/tauri/design-qa-artifacts/native-reference-comparison.png b/windows/tauri/design-qa-artifacts/native-reference-comparison.png new file mode 100644 index 000000000..e0bf99c10 Binary files /dev/null and b/windows/tauri/design-qa-artifacts/native-reference-comparison.png differ diff --git a/windows/tauri/design-qa-artifacts/native-windows-added-diff.png b/windows/tauri/design-qa-artifacts/native-windows-added-diff.png new file mode 100644 index 000000000..42fca6fd7 Binary files /dev/null and b/windows/tauri/design-qa-artifacts/native-windows-added-diff.png differ diff --git a/windows/tauri/design-qa-artifacts/native-windows-split-diff.png b/windows/tauri/design-qa-artifacts/native-windows-split-diff.png new file mode 100644 index 000000000..3b6a3316a Binary files /dev/null and b/windows/tauri/design-qa-artifacts/native-windows-split-diff.png differ diff --git a/windows/tauri/design-qa.md b/windows/tauri/design-qa.md new file mode 100644 index 000000000..9d5b3e40d --- /dev/null +++ b/windows/tauri/design-qa.md @@ -0,0 +1,46 @@ +# Design QA: Windows Source Control diff + +- Added-file reference: `D:/Downloads/document/xwechat_files/wxid_id17m8qt937c22_c153/temp/RWTemp/2026-08/1aede52250875b292d874992a7f6d5b7/f38120ff65bc487d72adb50de30e6dc7.png` +- Split modified-file reference: `D:/Downloads/document/xwechat_files/wxid_id17m8qt937c22_c153/temp/RWTemp/2026-08/1aede52250875b292d874992a7f6d5b7/2d6a05680ba07a8527dd10e69bdecacc.png` +- Native Windows added-file capture: `design-qa-artifacts/native-windows-added-diff.png` +- Native Windows modified-file capture: `design-qa-artifacts/native-windows-split-diff.png` +- Combined reference/implementation input: `design-qa-artifacts/native-reference-comparison.png` +- Native viewport: 1362 x 856, dark theme. + +## Result + +The working-tree diff now follows the reference hierarchy: file/status title, compact diff toolbar, explicit version header, blue hunk header, and semantic code body. The implementation keeps Lithe's native Windows chrome and design tokens while matching the reference's information architecture. + +### Added file + +- The untracked three-line fixture reports `+3 -0` in Source Control and `+3` on the file row. +- Selecting the file opens the diff surface, not a normal text editor or serialized patch. +- The header identifies `ADDED`, `WORKTREE`, and `Added version`. +- The hunk range is visible as `@@ -0,0 +1,3 @@`. +- All three source rows use the full-width added background with an added rail and independent line numbers. +- Side-by-side mode is disabled because a new file has no previous version. + +### Modified file + +- The left pane is labeled `Index version`; the right pane is labeled `Current version`. +- Both panes use independent line-number gutters and compact source streams. +- Removed content is red on the left and added content is green on the right. +- The center connector gutter is 28 px and shows curved transition bands with direction markers. +- The layout does not add fake blank code rows to the side that does not own a line. + +### Interaction and native verification + +- Verified in the running Tauri Windows application through native window capture and input. +- Clicking both untracked and tracked file rows opens the expected diff. +- Unified/side-by-side controls follow file type, and the whitespace control remains interactive. +- The temporary tracked-file change used for split verification was restored; the test repository was left with only the user's original untracked `c.txt`. + +### Automated verification + +- `bun test`: 51 passed, 0 failed. +- `bun run typecheck`: passed. +- `bun run lint -- ...`: passed for the changed files; existing unrelated repository warnings remain. + +No actionable P0, P1, or P2 visual mismatch remains in the requested added-file and modified-file diff flows. + +final result: passed diff --git a/windows/tauri/index.html b/windows/tauri/index.html index 3079979ba..b4e5bf26d 100644 --- a/windows/tauri/index.html +++ b/windows/tauri/index.html @@ -4,6 +4,7 @@ + Lithe