@cowwo/dsh-lookout 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/README.md CHANGED
@@ -8,7 +8,7 @@
8
8
 
9
9
  一句话:**瞭望哨:盯着你的每个会话——谁跑完了、谁出错了、谁卡住了、谁在等你审批,用浏览器图标角标、红点/黄点和邮件通知你;顺带显示当前会话 ID。**
10
10
 
11
- ## 三块功能
11
+ ## 功能
12
12
 
13
13
  ### 1. 换图标与角标
14
14
 
@@ -22,6 +22,16 @@
22
22
 
23
23
  在会话头部右侧显示当前会话的完整 ID;复制时会净化剪贴板内容,避免选区夹带多余换行。
24
24
 
25
+ ### 4. 文件行 ⋯ 菜单
26
+
27
+ 文件面板里每个文件 / 文件夹行的右侧多出一个「⋯」按钮,点开可以:
28
+
29
+ - **复制路径**:把这一行的**绝对路径**原样放进剪贴板(不是屏幕上显示的短名)。
30
+ - **下载**:下载单个文件,上限 20 MB;超过会提示「文件超过 20MB,暂不支持下载」。
31
+ - **文件夹**:菜单仍可打开,但「下载」是灰的,并标注「文件夹暂不支持」。
32
+
33
+ 设置页里有「文件行 ⋯ 菜单」开关,可以整体关掉。这个菜单是贴着宿主文件树画上去的:DSH 升级换了文件面板结构时,它可能不再出现,但**文件树本身不受影响**;连续 3 次注入失败会自动停止,并且只提示一次(可点「不再提示」)。
34
+
25
35
  ## 怎么用
26
36
 
27
37
  打开 DSH 设置页(Web GUI 的设置 / Settings),找到 **瞭望哨(Lookout)** 这一项,里面有:
@@ -30,6 +40,7 @@
30
40
  - 未读规则、进行中规则的阈值
31
41
  - 邮件通知:SMTP 主机、端口、账号、密码、收件人,以及「发一封测试邮件」按钮
32
42
  - 会话 ID 显示开关
43
+ - 文件行 ⋯ 菜单开关
33
44
 
34
45
  ## 数据存在哪
35
46
 
package/client/client.js CHANGED
@@ -7300,6 +7300,878 @@ window.__ModuleLoader__.load({
7300
7300
  } finally {
7301
7301
  __loader.load = __origLoad;
7302
7302
  }
7303
+
7304
+ //#region file row menu
7305
+ /**
7306
+ * The file tree's "⋯" row menu: copy a row's absolute path, download a file.
7307
+ *
7308
+ * This region lives in the merge glue rather than inside one of the three
7309
+ * bundled parts because those parts are byte-locked by
7310
+ * test/merged-client.test.mjs (each must stay a byte-for-byte copy of its
7311
+ * original bundle). It is still one self-contained unit: a single start/stop
7312
+ * pair, no React for the menu itself, and nothing but DOM writes into the
7313
+ * shipped file tree.
7314
+ *
7315
+ * Host contract (read-only reference: @deepseek-ai/dsh-client-ui-sidebar-files):
7316
+ * [data-files-body] li[data-files-entry="file"|"directory"][data-files-path]
7317
+ * > button.row
7318
+ * The absolute path is ALWAYS read from data-files-path; it is never rebuilt
7319
+ * from the row text. Rows of type "other" are left alone. The menu button is
7320
+ * inserted as a SIBLING after that row button, never inside it: a nested
7321
+ * button is invalid HTML and its click would bubble into "open this file".
7322
+ *
7323
+ * Every entry point is guarded. A selector that stops matching after a host
7324
+ * upgrade can only cost these buttons, never the host's own rendering; three
7325
+ * consecutive scans that place nothing at all stop the feature for the page.
7326
+ */
7327
+ const ROW_MENU_NS = "dsh-lookout";
7328
+ /** Sibling button injected after the row button; its presence is the idempotency key. */
7329
+ const ROW_MENU_ATTR = "data-lookout-rowmenu";
7330
+ const ROW_MENU_POP_ATTR = "data-lookout-rowmenu-pop";
7331
+ const ROW_MENU_TOAST_ATTR = "data-lookout-toast";
7332
+ const ROW_MENU_STYLE_KEY = "lookout-row-menu";
7333
+ const ROW_MENU_BODY_SELECTOR = "[data-files-body]";
7334
+ const ROW_MENU_ROWS_SELECTOR = 'li[data-files-entry="file"],li[data-files-entry="directory"]';
7335
+ const ROW_MENU_PATH_ATTR = "data-files-path";
7336
+ /** A run that places nothing at all, this many times in a row, gives up. */
7337
+ const ROW_MENU_MAX_FAILURES = 3;
7338
+ const ROW_MENU_SCAN_DEBOUNCE = 200;
7339
+ const ROW_MENU_QUIET_MS = 250;
7340
+ const ROW_MENU_FEEDBACK_MS = 1500;
7341
+ const ROW_MENU_TOAST_MS = 3000;
7342
+ /**
7343
+ * How long after opening the menu a scroll/resize counts as part of the tap.
7344
+ *
7345
+ * Phones fire scroll and resize while the tap is still settling (visual viewport
7346
+ * shifts, focus-driven scrolling, the browser chrome collapsing). Closing on those
7347
+ * made the menu flash and vanish on mobile, so inside this window the menu only
7348
+ * repositions; a later, real scroll closes it as usual.
7349
+ */
7350
+ const ROW_MENU_SCROLL_GRACE_MS = 700;
7351
+ const ROW_MENU_BUTTON_PADDING = "32px";
7352
+ const ROW_MENU_ENABLED_KEY = "dsh-lookout:file-row-menu:enabled";
7353
+ const ROW_MENU_DISMISSED_KEY = "dsh-lookout:file-row-menu:gave-up-dismissed";
7354
+
7355
+ /** Menu copy, one namespace of its own so it cannot collide with the three parts. */
7356
+ const ROW_MENU_ZH = {
7357
+ nav: "文件行 ⋯ 菜单",
7358
+ toggleLabel: "在文件树的每一行右侧显示「⋯」菜单",
7359
+ toggleHint: "点开可以复制这一行的绝对路径,或下载单个文件;文件夹的下载暂不支持。这一项贴着宿主文件树绘制,宿主升级后可能失效,失效只是不显示,文件树本身不受影响。",
7360
+ ariaLabel: "行操作",
7361
+ copyPath: "复制路径",
7362
+ download: "下载",
7363
+ folderUnsupported: "文件夹暂不支持",
7364
+ copyFailed: "复制失败,请手动选择路径",
7365
+ err401: "登录已过期,请刷新页面重新登录",
7366
+ err413: "文件超过 20MB,暂不支持下载",
7367
+ err404: "文件不存在或已被删除",
7368
+ err403: "没有权限读取这个文件",
7369
+ errOther: "下载失败(HTTP {status})",
7370
+ errNetwork: "下载失败,请检查连接",
7371
+ givingUp: "文件行「⋯」菜单连续注入失败,已自动停止;文件树本身不受影响。",
7372
+ dismiss: "不再提示"
7373
+ };
7374
+ const ROW_MENU_EN = {
7375
+ nav: "File row ⋯ menu",
7376
+ toggleLabel: "Show a ⋯ menu on every file-tree row",
7377
+ toggleHint: "Open it to copy the row's absolute path or download a single file; folder downloads are not supported. This is drawn against the shipped file tree, so a host upgrade may disable it — it only disappears, and the tree itself is unaffected.",
7378
+ ariaLabel: "Row actions",
7379
+ copyPath: "Copy path",
7380
+ download: "Download",
7381
+ folderUnsupported: "Folders not supported",
7382
+ copyFailed: "Copy failed — select the path and copy it manually",
7383
+ err401: "Your session has expired. Refresh the page and sign in again.",
7384
+ err413: "This file is larger than 20 MB and cannot be downloaded yet.",
7385
+ err404: "The file no longer exists or was deleted.",
7386
+ err403: "You do not have permission to read this file.",
7387
+ errOther: "Download failed (HTTP {status}).",
7388
+ errNetwork: "Download failed — check your connection.",
7389
+ givingUp: "The file-row ⋯ menu could not be injected repeatedly and stopped; the file tree itself is unaffected.",
7390
+ dismiss: "Do not show again"
7391
+ };
7392
+
7393
+ /**
7394
+ * Only theme variables that really exist in the shipped theme are used.
7395
+ * No literal colour is written here, so light/dark follows the host.
7396
+ */
7397
+ const ROW_MENU_CSS = [
7398
+ ".dsh-lookout-rowmenu{position:absolute;top:4px;right:4px;z-index:2;display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;padding:0;border:0;border-radius:6px;background:transparent;color:var(--dsw-alias-label-tertiary);cursor:pointer;line-height:1;touch-action:manipulation;-webkit-tap-highlight-color:transparent;}",
7399
+ ".dsh-lookout-rowmenu:hover,.dsh-lookout-rowmenu:focus-visible{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-interactive-bg-hover);outline:none;}",
7400
+ ".dsh-lookout-rowmenu svg{display:block;width:16px;height:16px;pointer-events:none;}",
7401
+ ".dsh-lookout-rowmenu[data-lookout-copied]{color:var(--dsw-alias-state-success-primary);}",
7402
+ ".dsh-lookout-rowmenu-pop{position:fixed;z-index:2147482000;min-width:176px;padding:4px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-bg-overlay);color:var(--dsw-alias-label-primary);font-size:13px;line-height:1.4;box-shadow:0 8px 24px var(--dsw-alias-bg-mask-drop);}",
7403
+ ".dsh-lookout-rowmenu-item{display:flex;align-items:center;justify-content:space-between;gap:10px;width:100%;padding:7px 10px;border:0;border-radius:6px;background:transparent;color:inherit;font:inherit;text-align:left;cursor:pointer;touch-action:manipulation;-webkit-tap-highlight-color:transparent;}",
7404
+ ".dsh-lookout-rowmenu-item:hover,.dsh-lookout-rowmenu-item:focus-visible{background:var(--dsw-alias-interactive-bg-hover);outline:none;}",
7405
+ ".dsh-lookout-rowmenu-item[aria-disabled=\"true\"]{color:var(--dsw-alias-label-dimmed);cursor:not-allowed;}",
7406
+ ".dsh-lookout-rowmenu-item[aria-disabled=\"true\"]:hover{background:transparent;}",
7407
+ ".dsh-lookout-rowmenu-label{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}",
7408
+ ".dsh-lookout-rowmenu-note{flex:none;color:var(--dsw-alias-label-tertiary);font-size:11px;white-space:nowrap;}",
7409
+ ".dsh-lookout-toast{position:fixed;left:50%;bottom:24px;z-index:90;display:flex;align-items:center;gap:10px;max-width:min(92vw,560px);padding:9px 12px;transform:translateX(-50%);border:1px solid var(--dsw-alias-border-l2);border-radius:10px;background:var(--dsw-alias-toast-bg);color:var(--dsw-alias-label-primary-inverted);font-size:12px;line-height:1.5;box-shadow:0 8px 24px var(--dsw-alias-bg-mask-drop);}",
7410
+ ".dsh-lookout-toast-text{min-width:0;}",
7411
+ ".dsh-lookout-toast button{flex:none;padding:3px 8px;border:1px solid var(--dsw-alias-border-l2);border-radius:6px;background:transparent;color:inherit;font:inherit;cursor:pointer;}",
7412
+ ".dsh-lookout-toast button:hover{background:var(--dsw-alias-interactive-bg-hover);}"
7413
+ ].join("\n");
7414
+
7415
+ /** Chinese fallback for every translate call, also used when locale is unavailable. */
7416
+ function rowMenuFallbackText(key, params) {
7417
+ const template = typeof ROW_MENU_ZH[key] === "string" ? ROW_MENU_ZH[key] : key;
7418
+ if (params === null || params === undefined || typeof params !== "object") return template;
7419
+ let text = template;
7420
+ for (const name of Object.keys(params)) text = text.split("{" + name + "}").join(String(params[name]));
7421
+ return text;
7422
+ }
7423
+
7424
+ /** Read one persisted switch; storage being unavailable must never break the feature. */
7425
+ function rowMenuReadFlag(key, fallback) {
7426
+ try {
7427
+ const raw = window.localStorage.getItem(key);
7428
+ if (raw === "1") return true;
7429
+ if (raw === "0") return false;
7430
+ return fallback;
7431
+ } catch { return fallback; }
7432
+ }
7433
+
7434
+ /** Persist one switch; a private-mode storage failure only costs durability. */
7435
+ function rowMenuWriteFlag(key, value) {
7436
+ try { window.localStorage.setItem(key, value ? "1" : "0"); } catch { /* advisory */ }
7437
+ }
7438
+
7439
+ /** One warning per page at most, and only for a real degradation. */
7440
+ let rowMenuWarned = false;
7441
+ function rowMenuWarn(message, error) {
7442
+ if (rowMenuWarned) return;
7443
+ rowMenuWarned = true;
7444
+ try { console.warn("dsh-lookout: " + message, error); } catch { /* no console */ }
7445
+ }
7446
+
7447
+ /**
7448
+ * Start the row menu and hand its disposer to the plugin's own lifecycle.
7449
+ * A separate wrapper keeps the whole install guarded: this UI may never throw
7450
+ * into the host's apply().
7451
+ * @param ctx - the merged plugin's client context.
7452
+ * @param require - the ModuleLoader require, used lazily for primitives/react.
7453
+ * @returns {void}
7454
+ */
7455
+ function rowMenuStart(ctx, require) {
7456
+ let dispose = null;
7457
+ try {
7458
+ dispose = rowMenuInstall(ctx, require);
7459
+ } catch (error) {
7460
+ rowMenuWarn("文件行菜单初始化失败,已跳过(不影响宿主):", error);
7461
+ return;
7462
+ }
7463
+ try {
7464
+ if (ctx !== null && ctx !== undefined && typeof ctx.effect === "function") {
7465
+ ctx.effect(() => dispose, "dsh-lookout: file row menu");
7466
+ } else if (ctx !== null && ctx !== undefined && typeof ctx.on === "function") {
7467
+ ctx.on("dispose", () => { try { dispose(); } catch { /* best effort */ } });
7468
+ }
7469
+ } catch (error) {
7470
+ rowMenuWarn("文件行菜单挂到生命周期失败:", error);
7471
+ }
7472
+ }
7473
+
7474
+ /**
7475
+ * Install the feature and return its teardown.
7476
+ * @param ctx - the merged plugin's client context.
7477
+ * @param require - the ModuleLoader require.
7478
+ * @returns {() => void} disposer: removes every node, restores every style.
7479
+ */
7480
+ function rowMenuInstall(ctx, require) {
7481
+ let disposed = false;
7482
+ let enabled = rowMenuReadFlag(ROW_MENU_ENABLED_KEY, true);
7483
+ let translate = rowMenuFallbackText;
7484
+ let observer = null;
7485
+ let retryTimer = 0;
7486
+ let scanTimer = 0;
7487
+ let painting = false;
7488
+ let paintedAt = 0;
7489
+ let failures = 0;
7490
+ let givenUp = false;
7491
+ let menu = null;
7492
+ let menuAnchor = null;
7493
+ let menuRow = null;
7494
+ let menuPath = "";
7495
+ let menuOpenedAt = 0;
7496
+ let toast = null;
7497
+ let toastTimer = 0;
7498
+ const feedbackTimers = new Map();
7499
+ const savedRowPositions = new Map();
7500
+ const savedButtonPadding = new Map();
7501
+
7502
+ // ---- locale ---------------------------------------------------------
7503
+ try {
7504
+ const locale = ctx !== null && ctx !== undefined ? ctx.locale : null;
7505
+ if (locale !== null && locale !== undefined && typeof locale.register === "function") {
7506
+ const register = () => locale.register(ROW_MENU_NS, { zh: ROW_MENU_ZH, en: ROW_MENU_EN });
7507
+ if (typeof ctx.effect === "function") ctx.effect(register, "dsh-lookout: file row menu dictionaries");
7508
+ else register();
7509
+ }
7510
+ if (locale !== null && locale !== undefined && typeof locale.bind === "function") {
7511
+ const bound = locale.bind(ROW_MENU_NS);
7512
+ translate = (key, params) => {
7513
+ let text = key;
7514
+ try { text = bound(key, params); } catch { text = key; }
7515
+ return typeof text === "string" && text !== key ? text : rowMenuFallbackText(key, params);
7516
+ };
7517
+ }
7518
+ } catch (error) {
7519
+ // Chinese fallback is already in translate.
7520
+ }
7521
+
7522
+ // ---- settings page --------------------------------------------------
7523
+ // One item, its own section: the three bundled parts own their own pages and
7524
+ // are byte-locked, so this feature registers its own settings.section (order
7525
+ // 55, between icon management at 50 and mail at 60).
7526
+ try {
7527
+ const React = require("react");
7528
+ if (React !== null && typeof React.createElement === "function" && typeof React.useState === "function") {
7529
+ function RowMenuSection(props) {
7530
+ const label = props !== null && props !== undefined && typeof props.t === "function" ? props.t : translate;
7531
+ const [checked, setChecked] = React.useState(() => rowMenuReadFlag(ROW_MENU_ENABLED_KEY, true));
7532
+ const onChange = (event) => {
7533
+ const next = event.target.checked === true;
7534
+ setChecked(next);
7535
+ setEnabled(next);
7536
+ };
7537
+ return React.createElement("div", { "data-lookout-settings": "row-menu" },
7538
+ React.createElement("div", { style: { fontSize: 13, fontWeight: 600, color: "var(--dsw-alias-label-primary)", marginBottom: 8 } }, label("nav")),
7539
+ React.createElement("label", { style: { display: "inline-flex", alignItems: "center", gap: 6, fontSize: 12, color: "var(--dsw-alias-label-primary)", cursor: "pointer" } },
7540
+ React.createElement("input", { type: "checkbox", checked, onChange }),
7541
+ React.createElement("span", null, label("toggleLabel"))
7542
+ ),
7543
+ React.createElement("div", { style: { marginTop: 6, fontSize: 12, lineHeight: 1.6, color: "var(--dsw-alias-label-tertiary)" } }, label("toggleHint"))
7544
+ );
7545
+ }
7546
+ const slots = ctx !== null && ctx !== undefined ? ctx.slots : null;
7547
+ if (slots !== null && slots !== undefined && typeof slots.inject === "function" && typeof slots.register === "function") {
7548
+ slots.inject("settings.section", () => slots.register({
7549
+ name: "settings.section",
7550
+ id: "lookout-row-menu",
7551
+ order: 55,
7552
+ label: () => translate("nav"),
7553
+ locale: ROW_MENU_NS,
7554
+ inject: () => ({ t: translate })
7555
+ }, (props) => React.createElement(RowMenuSection, {
7556
+ t: props !== null && props !== undefined && typeof props.t === "function" ? props.t : translate
7557
+ })));
7558
+ }
7559
+ }
7560
+ } catch (error) {
7561
+ // Settings are optional; the feature itself is not.
7562
+ }
7563
+
7564
+ // ---- style ----------------------------------------------------------
7565
+ function ensureStyleTag() {
7566
+ try {
7567
+ if (document.querySelector('style[data-plugin-css="' + ROW_MENU_STYLE_KEY + '"]') !== null) return;
7568
+ const tag = document.createElement("style");
7569
+ tag.setAttribute("data-plugin", "dsh-lookout");
7570
+ tag.setAttribute("data-plugin-css", ROW_MENU_STYLE_KEY);
7571
+ tag.textContent = ROW_MENU_CSS;
7572
+ const head = document.head !== null && document.head !== undefined ? document.head : document.body;
7573
+ if (head !== null && head !== undefined) head.appendChild(tag);
7574
+ } catch (error) { /* styling is best effort */ }
7575
+ }
7576
+
7577
+ // ---- injected row button -------------------------------------------
7578
+ function directRowButton(row) {
7579
+ for (const child of row.children) {
7580
+ if (child.tagName === "BUTTON" && child.getAttribute(ROW_MENU_ATTR) === null) return child;
7581
+ }
7582
+ return null;
7583
+ }
7584
+
7585
+ function directMenuButton(row) {
7586
+ for (const child of row.children) {
7587
+ if (child.getAttribute(ROW_MENU_ATTR) !== null) return child;
7588
+ }
7589
+ return null;
7590
+ }
7591
+
7592
+ function ensureStyles(row, rowButton) {
7593
+ if (!savedRowPositions.has(row)) savedRowPositions.set(row, row.style.position);
7594
+ try {
7595
+ if (window.getComputedStyle(row).position === "static") row.style.position = "relative";
7596
+ } catch { row.style.position = "relative"; }
7597
+ if (!savedButtonPadding.has(rowButton)) savedButtonPadding.set(rowButton, rowButton.style.paddingRight);
7598
+ if (rowButton.style.paddingRight !== ROW_MENU_BUTTON_PADDING) rowButton.style.paddingRight = ROW_MENU_BUTTON_PADDING;
7599
+ }
7600
+
7601
+ function pruneDetachedStyles() {
7602
+ for (const [row, position] of savedRowPositions) {
7603
+ if (row.isConnected) continue;
7604
+ try { row.style.position = position; } catch { /* detached */ }
7605
+ savedRowPositions.delete(row);
7606
+ }
7607
+ for (const [button, padding] of savedButtonPadding) {
7608
+ if (button.isConnected) continue;
7609
+ try { button.style.paddingRight = padding; } catch { /* detached */ }
7610
+ savedButtonPadding.delete(button);
7611
+ }
7612
+ }
7613
+
7614
+ function restoreStyles() {
7615
+ for (const [row, position] of savedRowPositions) { try { row.style.position = position; } catch { /* detached */ } }
7616
+ for (const [button, padding] of savedButtonPadding) { try { button.style.paddingRight = padding; } catch { /* detached */ } }
7617
+ savedRowPositions.clear();
7618
+ savedButtonPadding.clear();
7619
+ }
7620
+
7621
+ function svgRoot() {
7622
+ const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
7623
+ svg.setAttribute("viewBox", "0 0 16 16");
7624
+ svg.setAttribute("width", "16");
7625
+ svg.setAttribute("height", "16");
7626
+ svg.setAttribute("aria-hidden", "true");
7627
+ svg.setAttribute("focusable", "false");
7628
+ svg.setAttribute("fill", "currentColor");
7629
+ return svg;
7630
+ }
7631
+
7632
+ /** The only menu glyph: three dots, drawn here so no primitives icon is required. */
7633
+ function dotsIcon() {
7634
+ const svg = svgRoot();
7635
+ for (const cx of [3, 8, 13]) {
7636
+ const dot = document.createElementNS("http://www.w3.org/2000/svg", "circle");
7637
+ dot.setAttribute("cx", String(cx));
7638
+ dot.setAttribute("cy", "8");
7639
+ dot.setAttribute("r", "1.4");
7640
+ svg.appendChild(dot);
7641
+ }
7642
+ return svg;
7643
+ }
7644
+
7645
+ /** Copy feedback: the same button briefly shows a check. */
7646
+ function checkIcon() {
7647
+ const svg = svgRoot();
7648
+ const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
7649
+ path.setAttribute("d", "M3.5 8.5 6.6 11.5 12.5 4.8");
7650
+ path.setAttribute("fill", "none");
7651
+ path.setAttribute("stroke", "currentColor");
7652
+ path.setAttribute("stroke-width", "1.8");
7653
+ path.setAttribute("stroke-linecap", "round");
7654
+ path.setAttribute("stroke-linejoin", "round");
7655
+ svg.appendChild(path);
7656
+ return svg;
7657
+ }
7658
+
7659
+ function setButtonIcon(button, copied) {
7660
+ try {
7661
+ while (button.firstChild !== null) button.removeChild(button.firstChild);
7662
+ button.appendChild(copied ? checkIcon() : dotsIcon());
7663
+ if (copied) button.setAttribute("data-lookout-copied", "");
7664
+ else button.removeAttribute("data-lookout-copied");
7665
+ } catch (error) { /* best effort */ }
7666
+ }
7667
+
7668
+ function flashCopied(button) {
7669
+ if (button === null || button === undefined || !button.isConnected) return;
7670
+ const pending = feedbackTimers.get(button);
7671
+ if (pending !== undefined) { try { window.clearTimeout(pending); } catch { /* gone */ } }
7672
+ setButtonIcon(button, true);
7673
+ const timer = window.setTimeout(() => {
7674
+ feedbackTimers.delete(button);
7675
+ if (button.isConnected) setButtonIcon(button, false);
7676
+ }, ROW_MENU_FEEDBACK_MS);
7677
+ feedbackTimers.set(button, timer);
7678
+ }
7679
+
7680
+ function createRowButton() {
7681
+ const button = document.createElement("button");
7682
+ button.type = "button";
7683
+ button.className = "dsh-lookout-rowmenu";
7684
+ button.setAttribute(ROW_MENU_ATTR, "");
7685
+ button.setAttribute("aria-haspopup", "menu");
7686
+ button.setAttribute("aria-expanded", "false");
7687
+ button.setAttribute("aria-label", translate("ariaLabel"));
7688
+ button.title = translate("ariaLabel");
7689
+ button.appendChild(dotsIcon());
7690
+ button.addEventListener("click", (event) => {
7691
+ event.preventDefault();
7692
+ event.stopPropagation();
7693
+ if (disposed) return;
7694
+ if (menuAnchor === button) { closeMenu(); return; }
7695
+ const path = button.getAttribute("data-lookout-path");
7696
+ openMenu(button, typeof path === "string" ? path : "", button.getAttribute("data-lookout-directory") === "1");
7697
+ });
7698
+ return button;
7699
+ }
7700
+
7701
+ // ---- scan / injection ----------------------------------------------
7702
+ function scan() {
7703
+ if (disposed || !enabled || givenUp) return;
7704
+ if (document.querySelector(ROW_MENU_BODY_SELECTOR) === null) return;
7705
+ painting = true;
7706
+ let placed = 0;
7707
+ let missed = 0;
7708
+ let failure = null;
7709
+ try {
7710
+ pruneDetachedStyles();
7711
+ const bodies = document.querySelectorAll(ROW_MENU_BODY_SELECTOR);
7712
+ for (const body of bodies) {
7713
+ const rows = body.querySelectorAll(ROW_MENU_ROWS_SELECTOR);
7714
+ for (const row of rows) {
7715
+ const path = row.getAttribute(ROW_MENU_PATH_ATTR);
7716
+ if (typeof path !== "string" || path === "") { missed++; continue; }
7717
+ const rowButton = directRowButton(row);
7718
+ if (rowButton === null) { missed++; continue; }
7719
+ const isDirectory = row.getAttribute("data-files-entry") === "directory";
7720
+ ensureStyles(row, rowButton);
7721
+ let button = directMenuButton(row);
7722
+ if (button === null) {
7723
+ button = createRowButton();
7724
+ rowButton.insertAdjacentElement("afterend", button);
7725
+ }
7726
+ const previousPath = button.getAttribute("data-lookout-path");
7727
+ if (previousPath !== path) {
7728
+ if (menuAnchor === button) closeMenu();
7729
+ button.setAttribute("data-lookout-path", path);
7730
+ }
7731
+ button.setAttribute("data-lookout-directory", isDirectory ? "1" : "0");
7732
+ button.setAttribute("aria-label", translate("ariaLabel"));
7733
+ button.title = translate("ariaLabel");
7734
+ placed++;
7735
+ }
7736
+ }
7737
+ if (menuAnchor !== null) {
7738
+ const rowPathNow = menuRow !== null && menuRow.isConnected ? menuRow.getAttribute(ROW_MENU_PATH_ATTR) : null;
7739
+ if (!menuAnchor.isConnected || menuRow === null || !menuRow.isConnected || rowPathNow !== menuPath) closeMenu();
7740
+ }
7741
+ } catch (error) {
7742
+ failure = error;
7743
+ } finally {
7744
+ painting = false;
7745
+ paintedAt = Date.now();
7746
+ }
7747
+ if (placed > 0) failures = 0;
7748
+ else if (failure !== null) noteFailure(failure);
7749
+ else if (missed > 0) noteFailure("no eligible row could take the menu button");
7750
+ }
7751
+
7752
+ function noteFailure(why) {
7753
+ if (givenUp) return;
7754
+ failures++;
7755
+ if (failures === 1) rowMenuWarn("文件行菜单注入失败,已降级(不影响文件树):", why);
7756
+ if (failures < ROW_MENU_MAX_FAILURES) return;
7757
+ givenUp = true;
7758
+ stopWatching();
7759
+ if (!rowMenuReadFlag(ROW_MENU_DISMISSED_KEY, false)) showToast(translate("givingUp"), true);
7760
+ }
7761
+
7762
+ // ---- watchers -------------------------------------------------------
7763
+ function ensureObserver() {
7764
+ if (observer !== null || disposed || !enabled || givenUp) return;
7765
+ if (typeof window.MutationObserver !== "function") return;
7766
+ const root = document.body;
7767
+ if (root === null || root === undefined) return;
7768
+ observer = new window.MutationObserver(onMutations);
7769
+ observer.observe(root, { childList: true, subtree: true });
7770
+ }
7771
+
7772
+ function onMutations() {
7773
+ if (disposed || !enabled || givenUp) return;
7774
+ try {
7775
+ if (painting) return;
7776
+ if (Date.now() - paintedAt < ROW_MENU_QUIET_MS) return;
7777
+ if (menuAnchor !== null && !menuAnchor.isConnected) closeMenu();
7778
+ if (document.querySelector(ROW_MENU_BODY_SELECTOR) === null) {
7779
+ if (retryTimer === 0) retryTimer = window.setInterval(attachContainer, 1000);
7780
+ return;
7781
+ }
7782
+ scheduleScan();
7783
+ } catch (error) {
7784
+ noteFailure(error);
7785
+ }
7786
+ }
7787
+
7788
+ function scheduleScan() {
7789
+ if (disposed || !enabled || givenUp) return;
7790
+ if (scanTimer !== 0) { try { window.clearTimeout(scanTimer); } catch { /* gone */ } }
7791
+ scanTimer = window.setTimeout(() => { scanTimer = 0; scan(); }, ROW_MENU_SCAN_DEBOUNCE);
7792
+ }
7793
+
7794
+ function attachContainer() {
7795
+ if (disposed || !enabled || givenUp) return true;
7796
+ ensureObserver();
7797
+ if (document.querySelector(ROW_MENU_BODY_SELECTOR) === null) return false;
7798
+ scheduleScan();
7799
+ if (observer !== null && retryTimer !== 0) {
7800
+ try { window.clearInterval(retryTimer); } catch { /* gone */ }
7801
+ retryTimer = 0;
7802
+ }
7803
+ return true;
7804
+ }
7805
+
7806
+ function startWatching() {
7807
+ if (disposed || !enabled || givenUp) return;
7808
+ ensureObserver();
7809
+ if (document.querySelector(ROW_MENU_BODY_SELECTOR) === null) {
7810
+ if (retryTimer === 0) retryTimer = window.setInterval(attachContainer, 1000);
7811
+ } else {
7812
+ scheduleScan();
7813
+ }
7814
+ // No MutationObserver in this build: the 1s retry is the only updater left.
7815
+ if (observer === null && retryTimer === 0) retryTimer = window.setInterval(attachContainer, 1000);
7816
+ }
7817
+
7818
+ function stopWatching() {
7819
+ if (observer !== null) { try { observer.disconnect(); } catch { /* gone */ } observer = null; }
7820
+ if (retryTimer !== 0) { try { window.clearInterval(retryTimer); } catch { /* gone */ } retryTimer = 0; }
7821
+ if (scanTimer !== 0) { try { window.clearTimeout(scanTimer); } catch { /* gone */ } scanTimer = 0; }
7822
+ painting = false;
7823
+ }
7824
+
7825
+ // ---- switch ---------------------------------------------------------
7826
+ function setEnabled(next) {
7827
+ const on = next === true;
7828
+ enabled = on;
7829
+ rowMenuWriteFlag(ROW_MENU_ENABLED_KEY, on);
7830
+ if (disposed) return;
7831
+ if (!on) {
7832
+ stopWatching();
7833
+ closeMenu();
7834
+ hideToast();
7835
+ removeInjected();
7836
+ return;
7837
+ }
7838
+ givenUp = false;
7839
+ failures = 0;
7840
+ hideToast();
7841
+ startWatching();
7842
+ }
7843
+
7844
+ function removeInjected() {
7845
+ try { document.querySelectorAll("[" + ROW_MENU_ATTR + "]").forEach((node) => node.remove()); } catch { /* gone */ }
7846
+ restoreStyles();
7847
+ for (const timer of feedbackTimers.values()) { try { window.clearTimeout(timer); } catch { /* gone */ } }
7848
+ feedbackTimers.clear();
7849
+ }
7850
+
7851
+ // ---- menu -----------------------------------------------------------
7852
+ function createMenuItem(label, onActivate) {
7853
+ const item = document.createElement("button");
7854
+ item.type = "button";
7855
+ item.className = "dsh-lookout-rowmenu-item";
7856
+ item.setAttribute("role", "menuitem");
7857
+ const text = document.createElement("span");
7858
+ text.className = "dsh-lookout-rowmenu-label";
7859
+ text.textContent = label;
7860
+ item.appendChild(text);
7861
+ if (typeof onActivate === "function") {
7862
+ item.addEventListener("click", (event) => {
7863
+ event.preventDefault();
7864
+ event.stopPropagation();
7865
+ onActivate();
7866
+ });
7867
+ }
7868
+ return item;
7869
+ }
7870
+
7871
+ function openMenu(anchor, path, isDirectory) {
7872
+ if (disposed) return;
7873
+ if (document.body === null || document.body === undefined) return;
7874
+ closeMenu();
7875
+ const popup = document.createElement("div");
7876
+ popup.className = "dsh-lookout-rowmenu-pop";
7877
+ popup.setAttribute(ROW_MENU_POP_ATTR, "");
7878
+ popup.setAttribute("role", "menu");
7879
+ popup.setAttribute("aria-label", translate("ariaLabel"));
7880
+
7881
+ const copy = createMenuItem(translate("copyPath"), () => {
7882
+ closeMenu();
7883
+ void copyRowPath(path, anchor);
7884
+ });
7885
+ popup.appendChild(copy);
7886
+
7887
+ const download = createMenuItem(translate("download"), null);
7888
+ if (isDirectory) {
7889
+ download.disabled = true;
7890
+ download.setAttribute("aria-disabled", "true");
7891
+ const note = document.createElement("span");
7892
+ note.className = "dsh-lookout-rowmenu-note";
7893
+ note.textContent = translate("folderUnsupported");
7894
+ download.appendChild(note);
7895
+ } else {
7896
+ download.setAttribute("aria-disabled", "false");
7897
+ download.addEventListener("click", (event) => {
7898
+ event.preventDefault();
7899
+ event.stopPropagation();
7900
+ closeMenu();
7901
+ void downloadRowFile(path);
7902
+ });
7903
+ }
7904
+ popup.appendChild(download);
7905
+
7906
+ document.body.appendChild(popup);
7907
+ positionMenu(popup, anchor);
7908
+ menu = popup;
7909
+ menuAnchor = anchor;
7910
+ menuRow = anchor.closest("li");
7911
+ menuPath = path;
7912
+ menuOpenedAt = Date.now();
7913
+ anchor.setAttribute("aria-expanded", "true");
7914
+ paintedAt = Date.now();
7915
+ document.addEventListener("pointerdown", onDocumentPointerDown, true);
7916
+ document.addEventListener("keydown", onDocumentKeyDown, true);
7917
+ window.addEventListener("scroll", onDocumentScroll, true);
7918
+ window.addEventListener("resize", repositionMenu, true);
7919
+ watchVisualViewport(true);
7920
+ window.setTimeout(() => {
7921
+ if (menu !== popup) return;
7922
+ const first = popup.querySelector('[role="menuitem"]:not([disabled])');
7923
+ if (first === null) return;
7924
+ try { first.focus({ preventScroll: true }); } catch { try { first.focus(); } catch { /* best effort */ } }
7925
+ }, 0);
7926
+ }
7927
+
7928
+ function positionMenu(popup, anchor) {
7929
+ try {
7930
+ const anchorRect = anchor.getBoundingClientRect();
7931
+ const popupRect = popup.getBoundingClientRect();
7932
+ const margin = 8;
7933
+ const gap = 6;
7934
+ let left = anchorRect.right - popupRect.width;
7935
+ let top = anchorRect.bottom + gap;
7936
+ const maxLeft = window.innerWidth - popupRect.width - margin;
7937
+ if (left > maxLeft) left = maxLeft;
7938
+ if (left < margin) left = margin;
7939
+ if (top + popupRect.height > window.innerHeight - margin) top = anchorRect.top - popupRect.height - gap;
7940
+ if (top < margin) top = margin;
7941
+ popup.style.left = Math.round(left) + "px";
7942
+ popup.style.top = Math.round(top) + "px";
7943
+ } catch (error) { /* the CSS default is still usable */ }
7944
+ }
7945
+
7946
+ function closeMenu() {
7947
+ if (menu === null) return;
7948
+ const popup = menu;
7949
+ const anchor = menuAnchor;
7950
+ menu = null;
7951
+ menuAnchor = null;
7952
+ menuRow = null;
7953
+ menuPath = "";
7954
+ try { popup.remove(); } catch { /* gone */ }
7955
+ if (anchor !== null) { try { anchor.setAttribute("aria-expanded", "false"); } catch { /* gone */ } }
7956
+ document.removeEventListener("pointerdown", onDocumentPointerDown, true);
7957
+ document.removeEventListener("keydown", onDocumentKeyDown, true);
7958
+ window.removeEventListener("scroll", onDocumentScroll, true);
7959
+ window.removeEventListener("resize", repositionMenu, true);
7960
+ watchVisualViewport(false);
7961
+ }
7962
+
7963
+ /** Move the open menu back inside the viewport without closing it. */
7964
+ function repositionMenu() {
7965
+ if (menu === null || menuAnchor === null) return;
7966
+ positionMenu(menu, menuAnchor);
7967
+ }
7968
+
7969
+ /**
7970
+ * A scroll normally means the anchor moved, but a phone fires one while the tap
7971
+ * settles; that churn must not close the menu the tap just opened.
7972
+ */
7973
+ function onDocumentScroll() {
7974
+ if (menu === null) return;
7975
+ if (Date.now() - menuOpenedAt < ROW_MENU_SCROLL_GRACE_MS) { repositionMenu(); return; }
7976
+ closeMenu();
7977
+ }
7978
+
7979
+ /** Follow the visual viewport (URL bar, on-screen keyboard) instead of dying with it. */
7980
+ function watchVisualViewport(on) {
7981
+ try {
7982
+ const viewport = window.visualViewport;
7983
+ if (viewport === null || viewport === undefined || typeof viewport.addEventListener !== "function") return;
7984
+ if (on) {
7985
+ viewport.addEventListener("resize", repositionMenu);
7986
+ viewport.addEventListener("scroll", repositionMenu);
7987
+ } else {
7988
+ viewport.removeEventListener("resize", repositionMenu);
7989
+ viewport.removeEventListener("scroll", repositionMenu);
7990
+ }
7991
+ } catch { /* older browsers simply skip the follow-up */ }
7992
+ }
7993
+
7994
+ function onDocumentPointerDown(event) {
7995
+ if (menu === null) return;
7996
+ const target = event.target;
7997
+ if (target !== null && target !== undefined) {
7998
+ if (menu.contains(target)) return;
7999
+ if (menuAnchor !== null && menuAnchor.contains(target)) return;
8000
+ }
8001
+ closeMenu();
8002
+ }
8003
+
8004
+ function onDocumentKeyDown(event) {
8005
+ if (menu === null) return;
8006
+ if (event.key === "Escape" || event.key === "Esc") {
8007
+ event.preventDefault();
8008
+ event.stopPropagation();
8009
+ const anchor = menuAnchor;
8010
+ closeMenu();
8011
+ if (anchor !== null) { try { anchor.focus(); } catch { /* gone */ } }
8012
+ return;
8013
+ }
8014
+ if (event.key === "ArrowDown" || event.key === "ArrowUp") {
8015
+ const items = [];
8016
+ for (const item of menu.querySelectorAll('[role="menuitem"]')) {
8017
+ if (item.disabled !== true) items.push(item);
8018
+ }
8019
+ if (items.length === 0) return;
8020
+ event.preventDefault();
8021
+ const current = items.indexOf(document.activeElement);
8022
+ const step = event.key === "ArrowDown" ? 1 : -1;
8023
+ const next = current < 0 ? (step > 0 ? 0 : items.length - 1) : (current + step + items.length) % items.length;
8024
+ try { items[next].focus(); } catch { /* best effort */ }
8025
+ }
8026
+ }
8027
+
8028
+ // ---- clipboard ------------------------------------------------------
8029
+ function requirePrimitives() {
8030
+ try { return require("@deepseek-ai/dsh-client-ui-primitives"); } catch { return null; }
8031
+ }
8032
+
8033
+ async function writeClipboardText(text) {
8034
+ const primitives = requirePrimitives();
8035
+ if (primitives !== null && primitives !== undefined && typeof primitives.writeClipboard === "function") {
8036
+ try {
8037
+ const result = await primitives.writeClipboard(text);
8038
+ if (result !== false) return true;
8039
+ } catch (error) { /* fall through */ }
8040
+ }
8041
+ try {
8042
+ if (typeof navigator !== "undefined" && navigator.clipboard !== null && navigator.clipboard !== undefined && typeof navigator.clipboard.writeText === "function") {
8043
+ await navigator.clipboard.writeText(text);
8044
+ return true;
8045
+ }
8046
+ } catch (error) { /* fall through */ }
8047
+ try {
8048
+ const area = document.createElement("textarea");
8049
+ area.value = text;
8050
+ area.setAttribute("readonly", "");
8051
+ area.style.position = "fixed";
8052
+ area.style.top = "0";
8053
+ area.style.left = "-9999px";
8054
+ area.style.opacity = "0";
8055
+ document.body.appendChild(area);
8056
+ area.focus();
8057
+ area.select();
8058
+ const copied = typeof document.execCommand === "function" && document.execCommand("copy") === true;
8059
+ area.remove();
8060
+ return copied;
8061
+ } catch (error) { return false; }
8062
+ }
8063
+
8064
+ async function copyRowPath(path, anchor) {
8065
+ let copied = false;
8066
+ try { copied = await writeClipboardText(path); } catch { copied = false; }
8067
+ if (disposed) return;
8068
+ if (copied) flashCopied(anchor);
8069
+ else showToast(translate("copyFailed"), false);
8070
+ }
8071
+
8072
+ // ---- download -------------------------------------------------------
8073
+ function fileNameFor(path) {
8074
+ const trimmed = path.replace(/[/\\]+$/, "");
8075
+ const slash = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\"));
8076
+ const last = slash >= 0 ? trimmed.slice(slash + 1) : trimmed;
8077
+ try { return decodeURIComponent(last) || "download"; } catch { return last || "download"; }
8078
+ }
8079
+
8080
+ function downloadErrorText(status) {
8081
+ switch (status) {
8082
+ case 401: return translate("err401");
8083
+ case 413: return translate("err413");
8084
+ case 404: return translate("err404");
8085
+ case 403: return translate("err403");
8086
+ default: return translate("errOther", { status: status > 0 ? status : "?" });
8087
+ }
8088
+ }
8089
+
8090
+ async function downloadRowFile(path) {
8091
+ let response = null;
8092
+ try {
8093
+ response = await window.fetch("api/file?path=" + encodeURIComponent(path), { credentials: "same-origin" });
8094
+ } catch (error) {
8095
+ if (!disposed) showToast(translate("errNetwork"), false);
8096
+ return;
8097
+ }
8098
+ if (response === null || response.ok !== true) {
8099
+ const status = response !== null && typeof response.status === "number" ? response.status : 0;
8100
+ if (!disposed) showToast(downloadErrorText(status), false);
8101
+ return;
8102
+ }
8103
+ try {
8104
+ const blob = await response.blob();
8105
+ if (disposed) return;
8106
+ const objectUrl = window.URL.createObjectURL(blob);
8107
+ const link = document.createElement("a");
8108
+ link.href = objectUrl;
8109
+ link.download = fileNameFor(path);
8110
+ link.rel = "noopener";
8111
+ link.style.display = "none";
8112
+ document.body.appendChild(link);
8113
+ link.click();
8114
+ link.remove();
8115
+ window.setTimeout(() => { try { window.URL.revokeObjectURL(objectUrl); } catch { /* gone */ } }, 1000);
8116
+ } catch (error) {
8117
+ if (!disposed) showToast(translate("errNetwork"), false);
8118
+ }
8119
+ }
8120
+
8121
+ // ---- toast ----------------------------------------------------------
8122
+ function showToast(message, dismissable) {
8123
+ if (disposed) return;
8124
+ if (document.body === null || document.body === undefined) return;
8125
+ hideToast();
8126
+ const box = document.createElement("div");
8127
+ box.className = "dsh-lookout-toast";
8128
+ box.setAttribute(ROW_MENU_TOAST_ATTR, "");
8129
+ box.setAttribute("role", "status");
8130
+ box.setAttribute("aria-live", "polite");
8131
+ const text = document.createElement("span");
8132
+ text.className = "dsh-lookout-toast-text";
8133
+ text.textContent = message;
8134
+ box.appendChild(text);
8135
+ if (dismissable === true) {
8136
+ const dismiss = document.createElement("button");
8137
+ dismiss.type = "button";
8138
+ dismiss.textContent = translate("dismiss");
8139
+ dismiss.addEventListener("click", (event) => {
8140
+ event.preventDefault();
8141
+ event.stopPropagation();
8142
+ rowMenuWriteFlag(ROW_MENU_DISMISSED_KEY, true);
8143
+ hideToast();
8144
+ });
8145
+ box.appendChild(dismiss);
8146
+ }
8147
+ document.body.appendChild(box);
8148
+ toast = box;
8149
+ paintedAt = Date.now();
8150
+ toastTimer = window.setTimeout(hideToast, ROW_MENU_TOAST_MS);
8151
+ }
8152
+
8153
+ function hideToast() {
8154
+ if (toastTimer !== 0) { try { window.clearTimeout(toastTimer); } catch { /* gone */ } toastTimer = 0; }
8155
+ if (toast !== null) { try { toast.remove(); } catch { /* gone */ } toast = null; }
8156
+ }
8157
+
8158
+ // ---- lifecycle ------------------------------------------------------
8159
+ function dispose() {
8160
+ if (disposed) return;
8161
+ disposed = true;
8162
+ try { stopWatching(); } catch { /* best effort */ }
8163
+ try { closeMenu(); } catch { /* best effort */ }
8164
+ try { hideToast(); } catch { /* best effort */ }
8165
+ try { removeInjected(); } catch { /* best effort */ }
8166
+ try { document.querySelectorAll('style[data-plugin-css="' + ROW_MENU_STYLE_KEY + '"]').forEach((node) => node.remove()); } catch { /* best effort */ }
8167
+ }
8168
+
8169
+ ensureStyleTag();
8170
+ if (enabled) startWatching();
8171
+ return dispose;
8172
+ }
8173
+ //#endregion
8174
+
7303
8175
  window.__ModuleLoader__.load({
7304
8176
  id: "dsh-lookout",
7305
8177
  factory: (require) => {
@@ -7308,6 +8180,10 @@ window.__ModuleLoader__.load({
7308
8180
  return {
7309
8181
  inject: __inject,
7310
8182
  apply(ctx) {
8183
+ // The file-row "⋯" menu is independent of the three bundled parts, so it
8184
+ // starts first and owns its own try/catch: a fault in any part (or a
8185
+ // missing files panel) only costs this feature, never the host.
8186
+ rowMenuStart(ctx, require);
7311
8187
  for (const m of __mods) { if (m && typeof m.apply === "function") m.apply(ctx); }
7312
8188
  },
7313
8189
  };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@cowwo/dsh-lookout",
3
3
  "description": "瞭望哨:盯着你的每个会话——谁跑完了、谁出错了、谁卡住了、谁在等你审批,用浏览器图标角标、红点/黄点和邮件通知你;顺带显示当前会话 ID。· Lookout: watches every session — who finished, who errored, who is stuck, and who is waiting for your approval — and notifies you through the browser favicon badge, red/yellow dots, and email, while also showing the current session ID.",
4
- "version": "0.1.0",
4
+ "version": "0.1.2",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "main": "lib/index.js",