@goodandready/dsh-context-lens 0.1.16 → 0.1.18

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/lib/client.js CHANGED
@@ -5,7 +5,14 @@ window.__ModuleLoader__.load({
5
5
  const React = require('react');
6
6
  const NS = '@goodandready/dsh-context-lens';
7
7
 
8
- const en = {
8
+ const en = {
9
+ 'update.available': 'Update available: v{latestVersion} (current: v{currentVersion})',
10
+ 'update.btn': 'Update Now',
11
+ 'update.updating': 'Updating…',
12
+ 'update.done': 'Successfully updated to v{version}! Please restart DSH.',
13
+ 'update.failed': 'Update failed: {error}',
14
+ 'update.checking': 'Checking for updates…',
15
+ 'update.up_to_date': 'Up to date',
9
16
  title: 'Context Lens & Token Guard',
10
17
  sub: 'AST compression, log filtering, token budget',
11
18
  mode: 'Compression mode',
@@ -23,39 +30,55 @@ window.__ModuleLoader__.load({
23
30
  lowBudget: 'Budget nearly exhausted',
24
31
  threshold: 'Auto-compress threshold (chars)',
25
32
  autoCollapse: 'Warn when budget is nearly exhausted',
33
+ budgetAlertPercent: 'Budget alert threshold (%)',
26
34
  previewApprox: 'Server preview',
27
35
  noData: 'No data yet',
28
36
  refresh: 'Refresh',
29
37
  ops: 'ops',
30
38
  active: 'Active',
31
39
  readyState: 'Ready',
32
- lowBudgetBadge: 'Low Budget ⚠'
40
+ lowBudgetBadge: 'Low Budget ⚠',
41
+ focusPaths: 'Active focus',
42
+ noFocus: 'No focus paths set',
43
+ clearFocus: 'Clear focus'
33
44
  };
34
- const ru = {
35
- title: 'Context Lens & Token Guard',
36
- sub: 'Сжатие AST, фильтрация логов, контроль токенов',
37
- mode: 'Режим сжатия',
38
- depth: 'Глубина AST',
39
- tracking: 'Считать экономию',
40
- preview: 'Предпросмотр лога',
41
- compress: 'Сжать',
42
- saved: 'Сэкономлено',
43
- tokens: 'токенов',
44
- placeholder: 'Вставьте лог Jest/Pytest…',
45
- saving: 'Сохранение…',
46
- ready: 'Готово',
47
- budget: 'Бюджет',
48
- history: 'Последние операции',
49
- lowBudget: 'Бюджет почти исчерпан',
50
- threshold: 'Порог авто-сжатия (символы)',
51
- autoCollapse: 'Предупреждать при почти исчерпанном бюджете',
52
- previewApprox: 'Серверный предпросмотр',
53
- noData: 'Пока нет данных',
54
- refresh: 'Обновить',
55
- ops: 'оп.',
56
- active: 'Активен',
57
- readyState: 'Готов',
58
- lowBudgetBadge: 'Мало бюджета ⚠'
45
+
46
+ const zh = {
47
+ 'update.available': '发现新版本 v{latestVersion}(当前版本 v{currentVersion})',
48
+ 'update.btn': '立即更新',
49
+ 'update.updating': '正在更新…',
50
+ 'update.done': '更新成功至 v{version}!请重启 DSH 服务生效。',
51
+ 'update.failed': '更新失败:{error}',
52
+ 'update.checking': '正在检查更新…',
53
+ 'update.up_to_date': '已是最新版本',
54
+ title: 'Context Lens 上下文镜头与 Token 守卫',
55
+ sub: 'AST 代码精简、日志过滤与 Token 预算监控',
56
+ mode: '压缩模式',
57
+ depth: 'AST 最大深度',
58
+ tracking: '追踪 Token 节省',
59
+ preview: '日志预览',
60
+ compress: '压缩',
61
+ saved: '已节省',
62
+ tokens: 'Token',
63
+ placeholder: '在此粘贴 Jest/Pytest 等测试日志…',
64
+ saving: '保存中…',
65
+ ready: '已就绪',
66
+ budget: '预算',
67
+ history: '近期操作',
68
+ lowBudget: 'Token 预算即将耗尽',
69
+ threshold: '自动压缩阈值 (字符数)',
70
+ autoCollapse: '预算偏低时在界面提示警告',
71
+ budgetAlertPercent: '预算警告阈值 (%)',
72
+ previewApprox: '服务端压缩预览',
73
+ noData: '暂无数据',
74
+ refresh: '刷新',
75
+ ops: '次操作',
76
+ active: '运行中',
77
+ readyState: '已就绪',
78
+ lowBudgetBadge: '预算紧张 ⚠',
79
+ focusPaths: '当前聚焦路径',
80
+ noFocus: '未设置聚焦路径',
81
+ clearFocus: '清除聚焦'
59
82
  };
60
83
 
61
84
  function ensureCss() {
@@ -99,12 +122,12 @@ window.__ModuleLoader__.load({
99
122
  .cl-badge-ok {
100
123
  border-color: var(--dsw-alias-state-success-primary, #10b981);
101
124
  color: var(--dsw-alias-state-success-primary, #10b981);
102
- background: rgba(16, 185, 129, 0.08);
125
+ background: color-mix(in srgb, var(--dsw-alias-state-success-primary, #10b981) 8%, transparent);
103
126
  }
104
127
  .cl-badge-warn {
105
128
  border-color: var(--dsw-alias-state-error-primary, #ef4444);
106
129
  color: var(--dsw-alias-state-error-primary, #ef4444);
107
- background: rgba(239, 68, 68, 0.08);
130
+ background: color-mix(in srgb, var(--dsw-alias-state-error-primary, #ef4444) 8%, transparent);
108
131
  }
109
132
  .cl-stat-grid {
110
133
  display: grid;
@@ -202,7 +225,7 @@ window.__ModuleLoader__.load({
202
225
  .cl-banner-warn {
203
226
  padding: 10px 14px;
204
227
  border-radius: 8px;
205
- background: rgba(239, 68, 68, 0.1);
228
+ background: color-mix(in srgb, var(--dsw-alias-state-error-primary, #ef4444) 10%, transparent);
206
229
  border: 1px solid var(--dsw-alias-state-error-primary, #ef4444);
207
230
  color: var(--dsw-alias-state-error-primary, #ef4444);
208
231
  font-size: 12px;
@@ -220,10 +243,22 @@ window.__ModuleLoader__.load({
220
243
  .cl-hist-item:last-child {
221
244
  border-bottom: none;
222
245
  }
246
+ .cl-tag {
247
+ display: inline-flex;
248
+ align-items: center;
249
+ gap: 4px;
250
+ padding: 2px 6px;
251
+ background: var(--dsw-alias-bg-layer-1);
252
+ border: 1px solid var(--dsw-alias-border-l2);
253
+ border-radius: 4px;
254
+ font-size: 11px;
255
+ font-family: monospace;
256
+ color: var(--dsw-alias-label-secondary);
257
+ }
223
258
  .cl-error-box {
224
259
  padding: 12px;
225
260
  border-radius: 8px;
226
- background: rgba(239, 68, 68, 0.08);
261
+ background: color-mix(in srgb, var(--dsw-alias-state-error-primary, #ef4444) 8%, transparent);
227
262
  border: 1px solid var(--dsw-alias-state-error-primary, #ef4444);
228
263
  color: var(--dsw-alias-state-error-primary, #ef4444);
229
264
  font-size: 12px;
@@ -232,6 +267,18 @@ window.__ModuleLoader__.load({
232
267
  document.head.appendChild(style);
233
268
  }
234
269
 
270
+ function makeT(dict, fallback) {
271
+ return function t(key, vars) {
272
+ let val = (dict && dict[key]) || (fallback && fallback[key]) || key;
273
+ if (vars && typeof val === 'string') {
274
+ for (const k of Object.keys(vars)) {
275
+ val = val.replace(new RegExp('\\{' + k + '\\}', 'g'), String(vars[k]));
276
+ }
277
+ }
278
+ return val;
279
+ };
280
+ }
281
+
235
282
  function createErrorBoundary() {
236
283
  if (!React || typeof React.Component !== 'function') {
237
284
  return function NoopBoundary(props) { return (props && props.children) || null; };
@@ -270,13 +317,13 @@ window.__ModuleLoader__.load({
270
317
  try {
271
318
  if (ctx && ctx.locale && typeof ctx.locale.getSnapshot === 'function') {
272
319
  const snap = ctx.locale.getSnapshot();
273
- if (snap && snap.active && snap.active.startsWith('ru')) return ru;
320
+ if (snap && snap.active && snap.active.startsWith('zh')) return zh;
274
321
  }
275
- } catch {}
322
+ } catch (err) { void err; }
276
323
  return en;
277
324
  }
278
325
 
279
- function StatusPanel({ stats, history, labels, onRefresh, title, showBadge, compact }) {
326
+ function StatusPanel({ stats, history, focus, sessionId, labels, onRefresh, onClearFocus, title, showBadge, compact }) {
280
327
  ensureCss();
281
328
  const L = labels || en;
282
329
  const warn = !!(stats && stats.lowBudget);
@@ -351,6 +398,33 @@ window.__ModuleLoader__.load({
351
398
  ));
352
399
  }
353
400
 
401
+ // Active focus paths section
402
+ const focusPaths = (focus && focus.paths) || [];
403
+ if (focusPaths.length > 0 || onClearFocus) {
404
+ children.push(React.createElement('div', {
405
+ key: 'focus',
406
+ style: { marginTop: 10, paddingTop: 8, borderTop: '1px solid var(--dsw-alias-border-l2)' }
407
+ },
408
+ React.createElement('div', {
409
+ style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 }
410
+ },
411
+ React.createElement('span', { style: { fontSize: 11, fontWeight: 600, color: 'var(--dsw-alias-label-secondary)' } }, L.focusPaths),
412
+ focusPaths.length > 0 && onClearFocus
413
+ ? React.createElement('button', {
414
+ type: 'button',
415
+ onClick: onClearFocus,
416
+ style: { background: 'none', border: 'none', padding: 0, fontSize: 10, color: 'var(--dsw-alias-label-tertiary)', cursor: 'pointer', textDecoration: 'underline' }
417
+ }, L.clearFocus)
418
+ : null
419
+ ),
420
+ focusPaths.length > 0
421
+ ? React.createElement('div', { style: { display: 'flex', flexWrap: 'wrap', gap: 4, marginTop: 4 } },
422
+ ...focusPaths.slice(0, 4).map((p, i) => React.createElement('span', { key: i, className: 'cl-tag' }, p))
423
+ )
424
+ : React.createElement('div', { style: { fontSize: 11, color: 'var(--dsw-alias-label-tertiary)', fontStyle: 'italic' } }, L.noFocus)
425
+ ));
426
+ }
427
+
354
428
  if (history && history.length) {
355
429
  children.push(React.createElement('div', {
356
430
  key: 'hist',
@@ -386,20 +460,31 @@ window.__ModuleLoader__.load({
386
460
  .then((r) => r.ok ? r.json() : null);
387
461
  }
388
462
 
463
+ function clearLensFocus(sessionId) {
464
+ const q = sessionId ? '?sessionId=' + encodeURIComponent(sessionId) : '';
465
+ return fetch('/dsh-context-lens/clear-focus' + q, { method: 'POST', headers: { accept: 'application/json' } })
466
+ .then((r) => r.ok ? r.json() : null);
467
+ }
468
+
389
469
  function LensTabInner({ ctx: _ctx, scope }) {
390
470
  ensureCss();
391
471
  const [stats, setStats] = React.useState(null);
392
472
  const [history, setHistory] = React.useState([]);
473
+ const [focus, setFocus] = React.useState(null);
474
+ const [sessionId, setSessionId] = React.useState(null);
393
475
  const L = React.useMemo(() => getActiveLocale(_ctx), [_ctx]);
394
476
  const apply = (j) => {
395
477
  if (j && j.stats) setStats(j.stats);
396
478
  if (j && j.history) setHistory(j.history || []);
479
+ if (j && j.focus) setFocus(j.focus);
480
+ if (j && j.sessionId) setSessionId(j.sessionId);
397
481
  };
398
482
  const refresh = () => fetchLensStatus().then(apply).catch(() => {});
483
+ const handleClearFocus = () => clearLensFocus(sessionId).then(() => refresh()).catch(() => {});
399
484
  React.useEffect(() => { refresh(); }, []);
400
485
  return React.createElement('div', { style: { padding: 12 } },
401
486
  React.createElement(StatusPanel, {
402
- stats, history, labels: L, onRefresh: refresh, title: 'Context Lens', showBadge: false, compact: false
487
+ stats, history, focus, sessionId, labels: L, onRefresh: refresh, onClearFocus: handleClearFocus, title: 'Context Lens', showBadge: false, compact: false
403
488
  })
404
489
  );
405
490
  }
@@ -414,6 +499,8 @@ window.__ModuleLoader__.load({
414
499
  ensureCss();
415
500
  const [stats, setStats] = React.useState(null);
416
501
  const [history, setHistory] = React.useState([]);
502
+ const [focus, setFocus] = React.useState(null);
503
+ const [sessionId, setSessionId] = React.useState(null);
417
504
  const [open, setOpen] = React.useState(false);
418
505
  const ref = React.useRef(null);
419
506
  const L = React.useMemo(() => getActiveLocale(_ctx), [_ctx]);
@@ -421,8 +508,12 @@ window.__ModuleLoader__.load({
421
508
  const fetchStatus = () => fetchLensStatus().then((j) => {
422
509
  if (j && j.stats) setStats(j.stats);
423
510
  if (j && j.history) setHistory(j.history || []);
511
+ if (j && j.focus) setFocus(j.focus);
512
+ if (j && j.sessionId) setSessionId(j.sessionId);
424
513
  }).catch(() => {});
425
514
 
515
+ const handleClearFocus = () => clearLensFocus(sessionId).then(() => fetchStatus()).catch(() => {});
516
+
426
517
  React.useEffect(() => {
427
518
  let alive = true;
428
519
  fetchStatus();
@@ -469,13 +560,13 @@ window.__ModuleLoader__.load({
469
560
  style: {
470
561
  position: 'absolute', top: 'calc(100% + 6px)', right: 0, width: 280,
471
562
  background: 'var(--dsw-alias-bg-layer-3)', border: '1px solid var(--dsw-alias-border-l2)',
472
- borderRadius: 10, boxShadow: '0 8px 24px rgba(0, 0, 0, 0.28)', padding: '12px 14px',
563
+ borderRadius: 10, boxShadow: 'var(--dsw-alias-modal-shadow, var(--dsw-alias-shadow-large, 0 4px 16px var(--dsw-alias-border-l1)))', padding: '12px 14px',
473
564
  zIndex: 1000, fontSize: 12, color: 'var(--dsw-alias-label-primary)', cursor: 'default', textAlign: 'left'
474
565
  },
475
566
  onClick: (e) => e.stopPropagation()
476
567
  },
477
568
  React.createElement(StatusPanel, {
478
- stats, history, labels: L, onRefresh: fetchStatus, title: 'Context Lens', showBadge: true, compact: true
569
+ stats, history, focus, sessionId, labels: L, onRefresh: fetchStatus, onClearFocus: handleClearFocus, title: 'Context Lens', showBadge: true, compact: true
479
570
  })
480
571
  ) : null;
481
572
 
@@ -507,6 +598,7 @@ window.__ModuleLoader__.load({
507
598
  astSkeletonMaxDepth: 3,
508
599
  tokenSavingsTracking: true,
509
600
  budgetLimit: 100000,
601
+ budgetAlertPercent: 90,
510
602
  autoCollapse: true,
511
603
  autoCompressThreshold: 4000
512
604
  });
@@ -521,7 +613,7 @@ window.__ModuleLoader__.load({
521
613
  const scopeRef = React.useRef(null);
522
614
  if (!scopeRef.current && _ctx) {
523
615
  try {
524
- const s = (_ctx.get && _ctx.get('lanSettings')) || _ctx.settingsScope;
616
+ const s = _ctx.settingsScope || (_ctx.get && _ctx.get('settingsScope'));
525
617
  if (s && typeof s.bind === 'function') {
526
618
  scopeRef.current = s.bind({ namespace: NS });
527
619
  }
@@ -561,7 +653,7 @@ window.__ModuleLoader__.load({
561
653
  unsub = scope.subscribe(() => {
562
654
  if (!cancelled) loadSnap();
563
655
  });
564
- } catch (_) {}
656
+ } catch (_err) { void _err; }
565
657
  }
566
658
 
567
659
  return () => {
@@ -570,6 +662,69 @@ window.__ModuleLoader__.load({
570
662
  };
571
663
  }, [scope]);
572
664
 
665
+ const [updateState, setUpdateState] = React.useState({
666
+ checking: false,
667
+ updating: false,
668
+ currentVersion: '0.1.18',
669
+ latestVersion: '',
670
+ updateAvailable: false,
671
+ canAutoUpdate: true,
672
+ notice: '',
673
+ error: ''
674
+ });
675
+
676
+ const checkUpdate = React.useCallback(async () => {
677
+ setUpdateState((s) => ({ ...s, checking: true, error: '' }));
678
+ try {
679
+ const res = await fetch('/api/dsh-context-lens/update');
680
+ if (!res.ok) throw new Error('HTTP ' + res.status);
681
+ const data = await res.json().catch(() => ({}));
682
+ setUpdateState((s) => ({
683
+ ...s,
684
+ checking: false,
685
+ currentVersion: data.currentVersion || s.currentVersion,
686
+ latestVersion: data.latestVersion || '',
687
+ updateAvailable: !!data.updateAvailable,
688
+ canAutoUpdate: data.canAutoUpdate !== false,
689
+ }));
690
+ } catch (_) {
691
+ setUpdateState((s) => ({ ...s, checking: false }));
692
+ }
693
+ }, []);
694
+
695
+ React.useEffect(() => {
696
+ if (expanded) checkUpdate();
697
+ }, [expanded, checkUpdate]);
698
+
699
+ async function handleTriggerUpdate() {
700
+ if (updateState.updating) return;
701
+ setUpdateState((s) => ({ ...s, updating: true, error: '', notice: '' }));
702
+ try {
703
+ const res = await fetch('/api/dsh-context-lens/update', {
704
+ method: 'POST',
705
+ headers: { 'x-dsh-plugin-update': '1', 'x-dsh-context-lens-update': '1' }
706
+ });
707
+ const data = await res.json().catch(() => ({}));
708
+ if (!res.ok || data.ok === false || data.error) {
709
+ throw new Error(data.error || ('HTTP ' + res.status));
710
+ }
711
+ const newVer = data.updatedVersion || updateState.latestVersion || updateState.currentVersion;
712
+ setUpdateState((s) => ({
713
+ ...s,
714
+ updating: false,
715
+ updateAvailable: false,
716
+ currentVersion: newVer,
717
+ notice: tt('update.done', { version: newVer }),
718
+ }));
719
+ } catch (e) {
720
+ setUpdateState((s) => ({
721
+ ...s,
722
+ updating: false,
723
+ error: tt('update.failed', { error: e && e.message || String(e) }),
724
+ }));
725
+ }
726
+ }
727
+
573
728
  React.useEffect(() => {
574
729
  if (!expanded) return;
575
730
  fetch('/dsh-context-lens/status').then((r) => r.ok ? r.json() : null).then((j) => {
@@ -580,7 +735,8 @@ window.__ModuleLoader__.load({
580
735
  }).catch(() => {});
581
736
  }, [expanded, draft.autoCollapse]);
582
737
 
583
- const tt = t || ((k) => (en[k] || k));
738
+ const L = React.useMemo(() => getActiveLocale(_ctx), [_ctx]);
739
+ const tt = React.useMemo(() => (t ? t : makeT(L, en)), [t, L]);
584
740
 
585
741
  let ChevronIcon = null;
586
742
  try {
@@ -589,9 +745,29 @@ window.__ModuleLoader__.load({
589
745
  } catch (e) { ChevronIcon = null; }
590
746
 
591
747
  const Chevron = ChevronIcon ? function ChevronNode(p) {
592
- return React.createElement(ChevronIcon, { className: 'cl-chev' + (p.open ? ' cl-chev-open' : ''), style: { marginLeft: 'auto', color: 'var(--dsw-alias-label-tertiary)', transition: 'transform .16s', transform: p.open ? 'rotate(180deg)' : 'none' } });
748
+ return React.createElement(ChevronIcon, { className: 'cl-chev' + (p.open ? ' cl-chev-open' : ''), style: { marginLeft: 'auto', color: 'var(--dsw-alias-label-tertiary)', transition: 'transform .16s ease', transform: p.open ? 'rotate(180deg)' : 'none' } });
593
749
  } : function Fallback(p) {
594
- return React.createElement('span', { className: 'cl-chev' + (p.open ? ' cl-chev-open' : ''), style: { marginLeft: 'auto', color: 'var(--dsw-alias-label-tertiary)' } }, '▼');
750
+ return React.createElement('svg', {
751
+ className: 'cl-chev' + (p.open ? ' cl-chev-open' : ''),
752
+ viewBox: '0 0 14 14',
753
+ width: 14,
754
+ height: 14,
755
+ style: {
756
+ marginLeft: 'auto',
757
+ color: 'var(--dsw-alias-label-tertiary)',
758
+ transform: p.open ? 'rotate(180deg)' : 'none',
759
+ transition: 'transform .16s ease',
760
+ display: 'inline-block',
761
+ flexShrink: 0
762
+ }
763
+ }, React.createElement('path', {
764
+ d: 'M3.5 5.25L7 8.75L10.5 5.25',
765
+ fill: 'none',
766
+ stroke: 'currentColor',
767
+ strokeWidth: '1.5',
768
+ strokeLinecap: 'round',
769
+ strokeLinejoin: 'round'
770
+ }));
595
771
  };
596
772
 
597
773
  async function onSave() {
@@ -645,6 +821,39 @@ window.__ModuleLoader__.load({
645
821
  React.createElement(Chevron, { open: expanded })
646
822
  ),
647
823
  expanded ? React.createElement('div', { className: 'cl-body', style: { borderTop: '1px solid var(--dsw-alias-border-l2)', paddingTop: 12 } },
824
+ React.createElement('div', {
825
+ style: {
826
+ display: 'flex', alignItems: 'center', justifyContent: 'space-between',
827
+ padding: '6px 10px', marginBottom: 10, borderRadius: 8,
828
+ background: 'var(--dsw-alias-bg-layer-2)', border: '1px solid var(--dsw-alias-border-l2)', fontSize: 12
829
+ }
830
+ },
831
+ React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 8 } },
832
+ React.createElement('span', { style: { fontWeight: 500, color: 'var(--dsw-alias-label-primary)' } },
833
+ 'v' + (updateState.currentVersion || '0.1.18')
834
+ ),
835
+ updateState.checking
836
+ ? React.createElement('span', { style: { color: 'var(--dsw-alias-label-tertiary)', fontSize: 11 } }, tt('update.checking'))
837
+ : updateState.updateAvailable
838
+ ? React.createElement('span', { className: 'cl-badge cl-badge-warn' },
839
+ tt('update.available', { latestVersion: updateState.latestVersion, currentVersion: updateState.currentVersion })
840
+ )
841
+ : React.createElement('span', { className: 'cl-badge cl-badge-ok' },
842
+ '✓ ' + tt('update.up_to_date')
843
+ )
844
+ ),
845
+ updateState.updateAvailable
846
+ ? React.createElement('button', {
847
+ type: 'button',
848
+ className: 'cl-btn cl-btn-primary',
849
+ disabled: updateState.updating,
850
+ onClick: handleTriggerUpdate,
851
+ style: { padding: '3px 8px', fontSize: 11 }
852
+ }, updateState.updating ? tt('update.updating') : tt('update.btn'))
853
+ : null
854
+ ),
855
+ updateState.notice ? React.createElement('div', { className: 'cl-banner-ok', style: { marginBottom: 10, padding: '8px 12px', borderRadius: 8, background: 'color-mix(in srgb, var(--dsw-alias-state-success-primary, #10b981) 8%, transparent)', border: '1px solid var(--dsw-alias-state-success-primary, #10b981)', color: 'var(--dsw-alias-state-success-primary, #10b981)', fontSize: 12 } }, '✓ ' + updateState.notice) : null,
856
+ updateState.error ? React.createElement('div', { className: 'cl-banner-warn', style: { marginBottom: 10 } }, updateState.error) : null,
648
857
  budgetWarn ? React.createElement('div', { className: 'cl-banner-warn', style: { marginBottom: 12 } }, '⚠ ' + tt('lowBudget')) : null,
649
858
  status === 'loading' ? React.createElement('div', { style: { padding: 12, color: 'var(--dsw-alias-label-secondary)' } }, 'Loading…') :
650
859
  status === 'unavailable' ? React.createElement('div', { style: { padding: 12, color: 'var(--dsw-alias-label-secondary)' } }, 'Settings unavailable — plugin not registered on host yet') :
@@ -696,6 +905,14 @@ window.__ModuleLoader__.load({
696
905
  onChange: (e) => setDraft((d) => ({ ...d, budgetLimit: parseInt(e.target.value, 10) || 100000 }))
697
906
  })
698
907
  ),
908
+ React.createElement('div', { className: 'cl-field', style: { display: 'flex', flexDirection: 'column', gap: 6, padding: '8px 0' } },
909
+ React.createElement('label', { style: { fontSize: 13, color: 'var(--dsw-alias-label-secondary)' } }, tt('budgetAlertPercent')),
910
+ React.createElement('input', {
911
+ className: 'cl-input', type: 'number', min: 50, max: 99, step: 1,
912
+ value: draft.budgetAlertPercent,
913
+ onChange: (e) => setDraft((d) => ({ ...d, budgetAlertPercent: parseInt(e.target.value, 10) || 90 }))
914
+ })
915
+ ),
699
916
  React.createElement('div', { className: 'cl-field', style: { display: 'flex', flexDirection: 'column', gap: 6, padding: '8px 0' } },
700
917
  React.createElement('label', { style: { fontSize: 13, color: 'var(--dsw-alias-label-secondary)' } }, tt('threshold')),
701
918
  React.createElement('input', {
@@ -741,9 +958,28 @@ window.__ModuleLoader__.load({
741
958
  );
742
959
  }
743
960
 
744
- module.exports.inject = ['slots', 'locale'];
961
+ module.exports.inject = ['slots', 'locale', 'settingsScope'];
745
962
  module.exports.apply = function apply(ctx) {
746
- try { ctx.locale.register(NS, { en, ru }); } catch (e) { console.warn('[dsh-context-lens] locale register failed', e && e.message || e); }
963
+ let hasEffect = false;
964
+ try {
965
+ hasEffect = typeof ctx.effect === 'function';
966
+ } catch (_) {
967
+ hasEffect = false;
968
+ }
969
+ if (hasEffect) {
970
+ ctx.effect(() => {
971
+ try {
972
+ const dispose = ctx.locale?.register ? ctx.locale.register(NS, { en, zh }) : undefined;
973
+ return () => {
974
+ if (typeof dispose === 'function') dispose();
975
+ };
976
+ } catch (e) {
977
+ console.warn('[dsh-context-lens] locale register failed', e && e.message || e);
978
+ }
979
+ }, 'dsh-context-lens: locale');
980
+ } else {
981
+ try { ctx.locale.register(NS, { en, zh }); } catch (e) { console.warn('[dsh-context-lens] locale register failed', e && e.message || e); }
982
+ }
747
983
  if (!ctx.slots) return;
748
984
  let registered = false;
749
985
  const doRegister = () => {
@@ -819,14 +1055,14 @@ window.__ModuleLoader__.load({
819
1055
  try { registerPaneTab(); } catch (e2) {}
820
1056
  }
821
1057
  } else {
822
- try { registerPaneTab(); } catch (e) {}
1058
+ try { registerPaneTab(); } catch (err) { void err; }
823
1059
  }
824
1060
  }
825
1061
  } catch (e) {
826
1062
  console.warn("[dsh-context-lens] native sidebar registration failed", e && e.message || e);
827
1063
  }
828
1064
  });
829
- } catch (e) {}
1065
+ } catch (err) { void err; }
830
1066
  }
831
1067
 
832
1068
  if (typeof ctx.inject === 'function') {
@@ -851,7 +1087,7 @@ window.__ModuleLoader__.load({
851
1087
  console.warn('[dsh-context-lens] betterSidebar registerTab failed', e && e.message || e);
852
1088
  }
853
1089
  });
854
- } catch (e) {}
1090
+ } catch (err) { void err; }
855
1091
  }
856
1092
 
857
1093
  if (ctx.slots) {
@@ -875,7 +1111,7 @@ window.__ModuleLoader__.load({
875
1111
  try { headerChipRegister(); } catch (e2) {}
876
1112
  }
877
1113
  } else {
878
- try { headerChipRegister(); } catch (e) {}
1114
+ try { headerChipRegister(); } catch (err) { void err; }
879
1115
  }
880
1116
  }
881
1117
  };
package/lib/http.js ADDED
@@ -0,0 +1,114 @@
1
+ export function writeJson(res, code, body) {
2
+ try {
3
+ res.writeHead(code, {
4
+ 'Content-Type': 'application/json; charset=utf-8',
5
+ 'Cache-Control': 'no-store',
6
+ });
7
+ res.end(JSON.stringify(body));
8
+ } catch {
9
+ /* socket already closed */
10
+ }
11
+ }
12
+
13
+ export function readBody(req, maxBytes = 256 * 1024) {
14
+ return new Promise((resolve, reject) => {
15
+ const chunks = [];
16
+ let size = 0;
17
+ req.on('data', (c) => {
18
+ size += c.length;
19
+ if (size > maxBytes) {
20
+ req.destroy();
21
+ reject(new Error('body too large'));
22
+ return;
23
+ }
24
+ chunks.push(c);
25
+ });
26
+ req.on('end', () => resolve(Buffer.concat(chunks)));
27
+ req.on('error', reject);
28
+ });
29
+ }
30
+
31
+ export function header(request, name) {
32
+ const value = request?.headers?.[name.toLowerCase()];
33
+ return Array.isArray(value) ? value[0] : value;
34
+ }
35
+
36
+ export function isLoopbackAddress(value) {
37
+ const address = String(value || '').toLowerCase().replace(/^\[|\]$/g, '');
38
+ return address === 'localhost' || address === 'localhost.' || address === '::1'
39
+ || address.startsWith('127.')
40
+ || address.startsWith('::ffff:127.');
41
+ }
42
+
43
+ /** Reject cross-site writes while allowing local, LAN, and reverse-proxy UIs. */
44
+ export function isTrustedRequest(request) {
45
+ const secFetchSite = header(request, 'sec-fetch-site');
46
+ if (secFetchSite === 'cross-site') {
47
+ return false;
48
+ }
49
+
50
+ const host = header(request, 'x-forwarded-host') || header(request, 'host');
51
+ const origin = header(request, 'origin');
52
+ if (origin) {
53
+ try {
54
+ const url = new URL(origin);
55
+ if (host && url.host.toLowerCase() === host.toLowerCase()) {
56
+ return true;
57
+ }
58
+ if (isLoopbackAddress(url.hostname) && isLoopbackAddress(request?.socket?.remoteAddress)) {
59
+ return true;
60
+ }
61
+ return false;
62
+ } catch {
63
+ return false;
64
+ }
65
+ }
66
+
67
+ const referer = header(request, 'referer');
68
+ if (referer) {
69
+ try {
70
+ const url = new URL(referer);
71
+ if (host && url.host.toLowerCase() === host.toLowerCase()) {
72
+ return true;
73
+ }
74
+ if (isLoopbackAddress(url.hostname) && isLoopbackAddress(request?.socket?.remoteAddress)) {
75
+ return true;
76
+ }
77
+ return false;
78
+ } catch {
79
+ return false;
80
+ }
81
+ }
82
+
83
+ // Requests without origin/referer (e.g. curl, loopback tests, or internal calls)
84
+ if (isLoopbackAddress(request?.socket?.remoteAddress)) {
85
+ return true;
86
+ }
87
+
88
+ if (secFetchSite === 'same-origin' || secFetchSite === 'same-site') {
89
+ return true;
90
+ }
91
+
92
+ return false;
93
+ }
94
+
95
+ export const UPDATE_HEADER = 'x-dsh-plugin-update';
96
+ export const ALT_UPDATE_HEADER = 'x-dsh-context-lens-update';
97
+
98
+ export function isTrustedUpdateRequest(request) {
99
+ const updHdr = header(request, UPDATE_HEADER) || header(request, ALT_UPDATE_HEADER);
100
+ if (updHdr !== '1') return false;
101
+ if (!isLoopbackAddress(request?.socket?.remoteAddress)) return false;
102
+ const site = header(request, 'sec-fetch-site');
103
+ if (site !== undefined && site !== 'same-origin') return false;
104
+ const origin = header(request, 'origin');
105
+ const host = header(request, 'host');
106
+ if (origin === undefined || host === undefined) return false;
107
+ try {
108
+ const url = new URL(origin);
109
+ return (url.protocol === 'http:' || url.protocol === 'https:')
110
+ && isLoopbackAddress(url.hostname) && url.host === host;
111
+ } catch {
112
+ return false;
113
+ }
114
+ }