@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,94 @@
|
|
|
1
|
+
import SwiftUI
|
|
2
|
+
import RemuxKit
|
|
3
|
+
|
|
4
|
+
/// Horizontal tab bar showing tabs in the current session.
|
|
5
|
+
/// Supports click to switch, + button to create, close button on hover.
|
|
6
|
+
struct TabBarView: View {
|
|
7
|
+
@Environment(RemuxState.self) private var state
|
|
8
|
+
|
|
9
|
+
var body: some View {
|
|
10
|
+
ScrollView(.horizontal, showsIndicators: false) {
|
|
11
|
+
HStack(spacing: 0) {
|
|
12
|
+
ForEach(state.tabs, id: \.index) { tab in
|
|
13
|
+
TabItemView(
|
|
14
|
+
tab: tab,
|
|
15
|
+
isActive: tab.index == state.activeTabIndex,
|
|
16
|
+
onSelect: {
|
|
17
|
+
if let pane = tab.panes.first {
|
|
18
|
+
state.switchTab(id: pane.id)
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
onClose: {
|
|
22
|
+
if let pane = tab.panes.first {
|
|
23
|
+
state.closeTab(id: pane.id)
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// New tab button
|
|
30
|
+
Button(action: { state.createTab() }) {
|
|
31
|
+
Image(systemName: "plus")
|
|
32
|
+
.font(.system(size: 11))
|
|
33
|
+
.foregroundStyle(.secondary)
|
|
34
|
+
.frame(width: 28, height: 28)
|
|
35
|
+
}
|
|
36
|
+
.buttonStyle(.plain)
|
|
37
|
+
.help("New Tab")
|
|
38
|
+
|
|
39
|
+
Spacer()
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
.frame(height: 32)
|
|
43
|
+
.background(.bar)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
struct TabItemView: View {
|
|
48
|
+
let tab: WorkspaceTab
|
|
49
|
+
let isActive: Bool
|
|
50
|
+
let onSelect: () -> Void
|
|
51
|
+
let onClose: () -> Void
|
|
52
|
+
|
|
53
|
+
@State private var isHovering = false
|
|
54
|
+
|
|
55
|
+
var body: some View {
|
|
56
|
+
HStack(spacing: 4) {
|
|
57
|
+
if tab.hasBell {
|
|
58
|
+
Circle()
|
|
59
|
+
.fill(.red)
|
|
60
|
+
.frame(width: 6, height: 6)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
Text(tab.name)
|
|
64
|
+
.font(.system(size: 12))
|
|
65
|
+
.lineLimit(1)
|
|
66
|
+
|
|
67
|
+
if isHovering {
|
|
68
|
+
Button(action: onClose) {
|
|
69
|
+
Image(systemName: "xmark")
|
|
70
|
+
.font(.system(size: 8, weight: .bold))
|
|
71
|
+
.foregroundStyle(.secondary)
|
|
72
|
+
}
|
|
73
|
+
.buttonStyle(.plain)
|
|
74
|
+
.frame(width: 14, height: 14)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
.padding(.horizontal, 12)
|
|
78
|
+
.padding(.vertical, 6)
|
|
79
|
+
.background(isActive ? Color.accentColor.opacity(0.15) : Color.clear)
|
|
80
|
+
.cornerRadius(6)
|
|
81
|
+
.overlay(
|
|
82
|
+
RoundedRectangle(cornerRadius: 6)
|
|
83
|
+
.stroke(isActive ? Color.accentColor.opacity(0.3) : Color.clear, lineWidth: 1)
|
|
84
|
+
)
|
|
85
|
+
.onHover { isHovering = $0 }
|
|
86
|
+
.onTapGesture { onSelect() }
|
|
87
|
+
.contextMenu {
|
|
88
|
+
Button("Close Tab") { onClose() }
|
|
89
|
+
Button("Rename Tab...") {
|
|
90
|
+
// TODO: rename dialog
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import AppKit
|
|
2
|
+
import UniformTypeIdentifiers
|
|
3
|
+
|
|
4
|
+
/// Helper for enhanced clipboard operations in the terminal.
|
|
5
|
+
/// Adapted from ghostty-org/ghostty macOS clipboard handling patterns.
|
|
6
|
+
enum ClipboardHelper {
|
|
7
|
+
|
|
8
|
+
/// Read paste content from the pasteboard with priority:
|
|
9
|
+
/// file URLs -> plain text -> RTF -> HTML
|
|
10
|
+
static func pasteContent(from pasteboard: NSPasteboard) -> String? {
|
|
11
|
+
// Priority 1: File URLs — paste as escaped shell paths
|
|
12
|
+
if let urls = pasteboard.readObjects(forClasses: [NSURL.self], options: nil) as? [URL],
|
|
13
|
+
!urls.isEmpty {
|
|
14
|
+
let paths = urls.compactMap { url -> String? in
|
|
15
|
+
guard url.isFileURL else { return url.absoluteString }
|
|
16
|
+
return escapeForShell(url.path)
|
|
17
|
+
}
|
|
18
|
+
if !paths.isEmpty {
|
|
19
|
+
return paths.joined(separator: " ")
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Priority 2: Plain text
|
|
24
|
+
if let text = pasteboard.string(forType: .string), !text.isEmpty {
|
|
25
|
+
return text
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Priority 3: RTF — extract plain text from attributed string
|
|
29
|
+
if let rtfData = pasteboard.data(forType: .rtf) {
|
|
30
|
+
if let attrStr = NSAttributedString(rtf: rtfData, documentAttributes: nil) {
|
|
31
|
+
let text = attrStr.string
|
|
32
|
+
if !text.isEmpty { return text }
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Priority 4: HTML — extract plain text from HTML
|
|
37
|
+
if let htmlData = pasteboard.data(forType: .html) {
|
|
38
|
+
if let attrStr = try? NSAttributedString(
|
|
39
|
+
data: htmlData,
|
|
40
|
+
options: [.documentType: NSAttributedString.DocumentType.html],
|
|
41
|
+
documentAttributes: nil
|
|
42
|
+
) {
|
|
43
|
+
let text = attrStr.string
|
|
44
|
+
if !text.isEmpty { return text }
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return nil
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/// Escape a file path for safe use in shell commands.
|
|
52
|
+
/// Handles spaces, parentheses, quotes, and other special characters.
|
|
53
|
+
static func escapeForShell(_ path: String) -> String {
|
|
54
|
+
let specialChars: Set<Character> = [
|
|
55
|
+
" ", "(", ")", "'", "\"", "\\", "!", "#", "$", "&",
|
|
56
|
+
";", "|", "<", ">", "?", "*", "[", "]", "{", "}",
|
|
57
|
+
"~", "`", "^",
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
var result = ""
|
|
61
|
+
for char in path {
|
|
62
|
+
if specialChars.contains(char) {
|
|
63
|
+
result.append("\\")
|
|
64
|
+
}
|
|
65
|
+
result.append(char)
|
|
66
|
+
}
|
|
67
|
+
return result
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/// Save a pasted image from the pasteboard to a temp file.
|
|
71
|
+
/// Returns the file URL if successful.
|
|
72
|
+
static func saveImageToTemp(from pasteboard: NSPasteboard) -> URL? {
|
|
73
|
+
// Check for image data types
|
|
74
|
+
let imageTypes: [NSPasteboard.PasteboardType] = [.tiff, .png]
|
|
75
|
+
|
|
76
|
+
for imageType in imageTypes {
|
|
77
|
+
guard let imageData = pasteboard.data(forType: imageType) else { continue }
|
|
78
|
+
|
|
79
|
+
guard let image = NSImage(data: imageData),
|
|
80
|
+
let tiffData = image.tiffRepresentation,
|
|
81
|
+
let bitmap = NSBitmapImageRep(data: tiffData),
|
|
82
|
+
let pngData = bitmap.representation(using: .png, properties: [:]) else {
|
|
83
|
+
continue
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
let tempDir = FileManager.default.temporaryDirectory
|
|
87
|
+
let filename = "remux-paste-\(UUID().uuidString.prefix(8)).png"
|
|
88
|
+
let fileURL = tempDir.appendingPathComponent(filename)
|
|
89
|
+
|
|
90
|
+
do {
|
|
91
|
+
try pngData.write(to: fileURL)
|
|
92
|
+
return fileURL
|
|
93
|
+
} catch {
|
|
94
|
+
NSLog("[remux] Failed to save pasted image: \(error)")
|
|
95
|
+
continue
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return nil
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
import SwiftUI
|
|
2
|
+
import AppKit
|
|
3
|
+
|
|
4
|
+
/// Vi-like copy mode overlay for terminal text selection.
|
|
5
|
+
/// Toggle with Cmd+Shift+C. Shows "COPY MODE" indicator.
|
|
6
|
+
/// Arrow keys move cursor, v enters visual select, y copies, Escape exits.
|
|
7
|
+
///
|
|
8
|
+
/// Works by reading current terminal text from inspect API and
|
|
9
|
+
/// rendering a selection overlay.
|
|
10
|
+
///
|
|
11
|
+
/// Adapted from tmux copy-mode and Zellij scroll/search mode UX patterns.
|
|
12
|
+
struct CopyModeOverlay: View {
|
|
13
|
+
@Binding var isActive: Bool
|
|
14
|
+
@State private var cursorRow: Int = 0
|
|
15
|
+
@State private var cursorCol: Int = 0
|
|
16
|
+
@State private var selectionStart: CopyModePosition?
|
|
17
|
+
@State private var isVisualMode: Bool = false
|
|
18
|
+
@State private var terminalLines: [String] = []
|
|
19
|
+
@State private var statusMessage: String = ""
|
|
20
|
+
|
|
21
|
+
/// Callback to request terminal text content.
|
|
22
|
+
var onRequestContent: (() -> [String])?
|
|
23
|
+
/// Callback when text is copied.
|
|
24
|
+
var onCopy: ((String) -> Void)?
|
|
25
|
+
|
|
26
|
+
struct CopyModePosition: Equatable {
|
|
27
|
+
var row: Int
|
|
28
|
+
var col: Int
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
var body: some View {
|
|
32
|
+
if isActive {
|
|
33
|
+
ZStack {
|
|
34
|
+
// Semi-transparent overlay
|
|
35
|
+
Color.black.opacity(0.05)
|
|
36
|
+
.allowsHitTesting(true)
|
|
37
|
+
|
|
38
|
+
VStack {
|
|
39
|
+
// Mode indicator
|
|
40
|
+
HStack {
|
|
41
|
+
Spacer()
|
|
42
|
+
|
|
43
|
+
HStack(spacing: 6) {
|
|
44
|
+
Image(systemName: "doc.on.doc")
|
|
45
|
+
.font(.system(size: 10))
|
|
46
|
+
Text(isVisualMode ? "VISUAL" : "COPY MODE")
|
|
47
|
+
.font(.system(size: 11, weight: .semibold, design: .monospaced))
|
|
48
|
+
}
|
|
49
|
+
.padding(.horizontal, 10)
|
|
50
|
+
.padding(.vertical, 4)
|
|
51
|
+
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 6))
|
|
52
|
+
.overlay(
|
|
53
|
+
RoundedRectangle(cornerRadius: 6)
|
|
54
|
+
.stroke(Color.yellow.opacity(0.5), lineWidth: 1)
|
|
55
|
+
)
|
|
56
|
+
.padding(.trailing, 12)
|
|
57
|
+
.padding(.top, 8)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
Spacer()
|
|
61
|
+
|
|
62
|
+
// Status bar
|
|
63
|
+
HStack {
|
|
64
|
+
if !statusMessage.isEmpty {
|
|
65
|
+
Text(statusMessage)
|
|
66
|
+
.font(.system(size: 11, design: .monospaced))
|
|
67
|
+
.foregroundStyle(.secondary)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
Spacer()
|
|
71
|
+
|
|
72
|
+
Text("Ln \(cursorRow + 1), Col \(cursorCol + 1)")
|
|
73
|
+
.font(.system(size: 11, design: .monospaced))
|
|
74
|
+
.foregroundStyle(.secondary)
|
|
75
|
+
|
|
76
|
+
Text("| h/j/k/l: move | v: visual | y: yank | Esc: exit")
|
|
77
|
+
.font(.system(size: 10, design: .monospaced))
|
|
78
|
+
.foregroundStyle(.tertiary)
|
|
79
|
+
}
|
|
80
|
+
.padding(.horizontal, 10)
|
|
81
|
+
.padding(.vertical, 4)
|
|
82
|
+
.background(.ultraThinMaterial)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
.onAppear {
|
|
86
|
+
loadContent()
|
|
87
|
+
}
|
|
88
|
+
.onKeyPress { keyPress in
|
|
89
|
+
handleKeyPress(keyPress)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
private func loadContent() {
|
|
95
|
+
if let lines = onRequestContent?() {
|
|
96
|
+
terminalLines = lines
|
|
97
|
+
if !lines.isEmpty {
|
|
98
|
+
cursorRow = max(0, lines.count - 1)
|
|
99
|
+
cursorCol = 0
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
statusMessage = ""
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
private func handleKeyPress(_ keyPress: KeyPress) -> KeyPress.Result {
|
|
106
|
+
switch keyPress.characters {
|
|
107
|
+
// Movement: vi keys
|
|
108
|
+
case "h":
|
|
109
|
+
moveCursor(dRow: 0, dCol: -1)
|
|
110
|
+
return .handled
|
|
111
|
+
case "j":
|
|
112
|
+
moveCursor(dRow: 1, dCol: 0)
|
|
113
|
+
return .handled
|
|
114
|
+
case "k":
|
|
115
|
+
moveCursor(dRow: -1, dCol: 0)
|
|
116
|
+
return .handled
|
|
117
|
+
case "l":
|
|
118
|
+
moveCursor(dRow: 0, dCol: 1)
|
|
119
|
+
return .handled
|
|
120
|
+
|
|
121
|
+
// Movement: word
|
|
122
|
+
case "w":
|
|
123
|
+
moveWordForward()
|
|
124
|
+
return .handled
|
|
125
|
+
case "b":
|
|
126
|
+
moveWordBackward()
|
|
127
|
+
return .handled
|
|
128
|
+
|
|
129
|
+
// Line start/end
|
|
130
|
+
case "0":
|
|
131
|
+
cursorCol = 0
|
|
132
|
+
return .handled
|
|
133
|
+
case "$":
|
|
134
|
+
if cursorRow < terminalLines.count {
|
|
135
|
+
cursorCol = max(0, terminalLines[cursorRow].count - 1)
|
|
136
|
+
}
|
|
137
|
+
return .handled
|
|
138
|
+
|
|
139
|
+
// Top/bottom
|
|
140
|
+
case "g":
|
|
141
|
+
cursorRow = 0
|
|
142
|
+
cursorCol = 0
|
|
143
|
+
return .handled
|
|
144
|
+
case "G":
|
|
145
|
+
cursorRow = max(0, terminalLines.count - 1)
|
|
146
|
+
cursorCol = 0
|
|
147
|
+
return .handled
|
|
148
|
+
|
|
149
|
+
// Visual mode toggle
|
|
150
|
+
case "v":
|
|
151
|
+
toggleVisualMode()
|
|
152
|
+
return .handled
|
|
153
|
+
|
|
154
|
+
// Yank (copy)
|
|
155
|
+
case "y":
|
|
156
|
+
yankSelection()
|
|
157
|
+
return .handled
|
|
158
|
+
|
|
159
|
+
default:
|
|
160
|
+
break
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Arrow keys
|
|
164
|
+
switch keyPress.key {
|
|
165
|
+
case .upArrow:
|
|
166
|
+
moveCursor(dRow: -1, dCol: 0)
|
|
167
|
+
return .handled
|
|
168
|
+
case .downArrow:
|
|
169
|
+
moveCursor(dRow: 1, dCol: 0)
|
|
170
|
+
return .handled
|
|
171
|
+
case .leftArrow:
|
|
172
|
+
moveCursor(dRow: 0, dCol: -1)
|
|
173
|
+
return .handled
|
|
174
|
+
case .rightArrow:
|
|
175
|
+
moveCursor(dRow: 0, dCol: 1)
|
|
176
|
+
return .handled
|
|
177
|
+
case .escape:
|
|
178
|
+
if isVisualMode {
|
|
179
|
+
isVisualMode = false
|
|
180
|
+
selectionStart = nil
|
|
181
|
+
statusMessage = ""
|
|
182
|
+
} else {
|
|
183
|
+
isActive = false
|
|
184
|
+
}
|
|
185
|
+
return .handled
|
|
186
|
+
default:
|
|
187
|
+
break
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return .ignored
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
private func moveCursor(dRow: Int, dCol: Int) {
|
|
194
|
+
let newRow = max(0, min(terminalLines.count - 1, cursorRow + dRow))
|
|
195
|
+
let maxCol = max(0, (newRow < terminalLines.count ? terminalLines[newRow].count : 80) - 1)
|
|
196
|
+
let newCol = max(0, min(maxCol, cursorCol + dCol))
|
|
197
|
+
cursorRow = newRow
|
|
198
|
+
cursorCol = newCol
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
private func moveWordForward() {
|
|
202
|
+
guard cursorRow < terminalLines.count else { return }
|
|
203
|
+
let line = terminalLines[cursorRow]
|
|
204
|
+
let chars = Array(line)
|
|
205
|
+
var col = cursorCol
|
|
206
|
+
|
|
207
|
+
// Skip current word characters
|
|
208
|
+
while col < chars.count && !chars[col].isWhitespace { col += 1 }
|
|
209
|
+
// Skip whitespace
|
|
210
|
+
while col < chars.count && chars[col].isWhitespace { col += 1 }
|
|
211
|
+
|
|
212
|
+
if col >= chars.count && cursorRow < terminalLines.count - 1 {
|
|
213
|
+
cursorRow += 1
|
|
214
|
+
cursorCol = 0
|
|
215
|
+
} else {
|
|
216
|
+
cursorCol = min(col, max(0, chars.count - 1))
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
private func moveWordBackward() {
|
|
221
|
+
guard cursorRow < terminalLines.count else { return }
|
|
222
|
+
let line = terminalLines[cursorRow]
|
|
223
|
+
let chars = Array(line)
|
|
224
|
+
var col = cursorCol
|
|
225
|
+
|
|
226
|
+
if col <= 0 && cursorRow > 0 {
|
|
227
|
+
cursorRow -= 1
|
|
228
|
+
cursorCol = max(0, terminalLines[cursorRow].count - 1)
|
|
229
|
+
return
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Skip whitespace backwards
|
|
233
|
+
while col > 0 && chars[max(0, col - 1)].isWhitespace { col -= 1 }
|
|
234
|
+
// Skip word characters backwards
|
|
235
|
+
while col > 0 && !chars[max(0, col - 1)].isWhitespace { col -= 1 }
|
|
236
|
+
|
|
237
|
+
cursorCol = max(0, col)
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
private func toggleVisualMode() {
|
|
241
|
+
if isVisualMode {
|
|
242
|
+
isVisualMode = false
|
|
243
|
+
selectionStart = nil
|
|
244
|
+
statusMessage = ""
|
|
245
|
+
} else {
|
|
246
|
+
isVisualMode = true
|
|
247
|
+
selectionStart = CopyModePosition(row: cursorRow, col: cursorCol)
|
|
248
|
+
statusMessage = "VISUAL: move to select, y to yank"
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
private func yankSelection() {
|
|
253
|
+
let text: String
|
|
254
|
+
if isVisualMode, let start = selectionStart {
|
|
255
|
+
text = extractSelection(from: start, to: CopyModePosition(row: cursorRow, col: cursorCol))
|
|
256
|
+
} else {
|
|
257
|
+
// Yank current line
|
|
258
|
+
if cursorRow < terminalLines.count {
|
|
259
|
+
text = terminalLines[cursorRow]
|
|
260
|
+
} else {
|
|
261
|
+
text = ""
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
guard !text.isEmpty else {
|
|
266
|
+
statusMessage = "Nothing to yank"
|
|
267
|
+
return
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Copy to clipboard
|
|
271
|
+
NSPasteboard.general.clearContents()
|
|
272
|
+
NSPasteboard.general.setString(text, forType: .string)
|
|
273
|
+
|
|
274
|
+
statusMessage = "Yanked \(text.count) chars"
|
|
275
|
+
onCopy?(text)
|
|
276
|
+
|
|
277
|
+
// Exit copy mode after yank
|
|
278
|
+
isVisualMode = false
|
|
279
|
+
selectionStart = nil
|
|
280
|
+
|
|
281
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) {
|
|
282
|
+
self.isActive = false
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
private func extractSelection(from start: CopyModePosition, to end: CopyModePosition) -> String {
|
|
287
|
+
let (s, e) = start.row < end.row || (start.row == end.row && start.col <= end.col)
|
|
288
|
+
? (start, end) : (end, start)
|
|
289
|
+
|
|
290
|
+
guard s.row < terminalLines.count else { return "" }
|
|
291
|
+
|
|
292
|
+
if s.row == e.row {
|
|
293
|
+
let line = terminalLines[s.row]
|
|
294
|
+
let chars = Array(line)
|
|
295
|
+
let startIdx = min(s.col, chars.count)
|
|
296
|
+
let endIdx = min(e.col + 1, chars.count)
|
|
297
|
+
return String(chars[startIdx..<endIdx])
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
var result: [String] = []
|
|
301
|
+
|
|
302
|
+
// First line (from start col)
|
|
303
|
+
let firstLine = terminalLines[s.row]
|
|
304
|
+
let firstChars = Array(firstLine)
|
|
305
|
+
let startIdx = min(s.col, firstChars.count)
|
|
306
|
+
result.append(String(firstChars[startIdx...]))
|
|
307
|
+
|
|
308
|
+
// Middle lines (full)
|
|
309
|
+
for row in (s.row + 1)..<e.row {
|
|
310
|
+
if row < terminalLines.count {
|
|
311
|
+
result.append(terminalLines[row])
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Last line (up to end col)
|
|
316
|
+
if e.row < terminalLines.count {
|
|
317
|
+
let lastLine = terminalLines[e.row]
|
|
318
|
+
let lastChars = Array(lastLine)
|
|
319
|
+
let endIdx = min(e.col + 1, lastChars.count)
|
|
320
|
+
result.append(String(lastChars[..<endIdx]))
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
return result.joined(separator: "\n")
|
|
324
|
+
}
|
|
325
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import SwiftUI
|
|
2
|
+
import RemuxKit
|
|
3
|
+
|
|
4
|
+
/// SwiftUI wrapper for GhosttyNativeView (libghostty Metal renderer).
|
|
5
|
+
/// The relay socket path is used to create the ghostty surface with `nc -U` as its command.
|
|
6
|
+
struct GhosttyNativeTerminalView: NSViewRepresentable {
|
|
7
|
+
let socketPath: String
|
|
8
|
+
@Binding var viewRef: GhosttyNativeView?
|
|
9
|
+
var onResize: ((Int, Int) -> Void)?
|
|
10
|
+
var onBell: (() -> Void)?
|
|
11
|
+
var onTitle: ((String) -> Void)?
|
|
12
|
+
var onSearchStart: ((String?) -> Void)?
|
|
13
|
+
var onSearchEnd: (() -> Void)?
|
|
14
|
+
var onSearchTotal: ((Int) -> Void)?
|
|
15
|
+
var onSearchSelected: ((Int) -> Void)?
|
|
16
|
+
|
|
17
|
+
func makeNSView(context: Context) -> GhosttyNativeView {
|
|
18
|
+
let view = GhosttyNativeView(frame: .zero, socketPath: socketPath)
|
|
19
|
+
view.onResize = onResize
|
|
20
|
+
view.onBell = onBell
|
|
21
|
+
view.onTitle = onTitle
|
|
22
|
+
view.onSearchStart = onSearchStart
|
|
23
|
+
view.onSearchEnd = onSearchEnd
|
|
24
|
+
view.onSearchTotal = onSearchTotal
|
|
25
|
+
view.onSearchSelected = onSearchSelected
|
|
26
|
+
DispatchQueue.main.async { viewRef = view }
|
|
27
|
+
return view
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
func updateNSView(_ nsView: GhosttyNativeView, context: Context) {
|
|
31
|
+
nsView.onResize = onResize
|
|
32
|
+
nsView.onBell = onBell
|
|
33
|
+
nsView.onTitle = onTitle
|
|
34
|
+
nsView.onSearchStart = onSearchStart
|
|
35
|
+
nsView.onSearchEnd = onSearchEnd
|
|
36
|
+
nsView.onSearchTotal = onSearchTotal
|
|
37
|
+
nsView.onSearchSelected = onSearchSelected
|
|
38
|
+
}
|
|
39
|
+
}
|