@lattices/cli 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +155 -0
- package/app/Lattices.app/Contents/Info.plist +24 -0
- package/app/Package.swift +13 -0
- package/app/Sources/AccessibilityTextExtractor.swift +111 -0
- package/app/Sources/ActionRow.swift +61 -0
- package/app/Sources/App.swift +10 -0
- package/app/Sources/AppDelegate.swift +242 -0
- package/app/Sources/AppShellView.swift +62 -0
- package/app/Sources/AppTypeClassifier.swift +70 -0
- package/app/Sources/AppWindowShell.swift +63 -0
- package/app/Sources/CheatSheetHUD.swift +332 -0
- package/app/Sources/CommandModeState.swift +1362 -0
- package/app/Sources/CommandModeView.swift +1405 -0
- package/app/Sources/CommandModeWindow.swift +192 -0
- package/app/Sources/CommandPaletteView.swift +307 -0
- package/app/Sources/CommandPaletteWindow.swift +134 -0
- package/app/Sources/DaemonProtocol.swift +101 -0
- package/app/Sources/DaemonServer.swift +414 -0
- package/app/Sources/DesktopModel.swift +149 -0
- package/app/Sources/DesktopModelTypes.swift +71 -0
- package/app/Sources/DiagnosticLog.swift +271 -0
- package/app/Sources/EventBus.swift +30 -0
- package/app/Sources/HotkeyManager.swift +254 -0
- package/app/Sources/HotkeyStore.swift +338 -0
- package/app/Sources/InventoryManager.swift +35 -0
- package/app/Sources/InventoryPath.swift +43 -0
- package/app/Sources/KeyRecorderView.swift +210 -0
- package/app/Sources/LatticesApi.swift +1234 -0
- package/app/Sources/LayerBezel.swift +203 -0
- package/app/Sources/MainView.swift +479 -0
- package/app/Sources/MainWindow.swift +83 -0
- package/app/Sources/OcrModel.swift +430 -0
- package/app/Sources/OcrStore.swift +329 -0
- package/app/Sources/OmniSearchState.swift +283 -0
- package/app/Sources/OmniSearchView.swift +288 -0
- package/app/Sources/OmniSearchWindow.swift +105 -0
- package/app/Sources/OrphanRow.swift +129 -0
- package/app/Sources/PaletteCommand.swift +419 -0
- package/app/Sources/PermissionChecker.swift +125 -0
- package/app/Sources/Preferences.swift +99 -0
- package/app/Sources/ProcessModel.swift +199 -0
- package/app/Sources/ProcessQuery.swift +151 -0
- package/app/Sources/Project.swift +28 -0
- package/app/Sources/ProjectRow.swift +368 -0
- package/app/Sources/ProjectScanner.swift +128 -0
- package/app/Sources/ScreenMapState.swift +2387 -0
- package/app/Sources/ScreenMapView.swift +2820 -0
- package/app/Sources/ScreenMapWindowController.swift +89 -0
- package/app/Sources/SessionManager.swift +72 -0
- package/app/Sources/SettingsView.swift +1064 -0
- package/app/Sources/SettingsWindow.swift +20 -0
- package/app/Sources/TabGroupRow.swift +178 -0
- package/app/Sources/Terminal.swift +259 -0
- package/app/Sources/TerminalQuery.swift +156 -0
- package/app/Sources/TerminalSynthesizer.swift +200 -0
- package/app/Sources/Theme.swift +163 -0
- package/app/Sources/TilePickerView.swift +209 -0
- package/app/Sources/TmuxModel.swift +53 -0
- package/app/Sources/TmuxQuery.swift +81 -0
- package/app/Sources/WindowTiler.swift +1778 -0
- package/app/Sources/WorkspaceManager.swift +575 -0
- package/bin/client.js +4 -0
- package/bin/daemon-client.js +187 -0
- package/bin/lattices-app.js +221 -0
- package/bin/lattices.js +1551 -0
- package/docs/api.md +924 -0
- package/docs/app.md +297 -0
- package/docs/concepts.md +135 -0
- package/docs/config.md +245 -0
- package/docs/layers.md +410 -0
- package/docs/ocr.md +185 -0
- package/docs/overview.md +94 -0
- package/docs/quickstart.md +75 -0
- package/package.json +42 -0
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import SQLite3
|
|
3
|
+
|
|
4
|
+
// MARK: - Search Result
|
|
5
|
+
|
|
6
|
+
struct OcrSearchResult {
|
|
7
|
+
let id: Int64
|
|
8
|
+
let wid: UInt32
|
|
9
|
+
let app: String
|
|
10
|
+
let title: String
|
|
11
|
+
let frame: WindowFrame
|
|
12
|
+
let fullText: String
|
|
13
|
+
let snippet: String
|
|
14
|
+
let timestamp: Date
|
|
15
|
+
let source: TextSource
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// MARK: - SQLite OCR Store
|
|
19
|
+
|
|
20
|
+
final class OcrStore {
|
|
21
|
+
static let shared = OcrStore()
|
|
22
|
+
|
|
23
|
+
private var db: OpaquePointer?
|
|
24
|
+
private let queue = DispatchQueue(label: "com.arach.lattices.ocrstore", qos: .background)
|
|
25
|
+
|
|
26
|
+
// Cached prepared statements
|
|
27
|
+
private var insertStmt: OpaquePointer?
|
|
28
|
+
private var cleanupStmt: OpaquePointer?
|
|
29
|
+
|
|
30
|
+
private let sqliteTransient = unsafeBitCast(-1, to: sqlite3_destructor_type.self)
|
|
31
|
+
|
|
32
|
+
// MARK: - Open / Schema
|
|
33
|
+
|
|
34
|
+
func open() {
|
|
35
|
+
queue.sync {
|
|
36
|
+
guard db == nil else { return }
|
|
37
|
+
|
|
38
|
+
let dir = NSHomeDirectory() + "/.lattices"
|
|
39
|
+
try? FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)
|
|
40
|
+
let path = dir + "/ocr.db"
|
|
41
|
+
|
|
42
|
+
guard sqlite3_open_v2(path, &db, SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE | SQLITE_OPEN_FULLMUTEX, nil) == SQLITE_OK else {
|
|
43
|
+
DiagnosticLog.shared.error("OcrStore: failed to open \(path)")
|
|
44
|
+
return
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// WAL mode for concurrent reads/writes
|
|
48
|
+
exec("PRAGMA journal_mode=WAL")
|
|
49
|
+
exec("PRAGMA synchronous=NORMAL")
|
|
50
|
+
|
|
51
|
+
// Main table
|
|
52
|
+
exec("""
|
|
53
|
+
CREATE TABLE IF NOT EXISTS ocr_entry (
|
|
54
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
55
|
+
wid INTEGER NOT NULL,
|
|
56
|
+
app TEXT NOT NULL,
|
|
57
|
+
title TEXT NOT NULL,
|
|
58
|
+
frame_x REAL,
|
|
59
|
+
frame_y REAL,
|
|
60
|
+
frame_w REAL,
|
|
61
|
+
frame_h REAL,
|
|
62
|
+
full_text TEXT NOT NULL,
|
|
63
|
+
timestamp REAL NOT NULL
|
|
64
|
+
)
|
|
65
|
+
""")
|
|
66
|
+
exec("CREATE INDEX IF NOT EXISTS idx_ocr_entry_timestamp ON ocr_entry(timestamp)")
|
|
67
|
+
exec("CREATE INDEX IF NOT EXISTS idx_ocr_entry_wid ON ocr_entry(wid)")
|
|
68
|
+
|
|
69
|
+
// FTS5 content-sync table
|
|
70
|
+
exec("""
|
|
71
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS ocr_fts USING fts5(
|
|
72
|
+
full_text, app, title,
|
|
73
|
+
content='ocr_entry', content_rowid='id'
|
|
74
|
+
)
|
|
75
|
+
""")
|
|
76
|
+
|
|
77
|
+
// Triggers to keep FTS in sync
|
|
78
|
+
exec("""
|
|
79
|
+
CREATE TRIGGER IF NOT EXISTS ocr_fts_ai AFTER INSERT ON ocr_entry BEGIN
|
|
80
|
+
INSERT INTO ocr_fts(rowid, full_text, app, title)
|
|
81
|
+
VALUES (new.id, new.full_text, new.app, new.title);
|
|
82
|
+
END
|
|
83
|
+
""")
|
|
84
|
+
exec("""
|
|
85
|
+
CREATE TRIGGER IF NOT EXISTS ocr_fts_ad AFTER DELETE ON ocr_entry BEGIN
|
|
86
|
+
INSERT INTO ocr_fts(ocr_fts, rowid, full_text, app, title)
|
|
87
|
+
VALUES ('delete', old.id, old.full_text, old.app, old.title);
|
|
88
|
+
END
|
|
89
|
+
""")
|
|
90
|
+
exec("""
|
|
91
|
+
CREATE TRIGGER IF NOT EXISTS ocr_fts_au AFTER UPDATE ON ocr_entry BEGIN
|
|
92
|
+
INSERT INTO ocr_fts(ocr_fts, rowid, full_text, app, title)
|
|
93
|
+
VALUES ('delete', old.id, old.full_text, old.app, old.title);
|
|
94
|
+
INSERT INTO ocr_fts(rowid, full_text, app, title)
|
|
95
|
+
VALUES (new.id, new.full_text, new.app, new.title);
|
|
96
|
+
END
|
|
97
|
+
""")
|
|
98
|
+
|
|
99
|
+
// Migration: add source column if not present
|
|
100
|
+
migrateAddSourceColumn()
|
|
101
|
+
|
|
102
|
+
// Prepare cached statements
|
|
103
|
+
prepareInsert()
|
|
104
|
+
prepareCleanup()
|
|
105
|
+
|
|
106
|
+
// Run cleanup on open
|
|
107
|
+
cleanupSync(olderThanDays: 3)
|
|
108
|
+
|
|
109
|
+
DiagnosticLog.shared.info("OcrStore: opened \(path)")
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// MARK: - Insert (batch, async)
|
|
114
|
+
|
|
115
|
+
func insert(results: [OcrWindowResult]) {
|
|
116
|
+
guard !results.isEmpty else { return }
|
|
117
|
+
queue.async { [weak self] in
|
|
118
|
+
guard let self, let db = self.db, let stmt = self.insertStmt else { return }
|
|
119
|
+
|
|
120
|
+
sqlite3_exec(db, "BEGIN TRANSACTION", nil, nil, nil)
|
|
121
|
+
|
|
122
|
+
for r in results {
|
|
123
|
+
let ts = r.timestamp.timeIntervalSince1970
|
|
124
|
+
sqlite3_bind_int(stmt, 1, Int32(r.wid))
|
|
125
|
+
self.bindText(stmt, 2, r.app)
|
|
126
|
+
self.bindText(stmt, 3, r.title)
|
|
127
|
+
sqlite3_bind_double(stmt, 4, r.frame.x)
|
|
128
|
+
sqlite3_bind_double(stmt, 5, r.frame.y)
|
|
129
|
+
sqlite3_bind_double(stmt, 6, r.frame.w)
|
|
130
|
+
sqlite3_bind_double(stmt, 7, r.frame.h)
|
|
131
|
+
self.bindText(stmt, 8, r.fullText)
|
|
132
|
+
sqlite3_bind_double(stmt, 9, ts)
|
|
133
|
+
self.bindText(stmt, 10, r.source.rawValue)
|
|
134
|
+
sqlite3_step(stmt)
|
|
135
|
+
sqlite3_reset(stmt)
|
|
136
|
+
sqlite3_clear_bindings(stmt)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
sqlite3_exec(db, "COMMIT", nil, nil, nil)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// MARK: - Search (FTS5, synchronous)
|
|
144
|
+
|
|
145
|
+
func search(query: String, app: String? = nil, limit: Int = 50) -> [OcrSearchResult] {
|
|
146
|
+
guard let db else { return [] }
|
|
147
|
+
|
|
148
|
+
var sql = """
|
|
149
|
+
SELECT e.id, e.wid, e.app, e.title,
|
|
150
|
+
e.frame_x, e.frame_y, e.frame_w, e.frame_h,
|
|
151
|
+
e.full_text, e.timestamp,
|
|
152
|
+
snippet(ocr_fts, 0, '»', '«', '…', 32) AS snip,
|
|
153
|
+
COALESCE(e.source, 'ocr') AS source
|
|
154
|
+
FROM ocr_fts f
|
|
155
|
+
JOIN ocr_entry e ON e.id = f.rowid
|
|
156
|
+
WHERE ocr_fts MATCH ?1
|
|
157
|
+
"""
|
|
158
|
+
if app != nil { sql += " AND e.app = ?2" }
|
|
159
|
+
sql += " ORDER BY rank LIMIT ?3"
|
|
160
|
+
|
|
161
|
+
var stmt: OpaquePointer?
|
|
162
|
+
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [] }
|
|
163
|
+
defer { sqlite3_finalize(stmt) }
|
|
164
|
+
|
|
165
|
+
bindText(stmt!, 1, query)
|
|
166
|
+
if let app { bindText(stmt!, 2, app) }
|
|
167
|
+
sqlite3_bind_int(stmt!, 3, Int32(limit))
|
|
168
|
+
|
|
169
|
+
var results: [OcrSearchResult] = []
|
|
170
|
+
while sqlite3_step(stmt) == SQLITE_ROW {
|
|
171
|
+
results.append(rowToSearchResult(stmt!))
|
|
172
|
+
}
|
|
173
|
+
return results
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// MARK: - History (per-window, synchronous)
|
|
177
|
+
|
|
178
|
+
func history(wid: UInt32, limit: Int = 50) -> [OcrSearchResult] {
|
|
179
|
+
guard let db else { return [] }
|
|
180
|
+
|
|
181
|
+
let sql = """
|
|
182
|
+
SELECT id, wid, app, title,
|
|
183
|
+
frame_x, frame_y, frame_w, frame_h,
|
|
184
|
+
full_text, timestamp, '' AS snip,
|
|
185
|
+
COALESCE(source, 'ocr') AS source
|
|
186
|
+
FROM ocr_entry
|
|
187
|
+
WHERE wid = ?1
|
|
188
|
+
ORDER BY timestamp DESC
|
|
189
|
+
LIMIT ?2
|
|
190
|
+
"""
|
|
191
|
+
|
|
192
|
+
var stmt: OpaquePointer?
|
|
193
|
+
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [] }
|
|
194
|
+
defer { sqlite3_finalize(stmt) }
|
|
195
|
+
|
|
196
|
+
sqlite3_bind_int(stmt!, 1, Int32(wid))
|
|
197
|
+
sqlite3_bind_int(stmt!, 2, Int32(limit))
|
|
198
|
+
|
|
199
|
+
var results: [OcrSearchResult] = []
|
|
200
|
+
while sqlite3_step(stmt) == SQLITE_ROW {
|
|
201
|
+
results.append(rowToSearchResult(stmt!))
|
|
202
|
+
}
|
|
203
|
+
return results
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// MARK: - Recent (chronological, synchronous)
|
|
207
|
+
|
|
208
|
+
func recent(limit: Int = 50) -> [OcrSearchResult] {
|
|
209
|
+
guard let db else { return [] }
|
|
210
|
+
|
|
211
|
+
let sql = """
|
|
212
|
+
SELECT id, wid, app, title,
|
|
213
|
+
frame_x, frame_y, frame_w, frame_h,
|
|
214
|
+
full_text, timestamp, '' AS snip,
|
|
215
|
+
COALESCE(source, 'ocr') AS source
|
|
216
|
+
FROM ocr_entry
|
|
217
|
+
ORDER BY timestamp DESC
|
|
218
|
+
LIMIT ?1
|
|
219
|
+
"""
|
|
220
|
+
|
|
221
|
+
var stmt: OpaquePointer?
|
|
222
|
+
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [] }
|
|
223
|
+
defer { sqlite3_finalize(stmt) }
|
|
224
|
+
|
|
225
|
+
sqlite3_bind_int(stmt!, 1, Int32(limit))
|
|
226
|
+
|
|
227
|
+
var results: [OcrSearchResult] = []
|
|
228
|
+
while sqlite3_step(stmt) == SQLITE_ROW {
|
|
229
|
+
results.append(rowToSearchResult(stmt!))
|
|
230
|
+
}
|
|
231
|
+
return results
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// MARK: - Cleanup
|
|
235
|
+
|
|
236
|
+
private func cleanupSync(olderThanDays days: Int) {
|
|
237
|
+
guard let db, let stmt = cleanupStmt else { return }
|
|
238
|
+
let cutoff = Date().timeIntervalSince1970 - Double(days * 86400)
|
|
239
|
+
sqlite3_bind_double(stmt, 1, cutoff)
|
|
240
|
+
sqlite3_step(stmt)
|
|
241
|
+
sqlite3_reset(stmt)
|
|
242
|
+
sqlite3_clear_bindings(stmt)
|
|
243
|
+
|
|
244
|
+
let deleted = sqlite3_changes(db)
|
|
245
|
+
if deleted > 0 {
|
|
246
|
+
DiagnosticLog.shared.info("OcrStore: cleaned up \(deleted) entries older than \(days) days")
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// MARK: - Helpers
|
|
251
|
+
|
|
252
|
+
private func exec(_ sql: String) {
|
|
253
|
+
guard let db else { return }
|
|
254
|
+
var err: UnsafeMutablePointer<CChar>?
|
|
255
|
+
if sqlite3_exec(db, sql, nil, nil, &err) != SQLITE_OK {
|
|
256
|
+
let msg = err.map { String(cString: $0) } ?? "unknown"
|
|
257
|
+
DiagnosticLog.shared.error("OcrStore SQL error: \(msg)")
|
|
258
|
+
sqlite3_free(err)
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
private func bindText(_ stmt: OpaquePointer, _ index: Int32, _ value: String) {
|
|
263
|
+
sqlite3_bind_text(stmt, index, value, -1, sqliteTransient)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
private func prepareInsert() {
|
|
267
|
+
let sql = """
|
|
268
|
+
INSERT INTO ocr_entry (wid, app, title, frame_x, frame_y, frame_w, frame_h, full_text, timestamp, source)
|
|
269
|
+
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)
|
|
270
|
+
"""
|
|
271
|
+
sqlite3_prepare_v2(db, sql, -1, &insertStmt, nil)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
private func migrateAddSourceColumn() {
|
|
275
|
+
// Check if source column exists
|
|
276
|
+
var tableInfoStmt: OpaquePointer?
|
|
277
|
+
let sql = "PRAGMA table_info(ocr_entry)"
|
|
278
|
+
guard sqlite3_prepare_v2(db, sql, -1, &tableInfoStmt, nil) == SQLITE_OK else { return }
|
|
279
|
+
defer { sqlite3_finalize(tableInfoStmt) }
|
|
280
|
+
|
|
281
|
+
var hasSource = false
|
|
282
|
+
while sqlite3_step(tableInfoStmt) == SQLITE_ROW {
|
|
283
|
+
if let name = sqlite3_column_text(tableInfoStmt, 1) {
|
|
284
|
+
if String(cString: name) == "source" {
|
|
285
|
+
hasSource = true
|
|
286
|
+
break
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if !hasSource {
|
|
292
|
+
exec("ALTER TABLE ocr_entry ADD COLUMN source TEXT DEFAULT 'ocr'")
|
|
293
|
+
DiagnosticLog.shared.info("OcrStore: migrated — added source column")
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
private func prepareCleanup() {
|
|
298
|
+
let sql = "DELETE FROM ocr_entry WHERE timestamp < ?1"
|
|
299
|
+
sqlite3_prepare_v2(db, sql, -1, &cleanupStmt, nil)
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
private func columnText(_ stmt: OpaquePointer, _ index: Int32) -> String {
|
|
303
|
+
if let cStr = sqlite3_column_text(stmt, index) {
|
|
304
|
+
return String(cString: cStr)
|
|
305
|
+
}
|
|
306
|
+
return ""
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
private func rowToSearchResult(_ stmt: OpaquePointer) -> OcrSearchResult {
|
|
310
|
+
let sourceStr = columnText(stmt, 11)
|
|
311
|
+
let source = TextSource(rawValue: sourceStr) ?? .ocr
|
|
312
|
+
return OcrSearchResult(
|
|
313
|
+
id: sqlite3_column_int64(stmt, 0),
|
|
314
|
+
wid: UInt32(sqlite3_column_int(stmt, 1)),
|
|
315
|
+
app: columnText(stmt, 2),
|
|
316
|
+
title: columnText(stmt, 3),
|
|
317
|
+
frame: WindowFrame(
|
|
318
|
+
x: sqlite3_column_double(stmt, 4),
|
|
319
|
+
y: sqlite3_column_double(stmt, 5),
|
|
320
|
+
w: sqlite3_column_double(stmt, 6),
|
|
321
|
+
h: sqlite3_column_double(stmt, 7)
|
|
322
|
+
),
|
|
323
|
+
fullText: columnText(stmt, 8),
|
|
324
|
+
snippet: columnText(stmt, 10),
|
|
325
|
+
timestamp: Date(timeIntervalSince1970: sqlite3_column_double(stmt, 9)),
|
|
326
|
+
source: source
|
|
327
|
+
)
|
|
328
|
+
}
|
|
329
|
+
}
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import AppKit
|
|
2
|
+
import Combine
|
|
3
|
+
import Foundation
|
|
4
|
+
|
|
5
|
+
// MARK: - Result Types
|
|
6
|
+
|
|
7
|
+
enum OmniResultKind: String {
|
|
8
|
+
case window
|
|
9
|
+
case project
|
|
10
|
+
case session
|
|
11
|
+
case process
|
|
12
|
+
case ocrContent
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
struct OmniResult: Identifiable {
|
|
16
|
+
let id = UUID()
|
|
17
|
+
let kind: OmniResultKind
|
|
18
|
+
let title: String
|
|
19
|
+
let subtitle: String
|
|
20
|
+
let icon: String
|
|
21
|
+
let score: Int // higher = better match
|
|
22
|
+
let action: () -> Void
|
|
23
|
+
|
|
24
|
+
/// Group label for display
|
|
25
|
+
var groupLabel: String {
|
|
26
|
+
switch kind {
|
|
27
|
+
case .window: return "Windows"
|
|
28
|
+
case .project: return "Projects"
|
|
29
|
+
case .session: return "Sessions"
|
|
30
|
+
case .process: return "Processes"
|
|
31
|
+
case .ocrContent: return "Screen Text"
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// MARK: - Activity Summary
|
|
37
|
+
|
|
38
|
+
struct ActivitySummary {
|
|
39
|
+
struct AppWindowCount: Identifiable {
|
|
40
|
+
let id: String
|
|
41
|
+
let appName: String
|
|
42
|
+
let count: Int
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
struct SessionInfo: Identifiable {
|
|
46
|
+
let id: String
|
|
47
|
+
let name: String
|
|
48
|
+
let paneCount: Int
|
|
49
|
+
let attached: Bool
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let windowsByApp: [AppWindowCount]
|
|
53
|
+
let totalWindows: Int
|
|
54
|
+
let sessions: [SessionInfo]
|
|
55
|
+
let interestingProcesses: [ProcessEntry]
|
|
56
|
+
let lastOcrScan: Date?
|
|
57
|
+
let ocrWindowCount: Int
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// MARK: - State
|
|
61
|
+
|
|
62
|
+
final class OmniSearchState: ObservableObject {
|
|
63
|
+
@Published var query: String = ""
|
|
64
|
+
@Published var results: [OmniResult] = []
|
|
65
|
+
@Published var selectedIndex: Int = 0
|
|
66
|
+
@Published var activitySummary: ActivitySummary?
|
|
67
|
+
|
|
68
|
+
private var cancellables = Set<AnyCancellable>()
|
|
69
|
+
private var debounceTimer: AnyCancellable?
|
|
70
|
+
|
|
71
|
+
init() {
|
|
72
|
+
// Debounce search by 150ms
|
|
73
|
+
debounceTimer = $query
|
|
74
|
+
.debounce(for: .milliseconds(150), scheduler: RunLoop.main)
|
|
75
|
+
.sink { [weak self] q in
|
|
76
|
+
if q.isEmpty {
|
|
77
|
+
self?.results = []
|
|
78
|
+
self?.refreshSummary()
|
|
79
|
+
} else {
|
|
80
|
+
self?.search(q)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
refreshSummary()
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// MARK: - Search
|
|
88
|
+
|
|
89
|
+
private func search(_ query: String) {
|
|
90
|
+
let q = query.lowercased()
|
|
91
|
+
var all: [OmniResult] = []
|
|
92
|
+
|
|
93
|
+
// Windows
|
|
94
|
+
let desktop = DesktopModel.shared
|
|
95
|
+
for win in desktop.allWindows() {
|
|
96
|
+
let score = scoreMatch(q, against: [win.app, win.title])
|
|
97
|
+
if score > 0 {
|
|
98
|
+
let wid = win.wid
|
|
99
|
+
let pid = win.pid
|
|
100
|
+
all.append(OmniResult(
|
|
101
|
+
kind: .window,
|
|
102
|
+
title: win.app,
|
|
103
|
+
subtitle: win.title.isEmpty ? "Window \(win.wid)" : win.title,
|
|
104
|
+
icon: "macwindow",
|
|
105
|
+
score: score
|
|
106
|
+
) {
|
|
107
|
+
WindowTiler.focusWindow(wid: wid, pid: pid)
|
|
108
|
+
})
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Projects
|
|
113
|
+
let scanner = ProjectScanner.shared
|
|
114
|
+
for project in scanner.projects {
|
|
115
|
+
let score = scoreMatch(q, against: [project.name, project.path])
|
|
116
|
+
if score > 0 {
|
|
117
|
+
let proj = project
|
|
118
|
+
all.append(OmniResult(
|
|
119
|
+
kind: .project,
|
|
120
|
+
title: project.name,
|
|
121
|
+
subtitle: project.path,
|
|
122
|
+
icon: "folder",
|
|
123
|
+
score: score
|
|
124
|
+
) {
|
|
125
|
+
SessionManager.launch(project: proj)
|
|
126
|
+
})
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Tmux Sessions
|
|
131
|
+
let tmux = TmuxModel.shared
|
|
132
|
+
for session in tmux.sessions {
|
|
133
|
+
let paneCommands = session.panes.map(\.currentCommand)
|
|
134
|
+
let score = scoreMatch(q, against: [session.name] + paneCommands)
|
|
135
|
+
if score > 0 {
|
|
136
|
+
let name = session.name
|
|
137
|
+
all.append(OmniResult(
|
|
138
|
+
kind: .session,
|
|
139
|
+
title: session.name,
|
|
140
|
+
subtitle: "\(session.windowCount) windows, \(session.panes.count) panes\(session.attached ? " (attached)" : "")",
|
|
141
|
+
icon: "terminal",
|
|
142
|
+
score: score
|
|
143
|
+
) {
|
|
144
|
+
let terminal = Preferences.shared.terminal
|
|
145
|
+
terminal.focusOrAttach(session: name)
|
|
146
|
+
})
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Processes
|
|
151
|
+
let processes = ProcessModel.shared
|
|
152
|
+
for proc in processes.interesting {
|
|
153
|
+
let score = scoreMatch(q, against: [proc.comm, proc.args, proc.cwd ?? ""])
|
|
154
|
+
if score > 0 {
|
|
155
|
+
all.append(OmniResult(
|
|
156
|
+
kind: .process,
|
|
157
|
+
title: proc.comm,
|
|
158
|
+
subtitle: proc.cwd ?? proc.args,
|
|
159
|
+
icon: "gearshape",
|
|
160
|
+
score: score
|
|
161
|
+
) {
|
|
162
|
+
// No direct action for processes — just informational
|
|
163
|
+
})
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// OCR content
|
|
168
|
+
let ocr = OcrModel.shared
|
|
169
|
+
for (_, result) in ocr.results {
|
|
170
|
+
let ocrScore = scoreOcr(q, fullText: result.fullText)
|
|
171
|
+
if ocrScore > 0 {
|
|
172
|
+
let wid = result.wid
|
|
173
|
+
let pid = desktop.windows[wid]?.pid ?? 0
|
|
174
|
+
// Find matching line for subtitle
|
|
175
|
+
let matchLine = result.texts
|
|
176
|
+
.first { $0.text.lowercased().contains(q) }?
|
|
177
|
+
.text ?? String(result.fullText.prefix(80))
|
|
178
|
+
all.append(OmniResult(
|
|
179
|
+
kind: .ocrContent,
|
|
180
|
+
title: "\(result.app) — \(result.title)",
|
|
181
|
+
subtitle: matchLine,
|
|
182
|
+
icon: "doc.text.magnifyingglass",
|
|
183
|
+
score: ocrScore
|
|
184
|
+
) {
|
|
185
|
+
WindowTiler.focusWindow(wid: wid, pid: pid)
|
|
186
|
+
})
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Sort by score descending
|
|
191
|
+
all.sort { $0.score > $1.score }
|
|
192
|
+
|
|
193
|
+
results = all
|
|
194
|
+
selectedIndex = 0
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// MARK: - Scoring
|
|
198
|
+
|
|
199
|
+
private func scoreMatch(_ query: String, against fields: [String]) -> Int {
|
|
200
|
+
var best = 0
|
|
201
|
+
for field in fields {
|
|
202
|
+
let lower = field.lowercased()
|
|
203
|
+
if lower == query {
|
|
204
|
+
best = max(best, 100) // exact
|
|
205
|
+
} else if lower.hasPrefix(query) {
|
|
206
|
+
best = max(best, 80) // prefix
|
|
207
|
+
} else if lower.contains(query) {
|
|
208
|
+
best = max(best, 60) // contains
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return best
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
private func scoreOcr(_ query: String, fullText: String) -> Int {
|
|
215
|
+
let lower = fullText.lowercased()
|
|
216
|
+
if lower.contains(query) { return 40 }
|
|
217
|
+
return 0
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// MARK: - Navigation
|
|
221
|
+
|
|
222
|
+
func moveSelection(_ delta: Int) {
|
|
223
|
+
guard !results.isEmpty else { return }
|
|
224
|
+
selectedIndex = max(0, min(results.count - 1, selectedIndex + delta))
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
func activateSelected() {
|
|
228
|
+
guard selectedIndex >= 0, selectedIndex < results.count else { return }
|
|
229
|
+
results[selectedIndex].action()
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// MARK: - Activity Summary
|
|
233
|
+
|
|
234
|
+
func refreshSummary() {
|
|
235
|
+
let desktop = DesktopModel.shared
|
|
236
|
+
let windows = desktop.allWindows()
|
|
237
|
+
|
|
238
|
+
// Group by app
|
|
239
|
+
var appCounts: [String: Int] = [:]
|
|
240
|
+
for win in windows {
|
|
241
|
+
appCounts[win.app, default: 0] += 1
|
|
242
|
+
}
|
|
243
|
+
let windowsByApp = appCounts
|
|
244
|
+
.sorted { $0.value > $1.value }
|
|
245
|
+
.map { ActivitySummary.AppWindowCount(id: $0.key, appName: $0.key, count: $0.value) }
|
|
246
|
+
|
|
247
|
+
// Sessions
|
|
248
|
+
let sessions = TmuxModel.shared.sessions.map {
|
|
249
|
+
ActivitySummary.SessionInfo(
|
|
250
|
+
id: $0.id,
|
|
251
|
+
name: $0.name,
|
|
252
|
+
paneCount: $0.panes.count,
|
|
253
|
+
attached: $0.attached
|
|
254
|
+
)
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Processes
|
|
258
|
+
let procs = ProcessModel.shared.interesting
|
|
259
|
+
|
|
260
|
+
// OCR info
|
|
261
|
+
let ocrResults = OcrModel.shared.results
|
|
262
|
+
let lastScan: Date? = ocrResults.values.map(\.timestamp).max()
|
|
263
|
+
|
|
264
|
+
activitySummary = ActivitySummary(
|
|
265
|
+
windowsByApp: windowsByApp,
|
|
266
|
+
totalWindows: windows.count,
|
|
267
|
+
sessions: sessions,
|
|
268
|
+
interestingProcesses: procs,
|
|
269
|
+
lastOcrScan: lastScan,
|
|
270
|
+
ocrWindowCount: ocrResults.count
|
|
271
|
+
)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/// Grouped results for display
|
|
275
|
+
var groupedResults: [(String, [OmniResult])] {
|
|
276
|
+
let groups = Dictionary(grouping: results) { $0.groupLabel }
|
|
277
|
+
let order: [String] = ["Windows", "Projects", "Sessions", "Processes", "Screen Text"]
|
|
278
|
+
return order.compactMap { key in
|
|
279
|
+
guard let items = groups[key], !items.isEmpty else { return nil }
|
|
280
|
+
return (key, items)
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|