HistoryView: Adds ability to delete history items
This commit is contained in:
@@ -7,9 +7,58 @@
|
|||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import CoreData
|
import CoreData
|
||||||
|
import Combine
|
||||||
|
|
||||||
class BrowserHistory
|
class BrowserHistory
|
||||||
{
|
{
|
||||||
|
class ViewModel: NSObject, ObservableObject, NSFetchedResultsControllerDelegate {
|
||||||
|
@Published var searchQuery: String = ""
|
||||||
|
@Published private(set) var historyItems: [HistoryItem] = []
|
||||||
|
|
||||||
|
fileprivate let historyController: BrowserHistory
|
||||||
|
fileprivate var searchQueryObserver: AnyCancellable? = nil
|
||||||
|
fileprivate var fetchedResultsController: NSFetchedResultsController<HistoryItemEntity> {
|
||||||
|
didSet { fetchedResultsController.delegate = self; performInitialFetch() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fileprivate init(fetchedResultsController: NSFetchedResultsController<HistoryItemEntity>, historyController: BrowserHistory) {
|
||||||
|
self.fetchedResultsController = fetchedResultsController
|
||||||
|
self.historyController = historyController
|
||||||
|
super.init()
|
||||||
|
|
||||||
|
searchQueryObserver = $searchQuery.sink { [unowned self] newValue in
|
||||||
|
self.fetchedResultsController = historyController.fetchRequestController(forQuery: newValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func item(forIdentifier identifier: HistoryItem.ID) -> HistoryItem? {
|
||||||
|
if let object = fetchedResultsController.managedObjectContext.object(with: identifier) as? HistoryItemEntity {
|
||||||
|
return HistoryItem(entity: object)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
public func deleteItems(_ items: Set<HistoryItem.ID>) {
|
||||||
|
items.forEach { identifier in
|
||||||
|
historyController.deleteItem(withIdentifier: identifier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
|
||||||
|
if let objects = controller.fetchedObjects as? [HistoryItemEntity] {
|
||||||
|
self.historyItems = objects.map { HistoryItem(entity: $0) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fileprivate func performInitialFetch() {
|
||||||
|
try? fetchedResultsController.performFetch()
|
||||||
|
if let objects = fetchedResultsController.fetchedObjects {
|
||||||
|
self.historyItems = objects.map { HistoryItem(entity: $0) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static public let shared = BrowserHistory()
|
static public let shared = BrowserHistory()
|
||||||
|
|
||||||
lazy fileprivate var persistentContainer: NSPersistentContainer = {
|
lazy fileprivate var persistentContainer: NSPersistentContainer = {
|
||||||
@@ -37,19 +86,30 @@ class BrowserHistory
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public func allHistory(limit: Int? = nil) -> [HistoryItem] {
|
public func viewModel(forFilterString filterString: String? = nil, limit: Int? = nil) -> ViewModel {
|
||||||
let dataContext = persistentContainer.viewContext
|
let resultsController = fetchRequestController(forQuery: filterString, limit: limit)
|
||||||
|
return ViewModel(fetchedResultsController: resultsController, historyController: self)
|
||||||
let fetchRequest: NSFetchRequest<HistoryItemEntity> = HistoryItemEntity.fetchRequest()
|
}
|
||||||
fetchRequest.sortDescriptors = [
|
|
||||||
// Sort by date
|
public func historyItem(forIdentifier: HistoryItem.ID) -> HistoryItem? {
|
||||||
NSSortDescriptor(keyPath: \HistoryItemEntity.lastVisited, ascending: false)
|
if let entity = try? persistentContainer.viewContext.existingObject(with: forIdentifier) as? HistoryItemEntity {
|
||||||
]
|
return HistoryItem(entity: entity)
|
||||||
|
|
||||||
if let limit {
|
|
||||||
fetchRequest.fetchLimit = limit
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
public func deleteItem(withIdentifier identifier: HistoryItem.ID) {
|
||||||
|
let dataContext = persistentContainer.viewContext
|
||||||
|
if let object = try? dataContext.existingObject(with: identifier) {
|
||||||
|
dataContext.delete(object)
|
||||||
|
try? dataContext.save()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func allHistory(filteredBy filterString: String? = nil, limit: Int? = nil) -> [HistoryItem] {
|
||||||
|
let dataContext = persistentContainer.viewContext
|
||||||
|
let fetchRequest = fetchRequest(forStringContaining: filterString, limit: limit)
|
||||||
let entities: [HistoryItemEntity] = (try? dataContext.fetch(fetchRequest)) ?? []
|
let entities: [HistoryItemEntity] = (try? dataContext.fetch(fetchRequest)) ?? []
|
||||||
|
|
||||||
return entities.map { (entity) -> HistoryItem in
|
return entities.map { (entity) -> HistoryItem in
|
||||||
@@ -98,6 +158,38 @@ class BrowserHistory
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension BrowserHistory
|
||||||
|
{
|
||||||
|
fileprivate func fetchRequestController(forQuery query: String? = nil, limit: Int? = nil) -> NSFetchedResultsController<HistoryItemEntity> {
|
||||||
|
let fetchRequest = fetchRequest(forStringContaining: query, limit: limit)
|
||||||
|
return NSFetchedResultsController(fetchRequest: fetchRequest,
|
||||||
|
managedObjectContext: persistentContainer.viewContext,
|
||||||
|
sectionNameKeyPath: nil, cacheName: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
fileprivate func fetchRequest(forStringContaining filterString: String? = nil, limit: Int? = nil) -> NSFetchRequest<HistoryItemEntity> {
|
||||||
|
let fetchRequest: NSFetchRequest<HistoryItemEntity> = HistoryItemEntity.fetchRequest()
|
||||||
|
fetchRequest.sortDescriptors = [
|
||||||
|
// Sort by date
|
||||||
|
NSSortDescriptor(keyPath: \HistoryItemEntity.lastVisited, ascending: false)
|
||||||
|
]
|
||||||
|
|
||||||
|
if let limit {
|
||||||
|
fetchRequest.fetchLimit = limit
|
||||||
|
}
|
||||||
|
|
||||||
|
if let filterString, filterString.count > 0 {
|
||||||
|
fetchRequest.predicate = NSPredicate(format: """
|
||||||
|
host CONTAINS[cd] %@
|
||||||
|
OR title CONTAINS[cd] %@
|
||||||
|
OR url CONTAINS[cd] %@
|
||||||
|
""", filterString, filterString, filterString)
|
||||||
|
}
|
||||||
|
|
||||||
|
return fetchRequest
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
extension URL
|
extension URL
|
||||||
{
|
{
|
||||||
public func topLevelURL() -> URL {
|
public func topLevelURL() -> URL {
|
||||||
|
|||||||
@@ -12,13 +12,13 @@ struct HistoryItem: Hashable, Identifiable
|
|||||||
var url: URL
|
var url: URL
|
||||||
var title: String
|
var title: String
|
||||||
var lastVisited: Date
|
var lastVisited: Date
|
||||||
var id: ObjectIdentifier
|
var id: NSManagedObjectID
|
||||||
|
|
||||||
init(entity: HistoryItemEntity) {
|
init(entity: HistoryItemEntity) {
|
||||||
self.url = entity.url ?? URL(string: "about:blank")!
|
self.url = entity.url ?? URL(string: "about:blank")!
|
||||||
self.lastVisited = entity.lastVisited ?? Date()
|
self.lastVisited = entity.lastVisited ?? Date()
|
||||||
self.title = entity.title ?? ""
|
self.title = entity.title ?? ""
|
||||||
self.id = entity.id
|
self.id = entity.objectID
|
||||||
}
|
}
|
||||||
|
|
||||||
// For testing/previews
|
// For testing/previews
|
||||||
@@ -26,6 +26,6 @@ struct HistoryItem: Hashable, Identifiable
|
|||||||
self.url = url
|
self.url = url
|
||||||
self.title = title
|
self.title = title
|
||||||
self.lastVisited = lastVisited
|
self.lastVisited = lastVisited
|
||||||
self.id = ObjectIdentifier(NSUUID())
|
self.id = NSManagedObjectID()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import UIKit
|
|||||||
@MainActor
|
@MainActor
|
||||||
class HistoryBrowserViewController: UIHostingController<HistoryView> {
|
class HistoryBrowserViewController: UIHostingController<HistoryView> {
|
||||||
public init() {
|
public init() {
|
||||||
super.init(rootView: HistoryView(historyItems: BrowserHistory.shared.allHistory(limit: 500)))
|
super.init(rootView: HistoryView(viewModel: BrowserHistory.shared.viewModel(limit: 500)))
|
||||||
}
|
}
|
||||||
|
|
||||||
required dynamic init?(coder aDecoder: NSCoder) {
|
required dynamic init?(coder aDecoder: NSCoder) {
|
||||||
|
|||||||
@@ -9,24 +9,19 @@ import SwiftUI
|
|||||||
import UniformTypeIdentifiers
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
struct HistoryView: View {
|
struct HistoryView: View {
|
||||||
var historyItems: [HistoryItem]
|
@StateObject public var viewModel: BrowserHistory.ViewModel
|
||||||
|
|
||||||
private let dateFormatter: DateFormatter
|
|
||||||
@State public var selectedItems = Set<HistoryItem.ID>()
|
@State public var selectedItems = Set<HistoryItem.ID>()
|
||||||
@Environment(\.dismiss) private var dismissAction
|
|
||||||
|
|
||||||
init(historyItems: [HistoryItem]) {
|
private let dateFormatter = DateFormatter() .. {
|
||||||
self.historyItems = historyItems
|
$0.locale = Locale.current
|
||||||
|
$0.dateStyle = .medium
|
||||||
let formatter = DateFormatter()
|
$0.timeStyle = .short
|
||||||
formatter.locale = Locale.current
|
|
||||||
formatter.dateStyle = .medium
|
|
||||||
formatter.timeStyle = .short
|
|
||||||
self.dateFormatter = formatter
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Environment(\.dismiss) private var dismissAction
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Table(historyItems, selection: $selectedItems) {
|
Table(viewModel.historyItems, selection: $selectedItems) {
|
||||||
TableColumn("Title", value: \.title)
|
TableColumn("Title", value: \.title)
|
||||||
|
|
||||||
TableColumn("URL") { item in
|
TableColumn("URL") { item in
|
||||||
@@ -38,34 +33,30 @@ struct HistoryView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.contextMenu(forSelectionType: HistoryItem.ID.self, menu: { items in
|
.contextMenu(forSelectionType: HistoryItem.ID.self, menu: { items in
|
||||||
if let firstItem: HistoryItem.ID = items.first,
|
let historyItems = items.compactMap { viewModel.item(forIdentifier: $0) }
|
||||||
let historyItem = historyItems.first { $0.id == firstItem }
|
Button("Copy") {
|
||||||
{
|
UIPasteboard.general.setItems(historyItems.map { [
|
||||||
Button("Copy") {
|
UTType.url.identifier : $0.url,
|
||||||
UIPasteboard.general.addItems([
|
UTType.utf8PlainText.identifier : $0.url.absoluteString,
|
||||||
[ UTType.url.identifier : historyItem.url ]
|
] })
|
||||||
])
|
}
|
||||||
}
|
|
||||||
|
Button("Delete") {
|
||||||
// TODO: Delete?
|
viewModel.deleteItems(items)
|
||||||
}
|
}
|
||||||
}, primaryAction: { items in
|
}, primaryAction: { items in
|
||||||
if let firstItem: HistoryItem.ID = items.first,
|
items.compactMap({ viewModel.item(forIdentifier: $0) }).forEach { item in
|
||||||
let historyItem = historyItems.first(where: { $0.id == firstItem })
|
UIApplication.shared.open(item.url)
|
||||||
{
|
|
||||||
UIApplication.shared.open(historyItem.url)
|
|
||||||
dismissAction()
|
dismissAction()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
.searchable(text: $viewModel.searchQuery)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct HistoryViewPreviewProvider: PreviewProvider {
|
struct HistoryViewPreviewProvider: PreviewProvider {
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
HistoryView(historyItems: [
|
HistoryView(viewModel: BrowserHistory.shared.viewModel())
|
||||||
HistoryItem(url: URL(string: "https://apple.com")!, title: "Apple", lastVisited: Date.now),
|
|
||||||
HistoryItem(url: URL(string: "https://google.com")!, title: "Google", lastVisited: Date.now)
|
|
||||||
])
|
|
||||||
.previewLayout(.fixed(width: 480.0, height: 800.0))
|
.previewLayout(.fixed(width: 480.0, height: 800.0))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,15 @@
|
|||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
|
infix operator .. : AssignmentPrecedence
|
||||||
|
|
||||||
|
@discardableResult @inline(__always) @inlinable
|
||||||
|
public func .. <T: Any>(it: T, apply: (inout T) throws -> Void) rethrows -> T {
|
||||||
|
var it = it
|
||||||
|
try apply(&it)
|
||||||
|
return it
|
||||||
|
}
|
||||||
|
|
||||||
protocol Conf { }
|
protocol Conf { }
|
||||||
|
|
||||||
extension Conf {
|
extension Conf {
|
||||||
|
|||||||
Reference in New Issue
Block a user