From 0fb837d4a117c60479df26cc0cbe4e018e038293 Mon Sep 17 00:00:00 2001 From: Jason Morley Date: Sun, 20 Nov 2022 23:11:05 +0000 Subject: [PATCH] feat: Pick which file types to display on macOS (#440) --- .../FileawayCore/Extensions/FileManager.swift | 6 +- .../FileawayCore/Extensions/UTType.swift | 39 ++++++++- .../Extensions/UserDefaults.swift | 15 ++++ .../FileawayCore/Model/ApplicationModel.swift | 4 +- .../FileawayCore/Model/DirectoryModel.swift | 59 ++++++++----- .../Model/FileTypePickerModel.swift | 83 +++++++++++++++++++ .../Sources/FileawayCore/Model/Settings.swift | 37 +++++++-- .../Utilities/DirectoryMonitor.swift | 67 ++++++--------- .../Utilities/SafeUserDefaults.swift | 46 ++++++++++ .../Fileaway-macOS.xcodeproj/project.pbxproj | 8 ++ .../Views/Settings/FileTypePickerView.swift | 79 ++++++++++++++++++ .../Views/Settings/GeneralSettingsView.swift | 33 ++++++++ .../Views/Settings/SettingsView.swift | 13 ++- 13 files changed, 405 insertions(+), 84 deletions(-) create mode 100644 core/Sources/FileawayCore/Model/FileTypePickerModel.swift create mode 100644 core/Sources/FileawayCore/Utilities/SafeUserDefaults.swift create mode 100644 macos/Fileaway/Views/Settings/FileTypePickerView.swift create mode 100644 macos/Fileaway/Views/Settings/GeneralSettingsView.swift diff --git a/core/Sources/FileawayCore/Extensions/FileManager.swift b/core/Sources/FileawayCore/Extensions/FileManager.swift index 979a10a3..e8545884 100644 --- a/core/Sources/FileawayCore/Extensions/FileManager.swift +++ b/core/Sources/FileawayCore/Extensions/FileManager.swift @@ -26,16 +26,12 @@ extension FileManager { return urls(for: .libraryDirectory, in: .userDomainMask)[0] } - public func files(at url: URL, extensions: [String]) -> [URL] { + public func files(at url: URL) -> [URL] { var files: [URL] = [] if let enumerator = enumerator(at: url, includingPropertiesForKeys: [.isRegularFileKey], options: [.skipsHiddenFiles, .skipsPackageDescendants]) { for case let fileURL as URL in enumerator { - guard fileURL.matches(extensions: extensions) else { - continue - } - do { let fileAttributes = try fileURL.resourceValues(forKeys:[.isRegularFileKey]) if fileAttributes.isRegularFile! { diff --git a/core/Sources/FileawayCore/Extensions/UTType.swift b/core/Sources/FileawayCore/Extensions/UTType.swift index 48151305..608741d9 100644 --- a/core/Sources/FileawayCore/Extensions/UTType.swift +++ b/core/Sources/FileawayCore/Extensions/UTType.swift @@ -20,9 +20,44 @@ import UniformTypeIdentifiers -extension UTType { - +extension UTType: Identifiable { + public static let rules = UTType(exportedAs: "uk.co.inseven.fileaway.rules") public static var component = UTType(exportedAs: "uk.co.inseven.fileaway.component") + public static var doc = UTType(filenameExtension: "doc")! + public static var docx = UTType(filenameExtension: "docx")! + public static var numbers = UTType(filenameExtension: "numbers")! + public static var pages = UTType(filenameExtension: "pages")! + public static var xls = UTType(filenameExtension: "xls")! + public static var xlsx = UTType(filenameExtension: "xlsx")! + + + public var id: String { self.identifier } + + public var localizedDisplayName: String { + if let localizedDescription = localizedDescription { + return localizedDescription + } + if let preferredFilenameExtension = preferredFilenameExtension { + return preferredFilenameExtension + } + if let preferedMIMEType = preferredMIMEType { + return preferedMIMEType + } + return "Unknown Type" + } + + public func conforms(to types: Set) -> Bool { + if types.contains(self) { + return true + } + for type in supertypes { + if types.contains(type) { + return true + } + } + return false + } + } diff --git a/core/Sources/FileawayCore/Extensions/UserDefaults.swift b/core/Sources/FileawayCore/Extensions/UserDefaults.swift index e1401285..2b77c07b 100644 --- a/core/Sources/FileawayCore/Extensions/UserDefaults.swift +++ b/core/Sources/FileawayCore/Extensions/UserDefaults.swift @@ -60,4 +60,19 @@ extension UserDefaults { #endif } + public func setCodable(_ codable: Codable, forKey defaultName: String) throws { + let encoder = JSONEncoder() + if let encoded = try? encoder.encode(codable) { + set(encoded, forKey: defaultName) + } + } + + public func codable(_ type: T.Type, forKey defaultName: String) throws -> T? { + guard let object = object(forKey: defaultName) as? Data else { + return nil + } + let decoder = JSONDecoder() + return try decoder.decode(T.self, from: object) + } + } diff --git a/core/Sources/FileawayCore/Model/ApplicationModel.swift b/core/Sources/FileawayCore/Model/ApplicationModel.swift index 2c9543c1..1aa6ec4e 100644 --- a/core/Sources/FileawayCore/Model/ApplicationModel.swift +++ b/core/Sources/FileawayCore/Model/ApplicationModel.swift @@ -41,7 +41,7 @@ extension EnvironmentValues { public class ApplicationModel: ObservableObject { - private var settings = Settings() + public let settings = Settings() @Published public var directories: [DirectoryModel] = [] @Published public var allRules: [RuleModel] = [] @@ -125,7 +125,7 @@ public class ApplicationModel: ObservableObject { @MainActor private func addDirectoryObserver(type: DirectoryModel.DirectoryType, url: URL) { dispatchPrecondition(condition: .onQueue(.main)) - let directoryObserver = DirectoryModel(type: type, url: url) + let directoryObserver = DirectoryModel(settings: settings, type: type, url: url) directories.append(directoryObserver) directoryObserver.start() updateCountSubscription() diff --git a/core/Sources/FileawayCore/Model/DirectoryModel.swift b/core/Sources/FileawayCore/Model/DirectoryModel.swift index e453ade9..abc83f7f 100644 --- a/core/Sources/FileawayCore/Model/DirectoryModel.swift +++ b/core/Sources/FileawayCore/Model/DirectoryModel.swift @@ -20,6 +20,7 @@ import Combine import SwiftUI +import UniformTypeIdentifiers import Interact @@ -45,15 +46,18 @@ public class DirectoryModel: ObservableObject, Identifiable, Hashable { @Published public var files: [FileInfo] = [] @Published public var isLoading: Bool = true - private let extensions = ["pdf"] - private var directoryMonitor: DirectoryMonitor? + private let settings: Settings private let syncQueue = DispatchQueue.init(label: "DirectoryModel.syncQueue") + private var directoryMonitor: DirectoryMonitor private var cache: NSCache = NSCache() + private var cancelables: Set = [] - public init(type: DirectoryType, url: URL) { + public init(settings: Settings, type: DirectoryType, url: URL) { + self.settings = settings self.type = type self.url = url self.ruleSet = RulesModel(url: url) + self.directoryMonitor = DirectoryMonitor(locations: [url]) } public func hash(into hasher: inout Hasher) { @@ -61,36 +65,49 @@ public class DirectoryModel: ObservableObject, Identifiable, Hashable { } @MainActor public func start() { - self.directoryMonitor = try! DirectoryMonitor(locations: [url], - extensions: extensions, - targetQueue: syncQueue) { urls in - let files = urls - .map { url in - if let fileInfo = self.cache.object(forKey: url as NSURL) { + + directoryMonitor.start() + + directoryMonitor + .$files + .compactMap { $0 } // nil is a marker that the data is loading + .combineLatest(settings.$types) + .receive(on: syncQueue) + .map { files, types in + let files = files + .compactMap { url -> FileInfo? in + + // Filter by file type. + guard let type = UTType(filenameExtension: url.pathExtension), type.conforms(to: types) else { + return nil + } + + // Load file info from the cache if we have it, updating if not. + if let fileInfo = self.cache.object(forKey: url as NSURL) { + return fileInfo + } + let fileInfo = FileInfo(url: url) + self.cache.setObject(fileInfo, forKey: url as NSURL) + return fileInfo } - let fileInfo = FileInfo(url: url) - self.cache.setObject(fileInfo, forKey: url as NSURL) - return fileInfo - } - DispatchQueue.main.sync { + return files + } + .receive(on: DispatchQueue.main) + .sink { files in self.files = files self.isLoading = false } - } - self.directoryMonitor?.start() + .store(in: &cancelables) } @MainActor public func stop() { - guard let directoryMonitor = directoryMonitor else { - return - } directoryMonitor.stop() - self.directoryMonitor = nil + cancelables.removeAll() } @MainActor public func refresh() { - directoryMonitor?.refresh() + directoryMonitor.refresh() } } diff --git a/core/Sources/FileawayCore/Model/FileTypePickerModel.swift b/core/Sources/FileawayCore/Model/FileTypePickerModel.swift new file mode 100644 index 00000000..3b5464fa --- /dev/null +++ b/core/Sources/FileawayCore/Model/FileTypePickerModel.swift @@ -0,0 +1,83 @@ +// Copyright (c) 2018-2022 InSeven Limited +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import Combine +import SwiftUI +import UniformTypeIdentifiers + +import Interact + +public class FileTypePickerModel: ObservableObject, Runnable { + + @Published public var types: [UTType] = [] + @Published public var selection: Set = [] + @Published public var input: String = "" + + private var settings: Settings + private var cancellables: Set = [] + + @MainActor public var canSubmit: Bool { + return !self.input.isEmpty + } + + public init(settings: Settings) { + self.settings = settings + } + + @MainActor public func start() { + + settings + .$types + .map { types in + return Array(types) + .sorted { lhs, rhs in + lhs.localizedDisplayName.localizedStandardCompare(rhs.localizedDisplayName) == .orderedAscending + } + } + .receive(on: DispatchQueue.main) + .sink { types in + self.types = types + } + .store(in: &cancellables) + + } + + @MainActor public func stop() { + cancellables.removeAll() + } + + @MainActor public func submit() { + guard let type = UTType(filenameExtension: input) else { + // TODO: Print unknown type?? + print("Unknown type '\(input)'.") + return + } + self.settings.types.insert(type) + input = "" + } + + @MainActor public func remove(_ ids: Set) { + self.settings.types = self.settings.types.filter { !ids.contains($0.id) } + for id in ids { + selection.remove(id) + } + } + +} diff --git a/core/Sources/FileawayCore/Model/Settings.swift b/core/Sources/FileawayCore/Model/Settings.swift index 659d6fec..9e774fba 100644 --- a/core/Sources/FileawayCore/Model/Settings.swift +++ b/core/Sources/FileawayCore/Model/Settings.swift @@ -19,27 +19,52 @@ // SOFTWARE. import SwiftUI +import UniformTypeIdentifiers public class Settings: ObservableObject { - private static let inboxUrls = "inbox-urls" - private static let archiveUrls = "archive-urls" + enum Key: String { + case inboxUrls = "inbox-urls" + case archiveUrls = "archive-urls" + case fileTypes = "file-types" + } + + private static let defaultFileTypes: Set = [ + .commaSeparatedText, + .doc, + .docx, + .numbers, + .pages, + .pdf, + .rtf, + .xls, + .xlsx, + ] // TODO: These are never updated during the run of the app. @Published public var inboxUrls: [URL] = [] @Published public var archiveUrls: [URL] = [] + @Published public var types: Set = [] { + didSet { + try? defaults.setCodable(types, for: .fileTypes) + } + } + + private let defaults: SafeUserDefaults = SafeUserDefaults(defaults: UserDefaults.standard) + public init() { - inboxUrls = (try? UserDefaults.standard.securityScopeURLs(forKey: Self.inboxUrls)) ?? [] - archiveUrls = (try? UserDefaults.standard.securityScopeURLs(forKey: Self.archiveUrls)) ?? [] + inboxUrls = (try? defaults.securityScopeURLs(for: .inboxUrls)) ?? [] + archiveUrls = (try? defaults.securityScopeURLs(for: .archiveUrls)) ?? [] + types = (try? defaults.codable(Set.self, for: .fileTypes)) ?? Self.defaultFileTypes } public func setInboxUrls(_ urls: [URL]) throws { - try UserDefaults.standard.setSecurityScopeURLs(urls, forKey: Self.inboxUrls) + try defaults.setSecurityScopeURLs(urls, for: .inboxUrls) } public func setArchiveUrls(_ urls: [URL]) throws { - try UserDefaults.standard.setSecurityScopeURLs(urls, forKey: Self.archiveUrls) + try defaults.setSecurityScopeURLs(urls, for: .archiveUrls) } } diff --git a/core/Sources/FileawayCore/Utilities/DirectoryMonitor.swift b/core/Sources/FileawayCore/Utilities/DirectoryMonitor.swift index eb696999..573c2770 100644 --- a/core/Sources/FileawayCore/Utilities/DirectoryMonitor.swift +++ b/core/Sources/FileawayCore/Utilities/DirectoryMonitor.swift @@ -24,14 +24,12 @@ import Foundation import EonilFSEvents #endif -// TODO: Move the extension filtering out of this class. -public class DirectoryMonitor { +public class DirectoryMonitor: ObservableObject { let locations: [URL] - let extensions: [String] - let handler: (Set) -> Void let syncQueue = DispatchQueue(label: "DirectoryMonitor.syncQueue") - let targetQueue: DispatchQueue + + @MainActor @Published var files: Set? = nil #if os(macOS) lazy var stream: EonilFSEventStream = { @@ -42,25 +40,24 @@ public class DirectoryMonitor { flags: [.fileEvents], handler: { event in let url = URL(fileURLWithPath: event.path) - guard let flag = event.flag, - self.extensions.contains(url.pathExtension) else { + guard let flag = event.flag else { return } - if flag.contains(.itemRemoved) { - self.files.remove(url) - self.targetQueue_update() - } else if flag.contains(.itemRenamed) { - if FileManager.default.fileExists(atPath: event.path) { - self.files.insert(url) + Task { @MainActor in + precondition(self.files != nil) + if flag.contains(.itemRemoved) { + self.files?.remove(url) + } else if flag.contains(.itemRenamed) { + if FileManager.default.fileExists(atPath: event.path) { + self.files?.insert(url) + } else { + self.files?.remove(url) + } + } else if flag.contains(.itemCreated) { + self.files?.insert(url) } else { - self.files.remove(url) + print("Unhandled event \(event)") } - self.targetQueue_update() - } else if flag.contains(.itemCreated) { - self.files.insert(url) - self.targetQueue_update() - } else { - print("Unhandled event \(event)") } }) stream.setDispatchQueue(syncQueue) @@ -68,16 +65,8 @@ public class DirectoryMonitor { }() #endif - var files: Set = [] - - public init(locations: [URL], - extensions: [String], - targetQueue: DispatchQueue, - handler: @escaping (Set) -> Void) throws { + public init(locations: [URL]) { self.locations = locations - self.extensions = extensions - self.targetQueue = targetQueue - self.handler = handler } public func start() { @@ -86,8 +75,10 @@ public class DirectoryMonitor { #if os(macOS) try! self.stream.start() #endif - self.files = Set(FileManager.default.files(at: self.locations.first!, extensions: self.extensions)) - self.targetQueue_update() + let files = Set(FileManager.default.files(at: self.locations.first!)) + Task { @MainActor in + self.files = files + } } } @@ -103,16 +94,10 @@ public class DirectoryMonitor { public func refresh() { dispatchPrecondition(condition: .notOnQueue(syncQueue)) syncQueue.async { - self.files = Set(FileManager.default.files(at: self.locations.first!, extensions: self.extensions)) - self.targetQueue_update() - } - } - - func targetQueue_update() { - dispatchPrecondition(condition: .onQueue(syncQueue)) - let files = Set(self.files) - targetQueue.async { - self.handler(files) + let files = Set(FileManager.default.files(at: self.locations.first!)) + Task { @MainActor in + self.files = files + } } } diff --git a/core/Sources/FileawayCore/Utilities/SafeUserDefaults.swift b/core/Sources/FileawayCore/Utilities/SafeUserDefaults.swift new file mode 100644 index 00000000..d294931a --- /dev/null +++ b/core/Sources/FileawayCore/Utilities/SafeUserDefaults.swift @@ -0,0 +1,46 @@ +// Copyright (c) 2018-2022 InSeven Limited +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import Foundation + +class SafeUserDefaults> { + + let defaults: UserDefaults + + init(defaults: UserDefaults) { + self.defaults = defaults + } + + public func setCodable(_ codable: Codable, for key: Key) throws { + try defaults.setCodable(codable, forKey: key.rawValue) + } + + public func codable(_ type: T.Type, for key: Key) throws -> T? { + return try defaults.codable(T.self, forKey: key.rawValue) + } + + public func setSecurityScopeURLs(_ urls: [URL], for key: Key) throws { + return try defaults.setSecurityScopeURLs(urls, forKey: key.rawValue) + } + + public func securityScopeURLs(for key: Key) throws -> [URL] { + return try defaults.securityScopeURLs(forKey: key.rawValue) + } +} diff --git a/macos/Fileaway-macOS.xcodeproj/project.pbxproj b/macos/Fileaway-macOS.xcodeproj/project.pbxproj index 48d406e4..003d2813 100644 --- a/macos/Fileaway-macOS.xcodeproj/project.pbxproj +++ b/macos/Fileaway-macOS.xcodeproj/project.pbxproj @@ -9,6 +9,8 @@ /* Begin PBXBuildFile section */ D8037B122617F8CD00F41971 /* Introspect in Frameworks */ = {isa = PBXBuildFile; productRef = D8037B112617F8CD00F41971 /* Introspect */; }; D82D73D728B4C659006F49F1 /* Wizard.swift in Sources */ = {isa = PBXBuildFile; fileRef = D82D73D628B4C659006F49F1 /* Wizard.swift */; }; + D8384906292AE7EB00EA2595 /* GeneralSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8384905292AE7EB00EA2595 /* GeneralSettingsView.swift */; }; + D8384908292AE96800EA2595 /* FileTypePickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8384907292AE96800EA2595 /* FileTypePickerView.swift */; }; D83CDCEA2620FF210063C91C /* RuleSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = D83CDCE92620FF210063C91C /* RuleSheet.swift */; }; D85C95812579CED30007AE1E /* SelectionToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = D85C95802579CED30007AE1E /* SelectionToolbar.swift */; }; D87DEF7F25774F6C006CFE85 /* FileawayApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = D87DEF7E25774F6C006CFE85 /* FileawayApp.swift */; }; @@ -65,6 +67,8 @@ /* Begin PBXFileReference section */ D82D73D628B4C659006F49F1 /* Wizard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Wizard.swift; sourceTree = ""; }; + D8384905292AE7EB00EA2595 /* GeneralSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeneralSettingsView.swift; sourceTree = ""; }; + D8384907292AE96800EA2595 /* FileTypePickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileTypePickerView.swift; sourceTree = ""; }; D83CDCE92620FF210063C91C /* RuleSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RuleSheet.swift; sourceTree = ""; }; D85C95802579CED30007AE1E /* SelectionToolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectionToolbar.swift; sourceTree = ""; }; D876A3B32654873E006D19FB /* FileawayCore.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = FileawayCore.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -274,6 +278,8 @@ D8F2524E2923A7DE00CC30AF /* ComponentValueTextField.swift */, D8F2523F2922E5A600CC30AF /* ComponentView.swift */, D8E7F9E22599B00A00C7409B /* DestinationTable.swift */, + D8384907292AE96800EA2595 /* FileTypePickerView.swift */, + D8384905292AE7EB00EA2595 /* GeneralSettingsView.swift */, D8B657ED2642D40F009DE837 /* LocationsEditor.swift */, D8FC5349257BB7A4008FB609 /* LocationsSettingsView.swift */, D8E7F9DD2599AFE000C7409B /* RuleDetailView.swift */, @@ -474,10 +480,12 @@ D8F252522923BF5B00CC30AF /* NSItemProvider.swift in Sources */, D8F252402922E5A600CC30AF /* ComponentView.swift in Sources */, D8F8BE3725881C1A0010432F /* RulePicker.swift in Sources */, + D8384908292AE96800EA2595 /* FileTypePickerView.swift in Sources */, D8F252442922E62400CC30AF /* VariableNameTextField.swift in Sources */, D85C95812579CED30007AE1E /* SelectionToolbar.swift in Sources */, D8C4CC4D292101680038B06E /* StackNavigationBar.swift in Sources */, D8E7F9B125999C8800C7409B /* RulesEditor.swift in Sources */, + D8384906292AE7EB00EA2595 /* GeneralSettingsView.swift in Sources */, D8F8BE12258818450010432F /* WizardView.swift in Sources */, D87DEF7F25774F6C006CFE85 /* FileawayApp.swift in Sources */, D8E7F9EB2599B05300C7409B /* VariablesTable.swift in Sources */, diff --git a/macos/Fileaway/Views/Settings/FileTypePickerView.swift b/macos/Fileaway/Views/Settings/FileTypePickerView.swift new file mode 100644 index 00000000..4349b357 --- /dev/null +++ b/macos/Fileaway/Views/Settings/FileTypePickerView.swift @@ -0,0 +1,79 @@ +// Copyright (c) 2018-2022 InSeven Limited +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import Combine +import SwiftUI +import UniformTypeIdentifiers + +import Interact + +import FileawayCore + +struct FileTypePickerView: View { + + @StateObject var fileTypePickerModel: FileTypePickerModel + + init(settings: FileawayCore.Settings) { + _fileTypePickerModel = StateObject(wrappedValue: FileTypePickerModel(settings: settings)) + } + + var body: some View { + GroupBox("File Types") { + VStack { + List(selection: $fileTypePickerModel.selection) { + ForEach(fileTypePickerModel.types) { type in + HStack { + Text(type.localizedDisplayName) + Spacer() + if let preferredFilenameExtension = type.preferredFilenameExtension { + Text(preferredFilenameExtension) + .foregroundColor(.secondary) + } else if let preferredMIMEType = type.preferredMIMEType { + Text(preferredMIMEType) + .foregroundColor(.secondary) + } + } + .lineLimit(1) + } + } + .contextMenu(forSelectionType: UTType.ID.self) { selection in + Button("Remove") { + fileTypePickerModel.remove(selection) + } + } + .onDeleteCommand { + fileTypePickerModel.remove(fileTypePickerModel.selection) + } + HStack { + TextField("File Extension", text: $fileTypePickerModel.input) + .onSubmit { + fileTypePickerModel.submit() + } + Button("Add") { + fileTypePickerModel.submit() + } + .disabled(!fileTypePickerModel.canSubmit) + } + } + } + .runs(fileTypePickerModel) + } + +} diff --git a/macos/Fileaway/Views/Settings/GeneralSettingsView.swift b/macos/Fileaway/Views/Settings/GeneralSettingsView.swift new file mode 100644 index 00000000..92e73547 --- /dev/null +++ b/macos/Fileaway/Views/Settings/GeneralSettingsView.swift @@ -0,0 +1,33 @@ +// Copyright (c) 2018-2022 InSeven Limited +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import SwiftUI + +import FileawayCore + +struct GeneralSettingsView: View { + + @Environment(\.applicationModel) var applicationModel: ApplicationModel + + var body: some View { + FileTypePickerView(settings: applicationModel.settings) + } + +} diff --git a/macos/Fileaway/Views/Settings/SettingsView.swift b/macos/Fileaway/Views/Settings/SettingsView.swift index a4becd36..b5d71c3b 100644 --- a/macos/Fileaway/Views/Settings/SettingsView.swift +++ b/macos/Fileaway/Views/Settings/SettingsView.swift @@ -24,19 +24,18 @@ import FileawayCore struct SettingsView: View { - private enum Tabs: Hashable { - case general - } - @ObservedObject var applicationModel: ApplicationModel - + var body: some View { TabView { + GeneralSettingsView() + .tabItem { + Label("General", systemImage: "gear") + } LocationsSettingsView(applicationModel: applicationModel) .tabItem { - Label("Locations", systemImage: "folder") + Label("Folders", systemImage: "folder") } - .tag(Tabs.general) RulesSettingsView(applicationModel: applicationModel) .tabItem { Label("Rules", systemImage: "tray.and.arrow.down")