@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
package/src/push.ts
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Web Push notification module for Remux.
|
|
3
|
+
* VAPID key management (generate on first run, persist in SQLite settings),
|
|
4
|
+
* push subscription helpers, and broadcast-to-all utility.
|
|
5
|
+
*
|
|
6
|
+
* Uses the web-push library (MIT) for VAPID auth + notification delivery.
|
|
7
|
+
* https://github.com/web-push-libs/web-push
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import webpush from "web-push";
|
|
11
|
+
import {
|
|
12
|
+
getSetting,
|
|
13
|
+
setSetting,
|
|
14
|
+
getPushSubscription,
|
|
15
|
+
listPushSubscriptions,
|
|
16
|
+
removePushSubscription,
|
|
17
|
+
} from "./store.js";
|
|
18
|
+
|
|
19
|
+
// ── VAPID Keys ──────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
const VAPID_PUBLIC_KEY = "vapid_public_key";
|
|
22
|
+
const VAPID_PRIVATE_KEY = "vapid_private_key";
|
|
23
|
+
const VAPID_SUBJECT = "mailto:remux@localhost";
|
|
24
|
+
|
|
25
|
+
let vapidPublicKey: string | null = null;
|
|
26
|
+
let vapidReady = false;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Initialize Web Push: load existing VAPID keys from SQLite settings,
|
|
30
|
+
* or generate new ones on first run.
|
|
31
|
+
*/
|
|
32
|
+
export function initPush(): void {
|
|
33
|
+
let pubKey = getSetting(VAPID_PUBLIC_KEY);
|
|
34
|
+
let privKey = getSetting(VAPID_PRIVATE_KEY);
|
|
35
|
+
|
|
36
|
+
if (!pubKey || !privKey) {
|
|
37
|
+
// First run: generate VAPID key pair
|
|
38
|
+
const keys = webpush.generateVAPIDKeys();
|
|
39
|
+
pubKey = keys.publicKey;
|
|
40
|
+
privKey = keys.privateKey;
|
|
41
|
+
setSetting(VAPID_PUBLIC_KEY, pubKey);
|
|
42
|
+
setSetting(VAPID_PRIVATE_KEY, privKey);
|
|
43
|
+
console.log("[push] generated new VAPID keys");
|
|
44
|
+
} else {
|
|
45
|
+
console.log("[push] loaded VAPID keys from store");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
webpush.setVapidDetails(VAPID_SUBJECT, pubKey, privKey);
|
|
49
|
+
vapidPublicKey = pubKey;
|
|
50
|
+
vapidReady = true;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Return the public VAPID key for client-side subscription.
|
|
55
|
+
* Returns null if push is not initialized.
|
|
56
|
+
*/
|
|
57
|
+
export function getVapidPublicKey(): string | null {
|
|
58
|
+
return vapidPublicKey;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Check if push notifications are initialized and ready.
|
|
63
|
+
*/
|
|
64
|
+
export function isPushReady(): boolean {
|
|
65
|
+
return vapidReady;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Send a push notification to a specific device by deviceId.
|
|
70
|
+
* Returns true if sent successfully, false otherwise.
|
|
71
|
+
* Automatically removes stale subscriptions on 404/410.
|
|
72
|
+
*/
|
|
73
|
+
export async function sendPushNotification(
|
|
74
|
+
deviceId: string,
|
|
75
|
+
title: string,
|
|
76
|
+
body: string,
|
|
77
|
+
): Promise<boolean> {
|
|
78
|
+
if (!vapidReady) return false;
|
|
79
|
+
|
|
80
|
+
const sub = getPushSubscription(deviceId);
|
|
81
|
+
if (!sub) return false;
|
|
82
|
+
|
|
83
|
+
const payload = JSON.stringify({ title, body, tag: "notification" });
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
await webpush.sendNotification(
|
|
87
|
+
{
|
|
88
|
+
endpoint: sub.endpoint,
|
|
89
|
+
keys: { p256dh: sub.p256dh, auth: sub.auth },
|
|
90
|
+
},
|
|
91
|
+
payload,
|
|
92
|
+
);
|
|
93
|
+
return true;
|
|
94
|
+
} catch (err: any) {
|
|
95
|
+
// 404 or 410 means subscription is no longer valid
|
|
96
|
+
if (err.statusCode === 404 || err.statusCode === 410) {
|
|
97
|
+
removePushSubscription(deviceId);
|
|
98
|
+
console.log(
|
|
99
|
+
`[push] removed stale subscription for device ${deviceId} (${err.statusCode})`,
|
|
100
|
+
);
|
|
101
|
+
} else {
|
|
102
|
+
console.error(`[push] failed to send to device ${deviceId}:`, err.message);
|
|
103
|
+
}
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Broadcast a push notification to all subscribed devices,
|
|
110
|
+
* optionally excluding specific device IDs (e.g., currently connected ones).
|
|
111
|
+
*/
|
|
112
|
+
export async function broadcastPush(
|
|
113
|
+
title: string,
|
|
114
|
+
body: string,
|
|
115
|
+
excludeDeviceIds: string[] = [],
|
|
116
|
+
): Promise<void> {
|
|
117
|
+
if (!vapidReady) return;
|
|
118
|
+
|
|
119
|
+
const subs = listPushSubscriptions();
|
|
120
|
+
const excludeSet = new Set(excludeDeviceIds);
|
|
121
|
+
|
|
122
|
+
const promises = subs
|
|
123
|
+
.filter((s) => !excludeSet.has(s.deviceId))
|
|
124
|
+
.map((s) => sendPushNotification(s.deviceId, title, body));
|
|
125
|
+
|
|
126
|
+
await Promise.allSettled(promises);
|
|
127
|
+
}
|
package/src/renderers.ts
ADDED
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lightweight content renderers for workspace artifacts.
|
|
3
|
+
* Converts diff, markdown, and ANSI text to styled HTML.
|
|
4
|
+
* No external dependencies — pure string/regex parsing.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
// ── Helpers ────────────────────────────────────────────────────
|
|
8
|
+
|
|
9
|
+
function escapeHtml(s: string): string {
|
|
10
|
+
return s
|
|
11
|
+
.replace(/&/g, "&")
|
|
12
|
+
.replace(/</g, "<")
|
|
13
|
+
.replace(/>/g, ">")
|
|
14
|
+
.replace(/"/g, """);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// ── Content-type detection ─────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Detect content type from raw text.
|
|
21
|
+
* Priority: diff > ansi > markdown > plain.
|
|
22
|
+
*/
|
|
23
|
+
export function detectContentType(
|
|
24
|
+
text: string,
|
|
25
|
+
): "diff" | "markdown" | "ansi" | "plain" {
|
|
26
|
+
if (!text) return "plain";
|
|
27
|
+
|
|
28
|
+
// Unified diff: "diff --git" header or "---"/"+++" pair with @@ hunks
|
|
29
|
+
if (/^diff --git /m.test(text)) return "diff";
|
|
30
|
+
if (
|
|
31
|
+
/^--- .+\n\+\+\+ .+\n@@/m.test(text)
|
|
32
|
+
)
|
|
33
|
+
return "diff";
|
|
34
|
+
|
|
35
|
+
// ANSI: contains SGR escape sequences
|
|
36
|
+
if (/\x1b\[[\d;]*m/.test(text)) return "ansi";
|
|
37
|
+
|
|
38
|
+
// Markdown heuristics: headers, bold, code blocks, links
|
|
39
|
+
if (/^#{1,6}\s+\S/m.test(text)) return "markdown";
|
|
40
|
+
if (/\*\*[^*]+\*\*/.test(text)) return "markdown";
|
|
41
|
+
if (/```[\s\S]*?```/.test(text)) return "markdown";
|
|
42
|
+
if (/\[[^\]]+\]\([^)]+\)/.test(text)) return "markdown";
|
|
43
|
+
|
|
44
|
+
return "plain";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ── Diff renderer ──────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Render unified diff text to HTML.
|
|
51
|
+
* Produces a <div class="diff-container"> with per-line markup.
|
|
52
|
+
*/
|
|
53
|
+
export function renderDiff(diffText: string): string {
|
|
54
|
+
const lines = diffText.split("\n");
|
|
55
|
+
const out: string[] = ['<div class="diff-container">'];
|
|
56
|
+
|
|
57
|
+
// Track line numbers for old/new sides
|
|
58
|
+
let oldLine = 0;
|
|
59
|
+
let newLine = 0;
|
|
60
|
+
|
|
61
|
+
for (const raw of lines) {
|
|
62
|
+
const escaped = escapeHtml(raw);
|
|
63
|
+
|
|
64
|
+
if (
|
|
65
|
+
raw.startsWith("diff --git") ||
|
|
66
|
+
raw.startsWith("index ") ||
|
|
67
|
+
raw.startsWith("---") ||
|
|
68
|
+
raw.startsWith("+++")
|
|
69
|
+
) {
|
|
70
|
+
out.push(
|
|
71
|
+
`<div class="diff-header">${escaped}</div>`,
|
|
72
|
+
);
|
|
73
|
+
} else if (raw.startsWith("@@")) {
|
|
74
|
+
// Parse hunk header for line numbers
|
|
75
|
+
const m = raw.match(/@@ -(\d+)/);
|
|
76
|
+
if (m) {
|
|
77
|
+
oldLine = parseInt(m[1], 10);
|
|
78
|
+
const m2 = raw.match(/@@ -\d+(?:,\d+)? \+(\d+)/);
|
|
79
|
+
newLine = m2 ? parseInt(m2[1], 10) : oldLine;
|
|
80
|
+
}
|
|
81
|
+
out.push(
|
|
82
|
+
`<div class="diff-hunk">${escaped}</div>`,
|
|
83
|
+
);
|
|
84
|
+
} else if (raw.startsWith("+")) {
|
|
85
|
+
out.push(
|
|
86
|
+
`<div class="diff-add"><span class="diff-line-num">${newLine}</span>${escaped}</div>`,
|
|
87
|
+
);
|
|
88
|
+
newLine++;
|
|
89
|
+
} else if (raw.startsWith("-")) {
|
|
90
|
+
out.push(
|
|
91
|
+
`<div class="diff-del"><span class="diff-line-num">${oldLine}</span>${escaped}</div>`,
|
|
92
|
+
);
|
|
93
|
+
oldLine++;
|
|
94
|
+
} else {
|
|
95
|
+
// Context line (starts with space or is empty)
|
|
96
|
+
out.push(
|
|
97
|
+
`<div class="diff-ctx"><span class="diff-line-num">${oldLine}</span>${escaped}</div>`,
|
|
98
|
+
);
|
|
99
|
+
oldLine++;
|
|
100
|
+
newLine++;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
out.push("</div>");
|
|
105
|
+
return out.join("\n");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ── Markdown renderer ──────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Lightweight markdown-to-HTML converter.
|
|
112
|
+
* Covers headers, bold, italic, inline code, code blocks,
|
|
113
|
+
* lists, links, blockquotes, horizontal rules, paragraphs.
|
|
114
|
+
*/
|
|
115
|
+
export function renderMarkdown(md: string): string {
|
|
116
|
+
const lines = md.split("\n");
|
|
117
|
+
const out: string[] = ['<div class="rendered-md">'];
|
|
118
|
+
|
|
119
|
+
let inCodeBlock = false;
|
|
120
|
+
let codeLang = "";
|
|
121
|
+
let codeLines: string[] = [];
|
|
122
|
+
let inList: "ul" | "ol" | null = null;
|
|
123
|
+
let inBlockquote = false;
|
|
124
|
+
let paragraph: string[] = [];
|
|
125
|
+
|
|
126
|
+
function flushParagraph(): void {
|
|
127
|
+
if (paragraph.length > 0) {
|
|
128
|
+
const text = paragraph.join(" ");
|
|
129
|
+
out.push(`<p>${inlineFormat(text)}</p>`);
|
|
130
|
+
paragraph = [];
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function flushList(): void {
|
|
135
|
+
if (inList) {
|
|
136
|
+
out.push(`</${inList}>`);
|
|
137
|
+
inList = null;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function flushBlockquote(): void {
|
|
142
|
+
if (inBlockquote) {
|
|
143
|
+
out.push("</blockquote>");
|
|
144
|
+
inBlockquote = false;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Apply inline formatting to raw (unescaped) text.
|
|
150
|
+
* Handles: inline code, bold, italic, links.
|
|
151
|
+
* Escapes HTML at leaf level to avoid double-escaping.
|
|
152
|
+
*/
|
|
153
|
+
function inlineFormat(raw: string): string {
|
|
154
|
+
// Split on inline code spans first to protect their content
|
|
155
|
+
const parts = raw.split(/(`[^`]+`)/);
|
|
156
|
+
return parts
|
|
157
|
+
.map((part) => {
|
|
158
|
+
if (part.startsWith("`") && part.endsWith("`")) {
|
|
159
|
+
// Inline code — escape content, wrap in <code>
|
|
160
|
+
const code = part.slice(1, -1);
|
|
161
|
+
return `<code>${escapeHtml(code)}</code>`;
|
|
162
|
+
}
|
|
163
|
+
// Non-code text: escape, then apply bold/italic/links
|
|
164
|
+
let escaped = escapeHtml(part);
|
|
165
|
+
escaped = escaped.replace(
|
|
166
|
+
/\*\*([^*]+)\*\*/g,
|
|
167
|
+
"<strong>$1</strong>",
|
|
168
|
+
);
|
|
169
|
+
escaped = escaped.replace(
|
|
170
|
+
/(?<!\*)\*([^*]+)\*(?!\*)/g,
|
|
171
|
+
"<em>$1</em>",
|
|
172
|
+
);
|
|
173
|
+
escaped = escaped.replace(
|
|
174
|
+
/\[([^\]]+)\]\(([^)]+)\)/g,
|
|
175
|
+
'<a href="$2" target="_blank" rel="noopener">$1</a>',
|
|
176
|
+
);
|
|
177
|
+
return escaped;
|
|
178
|
+
})
|
|
179
|
+
.join("");
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
for (let i = 0; i < lines.length; i++) {
|
|
183
|
+
const line = lines[i];
|
|
184
|
+
|
|
185
|
+
// Code block toggle
|
|
186
|
+
if (line.startsWith("```")) {
|
|
187
|
+
if (!inCodeBlock) {
|
|
188
|
+
flushParagraph();
|
|
189
|
+
flushList();
|
|
190
|
+
flushBlockquote();
|
|
191
|
+
inCodeBlock = true;
|
|
192
|
+
codeLang = line.slice(3).trim();
|
|
193
|
+
codeLines = [];
|
|
194
|
+
continue;
|
|
195
|
+
} else {
|
|
196
|
+
// End code block
|
|
197
|
+
const langAttr = codeLang
|
|
198
|
+
? ` class="language-${escapeHtml(codeLang)}"`
|
|
199
|
+
: "";
|
|
200
|
+
out.push(
|
|
201
|
+
`<pre><code${langAttr}>${escapeHtml(codeLines.join("\n"))}</code></pre>`,
|
|
202
|
+
);
|
|
203
|
+
inCodeBlock = false;
|
|
204
|
+
codeLang = "";
|
|
205
|
+
codeLines = [];
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (inCodeBlock) {
|
|
211
|
+
codeLines.push(line);
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Horizontal rule
|
|
216
|
+
if (/^---+$/.test(line.trim())) {
|
|
217
|
+
flushParagraph();
|
|
218
|
+
flushList();
|
|
219
|
+
flushBlockquote();
|
|
220
|
+
out.push("<hr>");
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Headers
|
|
225
|
+
const headerMatch = line.match(/^(#{1,6})\s+(.*)/);
|
|
226
|
+
if (headerMatch) {
|
|
227
|
+
flushParagraph();
|
|
228
|
+
flushList();
|
|
229
|
+
flushBlockquote();
|
|
230
|
+
const level = headerMatch[1].length;
|
|
231
|
+
const text = escapeHtml(headerMatch[2]);
|
|
232
|
+
out.push(`<h${level}>${text}</h${level}>`);
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Blockquotes
|
|
237
|
+
if (line.startsWith("> ")) {
|
|
238
|
+
flushParagraph();
|
|
239
|
+
flushList();
|
|
240
|
+
if (!inBlockquote) {
|
|
241
|
+
inBlockquote = true;
|
|
242
|
+
out.push("<blockquote>");
|
|
243
|
+
}
|
|
244
|
+
out.push(inlineFormat(line.slice(2)));
|
|
245
|
+
continue;
|
|
246
|
+
} else if (inBlockquote) {
|
|
247
|
+
flushBlockquote();
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Unordered list
|
|
251
|
+
if (/^[-*]\s+/.test(line)) {
|
|
252
|
+
flushParagraph();
|
|
253
|
+
flushBlockquote();
|
|
254
|
+
if (inList !== "ul") {
|
|
255
|
+
flushList();
|
|
256
|
+
inList = "ul";
|
|
257
|
+
out.push("<ul>");
|
|
258
|
+
}
|
|
259
|
+
const text = line.replace(/^[-*]\s+/, "");
|
|
260
|
+
out.push(`<li>${inlineFormat(text)}</li>`);
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Ordered list
|
|
265
|
+
if (/^\d+\.\s+/.test(line)) {
|
|
266
|
+
flushParagraph();
|
|
267
|
+
flushBlockquote();
|
|
268
|
+
if (inList !== "ol") {
|
|
269
|
+
flushList();
|
|
270
|
+
inList = "ol";
|
|
271
|
+
out.push("<ol>");
|
|
272
|
+
}
|
|
273
|
+
const text = line.replace(/^\d+\.\s+/, "");
|
|
274
|
+
out.push(`<li>${inlineFormat(text)}</li>`);
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// End of list on non-list line
|
|
279
|
+
if (inList && !/^\s*$/.test(line)) {
|
|
280
|
+
flushList();
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Empty line = paragraph break
|
|
284
|
+
if (line.trim() === "") {
|
|
285
|
+
flushParagraph();
|
|
286
|
+
flushList();
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Regular text -> accumulate into paragraph (raw, inlineFormat handles escaping)
|
|
291
|
+
paragraph.push(line);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Flush remaining state
|
|
295
|
+
if (inCodeBlock) {
|
|
296
|
+
const langAttr = codeLang
|
|
297
|
+
? ` class="language-${escapeHtml(codeLang)}"`
|
|
298
|
+
: "";
|
|
299
|
+
out.push(
|
|
300
|
+
`<pre><code${langAttr}>${escapeHtml(codeLines.join("\n"))}</code></pre>`,
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
flushParagraph();
|
|
304
|
+
flushList();
|
|
305
|
+
flushBlockquote();
|
|
306
|
+
|
|
307
|
+
out.push("</div>");
|
|
308
|
+
return out.join("\n");
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// ── ANSI renderer ──────────────────────────────────────────────
|
|
312
|
+
|
|
313
|
+
// Standard 8 ANSI colors (normal intensity)
|
|
314
|
+
const ANSI_COLORS: Record<number, string> = {
|
|
315
|
+
30: "#000000",
|
|
316
|
+
31: "#cc0000",
|
|
317
|
+
32: "#00cc00",
|
|
318
|
+
33: "#cccc00",
|
|
319
|
+
34: "#0000cc",
|
|
320
|
+
35: "#cc00cc",
|
|
321
|
+
36: "#00cccc",
|
|
322
|
+
37: "#cccccc",
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
// Bright ANSI colors
|
|
326
|
+
const ANSI_BRIGHT_COLORS: Record<number, string> = {
|
|
327
|
+
90: "#555555",
|
|
328
|
+
91: "#ff5555",
|
|
329
|
+
92: "#55ff55",
|
|
330
|
+
93: "#ffff55",
|
|
331
|
+
94: "#5555ff",
|
|
332
|
+
95: "#ff55ff",
|
|
333
|
+
96: "#55ffff",
|
|
334
|
+
97: "#ffffff",
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
interface AnsiState {
|
|
338
|
+
bold: boolean;
|
|
339
|
+
dim: boolean;
|
|
340
|
+
italic: boolean;
|
|
341
|
+
underline: boolean;
|
|
342
|
+
fgColor: string | null;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Convert ANSI escape sequences to HTML spans.
|
|
347
|
+
* Supports: basic 8 colors, bright colors, bold, dim, italic, underline, reset.
|
|
348
|
+
* Strips non-SGR escape sequences (cursor movement, erase, etc).
|
|
349
|
+
*/
|
|
350
|
+
export function renderAnsi(ansiText: string): string {
|
|
351
|
+
// First, strip all non-SGR CSI sequences (anything that's not \x1b[...m)
|
|
352
|
+
// Also strip OSC sequences (\x1b]...\x07)
|
|
353
|
+
let cleaned = ansiText.replace(/\x1b\][^\x07]*\x07/g, "");
|
|
354
|
+
// Strip non-SGR CSI sequences: \x1b[ followed by params and a final byte that isn't 'm'
|
|
355
|
+
cleaned = cleaned.replace(/\x1b\[[\d;]*[A-LN-Za-ln-z]/g, "");
|
|
356
|
+
|
|
357
|
+
// Check if there are any SGR sequences at all
|
|
358
|
+
if (!/\x1b\[[\d;]*m/.test(cleaned)) {
|
|
359
|
+
return escapeHtml(cleaned);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const state: AnsiState = {
|
|
363
|
+
bold: false,
|
|
364
|
+
dim: false,
|
|
365
|
+
italic: false,
|
|
366
|
+
underline: false,
|
|
367
|
+
fgColor: null,
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
const parts: string[] = [];
|
|
371
|
+
let spanOpen = false;
|
|
372
|
+
|
|
373
|
+
// Split on SGR sequences
|
|
374
|
+
const re = /\x1b\[([\d;]*)m/g;
|
|
375
|
+
let lastIndex = 0;
|
|
376
|
+
let match: RegExpExecArray | null;
|
|
377
|
+
|
|
378
|
+
while ((match = re.exec(cleaned)) !== null) {
|
|
379
|
+
// Emit text before this sequence
|
|
380
|
+
const text = cleaned.slice(lastIndex, match.index);
|
|
381
|
+
if (text) {
|
|
382
|
+
parts.push(escapeHtml(text));
|
|
383
|
+
}
|
|
384
|
+
lastIndex = match.index + match[0].length;
|
|
385
|
+
|
|
386
|
+
// Parse SGR codes
|
|
387
|
+
const codes = match[1]
|
|
388
|
+
? match[1].split(";").map(Number)
|
|
389
|
+
: [0];
|
|
390
|
+
|
|
391
|
+
for (const code of codes) {
|
|
392
|
+
if (code === 0) {
|
|
393
|
+
// Reset
|
|
394
|
+
if (spanOpen) {
|
|
395
|
+
parts.push("</span>");
|
|
396
|
+
spanOpen = false;
|
|
397
|
+
}
|
|
398
|
+
state.bold = false;
|
|
399
|
+
state.dim = false;
|
|
400
|
+
state.italic = false;
|
|
401
|
+
state.underline = false;
|
|
402
|
+
state.fgColor = null;
|
|
403
|
+
} else if (code === 1) {
|
|
404
|
+
state.bold = true;
|
|
405
|
+
} else if (code === 2) {
|
|
406
|
+
state.dim = true;
|
|
407
|
+
} else if (code === 3) {
|
|
408
|
+
state.italic = true;
|
|
409
|
+
} else if (code === 4) {
|
|
410
|
+
state.underline = true;
|
|
411
|
+
} else if (code >= 30 && code <= 37) {
|
|
412
|
+
state.fgColor = ANSI_COLORS[code] || null;
|
|
413
|
+
} else if (code >= 90 && code <= 97) {
|
|
414
|
+
state.fgColor = ANSI_BRIGHT_COLORS[code] || null;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// Close previous span and open new one with current state
|
|
419
|
+
if (spanOpen) {
|
|
420
|
+
parts.push("</span>");
|
|
421
|
+
spanOpen = false;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const classes: string[] = [];
|
|
425
|
+
const styles: string[] = [];
|
|
426
|
+
|
|
427
|
+
if (state.bold) classes.push("ansi-bold");
|
|
428
|
+
if (state.dim) classes.push("ansi-dim");
|
|
429
|
+
if (state.italic) classes.push("ansi-italic");
|
|
430
|
+
if (state.underline) classes.push("ansi-underline");
|
|
431
|
+
if (state.fgColor) styles.push(`color:${state.fgColor}`);
|
|
432
|
+
|
|
433
|
+
if (classes.length > 0 || styles.length > 0) {
|
|
434
|
+
let tag = "<span";
|
|
435
|
+
if (classes.length > 0) tag += ` class="${classes.join(" ")}"`;
|
|
436
|
+
if (styles.length > 0) tag += ` style="${styles.join(";")}"`;
|
|
437
|
+
tag += ">";
|
|
438
|
+
parts.push(tag);
|
|
439
|
+
spanOpen = true;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// Remaining text after last sequence
|
|
444
|
+
const remainder = cleaned.slice(lastIndex);
|
|
445
|
+
if (remainder) {
|
|
446
|
+
parts.push(escapeHtml(remainder));
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// Close any open span
|
|
450
|
+
if (spanOpen) {
|
|
451
|
+
parts.push("</span>");
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
return parts.join("");
|
|
455
|
+
}
|