@wangyaoshen/remux 0.3.8-dev.29e114b
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,277 @@
|
|
|
1
|
+
import SwiftUI
|
|
2
|
+
import AppKit
|
|
3
|
+
import Foundation
|
|
4
|
+
|
|
5
|
+
/// Markdown file viewer panel for the split pane system.
|
|
6
|
+
/// Renders .md files using SwiftUI's AttributedString with markdown parsing.
|
|
7
|
+
/// Supports file picker and auto-reload on file change (FSEvents).
|
|
8
|
+
///
|
|
9
|
+
/// Conforms to PanelProtocol.
|
|
10
|
+
/// Adapted from Marked.app / MacDown conceptual design.
|
|
11
|
+
@MainActor
|
|
12
|
+
final class MarkdownPanel: PanelProtocol {
|
|
13
|
+
let id: UUID
|
|
14
|
+
private(set) var isFocused = false
|
|
15
|
+
private(set) var filePath: URL?
|
|
16
|
+
private(set) var content: String = ""
|
|
17
|
+
private(set) var fileName: String = "Markdown"
|
|
18
|
+
|
|
19
|
+
var title: String { fileName }
|
|
20
|
+
var canClose: Bool { true }
|
|
21
|
+
|
|
22
|
+
/// Called when the content changes (for view refresh).
|
|
23
|
+
var onContentChange: (() -> Void)?
|
|
24
|
+
|
|
25
|
+
/// File system event source for auto-reload.
|
|
26
|
+
nonisolated(unsafe) private var fsEventStream: FSEventStreamRef?
|
|
27
|
+
|
|
28
|
+
init(id: UUID = UUID(), filePath: URL? = nil) {
|
|
29
|
+
self.id = id
|
|
30
|
+
if let filePath {
|
|
31
|
+
self.filePath = filePath
|
|
32
|
+
self.fileName = filePath.lastPathComponent
|
|
33
|
+
loadFile(at: filePath)
|
|
34
|
+
watchFile(at: filePath)
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
deinit {
|
|
39
|
+
if let stream = fsEventStream {
|
|
40
|
+
FSEventStreamStop(stream)
|
|
41
|
+
FSEventStreamInvalidate(stream)
|
|
42
|
+
FSEventStreamRelease(stream)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
func focus() { isFocused = true }
|
|
47
|
+
func blur() { isFocused = false }
|
|
48
|
+
|
|
49
|
+
// MARK: - File operations
|
|
50
|
+
|
|
51
|
+
func openFile() {
|
|
52
|
+
let panel = NSOpenPanel()
|
|
53
|
+
panel.allowedContentTypes = [.init(filenameExtension: "md")!]
|
|
54
|
+
panel.allowsMultipleSelection = false
|
|
55
|
+
panel.canChooseDirectories = false
|
|
56
|
+
|
|
57
|
+
panel.begin { [weak self] response in
|
|
58
|
+
guard response == .OK, let url = panel.url else { return }
|
|
59
|
+
Task { @MainActor in
|
|
60
|
+
self?.setFile(url)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
func setFile(_ url: URL) {
|
|
66
|
+
stopWatching()
|
|
67
|
+
filePath = url
|
|
68
|
+
fileName = url.lastPathComponent
|
|
69
|
+
loadFile(at: url)
|
|
70
|
+
watchFile(at: url)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
private func loadFile(at url: URL) {
|
|
74
|
+
do {
|
|
75
|
+
content = try String(contentsOf: url, encoding: .utf8)
|
|
76
|
+
onContentChange?()
|
|
77
|
+
} catch {
|
|
78
|
+
content = "Failed to load file: \(error.localizedDescription)"
|
|
79
|
+
onContentChange?()
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// MARK: - File watching (FSEvents)
|
|
84
|
+
|
|
85
|
+
private func watchFile(at url: URL) {
|
|
86
|
+
let dirPath = url.deletingLastPathComponent().path as CFString
|
|
87
|
+
var context = FSEventStreamContext()
|
|
88
|
+
|
|
89
|
+
let rawSelf = Unmanaged.passUnretained(self).toOpaque()
|
|
90
|
+
context.info = rawSelf
|
|
91
|
+
|
|
92
|
+
let callback: FSEventStreamCallback = { _, info, numEvents, eventPaths, _, _ in
|
|
93
|
+
guard let info else { return }
|
|
94
|
+
let panel = Unmanaged<MarkdownPanel>.fromOpaque(info).takeUnretainedValue()
|
|
95
|
+
let count = numEvents
|
|
96
|
+
_ = count
|
|
97
|
+
Task { @MainActor in
|
|
98
|
+
if let path = panel.filePath {
|
|
99
|
+
panel.loadFile(at: path)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
var paths = [dirPath] as CFArray
|
|
105
|
+
let stream = FSEventStreamCreate(
|
|
106
|
+
nil,
|
|
107
|
+
callback,
|
|
108
|
+
&context,
|
|
109
|
+
paths,
|
|
110
|
+
FSEventStreamEventId(kFSEventStreamEventIdSinceNow),
|
|
111
|
+
1.0, // 1 second latency
|
|
112
|
+
FSEventStreamCreateFlags(kFSEventStreamCreateFlagFileEvents | kFSEventStreamCreateFlagUseCFTypes)
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
if let stream {
|
|
116
|
+
fsEventStream = stream
|
|
117
|
+
FSEventStreamScheduleWithRunLoop(stream, CFRunLoopGetMain(), CFRunLoopMode.defaultMode.rawValue)
|
|
118
|
+
FSEventStreamStart(stream)
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
private func stopWatching() {
|
|
123
|
+
if let stream = fsEventStream {
|
|
124
|
+
FSEventStreamStop(stream)
|
|
125
|
+
FSEventStreamInvalidate(stream)
|
|
126
|
+
FSEventStreamRelease(stream)
|
|
127
|
+
fsEventStream = nil
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// MARK: - MarkdownPanelView (SwiftUI)
|
|
133
|
+
|
|
134
|
+
/// SwiftUI view wrapping a MarkdownPanel with toolbar and rendered content.
|
|
135
|
+
struct MarkdownPanelView: View {
|
|
136
|
+
@State private var panel: MarkdownPanel
|
|
137
|
+
@State private var refreshID: UUID = UUID()
|
|
138
|
+
|
|
139
|
+
init(panel: MarkdownPanel) {
|
|
140
|
+
_panel = State(initialValue: panel)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
var body: some View {
|
|
144
|
+
VStack(spacing: 0) {
|
|
145
|
+
// Toolbar
|
|
146
|
+
HStack(spacing: 8) {
|
|
147
|
+
Image(systemName: "doc.text")
|
|
148
|
+
.foregroundStyle(.secondary)
|
|
149
|
+
|
|
150
|
+
Text(panel.fileName)
|
|
151
|
+
.font(.system(size: 12, weight: .medium))
|
|
152
|
+
.lineLimit(1)
|
|
153
|
+
|
|
154
|
+
Spacer()
|
|
155
|
+
|
|
156
|
+
Button(action: { panel.openFile() }) {
|
|
157
|
+
Image(systemName: "folder")
|
|
158
|
+
.font(.system(size: 11))
|
|
159
|
+
}
|
|
160
|
+
.buttonStyle(.borderless)
|
|
161
|
+
.help("Open Markdown File")
|
|
162
|
+
|
|
163
|
+
if panel.filePath != nil {
|
|
164
|
+
Button(action: { reloadContent() }) {
|
|
165
|
+
Image(systemName: "arrow.clockwise")
|
|
166
|
+
.font(.system(size: 11))
|
|
167
|
+
}
|
|
168
|
+
.buttonStyle(.borderless)
|
|
169
|
+
.help("Reload")
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
.padding(.horizontal, 10)
|
|
173
|
+
.padding(.vertical, 6)
|
|
174
|
+
.background(.bar)
|
|
175
|
+
|
|
176
|
+
Divider()
|
|
177
|
+
|
|
178
|
+
// Content
|
|
179
|
+
if panel.content.isEmpty && panel.filePath == nil {
|
|
180
|
+
ContentUnavailableView(
|
|
181
|
+
"No File Open",
|
|
182
|
+
systemImage: "doc.text",
|
|
183
|
+
description: Text("Click the folder icon to open a Markdown file.")
|
|
184
|
+
)
|
|
185
|
+
} else {
|
|
186
|
+
ScrollView {
|
|
187
|
+
MarkdownRenderedContent(markdown: panel.content)
|
|
188
|
+
.padding(16)
|
|
189
|
+
.frame(maxWidth: .infinity, alignment: .leading)
|
|
190
|
+
.id(refreshID)
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
.onAppear {
|
|
195
|
+
panel.onContentChange = {
|
|
196
|
+
refreshID = UUID()
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
private func reloadContent() {
|
|
202
|
+
if let path = panel.filePath {
|
|
203
|
+
panel.setFile(path)
|
|
204
|
+
refreshID = UUID()
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/// Renders markdown text as attributed content using SwiftUI's built-in markdown parser.
|
|
210
|
+
struct MarkdownRenderedContent: View {
|
|
211
|
+
let markdown: String
|
|
212
|
+
|
|
213
|
+
var body: some View {
|
|
214
|
+
VStack(alignment: .leading, spacing: 8) {
|
|
215
|
+
// Split by blank lines to create paragraph blocks
|
|
216
|
+
let blocks = markdown.components(separatedBy: "\n\n")
|
|
217
|
+
ForEach(Array(blocks.enumerated()), id: \.offset) { _, block in
|
|
218
|
+
let trimmed = block.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
219
|
+
if trimmed.hasPrefix("# ") {
|
|
220
|
+
Text(trimmed.dropFirst(2))
|
|
221
|
+
.font(.title)
|
|
222
|
+
.fontWeight(.bold)
|
|
223
|
+
.padding(.top, 8)
|
|
224
|
+
} else if trimmed.hasPrefix("## ") {
|
|
225
|
+
Text(trimmed.dropFirst(3))
|
|
226
|
+
.font(.title2)
|
|
227
|
+
.fontWeight(.semibold)
|
|
228
|
+
.padding(.top, 6)
|
|
229
|
+
} else if trimmed.hasPrefix("### ") {
|
|
230
|
+
Text(trimmed.dropFirst(4))
|
|
231
|
+
.font(.title3)
|
|
232
|
+
.fontWeight(.medium)
|
|
233
|
+
.padding(.top, 4)
|
|
234
|
+
} else if trimmed.hasPrefix("```") {
|
|
235
|
+
// Code block
|
|
236
|
+
let code = trimmed
|
|
237
|
+
.replacingOccurrences(of: "```\\w*\n?", with: "", options: .regularExpression)
|
|
238
|
+
.replacingOccurrences(of: "```", with: "")
|
|
239
|
+
Text(code)
|
|
240
|
+
.font(.system(size: 12, design: .monospaced))
|
|
241
|
+
.padding(10)
|
|
242
|
+
.frame(maxWidth: .infinity, alignment: .leading)
|
|
243
|
+
.background(Color.primary.opacity(0.05))
|
|
244
|
+
.cornerRadius(6)
|
|
245
|
+
} else if trimmed.hasPrefix("- ") || trimmed.hasPrefix("* ") {
|
|
246
|
+
// List items
|
|
247
|
+
let items = trimmed.components(separatedBy: "\n")
|
|
248
|
+
ForEach(Array(items.enumerated()), id: \.offset) { _, item in
|
|
249
|
+
let cleaned = item
|
|
250
|
+
.replacingOccurrences(of: "^[\\-\\*]\\s+", with: "", options: .regularExpression)
|
|
251
|
+
HStack(alignment: .top, spacing: 6) {
|
|
252
|
+
Text("\u{2022}")
|
|
253
|
+
.foregroundStyle(.secondary)
|
|
254
|
+
if let attrStr = try? AttributedString(markdown: cleaned) {
|
|
255
|
+
Text(attrStr)
|
|
256
|
+
} else {
|
|
257
|
+
Text(cleaned)
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
} else if trimmed.hasPrefix("---") || trimmed.hasPrefix("***") {
|
|
262
|
+
Divider()
|
|
263
|
+
.padding(.vertical, 4)
|
|
264
|
+
} else if !trimmed.isEmpty {
|
|
265
|
+
// Regular paragraph with inline markdown
|
|
266
|
+
if let attrStr = try? AttributedString(markdown: trimmed) {
|
|
267
|
+
Text(attrStr)
|
|
268
|
+
.textSelection(.enabled)
|
|
269
|
+
} else {
|
|
270
|
+
Text(trimmed)
|
|
271
|
+
.textSelection(.enabled)
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// Protocol for panels displayed in split panes.
|
|
4
|
+
/// TerminalPanel is the primary conformer; BrowserPanel, MarkdownPanel, etc.
|
|
5
|
+
/// can be added later.
|
|
6
|
+
@MainActor
|
|
7
|
+
protocol PanelProtocol: Identifiable {
|
|
8
|
+
var id: UUID { get }
|
|
9
|
+
var title: String { get }
|
|
10
|
+
var canClose: Bool { get }
|
|
11
|
+
|
|
12
|
+
func focus()
|
|
13
|
+
func blur()
|
|
14
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// Recursive binary tree for split pane layout.
|
|
4
|
+
/// Adapted from ghostty-org/ghostty SplitNode design pattern.
|
|
5
|
+
indirect enum SplitNode: Identifiable, Sendable {
|
|
6
|
+
case leaf(LeafData)
|
|
7
|
+
case branch(BranchData)
|
|
8
|
+
|
|
9
|
+
struct LeafData: Identifiable, Sendable {
|
|
10
|
+
var id: UUID
|
|
11
|
+
var tabIndex: Int
|
|
12
|
+
var panelType: PanelType
|
|
13
|
+
|
|
14
|
+
init(id: UUID = UUID(), tabIndex: Int, panelType: PanelType = .terminal) {
|
|
15
|
+
self.id = id
|
|
16
|
+
self.tabIndex = tabIndex
|
|
17
|
+
self.panelType = panelType
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
struct BranchData: Identifiable, Sendable {
|
|
22
|
+
var id: UUID
|
|
23
|
+
var orientation: Orientation
|
|
24
|
+
var ratio: CGFloat
|
|
25
|
+
var first: SplitNode
|
|
26
|
+
var second: SplitNode
|
|
27
|
+
|
|
28
|
+
init(
|
|
29
|
+
id: UUID = UUID(),
|
|
30
|
+
orientation: Orientation,
|
|
31
|
+
ratio: CGFloat = 0.5,
|
|
32
|
+
first: SplitNode,
|
|
33
|
+
second: SplitNode
|
|
34
|
+
) {
|
|
35
|
+
self.id = id
|
|
36
|
+
self.orientation = orientation
|
|
37
|
+
self.ratio = ratio
|
|
38
|
+
self.first = first
|
|
39
|
+
self.second = second
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
enum Orientation: String, Sendable, Codable {
|
|
44
|
+
case horizontal // side by side (split right)
|
|
45
|
+
case vertical // top and bottom (split down)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
var id: UUID {
|
|
49
|
+
switch self {
|
|
50
|
+
case .leaf(let data): data.id
|
|
51
|
+
case .branch(let data): data.id
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// MARK: - Tree operations
|
|
56
|
+
|
|
57
|
+
/// Find a leaf by its ID.
|
|
58
|
+
func findLeaf(id: UUID) -> LeafData? {
|
|
59
|
+
switch self {
|
|
60
|
+
case .leaf(let data):
|
|
61
|
+
return data.id == id ? data : nil
|
|
62
|
+
case .branch(let data):
|
|
63
|
+
return data.first.findLeaf(id: id) ?? data.second.findLeaf(id: id)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/// Collect all leaf nodes in order.
|
|
68
|
+
var allLeaves: [LeafData] {
|
|
69
|
+
switch self {
|
|
70
|
+
case .leaf(let data):
|
|
71
|
+
return [data]
|
|
72
|
+
case .branch(let data):
|
|
73
|
+
return data.first.allLeaves + data.second.allLeaves
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/// Split a leaf node into a branch with the original leaf and a new leaf.
|
|
78
|
+
func split(leafID: UUID, orientation: Orientation, newTabIndex: Int, panelType: PanelType = .terminal) -> SplitNode {
|
|
79
|
+
switch self {
|
|
80
|
+
case .leaf(let data):
|
|
81
|
+
guard data.id == leafID else { return self }
|
|
82
|
+
let newLeaf = SplitNode.leaf(LeafData(tabIndex: newTabIndex, panelType: panelType))
|
|
83
|
+
return .branch(BranchData(
|
|
84
|
+
orientation: orientation,
|
|
85
|
+
first: self,
|
|
86
|
+
second: newLeaf
|
|
87
|
+
))
|
|
88
|
+
|
|
89
|
+
case .branch(var data):
|
|
90
|
+
data.first = data.first.split(leafID: leafID, orientation: orientation, newTabIndex: newTabIndex, panelType: panelType)
|
|
91
|
+
data.second = data.second.split(leafID: leafID, orientation: orientation, newTabIndex: newTabIndex, panelType: panelType)
|
|
92
|
+
return .branch(data)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/// Remove a leaf node by its ID. Returns nil if the tree becomes empty.
|
|
97
|
+
func removeLeaf(id leafID: UUID) -> SplitNode? {
|
|
98
|
+
switch self {
|
|
99
|
+
case .leaf(let data):
|
|
100
|
+
return data.id == leafID ? nil : self
|
|
101
|
+
|
|
102
|
+
case .branch(let data):
|
|
103
|
+
let firstResult = data.first.removeLeaf(id: leafID)
|
|
104
|
+
let secondResult = data.second.removeLeaf(id: leafID)
|
|
105
|
+
|
|
106
|
+
// If either child was removed, return the remaining one
|
|
107
|
+
if firstResult == nil { return secondResult }
|
|
108
|
+
if secondResult == nil { return firstResult }
|
|
109
|
+
|
|
110
|
+
// Both still exist — reconstruct the branch
|
|
111
|
+
var newData = data
|
|
112
|
+
newData.first = firstResult!
|
|
113
|
+
newData.second = secondResult!
|
|
114
|
+
return .branch(newData)
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/// Update the split ratio for a specific branch.
|
|
119
|
+
func updateRatio(branchID: UUID, ratio: CGFloat) -> SplitNode {
|
|
120
|
+
switch self {
|
|
121
|
+
case .leaf:
|
|
122
|
+
return self
|
|
123
|
+
case .branch(var data):
|
|
124
|
+
if data.id == branchID {
|
|
125
|
+
data.ratio = max(0.1, min(0.9, ratio))
|
|
126
|
+
return .branch(data)
|
|
127
|
+
}
|
|
128
|
+
data.first = data.first.updateRatio(branchID: branchID, ratio: ratio)
|
|
129
|
+
data.second = data.second.updateRatio(branchID: branchID, ratio: ratio)
|
|
130
|
+
return .branch(data)
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/// Get the next leaf after the given leaf ID (for focus navigation).
|
|
135
|
+
func nextLeaf(after leafID: UUID) -> LeafData? {
|
|
136
|
+
let leaves = allLeaves
|
|
137
|
+
guard let idx = leaves.firstIndex(where: { $0.id == leafID }) else { return nil }
|
|
138
|
+
let nextIdx = (idx + 1) % leaves.count
|
|
139
|
+
return leaves[nextIdx]
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/// Get the previous leaf before the given leaf ID.
|
|
143
|
+
func previousLeaf(before leafID: UUID) -> LeafData? {
|
|
144
|
+
let leaves = allLeaves
|
|
145
|
+
guard let idx = leaves.firstIndex(where: { $0.id == leafID }) else { return nil }
|
|
146
|
+
let prevIdx = (idx - 1 + leaves.count) % leaves.count
|
|
147
|
+
return leaves[prevIdx]
|
|
148
|
+
}
|
|
149
|
+
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import SwiftUI
|
|
2
|
+
import RemuxKit
|
|
3
|
+
|
|
4
|
+
/// Recursive SwiftUI view that renders a SplitNode tree.
|
|
5
|
+
/// Leaf nodes render a TerminalContainerView; branch nodes render two
|
|
6
|
+
/// children with a draggable divider between them.
|
|
7
|
+
///
|
|
8
|
+
/// Architecture ref: ghostty-org/ghostty SplitView (recursive split pattern)
|
|
9
|
+
struct SplitView: View {
|
|
10
|
+
let node: SplitNode
|
|
11
|
+
@Binding var focusedLeafID: UUID?
|
|
12
|
+
var onSplit: (UUID, SplitNode.Orientation) -> Void
|
|
13
|
+
var onClose: (UUID) -> Void
|
|
14
|
+
var onRatioChange: (UUID, CGFloat) -> Void
|
|
15
|
+
|
|
16
|
+
var body: some View {
|
|
17
|
+
switch node {
|
|
18
|
+
case .leaf(let data):
|
|
19
|
+
SplitLeafView(
|
|
20
|
+
data: data,
|
|
21
|
+
isFocused: focusedLeafID == data.id,
|
|
22
|
+
onFocus: { focusedLeafID = data.id }
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
case .branch(let data):
|
|
26
|
+
SplitBranchView(
|
|
27
|
+
data: data,
|
|
28
|
+
focusedLeafID: $focusedLeafID,
|
|
29
|
+
onSplit: onSplit,
|
|
30
|
+
onClose: onClose,
|
|
31
|
+
onRatioChange: onRatioChange
|
|
32
|
+
)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/// Renders a single leaf in the split tree — dispatches by panel type.
|
|
38
|
+
struct SplitLeafView: View {
|
|
39
|
+
let data: SplitNode.LeafData
|
|
40
|
+
let isFocused: Bool
|
|
41
|
+
var onFocus: () -> Void
|
|
42
|
+
|
|
43
|
+
/// Browser panels keyed by leaf ID (kept alive across re-renders).
|
|
44
|
+
@State private var browserPanel: BrowserPanel?
|
|
45
|
+
/// Markdown panels keyed by leaf ID.
|
|
46
|
+
@State private var markdownPanel: MarkdownPanel?
|
|
47
|
+
|
|
48
|
+
var body: some View {
|
|
49
|
+
panelContent
|
|
50
|
+
.overlay(alignment: .topLeading) {
|
|
51
|
+
if isFocused {
|
|
52
|
+
RoundedRectangle(cornerRadius: 0)
|
|
53
|
+
.stroke(Color.accentColor.opacity(0.4), lineWidth: 2)
|
|
54
|
+
.allowsHitTesting(false)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
.contentShape(Rectangle())
|
|
58
|
+
.onTapGesture {
|
|
59
|
+
onFocus()
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
@ViewBuilder
|
|
64
|
+
private var panelContent: some View {
|
|
65
|
+
switch data.panelType {
|
|
66
|
+
case .terminal:
|
|
67
|
+
TerminalContainerView()
|
|
68
|
+
|
|
69
|
+
case .browser:
|
|
70
|
+
let panel = getBrowserPanel()
|
|
71
|
+
BrowserPanelView(panel: panel)
|
|
72
|
+
|
|
73
|
+
case .markdown:
|
|
74
|
+
let panel = getMarkdownPanel()
|
|
75
|
+
MarkdownPanelView(panel: panel)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
private func getBrowserPanel() -> BrowserPanel {
|
|
80
|
+
if let existing = browserPanel { return existing }
|
|
81
|
+
let panel = BrowserPanel(id: data.id)
|
|
82
|
+
DispatchQueue.main.async { browserPanel = panel }
|
|
83
|
+
return panel
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
private func getMarkdownPanel() -> MarkdownPanel {
|
|
87
|
+
if let existing = markdownPanel { return existing }
|
|
88
|
+
let panel = MarkdownPanel(id: data.id)
|
|
89
|
+
DispatchQueue.main.async { markdownPanel = panel }
|
|
90
|
+
return panel
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/// Renders a branch node: two children separated by a draggable divider.
|
|
95
|
+
struct SplitBranchView: View {
|
|
96
|
+
let data: SplitNode.BranchData
|
|
97
|
+
@Binding var focusedLeafID: UUID?
|
|
98
|
+
var onSplit: (UUID, SplitNode.Orientation) -> Void
|
|
99
|
+
var onClose: (UUID) -> Void
|
|
100
|
+
var onRatioChange: (UUID, CGFloat) -> Void
|
|
101
|
+
|
|
102
|
+
@State private var dividerDragging = false
|
|
103
|
+
|
|
104
|
+
var body: some View {
|
|
105
|
+
GeometryReader { geometry in
|
|
106
|
+
let isHorizontal = data.orientation == .horizontal
|
|
107
|
+
let totalSize = isHorizontal ? geometry.size.width : geometry.size.height
|
|
108
|
+
let dividerThickness: CGFloat = 4
|
|
109
|
+
let availableSize = totalSize - dividerThickness
|
|
110
|
+
let firstSize = availableSize * data.ratio
|
|
111
|
+
let secondSize = availableSize * (1 - data.ratio)
|
|
112
|
+
|
|
113
|
+
if isHorizontal {
|
|
114
|
+
HStack(spacing: 0) {
|
|
115
|
+
// First child
|
|
116
|
+
SplitView(
|
|
117
|
+
node: data.first,
|
|
118
|
+
focusedLeafID: $focusedLeafID,
|
|
119
|
+
onSplit: onSplit,
|
|
120
|
+
onClose: onClose,
|
|
121
|
+
onRatioChange: onRatioChange
|
|
122
|
+
)
|
|
123
|
+
.frame(width: max(30, firstSize))
|
|
124
|
+
|
|
125
|
+
// Draggable divider
|
|
126
|
+
SplitDivider(isHorizontal: true, isDragging: $dividerDragging)
|
|
127
|
+
.gesture(
|
|
128
|
+
DragGesture()
|
|
129
|
+
.onChanged { value in
|
|
130
|
+
dividerDragging = true
|
|
131
|
+
let newRatio = (firstSize + value.translation.width) / availableSize
|
|
132
|
+
onRatioChange(data.id, newRatio)
|
|
133
|
+
}
|
|
134
|
+
.onEnded { _ in
|
|
135
|
+
dividerDragging = false
|
|
136
|
+
}
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
// Second child
|
|
140
|
+
SplitView(
|
|
141
|
+
node: data.second,
|
|
142
|
+
focusedLeafID: $focusedLeafID,
|
|
143
|
+
onSplit: onSplit,
|
|
144
|
+
onClose: onClose,
|
|
145
|
+
onRatioChange: onRatioChange
|
|
146
|
+
)
|
|
147
|
+
.frame(width: max(30, secondSize))
|
|
148
|
+
}
|
|
149
|
+
} else {
|
|
150
|
+
VStack(spacing: 0) {
|
|
151
|
+
// First child
|
|
152
|
+
SplitView(
|
|
153
|
+
node: data.first,
|
|
154
|
+
focusedLeafID: $focusedLeafID,
|
|
155
|
+
onSplit: onSplit,
|
|
156
|
+
onClose: onClose,
|
|
157
|
+
onRatioChange: onRatioChange
|
|
158
|
+
)
|
|
159
|
+
.frame(height: max(30, firstSize))
|
|
160
|
+
|
|
161
|
+
// Draggable divider
|
|
162
|
+
SplitDivider(isHorizontal: false, isDragging: $dividerDragging)
|
|
163
|
+
.gesture(
|
|
164
|
+
DragGesture()
|
|
165
|
+
.onChanged { value in
|
|
166
|
+
dividerDragging = true
|
|
167
|
+
let newRatio = (firstSize + value.translation.height) / availableSize
|
|
168
|
+
onRatioChange(data.id, newRatio)
|
|
169
|
+
}
|
|
170
|
+
.onEnded { _ in
|
|
171
|
+
dividerDragging = false
|
|
172
|
+
}
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
// Second child
|
|
176
|
+
SplitView(
|
|
177
|
+
node: data.second,
|
|
178
|
+
focusedLeafID: $focusedLeafID,
|
|
179
|
+
onSplit: onSplit,
|
|
180
|
+
onClose: onClose,
|
|
181
|
+
onRatioChange: onRatioChange
|
|
182
|
+
)
|
|
183
|
+
.frame(height: max(30, secondSize))
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/// The visual divider between split panes, with hover and drag states.
|
|
191
|
+
struct SplitDivider: View {
|
|
192
|
+
let isHorizontal: Bool
|
|
193
|
+
@Binding var isDragging: Bool
|
|
194
|
+
@State private var isHovering = false
|
|
195
|
+
|
|
196
|
+
var body: some View {
|
|
197
|
+
Rectangle()
|
|
198
|
+
.fill(fillColor)
|
|
199
|
+
.frame(
|
|
200
|
+
width: isHorizontal ? 4 : nil,
|
|
201
|
+
height: isHorizontal ? nil : 4
|
|
202
|
+
)
|
|
203
|
+
.onHover { hovering in
|
|
204
|
+
isHovering = hovering
|
|
205
|
+
if hovering {
|
|
206
|
+
NSCursor.resizeLeftRight.push()
|
|
207
|
+
} else {
|
|
208
|
+
NSCursor.pop()
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
.onContinuousHover { phase in
|
|
212
|
+
switch phase {
|
|
213
|
+
case .active:
|
|
214
|
+
if isHorizontal {
|
|
215
|
+
NSCursor.resizeLeftRight.set()
|
|
216
|
+
} else {
|
|
217
|
+
NSCursor.resizeUpDown.set()
|
|
218
|
+
}
|
|
219
|
+
case .ended:
|
|
220
|
+
NSCursor.arrow.set()
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
private var fillColor: Color {
|
|
226
|
+
if isDragging {
|
|
227
|
+
return Color.accentColor.opacity(0.6)
|
|
228
|
+
} else if isHovering {
|
|
229
|
+
return Color.accentColor.opacity(0.3)
|
|
230
|
+
} else {
|
|
231
|
+
return Color.primary.opacity(0.08)
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// Terminal panel conforming to PanelProtocol.
|
|
4
|
+
/// Wraps the terminal state for a single pane in the split tree.
|
|
5
|
+
@MainActor
|
|
6
|
+
final class TerminalPanel: PanelProtocol {
|
|
7
|
+
let id: UUID
|
|
8
|
+
let tabIndex: Int
|
|
9
|
+
private(set) var isFocused = false
|
|
10
|
+
|
|
11
|
+
var title: String { "Terminal \(tabIndex)" }
|
|
12
|
+
var canClose: Bool { true }
|
|
13
|
+
|
|
14
|
+
init(id: UUID = UUID(), tabIndex: Int) {
|
|
15
|
+
self.id = id
|
|
16
|
+
self.tabIndex = tabIndex
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
func focus() {
|
|
20
|
+
isFocused = true
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
func blur() {
|
|
24
|
+
isFocused = false
|
|
25
|
+
}
|
|
26
|
+
}
|