@keith9681/dsh-sidebar 0.15.2

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 (220) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +0 -0
  3. package/README_EN.md +0 -0
  4. package/cordis.patch.yml +49 -0
  5. package/lib/client-editor.js +34651 -0
  6. package/lib/client-mermaid.js +200629 -0
  7. package/lib/client-registry.js +13388 -0
  8. package/lib/client-terminal.js +8904 -0
  9. package/lib/client.js +13388 -0
  10. package/lib/index.js +3959 -0
  11. package/lib/invariant.js +22 -0
  12. package/lib/types/agent-pty.d.ts +226 -0
  13. package/lib/types/browser-probe.d.ts +15 -0
  14. package/lib/types/bundle-route.d.ts +12 -0
  15. package/lib/types/client/BrowserView.d.ts +22 -0
  16. package/lib/types/client/DiffTab.d.ts +6 -0
  17. package/lib/types/client/DiffView.d.ts +51 -0
  18. package/lib/types/client/EditorHost.d.ts +12 -0
  19. package/lib/types/client/FileTree.d.ts +36 -0
  20. package/lib/types/client/GitView.d.ts +8 -0
  21. package/lib/types/client/OrphanedTab.d.ts +11 -0
  22. package/lib/types/client/PdfView.d.ts +6 -0
  23. package/lib/types/client/RenderBoundary.d.ts +32 -0
  24. package/lib/types/client/SandboxStatusBar.d.ts +10 -0
  25. package/lib/types/client/SideCardSection.d.ts +74 -0
  26. package/lib/types/client/SideChatView.d.ts +16 -0
  27. package/lib/types/client/Sidebar.d.ts +6 -0
  28. package/lib/types/client/SubagentView.d.ts +16 -0
  29. package/lib/types/client/TabBar.d.ts +43 -0
  30. package/lib/types/client/TerminalView.d.ts +24 -0
  31. package/lib/types/client/TextEditor.d.ts +10 -0
  32. package/lib/types/client/TreePanel.d.ts +23 -0
  33. package/lib/types/client/UploadOverlay.d.ts +20 -0
  34. package/lib/types/client/add-plugin-modal.d.ts +15 -0
  35. package/lib/types/client/api.d.ts +268 -0
  36. package/lib/types/client/binary-download.d.ts +12 -0
  37. package/lib/types/client/breakpoints.d.ts +12 -0
  38. package/lib/types/client/browser.d.ts +50 -0
  39. package/lib/types/client/builtins/index.d.ts +18 -0
  40. package/lib/types/client/builtins/tabs.d.ts +11 -0
  41. package/lib/types/client/builtins/viewers.d.ts +3 -0
  42. package/lib/types/client/chunk-loader.d.ts +124 -0
  43. package/lib/types/client/chunks/editor.d.ts +9 -0
  44. package/lib/types/client/chunks/mermaid.d.ts +10 -0
  45. package/lib/types/client/chunks/terminal.d.ts +9 -0
  46. package/lib/types/client/cm-themes.d.ts +24 -0
  47. package/lib/types/client/conversation-draft.d.ts +14 -0
  48. package/lib/types/client/desktop-env.d.ts +35 -0
  49. package/lib/types/client/editable-state.d.ts +27 -0
  50. package/lib/types/client/editor-load.d.ts +66 -0
  51. package/lib/types/client/frame-batcher.d.ts +24 -0
  52. package/lib/types/client/icons.d.ts +57 -0
  53. package/lib/types/client/image-types.d.ts +3 -0
  54. package/lib/types/client/ime-guard.d.ts +36 -0
  55. package/lib/types/client/index.d.ts +19 -0
  56. package/lib/types/client/intercept.d.ts +33 -0
  57. package/lib/types/client/lang.d.ts +12 -0
  58. package/lib/types/client/lazy-chunk.d.ts +25 -0
  59. package/lib/types/client/link-intercept.d.ts +42 -0
  60. package/lib/types/client/locales.d.ts +366 -0
  61. package/lib/types/client/mermaid-blocks.d.ts +41 -0
  62. package/lib/types/client/mermaid-sanitize.d.ts +2 -0
  63. package/lib/types/client/mermaid.d.ts +12 -0
  64. package/lib/types/client/open-when-sized.d.ts +20 -0
  65. package/lib/types/client/open-with-settings.d.ts +4 -0
  66. package/lib/types/client/open-with.d.ts +87 -0
  67. package/lib/types/client/openpath-intercept.d.ts +41 -0
  68. package/lib/types/client/paths.d.ts +35 -0
  69. package/lib/types/client/pdf-types.d.ts +2 -0
  70. package/lib/types/client/plugin-settings.d.ts +8 -0
  71. package/lib/types/client/plugins-shared.d.ts +27 -0
  72. package/lib/types/client/plugins-tabs.d.ts +3 -0
  73. package/lib/types/client/plugins-viewers.d.ts +3 -0
  74. package/lib/types/client/prefs.d.ts +41 -0
  75. package/lib/types/client/produced-files.d.ts +23 -0
  76. package/lib/types/client/selection-payload.d.ts +27 -0
  77. package/lib/types/client/service.d.ts +440 -0
  78. package/lib/types/client/settings-nav-icon.d.ts +19 -0
  79. package/lib/types/client/shell-presets.d.ts +48 -0
  80. package/lib/types/client/sidechat-transcript.d.ts +103 -0
  81. package/lib/types/client/split-pane.d.ts +30 -0
  82. package/lib/types/client/state.d.ts +359 -0
  83. package/lib/types/client/subagent-detect.d.ts +54 -0
  84. package/lib/types/client/subagent-jobs.d.ts +63 -0
  85. package/lib/types/client/tab-content-memo.d.ts +33 -0
  86. package/lib/types/client/terminal-font.d.ts +20 -0
  87. package/lib/types/client/theme.d.ts +45 -0
  88. package/lib/types/client/titlebar-strip.d.ts +23 -0
  89. package/lib/types/client/upload.d.ts +69 -0
  90. package/lib/types/client/wco.d.ts +47 -0
  91. package/lib/types/config.d.ts +64 -0
  92. package/lib/types/context-types.d.ts +603 -0
  93. package/lib/types/fs-operations.d.ts +28 -0
  94. package/lib/types/fs-search.d.ts +24 -0
  95. package/lib/types/fs-tree.d.ts +51 -0
  96. package/lib/types/git.d.ts +82 -0
  97. package/lib/types/html-route.d.ts +59 -0
  98. package/lib/types/index.d.ts +47 -0
  99. package/lib/types/invariant.d.ts +15 -0
  100. package/lib/types/jobs-routes.d.ts +45 -0
  101. package/lib/types/open-external.d.ts +24 -0
  102. package/lib/types/prefs-shared.d.ts +225 -0
  103. package/lib/types/pty-deps.d.ts +78 -0
  104. package/lib/types/pty-manager.d.ts +168 -0
  105. package/lib/types/sidechat-core.d.ts +172 -0
  106. package/lib/types/sidechat-routes.d.ts +30 -0
  107. package/lib/types/subagent-activity.d.ts +44 -0
  108. package/lib/types/subagent-live-route.d.ts +44 -0
  109. package/lib/types/tools.d.ts +31 -0
  110. package/lib/types/trust-fence.d.ts +24 -0
  111. package/lib/types/wire.d.ts +38 -0
  112. package/package.json +173 -0
  113. package/scripts/install.ps1 +289 -0
  114. package/scripts/install.sh +287 -0
  115. package/src/agent-pty.ts +527 -0
  116. package/src/browser-probe.ts +26 -0
  117. package/src/bundle-route.ts +129 -0
  118. package/src/client/BrowserView.tsx +253 -0
  119. package/src/client/DiffTab.tsx +110 -0
  120. package/src/client/DiffView.tsx +305 -0
  121. package/src/client/EditorHost.tsx +525 -0
  122. package/src/client/FileTree.tsx +653 -0
  123. package/src/client/GitView.tsx +558 -0
  124. package/src/client/OrphanedTab.tsx +27 -0
  125. package/src/client/PdfView.tsx +110 -0
  126. package/src/client/RenderBoundary.tsx +49 -0
  127. package/src/client/SandboxStatusBar.tsx +60 -0
  128. package/src/client/SideCardSection.module.css +960 -0
  129. package/src/client/SideCardSection.tsx +1152 -0
  130. package/src/client/SideChatView.module.css +437 -0
  131. package/src/client/SideChatView.tsx +644 -0
  132. package/src/client/Sidebar.tsx +1148 -0
  133. package/src/client/SubagentView.module.css +547 -0
  134. package/src/client/SubagentView.tsx +883 -0
  135. package/src/client/TabBar.tsx +305 -0
  136. package/src/client/TerminalView.tsx +383 -0
  137. package/src/client/TextEditor.tsx +500 -0
  138. package/src/client/TreePanel.tsx +273 -0
  139. package/src/client/UploadOverlay.tsx +62 -0
  140. package/src/client/add-plugin-modal.tsx +199 -0
  141. package/src/client/api.ts +335 -0
  142. package/src/client/binary-download.tsx +23 -0
  143. package/src/client/breakpoints.ts +51 -0
  144. package/src/client/browser.ts +119 -0
  145. package/src/client/builtins/index.ts +37 -0
  146. package/src/client/builtins/tabs.tsx +331 -0
  147. package/src/client/builtins/viewers.tsx +125 -0
  148. package/src/client/chunk-loader.ts +351 -0
  149. package/src/client/chunks/editor.tsx +9 -0
  150. package/src/client/chunks/mermaid.tsx +10 -0
  151. package/src/client/chunks/terminal.tsx +9 -0
  152. package/src/client/cm-themes.ts +129 -0
  153. package/src/client/conversation-draft.ts +29 -0
  154. package/src/client/css-modules.d.ts +5 -0
  155. package/src/client/desktop-env.ts +72 -0
  156. package/src/client/editable-state.ts +70 -0
  157. package/src/client/editor-load.ts +92 -0
  158. package/src/client/frame-batcher.ts +56 -0
  159. package/src/client/icons.tsx +161 -0
  160. package/src/client/image-types.ts +8 -0
  161. package/src/client/ime-guard.ts +47 -0
  162. package/src/client/index.tsx +369 -0
  163. package/src/client/intercept.tsx +110 -0
  164. package/src/client/lang.ts +103 -0
  165. package/src/client/layout.css +94 -0
  166. package/src/client/lazy-chunk.tsx +89 -0
  167. package/src/client/link-intercept.ts +73 -0
  168. package/src/client/locales.ts +734 -0
  169. package/src/client/mermaid-blocks.ts +110 -0
  170. package/src/client/mermaid-sanitize.ts +93 -0
  171. package/src/client/mermaid.tsx +401 -0
  172. package/src/client/open-when-sized.ts +43 -0
  173. package/src/client/open-with-settings.tsx +135 -0
  174. package/src/client/open-with.ts +221 -0
  175. package/src/client/openpath-intercept.ts +62 -0
  176. package/src/client/paths.ts +47 -0
  177. package/src/client/pdf-types.ts +4 -0
  178. package/src/client/plugin-settings.ts +43 -0
  179. package/src/client/plugins-shared.ts +29 -0
  180. package/src/client/plugins-tabs.ts +87 -0
  181. package/src/client/plugins-viewers.ts +30 -0
  182. package/src/client/prefs.ts +207 -0
  183. package/src/client/produced-files.ts +91 -0
  184. package/src/client/selection-payload.ts +86 -0
  185. package/src/client/service.ts +838 -0
  186. package/src/client/settings-nav-icon.ts +45 -0
  187. package/src/client/shell-presets.ts +83 -0
  188. package/src/client/sidebar.module.css +2577 -0
  189. package/src/client/sidechat-transcript.ts +321 -0
  190. package/src/client/split-pane.tsx +322 -0
  191. package/src/client/state.ts +1175 -0
  192. package/src/client/subagent-detect.ts +137 -0
  193. package/src/client/subagent-jobs.ts +161 -0
  194. package/src/client/tab-content-memo.ts +46 -0
  195. package/src/client/terminal-font.ts +28 -0
  196. package/src/client/theme.ts +110 -0
  197. package/src/client/titlebar-strip.ts +37 -0
  198. package/src/client/upload.ts +187 -0
  199. package/src/client/wco.ts +120 -0
  200. package/src/config.ts +155 -0
  201. package/src/context-types.ts +610 -0
  202. package/src/fs-operations.ts +99 -0
  203. package/src/fs-search.ts +86 -0
  204. package/src/fs-tree.ts +158 -0
  205. package/src/git.ts +244 -0
  206. package/src/html-route.ts +106 -0
  207. package/src/index.ts +1132 -0
  208. package/src/invariant.ts +32 -0
  209. package/src/jobs-routes.ts +257 -0
  210. package/src/open-external.ts +90 -0
  211. package/src/prefs-shared.ts +268 -0
  212. package/src/pty-deps.ts +240 -0
  213. package/src/pty-manager.ts +376 -0
  214. package/src/sidechat-core.ts +468 -0
  215. package/src/sidechat-routes.ts +344 -0
  216. package/src/subagent-activity.ts +91 -0
  217. package/src/subagent-live-route.ts +93 -0
  218. package/src/tools.ts +479 -0
  219. package/src/trust-fence.ts +77 -0
  220. package/src/wire.ts +100 -0
@@ -0,0 +1,273 @@
1
+ /**
2
+ * The files window's tree surface: a global file-name search box on top
3
+ * (300ms debounce; an in-flight search is aborted by the next keystroke)
4
+ * over either the shared controlled FileTree (empty query) or the flat
5
+ * result list (relative paths; click opens through the caller's mode-aware
6
+ * open). Owns its refresh tick: the icon next to the search input clears
7
+ * the tree cache. EditorHost docks it as the tab's right panel (wrapped in
8
+ * a drag-resize handle) and provides the file context-menu open escapes.
9
+ *
10
+ * Uploads (header pickers, the tree's drag-drop and "upload here" menu)
11
+ * all funnel through here: one session at a time, shown in a full-window
12
+ * progress overlay with cancel, followed by a tree refresh and a one-line
13
+ * hint under the search row (success fades, failures and cancels stay).
14
+ * OS file drags are shielded at the panel host (see Sidebar.tsx), so a
15
+ * drop over the file window uploads here and never reaches DSH's chat
16
+ * intake.
17
+ */
18
+ import { useEffect, useRef, useState, type InputHTMLAttributes } from 'react'
19
+ import clsx from 'clsx'
20
+ import { IconFolderOpen16, IconRefreshOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
21
+ import { api } from './api.ts'
22
+ import { FileTree } from './FileTree.tsx'
23
+ import { IconUploadOutline16 } from './icons.tsx'
24
+ import type { OpenWithTarget } from './open-with.ts'
25
+ import { t } from './locales.ts'
26
+ import { resolveSidebarPath } from './produced-files.ts'
27
+ import { UploadOverlay } from './UploadOverlay.tsx'
28
+ import {
29
+ summarizeResults, uploadHintText, uploadItemsFromFiles, uploadToDir,
30
+ UPLOAD_HINT_MS, type UploadItem,
31
+ } from './upload.ts'
32
+ import css from './sidebar.module.css'
33
+
34
+ /** One in-flight upload session (the overlay's progress source). */
35
+ interface UploadSession {
36
+ dir: string
37
+ done: number
38
+ total: number
39
+ /** Relative path of the file being uploaded ('' when none is in flight). */
40
+ current: string
41
+ controller: AbortController
42
+ }
43
+
44
+ export function TreePanel(props: {
45
+ sessionId: string
46
+ cwd: string | undefined
47
+ expanded: string[]
48
+ onToggle: (path: string) => void
49
+ onOpenFile: (path: string) => void
50
+ /** File context-menu "open in a new tab" (passed through to FileTree). */
51
+ onOpenFileNewTab?: (path: string) => void
52
+ /** File context-menu "open to the side" (passed through to FileTree). */
53
+ onOpenFileSide?: (path: string) => void
54
+ /** The "open with" menu surface (passed through to FileTree; absent →
55
+ * the whole section is hidden). */
56
+ openWithTargets?: OpenWithTarget[]
57
+ openWithPinned?: string[]
58
+ openWithSsh?: boolean
59
+ onOpenWith?: (targetId: string, path: string) => void
60
+ onToggleOpenWithPin?: (targetId: string) => void
61
+ onReferenceFile: (path: string) => void
62
+ /** Full-window presentation: the panel fills its host instead of docking
63
+ * at a fixed width. */
64
+ full?: boolean
65
+ }) {
66
+ const { sessionId, cwd, expanded, onToggle, onOpenFile, onOpenFileNewTab, onOpenFileSide, openWithTargets, openWithPinned, openWithSsh, onOpenWith, onToggleOpenWithPin, onReferenceFile, full } = props
67
+ const [query, setQuery] = useState('')
68
+ const [results, setResults] = useState<{ matches: string[]; truncated: boolean } | null>(null)
69
+ const [error, setError] = useState<string | null>(null)
70
+ const [refreshTick, setRefreshTick] = useState(0)
71
+ /** One-line upload status under the search row ('' hides the hint). */
72
+ const [uploadStatus, setUploadStatus] = useState('')
73
+ /** Whether the status line is a failure/cancel (error color, stays visible). */
74
+ const [uploadFailed, setUploadFailed] = useState(false)
75
+ /** The in-flight upload session (null → no overlay, buttons enabled). */
76
+ const [upload, setUpload] = useState<UploadSession | null>(null)
77
+ /** True between the cancel click and the session settling (button disabled). */
78
+ const [cancelling, setCancelling] = useState(false)
79
+ /** Set by cancelUpload; the settle path shows 'upload cancelled' instead of
80
+ * summarizing the partial results. */
81
+ const cancelledRef = useRef(false)
82
+ const fileInputRef = useRef<HTMLInputElement>(null)
83
+ const folderInputRef = useRef<HTMLInputElement>(null)
84
+
85
+ /** Start one upload session into `dir` (absolute, inside the workspace). */
86
+ const startUpload = (dir: string, items: UploadItem[]): void => {
87
+ if (items.length === 0 || cwd === undefined || upload !== null) return
88
+ cancelledRef.current = false
89
+ const controller = new AbortController()
90
+ setUploadFailed(false)
91
+ setUploadStatus(uploadHintText(0, items.length, '', dir, t))
92
+ setUpload({ dir, done: 0, total: items.length, current: '', controller })
93
+ void uploadToDir({ sessionId, cwd }, dir, items, (done, total, current) => {
94
+ if (current !== '') setUploadStatus(uploadHintText(done, total, current, dir, t))
95
+ setUpload(session => session === null ? session : { ...session, done, total, current })
96
+ }, controller.signal).then((results) => {
97
+ setUpload(null)
98
+ setCancelling(false)
99
+ // Reload the tree whatever the outcome: files may have landed before a
100
+ // cancel, and failures leave whatever did succeed visible.
101
+ setRefreshTick(tick => tick + 1)
102
+ if (cancelledRef.current) {
103
+ setUploadStatus(t('uploadCancelled'))
104
+ setUploadFailed(true)
105
+ return
106
+ }
107
+ const status = summarizeResults(results, t)
108
+ setUploadStatus(status)
109
+ setUploadFailed(results.some(result => !result.ok))
110
+ // Success messages are transient; failures stay until the next action.
111
+ if (results.every(result => result.ok)) {
112
+ window.setTimeout(() => {
113
+ setUploadStatus(current => current === status ? '' : current)
114
+ }, UPLOAD_HINT_MS)
115
+ }
116
+ })
117
+ }
118
+
119
+ /** Cancel the in-flight upload (aborts the request; the host drops its temp). */
120
+ const cancelUpload = (): void => {
121
+ if (upload === null || cancelling) return
122
+ cancelledRef.current = true
123
+ setCancelling(true)
124
+ upload.controller.abort()
125
+ }
126
+
127
+ const folderInputProps = { webkitdirectory: '' } as InputHTMLAttributes<HTMLInputElement>
128
+
129
+ const needle = query.trim()
130
+ useEffect(() => {
131
+ if (needle === '') {
132
+ setResults(null)
133
+ setError(null)
134
+ return
135
+ }
136
+ const controller = new AbortController()
137
+ const timer = window.setTimeout(() => {
138
+ api.fsSearch({ sessionId, cwd }, needle, controller.signal).then((found) => {
139
+ setResults(found)
140
+ setError(null)
141
+ }).catch((failure: unknown) => {
142
+ if (controller.signal.aborted) return
143
+ setResults(null)
144
+ setError(failure instanceof Error ? failure.message : String(failure))
145
+ })
146
+ }, 300)
147
+ return () => {
148
+ window.clearTimeout(timer)
149
+ controller.abort()
150
+ }
151
+ }, [sessionId, cwd, needle])
152
+
153
+ const busy = upload !== null
154
+
155
+ return (
156
+ <div className={clsx(css.editorTreePanel, full === true && css.editorTreePanelFull)}>
157
+ <div className={css.editorTreeSearch}>
158
+ <input
159
+ className={css.editorSearchInput}
160
+ value={query}
161
+ placeholder={t('editorSearchPlaceholder')}
162
+ spellCheck={false}
163
+ onChange={(event) => { setQuery(event.target.value) }}
164
+ />
165
+ <button
166
+ type="button"
167
+ className={css.iconButton}
168
+ aria-label={t('refresh')}
169
+ title={t('refresh')}
170
+ onClick={() => { setRefreshTick(tick => tick + 1) }}
171
+ >
172
+ <IconRefreshOutline16 size={14} />
173
+ </button>
174
+ <button
175
+ type="button"
176
+ className={css.iconButton}
177
+ aria-label={t('uploadFiles')}
178
+ title={t('uploadFiles')}
179
+ disabled={busy}
180
+ onClick={() => { fileInputRef.current?.click() }}
181
+ >
182
+ <IconUploadOutline16 size={14} />
183
+ </button>
184
+ <button
185
+ type="button"
186
+ className={css.iconButton}
187
+ aria-label={t('uploadFolder')}
188
+ title={t('uploadFolder')}
189
+ disabled={busy}
190
+ onClick={() => { folderInputRef.current?.click() }}
191
+ >
192
+ <IconFolderOpen16 size={14} />
193
+ </button>
194
+ <input
195
+ ref={fileInputRef}
196
+ type="file"
197
+ multiple
198
+ style={{ display: 'none' }}
199
+ onChange={(event) => {
200
+ if (cwd !== undefined) startUpload(cwd, uploadItemsFromFiles(event.target.files ?? []))
201
+ event.target.value = ''
202
+ }}
203
+ />
204
+ <input
205
+ ref={folderInputRef}
206
+ type="file"
207
+ multiple
208
+ {...folderInputProps}
209
+ style={{ display: 'none' }}
210
+ onChange={(event) => {
211
+ if (cwd !== undefined) startUpload(cwd, uploadItemsFromFiles(event.target.files ?? []))
212
+ event.target.value = ''
213
+ }}
214
+ />
215
+ </div>
216
+ {uploadStatus !== '' && (
217
+ <div className={clsx(css.editorSearchHint, uploadFailed && css.editorError)} title={uploadStatus}>{uploadStatus}</div>
218
+ )}
219
+ {needle === '' ? (
220
+ <FileTree
221
+ sessionId={sessionId}
222
+ cwd={cwd}
223
+ expanded={expanded}
224
+ onToggle={onToggle}
225
+ onOpenFile={onOpenFile}
226
+ onOpenFileNewTab={onOpenFileNewTab}
227
+ onOpenFileSide={onOpenFileSide}
228
+ openWithTargets={openWithTargets}
229
+ openWithPinned={openWithPinned}
230
+ openWithSsh={openWithSsh}
231
+ onOpenWith={onOpenWith}
232
+ onToggleOpenWithPin={onToggleOpenWithPin}
233
+ onReferenceFile={onReferenceFile}
234
+ refreshTick={refreshTick}
235
+ onUploadRequest={startUpload}
236
+ busy={busy}
237
+ />
238
+ ) : (
239
+ <div className={css.explorerBody}>
240
+ {error !== null && <div className={clsx(css.editorSearchHint, css.editorError)}>{error}</div>}
241
+ {error === null && results === null && <div className={css.editorSearchHint}>{t('loading')}</div>}
242
+ {error === null && results !== null && results.matches.length === 0 && (
243
+ <div className={css.editorSearchHint}>{t('editorSearchNoResults')}</div>
244
+ )}
245
+ {error === null && results !== null && results.matches.map(rel => (
246
+ <button
247
+ key={rel}
248
+ type="button"
249
+ className={css.editorSearchResult}
250
+ title={rel}
251
+ onClick={() => { onOpenFile(resolveSidebarPath(cwd, rel)) }}
252
+ >
253
+ {rel}
254
+ </button>
255
+ ))}
256
+ {error === null && results?.truncated === true && (
257
+ <div className={css.editorSearchHint}>{t('editorSearchTruncated')}</div>
258
+ )}
259
+ </div>
260
+ )}
261
+ {upload !== null && (
262
+ <UploadOverlay
263
+ dir={upload.dir}
264
+ done={upload.done}
265
+ total={upload.total}
266
+ current={upload.current}
267
+ onCancel={cancelUpload}
268
+ cancelling={cancelling}
269
+ />
270
+ )}
271
+ </div>
272
+ )
273
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Full-window upload progress over the files tree: a blurred scrim (same mask
3
+ * token as the repo's Modal primitive) with a card showing the target
4
+ * directory, file-level progress, and a cancel button. Esc cancels too —
5
+ * clicking the scrim does not, so a stray click can never abort an upload.
6
+ * Rendered inside TreePanel (absolute inset-0), so it covers only the file
7
+ * window and never the conversation column.
8
+ */
9
+ import { useEffect, type ReactNode } from 'react'
10
+ import { IconUploadOutline16 } from './icons.tsx'
11
+ import { t } from './locales.ts'
12
+ import { uploadHintText } from './upload.ts'
13
+ import css from './sidebar.module.css'
14
+
15
+ export function UploadOverlay(props: {
16
+ /** Absolute upload directory (the session workspace or a tree directory). */
17
+ dir: string
18
+ done: number
19
+ total: number
20
+ /** Relative path of the file being uploaded ('' when none is in flight). */
21
+ current: string
22
+ onCancel: () => void
23
+ /** True while cancellation is in flight (disables the cancel button). */
24
+ cancelling?: boolean
25
+ }): ReactNode {
26
+ const { dir, done, total, current, onCancel, cancelling } = props
27
+ // Esc cancels (MermaidModal-style native listener; the overlay is not a
28
+ // focus-trapped dialog, so a window listener is the honest scope).
29
+ useEffect(() => {
30
+ const onKey = (event: KeyboardEvent): void => {
31
+ if (event.key === 'Escape') onCancel()
32
+ }
33
+ window.addEventListener('keydown', onKey)
34
+ return () => { window.removeEventListener('keydown', onKey) }
35
+ }, [onCancel])
36
+
37
+ const percent = total === 0 ? 0 : Math.min(100, Math.round((done / total) * 100))
38
+ return (
39
+ <div className={css.uploadOverlay} role="dialog" aria-modal="true" aria-label={t('uploadingTo', { dir })}>
40
+ <div className={css.uploadOverlayCard}>
41
+ <div className={css.uploadOverlayTitle} title={dir}>
42
+ <IconUploadOutline16 size={16} />
43
+ <span>{t('uploadingTo', { dir })}</span>
44
+ </div>
45
+ <div
46
+ className={css.uploadOverlayProgress}
47
+ role="progressbar"
48
+ aria-valuemin={0}
49
+ aria-valuemax={total}
50
+ aria-valuenow={done}
51
+ aria-valuetext={t('uploadProgress', { done, total, name: current })}
52
+ >
53
+ <div className={css.uploadOverlayProgressFill} style={{ width: `${percent}%` }} />
54
+ </div>
55
+ <div className={css.uploadOverlayStatus}>{uploadHintText(done, total, current, dir, t)}</div>
56
+ <button type="button" className={css.uploadOverlayCancel} disabled={cancelling} onClick={onCancel}>
57
+ {t('cancel')}
58
+ </button>
59
+ </div>
60
+ </div>
61
+ )
62
+ }
@@ -0,0 +1,199 @@
1
+ /**
2
+ * The "add plugin" modals (Side card settings → the dashed cards at the
3
+ * end of the 侧边栏内容 / 文件预览 grids): declare that the sidebar's
4
+ * extension points — tab pages and file previewers — are open to plugins
5
+ * (registered through `ctx.betterSidebar`), point at the GitHub topic page
6
+ * for discovery, and show the repo's recommended plugin catalog of the
7
+ * matching kind (name / url / description / install script).
8
+ *
9
+ * Per entry there are two actions:
10
+ * - 「跳转」opens the plugin's repo in a REAL new browser tab (window.open
11
+ * — a button, so the sidebar link takeover cannot reroute it);
12
+ * - 「安装」only COPIES the install script to the clipboard (writeClipboard)
13
+ * with a transient "已复制" feedback on the button — the user pastes and
14
+ * runs it wherever they manage their DSH profile. No terminal is opened,
15
+ * nothing is closed, nothing can fail outward.
16
+ *
17
+ * The body is extracted as {@link PluginListBody} so tests render it
18
+ * directly — the Modal primitive runs hooks unconditionally, so an open
19
+ * Modal must never be renderToString'd (same rule as the settingsFor popup
20
+ * in SideCardSection); the modal itself mounts only while open.
21
+ */
22
+ import { useState, type ReactNode } from 'react'
23
+ import { Modal, writeClipboard } from '@deepseek-ai/dsh-client-ui-primitives'
24
+ import type { BetterSidebarService } from './service.ts'
25
+ import { PLUGIN_TOPIC_URL, type PluginEntry } from './plugins-shared.ts'
26
+ import { builtinTabPlugins } from './plugins-tabs.ts'
27
+ import { builtinViewerPlugins } from './plugins-viewers.ts'
28
+ import { t } from './locales.ts'
29
+ import css from './SideCardSection.module.css'
30
+
31
+ /** Which extension point the modal is adding a plugin for. */
32
+ export type PluginKind = 'tab' | 'viewer'
33
+
34
+ /** The catalog of one kind (kept in two repo files: plugins-tabs.ts /
35
+ * plugins-viewers.ts). */
36
+ function catalogOf(kind: PluginKind): readonly PluginEntry[] {
37
+ return kind === 'tab' ? builtinTabPlugins : builtinViewerPlugins
38
+ }
39
+
40
+ /** How long the "已复制" feedback stays on the copy button. */
41
+ const COPIED_FEEDBACK_MS = 1500
42
+
43
+ /** The modal body: the GitHub topic button + the recommended plugin list
44
+ * with per-entry jump/copy buttons (extracted for direct testing). */
45
+ export function PluginListBody(props: { service: BetterSidebarService; kind: PluginKind }) {
46
+ const { service, kind } = props
47
+ // Which entry's copy button currently shows the "已复制" feedback.
48
+ const [copiedId, setCopiedId] = useState<string | null>(null)
49
+ // Live catalog filter (name / id / description). A free-text search keeps
50
+ // the list usable once the catalogs grow beyond a handful of entries:
51
+ // every keystroke narrows the rendered rows, so a long catalog never
52
+ // becomes a wall of scrolling.
53
+ const [query, setQuery] = useState('')
54
+
55
+ const catalog = catalogOf(kind)
56
+ const needle = query.trim().toLowerCase()
57
+ const matches = (entry: PluginEntry): boolean => {
58
+ if (needle === '') return true
59
+ const description = typeof entry.description === 'function' ? entry.description() : entry.description
60
+ return entry.name.toLowerCase().includes(needle)
61
+ || entry.id.toLowerCase().includes(needle)
62
+ || description.toLowerCase().includes(needle)
63
+ }
64
+ const filtered = catalog.filter(matches)
65
+ // Group by the optional category (stable order: category order of first
66
+ // appearance, uncategorized entries last under the plain list).
67
+ const groups = new Map<string | undefined, PluginEntry[]>()
68
+ for (const entry of filtered) {
69
+ const key = entry.category === undefined ? undefined : (typeof entry.category === 'function' ? entry.category() : entry.category)
70
+ const list = groups.get(key)
71
+ if (list === undefined) groups.set(key, [entry])
72
+ else list.push(entry)
73
+ }
74
+
75
+ /** Copy the entry's install script to the clipboard and flash the button's
76
+ * "已复制" label for a moment. The feedback ONLY appears after a
77
+ * successful write — when the clipboard is unavailable or denied
78
+ * (writeClipboard resolves false) nothing is shown, so the user is never
79
+ * told to paste a command that was not placed on the clipboard. Never
80
+ * closes anything, never throws outward. */
81
+ const copy = async (entry: PluginEntry): Promise<void> => {
82
+ const written = await writeClipboard(entry.install)
83
+ if (!written) return
84
+ setCopiedId(entry.id)
85
+ window.setTimeout(() => {
86
+ setCopiedId(current => (current === entry.id ? null : current))
87
+ }, COPIED_FEEDBACK_MS)
88
+ }
89
+
90
+ /** Open the plugin's repo in a REAL new browser tab (window.open — a
91
+ * button, so the sidebar link takeover cannot reroute it). */
92
+ const jump = (entry: PluginEntry): void => {
93
+ window.open(entry.url, '_blank', 'noopener')
94
+ }
95
+
96
+ /** One catalog row (extracted so the group render stays flat). */
97
+ const renderEntry = (entry: PluginEntry): ReactNode => (
98
+ <div key={entry.id} className={css.pluginEntry}>
99
+ <div className={css.pluginEntryHead}>
100
+ {/* The name is a BUTTON on the same window.open path as the
101
+ jump button: as an anchor it would be caught by the
102
+ document-capture link takeover (which ignores
103
+ target=_blank) and land in the sidebar browser. */}
104
+ <button
105
+ type="button"
106
+ className={css.pluginName}
107
+ aria-label={`${t('openPlugin')}: ${entry.name}`}
108
+ onClick={() => { jump(entry) }}
109
+ >
110
+ {entry.name}
111
+ </button>
112
+ <span className={css.pluginEntryActions}>
113
+ <button
114
+ type="button"
115
+ className={css.pluginJumpBtn}
116
+ aria-label={`${t('openPlugin')}: ${entry.name}`}
117
+ onClick={() => { jump(entry) }}
118
+ >
119
+ {t('openPlugin')}
120
+ </button>
121
+ <button
122
+ type="button"
123
+ className={css.pluginCopyBtn}
124
+ aria-label={`${t('copyInstall')}: ${entry.name}`}
125
+ onClick={() => { copy(entry) }}
126
+ >
127
+ {copiedId === entry.id ? t('copied') : t('copy')}
128
+ </button>
129
+ </span>
130
+ </div>
131
+ <div className={css.pluginDesc}>
132
+ {typeof entry.description === 'function' ? entry.description() : entry.description}
133
+ </div>
134
+ <code className={css.pluginInstall}>{entry.install}</code>
135
+ </div>
136
+ )
137
+
138
+ return (
139
+ <div className={css.pluginList}>
140
+ <button
141
+ type="button"
142
+ className={css.pluginTopicBtn}
143
+ onClick={() => { window.open(PLUGIN_TOPIC_URL, '_blank', 'noopener') }}
144
+ >
145
+ {t('addPluginsBrowseMore')}
146
+ </button>
147
+ <input
148
+ type="search"
149
+ className={css.pluginSearch}
150
+ placeholder={t('addPluginsSearch')}
151
+ aria-label={t('addPluginsSearch')}
152
+ value={query}
153
+ onChange={event => { setQuery(event.currentTarget.value) }}
154
+ />
155
+ <div className={css.groupHeading}>
156
+ <span>{t('addPluginsRecommended')}</span>
157
+ <span className={css.count}>{filtered.length}</span>
158
+ </div>
159
+ {catalog.length === 0 ? (
160
+ <div className={css.pluginEmpty}>{t('addPluginsEmpty')}</div>
161
+ ) : filtered.length === 0 ? (
162
+ <div className={css.pluginEmpty}>{t('addPluginsNoMatch')}</div>
163
+ ) : (
164
+ <div className={css.pluginEntries}>
165
+ {[...groups.entries()].map(([category, entries]) => (
166
+ <div key={category ?? '\u0000'} className={css.pluginGroup}>
167
+ {category !== undefined && (
168
+ <div className={css.pluginGroupHeading}>{category}</div>
169
+ )}
170
+ {entries.map(renderEntry)}
171
+ </div>
172
+ ))}
173
+ </div>
174
+ )}
175
+ </div>
176
+ )
177
+ }
178
+
179
+ /** The modal itself (mounted only while open — see the module comment). */
180
+ export function AddPluginModal(props: { service: BetterSidebarService; onClose: () => void; kind: PluginKind }) {
181
+ const { service, onClose, kind } = props
182
+ return (
183
+ <Modal
184
+ open
185
+ onClose={onClose}
186
+ title={kind === 'tab' ? t('addPluginsTabCard') : t('addPluginsViewerCard')}
187
+ description={kind === 'tab' ? t('addPluginsTabDesc') : t('addPluginsViewerDesc')}
188
+ closeLabel={t('close')}
189
+ className={css.pluginModal}
190
+ footer={(
191
+ <button type="button" className={css.done} onClick={onClose}>
192
+ {t('settingsDone')}
193
+ </button>
194
+ )}
195
+ >
196
+ <PluginListBody service={service} kind={kind} />
197
+ </Modal>
198
+ )
199
+ }