dsh-autorestart 0.2.1 → 0.3.1
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 +0 -0
- package/README.md +199 -312
- package/README.zh.md +504 -0
- package/assets/icon-180.png +0 -0
- package/assets/screenshot-1.png +0 -0
- package/assets/screenshot-2.png +0 -0
- package/assets/screenshot-3.png +0 -0
- package/client.js +267 -8
- package/cordis.patch.yml +0 -0
- package/icon.svg +0 -0
- package/index.js +1393 -50
- package/lib/checkpoint.js +516 -2
- package/lib/lifecycle.js +411 -2
- package/lib/restart.js +0 -0
- package/locale/en.json +0 -0
- package/locale/zh.json +0 -0
- package/package.json +6 -3
- package/screenshots.json +5 -0
package/client.js
CHANGED
|
@@ -28,6 +28,20 @@
|
|
|
28
28
|
* block it (by title when the Host provided one) and how long this
|
|
29
29
|
* session has been waiting.
|
|
30
30
|
*
|
|
31
|
+
* Goal-aware states on top of that:
|
|
32
|
+
*
|
|
33
|
+
* - a blocker whose reason is `goal-active` is a session whose armed goal
|
|
34
|
+
* keeps driving rounds by itself, so it holds the restart on its own.
|
|
35
|
+
* The pill shows that state and offers a button which parks that goal
|
|
36
|
+
* and asks for the restart immediately (`POST <parkUrl>` with
|
|
37
|
+
* `{ sessionId, restart: true }`);
|
|
38
|
+
* - a blocker whose reason is `goal-exhausted` cannot be parked at all
|
|
39
|
+
* (the round budget of its goal is used up), so the pill explains that
|
|
40
|
+
* and offers no button for it;
|
|
41
|
+
* - the Host also reports the goals it already parked (`parkedGoals`): the
|
|
42
|
+
* pill says how many were parked and that they continue automatically
|
|
43
|
+
* after the restart, and the details panel lists them.
|
|
44
|
+
*
|
|
31
45
|
* Everything here is side-effect free until `apply(ctx)` runs: no timers, no
|
|
32
46
|
* fetches, no DOM access at module scope.
|
|
33
47
|
*/
|
|
@@ -40,6 +54,12 @@ window.__ModuleLoader__.load({
|
|
|
40
54
|
|
|
41
55
|
const NS = 'dshAutorestart'
|
|
42
56
|
const HEALTH_URL = '/__dsh_autorestart/health?state=1'
|
|
57
|
+
// The route prefix is derived from the health URL, so the park route is
|
|
58
|
+
// never spelled out twice: the health path without its last segment.
|
|
59
|
+
// The Host sends the authoritative `parkUrl` in the state payload; this
|
|
60
|
+
// is only the fallback for an older Host.
|
|
61
|
+
const ROUTE_PREFIX = HEALTH_URL.split('?')[0].replace(/\/[^/]*$/, '')
|
|
62
|
+
const DEFAULT_PARK_URL = ROUTE_PREFIX + '/park'
|
|
43
63
|
const POLL_MS = 2000
|
|
44
64
|
// The brand accent (blue-purple). The pending-restart marker is a brand
|
|
45
65
|
// signal, not a warning: no warning/yellow token is used here.
|
|
@@ -65,13 +85,27 @@ window.__ModuleLoader__.load({
|
|
|
65
85
|
busyPrefix: ' · 被 ',
|
|
66
86
|
busySuffix: ' 个会话占用',
|
|
67
87
|
idle: ' · 空闲后自动重启',
|
|
88
|
+
goalActive: ' · 被某个会话自动续跑的 goal 占着',
|
|
89
|
+
goalExhausted: ' · 某个会话的 goal 轮次额度已用完,无法续跑',
|
|
90
|
+
parkedGoals: ' · 已停泊 {n} 个 goal,重启后自动继续',
|
|
91
|
+
parkButton: '停泊并重启',
|
|
92
|
+
parkButtonAria: '停泊该会话的 goal,并立即请求重启',
|
|
93
|
+
parkBusy: '停泊中…',
|
|
94
|
+
parkError: '停泊失败:{message}',
|
|
68
95
|
tooltip: '等待其他会话空闲后自动重启;重启后会自动继续本会话进度',
|
|
96
|
+
tooltipWithError: '等待其他会话空闲后自动重启;重启后会自动继续本会话进度。{message}',
|
|
69
97
|
dotAria: '待重启',
|
|
70
98
|
panelHeading: '待重启',
|
|
71
99
|
panelStateBusy: '等待其他会话空闲后重启',
|
|
72
100
|
panelStateIdle: '等待空闲窗口后重启',
|
|
101
|
+
panelStateGoalActive: '等待其他会话停泊它自动续跑的 goal',
|
|
102
|
+
panelStateGoalExhausted: '等待其他会话的 goal(轮次额度已用完)',
|
|
73
103
|
panelBlockedTitle: '占用会话:{title}',
|
|
74
104
|
panelBlockedGeneric: '有一个会话正在运行',
|
|
105
|
+
panelGoalActiveLine: '占用会话「{title}」:goal 正在自动续跑(goal-active)',
|
|
106
|
+
panelGoalExhaustedLine: '占用会话「{title}」:goal 轮次额度已用完,无法继续(goal-exhausted)',
|
|
107
|
+
panelParkedTitle: '已停泊的 goal(重启后自动继续):',
|
|
108
|
+
panelParkedGoal: '会话 {session} · {goal}',
|
|
75
109
|
panelWaiting: '已等待 {duration}',
|
|
76
110
|
panelResume: '重启后本会话会自动继续进度',
|
|
77
111
|
durationSeconds: '{n} 秒',
|
|
@@ -84,14 +118,29 @@ window.__ModuleLoader__.load({
|
|
|
84
118
|
busyPrefix: ' · blocked by ',
|
|
85
119
|
busySuffix: ' session(s)',
|
|
86
120
|
idle: ' · restarts once idle',
|
|
121
|
+
goalActive: ' · held by a self-continuing goal',
|
|
122
|
+
goalExhausted: ' · a goal ran out of rounds',
|
|
123
|
+
parkedGoals: ' · {n} goal(s) parked, they continue after the restart',
|
|
124
|
+
parkButton: 'Park & restart',
|
|
125
|
+
parkButtonAria: 'Park that session goal and request the restart now',
|
|
126
|
+
parkBusy: 'Parking…',
|
|
127
|
+
parkError: 'Park failed: {message}',
|
|
87
128
|
tooltip:
|
|
88
129
|
'Waits for the other sessions to become idle, then restarts automatically; this session resumes its progress after the restart.',
|
|
130
|
+
tooltipWithError:
|
|
131
|
+
'Waits for the other sessions to become idle, then restarts automatically; this session resumes its progress after the restart. {message}',
|
|
89
132
|
dotAria: 'Pending restart',
|
|
90
133
|
panelHeading: 'Pending restart',
|
|
91
134
|
panelStateBusy: 'Waiting for the other sessions to become idle',
|
|
92
135
|
panelStateIdle: 'Waiting for the idle window',
|
|
136
|
+
panelStateGoalActive: 'Waiting for the other sessions to park their self-continuing goal',
|
|
137
|
+
panelStateGoalExhausted: 'Waiting for the other sessions\' goal (its round budget is used up)',
|
|
93
138
|
panelBlockedTitle: 'Blocking session: {title}',
|
|
94
139
|
panelBlockedGeneric: 'One session is running',
|
|
140
|
+
panelGoalActiveLine: 'Blocking session "{title}": the goal keeps driving rounds by itself (goal-active)',
|
|
141
|
+
panelGoalExhaustedLine: 'Blocking session "{title}": the goal ran out of rounds and cannot continue (goal-exhausted)',
|
|
142
|
+
panelParkedTitle: 'Parked goals (they continue after the restart):',
|
|
143
|
+
panelParkedGoal: 'session {session} · {goal}',
|
|
95
144
|
panelWaiting: 'Waiting for {duration}',
|
|
96
145
|
panelResume: 'This session resumes its progress automatically after the restart.',
|
|
97
146
|
durationSeconds: '{n}s',
|
|
@@ -274,6 +323,24 @@ window.__ModuleLoader__.load({
|
|
|
274
323
|
return fill(t('durationHours'), { n: hours, m: minutes % 60 })
|
|
275
324
|
}
|
|
276
325
|
|
|
326
|
+
/**
|
|
327
|
+
* The park route to POST to: the one the Host sent with the state payload,
|
|
328
|
+
* or the one derived from the health path when the Host is older.
|
|
329
|
+
*/
|
|
330
|
+
function parkUrlFor(info) {
|
|
331
|
+
const sent = info && typeof info.parkUrl === 'string' ? info.parkUrl.trim() : ''
|
|
332
|
+
if (sent.startsWith('/')) return sent
|
|
333
|
+
return DEFAULT_PARK_URL
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** A short, human readable description of a failed request. */
|
|
337
|
+
function describeError(error) {
|
|
338
|
+
if (!error) return 'unknown error'
|
|
339
|
+
if (typeof error === 'string') return error
|
|
340
|
+
if (typeof error.message === 'string' && error.message !== '') return error.message
|
|
341
|
+
return String(error)
|
|
342
|
+
}
|
|
343
|
+
|
|
277
344
|
// The pill sits in the composer tool row, so it must fit the toolbar
|
|
278
345
|
// height (~22px) and never push that row around: `flex: 'none'` plus a
|
|
279
346
|
// fixed height, and the text is clipped with an ellipsis instead of
|
|
@@ -380,6 +447,42 @@ window.__ModuleLoader__.load({
|
|
|
380
447
|
opacity: 0.75,
|
|
381
448
|
}
|
|
382
449
|
|
|
450
|
+
// The park button lives inside the pill and reuses the pill's own button
|
|
451
|
+
// look (same tokens, same border, same radius), only smaller so the pill
|
|
452
|
+
// stays a one-line chip. It is inline (no absolute/fixed positioning, no
|
|
453
|
+
// negative margins) and never becomes a second element in the slot.
|
|
454
|
+
const PARK_BUTTON_STYLE = {
|
|
455
|
+
...PILL_STYLE,
|
|
456
|
+
height: 18,
|
|
457
|
+
padding: '0 6px',
|
|
458
|
+
fontSize: 11,
|
|
459
|
+
lineHeight: '16px',
|
|
460
|
+
gap: 4,
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// The park button while its request is in flight.
|
|
464
|
+
const PARK_BUTTON_BUSY_STYLE = {
|
|
465
|
+
...PARK_BUTTON_STYLE,
|
|
466
|
+
cursor: 'default',
|
|
467
|
+
opacity: 0.6,
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// The short "N goals parked" line inside the pill.
|
|
471
|
+
const PARKED_LINE_STYLE = {
|
|
472
|
+
minWidth: 0,
|
|
473
|
+
overflow: 'hidden',
|
|
474
|
+
textOverflow: 'ellipsis',
|
|
475
|
+
whiteSpace: 'nowrap',
|
|
476
|
+
opacity: 0.75,
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// The inline error line of the details panel.
|
|
480
|
+
const PANEL_ERROR_STYLE = {
|
|
481
|
+
margin: '2px 0',
|
|
482
|
+
fontWeight: 600,
|
|
483
|
+
color: 'var(--dsw-alias-danger, #d14343)',
|
|
484
|
+
}
|
|
485
|
+
|
|
383
486
|
/**
|
|
384
487
|
* One compact pill in the composer tool row, visible only while THIS
|
|
385
488
|
* session is waiting for a pending restart. Clicking it (or pressing
|
|
@@ -390,8 +493,13 @@ window.__ModuleLoader__.load({
|
|
|
390
493
|
const hasSession = sessionId !== null && sessionId !== ''
|
|
391
494
|
const info = useSharedInfo(hasSession)
|
|
392
495
|
const [open, setOpen] = useState(false)
|
|
496
|
+
// The session id of the goal that is currently being parked, or null.
|
|
497
|
+
const [parking, setParking] = useState(null)
|
|
498
|
+
// The message of the last failed park request (the Host's own message).
|
|
499
|
+
const [parkError, setParkError] = useState('')
|
|
393
500
|
const [, setTick] = useState(0)
|
|
394
501
|
const wrapperRef = useRef(null)
|
|
502
|
+
const t = translate ?? ((name) => name)
|
|
395
503
|
|
|
396
504
|
const pending = Boolean(info && info.pendingRestart === true)
|
|
397
505
|
// The restart is imminent: the Host's idle gate is open, so the restart
|
|
@@ -400,7 +508,11 @@ window.__ModuleLoader__.load({
|
|
|
400
508
|
|
|
401
509
|
// The panel never outlives the pending state.
|
|
402
510
|
useEffect(() => {
|
|
403
|
-
if (!pending)
|
|
511
|
+
if (!pending) {
|
|
512
|
+
setOpen(false)
|
|
513
|
+
setParkError('')
|
|
514
|
+
setParking(null)
|
|
515
|
+
}
|
|
404
516
|
}, [pending])
|
|
405
517
|
|
|
406
518
|
// Clicking outside the panel or pressing Escape closes it. Both
|
|
@@ -440,11 +552,22 @@ window.__ModuleLoader__.load({
|
|
|
440
552
|
|
|
441
553
|
const blockedBy = Array.isArray(info.blockedBy) ? info.blockedBy : []
|
|
442
554
|
const others = blockedBy.filter((entry) => entry && String(entry.sessionId) !== key)
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
const
|
|
447
|
-
|
|
555
|
+
// A goal-driven blocker: an armed goal that keeps driving rounds by
|
|
556
|
+
// itself (`goal-active`, parkable) or a goal whose round budget is used
|
|
557
|
+
// up (`goal-exhausted`, not parkable at all).
|
|
558
|
+
const goalActive = others.filter((entry) => String(entry.reason) === 'goal-active')
|
|
559
|
+
const goalExhausted = others.filter((entry) => String(entry.reason) === 'goal-exhausted')
|
|
560
|
+
const otherBusy = others.length - goalActive.length - goalExhausted.length
|
|
561
|
+
// The goals the Host already parked: they continue after the restart.
|
|
562
|
+
const parkedGoals = Array.isArray(info.parkedGoals) ? info.parkedGoals : []
|
|
563
|
+
|
|
564
|
+
const status = goalActive.length > 0
|
|
565
|
+
? t('goalActive')
|
|
566
|
+
: goalExhausted.length > 0
|
|
567
|
+
? t('goalExhausted')
|
|
568
|
+
: otherBusy > 0
|
|
569
|
+
? `${t('busyPrefix')}${otherBusy}${t('busySuffix')}`
|
|
570
|
+
: t('idle')
|
|
448
571
|
|
|
449
572
|
if (pulsing) ensurePulseStyle()
|
|
450
573
|
|
|
@@ -458,6 +581,57 @@ window.__ModuleLoader__.load({
|
|
|
458
581
|
}
|
|
459
582
|
}
|
|
460
583
|
|
|
584
|
+
/**
|
|
585
|
+
* Park the goal of one blocking session and ask for the restart now.
|
|
586
|
+
*
|
|
587
|
+
* The Host parks that goal (so the session stops driving rounds) and
|
|
588
|
+
* makes this session a requester of the pending restart. The shared
|
|
589
|
+
* poll picks the new state up on its next tick.
|
|
590
|
+
*
|
|
591
|
+
* Never throws: a refused request (403) or a conflict (409) shows the
|
|
592
|
+
* Host's own message as an inline error line instead.
|
|
593
|
+
*/
|
|
594
|
+
const parkAndRestart = async (targetSessionId) => {
|
|
595
|
+
if (parking) return
|
|
596
|
+
setParking(targetSessionId)
|
|
597
|
+
setParkError('')
|
|
598
|
+
try {
|
|
599
|
+
const response = await fetch(parkUrlFor(info), {
|
|
600
|
+
method: 'POST',
|
|
601
|
+
headers: { 'content-type': 'application/json' },
|
|
602
|
+
body: JSON.stringify({ sessionId: targetSessionId, restart: true }),
|
|
603
|
+
})
|
|
604
|
+
let body = null
|
|
605
|
+
try {
|
|
606
|
+
body = await response.json()
|
|
607
|
+
} catch {
|
|
608
|
+
body = null
|
|
609
|
+
}
|
|
610
|
+
if (!response.ok) {
|
|
611
|
+
const message = body && typeof body === 'object'
|
|
612
|
+
? (body.error || body.message || '')
|
|
613
|
+
: ''
|
|
614
|
+
setParkError(fill(t('parkError'), { message: message || String(response.status) }))
|
|
615
|
+
setOpen(true)
|
|
616
|
+
return
|
|
617
|
+
}
|
|
618
|
+
if (body && Array.isArray(body.reported)) {
|
|
619
|
+
const reported = body.reported.find(
|
|
620
|
+
(entry) => entry && String(entry.sessionId) === String(targetSessionId),
|
|
621
|
+
)
|
|
622
|
+
if (reported) {
|
|
623
|
+
setParkError(fill(t('parkError'), { message: String(reported.reason || 'not-parked') }))
|
|
624
|
+
setOpen(true)
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
} catch (error) {
|
|
628
|
+
setParkError(fill(t('parkError'), { message: describeError(error) }))
|
|
629
|
+
setOpen(true)
|
|
630
|
+
} finally {
|
|
631
|
+
setParking(null)
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
461
635
|
const pendingSince = stateStore.pendingSince
|
|
462
636
|
const waitingMs = pendingSince === null || pendingSince === undefined
|
|
463
637
|
? 0
|
|
@@ -468,17 +642,60 @@ window.__ModuleLoader__.load({
|
|
|
468
642
|
h(
|
|
469
643
|
'div',
|
|
470
644
|
{ key: 'state', style: PANEL_LINE_STYLE },
|
|
471
|
-
|
|
645
|
+
goalActive.length > 0
|
|
646
|
+
? t('panelStateGoalActive')
|
|
647
|
+
: goalExhausted.length > 0
|
|
648
|
+
? t('panelStateGoalExhausted')
|
|
649
|
+
: otherBusy > 0
|
|
650
|
+
? t('panelStateBusy')
|
|
651
|
+
: t('panelStateIdle'),
|
|
472
652
|
),
|
|
473
653
|
]
|
|
654
|
+
if (parkError) {
|
|
655
|
+
panelLines.push(h('div', { key: 'error', style: PANEL_ERROR_STYLE }, parkError))
|
|
656
|
+
}
|
|
474
657
|
others.forEach((entry, index) => {
|
|
475
658
|
const title = entry && typeof entry.title === 'string' ? entry.title.trim() : ''
|
|
659
|
+
const reason = entry && typeof entry.reason === 'string' ? entry.reason : ''
|
|
660
|
+
const name = title || String(entry && entry.sessionId)
|
|
661
|
+
if (reason === 'goal-active') {
|
|
662
|
+
panelLines.push(h(
|
|
663
|
+
'div',
|
|
664
|
+
{ key: 'blocked-' + index, style: PANEL_LINE_STYLE },
|
|
665
|
+
fill(t('panelGoalActiveLine'), { title: name }),
|
|
666
|
+
))
|
|
667
|
+
return
|
|
668
|
+
}
|
|
669
|
+
if (reason === 'goal-exhausted') {
|
|
670
|
+
panelLines.push(h(
|
|
671
|
+
'div',
|
|
672
|
+
{ key: 'blocked-' + index, style: PANEL_LINE_STYLE },
|
|
673
|
+
fill(t('panelGoalExhaustedLine'), { title: name }),
|
|
674
|
+
))
|
|
675
|
+
return
|
|
676
|
+
}
|
|
476
677
|
panelLines.push(h(
|
|
477
678
|
'div',
|
|
478
679
|
{ key: 'blocked-' + index, style: PANEL_LINE_STYLE },
|
|
479
680
|
title ? fill(t('panelBlockedTitle'), { title }) : t('panelBlockedGeneric'),
|
|
480
681
|
))
|
|
481
682
|
})
|
|
683
|
+
if (parkedGoals.length > 0) {
|
|
684
|
+
panelLines.push(h(
|
|
685
|
+
'div',
|
|
686
|
+
{ key: 'parked-title', style: PANEL_MUTED_STYLE },
|
|
687
|
+
t('panelParkedTitle'),
|
|
688
|
+
))
|
|
689
|
+
parkedGoals.forEach((entry, index) => {
|
|
690
|
+
const parkedSession = entry && entry.sessionId != null ? String(entry.sessionId) : ''
|
|
691
|
+
const parkedGoal = entry && entry.goalId != null ? String(entry.goalId) : '-'
|
|
692
|
+
panelLines.push(h(
|
|
693
|
+
'div',
|
|
694
|
+
{ key: 'parked-' + index, style: PANEL_LINE_STYLE },
|
|
695
|
+
fill(t('panelParkedGoal'), { session: parkedSession, goal: parkedGoal }),
|
|
696
|
+
))
|
|
697
|
+
})
|
|
698
|
+
}
|
|
482
699
|
panelLines.push(h(
|
|
483
700
|
'div',
|
|
484
701
|
{ key: 'waiting', style: PANEL_LINE_STYLE },
|
|
@@ -502,7 +719,9 @@ window.__ModuleLoader__.load({
|
|
|
502
719
|
'div',
|
|
503
720
|
{
|
|
504
721
|
style: PILL_STYLE,
|
|
505
|
-
title:
|
|
722
|
+
title: parkError
|
|
723
|
+
? fill(t('tooltipWithError'), { message: parkError })
|
|
724
|
+
: t('tooltip'),
|
|
506
725
|
role: 'button',
|
|
507
726
|
tabIndex: 0,
|
|
508
727
|
'aria-haspopup': 'dialog',
|
|
@@ -512,6 +731,46 @@ window.__ModuleLoader__.load({
|
|
|
512
731
|
},
|
|
513
732
|
h('span', { style: dotStyle(DOT_STYLE, pulsing), 'aria-hidden': 'true' }),
|
|
514
733
|
h('span', { style: TEXT_STYLE }, h('b', null, t('label')), status),
|
|
734
|
+
// One park button per goal-driven blocker that can be parked. It
|
|
735
|
+
// lives inside the pill, so the slot still has a single root.
|
|
736
|
+
goalActive.map((entry, index) => {
|
|
737
|
+
const target = String(entry.sessionId)
|
|
738
|
+
const busy = parking === target
|
|
739
|
+
const onParkClick = (event) => {
|
|
740
|
+
// The button must not toggle the details panel of the pill.
|
|
741
|
+
if (event && typeof event.stopPropagation === 'function') event.stopPropagation()
|
|
742
|
+
void parkAndRestart(target)
|
|
743
|
+
}
|
|
744
|
+
const onParkKeyDown = (event) => {
|
|
745
|
+
if (!event) return
|
|
746
|
+
const pressed = event.key
|
|
747
|
+
if (pressed === 'Enter' || pressed === ' ' || pressed === 'Spacebar') {
|
|
748
|
+
// Same here: the pill's own Enter / Space handler must not run.
|
|
749
|
+
if (typeof event.stopPropagation === 'function') event.stopPropagation()
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
return h(
|
|
753
|
+
'button',
|
|
754
|
+
{
|
|
755
|
+
key: 'park-' + index,
|
|
756
|
+
type: 'button',
|
|
757
|
+
style: busy ? PARK_BUTTON_BUSY_STYLE : PARK_BUTTON_STYLE,
|
|
758
|
+
title: t('parkButtonAria'),
|
|
759
|
+
'aria-label': t('parkButtonAria'),
|
|
760
|
+
disabled: parking !== null,
|
|
761
|
+
onClick: onParkClick,
|
|
762
|
+
onKeyDown: onParkKeyDown,
|
|
763
|
+
},
|
|
764
|
+
busy ? t('parkBusy') : t('parkButton'),
|
|
765
|
+
)
|
|
766
|
+
}),
|
|
767
|
+
parkedGoals.length > 0
|
|
768
|
+
? h(
|
|
769
|
+
'span',
|
|
770
|
+
{ style: PARKED_LINE_STYLE },
|
|
771
|
+
fill(t('parkedGoals'), { n: parkedGoals.length }),
|
|
772
|
+
)
|
|
773
|
+
: null,
|
|
515
774
|
),
|
|
516
775
|
open
|
|
517
776
|
? h(
|
package/cordis.patch.yml
CHANGED
|
File without changes
|
package/icon.svg
CHANGED
|
File without changes
|