diff --git a/.agents/skills/release-lithe/SKILL.md b/.agents/skills/release-lithe/SKILL.md index cc7183ceb..8cc09d3d6 100644 --- a/.agents/skills/release-lithe/SKILL.md +++ b/.agents/skills/release-lithe/SKILL.md @@ -17,8 +17,26 @@ keep their existing workflow unless the task explicitly includes them. Do not invent features, compatibility claims, download assets, or fixes. - Treat the release notes as a required release artifact. A missing or empty file blocks the release; never fall back to GitHub-generated notes. -- Keep user-facing language focused on outcomes. Do not publish a raw commit or - pull-request list as the release description. +- Keep the release description short, plain-language, and focused on what users + can do or notice after updating. Write from the user's point of view: explain + the problem solved or workflow improved before naming the implementation. +- Include all meaningful user-facing changes, grouping related changes when that + makes the release easier to scan. Do not impose a fixed number of highlights. + Each highlight should answer “What does this change mean for me?” in one + sentence. Avoid internal names and jargon such as + implementation class names, protocol phases, rendering backends, or test + terminology. Mention a technical name only when a user must recognize it in + the UI or follow a setup step, and explain it in plain language. +- Do not publish a raw commit or pull-request list as the release description. +- Every stable release description must include the macOS Gatekeeper recovery + note below in both languages. Keep the command exactly as written and remind + users to use it only for an app downloaded from a source they trust: + + Chinese: + `xattr -dr com.apple.quarantine /Applications/Lithe.app` + + English: + `xattr -dr com.apple.quarantine /Applications/Lithe.app` ## Use the bilingual structure @@ -34,17 +52,58 @@ this order: 6. English counterparts for any optional product-area groupings. 7. `### Upgrade instructions` and `### Compatibility and known issues`. 8. The equivalent English comparison link. +9. At the very bottom, `### 贡献者` followed by `### Contributors`, with + equivalent contributor names or GitHub profile links in both languages. The download sections cover the project page, both macOS architectures, Windows x64, and the complete Release Assets page. Use versioned asset URLs that match the packaging workflows. Upgrade instructions cover macOS DMG, -Homebrew, and Windows. Compatibility sections include platform preview or -signing limitations only when they actually apply to that version. +Homebrew, and Windows. The compatibility section must include the Gatekeeper +note, plus platform preview or signing limitations only when they actually +apply to that version. + +## User-facing writing checklist + +- Lead with the result: “启动项目更快”“终端输出更流畅”“打开文件不再反复报错”. +- Replace implementation descriptions with the visible effect on editing, + running, debugging, Git, databases, or updates. +- Keep each bullet to one idea and one sentence; combine related fixes when the + user impact is the same. +- Use everyday words. If a technical term is unavoidable, add a short + explanation the first time it appears. +- Do not claim performance numbers, compatibility, security, or fixes unless + the target commit and release checks verify them. +- Include the Gatekeeper recovery instructions as a small, actionable note: + + Chinese: “如果 macOS 提示无法打开 Lithe.app,请在‘应用程序’中按住 Control + 点按应用并选择‘打开’;如果仍被阻止,可在终端执行 + `xattr -dr com.apple.quarantine /Applications/Lithe.app`。仅对可信来源的应用使用。” + + English: “If macOS says it cannot open Lithe.app, Control-click it in + Applications and choose Open. If it is still blocked, run + `xattr -dr com.apple.quarantine /Applications/Lithe.app` in Terminal. Use + this only for an app from a source you trust.” Use the most recent stable file under `docs/releases/` as the formatting reference, but verify every statement and URL for the new version instead of copying stale details. +## Contributors + +- Add a contributor list at the bottom of every stable release description, + after the English comparison link. +- Build the list from the commits and merged pull requests between the + previous stable tag and the new tag. Verify names and profile links against + GitHub before publishing; do not infer identities from an email address. +- Include human contributors who made code, documentation, design, testing, or + release work relevant to the version. Exclude automation accounts such as + `github-actions[bot]` unless the release explicitly needs to credit them. +- Keep the list short and readable. Do not include a raw commit log or every + incidental merge author. +- Use the same people and links in the Chinese and English sections. If no + human contribution can be verified for a release, write a brief equivalent + sentence instead of leaving the section empty. + ## Validate before publishing - Confirm the version uses `MAJOR.MINOR.PATCH`, the filename is exactly diff --git a/Package.resolved b/Package.resolved index 1be7fc1a8..8e67648d6 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "c7538017d5a84c9119e78e47b189a0e9f8751f463d32ee202500635b863ce5b2", + "originHash" : "174978129e44c6ca8e408691371fef707d0bd079d5dd55de25c3f90f483f08f4", "pins" : [ { "identity" : "swift-argument-parser", @@ -10,6 +10,24 @@ "version" : "1.8.2" } }, + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-syntax.git", + "state" : { + "revision" : "4799286537280063c85a32f09884cfbca301b1a1", + "version" : "602.0.0" + } + }, + { + "identity" : "swift-testing", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-testing.git", + "state" : { + "revision" : "5ee435b15ad40ec1f644b5eb9d247f263ccd2170", + "version" : "6.2.4" + } + }, { "identity" : "swiftterm", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index 2e2cda227..ee01c8e41 100644 --- a/Package.swift +++ b/Package.swift @@ -28,7 +28,8 @@ let package = Package( .executable(name: "LitheOfficialPluginVerifier", targets: ["LitheOfficialPluginVerifier"]) ], dependencies: [ - .package(url: "https://github.com/migueldeicaza/SwiftTerm.git", exact: "1.15.0") + .package(url: "https://github.com/migueldeicaza/SwiftTerm.git", exact: "1.15.0"), + .package(url: "https://github.com/swiftlang/swift-testing.git", exact: "6.2.4") ], targets: [ .target( @@ -118,7 +119,7 @@ let package = Package( ), .testTarget( name: "LitheTests", - dependencies: ["Lithe", "LitheModuleAPI", "LitheApplicationKernel", "LitheCoreContracts", "LitheGitModule", "LitheDatabaseModule", "LitheAIAssistanceModule", "LitheLanguageIntelligenceModule", "LitheGoSupportModule"], + dependencies: ["Lithe", "LitheModuleAPI", "LitheApplicationKernel", "LitheCoreContracts", "LitheGitModule", "LitheDatabaseModule", "LitheAIAssistanceModule", "LitheLanguageIntelligenceModule", "LitheGoSupportModule", .product(name: "Testing", package: "swift-testing")], path: "macos/Tests/LitheTests", resources: [ .copy("Fixtures") @@ -129,7 +130,7 @@ let package = Package( ), .testTarget( name: "LitheApplicationKernelTests", - dependencies: ["LitheModuleAPI", "LitheApplicationKernel"], + dependencies: ["LitheModuleAPI", "LitheApplicationKernel", .product(name: "Testing", package: "swift-testing")], path: "macos/Tests/LitheApplicationKernelTests", swiftSettings: [ .swiftLanguageMode(.v6) @@ -137,61 +138,61 @@ let package = Package( ), .testTarget( name: "LitheTerminalModuleTests", - dependencies: ["LitheTerminalModule"], + dependencies: ["LitheTerminalModule", .product(name: "Testing", package: "swift-testing")], path: "macos/Tests/LitheTerminalModuleTests", swiftSettings: [.swiftLanguageMode(.v6)] ), .testTarget( name: "LitheAIAssistanceModuleTests", - dependencies: ["LitheAIAssistanceModule", "LitheApplicationKernel", "LitheCoreContracts"], + dependencies: ["LitheAIAssistanceModule", "LitheApplicationKernel", "LitheCoreContracts", .product(name: "Testing", package: "swift-testing")], path: "macos/Tests/LitheAIAssistanceModuleTests", swiftSettings: [.swiftLanguageMode(.v6)] ), .testTarget( name: "LitheSearchModuleTests", - dependencies: ["LitheSearchModule", "LitheApplicationKernel"], + dependencies: ["LitheSearchModule", "LitheApplicationKernel", .product(name: "Testing", package: "swift-testing")], path: "macos/Tests/LitheSearchModuleTests", swiftSettings: [.swiftLanguageMode(.v6)] ), .testTarget( name: "LitheLocalHistoryModuleTests", - dependencies: ["LitheLocalHistoryModule", "LitheApplicationKernel"], + dependencies: ["LitheLocalHistoryModule", "LitheApplicationKernel", .product(name: "Testing", package: "swift-testing")], path: "macos/Tests/LitheLocalHistoryModuleTests", swiftSettings: [.swiftLanguageMode(.v6)] ), .testTarget( name: "LitheGitModuleTests", - dependencies: ["LitheGitModule", "LitheApplicationKernel"], + dependencies: ["LitheGitModule", "LitheApplicationKernel", .product(name: "Testing", package: "swift-testing")], path: "macos/Tests/LitheGitModuleTests", swiftSettings: [.swiftLanguageMode(.v6)] ), .testTarget( name: "LitheDatabaseModuleTests", - dependencies: ["LitheDatabaseModule", "LitheApplicationKernel"], + dependencies: ["LitheDatabaseModule", "LitheApplicationKernel", .product(name: "Testing", package: "swift-testing")], path: "macos/Tests/LitheDatabaseModuleTests", swiftSettings: [.swiftLanguageMode(.v6)] ), .testTarget( name: "LitheLanguageIntelligenceModuleTests", - dependencies: ["LitheLanguageIntelligenceModule", "LitheApplicationKernel", "LitheCoreContracts"], + dependencies: ["LitheLanguageIntelligenceModule", "LitheApplicationKernel", "LitheCoreContracts", .product(name: "Testing", package: "swift-testing")], path: "macos/Tests/LitheLanguageIntelligenceModuleTests", swiftSettings: [.swiftLanguageMode(.v6)] ), .testTarget( name: "LitheDebugModuleTests", - dependencies: ["LitheDebugModule", "LitheApplicationKernel"], + dependencies: ["LitheDebugModule", "LitheApplicationKernel", .product(name: "Testing", package: "swift-testing")], path: "macos/Tests/LitheDebugModuleTests", swiftSettings: [.swiftLanguageMode(.v6)] ), .testTarget( name: "LitheExecutionModuleTests", - dependencies: ["LitheExecutionModule", "LitheApplicationKernel", "LitheCoreContracts"], + dependencies: ["LitheExecutionModule", "LitheApplicationKernel", "LitheCoreContracts", .product(name: "Testing", package: "swift-testing")], path: "macos/Tests/LitheExecutionModuleTests", swiftSettings: [.swiftLanguageMode(.v6)] ), .testTarget( name: "LitheWorkspaceModuleTests", - dependencies: ["LitheWorkspaceModule", "LitheApplicationKernel"], + dependencies: ["LitheWorkspaceModule", "LitheApplicationKernel", .product(name: "Testing", package: "swift-testing")], path: "macos/Tests/LitheWorkspaceModuleTests", swiftSettings: [.swiftLanguageMode(.v6)] ), @@ -200,7 +201,8 @@ let package = Package( dependencies: [ "LitheGoSupportModule", "LitheApplicationKernel", - "LitheLanguageIntelligenceModule" + "LitheLanguageIntelligenceModule", + .product(name: "Testing", package: "swift-testing") ], path: "Plugins/mac/Official/GoSupport/Tests/LitheGoSupportModuleTests", swiftSettings: [.swiftLanguageMode(.v6)] diff --git a/README.md b/README.md index 1b894b761..41b0bf52b 100644 --- a/README.md +++ b/README.md @@ -128,6 +128,20 @@ brew install --cask 1lck/lithe/lithe brew upgrade --cask lithe ``` +### If macOS says it cannot open `Lithe.app` + +If macOS says that Apple cannot verify whether `Lithe.app` contains malware, the manually downloaded package may not yet be notarized by Apple. First confirm that the app came from the trusted [GitHub Releases](https://github.com/1lck/Lithe-IDEA/releases/latest), then use one of these methods: + +1. In **Applications**, Control-click `Lithe.app`, choose **Open**, and choose **Open** again in the confirmation dialog. +2. If macOS still blocks it, open **System Settings > Privacy & Security**, click **Open Anyway** next to the security warning, and launch the app again. +3. You can also remove the quarantine attribute in Terminal: + + ```bash + xattr -dr com.apple.quarantine /Applications/Lithe.app + ``` + +Only use these steps for an app whose source you trust. Homebrew installations usually do not require manual quarantine removal. + Java features require JDK 17 or newer. Release packages include Eclipse JDT Language Server, so JDTLS does not need to be installed separately. ## Architecture Overview diff --git a/README.zh-CN.md b/README.zh-CN.md index 273e290f2..052c51766 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -128,6 +128,20 @@ brew install --cask 1lck/lithe/lithe brew upgrade --cask lithe ``` +### macOS 提示“无法打开 Lithe.app” + +如果 macOS 提示“Apple 无法验证 Lithe.app 是否包含可能危害 Mac 安全或泄漏隐私的恶意软件”,通常是因为手动下载的安装包尚未经过 Apple 公证。请先确认应用来自可信的 [GitHub Releases](https://github.com/1lck/Lithe-IDEA/releases/latest),然后选择以下任一方式: + +1. 在“应用程序”中按住 Control 键点按 `Lithe.app`,选择“打开”,再在确认对话框中选择“打开”。 +2. 如果仍被阻止,打开“系统设置 > 隐私与安全性”,在安全性提示旁点按“仍要打开”,然后再次启动应用。 +3. 也可以在终端中移除下载文件的隔离标记: + + ```bash + xattr -dr com.apple.quarantine /Applications/Lithe.app + ``` + +上述命令只应对你确认来源可靠的应用使用;通过 Homebrew 安装通常不需要手动执行这些步骤。 + Java 功能需要 JDK 17 或更高版本。正式安装包已包含 Eclipse JDT Language Server,无需单独安装 JDTLS。 ## 架构概览 diff --git a/macos/Sources/Lithe/LitheApp.swift b/macos/Sources/Lithe/LitheApp.swift index 95e22a1d0..2921b184a 100644 --- a/macos/Sources/Lithe/LitheApp.swift +++ b/macos/Sources/Lithe/LitheApp.swift @@ -616,6 +616,7 @@ private struct SettingsWindowAccessor: NSViewRepresentable { guard let window = view.window else { return } reference.window = window window.title = title + window.level = .floating let windowAppearance = themePreference.windowAppearance if window.appearance?.name != windowAppearance?.name { window.appearance = windowAppearance diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+Notifications.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+Notifications.swift index a633d89d5..8fc874b20 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+Notifications.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+Notifications.swift @@ -1,5 +1,10 @@ import Foundation +private enum WorkbenchNotificationTiming { + static let displayDuration: Duration = .seconds(4) + static let maximumVisibleCount = 3 +} + extension AppModel { func showNotification(_ message: String) { let notification = WorkbenchNotification(message: message) @@ -7,16 +12,53 @@ extension AppModel { if notifications.count > 100 { notifications.removeLast(notifications.count - 100) } + + activeNotifications.append(notification) + if activeNotifications.count > WorkbenchNotificationTiming.maximumVisibleCount { + let removed = activeNotifications.removeFirst() + cancelNotificationDismissal(for: removed.id) + } notificationMessage = message - Task { @MainActor [weak self] in - try? await Task.sleep(for: .seconds(2)) - if self?.notifications.first?.id == notification.id, - self?.notificationMessage == message { - self?.notificationMessage = nil + if areNotificationsHovered { + notificationRemainingDurations[notification.id] = WorkbenchNotificationTiming.displayDuration + } else { + scheduleNotificationDismissal( + for: notification, + after: WorkbenchNotificationTiming.displayDuration + ) + } + } + + func setNotificationStackHovered(_ isHovered: Bool) { + guard areNotificationsHovered != isHovered else { return } + areNotificationsHovered = isHovered + + if isHovered { + let now = ContinuousClock().now + for notification in activeNotifications { + if let deadline = notificationDismissalDeadlines.removeValue(forKey: notification.id) { + notificationRemainingDurations[notification.id] = now < deadline + ? now.duration(to: deadline) + : .zero + } + notificationDismissalTasks.removeValue(forKey: notification.id)?.cancel() + } + } else { + for notification in activeNotifications { + let remaining = notificationRemainingDurations.removeValue(forKey: notification.id) + ?? WorkbenchNotificationTiming.displayDuration + scheduleNotificationDismissal(for: notification, after: remaining) } } } + func dismissNotification(_ id: UUID) { + guard activeNotifications.contains(where: { $0.id == id }) else { return } + activeNotifications.removeAll { $0.id == id } + cancelNotificationDismissal(for: id) + notificationMessage = activeNotifications.last?.message + } + func markAllNotificationsRead() { for index in notifications.indices { notifications[index].isRead = true @@ -25,6 +67,40 @@ extension AppModel { func clearNotifications() { notifications.removeAll() + activeNotifications.removeAll() + for task in notificationDismissalTasks.values { + task.cancel() + } + notificationDismissalTasks.removeAll() + notificationDismissalDeadlines.removeAll() + notificationRemainingDurations.removeAll() + areNotificationsHovered = false notificationMessage = nil } + + private func scheduleNotificationDismissal( + for notification: WorkbenchNotification, + after duration: Duration + ) { + notificationDismissalTasks[notification.id]?.cancel() + let deadline = ContinuousClock().now.advanced(by: duration) + notificationDismissalDeadlines[notification.id] = deadline + notificationDismissalTasks[notification.id] = Task { @MainActor [weak self] in + do { + try await Task.sleep(for: duration) + } catch { + return + } + guard let self, + !self.areNotificationsHovered, + self.notificationDismissalDeadlines[notification.id] == deadline else { return } + self.dismissNotification(notification.id) + } + } + + private func cancelNotificationDismissal(for id: UUID) { + notificationDismissalTasks.removeValue(forKey: id)?.cancel() + notificationDismissalDeadlines.removeValue(forKey: id) + notificationRemainingDurations.removeValue(forKey: id) + } } diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel.swift b/macos/Sources/Lithe/Models/AppModel/AppModel.swift index c63c61c33..3514c3a68 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel.swift @@ -98,7 +98,12 @@ final class AppModel: ObservableObject, Identifiable { workspaceFeature.isPerformingProjectItemOperation } @Published var notificationMessage: String? + @Published var activeNotifications: [WorkbenchNotification] = [] @Published var notifications: [WorkbenchNotification] = [] + var notificationDismissalTasks: [UUID: Task] = [:] + var notificationDismissalDeadlines: [UUID: ContinuousClock.Instant] = [:] + var notificationRemainingDurations: [UUID: Duration] = [:] + var areNotificationsHovered = false @Published var detectedAIConfigurations: [AIConfigurationSnapshot] = [] @Published var commitMessage = "" @Published var amendCommit = false diff --git a/macos/Sources/Lithe/Theme/LitheTheme.swift b/macos/Sources/Lithe/Theme/LitheTheme.swift index 427db6503..274c15feb 100644 --- a/macos/Sources/Lithe/Theme/LitheTheme.swift +++ b/macos/Sources/Lithe/Theme/LitheTheme.swift @@ -49,6 +49,7 @@ enum LitheTheme { let sidebar: RGBA let editor: RGBA let raised: RGBA + let notification: RGBA let selection: RGBA let subtleSelection: RGBA let hoverBackground: RGBA @@ -137,6 +138,7 @@ enum LitheTheme { sidebar: surface, editor: surface, raised: surface.mixed(with: ink, amount: isDark ? 0.085 : 0.018), + notification: surface.mixed(with: ink, amount: isDark ? 0.085 : 0.018), selection: accent, subtleSelection: subtleAccent, hoverBackground: ink.withAlpha(isDark ? 0.065 : 0.055), @@ -187,6 +189,7 @@ enum LitheTheme { sidebar: adaptive(light: (1, 1, 1, 1), dark: (0.110, 0.114, 0.122, 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)), + notification: adaptive(light: (1, 1, 1, 1), dark: (51.0 / 255.0, 54.0 / 255.0, 59.0 / 255.0, 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)), @@ -299,6 +302,16 @@ enum LitheTheme { static var sidebar: Color { adaptive(\.sidebar) } static var editor: Color { adaptive(\.editor) } static var raised: Color { adaptive(\.raised) } + static var notificationBackground: Color { adaptive(\.notification) } + static var contextMenuBackground: Color { + Color(nsColor: NSColor(name: nil) { appearance in + let isDark = appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua + if activeTheme == .lithe, isDark { + return NSColor(srgbRed: 38.0 / 255.0, green: 39.0 / 255.0, blue: 44.0 / 255.0, alpha: 1) + } + return Palette.make(theme: activeTheme, isDark: isDark).popupBackground.nsColor + }) + } // MARK: - 选中与悬停 static var selection: Color { adaptive(\.selection) } @@ -393,7 +406,7 @@ enum LitheTheme { static let treeRowHeight: CGFloat = 27 // IntelliJ IDEA New UI uses contiguous project-tree rows, 4/12 pt // tree insets, and an 8 pt selection arc (4 pt corner radius). - static let projectTreeRowSpacing: CGFloat = 0 + static let projectTreeRowSpacing: CGFloat = 1 static let projectTreeContentVerticalInset: CGFloat = 4 static let projectTreeContentHorizontalInset: CGFloat = 12 static let projectTreeSelectionCornerRadius: CGFloat = 4 diff --git a/macos/Sources/Lithe/Views/Components/LitheContextMenu.swift b/macos/Sources/Lithe/Views/Components/LitheContextMenu.swift index bcd299dd2..b57478460 100644 --- a/macos/Sources/Lithe/Views/Components/LitheContextMenu.swift +++ b/macos/Sources/Lithe/Views/Components/LitheContextMenu.swift @@ -1,10 +1,23 @@ import AppKit import SwiftUI +private enum LitheContextMenuMetrics { + static let minimumRootWidth: CGFloat = 272 + static let minimumSubmenuWidth: CGFloat = 220 + static let maximumWidth: CGFloat = 360 + static let itemFont = NSFont.menuFont(ofSize: 12) + static let shortcutFont = NSFont.menuFont(ofSize: 11) + static let rowHeight: CGFloat = 26 + static let separatorHeight: CGFloat = 11 + static let verticalPadding: CGFloat = 12 + static let submenuSpacing: CGFloat = 1 +} + struct LitheContextMenuItem: Identifiable { enum Kind { case action case separator + case submenu([LitheContextMenuItem]) } enum Role { @@ -51,28 +64,142 @@ struct LitheContextMenuItem: Identifiable { action: {} ) } + + static func submenu( + _ title: String, + systemImage: String? = nil, + items: [LitheContextMenuItem] + ) -> Self { + Self( + kind: .submenu(items), + title: title, + systemImage: systemImage, + shortcut: nil, + role: .standard, + isEnabled: true, + action: {} + ) + } } private struct LitheContextMenuContent: View { let items: [LitheContextMenuItem] let width: CGFloat let dismiss: () -> Void + let submenuWidth: CGFloat + let submenuHeight: CGFloat + let submenuOnLeft: Bool + let onSubmenuVisibilityChanged: (Bool) -> Void + @State private var openSubmenuID: UUID? + + private var openSubmenuItems: [LitheContextMenuItem]? { + guard let openSubmenuID else { return nil } + guard case .submenu(let items) = self.items.first(where: { $0.id == openSubmenuID })?.kind else { + return nil + } + return items + } + + private var submenuTopOffset: CGFloat { + guard let openSubmenuID, openSubmenuItems != nil else { return 0 } + let itemTop = items + .prefix { $0.id != openSubmenuID } + .reduce(LitheContextMenuMetrics.verticalPadding / 2) { offset, item in + offset + { + switch item.kind { + case .separator: + LitheContextMenuMetrics.separatorHeight + case .action, .submenu: + LitheContextMenuMetrics.rowHeight + } + }() + } + return min( + itemTop, + max(0, Self.menuHeight(for: items) - submenuHeight) + ) + } var body: some View { + ZStack(alignment: .topLeading) { + menuColumn( + items, + width: width, + onSubmenuHover: { item, hovering in + guard hovering else { return } + if case .submenu = item.kind { + openSubmenuID = item.id + } else { + openSubmenuID = nil + } + } + ) + .offset( + x: submenuOnLeft && openSubmenuItems != nil + ? submenuWidth + LitheContextMenuMetrics.submenuSpacing + : 0 + ) + + if let openSubmenuItems { + menuColumn(openSubmenuItems, width: submenuWidth) + .offset( + x: submenuOnLeft + ? 0 + : width + LitheContextMenuMetrics.submenuSpacing, + y: submenuTopOffset + ) + .zIndex(1) + } + } + .frame( + width: width + (openSubmenuItems == nil ? 0 : submenuWidth + LitheContextMenuMetrics.submenuSpacing), + height: max( + Self.menuHeight(for: items), + openSubmenuItems == nil ? 0 : submenuHeight + ), + alignment: .topLeading + ) + .contentShape(Rectangle()) + .onHover { isHovering in + if !isHovering { + openSubmenuID = nil + } + } + .onChange(of: openSubmenuID) { submenuID in + onSubmenuVisibilityChanged(submenuID != nil) + } + } + + @ViewBuilder + private func menuColumn( + _ items: [LitheContextMenuItem], + width: CGFloat, + onSubmenuHover: ((LitheContextMenuItem, Bool) -> Void)? = nil + ) -> some View { VStack(spacing: 0) { ForEach(items) { item in switch item.kind { case .action: - LitheContextMenuRow(item: item) { - dismiss() - item.action() - } + LitheContextMenuRow( + item: item, + action: { + dismiss() + item.action() + }, + onHover: { hovering in + onSubmenuHover?(item, hovering) + } + ) case .separator: Rectangle() .fill(LitheTheme.divider) .frame(height: 1) .padding(.horizontal, 8) .padding(.vertical, 5) + case .submenu: + LitheContextMenuRow(item: item) { hovering in + onSubmenuHover?(item, hovering) + } } } } @@ -80,22 +207,54 @@ private struct LitheContextMenuContent: View { .frame(width: width) .background { RoundedRectangle(cornerRadius: 9, style: .continuous) - .fill(LitheTheme.sidebar) + .fill(LitheTheme.contextMenuBackground) } - .overlay { - RoundedRectangle(cornerRadius: 9, style: .continuous) - .stroke(LitheTheme.panelBorder, lineWidth: 1) + } + + private static func menuHeight(for items: [LitheContextMenuItem]) -> CGFloat { + items.reduce(LitheContextMenuMetrics.verticalPadding) { height, item in + switch item.kind { + case .separator: + height + LitheContextMenuMetrics.separatorHeight + case .action, .submenu: + height + LitheContextMenuMetrics.rowHeight + } } } } private struct LitheContextMenuRow: View { let item: LitheContextMenuItem - let action: () -> Void + let action: (() -> Void)? + let onSubmenuHover: ((Bool) -> Void)? @State private var isHovering = false + init( + item: LitheContextMenuItem, + action: @escaping () -> Void, + onHover: ((Bool) -> Void)? = nil + ) { + self.item = item + self.action = action + self.onSubmenuHover = onHover + } + + init(item: LitheContextMenuItem, onSubmenuHover: @escaping (Bool) -> Void) { + self.item = item + self.action = nil + self.onSubmenuHover = onSubmenuHover + } + + private var submenuItems: [LitheContextMenuItem]? { + guard case .submenu(let items) = item.kind else { return nil } + return items + } + var body: some View { - Button(action: action) { + Button { + guard submenuItems == nil else { return } + action?() + } label: { HStack(spacing: 9) { Group { if let systemImage = item.systemImage { @@ -107,26 +266,28 @@ private struct LitheContextMenuRow: View { } .frame(width: 16, height: 16) .foregroundStyle( - item.role == .destructive - ? LitheTheme.error - : (isHovering ? LitheTheme.toolWindowSelectedText : LitheTheme.secondaryText) + isHovering ? LitheTheme.toolWindowSelectedText : LitheTheme.secondaryText ) Text(LocalizedStringKey(item.title)) - .font(.system(size: 13, weight: .regular)) + .font(Font(LitheContextMenuMetrics.itemFont)) .foregroundStyle(isHovering ? LitheTheme.toolWindowSelectedText : LitheTheme.primaryText) .lineLimit(1) Spacer(minLength: 14) - if let shortcut = item.shortcut { + if submenuItems != nil { + Image(systemName: "chevron.right") + .font(.system(size: 9, weight: .semibold)) + .foregroundStyle(isHovering ? LitheTheme.toolWindowSelectedText : LitheTheme.secondaryText) + } else if let shortcut = item.shortcut { Text(shortcut) - .font(.system(size: 12, weight: .regular)) + .font(Font(LitheContextMenuMetrics.shortcutFont)) .foregroundStyle(isHovering ? LitheTheme.toolWindowSelectedText.opacity(0.78) : LitheTheme.tertiaryText) } } .padding(.horizontal, 9) - .frame(height: 28) + .frame(height: LitheContextMenuMetrics.rowHeight) .contentShape(Rectangle()) .background { RoundedRectangle(cornerRadius: 5, style: .continuous) @@ -137,7 +298,10 @@ private struct LitheContextMenuRow: View { .buttonStyle(.plain) .disabled(!item.isEnabled) .opacity(item.isEnabled ? 1 : 0.45) - .onHover { isHovering = $0 } + .onHover { hovering in + isHovering = hovering + onSubmenuHover?(hovering) + } } } @@ -147,10 +311,9 @@ private final class LitheContextMenuPanel: NSPanel { } @MainActor -private final class LitheContextMenuPresenter: NSObject, NSWindowDelegate { +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? @@ -164,16 +327,54 @@ private final class LitheContextMenuPresenter: NSObject, NSWindowDelegate { dismiss() guard !items.isEmpty else { return } - let menuHeight = items.reduce(CGFloat(12)) { height, item in - height + (item.kind == .separator ? 11 : 28) + let menuWidth = Self.menuWidth( + for: items, + minimumWidth: LitheContextMenuMetrics.minimumRootWidth + ) + let menuHeight = Self.menuHeight(for: items) + let submenuWidths = items.compactMap { item -> CGFloat? in + guard case .submenu(let submenuItems) = item.kind else { return nil } + return Self.menuWidth( + for: submenuItems, + minimumWidth: LitheContextMenuMetrics.minimumSubmenuWidth + ) + } + let submenuHeights = items.compactMap { item -> CGFloat? in + guard case .submenu(let submenuItems) = item.kind else { return nil } + return Self.menuHeight(for: submenuItems) } + let submenuWidth = submenuWidths.max() ?? 0 + let submenuHeight = submenuHeights.max() ?? 0 + 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) + ) + let submenuOnLeft = submenuWidth > 0 + && origin.x + menuWidth + submenuWidth + LitheContextMenuMetrics.submenuSpacing > visibleFrame.maxX - 6 + && origin.x - submenuWidth - LitheContextMenuMetrics.submenuSpacing >= visibleFrame.minX + 6 let content = LitheContextMenuContent( items: items, width: menuWidth, - dismiss: { [weak self] in self?.dismiss() } + dismiss: { [weak self] in self?.dismiss() }, + submenuWidth: submenuWidth, + submenuHeight: submenuHeight, + submenuOnLeft: submenuOnLeft, + onSubmenuVisibilityChanged: { [weak self] isVisible in + self?.resizeMenu( + isSubmenuVisible: isVisible, + rootWidth: menuWidth, + rootHeight: menuHeight, + submenuWidth: submenuWidth, + submenuHeight: submenuHeight, + submenuOnLeft: submenuOnLeft + ) + } ) .environment(\.locale, locale) - .frame(width: menuWidth, height: menuHeight) let panel = LitheContextMenuPanel( contentRect: NSRect(x: 0, y: 0, width: menuWidth, height: menuHeight), @@ -183,6 +384,7 @@ private final class LitheContextMenuPresenter: NSObject, NSWindowDelegate { ) panel.contentViewController = NSHostingController(rootView: content) panel.appearance = appearance + panel.animationBehavior = .none panel.backgroundColor = .clear panel.isOpaque = false panel.hasShadow = true @@ -192,14 +394,6 @@ private final class LitheContextMenuPresenter: NSObject, NSWindowDelegate { 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 @@ -208,6 +402,75 @@ private final class LitheContextMenuPresenter: NSObject, NSWindowDelegate { panel.makeKey() } + private func resizeMenu( + isSubmenuVisible: Bool, + rootWidth: CGFloat, + rootHeight: CGFloat, + submenuWidth: CGFloat, + submenuHeight: CGFloat, + submenuOnLeft: Bool + ) { + guard let panel else { return } + let width = rootWidth + ( + isSubmenuVisible + ? submenuWidth + LitheContextMenuMetrics.submenuSpacing + : 0 + ) + let height = max(rootHeight, isSubmenuVisible ? submenuHeight : 0) + var frame = panel.frame + let wasSubmenuVisible = frame.width > rootWidth + if submenuOnLeft, isSubmenuVisible != wasSubmenuVisible { + frame.origin.x += isSubmenuVisible + ? -(submenuWidth + LitheContextMenuMetrics.submenuSpacing) + : submenuWidth + LitheContextMenuMetrics.submenuSpacing + } + frame.origin.y += frame.height - height + frame.size = NSSize(width: width, height: height) + panel.setFrame(frame, display: true) + } + + fileprivate static func menuWidth( + for items: [LitheContextMenuItem], + minimumWidth: CGFloat + ) -> CGFloat { + let widestItem = items.reduce(CGFloat.zero) { width, item in + guard case .action = item.kind else { + guard case .submenu = item.kind else { return width } + return max(width, menuItemWidth(item)) + } + return max(width, menuItemWidth(item)) + } + let contentWidth = widestItem + 67 + return min( + max(contentWidth, minimumWidth), + LitheContextMenuMetrics.maximumWidth + ) + } + + fileprivate static func menuHeight(for items: [LitheContextMenuItem]) -> CGFloat { + items.reduce(LitheContextMenuMetrics.verticalPadding) { height, item in + switch item.kind { + case .separator: + height + LitheContextMenuMetrics.separatorHeight + case .action, .submenu: + height + LitheContextMenuMetrics.rowHeight + } + } + } + + private static func menuItemWidth(_ item: LitheContextMenuItem) -> CGFloat { + let titleWidth = (item.title as NSString).size( + withAttributes: [.font: LitheContextMenuMetrics.itemFont] + ).width + let shortcutWidth = item.shortcut.map { + ($0 as NSString).size( + withAttributes: [.font: LitheContextMenuMetrics.shortcutFont] + ).width + } ?? 0 + let shortcutSpacing: CGFloat = item.shortcut == nil ? 0 : 18 + return titleWidth + shortcutWidth + shortcutSpacing + } + func dismiss() { removeEventMonitors() panel?.orderOut(nil) @@ -230,6 +493,7 @@ private final class LitheContextMenuPresenter: NSObject, NSWindowDelegate { } if event.type != .keyDown, event.window !== self.panel { self.dismiss() + return nil } return event } @@ -256,6 +520,7 @@ private final class LitheContextMenuPresenter: NSObject, NSWindowDelegate { private struct LitheContextMenuTrigger: NSViewRepresentable { @Environment(\.locale) private var locale let items: () -> [LitheContextMenuItem] + let onRightClick: () -> Void func makeNSView(context: Context) -> LitheRightClickCaptureView { let view = LitheRightClickCaptureView() @@ -269,6 +534,7 @@ private struct LitheContextMenuTrigger: NSViewRepresentable { private func update(_ view: LitheRightClickCaptureView) { view.onRightClick = { screenPoint, appearance in + onRightClick() LitheContextMenuPresenter.shared.show( items: items(), at: screenPoint, @@ -295,9 +561,12 @@ private final class LitheRightClickCaptureView: NSView { } extension View { - func litheContextMenu(items: @escaping () -> [LitheContextMenuItem]) -> some View { + func litheContextMenu( + items: @escaping () -> [LitheContextMenuItem], + onRightClick: @escaping () -> Void = {} + ) -> some View { overlay { - LitheContextMenuTrigger(items: items) + LitheContextMenuTrigger(items: items, onRightClick: onRightClick) } } } diff --git a/macos/Sources/Lithe/Views/Components/LitheScrollViewChrome.swift b/macos/Sources/Lithe/Views/Components/LitheScrollViewChrome.swift index 229fadc59..2d1a15a44 100644 --- a/macos/Sources/Lithe/Views/Components/LitheScrollViewChrome.swift +++ b/macos/Sources/Lithe/Views/Components/LitheScrollViewChrome.swift @@ -97,6 +97,7 @@ struct LitheScrollViewChrome: NSViewRepresentable { nsView.alwaysShowVertical = alwaysShowVertical nsView.usesCompactScrollers = usesCompactScrollers nsView.configureEnclosingScrollView() + nsView.needsDisplay = true } final class ScrollViewProbe: NSView { @@ -144,6 +145,23 @@ struct LitheScrollViewChrome: NSViewRepresentable { if !scrollView.hasVerticalScroller { scrollView.hasVerticalScroller = true } + // Persistent scrollers require legacy style because AppKit owns + // overlay fade behavior. Non-persistent scrollers remain overlay. + let scrollerStyle: NSScroller.Style = alwaysShowVertical ? .legacy : .overlay + if scrollView.scrollerStyle != scrollerStyle { + scrollView.scrollerStyle = scrollerStyle + } + if scrollView.autohidesScrollers != !alwaysShowVertical { + scrollView.autohidesScrollers = !alwaysShowVertical + } + let isDark = scrollView.effectiveAppearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua + let knobStyle: NSScroller.KnobStyle = isDark ? .light : .dark + if scrollView.verticalScroller?.knobStyle != knobStyle { + scrollView.verticalScroller?.knobStyle = knobStyle + } + if scrollView.horizontalScroller?.knobStyle != knobStyle { + scrollView.horizontalScroller?.knobStyle = knobStyle + } if usesCompactScrollers { if !(scrollView.verticalScroller is CompactScroller) { scrollView.verticalScroller = CompactScroller() @@ -163,22 +181,6 @@ struct LitheScrollViewChrome: NSViewRepresentable { scroller?.layer?.backgroundColor = NSColor.clear.cgColor } } - - // Persistent scrollers require legacy style because AppKit owns - // overlay fade behavior. Non-persistent scrollers remain overlay. - let scrollerStyle: NSScroller.Style = alwaysShowVertical ? .legacy : .overlay - if scrollView.scrollerStyle != scrollerStyle { - scrollView.scrollerStyle = scrollerStyle - } - if scrollView.autohidesScrollers != !alwaysShowVertical { - scrollView.autohidesScrollers = !alwaysShowVertical - } - if scrollView.verticalScroller?.knobStyle != .dark { - scrollView.verticalScroller?.knobStyle = .dark - } - if scrollView.horizontalScroller?.knobStyle != .dark { - scrollView.horizontalScroller?.knobStyle = .dark - } let controlSize: NSControl.ControlSize = usesCompactScrollers ? .mini : .regular if scrollView.verticalScroller?.controlSize != controlSize { scrollView.verticalScroller?.controlSize = controlSize @@ -275,11 +277,11 @@ struct LitheScrollViewChrome: NSViewRepresentable { guard knobRect.width > 0, knobRect.height > 0 else { return } let isDark = effectiveAppearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua - let sidebar = LitheTheme.nsColor(.sidebar, isDark: isDark) let secondaryText = LitheTheme.nsColor(.secondaryText, isDark: isDark) - let thumbColor = sidebar.blended(withFraction: 0.28, of: secondaryText) - ?? secondaryText - thumbColor.withAlphaComponent(0.95).setFill() + let thumbColor = isDark && LitheTheme.activeTheme == .lithe + ? NSColor(srgbRed: 67.0 / 255.0, green: 67.0 / 255.0, blue: 67.0 / 255.0, alpha: 1) + : secondaryText.withAlphaComponent(isDark ? 0.62 : 0.36) + thumbColor.setFill() NSBezierPath( roundedRect: knobRect, xRadius: min(2.5, knobRect.width / 2), diff --git a/macos/Sources/Lithe/Views/Components/LitheToolWindowHeader.swift b/macos/Sources/Lithe/Views/Components/LitheToolWindowHeader.swift index ed94fcbd4..54adcde06 100644 --- a/macos/Sources/Lithe/Views/Components/LitheToolWindowHeader.swift +++ b/macos/Sources/Lithe/Views/Components/LitheToolWindowHeader.swift @@ -1,8 +1,8 @@ import SwiftUI /// Shared title-bar chrome for bottom tool windows. Individual windows provide -/// their own controls while the title, spacing, border and minimize affordance -/// remain visually consistent. +/// their own controls while the title, spacing, and minimize affordance remain +/// visually consistent. struct LitheToolWindowHeader: View { let title: String let systemImage: String? @@ -64,8 +64,12 @@ struct LitheToolWindowHeader: View { .padding(.trailing, 7) .frame(height: LitheTheme.Metrics.toolWindowHeaderHeight) .litheWorkbenchSurface(LitheTheme.toolHeader) - .overlay(alignment: .bottom) { - Rectangle().fill(LitheTheme.divider).frame(height: 1) + .litheContextMenu { + onMinimize.map { minimize in + [ + .action("Hide \(title) Tool Window", systemImage: "minus", action: minimize) + ] + } ?? [] } } } diff --git a/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift b/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift index 1d7a93731..2d5d84ef6 100644 --- a/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift +++ b/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift @@ -93,6 +93,7 @@ enum EditorLayoutMetrics { static let leadingInset: CGFloat = 4 static let lineFragmentPadding: CGFloat = 4 static let caretWidth: CGFloat = 2 + static let currentLineHorizontalInset: CGFloat = 1 static func showsBlameMetadata( line: Int, @@ -482,17 +483,17 @@ struct CodeEditorView: NSViewRepresentable { func makeNSView(context: Context) -> EditorContainerView { let palette = CodeEditorPalette(isDark: colorScheme == .dark, theme: settings.colorTheme) - let showsWorkbenchBackground = model.workbenchBackgroundFeature.hasImage let container = EditorContainerView() - container.displaysTransparentBackground = showsWorkbenchBackground + container.displaysTransparentBackground = true let scrollView = NSScrollView(frame: .zero) scrollView.translatesAutoresizingMaskIntoConstraints = false scrollView.borderType = .noBorder scrollView.hasVerticalScroller = true scrollView.autohidesScrollers = true - scrollView.drawsBackground = !showsWorkbenchBackground - scrollView.backgroundColor = palette.background - scrollView.contentView.drawsBackground = !showsWorkbenchBackground + scrollView.drawsBackground = false + scrollView.backgroundColor = .clear + scrollView.contentView.drawsBackground = false + scrollView.contentView.backgroundColor = .clear scrollView.wantsLayer = true scrollView.layer?.masksToBounds = true @@ -528,8 +529,15 @@ struct CodeEditorView: NSViewRepresentable { textView.font = LitheTheme.editorFont(size: settings.editorFontSize) textView.defaultParagraphStyle = LitheTheme.editorParagraphStyle textView.indentationWidth = settings.tabWidth - textView.applyAppearance(palette, isTransparent: showsWorkbenchBackground) - textView.drawsBackground = !showsWorkbenchBackground + textView.applyAppearance(palette, isTransparent: true) + textView.drawsBackground = false + let viewportState = viewportStore.state(for: document.id) + let textLength = (textView.string as NSString).length + let selectionLocation = min(viewportState.selectionLocation, textLength) + let selectionLength = min(viewportState.selectionLength, textLength - selectionLocation) + textView.setSelectedRange( + NSRange(location: selectionLocation, length: selectionLength) + ) textView.isEditable = !document.isReadOnly textView.isSelectable = true textView.onWindowAttached = { [weak coordinator = context.coordinator] in @@ -614,9 +622,12 @@ struct CodeEditorView: NSViewRepresentable { coordinator?.updateStandardGutterWidth(width) } gutter.attach(textView: textView, scrollView: scrollView) - gutter.applyAppearance(palette, isTransparent: showsWorkbenchBackground) + gutter.applyAppearance(palette, isTransparent: true) context.coordinator.attachMarkdownScrollSync(to: scrollView) context.coordinator.attachViewportTracking(to: scrollView) + if let initialImportFold = JavaInitialImportFold.region(in: document.text as NSString) { + context.coordinator.primeJavaImportFold(initialImportFold) + } textView.onCaretPresentationChanged = { [weak gutter] in gutter?.needsDisplay = true @@ -648,18 +659,18 @@ struct CodeEditorView: NSViewRepresentable { func updateNSView(_ container: EditorContainerView, context: Context) { guard let textView = container.scrollView?.documentView as? NSTextView else { return } let palette = CodeEditorPalette(isDark: colorScheme == .dark, theme: settings.colorTheme) - let showsWorkbenchBackground = model.workbenchBackgroundFeature.hasImage let appearanceChanged = context.coordinator.isDarkAppearance != palette.isDark || context.coordinator.colorTheme != settings.colorTheme context.coordinator.document = document context.coordinator.model = model context.coordinator.shouldFocus = shouldFocus context.coordinator.markdownScrollPosition = markdownScrollPosition - container.displaysTransparentBackground = showsWorkbenchBackground + container.displaysTransparentBackground = true if let scrollView = container.scrollView { - scrollView.drawsBackground = !showsWorkbenchBackground - scrollView.backgroundColor = palette.background - scrollView.contentView.drawsBackground = !showsWorkbenchBackground + scrollView.drawsBackground = false + scrollView.backgroundColor = .clear + scrollView.contentView.drawsBackground = false + scrollView.contentView.backgroundColor = .clear context.coordinator.attachMarkdownScrollSync(to: scrollView) context.coordinator.attachMarkdownImagePasteMonitor(to: scrollView) context.coordinator.attachViewportTracking(to: scrollView) @@ -683,7 +694,7 @@ struct CodeEditorView: NSViewRepresentable { tabWidth: tabWidth, languageFeatures: languageFeatures, isReadOnly: document.isReadOnly, - isTransparent: showsWorkbenchBackground, + isTransparent: true, palette: palette, textView: textView, gutter: container.gutter @@ -1261,6 +1272,13 @@ struct CodeEditorView: NSViewRepresentable { highlightedRanges.removeAll() } + func primeJavaImportFold(_ region: JavaFoldRegion) { + guard fileExtension.lowercased() == "java" else { return } + foldRegions = [region] + collapsedFoldIDs = [region.id] + applyFoldState() + } + func scheduleFoldRefresh(useDefaultImportFold: Bool = false) { scheduleJavaNavigationMarkerRefresh() foldRefreshTask?.cancel() @@ -2705,10 +2723,14 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate { forGlyphAt: glyphRange.location, effectiveRange: nil ) + let horizontalInset = EditorLayoutMetrics.currentLineHorizontalInset + let visibleEditorRect = enclosingScrollView.map { + convert($0.contentView.bounds, from: $0.contentView).intersection(bounds) + } ?? bounds let currentLineRect = NSRect( - x: 0, + x: visibleEditorRect.minX + horizontalInset, y: textContainerOrigin.y + lineRect.minY, - width: bounds.width, + width: max(0, visibleEditorRect.width - horizontalInset * 2), height: lineRect.height ) guard currentLineRect.intersects(rect) else { return } @@ -2910,18 +2932,25 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate { override func flagsChanged(with event: NSEvent) { super.flagsChanged(with: event) + guard let window, + isEditorHitTarget(at: convert(window.mouseLocationOutsideOfEventStream, from: nil)) else { + NSCursor.arrow.set() + return + } guard isLanguageNavigationEnabled, hasNavigationModifier(event.modifierFlags) else { clearLinkHighlight() return } - if let window { - updateLinkHighlight(at: convert(window.mouseLocationOutsideOfEventStream, from: nil)) - } + updateLinkHighlight(at: convert(window.mouseLocationOutsideOfEventStream, from: nil)) } override func mouseEntered(with event: NSEvent) { super.mouseEntered(with: event) let point = convert(event.locationInWindow, from: nil) + guard isEditorHitTarget(at: point) else { + NSCursor.arrow.set() + return + } let summaryRegion = foldSummaryRegion(at: point) updateFoldHover(to: summaryRegion?.id) if summaryRegion != nil { @@ -2931,6 +2960,10 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate { override func mouseMoved(with event: NSEvent) { let point = convert(event.locationInWindow, from: nil) + guard isEditorHitTarget(at: point) else { + NSCursor.arrow.set() + return + } let summaryRegion = foldSummaryRegion(at: point) updateFoldHover(to: summaryRegion?.id) if summaryRegion != nil { @@ -2957,6 +2990,10 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate { override func cursorUpdate(with event: NSEvent) { let point = convert(event.locationInWindow, from: nil) + guard isEditorHitTarget(at: point) else { + NSCursor.arrow.set() + return + } if foldSummaryRegion(at: point) != nil { NSCursor.pointingHand.set() return @@ -2968,6 +3005,14 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate { NSCursor.iBeam.set() } + private func isEditorHitTarget(at point: NSPoint) -> Bool { + guard let contentView = window?.contentView, + let hitView = contentView.hitTest(convert(point, to: contentView)) else { + return true + } + return hitView === self || hitView.isDescendant(of: self) + } + override func mouseExited(with event: NSEvent) { super.mouseExited(with: event) updateFoldHover(to: nil) @@ -3343,31 +3388,81 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate { } } - let menu = super.menu(for: event) ?? NSMenu() - let goToLineItem = NSMenuItem( - title: NSLocalizedString("Go to Line…", comment: "Context menu item that opens the go-to-line dialog"), - action: #selector(goToLineFromMenu), - keyEquivalent: "" + guard let window else { return super.menu(for: event) } + LitheContextMenuPresenter.shared.show( + items: editorContextMenuItems(), + at: window.convertPoint(toScreen: event.locationInWindow), + appearance: effectiveAppearance, + locale: Locale.current ) - goToLineItem.target = self - menu.insertItem(goToLineItem, at: 0) - menu.insertItem(.separator(), at: 1) - let languageItems = languageContextMenuItems() + return nil + } + + private func editorContextMenuItems() -> [LitheContextMenuItem] { + var items: [LitheContextMenuItem] = [] + func addLanguageItem( + _ feature: LanguageServerFeatureSet, + _ title: String, + _ systemImage: String, + _ action: @escaping () -> Void + ) { + guard languageServerFeatures.contains(feature) else { return } + items.append(.action(title, systemImage: systemImage, action: action)) + } + + addLanguageItem(.implementation, "Go to Implementation", "arrow.turn.up.right", onGoToImplementation ?? {}) + addLanguageItem(.definition, "Go to Definition", "arrow.up.right", onGoToDefinition ?? {}) + addLanguageItem(.references, "Find Usages", "magnifyingglass", onFindUsages ?? {}) + addLanguageItem(.hover, "Quick Documentation", "questionmark.circle", { + let position = self.languageServerPosition(at: self.selectedRange().location) + self.onQuickDocumentation?(position.line, position.utf16Column) + }) + addLanguageItem(.completion, "Complete Symbol", "text.cursor", { + self.requestLanguageCompletions() + }) + addLanguageItem(.rename, "Rename Symbol", "pencil", { + self.renameSymbolFromMenu() + }) + addLanguageItem(.formatting, "Format Document", "text.alignleft", onFormatRequested ?? {}) + addLanguageItem(.codeActions, "Source Actions…", "wand.and.stars", { + self.codeActionsFromMenu() + }) + if onRunToCursor != nil { - let runToCursor = NSMenuItem( - title: "Run to Cursor", - action: #selector(runToCursorFromMenu), - keyEquivalent: "" - ) - runToCursor.target = self - runToCursor.isEnabled = isRunToCursorEnabled - menu.insertItem(.separator(), at: 0) - menu.insertItem(runToCursor, at: 0) - } - guard !languageItems.isEmpty else { return menu } - menu.insertItem(.separator(), at: 0) - for item in languageItems.reversed() { menu.insertItem(item, at: 0) } - return menu + items.append(.action("Run to Cursor", systemImage: "arrow.right.to.line", isEnabled: isRunToCursorEnabled, action: { + let position = self.languageServerPosition(at: self.selectedRange().location) + self.onRunToCursor?(position.line, position.utf16Column) + })) + } + if onGoToLineRequested != nil { + items.append(.action("Go to Line…", systemImage: "text.line.first.and.arrowtriangle.forward", action: { + self.onGoToLineRequested?() + })) + } + if !items.isEmpty { items.append(.separator) } + let selectionLength = selectedRange().length + items += [ + .action("Undo", systemImage: "arrow.uturn.backward", isEnabled: undoManager?.canUndo == true, action: { + self.undoManager?.undo() + }), + .action("Redo", systemImage: "arrow.uturn.forward", isEnabled: undoManager?.canRedo == true, action: { + self.undoManager?.redo() + }), + .separator, + .action("Cut", systemImage: "scissors", isEnabled: isEditable && selectionLength > 0, action: { + self.cut(nil) + }), + .action("Copy", systemImage: "doc.on.doc", isEnabled: selectionLength > 0, action: { + self.copy(nil) + }), + .action("Paste", systemImage: "doc.on.clipboard", isEnabled: isEditable, action: { + self.paste(nil) + }), + .action("Select All", systemImage: "selection.pin.in.out", isEnabled: !string.isEmpty, action: { + self.selectAll(nil) + }) + ] + return items } func languageContextMenuItems() -> [NSMenuItem] { @@ -3664,6 +3759,38 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate { } } +private enum JavaInitialImportFold { + private static let pattern = #"(?m)^[ \t]*import[ \t]+[^;]+;[ \t]*$"# + + static func region(in source: NSString) -> JavaFoldRegion? { + guard let expression = try? NSRegularExpression(pattern: pattern) else { return nil } + let matches = expression.matches( + in: source as String, + range: NSRange(location: 0, length: source.length) + ) + guard matches.count >= 2, + let first = matches.first, + let last = matches.last else { return nil } + + let firstLine = source.lineRange(for: NSRange(location: first.range.location, length: 0)) + let lastLine = source.lineRange(for: NSRange(location: last.range.location, length: 0)) + let hiddenStart = NSMaxRange(firstLine) + let hiddenEnd = NSMaxRange(lastLine) + return JavaFoldRegion( + kind: .imports, + startLine: lineNumber(in: source, at: first.range.location), + endLine: lineNumber(in: source, at: last.range.location), + hiddenRange: NSRange(location: hiddenStart, length: hiddenEnd - hiddenStart) + ) + } + + private static func lineNumber(in source: NSString, at location: Int) -> Int { + source.substring(to: min(location, source.length)).reduce(into: 0) { count, character in + if character == "\n" { count += 1 } + } + } +} + @MainActor final class EditorContainerView: NSView { weak var scrollView: NSScrollView? @@ -3765,7 +3892,7 @@ final class LineNumberGutterView: NSView { self.textView = textView self.scrollView = scrollView wantsLayer = true - layer?.backgroundColor = palette.gutterBackground.cgColor + layer?.backgroundColor = NSColor.clear.cgColor refreshLineNumberLayout() scrollView.contentView.postsBoundsChangedNotifications = true boundsObserver = NotificationCenter.default.addObserver( @@ -4118,7 +4245,12 @@ final class LineNumberGutterView: NSView { } if lineNumber - 1 == currentLine { palette.currentLine.setFill() - NSRect(x: 0, y: y, width: bounds.width, height: lineRect.height).fill() + NSRect( + x: bounds.minX + EditorLayoutMetrics.currentLineHorizontalInset, + y: y, + width: max(0, bounds.width - EditorLayoutMetrics.currentLineHorizontalInset * 2), + height: lineRect.height + ).fill() } if !isBlameVisible, hoveredDebugBreakpointLine == lineNumber - 1 { @@ -4713,23 +4845,37 @@ final class LineNumberGutterView: NSView { return super.menu(for: event) } contextGitLineChange = marker - let menu = NSMenu(title: "Git Line Change") - menu.addItem(withTitle: "Show Git Diff", action: #selector(showGitLineChangeFromMenu), keyEquivalent: "") - menu.items.last?.target = self + guard let window else { return nil } + var items: [LitheContextMenuItem] = [ + .action("Show Git Diff", systemImage: "doc.text.magnifyingglass", action: { [weak self] in + self?.showGitLineChangeFromMenu() + }) + ] if onStageGitLineChange != nil { - menu.addItem(withTitle: "Stage Change Block", action: #selector(stageGitLineChangeFromMenu), keyEquivalent: "") - menu.items.last?.target = self + items.append(.action("Stage Change Block", systemImage: "plus.square", action: { [weak self] in + self?.stageGitLineChangeFromMenu() + })) } if onUnstageGitLineChange != nil { - menu.addItem(withTitle: "Unstage Change Block", action: #selector(unstageGitLineChangeFromMenu), keyEquivalent: "") - menu.items.last?.target = self + items.append(.action("Unstage Change Block", systemImage: "arrow.uturn.backward", action: { [weak self] in + self?.unstageGitLineChangeFromMenu() + })) } if onDiscardGitLineChange != nil { - menu.addItem(.separator()) - menu.addItem(withTitle: "Discard Change Block…", action: #selector(discardGitLineChangeFromMenu), keyEquivalent: "") - menu.items.last?.target = self + items += [ + .separator, + .action("Discard Change Block…", systemImage: "trash", role: .destructive, action: { [weak self] in + self?.discardGitLineChangeFromMenu() + }) + ] } - return menu + LitheContextMenuPresenter.shared.show( + items: items, + at: window.convertPoint(toScreen: event.locationInWindow), + appearance: effectiveAppearance, + locale: Locale.current + ) + return nil } func debugBreakpointContextMenu(forLine line: Int) -> NSMenu? { diff --git a/macos/Sources/Lithe/Views/Editor/EditorAreaView.swift b/macos/Sources/Lithe/Views/Editor/EditorAreaView.swift index 099598488..ccbcfd2f7 100644 --- a/macos/Sources/Lithe/Views/Editor/EditorAreaView.swift +++ b/macos/Sources/Lithe/Views/Editor/EditorAreaView.swift @@ -267,7 +267,7 @@ struct EditorAreaView: View { } } .contentShape(Rectangle()) - .contextMenu { + .litheContextMenu { editorTabContextMenu(for: document, at: index) } .onHover { isHovering in @@ -492,14 +492,16 @@ struct EditorAreaView: View { .onHover { isHovering in hoveredTabID = isHovering ? session.id : nil } - .contextMenu { - Button("Interrupt", action: session.interrupt) - Button("Restart", action: session.restart) - Button("Clear", action: session.clear) - Divider() - Button("Close") { - model.requestCloseTerminalSession(session) - } + .litheContextMenu { + [ + .action("Interrupt", systemImage: "stop.fill", action: session.interrupt), + .action("Restart", systemImage: "arrow.clockwise", action: session.restart), + .action("Clear", systemImage: "eraser", action: session.clear), + .separator, + .action("Close", systemImage: "xmark", action: { + model.requestCloseTerminalSession(session) + }) + ] } .opacity(isDragged ? 0.92 : 1) .scaleEffect(isDragged ? 0.99 : 1) @@ -945,14 +947,12 @@ struct EditorAreaView: View { private var editorWorkspace: some View { VStack(spacing: 0) { editorTabs - Rectangle().fill(LitheTheme.divider).frame(height: 1) if model.activeEditorTerminalSession == nil, let splitDocumentID, let splitDocument = model.openDocuments.first(where: { $0.id == splitDocumentID }) { HStack(spacing: 0) { editorPane(model.activeDocument) - Rectangle().fill(LitheTheme.divider).frame(width: 1) editorPane(splitDocument, showsHeader: true) } } else { @@ -988,7 +988,6 @@ struct EditorAreaView: View { .padding(.horizontal, 10) .frame(height: 30) .background(LitheTheme.toolHeader) - Rectangle().fill(LitheTheme.divider).frame(height: 1) } if let document { @@ -1028,81 +1027,79 @@ struct EditorAreaView: View { } } - @ViewBuilder - private func editorTabContextMenu(for document: EditorDocument, at index: Int) -> some View { - Group { - Button("Close") { - model.requestCloseDocument(document) - } - - Button("Open in Right Split") { - splitDocumentID = document.id - } - .disabled(model.openDocuments.count < 2) - - Button("Close Other Tabs") { - model.requestCloseDocuments( - model.openDocuments.filter { $0.id != document.id }, - preferredDocumentID: document.id - ) - } - .disabled(model.openDocuments.count <= 1) - - Button("Close Tabs to the Left") { - model.requestCloseDocuments( - Array(model.openDocuments.prefix(index)), - preferredDocumentID: document.id - ) - } - .disabled(index == 0) - - Button("Close Tabs to the Right") { - let documents = Array(model.openDocuments.dropFirst(index + 1)) - model.requestCloseDocuments(documents, preferredDocumentID: document.id) - } - .disabled(index >= model.openDocuments.count - 1) - - Button("Close Unmodified Tabs") { - model.requestCloseDocuments( - model.openDocuments.filter { !$0.isDirty }, - preferredDocumentID: document.id - ) - } - - Button("Close All Tabs") { - model.requestCloseDocuments(model.openDocuments) - } - } - - Divider() - - Menu("Copy Path / Reference") { - Button("Copy Path") { - model.copyProjectItemPath(document.url, relative: false) - } - Button("Copy Relative Path") { - model.copyProjectItemPath(document.url, relative: true) - } - } + private func editorTabContextMenu( + for document: EditorDocument, + at index: Int + ) -> [LitheContextMenuItem] { + var items: [LitheContextMenuItem] = [ + .action("Close", action: { model.requestCloseDocument(document) }), + .action( + "Open in Right Split", + isEnabled: model.openDocuments.count >= 2, + action: { splitDocumentID = document.id } + ), + .action( + "Close Other Tabs", + isEnabled: model.openDocuments.count > 1, + action: { + model.requestCloseDocuments( + model.openDocuments.filter { $0.id != document.id }, + preferredDocumentID: document.id + ) + } + ), + .action( + "Close Tabs to the Left", + isEnabled: index > 0, + action: { + model.requestCloseDocuments( + Array(model.openDocuments.prefix(index)), + preferredDocumentID: document.id + ) + } + ), + .action( + "Close Tabs to the Right", + isEnabled: index < model.openDocuments.count - 1, + action: { + model.requestCloseDocuments( + Array(model.openDocuments.dropFirst(index + 1)), + preferredDocumentID: document.id + ) + } + ), + .action( + "Close Unmodified Tabs", + action: { + model.requestCloseDocuments( + model.openDocuments.filter { !$0.isDirty }, + preferredDocumentID: document.id + ) + } + ), + .action("Close All Tabs", action: { model.requestCloseDocuments(model.openDocuments) }), + .separator, + .submenu("Copy Path / Reference", items: [ + .action("Copy Path", action: { model.copyProjectItemPath(document.url, relative: false) }), + .action("Copy Relative Path", action: { model.copyProjectItemPath(document.url, relative: true) }) + ]) + ] 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) - } - Button("Local History…") { - model.showLocalHistory(for: document.url) - } - - Divider() - - Button("Rename…") { - model.requestRenameProjectItem(at: document.url) + items.append( + .action("Reveal in Project Tree", action: { + model.activeDocumentID = document.id + model.revealInProjectTree(document.url) + }) + ) } + items += [ + .action("Show in Finder", action: { model.revealProjectItemInFinder(document.url) }), + .action("Local History…", action: { model.showLocalHistory(for: document.url) }), + .separator, + .action("Rename…", action: { model.requestRenameProjectItem(at: document.url) }) + ] + return items } @ViewBuilder @@ -1123,9 +1120,6 @@ struct EditorAreaView: View { HStack(spacing: 0) { editorWithFindBar(document, markdownScrollPosition: scrollPosition) .frame(maxWidth: .infinity, maxHeight: .infinity) - Rectangle() - .fill(LitheTheme.divider) - .frame(width: 1) MarkdownPreviewView( document: document, scrollPosition: scrollPosition diff --git a/macos/Sources/Lithe/Views/Git/ChangesSidebarView.swift b/macos/Sources/Lithe/Views/Git/ChangesSidebarView.swift index 93edc59c4..d0d0345ec 100644 --- a/macos/Sources/Lithe/Views/Git/ChangesSidebarView.swift +++ b/macos/Sources/Lithe/Views/Git/ChangesSidebarView.swift @@ -331,11 +331,17 @@ struct ChangesSidebarView: View { } .buttonStyle(.plain) .lithePointer() - .contextMenu { - Button("Apply") { Task { await model.applyStash(stash) } } - Button("Pop") { Task { await model.applyStash(stash, pop: true) } } - Divider() - Button("Drop", role: .destructive) { pendingDropStash = stash } + .litheContextMenu { + [ + .action("Apply", systemImage: "arrow.down.circle", action: { + Task { await model.applyStash(stash) } + }), + .action("Pop", systemImage: "arrow.up.circle", action: { + Task { await model.applyStash(stash, pop: true) } + }), + .separator, + .action("Drop", role: .destructive, action: { pendingDropStash = stash }) + ] } } @@ -383,9 +389,13 @@ struct ChangesSidebarView: View { } .buttonStyle(.plain) .lithePointer() - .contextMenu { - Button("Restore") { Task { await model.applyShelf(shelf) } } - Button("Drop", role: .destructive) { pendingDropShelf = shelf } + .litheContextMenu { + [ + .action("Restore", systemImage: "arrow.uturn.backward", action: { + Task { await model.applyShelf(shelf) } + }), + .action("Drop", role: .destructive, action: { pendingDropShelf = shelf }) + ] } } @@ -620,63 +630,55 @@ struct ChangesSidebarView: View { .frame(height: 30) .background(model.selectedChange?.id == change.id ? LitheTheme.subtleSelection : .clear) .clipShape(RoundedRectangle(cornerRadius: 4)) - .contextMenu { - changeContextMenu(for: change) + .litheContextMenu { + changeContextMenuItems(for: change) } } - @ViewBuilder - private func changeContextMenu(for change: GitChange) -> some View { + private func changeContextMenuItems(for change: GitChange) -> [LitheContextMenuItem] { + var items: [LitheContextMenuItem] = [] if change.kind != .deleted { - Button("Open") { + items.append(.action("Open", systemImage: "doc.text", action: { model.openFile(change.url, displayPath: change.path) - } + })) } - - Button("Show Diff") { + items.append(.action("Show Diff", systemImage: "doc.text.magnifyingglass", action: { model.selectChange(change) - } - - Divider() - - if change.isStaged { - Button("Unstage") { - model.toggleStaging(change) - } - } else { - Button("Stage File") { - model.toggleStaging(change) - } - } - + })) + items.append(.separator) + items.append(.action( + change.isStaged ? "Unstage" : "Stage File", + systemImage: change.isStaged ? "arrow.uturn.backward" : "plus.square", + action: { model.toggleStaging(change) } + )) if change.hasWorkingTreeChange { - Button("Discard Changes", role: .destructive) { - model.requestDiscardChange(change) - } - } - - Divider() - - Button("Local History…") { - model.showLocalHistory(for: change.url) - } - .disabled(change.kind == .deleted) - - Button("Show in Finder") { - let url = change.kind == .deleted - ? change.url.deletingLastPathComponent() - : change.url - model.revealProjectItemInFinder(url) - } - - Menu("Copy Path / Reference") { - Button("Copy Path") { - model.copyProjectItemPath(change.url, relative: false) - } - Button("Copy Relative Path") { - model.copyProjectItemPath(change.url, relative: true) - } + items.append(.action( + "Discard Changes", + systemImage: "trash", + role: .destructive, + action: { model.requestDiscardChange(change) } + )) } + items += [ + .separator, + .action( + "Local History…", + systemImage: "clock.arrow.circlepath", + isEnabled: change.kind != .deleted, + action: { model.showLocalHistory(for: change.url) } + ), + .action("Show in Finder", systemImage: "folder", action: { + let url = change.kind == .deleted + ? change.url.deletingLastPathComponent() + : change.url + model.revealProjectItemInFinder(url) + }), + .submenu("Copy Path / Reference", items: [ + .action("Copy Path", action: { model.copyProjectItemPath(change.url, relative: false) }), + .action("Copy Relative Path", action: { model.copyProjectItemPath(change.url, relative: true) }) + ]) + ] + return items } private var commitArea: some View { @@ -769,7 +771,7 @@ struct ChangesSidebarView: View { } .controlSize(.small) } - .padding(10) + .padding([.top, .horizontal], 10) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .background(model.workbenchBackgroundFeature.hasImage ? Color.clear : LitheTheme.toolHeader) } diff --git a/macos/Sources/Lithe/Views/Search/ProjectReplaceView.swift b/macos/Sources/Lithe/Views/Search/ProjectReplaceView.swift index 8acfd2386..d0b361688 100644 --- a/macos/Sources/Lithe/Views/Search/ProjectReplaceView.swift +++ b/macos/Sources/Lithe/Views/Search/ProjectReplaceView.swift @@ -223,6 +223,24 @@ struct ProjectReplaceView: View { .padding(.vertical, 7) .frame(maxWidth: .infinity, alignment: .leading) .contentShape(Rectangle()) + .litheContextMenu { + [ + .action("Open", systemImage: "doc.text", action: { + model.openFile(file.url, displayPath: file.relativePath) + }), + .action("Show in Finder", systemImage: "folder", action: { + model.revealProjectItemInFinder(file.url) + }), + .submenu("Copy Path / Reference", items: [ + .action("Copy Path", action: { + model.copyProjectItemPath(file.url, relative: false) + }), + .action("Copy Relative Path", action: { + model.copyProjectItemPath(file.url, relative: true) + }) + ]) + ] + } } private func clearPreview() { diff --git a/macos/Sources/Lithe/Views/Search/SearchEverywhereView.swift b/macos/Sources/Lithe/Views/Search/SearchEverywhereView.swift index 4aa3d2614..f69360119 100644 --- a/macos/Sources/Lithe/Views/Search/SearchEverywhereView.swift +++ b/macos/Sources/Lithe/Views/Search/SearchEverywhereView.swift @@ -333,6 +333,24 @@ struct SearchEverywhereView: View { .frame(maxWidth: .infinity) .frame(height: 24) .background(index == selectedIndex ? LitheTheme.selection : .clear) + .litheContextMenu { + [ + .action("Open", systemImage: "doc.text", action: { + model.openSearchEverywhereResult(result) + }), + .action("Show in Finder", systemImage: "folder", action: { + model.revealProjectItemInFinder(result.url) + }), + .submenu("Copy Path / Reference", items: [ + .action("Copy Path", action: { + model.copyProjectItemPath(result.url, relative: false) + }), + .action("Copy Relative Path", action: { + model.copyProjectItemPath(result.url, relative: true) + }) + ]) + ] + } } /// 结果所在目录(不含文件名本身),文件直接位于工作区根下时为空。 diff --git a/macos/Sources/Lithe/Views/Search/SearchSidebarView.swift b/macos/Sources/Lithe/Views/Search/SearchSidebarView.swift index f415fd0dc..cf8b8ab76 100644 --- a/macos/Sources/Lithe/Views/Search/SearchSidebarView.swift +++ b/macos/Sources/Lithe/Views/Search/SearchSidebarView.swift @@ -108,6 +108,24 @@ struct SearchSidebarView: View { } .buttonStyle(.plain) .lithePointer() + .litheContextMenu { + [ + .action("Open", systemImage: "doc.text", action: { + model.openSearchResult(result) + }), + .action("Show in Finder", systemImage: "folder", action: { + model.revealProjectItemInFinder(result.url) + }), + .submenu("Copy Path / Reference", items: [ + .action("Copy Path", action: { + model.copyProjectItemPath(result.url, relative: false) + }), + .action("Copy Relative Path", action: { + model.copyProjectItemPath(result.url, relative: true) + }) + ]) + ] + } Rectangle().fill(LitheTheme.divider).frame(height: 1) } } diff --git a/macos/Sources/Lithe/Views/Terminal/TerminalSurfaceView.swift b/macos/Sources/Lithe/Views/Terminal/TerminalSurfaceView.swift index c9319ec3f..d16cbd5f6 100644 --- a/macos/Sources/Lithe/Views/Terminal/TerminalSurfaceView.swift +++ b/macos/Sources/Lithe/Views/Terminal/TerminalSurfaceView.swift @@ -20,6 +20,28 @@ struct TerminalSurfaceView: View { } } .background(model.workbenchBackgroundFeature.hasImage ? Color.clear : LitheTheme.editor) + .litheContextMenu { + [ + .action("Copy", systemImage: "doc.on.doc", action: { + NSApp.sendAction(#selector(NSText.copy(_:)), to: nil, from: nil) + }), + .action("Paste", systemImage: "doc.on.clipboard", action: { + NSApp.sendAction(#selector(NSText.paste(_:)), to: nil, from: nil) + }), + .action("Select All", systemImage: "selection.pin.in.out", action: { + NSApp.sendAction(#selector(NSText.selectAll(_:)), to: nil, from: nil) + }), + .separator, + .action("Clear", systemImage: "eraser", action: session.clear), + .action("Restart", systemImage: "arrow.clockwise", action: { + session.restart() + session.focus() + }), + .action("Close Terminal", systemImage: "xmark", action: { + model.requestCloseTerminalSession(session) + }) + ] + } .task(id: session.id) { requestInputFocus() } diff --git a/macos/Sources/Lithe/Views/Terminal/TerminalView.swift b/macos/Sources/Lithe/Views/Terminal/TerminalView.swift index be8a52005..ffbc4210d 100644 --- a/macos/Sources/Lithe/Views/Terminal/TerminalView.swift +++ b/macos/Sources/Lithe/Views/Terminal/TerminalView.swift @@ -200,14 +200,16 @@ struct TerminalView: View { ) } } - .contextMenu { - Button("Move to Editor") { - model.moveTerminalToEditor(session.id) - } - Divider() - Button("Close") { - model.requestCloseTerminalSession(session) - } + .litheContextMenu { + [ + .action("Move to Editor", systemImage: "rectangle.center.inset.filled", action: { + model.moveTerminalToEditor(session.id) + }), + .separator, + .action("Close", systemImage: "xmark", action: { + model.requestCloseTerminalSession(session) + }) + ] } .lithePointer() } diff --git a/macos/Sources/Lithe/Views/Workbench/SplitHandleView.swift b/macos/Sources/Lithe/Views/Workbench/SplitHandleView.swift index 1383e505c..f0d598ce7 100644 --- a/macos/Sources/Lithe/Views/Workbench/SplitHandleView.swift +++ b/macos/Sources/Lithe/Views/Workbench/SplitHandleView.swift @@ -21,10 +21,6 @@ struct SplitHandleView: View { @State private var isHovering = false @State private var isDragging = false - @State private var lastTranslation: CGFloat = 0 - @State private var dragUpdateBuffer = FrameCoalescedDragUpdateBuffer() - @State private var dragUpdateTask: Task? - @State private var cursor = SplitHandleCursor() init( axis: LitheSplitAxis, @@ -44,56 +40,37 @@ struct SplitHandleView: View { self.onDragEnded = onDragEnded } + @ViewBuilder var body: some View { + if axis == .horizontal { + handleSurface.frame(maxHeight: .infinity) + } else { + handleSurface.frame(maxWidth: .infinity) + } + } + + private var handleSurface: some View { ZStack { trackBackground - Color.clear dividerLine + SplitHandleInteractionView( + axis: axis, + onHoverChanged: { isHovering = $0 }, + onDragStateChanged: { isDragging = $0 }, + onDragStarted: onDragStarted, + onDragChanged: onDragChanged, + onDragEnded: onDragEnded + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) } .frame( width: axis == .horizontal ? Self.thickness : nil, height: axis == .vertical ? Self.thickness : nil ) .contentShape(Rectangle()) - .gesture( - // The handle moves with the resized pane, so local coordinates create a - // feedback loop where translation jumps as the coordinate origin moves. - DragGesture(minimumDistance: 0, coordinateSpace: .global) - .onChanged { value in - if !isDragging { - isDragging = true - lastTranslation = 0 - cursor.update(isResizing: true, cursor: resizeCursor) - onDragStarted() - } - let currentTranslation = axis == .horizontal ? value.translation.width : value.translation.height - // Pointer devices can deliver substantially more events than the - // display can present. Keep only the newest translation for the - // next frame instead of forcing every intermediate layout. - if abs(currentTranslation - lastTranslation) >= 1 { - lastTranslation = currentTranslation - scheduleDragUpdate(currentTranslation) - } - } - .onEnded { value in - let finalTranslation = axis == .horizontal - ? value.translation.width - : value.translation.height - cancelScheduledDragUpdate() - isDragging = false - lastTranslation = 0 - cursor.update(isResizing: isHovering, cursor: resizeCursor) - onDragEnded(finalTranslation) - } - ) - .onHover { isInside in - guard isInside != isHovering else { return } - isHovering = isInside - cursor.update(isResizing: isInside || isDragging, cursor: resizeCursor) - } .onDisappear { - cancelScheduledDragUpdate() - cursor.update(isResizing: false, cursor: resizeCursor) + isHovering = false + isDragging = false } .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") @@ -121,7 +98,7 @@ struct SplitHandleView: View { @ViewBuilder private var dividerLine: some View { let isHighlighted = isHovering || isDragging - if showsIdleDivider || isHighlighted { + if showsIdleDivider { let color = isHighlighted ? LitheTheme.accent : LitheTheme.divider if axis == .horizontal { @@ -138,41 +115,183 @@ struct SplitHandleView: View { } } - private var resizeCursor: NSCursor { - axis == .horizontal ? .resizeLeftRight : .resizeUpDown +} + +private struct SplitHandleInteractionView: NSViewRepresentable { + let axis: LitheSplitAxis + let onHoverChanged: (Bool) -> Void + let onDragStateChanged: (Bool) -> Void + let onDragStarted: () -> Void + let onDragChanged: (CGFloat) -> Void + let onDragEnded: (CGFloat) -> Void + + func makeNSView(context: Context) -> SplitHandleInteractionNSView { + SplitHandleInteractionNSView( + axis: axis, + onHoverChanged: onHoverChanged, + onDragStateChanged: onDragStateChanged, + onDragStarted: onDragStarted, + onDragChanged: onDragChanged, + onDragEnded: onDragEnded + ) } - private func scheduleDragUpdate(_ translation: CGFloat) { - guard dragUpdateBuffer.submit(translation) else { return } - dragUpdateTask = Task { @MainActor in - try? await Task.sleep(for: .milliseconds(16)) - guard !Task.isCancelled else { return } - let translation = dragUpdateBuffer.takePendingValue() - dragUpdateTask = nil - if let translation { - onDragChanged(translation) - } - } + func updateNSView(_ nsView: SplitHandleInteractionNSView, context: Context) { + nsView.update( + axis: axis, + onHoverChanged: onHoverChanged, + onDragStateChanged: onDragStateChanged, + onDragStarted: onDragStarted, + onDragChanged: onDragChanged, + onDragEnded: onDragEnded + ) } - private func cancelScheduledDragUpdate() { - dragUpdateTask?.cancel() - dragUpdateTask = nil - dragUpdateBuffer.cancel() + static func dismantleNSView(_ nsView: SplitHandleInteractionNSView, coordinator: ()) { + nsView.cancelInteraction() } } -private final class SplitHandleCursor { - private var isResizing = false +private final class SplitHandleInteractionNSView: NSView { + private var axis: LitheSplitAxis + private var onHoverChanged: (Bool) -> Void + private var onDragStateChanged: (Bool) -> Void + private var onDragStarted: () -> Void + private var onDragChanged: (CGFloat) -> Void + private var onDragEnded: (CGFloat) -> Void + private var trackingArea: NSTrackingArea? + private var dragStartInWindow: NSPoint? + private var isDragging = false + private var isInside = false - @MainActor - func update(isResizing newValue: Bool, cursor: NSCursor) { - guard newValue != isResizing else { return } - isResizing = newValue - if newValue { - cursor.push() - } else { - NSCursor.pop() + init( + axis: LitheSplitAxis, + onHoverChanged: @escaping (Bool) -> Void, + onDragStateChanged: @escaping (Bool) -> Void, + onDragStarted: @escaping () -> Void, + onDragChanged: @escaping (CGFloat) -> Void, + onDragEnded: @escaping (CGFloat) -> Void + ) { + self.axis = axis + self.onHoverChanged = onHoverChanged + self.onDragStateChanged = onDragStateChanged + self.onDragStarted = onDragStarted + self.onDragChanged = onDragChanged + self.onDragEnded = onDragEnded + super.init(frame: .zero) + wantsLayer = true + layer?.backgroundColor = NSColor.clear.cgColor + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func updateTrackingAreas() { + if let trackingArea { + removeTrackingArea(trackingArea) + } + let area = NSTrackingArea( + rect: bounds, + options: [.mouseEnteredAndExited, .cursorUpdate, .activeInKeyWindow, .inVisibleRect], + owner: self, + userInfo: nil + ) + addTrackingArea(area) + trackingArea = area + super.updateTrackingAreas() + } + + override func resetCursorRects() { + super.resetCursorRects() + addCursorRect(bounds, cursor: resizeCursor) + } + + override func cursorUpdate(with event: NSEvent) { + resizeCursor.set() + } + + override func mouseEntered(with event: NSEvent) { + isInside = true + onHoverChanged(true) + resizeCursor.set() + } + + override func mouseExited(with event: NSEvent) { + isInside = false + onHoverChanged(false) + if !isDragging { + NSCursor.arrow.set() + } + } + + override func acceptsFirstMouse(for event: NSEvent?) -> Bool { + true + } + + override func mouseDown(with event: NSEvent) { + isDragging = true + dragStartInWindow = event.locationInWindow + onDragStateChanged(true) + resizeCursor.set() + onDragStarted() + } + + override func mouseDragged(with event: NSEvent) { + guard let dragStartInWindow else { return } + onDragChanged(translation(from: dragStartInWindow, to: event.locationInWindow)) + } + + override func mouseUp(with event: NSEvent) { + guard let dragStartInWindow else { return } + onDragEnded(translation(from: dragStartInWindow, to: event.locationInWindow)) + self.dragStartInWindow = nil + isDragging = false + onDragStateChanged(false) + (isInside ? resizeCursor : NSCursor.arrow).set() + } + + func update( + axis: LitheSplitAxis, + onHoverChanged: @escaping (Bool) -> Void, + onDragStateChanged: @escaping (Bool) -> Void, + onDragStarted: @escaping () -> Void, + onDragChanged: @escaping (CGFloat) -> Void, + onDragEnded: @escaping (CGFloat) -> Void + ) { + self.axis = axis + self.onHoverChanged = onHoverChanged + self.onDragStateChanged = onDragStateChanged + self.onDragStarted = onDragStarted + self.onDragChanged = onDragChanged + self.onDragEnded = onDragEnded + window?.invalidateCursorRects(for: self) + } + + func cancelInteraction() { + dragStartInWindow = nil + if isDragging { + isDragging = false + onDragStateChanged(false) + } + if isInside { + isInside = false + onHoverChanged(false) + } + NSCursor.arrow.set() + } + + private var resizeCursor: NSCursor { + axis == .horizontal ? .resizeLeftRight : .resizeUpDown + } + + private func translation(from start: NSPoint, to current: NSPoint) -> CGFloat { + axis == .horizontal ? current.x - start.x : start.y - current.y + } + + deinit { + if isDragging || isInside { + NSCursor.arrow.set() } } } diff --git a/macos/Sources/Lithe/Views/Workbench/WorkbenchModuleUIComposition.swift b/macos/Sources/Lithe/Views/Workbench/WorkbenchModuleUIComposition.swift index 9fbbf5c6d..8e693db1c 100644 --- a/macos/Sources/Lithe/Views/Workbench/WorkbenchModuleUIComposition.swift +++ b/macos/Sources/Lithe/Views/Workbench/WorkbenchModuleUIComposition.swift @@ -43,7 +43,6 @@ enum WorkbenchModuleUIComposition { contributions: GitModule.moduleContributions, actions: [ .init(id: "git.log.toggle", perform: { model in - if !model.isGitLogVisible { model.selectedSidebar = .changes } Task { await model.toggleGitLog() } }) ], diff --git a/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift b/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift index 97697c959..2ed1e92ae 100644 --- a/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift +++ b/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift @@ -4,8 +4,7 @@ import LitheGitModule enum WorkbenchLayoutMetrics { static let rightActivityBarWidth: CGFloat = 40 - static let rightActivityBarDividerWidth: CGFloat = 1 - static let workspaceTrailingInset = rightActivityBarWidth + rightActivityBarDividerWidth + static let workspaceTrailingInset = rightActivityBarWidth } private enum ActivityBarMetrics { @@ -19,9 +18,11 @@ private enum ActivityBarMetrics { } private enum WorkbenchWorkspaceMetrics { - static let paneInset: CGFloat = 6 + static let paneInset: CGFloat = 0 static let paneSpacing: CGFloat = 6 static let paneCornerRadius: CGFloat = 10 + static let minimumTopPaneHeight: CGFloat = 220 + static let changesMinimumTopPaneHeight: CGFloat = 332 } private enum WorkbenchPopoverLayoutMetrics { @@ -92,18 +93,13 @@ struct WorkbenchView: View { var body: some View { VStack(spacing: 0) { topBar - Rectangle().fill(LitheTheme.divider).frame(height: 1) if projectSessions.openProjects.count > 1 { projectTabBar - Rectangle().fill(LitheTheme.divider).frame(height: 1) } HStack(spacing: 0) { activityBar - Rectangle() - .fill(LitheTheme.divider) - .frame(width: 1) workspaceArea .padding(.trailing, WorkbenchLayoutMetrics.workspaceTrailingInset) } @@ -112,7 +108,6 @@ struct WorkbenchView: View { rightHoverRegion } - Rectangle().fill(LitheTheme.divider).frame(height: 1) statusBar } .background { @@ -303,17 +298,53 @@ struct WorkbenchView: View { } } } - .overlay(alignment: .bottom) { - if let message = model.notificationMessage { - Text(LocalizedStringKey(message)) - .font(.system(size: 12, weight: .medium)) - .foregroundStyle(LitheTheme.primaryText) - .padding(.horizontal, 14) - .frame(height: 34) - .background(LitheTheme.raised) - .clipShape(RoundedRectangle(cornerRadius: 7)) - .shadow(color: .black.opacity(0.35), radius: 12, y: 4) - .padding(.bottom, 38) + .overlay(alignment: .bottomTrailing) { + if !model.activeNotifications.isEmpty { + VStack(alignment: .trailing, spacing: 8) { + ForEach(model.activeNotifications) { notification in + HStack(alignment: .center, spacing: 10) { + Image(systemName: "info.circle.fill") + .font(.system(size: 14)) + .foregroundStyle(LitheTheme.accent) + + Text(LocalizedStringKey(notification.message)) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(LitheTheme.primaryText) + .fixedSize(horizontal: false, vertical: true) + + Spacer(minLength: 4) + + Button { + model.dismissNotification(notification.id) + } label: { + Image(systemName: "xmark") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(LitheTheme.tertiaryText) + } + .buttonStyle(.plain) + .frame(width: 16, height: 16) + .contentShape(Rectangle()) + .litheRowHover(cornerRadius: LitheTheme.Metrics.cornerRadius, animation: nil) + .accessibilityLabel("Dismiss notification") + } + .padding(.leading, 12) + .padding(.trailing, 6) + .padding(.vertical, 10) + .frame(minWidth: 280, maxWidth: 360, alignment: .topLeading) + .background(LitheTheme.notificationBackground) + .clipShape(RoundedRectangle(cornerRadius: 7)) + .contentShape(RoundedRectangle(cornerRadius: 7)) + .onContinuousHover(coordinateSpace: .local) { phase in + if case .active = phase { + NSCursor.arrow.set() + } + } + } + } + .contentShape(Rectangle()) + .onHover { model.setNotificationStackHovered($0) } + .padding(.trailing, WorkbenchLayoutMetrics.rightActivityBarWidth + 12) + .padding(.bottom, 38) } } .overlay { @@ -487,11 +518,6 @@ struct WorkbenchView: View { value: .bounds ) { $0 } - Rectangle() - .fill(LitheTheme.divider) - .frame(width: 1, height: 20) - .padding(.horizontal, 5) - Button { updateSwitcherPresentation( project: false, @@ -1108,9 +1134,6 @@ struct WorkbenchView: View { } } } - Rectangle() - .fill(LitheTheme.divider) - .frame(width: 1) pluginActivityBar } .fixedSize(horizontal: true, vertical: false) @@ -1216,6 +1239,12 @@ struct WorkbenchView: View { WorkbenchWorkspaceSplitView( sidebarWidth: sidebarWidth, topPaneHeight: topPaneHeight, + sidebarPaneBackground: model.selectedSidebar == .changes + ? LitheTheme.toolHeader + : LitheTheme.editor, + minimumTopPaneHeight: model.selectedSidebar == .changes + ? WorkbenchWorkspaceMetrics.changesMinimumTopPaneHeight + : WorkbenchWorkspaceMetrics.minimumTopPaneHeight, isBottomToolVisible: isBottomToolVisible, onSidebarWidthCommitted: { width in sidebarWidth = width @@ -1495,10 +1524,6 @@ private struct WorkbenchNotificationCenterView: View { .padding(.horizontal, 14) .frame(height: 38) - Rectangle() - .fill(LitheTheme.divider) - .frame(height: 1) - if model.notifications.isEmpty { VStack(spacing: 8) { Image(systemName: "bell") @@ -1558,6 +1583,8 @@ private struct WorkbenchNotificationCenterView: View { private struct WorkbenchWorkspaceSplitView: View { let sidebarWidth: CGFloat let topPaneHeight: CGFloat? + let sidebarPaneBackground: Color + let minimumTopPaneHeight: CGFloat let isBottomToolVisible: Bool let onSidebarWidthCommitted: (CGFloat) -> Void let onTopPaneHeightCommitted: (CGFloat) -> Void @@ -1576,6 +1603,8 @@ private struct WorkbenchWorkspaceSplitView Void, onTopPaneHeightCommitted: @escaping (CGFloat) -> Void, @@ -1588,6 +1617,8 @@ private struct WorkbenchWorkspaceSplitView = [] @State private var expandedTreeRootPath: String? + @State private var contextMenuPath: String? var body: some View { VStack(spacing: 0) { sidebarHeader - Rectangle().fill(LitheTheme.divider).frame(height: 1) if model.isLoadingWorkspace { VStack(spacing: 10) { @@ -41,7 +41,8 @@ struct ProjectSidebarView: View { ), actions: ProjectTreeActions(model: model), expandedDirectoryPathsSnapshot: expandedDirectoryPaths, - expandedDirectoryPaths: $expandedDirectoryPaths + expandedDirectoryPaths: $expandedDirectoryPaths, + contextMenuPath: $contextMenuPath ) .equatable() } @@ -53,6 +54,7 @@ struct ProjectSidebarView: View { ) } .scrollContentBackground(.hidden) + .litheScrollViewChrome(usesCompactScrollers: true) .task( id: ProjectTreeTaskID( rootPath: root.url.standardizedFileURL.path, @@ -87,28 +89,6 @@ struct ProjectSidebarView: View { model.consumeProjectTreeRevealRequest(id: request.id) } } - .contextMenu { - Button("New File…") { - model.requestCreateFile(in: root.url) - } - Button("New Directory…") { - model.requestCreateDirectory(in: root.url) - } - Divider() - Button("Show Project in Finder") { - model.revealProjectItemInFinder(root.url) - } - Button("Show Project Local History…") { - model.showProjectLocalHistory() - } - Button("Copy Project Path") { - model.copyProjectItemPath(root.url, relative: false) - } - Divider() - Button("Refresh") { - Task { await model.refreshWorkspace() } - } - } } } } else if let error = model.workspaceLoadErrorMessage { @@ -271,6 +251,9 @@ private final class ProjectTreeActions: @unchecked Sendable { nonisolated func showLocalHistory(_ url: URL) { Task { @MainActor in self.model.showLocalHistory(for: url) } } + nonisolated func showProjectLocalHistory() { + Task { @MainActor in self.model.showProjectLocalHistory() } + } func javaIconKind(_ url: URL) async -> LitheIconKind? { await model.javaIconKind(for: url) } @@ -285,6 +268,7 @@ private struct ProjectFileTreeContent: View, Equatable { let actions: ProjectTreeActions let expandedDirectoryPathsSnapshot: Set @Binding var expandedDirectoryPaths: Set + @Binding var contextMenuPath: String? static func == (lhs: ProjectFileTreeContent, rhs: ProjectFileTreeContent) -> Bool { lhs.root == rhs.root @@ -293,6 +277,7 @@ private struct ProjectFileTreeContent: View, Equatable { && lhs.activeDocumentURL == rhs.activeDocumentURL && lhs.gitStatus == rhs.gitStatus && lhs.expandedDirectoryPathsSnapshot == rhs.expandedDirectoryPathsSnapshot + && lhs.contextMenuPath == rhs.contextMenuPath } var body: some View { @@ -304,7 +289,8 @@ private struct ProjectFileTreeContent: View, Equatable { activeDocumentURL: activeDocumentURL, gitStatus: gitStatus, actions: actions, - expandedDirectoryPaths: $expandedDirectoryPaths + expandedDirectoryPaths: $expandedDirectoryPaths, + contextMenuPath: $contextMenuPath ) .id(root.url.standardizedFileURL.path) } @@ -319,6 +305,7 @@ private struct FileNodeRow: View { let gitStatus: ProjectGitStatusSnapshot let actions: ProjectTreeActions @Binding var expandedDirectoryPaths: Set + @Binding var contextMenuPath: String? @State private var resolvedJavaIconKind: LitheIconKind? private var rowWidth: CGFloat { @@ -349,7 +336,8 @@ private struct FileNodeRow: View { activeDocumentURL: activeDocumentURL, gitStatus: gitStatus, actions: actions, - expandedDirectoryPaths: $expandedDirectoryPaths + expandedDirectoryPaths: $expandedDirectoryPaths, + contextMenuPath: $contextMenuPath ) .id(child.url.standardizedFileURL.path) } @@ -362,6 +350,7 @@ private struct FileNodeRow: View { private var directoryRow: some View { Button { + contextMenuPath = nil if isExpanded { expandedDirectoryPaths.remove(node.url.path) node.collapsedAncestorPaths.forEach { expandedDirectoryPaths.remove($0) } @@ -392,18 +381,24 @@ private struct FileNodeRow: View { .frame(height: rowHeight) .contentShape(Rectangle()) .litheRowHover( + isActive: contextMenuPath == node.url.standardizedFileURL.path, cornerRadius: LitheTheme.Metrics.projectTreeSelectionCornerRadius, + activeBackground: LitheTheme.subtleSelection, animation: nil ) } .buttonStyle(LitheTreeRowButtonStyle()) .lithePointer() .padding(.horizontal, LitheTheme.Metrics.projectTreeContentHorizontalInset) - .contextMenu { directoryContextMenu } + .litheContextMenu( + items: { directoryContextMenuItems }, + onRightClick: { contextMenuPath = node.url.standardizedFileURL.path } + ) } private var fileRow: some View { Button { + contextMenuPath = nil actions.openFile(node.url) } label: { HStack(spacing: 6) { @@ -431,7 +426,8 @@ private struct FileNodeRow: View { .contentShape(Rectangle()) .litheRowHover( isActive: activeDocumentURL?.standardizedFileURL.path - == node.url.standardizedFileURL.path, + == node.url.standardizedFileURL.path + || contextMenuPath == node.url.standardizedFileURL.path, cornerRadius: LitheTheme.Metrics.projectTreeSelectionCornerRadius, activeBackground: LitheTheme.subtleSelection, animation: nil @@ -440,106 +436,129 @@ private struct FileNodeRow: View { .buttonStyle(LitheTreeRowButtonStyle()) .lithePointer() .padding(.horizontal, LitheTheme.Metrics.projectTreeContentHorizontalInset) - .contextMenu { fileContextMenu } + .litheContextMenu( + items: { fileContextMenuItems }, + onRightClick: { contextMenuPath = node.url.standardizedFileURL.path } + ) .task(id: node.url.standardizedFileURL.path) { guard node.url.pathExtension.lowercased() == "java" else { return } resolvedJavaIconKind = await actions.javaIconKind(node.url) } } - @ViewBuilder - private var directoryContextMenu: some View { - if gitStatus.kind(for: node.url, isDirectory: true) != nil { - Button("Show Git Diff") { - actions.showGitDirectoryDiff(node.url) - } - Divider() - } - - Button("New File…") { - actions.requestCreateFile(node.url) - } - Button("New Directory…") { - actions.requestCreateDirectory(node.url) - } + private var directoryContextMenuItems: [LitheContextMenuItem] { + var items: [LitheContextMenuItem] = [] - Divider() + items += [ + .submenu("New", items: [ + .action("New File…", systemImage: "doc") { + actions.requestCreateFile(node.url) + }, + .action("New Directory…", systemImage: "folder") { + actions.requestCreateDirectory(node.url) + } + ]), + .separator + ] - 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) + if gitStatus.kind(for: node.url, isDirectory: true) != nil { + items += [ + .action("Show Git Diff", systemImage: "arrow.triangle.branch") { + actions.showGitDirectoryDiff(node.url) + }, + .separator + ] } - if depth > 0 { - Divider() - - Button("Duplicate") { - actions.duplicate(node.url) - } - Button("Rename…") { - actions.requestRename(node.url) - } - Button("Move to Trash", role: .destructive) { - actions.requestDelete(node.url, true) - } + if depth == 0 { + items += [ + .action("Show Project in Finder", systemImage: "folder") { + actions.revealInFinder(node.url) + }, + .action("Show Project Local History…", systemImage: "clock.arrow.circlepath") { + actions.showProjectLocalHistory() + }, + .action("Copy Project Path", systemImage: "doc.on.doc") { + actions.copyPath(node.url, relative: false) + }, + .action("Copy Relative Path") { + actions.copyPath(node.url, relative: true) + } + ] + } else { + items += [ + .action("Show in Finder", systemImage: "folder") { + actions.revealInFinder(node.url) + }, + .action("Copy Path", systemImage: "doc.on.doc") { + actions.copyPath(node.url, relative: false) + }, + .action("Copy Relative Path") { + actions.copyPath(node.url, relative: true) + }, + .separator, + .action("Duplicate") { + actions.duplicate(node.url) + }, + .action("Rename…") { + actions.requestRename(node.url) + }, + .action("Move to Trash", systemImage: "trash", role: .destructive) { + actions.requestDelete(node.url, true) + } + ] } - Divider() - - Button("Refresh") { - actions.refreshWorkspace() - } + items += [ + .separator, + .action("Refresh", systemImage: "arrow.clockwise") { + actions.refreshWorkspace() + } + ] + return items } - @ViewBuilder - private var fileContextMenu: some View { - Group { - Button("Open") { + private var fileContextMenuItems: [LitheContextMenuItem] { + var items: [LitheContextMenuItem] = [ + .action("Open") { actions.openFile(node.url) } + ] - if let change = gitStatus.change(for: node.url) { - Button("Show Git Diff") { + if let change = gitStatus.change(for: node.url) { + items += [ + .action("Show Git Diff", systemImage: "arrow.triangle.branch") { actions.selectChange(change) } - } + ] } - Divider() - - Group { - Button("Duplicate") { + items += [ + .separator, + .action("Duplicate") { actions.duplicate(node.url) - } - Button("Rename…") { + }, + .action("Rename…") { actions.requestRename(node.url) - } - Button("Local History…") { + }, + .action("Local History…", systemImage: "clock.arrow.circlepath") { actions.showLocalHistory(node.url) - } - Button("Move to Trash", role: .destructive) { + }, + .action("Move to Trash", systemImage: "trash", role: .destructive) { actions.requestDelete(node.url, false) - } - } - - Divider() - - Group { - Button("Show in Finder") { + }, + .separator, + .action("Show in Finder", systemImage: "folder") { actions.revealInFinder(node.url) - } - Button("Copy Path") { + }, + .action("Copy Path", systemImage: "doc.on.doc") { actions.copyPath(node.url, relative: false) - } - Button("Copy Relative Path") { + }, + .action("Copy Relative Path") { actions.copyPath(node.url, relative: true) } - } + ] + return items } private var gitStatusColor: Color? { diff --git a/macos/Tests/LitheCoreVerifier/main.swift b/macos/Tests/LitheCoreVerifier/CoreVerification.swift similarity index 100% rename from macos/Tests/LitheCoreVerifier/main.swift rename to macos/Tests/LitheCoreVerifier/CoreVerification.swift diff --git a/macos/Tests/LitheGitGraphVerifier/main.swift b/macos/Tests/LitheGitGraphVerifier/GitGraphVerification.swift similarity index 100% rename from macos/Tests/LitheGitGraphVerifier/main.swift rename to macos/Tests/LitheGitGraphVerifier/GitGraphVerification.swift diff --git a/macos/Tests/LitheOfficialPluginVerifier/main.swift b/macos/Tests/LitheOfficialPluginVerifier/OfficialPluginVerifier.swift similarity index 100% rename from macos/Tests/LitheOfficialPluginVerifier/main.swift rename to macos/Tests/LitheOfficialPluginVerifier/OfficialPluginVerifier.swift diff --git a/macos/Tests/LitheTests/ProjectTreeLayoutMetricsTests.swift b/macos/Tests/LitheTests/ProjectTreeLayoutMetricsTests.swift index 426d53431..fc8ec1b54 100644 --- a/macos/Tests/LitheTests/ProjectTreeLayoutMetricsTests.swift +++ b/macos/Tests/LitheTests/ProjectTreeLayoutMetricsTests.swift @@ -4,7 +4,7 @@ import Testing struct ProjectTreeLayoutMetricsTests { @Test func treeRowsMatchIntelliJNewUILayoutMetrics() { - #expect(LitheTheme.Metrics.projectTreeRowSpacing == 0) + #expect(LitheTheme.Metrics.projectTreeRowSpacing == 1) #expect(LitheTheme.Metrics.projectTreeContentVerticalInset == 4) #expect(LitheTheme.Metrics.projectTreeContentHorizontalInset == 12) #expect(LitheTheme.Metrics.projectTreeSelectionCornerRadius == 4) diff --git a/macos/Tests/LitheTests/WorkbenchNotificationTests.swift b/macos/Tests/LitheTests/WorkbenchNotificationTests.swift index 4608b5305..c50427b48 100644 --- a/macos/Tests/LitheTests/WorkbenchNotificationTests.swift +++ b/macos/Tests/LitheTests/WorkbenchNotificationTests.swift @@ -24,6 +24,15 @@ struct WorkbenchNotificationTests { #expect(model.notifications.first?.message == "Message 100") #expect(model.notifications.last?.message == "Message 1") #expect(model.notifications.allSatisfy { !$0.isRead }) + #expect(model.activeNotifications.map(\.message) == ["Message 98", "Message 99", "Message 100"]) + + model.setNotificationStackHovered(true) + model.showNotification("Message 101") + #expect(model.activeNotifications.map(\.message) == ["Message 99", "Message 100", "Message 101"]) + + let dismissedID = model.activeNotifications[1].id + model.dismissNotification(dismissedID) + #expect(model.activeNotifications.map(\.message) == ["Message 99", "Message 101"]) model.markAllNotificationsRead() #expect(model.notifications.allSatisfy { $0.isRead }) diff --git a/macos/Tests/LitheTests/WorkbenchRenderingSafetyTests.swift b/macos/Tests/LitheTests/WorkbenchRenderingSafetyTests.swift index 4f29f9f54..fa06644d9 100644 --- a/macos/Tests/LitheTests/WorkbenchRenderingSafetyTests.swift +++ b/macos/Tests/LitheTests/WorkbenchRenderingSafetyTests.swift @@ -5,12 +5,8 @@ import Testing @Suite("Workbench rendering safety") struct WorkbenchRenderingSafetyTests { @Test - func workspaceReservesTheRightActivityBarAndItsDivider() { - #expect( - WorkbenchLayoutMetrics.workspaceTrailingInset - == WorkbenchLayoutMetrics.rightActivityBarWidth - + WorkbenchLayoutMetrics.rightActivityBarDividerWidth - ) + func workspaceReservesTheRightActivityBar() { + #expect(WorkbenchLayoutMetrics.workspaceTrailingInset == WorkbenchLayoutMetrics.rightActivityBarWidth) } @Test