@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,288 @@
|
|
|
1
|
+
import SwiftUI
|
|
2
|
+
|
|
3
|
+
struct OmniSearchView: View {
|
|
4
|
+
@ObservedObject var state: OmniSearchState
|
|
5
|
+
var onDismiss: () -> Void
|
|
6
|
+
|
|
7
|
+
@FocusState private var searchFocused: Bool
|
|
8
|
+
|
|
9
|
+
var body: some View {
|
|
10
|
+
VStack(spacing: 0) {
|
|
11
|
+
// Search field
|
|
12
|
+
HStack(spacing: 8) {
|
|
13
|
+
Image(systemName: "magnifyingglass")
|
|
14
|
+
.foregroundColor(Palette.textMuted)
|
|
15
|
+
.font(.system(size: 13))
|
|
16
|
+
|
|
17
|
+
TextField("Search windows, projects, sessions...", text: $state.query)
|
|
18
|
+
.textFieldStyle(.plain)
|
|
19
|
+
.font(Typo.mono(14))
|
|
20
|
+
.foregroundColor(Palette.text)
|
|
21
|
+
.focused($searchFocused)
|
|
22
|
+
|
|
23
|
+
if !state.query.isEmpty {
|
|
24
|
+
Button { state.query = "" } label: {
|
|
25
|
+
Image(systemName: "xmark.circle.fill")
|
|
26
|
+
.foregroundColor(Palette.textMuted)
|
|
27
|
+
.font(.system(size: 12))
|
|
28
|
+
}
|
|
29
|
+
.buttonStyle(.plain)
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
.padding(.horizontal, 14)
|
|
33
|
+
.padding(.vertical, 12)
|
|
34
|
+
.background(Palette.surface)
|
|
35
|
+
|
|
36
|
+
Rectangle()
|
|
37
|
+
.fill(Palette.border)
|
|
38
|
+
.frame(height: 0.5)
|
|
39
|
+
|
|
40
|
+
// Content
|
|
41
|
+
if state.query.isEmpty {
|
|
42
|
+
summaryView
|
|
43
|
+
} else if state.results.isEmpty {
|
|
44
|
+
emptyResults
|
|
45
|
+
} else {
|
|
46
|
+
resultsView
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
.frame(minWidth: 520, idealWidth: 520, maxWidth: 700, minHeight: 360, idealHeight: 480, maxHeight: 600)
|
|
50
|
+
.background(PanelBackground())
|
|
51
|
+
.preferredColorScheme(.dark)
|
|
52
|
+
.onAppear {
|
|
53
|
+
searchFocused = true
|
|
54
|
+
state.refreshSummary()
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// MARK: - Results
|
|
59
|
+
|
|
60
|
+
private var resultsView: some View {
|
|
61
|
+
ScrollViewReader { proxy in
|
|
62
|
+
ScrollView {
|
|
63
|
+
LazyVStack(alignment: .leading, spacing: 2) {
|
|
64
|
+
var flatIndex = 0
|
|
65
|
+
ForEach(state.groupedResults, id: \.0) { group, items in
|
|
66
|
+
// Group header
|
|
67
|
+
Text(group.uppercased())
|
|
68
|
+
.font(Typo.caption(9))
|
|
69
|
+
.foregroundColor(Palette.textMuted)
|
|
70
|
+
.padding(.horizontal, 14)
|
|
71
|
+
.padding(.top, 8)
|
|
72
|
+
.padding(.bottom, 2)
|
|
73
|
+
|
|
74
|
+
ForEach(items) { item in
|
|
75
|
+
let idx = flatIndex
|
|
76
|
+
let _ = { flatIndex += 1 }()
|
|
77
|
+
resultRow(item, index: idx)
|
|
78
|
+
.id(item.id)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
.padding(.vertical, 4)
|
|
83
|
+
}
|
|
84
|
+
.onChange(of: state.selectedIndex) { newVal in
|
|
85
|
+
if newVal < state.results.count {
|
|
86
|
+
let item = state.results[newVal]
|
|
87
|
+
withAnimation(.easeOut(duration: 0.1)) {
|
|
88
|
+
proxy.scrollTo(item.id, anchor: .center)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
private func resultRow(_ item: OmniResult, index: Int) -> some View {
|
|
96
|
+
let isSelected = index == state.selectedIndex
|
|
97
|
+
return Button {
|
|
98
|
+
item.action()
|
|
99
|
+
onDismiss()
|
|
100
|
+
} label: {
|
|
101
|
+
HStack(spacing: 10) {
|
|
102
|
+
Image(systemName: item.icon)
|
|
103
|
+
.font(.system(size: 11, weight: .medium))
|
|
104
|
+
.foregroundColor(isSelected ? Palette.text : Palette.textDim)
|
|
105
|
+
.frame(width: 16)
|
|
106
|
+
|
|
107
|
+
VStack(alignment: .leading, spacing: 1) {
|
|
108
|
+
Text(item.title)
|
|
109
|
+
.font(Typo.mono(12))
|
|
110
|
+
.foregroundColor(isSelected ? Palette.text : Palette.textDim)
|
|
111
|
+
.lineLimit(1)
|
|
112
|
+
|
|
113
|
+
Text(item.subtitle)
|
|
114
|
+
.font(Typo.mono(10))
|
|
115
|
+
.foregroundColor(Palette.textMuted)
|
|
116
|
+
.lineLimit(1)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
Spacer()
|
|
120
|
+
|
|
121
|
+
Text(item.kind.rawValue)
|
|
122
|
+
.font(Typo.mono(9))
|
|
123
|
+
.foregroundColor(Palette.textMuted)
|
|
124
|
+
.padding(.horizontal, 5)
|
|
125
|
+
.padding(.vertical, 2)
|
|
126
|
+
.background(
|
|
127
|
+
RoundedRectangle(cornerRadius: 3)
|
|
128
|
+
.fill(Palette.surface)
|
|
129
|
+
)
|
|
130
|
+
}
|
|
131
|
+
.padding(.horizontal, 14)
|
|
132
|
+
.padding(.vertical, 6)
|
|
133
|
+
.background(
|
|
134
|
+
RoundedRectangle(cornerRadius: 5)
|
|
135
|
+
.fill(isSelected ? Palette.surfaceHov : Color.clear)
|
|
136
|
+
)
|
|
137
|
+
.contentShape(Rectangle())
|
|
138
|
+
}
|
|
139
|
+
.buttonStyle(.plain)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// MARK: - Empty Results
|
|
143
|
+
|
|
144
|
+
private var emptyResults: some View {
|
|
145
|
+
VStack(spacing: 12) {
|
|
146
|
+
Spacer()
|
|
147
|
+
Image(systemName: "magnifyingglass")
|
|
148
|
+
.font(.system(size: 24, weight: .light))
|
|
149
|
+
.foregroundColor(Palette.textMuted)
|
|
150
|
+
Text("No results for \"\(state.query)\"")
|
|
151
|
+
.font(Typo.mono(12))
|
|
152
|
+
.foregroundColor(Palette.textDim)
|
|
153
|
+
Spacer()
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// MARK: - Activity Summary
|
|
158
|
+
|
|
159
|
+
private var summaryView: some View {
|
|
160
|
+
ScrollView {
|
|
161
|
+
VStack(alignment: .leading, spacing: 14) {
|
|
162
|
+
if let summary = state.activitySummary {
|
|
163
|
+
// Windows by app
|
|
164
|
+
summarySection("WINDOWS", icon: "macwindow", count: summary.totalWindows) {
|
|
165
|
+
ForEach(summary.windowsByApp) { app in
|
|
166
|
+
HStack {
|
|
167
|
+
Text(app.appName)
|
|
168
|
+
.font(Typo.mono(11))
|
|
169
|
+
.foregroundColor(Palette.textDim)
|
|
170
|
+
.lineLimit(1)
|
|
171
|
+
Spacer()
|
|
172
|
+
Text("\(app.count)")
|
|
173
|
+
.font(Typo.monoBold(11))
|
|
174
|
+
.foregroundColor(Palette.text)
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Sessions
|
|
180
|
+
if !summary.sessions.isEmpty {
|
|
181
|
+
summarySection("TMUX SESSIONS", icon: "terminal", count: summary.sessions.count) {
|
|
182
|
+
ForEach(summary.sessions) { session in
|
|
183
|
+
HStack {
|
|
184
|
+
Circle()
|
|
185
|
+
.fill(session.attached ? Palette.running : Palette.textMuted)
|
|
186
|
+
.frame(width: 6, height: 6)
|
|
187
|
+
Text(session.name)
|
|
188
|
+
.font(Typo.mono(11))
|
|
189
|
+
.foregroundColor(Palette.textDim)
|
|
190
|
+
Spacer()
|
|
191
|
+
Text("\(session.paneCount) panes")
|
|
192
|
+
.font(Typo.mono(10))
|
|
193
|
+
.foregroundColor(Palette.textMuted)
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Processes
|
|
200
|
+
if !summary.interestingProcesses.isEmpty {
|
|
201
|
+
summarySection("PROCESSES", icon: "gearshape", count: summary.interestingProcesses.count) {
|
|
202
|
+
ForEach(Array(summary.interestingProcesses.prefix(10).enumerated()), id: \.offset) { _, proc in
|
|
203
|
+
HStack {
|
|
204
|
+
Text(proc.comm)
|
|
205
|
+
.font(Typo.monoBold(11))
|
|
206
|
+
.foregroundColor(Palette.textDim)
|
|
207
|
+
if let cwd = proc.cwd {
|
|
208
|
+
Text(cwd.replacingOccurrences(of: NSHomeDirectory(), with: "~"))
|
|
209
|
+
.font(Typo.mono(10))
|
|
210
|
+
.foregroundColor(Palette.textMuted)
|
|
211
|
+
.lineLimit(1)
|
|
212
|
+
}
|
|
213
|
+
Spacer()
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// OCR info
|
|
220
|
+
if summary.ocrWindowCount > 0 {
|
|
221
|
+
HStack(spacing: 6) {
|
|
222
|
+
Image(systemName: "doc.text.magnifyingglass")
|
|
223
|
+
.font(.system(size: 10))
|
|
224
|
+
.foregroundColor(Palette.textMuted)
|
|
225
|
+
Text("OCR: \(summary.ocrWindowCount) windows scanned")
|
|
226
|
+
.font(Typo.mono(10))
|
|
227
|
+
.foregroundColor(Palette.textMuted)
|
|
228
|
+
if let t = summary.lastOcrScan {
|
|
229
|
+
Spacer()
|
|
230
|
+
Text(relativeTime(t))
|
|
231
|
+
.font(Typo.mono(9))
|
|
232
|
+
.foregroundColor(Palette.textMuted)
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
.padding(.horizontal, 14)
|
|
236
|
+
}
|
|
237
|
+
} else {
|
|
238
|
+
Text("Loading...")
|
|
239
|
+
.font(Typo.mono(11))
|
|
240
|
+
.foregroundColor(Palette.textMuted)
|
|
241
|
+
.padding(14)
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
.padding(.vertical, 10)
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
private func summarySection<Content: View>(
|
|
249
|
+
_ title: String,
|
|
250
|
+
icon: String,
|
|
251
|
+
count: Int,
|
|
252
|
+
@ViewBuilder content: () -> Content
|
|
253
|
+
) -> some View {
|
|
254
|
+
VStack(alignment: .leading, spacing: 6) {
|
|
255
|
+
HStack(spacing: 6) {
|
|
256
|
+
Image(systemName: icon)
|
|
257
|
+
.font(.system(size: 10, weight: .medium))
|
|
258
|
+
.foregroundColor(Palette.textMuted)
|
|
259
|
+
Text(title)
|
|
260
|
+
.font(Typo.caption(9))
|
|
261
|
+
.foregroundColor(Palette.textMuted)
|
|
262
|
+
Text("\(count)")
|
|
263
|
+
.font(Typo.monoBold(9))
|
|
264
|
+
.foregroundColor(Palette.running)
|
|
265
|
+
.padding(.horizontal, 4)
|
|
266
|
+
.padding(.vertical, 1)
|
|
267
|
+
.background(
|
|
268
|
+
RoundedRectangle(cornerRadius: 3)
|
|
269
|
+
.fill(Palette.running.opacity(0.12))
|
|
270
|
+
)
|
|
271
|
+
Spacer()
|
|
272
|
+
}
|
|
273
|
+
.padding(.horizontal, 14)
|
|
274
|
+
|
|
275
|
+
VStack(spacing: 3) {
|
|
276
|
+
content()
|
|
277
|
+
}
|
|
278
|
+
.padding(.horizontal, 14)
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
private func relativeTime(_ date: Date) -> String {
|
|
283
|
+
let seconds = Int(Date().timeIntervalSince(date))
|
|
284
|
+
if seconds < 60 { return "\(seconds)s ago" }
|
|
285
|
+
if seconds < 3600 { return "\(seconds / 60)m ago" }
|
|
286
|
+
return "\(seconds / 3600)h ago"
|
|
287
|
+
}
|
|
288
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import AppKit
|
|
2
|
+
import SwiftUI
|
|
3
|
+
|
|
4
|
+
final class OmniSearchWindow {
|
|
5
|
+
static let shared = OmniSearchWindow()
|
|
6
|
+
|
|
7
|
+
private var panel: NSPanel?
|
|
8
|
+
private var keyMonitor: Any?
|
|
9
|
+
private var state: OmniSearchState?
|
|
10
|
+
|
|
11
|
+
var isVisible: Bool { panel?.isVisible ?? false }
|
|
12
|
+
|
|
13
|
+
func toggle() {
|
|
14
|
+
if isVisible {
|
|
15
|
+
dismiss()
|
|
16
|
+
} else {
|
|
17
|
+
show()
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
func show() {
|
|
22
|
+
if let p = panel, p.isVisible {
|
|
23
|
+
p.makeKeyAndOrderFront(nil)
|
|
24
|
+
NSApp.activate(ignoringOtherApps: true)
|
|
25
|
+
return
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Fresh state each time
|
|
29
|
+
let searchState = OmniSearchState()
|
|
30
|
+
state = searchState
|
|
31
|
+
|
|
32
|
+
let view = OmniSearchView(state: searchState) { [weak self] in
|
|
33
|
+
self?.dismiss()
|
|
34
|
+
}
|
|
35
|
+
.preferredColorScheme(.dark)
|
|
36
|
+
|
|
37
|
+
let hosting = NSHostingController(rootView: view)
|
|
38
|
+
hosting.preferredContentSize = NSSize(width: 520, height: 480)
|
|
39
|
+
|
|
40
|
+
let p = NSPanel(
|
|
41
|
+
contentRect: NSRect(x: 0, y: 0, width: 520, height: 480),
|
|
42
|
+
styleMask: [.titled, .closable, .resizable, .utilityWindow, .nonactivatingPanel],
|
|
43
|
+
backing: .buffered,
|
|
44
|
+
defer: false
|
|
45
|
+
)
|
|
46
|
+
p.contentViewController = hosting
|
|
47
|
+
p.title = "Omni Search"
|
|
48
|
+
p.titlebarAppearsTransparent = true
|
|
49
|
+
p.titleVisibility = .hidden
|
|
50
|
+
p.isMovableByWindowBackground = true
|
|
51
|
+
p.level = .floating
|
|
52
|
+
p.isOpaque = false
|
|
53
|
+
p.backgroundColor = NSColor(red: 0.11, green: 0.11, blue: 0.12, alpha: 1.0)
|
|
54
|
+
p.hasShadow = true
|
|
55
|
+
p.appearance = NSAppearance(named: .darkAqua)
|
|
56
|
+
p.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
|
|
57
|
+
p.minSize = NSSize(width: 400, height: 300)
|
|
58
|
+
p.maxSize = NSSize(width: 700, height: 700)
|
|
59
|
+
|
|
60
|
+
// Center on screen
|
|
61
|
+
if let screen = NSScreen.main {
|
|
62
|
+
let visibleFrame = screen.visibleFrame
|
|
63
|
+
let x = visibleFrame.midX - 260
|
|
64
|
+
let y = visibleFrame.midY + 60 // slightly above center
|
|
65
|
+
p.setFrameOrigin(NSPoint(x: x, y: y))
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
p.makeKeyAndOrderFront(nil)
|
|
69
|
+
NSApp.activate(ignoringOtherApps: true)
|
|
70
|
+
panel = p
|
|
71
|
+
|
|
72
|
+
// Key monitor: Escape → dismiss, arrow keys → navigate, Enter → activate
|
|
73
|
+
keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
|
|
74
|
+
guard self?.panel?.isKeyWindow == true else { return event }
|
|
75
|
+
|
|
76
|
+
switch event.keyCode {
|
|
77
|
+
case 53: // Escape
|
|
78
|
+
self?.dismiss()
|
|
79
|
+
return nil
|
|
80
|
+
case 125: // ↓
|
|
81
|
+
self?.state?.moveSelection(1)
|
|
82
|
+
return nil
|
|
83
|
+
case 126: // ↑
|
|
84
|
+
self?.state?.moveSelection(-1)
|
|
85
|
+
return nil
|
|
86
|
+
case 36: // Enter
|
|
87
|
+
self?.state?.activateSelected()
|
|
88
|
+
self?.dismiss()
|
|
89
|
+
return nil
|
|
90
|
+
default:
|
|
91
|
+
return event
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
func dismiss() {
|
|
97
|
+
panel?.orderOut(nil)
|
|
98
|
+
panel = nil
|
|
99
|
+
state = nil
|
|
100
|
+
if let monitor = keyMonitor {
|
|
101
|
+
NSEvent.removeMonitor(monitor)
|
|
102
|
+
keyMonitor = nil
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import SwiftUI
|
|
2
|
+
|
|
3
|
+
struct OrphanRow: View {
|
|
4
|
+
let session: TmuxSession
|
|
5
|
+
var onAttach: () -> Void
|
|
6
|
+
var onKill: () -> Void
|
|
7
|
+
|
|
8
|
+
@State private var isHovered = false
|
|
9
|
+
@State private var isExpanded = false
|
|
10
|
+
|
|
11
|
+
private var commandSummary: String {
|
|
12
|
+
let commands = session.panes
|
|
13
|
+
.map(\.currentCommand)
|
|
14
|
+
.filter { !$0.isEmpty }
|
|
15
|
+
let unique = commands.count <= 3 ? commands : Array(commands.prefix(3)) + ["..."]
|
|
16
|
+
return "\(session.panes.count) pane\(session.panes.count == 1 ? "" : "s") \u{2014} \(unique.joined(separator: ", "))"
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
var body: some View {
|
|
20
|
+
VStack(spacing: 0) {
|
|
21
|
+
// Header row
|
|
22
|
+
HStack(spacing: 10) {
|
|
23
|
+
// Status bar — amber for orphan
|
|
24
|
+
RoundedRectangle(cornerRadius: 1)
|
|
25
|
+
.fill(Palette.detach)
|
|
26
|
+
.frame(width: 3, height: 32)
|
|
27
|
+
|
|
28
|
+
// Expand chevron
|
|
29
|
+
Button {
|
|
30
|
+
withAnimation(.easeOut(duration: 0.15)) { isExpanded.toggle() }
|
|
31
|
+
} label: {
|
|
32
|
+
Image(systemName: isExpanded ? "chevron.down" : "chevron.right")
|
|
33
|
+
.font(.system(size: 9, weight: .semibold))
|
|
34
|
+
.foregroundColor(Palette.textMuted)
|
|
35
|
+
.frame(width: 14)
|
|
36
|
+
}
|
|
37
|
+
.buttonStyle(.plain)
|
|
38
|
+
|
|
39
|
+
// Info
|
|
40
|
+
VStack(alignment: .leading, spacing: 3) {
|
|
41
|
+
HStack(spacing: 6) {
|
|
42
|
+
Text(session.name)
|
|
43
|
+
.font(Typo.heading(13))
|
|
44
|
+
.foregroundColor(Palette.text)
|
|
45
|
+
.lineLimit(1)
|
|
46
|
+
|
|
47
|
+
if session.attached {
|
|
48
|
+
Text("attached")
|
|
49
|
+
.font(Typo.mono(9))
|
|
50
|
+
.foregroundColor(Palette.detach)
|
|
51
|
+
.padding(.horizontal, 5)
|
|
52
|
+
.padding(.vertical, 1)
|
|
53
|
+
.background(
|
|
54
|
+
RoundedRectangle(cornerRadius: 3)
|
|
55
|
+
.fill(Palette.detach.opacity(0.12))
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
Text(commandSummary)
|
|
61
|
+
.font(Typo.mono(10))
|
|
62
|
+
.foregroundColor(Palette.textMuted)
|
|
63
|
+
.lineLimit(1)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
Spacer()
|
|
67
|
+
|
|
68
|
+
// Actions
|
|
69
|
+
HStack(spacing: 4) {
|
|
70
|
+
Button(action: onKill) {
|
|
71
|
+
Text("Kill")
|
|
72
|
+
.angularButton(Palette.kill, filled: false)
|
|
73
|
+
}
|
|
74
|
+
.buttonStyle(.plain)
|
|
75
|
+
|
|
76
|
+
Button(action: onAttach) {
|
|
77
|
+
Text("Attach")
|
|
78
|
+
.angularButton(Palette.running)
|
|
79
|
+
}
|
|
80
|
+
.buttonStyle(.plain)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
.padding(.horizontal, 10)
|
|
84
|
+
.padding(.vertical, 8)
|
|
85
|
+
.glassCard(hovered: isHovered)
|
|
86
|
+
|
|
87
|
+
// Expanded pane list
|
|
88
|
+
if isExpanded {
|
|
89
|
+
VStack(spacing: 2) {
|
|
90
|
+
ForEach(session.panes) { pane in
|
|
91
|
+
paneRow(pane)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
.padding(.leading, 36)
|
|
95
|
+
.padding(.trailing, 10)
|
|
96
|
+
.padding(.vertical, 4)
|
|
97
|
+
.transition(.opacity.combined(with: .move(edge: .top)))
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
.contentShape(Rectangle())
|
|
101
|
+
.onHover { isHovered = $0 }
|
|
102
|
+
.contextMenu {
|
|
103
|
+
Button("Attach") { onAttach() }
|
|
104
|
+
Divider()
|
|
105
|
+
Button("Kill Session") { onKill() }
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
private func paneRow(_ pane: TmuxPane) -> some View {
|
|
110
|
+
HStack(spacing: 8) {
|
|
111
|
+
Circle()
|
|
112
|
+
.fill(pane.isActive ? Palette.detach.opacity(0.7) : Palette.textMuted)
|
|
113
|
+
.frame(width: 5, height: 5)
|
|
114
|
+
|
|
115
|
+
Text(pane.title.isEmpty ? pane.currentCommand : pane.title)
|
|
116
|
+
.font(Typo.mono(11))
|
|
117
|
+
.foregroundColor(Palette.text)
|
|
118
|
+
.lineLimit(1)
|
|
119
|
+
|
|
120
|
+
Spacer()
|
|
121
|
+
|
|
122
|
+
Text(pane.currentCommand)
|
|
123
|
+
.font(Typo.mono(9))
|
|
124
|
+
.foregroundColor(Palette.textDim)
|
|
125
|
+
}
|
|
126
|
+
.padding(.horizontal, 8)
|
|
127
|
+
.padding(.vertical, 4)
|
|
128
|
+
}
|
|
129
|
+
}
|