@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,203 @@
|
|
|
1
|
+
import AppKit
|
|
2
|
+
import SwiftUI
|
|
3
|
+
|
|
4
|
+
// MARK: - Layer Switch HUD
|
|
5
|
+
|
|
6
|
+
/// A notch-style pill that briefly shows the active layer name when switching.
|
|
7
|
+
final class LayerBezel {
|
|
8
|
+
static let shared = LayerBezel()
|
|
9
|
+
|
|
10
|
+
private var panel: NSPanel?
|
|
11
|
+
private var dismissTimer: Timer?
|
|
12
|
+
/// Cached pill width per layer count — stable once computed for a workspace
|
|
13
|
+
private var cachedWidth: CGFloat?
|
|
14
|
+
private var cachedLayerSignature: String?
|
|
15
|
+
|
|
16
|
+
/// Show the layer bezel for a given layer label and index.
|
|
17
|
+
func show(label: String, index: Int, total: Int, allLabels: [String]) {
|
|
18
|
+
dismissTimer?.invalidate()
|
|
19
|
+
|
|
20
|
+
guard let screen = NSScreen.main ?? NSScreen.screens.first else { return }
|
|
21
|
+
let screenFrame = screen.frame
|
|
22
|
+
|
|
23
|
+
let pillWidth = stableWidth(for: allLabels, total: total)
|
|
24
|
+
let pillHeight: CGFloat = 64
|
|
25
|
+
|
|
26
|
+
// Position: centered on screen, upper third
|
|
27
|
+
let x = screenFrame.origin.x + (screenFrame.width - pillWidth) / 2
|
|
28
|
+
let y = screenFrame.origin.y + screenFrame.height * 0.65
|
|
29
|
+
|
|
30
|
+
let pillFrame = NSRect(x: x, y: y, width: pillWidth, height: pillHeight)
|
|
31
|
+
|
|
32
|
+
let view = LayerBezelView(label: label, index: index, total: total)
|
|
33
|
+
let hostingView = NSHostingView(rootView: view)
|
|
34
|
+
|
|
35
|
+
if panel == nil {
|
|
36
|
+
let p = NSPanel(
|
|
37
|
+
contentRect: pillFrame,
|
|
38
|
+
styleMask: [.borderless, .nonactivatingPanel],
|
|
39
|
+
backing: .buffered,
|
|
40
|
+
defer: false
|
|
41
|
+
)
|
|
42
|
+
p.isOpaque = false
|
|
43
|
+
p.backgroundColor = .clear
|
|
44
|
+
p.level = .statusBar
|
|
45
|
+
p.hasShadow = false
|
|
46
|
+
p.hidesOnDeactivate = false
|
|
47
|
+
p.isReleasedWhenClosed = false
|
|
48
|
+
p.isMovable = false
|
|
49
|
+
p.collectionBehavior = [.canJoinAllSpaces, .stationary, .fullScreenAuxiliary]
|
|
50
|
+
p.ignoresMouseEvents = true
|
|
51
|
+
panel = p
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
guard let p = panel else { return }
|
|
55
|
+
|
|
56
|
+
p.contentView = hostingView
|
|
57
|
+
p.setFrame(pillFrame, display: false)
|
|
58
|
+
p.alphaValue = 0
|
|
59
|
+
p.orderFrontRegardless()
|
|
60
|
+
|
|
61
|
+
NSAnimationContext.runAnimationGroup { ctx in
|
|
62
|
+
ctx.duration = 0.15
|
|
63
|
+
p.animator().alphaValue = 1.0
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
dismissTimer = Timer.scheduledTimer(withTimeInterval: 1.5, repeats: false) { [weak self] _ in
|
|
67
|
+
self?.dismiss()
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
func dismiss() {
|
|
72
|
+
guard let p = panel, p.isVisible else { return }
|
|
73
|
+
NSAnimationContext.runAnimationGroup({ ctx in
|
|
74
|
+
ctx.duration = 0.3
|
|
75
|
+
p.animator().alphaValue = 0
|
|
76
|
+
}, completionHandler: {
|
|
77
|
+
p.orderOut(nil)
|
|
78
|
+
})
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/// Invalidate cached width (call when workspace config changes)
|
|
82
|
+
func invalidateCache() {
|
|
83
|
+
cachedWidth = nil
|
|
84
|
+
cachedLayerSignature = nil
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// MARK: - Width Heuristics
|
|
88
|
+
|
|
89
|
+
/// Compute a stable pill width based on the longest layer label.
|
|
90
|
+
/// Cached so the pill never resizes between switches within the same workspace.
|
|
91
|
+
private func stableWidth(for allLabels: [String], total: Int) -> CGFloat {
|
|
92
|
+
let signature = allLabels.joined(separator: "|") + ":\(total)"
|
|
93
|
+
if let cached = cachedWidth, cachedLayerSignature == signature {
|
|
94
|
+
return cached
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Measure the widest label using the actual font
|
|
98
|
+
let font = NSFont(name: "NewYork-RegularItalic", size: 24)
|
|
99
|
+
?? NSFont.systemFont(ofSize: 24, weight: .medium)
|
|
100
|
+
let attrs: [NSAttributedString.Key: Any] = [.font: font]
|
|
101
|
+
|
|
102
|
+
var maxTextWidth: CGFloat = 0
|
|
103
|
+
for label in allLabels {
|
|
104
|
+
let size = (label as NSString).size(withAttributes: attrs)
|
|
105
|
+
maxTextWidth = max(maxTextWidth, ceil(size.width))
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// dots width: 7px per dot + 5px spacing
|
|
109
|
+
let dotsWidth = CGFloat(total) * 7 + CGFloat(max(0, total - 1)) * 5
|
|
110
|
+
// divider + spacing
|
|
111
|
+
let dividerWidth: CGFloat = 1 + 14 * 2
|
|
112
|
+
// horizontal padding
|
|
113
|
+
let hPadding: CGFloat = 36 * 2
|
|
114
|
+
|
|
115
|
+
let contentWidth = dotsWidth + dividerWidth + maxTextWidth + hPadding
|
|
116
|
+
|
|
117
|
+
// Minimum 360, round up to nearest 20 for visual stability
|
|
118
|
+
let rawWidth = max(360, contentWidth)
|
|
119
|
+
let width = ceil(rawWidth / 20) * 20
|
|
120
|
+
|
|
121
|
+
cachedWidth = width
|
|
122
|
+
cachedLayerSignature = signature
|
|
123
|
+
return width
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// MARK: - Bezel View
|
|
128
|
+
|
|
129
|
+
struct LayerBezelView: View {
|
|
130
|
+
let label: String
|
|
131
|
+
let index: Int
|
|
132
|
+
let total: Int
|
|
133
|
+
|
|
134
|
+
private var layerFont: Font {
|
|
135
|
+
// New York Italic — Apple's serif font
|
|
136
|
+
if let descriptor = NSFontDescriptor(fontAttributes: [
|
|
137
|
+
.family: "New York",
|
|
138
|
+
.traits: [NSFontDescriptor.TraitKey.symbolic: NSFontDescriptor.SymbolicTraits.italic.rawValue]
|
|
139
|
+
]).withDesign(.serif) {
|
|
140
|
+
return Font(NSFont(descriptor: descriptor, size: 24) ?? .systemFont(ofSize: 24))
|
|
141
|
+
}
|
|
142
|
+
return .system(size: 24, weight: .medium, design: .serif).italic()
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
var body: some View {
|
|
146
|
+
HStack(spacing: 14) {
|
|
147
|
+
// Layer index dots
|
|
148
|
+
HStack(spacing: 5) {
|
|
149
|
+
ForEach(0..<total, id: \.self) { i in
|
|
150
|
+
Circle()
|
|
151
|
+
.fill(i == index ? Color.white : Color.white.opacity(0.25))
|
|
152
|
+
.frame(width: 7, height: 7)
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Divider
|
|
157
|
+
Rectangle()
|
|
158
|
+
.fill(Color.white.opacity(0.15))
|
|
159
|
+
.frame(width: 1, height: 20)
|
|
160
|
+
|
|
161
|
+
// Layer name
|
|
162
|
+
Text(label)
|
|
163
|
+
.font(layerFont)
|
|
164
|
+
.foregroundStyle(
|
|
165
|
+
.linearGradient(
|
|
166
|
+
colors: [.white, .white.opacity(0.85)],
|
|
167
|
+
startPoint: .leading,
|
|
168
|
+
endPoint: .trailing
|
|
169
|
+
)
|
|
170
|
+
)
|
|
171
|
+
}
|
|
172
|
+
.padding(.horizontal, 36)
|
|
173
|
+
.padding(.vertical, 16)
|
|
174
|
+
.frame(maxWidth: .infinity)
|
|
175
|
+
.background(
|
|
176
|
+
RoundedRectangle(cornerRadius: 28, style: .continuous)
|
|
177
|
+
.fill(Color.black)
|
|
178
|
+
)
|
|
179
|
+
.overlay(
|
|
180
|
+
RoundedRectangle(cornerRadius: 28, style: .continuous)
|
|
181
|
+
.strokeBorder(
|
|
182
|
+
.linearGradient(
|
|
183
|
+
colors: [.white.opacity(0.9), .white.opacity(0.22)],
|
|
184
|
+
startPoint: .top,
|
|
185
|
+
endPoint: .bottom
|
|
186
|
+
),
|
|
187
|
+
lineWidth: 0.5
|
|
188
|
+
)
|
|
189
|
+
)
|
|
190
|
+
// Inner glow — top edge highlight
|
|
191
|
+
.overlay(
|
|
192
|
+
RoundedRectangle(cornerRadius: 28, style: .continuous)
|
|
193
|
+
.fill(
|
|
194
|
+
.linearGradient(
|
|
195
|
+
colors: [.white.opacity(0.08), .clear],
|
|
196
|
+
startPoint: .top,
|
|
197
|
+
endPoint: .center
|
|
198
|
+
)
|
|
199
|
+
)
|
|
200
|
+
.allowsHitTesting(false)
|
|
201
|
+
)
|
|
202
|
+
}
|
|
203
|
+
}
|
|
@@ -0,0 +1,479 @@
|
|
|
1
|
+
import SwiftUI
|
|
2
|
+
|
|
3
|
+
struct MainView: View {
|
|
4
|
+
@ObservedObject var scanner: ProjectScanner
|
|
5
|
+
@StateObject private var prefs = Preferences.shared
|
|
6
|
+
@StateObject private var permChecker = PermissionChecker.shared
|
|
7
|
+
@ObservedObject private var workspace = WorkspaceManager.shared
|
|
8
|
+
@StateObject private var inventory = InventoryManager.shared
|
|
9
|
+
@State private var searchText = ""
|
|
10
|
+
@State private var hasCheckedSetup = false
|
|
11
|
+
@State private var bannerDismissed = false
|
|
12
|
+
@State private var orphanSectionCollapsed = true
|
|
13
|
+
private var filtered: [Project] {
|
|
14
|
+
if searchText.isEmpty { return scanner.projects }
|
|
15
|
+
return scanner.projects.filter {
|
|
16
|
+
$0.name.localizedCaseInsensitiveContains(searchText)
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
private var filteredOrphans: [TmuxSession] {
|
|
21
|
+
if searchText.isEmpty { return inventory.orphans }
|
|
22
|
+
return inventory.orphans.filter {
|
|
23
|
+
$0.name.localizedCaseInsensitiveContains(searchText)
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
private var needsSetup: Bool { prefs.scanRoot.isEmpty }
|
|
28
|
+
private var runningCount: Int { scanner.projects.filter(\.isRunning).count }
|
|
29
|
+
|
|
30
|
+
var body: some View {
|
|
31
|
+
VStack(spacing: 0) {
|
|
32
|
+
mainContent
|
|
33
|
+
}
|
|
34
|
+
.frame(minWidth: 380, idealWidth: 380, maxWidth: 600, minHeight: 460, idealHeight: 460, maxHeight: .infinity)
|
|
35
|
+
.background(PanelBackground())
|
|
36
|
+
.preferredColorScheme(.dark)
|
|
37
|
+
.onAppear {
|
|
38
|
+
let tTotal = DiagnosticLog.shared.startTimed("MainView.onAppear (total)")
|
|
39
|
+
if needsSetup && !hasCheckedSetup {
|
|
40
|
+
hasCheckedSetup = true
|
|
41
|
+
SettingsWindowController.shared.show()
|
|
42
|
+
}
|
|
43
|
+
scanner.updateRoot(prefs.scanRoot)
|
|
44
|
+
|
|
45
|
+
let tScan = DiagnosticLog.shared.startTimed("ProjectScanner.scan")
|
|
46
|
+
scanner.scan()
|
|
47
|
+
DiagnosticLog.shared.finish(tScan)
|
|
48
|
+
|
|
49
|
+
let tInv = DiagnosticLog.shared.startTimed("InventoryManager.refresh")
|
|
50
|
+
inventory.refresh()
|
|
51
|
+
DiagnosticLog.shared.finish(tInv)
|
|
52
|
+
|
|
53
|
+
let tPerm = DiagnosticLog.shared.startTimed("PermissionChecker.check")
|
|
54
|
+
permChecker.check()
|
|
55
|
+
DiagnosticLog.shared.finish(tPerm)
|
|
56
|
+
|
|
57
|
+
bannerDismissed = false
|
|
58
|
+
DiagnosticLog.shared.finish(tTotal)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
private var mainContent: some View {
|
|
63
|
+
VStack(spacing: 0) {
|
|
64
|
+
// Title bar
|
|
65
|
+
HStack {
|
|
66
|
+
Text("lattices")
|
|
67
|
+
.font(Typo.title())
|
|
68
|
+
.foregroundColor(Palette.text)
|
|
69
|
+
|
|
70
|
+
if runningCount > 0 || !inventory.orphans.isEmpty {
|
|
71
|
+
let total = runningCount + inventory.orphans.count
|
|
72
|
+
Text("\(total) session\(total == 1 ? "" : "s")")
|
|
73
|
+
.font(Typo.mono(10))
|
|
74
|
+
.foregroundColor(Palette.running)
|
|
75
|
+
.padding(.leading, 4)
|
|
76
|
+
} else {
|
|
77
|
+
Text("None")
|
|
78
|
+
.font(Typo.mono(10))
|
|
79
|
+
.foregroundColor(Palette.textMuted)
|
|
80
|
+
.padding(.leading, 4)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
Spacer()
|
|
84
|
+
|
|
85
|
+
headerButton(icon: "arrow.up.left.and.arrow.down.right") {
|
|
86
|
+
(NSApp.delegate as? AppDelegate)?.dismissPopover()
|
|
87
|
+
MainWindow.shared.show()
|
|
88
|
+
}
|
|
89
|
+
headerButton(icon: "arrow.clockwise") { scanner.scan(); inventory.refresh() }
|
|
90
|
+
}
|
|
91
|
+
.padding(.horizontal, 18)
|
|
92
|
+
.padding(.top, 14)
|
|
93
|
+
.padding(.bottom, 10)
|
|
94
|
+
|
|
95
|
+
// Layer switcher
|
|
96
|
+
if let config = workspace.config, let layers = config.layers, layers.count > 1 {
|
|
97
|
+
layerBar(config: config)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Search
|
|
101
|
+
HStack(spacing: 8) {
|
|
102
|
+
Image(systemName: "magnifyingglass")
|
|
103
|
+
.foregroundColor(Palette.textMuted)
|
|
104
|
+
.font(.system(size: 11))
|
|
105
|
+
TextField("Search projects...", text: $searchText)
|
|
106
|
+
.textFieldStyle(.plain)
|
|
107
|
+
.font(Typo.body(13))
|
|
108
|
+
.foregroundColor(Palette.text)
|
|
109
|
+
if !searchText.isEmpty {
|
|
110
|
+
Button { searchText = "" } label: {
|
|
111
|
+
Image(systemName: "xmark.circle.fill")
|
|
112
|
+
.foregroundColor(Palette.textMuted)
|
|
113
|
+
.font(.system(size: 11))
|
|
114
|
+
}
|
|
115
|
+
.buttonStyle(.plain)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
.padding(.horizontal, 12)
|
|
119
|
+
.padding(.vertical, 8)
|
|
120
|
+
.background(
|
|
121
|
+
RoundedRectangle(cornerRadius: 4)
|
|
122
|
+
.fill(Palette.surface)
|
|
123
|
+
)
|
|
124
|
+
.padding(.horizontal, 14)
|
|
125
|
+
.padding(.bottom, 10)
|
|
126
|
+
|
|
127
|
+
// Permission banner
|
|
128
|
+
if !permChecker.allGranted && !bannerDismissed {
|
|
129
|
+
permissionBanner
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
Rectangle()
|
|
133
|
+
.fill(Palette.border)
|
|
134
|
+
.frame(height: 0.5)
|
|
135
|
+
|
|
136
|
+
// List
|
|
137
|
+
if filtered.isEmpty && (workspace.config?.groups ?? []).isEmpty {
|
|
138
|
+
Spacer()
|
|
139
|
+
emptyState
|
|
140
|
+
Spacer()
|
|
141
|
+
} else {
|
|
142
|
+
ScrollView {
|
|
143
|
+
LazyVStack(spacing: 4) {
|
|
144
|
+
// Tab groups section
|
|
145
|
+
if let groups = workspace.config?.groups, !groups.isEmpty, searchText.isEmpty {
|
|
146
|
+
ForEach(groups) { group in
|
|
147
|
+
TabGroupRow(group: group, workspace: workspace)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if !filtered.isEmpty {
|
|
151
|
+
Rectangle()
|
|
152
|
+
.fill(Palette.border)
|
|
153
|
+
.frame(height: 0.5)
|
|
154
|
+
.padding(.vertical, 4)
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Projects
|
|
159
|
+
ForEach(filtered) { project in
|
|
160
|
+
ProjectRow(project: project) {
|
|
161
|
+
SessionManager.launch(project: project)
|
|
162
|
+
} onDetach: {
|
|
163
|
+
SessionManager.detach(project: project)
|
|
164
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
|
165
|
+
scanner.refreshStatus()
|
|
166
|
+
}
|
|
167
|
+
} onKill: {
|
|
168
|
+
SessionManager.kill(project: project)
|
|
169
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
|
170
|
+
scanner.refreshStatus()
|
|
171
|
+
}
|
|
172
|
+
} onSync: {
|
|
173
|
+
SessionManager.sync(project: project)
|
|
174
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
|
|
175
|
+
scanner.refreshStatus()
|
|
176
|
+
}
|
|
177
|
+
} onRestart: { paneName in
|
|
178
|
+
SessionManager.restart(project: project, paneName: paneName)
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Orphan sessions
|
|
183
|
+
if !filteredOrphans.isEmpty {
|
|
184
|
+
orphanSection
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
.padding(.horizontal, 10)
|
|
188
|
+
.padding(.vertical, 8)
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
Rectangle()
|
|
193
|
+
.fill(Palette.border)
|
|
194
|
+
.frame(height: 0.5)
|
|
195
|
+
|
|
196
|
+
// Actions footer
|
|
197
|
+
actionsSection
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// MARK: - Orphan section
|
|
202
|
+
|
|
203
|
+
private var orphanSection: some View {
|
|
204
|
+
VStack(spacing: 4) {
|
|
205
|
+
Rectangle()
|
|
206
|
+
.fill(Palette.border)
|
|
207
|
+
.frame(height: 0.5)
|
|
208
|
+
.padding(.vertical, 4)
|
|
209
|
+
|
|
210
|
+
// Section header
|
|
211
|
+
Button {
|
|
212
|
+
withAnimation(.easeOut(duration: 0.15)) { orphanSectionCollapsed.toggle() }
|
|
213
|
+
} label: {
|
|
214
|
+
HStack(spacing: 6) {
|
|
215
|
+
Image(systemName: orphanSectionCollapsed ? "chevron.right" : "chevron.down")
|
|
216
|
+
.font(.system(size: 9, weight: .semibold))
|
|
217
|
+
.foregroundColor(Palette.textMuted)
|
|
218
|
+
|
|
219
|
+
Text("Unmanaged Sessions")
|
|
220
|
+
.font(Typo.caption(10))
|
|
221
|
+
.foregroundColor(Palette.textMuted)
|
|
222
|
+
|
|
223
|
+
Text("\(filteredOrphans.count)")
|
|
224
|
+
.font(Typo.mono(9))
|
|
225
|
+
.foregroundColor(Palette.detach)
|
|
226
|
+
.padding(.horizontal, 5)
|
|
227
|
+
.padding(.vertical, 1)
|
|
228
|
+
.background(
|
|
229
|
+
RoundedRectangle(cornerRadius: 3)
|
|
230
|
+
.fill(Palette.detach.opacity(0.12))
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
Spacer()
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
.buttonStyle(.plain)
|
|
237
|
+
.padding(.horizontal, 4)
|
|
238
|
+
|
|
239
|
+
if !orphanSectionCollapsed {
|
|
240
|
+
ForEach(filteredOrphans) { session in
|
|
241
|
+
OrphanRow(
|
|
242
|
+
session: session,
|
|
243
|
+
onAttach: {
|
|
244
|
+
let terminal = Preferences.shared.terminal
|
|
245
|
+
terminal.focusOrAttach(session: session.name)
|
|
246
|
+
},
|
|
247
|
+
onKill: {
|
|
248
|
+
SessionManager.killByName(session.name)
|
|
249
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
|
250
|
+
inventory.refresh()
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
)
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// MARK: - Actions footer
|
|
260
|
+
|
|
261
|
+
private var actionsSection: some View {
|
|
262
|
+
VStack(spacing: 0) {
|
|
263
|
+
ActionRow(shortcut: "1", label: "Command Palette", hotkey: hotkeyLabel(.palette), icon: "command", accentColor: Palette.running) {
|
|
264
|
+
CommandPaletteWindow.shared.toggle()
|
|
265
|
+
}
|
|
266
|
+
ActionRow(shortcut: "2", label: "Screen Map", hotkey: hotkeyLabel(.screenMap), icon: "rectangle.3.group") {
|
|
267
|
+
ScreenMapWindowController.shared.toggle()
|
|
268
|
+
}
|
|
269
|
+
ActionRow(shortcut: "3", label: "Desktop Inventory", hotkey: hotkeyLabel(.desktopInventory), icon: "rectangle.split.2x1") {
|
|
270
|
+
CommandModeWindow.shared.toggle()
|
|
271
|
+
}
|
|
272
|
+
ActionRow(shortcut: "4", label: "Window Bezel", hotkey: hotkeyLabel(.bezel), icon: "macwindow") {
|
|
273
|
+
WindowBezel.showBezelForFrontmostWindow()
|
|
274
|
+
}
|
|
275
|
+
ActionRow(shortcut: "5", label: "Cheat Sheet", hotkey: hotkeyLabel(.cheatSheet), icon: "keyboard") {
|
|
276
|
+
CheatSheetHUD.shared.toggle()
|
|
277
|
+
}
|
|
278
|
+
ActionRow(shortcut: "6", label: "Omni Search", hotkey: hotkeyLabel(.omniSearch), icon: "magnifyingglass", accentColor: Palette.running) {
|
|
279
|
+
OmniSearchWindow.shared.toggle()
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
Rectangle()
|
|
283
|
+
.fill(Palette.border)
|
|
284
|
+
.frame(height: 0.5)
|
|
285
|
+
.padding(.horizontal, 10)
|
|
286
|
+
|
|
287
|
+
ActionRow(shortcut: "S", label: "Settings", icon: "gearshape") {
|
|
288
|
+
SettingsWindowController.shared.show()
|
|
289
|
+
}
|
|
290
|
+
HStack(spacing: 0) {
|
|
291
|
+
ActionRow(shortcut: "D", label: "Diagnostics", icon: "stethoscope") {
|
|
292
|
+
DiagnosticWindow.shared.toggle()
|
|
293
|
+
}
|
|
294
|
+
if !permChecker.allGranted {
|
|
295
|
+
Circle()
|
|
296
|
+
.fill(Palette.detach)
|
|
297
|
+
.frame(width: 6, height: 6)
|
|
298
|
+
.padding(.trailing, 14)
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
Rectangle()
|
|
303
|
+
.fill(Palette.border)
|
|
304
|
+
.frame(height: 0.5)
|
|
305
|
+
.padding(.horizontal, 10)
|
|
306
|
+
|
|
307
|
+
ActionRow(shortcut: "Q", label: "Quit", icon: "power", accentColor: Palette.kill) {
|
|
308
|
+
NSApp.terminate(nil)
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
.padding(.vertical, 4)
|
|
312
|
+
.background(Palette.surface.opacity(0.4))
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
private func hotkeyLabel(_ action: HotkeyAction) -> String? {
|
|
316
|
+
guard let binding = HotkeyStore.shared.bindings[action] else { return nil }
|
|
317
|
+
return binding.displayParts.joined(separator: "")
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// MARK: - Empty state
|
|
321
|
+
|
|
322
|
+
private var emptyState: some View {
|
|
323
|
+
VStack(spacing: 14) {
|
|
324
|
+
Image(systemName: "terminal")
|
|
325
|
+
.font(.system(size: 28, weight: .light))
|
|
326
|
+
.foregroundColor(Palette.textMuted)
|
|
327
|
+
|
|
328
|
+
Text("No projects yet")
|
|
329
|
+
.font(Typo.heading(14))
|
|
330
|
+
.foregroundColor(Palette.textDim)
|
|
331
|
+
|
|
332
|
+
Text("Run lattices init in a project\nto add it here")
|
|
333
|
+
.font(Typo.mono(11))
|
|
334
|
+
.foregroundColor(Palette.textMuted)
|
|
335
|
+
.multilineTextAlignment(.center)
|
|
336
|
+
.lineSpacing(3)
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// MARK: - Permission banner
|
|
341
|
+
|
|
342
|
+
private var permissionBanner: some View {
|
|
343
|
+
VStack(alignment: .leading, spacing: 6) {
|
|
344
|
+
HStack {
|
|
345
|
+
Image(systemName: "exclamationmark.triangle.fill")
|
|
346
|
+
.font(.system(size: 10))
|
|
347
|
+
.foregroundColor(Palette.detach)
|
|
348
|
+
Text("PERMISSIONS NEEDED")
|
|
349
|
+
.font(Typo.monoBold(10))
|
|
350
|
+
.foregroundColor(Palette.detach)
|
|
351
|
+
Spacer()
|
|
352
|
+
Button { bannerDismissed = true } label: {
|
|
353
|
+
Image(systemName: "xmark")
|
|
354
|
+
.font(.system(size: 8, weight: .bold))
|
|
355
|
+
.foregroundColor(Palette.textMuted)
|
|
356
|
+
}
|
|
357
|
+
.buttonStyle(.plain)
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
permissionRow("Accessibility", granted: permChecker.accessibility) {
|
|
361
|
+
permChecker.requestAccessibility()
|
|
362
|
+
}
|
|
363
|
+
permissionRow("Screen Recording", granted: permChecker.screenRecording) {
|
|
364
|
+
permChecker.requestScreenRecording()
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
Text("Click a row to request access.")
|
|
368
|
+
.font(Typo.mono(9))
|
|
369
|
+
.foregroundColor(Palette.textMuted)
|
|
370
|
+
}
|
|
371
|
+
.padding(12)
|
|
372
|
+
.background(
|
|
373
|
+
RoundedRectangle(cornerRadius: 5)
|
|
374
|
+
.fill(Palette.detach.opacity(0.08))
|
|
375
|
+
.overlay(
|
|
376
|
+
RoundedRectangle(cornerRadius: 5)
|
|
377
|
+
.strokeBorder(Palette.detach.opacity(0.20), lineWidth: 0.5)
|
|
378
|
+
)
|
|
379
|
+
)
|
|
380
|
+
.padding(.horizontal, 14)
|
|
381
|
+
.padding(.bottom, 10)
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
private func permissionRow(_ name: String, granted: Bool, open: @escaping () -> Void) -> some View {
|
|
385
|
+
Button(action: { if !granted { open() } }) {
|
|
386
|
+
HStack(spacing: 6) {
|
|
387
|
+
Image(systemName: granted ? "checkmark.circle.fill" : "circle")
|
|
388
|
+
.font(.system(size: 10))
|
|
389
|
+
.foregroundColor(granted ? Palette.running : Palette.detach)
|
|
390
|
+
Text(name)
|
|
391
|
+
.font(Typo.mono(10))
|
|
392
|
+
.foregroundColor(Palette.text)
|
|
393
|
+
Spacer()
|
|
394
|
+
if granted {
|
|
395
|
+
Text("granted")
|
|
396
|
+
.font(Typo.mono(9))
|
|
397
|
+
.foregroundColor(Palette.running)
|
|
398
|
+
} else {
|
|
399
|
+
HStack(spacing: 4) {
|
|
400
|
+
Text("not set")
|
|
401
|
+
.font(Typo.mono(9))
|
|
402
|
+
.foregroundColor(Palette.detach)
|
|
403
|
+
Image(systemName: "arrow.up.forward.square")
|
|
404
|
+
.font(.system(size: 9))
|
|
405
|
+
.foregroundColor(Palette.detach)
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
.padding(.vertical, 4)
|
|
410
|
+
.padding(.horizontal, 8)
|
|
411
|
+
.background(
|
|
412
|
+
RoundedRectangle(cornerRadius: 4)
|
|
413
|
+
.fill(granted ? Color.clear : Palette.detach.opacity(0.06))
|
|
414
|
+
)
|
|
415
|
+
}
|
|
416
|
+
.buttonStyle(.plain)
|
|
417
|
+
.disabled(granted)
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// MARK: - Layer Bar
|
|
421
|
+
|
|
422
|
+
private func layerBar(config: WorkspaceConfig) -> some View {
|
|
423
|
+
HStack(spacing: 6) {
|
|
424
|
+
ForEach(Array((config.layers ?? []).enumerated()), id: \.element.id) { i, layer in
|
|
425
|
+
let isActive = i == workspace.activeLayerIndex
|
|
426
|
+
let counts = workspace.layerRunningCount(index: i)
|
|
427
|
+
Button {
|
|
428
|
+
workspace.tileLayer(index: i)
|
|
429
|
+
} label: {
|
|
430
|
+
VStack(spacing: 2) {
|
|
431
|
+
HStack(spacing: 5) {
|
|
432
|
+
Circle()
|
|
433
|
+
.fill(isActive ? Palette.running : Palette.textMuted.opacity(0.4))
|
|
434
|
+
.frame(width: 6, height: 6)
|
|
435
|
+
Text(layer.label)
|
|
436
|
+
.font(Typo.mono(11))
|
|
437
|
+
.foregroundColor(isActive ? Palette.text : Palette.textDim)
|
|
438
|
+
if counts.total > 0 {
|
|
439
|
+
Text("\(counts.running)/\(counts.total)")
|
|
440
|
+
.font(Typo.mono(8))
|
|
441
|
+
.foregroundColor(counts.running > 0 ? Palette.running : Palette.textMuted)
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
Text("\u{2325}\(i + 1)")
|
|
445
|
+
.font(Typo.mono(8))
|
|
446
|
+
.foregroundColor(Palette.textMuted.opacity(0.6))
|
|
447
|
+
}
|
|
448
|
+
.padding(.horizontal, 10)
|
|
449
|
+
.padding(.vertical, 5)
|
|
450
|
+
.background(
|
|
451
|
+
RoundedRectangle(cornerRadius: 5)
|
|
452
|
+
.fill(isActive ? Palette.running.opacity(0.1) : Color.clear)
|
|
453
|
+
)
|
|
454
|
+
.overlay(
|
|
455
|
+
RoundedRectangle(cornerRadius: 5)
|
|
456
|
+
.strokeBorder(isActive ? Palette.running.opacity(0.3) : Palette.border, lineWidth: 0.5)
|
|
457
|
+
)
|
|
458
|
+
}
|
|
459
|
+
.buttonStyle(.plain)
|
|
460
|
+
.disabled(workspace.isSwitching)
|
|
461
|
+
}
|
|
462
|
+
Spacer()
|
|
463
|
+
}
|
|
464
|
+
.padding(.horizontal, 14)
|
|
465
|
+
.padding(.bottom, 8)
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// MARK: - Helpers
|
|
469
|
+
|
|
470
|
+
private func headerButton(icon: String, action: @escaping () -> Void) -> some View {
|
|
471
|
+
Button(action: action) {
|
|
472
|
+
Image(systemName: icon)
|
|
473
|
+
.font(.system(size: 12, weight: .medium))
|
|
474
|
+
.foregroundColor(Palette.textDim)
|
|
475
|
+
.frame(width: 28, height: 28)
|
|
476
|
+
}
|
|
477
|
+
.buttonStyle(.plain)
|
|
478
|
+
}
|
|
479
|
+
}
|