From 993b7fff8ee733056fbda0ab90864783b48192d1 Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Mon, 17 Aug 2026 10:55:50 +0800 Subject: [PATCH 1/4] ci: allow unsigned Windows official installers Skip Authenticode import and signing when the certificate secrets are absent so a v*.*.* tag can still publish the NSIS installer. --- .github/workflows/release-windows.yml | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release-windows.yml b/.github/workflows/release-windows.yml index 94dba0d9..d490a861 100644 --- a/.github/workflows/release-windows.yml +++ b/.github/workflows/release-windows.yml @@ -59,13 +59,16 @@ jobs: run: ./scripts/build-windows.ps1 -Configuration Release - name: Import Authenticode certificate + id: signing shell: pwsh env: WINDOWS_SIGNING_CERTIFICATE_BASE64: ${{ secrets.WINDOWS_SIGNING_CERTIFICATE_BASE64 }} WINDOWS_SIGNING_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_SIGNING_CERTIFICATE_PASSWORD }} run: | if ([string]::IsNullOrWhiteSpace($env:WINDOWS_SIGNING_CERTIFICATE_BASE64)) { - throw "WINDOWS_SIGNING_CERTIFICATE_BASE64 is required for a signed Windows release." + Write-Output "No Authenticode certificate is configured; the Windows installer will be unsigned." + "signed=false" >> $env:GITHUB_OUTPUT + exit 0 } $path = Join-Path $env:RUNNER_TEMP "lithe-signing.pfx" [System.IO.File]::WriteAllBytes( @@ -76,13 +79,25 @@ jobs: -CertStoreLocation Cert:\CurrentUser\My -Password $password if ($null -eq $certificate) { throw "Could not import the Authenticode certificate." } "LITHE_WINDOWS_CERTIFICATE_THUMBPRINT=$($certificate.Thumbprint)" >> $env:GITHUB_ENV + "signed=true" >> $env:GITHUB_OUTPUT - name: Package Windows installer shell: pwsh env: LITHE_VERSION: ${{ steps.version.outputs.version }} LITHE_WINDOWS_TIMESTAMP_SERVER: ${{ secrets.WINDOWS_TIMESTAMP_SERVER }} - run: ./scripts/package-windows.ps1 -Configuration Release -Version $env:LITHE_VERSION -RequireAuthenticodeSignature + WINDOWS_RELEASE_SIGNED: ${{ steps.signing.outputs.signed }} + run: | + $packageArgs = @( + "-Configuration", "Release", + "-Version", $env:LITHE_VERSION + ) + if ($env:WINDOWS_RELEASE_SIGNED -eq "true") { + $packageArgs += "-RequireAuthenticodeSignature" + } else { + Write-Output "Packaging an unsigned Windows installer." + } + ./scripts/package-windows.ps1 @packageArgs - name: Verify installer checksum shell: pwsh From 30329f08d72ed08edf48f032aa5ed5d552e844f7 Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Mon, 17 Aug 2026 10:59:34 +0800 Subject: [PATCH 2/4] ci: publish a rolling Windows preview installer Add a scheduled preview workflow that packages the Windows NSIS installer from preview/0.3.0 and uploads it to the shared preview release. Signing stays optional when no certificate is configured. --- .github/workflows/release-preview-windows.yml | 137 ++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 .github/workflows/release-preview-windows.yml diff --git a/.github/workflows/release-preview-windows.yml b/.github/workflows/release-preview-windows.yml new file mode 100644 index 00000000..c34fe0e9 --- /dev/null +++ b/.github/workflows/release-preview-windows.yml @@ -0,0 +1,137 @@ +name: Release Windows Preview + +on: + schedule: + # Offset from the macOS preview so the shared rolling tag usually exists first. + - cron: "27 3 * * *" + workflow_dispatch: + inputs: + source_branch: + description: "Preview branch to build" + required: true + default: "preview/0.3.0" + type: string + +permissions: + contents: read + +concurrency: + group: release-preview-windows + cancel-in-progress: false + +env: + PREVIEW_BRANCH: preview/0.3.0 + PREVIEW_VERSION: 0.3.0 + PREVIEW_TAG: preview-0.3.0 + +jobs: + release: + name: Build and publish Windows preview + if: github.actor == github.repository_owner || github.event_name == 'schedule' + runs-on: windows-latest + timeout-minutes: 60 + permissions: + contents: write + + steps: + - name: Check out preview source + uses: actions/checkout@v4 + with: + ref: ${{ github.event_name == 'workflow_dispatch' && inputs.source_branch || env.PREVIEW_BRANCH }} + + - name: Record source revision + id: source + shell: pwsh + run: | + $sha = (git rev-parse HEAD).Trim() + "sha=$sha" >> $env:GITHUB_OUTPUT + + - name: Set up Rust + uses: dtolnay/rust-toolchain@stable + with: + targets: x86_64-pc-windows-msvc + + - name: Set up Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: "1.3.12" + + - name: Build Windows Tauri application + shell: pwsh + run: ./scripts/build-windows.ps1 -Configuration Release + + - name: Import Authenticode certificate + id: signing + shell: pwsh + env: + WINDOWS_SIGNING_CERTIFICATE_BASE64: ${{ secrets.WINDOWS_SIGNING_CERTIFICATE_BASE64 }} + WINDOWS_SIGNING_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_SIGNING_CERTIFICATE_PASSWORD }} + run: | + if ([string]::IsNullOrWhiteSpace($env:WINDOWS_SIGNING_CERTIFICATE_BASE64)) { + Write-Output "No Authenticode certificate is configured; the Windows preview installer will be unsigned." + "signed=false" >> $env:GITHUB_OUTPUT + exit 0 + } + $path = Join-Path $env:RUNNER_TEMP "lithe-signing.pfx" + [System.IO.File]::WriteAllBytes( + $path, + [Convert]::FromBase64String($env:WINDOWS_SIGNING_CERTIFICATE_BASE64)) + $password = ConvertTo-SecureString $env:WINDOWS_SIGNING_CERTIFICATE_PASSWORD -AsPlainText -Force + $certificate = Import-PfxCertificate -FilePath $path ` + -CertStoreLocation Cert:\CurrentUser\My -Password $password + if ($null -eq $certificate) { throw "Could not import the Authenticode certificate." } + "LITHE_WINDOWS_CERTIFICATE_THUMBPRINT=$($certificate.Thumbprint)" >> $env:GITHUB_ENV + "signed=true" >> $env:GITHUB_OUTPUT + + - name: Package Windows installer + shell: pwsh + env: + LITHE_VERSION: ${{ env.PREVIEW_VERSION }} + LITHE_WINDOWS_TIMESTAMP_SERVER: ${{ secrets.WINDOWS_TIMESTAMP_SERVER }} + WINDOWS_RELEASE_SIGNED: ${{ steps.signing.outputs.signed }} + run: | + $packageArgs = @( + "-Configuration", "Release", + "-Version", $env:LITHE_VERSION + ) + if ($env:WINDOWS_RELEASE_SIGNED -eq "true") { + $packageArgs += "-RequireAuthenticodeSignature" + } else { + Write-Output "Packaging an unsigned Windows preview installer." + } + ./scripts/package-windows.ps1 @packageArgs + + - name: Verify installer checksum + shell: pwsh + run: | + $installer = "dist/Lithe-${{ env.PREVIEW_VERSION }}-windows-x64.exe" + $expected = ((Get-Content "$installer.sha256" -Raw) -split '\s+')[0] + $actual = (Get-FileHash -Algorithm SHA256 $installer).Hash.ToLowerInvariant() + if ($expected -ne $actual) { throw "Installer checksum mismatch" } + + - name: Create or update rolling preview Release + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ env.PREVIEW_TAG }} + LITHE_VERSION: ${{ env.PREVIEW_VERSION }} + SOURCE_SHA: ${{ steps.source.outputs.sha }} + shell: pwsh + run: | + $notes = "Rolling Preview build from $env:SOURCE_SHA. This release is replaced by the next scheduled build. The Windows installer may be unsigned until an Authenticode certificate is configured." + $existing = gh release view $env:RELEASE_TAG --repo $env:GITHUB_REPOSITORY 2>$null + if ($LASTEXITCODE -ne 0) { + gh release create $env:RELEASE_TAG ` + --repo $env:GITHUB_REPOSITORY ` + --target $env:SOURCE_SHA ` + --title "Lithe $env:LITHE_VERSION Preview" ` + --notes $notes ` + --prerelease + if ($LASTEXITCODE -ne 0) { + $existing = gh release view $env:RELEASE_TAG --repo $env:GITHUB_REPOSITORY 2>$null + if ($LASTEXITCODE -ne 0) { throw "Could not create or find preview release $env:RELEASE_TAG" } + } + } + gh release upload $env:RELEASE_TAG ` + "dist/Lithe-$env:LITHE_VERSION-windows-x64.exe" ` + "dist/Lithe-$env:LITHE_VERSION-windows-x64.exe.sha256" ` + --repo $env:GITHUB_REPOSITORY --clobber From 280ebac4caa66f7d5d587e3b9bbd8ca89961e488 Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Mon, 17 Aug 2026 11:45:29 +0800 Subject: [PATCH 3/4] fix(ci): bind Windows packaging arguments by name --- .github/workflows/release-preview-windows.yml | 10 +++++----- .github/workflows/release-windows.yml | 10 +++++----- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/workflows/release-preview-windows.yml b/.github/workflows/release-preview-windows.yml index c34fe0e9..5b3b5274 100644 --- a/.github/workflows/release-preview-windows.yml +++ b/.github/workflows/release-preview-windows.yml @@ -90,12 +90,12 @@ jobs: LITHE_WINDOWS_TIMESTAMP_SERVER: ${{ secrets.WINDOWS_TIMESTAMP_SERVER }} WINDOWS_RELEASE_SIGNED: ${{ steps.signing.outputs.signed }} run: | - $packageArgs = @( - "-Configuration", "Release", - "-Version", $env:LITHE_VERSION - ) + $packageArgs = @{ + Configuration = "Release" + Version = $env:LITHE_VERSION + } if ($env:WINDOWS_RELEASE_SIGNED -eq "true") { - $packageArgs += "-RequireAuthenticodeSignature" + $packageArgs.RequireAuthenticodeSignature = $true } else { Write-Output "Packaging an unsigned Windows preview installer." } diff --git a/.github/workflows/release-windows.yml b/.github/workflows/release-windows.yml index d490a861..66106277 100644 --- a/.github/workflows/release-windows.yml +++ b/.github/workflows/release-windows.yml @@ -88,12 +88,12 @@ jobs: LITHE_WINDOWS_TIMESTAMP_SERVER: ${{ secrets.WINDOWS_TIMESTAMP_SERVER }} WINDOWS_RELEASE_SIGNED: ${{ steps.signing.outputs.signed }} run: | - $packageArgs = @( - "-Configuration", "Release", - "-Version", $env:LITHE_VERSION - ) + $packageArgs = @{ + Configuration = "Release" + Version = $env:LITHE_VERSION + } if ($env:WINDOWS_RELEASE_SIGNED -eq "true") { - $packageArgs += "-RequireAuthenticodeSignature" + $packageArgs.RequireAuthenticodeSignature = $true } else { Write-Output "Packaging an unsigned Windows installer." } From da396eaa20ab78982b433065ccdb57405766cef4 Mon Sep 17 00:00:00 2001 From: arkleselect Date: Tue, 18 Aug 2026 09:48:47 +0800 Subject: [PATCH 4/4] =?UTF-8?q?feat:=20=E5=AE=8C=E5=96=84=E5=8D=95?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E7=BC=96=E8=BE=91=E5=99=A8=E5=92=8C=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E5=99=A8=E4=BA=A4=E4=BA=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Resources/Info.plist | 40 +++ .../Features/DocumentFeatureModel.swift | 122 +++++++ Sources/Lithe/LitheApp.swift | 80 ++++- .../AppModel/AppModel+FeatureState.swift | 3 + Sources/Lithe/Models/AppModel/AppModel.swift | 41 ++- .../Workspace/ProjectSessionManager.swift | 29 ++ .../Workspace/WorkspaceTextFilePolicy.swift | 2 + Sources/Lithe/Theme/LitheTheme.swift | 39 ++- Sources/Lithe/Views/App/RootView.swift | 51 ++- Sources/Lithe/Views/App/WelcomeView.swift | 4 +- .../Views/Components/LitheContextMenu.swift | 303 ++++++++++++++++++ .../Components/LitheToolWindowHeader.swift | 6 +- .../Lithe/Views/Editor/CodeEditorView.swift | 218 +++++++++++-- .../Views/Editor/StandaloneEditorView.swift | 120 +++++++ .../Views/Workbench/SplitHandleView.swift | 75 +++-- .../Lithe/Views/Workbench/WorkbenchView.swift | 25 +- .../Views/Workspace/ProjectSidebarView.swift | 189 ++++++----- Tests/LitheTests/LitheCoreLogicTests.swift | 99 +++++- 18 files changed, 1276 insertions(+), 170 deletions(-) create mode 100644 Sources/Lithe/Views/Components/LitheContextMenu.swift create mode 100644 Sources/Lithe/Views/Editor/StandaloneEditorView.swift diff --git a/Resources/Info.plist b/Resources/Info.plist index 2d67ff9c..4a9ee123 100644 --- a/Resources/Info.plist +++ b/Resources/Info.plist @@ -23,6 +23,46 @@ Lithe CFBundleIconFile AppIcon + CFBundleDocumentTypes + + + CFBundleTypeName + Plain Text Document + CFBundleTypeRole + Editor + LSHandlerRank + Alternate + LSItemContentTypes + + public.text + public.plain-text + public.source-code + net.daringfireball.markdown + + CFBundleTypeExtensions + + txt + md + markdown + java + json + xml + yaml + yml + toml + rs + swift + kt + js + ts + tsx + jsx + css + html + sql + + + CFBundleIdentifier app.lithe.desktop CFBundleInfoDictionaryVersion diff --git a/Sources/Lithe/Application/Features/DocumentFeatureModel.swift b/Sources/Lithe/Application/Features/DocumentFeatureModel.swift index 20815318..1ac12f9b 100644 --- a/Sources/Lithe/Application/Features/DocumentFeatureModel.swift +++ b/Sources/Lithe/Application/Features/DocumentFeatureModel.swift @@ -1,12 +1,53 @@ import Combine import Foundation +enum StandaloneFileOpenFailure: Error, Equatable { + case unavailable + case directory + case tooLarge + case notText + case readFailed + + var title: String { + switch self { + case .unavailable: "File is not available" + case .directory: "Folders cannot be opened as text" + case .tooLarge: "File is too large to open" + case .notText: "This file cannot be displayed as text" + case .readFailed: "Could not read this file" + } + } + + var detail: String { + switch self { + case .unavailable: + "The file no longer exists or Lithe does not have access to it." + case .directory: + "Open a text file instead of a folder." + case .tooLarge: + "Standalone text files are limited to 32 MB." + case .notText: + "Only UTF-8 text files are supported in the standalone editor." + case .readFailed: + "The file could not be read. Check its permissions and try again." + } + } +} + +enum StandaloneFileLoadState: Equatable { + case idle + case loading + case loaded + case failed(StandaloneFileOpenFailure) +} + /// Owns editor document lifecycle and persistence-facing state. Java services, /// local history, and UI notifications are supplied as callbacks by AppModel. @MainActor final class DocumentFeatureModel: ObservableObject { @Published private(set) var openDocuments: [EditorDocument] = [] @Published var activeDocumentID: UUID? + @Published private(set) var standaloneFileLoadState: StandaloneFileLoadState = .idle @Published private(set) var pendingCloseDocument: EditorDocument? @Published private(set) var isPendingProjectClose = false @@ -31,6 +72,8 @@ final class DocumentFeatureModel: ObservableObject { private var latestFileOpenRequestID: UUID? private var pendingCloseQueue: [EditorDocument] = [] private var pendingClosePreferredDocumentID: UUID? + private var standaloneOpenRequestID: UUID? + private var standaloneOpenTask: Task? init( operations: any WorkspaceOperations, @@ -82,6 +125,9 @@ final class DocumentFeatureModel: ObservableObject { } func reset() { + standaloneOpenTask?.cancel() + standaloneOpenTask = nil + standaloneOpenRequestID = nil autoSaveTasks.values.forEach { $0.cancel() } autoSaveTasks.removeAll() pendingFileOpenRequests.removeAll() @@ -92,6 +138,7 @@ final class DocumentFeatureModel: ObservableObject { isPendingProjectClose = false openDocuments = [] activeDocumentID = nil + standaloneFileLoadState = .idle } func openFile( @@ -120,6 +167,81 @@ final class DocumentFeatureModel: ObservableObject { ) } } + func openStandaloneFile(_ url: URL) { + let normalizedURL = url.standardizedFileURL + if let existing = openDocuments.first(where: { $0.url == normalizedURL }) { + activeDocumentID = existing.id + standaloneFileLoadState = .loaded + return + } + + standaloneOpenTask?.cancel() + let requestID = UUID() + standaloneOpenRequestID = requestID + standaloneFileLoadState = .loading + openDocuments = [] + activeDocumentID = nil + let fileStorage = self.fileStorage + standaloneOpenTask = Task { [weak self] in + guard let self else { return } + let result = await Task.detached(priority: .userInitiated) { + Self.readStandaloneFile(at: normalizedURL, using: fileStorage) + }.value + + guard self.standaloneOpenRequestID == requestID else { return } + self.standaloneOpenTask = nil + + guard case let .success(text) = result else { + if case let .failure(failure) = result { + self.standaloneFileLoadState = .failed(failure) + } + return + } + + let document = EditorDocument( + url: normalizedURL, + text: text, + modificationDate: EditorDocument.modificationDate(for: normalizedURL), + isReadOnly: false + ) + self.openDocuments = [document] + self.activeDocumentID = document.id + self.standaloneFileLoadState = .loaded + self.onDocumentCollectionChanged?() + self.onDocumentOpened?(document) + } + } + + nonisolated private static func readStandaloneFile( + at url: URL, + using fileStorage: any FileStorage + ) -> Result { + guard let metadata = fileStorage.metadata(for: url) else { + return .failure(.unavailable) + } + guard !metadata.isDirectory else { return .failure(.directory) } + guard metadata.isRegularFile else { return .failure(.unavailable) } + if let byteCount = metadata.byteCount, + byteCount > WorkspaceTextFilePolicy.standaloneFileByteLimit { + return .failure(.tooLarge) + } + + let data: Data + do { + data = try fileStorage.readData(from: url, options: []) + } catch { + return .failure(.readFailed) + } + guard data.count <= WorkspaceTextFilePolicy.standaloneFileByteLimit else { + return .failure(.tooLarge) + } + guard let text = String(data: data, encoding: .utf8), + WorkspaceTextFilePolicy.isPlainText(text) else { + return .failure(.notText) + } + return .success(text) + } + func openFileAsync( _ normalizedURL: URL, isReadOnly: Bool, diff --git a/Sources/Lithe/LitheApp.swift b/Sources/Lithe/LitheApp.swift index b5d01dfc..ce0a5ebb 100644 --- a/Sources/Lithe/LitheApp.swift +++ b/Sources/Lithe/LitheApp.swift @@ -5,7 +5,15 @@ private let litheProcessLaunchDate = Date() @MainActor final class LitheAppDelegate: NSObject, NSApplicationDelegate { - weak var projectSessions: ProjectSessionManager? + private var pendingFileURLs: [URL] = [] + weak var projectSessions: ProjectSessionManager? { + didSet { + guard let projectSessions else { return } + let pendingURLs = pendingFileURLs + pendingFileURLs.removeAll() + pendingURLs.forEach { projectSessions.openStandaloneFile($0) } + } + } var recordCleanPluginShutdown: (() -> Void)? var authorizationCallbackRouter: MacExternalAuthorizationCallbackRouter? @@ -13,12 +21,27 @@ final class LitheAppDelegate: NSObject, NSApplicationDelegate { true } + func applicationWillFinishLaunching(_ notification: Notification) { + // SwiftUI normally forwards this event to the delegate methods below, + // but older Finder/AppKit launch paths can bypass that forwarding. + NSAppleEventManager.shared().setEventHandler( + self, + andSelector: #selector(handleOpenDocuments(_:withReplyEvent:)), + forEventClass: AEEventClass(kCoreEventClass), + andEventID: AEEventID(kAEOpenDocuments) + ) + } + func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { guard let projectSessions else { return .terminateNow } return Self.confirmUnsavedDocuments(for: projectSessions) ? .terminateNow : .terminateCancel } func applicationWillTerminate(_ notification: Notification) { + NSAppleEventManager.shared().removeEventHandler( + forEventClass: AEEventClass(kCoreEventClass), + andEventID: AEEventID(kAEOpenDocuments) + ) projectSessions?.stopAllSessions() recordCleanPluginShutdown?() } @@ -29,7 +52,55 @@ final class LitheAppDelegate: NSObject, NSApplicationDelegate { } func application(_ application: NSApplication, open urls: [URL]) { - urls.forEach { authorizationCallbackRouter?.route($0) } + handleOpenedURLs(urls) + } + + // Finder can deliver document-open Apple Events through these older + // delegate methods, depending on whether the app was already running. + func application(_ sender: NSApplication, openFile filename: String) -> Bool { + handleOpenedURLs([URL(fileURLWithPath: filename)]) + return true + } + + func application(_ sender: NSApplication, openFiles filenames: [String]) { + handleOpenedURLs(filenames.map(URL.init(fileURLWithPath:))) + sender.reply(toOpenOrPrint: .success) + } + + @objc private func handleOpenDocuments( + _ event: NSAppleEventDescriptor, + withReplyEvent replyEvent: NSAppleEventDescriptor? + ) { + guard let fileList = event.paramDescriptor(forKeyword: keyDirectObject) else { return } + + var urls: [URL] = [] + guard fileList.numberOfItems > 0 else { return } + for index in 1...fileList.numberOfItems { + guard let aliasDescriptor = fileList.atIndex(index), + let fileURLDescriptor = aliasDescriptor.coerce(toDescriptorType: typeFileURL), + let url = URL(dataRepresentation: fileURLDescriptor.data, relativeTo: nil) else { + continue + } + urls.append(url) + } + + handleOpenedURLs(urls) + } + + private func handleOpenedURLs(_ urls: [URL]) { + for url in urls { + if url.scheme == "lithe" { + authorizationCallbackRouter?.route(url) + } else if url.isFileURL { + if let projectSessions { + projectSessions.openStandaloneFile(url) + } else if !pendingFileURLs.contains(where: { + $0.standardizedFileURL == url.standardizedFileURL + }) { + pendingFileURLs.append(url.standardizedFileURL) + } + } + } } static func confirmUnsavedDocuments(for projectSessions: ProjectSessionManager) -> Bool { @@ -159,6 +230,11 @@ struct LitheApp: App { } .litheKeyboardShortcut(model.keyboardShortcutFeature.primaryKeyPress(for: "close-project")) .disabled(model.workspaceURL == nil) + + Button("Close File") { + model.closeStandaloneFile() + } + .disabled(model.standaloneFileURL == nil) } CommandGroup(replacing: .appSettings) { diff --git a/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift b/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift index 0f8b0548..a9878474 100644 --- a/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift +++ b/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift @@ -28,6 +28,9 @@ extension AppModel { } var openDocuments: [EditorDocument] { documentFeature.openDocuments } + var standaloneFileLoadState: StandaloneFileLoadState { + documentFeature.standaloneFileLoadState + } var activeDocumentID: UUID? { get { documentFeature.activeDocumentID } set { diff --git a/Sources/Lithe/Models/AppModel/AppModel.swift b/Sources/Lithe/Models/AppModel/AppModel.swift index c4eff7bb..aa290b83 100644 --- a/Sources/Lithe/Models/AppModel/AppModel.swift +++ b/Sources/Lithe/Models/AppModel/AppModel.swift @@ -40,6 +40,7 @@ enum SettingsCategory: String, CaseIterable, Identifiable { final class AppModel: ObservableObject, Identifiable { let id = UUID() @Published private(set) var workspaceURL: URL? + @Published private(set) var standaloneFileURL: URL? @Published var selectedSidebar: SidebarDestination = .project { didSet { if selectedSidebar == .changes, oldValue != .changes { @@ -600,10 +601,16 @@ final class AppModel: ObservableObject, Identifiable { self?.withHistoryModule { $0.recordExternalChanges(paths) } }, onDocumentCollectionChanged: { [weak self] in - self?.workspaceFeature.scheduleWorkspaceSessionPersistence() + guard let self, self.workspaceURL != nil else { return } + self.workspaceFeature.scheduleWorkspaceSessionPersistence() }, onProjectCloseReady: { [weak self] in - self?.performCloseProject() + guard let self else { return } + if self.workspaceURL != nil { + self.performCloseProject() + } else if self.standaloneFileURL != nil { + self.performCloseStandaloneFile() + } } ) documentFeatureObservation = documentFeature.objectWillChange.sink { [weak self] _ in @@ -969,6 +976,7 @@ final class AppModel: ObservableObject, Identifiable { gitLogSearchQuery = "" projectHistoryFeatureIfActive?.reset() workspaceURL = normalizedURL + standaloneFileURL = nil let visibilityRules = settings.fileVisibilityRules workspaceFeature.beginWorkspace(at: normalizedURL, visibilityRules: visibilityRules) selectedSidebar = .project @@ -997,6 +1005,14 @@ final class AppModel: ObservableObject, Identifiable { } } + func closeStandaloneFile() { + guard standaloneFileURL != nil else { return } + guard documentFeature.beginProjectClose() else { + performCloseStandaloneFile() + return + } + } + private func performCloseProject() { Task { [weak self] in guard let self else { return } @@ -1010,6 +1026,7 @@ final class AppModel: ObservableObject, Identifiable { } stopAccessingWorkspace() workspaceURL = nil + standaloneFileURL = nil reloadLanguageProviderCatalog(for: nil) selectedSidebar = .project workspaceFeature.reset() @@ -1060,6 +1077,16 @@ final class AppModel: ObservableObject, Identifiable { didCloseProject?() } + private func performCloseStandaloneFile() { + standaloneFileURL = nil + documentFeature.reset() + isFindBarVisible = false + findBarQuery = "" + findMatchCount = 0 + currentFindMatchIndex = 0 + didCloseProject?() + } + private func stopAccessingWorkspace() { guard let securityScopedWorkspaceURL else { return } platformUI.stopAccessingProject(securityScopedWorkspaceURL) @@ -1096,6 +1123,16 @@ final class AppModel: ObservableObject, Identifiable { documentFeature.openFile(url, isReadOnly: isReadOnly, displayPath: displayPath) } + func openStandaloneFile(_ url: URL) { + let normalizedURL = url.standardizedFileURL + workspaceURL = nil + standaloneFileURL = normalizedURL + documentFeature.reset() + isFindBarVisible = false + findBarQuery = "" + documentFeature.openStandaloneFile(normalizedURL) + } + func javaIconKind(for url: URL) async -> LitheIconKind? { await JavaFileIconResolver.resolve(for: url, storage: services.fileStorage) } diff --git a/Sources/Lithe/Models/Workspace/ProjectSessionManager.swift b/Sources/Lithe/Models/Workspace/ProjectSessionManager.swift index 51f39b11..86f90795 100644 --- a/Sources/Lithe/Models/Workspace/ProjectSessionManager.swift +++ b/Sources/Lithe/Models/Workspace/ProjectSessionManager.swift @@ -65,6 +65,20 @@ final class ProjectSessionManager: ObservableObject { refreshRecentProjects() } + func openStandaloneFile(_ url: URL) { + let model: AppModel + if activeModel.workspaceURL == nil && activeModel.standaloneFileURL == nil { + model = activeModel + } else { + activeModel.setProjectSessionActive(false) + model = modelFactory() + sessions.append(model) + configure(model) + activeSessionID = model.id + } + model.openStandaloneFile(url.standardizedFileURL) + } + func requestOpenProject(_ url: URL, from sourceSessionID: UUID) { let normalizedURL = url.standardizedFileURL if let existing = openProjects.first(where: { @@ -130,6 +144,21 @@ final class ProjectSessionManager: ObservableObject { activeModel.closeProject() } + func requestCloseActiveSession() -> Bool { + if activeModel.workspaceURL != nil { + closeActiveProject() + return false + } + if activeModel.standaloneFileURL != nil { + if activeModel.hasUnsavedDocuments { + activeModel.closeStandaloneFile() + return false + } + return true + } + return true + } + func closeProject(_ id: UUID) { guard sessions.contains(where: { $0.id == id }) else { return } if id != activeSessionID { diff --git a/Sources/Lithe/Models/Workspace/WorkspaceTextFilePolicy.swift b/Sources/Lithe/Models/Workspace/WorkspaceTextFilePolicy.swift index b111bbaa..c49ab530 100644 --- a/Sources/Lithe/Models/Workspace/WorkspaceTextFilePolicy.swift +++ b/Sources/Lithe/Models/Workspace/WorkspaceTextFilePolicy.swift @@ -1,6 +1,8 @@ import Foundation enum WorkspaceTextFilePolicy { + static let standaloneFileByteLimit = 32 * 1024 * 1024 + private static let extensions: Set = [ "c", "cc", "cpp", "css", "go", "h", "hpp", "html", "java", "js", "json", "jsx", "kt", "kts", "md", "m", "mm", "php", "plist", "properties", "py", "rb", diff --git a/Sources/Lithe/Theme/LitheTheme.swift b/Sources/Lithe/Theme/LitheTheme.swift index 556b23c4..c69cdb83 100644 --- a/Sources/Lithe/Theme/LitheTheme.swift +++ b/Sources/Lithe/Theme/LitheTheme.swift @@ -45,6 +45,7 @@ enum LitheTheme { let window: RGBA let titlebar: RGBA let toolHeader: RGBA + let toolHeaderInactive: RGBA let sidebar: RGBA let editor: RGBA let raised: RGBA @@ -67,6 +68,8 @@ enum LitheTheme { let primaryText: RGBA let secondaryText: RGBA let tertiaryText: RGBA + let toolWindowText: RGBA + let toolWindowSelectedText: RGBA let accent: RGBA let runAction: RGBA let success: RGBA @@ -131,6 +134,7 @@ enum LitheTheme { window: surface, titlebar: surface.mixed(with: ink, amount: strongChromeAmount), toolHeader: surface.mixed(with: ink, amount: chromeAmount), + toolHeaderInactive: surface.mixed(with: ink, amount: strongChromeAmount), sidebar: isDark ? surface.mixed(with: RGBA(0x000000), amount: 0.10) : surface.mixed(with: ink, amount: chromeAmount), @@ -157,6 +161,8 @@ enum LitheTheme { primaryText: ink, secondaryText: ink.withAlpha(isDark ? 0.62 : 0.60), tertiaryText: ink.withAlpha(isDark ? 0.43 : 0.42), + toolWindowText: ink, + toolWindowSelectedText: RGBA(0xffffff), accent: accent, runAction: isDark ? RGBA(0x59a869) : RGBA(0x2e7d32), success: diffAdded, @@ -177,31 +183,34 @@ enum LitheTheme { } return Palette( - window: adaptive(light: (0.965, 0.969, 0.976, 1), dark: (0.106, 0.113, 0.125, 1)), - titlebar: adaptive(light: (0.925, 0.933, 0.945, 1), dark: (0.145, 0.155, 0.169, 1)), - toolHeader: adaptive(light: (0.945, 0.949, 0.957, 1), dark: (0.122, 0.130, 0.142, 1)), - sidebar: adaptive(light: (0.925, 0.933, 0.945, 1), dark: (0.090, 0.096, 0.106, 1)), - editor: adaptive(light: (1, 1, 1, 1), dark: (0.074, 0.079, 0.088, 1)), + window: adaptive(light: (0.965, 0.969, 0.976, 1), dark: (0.157, 0.161, 0.173, 1)), + titlebar: adaptive(light: (0.925, 0.933, 0.945, 1), dark: (0.157, 0.161, 0.173, 1)), + toolHeader: adaptive(light: (0.925, 0.933, 0.945, 1), dark: (0.157, 0.161, 0.173, 1)), + toolHeaderInactive: adaptive(light: (0.902, 0.910, 0.925, 1), dark: (0.224, 0.231, 0.251, 1)), + sidebar: adaptive(light: (0.925, 0.933, 0.945, 1), dark: (0.157, 0.161, 0.173, 1)), + editor: adaptive(light: (1, 1, 1, 1), dark: (0.110, 0.114, 0.122, 1)), raised: adaptive(light: (1, 1, 1, 1), dark: (0.165, 0.175, 0.190, 1)), - selection: adaptive(light: (0.205, 0.435, 0.765, 1), dark: (0.170, 0.290, 0.490, 1)), + selection: adaptive(light: (0.275, 0.455, 0.945, 1), dark: (0.208, 0.455, 0.941, 1)), subtleSelection: adaptive(light: (0.855, 0.902, 0.973, 1), dark: (0.205, 0.218, 0.238, 1)), hoverBackground: adaptive(light: (0, 0, 0, 0.050), dark: (1, 1, 1, 0.055)), pressedBackground: adaptive(light: (0, 0, 0, 0.090), dark: (1, 1, 1, 0.095)), - activeTabBackground: adaptive(light: (1, 1, 1, 1), dark: (0.145, 0.155, 0.170, 1)), + activeTabBackground: adaptive(light: (1, 1, 1, 1), dark: (0.110, 0.114, 0.122, 1)), tabUnderline: adaptive(light: (0.180, 0.425, 0.790, 1), dark: (0.31, 0.58, 0.98, 1)), diffInformationBackground: adaptive(light: (0.895, 0.935, 0.990, 1), dark: (0.13, 0.20, 0.30, 1)), diffInformationText: adaptive(light: (0.105, 0.365, 0.680, 1), dark: (0.50, 0.72, 0.98, 1)), - divider: adaptive(light: (0, 0, 0, 0.100), dark: (1, 1, 1, 0.075)), - panelBorder: adaptive(light: (0, 0, 0, 0.145), dark: (1, 1, 1, 0.13)), + divider: adaptive(light: (0, 0, 0, 0.100), dark: (0.180, 0.188, 0.212, 1)), + panelBorder: adaptive(light: (0, 0, 0, 0.145), dark: (0.263, 0.271, 0.290, 1)), inputBackground: adaptive(light: (1, 1, 1, 1), dark: (0.065, 0.070, 0.078, 1)), inputBorder: adaptive(light: (0, 0, 0, 0.150), dark: (1, 1, 1, 0.12)), inputFocusBorder: adaptive(light: (0.180, 0.425, 0.790, 0.90), dark: (0.31, 0.58, 0.98, 0.85)), - popupBackground: adaptive(light: (1, 1, 1, 1), dark: (0.135, 0.143, 0.157, 1)), + popupBackground: adaptive(light: (1, 1, 1, 1), dark: (0.157, 0.161, 0.173, 1)), popupShadow: adaptive(light: (0, 0, 0, 0.20), dark: (0, 0, 0, 0.55)), badgeBackground: adaptive(light: (0, 0, 0, 0.075), dark: (1, 1, 1, 0.10)), - primaryText: adaptive(light: (0, 0, 0, 0.82), dark: (1, 1, 1, 0.86)), + primaryText: adaptive(light: (0, 0, 0, 0.82), dark: (0.875, 0.882, 0.898, 1)), secondaryText: adaptive(light: (0, 0, 0, 0.55), dark: (1, 1, 1, 0.50)), tertiaryText: adaptive(light: (0, 0, 0, 0.38), dark: (1, 1, 1, 0.34)), + toolWindowText: adaptive(light: (0, 0, 0, 0.82), dark: (0.875, 0.882, 0.898, 1)), + toolWindowSelectedText: adaptive(light: (1, 1, 1, 1), dark: (1, 1, 1, 1)), accent: adaptive(light: (0.180, 0.425, 0.790, 1), dark: (0.31, 0.58, 0.98, 1)), runAction: adaptive(light: (0.180, 0.490, 0.196, 1), dark: (0.349, 0.659, 0.412, 1)), success: adaptive(light: (0.105, 0.545, 0.235, 1), dark: (0.28, 0.72, 0.39, 1)), @@ -229,6 +238,7 @@ enum LitheTheme { case skill case guide case activeGuide + case divider } static func nsColor( @@ -249,6 +259,7 @@ enum LitheTheme { case .skill: palette.skill.nsColor case .guide: palette.guide.nsColor case .activeGuide: palette.activeGuide.nsColor + case .divider: palette.divider.nsColor } } @@ -257,6 +268,7 @@ enum LitheTheme { static var titlebar: Color { adaptive(\.titlebar) } static var settingsSurface: Color { editor } static var toolHeader: Color { adaptive(\.toolHeader) } + static var toolHeaderInactive: Color { adaptive(\.toolHeaderInactive) } static var sidebar: Color { adaptive(\.sidebar) } static var editor: Color { adaptive(\.editor) } static var raised: Color { adaptive(\.raised) } @@ -292,6 +304,8 @@ enum LitheTheme { static var primaryText: Color { adaptive(\.primaryText) } static var secondaryText: Color { adaptive(\.secondaryText) } static var tertiaryText: Color { adaptive(\.tertiaryText) } + static var toolWindowText: Color { adaptive(\.toolWindowText) } + static var toolWindowSelectedText: Color { adaptive(\.toolWindowSelectedText) } // MARK: - 语义色 static var accent: Color { adaptive(\.accent) } @@ -323,6 +337,7 @@ enum LitheTheme { static var smallFont: Font { uiFont(size: 12) } static let codeFont = Font.custom("JetBrainsMono-Regular", size: 13) static let editorLineHeightMultiple: CGFloat = 1.2 + static let editorBaselineLift: CGFloat = 1.5 static func editorFont(size: CGFloat, weight: NSFont.Weight = .regular) -> NSFont { let postScriptName = weight.rawValue >= NSFont.Weight.semibold.rawValue @@ -399,7 +414,7 @@ struct LitheIconButtonStyle: ButtonStyle { func makeBody(configuration: Configuration) -> some View { configuration.label - .foregroundStyle(LitheTheme.secondaryText) + .foregroundStyle(LitheTheme.toolWindowText) .frame(width: 28, height: 28) .background( RoundedRectangle(cornerRadius: LitheTheme.Metrics.cornerRadius) diff --git a/Sources/Lithe/Views/App/RootView.swift b/Sources/Lithe/Views/App/RootView.swift index ccde8c35..1f96708d 100644 --- a/Sources/Lithe/Views/App/RootView.swift +++ b/Sources/Lithe/Views/App/RootView.swift @@ -118,7 +118,9 @@ struct RootView: View { @ViewBuilder private func projectContent(for session: AppModel) -> some View { Group { - if session.workspaceURL == nil { + if session.standaloneFileURL != nil { + StandaloneEditorView() + } else if session.workspaceURL == nil { WelcomeView() } else { WorkbenchView() @@ -140,10 +142,15 @@ struct RootView: View { } private var windowLayout: LitheWindowLayout { - projectSessions.activeModel.workspaceURL == nil ? .welcome : .workspace + let activeModel = projectSessions.activeModel + if activeModel.standaloneFileURL != nil { return .standalone } + return activeModel.workspaceURL == nil ? .welcome : .workspace } private var windowTitle: String? { + if windowLayout == .standalone { + return projectSessions.activeModel.standaloneFileURL?.lastPathComponent ?? "Lithe" + } guard windowLayout == .welcome else { return nil } return String( localized: "Welcome to Lithe", @@ -181,15 +188,20 @@ private struct WindowCloseGuard: NSViewRepresentable { enum LitheWindowLayout: Equatable { case welcome case workspace + case standalone static let welcomeContentSize = NSSize(width: 900, height: 620) static let workspaceContentSize = NSSize(width: 1440, height: 900) + static let standaloneContentSize = NSSize(width: 1200, height: 760) + static let standaloneMinimumContentSize = NSSize(width: 760, height: 480) + static let standaloneMaximumContentSize = NSSize(width: 1200, height: 820) static let screenMargin: CGFloat = 12 var contentSize: NSSize { switch self { case .welcome: Self.welcomeContentSize case .workspace: Self.workspaceContentSize + case .standalone: Self.standaloneContentSize } } @@ -197,9 +209,22 @@ enum LitheWindowLayout: Equatable { switch self { case .welcome: NSSize(width: 820, height: 560) case .workspace: NSSize(width: 980, height: 640) + case .standalone: Self.standaloneMinimumContentSize } } + static func standaloneContentSize(fitting visibleFrame: NSRect) -> NSSize { + NSSize( + width: min( + max(visibleFrame.width * 0.65, standaloneMinimumContentSize.width), + standaloneMaximumContentSize.width + ), + height: min( + max(visibleFrame.height * 0.72, standaloneMinimumContentSize.height), + standaloneMaximumContentSize.height + ) + ) + } static func frame(_ targetFrame: NSRect, fitting visibleFrame: NSRect) -> NSRect { let availableFrame = visibleFrame.insetBy(dx: screenMargin, dy: screenMargin) var fittedFrame = targetFrame @@ -220,13 +245,19 @@ enum LitheWindowLayout: Equatable { @MainActor protocol ProjectWindowSessionHandling: AnyObject { var hasActiveProject: Bool { get } + var hasActiveStandaloneFile: Bool { get } func closeActiveProject() + func requestCloseActiveSession() -> Bool } extension ProjectSessionManager: ProjectWindowSessionHandling { var hasActiveProject: Bool { activeModel.workspaceURL != nil } + + var hasActiveStandaloneFile: Bool { + activeModel.standaloneFileURL != nil + } } @MainActor @@ -273,9 +304,8 @@ final class LitheWindowCoordinator: NSObject, NSWindowDelegate { } func windowShouldClose(_ sender: NSWindow) -> Bool { - if projectSessions.hasActiveProject { - projectSessions.closeActiveProject() - return false + if projectSessions.hasActiveProject || projectSessions.hasActiveStandaloneFile { + return projectSessions.requestCloseActiveSession() } return true } @@ -297,13 +327,20 @@ final class LitheWindowCoordinator: NSObject, NSWindowDelegate { restoredWorkspaceFrame = nil let currentFrame = window.frame - let targetContentRect = NSRect(origin: .zero, size: layout.contentSize) + let visibleFrame = (window.screen ?? NSScreen.main)?.visibleFrame + let targetContentSize: NSSize + if layout == .standalone, let visibleFrame { + targetContentSize = LitheWindowLayout.standaloneContentSize(fitting: visibleFrame) + } else { + targetContentSize = layout.contentSize + } + let targetContentRect = NSRect(origin: .zero, size: targetContentSize) var targetFrame = window.frameRect(forContentRect: targetContentRect) targetFrame.origin = NSPoint( x: currentFrame.midX - targetFrame.width / 2, y: currentFrame.midY - targetFrame.height / 2 ) - if let visibleFrame = (window.screen ?? NSScreen.main)?.visibleFrame { + if let visibleFrame { targetFrame = LitheWindowLayout.frame(targetFrame, fitting: visibleFrame) } window.setFrame(targetFrame, display: true, animate: shouldAnimate) diff --git a/Sources/Lithe/Views/App/WelcomeView.swift b/Sources/Lithe/Views/App/WelcomeView.swift index 1ea756da..163ac8f3 100644 --- a/Sources/Lithe/Views/App/WelcomeView.swift +++ b/Sources/Lithe/Views/App/WelcomeView.swift @@ -15,7 +15,7 @@ struct WelcomeView: View { Rectangle().fill(LitheTheme.divider.opacity(0.55)).frame(width: 1) projectsContent } - .background(LitheTheme.window) + .background(LitheTheme.editor) .background(WelcomeInitialFocusReset()) } @@ -208,7 +208,7 @@ struct WelcomeView: View { } } } - .background(LitheTheme.window) + .background(LitheTheme.editor) } private var emptyProjectsState: some View { diff --git a/Sources/Lithe/Views/Components/LitheContextMenu.swift b/Sources/Lithe/Views/Components/LitheContextMenu.swift new file mode 100644 index 00000000..bcd299dd --- /dev/null +++ b/Sources/Lithe/Views/Components/LitheContextMenu.swift @@ -0,0 +1,303 @@ +import AppKit +import SwiftUI + +struct LitheContextMenuItem: Identifiable { + enum Kind { + case action + case separator + } + + enum Role { + case standard + case destructive + } + + let id = UUID() + let kind: Kind + let title: String + let systemImage: String? + let shortcut: String? + let role: Role + let isEnabled: Bool + let action: () -> Void + + static func action( + _ title: String, + systemImage: String? = nil, + shortcut: String? = nil, + role: Role = .standard, + isEnabled: Bool = true, + action: @escaping () -> Void + ) -> Self { + Self( + kind: .action, + title: title, + systemImage: systemImage, + shortcut: shortcut, + role: role, + isEnabled: isEnabled, + action: action + ) + } + + static var separator: Self { + Self( + kind: .separator, + title: "", + systemImage: nil, + shortcut: nil, + role: .standard, + isEnabled: false, + action: {} + ) + } +} + +private struct LitheContextMenuContent: View { + let items: [LitheContextMenuItem] + let width: CGFloat + let dismiss: () -> Void + + var body: some View { + VStack(spacing: 0) { + ForEach(items) { item in + switch item.kind { + case .action: + LitheContextMenuRow(item: item) { + dismiss() + item.action() + } + case .separator: + Rectangle() + .fill(LitheTheme.divider) + .frame(height: 1) + .padding(.horizontal, 8) + .padding(.vertical, 5) + } + } + } + .padding(.vertical, 6) + .frame(width: width) + .background { + RoundedRectangle(cornerRadius: 9, style: .continuous) + .fill(LitheTheme.sidebar) + } + .overlay { + RoundedRectangle(cornerRadius: 9, style: .continuous) + .stroke(LitheTheme.panelBorder, lineWidth: 1) + } + } +} + +private struct LitheContextMenuRow: View { + let item: LitheContextMenuItem + let action: () -> Void + @State private var isHovering = false + + var body: some View { + Button(action: action) { + HStack(spacing: 9) { + Group { + if let systemImage = item.systemImage { + Image(systemName: systemImage) + .font(.system(size: 13, weight: .regular)) + } else { + Color.clear + } + } + .frame(width: 16, height: 16) + .foregroundStyle( + item.role == .destructive + ? LitheTheme.error + : (isHovering ? LitheTheme.toolWindowSelectedText : LitheTheme.secondaryText) + ) + + Text(LocalizedStringKey(item.title)) + .font(.system(size: 13, weight: .regular)) + .foregroundStyle(isHovering ? LitheTheme.toolWindowSelectedText : LitheTheme.primaryText) + .lineLimit(1) + + Spacer(minLength: 14) + + if let shortcut = item.shortcut { + Text(shortcut) + .font(.system(size: 12, weight: .regular)) + .foregroundStyle(isHovering ? LitheTheme.toolWindowSelectedText.opacity(0.78) : LitheTheme.tertiaryText) + } + } + .padding(.horizontal, 9) + .frame(height: 28) + .contentShape(Rectangle()) + .background { + RoundedRectangle(cornerRadius: 5, style: .continuous) + .fill(isHovering ? LitheTheme.selection : .clear) + } + .padding(.horizontal, 5) + } + .buttonStyle(.plain) + .disabled(!item.isEnabled) + .opacity(item.isEnabled ? 1 : 0.45) + .onHover { isHovering = $0 } + } +} + +@MainActor +private final class LitheContextMenuPanel: NSPanel { + override var canBecomeKey: Bool { true } +} + +@MainActor +private final class LitheContextMenuPresenter: NSObject, NSWindowDelegate { + static let shared = LitheContextMenuPresenter() + + private let menuWidth: CGFloat = 252 + private var panel: LitheContextMenuPanel? + private var localEventMonitor: Any? + private var globalEventMonitor: Any? + + func show( + items: [LitheContextMenuItem], + at screenPoint: NSPoint, + appearance: NSAppearance?, + locale: Locale + ) { + dismiss() + guard !items.isEmpty else { return } + + let menuHeight = items.reduce(CGFloat(12)) { height, item in + height + (item.kind == .separator ? 11 : 28) + } + let content = LitheContextMenuContent( + items: items, + width: menuWidth, + dismiss: { [weak self] in self?.dismiss() } + ) + .environment(\.locale, locale) + .frame(width: menuWidth, height: menuHeight) + + let panel = LitheContextMenuPanel( + contentRect: NSRect(x: 0, y: 0, width: menuWidth, height: menuHeight), + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: false + ) + panel.contentViewController = NSHostingController(rootView: content) + panel.appearance = appearance + panel.backgroundColor = .clear + panel.isOpaque = false + panel.hasShadow = true + panel.level = .popUpMenu + panel.isFloatingPanel = true + panel.hidesOnDeactivate = true + panel.collectionBehavior = [.transient, .fullScreenAuxiliary] + panel.delegate = self + + let visibleFrame = NSScreen.screens + .first(where: { $0.frame.contains(screenPoint) })? + .visibleFrame ?? NSScreen.main?.visibleFrame ?? .zero + let preferredOrigin = NSPoint(x: screenPoint.x - 6, y: screenPoint.y - menuHeight + 6) + let origin = NSPoint( + x: min(max(preferredOrigin.x, visibleFrame.minX + 6), visibleFrame.maxX - menuWidth - 6), + y: min(max(preferredOrigin.y, visibleFrame.minY + 6), visibleFrame.maxY - menuHeight - 6) + ) + panel.setFrameOrigin(origin) + + self.panel = panel + installEventMonitors() + panel.orderFrontRegardless() + panel.makeKey() + } + + func dismiss() { + removeEventMonitors() + panel?.orderOut(nil) + panel?.close() + panel = nil + } + + func windowDidResignKey(_ notification: Notification) { + dismiss() + } + + private func installEventMonitors() { + localEventMonitor = NSEvent.addLocalMonitorForEvents( + matching: [.leftMouseDown, .rightMouseDown, .keyDown] + ) { [weak self] event in + guard let self else { return event } + if event.type == .keyDown, event.keyCode == 53 { + self.dismiss() + return nil + } + if event.type != .keyDown, event.window !== self.panel { + self.dismiss() + } + return event + } + globalEventMonitor = NSEvent.addGlobalMonitorForEvents( + matching: [.leftMouseDown, .rightMouseDown] + ) { [weak self] _ in + self?.dismiss() + } + } + + private func removeEventMonitors() { + if let localEventMonitor { + NSEvent.removeMonitor(localEventMonitor) + self.localEventMonitor = nil + } + if let globalEventMonitor { + NSEvent.removeMonitor(globalEventMonitor) + self.globalEventMonitor = nil + } + } +} + +@MainActor +private struct LitheContextMenuTrigger: NSViewRepresentable { + @Environment(\.locale) private var locale + let items: () -> [LitheContextMenuItem] + + func makeNSView(context: Context) -> LitheRightClickCaptureView { + let view = LitheRightClickCaptureView() + update(view) + return view + } + + func updateNSView(_ nsView: LitheRightClickCaptureView, context: Context) { + update(nsView) + } + + private func update(_ view: LitheRightClickCaptureView) { + view.onRightClick = { screenPoint, appearance in + LitheContextMenuPresenter.shared.show( + items: items(), + at: screenPoint, + appearance: appearance, + locale: locale + ) + } + } +} + +@MainActor +private final class LitheRightClickCaptureView: NSView { + var onRightClick: (@MainActor (NSPoint, NSAppearance?) -> Void)? + + override func hitTest(_ point: NSPoint) -> NSView? { + guard NSApp.currentEvent?.type == .rightMouseDown else { return nil } + return super.hitTest(point) + } + + override func rightMouseDown(with event: NSEvent) { + guard let window else { return } + onRightClick?(window.convertPoint(toScreen: event.locationInWindow), effectiveAppearance) + } +} + +extension View { + func litheContextMenu(items: @escaping () -> [LitheContextMenuItem]) -> some View { + overlay { + LitheContextMenuTrigger(items: items) + } + } +} diff --git a/Sources/Lithe/Views/Components/LitheToolWindowHeader.swift b/Sources/Lithe/Views/Components/LitheToolWindowHeader.swift index 926b92bf..13f46bcc 100644 --- a/Sources/Lithe/Views/Components/LitheToolWindowHeader.swift +++ b/Sources/Lithe/Views/Components/LitheToolWindowHeader.swift @@ -35,15 +35,15 @@ struct LitheToolWindowHeader: View { size: 13, fallbackSystemImage: systemImage ?? "circle" ) - .foregroundStyle(LitheTheme.secondaryText) + .foregroundStyle(LitheTheme.toolWindowText) } else if let systemImage { Image(systemName: systemImage) .font(.system(size: 12, weight: .medium)) - .foregroundStyle(LitheTheme.secondaryText) + .foregroundStyle(LitheTheme.toolWindowText) } Text(LocalizedStringKey(title)) .font(.system(size: 12.5, weight: .semibold)) - .foregroundStyle(LitheTheme.primaryText) + .foregroundStyle(LitheTheme.toolWindowText) if let subtitle, !subtitle.isEmpty { Text(LocalizedStringKey(subtitle)) .font(.system(size: 11.5, weight: .medium)) diff --git a/Sources/Lithe/Views/Editor/CodeEditorView.swift b/Sources/Lithe/Views/Editor/CodeEditorView.swift index 2efdf418..d72fd42b 100644 --- a/Sources/Lithe/Views/Editor/CodeEditorView.swift +++ b/Sources/Lithe/Views/Editor/CodeEditorView.swift @@ -9,8 +9,21 @@ fileprivate struct CodeEditorPalette { static let dark = CodeEditorPalette(isDark: true, theme: .lithe) var background: NSColor { themeColor(.editor) } - var gutterBackground: NSColor { themeColor(.sidebar) } - var text: NSColor { themeColor(.primaryText) } + var gutterBackground: NSColor { themeColor(.editor) } + var divider: NSColor { themeColor(.divider) } + var gutterDivider: NSColor { + color( + light: (0.78, 0.79, 0.81, 1), + dark: (0.204, 0.212, 0.231, 1) + ) + } + var text: NSColor { + guard theme == .lithe else { return themeColor(.primaryText) } + return color( + light: (0, 0, 0, 0.82), + dark: (0.737, 0.745, 0.769, 1) + ) + } var caret: NSColor { themeColor(.primaryText) } var selection: NSColor { themeColor(.accent).withAlphaComponent(isDark ? 0.42 : 0.24) } var selectionText: NSColor { themeColor(.primaryText) } @@ -55,6 +68,13 @@ fileprivate struct CodeEditorPalette { } } +private enum EditorLayoutMetrics { + static let standardWidth: CGFloat = 45 + static let editorLeadingInset: CGFloat = 0 + static let editorLineFragmentPadding: CGFloat = 4 + static let caretWidth: CGFloat = 2 +} + struct CodeEditorView: NSViewRepresentable { @Environment(\.colorScheme) private var colorScheme @EnvironmentObject private var model: AppModel @@ -100,7 +120,7 @@ struct CodeEditorView: NSViewRepresentable { scrollView.topAnchor.constraint(equalTo: container.topAnchor), scrollView.bottomAnchor.constraint(equalTo: container.bottomAnchor) ]) - let gutterWidthConstraint = gutter.widthAnchor.constraint(equalToConstant: 52) + let gutterWidthConstraint = gutter.widthAnchor.constraint(equalToConstant: EditorLayoutMetrics.standardWidth) gutterWidthConstraint.isActive = true let textView = CodeTextView(frame: NSRect(x: 0, y: 0, width: 900, height: 700)) @@ -118,7 +138,8 @@ struct CodeEditorView: NSViewRepresentable { textView.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude) textView.textContainer?.containerSize = NSSize(width: scrollView.contentSize.width, height: CGFloat.greatestFiniteMagnitude) textView.textContainer?.widthTracksTextView = true - textView.textContainerInset = NSSize(width: 12, height: 10) + textView.textContainerInset = NSSize(width: EditorLayoutMetrics.editorLeadingInset, height: 0) + textView.textContainer?.lineFragmentPadding = EditorLayoutMetrics.editorLineFragmentPadding textView.font = LitheTheme.editorFont(size: settings.editorFontSize) textView.defaultParagraphStyle = LitheTheme.editorParagraphStyle textView.indentationWidth = settings.tabWidth @@ -189,6 +210,9 @@ struct CodeEditorView: NSViewRepresentable { context.coordinator.textView = textView context.coordinator.gutter = gutter + textView.onCaretPresentationChanged = { [weak gutter] in + gutter?.needsDisplay = true + } context.coordinator.container = container context.coordinator.attachMarkdownImagePasteMonitor(to: scrollView) context.coordinator.codeVisionOverlay = CodeVisionOverlayController(textView: textView) @@ -492,9 +516,6 @@ struct CodeEditorView: NSViewRepresentable { } func textViewDidChangeSelection(_ notification: Notification) { - (textView as? CodeTextView)?.updateEditorDecorations() - textView?.needsDisplay = true - gutter?.needsDisplay = true updateCaret() } @@ -592,7 +613,7 @@ struct CodeEditorView: NSViewRepresentable { $0.fileURL.standardizedFileURL == url }.map(\.line) let debugBreakpointLines = Set(javaBreakpointLines + genericBreakpointLines) - container?.gutterWidthConstraint?.constant = isBlameVisible ? 224 : 52 + container?.gutterWidthConstraint?.constant = isBlameVisible ? 224 : EditorLayoutMetrics.standardWidth gutter?.update(blameLines: blameLines, isVisible: isBlameVisible) { [weak model] blame in Task { await model?.showGitCommit(blame.commitHash) } } @@ -756,6 +777,7 @@ private struct TextLineIndex { } final class CodeTextView: NSTextView, NSLayoutManagerDelegate { + var onCaretPresentationChanged: (() -> Void)? var indentationWidth = 4 var isLanguageNavigationEnabled = false var isLanguageIntelligenceEnabled = false @@ -802,6 +824,8 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate { private var hoveredFoldID: String? private var lineIndex = TextLineIndex(source: "" as NSString) nonisolated(unsafe) private var windowResignObserver: NSObjectProtocol? + private var caretVisible = true + private var caretPresentationGeneration = 0 fileprivate func applyAppearance(_ palette: CodeEditorPalette) { guard appliedDarkAppearance != palette.isDark @@ -830,6 +854,54 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate { super.paste(sender) } + override func setSelectedRange(_ charRange: NSRange) { + super.setSelectedRange(charRange) + synchronizeCaretPresentation() + } + + override func setSelectedRange( + _ charRange: NSRange, + affinity: NSSelectionAffinity, + stillSelecting flag: Bool + ) { + super.setSelectedRange(charRange, affinity: affinity, stillSelecting: flag) + synchronizeCaretPresentation() + } + + private func synchronizeCaretPresentation() { + updateEditorDecorations() + needsDisplay = true + onCaretPresentationChanged?() + updateInsertionPointStateAndRestartTimer(true) + } + + override func updateInsertionPointStateAndRestartTimer(_ restartFlag: Bool) { + guard restartFlag else { return } + caretPresentationGeneration &+= 1 + let generation = caretPresentationGeneration + caretVisible = true + needsDisplay = true + + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(500)) { [weak self] in + self?.startCaretBlinking(for: generation) + } + } + + private func startCaretBlinking(for generation: Int) { + guard generation == caretPresentationGeneration else { return } + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(500)) { [weak self] in + guard let self, generation == self.caretPresentationGeneration else { return } + self.caretVisible.toggle() + self.needsDisplay = true + self.startCaretBlinking(for: generation) + } + } + + override func drawInsertionPoint(in rect: NSRect, color: NSColor, turnedOn _: Bool) { + // The editor paints the caret from draw(_:) so AppKit's independent + // insertion-point blink callbacks cannot overwrite its width or phase. + } + override func performKeyEquivalent(with event: NSEvent) -> Bool { if Self.isStandardPasteShortcut(event), onPasteImage?() == true { return true @@ -889,12 +961,6 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate { let source = string as NSString let caret = min(selectedRange().location, source.length) - let lineRange = source.lineRange(for: NSRange(location: caret, length: 0)) - layoutManager.addTemporaryAttribute( - .backgroundColor, - value: currentLineColor, - forCharacterRange: lineRange - ) for range in matchingBracketRanges(in: source, caret: caret) { layoutManager.addTemporaryAttribute(.backgroundColor, value: bracketColor, forCharacterRange: range) @@ -1155,14 +1221,22 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate { forGlyphRange glyphRange: NSRange ) -> Bool { let characterRange = layoutManager.characterRange(forGlyphRange: glyphRange, actualGlyphRange: nil) - guard collapsedFoldIDs.contains(where: { id in + let isCollapsedLine = collapsedFoldIDs.contains(where: { id in guard let region = foldRegions.first(where: { $0.id == id }) else { return false } return NSLocationInRange(characterRange.location, region.hiddenRange) - }) else { return false } + }) + + guard !isCollapsedLine else { + lineFragmentRect.pointee.size.height = 0 + lineFragmentUsedRect.pointee.size.height = 0 + baselineOffset.pointee = 0 + return true + } - lineFragmentRect.pointee.size.height = 0 - lineFragmentUsedRect.pointee.size.height = 0 - baselineOffset.pointee = 0 + baselineOffset.pointee = max( + 0, + baselineOffset.pointee - LitheTheme.editorBaselineLift + ) return true } @@ -1311,9 +1385,37 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate { override func drawBackground(in rect: NSRect) { super.drawBackground(in: rect) + drawCurrentLineBackground(in: rect) drawIndentGuides(in: rect) } + private func drawCurrentLineBackground(in rect: NSRect) { + let source = string as NSString + let caret = min(selectedRange().location, source.length) + let lineRange = source.lineRange(for: NSRange(location: caret, length: 0)) + guard let layoutManager, + layoutManager.numberOfGlyphs > 0 else { return } + + let glyphRange = layoutManager.glyphRange( + forCharacterRange: lineRange, + actualCharacterRange: nil + ) + guard glyphRange.location < layoutManager.numberOfGlyphs else { return } + let lineRect = layoutManager.lineFragmentRect( + forGlyphAt: glyphRange.location, + effectiveRange: nil + ) + let currentLineRect = NSRect( + x: 0, + y: textContainerOrigin.y + lineRect.minY, + width: bounds.width, + height: lineRect.height + ) + guard currentLineRect.intersects(rect) else { return } + currentLineColor.setFill() + currentLineRect.intersection(rect).fill() + } + private func lineFragmentRect( forLine line: Int, in source: NSString, @@ -1384,6 +1486,46 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate { ] ) } + drawCaret() + } + + private func drawCaret() { + guard caretVisible, + window?.firstResponder === self, + let layoutManager, + let textContainer else { return } + + let sourceLength = string.utf16.count + let location = min(selectedRange().location, sourceLength) + let caretRect: NSRect + if layoutManager.numberOfGlyphs == 0 { + let lineHeight = layoutManager.defaultLineHeight(for: font ?? .systemFont(ofSize: 13)) + caretRect = NSRect( + x: textContainerOrigin.x, + y: textContainerOrigin.y, + width: EditorLayoutMetrics.caretWidth, + height: lineHeight + ) + } else { + let isAtDocumentEnd = location == sourceLength + let glyphIndex = layoutManager.glyphIndexForCharacter( + at: min(location, sourceLength - 1) + ) + let glyphRect = layoutManager.boundingRect( + forGlyphRange: NSRange(location: glyphIndex, length: 1), + in: textContainer + ) + let lineRect = layoutManager.lineFragmentRect(forGlyphAt: glyphIndex, effectiveRange: nil) + caretRect = NSRect( + x: textContainerOrigin.x + (isAtDocumentEnd ? glyphRect.maxX : glyphRect.minX), + y: textContainerOrigin.y + lineRect.minY, + width: EditorLayoutMetrics.caretWidth, + height: lineRect.height + ) + } + + insertionPointColor.setFill() + caretRect.fill() } override func mouseDown(with event: NSEvent) { @@ -1483,6 +1625,14 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate { return super.resignFirstResponder() } + override func becomeFirstResponder() -> Bool { + let becameFirstResponder = super.becomeFirstResponder() + if becameFirstResponder { + updateInsertionPointStateAndRestartTimer(true) + } + return becameFirstResponder + } + private func updateFoldHover(at point: NSPoint?) { let nextID = point.flatMap { point in foldRegions.first(where: { @@ -1858,7 +2008,7 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate { scrollView.documentView = textView scrollView.hasVerticalScroller = true scrollView.drawsBackground = true - scrollView.backgroundColor = NSColor(red: 0.105, green: 0.11, blue: 0.12, alpha: 1) + scrollView.backgroundColor = LitheTheme.nsColor(.editor, theme: .lithe, isDark: true) let controller = NSViewController() controller.view = scrollView controller.preferredContentSize = NSSize(width: 480, height: 220) @@ -2217,7 +2367,9 @@ final class LineNumberGutterView: NSView { in: textContainer ) guard layoutManager.numberOfGlyphs > 0 else { - drawLineNumber(1, y: textView.textContainerInset.height) + let lineHeight = max(18, textView.layoutManager?.defaultLineHeight(for: textView.font ?? .systemFont(ofSize: 13)) ?? 18) + drawLineNumber(1, y: textView.textContainerInset.height, height: lineHeight) + drawEditorDivider(in: dirtyRect) return } @@ -2267,7 +2419,7 @@ final class LineNumberGutterView: NSView { if let marker = gitLineChangeMarkersByLine[lineNumber - 1] { drawGitLineChange(marker, y: y, height: lineRect.height) } - drawLineNumber(lineNumber, y: y + 1) + drawLineNumber(lineNumber, y: y, height: lineRect.height) let nextGlyph = NSMaxRange(lineGlyphRange) glyphIndex = nextGlyph > glyphIndex ? nextGlyph : glyphIndex + 1 @@ -2280,6 +2432,20 @@ final class LineNumberGutterView: NSView { visibleRect: visibleRect, layoutManager: layoutManager ) + + drawEditorDivider(in: dirtyRect) + } + + private func drawEditorDivider(in dirtyRect: NSRect) { + // Keep the gutter/editor boundary visible over the current-line fill, + // including when an empty document has no glyphs to lay out. + palette.gutterDivider.setFill() + NSRect( + x: bounds.width - 1, + y: dirtyRect.minY, + width: 1, + height: dirtyRect.height + ).fill() } private func drawFoldIndicators( @@ -2309,14 +2475,18 @@ final class LineNumberGutterView: NSView { } } - private func drawLineNumber(_ number: Int, y: CGFloat) { + private func drawLineNumber(_ number: Int, y: CGFloat, height: CGFloat) { let label = String(number) as NSString let attributes: [NSAttributedString.Key: Any] = [ .font: NSFont.monospacedDigitSystemFont(ofSize: 10.5, weight: .regular), .foregroundColor: palette.lineNumber ] let size = label.size(withAttributes: attributes) - label.draw(at: NSPoint(x: bounds.width - size.width - 9, y: y), withAttributes: attributes) + let centeredY = y + max(0, (height - size.height) / 2) + label.draw( + at: NSPoint(x: (bounds.width - size.width) / 2, y: centeredY), + withAttributes: attributes + ) } private func drawFoldIndicator(_ region: JavaFoldRegion, y: CGFloat, height: CGFloat) { diff --git a/Sources/Lithe/Views/Editor/StandaloneEditorView.swift b/Sources/Lithe/Views/Editor/StandaloneEditorView.swift new file mode 100644 index 00000000..89b73427 --- /dev/null +++ b/Sources/Lithe/Views/Editor/StandaloneEditorView.swift @@ -0,0 +1,120 @@ +import SwiftUI + +struct StandaloneEditorView: View { + @EnvironmentObject private var model: AppModel + + var body: some View { + VStack(spacing: 0) { + header + Rectangle() + .fill(LitheTheme.divider) + .frame(height: 1) + + content + } + .background(LitheTheme.editor) + .confirmationDialog( + "Save changes before closing?", + isPresented: Binding( + get: { model.pendingCloseDocument != nil }, + set: { if !$0 { model.cancelPendingClose() } } + ), + titleVisibility: .visible + ) { + Button("Save") { model.closePendingDocument(discardingChanges: false) } + Button("Discard Changes", role: .destructive) { + model.closePendingDocument(discardingChanges: true) + } + Button("Cancel", role: .cancel) { model.cancelPendingClose() } + } message: { + Text(model.pendingCloseDocument?.url.lastPathComponent ?? "") + } + } + + @ViewBuilder + private var content: some View { + switch model.standaloneFileLoadState { + case .idle, .loading: + ProgressView("Opening file…") + .frame(maxWidth: .infinity, maxHeight: .infinity) + case .loaded: + if let document = model.activeDocument { + CodeEditorView(document: document, shouldFocus: true) + .overlay(alignment: .top) { + if model.isFindBarVisible { + FindBarView() + .padding(.top, 10) + .padding(.horizontal, 12) + } + } + } else { + failureView(.readFailed) + } + case let .failed(failure): + failureView(failure) + } + } + + private func failureView(_ failure: StandaloneFileOpenFailure) -> some View { + VStack(spacing: 10) { + LitheSystemIcon(systemImage: "doc.text.magnifyingglass") + .font(.system(size: 26)) + .foregroundStyle(LitheTheme.secondaryText) + Text(failure.title) + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(LitheTheme.primaryText) + Text(failure.detail) + .font(.system(size: 12)) + .foregroundStyle(LitheTheme.secondaryText) + .multilineTextAlignment(.center) + .frame(maxWidth: 420) + HStack(spacing: 8) { + Button("Try Again") { + if let url = model.standaloneFileURL { + model.openStandaloneFile(url) + } + } + .buttonStyle(LitheSecondaryButtonStyle()) + Button("Close File") { + model.closeStandaloneFile() + } + .buttonStyle(LithePrimaryButtonStyle()) + } + .padding(.top, 4) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(32) + } + + private var header: some View { + HStack(spacing: 8) { + if let document = model.activeDocument { + LitheIcon( + kind: LitheIcons.kind(for: document.url, isDirectory: false), + size: 14 + ) + Text(document.displayName) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(LitheTheme.primaryText) + .lineLimit(1) + if document.isDirty { + Circle() + .fill(LitheTheme.accent) + .frame(width: 6, height: 6) + } + Spacer() + Text(document.url.path) + .font(.system(size: 10.5)) + .foregroundStyle(LitheTheme.tertiaryText) + .lineLimit(1) + } else { + Text(model.standaloneFileURL?.lastPathComponent ?? "Opening file…") + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(LitheTheme.secondaryText) + } + } + .padding(.horizontal, 12) + .frame(height: 34) + .background(LitheTheme.toolHeader) + } +} diff --git a/Sources/Lithe/Views/Workbench/SplitHandleView.swift b/Sources/Lithe/Views/Workbench/SplitHandleView.swift index 2cff80ea..49a1db92 100644 --- a/Sources/Lithe/Views/Workbench/SplitHandleView.swift +++ b/Sources/Lithe/Views/Workbench/SplitHandleView.swift @@ -12,6 +12,8 @@ struct SplitHandleView: View { static let thickness: CGFloat = 10 let axis: LitheSplitAxis + let leadingBackground: Color + let trailingBackground: Color let onDragStarted: () -> Void let onDragChanged: (CGFloat) -> Void let onDragEnded: () -> Void @@ -20,8 +22,25 @@ struct SplitHandleView: View { @State private var isDragging = false @State private var lastTranslation: CGFloat = 0 + init( + axis: LitheSplitAxis, + leadingBackground: Color = .clear, + trailingBackground: Color = .clear, + onDragStarted: @escaping () -> Void, + onDragChanged: @escaping (CGFloat) -> Void, + onDragEnded: @escaping () -> Void + ) { + self.axis = axis + self.leadingBackground = leadingBackground + self.trailingBackground = trailingBackground + self.onDragStarted = onDragStarted + self.onDragChanged = onDragChanged + self.onDragEnded = onDragEnded + } + var body: some View { ZStack { + trackBackground Color.clear dividerLine } @@ -57,38 +76,50 @@ struct SplitHandleView: View { guard isInside != isHovering else { return } isHovering = isInside if isInside { - resizeCursor.push() + resizeCursor.set() } else { - NSCursor.pop() - } - } - .onDisappear { - if isHovering { - NSCursor.pop() + NSCursor.arrow.set() } } .help(axis == .horizontal ? "Drag left or right to resize" : "Drag up or down to resize") .accessibilityLabel(axis == .horizontal ? "Horizontal pane resize handle" : "Vertical pane resize handle") } + @ViewBuilder + private var trackBackground: some View { + if axis == .horizontal { + HStack(spacing: 0) { + leadingBackground + .frame(maxWidth: .infinity, maxHeight: .infinity) + trailingBackground + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } else { + VStack(spacing: 0) { + leadingBackground + .frame(maxWidth: .infinity, maxHeight: .infinity) + trailingBackground + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + } + @ViewBuilder private var dividerLine: some View { - if isHovering || isDragging { - let color = isDragging - ? LitheTheme.accent.opacity(0.72) - : LitheTheme.divider + let color = isDragging + ? LitheTheme.accent.opacity(0.72) + : LitheTheme.divider - if axis == .horizontal { - Rectangle() - .fill(color) - .frame(width: isDragging ? 3 : (isHovering ? 2 : 1)) - .frame(maxHeight: .infinity) - } else { - Rectangle() - .fill(color) - .frame(height: isDragging ? 3 : (isHovering ? 2 : 1)) - .frame(maxWidth: .infinity) - } + if axis == .horizontal { + Rectangle() + .fill(color) + .frame(width: isDragging ? 3 : (isHovering ? 2 : 1)) + .frame(maxHeight: .infinity) + } else { + Rectangle() + .fill(color) + .frame(height: isDragging ? 3 : (isHovering ? 2 : 1)) + .frame(maxWidth: .infinity) } } diff --git a/Sources/Lithe/Views/Workbench/WorkbenchView.swift b/Sources/Lithe/Views/Workbench/WorkbenchView.swift index 048c4dd7..01457f7e 100644 --- a/Sources/Lithe/Views/Workbench/WorkbenchView.swift +++ b/Sources/Lithe/Views/Workbench/WorkbenchView.swift @@ -4,6 +4,7 @@ import LitheGitModule private enum ActivityBarMetrics { static let width: CGFloat = 38 + static let rightWidth: CGFloat = 40 static let buttonWidth: CGFloat = 30 static let buttonHeight: CGFloat = 30 static let spacing: CGFloat = 4 @@ -46,8 +47,10 @@ struct WorkbenchView: View { HStack(spacing: 0) { activityBar + Rectangle() + .fill(LitheTheme.divider) + .frame(width: 1) workspaceArea - Color.clear.frame(width: ActivityBarMetrics.width) } .frame(maxHeight: .infinity) .overlay(alignment: .trailing) { @@ -611,7 +614,7 @@ struct WorkbenchView: View { Spacer() } .padding(.top, ActivityBarMetrics.edgeInset) - .frame(width: ActivityBarMetrics.width) + .frame(width: ActivityBarMetrics.rightWidth) .background(LitheTheme.titlebar) } @@ -643,6 +646,9 @@ struct WorkbenchView: View { } } } + Rectangle() + .fill(LitheTheme.divider) + .frame(width: 1) pluginActivityBar } .fixedSize(horizontal: true, vertical: false) @@ -1122,7 +1128,7 @@ private struct WorkbenchWorkspaceSplitView [LitheContextMenuItem] { + [ + .action("New File…", systemImage: "doc.badge.plus") { + model.requestCreateFile(in: root.url) + }, + .action("New Directory…", systemImage: "folder.badge.plus") { + model.requestCreateDirectory(in: root.url) + }, + .separator, + .action("Show Project in Finder", systemImage: "folder") { + model.revealProjectItemInFinder(root.url) + }, + .action("Show Project Local History…", systemImage: "clock.arrow.circlepath") { + model.showProjectLocalHistory() + }, + .action("Copy Project Path", systemImage: "doc.on.doc") { + model.copyProjectItemPath(root.url, relative: false) + }, + .separator, + .action("Refresh", systemImage: "arrow.clockwise") { + Task { await model.refreshWorkspace() } + } + ] + } } private struct FileNodeRow: View { @@ -230,7 +246,7 @@ private struct FileNodeRow: View { .lithePointer() .padding(.vertical, 0.5) .padding(.horizontal, Self.horizontalInset) - .contextMenu { directoryContextMenu } + .litheContextMenu { directoryContextMenuItems } } private var fileRow: some View { @@ -271,106 +287,107 @@ private struct FileNodeRow: View { .lithePointer() .padding(.vertical, 0.5) .padding(.horizontal, Self.horizontalInset) - .contextMenu { fileContextMenu } + .litheContextMenu { fileContextMenuItems } .task(id: node.url.standardizedFileURL.path) { guard node.url.pathExtension.lowercased() == "java" else { return } resolvedJavaIconKind = await model.javaIconKind(for: node.url) } } - @ViewBuilder - private var directoryContextMenu: some View { + private var directoryContextMenuItems: [LitheContextMenuItem] { + var items: [LitheContextMenuItem] = [] if model.gitTreeStatus(for: node.url, isDirectory: true) != nil { - Button("Show Git Diff") { + items.append(.action("Show Git Diff", systemImage: "arrow.left.and.right") { Task { await model.showGitDirectoryDiff(for: node.url) } - } - Divider() + }) + items.append(.separator) } - Button("New File…") { + items.append(.action("New File…", systemImage: "doc.badge.plus") { model.requestCreateFile(in: node.url) - } - Button("New Directory…") { + }) + items.append(.action("New Directory…", systemImage: "folder.badge.plus") { model.requestCreateDirectory(in: node.url) - } + }) - Divider() + items.append(.separator) - Button("Show in Finder") { + items.append(.action("Show in Finder", systemImage: "folder") { model.revealProjectItemInFinder(node.url) - } - Button("Copy Path") { + }) + items.append(.action("Copy Path", systemImage: "doc.on.doc") { model.copyProjectItemPath(node.url, relative: false) - } - Button("Copy Relative Path") { + }) + items.append(.action("Copy Relative Path", systemImage: "point.topleft.down.to.point.bottomright.curvepath") { model.copyProjectItemPath(node.url, relative: true) - } + }) if depth > 0 { - Divider() + items.append(.separator) - Button("Duplicate") { + items.append(.action("Duplicate", systemImage: "plus.square.on.square") { Task { await model.duplicateProjectItem(at: node.url) } - } - Button("Rename…") { + }) + items.append(.action("Rename…", systemImage: "pencil") { model.requestRenameProjectItem(at: node.url) - } - Button("Move to Trash", role: .destructive) { + }) + items.append(.action("Move to Trash", systemImage: "trash", role: .destructive) { model.requestDeleteProjectItem(at: node.url, isDirectory: true) - } + }) } - Divider() + items.append(.separator) - Button("Refresh") { + items.append(.action("Refresh", systemImage: "arrow.clockwise") { Task { await model.refreshWorkspace() } - } + }) + return items } - @ViewBuilder - private var fileContextMenu: some View { - Group { - Button("Open") { + private var fileContextMenuItems: [LitheContextMenuItem] { + var items: [LitheContextMenuItem] = [ + .action("Open", systemImage: "doc.text") { model.openFile(node.url) } + ] - if let change = model.gitChange(for: node.url) { - Button("Show Git Diff") { - model.selectChange(change) - } - } + if let change = model.gitChange(for: node.url) { + items.append(.action("Show Git Diff", systemImage: "arrow.left.and.right") { + model.selectChange(change) + }) } - Divider() + items.append(.separator) - Group { - Button("Duplicate") { + items.append(contentsOf: [ + .action("Duplicate", systemImage: "plus.square.on.square") { Task { await model.duplicateProjectItem(at: node.url) } - } - Button("Rename…") { + }, + .action("Rename…", systemImage: "pencil") { model.requestRenameProjectItem(at: node.url) - } - Button("Local History…") { + }, + .action("Local History…", systemImage: "clock.arrow.circlepath") { model.showLocalHistory(for: node.url) - } - Button("Move to Trash", role: .destructive) { + }, + .action("Move to Trash", systemImage: "trash", role: .destructive) { model.requestDeleteProjectItem(at: node.url, isDirectory: false) } - } + ]) - Divider() + items.append(.separator) - Group { - Button("Show in Finder") { + items.append(contentsOf: [ + .action("Show in Finder", systemImage: "folder") { model.revealProjectItemInFinder(node.url) - } - Button("Copy Path") { + }, + .action("Copy Path", systemImage: "doc.on.doc") { model.copyProjectItemPath(node.url, relative: false) - } - Button("Copy Relative Path") { + }, + .action("Copy Relative Path", systemImage: "point.topleft.down.to.point.bottomright.curvepath") { model.copyProjectItemPath(node.url, relative: true) } - } + ]) + return items } private var gitStatusColor: Color? { diff --git a/Tests/LitheTests/LitheCoreLogicTests.swift b/Tests/LitheTests/LitheCoreLogicTests.swift index 7aef674b..17a115d5 100644 --- a/Tests/LitheTests/LitheCoreLogicTests.swift +++ b/Tests/LitheTests/LitheCoreLogicTests.swift @@ -86,6 +86,26 @@ struct LitheCoreLogicTests { #expect(fittedFrame.maxY <= visibleFrame.maxY) } + @Test + func standaloneWindowUsesScreenRatioWithinSizeLimits() { + let regularScreen = NSRect(x: 0, y: 0, width: 1440, height: 900) + let compactScreen = NSRect(x: 0, y: 0, width: 900, height: 600) + let largeScreen = NSRect(x: 0, y: 0, width: 2560, height: 1600) + + #expect( + LitheWindowLayout.standaloneContentSize(fitting: regularScreen) + == NSSize(width: 936, height: 648) + ) + #expect( + LitheWindowLayout.standaloneContentSize(fitting: compactScreen) + == LitheWindowLayout.standaloneMinimumContentSize + ) + #expect( + LitheWindowLayout.standaloneContentSize(fitting: largeScreen) + == LitheWindowLayout.standaloneMaximumContentSize + ) + } + @Test @MainActor func workspaceTitleBarZoomsToTheVisibleScreenAndRestores() { @@ -1323,6 +1343,51 @@ struct LitheCoreLogicTests { #expect(!WorkspaceTextFilePolicy.isPlainText(Data([0x00, 0x01, 0x02]))) } + @Test + @MainActor + func standaloneEditorLoadsUtf8TextAndLeavesBinaryFilesInFailedState() async { + let storage = InMemoryFileStorage() + let textURL = URL(fileURLWithPath: "/in-memory/notes.txt") + let binaryURL = URL(fileURLWithPath: "/in-memory/archive.bin") + storage.seed(Data("let answer = 42\n".utf8), at: textURL) + storage.seed(Data([0x00, 0x01, 0x02]), at: binaryURL) + + let feature = DocumentFeatureModel( + operations: EmptyWorkspaceOperations(readFileValue: nil), + fileOperations: EmptyWorkspaceFileOperations(), + fileStorage: storage, + binaryFileViewerRegistry: BinaryFileViewerRegistry() + ) + feature.configure( + workspaceURLProvider: { nil }, + autoSaveEnabledProvider: { false }, + autoSaveDelayProvider: { 0 }, + notify: { _ in }, + onDocumentOpened: { _ in }, + onDocumentChanged: { _ in }, + onDocumentClosed: { _ in }, + onRecordSave: { _, _ in }, + onRecordDiscard: { _ in }, + onRecordExternalChanges: { _ in }, + onDocumentCollectionChanged: {}, + onProjectCloseReady: {} + ) + + feature.openStandaloneFile(textURL) + for _ in 0..<100 where feature.standaloneFileLoadState == .loading { + await Task.yield() + } + #expect(feature.standaloneFileLoadState == .loaded) + #expect(feature.activeDocument?.text == "let answer = 42\n") + + feature.openStandaloneFile(binaryURL) + for _ in 0..<100 where feature.standaloneFileLoadState == .loading { + await Task.yield() + } + #expect(feature.standaloneFileLoadState == .failed(.notText)) + #expect(feature.activeDocument == nil) + } + @Test @MainActor func binaryFileViewerRegistryPrefersMagicAndDefaultsToDeny() async { let registry = BinaryFileViewerRegistry() @@ -2489,6 +2554,7 @@ struct LitheCoreLogicTests { @MainActor private final class TestProjectWindowSessions: ProjectWindowSessionHandling { var hasActiveProject: Bool + var hasActiveStandaloneFile = false private(set) var closeActiveProjectCallCount = 0 init(hasActiveProject: Bool) { @@ -2498,6 +2564,11 @@ private final class TestProjectWindowSessions: ProjectWindowSessionHandling { func closeActiveProject() { closeActiveProjectCallCount += 1 } + + func requestCloseActiveSession() -> Bool { + closeActiveProject() + return false + } } private final class RecordingProcessRunner: ProcessRunner, DatabaseProcessRunning, @unchecked Sendable { @@ -3503,7 +3574,33 @@ private final class InMemoryFileStorage: FileStorage, GitShelfStorage, DatabaseF func cacheDirectory() -> URL { support } func applicationSupportDirectory() -> URL { support } func temporaryDirectory() -> URL { support } - func metadata(for url: URL) -> FileMetadata? { nil } + func metadata(for url: URL) -> FileMetadata? { + lock.lock() + defer { lock.unlock() } + if let data = files[url.path] { + return FileMetadata( + byteCount: data.count, + modificationDate: nil, + isRegularFile: true, + isDirectory: false + ) + } + if directories.contains(url.path) { + return FileMetadata( + byteCount: nil, + modificationDate: nil, + isRegularFile: false, + isDirectory: true + ) + } + return nil + } + + func seed(_ data: Data, at url: URL) { + lock.lock() + files[url.path] = data + lock.unlock() + } func fileExists(at url: URL) -> Bool { lock.lock()