@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.
- package/.github/ISSUE_TEMPLATE/bug_report.md +47 -0
- package/.github/ISSUE_TEMPLATE/feature_request.md +38 -0
- package/.github/PULL_REQUEST_TEMPLATE.md +28 -0
- package/.github/dependabot.yml +33 -0
- package/.github/workflows/ci.yml +65 -0
- package/.github/workflows/deploy.yml +65 -0
- package/.github/workflows/publish.yml +312 -0
- package/.github/workflows/release-please.yml +21 -0
- package/.gitmodules +3 -0
- package/.nvmrc +1 -0
- package/.release-please-manifest.json +3 -0
- package/CLAUDE.md +104 -0
- package/Dockerfile +23 -0
- package/LICENSE +21 -0
- package/README.md +120 -0
- package/apps/ios/Config/signing.xcconfig +4 -0
- package/apps/ios/Package.swift +26 -0
- package/apps/ios/Remux.xcodeproj/project.pbxproj +477 -0
- package/apps/ios/Remux.xcodeproj/project.xcworkspace/contents.xcworkspacedata +7 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/Contents.json +23 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_1024x1024.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_120x120.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_152x152.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_167x167.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_180x180.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_20x20.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_29x29.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_40x40.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_58x58.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_60x60.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_76x76.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_80x80.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_87x87.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/Contents.json +6 -0
- package/apps/ios/Sources/Remux/Extensions/FaceIDManager.swift +29 -0
- package/apps/ios/Sources/Remux/Extensions/InspectCache.swift +66 -0
- package/apps/ios/Sources/Remux/MainTabView.swift +32 -0
- package/apps/ios/Sources/Remux/Remux.entitlements +8 -0
- package/apps/ios/Sources/Remux/RemuxiOSApp.swift +14 -0
- package/apps/ios/Sources/Remux/RootView.swift +130 -0
- package/apps/ios/Sources/Remux/Views/Control/ControlView.swift +102 -0
- package/apps/ios/Sources/Remux/Views/Inspect/InspectView.swift +98 -0
- package/apps/ios/Sources/Remux/Views/Live/LiveTerminalView.swift +132 -0
- package/apps/ios/Sources/Remux/Views/Now/NowView.swift +173 -0
- package/apps/ios/Sources/Remux/Views/Onboarding/ManualConnectView.swift +55 -0
- package/apps/ios/Sources/Remux/Views/Onboarding/OnboardingView.swift +70 -0
- package/apps/ios/Sources/Remux/Views/Onboarding/QRScannerView.swift +92 -0
- package/apps/ios/Sources/Remux/Views/Settings/MeView.swift +136 -0
- package/apps/macos/Package.swift +37 -0
- package/apps/macos/Resources/shell-integration/bash/bash-preexec.sh +382 -0
- package/apps/macos/Resources/shell-integration/bash/ghostty.bash +315 -0
- package/apps/macos/Resources/shell-integration/elvish/lib/ghostty-integration.elv +191 -0
- package/apps/macos/Resources/shell-integration/fish/vendor_conf.d/ghostty-shell-integration.fish +246 -0
- package/apps/macos/Resources/shell-integration/nushell/vendor/autoload/ghostty.nu +110 -0
- package/apps/macos/Resources/shell-integration/zsh/.zshenv +61 -0
- package/apps/macos/Resources/shell-integration/zsh/ghostty-integration +458 -0
- package/apps/macos/Resources/terminfo/67/ghostty +0 -0
- package/apps/macos/Resources/terminfo/78/xterm-ghostty +0 -0
- package/apps/macos/Sources/Remux/AppDelegate.swift +257 -0
- package/apps/macos/Sources/Remux/CrashReporter.swift +210 -0
- package/apps/macos/Sources/Remux/FinderIntegration.swift +117 -0
- package/apps/macos/Sources/Remux/GhosttyConfig.swift +311 -0
- package/apps/macos/Sources/Remux/KeyboardShortcuts/ShortcutAction.swift +115 -0
- package/apps/macos/Sources/Remux/KeyboardShortcuts/ShortcutSettingsView.swift +271 -0
- package/apps/macos/Sources/Remux/KeyboardShortcuts/StoredShortcut.swift +149 -0
- package/apps/macos/Sources/Remux/MainContentView.swift +308 -0
- package/apps/macos/Sources/Remux/MenuBarManager.swift +275 -0
- package/apps/macos/Sources/Remux/NotificationManager.swift +145 -0
- package/apps/macos/Sources/Remux/PortScanner.swift +152 -0
- package/apps/macos/Sources/Remux/RemuxApp.swift +13 -0
- package/apps/macos/Sources/Remux/SSHDetector.swift +151 -0
- package/apps/macos/Sources/Remux/SessionPersistence.swift +226 -0
- package/apps/macos/Sources/Remux/SocketController.swift +258 -0
- package/apps/macos/Sources/Remux/UpdateChecker.swift +152 -0
- package/apps/macos/Sources/Remux/Views/CommandPalette.swift +198 -0
- package/apps/macos/Sources/Remux/Views/ConnectionView.swift +84 -0
- package/apps/macos/Sources/Remux/Views/InspectView.swift +127 -0
- package/apps/macos/Sources/Remux/Views/SettingsView.swift +77 -0
- package/apps/macos/Sources/Remux/Views/Sidebar/SidebarView.swift +410 -0
- package/apps/macos/Sources/Remux/Views/SplitTree/BrowserPanel.swift +193 -0
- package/apps/macos/Sources/Remux/Views/SplitTree/MarkdownPanel.swift +277 -0
- package/apps/macos/Sources/Remux/Views/SplitTree/PanelProtocol.swift +14 -0
- package/apps/macos/Sources/Remux/Views/SplitTree/SplitNode.swift +149 -0
- package/apps/macos/Sources/Remux/Views/SplitTree/SplitView.swift +234 -0
- package/apps/macos/Sources/Remux/Views/SplitTree/TerminalPanel.swift +26 -0
- package/apps/macos/Sources/Remux/Views/TabBarView.swift +94 -0
- package/apps/macos/Sources/Remux/Views/Terminal/ClipboardHelper.swift +101 -0
- package/apps/macos/Sources/Remux/Views/Terminal/CopyModeOverlay.swift +325 -0
- package/apps/macos/Sources/Remux/Views/Terminal/GhosttyNativeTerminalView.swift +39 -0
- package/apps/macos/Sources/Remux/Views/Terminal/GhosttyNativeView.swift +559 -0
- package/apps/macos/Sources/Remux/Views/Terminal/SurfaceSearchOverlay.swift +109 -0
- package/apps/macos/Sources/Remux/Views/Terminal/TerminalContainerView.swift +95 -0
- package/apps/macos/Sources/Remux/Views/Terminal/TerminalRelay.swift +117 -0
- package/build.mjs +33 -0
- package/native/android/DecodeGoldenPayloads.kt +487 -0
- package/native/android/ProtocolModels.kt +188 -0
- package/native/ios/DecodeGoldenPayloads.swift +711 -0
- package/native/ios/ProtocolModels.swift +200 -0
- package/package.json +45 -0
- package/packages/RemuxKit/Package.swift +27 -0
- package/packages/RemuxKit/Sources/RemuxKit/Device/DeviceManager.swift +27 -0
- package/packages/RemuxKit/Sources/RemuxKit/Models/ProtocolModels.swift +206 -0
- package/packages/RemuxKit/Sources/RemuxKit/Networking/MessageRouter.swift +108 -0
- package/packages/RemuxKit/Sources/RemuxKit/Networking/RemuxConnection.swift +395 -0
- package/packages/RemuxKit/Sources/RemuxKit/State/RemuxState.swift +188 -0
- package/packages/RemuxKit/Sources/RemuxKit/Storage/KeychainStore.swift +142 -0
- package/packages/RemuxKit/Sources/RemuxKit/Terminal/GhosttyBridge.swift +145 -0
- package/packages/RemuxKit/Sources/RemuxKit/Terminal/GhosttyTerminalView.swift +35 -0
- package/packages/RemuxKit/Sources/RemuxKit/Terminal/Resources/ghostty-terminal.html +91 -0
- package/packages/RemuxKit/Tests/RemuxKitTests/ConnectionIntegrationTest.swift +74 -0
- package/packages/RemuxKit/Tests/RemuxKitTests/KeychainStoreTests.swift +81 -0
- package/packages/RemuxKit/Tests/RemuxKitTests/ProtocolModelsTests.swift +179 -0
- package/packages/RemuxKit/Tests/RemuxKitTests/RemuxStateTests.swift +62 -0
- package/playwright.config.ts +17 -0
- package/pnpm-lock.yaml +1588 -0
- package/pty-daemon.js +303 -0
- package/release-please-config.json +14 -0
- package/scripts/auto-deploy.sh +46 -0
- package/scripts/build-dmg.sh +121 -0
- package/scripts/build-ghostty-kit.sh +43 -0
- package/scripts/check-active-terminology.mjs +132 -0
- package/scripts/setup-ci-secrets.sh +80 -0
- package/scripts/sync-ghostty-web.sh +28 -0
- package/scripts/upload-testflight.sh +100 -0
- package/server.js +7074 -0
- package/src/adapters/agent-events.ts +246 -0
- package/src/adapters/claude-code.ts +158 -0
- package/src/adapters/codex.ts +210 -0
- package/src/adapters/generic-shell.ts +58 -0
- package/src/adapters/index.ts +15 -0
- package/src/adapters/registry.ts +99 -0
- package/src/adapters/types.ts +41 -0
- package/src/auth.ts +174 -0
- package/src/e2ee.ts +236 -0
- package/src/git-service.ts +168 -0
- package/src/message-buffer.ts +137 -0
- package/src/pty-daemon.ts +357 -0
- package/src/push.ts +127 -0
- package/src/renderers.ts +455 -0
- package/src/server.ts +2407 -0
- package/src/service.ts +226 -0
- package/src/session.ts +978 -0
- package/src/store.ts +1422 -0
- package/src/team.ts +123 -0
- package/src/tunnel.ts +126 -0
- package/src/types.d.ts +50 -0
- package/src/vt-tracker.ts +188 -0
- package/src/workspace-head.ts +144 -0
- package/src/workspace.ts +153 -0
- package/src/ws-handler.ts +1526 -0
- package/start.ps1 +83 -0
- package/tests/adapters.test.js +171 -0
- package/tests/auth.test.js +243 -0
- package/tests/codex-adapter.test.js +535 -0
- package/tests/durable-stream.test.js +153 -0
- package/tests/e2e/app.spec.js +530 -0
- package/tests/e2ee.test.js +325 -0
- package/tests/message-buffer.test.js +245 -0
- package/tests/message-routing.test.js +305 -0
- package/tests/pty-daemon.test.js +346 -0
- package/tests/push.test.js +281 -0
- package/tests/renderers.test.js +391 -0
- package/tests/search-shell.test.js +499 -0
- package/tests/server.test.js +882 -0
- package/tests/service.test.js +267 -0
- package/tests/store.test.js +369 -0
- package/tests/tunnel.test.js +67 -0
- package/tests/workspace-head.test.js +116 -0
- package/tests/workspace.test.js +417 -0
- package/tsconfig.backend.json +11 -0
- package/tsconfig.json +15 -0
- package/tui/client/client_test.go +125 -0
- package/tui/client/connection.go +342 -0
- package/tui/client/host_manager.go +141 -0
- package/tui/config/cache.go +81 -0
- package/tui/config/config.go +53 -0
- package/tui/config/config_test.go +89 -0
- package/tui/go.mod +32 -0
- package/tui/go.sum +50 -0
- package/tui/main.go +261 -0
- package/tui/tests/integration_test.go +283 -0
- package/tui/ui/model.go +310 -0
- 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
|
+
}
|