@chatcode/cco-better-sidebar 0.18.0-alpha.0

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 (329) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +554 -0
  3. package/README_EN.md +563 -0
  4. package/assets/logo.svg +1 -0
  5. package/cordis.patch.yml +79 -0
  6. package/lib/client-editor.js +55744 -0
  7. package/lib/client-mermaid.js +208256 -0
  8. package/lib/client-registry.js +30378 -0
  9. package/lib/client-terminal.js +16894 -0
  10. package/lib/client.js +30378 -0
  11. package/lib/index.js +6897 -0
  12. package/lib/invariant.js +22 -0
  13. package/lib/types/agent-opens.d.ts +65 -0
  14. package/lib/types/agent-pty.d.ts +226 -0
  15. package/lib/types/browser-probe.d.ts +15 -0
  16. package/lib/types/bundle-route.d.ts +12 -0
  17. package/lib/types/chatcode-update.d.ts +47 -0
  18. package/lib/types/client/AccountMenu.d.ts +1 -0
  19. package/lib/types/client/AiRatioSection.d.ts +3 -0
  20. package/lib/types/client/BrowserView.d.ts +39 -0
  21. package/lib/types/client/ChatcodeGate.d.ts +6 -0
  22. package/lib/types/client/CodingPlanModelsSettings.d.ts +9 -0
  23. package/lib/types/client/DiffTab.d.ts +6 -0
  24. package/lib/types/client/DiffView.d.ts +51 -0
  25. package/lib/types/client/EditorHost.d.ts +13 -0
  26. package/lib/types/client/FenceErrorNotice.d.ts +5 -0
  27. package/lib/types/client/FileTree.d.ts +41 -0
  28. package/lib/types/client/FreeWindow.d.ts +29 -0
  29. package/lib/types/client/GitView.d.ts +12 -0
  30. package/lib/types/client/MarkdownHtml.d.ts +32 -0
  31. package/lib/types/client/OrphanedTab.d.ts +11 -0
  32. package/lib/types/client/PdfView.d.ts +6 -0
  33. package/lib/types/client/ReleaseNotesDialog.d.ts +11 -0
  34. package/lib/types/client/RenderBoundary.d.ts +32 -0
  35. package/lib/types/client/SandboxStatusBar.d.ts +10 -0
  36. package/lib/types/client/SideCardSection.d.ts +74 -0
  37. package/lib/types/client/Sidebar.d.ts +8 -0
  38. package/lib/types/client/TabBar.d.ts +54 -0
  39. package/lib/types/client/TerminalView.d.ts +24 -0
  40. package/lib/types/client/TextEditor.d.ts +10 -0
  41. package/lib/types/client/TreePanel.d.ts +27 -0
  42. package/lib/types/client/UpdateDialog.d.ts +10 -0
  43. package/lib/types/client/UpdateSettingsPanel.d.ts +1 -0
  44. package/lib/types/client/UpdateSettingsSection.d.ts +10 -0
  45. package/lib/types/client/UploadOverlay.d.ts +20 -0
  46. package/lib/types/client/UsageHeatmapSection.d.ts +4 -0
  47. package/lib/types/client/add-plugin-modal.d.ts +15 -0
  48. package/lib/types/client/api.d.ts +345 -0
  49. package/lib/types/client/binary-download.d.ts +12 -0
  50. package/lib/types/client/breakpoints.d.ts +17 -0
  51. package/lib/types/client/browser.d.ts +60 -0
  52. package/lib/types/client/builtins/index.d.ts +18 -0
  53. package/lib/types/client/builtins/tabs.d.ts +11 -0
  54. package/lib/types/client/builtins/viewers.d.ts +3 -0
  55. package/lib/types/client/chat-presentation.d.ts +13 -0
  56. package/lib/types/client/chunk-loader.d.ts +124 -0
  57. package/lib/types/client/chunks/editor.d.ts +9 -0
  58. package/lib/types/client/chunks/mermaid.d.ts +10 -0
  59. package/lib/types/client/chunks/terminal.d.ts +9 -0
  60. package/lib/types/client/cm-themes.d.ts +24 -0
  61. package/lib/types/client/conversation-draft.d.ts +14 -0
  62. package/lib/types/client/desktop-env.d.ts +35 -0
  63. package/lib/types/client/editor-load.d.ts +66 -0
  64. package/lib/types/client/frame-batcher.d.ts +24 -0
  65. package/lib/types/client/icons.d.ts +66 -0
  66. package/lib/types/client/image-types.d.ts +3 -0
  67. package/lib/types/client/ime-guard.d.ts +36 -0
  68. package/lib/types/client/index.d.ts +19 -0
  69. package/lib/types/client/intercept.d.ts +44 -0
  70. package/lib/types/client/lang.d.ts +14 -0
  71. package/lib/types/client/layout-push.d.ts +15 -0
  72. package/lib/types/client/lazy-chunk.d.ts +25 -0
  73. package/lib/types/client/link-intercept.d.ts +42 -0
  74. package/lib/types/client/locales-ar.d.ts +18 -0
  75. package/lib/types/client/locales-de.d.ts +4 -0
  76. package/lib/types/client/locales-fr.d.ts +10 -0
  77. package/lib/types/client/locales-hi.d.ts +17 -0
  78. package/lib/types/client/locales-id.d.ts +15 -0
  79. package/lib/types/client/locales-it.d.ts +8 -0
  80. package/lib/types/client/locales-ja.d.ts +17 -0
  81. package/lib/types/client/locales-ko.d.ts +9 -0
  82. package/lib/types/client/locales-nl.d.ts +16 -0
  83. package/lib/types/client/locales-pl.d.ts +19 -0
  84. package/lib/types/client/locales-pt.d.ts +1 -0
  85. package/lib/types/client/locales-ru.d.ts +13 -0
  86. package/lib/types/client/locales-sv.d.ts +1 -0
  87. package/lib/types/client/locales-th.d.ts +17 -0
  88. package/lib/types/client/locales-tr.d.ts +17 -0
  89. package/lib/types/client/locales-vi.d.ts +17 -0
  90. package/lib/types/client/locales-zh-HK.d.ts +32 -0
  91. package/lib/types/client/locales-zh-MO.d.ts +32 -0
  92. package/lib/types/client/locales-zh-TW.d.ts +32 -0
  93. package/lib/types/client/locales.d.ts +571 -0
  94. package/lib/types/client/markdown-html.d.ts +96 -0
  95. package/lib/types/client/markdown-images.d.ts +45 -0
  96. package/lib/types/client/markdown-labels.d.ts +21 -0
  97. package/lib/types/client/md-toc.d.ts +4 -0
  98. package/lib/types/client/mermaid-blocks.d.ts +47 -0
  99. package/lib/types/client/mermaid-sanitize.d.ts +2 -0
  100. package/lib/types/client/mermaid.d.ts +12 -0
  101. package/lib/types/client/open-when-sized.d.ts +20 -0
  102. package/lib/types/client/open-with-settings.d.ts +4 -0
  103. package/lib/types/client/open-with.d.ts +87 -0
  104. package/lib/types/client/openpath-intercept.d.ts +52 -0
  105. package/lib/types/client/paths.d.ts +43 -0
  106. package/lib/types/client/pdf-types.d.ts +2 -0
  107. package/lib/types/client/pinned.d.ts +86 -0
  108. package/lib/types/client/plugin-settings.d.ts +8 -0
  109. package/lib/types/client/plugins-shared.d.ts +27 -0
  110. package/lib/types/client/plugins-tabs.d.ts +3 -0
  111. package/lib/types/client/plugins-viewers.d.ts +3 -0
  112. package/lib/types/client/prefs.d.ts +41 -0
  113. package/lib/types/client/produced-files.d.ts +29 -0
  114. package/lib/types/client/selection-payload.d.ts +27 -0
  115. package/lib/types/client/service.d.ts +445 -0
  116. package/lib/types/client/session-pins.d.ts +14 -0
  117. package/lib/types/client/settings-nav-icon.d.ts +27 -0
  118. package/lib/types/client/shell-presets.d.ts +48 -0
  119. package/lib/types/client/split-pane.d.ts +38 -0
  120. package/lib/types/client/state.d.ts +480 -0
  121. package/lib/types/client/tab-content-memo.d.ts +40 -0
  122. package/lib/types/client/terminal-font.d.ts +78 -0
  123. package/lib/types/client/terminal-links.d.ts +119 -0
  124. package/lib/types/client/theme.d.ts +45 -0
  125. package/lib/types/client/titlebar-strip.d.ts +23 -0
  126. package/lib/types/client/ui-tweaks/appicons.d.ts +16 -0
  127. package/lib/types/client/ui-tweaks/archive.d.ts +42 -0
  128. package/lib/types/client/ui-tweaks/branding.d.ts +45 -0
  129. package/lib/types/client/ui-tweaks/cachehit.d.ts +41 -0
  130. package/lib/types/client/ui-tweaks/gitbar.d.ts +103 -0
  131. package/lib/types/client/ui-tweaks/index.d.ts +279 -0
  132. package/lib/types/client/ui-tweaks/mcp.d.ts +28 -0
  133. package/lib/types/client/ui-tweaks/notifier.d.ts +125 -0
  134. package/lib/types/client/ui-tweaks/whale.d.ts +55 -0
  135. package/lib/types/client/upload.d.ts +69 -0
  136. package/lib/types/client/useChatcodeAccount.d.ts +10 -0
  137. package/lib/types/client/wco.d.ts +47 -0
  138. package/lib/types/codingplan.d.ts +37 -0
  139. package/lib/types/config.d.ts +95 -0
  140. package/lib/types/context-types.d.ts +540 -0
  141. package/lib/types/fs-operations.d.ts +30 -0
  142. package/lib/types/fs-search.d.ts +24 -0
  143. package/lib/types/fs-tree.d.ts +51 -0
  144. package/lib/types/git.d.ts +124 -0
  145. package/lib/types/html-route.d.ts +59 -0
  146. package/lib/types/index.d.ts +47 -0
  147. package/lib/types/invariant.d.ts +15 -0
  148. package/lib/types/login-route.d.ts +2 -0
  149. package/lib/types/open-external.d.ts +24 -0
  150. package/lib/types/path-security.d.ts +26 -0
  151. package/lib/types/prefs-shared.d.ts +257 -0
  152. package/lib/types/pty-deps.d.ts +79 -0
  153. package/lib/types/pty-manager.d.ts +168 -0
  154. package/lib/types/tools.d.ts +34 -0
  155. package/lib/types/trust-fence.d.ts +24 -0
  156. package/lib/types/ui-tweaks/archive.d.ts +71 -0
  157. package/lib/types/ui-tweaks/config.d.ts +186 -0
  158. package/lib/types/ui-tweaks/git-web.d.ts +43 -0
  159. package/lib/types/ui-tweaks/git.d.ts +311 -0
  160. package/lib/types/ui-tweaks/index.d.ts +17 -0
  161. package/lib/types/ui-tweaks/mcp.d.ts +103 -0
  162. package/lib/types/ui-tweaks/turn-outcome.d.ts +57 -0
  163. package/lib/types/ui-tweaks/web.d.ts +55 -0
  164. package/lib/types/wire.d.ts +38 -0
  165. package/package.json +213 -0
  166. package/scripts/install.ps1 +310 -0
  167. package/scripts/install.sh +287 -0
  168. package/src/agent-opens.ts +265 -0
  169. package/src/agent-pty.ts +527 -0
  170. package/src/browser-probe.ts +26 -0
  171. package/src/bundle-route.ts +129 -0
  172. package/src/chatcode-update.ts +193 -0
  173. package/src/client/AccountMenu.tsx +177 -0
  174. package/src/client/AiRatioSection.module.css +329 -0
  175. package/src/client/AiRatioSection.tsx +292 -0
  176. package/src/client/BrowserView.tsx +289 -0
  177. package/src/client/ChatcodeGate.tsx +35 -0
  178. package/src/client/CodingPlanModelsSettings.module.css +118 -0
  179. package/src/client/CodingPlanModelsSettings.tsx +101 -0
  180. package/src/client/DiffTab.tsx +114 -0
  181. package/src/client/DiffView.tsx +305 -0
  182. package/src/client/EditorHost.tsx +563 -0
  183. package/src/client/FenceErrorNotice.tsx +40 -0
  184. package/src/client/FileTree.tsx +701 -0
  185. package/src/client/FreeWindow.tsx +337 -0
  186. package/src/client/GitView.tsx +722 -0
  187. package/src/client/MarkdownHtml.tsx +296 -0
  188. package/src/client/OrphanedTab.tsx +27 -0
  189. package/src/client/PdfView.tsx +110 -0
  190. package/src/client/ReleaseNotesDialog.tsx +91 -0
  191. package/src/client/RenderBoundary.tsx +49 -0
  192. package/src/client/SandboxStatusBar.tsx +60 -0
  193. package/src/client/SideCardSection.module.css +932 -0
  194. package/src/client/SideCardSection.tsx +1169 -0
  195. package/src/client/Sidebar.tsx +1857 -0
  196. package/src/client/TabBar.tsx +365 -0
  197. package/src/client/TerminalView.tsx +424 -0
  198. package/src/client/TextEditor.tsx +468 -0
  199. package/src/client/TreePanel.tsx +279 -0
  200. package/src/client/UpdateDialog.tsx +186 -0
  201. package/src/client/UpdateSettingsPanel.tsx +29 -0
  202. package/src/client/UpdateSettingsSection.tsx +22 -0
  203. package/src/client/UploadOverlay.tsx +62 -0
  204. package/src/client/UsageHeatmapSection.module.css +440 -0
  205. package/src/client/UsageHeatmapSection.tsx +301 -0
  206. package/src/client/add-plugin-modal.tsx +199 -0
  207. package/src/client/api.ts +433 -0
  208. package/src/client/binary-download.tsx +23 -0
  209. package/src/client/breakpoints.ts +61 -0
  210. package/src/client/browser.ts +167 -0
  211. package/src/client/builtins/index.ts +37 -0
  212. package/src/client/builtins/tabs.tsx +275 -0
  213. package/src/client/builtins/viewers.tsx +125 -0
  214. package/src/client/chat-presentation.ts +95 -0
  215. package/src/client/chunk-loader.ts +350 -0
  216. package/src/client/chunks/editor.tsx +9 -0
  217. package/src/client/chunks/mermaid.tsx +10 -0
  218. package/src/client/chunks/terminal.tsx +9 -0
  219. package/src/client/cm-themes.ts +129 -0
  220. package/src/client/conversation-draft.ts +29 -0
  221. package/src/client/css-modules.d.ts +5 -0
  222. package/src/client/desktop-env.ts +72 -0
  223. package/src/client/editor-load.ts +92 -0
  224. package/src/client/frame-batcher.ts +56 -0
  225. package/src/client/icons.tsx +170 -0
  226. package/src/client/image-types.ts +8 -0
  227. package/src/client/ime-guard.ts +47 -0
  228. package/src/client/index.tsx +501 -0
  229. package/src/client/intercept.tsx +172 -0
  230. package/src/client/lang.ts +225 -0
  231. package/src/client/layout-push.ts +37 -0
  232. package/src/client/layout.css +156 -0
  233. package/src/client/lazy-chunk.tsx +89 -0
  234. package/src/client/link-intercept.ts +73 -0
  235. package/src/client/locales-ar.ts +387 -0
  236. package/src/client/locales-de.ts +372 -0
  237. package/src/client/locales-fr.ts +379 -0
  238. package/src/client/locales-hi.ts +386 -0
  239. package/src/client/locales-id.ts +384 -0
  240. package/src/client/locales-it.ts +377 -0
  241. package/src/client/locales-ja.ts +486 -0
  242. package/src/client/locales-ko.ts +378 -0
  243. package/src/client/locales-nl.ts +384 -0
  244. package/src/client/locales-pl.ts +388 -0
  245. package/src/client/locales-pt.ts +369 -0
  246. package/src/client/locales-ru.ts +384 -0
  247. package/src/client/locales-sv.ts +369 -0
  248. package/src/client/locales-th.ts +386 -0
  249. package/src/client/locales-tr.ts +386 -0
  250. package/src/client/locales-vi.ts +386 -0
  251. package/src/client/locales-zh-HK.ts +401 -0
  252. package/src/client/locales-zh-MO.ts +401 -0
  253. package/src/client/locales-zh-TW.ts +401 -0
  254. package/src/client/locales.ts +1229 -0
  255. package/src/client/markdown-html.ts +331 -0
  256. package/src/client/markdown-images.ts +137 -0
  257. package/src/client/markdown-labels.tsx +31 -0
  258. package/src/client/md-toc.tsx +127 -0
  259. package/src/client/mermaid-blocks.ts +110 -0
  260. package/src/client/mermaid-sanitize.ts +93 -0
  261. package/src/client/mermaid.tsx +406 -0
  262. package/src/client/open-when-sized.ts +43 -0
  263. package/src/client/open-with-settings.tsx +135 -0
  264. package/src/client/open-with.ts +221 -0
  265. package/src/client/openpath-intercept.ts +79 -0
  266. package/src/client/paths.ts +63 -0
  267. package/src/client/pdf-types.ts +4 -0
  268. package/src/client/pinned.ts +184 -0
  269. package/src/client/plugin-settings.ts +43 -0
  270. package/src/client/plugins-shared.ts +29 -0
  271. package/src/client/plugins-tabs.ts +153 -0
  272. package/src/client/plugins-viewers.ts +44 -0
  273. package/src/client/prefs.ts +216 -0
  274. package/src/client/produced-files.ts +120 -0
  275. package/src/client/selection-payload.ts +86 -0
  276. package/src/client/service.ts +873 -0
  277. package/src/client/session-pins.tsx +334 -0
  278. package/src/client/settings-nav-icon.ts +55 -0
  279. package/src/client/shell-presets.ts +83 -0
  280. package/src/client/sidebar.module.css +3607 -0
  281. package/src/client/split-pane.tsx +333 -0
  282. package/src/client/state.ts +1646 -0
  283. package/src/client/tab-content-memo.ts +55 -0
  284. package/src/client/terminal-font.ts +224 -0
  285. package/src/client/terminal-links.ts +194 -0
  286. package/src/client/theme.ts +110 -0
  287. package/src/client/titlebar-strip.ts +37 -0
  288. package/src/client/ui-tweaks/appicons.ts +24 -0
  289. package/src/client/ui-tweaks/archive.tsx +281 -0
  290. package/src/client/ui-tweaks/branding.tsx +232 -0
  291. package/src/client/ui-tweaks/cachehit.tsx +122 -0
  292. package/src/client/ui-tweaks/gitbar.tsx +2559 -0
  293. package/src/client/ui-tweaks/index.tsx +1696 -0
  294. package/src/client/ui-tweaks/mcp.tsx +582 -0
  295. package/src/client/ui-tweaks/notifier.ts +514 -0
  296. package/src/client/ui-tweaks/whale.tsx +171 -0
  297. package/src/client/upload.ts +187 -0
  298. package/src/client/useChatcodeAccount.ts +103 -0
  299. package/src/client/wco.ts +120 -0
  300. package/src/codingplan.ts +146 -0
  301. package/src/config.ts +207 -0
  302. package/src/context-types.ts +559 -0
  303. package/src/fs-operations.ts +97 -0
  304. package/src/fs-search.ts +113 -0
  305. package/src/fs-tree.ts +158 -0
  306. package/src/git.ts +446 -0
  307. package/src/html-route.ts +106 -0
  308. package/src/index.ts +1524 -0
  309. package/src/invariant.ts +32 -0
  310. package/src/login-route.ts +26 -0
  311. package/src/open-external.ts +90 -0
  312. package/src/path-security.ts +81 -0
  313. package/src/prefs-shared.ts +303 -0
  314. package/src/pty-deps.ts +241 -0
  315. package/src/pty-manager.ts +376 -0
  316. package/src/tools.ts +482 -0
  317. package/src/trust-fence.ts +84 -0
  318. package/src/ui-tweaks/archive.ts +293 -0
  319. package/src/ui-tweaks/config.ts +257 -0
  320. package/src/ui-tweaks/git-web.ts +421 -0
  321. package/src/ui-tweaks/git.ts +1524 -0
  322. package/src/ui-tweaks/index.ts +61 -0
  323. package/src/ui-tweaks/mcp.ts +535 -0
  324. package/src/ui-tweaks/turn-outcome.ts +105 -0
  325. package/src/ui-tweaks/web.ts +225 -0
  326. package/src/wire.ts +110 -0
  327. package/vendor/addon-fit.js +2 -0
  328. package/vendor/xterm.css +218 -0
  329. package/vendor/xterm.js +5 -0
@@ -0,0 +1,701 @@
1
+ /**
2
+ * The controlled file tree behind the files window's tree panel (TreePanel
3
+ * wraps it with the search box): a lazy VSCode-style tree rooted at the
4
+ * session's working directory. Levels load on expansion (one API call per
5
+ * directory), directories sort first, hidden entries render dimmed. The
6
+ * expansion set lives in the per-session state (owned by the caller); the
7
+ * caller also owns the refresh affordance — a `refreshTick` bump wipes the
8
+ * level cache so the visible set reloads.
9
+ *
10
+ * Row actions: hovering a row reveals an @-reference button on the far
11
+ * right (appends `@<relative path>` to the composer draft), and right-click
12
+ * opens a context menu: file rows offer the caller's open escapes
13
+ * (new tab / to the side, only when the callbacks exist) and a download
14
+ * action (the host serves raw bytes, binary-safe); directory rows offer
15
+ * "upload here"; every row can copy the relative or absolute path (with a
16
+ * brief "copied" label replacing the button after a successful write).
17
+ *
18
+ * Uploads start here (drag-drop or the context menu picker) but run in the
19
+ * caller: every request is reported through `onUploadRequest(dir, items)`
20
+ * (VSCode semantics — a drop on a file row targets its parent directory),
21
+ * and `busy` gates new drags while one upload is in flight.
22
+ */
23
+ import { useCallback, useEffect, useRef, useState, type DragEvent, type MouseEvent, type ReactNode } from 'react'
24
+ import { createPortal } from 'react-dom'
25
+ import clsx from 'clsx'
26
+ import {
27
+ IconChevronRightOutline14, IconCodeOutline16, IconCopyOutline16, IconDownloadOutline16,
28
+ IconLinkOutline16, Menu, type MenuEntry, type MenuItem, writeClipboard,
29
+ } from '@deepseek-ai/dsh-client-ui-primitives'
30
+ import { SiCursor, SiZedindustries } from 'react-icons/si'
31
+ import { VscFile, VscFolder, VscFolderOpened, VscLinkExternal, VscPin, VscPinned } from 'react-icons/vsc'
32
+ import { api, downloadUrl, isOutsideWorkspaceMessage, type FsEntry } from './api.ts'
33
+ import { FenceErrorNotice } from './FenceErrorNotice.tsx'
34
+ import { IconUploadOutline16, IconVscode16 } from './icons.tsx'
35
+ import type { OpenWithTarget } from './open-with.ts'
36
+ import { relativeTo } from './paths.ts'
37
+ import { t } from './locales.ts'
38
+ import type { SidebarStore } from './state.ts'
39
+ import { uploadItemsFromDrop, uploadItemsFromFiles, type UploadItem } from './upload.ts'
40
+ import css from './sidebar.module.css'
41
+
42
+ interface LevelData {
43
+ entries?: FsEntry[]
44
+ error?: string
45
+ }
46
+
47
+ /** Root label: the last path segment (mirror of the host rootLabel). */
48
+ export function baseName(path: string): string {
49
+ const trimmed = path.replace(/[\\/]+$/, '')
50
+ const at = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\'))
51
+ return at === -1 ? trimmed : trimmed.slice(at + 1)
52
+ }
53
+
54
+ /** The containing directory of an absolute row path (never the root edge here). */
55
+ function parentOf(path: string): string {
56
+ const at = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'))
57
+ return at <= 0 ? path : path.slice(0, at)
58
+ }
59
+
60
+ /** Only OS file drags belong to the upload surface; in-app drags (tab reorder,
61
+ * split zones) must pass through untouched to the pane's tab-drop handling
62
+ * (mirror of Sidebar.tsx's panel-host shield gate). */
63
+ function isFileDrag(event: DragEvent): boolean {
64
+ return event.dataTransfer?.types.includes('Files') ?? false
65
+ }
66
+
67
+ /** How long the row's "copied" label stays after a successful write. */
68
+ const COPIED_MS = 1200
69
+
70
+ /**
71
+ * The drop overlay's hero art: an arrow rising out of a notched tray
72
+ * (upload zone — the same glyph family as the toolbar's upload icon) and a
73
+ * tilted pair of photo cards (chat zone). Hand-drawn, colored in the
74
+ * palette of DSH's own native drop illustration (#3964FE / #9CE5ED) so the
75
+ * two zones read as one family; the drop overlay is this flow's one brand
76
+ * moment, so it gets color the rest of the UI never does.
77
+ */
78
+ const UploadDropIllustration = () => (
79
+ <svg width="64" height="56" viewBox="0 0 64 56" fill="none" aria-hidden="true">
80
+ <path d="M32 28V11" stroke="#3964FE" strokeWidth="5" strokeLinecap="round" />
81
+ <path d="M23 20l9-9 9 9" stroke="#3964FE" strokeWidth="5" strokeLinecap="round" strokeLinejoin="round" />
82
+ <path
83
+ d="M10 40a4 4 0 0 1 4-4h7l3.2 4.6a5 5 0 0 0 4.1 2.2h7.4a5 5 0 0 0 4.1-2.2L43 36h7a4 4 0 0 1 4 4v2a10 10 0 0 1-10 10H20A10 10 0 0 1 10 42v-2z"
84
+ fill="#9CE5ED"
85
+ />
86
+ </svg>
87
+ )
88
+
89
+ /** The chat zone's art: two tilted photo cards, each with its own
90
+ * sun-over-mountains motif (the back card carries detail too, so it never
91
+ * reads as a bare blob). */
92
+ const ChatDropIllustration = () => (
93
+ <svg width="96" height="76" viewBox="0 0 96 76" fill="none" aria-hidden="true">
94
+ <g transform="rotate(-12 24 34)">
95
+ <rect x="6" y="16" width="36" height="36" rx="10" fill="#9CE5ED" />
96
+ <circle cx="16" cy="27" r="3.5" fill="white" />
97
+ <path d="M11 44l8-9 6 6 4-4 8 9" stroke="white" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" />
98
+ </g>
99
+ <g transform="rotate(8 61 35)">
100
+ <rect x="40" y="12" width="42" height="46" rx="10" fill="#3964FE" />
101
+ <circle cx="55" cy="27" r="5" fill="white" />
102
+ <path d="M46 50l10-13 7 8 6-6 9 11" stroke="white" strokeWidth="3.5" strokeLinecap="round" strokeLinejoin="round" />
103
+ </g>
104
+ </svg>
105
+ )
106
+
107
+ export function FileTree(props: {
108
+ sessionId: string
109
+ cwd: string | undefined
110
+ /** The sidebar store: the fence-refusal notice writes the `workspaceFence` pref through it. */
111
+ store: SidebarStore
112
+ expanded: string[]
113
+ /** Files highlighted by a "Show in folder" reveal (absolute paths). */
114
+ revealed: string[]
115
+ onToggle: (path: string) => void
116
+ onOpenFile: (path: string) => void
117
+ /** Context-menu "open in a new tab" (file rows; absent → no entry). */
118
+ onOpenFileNewTab?: (path: string) => void
119
+ /** Context-menu "open to the side" (file rows; absent → no entry). */
120
+ onOpenFileSide?: (path: string) => void
121
+ /**
122
+ * The "open with" menu: resolved external targets (already SSH-filtered
123
+ * and in menu order). Absent → the whole section is hidden.
124
+ */
125
+ openWithTargets?: OpenWithTarget[]
126
+ /** Ids of targets pinned to the menu's top level (subset of the ids). */
127
+ openWithPinned?: string[]
128
+ /** Whether the workspace is remote (appends the SSH hint to target labels). */
129
+ openWithSsh?: boolean
130
+ /** Open one target externally (reveal or URL — the caller decides). */
131
+ onOpenWith?: (targetId: string, path: string) => void
132
+ /** Toggle one target's pinned state (the submenu row's pushpin). */
133
+ onToggleOpenWithPin?: (targetId: string) => void
134
+ /** Insert `@<relative path>` into the composer draft. */
135
+ onReferenceFile: (path: string) => void
136
+ /** Bump to wipe the level cache and reload the visible set. */
137
+ refreshTick: number
138
+ /** Upload into `dir` (absolute, inside the workspace); runs in the caller. */
139
+ onUploadRequest: (dir: string, items: UploadItem[]) => void
140
+ /** True while an upload is in flight (drops are ignored). */
141
+ busy: boolean
142
+ }) {
143
+ const { sessionId, cwd, store, expanded, revealed, onToggle, onOpenFile, onOpenFileNewTab, onOpenFileSide, openWithTargets, openWithPinned, openWithSsh, onOpenWith, onToggleOpenWithPin, onReferenceFile, refreshTick, onUploadRequest, busy } = props
144
+ const [data, setData] = useState<Record<string, LevelData>>({})
145
+ const dataRef = useRef(data)
146
+ /** The row whose path was just copied ("copied" label replaces its button). */
147
+ const [copiedPath, setCopiedPath] = useState<string | null>(null)
148
+ /** Open context menu: the row path (and whether it is a directory) plus the cursor position. */
149
+ const [rowMenu, setRowMenu] = useState<{ path: string; isDir: boolean; x: number; y: number } | null>(null)
150
+ /** Whether a file drag hovers the tree (drives the portaled drop zone). */
151
+ const [dropOver, setDropOver] = useState(false)
152
+ /** The directory a drag is hovering right now (null = body, drop to root). */
153
+ const [dropTarget, setDropTarget] = useState<string | null>(null)
154
+ /**
155
+ * Enter/leave depth under the tree body. dragenter/dragleave fire per
156
+ * element along the drag path (and bubble), so a counter — DSH InputBar's
157
+ * own pattern — is the flicker-free signal; relatedTarget is unreliable
158
+ * across engines for drag events.
159
+ */
160
+ const dropDepth = useRef(0)
161
+ /** Explorer body element; its viewport rect anchors the portaled drop zone. */
162
+ const bodyRef = useRef<HTMLDivElement>(null)
163
+ /** The body's viewport rect captured at drag entry (null = not measured). */
164
+ const [dropRect, setDropRect] = useState<{ top: number; left: number; width: number; height: number } | null>(null)
165
+ /** Context-menu "upload here" target directory. */
166
+ const pendingUploadDir = useRef<string | undefined>(undefined)
167
+ const fileInputRef = useRef<HTMLInputElement>(null)
168
+
169
+ /** Reset all drag state (drop landed, the drag left, or a new drag begins). */
170
+ const resetDrop = (): void => {
171
+ dropDepth.current = 0
172
+ setDropOver(false)
173
+ setDropTarget(null)
174
+ setDropRect(null)
175
+ }
176
+
177
+ /**
178
+ * Drop handlers: always swallow the event (a dropped file must never open
179
+ * in the browser), then report the target directory to the caller. A drop
180
+ * ends the drag without further leave events, so the depth resets here.
181
+ * The payload collection is async (dropped folders are traversed through
182
+ * their entry handles — captured synchronously inside uploadItemsFromDrop
183
+ * while the dataTransfer is still live), so the request rides a then.
184
+ */
185
+ const reportDrop = (dir: string, data: DataTransfer | undefined): void => {
186
+ if (busy) return
187
+ void uploadItemsFromDrop(data).then((items) => {
188
+ if (items.length > 0) onUploadRequest(dir, items)
189
+ })
190
+ }
191
+ const handleBodyDrop = (event: DragEvent): void => {
192
+ if (!isFileDrag(event)) return
193
+ event.preventDefault()
194
+ event.stopPropagation()
195
+ resetDrop()
196
+ if (cwd !== undefined) reportDrop(cwd, event.dataTransfer)
197
+ }
198
+ const handleDirDrop = (event: DragEvent, dir: string): void => {
199
+ if (!isFileDrag(event)) return
200
+ event.preventDefault()
201
+ event.stopPropagation()
202
+ resetDrop()
203
+ reportDrop(dir, event.dataTransfer)
204
+ }
205
+ const handleFileDrop = (event: DragEvent, path: string): void => {
206
+ // VSCode semantics: dropping onto a file uploads into its directory.
207
+ handleDirDrop(event, parentOf(path))
208
+ }
209
+ const handleBodyDragEnter = (event: DragEvent): void => {
210
+ if (!isFileDrag(event)) return
211
+ event.preventDefault()
212
+ event.stopPropagation()
213
+ dropDepth.current += 1
214
+ if (busy) return
215
+ // First entry: anchor the portaled drop zone to the body's rect.
216
+ if (dropDepth.current === 1) {
217
+ const rect = bodyRef.current?.getBoundingClientRect()
218
+ setDropRect(rect === undefined ? null : { top: rect.top, left: rect.left, width: rect.width, height: rect.height })
219
+ }
220
+ setDropOver(true)
221
+ }
222
+ const handleBodyDragLeave = (): void => {
223
+ dropDepth.current = Math.max(0, dropDepth.current - 1)
224
+ if (dropDepth.current > 0) return
225
+ setDropOver(false)
226
+ setDropTarget(null)
227
+ setDropRect(null)
228
+ }
229
+ const handleBodyDragOver = (event: DragEvent): void => {
230
+ if (!isFileDrag(event)) return
231
+ event.preventDefault()
232
+ event.stopPropagation()
233
+ event.dataTransfer.dropEffect = busy ? 'none' : 'copy'
234
+ if (busy) return
235
+ // Rows stop propagation, so this only fires over non-row regions: the
236
+ // drag targets the workspace root. dragover fires continuously, making
237
+ // it the authoritative (flicker-free) place to clear the row target.
238
+ setDropTarget(null)
239
+ }
240
+ const handleRowDragOver = (event: DragEvent, dir: string): void => {
241
+ if (!isFileDrag(event)) return
242
+ event.preventDefault()
243
+ event.stopPropagation()
244
+ event.dataTransfer.dropEffect = busy ? 'none' : 'copy'
245
+ if (busy) return
246
+ setDropTarget(dir)
247
+ }
248
+
249
+ const storeLevel = useCallback((path: string, level: LevelData) => {
250
+ dataRef.current = { ...dataRef.current, [path]: level }
251
+ setData(dataRef.current)
252
+ }, [])
253
+
254
+ const loadDir = useCallback((dir: string) => {
255
+ if (dataRef.current[dir] !== undefined) return
256
+ storeLevel(dir, {})
257
+ api.fsTree({ sessionId, cwd }, dir).then((listing) => {
258
+ storeLevel(dir, { entries: listing.entries })
259
+ }).catch((error: unknown) => {
260
+ storeLevel(dir, { error: error instanceof Error ? error.message : String(error) })
261
+ })
262
+ }, [sessionId, cwd, storeLevel])
263
+
264
+ /** Drop one level from the cache and reload it (the fence notice's retry). */
265
+ const retryDir = useCallback((dir: string) => {
266
+ delete dataRef.current[dir]
267
+ setData({ ...dataRef.current })
268
+ loadDir(dir)
269
+ }, [loadDir])
270
+
271
+ // The caller's refresh tick wipes the cache (declared BEFORE the load
272
+ // effect so the reload below sees the empty cache).
273
+ const lastTick = useRef(refreshTick)
274
+ useEffect(() => {
275
+ if (lastTick.current === refreshTick) return
276
+ lastTick.current = refreshTick
277
+ dataRef.current = {}
278
+ setData({})
279
+ }, [refreshTick])
280
+
281
+ useEffect(() => {
282
+ // Load the visible set; already-loaded levels (kept in the cache) are
283
+ // not refetched. Only the refresh tick wipes the cache.
284
+ const root = cwd
285
+ if (root === undefined) return
286
+ loadDir(root)
287
+ for (const dir of expanded) loadDir(dir)
288
+ }, [cwd, expanded, refreshTick, loadDir])
289
+
290
+ // Bring a "Show in folder" reveal into view: the ancestors expand above
291
+ // (revealPaths), but the row may not be scrolled into sight — a reveal on
292
+ // a long tree should surface the highlighted file. Re-runs when the tree
293
+ // data or reveal set changes (the row appears after its level loads).
294
+ // Scrolls ONLY this tree's body: scrollIntoView would scroll every
295
+ // scrollable ancestor, and the clipping panel host
296
+ // ([data-dsh-panel-host]) is still programmatically scrollable — a deep
297
+ // reveal shifted the whole panel, tab bar included, out of the viewport.
298
+ useEffect(() => {
299
+ if (revealed.length === 0) return
300
+ const body = bodyRef.current
301
+ if (body === null) return
302
+ const row = body.querySelector<HTMLElement>('[data-dsh-revealed]')
303
+ if (row === null) return
304
+ const bodyTop = body.getBoundingClientRect().top
305
+ const rowRect = row.getBoundingClientRect()
306
+ const target = body.scrollTop + (rowRect.top + rowRect.height / 2) - (bodyTop + body.clientHeight / 2)
307
+ const max = Math.max(body.scrollHeight - body.clientHeight, 0)
308
+ body.scrollTo({ top: Math.min(Math.max(target, 0), max), behavior: 'smooth' })
309
+ }, [revealed, data])
310
+
311
+ /** Copy `text`; on success flip the row's copied label for a moment. */
312
+ const copyPath = useCallback((text: string, path: string): void => {
313
+ void writeClipboard(text).then((ok) => {
314
+ if (!ok) return
315
+ setCopiedPath(path)
316
+ window.setTimeout(() => {
317
+ setCopiedPath(current => current === path ? null : current)
318
+ }, COPIED_MS)
319
+ })
320
+ }, [])
321
+
322
+ /** The row's trailing actions: the @-reference button, or the copied label. */
323
+ const rowActions = (entry: FsEntry): ReactNode => {
324
+ if (copiedPath === entry.path) {
325
+ return <span className={css.explorerCopied}>{t('copied')}</span>
326
+ }
327
+ return (
328
+ <button
329
+ type="button"
330
+ className={css.explorerRef}
331
+ aria-label={t('referenceFile')}
332
+ title={t('referenceFile')}
333
+ onClick={(event) => {
334
+ event.stopPropagation()
335
+ onReferenceFile(entry.path)
336
+ }}
337
+ >
338
+ {t('referenceFile')}
339
+ </button>
340
+ )
341
+ }
342
+
343
+ const openRowMenu = (event: MouseEvent, path: string, isDir: boolean): void => {
344
+ event.preventDefault()
345
+ event.stopPropagation()
346
+ setRowMenu({ path, isDir, x: event.clientX, y: event.clientY })
347
+ }
348
+
349
+ /** Download a file through the host route (raw bytes, binary-safe). */
350
+ const downloadFile = (path: string): void => {
351
+ const url = downloadUrl({ sessionId, cwd }, path)
352
+ const anchor = document.createElement('a')
353
+ anchor.href = url
354
+ anchor.style.display = 'none'
355
+ document.body.appendChild(anchor)
356
+ anchor.click()
357
+ anchor.remove()
358
+ }
359
+
360
+ /** The menu label of one open target: a locale key for the built-ins, the
361
+ * user's own name for custom editors, plus the SSH hint in remote mode. */
362
+ const openWithLabelOf = (target: OpenWithTarget): string => {
363
+ const name = target.nameKey !== undefined ? t(target.nameKey) : target.name
364
+ return openWithSsh === true && !target.localOnly ? `${name}${t('openWithSshSuffix')}` : name
365
+ }
366
+
367
+ /**
368
+ * The "open with" menu entries: the pinned targets as DIRECT rows, then
369
+ * the parent row with every target as a nested submenu. Both only render
370
+ * when the caller wired the feature and at least one target is visible.
371
+ */
372
+ const openWithEntries = (): MenuEntry[] => {
373
+ if (openWithTargets === undefined || onOpenWith === undefined || openWithTargets.length === 0) return []
374
+ const pinnedIds = openWithPinned ?? []
375
+ /** Brand marks for the built-ins (monochrome silhouettes, currentColor);
376
+ * reveal gets the folder glyph, custom editors a generic code mark. */
377
+ const itemIcon = (target: OpenWithTarget): ReactNode => {
378
+ if (target.kind === 'reveal') return <VscFolderOpened size={16} />
379
+ if (target.id === 'vscode') return <IconVscode16 size={16} />
380
+ if (target.id === 'cursor') return <SiCursor size={16} />
381
+ if (target.id === 'zed') return <SiZedindustries size={16} />
382
+ return <IconCodeOutline16 size={16} />
383
+ }
384
+ const pinned = openWithTargets
385
+ .filter(target => pinnedIds.includes(target.id))
386
+ .map<MenuItem>(target => ({
387
+ id: `open-with:${target.id}`,
388
+ label: openWithLabelOf(target),
389
+ icon: itemIcon(target),
390
+ }))
391
+ const submenu = openWithTargets.map<MenuItem>(target => {
392
+ const pinnedNow = pinnedIds.includes(target.id)
393
+ return {
394
+ id: `open-with:${target.id}`,
395
+ label: (
396
+ <span className={css.openWithLabel}>
397
+ <span className={css.openWithName}>{openWithLabelOf(target)}</span>
398
+ {/* The pushpin: a span (never a button — the Menu row itself is
399
+ a button, so a nested interactive element would be invalid).
400
+ Clicking it pins/unpins the target at the menu's top level
401
+ WITHOUT selecting the row: the pin stops propagation, so the
402
+ menu stays open and the icon flips on the next render. */}
403
+ <span
404
+ role="button"
405
+ tabIndex={-1}
406
+ className={clsx(css.openWithPin, pinnedNow && css.openWithPinActive)}
407
+ aria-label={pinnedNow ? t('unpinOpenWith') : t('pinOpenWith')}
408
+ title={pinnedNow ? t('unpinOpenWith') : t('pinOpenWith')}
409
+ onClick={(event) => {
410
+ event.preventDefault()
411
+ event.stopPropagation()
412
+ onToggleOpenWithPin?.(target.id)
413
+ }}
414
+ >
415
+ {pinnedNow ? <VscPinned size={14} /> : <VscPin size={14} />}
416
+ </span>
417
+ </span>
418
+ ),
419
+ icon: itemIcon(target),
420
+ }
421
+ })
422
+ return [
423
+ ...pinned,
424
+ ...(pinned.length > 0 ? [{ id: 'open-with-sep', type: 'separator' } as MenuEntry] : []),
425
+ {
426
+ id: 'open-with-menu',
427
+ // The primitives Menu renders no chevron for submenu parents — the
428
+ // trailing arrow is supplied inside the label (full-width flex row,
429
+ // right-aligned), matching how the submenu rows right-align the pin.
430
+ label: (
431
+ <span className={css.openWithLabel}>
432
+ <span className={css.openWithName}>{t('openWithMenu')}</span>
433
+ <IconChevronRightOutline14 size={14} className={css.openWithChevron} aria-hidden />
434
+ </span>
435
+ ),
436
+ icon: <VscLinkExternal size={16} />,
437
+ submenu,
438
+ },
439
+ ]
440
+ }
441
+
442
+ const root = cwd
443
+
444
+ const renderLevel = (dir: string, depth: number): ReactNode => {
445
+ const level = data[dir]
446
+ if (level === undefined) {
447
+ return <div className={css.explorerRow} style={{ paddingLeft: depth * 22 + 6 }}>{t('loading')}</div>
448
+ }
449
+ if (level.error !== undefined) {
450
+ // The fence refusal becomes the friendly notice (reason + one-click
451
+ // global off + immediate retry of this directory), never the raw
452
+ // `path "..." is outside workspace` wire text.
453
+ if (isOutsideWorkspaceMessage(level.error)) {
454
+ return (
455
+ <div style={{ paddingLeft: depth * 22 + 6 }}>
456
+ <FenceErrorNotice store={store} onDisabled={() => { retryDir(dir) }} />
457
+ </div>
458
+ )
459
+ }
460
+ return (
461
+ <div className={clsx(css.explorerRow, css.explorerError)} style={{ paddingLeft: depth * 22 + 6 }}>
462
+ {level.error}
463
+ </div>
464
+ )
465
+ }
466
+ const entries = level.entries ?? []
467
+ return entries.map(entry => {
468
+ if (entry.isDir) {
469
+ const isOpen = expanded.includes(entry.path)
470
+ return (
471
+ <div key={entry.path}>
472
+ <div
473
+ role="button"
474
+ tabIndex={0}
475
+ className={clsx(
476
+ css.explorerRow, css.explorerDir, entry.hidden && css.explorerHidden,
477
+ dropTarget === entry.path && css.explorerRowDropTarget,
478
+ revealed.includes(entry.path) && css.explorerRowRevealed,
479
+ )}
480
+ data-dsh-revealed={revealed.includes(entry.path) ? 'true' : undefined}
481
+ style={{ paddingLeft: depth * 22 + 6 }}
482
+ onClick={() => { onToggle(entry.path) }}
483
+ onKeyDown={(event) => {
484
+ if (event.key === 'Enter' || event.key === ' ') {
485
+ event.preventDefault()
486
+ onToggle(entry.path)
487
+ }
488
+ }}
489
+ onDragOver={(event) => { handleRowDragOver(event, entry.path) }}
490
+ onDrop={(event) => { handleDirDrop(event, entry.path) }}
491
+ onContextMenu={(event) => { openRowMenu(event, entry.path, true) }}
492
+ >
493
+ {isOpen ? <VscFolderOpened size={14} /> : <VscFolder size={14} />}
494
+ <span className={css.explorerName}>{entry.name}</span>
495
+ {entry.isSymlink && <IconLinkOutline16 size={12} className={css.explorerSymlink} />}
496
+ {rowActions(entry)}
497
+ </div>
498
+ {isOpen && renderLevel(entry.path, depth + 1)}
499
+ </div>
500
+ )
501
+ }
502
+ return (
503
+ <div
504
+ key={entry.path}
505
+ role="button"
506
+ tabIndex={0}
507
+ className={clsx(
508
+ css.explorerRow, entry.hidden && css.explorerHidden, entry.broken && css.explorerBroken,
509
+ dropTarget === parentOf(entry.path) && css.explorerRowDropTarget,
510
+ revealed.includes(entry.path) && css.explorerRowRevealed,
511
+ )}
512
+ data-dsh-revealed={revealed.includes(entry.path) ? 'true' : undefined}
513
+ style={{ paddingLeft: depth * 22 + 6 }}
514
+ title={entry.broken ? `${entry.path} — ${t('brokenSymlink')}` : entry.path}
515
+ onClick={() => { onOpenFile(entry.path) }}
516
+ onKeyDown={(event) => {
517
+ if (event.key === 'Enter' || event.key === ' ') {
518
+ event.preventDefault()
519
+ onOpenFile(entry.path)
520
+ }
521
+ }}
522
+ onDragOver={(event) => { handleRowDragOver(event, parentOf(entry.path)) }}
523
+ onDrop={(event) => { handleFileDrop(event, entry.path) }}
524
+ onContextMenu={(event) => { openRowMenu(event, entry.path, false) }}
525
+ >
526
+ <VscFile size={14} />
527
+ <span className={css.explorerName}>{entry.name}</span>
528
+ {entry.isSymlink && <IconLinkOutline16 size={12} className={css.explorerSymlink} />}
529
+ {rowActions(entry)}
530
+ </div>
531
+ )
532
+ })
533
+ }
534
+
535
+ return (
536
+ <div
537
+ ref={bodyRef}
538
+ className={css.explorerBody}
539
+ onDragEnter={handleBodyDragEnter}
540
+ onDragOver={handleBodyDragOver}
541
+ onDragLeave={handleBodyDragLeave}
542
+ onDrop={handleBodyDrop}
543
+ >
544
+ {root === undefined ? (
545
+ <div className={css.explorerEmpty}>{t('noSession')}</div>
546
+ ) : (
547
+ <>
548
+ <div
549
+ className={clsx(css.explorerRow, dropTarget === root && css.explorerRowDropTarget)}
550
+ style={{ paddingLeft: 6 }}
551
+ onDragOver={(event) => { handleRowDragOver(event, root) }}
552
+ onDrop={(event) => { handleDirDrop(event, root) }}
553
+ onContextMenu={(event) => { openRowMenu(event, root, true) }}
554
+ >
555
+ <VscFolderOpened size={14} />
556
+ <span className={css.explorerName}>{baseName(root)}</span>
557
+ {copiedPath === root
558
+ ? <span className={css.explorerCopied}>{t('copied')}</span>
559
+ : (
560
+ <button
561
+ type="button"
562
+ className={css.explorerRef}
563
+ aria-label={t('referenceFile')}
564
+ title={t('referenceFile')}
565
+ onClick={(event) => {
566
+ event.stopPropagation()
567
+ onReferenceFile(root)
568
+ }}
569
+ >
570
+ {t('referenceFile')}
571
+ </button>
572
+ )}
573
+ </div>
574
+ {data[root] !== undefined && renderLevel(root, 1)}
575
+ </>
576
+ )}
577
+ {dropOver && dropRect !== null && createPortal(
578
+ /*
579
+ * The sidebar's drop surface, portaled to document.body at z-1001 —
580
+ * above DSH's own whole-page drop mask (z-1000, see InputBar's
581
+ * document-level intake) so the two never compete. It dims the WHOLE
582
+ * viewport (the giant box-shadow spread on the zone frame is the
583
+ * mask; the zone rect itself stays clear), teaching the zone split:
584
+ * the dimmed conversation column still takes drops into the chat
585
+ * natively (this layer is pointer-inert), while the clear frame
586
+ * marks the tree as the workspace-upload zone. Deliberate exception
587
+ * to the "panel stays below the DSH float stack" rule: transient,
588
+ * and the drop always lands on the element beneath. The hint pill
589
+ * docks at the TOP edge of the zone — right under the search row,
590
+ * the first thing the eye meets — keeping the rows aimable.
591
+ */
592
+ <>
593
+ <div
594
+ className={css.uploadDropZone}
595
+ style={{
596
+ top: dropRect.top + 2,
597
+ left: dropRect.left + 2,
598
+ width: dropRect.width - 4,
599
+ height: dropRect.height - 4,
600
+ }}
601
+ >
602
+ <div className={css.uploadDropHero}>
603
+ <UploadDropIllustration />
604
+ <div className={css.uploadDropZonePill}>
605
+ <IconUploadOutline16 size={14} />
606
+ <span className={css.uploadDropZoneText}>
607
+ {dropTarget !== null ? t('uploadTo', { dir: dropTarget }) : t('uploadDropHint')}
608
+ </span>
609
+ </div>
610
+ </div>
611
+ </div>
612
+ {/* The left zone's invitation, centered in the space beside the
613
+ tree; skipped when that space is too narrow to hold it. */}
614
+ {dropRect.left >= 200 && (
615
+ <div className={css.uploadDropChatHint} style={{ width: dropRect.left }}>
616
+ <div className={css.uploadDropChatCard}>
617
+ <ChatDropIllustration />
618
+ <span>{t('uploadDropChat')}</span>
619
+ </div>
620
+ </div>
621
+ )}
622
+ </>,
623
+ document.body,
624
+ )}
625
+ {/*
626
+ The one shared context menu, positioned at the right-click cursor
627
+ (portal so the tree's overflow clip cannot crop it).
628
+ */}
629
+ <input
630
+ ref={fileInputRef}
631
+ type="file"
632
+ multiple
633
+ style={{ display: 'none' }}
634
+ onChange={(event) => {
635
+ const dir = pendingUploadDir.current ?? root
636
+ pendingUploadDir.current = undefined
637
+ if (dir !== undefined && !busy) onUploadRequest(dir, uploadItemsFromFiles(event.target.files ?? []))
638
+ event.target.value = ''
639
+ }}
640
+ />
641
+ <Menu
642
+ open={rowMenu !== null}
643
+ onClose={() => { setRowMenu(null) }}
644
+ items={[
645
+ // The open escapes head the FILE menu (dirs only get copy).
646
+ ...(rowMenu?.isDir === false && onOpenFileNewTab !== undefined
647
+ ? [{ id: 'open-new-tab', label: t('openFileNewTab'), icon: <IconCodeOutline16 size={16} /> }]
648
+ : []),
649
+ ...(rowMenu?.isDir === false && onOpenFileSide !== undefined
650
+ ? [{ id: 'open-side', label: t('openFileSide'), icon: <VscFolderOpened size={16} /> }]
651
+ : []),
652
+ ...openWithEntries(),
653
+ // Download applies to files only (the host route refuses directories).
654
+ ...(rowMenu?.isDir === false
655
+ ? [{ id: 'download', label: t('download'), icon: <IconDownloadOutline16 size={16} /> }]
656
+ : []),
657
+ // Upload into a directory (incl. the workspace root row).
658
+ ...(rowMenu?.isDir === true
659
+ ? [{ id: 'upload-here', label: t('uploadHere'), icon: <IconUploadOutline16 size={16} /> }]
660
+ : []),
661
+ { id: 'relative', label: t('copyRelative'), icon: <IconCopyOutline16 size={16} /> },
662
+ { id: 'absolute', label: t('copyAbsolute'), icon: <IconCopyOutline16 size={16} /> },
663
+ ]}
664
+ onSelect={(id) => {
665
+ const target = rowMenu
666
+ if (target === null) return
667
+ setRowMenu(null)
668
+ if (id === 'open-new-tab') {
669
+ onOpenFileNewTab?.(target.path)
670
+ return
671
+ }
672
+ if (id === 'open-side') {
673
+ onOpenFileSide?.(target.path)
674
+ return
675
+ }
676
+ if (id.startsWith('open-with:')) {
677
+ onOpenWith?.(id.slice('open-with:'.length), target.path)
678
+ return
679
+ }
680
+ if (id === 'download') {
681
+ downloadFile(target.path)
682
+ return
683
+ }
684
+ if (id === 'upload-here') {
685
+ pendingUploadDir.current = target.path
686
+ fileInputRef.current?.click()
687
+ return
688
+ }
689
+ copyPath(
690
+ id === 'relative' ? relativeTo(cwd ?? '', target.path) : target.path,
691
+ target.path,
692
+ )
693
+ }}
694
+ portal
695
+ align="start"
696
+ getAnchorRect={() => (rowMenu === null ? null : new DOMRect(rowMenu.x, rowMenu.y, 0, 0))}
697
+ anchor={<span />}
698
+ />
699
+ </div>
700
+ )
701
+ }