Bump version to 1.3.32
This commit is contained in:
@@ -74,9 +74,7 @@ func (t *Tracker) Feed(data []byte) error {
|
|||||||
if len(t.screen.Dirty) > 0 {
|
if len(t.screen.Dirty) > 0 {
|
||||||
t.changeCounter++
|
t.changeCounter++
|
||||||
// Clear dirty set so subsequent feeds detect new changes
|
// Clear dirty set so subsequent feeds detect new changes
|
||||||
for k := range t.screen.Dirty {
|
clear(t.screen.Dirty)
|
||||||
delete(t.screen.Dirty, k)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -108,9 +106,6 @@ func (t *Tracker) Snapshot() Snapshot {
|
|||||||
for col := 0; col < t.screen.Columns; col++ {
|
for col := 0; col < t.screen.Columns; col++ {
|
||||||
raw := t.screen.Buffer[row][col]
|
raw := t.screen.Buffer[row][col]
|
||||||
data := raw.Data
|
data := raw.Data
|
||||||
if data == "" {
|
|
||||||
data = " "
|
|
||||||
}
|
|
||||||
line[col] = Cell{
|
line[col] = Cell{
|
||||||
Data: data,
|
Data: data,
|
||||||
FG: colorToString(raw.Attr.Fg),
|
FG: colorToString(raw.Attr.Fg),
|
||||||
|
|||||||
+55
-11
@@ -40,30 +40,74 @@ func RenderTerminalPNG(
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
bgColor := mustParseHexColor(background)
|
bgFillColor := mustParseHexColor(background)
|
||||||
img := image.NewRGBA(image.Rect(0, 0, imgWidth, imgHeight))
|
img := image.NewRGBA(image.Rect(0, 0, imgWidth, imgHeight))
|
||||||
draw.Draw(img, img.Bounds(), &image.Uniform{bgColor}, image.Point{}, draw.Src)
|
draw.Draw(img, img.Bounds(), &image.Uniform{bgFillColor}, image.Point{}, draw.Src)
|
||||||
|
|
||||||
|
// Cache parsed colors to avoid per-cell hex parsing overhead on large terminals.
|
||||||
|
colorCache := map[string]color.RGBA{}
|
||||||
|
parseColor := func(hex string) color.RGBA {
|
||||||
|
if c, ok := colorCache[hex]; ok {
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
c := mustParseHexColor(hex)
|
||||||
|
colorCache[hex] = c
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
for rowIdx := 0; rowIdx < len(buffer); rowIdx++ {
|
for rowIdx := 0; rowIdx < len(buffer); rowIdx++ {
|
||||||
row := buffer[rowIdx]
|
row := buffer[rowIdx]
|
||||||
rectY := pngPadding + rowIdx*cellHeight
|
rectY := pngPadding + rowIdx*cellHeight
|
||||||
for col := 0; col < len(row); col++ {
|
for col := 0; col < len(row); col++ {
|
||||||
cell := row[col]
|
cell := row[col]
|
||||||
if cell.Data == "" && cell.BG == "" {
|
charData := cell.Data
|
||||||
|
|
||||||
|
// Fast-path skip for visually empty cells (default background, no reverse video).
|
||||||
|
if (charData == "" || charData == " ") && !cell.Reverse &&
|
||||||
|
(cell.BG == "" || strings.EqualFold(cell.BG, "default")) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
x := pngPadding + col*pngCharWidth
|
x := pngPadding + col*pngCharWidth
|
||||||
fg := colorToHex(cell.FG, true, palette, foreground, background)
|
fgHex := colorToHex(cell.FG, true, palette, foreground, background)
|
||||||
bg := colorToHex(cell.BG, false, palette, foreground, background)
|
bgHex := colorToHex(cell.BG, false, palette, foreground, background)
|
||||||
if cell.Reverse {
|
if cell.Reverse {
|
||||||
fg, bg = bg, fg
|
fgHex, bgHex = bgHex, fgHex
|
||||||
}
|
}
|
||||||
bgColor := mustParseHexColor(bg)
|
|
||||||
coverage := uint8(0)
|
// If there's no glyph, only render background when it differs from the image background.
|
||||||
if cell.Data != "" {
|
if charData == "" || charData == " " {
|
||||||
coverage = coverageForRune(firstRune(cell.Data))
|
if bgHex == background {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
draw.Draw(
|
||||||
|
img,
|
||||||
|
image.Rect(x, rectY, x+pngCharWidth, rectY+cellHeight),
|
||||||
|
&image.Uniform{parseColor(bgHex)},
|
||||||
|
image.Point{},
|
||||||
|
draw.Src,
|
||||||
|
)
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
cellColor := blendColors(mustParseHexColor(fg), bgColor, coverage)
|
|
||||||
|
bgColor := parseColor(bgHex)
|
||||||
|
fgColor := parseColor(fgHex)
|
||||||
|
coverage := coverageForRune(firstRune(charData))
|
||||||
|
var cellColor color.RGBA
|
||||||
|
switch coverage {
|
||||||
|
case 0:
|
||||||
|
cellColor = bgColor
|
||||||
|
case 255:
|
||||||
|
cellColor = fgColor
|
||||||
|
default:
|
||||||
|
cellColor = blendColors(fgColor, bgColor, coverage)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip drawing default background cells (background already filled).
|
||||||
|
if cellColor == bgFillColor {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
draw.Draw(
|
draw.Draw(
|
||||||
img,
|
img,
|
||||||
image.Rect(x, rectY, x+pngCharWidth, rectY+cellHeight),
|
image.Rect(x, rectY, x+pngCharWidth, rectY+cellHeight),
|
||||||
|
|||||||
+97
-49
@@ -2,6 +2,7 @@ package webterm
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
|
"bytes"
|
||||||
"compress/gzip"
|
"compress/gzip"
|
||||||
"context"
|
"context"
|
||||||
"crypto/sha1"
|
"crypto/sha1"
|
||||||
@@ -555,14 +556,36 @@ func (s *LocalServer) handleWebSocket(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
stdinQueue := make(chan stdinWrite, wsSendQueueMax)
|
stdinQueue := make(chan stdinWrite, wsSendQueueMax)
|
||||||
defer close(stdinQueue)
|
defer close(stdinQueue)
|
||||||
|
|
||||||
|
// Coalesce small stdin writes (e.g. key repeats) to reduce syscall and locking overhead.
|
||||||
|
const stdinCoalesceMaxBytes = 4 * 1024
|
||||||
go func() {
|
go func() {
|
||||||
|
var buf bytes.Buffer
|
||||||
for write := range stdinQueue {
|
for write := range stdinQueue {
|
||||||
if !write.session.SendBytes([]byte(write.data)) {
|
buf.Reset()
|
||||||
|
buf.WriteString(write.data)
|
||||||
|
for buf.Len() < stdinCoalesceMaxBytes {
|
||||||
|
select {
|
||||||
|
case next := <-stdinQueue:
|
||||||
|
buf.WriteString(next.data)
|
||||||
|
default:
|
||||||
|
goto flush
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flush:
|
||||||
|
if !write.session.SendBytes(buf.Bytes()) {
|
||||||
log.Printf("stdin write failed route=%s remote=%s", routeKey, r.RemoteAddr)
|
log.Printf("stdin write failed route=%s remote=%s", routeKey, r.RemoteAddr)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
// Allocate the timeout timer once; avoid time.After() per stdin message.
|
||||||
|
stdinTimer := time.NewTimer(stdinWriteTimeout)
|
||||||
|
if !stdinTimer.Stop() {
|
||||||
|
<-stdinTimer.C
|
||||||
|
}
|
||||||
|
defer stdinTimer.Stop()
|
||||||
|
|
||||||
for {
|
for {
|
||||||
messageType, payload, err := conn.ReadMessage()
|
messageType, payload, err := conn.ReadMessage()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -589,12 +612,32 @@ func (s *LocalServer) handleWebSocket(w http.ResponseWriter, r *http.Request) {
|
|||||||
if len(envelope) > 1 {
|
if len(envelope) > 1 {
|
||||||
data, _ = envelope[1].(string)
|
data, _ = envelope[1].(string)
|
||||||
}
|
}
|
||||||
|
write := stdinWrite{session: session, data: data}
|
||||||
select {
|
select {
|
||||||
case stdinQueue <- stdinWrite{session: session, data: data}:
|
case stdinQueue <- write:
|
||||||
case <-time.After(stdinWriteTimeout):
|
// queued
|
||||||
log.Printf("stdin queue saturated route=%s remote=%s: disconnecting client", routeKey, r.RemoteAddr)
|
default:
|
||||||
sendJSON([]any{"error", "Input backlog detected"})
|
// Queue is full; wait briefly for it to drain.
|
||||||
return
|
if !stdinTimer.Stop() {
|
||||||
|
select {
|
||||||
|
case <-stdinTimer.C:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stdinTimer.Reset(stdinWriteTimeout)
|
||||||
|
select {
|
||||||
|
case stdinQueue <- write:
|
||||||
|
if !stdinTimer.Stop() {
|
||||||
|
select {
|
||||||
|
case <-stdinTimer.C:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case <-stdinTimer.C:
|
||||||
|
log.Printf("stdin queue saturated route=%s remote=%s: disconnecting client", routeKey, r.RemoteAddr)
|
||||||
|
sendJSON([]any{"error", "Input backlog detected"})
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case "resize":
|
case "resize":
|
||||||
@@ -1168,7 +1211,9 @@ func (s *LocalServer) handleRoot(w http.ResponseWriter, r *http.Request) {
|
|||||||
const etagBySlug = {};
|
const etagBySlug = {};
|
||||||
const refreshQueue = [];
|
const refreshQueue = [];
|
||||||
const queuedRefresh = {};
|
const queuedRefresh = {};
|
||||||
let screenshotRequestInFlight = false;
|
// Allow limited parallelism when fetching thumbnails so large dashboards update faster.
|
||||||
|
const MAX_SCREENSHOT_CONCURRENCY = Math.max(2, Math.min(4, Math.floor((navigator.hardwareConcurrency || 4) / 2)));
|
||||||
|
let screenshotRequestsInFlight = 0;
|
||||||
const grid = document.getElementById('grid');
|
const grid = document.getElementById('grid');
|
||||||
const subtitle = document.getElementById('subtitle');
|
const subtitle = document.getElementById('subtitle');
|
||||||
|
|
||||||
@@ -1452,48 +1497,51 @@ func (s *LocalServer) handleRoot(w http.ResponseWriter, r *http.Request) {
|
|||||||
window.addEventListener('blur', onDashboardFocusChanged);
|
window.addEventListener('blur', onDashboardFocusChanged);
|
||||||
|
|
||||||
function processRefreshQueue() {
|
function processRefreshQueue() {
|
||||||
if (screenshotRequestInFlight || refreshQueue.length === 0 || !dashboardCanRequestScreenshots()) return;
|
if (refreshQueue.length === 0 || !dashboardCanRequestScreenshots()) return;
|
||||||
const slug = refreshQueue.shift();
|
|
||||||
delete queuedRefresh[slug];
|
while (screenshotRequestsInFlight < MAX_SCREENSHOT_CONCURRENCY && refreshQueue.length > 0) {
|
||||||
const card = cardsBySlug[slug];
|
const slug = refreshQueue.shift();
|
||||||
if (!card || !card.img) {
|
delete queuedRefresh[slug];
|
||||||
setTimeout(processRefreshQueue, 0);
|
const card = cardsBySlug[slug];
|
||||||
return;
|
if (!card || !card.img) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
screenshotRequestsInFlight++;
|
||||||
|
const url = screenshotEndpoint + '?route_key=' + encodeURIComponent(slug);
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), 5000);
|
||||||
|
const headers = {};
|
||||||
|
if (etagBySlug[slug]) {
|
||||||
|
headers['If-None-Match'] = etagBySlug[slug];
|
||||||
|
}
|
||||||
|
fetch(url, { cache: 'no-cache', headers, signal: controller.signal })
|
||||||
|
.then((resp) => {
|
||||||
|
const nextETag = resp.headers.get('ETag');
|
||||||
|
if (nextETag) {
|
||||||
|
etagBySlug[slug] = nextETag;
|
||||||
|
}
|
||||||
|
if (resp.status === 304) return null;
|
||||||
|
if (!resp.ok) throw new Error('screenshot fetch failed');
|
||||||
|
return resp.blob();
|
||||||
|
})
|
||||||
|
.then((blob) => {
|
||||||
|
if (!blob) return;
|
||||||
|
const currentCard = cardsBySlug[slug];
|
||||||
|
if (!currentCard || !currentCard.img) return;
|
||||||
|
const previous = activeObjectURLBySlug[slug];
|
||||||
|
if (previous) URL.revokeObjectURL(previous);
|
||||||
|
const objectURL = URL.createObjectURL(blob);
|
||||||
|
activeObjectURLBySlug[slug] = objectURL;
|
||||||
|
currentCard.img.src = objectURL;
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
screenshotRequestsInFlight--;
|
||||||
|
setTimeout(processRefreshQueue, 0);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
screenshotRequestInFlight = true;
|
|
||||||
const url = screenshotEndpoint + '?route_key=' + encodeURIComponent(slug);
|
|
||||||
const controller = new AbortController();
|
|
||||||
const timeout = setTimeout(() => controller.abort(), 5000);
|
|
||||||
const headers = {};
|
|
||||||
if (etagBySlug[slug]) {
|
|
||||||
headers['If-None-Match'] = etagBySlug[slug];
|
|
||||||
}
|
|
||||||
fetch(url, { cache: 'no-cache', headers, signal: controller.signal })
|
|
||||||
.then((resp) => {
|
|
||||||
const nextETag = resp.headers.get('ETag');
|
|
||||||
if (nextETag) {
|
|
||||||
etagBySlug[slug] = nextETag;
|
|
||||||
}
|
|
||||||
if (resp.status === 304) return null;
|
|
||||||
if (!resp.ok) throw new Error('screenshot fetch failed');
|
|
||||||
return resp.blob();
|
|
||||||
})
|
|
||||||
.then((blob) => {
|
|
||||||
if (!blob) return;
|
|
||||||
const currentCard = cardsBySlug[slug];
|
|
||||||
if (!currentCard || !currentCard.img) return;
|
|
||||||
const previous = activeObjectURLBySlug[slug];
|
|
||||||
if (previous) URL.revokeObjectURL(previous);
|
|
||||||
const objectURL = URL.createObjectURL(blob);
|
|
||||||
activeObjectURLBySlug[slug] = objectURL;
|
|
||||||
currentCard.img.src = objectURL;
|
|
||||||
})
|
|
||||||
.catch(() => {})
|
|
||||||
.finally(() => {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
screenshotRequestInFlight = false;
|
|
||||||
setTimeout(processRefreshQueue, 0);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function queueTileRefresh(slug) {
|
function queueTileRefresh(slug) {
|
||||||
@@ -1570,7 +1618,7 @@ func (s *LocalServer) handleRoot(w http.ResponseWriter, r *http.Request) {
|
|||||||
grid.innerHTML = '';
|
grid.innerHTML = '';
|
||||||
cardsBySlug = {};
|
cardsBySlug = {};
|
||||||
refreshQueue.length = 0;
|
refreshQueue.length = 0;
|
||||||
screenshotRequestInFlight = false;
|
screenshotRequestsInFlight = 0;
|
||||||
for (const key in queuedRefresh) {
|
for (const key in queuedRefresh) {
|
||||||
delete queuedRefresh[key];
|
delete queuedRefresh[key];
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -14,6 +14,12 @@ const MAX_MESSAGE_QUEUE_SIZE = 1000;
|
|||||||
const RESOURCE_CLEANUP_INTERVAL_MS = 30_000;
|
const RESOURCE_CLEANUP_INTERVAL_MS = 30_000;
|
||||||
/** Maximum bytes to buffer while the tab is hidden (256 KB) */
|
/** Maximum bytes to buffer while the tab is hidden (256 KB) */
|
||||||
const MAX_HIDDEN_BUFFER_BYTES = 256 * 1024;
|
const MAX_HIDDEN_BUFFER_BYTES = 256 * 1024;
|
||||||
|
|
||||||
|
/** Batch stdin writes to reduce per-keystroke overhead and avoid WS/PTY backlogs */
|
||||||
|
const STDIN_BATCH_DELAY_MS = 10;
|
||||||
|
/** Flush stdin batch when it gets large (e.g. paste) */
|
||||||
|
const STDIN_BATCH_MAX_CHARS = 8192;
|
||||||
|
|
||||||
const BELL_EMOJI = "🔔";
|
const BELL_EMOJI = "🔔";
|
||||||
|
|
||||||
/** Shared Ghostty WASM instance (loaded once, reused across all terminals) */
|
/** Shared Ghostty WASM instance (loaded once, reused across all terminals) */
|
||||||
@@ -663,6 +669,11 @@ class WebTerminal {
|
|||||||
private lastMessageAt = 0;
|
private lastMessageAt = 0;
|
||||||
private lastPongAt = 0;
|
private lastPongAt = 0;
|
||||||
private messageQueue: [string, unknown][] = [];
|
private messageQueue: [string, unknown][] = [];
|
||||||
|
|
||||||
|
// Stdin batching (coalesces key repeats into fewer WS frames)
|
||||||
|
private pendingStdin = "";
|
||||||
|
private pendingStdinTimer: number | undefined;
|
||||||
|
|
||||||
private lastValidSize: { cols: number; rows: number } | null = null;
|
private lastValidSize: { cols: number; rows: number } | null = null;
|
||||||
private mobileInput: HTMLTextAreaElement | null = null;
|
private mobileInput: HTMLTextAreaElement | null = null;
|
||||||
private mobileKeybar: HTMLElement | null = null;
|
private mobileKeybar: HTMLElement | null = null;
|
||||||
@@ -828,7 +839,7 @@ class WebTerminal {
|
|||||||
// Handle terminal input
|
// Handle terminal input
|
||||||
this.terminal.onData((data) => {
|
this.terminal.onData((data) => {
|
||||||
this.clearBellState();
|
this.clearBellState();
|
||||||
this.send(["stdin", data]);
|
this.sendStdin(data);
|
||||||
});
|
});
|
||||||
|
|
||||||
this.terminal.onBell(() => {
|
this.terminal.onBell(() => {
|
||||||
@@ -947,7 +958,7 @@ class WebTerminal {
|
|||||||
if (!text) {
|
if (!text) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.send(["stdin", applyMobileModifiers(text)]);
|
this.sendStdin(applyMobileModifiers(text));
|
||||||
textarea.value = "";
|
textarea.value = "";
|
||||||
this.deactivateModifiers();
|
this.deactivateModifiers();
|
||||||
this.pendingCtrl = false;
|
this.pendingCtrl = false;
|
||||||
@@ -1000,7 +1011,7 @@ class WebTerminal {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const toSend = isAlt ? applyAltModifier(ctrlApplied) : ctrlApplied;
|
const toSend = isAlt ? applyAltModifier(ctrlApplied) : ctrlApplied;
|
||||||
this.send(["stdin", toSend]); // Ctrl+A=0x01, Ctrl+C=0x03, etc.
|
this.sendStdin(toSend); // Ctrl+A=0x01, Ctrl+C=0x03, etc.
|
||||||
this.deactivateModifiers(); // Clear modifiers after physical Ctrl+key
|
this.deactivateModifiers(); // Clear modifiers after physical Ctrl+key
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1010,7 +1021,7 @@ class WebTerminal {
|
|||||||
if (fnApplied) {
|
if (fnApplied) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
this.send(["stdin", fnApplied]);
|
this.sendStdin(fnApplied);
|
||||||
this.deactivateModifiers();
|
this.deactivateModifiers();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1049,7 +1060,7 @@ class WebTerminal {
|
|||||||
if (seq) {
|
if (seq) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
this.send(["stdin", isAlt ? applyAltModifier(seq) : seq]);
|
this.sendStdin(isAlt ? applyAltModifier(seq) : seq);
|
||||||
// Always clear modifiers after any key
|
// Always clear modifiers after any key
|
||||||
this.deactivateModifiers();
|
this.deactivateModifiers();
|
||||||
}
|
}
|
||||||
@@ -1077,7 +1088,7 @@ class WebTerminal {
|
|||||||
const toSend = applyModifiers(event.key, useShift, useCtrl, useAlt, useFn);
|
const toSend = applyModifiers(event.key, useShift, useCtrl, useAlt, useFn);
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
this.send(["stdin", toSend]);
|
this.sendStdin(toSend);
|
||||||
handled = true;
|
handled = true;
|
||||||
} else {
|
} else {
|
||||||
let seq: string | null = null;
|
let seq: string | null = null;
|
||||||
@@ -1120,7 +1131,7 @@ class WebTerminal {
|
|||||||
if (seq) {
|
if (seq) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
this.send(["stdin", useAlt ? applyAltModifier(seq) : seq]);
|
this.sendStdin(useAlt ? applyAltModifier(seq) : seq);
|
||||||
handled = true;
|
handled = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1319,7 +1330,7 @@ class WebTerminal {
|
|||||||
key = applyAltModifier(key);
|
key = applyAltModifier(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.send(["stdin", key]);
|
this.sendStdin(key);
|
||||||
this.deactivateModifiers();
|
this.deactivateModifiers();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1535,7 +1546,7 @@ class WebTerminal {
|
|||||||
|
|
||||||
/** Validate terminal dimensions */
|
/** Validate terminal dimensions */
|
||||||
private isValidSize(cols: number, rows: number): boolean {
|
private isValidSize(cols: number, rows: number): boolean {
|
||||||
return cols >= 2 && cols <= 500 && rows >= 1 && rows <= 200;
|
return cols >= 2 && cols <= 500 && rows >= 1 && rows <= 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Connect to WebSocket server */
|
/** Connect to WebSocket server */
|
||||||
@@ -1557,7 +1568,8 @@ class WebTerminal {
|
|||||||
this.element.classList.add("-connected");
|
this.element.classList.add("-connected");
|
||||||
this.element.classList.remove("-disconnected");
|
this.element.classList.remove("-disconnected");
|
||||||
|
|
||||||
// Process any queued messages
|
// Flush any batched stdin and process queued messages
|
||||||
|
this.flushStdin();
|
||||||
this.processMessageQueue();
|
this.processMessageQueue();
|
||||||
|
|
||||||
// Send initial size
|
// Send initial size
|
||||||
@@ -1723,8 +1735,50 @@ class WebTerminal {
|
|||||||
this.connect();
|
this.connect();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Queue stdin data for batched sending */
|
||||||
|
private sendStdin(data: string): void {
|
||||||
|
if (!data) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.pendingStdin += data;
|
||||||
|
|
||||||
|
// Flush immediately for large payloads (e.g. paste) to avoid excessive buffering.
|
||||||
|
if (this.pendingStdin.length >= STDIN_BATCH_MAX_CHARS) {
|
||||||
|
this.flushStdin();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.pendingStdinTimer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.pendingStdinTimer = window.setTimeout(() => {
|
||||||
|
this.pendingStdinTimer = undefined;
|
||||||
|
this.flushStdin();
|
||||||
|
}, STDIN_BATCH_DELAY_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
private flushStdin(): void {
|
||||||
|
if (this.pendingStdinTimer) {
|
||||||
|
clearTimeout(this.pendingStdinTimer);
|
||||||
|
this.pendingStdinTimer = undefined;
|
||||||
|
}
|
||||||
|
if (!this.pendingStdin) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const chunk = this.pendingStdin;
|
||||||
|
this.pendingStdin = "";
|
||||||
|
this.send(["stdin", chunk]);
|
||||||
|
}
|
||||||
|
|
||||||
/** Send message to server with queueing support */
|
/** Send message to server with queueing support */
|
||||||
private send(message: [string, unknown]): void {
|
private send(message: [string, unknown]): void {
|
||||||
|
// Preserve ordering: flush any pending stdin before non-stdin messages (resize/ping/etc).
|
||||||
|
if (message[0] !== "stdin" && this.pendingStdin) {
|
||||||
|
this.flushStdin();
|
||||||
|
}
|
||||||
|
|
||||||
if (this.messageQueue.length >= MAX_MESSAGE_QUEUE_SIZE) {
|
if (this.messageQueue.length >= MAX_MESSAGE_QUEUE_SIZE) {
|
||||||
this.messageQueue = this.messageQueue.slice(-Math.floor(MAX_MESSAGE_QUEUE_SIZE / 2));
|
this.messageQueue = this.messageQueue.slice(-Math.floor(MAX_MESSAGE_QUEUE_SIZE / 2));
|
||||||
console.warn("[webterm] Message queue overflow; trimmed old messages");
|
console.warn("[webterm] Message queue overflow; trimmed old messages");
|
||||||
@@ -1775,6 +1829,11 @@ class WebTerminal {
|
|||||||
dispose(): void {
|
dispose(): void {
|
||||||
this.stopResourceCleanup();
|
this.stopResourceCleanup();
|
||||||
this.stopHeartbeatWatchdog();
|
this.stopHeartbeatWatchdog();
|
||||||
|
if (this.pendingStdinTimer) {
|
||||||
|
clearTimeout(this.pendingStdinTimer);
|
||||||
|
this.pendingStdinTimer = undefined;
|
||||||
|
}
|
||||||
|
this.pendingStdin = "";
|
||||||
if (this.resizeDebounceTimer) {
|
if (this.resizeDebounceTimer) {
|
||||||
clearTimeout(this.resizeDebounceTimer);
|
clearTimeout(this.resizeDebounceTimer);
|
||||||
this.resizeDebounceTimer = undefined;
|
this.resizeDebounceTimer = undefined;
|
||||||
|
|||||||
+23
-1
@@ -71,18 +71,40 @@ func RenderTerminalSVG(
|
|||||||
for col := 0; col < len(row); col++ {
|
for col := 0; col < len(row); col++ {
|
||||||
cell := row[col]
|
cell := row[col]
|
||||||
charData := cell.Data
|
charData := cell.Data
|
||||||
if charData == "" {
|
|
||||||
|
blankGlyph := charData == "" || charData == " "
|
||||||
|
defaultBG := cell.BG == "" || strings.EqualFold(cell.BG, "default")
|
||||||
|
// If there's no glyph, no underline, no reverse, and the background is default,
|
||||||
|
// this cell is visually empty.
|
||||||
|
if blankGlyph && !cell.Underscore && !cell.Reverse && defaultBG {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
x := 10 + float64(col)*charWidth
|
x := 10 + float64(col)*charWidth
|
||||||
fg := colorToHex(cell.FG, true, palette, foreground, background)
|
fg := colorToHex(cell.FG, true, palette, foreground, background)
|
||||||
bg := colorToHex(cell.BG, false, palette, foreground, background)
|
bg := colorToHex(cell.BG, false, palette, foreground, background)
|
||||||
if cell.Reverse {
|
if cell.Reverse {
|
||||||
fg, bg = bg, fg
|
fg, bg = bg, fg
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Render background even for empty cells (important for reverse video / colored spans).
|
||||||
if bg != background {
|
if bg != background {
|
||||||
b.WriteString(fmt.Sprintf(`<rect x="%.1f" y="%.1f" width="%.1f" height="%.1f" fill="%s"/>`, x, rectY, charWidth+0.5, cellHeight+0.5, bg))
|
b.WriteString(fmt.Sprintf(`<rect x="%.1f" y="%.1f" width="%.1f" height="%.1f" fill="%s"/>`, x, rectY, charWidth+0.5, cellHeight+0.5, bg))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only render text for visible glyphs (or underlined blanks).
|
||||||
|
if charData == "" {
|
||||||
|
if cell.Underscore {
|
||||||
|
charData = " "
|
||||||
|
} else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if charData == " " && !cell.Underscore {
|
||||||
|
// Space glyphs are visually empty; keep background only.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
attrs := []string{fmt.Sprintf(`x="%.1f"`, x)}
|
attrs := []string{fmt.Sprintf(`x="%.1f"`, x)}
|
||||||
if fg != foreground {
|
if fg != foreground {
|
||||||
attrs = append(attrs, `fill="`+fg+`"`)
|
attrs = append(attrs, `fill="`+fg+`"`)
|
||||||
|
|||||||
Reference in New Issue
Block a user