Skip to content

Commit

Permalink
fix: Detect and propagate file modifications (#84)
Browse files Browse the repository at this point in the history
  • Loading branch information
jbmorley authored Feb 29, 2024
1 parent b27f50a commit 94cdd7f
Show file tree
Hide file tree
Showing 13 changed files with 351 additions and 129 deletions.
6 changes: 4 additions & 2 deletions Folders/Extensions/FileManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,8 @@ extension FileManager {
print("Failed to determine content type for \(fileURL).")
continue
}
files.append(Details(ownerURL: ownerURL ?? directoryURL,
files.append(Details(uuid: UUID(),
ownerURL: ownerURL ?? directoryURL,
url: fileURL,
contentType: contentType,
contentModificationDate: contentModificationDate.millisecondsSinceReferenceDate))
Expand Down Expand Up @@ -76,7 +77,8 @@ extension FileManager {
throw FoldersError.general("Unable to get content modification date for file '\(url.path)'.")
}

return Details(ownerURL: owner,
return Details(uuid: UUID(),
ownerURL: owner,
url: url,
contentType: isDirectory ? .directory : contentType,
contentModificationDate: contentModificationDate.millisecondsSinceReferenceDate)
Expand Down
37 changes: 24 additions & 13 deletions Folders/Models/ApplicationModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -149,15 +149,18 @@ class ApplicationModel: NSObject, ObservableObject {
updater.stop()
}

// Remove the sidebar entry.
sidebarItems.removeAll { $0.url == url }

// Remove the entires from the database.
do {
try store.removeBlocking(owner: url)
} catch {
// TODO: Better error handling.
print("Failed to remove files with error \(error).")
DispatchQueue.global(qos: .background).async {
do {
try self.store.removeBlocking(owner: url)
} catch {
// TODO: Better error handling.
print("Failed to remove files with error \(error).")
}
}

sidebarItems.removeAll { $0.url == url }
}

}
Expand Down Expand Up @@ -201,16 +204,24 @@ extension ApplicationModel: StoreViewDelegate {
return items
}

func storeViewDidUpdate(_ storeView: StoreView) {
self.lookup = sidebarItems(for: storeView.files)
func storeView(_ storeView: StoreView, didUpdateFiles files: [Details]) {
assert(Set(files.map({ $0.url })).count == files.count)
self.lookup = sidebarItems(for: files)
}

func storeView(_ storeView: StoreView, didInsertFile file: Details, atIndex: Int, files: [Details]) {
assert(Set(files.map({ $0.url })).count == files.count)
self.lookup = sidebarItems(for: files)
}

func storeView(_ storeView: StoreView, didInsertFile file: Details, atIndex: Int) {
self.lookup = sidebarItems(for: storeView.files)
func storeView(_ storeView: StoreView, didUpdateFile file: Details, atIndex: Int, files: [Details]) {
assert(Set(files.map({ $0.url })).count == files.count)
self.lookup = sidebarItems(for: files)
}

func storeView(_ storeView: StoreView, didRemoveFileWithIdentifier identifier: Details.Identifier, atIndex: Int) {
self.lookup = sidebarItems(for: storeView.files)
func storeView(_ storeView: StoreView, didRemoveFileWithIdentifier identifier: Details.Identifier, atIndex: Int, files: [Details]) {
assert(Set(files.map({ $0.url })).count == files.count)
self.lookup = sidebarItems(for: files)
}

}
25 changes: 23 additions & 2 deletions Folders/Models/Details.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import UniformTypeIdentifiers

struct Details: Hashable {

// TODO: This isn't really an identifier anymore is it.
struct Identifier: Equatable, Hashable {
let ownerURL: URL
let url: URL
Expand All @@ -36,6 +37,7 @@ struct Details: Hashable {

let identifier: Identifier
let ownerURL: URL
let uuid: UUID
let url: URL
let contentType: UTType

Expand All @@ -44,7 +46,8 @@ struct Details: Hashable {
// instead store the contentModificationDate as an Int which represents milliseconds since the reference data.
let contentModificationDate: Int

init(ownerURL: URL, url: URL, contentType: UTType, contentModificationDate: Int) {
init(uuid: UUID, ownerURL: URL, url: URL, contentType: UTType, contentModificationDate: Int) {
self.uuid = uuid
self.identifier = Identifier(ownerURL: ownerURL, url: url)
self.ownerURL = ownerURL
self.url = url
Expand All @@ -57,10 +60,28 @@ struct Details: Hashable {
}

func setting(ownerURL: URL) -> Details {
return Details(ownerURL: ownerURL,
return Details(uuid: uuid,
ownerURL: ownerURL,
url: url,
contentType: contentType,
contentModificationDate: contentModificationDate)
}

func equivalent(to details: Details) -> Bool {
// TODO: Does the content type ever change?
// TODO: Function overload?
return (ownerURL == details.ownerURL &&
url == details.url &&
contentType == details.contentType &&
contentModificationDate == details.contentModificationDate)
}

func applying(details: Details) -> Details {
return Details(uuid: uuid,
ownerURL: ownerURL,
url: url,
contentType: contentType,
contentModificationDate: details.contentModificationDate)
}

}
5 changes: 5 additions & 0 deletions Folders/Models/SceneModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,4 +41,9 @@ class SceneModel: ObservableObject {
selection = sidebarItem.id
}

func remove(_ url: URL) {
selection = nil
applicationModel.remove(url)
}

}
115 changes: 78 additions & 37 deletions Folders/Utilities/DirectoryScanner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ class DirectoryScanner {
let url: URL
let workQueue = DispatchQueue(label: "workQueue")
var stream: FSEventStream? = nil
var identifiers: Set<Details.Identifier> = [] // Synchronized on workQueue
var identifiers = [Details.Identifier: Details]() // Synchronized on workQueue
weak var delegate: DirectoryScannerDelegate?

init(url: URL) {
Expand All @@ -46,6 +46,7 @@ class DirectoryScanner {

func start(load: @escaping () -> Set<Details>,
onFileCreation: @escaping (any Collection<Details>) -> Void,
onFileUpdate: @escaping (any Collection<Details>) -> Void,
onFileDeletion: @escaping (any Collection<Details.Identifier>) -> Void) {
// TODO: Allow this to be run with a blocking startup.
dispatchPrecondition(condition: .notOnQueue(workQueue))
Expand Down Expand Up @@ -73,14 +74,14 @@ class DirectoryScanner {

// Depending on the system load, it seems like we sometimes receive events for file operations that
// were already captured in our initial snapshot that we want to ignore.
guard !self.identifiers.contains(details.identifier) else {
guard self.identifiers[details.identifier] == nil else {
return
}

print("File created at path '\(path)'")

onFileCreation([details])
self.identifiers.insert(details.identifier)
self.identifiers[details.identifier] = details

case .itemRenamed(path: let path, itemType: let itemType, eventId: _, fromUs: _):

Expand All @@ -94,9 +95,13 @@ class DirectoryScanner {
// If a file exists at the new path and also exists in our runtime cache of files then we infer
// that this rename actuall represents a content modification operation; our file has been
// atomically replaced by a new file containing new content.
if self.identifiers.contains(details.identifier) {
if let old = self.identifiers[details.identifier] {
print("File updated by rename '\(url)'")
onFileDeletion([details.identifier])
// onFileDeletion([details.identifier])
let update = old.applying(details: details)
self.identifiers[details.identifier] = update
onFileUpdate([update])
return
// TODO: We should ensure we delete all our children if we're a directory.
} else {
print("File added by rename '\(url)'")
Expand All @@ -106,10 +111,12 @@ class DirectoryScanner {
if itemType == .dir {
let files = try fileManager.files(directoryURL: url, ownerURL: ownerURL)
onFileCreation(files)
self.identifiers.formUnion(files.map({ $0.identifier }))
for file in files {
self.identifiers[file.identifier] = file
}
} else {
onFileCreation([details])
self.identifiers.insert(details.identifier)
self.identifiers[details.identifier] = details
}

} else {
Expand All @@ -118,12 +125,14 @@ class DirectoryScanner {
// If it's a directory, then we need to work out what files are being removed.
let identifier = Details.Identifier(ownerURL: ownerURL, url: url)
if itemType == .dir {
let identifiers = self.identifiers.filter { $0.url.path.hasPrefix(url.path + "/") } + [identifier]
let identifiers = self.identifiers.keys.filter { $0.url.path.hasPrefix(url.path + "/") } + [identifier]
onFileDeletion(Array(identifiers))
self.identifiers.subtract(identifiers)
for identifier in identifiers {
self.identifiers.removeValue(forKey: identifier)
}
} else {
onFileDeletion([identifier])
self.identifiers.remove(identifier)
self.identifiers.removeValue(forKey: identifier)
}
}

Expand All @@ -133,7 +142,7 @@ class DirectoryScanner {
print("File removed '\(url)'")
let identifier = Details.Identifier(ownerURL: ownerURL, url: url)
onFileDeletion([identifier])
self.identifiers.remove(identifier)
self.identifiers.removeValue(forKey: identifier)

case .itemInodeMetadataModified(path: let path, itemType: let itemType, eventId: _, fromUs: _):

Expand All @@ -142,18 +151,19 @@ class DirectoryScanner {
// TODO: Consider generalising this code.
let url = URL(filePath: path, itemType: itemType)
let identifier = Details.Identifier(ownerURL: ownerURL, url: url)
let details = try fileManager.details(for: url, owner: ownerURL)

// Remove the file if it exists in our set.
if self.identifiers.contains(identifier) {
onFileDeletion([identifier])
if let old = self.identifiers[identifier] {
let new = old.applying(details: details)
onFileUpdate([new])
self.identifiers[identifier] = new
return
}

// Create a new identifier corresponding to the udpated file.
let details = try fileManager.details(for: url, owner: ownerURL) // TODO: details(for identifier: Details.Identifier)?
onFileCreation([details])

// Ensure there's an entry for the (potentially) new file.
self.identifiers.insert(identifier)
self.identifiers[identifier] = details

default:
print("Unhandled file event \(event).")
Expand All @@ -180,34 +190,65 @@ class DirectoryScanner {

// TODO: Handle errors.
let fileManager = FileManager.default
let files = Set(try! fileManager.files(directoryURL: url))

let currentState = load()
// TODO: Index by URL not Identifier

// Add just the new files.
let created = files.subtracting(currentState)
if created.count > 0 {
print("Inserting \(created.count) new files...")
onFileCreation(created)
// Load the snapshot.
let snapshot = load()
// TODO: Rename
let snapshotIdentifiers = snapshot.reduce(into: [Details.Identifier: Details]()) { partialResult, details in
partialResult[details.identifier] = details
}

// Remove the remaining files.
let deleted = currentState.subtracting(files)
.map { $0.identifier }
if deleted.count > 0 {
print("Removing \(deleted.count) deleted files...")
onFileDeletion(deleted)
// Load the current state from the file system.
let current = Set(try! fileManager.files(directoryURL: url))
let currentIdentifiers = current.reduce(into: [Details.Identifier: Details]()) { partialResult, details in
partialResult[details.identifier] = details
}

// Cache the initial state.
self.identifiers = files
.map {
return $0.identifier
}
.reduce(into: Set<Details.Identifier>()) { partialResult, identifier in
partialResult.insert(identifier)
// Determine the deleted files and apply the changes.
let deletedIdentifiers = Set(snapshotIdentifiers.keys).subtracting(currentIdentifiers.keys)
if deletedIdentifiers.count > 0 {
print("Removing \(deletedIdentifiers.count) deleted files...")
onFileDeletion(deletedIdentifiers)
}

// Walk the current files, determine the operation required to update the snapshot, and assemble the
// in-memory state.
var additions = [Details]()
var updates = [Details]()
var state = [Details.Identifier: Details]()

// TODO: Can and should we track deletions here too?
for file in current {
if let snapshot = snapshotIdentifiers[file.identifier] {
if !snapshot.equivalent(to: file) { // Modified.
let update = snapshot.applying(details: file)
updates.append(update)
state[file.identifier] = update
} else { // Unchanged.
state[file.identifier] = snapshot
}
} else { // Created.
additions.append(file)
state[file.identifier] = file
}
}

// Add the new files.
if additions.count > 0 {
print("Inserting \(additions.count) new files...")
onFileCreation(additions)
}

// Apply the updates.
if updates.count > 0 {
print("Updating \(updates.count) modified files...")
onFileUpdate(updates)
}

// Cache the initial state.
self.identifiers = state
self.delegate?.directoryScannerDidStart(self)
}

Expand Down
Loading

0 comments on commit 94cdd7f

Please sign in to comment.