@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,200 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
// MARK: - Unified Output
|
|
4
|
+
|
|
5
|
+
struct TerminalInstance {
|
|
6
|
+
// Join key
|
|
7
|
+
let tty: String
|
|
8
|
+
|
|
9
|
+
// Tab info (from AppleScript)
|
|
10
|
+
let app: Terminal?
|
|
11
|
+
let windowIndex: Int?
|
|
12
|
+
let tabIndex: Int?
|
|
13
|
+
let isActiveTab: Bool
|
|
14
|
+
let tabTitle: String?
|
|
15
|
+
let terminalSessionId: String? // iTerm2 unique ID
|
|
16
|
+
|
|
17
|
+
// Process info (from ps)
|
|
18
|
+
let processes: [ProcessEntry]
|
|
19
|
+
let shellPid: Int?
|
|
20
|
+
let cwd: String?
|
|
21
|
+
|
|
22
|
+
// Tmux info
|
|
23
|
+
let tmuxSession: String?
|
|
24
|
+
let tmuxPaneId: String?
|
|
25
|
+
|
|
26
|
+
// Window info (from CGWindowList)
|
|
27
|
+
let windowId: UInt32?
|
|
28
|
+
let windowTitle: String?
|
|
29
|
+
|
|
30
|
+
// Computed
|
|
31
|
+
var hasClaude: Bool {
|
|
32
|
+
processes.contains { $0.comm == "claude" }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
var displayName: String {
|
|
36
|
+
if let session = tmuxSession { return session }
|
|
37
|
+
if let title = tabTitle, !title.isEmpty { return title }
|
|
38
|
+
if let title = windowTitle, !title.isEmpty { return title }
|
|
39
|
+
return tty
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// MARK: - Synthesizer
|
|
44
|
+
|
|
45
|
+
enum TerminalSynthesizer {
|
|
46
|
+
|
|
47
|
+
/// Pure-function merge: joins 5 slices by TTY into unified TerminalInstances.
|
|
48
|
+
///
|
|
49
|
+
/// - Parameters:
|
|
50
|
+
/// - processTable: Full process table from ProcessQuery.snapshot()
|
|
51
|
+
/// - interesting: Filtered interesting processes
|
|
52
|
+
/// - tmuxSessions: Current tmux sessions with panes
|
|
53
|
+
/// - terminalTabs: AppleScript-enumerated tabs
|
|
54
|
+
/// - windows: CGWindowList entries
|
|
55
|
+
static func synthesize(
|
|
56
|
+
processTable: [Int: ProcessEntry],
|
|
57
|
+
interesting: [ProcessEntry],
|
|
58
|
+
tmuxSessions: [TmuxSession],
|
|
59
|
+
terminalTabs: [TerminalTab],
|
|
60
|
+
windows: [UInt32: WindowEntry]
|
|
61
|
+
) -> [TerminalInstance] {
|
|
62
|
+
|
|
63
|
+
// 1. Single pass: index ALL processes by normalized TTY
|
|
64
|
+
// This avoids O(TTYs × processes) re-scans later.
|
|
65
|
+
var allProcessesByTTY: [String: [ProcessEntry]] = [:]
|
|
66
|
+
for entry in processTable.values {
|
|
67
|
+
let tty = TerminalQuery.normalizeTTY(entry.tty)
|
|
68
|
+
guard tty != "??" else { continue }
|
|
69
|
+
allProcessesByTTY[tty, default: []].append(entry)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// 2. Group interesting processes by TTY (subset of above)
|
|
73
|
+
var interestingByTTY: [String: [ProcessEntry]] = [:]
|
|
74
|
+
for entry in interesting {
|
|
75
|
+
let tty = TerminalQuery.normalizeTTY(entry.tty)
|
|
76
|
+
guard tty != "??" else { continue }
|
|
77
|
+
interestingByTTY[tty, default: []].append(entry)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// 3. Build tmux pane → TTY lookup
|
|
81
|
+
var tmuxByTTY: [String: (session: String, paneId: String)] = [:]
|
|
82
|
+
for session in tmuxSessions {
|
|
83
|
+
for pane in session.panes {
|
|
84
|
+
if let entry = processTable[pane.pid] {
|
|
85
|
+
let tty = TerminalQuery.normalizeTTY(entry.tty)
|
|
86
|
+
if tty != "??" {
|
|
87
|
+
tmuxByTTY[tty] = (session: session.name, paneId: pane.id)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// 4. Index terminal tabs by TTY
|
|
94
|
+
var tabByTTY: [String: TerminalTab] = [:]
|
|
95
|
+
for tab in terminalTabs {
|
|
96
|
+
tabByTTY[tab.tty] = tab
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// 5. Collect all known TTYs (union of all maps)
|
|
100
|
+
var allTTYs = Set(interestingByTTY.keys)
|
|
101
|
+
allTTYs.formUnion(tmuxByTTY.keys)
|
|
102
|
+
allTTYs.formUnion(tabByTTY.keys)
|
|
103
|
+
|
|
104
|
+
// 6. Build window lookup for positional matching
|
|
105
|
+
let windowsByApp = buildWindowsByApp(windows)
|
|
106
|
+
|
|
107
|
+
// 7. For each TTY, merge all slices
|
|
108
|
+
var instances: [TerminalInstance] = []
|
|
109
|
+
for tty in allTTYs {
|
|
110
|
+
let procs = interestingByTTY[tty] ?? []
|
|
111
|
+
let tab = tabByTTY[tty]
|
|
112
|
+
let tmux = tmuxByTTY[tty]
|
|
113
|
+
|
|
114
|
+
// Shell PID: process on this TTY whose parent is NOT on this TTY
|
|
115
|
+
let ttyProcs = allProcessesByTTY[tty] ?? []
|
|
116
|
+
let ttyPids = Set(ttyProcs.map(\.pid))
|
|
117
|
+
let shellPid = ttyProcs.first { !ttyPids.contains($0.ppid) }?.pid
|
|
118
|
+
|
|
119
|
+
// CWD: deepest interesting process's cwd, or shell's cwd
|
|
120
|
+
let cwd = procs.last(where: { $0.cwd != nil })?.cwd
|
|
121
|
+
?? (shellPid.flatMap { processTable[$0]?.cwd })
|
|
122
|
+
|
|
123
|
+
// Window: try lattices tag match first, then positional
|
|
124
|
+
let windowMatch = resolveWindow(
|
|
125
|
+
tmuxSession: tmux?.session,
|
|
126
|
+
tab: tab,
|
|
127
|
+
windowsByApp: windowsByApp,
|
|
128
|
+
allWindows: windows
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
instances.append(TerminalInstance(
|
|
132
|
+
tty: tty,
|
|
133
|
+
app: tab?.app,
|
|
134
|
+
windowIndex: tab?.windowIndex,
|
|
135
|
+
tabIndex: tab?.tabIndex,
|
|
136
|
+
isActiveTab: tab?.isActiveTab ?? false,
|
|
137
|
+
tabTitle: tab?.title,
|
|
138
|
+
terminalSessionId: tab?.sessionId,
|
|
139
|
+
processes: procs,
|
|
140
|
+
shellPid: shellPid,
|
|
141
|
+
cwd: cwd,
|
|
142
|
+
tmuxSession: tmux?.session,
|
|
143
|
+
tmuxPaneId: tmux?.paneId,
|
|
144
|
+
windowId: windowMatch?.wid,
|
|
145
|
+
windowTitle: windowMatch?.title
|
|
146
|
+
))
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// 7. Sort: Claude first, active tabs first, then by TTY
|
|
150
|
+
instances.sort { a, b in
|
|
151
|
+
if a.hasClaude != b.hasClaude { return a.hasClaude }
|
|
152
|
+
if a.isActiveTab != b.isActiveTab { return a.isActiveTab }
|
|
153
|
+
return a.tty < b.tty
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return instances
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// MARK: - Private Helpers
|
|
160
|
+
|
|
161
|
+
/// Group windows by app name for positional matching.
|
|
162
|
+
private static func buildWindowsByApp(_ windows: [UInt32: WindowEntry]) -> [String: [WindowEntry]] {
|
|
163
|
+
var result: [String: [WindowEntry]] = [:]
|
|
164
|
+
for w in windows.values {
|
|
165
|
+
result[w.app, default: []].append(w)
|
|
166
|
+
}
|
|
167
|
+
// Sort each app's windows for consistent positional matching
|
|
168
|
+
for key in result.keys {
|
|
169
|
+
result[key]?.sort { $0.wid < $1.wid }
|
|
170
|
+
}
|
|
171
|
+
return result
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/// Resolve a window for this TTY. Try lattices tag match first, then positional.
|
|
175
|
+
private static func resolveWindow(
|
|
176
|
+
tmuxSession: String?,
|
|
177
|
+
tab: TerminalTab?,
|
|
178
|
+
windowsByApp: [String: [WindowEntry]],
|
|
179
|
+
allWindows: [UInt32: WindowEntry]
|
|
180
|
+
) -> WindowEntry? {
|
|
181
|
+
// Strategy 1: lattices session tag match
|
|
182
|
+
if let session = tmuxSession {
|
|
183
|
+
let tag = Terminal.windowTag(for: session)
|
|
184
|
+
if let match = allWindows.values.first(where: { $0.title.contains(tag) }) {
|
|
185
|
+
return match
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Strategy 2: positional match by app + window index
|
|
190
|
+
if let tab = tab {
|
|
191
|
+
let appName = tab.app.rawValue
|
|
192
|
+
if let appWindows = windowsByApp[appName],
|
|
193
|
+
tab.windowIndex < appWindows.count {
|
|
194
|
+
return appWindows[tab.windowIndex]
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return nil
|
|
199
|
+
}
|
|
200
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import SwiftUI
|
|
2
|
+
|
|
3
|
+
// MARK: - Colors
|
|
4
|
+
|
|
5
|
+
enum Palette {
|
|
6
|
+
// Base surfaces — warm dark
|
|
7
|
+
static let bg = Color(red: 0.11, green: 0.11, blue: 0.12) // #1C1C1E
|
|
8
|
+
static let surface = Color(red: 0.15, green: 0.15, blue: 0.16) // Raised cards
|
|
9
|
+
static let surfaceHov = Color(red: 0.18, green: 0.18, blue: 0.19) // Hovered cards
|
|
10
|
+
static let border = Color.white.opacity(0.05)
|
|
11
|
+
static let borderLit = Color.white.opacity(0.10)
|
|
12
|
+
|
|
13
|
+
// Text
|
|
14
|
+
static let text = Color.white.opacity(0.92)
|
|
15
|
+
static let textDim = Color.white.opacity(0.50)
|
|
16
|
+
static let textMuted = Color.white.opacity(0.30)
|
|
17
|
+
|
|
18
|
+
// Functional accents
|
|
19
|
+
static let running = Color(red: 0.20, green: 0.78, blue: 0.45) // Green
|
|
20
|
+
static let detach = Color(red: 0.96, green: 0.65, blue: 0.14) // Amber
|
|
21
|
+
static let kill = Color(red: 0.94, green: 0.30, blue: 0.35) // Red
|
|
22
|
+
static let launch = Color.white // Clean white
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// MARK: - Typography
|
|
26
|
+
|
|
27
|
+
enum Typo {
|
|
28
|
+
private static let jetbrains = "JetBrains Mono"
|
|
29
|
+
private static let geist = "GeistMono Nerd Font"
|
|
30
|
+
private static let gohu = "GohuFontuni14 Nerd Font"
|
|
31
|
+
|
|
32
|
+
static func title(_ size: CGFloat = 15) -> Font {
|
|
33
|
+
.system(size: size, weight: .bold, design: .rounded)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
static func heading(_ size: CGFloat = 13) -> Font {
|
|
37
|
+
.system(size: size, weight: .semibold, design: .rounded)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
static func body(_ size: CGFloat = 12) -> Font {
|
|
41
|
+
.system(size: size, weight: .regular, design: .rounded)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
static func caption(_ size: CGFloat = 10) -> Font {
|
|
45
|
+
.system(size: size, weight: .medium, design: .rounded)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
static func mono(_ size: CGFloat = 11) -> Font {
|
|
49
|
+
.custom(jetbrains, size: size)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
static func monoBold(_ size: CGFloat = 11) -> Font {
|
|
53
|
+
Font.custom(jetbrains, size: size).weight(.semibold)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
static func geistMono(_ size: CGFloat = 11) -> Font {
|
|
57
|
+
.custom(geist, size: size)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
static func geistMonoBold(_ size: CGFloat = 11) -> Font {
|
|
61
|
+
Font.custom(geist, size: size).weight(.medium)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
static func pixel(_ size: CGFloat = 14) -> Font {
|
|
65
|
+
.custom(gohu, size: size)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// MARK: - Background
|
|
70
|
+
|
|
71
|
+
struct PanelBackground: View {
|
|
72
|
+
var body: some View {
|
|
73
|
+
Palette.bg
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// MARK: - Reusable modifiers
|
|
78
|
+
|
|
79
|
+
struct GlassCard: ViewModifier {
|
|
80
|
+
var isHovered: Bool = false
|
|
81
|
+
|
|
82
|
+
func body(content: Content) -> some View {
|
|
83
|
+
content
|
|
84
|
+
.background(
|
|
85
|
+
RoundedRectangle(cornerRadius: 5)
|
|
86
|
+
.fill(isHovered ? Palette.surfaceHov : Palette.surface)
|
|
87
|
+
.overlay(
|
|
88
|
+
RoundedRectangle(cornerRadius: 5)
|
|
89
|
+
.strokeBorder(isHovered ? Palette.borderLit : Palette.border, lineWidth: 0.5)
|
|
90
|
+
)
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
struct LiquidGlassCard: ViewModifier {
|
|
96
|
+
func body(content: Content) -> some View {
|
|
97
|
+
content
|
|
98
|
+
.background(
|
|
99
|
+
ZStack {
|
|
100
|
+
// Base: translucent dark fill
|
|
101
|
+
RoundedRectangle(cornerRadius: 10)
|
|
102
|
+
.fill(Color.white.opacity(0.04))
|
|
103
|
+
|
|
104
|
+
// Subtle gradient: brighter at top edge for "glass reflection"
|
|
105
|
+
RoundedRectangle(cornerRadius: 10)
|
|
106
|
+
.fill(
|
|
107
|
+
LinearGradient(
|
|
108
|
+
colors: [Color.white.opacity(0.06), Color.clear],
|
|
109
|
+
startPoint: .top,
|
|
110
|
+
endPoint: .center
|
|
111
|
+
)
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
// Border: top-bright, bottom-dark for depth
|
|
115
|
+
RoundedRectangle(cornerRadius: 10)
|
|
116
|
+
.strokeBorder(
|
|
117
|
+
LinearGradient(
|
|
118
|
+
colors: [Color.white.opacity(0.12), Color.white.opacity(0.04)],
|
|
119
|
+
startPoint: .top,
|
|
120
|
+
endPoint: .bottom
|
|
121
|
+
),
|
|
122
|
+
lineWidth: 0.5
|
|
123
|
+
)
|
|
124
|
+
}
|
|
125
|
+
)
|
|
126
|
+
.shadow(color: Color.black.opacity(0.2), radius: 8, y: 4)
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
struct AngularButton: ViewModifier {
|
|
131
|
+
let color: Color
|
|
132
|
+
var filled: Bool = true
|
|
133
|
+
|
|
134
|
+
func body(content: Content) -> some View {
|
|
135
|
+
content
|
|
136
|
+
.font(Typo.monoBold(10))
|
|
137
|
+
.foregroundColor(filled ? Palette.bg : color)
|
|
138
|
+
.padding(.horizontal, 8)
|
|
139
|
+
.padding(.vertical, 4)
|
|
140
|
+
.background(
|
|
141
|
+
RoundedRectangle(cornerRadius: 3)
|
|
142
|
+
.fill(filled ? color : color.opacity(0.10))
|
|
143
|
+
)
|
|
144
|
+
.overlay(
|
|
145
|
+
RoundedRectangle(cornerRadius: 3)
|
|
146
|
+
.strokeBorder(filled ? Color.clear : color.opacity(0.25), lineWidth: 0.5)
|
|
147
|
+
)
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
extension View {
|
|
152
|
+
func glassCard(hovered: Bool = false) -> some View {
|
|
153
|
+
modifier(GlassCard(isHovered: hovered))
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
func liquidGlass() -> some View {
|
|
157
|
+
modifier(LiquidGlassCard())
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
func angularButton(_ color: Color, filled: Bool = true) -> some View {
|
|
161
|
+
modifier(AngularButton(color: color, filled: filled))
|
|
162
|
+
}
|
|
163
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import SwiftUI
|
|
2
|
+
|
|
3
|
+
struct TilePickerView: View {
|
|
4
|
+
let sessionName: String
|
|
5
|
+
let terminal: Terminal
|
|
6
|
+
let onSelect: (TilePosition) -> Void
|
|
7
|
+
let onGoToSpace: (Int) -> Void // space ID
|
|
8
|
+
let onDismiss: () -> Void
|
|
9
|
+
|
|
10
|
+
@State private var hoveredTile: TilePosition?
|
|
11
|
+
@State private var hoveredSpace: Int? // space ID
|
|
12
|
+
@State private var displaySpaces: [DisplaySpaces] = []
|
|
13
|
+
@State private var windowSpaceId: Int = 0
|
|
14
|
+
@State private var currentTile: TilePosition?
|
|
15
|
+
|
|
16
|
+
private let grid: [[TilePosition]] = [
|
|
17
|
+
[.topLeft, .topRight],
|
|
18
|
+
[.left, .right],
|
|
19
|
+
[.bottomLeft, .bottomRight],
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
var body: some View {
|
|
23
|
+
VStack(spacing: 8) {
|
|
24
|
+
HStack {
|
|
25
|
+
Text("TILE WINDOW")
|
|
26
|
+
.font(Typo.pixel(12))
|
|
27
|
+
.foregroundColor(Palette.running)
|
|
28
|
+
Spacer()
|
|
29
|
+
Button(action: onDismiss) {
|
|
30
|
+
Image(systemName: "xmark")
|
|
31
|
+
.font(.system(size: 8, weight: .bold))
|
|
32
|
+
.foregroundColor(Palette.textDim)
|
|
33
|
+
.frame(width: 18, height: 18)
|
|
34
|
+
.background(
|
|
35
|
+
RoundedRectangle(cornerRadius: 3)
|
|
36
|
+
.fill(Palette.surface)
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
.buttonStyle(.plain)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Tile grid
|
|
43
|
+
VStack(spacing: 3) {
|
|
44
|
+
ForEach(grid, id: \.first?.id) { row in
|
|
45
|
+
HStack(spacing: 3) {
|
|
46
|
+
ForEach(row) { tile in
|
|
47
|
+
tileCell(tile)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
HStack(spacing: 3) {
|
|
54
|
+
tileWideCell(.maximize)
|
|
55
|
+
tileWideCell(.center)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Spaces per display — navigate to space
|
|
59
|
+
ForEach(displaySpaces, id: \.displayIndex) { display in
|
|
60
|
+
if display.spaces.count > 1 || displaySpaces.count > 1 {
|
|
61
|
+
Rectangle()
|
|
62
|
+
.fill(Palette.border)
|
|
63
|
+
.frame(height: 0.5)
|
|
64
|
+
.padding(.vertical, 2)
|
|
65
|
+
|
|
66
|
+
HStack {
|
|
67
|
+
Text(displaySpaces.count > 1
|
|
68
|
+
? "DISPLAY \(display.displayIndex + 1) SPACES"
|
|
69
|
+
: "GO TO SPACE")
|
|
70
|
+
.font(Typo.pixel(10))
|
|
71
|
+
.foregroundColor(Palette.textMuted)
|
|
72
|
+
Spacer()
|
|
73
|
+
if windowSpaceId > 0 {
|
|
74
|
+
let windowOnDisplay = display.spaces.contains { $0.id == windowSpaceId }
|
|
75
|
+
if windowOnDisplay {
|
|
76
|
+
Text("window here")
|
|
77
|
+
.font(Typo.mono(9))
|
|
78
|
+
.foregroundColor(Palette.running)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
HStack(spacing: 3) {
|
|
84
|
+
ForEach(display.spaces) { space in
|
|
85
|
+
spaceCell(space: space)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
.padding(12)
|
|
92
|
+
.background(
|
|
93
|
+
RoundedRectangle(cornerRadius: 6)
|
|
94
|
+
.fill(Palette.surface)
|
|
95
|
+
.overlay(
|
|
96
|
+
RoundedRectangle(cornerRadius: 6)
|
|
97
|
+
.strokeBorder(Palette.borderLit, lineWidth: 0.5)
|
|
98
|
+
)
|
|
99
|
+
)
|
|
100
|
+
.onAppear {
|
|
101
|
+
displaySpaces = WindowTiler.getDisplaySpaces()
|
|
102
|
+
// Find which space this session's window is on + current tile
|
|
103
|
+
if let info = WindowTiler.getWindowInfo(session: sessionName, terminal: terminal) {
|
|
104
|
+
if let spaceId = WindowTiler.getSpacesForWindow(info.wid).first {
|
|
105
|
+
windowSpaceId = spaceId
|
|
106
|
+
}
|
|
107
|
+
currentTile = info.tilePosition
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
private func tileCell(_ tile: TilePosition) -> some View {
|
|
113
|
+
let isCurrent = currentTile == tile
|
|
114
|
+
let isHovered = hoveredTile == tile
|
|
115
|
+
return Button {
|
|
116
|
+
onSelect(tile)
|
|
117
|
+
} label: {
|
|
118
|
+
Image(systemName: tile.icon)
|
|
119
|
+
.font(.system(size: 14))
|
|
120
|
+
.foregroundColor(isHovered ? Palette.running : isCurrent ? Palette.running.opacity(0.8) : Palette.textDim)
|
|
121
|
+
.frame(maxWidth: .infinity)
|
|
122
|
+
.frame(height: 32)
|
|
123
|
+
.background(
|
|
124
|
+
RoundedRectangle(cornerRadius: 4)
|
|
125
|
+
.fill(isHovered ? Palette.running.opacity(0.1) : isCurrent ? Palette.running.opacity(0.06) : Palette.bg)
|
|
126
|
+
.overlay(
|
|
127
|
+
RoundedRectangle(cornerRadius: 4)
|
|
128
|
+
.strokeBorder(
|
|
129
|
+
isHovered ? Palette.running.opacity(0.3) : isCurrent ? Palette.running.opacity(0.25) : Palette.border,
|
|
130
|
+
lineWidth: isCurrent ? 1 : 0.5
|
|
131
|
+
)
|
|
132
|
+
)
|
|
133
|
+
)
|
|
134
|
+
}
|
|
135
|
+
.buttonStyle(.plain)
|
|
136
|
+
.onHover { hoveredTile = $0 ? tile : nil }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private func tileWideCell(_ tile: TilePosition) -> some View {
|
|
140
|
+
let isCurrent = currentTile == tile
|
|
141
|
+
let isHovered = hoveredTile == tile
|
|
142
|
+
return Button {
|
|
143
|
+
onSelect(tile)
|
|
144
|
+
} label: {
|
|
145
|
+
HStack(spacing: 4) {
|
|
146
|
+
Image(systemName: tile.icon)
|
|
147
|
+
.font(.system(size: 12))
|
|
148
|
+
Text(tile.label)
|
|
149
|
+
.font(Typo.mono(10))
|
|
150
|
+
}
|
|
151
|
+
.foregroundColor(isHovered ? Palette.running : isCurrent ? Palette.running.opacity(0.8) : Palette.textDim)
|
|
152
|
+
.frame(maxWidth: .infinity)
|
|
153
|
+
.frame(height: 28)
|
|
154
|
+
.background(
|
|
155
|
+
RoundedRectangle(cornerRadius: 4)
|
|
156
|
+
.fill(isHovered ? Palette.running.opacity(0.1) : isCurrent ? Palette.running.opacity(0.06) : Palette.bg)
|
|
157
|
+
.overlay(
|
|
158
|
+
RoundedRectangle(cornerRadius: 4)
|
|
159
|
+
.strokeBorder(
|
|
160
|
+
isHovered ? Palette.running.opacity(0.3) : isCurrent ? Palette.running.opacity(0.25) : Palette.border,
|
|
161
|
+
lineWidth: isCurrent ? 1 : 0.5
|
|
162
|
+
)
|
|
163
|
+
)
|
|
164
|
+
)
|
|
165
|
+
}
|
|
166
|
+
.buttonStyle(.plain)
|
|
167
|
+
.onHover { hoveredTile = $0 ? tile : nil }
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
private func spaceCell(space: SpaceInfo) -> some View {
|
|
171
|
+
let hasWindow = space.id == windowSpaceId
|
|
172
|
+
return Button {
|
|
173
|
+
onGoToSpace(space.id)
|
|
174
|
+
} label: {
|
|
175
|
+
HStack(spacing: 4) {
|
|
176
|
+
Image(systemName: space.isCurrent ? "desktopcomputer" : hasWindow ? "macwindow" : "rectangle.on.rectangle")
|
|
177
|
+
.font(.system(size: 10))
|
|
178
|
+
Text("\(space.index)")
|
|
179
|
+
.font(Typo.monoBold(11))
|
|
180
|
+
}
|
|
181
|
+
.foregroundColor(
|
|
182
|
+
hoveredSpace == space.id ? Palette.running :
|
|
183
|
+
hasWindow ? Palette.running :
|
|
184
|
+
space.isCurrent ? Palette.text : Palette.textDim
|
|
185
|
+
)
|
|
186
|
+
.frame(maxWidth: .infinity)
|
|
187
|
+
.frame(height: 28)
|
|
188
|
+
.background(
|
|
189
|
+
RoundedRectangle(cornerRadius: 4)
|
|
190
|
+
.fill(
|
|
191
|
+
hoveredSpace == space.id ? Palette.running.opacity(0.1) :
|
|
192
|
+
hasWindow ? Palette.running.opacity(0.05) :
|
|
193
|
+
space.isCurrent ? Palette.bg.opacity(0.5) : Palette.bg
|
|
194
|
+
)
|
|
195
|
+
.overlay(
|
|
196
|
+
RoundedRectangle(cornerRadius: 4)
|
|
197
|
+
.strokeBorder(
|
|
198
|
+
hoveredSpace == space.id ? Palette.running.opacity(0.3) :
|
|
199
|
+
hasWindow ? Palette.running.opacity(0.3) :
|
|
200
|
+
space.isCurrent ? Palette.borderLit : Palette.border,
|
|
201
|
+
lineWidth: 0.5
|
|
202
|
+
)
|
|
203
|
+
)
|
|
204
|
+
)
|
|
205
|
+
}
|
|
206
|
+
.buttonStyle(.plain)
|
|
207
|
+
.onHover { hoveredSpace = $0 ? space.id : nil }
|
|
208
|
+
}
|
|
209
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
final class TmuxModel: ObservableObject {
|
|
4
|
+
static let shared = TmuxModel()
|
|
5
|
+
|
|
6
|
+
@Published private(set) var sessions: [TmuxSession] = []
|
|
7
|
+
private var timer: Timer?
|
|
8
|
+
|
|
9
|
+
func start(interval: TimeInterval = 3.0) {
|
|
10
|
+
guard timer == nil else { return }
|
|
11
|
+
DiagnosticLog.shared.info("TmuxModel: starting (interval=\(interval)s)")
|
|
12
|
+
poll()
|
|
13
|
+
timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in
|
|
14
|
+
self?.poll()
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
func stop() {
|
|
19
|
+
timer?.invalidate()
|
|
20
|
+
timer = nil
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
func poll() {
|
|
24
|
+
let fresh = TmuxQuery.listSessions()
|
|
25
|
+
let changed = sessionsChanged(old: sessions, new: fresh)
|
|
26
|
+
|
|
27
|
+
DispatchQueue.main.async {
|
|
28
|
+
self.sessions = fresh
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if changed {
|
|
32
|
+
EventBus.shared.post(.tmuxChanged(sessions: fresh))
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
func isRunning(_ name: String) -> Bool {
|
|
37
|
+
sessions.contains { $0.name == name }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
private func sessionsChanged(old: [TmuxSession], new: [TmuxSession]) -> Bool {
|
|
41
|
+
guard old.count == new.count else { return true }
|
|
42
|
+
let oldNames = Set(old.map(\.name))
|
|
43
|
+
let newNames = Set(new.map(\.name))
|
|
44
|
+
if oldNames != newNames { return true }
|
|
45
|
+
// Check pane counts changed
|
|
46
|
+
for newSession in new {
|
|
47
|
+
guard let oldSession = old.first(where: { $0.name == newSession.name }) else { return true }
|
|
48
|
+
if oldSession.panes.count != newSession.panes.count { return true }
|
|
49
|
+
if oldSession.attached != newSession.attached { return true }
|
|
50
|
+
}
|
|
51
|
+
return false
|
|
52
|
+
}
|
|
53
|
+
}
|