Skip to content

Commit e74d659

Browse files
committed
Improve sync folder performance
1 parent a6f1e45 commit e74d659

5 files changed

Lines changed: 261 additions & 27 deletions

File tree

Sources/TokenMeter/DashboardModel.swift

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,9 @@ final class DashboardModel: ObservableObject {
106106
let device = Self.loadLocalDevice(defaults: defaults)
107107
self.localDevice = device
108108
self.scanner = TokenLogScanner(localDevice: device)
109-
self.syncFolderPath = defaults.string(forKey: Self.syncFolderPathKey)
109+
let storedSyncFolderPath = defaults.string(forKey: Self.syncFolderPathKey)
110+
self.syncFolderPath = storedSyncFolderPath
111+
self.deviceFilter = storedSyncFolderPath?.isEmpty == false ? Self.allDevicesFilterId : device.id
110112
refresh()
111113
}
112114

@@ -157,8 +159,9 @@ final class DashboardModel: ObservableObject {
157159
if !modelOptions.contains(modelFilter) {
158160
modelFilter = "All Models"
159161
}
160-
if !deviceOptions.contains(where: { $0.id == deviceFilter }) {
161-
deviceFilter = Self.allDevicesFilterId
162+
let availableDeviceOptions = deviceOptions
163+
if !availableDeviceOptions.contains(where: { $0.id == deviceFilter }) {
164+
deviceFilter = availableDeviceOptions.first?.id ?? Self.allDevicesFilterId
162165
}
163166
}
164167

@@ -171,6 +174,10 @@ final class DashboardModel: ObservableObject {
171174
return URL(fileURLWithPath: syncFolderPath)
172175
}
173176

177+
var isSyncConfigured: Bool {
178+
syncFolderURL != nil
179+
}
180+
174181
var defaultICloudSyncFolderURL: URL? {
175182
let root = FileManager.default.homeDirectoryForCurrentUser
176183
.appendingPathComponent("Library", isDirectory: true)
@@ -185,10 +192,16 @@ final class DashboardModel: ObservableObject {
185192
}
186193

187194
var selectedDeviceTitle: String {
188-
deviceOptions.first { $0.id == deviceFilter }?.title ?? "All Devices"
195+
deviceOptions.first { $0.id == deviceFilter }?.title ?? deviceOptions.first?.title ?? "This Mac"
189196
}
190197

191198
var deviceOptions: [DashboardDeviceOption] {
199+
guard isSyncConfigured else {
200+
return [
201+
DashboardDeviceOption(id: localDevice.id, title: "This Mac", deviceId: localDevice.id)
202+
]
203+
}
204+
192205
var options = [
193206
DashboardDeviceOption(id: Self.allDevicesFilterId, title: "All Devices", deviceId: nil),
194207
DashboardDeviceOption(id: localDevice.id, title: "This Mac", deviceId: localDevice.id)
@@ -231,7 +244,7 @@ final class DashboardModel: ObservableObject {
231244
func clearSyncFolder() {
232245
defaults.removeObject(forKey: Self.syncFolderPathKey)
233246
syncFolderPath = nil
234-
deviceFilter = Self.allDevicesFilterId
247+
deviceFilter = localDevice.id
235248
refresh(restartInProgress: true)
236249
}
237250

Sources/TokenMeter/DashboardView.swift

Lines changed: 34 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ struct DashboardView: View {
77
@EnvironmentObject private var updates: UpdateModel
88
@State private var showingFilters = false
99
@State private var showingDetails = false
10+
@State private var showingSyncSettings = false
1011
@AppStorage("showFullTokenNumbers") private var showFullTokenNumbers = false
1112

1213
private let refreshTimer = Timer.publish(every: 60, on: .main, in: .common).autoconnect()
@@ -43,7 +44,7 @@ struct DashboardView: View {
4344
.padding(.top, 10)
4445
}
4546

46-
syncFolderBlock
47+
syncSettingsSection
4748

4849
dataSourcesBlock
4950

@@ -142,15 +143,17 @@ struct DashboardView: View {
142143
}
143144

144145
private var headerSubtitle: String {
145-
let deviceScope = model.selectedDeviceTitle
146+
let sectionTitle: String
146147
switch model.selectedSection {
147148
case .all:
148-
return "Combined usage - \(deviceScope)"
149+
sectionTitle = "Combined usage"
149150
case .codex:
150-
return "Codex sessions - \(deviceScope)"
151+
sectionTitle = "Codex sessions"
151152
case .claude:
152-
return "Claude Code sessions - \(deviceScope)"
153+
sectionTitle = "Claude Code sessions"
153154
}
155+
guard model.isSyncConfigured else { return sectionTitle }
156+
return "\(sectionTitle) - \(model.selectedDeviceTitle)"
154157
}
155158

156159
private var numberFormat: TokenNumberFormat {
@@ -175,7 +178,9 @@ struct DashboardView: View {
175178
.fill(TokenMeterTheme.control)
176179
}
177180

178-
devicePicker
181+
if model.isSyncConfigured {
182+
devicePicker
183+
}
179184

180185
Spacer()
181186

@@ -545,7 +550,9 @@ struct DashboardView: View {
545550
footerItem("Codex files", model.scanResult.codexFileCount)
546551
footerItem("Claude files", model.scanResult.claudeFileCount)
547552
footerItem("Events", model.scanResult.events.count)
548-
footerItem("Devices", Set(model.scanResult.events.map(\.deviceId)).count)
553+
if model.isSyncConfigured {
554+
footerItem("Devices", Set(model.scanResult.events.map(\.deviceId)).count)
555+
}
549556
footerItem("Errors", model.scanResult.parseErrorCount)
550557
Spacer()
551558
Text("Scanned \(model.scanResult.scannedAt.formatted(date: .omitted, time: .shortened))")
@@ -611,7 +618,7 @@ struct DashboardView: View {
611618
return DashboardNotice(
612619
icon: "line.3.horizontal.decrease.circle",
613620
title: "No matching events",
614-
message: "The current section, time range, project, or model selection has no token events.",
621+
message: noMatchingEventsMessage,
615622
tint: TokenMeterTheme.secondaryText
616623
)
617624
}
@@ -646,6 +653,25 @@ struct DashboardView: View {
646653
return nil
647654
}
648655

656+
private var noMatchingEventsMessage: String {
657+
if model.isSyncConfigured {
658+
return "The current section, time range, project, model, or device selection has no token events."
659+
}
660+
return "The current section, time range, project, or model selection has no token events."
661+
}
662+
663+
@ViewBuilder
664+
private var syncSettingsSection: some View {
665+
if model.isSyncConfigured {
666+
syncFolderBlock
667+
} else {
668+
CollapsibleSection("Sync Folder", isExpanded: $showingSyncSettings) {
669+
syncFolderBlock
670+
.padding(.top, 8)
671+
}
672+
}
673+
}
674+
649675
private func chooseSyncFolder() {
650676
let panel = NSOpenPanel()
651677
panel.title = "Choose TokenMeter Sync Folder"

Sources/TokenMeterCore/SyncLedger.swift

Lines changed: 105 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ public final class TokenSyncLedgerStore: @unchecked Sendable {
1515
public func synchronize(
1616
localEvents: [TokenEvent],
1717
replaceLocalLedger: Bool = false,
18+
importedAfter: Date? = nil,
1819
isCancelled: () -> Bool = { false }
1920
) -> (events: [TokenEvent], status: SyncFolderStatus) {
2021
guard fileManager.fileExists(atPath: folder.path) else {
@@ -37,7 +38,7 @@ public final class TokenSyncLedgerStore: @unchecked Sendable {
3738
exportError = error.localizedDescription
3839
}
3940

40-
let readResult = readDeviceLedgers(isCancelled: isCancelled)
41+
let readResult = readDeviceLedgers(importedAfter: importedAfter, isCancelled: isCancelled)
4142
let status = SyncFolderStatus(
4243
path: folder.path,
4344
exists: true,
@@ -63,35 +64,83 @@ public final class TokenSyncLedgerStore: @unchecked Sendable {
6364
let localLedgerURL = devicesURL.appendingPathComponent("\(safeFileName(localDevice.id)).jsonl")
6465

6566
var recordsByKey: [String: SyncLedgerRecord] = [:]
66-
if !replaceExisting, fileManager.fileExists(atPath: localLedgerURL.path) {
67-
let existing = readRecords(from: localLedgerURL, isCancelled: isCancelled).records
68-
for record in existing {
69-
recordsByKey[record.identityKey] = record
70-
}
71-
}
72-
7367
for event in events where !isCancelled() {
7468
let record = SyncLedgerRecord(event: event.withDevice(localDevice))
7569
recordsByKey[record.identityKey] = record
7670
}
7771

78-
let records = recordsByKey.values.sorted {
72+
let records: [SyncLedgerRecord]
73+
if replaceExisting {
74+
records = sortedRecords(Array(recordsByKey.values))
75+
try write(records: records, to: localLedgerURL)
76+
return records.count
77+
}
78+
79+
let existingKeys = readIdentityKeys(from: localLedgerURL, isCancelled: isCancelled)
80+
records = sortedRecords(recordsByKey.values.filter { !existingKeys.contains($0.identityKey) })
81+
guard !records.isEmpty else { return 0 }
82+
83+
try append(records: records, to: localLedgerURL)
84+
return records.count
85+
}
86+
87+
private func sortedRecords(_ records: [SyncLedgerRecord]) -> [SyncLedgerRecord] {
88+
records.sorted {
7989
if $0.timestamp == $1.timestamp {
8090
return $0.eventId < $1.eventId
8191
}
8292
return $0.timestamp < $1.timestamp
8393
}
94+
}
95+
96+
private func write(records: [SyncLedgerRecord], to url: URL) throws {
8497
let encoder = Self.jsonEncoder
8598
let lines = try records.map { record in
8699
let data = try encoder.encode(record)
87100
return String(decoding: data, as: UTF8.self)
88101
}
89102
let data = (lines.joined(separator: "\n") + (lines.isEmpty ? "" : "\n")).data(using: .utf8) ?? Data()
90-
try data.write(to: localLedgerURL, options: [.atomic])
91-
return events.count
103+
try data.write(to: url, options: [.atomic])
104+
}
105+
106+
private func append(records: [SyncLedgerRecord], to url: URL) throws {
107+
let encoder = Self.jsonEncoder
108+
let lines = try records.map { record in
109+
let data = try encoder.encode(record)
110+
return String(decoding: data, as: UTF8.self)
111+
}
112+
guard !lines.isEmpty else { return }
113+
114+
if !fileManager.fileExists(atPath: url.path) {
115+
let data = (lines.joined(separator: "\n") + "\n").data(using: .utf8) ?? Data()
116+
try data.write(to: url, options: [.atomic])
117+
return
118+
}
119+
120+
let handle = try FileHandle(forUpdating: url)
121+
defer {
122+
try? handle.close()
123+
}
124+
125+
let endOffset = try handle.seekToEnd()
126+
if endOffset > 0 {
127+
try handle.seek(toOffset: endOffset - 1)
128+
let lastByte = handle.readDataToEndOfFile()
129+
if lastByte != Data([0x0A]) {
130+
try handle.seekToEnd()
131+
try handle.write(contentsOf: Data([0x0A]))
132+
}
133+
}
134+
135+
try handle.seekToEnd()
136+
let data = (lines.joined(separator: "\n") + "\n").data(using: .utf8) ?? Data()
137+
try handle.write(contentsOf: data)
92138
}
93139

94-
private func readDeviceLedgers(isCancelled: () -> Bool) -> (events: [TokenEvent], deviceFileCount: Int, parseErrorCount: Int) {
140+
private func readDeviceLedgers(
141+
importedAfter: Date?,
142+
isCancelled: () -> Bool
143+
) -> (events: [TokenEvent], deviceFileCount: Int, parseErrorCount: Int) {
95144
let devicesURL = folder.appendingPathComponent("devices", isDirectory: true)
96145
guard fileManager.fileExists(atPath: devicesURL.path) else {
97146
return ([], 0, 0)
@@ -111,15 +160,35 @@ public final class TokenSyncLedgerStore: @unchecked Sendable {
111160

112161
for case let url as URL in enumerator where !isCancelled() && url.pathExtension == "jsonl" {
113162
fileCount += 1
114-
let result = readRecords(from: url, isCancelled: isCancelled)
163+
let result = readRecords(from: url, importedAfter: importedAfter, isCancelled: isCancelled)
115164
parseErrors += result.parseErrorCount
116165
events.append(contentsOf: result.records.map(\.tokenEvent))
117166
}
118167

119168
return (events, fileCount, parseErrors)
120169
}
121170

122-
private func readRecords(from url: URL, isCancelled: () -> Bool) -> (records: [SyncLedgerRecord], parseErrorCount: Int) {
171+
private func readIdentityKeys(from url: URL, isCancelled: () -> Bool) -> Set<String> {
172+
guard fileManager.fileExists(atPath: url.path) else { return [] }
173+
guard let content = try? String(contentsOf: url, encoding: .utf8) else { return [] }
174+
175+
let decoder = Self.jsonDecoder
176+
var keys: Set<String> = []
177+
for line in content.split(separator: "\n", omittingEmptySubsequences: true) where !isCancelled() {
178+
guard let data = String(line).data(using: .utf8),
179+
let identity = try? decoder.decode(SyncLedgerIdentity.self, from: data) else {
180+
continue
181+
}
182+
keys.insert(identity.identityKey)
183+
}
184+
return keys
185+
}
186+
187+
private func readRecords(
188+
from url: URL,
189+
importedAfter: Date?,
190+
isCancelled: () -> Bool
191+
) -> (records: [SyncLedgerRecord], parseErrorCount: Int) {
123192
guard let content = try? String(contentsOf: url, encoding: .utf8) else {
124193
return ([], 1)
125194
}
@@ -135,6 +204,10 @@ public final class TokenSyncLedgerStore: @unchecked Sendable {
135204
}
136205

137206
do {
207+
if let importedAfter {
208+
let header = try decoder.decode(SyncLedgerRecordHeader.self, from: data)
209+
guard header.timestamp >= importedAfter else { continue }
210+
}
138211
let record = try decoder.decode(SyncLedgerRecord.self, from: data)
139212
records.append(record)
140213
} catch {
@@ -194,6 +267,24 @@ public final class TokenSyncLedgerStore: @unchecked Sendable {
194267
}
195268
}
196269

270+
private struct SyncLedgerIdentity: Decodable {
271+
var deviceId: String
272+
var eventId: String
273+
274+
var identityKey: String {
275+
"\(deviceId)|\(eventId)"
276+
}
277+
278+
enum CodingKeys: String, CodingKey {
279+
case deviceId = "device_id"
280+
case eventId = "event_id"
281+
}
282+
}
283+
284+
private struct SyncLedgerRecordHeader: Decodable {
285+
var timestamp: Date
286+
}
287+
197288
private struct SyncLedgerRecord: Codable, Hashable {
198289
var schemaVersion: Int
199290
var deviceId: String

Sources/TokenMeterCore/TokenLogScanner.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ public final class TokenLogScanner: @unchecked Sendable {
7474
localEvents: localEvents,
7575
syncFolder: syncFolder,
7676
replaceSyncLedger: replaceSyncLedger,
77+
importedAfter: modifiedAfter,
7778
isCancelled: isCancelled
7879
)
7980
let events = deduplicated(events: localEvents + syncOutcome.events)
@@ -200,6 +201,7 @@ public final class TokenLogScanner: @unchecked Sendable {
200201
localEvents: [TokenEvent],
201202
syncFolder: URL?,
202203
replaceSyncLedger: Bool,
204+
importedAfter: Date?,
203205
isCancelled: () -> Bool
204206
) -> (events: [TokenEvent], status: SyncFolderStatus) {
205207
guard let syncFolder else {
@@ -214,6 +216,7 @@ public final class TokenLogScanner: @unchecked Sendable {
214216
return store.synchronize(
215217
localEvents: localEvents,
216218
replaceLocalLedger: replaceSyncLedger,
219+
importedAfter: importedAfter,
217220
isCancelled: isCancelled
218221
)
219222
}

0 commit comments

Comments
 (0)