@wangyaoshen/remux 0.3.8-dev.bab6c95
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 +138 -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 +456 -0
- package/apps/ios/Remux.xcodeproj/project.xcworkspace/contents.xcworkspacedata +7 -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/sync-ghostty-web.sh +28 -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,66 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import RemuxKit
|
|
3
|
+
|
|
4
|
+
/// Offline Inspect cache using file-based storage.
|
|
5
|
+
/// Caches up to 50MB of recent Inspect snapshots, LRU eviction.
|
|
6
|
+
actor InspectCache {
|
|
7
|
+
static let shared = InspectCache()
|
|
8
|
+
|
|
9
|
+
private let cacheDir: URL
|
|
10
|
+
private let maxSize: Int = 50 * 1024 * 1024 // 50MB
|
|
11
|
+
|
|
12
|
+
private init() {
|
|
13
|
+
let base = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first!
|
|
14
|
+
cacheDir = base.appendingPathComponent("remux-inspect-cache", isDirectory: true)
|
|
15
|
+
try? FileManager.default.createDirectory(at: cacheDir, withIntermediateDirectories: true)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
func save(snapshot: InspectSnapshot, server: String, tabIndex: Int) {
|
|
19
|
+
let key = cacheKey(server: server, tabIndex: tabIndex)
|
|
20
|
+
let entry = CacheEntry(snapshot: snapshot, cachedAt: Date())
|
|
21
|
+
guard let data = try? JSONEncoder().encode(entry) else { return }
|
|
22
|
+
let file = cacheDir.appendingPathComponent(key)
|
|
23
|
+
try? data.write(to: file)
|
|
24
|
+
evictIfNeeded()
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
func load(server: String, tabIndex: Int) -> InspectSnapshot? {
|
|
28
|
+
let key = cacheKey(server: server, tabIndex: tabIndex)
|
|
29
|
+
let file = cacheDir.appendingPathComponent(key)
|
|
30
|
+
guard let data = try? Data(contentsOf: file),
|
|
31
|
+
let entry = try? JSONDecoder().decode(CacheEntry.self, from: data) else { return nil }
|
|
32
|
+
return entry.snapshot
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
private func cacheKey(server: String, tabIndex: Int) -> String {
|
|
36
|
+
let sanitized = server.replacingOccurrences(of: "/", with: "_")
|
|
37
|
+
.replacingOccurrences(of: ":", with: "_")
|
|
38
|
+
return "\(sanitized)_tab\(tabIndex).json"
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
private func evictIfNeeded() {
|
|
42
|
+
guard let files = try? FileManager.default.contentsOfDirectory(
|
|
43
|
+
at: cacheDir, includingPropertiesForKeys: [.fileSizeKey, .contentModificationDateKey]
|
|
44
|
+
) else { return }
|
|
45
|
+
|
|
46
|
+
var totalSize = 0
|
|
47
|
+
var sorted = files.compactMap { url -> (URL, Int, Date)? in
|
|
48
|
+
guard let vals = try? url.resourceValues(forKeys: [.fileSizeKey, .contentModificationDateKey]),
|
|
49
|
+
let size = vals.fileSize,
|
|
50
|
+
let date = vals.contentModificationDate else { return nil }
|
|
51
|
+
totalSize += size
|
|
52
|
+
return (url, size, date)
|
|
53
|
+
}.sorted { $0.2 < $1.2 } // oldest first
|
|
54
|
+
|
|
55
|
+
while totalSize > maxSize, !sorted.isEmpty {
|
|
56
|
+
let oldest = sorted.removeFirst()
|
|
57
|
+
try? FileManager.default.removeItem(at: oldest.0)
|
|
58
|
+
totalSize -= oldest.1
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
private struct CacheEntry: Codable {
|
|
64
|
+
let snapshot: InspectSnapshot
|
|
65
|
+
let cachedAt: Date
|
|
66
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import SwiftUI
|
|
2
|
+
import RemuxKit
|
|
3
|
+
|
|
4
|
+
/// Main 5-tab navigation: Now / Inspect / Live / Control / Me
|
|
5
|
+
struct MainTabView: View {
|
|
6
|
+
@Environment(RemuxState.self) private var state
|
|
7
|
+
@State private var selectedTab = 0
|
|
8
|
+
|
|
9
|
+
var body: some View {
|
|
10
|
+
TabView(selection: $selectedTab) {
|
|
11
|
+
NowView()
|
|
12
|
+
.tabItem { Label("Now", systemImage: "house.fill") }
|
|
13
|
+
.tag(0)
|
|
14
|
+
|
|
15
|
+
InspectView()
|
|
16
|
+
.tabItem { Label("Inspect", systemImage: "doc.text.magnifyingglass") }
|
|
17
|
+
.tag(1)
|
|
18
|
+
|
|
19
|
+
LiveTerminalView()
|
|
20
|
+
.tabItem { Label("Live", systemImage: "terminal.fill") }
|
|
21
|
+
.tag(2)
|
|
22
|
+
|
|
23
|
+
ControlView()
|
|
24
|
+
.tabItem { Label("Control", systemImage: "slider.horizontal.3") }
|
|
25
|
+
.tag(3)
|
|
26
|
+
|
|
27
|
+
MeView()
|
|
28
|
+
.tabItem { Label("Me", systemImage: "person.fill") }
|
|
29
|
+
.tag(4)
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import SwiftUI
|
|
2
|
+
import RemuxKit
|
|
3
|
+
|
|
4
|
+
/// Root view: onboarding → Face ID → main app.
|
|
5
|
+
/// iPad uses NavigationSplitView, iPhone uses TabView.
|
|
6
|
+
struct RootView: View {
|
|
7
|
+
@Environment(RemuxState.self) private var state
|
|
8
|
+
@Environment(\.horizontalSizeClass) private var sizeClass
|
|
9
|
+
@State private var isLocked = true
|
|
10
|
+
@State private var faceIdManager = FaceIDManager()
|
|
11
|
+
private let keychain = KeychainStore()
|
|
12
|
+
|
|
13
|
+
var body: some View {
|
|
14
|
+
Group {
|
|
15
|
+
if isLocked {
|
|
16
|
+
LockedView { unlock() }
|
|
17
|
+
} else if case .connected = state.connectionStatus {
|
|
18
|
+
adaptiveMainView
|
|
19
|
+
} else if case .reconnecting = state.connectionStatus {
|
|
20
|
+
adaptiveMainView
|
|
21
|
+
.overlay(ReconnectingBanner())
|
|
22
|
+
} else {
|
|
23
|
+
OnboardingView()
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
.onAppear {
|
|
27
|
+
Task { await checkAuth() }
|
|
28
|
+
}
|
|
29
|
+
.onChange(of: state.connectionStatus) { _, newStatus in
|
|
30
|
+
if case .disconnected = newStatus,
|
|
31
|
+
let snapshot = state.inspectSnapshot,
|
|
32
|
+
let server = state.serverURL?.absoluteString {
|
|
33
|
+
Task {
|
|
34
|
+
await InspectCache.shared.save(
|
|
35
|
+
snapshot: snapshot, server: server, tabIndex: state.activeTabIndex
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
@ViewBuilder
|
|
43
|
+
private var adaptiveMainView: some View {
|
|
44
|
+
if sizeClass == .regular {
|
|
45
|
+
iPadLayout
|
|
46
|
+
} else {
|
|
47
|
+
MainTabView()
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/// iPad multi-column: sidebar (tabs) + terminal + inspect
|
|
52
|
+
private var iPadLayout: some View {
|
|
53
|
+
NavigationSplitView {
|
|
54
|
+
List {
|
|
55
|
+
Section("Session: \(state.currentSession)") {
|
|
56
|
+
ForEach(state.tabs, id: \.index) { tab in
|
|
57
|
+
Button {
|
|
58
|
+
if let pane = tab.panes.first { state.switchTab(id: pane.id) }
|
|
59
|
+
} label: {
|
|
60
|
+
HStack {
|
|
61
|
+
Image(systemName: tab.active ? "terminal.fill" : "terminal")
|
|
62
|
+
Text(tab.name)
|
|
63
|
+
Spacer()
|
|
64
|
+
if tab.hasBell { Circle().fill(.red).frame(width: 6, height: 6) }
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
Section {
|
|
70
|
+
Button { state.createTab() } label: {
|
|
71
|
+
Label("New Tab", systemImage: "plus")
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
.navigationTitle("Remux")
|
|
76
|
+
} content: {
|
|
77
|
+
LiveTerminalView()
|
|
78
|
+
} detail: {
|
|
79
|
+
InspectView()
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
private func checkAuth() async {
|
|
84
|
+
let authed = await faceIdManager.authenticateIfNeeded()
|
|
85
|
+
isLocked = !authed
|
|
86
|
+
if authed { tryAutoConnect() }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
private func unlock() {
|
|
90
|
+
Task {
|
|
91
|
+
let authed = await faceIdManager.authenticateIfNeeded()
|
|
92
|
+
if authed { isLocked = false; tryAutoConnect() }
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
private func tryAutoConnect() {
|
|
97
|
+
let servers = keychain.savedServers()
|
|
98
|
+
if let server = servers.first,
|
|
99
|
+
let token = keychain.loadResumeToken(forServer: server) ?? keychain.loadServerToken(forServer: server),
|
|
100
|
+
let url = URL(string: server) {
|
|
101
|
+
state.connect(url: url, credential: .token(token))
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
struct LockedView: View {
|
|
107
|
+
let onUnlock: () -> Void
|
|
108
|
+
var body: some View {
|
|
109
|
+
VStack(spacing: 20) {
|
|
110
|
+
Image(systemName: "lock.fill").font(.system(size: 48)).foregroundStyle(.secondary)
|
|
111
|
+
Text("Remux is Locked").font(.title2)
|
|
112
|
+
Button("Unlock with Face ID") { onUnlock() }
|
|
113
|
+
.buttonStyle(.borderedProminent)
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
struct ReconnectingBanner: View {
|
|
119
|
+
var body: some View {
|
|
120
|
+
VStack {
|
|
121
|
+
HStack {
|
|
122
|
+
ProgressView().scaleEffect(0.8)
|
|
123
|
+
Text("Reconnecting...").font(.caption).foregroundStyle(.secondary)
|
|
124
|
+
}
|
|
125
|
+
.padding(8).background(.ultraThinMaterial).cornerRadius(8)
|
|
126
|
+
Spacer()
|
|
127
|
+
}
|
|
128
|
+
.padding(.top, 8)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import SwiftUI
|
|
2
|
+
import RemuxKit
|
|
3
|
+
|
|
4
|
+
/// Control tab: session/tab management with swipe actions.
|
|
5
|
+
struct ControlView: View {
|
|
6
|
+
@Environment(RemuxState.self) private var state
|
|
7
|
+
@State private var showNewSession = false
|
|
8
|
+
|
|
9
|
+
var body: some View {
|
|
10
|
+
NavigationStack {
|
|
11
|
+
List {
|
|
12
|
+
sessionSection
|
|
13
|
+
tabsSection
|
|
14
|
+
actionsSection
|
|
15
|
+
}
|
|
16
|
+
.navigationTitle("Control")
|
|
17
|
+
.alert("New Session", isPresented: $showNewSession) {
|
|
18
|
+
Button("Create") {
|
|
19
|
+
state.createSession(name: "session-\(Int.random(in: 1000...9999))")
|
|
20
|
+
}
|
|
21
|
+
Button("Cancel", role: .cancel) {}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
private var sessionSection: some View {
|
|
27
|
+
Section("Session") {
|
|
28
|
+
HStack {
|
|
29
|
+
Image(systemName: "server.rack")
|
|
30
|
+
Text(state.currentSession)
|
|
31
|
+
.font(.headline)
|
|
32
|
+
Spacer()
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
private var tabsSection: some View {
|
|
38
|
+
Section("Tabs (\(state.tabs.count))") {
|
|
39
|
+
ForEach(state.tabs, id: \.index) { tab in
|
|
40
|
+
TabRow(tab: tab, isActive: tab.index == state.activeTabIndex)
|
|
41
|
+
.contentShape(Rectangle())
|
|
42
|
+
.onTapGesture {
|
|
43
|
+
if let pane = tab.panes.first {
|
|
44
|
+
state.switchTab(id: pane.id)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
.swipeActions(edge: .trailing) {
|
|
48
|
+
Button(role: .destructive) {
|
|
49
|
+
if let pane = tab.panes.first {
|
|
50
|
+
state.closeTab(id: pane.id)
|
|
51
|
+
}
|
|
52
|
+
} label: {
|
|
53
|
+
Label("Close", systemImage: "xmark")
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
private var actionsSection: some View {
|
|
61
|
+
Section {
|
|
62
|
+
Button(action: { state.createTab() }) {
|
|
63
|
+
Label("New Tab", systemImage: "plus")
|
|
64
|
+
}
|
|
65
|
+
Button(action: { showNewSession = true }) {
|
|
66
|
+
Label("New Session", systemImage: "plus.rectangle.on.folder")
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
struct TabRow: View {
|
|
73
|
+
let tab: WorkspaceTab
|
|
74
|
+
let isActive: Bool
|
|
75
|
+
|
|
76
|
+
var body: some View {
|
|
77
|
+
HStack {
|
|
78
|
+
Image(systemName: isActive ? "terminal.fill" : "terminal")
|
|
79
|
+
.foregroundStyle(isActive ? Color.accentColor : Color.secondary)
|
|
80
|
+
VStack(alignment: .leading) {
|
|
81
|
+
Text(tab.name)
|
|
82
|
+
if let pane = tab.panes.first {
|
|
83
|
+
Text("\(pane.cols)×\(pane.rows)")
|
|
84
|
+
.font(.caption)
|
|
85
|
+
.foregroundStyle(.tertiary)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
Spacer()
|
|
89
|
+
if isActive {
|
|
90
|
+
Text("Active")
|
|
91
|
+
.font(.caption2)
|
|
92
|
+
.padding(.horizontal, 6)
|
|
93
|
+
.padding(.vertical, 2)
|
|
94
|
+
.background(.tint.opacity(0.15))
|
|
95
|
+
.cornerRadius(4)
|
|
96
|
+
}
|
|
97
|
+
if tab.hasBell {
|
|
98
|
+
Circle().fill(.red).frame(width: 6, height: 6)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import SwiftUI
|
|
2
|
+
import RemuxKit
|
|
3
|
+
|
|
4
|
+
/// Inspect tab: readable terminal content with badges, search, pagination.
|
|
5
|
+
struct InspectView: View {
|
|
6
|
+
@Environment(RemuxState.self) private var state
|
|
7
|
+
@State private var searchQuery = ""
|
|
8
|
+
|
|
9
|
+
var body: some View {
|
|
10
|
+
NavigationStack {
|
|
11
|
+
VStack(spacing: 0) {
|
|
12
|
+
// Tab selector
|
|
13
|
+
ScrollView(.horizontal, showsIndicators: false) {
|
|
14
|
+
HStack(spacing: 8) {
|
|
15
|
+
ForEach(state.tabs, id: \.index) { tab in
|
|
16
|
+
Button {
|
|
17
|
+
state.requestInspect(tabIndex: tab.index)
|
|
18
|
+
} label: {
|
|
19
|
+
Text(tab.name)
|
|
20
|
+
.font(.caption)
|
|
21
|
+
.padding(.horizontal, 12)
|
|
22
|
+
.padding(.vertical, 6)
|
|
23
|
+
.background(tab.index == state.activeTabIndex ? Color.accentColor : Color.secondary.opacity(0.15))
|
|
24
|
+
.foregroundStyle(tab.index == state.activeTabIndex ? .white : .primary)
|
|
25
|
+
.cornerRadius(16)
|
|
26
|
+
}
|
|
27
|
+
.buttonStyle(.plain)
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
.padding(.horizontal)
|
|
31
|
+
.padding(.vertical, 8)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Descriptor badges
|
|
35
|
+
if let snapshot = state.inspectSnapshot {
|
|
36
|
+
HStack(spacing: 6) {
|
|
37
|
+
Badge(text: snapshot.descriptor.source, color: .blue)
|
|
38
|
+
Badge(text: snapshot.descriptor.precision, color: snapshot.descriptor.precision == "precise" ? .green : .yellow)
|
|
39
|
+
Badge(text: snapshot.descriptor.staleness, color: snapshot.descriptor.staleness == "fresh" ? .green : .orange)
|
|
40
|
+
Spacer()
|
|
41
|
+
Text(snapshot.descriptor.capturedAt.prefix(19).replacingOccurrences(of: "T", with: " "))
|
|
42
|
+
.font(.caption2)
|
|
43
|
+
.foregroundStyle(.tertiary)
|
|
44
|
+
}
|
|
45
|
+
.padding(.horizontal)
|
|
46
|
+
.padding(.bottom, 4)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
Divider()
|
|
50
|
+
|
|
51
|
+
// Content
|
|
52
|
+
if let snapshot = state.inspectSnapshot {
|
|
53
|
+
List {
|
|
54
|
+
ForEach(Array(snapshot.items.enumerated()), id: \.offset) { _, item in
|
|
55
|
+
Text(item.content)
|
|
56
|
+
.font(.system(.caption, design: .monospaced))
|
|
57
|
+
.foregroundStyle(item.type == "output" ? .primary : .secondary)
|
|
58
|
+
.listRowInsets(EdgeInsets(top: 1, leading: 12, bottom: 1, trailing: 12))
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
.listStyle(.plain)
|
|
62
|
+
} else {
|
|
63
|
+
ContentUnavailableView(
|
|
64
|
+
"No Inspect Data",
|
|
65
|
+
systemImage: "doc.text",
|
|
66
|
+
description: Text("Pull to refresh or switch tabs above.")
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
.navigationTitle("Inspect")
|
|
71
|
+
.searchable(text: $searchQuery, prompt: "Search terminal content")
|
|
72
|
+
.onSubmit(of: .search) {
|
|
73
|
+
state.requestInspect(tabIndex: state.activeTabIndex, query: searchQuery.isEmpty ? nil : searchQuery)
|
|
74
|
+
}
|
|
75
|
+
.refreshable {
|
|
76
|
+
state.requestInspect(tabIndex: state.activeTabIndex, query: searchQuery.isEmpty ? nil : searchQuery)
|
|
77
|
+
}
|
|
78
|
+
.onAppear {
|
|
79
|
+
state.requestInspect(tabIndex: state.activeTabIndex)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
struct Badge: View {
|
|
86
|
+
let text: String
|
|
87
|
+
let color: Color
|
|
88
|
+
|
|
89
|
+
var body: some View {
|
|
90
|
+
Text(text)
|
|
91
|
+
.font(.system(size: 10))
|
|
92
|
+
.padding(.horizontal, 6)
|
|
93
|
+
.padding(.vertical, 2)
|
|
94
|
+
.background(color.opacity(0.15))
|
|
95
|
+
.foregroundStyle(color)
|
|
96
|
+
.cornerRadius(4)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import SwiftUI
|
|
2
|
+
import RemuxKit
|
|
3
|
+
|
|
4
|
+
/// Live terminal tab: full-screen ghostty-web terminal via WKWebView.
|
|
5
|
+
struct LiveTerminalView: View {
|
|
6
|
+
@Environment(RemuxState.self) private var state
|
|
7
|
+
@State private var bridge: GhosttyBridge?
|
|
8
|
+
@State private var showKeyboardAccessory = true
|
|
9
|
+
|
|
10
|
+
var body: some View {
|
|
11
|
+
ZStack {
|
|
12
|
+
// Terminal
|
|
13
|
+
if let bridge {
|
|
14
|
+
GhosttyTerminalView(bridge: bridge)
|
|
15
|
+
.ignoresSafeArea(.keyboard)
|
|
16
|
+
.onReceive(NotificationCenter.default.publisher(for: .remuxTerminalData)) { notification in
|
|
17
|
+
if let data = notification.userInfo?["data"] as? Data {
|
|
18
|
+
bridge.writeToTerminal(data: data)
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Connection status overlay
|
|
24
|
+
if case .disconnected = state.connectionStatus {
|
|
25
|
+
Color.black.opacity(0.7)
|
|
26
|
+
.ignoresSafeArea()
|
|
27
|
+
VStack {
|
|
28
|
+
Image(systemName: "wifi.slash")
|
|
29
|
+
.font(.largeTitle)
|
|
30
|
+
.foregroundStyle(.white)
|
|
31
|
+
Text("Disconnected")
|
|
32
|
+
.foregroundStyle(.white)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
.safeAreaInset(edge: .bottom) {
|
|
37
|
+
if showKeyboardAccessory {
|
|
38
|
+
TerminalKeyboardAccessory(bridge: bridge)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
.onAppear { setupBridge() }
|
|
42
|
+
.navigationBarHidden(true)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
private func setupBridge() {
|
|
46
|
+
let b = GhosttyBridge()
|
|
47
|
+
b.onInput = { input in
|
|
48
|
+
if let data = input.data(using: .utf8) {
|
|
49
|
+
state.sendTerminalData(data)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
b.onResize = { cols, rows in
|
|
53
|
+
state.sendJSON(["type": "resize", "cols": cols, "rows": rows])
|
|
54
|
+
}
|
|
55
|
+
b.onReady = {
|
|
56
|
+
// Terminal initialized — attach to current tab
|
|
57
|
+
}
|
|
58
|
+
b.onBell = {
|
|
59
|
+
UIImpactFeedbackGenerator(style: .heavy).impactOccurred()
|
|
60
|
+
}
|
|
61
|
+
bridge = b
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/// Keyboard accessory bar with Esc, Tab, Ctrl, arrows.
|
|
66
|
+
/// Design ref: Termius / Blink terminal keyboard accessory.
|
|
67
|
+
struct TerminalKeyboardAccessory: View {
|
|
68
|
+
let bridge: GhosttyBridge?
|
|
69
|
+
@State private var ctrlActive = false
|
|
70
|
+
|
|
71
|
+
var body: some View {
|
|
72
|
+
ScrollView(.horizontal, showsIndicators: false) {
|
|
73
|
+
HStack(spacing: 8) {
|
|
74
|
+
AccessoryButton("Esc") { send("\u{1b}") }
|
|
75
|
+
AccessoryButton("Tab") { send("\t") }
|
|
76
|
+
|
|
77
|
+
AccessoryButton(ctrlActive ? "Ctrl ●" : "Ctrl") {
|
|
78
|
+
ctrlActive.toggle()
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
Divider().frame(height: 20)
|
|
82
|
+
|
|
83
|
+
AccessoryButton("↑") { send("\u{1b}[A") }
|
|
84
|
+
AccessoryButton("↓") { send("\u{1b}[B") }
|
|
85
|
+
AccessoryButton("←") { send("\u{1b}[D") }
|
|
86
|
+
AccessoryButton("→") { send("\u{1b}[C") }
|
|
87
|
+
|
|
88
|
+
Divider().frame(height: 20)
|
|
89
|
+
|
|
90
|
+
AccessoryButton("Ctrl+C") { send("\u{03}") }
|
|
91
|
+
AccessoryButton("Ctrl+D") { send("\u{04}") }
|
|
92
|
+
AccessoryButton("Ctrl+Z") { send("\u{1a}") }
|
|
93
|
+
}
|
|
94
|
+
.padding(.horizontal, 8)
|
|
95
|
+
.padding(.vertical, 4)
|
|
96
|
+
}
|
|
97
|
+
.background(.bar)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
private func send(_ text: String) {
|
|
101
|
+
if ctrlActive, text.count == 1, let ascii = text.first?.asciiValue {
|
|
102
|
+
// Ctrl+key: send control character (ascii - 64)
|
|
103
|
+
let ctrl = Character(UnicodeScalar(ascii &- 64))
|
|
104
|
+
bridge?.writeString(String(ctrl))
|
|
105
|
+
ctrlActive = false
|
|
106
|
+
} else {
|
|
107
|
+
bridge?.writeString(text)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
struct AccessoryButton: View {
|
|
113
|
+
let title: String
|
|
114
|
+
let action: () -> Void
|
|
115
|
+
|
|
116
|
+
init(_ title: String, action: @escaping () -> Void) {
|
|
117
|
+
self.title = title
|
|
118
|
+
self.action = action
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
var body: some View {
|
|
122
|
+
Button(action: action) {
|
|
123
|
+
Text(title)
|
|
124
|
+
.font(.system(size: 13, weight: .medium, design: .monospaced))
|
|
125
|
+
.padding(.horizontal, 10)
|
|
126
|
+
.padding(.vertical, 6)
|
|
127
|
+
.background(.quaternary)
|
|
128
|
+
.cornerRadius(6)
|
|
129
|
+
}
|
|
130
|
+
.buttonStyle(.plain)
|
|
131
|
+
}
|
|
132
|
+
}
|