Skip to content

Commit fbf0e87

Browse files
committed
perf: pane index, write debounce, one-pass pill
1 parent 87e69da commit fbf0e87

6 files changed

Lines changed: 117 additions & 46 deletions

File tree

Notchy/AutocompleteEngine.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ class AutocompleteEngine {
3030
let lowercasedInput = input.lowercased()
3131

3232
var scored: [AutocompleteSuggestion] = []
33+
let now = Date()
3334

3435
for cmd in commands {
3536
// Skip exact matches
@@ -52,7 +53,7 @@ class AutocompleteEngine {
5253
score += min(log2(Double(cmd.count) + 1) * 5, 30)
5354

5455
// Recency bonus
55-
let daysSinceUse = Date().timeIntervalSince(cmd.lastUsed) / 86400
56+
let daysSinceUse = now.timeIntervalSince(cmd.lastUsed) / 86400
5657
if daysSinceUse < 1 {
5758
score += 20
5859
} else if daysSinceUse < 7 {

Notchy/CommandStore.swift

Lines changed: 38 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,37 @@ class CommandStore {
7474
/// Drain any in-flight async work so all queued writes finish before we
7575
/// return. Safe to call from any thread.
7676
func flushSync() {
77-
queue.sync { /* serial fence */ }
77+
queue.sync { drainPendingSaves() }
78+
}
79+
80+
// MARK: - Debounced persistence (call only from within queue)
81+
82+
/// Directories with unsaved changes and the exact snapshot to persist.
83+
/// Holding the data (not just the dirty flag) protects against the LRU
84+
/// cache evicting a directory before its debounced save lands.
85+
private var pendingSaves: [String: [StoredCommand]] = [:]
86+
private var saveTimer: DispatchSourceTimer?
87+
88+
/// Coalesces the full-file JSON rewrite that used to run on every Enter.
89+
/// Safe against quits: the willTerminate/willResignActive observers call
90+
/// flushSync(), which drains pending saves synchronously.
91+
private func scheduleSave(_ commands: [StoredCommand], for directory: String) {
92+
pendingSaves[directory] = commands
93+
guard saveTimer == nil else { return }
94+
let timer = DispatchSource.makeTimerSource(queue: queue)
95+
timer.schedule(deadline: .now() + 1.0)
96+
timer.setEventHandler { [weak self] in self?.drainPendingSaves() }
97+
timer.resume()
98+
saveTimer = timer
99+
}
100+
101+
private func drainPendingSaves() {
102+
saveTimer?.cancel()
103+
saveTimer = nil
104+
for (directory, commands) in pendingSaves {
105+
saveCommands(commands, for: directory)
106+
}
107+
pendingSaves.removeAll()
78108
}
79109

80110
// MARK: - Public API
@@ -103,12 +133,7 @@ class CommandStore {
103133
cmds = Array(cmds.prefix(Self.maxCommandsPerDirectory))
104134
}
105135
self.updateCache(directory: directory, commands: cmds)
106-
// Persist immediately on the serial queue. The previous version
107-
// debounced writes by 2s and relied on app-lifecycle notifications
108-
// to flush — those notifications never fired because the
109-
// selector-based observer required NSObject inheritance, so a
110-
// burst of commands followed by a quit lost all of them.
111-
self.saveCommands(cmds, for: directory)
136+
self.scheduleSave(cmds, for: directory)
112137
}
113138
}
114139

@@ -118,7 +143,7 @@ class CommandStore {
118143
var cmds = self._commands(for: directory)
119144
cmds.removeAll { $0.text == command }
120145
self.updateCache(directory: directory, commands: cmds)
121-
self.saveCommands(cmds, for: directory)
146+
self.scheduleSave(cmds, for: directory)
122147
}
123148
}
124149

@@ -186,6 +211,11 @@ class CommandStore {
186211
touchCache(directory)
187212
return cached
188213
}
214+
// A pending debounced save is fresher than the file on disk.
215+
if let pending = pendingSaves[directory] {
216+
updateCache(directory: directory, commands: pending)
217+
return pending
218+
}
189219
let loaded = loadCommands(for: directory)
190220
updateCache(directory: directory, commands: loaded)
191221
return loaded

Notchy/NotchWindow.swift

Lines changed: 29 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -353,6 +353,8 @@ class NotchWindow: NSPanel {
353353
self?.proximityTick()
354354
}
355355
if let timer = hoverPollTimer {
356+
// Let the system coalesce wakeups — hover latency tolerates 50ms.
357+
timer.tolerance = 0.05
356358
RunLoop.main.add(timer, forMode: .common)
357359
}
358360
}
@@ -606,38 +608,43 @@ enum NotchDisplayState: Equatable {
606608

607609
/// Hierarchy: .taskCompleted (always shown) > .waitingForInput > .working > .idle
608610
@MainActor static var current: NotchDisplayState {
609-
let sessions = SessionStore.shared.sessions
610-
if sessions.contains(where: { $0.terminalStatus == .taskCompleted }) {
611-
return .taskCompleted
612-
}
613-
if sessions.contains(where: { $0.terminalStatus == .waitingForInput }) {
614-
return .waitingForInput
615-
}
616-
if sessions.contains(where: { $0.terminalStatus == .working }) {
617-
return .working
611+
snapshot().state
612+
}
613+
614+
/// Aggregate state + attention count in a single pass over the sessions.
615+
/// `terminalStatus` recomputes over paneStatuses on each access, so the
616+
/// pill computes this once per render instead of once per property access.
617+
@MainActor static func snapshot() -> (state: NotchDisplayState, attentionCount: Int) {
618+
var state = NotchDisplayState.idle
619+
var count = 0
620+
for session in SessionStore.shared.sessions {
621+
switch session.terminalStatus {
622+
case .taskCompleted:
623+
state = .taskCompleted
624+
count += 1
625+
case .waitingForInput:
626+
if state != .taskCompleted { state = .waitingForInput }
627+
count += 1
628+
case .working:
629+
if state == .idle { state = .working }
630+
count += 1
631+
default:
632+
break
633+
}
618634
}
619-
return .idle
620-
}
621-
622-
/// Number of sessions currently demanding attention (working, waiting for
623-
/// input, or just completed). Drives the count badge in the notch pill.
624-
@MainActor static var attentionCount: Int {
625-
SessionStore.shared.sessions.filter {
626-
$0.terminalStatus == .working
627-
|| $0.terminalStatus == .waitingForInput
628-
|| $0.terminalStatus == .taskCompleted
629-
}.count
635+
return (state, count)
630636
}
631637
}
632638

633639
// MARK: - Notch pill SwiftUI content
634640

635641
struct NotchPillContent: View {
636642
var isHovering: Bool = false
637-
private var displayState: NotchDisplayState { .current }
638-
private var attentionCount: Int { NotchDisplayState.attentionCount }
639643

640644
var body: some View {
645+
// Single pass over sessions per render; each property access would
646+
// otherwise re-scan the full session list.
647+
let (displayState, attentionCount) = NotchDisplayState.snapshot()
641648
ZStack {
642649
HStack {
643650
if displayState == .idle {

Notchy/SessionHistoryManager.swift

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -62,10 +62,12 @@ class SessionHistoryManager {
6262
func appendData(_ data: Data, for sessionId: UUID) {
6363
guard !data.isEmpty else { return }
6464
queue.async { [self] in
65-
if var pending = pendingWrites[sessionId] {
66-
pending.data.append(data)
67-
pendingWrites[sessionId] = pending
68-
if pending.data.count >= Self.flushBytesThreshold {
65+
if pendingWrites[sessionId] != nil {
66+
// Append in place — pulling the struct out into a local first
67+
// makes the Data multiply-referenced and forces a full
68+
// copy-on-write of the accumulated buffer on every chunk.
69+
pendingWrites[sessionId]!.data.append(data)
70+
if pendingWrites[sessionId]!.data.count >= Self.flushBytesThreshold {
6971
flushPending(for: sessionId)
7072
}
7173
} else {

Notchy/SessionStore.swift

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -215,7 +215,7 @@ class SessionStore {
215215
}
216216

217217
func updateWorkingDirectory(_ paneId: UUID, directory: String) {
218-
guard let index = sessions.firstIndex(where: { $0.splitRoot.containsPane(paneId) }) else { return }
218+
guard let index = sessionIndex(forPane: paneId) else { return }
219219
let oldDir = sessions[index].splitRoot.workingDirectory(for: paneId)
220220
guard oldDir != directory else { return }
221221
sessions[index].splitRoot = sessions[index].splitRoot.updatingWorkingDirectory(paneId, to: directory)
@@ -386,19 +386,42 @@ class SessionStore {
386386
persistSessions()
387387
}
388388

389+
/// Cache mapping pane id → owning session id. Self-healing: every hit is
390+
/// verified against the current split tree and any miss triggers a full
391+
/// rebuild, so split/close/restore mutations never need to invalidate it.
392+
/// Avoids a DFS over every session's split tree on each output event.
393+
@ObservationIgnored private var paneToSessionCache: [UUID: UUID] = [:]
394+
395+
private func sessionIndex(forPane paneId: UUID) -> Int? {
396+
if let sid = paneToSessionCache[paneId],
397+
let index = sessions.firstIndex(where: { $0.id == sid }),
398+
sessions[index].splitRoot.containsPane(paneId) {
399+
return index
400+
}
401+
paneToSessionCache.removeAll(keepingCapacity: true)
402+
for session in sessions {
403+
for pane in session.splitRoot.allPaneIds {
404+
paneToSessionCache[pane] = session.id
405+
}
406+
}
407+
guard let sid = paneToSessionCache[paneId],
408+
let index = sessions.firstIndex(where: { $0.id == sid }) else { return nil }
409+
return index
410+
}
411+
389412
/// Flag a session as having unread output when a chunk arrives on a pane
390413
/// whose tab is not active. Idempotent: only mutates on the first chunk
391414
/// after the flag was cleared, so a busy background tab won't churn.
392415
func noteOutput(forPane paneId: UUID) {
393-
guard let index = sessions.firstIndex(where: { $0.splitRoot.containsPane(paneId) }) else { return }
416+
guard let index = sessionIndex(forPane: paneId) else { return }
394417
let sessionId = sessions[index].id
395418
guard sessionId != activeSessionId, !sessions[index].isSleeping else { return }
396419
guard !unreadSessionIds.contains(sessionId) else { return }
397420
unreadSessionIds.insert(sessionId)
398421
}
399422

400423
func updateTerminalStatus(_ paneId: UUID, status: TerminalStatus) {
401-
guard let index = sessions.firstIndex(where: { $0.splitRoot.containsPane(paneId) }) else { return }
424+
guard let index = sessionIndex(forPane: paneId) else { return }
402425
// A sleeping session has no live terminals; any in-flight status callback
403426
// from a torn-down terminal must not resurrect paneStatuses.
404427
guard !sessions[index].isSleeping else { return }
@@ -740,7 +763,7 @@ class SessionStore {
740763
}
741764

742765
func focusPane(_ paneId: UUID) {
743-
guard let index = sessions.firstIndex(where: { $0.splitRoot.containsPane(paneId) }) else { return }
766+
guard let index = sessionIndex(forPane: paneId) else { return }
744767
sessions[index].focusedPaneId = paneId
745768
TerminalManager.shared.focusTerminal(for: paneId)
746769
}

Notchy/TerminalManager.swift

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -360,11 +360,15 @@ class ClickThroughTerminalView: LocalProcessTerminalView {
360360

361361
// Debounce status checks — the buffer can be mid-render when
362362
// dataReceived fires, causing transient misreads that flicker
363-
// between .working and .idle.
364-
statusDebounceTimer?.invalidate()
365-
statusDebounceTimer = Timer.scheduledTimer(withTimeInterval: 0.15, repeats: false) { [weak self] _ in
366-
guard let self else { return }
367-
self.evaluateStatus(for: id)
363+
// between .working and .idle. Pushing fireDate back reuses the
364+
// timer instead of allocating a new one per chunk.
365+
if let timer = statusDebounceTimer, timer.isValid {
366+
timer.fireDate = Date().addingTimeInterval(0.15)
367+
} else {
368+
statusDebounceTimer = Timer.scheduledTimer(withTimeInterval: 0.15, repeats: false) { [weak self] _ in
369+
guard let self else { return }
370+
self.evaluateStatus(for: id)
371+
}
368372
}
369373

370374
// Trigger autocomplete evaluation
@@ -418,17 +422,21 @@ class ClickThroughTerminalView: LocalProcessTerminalView {
418422
return Self.sanitizeForDisplay(String(last.prefix(100)))
419423
}
420424

425+
// Compiled once — sanitizeForDisplay runs on every status evaluation.
426+
private static let csiRegex = try? NSRegularExpression(pattern: "\u{001B}\\[[0-?]*[ -/]*[@-~]")
427+
private static let oscRegex = try? NSRegularExpression(pattern: "\u{001B}\\][^\u{0007}\u{001B}]*[\u{0007}\u{001B}]")
428+
421429
/// Removes ANSI escape sequences, control characters, and bidi-override
422430
/// codepoints so terminal output cannot spoof or break notification text.
423431
static func sanitizeForDisplay(_ input: String) -> String {
424432
var stripped = input
425433
// Strip CSI escape sequences (ESC [ ... letter)
426-
if let regex = try? NSRegularExpression(pattern: "\u{001B}\\[[0-?]*[ -/]*[@-~]") {
434+
if let regex = csiRegex {
427435
let range = NSRange(stripped.startIndex..., in: stripped)
428436
stripped = regex.stringByReplacingMatches(in: stripped, range: range, withTemplate: "")
429437
}
430438
// Strip OSC sequences (ESC ] ... BEL/ST)
431-
if let regex = try? NSRegularExpression(pattern: "\u{001B}\\][^\u{0007}\u{001B}]*[\u{0007}\u{001B}]") {
439+
if let regex = oscRegex {
432440
let range = NSRange(stripped.startIndex..., in: stripped)
433441
stripped = regex.stringByReplacingMatches(in: stripped, range: range, withTemplate: "")
434442
}

0 commit comments

Comments
 (0)