@wangyaoshen/remux 0.3.8-dev.a8ceb0c

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,410 @@
1
+ import SwiftUI
2
+ import RemuxKit
3
+
4
+ /// Workspace sidebar showing sessions, tabs, connection status, and update banner.
5
+ /// Supports git branch display, drag-to-reorder, and pin/unpin.
6
+ /// Design ref: cmux TabManager/Workspace sidebar pattern
7
+ struct SidebarView: View {
8
+ @Environment(RemuxState.self) private var state
9
+
10
+ /// Per-tab workspace colors. Key = tab index.
11
+ @State private var tabColors: [Int: Color] = [:]
12
+
13
+ /// Set of tab indices with unread activity (tabs that received data while not active).
14
+ @State private var unreadTabs: Set<Int> = []
15
+
16
+ /// Tab index currently being renamed (inline editing).
17
+ @State private var renamingTabIndex: Int?
18
+
19
+ /// Text field value for inline rename.
20
+ @State private var renameText: String = ""
21
+
22
+ /// Update checker instance (shared across views).
23
+ @State private var updateChecker = UpdateChecker()
24
+
25
+ /// Ordered tab indices for drag-to-reorder.
26
+ @State private var tabOrder: [Int] = []
27
+
28
+ /// Set of pinned tab indices.
29
+ @State private var pinnedTabs: Set<Int> = []
30
+
31
+ /// Git branch per tab (parsed from CWD's .git/HEAD).
32
+ @State private var tabGitBranches: [Int: String] = [:]
33
+
34
+ /// Port scanner (passed from parent).
35
+ var portScanner: PortScanner?
36
+
37
+ /// Preset colors for workspace color picker.
38
+ private let presetColors: [Color] = [
39
+ .red, .orange, .yellow, .green, .mint, .teal,
40
+ .cyan, .blue, .indigo, .purple, .pink, .brown,
41
+ .gray, Color(nsColor: .systemTeal), Color(nsColor: .systemIndigo),
42
+ Color(nsColor: .controlAccentColor),
43
+ ]
44
+
45
+ /// Sorted tabs: pinned first, then ordered.
46
+ private var sortedTabs: [WorkspaceTab] {
47
+ let tabs = state.tabs
48
+ let pinned = tabs.filter { pinnedTabs.contains($0.index) }
49
+ let unpinned = tabs.filter { !pinnedTabs.contains($0.index) }
50
+ return pinned + unpinned
51
+ }
52
+
53
+ var body: some View {
54
+ VStack(spacing: 0) {
55
+ List {
56
+ // Connection status
57
+ Section {
58
+ HStack {
59
+ Circle()
60
+ .fill(statusColor)
61
+ .frame(width: 8, height: 8)
62
+ Text(statusText)
63
+ .font(.caption)
64
+ .foregroundStyle(.secondary)
65
+ }
66
+ }
67
+
68
+ // Tab list with drag-to-reorder
69
+ Section("Tabs") {
70
+ ForEach(sortedTabs, id: \.index) { tab in
71
+ SidebarTabRow(
72
+ tab: tab,
73
+ isActive: tab.active,
74
+ tabColor: tabColors[tab.index],
75
+ isUnread: unreadTabs.contains(tab.index),
76
+ isRenaming: renamingTabIndex == tab.index,
77
+ isPinned: pinnedTabs.contains(tab.index),
78
+ gitBranch: tabGitBranches[tab.index],
79
+ renameText: renamingTabIndex == tab.index ? $renameText : .constant(""),
80
+ presetColors: presetColors,
81
+ detectedPorts: portScanner?.ports ?? [],
82
+ onSelect: {
83
+ if let pane = tab.panes.first {
84
+ state.switchTab(id: pane.id)
85
+ }
86
+ unreadTabs.remove(tab.index)
87
+ },
88
+ onRename: {
89
+ renamingTabIndex = tab.index
90
+ renameText = tab.name
91
+ },
92
+ onCommitRename: {
93
+ if let pane = tab.panes.first, !renameText.isEmpty {
94
+ state.renameTab(id: pane.id, name: renameText)
95
+ }
96
+ renamingTabIndex = nil
97
+ },
98
+ onCancelRename: {
99
+ renamingTabIndex = nil
100
+ },
101
+ onColorSelect: { color in
102
+ tabColors[tab.index] = color
103
+ },
104
+ onTogglePin: {
105
+ if pinnedTabs.contains(tab.index) {
106
+ pinnedTabs.remove(tab.index)
107
+ } else {
108
+ pinnedTabs.insert(tab.index)
109
+ }
110
+ },
111
+ onOpenPort: { _ in }
112
+ )
113
+ }
114
+ .onMove { source, destination in
115
+ var ordered = sortedTabs.map(\.index)
116
+ ordered.move(fromOffsets: source, toOffset: destination)
117
+ tabOrder = ordered
118
+ }
119
+ }
120
+
121
+ // Actions
122
+ Section {
123
+ Button {
124
+ state.createTab()
125
+ } label: {
126
+ Label("New Tab", systemImage: "plus")
127
+ }
128
+ .buttonStyle(.plain)
129
+ }
130
+ }
131
+ .listStyle(.sidebar)
132
+
133
+ // Footer: update banner
134
+ if updateChecker.hasUpdate, let version = updateChecker.latestVersion {
135
+ SidebarUpdateBanner(
136
+ version: version,
137
+ onDownload: { updateChecker.openReleasePage() },
138
+ onDismiss: { updateChecker.dismissCurrentUpdate() }
139
+ )
140
+ }
141
+ }
142
+ .navigationTitle(state.currentSession.isEmpty ? "Remux" : state.currentSession)
143
+ .onAppear {
144
+ updateChecker.start()
145
+ refreshGitBranches()
146
+ }
147
+ .onChange(of: state.tabs) { _, _ in
148
+ refreshGitBranches()
149
+ }
150
+ }
151
+
152
+ // MARK: - Git branch detection
153
+
154
+ /// Parse .git/HEAD from each tab's CWD to get the current branch.
155
+ private func refreshGitBranches() {
156
+ for tab in state.tabs {
157
+ guard let cwd = tab.panes.first?.cwd, !cwd.isEmpty else { continue }
158
+ let gitHead = (cwd as NSString).appendingPathComponent(".git/HEAD")
159
+ if let content = try? String(contentsOfFile: gitHead, encoding: .utf8) {
160
+ let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines)
161
+ if trimmed.hasPrefix("ref: refs/heads/") {
162
+ let branch = String(trimmed.dropFirst("ref: refs/heads/".count))
163
+ tabGitBranches[tab.index] = branch
164
+ } else {
165
+ // Detached HEAD — show short hash
166
+ tabGitBranches[tab.index] = String(trimmed.prefix(7))
167
+ }
168
+ }
169
+ }
170
+ }
171
+
172
+ private var statusColor: Color {
173
+ switch state.connectionStatus {
174
+ case .connected: .green
175
+ case .reconnecting: .yellow
176
+ case .connecting, .authenticating: .orange
177
+ case .disconnected: .red
178
+ }
179
+ }
180
+
181
+ private var statusText: String {
182
+ switch state.connectionStatus {
183
+ case .connected: "Connected"
184
+ case .reconnecting(let attempt): "Reconnecting (\(attempt))..."
185
+ case .connecting: "Connecting..."
186
+ case .authenticating: "Authenticating..."
187
+ case .disconnected: "Disconnected"
188
+ }
189
+ }
190
+ }
191
+
192
+ // MARK: - Sidebar Tab Row
193
+
194
+ /// A single tab row in the sidebar with color indicator, unread dot, pin, git branch, and inline rename.
195
+ struct SidebarTabRow: View {
196
+ let tab: WorkspaceTab
197
+ let isActive: Bool
198
+ let tabColor: Color?
199
+ let isUnread: Bool
200
+ let isRenaming: Bool
201
+ let isPinned: Bool
202
+ let gitBranch: String?
203
+ @Binding var renameText: String
204
+ let presetColors: [Color]
205
+ let detectedPorts: [PortScanner.DetectedPort]
206
+ var onSelect: () -> Void
207
+ var onRename: () -> Void
208
+ var onCommitRename: () -> Void
209
+ var onCancelRename: () -> Void
210
+ var onColorSelect: (Color) -> Void
211
+ var onTogglePin: () -> Void
212
+ var onOpenPort: (Int) -> Void
213
+
214
+ var body: some View {
215
+ VStack(alignment: .leading, spacing: 2) {
216
+ Button(action: onSelect) {
217
+ HStack(spacing: 6) {
218
+ // Pin indicator
219
+ if isPinned {
220
+ Image(systemName: "pin.fill")
221
+ .font(.system(size: 8))
222
+ .foregroundStyle(.orange)
223
+ }
224
+
225
+ // Workspace color dot
226
+ if let color = tabColor {
227
+ Circle()
228
+ .fill(color)
229
+ .frame(width: 6, height: 6)
230
+ }
231
+
232
+ Image(systemName: "terminal")
233
+ .foregroundStyle(isActive ? .primary : .secondary)
234
+
235
+ // Tab name (editable or static)
236
+ if isRenaming {
237
+ TextField("Tab Name", text: $renameText, onCommit: onCommitRename)
238
+ .textFieldStyle(.roundedBorder)
239
+ .frame(maxWidth: 120)
240
+ .onExitCommand(perform: onCancelRename)
241
+ } else {
242
+ Text(tab.name)
243
+ .lineLimit(1)
244
+ }
245
+
246
+ Spacer()
247
+
248
+ // Unread activity indicator
249
+ if isUnread && !isActive {
250
+ UnreadDot()
251
+ }
252
+
253
+ // Bell indicator
254
+ if tab.hasBell {
255
+ Circle()
256
+ .fill(.red)
257
+ .frame(width: 6, height: 6)
258
+ }
259
+ }
260
+ }
261
+ .buttonStyle(.plain)
262
+
263
+ // Git branch display
264
+ if let branch = gitBranch {
265
+ HStack(spacing: 4) {
266
+ Image(systemName: "arrow.triangle.branch")
267
+ .font(.system(size: 9))
268
+ .foregroundStyle(.purple)
269
+ Text(branch)
270
+ .font(.system(size: 10, design: .monospaced))
271
+ .foregroundStyle(.purple.opacity(0.8))
272
+ .lineLimit(1)
273
+ }
274
+ .padding(.leading, isPinned ? 20 : 14)
275
+ }
276
+
277
+ // Detected ports for this tab
278
+ let tabPorts = detectedPorts
279
+ if !tabPorts.isEmpty && isActive {
280
+ ForEach(tabPorts) { port in
281
+ Button(action: { onOpenPort(port.port) }) {
282
+ HStack(spacing: 4) {
283
+ Image(systemName: "network")
284
+ .font(.system(size: 9))
285
+ .foregroundStyle(.blue)
286
+ Text(":\(port.port)")
287
+ .font(.system(size: 10, design: .monospaced))
288
+ .foregroundStyle(.blue)
289
+ Text(port.processName)
290
+ .font(.system(size: 9))
291
+ .foregroundStyle(.secondary)
292
+ }
293
+ }
294
+ .buttonStyle(.plain)
295
+ .padding(.leading, isPinned ? 20 : 14)
296
+ }
297
+ }
298
+ }
299
+ .padding(.vertical, 2)
300
+ .contextMenu {
301
+ // Pin/Unpin
302
+ Button(isPinned ? "Unpin Tab" : "Pin Tab") { onTogglePin() }
303
+
304
+ // Rename
305
+ Button("Rename Tab") { onRename() }
306
+
307
+ Divider()
308
+
309
+ // Color picker submenu
310
+ Menu("Set Color") {
311
+ ForEach(Array(presetColors.enumerated()), id: \.offset) { idx, color in
312
+ Button {
313
+ onColorSelect(color)
314
+ } label: {
315
+ Label {
316
+ Text("Color \(idx + 1)")
317
+ } icon: {
318
+ Image(systemName: "circle.fill")
319
+ .foregroundStyle(color)
320
+ }
321
+ }
322
+ }
323
+
324
+ Divider()
325
+
326
+ Button("Remove Color") {
327
+ onColorSelect(.clear)
328
+ }
329
+ }
330
+
331
+ Divider()
332
+
333
+ Button("Close Tab") {
334
+ // Close handled via environment in the parent
335
+ }
336
+ }
337
+ .onTapGesture(count: 2) {
338
+ // Double-click to rename
339
+ onRename()
340
+ }
341
+ .onTapGesture(count: 1) {
342
+ onSelect()
343
+ }
344
+ }
345
+ }
346
+
347
+ // MARK: - Unread Activity Dot
348
+
349
+ /// Animated red dot indicating unread terminal activity.
350
+ struct UnreadDot: View {
351
+ @State private var isPulsing = false
352
+
353
+ var body: some View {
354
+ Circle()
355
+ .fill(.red)
356
+ .frame(width: 8, height: 8)
357
+ .scaleEffect(isPulsing ? 1.3 : 1.0)
358
+ .opacity(isPulsing ? 0.7 : 1.0)
359
+ .animation(
360
+ .easeInOut(duration: 0.8).repeatForever(autoreverses: true),
361
+ value: isPulsing
362
+ )
363
+ .onAppear { isPulsing = true }
364
+ }
365
+ }
366
+
367
+ // MARK: - Update Banner
368
+
369
+ /// Banner shown in sidebar footer when a new version is available.
370
+ struct SidebarUpdateBanner: View {
371
+ let version: String
372
+ var onDownload: () -> Void
373
+ var onDismiss: () -> Void
374
+
375
+ var body: some View {
376
+ VStack(spacing: 6) {
377
+ Divider()
378
+
379
+ HStack(spacing: 8) {
380
+ Image(systemName: "arrow.down.circle.fill")
381
+ .foregroundStyle(.blue)
382
+ .font(.system(size: 14))
383
+
384
+ VStack(alignment: .leading, spacing: 1) {
385
+ Text("Update Available")
386
+ .font(.caption.weight(.medium))
387
+ Text("v\(version)")
388
+ .font(.caption2)
389
+ .foregroundStyle(.secondary)
390
+ }
391
+
392
+ Spacer()
393
+
394
+ Button("Download") { onDownload() }
395
+ .controlSize(.small)
396
+ .buttonStyle(.borderedProminent)
397
+
398
+ Button(action: onDismiss) {
399
+ Image(systemName: "xmark")
400
+ .font(.system(size: 9, weight: .bold))
401
+ .foregroundStyle(.secondary)
402
+ }
403
+ .buttonStyle(.plain)
404
+ }
405
+ .padding(.horizontal, 12)
406
+ .padding(.vertical, 8)
407
+ }
408
+ .background(.bar)
409
+ }
410
+ }
@@ -0,0 +1,193 @@
1
+ import AppKit
2
+ import WebKit
3
+ import SwiftUI
4
+
5
+ /// Panel type for identifying what kind of panel a split leaf holds.
6
+ enum PanelType: String, Codable, Sendable {
7
+ case terminal
8
+ case browser
9
+ case markdown
10
+ }
11
+
12
+ /// WKWebView-based browser panel for embedding in the split pane system.
13
+ /// Conforms to PanelProtocol. Triggered by Cmd+Shift+B or menu item.
14
+ /// Adapted from wave-terminal browser panel pattern.
15
+ @MainActor
16
+ final class BrowserPanel: NSObject, PanelProtocol, WKNavigationDelegate {
17
+ let id: UUID
18
+ private(set) var isFocused = false
19
+ private(set) var currentURL: URL?
20
+ private(set) var pageTitle: String?
21
+ private(set) var canGoBack: Bool = false
22
+ private(set) var canGoForward: Bool = false
23
+ private(set) var isLoading: Bool = false
24
+
25
+ let webView: WKWebView
26
+
27
+ var title: String {
28
+ pageTitle ?? currentURL?.host ?? "Browser"
29
+ }
30
+
31
+ var canClose: Bool { true }
32
+
33
+ /// Called when navigation state changes (URL, title, loading, back/forward).
34
+ var onStateChange: (() -> Void)?
35
+
36
+ init(id: UUID = UUID(), url: URL? = nil) {
37
+ self.id = id
38
+
39
+ let config = WKWebViewConfiguration()
40
+ config.preferences.isElementFullscreenEnabled = true
41
+ self.webView = WKWebView(frame: .zero, configuration: config)
42
+
43
+ super.init()
44
+
45
+ webView.navigationDelegate = self
46
+ webView.allowsBackForwardNavigationGestures = true
47
+
48
+ if let url {
49
+ navigate(to: url)
50
+ } else {
51
+ navigate(to: URL(string: "https://www.google.com")!)
52
+ }
53
+ }
54
+
55
+ func focus() { isFocused = true }
56
+ func blur() { isFocused = false }
57
+
58
+ // MARK: - Navigation
59
+
60
+ func navigate(to url: URL) {
61
+ let request = URLRequest(url: url)
62
+ webView.load(request)
63
+ }
64
+
65
+ func navigateToString(_ urlString: String) {
66
+ var str = urlString.trimmingCharacters(in: .whitespacesAndNewlines)
67
+ if !str.contains("://") {
68
+ if str.contains(".") && !str.contains(" ") {
69
+ str = "https://" + str
70
+ } else {
71
+ str = "https://www.google.com/search?q=" + (str.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? str)
72
+ }
73
+ }
74
+ guard let url = URL(string: str) else { return }
75
+ navigate(to: url)
76
+ }
77
+
78
+ func goBack() { webView.goBack() }
79
+ func goForward() { webView.goForward() }
80
+ func reload() { webView.reload() }
81
+
82
+ // MARK: - WKNavigationDelegate
83
+
84
+ nonisolated func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
85
+ Task { @MainActor in
86
+ self.currentURL = webView.url
87
+ self.pageTitle = webView.title
88
+ self.canGoBack = webView.canGoBack
89
+ self.canGoForward = webView.canGoForward
90
+ self.isLoading = false
91
+ self.onStateChange?()
92
+ }
93
+ }
94
+
95
+ nonisolated func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
96
+ Task { @MainActor in
97
+ self.isLoading = true
98
+ self.currentURL = webView.url
99
+ self.onStateChange?()
100
+ }
101
+ }
102
+
103
+ nonisolated func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
104
+ Task { @MainActor in
105
+ self.isLoading = false
106
+ self.onStateChange?()
107
+ }
108
+ }
109
+
110
+ nonisolated func webView(_ webView: WKWebView, didCommit navigation: WKNavigation!) {
111
+ Task { @MainActor in
112
+ self.currentURL = webView.url
113
+ self.canGoBack = webView.canGoBack
114
+ self.canGoForward = webView.canGoForward
115
+ self.onStateChange?()
116
+ }
117
+ }
118
+ }
119
+
120
+ // MARK: - BrowserPanelView (SwiftUI)
121
+
122
+ /// SwiftUI view wrapping a BrowserPanel with toolbar (back, forward, refresh, URL bar).
123
+ struct BrowserPanelView: View {
124
+ @State private var panel: BrowserPanel
125
+ @State private var urlText: String = ""
126
+ @State private var refreshID: UUID = UUID()
127
+
128
+ init(panel: BrowserPanel) {
129
+ _panel = State(initialValue: panel)
130
+ _urlText = State(initialValue: panel.currentURL?.absoluteString ?? "")
131
+ }
132
+
133
+ var body: some View {
134
+ VStack(spacing: 0) {
135
+ // Toolbar
136
+ HStack(spacing: 6) {
137
+ Button(action: { panel.goBack() }) {
138
+ Image(systemName: "chevron.left")
139
+ .font(.system(size: 12, weight: .medium))
140
+ }
141
+ .buttonStyle(.borderless)
142
+ .disabled(!panel.canGoBack)
143
+
144
+ Button(action: { panel.goForward() }) {
145
+ Image(systemName: "chevron.right")
146
+ .font(.system(size: 12, weight: .medium))
147
+ }
148
+ .buttonStyle(.borderless)
149
+ .disabled(!panel.canGoForward)
150
+
151
+ Button(action: { panel.reload() }) {
152
+ Image(systemName: panel.isLoading ? "xmark" : "arrow.clockwise")
153
+ .font(.system(size: 11, weight: .medium))
154
+ }
155
+ .buttonStyle(.borderless)
156
+
157
+ TextField("URL", text: $urlText)
158
+ .textFieldStyle(.roundedBorder)
159
+ .font(.system(size: 12))
160
+ .onSubmit {
161
+ panel.navigateToString(urlText)
162
+ }
163
+ }
164
+ .padding(.horizontal, 8)
165
+ .padding(.vertical, 4)
166
+ .background(.bar)
167
+
168
+ Divider()
169
+
170
+ // Web content
171
+ BrowserWebViewRepresentable(webView: panel.webView)
172
+ .id(refreshID)
173
+ }
174
+ .onAppear {
175
+ panel.onStateChange = { [weak panel] in
176
+ guard let panel else { return }
177
+ urlText = panel.currentURL?.absoluteString ?? ""
178
+ refreshID = UUID()
179
+ }
180
+ }
181
+ }
182
+ }
183
+
184
+ /// NSViewRepresentable wrapping WKWebView.
185
+ struct BrowserWebViewRepresentable: NSViewRepresentable {
186
+ let webView: WKWebView
187
+
188
+ func makeNSView(context: Context) -> WKWebView {
189
+ return webView
190
+ }
191
+
192
+ func updateNSView(_ nsView: WKWebView, context: Context) {}
193
+ }