@wangyaoshen/remux 0.3.9-dev.390cb29 → 0.3.10-dev.574c4d2
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/apps/macos/Package.swift +5 -0
- package/apps/macos/Sources/Remux/AppCommand.swift +114 -0
- package/apps/macos/Sources/Remux/AppDelegate.swift +26 -0
- package/apps/macos/Sources/Remux/MainContentView.swift +56 -0
- package/apps/macos/Sources/Remux/MenuBarManager.swift +18 -26
- package/apps/macos/Sources/Remux/NotificationManager.swift +52 -7
- package/apps/macos/Sources/Remux/Views/SplitTree/SplitView.swift +1 -1
- package/apps/macos/Sources/Remux/Views/Terminal/GhosttyNativeView.swift +10 -4
- package/apps/macos/Sources/Remux/Views/Terminal/TerminalContainerView.swift +35 -5
- package/apps/macos/Sources/Remux/WindowObserver.swift +38 -0
- package/apps/macos/Tests/RemuxTests/AppCommandTests.swift +30 -0
- package/apps/macos/Tests/RemuxTests/NotificationManagerTests.swift +28 -0
- package/package.json +1 -1
- package/packages/RemuxKit/Sources/RemuxKit/Storage/KeychainStore.swift +3 -3
- package/packages/RemuxKit/Tests/RemuxKitTests/KeychainStoreTests.swift +3 -3
- package/pty-daemon.js +17 -11
- package/server.js +250 -1467
- package/services/discovery/local-server.js +98 -0
- package/services/discovery/worker.js +125 -0
- package/services/discovery/wrangler.toml +7 -0
- package/src/pty-daemon.ts +17 -11
- package/src/server.ts +205 -1458
- package/src/session.ts +42 -4
- package/tests/auth.test.js +1 -1
- package/tests/e2e/app.spec.js +113 -288
- package/tests/pty-daemon.test.js +20 -1
- package/tests/server.test.js +49 -11
- package/vitest.config.js +1 -0
package/apps/macos/Package.swift
CHANGED
|
@@ -29,6 +29,11 @@ let package = Package(
|
|
|
29
29
|
.linkedLibrary("z"),
|
|
30
30
|
]
|
|
31
31
|
),
|
|
32
|
+
.testTarget(
|
|
33
|
+
name: "RemuxTests",
|
|
34
|
+
dependencies: ["Remux"],
|
|
35
|
+
path: "Tests/RemuxTests"
|
|
36
|
+
),
|
|
32
37
|
.binaryTarget(
|
|
33
38
|
name: "GhosttyKit",
|
|
34
39
|
path: "../../vendor/ghostty/macos/GhosttyKit.xcframework"
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
enum WindowCommandAction: String, Sendable {
|
|
4
|
+
case splitRight
|
|
5
|
+
case splitDown
|
|
6
|
+
case closePane
|
|
7
|
+
case focusNextPane
|
|
8
|
+
case focusPreviousPane
|
|
9
|
+
case newBrowserPane
|
|
10
|
+
case newMarkdownPane
|
|
11
|
+
case commandPalette
|
|
12
|
+
case copyMode
|
|
13
|
+
case findInTerminal
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
struct WindowCommand: Equatable, Sendable {
|
|
17
|
+
private static let actionKey = "action"
|
|
18
|
+
private static let targetWindowNumberKey = "targetWindowNumber"
|
|
19
|
+
|
|
20
|
+
let action: WindowCommandAction
|
|
21
|
+
let targetWindowNumber: Int
|
|
22
|
+
|
|
23
|
+
init(action: WindowCommandAction, targetWindowNumber: Int) {
|
|
24
|
+
self.action = action
|
|
25
|
+
self.targetWindowNumber = targetWindowNumber
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
init?(notification: Notification) {
|
|
29
|
+
guard notification.name == .remuxWindowCommand,
|
|
30
|
+
let rawAction = notification.userInfo?[Self.actionKey] as? String,
|
|
31
|
+
let action = WindowCommandAction(rawValue: rawAction),
|
|
32
|
+
let targetWindowNumber = notification.userInfo?[Self.targetWindowNumberKey] as? Int else {
|
|
33
|
+
return nil
|
|
34
|
+
}
|
|
35
|
+
self.init(action: action, targetWindowNumber: targetWindowNumber)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
func matches(windowNumber: Int?) -> Bool {
|
|
39
|
+
guard let windowNumber else { return false }
|
|
40
|
+
return targetWindowNumber == windowNumber
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
func post() {
|
|
44
|
+
NotificationCenter.default.post(
|
|
45
|
+
name: .remuxWindowCommand,
|
|
46
|
+
object: nil,
|
|
47
|
+
userInfo: [
|
|
48
|
+
Self.actionKey: action.rawValue,
|
|
49
|
+
Self.targetWindowNumberKey: targetWindowNumber,
|
|
50
|
+
]
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
enum TerminalCommandAction: String, Sendable {
|
|
56
|
+
case showSearch
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
struct TerminalCommand: Equatable, Sendable {
|
|
60
|
+
private static let actionKey = "action"
|
|
61
|
+
private static let targetWindowNumberKey = "targetWindowNumber"
|
|
62
|
+
private static let leafIDKey = "leafID"
|
|
63
|
+
|
|
64
|
+
let action: TerminalCommandAction
|
|
65
|
+
let targetWindowNumber: Int
|
|
66
|
+
let leafID: UUID?
|
|
67
|
+
|
|
68
|
+
init(action: TerminalCommandAction, targetWindowNumber: Int, leafID: UUID?) {
|
|
69
|
+
self.action = action
|
|
70
|
+
self.targetWindowNumber = targetWindowNumber
|
|
71
|
+
self.leafID = leafID
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
init?(notification: Notification) {
|
|
75
|
+
guard notification.name == .remuxTerminalCommand,
|
|
76
|
+
let rawAction = notification.userInfo?[Self.actionKey] as? String,
|
|
77
|
+
let action = TerminalCommandAction(rawValue: rawAction),
|
|
78
|
+
let targetWindowNumber = notification.userInfo?[Self.targetWindowNumberKey] as? Int else {
|
|
79
|
+
return nil
|
|
80
|
+
}
|
|
81
|
+
self.init(
|
|
82
|
+
action: action,
|
|
83
|
+
targetWindowNumber: targetWindowNumber,
|
|
84
|
+
leafID: notification.userInfo?[Self.leafIDKey] as? UUID
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
func matches(windowNumber: Int?, leafID: UUID?) -> Bool {
|
|
89
|
+
guard let windowNumber, targetWindowNumber == windowNumber else {
|
|
90
|
+
return false
|
|
91
|
+
}
|
|
92
|
+
return self.leafID == leafID
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
func post() {
|
|
96
|
+
var userInfo: [String: Any] = [
|
|
97
|
+
Self.actionKey: action.rawValue,
|
|
98
|
+
Self.targetWindowNumberKey: targetWindowNumber,
|
|
99
|
+
]
|
|
100
|
+
if let leafID {
|
|
101
|
+
userInfo[Self.leafIDKey] = leafID
|
|
102
|
+
}
|
|
103
|
+
NotificationCenter.default.post(
|
|
104
|
+
name: .remuxTerminalCommand,
|
|
105
|
+
object: nil,
|
|
106
|
+
userInfo: userInfo
|
|
107
|
+
)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
extension Notification.Name {
|
|
112
|
+
static let remuxWindowCommand = Notification.Name("remuxWindowCommand")
|
|
113
|
+
static let remuxTerminalCommand = Notification.Name("remuxTerminalCommand")
|
|
114
|
+
}
|
|
@@ -38,6 +38,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|
|
38
38
|
finderIntegration = FinderIntegration(state: state)
|
|
39
39
|
finderIntegration?.registerServices()
|
|
40
40
|
|
|
41
|
+
NotificationCenter.default.addObserver(
|
|
42
|
+
self,
|
|
43
|
+
selector: #selector(handleTerminalDataNotification(_:)),
|
|
44
|
+
name: .remuxTerminalData,
|
|
45
|
+
object: nil
|
|
46
|
+
)
|
|
47
|
+
|
|
41
48
|
// Start autosave
|
|
42
49
|
SessionPersistence.shared.startAutosave { [weak self] in
|
|
43
50
|
guard let self else {
|
|
@@ -60,6 +67,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|
|
60
67
|
}
|
|
61
68
|
|
|
62
69
|
func applicationWillTerminate(_ notification: Notification) {
|
|
70
|
+
NotificationCenter.default.removeObserver(self)
|
|
71
|
+
|
|
63
72
|
// Stop socket controller
|
|
64
73
|
socketController?.stop()
|
|
65
74
|
|
|
@@ -254,4 +263,21 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|
|
254
263
|
}
|
|
255
264
|
detachedWindows.removeAll()
|
|
256
265
|
}
|
|
266
|
+
|
|
267
|
+
@objc private func handleTerminalDataNotification(_ notification: Notification) {
|
|
268
|
+
guard let data = notification.userInfo?["data"] as? Data else { return }
|
|
269
|
+
|
|
270
|
+
let oscNotifications = OSCNotificationParser.parse(data)
|
|
271
|
+
guard !oscNotifications.isEmpty else { return }
|
|
272
|
+
|
|
273
|
+
let sessionName = state.currentSession.isEmpty ? "Remux" : state.currentSession
|
|
274
|
+
for oscNotification in oscNotifications {
|
|
275
|
+
notificationManager?.handleNotification(.init(
|
|
276
|
+
title: oscNotification.title,
|
|
277
|
+
body: oscNotification.body,
|
|
278
|
+
tabIndex: state.activeTabIndex,
|
|
279
|
+
sessionName: sessionName
|
|
280
|
+
))
|
|
281
|
+
}
|
|
282
|
+
}
|
|
257
283
|
}
|
|
@@ -6,6 +6,7 @@ import RemuxKit
|
|
|
6
6
|
struct MainContentView: View {
|
|
7
7
|
@Environment(RemuxState.self) private var state
|
|
8
8
|
@State private var showInspect = false
|
|
9
|
+
@State private var windowNumber: Int?
|
|
9
10
|
|
|
10
11
|
// Split pane state
|
|
11
12
|
@State private var splitRoot: SplitNode = .leaf(SplitNode.LeafData(tabIndex: 0))
|
|
@@ -88,6 +89,11 @@ struct MainContentView: View {
|
|
|
88
89
|
isPresented: $showCommandPalette,
|
|
89
90
|
commands: buildCommandList()
|
|
90
91
|
)
|
|
92
|
+
|
|
93
|
+
WindowObserver { window in
|
|
94
|
+
windowNumber = window?.windowNumber
|
|
95
|
+
}
|
|
96
|
+
.frame(width: 0, height: 0)
|
|
91
97
|
}
|
|
92
98
|
.onAppear {
|
|
93
99
|
// Set initial focused leaf
|
|
@@ -95,6 +101,13 @@ struct MainContentView: View {
|
|
|
95
101
|
focusedLeafID = splitRoot.allLeaves.first?.id
|
|
96
102
|
}
|
|
97
103
|
}
|
|
104
|
+
.onReceive(NotificationCenter.default.publisher(for: .remuxWindowCommand)) { notification in
|
|
105
|
+
guard let command = WindowCommand(notification: notification),
|
|
106
|
+
command.matches(windowNumber: windowNumber) else {
|
|
107
|
+
return
|
|
108
|
+
}
|
|
109
|
+
handleWindowCommand(command.action)
|
|
110
|
+
}
|
|
98
111
|
}
|
|
99
112
|
|
|
100
113
|
@ViewBuilder
|
|
@@ -296,6 +309,49 @@ struct MainContentView: View {
|
|
|
296
309
|
}
|
|
297
310
|
}
|
|
298
311
|
|
|
312
|
+
private func handleWindowCommand(_ action: WindowCommandAction) {
|
|
313
|
+
switch action {
|
|
314
|
+
case .splitRight:
|
|
315
|
+
splitPane(orientation: .horizontal)
|
|
316
|
+
case .splitDown:
|
|
317
|
+
splitPane(orientation: .vertical)
|
|
318
|
+
case .closePane:
|
|
319
|
+
closePane()
|
|
320
|
+
case .focusNextPane:
|
|
321
|
+
focusNextPane()
|
|
322
|
+
case .focusPreviousPane:
|
|
323
|
+
focusPreviousPane()
|
|
324
|
+
case .newBrowserPane:
|
|
325
|
+
addBrowserPane()
|
|
326
|
+
case .newMarkdownPane:
|
|
327
|
+
addMarkdownPane()
|
|
328
|
+
case .commandPalette:
|
|
329
|
+
toggleCommandPalette()
|
|
330
|
+
case .copyMode:
|
|
331
|
+
toggleCopyMode()
|
|
332
|
+
case .findInTerminal:
|
|
333
|
+
guard let windowNumber,
|
|
334
|
+
let leafID = activeTerminalLeafID else {
|
|
335
|
+
return
|
|
336
|
+
}
|
|
337
|
+
TerminalCommand(
|
|
338
|
+
action: .showSearch,
|
|
339
|
+
targetWindowNumber: windowNumber,
|
|
340
|
+
leafID: leafID
|
|
341
|
+
).post()
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
private var activeTerminalLeafID: UUID? {
|
|
346
|
+
if let focusedLeafID,
|
|
347
|
+
let leaf = splitRoot.findLeaf(id: focusedLeafID),
|
|
348
|
+
leaf.panelType == .terminal {
|
|
349
|
+
return focusedLeafID
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
return splitRoot.allLeaves.first(where: { $0.panelType == .terminal })?.id
|
|
353
|
+
}
|
|
354
|
+
|
|
299
355
|
// MARK: - SSH upload
|
|
300
356
|
|
|
301
357
|
private func handleSSHUpload() {
|
|
@@ -6,20 +6,6 @@ import RemuxKit
|
|
|
6
6
|
final class MenuBarManager {
|
|
7
7
|
private weak var state: RemuxState?
|
|
8
8
|
|
|
9
|
-
/// Callback for split operations, wired from AppDelegate.
|
|
10
|
-
var onSplitRight: (() -> Void)?
|
|
11
|
-
var onSplitDown: (() -> Void)?
|
|
12
|
-
var onClosePane: (() -> Void)?
|
|
13
|
-
var onFocusNextPane: (() -> Void)?
|
|
14
|
-
var onFocusPreviousPane: (() -> Void)?
|
|
15
|
-
|
|
16
|
-
/// Callbacks for new features.
|
|
17
|
-
var onNewBrowserPane: (() -> Void)?
|
|
18
|
-
var onNewMarkdownPane: (() -> Void)?
|
|
19
|
-
var onCommandPalette: (() -> Void)?
|
|
20
|
-
var onCopyMode: (() -> Void)?
|
|
21
|
-
var onDetachPane: (() -> Void)?
|
|
22
|
-
|
|
23
9
|
init(state: RemuxState) {
|
|
24
10
|
self.state = state
|
|
25
11
|
setupMenuBar()
|
|
@@ -211,52 +197,53 @@ final class MenuBarManager {
|
|
|
211
197
|
}
|
|
212
198
|
|
|
213
199
|
@objc private func findInTerminal(_ sender: Any?) {
|
|
214
|
-
|
|
215
|
-
// which intercepts Cmd+F. The menu item provides discoverability.
|
|
200
|
+
dispatch(.findInTerminal)
|
|
216
201
|
}
|
|
217
202
|
|
|
218
203
|
// MARK: - Split pane actions
|
|
219
204
|
|
|
220
205
|
@objc private func splitRightAction(_ sender: Any?) {
|
|
221
|
-
|
|
206
|
+
dispatch(.splitRight)
|
|
222
207
|
}
|
|
223
208
|
|
|
224
209
|
@objc private func splitDownAction(_ sender: Any?) {
|
|
225
|
-
|
|
210
|
+
dispatch(.splitDown)
|
|
226
211
|
}
|
|
227
212
|
|
|
228
213
|
@objc private func closePaneAction(_ sender: Any?) {
|
|
229
|
-
|
|
214
|
+
dispatch(.closePane)
|
|
230
215
|
}
|
|
231
216
|
|
|
232
217
|
@objc private func focusNextAction(_ sender: Any?) {
|
|
233
|
-
|
|
218
|
+
dispatch(.focusNextPane)
|
|
234
219
|
}
|
|
235
220
|
|
|
236
221
|
@objc private func focusPrevAction(_ sender: Any?) {
|
|
237
|
-
|
|
222
|
+
dispatch(.focusPreviousPane)
|
|
238
223
|
}
|
|
239
224
|
|
|
240
225
|
// MARK: - New panel actions
|
|
241
226
|
|
|
242
227
|
@objc private func newBrowserPaneAction(_ sender: Any?) {
|
|
243
|
-
|
|
228
|
+
dispatch(.newBrowserPane)
|
|
244
229
|
}
|
|
245
230
|
|
|
246
231
|
@objc private func newMarkdownPaneAction(_ sender: Any?) {
|
|
247
|
-
|
|
232
|
+
dispatch(.newMarkdownPane)
|
|
248
233
|
}
|
|
249
234
|
|
|
250
235
|
@objc private func commandPaletteAction(_ sender: Any?) {
|
|
251
|
-
|
|
236
|
+
dispatch(.commandPalette)
|
|
252
237
|
}
|
|
253
238
|
|
|
254
239
|
@objc private func copyModeAction(_ sender: Any?) {
|
|
255
|
-
|
|
240
|
+
dispatch(.copyMode)
|
|
256
241
|
}
|
|
257
242
|
|
|
258
243
|
@objc private func detachPaneAction(_ sender: Any?) {
|
|
259
|
-
|
|
244
|
+
if let appDelegate = NSApp.delegate as? AppDelegate {
|
|
245
|
+
appDelegate.detachPaneToWindow()
|
|
246
|
+
}
|
|
260
247
|
}
|
|
261
248
|
|
|
262
249
|
@objc private func openInEditor(_ sender: Any?) {
|
|
@@ -272,4 +259,9 @@ final class MenuBarManager {
|
|
|
272
259
|
|
|
273
260
|
FinderIntegration.openInExternalEditor(path: cwd, editor: editor)
|
|
274
261
|
}
|
|
262
|
+
|
|
263
|
+
private func dispatch(_ action: WindowCommandAction) {
|
|
264
|
+
guard let targetWindowNumber = NSApp.keyWindow?.windowNumber else { return }
|
|
265
|
+
WindowCommand(action: action, targetWindowNumber: targetWindowNumber).post()
|
|
266
|
+
}
|
|
275
267
|
}
|
|
@@ -84,7 +84,7 @@ final class NotificationManager: NSObject {
|
|
|
84
84
|
}
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
-
extension NotificationManager:
|
|
87
|
+
extension NotificationManager: UNUserNotificationCenterDelegate {
|
|
88
88
|
nonisolated func userNotificationCenter(
|
|
89
89
|
_ center: UNUserNotificationCenter,
|
|
90
90
|
didReceive response: UNNotificationResponse,
|
|
@@ -102,21 +102,44 @@ extension NotificationManager: @preconcurrency UNUserNotificationCenterDelegate
|
|
|
102
102
|
willPresent notification: UNNotification,
|
|
103
103
|
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
|
|
104
104
|
) {
|
|
105
|
-
let options: UNNotificationPresentationOptions
|
|
105
|
+
let options: UNNotificationPresentationOptions
|
|
106
|
+
if Thread.isMainThread {
|
|
107
|
+
options = MainActor.assumeIsolated {
|
|
108
|
+
currentPresentationOptions()
|
|
109
|
+
}
|
|
110
|
+
} else {
|
|
111
|
+
options = DispatchQueue.main.sync {
|
|
112
|
+
MainActor.assumeIsolated {
|
|
113
|
+
currentPresentationOptions()
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
106
117
|
completionHandler(options)
|
|
107
118
|
}
|
|
119
|
+
|
|
120
|
+
@MainActor
|
|
121
|
+
private func currentPresentationOptions() -> UNNotificationPresentationOptions {
|
|
122
|
+
NSApp.isActive ? [] : [.banner, .sound]
|
|
123
|
+
}
|
|
108
124
|
}
|
|
109
125
|
|
|
110
126
|
// MARK: - OSC Notification Parser
|
|
111
127
|
|
|
112
128
|
/// Parses OSC 9/99/777 notification sequences from PTY data.
|
|
113
129
|
/// Ref: cmux OSC notification detection approach
|
|
130
|
+
struct OSCParsedNotification: Equatable {
|
|
131
|
+
let title: String
|
|
132
|
+
let body: String
|
|
133
|
+
}
|
|
134
|
+
|
|
114
135
|
struct OSCNotificationParser {
|
|
136
|
+
private static let defaultTitle = "Terminal Notification"
|
|
137
|
+
|
|
115
138
|
/// Parse PTY data for notification sequences.
|
|
116
139
|
/// Returns extracted notifications (if any).
|
|
117
|
-
static func parse(_ data: Data) -> [
|
|
140
|
+
static func parse(_ data: Data) -> [OSCParsedNotification] {
|
|
118
141
|
guard let text = String(data: data, encoding: .utf8) else { return [] }
|
|
119
|
-
var notifications: [
|
|
142
|
+
var notifications: [OSCParsedNotification] = []
|
|
120
143
|
|
|
121
144
|
// OSC 9: iTerm2 notification — ESC ] 9 ; <message> BEL/ST
|
|
122
145
|
let osc9Pattern = "\u{1b}]9;([^\u{07}\u{1b}]+)[\u{07}]"
|
|
@@ -124,7 +147,24 @@ struct OSCNotificationParser {
|
|
|
124
147
|
let matches = regex.matches(in: text, range: NSRange(text.startIndex..., in: text))
|
|
125
148
|
for match in matches {
|
|
126
149
|
if let range = Range(match.range(at: 1), in: text) {
|
|
127
|
-
notifications.append(
|
|
150
|
+
notifications.append(OSCParsedNotification(
|
|
151
|
+
title: defaultTitle,
|
|
152
|
+
body: String(text[range])
|
|
153
|
+
))
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// OSC 99: kitty notification — ESC ] 99 ; <message> BEL
|
|
159
|
+
let osc99Pattern = "\u{1b}]99;([^\u{07}\u{1b}]+)[\u{07}]"
|
|
160
|
+
if let regex = try? NSRegularExpression(pattern: osc99Pattern) {
|
|
161
|
+
let matches = regex.matches(in: text, range: NSRange(text.startIndex..., in: text))
|
|
162
|
+
for match in matches {
|
|
163
|
+
if let range = Range(match.range(at: 1), in: text) {
|
|
164
|
+
notifications.append(OSCParsedNotification(
|
|
165
|
+
title: defaultTitle,
|
|
166
|
+
body: String(text[range])
|
|
167
|
+
))
|
|
128
168
|
}
|
|
129
169
|
}
|
|
130
170
|
}
|
|
@@ -134,8 +174,13 @@ struct OSCNotificationParser {
|
|
|
134
174
|
if let regex = try? NSRegularExpression(pattern: osc777Pattern) {
|
|
135
175
|
let matches = regex.matches(in: text, range: NSRange(text.startIndex..., in: text))
|
|
136
176
|
for match in matches {
|
|
137
|
-
if let
|
|
138
|
-
|
|
177
|
+
if let titleRange = Range(match.range(at: 1), in: text),
|
|
178
|
+
let bodyRange = Range(match.range(at: 2), in: text) {
|
|
179
|
+
let title = String(text[titleRange]).isEmpty ? defaultTitle : String(text[titleRange])
|
|
180
|
+
notifications.append(OSCParsedNotification(
|
|
181
|
+
title: title,
|
|
182
|
+
body: String(text[bodyRange])
|
|
183
|
+
))
|
|
139
184
|
}
|
|
140
185
|
}
|
|
141
186
|
}
|
|
@@ -243,15 +243,21 @@ final class GhosttyNativeView: NSView, @preconcurrency NSTextInputClient {
|
|
|
243
243
|
|
|
244
244
|
/// Navigate to the next search match.
|
|
245
245
|
func searchForward() {
|
|
246
|
-
|
|
247
|
-
let action = "search_forward"
|
|
248
|
-
_ = ghostty_surface_binding_action(surface, action, UInt(action.utf8.count))
|
|
246
|
+
performBindingAction("search_forward")
|
|
249
247
|
}
|
|
250
248
|
|
|
251
249
|
/// Navigate to the previous search match.
|
|
252
250
|
func searchBackward() {
|
|
251
|
+
performBindingAction("search_backward")
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/// Update the active search query for the surface.
|
|
255
|
+
func updateSearch(_ query: String) {
|
|
256
|
+
performBindingAction("search:\(query)")
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
private func performBindingAction(_ action: String) {
|
|
253
260
|
guard let surface else { return }
|
|
254
|
-
let action = "search_backward"
|
|
255
261
|
_ = ghostty_surface_binding_action(surface, action, UInt(action.utf8.count))
|
|
256
262
|
}
|
|
257
263
|
|
|
@@ -6,9 +6,12 @@ import RemuxKit
|
|
|
6
6
|
/// Remote PTY -> WebSocket -> RemuxState -> relay.writeToTerminal() -> socket -> nc stdout -> ghostty Metal render
|
|
7
7
|
/// User types -> ghostty -> nc stdin -> socket -> relay.onDataFromClient -> state.sendTerminalData() -> WebSocket -> remote PTY
|
|
8
8
|
struct TerminalContainerView: View {
|
|
9
|
+
let leafID: UUID?
|
|
10
|
+
|
|
9
11
|
@Environment(RemuxState.self) private var state
|
|
10
12
|
@State private var terminalView: GhosttyNativeView?
|
|
11
13
|
@State private var relay = TerminalRelay()
|
|
14
|
+
@State private var windowNumber: Int?
|
|
12
15
|
|
|
13
16
|
// Search state
|
|
14
17
|
@State private var searchVisible = false
|
|
@@ -16,6 +19,10 @@ struct TerminalContainerView: View {
|
|
|
16
19
|
@State private var searchTotal = 0
|
|
17
20
|
@State private var searchSelected = -1
|
|
18
21
|
|
|
22
|
+
init(leafID: UUID? = nil) {
|
|
23
|
+
self.leafID = leafID
|
|
24
|
+
}
|
|
25
|
+
|
|
19
26
|
var body: some View {
|
|
20
27
|
ZStack(alignment: .topTrailing) {
|
|
21
28
|
GhosttyNativeTerminalView(
|
|
@@ -55,15 +62,11 @@ struct TerminalContainerView: View {
|
|
|
55
62
|
selectedMatch: $searchSelected,
|
|
56
63
|
onSearch: { query in
|
|
57
64
|
guard let view = terminalView else { return }
|
|
65
|
+
view.updateSearch(query)
|
|
58
66
|
if query.isEmpty {
|
|
59
67
|
searchTotal = 0
|
|
60
68
|
searchSelected = -1
|
|
61
69
|
}
|
|
62
|
-
// Ghostty search is triggered by typing into the search bar,
|
|
63
|
-
// which sends the text via binding_action
|
|
64
|
-
let action = "search:\(query)"
|
|
65
|
-
view.sendText("") // ensure surface has focus
|
|
66
|
-
_ = action // search is driven by the overlay text field
|
|
67
70
|
},
|
|
68
71
|
onNext: {
|
|
69
72
|
terminalView?.searchForward()
|
|
@@ -74,14 +77,37 @@ struct TerminalContainerView: View {
|
|
|
74
77
|
onClose: {
|
|
75
78
|
searchTotal = 0
|
|
76
79
|
searchSelected = -1
|
|
80
|
+
terminalView?.updateSearch("")
|
|
77
81
|
}
|
|
78
82
|
)
|
|
83
|
+
|
|
84
|
+
WindowObserver { window in
|
|
85
|
+
windowNumber = window?.windowNumber
|
|
86
|
+
}
|
|
87
|
+
.frame(width: 0, height: 0)
|
|
79
88
|
}
|
|
80
89
|
.onReceive(NotificationCenter.default.publisher(for: .remuxTerminalData)) { notification in
|
|
81
90
|
if let data = notification.userInfo?["data"] as? Data {
|
|
82
91
|
relay.writeToTerminal(data)
|
|
83
92
|
}
|
|
84
93
|
}
|
|
94
|
+
.onReceive(NotificationCenter.default.publisher(for: .remuxWindowCommand)) { notification in
|
|
95
|
+
guard leafID == nil,
|
|
96
|
+
let command = WindowCommand(notification: notification),
|
|
97
|
+
command.action == .findInTerminal,
|
|
98
|
+
command.matches(windowNumber: windowNumber) else {
|
|
99
|
+
return
|
|
100
|
+
}
|
|
101
|
+
openSearch()
|
|
102
|
+
}
|
|
103
|
+
.onReceive(NotificationCenter.default.publisher(for: .remuxTerminalCommand)) { notification in
|
|
104
|
+
guard let command = TerminalCommand(notification: notification),
|
|
105
|
+
command.action == .showSearch,
|
|
106
|
+
command.matches(windowNumber: windowNumber, leafID: leafID) else {
|
|
107
|
+
return
|
|
108
|
+
}
|
|
109
|
+
openSearch()
|
|
110
|
+
}
|
|
85
111
|
.onAppear {
|
|
86
112
|
relay.onDataFromClient = { data in
|
|
87
113
|
state.sendTerminalData(data)
|
|
@@ -92,4 +118,8 @@ struct TerminalContainerView: View {
|
|
|
92
118
|
relay.stop()
|
|
93
119
|
}
|
|
94
120
|
}
|
|
121
|
+
|
|
122
|
+
private func openSearch() {
|
|
123
|
+
searchVisible = true
|
|
124
|
+
}
|
|
95
125
|
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import AppKit
|
|
2
|
+
import SwiftUI
|
|
3
|
+
|
|
4
|
+
struct WindowObserver: NSViewRepresentable {
|
|
5
|
+
var onWindowChange: (NSWindow?) -> Void
|
|
6
|
+
|
|
7
|
+
func makeNSView(context: Context) -> WindowObserverView {
|
|
8
|
+
let view = WindowObserverView()
|
|
9
|
+
view.onWindowChange = onWindowChange
|
|
10
|
+
return view
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
func updateNSView(_ nsView: WindowObserverView, context: Context) {
|
|
14
|
+
nsView.onWindowChange = onWindowChange
|
|
15
|
+
nsView.reportWindow()
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
final class WindowObserverView: NSView {
|
|
20
|
+
var onWindowChange: ((NSWindow?) -> Void)?
|
|
21
|
+
|
|
22
|
+
override func viewDidMoveToWindow() {
|
|
23
|
+
super.viewDidMoveToWindow()
|
|
24
|
+
reportWindow()
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
override func viewDidMoveToSuperview() {
|
|
28
|
+
super.viewDidMoveToSuperview()
|
|
29
|
+
reportWindow()
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
func reportWindow() {
|
|
33
|
+
DispatchQueue.main.async { [weak self] in
|
|
34
|
+
guard let self else { return }
|
|
35
|
+
self.onWindowChange?(self.window)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import Testing
|
|
3
|
+
@testable import Remux
|
|
4
|
+
|
|
5
|
+
@Suite("AppCommand")
|
|
6
|
+
struct AppCommandTests {
|
|
7
|
+
|
|
8
|
+
@Test("Window command only matches its target window")
|
|
9
|
+
func windowCommandMatchesTargetWindow() {
|
|
10
|
+
let command = WindowCommand(action: .splitRight, targetWindowNumber: 17)
|
|
11
|
+
|
|
12
|
+
#expect(command.matches(windowNumber: 17))
|
|
13
|
+
#expect(!command.matches(windowNumber: 18))
|
|
14
|
+
#expect(!command.matches(windowNumber: nil))
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
@Test("Terminal command matches both window and leaf")
|
|
18
|
+
func terminalCommandMatchesWindowAndLeaf() {
|
|
19
|
+
let leafID = UUID()
|
|
20
|
+
let command = TerminalCommand(
|
|
21
|
+
action: .showSearch,
|
|
22
|
+
targetWindowNumber: 42,
|
|
23
|
+
leafID: leafID
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
#expect(command.matches(windowNumber: 42, leafID: leafID))
|
|
27
|
+
#expect(!command.matches(windowNumber: 43, leafID: leafID))
|
|
28
|
+
#expect(!command.matches(windowNumber: 42, leafID: UUID()))
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import Testing
|
|
3
|
+
@testable import Remux
|
|
4
|
+
|
|
5
|
+
@Suite("OSCNotificationParser")
|
|
6
|
+
struct OSCNotificationParserTests {
|
|
7
|
+
|
|
8
|
+
@Test("Parses OSC 9 and OSC 99 notifications")
|
|
9
|
+
func parsesOSC9And99() {
|
|
10
|
+
let payload = "\u{1b}]9;Build complete\u{07}\u{1b}]99;Tests passed\u{07}"
|
|
11
|
+
let parsed = OSCNotificationParser.parse(Data(payload.utf8))
|
|
12
|
+
|
|
13
|
+
#expect(parsed == [
|
|
14
|
+
OSCParsedNotification(title: "Terminal Notification", body: "Build complete"),
|
|
15
|
+
OSCParsedNotification(title: "Terminal Notification", body: "Tests passed"),
|
|
16
|
+
])
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
@Test("Parses OSC 777 notifications with explicit title")
|
|
20
|
+
func parsesOSC777() {
|
|
21
|
+
let payload = "\u{1b}]777;notify;Deploy;Finished successfully\u{07}"
|
|
22
|
+
let parsed = OSCNotificationParser.parse(Data(payload.utf8))
|
|
23
|
+
|
|
24
|
+
#expect(parsed == [
|
|
25
|
+
OSCParsedNotification(title: "Deploy", body: "Finished successfully"),
|
|
26
|
+
])
|
|
27
|
+
}
|
|
28
|
+
}
|