@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,152 @@
|
|
|
1
|
+
import AppKit
|
|
2
|
+
|
|
3
|
+
/// Lightweight update checker that polls GitHub releases API.
|
|
4
|
+
/// Adapted from Sparkle's conceptual model but without the framework dependency.
|
|
5
|
+
@MainActor
|
|
6
|
+
@Observable
|
|
7
|
+
final class UpdateChecker {
|
|
8
|
+
|
|
9
|
+
// MARK: - Published state
|
|
10
|
+
|
|
11
|
+
private(set) var latestVersion: String?
|
|
12
|
+
private(set) var releaseURL: String?
|
|
13
|
+
private(set) var releaseNotes: String?
|
|
14
|
+
private(set) var hasUpdate: Bool = false
|
|
15
|
+
|
|
16
|
+
// MARK: - Config
|
|
17
|
+
|
|
18
|
+
/// GitHub API endpoint for latest release.
|
|
19
|
+
static let apiURL = "https://api.github.com/repos/yaoshenwang/remux/releases/latest"
|
|
20
|
+
|
|
21
|
+
/// Check interval: 4 hours.
|
|
22
|
+
private let checkInterval: TimeInterval = 4 * 60 * 60
|
|
23
|
+
|
|
24
|
+
/// UserDefaults key for dismissed version.
|
|
25
|
+
private static let dismissedVersionKey = "UpdateChecker.dismissedVersion"
|
|
26
|
+
|
|
27
|
+
// MARK: - Internal state
|
|
28
|
+
|
|
29
|
+
private var timer: Timer?
|
|
30
|
+
private var isChecking = false
|
|
31
|
+
|
|
32
|
+
init() {}
|
|
33
|
+
|
|
34
|
+
// MARK: - Public API
|
|
35
|
+
|
|
36
|
+
/// Start the update checker: check immediately, then every 4 hours.
|
|
37
|
+
func start() {
|
|
38
|
+
Task { await check() }
|
|
39
|
+
timer = Timer.scheduledTimer(withTimeInterval: checkInterval, repeats: true) { [weak self] _ in
|
|
40
|
+
Task { @MainActor [weak self] in
|
|
41
|
+
await self?.check()
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/// Stop the periodic check timer.
|
|
47
|
+
func stop() {
|
|
48
|
+
timer?.invalidate()
|
|
49
|
+
timer = nil
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/// Dismiss the current update notification (suppress until a newer version appears).
|
|
53
|
+
func dismissCurrentUpdate() {
|
|
54
|
+
guard let version = latestVersion else { return }
|
|
55
|
+
UserDefaults.standard.set(version, forKey: Self.dismissedVersionKey)
|
|
56
|
+
hasUpdate = false
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/// Open the release page in the default browser.
|
|
60
|
+
func openReleasePage() {
|
|
61
|
+
guard let urlStr = releaseURL, let url = URL(string: urlStr) else {
|
|
62
|
+
// Fallback to releases page
|
|
63
|
+
if let url = URL(string: "https://github.com/yaoshenwang/remux/releases") {
|
|
64
|
+
NSWorkspace.shared.open(url)
|
|
65
|
+
}
|
|
66
|
+
return
|
|
67
|
+
}
|
|
68
|
+
NSWorkspace.shared.open(url)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/// Force a manual check (ignores dismissed version).
|
|
72
|
+
func checkNow() async {
|
|
73
|
+
await check(ignoreDismissed: true)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// MARK: - Check logic
|
|
77
|
+
|
|
78
|
+
private func check(ignoreDismissed: Bool = false) async {
|
|
79
|
+
guard !isChecking else { return }
|
|
80
|
+
isChecking = true
|
|
81
|
+
defer { isChecking = false }
|
|
82
|
+
|
|
83
|
+
guard let url = URL(string: Self.apiURL) else { return }
|
|
84
|
+
|
|
85
|
+
do {
|
|
86
|
+
var request = URLRequest(url: url)
|
|
87
|
+
request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept")
|
|
88
|
+
request.timeoutInterval = 15
|
|
89
|
+
|
|
90
|
+
let (data, response) = try await URLSession.shared.data(for: request)
|
|
91
|
+
|
|
92
|
+
guard let httpResponse = response as? HTTPURLResponse,
|
|
93
|
+
httpResponse.statusCode == 200 else {
|
|
94
|
+
NSLog("[remux] Update check: non-200 response")
|
|
95
|
+
return
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
99
|
+
let tagName = json["tag_name"] as? String else {
|
|
100
|
+
NSLog("[remux] Update check: failed to parse response")
|
|
101
|
+
return
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
let remoteVersion = tagName.hasPrefix("v") ? String(tagName.dropFirst()) : tagName
|
|
105
|
+
let htmlURL = json["html_url"] as? String
|
|
106
|
+
let body = json["body"] as? String
|
|
107
|
+
|
|
108
|
+
latestVersion = remoteVersion
|
|
109
|
+
releaseURL = htmlURL
|
|
110
|
+
releaseNotes = body
|
|
111
|
+
|
|
112
|
+
let currentVersion = Self.currentBundleVersion()
|
|
113
|
+
let isNewer = Self.isVersion(remoteVersion, newerThan: currentVersion)
|
|
114
|
+
|
|
115
|
+
// Check if this version was dismissed
|
|
116
|
+
let dismissedVersion = UserDefaults.standard.string(forKey: Self.dismissedVersionKey)
|
|
117
|
+
let isDismissed = !ignoreDismissed && dismissedVersion == remoteVersion
|
|
118
|
+
|
|
119
|
+
hasUpdate = isNewer && !isDismissed
|
|
120
|
+
|
|
121
|
+
if hasUpdate {
|
|
122
|
+
NSLog("[remux] Update available: %@ -> %@", currentVersion, remoteVersion)
|
|
123
|
+
}
|
|
124
|
+
} catch {
|
|
125
|
+
NSLog("[remux] Update check failed: %@", error.localizedDescription)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// MARK: - Version comparison
|
|
130
|
+
|
|
131
|
+
/// Get the current app version from the bundle, or a fallback.
|
|
132
|
+
static func currentBundleVersion() -> String {
|
|
133
|
+
Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String
|
|
134
|
+
?? ProcessInfo.processInfo.environment["REMUX_VERSION"]
|
|
135
|
+
?? "0.0.0"
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/// Compare semantic version strings. Returns true if `a` is newer than `b`.
|
|
139
|
+
static func isVersion(_ a: String, newerThan b: String) -> Bool {
|
|
140
|
+
let aParts = a.split(separator: ".").compactMap { Int($0) }
|
|
141
|
+
let bParts = b.split(separator: ".").compactMap { Int($0) }
|
|
142
|
+
|
|
143
|
+
let maxLen = max(aParts.count, bParts.count)
|
|
144
|
+
for i in 0..<maxLen {
|
|
145
|
+
let aVal = i < aParts.count ? aParts[i] : 0
|
|
146
|
+
let bVal = i < bParts.count ? bParts[i] : 0
|
|
147
|
+
if aVal > bVal { return true }
|
|
148
|
+
if aVal < bVal { return false }
|
|
149
|
+
}
|
|
150
|
+
return false
|
|
151
|
+
}
|
|
152
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import SwiftUI
|
|
2
|
+
import AppKit
|
|
3
|
+
|
|
4
|
+
/// Command entry for the palette. Each command has a display name,
|
|
5
|
+
/// optional shortcut string, and an action closure.
|
|
6
|
+
struct PaletteCommand: Identifiable {
|
|
7
|
+
let id: String
|
|
8
|
+
let name: String
|
|
9
|
+
let shortcut: String
|
|
10
|
+
let category: String
|
|
11
|
+
let action: @MainActor () -> Void
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/// Modal command palette overlay triggered by Cmd+Shift+P.
|
|
15
|
+
/// Provides fuzzy search over all available commands.
|
|
16
|
+
/// Adapted from VS Code / Warp command palette UX pattern.
|
|
17
|
+
struct CommandPalette: View {
|
|
18
|
+
@Binding var isPresented: Bool
|
|
19
|
+
let commands: [PaletteCommand]
|
|
20
|
+
|
|
21
|
+
@State private var query: String = ""
|
|
22
|
+
@State private var selectedIndex: Int = 0
|
|
23
|
+
@FocusState private var isFocused: Bool
|
|
24
|
+
|
|
25
|
+
private var filteredCommands: [PaletteCommand] {
|
|
26
|
+
if query.isEmpty { return commands }
|
|
27
|
+
let q = query.lowercased()
|
|
28
|
+
return commands.filter { cmd in
|
|
29
|
+
fuzzyMatch(query: q, target: cmd.name.lowercased())
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
var body: some View {
|
|
34
|
+
if isPresented {
|
|
35
|
+
ZStack {
|
|
36
|
+
// Dismiss backdrop
|
|
37
|
+
Color.black.opacity(0.3)
|
|
38
|
+
.ignoresSafeArea()
|
|
39
|
+
.onTapGesture { dismiss() }
|
|
40
|
+
|
|
41
|
+
VStack(spacing: 0) {
|
|
42
|
+
// Search field
|
|
43
|
+
HStack(spacing: 8) {
|
|
44
|
+
Image(systemName: "magnifyingglass")
|
|
45
|
+
.foregroundStyle(.secondary)
|
|
46
|
+
.font(.system(size: 14))
|
|
47
|
+
|
|
48
|
+
TextField("Type a command...", text: $query)
|
|
49
|
+
.textFieldStyle(.plain)
|
|
50
|
+
.font(.system(size: 14))
|
|
51
|
+
.focused($isFocused)
|
|
52
|
+
.onSubmit { executeSelected() }
|
|
53
|
+
.onChange(of: query) { _, _ in
|
|
54
|
+
selectedIndex = 0
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
.padding(.horizontal, 14)
|
|
58
|
+
.padding(.vertical, 10)
|
|
59
|
+
|
|
60
|
+
Divider()
|
|
61
|
+
|
|
62
|
+
// Command list
|
|
63
|
+
ScrollViewReader { proxy in
|
|
64
|
+
ScrollView {
|
|
65
|
+
LazyVStack(spacing: 0) {
|
|
66
|
+
let filtered = filteredCommands
|
|
67
|
+
ForEach(Array(filtered.enumerated()), id: \.element.id) { idx, cmd in
|
|
68
|
+
CommandRow(
|
|
69
|
+
command: cmd,
|
|
70
|
+
isSelected: idx == selectedIndex
|
|
71
|
+
)
|
|
72
|
+
.id(idx)
|
|
73
|
+
.onTapGesture {
|
|
74
|
+
selectedIndex = idx
|
|
75
|
+
executeSelected()
|
|
76
|
+
}
|
|
77
|
+
.onHover { hovering in
|
|
78
|
+
if hovering { selectedIndex = idx }
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if filtered.isEmpty {
|
|
83
|
+
Text("No matching commands")
|
|
84
|
+
.foregroundStyle(.secondary)
|
|
85
|
+
.font(.system(size: 13))
|
|
86
|
+
.padding(.vertical, 20)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
.frame(maxHeight: 300)
|
|
91
|
+
.onChange(of: selectedIndex) { _, newValue in
|
|
92
|
+
proxy.scrollTo(newValue, anchor: .center)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 10))
|
|
97
|
+
.overlay(
|
|
98
|
+
RoundedRectangle(cornerRadius: 10)
|
|
99
|
+
.stroke(Color.primary.opacity(0.1), lineWidth: 1)
|
|
100
|
+
)
|
|
101
|
+
.shadow(color: .black.opacity(0.25), radius: 20, y: 10)
|
|
102
|
+
.frame(width: 500)
|
|
103
|
+
.frame(maxHeight: 380)
|
|
104
|
+
.offset(y: -80)
|
|
105
|
+
}
|
|
106
|
+
.onAppear {
|
|
107
|
+
query = ""
|
|
108
|
+
selectedIndex = 0
|
|
109
|
+
isFocused = true
|
|
110
|
+
}
|
|
111
|
+
.onKeyPress(.upArrow) {
|
|
112
|
+
moveSelection(by: -1)
|
|
113
|
+
return .handled
|
|
114
|
+
}
|
|
115
|
+
.onKeyPress(.downArrow) {
|
|
116
|
+
moveSelection(by: 1)
|
|
117
|
+
return .handled
|
|
118
|
+
}
|
|
119
|
+
.onKeyPress(.escape) {
|
|
120
|
+
dismiss()
|
|
121
|
+
return .handled
|
|
122
|
+
}
|
|
123
|
+
.onKeyPress(.return) {
|
|
124
|
+
executeSelected()
|
|
125
|
+
return .handled
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
private func moveSelection(by delta: Int) {
|
|
131
|
+
let count = filteredCommands.count
|
|
132
|
+
guard count > 0 else { return }
|
|
133
|
+
selectedIndex = (selectedIndex + delta + count) % count
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
private func executeSelected() {
|
|
137
|
+
let filtered = filteredCommands
|
|
138
|
+
guard selectedIndex >= 0, selectedIndex < filtered.count else { return }
|
|
139
|
+
let cmd = filtered[selectedIndex]
|
|
140
|
+
dismiss()
|
|
141
|
+
cmd.action()
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
private func dismiss() {
|
|
145
|
+
isPresented = false
|
|
146
|
+
query = ""
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/// Simple fuzzy match: all characters in query appear in order in target.
|
|
150
|
+
private func fuzzyMatch(query: String, target: String) -> Bool {
|
|
151
|
+
var targetIdx = target.startIndex
|
|
152
|
+
for qChar in query {
|
|
153
|
+
guard let found = target[targetIdx...].firstIndex(of: qChar) else {
|
|
154
|
+
return false
|
|
155
|
+
}
|
|
156
|
+
targetIdx = target.index(after: found)
|
|
157
|
+
}
|
|
158
|
+
return true
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/// Single command row in the palette.
|
|
163
|
+
struct CommandRow: View {
|
|
164
|
+
let command: PaletteCommand
|
|
165
|
+
let isSelected: Bool
|
|
166
|
+
|
|
167
|
+
var body: some View {
|
|
168
|
+
HStack {
|
|
169
|
+
VStack(alignment: .leading, spacing: 1) {
|
|
170
|
+
Text(command.name)
|
|
171
|
+
.font(.system(size: 13))
|
|
172
|
+
.foregroundStyle(isSelected ? .primary : .primary)
|
|
173
|
+
|
|
174
|
+
Text(command.category)
|
|
175
|
+
.font(.system(size: 10))
|
|
176
|
+
.foregroundStyle(.secondary)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
Spacer()
|
|
180
|
+
|
|
181
|
+
if !command.shortcut.isEmpty {
|
|
182
|
+
Text(command.shortcut)
|
|
183
|
+
.font(.system(size: 11, design: .monospaced))
|
|
184
|
+
.foregroundStyle(.secondary)
|
|
185
|
+
.padding(.horizontal, 6)
|
|
186
|
+
.padding(.vertical, 2)
|
|
187
|
+
.background(
|
|
188
|
+
RoundedRectangle(cornerRadius: 4)
|
|
189
|
+
.fill(Color.primary.opacity(0.06))
|
|
190
|
+
)
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
.padding(.horizontal, 14)
|
|
194
|
+
.padding(.vertical, 6)
|
|
195
|
+
.background(isSelected ? Color.accentColor.opacity(0.15) : Color.clear)
|
|
196
|
+
.contentShape(Rectangle())
|
|
197
|
+
}
|
|
198
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import SwiftUI
|
|
2
|
+
import RemuxKit
|
|
3
|
+
|
|
4
|
+
/// Connection setup view — shown when not connected.
|
|
5
|
+
struct ConnectionView: View {
|
|
6
|
+
@Environment(RemuxState.self) private var state
|
|
7
|
+
@State private var serverURL = ""
|
|
8
|
+
@State private var token = ""
|
|
9
|
+
@State private var errorMessage: String?
|
|
10
|
+
|
|
11
|
+
private let keychain = KeychainStore()
|
|
12
|
+
|
|
13
|
+
var body: some View {
|
|
14
|
+
VStack(spacing: 20) {
|
|
15
|
+
Image(systemName: "terminal")
|
|
16
|
+
.font(.system(size: 48))
|
|
17
|
+
.foregroundStyle(.secondary)
|
|
18
|
+
|
|
19
|
+
Text("Connect to Remux Server")
|
|
20
|
+
.font(.title2)
|
|
21
|
+
|
|
22
|
+
VStack(alignment: .leading, spacing: 12) {
|
|
23
|
+
TextField("Server URL (e.g. http://localhost:8767)", text: $serverURL)
|
|
24
|
+
.textFieldStyle(.roundedBorder)
|
|
25
|
+
|
|
26
|
+
SecureField("Token", text: $token)
|
|
27
|
+
.textFieldStyle(.roundedBorder)
|
|
28
|
+
|
|
29
|
+
if let error = errorMessage {
|
|
30
|
+
Text(error)
|
|
31
|
+
.foregroundStyle(.red)
|
|
32
|
+
.font(.caption)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
.frame(maxWidth: 400)
|
|
36
|
+
|
|
37
|
+
Button("Connect") {
|
|
38
|
+
connect()
|
|
39
|
+
}
|
|
40
|
+
.buttonStyle(.borderedProminent)
|
|
41
|
+
.disabled(serverURL.isEmpty || token.isEmpty)
|
|
42
|
+
|
|
43
|
+
// Saved servers
|
|
44
|
+
let savedServers = keychain.savedServers()
|
|
45
|
+
if !savedServers.isEmpty {
|
|
46
|
+
Divider()
|
|
47
|
+
Text("Recent Servers")
|
|
48
|
+
.font(.caption)
|
|
49
|
+
.foregroundStyle(.secondary)
|
|
50
|
+
|
|
51
|
+
ForEach(savedServers, id: \.self) { server in
|
|
52
|
+
Button(server) {
|
|
53
|
+
serverURL = server
|
|
54
|
+
if let savedToken = keychain.loadServerToken(forServer: server) {
|
|
55
|
+
token = savedToken
|
|
56
|
+
connect()
|
|
57
|
+
} else if let resumeToken = keychain.loadResumeToken(forServer: server) {
|
|
58
|
+
connectWithResumeToken(server: server, resumeToken: resumeToken)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
.buttonStyle(.plain)
|
|
62
|
+
.foregroundStyle(.blue)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
.padding(40)
|
|
67
|
+
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
private func connect() {
|
|
71
|
+
guard let url = URL(string: serverURL) else {
|
|
72
|
+
errorMessage = "Invalid URL"
|
|
73
|
+
return
|
|
74
|
+
}
|
|
75
|
+
errorMessage = nil
|
|
76
|
+
try? keychain.saveServerToken(token, forServer: serverURL)
|
|
77
|
+
state.connect(url: url, credential: .token(token))
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
private func connectWithResumeToken(server: String, resumeToken: String) {
|
|
81
|
+
guard let url = URL(string: server) else { return }
|
|
82
|
+
state.connect(url: url, credential: .resumeToken(resumeToken))
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import SwiftUI
|
|
2
|
+
import RemuxKit
|
|
3
|
+
|
|
4
|
+
/// Inspect panel showing readable terminal content.
|
|
5
|
+
/// Toggleable via Cmd+I or View menu.
|
|
6
|
+
struct InspectView: View {
|
|
7
|
+
@Environment(RemuxState.self) private var state
|
|
8
|
+
@State private var searchQuery = ""
|
|
9
|
+
@State private var isAutoRefreshing = true
|
|
10
|
+
|
|
11
|
+
private let refreshTimer = Timer.publish(every: 2, on: .main, in: .common).autoconnect()
|
|
12
|
+
|
|
13
|
+
var body: some View {
|
|
14
|
+
VStack(spacing: 0) {
|
|
15
|
+
// Header with badges
|
|
16
|
+
HStack {
|
|
17
|
+
Text("Inspect")
|
|
18
|
+
.font(.headline)
|
|
19
|
+
|
|
20
|
+
Spacer()
|
|
21
|
+
|
|
22
|
+
if let snapshot = state.inspectSnapshot {
|
|
23
|
+
HStack(spacing: 6) {
|
|
24
|
+
Badge(text: snapshot.descriptor.source, color: .blue)
|
|
25
|
+
Badge(text: snapshot.descriptor.precision, color: precisionColor(snapshot.descriptor.precision))
|
|
26
|
+
Badge(text: snapshot.descriptor.staleness, color: stalenessColor(snapshot.descriptor.staleness))
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
Button(action: { requestInspect() }) {
|
|
31
|
+
Image(systemName: "arrow.clockwise")
|
|
32
|
+
}
|
|
33
|
+
.buttonStyle(.plain)
|
|
34
|
+
.help("Refresh")
|
|
35
|
+
}
|
|
36
|
+
.padding(.horizontal, 12)
|
|
37
|
+
.padding(.vertical, 8)
|
|
38
|
+
.background(.bar)
|
|
39
|
+
|
|
40
|
+
// Search bar
|
|
41
|
+
HStack {
|
|
42
|
+
Image(systemName: "magnifyingglass")
|
|
43
|
+
.foregroundStyle(.secondary)
|
|
44
|
+
TextField("Search terminal content...", text: $searchQuery)
|
|
45
|
+
.textFieldStyle(.plain)
|
|
46
|
+
.onSubmit { requestInspect() }
|
|
47
|
+
if !searchQuery.isEmpty {
|
|
48
|
+
Button(action: { searchQuery = ""; requestInspect() }) {
|
|
49
|
+
Image(systemName: "xmark.circle.fill")
|
|
50
|
+
.foregroundStyle(.secondary)
|
|
51
|
+
}
|
|
52
|
+
.buttonStyle(.plain)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
.padding(.horizontal, 12)
|
|
56
|
+
.padding(.vertical, 6)
|
|
57
|
+
|
|
58
|
+
Divider()
|
|
59
|
+
|
|
60
|
+
// Content
|
|
61
|
+
if let snapshot = state.inspectSnapshot {
|
|
62
|
+
ScrollView {
|
|
63
|
+
LazyVStack(alignment: .leading, spacing: 0) {
|
|
64
|
+
ForEach(Array(snapshot.items.enumerated()), id: \.offset) { _, item in
|
|
65
|
+
Text(item.content)
|
|
66
|
+
.font(.system(.body, design: .monospaced))
|
|
67
|
+
.foregroundStyle(item.type == "output" ? .primary : .secondary)
|
|
68
|
+
.textSelection(.enabled)
|
|
69
|
+
.padding(.horizontal, 12)
|
|
70
|
+
.padding(.vertical, 1)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
} else {
|
|
75
|
+
ContentUnavailableView(
|
|
76
|
+
"No Inspect Data",
|
|
77
|
+
systemImage: "doc.text",
|
|
78
|
+
description: Text("Connect to a server and switch to a tab to inspect terminal content.")
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
.onReceive(refreshTimer) { _ in
|
|
83
|
+
if isAutoRefreshing {
|
|
84
|
+
requestInspect()
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
.onAppear { requestInspect() }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
private func requestInspect() {
|
|
91
|
+
state.requestInspect(
|
|
92
|
+
tabIndex: state.activeTabIndex,
|
|
93
|
+
query: searchQuery.isEmpty ? nil : searchQuery
|
|
94
|
+
)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
private func precisionColor(_ p: String) -> Color {
|
|
98
|
+
switch p {
|
|
99
|
+
case "precise": .green
|
|
100
|
+
case "approximate": .yellow
|
|
101
|
+
default: .gray
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
private func stalenessColor(_ s: String) -> Color {
|
|
106
|
+
switch s {
|
|
107
|
+
case "fresh": .green
|
|
108
|
+
case "stale": .orange
|
|
109
|
+
default: .gray
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
struct Badge: View {
|
|
115
|
+
let text: String
|
|
116
|
+
let color: Color
|
|
117
|
+
|
|
118
|
+
var body: some View {
|
|
119
|
+
Text(text)
|
|
120
|
+
.font(.system(size: 10))
|
|
121
|
+
.padding(.horizontal, 6)
|
|
122
|
+
.padding(.vertical, 2)
|
|
123
|
+
.background(color.opacity(0.15))
|
|
124
|
+
.foregroundStyle(color)
|
|
125
|
+
.cornerRadius(4)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import SwiftUI
|
|
2
|
+
import ServiceManagement
|
|
3
|
+
|
|
4
|
+
/// Settings window for Remux macOS app.
|
|
5
|
+
struct SettingsView: View {
|
|
6
|
+
@AppStorage("theme") private var theme: String = "system"
|
|
7
|
+
@AppStorage("globalShortcut") private var globalShortcut: String = "⌘⇧R"
|
|
8
|
+
@AppStorage("notifyBell") private var notifyBell = true
|
|
9
|
+
@AppStorage("notifyRunComplete") private var notifyRunComplete = true
|
|
10
|
+
@AppStorage("notifyApproval") private var notifyApproval = true
|
|
11
|
+
@AppStorage("launchAtLogin") private var launchAtLogin = false
|
|
12
|
+
|
|
13
|
+
var body: some View {
|
|
14
|
+
TabView {
|
|
15
|
+
generalTab
|
|
16
|
+
.tabItem { Label("General", systemImage: "gear") }
|
|
17
|
+
|
|
18
|
+
ShortcutSettingsView()
|
|
19
|
+
.tabItem { Label("Shortcuts", systemImage: "keyboard") }
|
|
20
|
+
|
|
21
|
+
notificationsTab
|
|
22
|
+
.tabItem { Label("Notifications", systemImage: "bell") }
|
|
23
|
+
}
|
|
24
|
+
.frame(width: 560, height: 440)
|
|
25
|
+
.padding()
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
private var generalTab: some View {
|
|
29
|
+
Form {
|
|
30
|
+
Picker("Theme", selection: $theme) {
|
|
31
|
+
Text("System").tag("system")
|
|
32
|
+
Text("Dark").tag("dark")
|
|
33
|
+
Text("Light").tag("light")
|
|
34
|
+
}
|
|
35
|
+
.onChange(of: theme) { _, newValue in
|
|
36
|
+
applyTheme(newValue)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
LabeledContent("Global Shortcut") {
|
|
40
|
+
Text(globalShortcut)
|
|
41
|
+
.foregroundStyle(.secondary)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
Toggle("Launch at Login", isOn: $launchAtLogin)
|
|
45
|
+
.onChange(of: launchAtLogin) { _, enabled in
|
|
46
|
+
do {
|
|
47
|
+
if enabled {
|
|
48
|
+
try SMAppService.mainApp.register()
|
|
49
|
+
} else {
|
|
50
|
+
try SMAppService.mainApp.unregister()
|
|
51
|
+
}
|
|
52
|
+
} catch {
|
|
53
|
+
launchAtLogin = !enabled
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
private var notificationsTab: some View {
|
|
60
|
+
Form {
|
|
61
|
+
Toggle("Terminal Bell", isOn: $notifyBell)
|
|
62
|
+
Toggle("Run Complete", isOn: $notifyRunComplete)
|
|
63
|
+
Toggle("Approval Needed", isOn: $notifyApproval)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
private func applyTheme(_ theme: String) {
|
|
68
|
+
switch theme {
|
|
69
|
+
case "dark":
|
|
70
|
+
NSApp.appearance = NSAppearance(named: .darkAqua)
|
|
71
|
+
case "light":
|
|
72
|
+
NSApp.appearance = NSAppearance(named: .aqua)
|
|
73
|
+
default:
|
|
74
|
+
NSApp.appearance = nil // system
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|