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 1add71c3..6be8d266 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 { @@ -162,6 +233,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 0dd2aa30..932fd2c2 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 332dff63..46db8cc5 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 { @@ -615,10 +616,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 @@ -986,6 +993,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 @@ -1014,6 +1022,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 } @@ -1027,6 +1043,7 @@ final class AppModel: ObservableObject, Identifiable { } stopAccessingWorkspace() workspaceURL = nil + standaloneFileURL = nil reloadLanguageProviderCatalog(for: nil) selectedSidebar = .project workspaceFeature.reset() @@ -1075,6 +1092,13 @@ final class AppModel: ObservableObject, Identifiable { didCloseProject?() } + private func performCloseStandaloneFile() { + standaloneFileURL = nil + documentFeature.reset() + editorChrome.resetFindBar() + didCloseProject?() + } + private func stopAccessingWorkspace() { guard let securityScopedWorkspaceURL else { return } platformUI.stopAccessingProject(securityScopedWorkspaceURL) @@ -1111,6 +1135,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 5ca8e659..ca9d074d 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 2b5c5f42..8e41081f 100644 --- a/Sources/Lithe/Views/App/RootView.swift +++ b/Sources/Lithe/Views/App/RootView.swift @@ -91,7 +91,9 @@ 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 updatePromptPresented: Binding { @@ -112,7 +114,9 @@ private struct ProjectSessionContent: View { var body: some View { Group { - if session.workspaceURL == nil { + if session.standaloneFileURL != nil { + StandaloneEditorView() + } else if session.workspaceURL == nil { WelcomeView() } else { WorkbenchView() @@ -165,10 +169,15 @@ private struct ActiveSessionChrome: View { } private var windowLayout: LitheWindowLayout { - model.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", @@ -206,15 +215,20 @@ private struct WindowCloseGuard: NSViewRepresentable { enum LitheWindowLayout: Equatable { case welcome case workspace + case standalone static let welcomeContentSize = NSSize(width: 900, height: 620) static let workspaceContentSize = NSSize(width: 1440, height: 900) + static let standaloneContentSize = NSSize(width: 1200, height: 760) + static let standaloneMinimumContentSize = NSSize(width: 760, height: 480) + static let standaloneMaximumContentSize = NSSize(width: 1200, height: 820) static let screenMargin: CGFloat = 12 var contentSize: NSSize { switch self { case .welcome: Self.welcomeContentSize case .workspace: Self.workspaceContentSize + case .standalone: Self.standaloneContentSize } } @@ -222,9 +236,22 @@ enum LitheWindowLayout: Equatable { switch self { case .welcome: NSSize(width: 820, height: 560) case .workspace: NSSize(width: 980, height: 640) + case .standalone: Self.standaloneMinimumContentSize } } + static func standaloneContentSize(fitting visibleFrame: NSRect) -> NSSize { + NSSize( + width: min( + max(visibleFrame.width * 0.65, standaloneMinimumContentSize.width), + standaloneMaximumContentSize.width + ), + height: min( + max(visibleFrame.height * 0.72, standaloneMinimumContentSize.height), + standaloneMaximumContentSize.height + ) + ) + } static func frame(_ targetFrame: NSRect, fitting visibleFrame: NSRect) -> NSRect { let availableFrame = visibleFrame.insetBy(dx: screenMargin, dy: screenMargin) var fittedFrame = targetFrame @@ -245,13 +272,19 @@ enum LitheWindowLayout: Equatable { @MainActor protocol ProjectWindowSessionHandling: AnyObject { var hasActiveProject: Bool { get } + var hasActiveStandaloneFile: Bool { get } func closeActiveProject() + func requestCloseActiveSession() -> Bool } extension ProjectSessionManager: ProjectWindowSessionHandling { var hasActiveProject: Bool { activeModel.workspaceURL != nil } + + var hasActiveStandaloneFile: Bool { + activeModel.standaloneFileURL != nil + } } @MainActor @@ -298,9 +331,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 } @@ -322,13 +354,20 @@ final class LitheWindowCoordinator: NSObject, NSWindowDelegate { restoredWorkspaceFrame = nil let currentFrame = window.frame - let targetContentRect = NSRect(origin: .zero, size: layout.contentSize) + let visibleFrame = (window.screen ?? NSScreen.main)?.visibleFrame + let targetContentSize: NSSize + if layout == .standalone, let visibleFrame { + targetContentSize = LitheWindowLayout.standaloneContentSize(fitting: visibleFrame) + } else { + targetContentSize = layout.contentSize + } + let targetContentRect = NSRect(origin: .zero, size: targetContentSize) var targetFrame = window.frameRect(forContentRect: targetContentRect) targetFrame.origin = NSPoint( x: currentFrame.midX - targetFrame.width / 2, y: currentFrame.midY - targetFrame.height / 2 ) - if let visibleFrame = (window.screen ?? NSScreen.main)?.visibleFrame { + if let visibleFrame { targetFrame = LitheWindowLayout.frame(targetFrame, fitting: visibleFrame) } window.setFrame(targetFrame, display: true, animate: shouldAnimate) diff --git a/Sources/Lithe/Views/App/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 d27a90aa..908badc0 100644 --- a/Sources/Lithe/Views/Editor/CodeEditorView.swift +++ b/Sources/Lithe/Views/Editor/CodeEditorView.swift @@ -9,8 +9,20 @@ fileprivate struct CodeEditorPalette { static let dark = CodeEditorPalette(isDark: true, theme: .lithe) var background: NSColor { themeColor(.editor) } - var gutterBackground: NSColor { themeColor(.sidebar) } - var text: NSColor { themeColor(.primaryText) } + var gutterBackground: NSColor { themeColor(.editor) } + var gutterDivider: NSColor { + color( + light: (0.78, 0.79, 0.81, 1), + dark: (0.204, 0.212, 0.231, 1) + ) + } + var text: NSColor { + guard theme == .lithe else { return themeColor(.primaryText) } + return color( + light: (0, 0, 0, 0.82), + dark: (0.737, 0.745, 0.769, 1) + ) + } var caret: NSColor { themeColor(.primaryText) } var selection: NSColor { themeColor(.accent).withAlphaComponent(isDark ? 0.42 : 0.24) } var selectionText: NSColor { themeColor(.primaryText) } @@ -55,6 +67,13 @@ fileprivate struct CodeEditorPalette { } } +private enum EditorLayoutMetrics { + static let standardGutterWidth: CGFloat = 45 + static let leadingInset: CGFloat = 0 + static let lineFragmentPadding: CGFloat = 4 + static let caretWidth: CGFloat = 2 +} + struct CodeEditorView: NSViewRepresentable { @Environment(\.colorScheme) private var colorScheme @EnvironmentObject private var model: AppModel @@ -102,7 +121,9 @@ struct CodeEditorView: NSViewRepresentable { scrollView.topAnchor.constraint(equalTo: container.topAnchor), scrollView.bottomAnchor.constraint(equalTo: container.bottomAnchor) ]) - let gutterWidthConstraint = gutter.widthAnchor.constraint(equalToConstant: 52) + let gutterWidthConstraint = gutter.widthAnchor.constraint( + equalToConstant: EditorLayoutMetrics.standardGutterWidth + ) gutterWidthConstraint.isActive = true let textView = CodeTextView(frame: NSRect(x: 0, y: 0, width: 900, height: 700)) @@ -120,7 +141,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.leadingInset, height: 0) + textView.textContainer?.lineFragmentPadding = EditorLayoutMetrics.lineFragmentPadding textView.font = LitheTheme.editorFont(size: settings.editorFontSize) textView.defaultParagraphStyle = LitheTheme.editorParagraphStyle textView.indentationWidth = settings.tabWidth @@ -191,6 +213,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) @@ -792,7 +817,9 @@ struct CodeEditorView: NSViewRepresentable { appliedBlameVisible = isBlameVisible appliedBlameLines = blameLines appliedDebugBreakpointLines = debugBreakpointLines - container?.gutterWidthConstraint?.constant = isBlameVisible ? 224 : 52 + container?.gutterWidthConstraint?.constant = isBlameVisible + ? 224 + : EditorLayoutMetrics.standardGutterWidth gutter?.update(blameLines: blameLines, isVisible: isBlameVisible) { [weak model] blame in Task { await model?.showGitCommit(blame.commitHash) } } @@ -988,6 +1015,7 @@ private struct TextLineIndex { } final class CodeTextView: NSTextView, NSLayoutManagerDelegate { + var onCaretPresentationChanged: (() -> Void)? var indentationWidth = 4 var isLanguageNavigationEnabled = false var isLanguageIntelligenceEnabled = false @@ -1035,6 +1063,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 @@ -1063,6 +1093,54 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate { super.paste(sender) } + override func setSelectedRange(_ charRange: NSRange) { + super.setSelectedRange(charRange) + synchronizeCaretPresentation() + } + + override func setSelectedRange( + _ charRange: NSRange, + affinity: NSSelectionAffinity, + stillSelecting flag: Bool + ) { + super.setSelectedRange(charRange, affinity: affinity, stillSelecting: flag) + synchronizeCaretPresentation() + } + + private func synchronizeCaretPresentation() { + updateCaretDecorations() + needsDisplay = true + onCaretPresentationChanged?() + updateInsertionPointStateAndRestartTimer(true) + } + + override func updateInsertionPointStateAndRestartTimer(_ restartFlag: Bool) { + guard restartFlag else { return } + caretPresentationGeneration &+= 1 + let generation = caretPresentationGeneration + caretVisible = true + needsDisplay = true + + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(500)) { [weak self] in + self?.startCaretBlinking(for: generation) + } + } + + private func startCaretBlinking(for generation: Int) { + guard generation == caretPresentationGeneration else { return } + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(500)) { [weak self] in + guard let self, generation == self.caretPresentationGeneration else { return } + self.caretVisible.toggle() + self.needsDisplay = true + self.startCaretBlinking(for: generation) + } + } + + override func drawInsertionPoint(in rect: NSRect, color: NSColor, turnedOn _: Bool) { + // The editor paints the caret from draw(_:) so AppKit's independent + // insertion-point blink callbacks cannot overwrite its width or phase. + } + override func performKeyEquivalent(with event: NSEvent) -> Bool { if Self.isStandardPasteShortcut(event), onPasteImage?() == true { return true @@ -1677,9 +1755,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, @@ -1750,6 +1856,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) { @@ -1849,6 +1995,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: { @@ -2583,7 +2737,12 @@ final class LineNumberGutterView: NSView { in: textContainer ) guard layoutManager.numberOfGlyphs > 0 else { - drawLineNumber(1, y: textView.textContainerInset.height) + let lineHeight = max( + 18, + layoutManager.defaultLineHeight(for: textView.font ?? .systemFont(ofSize: 13)) + ) + drawLineNumber(1, y: textView.textContainerInset.height, height: lineHeight) + drawEditorDivider(in: dirtyRect) return } @@ -2633,7 +2792,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 @@ -2646,6 +2805,17 @@ final class LineNumberGutterView: NSView { visibleRect: visibleRect, layoutManager: layoutManager ) + drawEditorDivider(in: dirtyRect) + } + + private func drawEditorDivider(in dirtyRect: NSRect) { + palette.gutterDivider.setFill() + NSRect( + x: bounds.width - 1, + y: dirtyRect.minY, + width: 1, + height: dirtyRect.height + ).fill() } private func drawFoldIndicators( @@ -2675,14 +2845,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 9e5ddab0..a3be0ea2 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 @@ -44,8 +45,10 @@ struct WorkbenchView: View { HStack(spacing: 0) { activityBar + Rectangle() + .fill(LitheTheme.divider) + .frame(width: 1) workspaceArea - Color.clear.frame(width: ActivityBarMetrics.width) } .frame(maxHeight: .infinity) .overlay(alignment: .trailing) { @@ -609,7 +612,7 @@ struct WorkbenchView: View { Spacer() } .padding(.top, ActivityBarMetrics.edgeInset) - .frame(width: ActivityBarMetrics.width) + .frame(width: ActivityBarMetrics.rightWidth) .background(LitheTheme.titlebar) } @@ -641,6 +644,9 @@ struct WorkbenchView: View { } } } + Rectangle() + .fill(LitheTheme.divider) + .frame(width: 1) pluginActivityBar } .fixedSize(horizontal: true, vertical: false) @@ -1007,7 +1013,7 @@ private struct WorkbenchWorkspaceSplitView Bool { + closeActiveProject() + return false + } } private final class RecordingProcessRunner: ProcessRunner, DatabaseProcessRunning, @unchecked Sendable { @@ -3644,7 +3715,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()