dsh-bettertaskgraphs 0.0.0-stage → 0.2.3
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/LICENSE +674 -0
- package/README.md +55 -2
- package/client.js +535 -0
- package/client.source.mjs +318 -0
- package/cordis.patch.yml +5 -0
- package/core.mjs +432 -0
- package/host.mjs +472 -0
- package/index.mjs +70 -0
- package/journal.mjs +131 -0
- package/package.json +78 -4
- package/projection.mjs +225 -0
- package/remote.mjs +51 -0
- package/scripts/build-client.mjs +26 -0
- package/store.mjs +152 -0
- package/tools.mjs +94 -0
- package/view-model.mjs +157 -0
- package/work-policy.mjs +74 -0
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
// SPDX-License-Identifier: GPL-3.0-only
|
|
2
|
+
// Loaded in the existing DSH browser seat, without a separate React root.
|
|
3
|
+
export function createClientPlugin(React, z, createRemoteContribution, graphPresentation, branchLayout) {
|
|
4
|
+
const { createElement: h, useEffect, useId, useMemo, useRef, useState } = React
|
|
5
|
+
const locale = {
|
|
6
|
+
zh: {
|
|
7
|
+
title: '事务图', progress: '任务成功 {done}/{total}', active: '当前事务', ready: '可开始', noReady: '暂无可开始事务',
|
|
8
|
+
retry: '刷新', unavailable: '暂时无法读取事务图', stale: '显示上次读取的状态', recovering: '当前事务等待恢复对账',
|
|
9
|
+
pending: '状态更新待对账', waiting: '暂时没有可执行步骤,等待当前状态核对',
|
|
10
|
+
tree: '事务层级', details: '事务关系', incoming: '进入条件:', conditionSeparator: ':',
|
|
11
|
+
noEdges: '无进入条件',
|
|
12
|
+
complete: '完成', inProgress: '执行中', pendingNode: '待执行', inactive: '分支尚未激活', incomplete: '未完成',
|
|
13
|
+
branchWaiting: '等待分支选择', branchExcluded: '未选分支', branchChosen: '已选路径', graphComplete: '选中路径已完成',
|
|
14
|
+
skipped: '跳过,未执行', failed: '失败', blocked: '受阻', unsatisfied: '未满足', grouped: '组内尚有未完成',
|
|
15
|
+
fullscreen: '全屏', exitFullscreen: '退出全屏', branchGraph: '事务分支图',
|
|
16
|
+
prerequisiteMet: '前置已满足', edgeWaiting: '等待条件',
|
|
17
|
+
additionalPrerequisites: '另有 {count} 项前置',
|
|
18
|
+
requires: '前置完成后', onComplete: '完成时', onIncomplete: '未完成时', onCase: '分支 {value}',
|
|
19
|
+
totalIncomplete: '{count} 项未完成', totalSkipped: '{count} 项跳过', totalInactive: '{count} 项分支未激活',
|
|
20
|
+
totalExcluded: '{count} 项属于未选分支',
|
|
21
|
+
revision: '版本 {revision}',
|
|
22
|
+
},
|
|
23
|
+
en: {
|
|
24
|
+
title: 'Transaction graph', progress: '{done}/{total} tasks successful', active: 'Current transaction', ready: 'Ready to start',
|
|
25
|
+
noReady: 'No transaction ready to start', retry: 'Refresh', unavailable: 'Transaction graph is unavailable', stale: 'Showing last known state',
|
|
26
|
+
recovering: 'Current transaction awaits reconciliation', pending: 'Status update awaits reconciliation',
|
|
27
|
+
waiting: 'Execution is on hold until the current state is reconciled',
|
|
28
|
+
tree: 'Transaction hierarchy', details: 'Transaction relationships', incoming: 'Entry conditions:', conditionSeparator: ': ',
|
|
29
|
+
noEdges: 'No entry conditions',
|
|
30
|
+
complete: 'Complete', inProgress: 'In progress', pendingNode: 'Pending', inactive: 'Branch not activated',
|
|
31
|
+
incomplete: 'Incomplete', skipped: 'Skipped', failed: 'Failed', blocked: 'Blocked', unsatisfied: 'Unsatisfied', grouped: 'Group incomplete',
|
|
32
|
+
fullscreen: 'Full screen', exitFullscreen: 'Exit full screen', branchGraph: 'Transaction branch graph',
|
|
33
|
+
prerequisiteMet: 'Prerequisite met', edgeWaiting: 'Awaiting condition',
|
|
34
|
+
additionalPrerequisites: '+{count} prerequisites',
|
|
35
|
+
branchWaiting: 'Awaiting branch selection', branchExcluded: 'Branch not selected',
|
|
36
|
+
branchChosen: 'Selected path', graphComplete: 'Selected paths complete',
|
|
37
|
+
requires: 'After prerequisite', onComplete: 'On completion', onIncomplete: 'On incomplete', onCase: 'Case {value}',
|
|
38
|
+
totalIncomplete: '{count} incomplete', totalSkipped: '{count} skipped', totalInactive: '{count} inactive branches',
|
|
39
|
+
totalExcluded: '{count} on unselected paths',
|
|
40
|
+
revision: 'Revision {revision}',
|
|
41
|
+
},
|
|
42
|
+
}
|
|
43
|
+
const statusLabels = {
|
|
44
|
+
ACTIVE: 'inProgress', COMPLETE: 'complete', CONTAINER_INCOMPLETE: 'grouped', INACTIVE: 'inactive',
|
|
45
|
+
INCOMPLETE: 'incomplete', PENDING: 'pendingNode', SKIPPED: 'skipped',
|
|
46
|
+
}
|
|
47
|
+
const dispositions = { FAILED: 'failed', UNSATISFIED: 'unsatisfied', SKIPPED_NOT_RUN: 'skipped' }
|
|
48
|
+
const styles = `
|
|
49
|
+
.tg-dock{--tg-border:var(--dsw-alias-border-l1,#8885);--tg-panel:var(--dsw-specific-menu,Canvas);--tg-muted:var(--dsw-alias-label-secondary,inherit);container-type:inline-size;box-sizing:border-box;width:calc(100% - 24px);max-width:var(--dsh-composer-card-max-width,860px);margin:0 auto;border:1px solid var(--tg-border);border-radius:var(--dsw-radius-lg,12px);background:var(--tg-panel);color:var(--dsw-alias-label-primary,CanvasText);font:inherit}
|
|
50
|
+
.tg-dock button{font:inherit;color:inherit;cursor:pointer}
|
|
51
|
+
.tg-dock button:focus-visible,.tg-canvas-scroll:focus-visible{outline:2px solid #4f91ed;outline-offset:-2px}
|
|
52
|
+
.tg-top{display:flex;align-items:center;gap:3px;padding-inline-end:8px}
|
|
53
|
+
.tg-header{display:flex;align-items:center;gap:10px;flex:1 1 auto;padding:7px 10px;border:0;border-radius:8px;background:transparent;text-align:left;font-size:13px!important;min-width:0}
|
|
54
|
+
.tg-heading{font-weight:600;flex:none}.tg-summary{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--tg-muted)}.tg-count{flex:none;font-size:12px;color:var(--tg-muted)}
|
|
55
|
+
.tg-chevron{display:inline-block;flex:none;transition:transform 180ms ease}.tg-header[aria-expanded=true] .tg-chevron{transform:rotate(180deg)}
|
|
56
|
+
.tg-fold{display:grid;grid-template-rows:0fr;opacity:0;transition:grid-template-rows 180ms ease,opacity 150ms ease}
|
|
57
|
+
.tg-fold[data-expanded=true]{grid-template-rows:1fr;opacity:1}.tg-fold-clip{min-height:0;overflow:hidden}
|
|
58
|
+
.tg-body{padding:0 12px 10px;max-height:min(46vh,440px);overflow:auto;overscroll-behavior:contain}.tg-overview{display:flex;align-items:center;gap:8px;justify-content:space-between;margin:4px 0 7px;overflow-wrap:anywhere}
|
|
59
|
+
.tg-overview h3,.tg-area h4,.tg-condition-heading{font-size:13px;margin:0;font-weight:600}
|
|
60
|
+
.tg-actions{display:flex;align-items:center;gap:7px;flex:none;font-size:11px}
|
|
61
|
+
.tg-dock .tg-control{border:1px solid var(--tg-border);padding:3px 7px;border-radius:6px;background:transparent;font-size:12px;flex:none}
|
|
62
|
+
.tg-control:disabled{cursor:default;opacity:.6}
|
|
63
|
+
.tg-notice{padding:6px 8px;background:#d78c0c20;border-radius:6px;margin:4px 0;font-size:12px;overflow-wrap:anywhere}
|
|
64
|
+
.tg-progress,.tg-ready{display:flex;flex-wrap:wrap;gap:5px 12px;margin:5px 0;font-size:12px;align-items:baseline}.tg-progress{color:var(--tg-muted)}
|
|
65
|
+
.tg-ready button,.tg-link-button{border:0;background:transparent;padding:0;text-align:left;text-decoration:underline;text-decoration-thickness:1px;text-underline-offset:2px;overflow-wrap:anywhere;max-width:100%}
|
|
66
|
+
.tg-grid{display:grid;grid-template-columns:minmax(0,1.2fr) minmax(0,1fr);gap:8px;margin-top:8px}
|
|
67
|
+
.tg-area{min-width:0;padding:8px;border:1px solid var(--tg-border);border-radius:8px;overflow-wrap:anywhere}
|
|
68
|
+
.tg-area h4{margin-bottom:6px}
|
|
69
|
+
.tg-tree,.tg-tree ol{list-style:none;margin:0;padding:0}.tg-tree ol{margin-inline-start:8px;padding-inline-start:8px;border-inline-start:1px solid var(--tg-border)}
|
|
70
|
+
.tg-node{display:flex;align-items:baseline;gap:5px;flex-wrap:wrap;width:100%;text-align:left;border:0;border-radius:5px;background:transparent;padding:4px 3px;font-size:12px!important;overflow-wrap:anywhere;transition:background-color 140ms ease}
|
|
71
|
+
.tg-node:hover,.tg-node[aria-pressed=true]{background:#4f91ed21}.tg-node[aria-current=step]{outline:1px solid #4f91ed;outline-offset:-1px}.tg-node-name{min-width:0;max-width:100%;overflow-wrap:anywhere}.tg-node[data-kind=container] .tg-node-name{font-weight:650}
|
|
72
|
+
.tg-badge{padding:0 4px;border-radius:4px;background:#8882;font-size:10px;line-height:16px}
|
|
73
|
+
.tg-badge[data-state=complete]{background:#3399682c}.tg-badge[data-state=inProgress]{background:#4f91ed35}
|
|
74
|
+
.tg-badge:is([data-state=blocked],[data-state=incomplete],[data-state=unsatisfied]){background:#d78c0c2c}
|
|
75
|
+
.tg-badge[data-state=failed]{background:#d452522f}
|
|
76
|
+
.tg-current,.tg-conditions{font-size:12px;line-height:1.4;overflow-wrap:anywhere}.tg-current{margin-bottom:7px}.tg-current .tg-badge{margin-inline-start:6px}
|
|
77
|
+
.tg-conditions{list-style:decimal;margin:4px 0 0;padding-inline-start:1.7em}.tg-conditions li+li{margin-top:4px}.tg-no-conditions{margin:4px 0 0;color:var(--tg-muted);font-size:12px}
|
|
78
|
+
|
|
79
|
+
.tg-viewer{
|
|
80
|
+
--tg-panel:var(--dsw-alias-bg-base,Canvas);
|
|
81
|
+
--tg-card-fill:color-mix(in srgb,var(--tg-panel),currentColor 7%);
|
|
82
|
+
--tg-card-border:color-mix(in srgb,var(--tg-panel),currentColor 32%);
|
|
83
|
+
--tg-complete:light-dark(#287552,#6acda0);
|
|
84
|
+
--tg-active:light-dark(#296ac4,#80b2ff);
|
|
85
|
+
--tg-blocked:light-dark(#a36119,#edab5b);
|
|
86
|
+
--tg-failed:light-dark(#aa4242,#fa8585);
|
|
87
|
+
box-sizing:border-box;position:fixed;inset:0;width:100%;height:100%;max-width:none;max-height:none;margin:0;padding:14px 18px;border:0;overflow:hidden;background:linear-gradient(var(--tg-panel),var(--tg-panel)),Canvas;color:inherit;display:grid;grid-template-rows:auto auto minmax(0,1fr) auto;gap:10px;opacity:0;transform:translateY(8px) scale(.995);transition:opacity 180ms ease,transform 180ms ease,display 180ms allow-discrete,overlay 180ms allow-discrete
|
|
88
|
+
}
|
|
89
|
+
.tg-viewer:not([open]){display:none}.tg-viewer[open]{opacity:1;transform:none}
|
|
90
|
+
@starting-style{.tg-viewer[open]{opacity:0;transform:translateY(8px) scale(.995)}}
|
|
91
|
+
.tg-viewer-header{display:flex;align-items:center;justify-content:space-between;gap:10px 20px;flex-wrap:wrap}
|
|
92
|
+
.tg-viewer-header h2{margin:2px 0;font-size:20px;overflow-wrap:anywhere}.tg-viewer-eyebrow{color:var(--tg-muted);font-size:12px}
|
|
93
|
+
.tg-viewer-header .tg-actions{flex:0 1 auto;flex-wrap:wrap}.tg-viewer .tg-area{max-height:min(28vh,210px);overflow:auto}
|
|
94
|
+
.tg-legend{display:flex;flex-wrap:wrap;gap:5px 16px;margin:0;padding:0;list-style:none;color:var(--tg-muted);font-size:12px}
|
|
95
|
+
.tg-legend li{display:flex;align-items:center;gap:5px}
|
|
96
|
+
.tg-wire,.tg-legend-mark{--tg-line:light-dark(#676d76,#b0b8c6);--tg-line-opacity:.78}
|
|
97
|
+
:is(.tg-wire,.tg-legend-mark)[data-state=branchChosen]{--tg-line:var(--tg-active);--tg-line-opacity:1}
|
|
98
|
+
:is(.tg-wire,.tg-legend-mark)[data-state=prerequisiteMet]{--tg-line:var(--tg-complete);--tg-line-opacity:1}
|
|
99
|
+
:is(.tg-wire,.tg-legend-mark)[data-state=blocked]{--tg-line:var(--tg-blocked);--tg-line-opacity:1}
|
|
100
|
+
:is(.tg-wire,.tg-legend-mark)[data-state=branchExcluded]{--tg-line-opacity:.62}
|
|
101
|
+
.tg-legend-mark{width:16px;border-top:2px solid var(--tg-line);opacity:var(--tg-line-opacity)}
|
|
102
|
+
.tg-legend-mark[data-state=branchExcluded]{border-top-style:dashed}
|
|
103
|
+
.tg-canvas-scroll{display:flex;min-height:0;overflow:auto;overscroll-behavior:contain;border:1px solid var(--tg-border);border-radius:10px;background-color:color-mix(in srgb,var(--tg-panel),currentColor 2%);background-image:radial-gradient(var(--tg-border) .65px,transparent .65px);background-size:15px 15px}
|
|
104
|
+
.tg-canvas{flex:none;position:relative;margin:auto}.tg-wires{position:absolute;inset:0;pointer-events:none}
|
|
105
|
+
.tg-wire{fill:none;stroke:var(--tg-line);stroke-width:2;stroke-linecap:round;stroke-linejoin:round;opacity:var(--tg-line-opacity);pointer-events:stroke}
|
|
106
|
+
.tg-wire[data-state=branchExcluded]{stroke-dasharray:5 5}.tg-wire[data-state=branchChosen]{stroke-width:2.6}
|
|
107
|
+
.tg-wire-label{fill:currentColor;stroke:var(--tg-panel);stroke-width:4;stroke-linejoin:round;paint-order:stroke;font-size:11px}
|
|
108
|
+
.tg-dock .tg-card{--tg-card-accent:var(--tg-card-border);box-sizing:border-box;position:absolute;display:flex;flex-direction:column;align-items:flex-start;justify-content:center;gap:3px;padding:5px 9px;overflow:hidden;text-align:left;border:1px solid var(--tg-card-border);border-inline-start:4px solid var(--tg-card-accent);border-radius:9px;background:var(--tg-card-fill);box-shadow:0 2px 5px #0002;transition:background-color 140ms ease,border-color 140ms ease}
|
|
109
|
+
.tg-dock .tg-card[data-state=complete]{--tg-card-accent:var(--tg-complete)}
|
|
110
|
+
.tg-dock .tg-card[data-state=failed]{--tg-card-accent:var(--tg-failed)}
|
|
111
|
+
.tg-dock .tg-card[data-state=blocked]{--tg-card-accent:var(--tg-blocked)}
|
|
112
|
+
.tg-dock .tg-card[data-state=inProgress]{--tg-card-accent:var(--tg-active)}
|
|
113
|
+
.tg-dock .tg-card[data-state=branchExcluded]{border-style:dashed;box-shadow:none}
|
|
114
|
+
.tg-dock .tg-card:hover{background:color-mix(in srgb,var(--tg-card-fill),var(--tg-active) 8%)}
|
|
115
|
+
.tg-dock .tg-card[aria-pressed=true]{background:color-mix(in srgb,var(--tg-card-fill),var(--tg-active) 15%);border-color:var(--tg-active);border-inline-start-color:var(--tg-card-accent);box-shadow:0 0 0 1px var(--tg-active)}
|
|
116
|
+
.tg-card-title{font-size:14px;line-height:1.2;font-weight:650;overflow-wrap:anywhere;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;overflow:hidden}
|
|
117
|
+
.tg-card-group{font-size:11px;line-height:1.2;color:var(--tg-muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100%}
|
|
118
|
+
.tg-card-states{display:flex;gap:5px;max-width:100%;white-space:nowrap}.tg-card-states .tg-badge{min-width:0;overflow:hidden;text-overflow:ellipsis}
|
|
119
|
+
.tg-card-states .tg-badge[data-state]{font-size:11px;line-height:18px;background:color-mix(in srgb,var(--tg-card-fill),var(--tg-card-accent) 20%)}
|
|
120
|
+
@container (max-width:580px){.tg-grid{grid-template-columns:minmax(0,1fr)}.tg-tree ol{margin-inline-start:3px;padding-inline-start:4px}}
|
|
121
|
+
@container (max-width:250px){.tg-top,.tg-header,.tg-overview,.tg-actions{flex-wrap:wrap}.tg-top{padding:4px;gap:3px}.tg-header{padding:3px;gap:4px}.tg-summary{flex-basis:100%;order:1}.tg-count{flex:1 1 auto;min-width:0;overflow-wrap:anywhere}.tg-body{padding-inline:4px}.tg-area{padding:4px}.tg-tree ol{margin-inline-start:1px;padding-inline-start:2px}.tg-actions{flex:0 1 auto;min-width:0}}
|
|
122
|
+
@media(max-width:580px){.tg-viewer{padding:8px;gap:7px}.tg-viewer-header h2{font-size:16px}}
|
|
123
|
+
@media(prefers-reduced-motion:reduce){.tg-viewer,.tg-fold,.tg-chevron,.tg-node,.tg-dock .tg-card{transition:none}}
|
|
124
|
+
`
|
|
125
|
+
|
|
126
|
+
const contribution = createRemoteContribution(z)
|
|
127
|
+
const statusResult = contribution.descriptors[0].result.create()
|
|
128
|
+
function nodeState(node) {
|
|
129
|
+
if (node.branchState === 'NOT_SELECTED') return 'branchExcluded'
|
|
130
|
+
if (node.branchState === 'WAITING' && !node.disposition) return 'branchWaiting'
|
|
131
|
+
return dispositions[node.disposition] ?? (node.blocked ? 'blocked' : statusLabels[node.status])
|
|
132
|
+
}
|
|
133
|
+
function badge(node, t) {
|
|
134
|
+
const state = nodeState(node)
|
|
135
|
+
return h('span', { className: 'tg-badge', 'data-state': state }, t(state))
|
|
136
|
+
}
|
|
137
|
+
function edgeText(edge, t) {
|
|
138
|
+
if (edge.kind === 'prerequisite') return t('requires')
|
|
139
|
+
if (edge.kind === 'case') return t('onCase', { value: edge.case })
|
|
140
|
+
return t(edge.outcome === 'complete' ? 'onComplete' : 'onIncomplete')
|
|
141
|
+
}
|
|
142
|
+
function Conditions({ selected, conditions, t, onSelect }) {
|
|
143
|
+
if (!selected) return null
|
|
144
|
+
return h('section', { className: 'tg-area', 'aria-label': t('details') },
|
|
145
|
+
h('h4', null, t('details')),
|
|
146
|
+
h('div', { className: 'tg-current' }, h('strong', null, selected.label), badge(selected, t)),
|
|
147
|
+
h('h5', { className: 'tg-condition-heading' }, t('incoming')),
|
|
148
|
+
conditions.length ? h('ol', { className: 'tg-conditions' }, conditions.map(node =>
|
|
149
|
+
h('li', { key: node.id },
|
|
150
|
+
h('button', { type: 'button', className: 'tg-link-button', onClick: () => onSelect(node.id) }, node.label),
|
|
151
|
+
t('conditionSeparator'), t(nodeState(node))))) :
|
|
152
|
+
h('p', { className: 'tg-no-conditions' }, t('noEdges')))
|
|
153
|
+
}
|
|
154
|
+
function BranchCanvas({ graph, selected, active, t, onSelect }) {
|
|
155
|
+
const layout = useMemo(() => branchLayout(graph), [graph])
|
|
156
|
+
const arrowId = useId()
|
|
157
|
+
const wires = layout.wires.map(wire => h('g', { key: wire.key },
|
|
158
|
+
h('path', { className: 'tg-wire', 'data-state': wire.state, d: wire.path, markerEnd: `url(#${arrowId})` },
|
|
159
|
+
h('title', null, `${wire.from.label} → ${wire.to.label} · ${edgeText(wire.edge, t)} · ${t(wire.state)}`)),
|
|
160
|
+
wire.showLabel && h('text', { className: 'tg-wire-label', x: wire.labelX, y: wire.labelY, textAnchor: 'middle' },
|
|
161
|
+
edgeText(wire.edge, t))))
|
|
162
|
+
const cards = layout.cards.map(({ node, group, x, y, additionalPrerequisites }) => h('button', {
|
|
163
|
+
key: node.id, type: 'button', className: 'tg-card', 'data-state': nodeState(node),
|
|
164
|
+
'aria-pressed': selected?.id === node.id, 'aria-current': active?.id === node.id ? 'step' : undefined,
|
|
165
|
+
style: { left: x, top: y, width: layout.cardWidth, height: layout.cardHeight },
|
|
166
|
+
onClick: () => onSelect(node.id), title: node.label,
|
|
167
|
+
}, group && h('span', { className: 'tg-card-group' }, group),
|
|
168
|
+
h('strong', { className: 'tg-card-title' }, node.label),
|
|
169
|
+
h('span', { className: 'tg-card-states' }, badge(node, t),
|
|
170
|
+
additionalPrerequisites > 0 && h('span', { className: 'tg-badge' },
|
|
171
|
+
t('additionalPrerequisites', { count: additionalPrerequisites })))))
|
|
172
|
+
return h('div', { className: 'tg-canvas-scroll', tabIndex: 0, role: 'region', 'aria-label': t('branchGraph') },
|
|
173
|
+
h('div', { className: 'tg-canvas', style: { width: layout.width, height: layout.height } },
|
|
174
|
+
h('svg', { className: 'tg-wires', 'aria-hidden': true, width: layout.width, height: layout.height },
|
|
175
|
+
h('defs', null, h('marker', { id: arrowId, viewBox: '0 0 8 8', refX: 7, refY: 4, markerWidth: 5, markerHeight: 5, orient: 'auto' },
|
|
176
|
+
h('path', { d: 'M 0 0 L 8 4 L 0 8 Z', fill: 'context-stroke' }))),
|
|
177
|
+
wires),
|
|
178
|
+
cards))
|
|
179
|
+
}
|
|
180
|
+
function GraphPanel({ graph, t, busy, failed, refresh }) {
|
|
181
|
+
const [expanded, setExpanded] = useState(false)
|
|
182
|
+
const [selectedId, setSelected] = useState(null)
|
|
183
|
+
const [viewerVisited, setViewerVisited] = useState(false)
|
|
184
|
+
const viewer = useRef(null)
|
|
185
|
+
const bodyId = useId()
|
|
186
|
+
const viewerId = useId()
|
|
187
|
+
const viewerTitleId = useId()
|
|
188
|
+
const model = graphPresentation(graph, selectedId)
|
|
189
|
+
const { active, selected, progress } = model
|
|
190
|
+
let current = t('noReady')
|
|
191
|
+
if (active) current = `${t(graph.active.recoveryRequired ? 'recovering' : 'active')} · ${active.label}`
|
|
192
|
+
else if (graph.pending) current = t('pending')
|
|
193
|
+
else if (model.children.get(null)?.[0]?.status === 'COMPLETE') current = t('graphComplete')
|
|
194
|
+
else if (progress.ready) current = `${t('ready')} · ${progress.ready}`
|
|
195
|
+
const title = failed ? `${t('stale')} · ${current}` : current
|
|
196
|
+
const progressText = t('progress', { done: progress.complete, total: progress.total })
|
|
197
|
+
const conditionProps = { selected, conditions: model.conditions, t, onSelect: setSelected }
|
|
198
|
+
const refreshButton = () => h('button', { className: 'tg-control', type: 'button', disabled: busy, onClick: refresh }, t('retry'))
|
|
199
|
+
const notices = () => h(React.Fragment, null,
|
|
200
|
+
failed && h('p', { className: 'tg-notice', role: 'status' }, t('unavailable')),
|
|
201
|
+
graph.pending && h('p', { className: 'tg-notice', role: 'status' }, t('waiting')))
|
|
202
|
+
function openViewer() {
|
|
203
|
+
const dialog = viewer.current
|
|
204
|
+
if (dialog.open) return
|
|
205
|
+
setViewerVisited(true)
|
|
206
|
+
dialog.showModal()
|
|
207
|
+
dialog.querySelector('[data-tg-close]').focus()
|
|
208
|
+
}
|
|
209
|
+
function branch(parent, depth = 0) {
|
|
210
|
+
const rows = model.children.get(parent)
|
|
211
|
+
if (!rows || depth >= 16) return null
|
|
212
|
+
return h('ol', { className: parent === null ? 'tg-tree' : undefined }, rows.map(node =>
|
|
213
|
+
h('li', { key: node.id },
|
|
214
|
+
h('button', { className: 'tg-node', type: 'button', 'data-kind': node.kind,
|
|
215
|
+
'aria-pressed': selected?.id === node.id,
|
|
216
|
+
'aria-current': active?.id === node.id ? 'step' : undefined,
|
|
217
|
+
onClick: () => setSelected(node.id) },
|
|
218
|
+
h('span', { className: 'tg-node-name' }, node.label), badge(node, t),
|
|
219
|
+
model.legal.has(node.id) && h('span', { className: 'tg-badge' }, t('ready'))),
|
|
220
|
+
branch(node.id, depth + 1))))
|
|
221
|
+
}
|
|
222
|
+
return h('section', { className: 'tg-dock', 'data-testid': 'transaction-graph-dock', 'aria-label': t('title') },
|
|
223
|
+
h('style', null, styles),
|
|
224
|
+
h('div', { className: 'tg-top' },
|
|
225
|
+
h('button', { type: 'button', className: 'tg-header', 'aria-expanded': expanded, 'aria-controls': bodyId,
|
|
226
|
+
onClick: () => setExpanded(value => !value) },
|
|
227
|
+
h('span', { className: 'tg-heading' }, t('title')),
|
|
228
|
+
h('span', { className: 'tg-summary', title, role: 'status' }, title),
|
|
229
|
+
h('span', { className: 'tg-count' }, progressText),
|
|
230
|
+
h('span', { className: 'tg-chevron', 'aria-hidden': true }, '⌄')),
|
|
231
|
+
h('button', { type: 'button', className: 'tg-control', 'aria-haspopup': 'dialog', 'aria-controls': viewerId,
|
|
232
|
+
onClick: openViewer }, t('fullscreen'))),
|
|
233
|
+
h('div', { id: bodyId, className: 'tg-fold', 'data-expanded': expanded, 'aria-hidden': !expanded,
|
|
234
|
+
ref: element => { if (element) element.inert = !expanded } },
|
|
235
|
+
h('div', { className: 'tg-fold-clip' }, h('div', { className: 'tg-body' },
|
|
236
|
+
h('div', { className: 'tg-overview' }, h('h3', null, graph.title),
|
|
237
|
+
h('div', { className: 'tg-actions' }, h('span', null, t('revision', { revision: graph.revision })), refreshButton())),
|
|
238
|
+
notices(),
|
|
239
|
+
(progress.incomplete > 0 || progress.skipped > 0 || progress.inactive > 0 || progress.excluded > 0) &&
|
|
240
|
+
h('p', { className: 'tg-progress' },
|
|
241
|
+
progress.incomplete > 0 && h('span', null, t('totalIncomplete', { count: progress.incomplete })),
|
|
242
|
+
progress.skipped > 0 && h('span', null, t('totalSkipped', { count: progress.skipped })),
|
|
243
|
+
progress.inactive > 0 && h('span', null, t('totalInactive', { count: progress.inactive })),
|
|
244
|
+
progress.excluded > 0 && h('span', null, t('totalExcluded', { count: progress.excluded }))),
|
|
245
|
+
graph.legalNext.length > 0 && h('div', { className: 'tg-ready' }, h('strong', null, t('ready')),
|
|
246
|
+
graph.legalNext.map(choice => h('button', { type: 'button', className: 'tg-link-button', key: choice.id,
|
|
247
|
+
onClick: () => setSelected(choice.id) }, model.nodes.get(choice.id)?.label))),
|
|
248
|
+
h('div', { className: 'tg-grid' },
|
|
249
|
+
h('section', { className: 'tg-area', 'aria-label': t('tree') }, h('h4', null, t('tree')), branch(null)),
|
|
250
|
+
h(Conditions, conditionProps))))),
|
|
251
|
+
h('dialog', { id: viewerId, ref: viewer, className: 'tg-viewer', 'aria-labelledby': viewerTitleId },
|
|
252
|
+
h('div', { className: 'tg-viewer-header' },
|
|
253
|
+
h('div', null, h('div', { className: 'tg-viewer-eyebrow' }, t('branchGraph')), h('h2', { id: viewerTitleId }, graph.title)),
|
|
254
|
+
h('div', { className: 'tg-actions' },
|
|
255
|
+
h('span', null, progressText), h('span', null, t('revision', { revision: graph.revision })), refreshButton(),
|
|
256
|
+
h('button', { className: 'tg-control', type: 'button', 'data-tg-close': true,
|
|
257
|
+
onClick: () => viewer.current.close() }, t('exitFullscreen')))),
|
|
258
|
+
h('div', null, notices(),
|
|
259
|
+
h('ul', { className: 'tg-legend' }, ['branchChosen', 'prerequisiteMet', 'blocked', 'edgeWaiting', 'branchExcluded'].map(state =>
|
|
260
|
+
h('li', { key: state }, h('span', { className: 'tg-legend-mark', 'data-state': state, 'aria-hidden': true }), t(state))))),
|
|
261
|
+
viewerVisited && h(BranchCanvas, { graph, selected, active, t, onSelect: setSelected }),
|
|
262
|
+
h(Conditions, conditionProps)))
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function makeDock(api) {
|
|
266
|
+
return function TransactionGraphDock({ sessionId, useSession, useAccess, t }) {
|
|
267
|
+
const canRead = useAccess('transactionGraphView/status')
|
|
268
|
+
const opened = useSession(session => session.openState === 'open' && !session.removed)
|
|
269
|
+
const running = useSession(session => session.running)
|
|
270
|
+
const [result, setResult] = useState(null)
|
|
271
|
+
const [refreshCount, requestRefresh] = useState(0)
|
|
272
|
+
const refresh = () => requestRefresh(value => value + 1)
|
|
273
|
+
useEffect(() => {
|
|
274
|
+
if (!canRead || !opened) return
|
|
275
|
+
const controller = new AbortController()
|
|
276
|
+
let timer
|
|
277
|
+
async function refreshStatus() {
|
|
278
|
+
const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(8000)])
|
|
279
|
+
setResult(value => ({
|
|
280
|
+
sessionId, graph: value?.sessionId === sessionId ? value.graph : null,
|
|
281
|
+
busy: true, failed: value?.sessionId === sessionId && value.failed,
|
|
282
|
+
}))
|
|
283
|
+
try {
|
|
284
|
+
const response = await api.status({ sessionId }, signal)
|
|
285
|
+
if (controller.signal.aborted) return
|
|
286
|
+
const parsed = response?.ok ? statusResult.safeParse(response.value) : null
|
|
287
|
+
if (!parsed?.success || parsed.data.sessionId !== sessionId || parsed.data.status === 'unavailable') throw new Error('Unavailable')
|
|
288
|
+
setResult({ sessionId, graph: parsed.data.graph, busy: false, failed: false })
|
|
289
|
+
} catch {
|
|
290
|
+
if (controller.signal.aborted) return
|
|
291
|
+
setResult(value => ({ sessionId, graph: value?.sessionId === sessionId ? value.graph : null, busy: false, failed: true }))
|
|
292
|
+
} finally {
|
|
293
|
+
if (running && !controller.signal.aborted) timer = setTimeout(refreshStatus, 2500)
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
void refreshStatus()
|
|
297
|
+
return () => { controller.abort(); clearTimeout(timer) }
|
|
298
|
+
}, [canRead, opened, running, sessionId, refreshCount])
|
|
299
|
+
if (!canRead || !opened || result?.sessionId !== sessionId || !result.graph) return null
|
|
300
|
+
return h(GraphPanel, { key: sessionId, graph: result.graph, t, busy: result.busy, failed: result.failed, refresh })
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
return {
|
|
305
|
+
inject: ['slots', 'locale', 'remote'],
|
|
306
|
+
async apply(ctx) {
|
|
307
|
+
ctx.effect(() => ctx.locale.register('projectTransactionGraph', locale))
|
|
308
|
+
const unmount = await ctx.remote.$mount(contribution)
|
|
309
|
+
ctx.effect(() => unmount)
|
|
310
|
+
ctx.inject(['remote.transactionGraphView'], mounted => {
|
|
311
|
+
const Dock = makeDock(mounted.remote.transactionGraphView)
|
|
312
|
+
mounted.slots.inject('conversation.input.dock', () => mounted.slots.register({
|
|
313
|
+
name: 'conversation.input.dock', id: 'project-transaction-graph', locale: 'projectTransactionGraph', order: 10,
|
|
314
|
+
}, Dock))
|
|
315
|
+
})
|
|
316
|
+
},
|
|
317
|
+
}
|
|
318
|
+
}
|