@lattices/cli 0.4.13 → 0.5.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 +5 -7
- package/apps/mac/Info.plist +2 -2
- package/apps/mac/Lattices.app/Contents/Info.plist +4 -12
- package/apps/mac/Lattices.app/Contents/MacOS/Lattices +0 -0
- package/bin/lattices-app.ts +110 -17
- package/bin/lattices-build +125 -0
- package/bin/lattices-dev +89 -16
- package/bin/lattices.ts +977 -16
- package/docs/agents.md +81 -4
- package/docs/ai-chat-ux-review.md +416 -0
- package/docs/api.md +135 -3
- package/docs/app.md +30 -8
- package/docs/config.md +4 -0
- package/docs/mouse-gestures.md +191 -63
- package/docs/proposals/LAT-004-interactive-overlay-actors.md +1 -1
- package/docs/proposals/LAT-005-action-runtime-product-spine.md +914 -0
- package/docs/proposals/LAT-006-mira-in-lattices.md +553 -0
- package/docs/reference/dewey.config.ts +2 -2
- package/docs/release.md +171 -0
- package/docs/repo-structure.md +4 -5
- package/docs/voice.md +11 -27
- package/package.json +9 -10
- package/apps/mac/Package.swift +0 -27
- package/apps/mac/Sources/AppShell/App.swift +0 -26
- package/apps/mac/Sources/AppShell/AppActivationCoordinator.swift +0 -27
- package/apps/mac/Sources/AppShell/AppDelegate.swift +0 -189
- package/apps/mac/Sources/AppShell/AppServicesBootstrap.swift +0 -25
- package/apps/mac/Sources/AppShell/AppShellView.swift +0 -171
- package/apps/mac/Sources/AppShell/AppUpdater.swift +0 -305
- package/apps/mac/Sources/AppShell/CliActionLauncher.swift +0 -50
- package/apps/mac/Sources/AppShell/HomeDashboardView.swift +0 -133
- package/apps/mac/Sources/AppShell/HotkeyBootstrap.swift +0 -87
- package/apps/mac/Sources/AppShell/KeyRecorderView.swift +0 -210
- package/apps/mac/Sources/AppShell/LatticesRuntime.swift +0 -104
- package/apps/mac/Sources/AppShell/MainView.swift +0 -847
- package/apps/mac/Sources/AppShell/MainWindow.swift +0 -83
- package/apps/mac/Sources/AppShell/MenuBarController.swift +0 -177
- package/apps/mac/Sources/AppShell/OnboardingView.swift +0 -483
- package/apps/mac/Sources/AppShell/PermissionsAssistantView.swift +0 -366
- package/apps/mac/Sources/AppShell/PermissionsAssistantWindow.swift +0 -70
- package/apps/mac/Sources/AppShell/Preferences.swift +0 -297
- package/apps/mac/Sources/AppShell/SettingsView.swift +0 -3163
- package/apps/mac/Sources/AppShell/SettingsWindow.swift +0 -34
- package/apps/mac/Sources/AppShell/WorkspaceInspectorPresenter.swift +0 -13
- package/apps/mac/Sources/Core/Actions/HotkeyManager.swift +0 -256
- package/apps/mac/Sources/Core/Actions/HotkeyStore.swift +0 -399
- package/apps/mac/Sources/Core/Actions/IntentEngine.swift +0 -988
- package/apps/mac/Sources/Core/Actions/IntentSchema.swift +0 -94
- package/apps/mac/Sources/Core/Actions/Intents/CreateLayerIntent.swift +0 -54
- package/apps/mac/Sources/Core/Actions/Intents/DistributeIntent.swift +0 -56
- package/apps/mac/Sources/Core/Actions/Intents/FocusIntent.swift +0 -69
- package/apps/mac/Sources/Core/Actions/Intents/HelpIntent.swift +0 -41
- package/apps/mac/Sources/Core/Actions/Intents/KillIntent.swift +0 -47
- package/apps/mac/Sources/Core/Actions/Intents/LatticeIntent.swift +0 -53
- package/apps/mac/Sources/Core/Actions/Intents/LaunchIntent.swift +0 -67
- package/apps/mac/Sources/Core/Actions/Intents/ListSessionsIntent.swift +0 -32
- package/apps/mac/Sources/Core/Actions/Intents/ListWindowsIntent.swift +0 -30
- package/apps/mac/Sources/Core/Actions/Intents/ScanIntent.swift +0 -52
- package/apps/mac/Sources/Core/Actions/Intents/SearchIntent.swift +0 -190
- package/apps/mac/Sources/Core/Actions/Intents/SwitchLayerIntent.swift +0 -50
- package/apps/mac/Sources/Core/Actions/Intents/TileIntent.swift +0 -61
- package/apps/mac/Sources/Core/Actions/PaletteCommand.swift +0 -439
- package/apps/mac/Sources/Core/Actions/VoiceIntentResolver.swift +0 -713
- package/apps/mac/Sources/Core/Companion/CompanionActivityLog.swift +0 -70
- package/apps/mac/Sources/Core/Companion/CompanionKeyboardController.swift +0 -141
- package/apps/mac/Sources/Core/Companion/LatticesCompanionBridgeServer.swift +0 -454
- package/apps/mac/Sources/Core/Companion/LatticesCompanionCockpit.swift +0 -555
- package/apps/mac/Sources/Core/Companion/LatticesCompanionSecurityCoordinator.swift +0 -629
- package/apps/mac/Sources/Core/Companion/LatticesCompanionTrackpadController.swift +0 -204
- package/apps/mac/Sources/Core/Companion/LatticesDeckHost.swift +0 -1463
- package/apps/mac/Sources/Core/Daemon/DaemonProtocol.swift +0 -114
- package/apps/mac/Sources/Core/Daemon/DaemonServer.swift +0 -427
- package/apps/mac/Sources/Core/Daemon/LatticesApi.swift +0 -2965
- package/apps/mac/Sources/Core/Desktop/AccessibilityTextExtractor.swift +0 -111
- package/apps/mac/Sources/Core/Desktop/AppTypeClassifier.swift +0 -106
- package/apps/mac/Sources/Core/Desktop/DesktopModel.swift +0 -331
- package/apps/mac/Sources/Core/Desktop/DesktopModelTypes.swift +0 -73
- package/apps/mac/Sources/Core/Desktop/InventoryManager.swift +0 -35
- package/apps/mac/Sources/Core/Desktop/InventoryPath.swift +0 -43
- package/apps/mac/Sources/Core/Desktop/MouseFinder.swift +0 -527
- package/apps/mac/Sources/Core/Desktop/OcrModel.swift +0 -467
- package/apps/mac/Sources/Core/Desktop/OcrStore.swift +0 -329
- package/apps/mac/Sources/Core/Desktop/PlacementSpec.swift +0 -195
- package/apps/mac/Sources/Core/Desktop/SessionWindowLocator.swift +0 -139
- package/apps/mac/Sources/Core/Desktop/TilePickerView.swift +0 -209
- package/apps/mac/Sources/Core/Desktop/WindowCapture.swift +0 -33
- package/apps/mac/Sources/Core/Desktop/WindowDragSnapController.swift +0 -429
- package/apps/mac/Sources/Core/Desktop/WindowPreviewCard.swift +0 -100
- package/apps/mac/Sources/Core/Desktop/WindowPreviewStore.swift +0 -112
- package/apps/mac/Sources/Core/Desktop/WindowSelectionStore.swift +0 -76
- package/apps/mac/Sources/Core/Desktop/WindowTiler.swift +0 -2222
- package/apps/mac/Sources/Core/Input/EventTapBreaker.swift +0 -124
- package/apps/mac/Sources/Core/Input/EventTapThread.swift +0 -54
- package/apps/mac/Sources/Core/Input/InputCaptureResetCenter.swift +0 -20
- package/apps/mac/Sources/Core/Input/KeyboardRemapConfig.swift +0 -69
- package/apps/mac/Sources/Core/Input/KeyboardRemapController.swift +0 -346
- package/apps/mac/Sources/Core/Input/KeyboardRemapStore.swift +0 -141
- package/apps/mac/Sources/Core/Input/MouseGestureConfig.swift +0 -499
- package/apps/mac/Sources/Core/Input/MouseGestureController.swift +0 -2271
- package/apps/mac/Sources/Core/Input/MouseInputDeviceStore.swift +0 -98
- package/apps/mac/Sources/Core/Input/MouseInputEventViewer.swift +0 -272
- package/apps/mac/Sources/Core/Input/MouseShortcutStore.swift +0 -170
- package/apps/mac/Sources/Core/Input/SecureEventInputMonitor.swift +0 -39
- package/apps/mac/Sources/Core/Input/ShapeRecognizer.swift +0 -624
- package/apps/mac/Sources/Core/Input/TapBudgetMeter.swift +0 -56
- package/apps/mac/Sources/Core/Overlays/AppWindowShell.swift +0 -63
- package/apps/mac/Sources/Core/Overlays/CommandMode/CommandModeState.swift +0 -1566
- package/apps/mac/Sources/Core/Overlays/CommandMode/CommandModeView.swift +0 -1927
- package/apps/mac/Sources/Core/Overlays/CommandMode/CommandModeWindow.swift +0 -196
- package/apps/mac/Sources/Core/Overlays/CommandPalette/CommandPaletteView.swift +0 -307
- package/apps/mac/Sources/Core/Overlays/CommandPalette/CommandPaletteWindow.swift +0 -67
- package/apps/mac/Sources/Core/Overlays/HUD/CheatSheetHUD.swift +0 -576
- package/apps/mac/Sources/Core/Overlays/HUD/HUDBottomBar.swift +0 -279
- package/apps/mac/Sources/Core/Overlays/HUD/HUDController.swift +0 -1158
- package/apps/mac/Sources/Core/Overlays/HUD/HUDLeftBar.swift +0 -849
- package/apps/mac/Sources/Core/Overlays/HUD/HUDMinimap.swift +0 -179
- package/apps/mac/Sources/Core/Overlays/HUD/HUDRightBar.swift +0 -596
- package/apps/mac/Sources/Core/Overlays/HUD/HUDState.swift +0 -367
- package/apps/mac/Sources/Core/Overlays/HUD/HUDTopBar.swift +0 -243
- package/apps/mac/Sources/Core/Overlays/HUD/LauncherHUD.swift +0 -334
- package/apps/mac/Sources/Core/Overlays/HUD/LayerBezel.swift +0 -203
- package/apps/mac/Sources/Core/Overlays/OmniSearch/OmniSearchState.swift +0 -280
- package/apps/mac/Sources/Core/Overlays/OmniSearch/OmniSearchView.swift +0 -422
- package/apps/mac/Sources/Core/Overlays/OmniSearch/OmniSearchWindow.swift +0 -94
- package/apps/mac/Sources/Core/Overlays/OverlayPanelShell.swift +0 -241
- package/apps/mac/Sources/Core/Overlays/ScreenMap/ScreenMapState.swift +0 -3135
- package/apps/mac/Sources/Core/Overlays/ScreenMap/ScreenMapView.swift +0 -3977
- package/apps/mac/Sources/Core/Overlays/ScreenMap/ScreenMapWindowController.swift +0 -119
- package/apps/mac/Sources/Core/Overlays/ScreenOverlayCanvasController.swift +0 -1217
- package/apps/mac/Sources/Core/Overlays/Voice/VoiceCommandWindow.swift +0 -1575
- package/apps/mac/Sources/Core/Pi/PiAuthNextStepCard.swift +0 -148
- package/apps/mac/Sources/Core/Pi/PiAuthPromptCard.swift +0 -90
- package/apps/mac/Sources/Core/Pi/PiChatDock.swift +0 -564
- package/apps/mac/Sources/Core/Pi/PiChatSession.swift +0 -1948
- package/apps/mac/Sources/Core/Pi/PiInstallCallout.swift +0 -86
- package/apps/mac/Sources/Core/Pi/PiProviderSetupCallout.swift +0 -99
- package/apps/mac/Sources/Core/Pi/PiWorkspaceView.swift +0 -510
- package/apps/mac/Sources/Core/System/Capability.swift +0 -79
- package/apps/mac/Sources/Core/System/DiagnosticLog.swift +0 -373
- package/apps/mac/Sources/Core/System/EventBus.swift +0 -31
- package/apps/mac/Sources/Core/System/PermissionChecker.swift +0 -224
- package/apps/mac/Sources/Core/System/ProcessModel.swift +0 -199
- package/apps/mac/Sources/Core/System/ProcessQuery.swift +0 -151
- package/apps/mac/Sources/Core/System/SystemTelemetryMonitor.swift +0 -273
- package/apps/mac/Sources/Core/Voice/AdvisorLearningStore.swift +0 -90
- package/apps/mac/Sources/Core/Voice/AgentSession.swift +0 -377
- package/apps/mac/Sources/Core/Voice/AudioProvider.swift +0 -555
- package/apps/mac/Sources/Core/Voice/HandsOffSession.swift +0 -839
- package/apps/mac/Sources/Core/Voice/VoiceChatView.swift +0 -192
- package/apps/mac/Sources/Core/Voice/VoxClient.swift +0 -454
- package/apps/mac/Sources/Core/Workspace/Project.swift +0 -28
- package/apps/mac/Sources/Core/Workspace/ProjectScanner.swift +0 -141
- package/apps/mac/Sources/Core/Workspace/SessionLayerStore.swift +0 -285
- package/apps/mac/Sources/Core/Workspace/SessionManager.swift +0 -75
- package/apps/mac/Sources/Core/Workspace/Terminal/Terminal.swift +0 -259
- package/apps/mac/Sources/Core/Workspace/Terminal/TerminalQuery.swift +0 -156
- package/apps/mac/Sources/Core/Workspace/Terminal/TerminalSynthesizer.swift +0 -200
- package/apps/mac/Sources/Core/Workspace/Tmux/TmuxModel.swift +0 -60
- package/apps/mac/Sources/Core/Workspace/Tmux/TmuxQuery.swift +0 -105
- package/apps/mac/Sources/Core/Workspace/WorkspaceManager.swift +0 -1027
- package/apps/mac/Sources/UI/ActionRow.swift +0 -78
- package/apps/mac/Sources/UI/OrphanRow.swift +0 -129
- package/apps/mac/Sources/UI/ProjectRow.swift +0 -368
- package/apps/mac/Sources/UI/TabGroupRow.swift +0 -178
- package/apps/mac/Sources/UI/Theme.swift +0 -164
- package/apps/mac/Tests/StageDragTests.swift +0 -333
- package/apps/mac/Tests/StageJoinTests.swift +0 -313
- package/apps/mac/Tests/StageManagerTests.swift +0 -280
- package/apps/mac/Tests/StageTileTests.swift +0 -353
- package/swift/Package.swift +0 -20
- package/swift/Sources/DeckKit/DeckAction.swift +0 -51
- package/swift/Sources/DeckKit/DeckBridgeSecurity.swift +0 -152
- package/swift/Sources/DeckKit/DeckCockpit.swift +0 -82
- package/swift/Sources/DeckKit/DeckHost.swift +0 -7
- package/swift/Sources/DeckKit/DeckManifest.swift +0 -145
- package/swift/Sources/DeckKit/DeckRuntimeSnapshot.swift +0 -533
- package/swift/Sources/DeckKit/DeckTrackpad.swift +0 -63
- package/swift/Sources/DeckKit/DeckValue.swift +0 -93
- package/swift/Sources/DeckKit/DeckVoiceError.swift +0 -88
- package/swift/Tests/DeckKitTests/DeckKitTests.swift +0 -286
|
@@ -1,1027 +0,0 @@
|
|
|
1
|
-
import AppKit
|
|
2
|
-
import CryptoKit
|
|
3
|
-
import Foundation
|
|
4
|
-
|
|
5
|
-
// MARK: - Data Model
|
|
6
|
-
|
|
7
|
-
struct TabGroupTab: Codable {
|
|
8
|
-
let path: String
|
|
9
|
-
let label: String?
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
struct TabGroup: Codable, Identifiable {
|
|
13
|
-
let id: String
|
|
14
|
-
let label: String
|
|
15
|
-
let tabs: [TabGroupTab]
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
struct LayerProject: Codable {
|
|
19
|
-
let path: String?
|
|
20
|
-
let group: String?
|
|
21
|
-
let tile: String?
|
|
22
|
-
let display: Int?
|
|
23
|
-
let app: String? // match by owner app name (e.g. "Google Chrome", "Xcode")
|
|
24
|
-
let title: String? // substring match on window title (case-insensitive)
|
|
25
|
-
let url: String? // URL to open if no matching window found
|
|
26
|
-
let launch: String? // app name to launch if not running (via `open -a`)
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
struct Layer: Codable, Identifiable {
|
|
30
|
-
let id: String
|
|
31
|
-
let label: String
|
|
32
|
-
let projects: [LayerProject]
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
struct WorkspaceConfig: Codable {
|
|
36
|
-
let name: String
|
|
37
|
-
let groups: [TabGroup]?
|
|
38
|
-
let layers: [Layer]?
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
// MARK: - Grid Presets & Named Layouts
|
|
42
|
-
|
|
43
|
-
struct GridPreset: Codable {
|
|
44
|
-
let x: CGFloat
|
|
45
|
-
let y: CGFloat
|
|
46
|
-
let w: CGFloat
|
|
47
|
-
let h: CGFloat
|
|
48
|
-
|
|
49
|
-
var fractions: (CGFloat, CGFloat, CGFloat, CGFloat) { (x, y, w, h) }
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
struct LayoutWindowSpec: Codable {
|
|
53
|
-
let app: String
|
|
54
|
-
let tile: String // TilePosition name or preset name
|
|
55
|
-
let display: Int? // spatial display number (1-based), nil = current
|
|
56
|
-
let title: String? // optional title match for disambiguation
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
struct LayoutConfig: Codable {
|
|
60
|
-
let windows: [LayoutWindowSpec]
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
struct GridFile: Codable {
|
|
64
|
-
let presets: [String: GridPreset]?
|
|
65
|
-
let layouts: [String: LayoutConfig]?
|
|
66
|
-
let snapZones: SnapZonesConfig?
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
enum SnapModifierKey: String, Codable, Equatable, CaseIterable, Identifiable {
|
|
70
|
-
case command
|
|
71
|
-
case option
|
|
72
|
-
case control
|
|
73
|
-
case shift
|
|
74
|
-
|
|
75
|
-
var id: String { rawValue }
|
|
76
|
-
|
|
77
|
-
var label: String {
|
|
78
|
-
switch self {
|
|
79
|
-
case .command:
|
|
80
|
-
return "Command"
|
|
81
|
-
case .option:
|
|
82
|
-
return "Option"
|
|
83
|
-
case .control:
|
|
84
|
-
return "Control"
|
|
85
|
-
case .shift:
|
|
86
|
-
return "Shift"
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
var shortLabel: String {
|
|
91
|
-
switch self {
|
|
92
|
-
case .command:
|
|
93
|
-
return "Cmd"
|
|
94
|
-
case .option:
|
|
95
|
-
return "Opt"
|
|
96
|
-
case .control:
|
|
97
|
-
return "Ctrl"
|
|
98
|
-
case .shift:
|
|
99
|
-
return "Shift"
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
var eventFlags: NSEvent.ModifierFlags {
|
|
104
|
-
switch self {
|
|
105
|
-
case .command:
|
|
106
|
-
return .command
|
|
107
|
-
case .option:
|
|
108
|
-
return .option
|
|
109
|
-
case .control:
|
|
110
|
-
return .control
|
|
111
|
-
case .shift:
|
|
112
|
-
return .shift
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
var cgEventFlags: CGEventFlags {
|
|
117
|
-
switch self {
|
|
118
|
-
case .command:
|
|
119
|
-
return .maskCommand
|
|
120
|
-
case .option:
|
|
121
|
-
return .maskAlternate
|
|
122
|
-
case .control:
|
|
123
|
-
return .maskControl
|
|
124
|
-
case .shift:
|
|
125
|
-
return .maskShift
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
enum SnapZoneTriggerSpec: Codable, Equatable {
|
|
132
|
-
case named(String)
|
|
133
|
-
case fractions(FractionalPlacement)
|
|
134
|
-
|
|
135
|
-
init(from decoder: Decoder) throws {
|
|
136
|
-
let container = try decoder.singleValueContainer()
|
|
137
|
-
if let named = try? container.decode(String.self) {
|
|
138
|
-
self = .named(named)
|
|
139
|
-
return
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
let preset = try container.decode(GridPreset.self)
|
|
143
|
-
guard let placement = FractionalPlacement(x: preset.x, y: preset.y, w: preset.w, h: preset.h) else {
|
|
144
|
-
throw DecodingError.dataCorruptedError(
|
|
145
|
-
in: container,
|
|
146
|
-
debugDescription: "snap zone trigger fractions must stay within 0...1"
|
|
147
|
-
)
|
|
148
|
-
}
|
|
149
|
-
self = .fractions(placement)
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
func encode(to encoder: Encoder) throws {
|
|
153
|
-
var container = encoder.singleValueContainer()
|
|
154
|
-
switch self {
|
|
155
|
-
case .named(let name):
|
|
156
|
-
try container.encode(name)
|
|
157
|
-
case .fractions(let placement):
|
|
158
|
-
try container.encode(GridPreset(x: placement.x, y: placement.y, w: placement.w, h: placement.h))
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
enum SnapZonePlacementSpec: Codable, Equatable {
|
|
164
|
-
case named(String)
|
|
165
|
-
case fractions(FractionalPlacement)
|
|
166
|
-
|
|
167
|
-
init(from decoder: Decoder) throws {
|
|
168
|
-
let container = try decoder.singleValueContainer()
|
|
169
|
-
if let named = try? container.decode(String.self) {
|
|
170
|
-
self = .named(named)
|
|
171
|
-
return
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
let preset = try container.decode(GridPreset.self)
|
|
175
|
-
guard let placement = FractionalPlacement(x: preset.x, y: preset.y, w: preset.w, h: preset.h) else {
|
|
176
|
-
throw DecodingError.dataCorruptedError(
|
|
177
|
-
in: container,
|
|
178
|
-
debugDescription: "snap zone placement fractions must stay within 0...1"
|
|
179
|
-
)
|
|
180
|
-
}
|
|
181
|
-
self = .fractions(placement)
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
func encode(to encoder: Encoder) throws {
|
|
185
|
-
var container = encoder.singleValueContainer()
|
|
186
|
-
switch self {
|
|
187
|
-
case .named(let name):
|
|
188
|
-
try container.encode(name)
|
|
189
|
-
case .fractions(let placement):
|
|
190
|
-
try container.encode(GridPreset(x: placement.x, y: placement.y, w: placement.w, h: placement.h))
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
struct SnapZoneDefinition: Codable, Equatable, Identifiable {
|
|
196
|
-
let rawID: String?
|
|
197
|
-
let label: String?
|
|
198
|
-
let placement: SnapZonePlacementSpec
|
|
199
|
-
let trigger: SnapZoneTriggerSpec
|
|
200
|
-
let priority: Int?
|
|
201
|
-
|
|
202
|
-
enum CodingKeys: String, CodingKey {
|
|
203
|
-
case rawID = "id"
|
|
204
|
-
case label
|
|
205
|
-
case placement
|
|
206
|
-
case trigger
|
|
207
|
-
case priority
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
var id: String {
|
|
211
|
-
let trimmed = rawID?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
212
|
-
return trimmed.isEmpty ? fallbackID : trimmed
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
private var fallbackID: String {
|
|
216
|
-
switch placement {
|
|
217
|
-
case .named(let name):
|
|
218
|
-
return name
|
|
219
|
-
case .fractions(let fractions):
|
|
220
|
-
return "fractions-\(fractions.x)-\(fractions.y)-\(fractions.w)-\(fractions.h)"
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
struct SnapZonesConfig: Codable, Equatable {
|
|
226
|
-
let enabled: Bool?
|
|
227
|
-
let modifier: SnapModifierKey?
|
|
228
|
-
let zoneOpacity: Double?
|
|
229
|
-
let highlightOpacity: Double?
|
|
230
|
-
let previewOpacity: Double?
|
|
231
|
-
let cornerRadius: CGFloat?
|
|
232
|
-
let rules: [SnapZoneDefinition]?
|
|
233
|
-
|
|
234
|
-
enum CodingKeys: String, CodingKey {
|
|
235
|
-
case enabled
|
|
236
|
-
case modifier
|
|
237
|
-
case zoneOpacity
|
|
238
|
-
case highlightOpacity
|
|
239
|
-
case previewOpacity
|
|
240
|
-
case cornerRadius
|
|
241
|
-
case rules
|
|
242
|
-
case zones
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
init(
|
|
246
|
-
enabled: Bool?,
|
|
247
|
-
modifier: SnapModifierKey?,
|
|
248
|
-
zoneOpacity: Double?,
|
|
249
|
-
highlightOpacity: Double?,
|
|
250
|
-
previewOpacity: Double?,
|
|
251
|
-
cornerRadius: CGFloat?,
|
|
252
|
-
rules: [SnapZoneDefinition]?
|
|
253
|
-
) {
|
|
254
|
-
self.enabled = enabled
|
|
255
|
-
self.modifier = modifier
|
|
256
|
-
self.zoneOpacity = zoneOpacity
|
|
257
|
-
self.highlightOpacity = highlightOpacity
|
|
258
|
-
self.previewOpacity = previewOpacity
|
|
259
|
-
self.cornerRadius = cornerRadius
|
|
260
|
-
self.rules = rules
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
init(from decoder: Decoder) throws {
|
|
264
|
-
let container = try decoder.container(keyedBy: CodingKeys.self)
|
|
265
|
-
enabled = try container.decodeIfPresent(Bool.self, forKey: .enabled)
|
|
266
|
-
modifier = try container.decodeIfPresent(SnapModifierKey.self, forKey: .modifier)
|
|
267
|
-
zoneOpacity = try container.decodeIfPresent(Double.self, forKey: .zoneOpacity)
|
|
268
|
-
highlightOpacity = try container.decodeIfPresent(Double.self, forKey: .highlightOpacity)
|
|
269
|
-
previewOpacity = try container.decodeIfPresent(Double.self, forKey: .previewOpacity)
|
|
270
|
-
cornerRadius = try container.decodeIfPresent(CGFloat.self, forKey: .cornerRadius)
|
|
271
|
-
let decodedRules = try container.decodeIfPresent([SnapZoneDefinition].self, forKey: .rules)
|
|
272
|
-
let decodedZones = try container.decodeIfPresent([SnapZoneDefinition].self, forKey: .zones)
|
|
273
|
-
rules = decodedRules ?? decodedZones
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
func encode(to encoder: Encoder) throws {
|
|
277
|
-
var container = encoder.container(keyedBy: CodingKeys.self)
|
|
278
|
-
try container.encodeIfPresent(enabled, forKey: .enabled)
|
|
279
|
-
try container.encodeIfPresent(modifier, forKey: .modifier)
|
|
280
|
-
try container.encodeIfPresent(zoneOpacity, forKey: .zoneOpacity)
|
|
281
|
-
try container.encodeIfPresent(highlightOpacity, forKey: .highlightOpacity)
|
|
282
|
-
try container.encodeIfPresent(previewOpacity, forKey: .previewOpacity)
|
|
283
|
-
try container.encodeIfPresent(cornerRadius, forKey: .cornerRadius)
|
|
284
|
-
try container.encodeIfPresent(rules, forKey: .rules)
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
static let defaults = SnapZonesConfig(
|
|
288
|
-
enabled: true,
|
|
289
|
-
modifier: .command,
|
|
290
|
-
zoneOpacity: 0.10,
|
|
291
|
-
highlightOpacity: 0.22,
|
|
292
|
-
previewOpacity: 0.18,
|
|
293
|
-
cornerRadius: 18,
|
|
294
|
-
rules: [
|
|
295
|
-
SnapZoneDefinition(
|
|
296
|
-
rawID: "top-left",
|
|
297
|
-
label: "Top Left",
|
|
298
|
-
placement: .named("top-left"),
|
|
299
|
-
trigger: .fractions(FractionalPlacement(x: 0.00, y: 0.00, w: 0.24, h: 0.18)!),
|
|
300
|
-
priority: 40
|
|
301
|
-
),
|
|
302
|
-
SnapZoneDefinition(
|
|
303
|
-
rawID: "maximize",
|
|
304
|
-
label: "Maximize",
|
|
305
|
-
placement: .named("maximize"),
|
|
306
|
-
trigger: .fractions(FractionalPlacement(x: 0.24, y: 0.00, w: 0.52, h: 0.12)!),
|
|
307
|
-
priority: 20
|
|
308
|
-
),
|
|
309
|
-
SnapZoneDefinition(
|
|
310
|
-
rawID: "top-right",
|
|
311
|
-
label: "Top Right",
|
|
312
|
-
placement: .named("top-right"),
|
|
313
|
-
trigger: .fractions(FractionalPlacement(x: 0.76, y: 0.00, w: 0.24, h: 0.18)!),
|
|
314
|
-
priority: 40
|
|
315
|
-
),
|
|
316
|
-
SnapZoneDefinition(
|
|
317
|
-
rawID: "left",
|
|
318
|
-
label: "Left",
|
|
319
|
-
placement: .named("left"),
|
|
320
|
-
trigger: .fractions(FractionalPlacement(x: 0.00, y: 0.18, w: 0.12, h: 0.64)!),
|
|
321
|
-
priority: 10
|
|
322
|
-
),
|
|
323
|
-
SnapZoneDefinition(
|
|
324
|
-
rawID: "right",
|
|
325
|
-
label: "Right",
|
|
326
|
-
placement: .named("right"),
|
|
327
|
-
trigger: .fractions(FractionalPlacement(x: 0.88, y: 0.18, w: 0.12, h: 0.64)!),
|
|
328
|
-
priority: 10
|
|
329
|
-
),
|
|
330
|
-
SnapZoneDefinition(
|
|
331
|
-
rawID: "bottom-left",
|
|
332
|
-
label: "Bottom Left",
|
|
333
|
-
placement: .named("bottom-left"),
|
|
334
|
-
trigger: .fractions(FractionalPlacement(x: 0.00, y: 0.82, w: 0.24, h: 0.18)!),
|
|
335
|
-
priority: 40
|
|
336
|
-
),
|
|
337
|
-
SnapZoneDefinition(
|
|
338
|
-
rawID: "bottom-right",
|
|
339
|
-
label: "Bottom Right",
|
|
340
|
-
placement: .named("bottom-right"),
|
|
341
|
-
trigger: .fractions(FractionalPlacement(x: 0.76, y: 0.82, w: 0.24, h: 0.18)!),
|
|
342
|
-
priority: 40
|
|
343
|
-
),
|
|
344
|
-
]
|
|
345
|
-
)
|
|
346
|
-
|
|
347
|
-
func merged(over defaults: SnapZonesConfig = .defaults) -> SnapZonesConfig {
|
|
348
|
-
SnapZonesConfig(
|
|
349
|
-
enabled: enabled ?? defaults.enabled,
|
|
350
|
-
modifier: modifier ?? defaults.modifier,
|
|
351
|
-
zoneOpacity: zoneOpacity ?? defaults.zoneOpacity,
|
|
352
|
-
highlightOpacity: highlightOpacity ?? defaults.highlightOpacity,
|
|
353
|
-
previewOpacity: previewOpacity ?? defaults.previewOpacity,
|
|
354
|
-
cornerRadius: cornerRadius ?? defaults.cornerRadius,
|
|
355
|
-
rules: rules ?? defaults.rules
|
|
356
|
-
)
|
|
357
|
-
}
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
// MARK: - Manager
|
|
361
|
-
|
|
362
|
-
class WorkspaceManager: ObservableObject {
|
|
363
|
-
static let shared = WorkspaceManager()
|
|
364
|
-
|
|
365
|
-
@Published var config: WorkspaceConfig?
|
|
366
|
-
@Published var activeLayerIndex: Int = 0
|
|
367
|
-
@Published var isSwitching: Bool = false
|
|
368
|
-
@Published var gridPresets: [String: GridPreset] = [:]
|
|
369
|
-
@Published var gridLayouts: [String: LayoutConfig] = [:]
|
|
370
|
-
@Published var snapZonesConfig: SnapZonesConfig = .defaults
|
|
371
|
-
|
|
372
|
-
private let configPath: String
|
|
373
|
-
private let gridConfigPath: String
|
|
374
|
-
private let snapZonesConfigPath: String
|
|
375
|
-
private var tmuxPath: String { TmuxQuery.resolvedPath ?? "/opt/homebrew/bin/tmux" }
|
|
376
|
-
private let activeLayerKey = "lattices.activeLayerIndex"
|
|
377
|
-
|
|
378
|
-
init() {
|
|
379
|
-
let home = FileManager.default.homeDirectoryForCurrentUser.path
|
|
380
|
-
self.configPath = (home as NSString).appendingPathComponent(".lattices/workspace.json")
|
|
381
|
-
self.gridConfigPath = (home as NSString).appendingPathComponent(".lattices/grid.json")
|
|
382
|
-
self.snapZonesConfigPath = (home as NSString).appendingPathComponent(".lattices/snap-zones.json")
|
|
383
|
-
self.activeLayerIndex = UserDefaults.standard.integer(forKey: activeLayerKey)
|
|
384
|
-
loadConfig()
|
|
385
|
-
loadGridConfig()
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
var activeLayer: Layer? {
|
|
389
|
-
guard let config, let layers = config.layers, activeLayerIndex < layers.count else { return nil }
|
|
390
|
-
return layers[activeLayerIndex]
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
/// Look up a layer index by id or label (case-insensitive)
|
|
394
|
-
func layerIndex(named name: String) -> Int? {
|
|
395
|
-
guard let layers = config?.layers else { return nil }
|
|
396
|
-
// Try exact id match first
|
|
397
|
-
if let i = layers.firstIndex(where: { $0.id == name }) { return i }
|
|
398
|
-
// Then case-insensitive id
|
|
399
|
-
if let i = layers.firstIndex(where: { $0.id.localizedCaseInsensitiveCompare(name) == .orderedSame }) { return i }
|
|
400
|
-
// Then case-insensitive label
|
|
401
|
-
if let i = layers.firstIndex(where: { $0.label.localizedCaseInsensitiveCompare(name) == .orderedSame }) { return i }
|
|
402
|
-
return nil
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
// MARK: - Config I/O
|
|
406
|
-
|
|
407
|
-
func loadConfig() {
|
|
408
|
-
guard FileManager.default.fileExists(atPath: configPath),
|
|
409
|
-
let data = FileManager.default.contents(atPath: configPath) else {
|
|
410
|
-
config = nil
|
|
411
|
-
return
|
|
412
|
-
}
|
|
413
|
-
do {
|
|
414
|
-
config = try JSONDecoder().decode(WorkspaceConfig.self, from: data)
|
|
415
|
-
// Clamp saved index
|
|
416
|
-
if let config, let layers = config.layers, activeLayerIndex >= layers.count {
|
|
417
|
-
activeLayerIndex = 0
|
|
418
|
-
}
|
|
419
|
-
} catch {
|
|
420
|
-
DiagnosticLog.shared.error("WorkspaceManager: failed to decode workspace.json — \(error.localizedDescription)")
|
|
421
|
-
config = nil
|
|
422
|
-
}
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
func reloadConfig() {
|
|
426
|
-
loadConfig()
|
|
427
|
-
loadGridConfig()
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
// MARK: - Grid Config I/O
|
|
431
|
-
|
|
432
|
-
func loadGridConfig() {
|
|
433
|
-
var presets: [String: GridPreset] = [:]
|
|
434
|
-
var layouts: [String: LayoutConfig] = [:]
|
|
435
|
-
var snapZones = SnapZonesConfig.defaults
|
|
436
|
-
|
|
437
|
-
// Load global ~/.lattices/grid.json
|
|
438
|
-
if FileManager.default.fileExists(atPath: gridConfigPath),
|
|
439
|
-
let data = FileManager.default.contents(atPath: gridConfigPath) {
|
|
440
|
-
do {
|
|
441
|
-
let gridFile = try JSONDecoder().decode(GridFile.self, from: data)
|
|
442
|
-
if let p = gridFile.presets { presets.merge(p) { _, new in new } }
|
|
443
|
-
if let l = gridFile.layouts { layouts.merge(l) { _, new in new } }
|
|
444
|
-
if let snap = gridFile.snapZones {
|
|
445
|
-
snapZones = snap.merged(over: snapZones)
|
|
446
|
-
}
|
|
447
|
-
} catch {
|
|
448
|
-
DiagnosticLog.shared.error("WorkspaceManager: failed to decode grid.json — \(error.localizedDescription)")
|
|
449
|
-
}
|
|
450
|
-
}
|
|
451
|
-
|
|
452
|
-
if FileManager.default.fileExists(atPath: snapZonesConfigPath),
|
|
453
|
-
let data = FileManager.default.contents(atPath: snapZonesConfigPath) {
|
|
454
|
-
do {
|
|
455
|
-
let config = try JSONDecoder().decode(SnapZonesConfig.self, from: data)
|
|
456
|
-
snapZones = config.merged(over: snapZones)
|
|
457
|
-
} catch {
|
|
458
|
-
DiagnosticLog.shared.error("WorkspaceManager: failed to decode snap-zones.json — \(error.localizedDescription)")
|
|
459
|
-
}
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
// Merge per-project .lattices.json "grid" section on top
|
|
463
|
-
let projectGridPath = ".lattices.json"
|
|
464
|
-
if FileManager.default.fileExists(atPath: projectGridPath),
|
|
465
|
-
let data = FileManager.default.contents(atPath: projectGridPath) {
|
|
466
|
-
do {
|
|
467
|
-
if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
468
|
-
let gridDict = json["grid"] {
|
|
469
|
-
let gridData = try JSONSerialization.data(withJSONObject: gridDict)
|
|
470
|
-
let gridFile = try JSONDecoder().decode(GridFile.self, from: gridData)
|
|
471
|
-
if let p = gridFile.presets { presets.merge(p) { _, new in new } }
|
|
472
|
-
if let l = gridFile.layouts { layouts.merge(l) { _, new in new } }
|
|
473
|
-
if let snap = gridFile.snapZones {
|
|
474
|
-
snapZones = snap.merged(over: snapZones)
|
|
475
|
-
}
|
|
476
|
-
}
|
|
477
|
-
} catch {
|
|
478
|
-
DiagnosticLog.shared.error("WorkspaceManager: failed to decode .lattices.json grid — \(error.localizedDescription)")
|
|
479
|
-
}
|
|
480
|
-
}
|
|
481
|
-
|
|
482
|
-
self.gridPresets = presets
|
|
483
|
-
self.gridLayouts = layouts
|
|
484
|
-
self.snapZonesConfig = snapZones
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
func updateSnapModifier(_ modifier: SnapModifierKey) {
|
|
488
|
-
let updated = SnapZonesConfig(
|
|
489
|
-
enabled: snapZonesConfig.enabled,
|
|
490
|
-
modifier: modifier,
|
|
491
|
-
zoneOpacity: snapZonesConfig.zoneOpacity,
|
|
492
|
-
highlightOpacity: snapZonesConfig.highlightOpacity,
|
|
493
|
-
previewOpacity: snapZonesConfig.previewOpacity,
|
|
494
|
-
cornerRadius: snapZonesConfig.cornerRadius,
|
|
495
|
-
rules: snapZonesConfig.rules
|
|
496
|
-
)
|
|
497
|
-
|
|
498
|
-
do {
|
|
499
|
-
let url = URL(fileURLWithPath: snapZonesConfigPath)
|
|
500
|
-
try FileManager.default.createDirectory(
|
|
501
|
-
at: url.deletingLastPathComponent(),
|
|
502
|
-
withIntermediateDirectories: true
|
|
503
|
-
)
|
|
504
|
-
|
|
505
|
-
let encoder = JSONEncoder()
|
|
506
|
-
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
|
507
|
-
let data = try encoder.encode(updated)
|
|
508
|
-
try data.write(to: url, options: .atomic)
|
|
509
|
-
|
|
510
|
-
loadGridConfig()
|
|
511
|
-
DiagnosticLog.shared.info("WorkspaceManager: updated snap modifier to \(modifier.rawValue)")
|
|
512
|
-
} catch {
|
|
513
|
-
DiagnosticLog.shared.error("WorkspaceManager: failed to write snap-zones.json — \(error.localizedDescription)")
|
|
514
|
-
}
|
|
515
|
-
}
|
|
516
|
-
|
|
517
|
-
/// Resolve a tile string to fractions: check user presets first, then built-in TilePosition
|
|
518
|
-
func resolveTileFractions(_ tile: String) -> (CGFloat, CGFloat, CGFloat, CGFloat)? {
|
|
519
|
-
resolvePlacement(tile)?.fractions
|
|
520
|
-
}
|
|
521
|
-
|
|
522
|
-
func resolvePlacement(_ tile: String) -> PlacementSpec? {
|
|
523
|
-
if let preset = gridPresets[tile],
|
|
524
|
-
let placement = FractionalPlacement(x: preset.x, y: preset.y, w: preset.w, h: preset.h) {
|
|
525
|
-
return .fractions(placement)
|
|
526
|
-
}
|
|
527
|
-
return PlacementSpec(string: tile)
|
|
528
|
-
}
|
|
529
|
-
|
|
530
|
-
// MARK: - Tab Groups
|
|
531
|
-
|
|
532
|
-
func group(byId id: String) -> TabGroup? {
|
|
533
|
-
config?.groups?.first(where: { $0.id == id })
|
|
534
|
-
}
|
|
535
|
-
|
|
536
|
-
func isGroupRunning(_ group: TabGroup) -> Bool {
|
|
537
|
-
group.tabs.contains { tab in
|
|
538
|
-
let name = Self.sessionName(for: tab.path)
|
|
539
|
-
return shell([tmuxPath, "has-session", "-t", name]) == 0
|
|
540
|
-
}
|
|
541
|
-
}
|
|
542
|
-
|
|
543
|
-
/// Count how many tabs in the group have running sessions
|
|
544
|
-
func runningTabCount(_ group: TabGroup) -> Int {
|
|
545
|
-
group.tabs.filter { tab in
|
|
546
|
-
let name = Self.sessionName(for: tab.path)
|
|
547
|
-
return shell([tmuxPath, "has-session", "-t", name]) == 0
|
|
548
|
-
}.count
|
|
549
|
-
}
|
|
550
|
-
|
|
551
|
-
/// Launch a group by opening each tab as a separate iTerm/Terminal tab
|
|
552
|
-
func launchGroup(_ group: TabGroup) {
|
|
553
|
-
let terminal = Preferences.shared.terminal
|
|
554
|
-
for (i, tab) in group.tabs.enumerated() {
|
|
555
|
-
let label = tab.label ?? (tab.path as NSString).lastPathComponent
|
|
556
|
-
DispatchQueue.main.asyncAfter(deadline: .now() + Double(i) * 0.4) {
|
|
557
|
-
if i == 0 {
|
|
558
|
-
terminal.launch(command: "/opt/homebrew/bin/lattices start", in: tab.path)
|
|
559
|
-
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
|
|
560
|
-
terminal.nameTab(label)
|
|
561
|
-
}
|
|
562
|
-
} else {
|
|
563
|
-
terminal.launchTab(command: "/opt/homebrew/bin/lattices start", in: tab.path, tabName: label)
|
|
564
|
-
}
|
|
565
|
-
}
|
|
566
|
-
}
|
|
567
|
-
}
|
|
568
|
-
|
|
569
|
-
/// Kill all individual tab sessions for a group
|
|
570
|
-
func killGroup(_ group: TabGroup) {
|
|
571
|
-
for tab in group.tabs {
|
|
572
|
-
let name = Self.sessionName(for: tab.path)
|
|
573
|
-
let task = Process()
|
|
574
|
-
task.executableURL = URL(fileURLWithPath: tmuxPath)
|
|
575
|
-
task.arguments = ["kill-session", "-t", name]
|
|
576
|
-
task.standardOutput = FileHandle.nullDevice
|
|
577
|
-
task.standardError = FileHandle.nullDevice
|
|
578
|
-
try? task.run()
|
|
579
|
-
task.waitUntilExit()
|
|
580
|
-
}
|
|
581
|
-
}
|
|
582
|
-
|
|
583
|
-
/// Focus a specific tab's session in the terminal
|
|
584
|
-
func focusTab(group: TabGroup, tabIndex: Int) {
|
|
585
|
-
guard tabIndex >= 0, tabIndex < group.tabs.count else { return }
|
|
586
|
-
let tab = group.tabs[tabIndex]
|
|
587
|
-
let sessionName = Self.sessionName(for: tab.path)
|
|
588
|
-
let terminal = Preferences.shared.terminal
|
|
589
|
-
terminal.focusOrAttach(session: sessionName)
|
|
590
|
-
}
|
|
591
|
-
|
|
592
|
-
/// Run a command and return exit code
|
|
593
|
-
private func shell(_ args: [String]) -> Int32 {
|
|
594
|
-
let task = Process()
|
|
595
|
-
task.executableURL = URL(fileURLWithPath: args[0])
|
|
596
|
-
task.arguments = Array(args.dropFirst())
|
|
597
|
-
task.standardOutput = FileHandle.nullDevice
|
|
598
|
-
task.standardError = FileHandle.nullDevice
|
|
599
|
-
try? task.run()
|
|
600
|
-
task.waitUntilExit()
|
|
601
|
-
return task.terminationStatus
|
|
602
|
-
}
|
|
603
|
-
|
|
604
|
-
// MARK: - Display Helper
|
|
605
|
-
|
|
606
|
-
/// Resolve a display index to an NSScreen (falls back to first screen)
|
|
607
|
-
private func screen(for displayIndex: Int?) -> NSScreen? {
|
|
608
|
-
let screens = NSScreen.screens
|
|
609
|
-
guard !screens.isEmpty else { return nil }
|
|
610
|
-
let idx = displayIndex ?? 0
|
|
611
|
-
return idx < screens.count ? screens[idx] : screens[0]
|
|
612
|
-
}
|
|
613
|
-
|
|
614
|
-
// MARK: - Window Lookup
|
|
615
|
-
|
|
616
|
-
/// Find a tracked window for a session name (instant — uses DesktopModel cache)
|
|
617
|
-
private func windowForSession(_ sessionName: String) -> WindowEntry? {
|
|
618
|
-
DesktopModel.shared.windowForSession(sessionName)
|
|
619
|
-
}
|
|
620
|
-
|
|
621
|
-
/// Resolve a session name to a tile target: (wid, pid, frame).
|
|
622
|
-
/// Returns nil if the window isn't tracked or has no tile position.
|
|
623
|
-
private func batchTarget(session: String, position: PlacementSpec, screen: NSScreen) -> (wid: UInt32, pid: Int32, frame: CGRect)? {
|
|
624
|
-
guard let entry = windowForSession(session) else { return nil }
|
|
625
|
-
let frame = WindowTiler.tileFrame(for: position, on: screen)
|
|
626
|
-
return (entry.wid, entry.pid, frame)
|
|
627
|
-
}
|
|
628
|
-
|
|
629
|
-
// MARK: - Tiling
|
|
630
|
-
|
|
631
|
-
/// Re-tile the current layer without switching (for "tile all")
|
|
632
|
-
func retileCurrentLayer() {
|
|
633
|
-
tileLayer(index: activeLayerIndex, launch: false, force: true)
|
|
634
|
-
}
|
|
635
|
-
|
|
636
|
-
/// Count running projects+groups in a layer
|
|
637
|
-
func layerRunningCount(index: Int) -> (running: Int, total: Int) {
|
|
638
|
-
guard let config, let layers = config.layers, index < layers.count else { return (0, 0) }
|
|
639
|
-
let layer = layers[index]
|
|
640
|
-
let scanner = ProjectScanner.shared
|
|
641
|
-
var running = 0
|
|
642
|
-
let total = layer.projects.count
|
|
643
|
-
|
|
644
|
-
for lp in layer.projects {
|
|
645
|
-
if let groupId = lp.group, let group = group(byId: groupId) {
|
|
646
|
-
if isGroupRunning(group) { running += 1 }
|
|
647
|
-
} else if let appName = lp.app {
|
|
648
|
-
if DesktopModel.shared.windowForApp(app: appName, title: lp.title) != nil { running += 1 }
|
|
649
|
-
} else if let path = lp.path {
|
|
650
|
-
let project = scanner.projects.first(where: { $0.path == path })
|
|
651
|
-
if project?.isRunning == true { running += 1 }
|
|
652
|
-
}
|
|
653
|
-
}
|
|
654
|
-
return (running, total)
|
|
655
|
-
}
|
|
656
|
-
|
|
657
|
-
// MARK: - Layer Focus (raise only)
|
|
658
|
-
|
|
659
|
-
/// Switch to a layer by raising all its windows in place — no launching, no tiling, no moving.
|
|
660
|
-
/// This is the default hotkey action: just bring the layer's windows to the front.
|
|
661
|
-
func focusLayer(index: Int) {
|
|
662
|
-
guard let config, let layers = config.layers, index < layers.count else { return }
|
|
663
|
-
if index == activeLayerIndex { return }
|
|
664
|
-
|
|
665
|
-
let diag = DiagnosticLog.shared
|
|
666
|
-
let t = diag.startTimed("focusLayer \(activeLayerIndex)→\(index)")
|
|
667
|
-
|
|
668
|
-
DesktopModel.shared.poll()
|
|
669
|
-
|
|
670
|
-
let targetLayer = layers[index]
|
|
671
|
-
var windowsToRaise: [(wid: UInt32, pid: Int32)] = []
|
|
672
|
-
|
|
673
|
-
for lp in targetLayer.projects {
|
|
674
|
-
if let groupId = lp.group, let grp = group(byId: groupId) {
|
|
675
|
-
// Raise all tab windows in the group
|
|
676
|
-
for tab in grp.tabs {
|
|
677
|
-
let sessionName = Self.sessionName(for: tab.path)
|
|
678
|
-
if let entry = windowForSession(sessionName) {
|
|
679
|
-
windowsToRaise.append((entry.wid, entry.pid))
|
|
680
|
-
}
|
|
681
|
-
}
|
|
682
|
-
continue
|
|
683
|
-
}
|
|
684
|
-
|
|
685
|
-
if let appName = lp.app {
|
|
686
|
-
if let entry = DesktopModel.shared.windowForApp(app: appName, title: lp.title) {
|
|
687
|
-
windowsToRaise.append((entry.wid, entry.pid))
|
|
688
|
-
}
|
|
689
|
-
continue
|
|
690
|
-
}
|
|
691
|
-
|
|
692
|
-
guard let path = lp.path else { continue }
|
|
693
|
-
let sessionName = Self.sessionName(for: path)
|
|
694
|
-
if let entry = windowForSession(sessionName) {
|
|
695
|
-
windowsToRaise.append((entry.wid, entry.pid))
|
|
696
|
-
}
|
|
697
|
-
|
|
698
|
-
// Also raise companion windows
|
|
699
|
-
let companions = projectWindows(at: path)
|
|
700
|
-
for cw in companions {
|
|
701
|
-
guard let appName = cw.app else { continue }
|
|
702
|
-
if let entry = DesktopModel.shared.windowForApp(app: appName, title: cw.title) {
|
|
703
|
-
windowsToRaise.append((entry.wid, entry.pid))
|
|
704
|
-
}
|
|
705
|
-
}
|
|
706
|
-
}
|
|
707
|
-
|
|
708
|
-
if !windowsToRaise.isEmpty {
|
|
709
|
-
WindowTiler.raiseWindowsAndReactivate(windows: windowsToRaise)
|
|
710
|
-
}
|
|
711
|
-
|
|
712
|
-
activeLayerIndex = index
|
|
713
|
-
UserDefaults.standard.set(index, forKey: activeLayerKey)
|
|
714
|
-
|
|
715
|
-
let allLabels = layers.map(\.label)
|
|
716
|
-
LayerBezel.shared.show(label: targetLayer.label, index: index, total: layers.count, allLabels: allLabels)
|
|
717
|
-
HandsOffSession.shared.playCachedCue("Switched.")
|
|
718
|
-
|
|
719
|
-
diag.finish(t)
|
|
720
|
-
}
|
|
721
|
-
|
|
722
|
-
// MARK: - Unified Layer Tiling
|
|
723
|
-
|
|
724
|
-
/// Unified entry point for arranging a layer's windows.
|
|
725
|
-
///
|
|
726
|
-
/// | launch | force | Behavior |
|
|
727
|
-
/// |--------|-------|----------|
|
|
728
|
-
/// | false | false | Tile running projects only (focus) |
|
|
729
|
-
/// | true | false | Launch stopped + tile all, skip if same layer |
|
|
730
|
-
/// | true | true | Re-launch current layer |
|
|
731
|
-
/// | false | true | Re-tile current layer |
|
|
732
|
-
func tileLayer(index: Int, launch: Bool = false, force: Bool = false) {
|
|
733
|
-
guard let config, let layers = config.layers, index < layers.count else { return }
|
|
734
|
-
if launch && !force && index == activeLayerIndex { return }
|
|
735
|
-
|
|
736
|
-
let diag = DiagnosticLog.shared
|
|
737
|
-
let label = launch ? "tileLayer(launch)" : "tileLayer(focus)"
|
|
738
|
-
let overall = diag.startTimed("\(label) \(activeLayerIndex)→\(index)")
|
|
739
|
-
|
|
740
|
-
isSwitching = true
|
|
741
|
-
let terminal = Preferences.shared.terminal
|
|
742
|
-
let scanner = ProjectScanner.shared
|
|
743
|
-
let targetLayer = layers[index]
|
|
744
|
-
|
|
745
|
-
// Fresh poll so we see windows on all Spaces before matching
|
|
746
|
-
DesktopModel.shared.poll()
|
|
747
|
-
|
|
748
|
-
// Tile debug log (written to ~/.lattices/tile-debug.log)
|
|
749
|
-
let debugPath = (FileManager.default.homeDirectoryForCurrentUser.path as NSString).appendingPathComponent(".lattices/tile-debug.log")
|
|
750
|
-
var debugLines: [String] = ["tileLayer index=\(index) launch=\(launch) force=\(force) layer=\(targetLayer.id)"]
|
|
751
|
-
|
|
752
|
-
// Phase 1: classify each project
|
|
753
|
-
var batchMoves: [(wid: UInt32, pid: Int32, frame: CGRect)] = []
|
|
754
|
-
var fallbacks: [(session: String, position: PlacementSpec, screen: NSScreen)] = []
|
|
755
|
-
var launchQueue: [(session: String, position: PlacementSpec?, screen: NSScreen, launchAction: () -> Void)] = []
|
|
756
|
-
|
|
757
|
-
// Log screen info
|
|
758
|
-
for (i, s) in NSScreen.screens.enumerated() {
|
|
759
|
-
debugLines.append("screen[\(i)]: frame=\(s.frame) visible=\(s.visibleFrame)")
|
|
760
|
-
}
|
|
761
|
-
|
|
762
|
-
for lp in targetLayer.projects {
|
|
763
|
-
guard let lpScreen = screen(for: lp.display) else { continue }
|
|
764
|
-
|
|
765
|
-
if let groupId = lp.group, let grp = group(byId: groupId) {
|
|
766
|
-
let firstTabSession = grp.tabs.first.map { Self.sessionName(for: $0.path) } ?? ""
|
|
767
|
-
let position = lp.tile.flatMap { resolvePlacement($0) }
|
|
768
|
-
let groupRunning = isGroupRunning(grp)
|
|
769
|
-
|
|
770
|
-
if groupRunning, let pos = position,
|
|
771
|
-
let target = batchTarget(session: firstTabSession, position: pos, screen: lpScreen) {
|
|
772
|
-
batchMoves.append(target)
|
|
773
|
-
} else if !groupRunning && launch {
|
|
774
|
-
diag.info(" launch group: \(grp.label)")
|
|
775
|
-
launchQueue.append((firstTabSession, position, lpScreen, { [weak self] in
|
|
776
|
-
self?.launchGroup(grp)
|
|
777
|
-
}))
|
|
778
|
-
} else if groupRunning, let pos = position {
|
|
779
|
-
// Running but not in DesktopModel — fallback
|
|
780
|
-
fallbacks.append((firstTabSession, pos, lpScreen))
|
|
781
|
-
} else if !groupRunning {
|
|
782
|
-
diag.info(" skip (not running): \(grp.label)")
|
|
783
|
-
}
|
|
784
|
-
continue
|
|
785
|
-
}
|
|
786
|
-
|
|
787
|
-
// App-based window matching
|
|
788
|
-
if let appName = lp.app {
|
|
789
|
-
let position = lp.tile.flatMap { resolvePlacement($0) }
|
|
790
|
-
if let entry = DesktopModel.shared.windowForApp(app: appName, title: lp.title) {
|
|
791
|
-
if let pos = position {
|
|
792
|
-
let frame = WindowTiler.tileFrame(for: pos, on: lpScreen)
|
|
793
|
-
batchMoves.append((entry.wid, entry.pid, frame))
|
|
794
|
-
}
|
|
795
|
-
} else if let found = Self.findAppWindow(app: appName, title: lp.title) {
|
|
796
|
-
// Window exists but wasn't in DesktopModel (e.g. different Space) — tile it
|
|
797
|
-
diag.info(" found app via CGWindowList fallback: \(appName) wid=\(found.wid)")
|
|
798
|
-
if let pos = position {
|
|
799
|
-
let frame = WindowTiler.tileFrame(for: pos, on: lpScreen)
|
|
800
|
-
batchMoves.append((found.wid, found.pid, frame))
|
|
801
|
-
}
|
|
802
|
-
} else if launch {
|
|
803
|
-
diag.info(" launch app: \(appName)")
|
|
804
|
-
let capturedLp = lp
|
|
805
|
-
let capturedScreen = lpScreen
|
|
806
|
-
launchQueue.append(("app:\(appName)", nil, capturedScreen, { [weak self] in
|
|
807
|
-
self?.launchAppEntry(capturedLp)
|
|
808
|
-
}))
|
|
809
|
-
// Queue a delayed tile after launch
|
|
810
|
-
if let pos = position {
|
|
811
|
-
let capturedTitle = lp.title
|
|
812
|
-
let delay = Double(launchQueue.count) * 0.5 + 1.0
|
|
813
|
-
DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
|
|
814
|
-
DesktopModel.shared.poll()
|
|
815
|
-
if let entry = DesktopModel.shared.windowForApp(app: appName, title: capturedTitle) {
|
|
816
|
-
let frame = WindowTiler.tileFrame(for: pos, on: capturedScreen)
|
|
817
|
-
WindowTiler.batchMoveAndRaiseWindows([(entry.wid, entry.pid, frame)])
|
|
818
|
-
}
|
|
819
|
-
}
|
|
820
|
-
}
|
|
821
|
-
} else {
|
|
822
|
-
diag.info(" skip (not found): \(appName)")
|
|
823
|
-
}
|
|
824
|
-
continue
|
|
825
|
-
}
|
|
826
|
-
|
|
827
|
-
guard let path = lp.path else { continue }
|
|
828
|
-
let sessionName = Self.sessionName(for: path)
|
|
829
|
-
let project = scanner.projects.first(where: { $0.path == path })
|
|
830
|
-
let position = lp.tile.flatMap { resolvePlacement($0) }
|
|
831
|
-
// Check scanner first, fall back to direct tmux check for projects without .lattices.json
|
|
832
|
-
let isRunning = project?.isRunning == true || shell([tmuxPath, "has-session", "-t", sessionName]) == 0
|
|
833
|
-
|
|
834
|
-
if isRunning {
|
|
835
|
-
let foundWindow = windowForSession(sessionName)
|
|
836
|
-
let msg = " \(sessionName): running=\(isRunning) window=\(foundWindow?.wid ?? 0) tile=\(position?.wireValue ?? "nil") desktopCount=\(DesktopModel.shared.windows.count)"
|
|
837
|
-
diag.info(msg)
|
|
838
|
-
debugLines.append(msg)
|
|
839
|
-
if let pos = position,
|
|
840
|
-
let target = batchTarget(session: sessionName, position: pos, screen: lpScreen) {
|
|
841
|
-
batchMoves.append(target)
|
|
842
|
-
debugLines.append(" → batch move wid=\(target.wid) frame=\(target.frame)")
|
|
843
|
-
} else if let pos = position {
|
|
844
|
-
fallbacks.append((sessionName, pos, lpScreen))
|
|
845
|
-
debugLines.append(" → fallback \(pos.wireValue)")
|
|
846
|
-
}
|
|
847
|
-
} else if launch {
|
|
848
|
-
if let project {
|
|
849
|
-
let t = diag.startTimed("launch: \(project.name)")
|
|
850
|
-
SessionManager.launch(project: project)
|
|
851
|
-
diag.finish(t)
|
|
852
|
-
} else {
|
|
853
|
-
diag.info(" launch (direct): \(sessionName)")
|
|
854
|
-
terminal.launch(command: "/opt/homebrew/bin/lattices start", in: path)
|
|
855
|
-
}
|
|
856
|
-
launchQueue.append((sessionName, position, lpScreen, {}))
|
|
857
|
-
} else {
|
|
858
|
-
diag.info(" skip (not running): \(sessionName)")
|
|
859
|
-
}
|
|
860
|
-
|
|
861
|
-
// Compose companion windows from project's .lattices.json "windows" array
|
|
862
|
-
let companions = projectWindows(at: path)
|
|
863
|
-
for cw in companions {
|
|
864
|
-
guard let appName = cw.app else { continue }
|
|
865
|
-
let cwScreen = screen(for: cw.display ?? lp.display) ?? lpScreen
|
|
866
|
-
let cwPosition = cw.tile.flatMap { resolvePlacement($0) }
|
|
867
|
-
if let entry = DesktopModel.shared.windowForApp(app: appName, title: cw.title) {
|
|
868
|
-
if let pos = cwPosition {
|
|
869
|
-
let frame = WindowTiler.tileFrame(for: pos, on: cwScreen)
|
|
870
|
-
batchMoves.append((entry.wid, entry.pid, frame))
|
|
871
|
-
}
|
|
872
|
-
} else if launch {
|
|
873
|
-
diag.info(" launch companion: \(appName)")
|
|
874
|
-
let capturedCw = cw
|
|
875
|
-
launchQueue.append(("app:\(appName)", nil, cwScreen, { [weak self] in
|
|
876
|
-
self?.launchAppEntry(capturedCw)
|
|
877
|
-
}))
|
|
878
|
-
if let pos = cwPosition {
|
|
879
|
-
let capturedTitle = cw.title
|
|
880
|
-
let capturedScreen = cwScreen
|
|
881
|
-
let delay = Double(launchQueue.count) * 0.5 + 1.0
|
|
882
|
-
DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
|
|
883
|
-
DesktopModel.shared.poll()
|
|
884
|
-
if let entry = DesktopModel.shared.windowForApp(app: appName, title: capturedTitle) {
|
|
885
|
-
let frame = WindowTiler.tileFrame(for: pos, on: capturedScreen)
|
|
886
|
-
WindowTiler.batchMoveAndRaiseWindows([(entry.wid, entry.pid, frame)])
|
|
887
|
-
}
|
|
888
|
-
}
|
|
889
|
-
}
|
|
890
|
-
}
|
|
891
|
-
}
|
|
892
|
-
}
|
|
893
|
-
|
|
894
|
-
// Write debug log
|
|
895
|
-
debugLines.append("batchMoves=\(batchMoves.count) fallbacks=\(fallbacks.count) launchQueue=\(launchQueue.count)")
|
|
896
|
-
try? debugLines.joined(separator: "\n").write(toFile: debugPath, atomically: true, encoding: .utf8)
|
|
897
|
-
|
|
898
|
-
// Phase 2: batch tile all tracked windows
|
|
899
|
-
if !batchMoves.isEmpty {
|
|
900
|
-
let t = diag.startTimed("batch tile \(batchMoves.count) windows")
|
|
901
|
-
WindowTiler.batchMoveAndRaiseWindows(batchMoves)
|
|
902
|
-
diag.finish(t)
|
|
903
|
-
}
|
|
904
|
-
|
|
905
|
-
// Phase 3: fallback for running-but-untracked windows
|
|
906
|
-
for (i, fb) in fallbacks.enumerated() {
|
|
907
|
-
let delay = Double(i) * 0.15 + 0.1
|
|
908
|
-
DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
|
|
909
|
-
diag.info(" tile fallback: \(fb.session) → \(fb.position.wireValue)")
|
|
910
|
-
WindowTiler.navigateToWindow(session: fb.session, terminal: terminal)
|
|
911
|
-
WindowTiler.tile(session: fb.session, terminal: terminal, to: fb.position, on: fb.screen)
|
|
912
|
-
}
|
|
913
|
-
}
|
|
914
|
-
|
|
915
|
-
// Phase 4: staggered tile for newly-launched windows
|
|
916
|
-
for (i, item) in launchQueue.enumerated() {
|
|
917
|
-
let delay = Double(i) * 0.15 + 0.2
|
|
918
|
-
DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
|
|
919
|
-
item.launchAction()
|
|
920
|
-
if let pos = item.position {
|
|
921
|
-
let t = diag.startTimed("tile launched: \(item.session) → \(pos.wireValue)")
|
|
922
|
-
WindowTiler.tile(session: item.session, terminal: terminal, to: pos, on: item.screen)
|
|
923
|
-
diag.finish(t)
|
|
924
|
-
}
|
|
925
|
-
}
|
|
926
|
-
}
|
|
927
|
-
|
|
928
|
-
activeLayerIndex = index
|
|
929
|
-
UserDefaults.standard.set(index, forKey: activeLayerKey)
|
|
930
|
-
|
|
931
|
-
// Show layer bezel
|
|
932
|
-
let totalLayers = layers.count
|
|
933
|
-
let allLabels = layers.map(\.label)
|
|
934
|
-
LayerBezel.shared.show(label: targetLayer.label, index: index, total: totalLayers, allLabels: allLabels)
|
|
935
|
-
|
|
936
|
-
let maxDelay = max(
|
|
937
|
-
fallbacks.isEmpty ? 0.0 : Double(fallbacks.count) * 0.15 + 0.3,
|
|
938
|
-
launchQueue.isEmpty ? 0.0 : Double(launchQueue.count) * 0.15 + 0.5
|
|
939
|
-
)
|
|
940
|
-
let cleanupDelay = max(0.2, maxDelay)
|
|
941
|
-
DispatchQueue.main.asyncAfter(deadline: .now() + cleanupDelay) {
|
|
942
|
-
scanner.refreshStatus()
|
|
943
|
-
self.isSwitching = false
|
|
944
|
-
diag.finish(overall)
|
|
945
|
-
}
|
|
946
|
-
}
|
|
947
|
-
|
|
948
|
-
// MARK: - Per-Project Window Config
|
|
949
|
-
|
|
950
|
-
/// Read companion window entries from a project's .lattices.json "windows" array
|
|
951
|
-
func projectWindows(at projectPath: String) -> [LayerProject] {
|
|
952
|
-
let configPath = (projectPath as NSString).appendingPathComponent(".lattices.json")
|
|
953
|
-
guard let data = FileManager.default.contents(atPath: configPath),
|
|
954
|
-
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
955
|
-
let windowsArray = json["windows"] else { return [] }
|
|
956
|
-
do {
|
|
957
|
-
let windowsData = try JSONSerialization.data(withJSONObject: windowsArray)
|
|
958
|
-
return try JSONDecoder().decode([LayerProject].self, from: windowsData)
|
|
959
|
-
} catch {
|
|
960
|
-
DiagnosticLog.shared.error("WorkspaceManager: failed to decode windows in \(configPath) — \(error.localizedDescription)")
|
|
961
|
-
return []
|
|
962
|
-
}
|
|
963
|
-
}
|
|
964
|
-
|
|
965
|
-
// MARK: - App Launch Helper
|
|
966
|
-
|
|
967
|
-
/// Launch an app-based layer project (open URL or launch app by name)
|
|
968
|
-
private func launchAppEntry(_ lp: LayerProject) {
|
|
969
|
-
if let urlStr = lp.url, let url = URL(string: urlStr) {
|
|
970
|
-
NSWorkspace.shared.open(url)
|
|
971
|
-
} else if let appName = lp.launch ?? lp.app {
|
|
972
|
-
let task = Process()
|
|
973
|
-
task.executableURL = URL(fileURLWithPath: "/usr/bin/open")
|
|
974
|
-
task.arguments = ["-a", appName]
|
|
975
|
-
task.standardOutput = FileHandle.nullDevice
|
|
976
|
-
task.standardError = FileHandle.nullDevice
|
|
977
|
-
try? task.run()
|
|
978
|
-
}
|
|
979
|
-
}
|
|
980
|
-
|
|
981
|
-
// MARK: - App Window Fallback (CGWindowList .optionAll)
|
|
982
|
-
|
|
983
|
-
/// Find an app window across ALL Spaces via CGWindowList (bypasses DesktopModel cache)
|
|
984
|
-
static func findAppWindow(app: String, title: String?) -> (wid: UInt32, pid: Int32)? {
|
|
985
|
-
guard let list = CGWindowListCopyWindowInfo(
|
|
986
|
-
[.optionAll, .excludeDesktopElements],
|
|
987
|
-
kCGNullWindowID
|
|
988
|
-
) as? [[String: Any]] else { return nil }
|
|
989
|
-
|
|
990
|
-
for info in list {
|
|
991
|
-
guard let ownerName = info[kCGWindowOwnerName as String] as? String,
|
|
992
|
-
ownerName.localizedCaseInsensitiveContains(app),
|
|
993
|
-
let wid = info[kCGWindowNumber as String] as? UInt32,
|
|
994
|
-
let pid = info[kCGWindowOwnerPID as String] as? Int32,
|
|
995
|
-
let layer = info[kCGWindowLayer as String] as? Int, layer == 0,
|
|
996
|
-
let boundsDict = info[kCGWindowBounds as String] as? NSDictionary
|
|
997
|
-
else { continue }
|
|
998
|
-
|
|
999
|
-
var rect = CGRect.zero
|
|
1000
|
-
guard CGRectMakeWithDictionaryRepresentation(boundsDict, &rect),
|
|
1001
|
-
rect.width >= 50, rect.height >= 50 else { continue }
|
|
1002
|
-
|
|
1003
|
-
if let title {
|
|
1004
|
-
let windowTitle = info[kCGWindowName as String] as? String ?? ""
|
|
1005
|
-
guard windowTitle.localizedCaseInsensitiveContains(title) else { continue }
|
|
1006
|
-
}
|
|
1007
|
-
|
|
1008
|
-
return (wid, pid)
|
|
1009
|
-
}
|
|
1010
|
-
return nil
|
|
1011
|
-
}
|
|
1012
|
-
|
|
1013
|
-
// MARK: - Session Name Helper
|
|
1014
|
-
|
|
1015
|
-
/// Replicates Project.sessionName logic from a bare path
|
|
1016
|
-
static func sessionName(for path: String) -> String {
|
|
1017
|
-
let name = (path as NSString).lastPathComponent
|
|
1018
|
-
let base = name.replacingOccurrences(
|
|
1019
|
-
of: "[^a-zA-Z0-9_-]",
|
|
1020
|
-
with: "-",
|
|
1021
|
-
options: .regularExpression
|
|
1022
|
-
)
|
|
1023
|
-
let hash = SHA256.hash(data: Data(path.utf8))
|
|
1024
|
-
let short = hash.prefix(3).map { String(format: "%02x", $0) }.joined()
|
|
1025
|
-
return "\(base)-\(short)"
|
|
1026
|
-
}
|
|
1027
|
-
}
|