@wangyaoshen/remux 0.3.8-dev.29e114b

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.
Files changed (183) hide show
  1. package/.github/ISSUE_TEMPLATE/bug_report.md +47 -0
  2. package/.github/ISSUE_TEMPLATE/feature_request.md +38 -0
  3. package/.github/PULL_REQUEST_TEMPLATE.md +28 -0
  4. package/.github/dependabot.yml +33 -0
  5. package/.github/workflows/ci.yml +65 -0
  6. package/.github/workflows/deploy.yml +65 -0
  7. package/.github/workflows/publish.yml +312 -0
  8. package/.github/workflows/release-please.yml +21 -0
  9. package/.gitmodules +3 -0
  10. package/.nvmrc +1 -0
  11. package/.release-please-manifest.json +3 -0
  12. package/CLAUDE.md +104 -0
  13. package/Dockerfile +23 -0
  14. package/LICENSE +21 -0
  15. package/README.md +120 -0
  16. package/apps/ios/Config/signing.xcconfig +4 -0
  17. package/apps/ios/Package.swift +26 -0
  18. package/apps/ios/Remux.xcodeproj/project.pbxproj +477 -0
  19. package/apps/ios/Remux.xcodeproj/project.xcworkspace/contents.xcworkspacedata +7 -0
  20. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/Contents.json +23 -0
  21. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_1024x1024.png +0 -0
  22. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_120x120.png +0 -0
  23. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_152x152.png +0 -0
  24. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_167x167.png +0 -0
  25. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_180x180.png +0 -0
  26. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_20x20.png +0 -0
  27. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_29x29.png +0 -0
  28. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_40x40.png +0 -0
  29. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_58x58.png +0 -0
  30. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_60x60.png +0 -0
  31. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_76x76.png +0 -0
  32. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_80x80.png +0 -0
  33. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_87x87.png +0 -0
  34. package/apps/ios/Sources/Remux/Assets.xcassets/Contents.json +6 -0
  35. package/apps/ios/Sources/Remux/Extensions/FaceIDManager.swift +29 -0
  36. package/apps/ios/Sources/Remux/Extensions/InspectCache.swift +66 -0
  37. package/apps/ios/Sources/Remux/MainTabView.swift +32 -0
  38. package/apps/ios/Sources/Remux/Remux.entitlements +8 -0
  39. package/apps/ios/Sources/Remux/RemuxiOSApp.swift +14 -0
  40. package/apps/ios/Sources/Remux/RootView.swift +130 -0
  41. package/apps/ios/Sources/Remux/Views/Control/ControlView.swift +102 -0
  42. package/apps/ios/Sources/Remux/Views/Inspect/InspectView.swift +98 -0
  43. package/apps/ios/Sources/Remux/Views/Live/LiveTerminalView.swift +132 -0
  44. package/apps/ios/Sources/Remux/Views/Now/NowView.swift +173 -0
  45. package/apps/ios/Sources/Remux/Views/Onboarding/ManualConnectView.swift +55 -0
  46. package/apps/ios/Sources/Remux/Views/Onboarding/OnboardingView.swift +70 -0
  47. package/apps/ios/Sources/Remux/Views/Onboarding/QRScannerView.swift +92 -0
  48. package/apps/ios/Sources/Remux/Views/Settings/MeView.swift +136 -0
  49. package/apps/macos/Package.swift +37 -0
  50. package/apps/macos/Resources/shell-integration/bash/bash-preexec.sh +382 -0
  51. package/apps/macos/Resources/shell-integration/bash/ghostty.bash +315 -0
  52. package/apps/macos/Resources/shell-integration/elvish/lib/ghostty-integration.elv +191 -0
  53. package/apps/macos/Resources/shell-integration/fish/vendor_conf.d/ghostty-shell-integration.fish +246 -0
  54. package/apps/macos/Resources/shell-integration/nushell/vendor/autoload/ghostty.nu +110 -0
  55. package/apps/macos/Resources/shell-integration/zsh/.zshenv +61 -0
  56. package/apps/macos/Resources/shell-integration/zsh/ghostty-integration +458 -0
  57. package/apps/macos/Resources/terminfo/67/ghostty +0 -0
  58. package/apps/macos/Resources/terminfo/78/xterm-ghostty +0 -0
  59. package/apps/macos/Sources/Remux/AppDelegate.swift +257 -0
  60. package/apps/macos/Sources/Remux/CrashReporter.swift +210 -0
  61. package/apps/macos/Sources/Remux/FinderIntegration.swift +117 -0
  62. package/apps/macos/Sources/Remux/GhosttyConfig.swift +311 -0
  63. package/apps/macos/Sources/Remux/KeyboardShortcuts/ShortcutAction.swift +115 -0
  64. package/apps/macos/Sources/Remux/KeyboardShortcuts/ShortcutSettingsView.swift +271 -0
  65. package/apps/macos/Sources/Remux/KeyboardShortcuts/StoredShortcut.swift +149 -0
  66. package/apps/macos/Sources/Remux/MainContentView.swift +308 -0
  67. package/apps/macos/Sources/Remux/MenuBarManager.swift +275 -0
  68. package/apps/macos/Sources/Remux/NotificationManager.swift +145 -0
  69. package/apps/macos/Sources/Remux/PortScanner.swift +152 -0
  70. package/apps/macos/Sources/Remux/RemuxApp.swift +13 -0
  71. package/apps/macos/Sources/Remux/SSHDetector.swift +151 -0
  72. package/apps/macos/Sources/Remux/SessionPersistence.swift +226 -0
  73. package/apps/macos/Sources/Remux/SocketController.swift +258 -0
  74. package/apps/macos/Sources/Remux/UpdateChecker.swift +152 -0
  75. package/apps/macos/Sources/Remux/Views/CommandPalette.swift +198 -0
  76. package/apps/macos/Sources/Remux/Views/ConnectionView.swift +84 -0
  77. package/apps/macos/Sources/Remux/Views/InspectView.swift +127 -0
  78. package/apps/macos/Sources/Remux/Views/SettingsView.swift +77 -0
  79. package/apps/macos/Sources/Remux/Views/Sidebar/SidebarView.swift +410 -0
  80. package/apps/macos/Sources/Remux/Views/SplitTree/BrowserPanel.swift +193 -0
  81. package/apps/macos/Sources/Remux/Views/SplitTree/MarkdownPanel.swift +277 -0
  82. package/apps/macos/Sources/Remux/Views/SplitTree/PanelProtocol.swift +14 -0
  83. package/apps/macos/Sources/Remux/Views/SplitTree/SplitNode.swift +149 -0
  84. package/apps/macos/Sources/Remux/Views/SplitTree/SplitView.swift +234 -0
  85. package/apps/macos/Sources/Remux/Views/SplitTree/TerminalPanel.swift +26 -0
  86. package/apps/macos/Sources/Remux/Views/TabBarView.swift +94 -0
  87. package/apps/macos/Sources/Remux/Views/Terminal/ClipboardHelper.swift +101 -0
  88. package/apps/macos/Sources/Remux/Views/Terminal/CopyModeOverlay.swift +325 -0
  89. package/apps/macos/Sources/Remux/Views/Terminal/GhosttyNativeTerminalView.swift +39 -0
  90. package/apps/macos/Sources/Remux/Views/Terminal/GhosttyNativeView.swift +559 -0
  91. package/apps/macos/Sources/Remux/Views/Terminal/SurfaceSearchOverlay.swift +109 -0
  92. package/apps/macos/Sources/Remux/Views/Terminal/TerminalContainerView.swift +95 -0
  93. package/apps/macos/Sources/Remux/Views/Terminal/TerminalRelay.swift +117 -0
  94. package/build.mjs +33 -0
  95. package/native/android/DecodeGoldenPayloads.kt +487 -0
  96. package/native/android/ProtocolModels.kt +188 -0
  97. package/native/ios/DecodeGoldenPayloads.swift +711 -0
  98. package/native/ios/ProtocolModels.swift +200 -0
  99. package/package.json +45 -0
  100. package/packages/RemuxKit/Package.swift +27 -0
  101. package/packages/RemuxKit/Sources/RemuxKit/Device/DeviceManager.swift +27 -0
  102. package/packages/RemuxKit/Sources/RemuxKit/Models/ProtocolModels.swift +206 -0
  103. package/packages/RemuxKit/Sources/RemuxKit/Networking/MessageRouter.swift +108 -0
  104. package/packages/RemuxKit/Sources/RemuxKit/Networking/RemuxConnection.swift +395 -0
  105. package/packages/RemuxKit/Sources/RemuxKit/State/RemuxState.swift +188 -0
  106. package/packages/RemuxKit/Sources/RemuxKit/Storage/KeychainStore.swift +142 -0
  107. package/packages/RemuxKit/Sources/RemuxKit/Terminal/GhosttyBridge.swift +145 -0
  108. package/packages/RemuxKit/Sources/RemuxKit/Terminal/GhosttyTerminalView.swift +35 -0
  109. package/packages/RemuxKit/Sources/RemuxKit/Terminal/Resources/ghostty-terminal.html +91 -0
  110. package/packages/RemuxKit/Tests/RemuxKitTests/ConnectionIntegrationTest.swift +74 -0
  111. package/packages/RemuxKit/Tests/RemuxKitTests/KeychainStoreTests.swift +81 -0
  112. package/packages/RemuxKit/Tests/RemuxKitTests/ProtocolModelsTests.swift +179 -0
  113. package/packages/RemuxKit/Tests/RemuxKitTests/RemuxStateTests.swift +62 -0
  114. package/playwright.config.ts +17 -0
  115. package/pnpm-lock.yaml +1588 -0
  116. package/pty-daemon.js +303 -0
  117. package/release-please-config.json +14 -0
  118. package/scripts/auto-deploy.sh +46 -0
  119. package/scripts/build-dmg.sh +121 -0
  120. package/scripts/build-ghostty-kit.sh +43 -0
  121. package/scripts/check-active-terminology.mjs +132 -0
  122. package/scripts/setup-ci-secrets.sh +80 -0
  123. package/scripts/sync-ghostty-web.sh +28 -0
  124. package/scripts/upload-testflight.sh +100 -0
  125. package/server.js +7074 -0
  126. package/src/adapters/agent-events.ts +246 -0
  127. package/src/adapters/claude-code.ts +158 -0
  128. package/src/adapters/codex.ts +210 -0
  129. package/src/adapters/generic-shell.ts +58 -0
  130. package/src/adapters/index.ts +15 -0
  131. package/src/adapters/registry.ts +99 -0
  132. package/src/adapters/types.ts +41 -0
  133. package/src/auth.ts +174 -0
  134. package/src/e2ee.ts +236 -0
  135. package/src/git-service.ts +168 -0
  136. package/src/message-buffer.ts +137 -0
  137. package/src/pty-daemon.ts +357 -0
  138. package/src/push.ts +127 -0
  139. package/src/renderers.ts +455 -0
  140. package/src/server.ts +2407 -0
  141. package/src/service.ts +226 -0
  142. package/src/session.ts +978 -0
  143. package/src/store.ts +1422 -0
  144. package/src/team.ts +123 -0
  145. package/src/tunnel.ts +126 -0
  146. package/src/types.d.ts +50 -0
  147. package/src/vt-tracker.ts +188 -0
  148. package/src/workspace-head.ts +144 -0
  149. package/src/workspace.ts +153 -0
  150. package/src/ws-handler.ts +1526 -0
  151. package/start.ps1 +83 -0
  152. package/tests/adapters.test.js +171 -0
  153. package/tests/auth.test.js +243 -0
  154. package/tests/codex-adapter.test.js +535 -0
  155. package/tests/durable-stream.test.js +153 -0
  156. package/tests/e2e/app.spec.js +530 -0
  157. package/tests/e2ee.test.js +325 -0
  158. package/tests/message-buffer.test.js +245 -0
  159. package/tests/message-routing.test.js +305 -0
  160. package/tests/pty-daemon.test.js +346 -0
  161. package/tests/push.test.js +281 -0
  162. package/tests/renderers.test.js +391 -0
  163. package/tests/search-shell.test.js +499 -0
  164. package/tests/server.test.js +882 -0
  165. package/tests/service.test.js +267 -0
  166. package/tests/store.test.js +369 -0
  167. package/tests/tunnel.test.js +67 -0
  168. package/tests/workspace-head.test.js +116 -0
  169. package/tests/workspace.test.js +417 -0
  170. package/tsconfig.backend.json +11 -0
  171. package/tsconfig.json +15 -0
  172. package/tui/client/client_test.go +125 -0
  173. package/tui/client/connection.go +342 -0
  174. package/tui/client/host_manager.go +141 -0
  175. package/tui/config/cache.go +81 -0
  176. package/tui/config/config.go +53 -0
  177. package/tui/config/config_test.go +89 -0
  178. package/tui/go.mod +32 -0
  179. package/tui/go.sum +50 -0
  180. package/tui/main.go +261 -0
  181. package/tui/tests/integration_test.go +283 -0
  182. package/tui/ui/model.go +310 -0
  183. package/vitest.config.js +10 -0
@@ -0,0 +1,277 @@
1
+ import SwiftUI
2
+ import AppKit
3
+ import Foundation
4
+
5
+ /// Markdown file viewer panel for the split pane system.
6
+ /// Renders .md files using SwiftUI's AttributedString with markdown parsing.
7
+ /// Supports file picker and auto-reload on file change (FSEvents).
8
+ ///
9
+ /// Conforms to PanelProtocol.
10
+ /// Adapted from Marked.app / MacDown conceptual design.
11
+ @MainActor
12
+ final class MarkdownPanel: PanelProtocol {
13
+ let id: UUID
14
+ private(set) var isFocused = false
15
+ private(set) var filePath: URL?
16
+ private(set) var content: String = ""
17
+ private(set) var fileName: String = "Markdown"
18
+
19
+ var title: String { fileName }
20
+ var canClose: Bool { true }
21
+
22
+ /// Called when the content changes (for view refresh).
23
+ var onContentChange: (() -> Void)?
24
+
25
+ /// File system event source for auto-reload.
26
+ nonisolated(unsafe) private var fsEventStream: FSEventStreamRef?
27
+
28
+ init(id: UUID = UUID(), filePath: URL? = nil) {
29
+ self.id = id
30
+ if let filePath {
31
+ self.filePath = filePath
32
+ self.fileName = filePath.lastPathComponent
33
+ loadFile(at: filePath)
34
+ watchFile(at: filePath)
35
+ }
36
+ }
37
+
38
+ deinit {
39
+ if let stream = fsEventStream {
40
+ FSEventStreamStop(stream)
41
+ FSEventStreamInvalidate(stream)
42
+ FSEventStreamRelease(stream)
43
+ }
44
+ }
45
+
46
+ func focus() { isFocused = true }
47
+ func blur() { isFocused = false }
48
+
49
+ // MARK: - File operations
50
+
51
+ func openFile() {
52
+ let panel = NSOpenPanel()
53
+ panel.allowedContentTypes = [.init(filenameExtension: "md")!]
54
+ panel.allowsMultipleSelection = false
55
+ panel.canChooseDirectories = false
56
+
57
+ panel.begin { [weak self] response in
58
+ guard response == .OK, let url = panel.url else { return }
59
+ Task { @MainActor in
60
+ self?.setFile(url)
61
+ }
62
+ }
63
+ }
64
+
65
+ func setFile(_ url: URL) {
66
+ stopWatching()
67
+ filePath = url
68
+ fileName = url.lastPathComponent
69
+ loadFile(at: url)
70
+ watchFile(at: url)
71
+ }
72
+
73
+ private func loadFile(at url: URL) {
74
+ do {
75
+ content = try String(contentsOf: url, encoding: .utf8)
76
+ onContentChange?()
77
+ } catch {
78
+ content = "Failed to load file: \(error.localizedDescription)"
79
+ onContentChange?()
80
+ }
81
+ }
82
+
83
+ // MARK: - File watching (FSEvents)
84
+
85
+ private func watchFile(at url: URL) {
86
+ let dirPath = url.deletingLastPathComponent().path as CFString
87
+ var context = FSEventStreamContext()
88
+
89
+ let rawSelf = Unmanaged.passUnretained(self).toOpaque()
90
+ context.info = rawSelf
91
+
92
+ let callback: FSEventStreamCallback = { _, info, numEvents, eventPaths, _, _ in
93
+ guard let info else { return }
94
+ let panel = Unmanaged<MarkdownPanel>.fromOpaque(info).takeUnretainedValue()
95
+ let count = numEvents
96
+ _ = count
97
+ Task { @MainActor in
98
+ if let path = panel.filePath {
99
+ panel.loadFile(at: path)
100
+ }
101
+ }
102
+ }
103
+
104
+ var paths = [dirPath] as CFArray
105
+ let stream = FSEventStreamCreate(
106
+ nil,
107
+ callback,
108
+ &context,
109
+ paths,
110
+ FSEventStreamEventId(kFSEventStreamEventIdSinceNow),
111
+ 1.0, // 1 second latency
112
+ FSEventStreamCreateFlags(kFSEventStreamCreateFlagFileEvents | kFSEventStreamCreateFlagUseCFTypes)
113
+ )
114
+
115
+ if let stream {
116
+ fsEventStream = stream
117
+ FSEventStreamScheduleWithRunLoop(stream, CFRunLoopGetMain(), CFRunLoopMode.defaultMode.rawValue)
118
+ FSEventStreamStart(stream)
119
+ }
120
+ }
121
+
122
+ private func stopWatching() {
123
+ if let stream = fsEventStream {
124
+ FSEventStreamStop(stream)
125
+ FSEventStreamInvalidate(stream)
126
+ FSEventStreamRelease(stream)
127
+ fsEventStream = nil
128
+ }
129
+ }
130
+ }
131
+
132
+ // MARK: - MarkdownPanelView (SwiftUI)
133
+
134
+ /// SwiftUI view wrapping a MarkdownPanel with toolbar and rendered content.
135
+ struct MarkdownPanelView: View {
136
+ @State private var panel: MarkdownPanel
137
+ @State private var refreshID: UUID = UUID()
138
+
139
+ init(panel: MarkdownPanel) {
140
+ _panel = State(initialValue: panel)
141
+ }
142
+
143
+ var body: some View {
144
+ VStack(spacing: 0) {
145
+ // Toolbar
146
+ HStack(spacing: 8) {
147
+ Image(systemName: "doc.text")
148
+ .foregroundStyle(.secondary)
149
+
150
+ Text(panel.fileName)
151
+ .font(.system(size: 12, weight: .medium))
152
+ .lineLimit(1)
153
+
154
+ Spacer()
155
+
156
+ Button(action: { panel.openFile() }) {
157
+ Image(systemName: "folder")
158
+ .font(.system(size: 11))
159
+ }
160
+ .buttonStyle(.borderless)
161
+ .help("Open Markdown File")
162
+
163
+ if panel.filePath != nil {
164
+ Button(action: { reloadContent() }) {
165
+ Image(systemName: "arrow.clockwise")
166
+ .font(.system(size: 11))
167
+ }
168
+ .buttonStyle(.borderless)
169
+ .help("Reload")
170
+ }
171
+ }
172
+ .padding(.horizontal, 10)
173
+ .padding(.vertical, 6)
174
+ .background(.bar)
175
+
176
+ Divider()
177
+
178
+ // Content
179
+ if panel.content.isEmpty && panel.filePath == nil {
180
+ ContentUnavailableView(
181
+ "No File Open",
182
+ systemImage: "doc.text",
183
+ description: Text("Click the folder icon to open a Markdown file.")
184
+ )
185
+ } else {
186
+ ScrollView {
187
+ MarkdownRenderedContent(markdown: panel.content)
188
+ .padding(16)
189
+ .frame(maxWidth: .infinity, alignment: .leading)
190
+ .id(refreshID)
191
+ }
192
+ }
193
+ }
194
+ .onAppear {
195
+ panel.onContentChange = {
196
+ refreshID = UUID()
197
+ }
198
+ }
199
+ }
200
+
201
+ private func reloadContent() {
202
+ if let path = panel.filePath {
203
+ panel.setFile(path)
204
+ refreshID = UUID()
205
+ }
206
+ }
207
+ }
208
+
209
+ /// Renders markdown text as attributed content using SwiftUI's built-in markdown parser.
210
+ struct MarkdownRenderedContent: View {
211
+ let markdown: String
212
+
213
+ var body: some View {
214
+ VStack(alignment: .leading, spacing: 8) {
215
+ // Split by blank lines to create paragraph blocks
216
+ let blocks = markdown.components(separatedBy: "\n\n")
217
+ ForEach(Array(blocks.enumerated()), id: \.offset) { _, block in
218
+ let trimmed = block.trimmingCharacters(in: .whitespacesAndNewlines)
219
+ if trimmed.hasPrefix("# ") {
220
+ Text(trimmed.dropFirst(2))
221
+ .font(.title)
222
+ .fontWeight(.bold)
223
+ .padding(.top, 8)
224
+ } else if trimmed.hasPrefix("## ") {
225
+ Text(trimmed.dropFirst(3))
226
+ .font(.title2)
227
+ .fontWeight(.semibold)
228
+ .padding(.top, 6)
229
+ } else if trimmed.hasPrefix("### ") {
230
+ Text(trimmed.dropFirst(4))
231
+ .font(.title3)
232
+ .fontWeight(.medium)
233
+ .padding(.top, 4)
234
+ } else if trimmed.hasPrefix("```") {
235
+ // Code block
236
+ let code = trimmed
237
+ .replacingOccurrences(of: "```\\w*\n?", with: "", options: .regularExpression)
238
+ .replacingOccurrences(of: "```", with: "")
239
+ Text(code)
240
+ .font(.system(size: 12, design: .monospaced))
241
+ .padding(10)
242
+ .frame(maxWidth: .infinity, alignment: .leading)
243
+ .background(Color.primary.opacity(0.05))
244
+ .cornerRadius(6)
245
+ } else if trimmed.hasPrefix("- ") || trimmed.hasPrefix("* ") {
246
+ // List items
247
+ let items = trimmed.components(separatedBy: "\n")
248
+ ForEach(Array(items.enumerated()), id: \.offset) { _, item in
249
+ let cleaned = item
250
+ .replacingOccurrences(of: "^[\\-\\*]\\s+", with: "", options: .regularExpression)
251
+ HStack(alignment: .top, spacing: 6) {
252
+ Text("\u{2022}")
253
+ .foregroundStyle(.secondary)
254
+ if let attrStr = try? AttributedString(markdown: cleaned) {
255
+ Text(attrStr)
256
+ } else {
257
+ Text(cleaned)
258
+ }
259
+ }
260
+ }
261
+ } else if trimmed.hasPrefix("---") || trimmed.hasPrefix("***") {
262
+ Divider()
263
+ .padding(.vertical, 4)
264
+ } else if !trimmed.isEmpty {
265
+ // Regular paragraph with inline markdown
266
+ if let attrStr = try? AttributedString(markdown: trimmed) {
267
+ Text(attrStr)
268
+ .textSelection(.enabled)
269
+ } else {
270
+ Text(trimmed)
271
+ .textSelection(.enabled)
272
+ }
273
+ }
274
+ }
275
+ }
276
+ }
277
+ }
@@ -0,0 +1,14 @@
1
+ import Foundation
2
+
3
+ /// Protocol for panels displayed in split panes.
4
+ /// TerminalPanel is the primary conformer; BrowserPanel, MarkdownPanel, etc.
5
+ /// can be added later.
6
+ @MainActor
7
+ protocol PanelProtocol: Identifiable {
8
+ var id: UUID { get }
9
+ var title: String { get }
10
+ var canClose: Bool { get }
11
+
12
+ func focus()
13
+ func blur()
14
+ }
@@ -0,0 +1,149 @@
1
+ import Foundation
2
+
3
+ /// Recursive binary tree for split pane layout.
4
+ /// Adapted from ghostty-org/ghostty SplitNode design pattern.
5
+ indirect enum SplitNode: Identifiable, Sendable {
6
+ case leaf(LeafData)
7
+ case branch(BranchData)
8
+
9
+ struct LeafData: Identifiable, Sendable {
10
+ var id: UUID
11
+ var tabIndex: Int
12
+ var panelType: PanelType
13
+
14
+ init(id: UUID = UUID(), tabIndex: Int, panelType: PanelType = .terminal) {
15
+ self.id = id
16
+ self.tabIndex = tabIndex
17
+ self.panelType = panelType
18
+ }
19
+ }
20
+
21
+ struct BranchData: Identifiable, Sendable {
22
+ var id: UUID
23
+ var orientation: Orientation
24
+ var ratio: CGFloat
25
+ var first: SplitNode
26
+ var second: SplitNode
27
+
28
+ init(
29
+ id: UUID = UUID(),
30
+ orientation: Orientation,
31
+ ratio: CGFloat = 0.5,
32
+ first: SplitNode,
33
+ second: SplitNode
34
+ ) {
35
+ self.id = id
36
+ self.orientation = orientation
37
+ self.ratio = ratio
38
+ self.first = first
39
+ self.second = second
40
+ }
41
+ }
42
+
43
+ enum Orientation: String, Sendable, Codable {
44
+ case horizontal // side by side (split right)
45
+ case vertical // top and bottom (split down)
46
+ }
47
+
48
+ var id: UUID {
49
+ switch self {
50
+ case .leaf(let data): data.id
51
+ case .branch(let data): data.id
52
+ }
53
+ }
54
+
55
+ // MARK: - Tree operations
56
+
57
+ /// Find a leaf by its ID.
58
+ func findLeaf(id: UUID) -> LeafData? {
59
+ switch self {
60
+ case .leaf(let data):
61
+ return data.id == id ? data : nil
62
+ case .branch(let data):
63
+ return data.first.findLeaf(id: id) ?? data.second.findLeaf(id: id)
64
+ }
65
+ }
66
+
67
+ /// Collect all leaf nodes in order.
68
+ var allLeaves: [LeafData] {
69
+ switch self {
70
+ case .leaf(let data):
71
+ return [data]
72
+ case .branch(let data):
73
+ return data.first.allLeaves + data.second.allLeaves
74
+ }
75
+ }
76
+
77
+ /// Split a leaf node into a branch with the original leaf and a new leaf.
78
+ func split(leafID: UUID, orientation: Orientation, newTabIndex: Int, panelType: PanelType = .terminal) -> SplitNode {
79
+ switch self {
80
+ case .leaf(let data):
81
+ guard data.id == leafID else { return self }
82
+ let newLeaf = SplitNode.leaf(LeafData(tabIndex: newTabIndex, panelType: panelType))
83
+ return .branch(BranchData(
84
+ orientation: orientation,
85
+ first: self,
86
+ second: newLeaf
87
+ ))
88
+
89
+ case .branch(var data):
90
+ data.first = data.first.split(leafID: leafID, orientation: orientation, newTabIndex: newTabIndex, panelType: panelType)
91
+ data.second = data.second.split(leafID: leafID, orientation: orientation, newTabIndex: newTabIndex, panelType: panelType)
92
+ return .branch(data)
93
+ }
94
+ }
95
+
96
+ /// Remove a leaf node by its ID. Returns nil if the tree becomes empty.
97
+ func removeLeaf(id leafID: UUID) -> SplitNode? {
98
+ switch self {
99
+ case .leaf(let data):
100
+ return data.id == leafID ? nil : self
101
+
102
+ case .branch(let data):
103
+ let firstResult = data.first.removeLeaf(id: leafID)
104
+ let secondResult = data.second.removeLeaf(id: leafID)
105
+
106
+ // If either child was removed, return the remaining one
107
+ if firstResult == nil { return secondResult }
108
+ if secondResult == nil { return firstResult }
109
+
110
+ // Both still exist — reconstruct the branch
111
+ var newData = data
112
+ newData.first = firstResult!
113
+ newData.second = secondResult!
114
+ return .branch(newData)
115
+ }
116
+ }
117
+
118
+ /// Update the split ratio for a specific branch.
119
+ func updateRatio(branchID: UUID, ratio: CGFloat) -> SplitNode {
120
+ switch self {
121
+ case .leaf:
122
+ return self
123
+ case .branch(var data):
124
+ if data.id == branchID {
125
+ data.ratio = max(0.1, min(0.9, ratio))
126
+ return .branch(data)
127
+ }
128
+ data.first = data.first.updateRatio(branchID: branchID, ratio: ratio)
129
+ data.second = data.second.updateRatio(branchID: branchID, ratio: ratio)
130
+ return .branch(data)
131
+ }
132
+ }
133
+
134
+ /// Get the next leaf after the given leaf ID (for focus navigation).
135
+ func nextLeaf(after leafID: UUID) -> LeafData? {
136
+ let leaves = allLeaves
137
+ guard let idx = leaves.firstIndex(where: { $0.id == leafID }) else { return nil }
138
+ let nextIdx = (idx + 1) % leaves.count
139
+ return leaves[nextIdx]
140
+ }
141
+
142
+ /// Get the previous leaf before the given leaf ID.
143
+ func previousLeaf(before leafID: UUID) -> LeafData? {
144
+ let leaves = allLeaves
145
+ guard let idx = leaves.firstIndex(where: { $0.id == leafID }) else { return nil }
146
+ let prevIdx = (idx - 1 + leaves.count) % leaves.count
147
+ return leaves[prevIdx]
148
+ }
149
+ }
@@ -0,0 +1,234 @@
1
+ import SwiftUI
2
+ import RemuxKit
3
+
4
+ /// Recursive SwiftUI view that renders a SplitNode tree.
5
+ /// Leaf nodes render a TerminalContainerView; branch nodes render two
6
+ /// children with a draggable divider between them.
7
+ ///
8
+ /// Architecture ref: ghostty-org/ghostty SplitView (recursive split pattern)
9
+ struct SplitView: View {
10
+ let node: SplitNode
11
+ @Binding var focusedLeafID: UUID?
12
+ var onSplit: (UUID, SplitNode.Orientation) -> Void
13
+ var onClose: (UUID) -> Void
14
+ var onRatioChange: (UUID, CGFloat) -> Void
15
+
16
+ var body: some View {
17
+ switch node {
18
+ case .leaf(let data):
19
+ SplitLeafView(
20
+ data: data,
21
+ isFocused: focusedLeafID == data.id,
22
+ onFocus: { focusedLeafID = data.id }
23
+ )
24
+
25
+ case .branch(let data):
26
+ SplitBranchView(
27
+ data: data,
28
+ focusedLeafID: $focusedLeafID,
29
+ onSplit: onSplit,
30
+ onClose: onClose,
31
+ onRatioChange: onRatioChange
32
+ )
33
+ }
34
+ }
35
+ }
36
+
37
+ /// Renders a single leaf in the split tree — dispatches by panel type.
38
+ struct SplitLeafView: View {
39
+ let data: SplitNode.LeafData
40
+ let isFocused: Bool
41
+ var onFocus: () -> Void
42
+
43
+ /// Browser panels keyed by leaf ID (kept alive across re-renders).
44
+ @State private var browserPanel: BrowserPanel?
45
+ /// Markdown panels keyed by leaf ID.
46
+ @State private var markdownPanel: MarkdownPanel?
47
+
48
+ var body: some View {
49
+ panelContent
50
+ .overlay(alignment: .topLeading) {
51
+ if isFocused {
52
+ RoundedRectangle(cornerRadius: 0)
53
+ .stroke(Color.accentColor.opacity(0.4), lineWidth: 2)
54
+ .allowsHitTesting(false)
55
+ }
56
+ }
57
+ .contentShape(Rectangle())
58
+ .onTapGesture {
59
+ onFocus()
60
+ }
61
+ }
62
+
63
+ @ViewBuilder
64
+ private var panelContent: some View {
65
+ switch data.panelType {
66
+ case .terminal:
67
+ TerminalContainerView()
68
+
69
+ case .browser:
70
+ let panel = getBrowserPanel()
71
+ BrowserPanelView(panel: panel)
72
+
73
+ case .markdown:
74
+ let panel = getMarkdownPanel()
75
+ MarkdownPanelView(panel: panel)
76
+ }
77
+ }
78
+
79
+ private func getBrowserPanel() -> BrowserPanel {
80
+ if let existing = browserPanel { return existing }
81
+ let panel = BrowserPanel(id: data.id)
82
+ DispatchQueue.main.async { browserPanel = panel }
83
+ return panel
84
+ }
85
+
86
+ private func getMarkdownPanel() -> MarkdownPanel {
87
+ if let existing = markdownPanel { return existing }
88
+ let panel = MarkdownPanel(id: data.id)
89
+ DispatchQueue.main.async { markdownPanel = panel }
90
+ return panel
91
+ }
92
+ }
93
+
94
+ /// Renders a branch node: two children separated by a draggable divider.
95
+ struct SplitBranchView: View {
96
+ let data: SplitNode.BranchData
97
+ @Binding var focusedLeafID: UUID?
98
+ var onSplit: (UUID, SplitNode.Orientation) -> Void
99
+ var onClose: (UUID) -> Void
100
+ var onRatioChange: (UUID, CGFloat) -> Void
101
+
102
+ @State private var dividerDragging = false
103
+
104
+ var body: some View {
105
+ GeometryReader { geometry in
106
+ let isHorizontal = data.orientation == .horizontal
107
+ let totalSize = isHorizontal ? geometry.size.width : geometry.size.height
108
+ let dividerThickness: CGFloat = 4
109
+ let availableSize = totalSize - dividerThickness
110
+ let firstSize = availableSize * data.ratio
111
+ let secondSize = availableSize * (1 - data.ratio)
112
+
113
+ if isHorizontal {
114
+ HStack(spacing: 0) {
115
+ // First child
116
+ SplitView(
117
+ node: data.first,
118
+ focusedLeafID: $focusedLeafID,
119
+ onSplit: onSplit,
120
+ onClose: onClose,
121
+ onRatioChange: onRatioChange
122
+ )
123
+ .frame(width: max(30, firstSize))
124
+
125
+ // Draggable divider
126
+ SplitDivider(isHorizontal: true, isDragging: $dividerDragging)
127
+ .gesture(
128
+ DragGesture()
129
+ .onChanged { value in
130
+ dividerDragging = true
131
+ let newRatio = (firstSize + value.translation.width) / availableSize
132
+ onRatioChange(data.id, newRatio)
133
+ }
134
+ .onEnded { _ in
135
+ dividerDragging = false
136
+ }
137
+ )
138
+
139
+ // Second child
140
+ SplitView(
141
+ node: data.second,
142
+ focusedLeafID: $focusedLeafID,
143
+ onSplit: onSplit,
144
+ onClose: onClose,
145
+ onRatioChange: onRatioChange
146
+ )
147
+ .frame(width: max(30, secondSize))
148
+ }
149
+ } else {
150
+ VStack(spacing: 0) {
151
+ // First child
152
+ SplitView(
153
+ node: data.first,
154
+ focusedLeafID: $focusedLeafID,
155
+ onSplit: onSplit,
156
+ onClose: onClose,
157
+ onRatioChange: onRatioChange
158
+ )
159
+ .frame(height: max(30, firstSize))
160
+
161
+ // Draggable divider
162
+ SplitDivider(isHorizontal: false, isDragging: $dividerDragging)
163
+ .gesture(
164
+ DragGesture()
165
+ .onChanged { value in
166
+ dividerDragging = true
167
+ let newRatio = (firstSize + value.translation.height) / availableSize
168
+ onRatioChange(data.id, newRatio)
169
+ }
170
+ .onEnded { _ in
171
+ dividerDragging = false
172
+ }
173
+ )
174
+
175
+ // Second child
176
+ SplitView(
177
+ node: data.second,
178
+ focusedLeafID: $focusedLeafID,
179
+ onSplit: onSplit,
180
+ onClose: onClose,
181
+ onRatioChange: onRatioChange
182
+ )
183
+ .frame(height: max(30, secondSize))
184
+ }
185
+ }
186
+ }
187
+ }
188
+ }
189
+
190
+ /// The visual divider between split panes, with hover and drag states.
191
+ struct SplitDivider: View {
192
+ let isHorizontal: Bool
193
+ @Binding var isDragging: Bool
194
+ @State private var isHovering = false
195
+
196
+ var body: some View {
197
+ Rectangle()
198
+ .fill(fillColor)
199
+ .frame(
200
+ width: isHorizontal ? 4 : nil,
201
+ height: isHorizontal ? nil : 4
202
+ )
203
+ .onHover { hovering in
204
+ isHovering = hovering
205
+ if hovering {
206
+ NSCursor.resizeLeftRight.push()
207
+ } else {
208
+ NSCursor.pop()
209
+ }
210
+ }
211
+ .onContinuousHover { phase in
212
+ switch phase {
213
+ case .active:
214
+ if isHorizontal {
215
+ NSCursor.resizeLeftRight.set()
216
+ } else {
217
+ NSCursor.resizeUpDown.set()
218
+ }
219
+ case .ended:
220
+ NSCursor.arrow.set()
221
+ }
222
+ }
223
+ }
224
+
225
+ private var fillColor: Color {
226
+ if isDragging {
227
+ return Color.accentColor.opacity(0.6)
228
+ } else if isHovering {
229
+ return Color.accentColor.opacity(0.3)
230
+ } else {
231
+ return Color.primary.opacity(0.08)
232
+ }
233
+ }
234
+ }
@@ -0,0 +1,26 @@
1
+ import Foundation
2
+
3
+ /// Terminal panel conforming to PanelProtocol.
4
+ /// Wraps the terminal state for a single pane in the split tree.
5
+ @MainActor
6
+ final class TerminalPanel: PanelProtocol {
7
+ let id: UUID
8
+ let tabIndex: Int
9
+ private(set) var isFocused = false
10
+
11
+ var title: String { "Terminal \(tabIndex)" }
12
+ var canClose: Bool { true }
13
+
14
+ init(id: UUID = UUID(), tabIndex: Int) {
15
+ self.id = id
16
+ self.tabIndex = tabIndex
17
+ }
18
+
19
+ func focus() {
20
+ isFocused = true
21
+ }
22
+
23
+ func blur() {
24
+ isFocused = false
25
+ }
26
+ }