Skip to content

Commit f7e9a15

Browse files
authored
fix(tabs): notice any change to or deletion of an open SQL file, and report a failed move to Trash (#3088)
1 parent 1dda5ca commit f7e9a15

27 files changed

Lines changed: 1104 additions & 159 deletions

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,10 +103,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
103103
- Garbled name and ISO-8859-1 label on a UTF-8, UTF-16 or UTF-32 linked SQL file, and garbled big-endian UTF-32 files.
104104
- Cleared keyword, folder or **Global** on a saved query or its folder never reaching another device.
105105
- Renaming a folder putting back the scope another window had just set.
106+
- No changed-on-disk notice for an SQL file outside a linked folder or replaced by an older copy, and Save overwriting it.
106107
- Saved queries and their folders deleted at launch when their connection had not arrived from iCloud.
107108
- Saved queries left naming a deleted folder on other devices after that folder was deleted.
108109
- A keyword two linked SQL files both declared reaching a different file on each launch.
109110
- A keyword a saved query shared with a global one reaching either query, depending on the connection.
111+
- Silent failure moving a linked SQL file to the Trash, and an open tab recreating a deleted file on save.
110112
- AI chat's saved query mentions missing a query saved earlier in the same session.
111113
- **File > Import > Import Data…** importing every file as SQL. (#3047)
112114
- A file the import panel dimmed still opening, and reaching the wrong importer.
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
//
2+
// SourceFileDiskChangeMonitor.swift
3+
// TablePro
4+
//
5+
6+
import Foundation
7+
8+
@MainActor
9+
internal final class SourceFileDiskChangeMonitor {
10+
typealias StampReader = @Sendable ([URL]) async -> [FileStamp?]
11+
12+
private struct Probe {
13+
let tabId: UUID
14+
let url: URL
15+
let recordedStamp: FileStamp?
16+
}
17+
18+
private weak var tabManager: QueryTabManager?
19+
private let readStamps: StampReader
20+
private var inFlight: Task<Void, Never>?
21+
private var needsAnotherPass = false
22+
23+
init(tabManager: QueryTabManager, readStamps: @escaping StampReader = SourceFileDiskChangeMonitor.readStampsOffMainActor) {
24+
self.tabManager = tabManager
25+
self.readStamps = readStamps
26+
}
27+
28+
func refresh() {
29+
guard inFlight == nil else {
30+
needsAnotherPass = true
31+
return
32+
}
33+
inFlight = Task { await self.runPasses() }
34+
}
35+
36+
func waitUntilIdle() async {
37+
await inFlight?.value
38+
}
39+
40+
func cancel() {
41+
needsAnotherPass = false
42+
inFlight?.cancel()
43+
}
44+
45+
@concurrent
46+
nonisolated static func readStampsOffMainActor(_ urls: [URL]) async -> [FileStamp?] {
47+
urls.map(FileStamp.read)
48+
}
49+
50+
private func runPasses() async {
51+
repeat {
52+
needsAnotherPass = false
53+
await runPass()
54+
} while needsAnotherPass && !Task.isCancelled
55+
inFlight = nil
56+
}
57+
58+
private func runPass() async {
59+
guard let tabManager else { return }
60+
let probes = tabManager.tabs.compactMap { tab -> Probe? in
61+
guard let url = tab.content.sourceFileURL else { return nil }
62+
return Probe(tabId: tab.id, url: url, recordedStamp: tab.content.savedFileStamp)
63+
}
64+
guard !probes.isEmpty else { return }
65+
let stamps = await readStamps(probes.map(\.url))
66+
guard !Task.isCancelled else { return }
67+
for (probe, stamp) in zip(probes, stamps) {
68+
apply(stamp, to: probe)
69+
}
70+
}
71+
72+
private func apply(_ stamp: FileStamp?, to probe: Probe) {
73+
guard let tabManager,
74+
let tab = tabManager.tabs.first(where: { $0.id == probe.tabId }),
75+
tab.content.sourceFileURL == probe.url,
76+
tab.content.savedFileStamp == probe.recordedStamp else { return }
77+
var settled = tab.content
78+
FileTabBaseline.settle(FileTabBaseline.diskChange(in: settled, current: stamp), in: &settled)
79+
guard settled.diskChange != tab.content.diskChange
80+
|| settled.dismissedDiskChange != tab.content.dismissedDiskChange else { return }
81+
tabManager.mutate(tabId: probe.tabId) { mutable in
82+
mutable.content.diskChange = settled.diskChange
83+
mutable.content.dismissedDiskChange = settled.dismissedDiskChange
84+
}
85+
}
86+
}

TablePro/Core/Services/Infrastructure/EditorTabOpener.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ internal enum EditorTabOpener {
6969
title: payload.tabTitle,
7070
databaseName: payload.databaseName ?? browseDatabaseName,
7171
sourceFileURL: payload.sourceFileURL,
72+
sourceFileStamp: payload.sourceFileStamp,
7273
claimFocus: true
7374
)
7475
case .createTable:

TablePro/Core/Services/Infrastructure/TabRouter.swift

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -486,18 +486,21 @@ internal final class TabRouter {
486486
}
487487

488488
if let session = DatabaseManager.shared.lastActiveSession {
489-
let content = await Task.detached(priority: .userInitiated) { () -> String? in
490-
try? String(contentsOf: url, encoding: .utf8)
489+
let read = await Task.detached(priority: .userInitiated) { () -> (content: String, stamp: FileStamp?)? in
490+
let stamp = FileStamp.read(url)
491+
guard let content = try? String(contentsOf: url, encoding: .utf8) else { return nil }
492+
return (content, stamp)
491493
}.value
492-
guard let content else {
494+
guard let read else {
493495
Self.logger.error("Failed to read SQL file: \(url.lastPathComponent, privacy: .private(mask: .hash))")
494496
return
495497
}
496498
let payload = EditorTabPayload(
497499
connectionId: session.connection.id,
498500
tabType: .query,
499-
initialQuery: content,
500-
sourceFileURL: url
501+
initialQuery: read.content,
502+
sourceFileURL: url,
503+
sourceFileStamp: read.stamp
501504
)
502505
WindowManager.shared.openTab(payload: payload)
503506
AppActivationPolicyController.shared.activate(ignoringOtherApps: true)
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
//
2+
// FileStamp.swift
3+
// TablePro
4+
//
5+
6+
import Foundation
7+
8+
internal struct FileStamp: Codable, Hashable, Sendable {
9+
let modificationSeconds: Int
10+
let modificationNanoseconds: Int
11+
let size: Int64
12+
let fileNumber: UInt64
13+
14+
var modificationDate: Date {
15+
Date(timeIntervalSince1970: TimeInterval(modificationSeconds) + TimeInterval(modificationNanoseconds) / 1_000_000_000)
16+
}
17+
18+
static func read(_ url: URL) -> FileStamp? {
19+
var status = stat()
20+
let succeeded = url.withUnsafeFileSystemRepresentation { path -> Bool in
21+
guard let path else { return false }
22+
return stat(path, &status) == 0
23+
}
24+
guard succeeded else { return nil }
25+
return FileStamp(
26+
modificationSeconds: status.st_mtimespec.tv_sec,
27+
modificationNanoseconds: status.st_mtimespec.tv_nsec,
28+
size: status.st_size,
29+
fileNumber: status.st_ino
30+
)
31+
}
32+
}

TablePro/Core/Utilities/File/FileTextLoader.swift

Lines changed: 14 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -6,34 +6,34 @@
66
import Foundation
77

88
internal enum FileTextLoader {
9-
struct LoadedText {
9+
struct LoadedText: Sendable {
1010
let content: String
1111
let encoding: String.Encoding
12-
/// When the file was last written, as of just before this text was read.
12+
/// What the file was, as of just before this text was read.
1313
///
1414
/// Read here rather than by the caller, because a caller that stats afterwards records a
15-
/// date newer than the text it is holding, and a write that lands in between is then
16-
/// invisible: the tab looks up to date against a file it never read. Taking the date first
15+
/// stamp newer than the text it is holding, and a write that lands in between is then
16+
/// invisible: the tab looks up to date against a file it never read. Taking the stamp first
1717
/// fails the other way, leaving the baseline older than the text, so the changed-on-disk
1818
/// notice can fire once too often but never go missing.
19-
let modifiedAt: Date?
19+
let stamp: FileStamp?
2020
var isUTF8: Bool { encoding == .utf8 }
2121
}
2222

2323
static func load(_ url: URL) -> LoadedText? {
24-
let modifiedAt = modificationDate(of: url)
24+
let stamp = FileStamp.read(url)
2525
if startsWithByteOrderMark(url) {
26-
return loadByteOrderMarked(url, modifiedAt: modifiedAt)
26+
return loadByteOrderMarked(url, stamp: stamp)
2727
}
2828
var detected: String.Encoding = .utf8
2929
if let content = try? String(contentsOf: url, usedEncoding: &detected) {
30-
return LoadedText(content: content, encoding: detected, modifiedAt: modifiedAt)
30+
return LoadedText(content: content, encoding: detected, stamp: stamp)
3131
}
3232
if let content = try? String(contentsOf: url, encoding: .utf8) {
33-
return LoadedText(content: content, encoding: .utf8, modifiedAt: modifiedAt)
33+
return LoadedText(content: content, encoding: .utf8, stamp: stamp)
3434
}
3535
if let content = try? String(contentsOf: url, encoding: .isoLatin1) {
36-
return LoadedText(content: content, encoding: .isoLatin1, modifiedAt: modifiedAt)
36+
return LoadedText(content: content, encoding: .isoLatin1, stamp: stamp)
3737
}
3838
return nil
3939
}
@@ -43,17 +43,13 @@ internal enum FileTextLoader {
4343
return TextPrefixDecoder.decode(data, prefixLength: data.count)?.content
4444
}
4545

46-
static func modificationDate(of url: URL) -> Date? {
47-
(try? FileManager.default.attributesOfItem(atPath: url.path)[.modificationDate]) as? Date
48-
}
49-
5046
static func loadHeader(_ url: URL, maxBytes: Int = 4_096) -> LoadedText? {
47+
let stamp = FileStamp.read(url)
5148
guard let handle = try? FileHandle(forReadingFrom: url) else { return nil }
5249
defer { try? handle.close() }
5350
guard let bytes = try? handle.read(upToCount: maxBytes + TextPrefixDecoder.lookaheadLength),
5451
let decoded = TextPrefixDecoder.decode(bytes, prefixLength: maxBytes) else { return nil }
55-
let modifiedAt = modificationDate(of: url)
56-
return LoadedText(content: decoded.content, encoding: decoded.encoding, modifiedAt: modifiedAt)
52+
return LoadedText(content: decoded.content, encoding: decoded.encoding, stamp: stamp)
5753
}
5854

5955
private static func startsWithByteOrderMark(_ url: URL) -> Bool {
@@ -63,10 +59,10 @@ internal enum FileTextLoader {
6359
return ByteOrderMark.leading(bytes) != nil
6460
}
6561

66-
private static func loadByteOrderMarked(_ url: URL, modifiedAt: Date?) -> LoadedText? {
62+
private static func loadByteOrderMarked(_ url: URL, stamp: FileStamp?) -> LoadedText? {
6763
guard let bytes = try? Data(contentsOf: url),
6864
let decoded = TextPrefixDecoder.decode(bytes, prefixLength: bytes.count) else { return nil }
69-
return LoadedText(content: decoded.content, encoding: decoded.encoding, modifiedAt: modifiedAt)
65+
return LoadedText(content: decoded.content, encoding: decoded.encoding, stamp: stamp)
7066
}
7167
}
7268

TablePro/Core/VersionHistory/LinkedFileVersionHistoryProvider.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ internal struct LinkedFileVersionHistoryProvider: VersionHistoryProvider {
3131
let records = try await gitCall { try await client.status(in: directory, pathspec: fileURL.lastPathComponent) }
3232
let current = VersionHistoryEntry(
3333
reference: .current,
34-
date: FileTextLoader.modificationDate(of: fileURL),
34+
date: FileStamp.read(fileURL)?.modificationDate,
3535
hasUncommittedChanges: !records.isEmpty
3636
)
3737
guard try await gitCall({ try await client.hasCommits(in: directory) }) else {

TablePro/Models/Query/EditorTabPayload.swift

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ internal struct EditorTabPayload: Codable, Hashable {
5454
internal let initialFilterState: TabFilterState?
5555
/// Source file URL for .sql files opened from disk (used for deduplication)
5656
internal let sourceFileURL: URL?
57+
internal let sourceFileStamp: FileStamp?
5758
/// Schema key for ER diagram tabs
5859
internal let erDiagramSchemaKey: String?
5960
/// The routine or trigger a .objectSource tab shows
@@ -69,7 +70,7 @@ internal struct EditorTabPayload: Codable, Hashable {
6970
case initialQuery, isView, objectType, showStructure, skipAutoExecute, isPreview
7071
case forcesNewTab
7172
case tabTitle
72-
case initialFilterState, sourceFileURL, erDiagramSchemaKey, objectRef, versionHistorySubject, intent
73+
case initialFilterState, sourceFileURL, sourceFileStamp, erDiagramSchemaKey, objectRef, versionHistorySubject, intent
7374
// Legacy key for backward decoding only
7475
case isNewTab
7576
}
@@ -90,6 +91,7 @@ internal struct EditorTabPayload: Codable, Hashable {
9091
forcesNewTab: Bool = false,
9192
initialFilterState: TabFilterState? = nil,
9293
sourceFileURL: URL? = nil,
94+
sourceFileStamp: FileStamp? = nil,
9395
erDiagramSchemaKey: String? = nil,
9496
objectRef: DatabaseObjectRef? = nil,
9597
versionHistorySubject: VersionHistorySubject? = nil,
@@ -111,6 +113,7 @@ internal struct EditorTabPayload: Codable, Hashable {
111113
self.forcesNewTab = forcesNewTab
112114
self.initialFilterState = initialFilterState
113115
self.sourceFileURL = sourceFileURL
116+
self.sourceFileStamp = sourceFileStamp
114117
self.erDiagramSchemaKey = erDiagramSchemaKey
115118
self.objectRef = objectRef
116119
self.versionHistorySubject = versionHistorySubject
@@ -138,6 +141,7 @@ internal struct EditorTabPayload: Codable, Hashable {
138141
forcesNewTab = try container.decodeIfPresent(Bool.self, forKey: .forcesNewTab) ?? false
139142
initialFilterState = try container.decodeIfPresent(TabFilterState.self, forKey: .initialFilterState)
140143
sourceFileURL = try container.decodeIfPresent(URL.self, forKey: .sourceFileURL)
144+
sourceFileStamp = try container.decodeIfPresent(FileStamp.self, forKey: .sourceFileStamp)
141145
erDiagramSchemaKey = try container.decodeIfPresent(String.self, forKey: .erDiagramSchemaKey)
142146
objectRef = try container.decodeIfPresent(DatabaseObjectRef.self, forKey: .objectRef)
143147
versionHistorySubject = try container.decodeIfPresent(VersionHistorySubject.self, forKey: .versionHistorySubject)
@@ -167,6 +171,7 @@ internal struct EditorTabPayload: Codable, Hashable {
167171
try container.encode(forcesNewTab, forKey: .forcesNewTab)
168172
try container.encodeIfPresent(initialFilterState, forKey: .initialFilterState)
169173
try container.encodeIfPresent(sourceFileURL, forKey: .sourceFileURL)
174+
try container.encodeIfPresent(sourceFileStamp, forKey: .sourceFileStamp)
170175
try container.encodeIfPresent(erDiagramSchemaKey, forKey: .erDiagramSchemaKey)
171176
try container.encodeIfPresent(objectRef, forKey: .objectRef)
172177
try container.encodeIfPresent(versionHistorySubject, forKey: .versionHistorySubject)
@@ -191,6 +196,7 @@ internal struct EditorTabPayload: Codable, Hashable {
191196
self.forcesNewTab = false
192197
self.initialFilterState = nil
193198
self.sourceFileURL = tab.content.sourceFileURL
199+
self.sourceFileStamp = nil
194200
self.erDiagramSchemaKey = tab.display.erDiagramSchemaKey
195201
self.objectRef = tab.display.objectRef
196202
self.versionHistorySubject = tab.display.versionHistorySubject

TablePro/Models/Query/FileTabBaseline.swift

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,61 @@ import Foundation
1818
internal enum FileTabBaseline {
1919
internal static func hydrate(_ tab: inout QueryTab) {
2020
guard let url = tab.content.sourceFileURL, let loaded = FileTextLoader.load(url) else { return }
21-
tab.content.savedFileContent = loaded.content
22-
tab.content.loadMtime = loaded.modifiedAt
21+
record(loaded.content, stamp: loaded.stamp, in: &tab.content)
2322
}
2423

2524
internal static func hydrate(_ tabs: inout [QueryTab]) {
2625
for index in tabs.indices {
2726
hydrate(&tabs[index])
2827
}
2928
}
29+
30+
internal static func adopt(_ loaded: FileTextLoader.LoadedText, into content: inout TabQueryContent) {
31+
adopt(text: loaded.content, stamp: loaded.stamp, into: &content)
32+
}
33+
34+
internal static func adopt(text: String, stamp: FileStamp?, into content: inout TabQueryContent) {
35+
content.query = text
36+
record(text, stamp: stamp, in: &content)
37+
}
38+
39+
internal static func recordWrite(of text: String, to url: URL, in content: inout TabQueryContent) {
40+
record(text, stamp: FileStamp.read(url), in: &content)
41+
}
42+
43+
internal static func diskChange(in content: TabQueryContent) -> SourceFileDiskChange? {
44+
guard let url = content.sourceFileURL else { return nil }
45+
return diskChange(in: content, current: FileStamp.read(url))
46+
}
47+
48+
internal static func diskChange(in content: TabQueryContent, current: FileStamp?) -> SourceFileDiskChange? {
49+
guard content.sourceFileURL != nil else { return nil }
50+
return SourceFileDiskChange.detect(recorded: content.savedFileStamp, current: current)
51+
}
52+
53+
internal static func settle(_ detected: SourceFileDiskChange?, in content: inout TabQueryContent) {
54+
guard detected != content.dismissedDiskChange else {
55+
content.diskChange = nil
56+
return
57+
}
58+
content.dismissedDiskChange = nil
59+
content.diskChange = detected
60+
}
61+
62+
internal static func showDiskChange(_ change: SourceFileDiskChange, in content: inout TabQueryContent) {
63+
content.diskChange = change
64+
content.dismissedDiskChange = nil
65+
}
66+
67+
internal static func dismissDiskChange(in content: inout TabQueryContent) {
68+
content.dismissedDiskChange = content.diskChange
69+
content.diskChange = nil
70+
}
71+
72+
private static func record(_ text: String, stamp: FileStamp?, in content: inout TabQueryContent) {
73+
content.savedFileContent = text
74+
content.savedFileStamp = stamp
75+
content.diskChange = nil
76+
content.dismissedDiskChange = nil
77+
}
3078
}

0 commit comments

Comments
 (0)