@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,368 @@
|
|
|
1
|
+
import SwiftUI
|
|
2
|
+
|
|
3
|
+
struct ProjectRow: View {
|
|
4
|
+
let project: Project
|
|
5
|
+
let onLaunch: () -> Void
|
|
6
|
+
let onDetach: () -> Void
|
|
7
|
+
let onKill: () -> Void
|
|
8
|
+
let onSync: () -> Void
|
|
9
|
+
let onRestart: (String?) -> Void
|
|
10
|
+
|
|
11
|
+
@State private var isHovered = false
|
|
12
|
+
@State private var showCoach = false
|
|
13
|
+
@State private var showTilePicker = false
|
|
14
|
+
@State private var contextSpaces: [SpaceInfo] = []
|
|
15
|
+
@State private var windowInfo: WindowTiler.WindowInfo?
|
|
16
|
+
|
|
17
|
+
var body: some View {
|
|
18
|
+
VStack(spacing: 0) {
|
|
19
|
+
HStack(spacing: 10) {
|
|
20
|
+
// Status bar
|
|
21
|
+
RoundedRectangle(cornerRadius: 1)
|
|
22
|
+
.fill(project.isRunning ? Palette.running : Palette.border)
|
|
23
|
+
.frame(width: 3, height: 32)
|
|
24
|
+
|
|
25
|
+
// Info — tap to highlight window
|
|
26
|
+
VStack(alignment: .leading, spacing: 3) {
|
|
27
|
+
Text(project.name)
|
|
28
|
+
.font(Typo.heading(13))
|
|
29
|
+
.foregroundColor(Palette.text)
|
|
30
|
+
.lineLimit(1)
|
|
31
|
+
|
|
32
|
+
HStack(spacing: 6) {
|
|
33
|
+
if !project.paneSummary.isEmpty {
|
|
34
|
+
Text(project.paneSummary)
|
|
35
|
+
.font(Typo.mono(10))
|
|
36
|
+
.foregroundColor(Palette.textMuted)
|
|
37
|
+
.lineLimit(1)
|
|
38
|
+
} else if let cmd = project.devCommand {
|
|
39
|
+
Text(cmd)
|
|
40
|
+
.font(Typo.mono(10))
|
|
41
|
+
.foregroundColor(Palette.textMuted)
|
|
42
|
+
.lineLimit(1)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if project.isRunning, let info = windowInfo {
|
|
46
|
+
Spacer(minLength: 4)
|
|
47
|
+
locationBadge(info)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
.contentShape(Rectangle())
|
|
52
|
+
.onTapGesture {
|
|
53
|
+
if project.isRunning {
|
|
54
|
+
WindowTiler.highlightWindow(session: project.sessionName)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
Spacer()
|
|
59
|
+
|
|
60
|
+
// Actions
|
|
61
|
+
HStack(spacing: 4) {
|
|
62
|
+
if project.isRunning {
|
|
63
|
+
Button(action: {
|
|
64
|
+
withAnimation(.easeOut(duration: 0.15)) { showTilePicker.toggle() }
|
|
65
|
+
if !showTilePicker {
|
|
66
|
+
// Picker just opened — highlight the window
|
|
67
|
+
WindowTiler.highlightWindow(session: project.sessionName)
|
|
68
|
+
} else {
|
|
69
|
+
WindowHighlight.shared.dismiss()
|
|
70
|
+
}
|
|
71
|
+
}) {
|
|
72
|
+
Image(systemName: "rectangle.split.2x1")
|
|
73
|
+
.font(.system(size: 10))
|
|
74
|
+
.angularButton(Palette.textDim, filled: false)
|
|
75
|
+
}
|
|
76
|
+
.buttonStyle(.plain)
|
|
77
|
+
|
|
78
|
+
Button(action: { handleDetach() }) {
|
|
79
|
+
Text("Detach")
|
|
80
|
+
.angularButton(Palette.detach, filled: false)
|
|
81
|
+
}
|
|
82
|
+
.buttonStyle(.plain)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
Button(action: onLaunch) {
|
|
86
|
+
Text(project.isRunning ? "Attach" : "Launch")
|
|
87
|
+
.angularButton(project.isRunning ? Palette.running : Palette.launch)
|
|
88
|
+
}
|
|
89
|
+
.buttonStyle(.plain)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
.padding(.horizontal, 10)
|
|
93
|
+
.padding(.vertical, 8)
|
|
94
|
+
.glassCard(hovered: isHovered)
|
|
95
|
+
|
|
96
|
+
// Coach card
|
|
97
|
+
if showCoach {
|
|
98
|
+
CoachView {
|
|
99
|
+
withAnimation(.easeOut(duration: 0.15)) { showCoach = false }
|
|
100
|
+
}
|
|
101
|
+
.transition(.opacity.combined(with: .move(edge: .top)))
|
|
102
|
+
.padding(.top, 4)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Tile picker
|
|
106
|
+
if showTilePicker {
|
|
107
|
+
TilePickerView(
|
|
108
|
+
sessionName: project.sessionName,
|
|
109
|
+
terminal: Preferences.shared.terminal,
|
|
110
|
+
onSelect: { position in
|
|
111
|
+
WindowHighlight.shared.dismiss()
|
|
112
|
+
WindowTiler.tile(
|
|
113
|
+
session: project.sessionName,
|
|
114
|
+
terminal: Preferences.shared.terminal,
|
|
115
|
+
to: position
|
|
116
|
+
)
|
|
117
|
+
withAnimation(.easeOut(duration: 0.15)) { showTilePicker = false }
|
|
118
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { refreshWindowInfo() }
|
|
119
|
+
},
|
|
120
|
+
onGoToSpace: { spaceId in
|
|
121
|
+
WindowHighlight.shared.dismiss()
|
|
122
|
+
let result = WindowTiler.moveWindowToSpace(
|
|
123
|
+
session: project.sessionName,
|
|
124
|
+
terminal: Preferences.shared.terminal,
|
|
125
|
+
spaceId: spaceId
|
|
126
|
+
)
|
|
127
|
+
if case .success = result {
|
|
128
|
+
WindowTiler.switchToSpace(spaceId: spaceId)
|
|
129
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
|
|
130
|
+
WindowTiler.highlightWindow(session: project.sessionName)
|
|
131
|
+
}
|
|
132
|
+
} else if case .alreadyOnSpace = result {
|
|
133
|
+
WindowTiler.switchToSpace(spaceId: spaceId)
|
|
134
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
|
|
135
|
+
WindowTiler.highlightWindow(session: project.sessionName)
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
withAnimation(.easeOut(duration: 0.15)) { showTilePicker = false }
|
|
139
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { refreshWindowInfo() }
|
|
140
|
+
},
|
|
141
|
+
onDismiss: {
|
|
142
|
+
WindowHighlight.shared.dismiss()
|
|
143
|
+
withAnimation(.easeOut(duration: 0.15)) { showTilePicker = false }
|
|
144
|
+
}
|
|
145
|
+
)
|
|
146
|
+
.transition(.opacity.combined(with: .move(edge: .top)))
|
|
147
|
+
.padding(.top, 4)
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
.contentShape(Rectangle())
|
|
151
|
+
.onHover { isHovered = $0 }
|
|
152
|
+
.onAppear {
|
|
153
|
+
if project.isRunning {
|
|
154
|
+
contextSpaces = WindowTiler.getDisplaySpaces().flatMap(\.spaces)
|
|
155
|
+
refreshWindowInfo()
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
.contextMenu {
|
|
159
|
+
if project.isRunning {
|
|
160
|
+
Button("Attach") { onLaunch() }
|
|
161
|
+
Button {
|
|
162
|
+
WindowTiler.navigateToWindow(
|
|
163
|
+
session: project.sessionName,
|
|
164
|
+
terminal: Preferences.shared.terminal
|
|
165
|
+
)
|
|
166
|
+
} label: {
|
|
167
|
+
Label("Go to Window", systemImage: "macwindow")
|
|
168
|
+
}
|
|
169
|
+
Button("Detach") { onDetach() }
|
|
170
|
+
Menu("Tile Window") {
|
|
171
|
+
ForEach(TilePosition.allCases) { tile in
|
|
172
|
+
Button {
|
|
173
|
+
WindowTiler.tile(
|
|
174
|
+
session: project.sessionName,
|
|
175
|
+
terminal: Preferences.shared.terminal,
|
|
176
|
+
to: tile
|
|
177
|
+
)
|
|
178
|
+
} label: {
|
|
179
|
+
Label(tile.label, systemImage: tile.icon)
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
if !contextSpaces.isEmpty {
|
|
184
|
+
Menu("Go to Space") {
|
|
185
|
+
ForEach(contextSpaces) { space in
|
|
186
|
+
Button {
|
|
187
|
+
let result = WindowTiler.moveWindowToSpace(
|
|
188
|
+
session: project.sessionName,
|
|
189
|
+
terminal: Preferences.shared.terminal,
|
|
190
|
+
spaceId: space.id
|
|
191
|
+
)
|
|
192
|
+
if case .success = result {
|
|
193
|
+
WindowTiler.switchToSpace(spaceId: space.id)
|
|
194
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
|
|
195
|
+
WindowTiler.highlightWindow(session: project.sessionName)
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
} label: {
|
|
199
|
+
Label(
|
|
200
|
+
"Space \(space.index)\(space.isCurrent ? " (current)" : "")",
|
|
201
|
+
systemImage: space.isCurrent ? "desktopcomputer" : "rectangle.on.rectangle"
|
|
202
|
+
)
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
Divider()
|
|
208
|
+
Button("Sync Session") { onSync() }
|
|
209
|
+
Menu("Restart Pane") {
|
|
210
|
+
ForEach(project.paneNames, id: \.self) { name in
|
|
211
|
+
Button(name) { onRestart(name) }
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
Divider()
|
|
215
|
+
Button("Kill Session") { onKill() }
|
|
216
|
+
} else {
|
|
217
|
+
Button("Launch") { onLaunch() }
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// MARK: - Location Badge
|
|
223
|
+
|
|
224
|
+
@ViewBuilder
|
|
225
|
+
private func locationBadge(_ info: WindowTiler.WindowInfo) -> some View {
|
|
226
|
+
HStack(spacing: 3) {
|
|
227
|
+
// Multi-display prefix
|
|
228
|
+
if NSScreen.screens.count > 1 && info.displayIndex > 0 {
|
|
229
|
+
Text("D\(info.displayIndex + 1)")
|
|
230
|
+
.font(Typo.mono(9))
|
|
231
|
+
.foregroundColor(Palette.textMuted)
|
|
232
|
+
Text("\u{00B7}")
|
|
233
|
+
.font(Typo.mono(9))
|
|
234
|
+
.foregroundColor(Palette.textMuted)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Space number
|
|
238
|
+
Text(circledDigit(info.spaceIndex))
|
|
239
|
+
.font(.system(size: 10))
|
|
240
|
+
.foregroundColor(Palette.textDim)
|
|
241
|
+
|
|
242
|
+
// Tile position icon
|
|
243
|
+
if let tile = info.tilePosition {
|
|
244
|
+
Image(systemName: tile.icon)
|
|
245
|
+
.font(.system(size: 9))
|
|
246
|
+
.foregroundColor(Palette.textMuted)
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
.padding(.horizontal, 5)
|
|
250
|
+
.padding(.vertical, 2)
|
|
251
|
+
.background(
|
|
252
|
+
RoundedRectangle(cornerRadius: 4)
|
|
253
|
+
.fill(Palette.surface.opacity(0.6))
|
|
254
|
+
)
|
|
255
|
+
.contentShape(Rectangle())
|
|
256
|
+
.onTapGesture {
|
|
257
|
+
WindowTiler.navigateToWindow(
|
|
258
|
+
session: project.sessionName,
|
|
259
|
+
terminal: Preferences.shared.terminal
|
|
260
|
+
)
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
private func circledDigit(_ n: Int) -> String {
|
|
265
|
+
let digits = ["\u{2776}","\u{2777}","\u{2778}","\u{2779}","\u{277A}","\u{277B}","\u{277C}","\u{277D}","\u{277E}"]
|
|
266
|
+
return n >= 1 && n <= 9 ? digits[n - 1] : "S\(n)"
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
private func refreshWindowInfo() {
|
|
270
|
+
guard project.isRunning else { windowInfo = nil; return }
|
|
271
|
+
DispatchQueue.global(qos: .userInitiated).async {
|
|
272
|
+
let info = WindowTiler.getWindowInfo(
|
|
273
|
+
session: project.sessionName,
|
|
274
|
+
terminal: Preferences.shared.terminal
|
|
275
|
+
)
|
|
276
|
+
DispatchQueue.main.async { windowInfo = info }
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
private func handleDetach() {
|
|
281
|
+
if Preferences.shared.mode == .learning {
|
|
282
|
+
withAnimation(.easeOut(duration: 0.15)) { showCoach.toggle() }
|
|
283
|
+
} else {
|
|
284
|
+
onDetach()
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// MARK: - Coach view
|
|
290
|
+
|
|
291
|
+
struct CoachView: View {
|
|
292
|
+
let onDismiss: () -> Void
|
|
293
|
+
|
|
294
|
+
var body: some View {
|
|
295
|
+
VStack(alignment: .leading, spacing: 10) {
|
|
296
|
+
HStack {
|
|
297
|
+
Text("TMUX SHORTCUTS")
|
|
298
|
+
.font(Typo.pixel(12))
|
|
299
|
+
.foregroundColor(Palette.running)
|
|
300
|
+
Spacer()
|
|
301
|
+
Button(action: onDismiss) {
|
|
302
|
+
Image(systemName: "xmark")
|
|
303
|
+
.font(.system(size: 8, weight: .bold))
|
|
304
|
+
.foregroundColor(Palette.textDim)
|
|
305
|
+
.frame(width: 18, height: 18)
|
|
306
|
+
.background(
|
|
307
|
+
RoundedRectangle(cornerRadius: 3)
|
|
308
|
+
.fill(Palette.surface)
|
|
309
|
+
)
|
|
310
|
+
}
|
|
311
|
+
.buttonStyle(.plain)
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
VStack(spacing: 6) {
|
|
315
|
+
KeyCombo(keys: ["Ctrl+B", "D"], label: "Detach", color: Palette.detach)
|
|
316
|
+
KeyCombo(keys: ["Ctrl+B", "X"], label: "Kill pane", color: Palette.kill)
|
|
317
|
+
KeyCombo(keys: ["Ctrl+B", "\u{2190}\u{2192}"], label: "Switch pane", color: Palette.text)
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
Text("Session stays alive after detaching")
|
|
321
|
+
.font(Typo.caption(10))
|
|
322
|
+
.foregroundColor(Palette.textMuted)
|
|
323
|
+
}
|
|
324
|
+
.padding(12)
|
|
325
|
+
.background(
|
|
326
|
+
RoundedRectangle(cornerRadius: 6)
|
|
327
|
+
.fill(Palette.surface)
|
|
328
|
+
.overlay(
|
|
329
|
+
RoundedRectangle(cornerRadius: 6)
|
|
330
|
+
.strokeBorder(Palette.borderLit, lineWidth: 0.5)
|
|
331
|
+
)
|
|
332
|
+
)
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
struct KeyCombo: View {
|
|
337
|
+
let keys: [String]
|
|
338
|
+
let label: String
|
|
339
|
+
var color: Color = .secondary
|
|
340
|
+
|
|
341
|
+
var body: some View {
|
|
342
|
+
HStack(spacing: 6) {
|
|
343
|
+
HStack(spacing: 3) {
|
|
344
|
+
ForEach(keys, id: \.self) { key in
|
|
345
|
+
Text(key)
|
|
346
|
+
.font(Typo.geistMonoBold(10))
|
|
347
|
+
.foregroundColor(Palette.text)
|
|
348
|
+
.padding(.horizontal, 6)
|
|
349
|
+
.padding(.vertical, 3)
|
|
350
|
+
.background(
|
|
351
|
+
RoundedRectangle(cornerRadius: 3)
|
|
352
|
+
.fill(Palette.bg)
|
|
353
|
+
.overlay(
|
|
354
|
+
RoundedRectangle(cornerRadius: 3)
|
|
355
|
+
.strokeBorder(Palette.border, lineWidth: 0.5)
|
|
356
|
+
)
|
|
357
|
+
)
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
Text(label)
|
|
362
|
+
.font(Typo.caption(11))
|
|
363
|
+
.foregroundColor(color)
|
|
364
|
+
|
|
365
|
+
Spacer()
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
class ProjectScanner: ObservableObject {
|
|
4
|
+
static let shared = ProjectScanner()
|
|
5
|
+
|
|
6
|
+
@Published var projects: [Project] = []
|
|
7
|
+
|
|
8
|
+
private var scanRoot: String
|
|
9
|
+
|
|
10
|
+
init(root: String? = nil) {
|
|
11
|
+
self.scanRoot = root ?? Preferences.shared.scanRoot
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
func updateRoot(_ root: String) {
|
|
15
|
+
self.scanRoot = root
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
func scan() {
|
|
19
|
+
let diag = DiagnosticLog.shared
|
|
20
|
+
|
|
21
|
+
// Use find to locate all .lattices.json files — no manual directory walking
|
|
22
|
+
let tFind = diag.startTimed("ProjectScanner: find .lattices.json")
|
|
23
|
+
let task = Process()
|
|
24
|
+
task.executableURL = URL(fileURLWithPath: "/usr/bin/find")
|
|
25
|
+
task.arguments = [scanRoot, "-name", ".lattices.json", "-maxdepth", "3", "-not", "-path", "*/.git/*", "-not", "-path", "*/node_modules/*"]
|
|
26
|
+
let pipe = Pipe()
|
|
27
|
+
task.standardOutput = pipe
|
|
28
|
+
task.standardError = FileHandle.nullDevice
|
|
29
|
+
try? task.run()
|
|
30
|
+
task.waitUntilExit()
|
|
31
|
+
diag.finish(tFind)
|
|
32
|
+
|
|
33
|
+
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
|
34
|
+
let output = String(data: data, encoding: .utf8) ?? ""
|
|
35
|
+
let configPaths = output.split(separator: "\n").map(String.init).filter { !$0.isEmpty }
|
|
36
|
+
|
|
37
|
+
let tParse = diag.startTimed("ProjectScanner: parse \(configPaths.count) configs")
|
|
38
|
+
var found: [Project] = []
|
|
39
|
+
|
|
40
|
+
for configPath in configPaths.sorted() {
|
|
41
|
+
let projectPath = (configPath as NSString).deletingLastPathComponent
|
|
42
|
+
let name = (projectPath as NSString).lastPathComponent
|
|
43
|
+
let (devCmd, pm) = detectDevCommand(at: projectPath)
|
|
44
|
+
let paneInfo = readPaneInfo(at: configPath)
|
|
45
|
+
|
|
46
|
+
var project = Project(
|
|
47
|
+
id: projectPath,
|
|
48
|
+
path: projectPath,
|
|
49
|
+
name: name,
|
|
50
|
+
devCommand: devCmd,
|
|
51
|
+
packageManager: pm,
|
|
52
|
+
hasConfig: true,
|
|
53
|
+
paneCount: paneInfo.count,
|
|
54
|
+
paneNames: paneInfo.names,
|
|
55
|
+
paneSummary: paneInfo.summary,
|
|
56
|
+
isRunning: false
|
|
57
|
+
)
|
|
58
|
+
project.isRunning = isSessionRunning(project.sessionName)
|
|
59
|
+
found.append(project)
|
|
60
|
+
}
|
|
61
|
+
diag.finish(tParse)
|
|
62
|
+
|
|
63
|
+
diag.info("ProjectScanner: found \(found.count) projects (\(found.filter(\.isRunning).count) running)")
|
|
64
|
+
DispatchQueue.main.async { self.projects = found }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
func refreshStatus() {
|
|
68
|
+
for i in projects.indices {
|
|
69
|
+
projects[i].isRunning = isSessionRunning(projects[i].sessionName)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// MARK: - Detection
|
|
74
|
+
|
|
75
|
+
private func detectDevCommand(at path: String) -> (String?, String?) {
|
|
76
|
+
let pkgPath = (path as NSString).appendingPathComponent("package.json")
|
|
77
|
+
guard let data = FileManager.default.contents(atPath: pkgPath),
|
|
78
|
+
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
79
|
+
let scripts = json["scripts"] as? [String: String]
|
|
80
|
+
else { return (nil, nil) }
|
|
81
|
+
|
|
82
|
+
let has = { (f: String) in
|
|
83
|
+
FileManager.default.fileExists(atPath: (path as NSString).appendingPathComponent(f))
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
var pm = "npm"
|
|
87
|
+
if has("pnpm-lock.yaml") { pm = "pnpm" }
|
|
88
|
+
else if has("bun.lockb") || has("bun.lock") { pm = "bun" }
|
|
89
|
+
else if has("yarn.lock") { pm = "yarn" }
|
|
90
|
+
|
|
91
|
+
let run = pm == "npm" ? "npm run" : pm
|
|
92
|
+
if scripts["dev"] != nil { return ("\(run) dev", pm) }
|
|
93
|
+
if scripts["start"] != nil { return ("\(run) start", pm) }
|
|
94
|
+
if scripts["serve"] != nil { return ("\(run) serve", pm) }
|
|
95
|
+
if scripts["watch"] != nil { return ("\(run) watch", pm) }
|
|
96
|
+
return (nil, pm)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
private func readPaneInfo(at configPath: String) -> (count: Int, names: [String], summary: String) {
|
|
100
|
+
guard let data = FileManager.default.contents(atPath: configPath),
|
|
101
|
+
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
102
|
+
let panes = json["panes"] as? [[String: Any]]
|
|
103
|
+
else { return (2, ["claude", "server"], "") }
|
|
104
|
+
|
|
105
|
+
let labels = panes.compactMap { pane -> String? in
|
|
106
|
+
if let name = pane["name"] as? String { return name }
|
|
107
|
+
if let cmd = pane["cmd"] as? String {
|
|
108
|
+
let parts = cmd.split(separator: " ")
|
|
109
|
+
return parts.first.map(String.init)
|
|
110
|
+
}
|
|
111
|
+
return nil
|
|
112
|
+
}
|
|
113
|
+
return (panes.count, labels, labels.joined(separator: " · "))
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
private static let tmuxPath = "/opt/homebrew/bin/tmux"
|
|
117
|
+
|
|
118
|
+
private func isSessionRunning(_ name: String) -> Bool {
|
|
119
|
+
let task = Process()
|
|
120
|
+
task.executableURL = URL(fileURLWithPath: Self.tmuxPath)
|
|
121
|
+
task.arguments = ["has-session", "-t", name]
|
|
122
|
+
task.standardOutput = FileHandle.nullDevice
|
|
123
|
+
task.standardError = FileHandle.nullDevice
|
|
124
|
+
try? task.run()
|
|
125
|
+
task.waitUntilExit()
|
|
126
|
+
return task.terminationStatus == 0
|
|
127
|
+
}
|
|
128
|
+
}
|