dsh-ws-recency 0.7.3 → 0.9.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 +31 -3
- package/client.js +271 -2
- package/index.js +221 -9
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# dsh-ws-recency
|
|
2
2
|
|
|
3
|
-
DSH
|
|
3
|
+
DSH 侧栏的五件事:**工作区分组按最近活跃排序**、**过期历史自动折叠**、**运行中/未读/需跟进三色标记**、**所有置顶会话汇总到顶部一个区**、**删除会话到可恢复的回收站**。单独装上就有效,不依赖其它插件。
|
|
4
4
|
|
|
5
5
|
## 一、分组排序
|
|
6
6
|
|
|
@@ -35,12 +35,40 @@ DSH 已经把**组内**会话按 `updatedAt` 倒序排好了(排序方式 =
|
|
|
35
35
|
- **标记只有你手动取消才会消失**:运行中/完成未读期间它只是被更强的状态盖住显示,跑完、读完(没有新未读)之后琥珀自己回来;
|
|
36
36
|
- 不受打开时长、会话是否继续对话影响——继续对话恰恰常常是还欠一次跟进的原因。
|
|
37
37
|
|
|
38
|
+
## 四、全局置顶区
|
|
39
|
+
|
|
40
|
+
DSH 原生的置顶只把会话顶到**它所属工作区分组的第一行**,分组本身仍在原位;一旦分组排在列表下面,置顶了也看不见。
|
|
41
|
+
|
|
42
|
+
本插件在**所有工作区分组之上**加一个「置顶」区,把宿主自己的置顶会话(`workspaces.pinnedSessionIds`)集中列出来:
|
|
43
|
+
|
|
44
|
+
- 用的是**宿主原生置顶集合**,不是第二套置顶:会话「…」菜单里的置顶/取消置顶照旧;
|
|
45
|
+
- 置顶区每行显示标题 + 所属工作区名,点行直接打开;hover 出现「取消置顶」;
|
|
46
|
+
- 置顶期间它在原工作区分组里的那一行**隐藏**(不重复出现),取消置顶后原行回到原位;
|
|
47
|
+
- 工作区分组、组内顺序、折叠、颜色全部照旧;
|
|
48
|
+
- 只在按工作区分组时出现;搜索、扁平视图、侧栏收起时自动隐藏。
|
|
49
|
+
|
|
50
|
+
## 五、归档与删除
|
|
51
|
+
|
|
52
|
+
**归档**用宿主原生的:「…」菜单 →「归档会话」;看归档的内容用侧栏「视图选项」→ 显示已归档 / 仅显示已归档。本插件不改这套。
|
|
53
|
+
|
|
54
|
+
**删除**是宿主没有的,本插件补上(「…」菜单 →「删除会话(移入回收站)」):
|
|
55
|
+
|
|
56
|
+
1. 先停掉它正在跑的工作;
|
|
57
|
+
2. 摘掉归档标记与置顶;
|
|
58
|
+
3. 从每个工作区的 `sessionIds` 记账里 detach;
|
|
59
|
+
4. 删掉投影缓存记录;
|
|
60
|
+
5. 把日志目录移动到 `~/.hypers-work/.trash/ws-recency/<sessionId>-<时间戳>/`;
|
|
61
|
+
6. 广播 `api-session/removed`,浏览器立即移除该行。
|
|
62
|
+
|
|
63
|
+
**不是物理删除**:目录只是被改名搬走,恢复就是把 `.trash/ws-recency/<id>-<时间>/` 移回 `sessions/<project>/<id>/`。这条删除走的是宿主侧路由(`POST /ws-recency/session-trash`,仅接受回环来源),因为浏览器侧碰不到文件系统。
|
|
64
|
+
|
|
38
65
|
## 设置
|
|
39
66
|
|
|
40
67
|
**设置 → 通用**:
|
|
41
68
|
|
|
42
69
|
- **历史折叠天数**:`−` / 数字输入 / `+`(1–90,默认 3),以及“选中 / 运行中 / 等待处理的会话不折叠”;
|
|
43
|
-
-
|
|
70
|
+
- **颜色与跟进标记**:颜色标记总开关;
|
|
71
|
+
- **全局置顶区**:显示开关。
|
|
44
72
|
|
|
45
73
|
偏好持久化在 Client store 的 `dsh.ws-recency.prefs.v1`。
|
|
46
74
|
|
|
@@ -69,7 +97,7 @@ dsh plugin --profile <profile> add github:chyxhtc/dsh-ws-recency
|
|
|
69
97
|
或固定版本的预构建包:
|
|
70
98
|
|
|
71
99
|
```powershell
|
|
72
|
-
dsh plugin --profile <profile> add https://github.com/chyxhtc/dsh-ws-recency/releases/download/v0.
|
|
100
|
+
dsh plugin --profile <profile> add https://github.com/chyxhtc/dsh-ws-recency/releases/download/v0.9.0/dsh-ws-recency-0.9.0.tgz
|
|
73
101
|
```
|
|
74
102
|
|
|
75
103
|
装完把 `dsh-ws-recency` 加进 `~/.hypers-work/profiles/<profile>/package.json` 的 `dsh.profile.bundles`,重启应用。
|
package/client.js
CHANGED
|
@@ -59,6 +59,8 @@
|
|
|
59
59
|
var FOLD_ATTR = "data-wsr-fold";
|
|
60
60
|
var STATE_ATTR = "data-wsr-state";
|
|
61
61
|
var DOT_TOKEN = "wsr-dot";
|
|
62
|
+
var PIN_ATTR = "data-wsr-pin";
|
|
63
|
+
var PIN_HIDDEN_ATTR = "data-wsr-pin-hidden";
|
|
62
64
|
var BUSY_RANK_ATTR = "data-busy-rank";
|
|
63
65
|
var CSS_ID = "dsh-ws-recency/css";
|
|
64
66
|
var MENU_ORDER = 460;
|
|
@@ -69,7 +71,7 @@
|
|
|
69
71
|
var DEFAULT_DAYS = 3;
|
|
70
72
|
var TICK_MS = 5 * 60 * 1000;
|
|
71
73
|
|
|
72
|
-
var data = { sessions: null, workspaces: null, settings: null, statuses: null, actions: null, t: null };
|
|
74
|
+
var data = { sessions: null, workspaces: null, settings: null, statuses: null, actions: null, service: null, t: null };
|
|
73
75
|
var timer = null;
|
|
74
76
|
var ticker = null;
|
|
75
77
|
var observer = null;
|
|
@@ -79,6 +81,8 @@
|
|
|
79
81
|
var labelled = new Set();
|
|
80
82
|
var toggles = new Set();
|
|
81
83
|
var dots = new Set();
|
|
84
|
+
var pinHidden = new Set();
|
|
85
|
+
var pinHost = null;
|
|
82
86
|
var expandedSections = new Set();
|
|
83
87
|
var clickMarked = new WeakSet();
|
|
84
88
|
var headerMarked = new WeakSet();
|
|
@@ -88,6 +92,16 @@
|
|
|
88
92
|
"[" + ROOT_ATTR + '="true"]{display:flex;flex-direction:column}',
|
|
89
93
|
"[" + ROOT_ATTR + '="true"]>*{flex:0 0 auto}',
|
|
90
94
|
"[" + HIDDEN_ATTR + '="true"]{display:none !important}',
|
|
95
|
+
"[" + PIN_HIDDEN_ATTR + '="true"]{display:none !important}',
|
|
96
|
+
".wsr-pin{display:flex;flex-direction:column;flex:0 0 auto;padding:2px 0 6px;margin-bottom:4px;border-bottom:.5px solid var(--dsw-alias-border-l2)}",
|
|
97
|
+
".wsr-pin-head{font-size:11px;line-height:16px;color:var(--dsw-alias-label-tertiary);padding:4px 12px 2px}",
|
|
98
|
+
".wsr-pin-row{display:flex;align-items:center;gap:8px;height:30px;padding:0 12px;border-radius:6px;cursor:pointer}",
|
|
99
|
+
".wsr-pin-row:hover{background:var(--dsw-alias-bg-layer-2)}",
|
|
100
|
+
".wsr-pin-title{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:13px;color:var(--dsw-alias-label-primary)}",
|
|
101
|
+
".wsr-pin-ws{flex:none;max-width:40%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:11px;color:var(--dsw-alias-label-tertiary)}",
|
|
102
|
+
".wsr-pin-unpin{flex:none;border:none;background:transparent;color:var(--dsw-alias-label-tertiary);cursor:pointer;font-size:11px;padding:2px 4px;border-radius:4px;visibility:hidden}",
|
|
103
|
+
".wsr-pin-row:hover .wsr-pin-unpin{visibility:visible}",
|
|
104
|
+
".wsr-pin-unpin:hover{color:var(--dsw-alias-label-primary)}",
|
|
91
105
|
".wsr-toggle{width:100%;height:28px;padding:0 12px 0 calc(28px + var(--dsh-workspace-indent,0px));cursor:pointer;text-align:left;color:var(--dsw-alias-label-tertiary);background:0 0;border:none;font-size:12px}",
|
|
92
106
|
".wsr-toggle:hover{color:var(--dsw-alias-label-secondary)}",
|
|
93
107
|
'[' + STATE_ATTR + '="running"] [class*="_title"]{color:#4f8ef7}',
|
|
@@ -114,8 +128,16 @@
|
|
|
114
128
|
"color.title": "\u989c\u8272\u4e0e\u8ddf\u8fdb\u6807\u8bb0",
|
|
115
129
|
"color.description": "\u8fd0\u884c\u4e2d=\u84dd\uff0c\u5b8c\u6210\u672a\u8bfb=\u7eff\uff0c\u624b\u52a8\u6807\u8bb0\u9700\u8981\u8ddf\u8fdb=\u7425\u73c0\u3002\u5728\u4f1a\u8bdd\u300c\u2026\u300d\u83dc\u5355\u91cc\u6807\u8bb0 / \u53d6\u6d88\u3002",
|
|
116
130
|
"color.enabled": "\u542f\u7528\u989c\u8272\u6807\u8bb0",
|
|
131
|
+
"pin.title": "\u5168\u5c40\u7f6e\u9876\u533a",
|
|
132
|
+
"pin.description": "\u5728\u6240\u6709\u5de5\u4f5c\u533a\u4e4b\u4e0a\u5355\u72ec\u5217\u51fa\u7f6e\u9876\u4f1a\u8bdd\uff1b\u53d6\u6d88\u7f6e\u9876\u540e\u56de\u5230\u5b83\u539f\u6765\u7684\u5de5\u4f5c\u533a\u4f4d\u7f6e\u3002",
|
|
133
|
+
"pin.enabled": "\u663e\u793a\u7f6e\u9876\u533a",
|
|
134
|
+
"pin.head": "\u7f6e\u9876",
|
|
135
|
+
"pin.unpin": "\u53d6\u6d88\u7f6e\u9876",
|
|
117
136
|
"menu.flag": "\u6807\u8bb0\u9700\u8981\u8ddf\u8fdb",
|
|
118
137
|
"menu.unflag": "\u53d6\u6d88\u8ddf\u8fdb\u6807\u8bb0",
|
|
138
|
+
"menu.trash": "\u5220\u9664\u4f1a\u8bdd\uff08\u79fb\u5165\u56de\u6536\u7ad9\uff09",
|
|
139
|
+
"trash.confirm": "\u5220\u9664\u8fd9\u4e2a\u4f1a\u8bdd\uff1f\n\n\u4f1a\u5148\u505c\u6b62\u5b83\u6b63\u5728\u8fdb\u884c\u7684\u5de5\u4f5c\uff0c\u518d\u628a\u65e5\u5fd7\u76ee\u5f55\u79fb\u5165\u56de\u6536\u7ad9\uff08\u4e0d\u662f\u7269\u7406\u5220\u9664\uff0c\u53ef\u4ee5\u624b\u52a8\u6062\u590d\uff09\u3002",
|
|
140
|
+
"trash.failed": "\u5220\u9664\u5931\u8d25\uff1a",
|
|
119
141
|
};
|
|
120
142
|
var EN = {
|
|
121
143
|
"settings.title": "Fold history after (days)",
|
|
@@ -126,8 +148,16 @@
|
|
|
126
148
|
"color.title": "Colors and follow-up marks",
|
|
127
149
|
"color.description": "Running = blue, finished-unopened = green, a session you marked as needing follow-up = amber. Mark it from the session's \"...\" menu.",
|
|
128
150
|
"color.enabled": "Enable color marks",
|
|
151
|
+
"pin.title": "Global pinned section",
|
|
152
|
+
"pin.description": "Lists pinned sessions in one section above every workspace; unpinning sends a session back to its own group.",
|
|
153
|
+
"pin.enabled": "Show the pinned section",
|
|
154
|
+
"pin.head": "Pinned",
|
|
155
|
+
"pin.unpin": "Unpin",
|
|
129
156
|
"menu.flag": "Mark as needs follow-up",
|
|
130
157
|
"menu.unflag": "Clear follow-up mark",
|
|
158
|
+
"menu.trash": "Delete session (move to trash)",
|
|
159
|
+
"trash.confirm": "Delete this session?\n\nIt stops whatever the session is running, then moves the log directory into the trash folder (a move, not an erase: you can restore it by hand).",
|
|
160
|
+
"trash.failed": "Delete failed: ",
|
|
131
161
|
};
|
|
132
162
|
|
|
133
163
|
var FALLBACK = { "fold.show": "Show history ({n})", "fold.hide": "Hide history" };
|
|
@@ -168,6 +198,7 @@
|
|
|
168
198
|
foldDays: Math.min(MAX_DAYS, Math.max(MIN_DAYS, Math.round(days))),
|
|
169
199
|
keepActive: settings.keepActive !== false,
|
|
170
200
|
colors: settings.colors !== false,
|
|
201
|
+
pinnedSection: settings.pinnedSection !== false,
|
|
171
202
|
followUps: settings.followUps !== null && typeof settings.followUps === "object" ? settings.followUps : {},
|
|
172
203
|
};
|
|
173
204
|
}
|
|
@@ -489,6 +520,157 @@
|
|
|
489
520
|
}
|
|
490
521
|
}
|
|
491
522
|
|
|
523
|
+
function baseName(path) {
|
|
524
|
+
if (typeof path !== "string" || path === "") return "";
|
|
525
|
+
var parts = path.replace(/[\\/]+$/, "").split(/[\\/]/);
|
|
526
|
+
return parts.length === 0 ? path : parts[parts.length - 1];
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/** sessionId -> owning workspace label, for the trailing tag in the pinned rows. */
|
|
530
|
+
function workspaceLabels(workspaces) {
|
|
531
|
+
var map = {};
|
|
532
|
+
var items = (workspaces !== null && workspaces !== undefined && workspaces.items) || [];
|
|
533
|
+
for (var i = 0; i < items.length; i += 1) {
|
|
534
|
+
var item = items[i];
|
|
535
|
+
var label = (item.title && String(item.title)) || baseName(item.path);
|
|
536
|
+
var ids = item.sessionIds || [];
|
|
537
|
+
for (var j = 0; j < ids.length; j += 1) if (map[ids[j]] === undefined) map[ids[j]] = label;
|
|
538
|
+
}
|
|
539
|
+
return map;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* The sessions the host itself has pinned, newest first.
|
|
544
|
+
*
|
|
545
|
+
* The pins are the host's own set (`workspaces.pinnedSessionIds`), so this is
|
|
546
|
+
* not a second pinning system: pin / unpin from the normal "..." menu and the
|
|
547
|
+
* section follows.
|
|
548
|
+
*/
|
|
549
|
+
function pinnedItemsOf(sessions, workspaces, settings) {
|
|
550
|
+
if (!settings.pinnedSection) return [];
|
|
551
|
+
var ids = (workspaces !== null && workspaces !== undefined && workspaces.pinnedSessionIds) || [];
|
|
552
|
+
if (ids.length === 0) return [];
|
|
553
|
+
var byId = sessions.byId || {};
|
|
554
|
+
var archived = new Set((workspaces && workspaces.archivedSessionIds) || []);
|
|
555
|
+
var labels = workspaceLabels(workspaces);
|
|
556
|
+
var out = [];
|
|
557
|
+
for (var i = 0; i < ids.length; i += 1) {
|
|
558
|
+
var session = byId[ids[i]];
|
|
559
|
+
if (session === undefined || session === null) continue;
|
|
560
|
+
if (session.origin === "subagent" || session.blank === true) continue;
|
|
561
|
+
if (archived.has(ids[i])) continue;
|
|
562
|
+
var title = (session.title || "").trim();
|
|
563
|
+
out.push({
|
|
564
|
+
id: ids[i],
|
|
565
|
+
title: title === "" ? ids[i].slice(0, 8) : title,
|
|
566
|
+
where: labels[ids[i]] || "",
|
|
567
|
+
updatedAt: typeof session.updatedAt === "number" ? session.updatedAt : 0,
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
out.sort(function (a, b) { return b.updatedAt - a.updatedAt; });
|
|
571
|
+
return out;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
function pinnedRowElement(item) {
|
|
575
|
+
var row = document.createElement("div");
|
|
576
|
+
row.className = "wsr-pin-row";
|
|
577
|
+
row.setAttribute("data-row-key", "pin:" + item.id);
|
|
578
|
+
var title = document.createElement("span");
|
|
579
|
+
title.className = "wsr-pin-title";
|
|
580
|
+
title.textContent = item.title;
|
|
581
|
+
title.setAttribute("title", item.title);
|
|
582
|
+
row.appendChild(title);
|
|
583
|
+
if (item.where !== "") {
|
|
584
|
+
var where = document.createElement("span");
|
|
585
|
+
where.className = "wsr-pin-ws";
|
|
586
|
+
where.textContent = item.where;
|
|
587
|
+
row.appendChild(where);
|
|
588
|
+
}
|
|
589
|
+
var unpin = document.createElement("button");
|
|
590
|
+
unpin.type = "button";
|
|
591
|
+
unpin.className = "wsr-pin-unpin";
|
|
592
|
+
unpin.textContent = t("pin.unpin");
|
|
593
|
+
unpin.addEventListener("click", function (event) {
|
|
594
|
+
if (event !== undefined && typeof event.stopPropagation === "function") event.stopPropagation();
|
|
595
|
+
if (data.service !== null && typeof data.service.unpinSession === "function") data.service.unpinSession(item.id);
|
|
596
|
+
schedule();
|
|
597
|
+
});
|
|
598
|
+
row.appendChild(unpin);
|
|
599
|
+
row.addEventListener("click", function () {
|
|
600
|
+
if (data.service !== null && typeof data.service.openSession === "function") data.service.openSession(item.id);
|
|
601
|
+
});
|
|
602
|
+
return row;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/** Hide the copies inside their own groups so nothing is listed twice. */
|
|
606
|
+
function hideOriginalRows(entries, pinnedIds) {
|
|
607
|
+
for (var i = 0; i < entries.length; i += 1) {
|
|
608
|
+
var rows = rowElements(entries[i].section);
|
|
609
|
+
for (var j = 0; j < rows.length; j += 1) {
|
|
610
|
+
var id = sessionIdOf(rows[j]);
|
|
611
|
+
if (id !== null && pinnedIds.has(id)) {
|
|
612
|
+
if (rows[j].getAttribute(PIN_HIDDEN_ATTR) !== "true") rows[j].setAttribute(PIN_HIDDEN_ATTR, "true");
|
|
613
|
+
pinHidden.add(rows[j]);
|
|
614
|
+
} else if (rows[j].getAttribute(PIN_HIDDEN_ATTR) !== null) {
|
|
615
|
+
rows[j].removeAttribute(PIN_HIDDEN_ATTR);
|
|
616
|
+
pinHidden.delete(rows[j]);
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* Render the "pinned" section above every workspace group.
|
|
624
|
+
*
|
|
625
|
+
* It is a plain element inserted as the first child of the list container
|
|
626
|
+
* (flex order -1), rebuilt only when the pinned set or its labels change. The
|
|
627
|
+
* host keeps owning the list; the workspace grouping below is untouched.
|
|
628
|
+
*/
|
|
629
|
+
function renderPinned(entries, sessions, workspaces, settings) {
|
|
630
|
+
if (typeof document === "undefined") return;
|
|
631
|
+
var items = pinnedItemsOf(sessions, workspaces, settings);
|
|
632
|
+
|
|
633
|
+
if (items.length === 0) {
|
|
634
|
+
if (pinHost !== null) {
|
|
635
|
+
pinHost.remove();
|
|
636
|
+
pinHost = null;
|
|
637
|
+
}
|
|
638
|
+
hideOriginalRows(entries, new Set());
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
if (entries.length === 0) return;
|
|
642
|
+
var container = entries[0].section.parentElement;
|
|
643
|
+
if (container === null) return;
|
|
644
|
+
|
|
645
|
+
if (pinHost === null || pinHost.parentElement !== container) {
|
|
646
|
+
var existing = container.querySelector("[" + PIN_ATTR + '="true"]');
|
|
647
|
+
if (existing !== null) existing.remove();
|
|
648
|
+
pinHost = document.createElement("div");
|
|
649
|
+
pinHost.className = "wsr-pin";
|
|
650
|
+
pinHost.setAttribute(PIN_ATTR, "true");
|
|
651
|
+
pinHost.style.order = "-1";
|
|
652
|
+
if (typeof container.insertBefore === "function" && container.firstChild !== undefined && container.firstChild !== null) {
|
|
653
|
+
container.insertBefore(pinHost, container.firstChild);
|
|
654
|
+
} else {
|
|
655
|
+
container.appendChild(pinHost);
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
pinHost.style.order = "-1";
|
|
659
|
+
|
|
660
|
+
var signature = items.map(function (item) { return item.id + "\u0000" + item.title + "\u0000" + item.where; }).join("\u0001");
|
|
661
|
+
if (pinHost.dataset.wsrSignature !== signature) {
|
|
662
|
+
pinHost.dataset.wsrSignature = signature;
|
|
663
|
+
while (pinHost.firstChild !== undefined && pinHost.firstChild !== null) pinHost.removeChild(pinHost.firstChild);
|
|
664
|
+
var head = document.createElement("div");
|
|
665
|
+
head.className = "wsr-pin-head";
|
|
666
|
+
head.textContent = t("pin.head");
|
|
667
|
+
pinHost.appendChild(head);
|
|
668
|
+
for (var i = 0; i < items.length; i += 1) pinHost.appendChild(pinnedRowElement(items[i]));
|
|
669
|
+
}
|
|
670
|
+
var pinnedIds = new Set(items.map(function (item) { return item.id; }));
|
|
671
|
+
hideOriginalRows(entries, pinnedIds);
|
|
672
|
+
}
|
|
673
|
+
|
|
492
674
|
function statusOf(statuses, id) {
|
|
493
675
|
if (statuses === null || statuses === undefined) return null;
|
|
494
676
|
if (typeof statuses.get === "function") return statuses.get(id) || null;
|
|
@@ -560,6 +742,7 @@
|
|
|
560
742
|
|
|
561
743
|
orderGroups(entries, latestByWorkspace(sessions, workspaces));
|
|
562
744
|
foldRows(entries, settings, sessions, Date.now());
|
|
745
|
+
renderPinned(entries, sessions, workspaces, settings);
|
|
563
746
|
if (settings.colors) decorateRows(entries, sessions, data.statuses, settings);
|
|
564
747
|
}
|
|
565
748
|
|
|
@@ -633,6 +816,42 @@
|
|
|
633
816
|
});
|
|
634
817
|
}
|
|
635
818
|
|
|
819
|
+
/**
|
|
820
|
+
* Session "..." menu row: move the session into the trash folder.
|
|
821
|
+
*
|
|
822
|
+
* DSH itself only archives; the host half of this plugin stops the work,
|
|
823
|
+
* detaches the bookkeeping and renames the log directory into
|
|
824
|
+
* `<home>/.trash/ws-recency/`. The browser half only asks for confirmation
|
|
825
|
+
* and posts the id.
|
|
826
|
+
*/
|
|
827
|
+
function TrashMenuItem(props) {
|
|
828
|
+
var Item = ui.MenuItemButton;
|
|
829
|
+
if (typeof Item !== "function") return null;
|
|
830
|
+
var Icon = ui.IconTrashOutlineRegular;
|
|
831
|
+
return h(Item, {
|
|
832
|
+
icon: typeof Icon === "function" ? h(Icon, {}) : undefined,
|
|
833
|
+
onSelect: function () {
|
|
834
|
+
var question = t("trash.confirm");
|
|
835
|
+
var confirmed = typeof window.confirm === "function" ? window.confirm(question) : false;
|
|
836
|
+
if (!confirmed) return;
|
|
837
|
+
fetch("/ws-recency/session-trash", {
|
|
838
|
+
method: "POST",
|
|
839
|
+
headers: { "content-type": "application/json" },
|
|
840
|
+
body: JSON.stringify({ sessionId: props.sessionId }),
|
|
841
|
+
})
|
|
842
|
+
.then(function (response) { return response.json(); })
|
|
843
|
+
.then(function (result) {
|
|
844
|
+
if (result !== null && result.ok !== true && typeof window.alert === "function") {
|
|
845
|
+
window.alert(t("trash.failed") + (result.error || "unknown"));
|
|
846
|
+
}
|
|
847
|
+
})
|
|
848
|
+
.catch(function (error) {
|
|
849
|
+
if (typeof window.alert === "function") window.alert(t("trash.failed") + (error && error.message ? error.message : error));
|
|
850
|
+
});
|
|
851
|
+
},
|
|
852
|
+
}, props.t("menu.trash"));
|
|
853
|
+
}
|
|
854
|
+
|
|
636
855
|
/** Settings -> General row: how old a session must be before it is folded. */
|
|
637
856
|
function SettingsRow(props) {
|
|
638
857
|
var settings = props.useStore(function (state) { return state; });
|
|
@@ -700,6 +919,28 @@
|
|
|
700
919
|
);
|
|
701
920
|
}
|
|
702
921
|
|
|
922
|
+
/** Settings -> General row: the global pinned section on/off. */
|
|
923
|
+
function PinSectionRow(props) {
|
|
924
|
+
var settings = props.useStore(function (state) { return state; });
|
|
925
|
+
var enabled = settings.pinnedSection !== false;
|
|
926
|
+
var text = props.t("pin.title");
|
|
927
|
+
return h("div", { className: "wsr-setRow" },
|
|
928
|
+
h("div", { className: "wsr-setText" },
|
|
929
|
+
h("div", { className: "wsr-setTitle" }, text),
|
|
930
|
+
h("div", { className: "wsr-setDesc" }, props.t("pin.description")),
|
|
931
|
+
),
|
|
932
|
+
h("div", { className: "wsr-controls" },
|
|
933
|
+
h("label", { className: "wsr-exempt" },
|
|
934
|
+
h("input", {
|
|
935
|
+
type: "checkbox",
|
|
936
|
+
checked: enabled,
|
|
937
|
+
onChange: function (event) { props.actions.setPinnedSection(event.target.checked); },
|
|
938
|
+
}),
|
|
939
|
+
props.t("pin.enabled")),
|
|
940
|
+
),
|
|
941
|
+
);
|
|
942
|
+
}
|
|
943
|
+
|
|
703
944
|
function shutdown() {
|
|
704
945
|
disposed = true;
|
|
705
946
|
if (timer !== null) window.clearTimeout(timer);
|
|
@@ -723,6 +964,12 @@
|
|
|
723
964
|
toggles.clear();
|
|
724
965
|
dots.forEach(function (dot) { dot.remove(); });
|
|
725
966
|
dots.clear();
|
|
967
|
+
pinHidden.forEach(function (element) {
|
|
968
|
+
if (element.removeAttribute !== undefined) element.removeAttribute(PIN_HIDDEN_ATTR);
|
|
969
|
+
});
|
|
970
|
+
pinHidden.clear();
|
|
971
|
+
if (pinHost !== null && pinHost.parentElement !== null) pinHost.remove();
|
|
972
|
+
pinHost = null;
|
|
726
973
|
roots.forEach(function (root) { root.removeAttribute(ROOT_ATTR); });
|
|
727
974
|
roots.clear();
|
|
728
975
|
var marked = document.querySelectorAll("[" + STATE_ATTR + "]");
|
|
@@ -736,15 +983,17 @@
|
|
|
736
983
|
|
|
737
984
|
function apply_(ctx) {
|
|
738
985
|
installCss();
|
|
986
|
+
try { data.service = ctx.get("uiWorkspace") || null; } catch (error) { data.service = null; }
|
|
739
987
|
var prefs = clientStore.defineStore({
|
|
740
988
|
init: function () {
|
|
741
|
-
return { enabled: true, foldDays: DEFAULT_DAYS, keepActive: true, colors: true, followUps: {} };
|
|
989
|
+
return { enabled: true, foldDays: DEFAULT_DAYS, keepActive: true, colors: true, pinnedSection: true, followUps: {} };
|
|
742
990
|
},
|
|
743
991
|
persist: "dsh.ws-recency.prefs.v1",
|
|
744
992
|
actions: {
|
|
745
993
|
setEnabled: function (draft, value) { draft.enabled = value === true; },
|
|
746
994
|
setKeepActive: function (draft, value) { draft.keepActive = value === true; },
|
|
747
995
|
setColors: function (draft, value) { draft.colors = value === true; },
|
|
996
|
+
setPinnedSection: function (draft, value) { draft.pinnedSection = value === true; },
|
|
748
997
|
setFoldDays: function (draft, value) {
|
|
749
998
|
var next = Number(value);
|
|
750
999
|
if (!Number.isFinite(next)) return;
|
|
@@ -807,6 +1056,16 @@
|
|
|
807
1056
|
}, ColorsRow);
|
|
808
1057
|
});
|
|
809
1058
|
|
|
1059
|
+
ctx.slots.inject("settings.general.item", function () {
|
|
1060
|
+
return ctx.slots.register({
|
|
1061
|
+
name: "settings.general.item",
|
|
1062
|
+
id: "ws-recency-pins",
|
|
1063
|
+
order: 47,
|
|
1064
|
+
store: prefs,
|
|
1065
|
+
locale: NS,
|
|
1066
|
+
}, PinSectionRow);
|
|
1067
|
+
});
|
|
1068
|
+
|
|
810
1069
|
ctx.slots.inject("sidebar.workspaces.session.menu.item", function () {
|
|
811
1070
|
return ctx.slots.register({
|
|
812
1071
|
name: "sidebar.workspaces.session.menu.item",
|
|
@@ -827,6 +1086,16 @@
|
|
|
827
1086
|
}, FollowUpDot);
|
|
828
1087
|
});
|
|
829
1088
|
|
|
1089
|
+
ctx.slots.inject("sidebar.workspaces.session.menu.item", function () {
|
|
1090
|
+
return ctx.slots.register({
|
|
1091
|
+
name: "sidebar.workspaces.session.menu.item",
|
|
1092
|
+
id: "ws-recency-trash",
|
|
1093
|
+
order: 700,
|
|
1094
|
+
store: prefs,
|
|
1095
|
+
locale: NS,
|
|
1096
|
+
}, TrashMenuItem);
|
|
1097
|
+
});
|
|
1098
|
+
|
|
830
1099
|
ctx.effect(function () {
|
|
831
1100
|
return function () { shutdown(); };
|
|
832
1101
|
}, "dsh-ws-recency: list observer");
|
package/index.js
CHANGED
|
@@ -1,14 +1,226 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Host half of dsh-ws-recency.
|
|
2
|
+
* Host half of dsh-ws-recency: the one part that needs the file system.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* Deleting a conversation is the only sidebar operation the Client cannot do on
|
|
5
|
+
* its own: DSH only offers "archive" (a flag in `archivedSessionIds`), while the
|
|
6
|
+
* log directory and the projection cache stay on disk forever. This module moves
|
|
7
|
+
* a session into a trash folder instead of erasing it:
|
|
8
|
+
*
|
|
9
|
+
* 1. stop whatever the session is running (`workspace/session-stop`)
|
|
10
|
+
* 2. drop the archive flag and the pin
|
|
11
|
+
* 3. detach it from every workspace that lists it
|
|
12
|
+
* 4. delete its projection-cache row
|
|
13
|
+
* 5. move `<home>/sessions/<project>/<sessionId>` to
|
|
14
|
+
* `<home>/.trash/ws-recency/<sessionId>-<timestamp>`
|
|
15
|
+
* 6. broadcast `api-session/removed` so the browser drops the row
|
|
16
|
+
*
|
|
17
|
+
* Nothing is erased irreversibly: the log directory is renamed, so restoring is
|
|
18
|
+
* a move back. The route only answers loopback origins and only accepts POST.
|
|
19
|
+
*
|
|
20
|
+
* @module dsh-ws-recency
|
|
8
21
|
*/
|
|
22
|
+
import { mkdir, readdir, rename } from "node:fs/promises";
|
|
23
|
+
import { dirname, join } from "node:path";
|
|
24
|
+
|
|
25
|
+
/** Cordis plugin name shown by the loader. */
|
|
26
|
+
export const name = "ws-recency";
|
|
27
|
+
|
|
28
|
+
/** Host services this module asks for; `webServer` is injected separately. */
|
|
29
|
+
export const inject = ["sessionPersistence", "workspaceRegistry", "sessions"];
|
|
30
|
+
|
|
31
|
+
/** Exact route the client half calls. */
|
|
32
|
+
export const TRASH_SESSION_PATH = "/ws-recency/session-trash";
|
|
33
|
+
|
|
34
|
+
const STOP_SETTLE_MS = 500;
|
|
35
|
+
const MAX_BODY_BYTES = 16 * 1024;
|
|
36
|
+
const RENAME_RETRY = 4;
|
|
37
|
+
const RENAME_GAP_MS = 150;
|
|
38
|
+
|
|
39
|
+
function loopbackOrigin(req) {
|
|
40
|
+
const origin = req.headers.origin;
|
|
41
|
+
if (origin === undefined) return true;
|
|
42
|
+
try {
|
|
43
|
+
const { hostname } = new URL(origin);
|
|
44
|
+
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]" || hostname === "::1";
|
|
45
|
+
} catch {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function readJsonBody(req) {
|
|
51
|
+
const chunks = [];
|
|
52
|
+
let size = 0;
|
|
53
|
+
for await (const chunk of req) {
|
|
54
|
+
size += chunk.length;
|
|
55
|
+
if (size > MAX_BODY_BYTES) throw new Error("request body too large");
|
|
56
|
+
chunks.push(chunk);
|
|
57
|
+
}
|
|
58
|
+
if (chunks.length === 0) return {};
|
|
59
|
+
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function sendJson(res, status, value) {
|
|
63
|
+
const body = JSON.stringify(value);
|
|
64
|
+
res.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
|
|
65
|
+
res.end(body);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function dshHome() {
|
|
69
|
+
try {
|
|
70
|
+
const module = await import("@deepseek-ai/dsh-home-paths");
|
|
71
|
+
if (typeof module.resolveDshHome === "function") return module.resolveDshHome();
|
|
72
|
+
} catch {
|
|
73
|
+
/* fall through to the environment */
|
|
74
|
+
}
|
|
75
|
+
const fromEnv = process.env.DSH_HOME;
|
|
76
|
+
if (typeof fromEnv === "string" && fromEnv !== "") return fromEnv;
|
|
77
|
+
return join(process.env.USERPROFILE ?? process.env.HOME ?? ".", ".dsh");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Absolute path of a session's log file, or undefined when it cannot be located. */
|
|
81
|
+
function locateLog(ctx, meta) {
|
|
82
|
+
const persistence = ctx.sessionPersistence;
|
|
83
|
+
if (persistence !== undefined && typeof persistence.locate === "function") {
|
|
84
|
+
try {
|
|
85
|
+
const located = persistence.locate(meta);
|
|
86
|
+
if (located !== undefined && located !== null && typeof located.path === "string" && located.path !== "") return located.path;
|
|
87
|
+
} catch {
|
|
88
|
+
/* fall back to the scan below */
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Rename with a short retry: Windows can hold a handle for a moment. */
|
|
95
|
+
async function moveWithRetry(source, target) {
|
|
96
|
+
for (let attempt = 0; attempt < RENAME_RETRY; attempt += 1) {
|
|
97
|
+
try {
|
|
98
|
+
await rename(source, target);
|
|
99
|
+
return true;
|
|
100
|
+
} catch (error) {
|
|
101
|
+
if (attempt === RENAME_RETRY - 1) throw error;
|
|
102
|
+
await new Promise((resolve) => setTimeout(resolve, RENAME_GAP_MS));
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Move the session log directory into the trash; returns the new path. */
|
|
109
|
+
async function trashSessionLog(ctx, meta, trashRoot) {
|
|
110
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
111
|
+
const target = join(trashRoot, `${meta.id}-${stamp}`);
|
|
112
|
+
await mkdir(trashRoot, { recursive: true });
|
|
113
|
+
|
|
114
|
+
const located = locateLog(ctx, meta);
|
|
115
|
+
if (located !== undefined) {
|
|
116
|
+
if (await moveWithRetry(dirname(located), target)) return target;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/* No `locate()` (older host): scan `<home>/sessions/<project>/<sessionId>`. */
|
|
120
|
+
const home = await dshHome();
|
|
121
|
+
const root = join(home, "sessions");
|
|
122
|
+
let projects;
|
|
123
|
+
try {
|
|
124
|
+
projects = await readdir(root, { withFileTypes: true });
|
|
125
|
+
} catch {
|
|
126
|
+
return undefined;
|
|
127
|
+
}
|
|
128
|
+
for (const project of projects) {
|
|
129
|
+
if (!project.isDirectory()) continue;
|
|
130
|
+
try {
|
|
131
|
+
if (await moveWithRetry(join(root, project.name, meta.id), target)) return target;
|
|
132
|
+
} catch {
|
|
133
|
+
/* not in this project directory */
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return undefined;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Stop the session, drop its bookkeeping, trash its logs, tell the browser. */
|
|
140
|
+
export async function trashSession(ctx, sessionId) {
|
|
141
|
+
const live = typeof ctx.sessions?.get === "function" ? ctx.sessions.get(sessionId) : undefined;
|
|
142
|
+
let meta;
|
|
143
|
+
if (live !== undefined) {
|
|
144
|
+
meta = live.header;
|
|
145
|
+
} else {
|
|
146
|
+
const stat = await ctx.sessionPersistence.stat(sessionId);
|
|
147
|
+
if (stat === undefined) return { ok: false, error: "session not found" };
|
|
148
|
+
meta = stat.header;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
try {
|
|
152
|
+
await ctx.parallel("workspace/session-stop", { sessionId });
|
|
153
|
+
} catch {
|
|
154
|
+
/* stopping is best effort, exactly like "stop and archive" */
|
|
155
|
+
}
|
|
156
|
+
await new Promise((resolve) => setTimeout(resolve, STOP_SETTLE_MS));
|
|
157
|
+
|
|
158
|
+
try { await ctx.workspaceRegistry.unarchiveSession(sessionId); } catch { /* not archived */ }
|
|
159
|
+
try { await ctx.workspaceRegistry.unpinSession(sessionId); } catch { /* not pinned */ }
|
|
160
|
+
|
|
161
|
+
for (const workspace of ctx.workspaceRegistry.list()) {
|
|
162
|
+
if (!Array.isArray(workspace.sessionIds) || !workspace.sessionIds.includes(sessionId)) continue;
|
|
163
|
+
try { await workspace.detachSession(sessionId); } catch { /* keep going */ }
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
try {
|
|
167
|
+
const domain = ctx.get("storageDomain");
|
|
168
|
+
const projcache = typeof domain?.get === "function" ? domain.get("session_projcache") : undefined;
|
|
169
|
+
if (projcache !== undefined) await projcache.table("sessions").delete(sessionId);
|
|
170
|
+
} catch {
|
|
171
|
+
/* the cache is derived data: a miss only costs a cold read */
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const home = await dshHome();
|
|
175
|
+
const trashRoot = join(home, ".trash", "ws-recency");
|
|
176
|
+
let moved;
|
|
177
|
+
try {
|
|
178
|
+
moved = await trashSessionLog(ctx, meta, trashRoot);
|
|
179
|
+
} catch (error) {
|
|
180
|
+
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
ctx.emit("api-session/removed", sessionId);
|
|
184
|
+
return { ok: true, trash: moved };
|
|
185
|
+
}
|
|
9
186
|
|
|
10
|
-
/**
|
|
11
|
-
export
|
|
187
|
+
/** POST { sessionId } -> move that session into the trash folder. */
|
|
188
|
+
export async function handleTrashRequest(ctx, req, res) {
|
|
189
|
+
if ((req.method ?? "POST").toUpperCase() !== "POST") {
|
|
190
|
+
sendJson(res, 405, { ok: false, error: "method not allowed" });
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
if (!loopbackOrigin(req)) {
|
|
194
|
+
sendJson(res, 403, { ok: false, error: "origin not trusted" });
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
try {
|
|
198
|
+
const body = await readJsonBody(req);
|
|
199
|
+
const sessionId = body !== null && typeof body.sessionId === "string" ? body.sessionId.trim() : "";
|
|
200
|
+
if (sessionId === "") {
|
|
201
|
+
sendJson(res, 400, { ok: false, error: "sessionId required" });
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
const result = await trashSession(ctx, sessionId);
|
|
205
|
+
sendJson(res, result.ok ? 200 : 500, result);
|
|
206
|
+
} catch (error) {
|
|
207
|
+
sendJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) });
|
|
208
|
+
}
|
|
209
|
+
}
|
|
12
210
|
|
|
13
|
-
/**
|
|
14
|
-
export function apply() {
|
|
211
|
+
/** Cordis entry point: mount the route when the host has a web server. */
|
|
212
|
+
export function apply(ctx) {
|
|
213
|
+
try {
|
|
214
|
+
if (typeof ctx.inject === "function") {
|
|
215
|
+
ctx.inject(["webServer"], (webCtx) => {
|
|
216
|
+
webCtx.effect(() => webCtx.webServer.register({
|
|
217
|
+
kind: "exact",
|
|
218
|
+
path: TRASH_SESSION_PATH,
|
|
219
|
+
handler: (req, res) => handleTrashRequest(ctx, req, res),
|
|
220
|
+
}), "ws-recency: trash route");
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
} catch {
|
|
224
|
+
/* a headless profile simply gets no route */
|
|
225
|
+
}
|
|
226
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-ws-recency",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Sidebar session management for DeepSeek Harness: workspace groups sorted by latest activity, stale history folded,
|
|
3
|
+
"version": "0.9.0",
|
|
4
|
+
"description": "Sidebar session management for DeepSeek Harness: workspace groups sorted by latest activity, stale history folded, a global pinned section, follow-up marks, and session delete into a recoverable trash.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "chyxhtc",
|
|
7
7
|
"repository": {
|