dsh-workspace-mover 2.2.0 → 2.4.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/package.json CHANGED
@@ -1,99 +1,103 @@
1
- {
2
- "name": "dsh-workspace-mover",
3
- "version": "2.2.0",
4
- "description": "在 DSH 侧边栏安全迁移会话:真迁移原始档案,支持失联会话恢复、移动历史、一键撤回和 agent 工具。",
5
- "type": "module",
6
- "main": "lib/index.js",
7
- "exports": {
8
- ".": "./lib/index.js",
9
- "./client": "./client/client.js",
10
- "./package.json": "./package.json"
11
- },
12
- "files": [
13
- "lib",
14
- "client",
15
- "test",
16
- "cordis.patch.yml",
17
- "README.md",
18
- "docs/ARCHITECTURE.md",
19
- "docs/media",
20
- "LICENSE"
21
- ], "scripts": {
22
- "test": "node --test test/core.test.mjs test/e2e-sandbox.test.mjs test/policy.test.mjs test/client-dom.test.mjs test/traffic-badge.test.mjs test/release-downloads.test.mjs",
23
- "check:syntax": "node --check lib/index.js && node --check client/client.js",
24
- "check:pack": "npm pack --dry-run",
25
- "check": "npm run check:syntax && npm test && npm run check:pack"
26
- },
27
- "keywords": [
28
- "deepseek-harness",
29
- "dsh",
30
- "dsh-plugin",
31
- "workspace",
32
- "session",
33
- "session-migration",
34
- "drag-and-drop",
35
- "agent-tools"
36
- ],
37
- "engines": {
38
- "node": ">=22",
39
- "dsh": ">=0.1.5-rc.1"
40
- },
41
- "//peerDependencies": "Deliberately `*`. These are OPTIONAL peers: the plugin declares them so storefronts can see the host packages it integrates with, but it fail-softs at runtime (mover.status capabilities + mover_doctor). npm's prerelease rule evaluates each `||` alternative as a set and only admits a prerelease when a comparator in that set shares the version's exact major.minor.patch tuple AND carries a prerelease tag -- so an upper bound like `<0.2.0-0` excludes EVERY 0.2.0 prerelease, which made the host report 2.2.0 as incompatible with 0.2.0-rc.2. Enumerating release lines would keep producing false incompatibilities on each new line; the real floor is engines.dsh, and capability flags report any actual runtime mismatch. Do NOT narrow these to version ranges.",
42
- "peerDependencies": {
43
- "@deepseek-ai/cordis": "*",
44
- "@deepseek-ai/dsh-session": "*",
45
- "@deepseek-ai/dsh-tools": "*",
46
- "@deepseek-ai/dsh-workspace": "*",
47
- "@deepseek-ai/schemastery": "*",
48
- "@deepseek-ai/dsh-client-connection": "*",
49
- "@deepseek-ai/dsh-client-locale": "*",
50
- "@deepseek-ai/dsh-client-ui-slots": "*"
51
- },
52
- "peerDependenciesMeta": {
53
- "@deepseek-ai/cordis": {
54
- "optional": true
55
- },
56
- "@deepseek-ai/dsh-session": {
57
- "optional": true
58
- },
59
- "@deepseek-ai/dsh-tools": {
60
- "optional": true
61
- },
62
- "@deepseek-ai/dsh-workspace": {
63
- "optional": true
64
- },
65
- "@deepseek-ai/schemastery": {
66
- "optional": true
67
- },
68
- "@deepseek-ai/dsh-client-connection": {
69
- "optional": true
70
- },
71
- "@deepseek-ai/dsh-client-locale": {
72
- "optional": true
73
- },
74
- "@deepseek-ai/dsh-client-ui-slots": {
75
- "optional": true
76
- }
77
- },
78
- "license": "MIT",
79
- "repository": {
80
- "type": "git",
81
- "url": "git+https://github.com/PianoPrince/dsh-workspace-mover.git"
82
- },
83
- "bugs": {
84
- "url": "https://github.com/PianoPrince/dsh-workspace-mover/issues"
85
- },
86
- "homepage": "https://github.com/PianoPrince/dsh-workspace-mover#readme",
87
- "dsh": {
88
- "bundle": {
89
- "patch": "./cordis.patch.yml"
90
- },
91
- "client": {
92
- "inject": [
93
- "@deepseek-ai/dsh-client-connection",
94
- "@deepseek-ai/dsh-client-ui-slots"
95
- ],
96
- "platform": "web"
97
- }
98
- }
99
- }
1
+ {
2
+ "name": "dsh-workspace-mover",
3
+ "version": "2.4.0",
4
+ "description": "在 DSH 侧边栏安全迁移会话:真迁移原始档案,支持失联会话恢复、移动历史、一键撤回和 agent 工具。",
5
+ "type": "module",
6
+ "main": "lib/index.js",
7
+ "exports": {
8
+ ".": "./lib/index.js",
9
+ "./client": "./client/client.js",
10
+ "./package.json": "./package.json"
11
+ },
12
+ "files": [
13
+ "lib",
14
+ "client",
15
+ "test",
16
+ "cordis.patch.yml",
17
+ "README.md",
18
+ "README_EN.md",
19
+ "CHANGELOG.md",
20
+ "docs/ARCHITECTURE.md",
21
+ "docs/compatibility",
22
+ "docs/media",
23
+ "LICENSE"
24
+ ], "scripts": {
25
+ "test": "node --test test/core.test.mjs test/e2e-sandbox.test.mjs test/policy.test.mjs test/client-dom.test.mjs test/traffic-badge.test.mjs test/release-downloads.test.mjs test/workspace-tools.test.mjs test/host-services.test.mjs",
26
+ "test:installed-host": "node --test test/installed-host.test.mjs",
27
+ "check:syntax": "node --check lib/index.js && node --check client/client.js",
28
+ "check:pack": "npm pack --dry-run",
29
+ "check": "npm run check:syntax && npm test && npm run check:pack"
30
+ },
31
+ "keywords": [
32
+ "deepseek-harness",
33
+ "dsh",
34
+ "dsh-plugin",
35
+ "workspace",
36
+ "session",
37
+ "session-migration",
38
+ "drag-and-drop",
39
+ "agent-tools"
40
+ ],
41
+ "engines": {
42
+ "node": ">=22",
43
+ "dsh": ">=0.1.5-rc.1"
44
+ },
45
+ "//peerDependencies": "Deliberately `*`. These are OPTIONAL peers: the plugin declares them so storefronts can see the host packages it integrates with, but it fail-softs at runtime (mover.status capabilities + mover_doctor). npm's prerelease rule evaluates each `||` alternative as a set and only admits a prerelease when a comparator in that set shares the version's exact major.minor.patch tuple AND carries a prerelease tag -- so an upper bound like `<0.2.0-0` excludes EVERY 0.2.0 prerelease, which made the host report 2.2.0 as incompatible with 0.2.0-rc.2. Enumerating release lines would keep producing false incompatibilities on each new line; the real floor is engines.dsh, and capability flags report any actual runtime mismatch. Do NOT narrow these to version ranges.",
46
+ "peerDependencies": {
47
+ "@deepseek-ai/cordis": "*",
48
+ "@deepseek-ai/dsh-session": "*",
49
+ "@deepseek-ai/dsh-tools": "*",
50
+ "@deepseek-ai/dsh-workspace": "*",
51
+ "@deepseek-ai/schemastery": "*",
52
+ "@deepseek-ai/dsh-client-connection": "*",
53
+ "@deepseek-ai/dsh-client-locale": "*",
54
+ "@deepseek-ai/dsh-client-ui-slots": "*"
55
+ },
56
+ "peerDependenciesMeta": {
57
+ "@deepseek-ai/cordis": {
58
+ "optional": true
59
+ },
60
+ "@deepseek-ai/dsh-session": {
61
+ "optional": true
62
+ },
63
+ "@deepseek-ai/dsh-tools": {
64
+ "optional": true
65
+ },
66
+ "@deepseek-ai/dsh-workspace": {
67
+ "optional": true
68
+ },
69
+ "@deepseek-ai/schemastery": {
70
+ "optional": true
71
+ },
72
+ "@deepseek-ai/dsh-client-connection": {
73
+ "optional": true
74
+ },
75
+ "@deepseek-ai/dsh-client-locale": {
76
+ "optional": true
77
+ },
78
+ "@deepseek-ai/dsh-client-ui-slots": {
79
+ "optional": true
80
+ }
81
+ },
82
+ "license": "MIT",
83
+ "repository": {
84
+ "type": "git",
85
+ "url": "git+https://github.com/PianoPrince/dsh-workspace-mover.git"
86
+ },
87
+ "bugs": {
88
+ "url": "https://github.com/PianoPrince/dsh-workspace-mover/issues"
89
+ },
90
+ "homepage": "https://github.com/PianoPrince/dsh-workspace-mover#readme",
91
+ "dsh": {
92
+ "bundle": {
93
+ "patch": "./cordis.patch.yml"
94
+ },
95
+ "client": {
96
+ "inject": [
97
+ "@deepseek-ai/dsh-client-connection",
98
+ "@deepseek-ai/dsh-client-ui-slots"
99
+ ],
100
+ "platform": "web"
101
+ }
102
+ }
103
+ }
@@ -100,6 +100,7 @@ function createElement(tag) {
100
100
  tagName: String(tag).toUpperCase(),
101
101
  childNodes: [],
102
102
  parentNode: null,
103
+ get parentElement() { return this.parentNode?.nodeType === 1 ? this.parentNode : null; },
103
104
  className: '',
104
105
  id: '',
105
106
  style: {},
@@ -297,12 +298,26 @@ const reactShim = {
297
298
 
298
299
  let mod = null;
299
300
  const rpcCalls = [];
301
+ const animationFrames = new Map();
302
+ let animationFrameId = 0;
303
+ function frame() {
304
+ const pending = [...animationFrames.values()];
305
+ animationFrames.clear();
306
+ for (const callback of pending) callback();
307
+ }
300
308
  let rpcResponder = () => ({ ok: true, value: {} });
301
309
 
302
310
  function setResponder(fn) { rpcResponder = fn; }
303
311
 
304
312
  before(async () => {
305
313
  globalThis.document = doc;
314
+ globalThis.getComputedStyle = (el) => ({ overflowY: el.style.overflowY ?? 'visible' });
315
+ globalThis.requestAnimationFrame = (callback) => {
316
+ const id = ++animationFrameId;
317
+ animationFrames.set(id, callback);
318
+ return id;
319
+ };
320
+ globalThis.cancelAnimationFrame = (id) => animationFrames.delete(id);
306
321
  globalThis.MutationObserver = MutationObserverStub;
307
322
  globalThis.Node = { ELEMENT_NODE: 1 };
308
323
  globalThis.Element = function Element() {};
@@ -371,6 +386,7 @@ function unmount() {
371
386
 
372
387
  function resetDom() {
373
388
  unmount();
389
+ animationFrames.clear();
374
390
  doc._body.childNodes = [];
375
391
  doc._listeners = [];
376
392
  rpcCalls.length = 0;
@@ -498,6 +514,44 @@ test('未卸载就重复装载时不得叠加第二份监听器(防重复实
498
514
  `重复 apply 不得叠加:${afterFirst} -> ${doc._listeners.length}`);
499
515
  });
500
516
 
517
+ test('drag autoscroll stops outside sidebar, at bounds, on dragleave/drop/dragend and unload', () => {
518
+ resetDom();
519
+ mount();
520
+ const row = sessionRow('Scroll test', 'session-aaa');
521
+ const scroller = createElement('div');
522
+ scroller.style.overflowY = 'auto';
523
+ Object.assign(scroller, { scrollHeight: 1000, clientHeight: 300, scrollTop: 100,
524
+ getBoundingClientRect: () => ({ left: 0, right: 200, top: 0, bottom: 300 }) });
525
+ scroller.appendChild(row);
526
+ attach(scroller);
527
+ const start = (x = 100, y = 290) => {
528
+ dispatch({ type: 'dragstart', target: row });
529
+ dispatch({ type: 'dragover', target: row, clientX: x, clientY: y });
530
+ };
531
+ start(); frame();
532
+ assert.equal(scroller.scrollTop, 112);
533
+ dispatch({ type: 'dragover', target: row, clientX: 400, clientY: 290 });
534
+ frame();
535
+ assert.equal(scroller.scrollTop, 112);
536
+ assert.equal(animationFrames.size, 0);
537
+ start(100, 150); frame();
538
+ assert.equal(animationFrames.size, 0, 'center does not scroll');
539
+ scroller.scrollTop = 700;
540
+ start(); frame();
541
+ assert.equal(animationFrames.size, 0, 'bottom boundary stops frames');
542
+ scroller.scrollTop = 0;
543
+ start(100, 5); frame();
544
+ assert.equal(animationFrames.size, 0, 'top boundary stops frames');
545
+ scroller.scrollTop = 100;
546
+ for (const type of ['dragleave', 'drop', 'dragend']) {
547
+ start();
548
+ dispatch({ type, target: doc._body, relatedTarget: null });
549
+ assert.equal(animationFrames.size, 0, `${type} cancels pending frames`);
550
+ }
551
+ start(); unmount();
552
+ assert.equal(animationFrames.size, 0, 'hot unload cancels frames');
553
+ });
554
+
501
555
  test('拖拽跨组移动:经权威 fiber 通道解析出正确 sessionId', async () => {
502
556
  resetDom();
503
557
  mount();
@@ -512,6 +566,31 @@ test('拖拽跨组移动:经权威 fiber 通道解析出正确 sessionId', asy
512
566
  assert.equal(calls[0].payload.targetWorkspaceId, 'wid-b');
513
567
  });
514
568
 
569
+ test('迁移标题使用权威预览,不包含侧边栏时间;驻留队列提示可能需重启', async () => {
570
+ resetDom();
571
+ mount();
572
+ const row = sessionRow('测试刚刚', 'session-aaa');
573
+ const beta = header('proj-beta');
574
+ fixture({ rows: [row] });
575
+ const prior = rpcResponder;
576
+ setResponder((endpoint, payload) => endpoint === 'mover.preview' ? {
577
+ ok: true, value: {
578
+ title: '测试', status: 'queued', source: { cwd: 'C:/proj-alpha' },
579
+ target: { path: 'C:/proj-beta' }, estimatedBackupBytes: 0,
580
+ warnings: ['resident writer; restart required']
581
+ }
582
+ } : prior(endpoint, payload));
583
+ attach(beta);
584
+ await dragDrop(row, beta, 'session-aaa', { confirm: false });
585
+ const overlay = doc._body.querySelector('.wsm-overlay');
586
+ assert.equal(overlay.querySelector('.wsm-session').textContent, '测试');
587
+ assert.match(overlay.querySelector('.wsm-preview').textContent, /可能需重启/);
588
+ assert.doesNotMatch(overlay.querySelector('.wsm-preview').textContent, /进入空闲队列/);
589
+ dispatch({ type: 'click', target: overlay.querySelector('.wsm-ok') });
590
+ await new Promise((resolve) => setTimeout(resolve, 0));
591
+ assert.equal(moveCalls()[0].payload.sessionTitle, '测试');
592
+ });
593
+
515
594
  test('兜底映射错配时不得把错配 id 当成真实会话移动(P2 回归)', async () => {
516
595
  resetDom();
517
596
  mount();
@@ -562,6 +641,135 @@ test('工作区解析必须自证:标题与路径都不匹配时不得按 DOM
562
641
  assert.equal(calls.length, 0, '标题与路径都不匹配时必须中止,不得按序号回退');
563
642
  });
564
643
 
644
+ test('工作区身份优先于标题:移入后仍可拖回原工作区', async () => {
645
+ resetDom();
646
+ mount();
647
+ const row = sessionRow('Alpha discussion', 'session-aaa');
648
+ const beta = header('proj-beta');
649
+ const alpha = header('proj-beta'); // Simulate stale visible text after a sidebar update.
650
+ Object.defineProperty(alpha, '__reactFiber$t', { value: {
651
+ memoizedProps: { group: { workspaceId: 'wid-a' } }, return: null
652
+ } });
653
+ fixture({ rows: [row] });
654
+ attach(beta, alpha);
655
+ await dragDrop(row, beta, 'session-aaa');
656
+ await dragDrop(row, alpha, 'session-aaa');
657
+ assert.deepEqual(moveCalls().map((call) => call.payload.targetWorkspaceId), ['wid-b', 'wid-a']);
658
+ });
659
+
660
+ test('工作区 data-row-key 优先于包含其他工作区名称的文字', async () => {
661
+ resetDom();
662
+ mount();
663
+ const row = sessionRow('Alpha discussion', 'session-aaa');
664
+ const beta = header('proj-alpha proj-beta');
665
+ beta.setAttribute('data-row-key', 'workspace:wid-b');
666
+ Object.defineProperty(beta, '__reactFiber$t', { value: {
667
+ memoizedProps: { group: { workspaceId: 'wid-a' } }, return: null
668
+ } }); // Rendered DOM identity must win over stale React props.
669
+ fixture({ rows: [row] });
670
+ attach(beta);
671
+ await dragDrop(row, beta, 'session-aaa');
672
+ assert.equal(moveCalls()[0]?.payload.targetWorkspaceId, 'wid-b');
673
+ });
674
+
675
+ test('嵌套会话标题不会被用作目标工作区名称', async () => {
676
+ resetDom();
677
+ mount();
678
+ const row = sessionRow('Alpha discussion', 'session-aaa');
679
+ const beta = header('proj-beta');
680
+ beta.appendChild(sessionRow('proj-alpha', 'session-other'));
681
+ fixture({ rows: [row] });
682
+ attach(beta);
683
+ await dragDrop(row, beta, 'session-aaa');
684
+ assert.equal(moveCalls()[0]?.payload.targetWorkspaceId, 'wid-b');
685
+ });
686
+
687
+ test('工作区文字匹配有歧义时停止迁移', async () => {
688
+ resetDom();
689
+ mount();
690
+ const row = sessionRow('Alpha discussion', 'session-aaa');
691
+ const ambiguous = header('proj-alpha proj-beta');
692
+ fixture({ rows: [row] });
693
+ attach(ambiguous);
694
+ await dragDrop(row, ambiguous, 'session-aaa');
695
+ assert.equal(moveCalls().length, 0);
696
+ });
697
+
698
+ test('落在标题外时不沿用上次悬停的工作区', async () => {
699
+ resetDom();
700
+ mount();
701
+ const row = sessionRow('Alpha discussion', 'session-aaa');
702
+ const beta = header('proj-beta');
703
+ fixture({ rows: [row] });
704
+ attach(beta);
705
+ const dt = { getData: () => 'session-aaa' };
706
+ dispatch({ type: 'dragstart', target: row, dataTransfer: dt });
707
+ dispatch({ type: 'dragover', target: beta, preventDefault() {} });
708
+ await new Promise((r) => setTimeout(r, 0));
709
+ dispatch({ type: 'drop', target: doc._body, dataTransfer: dt, preventDefault() {}, stopImmediatePropagation() {} });
710
+ await new Promise((r) => setTimeout(r, 0));
711
+ assert.equal(doc._body.querySelector('.wsm-overlay'), null);
712
+ assert.equal(moveCalls().length, 0);
713
+ });
714
+
715
+ test('同工作区预览阻断后可显式选择原工作区并重新预览再移回', async () => {
716
+ resetDom();
717
+ mount();
718
+ const row = sessionRow('Alpha discussion', 'session-aaa');
719
+ const beta = header('proj-beta');
720
+ const { ws } = fixture({ rows: [row] });
721
+ attach(beta);
722
+ setResponder((endpoint, payload) => {
723
+ if (endpoint === 'mover.workspaces') return { ok: true, value: { items: ws } };
724
+ if (endpoint === 'mover.preview') return { ok: true, value: {
725
+ status: payload.targetWorkspaceId === 'wid-b' ? 'blocked' : 'safe',
726
+ source: { cwd: 'C:/proj-beta' },
727
+ target: { path: ws.find((item) => item.workspaceId === payload.targetWorkspaceId).path },
728
+ warnings: [], estimatedBackupBytes: 123
729
+ } };
730
+ return { ok: true, value: {} };
731
+ });
732
+ await dragDrop(row, beta, 'session-aaa', { confirm: false });
733
+ const select = doc._body.querySelector('.wsm-target-select');
734
+ const ok = doc._body.querySelector('.wsm-ok');
735
+ assert.equal(ok.disabled, true);
736
+ select.value = 'wid-a';
737
+ dispatch({ type: 'change', target: select });
738
+ assert.equal(ok.disabled, true, 'Must wait for the new preview');
739
+ await new Promise((r) => setTimeout(r, 0));
740
+ assert.equal(ok.disabled, false);
741
+ assert.match(doc._body.querySelector('.wsm-preview').textContent, /目标路径: C:\/proj-alpha/);
742
+ dispatch({ type: 'click', target: ok });
743
+ await new Promise((r) => setTimeout(r, 0));
744
+ assert.equal(moveCalls()[0]?.payload.targetWorkspaceId, 'wid-a');
745
+ });
746
+
747
+ test('切换目标后预览失败时禁止按旧预览执行', async () => {
748
+ resetDom();
749
+ mount();
750
+ const row = sessionRow('Alpha discussion', 'session-aaa');
751
+ const beta = header('proj-beta');
752
+ const { ws } = fixture({ rows: [row] });
753
+ attach(beta);
754
+ setResponder((endpoint, payload) => {
755
+ if (endpoint === 'mover.workspaces') return { ok: true, value: { items: ws } };
756
+ if (endpoint === 'mover.preview') return payload.targetWorkspaceId === 'wid-a'
757
+ ? { ok: false, error: { message: 'preview failed' } }
758
+ : { ok: true, value: { status: 'safe', source: { cwd: 'C:/proj-alpha' } } };
759
+ return { ok: true, value: {} };
760
+ });
761
+ await dragDrop(row, beta, 'session-aaa', { confirm: false });
762
+ const select = doc._body.querySelector('.wsm-target-select');
763
+ select.value = 'wid-a';
764
+ dispatch({ type: 'change', target: select });
765
+ await new Promise((r) => setTimeout(r, 0));
766
+ const ok = doc._body.querySelector('.wsm-ok');
767
+ assert.equal(ok.disabled, true);
768
+ dispatch({ type: 'click', target: ok });
769
+ assert.equal(moveCalls().length, 0);
770
+ dispatch({ type: 'click', target: doc._body.querySelector('.wsm-cancel') });
771
+ });
772
+
565
773
  test('弹窗 Escape 关闭并释放 keydown 监听器', async () => {
566
774
  resetDom();
567
775
  mount();