git-watchtower 2.3.9 → 2.3.11

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "git-watchtower",
3
- "version": "2.3.9",
3
+ "version": "2.3.11",
4
4
  "description": "Terminal-based Git branch monitor with activity sparklines and optional dev server with live reload",
5
5
  "main": "bin/git-watchtower.js",
6
6
  "bin": {
package/src/server/web.js CHANGED
@@ -36,6 +36,16 @@ const MAX_PORT_RETRIES = 20;
36
36
  */
37
37
  const SSE_KEEPALIVE_INTERVAL = 15000;
38
38
 
39
+ /**
40
+ * Maximum number of consecutive periodic-state pushes a stalled client (one
41
+ * whose internal write buffer hasn't drained) is allowed to miss before we
42
+ * force-close it. At a 500 ms STATE_PUSH_INTERVAL this gives roughly 30 s
43
+ * of grace before eviction — enough for a paused tab to wake up, short
44
+ * enough that a permanently dead socket can't pin memory or keep
45
+ * `clientCount` misleadingly high.
46
+ */
47
+ const MAX_STALLED_PUSHES = 60;
48
+
39
49
  /**
40
50
  * Actions the web dashboard is allowed to POST to /api/action. Every entry
41
51
  * here MUST be matched by a `case` in `handleWebAction` in bin/git-watchtower.js
@@ -86,6 +96,15 @@ class WebDashboardServer {
86
96
  /** @type {Set<import('net').Socket>} Raw TCP sockets, tracked so stop() can force-close them */
87
97
  this._sockets = new Set();
88
98
 
99
+ /**
100
+ * Per-client count of consecutive state pushes skipped because the
101
+ * client's internal buffer hasn't drained. Reset to 0 on a successful
102
+ * write; a paused tab whose count crosses MAX_STALLED_PUSHES gets
103
+ * evicted to bound memory.
104
+ * @type {WeakMap<import('http').ServerResponse, number>}
105
+ */
106
+ this._stalledPushes = new WeakMap();
107
+
89
108
  // Multi-project support (populated by coordinator)
90
109
  /** @type {Map<string, Object>} */
91
110
  this.projects = new Map();
@@ -341,11 +360,9 @@ class WebDashboardServer {
341
360
  */
342
361
  flash(text, type) {
343
362
  const data = JSON.stringify({ text, type: type || 'info' });
363
+ const message = 'event: flash\ndata: ' + data + '\n\n';
344
364
  for (const client of this.clients) {
345
- try {
346
- client.write('event: flash\n');
347
- client.write('data: ' + data + '\n\n');
348
- } catch (e) { /* SSE client disconnected — will be pruned when its response closes */ }
365
+ this._writeToClient(client, message);
349
366
  }
350
367
  }
351
368
 
@@ -355,11 +372,9 @@ class WebDashboardServer {
355
372
  */
356
373
  sendPreview(data) {
357
374
  const json = JSON.stringify(data);
375
+ const message = 'event: preview\ndata: ' + json + '\n\n';
358
376
  for (const client of this.clients) {
359
- try {
360
- client.write('event: preview\n');
361
- client.write('data: ' + json + '\n\n');
362
- } catch (e) { /* SSE client disconnected — will be pruned when its response closes */ }
377
+ this._writeToClient(client, message);
363
378
  }
364
379
  }
365
380
 
@@ -369,14 +384,51 @@ class WebDashboardServer {
369
384
  */
370
385
  sendActionResult(result) {
371
386
  const json = JSON.stringify(result);
387
+ const message = 'event: actionResult\ndata: ' + json + '\n\n';
372
388
  for (const client of this.clients) {
373
- try {
374
- client.write('event: actionResult\n');
375
- client.write('data: ' + json + '\n\n');
376
- } catch (e) { /* SSE client disconnected — will be pruned when its response closes */ }
389
+ this._writeToClient(client, message);
377
390
  }
378
391
  }
379
392
 
393
+ /**
394
+ * Write a frame to a single SSE client, respecting backpressure.
395
+ * Skips the write when the client's internal buffer hasn't drained
396
+ * (`writableNeedDrain`), so a paused tab can't cause unbounded
397
+ * server-side queueing. Drops the client on synchronous write errors
398
+ * (most often "write after end" on an abruptly-closed socket).
399
+ *
400
+ * @param {import('http').ServerResponse} client
401
+ * @param {string} message - Pre-formatted SSE frame (event + data + \n\n)
402
+ * @returns {boolean} `true` if the write was issued, `false` if skipped or failed
403
+ * @private
404
+ */
405
+ _writeToClient(client, message) {
406
+ if (client.writableNeedDrain) {
407
+ // Client hasn't acknowledged earlier writes — skip this frame so
408
+ // backpressure doesn't accumulate. The next push retries naturally.
409
+ return false;
410
+ }
411
+ try {
412
+ client.write(message);
413
+ this._stalledPushes.set(client, 0);
414
+ return true;
415
+ } catch (e) {
416
+ this._dropClient(client);
417
+ return false;
418
+ }
419
+ }
420
+
421
+ /**
422
+ * Force-close and forget a client. Idempotent.
423
+ * @param {import('http').ServerResponse} client
424
+ * @private
425
+ */
426
+ _dropClient(client) {
427
+ try { client.end(); } catch (_) { /* already torn down */ }
428
+ this.clients.delete(client);
429
+ this._stalledPushes.delete(client);
430
+ }
431
+
380
432
  /**
381
433
  * Get the number of connected web clients.
382
434
  * @returns {number}
@@ -586,8 +638,22 @@ class WebDashboardServer {
586
638
 
587
639
  const message = 'event: state\ndata: ' + json + '\n\n';
588
640
  for (const client of this.clients) {
641
+ if (client.writableNeedDrain) {
642
+ // Backpressure: client's internal buffer hasn't drained from a
643
+ // previous write. Track how many consecutive periodic pushes
644
+ // we've skipped so a paused/dead tab can't accumulate stalled
645
+ // writes forever. The non-periodic emitters (flash/preview/
646
+ // actionResult) skip silently — only `_pushState` evicts.
647
+ const n = (this._stalledPushes.get(client) || 0) + 1;
648
+ this._stalledPushes.set(client, n);
649
+ if (n >= MAX_STALLED_PUSHES) {
650
+ this._dropClient(client);
651
+ }
652
+ continue;
653
+ }
589
654
  try {
590
655
  client.write(message);
656
+ this._stalledPushes.set(client, 0);
591
657
  } catch (e) {
592
658
  // Write failed — proactively prune the dead client instead of
593
659
  // waiting for req.on('close') to fire. On abrupt socket resets
@@ -595,8 +661,7 @@ class WebDashboardServer {
595
661
  // hit the same failed write, accumulating exception work and
596
662
  // keeping clientCount misleadingly high. Set.delete during
597
663
  // iteration is safe; the for-of iterator handles it.
598
- try { client.end(); } catch (_) { /* already torn down */ }
599
- this.clients.delete(client);
664
+ this._dropClient(client);
600
665
  }
601
666
  }
602
667
  }
@@ -606,5 +671,6 @@ module.exports = {
606
671
  WebDashboardServer,
607
672
  DEFAULT_WEB_PORT,
608
673
  STATE_PUSH_INTERVAL,
674
+ MAX_STALLED_PUSHES,
609
675
  ALLOWED_ACTIONS,
610
676
  };
package/src/ui/ansi.js CHANGED
@@ -358,12 +358,106 @@ function sanitizeForRender(str) {
358
358
  }
359
359
 
360
360
  /**
361
- * Get the visible length of a string (excluding ANSI codes)
362
- * @param {string} str - String potentially containing ANSI codes
363
- * @returns {number} Visible character count
361
+ * Display width of a single Unicode code point in terminal columns.
362
+ * Returns 0 for combining marks / variation selectors / ZWJ, 2 for East
363
+ * Asian Wide / Fullwidth and emoji-presentation code points, 1 otherwise.
364
+ * Used by visibleLength's grapheme iterator.
365
+ * @param {number} cp
366
+ * @returns {number}
367
+ * @private
368
+ */
369
+ function _codePointWidth(cp) {
370
+ if (cp == null) return 0;
371
+ // C0 / DEL — defensively zero-width (stripAnsi already drops them).
372
+ if (cp < 0x20 || cp === 0x7f) return 0;
373
+
374
+ // Combining marks, variation selectors, ZWJ — zero-width.
375
+ if (
376
+ (cp >= 0x0300 && cp <= 0x036F) || // Combining Diacritical Marks
377
+ (cp >= 0x1AB0 && cp <= 0x1AFF) || // Combining Diacritical Marks Extended
378
+ (cp >= 0x1DC0 && cp <= 0x1DFF) || // Combining Diacritical Marks Supplement
379
+ (cp >= 0x20D0 && cp <= 0x20FF) || // Combining Diacritical Marks for Symbols
380
+ (cp >= 0xFE00 && cp <= 0xFE0F) || // Variation Selectors (incl. emoji presentation)
381
+ (cp >= 0xFE20 && cp <= 0xFE2F) || // Combining Half Marks
382
+ cp === 0x200D // ZWJ
383
+ ) return 0;
384
+
385
+ // East Asian Wide / Fullwidth / emoji — two columns.
386
+ if (
387
+ (cp >= 0x1100 && cp <= 0x115F) || // Hangul Jamo
388
+ (cp >= 0x2E80 && cp <= 0x303E) || // CJK Radicals etc
389
+ (cp >= 0x3041 && cp <= 0x33FF) || // CJK Symbols / Hiragana / Katakana / Bopomofo / Hangul / CJK
390
+ (cp >= 0x3400 && cp <= 0x4DBF) || // CJK Unified Ideographs Ext A
391
+ (cp >= 0x4E00 && cp <= 0x9FFF) || // CJK Unified Ideographs
392
+ (cp >= 0xA000 && cp <= 0xA4CF) || // Yi Syllables
393
+ (cp >= 0xAC00 && cp <= 0xD7A3) || // Hangul Syllables
394
+ (cp >= 0xF900 && cp <= 0xFAFF) || // CJK Compatibility Ideographs
395
+ (cp >= 0xFE30 && cp <= 0xFE4F) || // CJK Compatibility Forms
396
+ (cp >= 0xFF00 && cp <= 0xFF60) || // Fullwidth Forms
397
+ (cp >= 0xFFE0 && cp <= 0xFFE6) || // Fullwidth Signs
398
+ (cp >= 0x1F300 && cp <= 0x1F5FF) || // Misc Symbols and Pictographs
399
+ (cp >= 0x1F600 && cp <= 0x1F64F) || // Emoticons
400
+ (cp >= 0x1F680 && cp <= 0x1F6FF) || // Transport
401
+ (cp >= 0x1F900 && cp <= 0x1F9FF) || // Supplemental Symbols and Pictographs
402
+ (cp >= 0x1FA00 && cp <= 0x1FAFF) || // Symbols and Pictographs Extended-A
403
+ (cp >= 0x1F1E6 && cp <= 0x1F1FF) || // Regional indicators (flags)
404
+ (cp >= 0x1F3FB && cp <= 0x1F3FF) || // Emoji modifiers (skin tone)
405
+ (cp >= 0x20000 && cp <= 0x2FFFD) || // CJK Ideographs Extension B-F
406
+ (cp >= 0x30000 && cp <= 0x3FFFD) // CJK Ideographs Extension G-H
407
+ ) return 2;
408
+
409
+ return 1;
410
+ }
411
+
412
+ // Reused grapheme segmenter — instantiating per-call would be wasteful for
413
+ // strings hit on every render. Default locale is fine; we only care about
414
+ // boundaries, not script-aware ordering.
415
+ const _graphemeSegmenter = (typeof Intl !== 'undefined' && Intl.Segmenter)
416
+ ? new Intl.Segmenter(undefined, { granularity: 'grapheme' })
417
+ : null;
418
+
419
+ /**
420
+ * Get the visible width of a string in terminal columns, excluding ANSI
421
+ * codes and counting wide characters (CJK ideographs, emoji, fullwidth)
422
+ * as 2 columns. Combining marks, variation selectors, and ZWJ are 0-width.
423
+ * Grapheme clusters that contain at least one wide code point clamp to 2
424
+ * — so a ZWJ family (e.g. 👨‍👩‍👧, which is 5 code points = 13 UTF-16 units)
425
+ * counts as 2, matching how terminals render it.
426
+ *
427
+ * Without this, branch names with emoji/CJK misalign sparklines, badges
428
+ * and box borders because the renderer's `padRight`/truncation math drifts
429
+ * by one or more columns per non-ASCII character.
430
+ *
431
+ * @param {string} str - String potentially containing ANSI codes and unicode
432
+ * @returns {number} Visible width in terminal columns
364
433
  */
365
434
  function visibleLength(str) {
366
- return stripAnsi(str).length;
435
+ const stripped = stripAnsi(str);
436
+ // ASCII fast path — every char is 1 column, regex avoids the Segmenter
437
+ // setup cost on the renderer's hot path.
438
+ if (/^[\x20-\x7e]*$/.test(stripped)) return stripped.length;
439
+ if (!_graphemeSegmenter) {
440
+ // Pre-Intl.Segmenter fallback (shouldn't hit on Node 16+).
441
+ let width = 0;
442
+ for (const ch of stripped) width += _codePointWidth(ch.codePointAt(0));
443
+ return width;
444
+ }
445
+ let total = 0;
446
+ for (const { segment } of _graphemeSegmenter.segment(stripped)) {
447
+ let clusterTotal = 0;
448
+ let clusterHasWide = false;
449
+ for (const ch of segment) {
450
+ const w = _codePointWidth(ch.codePointAt(0));
451
+ if (w === 2) clusterHasWide = true;
452
+ clusterTotal += w;
453
+ }
454
+ // ZWJ-joined emoji clusters (e.g. family / flag / skin-tone) sum to
455
+ // more than 2 by code point, but render as a single 2-column glyph.
456
+ // Cap them at 2; non-wide clusters (combining marks on a base char)
457
+ // still take at least 1 column.
458
+ total += clusterHasWide ? 2 : Math.max(1, clusterTotal);
459
+ }
460
+ return total;
367
461
  }
368
462
 
369
463
  /**
@@ -382,14 +476,39 @@ function truncate(str, maxLen, suffix = '…') {
382
476
  // raw input with cursor/screen-control sequences in it. SGR survives.
383
477
  const safeStr = sanitizeForRender(str);
384
478
  const visible = stripAnsi(safeStr);
385
- if (visible.length <= maxLen) {
479
+
480
+ // Compare in display columns, not UTF-16 code units, so a string of CJK
481
+ // ideographs (e.g. "中文测试" — 4 code units, 8 columns) is correctly
482
+ // recognised as needing truncation in a 4-column slot.
483
+ if (visibleLength(visible) <= maxLen) {
386
484
  return safeStr;
387
485
  }
388
486
 
389
- // Long-string path: drop ALL escapes (including SGR), truncate, append
390
- // ellipsis + reset. Existing behaviour, kept for layout determinism.
391
- const truncated = visible.slice(0, maxLen - suffix.length);
392
- return truncated + suffix + ansi.reset;
487
+ // Long-string path: drop ALL escapes (including SGR), accumulate
488
+ // graphemes up to the column budget, append ellipsis + reset. Walking
489
+ // graphemes (not code units) keeps wide characters from being split
490
+ // mid-glyph and keeps the truncated width <= maxLen even for emoji
491
+ // and CJK input.
492
+ const suffixWidth = visibleLength(suffix);
493
+ const budget = Math.max(0, maxLen - suffixWidth);
494
+ let acc = '';
495
+ let used = 0;
496
+ if (_graphemeSegmenter) {
497
+ for (const { segment } of _graphemeSegmenter.segment(visible)) {
498
+ const w = visibleLength(segment);
499
+ if (used + w > budget) break;
500
+ acc += segment;
501
+ used += w;
502
+ }
503
+ } else {
504
+ for (const ch of visible) {
505
+ const w = _codePointWidth(ch.codePointAt(0));
506
+ if (used + w > budget) break;
507
+ acc += ch;
508
+ used += w;
509
+ }
510
+ }
511
+ return acc + suffix + ansi.reset;
393
512
  }
394
513
 
395
514
  /**