- Rename native Rust crates: gsd-engine → forge-engine, gsd-ast → forge-ast, gsd-grep → forge-grep - Update all crate dependencies (Cargo.toml, .rs source) and N-API artifacts - Mass rename log prefix [gsd] → [forge] across 81 files (scripts, src/, extensions, tests) - Rename log prefix "gsd-db:" → "forge-db:" in template literals - Update nix flake: add sf-run-native devShell with Rust toolchain for native addon builds - Update CI workflow artifact names (build-native.yml) - Verify only packages/native/* touched (no upstream pi-* packages renamed) Rationale: Complete gsd-2 → singularity-forge rebrand (2026-04-15). Native addon is sf-run-specific; all gsd-prefixed logging and crate names must align with new identity. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
394 lines
12 KiB
TypeScript
394 lines
12 KiB
TypeScript
"use client"
|
|
|
|
import { useCallback, useEffect, useRef, useState } from "react"
|
|
import { useTheme } from "next-themes"
|
|
import { Loader2, ImagePlus } from "lucide-react"
|
|
import { cn } from "@/lib/utils"
|
|
import { validateImageFile } from "@/lib/image-utils"
|
|
import { buildProjectAbsoluteUrl, buildProjectPath } from "@/lib/project-url"
|
|
import { authFetch, appendAuthParam } from "@/lib/auth"
|
|
import { getXtermOptions, getXtermTheme } from "@/lib/xterm-theme"
|
|
import "@xterm/xterm/css/xterm.css"
|
|
|
|
type XTerminal = import("@xterm/xterm").Terminal
|
|
type XFitAddon = import("@xterm/addon-fit").FitAddon
|
|
|
|
interface MainSessionTerminalProps {
|
|
className?: string
|
|
fontSize?: number
|
|
projectCwd?: string
|
|
}
|
|
|
|
const MIN_INITIAL_ATTACH_WIDTH = 180
|
|
const MIN_INITIAL_ATTACH_HEIGHT = 120
|
|
const MIN_INITIAL_ATTACH_COLS = 20
|
|
const MIN_INITIAL_ATTACH_ROWS = 8
|
|
|
|
function getAttachableTerminalSize(container: HTMLDivElement | null, terminal: XTerminal | null): { cols: number; rows: number } | null {
|
|
if (!container || !terminal) return null
|
|
|
|
const rect = container.getBoundingClientRect()
|
|
if (rect.width < MIN_INITIAL_ATTACH_WIDTH || rect.height < MIN_INITIAL_ATTACH_HEIGHT) {
|
|
return null
|
|
}
|
|
|
|
if (terminal.cols < MIN_INITIAL_ATTACH_COLS || terminal.rows < MIN_INITIAL_ATTACH_ROWS) {
|
|
return null
|
|
}
|
|
|
|
return { cols: terminal.cols, rows: terminal.rows }
|
|
}
|
|
|
|
async function settleTerminalLayout(
|
|
container: HTMLDivElement | null,
|
|
terminal: XTerminal | null,
|
|
fitAddon: XFitAddon | null,
|
|
isDisposed: () => boolean,
|
|
): Promise<{ cols: number; rows: number } | null> {
|
|
if (typeof document !== "undefined" && "fonts" in document) {
|
|
try {
|
|
await Promise.race([
|
|
document.fonts.ready,
|
|
new Promise<void>((resolve) => setTimeout(resolve, 1000)),
|
|
])
|
|
} catch {
|
|
// Ignore font loading failures and fall through to repeated fit attempts.
|
|
}
|
|
}
|
|
|
|
for (let attempt = 0; attempt < 12; attempt++) {
|
|
if (isDisposed()) return null
|
|
|
|
await new Promise<void>((resolve) => {
|
|
requestAnimationFrame(() => resolve())
|
|
})
|
|
|
|
if (isDisposed()) return null
|
|
|
|
try {
|
|
fitAddon?.fit()
|
|
} catch {
|
|
// Hidden or detached.
|
|
}
|
|
|
|
const size = getAttachableTerminalSize(container, terminal)
|
|
if (size) {
|
|
return size
|
|
}
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
}
|
|
|
|
return getAttachableTerminalSize(container, terminal)
|
|
}
|
|
|
|
export function MainSessionTerminal({ className, fontSize, projectCwd }: MainSessionTerminalProps) {
|
|
const { resolvedTheme } = useTheme()
|
|
const isDark = resolvedTheme !== "light"
|
|
const wrapperRef = useRef<HTMLDivElement>(null)
|
|
const containerRef = useRef<HTMLDivElement>(null)
|
|
const termRef = useRef<XTerminal | null>(null)
|
|
const fitAddonRef = useRef<XFitAddon | null>(null)
|
|
const eventSourceRef = useRef<EventSource | null>(null)
|
|
const resizeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
const inputQueueRef = useRef<string[]>([])
|
|
const flushingRef = useRef(false)
|
|
const [connectionState, setConnectionState] = useState<"connecting" | "connected" | "error">("connecting")
|
|
const [hasOutput, setHasOutput] = useState(false)
|
|
const [isDragOver, setIsDragOver] = useState(false)
|
|
|
|
const flushInputQueue = useCallback(async () => {
|
|
if (flushingRef.current) return
|
|
flushingRef.current = true
|
|
while (inputQueueRef.current.length > 0) {
|
|
const data = inputQueueRef.current.shift()!
|
|
try {
|
|
await authFetch(buildProjectPath("/api/bridge-terminal/input", projectCwd), {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ data }),
|
|
})
|
|
} catch {
|
|
inputQueueRef.current.unshift(data)
|
|
break
|
|
}
|
|
}
|
|
flushingRef.current = false
|
|
}, [projectCwd])
|
|
|
|
const sendInput = useCallback((data: string) => {
|
|
inputQueueRef.current.push(data)
|
|
void flushInputQueue()
|
|
}, [flushInputQueue])
|
|
|
|
const sendResize = useCallback((cols: number, rows: number) => {
|
|
if (resizeTimeoutRef.current) clearTimeout(resizeTimeoutRef.current)
|
|
resizeTimeoutRef.current = setTimeout(() => {
|
|
void authFetch(buildProjectPath("/api/bridge-terminal/resize", projectCwd), {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ cols, rows }),
|
|
})
|
|
}, 75)
|
|
}, [projectCwd])
|
|
|
|
useEffect(() => {
|
|
if (termRef.current) {
|
|
termRef.current.options.theme = getXtermTheme(isDark)
|
|
}
|
|
}, [isDark])
|
|
|
|
useEffect(() => {
|
|
if (!termRef.current) return
|
|
termRef.current.options.fontSize = fontSize ?? 13
|
|
try {
|
|
fitAddonRef.current?.fit()
|
|
sendResize(termRef.current.cols, termRef.current.rows)
|
|
} catch {
|
|
// Hidden or not mounted yet.
|
|
}
|
|
}, [fontSize, sendResize])
|
|
|
|
useEffect(() => {
|
|
if (!containerRef.current) return
|
|
|
|
let disposed = false
|
|
let resizeObserver: ResizeObserver | null = null
|
|
let terminal: XTerminal | null = null
|
|
let fitAddon: XFitAddon | null = null
|
|
|
|
const init = async () => {
|
|
const [{ Terminal }, { FitAddon }] = await Promise.all([
|
|
import("@xterm/xterm"),
|
|
import("@xterm/addon-fit"),
|
|
])
|
|
|
|
if (disposed) return
|
|
|
|
terminal = new Terminal(getXtermOptions(isDark, fontSize))
|
|
fitAddon = new FitAddon()
|
|
terminal.loadAddon(fitAddon)
|
|
terminal.open(containerRef.current!)
|
|
|
|
termRef.current = terminal
|
|
fitAddonRef.current = fitAddon
|
|
|
|
const initialSize = await settleTerminalLayout(containerRef.current, terminal, fitAddon, () => disposed)
|
|
if (disposed) return
|
|
|
|
terminal.onData((data) => {
|
|
sendInput(data)
|
|
})
|
|
terminal.onBinary((data) => {
|
|
sendInput(data)
|
|
})
|
|
|
|
const connectStream = (preferredSize: { cols: number; rows: number } | null) => {
|
|
const streamUrl = buildProjectAbsoluteUrl(
|
|
"/api/bridge-terminal/stream",
|
|
window.location.origin,
|
|
projectCwd,
|
|
)
|
|
if (preferredSize) {
|
|
streamUrl.searchParams.set("cols", String(preferredSize.cols))
|
|
streamUrl.searchParams.set("rows", String(preferredSize.rows))
|
|
}
|
|
|
|
const es = new EventSource(appendAuthParam(streamUrl.toString()))
|
|
eventSourceRef.current = es
|
|
setConnectionState((current) => (current === "connected" ? current : "connecting"))
|
|
|
|
es.onmessage = (event) => {
|
|
try {
|
|
const message = JSON.parse(event.data) as { type: string; data?: string }
|
|
if (message.type === "connected") {
|
|
setConnectionState("connected")
|
|
void settleTerminalLayout(containerRef.current, termRef.current, fitAddonRef.current, () => disposed).then((size) => {
|
|
if (!size) return
|
|
sendResize(size.cols, size.rows)
|
|
})
|
|
return
|
|
}
|
|
|
|
if (message.type === "output" && typeof message.data === "string") {
|
|
termRef.current?.write(message.data)
|
|
setHasOutput(true)
|
|
}
|
|
} catch {
|
|
setConnectionState("error")
|
|
}
|
|
}
|
|
|
|
es.onerror = () => {
|
|
setConnectionState("error")
|
|
}
|
|
}
|
|
|
|
connectStream(initialSize)
|
|
|
|
resizeObserver = new ResizeObserver(() => {
|
|
if (disposed) return
|
|
try {
|
|
fitAddon?.fit()
|
|
if (terminal) {
|
|
sendResize(terminal.cols, terminal.rows)
|
|
}
|
|
} catch {
|
|
// Hidden or detached.
|
|
}
|
|
})
|
|
resizeObserver.observe(containerRef.current!)
|
|
}
|
|
|
|
void init()
|
|
|
|
return () => {
|
|
disposed = true
|
|
if (resizeTimeoutRef.current) clearTimeout(resizeTimeoutRef.current)
|
|
eventSourceRef.current?.close()
|
|
eventSourceRef.current = null
|
|
resizeObserver?.disconnect()
|
|
terminal?.dispose()
|
|
termRef.current = null
|
|
fitAddonRef.current = null
|
|
}
|
|
}, [fontSize, isDark, projectCwd, sendInput, sendResize])
|
|
|
|
const handleClick = useCallback(() => {
|
|
termRef.current?.focus()
|
|
}, [])
|
|
|
|
// ── Shift+Enter → newline (native DOM, capture phase) ────────────────────
|
|
// xterm.js sends \r for both Enter and Shift+Enter. The pi TUI editor
|
|
// recognizes \n (LF) as "insert newline". Capture-phase keydown intercepts
|
|
// before xterm's internal textarea processes the event.
|
|
useEffect(() => {
|
|
const el = wrapperRef.current
|
|
if (!el) return
|
|
|
|
const onKeyDown = (e: KeyboardEvent) => {
|
|
if (e.key === "Enter" && e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey) {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
sendInput("\n")
|
|
}
|
|
}
|
|
|
|
el.addEventListener("keydown", onKeyDown, true)
|
|
return () => el.removeEventListener("keydown", onKeyDown, true)
|
|
}, [sendInput])
|
|
|
|
// ── Drag-and-drop image upload (native DOM, capture phase) ──────────────
|
|
// React synthetic events don't reliably fire through xterm's internal DOM.
|
|
// Native capture-phase listeners intercept before xterm can swallow them —
|
|
// same pattern used for paste in ShellTerminal.
|
|
|
|
useEffect(() => {
|
|
const el = wrapperRef.current
|
|
if (!el) return
|
|
|
|
let counter = 0
|
|
|
|
const onDragEnter = (e: DragEvent) => {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
counter += 1
|
|
if (counter === 1) setIsDragOver(true)
|
|
}
|
|
|
|
const onDragOver = (e: DragEvent) => {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
}
|
|
|
|
const onDragLeave = (e: DragEvent) => {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
counter -= 1
|
|
if (counter <= 0) {
|
|
counter = 0
|
|
setIsDragOver(false)
|
|
}
|
|
}
|
|
|
|
const onDrop = (e: DragEvent) => {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
counter = 0
|
|
setIsDragOver(false)
|
|
|
|
const files = Array.from(e.dataTransfer?.files ?? [])
|
|
const imageFile = files.find((f) => f.type.startsWith("image/"))
|
|
if (!imageFile) return
|
|
|
|
const validation = validateImageFile(imageFile)
|
|
if (!validation.valid) {
|
|
console.warn("[main-terminal-upload] validation failed:", validation.error)
|
|
return
|
|
}
|
|
|
|
const formData = new FormData()
|
|
formData.append("file", imageFile)
|
|
|
|
void (async () => {
|
|
try {
|
|
const res = await authFetch(buildProjectPath("/api/terminal/upload", projectCwd), {
|
|
method: "POST",
|
|
body: formData,
|
|
})
|
|
const data = (await res.json()) as { ok?: boolean; path?: string; error?: string }
|
|
if (!res.ok || !data.path) {
|
|
console.error("[main-terminal-upload] upload failed:", data.error ?? `HTTP ${res.status}`)
|
|
return
|
|
}
|
|
console.log("[main-terminal-upload] injecting path:", data.path)
|
|
sendInput(`@${data.path} `)
|
|
} catch (err) {
|
|
console.error("[main-terminal-upload] upload request failed:", err)
|
|
}
|
|
})()
|
|
}
|
|
|
|
el.addEventListener("dragenter", onDragEnter, true)
|
|
el.addEventListener("dragover", onDragOver, true)
|
|
el.addEventListener("dragleave", onDragLeave, true)
|
|
el.addEventListener("drop", onDrop, true)
|
|
return () => {
|
|
el.removeEventListener("dragenter", onDragEnter, true)
|
|
el.removeEventListener("dragover", onDragOver, true)
|
|
el.removeEventListener("dragleave", onDragLeave, true)
|
|
el.removeEventListener("drop", onDrop, true)
|
|
}
|
|
}, [projectCwd, sendInput])
|
|
|
|
useEffect(() => {
|
|
const timer = setTimeout(() => termRef.current?.focus(), 80)
|
|
return () => clearTimeout(timer)
|
|
}, [])
|
|
|
|
return (
|
|
<div
|
|
ref={wrapperRef}
|
|
className={cn("relative h-full w-full bg-terminal", className)}
|
|
onClick={handleClick}
|
|
data-testid="main-session-native-terminal"
|
|
>
|
|
{!hasOutput && (
|
|
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-3 bg-terminal">
|
|
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
|
<span className="text-xs text-muted-foreground">
|
|
{connectionState === "error" ? "Reconnecting main session terminal…" : "Connecting to main session…"}
|
|
</span>
|
|
</div>
|
|
)}
|
|
{/* Drop overlay */}
|
|
{isDragOver && (
|
|
<div className="absolute inset-0 z-20 flex flex-col items-center justify-center gap-2 bg-background backdrop-blur-sm border-2 border-dashed border-primary rounded-md pointer-events-none">
|
|
<ImagePlus className="h-8 w-8 text-primary" />
|
|
<span className="text-sm font-medium text-primary">Drop image here</span>
|
|
</div>
|
|
)}
|
|
<div ref={containerRef} className="h-full w-full" style={{ padding: "8px 4px 4px 8px" }} />
|
|
</div>
|
|
)
|
|
}
|