@jcy2387/dsh-conversation-ui 0.1.0-rc.12

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.
Files changed (40) hide show
  1. package/LICENSE +21 -0
  2. package/README.en.md +123 -0
  3. package/README.md +123 -0
  4. package/conversation-ui-off.yml +3 -0
  5. package/cordis.patch.yml +10 -0
  6. package/lib/client.js +3672 -0
  7. package/lib/client.js.map +1 -0
  8. package/lib/index.js +302 -0
  9. package/package.json +182 -0
  10. package/src/boot-config.ts +28 -0
  11. package/src/client/AnimatedDisclosure.tsx +89 -0
  12. package/src/client/ConversationCard.module.css +253 -0
  13. package/src/client/ConversationCard.tsx +122 -0
  14. package/src/client/DeliverablesCard.module.css +187 -0
  15. package/src/client/DeliverablesCard.tsx +142 -0
  16. package/src/client/FollowHost.tsx +25 -0
  17. package/src/client/ToolActivityGroup.tsx +341 -0
  18. package/src/client/TurnPreludeUserNodeView.tsx +258 -0
  19. package/src/client/TurnProcessMember.tsx +92 -0
  20. package/src/client/TypewriterAssistantNodeView.module.css +1260 -0
  21. package/src/client/TypewriterAssistantNodeView.tsx +390 -0
  22. package/src/client/TypewriterToolNodeView.tsx +916 -0
  23. package/src/client/conversation-ui-card-controller.ts +162 -0
  24. package/src/client/conversation-ui-settings-api.ts +65 -0
  25. package/src/client/deliverables.ts +263 -0
  26. package/src/client/index.ts +242 -0
  27. package/src/client/locales.ts +74 -0
  28. package/src/client/teleprompterGlide.ts +590 -0
  29. package/src/client/turnElapsed.ts +27 -0
  30. package/src/client/turnProcessFold.ts +224 -0
  31. package/src/client/useConversationContent.ts +471 -0
  32. package/src/client/useFpsGuard.ts +84 -0
  33. package/src/config.ts +44 -0
  34. package/src/css-modules.d.ts +9 -0
  35. package/src/index.ts +2 -0
  36. package/src/package-meta.ts +39 -0
  37. package/src/plugin.ts +155 -0
  38. package/src/profile-installation.ts +121 -0
  39. package/src/settings-api.ts +33 -0
  40. package/src/settings.ts +30 -0
@@ -0,0 +1,253 @@
1
+ /* Mirrors the shipped PluginCard chrome without importing an internal module
2
+ from another client package. */
3
+
4
+ .card {
5
+ list-style: none;
6
+ display: flex;
7
+ flex-direction: column;
8
+ border: 1px solid var(--dsw-alias-border-l2);
9
+ border-radius: 12px;
10
+ background: var(--dsw-alias-bg-layer-3);
11
+ transition: border-color .16s, background .16s;
12
+ }
13
+
14
+ .card:hover {
15
+ border-color: var(--dsw-alias-label-dimmed);
16
+ }
17
+
18
+ .cardOpen {
19
+ border-color: var(--dsw-alias-label-dimmed);
20
+ background: var(--dsw-alias-bg-layer-2);
21
+ }
22
+
23
+ .header {
24
+ appearance: none;
25
+ display: flex;
26
+ align-items: center;
27
+ gap: 12px;
28
+ width: 100%;
29
+ padding: 14px 16px;
30
+ border: 0;
31
+ border-radius: 12px;
32
+ background: none;
33
+ cursor: pointer;
34
+ text-align: left;
35
+ font: inherit;
36
+ color: inherit;
37
+ }
38
+
39
+ .header:focus-visible {
40
+ outline: 2px solid var(--dsw-alias-brand-primary);
41
+ outline-offset: -2px;
42
+ }
43
+
44
+ .headText {
45
+ display: flex;
46
+ flex-direction: column;
47
+ gap: 4px;
48
+ min-width: 0;
49
+ flex: 1;
50
+ }
51
+
52
+ .name {
53
+ font-weight: 600;
54
+ font-size: 15px;
55
+ line-height: 1.4;
56
+ color: var(--dsw-alias-label-primary);
57
+ }
58
+
59
+ .description {
60
+ font-size: 13px;
61
+ line-height: 1.5;
62
+ color: var(--dsw-alias-label-tertiary);
63
+ }
64
+
65
+ .version {
66
+ flex: none;
67
+ max-width: 16rem;
68
+ overflow: hidden;
69
+ text-overflow: ellipsis;
70
+ white-space: nowrap;
71
+ font-size: 12px;
72
+ line-height: 1.5;
73
+ color: var(--dsw-alias-label-tertiary);
74
+ }
75
+
76
+ .pending {
77
+ flex: none;
78
+ padding: 1px 8px;
79
+ border-radius: 999px;
80
+ background: var(--dsw-alias-bg-module-platform);
81
+ color: var(--dsw-alias-label-secondary);
82
+ font-size: 11px;
83
+ font-weight: 500;
84
+ line-height: 17px;
85
+ white-space: nowrap;
86
+ }
87
+
88
+ .chevron {
89
+ flex: none;
90
+ color: var(--dsw-alias-label-tertiary);
91
+ transition: transform .16s;
92
+ }
93
+
94
+ .chevronOpen {
95
+ transform: rotate(180deg);
96
+ }
97
+
98
+ .body {
99
+ display: flex;
100
+ flex-direction: column;
101
+ margin: 0 16px;
102
+ padding-bottom: 8px;
103
+ border-top: 1px solid var(--dsw-alias-border-l2);
104
+ }
105
+
106
+ .readOnly {
107
+ margin: 12px 0 0;
108
+ font-size: 12px;
109
+ line-height: 1.5;
110
+ color: var(--dsw-alias-label-tertiary);
111
+ }
112
+
113
+ .field {
114
+ display: flex;
115
+ flex-direction: column;
116
+ gap: 6px;
117
+ padding: 12px 0;
118
+ }
119
+
120
+ .fieldHead {
121
+ display: flex;
122
+ align-items: center;
123
+ justify-content: space-between;
124
+ gap: 8px;
125
+ }
126
+
127
+ .label {
128
+ flex: 1;
129
+ min-width: 0;
130
+ font-size: 13px;
131
+ font-weight: 500;
132
+ line-height: 1.5;
133
+ color: var(--dsw-alias-label-primary);
134
+ }
135
+
136
+ .toggle {
137
+ width: 16px;
138
+ height: 16px;
139
+ accent-color: var(--dsw-alias-brand-primary);
140
+ cursor: pointer;
141
+ }
142
+
143
+ .toggle:disabled {
144
+ cursor: default;
145
+ }
146
+
147
+ .toggle:focus-visible {
148
+ outline: 2px solid var(--dsw-alias-brand-primary);
149
+ outline-offset: 2px;
150
+ }
151
+
152
+ .hint {
153
+ margin: 0;
154
+ font-size: 12px;
155
+ line-height: 1.5;
156
+ color: var(--dsw-alias-label-tertiary);
157
+ }
158
+
159
+ .failure {
160
+ display: flex;
161
+ align-items: center;
162
+ justify-content: space-between;
163
+ gap: 12px;
164
+ padding: 12px 0;
165
+ }
166
+
167
+ .updateRow {
168
+ display: flex;
169
+ align-items: center;
170
+ justify-content: space-between;
171
+ gap: 12px;
172
+ padding: 12px 0;
173
+ border-top: 1px solid var(--dsw-alias-border-l2);
174
+ }
175
+
176
+ .updateCopy {
177
+ display: flex;
178
+ flex-direction: column;
179
+ min-width: 0;
180
+ gap: 4px;
181
+ }
182
+
183
+ .footer {
184
+ display: flex;
185
+ align-items: center;
186
+ justify-content: flex-end;
187
+ gap: 8px;
188
+ padding: 12px 0 4px;
189
+ border-top: 1px solid var(--dsw-alias-border-l2);
190
+ }
191
+
192
+ .failed {
193
+ flex: 1;
194
+ min-width: 0;
195
+ margin: 0;
196
+ font-size: 12px;
197
+ line-height: 1.5;
198
+ color: var(--dsw-alias-label-error);
199
+ }
200
+
201
+ .save,
202
+ .discard,
203
+ .update {
204
+ appearance: none;
205
+ border: 1px solid transparent;
206
+ border-radius: 8px;
207
+ font: inherit;
208
+ font-size: 13px;
209
+ line-height: 1.5;
210
+ padding: 5px 14px;
211
+ cursor: pointer;
212
+ }
213
+
214
+ .save {
215
+ background: var(--dsw-alias-label-primary);
216
+ color: var(--dsw-alias-bg-layer-3);
217
+ }
218
+
219
+ .discard {
220
+ border-color: var(--dsw-alias-border-l2);
221
+ background: none;
222
+ color: var(--dsw-alias-label-secondary);
223
+ }
224
+
225
+ .discard:hover:not(:disabled),
226
+ .update:hover:not(:disabled) {
227
+ border-color: var(--dsw-alias-label-dimmed);
228
+ color: var(--dsw-alias-label-primary);
229
+ }
230
+
231
+ .update {
232
+ display: inline-flex;
233
+ align-items: center;
234
+ flex: none;
235
+ gap: 6px;
236
+ border-color: var(--dsw-alias-border-l2);
237
+ background: none;
238
+ color: var(--dsw-alias-label-secondary);
239
+ }
240
+
241
+ .discard:disabled,
242
+ .save:disabled,
243
+ .update:disabled {
244
+ opacity: 0.4;
245
+ cursor: default;
246
+ }
247
+
248
+ .discard:focus-visible,
249
+ .save:focus-visible,
250
+ .update:focus-visible {
251
+ outline: 2px solid var(--dsw-alias-brand-primary);
252
+ outline-offset: 1px;
253
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * The conversation-ui plugin configuration card, rendered inside the Web
3
+ * Settings "plugin configuration" page. One toggle, staged until the user
4
+ * saves — the same shape as the Host-shipped cards, hand-drawn because the
5
+ * Host cards' chrome is not exported for reuse.
6
+ */
7
+
8
+ import { useState } from 'react'
9
+ import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
10
+ import type {} from '@deepseek-ai/dsh-client-ui-settings-plugins/client'
11
+ import { IconChevronDownOutline14, IconRefreshOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
12
+ import type { ConversationCardFace } from './conversation-ui-card-controller.ts'
13
+ import css from './ConversationCard.module.css'
14
+
15
+ /** Props the renderer binds for the conversation-ui card. */
16
+ export type ConversationCardProps =
17
+ PropsRuntime<'settings.plugin.item'>
18
+ & PropsLocale<'settings.conversationUi'>
19
+ & InjectFace<ConversationCardFace>
20
+
21
+ /** Render the conversation-ui card independently of the core settings namespace allowlist. */
22
+ export function ConversationCard(props: ConversationCardProps) {
23
+ const { t } = props
24
+ const [open, setOpen] = useState(false)
25
+ const state = props.useConversationUiCard(snapshot => snapshot)
26
+ const blocked = !state.dirty || state.saving || state.status !== 'ready'
27
+ const versionLabel = state.version === undefined
28
+ ? null
29
+ : t(state.installation === 'development' ? 'developmentVersion' : 'version')
30
+ .replace('{version}', state.version)
31
+
32
+ return (
33
+ <li className={open ? `${css.card} ${css.cardOpen}` : css.card}>
34
+ <button
35
+ type="button"
36
+ className={css.header}
37
+ aria-expanded={open}
38
+ onClick={() => { setOpen(!open) }}
39
+ >
40
+ <span className={css.headText}>
41
+ <span className={css.name}>{t('title')}</span>
42
+ <span className={css.description}>{t('description')}</span>
43
+ </span>
44
+ {versionLabel === null ? null : <span className={css.version}>{versionLabel}</span>}
45
+ {state.dirty ? <span className={css.pending}>{t('unsaved')}</span> : null}
46
+ <IconChevronDownOutline14 className={open ? `${css.chevron} ${css.chevronOpen}` : css.chevron} />
47
+ </button>
48
+ {open
49
+ ? (
50
+ <div className={css.body}>
51
+ {state.status === 'loading' ? <p className={css.readOnly} role="status">{t('loading')}</p> : null}
52
+ {state.status === 'unavailable' ? (
53
+ <div className={css.failure}>
54
+ <p className={css.readOnly} role="status">{t('unavailable')}</p>
55
+ <button type="button" className={css.discard} onClick={props.reload}>{t('retry')}</button>
56
+ </div>
57
+ ) : null}
58
+ {state.status === 'ready' ? (
59
+ <>
60
+ {!state.writable ? <p className={css.readOnly} role="status">{t('readOnly')}</p> : null}
61
+ <label className={css.field}>
62
+ <span className={css.fieldHead}>
63
+ <span className={css.label}>{t('thinkAutoExpand')}</span>
64
+ <input
65
+ type="checkbox"
66
+ className={css.toggle}
67
+ checked={state.thinkAutoExpand}
68
+ disabled={!state.writable}
69
+ onChange={(event) => { props.edit(event.target.checked) }}
70
+ />
71
+ </span>
72
+ <span className={css.hint}>{t('thinkAutoExpandHint')}</span>
73
+ </label>
74
+ <div className={css.updateRow}>
75
+ <span className={css.updateCopy}>
76
+ <span className={css.label}>{t('updates')}</span>
77
+ <span className={css.hint}>
78
+ {state.restartRequired
79
+ ? t('restartRequired')
80
+ : state.installation === 'npm' ? t('updateHint')
81
+ : state.installation === 'development' ? t('developmentBuild') : t('updateUnavailable')}
82
+ </span>
83
+ </span>
84
+ <button
85
+ type="button"
86
+ className={css.update}
87
+ disabled={!state.canUpgrade || state.upgrading || state.restartRequired}
88
+ title={state.canUpgrade ? undefined : t('updateUnavailable')}
89
+ onClick={props.upgrade}
90
+ >
91
+ <span aria-hidden="true"><IconRefreshOutline14 /></span>
92
+ {t(state.upgrading ? 'updating' : 'update')}
93
+ </button>
94
+ </div>
95
+ {state.upgradeFailed ? <p className={css.failed} role="status">{t('updateFailed')}</p> : null}
96
+ <div className={css.footer}>
97
+ {state.failed ? <p className={css.failed} role="status">{t('saveFailed')}</p> : null}
98
+ <button
99
+ type="button"
100
+ className={css.discard}
101
+ disabled={!state.dirty || state.saving}
102
+ onClick={props.discard}
103
+ >
104
+ {t('discard')}
105
+ </button>
106
+ <button
107
+ type="button"
108
+ className={css.save}
109
+ disabled={blocked}
110
+ onClick={props.save}
111
+ >
112
+ {t(state.saving ? 'saving' : 'save')}
113
+ </button>
114
+ </div>
115
+ </>
116
+ ) : null}
117
+ </div>
118
+ )
119
+ : null}
120
+ </li>
121
+ )
122
+ }
@@ -0,0 +1,187 @@
1
+ :global([data-turn-tail]:has([data-stream-deliverables-card]) > *:has([data-produced-files-row])) {
2
+ display: none !important;
3
+ }
4
+
5
+ .root {
6
+ display: flex;
7
+ flex-direction: column;
8
+ gap: 0;
9
+ margin-top: 16px;
10
+ padding: 12px 12px 10px;
11
+ border: 1px solid var(--dsw-alias-border-l2);
12
+ border-radius: 12px;
13
+ background: var(--dsw-alias-bg-primary);
14
+ color: var(--dsw-alias-label-secondary);
15
+ font-size: 14px;
16
+ line-height: 22px;
17
+ box-sizing: border-box;
18
+ }
19
+
20
+ .header {
21
+ display: flex;
22
+ align-items: flex-start;
23
+ gap: 12px;
24
+ min-width: 0;
25
+ padding: 4px 8px 12px;
26
+ border-bottom: 1px solid var(--dsw-alias-border-l2);
27
+ }
28
+
29
+ .headerIcon {
30
+ display: inline-flex;
31
+ width: 40px;
32
+ height: 40px;
33
+ flex: none;
34
+ align-items: center;
35
+ justify-content: center;
36
+ border-radius: 10px;
37
+ background: var(--dsw-alias-interactive-bg-hover);
38
+ color: var(--dsw-alias-label-secondary);
39
+ }
40
+
41
+ .heading {
42
+ display: flex;
43
+ min-width: 0;
44
+ flex-direction: column;
45
+ gap: 1px;
46
+ }
47
+
48
+ .title {
49
+ color: var(--dsw-alias-label-primary);
50
+ font-size: 16px;
51
+ font-weight: 600;
52
+ line-height: 24px;
53
+ }
54
+
55
+ .subtitle {
56
+ margin: 0;
57
+ padding: 0;
58
+ border: 0;
59
+ background: transparent;
60
+ color: var(--dsw-alias-label-tertiary);
61
+ font: inherit;
62
+ font-size: 14px;
63
+ line-height: 20px;
64
+ text-align: start;
65
+ cursor: pointer;
66
+ }
67
+
68
+ .subtitle:hover {
69
+ color: var(--dsw-alias-label-secondary);
70
+ text-decoration: underline;
71
+ }
72
+
73
+ .list {
74
+ display: flex;
75
+ flex-direction: column;
76
+ min-width: 0;
77
+ padding: 10px 8px 0;
78
+ }
79
+
80
+ .item {
81
+ display: flex;
82
+ align-items: center;
83
+ width: 100%;
84
+ min-width: 0;
85
+ min-height: 28px;
86
+ gap: 0;
87
+ margin: 0;
88
+ padding: 2px 0;
89
+ border: 0;
90
+ border-radius: 5px;
91
+ background: transparent;
92
+ color: inherit;
93
+ font: inherit;
94
+ line-height: 22px;
95
+ text-align: start;
96
+ cursor: pointer;
97
+ }
98
+
99
+ .item:hover {
100
+ color: var(--dsw-alias-label-primary);
101
+ }
102
+
103
+ .item:focus-visible,
104
+ .more:focus-visible,
105
+ .subtitle:focus-visible {
106
+ outline: 2px solid var(--dsw-alias-border-l3);
107
+ outline-offset: 1px;
108
+ }
109
+
110
+ .filePath,
111
+ .websitePath {
112
+ display: flex;
113
+ min-width: 0;
114
+ flex: 1 1 auto;
115
+ overflow: hidden;
116
+ white-space: nowrap;
117
+ }
118
+
119
+ .parentPath {
120
+ min-width: 0;
121
+ overflow: hidden;
122
+ color: var(--dsw-alias-label-tertiary);
123
+ text-overflow: ellipsis;
124
+ white-space: nowrap;
125
+ }
126
+
127
+ .fileName {
128
+ flex: none;
129
+ color: var(--dsw-alias-label-secondary);
130
+ }
131
+
132
+ .websitePath {
133
+ overflow: hidden;
134
+ color: var(--dsw-alias-label-secondary);
135
+ text-overflow: ellipsis;
136
+ }
137
+
138
+ .stats {
139
+ display: inline-flex;
140
+ flex: none;
141
+ gap: 6px;
142
+ margin-inline-start: 8px;
143
+ font-size: 13px;
144
+ line-height: 22px;
145
+ white-space: nowrap;
146
+ }
147
+
148
+ .added {
149
+ color: var(--dsw-alias-label-success, #2f9e44);
150
+ }
151
+
152
+ .removed {
153
+ color: var(--dsw-alias-label-danger, #d94841);
154
+ }
155
+
156
+ .external {
157
+ flex: none;
158
+ color: var(--dsw-alias-label-tertiary);
159
+ }
160
+
161
+ .more {
162
+ display: inline-flex;
163
+ align-items: center;
164
+ align-self: flex-start;
165
+ gap: 5px;
166
+ margin: 8px 8px 0;
167
+ padding: 0;
168
+ border: 0;
169
+ background: transparent;
170
+ color: var(--dsw-alias-label-secondary);
171
+ font: inherit;
172
+ line-height: 22px;
173
+ cursor: pointer;
174
+ }
175
+
176
+ .more:hover {
177
+ color: var(--dsw-alias-label-primary);
178
+ }
179
+
180
+ .moreIcon {
181
+ display: inline-flex;
182
+ width: 14px;
183
+ height: 14px;
184
+ align-items: center;
185
+ justify-content: center;
186
+ flex: none;
187
+ }
@@ -0,0 +1,142 @@
1
+ import { useLayoutEffect, useState } from 'react'
2
+ import type { TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
3
+ import {
4
+ IconChevronDownOutline14,
5
+ IconChevronUpOutline14,
6
+ IconGlobeOutline14,
7
+ IconProjectAddOutline16,
8
+ } from '@deepseek-ai/dsh-client-ui-primitives'
9
+ import type { DeliverableEntry } from './deliverables.ts'
10
+ import css from './DeliverablesCard.module.css'
11
+
12
+ export interface DeliverablesCardProps extends TurnTailOwnerProps {
13
+ readonly matched: readonly DeliverableEntry[]
14
+ }
15
+
16
+ function isWebsite(entry: DeliverableEntry): boolean {
17
+ return entry.kind === 'website' || /^https?:\/\//i.test(entry.path)
18
+ }
19
+
20
+ function openWebsite(path: string): void {
21
+ if (typeof window !== 'undefined') window.open(path, '_blank', 'noopener,noreferrer')
22
+ }
23
+
24
+ function pathParts(path: string): { parent: string; name: string } {
25
+ const separator = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'))
26
+ if (separator < 0) return { parent: '', name: path }
27
+ return { parent: path.slice(0, separator + 1), name: path.slice(separator + 1) }
28
+ }
29
+
30
+ function hiddenKindLabel(entries: readonly DeliverableEntry[]): string {
31
+ if (entries.every(isWebsite)) return '网站'
32
+ if (entries.every(entry => !isWebsite(entry))) return '文件'
33
+ return '产物'
34
+ }
35
+
36
+ function countLabel(entries: readonly DeliverableEntry[]): string {
37
+ const files = entries.filter(entry => !isWebsite(entry)).length
38
+ const websites = entries.length - files
39
+ if (files > 0 && websites === 0) return `已编辑 ${files} 个文件`
40
+ if (files === 0) return `交付 ${websites} 个网站`
41
+ return `已交付 ${entries.length} 项产物`
42
+ }
43
+
44
+ interface EditStats {
45
+ readonly added: number
46
+ readonly removed: number
47
+ }
48
+
49
+ function collectEditStats(turn: number): ReadonlyMap<string, EditStats> {
50
+ const result = new Map<string, EditStats>()
51
+ if (typeof document === 'undefined') return result
52
+ for (const element of document.querySelectorAll<HTMLElement>('[data-stream-edit-stats]')) {
53
+ const row = element.closest<HTMLElement>('[data-stream-turn]')
54
+ if (row?.getAttribute('data-stream-turn') !== String(turn)) continue
55
+ const path = element.getAttribute('data-stream-edit-file')
56
+ const match = /\+(\d+)\s+-\s*(\d+)/.exec(element.textContent ?? '')
57
+ if (path === null || match === null) continue
58
+ result.set(path, { added: Number(match[1]), removed: Number(match[2]) })
59
+ }
60
+ return result
61
+ }
62
+
63
+ /** Codex-style turn-tail card for edited files and deployed websites. */
64
+ export function DeliverablesCard({ matched, turn, openFile }: DeliverablesCardProps) {
65
+ const [expanded, setExpanded] = useState(false)
66
+ const [domStats, setDomStats] = useState<ReadonlyMap<string, EditStats>>(() => new Map())
67
+ useLayoutEffect(() => {
68
+ setDomStats(collectEditStats(turn.turn))
69
+ }, [turn.turn, matched])
70
+ const entries = matched.map(entry => {
71
+ const stats = domStats.get(entry.path)
72
+ return stats === undefined || entry.added > 0 || entry.removed > 0 ? entry : { ...entry, ...stats }
73
+ })
74
+ const visible = expanded ? entries : entries.slice(0, 3)
75
+ const hidden = entries.length - visible.length
76
+ const summary = countLabel(entries)
77
+ const hasFiles = entries.some(entry => !isWebsite(entry))
78
+ const firstWebsite = entries.find(isWebsite)
79
+ return (
80
+ <section className={css.root} data-stream-deliverables-card data-stream-deliverables-count={entries.length}>
81
+ <div className={css.header}>
82
+ <span className={css.headerIcon} aria-hidden>
83
+ {hasFiles ? <IconProjectAddOutline16 size={20} /> : <IconGlobeOutline14 size={20} />}
84
+ </span>
85
+ <div className={css.heading}>
86
+ <strong className={css.title}>{summary}</strong>
87
+ {hasFiles ? (
88
+ <button type="button" className={css.subtitle} onClick={() => { setExpanded(true) }}>
89
+ 查看更改 ↗
90
+ </button>
91
+ ) : firstWebsite !== undefined ? (
92
+ <button type="button" className={css.subtitle} onClick={() => { openWebsite(firstWebsite.path) }}>
93
+ 打开网站 ↗
94
+ </button>
95
+ ) : null}
96
+ </div>
97
+ </div>
98
+ <div className={css.list}>
99
+ {visible.map(entry => {
100
+ const website = isWebsite(entry)
101
+ const parts = pathParts(entry.path)
102
+ return (
103
+ <button
104
+ key={`${entry.kind}:${entry.path}`}
105
+ type="button"
106
+ className={css.item}
107
+ title={entry.path}
108
+ onClick={() => { website ? openWebsite(entry.path) : openFile(entry.path) }}
109
+ >
110
+ {website ? (
111
+ <span className={css.websitePath}>{entry.path}</span>
112
+ ) : (
113
+ <span className={css.filePath}>
114
+ {parts.parent !== '' && <span className={css.parentPath}>{parts.parent}</span>}
115
+ <span className={css.fileName}>{parts.name || entry.path}</span>
116
+ </span>
117
+ )}
118
+ {!website && (entry.added > 0 || entry.removed > 0) && (
119
+ <span className={css.stats}>
120
+ {entry.added > 0 && <span className={css.added}>+{entry.added}</span>}
121
+ {entry.removed > 0 && <span className={css.removed}>-{entry.removed}</span>}
122
+ </span>
123
+ )}
124
+ {website && <span className={css.external} aria-hidden>↗</span>}
125
+ </button>
126
+ )
127
+ })}
128
+ </div>
129
+ {hidden > 0 && (
130
+ <button type="button" className={css.more} onClick={() => { setExpanded(true) }}>
131
+ 再显示 {hidden} 个{hiddenKindLabel(entries.slice(visible.length))}
132
+ <span className={css.moreIcon} aria-hidden><IconChevronDownOutline14 /></span>
133
+ </button>
134
+ )}
135
+ {expanded && entries.length > 3 && (
136
+ <button type="button" className={css.more} onClick={() => { setExpanded(false) }}>
137
+ 收起 <span className={css.moreIcon} aria-hidden><IconChevronUpOutline14 /></span>
138
+ </button>
139
+ )}
140
+ </section>
141
+ )
142
+ }