dsh-workspace-plus 0.2.0 → 0.6.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 +572 -397
- package/cordis.patch.yml +45 -8
- package/lib/client.js +2676 -1653
- package/lib/index.js +790 -707
- package/lib/notice/click.ps1 +67 -0
- package/lib/notice/format.js +313 -0
- package/lib/notice/icon.svg +16 -0
- package/lib/notice/index.js +951 -0
- package/lib/notice/locale/en.json +6 -0
- package/lib/notice/locale/zh.json +6 -0
- package/lib/notice/package.json +6 -0
- package/lib/notice/settings.js +336 -0
- package/lib/notice/toast-host.ps1 +616 -0
- package/lib/notice/toast.js +517 -0
- package/lib/notice/win32.js +206 -0
- package/package.json +73 -65
package/lib/client.js
CHANGED
|
@@ -1,1653 +1,2676 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* dsh-workspace-plus — browser half.
|
|
3
|
-
*
|
|
4
|
-
* This file is the package's `./client` export: a self-contained, factory-form
|
|
5
|
-
* CommonJS bundle registered with the shell's client module loader. The host
|
|
6
|
-
* serves it at `/plugins/dsh-workspace-plus/client.js`; the browser loads the
|
|
7
|
-
* script during boot but only *materializes* it (runs the factory body) when the
|
|
8
|
-
* plugin is first needed, so every side effect here — the injected stylesheet
|
|
9
|
-
* included — happens at materialization time, never at script-parse time.
|
|
10
|
-
*
|
|
11
|
-
* The feature — workspace directories, as a DIALOG rather than a page.
|
|
12
|
-
* A DSH workspace keeps its one root directory; this registers N more project
|
|
13
|
-
* folders against it, each with a short label, and the host injects that table
|
|
14
|
-
* into the agent's context on every request — so "改 backend" in conversation
|
|
15
|
-
* resolves to an absolute path instead of a guess.
|
|
16
|
-
*
|
|
17
|
-
* Five seats cooperate:
|
|
18
|
-
*
|
|
19
|
-
* `settings.section` the `workspace+` settings page. It owns one preference:
|
|
20
|
-
* whether the feature runs for the default (first-use)
|
|
21
|
-
* Workspace, which is off until the user turns it on
|
|
22
|
-
* because that Workspace belongs to the product.
|
|
23
|
-
*
|
|
24
|
-
* `shell.overlay` the dialog itself, the same frame-wide layer the
|
|
25
|
-
* shell's own workspace Rename/Delete dialogs use (the
|
|
26
|
-
* Modal portals to <body>, so the entry owns only the
|
|
27
|
-
* open state, not the stack position).
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
* and
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
* the
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
const
|
|
89
|
-
const
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
/**
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
.
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
.
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
.
|
|
177
|
-
|
|
178
|
-
.dsh-
|
|
179
|
-
.dsh-
|
|
180
|
-
.dsh-
|
|
181
|
-
.dsh-
|
|
182
|
-
.dsh-
|
|
183
|
-
.dsh-
|
|
184
|
-
.dsh-
|
|
185
|
-
.dsh-
|
|
186
|
-
.dsh-
|
|
187
|
-
.dsh-
|
|
188
|
-
.dsh-
|
|
189
|
-
.dsh-
|
|
190
|
-
.dsh-
|
|
191
|
-
.dsh-
|
|
192
|
-
.dsh-
|
|
193
|
-
.dsh-
|
|
194
|
-
.dsh-
|
|
195
|
-
.dsh-
|
|
196
|
-
.dsh-
|
|
197
|
-
.dsh-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
*
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
*
|
|
281
|
-
*
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
const
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
.
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
*
|
|
526
|
-
*
|
|
527
|
-
*
|
|
528
|
-
* @param
|
|
529
|
-
* @returns
|
|
530
|
-
*/
|
|
531
|
-
function
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
*
|
|
640
|
-
*
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
/**
|
|
693
|
-
*
|
|
694
|
-
*
|
|
695
|
-
*
|
|
696
|
-
*
|
|
697
|
-
*
|
|
698
|
-
*
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
*
|
|
711
|
-
*
|
|
712
|
-
*
|
|
713
|
-
*
|
|
714
|
-
*
|
|
715
|
-
*
|
|
716
|
-
*/
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
*
|
|
731
|
-
*
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
}
|
|
929
|
-
}
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
:
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
*
|
|
1149
|
-
*
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
*
|
|
1155
|
-
*
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
'
|
|
1237
|
-
{
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
{
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
*
|
|
1344
|
-
*
|
|
1345
|
-
*
|
|
1346
|
-
*
|
|
1347
|
-
*
|
|
1348
|
-
*
|
|
1349
|
-
*
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
return
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
*
|
|
1445
|
-
*
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
*
|
|
1454
|
-
*
|
|
1455
|
-
*
|
|
1456
|
-
*
|
|
1457
|
-
*
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
//
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
},
|
|
1536
|
-
|
|
1537
|
-
)
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
}
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1
|
+
/**
|
|
2
|
+
* dsh-workspace-plus — browser half.
|
|
3
|
+
*
|
|
4
|
+
* This file is the package's `./client` export: a self-contained, factory-form
|
|
5
|
+
* CommonJS bundle registered with the shell's client module loader. The host
|
|
6
|
+
* serves it at `/plugins/dsh-workspace-plus/client.js`; the browser loads the
|
|
7
|
+
* script during boot but only *materializes* it (runs the factory body) when the
|
|
8
|
+
* plugin is first needed, so every side effect here — the injected stylesheet
|
|
9
|
+
* included — happens at materialization time, never at script-parse time.
|
|
10
|
+
*
|
|
11
|
+
* The workspace+ feature — workspace directories, as a DIALOG rather than a page.
|
|
12
|
+
* A DSH workspace keeps its one root directory; this registers N more project
|
|
13
|
+
* folders against it, each with a short label, and the host injects that table
|
|
14
|
+
* into the agent's context on every request — so "改 backend" in conversation
|
|
15
|
+
* resolves to an absolute path instead of a guess.
|
|
16
|
+
*
|
|
17
|
+
* Five workspace+ seats cooperate:
|
|
18
|
+
*
|
|
19
|
+
* `settings.section` the `workspace+` settings page. It owns one preference:
|
|
20
|
+
* whether the feature runs for the default (first-use)
|
|
21
|
+
* Workspace, which is off until the user turns it on
|
|
22
|
+
* because that Workspace belongs to the product.
|
|
23
|
+
*
|
|
24
|
+
* `shell.overlay` the dialog itself, the same frame-wide layer the
|
|
25
|
+
* shell's own workspace Rename/Delete dialogs use (the
|
|
26
|
+
* Modal portals to <body>, so the entry owns only the
|
|
27
|
+
* open state, not the stack position). It reads as one
|
|
28
|
+
* column of bordered field rows — the workspace it is
|
|
29
|
+
* locked to, the 「目录」 list, an add row that IS the
|
|
30
|
+
* shell's own directory picker, and a footer split
|
|
31
|
+
* secondary-left / primary-right. There is no path input
|
|
32
|
+
* to type into: `ctx.uiWorkspace.pickDirectory()` is the
|
|
33
|
+
* only way a directory gets in, and the label/note row
|
|
34
|
+
* only appears once one has been chosen.
|
|
35
|
+
*
|
|
36
|
+
* workspace row button the primary trigger. The shell's workspace row menu is
|
|
37
|
+
* hardcoded to Rename/Delete with no plugin slot, so the
|
|
38
|
+
* trigger is a button this half appends to the row's
|
|
39
|
+
* action cluster, located through shell-emitted
|
|
40
|
+
* `data-row-key="workspace:<id>"` anchors and kept in
|
|
41
|
+
* place by a MutationObserver. Every real workspace row
|
|
42
|
+
* carries it; a workspace whose switch is off only draws
|
|
43
|
+
* it dimmed, because the dialog is where its labels are
|
|
44
|
+
* read and the switch itself lives on the settings page.
|
|
45
|
+
*
|
|
46
|
+
* `sidebar.panellist` the fallback trigger, registered only while no row
|
|
47
|
+
* button could be installed (a shell whose row DOM
|
|
48
|
+
* changed, or a sidebar with no workspaces yet).
|
|
49
|
+
* Registering it also requires a `main` key: the shell's
|
|
50
|
+
* panel button calls `layout.selectPanel` unconditionally
|
|
51
|
+
* and that throws for an unregistered key, so a hint
|
|
52
|
+
* panel holds the key and the glyph stops the click from
|
|
53
|
+
* ever reaching it.
|
|
54
|
+
*
|
|
55
|
+
* `@` trigger source the composer side. Typing `@` in a session offers that
|
|
56
|
+
* workspace's labels as their own group; a pick inserts a
|
|
57
|
+
* reference chip that serializes to the directory's
|
|
58
|
+
* absolute path, so the model receives the path even when
|
|
59
|
+
* the injected table is not in context.
|
|
60
|
+
*
|
|
61
|
+
* The 通知 (notice) sub-feature — ported from the dsh-notice plugin, which now
|
|
62
|
+
* ships as the `dsh-workspace-plus/notice` loader row of this same bundle — adds
|
|
63
|
+
* two more seats here, described in detail at its section below:
|
|
64
|
+
*
|
|
65
|
+
* `settings.section` the 通知 settings page (same id, label and order the
|
|
66
|
+
* standalone plugin used), writing through the same
|
|
67
|
+
* `/dsh-notice/api` routes. When the notice row is off
|
|
68
|
+
* the host answers 404 and the page explains the state.
|
|
69
|
+
*
|
|
70
|
+
* the pending poll the last centimetre of the click-to-open path: toast
|
|
71
|
+
* click → `dsh-notice:` protocol → host route → pending
|
|
72
|
+
* intent → THIS half → `ctx.uiWorkspace.openSession()`.
|
|
73
|
+
*
|
|
74
|
+
* Only the shell's frozen platform module table may be `require`d here (react,
|
|
75
|
+
* react-dom, react/jsx-runtime, @deepseek-ai/cordis,
|
|
76
|
+
* @deepseek-ai/dsh-client-store, @deepseek-ai/dsh-client-ui-slots,
|
|
77
|
+
* @deepseek-ai/dsh-client-ui-primitives, @deepseek-ai/dsh-client-ui-dockkit).
|
|
78
|
+
* Any other specifier needs a matching `dsh.client.external` entry in
|
|
79
|
+
* package.json, which this plugin does not use.
|
|
80
|
+
*/
|
|
81
|
+
window.__ModuleLoader__.load({
|
|
82
|
+
id: 'dsh-workspace-plus',
|
|
83
|
+
factory: (require) => {
|
|
84
|
+
var module = { exports: {} }
|
|
85
|
+
var exports = module.exports
|
|
86
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
|
|
87
|
+
|
|
88
|
+
const React = require('react')
|
|
89
|
+
const primitives = require('@deepseek-ai/dsh-client-ui-primitives')
|
|
90
|
+
const { Button, Modal, Switch } = primitives
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The shell's hover tooltip. The dialog's "outside the workspace root" badge
|
|
94
|
+
* says what it means through this rather than through a sentence in the row,
|
|
95
|
+
* so a shell that predates the export degrades to the still-labelled badge
|
|
96
|
+
* instead of crashing the dialog.
|
|
97
|
+
*/
|
|
98
|
+
const Tooltip = typeof primitives.Tooltip === 'function' ? primitives.Tooltip : null
|
|
99
|
+
|
|
100
|
+
/** Id of the directory-dialog fallback entry, and the main key that backs it. */
|
|
101
|
+
const WORKDIRS_PANEL_ID = 'workdirs'
|
|
102
|
+
|
|
103
|
+
/** Sidebar row label of the directory-dialog fallback entry. */
|
|
104
|
+
const WORKDIRS_PANEL_LABEL = 'workdirs'
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Position among the global panel rows. The shipped entries use 0 (Plugins)
|
|
108
|
+
* and 10 (Scheduled tasks); 30 keeps this one after them.
|
|
109
|
+
*/
|
|
110
|
+
const WORKDIRS_PANEL_ORDER = 30
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The settings page. `settings.section` occupants ship at -10 (account),
|
|
114
|
+
* 0 (general), 10 (models), 15 (plugins) and 20 (agent presets); 25 puts
|
|
115
|
+
* `workspace+` at the end of the product sections and before nothing else.
|
|
116
|
+
*/
|
|
117
|
+
const SETTINGS_SECTION_ID = 'workspaceplus'
|
|
118
|
+
const SETTINGS_SECTION_LABEL = 'workspace+'
|
|
119
|
+
const SETTINGS_SECTION_ORDER = 25
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Settings keys this page owns.
|
|
123
|
+
*
|
|
124
|
+
* `WORKSPACE_ENABLED_SETTING` is the per-workspace layer the settings page
|
|
125
|
+
* actually writes: one switch per workspace, keyed by the workspace key the
|
|
126
|
+
* host publishes in its snapshot. `DEFAULT_WORKSPACE_SETTING` is the default
|
|
127
|
+
* that layer falls back to for the default Workspace, and is read only.
|
|
128
|
+
*/
|
|
129
|
+
const DEFAULT_WORKSPACE_SETTING = 'defaultWorkspaceEnabled'
|
|
130
|
+
const WORKSPACE_ENABLED_SETTING = 'workspaceEnabled'
|
|
131
|
+
|
|
132
|
+
/** Host JSON endpoint, resolved against the page so it survives any mount path. */
|
|
133
|
+
const API_PATH = 'workspaceplus/api'
|
|
134
|
+
|
|
135
|
+
// -----------------------------------------------------------------------
|
|
136
|
+
// The 通知 (notice) sub-feature — client side
|
|
137
|
+
//
|
|
138
|
+
// The notice host half (`lib/notice/index.js`, the `dsh-workspace-plus/notice`
|
|
139
|
+
// loader row) owns the Windows transport and three HTTP routes. This half
|
|
140
|
+
// contributes the two browser-side pieces the dsh-notice plugin used to ship
|
|
141
|
+
// in its own client bundle:
|
|
142
|
+
//
|
|
143
|
+
// the 通知 settings page — same slot (`settings.section`), same id, same
|
|
144
|
+
// order, writing through the SAME routes as before (`/dsh-notice/api`).
|
|
145
|
+
// When the notice row is disabled the host answers 404, the page renders
|
|
146
|
+
// that state, and nothing else in this bundle is affected.
|
|
147
|
+
//
|
|
148
|
+
// the pending-open poll — the last centimetre of the click-to-open path:
|
|
149
|
+
// toast click → protocol → host route → pending intent → THIS half →
|
|
150
|
+
// ctx.uiWorkspace.openSession(). The poll is unconditionally cheap (one
|
|
151
|
+
// 404-tolerant GET every 4 s) and is what makes clicking a notification
|
|
152
|
+
// open the conversation; it stays on even when the settings page cannot
|
|
153
|
+
// load, because a notification may outlive a row toggle.
|
|
154
|
+
//
|
|
155
|
+
// Route paths and the settings-page identity are kept byte-identical to
|
|
156
|
+
// dsh-notice 0.2.1, so an in-place upgrade preserves the protocol scheme,
|
|
157
|
+
// the registry command and every stored override.
|
|
158
|
+
// -----------------------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
/** Settings section identity of the 通知 page (unchanged from dsh-notice). */
|
|
161
|
+
const NOTICE_SETTINGS_SECTION_ID = 'notice'
|
|
162
|
+
const NOTICE_SETTINGS_SECTION_LABEL = '通知'
|
|
163
|
+
const NOTICE_SETTINGS_SECTION_ORDER = 26
|
|
164
|
+
|
|
165
|
+
/** Host routes the notice half talks to; must match lib/notice/index.js. */
|
|
166
|
+
const NOTICE_API_PATH = '/dsh-notice/api'
|
|
167
|
+
const NOTICE_PENDING_PATH = '/dsh-notice/api/pending'
|
|
168
|
+
|
|
169
|
+
/** Backstop poll interval, and the retry interval after a failed poll. */
|
|
170
|
+
const NOTICE_POLL_INTERVAL_MS = 4000
|
|
171
|
+
const NOTICE_POLL_RETRY_MS = 2000
|
|
172
|
+
|
|
173
|
+
/** Stylesheet identity for the 通知 settings page (one tag, rewritten on reload). */
|
|
174
|
+
const NOTICE_STYLE_ID = 'dsh-workspace-plus/notice.css'
|
|
175
|
+
|
|
176
|
+
/** Prefix for every class the notice page injects, so it can never collide. */
|
|
177
|
+
const NOTICE_CSS = `
|
|
178
|
+
.dsh-notice-section { display: flex; flex-direction: column; gap: 14px; }
|
|
179
|
+
.dsh-notice-group { display: flex; flex-direction: column; gap: 10px; }
|
|
180
|
+
.dsh-notice-group-title { color: var(--dsw-alias-label-secondary); font-size: 12px; font-weight: 600; letter-spacing: .04em; }
|
|
181
|
+
.dsh-notice-row { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; }
|
|
182
|
+
.dsh-notice-text { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
|
|
183
|
+
.dsh-notice-title { color: var(--dsw-alias-label-primary); font-size: 14px; font-weight: 600; }
|
|
184
|
+
.dsh-notice-desc { color: var(--dsw-alias-label-secondary); font-size: 12.5px; line-height: 1.5; }
|
|
185
|
+
.dsh-notice-switch { position: relative; flex: none; width: 38px; height: 22px; margin-top: 2px; padding: 0; cursor: pointer; border: 1px solid var(--dsw-alias-border-l2); border-radius: 999px; background: var(--dsw-alias-bg-layer-2); transition: background .15s ease, border-color .15s ease; }
|
|
186
|
+
.dsh-notice-switch[aria-checked="true"] { background: var(--dsw-alias-brand-primary); border-color: var(--dsw-alias-brand-primary); }
|
|
187
|
+
.dsh-notice-switch:disabled { cursor: default; opacity: .5; }
|
|
188
|
+
.dsh-notice-knob { position: absolute; top: 2px; left: 2px; width: 16px; height: 16px; border-radius: 50%; background: var(--dsw-alias-bg-base); transition: transform .15s ease; }
|
|
189
|
+
.dsh-notice-switch[aria-checked="true"] .dsh-notice-knob { transform: translateX(16px); }
|
|
190
|
+
.dsh-notice-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
|
191
|
+
.dsh-notice-button { cursor: pointer; padding: 5px 12px; font-size: 13px; border-radius: 7px; border: 1px solid var(--dsw-alias-border-l2); background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-label-primary); }
|
|
192
|
+
.dsh-notice-button:hover:not(:disabled) { border-color: var(--dsw-alias-border-l1); }
|
|
193
|
+
.dsh-notice-button:disabled { cursor: default; opacity: .5; }
|
|
194
|
+
.dsh-notice-status { display: flex; flex-direction: column; gap: 4px; padding: 10px 12px; border-radius: 8px; background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-label-secondary); font-size: 12.5px; line-height: 1.6; }
|
|
195
|
+
.dsh-notice-mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; word-break: break-all; }
|
|
196
|
+
.dsh-notice-error { color: var(--dsw-alias-state-error-primary); }
|
|
197
|
+
.dsh-notice-ok { color: var(--dsw-alias-state-success-primary); }
|
|
198
|
+
`
|
|
199
|
+
|
|
200
|
+
/** Install the notice stylesheet, exactly one tag per document (rewritten on reload). */
|
|
201
|
+
function ensureNoticeStyles() {
|
|
202
|
+
if (typeof document === 'undefined') return
|
|
203
|
+
const existing = document.querySelector('style[data-plugin-css=' + JSON.stringify(NOTICE_STYLE_ID) + ']')
|
|
204
|
+
if (existing !== null) {
|
|
205
|
+
existing.textContent = NOTICE_CSS
|
|
206
|
+
return
|
|
207
|
+
}
|
|
208
|
+
const tag = document.createElement('style')
|
|
209
|
+
tag.dataset.plugin = 'dsh-workspace-plus'
|
|
210
|
+
tag.dataset.pluginCss = NOTICE_STYLE_ID
|
|
211
|
+
tag.textContent = NOTICE_CSS
|
|
212
|
+
document.head.appendChild(tag)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Fetch the notice host's snapshot.
|
|
217
|
+
*
|
|
218
|
+
* @param origin - the origin this page was served from, reported once so the
|
|
219
|
+
* host can learn its own listen port and register the protocol against it.
|
|
220
|
+
* @param signal - caller cancellation.
|
|
221
|
+
* @returns the parsed payload.
|
|
222
|
+
*/
|
|
223
|
+
async function fetchNoticeSnapshot(origin, signal) {
|
|
224
|
+
const query = origin === null ? '' : `?origin=${encodeURIComponent(origin)}`
|
|
225
|
+
const response = await fetch(`${NOTICE_API_PATH}${query}`, { cache: 'no-store', signal })
|
|
226
|
+
if (!response.ok) throw new Error(`host responded ${response.status}`)
|
|
227
|
+
return await response.json()
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Post one notice control operation.
|
|
232
|
+
*
|
|
233
|
+
* @param body - operation payload.
|
|
234
|
+
* @returns the parsed payload.
|
|
235
|
+
*/
|
|
236
|
+
async function postNoticeOperation(body) {
|
|
237
|
+
const response = await fetch(NOTICE_API_PATH, {
|
|
238
|
+
method: 'POST',
|
|
239
|
+
cache: 'no-store',
|
|
240
|
+
headers: { 'content-type': 'application/json' },
|
|
241
|
+
body: JSON.stringify(body),
|
|
242
|
+
})
|
|
243
|
+
if (!response.ok) throw new Error(`host responded ${response.status}`)
|
|
244
|
+
return await response.json()
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* One labelled switch row of the 通知 page.
|
|
249
|
+
*
|
|
250
|
+
* @param props - `{ title, description, checked, disabled, onChange }`.
|
|
251
|
+
* @returns the row element.
|
|
252
|
+
*/
|
|
253
|
+
function NoticeSwitchRow(props) {
|
|
254
|
+
return React.createElement(
|
|
255
|
+
'div',
|
|
256
|
+
{ className: 'dsh-notice-row' },
|
|
257
|
+
React.createElement(
|
|
258
|
+
'div',
|
|
259
|
+
{ className: 'dsh-notice-text' },
|
|
260
|
+
React.createElement('div', { className: 'dsh-notice-title' }, props.title),
|
|
261
|
+
props.description ? React.createElement('div', { className: 'dsh-notice-desc' }, props.description) : null,
|
|
262
|
+
),
|
|
263
|
+
React.createElement(
|
|
264
|
+
'button',
|
|
265
|
+
{
|
|
266
|
+
type: 'button',
|
|
267
|
+
role: 'switch',
|
|
268
|
+
'aria-checked': props.checked === true,
|
|
269
|
+
'aria-label': props.title,
|
|
270
|
+
disabled: props.disabled === true,
|
|
271
|
+
className: 'dsh-notice-switch',
|
|
272
|
+
onClick: () => props.onChange(props.checked !== true),
|
|
273
|
+
},
|
|
274
|
+
React.createElement('span', { className: 'dsh-notice-knob' }),
|
|
275
|
+
),
|
|
276
|
+
)
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* The 通知 settings page (the same page the dsh-notice plugin registered,
|
|
281
|
+
* now riding in this bundle). Every switch writes immediately; the host
|
|
282
|
+
* answers with a fresh snapshot, so the page never predicts what was
|
|
283
|
+
* accepted. A 404 — the notice row being disabled — renders as an
|
|
284
|
+
* explanation instead of an error, because that state is a choice, not a
|
|
285
|
+
* breakage.
|
|
286
|
+
*
|
|
287
|
+
* @returns the settings section element.
|
|
288
|
+
*/
|
|
289
|
+
function NoticeSettings() {
|
|
290
|
+
const [snapshot, setSnapshot] = React.useState(null)
|
|
291
|
+
const [status, setStatus] = React.useState({ kind: 'loading', message: '' })
|
|
292
|
+
const [busy, setBusy] = React.useState(false)
|
|
293
|
+
|
|
294
|
+
React.useEffect(() => {
|
|
295
|
+
let cancelled = false
|
|
296
|
+
const controller = typeof AbortController === 'undefined' ? undefined : new AbortController()
|
|
297
|
+
;(async () => {
|
|
298
|
+
try {
|
|
299
|
+
const payload = await fetchNoticeSnapshot(null, controller?.signal)
|
|
300
|
+
if (cancelled) return
|
|
301
|
+
setSnapshot(payload)
|
|
302
|
+
setStatus({ kind: 'ready', message: '' })
|
|
303
|
+
} catch (error) {
|
|
304
|
+
if (cancelled) return
|
|
305
|
+
setStatus({ kind: 'error', message: String((error && error.message) || error) })
|
|
306
|
+
}
|
|
307
|
+
})()
|
|
308
|
+
return () => {
|
|
309
|
+
cancelled = true
|
|
310
|
+
controller?.abort()
|
|
311
|
+
}
|
|
312
|
+
}, [])
|
|
313
|
+
|
|
314
|
+
const send = React.useCallback(async (body) => {
|
|
315
|
+
setBusy(true)
|
|
316
|
+
try {
|
|
317
|
+
const payload = await postNoticeOperation(body)
|
|
318
|
+
// A rejected operation answers with a message and no snapshot (an
|
|
319
|
+
// unknown op, a body that is not JSON). Adopting that as state would
|
|
320
|
+
// blank the page, so it only updates the status line.
|
|
321
|
+
if (payload !== null && typeof payload === 'object' && payload.settings !== undefined) {
|
|
322
|
+
setSnapshot(payload)
|
|
323
|
+
}
|
|
324
|
+
setStatus({
|
|
325
|
+
kind: payload !== null && payload.ok === true ? 'ready' : 'error',
|
|
326
|
+
message: String(payload?.message ?? ''),
|
|
327
|
+
})
|
|
328
|
+
} catch (error) {
|
|
329
|
+
setStatus({ kind: 'error', message: String((error && error.message) || error) })
|
|
330
|
+
} finally {
|
|
331
|
+
setBusy(false)
|
|
332
|
+
}
|
|
333
|
+
}, [])
|
|
334
|
+
|
|
335
|
+
if (snapshot === null) {
|
|
336
|
+
const message = status.kind === 'error' ? status.message : ''
|
|
337
|
+
// `host responded 404` is the notice ROW being switched off on the
|
|
338
|
+
// plugin page — a deliberate state, worded as one rather than as a
|
|
339
|
+
// failure. Any other message is a genuine transport error.
|
|
340
|
+
if (status.kind === 'error' && message.includes('404')) {
|
|
341
|
+
return React.createElement(
|
|
342
|
+
'div',
|
|
343
|
+
{ className: 'dsh-notice-section' },
|
|
344
|
+
React.createElement(
|
|
345
|
+
'div',
|
|
346
|
+
{ className: 'dsh-notice-desc' },
|
|
347
|
+
'通知子功能当前未启用:在侧栏「插件」页的 dsh-workspace-plus 卡片里打开 notice 这一行即可开启。',
|
|
348
|
+
),
|
|
349
|
+
)
|
|
350
|
+
}
|
|
351
|
+
return React.createElement(
|
|
352
|
+
'div',
|
|
353
|
+
{ className: 'dsh-notice-section' },
|
|
354
|
+
React.createElement(
|
|
355
|
+
'div',
|
|
356
|
+
{ className: 'dsh-notice-desc' },
|
|
357
|
+
status.kind === 'error' ? `读取设置失败:${message}` : '正在读取设置…',
|
|
358
|
+
),
|
|
359
|
+
)
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const settings = snapshot.settings ?? {}
|
|
363
|
+
const kinds = Array.isArray(snapshot.kinds) ? snapshot.kinds : []
|
|
364
|
+
const toast = snapshot.toast ?? {}
|
|
365
|
+
const protocol = snapshot.protocol ?? {}
|
|
366
|
+
const disabled = busy || settings.enabled !== true
|
|
367
|
+
|
|
368
|
+
const kindRows = kinds.map((kind) =>
|
|
369
|
+
React.createElement(NoticeSwitchRow, {
|
|
370
|
+
key: kind.id,
|
|
371
|
+
title: kind.label,
|
|
372
|
+
checked: kind.enabled === true,
|
|
373
|
+
disabled,
|
|
374
|
+
onChange: (next) => void send({ op: 'patch', patch: { kinds: { [kind.id]: next } } }),
|
|
375
|
+
}),
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
const transportLine = (() => {
|
|
379
|
+
if (snapshot.platformSupported !== true) return '当前平台不是 Windows,通知不会显示。'
|
|
380
|
+
// The daemon starts with the first notification, so "not running yet" is
|
|
381
|
+
// the ordinary state right after loading the plugin, not a fault.
|
|
382
|
+
if (toast.running !== true && toast.started !== true) {
|
|
383
|
+
return 'toast 守护进程尚未启动,将在第一条通知到来时自动启动。'
|
|
384
|
+
}
|
|
385
|
+
if (toast.running !== true) return 'toast 守护进程未运行,下一条通知会自动重新启动它。'
|
|
386
|
+
return `toast 守护进程运行中,以 ${
|
|
387
|
+
toast.appIdConfigured === true ? '指定' : '自动'
|
|
388
|
+
} AUMID 发言:${toast.appId ?? '(未知)'}`
|
|
389
|
+
})()
|
|
390
|
+
|
|
391
|
+
const protocolLine = (() => {
|
|
392
|
+
if (protocol.registered === true) return `点击通知可打开对应对话(已注册 ${protocol.scheme}:)。`
|
|
393
|
+
if (settings.registerProtocol === false) return '已关闭协议注册:点击通知只会把 Harness 窗口切到前台。'
|
|
394
|
+
return `尚未注册 ${protocol.scheme ?? 'dsh-notice'}:,点击通知只会把 Harness 窗口切到前台。`
|
|
395
|
+
})()
|
|
396
|
+
|
|
397
|
+
return React.createElement(
|
|
398
|
+
'div',
|
|
399
|
+
{ className: 'dsh-notice-section' },
|
|
400
|
+
React.createElement(
|
|
401
|
+
'div',
|
|
402
|
+
{ className: 'dsh-notice-group' },
|
|
403
|
+
React.createElement(NoticeSwitchRow, {
|
|
404
|
+
title: '启用桌面通知',
|
|
405
|
+
description: '对话需要你时,弹出 Windows 原生通知;点击通知可打开对应的对话。',
|
|
406
|
+
checked: settings.enabled === true,
|
|
407
|
+
disabled: busy,
|
|
408
|
+
onChange: (next) => void send({ op: 'patch', patch: { enabled: next } }),
|
|
409
|
+
}),
|
|
410
|
+
),
|
|
411
|
+
React.createElement(
|
|
412
|
+
'div',
|
|
413
|
+
{ className: 'dsh-notice-group' },
|
|
414
|
+
React.createElement('div', { className: 'dsh-notice-group-title' }, '通知类型'),
|
|
415
|
+
...kindRows,
|
|
416
|
+
),
|
|
417
|
+
React.createElement(
|
|
418
|
+
'div',
|
|
419
|
+
{ className: 'dsh-notice-group' },
|
|
420
|
+
React.createElement('div', { className: 'dsh-notice-group-title' }, '行为'),
|
|
421
|
+
React.createElement(NoticeSwitchRow, {
|
|
422
|
+
title: '只通知主对话',
|
|
423
|
+
description: '关闭后,子代理与团队成员的每一轮结束也会通知(通常非常密集)。',
|
|
424
|
+
checked: settings.scope === 'roots',
|
|
425
|
+
disabled,
|
|
426
|
+
onChange: (next) => void send({ op: 'patch', patch: { scope: next ? 'roots' : 'all' } }),
|
|
427
|
+
}),
|
|
428
|
+
React.createElement(NoticeSwitchRow, {
|
|
429
|
+
title: '通知声音',
|
|
430
|
+
description: '关闭后通知静音出现,仍然进入 Windows 通知中心。',
|
|
431
|
+
checked: settings.sound === true,
|
|
432
|
+
disabled,
|
|
433
|
+
onChange: (next) => void send({ op: 'patch', patch: { sound: next } }),
|
|
434
|
+
}),
|
|
435
|
+
React.createElement(NoticeSwitchRow, {
|
|
436
|
+
title: '点击时把 Harness 窗口切到前台',
|
|
437
|
+
description: '关闭后只切换对话,不改变窗口的前后顺序。',
|
|
438
|
+
checked: settings.focusOnClick === true,
|
|
439
|
+
disabled,
|
|
440
|
+
onChange: (next) => void send({ op: 'patch', patch: { focusOnClick: next } }),
|
|
441
|
+
}),
|
|
442
|
+
React.createElement(NoticeSwitchRow, {
|
|
443
|
+
title: '注册 dsh-notice: 协议',
|
|
444
|
+
description:
|
|
445
|
+
'在当前用户注册表下注册协议,让点击通知能定位到具体对话。关闭则退化为只切前台,不写注册表。',
|
|
446
|
+
checked: settings.registerProtocol === true,
|
|
447
|
+
disabled,
|
|
448
|
+
onChange: (next) => void send({ op: 'patch', patch: { registerProtocol: next } }),
|
|
449
|
+
}),
|
|
450
|
+
React.createElement(
|
|
451
|
+
'div',
|
|
452
|
+
{ className: 'dsh-notice-row' },
|
|
453
|
+
React.createElement(
|
|
454
|
+
'div',
|
|
455
|
+
{ className: 'dsh-notice-text' },
|
|
456
|
+
React.createElement('div', { className: 'dsh-notice-title' }, '通知显示时长'),
|
|
457
|
+
React.createElement(
|
|
458
|
+
'div',
|
|
459
|
+
{ className: 'dsh-notice-desc' },
|
|
460
|
+
settings.duration === 'long' ? '当前:长(约 25 秒)' : '当前:短(约 5 秒)',
|
|
461
|
+
),
|
|
462
|
+
),
|
|
463
|
+
React.createElement(
|
|
464
|
+
'button',
|
|
465
|
+
{
|
|
466
|
+
type: 'button',
|
|
467
|
+
className: 'dsh-notice-button',
|
|
468
|
+
disabled,
|
|
469
|
+
onClick: () => void send({ op: 'patch', patch: { duration: settings.duration === 'long' ? 'short' : 'long' } }),
|
|
470
|
+
},
|
|
471
|
+
settings.duration === 'long' ? '改为短' : '改为长',
|
|
472
|
+
),
|
|
473
|
+
),
|
|
474
|
+
),
|
|
475
|
+
React.createElement(
|
|
476
|
+
'div',
|
|
477
|
+
{ className: 'dsh-notice-actions' },
|
|
478
|
+
React.createElement(
|
|
479
|
+
'button',
|
|
480
|
+
{
|
|
481
|
+
type: 'button',
|
|
482
|
+
className: 'dsh-notice-button',
|
|
483
|
+
disabled: busy,
|
|
484
|
+
onClick: () => void send({ op: 'test' }),
|
|
485
|
+
},
|
|
486
|
+
'发送测试通知',
|
|
487
|
+
),
|
|
488
|
+
React.createElement(
|
|
489
|
+
'button',
|
|
490
|
+
{
|
|
491
|
+
type: 'button',
|
|
492
|
+
className: 'dsh-notice-button',
|
|
493
|
+
disabled: busy || Object.keys(snapshot.overrides ?? {}).length === 0,
|
|
494
|
+
onClick: () => void send({ op: 'clear' }),
|
|
495
|
+
},
|
|
496
|
+
'恢复默认',
|
|
497
|
+
),
|
|
498
|
+
),
|
|
499
|
+
React.createElement(
|
|
500
|
+
'div',
|
|
501
|
+
{ className: 'dsh-notice-status' },
|
|
502
|
+
React.createElement('div', null, transportLine),
|
|
503
|
+
React.createElement('div', null, protocolLine),
|
|
504
|
+
toast.lastError
|
|
505
|
+
? React.createElement('div', { className: 'dsh-notice-mono dsh-notice-error' }, String(toast.lastError))
|
|
506
|
+
: null,
|
|
507
|
+
status.message
|
|
508
|
+
? React.createElement(
|
|
509
|
+
'div',
|
|
510
|
+
{ className: status.kind === 'error' ? 'dsh-notice-error' : 'dsh-notice-ok' },
|
|
511
|
+
status.message,
|
|
512
|
+
)
|
|
513
|
+
: null,
|
|
514
|
+
),
|
|
515
|
+
)
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* Poll the notice host for a pending "open this conversation" intent.
|
|
520
|
+
*
|
|
521
|
+
* A click on a notification focuses the desktop window; the focus event
|
|
522
|
+
* triggers an immediate poll, so the conversation usually opens on the same
|
|
523
|
+
* frame the window appears. The timer is only the backstop for a click that
|
|
524
|
+
* arrives while the window already has focus. A 404 (notice row disabled)
|
|
525
|
+
* and a network failure are both silent: this poll must never become
|
|
526
|
+
* noticeable, and it must never take the workspace+ features down.
|
|
527
|
+
*
|
|
528
|
+
* @param ctx - the plugin's client context.
|
|
529
|
+
* @returns a disposer stopping every timer, listener and request in flight.
|
|
530
|
+
*/
|
|
531
|
+
function startNoticePendingPoll(ctx) {
|
|
532
|
+
let stopped = false
|
|
533
|
+
let timer = null
|
|
534
|
+
let inFlight = false
|
|
535
|
+
const controller = typeof AbortController === 'undefined' ? undefined : new AbortController()
|
|
536
|
+
|
|
537
|
+
const openSession = (sessionId) => {
|
|
538
|
+
if (typeof sessionId !== 'string' || sessionId === '') return
|
|
539
|
+
let workspace = null
|
|
540
|
+
try {
|
|
541
|
+
workspace = ctx.get('uiWorkspace')
|
|
542
|
+
} catch {
|
|
543
|
+
return
|
|
544
|
+
}
|
|
545
|
+
if (workspace === undefined || workspace === null || typeof workspace.openSession !== 'function') return
|
|
546
|
+
try {
|
|
547
|
+
workspace.openSession(sessionId)
|
|
548
|
+
} catch {
|
|
549
|
+
// An archived or unknown conversation cannot be selected; the click
|
|
550
|
+
// still brought the window forward, which is the useful half.
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
const schedule = (delay) => {
|
|
555
|
+
if (stopped) return
|
|
556
|
+
if (timer !== null) clearTimeout(timer)
|
|
557
|
+
timer = setTimeout(() => void tick(), delay)
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
const tick = async () => {
|
|
561
|
+
if (stopped || inFlight) return
|
|
562
|
+
inFlight = true
|
|
563
|
+
let delay = NOTICE_POLL_INTERVAL_MS
|
|
564
|
+
try {
|
|
565
|
+
const response = await fetch(`${NOTICE_PENDING_PATH}?_=${Date.now()}`, {
|
|
566
|
+
cache: 'no-store',
|
|
567
|
+
signal: controller?.signal,
|
|
568
|
+
})
|
|
569
|
+
if (response.ok) {
|
|
570
|
+
const payload = await response.json()
|
|
571
|
+
if (payload !== null && typeof payload === 'object' && typeof payload.sessionId === 'string') {
|
|
572
|
+
openSession(payload.sessionId)
|
|
573
|
+
}
|
|
574
|
+
} else {
|
|
575
|
+
delay = NOTICE_POLL_RETRY_MS
|
|
576
|
+
}
|
|
577
|
+
} catch {
|
|
578
|
+
delay = NOTICE_POLL_RETRY_MS
|
|
579
|
+
} finally {
|
|
580
|
+
inFlight = false
|
|
581
|
+
}
|
|
582
|
+
schedule(delay)
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// A click on a notification focuses the desktop window, and that focus
|
|
586
|
+
// event is what makes the conversation open immediately rather than up to
|
|
587
|
+
// one poll interval later.
|
|
588
|
+
const onFocus = () => schedule(0)
|
|
589
|
+
window.addEventListener('focus', onFocus)
|
|
590
|
+
document.addEventListener('visibilitychange', onFocus)
|
|
591
|
+
|
|
592
|
+
// Report the origin once, so the host learns the port it is listening on
|
|
593
|
+
// and can point the protocol registration at it. The settings page loads
|
|
594
|
+
// lazily, so this one early GET is also the only thing that wakes the
|
|
595
|
+
// host's protocol sync when the user never opens 设置 → 通知.
|
|
596
|
+
void fetchNoticeSnapshot(window.location.origin, controller?.signal).catch(() => {})
|
|
597
|
+
void tick()
|
|
598
|
+
|
|
599
|
+
return () => {
|
|
600
|
+
stopped = true
|
|
601
|
+
if (timer !== null) clearTimeout(timer)
|
|
602
|
+
window.removeEventListener('focus', onFocus)
|
|
603
|
+
document.removeEventListener('visibilitychange', onFocus)
|
|
604
|
+
controller?.abort()
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/**
|
|
609
|
+
* Run one notice registration, reporting a failure instead of throwing it.
|
|
610
|
+
*
|
|
611
|
+
* The shell treats a non-ACTIVE client entry as a fatal boot error, so an
|
|
612
|
+
* exception here would take the whole application down with it. The failure
|
|
613
|
+
* is still surfaced — through the shell's logger — but the fiber stays
|
|
614
|
+
* active and the workspace+ features keep working.
|
|
615
|
+
*
|
|
616
|
+
* @param ctx - the plugin's client context.
|
|
617
|
+
* @param subject - what was being registered, named in the message.
|
|
618
|
+
* @param setup - the registration body.
|
|
619
|
+
* @returns nothing.
|
|
620
|
+
*/
|
|
621
|
+
function registerNotice(ctx, subject, setup) {
|
|
622
|
+
try {
|
|
623
|
+
setup()
|
|
624
|
+
} catch (error) {
|
|
625
|
+
const message = String((error && error.message) || error)
|
|
626
|
+
try {
|
|
627
|
+
ctx.logger?.warn?.(`[dsh-workspace-plus/notice] ${subject}注册失败,已跳过:${message}`)
|
|
628
|
+
} catch {
|
|
629
|
+
// A logger that cannot log must not turn a skipped registration back
|
|
630
|
+
// into the boot failure this boundary exists to prevent.
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/** Identity of the injected stylesheet, used to keep exactly one copy. */
|
|
636
|
+
const STYLE_ID = 'dsh-workspace-plus/panel.css'
|
|
637
|
+
|
|
638
|
+
/**
|
|
639
|
+
* Row anchors. The shell emits `data-slot="<slot key>"` on every slot outlet
|
|
640
|
+
* and `data-row-key="workspace:<workspaceId>"` on a workspace group row, and
|
|
641
|
+
* a row's last element child is its action cluster.
|
|
642
|
+
*/
|
|
643
|
+
const SIDEBAR_SLOT_SELECTOR = '[data-slot="sidebar.workspaces"]'
|
|
644
|
+
const WORKSPACE_ROW_SELECTOR = '[data-row-key^="workspace:"]'
|
|
645
|
+
const ROW_KEY_PREFIX = 'workspace:'
|
|
646
|
+
|
|
647
|
+
/** Class of the button this half appends to a workspace row. */
|
|
648
|
+
const ROW_BUTTON_CLASS = 'dsh-workspace-plus-rowbtn'
|
|
649
|
+
|
|
650
|
+
/**
|
|
651
|
+
* Class marking that same button on a workspace the settings page switched
|
|
652
|
+
* off. The button stays — the dialog is the only place a workspace's labels
|
|
653
|
+
* can be read — but it draws dimmed and says why in its tooltip.
|
|
654
|
+
*/
|
|
655
|
+
const ROW_BUTTON_OFF_CLASS = 'dsh-workspace-plus-rowbtn-off'
|
|
656
|
+
|
|
657
|
+
/** Label of the injected row button. */
|
|
658
|
+
const ROW_BUTTON_LABEL = '工作区目录设置'
|
|
659
|
+
|
|
660
|
+
/** Label of that button where workspace+ is switched off for the workspace. */
|
|
661
|
+
const ROW_BUTTON_LABEL_OFF = '工作区目录设置(这个工作区已在 workspace+ 里停用)'
|
|
662
|
+
|
|
663
|
+
/**
|
|
664
|
+
* The shell's own folder-open artwork, copied verbatim from
|
|
665
|
+
* `@deepseek-ai/dsh-client-ui-primitives` (`IconFolderOpenArtwork`). It is
|
|
666
|
+
* static path data rather than a runtime import, so the injected button is
|
|
667
|
+
* pixel-identical to the row icons the shell draws beside it without adding a
|
|
668
|
+
* dependency on an icon export name.
|
|
669
|
+
*/
|
|
670
|
+
const FOLDER_ARTWORK = [
|
|
671
|
+
{
|
|
672
|
+
d: 'M2.55912 7.93683C2.67584 7.49906 3.0723 7.19446 3.52536 7.19446H13.6491C14.3061 7.19446 14.7846 7.81725 14.6153 8.45209L13.4411 12.856C13.3244 13.2938 12.9279 13.5984 12.4748 13.5984H2.35113C1.69411 13.5984 1.21562 12.9756 1.38489 12.3407L2.55912 7.93683Z',
|
|
673
|
+
opacity: '0.16',
|
|
674
|
+
},
|
|
675
|
+
{
|
|
676
|
+
d: 'M13.6491 6.69446C14.6346 6.69453 15.3522 7.62895 15.0983 8.58118L13.9245 12.9845C13.7494 13.6412 13.1539 14.0988 12.4743 14.0988H2.35126C1.36574 14.0988 0.648153 13.1643 0.902044 12.212L2.07587 7.80774C2.25102 7.15128 2.84567 6.69455 3.52509 6.69446H13.6491ZM3.52509 7.69446C3.29865 7.69455 3.10004 7.84674 3.04169 8.06555L1.86786 12.4698C1.78345 12.7872 2.02285 13.0988 2.35126 13.0988H12.4743C12.7007 13.0988 12.8992 12.9463 12.9577 12.7277L14.1325 8.32336C14.2171 8.00598 13.9776 7.69453 13.6491 7.69446H3.52509Z',
|
|
677
|
+
},
|
|
678
|
+
{
|
|
679
|
+
d: 'M4.7666 1.90137C5.13227 1.90144 5.48571 2.03525 5.75977 2.27734L7.27246 3.61328C7.36379 3.69382 7.48174 3.73828 7.60352 3.73828H12.3994C13.2276 3.73841 13.8993 4.41005 13.8994 5.23828V6.7168C13.8183 6.70327 13.735 6.69436 13.6494 6.69434H12.8994V5.23828C12.8993 4.96233 12.6754 4.73841 12.3994 4.73828H7.60352C7.23781 4.73828 6.88446 4.60438 6.61035 4.3623L5.09766 3.02637C5.00636 2.94576 4.88838 2.90144 4.7666 2.90137H2.0498C1.77366 2.90137 1.5498 3.12523 1.5498 3.40137V9.78223L0.902344 12.2119C0.648452 13.1642 1.36604 14.0986 2.35156 14.0986H2.0498C1.2214 14.0986 0.549838 13.427 0.549805 12.5986V3.40137C0.549805 2.57294 1.22138 1.90137 2.0498 1.90137H4.7666Z',
|
|
680
|
+
},
|
|
681
|
+
]
|
|
682
|
+
|
|
683
|
+
/** Glyph of the injected row button, built from the official artwork above. */
|
|
684
|
+
const ROW_BUTTON_GLYPH =
|
|
685
|
+
'<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" ' +
|
|
686
|
+
'aria-hidden="true" focusable="false">' +
|
|
687
|
+
FOLDER_ARTWORK.map(
|
|
688
|
+
(path) => `<path d="${path.d}" fill="currentColor"${path.opacity === undefined ? '' : ` opacity="${path.opacity}"`}/>`,
|
|
689
|
+
).join('') +
|
|
690
|
+
'</svg>'
|
|
691
|
+
|
|
692
|
+
/**
|
|
693
|
+
* The shell's shield contour, copied verbatim from
|
|
694
|
+
* `@deepseek-ai/dsh-client-ui-primitives` (`SHIELD_OUTLINE_PATH`, the artwork
|
|
695
|
+
* behind `IconShieldOutlineRegular`). Same reasoning as the folder artwork
|
|
696
|
+
* above: the badge is the product's own warning shape without depending on an
|
|
697
|
+
* export name. It strokes with `currentColor`, so the row picks the color (the
|
|
698
|
+
* theme's warn token) rather than the artwork.
|
|
699
|
+
*/
|
|
700
|
+
const SHIELD_PATH =
|
|
701
|
+
'M6.80132 2.14853C7.70663 1.80917 8.70422 1.80919 9.60952 2.14859L14.1296 3.84317V7.11961C14.1296 11.6089 ' +
|
|
702
|
+
'10.7615 13.5975 8.20543 14.5779C5.64931 13.5975 2.28052 11.6089 2.28052 7.11961V3.84317L6.80132 2.14853Z'
|
|
703
|
+
|
|
704
|
+
/**
|
|
705
|
+
* Two more official glyphs, same provenance as the folder artwork above and
|
|
706
|
+
* for the same reason: the dialog's row actions and its add row should read as
|
|
707
|
+
* the shell's own icons without depending on an export name.
|
|
708
|
+
*
|
|
709
|
+
* CLOSE_ARTWORK `IconCloseOutline16` (`ic_ds_close_outline_16`) — the row's
|
|
710
|
+
* remove control, the same cross the shell puts in a dialog's
|
|
711
|
+
* top-right corner.
|
|
712
|
+
* PLUS_ARTWORK `IconPlusOutline16` (`ic_ds_plus_outline_16`) — the add row.
|
|
713
|
+
* It is deliberately not a folder: the row's folder glyphs
|
|
714
|
+
* belong to registered directories, and a second folder would
|
|
715
|
+
* make the add row read as one of them.
|
|
716
|
+
*/
|
|
717
|
+
const CLOSE_ARTWORK = [
|
|
718
|
+
'M14.1168 13.197L13.197 14.1167L1.8833 2.80303L2.80309 1.88324L14.1168 13.197Z',
|
|
719
|
+
'M13.197 1.88326L14.1168 2.80305L2.80309 14.1168L1.8833 13.197L13.197 1.88326Z',
|
|
720
|
+
]
|
|
721
|
+
const PLUS_ARTWORK = ['M8.64453 1.5V7.34961H14.5V8.65039H8.64453V14.5H7.34473V8.65039H1.5V7.34961H7.34473V1.5H8.64453Z']
|
|
722
|
+
|
|
723
|
+
/**
|
|
724
|
+
* How long the fallback entry waits before deciding that no workspace-row
|
|
725
|
+
* button can exist, so a healthy shell never flashes it.
|
|
726
|
+
*/
|
|
727
|
+
const FALLBACK_SETTLE_MS = 1200
|
|
728
|
+
|
|
729
|
+
/**
|
|
730
|
+
* Panel styles. Everything is namespaced under `dsh-workspace-plus-` and
|
|
731
|
+
* colored through the shell's theme tokens, so the surfaces follow the active
|
|
732
|
+
* light/dark theme instead of hard-coding colors. Fallbacks after each token
|
|
733
|
+
* keep them readable on a shell that predates a given token.
|
|
734
|
+
*/
|
|
735
|
+
const CSS = `
|
|
736
|
+
.dsh-workspace-plus-panel{box-sizing:border-box;display:flex;align-items:center;justify-content:center;flex:1 1 auto;height:100%;min-height:0;padding:32px 24px;overflow-y:auto;background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-primary);font-size:14px;line-height:1.6}
|
|
737
|
+
.dsh-workspace-plus-card{box-sizing:border-box;width:min(520px,100%);display:flex;flex-direction:column;align-items:center;gap:14px;padding:36px 32px 30px;border:1px solid var(--dsw-alias-border-l1);border-radius:var(--dsw-radius-lg,14px);background:var(--dsw-alias-bg-layer-1);text-align:center;animation:dsh-workspace-plus-in .18s var(--ds-ease-in-out,ease-out) both}
|
|
738
|
+
.dsh-workspace-plus-badge{display:inline-flex;align-items:center;justify-content:center;width:56px;height:56px;border-radius:50%;color:var(--dsw-alias-brand-primary);background:var(--dsw-alias-bg-layer-2)}
|
|
739
|
+
.dsh-workspace-plus-glyph{display:block}
|
|
740
|
+
.dsh-workspace-plus-title{margin:0;font-size:24px;font-weight:600;letter-spacing:.2px}
|
|
741
|
+
.dsh-workspace-plus-subtitle{margin:0;max-width:42ch;color:var(--dsw-alias-label-secondary)}
|
|
742
|
+
.dsh-workspace-plus-actions{display:flex;align-items:center;gap:12px;margin-top:6px}
|
|
743
|
+
.dsh-workspace-plus-settings{flex-direction:column;width:100%;display:flex}
|
|
744
|
+
.dsh-workspace-plus-settings-row{border-bottom:.5px solid var(--dsw-alias-border-l2);justify-content:space-between;align-items:center;gap:24px;padding:16px 0;display:flex}
|
|
745
|
+
.dsh-workspace-plus-settings-text{min-width:0}
|
|
746
|
+
.dsh-workspace-plus-settings-title{font-size:14px;line-height:20px}
|
|
747
|
+
.dsh-workspace-plus-settings-tag{margin-left:8px;padding:0 6px;border-radius:999px;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-secondary);font-size:11px;line-height:16px;vertical-align:1px}
|
|
748
|
+
.dsh-workspace-plus-settings-description{color:var(--dsw-alias-label-secondary);margin-top:4px;font-size:12px;line-height:18px}
|
|
749
|
+
.dsh-workspace-plus-settings-error{color:var(--dsw-alias-state-error-primary);margin-top:8px;font-size:12px;line-height:18px}
|
|
750
|
+
.dsh-workspace-plus-settings-ok{color:var(--dsw-alias-state-success-primary);margin-top:8px;font-size:12px;line-height:18px}
|
|
751
|
+
.dsh-workspace-plus-rowbtn{display:inline-flex;align-items:center;justify-content:center;width:16px;height:16px;padding:0;border:none;flex:none;cursor:pointer;border-radius:var(--dsw-radius-xs,4px);background:0 0;color:var(--dsw-alias-label-tertiary)}
|
|
752
|
+
.dsh-workspace-plus-rowbtn:hover{color:var(--dsw-alias-label-primary)}
|
|
753
|
+
.dsh-workspace-plus-rowbtn-off{opacity:.55}
|
|
754
|
+
.dsh-workspace-plus-rowbtn-off:hover{opacity:1;color:var(--dsw-alias-label-primary)}
|
|
755
|
+
.dsh-workspace-plus-rowbtn:focus-visible{outline:var(--dsw-focus-ring-width,2px) solid var(--dsw-focus-ring-color,var(--dsw-alias-brand-primary));outline-offset:-2px}
|
|
756
|
+
.dsh-workspace-plus-dialog{box-sizing:border-box;display:flex;flex-direction:column;gap:16px;color:var(--dsw-alias-label-primary);font-size:14px;line-height:22px}
|
|
757
|
+
.dsh-workspace-plus-dialog-card.dsh-workspace-plus-dialog-card{width:min(640px,100%)}
|
|
758
|
+
.dsh-workspace-plus-field{box-sizing:border-box;display:flex;align-items:center;gap:10px;min-height:44px;padding:0 12px;border:1px solid var(--dsw-alias-border-l2,var(--dsw-alias-border-l1));border-radius:var(--dsw-radius-md,12px);background:var(--dsw-alias-bg-layer-1)}
|
|
759
|
+
.dsh-workspace-plus-field-glyph{display:inline-flex;flex:none;align-items:center;color:var(--dsw-alias-label-tertiary)}
|
|
760
|
+
.dsh-workspace-plus-field-title{flex:none;min-width:0;max-width:40%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:500}
|
|
761
|
+
.dsh-workspace-plus-field-root{min-width:0;flex:1 1 auto;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;text-align:right;font-family:var(--dsw-font-mono,ui-monospace,SFMono-Regular,Menlo,monospace);font-size:13px;color:var(--dsw-alias-label-tertiary)}
|
|
762
|
+
.dsh-workspace-plus-section{margin:0 2px -6px;color:var(--dsw-alias-label-secondary)}
|
|
763
|
+
.dsh-workspace-plus-list{box-sizing:border-box;width:100%;max-height:34vh;overflow-y:auto;border:1px solid var(--dsw-alias-border-l2,var(--dsw-alias-border-l1));border-radius:var(--dsw-radius-md,12px);background:var(--dsw-alias-bg-layer-1)}
|
|
764
|
+
.dsh-workspace-plus-row,.dsh-workspace-plus-field-row{box-sizing:border-box;display:flex;align-items:center;gap:10px;min-height:44px;padding:0 12px;border-top:1px solid var(--dsw-alias-border-l1)}
|
|
765
|
+
.dsh-workspace-plus-row:first-child,.dsh-workspace-plus-field-row:first-child{border-top:none}
|
|
766
|
+
.dsh-workspace-plus-row-tag{flex:none;max-width:38%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:500}
|
|
767
|
+
.dsh-workspace-plus-row-path{min-width:0;flex:1 1 auto;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:var(--dsw-font-mono,ui-monospace,SFMono-Regular,Menlo,monospace);font-size:13px;color:var(--dsw-alias-label-tertiary)}
|
|
768
|
+
.dsh-workspace-plus-row-note{flex:0 1 auto;max-width:16ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:13px;color:var(--dsw-alias-label-secondary)}
|
|
769
|
+
.dsh-workspace-plus-row-outside{flex:none;display:inline-flex;align-items:center;color:var(--dsw-alias-state-warn-primary)}
|
|
770
|
+
.dsh-workspace-plus-row-outside:focus-visible{outline:var(--dsw-focus-ring-width,2px) solid var(--dsw-focus-ring-color,var(--dsw-alias-brand-primary));outline-offset:1px;border-radius:var(--dsw-radius-xs,4px)}
|
|
771
|
+
.dsh-workspace-plus-remove{flex:none;display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;padding:0;border:none;border-radius:var(--dsw-radius-sm,8px);background:0 0;color:var(--dsw-alias-label-tertiary);cursor:pointer}
|
|
772
|
+
.dsh-workspace-plus-remove:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
|
|
773
|
+
.dsh-workspace-plus-remove:disabled{cursor:not-allowed;opacity:.4}
|
|
774
|
+
.dsh-workspace-plus-remove:focus-visible{outline:var(--dsw-focus-ring-width,2px) solid var(--dsw-focus-ring-color,var(--dsw-alias-brand-primary));outline-offset:-2px}
|
|
775
|
+
.dsh-workspace-plus-add{width:100%;font:inherit;text-align:left;color:var(--dsw-alias-label-secondary);background:0 0;border-right:none;border-bottom:none;border-left:none;cursor:pointer}
|
|
776
|
+
.dsh-workspace-plus-add:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
|
|
777
|
+
.dsh-workspace-plus-add:disabled{cursor:not-allowed;color:var(--dsw-alias-label-tertiary)}
|
|
778
|
+
.dsh-workspace-plus-add:focus-visible{outline:var(--dsw-focus-ring-width,2px) solid var(--dsw-focus-ring-color,var(--dsw-alias-brand-primary));outline-offset:-2px}
|
|
779
|
+
.dsh-workspace-plus-add-text{min-width:0;flex:1 1 auto;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
780
|
+
.dsh-workspace-plus-add-path{font-family:var(--dsw-font-mono,ui-monospace,SFMono-Regular,Menlo,monospace);font-size:13px}
|
|
781
|
+
.dsh-workspace-plus-select{min-width:0;flex:1 1 auto;font:inherit;color:var(--dsw-alias-label-primary);background:0 0;border:none;padding:0}
|
|
782
|
+
/* The light theme resolves bg-layer-1/2/3 to the same white, so a borderless
|
|
783
|
+
input filled with a layer token disappears inside its group there. The
|
|
784
|
+
interactive fills are the one neutral surface pair that reads in both themes:
|
|
785
|
+
6%/10% of the label colour in light, 8%/14% of it in dark. */
|
|
786
|
+
.dsh-workspace-plus-input{box-sizing:border-box;min-width:0;height:34px;padding:0 10px;font:inherit;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-interactive-bg-hover);border:none;border-radius:var(--dsw-radius-sm,8px)}
|
|
787
|
+
.dsh-workspace-plus-input:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-active)}
|
|
788
|
+
.dsh-workspace-plus-input:disabled{background:0 0;color:var(--dsw-alias-label-tertiary)}
|
|
789
|
+
.dsh-workspace-plus-select:focus-visible,.dsh-workspace-plus-input:focus-visible{outline:var(--dsw-focus-ring-width,2px) solid var(--dsw-focus-ring-color,var(--dsw-alias-brand-primary));outline-offset:-1px}
|
|
790
|
+
.dsh-workspace-plus-input-label{flex:0 1 180px}
|
|
791
|
+
.dsh-workspace-plus-input-note{flex:1 1 auto}
|
|
792
|
+
.dsh-workspace-plus-submit{flex:none}
|
|
793
|
+
.dsh-workspace-plus-empty{color:var(--dsw-alias-label-secondary)}
|
|
794
|
+
.dsh-workspace-plus-notice{box-sizing:border-box;display:flex;flex-direction:column;gap:4px;padding:10px 12px;border:1px solid var(--dsw-alias-border-l2,var(--dsw-alias-border-l1));border-radius:var(--dsw-radius-md,12px);background:var(--dsw-alias-bg-layer-1)}
|
|
795
|
+
.dsh-workspace-plus-notice-error{background:color-mix(in srgb,var(--dsw-alias-state-error-primary) 8%,var(--dsw-alias-bg-layer-1))}
|
|
796
|
+
.dsh-workspace-plus-notice-warn{background:color-mix(in srgb,var(--dsw-alias-state-warn-primary) 10%,var(--dsw-alias-bg-layer-1))}
|
|
797
|
+
.dsh-workspace-plus-status{font-size:14px;line-height:22px}
|
|
798
|
+
.dsh-workspace-plus-footer{display:flex;align-items:center;justify-content:space-between;gap:12px;width:100%}
|
|
799
|
+
.dsh-workspace-plus-note{color:var(--dsw-alias-label-secondary)}
|
|
800
|
+
.dsh-workspace-plus-off{color:var(--dsw-alias-state-warn-primary)}
|
|
801
|
+
.dsh-workspace-plus-error{color:var(--dsw-alias-state-error-primary)}
|
|
802
|
+
.dsh-workspace-plus-ok{color:var(--dsw-alias-state-success-primary)}
|
|
803
|
+
.dsh-workspace-plus-mono{font-family:var(--dsw-font-mono,ui-monospace,SFMono-Regular,Menlo,monospace);word-break:break-all}
|
|
804
|
+
@keyframes dsh-workspace-plus-in{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}}
|
|
805
|
+
@media (prefers-reduced-motion:reduce){.dsh-workspace-plus-card{animation:none}}
|
|
806
|
+
`
|
|
807
|
+
|
|
808
|
+
/**
|
|
809
|
+
* Install the panel stylesheet, exactly one tag per document. The factory body
|
|
810
|
+
* runs at materialization, and a client-module reload re-runs it inside the
|
|
811
|
+
* page that is already open: rewriting the existing tag (rather than skipping
|
|
812
|
+
* when one is present) keeps the count at one AND keeps it current, so a
|
|
813
|
+
* stylesheet edit never pairs the new markup with the previous CSS. The
|
|
814
|
+
* `data-plugin-css` marker is what finds that tag again.
|
|
815
|
+
*/
|
|
816
|
+
function ensureStyles() {
|
|
817
|
+
if (typeof document === 'undefined') return
|
|
818
|
+
const existing = document.querySelector('style[data-plugin-css=' + JSON.stringify(STYLE_ID) + ']')
|
|
819
|
+
if (existing !== null) {
|
|
820
|
+
existing.textContent = CSS
|
|
821
|
+
return
|
|
822
|
+
}
|
|
823
|
+
const tag = document.createElement('style')
|
|
824
|
+
tag.dataset.plugin = 'dsh-workspace-plus'
|
|
825
|
+
tag.dataset.pluginCss = STYLE_ID
|
|
826
|
+
tag.textContent = CSS
|
|
827
|
+
document.head.appendChild(tag)
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
ensureStyles()
|
|
831
|
+
|
|
832
|
+
// The notice page's stylesheet follows the same one-tag rule; calling it in
|
|
833
|
+
// the factory body (like the panel sheet above) means the 通知 page needs no
|
|
834
|
+
// `<style>` element of its own and reloads stay duplicate-free.
|
|
835
|
+
ensureNoticeStyles()
|
|
836
|
+
|
|
837
|
+
// -----------------------------------------------------------------------
|
|
838
|
+
// The workspace-directory feature
|
|
839
|
+
// -----------------------------------------------------------------------
|
|
840
|
+
|
|
841
|
+
/**
|
|
842
|
+
* One glyph built from inlined official artwork. Entries are either a bare
|
|
843
|
+
* path string or `{ d, opacity }`, so the folder's translucent underlay and
|
|
844
|
+
* the flat single-path icons share one renderer.
|
|
845
|
+
*
|
|
846
|
+
* @param props - the artwork to draw, its size, an optional class, and the
|
|
847
|
+
* click handler when the glyph is itself a control.
|
|
848
|
+
* @returns the icon element.
|
|
849
|
+
*/
|
|
850
|
+
function ArtworkGlyph({ artwork, size = 16, className = 'dsh-workspace-plus-glyph', onClick }) {
|
|
851
|
+
return React.createElement(
|
|
852
|
+
'svg',
|
|
853
|
+
{
|
|
854
|
+
className,
|
|
855
|
+
width: size,
|
|
856
|
+
height: size,
|
|
857
|
+
viewBox: '0 0 16 16',
|
|
858
|
+
fill: 'none',
|
|
859
|
+
xmlns: 'http://www.w3.org/2000/svg',
|
|
860
|
+
'aria-hidden': 'true',
|
|
861
|
+
focusable: 'false',
|
|
862
|
+
...(onClick === undefined ? {} : { onClick }),
|
|
863
|
+
},
|
|
864
|
+
artwork.map((entry, index) => {
|
|
865
|
+
const path = typeof entry === 'string' ? { d: entry } : entry
|
|
866
|
+
return React.createElement('path', {
|
|
867
|
+
key: index,
|
|
868
|
+
d: path.d,
|
|
869
|
+
fill: 'currentColor',
|
|
870
|
+
...(path.opacity === undefined ? {} : { opacity: path.opacity }),
|
|
871
|
+
})
|
|
872
|
+
}),
|
|
873
|
+
)
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
/**
|
|
877
|
+
* The folder-open artwork as an element, so the fallback sidebar entry, the
|
|
878
|
+
* hint panel, and the dialog's directory rows all draw the same icon the
|
|
879
|
+
* shell uses on its workspace row.
|
|
880
|
+
*
|
|
881
|
+
* @param props - optional size, free-form class, and the click handler when
|
|
882
|
+
* the glyph is itself a control.
|
|
883
|
+
* @returns the icon element.
|
|
884
|
+
*/
|
|
885
|
+
function FolderGlyph({ size = 16, className, onClick }) {
|
|
886
|
+
return React.createElement(ArtworkGlyph, { artwork: FOLDER_ARTWORK, size, className, onClick })
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
/** The row's remove control: the shell's cross. */
|
|
890
|
+
function CloseGlyph({ size = 16 }) {
|
|
891
|
+
return React.createElement(ArtworkGlyph, { artwork: CLOSE_ARTWORK, size })
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
/** The add row's leading glyph: the shell's plus. */
|
|
895
|
+
function PlusGlyph({ size = 16 }) {
|
|
896
|
+
return React.createElement(ArtworkGlyph, { artwork: PLUS_ARTWORK, size })
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
|
|
900
|
+
/**
|
|
901
|
+
* The directory glyph, shared by the fallback sidebar entry and the hint
|
|
902
|
+
* panel. It draws the same official artwork as the injected row button, so
|
|
903
|
+
* every entry point to this feature looks like the shell's own icons.
|
|
904
|
+
*
|
|
905
|
+
* @param props - sidebar icon share, plus the optional click handler.
|
|
906
|
+
* @returns the icon element.
|
|
907
|
+
*/
|
|
908
|
+
function WorkdirsPanelIcon({ size = 16, onClick }) {
|
|
909
|
+
return React.createElement(FolderGlyph, { size, onClick })
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
/**
|
|
913
|
+
* The fallback entry's glyph. Its click is stopped before the shell's panel
|
|
914
|
+
* button sees it, so the sidebar row opens the dialog instead of switching
|
|
915
|
+
* `main`; the hint panel behind that key is what a user sees if this ever
|
|
916
|
+
* stops working.
|
|
917
|
+
*
|
|
918
|
+
* @param props - sidebar icon share.
|
|
919
|
+
* @returns the icon element.
|
|
920
|
+
*/
|
|
921
|
+
function WorkdirsEntryIcon(props) {
|
|
922
|
+
return WorkdirsPanelIcon({
|
|
923
|
+
...props,
|
|
924
|
+
onClick: (event) => {
|
|
925
|
+
event.preventDefault()
|
|
926
|
+
event.stopPropagation()
|
|
927
|
+
openWorkdirsDialog(null)
|
|
928
|
+
},
|
|
929
|
+
})
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
/**
|
|
933
|
+
* Bundle-local open state for the dialog. Its two triggers live in different
|
|
934
|
+
* React trees from the dialog (one is not React at all), so they meet on this
|
|
935
|
+
* tiny external store rather than on a shared parent.
|
|
936
|
+
*/
|
|
937
|
+
let dialogSnapshot = { open: false, workspaceId: null }
|
|
938
|
+
const dialogListeners = new Set()
|
|
939
|
+
|
|
940
|
+
/** @returns the current dialog snapshot (referentially stable until it changes). */
|
|
941
|
+
function readDialog() {
|
|
942
|
+
return dialogSnapshot
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
/**
|
|
946
|
+
* Subscribe to dialog open-state changes.
|
|
947
|
+
*
|
|
948
|
+
* @param listener - called after each change.
|
|
949
|
+
* @returns the unsubscribe function.
|
|
950
|
+
*/
|
|
951
|
+
function subscribeDialog(listener) {
|
|
952
|
+
dialogListeners.add(listener)
|
|
953
|
+
return () => {
|
|
954
|
+
dialogListeners.delete(listener)
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
/**
|
|
959
|
+
* Replace the dialog snapshot and notify subscribers.
|
|
960
|
+
*
|
|
961
|
+
* @param next - the next `{ open, workspaceId }`.
|
|
962
|
+
*/
|
|
963
|
+
function writeDialog(next) {
|
|
964
|
+
const previous = dialogSnapshot
|
|
965
|
+
if (previous.open === next.open && previous.workspaceId === next.workspaceId) return
|
|
966
|
+
dialogSnapshot = next
|
|
967
|
+
for (const listener of [...dialogListeners]) listener()
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
/**
|
|
971
|
+
* Open the dialog, optionally focused on one workspace.
|
|
972
|
+
*
|
|
973
|
+
* @param workspaceId - the workspace to preselect, or null to keep the current one.
|
|
974
|
+
*/
|
|
975
|
+
function openWorkdirsDialog(workspaceId = null) {
|
|
976
|
+
writeDialog({ open: true, workspaceId })
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
/** Close the dialog. */
|
|
980
|
+
function closeWorkdirsDialog() {
|
|
981
|
+
writeDialog({ open: false, workspaceId: null })
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
/** The dialog's state before the first host response arrives. */
|
|
985
|
+
const WORKDIRS_IDLE = {
|
|
986
|
+
status: 'loading',
|
|
987
|
+
message: '',
|
|
988
|
+
ok: null,
|
|
989
|
+
registryAvailable: true,
|
|
990
|
+
workspaces: [],
|
|
991
|
+
selected: null,
|
|
992
|
+
locked: false,
|
|
993
|
+
busy: false,
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
/**
|
|
997
|
+
* Resolve the host endpoint against the page, so the same code works under a
|
|
998
|
+
* root mount, under a nested mount, and under the desktop shell's
|
|
999
|
+
* `dsh-app://app/` origin (which forwards every non-asset path to the Host).
|
|
1000
|
+
*
|
|
1001
|
+
* @returns the resolved endpoint URL.
|
|
1002
|
+
*/
|
|
1003
|
+
function apiUrl() {
|
|
1004
|
+
if (typeof document === 'undefined') return API_PATH
|
|
1005
|
+
try {
|
|
1006
|
+
return new URL(API_PATH, document.baseURI).toString()
|
|
1007
|
+
} catch {
|
|
1008
|
+
return API_PATH
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
/**
|
|
1013
|
+
* Read the workspace list plus every registered directory from the host.
|
|
1014
|
+
*
|
|
1015
|
+
* @param signal - optional abort signal.
|
|
1016
|
+
* @returns the parsed payload, or `{ ok: false, message }`.
|
|
1017
|
+
*/
|
|
1018
|
+
async function fetchWorkspaces(signal) {
|
|
1019
|
+
const response = await fetch(apiUrl(), {
|
|
1020
|
+
method: 'GET',
|
|
1021
|
+
headers: { accept: 'application/json' },
|
|
1022
|
+
credentials: 'same-origin',
|
|
1023
|
+
signal,
|
|
1024
|
+
})
|
|
1025
|
+
if (!response.ok) return { ok: false, message: 'host responded ' + response.status }
|
|
1026
|
+
return await response.json()
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
/**
|
|
1030
|
+
* Send one mutation to the host.
|
|
1031
|
+
*
|
|
1032
|
+
* @param payload - `{ op, workspace, label, path, note }`.
|
|
1033
|
+
* @returns the parsed payload, or `{ ok: false, message }`.
|
|
1034
|
+
*/
|
|
1035
|
+
async function postOperation(payload) {
|
|
1036
|
+
const response = await fetch(apiUrl(), {
|
|
1037
|
+
method: 'POST',
|
|
1038
|
+
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
|
1039
|
+
credentials: 'same-origin',
|
|
1040
|
+
body: JSON.stringify(payload),
|
|
1041
|
+
})
|
|
1042
|
+
if (!response.ok) return { ok: false, message: 'host responded ' + response.status }
|
|
1043
|
+
return await response.json()
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
/**
|
|
1047
|
+
* The last host payload this page has seen, shared by the three client
|
|
1048
|
+
* surfaces: the settings page renders one switch per workspace from it, the
|
|
1049
|
+
* row installer consults the per-workspace `enabled` flag, and the dialog
|
|
1050
|
+
* refreshes it after every mutation.
|
|
1051
|
+
*/
|
|
1052
|
+
let hostState = {
|
|
1053
|
+
known: false,
|
|
1054
|
+
settings: { [DEFAULT_WORKSPACE_SETTING]: false, [WORKSPACE_ENABLED_SETTING]: {} },
|
|
1055
|
+
defaultWorkspaceId: null,
|
|
1056
|
+
defaultWorkspacePath: null,
|
|
1057
|
+
workspaces: [],
|
|
1058
|
+
}
|
|
1059
|
+
const hostStateListeners = new Set()
|
|
1060
|
+
|
|
1061
|
+
/** @returns the current host snapshot (referentially stable until it changes). */
|
|
1062
|
+
function readHostState() {
|
|
1063
|
+
return hostState
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
/**
|
|
1067
|
+
* Subscribe to host-snapshot changes.
|
|
1068
|
+
*
|
|
1069
|
+
* @param listener - called after each change.
|
|
1070
|
+
* @returns the unsubscribe function.
|
|
1071
|
+
*/
|
|
1072
|
+
function subscribeHostState(listener) {
|
|
1073
|
+
hostStateListeners.add(listener)
|
|
1074
|
+
return () => {
|
|
1075
|
+
hostStateListeners.delete(listener)
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
/**
|
|
1080
|
+
* Fold a host payload into the shared snapshot. Unusable payloads leave the
|
|
1081
|
+
* previous snapshot alone.
|
|
1082
|
+
*
|
|
1083
|
+
* @param payload - a parsed `/workspaceplus/api` response.
|
|
1084
|
+
* @returns whether the payload was usable.
|
|
1085
|
+
*/
|
|
1086
|
+
function publishHostPayload(payload) {
|
|
1087
|
+
if (payload === null || typeof payload !== 'object' || payload.ok !== true) return false
|
|
1088
|
+
const rawOverrides = payload.settings?.[WORKSPACE_ENABLED_SETTING]
|
|
1089
|
+
const overrides = {}
|
|
1090
|
+
if (rawOverrides !== null && typeof rawOverrides === 'object' && !Array.isArray(rawOverrides)) {
|
|
1091
|
+
for (const [key, value] of Object.entries(rawOverrides)) {
|
|
1092
|
+
if (typeof value === 'boolean') overrides[key] = value
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
hostState = {
|
|
1096
|
+
known: true,
|
|
1097
|
+
settings: {
|
|
1098
|
+
[DEFAULT_WORKSPACE_SETTING]: payload.settings?.[DEFAULT_WORKSPACE_SETTING] === true,
|
|
1099
|
+
[WORKSPACE_ENABLED_SETTING]: overrides,
|
|
1100
|
+
},
|
|
1101
|
+
defaultWorkspaceId: typeof payload.defaultWorkspaceId === 'string' ? payload.defaultWorkspaceId : null,
|
|
1102
|
+
defaultWorkspacePath: typeof payload.defaultWorkspacePath === 'string' ? payload.defaultWorkspacePath : null,
|
|
1103
|
+
workspaces: Array.isArray(payload.workspaces) ? payload.workspaces : [],
|
|
1104
|
+
}
|
|
1105
|
+
for (const listener of [...hostStateListeners]) listener()
|
|
1106
|
+
return true
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
/**
|
|
1110
|
+
* Whether workspace+ runs for one workspace id.
|
|
1111
|
+
*
|
|
1112
|
+
* This is the host's own switch (`settings.workspaceEnabled`, with the
|
|
1113
|
+
* default Workspace falling back to `defaultWorkspaceSetting`); the host
|
|
1114
|
+
* gates prompt-context injection and the `workspace_dirs` tool on it. On the
|
|
1115
|
+
* browser side it only chooses how the row button is presented — normal or
|
|
1116
|
+
* switched-off — because the button itself is present everywhere. A
|
|
1117
|
+
* workspace the snapshot does not know yet counts as enabled: guessing
|
|
1118
|
+
* "off" would mislabel a perfectly ordinary row.
|
|
1119
|
+
*
|
|
1120
|
+
* @param workspaceId - the id taken from the row's `data-row-key`.
|
|
1121
|
+
* @returns true when workspace+ is on for that workspace.
|
|
1122
|
+
*/
|
|
1123
|
+
function isEnabledWorkspace(workspaceId) {
|
|
1124
|
+
const workspace = hostState.workspaces.find((candidate) => candidate.id === workspaceId)
|
|
1125
|
+
return workspace === undefined ? true : workspace.enabled !== false
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
/**
|
|
1129
|
+
* Refresh the shared snapshot from the host.
|
|
1130
|
+
*
|
|
1131
|
+
* @returns whether a usable payload arrived.
|
|
1132
|
+
*/
|
|
1133
|
+
async function refreshHostState() {
|
|
1134
|
+
try {
|
|
1135
|
+
return publishHostPayload(await fetchWorkspaces())
|
|
1136
|
+
} catch {
|
|
1137
|
+
return false
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
// -----------------------------------------------------------------------
|
|
1142
|
+
// The composer's `@` source
|
|
1143
|
+
// -----------------------------------------------------------------------
|
|
1144
|
+
|
|
1145
|
+
/**
|
|
1146
|
+
* Source name of the `@` group that offers this workspace's labels. It must be
|
|
1147
|
+
* unique for its trigger (`ui-reference` owns `@reference`): the input pipeline
|
|
1148
|
+
* routes a chip's serializer by source name, and a chip whose source has no
|
|
1149
|
+
* registered codec rejects the whole submit.
|
|
1150
|
+
*/
|
|
1151
|
+
const SOURCE_NAME = 'workspace-plus'
|
|
1152
|
+
|
|
1153
|
+
/**
|
|
1154
|
+
* Where this group sits in the `@` menu.
|
|
1155
|
+
*
|
|
1156
|
+
* The pipeline sorts the roster for one trigger by `InputTriggerSource.order`
|
|
1157
|
+
* — lower first, `?? 0` when absent, and `Array.prototype.sort` is stable, so
|
|
1158
|
+
* every source that omits the field ties at 0 and keeps registration order.
|
|
1159
|
+
* That is exactly why this plugin used to land last: it is materialized after
|
|
1160
|
+
* the shell's own plugins, and the product's `@` groups are `reference` (0)
|
|
1161
|
+
* and `subagent` (0), with `cordis` at 1. Declaring a value below zero is the
|
|
1162
|
+
* only way an add-on can precede them, and `-10` leaves the product room for a
|
|
1163
|
+
* group or two ahead of 0 without this one having to be re-tuned.
|
|
1164
|
+
*
|
|
1165
|
+
* The labels are the ones this workspace is actually navigated by, so they
|
|
1166
|
+
* belong at the top of the menu rather than under the file tree.
|
|
1167
|
+
*/
|
|
1168
|
+
const SOURCE_ORDER = -10
|
|
1169
|
+
|
|
1170
|
+
/** Section heading of the group's rows, standing in for the pipeline's group title. */
|
|
1171
|
+
const SOURCE_SECTION = '工作区目录'
|
|
1172
|
+
|
|
1173
|
+
/** The client session list, captured for the `@` source's cwd lookup. */
|
|
1174
|
+
let sessionsService = null
|
|
1175
|
+
|
|
1176
|
+
/**
|
|
1177
|
+
* Compare two directory paths the way the host does — separator- and
|
|
1178
|
+
* case-insensitively on Windows.
|
|
1179
|
+
*
|
|
1180
|
+
* @param left - one path.
|
|
1181
|
+
* @param right - the other path.
|
|
1182
|
+
* @returns whether they name the same directory.
|
|
1183
|
+
*/
|
|
1184
|
+
function samePath(left, right) {
|
|
1185
|
+
const fold = (value) =>
|
|
1186
|
+
String(value)
|
|
1187
|
+
.replace(/[\\/]+$/, '')
|
|
1188
|
+
.replace(/\//g, '\\')
|
|
1189
|
+
.toLowerCase()
|
|
1190
|
+
return fold(left) === fold(right)
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
/**
|
|
1194
|
+
* The session's working directory, which is the workspace key. `sessions.list`
|
|
1195
|
+
* is the same projection `ui-reference` and `ui-session` read; a composition
|
|
1196
|
+
* without it simply yields no candidates.
|
|
1197
|
+
*
|
|
1198
|
+
* @param sessionId - the id from the trigger pipeline's session handle.
|
|
1199
|
+
* @returns the cwd, or null.
|
|
1200
|
+
*/
|
|
1201
|
+
function sessionCwd(sessionId) {
|
|
1202
|
+
if (sessionsService === null || sessionId === undefined || sessionId === null) return null
|
|
1203
|
+
try {
|
|
1204
|
+
const cwd = sessionsService.list?.getSnapshot?.()?.byId?.[sessionId]?.cwd
|
|
1205
|
+
return typeof cwd === 'string' && cwd !== '' ? cwd : null
|
|
1206
|
+
} catch {
|
|
1207
|
+
return null
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
/**
|
|
1212
|
+
* The workspace a session belongs to, or null when it cannot be resolved. Used
|
|
1213
|
+
* to order the `@` menu, never to decide whether it has anything to offer.
|
|
1214
|
+
*
|
|
1215
|
+
* @param session - the pipeline's `{ sessionId }` handle.
|
|
1216
|
+
* @returns the host snapshot's workspace entry, or null.
|
|
1217
|
+
*/
|
|
1218
|
+
function workspaceForSession(session) {
|
|
1219
|
+
if (!hostState.known) return null
|
|
1220
|
+
const cwd = sessionCwd(session?.sessionId)
|
|
1221
|
+
if (cwd === null) return null
|
|
1222
|
+
return hostState.workspaces.find((candidate) => samePath(candidate.path, cwd)) ?? null
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
/**
|
|
1226
|
+
* Read back one candidate's opaque payload.
|
|
1227
|
+
*
|
|
1228
|
+
* @param value - the candidate's `value` string.
|
|
1229
|
+
* @returns `{ label, path }`, or undefined when the payload is unusable.
|
|
1230
|
+
*/
|
|
1231
|
+
function parseLabelValue(value) {
|
|
1232
|
+
if (typeof value !== 'string') return undefined
|
|
1233
|
+
try {
|
|
1234
|
+
const parsed = JSON.parse(value)
|
|
1235
|
+
if (parsed === null || typeof parsed !== 'object') return undefined
|
|
1236
|
+
if (typeof parsed.label !== 'string' || typeof parsed.path !== 'string') return undefined
|
|
1237
|
+
return { label: parsed.label, path: parsed.path }
|
|
1238
|
+
} catch {
|
|
1239
|
+
return undefined
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
/**
|
|
1244
|
+
* Build the `@` source that offers registered directory labels.
|
|
1245
|
+
*
|
|
1246
|
+
* A pick inserts a reference chip whose `ref` is the directory's ABSOLUTE PATH,
|
|
1247
|
+
* so the submitted prompt carries the path itself rather than a name the model
|
|
1248
|
+
* must resolve — the label routing then holds even if the injected table was
|
|
1249
|
+
* compacted away. The chip shows the label, and `appearance: 'folder'` draws
|
|
1250
|
+
* the shell's folder glyph without marking the chip openable.
|
|
1251
|
+
*
|
|
1252
|
+
* Candidates come from EVERY enabled workspace, grouped by workspace title,
|
|
1253
|
+
* with the calling session's workspace first when its cwd can be read. The
|
|
1254
|
+
* session lookup is an ordering refinement, never a requirement: keying the
|
|
1255
|
+
* menu on it meant one unresolved cwd turned the whole group into a silent
|
|
1256
|
+
* empty list, which is unfalsifiable from the outside.
|
|
1257
|
+
*
|
|
1258
|
+
* @returns the trigger source.
|
|
1259
|
+
*/
|
|
1260
|
+
function createLabelSource() {
|
|
1261
|
+
return {
|
|
1262
|
+
trigger: '@',
|
|
1263
|
+
name: SOURCE_NAME,
|
|
1264
|
+
// Sorting key of the `@` roster; see SOURCE_ORDER for why it is negative.
|
|
1265
|
+
order: SOURCE_ORDER,
|
|
1266
|
+
// The pipeline's group title would be an untranslated key; each row
|
|
1267
|
+
// carries its own section heading instead.
|
|
1268
|
+
showGroupTitle: false,
|
|
1269
|
+
// MUST be async: the pipeline calls `source.candidates(...).then(...)`, so
|
|
1270
|
+
// a synchronous array return throws inside its per-hit fan-out.
|
|
1271
|
+
async candidates(session, { query, signal }) {
|
|
1272
|
+
if (signal !== undefined && signal.aborted) return []
|
|
1273
|
+
const offered = hostState.workspaces.filter(
|
|
1274
|
+
(workspace) => workspace.enabled !== false && Array.isArray(workspace.dirs) && workspace.dirs.length > 0,
|
|
1275
|
+
)
|
|
1276
|
+
if (offered.length === 0) return []
|
|
1277
|
+
const cwd = sessionCwd(session?.sessionId)
|
|
1278
|
+
const currentIndex =
|
|
1279
|
+
cwd === null ? -1 : offered.findIndex((workspace) => samePath(workspace.path, cwd))
|
|
1280
|
+
const ordered = currentIndex <= 0 ? offered : [offered[currentIndex], ...offered.filter((_, index) => index !== currentIndex)]
|
|
1281
|
+
const needle = String(query ?? '').trim().toLowerCase()
|
|
1282
|
+
const matches = (dir) =>
|
|
1283
|
+
needle === '' ||
|
|
1284
|
+
dir.label.toLowerCase().includes(needle) ||
|
|
1285
|
+
(dir.note ?? '').toLowerCase().includes(needle) ||
|
|
1286
|
+
dir.path.toLowerCase().includes(needle)
|
|
1287
|
+
return ordered.flatMap((workspace) =>
|
|
1288
|
+
workspace.dirs.filter(matches).map((dir) => ({
|
|
1289
|
+
name: dir.label,
|
|
1290
|
+
label: dir.label,
|
|
1291
|
+
description: dir.note === undefined ? dir.path : dir.note + ' · ' + dir.path,
|
|
1292
|
+
icon: 'folder',
|
|
1293
|
+
section: workspace.title === undefined || workspace.title === '' ? workspace.path : workspace.title,
|
|
1294
|
+
value: JSON.stringify({ label: dir.label, path: dir.path }),
|
|
1295
|
+
})),
|
|
1296
|
+
)
|
|
1297
|
+
},
|
|
1298
|
+
onPick({ candidate }) {
|
|
1299
|
+
const value = parseLabelValue(candidate?.value)
|
|
1300
|
+
if (value === undefined) return undefined
|
|
1301
|
+
return {
|
|
1302
|
+
insert: {
|
|
1303
|
+
source: SOURCE_NAME,
|
|
1304
|
+
ref: value.path,
|
|
1305
|
+
label: value.label,
|
|
1306
|
+
appearance: 'folder',
|
|
1307
|
+
clipboardText: value.path,
|
|
1308
|
+
},
|
|
1309
|
+
}
|
|
1310
|
+
},
|
|
1311
|
+
codec: {
|
|
1312
|
+
clipboardText: (ref) => ref,
|
|
1313
|
+
// The model-facing form IS the absolute path.
|
|
1314
|
+
serialize: (ref) => Promise.resolve(ref),
|
|
1315
|
+
},
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
/**
|
|
1320
|
+
* Whether the `@` source is currently registered, and how many labels it would
|
|
1321
|
+
* offer. The settings page shows this so "the menu has no group" has a visible
|
|
1322
|
+
* cause instead of being unfalsifiable.
|
|
1323
|
+
*/
|
|
1324
|
+
let labelSourceRegistered = false
|
|
1325
|
+
|
|
1326
|
+
/**
|
|
1327
|
+
* Summarize the `@` source for the settings page.
|
|
1328
|
+
*
|
|
1329
|
+
* @returns `{ registered, workspaces, labels }`.
|
|
1330
|
+
*/
|
|
1331
|
+
function labelSourceStatus() {
|
|
1332
|
+
const offered = hostState.workspaces.filter(
|
|
1333
|
+
(workspace) => workspace.enabled !== false && Array.isArray(workspace.dirs) && workspace.dirs.length > 0,
|
|
1334
|
+
)
|
|
1335
|
+
return {
|
|
1336
|
+
registered: labelSourceRegistered,
|
|
1337
|
+
workspaces: offered.length,
|
|
1338
|
+
labels: offered.reduce((total, workspace) => total + workspace.dirs.length, 0),
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
/**
|
|
1343
|
+
* Pick the workspace the dialog shows: the requested id when it still exists,
|
|
1344
|
+
* otherwise whatever was selected, otherwise the first one.
|
|
1345
|
+
*
|
|
1346
|
+
* @param workspaces - the host's workspace list.
|
|
1347
|
+
* @param requestedId - the id the trigger asked for.
|
|
1348
|
+
* @param currentPath - the path currently selected.
|
|
1349
|
+
* @returns the resolved workspace path, or null.
|
|
1350
|
+
*/
|
|
1351
|
+
function selectWorkspace(workspaces, requestedId, currentPath) {
|
|
1352
|
+
const requested = requestedId === null || requestedId === undefined
|
|
1353
|
+
? undefined
|
|
1354
|
+
: workspaces.find((workspace) => workspace.id === requestedId)
|
|
1355
|
+
if (requested !== undefined) return requested.path
|
|
1356
|
+
if (currentPath !== null && currentPath !== undefined && workspaces.some((workspace) => workspace.path === currentPath)) {
|
|
1357
|
+
return currentPath
|
|
1358
|
+
}
|
|
1359
|
+
return workspaces[0]?.path ?? null
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
/**
|
|
1363
|
+
* The shell's directory picker (`ctx.uiWorkspace`), captured while the plugin
|
|
1364
|
+
* is mounted. It is injected optionally rather than declared in `inject`, so a
|
|
1365
|
+
* composition without ui-workspace keeps the whole plugin working — the dialog
|
|
1366
|
+
* simply loses its browse button.
|
|
1367
|
+
*/
|
|
1368
|
+
let directoryPicker = null
|
|
1369
|
+
|
|
1370
|
+
/**
|
|
1371
|
+
* The last path segment of a directory path, for either separator.
|
|
1372
|
+
*
|
|
1373
|
+
* @param path - any directory path.
|
|
1374
|
+
* @returns the final segment, or an empty string.
|
|
1375
|
+
*/
|
|
1376
|
+
function basenameOf(path) {
|
|
1377
|
+
const parts = String(path).replace(/[\\/]+$/, '').split(/[\\/]/)
|
|
1378
|
+
return parts[parts.length - 1] ?? ''
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
/**
|
|
1382
|
+
* Suggest a label for a picked directory. The host's label grammar is
|
|
1383
|
+
* `[A-Za-z0-9][A-Za-z0-9._-]{0,31}`, so anything else is folded to dashes and
|
|
1384
|
+
* dropped when nothing valid survives (a CJK directory name yields no guess
|
|
1385
|
+
* rather than a label the host would reject).
|
|
1386
|
+
*
|
|
1387
|
+
* @param path - the picked directory.
|
|
1388
|
+
* @returns a valid label, or an empty string when nothing sensible survives.
|
|
1389
|
+
*/
|
|
1390
|
+
function suggestLabel(path) {
|
|
1391
|
+
const cleaned = basenameOf(path)
|
|
1392
|
+
.replace(/[^A-Za-z0-9._-]+/g, '-')
|
|
1393
|
+
.replace(/^[^A-Za-z0-9]+/, '')
|
|
1394
|
+
.slice(0, 32)
|
|
1395
|
+
return /^[A-Za-z0-9][A-Za-z0-9._-]{0,31}$/.test(cleaned) ? cleaned : ''
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
/**
|
|
1399
|
+
* Fold a picked directory into the draft: the path always follows the picker,
|
|
1400
|
+
* and an empty label is filled from the directory's own name.
|
|
1401
|
+
*
|
|
1402
|
+
* @param draft - the current `{ label, path, note }`.
|
|
1403
|
+
* @param pickedPath - the directory the picker returned.
|
|
1404
|
+
* @returns the next draft.
|
|
1405
|
+
*/
|
|
1406
|
+
function draftAfterPick(draft, pickedPath) {
|
|
1407
|
+
return {
|
|
1408
|
+
...draft,
|
|
1409
|
+
path: pickedPath,
|
|
1410
|
+
label: draft.label.trim() === '' ? suggestLabel(pickedPath) : draft.label,
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
/**
|
|
1415
|
+
* Message shown when the shell's picker fails. A cancelled pick resolves to
|
|
1416
|
+
* `null` and never reaches here; this path is a real failure (no picker
|
|
1417
|
+
* backend, or the browse flow erroring), so it must be visible.
|
|
1418
|
+
*
|
|
1419
|
+
* @param error - whatever the picker rejected with.
|
|
1420
|
+
* @returns the user-facing message.
|
|
1421
|
+
*/
|
|
1422
|
+
function pickErrorMessage(error) {
|
|
1423
|
+
return '打开目录选择器失败:' + String((error && error.message) || error)
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
/**
|
|
1427
|
+
* Whether a registered directory sits outside its workspace root. Such a
|
|
1428
|
+
* directory is readable everywhere but only writable when the session's file
|
|
1429
|
+
* policy is `danger-full-access`, so the dialog marks it instead of letting
|
|
1430
|
+
* the user discover that from a denied write.
|
|
1431
|
+
*
|
|
1432
|
+
* @param workspacePath - the workspace root directory.
|
|
1433
|
+
* @param dirPath - the registered directory.
|
|
1434
|
+
* @returns true when the directory is outside the workspace root.
|
|
1435
|
+
*/
|
|
1436
|
+
function isOutside(workspacePath, dirPath) {
|
|
1437
|
+
const fold = (value) => String(value).replace(/[\\/]+$/, '').toLowerCase()
|
|
1438
|
+
const root = fold(workspacePath)
|
|
1439
|
+
const target = fold(dirPath)
|
|
1440
|
+
return target !== root && !target.startsWith(root + '\\') && !target.startsWith(root + '/')
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
/**
|
|
1444
|
+
* Why a directory outside the workspace root is called out at all. It is the
|
|
1445
|
+
* tooltip's payload, not row copy: the row carries an icon and the sentence
|
|
1446
|
+
* arrives on hover or keyboard focus.
|
|
1447
|
+
*/
|
|
1448
|
+
const OUTSIDE_HINT =
|
|
1449
|
+
'该目录在工作区根目录之外。DSH 沙箱只把工作区根目录当作可写根:会话权限不是 danger-full-access 时,' +
|
|
1450
|
+
'这里只能读,写入需要逐次批准(agent 会按工具提示发起升级)。'
|
|
1451
|
+
|
|
1452
|
+
/**
|
|
1453
|
+
* The warning badge of a directory outside its workspace root: the shell's
|
|
1454
|
+
* shield in the theme's warn color, with the explanation on a hover/focus
|
|
1455
|
+
* tooltip. It is focusable and labelled, so the warning is not hover-only.
|
|
1456
|
+
*
|
|
1457
|
+
* @returns the badge element, wrapped in a tooltip when the shell has one.
|
|
1458
|
+
*/
|
|
1459
|
+
function OutsideBadge() {
|
|
1460
|
+
const badge = React.createElement(
|
|
1461
|
+
'span',
|
|
1462
|
+
{
|
|
1463
|
+
className: 'dsh-workspace-plus-row-outside',
|
|
1464
|
+
role: 'img',
|
|
1465
|
+
tabIndex: 0,
|
|
1466
|
+
'aria-label': OUTSIDE_HINT,
|
|
1467
|
+
},
|
|
1468
|
+
React.createElement(
|
|
1469
|
+
'svg',
|
|
1470
|
+
{
|
|
1471
|
+
className: 'dsh-workspace-plus-glyph',
|
|
1472
|
+
width: 16,
|
|
1473
|
+
height: 16,
|
|
1474
|
+
viewBox: '0 0 16 16',
|
|
1475
|
+
fill: 'none',
|
|
1476
|
+
xmlns: 'http://www.w3.org/2000/svg',
|
|
1477
|
+
'aria-hidden': 'true',
|
|
1478
|
+
focusable: 'false',
|
|
1479
|
+
},
|
|
1480
|
+
React.createElement('path', {
|
|
1481
|
+
d: SHIELD_PATH,
|
|
1482
|
+
stroke: 'currentColor',
|
|
1483
|
+
strokeWidth: 1,
|
|
1484
|
+
strokeLinejoin: 'round',
|
|
1485
|
+
}),
|
|
1486
|
+
),
|
|
1487
|
+
)
|
|
1488
|
+
if (Tooltip === null) return badge
|
|
1489
|
+
// The bubble is portaled out of the dialog: the modal card clips its own
|
|
1490
|
+
// overflow, and its stacking context would cap an in-flow bubble's z-index.
|
|
1491
|
+
return React.createElement(
|
|
1492
|
+
Tooltip,
|
|
1493
|
+
{ label: OUTSIDE_HINT, side: 'top', portal: true, maxWidth: 320, delayMs: 120 },
|
|
1494
|
+
badge,
|
|
1495
|
+
)
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
/**
|
|
1499
|
+
* One registered directory row: glyph, label, absolute path, optional note,
|
|
1500
|
+
* the outside-the-root badge, and the remove control.
|
|
1501
|
+
*
|
|
1502
|
+
* The remove control is the shell's own cross rather than a labelled button:
|
|
1503
|
+
* every row carries one, and a word repeated down the list is what made the
|
|
1504
|
+
* earlier table read as a form instead of a list. Its accessible name still
|
|
1505
|
+
* says exactly what it removes.
|
|
1506
|
+
*
|
|
1507
|
+
* @param props - the row's directory, its workspace root, and the remove handler.
|
|
1508
|
+
* @returns the row element.
|
|
1509
|
+
*/
|
|
1510
|
+
function DirectoryRow({ dir, workspacePath, onRemove, disabled }) {
|
|
1511
|
+
const outside = isOutside(workspacePath, dir.path)
|
|
1512
|
+
return React.createElement(
|
|
1513
|
+
'div',
|
|
1514
|
+
{ className: 'dsh-workspace-plus-row' },
|
|
1515
|
+
React.createElement(
|
|
1516
|
+
'span',
|
|
1517
|
+
{ className: 'dsh-workspace-plus-field-glyph' },
|
|
1518
|
+
React.createElement(FolderGlyph, { size: 16 }),
|
|
1519
|
+
),
|
|
1520
|
+
React.createElement('span', { className: 'dsh-workspace-plus-row-tag', title: dir.label }, dir.label),
|
|
1521
|
+
React.createElement('span', { className: 'dsh-workspace-plus-row-path', title: dir.path }, dir.path),
|
|
1522
|
+
dir.note === undefined
|
|
1523
|
+
? null
|
|
1524
|
+
: React.createElement('span', { className: 'dsh-workspace-plus-row-note', title: dir.note }, dir.note),
|
|
1525
|
+
outside ? React.createElement(OutsideBadge) : null,
|
|
1526
|
+
React.createElement(
|
|
1527
|
+
'button',
|
|
1528
|
+
{
|
|
1529
|
+
type: 'button',
|
|
1530
|
+
className: 'dsh-workspace-plus-remove',
|
|
1531
|
+
disabled,
|
|
1532
|
+
'aria-label': '移除 ' + dir.label,
|
|
1533
|
+
title: '移除 ' + dir.label,
|
|
1534
|
+
onClick: () => onRemove(dir.label),
|
|
1535
|
+
},
|
|
1536
|
+
React.createElement(CloseGlyph, { size: 16 }),
|
|
1537
|
+
),
|
|
1538
|
+
)
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
/**
|
|
1542
|
+
* The dialog entry registered into `shell.overlay`.
|
|
1543
|
+
*
|
|
1544
|
+
* `props.initial` and `props.dialog` exist only as a test seam — the shell
|
|
1545
|
+
* renders overlay entries with its own standard props and no owner props.
|
|
1546
|
+
*
|
|
1547
|
+
* @param props - optional initial state for tests.
|
|
1548
|
+
* @returns the dialog element (or null while closed).
|
|
1549
|
+
*/
|
|
1550
|
+
function WorkdirsDialog(props) {
|
|
1551
|
+
const dialog = React.useSyncExternalStore(subscribeDialog, readDialog)
|
|
1552
|
+
const [state, setState] = React.useState(props?.initial ?? WORKDIRS_IDLE)
|
|
1553
|
+
const [draft, setDraft] = React.useState({ label: '', path: '', note: '' })
|
|
1554
|
+
const [picking, setPicking] = React.useState(false)
|
|
1555
|
+
|
|
1556
|
+
const patch = (changes) => setState((current) => ({ ...current, ...changes }))
|
|
1557
|
+
|
|
1558
|
+
/** Fold one successful host payload into the dialog state. */
|
|
1559
|
+
const applyPayload = (payload, requestedId) => {
|
|
1560
|
+
publishHostPayload(payload)
|
|
1561
|
+
if (payload === null || typeof payload !== 'object' || payload.ok !== true) {
|
|
1562
|
+
setState((current) => ({
|
|
1563
|
+
...current,
|
|
1564
|
+
status: 'error',
|
|
1565
|
+
busy: false,
|
|
1566
|
+
ok: false,
|
|
1567
|
+
message: (payload && payload.message) || 'host returned an unusable payload',
|
|
1568
|
+
}))
|
|
1569
|
+
return
|
|
1570
|
+
}
|
|
1571
|
+
const workspaces = Array.isArray(payload.workspaces) ? payload.workspaces : []
|
|
1572
|
+
// The trigger names the workspace, so the dialog edits THAT one and shows
|
|
1573
|
+
// no picker. Only a trigger without a workspace (the sidebar fallback)
|
|
1574
|
+
// leaves the choice open.
|
|
1575
|
+
const locked = requestedId === null || requestedId === undefined
|
|
1576
|
+
? null
|
|
1577
|
+
: workspaces.find((workspace) => workspace.id === requestedId) ?? null
|
|
1578
|
+
setState((current) => ({
|
|
1579
|
+
...current,
|
|
1580
|
+
status: 'ready',
|
|
1581
|
+
busy: false,
|
|
1582
|
+
ok: null,
|
|
1583
|
+
message: '',
|
|
1584
|
+
workspaces,
|
|
1585
|
+
registryAvailable: payload.registryAvailable !== false,
|
|
1586
|
+
locked: locked !== null,
|
|
1587
|
+
selected: locked !== null ? locked.path : selectWorkspace(workspaces, requestedId, current.selected),
|
|
1588
|
+
}))
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
const load = async (requestedId) => {
|
|
1592
|
+
patch({ busy: true })
|
|
1593
|
+
try {
|
|
1594
|
+
applyPayload(await fetchWorkspaces(), requestedId)
|
|
1595
|
+
} catch (error) {
|
|
1596
|
+
patch({ status: 'error', busy: false, ok: false, message: String((error && error.message) || error) })
|
|
1597
|
+
}
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1600
|
+
const submit = async (payload) => {
|
|
1601
|
+
patch({ busy: true })
|
|
1602
|
+
try {
|
|
1603
|
+
const result = await postOperation(payload)
|
|
1604
|
+
if (result === null || typeof result !== 'object') {
|
|
1605
|
+
patch({ busy: false, ok: false, message: 'host returned an unusable payload' })
|
|
1606
|
+
return false
|
|
1607
|
+
}
|
|
1608
|
+
publishHostPayload(result)
|
|
1609
|
+
const workspaces = Array.isArray(result.workspaces) ? result.workspaces : state.workspaces
|
|
1610
|
+
setState((current) => ({
|
|
1611
|
+
...current,
|
|
1612
|
+
status: 'ready',
|
|
1613
|
+
busy: false,
|
|
1614
|
+
ok: result.ok === true,
|
|
1615
|
+
message: String(result.message ?? ''),
|
|
1616
|
+
workspaces,
|
|
1617
|
+
registryAvailable: result.registryAvailable !== false,
|
|
1618
|
+
selected: selectWorkspace(workspaces, null, current.selected),
|
|
1619
|
+
}))
|
|
1620
|
+
return result.ok === true
|
|
1621
|
+
} catch (error) {
|
|
1622
|
+
patch({ status: 'error', busy: false, ok: false, message: String((error && error.message) || error) })
|
|
1623
|
+
return false
|
|
1624
|
+
}
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1627
|
+
// Refetch whenever the dialog opens, and whenever a trigger names a
|
|
1628
|
+
// different workspace, so the table is never stale.
|
|
1629
|
+
const open = dialog.open
|
|
1630
|
+
const requestedId = dialog.workspaceId
|
|
1631
|
+
React.useEffect(() => {
|
|
1632
|
+
if (!open) return undefined
|
|
1633
|
+
let cancelled = false
|
|
1634
|
+
const controller = typeof AbortController === 'undefined' ? undefined : new AbortController()
|
|
1635
|
+
;(async () => {
|
|
1636
|
+
try {
|
|
1637
|
+
const payload = await fetchWorkspaces(controller?.signal)
|
|
1638
|
+
if (cancelled) return
|
|
1639
|
+
applyPayload(payload, requestedId)
|
|
1640
|
+
} catch (error) {
|
|
1641
|
+
if (cancelled) return
|
|
1642
|
+
setState((current) => ({
|
|
1643
|
+
...current,
|
|
1644
|
+
status: 'error',
|
|
1645
|
+
busy: false,
|
|
1646
|
+
ok: false,
|
|
1647
|
+
message: String((error && error.message) || error),
|
|
1648
|
+
}))
|
|
1649
|
+
}
|
|
1650
|
+
})()
|
|
1651
|
+
return () => {
|
|
1652
|
+
cancelled = true
|
|
1653
|
+
controller?.abort()
|
|
1654
|
+
}
|
|
1655
|
+
}, [open, requestedId])
|
|
1656
|
+
|
|
1657
|
+
const selected = state.workspaces.find((workspace) => workspace.path === state.selected) ?? null
|
|
1658
|
+
const dirs = selected === null ? [] : selected.dirs
|
|
1659
|
+
/** Without `ctx.uiWorkspace` there is no picker, and the path has no manual entry. */
|
|
1660
|
+
const pickerAvailable = directoryPicker !== null && directoryPicker !== undefined
|
|
1661
|
+
|
|
1662
|
+
const addDirectory = async () => {
|
|
1663
|
+
if (selected === null) return
|
|
1664
|
+
const added = await submit({
|
|
1665
|
+
op: 'add',
|
|
1666
|
+
workspace: selected.path,
|
|
1667
|
+
label: draft.label,
|
|
1668
|
+
path: draft.path,
|
|
1669
|
+
note: draft.note,
|
|
1670
|
+
})
|
|
1671
|
+
if (added) setDraft({ label: '', path: '', note: '' })
|
|
1672
|
+
}
|
|
1673
|
+
|
|
1674
|
+
const removeDirectory = async (label) => {
|
|
1675
|
+
if (selected === null) return
|
|
1676
|
+
await submit({ op: 'remove', workspace: selected.path, label })
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1679
|
+
/**
|
|
1680
|
+
* Ask the shell's directory picker for a directory. A cancelled picker
|
|
1681
|
+
* resolves to `null` and changes nothing; a failed one reports the reason
|
|
1682
|
+
* where the add result goes, so the dialog never fails silently.
|
|
1683
|
+
*/
|
|
1684
|
+
const browse = async () => {
|
|
1685
|
+
if (directoryPicker === null || directoryPicker === undefined) return
|
|
1686
|
+
setPicking(true)
|
|
1687
|
+
try {
|
|
1688
|
+
const picked = await directoryPicker.pickDirectory()
|
|
1689
|
+
if (typeof picked === 'string' && picked !== '') setDraft((current) => draftAfterPick(current, picked))
|
|
1690
|
+
} catch (error) {
|
|
1691
|
+
patch({ ok: false, message: pickErrorMessage(error) })
|
|
1692
|
+
} finally {
|
|
1693
|
+
setPicking(false)
|
|
1694
|
+
}
|
|
1695
|
+
}
|
|
1696
|
+
|
|
1697
|
+
/**
|
|
1698
|
+
* The dialog's blocks, in reading order. Every one of them is a bordered
|
|
1699
|
+
* surface with the row grid inside, so the dialog reads as one column of
|
|
1700
|
+
* like things rather than a toolbar, a table and a form stacked on top of
|
|
1701
|
+
* each other.
|
|
1702
|
+
*/
|
|
1703
|
+
const identity =
|
|
1704
|
+
selected === null
|
|
1705
|
+
? null
|
|
1706
|
+
: React.createElement(
|
|
1707
|
+
'div',
|
|
1708
|
+
{ className: 'dsh-workspace-plus-field' },
|
|
1709
|
+
React.createElement(
|
|
1710
|
+
'span',
|
|
1711
|
+
{ className: 'dsh-workspace-plus-field-glyph' },
|
|
1712
|
+
React.createElement(FolderGlyph, { size: 16 }),
|
|
1713
|
+
),
|
|
1714
|
+
state.locked
|
|
1715
|
+
? React.createElement(
|
|
1716
|
+
'span',
|
|
1717
|
+
{ className: 'dsh-workspace-plus-field-title', title: selected.title || selected.path },
|
|
1718
|
+
selected.title || selected.path,
|
|
1719
|
+
)
|
|
1720
|
+
: React.createElement(
|
|
1721
|
+
'select',
|
|
1722
|
+
{
|
|
1723
|
+
className: 'dsh-workspace-plus-select',
|
|
1724
|
+
value: state.selected ?? '',
|
|
1725
|
+
disabled: state.workspaces.length === 0,
|
|
1726
|
+
'aria-label': '选择工作区',
|
|
1727
|
+
onChange: (event) => patch({ selected: event.target.value === '' ? null : event.target.value }),
|
|
1728
|
+
},
|
|
1729
|
+
state.workspaces.length === 0
|
|
1730
|
+
? React.createElement('option', { value: '' }, '(没有可用工作区)')
|
|
1731
|
+
: state.workspaces.map((workspace) =>
|
|
1732
|
+
React.createElement(
|
|
1733
|
+
'option',
|
|
1734
|
+
{ key: workspace.id, value: workspace.path },
|
|
1735
|
+
(workspace.title || workspace.path) + ' — ' + workspace.path,
|
|
1736
|
+
),
|
|
1737
|
+
),
|
|
1738
|
+
),
|
|
1739
|
+
// The trigger names the workspace, so the locked row also says which
|
|
1740
|
+
// root its labels resolve against.
|
|
1741
|
+
state.locked
|
|
1742
|
+
? React.createElement(
|
|
1743
|
+
'span',
|
|
1744
|
+
{ className: 'dsh-workspace-plus-field-root', title: selected.path },
|
|
1745
|
+
selected.path,
|
|
1746
|
+
)
|
|
1747
|
+
: null,
|
|
1748
|
+
)
|
|
1749
|
+
|
|
1750
|
+
const sectionLabel = React.createElement('div', { className: 'dsh-workspace-plus-section' }, '目录')
|
|
1751
|
+
|
|
1752
|
+
const listBlock = (...children) =>
|
|
1753
|
+
React.createElement('div', { className: 'dsh-workspace-plus-list' }, ...children)
|
|
1754
|
+
|
|
1755
|
+
const emptyRow = (text) =>
|
|
1756
|
+
React.createElement('div', { className: 'dsh-workspace-plus-field-row dsh-workspace-plus-empty' }, text)
|
|
1757
|
+
|
|
1758
|
+
const noticeBlock = (variant, ...children) =>
|
|
1759
|
+
React.createElement(
|
|
1760
|
+
'div',
|
|
1761
|
+
{
|
|
1762
|
+
className:
|
|
1763
|
+
'dsh-workspace-plus-notice' +
|
|
1764
|
+
(variant === '' || variant === undefined ? '' : ' dsh-workspace-plus-notice-' + variant),
|
|
1765
|
+
},
|
|
1766
|
+
...children,
|
|
1767
|
+
)
|
|
1768
|
+
|
|
1769
|
+
/**
|
|
1770
|
+
* The add group. Its row IS the picker — there is no path to type into — and
|
|
1771
|
+
* the label/note row appears only once a directory has been chosen, so an
|
|
1772
|
+
* idle dialog is a list plus one row, not a form with two empty fields.
|
|
1773
|
+
*/
|
|
1774
|
+
const addGroup = React.createElement(
|
|
1775
|
+
'div',
|
|
1776
|
+
{ className: 'dsh-workspace-plus-list' },
|
|
1777
|
+
React.createElement(
|
|
1778
|
+
'button',
|
|
1779
|
+
{
|
|
1780
|
+
type: 'button',
|
|
1781
|
+
className: 'dsh-workspace-plus-field-row dsh-workspace-plus-add',
|
|
1782
|
+
disabled: selected === null || state.busy || picking || !pickerAvailable,
|
|
1783
|
+
'aria-label': '选择目录',
|
|
1784
|
+
title: pickerAvailable
|
|
1785
|
+
? '打开目录选择器'
|
|
1786
|
+
: '当前页面没有挂载 ui-workspace,目录选择器不可用;可在对话里用 workspace_dirs 登记',
|
|
1787
|
+
onClick: () => void browse(),
|
|
1788
|
+
},
|
|
1789
|
+
React.createElement(
|
|
1790
|
+
'span',
|
|
1791
|
+
{ className: 'dsh-workspace-plus-field-glyph' },
|
|
1792
|
+
React.createElement(PlusGlyph, { size: 16 }),
|
|
1793
|
+
),
|
|
1794
|
+
React.createElement(
|
|
1795
|
+
'span',
|
|
1796
|
+
{
|
|
1797
|
+
className:
|
|
1798
|
+
draft.path === ''
|
|
1799
|
+
? 'dsh-workspace-plus-add-text'
|
|
1800
|
+
: 'dsh-workspace-plus-add-text dsh-workspace-plus-add-path',
|
|
1801
|
+
...(draft.path === '' ? {} : { title: draft.path }),
|
|
1802
|
+
},
|
|
1803
|
+
picking ? '选择中…' : draft.path === '' ? '添加目录' : draft.path,
|
|
1804
|
+
),
|
|
1805
|
+
),
|
|
1806
|
+
draft.path === ''
|
|
1807
|
+
? null
|
|
1808
|
+
: React.createElement(
|
|
1809
|
+
'div',
|
|
1810
|
+
{ className: 'dsh-workspace-plus-field-row dsh-workspace-plus-draft' },
|
|
1811
|
+
React.createElement('input', {
|
|
1812
|
+
className: 'dsh-workspace-plus-input dsh-workspace-plus-input-label',
|
|
1813
|
+
placeholder: '标签,如 backend',
|
|
1814
|
+
'aria-label': '标签',
|
|
1815
|
+
value: draft.label,
|
|
1816
|
+
disabled: state.busy,
|
|
1817
|
+
onChange: (event) => setDraft({ ...draft, label: event.target.value }),
|
|
1818
|
+
}),
|
|
1819
|
+
React.createElement('input', {
|
|
1820
|
+
className: 'dsh-workspace-plus-input dsh-workspace-plus-input-note',
|
|
1821
|
+
placeholder: '说明(可选)',
|
|
1822
|
+
'aria-label': '说明',
|
|
1823
|
+
value: draft.note,
|
|
1824
|
+
disabled: state.busy,
|
|
1825
|
+
onChange: (event) => setDraft({ ...draft, note: event.target.value }),
|
|
1826
|
+
}),
|
|
1827
|
+
React.createElement(
|
|
1828
|
+
Button,
|
|
1829
|
+
{
|
|
1830
|
+
variant: 'primary',
|
|
1831
|
+
className: 'dsh-workspace-plus-submit',
|
|
1832
|
+
disabled: state.busy || draft.path === '',
|
|
1833
|
+
onClick: () => void addDirectory(),
|
|
1834
|
+
},
|
|
1835
|
+
'添加',
|
|
1836
|
+
),
|
|
1837
|
+
),
|
|
1838
|
+
)
|
|
1839
|
+
|
|
1840
|
+
// The switch that turned this workspace off is on the settings page, not
|
|
1841
|
+
// here, so the state is stated where the user is already looking.
|
|
1842
|
+
const disabledNotice =
|
|
1843
|
+
selected === null || selected.enabled !== false
|
|
1844
|
+
? null
|
|
1845
|
+
: noticeBlock(
|
|
1846
|
+
'warn',
|
|
1847
|
+
React.createElement(
|
|
1848
|
+
'div',
|
|
1849
|
+
{ className: 'dsh-workspace-plus-off' },
|
|
1850
|
+
'这个工作区已在 workspace+ 里停用:标签不会注入对话,workspace_dirs 工具也会拒绝操作。' +
|
|
1851
|
+
'到 设置 → workspace+ 打开它的开关即可恢复。',
|
|
1852
|
+
),
|
|
1853
|
+
)
|
|
1854
|
+
|
|
1855
|
+
// The host's own sentence about what it just did — the only status copy the
|
|
1856
|
+
// dialog shows, and only while it is true. A failed read is not repeated
|
|
1857
|
+
// here: its notice above already says it, at length.
|
|
1858
|
+
const statusLine =
|
|
1859
|
+
state.status === 'error' || state.message === ''
|
|
1860
|
+
? null
|
|
1861
|
+
: React.createElement(
|
|
1862
|
+
'div',
|
|
1863
|
+
{
|
|
1864
|
+
className:
|
|
1865
|
+
'dsh-workspace-plus-status ' +
|
|
1866
|
+
(state.ok === false ? 'dsh-workspace-plus-error' : 'dsh-workspace-plus-ok'),
|
|
1867
|
+
},
|
|
1868
|
+
state.message,
|
|
1869
|
+
)
|
|
1870
|
+
|
|
1871
|
+
const bodyBlocks = (() => {
|
|
1872
|
+
if (state.status === 'error') {
|
|
1873
|
+
const unauthorized = state.message.includes('401')
|
|
1874
|
+
const unrouted = state.message.includes('404')
|
|
1875
|
+
/**
|
|
1876
|
+
* The three failures need different advice:
|
|
1877
|
+
* - 401: this document never completed the token exchange, so the
|
|
1878
|
+
* connection layer has no authority to admit the request.
|
|
1879
|
+
* - 404: the request reached the Host and nothing claimed the path.
|
|
1880
|
+
* The browser half hot-reloads from the served bundle, the host half
|
|
1881
|
+
* does not, so a stale Host process is the ordinary cause.
|
|
1882
|
+
* - anything else: the transport itself (a page with no HTTP carrier).
|
|
1883
|
+
*/
|
|
1884
|
+
const diagnosis = unauthorized
|
|
1885
|
+
? '这个页面没有带授权的会话凭据。请用 `dsh` 启动时打印的带 token 的地址重新打开 GUI(它会写入 cookie,然后跳转到干净地址)。'
|
|
1886
|
+
: unrouted
|
|
1887
|
+
? '请求到达了 DSH 宿主进程,但宿主里没有注册 ' +
|
|
1888
|
+
API_PATH +
|
|
1889
|
+
' 这条路由——通常意味着宿主进程还在跑旧版插件代码。浏览器半会随 bundle 热更新,宿主半不会:重启 dsh 即可。' +
|
|
1890
|
+
'重启后终端会出现 [dsh-workspace-plus] host half loaded 一行;若没有,请到 设置 → 插件 查看该插件行的状态。'
|
|
1891
|
+
: '这个面板通过 ' +
|
|
1892
|
+
API_PATH +
|
|
1893
|
+
' 与宿主进程通信,而当前页面没有可用的 HTTP 通道(例如页面不是 dsh 提供的、或宿主进程还没起来)。'
|
|
1894
|
+
return [
|
|
1895
|
+
noticeBlock(
|
|
1896
|
+
'error',
|
|
1897
|
+
React.createElement('div', { className: 'dsh-workspace-plus-error' }, '无法连接 host 接口:' + state.message),
|
|
1898
|
+
React.createElement('div', { className: 'dsh-workspace-plus-note' }, diagnosis),
|
|
1899
|
+
React.createElement(
|
|
1900
|
+
'div',
|
|
1901
|
+
{ className: 'dsh-workspace-plus-note' },
|
|
1902
|
+
'宿主半加载之后,映射也可以在对话里用 workspace_dirs 工具管理。',
|
|
1903
|
+
),
|
|
1904
|
+
),
|
|
1905
|
+
]
|
|
1906
|
+
}
|
|
1907
|
+
if (!state.registryAvailable) {
|
|
1908
|
+
return [
|
|
1909
|
+
noticeBlock(
|
|
1910
|
+
'',
|
|
1911
|
+
React.createElement(
|
|
1912
|
+
'div',
|
|
1913
|
+
{ className: 'dsh-workspace-plus-note' },
|
|
1914
|
+
'宿主进程没有加载 workspace 注册表,无法列出工作区。请在对话里用 workspace_dirs 工具按绝对路径管理标签。',
|
|
1915
|
+
),
|
|
1916
|
+
),
|
|
1917
|
+
]
|
|
1918
|
+
}
|
|
1919
|
+
if (state.status === 'loading') {
|
|
1920
|
+
return [sectionLabel, listBlock(emptyRow('正在读取…'))]
|
|
1921
|
+
}
|
|
1922
|
+
if (selected === null) {
|
|
1923
|
+
return [
|
|
1924
|
+
noticeBlock(
|
|
1925
|
+
'',
|
|
1926
|
+
React.createElement(
|
|
1927
|
+
'div',
|
|
1928
|
+
{ className: 'dsh-workspace-plus-note' },
|
|
1929
|
+
'还没有任何工作区。先在左侧边栏新建一个工作区,再回到这里登记目录。',
|
|
1930
|
+
),
|
|
1931
|
+
),
|
|
1932
|
+
]
|
|
1933
|
+
}
|
|
1934
|
+
const rows = dirs.map((dir) =>
|
|
1935
|
+
React.createElement(DirectoryRow, {
|
|
1936
|
+
key: dir.label,
|
|
1937
|
+
dir,
|
|
1938
|
+
workspacePath: selected.path,
|
|
1939
|
+
disabled: state.busy,
|
|
1940
|
+
onRemove: (label) => void removeDirectory(label),
|
|
1941
|
+
}),
|
|
1942
|
+
)
|
|
1943
|
+
return [
|
|
1944
|
+
sectionLabel,
|
|
1945
|
+
listBlock(...(rows.length === 0 ? [emptyRow('还没有登记目录')] : rows)),
|
|
1946
|
+
addGroup,
|
|
1947
|
+
]
|
|
1948
|
+
})()
|
|
1949
|
+
|
|
1950
|
+
const blocks = [identity, ...bodyBlocks, disabledNotice, statusLine].filter(
|
|
1951
|
+
(block) => block !== null && block !== undefined,
|
|
1952
|
+
)
|
|
1953
|
+
|
|
1954
|
+
return React.createElement(
|
|
1955
|
+
Modal,
|
|
1956
|
+
{
|
|
1957
|
+
open,
|
|
1958
|
+
onClose: closeWorkdirsDialog,
|
|
1959
|
+
title: '工作区目录设置',
|
|
1960
|
+
closeLabel: '关闭',
|
|
1961
|
+
// The card is wider than the shell's 380px default: a row carries a
|
|
1962
|
+
// label, an absolute path, a note and a badge.
|
|
1963
|
+
className: 'dsh-workspace-plus-dialog-card',
|
|
1964
|
+
// Secondary action left, primary right — the same split the shell's own
|
|
1965
|
+
// product dialogs use, so the footer reads without a second look.
|
|
1966
|
+
footer: React.createElement(
|
|
1967
|
+
'div',
|
|
1968
|
+
{ className: 'dsh-workspace-plus-footer' },
|
|
1969
|
+
React.createElement(
|
|
1970
|
+
Button,
|
|
1971
|
+
{ variant: 'ghost', disabled: state.busy, onClick: () => void load(requestedId) },
|
|
1972
|
+
'刷新',
|
|
1973
|
+
),
|
|
1974
|
+
React.createElement(Button, { variant: 'primary', onClick: closeWorkdirsDialog }, '完成'),
|
|
1975
|
+
),
|
|
1976
|
+
},
|
|
1977
|
+
React.createElement('div', { className: 'dsh-workspace-plus-dialog' }, ...blocks),
|
|
1978
|
+
)
|
|
1979
|
+
}
|
|
1980
|
+
|
|
1981
|
+
/**
|
|
1982
|
+
* The `workspace+` settings page, registered into `settings.section`.
|
|
1983
|
+
*
|
|
1984
|
+
* It owns one preference per workspace: whether the feature runs there. That
|
|
1985
|
+
* single switch covers the two surfaces the feature itself drives — the label
|
|
1986
|
+
* table injected into that workspace's conversations and the `workspace_dirs`
|
|
1987
|
+
* tool. It deliberately does NOT cover the workspace row button: every row
|
|
1988
|
+
* keeps its management entry point, so a switched-off workspace can still be
|
|
1989
|
+
* inspected (and understood) instead of silently losing its only way in.
|
|
1990
|
+
*
|
|
1991
|
+
* Every workspace defaults to on except the default (first-use) one, which
|
|
1992
|
+
* belongs to the product rather than to this plugin and therefore starts off.
|
|
1993
|
+
* A switch writes an explicit override for that workspace; nothing is deleted
|
|
1994
|
+
* when it is turned off, so a workspace can be switched back on at any time
|
|
1995
|
+
* without losing the labels registered in it.
|
|
1996
|
+
*
|
|
1997
|
+
* @param props - optional initial state for tests.
|
|
1998
|
+
* @returns the settings section element.
|
|
1999
|
+
*/
|
|
2000
|
+
function WorkspacePlusSettings(props) {
|
|
2001
|
+
const host = React.useSyncExternalStore(subscribeHostState, readHostState)
|
|
2002
|
+
const [state, setState] = React.useState(props?.initial ?? { status: 'loading', message: '', ok: null, busy: false })
|
|
2003
|
+
|
|
2004
|
+
const patch = (changes) => setState((current) => ({ ...current, ...changes }))
|
|
2005
|
+
|
|
2006
|
+
React.useEffect(() => {
|
|
2007
|
+
let cancelled = false
|
|
2008
|
+
const controller = typeof AbortController === 'undefined' ? undefined : new AbortController()
|
|
2009
|
+
;(async () => {
|
|
2010
|
+
try {
|
|
2011
|
+
const payload = await fetchWorkspaces(controller?.signal)
|
|
2012
|
+
if (cancelled) return
|
|
2013
|
+
if (!publishHostPayload(payload)) {
|
|
2014
|
+
setState((current) => ({
|
|
2015
|
+
...current,
|
|
2016
|
+
status: 'error',
|
|
2017
|
+
message: (payload && payload.message) || 'host returned an unusable payload',
|
|
2018
|
+
}))
|
|
2019
|
+
return
|
|
2020
|
+
}
|
|
2021
|
+
setState((current) => ({ ...current, status: 'ready', message: '' }))
|
|
2022
|
+
} catch (error) {
|
|
2023
|
+
if (cancelled) return
|
|
2024
|
+
setState((current) => ({
|
|
2025
|
+
...current,
|
|
2026
|
+
status: 'error',
|
|
2027
|
+
message: String((error && error.message) || error),
|
|
2028
|
+
}))
|
|
2029
|
+
}
|
|
2030
|
+
})()
|
|
2031
|
+
return () => {
|
|
2032
|
+
cancelled = true
|
|
2033
|
+
controller?.abort()
|
|
2034
|
+
}
|
|
2035
|
+
}, [])
|
|
2036
|
+
|
|
2037
|
+
/**
|
|
2038
|
+
* Flip one workspace's switch. The key is the one the host published for
|
|
2039
|
+
* that workspace, so the two halves cannot disagree about path spelling.
|
|
2040
|
+
*
|
|
2041
|
+
* @param key - the workspace's settings key.
|
|
2042
|
+
* @param next - the requested state.
|
|
2043
|
+
*/
|
|
2044
|
+
const toggle = async (key, next) => {
|
|
2045
|
+
patch({ busy: true })
|
|
2046
|
+
try {
|
|
2047
|
+
const result = await postOperation({
|
|
2048
|
+
op: 'settings',
|
|
2049
|
+
settings: { [WORKSPACE_ENABLED_SETTING]: { [key]: next } },
|
|
2050
|
+
})
|
|
2051
|
+
if (result === null || typeof result !== 'object') {
|
|
2052
|
+
patch({ busy: false, ok: false, message: 'host returned an unusable payload' })
|
|
2053
|
+
return
|
|
2054
|
+
}
|
|
2055
|
+
publishHostPayload(result)
|
|
2056
|
+
patch({ busy: false, ok: result.ok === true, message: String(result.message ?? '') })
|
|
2057
|
+
} catch (error) {
|
|
2058
|
+
patch({ busy: false, ok: false, message: String((error && error.message) || error) })
|
|
2059
|
+
}
|
|
2060
|
+
}
|
|
2061
|
+
|
|
2062
|
+
const unavailable = state.status === 'error'
|
|
2063
|
+
|
|
2064
|
+
/**
|
|
2065
|
+
* A workspace can only be switched from the key the host published for it.
|
|
2066
|
+
* Guessing one from the path is not an option: the host folds case on
|
|
2067
|
+
* Windows, so a guessed key would be accepted, stored, and then never match
|
|
2068
|
+
* — a silent no-op. A payload without keys means a stale host half, which
|
|
2069
|
+
* the page reports instead.
|
|
2070
|
+
*/
|
|
2071
|
+
const keyOf = (workspace) => (typeof workspace.key === 'string' && workspace.key !== '' ? workspace.key : null)
|
|
2072
|
+
const staleHost = host.known && host.workspaces.some((workspace) => keyOf(workspace) === null)
|
|
2073
|
+
|
|
2074
|
+
const workspaceRows = host.workspaces.map((workspace) => {
|
|
2075
|
+
const key = keyOf(workspace)
|
|
2076
|
+
return React.createElement(
|
|
2077
|
+
'div',
|
|
2078
|
+
{ key: workspace.id, className: 'dsh-workspace-plus-settings-row' },
|
|
2079
|
+
React.createElement(
|
|
2080
|
+
'div',
|
|
2081
|
+
{ className: 'dsh-workspace-plus-settings-text' },
|
|
2082
|
+
React.createElement(
|
|
2083
|
+
'div',
|
|
2084
|
+
{ className: 'dsh-workspace-plus-settings-title' },
|
|
2085
|
+
workspace.title || workspace.path,
|
|
2086
|
+
workspace.isDefault === true
|
|
2087
|
+
? React.createElement('span', { className: 'dsh-workspace-plus-settings-tag' }, '默认工作区')
|
|
2088
|
+
: null,
|
|
2089
|
+
),
|
|
2090
|
+
React.createElement(
|
|
2091
|
+
'div',
|
|
2092
|
+
{ className: 'dsh-workspace-plus-settings-description' },
|
|
2093
|
+
React.createElement('span', { className: 'dsh-workspace-plus-mono' }, workspace.path),
|
|
2094
|
+
workspace.isDefault === true
|
|
2095
|
+
? '(默认关闭,打开后它的对话才会看到标签表)'
|
|
2096
|
+
: null,
|
|
2097
|
+
),
|
|
2098
|
+
),
|
|
2099
|
+
React.createElement(Switch, {
|
|
2100
|
+
checked: workspace.enabled !== false,
|
|
2101
|
+
disabled: state.busy || unavailable || key === null,
|
|
2102
|
+
label: (workspace.title || workspace.path) + ' 启用 workspace+',
|
|
2103
|
+
onChange: (next) => void toggle(key, next),
|
|
2104
|
+
}),
|
|
2105
|
+
)
|
|
2106
|
+
})
|
|
2107
|
+
|
|
2108
|
+
const workspaceList = (() => {
|
|
2109
|
+
if (state.status === 'loading' && host.workspaces.length === 0) {
|
|
2110
|
+
return React.createElement('div', { className: 'dsh-workspace-plus-settings-description' }, '正在读取工作区…')
|
|
2111
|
+
}
|
|
2112
|
+
if (host.workspaces.length === 0) {
|
|
2113
|
+
return React.createElement(
|
|
2114
|
+
'div',
|
|
2115
|
+
{ className: 'dsh-workspace-plus-settings-description' },
|
|
2116
|
+
'没有可用的工作区。在左侧边栏新建一个工作区后,这里会出现它自己的开关。',
|
|
2117
|
+
)
|
|
2118
|
+
}
|
|
2119
|
+
return workspaceRows
|
|
2120
|
+
})()
|
|
2121
|
+
|
|
2122
|
+
const status = (() => {
|
|
2123
|
+
if (staleHost) {
|
|
2124
|
+
return React.createElement(
|
|
2125
|
+
'div',
|
|
2126
|
+
{ className: 'dsh-workspace-plus-settings-error' },
|
|
2127
|
+
'宿主返回的工作区数据里没有 key,无法按工作区切换 —— 通常是宿主进程还在跑旧版插件代码。重启 dsh 即可。',
|
|
2128
|
+
)
|
|
2129
|
+
}
|
|
2130
|
+
// A failed READ needs the transport diagnosis; a rejected WRITE only needs
|
|
2131
|
+
// the host's own message.
|
|
2132
|
+
if (state.status === 'error') {
|
|
2133
|
+
const unrouted = state.message.includes('404')
|
|
2134
|
+
return React.createElement(
|
|
2135
|
+
'div',
|
|
2136
|
+
{ className: 'dsh-workspace-plus-settings-error' },
|
|
2137
|
+
'无法读取宿主设置:' + state.message,
|
|
2138
|
+
unrouted
|
|
2139
|
+
? '(宿主里没有 ' + API_PATH + ' 这条路由,通常意味着 dsh 还没重启)'
|
|
2140
|
+
: '',
|
|
2141
|
+
)
|
|
2142
|
+
}
|
|
2143
|
+
if (state.message === '') return null
|
|
2144
|
+
return React.createElement(
|
|
2145
|
+
'div',
|
|
2146
|
+
{ className: state.ok === false ? 'dsh-workspace-plus-settings-error' : 'dsh-workspace-plus-settings-ok' },
|
|
2147
|
+
state.message,
|
|
2148
|
+
)
|
|
2149
|
+
})()
|
|
2150
|
+
|
|
2151
|
+
const sourceStatus = labelSourceStatus()
|
|
2152
|
+
const atLine = React.createElement(
|
|
2153
|
+
'div',
|
|
2154
|
+
{ className: 'dsh-workspace-plus-settings-description' },
|
|
2155
|
+
'输入框 `@` 候选:',
|
|
2156
|
+
sourceStatus.registered
|
|
2157
|
+
? sourceStatus.labels === 0
|
|
2158
|
+
? '候选源已注册,但还没有可用的标签 —— 先在工作区行里登记目录。'
|
|
2159
|
+
: '候选源已注册,共 ' +
|
|
2160
|
+
String(sourceStatus.labels) +
|
|
2161
|
+
' 个标签(分布在 ' +
|
|
2162
|
+
String(sourceStatus.workspaces) +
|
|
2163
|
+
' 个工作区)。'
|
|
2164
|
+
: '候选源未注册 —— 当前页面可能还是旧的插件 bundle,重启 dsh 并强制刷新页面。',
|
|
2165
|
+
)
|
|
2166
|
+
|
|
2167
|
+
return React.createElement(
|
|
2168
|
+
'div',
|
|
2169
|
+
{ className: 'dsh-workspace-plus-settings' },
|
|
2170
|
+
React.createElement(
|
|
2171
|
+
'div',
|
|
2172
|
+
{ className: 'dsh-workspace-plus-settings-description' },
|
|
2173
|
+
'按工作区启用:开关控制注入这个工作区对话上下文的标签表,以及 ',
|
|
2174
|
+
React.createElement('span', { className: 'dsh-workspace-plus-mono' }, 'workspace_dirs'),
|
|
2175
|
+
' 工具。关掉不会删除已登记的标签,也不会收走工作区行上的目录按钮——那个按钮在任何工作区都能打开弹窗。',
|
|
2176
|
+
),
|
|
2177
|
+
workspaceList,
|
|
2178
|
+
atLine,
|
|
2179
|
+
status,
|
|
2180
|
+
React.createElement(
|
|
2181
|
+
'div',
|
|
2182
|
+
{ className: 'dsh-workspace-plus-settings-description' },
|
|
2183
|
+
'标签本身在每个工作区的「工作区目录设置」弹窗里维护:悬停左侧工作区那一行的目录按钮即可打开。',
|
|
2184
|
+
),
|
|
2185
|
+
)
|
|
2186
|
+
}
|
|
2187
|
+
|
|
2188
|
+
/**
|
|
2189
|
+
* The `main` panel that holds the fallback entry's key. The shell's sidebar
|
|
2190
|
+
* button always calls `layout.selectPanel(id)` — which throws for an
|
|
2191
|
+
* unregistered key — so this key must exist even though the intended path is
|
|
2192
|
+
* the dialog.
|
|
2193
|
+
*
|
|
2194
|
+
* @returns the hint panel element.
|
|
2195
|
+
*/
|
|
2196
|
+
function WorkdirsHintPanel() {
|
|
2197
|
+
return React.createElement(
|
|
2198
|
+
'div',
|
|
2199
|
+
{ className: 'dsh-workspace-plus-panel' },
|
|
2200
|
+
React.createElement(
|
|
2201
|
+
'div',
|
|
2202
|
+
{ className: 'dsh-workspace-plus-card' },
|
|
2203
|
+
React.createElement(
|
|
2204
|
+
'div',
|
|
2205
|
+
{ className: 'dsh-workspace-plus-badge' },
|
|
2206
|
+
React.createElement(WorkdirsPanelIcon, { size: 28 }),
|
|
2207
|
+
),
|
|
2208
|
+
React.createElement('h1', { className: 'dsh-workspace-plus-title' }, '工作区目录设置'),
|
|
2209
|
+
React.createElement(
|
|
2210
|
+
'p',
|
|
2211
|
+
{ className: 'dsh-workspace-plus-subtitle' },
|
|
2212
|
+
'这个功能以弹窗形式提供:点击侧边栏工作区那一行的目录按钮即可打开。',
|
|
2213
|
+
),
|
|
2214
|
+
React.createElement(Button, { variant: 'primary', onClick: () => openWorkdirsDialog(null) }, '现在打开'),
|
|
2215
|
+
),
|
|
2216
|
+
)
|
|
2217
|
+
}
|
|
2218
|
+
|
|
2219
|
+
/**
|
|
2220
|
+
* Put the directory button in its seat inside a row's action cluster, and
|
|
2221
|
+
* keep it there across React re-renders.
|
|
2222
|
+
*
|
|
2223
|
+
* The shell renders that cluster as `[「更多」menu, 「新会话」button]` and this
|
|
2224
|
+
* plugin's button belongs between them, so the row reads
|
|
2225
|
+
* 「更多 · 工作区目录设置 · 新会话」and the new-session button stays the last
|
|
2226
|
+
* one. Inserting BEFORE the cluster's last element child is what achieves
|
|
2227
|
+
* that; `insertBefore(node, null)` appends, so a cluster with a single child
|
|
2228
|
+
* degrades to "after the menu" instead of splitting a pair that is not there.
|
|
2229
|
+
*
|
|
2230
|
+
* React re-renders the row without knowing this node exists, and it may
|
|
2231
|
+
* re-insert its own children around ours — so the position is re-asserted on
|
|
2232
|
+
* every reconcile pass rather than set once at creation.
|
|
2233
|
+
*
|
|
2234
|
+
* Re-assert only when the seat actually moved: `insertBefore` on a node that
|
|
2235
|
+
* is already exactly where it belongs still detaches and re-attaches it, and
|
|
2236
|
+
* a real pointer press that spans such a re-seat never becomes a click — the
|
|
2237
|
+
* browser drops the click because the press target was not connected when the
|
|
2238
|
+
* pointer was released. Reconcile runs on every DOM mutation in the frame, so
|
|
2239
|
+
* that detach used to land inside almost every press and the button was
|
|
2240
|
+
* unclickable while still looking (and hit-testing) perfectly fine.
|
|
2241
|
+
*
|
|
2242
|
+
* @param actions - the row's action cluster.
|
|
2243
|
+
* @param button - this plugin's button.
|
|
2244
|
+
*/
|
|
2245
|
+
function seatRowButton(actions, button) {
|
|
2246
|
+
const last = actions.lastElementChild
|
|
2247
|
+
if (last === button) return
|
|
2248
|
+
const anchor = actions.children.length > 1 ? last : null
|
|
2249
|
+
if (button.parentElement === actions && button.nextElementSibling === anchor) return
|
|
2250
|
+
actions.insertBefore(button, anchor)
|
|
2251
|
+
}
|
|
2252
|
+
|
|
2253
|
+
/**
|
|
2254
|
+
* Paint one row button with the switch state of its workspace.
|
|
2255
|
+
*
|
|
2256
|
+
* The button exists for every workspace, so this is also the only place the
|
|
2257
|
+
* switched-off state is visible on the row: the marker class dims the glyph
|
|
2258
|
+
* and the label says why, while the click still opens the dialog — which is
|
|
2259
|
+
* where those labels live and where the user decides what to do next.
|
|
2260
|
+
*
|
|
2261
|
+
* Idempotent, so the reconcile pass can call it on every button it already
|
|
2262
|
+
* owns without touching the DOM when nothing changed.
|
|
2263
|
+
*
|
|
2264
|
+
* @param button - this plugin's button on that row.
|
|
2265
|
+
* @param workspaceId - the id taken from the row's `data-row-key`.
|
|
2266
|
+
*/
|
|
2267
|
+
function markRowButton(button, workspaceId) {
|
|
2268
|
+
const enabled = isEnabledWorkspace(workspaceId)
|
|
2269
|
+
const label = enabled ? ROW_BUTTON_LABEL : ROW_BUTTON_LABEL_OFF
|
|
2270
|
+
button.classList.toggle(ROW_BUTTON_OFF_CLASS, !enabled)
|
|
2271
|
+
if (button.title !== label) button.title = label
|
|
2272
|
+
if (button.getAttribute('aria-label') !== label) button.setAttribute('aria-label', label)
|
|
2273
|
+
}
|
|
2274
|
+
|
|
2275
|
+
/**
|
|
2276
|
+
* Insert the directory button into every workspace row and keep it there.
|
|
2277
|
+
*
|
|
2278
|
+
* The shell's workspace row menu is hardcoded to Rename/Delete and exposes no
|
|
2279
|
+
* slot, so the trigger is injected into the row's action cluster instead. The
|
|
2280
|
+
* anchors are shell-emitted data attributes rather than hashed class names:
|
|
2281
|
+
* `data-slot="sidebar.workspaces"` on the browser outlet and
|
|
2282
|
+
* `data-row-key="workspace:<workspaceId>"` on a group row (that id is
|
|
2283
|
+
* `Workspace.id` from the host registry).
|
|
2284
|
+
*
|
|
2285
|
+
* A MutationObserver reconciles additions, removals, and position, because
|
|
2286
|
+
* React re-renders the row without knowing this node exists.
|
|
2287
|
+
*
|
|
2288
|
+
* Every real workspace row carries the button, including one whose `enabled`
|
|
2289
|
+
* flag is false (the default Workspace before the user turns its switch on,
|
|
2290
|
+
* or a workspace the user switched off). The switch governs whether the
|
|
2291
|
+
* label table runs for that workspace — context injection and the
|
|
2292
|
+
* `workspace_dirs` tool — never whether its management UI can be reached:
|
|
2293
|
+
* the dialog is the only place those labels can be read, and the settings
|
|
2294
|
+
* page is the only place the switch can be flipped, so hiding the trigger
|
|
2295
|
+
* would strand both. A switched-off workspace only changes the button's
|
|
2296
|
+
* tooltip and draws it dimmed, and `refresh()` re-reads the state after a
|
|
2297
|
+
* settings change.
|
|
2298
|
+
*
|
|
2299
|
+
* @param options - the availability callback, fired when the injected count changes.
|
|
2300
|
+
* @returns `{ dispose, available, refresh }`.
|
|
2301
|
+
*/
|
|
2302
|
+
function installWorkspaceRowButtons({ onAvailability } = {}) {
|
|
2303
|
+
if (typeof document === 'undefined' || typeof MutationObserver === 'undefined') {
|
|
2304
|
+
return { dispose: () => {}, available: () => false, refresh: () => {} }
|
|
2305
|
+
}
|
|
2306
|
+
const injected = new Map()
|
|
2307
|
+
let scheduled = false
|
|
2308
|
+
let stopped = false
|
|
2309
|
+
let lastReported = null
|
|
2310
|
+
|
|
2311
|
+
const report = () => {
|
|
2312
|
+
const available = injected.size > 0
|
|
2313
|
+
if (available === lastReported) return
|
|
2314
|
+
lastReported = available
|
|
2315
|
+
if (typeof onAvailability === 'function') onAvailability(available)
|
|
2316
|
+
}
|
|
2317
|
+
|
|
2318
|
+
const reconcile = () => {
|
|
2319
|
+
scheduled = false
|
|
2320
|
+
if (stopped) return
|
|
2321
|
+
const container = document.querySelector(SIDEBAR_SLOT_SELECTOR)
|
|
2322
|
+
// Nothing is mounted yet: reporting here would flash the fallback entry.
|
|
2323
|
+
if (container === null) return
|
|
2324
|
+
const rows = container.querySelectorAll(WORKSPACE_ROW_SELECTOR)
|
|
2325
|
+
const live = new Set(rows)
|
|
2326
|
+
for (const [row, button] of [...injected]) {
|
|
2327
|
+
const workspaceId = (row.getAttribute('data-row-key') ?? '').slice(ROW_KEY_PREFIX.length)
|
|
2328
|
+
const stillWanted = live.has(row) && row.isConnected && workspaceId !== ''
|
|
2329
|
+
if (stillWanted) continue
|
|
2330
|
+
button.remove()
|
|
2331
|
+
injected.delete(row)
|
|
2332
|
+
}
|
|
2333
|
+
for (const row of rows) {
|
|
2334
|
+
const workspaceId = (row.getAttribute('data-row-key') ?? '').slice(ROW_KEY_PREFIX.length)
|
|
2335
|
+
// The synthetic "ungrouped" row carries the empty key and owns no workspace.
|
|
2336
|
+
if (workspaceId === '') continue
|
|
2337
|
+
const actions = row.lastElementChild
|
|
2338
|
+
if (actions === null || actions === undefined) continue
|
|
2339
|
+
const existing = injected.get(row)
|
|
2340
|
+
if (existing !== undefined && existing.isConnected) {
|
|
2341
|
+
// Already there: only its seat and its switch state may have moved.
|
|
2342
|
+
seatRowButton(actions, existing)
|
|
2343
|
+
markRowButton(existing, workspaceId)
|
|
2344
|
+
continue
|
|
2345
|
+
}
|
|
2346
|
+
const button = document.createElement('button')
|
|
2347
|
+
button.type = 'button'
|
|
2348
|
+
button.className = ROW_BUTTON_CLASS
|
|
2349
|
+
button.title = ROW_BUTTON_LABEL
|
|
2350
|
+
button.setAttribute('aria-label', ROW_BUTTON_LABEL)
|
|
2351
|
+
button.innerHTML = ROW_BUTTON_GLYPH
|
|
2352
|
+
/**
|
|
2353
|
+
* Open the dialog for the row the button currently sits on. The key is
|
|
2354
|
+
* read here rather than captured, because the button outlives a row
|
|
2355
|
+
* whose key changes.
|
|
2356
|
+
*/
|
|
2357
|
+
const activate = (event) => {
|
|
2358
|
+
// Keep the activation off the row, whose own handler toggles the group.
|
|
2359
|
+
event.preventDefault()
|
|
2360
|
+
event.stopPropagation()
|
|
2361
|
+
const current = (row.getAttribute('data-row-key') ?? '').slice(ROW_KEY_PREFIX.length)
|
|
2362
|
+
openWorkdirsDialog(current === '' ? null : current)
|
|
2363
|
+
}
|
|
2364
|
+
button.addEventListener('click', activate)
|
|
2365
|
+
/**
|
|
2366
|
+
* A press can still lose its click when the shell's React replaces part
|
|
2367
|
+
* of the row mid-press (the browser then drops the click, because the
|
|
2368
|
+
* press target is no longer connected at release). The release itself
|
|
2369
|
+
* always arrives, so it opens the same dialog; a following click is a
|
|
2370
|
+
* no-op because the store ignores a repeated identical open.
|
|
2371
|
+
*
|
|
2372
|
+
* Touch is excluded: a scroll-drag that happens to start on this 16px
|
|
2373
|
+
* button delivers its release here through implicit pointer capture,
|
|
2374
|
+
* and opening the dialog then would be wrong. Taps still open it
|
|
2375
|
+
* through the click that follows.
|
|
2376
|
+
*/
|
|
2377
|
+
button.addEventListener('pointerup', (event) => {
|
|
2378
|
+
if (event.button === 0 && event.pointerType !== 'touch') activate(event)
|
|
2379
|
+
})
|
|
2380
|
+
markRowButton(button, workspaceId)
|
|
2381
|
+
seatRowButton(actions, button)
|
|
2382
|
+
injected.set(row, button)
|
|
2383
|
+
}
|
|
2384
|
+
report()
|
|
2385
|
+
}
|
|
2386
|
+
|
|
2387
|
+
const schedule = () => {
|
|
2388
|
+
if (scheduled || stopped) return
|
|
2389
|
+
scheduled = true
|
|
2390
|
+
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(reconcile)
|
|
2391
|
+
else setTimeout(reconcile, 16)
|
|
2392
|
+
}
|
|
2393
|
+
|
|
2394
|
+
const observer = new MutationObserver(schedule)
|
|
2395
|
+
observer.observe(document.body, { childList: true, subtree: true })
|
|
2396
|
+
reconcile()
|
|
2397
|
+
|
|
2398
|
+
return {
|
|
2399
|
+
available: () => injected.size > 0,
|
|
2400
|
+
refresh: () => {
|
|
2401
|
+
if (stopped) return
|
|
2402
|
+
reconcile()
|
|
2403
|
+
},
|
|
2404
|
+
dispose: () => {
|
|
2405
|
+
stopped = true
|
|
2406
|
+
observer.disconnect()
|
|
2407
|
+
for (const button of injected.values()) button.remove()
|
|
2408
|
+
injected.clear()
|
|
2409
|
+
},
|
|
2410
|
+
}
|
|
2411
|
+
}
|
|
2412
|
+
|
|
2413
|
+
// -----------------------------------------------------------------------
|
|
2414
|
+
// Plugin
|
|
2415
|
+
// -----------------------------------------------------------------------
|
|
2416
|
+
|
|
2417
|
+
/**
|
|
2418
|
+
* Services this browser plugin needs. `slots` is required to contribute
|
|
2419
|
+
* anything at all; the sidebar buttons and the panel selection are owned by
|
|
2420
|
+
* the shell, so this half never touches `ctx.layout` itself.
|
|
2421
|
+
*/
|
|
2422
|
+
const inject = ['slots']
|
|
2423
|
+
|
|
2424
|
+
/**
|
|
2425
|
+
* Contribute the `workspace+` settings page, the directory dialog, its
|
|
2426
|
+
* fallback entry, and the workspace-row buttons that open it — plus the two
|
|
2427
|
+
* client faces of the 通知 sub-feature (its settings page and the
|
|
2428
|
+
* pending-open poll behind notification clicks).
|
|
2429
|
+
*
|
|
2430
|
+
* Every registration goes through `ctx.slots.inject(key, callback)`: the
|
|
2431
|
+
* callback runs as soon as the shell declares that slot (immediately when it
|
|
2432
|
+
* already exists), and its returned disposer is owned by this plugin's fiber,
|
|
2433
|
+
* so unloading the plugin removes the entry again.
|
|
2434
|
+
*
|
|
2435
|
+
* @param ctx - the browser plugin context.
|
|
2436
|
+
*/
|
|
2437
|
+
function apply(ctx) {
|
|
2438
|
+
// The settings page owns the default-Workspace switch.
|
|
2439
|
+
ctx.effect(
|
|
2440
|
+
() =>
|
|
2441
|
+
ctx.slots.inject('settings.section', () =>
|
|
2442
|
+
ctx.slots.register(
|
|
2443
|
+
{
|
|
2444
|
+
name: 'settings.section',
|
|
2445
|
+
id: SETTINGS_SECTION_ID,
|
|
2446
|
+
order: SETTINGS_SECTION_ORDER,
|
|
2447
|
+
label: SETTINGS_SECTION_LABEL,
|
|
2448
|
+
},
|
|
2449
|
+
WorkspacePlusSettings,
|
|
2450
|
+
),
|
|
2451
|
+
),
|
|
2452
|
+
'dsh-workspace-plus: settings section',
|
|
2453
|
+
)
|
|
2454
|
+
|
|
2455
|
+
// The 通知 sub-feature. Both faces are guarded by `registerNotice`: a
|
|
2456
|
+
// failure is reported and skipped instead of failing the fiber, because
|
|
2457
|
+
// the shell's boot audit treats every non-ACTIVE entry as fatal and the
|
|
2458
|
+
// workspace+ features must survive a notice-side mistake. The poll runs
|
|
2459
|
+
// unconditionally — clicking an older notification must still open the
|
|
2460
|
+
// conversation even while the notice ROW is off (its host half answers
|
|
2461
|
+
// 404, the poll backs off quietly). The settings page lives in the same
|
|
2462
|
+
// slot with the id/order dsh-notice used, so it reads as the same page.
|
|
2463
|
+
registerNotice(ctx, '通知轮询', () => ctx.effect(() => startNoticePendingPoll(ctx), 'dsh-workspace-plus/notice: pending open poll'))
|
|
2464
|
+
registerNotice(ctx, '通知设置页', () =>
|
|
2465
|
+
ctx.effect(
|
|
2466
|
+
() =>
|
|
2467
|
+
ctx.slots.inject('settings.section', () =>
|
|
2468
|
+
ctx.slots.register(
|
|
2469
|
+
{
|
|
2470
|
+
name: 'settings.section',
|
|
2471
|
+
id: NOTICE_SETTINGS_SECTION_ID,
|
|
2472
|
+
order: NOTICE_SETTINGS_SECTION_ORDER,
|
|
2473
|
+
label: NOTICE_SETTINGS_SECTION_LABEL,
|
|
2474
|
+
},
|
|
2475
|
+
NoticeSettings,
|
|
2476
|
+
),
|
|
2477
|
+
),
|
|
2478
|
+
'dsh-workspace-plus/notice: settings section',
|
|
2479
|
+
),
|
|
2480
|
+
)
|
|
2481
|
+
|
|
2482
|
+
// The dialog lives in the frame-wide overlay: the same seat the shell's own
|
|
2483
|
+
// workspace Rename/Delete dialogs occupy.
|
|
2484
|
+
ctx.effect(
|
|
2485
|
+
() =>
|
|
2486
|
+
ctx.slots.inject('shell.overlay', () =>
|
|
2487
|
+
ctx.slots.register({ name: 'shell.overlay', id: 'workdirs-dialog', order: 50 }, WorkdirsDialog),
|
|
2488
|
+
),
|
|
2489
|
+
'dsh-workspace-plus: workdirs dialog',
|
|
2490
|
+
)
|
|
2491
|
+
|
|
2492
|
+
// The fallback entry's main key always exists; the entry itself is
|
|
2493
|
+
// registered only while no workspace-row button could be installed.
|
|
2494
|
+
ctx.effect(
|
|
2495
|
+
() =>
|
|
2496
|
+
ctx.slots.inject('main', () =>
|
|
2497
|
+
ctx.slots.register({ name: 'main', key: WORKDIRS_PANEL_ID }, WorkdirsHintPanel),
|
|
2498
|
+
),
|
|
2499
|
+
'dsh-workspace-plus: workdirs fallback panel',
|
|
2500
|
+
)
|
|
2501
|
+
|
|
2502
|
+
// The dialog's browse button asks the shell's own picker. Injected
|
|
2503
|
+
// optionally: a composition without ui-workspace still loads this plugin,
|
|
2504
|
+
// the button just stays disabled.
|
|
2505
|
+
ctx.effect(
|
|
2506
|
+
() =>
|
|
2507
|
+
ctx.inject(['uiWorkspace'], (uiScope) => {
|
|
2508
|
+
directoryPicker = uiScope.uiWorkspace
|
|
2509
|
+
return () => {
|
|
2510
|
+
directoryPicker = null
|
|
2511
|
+
}
|
|
2512
|
+
}),
|
|
2513
|
+
'dsh-workspace-plus: directory picker',
|
|
2514
|
+
)
|
|
2515
|
+
|
|
2516
|
+
// The `@` source and the session list it keys off. Both are injected
|
|
2517
|
+
// optionally: without them the composer simply offers no label group.
|
|
2518
|
+
ctx.inject(['sessions'], (sessionScope) => {
|
|
2519
|
+
sessionScope.effect(
|
|
2520
|
+
() => {
|
|
2521
|
+
sessionsService = sessionScope.sessions
|
|
2522
|
+
return () => {
|
|
2523
|
+
sessionsService = null
|
|
2524
|
+
}
|
|
2525
|
+
},
|
|
2526
|
+
'dsh-workspace-plus: session list',
|
|
2527
|
+
)
|
|
2528
|
+
})
|
|
2529
|
+
|
|
2530
|
+
ctx.inject(['inputTriggers'], (triggerScope) => {
|
|
2531
|
+
triggerScope.effect(
|
|
2532
|
+
() => {
|
|
2533
|
+
const dispose = triggerScope.inputTriggers.registerSource(createLabelSource())
|
|
2534
|
+
labelSourceRegistered = true
|
|
2535
|
+
return () => {
|
|
2536
|
+
labelSourceRegistered = false
|
|
2537
|
+
dispose()
|
|
2538
|
+
}
|
|
2539
|
+
},
|
|
2540
|
+
'dsh-workspace-plus: @ label source',
|
|
2541
|
+
)
|
|
2542
|
+
})
|
|
2543
|
+
|
|
2544
|
+
let registerEntry = null
|
|
2545
|
+
let entryDispose = null
|
|
2546
|
+
let rowEntryAvailable = false
|
|
2547
|
+
let settled = false
|
|
2548
|
+
|
|
2549
|
+
const syncEntry = () => {
|
|
2550
|
+
if (registerEntry === null) return
|
|
2551
|
+
const wantFallback = !rowEntryAvailable && settled
|
|
2552
|
+
if (wantFallback && entryDispose === null) entryDispose = registerEntry()
|
|
2553
|
+
if (!wantFallback && entryDispose !== null) {
|
|
2554
|
+
entryDispose()
|
|
2555
|
+
entryDispose = null
|
|
2556
|
+
}
|
|
2557
|
+
}
|
|
2558
|
+
|
|
2559
|
+
ctx.effect(
|
|
2560
|
+
() =>
|
|
2561
|
+
ctx.slots.inject('sidebar.panellist', () => {
|
|
2562
|
+
registerEntry = () =>
|
|
2563
|
+
ctx.slots.register(
|
|
2564
|
+
{
|
|
2565
|
+
name: 'sidebar.panellist',
|
|
2566
|
+
id: WORKDIRS_PANEL_ID,
|
|
2567
|
+
order: WORKDIRS_PANEL_ORDER,
|
|
2568
|
+
label: WORKDIRS_PANEL_LABEL,
|
|
2569
|
+
},
|
|
2570
|
+
WorkdirsEntryIcon,
|
|
2571
|
+
)
|
|
2572
|
+
syncEntry()
|
|
2573
|
+
return () => {
|
|
2574
|
+
registerEntry = null
|
|
2575
|
+
if (entryDispose !== null) {
|
|
2576
|
+
entryDispose()
|
|
2577
|
+
entryDispose = null
|
|
2578
|
+
}
|
|
2579
|
+
}
|
|
2580
|
+
}),
|
|
2581
|
+
'dsh-workspace-plus: workdirs fallback entry',
|
|
2582
|
+
)
|
|
2583
|
+
|
|
2584
|
+
const settleTimer = setTimeout(() => {
|
|
2585
|
+
settled = true
|
|
2586
|
+
syncEntry()
|
|
2587
|
+
}, FALLBACK_SETTLE_MS)
|
|
2588
|
+
|
|
2589
|
+
const rowButtons = installWorkspaceRowButtons({
|
|
2590
|
+
onAvailability: (available) => {
|
|
2591
|
+
rowEntryAvailable = available
|
|
2592
|
+
settled = true
|
|
2593
|
+
syncEntry()
|
|
2594
|
+
},
|
|
2595
|
+
})
|
|
2596
|
+
|
|
2597
|
+
// Warm the shared snapshot at boot and keep every row button in step with
|
|
2598
|
+
// the settings: a switch flipped in the settings page re-marks its button
|
|
2599
|
+
// (dimmed, with the reason in its tooltip) without a reload.
|
|
2600
|
+
const offHostState = subscribeHostState(() => rowButtons.refresh())
|
|
2601
|
+
void refreshHostState()
|
|
2602
|
+
|
|
2603
|
+
ctx.effect(
|
|
2604
|
+
() => () => {
|
|
2605
|
+
clearTimeout(settleTimer)
|
|
2606
|
+
offHostState()
|
|
2607
|
+
rowButtons.dispose()
|
|
2608
|
+
},
|
|
2609
|
+
'dsh-workspace-plus: workspace row buttons',
|
|
2610
|
+
)
|
|
2611
|
+
}
|
|
2612
|
+
|
|
2613
|
+
exports.WORKDIRS_PANEL_ID = WORKDIRS_PANEL_ID
|
|
2614
|
+
exports.WORKDIRS_PANEL_LABEL = WORKDIRS_PANEL_LABEL
|
|
2615
|
+
exports.SETTINGS_SECTION_ID = SETTINGS_SECTION_ID
|
|
2616
|
+
exports.SETTINGS_SECTION_LABEL = SETTINGS_SECTION_LABEL
|
|
2617
|
+
exports.SETTINGS_SECTION_ORDER = SETTINGS_SECTION_ORDER
|
|
2618
|
+
exports.NOTICE_SETTINGS_SECTION_ID = NOTICE_SETTINGS_SECTION_ID
|
|
2619
|
+
exports.NOTICE_SETTINGS_SECTION_LABEL = NOTICE_SETTINGS_SECTION_LABEL
|
|
2620
|
+
exports.NOTICE_SETTINGS_SECTION_ORDER = NOTICE_SETTINGS_SECTION_ORDER
|
|
2621
|
+
exports.NOTICE_API_PATH = NOTICE_API_PATH
|
|
2622
|
+
exports.NOTICE_PENDING_PATH = NOTICE_PENDING_PATH
|
|
2623
|
+
exports.NOTICE_POLL_INTERVAL_MS = NOTICE_POLL_INTERVAL_MS
|
|
2624
|
+
exports.NOTICE_POLL_RETRY_MS = NOTICE_POLL_RETRY_MS
|
|
2625
|
+
exports.fetchNoticeSnapshot = fetchNoticeSnapshot
|
|
2626
|
+
exports.postNoticeOperation = postNoticeOperation
|
|
2627
|
+
exports.NoticeSwitchRow = NoticeSwitchRow
|
|
2628
|
+
exports.NoticeSettings = NoticeSettings
|
|
2629
|
+
exports.startNoticePendingPoll = startNoticePendingPoll
|
|
2630
|
+
exports.registerNotice = registerNotice
|
|
2631
|
+
exports.ensureNoticeStyles = ensureNoticeStyles
|
|
2632
|
+
exports.DEFAULT_WORKSPACE_SETTING = DEFAULT_WORKSPACE_SETTING
|
|
2633
|
+
exports.WORKSPACE_ENABLED_SETTING = WORKSPACE_ENABLED_SETTING
|
|
2634
|
+
exports.API_PATH = API_PATH
|
|
2635
|
+
exports.ROW_BUTTON_CLASS = ROW_BUTTON_CLASS
|
|
2636
|
+
exports.ROW_BUTTON_OFF_CLASS = ROW_BUTTON_OFF_CLASS
|
|
2637
|
+
exports.ROW_BUTTON_LABEL = ROW_BUTTON_LABEL
|
|
2638
|
+
exports.ROW_BUTTON_LABEL_OFF = ROW_BUTTON_LABEL_OFF
|
|
2639
|
+
exports.SIDEBAR_SLOT_SELECTOR = SIDEBAR_SLOT_SELECTOR
|
|
2640
|
+
exports.WORKSPACE_ROW_SELECTOR = WORKSPACE_ROW_SELECTOR
|
|
2641
|
+
exports.FALLBACK_SETTLE_MS = FALLBACK_SETTLE_MS
|
|
2642
|
+
exports.readDialog = readDialog
|
|
2643
|
+
exports.subscribeDialog = subscribeDialog
|
|
2644
|
+
exports.openWorkdirsDialog = openWorkdirsDialog
|
|
2645
|
+
exports.closeWorkdirsDialog = closeWorkdirsDialog
|
|
2646
|
+
exports.readHostState = readHostState
|
|
2647
|
+
exports.subscribeHostState = subscribeHostState
|
|
2648
|
+
exports.publishHostPayload = publishHostPayload
|
|
2649
|
+
exports.isEnabledWorkspace = isEnabledWorkspace
|
|
2650
|
+
exports.SOURCE_NAME = SOURCE_NAME
|
|
2651
|
+
exports.SOURCE_SECTION = SOURCE_SECTION
|
|
2652
|
+
exports.samePath = samePath
|
|
2653
|
+
exports.sessionCwd = sessionCwd
|
|
2654
|
+
exports.workspaceForSession = workspaceForSession
|
|
2655
|
+
exports.parseLabelValue = parseLabelValue
|
|
2656
|
+
exports.createLabelSource = createLabelSource
|
|
2657
|
+
exports.labelSourceStatus = labelSourceStatus
|
|
2658
|
+
exports.selectWorkspace = selectWorkspace
|
|
2659
|
+
exports.isOutside = isOutside
|
|
2660
|
+
exports.basenameOf = basenameOf
|
|
2661
|
+
exports.suggestLabel = suggestLabel
|
|
2662
|
+
exports.draftAfterPick = draftAfterPick
|
|
2663
|
+
exports.pickErrorMessage = pickErrorMessage
|
|
2664
|
+
exports.installWorkspaceRowButtons = installWorkspaceRowButtons
|
|
2665
|
+
exports.seatRowButton = seatRowButton
|
|
2666
|
+
exports.markRowButton = markRowButton
|
|
2667
|
+
exports.WorkdirsPanelIcon = WorkdirsPanelIcon
|
|
2668
|
+
exports.WorkdirsEntryIcon = WorkdirsEntryIcon
|
|
2669
|
+
exports.WorkdirsDialog = WorkdirsDialog
|
|
2670
|
+
exports.WorkdirsHintPanel = WorkdirsHintPanel
|
|
2671
|
+
exports.WorkspacePlusSettings = WorkspacePlusSettings
|
|
2672
|
+
exports.apply = apply
|
|
2673
|
+
exports.inject = inject
|
|
2674
|
+
return module.exports
|
|
2675
|
+
},
|
|
2676
|
+
})
|