diff --git a/CodeEdit.xcodeproj/project.pbxproj b/CodeEdit.xcodeproj/project.pbxproj index ace9bd14fa..bc0e0ae3e5 100644 --- a/CodeEdit.xcodeproj/project.pbxproj +++ b/CodeEdit.xcodeproj/project.pbxproj @@ -17,6 +17,7 @@ 58F2EB03292FB2B0004A9BDE /* Documentation.docc in Sources */ = {isa = PBXBuildFile; fileRef = 58F2EACE292FB2B0004A9BDE /* Documentation.docc */; }; 58F2EB1E292FB954004A9BDE /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = 58F2EB1D292FB954004A9BDE /* Sparkle */; }; 5E4485612DF600D9008BBE69 /* AboutWindow in Frameworks */ = {isa = PBXBuildFile; productRef = 5E4485602DF600D9008BBE69 /* AboutWindow */; }; + 5EACE6222DF4BF08005E08B8 /* WelcomeWindow in Frameworks */ = {isa = PBXBuildFile; productRef = 5EACE6212DF4BF08005E08B8 /* WelcomeWindow */; }; 6C0617D62BDB4432008C9C42 /* LogStream in Frameworks */ = {isa = PBXBuildFile; productRef = 6C0617D52BDB4432008C9C42 /* LogStream */; }; 6C0824A12C5C0C9700A0751E /* SwiftTerm in Frameworks */ = {isa = PBXBuildFile; productRef = 6C0824A02C5C0C9700A0751E /* SwiftTerm */; }; 6C147C4529A329350089B630 /* OrderedCollections in Frameworks */ = {isa = PBXBuildFile; productRef = 6C147C4429A329350089B630 /* OrderedCollections */; }; @@ -182,6 +183,7 @@ 6C73A6D32D4F1E550012D95C /* CodeEditSourceEditor in Frameworks */, 2816F594280CF50500DD548B /* CodeEditSymbols in Frameworks */, 30CB64942C16CA9100CC8A9E /* LanguageClient in Frameworks */, + 5EACE6222DF4BF08005E08B8 /* WelcomeWindow in Frameworks */, 6C6BD6F829CD14D100235D17 /* CodeEditKit in Frameworks */, 6C0824A12C5C0C9700A0751E /* SwiftTerm in Frameworks */, 6C81916B29B41DD300B75C92 /* DequeModule in Frameworks */, @@ -319,6 +321,7 @@ 6CB94D022CA1205100E8651C /* AsyncAlgorithms */, 6CC00A8A2CBEF150004E8134 /* CodeEditSourceEditor */, 6C73A6D22D4F1E550012D95C /* CodeEditSourceEditor */, + 5EACE6212DF4BF08005E08B8 /* WelcomeWindow */, 5E4485602DF600D9008BBE69 /* AboutWindow */, ); productName = CodeEdit; @@ -423,6 +426,7 @@ 6C4E37FA2C73E00700AEE7B5 /* XCRemoteSwiftPackageReference "SwiftTerm" */, 6CB94D012CA1205100E8651C /* XCRemoteSwiftPackageReference "swift-async-algorithms" */, 6CF368562DBBD274006A77FD /* XCRemoteSwiftPackageReference "CodeEditSourceEditor" */, + 5EACE6202DF4BF08005E08B8 /* XCRemoteSwiftPackageReference "WelcomeWindow" */, 5E44855F2DF600D9008BBE69 /* XCRemoteSwiftPackageReference "AboutWindow" */, ); preferredProjectObjectVersion = 55; @@ -1699,8 +1703,16 @@ isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/CodeEditApp/AboutWindow"; requirement = { - branch = main; - kind = branch; + kind = upToNextMajorVersion; + minimumVersion = 1.0.0; + }; + }; + 5EACE6202DF4BF08005E08B8 /* XCRemoteSwiftPackageReference "WelcomeWindow" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/CodeEditApp/WelcomeWindow"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.0.0; }; }; 6C0617D42BDB4432008C9C42 /* XCRemoteSwiftPackageReference "LogStream" */ = { @@ -1808,6 +1820,11 @@ package = 5E44855F2DF600D9008BBE69 /* XCRemoteSwiftPackageReference "AboutWindow" */; productName = AboutWindow; }; + 5EACE6212DF4BF08005E08B8 /* WelcomeWindow */ = { + isa = XCSwiftPackageProductDependency; + package = 5EACE6202DF4BF08005E08B8 /* XCRemoteSwiftPackageReference "WelcomeWindow" */; + productName = WelcomeWindow; + }; 6C0617D52BDB4432008C9C42 /* LogStream */ = { isa = XCSwiftPackageProductDependency; package = 6C0617D42BDB4432008C9C42 /* XCRemoteSwiftPackageReference "LogStream" */; diff --git a/CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 8c45a5d0bf..5889948339 100644 --- a/CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,13 +1,13 @@ { - "originHash" : "d2fb154bcae6930206686830e061ba429d8f987fd11340e94a6d0b526b000a20", + "originHash" : "caf7678c3c52812febb80907a6a451d5e91a20058bbe45d250d7234c51299e91", "pins" : [ { "identity" : "aboutwindow", "kind" : "remoteSourceControl", "location" : "https://github.com/CodeEditApp/AboutWindow", "state" : { - "branch" : "main", - "revision" : "729e958450a639d517ea780a1884c5a48dd9ec80" + "revision" : "79c7c01fb739d024a3ca07fe153a068339213baf", + "version" : "1.0.0" } }, { @@ -287,6 +287,15 @@ "revision" : "d97db6d63507eb62c536bcb2c4ac7d70c8ec665e", "version" : "0.23.2" } + }, + { + "identity" : "welcomewindow", + "kind" : "remoteSourceControl", + "location" : "https://github.com/CodeEditApp/WelcomeWindow", + "state" : { + "revision" : "5168cf1ce9579b35ad00706fafef441418d8011f", + "version" : "1.0.0" + } } ], "version" : 3 diff --git a/CodeEdit.xcodeproj/xcshareddata/xcschemes/OpenWithCodeEdit.xcscheme b/CodeEdit.xcodeproj/xcshareddata/xcschemes/OpenWithCodeEdit.xcscheme index 88a7108b5a..2ef8c219e1 100644 --- a/CodeEdit.xcodeproj/xcshareddata/xcschemes/OpenWithCodeEdit.xcscheme +++ b/CodeEdit.xcodeproj/xcshareddata/xcschemes/OpenWithCodeEdit.xcscheme @@ -1,6 +1,6 @@ Void) { + guard let newDocumentUrl = self.newDocumentUrl else { return } + + let createdFile = self.fileManager.createFile( + atPath: newDocumentUrl.path, + contents: nil, + attributes: [FileAttributeKey.creationDate: Date()] + ) + + guard createdFile else { + print("Failed to create new document") + return + } + + self.openDocument(withContentsOf: newDocumentUrl, display: true) { _, _, _ in + onCompletion() + } + } + override func newDocument(_ sender: Any?) { guard let newDocumentUrl = self.newDocumentUrl else { return } @@ -71,7 +92,7 @@ final class CodeEditDocumentController: NSDocumentController { print("Unable to open document '\(url)': \(errorMessage)") } - RecentProjectsStore.shared.documentOpened(at: url) + RecentsStore.documentOpened(at: url) completionHandler(document, documentWasAlreadyOpen, error) } } diff --git a/CodeEdit/Features/Welcome/GitCloneButton.swift b/CodeEdit/Features/Welcome/GitCloneButton.swift new file mode 100644 index 0000000000..08293aafcb --- /dev/null +++ b/CodeEdit/Features/Welcome/GitCloneButton.swift @@ -0,0 +1,45 @@ +// +// GitCloneButton.swift +// CodeEdit +// +// Created by Giorgi Tchelidze on 07.06.25. +// + +import SwiftUI +import WelcomeWindow + +struct GitCloneButton: View { + + @State private var showGitClone = false + @State private var showCheckoutBranchItem: URL? + + var dismissWindow: () -> Void + + var body: some View { + WelcomeButton( + iconName: "square.and.arrow.down.on.square", + title: "Clone Git Repository...", + action: { + showGitClone = true + } + ) + .sheet(isPresented: $showGitClone) { + GitCloneView( + openBranchView: { url in + showCheckoutBranchItem = url + }, + openDocument: { url in + CodeEditDocumentController.shared.openDocument(at: url, onCompletion: { dismissWindow() }) + } + ) + } + .sheet(item: $showCheckoutBranchItem) { url in + GitCheckoutBranchView( + repoLocalPath: url, + openDocument: { url in + CodeEditDocumentController.shared.openDocument(at: url, onCompletion: { dismissWindow() }) + } + ) + } + } +} diff --git a/CodeEdit/Features/Welcome/Model/RecentProjectsStore.swift b/CodeEdit/Features/Welcome/Model/RecentProjectsStore.swift deleted file mode 100644 index 53fdaa641a..0000000000 --- a/CodeEdit/Features/Welcome/Model/RecentProjectsStore.swift +++ /dev/null @@ -1,133 +0,0 @@ -// -// RecentProjectsUtil.swift -// CodeEdit -// -// Created by Khan Winter on 10/22/24. -// - -import AppKit -import CoreSpotlight -import OSLog - -/// Helper methods for managing the recent projects list and donating list items to CoreSpotlight. -/// -/// Limits the number of remembered projects to 100 items. -/// -/// If a UI element needs to listen to changes in this list, listen for the -/// ``RecentProjectsStore/didUpdateNotification`` notification. -class RecentProjectsStore { - private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "", category: "RecentProjectsStore") - - /// The default projects store, uses the `UserDefaults.standard` storage location. - static let shared = RecentProjectsStore() - - private static let projectsdDefaultsKey = "recentProjectPaths" - static let didUpdateNotification = Notification.Name("RecentProjectsStore.didUpdate") - - /// The storage location for recent projects - let defaults: UserDefaults - - #if DEBUG - /// Create a new store with a `UserDefaults` storage location. - init(defaults: UserDefaults = UserDefaults.standard) { - self.defaults = defaults - } - #else - /// Create a new store with a `UserDefaults` storage location. - private init(defaults: UserDefaults = UserDefaults.standard) { - self.defaults = defaults - } - #endif - - /// Gets the recent paths array from `UserDefaults`. - private func recentPaths() -> [String] { - defaults.array(forKey: Self.projectsdDefaultsKey) as? [String] ?? [] - } - - /// Gets all recent paths from `UserDefaults` as an array of `URL`s. Includes both **projects** and - /// **single files**. - /// To filter for either projects or single files, use ``recentProjectURLs()`` or ``recentFileURLs``, respectively. - func recentURLs() -> [URL] { - recentPaths().map { URL(filePath: $0) } - } - - /// Gets the recent **Project** `URL`s from `UserDefaults`. - /// To get both single files and projects, use ``recentURLs()``. - func recentProjectURLs() -> [URL] { - recentURLs().filter { $0.isFolder } - } - - /// Gets the recent **Single File** `URL`s from `UserDefaults`. - /// To get both single files and projects, use ``recentURLs()``. - func recentFileURLs() -> [URL] { - recentURLs().filter { !$0.isFolder } - } - - /// Save a new paths array to defaults. Automatically limits the list to the most recent `100` items, donates - /// search items to Spotlight, and notifies observers. - private func setPaths(_ paths: [String]) { - defaults.setValue(Array(paths.prefix(100)), forKey: Self.projectsdDefaultsKey) - setDocumentControllerRecents() - donateSearchableItems() - NotificationCenter.default.post(name: Self.didUpdateNotification, object: nil) - } - - /// Notify the store that a url was opened. - /// Moves the path to the front if it was in the list already, or prepends it. - /// Saves the list to defaults when called. - /// - Parameter url: The url that was opened. Any url is accepted. File, directory, https. - func documentOpened(at url: URL) { - var projectURLs = recentURLs() - - if let containedIndex = projectURLs.firstIndex(where: { $0.componentCompare(url) }) { - projectURLs.move(fromOffsets: IndexSet(integer: containedIndex), toOffset: 0) - } else { - projectURLs.insert(url, at: 0) - } - - setPaths(projectURLs.map { $0.path(percentEncoded: false) }) - } - - /// Remove all project paths in the set. - /// - Parameter paths: The paths to remove. - /// - Returns: The remaining urls in the recent projects list. - func removeRecentProjects(_ paths: Set) -> [URL] { - let paths = Set(paths.map { $0.path(percentEncoded: false) }) - var recentProjectPaths = recentPaths() - recentProjectPaths.removeAll(where: { paths.contains($0) }) - setPaths(recentProjectPaths) - return recentURLs() - } - - func clearList() { - setPaths([]) - NotificationCenter.default.post(name: Self.didUpdateNotification, object: nil) - } - - /// Syncs AppKit's recent documents list with ours, keeping the dock menu and other lists up-to-date. - private func setDocumentControllerRecents() { - CodeEditDocumentController.shared.clearRecentDocuments(nil) - for path in recentURLs().prefix(10) { - CodeEditDocumentController.shared.noteNewRecentDocumentURL(path) - } - } - - /// Donates all recent URLs to Core Search, making them searchable in Spotlight - private func donateSearchableItems() { - let searchableItems = recentURLs().map { entity in - let attributeSet = CSSearchableItemAttributeSet(contentType: .content) - attributeSet.title = entity.lastPathComponent - attributeSet.relatedUniqueIdentifier = entity.path() - return CSSearchableItem( - uniqueIdentifier: entity.path(), - domainIdentifier: "app.codeedit.CodeEdit.ProjectItem", - attributeSet: attributeSet - ) - } - CSSearchableIndex.default().indexSearchableItems(searchableItems) { [weak self] error in - if let error = error { - self?.logger.debug("Failed to donate recent projects, error: \(error, privacy: .auto)") - } - } - } -} diff --git a/CodeEdit/Features/Welcome/NewFileButton.swift b/CodeEdit/Features/Welcome/NewFileButton.swift new file mode 100644 index 0000000000..75261faee5 --- /dev/null +++ b/CodeEdit/Features/Welcome/NewFileButton.swift @@ -0,0 +1,25 @@ +// +// NewFileButton.swift +// CodeEdit +// +// Created by Giorgi Tchelidze on 07.06.25. +// + +import SwiftUI +import WelcomeWindow + +struct NewFileButton: View { + + var dismissWindow: () -> Void + + var body: some View { + WelcomeButton( + iconName: "plus.square", + title: "Create New File...", + action: { + let documentController = CodeEditDocumentController() + documentController.createAndOpenNewDocument(onCompletion: { dismissWindow() }) + } + ) + } +} diff --git a/CodeEdit/Features/Welcome/OpenFileOrFolderButton.swift b/CodeEdit/Features/Welcome/OpenFileOrFolderButton.swift new file mode 100644 index 0000000000..78a8b9467e --- /dev/null +++ b/CodeEdit/Features/Welcome/OpenFileOrFolderButton.swift @@ -0,0 +1,31 @@ +// +// OpenFileOrFolderButton.swift +// CodeEdit +// +// Created by Giorgi Tchelidze on 07.06.25. +// + +import SwiftUI +import WelcomeWindow + +struct OpenFileOrFolderButton: View { + + @Environment(\.openWindow) + private var openWindow + + var dismissWindow: () -> Void + + var body: some View { + WelcomeButton( + iconName: "folder", + title: "Open File or Folder...", + action: { + CodeEditDocumentController.shared.openDocumentWithDialog( + configuration: .init(canChooseFiles: true, canChooseDirectories: true), + onDialogPresented: { dismissWindow() }, + onCancel: { openWindow(id: DefaultSceneID.welcome) } + ) + } + ) + } +} diff --git a/CodeEdit/Features/Welcome/Views/RecentProjectListItem.swift b/CodeEdit/Features/Welcome/Views/RecentProjectListItem.swift deleted file mode 100644 index e69eeee01c..0000000000 --- a/CodeEdit/Features/Welcome/Views/RecentProjectListItem.swift +++ /dev/null @@ -1,44 +0,0 @@ -// -// RecentProjectListItem.swift -// CodeEditModules/WelcomeModule -// -// Created by Ziyuan Zhao on 2022/3/18. -// - -import SwiftUI - -extension String { - func abbreviatingWithTildeInPath() -> String { - (self as NSString).abbreviatingWithTildeInPath - } -} - -struct RecentProjectListItem: View { - let projectPath: URL - - init(projectPath: URL) { - self.projectPath = projectPath - } - - var body: some View { - HStack(spacing: 8) { - Image(nsImage: NSWorkspace.shared.icon(forFile: projectPath.path(percentEncoded: false))) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 32, height: 32) - VStack(alignment: .leading) { - Text(projectPath.lastPathComponent) - .foregroundColor(.primary) - .font(.system(size: 13, weight: .semibold)) - .lineLimit(1) - Text(projectPath.deletingLastPathComponent().path(percentEncoded: false).abbreviatingWithTildeInPath()) - .foregroundColor(.secondary) - .font(.system(size: 11)) - .lineLimit(1) - .truncationMode(.head) - } - } - .frame(height: 36) - .contentShape(Rectangle()) - } -} diff --git a/CodeEdit/Features/Welcome/Views/RecentProjectsListView.swift b/CodeEdit/Features/Welcome/Views/RecentProjectsListView.swift deleted file mode 100644 index f39c14327c..0000000000 --- a/CodeEdit/Features/Welcome/Views/RecentProjectsListView.swift +++ /dev/null @@ -1,100 +0,0 @@ -// -// RecentProjectsListView.swift -// CodeEdit -// -// Created by Wouter Hennen on 02/02/2023. -// - -import SwiftUI -import CoreSpotlight - -struct RecentProjectsListView: View { - - @State private var selection: Set - @State var recentProjects: [URL] - - private let openDocument: (URL?, @escaping () -> Void) -> Void - private let dismissWindow: () -> Void - - init(openDocument: @escaping (URL?, @escaping () -> Void) -> Void, dismissWindow: @escaping () -> Void) { - self.openDocument = openDocument - self.dismissWindow = dismissWindow - self._recentProjects = .init(initialValue: RecentProjectsStore.shared.recentURLs()) - self._selection = .init(initialValue: Set(RecentProjectsStore.shared.recentURLs().prefix(1))) - } - - var listEmptyView: some View { - VStack { - Spacer() - Text(NSLocalizedString("No Recent Projects", comment: "")) - .font(.body) - .foregroundColor(.secondary) - Spacer() - } - } - - var body: some View { - List(recentProjects, id: \.self, selection: $selection) { project in - RecentProjectListItem(projectPath: project) - } - .listStyle(.sidebar) - .contextMenu(forSelectionType: URL.self) { items in - switch items.count { - case 0: - EmptyView() - default: - Button("Show in Finder") { - NSWorkspace.shared.activateFileViewerSelecting(Array(items)) - } - - Button("Copy path\(items.count > 1 ? "s" : "")") { - let pasteBoard = NSPasteboard.general - pasteBoard.clearContents() - pasteBoard.writeObjects(selection.map(\.relativePath) as [NSString]) - } - - Button("Remove from Recents") { - removeRecentProjects() - } - } - } primaryAction: { items in - items.forEach { openDocument($0, dismissWindow) } - } - .onCopyCommand { - selection.map { NSItemProvider(object: $0.path(percentEncoded: false) as NSString) } - } - .onDeleteCommand { - removeRecentProjects() - } - .background(EffectView(.underWindowBackground, blendingMode: .behindWindow)) - .background { - Button("") { - selection.forEach { openDocument($0, dismissWindow) } - } - .keyboardShortcut(.defaultAction) - .hidden() - } - .overlay { - Group { - if recentProjects.isEmpty { - listEmptyView - } - } - } - .onReceive( - NotificationCenter - .default - .publisher(for: RecentProjectsStore.didUpdateNotification).receive(on: RunLoop.main) - ) { _ in - updateRecentProjects() - } - } - - func removeRecentProjects() { - recentProjects = RecentProjectsStore.shared.removeRecentProjects(selection) - } - - func updateRecentProjects() { - recentProjects = RecentProjectsStore.shared.recentURLs() - } -} diff --git a/CodeEdit/Features/Welcome/Views/WelcomeActionView.swift b/CodeEdit/Features/Welcome/Views/WelcomeActionView.swift deleted file mode 100644 index ded382068c..0000000000 --- a/CodeEdit/Features/Welcome/Views/WelcomeActionView.swift +++ /dev/null @@ -1,47 +0,0 @@ -// -// WelcomeActionView.swift -// CodeEditModules/WelcomeModule -// -// Created by Ziyuan Zhao on 2022/3/18. -// - -import SwiftUI - -struct WelcomeActionView: View { - var iconName: String - var title: String - var action: () -> Void - - init(iconName: String, title: String, action: @escaping () -> Void) { - self.iconName = iconName - self.title = title - self.action = action - } - - var body: some View { - Button(action: action, label: { - HStack(spacing: 7) { - Image(systemName: iconName) - .aspectRatio(contentMode: .fit) - .foregroundColor(.secondary) - .font(.system(size: 20)) - .frame(width: 24) - Text(title) - .font(.system(size: 13, weight: .semibold)) - Spacer() - } - }) - .buttonStyle(WelcomeActionButtonStyle()) - } -} - -struct WelcomeActionButtonStyle: ButtonStyle { - func makeBody(configuration: Configuration) -> some View { - configuration.label - .contentShape(Rectangle()) - .padding(7) - .frame(height: 36) - .background(Color(.labelColor).opacity(configuration.isPressed ? 0.1 : 0.05)) - .clipShape(RoundedRectangle(cornerRadius: 8)) - } -} diff --git a/CodeEdit/Features/Welcome/Views/WelcomeView.swift b/CodeEdit/Features/Welcome/Views/WelcomeView.swift deleted file mode 100644 index a1529859e9..0000000000 --- a/CodeEdit/Features/Welcome/Views/WelcomeView.swift +++ /dev/null @@ -1,236 +0,0 @@ -// -// WelcomeView.swift -// CodeEditModules/WelcomeModule -// -// Created by Ziyuan Zhao on 2022/3/18. -// - -import SwiftUI -import AppKit -import Foundation - -struct WelcomeView: View { - @Environment(\.colorScheme) - var colorScheme - - @Environment(\.controlActiveState) - var controlActiveState - - @AppSettings(\.general.reopenBehavior) - var reopenBehavior - - @State var showGitClone = false - - @State var showCheckoutBranchItem: URL? - - @State var isHovering: Bool = false - - @State var isHoveringCloseButton: Bool = false - - private let openDocument: (URL?, @escaping () -> Void) -> Void - private let newDocument: () -> Void - private let dismissWindow: () -> Void - - init( - openDocument: @escaping (URL?, @escaping () -> Void) -> Void, - newDocument: @escaping () -> Void, - dismissWindow: @escaping () -> Void - ) { - self.openDocument = openDocument - self.newDocument = newDocument - self.dismissWindow = dismissWindow - } - - private var showWhenLaunchedBinding: Binding { - Binding { - reopenBehavior == .welcome - } set: { new in - reopenBehavior = new ? .welcome : .openPanel - } - } - - private var appVersion: String { - Bundle.versionString ?? "" - } - - private var appBuild: String { - Bundle.buildString ?? "" - } - - private var appVersionPostfix: String { - Bundle.versionPostfix ?? "" - } - - /// Get the macOS version & build - private var macOSVersion: String { - let url = URL(fileURLWithPath: "/System/Library/CoreServices/SystemVersion.plist") - guard let dict = NSDictionary(contentsOf: url), - let version = dict["ProductUserVisibleVersion"], - let build = dict["ProductBuildVersion"] - else { - return ProcessInfo.processInfo.operatingSystemVersionString - } - - return "\(version) (\(build))" - } - - /// Return the Xcode version and build (if installed) - private var xcodeVersion: String? { - guard let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: "com.apple.dt.Xcode"), - let bundle = Bundle(url: url), - let infoDict = bundle.infoDictionary, - let version = infoDict["CFBundleShortVersionString"] as? String, - let buildURL = URL(string: "\(url)Contents/version.plist"), - let buildDict = try? NSDictionary(contentsOf: buildURL, error: ()), - let build = buildDict["ProductBuildVersion"] - else { - return nil - } - - return "\(version) (\(build))" - } - - /// Get program and operating system information - private func copyInformation() { - var copyString = "CodeEdit: \(appVersion)\(appVersionPostfix) (\(appBuild))\n" - - copyString.append("macOS: \(macOSVersion)\n") - - if let xcodeVersion { - copyString.append("Xcode: \(xcodeVersion)") - } - - let pasteboard = NSPasteboard.general - pasteboard.clearContents() - pasteboard.setString(copyString, forType: .string) - } - - var body: some View { - ZStack(alignment: .topLeading) { - mainContent - dismissButton - } - .onHover { isHovering in - self.isHovering = isHovering - } - .sheet(isPresented: $showGitClone) { - GitCloneView( - openBranchView: { url in - showCheckoutBranchItem = url - }, - openDocument: { url in - openDocument(url, dismissWindow) - } - ) - } - .sheet(item: $showCheckoutBranchItem) { url in - GitCheckoutBranchView( - repoLocalPath: url, - openDocument: { url in - openDocument(url, dismissWindow) - } - ) - } - } - - private var mainContent: some View { - VStack(spacing: 0) { - Spacer().frame(height: 32) - ZStack { - if colorScheme == .dark { - Rectangle() - .frame(width: 104, height: 104) - .foregroundColor(.accentColor) - .clipShape(RoundedRectangle(cornerRadius: 24)) - .blur(radius: 64) - .opacity(0.5) - } - Image(nsImage: NSApp.applicationIconImage) - .resizable() - .frame(width: 128, height: 128) - } - Text(NSLocalizedString("CodeEdit", comment: "")) - .font(.system(size: 36, weight: .bold)) - Text( - String( - format: NSLocalizedString("Version %@%@ (%@)", comment: ""), - appVersion, - appVersionPostfix, - appBuild - ) - ) - .textSelection(.enabled) - .foregroundColor(.secondary) - .font(.system(size: 13.5)) - .onHover { hover in - if hover { - NSCursor.pointingHand.push() - } else { - NSCursor.pop() - } - } - .onTapGesture { - copyInformation() - } - .help("Copy System Information to Clipboard") - - Spacer().frame(height: 40) - HStack { - VStack(alignment: .leading, spacing: 8) { - WelcomeActionView( - iconName: "plus.square", - title: NSLocalizedString("Create New File...", comment: ""), - action: { - newDocument() - dismissWindow() - } - ) - WelcomeActionView( - iconName: "square.and.arrow.down.on.square", - title: NSLocalizedString("Clone Git Repository...", comment: ""), - action: { - showGitClone = true - } - ) - WelcomeActionView( - iconName: "folder", - title: NSLocalizedString("Open File or Folder...", comment: ""), - action: { - openDocument(nil, dismissWindow) - } - ) - } - } - Spacer() - } - .padding(.top, 20) - .padding(.horizontal, 56) - .padding(.bottom, 16) - .frame(width: 460) - .background( - colorScheme == .dark - ? Color(.black).opacity(0.2) - : Color(.white).opacity(controlActiveState == .inactive ? 1.0 : 0.5) - ) - .background(EffectView(.underWindowBackground, blendingMode: .behindWindow)) - } - - private var dismissButton: some View { - Button( - action: dismissWindow, - label: { - Image(systemName: "xmark.circle.fill") - .foregroundColor(isHoveringCloseButton ? Color(.secondaryLabelColor) : Color(.tertiaryLabelColor)) - } - ) - .buttonStyle(.plain) - .accessibilityLabel(Text("Close")) - .onHover { hover in - withAnimation(.linear(duration: 0.15)) { - isHoveringCloseButton = hover - } - } - .padding(10) - .transition(AnyTransition.opacity.animation(.easeInOut(duration: 0.25))) - } -} diff --git a/CodeEdit/Features/Welcome/Views/WelcomeWindow.swift b/CodeEdit/Features/Welcome/Views/WelcomeWindow.swift deleted file mode 100644 index e80014e48d..0000000000 --- a/CodeEdit/Features/Welcome/Views/WelcomeWindow.swift +++ /dev/null @@ -1,59 +0,0 @@ -// -// WelcomeWindow.swift -// CodeEdit -// -// Created by Wouter Hennen on 13/03/2023. -// - -import SwiftUI - -struct WelcomeWindow: Scene { - - @ObservedObject var settings = Settings.shared - - var body: some Scene { - Window("Welcome To CodeEdit", id: SceneID.welcome.rawValue) { - ContentView() - .frame(width: 740, height: 432) - .task { - if let window = NSApp.findWindow(.welcome) { - window.standardWindowButton(.closeButton)?.isHidden = true - window.standardWindowButton(.miniaturizeButton)?.isHidden = true - window.standardWindowButton(.zoomButton)?.isHidden = true - window.isMovableByWindowBackground = true - } - } - } - .windowStyle(.hiddenTitleBar) - .windowResizability(.contentSize) - } - - struct ContentView: View { - @Environment(\.dismiss) - var dismiss - @Environment(\.openWindow) - var openWindow - - var body: some View { - WelcomeWindowView { url, opened in - if let url { - CodeEditDocumentController.shared.openDocument(withContentsOf: url, display: true) { doc, _, _ in - if doc != nil { - opened() - } - } - } else { - dismiss() - CodeEditDocumentController.shared.openDocument( - onCompletion: { _, _ in opened() }, - onCancel: { openWindow(sceneID: .welcome) } - ) - } - } newDocument: { - CodeEditDocumentController.shared.newDocument(nil) - } dismissWindow: { - dismiss() - } - } - } -} diff --git a/CodeEdit/Features/Welcome/Views/WelcomeWindowView.swift b/CodeEdit/Features/Welcome/Views/WelcomeWindowView.swift deleted file mode 100644 index 378f930849..0000000000 --- a/CodeEdit/Features/Welcome/Views/WelcomeWindowView.swift +++ /dev/null @@ -1,53 +0,0 @@ -// -// WelcomeWindowView.swift -// CodeEditModules/WelcomeModule -// -// Created by Ziyuan Zhao on 2022/3/18. -// - -import SwiftUI - -struct WelcomeWindowView: View { - private let openDocument: (URL?, @escaping () -> Void) -> Void - private let newDocument: () -> Void - private let dismissWindow: () -> Void - - init( - openDocument: @escaping (URL?, @escaping () -> Void) -> Void, - newDocument: @escaping () -> Void, - dismissWindow: @escaping () -> Void - ) { - self.openDocument = openDocument - self.newDocument = newDocument - self.dismissWindow = dismissWindow - } - - var body: some View { - HStack(spacing: 0) { - WelcomeView( - openDocument: openDocument, - newDocument: newDocument, - dismissWindow: dismissWindow - ) - RecentProjectsListView(openDocument: openDocument, dismissWindow: dismissWindow) - .frame(width: 280) - } - .edgesIgnoringSafeArea(.top) - .onDrop(of: [.fileURL], isTargeted: .constant(true)) { providers in - NSApp.activate(ignoringOtherApps: true) - providers.forEach { - _ = $0.loadDataRepresentation(for: .fileURL) { data, _ in - if let data, let url = URL(dataRepresentation: data, relativeTo: nil) { - Task { - try? await CodeEditDocumentController - .shared - .openDocument(withContentsOf: url, display: true) - } - } - } - } - dismissWindow() - return true - } - } -} diff --git a/CodeEdit/Features/Welcome/WelcomeSubtitleView.swift b/CodeEdit/Features/Welcome/WelcomeSubtitleView.swift new file mode 100644 index 0000000000..a754da2214 --- /dev/null +++ b/CodeEdit/Features/Welcome/WelcomeSubtitleView.swift @@ -0,0 +1,61 @@ +// +// WelcomeSubtitleView.swift +// CodeEdit +// +// Created by Giorgi Tchelidze on 07.06.25. +// + +import SwiftUI +import WelcomeWindow + +struct WelcomeSubtitleView: View { + + private var appVersion: String { Bundle.versionString ?? "" } + private var appBuild: String { Bundle.buildString ?? "" } + private var appVersionPostfix: String { Bundle.versionPostfix ?? "" } + + private var macOSVersion: String { + let url = URL(fileURLWithPath: "/System/Library/CoreServices/SystemVersion.plist") + guard let dict = NSDictionary(contentsOf: url), + let version = dict["ProductUserVisibleVersion"], + let build = dict["ProductBuildVersion"] else { + return ProcessInfo.processInfo.operatingSystemVersionString + } + return "\(version) (\(build))" + } + + private var xcodeVersion: String? { + guard let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: "com.apple.dt.Xcode"), + let bundle = Bundle(url: url), + let infoDict = bundle.infoDictionary, + let version = infoDict["CFBundleShortVersionString"] as? String, + let buildURL = URL(string: "\(url)Contents/version.plist"), + let buildDict = try? NSDictionary(contentsOf: buildURL, error: ()), + let build = buildDict["ProductBuildVersion"] + else { + return nil + } + return "\(version) (\(build))" + } + + private func copyInformation() { + var copyString = "\(Bundle.displayName): \(appVersion)\(appVersionPostfix) (\(appBuild))\n" + copyString.append("macOS: \(macOSVersion)\n") + if let xcodeVersion { copyString.append("Xcode: \(xcodeVersion)") } + + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(copyString, forType: .string) + } + + var body: some View { + Text(String( + format: NSLocalizedString("Version %@%@ (%@)", comment: ""), + appVersion, appVersionPostfix, appBuild + )) + .textSelection(.enabled) + .onHover { $0 ? NSCursor.pointingHand.push() : NSCursor.pop() } + .onTapGesture { copyInformation() } + .help("Copy System Information to Clipboard") + } +} diff --git a/CodeEdit/Features/WindowCommands/Utils/CommandsFixes.swift b/CodeEdit/Features/WindowCommands/Utils/CommandsFixes.swift index d4de828087..3cc5439d4f 100644 --- a/CodeEdit/Features/WindowCommands/Utils/CommandsFixes.swift +++ b/CodeEdit/Features/WindowCommands/Utils/CommandsFixes.swift @@ -12,6 +12,7 @@ extension EventModifiers { } extension NSMenuItem { + @MainActor @objc fileprivate func fixAlternate(_ newValue: NSEvent.ModifierFlags) { if newValue.contains(.numericPad) { diff --git a/CodeEdit/Features/WindowCommands/Utils/RecentProjectsMenu.swift b/CodeEdit/Features/WindowCommands/Utils/RecentProjectsMenu.swift index f4c3c3d337..67ad05cc80 100644 --- a/CodeEdit/Features/WindowCommands/Utils/RecentProjectsMenu.swift +++ b/CodeEdit/Features/WindowCommands/Utils/RecentProjectsMenu.swift @@ -6,33 +6,64 @@ // import AppKit +import WelcomeWindow -class RecentProjectsMenu: NSObject { - let projectsStore: RecentProjectsStore +@MainActor +final class RecentProjectsMenu: NSObject, NSMenuDelegate { - init(projectsStore: RecentProjectsStore = .shared) { - self.projectsStore = projectsStore - } + // MARK: - Menu construction + + private let menuTitle = NSLocalizedString( + "Open Recent", + comment: "Open Recent menu title" + ) + private lazy var menu: NSMenu = { + let menu = NSMenu(title: menuTitle) + menu.delegate = self // <- make the menu ask us for updates + return menu + }() + + /// Entry point used by the caller (e.g. the main menu bar template). func makeMenu() -> NSMenu { - let menu = NSMenu(title: NSLocalizedString("Open Recent", comment: "Open Recent menu title")) + rebuildMenu() + return menu + } + + /// Called automatically right before the menu gets displayed. + func menuNeedsUpdate(_ menu: NSMenu) { + rebuildMenu() + } - addFileURLs(to: menu, fileURLs: projectsStore.recentProjectURLs().prefix(10)) - menu.addItem(NSMenuItem.separator()) - addFileURLs(to: menu, fileURLs: projectsStore.recentFileURLs().prefix(10)) - menu.addItem(NSMenuItem.separator()) + // Rebuilds the whole “Open Recent” menu. + private func rebuildMenu() { + menu.removeAllItems() + + addFileURLs( + to: menu, + fileURLs: RecentsStore.recentDirectoryURLs().prefix(10) + ) + menu.addItem(.separator()) + addFileURLs( + to: menu, + fileURLs: RecentsStore.recentFileURLs().prefix(10) + ) + menu.addItem(.separator()) let clearMenuItem = NSMenuItem( - title: NSLocalizedString("Clear Menu", comment: "Recent project menu clear button"), + title: NSLocalizedString( + "Clear Menu", + comment: "Recent project menu clear button" + ), action: #selector(clearMenuItemClicked(_:)), keyEquivalent: "" ) clearMenuItem.target = self menu.addItem(clearMenuItem) - - return menu } + // MARK: - Item creation helpers + private func addFileURLs(to menu: NSMenu, fileURLs: ArraySlice) { for url in fileURLs { let icon = NSWorkspace.shared.icon(forFile: url.path(percentEncoded: false)) @@ -80,7 +111,8 @@ class RecentProjectsMenu: NSObject { .path(percentEncoded: false) .abbreviatingWithTildeInPath() let alternateTitle = NSMutableAttributedString( - string: projectPath.lastPathComponent + " ", attributes: [.foregroundColor: NSColor.labelColor] + string: projectPath.lastPathComponent + " ", + attributes: [.foregroundColor: NSColor.labelColor] ) alternateTitle.append(NSAttributedString( string: parentPath, @@ -89,11 +121,11 @@ class RecentProjectsMenu: NSObject { return alternateTitle } + // MARK: - Actions + @objc - func recentProjectItemClicked(_ sender: NSMenuItem) { - guard let projectURL = sender.representedObject as? URL else { - return - } + private func recentProjectItemClicked(_ sender: NSMenuItem) { + guard let projectURL = sender.representedObject as? URL else { return } CodeEditDocumentController.shared.openDocument( withContentsOf: projectURL, display: true, @@ -102,7 +134,16 @@ class RecentProjectsMenu: NSObject { } @objc - func clearMenuItemClicked(_ sender: NSMenuItem) { - projectsStore.clearList() + private func clearMenuItemClicked(_ sender: NSMenuItem) { + RecentsStore.clearList() + rebuildMenu() + } +} + +// MARK: - Helpers + +private extension String { + func abbreviatingWithTildeInPath() -> String { + (self as NSString).abbreviatingWithTildeInPath } } diff --git a/CodeEdit/Utils/Extensions/Bundle/Bundle+Info.swift b/CodeEdit/Utils/Extensions/Bundle/Bundle+Info.swift index 9cc0a15ea6..8ca6eb67a2 100644 --- a/CodeEdit/Utils/Extensions/Bundle/Bundle+Info.swift +++ b/CodeEdit/Utils/Extensions/Bundle/Bundle+Info.swift @@ -9,6 +9,16 @@ import Foundation extension Bundle { + static var appName: String { + Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String ?? "Unknown App" + } + + static var displayName: String { + Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String + ?? Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String + ?? "Unknown App" + } + static var copyrightString: String? { Bundle.main.object(forInfoDictionaryKey: "NSHumanReadableCopyright") as? String } diff --git a/CodeEditTests/Features/Welcome/RecentProjectsTests.swift b/CodeEditTests/Features/Welcome/RecentProjectsTests.swift index a3ad378141..19fc6ccb8c 100644 --- a/CodeEditTests/Features/Welcome/RecentProjectsTests.swift +++ b/CodeEditTests/Features/Welcome/RecentProjectsTests.swift @@ -1,98 +1,196 @@ // -// RecentProjectsTests.swift +// RecentsStoreTests.swift // CodeEditTests // // Created by Khan Winter on 5/27/25. +// Updated for the new RecentsStore on 6/08/25 // import Testing import Foundation -@testable import CodeEdit +@testable import WelcomeWindow // <- contains RecentsStore + +// ----------------------------------------------------------------------------- +// MARK: - helpers +// ----------------------------------------------------------------------------- + +private let testDefaults: UserDefaults = { + let name = "RecentsStoreTests.\(UUID())" + let userDefaults = UserDefaults(suiteName: name)! + userDefaults.removePersistentDomain(forName: name) // start clean + return userDefaults +}() + +private extension URL { + /// Creates an empty file (or directory) on disk, so we can successfully + /// generate security-scoped bookmarks for it. + /// + /// - parameter directory: Pass `true` to create a directory, `false` + /// to create a regular file. + func materialise(directory: Bool) throws { + let fileManager = FileManager.default + if directory { + try fileManager.createDirectory(at: self, withIntermediateDirectories: true) + } else { + fileManager.createFile(atPath: path, contents: Data()) + } + } + + /// Convenience that returns a fresh URL inside the per-suite temp dir. + static func temp(named name: String, directory: Bool) -> URL { + TestContext.tempRoot.appendingPathComponent( + name, + isDirectory: directory + ) + } +} + +@MainActor +private func clear() { + RecentsStore.clearList() + testDefaults.removeObject(forKey: "recentProjectBookmarks") +} + +/// A container for values that need to remain alive for the whole test-suite. +private enum TestContext { + /// Every run gets its own random temp folder that is cleaned up + /// when the process exits. + static let tempRoot: URL = { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("RecentsStoreTests_\(UUID())", isDirectory: true) + try? FileManager.default.createDirectory( + at: root, + withIntermediateDirectories: true + ) + atexit_b { + try? FileManager.default.removeItem(at: root) + } + return root + }() +} + +// ----------------------------------------------------------------------------- +// MARK: - Test-suite +// ----------------------------------------------------------------------------- -// This suite needs to be serial due to the use of `UserDefaults` and sharing one testing storage location. +// Needs to be serial – everything writes to `UserDefaults.standard`. @Suite(.serialized) -class RecentProjectsTests { - let store: RecentProjectsStore +@MainActor +class RecentsStoreTests { init() { - let defaults = UserDefaults(suiteName: #file)! - defaults.removeSuite(named: #file) - store = RecentProjectsStore(defaults: defaults) + // Redirect the store to the throw-away suite. + RecentsStore.defaults = testDefaults + clear() } deinit { - try? FileManager.default.removeItem(atPath: #file + ".plist") + Task { @MainActor in + clear() + } } + // ------------------------------------------------------------------------- + // MARK: - Tests mirroring the old suite + // ------------------------------------------------------------------------- + @Test func newStoreEmpty() { - #expect(store.recentURLs().isEmpty) + clear() + #expect(RecentsStore.recentProjectURLs().isEmpty) } @Test - func savesURLs() { - store.documentOpened(at: URL(filePath: "Directory/", directoryHint: .isDirectory)) - store.documentOpened(at: URL(filePath: "Directory/file.txt", directoryHint: .notDirectory)) - - let recentURLs = store.recentURLs() - #expect(recentURLs.count == 2) - #expect(recentURLs[0].path(percentEncoded: false) == "Directory/file.txt") - #expect(recentURLs[1].path(percentEncoded: false) == "Directory/") + func savesURLs() throws { + clear() + let dir = URL.temp(named: "Directory", directory: true) + let file = URL.temp(named: "Directory/file.txt", directory: false) + + try dir.materialise(directory: true) + try file.materialise(directory: false) + + RecentsStore.documentOpened(at: dir) + RecentsStore.documentOpened(at: file) + + let recents = RecentsStore.recentProjectURLs() + #expect(recents.count == 2) + #expect(recents[0].standardizedFileURL == file.standardizedFileURL) + #expect(recents[1].standardizedFileURL == dir.standardizedFileURL) } @Test - func clearURLs() { - store.documentOpened(at: URL(filePath: "Directory/", directoryHint: .isDirectory)) - store.documentOpened(at: URL(filePath: "Directory/file.txt", directoryHint: .notDirectory)) + func clearURLs() throws { + clear() + let dir = URL.temp(named: "Directory", directory: true) + let file = URL.temp(named: "Directory/file.txt", directory: false) - #expect(store.recentURLs().count == 2) + try dir.materialise(directory: true) + try file.materialise(directory: false) - store.clearList() + RecentsStore.documentOpened(at: dir) + RecentsStore.documentOpened(at: file) + #expect(!RecentsStore.recentProjectURLs().isEmpty) - #expect(store.recentURLs().isEmpty) + RecentsStore.clearList() + #expect(RecentsStore.recentProjectURLs().isEmpty) } @Test - func duplicatesAreMovedToFront() { - store.documentOpened(at: URL(filePath: "Directory/", directoryHint: .isDirectory)) - store.documentOpened(at: URL(filePath: "Directory/file.txt", directoryHint: .notDirectory)) - // Move to front - store.documentOpened(at: URL(filePath: "Directory/", directoryHint: .isDirectory)) - // Remove duplicate - store.documentOpened(at: URL(filePath: "Directory/", directoryHint: .isDirectory)) - - let recentURLs = store.recentURLs() - #expect(recentURLs.count == 2) - - // Should be moved to the front of the list because it was 'opened' again. - #expect(recentURLs[0].path(percentEncoded: false) == "Directory/") - #expect(recentURLs[1].path(percentEncoded: false) == "Directory/file.txt") + func duplicatesAreMovedToFront() throws { + clear() + let dir = URL.temp(named: "Directory", directory: true) + let file = URL.temp(named: "Directory/file.txt", directory: false) + + try dir.materialise(directory: true) + try file.materialise(directory: false) + + RecentsStore.documentOpened(at: dir) + RecentsStore.documentOpened(at: file) + + // Open `dir` again → should move to front + RecentsStore.documentOpened(at: dir) + // Open duplicate again (no change in order, still unique) + RecentsStore.documentOpened(at: dir) + + let recents = RecentsStore.recentProjectURLs() + #expect(recents.count == 2) + #expect(recents[0].standardizedFileURL == dir.standardizedFileURL) + #expect(recents[1].standardizedFileURL == file.standardizedFileURL) } @Test - func removeSubset() { - store.documentOpened(at: URL(filePath: "Directory/", directoryHint: .isDirectory)) - store.documentOpened(at: URL(filePath: "Directory/file.txt", directoryHint: .notDirectory)) + func removeSubset() throws { + clear() + let dir = URL.temp(named: "Directory", directory: true) + let file = URL.temp(named: "Directory/file.txt", directory: false) - let remaining = store.removeRecentProjects(Set([URL(filePath: "Directory/", directoryHint: .isDirectory)])) + try dir.materialise(directory: true) + try file.materialise(directory: false) + + RecentsStore.documentOpened(at: dir) + RecentsStore.documentOpened(at: file) + + let remaining = RecentsStore.removeRecentProjects([dir]) + + #expect(remaining.count == 1) + #expect(remaining[0].standardizedFileURL == file.standardizedFileURL) + + let recents = RecentsStore.recentProjectURLs() + #expect(recents.count == 1) + #expect(recents[0].standardizedFileURL == file.standardizedFileURL) - #expect(remaining == [URL(filePath: "Directory/file.txt")]) - let recentURLs = store.recentURLs() - #expect(recentURLs.count == 1) - #expect(recentURLs[0].path(percentEncoded: false) == "Directory/file.txt") } @Test - func maxesOutAt100Items() { + func maxesOutAt100Items() throws { + clear() for idx in 0..<101 { - store.documentOpened( - at: URL( - filePath: "file\(idx).txt", - directoryHint: Bool.random() ? .isDirectory : .notDirectory - ) - ) + let isDir = Bool.random() + let name = "entry_\(idx)" + (isDir ? "" : ".txt") + let url = URL.temp(named: name, directory: isDir) + try url.materialise(directory: isDir) + RecentsStore.documentOpened(at: url) } - - #expect(store.recentURLs().count == 100) + #expect(RecentsStore.recentProjectURLs().count == 100) } }