@cowwo/dsh-lookout 0.1.1 → 0.2.0
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 +11 -1
- package/client/client.js +1952 -8
- package/lib/icon/index.js +54 -0
- package/lib/icon/workspace-prefs.js +129 -0
- package/lib/typert.host.js +88 -1
- package/package.json +1 -1
package/client/client.js
CHANGED
|
@@ -7339,6 +7339,15 @@ window.__ModuleLoader__.load({
|
|
|
7339
7339
|
const ROW_MENU_QUIET_MS = 250;
|
|
7340
7340
|
const ROW_MENU_FEEDBACK_MS = 1500;
|
|
7341
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;
|
|
7342
7351
|
const ROW_MENU_BUTTON_PADDING = "32px";
|
|
7343
7352
|
const ROW_MENU_ENABLED_KEY = "dsh-lookout:file-row-menu:enabled";
|
|
7344
7353
|
const ROW_MENU_DISMISSED_KEY = "dsh-lookout:file-row-menu:gave-up-dismissed";
|
|
@@ -7386,12 +7395,12 @@ window.__ModuleLoader__.load({
|
|
|
7386
7395
|
* No literal colour is written here, so light/dark follows the host.
|
|
7387
7396
|
*/
|
|
7388
7397
|
const ROW_MENU_CSS = [
|
|
7389
|
-
".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;}",
|
|
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;}",
|
|
7390
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;}",
|
|
7391
7400
|
".dsh-lookout-rowmenu svg{display:block;width:16px;height:16px;pointer-events:none;}",
|
|
7392
7401
|
".dsh-lookout-rowmenu[data-lookout-copied]{color:var(--dsw-alias-state-success-primary);}",
|
|
7393
|
-
".dsh-lookout-rowmenu-pop{position:fixed;z-index:
|
|
7394
|
-
".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;}",
|
|
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;}",
|
|
7395
7404
|
".dsh-lookout-rowmenu-item:hover,.dsh-lookout-rowmenu-item:focus-visible{background:var(--dsw-alias-interactive-bg-hover);outline:none;}",
|
|
7396
7405
|
".dsh-lookout-rowmenu-item[aria-disabled=\"true\"]{color:var(--dsw-alias-label-dimmed);cursor:not-allowed;}",
|
|
7397
7406
|
".dsh-lookout-rowmenu-item[aria-disabled=\"true\"]:hover{background:transparent;}",
|
|
@@ -7483,6 +7492,7 @@ window.__ModuleLoader__.load({
|
|
|
7483
7492
|
let menuAnchor = null;
|
|
7484
7493
|
let menuRow = null;
|
|
7485
7494
|
let menuPath = "";
|
|
7495
|
+
let menuOpenedAt = 0;
|
|
7486
7496
|
let toast = null;
|
|
7487
7497
|
let toastTimer = 0;
|
|
7488
7498
|
const feedbackTimers = new Map();
|
|
@@ -7899,16 +7909,19 @@ window.__ModuleLoader__.load({
|
|
|
7899
7909
|
menuAnchor = anchor;
|
|
7900
7910
|
menuRow = anchor.closest("li");
|
|
7901
7911
|
menuPath = path;
|
|
7912
|
+
menuOpenedAt = Date.now();
|
|
7902
7913
|
anchor.setAttribute("aria-expanded", "true");
|
|
7903
7914
|
paintedAt = Date.now();
|
|
7904
7915
|
document.addEventListener("pointerdown", onDocumentPointerDown, true);
|
|
7905
7916
|
document.addEventListener("keydown", onDocumentKeyDown, true);
|
|
7906
|
-
window.addEventListener("scroll",
|
|
7907
|
-
window.addEventListener("resize",
|
|
7917
|
+
window.addEventListener("scroll", onDocumentScroll, true);
|
|
7918
|
+
window.addEventListener("resize", repositionMenu, true);
|
|
7919
|
+
watchVisualViewport(true);
|
|
7908
7920
|
window.setTimeout(() => {
|
|
7909
7921
|
if (menu !== popup) return;
|
|
7910
7922
|
const first = popup.querySelector('[role="menuitem"]:not([disabled])');
|
|
7911
|
-
if (first
|
|
7923
|
+
if (first === null) return;
|
|
7924
|
+
try { first.focus({ preventScroll: true }); } catch { try { first.focus(); } catch { /* best effort */ } }
|
|
7912
7925
|
}, 0);
|
|
7913
7926
|
}
|
|
7914
7927
|
|
|
@@ -7942,8 +7955,40 @@ window.__ModuleLoader__.load({
|
|
|
7942
7955
|
if (anchor !== null) { try { anchor.setAttribute("aria-expanded", "false"); } catch { /* gone */ } }
|
|
7943
7956
|
document.removeEventListener("pointerdown", onDocumentPointerDown, true);
|
|
7944
7957
|
document.removeEventListener("keydown", onDocumentKeyDown, true);
|
|
7945
|
-
window.removeEventListener("scroll",
|
|
7946
|
-
window.removeEventListener("resize",
|
|
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 */ }
|
|
7947
7992
|
}
|
|
7948
7993
|
|
|
7949
7994
|
function onDocumentPointerDown(event) {
|
|
@@ -8127,6 +8172,1899 @@ window.__ModuleLoader__.load({
|
|
|
8127
8172
|
}
|
|
8128
8173
|
//#endregion
|
|
8129
8174
|
|
|
8175
|
+
//#region workspace rows
|
|
8176
|
+
/**
|
|
8177
|
+
* Workspace-sidebar tags, whole-group hiding, and the "Workspaces" settings table.
|
|
8178
|
+
*
|
|
8179
|
+
* Why the merge glue: the three bundled parts are byte-locked by
|
|
8180
|
+
* test/merged-client.test.mjs, and the shipped workspace browser is a single
|
|
8181
|
+
* opaque seat (sidebar.workspaces, kind single) with no per-row children, so a
|
|
8182
|
+
* tag can only be injected and hiding can only be a style rule. Both are built
|
|
8183
|
+
* to fail silently and never throw into the host's render tree.
|
|
8184
|
+
*
|
|
8185
|
+
* Host contract (read-only reference: @deepseek-ai/dsh-client-ui-workspace):
|
|
8186
|
+
* [data-slot="sidebar.workspaces"]
|
|
8187
|
+
* div.groupSection one per workspace; children nest
|
|
8188
|
+
* div[role="treeitem"][data-row-key="workspace:<id>"]
|
|
8189
|
+
* span.projectText > span.title
|
|
8190
|
+
* span.rowActions hover actions ("⋯" + new session)
|
|
8191
|
+
* div[role="group"] > div.groupSection child workspaces
|
|
8192
|
+
* div[role="treeitem"][data-row-key="session:<id>"][aria-selected]
|
|
8193
|
+
* button[data-row-key="overflow:<key>"]
|
|
8194
|
+
*
|
|
8195
|
+
* Tag placement is row-level: the badge is a direct child of the workspace
|
|
8196
|
+
* row, immediately before span.rowActions (or at the row's end when the host
|
|
8197
|
+
* renders no actions). margin-left:auto pushes it to the right edge; the
|
|
8198
|
+
* row's own gap supplies the minimum spacing from the name. The idempotency
|
|
8199
|
+
* key is data-lookout-ws-tag="<workspaceId>".
|
|
8200
|
+
*
|
|
8201
|
+
* Hiding is one generated rule per hidden workspace, with every selector
|
|
8202
|
+
* scoped to the group wrapper one/two levels above the keyed row (the shipped
|
|
8203
|
+
* HoverCard wraps its anchor in a <span>):
|
|
8204
|
+
* div:has(> [data-row-key="workspace:<id>"]),
|
|
8205
|
+
* div:has(>span>[data-row-key="workspace:<id>"]), {display:none!important}
|
|
8206
|
+
* plus .*groupSection variants of the same two shapes. :has() hides the whole
|
|
8207
|
+
* group (workspace row + its sessions + the "expand" button) without touching
|
|
8208
|
+
* React. No rule is ever generated for a workspace that contains the CURRENT
|
|
8209
|
+
* session, nor for any of its ancestors.
|
|
8210
|
+
*
|
|
8211
|
+
* Preferences live on the Host (iconCustom/getWorkspacePrefs /
|
|
8212
|
+
* setWorkspacePrefs, full-document replace) with a localStorage mirror used
|
|
8213
|
+
* first on load and kept as the fallback when RPC is unavailable. A save
|
|
8214
|
+
* failure is reported once, never per keystroke.
|
|
8215
|
+
*
|
|
8216
|
+
* Breaker (the row-menu pattern): three consecutive scans that place nothing
|
|
8217
|
+
* while there IS work to place stop the feature for the page, warn once,
|
|
8218
|
+
* explain once, and can be reset from the settings switch.
|
|
8219
|
+
*/
|
|
8220
|
+
/**
|
|
8221
|
+
* Own locale namespace. locale.register() THROWS when a namespace already owns a
|
|
8222
|
+
* locale, and the file-row menu above already registers zh/en under
|
|
8223
|
+
* "dsh-lookout"; sharing it would silently drop these dictionaries.
|
|
8224
|
+
*/
|
|
8225
|
+
const WS_ROWS_NS = "dsh-lookout-workspaces";
|
|
8226
|
+
const WS_TAG_ATTR = "data-lookout-ws-tag";
|
|
8227
|
+
const WS_TAG_TEXT_ATTR = "data-lookout-ws-tag-text";
|
|
8228
|
+
const WS_TOAST_ATTR = "data-lookout-ws-toast";
|
|
8229
|
+
const WS_STYLE_KEY = "lookout-workspace-rows";
|
|
8230
|
+
const WS_HIDE_STYLE_KEY = "lookout-workspace-hide";
|
|
8231
|
+
const WS_PREFS_LOCAL_KEY = "dsh-lookout.workspacePrefs";
|
|
8232
|
+
const WS_ROWS_ENABLED_KEY = "dsh-lookout:workspace-rows:enabled";
|
|
8233
|
+
const WS_ROWS_DISMISSED_KEY = "dsh-lookout:workspace-rows:gave-up-dismissed";
|
|
8234
|
+
const WS_MAX_FAILURES = 3;
|
|
8235
|
+
const WS_SCAN_DEBOUNCE = 200;
|
|
8236
|
+
const WS_QUIET_MS = 250;
|
|
8237
|
+
const WS_TOAST_MS = 3000;
|
|
8238
|
+
const WS_SAVE_DEBOUNCE = 250;
|
|
8239
|
+
const WS_TAG_MAX = 6;
|
|
8240
|
+
/** Pin replay: coalesce list churn, then move only what is out of place. */
|
|
8241
|
+
const WS_PIN_DEBOUNCE = 150;
|
|
8242
|
+
const WS_PIN_MAX_ROUNDS = 5;
|
|
8243
|
+
const WS_PIN_MAX_MOVES = 32;
|
|
8244
|
+
/** A refused reorder backs off, then gives up: no RPC hammering on a broken host. */
|
|
8245
|
+
const WS_PIN_RETRY_MS = 2000;
|
|
8246
|
+
const WS_PIN_MAX_FAILURES = 3;
|
|
8247
|
+
/** Same eight names the Host normalizes to; anything else falls back to gray. */
|
|
8248
|
+
const WS_COLORS = ["red", "orange", "yellow", "green", "cyan", "blue", "purple", "gray"];
|
|
8249
|
+
const WS_COLOR_DEFAULT = "gray";
|
|
8250
|
+
/**
|
|
8251
|
+
* Literal colours for the eight tag dots. Each capsule keeps the host's grey
|
|
8252
|
+
* fill and secondary text colour and draws only its dot in the accent hue;
|
|
8253
|
+
* the rest of the row keeps --dsw-alias-* tokens so light/dark still follows
|
|
8254
|
+
* the host; these hues have no token equivalent.
|
|
8255
|
+
*/
|
|
8256
|
+
const WS_COLOR_FALLBACK = {
|
|
8257
|
+
red: "#e5484d",
|
|
8258
|
+
orange: "#f76b15",
|
|
8259
|
+
yellow: "#d9a123",
|
|
8260
|
+
green: "#30a46c",
|
|
8261
|
+
cyan: "#00a2c7",
|
|
8262
|
+
blue: "#3e63dd",
|
|
8263
|
+
purple: "#8e4ec6",
|
|
8264
|
+
gray: "#8b8d98"
|
|
8265
|
+
};
|
|
8266
|
+
const WS_ROW_KEY_ATTR = "data-row-key";
|
|
8267
|
+
const WS_WORKSPACE_PREFIX = "workspace:";
|
|
8268
|
+
const WS_SESSION_PREFIX = "session:";
|
|
8269
|
+
const WS_CONTAINER_SELECTOR = '[data-slot="sidebar.workspaces"]';
|
|
8270
|
+
const WS_CONTAINER_FALLBACK = '[data-slot="sidebar"]';
|
|
8271
|
+
/** Workspace ids are uuid-ish; refuse anything a CSS attribute string cannot carry safely. */
|
|
8272
|
+
const WS_ID_PATTERN = /^[A-Za-z0-9._:-]{1,200}$/;
|
|
8273
|
+
const WS_HIDE_CSS_HEADER = "/* dsh-lookout: hidden workspaces (regenerated on every paint) */";
|
|
8274
|
+
|
|
8275
|
+
const WS_ROWS_CSS = [
|
|
8276
|
+
".dsh-lookout-ws-tag{display:inline-flex;align-items:center;gap:4px;flex:none;max-width:9em;margin-left:auto;padding:0 6px;height:16px;border-radius:999px;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-secondary);font-size:11px;line-height:16px;pointer-events:none;overflow:hidden;white-space:nowrap;}",
|
|
8277
|
+
".dsh-lookout-ws-tag-dot{flex:none;width:6px;height:6px;border-radius:50%;background:" + WS_COLOR_FALLBACK.gray + ";}",
|
|
8278
|
+
".dsh-lookout-ws-tag-text{min-width:0;overflow:hidden;text-overflow:ellipsis;}",
|
|
8279
|
+
".dsh-lookout-ws-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);}",
|
|
8280
|
+
".dsh-lookout-ws-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;}"
|
|
8281
|
+
].concat(WS_COLORS.map((name) => '.dsh-lookout-ws-tag[data-color="' + name + '"] .dsh-lookout-ws-tag-dot{background:' + WS_COLOR_FALLBACK[name] + ";}")).join("\n");
|
|
8282
|
+
|
|
8283
|
+
/** Chinese fallback for every translate call, also used when locale is unavailable. */
|
|
8284
|
+
const WS_ROWS_ZH = {
|
|
8285
|
+
nav: "工作区管理",
|
|
8286
|
+
toggleLabel: "在侧栏显示工作区标签,并按设置隐藏工作区",
|
|
8287
|
+
toggleHint: "标签只显示在侧栏工作区行右侧(悬停时出现在「⋯」按钮左边);隐藏是把整个工作区收起(连同它下面的会话和「展开」按钮)。当前会话所在的工作区永远不会被隐藏。这些偏好存在宿主侧,宿主升级后若定位不到工作区行,只会不显示,侧栏本身不受影响。",
|
|
8288
|
+
colName: "工作区",
|
|
8289
|
+
colTag: "标签",
|
|
8290
|
+
colColor: "颜色",
|
|
8291
|
+
colPin: "置顶",
|
|
8292
|
+
colHide: "隐藏",
|
|
8293
|
+
tagPlaceholder: "最多 6 字",
|
|
8294
|
+
pinnedHint: "置顶的工作区排在列表最前;取消置顶会尽量放回原来的位置。子工作区不单独置顶,跟着它的上级工作区排列。",
|
|
8295
|
+
pinChildHint: "子工作区不单独置顶,跟着上级工作区排列。",
|
|
8296
|
+
pinUnavailable: "拿不到工作区服务,置顶只会保存偏好,不会调整顺序。",
|
|
8297
|
+
pinFailed: "工作区置顶没有生效,列表可能保持原样。",
|
|
8298
|
+
cleanup: "清理失效项",
|
|
8299
|
+
cleaned: "已清理 {n} 个失效项",
|
|
8300
|
+
cleanNone: "没有可清理的失效项",
|
|
8301
|
+
empty: "还没有工作区。",
|
|
8302
|
+
colorRed: "红",
|
|
8303
|
+
colorOrange: "橙",
|
|
8304
|
+
colorYellow: "黄",
|
|
8305
|
+
colorGreen: "绿",
|
|
8306
|
+
colorCyan: "青",
|
|
8307
|
+
colorBlue: "蓝",
|
|
8308
|
+
colorPurple: "紫",
|
|
8309
|
+
colorGray: "灰",
|
|
8310
|
+
givingUp: "工作区标签连续注入失败,已自动停止;侧栏本身不受影响。",
|
|
8311
|
+
dismiss: "不再提示",
|
|
8312
|
+
saveFailed: "工作区偏好保存失败,改动只保留在本机。"
|
|
8313
|
+
};
|
|
8314
|
+
const WS_ROWS_EN = {
|
|
8315
|
+
nav: "Workspaces",
|
|
8316
|
+
toggleLabel: "Show workspace tags in the sidebar and hide workspaces as configured",
|
|
8317
|
+
toggleHint: "A tag sits at the right end of the workspace row, just left of the hover actions; hiding collapses a whole workspace group, including its sessions and the expand button. The workspace that holds the current session is never hidden. Preferences live on the host, so a host upgrade can at worst stop the tags from appearing \u2014 the sidebar itself is unaffected.",
|
|
8318
|
+
colName: "Workspace",
|
|
8319
|
+
colTag: "Tag",
|
|
8320
|
+
colColor: "Colour",
|
|
8321
|
+
colPin: "Pin",
|
|
8322
|
+
colHide: "Hide",
|
|
8323
|
+
tagPlaceholder: "Up to 6 characters",
|
|
8324
|
+
pinnedHint: "Pinned workspaces sit at the top of the list; unpinning puts one back where it was. Child workspaces are not pinned on their own \u2014 they follow their parent.",
|
|
8325
|
+
pinChildHint: "Child workspaces are not pinned on their own; they follow their parent workspace.",
|
|
8326
|
+
pinUnavailable: "The workspace service is unavailable, so pinning only saves the preference and never reorders.",
|
|
8327
|
+
pinFailed: "Pinning did not take effect; the list may look unchanged.",
|
|
8328
|
+
cleanup: "Remove stale entries",
|
|
8329
|
+
cleaned: "Removed {n} stale entries",
|
|
8330
|
+
cleanNone: "No stale entries to remove",
|
|
8331
|
+
empty: "No workspaces yet.",
|
|
8332
|
+
colorRed: "Red",
|
|
8333
|
+
colorOrange: "Orange",
|
|
8334
|
+
colorYellow: "Yellow",
|
|
8335
|
+
colorGreen: "Green",
|
|
8336
|
+
colorCyan: "Cyan",
|
|
8337
|
+
colorBlue: "Blue",
|
|
8338
|
+
colorPurple: "Purple",
|
|
8339
|
+
colorGray: "Gray",
|
|
8340
|
+
givingUp: "The workspace tags could not be injected repeatedly and stopped; the sidebar itself is unaffected.",
|
|
8341
|
+
dismiss: "Do not show again",
|
|
8342
|
+
saveFailed: "Workspace preferences could not be saved; changes stay in this browser only."
|
|
8343
|
+
};
|
|
8344
|
+
|
|
8345
|
+
const WS_STYLE_TITLE = { fontSize: 13, fontWeight: 600, color: "var(--dsw-alias-label-primary)", marginBottom: 8 };
|
|
8346
|
+
const WS_STYLE_HINT = { marginTop: 6, fontSize: 12, lineHeight: 1.6, color: "var(--dsw-alias-label-tertiary)" };
|
|
8347
|
+
const WS_STYLE_TABLE = { borderCollapse: "collapse", fontSize: 12, color: "var(--dsw-alias-label-primary)" };
|
|
8348
|
+
const WS_STYLE_TH = { padding: "4px 8px", textAlign: "left", fontWeight: 500, color: "var(--dsw-alias-label-tertiary)", borderBottom: "1px solid var(--dsw-alias-border-l2)" };
|
|
8349
|
+
const WS_STYLE_TD = { padding: "4px 8px", borderBottom: "1px solid var(--dsw-alias-border-l2)", verticalAlign: "middle" };
|
|
8350
|
+
const WS_STYLE_ACTIONS = { display: "flex", alignItems: "center", gap: 12, marginTop: 10 };
|
|
8351
|
+
const WS_STYLE_TOGGLE = { display: "inline-flex", alignItems: "center", gap: 6, fontSize: 12, color: "var(--dsw-alias-label-primary)", cursor: "pointer" };
|
|
8352
|
+
|
|
8353
|
+
function wsRowsFallbackText(key, params) {
|
|
8354
|
+
const template = typeof WS_ROWS_ZH[key] === "string" ? WS_ROWS_ZH[key] : key;
|
|
8355
|
+
if (params === null || params === undefined || typeof params !== "object") return template;
|
|
8356
|
+
let text = template;
|
|
8357
|
+
for (const name of Object.keys(params)) text = text.split("{" + name + "}").join(String(params[name]));
|
|
8358
|
+
return text;
|
|
8359
|
+
}
|
|
8360
|
+
|
|
8361
|
+
/** Tag normalization matches the Host: drop control characters, trim, cap at 6 Unicode characters. */
|
|
8362
|
+
function wsRowsNormalizeTag(input) {
|
|
8363
|
+
if (typeof input !== "string") return "";
|
|
8364
|
+
const cleaned = input.replace(/\p{Cc}/gu, "").trim();
|
|
8365
|
+
return [...cleaned].slice(0, WS_TAG_MAX).join("");
|
|
8366
|
+
}
|
|
8367
|
+
|
|
8368
|
+
function wsRowsNormalizeColor(input) {
|
|
8369
|
+
return WS_COLORS.indexOf(input) >= 0 ? input : WS_COLOR_DEFAULT;
|
|
8370
|
+
}
|
|
8371
|
+
|
|
8372
|
+
function wsRowsValidId(input) {
|
|
8373
|
+
return typeof input === "string" && WS_ID_PATTERN.test(input);
|
|
8374
|
+
}
|
|
8375
|
+
|
|
8376
|
+
function wsRowsEmptyDoc() {
|
|
8377
|
+
return { version: 1, workspaces: {} };
|
|
8378
|
+
}
|
|
8379
|
+
|
|
8380
|
+
function wsRowsNormalizeEntry(input) {
|
|
8381
|
+
const source = input !== null && typeof input === "object" && !Array.isArray(input) ? input : {};
|
|
8382
|
+
return {
|
|
8383
|
+
tag: wsRowsNormalizeTag(source.tag),
|
|
8384
|
+
color: wsRowsNormalizeColor(source.color),
|
|
8385
|
+
pinned: source.pinned === true,
|
|
8386
|
+
hidden: source.hidden === true,
|
|
8387
|
+
pinAnchor: typeof source.pinAnchor === "string" ? source.pinAnchor : null
|
|
8388
|
+
};
|
|
8389
|
+
}
|
|
8390
|
+
|
|
8391
|
+
/** Never throws: a corrupt cache or a bad Host response degrades to the empty document. */
|
|
8392
|
+
function wsRowsNormalizeDoc(input) {
|
|
8393
|
+
const doc = wsRowsEmptyDoc();
|
|
8394
|
+
if (input === null || typeof input !== "object" || Array.isArray(input)) return doc;
|
|
8395
|
+
const workspaces = input.workspaces;
|
|
8396
|
+
if (workspaces === null || typeof workspaces !== "object" || Array.isArray(workspaces)) return doc;
|
|
8397
|
+
for (const [id, entry] of Object.entries(workspaces)) {
|
|
8398
|
+
if (!wsRowsValidId(id)) continue;
|
|
8399
|
+
if (id === "__proto__" || id === "constructor" || id === "prototype") continue;
|
|
8400
|
+
doc.workspaces[id] = wsRowsNormalizeEntry(entry);
|
|
8401
|
+
}
|
|
8402
|
+
return doc;
|
|
8403
|
+
}
|
|
8404
|
+
|
|
8405
|
+
/** An entry that carries no preference at all is dropped instead of being kept as junk. */
|
|
8406
|
+
function wsRowsIsDefaultEntry(entry) {
|
|
8407
|
+
return entry.tag === "" && entry.pinned !== true && entry.hidden !== true && entry.pinAnchor === null;
|
|
8408
|
+
}
|
|
8409
|
+
|
|
8410
|
+
function wsRowsReadFlag(key, fallback) {
|
|
8411
|
+
try {
|
|
8412
|
+
const raw = window.localStorage.getItem(key);
|
|
8413
|
+
if (raw === "1") return true;
|
|
8414
|
+
if (raw === "0") return false;
|
|
8415
|
+
return fallback;
|
|
8416
|
+
} catch { return fallback; }
|
|
8417
|
+
}
|
|
8418
|
+
|
|
8419
|
+
function wsRowsWriteFlag(key, value) {
|
|
8420
|
+
try { window.localStorage.setItem(key, value ? "1" : "0"); } catch { /* advisory */ }
|
|
8421
|
+
}
|
|
8422
|
+
|
|
8423
|
+
function wsRowsReadLocalPrefs() {
|
|
8424
|
+
try {
|
|
8425
|
+
const raw = window.localStorage.getItem(WS_PREFS_LOCAL_KEY);
|
|
8426
|
+
if (typeof raw !== "string" || raw === "") return wsRowsEmptyDoc();
|
|
8427
|
+
return wsRowsNormalizeDoc(JSON.parse(raw));
|
|
8428
|
+
} catch { return wsRowsEmptyDoc(); }
|
|
8429
|
+
}
|
|
8430
|
+
|
|
8431
|
+
function wsRowsWriteLocalPrefs(doc) {
|
|
8432
|
+
try { window.localStorage.setItem(WS_PREFS_LOCAL_KEY, JSON.stringify(doc)); } catch { /* advisory */ }
|
|
8433
|
+
}
|
|
8434
|
+
|
|
8435
|
+
/** One warning per page at most, and only for a real degradation. */
|
|
8436
|
+
let wsRowsWarned = false;
|
|
8437
|
+
function wsRowsWarn(message, error) {
|
|
8438
|
+
if (wsRowsWarned) return;
|
|
8439
|
+
wsRowsWarned = true;
|
|
8440
|
+
try { console.warn("dsh-lookout: " + message, error); } catch { /* no console */ }
|
|
8441
|
+
}
|
|
8442
|
+
|
|
8443
|
+
/** The table's row list: live workspaces first, the stored ids when the host list is unavailable. */
|
|
8444
|
+
function wsRowsTableRows(prefs, items) {
|
|
8445
|
+
const doc = wsRowsNormalizeDoc(prefs);
|
|
8446
|
+
const rows = [];
|
|
8447
|
+
const seen = new Set();
|
|
8448
|
+
const roots = wsPinRootIds(items);
|
|
8449
|
+
if (Array.isArray(items)) {
|
|
8450
|
+
for (const item of items) {
|
|
8451
|
+
if (item === null || typeof item !== "object") continue;
|
|
8452
|
+
const id = typeof item.workspaceId === "string" ? item.workspaceId : (typeof item.id === "string" ? item.id : "");
|
|
8453
|
+
if (!wsRowsValidId(id) || seen.has(id)) continue;
|
|
8454
|
+
seen.add(id);
|
|
8455
|
+
const title = typeof item.title === "string" && item.title.trim() !== ""
|
|
8456
|
+
? item.title
|
|
8457
|
+
: (typeof item.name === "string" && item.name.trim() !== "" ? item.name : id);
|
|
8458
|
+
rows.push({
|
|
8459
|
+
id,
|
|
8460
|
+
name: title,
|
|
8461
|
+
child: items.length > 0 && !roots.has(id),
|
|
8462
|
+
entry: doc.workspaces[id] !== undefined ? doc.workspaces[id] : wsRowsNormalizeEntry(null)
|
|
8463
|
+
});
|
|
8464
|
+
}
|
|
8465
|
+
}
|
|
8466
|
+
if (rows.length === 0) {
|
|
8467
|
+
for (const id of Object.keys(doc.workspaces)) rows.push({ id, name: id, child: false, entry: doc.workspaces[id] });
|
|
8468
|
+
}
|
|
8469
|
+
return rows;
|
|
8470
|
+
}
|
|
8471
|
+
|
|
8472
|
+
function wsRowsKnownIds(items) {
|
|
8473
|
+
const ids = [];
|
|
8474
|
+
if (!Array.isArray(items)) return ids;
|
|
8475
|
+
for (const item of items) {
|
|
8476
|
+
if (item === null || typeof item !== "object") continue;
|
|
8477
|
+
const id = typeof item.workspaceId === "string" ? item.workspaceId : (typeof item.id === "string" ? item.id : "");
|
|
8478
|
+
if (wsRowsValidId(id)) ids.push(id);
|
|
8479
|
+
}
|
|
8480
|
+
return ids;
|
|
8481
|
+
}
|
|
8482
|
+
|
|
8483
|
+
/** Path spelling used by the shipped workspace list: forward slashes, no trailing slash. */
|
|
8484
|
+
function wsPinNormalizePath(value) {
|
|
8485
|
+
if (typeof value !== "string") return "";
|
|
8486
|
+
const trimmed = value.trim();
|
|
8487
|
+
if (trimmed === "") return "";
|
|
8488
|
+
const normalized = trimmed.replace(/\\/g, "/").replace(/\/+$/, "");
|
|
8489
|
+
return normalized === "" ? "/" : normalized;
|
|
8490
|
+
}
|
|
8491
|
+
|
|
8492
|
+
/**
|
|
8493
|
+
* Workspaces that are roots rather than children: a path sitting under
|
|
8494
|
+
* another registered workspace's path is a child and never pins on its own.
|
|
8495
|
+
* An item without a usable path counts as a root (topology unknown, never
|
|
8496
|
+
* guess); a stored pin on a child is ignored but never deleted.
|
|
8497
|
+
* @returns Set of root workspace ids.
|
|
8498
|
+
*/
|
|
8499
|
+
function wsPinRootIds(items) {
|
|
8500
|
+
const entries = [];
|
|
8501
|
+
if (Array.isArray(items)) {
|
|
8502
|
+
for (const item of items) {
|
|
8503
|
+
if (item === null || typeof item !== "object") continue;
|
|
8504
|
+
const id = typeof item.workspaceId === "string" && item.workspaceId !== ""
|
|
8505
|
+
? item.workspaceId
|
|
8506
|
+
: (typeof item.id === "string" ? item.id : "");
|
|
8507
|
+
if (!wsRowsValidId(id)) continue;
|
|
8508
|
+
entries.push({ id, path: wsPinNormalizePath(item.path) });
|
|
8509
|
+
}
|
|
8510
|
+
}
|
|
8511
|
+
const roots = new Set();
|
|
8512
|
+
for (const entry of entries) {
|
|
8513
|
+
let child = false;
|
|
8514
|
+
if (entry.path !== "") {
|
|
8515
|
+
for (const parent of entries) {
|
|
8516
|
+
if (parent === entry || parent.path === "") continue;
|
|
8517
|
+
const under = parent.path === "/"
|
|
8518
|
+
? entry.path.charAt(0) === "/"
|
|
8519
|
+
: entry.path.indexOf(parent.path + "/") === 0;
|
|
8520
|
+
if (under) { child = true; break; }
|
|
8521
|
+
}
|
|
8522
|
+
}
|
|
8523
|
+
if (!child) roots.add(entry.id);
|
|
8524
|
+
}
|
|
8525
|
+
return roots;
|
|
8526
|
+
}
|
|
8527
|
+
|
|
8528
|
+
/**
|
|
8529
|
+
* The minimum moves that put every pinned ROOT workspace at the front while
|
|
8530
|
+
* keeping their current relative order. Returns null when the list already
|
|
8531
|
+
* satisfies that, so a settled list makes ZERO insertBefore calls.
|
|
8532
|
+
*
|
|
8533
|
+
* Only the pinned workspaces that are not already in the correct prefix move;
|
|
8534
|
+
* each is inserted before the same first unpinned workspace, which keeps the
|
|
8535
|
+
* moved block in order without reading the list between awaits.
|
|
8536
|
+
* @param prefsDoc - the preference document.
|
|
8537
|
+
* @param items - the live workspace list (workspaceId / path / title).
|
|
8538
|
+
* @returns { steps, key } or null.
|
|
8539
|
+
*/
|
|
8540
|
+
function wsPinPlan(prefsDoc, items) {
|
|
8541
|
+
const doc = wsRowsNormalizeDoc(prefsDoc);
|
|
8542
|
+
const ids = [];
|
|
8543
|
+
if (Array.isArray(items)) {
|
|
8544
|
+
for (const item of items) {
|
|
8545
|
+
if (item === null || typeof item !== "object") continue;
|
|
8546
|
+
const id = typeof item.workspaceId === "string" && item.workspaceId !== ""
|
|
8547
|
+
? item.workspaceId
|
|
8548
|
+
: (typeof item.id === "string" ? item.id : "");
|
|
8549
|
+
if (wsRowsValidId(id) && ids.indexOf(id) < 0) ids.push(id);
|
|
8550
|
+
}
|
|
8551
|
+
}
|
|
8552
|
+
if (ids.length === 0) return null;
|
|
8553
|
+
const roots = wsPinRootIds(items);
|
|
8554
|
+
const pinned = [];
|
|
8555
|
+
for (const id of ids) {
|
|
8556
|
+
const entry = doc.workspaces[id];
|
|
8557
|
+
if (entry === undefined || entry.pinned !== true) continue;
|
|
8558
|
+
if (!roots.has(id)) continue; // a child's historical pin is ignored, not cleared
|
|
8559
|
+
pinned.push(id);
|
|
8560
|
+
}
|
|
8561
|
+
if (pinned.length === 0) return null;
|
|
8562
|
+
let placed = 0;
|
|
8563
|
+
while (placed < pinned.length && ids[placed] === pinned[placed]) placed++;
|
|
8564
|
+
if (placed >= pinned.length) return null;
|
|
8565
|
+
// ids[placed] is non-pinned by construction, so it is the stable anchor.
|
|
8566
|
+
const before = ids[placed];
|
|
8567
|
+
const steps = [];
|
|
8568
|
+
for (let index = placed; index < pinned.length; index++) steps.push({ id: pinned[index], before });
|
|
8569
|
+
return { steps, key: steps.map((step) => step.id + ">" + step.before).join("|") };
|
|
8570
|
+
}
|
|
8571
|
+
|
|
8572
|
+
/** Escape a value for a double-quoted CSS string; control characters are quoted numerically. */
|
|
8573
|
+
function wsRowsCssString(value) {
|
|
8574
|
+
let out = "";
|
|
8575
|
+
for (const character of String(value)) {
|
|
8576
|
+
const code = character.codePointAt(0);
|
|
8577
|
+
if (character === '"' || character === "\\") out += "\\" + character;
|
|
8578
|
+
else if (code < 0x20 || code === 0x7f) out += "\\" + code.toString(16) + " ";
|
|
8579
|
+
else out += character;
|
|
8580
|
+
}
|
|
8581
|
+
return out;
|
|
8582
|
+
}
|
|
8583
|
+
|
|
8584
|
+
function wsRowsStart(ctx, require) {
|
|
8585
|
+
let controller = null;
|
|
8586
|
+
try {
|
|
8587
|
+
controller = wsRowsInstall(ctx, require);
|
|
8588
|
+
} catch (error) {
|
|
8589
|
+
wsRowsWarn("工作区标签初始化失败,已跳过(不影响宿主):", error);
|
|
8590
|
+
return;
|
|
8591
|
+
}
|
|
8592
|
+
try {
|
|
8593
|
+
if (ctx !== null && ctx !== undefined && typeof ctx.effect === "function") {
|
|
8594
|
+
ctx.effect(() => () => { try { controller.dispose(); } catch { /* best effort */ } }, "dsh-lookout: workspace rows");
|
|
8595
|
+
} else if (ctx !== null && ctx !== undefined && typeof ctx.on === "function") {
|
|
8596
|
+
ctx.on("dispose", () => { try { controller.dispose(); } catch { /* best effort */ } });
|
|
8597
|
+
}
|
|
8598
|
+
} catch (error) {
|
|
8599
|
+
wsRowsWarn("工作区标签挂到生命周期失败:", error);
|
|
8600
|
+
}
|
|
8601
|
+
}
|
|
8602
|
+
|
|
8603
|
+
/**
|
|
8604
|
+
* Install tags + hiding + the settings table, and return the controller.
|
|
8605
|
+
* Every DOM lookup is guarded: a host upgrade can only cost the tags.
|
|
8606
|
+
* @returns {object} controller: dispose / snapshot / subscribe / updateEntry /
|
|
8607
|
+
* cleanupMissing / paintNow / domWorkspaceIds / setEnabled / isEnabled.
|
|
8608
|
+
*/
|
|
8609
|
+
function wsRowsInstall(ctx, require) {
|
|
8610
|
+
let disposed = false;
|
|
8611
|
+
let enabled = wsRowsReadFlag(WS_ROWS_ENABLED_KEY, true);
|
|
8612
|
+
let prefs = wsRowsReadLocalPrefs();
|
|
8613
|
+
let localDirty = false;
|
|
8614
|
+
let translate = wsRowsFallbackText;
|
|
8615
|
+
let observer = null;
|
|
8616
|
+
let observerRoot = null;
|
|
8617
|
+
let retryTimer = 0;
|
|
8618
|
+
let scanTimer = 0;
|
|
8619
|
+
let saveTimer = 0;
|
|
8620
|
+
let painting = false;
|
|
8621
|
+
let paintedAt = 0;
|
|
8622
|
+
let failures = 0;
|
|
8623
|
+
let givenUp = false;
|
|
8624
|
+
let saveFailedOnce = false;
|
|
8625
|
+
let toast = null;
|
|
8626
|
+
let toastTimer = 0;
|
|
8627
|
+
const prefListeners = new Set();
|
|
8628
|
+
// ---- pinning --------------------------------------------------------
|
|
8629
|
+
// The workspace service arrives late (ctx.inject) and is optional: tags,
|
|
8630
|
+
// hiding and the preferences table never wait for it.
|
|
8631
|
+
let pinService = null;
|
|
8632
|
+
let pinUnsubscribe = null;
|
|
8633
|
+
let pinTimer = 0;
|
|
8634
|
+
let pinRunning = false;
|
|
8635
|
+
let pinDirty = false;
|
|
8636
|
+
let pinWarned = false;
|
|
8637
|
+
let pinFailedOnce = false;
|
|
8638
|
+
let pinFailures = 0;
|
|
8639
|
+
let pinSuspended = false;
|
|
8640
|
+
let pinCooldownUntil = 0;
|
|
8641
|
+
|
|
8642
|
+
// ---- locale ---------------------------------------------------------
|
|
8643
|
+
try {
|
|
8644
|
+
const locale = ctx !== null && ctx !== undefined ? ctx.locale : null;
|
|
8645
|
+
if (locale !== null && locale !== undefined && typeof locale.register === "function") {
|
|
8646
|
+
const register = () => locale.register(WS_ROWS_NS, { zh: WS_ROWS_ZH, en: WS_ROWS_EN });
|
|
8647
|
+
if (typeof ctx.effect === "function") ctx.effect(register, "dsh-lookout: workspace rows dictionaries");
|
|
8648
|
+
else register();
|
|
8649
|
+
}
|
|
8650
|
+
if (locale !== null && locale !== undefined && typeof locale.bind === "function") {
|
|
8651
|
+
const bound = locale.bind(WS_ROWS_NS);
|
|
8652
|
+
translate = (key, params) => {
|
|
8653
|
+
let text = key;
|
|
8654
|
+
try { text = bound(key, params); } catch { text = key; }
|
|
8655
|
+
return typeof text === "string" && text !== key ? text : wsRowsFallbackText(key, params);
|
|
8656
|
+
};
|
|
8657
|
+
}
|
|
8658
|
+
} catch (error) { /* Chinese fallback is already in place */ }
|
|
8659
|
+
|
|
8660
|
+
const api = {
|
|
8661
|
+
snapshot: snapshot,
|
|
8662
|
+
subscribe: subscribePrefs,
|
|
8663
|
+
updateEntry: updateEntry,
|
|
8664
|
+
cleanupMissing: cleanupMissing,
|
|
8665
|
+
paintNow: paint,
|
|
8666
|
+
pinNow: pinNow,
|
|
8667
|
+
domWorkspaceIds: domWorkspaceIds,
|
|
8668
|
+
setEnabled: setEnabled,
|
|
8669
|
+
isEnabled: () => enabled === true
|
|
8670
|
+
};
|
|
8671
|
+
|
|
8672
|
+
// ---- settings page --------------------------------------------------
|
|
8673
|
+
// Own section (order 56, right after the file-row menu at 55): the three
|
|
8674
|
+
// bundled parts own their pages and are byte-locked.
|
|
8675
|
+
try {
|
|
8676
|
+
const React = require("react");
|
|
8677
|
+
if (React !== null && typeof React.createElement === "function" && typeof React.useState === "function") {
|
|
8678
|
+
function WorkspaceRowsSection(props) {
|
|
8679
|
+
const label = props !== null && props !== undefined && typeof props.t === "function" ? props.t : translate;
|
|
8680
|
+
const [view, setView] = React.useState(() => prefs);
|
|
8681
|
+
const [on, setOn] = React.useState(() => enabled);
|
|
8682
|
+
const [notice, setNotice] = React.useState("");
|
|
8683
|
+
React.useEffect(() => subscribePrefs(setView), []);
|
|
8684
|
+
const useWorkspaces = props !== null && props !== undefined ? props.useWorkspaces : null;
|
|
8685
|
+
const snapshotState = typeof useWorkspaces === "function" ? useWorkspaces((state) => state) : undefined;
|
|
8686
|
+
const items = snapshotState !== null && typeof snapshotState === "object" && Array.isArray(snapshotState.items) ? snapshotState.items : [];
|
|
8687
|
+
const rows = wsRowsTableRows(view, items);
|
|
8688
|
+
const commit = (id, patch) => { updateEntry(id, patch); };
|
|
8689
|
+
const onCleanup = () => {
|
|
8690
|
+
const live = wsRowsKnownIds(items);
|
|
8691
|
+
const ids = live.length > 0 ? live : domWorkspaceIds();
|
|
8692
|
+
const removed = cleanupMissing(ids);
|
|
8693
|
+
setNotice(removed > 0 ? label("cleaned", { n: removed }) : label("cleanNone"));
|
|
8694
|
+
};
|
|
8695
|
+
const header = React.createElement("thead", null,
|
|
8696
|
+
React.createElement("tr", null,
|
|
8697
|
+
["colName", "colTag", "colColor", "colPin", "colHide"].map((key) =>
|
|
8698
|
+
React.createElement("th", { key, style: WS_STYLE_TH }, label(key)))));
|
|
8699
|
+
const body = React.createElement("tbody", null,
|
|
8700
|
+
rows.map((row) => React.createElement("tr", { key: row.id, "data-lookout-ws-row": row.id },
|
|
8701
|
+
React.createElement("td", { style: WS_STYLE_TD }, row.name),
|
|
8702
|
+
React.createElement("td", { style: WS_STYLE_TD },
|
|
8703
|
+
React.createElement("input", {
|
|
8704
|
+
"data-lookout-ws-field": "tag",
|
|
8705
|
+
"data-lookout-ws-id": row.id,
|
|
8706
|
+
type: "text",
|
|
8707
|
+
maxLength: WS_TAG_MAX,
|
|
8708
|
+
value: row.entry.tag,
|
|
8709
|
+
placeholder: label("tagPlaceholder"),
|
|
8710
|
+
style: { width: 96 },
|
|
8711
|
+
onChange: (event) => commit(row.id, { tag: event.target.value })
|
|
8712
|
+
})),
|
|
8713
|
+
React.createElement("td", { style: WS_STYLE_TD },
|
|
8714
|
+
React.createElement("select", {
|
|
8715
|
+
"data-lookout-ws-field": "color",
|
|
8716
|
+
"data-lookout-ws-id": row.id,
|
|
8717
|
+
value: row.entry.color,
|
|
8718
|
+
onChange: (event) => commit(row.id, { color: event.target.value })
|
|
8719
|
+
}, WS_COLORS.map((name) =>
|
|
8720
|
+
React.createElement("option", { key: name, value: name },
|
|
8721
|
+
label("color" + name.charAt(0).toUpperCase() + name.slice(1)))))),
|
|
8722
|
+
React.createElement("td", { style: WS_STYLE_TD },
|
|
8723
|
+
React.createElement("input", {
|
|
8724
|
+
"data-lookout-ws-field": "pinned",
|
|
8725
|
+
"data-lookout-ws-id": row.id,
|
|
8726
|
+
type: "checkbox",
|
|
8727
|
+
checked: row.entry.pinned === true,
|
|
8728
|
+
disabled: row.child === true,
|
|
8729
|
+
title: row.child === true ? label("pinChildHint") : undefined,
|
|
8730
|
+
onChange: (event) => commit(row.id, { pinned: event.target.checked === true })
|
|
8731
|
+
})),
|
|
8732
|
+
React.createElement("td", { style: WS_STYLE_TD },
|
|
8733
|
+
React.createElement("input", {
|
|
8734
|
+
"data-lookout-ws-field": "hidden",
|
|
8735
|
+
"data-lookout-ws-id": row.id,
|
|
8736
|
+
type: "checkbox",
|
|
8737
|
+
checked: row.entry.hidden === true,
|
|
8738
|
+
onChange: (event) => commit(row.id, { hidden: event.target.checked === true })
|
|
8739
|
+
})))));
|
|
8740
|
+
return React.createElement("div", { "data-lookout-settings": "workspaces" },
|
|
8741
|
+
React.createElement("div", { style: WS_STYLE_TITLE }, label("nav")),
|
|
8742
|
+
rows.length === 0
|
|
8743
|
+
? React.createElement("div", { style: WS_STYLE_HINT }, label("empty"))
|
|
8744
|
+
: React.createElement("table", { style: WS_STYLE_TABLE, "data-lookout-ws-table": "" }, header, body),
|
|
8745
|
+
rows.some((row) => row.child === true)
|
|
8746
|
+
? React.createElement("div", { style: WS_STYLE_HINT }, label("pinChildHint"))
|
|
8747
|
+
: null,
|
|
8748
|
+
React.createElement("div", { style: WS_STYLE_ACTIONS },
|
|
8749
|
+
React.createElement("button", {
|
|
8750
|
+
type: "button",
|
|
8751
|
+
"data-lookout-ws-cleanup": "",
|
|
8752
|
+
onClick: onCleanup
|
|
8753
|
+
}, label("cleanup")),
|
|
8754
|
+
React.createElement("label", { style: WS_STYLE_TOGGLE },
|
|
8755
|
+
React.createElement("input", {
|
|
8756
|
+
type: "checkbox",
|
|
8757
|
+
"data-lookout-ws-toggle": "",
|
|
8758
|
+
checked: on,
|
|
8759
|
+
onChange: (event) => {
|
|
8760
|
+
const next = event.target.checked === true;
|
|
8761
|
+
setOn(next);
|
|
8762
|
+
setEnabled(next);
|
|
8763
|
+
}
|
|
8764
|
+
}),
|
|
8765
|
+
React.createElement("span", null, label("toggleLabel")))),
|
|
8766
|
+
notice !== "" ? React.createElement("div", { style: WS_STYLE_HINT }, notice) : null,
|
|
8767
|
+
React.createElement("div", { style: WS_STYLE_HINT }, label("toggleHint")),
|
|
8768
|
+
React.createElement("div", { style: WS_STYLE_HINT }, label("pinnedHint")));
|
|
8769
|
+
}
|
|
8770
|
+
const slots = ctx !== null && ctx !== undefined ? ctx.slots : null;
|
|
8771
|
+
if (slots !== null && slots !== undefined && typeof slots.inject === "function" && typeof slots.register === "function") {
|
|
8772
|
+
slots.inject("settings.section", () => slots.register({
|
|
8773
|
+
name: "settings.section",
|
|
8774
|
+
id: "lookout-workspaces",
|
|
8775
|
+
order: 56,
|
|
8776
|
+
label: () => translate("nav"),
|
|
8777
|
+
locale: WS_ROWS_NS,
|
|
8778
|
+
inject: () => ({ t: translate, wsRows: api })
|
|
8779
|
+
}, (props) => React.createElement(WorkspaceRowsSection, Object.assign({}, props, {
|
|
8780
|
+
t: props !== null && props !== undefined && typeof props.t === "function" ? props.t : translate,
|
|
8781
|
+
wsRows: api
|
|
8782
|
+
}))));
|
|
8783
|
+
}
|
|
8784
|
+
}
|
|
8785
|
+
} catch (error) {
|
|
8786
|
+
// Settings are optional; the tags and hiding are not.
|
|
8787
|
+
}
|
|
8788
|
+
|
|
8789
|
+
// ---- styles ---------------------------------------------------------
|
|
8790
|
+
function ensureStyleTag(key, css) {
|
|
8791
|
+
try {
|
|
8792
|
+
const existing = document.querySelector('style[data-plugin-css="' + key + '"]');
|
|
8793
|
+
if (existing !== null && existing !== undefined) {
|
|
8794
|
+
// A linked (hot-reloaded) client keeps its <style> tags alive. Refresh
|
|
8795
|
+
// stale text so a new build's CSS actually reaches the page.
|
|
8796
|
+
if (existing.textContent !== css) {
|
|
8797
|
+
try { existing.textContent = css; } catch { /* tag gone */ }
|
|
8798
|
+
}
|
|
8799
|
+
return existing;
|
|
8800
|
+
}
|
|
8801
|
+
const tag = document.createElement("style");
|
|
8802
|
+
tag.setAttribute("data-plugin", "dsh-lookout");
|
|
8803
|
+
tag.setAttribute("data-plugin-css", key);
|
|
8804
|
+
tag.textContent = css;
|
|
8805
|
+
const head = document.head !== null && document.head !== undefined ? document.head : document.body;
|
|
8806
|
+
if (head !== null && head !== undefined) head.appendChild(tag);
|
|
8807
|
+
return tag;
|
|
8808
|
+
} catch { return null; }
|
|
8809
|
+
}
|
|
8810
|
+
|
|
8811
|
+
function ensureStaticStyle() {
|
|
8812
|
+
ensureStyleTag(WS_STYLE_KEY, WS_ROWS_CSS);
|
|
8813
|
+
}
|
|
8814
|
+
|
|
8815
|
+
function ensureHideStyleTag() {
|
|
8816
|
+
return ensureStyleTag(WS_HIDE_STYLE_KEY, "");
|
|
8817
|
+
}
|
|
8818
|
+
|
|
8819
|
+
// ---- DOM lookups ----------------------------------------------------
|
|
8820
|
+
function container() {
|
|
8821
|
+
try {
|
|
8822
|
+
return document.querySelector(WS_CONTAINER_SELECTOR) || document.querySelector(WS_CONTAINER_FALLBACK) || null;
|
|
8823
|
+
} catch { return null; }
|
|
8824
|
+
}
|
|
8825
|
+
|
|
8826
|
+
function allRowNodes(root) {
|
|
8827
|
+
try {
|
|
8828
|
+
return Array.prototype.slice.call(root.querySelectorAll("[" + WS_ROW_KEY_ATTR + "]"));
|
|
8829
|
+
} catch { return []; }
|
|
8830
|
+
}
|
|
8831
|
+
|
|
8832
|
+
function workspaceRows(root) {
|
|
8833
|
+
return allRowNodes(root).filter((node) => {
|
|
8834
|
+
const key = node.getAttribute(WS_ROW_KEY_ATTR);
|
|
8835
|
+
return typeof key === "string" && key.indexOf(WS_WORKSPACE_PREFIX) === 0;
|
|
8836
|
+
});
|
|
8837
|
+
}
|
|
8838
|
+
|
|
8839
|
+
function rowIdOf(row) {
|
|
8840
|
+
const key = row.getAttribute(WS_ROW_KEY_ATTR);
|
|
8841
|
+
if (typeof key !== "string" || key.indexOf(WS_WORKSPACE_PREFIX) !== 0) return null;
|
|
8842
|
+
const id = key.slice(WS_WORKSPACE_PREFIX.length);
|
|
8843
|
+
return wsRowsValidId(id) ? id : null;
|
|
8844
|
+
}
|
|
8845
|
+
|
|
8846
|
+
function domWorkspaceIds() {
|
|
8847
|
+
const root = container();
|
|
8848
|
+
if (root === null) return [];
|
|
8849
|
+
const ids = [];
|
|
8850
|
+
for (const row of workspaceRows(root)) {
|
|
8851
|
+
const id = rowIdOf(row);
|
|
8852
|
+
if (id !== null) ids.push(id);
|
|
8853
|
+
}
|
|
8854
|
+
return ids;
|
|
8855
|
+
}
|
|
8856
|
+
|
|
8857
|
+
/**
|
|
8858
|
+
* The title element inside one workspace row.
|
|
8859
|
+
*
|
|
8860
|
+
* Primary match: the class token the shipped row gives its title
|
|
8861
|
+
* (YDXeBa_title -> a token ending in "title"). Fallback: the only descendant
|
|
8862
|
+
* span that holds text and no element children. Ambiguity yields null (a
|
|
8863
|
+
* missing badge), never a badge on the wrong row.
|
|
8864
|
+
*/
|
|
8865
|
+
function findTitleElement(row) {
|
|
8866
|
+
let spans;
|
|
8867
|
+
try { spans = Array.prototype.slice.call(row.querySelectorAll("span")); } catch { return null; }
|
|
8868
|
+
for (const span of spans) {
|
|
8869
|
+
const tokens = String(span.className === null || span.className === undefined ? "" : span.className).split(/\s+/);
|
|
8870
|
+
for (const token of tokens) {
|
|
8871
|
+
if (token !== "" && /(?:^|[_-])title$/i.test(token)) return span;
|
|
8872
|
+
}
|
|
8873
|
+
}
|
|
8874
|
+
const holders = [];
|
|
8875
|
+
for (const span of spans) {
|
|
8876
|
+
const text = typeof span.textContent === "string" ? span.textContent.trim() : "";
|
|
8877
|
+
if (text === "") continue;
|
|
8878
|
+
let hasElementChild = false;
|
|
8879
|
+
for (const child of span.children) {
|
|
8880
|
+
if (child.tagName === "SPAN" || child.tagName === "DIV" || child.tagName === "BUTTON" || child.tagName === "SVG") {
|
|
8881
|
+
hasElementChild = true;
|
|
8882
|
+
break;
|
|
8883
|
+
}
|
|
8884
|
+
}
|
|
8885
|
+
if (!hasElementChild) holders.push(span);
|
|
8886
|
+
}
|
|
8887
|
+
return holders.length === 1 ? holders[0] : null;
|
|
8888
|
+
}
|
|
8889
|
+
|
|
8890
|
+
/**
|
|
8891
|
+
* The direct child span that holds the row's hover actions: the "⋯" menu
|
|
8892
|
+
* and the new-session button. The shipped row gives it a class token ending
|
|
8893
|
+
* in "rowActions"; a row without one puts the tag at the row's end instead.
|
|
8894
|
+
*/
|
|
8895
|
+
function findRowActions(row) {
|
|
8896
|
+
let children;
|
|
8897
|
+
try { children = Array.prototype.slice.call(row.children); } catch { return null; }
|
|
8898
|
+
for (const child of children) {
|
|
8899
|
+
if (child.tagName !== "SPAN") continue;
|
|
8900
|
+
const tokens = String(child.className === null || child.className === undefined ? "" : child.className).split(/\s+/);
|
|
8901
|
+
for (const token of tokens) {
|
|
8902
|
+
if (token !== "" && /(?:^|[_-])rowActions$/i.test(token)) return child;
|
|
8903
|
+
}
|
|
8904
|
+
}
|
|
8905
|
+
return null;
|
|
8906
|
+
}
|
|
8907
|
+
|
|
8908
|
+
/**
|
|
8909
|
+
* Where the badge goes: the workspace row itself (div.projectRow), as a
|
|
8910
|
+
* direct child immediately before span.rowActions (or at the row's end when
|
|
8911
|
+
* the host renders no actions). The title only has to exist: the badge no
|
|
8912
|
+
* longer lives inside its flex column, so nothing has to be flipped to a row.
|
|
8913
|
+
*/
|
|
8914
|
+
function findTagAnchor(row) {
|
|
8915
|
+
return findTitleElement(row) === null ? null : row;
|
|
8916
|
+
}
|
|
8917
|
+
|
|
8918
|
+
/** True when the badge already sits at its home: row end, left of rowActions. */
|
|
8919
|
+
function badgeInPlace(row, badge, actions) {
|
|
8920
|
+
let children;
|
|
8921
|
+
try { children = Array.prototype.slice.call(row.children); } catch { return false; }
|
|
8922
|
+
const badgeIndex = children.indexOf(badge);
|
|
8923
|
+
if (badgeIndex < 0) return false;
|
|
8924
|
+
if (actions === null || actions.parentNode !== row) return badgeIndex === children.length - 1;
|
|
8925
|
+
return badgeIndex === children.indexOf(actions) - 1;
|
|
8926
|
+
}
|
|
8927
|
+
|
|
8928
|
+
/** Insert or move the badge to its home: row end, but left of rowActions. */
|
|
8929
|
+
function placeBadge(row, badge, actions) {
|
|
8930
|
+
if (actions !== null && actions.parentNode === row) row.insertBefore(badge, actions);
|
|
8931
|
+
else row.appendChild(badge);
|
|
8932
|
+
}
|
|
8933
|
+
|
|
8934
|
+
function badgeIn(row, id) {
|
|
8935
|
+
let nodes;
|
|
8936
|
+
try { nodes = Array.prototype.slice.call(row.querySelectorAll("[" + WS_TAG_ATTR + "]")); } catch { return null; }
|
|
8937
|
+
for (const node of nodes) if (node.getAttribute(WS_TAG_ATTR) === id) return node;
|
|
8938
|
+
return null;
|
|
8939
|
+
}
|
|
8940
|
+
|
|
8941
|
+
function createBadge(id, tag, color) {
|
|
8942
|
+
const badge = document.createElement("span");
|
|
8943
|
+
badge.className = "dsh-lookout-ws-tag";
|
|
8944
|
+
badge.setAttribute(WS_TAG_ATTR, id);
|
|
8945
|
+
badge.setAttribute("data-color", color);
|
|
8946
|
+
badge.title = tag;
|
|
8947
|
+
const dot = document.createElement("span");
|
|
8948
|
+
dot.className = "dsh-lookout-ws-tag-dot";
|
|
8949
|
+
dot.setAttribute("aria-hidden", "true");
|
|
8950
|
+
const text = document.createElement("span");
|
|
8951
|
+
text.className = "dsh-lookout-ws-tag-text";
|
|
8952
|
+
text.setAttribute(WS_TAG_TEXT_ATTR, "");
|
|
8953
|
+
text.textContent = tag;
|
|
8954
|
+
badge.appendChild(dot);
|
|
8955
|
+
badge.appendChild(text);
|
|
8956
|
+
return badge;
|
|
8957
|
+
}
|
|
8958
|
+
|
|
8959
|
+
function updateBadge(badge, tag, color) {
|
|
8960
|
+
badge.setAttribute("data-color", color);
|
|
8961
|
+
badge.title = tag;
|
|
8962
|
+
for (const child of badge.children) {
|
|
8963
|
+
if (child.getAttribute(WS_TAG_TEXT_ATTR) !== null && child.textContent !== tag) { child.textContent = tag; break; }
|
|
8964
|
+
}
|
|
8965
|
+
}
|
|
8966
|
+
|
|
8967
|
+
/**
|
|
8968
|
+
* Reconcile every rendered workspace row against the preferences.
|
|
8969
|
+
*
|
|
8970
|
+
* The badge is row-level: it must be a direct child of the row, immediately
|
|
8971
|
+
* before rowActions, pushed right by margin-left:auto. A badge left in the
|
|
8972
|
+
* old title-anchored spot (inside span.projectText), elsewhere under the
|
|
8973
|
+
* sidebar, or on the wrong side of the row is moved, never duplicated.
|
|
8974
|
+
*/
|
|
8975
|
+
function syncBadges(root, rows) {
|
|
8976
|
+
let placed = 0;
|
|
8977
|
+
let missed = 0;
|
|
8978
|
+
let wanted = 0;
|
|
8979
|
+
const known = new Set();
|
|
8980
|
+
for (const row of rows) {
|
|
8981
|
+
const id = rowIdOf(row);
|
|
8982
|
+
if (id === null) continue;
|
|
8983
|
+
known.add(id);
|
|
8984
|
+
const entry = prefs.workspaces[id];
|
|
8985
|
+
const tag = entry !== undefined ? entry.tag : "";
|
|
8986
|
+
if (tag === "") continue;
|
|
8987
|
+
wanted++;
|
|
8988
|
+
const anchor = findTagAnchor(row);
|
|
8989
|
+
if (anchor === null) { missed++; continue; }
|
|
8990
|
+
const actions = findRowActions(anchor);
|
|
8991
|
+
let badge = badgeIn(anchor, id);
|
|
8992
|
+
if (badge === null) {
|
|
8993
|
+
// Migrate a badge an older version left outside this row.
|
|
8994
|
+
try { badge = root.querySelector("[" + WS_TAG_ATTR + '="' + id + '"]'); } catch { badge = null; }
|
|
8995
|
+
}
|
|
8996
|
+
if (badge === null) placeBadge(anchor, createBadge(id, tag, entry.color), actions);
|
|
8997
|
+
else {
|
|
8998
|
+
if (!badgeInPlace(anchor, badge, actions)) placeBadge(anchor, badge, actions);
|
|
8999
|
+
updateBadge(badge, tag, entry.color);
|
|
9000
|
+
}
|
|
9001
|
+
placed++;
|
|
9002
|
+
}
|
|
9003
|
+
let stale;
|
|
9004
|
+
try { stale = Array.prototype.slice.call(root.querySelectorAll("[" + WS_TAG_ATTR + "]")); } catch { stale = []; }
|
|
9005
|
+
for (const badge of stale) {
|
|
9006
|
+
const id = badge.getAttribute(WS_TAG_ATTR);
|
|
9007
|
+
const entry = id !== null ? prefs.workspaces[id] : undefined;
|
|
9008
|
+
if (entry === undefined || entry.tag === "" || !known.has(id)) {
|
|
9009
|
+
try { badge.remove(); } catch { /* gone */ }
|
|
9010
|
+
}
|
|
9011
|
+
}
|
|
9012
|
+
return { placed, missed, wanted };
|
|
9013
|
+
}
|
|
9014
|
+
|
|
9015
|
+
// ---- current-session protection ------------------------------------
|
|
9016
|
+
/** The workspaces the official uiWorkspace service says hold the open session. */
|
|
9017
|
+
function officialCurrentWorkspaceIds() {
|
|
9018
|
+
const ids = new Set();
|
|
9019
|
+
try {
|
|
9020
|
+
const service = ctx !== null && ctx !== undefined && typeof ctx.get === "function" ? ctx.get("uiWorkspace") : null;
|
|
9021
|
+
if (service === null || service === undefined) return ids;
|
|
9022
|
+
const selection = service.selection !== null && service.selection !== undefined && typeof service.selection.getSnapshot === "function"
|
|
9023
|
+
? service.selection.getSnapshot()
|
|
9024
|
+
: null;
|
|
9025
|
+
const sessionId = selection !== null && typeof selection === "object" ? selection.sessionId : undefined;
|
|
9026
|
+
if (typeof sessionId !== "string" || sessionId === "") return ids;
|
|
9027
|
+
const list = service.workspaces !== null && service.workspaces !== undefined && service.workspaces.list !== null && service.workspaces.list !== undefined
|
|
9028
|
+
? service.workspaces.list.getSnapshot()
|
|
9029
|
+
: null;
|
|
9030
|
+
const items = list !== null && typeof list === "object" && Array.isArray(list.items) ? list.items : [];
|
|
9031
|
+
let current = null;
|
|
9032
|
+
for (const item of items) {
|
|
9033
|
+
if (item === null || typeof item !== "object") continue;
|
|
9034
|
+
if (!Array.isArray(item.sessionIds) || item.sessionIds.indexOf(sessionId) < 0) continue;
|
|
9035
|
+
if (typeof item.workspaceId === "string" && item.workspaceId !== "") { current = item; break; }
|
|
9036
|
+
}
|
|
9037
|
+
if (current === null) return ids;
|
|
9038
|
+
ids.add(current.workspaceId);
|
|
9039
|
+
const normalizePath = (value) => String(value).replace(/\\/g, "/").replace(/\/+$/, "");
|
|
9040
|
+
const currentPath = typeof current.path === "string" ? normalizePath(current.path) : "";
|
|
9041
|
+
if (currentPath !== "") {
|
|
9042
|
+
for (const item of items) {
|
|
9043
|
+
if (item === null || typeof item !== "object") continue;
|
|
9044
|
+
if (typeof item.workspaceId !== "string" || item.workspaceId === current.workspaceId) continue;
|
|
9045
|
+
const candidate = typeof item.path === "string" ? normalizePath(item.path) : "";
|
|
9046
|
+
if (candidate !== "" && currentPath.indexOf(candidate + "/") === 0) ids.add(item.workspaceId);
|
|
9047
|
+
}
|
|
9048
|
+
}
|
|
9049
|
+
} catch { /* no service: the DOM signals below still protect */ }
|
|
9050
|
+
return ids;
|
|
9051
|
+
}
|
|
9052
|
+
|
|
9053
|
+
function workspaceIdOfElement(element) {
|
|
9054
|
+
const key = element.getAttribute(WS_ROW_KEY_ATTR);
|
|
9055
|
+
if (typeof key !== "string" || key.indexOf(WS_WORKSPACE_PREFIX) !== 0) return null;
|
|
9056
|
+
const id = key.slice(WS_WORKSPACE_PREFIX.length);
|
|
9057
|
+
if (id === "") return "";
|
|
9058
|
+
return wsRowsValidId(id) ? id : null;
|
|
9059
|
+
}
|
|
9060
|
+
|
|
9061
|
+
/**
|
|
9062
|
+
* The workspace this group wrapper owns.
|
|
9063
|
+
*
|
|
9064
|
+
* HoverCard renders its anchor inside a wrapper <span> (the shipped row is a
|
|
9065
|
+
* direct child of that span), so the keyed row is either a direct child of
|
|
9066
|
+
* the group wrapper or one level below it. One level only: nested child
|
|
9067
|
+
* workspaces live under div[role="group"], never as a direct grandchild.
|
|
9068
|
+
*/
|
|
9069
|
+
function directWorkspaceChildId(element) {
|
|
9070
|
+
let children;
|
|
9071
|
+
try { children = element.children; } catch { return null; }
|
|
9072
|
+
if (children === null || children === undefined) return null;
|
|
9073
|
+
for (const child of children) {
|
|
9074
|
+
const direct = workspaceIdOfElement(child);
|
|
9075
|
+
if (direct !== null) return direct;
|
|
9076
|
+
let inner;
|
|
9077
|
+
try { inner = child.children; } catch { inner = null; }
|
|
9078
|
+
if (inner === null || inner === undefined) continue;
|
|
9079
|
+
for (const nested of inner) {
|
|
9080
|
+
const id = workspaceIdOfElement(nested);
|
|
9081
|
+
if (id !== null) return id;
|
|
9082
|
+
}
|
|
9083
|
+
}
|
|
9084
|
+
return null;
|
|
9085
|
+
}
|
|
9086
|
+
|
|
9087
|
+
/** Every workspace group that contains this node, nearest first. */
|
|
9088
|
+
function ancestorWorkspaceIds(node, root) {
|
|
9089
|
+
const ids = [];
|
|
9090
|
+
let element = node.parentElement;
|
|
9091
|
+
while (element !== null && element !== undefined && element !== root) {
|
|
9092
|
+
const id = directWorkspaceChildId(element);
|
|
9093
|
+
if (id !== null) ids.push(id);
|
|
9094
|
+
element = element.parentElement;
|
|
9095
|
+
}
|
|
9096
|
+
return ids;
|
|
9097
|
+
}
|
|
9098
|
+
|
|
9099
|
+
/** The shipped row marks the group holding the open session with a *folderActive class. */
|
|
9100
|
+
function marksCurrentGroup(row) {
|
|
9101
|
+
let nodes;
|
|
9102
|
+
try { nodes = Array.prototype.slice.call(row.querySelectorAll("span,div")); } catch { return false; }
|
|
9103
|
+
for (const node of nodes) {
|
|
9104
|
+
const tokens = String(node.className === null || node.className === undefined ? "" : node.className).split(/\s+/);
|
|
9105
|
+
for (const token of tokens) if (token !== "" && /folderActive$/i.test(token)) return true;
|
|
9106
|
+
}
|
|
9107
|
+
return false;
|
|
9108
|
+
}
|
|
9109
|
+
|
|
9110
|
+
/**
|
|
9111
|
+
* Workspace ids whose group must never be hidden.
|
|
9112
|
+
*
|
|
9113
|
+
* Three independent signals, unioned: the official service, the selected
|
|
9114
|
+
* session row (aria-selected / aria-current), and the folderActive mark that
|
|
9115
|
+
* also covers a COLLAPSED group whose session rows are not rendered. If a
|
|
9116
|
+
* selected session cannot be mapped to any workspace group, hide nothing at
|
|
9117
|
+
* all (blockAll): a row the person is reading must never disappear.
|
|
9118
|
+
*/
|
|
9119
|
+
function protectedIds(root) {
|
|
9120
|
+
const ids = new Set();
|
|
9121
|
+
let blockAll = false;
|
|
9122
|
+
for (const id of officialCurrentWorkspaceIds()) ids.add(id);
|
|
9123
|
+
for (const node of allRowNodes(root)) {
|
|
9124
|
+
const key = node.getAttribute(WS_ROW_KEY_ATTR);
|
|
9125
|
+
if (typeof key !== "string" || key.indexOf(WS_SESSION_PREFIX) !== 0) continue;
|
|
9126
|
+
const selected = node.getAttribute("aria-selected") === "true" || node.getAttribute("aria-current") === "true";
|
|
9127
|
+
if (!selected) continue;
|
|
9128
|
+
// "" is the ungrouped bucket's key (data-row-key="workspace:"): it counts as
|
|
9129
|
+
// "inside a group, but not inside any real workspace", so hiding the OTHER
|
|
9130
|
+
// workspaces is still safe. Only a selected session with no group wrapper at
|
|
9131
|
+
// all makes us hide nothing.
|
|
9132
|
+
const groupIds = ancestorWorkspaceIds(node, root);
|
|
9133
|
+
if (groupIds.length === 0) blockAll = true;
|
|
9134
|
+
for (const id of groupIds) if (id !== "") ids.add(id);
|
|
9135
|
+
}
|
|
9136
|
+
for (const row of workspaceRows(root)) {
|
|
9137
|
+
const id = rowIdOf(row);
|
|
9138
|
+
if (id !== null && marksCurrentGroup(row)) ids.add(id);
|
|
9139
|
+
}
|
|
9140
|
+
return { ids, blockAll };
|
|
9141
|
+
}
|
|
9142
|
+
|
|
9143
|
+
// ---- hiding ---------------------------------------------------------
|
|
9144
|
+
function renderHide(root) {
|
|
9145
|
+
const style = ensureHideStyleTag();
|
|
9146
|
+
let hidden = 0;
|
|
9147
|
+
let rules = [];
|
|
9148
|
+
try {
|
|
9149
|
+
const guard = protectedIds(root);
|
|
9150
|
+
for (const id of Object.keys(prefs.workspaces)) {
|
|
9151
|
+
if (prefs.workspaces[id].hidden !== true) continue;
|
|
9152
|
+
hidden++;
|
|
9153
|
+
if (guard.blockAll || guard.ids.has(id)) continue;
|
|
9154
|
+
if (!WS_ID_PATTERN.test(id)) continue;
|
|
9155
|
+
// Three shapes, all scoped to ONE or TWO levels above the keyed row, so
|
|
9156
|
+
// an ancestor group is never matched by accident:
|
|
9157
|
+
// div > row (ungrouped bucket; no hover wrapper)
|
|
9158
|
+
// div > span > row (the shipped HoverCard wrapper)
|
|
9159
|
+
// .*groupSection variants (belt and braces for the two shapes)
|
|
9160
|
+
const key = wsRowsCssString(WS_WORKSPACE_PREFIX + id);
|
|
9161
|
+
const attr = "[" + WS_ROW_KEY_ATTR + '="' + key + '"]';
|
|
9162
|
+
rules.push(
|
|
9163
|
+
"div:has(>" + attr + ")," +
|
|
9164
|
+
"div:has(>span>" + attr + ")," +
|
|
9165
|
+
'div[class*="groupSection"]:has(>' + attr + ")," +
|
|
9166
|
+
'div[class*="groupSection"]:has(>span>' + attr + ")" +
|
|
9167
|
+
"{display:none!important;}"
|
|
9168
|
+
);
|
|
9169
|
+
}
|
|
9170
|
+
} catch (error) {
|
|
9171
|
+
rules = [];
|
|
9172
|
+
wsRowsWarn("隐藏工作区样式生成失败,已跳过(不影响侧栏):", error);
|
|
9173
|
+
}
|
|
9174
|
+
if (style !== null && style !== undefined) {
|
|
9175
|
+
const text = rules.length === 0 ? "" : WS_HIDE_CSS_HEADER + "\n" + rules.join("\n");
|
|
9176
|
+
try { if (style.textContent !== text) style.textContent = text; } catch { /* gone */ }
|
|
9177
|
+
}
|
|
9178
|
+
return { rules: rules.length, wanted: hidden };
|
|
9179
|
+
}
|
|
9180
|
+
|
|
9181
|
+
// ---- paint ----------------------------------------------------------
|
|
9182
|
+
function paint() {
|
|
9183
|
+
if (disposed || !enabled || givenUp) return;
|
|
9184
|
+
const root = container();
|
|
9185
|
+
if (root === null) return;
|
|
9186
|
+
let rows;
|
|
9187
|
+
try { rows = workspaceRows(root); } catch (error) { noteFailure(error); return; }
|
|
9188
|
+
// Sidebar in rail mode, an empty list, or a build without workspace rows:
|
|
9189
|
+
// nothing to place is NOT a failure.
|
|
9190
|
+
if (rows.length === 0) return;
|
|
9191
|
+
painting = true;
|
|
9192
|
+
let placed = 0;
|
|
9193
|
+
let missed = 0;
|
|
9194
|
+
let wanted = 0;
|
|
9195
|
+
let failure = null;
|
|
9196
|
+
try {
|
|
9197
|
+
const synced = syncBadges(root, rows);
|
|
9198
|
+
placed += synced.placed;
|
|
9199
|
+
missed += synced.missed;
|
|
9200
|
+
wanted += synced.wanted;
|
|
9201
|
+
const hide = renderHide(root);
|
|
9202
|
+
placed += hide.rules;
|
|
9203
|
+
wanted += hide.wanted;
|
|
9204
|
+
} catch (error) {
|
|
9205
|
+
failure = error;
|
|
9206
|
+
} finally {
|
|
9207
|
+
painting = false;
|
|
9208
|
+
paintedAt = Date.now();
|
|
9209
|
+
}
|
|
9210
|
+
if (placed > 0) failures = 0;
|
|
9211
|
+
else if (failure !== null) noteFailure(failure);
|
|
9212
|
+
else if (wanted > 0 && missed > 0) noteFailure("这一轮没有任何工作区行能贴上标签: " + missed + " 个");
|
|
9213
|
+
}
|
|
9214
|
+
|
|
9215
|
+
function noteFailure(why) {
|
|
9216
|
+
if (givenUp || disposed) return;
|
|
9217
|
+
failures++;
|
|
9218
|
+
if (failures === 1) wsRowsWarn("工作区标签注入失败,已降级(不影响侧栏):", why);
|
|
9219
|
+
if (failures < WS_MAX_FAILURES) return;
|
|
9220
|
+
givenUp = true;
|
|
9221
|
+
stopWatching();
|
|
9222
|
+
if (!wsRowsReadFlag(WS_ROWS_DISMISSED_KEY, false)) showToast(translate("givingUp"), true);
|
|
9223
|
+
}
|
|
9224
|
+
|
|
9225
|
+
// ---- watchers -------------------------------------------------------
|
|
9226
|
+
function ensureObserver() {
|
|
9227
|
+
if (disposed || !enabled || givenUp) return;
|
|
9228
|
+
if (typeof window.MutationObserver !== "function") return;
|
|
9229
|
+
const root = container();
|
|
9230
|
+
if (root === null) return;
|
|
9231
|
+
if (observer !== null && observerRoot === root) return;
|
|
9232
|
+
if (observer !== null) { try { observer.disconnect(); } catch { /* gone */ } observer = null; }
|
|
9233
|
+
observer = new window.MutationObserver(onMutations);
|
|
9234
|
+
observerRoot = root;
|
|
9235
|
+
observer.observe(root, { childList: true, subtree: true });
|
|
9236
|
+
}
|
|
9237
|
+
|
|
9238
|
+
function onMutations() {
|
|
9239
|
+
if (disposed || !enabled || givenUp) return;
|
|
9240
|
+
try {
|
|
9241
|
+
if (painting) return;
|
|
9242
|
+
// Ignore only the settle window right after our own paint. Anything later
|
|
9243
|
+
// must reconcile, or a host re-render that took a badge leaves it gone.
|
|
9244
|
+
if (Date.now() - paintedAt < WS_QUIET_MS) return;
|
|
9245
|
+
if (container() === null) { scheduleRetry(); return; }
|
|
9246
|
+
scheduleScan();
|
|
9247
|
+
} catch (error) { noteFailure(error); }
|
|
9248
|
+
}
|
|
9249
|
+
|
|
9250
|
+
function scheduleScan() {
|
|
9251
|
+
if (disposed || !enabled || givenUp) return;
|
|
9252
|
+
if (scanTimer !== 0) { try { window.clearTimeout(scanTimer); } catch { /* gone */ } }
|
|
9253
|
+
scanTimer = window.setTimeout(() => { scanTimer = 0; paint(); }, WS_SCAN_DEBOUNCE);
|
|
9254
|
+
}
|
|
9255
|
+
|
|
9256
|
+
function scheduleRetry() {
|
|
9257
|
+
if (disposed || !enabled || givenUp) return;
|
|
9258
|
+
if (retryTimer !== 0) return;
|
|
9259
|
+
retryTimer = window.setInterval(attachContainer, 1000);
|
|
9260
|
+
}
|
|
9261
|
+
|
|
9262
|
+
function attachContainer() {
|
|
9263
|
+
if (disposed || !enabled || givenUp) return true;
|
|
9264
|
+
const root = container();
|
|
9265
|
+
if (root === null) return false;
|
|
9266
|
+
ensureObserver();
|
|
9267
|
+
paint();
|
|
9268
|
+
if (observer !== null && retryTimer !== 0) { try { window.clearInterval(retryTimer); } catch { /* gone */ } retryTimer = 0; }
|
|
9269
|
+
return true;
|
|
9270
|
+
}
|
|
9271
|
+
|
|
9272
|
+
function startWatching() {
|
|
9273
|
+
if (disposed || !enabled || givenUp) return;
|
|
9274
|
+
ensureObserver();
|
|
9275
|
+
if (container() !== null) paint();
|
|
9276
|
+
else scheduleRetry();
|
|
9277
|
+
// No MutationObserver in this build: the 1s retry is the only updater left.
|
|
9278
|
+
if (observer === null && retryTimer === 0) scheduleRetry();
|
|
9279
|
+
}
|
|
9280
|
+
|
|
9281
|
+
function stopWatching() {
|
|
9282
|
+
if (observer !== null) { try { observer.disconnect(); } catch { /* gone */ } observer = null; }
|
|
9283
|
+
observerRoot = null;
|
|
9284
|
+
if (retryTimer !== 0) { try { window.clearInterval(retryTimer); } catch { /* gone */ } retryTimer = 0; }
|
|
9285
|
+
if (scanTimer !== 0) { try { window.clearTimeout(scanTimer); } catch { /* gone */ } scanTimer = 0; }
|
|
9286
|
+
painting = false;
|
|
9287
|
+
}
|
|
9288
|
+
|
|
9289
|
+
// ---- preferences ----------------------------------------------------
|
|
9290
|
+
function snapshot() {
|
|
9291
|
+
return wsRowsNormalizeDoc(prefs);
|
|
9292
|
+
}
|
|
9293
|
+
|
|
9294
|
+
function subscribePrefs(listener) {
|
|
9295
|
+
if (typeof listener !== "function") return () => {};
|
|
9296
|
+
prefListeners.add(listener);
|
|
9297
|
+
return () => { prefListeners.delete(listener); };
|
|
9298
|
+
}
|
|
9299
|
+
|
|
9300
|
+
function notifyPrefs() {
|
|
9301
|
+
for (const listener of prefListeners) {
|
|
9302
|
+
try { listener(snapshot()); } catch { /* a listener must not break the paint */ }
|
|
9303
|
+
}
|
|
9304
|
+
}
|
|
9305
|
+
|
|
9306
|
+
function commitPrefs(next) {
|
|
9307
|
+
prefs = wsRowsNormalizeDoc(next);
|
|
9308
|
+
localDirty = true;
|
|
9309
|
+
wsRowsWriteLocalPrefs(prefs);
|
|
9310
|
+
notifyPrefs();
|
|
9311
|
+
paint();
|
|
9312
|
+
scheduleSave();
|
|
9313
|
+
}
|
|
9314
|
+
|
|
9315
|
+
function updateEntry(id, patch) {
|
|
9316
|
+
if (disposed || !wsRowsValidId(id)) return null;
|
|
9317
|
+
if (id === "__proto__" || id === "constructor" || id === "prototype") return null;
|
|
9318
|
+
const base = prefs.workspaces[id] !== undefined ? prefs.workspaces[id] : wsRowsNormalizeEntry(null);
|
|
9319
|
+
const next = {
|
|
9320
|
+
tag: base.tag,
|
|
9321
|
+
color: base.color,
|
|
9322
|
+
pinned: base.pinned,
|
|
9323
|
+
hidden: base.hidden,
|
|
9324
|
+
pinAnchor: base.pinAnchor
|
|
9325
|
+
};
|
|
9326
|
+
const wasPinned = base.pinned === true;
|
|
9327
|
+
let pinOn = false;
|
|
9328
|
+
let restoreAnchor = null;
|
|
9329
|
+
if (patch !== null && typeof patch === "object") {
|
|
9330
|
+
if (Object.hasOwn(patch, "tag")) next.tag = wsRowsNormalizeTag(patch.tag);
|
|
9331
|
+
if (Object.hasOwn(patch, "color")) next.color = wsRowsNormalizeColor(patch.color);
|
|
9332
|
+
if (Object.hasOwn(patch, "pinned")) {
|
|
9333
|
+
next.pinned = patch.pinned === true;
|
|
9334
|
+
// Pin: remember who this workspace sat behind, then replay to the top.
|
|
9335
|
+
// Unpin: restore behind that anchor and clear it (a missing anchor
|
|
9336
|
+
// leaves the row where it is).
|
|
9337
|
+
if (next.pinned === true && !wasPinned) {
|
|
9338
|
+
pinOn = true;
|
|
9339
|
+
next.pinAnchor = capturePinAnchor(id);
|
|
9340
|
+
} else if (next.pinned !== true && wasPinned) {
|
|
9341
|
+
restoreAnchor = base.pinAnchor;
|
|
9342
|
+
next.pinAnchor = null;
|
|
9343
|
+
}
|
|
9344
|
+
}
|
|
9345
|
+
if (Object.hasOwn(patch, "hidden")) next.hidden = patch.hidden === true;
|
|
9346
|
+
if (Object.hasOwn(patch, "pinAnchor")) next.pinAnchor = typeof patch.pinAnchor === "string" ? patch.pinAnchor : null;
|
|
9347
|
+
}
|
|
9348
|
+
const entry = wsRowsNormalizeEntry(next);
|
|
9349
|
+
const workspaces = Object.assign({}, prefs.workspaces);
|
|
9350
|
+
if (wsRowsIsDefaultEntry(entry)) delete workspaces[id];
|
|
9351
|
+
else workspaces[id] = entry;
|
|
9352
|
+
commitPrefs({ version: 1, workspaces });
|
|
9353
|
+
if (pinOn) requestPinReplay(0);
|
|
9354
|
+
else if (restoreAnchor !== null) restorePinAnchor(id, restoreAnchor);
|
|
9355
|
+
return entry;
|
|
9356
|
+
}
|
|
9357
|
+
|
|
9358
|
+
// ---- pinning --------------------------------------------------------
|
|
9359
|
+
/**
|
|
9360
|
+
* Bind the Client Workspace service late and non-blockingly. A host
|
|
9361
|
+
* without `workspaces` (or an older one whose ctx cannot inject) keeps
|
|
9362
|
+
* tags, hiding and the preferences table exactly as they were.
|
|
9363
|
+
*/
|
|
9364
|
+
function bindWorkspaceService() {
|
|
9365
|
+
if (disposed) return;
|
|
9366
|
+
const inject = ctx !== null && ctx !== undefined && typeof ctx.inject === "function" ? ctx.inject : null;
|
|
9367
|
+
if (inject === null) return;
|
|
9368
|
+
try {
|
|
9369
|
+
inject(["workspaces"], (scope) => {
|
|
9370
|
+
if (disposed) return;
|
|
9371
|
+
let service = null;
|
|
9372
|
+
try { service = scope !== null && scope !== undefined ? scope.workspaces : null; } catch { service = null; }
|
|
9373
|
+
if (service === null || typeof service !== "object" || typeof service.insertBefore !== "function") return;
|
|
9374
|
+
pinService = service;
|
|
9375
|
+
try {
|
|
9376
|
+
const source = service.list;
|
|
9377
|
+
if (source !== null && source !== undefined && typeof source.subscribe === "function") {
|
|
9378
|
+
pinUnsubscribe = source.subscribe(onWorkspaceListChanged);
|
|
9379
|
+
}
|
|
9380
|
+
} catch (error) { notePinWarn("订阅工作区列表失败:", error); }
|
|
9381
|
+
schedulePinReplay(0);
|
|
9382
|
+
});
|
|
9383
|
+
} catch (error) {
|
|
9384
|
+
notePinWarn("工作区置顶初始化失败(不影响标签和隐藏):", error);
|
|
9385
|
+
}
|
|
9386
|
+
}
|
|
9387
|
+
|
|
9388
|
+
function pinItems() {
|
|
9389
|
+
try {
|
|
9390
|
+
const source = pinService !== null && pinService !== undefined ? pinService.list : null;
|
|
9391
|
+
if (source === null || source === undefined || typeof source.getSnapshot !== "function") return [];
|
|
9392
|
+
const snapshot = source.getSnapshot();
|
|
9393
|
+
return snapshot !== null && typeof snapshot === "object" && Array.isArray(snapshot.items) ? snapshot.items : [];
|
|
9394
|
+
} catch { return []; }
|
|
9395
|
+
}
|
|
9396
|
+
|
|
9397
|
+
function pinWorkspaceIds() {
|
|
9398
|
+
const ids = [];
|
|
9399
|
+
for (const item of pinItems()) {
|
|
9400
|
+
if (item === null || typeof item !== "object") continue;
|
|
9401
|
+
const id = typeof item.workspaceId === "string" && item.workspaceId !== ""
|
|
9402
|
+
? item.workspaceId
|
|
9403
|
+
: (typeof item.id === "string" ? item.id : "");
|
|
9404
|
+
if (wsRowsValidId(id) && ids.indexOf(id) < 0) ids.push(id);
|
|
9405
|
+
}
|
|
9406
|
+
return ids;
|
|
9407
|
+
}
|
|
9408
|
+
|
|
9409
|
+
/** One warning per page: a missing service is a degradation, never an error. */
|
|
9410
|
+
function notePinWarn(message, error) {
|
|
9411
|
+
if (pinWarned) return;
|
|
9412
|
+
pinWarned = true;
|
|
9413
|
+
try { console.warn("dsh-lookout: " + message, error); } catch { /* no console */ }
|
|
9414
|
+
}
|
|
9415
|
+
|
|
9416
|
+
/** One toast and one warning per page; a broken reorder must never repeat. */
|
|
9417
|
+
function notePinFailure(error) {
|
|
9418
|
+
pinFailures++;
|
|
9419
|
+
if (pinFailures >= WS_PIN_MAX_FAILURES) pinSuspended = true;
|
|
9420
|
+
else pinCooldownUntil = Date.now() + WS_PIN_RETRY_MS;
|
|
9421
|
+
if (pinFailedOnce) return;
|
|
9422
|
+
pinFailedOnce = true;
|
|
9423
|
+
notePinWarn("工作区置顶失败(只提示一次):", error);
|
|
9424
|
+
try { showToast(translate("pinFailed"), false); } catch { /* best effort */ }
|
|
9425
|
+
}
|
|
9426
|
+
|
|
9427
|
+
function onWorkspaceListChanged() {
|
|
9428
|
+
if (disposed) return;
|
|
9429
|
+
// Our own moves change the list too: fold them into the running replay
|
|
9430
|
+
// instead of starting another one.
|
|
9431
|
+
if (pinRunning) { pinDirty = true; return; }
|
|
9432
|
+
schedulePinReplay(WS_PIN_DEBOUNCE);
|
|
9433
|
+
}
|
|
9434
|
+
|
|
9435
|
+
function schedulePinReplay(delay) {
|
|
9436
|
+
if (disposed || pinService === null || pinSuspended) return;
|
|
9437
|
+
if (pinTimer !== 0) return;
|
|
9438
|
+
let wait = typeof delay === "number" && delay > 0 ? delay : 0;
|
|
9439
|
+
const now = Date.now();
|
|
9440
|
+
if (pinCooldownUntil > now) wait = Math.max(wait, pinCooldownUntil - now);
|
|
9441
|
+
pinTimer = window.setTimeout(() => { pinTimer = 0; void pinNow(); }, wait);
|
|
9442
|
+
}
|
|
9443
|
+
|
|
9444
|
+
/** An explicit user action clears the failure backoff and replays immediately. */
|
|
9445
|
+
function requestPinReplay(delay) {
|
|
9446
|
+
pinSuspended = false;
|
|
9447
|
+
pinFailures = 0;
|
|
9448
|
+
pinCooldownUntil = 0;
|
|
9449
|
+
if (pinTimer !== 0) { try { window.clearTimeout(pinTimer); } catch { /* gone */ } pinTimer = 0; }
|
|
9450
|
+
schedulePinReplay(delay);
|
|
9451
|
+
}
|
|
9452
|
+
|
|
9453
|
+
/**
|
|
9454
|
+
* Replay pinned workspaces to the front. Zero insertBefore calls when the
|
|
9455
|
+
* order already fits; otherwise the smallest set of moves (one per pinned
|
|
9456
|
+
* workspace that is not already in the correct prefix). Re-entry from our
|
|
9457
|
+
* own moves is folded into pinDirty, and the identical-plan guard plus the
|
|
9458
|
+
* round/move caps stop a host that accepts a move without performing it.
|
|
9459
|
+
* @returns a promise settled when this replay finished.
|
|
9460
|
+
*/
|
|
9461
|
+
function pinNow() {
|
|
9462
|
+
if (disposed || pinSuspended) return Promise.resolve();
|
|
9463
|
+
const service = pinService;
|
|
9464
|
+
if (service === null || typeof service.insertBefore !== "function") return Promise.resolve();
|
|
9465
|
+
if (pinRunning) { pinDirty = true; return Promise.resolve(); }
|
|
9466
|
+
pinRunning = true;
|
|
9467
|
+
pinDirty = false;
|
|
9468
|
+
return (async () => {
|
|
9469
|
+
let moves = 0;
|
|
9470
|
+
let lastKey = null;
|
|
9471
|
+
let stalled = false;
|
|
9472
|
+
try {
|
|
9473
|
+
for (let round = 0; round < WS_PIN_MAX_ROUNDS; round++) {
|
|
9474
|
+
if (disposed) break;
|
|
9475
|
+
const plan = wsPinPlan(prefs, pinItems());
|
|
9476
|
+
if (plan === null) { pinDirty = false; break; }
|
|
9477
|
+
if (plan.key === lastKey) { stalled = true; break; }
|
|
9478
|
+
lastKey = plan.key;
|
|
9479
|
+
pinDirty = false;
|
|
9480
|
+
let stop = false;
|
|
9481
|
+
for (const step of plan.steps) {
|
|
9482
|
+
if (disposed || moves >= WS_PIN_MAX_MOVES) { stop = true; break; }
|
|
9483
|
+
try {
|
|
9484
|
+
await service.insertBefore(step.id, step.before);
|
|
9485
|
+
moves++;
|
|
9486
|
+
} catch (error) {
|
|
9487
|
+
notePinFailure(error);
|
|
9488
|
+
stop = true;
|
|
9489
|
+
break;
|
|
9490
|
+
}
|
|
9491
|
+
}
|
|
9492
|
+
if (stop) break;
|
|
9493
|
+
}
|
|
9494
|
+
} finally {
|
|
9495
|
+
pinRunning = false;
|
|
9496
|
+
const pending = pinDirty;
|
|
9497
|
+
pinDirty = false;
|
|
9498
|
+
if (pending && !stalled && !disposed) schedulePinReplay(WS_PIN_DEBOUNCE);
|
|
9499
|
+
}
|
|
9500
|
+
})();
|
|
9501
|
+
}
|
|
9502
|
+
|
|
9503
|
+
/** The workspace right before this one at pin time; null when it was first. */
|
|
9504
|
+
function capturePinAnchor(id) {
|
|
9505
|
+
if (pinService === null) { notePinWarn(translate("pinUnavailable")); return null; }
|
|
9506
|
+
const ids = pinWorkspaceIds();
|
|
9507
|
+
const at = ids.indexOf(id);
|
|
9508
|
+
return at > 0 ? ids[at - 1] : null;
|
|
9509
|
+
}
|
|
9510
|
+
|
|
9511
|
+
/**
|
|
9512
|
+
* Unpin restore: move the workspace back behind its recorded anchor. An
|
|
9513
|
+
* anchor that is gone (or null) leaves the workspace where it is, so a
|
|
9514
|
+
* deletion elsewhere never makes the list jump.
|
|
9515
|
+
*/
|
|
9516
|
+
function restorePinAnchor(id, anchor) {
|
|
9517
|
+
if (disposed || pinService === null || typeof anchor !== "string" || anchor === "") return;
|
|
9518
|
+
const ids = pinWorkspaceIds();
|
|
9519
|
+
if (ids.indexOf(id) < 0) return;
|
|
9520
|
+
const at = ids.indexOf(anchor);
|
|
9521
|
+
if (at < 0) return; // anchor gone: stay put (the caller already cleared it)
|
|
9522
|
+
const before = at + 1 < ids.length ? ids[at + 1] : undefined;
|
|
9523
|
+
if (before === id) return; // already right behind the anchor: zero calls
|
|
9524
|
+
try {
|
|
9525
|
+
const result = pinService.insertBefore(id, before);
|
|
9526
|
+
if (result !== null && typeof result === "object" && typeof result.then === "function") {
|
|
9527
|
+
result.catch((error) => notePinFailure(error));
|
|
9528
|
+
}
|
|
9529
|
+
} catch (error) { notePinFailure(error); }
|
|
9530
|
+
}
|
|
9531
|
+
|
|
9532
|
+
function cleanupMissing(ids) {
|
|
9533
|
+
if (disposed) return 0;
|
|
9534
|
+
const list = Array.isArray(ids) ? ids : [];
|
|
9535
|
+
const known = new Set();
|
|
9536
|
+
for (const id of list) if (wsRowsValidId(id)) known.add(id);
|
|
9537
|
+
// Without a live list the button must not wipe every preference.
|
|
9538
|
+
if (known.size === 0) return 0;
|
|
9539
|
+
const workspaces = {};
|
|
9540
|
+
let removed = 0;
|
|
9541
|
+
for (const [id, entry] of Object.entries(prefs.workspaces)) {
|
|
9542
|
+
if (known.has(id)) workspaces[id] = entry;
|
|
9543
|
+
else removed++;
|
|
9544
|
+
}
|
|
9545
|
+
if (removed === 0) return 0;
|
|
9546
|
+
commitPrefs({ version: 1, workspaces });
|
|
9547
|
+
return removed;
|
|
9548
|
+
}
|
|
9549
|
+
|
|
9550
|
+
function rpcFace() {
|
|
9551
|
+
try {
|
|
9552
|
+
if (ctx === null || ctx === undefined) return null;
|
|
9553
|
+
let connection = ctx.connection;
|
|
9554
|
+
if (connection === null || connection === undefined) {
|
|
9555
|
+
connection = typeof ctx.get === "function" ? ctx.get("connection") : null;
|
|
9556
|
+
}
|
|
9557
|
+
const face = connection !== null && connection !== undefined ? connection.rpc : null;
|
|
9558
|
+
return face !== null && face !== undefined && typeof face.call === "function" ? face : null;
|
|
9559
|
+
} catch { return null; }
|
|
9560
|
+
}
|
|
9561
|
+
|
|
9562
|
+
/** Startup pull: adopt the Host document, or keep the local mirror when RPC is unavailable. */
|
|
9563
|
+
function loadFromHost() {
|
|
9564
|
+
if (disposed) return;
|
|
9565
|
+
const face = rpcFace();
|
|
9566
|
+
if (face === null) return;
|
|
9567
|
+
try {
|
|
9568
|
+
face.call("/api", "iconCustom/getWorkspacePrefs", { args: {} }).then((response) => {
|
|
9569
|
+
if (disposed) return;
|
|
9570
|
+
if (response === null || typeof response !== "object" || response.ok !== true) return;
|
|
9571
|
+
// A local edit that happened while the pull was in flight wins; push it.
|
|
9572
|
+
if (localDirty) { scheduleSave(); return; }
|
|
9573
|
+
prefs = wsRowsNormalizeDoc(response.value);
|
|
9574
|
+
wsRowsWriteLocalPrefs(prefs);
|
|
9575
|
+
notifyPrefs();
|
|
9576
|
+
paint();
|
|
9577
|
+
}).catch(() => { /* keep the local mirror */ });
|
|
9578
|
+
} catch { /* keep the local mirror */ }
|
|
9579
|
+
}
|
|
9580
|
+
|
|
9581
|
+
function scheduleSave() {
|
|
9582
|
+
if (disposed) return;
|
|
9583
|
+
if (saveTimer !== 0) { try { window.clearTimeout(saveTimer); } catch { /* gone */ } }
|
|
9584
|
+
saveTimer = window.setTimeout(saveNow, WS_SAVE_DEBOUNCE);
|
|
9585
|
+
}
|
|
9586
|
+
|
|
9587
|
+
function saveNow() {
|
|
9588
|
+
saveTimer = 0;
|
|
9589
|
+
if (disposed) return;
|
|
9590
|
+
const face = rpcFace();
|
|
9591
|
+
// No RPC: the localStorage mirror is the documented fallback, not an error.
|
|
9592
|
+
if (face === null) return;
|
|
9593
|
+
const request = snapshot();
|
|
9594
|
+
try {
|
|
9595
|
+
face.call("/api", "iconCustom/setWorkspacePrefs", { args: { request } }).then((response) => {
|
|
9596
|
+
if (disposed) return;
|
|
9597
|
+
if (response === null || typeof response !== "object" || response.ok !== true) { noteSaveFailure(response); return; }
|
|
9598
|
+
localDirty = false;
|
|
9599
|
+
}).catch(noteSaveFailure);
|
|
9600
|
+
} catch (error) { noteSaveFailure(error); }
|
|
9601
|
+
}
|
|
9602
|
+
|
|
9603
|
+
/** Saved once per page: a broken Host must not turn every keystroke into a toast. */
|
|
9604
|
+
function noteSaveFailure(why) {
|
|
9605
|
+
if (saveFailedOnce) return;
|
|
9606
|
+
saveFailedOnce = true;
|
|
9607
|
+
try { console.warn("dsh-lookout: 工作区偏好保存失败(只提示一次):", why); } catch { /* no console */ }
|
|
9608
|
+
showToast(translate("saveFailed"), false);
|
|
9609
|
+
}
|
|
9610
|
+
|
|
9611
|
+
// ---- switch ---------------------------------------------------------
|
|
9612
|
+
function setEnabled(next) {
|
|
9613
|
+
const on = next === true;
|
|
9614
|
+
enabled = on;
|
|
9615
|
+
wsRowsWriteFlag(WS_ROWS_ENABLED_KEY, on);
|
|
9616
|
+
if (disposed) return;
|
|
9617
|
+
if (!on) {
|
|
9618
|
+
stopWatching();
|
|
9619
|
+
hideToast();
|
|
9620
|
+
removeBadges();
|
|
9621
|
+
const style = ensureHideStyleTag();
|
|
9622
|
+
if (style !== null && style !== undefined) { try { style.textContent = ""; } catch { /* gone */ } }
|
|
9623
|
+
return;
|
|
9624
|
+
}
|
|
9625
|
+
givenUp = false;
|
|
9626
|
+
failures = 0;
|
|
9627
|
+
hideToast();
|
|
9628
|
+
startWatching();
|
|
9629
|
+
requestPinReplay(0);
|
|
9630
|
+
}
|
|
9631
|
+
|
|
9632
|
+
function removeBadges() {
|
|
9633
|
+
try { document.querySelectorAll("[" + WS_TAG_ATTR + "]").forEach((node) => { try { node.remove(); } catch { /* gone */ } }); } catch { /* gone */ }
|
|
9634
|
+
}
|
|
9635
|
+
|
|
9636
|
+
// ---- toast ----------------------------------------------------------
|
|
9637
|
+
function showToast(message, dismissable) {
|
|
9638
|
+
if (disposed) return;
|
|
9639
|
+
if (document.body === null || document.body === undefined) return;
|
|
9640
|
+
hideToast();
|
|
9641
|
+
const box = document.createElement("div");
|
|
9642
|
+
box.className = "dsh-lookout-ws-toast";
|
|
9643
|
+
box.setAttribute(WS_TOAST_ATTR, "");
|
|
9644
|
+
box.setAttribute("role", "status");
|
|
9645
|
+
box.setAttribute("aria-live", "polite");
|
|
9646
|
+
const text = document.createElement("span");
|
|
9647
|
+
text.className = "dsh-lookout-ws-toast-text";
|
|
9648
|
+
text.textContent = message;
|
|
9649
|
+
box.appendChild(text);
|
|
9650
|
+
if (dismissable === true) {
|
|
9651
|
+
const dismiss = document.createElement("button");
|
|
9652
|
+
dismiss.type = "button";
|
|
9653
|
+
dismiss.textContent = translate("dismiss");
|
|
9654
|
+
dismiss.addEventListener("click", (event) => {
|
|
9655
|
+
event.preventDefault();
|
|
9656
|
+
event.stopPropagation();
|
|
9657
|
+
wsRowsWriteFlag(WS_ROWS_DISMISSED_KEY, true);
|
|
9658
|
+
hideToast();
|
|
9659
|
+
});
|
|
9660
|
+
box.appendChild(dismiss);
|
|
9661
|
+
}
|
|
9662
|
+
document.body.appendChild(box);
|
|
9663
|
+
toast = box;
|
|
9664
|
+
paintedAt = Date.now();
|
|
9665
|
+
toastTimer = window.setTimeout(hideToast, WS_TOAST_MS);
|
|
9666
|
+
}
|
|
9667
|
+
|
|
9668
|
+
function hideToast() {
|
|
9669
|
+
if (toastTimer !== 0) { try { window.clearTimeout(toastTimer); } catch { /* gone */ } toastTimer = 0; }
|
|
9670
|
+
if (toast !== null) { try { toast.remove(); } catch { /* gone */ } toast = null; }
|
|
9671
|
+
}
|
|
9672
|
+
|
|
9673
|
+
// ---- lifecycle ------------------------------------------------------
|
|
9674
|
+
function dispose() {
|
|
9675
|
+
if (disposed) return;
|
|
9676
|
+
disposed = true;
|
|
9677
|
+
try { stopWatching(); } catch { /* best effort */ }
|
|
9678
|
+
try { hideToast(); } catch { /* best effort */ }
|
|
9679
|
+
try { removeBadges(); } catch { /* best effort */ }
|
|
9680
|
+
if (saveTimer !== 0) { try { window.clearTimeout(saveTimer); } catch { /* gone */ } saveTimer = 0; }
|
|
9681
|
+
if (pinTimer !== 0) { try { window.clearTimeout(pinTimer); } catch { /* gone */ } pinTimer = 0; }
|
|
9682
|
+
if (pinUnsubscribe !== null) { try { pinUnsubscribe(); } catch { /* gone */ } pinUnsubscribe = null; }
|
|
9683
|
+
pinService = null;
|
|
9684
|
+
try {
|
|
9685
|
+
document.querySelectorAll('style[data-plugin-css="' + WS_STYLE_KEY + '"],style[data-plugin-css="' + WS_HIDE_STYLE_KEY + '"]').forEach((node) => {
|
|
9686
|
+
try { node.remove(); } catch { /* gone */ }
|
|
9687
|
+
});
|
|
9688
|
+
} catch { /* best effort */ }
|
|
9689
|
+
prefListeners.clear();
|
|
9690
|
+
}
|
|
9691
|
+
|
|
9692
|
+
ensureStaticStyle();
|
|
9693
|
+
bindWorkspaceService();
|
|
9694
|
+
startWatching();
|
|
9695
|
+
loadFromHost();
|
|
9696
|
+
|
|
9697
|
+
return {
|
|
9698
|
+
dispose,
|
|
9699
|
+
snapshot,
|
|
9700
|
+
subscribe: subscribePrefs,
|
|
9701
|
+
updateEntry,
|
|
9702
|
+
cleanupMissing,
|
|
9703
|
+
paintNow: paint,
|
|
9704
|
+
pinNow,
|
|
9705
|
+
domWorkspaceIds,
|
|
9706
|
+
setEnabled,
|
|
9707
|
+
isEnabled: () => enabled === true
|
|
9708
|
+
};
|
|
9709
|
+
}
|
|
9710
|
+
//#endregion
|
|
9711
|
+
|
|
9712
|
+
//#region session id in the sidebar hover card
|
|
9713
|
+
/**
|
|
9714
|
+
* The session id, printed inside the sidebar's session hover card.
|
|
9715
|
+
*
|
|
9716
|
+
* Why a slot instead of DOM patching: ui-workspace renders
|
|
9717
|
+
* `renderSlot("sidebar.session.row.hover", { sessionId })` between the card's
|
|
9718
|
+
* time line and its status lines, so this is a documented seat. No host markup
|
|
9719
|
+
* is read or written, and a host upgrade that moves the card carries our line
|
|
9720
|
+
* with it.
|
|
9721
|
+
*
|
|
9722
|
+
* The header badge of the byte-locked dsh-session-info part cannot be edited,
|
|
9723
|
+
* so "move" is delivered as CSS: while this line is ON, that badge is hidden,
|
|
9724
|
+
* which puts the id in exactly one place. Turning the switch OFF restores the
|
|
9725
|
+
* header and drops the card line — i.e. the shipped behaviour, untouched.
|
|
9726
|
+
*/
|
|
9727
|
+
const HOVER_SLOT = "sidebar.session.row.hover";
|
|
9728
|
+
/** Own locale namespace: locale.register THROWS on a duplicate namespace. */
|
|
9729
|
+
const HOVER_NS = "dsh-lookout-session-id";
|
|
9730
|
+
/** localStorage switch; absent or unreadable means ON. */
|
|
9731
|
+
const HOVER_ENABLED_KEY = "dsh-lookout:session-id:hover";
|
|
9732
|
+
const HOVER_STYLE_KEY = "lookout-session-id-hover";
|
|
9733
|
+
const HOVER_HIDE_STYLE_KEY = "lookout-session-id-hide";
|
|
9734
|
+
const HOVER_LINE_CLASS = "dsh-lookout-session-id-hover";
|
|
9735
|
+
const HOVER_TOAST_CLASS = "dsh-lookout-session-id-hover-toast";
|
|
9736
|
+
const HOVER_TOAST_ATTR = "data-lookout-session-id-toast";
|
|
9737
|
+
const HOVER_TOAST_MS = 2000;
|
|
9738
|
+
/** The bundled part's own badge class, hidden while the card carries the id. */
|
|
9739
|
+
const HOVER_HEADER_SELECTOR = ".dsh-session-info";
|
|
9740
|
+
|
|
9741
|
+
const HOVER_ZH = {
|
|
9742
|
+
nav: "会话 ID",
|
|
9743
|
+
toggleLabel: "在会话悬停卡片里显示会话 ID(顶部不再重复显示)",
|
|
9744
|
+
toggleHint: "侧栏里鼠标停在某个会话上,弹出的卡片里会多一行等宽字:这个会话的完整 ID(session-…),点一下即可复制。开启时顶部那串会话 ID 会隐藏,避免同一个 ID 显示两处;关掉即恢复原样。",
|
|
9745
|
+
copied: "已复制会话 ID",
|
|
9746
|
+
copyFailed: "复制失败,请手动选择会话 ID 复制",
|
|
9747
|
+
copyHint: "点击复制完整会话 ID",
|
|
9748
|
+
ariaLabel: "复制完整会话 ID"
|
|
9749
|
+
};
|
|
9750
|
+
const HOVER_EN = {
|
|
9751
|
+
nav: "Session ID",
|
|
9752
|
+
toggleLabel: "Show the session ID in the session hover card (hidden in the header)",
|
|
9753
|
+
toggleHint: "Hovering a session in the sidebar opens a card; this adds one monospace line with the session's full ID (session-…), and one click copies it. While this is on, the header stops showing the same id so it appears in exactly one place; turning it off restores the shipped behaviour.",
|
|
9754
|
+
copied: "Session ID copied",
|
|
9755
|
+
copyFailed: "Copy failed — select the session ID and copy it manually",
|
|
9756
|
+
copyHint: "Click to copy the full session ID",
|
|
9757
|
+
ariaLabel: "Copy the full session ID"
|
|
9758
|
+
};
|
|
9759
|
+
|
|
9760
|
+
const HOVER_CSS = [
|
|
9761
|
+
// One quiet line under the card's time, in the same voice as its status rows.
|
|
9762
|
+
"." + HOVER_LINE_CLASS + "{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px;line-height:1.5;color:var(--dsw-alias-label-tertiary,#8b8b8b);opacity:.85;word-break:break-all;cursor:copy;user-select:text;}",
|
|
9763
|
+
"." + HOVER_LINE_CLASS + ":hover,." + HOVER_LINE_CLASS + ":focus-visible{opacity:1;text-decoration:underline;outline:none;}",
|
|
9764
|
+
"." + HOVER_TOAST_CLASS + "{position:fixed;left:50%;bottom:24px;z-index:2147482100;max-width:min(92vw,560px);padding:9px 12px;transform:translateX(-50%);border:1px solid var(--dsw-alias-border-l2,#444);border-radius:10px;background:var(--dsw-alias-bg-overlay,#222);color:var(--dsw-alias-label-primary,#fff);font-family:-apple-system,BlinkMacSystemFont,\"Segoe UI\",Roboto,\"Helvetica Neue\",Arial,sans-serif;font-size:12px;line-height:1.5;box-shadow:0 8px 24px var(--dsw-alias-bg-mask-drop,rgba(0,0,0,.25));}",
|
|
9765
|
+
"." + HOVER_TOAST_CLASS + "[data-lookout-session-id-failed]{color:var(--dsw-alias-state-error-primary,#ff6b6b);}"
|
|
9766
|
+
].join("\n");
|
|
9767
|
+
|
|
9768
|
+
/** Chinese fallback for every translate call, used when locale is unavailable. */
|
|
9769
|
+
function hoverFallbackText(key, params) {
|
|
9770
|
+
const template = typeof HOVER_ZH[key] === "string" ? HOVER_ZH[key] : key;
|
|
9771
|
+
if (params === null || params === undefined || typeof params !== "object") return template;
|
|
9772
|
+
let text = template;
|
|
9773
|
+
for (const name of Object.keys(params)) text = text.split("{" + name + "}").join(String(params[name]));
|
|
9774
|
+
return text;
|
|
9775
|
+
}
|
|
9776
|
+
|
|
9777
|
+
/** Whether the card line is wanted; storage that throws counts as ON. */
|
|
9778
|
+
function hoverReadFlag() {
|
|
9779
|
+
try {
|
|
9780
|
+
const raw = window.localStorage.getItem(HOVER_ENABLED_KEY);
|
|
9781
|
+
if (raw === "1") return true;
|
|
9782
|
+
if (raw === "0") return false;
|
|
9783
|
+
return true;
|
|
9784
|
+
} catch { return true; }
|
|
9785
|
+
}
|
|
9786
|
+
|
|
9787
|
+
/** Persist the switch; a storage failure only costs durability. */
|
|
9788
|
+
function hoverWriteFlag(value) {
|
|
9789
|
+
try { window.localStorage.setItem(HOVER_ENABLED_KEY, value ? "1" : "0"); } catch { /* advisory */ }
|
|
9790
|
+
}
|
|
9791
|
+
|
|
9792
|
+
/** One warning per page at most, and only for a real degradation. */
|
|
9793
|
+
let hoverWarned = false;
|
|
9794
|
+
function hoverWarn(message, error) {
|
|
9795
|
+
if (hoverWarned) return;
|
|
9796
|
+
hoverWarned = true;
|
|
9797
|
+
try { console.warn("dsh-lookout: " + message, error); } catch { /* no console */ }
|
|
9798
|
+
}
|
|
9799
|
+
|
|
9800
|
+
/**
|
|
9801
|
+
* Mount the card line, its settings switch and the header-hiding stylesheet.
|
|
9802
|
+
* Swallows everything: a UI seat may never throw into the host apply().
|
|
9803
|
+
* @param ctx - the merged plugin client context.
|
|
9804
|
+
* @param require - the ModuleLoader require (React lives behind it).
|
|
9805
|
+
* @returns {void}
|
|
9806
|
+
*/
|
|
9807
|
+
function sessionIdHoverStart(ctx, require) {
|
|
9808
|
+
try {
|
|
9809
|
+
sessionIdHoverInstall(ctx, require);
|
|
9810
|
+
} catch (error) {
|
|
9811
|
+
hoverWarn("会话 ID 悬停卡片初始化失败,已跳过(不影响宿主):", error);
|
|
9812
|
+
}
|
|
9813
|
+
}
|
|
9814
|
+
|
|
9815
|
+
/**
|
|
9816
|
+
* Install the seat and return its teardown.
|
|
9817
|
+
* @param ctx - the merged plugin client context.
|
|
9818
|
+
* @param require - the ModuleLoader require.
|
|
9819
|
+
* @returns {() => void} disposer.
|
|
9820
|
+
*/
|
|
9821
|
+
function sessionIdHoverInstall(ctx, require) {
|
|
9822
|
+
let disposed = false;
|
|
9823
|
+
let enabled = hoverReadFlag();
|
|
9824
|
+
let translate = hoverFallbackText;
|
|
9825
|
+
let toast = null;
|
|
9826
|
+
let toastTimer = 0;
|
|
9827
|
+
const toggleListeners = new Set();
|
|
9828
|
+
const React = require("react");
|
|
9829
|
+
|
|
9830
|
+
/** Every card line and settings switch reads this, so one switch moves both. */
|
|
9831
|
+
function notifyToggle() {
|
|
9832
|
+
for (const listener of Array.from(toggleListeners)) { try { listener(enabled); } catch { /* gone */ } }
|
|
9833
|
+
}
|
|
9834
|
+
|
|
9835
|
+
function subscribeToggle(listener) {
|
|
9836
|
+
if (typeof listener !== "function") return () => {};
|
|
9837
|
+
toggleListeners.add(listener);
|
|
9838
|
+
return () => { toggleListeners.delete(listener); };
|
|
9839
|
+
}
|
|
9840
|
+
|
|
9841
|
+
// ---- locale ---------------------------------------------------------
|
|
9842
|
+
try {
|
|
9843
|
+
const locale = ctx !== null && ctx !== undefined ? ctx.locale : null;
|
|
9844
|
+
if (locale !== null && locale !== undefined && typeof locale.register === "function") {
|
|
9845
|
+
const register = () => locale.register(HOVER_NS, { zh: HOVER_ZH, en: HOVER_EN });
|
|
9846
|
+
if (typeof ctx.effect === "function") ctx.effect(register, "dsh-lookout: session id dictionaries");
|
|
9847
|
+
else register();
|
|
9848
|
+
}
|
|
9849
|
+
if (locale !== null && locale !== undefined && typeof locale.bind === "function") {
|
|
9850
|
+
const bound = locale.bind(HOVER_NS);
|
|
9851
|
+
translate = (key, params) => {
|
|
9852
|
+
let text = key;
|
|
9853
|
+
try { text = bound(key, params); } catch { text = key; }
|
|
9854
|
+
return typeof text === "string" && text !== key ? text : hoverFallbackText(key, params);
|
|
9855
|
+
};
|
|
9856
|
+
}
|
|
9857
|
+
} catch (error) { /* the Chinese fallback is already in translate */ }
|
|
9858
|
+
|
|
9859
|
+
// ---- styles ---------------------------------------------------------
|
|
9860
|
+
function ensureStyleTag(key, css) {
|
|
9861
|
+
try {
|
|
9862
|
+
const existing = document.querySelector('style[data-plugin-css="' + key + '"]');
|
|
9863
|
+
if (existing !== null && existing !== undefined) {
|
|
9864
|
+
if (typeof css === "string" && existing.textContent !== css) { try { existing.textContent = css; } catch { /* gone */ } }
|
|
9865
|
+
return existing;
|
|
9866
|
+
}
|
|
9867
|
+
const tag = document.createElement("style");
|
|
9868
|
+
tag.setAttribute("data-plugin", "dsh-lookout");
|
|
9869
|
+
tag.setAttribute("data-plugin-css", key);
|
|
9870
|
+
tag.textContent = typeof css === "string" ? css : "";
|
|
9871
|
+
const head = document.head !== null && document.head !== undefined ? document.head : document.body;
|
|
9872
|
+
if (head !== null && head !== undefined) head.appendChild(tag);
|
|
9873
|
+
return tag;
|
|
9874
|
+
} catch { return null; }
|
|
9875
|
+
}
|
|
9876
|
+
|
|
9877
|
+
/**
|
|
9878
|
+
* Hide the shipped header badge while the card carries the id.
|
|
9879
|
+
*
|
|
9880
|
+
* The badge lives in a byte-locked part, so it cannot be removed from the
|
|
9881
|
+
* bundle; hiding it with one scoped rule is the reversible half of "move".
|
|
9882
|
+
*/
|
|
9883
|
+
function syncHeaderBadge() {
|
|
9884
|
+
if (disposed) return;
|
|
9885
|
+
if (enabled) { ensureStyleTag(HOVER_HIDE_STYLE_KEY, HOVER_HEADER_SELECTOR + "{display:none !important;}"); return; }
|
|
9886
|
+
try {
|
|
9887
|
+
document.querySelectorAll('style[data-plugin-css="' + HOVER_HIDE_STYLE_KEY + '"]').forEach((node) => {
|
|
9888
|
+
try { node.remove(); } catch { /* gone */ }
|
|
9889
|
+
});
|
|
9890
|
+
} catch { /* best effort */ }
|
|
9891
|
+
}
|
|
9892
|
+
|
|
9893
|
+
// ---- toast + clipboard ----------------------------------------------
|
|
9894
|
+
function hideToast() {
|
|
9895
|
+
if (toastTimer !== 0) { try { window.clearTimeout(toastTimer); } catch { /* gone */ } toastTimer = 0; }
|
|
9896
|
+
if (toast !== null) { try { toast.remove(); } catch { /* gone */ } toast = null; }
|
|
9897
|
+
}
|
|
9898
|
+
|
|
9899
|
+
function showToast(message, ok) {
|
|
9900
|
+
if (disposed) return;
|
|
9901
|
+
if (document.body === null || document.body === undefined) return;
|
|
9902
|
+
hideToast();
|
|
9903
|
+
const box = document.createElement("div");
|
|
9904
|
+
box.className = HOVER_TOAST_CLASS;
|
|
9905
|
+
box.setAttribute(HOVER_TOAST_ATTR, "");
|
|
9906
|
+
if (ok !== true) box.setAttribute("data-lookout-session-id-failed", "");
|
|
9907
|
+
box.setAttribute("role", "status");
|
|
9908
|
+
box.setAttribute("aria-live", "polite");
|
|
9909
|
+
box.textContent = message;
|
|
9910
|
+
document.body.appendChild(box);
|
|
9911
|
+
toast = box;
|
|
9912
|
+
toastTimer = window.setTimeout(hideToast, HOVER_TOAST_MS);
|
|
9913
|
+
}
|
|
9914
|
+
|
|
9915
|
+
async function writeClipboard(text) {
|
|
9916
|
+
try {
|
|
9917
|
+
const primitives = require("@deepseek-ai/dsh-client-ui-primitives");
|
|
9918
|
+
if (primitives !== null && primitives !== undefined && typeof primitives.writeClipboard === "function") {
|
|
9919
|
+
const result = await primitives.writeClipboard(text);
|
|
9920
|
+
if (result !== false) return true;
|
|
9921
|
+
}
|
|
9922
|
+
} catch { /* fall through to the browser API */ }
|
|
9923
|
+
try {
|
|
9924
|
+
if (typeof navigator !== "undefined" && navigator.clipboard !== null && navigator.clipboard !== undefined && typeof navigator.clipboard.writeText === "function") {
|
|
9925
|
+
await navigator.clipboard.writeText(text);
|
|
9926
|
+
return true;
|
|
9927
|
+
}
|
|
9928
|
+
} catch { /* fall through to the textarea */ }
|
|
9929
|
+
try {
|
|
9930
|
+
const area = document.createElement("textarea");
|
|
9931
|
+
area.value = text;
|
|
9932
|
+
area.setAttribute("readonly", "");
|
|
9933
|
+
area.style.position = "fixed";
|
|
9934
|
+
area.style.top = "0";
|
|
9935
|
+
area.style.left = "-9999px";
|
|
9936
|
+
area.style.opacity = "0";
|
|
9937
|
+
document.body.appendChild(area);
|
|
9938
|
+
try { area.focus(); area.select(); } catch { /* focus is advisory */ }
|
|
9939
|
+
const copied = typeof document.execCommand === "function" && document.execCommand("copy") === true;
|
|
9940
|
+
try { area.remove(); } catch { /* gone */ }
|
|
9941
|
+
return copied;
|
|
9942
|
+
} catch { return false; }
|
|
9943
|
+
}
|
|
9944
|
+
|
|
9945
|
+
async function copyId(id) {
|
|
9946
|
+
let copied = false;
|
|
9947
|
+
try { copied = await writeClipboard(id); } catch { copied = false; }
|
|
9948
|
+
if (disposed) return;
|
|
9949
|
+
showToast(copied ? translate("copied") : translate("copyFailed"), copied === true);
|
|
9950
|
+
return copied;
|
|
9951
|
+
}
|
|
9952
|
+
|
|
9953
|
+
// ---- the card line ---------------------------------------------------
|
|
9954
|
+
/**
|
|
9955
|
+
* One card line: the full session id, click or Enter/Space to copy it.
|
|
9956
|
+
*
|
|
9957
|
+
* A `div[role=button]` rather than a `button`: the card is a passive panel
|
|
9958
|
+
* and the row it describes owns the click that opens the session, so this
|
|
9959
|
+
* line must stay a leaf that steals only its own events.
|
|
9960
|
+
*/
|
|
9961
|
+
function SessionIdHoverLine(props) {
|
|
9962
|
+
const [on, setOn] = React.useState(() => enabled);
|
|
9963
|
+
React.useEffect(() => subscribeToggle(setOn), []);
|
|
9964
|
+
const id = props !== null && props !== undefined && typeof props.sessionId === "string" ? props.sessionId : "";
|
|
9965
|
+
if (on !== true || id === "") return null;
|
|
9966
|
+
const label = props !== null && props !== undefined && typeof props.t === "function" ? props.t : translate;
|
|
9967
|
+
const activate = (event) => {
|
|
9968
|
+
if (event !== null && event !== undefined) {
|
|
9969
|
+
if (typeof event.preventDefault === "function") event.preventDefault();
|
|
9970
|
+
if (typeof event.stopPropagation === "function") event.stopPropagation();
|
|
9971
|
+
}
|
|
9972
|
+
void copyId(id);
|
|
9973
|
+
};
|
|
9974
|
+
const onKeyDown = (event) => {
|
|
9975
|
+
if (event === null || event === undefined) return;
|
|
9976
|
+
if (event.key !== "Enter" && event.key !== " " && event.key !== "Spacebar") return;
|
|
9977
|
+
activate(event);
|
|
9978
|
+
};
|
|
9979
|
+
return React.createElement("div", {
|
|
9980
|
+
className: HOVER_LINE_CLASS,
|
|
9981
|
+
role: "button",
|
|
9982
|
+
tabIndex: 0,
|
|
9983
|
+
title: label("copyHint"),
|
|
9984
|
+
"aria-label": label("ariaLabel"),
|
|
9985
|
+
"data-lookout-session-id": id,
|
|
9986
|
+
onClick: activate,
|
|
9987
|
+
onKeyDown
|
|
9988
|
+
}, id);
|
|
9989
|
+
}
|
|
9990
|
+
|
|
9991
|
+
// ---- settings --------------------------------------------------------
|
|
9992
|
+
function SessionIdHoverSection(props) {
|
|
9993
|
+
const label = props !== null && props !== undefined && typeof props.t === "function" ? props.t : translate;
|
|
9994
|
+
const [checked, setChecked] = React.useState(() => hoverReadFlag());
|
|
9995
|
+
React.useEffect(() => subscribeToggle(setChecked), []);
|
|
9996
|
+
const onChange = (event) => {
|
|
9997
|
+
const next = event.target.checked === true;
|
|
9998
|
+
setChecked(next);
|
|
9999
|
+
setEnabled(next);
|
|
10000
|
+
};
|
|
10001
|
+
return React.createElement("div", { "data-lookout-settings": "session-id" },
|
|
10002
|
+
React.createElement("div", { style: { fontSize: 13, fontWeight: 600, color: "var(--dsw-alias-label-primary)", marginBottom: 8 } }, label("nav")),
|
|
10003
|
+
React.createElement("label", { style: { display: "inline-flex", alignItems: "center", gap: 6, fontSize: 12, color: "var(--dsw-alias-label-primary)", cursor: "pointer" } },
|
|
10004
|
+
React.createElement("input", { type: "checkbox", checked, onChange }),
|
|
10005
|
+
React.createElement("span", null, label("toggleLabel"))
|
|
10006
|
+
),
|
|
10007
|
+
React.createElement("div", { style: { marginTop: 6, fontSize: 12, lineHeight: 1.6, color: "var(--dsw-alias-label-tertiary)" } }, label("toggleHint"))
|
|
10008
|
+
);
|
|
10009
|
+
}
|
|
10010
|
+
|
|
10011
|
+
/** Flip the line and the header rule together, then persist. */
|
|
10012
|
+
function setEnabled(next) {
|
|
10013
|
+
enabled = next === true;
|
|
10014
|
+
hoverWriteFlag(enabled);
|
|
10015
|
+
syncHeaderBadge();
|
|
10016
|
+
if (!enabled) hideToast();
|
|
10017
|
+
notifyToggle();
|
|
10018
|
+
}
|
|
10019
|
+
|
|
10020
|
+
// ---- wiring ----------------------------------------------------------
|
|
10021
|
+
ensureStyleTag(HOVER_STYLE_KEY, HOVER_CSS);
|
|
10022
|
+
syncHeaderBadge();
|
|
10023
|
+
|
|
10024
|
+
try {
|
|
10025
|
+
const slots = ctx !== null && ctx !== undefined ? ctx.slots : null;
|
|
10026
|
+
if (slots !== null && slots !== undefined && typeof slots.inject === "function" && typeof slots.register === "function") {
|
|
10027
|
+
// Order 1: above the scheduled-task section (order 10) and the status
|
|
10028
|
+
// lines, directly under the card's time — the id reads as part of the
|
|
10029
|
+
// card's own header, not as one more status.
|
|
10030
|
+
slots.inject(HOVER_SLOT, () => slots.register({
|
|
10031
|
+
name: HOVER_SLOT,
|
|
10032
|
+
id: "lookout-session-id",
|
|
10033
|
+
order: 1,
|
|
10034
|
+
locale: HOVER_NS,
|
|
10035
|
+
inject: () => ({ t: translate })
|
|
10036
|
+
}, SessionIdHoverLine));
|
|
10037
|
+
slots.inject("settings.section", () => slots.register({
|
|
10038
|
+
name: "settings.section",
|
|
10039
|
+
id: "lookout-session-id",
|
|
10040
|
+
order: 57,
|
|
10041
|
+
label: () => translate("nav"),
|
|
10042
|
+
locale: HOVER_NS,
|
|
10043
|
+
inject: () => ({ t: translate })
|
|
10044
|
+
}, SessionIdHoverSection));
|
|
10045
|
+
}
|
|
10046
|
+
} catch (error) {
|
|
10047
|
+
hoverWarn("会话 ID 插槽注册失败,已跳过(不影响宿主):", error);
|
|
10048
|
+
}
|
|
10049
|
+
|
|
10050
|
+
function dispose() {
|
|
10051
|
+
if (disposed) return;
|
|
10052
|
+
disposed = true;
|
|
10053
|
+
hideToast();
|
|
10054
|
+
toggleListeners.clear();
|
|
10055
|
+
try {
|
|
10056
|
+
document.querySelectorAll('style[data-plugin-css="' + HOVER_STYLE_KEY + '"],style[data-plugin-css="' + HOVER_HIDE_STYLE_KEY + '"]').forEach((node) => { try { node.remove(); } catch { /* gone */ } });
|
|
10057
|
+
} catch { /* best effort */ }
|
|
10058
|
+
}
|
|
10059
|
+
|
|
10060
|
+
if (ctx !== null && ctx !== undefined && typeof ctx.effect === "function") {
|
|
10061
|
+
try { ctx.effect(() => dispose, "dsh-lookout: session id in hover card"); } catch { /* best effort */ }
|
|
10062
|
+
}
|
|
10063
|
+
return dispose;
|
|
10064
|
+
}
|
|
10065
|
+
//#endregion
|
|
10066
|
+
|
|
10067
|
+
|
|
8130
10068
|
window.__ModuleLoader__.load({
|
|
8131
10069
|
id: "dsh-lookout",
|
|
8132
10070
|
factory: (require) => {
|
|
@@ -8139,6 +10077,12 @@ window.__ModuleLoader__.load({
|
|
|
8139
10077
|
// starts first and owns its own try/catch: a fault in any part (or a
|
|
8140
10078
|
// missing files panel) only costs this feature, never the host.
|
|
8141
10079
|
rowMenuStart(ctx, require);
|
|
10080
|
+
// Workspace tags + hiding are independent of the three bundled parts for
|
|
10081
|
+
// the same reason, and own their own try/catch inside wsRowsStart.
|
|
10082
|
+
wsRowsStart(ctx, require);
|
|
10083
|
+
// The session id in the sidebar's hover card rides an official slot
|
|
10084
|
+
// (sidebar.session.row.hover), so it owns its own try/catch too.
|
|
10085
|
+
sessionIdHoverStart(ctx, require);
|
|
8142
10086
|
for (const m of __mods) { if (m && typeof m.apply === "function") m.apply(ctx); }
|
|
8143
10087
|
},
|
|
8144
10088
|
};
|