diff --git a/CodeEdit.xcodeproj/project.pbxproj b/CodeEdit.xcodeproj/project.pbxproj index 800969d08..9fdea71be 100644 --- a/CodeEdit.xcodeproj/project.pbxproj +++ b/CodeEdit.xcodeproj/project.pbxproj @@ -97,6 +97,7 @@ 30E6D0012A6E505200A58B20 /* NavigatorSidebarViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30E6D0002A6E505200A58B20 /* NavigatorSidebarViewModel.swift */; }; 3E0196732A3921AC002648D8 /* codeedit_shell_integration_rc.zsh in Resources */ = {isa = PBXBuildFile; fileRef = 3E0196722A3921AC002648D8 /* codeedit_shell_integration_rc.zsh */; }; 3E01967A2A392B45002648D8 /* codeedit_shell_integration.bash in Resources */ = {isa = PBXBuildFile; fileRef = 3E0196792A392B45002648D8 /* codeedit_shell_integration.bash */; }; + 4ACC3CF72C898EA700380885 /* QuickLookPreviewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4ACC3CF62C898EA700380885 /* QuickLookPreviewController.swift */; }; 4E7F066629602E7B00BB3C12 /* CodeEditSplitViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E7F066529602E7B00BB3C12 /* CodeEditSplitViewController.swift */; }; 4EE96ECB2960565E00FFBEA8 /* DocumentsUnitTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EE96ECA2960565E00FFBEA8 /* DocumentsUnitTests.swift */; }; 4EE96ECE296059E000FFBEA8 /* NSHapticFeedbackPerformerMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EE96ECD296059E000FFBEA8 /* NSHapticFeedbackPerformerMock.swift */; }; @@ -770,6 +771,7 @@ 30E6D0002A6E505200A58B20 /* NavigatorSidebarViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigatorSidebarViewModel.swift; sourceTree = ""; }; 3E0196722A3921AC002648D8 /* codeedit_shell_integration_rc.zsh */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = codeedit_shell_integration_rc.zsh; sourceTree = ""; }; 3E0196792A392B45002648D8 /* codeedit_shell_integration.bash */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.script.sh; path = codeedit_shell_integration.bash; sourceTree = ""; }; + 4ACC3CF62C898EA700380885 /* QuickLookPreviewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickLookPreviewController.swift; sourceTree = ""; }; 4E7F066529602E7B00BB3C12 /* CodeEditSplitViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodeEditSplitViewController.swift; sourceTree = ""; }; 4EE96ECA2960565E00FFBEA8 /* DocumentsUnitTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DocumentsUnitTests.swift; sourceTree = ""; }; 4EE96ECD296059E000FFBEA8 /* NSHapticFeedbackPerformerMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSHapticFeedbackPerformerMock.swift; sourceTree = ""; }; @@ -1449,6 +1451,7 @@ 285FEC6C27FE4AC700E57D53 /* OutlineView */ = { isa = PBXGroup; children = ( + 4ACC3CF62C898EA700380885 /* QuickLookPreviewController.swift */, 2847019D27FDDF7600F87B6B /* ProjectNavigatorOutlineView.swift */, 285FEC6D27FE4B4A00E57D53 /* ProjectNavigatorViewController.swift */, 6CC17B522C43314000834E2C /* ProjectNavigatorViewController+NSOutlineViewDelegate.swift */, @@ -4191,6 +4194,7 @@ B62AEDDC2A27C1B3009A9F52 /* OSLogType+Color.swift in Sources */, 587B9E6329301D8F00AC7927 /* GitLabAccount.swift in Sources */, 6C1CC99B2B1E7CBC0002349B /* FindNavigatorIndexBar.swift in Sources */, + 4ACC3CF72C898EA700380885 /* QuickLookPreviewController.swift in Sources */, 285FEC7027FE4B9800E57D53 /* ProjectNavigatorTableViewCell.swift in Sources */, B69D3EE32C5F536B005CF43A /* ActiveTaskView.swift in Sources */, 300051672BBD3A5D00A98562 /* ServiceContainer.swift in Sources */, @@ -5632,7 +5636,7 @@ repositoryURL = "https://github.com/groue/GRDB.swift.git"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 6.0.0; + minimumVersion = 6.29.2; }; }; 6C6BD6F229CD142C00235D17 /* XCRemoteSwiftPackageReference "collectionconcurrencykit" */ = { diff --git a/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFile.swift b/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFile.swift index f9ece260d..ed8b03660 100644 --- a/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFile.swift +++ b/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFile.swift @@ -164,6 +164,9 @@ final class CEWorkspaceFile: Codable, Comparable, Hashable, Identifiable, Editor FileIcon.iconColor(fileType: type) } + /// Returns whether the item is being opened in Quick Look. + var isOpeningInQuickLook = false + init( id: String, url: URL, diff --git a/CodeEdit/Features/Editor/Views/EditorAreaFileView.swift b/CodeEdit/Features/Editor/Views/EditorAreaFileView.swift index e5d4f9ffc..55c9ed019 100644 --- a/CodeEdit/Features/Editor/Views/EditorAreaFileView.swift +++ b/CodeEdit/Features/Editor/Views/EditorAreaFileView.swift @@ -23,18 +23,23 @@ struct EditorAreaFileView: View { var textViewCoordinators: [TextViewCoordinator] = [] @ViewBuilder var editorAreaFileView: some View { - if let utType = codeFile.utType, utType.conforms(to: .text) { - CodeFileView(codeFile: codeFile, textViewCoordinators: textViewCoordinators) - } else { - NonTextFileView(fileDocument: codeFile) - .padding(.top, edgeInsets.top - 1.74) - .padding(.bottom, StatusBarView.height + 1.26) - .modifier(UpdateStatusBarInfo(with: codeFile.fileURL)) - .onDisappear { - statusBarViewModel.dimensions = nil - statusBarViewModel.fileSize = nil - } - } + if let document = file.fileDocument { + if file.isOpeningInQuickLook { + AnyFileView(file.url) + .padding(.top, edgeInsets.top - 1.74) + .padding(.bottom, StatusBarView.height + 1.26) + } else if let utType = document.utType, utType.conforms(to: .text) { + CodeFileView(codeFile: document, textViewCoordinators: textViewCoordinators) + } else { + NonTextFileView(fileDocument: document) + .padding(.top, edgeInsets.top - 1.74) + .padding(.bottom, StatusBarView.height + 1.26) + .modifier(UpdateStatusBarInfo(with: document.fileURL)) + .onDisappear { + statusBarViewModel.dimensions = nil + statusBarViewModel.fileSize = nil + } + } } var body: some View { diff --git a/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorMenu.swift b/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorMenu.swift index 01206e590..57ceef61f 100644 --- a/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorMenu.swift +++ b/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorMenu.swift @@ -120,8 +120,9 @@ final class ProjectNavigatorMenu: NSMenu { return ([.none()], []) } var primaryItems = [NSMenuItem]() - if type.conforms(to: .sourceCode) { - primaryItems.append(.sourceCode()) + if type.conforms(to: .text) || checkFileIsText(for: item) { + primaryItems.append(menuItem("Source Code", action: #selector(openInSourceCode))) + primaryItems.append(.separator()) } if type.conforms(to: .propertyList) { primaryItems.append(.propertyList()) @@ -141,10 +142,7 @@ final class ProjectNavigatorMenu: NSMenu { secondaryItems.append(.hex()) } - // FIXME: Update the quickLook condition - if type.conforms(to: .data) { - secondaryItems.append(.quickLook()) - } + secondaryItems.append(QuickLookPreviewController.quickLookMenu(item: item, workspace: workspace)) return (primaryItems, secondaryItems) } @@ -178,6 +176,31 @@ final class ProjectNavigatorMenu: NSMenu { return sourceControlMenu } + /// Checks if the given `CEWorkspaceFile` is a text file. + /// This function verifies if the content of the file can be decoded as text. + /// It is used as a fallback when the type identifier does not conform to the expected text type. + func checkFileIsText(for item: CEWorkspaceFile) -> Bool { + do { + // Read the first 512 bytes of the file. + let fileHandle = try FileHandle(forReadingFrom: item.url) + let fileData = fileHandle.readData(ofLength: 512) + defer { fileHandle.closeFile() } + + // Extreme use case: Returns true for empty files. + // E.g. The user hasn't typed anything on it, yet. + // This is used for files like .env, .gitignore or files that lack an extension. + if fileData.isEmpty { + return true + } + + // swiftlint:disable:next non_optional_string_data_conversion + return String(data: fileData, encoding: .utf8) != nil + } catch { + print("Error reading file: \(error)") + return false + } + } + /// Updates the menu for the selected item and hides it if no item is provided. override func update() { removeAllItems() @@ -264,6 +287,16 @@ final class ProjectNavigatorMenu: NSMenu { guard let item else { return } workspace?.workspaceFileManager?.duplicate(file: item) } + + /// Action that opens the file in Source Code. + @objc + private func openInSourceCode() { + guard let item else { return } + item.isOpeningInQuickLook = false + guard let workspace, + let editorManager = workspace.editorManager else { return } + editorManager.openTab(item: item) + } } extension NSMenuItem { diff --git a/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/QuickLookPreviewController.swift b/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/QuickLookPreviewController.swift new file mode 100644 index 000000000..6cd28f57c --- /dev/null +++ b/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/QuickLookPreviewController.swift @@ -0,0 +1,59 @@ +// +// QuickLookPreviewController.swift +// CodeEdit +// +// Created by Leonardo LarraƱaga on 7/10/24. +// + +import QuickLookUI +import SwiftUI + +/// A class that handles file preview using Quick Look. +class QuickLookPreviewController: NSObject, QLPreviewPanelDataSource, QLPreviewPanelDelegate { + /// Shared instance of `QuickLookPreviewController` for global use. + static let shared = QuickLookPreviewController() + + /// URL of the item to preview. + static var previewItemURL: URL! + + /// Returns the number of items to preview in the Quick Look panel. + /// Requiered function for `QLPreviewPanelDelegate`. + func numberOfPreviewItems(in panel: QLPreviewPanel!) -> Int { + return 1 + } + + /// Returns the item to preview at the specified index. + /// Requiered function for `QLPreviewPanelDelegate`. + func previewPanel(_ panel: QLPreviewPanel!, previewItemAt index: Int) -> (any QLPreviewItem)! { + return QuickLookPreviewController.previewItemURL as QLPreviewItem + } + + /// Opens the item in a Quick Look tab. + @objc + func openQuickLookTab(_ sender: NSMenuItem) { + guard let context = sender.representedObject as? (CEWorkspaceFile, WorkspaceDocument?) else { return } + let (item, workspace) = context + + guard let workspace, + let editorManager = workspace.editorManager else { return } + item.isOpeningInQuickLook = true + editorManager.openTab(item: item) + } + + /// Creates a Quick Look menu for the specified item. + /// - Parameter item: The workspace file (`CEWorkspaceFile`) for which the menu item will be created. + /// - Returns: A menu item configured to open Quick Look. + static func quickLookMenu(item: CEWorkspaceFile, workspace: WorkspaceDocument?) -> NSMenuItem { + QuickLookPreviewController.previewItemURL = item.url + + let quickLookMenuItem = NSMenuItem( + title: "Quick Look", + action: #selector(QuickLookPreviewController.shared.openQuickLookTab(_:)), + keyEquivalent: "" + ) + quickLookMenuItem.target = QuickLookPreviewController.shared + quickLookMenuItem.representedObject = (item, workspace) + + return quickLookMenuItem + } +}