@dong-victor/dsh-better-sidebar-starter 0.1.0 → 0.1.2

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
@@ -100,6 +100,14 @@ window.__ModuleLoader__.load({
100
100
  function ClipboardIcon({ size }) {
101
101
  return svg(size, `<path d="M19 3h-4.18C14.4 1.84 13.3 1 12 1c-1.3 0-2.4.84-2.82 2H5c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-7 0c.55 0 1 .45 1 1s-.45 1-1 1-1-.45-1-1 .45-1 1-1zm2 14H7v-2h7v2zm3-4H7v-2h10v2zm0-4H7V7h10v2z" fill="currentColor"/>`);
102
102
  }
103
+ /** 📄 Copy — duplicate a run config. */
104
+ function CopyIcon({ size }) {
105
+ return svg(size, `<path d="M21 8c0-1.1-.9-2-2-2h-9c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h9c1.1 0 2-.9 2-2V8z" fill="currentColor"/><path d="M3 16V4c0-1.1.9-2 2-2h10v2H5v12H3z" fill="currentColor" opacity="0.6"/>`);
106
+ }
107
+ /** ✈️ Send — send selected text to the conversation composer. */
108
+ function SendToChatIcon({ size }) {
109
+ return svg(size, `<path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z" fill="currentColor"/>`);
110
+ }
103
111
  /** Get the icon for a config type. */
104
112
  function typeIcon(type, size) {
105
113
  switch (type) {
@@ -141,7 +149,7 @@ window.__ModuleLoader__.load({
141
149
  }
142
150
  /** The config tree component. */
143
151
  function ConfigTree(props) {
144
- const { configs, instances, selectedInstanceId, selectedConfigId, onSelectConfig, onSelectInstance, onStartConfig, onStopInstance, onEditConfig } = props;
152
+ const { configs, instances, selectedInstanceId, selectedConfigId, onSelectConfig, onSelectInstance, onStartConfig, onStopInstance, onEditConfig, onDuplicateConfig } = props;
145
153
  const [collapsed, setCollapsed] = (0, react.useState)(/* @__PURE__ */ new Set());
146
154
  const toggleGroup = (type) => {
147
155
  setCollapsed((prev) => {
@@ -201,7 +209,14 @@ window.__ModuleLoader__.load({
201
209
  e.stopPropagation();
202
210
  onEditConfig(config.id);
203
211
  }
204
- }, GearIcon({ size: 12 }))))];
212
+ }, GearIcon({ size: 12 })), (0, react.createElement)("button", {
213
+ className: "sts-icon-btn dup",
214
+ title: "复制(生成副本配置)",
215
+ onClick: (e) => {
216
+ e.stopPropagation();
217
+ onDuplicateConfig(config.id);
218
+ }
219
+ }, CopyIcon({ size: 12 }))))];
205
220
  for (const inst of configInstances) {
206
221
  const instSelected = selectedInstanceId === inst.id;
207
222
  rows.push((0, react.createElement)("div", {
@@ -324,6 +339,221 @@ window.__ModuleLoader__.load({
324
339
  return ANSI_COLORS[seg.bg] ?? null;
325
340
  }
326
341
  //#endregion
342
+ //#region src/client/logHighlight.ts
343
+ const C = {
344
+ ts: "#8f8f98",
345
+ thread: "#b48ead",
346
+ levelInfo: "#6ab04c",
347
+ levelDebug: "#7a9ce8",
348
+ levelWarn: "#e2b93d",
349
+ levelError: "#f0625f",
350
+ clazz: "#61afef",
351
+ exception: "#ff5f56",
352
+ frame: "#c678dd",
353
+ location: "#56b6c2"
354
+ };
355
+ /** Log level → color. */
356
+ function levelColor(level) {
357
+ switch (level) {
358
+ case "DEBUG":
359
+ case "TRACE": return C.levelDebug;
360
+ case "INFO": return C.levelInfo;
361
+ case "WARN": return C.levelWarn;
362
+ case "ERROR":
363
+ case "FATAL":
364
+ case "OFF": return C.levelError;
365
+ default: return C.levelInfo;
366
+ }
367
+ }
368
+ /** One-pass tokenizer: timestamps, levels, threads, exceptions, stacks, classes. */
369
+ const TOKEN_RE = new RegExp([
370
+ "(?<ts>\\d{4}-\\d{2}-\\d{2}[T ]\\d{2}:\\d{2}:\\d{2}[.,]\\d{1,6}(?:Z|[+-]\\d{2}:?\\d{2})?)",
371
+ "|(?<level>\\b(?:DEBUG|INFO|WARN|ERROR|TRACE|FATAL|OFF)\\b)",
372
+ "|(?<thread>\\[[^\\]\\r\\n]+\\])",
373
+ "|(?<exception>\\bCaused by:|[A-Z]\\w*(?:Exception|Error)(?:\\b|:\\s))",
374
+ "|(?<frame>\\s+at\\s+(?<target>[\\w$./<>]+)(?:\\((?<loc>[^)]*)\\))?)",
375
+ "|(?<location>\\((?<lfile>[\\w$]+\\.(?:java|kt|groovy|scala)):(?<lline>\\d+)(?::\\d+)?\\))",
376
+ "|(?<call>(?<callCls>[A-Za-z_$][\\w$]*(?:\\.[A-Za-z_$][\\w$]*)+)\\.(?<callMethod>[A-Za-z_$][\\w$]*)\\s*\\()",
377
+ "|(?<clazz>[A-Za-z_$][\\w$]*(?:\\.[A-Za-z_$][\\w$]*)+\\b)"
378
+ ].join(""), "g");
379
+ /** The last segment must look like a class name (starts uppercase); field
380
+ * chains such as System.out are skipped. */
381
+ function looksLikeClassName(fqcn) {
382
+ const last = fqcn.split(".").pop() ?? "";
383
+ return last !== "" && /^[A-Z]/.test(last);
384
+ }
385
+ /** Split a stack-frame target `pkg.Cls.method` into class + method. */
386
+ function parseFrameTarget(target) {
387
+ const dot = target.lastIndexOf(".");
388
+ if (dot > 0 && dot < target.length - 1) return {
389
+ className: target.slice(0, dot),
390
+ method: target.slice(dot + 1)
391
+ };
392
+ return {
393
+ className: target,
394
+ method: ""
395
+ };
396
+ }
397
+ /** Parse a `File.java:45` (or `File.java:45:12`) location string. */
398
+ function parseFrameLoc(loc) {
399
+ if (loc === void 0 || loc.trim() === "") return {};
400
+ const parts = loc.trim().split(":");
401
+ if (parts.length < 2) return {};
402
+ const line = Number(parts[1]);
403
+ return {
404
+ file: parts[0],
405
+ line: Number.isInteger(line) && line > 0 ? line : void 0
406
+ };
407
+ }
408
+ /**
409
+ * Tokenize a single log line into highlighted segments.
410
+ * Returns one segment per token; the rest stays plain.
411
+ */
412
+ function highlightLogText(text) {
413
+ const out = [];
414
+ let last = 0;
415
+ TOKEN_RE.lastIndex = 0;
416
+ let m;
417
+ while ((m = TOKEN_RE.exec(text)) !== null) {
418
+ if (m.index > last) out.push({ text: text.slice(last, m.index) });
419
+ const g = m.groups ?? {};
420
+ if (g.ts !== void 0) out.push({
421
+ text: m[0],
422
+ color: C.ts
423
+ });
424
+ else if (g.level !== void 0) out.push({
425
+ text: m[0],
426
+ color: levelColor(g.level),
427
+ bold: true
428
+ });
429
+ else if (g.thread !== void 0) out.push({
430
+ text: m[0],
431
+ color: C.thread
432
+ });
433
+ else if (g.exception !== void 0) out.push({
434
+ text: m[0],
435
+ color: C.exception,
436
+ bold: true
437
+ });
438
+ else if (g.frame !== void 0) {
439
+ const { className, method } = parseFrameTarget(g.target ?? "");
440
+ const { file, line } = parseFrameLoc(g.loc);
441
+ out.push({
442
+ text: m[0],
443
+ color: C.frame,
444
+ link: {
445
+ kind: "frame",
446
+ className,
447
+ method,
448
+ file,
449
+ line
450
+ }
451
+ });
452
+ } else if (g.location !== void 0) out.push({
453
+ text: m[0],
454
+ color: C.location,
455
+ link: {
456
+ kind: "location",
457
+ file: g.lfile,
458
+ line: Number(g.lline)
459
+ }
460
+ });
461
+ else if (g.call !== void 0) {
462
+ const className = g.callCls ?? "";
463
+ out.push({
464
+ text: m[0],
465
+ color: C.clazz,
466
+ link: looksLikeClassName(className) ? {
467
+ kind: "class",
468
+ className,
469
+ method: g.callMethod ?? ""
470
+ } : void 0
471
+ });
472
+ } else if (g.clazz !== void 0) {
473
+ const className = g.clazz;
474
+ out.push({
475
+ text: m[0],
476
+ color: C.clazz,
477
+ link: looksLikeClassName(className) ? {
478
+ kind: "class",
479
+ className
480
+ } : void 0
481
+ });
482
+ } else out.push({ text: m[0] });
483
+ last = m.index + m[0].length;
484
+ }
485
+ if (last < text.length) out.push({ text: text.slice(last) });
486
+ return out;
487
+ }
488
+ //#endregion
489
+ //#region src/client/conversationDraft.ts
490
+ /**
491
+ * Append `text` to the session's composer draft (space-separated, like the
492
+ * @-mentions). Returns false — and warns — when the conversation service
493
+ * or the session scope is unavailable.
494
+ */
495
+ function appendToDraft(ctx, sessionId, text) {
496
+ try {
497
+ const actx = ctx.sessions?.scope(sessionId);
498
+ if (actx === void 0) return false;
499
+ const conversation = ctx.get.bind(ctx)("conversation");
500
+ if (conversation === void 0 || conversation === null) return false;
501
+ const input = conversation.input.for(actx);
502
+ if (input === void 0) return false;
503
+ const draft = input.state.getSnapshot().draft ?? "";
504
+ input.setDraft(draft.trim() === "" ? text : `${draft} ${text}`);
505
+ return true;
506
+ } catch (error) {
507
+ console.warn("[dsh-better-sidebar-starter] draft insert failed:", error);
508
+ return false;
509
+ }
510
+ }
511
+ //#endregion
512
+ //#region src/client/utils.ts
513
+ /**
514
+ * Shared utility functions for the starter plugin.
515
+ * @module dsh-better-sidebar-starter/client/utils
516
+ */
517
+ let toastEl = null;
518
+ let toastTimer = null;
519
+ /** Show a small floating toast notification. */
520
+ function showToast(message, success) {
521
+ if (toastTimer !== null) {
522
+ clearTimeout(toastTimer);
523
+ toastTimer = null;
524
+ }
525
+ if (toastEl !== null) {
526
+ document.body.removeChild(toastEl);
527
+ toastEl = null;
528
+ }
529
+ const el = document.createElement("div");
530
+ el.textContent = message;
531
+ el.style.cssText = `
532
+ position: fixed;
533
+ top: 16px;
534
+ left: 50%;
535
+ transform: translateX(-50%);
536
+ padding: 8px 16px;
537
+ background: ${success ? "rgba(78,154,6,0.9)" : "rgba(235,87,87,0.9)"};
538
+ color: #fff;
539
+ font-size: 12px;
540
+ border-radius: 6px;
541
+ z-index: 999999;
542
+ box-shadow: 0 2px 12px rgba(0,0,0,0.4);
543
+ pointer-events: none;
544
+ font-family: ui-monospace, SFMono-Regular, "Cascadia Mono", "Cascadia Code", Menlo, Consolas, monospace;
545
+ `;
546
+ document.body.appendChild(el);
547
+ toastEl = el;
548
+ toastTimer = setTimeout(() => {
549
+ if (toastEl !== null) {
550
+ document.body.removeChild(toastEl);
551
+ toastEl = null;
552
+ }
553
+ toastTimer = null;
554
+ }, 2e3);
555
+ }
556
+ //#endregion
327
557
  //#region src/client/LogView.tsx
328
558
  /**
329
559
  * Log view: the right pane showing real-time log output from a running
@@ -331,32 +561,126 @@ window.__ModuleLoader__.load({
331
561
  * renders log lines with auto-scroll.
332
562
  * @module dsh-better-sidebar-starter/client/LogView
333
563
  */
334
- /** Render ANSI-colored text segments. */
335
- function renderAnsiLine(text, keyBase) {
336
- return parseAnsi(text).map((seg, i) => {
337
- const style = {};
564
+ /** API base. */
565
+ const API_BASE$1 = "/api/dsh-better-sidebar-starter";
566
+ /** Render ANSI-colored text segments, applying Java-log syntax highlighting
567
+ * to text the program did not color itself (ANSI-colored spans keep the
568
+ * program's own colors). Tokens with a source link become clickable. */
569
+ function renderAnsiLine(text, keyBase, onLinkClick) {
570
+ const segments = parseAnsi(text);
571
+ const nodes = [];
572
+ let hlCounter = 0;
573
+ for (let i = 0; i < segments.length; i++) {
574
+ const seg = segments[i];
338
575
  const fg = segmentFgColor(seg);
339
576
  const bg = segmentBgColor(seg);
340
- if (fg !== null) style.color = fg;
341
- if (bg !== null) style.backgroundColor = bg;
342
- if (seg.bold) style.fontWeight = "bold";
343
- if (seg.dim) style.opacity = "0.6";
344
- if (seg.italic) style.fontStyle = "italic";
345
- if (seg.underline) style.textDecoration = "underline";
346
- return (0, react.createElement)("span", {
347
- key: `${keyBase}-${i}`,
348
- style
349
- }, seg.text);
350
- });
577
+ const baseStyle = {};
578
+ if (seg.bold) baseStyle.fontWeight = "bold";
579
+ if (seg.dim) baseStyle.opacity = "0.6";
580
+ if (seg.italic) baseStyle.fontStyle = "italic";
581
+ if (seg.underline) baseStyle.textDecoration = "underline";
582
+ if (fg !== null || bg !== null) {
583
+ const style = { ...baseStyle };
584
+ if (fg !== null) style.color = fg;
585
+ if (bg !== null) style.backgroundColor = bg;
586
+ nodes.push((0, react.createElement)("span", {
587
+ key: `${keyBase}-${i}`,
588
+ style
589
+ }, seg.text));
590
+ } else {
591
+ const hl = highlightLogText(seg.text);
592
+ for (const h of hl) {
593
+ const style = { ...baseStyle };
594
+ if (h.color !== void 0) style.color = h.color;
595
+ if (h.bold === true) style.fontWeight = "bold";
596
+ nodes.push((0, react.createElement)("span", {
597
+ key: `${keyBase}-${i}-hl${hlCounter++}`,
598
+ className: h.link !== void 0 ? "sts-log-link" : void 0,
599
+ style,
600
+ onClick: h.link !== void 0 ? (e) => {
601
+ e.stopPropagation();
602
+ onLinkClick(h.link);
603
+ } : void 0
604
+ }, h.text));
605
+ }
606
+ }
607
+ }
608
+ return nodes;
351
609
  }
352
610
  /** The log view component. */
353
- function LogView({ instance, onStop, onRestart }) {
611
+ function LogView({ instance, ctx, sessionId, cwd, onStop, onRestart }) {
354
612
  const [logLines, setLogLines] = (0, react.useState)([]);
355
613
  const [autoScroll, setAutoScroll] = (0, react.useState)(true);
356
614
  const [exitInfo, setExitInfo] = (0, react.useState)(null);
615
+ const [selLength, setSelLength] = (0, react.useState)(0);
357
616
  const contentRef = (0, react.useRef)(null);
358
617
  const wsRef = (0, react.useRef)(null);
359
618
  const lineKeyCounter = (0, react.useRef)(0);
619
+ (0, react.useEffect)(() => {
620
+ const updateSel = () => {
621
+ const sel = window.getSelection();
622
+ const len = sel !== null && !sel.isCollapsed ? sel.toString().trim().length : 0;
623
+ setSelLength(len);
624
+ };
625
+ document.addEventListener("selectionchange", updateSel);
626
+ return () => document.removeEventListener("selectionchange", updateSel);
627
+ }, []);
628
+ /** Send the currently selected log text into the conversation composer. */
629
+ const handleSendSelection = () => {
630
+ const sel = window.getSelection();
631
+ const text = sel !== null && !sel.isCollapsed ? sel.toString().trim() : "";
632
+ if (text === "") {
633
+ showToast("请先在日志中选中文本", false);
634
+ return;
635
+ }
636
+ if (appendToDraft(ctx, sessionId, text)) showToast(`已将选中文本(${text.length} 字符)发送到对话`, true);
637
+ else showToast("发送失败:对话服务不可用", false);
638
+ };
639
+ /** Open the source file behind a highlighted class / frame / location token. */
640
+ const handleCodeClick = async (link) => {
641
+ const payload = { root: instance.cwd };
642
+ if (link.kind === "class" || link.kind === "frame") payload.className = link.className;
643
+ if (link.file !== void 0) payload.file = link.file;
644
+ if (link.method !== void 0 && link.method !== "<init>" && link.method !== "<clinit>") payload.method = link.method;
645
+ if (link.line !== void 0) payload.line = link.line;
646
+ try {
647
+ const params = new URLSearchParams({ sessionId });
648
+ if (cwd !== void 0 && cwd !== "") params.set("cwd", cwd);
649
+ const resp = await fetch(`${API_BASE$1}/locate-source?${params.toString()}`, {
650
+ method: "POST",
651
+ headers: { "content-type": "application/json" },
652
+ body: JSON.stringify(payload)
653
+ });
654
+ if (!resp.ok) {
655
+ let reason = "定位源码失败";
656
+ try {
657
+ const err = await resp.json();
658
+ if (typeof err.error === "string" && err.error !== "") reason = err.error;
659
+ } catch {}
660
+ showToast(reason, false);
661
+ return;
662
+ }
663
+ const data = await resp.json();
664
+ if (data.ok !== true || typeof data.path !== "string") {
665
+ showToast(data.reason ?? "未找到源文件", false);
666
+ return;
667
+ }
668
+ const base = data.path.split(/[\\/]/).pop() ?? data.path;
669
+ const line = typeof data.line === "number" ? data.line : void 0;
670
+ const title = line !== void 0 ? `${base} : ${line}` : base;
671
+ const betterSidebar = ctx.betterSidebar;
672
+ if (betterSidebar !== void 0) betterSidebar.openFile({
673
+ sessionId,
674
+ cwd
675
+ }, data.path, title);
676
+ try {
677
+ navigator.clipboard?.writeText(`${base}${line !== void 0 ? `:${line}` : ""}`);
678
+ } catch {}
679
+ showToast(line !== void 0 ? `已打开 ${base},定位到第 ${line} 行(已复制 ${base}:${line})` : `已打开 ${base}`, true);
680
+ } catch {
681
+ showToast("定位源码失败", false);
682
+ }
683
+ };
360
684
  const lineBuffer = (0, react.useRef)({ current: [] });
361
685
  /** Flush the buffer into a new log line. */
362
686
  const flushLine = (0, react.useCallback)(() => {
@@ -438,15 +762,19 @@ window.__ModuleLoader__.load({
438
762
  className: "sts-icon-btn stop",
439
763
  title: "停止",
440
764
  onClick: onStop
441
- }, StopIcon({ size: 14 }), " 停止") : null, (0, react.createElement)("button", {
765
+ }, StopIcon({ size: 14 }), (0, react.createElement)("span", { className: "sts-icon-btn-label" }, "停止")) : null, (0, react.createElement)("button", {
442
766
  className: "sts-icon-btn",
443
767
  title: "重启",
444
768
  onClick: onRestart
445
- }, RestartIcon({ size: 14 }), " 重启"), (0, react.createElement)("button", {
769
+ }, RestartIcon({ size: 14 }), (0, react.createElement)("span", { className: "sts-icon-btn-label" }, "重启")), (0, react.createElement)("button", {
446
770
  className: "sts-icon-btn",
447
771
  title: "清空",
448
772
  onClick: handleClear
449
- }, TrashIcon({ size: 14 }), " 清空"), (0, react.createElement)("label", {
773
+ }, TrashIcon({ size: 14 }), (0, react.createElement)("span", { className: "sts-icon-btn-label" }, "清空")), (0, react.createElement)("button", {
774
+ className: "sts-icon-btn send",
775
+ title: selLength > 0 ? `发送选中的 ${selLength} 字符到对话` : "发送选中的文本到对话",
776
+ onClick: handleSendSelection
777
+ }, SendToChatIcon({ size: 14 }), (0, react.createElement)("span", { className: "sts-icon-btn-label" }, selLength > 0 ? `发送选中(${selLength})` : "发送选中")), (0, react.createElement)("label", {
450
778
  className: "sts-log-toolbar-label",
451
779
  onClick: () => setAutoScroll((v) => !v)
452
780
  }, autoScroll ? "☑" : "☐", " 自动滚动")), (0, react.createElement)("div", {
@@ -462,7 +790,9 @@ window.__ModuleLoader__.load({
462
790
  return (0, react.createElement)("span", {
463
791
  key: segKey,
464
792
  className: entry.stream === "stderr" ? "sts-log-stderr" : ""
465
- }, renderAnsiLine(entry.text, segKey));
793
+ }, renderAnsiLine(entry.text, segKey, (link) => {
794
+ handleCodeClick(link);
795
+ }));
466
796
  }));
467
797
  }), exitInfo !== null ? (0, react.createElement)("div", {
468
798
  key: "exit-info",
@@ -487,7 +817,7 @@ window.__ModuleLoader__.load({
487
817
  custom: "Custom"
488
818
  };
489
819
  /** The detail card component. */
490
- function ConfigDetail({ config, onStart, onEdit, onDelete }) {
820
+ function ConfigDetail({ config, onStart, onEdit, onDuplicate, onDelete }) {
491
821
  const envEntries = Object.entries(config.env);
492
822
  const dateFmt = (ts) => {
493
823
  if (ts === null) return "—";
@@ -500,6 +830,10 @@ window.__ModuleLoader__.load({
500
830
  className: "sts-detail-btn danger",
501
831
  onClick: onDelete
502
832
  }, (0, react.createElement)(TrashIcon, { size: 14 }), " 删除"), (0, react.createElement)("button", {
833
+ className: "sts-detail-btn",
834
+ onClick: onDuplicate,
835
+ title: "复制为副本(保留全部信息)"
836
+ }, (0, react.createElement)(CopyIcon, { size: 14 }), " 复制"), (0, react.createElement)("button", {
503
837
  className: "sts-detail-btn",
504
838
  onClick: onEdit
505
839
  }, (0, react.createElement)(GearIcon, { size: 14 }), " 编辑"), (0, react.createElement)("button", {
@@ -1324,6 +1658,30 @@ window.__ModuleLoader__.load({
1324
1658
  .sts-icon-btn.stop { color: #eb5757; }
1325
1659
  .sts-icon-btn.gear { color: var(--dsw-alias-label-secondary, #b0b0b0); }
1326
1660
  .sts-icon-btn.trash { color: #eb5757; }
1661
+ .sts-icon-btn.dup { color: var(--dsw-alias-label-secondary, #b0b0b0); }
1662
+ .sts-icon-btn.send { color: #4a90d9; }
1663
+ /* Toolbar buttons (log view) are icon-only; the label expands on hover. */
1664
+ .sts-log-toolbar .sts-icon-btn {
1665
+ width: 26px;
1666
+ height: 26px;
1667
+ padding: 0 6px;
1668
+ gap: 0;
1669
+ font-size: 11px;
1670
+ opacity: 1;
1671
+ }
1672
+ .sts-log-toolbar .sts-icon-btn .sts-icon-btn-label {
1673
+ display: inline-block;
1674
+ max-width: 0;
1675
+ overflow: hidden;
1676
+ white-space: nowrap;
1677
+ opacity: 0;
1678
+ transition: max-width 0.18s ease, opacity 0.18s ease, margin-left 0.18s ease;
1679
+ }
1680
+ .sts-log-toolbar .sts-icon-btn:hover .sts-icon-btn-label {
1681
+ max-width: 120px;
1682
+ margin-left: 4px;
1683
+ opacity: 1;
1684
+ }
1327
1685
 
1328
1686
  /* ===== Instance row ===== */
1329
1687
  .sts-instance-row {
@@ -1411,6 +1769,16 @@ window.__ModuleLoader__.load({
1411
1769
  .sts-log-line {
1412
1770
  min-height: 1em;
1413
1771
  }
1772
+ /* Clickable source tokens (classes / stack frames) inside the log. */
1773
+ .sts-log-link {
1774
+ cursor: pointer;
1775
+ border-bottom: 1px dashed rgba(97, 175, 239, 0.4);
1776
+ transition: background 0.1s ease;
1777
+ }
1778
+ .sts-log-link:hover {
1779
+ background: rgba(97, 175, 239, 0.14);
1780
+ border-bottom-color: #61afef;
1781
+ }
1414
1782
 
1415
1783
  /* ===== Config detail ===== */
1416
1784
  .sts-detail {
@@ -2168,6 +2536,34 @@ window.__ModuleLoader__.load({
2168
2536
  });
2169
2537
  setShowModal(true);
2170
2538
  };
2539
+ /**
2540
+ * Duplicate a config: open the editor prefilled with ALL of the original
2541
+ * config's info under an auto-generated "xxx-副本" name (deduped against
2542
+ * existing names). Saving it (id omitted) creates a brand-new config.
2543
+ */
2544
+ const handleDuplicateConfig = (configId) => {
2545
+ const config = configs.find((c) => c.id === configId);
2546
+ if (config === void 0) return;
2547
+ const stem = config.name.endsWith("-副本") ? config.name.slice(0, -3) : config.name;
2548
+ const taken = new Set(configs.map((c) => c.name));
2549
+ let dupName = `${stem}-副本`;
2550
+ let suffix = 2;
2551
+ while (taken.has(dupName)) {
2552
+ dupName = `${stem}-副本${suffix}`;
2553
+ suffix++;
2554
+ }
2555
+ setEditingConfig({
2556
+ name: dupName,
2557
+ type: config.type,
2558
+ command: config.command,
2559
+ cwd: config.cwd,
2560
+ env: config.env,
2561
+ jvmArgs: config.jvmArgs,
2562
+ args: config.args,
2563
+ runtime: config.runtime
2564
+ });
2565
+ setShowModal(true);
2566
+ };
2171
2567
  /** Save a config (create or update). */
2172
2568
  const handleSaveConfig = (0, react.useCallback)(async (config) => {
2173
2569
  try {
@@ -2260,12 +2656,16 @@ window.__ModuleLoader__.load({
2260
2656
  onStopInstance: (id) => {
2261
2657
  handleStop(id);
2262
2658
  },
2263
- onEditConfig: handleEditConfig
2659
+ onEditConfig: handleEditConfig,
2660
+ onDuplicateConfig: handleDuplicateConfig
2264
2661
  })), (0, react.createElement)("div", {
2265
2662
  className: `sts-split-gutter${dragging ? " dragging" : ""}${isHorizontal ? "" : " sts-split-gutter-v"}`,
2266
2663
  onMouseDown: handleMouseDown
2267
2664
  }), (0, react.createElement)("div", { className: `sts-split-second${isHorizontal ? "" : " sts-split-second-v"}` }, selectedInstance !== null ? (0, react.createElement)(LogView, {
2268
2665
  instance: selectedInstance,
2666
+ ctx: props.ctx,
2667
+ sessionId: scope.sessionId ?? store.getSnapshot().sessionId ?? "",
2668
+ cwd: scope.cwd,
2269
2669
  onStop: () => {
2270
2670
  handleStop(selectedInstance.id);
2271
2671
  },
@@ -2278,6 +2678,7 @@ window.__ModuleLoader__.load({
2278
2678
  handleStart(selectedConfig.id);
2279
2679
  },
2280
2680
  onEdit: () => handleEditConfig(selectedConfig.id),
2681
+ onDuplicate: () => handleDuplicateConfig(selectedConfig.id),
2281
2682
  onDelete: () => {
2282
2683
  handleDeleteConfig(selectedConfig.id);
2283
2684
  }