@fonlan/dsh-quick-commands 0.1.4 → 0.1.6
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/lib/client.js +110 -74
- package/lib/client.js.map +1 -1
- package/package.json +1 -1
- package/src/client/header.tsx +89 -34
- package/src/client/index.tsx +15 -14
- package/src/client/locales.ts +7 -7
- package/src/client/quick-commands.css +25 -190
- package/src/client/settings-section.css +163 -0
- package/src/client/settings-section.tsx +195 -0
- package/src/client/settings-card.tsx +0 -209
package/src/client/locales.ts
CHANGED
|
@@ -37,10 +37,10 @@ export const zh = {
|
|
|
37
37
|
runLost: '运行记录已失效',
|
|
38
38
|
runNotFound: '工作区或命令未找到',
|
|
39
39
|
|
|
40
|
-
// settings
|
|
40
|
+
// settings page (设置 → 侧栏「快捷命令」)
|
|
41
|
+
settingsTitle: '快捷命令',
|
|
41
42
|
cardTitle: '快速命令',
|
|
42
|
-
|
|
43
|
-
collapseOrExpand: '展开 / 收起',
|
|
43
|
+
sectionSub: '为每个工作区配置常用命令,会话头部一键运行',
|
|
44
44
|
cardIntro: '为每个工作区配置常用命令,运行于工作区目录。支持占位符:{workspace} 工作区路径,{cwd} 会话目录,{title} 工作区标题。',
|
|
45
45
|
cardWorkspaces: '工作区',
|
|
46
46
|
cardNoWorkspaces: '没有工作区。请在侧边栏先添加工作区。',
|
|
@@ -54,9 +54,9 @@ export const zh = {
|
|
|
54
54
|
cardAnchor: '输出弹窗位置',
|
|
55
55
|
cardAnchorCorner: '右下角',
|
|
56
56
|
cardAnchorButton: '锚定按钮下方',
|
|
57
|
-
}
|
|
57
|
+
} as const
|
|
58
58
|
|
|
59
|
-
export const en = {
|
|
59
|
+
export const en: Record<keyof typeof zh, string> = {
|
|
60
60
|
buttonTitle: 'Quick commands',
|
|
61
61
|
buttonTooltip: 'Quick commands (current workspace)',
|
|
62
62
|
noWorkspace: 'This session is not in any configured workspace',
|
|
@@ -87,9 +87,9 @@ export const en = {
|
|
|
87
87
|
runLost: 'Run record expired',
|
|
88
88
|
runNotFound: 'Workspace or command not found',
|
|
89
89
|
|
|
90
|
+
settingsTitle: 'Quick Commands',
|
|
90
91
|
cardTitle: 'Quick commands',
|
|
91
|
-
|
|
92
|
-
collapseOrExpand: 'Expand / collapse',
|
|
92
|
+
sectionSub: 'Configure common commands per workspace, run them from the session header',
|
|
93
93
|
cardIntro: 'Configure common commands per workspace; they run in the workspace directory. Placeholders: {workspace} workspace path, {cwd} session cwd, {title} workspace title.',
|
|
94
94
|
cardWorkspaces: 'Workspaces',
|
|
95
95
|
cardNoWorkspaces: 'No workspaces. Add one in the sidebar first.',
|
|
@@ -370,196 +370,6 @@
|
|
|
370
370
|
opacity: 1;
|
|
371
371
|
}
|
|
372
372
|
|
|
373
|
-
/* ── settings card (设置 → 插件配置) — mirrors official PluginCard disclosure ── */
|
|
374
|
-
.qc-settings-card {
|
|
375
|
-
border: 1px solid var(--dsw-alias-border-l2, rgba(128, 128, 128, 0.35));
|
|
376
|
-
background: var(--dsw-alias-bg-layer-2, #1f1f21);
|
|
377
|
-
border-radius: 12px;
|
|
378
|
-
list-style: none;
|
|
379
|
-
transition: border-color .16s, background .16s;
|
|
380
|
-
}
|
|
381
|
-
.qc-settings-card:hover {
|
|
382
|
-
border-color: var(--dsw-alias-label-secondary, #cfd3d6);
|
|
383
|
-
}
|
|
384
|
-
.qc-settings-card-open {
|
|
385
|
-
background: var(--dsw-alias-bg-layer-1, #27272a);
|
|
386
|
-
border-color: var(--dsw-alias-label-secondary, #cfd3d6);
|
|
387
|
-
}
|
|
388
|
-
.qc-settings-header {
|
|
389
|
-
appearance: none;
|
|
390
|
-
width: 100%;
|
|
391
|
-
font: inherit;
|
|
392
|
-
color: inherit;
|
|
393
|
-
text-align: left;
|
|
394
|
-
cursor: pointer;
|
|
395
|
-
background: transparent;
|
|
396
|
-
border: 0;
|
|
397
|
-
border-radius: 12px;
|
|
398
|
-
align-items: center;
|
|
399
|
-
gap: 12px;
|
|
400
|
-
padding: 14px 16px;
|
|
401
|
-
display: flex;
|
|
402
|
-
}
|
|
403
|
-
.qc-settings-header:focus-visible {
|
|
404
|
-
outline: 2px solid var(--dsw-alias-brand-primary, #4a9eff);
|
|
405
|
-
outline-offset: -2px;
|
|
406
|
-
}
|
|
407
|
-
.qc-settings-head-text {
|
|
408
|
-
flex-direction: column;
|
|
409
|
-
flex: 1;
|
|
410
|
-
gap: 4px;
|
|
411
|
-
min-width: 0;
|
|
412
|
-
display: flex;
|
|
413
|
-
}
|
|
414
|
-
.qc-settings-name {
|
|
415
|
-
color: var(--dsw-alias-label-primary, #f2f2f2);
|
|
416
|
-
font-size: 15px;
|
|
417
|
-
font-weight: 600;
|
|
418
|
-
line-height: 1.4;
|
|
419
|
-
}
|
|
420
|
-
.qc-settings-description {
|
|
421
|
-
color: var(--dsw-alias-label-secondary, #cfd3d6);
|
|
422
|
-
font-size: 13px;
|
|
423
|
-
line-height: 1.5;
|
|
424
|
-
}
|
|
425
|
-
.qc-settings-chevron {
|
|
426
|
-
color: var(--dsw-alias-label-secondary, #cfd3d6);
|
|
427
|
-
flex: none;
|
|
428
|
-
transition: transform .16s;
|
|
429
|
-
}
|
|
430
|
-
.qc-settings-chevron-open {
|
|
431
|
-
transform: rotate(180deg);
|
|
432
|
-
}
|
|
433
|
-
.qc-settings-body {
|
|
434
|
-
border-top: 1px solid var(--dsw-alias-border-l2, rgba(128, 128, 128, 0.35));
|
|
435
|
-
margin: 0 16px;
|
|
436
|
-
padding-bottom: 12px;
|
|
437
|
-
display: flex;
|
|
438
|
-
flex-direction: column;
|
|
439
|
-
gap: 10px;
|
|
440
|
-
}
|
|
441
|
-
.qc-settings-intro {
|
|
442
|
-
font-size: 12px;
|
|
443
|
-
color: var(--dsw-alias-label-secondary, #cfd3d6);
|
|
444
|
-
margin: 8px 0 0;
|
|
445
|
-
line-height: 1.5;
|
|
446
|
-
}
|
|
447
|
-
.qc-settings-anchor {
|
|
448
|
-
display: flex;
|
|
449
|
-
align-items: center;
|
|
450
|
-
gap: 12px;
|
|
451
|
-
font-size: 12px;
|
|
452
|
-
}
|
|
453
|
-
.qc-settings-anchor-label {
|
|
454
|
-
color: var(--dsw-alias-label-primary, #f2f2f2);
|
|
455
|
-
font-weight: 500;
|
|
456
|
-
}
|
|
457
|
-
.qc-settings-radio {
|
|
458
|
-
display: inline-flex;
|
|
459
|
-
align-items: center;
|
|
460
|
-
gap: 4px;
|
|
461
|
-
color: var(--dsw-alias-label-secondary, #cfd3d6);
|
|
462
|
-
cursor: pointer;
|
|
463
|
-
}
|
|
464
|
-
.qc-settings-error {
|
|
465
|
-
font-size: 12px;
|
|
466
|
-
color: var(--dsw-alias-state-error-primary, #e5484d);
|
|
467
|
-
}
|
|
468
|
-
.qc-settings-empty {
|
|
469
|
-
font-size: 12px;
|
|
470
|
-
color: var(--dsw-alias-label-secondary, #cfd3d6);
|
|
471
|
-
margin: 0;
|
|
472
|
-
}
|
|
473
|
-
.qc-settings-workspace {
|
|
474
|
-
border: 1px solid var(--dsw-alias-border-l2, rgba(128, 128, 128, 0.35));
|
|
475
|
-
border-radius: 10px;
|
|
476
|
-
padding: 10px;
|
|
477
|
-
display: flex;
|
|
478
|
-
flex-direction: column;
|
|
479
|
-
gap: 8px;
|
|
480
|
-
}
|
|
481
|
-
.qc-settings-workspace-head {
|
|
482
|
-
display: flex;
|
|
483
|
-
align-items: baseline;
|
|
484
|
-
gap: 8px;
|
|
485
|
-
}
|
|
486
|
-
.qc-settings-workspace-title {
|
|
487
|
-
font-size: 13px;
|
|
488
|
-
font-weight: 600;
|
|
489
|
-
color: var(--dsw-alias-label-primary, #f2f2f2);
|
|
490
|
-
}
|
|
491
|
-
.qc-settings-workspace-path {
|
|
492
|
-
font-size: 11px;
|
|
493
|
-
font-family: var(--dsw-font-family-mono, monospace);
|
|
494
|
-
color: var(--dsw-alias-label-tertiary, #adb2b8);
|
|
495
|
-
overflow: hidden;
|
|
496
|
-
text-overflow: ellipsis;
|
|
497
|
-
white-space: nowrap;
|
|
498
|
-
}
|
|
499
|
-
.qc-settings-no-commands {
|
|
500
|
-
font-size: 12px;
|
|
501
|
-
color: var(--dsw-alias-label-tertiary, #adb2b8);
|
|
502
|
-
margin: 0;
|
|
503
|
-
}
|
|
504
|
-
.qc-settings-command {
|
|
505
|
-
display: flex;
|
|
506
|
-
gap: 6px;
|
|
507
|
-
align-items: center;
|
|
508
|
-
}
|
|
509
|
-
.qc-settings-field {
|
|
510
|
-
border: 1px solid var(--dsw-alias-border-l2, rgba(128, 128, 128, 0.35));
|
|
511
|
-
border-radius: 8px;
|
|
512
|
-
background: var(--dsw-alias-bg-layer-1, #2a2a2d);
|
|
513
|
-
color: var(--dsw-alias-label-primary, #f2f2f2);
|
|
514
|
-
font-size: 12px;
|
|
515
|
-
padding: 6px 8px;
|
|
516
|
-
outline: none;
|
|
517
|
-
}
|
|
518
|
-
.qc-settings-field::placeholder {
|
|
519
|
-
color: var(--dsw-alias-label-tertiary, #adb2b8);
|
|
520
|
-
}
|
|
521
|
-
.qc-settings-field:focus {
|
|
522
|
-
border-color: var(--dsw-alias-brand-primary, #4a9eff);
|
|
523
|
-
}
|
|
524
|
-
.qc-settings-field-name {
|
|
525
|
-
width: 160px;
|
|
526
|
-
flex: none;
|
|
527
|
-
}
|
|
528
|
-
.qc-settings-field-command {
|
|
529
|
-
flex: 1;
|
|
530
|
-
font-family: var(--dsw-font-family-mono, monospace);
|
|
531
|
-
}
|
|
532
|
-
.qc-settings-icon-btn {
|
|
533
|
-
border: 0;
|
|
534
|
-
background: transparent;
|
|
535
|
-
color: var(--dsw-alias-label-tertiary, #adb2b8);
|
|
536
|
-
cursor: pointer;
|
|
537
|
-
padding: 4px;
|
|
538
|
-
display: inline-flex;
|
|
539
|
-
align-items: center;
|
|
540
|
-
border-radius: 6px;
|
|
541
|
-
}
|
|
542
|
-
.qc-settings-icon-btn:hover {
|
|
543
|
-
background: color-mix(in srgb, var(--dsw-alias-label-primary, #f2f2f2) 10%, transparent);
|
|
544
|
-
color: var(--dsw-alias-state-error-primary, #e5484d);
|
|
545
|
-
}
|
|
546
|
-
.qc-settings-add-btn {
|
|
547
|
-
align-self: flex-start;
|
|
548
|
-
display: inline-flex;
|
|
549
|
-
align-items: center;
|
|
550
|
-
gap: 4px;
|
|
551
|
-
border: 1px solid var(--dsw-alias-border-l2, rgba(128, 128, 128, 0.35));
|
|
552
|
-
border-radius: 8px;
|
|
553
|
-
background: transparent;
|
|
554
|
-
color: var(--dsw-alias-label-primary, #f2f2f2);
|
|
555
|
-
font-size: 12px;
|
|
556
|
-
padding: 4px 10px;
|
|
557
|
-
cursor: pointer;
|
|
558
|
-
}
|
|
559
|
-
.qc-settings-add-btn:hover {
|
|
560
|
-
background: color-mix(in srgb, var(--dsw-alias-label-primary, #f2f2f2) 10%, transparent);
|
|
561
|
-
}
|
|
562
|
-
|
|
563
373
|
/* ── mobile fallback (dsh-mobile-nav) ──────────────────────────────────────
|
|
564
374
|
The dsh-mobile-nav plugin (≤1023px) hides the ENTIRE header utilities seat
|
|
565
375
|
with `[data-mobile-nav="frame"] [data-phase] header > :first-child >
|
|
@@ -581,3 +391,28 @@
|
|
|
581
391
|
[data-slot="conversation.session.header.utilities"] > :not(.qc-head-wrap) {
|
|
582
392
|
display: none !important;
|
|
583
393
|
}
|
|
394
|
+
|
|
395
|
+
/* ── viewport-level command menu ─────────────────────────────────────────
|
|
396
|
+
The phone shell's nav rail creates a sibling stacking context and its
|
|
397
|
+
center column can clip header descendants. The React menu therefore mounts
|
|
398
|
+
in document.body; these styles deliberately detach it from that header
|
|
399
|
+
context. Pixel coordinates (including the 56px rail exclusion) are set by
|
|
400
|
+
positionMenu() in header.tsx. */
|
|
401
|
+
.qc-menu-portal {
|
|
402
|
+
position: fixed;
|
|
403
|
+
right: auto;
|
|
404
|
+
z-index: 1100;
|
|
405
|
+
box-sizing: border-box;
|
|
406
|
+
max-height: min(420px, calc(100dvh - 72px));
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
@media (max-width: 767px) {
|
|
410
|
+
/* A 44px target lets a fingertip reliably activate both the stop and close
|
|
411
|
+
buttons without changing the desktop header density. */
|
|
412
|
+
body[data-dsh-mobile] .qc-popup-icon,
|
|
413
|
+
[data-mobile-nav="frame"] .qc-popup-icon {
|
|
414
|
+
min-width: 44px;
|
|
415
|
+
min-height: 44px;
|
|
416
|
+
justify-content: center;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Styles for the quick-commands settings page.
|
|
3
|
+
*
|
|
4
|
+
* The page occupies the settings panel content column: a static header
|
|
5
|
+
* (title plus a one-line summary) over the always-visible command editor.
|
|
6
|
+
* Inner controls follow the built-in settings language.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/* page layout */
|
|
10
|
+
|
|
11
|
+
.qc-page {
|
|
12
|
+
display: flex;
|
|
13
|
+
flex-direction: column;
|
|
14
|
+
gap: 16px;
|
|
15
|
+
max-width: 720px;
|
|
16
|
+
}
|
|
17
|
+
.qc-page-head {
|
|
18
|
+
display: flex;
|
|
19
|
+
flex-direction: column;
|
|
20
|
+
gap: 6px;
|
|
21
|
+
}
|
|
22
|
+
.qc-page-title {
|
|
23
|
+
margin: 0;
|
|
24
|
+
color: var(--dsw-alias-label-primary, #f2f2f2);
|
|
25
|
+
font-size: 15px;
|
|
26
|
+
font-weight: 600;
|
|
27
|
+
line-height: 1.4;
|
|
28
|
+
}
|
|
29
|
+
.qc-page-sub {
|
|
30
|
+
margin: 0;
|
|
31
|
+
color: var(--dsw-alias-label-secondary, #cfd3d6);
|
|
32
|
+
font-size: 13px;
|
|
33
|
+
line-height: 1.5;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/* body: workspace / command-set editor */
|
|
37
|
+
|
|
38
|
+
.qc-settings-body {
|
|
39
|
+
display: flex;
|
|
40
|
+
flex-direction: column;
|
|
41
|
+
gap: 10px;
|
|
42
|
+
}
|
|
43
|
+
.qc-settings-intro {
|
|
44
|
+
font-size: 12px;
|
|
45
|
+
color: var(--dsw-alias-label-secondary, #cfd3d6);
|
|
46
|
+
margin: 0;
|
|
47
|
+
line-height: 1.5;
|
|
48
|
+
}
|
|
49
|
+
.qc-settings-anchor {
|
|
50
|
+
display: flex;
|
|
51
|
+
align-items: center;
|
|
52
|
+
gap: 12px;
|
|
53
|
+
font-size: 12px;
|
|
54
|
+
}
|
|
55
|
+
.qc-settings-anchor-label {
|
|
56
|
+
color: var(--dsw-alias-label-primary, #f2f2f2);
|
|
57
|
+
font-weight: 500;
|
|
58
|
+
}
|
|
59
|
+
.qc-settings-radio {
|
|
60
|
+
display: inline-flex;
|
|
61
|
+
align-items: center;
|
|
62
|
+
gap: 4px;
|
|
63
|
+
color: var(--dsw-alias-label-secondary, #cfd3d6);
|
|
64
|
+
cursor: pointer;
|
|
65
|
+
}
|
|
66
|
+
.qc-settings-error {
|
|
67
|
+
font-size: 12px;
|
|
68
|
+
color: var(--dsw-alias-state-error-primary, #e5484d);
|
|
69
|
+
}
|
|
70
|
+
.qc-settings-empty {
|
|
71
|
+
font-size: 12px;
|
|
72
|
+
color: var(--dsw-alias-label-secondary, #cfd3d6);
|
|
73
|
+
margin: 0;
|
|
74
|
+
}
|
|
75
|
+
.qc-settings-workspace {
|
|
76
|
+
border: 1px solid var(--dsw-alias-border-l2, rgba(128, 128, 128, 0.35));
|
|
77
|
+
border-radius: 10px;
|
|
78
|
+
padding: 10px;
|
|
79
|
+
display: flex;
|
|
80
|
+
flex-direction: column;
|
|
81
|
+
gap: 8px;
|
|
82
|
+
}
|
|
83
|
+
.qc-settings-workspace-head {
|
|
84
|
+
display: flex;
|
|
85
|
+
align-items: baseline;
|
|
86
|
+
gap: 8px;
|
|
87
|
+
}
|
|
88
|
+
.qc-settings-workspace-title {
|
|
89
|
+
font-size: 13px;
|
|
90
|
+
font-weight: 600;
|
|
91
|
+
color: var(--dsw-alias-label-primary, #f2f2f2);
|
|
92
|
+
}
|
|
93
|
+
.qc-settings-workspace-path {
|
|
94
|
+
font-size: 11px;
|
|
95
|
+
font-family: var(--dsw-font-family-mono, monospace);
|
|
96
|
+
color: var(--dsw-alias-label-tertiary, #adb2b8);
|
|
97
|
+
overflow: hidden;
|
|
98
|
+
text-overflow: ellipsis;
|
|
99
|
+
white-space: nowrap;
|
|
100
|
+
}
|
|
101
|
+
.qc-settings-no-commands {
|
|
102
|
+
font-size: 12px;
|
|
103
|
+
color: var(--dsw-alias-label-tertiary, #adb2b8);
|
|
104
|
+
margin: 0;
|
|
105
|
+
}
|
|
106
|
+
.qc-settings-command {
|
|
107
|
+
display: flex;
|
|
108
|
+
gap: 6px;
|
|
109
|
+
align-items: center;
|
|
110
|
+
}
|
|
111
|
+
.qc-settings-field {
|
|
112
|
+
border: 1px solid var(--dsw-alias-border-l2, rgba(128, 128, 128, 0.35));
|
|
113
|
+
border-radius: 8px;
|
|
114
|
+
background: var(--dsw-alias-bg-layer-1, #2a2a2d);
|
|
115
|
+
color: var(--dsw-alias-label-primary, #f2f2f2);
|
|
116
|
+
font-size: 12px;
|
|
117
|
+
padding: 6px 8px;
|
|
118
|
+
outline: none;
|
|
119
|
+
}
|
|
120
|
+
.qc-settings-field::placeholder {
|
|
121
|
+
color: var(--dsw-alias-label-tertiary, #adb2b8);
|
|
122
|
+
}
|
|
123
|
+
.qc-settings-field:focus {
|
|
124
|
+
border-color: var(--dsw-alias-brand-primary, #4a9eff);
|
|
125
|
+
}
|
|
126
|
+
.qc-settings-field-name {
|
|
127
|
+
width: 160px;
|
|
128
|
+
flex: none;
|
|
129
|
+
}
|
|
130
|
+
.qc-settings-field-command {
|
|
131
|
+
flex: 1;
|
|
132
|
+
font-family: var(--dsw-font-family-mono, monospace);
|
|
133
|
+
}
|
|
134
|
+
.qc-settings-icon-btn {
|
|
135
|
+
border: 0;
|
|
136
|
+
background: transparent;
|
|
137
|
+
color: var(--dsw-alias-label-tertiary, #adb2b8);
|
|
138
|
+
cursor: pointer;
|
|
139
|
+
padding: 4px;
|
|
140
|
+
display: inline-flex;
|
|
141
|
+
align-items: center;
|
|
142
|
+
border-radius: 6px;
|
|
143
|
+
}
|
|
144
|
+
.qc-settings-icon-btn:hover {
|
|
145
|
+
background: color-mix(in srgb, var(--dsw-alias-label-primary, #f2f2f2) 10%, transparent);
|
|
146
|
+
color: var(--dsw-alias-state-error-primary, #e5484d);
|
|
147
|
+
}
|
|
148
|
+
.qc-settings-add-btn {
|
|
149
|
+
align-self: flex-start;
|
|
150
|
+
display: inline-flex;
|
|
151
|
+
align-items: center;
|
|
152
|
+
gap: 4px;
|
|
153
|
+
border: 1px solid var(--dsw-alias-border-l2, rgba(128, 128, 128, 0.35));
|
|
154
|
+
border-radius: 8px;
|
|
155
|
+
background: transparent;
|
|
156
|
+
color: var(--dsw-alias-label-primary, #f2f2f2);
|
|
157
|
+
font-size: 12px;
|
|
158
|
+
padding: 4px 10px;
|
|
159
|
+
cursor: pointer;
|
|
160
|
+
}
|
|
161
|
+
.qc-settings-add-btn:hover {
|
|
162
|
+
background: color-mix(in srgb, var(--dsw-alias-label-primary, #f2f2f2) 10%, transparent);
|
|
163
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Quick-commands settings page (设置 → 侧栏「快捷命令」).
|
|
3
|
+
*
|
|
4
|
+
* Registers into the "settings.section" list slot, so the page owns one entry
|
|
5
|
+
* in the settings sidebar and renders in the settings panel's content column.
|
|
6
|
+
* The header is static (title + one-line summary) over the always-visible
|
|
7
|
+
* body: the workspace / command-set editor and the popup anchor preference.
|
|
8
|
+
* Body edits instant-save per change; partial rows (empty name/command)
|
|
9
|
+
* persist while the user types.
|
|
10
|
+
*/
|
|
11
|
+
import { useEffect, useRef, useState } from 'react'
|
|
12
|
+
import {
|
|
13
|
+
IconPlusOutline16,
|
|
14
|
+
IconTrashOutline16,
|
|
15
|
+
} from '@deepseek-ai/dsh-client-ui-primitives'
|
|
16
|
+
import { quickApi } from './api'
|
|
17
|
+
import type { QuickRosterEntry, QuickCommandsSettings } from '../shared/contract'
|
|
18
|
+
import './settings-section.css'
|
|
19
|
+
|
|
20
|
+
interface Props {
|
|
21
|
+
t: (key: string) => string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface RosterState {
|
|
25
|
+
loaded: boolean
|
|
26
|
+
entries: QuickRosterEntry[]
|
|
27
|
+
anchor: QuickCommandsSettings['popupAnchor']
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
type CommandDraft = { name: string; command: string }
|
|
31
|
+
|
|
32
|
+
export function QuickCommandsSettingsSection({ t }: Props): JSX.Element {
|
|
33
|
+
const [state, setState] = useState<RosterState>({ loaded: false, entries: [], anchor: 'corner' })
|
|
34
|
+
const [error, setError] = useState<string | null>(null)
|
|
35
|
+
// Guard concurrent saves: the last write wins via a monotonically bumped token.
|
|
36
|
+
const saveToken = useRef(0)
|
|
37
|
+
|
|
38
|
+
const load = async (): Promise<void> => {
|
|
39
|
+
try {
|
|
40
|
+
const [entries, settings] = await Promise.all([quickApi.workspacesList(), quickApi.settingsGet()])
|
|
41
|
+
setState({ loaded: true, entries, anchor: settings.popupAnchor })
|
|
42
|
+
setError(null)
|
|
43
|
+
} catch (e) {
|
|
44
|
+
setState((s) => ({ ...s, loaded: true }))
|
|
45
|
+
setError(e instanceof Error ? e.message : String(e))
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
void load()
|
|
51
|
+
}, [])
|
|
52
|
+
|
|
53
|
+
/** Instant-save the workspace's whole command set; optimistic UI. */
|
|
54
|
+
const saveCommands = async (workspaceId: string, commands: CommandDraft[]): Promise<void> => {
|
|
55
|
+
const token = ++saveToken.current
|
|
56
|
+
try {
|
|
57
|
+
const next = await quickApi.settingsSetCommands(workspaceId, commands)
|
|
58
|
+
if (token !== saveToken.current) return
|
|
59
|
+
setState((s) => ({
|
|
60
|
+
...s,
|
|
61
|
+
anchor: next.popupAnchor,
|
|
62
|
+
entries: s.entries.map((e) => (e.workspaceId === workspaceId ? { ...e, commands } : e)),
|
|
63
|
+
}))
|
|
64
|
+
setError(null)
|
|
65
|
+
} catch (e) {
|
|
66
|
+
setError(e instanceof Error ? e.message : String(e))
|
|
67
|
+
// Re-sync to server truth on failure.
|
|
68
|
+
void load()
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Compute the next command list for a workspace, THEN save + update state. */
|
|
73
|
+
const mutateCommands = (workspaceId: string, next: (current: CommandDraft[]) => CommandDraft[]): void => {
|
|
74
|
+
const entry = state.entries.find((e) => e.workspaceId === workspaceId)
|
|
75
|
+
if (entry === undefined) return
|
|
76
|
+
const commands = next(entry.commands)
|
|
77
|
+
// Optimistic update first (row stays visible immediately), then persist.
|
|
78
|
+
setState((s) => ({
|
|
79
|
+
...s,
|
|
80
|
+
entries: s.entries.map((e) => (e.workspaceId === workspaceId ? { ...e, commands } : e)),
|
|
81
|
+
}))
|
|
82
|
+
void saveCommands(workspaceId, commands)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const patchEntry = (workspaceId: string, index: number, field: 'name' | 'command', value: string): void => {
|
|
86
|
+
mutateCommands(workspaceId, (current) =>
|
|
87
|
+
current.map((c, i) => (i === index ? { ...c, [field]: value } : c)),
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const addCommand = (workspaceId: string): void => {
|
|
92
|
+
mutateCommands(workspaceId, (current) => [...current, { name: '', command: '' }])
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const removeCommand = (workspaceId: string, index: number): void => {
|
|
96
|
+
mutateCommands(workspaceId, (current) => current.filter((_, i) => i !== index))
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const setAnchor = async (anchor: QuickCommandsSettings['popupAnchor']): Promise<void> => {
|
|
100
|
+
setState((s) => ({ ...s, anchor }))
|
|
101
|
+
try {
|
|
102
|
+
const next = await quickApi.settingsSetAnchor(anchor)
|
|
103
|
+
setState((s) => ({ ...s, anchor: next.popupAnchor }))
|
|
104
|
+
} catch (e) {
|
|
105
|
+
setError(e instanceof Error ? e.message : String(e))
|
|
106
|
+
void load()
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const title = t('cardTitle')
|
|
111
|
+
const description = t('sectionSub')
|
|
112
|
+
|
|
113
|
+
return (
|
|
114
|
+
<div className="qc-page">
|
|
115
|
+
<header className="qc-page-head">
|
|
116
|
+
<h3 className="qc-page-title">{title}</h3>
|
|
117
|
+
<p className="qc-page-sub">{description}</p>
|
|
118
|
+
</header>
|
|
119
|
+
<div className="qc-settings-body">
|
|
120
|
+
<p className="qc-settings-intro">{t('cardIntro')}</p>
|
|
121
|
+
|
|
122
|
+
<div className="qc-settings-anchor">
|
|
123
|
+
<span className="qc-settings-anchor-label">{t('cardAnchor')}</span>
|
|
124
|
+
<label className="qc-settings-radio">
|
|
125
|
+
<input
|
|
126
|
+
type="radio"
|
|
127
|
+
name="qc-anchor"
|
|
128
|
+
checked={state.anchor === 'corner'}
|
|
129
|
+
onChange={() => void setAnchor('corner')}
|
|
130
|
+
/>
|
|
131
|
+
{t('cardAnchorCorner')}
|
|
132
|
+
</label>
|
|
133
|
+
<label className="qc-settings-radio">
|
|
134
|
+
<input
|
|
135
|
+
type="radio"
|
|
136
|
+
name="qc-anchor"
|
|
137
|
+
checked={state.anchor === 'button'}
|
|
138
|
+
onChange={() => void setAnchor('button')}
|
|
139
|
+
/>
|
|
140
|
+
{t('cardAnchorButton')}
|
|
141
|
+
</label>
|
|
142
|
+
</div>
|
|
143
|
+
|
|
144
|
+
{error !== null && <div className="qc-settings-error">{t('cardSaveError')}: {error}</div>}
|
|
145
|
+
|
|
146
|
+
{state.loaded && state.entries.length === 0 && (
|
|
147
|
+
<p className="qc-settings-empty">{t('cardNoWorkspaces')}</p>
|
|
148
|
+
)}
|
|
149
|
+
|
|
150
|
+
{state.entries.map((entry) => (
|
|
151
|
+
<section key={entry.workspaceId} className="qc-settings-workspace">
|
|
152
|
+
<header className="qc-settings-workspace-head">
|
|
153
|
+
<span className="qc-settings-workspace-title">{entry.title || entry.path || entry.workspaceId}</span>
|
|
154
|
+
<span className="qc-settings-workspace-path">{entry.path}</span>
|
|
155
|
+
</header>
|
|
156
|
+
{entry.commands.length === 0 && (
|
|
157
|
+
<p className="qc-settings-no-commands">{t('cardNoCommands')}</p>
|
|
158
|
+
)}
|
|
159
|
+
{entry.commands.map((command, index) => (
|
|
160
|
+
<div key={`${entry.workspaceId}-${index}`} className="qc-settings-command">
|
|
161
|
+
<input
|
|
162
|
+
className="qc-settings-field qc-settings-field-name"
|
|
163
|
+
placeholder={t('cardName')}
|
|
164
|
+
value={command.name}
|
|
165
|
+
aria-label={t('cardName')}
|
|
166
|
+
onChange={(e) => patchEntry(entry.workspaceId, index, 'name', e.target.value)}
|
|
167
|
+
/>
|
|
168
|
+
<input
|
|
169
|
+
className="qc-settings-field qc-settings-field-command"
|
|
170
|
+
placeholder={t('cardCommand')}
|
|
171
|
+
value={command.command}
|
|
172
|
+
aria-label={t('cardCommand')}
|
|
173
|
+
onChange={(e) => patchEntry(entry.workspaceId, index, 'command', e.target.value)}
|
|
174
|
+
/>
|
|
175
|
+
<button
|
|
176
|
+
type="button"
|
|
177
|
+
className="qc-settings-icon-btn"
|
|
178
|
+
aria-label={t('cardRemove')}
|
|
179
|
+
title={t('cardRemove')}
|
|
180
|
+
onClick={() => removeCommand(entry.workspaceId, index)}
|
|
181
|
+
>
|
|
182
|
+
<IconTrashOutline16 size={14} />
|
|
183
|
+
</button>
|
|
184
|
+
</div>
|
|
185
|
+
))}
|
|
186
|
+
<button type="button" className="qc-settings-add-btn" onClick={() => addCommand(entry.workspaceId)}>
|
|
187
|
+
<IconPlusOutline16 size={14} />
|
|
188
|
+
{t('cardAddCommand')}
|
|
189
|
+
</button>
|
|
190
|
+
</section>
|
|
191
|
+
))}
|
|
192
|
+
</div>
|
|
193
|
+
</div>
|
|
194
|
+
)
|
|
195
|
+
}
|