wiki-viewer 1.14.1 → 1.14.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 (384) hide show
  1. package/.next/standalone/.next/BUILD_ID +1 -1
  2. package/.next/standalone/.next/build-manifest.json +3 -3
  3. package/.next/standalone/.next/server/app/_global-error.html +1 -1
  4. package/.next/standalone/.next/server/app/_global-error.rsc +1 -1
  5. package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
  6. package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  7. package/.next/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  8. package/.next/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  9. package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  10. package/.next/standalone/.next/server/app/_not-found.html +1 -1
  11. package/.next/standalone/.next/server/app/_not-found.rsc +1 -1
  12. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  13. package/.next/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  14. package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  15. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  16. package/.next/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  17. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  18. package/.next/standalone/.next/server/app/api/agent/activity/route.js.nft.json +1 -1
  19. package/.next/standalone/.next/server/app/api/agent/events/[...path]/route.js.nft.json +1 -1
  20. package/.next/standalone/.next/server/app/api/agent/files/[...path]/route.js.nft.json +1 -1
  21. package/.next/standalone/.next/server/app/api/agent/fs/file/[...path]/route.js.nft.json +1 -1
  22. package/.next/standalone/.next/server/app/api/agent/fs/ls/[[...path]]/route.js.nft.json +1 -1
  23. package/.next/standalone/.next/server/app/api/agent/fs/move/route.js.nft.json +1 -1
  24. package/.next/standalone/.next/server/app/api/agent/fs/search/route.js.nft.json +1 -1
  25. package/.next/standalone/.next/server/app/api/agent/internal/span/route.js.nft.json +1 -1
  26. package/.next/standalone/.next/server/app/api/agent/settings/route.js.nft.json +1 -1
  27. package/.next/standalone/.next/server/app/api/agent/sidecar/[...path]/route.js.nft.json +1 -1
  28. package/.next/standalone/.next/server/app/api/assets/[...path]/route.js.nft.json +1 -1
  29. package/.next/standalone/.next/server/app/api/share/[token]/route.js.nft.json +1 -1
  30. package/.next/standalone/.next/server/app/api/share/route.js.nft.json +1 -1
  31. package/.next/standalone/.next/server/app/api/system/reveal/route.js.nft.json +1 -1
  32. package/.next/standalone/.next/server/app/api/system/set-root/route.js.nft.json +1 -1
  33. package/.next/standalone/.next/server/app/api/system/workspaces/[id]/open/route.js.nft.json +1 -1
  34. package/.next/standalone/.next/server/app/api/system/workspaces/[id]/refresh/route.js.nft.json +1 -1
  35. package/.next/standalone/.next/server/app/api/system/workspaces/[id]/route.js.nft.json +1 -1
  36. package/.next/standalone/.next/server/app/api/system/workspaces/route.js.nft.json +1 -1
  37. package/.next/standalone/.next/server/app/api/upload/[...path]/route.js.nft.json +1 -1
  38. package/.next/standalone/.next/server/app/api/wiki/app/route.js.nft.json +1 -1
  39. package/.next/standalone/.next/server/app/api/wiki/backlinks/route.js.nft.json +1 -1
  40. package/.next/standalone/.next/server/app/api/wiki/content/route.js.nft.json +1 -1
  41. package/.next/standalone/.next/server/app/api/wiki/download/route.js.nft.json +1 -1
  42. package/.next/standalone/.next/server/app/api/wiki/file/route.js.nft.json +1 -1
  43. package/.next/standalone/.next/server/app/api/wiki/folder/route.js.nft.json +1 -1
  44. package/.next/standalone/.next/server/app/api/wiki/git-branches/route.js.nft.json +1 -1
  45. package/.next/standalone/.next/server/app/api/wiki/git-checkout/route.js.nft.json +1 -1
  46. package/.next/standalone/.next/server/app/api/wiki/git-diff/route.js.nft.json +1 -1
  47. package/.next/standalone/.next/server/app/api/wiki/git-file-info/route.js.nft.json +1 -1
  48. package/.next/standalone/.next/server/app/api/wiki/git-history/route.js.nft.json +1 -1
  49. package/.next/standalone/.next/server/app/api/wiki/git-pull/route.js.nft.json +1 -1
  50. package/.next/standalone/.next/server/app/api/wiki/move/route.js.nft.json +1 -1
  51. package/.next/standalone/.next/server/app/api/wiki/new-file/route.js.nft.json +1 -1
  52. package/.next/standalone/.next/server/app/api/wiki/outlinks/route.js.nft.json +1 -1
  53. package/.next/standalone/.next/server/app/api/wiki/page/route.js.nft.json +1 -1
  54. package/.next/standalone/.next/server/app/api/wiki/presence/route.js.nft.json +1 -1
  55. package/.next/standalone/.next/server/app/api/wiki/route.js.nft.json +1 -1
  56. package/.next/standalone/.next/server/app/api/wiki/search/route.js.nft.json +1 -1
  57. package/.next/standalone/.next/server/app/api/wiki/slugs/route.js.nft.json +1 -1
  58. package/.next/standalone/.next/server/app/api/wiki/upload/route.js.nft.json +1 -1
  59. package/.next/standalone/.next/server/app/api/wiki/watch/route.js.nft.json +1 -1
  60. package/.next/standalone/.next/server/app/index.html +1 -1
  61. package/.next/standalone/.next/server/app/index.rsc +1 -1
  62. package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  63. package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
  64. package/.next/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  65. package/.next/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  66. package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  67. package/.next/standalone/.next/server/middleware-build-manifest.js +3 -3
  68. package/.next/standalone/.next/server/middleware-manifest.json +1 -1
  69. package/.next/standalone/.next/server/pages/404.html +1 -1
  70. package/.next/standalone/.next/server/pages/500.html +1 -1
  71. package/.next/standalone/package.json +1 -1
  72. package/bin/postbuild.js +21 -0
  73. package/package.json +1 -1
  74. package/.next/standalone/.github/workflows/publish.yml +0 -37
  75. package/.next/standalone/.handoffs/archive/handoff-20260612-113332.md +0 -98
  76. package/.next/standalone/.handoffs/archive/handoff-20260612-141032.md +0 -73
  77. package/.next/standalone/.pi/ownership.json +0 -3
  78. package/.next/standalone/AGENTS.md +0 -114
  79. package/.next/standalone/DESIGN.md +0 -521
  80. package/.next/standalone/README.md +0 -821
  81. package/.next/standalone/TODO.md +0 -20
  82. package/.next/standalone/agents/bootstrap-prompt.md +0 -14
  83. package/.next/standalone/agents/wiki-viewer-skill/SKILL.md +0 -456
  84. package/.next/standalone/bin/postbuild.js +0 -78
  85. package/.next/standalone/bin/wiki-viewer.js +0 -922
  86. package/.next/standalone/certificates/localhost-key.pem +0 -28
  87. package/.next/standalone/certificates/localhost.pem +0 -24
  88. package/.next/standalone/docs/agent-collab-plan.md +0 -1615
  89. package/.next/standalone/docs/agent-collab-v2-plan.md +0 -771
  90. package/.next/standalone/docs/agent-fs-plan.md +0 -232
  91. package/.next/standalone/docs/editorial-skin-spec.md +0 -242
  92. package/.next/standalone/docs/file-vs-collab-authority.md +0 -124
  93. package/.next/standalone/docs/mobile-readiness-spec.md +0 -229
  94. package/.next/standalone/docs/workspacing-plan.md +0 -364
  95. package/.next/standalone/packages/wiki-viewer-mcp/README.md +0 -195
  96. package/.next/standalone/packages/wiki-viewer-mcp/bench-e2e.mjs +0 -166
  97. package/.next/standalone/packages/wiki-viewer-mcp/bench-latency.mjs +0 -87
  98. package/.next/standalone/packages/wiki-viewer-mcp/bench-payload.mjs +0 -45
  99. package/.next/standalone/packages/wiki-viewer-mcp/dist/http-client.js +0 -331
  100. package/.next/standalone/packages/wiki-viewer-mcp/dist/index.js +0 -531
  101. package/.next/standalone/packages/wiki-viewer-mcp/dist/register.js +0 -94
  102. package/.next/standalone/packages/wiki-viewer-mcp/dist/state-cache.js +0 -28
  103. package/.next/standalone/packages/wiki-viewer-mcp/package-lock.json +0 -1716
  104. package/.next/standalone/packages/wiki-viewer-mcp/package.json +0 -41
  105. package/.next/standalone/packages/wiki-viewer-mcp/src/http-client.ts +0 -459
  106. package/.next/standalone/packages/wiki-viewer-mcp/src/index.ts +0 -638
  107. package/.next/standalone/packages/wiki-viewer-mcp/src/register.ts +0 -144
  108. package/.next/standalone/packages/wiki-viewer-mcp/src/state-cache.ts +0 -55
  109. package/.next/standalone/packages/wiki-viewer-mcp/tsconfig.json +0 -19
  110. package/.next/standalone/pnpm-lock.yaml +0 -6697
  111. package/.next/standalone/pnpm-workspace.yaml +0 -8
  112. package/.next/standalone/postcss.config.mjs +0 -2
  113. package/.next/standalone/src/app/api/agent/activity/route.ts +0 -59
  114. package/.next/standalone/src/app/api/agent/admin/agents/[agentId]/revoke/route.ts +0 -40
  115. package/.next/standalone/src/app/api/agent/admin/agents/route.ts +0 -33
  116. package/.next/standalone/src/app/api/agent/admin/registrations/[regId]/approve/route.ts +0 -83
  117. package/.next/standalone/src/app/api/agent/admin/registrations/[regId]/deny/route.ts +0 -36
  118. package/.next/standalone/src/app/api/agent/admin/registrations/route.ts +0 -27
  119. package/.next/standalone/src/app/api/agent/events/[...path]/route.ts +0 -148
  120. package/.next/standalone/src/app/api/agent/files/[...path]/route.ts +0 -219
  121. package/.next/standalone/src/app/api/agent/fs/file/[...path]/route.ts +0 -446
  122. package/.next/standalone/src/app/api/agent/fs/ls/[[...path]]/route.ts +0 -152
  123. package/.next/standalone/src/app/api/agent/fs/move/route.ts +0 -130
  124. package/.next/standalone/src/app/api/agent/fs/search/route.ts +0 -272
  125. package/.next/standalone/src/app/api/agent/internal/span/route.ts +0 -121
  126. package/.next/standalone/src/app/api/agent/register/[regId]/route.ts +0 -50
  127. package/.next/standalone/src/app/api/agent/register/route.ts +0 -118
  128. package/.next/standalone/src/app/api/agent/settings/route.ts +0 -34
  129. package/.next/standalone/src/app/api/agent/settings/token/regenerate/route.ts +0 -20
  130. package/.next/standalone/src/app/api/agent/sidecar/[...path]/route.ts +0 -53
  131. package/.next/standalone/src/app/api/agents/install/route.ts +0 -143
  132. package/.next/standalone/src/app/api/agents/skill/route.ts +0 -20
  133. package/.next/standalone/src/app/api/agents/skill.tar.gz/route.ts +0 -87
  134. package/.next/standalone/src/app/api/app-proxy/[...path]/route.ts +0 -247
  135. package/.next/standalone/src/app/api/assets/[...path]/route.ts +0 -75
  136. package/.next/standalone/src/app/api/auth/[...all]/route.ts +0 -7
  137. package/.next/standalone/src/app/api/owner/init/route.ts +0 -14
  138. package/.next/standalone/src/app/api/share/[token]/route.ts +0 -269
  139. package/.next/standalone/src/app/api/share/route.ts +0 -127
  140. package/.next/standalone/src/app/api/system/admins/route.ts +0 -103
  141. package/.next/standalone/src/app/api/system/auth-config/route.ts +0 -22
  142. package/.next/standalone/src/app/api/system/auth-settings/route.ts +0 -86
  143. package/.next/standalone/src/app/api/system/browse/route.ts +0 -82
  144. package/.next/standalone/src/app/api/system/clear-root/route.ts +0 -16
  145. package/.next/standalone/src/app/api/system/config/route.ts +0 -14
  146. package/.next/standalone/src/app/api/system/pins/route.ts +0 -38
  147. package/.next/standalone/src/app/api/system/reveal/route.ts +0 -36
  148. package/.next/standalone/src/app/api/system/root-status/route.ts +0 -13
  149. package/.next/standalone/src/app/api/system/set-root/route.ts +0 -53
  150. package/.next/standalone/src/app/api/system/users/route.ts +0 -120
  151. package/.next/standalone/src/app/api/system/workspaces/[id]/open/route.ts +0 -34
  152. package/.next/standalone/src/app/api/system/workspaces/[id]/refresh/route.ts +0 -32
  153. package/.next/standalone/src/app/api/system/workspaces/[id]/route.ts +0 -78
  154. package/.next/standalone/src/app/api/system/workspaces/route.ts +0 -128
  155. package/.next/standalone/src/app/api/upload/[...path]/route.ts +0 -85
  156. package/.next/standalone/src/app/api/wiki/app/route.ts +0 -60
  157. package/.next/standalone/src/app/api/wiki/backlinks/route.ts +0 -29
  158. package/.next/standalone/src/app/api/wiki/content/route.ts +0 -159
  159. package/.next/standalone/src/app/api/wiki/download/route.ts +0 -100
  160. package/.next/standalone/src/app/api/wiki/file/route.ts +0 -57
  161. package/.next/standalone/src/app/api/wiki/folder/route.ts +0 -34
  162. package/.next/standalone/src/app/api/wiki/git-branches/route.ts +0 -32
  163. package/.next/standalone/src/app/api/wiki/git-checkout/route.ts +0 -45
  164. package/.next/standalone/src/app/api/wiki/git-diff/route.ts +0 -33
  165. package/.next/standalone/src/app/api/wiki/git-file-info/route.ts +0 -27
  166. package/.next/standalone/src/app/api/wiki/git-history/route.ts +0 -27
  167. package/.next/standalone/src/app/api/wiki/git-pull/route.ts +0 -48
  168. package/.next/standalone/src/app/api/wiki/move/route.ts +0 -61
  169. package/.next/standalone/src/app/api/wiki/new-file/route.ts +0 -56
  170. package/.next/standalone/src/app/api/wiki/outlinks/route.ts +0 -76
  171. package/.next/standalone/src/app/api/wiki/page/route.ts +0 -92
  172. package/.next/standalone/src/app/api/wiki/presence/route.ts +0 -56
  173. package/.next/standalone/src/app/api/wiki/route.ts +0 -116
  174. package/.next/standalone/src/app/api/wiki/search/route.ts +0 -44
  175. package/.next/standalone/src/app/api/wiki/slugs/route.ts +0 -54
  176. package/.next/standalone/src/app/api/wiki/upload/route.ts +0 -95
  177. package/.next/standalone/src/app/api/wiki/watch/route.ts +0 -67
  178. package/.next/standalone/src/app/globals.css +0 -1249
  179. package/.next/standalone/src/app/icon.svg +0 -11
  180. package/.next/standalone/src/app/layout.tsx +0 -78
  181. package/.next/standalone/src/app/manifest.ts +0 -30
  182. package/.next/standalone/src/app/page.tsx +0 -3260
  183. package/.next/standalone/src/app/s/[token]/page.tsx +0 -357
  184. package/.next/standalone/src/app/signin/page.tsx +0 -16
  185. package/.next/standalone/src/app/signin/signin-form.tsx +0 -238
  186. package/.next/standalone/src/components/ai-panel/activity-row.tsx +0 -284
  187. package/.next/standalone/src/components/ai-panel/ai-panel.tsx +0 -311
  188. package/.next/standalone/src/components/ai-panel/token-section.tsx +0 -313
  189. package/.next/standalone/src/components/auth-settings-sheet.tsx +0 -474
  190. package/.next/standalone/src/components/confirm-dialog.tsx +0 -56
  191. package/.next/standalone/src/components/dir-picker.tsx +0 -606
  192. package/.next/standalone/src/components/editor/backlinks-panel.tsx +0 -139
  193. package/.next/standalone/src/components/editor/bubble-menu.tsx +0 -346
  194. package/.next/standalone/src/components/editor/callout-extension.ts +0 -36
  195. package/.next/standalone/src/components/editor/comment-pip.tsx +0 -56
  196. package/.next/standalone/src/components/editor/comment-thread.tsx +0 -254
  197. package/.next/standalone/src/components/editor/csv-viewer.tsx +0 -379
  198. package/.next/standalone/src/components/editor/document-outline.tsx +0 -230
  199. package/.next/standalone/src/components/editor/editor-toolbar.tsx +0 -564
  200. package/.next/standalone/src/components/editor/editor.tsx +0 -1327
  201. package/.next/standalone/src/components/editor/extensions/drag-handle.ts +0 -325
  202. package/.next/standalone/src/components/editor/extensions/heading-anchors.ts +0 -55
  203. package/.next/standalone/src/components/editor/extensions/proof-span.ts +0 -60
  204. package/.next/standalone/src/components/editor/extensions/resizable-image.tsx +0 -159
  205. package/.next/standalone/src/components/editor/extensions.ts +0 -150
  206. package/.next/standalone/src/components/editor/file-fallback-viewer.tsx +0 -75
  207. package/.next/standalone/src/components/editor/folder-index.tsx +0 -236
  208. package/.next/standalone/src/components/editor/google-doc-viewer.tsx +0 -73
  209. package/.next/standalone/src/components/editor/image-viewer.tsx +0 -57
  210. package/.next/standalone/src/components/editor/large-file-gate.tsx +0 -108
  211. package/.next/standalone/src/components/editor/link-popover.tsx +0 -111
  212. package/.next/standalone/src/components/editor/media-popover.tsx +0 -212
  213. package/.next/standalone/src/components/editor/media-viewer.tsx +0 -70
  214. package/.next/standalone/src/components/editor/mermaid-viewer.tsx +0 -286
  215. package/.next/standalone/src/components/editor/node-app-viewer.tsx +0 -328
  216. package/.next/standalone/src/components/editor/notebook-viewer.tsx +0 -371
  217. package/.next/standalone/src/components/editor/office/docx-viewer.tsx +0 -93
  218. package/.next/standalone/src/components/editor/office/office-chrome.tsx +0 -83
  219. package/.next/standalone/src/components/editor/office/pptx-viewer.tsx +0 -104
  220. package/.next/standalone/src/components/editor/office/xlsx-viewer.tsx +0 -192
  221. package/.next/standalone/src/components/editor/pdf-viewer.tsx +0 -32
  222. package/.next/standalone/src/components/editor/proof-span-popover.tsx +0 -162
  223. package/.next/standalone/src/components/editor/slash-commands.tsx +0 -629
  224. package/.next/standalone/src/components/editor/source-viewer.tsx +0 -289
  225. package/.next/standalone/src/components/editor/suggest-edit-popover.tsx +0 -229
  226. package/.next/standalone/src/components/editor/suggestion-card.tsx +0 -206
  227. package/.next/standalone/src/components/editor/table-menu.tsx +0 -232
  228. package/.next/standalone/src/components/editor/view-mode-comment-button.tsx +0 -77
  229. package/.next/standalone/src/components/editor/website-viewer.tsx +0 -75
  230. package/.next/standalone/src/components/editor/wiki-link-create-dialog.tsx +0 -189
  231. package/.next/standalone/src/components/editor/wiki-link-decorator.ts +0 -79
  232. package/.next/standalone/src/components/editor/wiki-link-extension.ts +0 -120
  233. package/.next/standalone/src/components/editor/wiki-link-picker.tsx +0 -250
  234. package/.next/standalone/src/components/layout/viewer-toolbar.tsx +0 -54
  235. package/.next/standalone/src/components/search/search-command-dialog.tsx +0 -246
  236. package/.next/standalone/src/components/search/sidebar-search-box.tsx +0 -121
  237. package/.next/standalone/src/components/search/snippet-text.tsx +0 -48
  238. package/.next/standalone/src/components/share-dialog.tsx +0 -307
  239. package/.next/standalone/src/components/theme-provider.tsx +0 -16
  240. package/.next/standalone/src/components/theme-toggle.tsx +0 -24
  241. package/.next/standalone/src/components/ui/badge.tsx +0 -35
  242. package/.next/standalone/src/components/ui/button.tsx +0 -56
  243. package/.next/standalone/src/components/ui/card.tsx +0 -63
  244. package/.next/standalone/src/components/ui/collapsible.tsx +0 -9
  245. package/.next/standalone/src/components/ui/command.tsx +0 -151
  246. package/.next/standalone/src/components/ui/context-menu.tsx +0 -191
  247. package/.next/standalone/src/components/ui/dialog.tsx +0 -119
  248. package/.next/standalone/src/components/ui/dropdown-menu.tsx +0 -194
  249. package/.next/standalone/src/components/ui/input.tsx +0 -22
  250. package/.next/standalone/src/components/ui/label.tsx +0 -25
  251. package/.next/standalone/src/components/ui/popover.tsx +0 -33
  252. package/.next/standalone/src/components/ui/scroll-area.tsx +0 -47
  253. package/.next/standalone/src/components/ui/select.tsx +0 -156
  254. package/.next/standalone/src/components/ui/separator.tsx +0 -30
  255. package/.next/standalone/src/components/ui/sheet.tsx +0 -140
  256. package/.next/standalone/src/components/ui/skeleton.tsx +0 -15
  257. package/.next/standalone/src/components/ui/switch.tsx +0 -28
  258. package/.next/standalone/src/components/ui/tabs.tsx +0 -54
  259. package/.next/standalone/src/components/ui/textarea.tsx +0 -21
  260. package/.next/standalone/src/components/ui/tip.tsx +0 -23
  261. package/.next/standalone/src/components/ui/toggle-group.tsx +0 -41
  262. package/.next/standalone/src/components/ui/tooltip.tsx +0 -27
  263. package/.next/standalone/src/components/view-width-toggle.tsx +0 -70
  264. package/.next/standalone/src/components/wiki/frontmatter-header.tsx +0 -164
  265. package/.next/standalone/src/hooks/use-is-mobile.ts +0 -14
  266. package/.next/standalone/src/lib/app-runner.ts +0 -272
  267. package/.next/standalone/src/lib/auth/admin.ts +0 -120
  268. package/.next/standalone/src/lib/auth/allowlist.ts +0 -50
  269. package/.next/standalone/src/lib/auth/client.ts +0 -4
  270. package/.next/standalone/src/lib/auth/csrf.ts +0 -68
  271. package/.next/standalone/src/lib/auth/server.ts +0 -215
  272. package/.next/standalone/src/lib/cabinets/tree.ts +0 -75
  273. package/.next/standalone/src/lib/config.ts +0 -114
  274. package/.next/standalone/src/lib/embeds/detect.ts +0 -142
  275. package/.next/standalone/src/lib/git-secrets.ts +0 -65
  276. package/.next/standalone/src/lib/git.ts +0 -503
  277. package/.next/standalone/src/lib/google/detect.ts +0 -107
  278. package/.next/standalone/src/lib/markdown/parse-frontmatter.ts +0 -104
  279. package/.next/standalone/src/lib/markdown/remark-wikilinks.ts +0 -100
  280. package/.next/standalone/src/lib/markdown/sanitize-schema.ts +0 -106
  281. package/.next/standalone/src/lib/markdown/to-html.ts +0 -282
  282. package/.next/standalone/src/lib/markdown/to-markdown.ts +0 -252
  283. package/.next/standalone/src/lib/proof/activity-shared.ts +0 -31
  284. package/.next/standalone/src/lib/proof/activity.ts +0 -74
  285. package/.next/standalone/src/lib/proof/audit.ts +0 -88
  286. package/.next/standalone/src/lib/proof/auth.ts +0 -143
  287. package/.next/standalone/src/lib/proof/block-refs.ts +0 -133
  288. package/.next/standalone/src/lib/proof/blocks.ts +0 -73
  289. package/.next/standalone/src/lib/proof/client-auth.ts +0 -23
  290. package/.next/standalone/src/lib/proof/collab-state.ts +0 -81
  291. package/.next/standalone/src/lib/proof/event-bus.ts +0 -47
  292. package/.next/standalone/src/lib/proof/file-lock.ts +0 -43
  293. package/.next/standalone/src/lib/proof/glob.ts +0 -51
  294. package/.next/standalone/src/lib/proof/idempotency.ts +0 -37
  295. package/.next/standalone/src/lib/proof/lease.ts +0 -117
  296. package/.next/standalone/src/lib/proof/mutex.ts +0 -36
  297. package/.next/standalone/src/lib/proof/ops-applier.ts +0 -727
  298. package/.next/standalone/src/lib/proof/owner-auth.ts +0 -2
  299. package/.next/standalone/src/lib/proof/pending.ts +0 -97
  300. package/.next/standalone/src/lib/proof/proof-span.ts +0 -141
  301. package/.next/standalone/src/lib/proof/rate-limit.ts +0 -53
  302. package/.next/standalone/src/lib/proof/raw-fs.ts +0 -197
  303. package/.next/standalone/src/lib/proof/register-rate-limit.ts +0 -53
  304. package/.next/standalone/src/lib/proof/registry.ts +0 -195
  305. package/.next/standalone/src/lib/proof/sidecar.ts +0 -103
  306. package/.next/standalone/src/lib/proof/suggest-capture.ts +0 -69
  307. package/.next/standalone/src/lib/proof/types.ts +0 -182
  308. package/.next/standalone/src/lib/proof-config.ts +0 -14
  309. package/.next/standalone/src/lib/root-dir.ts +0 -35
  310. package/.next/standalone/src/lib/search/indexable-exts.ts +0 -25
  311. package/.next/standalone/src/lib/search/indexer.ts +0 -634
  312. package/.next/standalone/src/lib/search/sanitize.ts +0 -35
  313. package/.next/standalone/src/lib/search/search-db.ts +0 -57
  314. package/.next/standalone/src/lib/search/watcher-pool.ts +0 -79
  315. package/.next/standalone/src/lib/shared-docs/db.ts +0 -177
  316. package/.next/standalone/src/lib/toast.ts +0 -9
  317. package/.next/standalone/src/lib/utils.ts +0 -6
  318. package/.next/standalone/src/lib/wiki-helpers.ts +0 -36
  319. package/.next/standalone/src/lib/workspace-client.ts +0 -82
  320. package/.next/standalone/src/lib/workspace-context.ts +0 -173
  321. package/.next/standalone/src/lib/workspaces.ts +0 -400
  322. package/.next/standalone/src/middleware.ts +0 -44
  323. package/.next/standalone/src/stores/ai-panel-store.ts +0 -79
  324. package/.next/standalone/src/stores/editor-store.ts +0 -439
  325. package/.next/standalone/src/stores/pin-store.ts +0 -56
  326. package/.next/standalone/src/stores/proof-store.ts +0 -124
  327. package/.next/standalone/src/stores/recent-store.ts +0 -51
  328. package/.next/standalone/src/stores/search-store.ts +0 -104
  329. package/.next/standalone/src/stores/show-hidden-store.ts +0 -26
  330. package/.next/standalone/src/stores/sidebar-width-store.ts +0 -39
  331. package/.next/standalone/src/stores/skin-store.ts +0 -42
  332. package/.next/standalone/src/stores/tree-store.ts +0 -52
  333. package/.next/standalone/src/stores/view-width-store.ts +0 -101
  334. package/.next/standalone/src/stores/wiki-slugs-store.ts +0 -75
  335. package/.next/standalone/src/tests/proof/activity-aggregator.test.ts +0 -146
  336. package/.next/standalone/src/tests/proof/admin-create-user.test.ts +0 -43
  337. package/.next/standalone/src/tests/proof/agent-fs.test.ts +0 -973
  338. package/.next/standalone/src/tests/proof/agent-ownership.test.ts +0 -140
  339. package/.next/standalone/src/tests/proof/agent-workspace-scope.test.ts +0 -134
  340. package/.next/standalone/src/tests/proof/agents-install.test.ts +0 -110
  341. package/.next/standalone/src/tests/proof/bench-write.ts +0 -179
  342. package/.next/standalone/src/tests/proof/better-auth.test.ts +0 -68
  343. package/.next/standalone/src/tests/proof/block-refs.test.ts +0 -108
  344. package/.next/standalone/src/tests/proof/blocks.test.ts +0 -92
  345. package/.next/standalone/src/tests/proof/collab-state.test.ts +0 -501
  346. package/.next/standalone/src/tests/proof/comments-ops.test.ts +0 -177
  347. package/.next/standalone/src/tests/proof/editor-roundtrip.test.ts +0 -85
  348. package/.next/standalone/src/tests/proof/external-edit.test.ts +0 -58
  349. package/.next/standalone/src/tests/proof/file-lock.test.ts +0 -80
  350. package/.next/standalone/src/tests/proof/git-branches-checkout.test.ts +0 -136
  351. package/.next/standalone/src/tests/proof/git-read-features.test.ts +0 -473
  352. package/.next/standalone/src/tests/proof/git-secrets.test.ts +0 -138
  353. package/.next/standalone/src/tests/proof/git-sparse.test.ts +0 -257
  354. package/.next/standalone/src/tests/proof/git-workspace.test.ts +0 -135
  355. package/.next/standalone/src/tests/proof/git.test.ts +0 -243
  356. package/.next/standalone/src/tests/proof/glob.test.ts +0 -82
  357. package/.next/standalone/src/tests/proof/helpers/auth-session.ts +0 -27
  358. package/.next/standalone/src/tests/proof/markdown-to-html.test.ts +0 -46
  359. package/.next/standalone/src/tests/proof/ops-applier.test.ts +0 -368
  360. package/.next/standalone/src/tests/proof/owner-init.test.ts +0 -2
  361. package/.next/standalone/src/tests/proof/parse-frontmatter.test.ts +0 -60
  362. package/.next/standalone/src/tests/proof/preload.ts +0 -21
  363. package/.next/standalone/src/tests/proof/proof-span.test.ts +0 -98
  364. package/.next/standalone/src/tests/proof/rate-limit.test.ts +0 -54
  365. package/.next/standalone/src/tests/proof/readonly-enforcement.test.ts +0 -161
  366. package/.next/standalone/src/tests/proof/reconcile-sidecar.test.ts +0 -195
  367. package/.next/standalone/src/tests/proof/registration-flow.test.ts +0 -330
  368. package/.next/standalone/src/tests/proof/registry.test.ts +0 -169
  369. package/.next/standalone/src/tests/proof/routes.test.ts +0 -516
  370. package/.next/standalone/src/tests/proof/search-index.test.ts +0 -376
  371. package/.next/standalone/src/tests/proof/sidecar-lifecycle.test.ts +0 -177
  372. package/.next/standalone/src/tests/proof/sidecar-trim.test.ts +0 -58
  373. package/.next/standalone/src/tests/proof/suggestion-ops.test.ts +0 -280
  374. package/.next/standalone/src/tests/proof/wiki-content-put.test.ts +0 -140
  375. package/.next/standalone/src/tests/proof/wiki-routes-auth.test.ts +0 -208
  376. package/.next/standalone/src/tests/proof/workspace-isolation.test.ts +0 -87
  377. package/.next/standalone/src/tests/proof/workspaces-api.test.ts +0 -227
  378. package/.next/standalone/src/types/index.ts +0 -47
  379. package/.next/standalone/tailwind.config.ts +0 -191
  380. package/.next/standalone/tsconfig.json +0 -41
  381. package/.next/standalone/tsconfig.tsbuildinfo +0 -1
  382. /package/.next/standalone/.next/static/{WMOTgfV82apmdovxzgbMx → rRyvFORvJoc5OWbCR2A_P}/_buildManifest.js +0 -0
  383. /package/.next/standalone/.next/static/{WMOTgfV82apmdovxzgbMx → rRyvFORvJoc5OWbCR2A_P}/_clientMiddlewareManifest.js +0 -0
  384. /package/.next/standalone/.next/static/{WMOTgfV82apmdovxzgbMx → rRyvFORvJoc5OWbCR2A_P}/_ssgManifest.js +0 -0
@@ -1,3260 +0,0 @@
1
- "use client";
2
-
3
- import {
4
- AlertCircle,
5
- Bot,
6
- Check,
7
- ChevronDown,
8
- ChevronRight,
9
- Copy,
10
- Download,
11
- File,
12
- FilePlus,
13
- FileText,
14
- Folder,
15
- FolderOpen,
16
- FolderPlus,
17
- GitBranch,
18
- GitMerge,
19
- Globe,
20
- History,
21
- User,
22
- Image as ImageIcon,
23
- Link,
24
- Loader2,
25
- Maximize2,
26
- MoreHorizontal,
27
- PanelLeftClose,
28
- PanelLeftOpen,
29
- Pencil,
30
- Eye,
31
- EyeOff,
32
- Pin,
33
- Plus,
34
- RefreshCw,
35
- Search,
36
- Settings,
37
- Share,
38
- Slash,
39
- SortAsc,
40
- Sparkles,
41
- Star,
42
- Terminal,
43
- Trash2,
44
- Upload,
45
- X,
46
- } from "lucide-react";
47
- import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
48
- import { createPortal } from "react-dom";
49
-
50
- import { ConfirmDialog } from "@/components/confirm-dialog";
51
- import { CsvViewer } from "@/components/editor/csv-viewer";
52
- import { KBEditor } from "@/components/editor/editor";
53
- import { FileFallbackViewer } from "@/components/editor/file-fallback-viewer";
54
- import { LargeFileGate } from "@/components/editor/large-file-gate";
55
- import { ImageViewer } from "@/components/editor/image-viewer";
56
- import { MediaViewer } from "@/components/editor/media-viewer";
57
- import { MermaidViewer } from "@/components/editor/mermaid-viewer";
58
- import { NotebookViewer } from "@/components/editor/notebook-viewer";
59
- import { DocxViewer } from "@/components/editor/office/docx-viewer";
60
- import { PptxViewer } from "@/components/editor/office/pptx-viewer";
61
- import { XlsxViewer } from "@/components/editor/office/xlsx-viewer";
62
- import { PdfViewer } from "@/components/editor/pdf-viewer";
63
- import { SourceViewer } from "@/components/editor/source-viewer";
64
- import { WebsiteViewer } from "@/components/editor/website-viewer";
65
- import { NodeAppViewer } from "@/components/editor/node-app-viewer";
66
- import { DirPicker } from "@/components/dir-picker";
67
- import {
68
- DropdownMenu,
69
- DropdownMenuContent,
70
- DropdownMenuItem,
71
- DropdownMenuSeparator,
72
- DropdownMenuTrigger,
73
- } from "@/components/ui/dropdown-menu";
74
- import { getActiveWorkspaceId, withWs, wsFetch } from "@/lib/workspace-client";
75
- import { markdownToHtml } from "@/lib/markdown/to-html";
76
- import { ThemeToggle } from "@/components/theme-toggle";
77
- import { AuthSettingsSheet } from "@/components/auth-settings-sheet";
78
- import { ShareDialog } from "@/components/share-dialog";
79
- import { Button } from "@/components/ui/button";
80
- import { Card } from "@/components/ui/card";
81
-
82
- import { showError, showSuccess } from "@/lib/toast";
83
- import {
84
- ContextMenu,
85
- ContextMenuContent,
86
- ContextMenuItem,
87
- ContextMenuSeparator,
88
- ContextMenuTrigger,
89
- } from "@/components/ui/context-menu";
90
- import { useRecentStore } from "@/stores/recent-store";
91
- import { usePinStore } from "@/stores/pin-store";
92
- import { cn } from "@/lib/utils";
93
- import { AIPanel } from "@/components/ai-panel/ai-panel";
94
- import { SearchCommandDialog } from "@/components/search/search-command-dialog";
95
- import { SidebarSearchBox } from "@/components/search/sidebar-search-box";
96
- import { useAIPanelStore } from "@/stores/ai-panel-store";
97
- import { useEditorStore, prefetchPage } from "@/stores/editor-store";
98
- import {
99
- useViewWidthStore,
100
- VIEW_WIDTH_CLASS,
101
- VIEW_ALIGN_CLASS,
102
- } from "@/stores/view-width-store";
103
- import {
104
- useSidebarWidthStore,
105
- SIDEBAR_MIN_WIDTH,
106
- SIDEBAR_MAX_WIDTH,
107
- } from "@/stores/sidebar-width-store";
108
- import { ViewWidthToggle } from "@/components/view-width-toggle";
109
- import { useWikiSlugsStore } from "@/stores/wiki-slugs-store";
110
- import { useShowHiddenStore } from "@/stores/show-hidden-store";
111
- import { useIsMobile } from "@/hooks/use-is-mobile";
112
-
113
- function timeAgo(iso: string): string {
114
- const t = new Date(iso).getTime();
115
- if (!Number.isFinite(t)) return "";
116
- const diff = Math.max(0, Date.now() - t);
117
- const s = Math.floor(diff / 1000);
118
- if (s < 60) return "just now";
119
- const m = Math.floor(s / 60);
120
- if (m < 60) return `${m}m ago`;
121
- const h = Math.floor(m / 60);
122
- if (h < 24) return `${h}h ago`;
123
- return `${Math.floor(h / 24)}d ago`;
124
- }
125
-
126
- interface TreeNode {
127
- name: string;
128
- path: string;
129
- type: "dir" | "file" | "app" | "node-app";
130
- size?: number;
131
- modifiedAt: string;
132
- children?: TreeNode[];
133
- expanded?: boolean;
134
- loading?: boolean;
135
- git?: { branch: string; dirty: boolean };
136
- }
137
-
138
- type ViewerKind =
139
- | "editor"
140
- | "csv"
141
- | "pdf"
142
- | "mermaid"
143
- | "notebook"
144
- | "image"
145
- | "media"
146
- | "docx"
147
- | "xlsx"
148
- | "pptx"
149
- | "source"
150
- | "fallback"
151
- | "app"
152
- | "html"
153
- | "node-app"
154
- | "text";
155
-
156
- // Viewer kinds safe to open at any size: they stream, paginate, or proxy and
157
- // never load the whole file into JS. Everything else goes behind LargeFileGate,
158
- // so a new viewer is fail-safe by default until proven safe here.
159
- const SAFE_VIEWER_KINDS = new Set<ViewerKind>([
160
- "image",
161
- "media",
162
- "pdf",
163
- "fallback",
164
- "app",
165
- "node-app",
166
- "html",
167
- ]);
168
-
169
- // Files above this size open behind a confirmation gate for unsafe viewers.
170
- const LARGE_FILE_GATE_BYTES = 5 * 1024 * 1024; // 5 MB
171
-
172
- function ext(name: string) {
173
- return name.split(".").pop()?.toLowerCase() ?? "";
174
- }
175
-
176
- function viewerKindFor(
177
- filename: string,
178
- nodeType: "file" | "app" | "dir" | "node-app",
179
- ): ViewerKind {
180
- if (nodeType === "node-app") return "node-app";
181
- if (nodeType === "app") return "app";
182
- if (nodeType === "dir") return "fallback";
183
- const base = filename.split("/").pop() ?? filename;
184
- // Dotfile with no real extension (".env", ".gitignore", ".bashrc"):
185
- // `".env".split(".").pop()` -> "env", which would match nothing below,
186
- // so treat any leading-dot name as text and let the viewer sniff bytes.
187
- if (base.startsWith(".") && base.indexOf(".", 1) === -1) return "source";
188
- const fileExt = ext(filename);
189
- // No extension at all ("Makefile", "LICENSE", "Dockerfile"): assume text.
190
- if (!fileExt) return "source";
191
- if (["md", "markdown"].includes(fileExt)) return "editor";
192
- if (fileExt === "txt") return "text";
193
- if (["csv", "tsv"].includes(fileExt)) return "csv";
194
- if (fileExt === "pdf") return "pdf";
195
- if (["mmd", "mermaid"].includes(fileExt)) return "mermaid";
196
- if (fileExt === "ipynb") return "notebook";
197
- if (
198
- ["png", "jpg", "jpeg", "gif", "webp", "svg", "avif", "ico", "bmp"].includes(
199
- fileExt,
200
- )
201
- )
202
- return "image";
203
- if (
204
- ["mp4", "webm", "mov", "m4v", "mp3", "wav", "ogg", "m4a", "aac"].includes(
205
- fileExt,
206
- )
207
- )
208
- return "media";
209
- if (fileExt === "docx") return "docx";
210
- if (["xlsx", "xlsm"].includes(fileExt)) return "xlsx";
211
- if (fileExt === "pptx") return "pptx";
212
- if (fileExt === "html") return "html";
213
- if (
214
- [
215
- "py", "js", "ts", "tsx", "jsx", "go", "rs", "java", "c", "cpp", "h",
216
- "sh", "bash", "zsh", "rb", "php", "swift", "kt", "lua", "sql", "yaml",
217
- "yml", "toml", "json", "xml", "css", "scss",
218
- ].includes(fileExt)
219
- )
220
- return "source";
221
- // Default: assume text and let SourceViewer sniff the bytes. If the file is
222
- // actually binary, SourceViewer degrades to a download/reveal fallback.
223
- // This avoids a brittle text-extension whitelist that always misses
224
- // something (.env, .ini, .lock, .gradle, .properties, ...).
225
- return "source";
226
- }
227
-
228
- const TEXT_EDITABLE_EXTS = new Set([
229
- "txt", "md", "markdown", "json", "yaml", "yml", "toml", "csv", "tsv",
230
- "xml", "html", "css", "js", "ts", "tsx", "jsx", "sh", "bash", "zsh",
231
- "rb", "py", "go", "rs", "java", "c", "cpp", "h", "php", "swift", "kt",
232
- "lua", "sql", "scss", "mmd", "mermaid", "ini", "env", "log", "conf",
233
- ]);
234
-
235
- function isText(name: string) {
236
- const kind = viewerKindFor(name, "file");
237
- if (kind === "editor" || kind === "text") return true;
238
- return TEXT_EDITABLE_EXTS.has(ext(name));
239
- }
240
-
241
- function isMarkdown(name: string) {
242
- return ["md", "markdown"].includes(ext(name));
243
- }
244
- function isImage(name: string) {
245
- return viewerKindFor(name, "file") === "image";
246
- }
247
- function isHtmlFile(name: string) {
248
- return viewerKindFor(name, "file") === "html";
249
- }
250
-
251
- async function fetchDir(dir: string): Promise<TreeNode[]> {
252
- const res = await wsFetch(`/api/wiki?dir=${encodeURIComponent(dir)}`);
253
- if (!res.ok) return [];
254
- const data: {
255
- entries: Array<{
256
- name: string;
257
- type: "dir" | "file" | "app" | "node-app";
258
- size?: number;
259
- modifiedAt: string;
260
- git?: { branch: string; dirty: boolean };
261
- }>;
262
- } = await res.json();
263
- return data.entries.map((e) => ({
264
- name: e.name,
265
- path: dir ? `${dir}/${e.name}` : e.name,
266
- type: e.type,
267
- size: e.size,
268
- modifiedAt: e.modifiedAt,
269
- expanded: false,
270
- git: e.git,
271
- }));
272
- }
273
-
274
- // One-shot hover-prefetch cache for directory listings: warmed on tree-row hover,
275
- // consumed (read + deleted) by the first expand so there's no long-lived staleness.
276
- // reloadDir/the file-watcher always fetch fresh and bypass this.
277
- const dirPrefetchCache = new Map<string, TreeNode[]>();
278
- const dirPrefetchInflight = new Map<string, Promise<TreeNode[]>>();
279
-
280
- function prefetchDir(dir: string): void {
281
- if (dirPrefetchCache.has(dir) || dirPrefetchInflight.has(dir)) return;
282
- const promise = fetchDir(dir)
283
- .then((children) => {
284
- dirPrefetchCache.set(dir, children);
285
- return children;
286
- })
287
- .finally(() => dirPrefetchInflight.delete(dir));
288
- dirPrefetchInflight.set(dir, promise);
289
- promise.catch(() => {});
290
- }
291
-
292
- /** Consume a prefetched (or in-flight) dir listing, if any. One-shot. */
293
- async function takePrefetchedDir(dir: string): Promise<TreeNode[] | null> {
294
- const ready = dirPrefetchCache.get(dir);
295
- if (ready) { dirPrefetchCache.delete(dir); return ready; }
296
- const inflight = dirPrefetchInflight.get(dir);
297
- if (inflight) { const r = await inflight; dirPrefetchCache.delete(dir); return r; }
298
- return null;
299
- }
300
-
301
- function updateNodes(
302
- nodes: TreeNode[],
303
- targetPath: string,
304
- updater: (n: TreeNode) => TreeNode,
305
- ): TreeNode[] {
306
- return nodes.map((n) => {
307
- if (n.path === targetPath) return updater(n);
308
- if (n.children)
309
- return { ...n, children: updateNodes(n.children, targetPath, updater) };
310
- return n;
311
- });
312
- }
313
-
314
- function removeNode(nodes: TreeNode[], targetPath: string): TreeNode[] {
315
- return nodes
316
- .filter((n) => n.path !== targetPath)
317
- .map((n) =>
318
- n.children ? { ...n, children: removeNode(n.children, targetPath) } : n,
319
- );
320
- }
321
-
322
- /**
323
- * Stable handler bundle passed to FileTree. Every method is referentially stable
324
- * across Page() renders (backed by a ref dispatcher), so FileTree's React.memo
325
- * holds and the whole tree skips re-rendering when unrelated Page state changes
326
- * (dialogs, search, editor typing, sidebar resize, dropdowns, etc.).
327
- */
328
- interface TreeCtx {
329
- toggleFolder: (node: TreeNode) => void;
330
- openViewer: (node: TreeNode) => void;
331
- copyPath: (path: string) => void;
332
- copyWikiLink: (name: string) => void;
333
- copyUrl: (path: string) => void;
334
- copyRawContent: (path: string) => void;
335
- copyFormattedContent: (path: string, name: string) => void;
336
- handleDownload: (node: TreeNode) => void;
337
- triggerUpload: (dir: string) => void;
338
- handleCreateFile: () => void;
339
- handleCreateFolder: () => void;
340
- handleDragStart: (e: React.DragEvent, node: TreeNode) => void;
341
- handleDragOver: (e: React.DragEvent, targetPath: string, targetType: "dir" | "root") => void;
342
- handleDropOnFolder: (e: React.DragEvent, targetDirPath: string) => void;
343
- handleGitPull: (nodePath: string, parentDir: string) => void;
344
- handleCheckout: (nodePath: string, branch: string, parentDir: string) => void;
345
- loadBranches: (nodePath: string) => void;
346
- prefetch: (node: TreeNode) => void;
347
- togglePin: (node: TreeNode, wsId: string | null) => void;
348
- setDragOverPath: (p: string | null) => void;
349
- setSidebarCollapsed: (b: boolean) => void;
350
- setBranchDropdownNode: (p: string | null) => void;
351
- setBranchDropdownPos: (p: { top: number; left: number } | null) => void;
352
- setNewFileParent: (p: string | null) => void;
353
- setNewFileName: (s: string) => void;
354
- setFileCreateError: (s: string | null) => void;
355
- setNewFolderParent: (p: string | null) => void;
356
- setNewFolderName: (s: string) => void;
357
- setFolderError: (s: string | null) => void;
358
- setDeletingPath: (p: string | null) => void;
359
- setDeletingIsDir: (b: boolean) => void;
360
- }
361
-
362
- interface FileTreeProps {
363
- ctx: TreeCtx;
364
- nodes: TreeNode[];
365
- openPath: string | null;
366
- dragOverPath: string | null;
367
- branchDropdownNode: string | null;
368
- branchDropdownPos: { top: number; left: number } | null;
369
- nodeBranches: Record<string, { name: string; current: boolean }[]>;
370
- branchesLoading: Record<string, boolean>;
371
- checkingOutBranch: string | null;
372
- pullingRepo: string | null;
373
- activePaths: Set<string>;
374
- pins: Array<{ path: string }>;
375
- isMobile: boolean;
376
- activeWorkspaceId: string | null;
377
- newFileParent: string | null;
378
- newFileName: string;
379
- fileCreateError: string | null;
380
- newFolderParent: string | null;
381
- newFolderName: string;
382
- folderError: string | null;
383
- sidebarScrollRef: React.RefObject<HTMLDivElement | null>;
384
- }
385
-
386
- // content-visibility:auto lets the browser skip layout/paint for off-screen
387
- // rows — virtualization without JS scroll math, and rows stay in the DOM so
388
- // keyboard nav (querySelectorAll) and Ctrl+F still work. Combined with per-row
389
- // memo (below), large trees stay cheap on both the React and browser sides.
390
- const ROW_CV: React.CSSProperties = { contentVisibility: "auto", containIntrinsicSize: "auto 32px" };
391
-
392
- interface TreeRowViewProps {
393
- node: TreeNode;
394
- depth: number;
395
- ctx: TreeCtx;
396
- isMobile: boolean;
397
- activeWorkspaceId: string | null;
398
- sidebarScrollRef: React.RefObject<HTMLDivElement | null>;
399
- isActive: boolean;
400
- isDragOver: boolean;
401
- isPinned: boolean;
402
- isAgentActive: boolean;
403
- isPulling: boolean;
404
- branchOpen: boolean;
405
- branchPos: { top: number; left: number } | null;
406
- branches: { name: string; current: boolean }[];
407
- branchLoading: boolean;
408
- checkingOut: string | null;
409
- onHoverEnter: (node: TreeNode) => void;
410
- onHoverLeave: () => void;
411
- }
412
-
413
- // Memoized row: re-renders only when its OWN derived props change. Navigation
414
- // (openPath change) re-renders just the two affected rows, not the whole tree.
415
- const TreeRowView = memo(function TreeRowView({
416
- node, depth, ctx, isMobile, activeWorkspaceId, sidebarScrollRef,
417
- isActive, isDragOver, isPinned, isAgentActive, isPulling,
418
- branchOpen, branchPos, branches, branchLoading, checkingOut,
419
- onHoverEnter, onHoverLeave,
420
- }: TreeRowViewProps) {
421
- return (
422
- <ContextMenu>
423
- <ContextMenuTrigger asChild>
424
- <div
425
- role="treeitem"
426
- tabIndex={0}
427
- draggable={!isMobile}
428
- onKeyDown={(e) => {
429
- if (e.key === "Enter" || e.key === " ") {
430
- e.preventDefault();
431
- if (node.type === "dir") ctx.toggleFolder(node);
432
- else if (node.type === "app" || node.type === "node-app") { ctx.openViewer(node); ctx.toggleFolder(node); }
433
- else ctx.openViewer(node);
434
- } else if (e.key === "ArrowDown") {
435
- e.preventDefault();
436
- const container = sidebarScrollRef.current;
437
- if (!container) return;
438
- const items = Array.from(container.querySelectorAll<HTMLElement>('[role="treeitem"]'));
439
- const idx = items.indexOf(e.currentTarget as HTMLElement);
440
- items[idx + 1]?.focus();
441
- } else if (e.key === "ArrowUp") {
442
- e.preventDefault();
443
- const container = sidebarScrollRef.current;
444
- if (!container) return;
445
- const items = Array.from(container.querySelectorAll<HTMLElement>('[role="treeitem"]'));
446
- const idx = items.indexOf(e.currentTarget as HTMLElement);
447
- items[idx - 1]?.focus();
448
- } else if (e.key === "ArrowRight") {
449
- e.preventDefault();
450
- if (node.type === "dir" || node.type === "app" || node.type === "node-app") {
451
- if (!node.expanded) {
452
- ctx.toggleFolder(node);
453
- } else {
454
- const container = sidebarScrollRef.current;
455
- if (!container) return;
456
- const items = Array.from(container.querySelectorAll<HTMLElement>('[role="treeitem"]'));
457
- const idx = items.indexOf(e.currentTarget as HTMLElement);
458
- items[idx + 1]?.focus();
459
- }
460
- }
461
- } else if (e.key === "ArrowLeft") {
462
- e.preventDefault();
463
- if ((node.type === "dir" || node.type === "app" || node.type === "node-app") && node.expanded) {
464
- ctx.toggleFolder(node);
465
- } else if (depth > 0) {
466
- const container = sidebarScrollRef.current;
467
- if (!container) return;
468
- const items = Array.from(container.querySelectorAll<HTMLElement>('[role="treeitem"]'));
469
- const current = e.currentTarget as HTMLElement;
470
- const idx = items.indexOf(current);
471
- const currentPL = Number.parseInt(current.style.paddingLeft ?? "0", 10);
472
- for (let i = idx - 1; i >= 0; i--) {
473
- const pl = Number.parseInt(items[i].style.paddingLeft ?? "0", 10);
474
- if (pl < currentPL) { items[i].focus(); break; }
475
- }
476
- }
477
- }
478
- }}
479
- onDragStart={(e) => ctx.handleDragStart(e, node)}
480
- onDragOver={(e) =>
481
- node.type === "dir"
482
- ? ctx.handleDragOver(e, node.path, "dir")
483
- : e.preventDefault()
484
- }
485
- onDragLeave={() => ctx.setDragOverPath(null)}
486
- onDrop={(e) =>
487
- node.type === "dir"
488
- ? ctx.handleDropOnFolder(e, node.path)
489
- : e.preventDefault()
490
- }
491
- onMouseEnter={() => onHoverEnter(node)}
492
- onMouseLeave={onHoverLeave}
493
- className={cn(
494
- "tree-row-reveal flex items-center gap-1.5 rounded-sm px-2 py-1 text-sm cursor-pointer group transition-colors select-none touch-target",
495
- isActive
496
- ? "bg-accent-soft text-foreground font-medium"
497
- : "hover:bg-muted",
498
- isDragOver && "ring-2 ring-primary bg-primary-soft",
499
- node.name.startsWith(".") && "opacity-40",
500
- )}
501
- style={{ paddingLeft: `${depth * 14 + 8}px`, ...ROW_CV }}
502
- onClick={() => {
503
- if (node.type === "dir") ctx.toggleFolder(node);
504
- else if (node.type === "app" || node.type === "node-app") { ctx.openViewer(node); ctx.toggleFolder(node); if (isMobile) ctx.setSidebarCollapsed(true); }
505
- else { ctx.openViewer(node); if (isMobile) ctx.setSidebarCollapsed(true); }
506
- }}
507
- >
508
- {(node.type === "dir" || node.type === "app" || node.type === "node-app") ? (
509
- node.loading ? (
510
- <Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin text-muted-foreground" />
511
- ) : (
512
- <ChevronRight
513
- className="h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform duration-200 ease-out"
514
- style={{ transform: node.expanded ? "rotate(90deg)" : "rotate(0deg)" }}
515
- />
516
- )
517
- ) : (
518
- <span className="w-3.5 shrink-0" />
519
- )}
520
-
521
- <span className="editorial-tree-typeicon">{node.type === "dir" ? (
522
- node.expanded ? (
523
- <FolderOpen className={cn("h-4 w-4 shrink-0", !isActive && "text-warning")} />
524
- ) : (
525
- <Folder className={cn("h-4 w-4 shrink-0", !isActive && "text-warning")} />
526
- )
527
- ) : node.type === "app" ? (
528
- <Globe className={cn("h-4 w-4 shrink-0", !isActive && "text-foreground/70")} />
529
- ) : node.type === "node-app" ? (
530
- <Terminal className={cn("h-4 w-4 shrink-0", !isActive && "text-emerald-500")} />
531
- ) : isHtmlFile(node.name) ? (
532
- <Globe className={cn("h-4 w-4 shrink-0", !isActive && "text-foreground/60")} />
533
- ) : isImage(node.name) ? (
534
- <ImageIcon className={cn("h-4 w-4 shrink-0", !isActive && "text-sunshine-700")} />
535
- ) : isText(node.name) ? (
536
- <FileText className={cn("h-4 w-4 shrink-0", !isActive && "text-foreground/70")} />
537
- ) : (
538
- <File className={cn("h-4 w-4 shrink-0", !isActive && "text-foreground/60")} />
539
- )}</span>
540
-
541
- <span className="min-w-0 flex-1 truncate">{node.name}</span>
542
-
543
- {/* Git repo badge */}
544
- {node.git && (
545
- <span className="relative flex shrink-0 items-center gap-0.5 rounded-sm bg-muted px-1 py-0.5 text-[10px] text-muted-foreground">
546
- <button
547
- type="button"
548
- className="flex items-center gap-0.5 hover:text-foreground"
549
- title="Switch branch"
550
- onClick={(e) => {
551
- e.stopPropagation();
552
- if (branchOpen) { ctx.setBranchDropdownNode(null); ctx.setBranchDropdownPos(null); return; }
553
- const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
554
- ctx.setBranchDropdownPos({ top: rect.bottom + 4, left: rect.left });
555
- ctx.setBranchDropdownNode(node.path);
556
- ctx.loadBranches(node.path);
557
- }}
558
- >
559
- <GitBranch className="h-2.5 w-2.5" />
560
- {node.git.branch}
561
- {node.git.dirty && <span className="ml-0.5 text-warning">*</span>}
562
- </button>
563
- {isPulling ? (
564
- <Loader2 className="ml-0.5 h-2.5 w-2.5 animate-spin" />
565
- ) : (
566
- <button
567
- type="button"
568
- onClick={(e) => {
569
- e.stopPropagation();
570
- const parentDir = node.path.includes("/")
571
- ? node.path.substring(0, node.path.lastIndexOf("/"))
572
- : "";
573
- ctx.handleGitPull(node.path, parentDir);
574
- }}
575
- className="ml-0.5 text-muted-foreground hover:text-foreground"
576
- title="Pull latest"
577
- >
578
- <RefreshCw className="h-2.5 w-2.5" />
579
- </button>
580
- )}
581
- {/* Branch dropdown rendered as portal to escape overflow-hidden */}
582
- {branchOpen && branchPos && typeof document !== "undefined" && createPortal(
583
- <div
584
- style={{ position: "fixed", top: branchPos.top, left: branchPos.left }}
585
- className="z-[9999] min-w-[120px] rounded-md border bg-popover p-1 shadow-md"
586
- onKeyDown={(e) => { if (e.key === "Escape") { ctx.setBranchDropdownNode(null); ctx.setBranchDropdownPos(null); } }}
587
- onClick={(e) => e.stopPropagation()}
588
- >
589
- {branchLoading ? (
590
- <div className="flex justify-center py-2">
591
- <Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" />
592
- </div>
593
- ) : branches.length === 0 ? (
594
- <p className="px-2 py-1 text-[10px] text-muted-foreground">No branches</p>
595
- ) : (
596
- branches.map((b) => {
597
- const parentDir = node.path.includes("/")
598
- ? node.path.substring(0, node.path.lastIndexOf("/"))
599
- : "";
600
- return (
601
- <button
602
- key={b.name}
603
- type="button"
604
- disabled={checkingOut !== null || b.current}
605
- className={cn(
606
- "flex w-full items-center gap-1.5 rounded px-2 py-1 text-left text-[11px] hover:bg-muted",
607
- b.current && "font-semibold text-foreground",
608
- !b.current && "text-muted-foreground",
609
- )}
610
- onClick={(e) => {
611
- e.stopPropagation();
612
- if (!b.current) ctx.handleCheckout(node.path, b.name, parentDir);
613
- }}
614
- >
615
- {b.current ? <Check className="h-3 w-3 shrink-0" /> : <span className="w-3 shrink-0" />}
616
- {checkingOut === b.name ? <Loader2 className="h-3 w-3 animate-spin" /> : b.name}
617
- </button>
618
- );
619
- })
620
- )}
621
- </div>,
622
- document.body,
623
- )}
624
- </span>
625
- )}
626
-
627
- {/* Agent presence dot */}
628
- {isAgentActive && (
629
- <span
630
- className="ml-0.5 h-1.5 w-1.5 rounded-full bg-emerald-400 shrink-0 animate-pulse"
631
- title="Agent recently active"
632
- />
633
- )}
634
-
635
- <div
636
- className="hover-reveal flex max-w-0 shrink-0 items-center overflow-hidden opacity-0 transition-all duration-150 group-hover:max-w-7 group-hover:opacity-100 focus-within:max-w-7 focus-within:opacity-100"
637
- onClick={(e) => e.stopPropagation()}
638
- onKeyDown={(e) => e.stopPropagation()}
639
- >
640
- <DropdownMenu>
641
- <DropdownMenuTrigger asChild>
642
- <Button
643
- size="sm"
644
- variant="ghost"
645
- className="h-6 w-6 p-0 text-muted-foreground hover:text-foreground"
646
- title="File actions"
647
- >
648
- <MoreHorizontal className="h-3.5 w-3.5" />
649
- </Button>
650
- </DropdownMenuTrigger>
651
- <DropdownMenuContent align="end" className="w-48">
652
- <DropdownMenuItem onClick={() => ctx.copyPath(node.path)}>
653
- <Copy className="mr-2 h-3.5 w-3.5" />
654
- Copy path
655
- </DropdownMenuItem>
656
- {isMarkdown(node.name) && (
657
- <DropdownMenuItem onClick={() => ctx.copyWikiLink(node.name)}>
658
- <FileText className="mr-2 h-3.5 w-3.5" />
659
- Copy wiki link
660
- </DropdownMenuItem>
661
- )}
662
- <DropdownMenuItem onClick={() => ctx.copyUrl(node.path)}>
663
- <Link className="mr-2 h-3.5 w-3.5" />
664
- Copy URL
665
- </DropdownMenuItem>
666
- {node.type === "file" && isText(node.name) && (
667
- <>
668
- <DropdownMenuItem onClick={() => ctx.copyRawContent(node.path)}>
669
- <FileText className="mr-2 h-3.5 w-3.5" />
670
- Copy raw content
671
- </DropdownMenuItem>
672
- <DropdownMenuItem onClick={() => ctx.copyFormattedContent(node.path, node.name)}>
673
- <FileText className="mr-2 h-3.5 w-3.5" />
674
- Copy formatted content
675
- </DropdownMenuItem>
676
- </>
677
- )}
678
- <DropdownMenuSeparator />
679
- {node.type === "dir" && (
680
- <>
681
- <DropdownMenuItem
682
- onClick={async () => {
683
- if (!node.expanded) ctx.toggleFolder(node);
684
- ctx.setNewFileParent(node.path);
685
- ctx.setNewFileName("");
686
- ctx.setFileCreateError(null);
687
- }}
688
- >
689
- <FilePlus className="mr-2 h-3.5 w-3.5" />
690
- New file here
691
- </DropdownMenuItem>
692
- <DropdownMenuItem onClick={() => ctx.triggerUpload(node.path)}>
693
- <Upload className="mr-2 h-3.5 w-3.5" />
694
- Upload here
695
- </DropdownMenuItem>
696
- <DropdownMenuItem
697
- onClick={() => {
698
- ctx.setNewFolderParent(node.path);
699
- ctx.setNewFolderName("");
700
- ctx.setFolderError(null);
701
- }}
702
- >
703
- <FolderPlus className="mr-2 h-3.5 w-3.5" />
704
- New subfolder
705
- </DropdownMenuItem>
706
- <DropdownMenuSeparator />
707
- </>
708
- )}
709
- <DropdownMenuItem onClick={() => ctx.handleDownload(node)}>
710
- <Download className="mr-2 h-3.5 w-3.5" />
711
- {node.type === "file" ? "Download" : "Download as zip"}
712
- </DropdownMenuItem>
713
- <DropdownMenuItem
714
- onClick={() => ctx.togglePin(node, activeWorkspaceId)}
715
- >
716
- <Star className={cn("mr-2 h-3.5 w-3.5", isPinned && "fill-current text-amber-400")} />
717
- {isPinned ? "Unpin" : "Pin to top"}
718
- </DropdownMenuItem>
719
- <DropdownMenuSeparator />
720
- <DropdownMenuItem
721
- className="text-destructive focus:text-destructive"
722
- onClick={() => {
723
- ctx.setDeletingPath(node.path);
724
- ctx.setDeletingIsDir(node.type !== "file");
725
- }}
726
- >
727
- <Trash2 className="mr-2 h-3.5 w-3.5" />
728
- Delete
729
- </DropdownMenuItem>
730
- </DropdownMenuContent>
731
- </DropdownMenu>
732
- </div>
733
- </div>
734
- </ContextMenuTrigger>
735
- <ContextMenuContent>
736
- <ContextMenuItem onSelect={() => ctx.copyPath(node.path)}>
737
- Copy path
738
- </ContextMenuItem>
739
- {isMarkdown(node.name) && (
740
- <ContextMenuItem onSelect={() => ctx.copyWikiLink(node.name)}>
741
- Copy wiki link
742
- </ContextMenuItem>
743
- )}
744
- <ContextMenuSeparator />
745
- <ContextMenuItem onSelect={() => ctx.copyUrl(node.path)}>
746
- Copy URL
747
- </ContextMenuItem>
748
- {node.type === "file" && isText(node.name) && (
749
- <>
750
- <ContextMenuSeparator />
751
- <ContextMenuItem onSelect={() => ctx.copyRawContent(node.path)}>
752
- Copy raw content
753
- </ContextMenuItem>
754
- <ContextMenuItem onSelect={() => ctx.copyFormattedContent(node.path, node.name)}>
755
- Copy formatted content
756
- </ContextMenuItem>
757
- </>
758
- )}
759
- </ContextMenuContent>
760
- </ContextMenu>
761
- );
762
- });
763
-
764
- const FileTree = memo(function FileTree(p: FileTreeProps) {
765
- const {
766
- ctx,
767
- openPath,
768
- dragOverPath,
769
- branchDropdownNode,
770
- branchDropdownPos,
771
- nodeBranches,
772
- branchesLoading,
773
- checkingOutBranch,
774
- pullingRepo,
775
- activePaths,
776
- pins,
777
- isMobile,
778
- activeWorkspaceId,
779
- newFileParent,
780
- newFileName,
781
- fileCreateError,
782
- newFolderParent,
783
- newFolderName,
784
- folderError,
785
- sidebarScrollRef,
786
- } = p;
787
-
788
- const showHidden = useShowHiddenStore((s) => s.showHidden);
789
-
790
- // Hover-intent prefetch: a single shared timer so only the row the pointer
791
- // settles on (>120ms) is prefetched — passing the cursor over rows doesn't
792
- // fire a request per row. Stable identities so TreeRowView's memo holds.
793
- const hoverTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
794
- const onHoverEnter = useCallback((node: TreeNode) => {
795
- const isCollapsedDir =
796
- (node.type === "dir" || node.type === "app" || node.type === "node-app") &&
797
- !node.expanded &&
798
- node.children === undefined;
799
- if (node.type !== "file" && !isCollapsedDir) return;
800
- if (hoverTimer.current) clearTimeout(hoverTimer.current);
801
- hoverTimer.current = setTimeout(() => ctx.prefetch(node), 120);
802
- }, [ctx]);
803
- const onHoverLeave = useCallback(() => {
804
- if (hoverTimer.current) { clearTimeout(hoverTimer.current); hoverTimer.current = null; }
805
- }, []);
806
-
807
- // Flatten the visible tree into a list. Removes the recursion that blocks
808
- // per-row memo and lets create-inputs / empty markers be plain siblings.
809
- // Recomputed only when the tree shape or which folder is mid-create changes.
810
- const flat = useMemo(() => {
811
- const out: Array<{ kind: "row" | "newfolder" | "newfile" | "empty"; node: TreeNode; depth: number }> = [];
812
- const walk = (nodes: TreeNode[], depth: number) => {
813
- for (const node of nodes) {
814
- if (!showHidden && node.name.startsWith(".")) continue;
815
- out.push({ kind: "row", node, depth });
816
- if (node.type === "dir") {
817
- if (newFolderParent === node.path) out.push({ kind: "newfolder", node, depth });
818
- if (newFileParent === node.path) out.push({ kind: "newfile", node, depth });
819
- }
820
- if ((node.type === "dir" || node.type === "app" || node.type === "node-app") && node.expanded) {
821
- if (node.children && node.children.length > 0) walk(node.children, depth + 1);
822
- else if (node.children?.length === 0) out.push({ kind: "empty", node, depth });
823
- }
824
- }
825
- };
826
- walk(p.nodes, 0);
827
- return out;
828
- }, [p.nodes, newFileParent, newFolderParent, showHidden]);
829
-
830
- return (
831
- <>
832
- {flat.map((item) => {
833
- const { node, depth } = item;
834
- if (item.kind === "row") {
835
- const branchOpen = branchDropdownNode === node.path;
836
- return (
837
- <TreeRowView
838
- key={node.path}
839
- node={node}
840
- depth={depth}
841
- ctx={ctx}
842
- isMobile={isMobile}
843
- activeWorkspaceId={activeWorkspaceId}
844
- sidebarScrollRef={sidebarScrollRef}
845
- isActive={openPath === node.path}
846
- isDragOver={dragOverPath === node.path}
847
- isPinned={pins.some((pin) => pin.path === node.path)}
848
- isAgentActive={activePaths.has(node.path)}
849
- isPulling={pullingRepo === node.path}
850
- branchOpen={branchOpen}
851
- branchPos={branchOpen ? branchDropdownPos : null}
852
- branches={node.git ? (nodeBranches[node.path] ?? []) : []}
853
- branchLoading={node.git ? !!branchesLoading[node.path] : false}
854
- checkingOut={branchOpen ? checkingOutBranch : null}
855
- onHoverEnter={onHoverEnter}
856
- onHoverLeave={onHoverLeave}
857
- />
858
- );
859
- }
860
- if (item.kind === "newfolder") {
861
- return (
862
- <div
863
- key={`nf-${node.path}`}
864
- className="flex items-center gap-1.5 px-2 py-1"
865
- style={{ paddingLeft: `${(depth + 1) * 14 + 8}px`, ...ROW_CV }}
866
- >
867
- <span className="w-3.5 shrink-0" />
868
- <Folder className="h-4 w-4 shrink-0 text-warning" />
869
- <input
870
- className="flex-1 bg-transparent text-sm outline-none border-b border-border min-w-0"
871
- placeholder="Folder name"
872
- value={newFolderName}
873
- onChange={(e) => ctx.setNewFolderName(e.target.value)}
874
- onKeyDown={(e) => {
875
- if (e.key === "Enter") ctx.handleCreateFolder();
876
- if (e.key === "Escape") { ctx.setNewFolderParent(null); ctx.setNewFolderName(""); }
877
- }}
878
- />
879
- {folderError && <span className="text-xs text-destructive">{folderError}</span>}
880
- <Button size="sm" variant="ghost" className="h-6 w-6 p-0" onClick={ctx.handleCreateFolder}>
881
- <Check className="h-3 w-3" />
882
- </Button>
883
- <Button size="sm" variant="ghost" className="h-6 w-6 p-0" onClick={() => { ctx.setNewFolderParent(null); ctx.setNewFolderName(""); }}>
884
- <X className="h-3 w-3" />
885
- </Button>
886
- </div>
887
- );
888
- }
889
- if (item.kind === "newfile") {
890
- return (
891
- <div
892
- key={`ff-${node.path}`}
893
- className="flex items-center gap-1.5 px-2 py-1"
894
- style={{ paddingLeft: `${(depth + 1) * 14 + 8}px`, ...ROW_CV }}
895
- >
896
- <span className="w-3.5 shrink-0" />
897
- <FileText className="h-4 w-4 shrink-0 text-accent" />
898
- <input
899
- autoFocus
900
- className="flex-1 bg-transparent text-sm outline-none border-b border-border min-w-0"
901
- placeholder="filename (default .md)"
902
- value={newFileName}
903
- onChange={(e) => ctx.setNewFileName(e.target.value)}
904
- onKeyDown={(e) => {
905
- if (e.key === "Enter") ctx.handleCreateFile();
906
- if (e.key === "Escape") { ctx.setNewFileParent(null); ctx.setNewFileName(""); }
907
- }}
908
- />
909
- {fileCreateError && <span className="text-xs text-destructive">{fileCreateError}</span>}
910
- <Button size="sm" variant="ghost" className="h-6 w-6 p-0" onClick={ctx.handleCreateFile}>
911
- <Check className="h-3 w-3" />
912
- </Button>
913
- <Button size="sm" variant="ghost" className="h-6 w-6 p-0" onClick={() => { ctx.setNewFileParent(null); ctx.setNewFileName(""); }}>
914
- <X className="h-3 w-3" />
915
- </Button>
916
- </div>
917
- );
918
- }
919
- // empty
920
- return (
921
- <div
922
- key={`empty-${node.path}`}
923
- className="text-xs text-muted-foreground/50 py-0.5"
924
- style={{ paddingLeft: `${(depth + 1) * 14 + 8 + 14 + 6 + 16 + 6}px`, ...ROW_CV }}
925
- >
926
- Empty
927
- </div>
928
- );
929
- })}
930
- </>
931
- );
932
- });
933
-
934
- export default function Page() {
935
- const slugsLoadedAt = useWikiSlugsStore((s) => s.loadedAt);
936
- useEffect(() => {
937
- void useWikiSlugsStore.getState().load();
938
- }, []);
939
- void slugsLoadedAt;
940
-
941
- // null = checking, false = not set, true = ready
942
- const [rootConfigured, setRootConfigured] = useState<boolean | null>(null);
943
- const [rootPath, setRootPath] = useState<string | null>(null);
944
- // Workspace state
945
- const [activeWorkspaceId, setActiveWorkspaceId] = useState<string | null>(() =>
946
- typeof window !== "undefined" ? getActiveWorkspaceId() : null
947
- );
948
- const [workspaces, setWorkspaces] = useState<Array<{id:string;name:string;rootDir:string;lastOpenedAt?:string;createdAt:string;readOnly?:boolean;git?:{remoteUrl:string;branch?:string;username?:string;lastPulledAt?:string;lastSha?:string;lastError?:string}}>>([]);
949
- const [isWsAdmin, setIsWsAdmin] = useState(false);
950
- const [addingWorkspace, setAddingWorkspace] = useState(false);
951
- const [deletingWorkspaceId, setDeletingWorkspaceId] = useState<string | null>(null);
952
- const [refreshingWsId, setRefreshingWsId] = useState<string | null>(null);
953
-
954
- const loadWorkspaces = useCallback(async () => {
955
- try {
956
- const res = await fetch("/api/system/workspaces");
957
- if (!res.ok) throw new Error("Failed");
958
- const d: { workspaces: Array<{id:string;name:string;rootDir:string;lastOpenedAt?:string;createdAt:string;readOnly?:boolean;git?:{remoteUrl:string;branch?:string;username?:string;lastPulledAt?:string;lastSha?:string;lastError?:string}}>; isAdmin: boolean } = await res.json();
959
- setWorkspaces(d.workspaces);
960
- setIsWsAdmin(d.isAdmin);
961
- if (d.workspaces.length > 0) {
962
- setRootConfigured(true);
963
- const urlWsId = new URLSearchParams(window.location.search).get("ws");
964
- const inList = urlWsId ? d.workspaces.find((w) => w.id === urlWsId) : null;
965
- const active = inList ?? [...d.workspaces].sort(
966
- (a, b) => (b.lastOpenedAt ?? b.createdAt).localeCompare(a.lastOpenedAt ?? a.createdAt)
967
- )[0];
968
- setActiveWorkspaceId(active.id);
969
- setRootPath(active.rootDir);
970
- if (!inList) {
971
- const u = new URL(location.href);
972
- u.searchParams.set("ws", active.id);
973
- history.replaceState(null, "", u.toString());
974
- }
975
- } else {
976
- setRootConfigured(false);
977
- }
978
- } catch {
979
- setRootConfigured(false);
980
- }
981
- }, []);
982
-
983
- useEffect(() => {
984
- void loadWorkspaces();
985
- }, [loadWorkspaces]);
986
-
987
- const editorCurrentPath = useEditorStore((s) => s.currentPath);
988
-
989
- // Recent files
990
- const recents = useRecentStore((s) => s.recents);
991
- const [pinnedCollapsed, setPinnedCollapsed] = useState(false);
992
- const [recentCollapsed, setRecentCollapsed] = useState(true);
993
-
994
- // Pins
995
- const pins = usePinStore((s) => s.pins);
996
-
997
- // Sidebar scroll container ref (for keyboard nav)
998
- const sidebarScrollRef = useRef<HTMLDivElement>(null);
999
-
1000
- // Agent presence: paths touched by agents in last 60s
1001
- const activity = useAIPanelStore((s) => s.activity);
1002
- const activePaths = useMemo(() => {
1003
- const cutoff = new Date(Date.now() - 60 * 1000).toISOString();
1004
- const paths = new Set<string>();
1005
- for (const ev of activity) {
1006
- // Only agent-authored events count as "agent presence".
1007
- if (ev.at >= cutoff && ev.path && ev.by?.startsWith("ai:"))
1008
- paths.add(ev.path);
1009
- }
1010
- return paths;
1011
- }, [activity]);
1012
-
1013
- // Poll activity for presence indicator
1014
- useEffect(() => {
1015
- const load = () => { void useAIPanelStore.getState().loadActivity(); };
1016
- load();
1017
- const id = setInterval(load, 10_000);
1018
- return () => clearInterval(id);
1019
- }, []);
1020
-
1021
- // Reload recents + pins when workspace changes
1022
- useEffect(() => {
1023
- useRecentStore.getState().loadForWorkspace(activeWorkspaceId);
1024
- usePinStore.getState().loadForWorkspace(activeWorkspaceId);
1025
- }, [activeWorkspaceId]);
1026
-
1027
- // Speculative warm-up: at idle, prefetch the markdown files the user is most
1028
- // likely to open next — pins and recents (the top of the sidebar). prefetchPage
1029
- // dedups, so this is a no-op for already-cached pages. Bounded to keep it cheap.
1030
- useEffect(() => {
1031
- const paths = [
1032
- ...pins.map((p) => p.path),
1033
- ...recents.slice(0, 8).map((r) => r.path),
1034
- ]
1035
- .filter((p, i, arr) => isMarkdown(p) && arr.indexOf(p) === i)
1036
- .slice(0, 12);
1037
- if (paths.length === 0) return;
1038
- const ric: (cb: () => void) => number =
1039
- typeof window !== "undefined" && "requestIdleCallback" in window
1040
- ? (cb) => (window as unknown as { requestIdleCallback: (c: () => void) => number }).requestIdleCallback(cb)
1041
- : (cb) => window.setTimeout(cb, 400);
1042
- const id = ric(() => { for (const p of paths) prefetchPage(p); });
1043
- return () => {
1044
- if (typeof window !== "undefined" && "cancelIdleCallback" in window) {
1045
- (window as unknown as { cancelIdleCallback: (h: number) => void }).cancelIdleCallback(id);
1046
- } else {
1047
- clearTimeout(id);
1048
- }
1049
- };
1050
- }, [pins, recents]);
1051
-
1052
- // Path captured from the URL at first render, before any effect can clear it.
1053
- // Restore reads from this ref (never the live URL) so URL sync can't break it.
1054
- const initialUrlPathRef = useRef<string | null>(
1055
- typeof window !== "undefined"
1056
- ? new URLSearchParams(window.location.search).get("path")
1057
- : null,
1058
- );
1059
- const didRestoreRef = useRef(false);
1060
-
1061
- const [roots, setRoots] = useState<TreeNode[]>([]);
1062
- const [rootLoaded, setRootLoaded] = useState(false);
1063
- const [rootLoading, setRootLoading] = useState(false);
1064
- const rootLoadingRef = useRef(false);
1065
- const [refreshingTree, setRefreshingTree] = useState(false);
1066
-
1067
- const [uploading, setUploading] = useState(false);
1068
- const [uploadError, setUploadError] = useState<string | null>(null);
1069
- const fileInputRef = useRef<HTMLInputElement>(null);
1070
- const uploadDirRef = useRef<string>("");
1071
-
1072
- const [newFolderParent, setNewFolderParent] = useState<string | null>(null);
1073
- const [newFolderName, setNewFolderName] = useState("");
1074
- const [folderError, setFolderError] = useState<string | null>(null);
1075
-
1076
- const [newFileParent, setNewFileParent] = useState<string | null>(null);
1077
- const [newFileName, setNewFileName] = useState("");
1078
- const [fileCreateError, setFileCreateError] = useState<string | null>(null);
1079
-
1080
- const [deletingPath, setDeletingPath] = useState<string | null>(null);
1081
- const [deletingIsDir, setDeletingIsDir] = useState(false);
1082
-
1083
- const [openFile, setOpenFile] = useState<{
1084
- path: string;
1085
- name: string;
1086
- nodeType: "file" | "app" | "node-app";
1087
- size?: number;
1088
- } | null>(null);
1089
- // Path the user clicked "open anyway" for. Keyed by path so confirming one
1090
- // large file does not open the next.
1091
- const [gateBypassPath, setGateBypassPath] = useState<string | null>(null);
1092
- const [appFullscreen, setAppFullscreen] = useState(false);
1093
- const [appKey, setAppKey] = useState(0);
1094
- const isMobile = useIsMobile();
1095
- const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
1096
- const sidebarWidth = useSidebarWidthStore((s) => s.width);
1097
- const showHidden = useShowHiddenStore((s) => s.showHidden);
1098
- const setSidebarWidth = useSidebarWidthStore((s) => s.setWidth);
1099
- // Mobile: when the viewport is mobile, the sidebar defaults to closed.
1100
- // Re-runs when isMobile flips (orientation change, devtools resize).
1101
- useEffect(() => {
1102
- if (isMobile) setSidebarCollapsed(true);
1103
- }, [isMobile]);
1104
- const [sidebarResizing, setSidebarResizing] = useState(false);
1105
- const [settingsOpen, setSettingsOpen] = useState(false);
1106
- const [shareDialogOpen, setShareDialogOpen] = useState(false);
1107
- const [fileContent, setFileContent] = useState<string | null>(null);
1108
- const [fileRevision, setFileRevision] = useState(0);
1109
- const [fileLoading, setFileLoading] = useState(false);
1110
- const [editing, setEditing] = useState(false);
1111
- const [editContent, setEditContent] = useState("");
1112
- const [saving, setSaving] = useState(false);
1113
- const [saveError, setSaveError] = useState<string | null>(null);
1114
-
1115
- // Drag state
1116
- const dragNodeRef = useRef<TreeNode | null>(null);
1117
- const [dragOverPath, setDragOverPath] = useState<string | null>(null);
1118
-
1119
- // Refs for watcher handler (avoids stale closures in the SSE effect)
1120
- const openFileRef = useRef<typeof openFile>(null);
1121
- const editingRef = useRef(false);
1122
- const refreshViewerRef = useRef<() => Promise<void>>(async () => {});
1123
-
1124
- useEffect(() => {
1125
- if (rootLoaded || rootLoadingRef.current) return;
1126
- rootLoadingRef.current = true;
1127
- setRootLoading(true);
1128
-
1129
- fetchDir("")
1130
- .then((nodes) => {
1131
- setRoots(nodes);
1132
- setRootLoaded(true);
1133
- setRootLoading(false);
1134
- })
1135
- .catch(() => {
1136
- rootLoadingRef.current = false;
1137
- setRootLoading(false);
1138
- });
1139
- }, [rootLoaded]);
1140
-
1141
- // Expand every ancestor folder of `p` so the file is visible in the tree.
1142
- // Best-effort: requires the root nodes to already be loaded.
1143
- const revealPath = useCallback(async (p: string) => {
1144
- const parts = p.split("/");
1145
- if (parts.length <= 1) return;
1146
-
1147
- // Fetch every ancestor's children first. We must NOT apply them with one
1148
- // setRoots per level: those updaters run against the same stale `prev`
1149
- // snapshot, so a nested prefix isn't in the tree yet and updateNodes
1150
- // silently no-ops (only the top level ever matched). Collect all levels,
1151
- // then splice them in a single pass so each parent's children exist before
1152
- // the next level is inserted.
1153
- const levels: { prefix: string; children: TreeNode[] }[] = [];
1154
- let prefix = "";
1155
- for (let i = 0; i < parts.length - 1; i++) {
1156
- prefix = prefix ? `${prefix}/${parts[i]}` : parts[i];
1157
- levels.push({ prefix, children: await fetchDir(prefix) });
1158
- }
1159
-
1160
- setRoots((prev) => {
1161
- let next = prev;
1162
- for (const { prefix: pfx, children } of levels) {
1163
- next = updateNodes(next, pfx, (n) => ({
1164
- ...n,
1165
- children,
1166
- expanded: true,
1167
- }));
1168
- }
1169
- return next;
1170
- });
1171
- }, []);
1172
-
1173
- const reloadDir = useCallback(async (dir: string) => {
1174
- const fresh = await fetchDir(dir);
1175
- if (dir === "") {
1176
- // Merge: keep expanded state + loaded children for nodes that still
1177
- // exist. A blind setRoots(fresh) would collapse the tree and wipe any
1178
- // expansion done by revealPath on reload (watcher fires this often).
1179
- setRoots((prev) => {
1180
- const prevByPath = new Map(prev.map((n) => [n.path, n]));
1181
- return fresh.map((n) => {
1182
- const old = prevByPath.get(n.path);
1183
- if (old && (old.type === "dir" || old.type === "app")) {
1184
- return {
1185
- ...n,
1186
- expanded: old.expanded,
1187
- children: old.children,
1188
- };
1189
- }
1190
- return n;
1191
- });
1192
- });
1193
- } else {
1194
- setRoots((prev) =>
1195
- updateNodes(prev, dir, (n) => ({
1196
- ...n,
1197
- children: fresh,
1198
- expanded: true,
1199
- })),
1200
- );
1201
- }
1202
- }, []);
1203
-
1204
- const [pullingRepo, setPullingRepo] = useState<string | null>(null);
1205
-
1206
- // Git file info (last author + date for open file)
1207
- const [gitFileInfo, setGitFileInfo] = useState<{ sha: string; author: string; date: string } | null>(null);
1208
-
1209
- // History panel
1210
- const [showHistory, setShowHistory] = useState(false);
1211
- const [historyCommits, setHistoryCommits] = useState<{ sha: string; shortSha: string; message: string; author: string; date: string }[]>([]);
1212
- const [historyLoading, setHistoryLoading] = useState(false);
1213
- const [selectedDiffSha, setSelectedDiffSha] = useState<string | null>(null);
1214
- const [diffContent, setDiffContent] = useState<string | null>(null);
1215
- const [diffLoading, setDiffLoading] = useState(false);
1216
-
1217
- // Branch switcher
1218
- const [nodeBranches, setNodeBranches] = useState<Record<string, { name: string; current: boolean }[]>>({});
1219
- const [branchesLoading, setBranchesLoading] = useState<Record<string, boolean>>({});
1220
- const [checkingOutBranch, setCheckingOutBranch] = useState<string | null>(null);
1221
- const [branchDropdownNode, setBranchDropdownNode] = useState<string | null>(null);
1222
- const [branchDropdownPos, setBranchDropdownPos] = useState<{ top: number; left: number } | null>(null);
1223
-
1224
- const handleGitPull = useCallback(async (nodePath: string, parentDir: string) => {
1225
- if (pullingRepo) return;
1226
- setPullingRepo(nodePath);
1227
- try {
1228
- const res = await wsFetch("/api/wiki/git-pull", {
1229
- method: "POST",
1230
- body: JSON.stringify({ path: nodePath }),
1231
- });
1232
- if (!res.ok) {
1233
- const e: { error?: string; message?: string } = await res.json();
1234
- showError(e.message ?? e.error ?? "Pull failed");
1235
- return;
1236
- }
1237
- const data: { branch: string; sha: string } = await res.json();
1238
- showSuccess(`Pulled ${nodePath} (${data.branch} @ ${data.sha.slice(0, 7)})`);
1239
- await reloadDir(parentDir);
1240
- } catch {
1241
- showError("Pull failed");
1242
- } finally {
1243
- setPullingRepo(null);
1244
- }
1245
- }, [pullingRepo, reloadDir]);
1246
-
1247
- // Reset history panel when file changes
1248
- useEffect(() => {
1249
- setShowHistory(false);
1250
- setHistoryCommits([]);
1251
- setSelectedDiffSha(null);
1252
- setDiffContent(null);
1253
- }, [openFile?.path]);
1254
-
1255
- // Fetch git metadata for open file. Keyed on path only: re-fetching on every
1256
- // openFile object re-ref (unrelated re-renders) wastes a git round-trip.
1257
- const openPath = openFile?.path;
1258
- useEffect(() => {
1259
- if (!openPath) { setGitFileInfo(null); return; }
1260
- let cancelled = false;
1261
- // Debounced: rapid navigation shouldn't fire a git round-trip per pass-through.
1262
- const timer = setTimeout(() => {
1263
- void (async () => {
1264
- try {
1265
- const res = await wsFetch(`/api/wiki/git-file-info?path=${encodeURIComponent(openPath)}`);
1266
- if (cancelled) return;
1267
- if (!res.ok) { setGitFileInfo(null); return; }
1268
- const d: { info: { sha: string; author: string; date: string } | null } = await res.json();
1269
- if (!cancelled) setGitFileInfo(d.info);
1270
- } catch { if (!cancelled) setGitFileInfo(null); }
1271
- })();
1272
- }, 200);
1273
- return () => { cancelled = true; clearTimeout(timer); };
1274
- }, [openPath]);
1275
-
1276
- const loadHistory = useCallback(async () => {
1277
- if (!openFile) return;
1278
- const path = openFile.path;
1279
- setShowHistory(true);
1280
- setHistoryLoading(true);
1281
- setHistoryCommits([]);
1282
- setSelectedDiffSha(null);
1283
- setDiffContent(null);
1284
- try {
1285
- const res = await wsFetch(`/api/wiki/git-history?path=${encodeURIComponent(path)}`);
1286
- if (!res.ok) { showError("Could not load history"); return; }
1287
- const d: { commits: { sha: string; shortSha: string; message: string; author: string; date: string }[] } = await res.json();
1288
- if (openFile.path === path) setHistoryCommits(d.commits);
1289
- } catch { showError("Could not load history"); }
1290
- finally { setHistoryLoading(false); }
1291
- }, [openFile]);
1292
-
1293
- const selectDiff = useCallback(async (sha: string) => {
1294
- if (!openFile) return;
1295
- if (selectedDiffSha === sha) { setSelectedDiffSha(null); setDiffContent(null); return; }
1296
- const path = openFile.path;
1297
- const targetSha = sha;
1298
- setSelectedDiffSha(sha);
1299
- setDiffLoading(true);
1300
- setDiffContent(null);
1301
- try {
1302
- const res = await wsFetch(`/api/wiki/git-diff?path=${encodeURIComponent(path)}&sha=${encodeURIComponent(sha)}`);
1303
- if (!res.ok) { showError("Could not load diff"); return; }
1304
- const d: { diff: string } = await res.json();
1305
- if (openFile.path === path && selectedDiffSha === targetSha) setDiffContent(d.diff);
1306
- } catch { showError("Could not load diff"); }
1307
- finally { setDiffLoading(false); }
1308
- }, [openFile, selectedDiffSha]);
1309
-
1310
- const loadBranches = useCallback(async (nodePath: string) => {
1311
- if (nodeBranches[nodePath] || branchesLoading[nodePath]) return;
1312
- let cancelled = false;
1313
- setBranchesLoading((prev) => ({ ...prev, [nodePath]: true }));
1314
- try {
1315
- const res = await wsFetch(`/api/wiki/git-branches?path=${encodeURIComponent(nodePath)}`);
1316
- if (cancelled) return;
1317
- if (!res.ok) { showError("Could not load branches"); return; }
1318
- const d: { branches: { name: string; current: boolean }[] } = await res.json();
1319
- if (!cancelled) setNodeBranches((prev) => ({ ...prev, [nodePath]: d.branches }));
1320
- } catch { if (!cancelled) showError("Could not load branches"); }
1321
- finally { if (!cancelled) setBranchesLoading((prev) => ({ ...prev, [nodePath]: false })); }
1322
- return () => { cancelled = true; };
1323
- }, [nodeBranches, branchesLoading]);
1324
-
1325
- const handleCheckout = useCallback(async (nodePath: string, branch: string, parentDir: string) => {
1326
- if (checkingOutBranch) return;
1327
- setCheckingOutBranch(branch);
1328
- try {
1329
- const res = await wsFetch("/api/wiki/git-checkout", {
1330
- method: "POST",
1331
- body: JSON.stringify({ path: nodePath, branch }),
1332
- });
1333
- if (res.status === 409) { showError("Repository has uncommitted changes"); return; }
1334
- if (!res.ok) { const e: { error?: string } = await res.json(); showError(e.error ?? "Checkout failed"); return; }
1335
- const d: { branch: string; sha: string } = await res.json();
1336
- showSuccess(`Switched to ${d.branch}`);
1337
- // Invalidate cached branches so next open re-fetches
1338
- setNodeBranches((prev) => { const n = { ...prev }; delete n[nodePath]; return n; });
1339
- setBranchDropdownNode(null);
1340
- setBranchDropdownPos(null);
1341
- await reloadDir(parentDir);
1342
- } catch { showError("Checkout failed"); }
1343
- finally { setCheckingOutBranch(null); }
1344
- }, [checkingOutBranch, reloadDir]);
1345
-
1346
- // Close branch dropdown on outside click or Escape
1347
- useEffect(() => {
1348
- if (!branchDropdownNode) return;
1349
- const handleClick = () => { setBranchDropdownNode(null); setBranchDropdownPos(null); };
1350
- const handleKey = (e: KeyboardEvent) => { if (e.key === "Escape") { setBranchDropdownNode(null); setBranchDropdownPos(null); } };
1351
- document.addEventListener("click", handleClick);
1352
- document.addEventListener("keydown", handleKey);
1353
- return () => {
1354
- document.removeEventListener("click", handleClick);
1355
- document.removeEventListener("keydown", handleKey);
1356
- };
1357
- }, [branchDropdownNode]);
1358
-
1359
- const collectExpandedPaths = useCallback((nodes: TreeNode[]): string[] => {
1360
- const paths: string[] = [];
1361
- for (const n of nodes) {
1362
- if ((n.type === "dir" || n.type === "app" || n.type === "node-app") && n.expanded && n.children) {
1363
- paths.push(n.path);
1364
- paths.push(...collectExpandedPaths(n.children));
1365
- }
1366
- }
1367
- return paths;
1368
- }, []);
1369
-
1370
- const refreshTree = useCallback(async () => {
1371
- setRefreshingTree(true);
1372
- try {
1373
- const expandedPaths = collectExpandedPaths(roots);
1374
- const fresh = await fetchDir("");
1375
- setRoots(fresh);
1376
- for (const p of expandedPaths) {
1377
- const dirFresh = await fetchDir(p);
1378
- setRoots((prev) =>
1379
- updateNodes(prev, p, (n) => ({
1380
- ...n,
1381
- children: dirFresh,
1382
- expanded: true,
1383
- })),
1384
- );
1385
- }
1386
- } finally {
1387
- setRefreshingTree(false);
1388
- }
1389
- }, [roots, collectExpandedPaths]);
1390
-
1391
- const refreshViewer = useCallback(async () => {
1392
- if (!openFile) return;
1393
- if (isMarkdown(openFile.name)) {
1394
- setFileLoading(true);
1395
- try {
1396
- await useEditorStore.getState().loadPage(openFile.path);
1397
- } finally {
1398
- setFileLoading(false);
1399
- }
1400
- return;
1401
- }
1402
- const kind = viewerKindFor(openFile.name, openFile.nodeType);
1403
- if (!["editor", "text"].includes(kind) && !isText(openFile.name)) return;
1404
- setFileLoading(true);
1405
- try {
1406
- const res = await wsFetch(
1407
- `/api/wiki/content?path=${encodeURIComponent(openFile.path)}`,
1408
- );
1409
- if (res.ok) {
1410
- const d: { content: string } = await res.json();
1411
- setFileContent(d.content);
1412
- setFileRevision(Number(res.headers.get("X-Wiki-Revision") ?? 0));
1413
- }
1414
- } catch {
1415
- /* ignore */
1416
- }
1417
- setFileLoading(false);
1418
- }, [openFile]);
1419
-
1420
- useEffect(() => {
1421
- if (!openFile || !isMarkdown(openFile.name)) return;
1422
- if (useEditorStore.getState().currentPath === openFile.path) return;
1423
- void useEditorStore.getState().loadPage(openFile.path);
1424
- }, [openFile]);
1425
-
1426
- // Keep watcher refs in sync
1427
- useEffect(() => {
1428
- openFileRef.current = openFile;
1429
- }, [openFile]);
1430
- useEffect(() => {
1431
- editingRef.current = editing;
1432
- }, [editing]);
1433
- useEffect(() => {
1434
- refreshViewerRef.current = refreshViewer;
1435
- }, [refreshViewer]);
1436
-
1437
- // File watcher: auto-update tree + open file via SSE
1438
- useEffect(() => {
1439
- if (!rootConfigured) return;
1440
-
1441
- const pendingReloads = new Map<string, ReturnType<typeof setTimeout>>();
1442
-
1443
- function scheduleReload(dir: string) {
1444
- if (pendingReloads.has(dir)) clearTimeout(pendingReloads.get(dir)!);
1445
- pendingReloads.set(
1446
- dir,
1447
- setTimeout(() => {
1448
- pendingReloads.delete(dir);
1449
- void reloadDir(dir);
1450
- }, 300),
1451
- );
1452
- }
1453
-
1454
- const es = new EventSource(withWs("/api/wiki/watch"));
1455
-
1456
- es.onmessage = (event) => {
1457
- let data: { type: string; path: string };
1458
- try {
1459
- data = JSON.parse(event.data as string) as {
1460
- type: string;
1461
- path: string;
1462
- };
1463
- } catch {
1464
- return;
1465
- }
1466
- const { type, path: relPath } = data;
1467
-
1468
- // Reload the affected parent dir in the tree
1469
- const parts = relPath.split("/");
1470
- const parentDir =
1471
- parts.length > 1 ? parts.slice(0, -1).join("/") : "";
1472
- scheduleReload(parentDir);
1473
-
1474
- // If the open file changed externally and we're not editing, refresh it
1475
- if (
1476
- type === "change" &&
1477
- openFileRef.current?.path === relPath &&
1478
- !editingRef.current
1479
- ) {
1480
- const key = `__file__${relPath}`;
1481
- if (pendingReloads.has(key)) clearTimeout(pendingReloads.get(key)!);
1482
- pendingReloads.set(
1483
- key,
1484
- setTimeout(() => {
1485
- pendingReloads.delete(key);
1486
- void refreshViewerRef.current();
1487
- }, 400),
1488
- );
1489
- }
1490
- };
1491
-
1492
- return () => {
1493
- es.close();
1494
- for (const t of pendingReloads.values()) clearTimeout(t);
1495
- };
1496
- }, [rootConfigured, reloadDir]);
1497
-
1498
- async function toggleFolder(node: TreeNode) {
1499
- if (node.type !== "dir" && node.type !== "app" && node.type !== "node-app") return;
1500
- if (!node.expanded) {
1501
- if (node.children === undefined) {
1502
- const prefetched = await takePrefetchedDir(node.path);
1503
- if (prefetched) {
1504
- setRoots((prev) =>
1505
- updateNodes(prev, node.path, (n) => ({
1506
- ...n,
1507
- loading: false,
1508
- children: prefetched,
1509
- expanded: true,
1510
- })),
1511
- );
1512
- return;
1513
- }
1514
- setRoots((prev) =>
1515
- updateNodes(prev, node.path, (n) => ({ ...n, loading: true })),
1516
- );
1517
- const children = await fetchDir(node.path);
1518
- setRoots((prev) =>
1519
- updateNodes(prev, node.path, (n) => ({
1520
- ...n,
1521
- loading: false,
1522
- children,
1523
- expanded: true,
1524
- })),
1525
- );
1526
- } else {
1527
- setRoots((prev) =>
1528
- updateNodes(prev, node.path, (n) => ({ ...n, expanded: true })),
1529
- );
1530
- }
1531
- } else {
1532
- setRoots((prev) =>
1533
- updateNodes(prev, node.path, (n) => ({ ...n, expanded: false })),
1534
- );
1535
- }
1536
- }
1537
-
1538
- useEffect(() => {
1539
- setAppFullscreen(false);
1540
- }, []);
1541
-
1542
- async function openViewer(node: TreeNode) {
1543
- // Push to recent files (only real files, not dirs/apps)
1544
- if (node.type === "file") {
1545
- useRecentStore.getState().push(
1546
- { path: node.path, name: node.name },
1547
- activeWorkspaceId,
1548
- );
1549
- }
1550
- setOpenFile({
1551
- path: node.path,
1552
- name: node.name,
1553
- size: node.size,
1554
- nodeType:
1555
- node.type === "app"
1556
- ? "app"
1557
- : node.type === "node-app"
1558
- ? "node-app"
1559
- : "file",
1560
- });
1561
- setEditing(false);
1562
- setSaveError(null);
1563
- setFileContent(null);
1564
- setFileRevision(0);
1565
- if (node.type === "file" && isMarkdown(node.name)) {
1566
- setFileLoading(true);
1567
- try {
1568
- await useEditorStore.getState().loadPage(node.path);
1569
- } finally {
1570
- setFileLoading(false);
1571
- }
1572
- return;
1573
- }
1574
- const kind = viewerKindFor(node.name, node.type);
1575
- if (!["editor", "text"].includes(kind) && !isText(node.name)) return;
1576
- setFileLoading(true);
1577
- try {
1578
- const res = await wsFetch(
1579
- `/api/wiki/content?path=${encodeURIComponent(node.path)}`,
1580
- );
1581
- if (res.ok) {
1582
- const d: { content: string } = await res.json();
1583
- setFileContent(d.content);
1584
- setFileRevision(Number(res.headers.get("X-Wiki-Revision") ?? 0));
1585
- }
1586
- } catch {
1587
- /* ignore */
1588
- }
1589
- setFileLoading(false);
1590
- }
1591
-
1592
- // Open a file from a search result: reveal it in the tree, then open it.
1593
- const openFromSearch = useCallback(
1594
- (relPath: string) => {
1595
- const name = relPath.split("/").pop() ?? relPath;
1596
- void revealPath(relPath);
1597
- void openViewer({ path: relPath, name, type: "file" } as TreeNode);
1598
- },
1599
- [revealPath],
1600
- );
1601
-
1602
- // biome-ignore lint/correctness/useExhaustiveDependencies: only react to editor path changes
1603
- useEffect(() => {
1604
- if (!editorCurrentPath) return;
1605
- if (openFile && openFile.path === editorCurrentPath) return;
1606
- const name = editorCurrentPath.split("/").pop() ?? editorCurrentPath;
1607
- void openViewer({
1608
- path: editorCurrentPath,
1609
- name,
1610
- type: "file",
1611
- } as TreeNode);
1612
- }, [editorCurrentPath]);
1613
-
1614
- // Persist the open file to the URL (?path=) so reloads restore it.
1615
- // replaceState avoids polluting history on every file switch.
1616
- useEffect(() => {
1617
- if (typeof window === "undefined") return;
1618
- const url = new URL(window.location.href);
1619
- if (openFile) url.searchParams.set("path", openFile.path);
1620
- else url.searchParams.delete("path");
1621
- window.history.replaceState(null, "", url.toString());
1622
- }, [openFile]);
1623
-
1624
- // Restore the open file from the URL once the root tree is loaded.
1625
- // Resolves node type from the parent dir listing (file vs app vs node-app).
1626
- // biome-ignore lint/correctness/useExhaustiveDependencies: openViewer is a hoisted stable fn; restore runs once
1627
- useEffect(() => {
1628
- if (didRestoreRef.current) return;
1629
- if (!rootLoaded) return;
1630
- const target = initialUrlPathRef.current;
1631
- if (!target) {
1632
- didRestoreRef.current = true;
1633
- return;
1634
- }
1635
- didRestoreRef.current = true;
1636
-
1637
- void (async () => {
1638
- const parts = target.split("/");
1639
- const name = parts[parts.length - 1];
1640
- const parentDir = parts.slice(0, -1).join("/");
1641
- const siblings = await fetchDir(parentDir);
1642
- const match = siblings.find((s) => s.path === target);
1643
- if (!match) return; // file no longer exists; leave home view
1644
- await revealPath(target);
1645
- void openViewer({
1646
- path: target,
1647
- name,
1648
- type: match.type,
1649
- modifiedAt: match.modifiedAt,
1650
- } as TreeNode);
1651
- })();
1652
- }, [rootLoaded, revealPath]);
1653
-
1654
- const switchWorkspace = useCallback(async (id: string) => {
1655
- if (id === activeWorkspaceId) return;
1656
- try {
1657
- await fetch(`/api/system/workspaces/${id}/open`, { method: "POST" });
1658
- } catch {
1659
- /* best-effort lastOpened bump */
1660
- }
1661
- const u = new URL(location.href);
1662
- u.searchParams.set("ws", id);
1663
- u.searchParams.delete("path");
1664
- history.pushState(null, "", u.toString());
1665
- // Reset open file + caches; the key={activeWorkspaceId} remount clears the rest.
1666
- setOpenFile(null);
1667
- setFileContent(null);
1668
- setEditing(false);
1669
- setRoots([]);
1670
- setRootLoaded(false);
1671
- rootLoadingRef.current = false;
1672
- useWikiSlugsStore.getState().invalidate();
1673
- const ws = workspaces.find((w) => w.id === id);
1674
- if (ws) setRootPath(ws.rootDir);
1675
- setActiveWorkspaceId(id);
1676
- }, [activeWorkspaceId, workspaces]);
1677
-
1678
- const handleDeleteWorkspace = useCallback(async () => {
1679
- if (!deletingWorkspaceId) return;
1680
- try {
1681
- const res = await fetch(`/api/system/workspaces/${deletingWorkspaceId}`, {
1682
- method: "DELETE",
1683
- });
1684
- if (!res.ok) throw new Error("Failed");
1685
- // If deleting the active workspace, switch to first available or picker
1686
- if (deletingWorkspaceId === activeWorkspaceId) {
1687
- const remaining = workspaces.filter((w) => w.id !== deletingWorkspaceId);
1688
- if (remaining.length > 0) {
1689
- const next = [...remaining].sort(
1690
- (a, b) => (b.lastOpenedAt ?? b.createdAt).localeCompare(a.lastOpenedAt ?? a.createdAt)
1691
- )[0];
1692
- await switchWorkspace(next.id);
1693
- } else {
1694
- setRootConfigured(false);
1695
- }
1696
- }
1697
- await loadWorkspaces();
1698
- } catch {
1699
- /* ignore */
1700
- } finally {
1701
- setDeletingWorkspaceId(null);
1702
- }
1703
- }, [deletingWorkspaceId, activeWorkspaceId, workspaces, switchWorkspace, loadWorkspaces]);
1704
-
1705
- const handleRefreshWorkspace = useCallback(async (id: string) => {
1706
- if (refreshingWsId) return;
1707
- setRefreshingWsId(id);
1708
- try {
1709
- const res = await fetch(`/api/system/workspaces/${id}/refresh`, { method: "POST" });
1710
- if (!res.ok) {
1711
- const e: { error?: string } = await res.json();
1712
- showError(e.error ?? "Refresh failed");
1713
- }
1714
- await loadWorkspaces();
1715
- } catch {
1716
- showError("Refresh failed");
1717
- } finally {
1718
- setRefreshingWsId(null);
1719
- }
1720
- }, [refreshingWsId, loadWorkspaces]);
1721
-
1722
- async function handleSave() {
1723
- if (!openFile) return;
1724
- setSaving(true);
1725
- setSaveError(null);
1726
- const res = await wsFetch("/api/wiki/content", {
1727
- method: "PUT",
1728
- headers: { "Content-Type": "application/json" },
1729
- body: JSON.stringify({
1730
- path: openFile.path,
1731
- content: editContent,
1732
- baseRevision: fileRevision,
1733
- }),
1734
- });
1735
- if (res.ok) {
1736
- const d: { revision?: number } = await res.json();
1737
- if (typeof d.revision === "number") setFileRevision(d.revision);
1738
- setFileContent(editContent);
1739
- setEditing(false);
1740
- } else {
1741
- const e: { error?: string } = await res.json();
1742
- setSaveError(e.error ?? "Save failed");
1743
- }
1744
- setSaving(false);
1745
- }
1746
-
1747
- const doUpload = useCallback(
1748
- async (files: FileList | File[], dir: string) => {
1749
- const list = Array.from(files);
1750
- if (!list.length) return;
1751
- setUploading(true);
1752
- setUploadError(null);
1753
- try {
1754
- for (const file of list) {
1755
- const fd = new FormData();
1756
- fd.append("file", file);
1757
- fd.append("dir", dir);
1758
- const res = await wsFetch("/api/wiki/upload", {
1759
- method: "POST",
1760
- body: fd,
1761
- });
1762
- if (!res.ok) {
1763
- const e: { error?: string } = await res.json();
1764
- setUploadError(e.error ?? "Upload failed");
1765
- showError(e.error ?? "Upload failed");
1766
- break;
1767
- }
1768
- }
1769
- await reloadDir(dir);
1770
- } catch {
1771
- setUploadError("Upload failed.");
1772
- } finally {
1773
- setUploading(false);
1774
- if (fileInputRef.current) fileInputRef.current.value = "";
1775
- }
1776
- },
1777
- [reloadDir],
1778
- );
1779
-
1780
- function triggerUpload(dir: string) {
1781
- uploadDirRef.current = dir;
1782
- fileInputRef.current?.click();
1783
- }
1784
-
1785
- async function handleCreateFolder() {
1786
- const name = newFolderName.trim();
1787
- if (!name || newFolderParent === null) return;
1788
- setFolderError(null);
1789
- const rel = newFolderParent ? `${newFolderParent}/${name}` : name;
1790
- const res = await wsFetch("/api/wiki/folder", {
1791
- method: "POST",
1792
- headers: { "Content-Type": "application/json" },
1793
- body: JSON.stringify({ path: rel }),
1794
- });
1795
- if (res.ok) {
1796
- setNewFolderParent(null);
1797
- setNewFolderName("");
1798
- await reloadDir(newFolderParent);
1799
- if (newFolderParent !== "") {
1800
- setRoots((prev) =>
1801
- updateNodes(prev, newFolderParent, (n) => ({ ...n, expanded: true })),
1802
- );
1803
- }
1804
- } else {
1805
- const e: { error?: string } = await res.json();
1806
- setFolderError(e.error ?? "Failed");
1807
- }
1808
- }
1809
-
1810
- async function handleCreateFile() {
1811
- const raw = newFileName.trim();
1812
- if (!raw || newFileParent === null) return;
1813
- setFileCreateError(null);
1814
- const name = raw.includes(".") ? raw : `${raw}.md`;
1815
- const rel = newFileParent ? `${newFileParent}/${name}` : name;
1816
- const res = await wsFetch("/api/wiki/new-file", {
1817
- method: "POST",
1818
- headers: { "Content-Type": "application/json" },
1819
- body: JSON.stringify({ path: rel }),
1820
- });
1821
- if (res.ok) {
1822
- const parent = newFileParent;
1823
- setNewFileParent(null);
1824
- setNewFileName("");
1825
- await reloadDir(parent);
1826
- if (parent !== "") {
1827
- setRoots((prev) =>
1828
- updateNodes(prev, parent, (n) => ({ ...n, expanded: true })),
1829
- );
1830
- }
1831
- void openViewer({
1832
- path: rel,
1833
- name,
1834
- type: "file",
1835
- modifiedAt: new Date().toISOString(),
1836
- } as TreeNode);
1837
- } else {
1838
- const e: { error?: string } = await res.json();
1839
- setFileCreateError(e.error ?? "Failed");
1840
- }
1841
- }
1842
-
1843
- function handleDownload(node: TreeNode) {
1844
- const url = withWs(`/api/wiki/download?path=${encodeURIComponent(node.path)}`);
1845
- const a = document.createElement("a");
1846
- a.href = url;
1847
- a.download = node.type === "file" ? node.name : `${node.name}.zip`;
1848
- document.body.appendChild(a);
1849
- a.click();
1850
- a.remove();
1851
- }
1852
-
1853
- function copyPath(path: string) {
1854
- void navigator.clipboard.writeText(path);
1855
- showSuccess("Path copied");
1856
- }
1857
-
1858
- function copyWikiLink(name: string) {
1859
- const slug = name.replace(/\.(md|markdown)$/i, "");
1860
- void navigator.clipboard.writeText(`[[${slug}]]`);
1861
- showSuccess("Wiki link copied");
1862
- }
1863
-
1864
- function copyUrl(path: string) {
1865
- const url = new URL(location.href);
1866
- url.searchParams.set("path", path);
1867
- if (activeWorkspaceId) url.searchParams.set("ws", activeWorkspaceId);
1868
- void navigator.clipboard.writeText(url.toString());
1869
- showSuccess("URL copied");
1870
- }
1871
-
1872
- async function getTextContent(path: string) {
1873
- if (openFile?.path === path && fileContent !== null) return fileContent;
1874
- if (useEditorStore.getState().currentPath === path) {
1875
- return useEditorStore.getState().content;
1876
- }
1877
- const res = await wsFetch(`/api/wiki/content?path=${encodeURIComponent(path)}`);
1878
- if (!res.ok) throw new Error("Cannot copy content");
1879
- const data: { content: string } = await res.json();
1880
- return data.content;
1881
- }
1882
-
1883
- async function copyRawContent(path: string) {
1884
- try {
1885
- const content = await getTextContent(path);
1886
- await navigator.clipboard.writeText(content);
1887
- showSuccess("Raw content copied");
1888
- } catch {
1889
- showError("Could not copy file content");
1890
- }
1891
- }
1892
-
1893
- async function copyFormattedContent(path: string, name: string) {
1894
- try {
1895
- const content = await getTextContent(path);
1896
- if (!isMarkdown(name)) {
1897
- await navigator.clipboard.writeText(content);
1898
- showSuccess("Content copied");
1899
- return;
1900
- }
1901
- const html = await markdownToHtml(content, { pagePath: path, sanitize: true });
1902
- if ("ClipboardItem" in window && navigator.clipboard.write) {
1903
- await navigator.clipboard.write([
1904
- new ClipboardItem({
1905
- "text/html": new Blob([html], { type: "text/html" }),
1906
- "text/plain": new Blob([content], { type: "text/plain" }),
1907
- }),
1908
- ]);
1909
- } else {
1910
- await navigator.clipboard.writeText(content);
1911
- }
1912
- showSuccess("Formatted content copied");
1913
- } catch {
1914
- showError("Could not copy formatted content");
1915
- }
1916
- }
1917
-
1918
- function renderCopyMenu(node: { path: string; name: string }) {
1919
- return (
1920
- <DropdownMenu>
1921
- <DropdownMenuTrigger asChild>
1922
- <Button
1923
- size="sm"
1924
- variant="ghost"
1925
- className="h-7 gap-1.5 px-2 text-xs"
1926
- title="Copy path, wiki link, or URL"
1927
- >
1928
- <Copy className="h-3.5 w-3.5" />
1929
- Copy
1930
- <ChevronDown className="h-3 w-3 opacity-60" />
1931
- </Button>
1932
- </DropdownMenuTrigger>
1933
- <DropdownMenuContent align="end" className="w-44">
1934
- <DropdownMenuItem onClick={() => copyPath(node.path)}>
1935
- <Copy className="mr-2 h-3.5 w-3.5" />
1936
- Copy path
1937
- </DropdownMenuItem>
1938
- {isMarkdown(node.name) && (
1939
- <DropdownMenuItem onClick={() => copyWikiLink(node.name)}>
1940
- <FileText className="mr-2 h-3.5 w-3.5" />
1941
- Copy wiki link
1942
- </DropdownMenuItem>
1943
- )}
1944
- <DropdownMenuSeparator />
1945
- <DropdownMenuItem onClick={() => copyUrl(node.path)}>
1946
- <Link className="mr-2 h-3.5 w-3.5" />
1947
- Copy URL
1948
- </DropdownMenuItem>
1949
- {isText(node.name) && (
1950
- <>
1951
- <DropdownMenuSeparator />
1952
- <DropdownMenuItem onClick={() => void copyRawContent(node.path)}>
1953
- <FileText className="mr-2 h-3.5 w-3.5" />
1954
- Copy raw content
1955
- </DropdownMenuItem>
1956
- <DropdownMenuItem onClick={() => void copyFormattedContent(node.path, node.name)}>
1957
- <FileText className="mr-2 h-3.5 w-3.5" />
1958
- Copy formatted content
1959
- </DropdownMenuItem>
1960
- </>
1961
- )}
1962
- </DropdownMenuContent>
1963
- </DropdownMenu>
1964
- );
1965
- }
1966
-
1967
- async function handleDelete() {
1968
- if (!deletingPath) return;
1969
- await wsFetch("/api/wiki", {
1970
- method: "DELETE",
1971
- headers: { "Content-Type": "application/json" },
1972
- body: JSON.stringify({ path: deletingPath }),
1973
- });
1974
- if (
1975
- openFile?.path === deletingPath ||
1976
- openFile?.path.startsWith(`${deletingPath}/`)
1977
- ) {
1978
- setOpenFile(null);
1979
- setFileContent(null);
1980
- }
1981
- setRoots((prev) => removeNode(prev, deletingPath));
1982
- setDeletingPath(null);
1983
- }
1984
-
1985
- function handleDragStart(e: React.DragEvent, node: TreeNode) {
1986
- dragNodeRef.current = node;
1987
- e.dataTransfer.effectAllowed = "move";
1988
- e.dataTransfer.setData("text/plain", node.path);
1989
- }
1990
-
1991
- function handleDragOver(
1992
- e: React.DragEvent,
1993
- targetPath: string,
1994
- targetType: "dir" | "root",
1995
- ) {
1996
- e.preventDefault();
1997
- e.stopPropagation();
1998
- const dragging = dragNodeRef.current;
1999
- if (!dragging) return;
2000
- if (
2001
- dragging.path === targetPath ||
2002
- targetPath.startsWith(`${dragging.path}/`)
2003
- )
2004
- return;
2005
- e.dataTransfer.dropEffect = "move";
2006
- setDragOverPath(targetType === "root" ? "" : targetPath);
2007
- }
2008
-
2009
- async function handleDropOnFolder(e: React.DragEvent, targetDirPath: string) {
2010
- e.preventDefault();
2011
- e.stopPropagation();
2012
- setDragOverPath(null);
2013
- const node = dragNodeRef.current;
2014
- dragNodeRef.current = null;
2015
- if (!node) return;
2016
- if (
2017
- node.path === targetDirPath ||
2018
- targetDirPath.startsWith(`${node.path}/`)
2019
- )
2020
- return;
2021
-
2022
- const newPath = targetDirPath ? `${targetDirPath}/${node.name}` : node.name;
2023
- if (newPath === node.path) return;
2024
-
2025
- const res = await wsFetch("/api/wiki/move", {
2026
- method: "POST",
2027
- headers: { "Content-Type": "application/json" },
2028
- body: JSON.stringify({ from: node.path, to: newPath }),
2029
- });
2030
- if (res.ok) {
2031
- const sourceParent = node.path.includes("/")
2032
- ? node.path.split("/").slice(0, -1).join("/")
2033
- : "";
2034
- await reloadDir(sourceParent);
2035
- if (targetDirPath !== sourceParent) await reloadDir(targetDirPath);
2036
- if (openFile?.path === node.path)
2037
- setOpenFile({
2038
- path: newPath,
2039
- name: node.name,
2040
- nodeType: openFile.nodeType,
2041
- size: openFile.size,
2042
- });
2043
- }
2044
- }
2045
-
2046
- const openFileViewerKind = openFile
2047
- ? viewerKindFor(openFile.name, openFile.nodeType)
2048
- : null;
2049
-
2050
- // Gate everything not explicitly safe. Markdown ("editor") is gated too:
2051
- // TipTap builds a node per block and freezes on big docs.
2052
- const showLargeFileGate =
2053
- !!openFile &&
2054
- openFileViewerKind !== null &&
2055
- !SAFE_VIEWER_KINDS.has(openFileViewerKind) &&
2056
- (openFile.size ?? 0) > LARGE_FILE_GATE_BYTES &&
2057
- gateBypassPath !== openFile.path;
2058
-
2059
- const viewWidth = useViewWidthStore((s) => s.width);
2060
- const viewAlign = useViewWidthStore((s) => s.align);
2061
- // Width toggle only meaningful for text-flow viewers (long prose lines).
2062
- const widthAwareViewer =
2063
- openFileViewerKind === null ||
2064
- openFileViewerKind === "editor" ||
2065
- openFileViewerKind === "text" ||
2066
- openFileViewerKind === "source" ||
2067
- openFileViewerKind === "notebook" ||
2068
- openFileViewerKind === "fallback";
2069
- const contentWidthClass = widthAwareViewer ? VIEW_WIDTH_CLASS[viewWidth] : "";
2070
- const contentAlignClass = widthAwareViewer ? VIEW_ALIGN_CLASS[viewAlign] : "";
2071
-
2072
- // Ref-backed dispatcher: handlers below are recreated each render, but the
2073
- // dispatcher methods are referentially stable, so FileTree's React.memo holds.
2074
- const treeHandlersRef = useRef<TreeCtx | null>(null);
2075
- treeHandlersRef.current = {
2076
- toggleFolder: (n) => void toggleFolder(n),
2077
- openViewer: (n) => void openViewer(n),
2078
- copyPath,
2079
- copyWikiLink,
2080
- copyUrl,
2081
- copyRawContent: (path) => void copyRawContent(path),
2082
- copyFormattedContent: (path, name) => void copyFormattedContent(path, name),
2083
- handleDownload,
2084
- triggerUpload,
2085
- handleCreateFile: () => void handleCreateFile(),
2086
- handleCreateFolder: () => void handleCreateFolder(),
2087
- handleDragStart,
2088
- handleDragOver,
2089
- handleDropOnFolder: (e, p) => void handleDropOnFolder(e, p),
2090
- handleGitPull: (p, d) => void handleGitPull(p, d),
2091
- handleCheckout: (p, b, d) => void handleCheckout(p, b, d),
2092
- loadBranches: (p) => void loadBranches(p),
2093
- prefetch: (node) => {
2094
- if (node.type === "file") { if (isMarkdown(node.name)) prefetchPage(node.path); }
2095
- else prefetchDir(node.path);
2096
- },
2097
- togglePin: (node, wsId) =>
2098
- usePinStore.getState().toggle(
2099
- { path: node.path, name: node.name, type: (node.type === "dir" ? "file" : node.type) as "file" | "app" | "node-app" },
2100
- wsId,
2101
- ),
2102
- setDragOverPath,
2103
- setSidebarCollapsed,
2104
- setBranchDropdownNode,
2105
- setBranchDropdownPos,
2106
- setNewFileParent,
2107
- setNewFileName,
2108
- setFileCreateError,
2109
- setNewFolderParent,
2110
- setNewFolderName,
2111
- setFolderError,
2112
- setDeletingPath,
2113
- setDeletingIsDir,
2114
- };
2115
- const treeCtx = useMemo<TreeCtx>(() => {
2116
- const r = treeHandlersRef;
2117
- return {
2118
- toggleFolder: (n) => r.current!.toggleFolder(n),
2119
- openViewer: (n) => r.current!.openViewer(n),
2120
- copyPath: (p) => r.current!.copyPath(p),
2121
- copyWikiLink: (n) => r.current!.copyWikiLink(n),
2122
- copyUrl: (p) => r.current!.copyUrl(p),
2123
- copyRawContent: (p) => r.current!.copyRawContent(p),
2124
- copyFormattedContent: (p, n) => r.current!.copyFormattedContent(p, n),
2125
- handleDownload: (n) => r.current!.handleDownload(n),
2126
- triggerUpload: (d) => r.current!.triggerUpload(d),
2127
- handleCreateFile: () => r.current!.handleCreateFile(),
2128
- handleCreateFolder: () => r.current!.handleCreateFolder(),
2129
- handleDragStart: (e, n) => r.current!.handleDragStart(e, n),
2130
- handleDragOver: (e, p, t) => r.current!.handleDragOver(e, p, t),
2131
- handleDropOnFolder: (e, p) => r.current!.handleDropOnFolder(e, p),
2132
- handleGitPull: (p, d) => r.current!.handleGitPull(p, d),
2133
- handleCheckout: (p, b, d) => r.current!.handleCheckout(p, b, d),
2134
- loadBranches: (p) => r.current!.loadBranches(p),
2135
- prefetch: (n) => r.current!.prefetch(n),
2136
- togglePin: (n, w) => r.current!.togglePin(n, w),
2137
- setDragOverPath: (p) => r.current!.setDragOverPath(p),
2138
- setSidebarCollapsed: (b) => r.current!.setSidebarCollapsed(b),
2139
- setBranchDropdownNode: (p) => r.current!.setBranchDropdownNode(p),
2140
- setBranchDropdownPos: (p) => r.current!.setBranchDropdownPos(p),
2141
- setNewFileParent: (p) => r.current!.setNewFileParent(p),
2142
- setNewFileName: (s) => r.current!.setNewFileName(s),
2143
- setFileCreateError: (s) => r.current!.setFileCreateError(s),
2144
- setNewFolderParent: (p) => r.current!.setNewFolderParent(p),
2145
- setNewFolderName: (s) => r.current!.setNewFolderName(s),
2146
- setFolderError: (s) => r.current!.setFolderError(s),
2147
- setDeletingPath: (p) => r.current!.setDeletingPath(p),
2148
- setDeletingIsDir: (b) => r.current!.setDeletingIsDir(b),
2149
- };
2150
- }, []);
2151
-
2152
- return (
2153
- <div key={activeWorkspaceId ?? "none"} className="flex h-screen gap-0 overflow-hidden bg-background">
2154
- {rootConfigured === null && (
2155
- <div className="flex-1 flex items-center justify-center">
2156
- <Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
2157
- </div>
2158
- )}
2159
- {rootConfigured === false && (
2160
- <DirPicker onSelect={(workspaceId) => {
2161
- const u = new URL(location.href);
2162
- u.searchParams.set("ws", workspaceId);
2163
- u.searchParams.delete("path");
2164
- history.replaceState(null, "", u.toString());
2165
- setRootLoaded(false);
2166
- rootLoadingRef.current = false;
2167
- setActiveWorkspaceId(workspaceId);
2168
- setRootConfigured(true);
2169
- void loadWorkspaces();
2170
- }} />
2171
- )}
2172
- {rootConfigured === true && addingWorkspace && (
2173
- <div className="flex-1 flex flex-col">
2174
- <div className="flex items-center justify-end border-b px-3 py-2 bg-muted shrink-0">
2175
- <Button size="sm" variant="ghost" className="h-7 gap-1.5 text-xs" onClick={() => setAddingWorkspace(false)}>
2176
- <X className="h-3.5 w-3.5" /> Cancel
2177
- </Button>
2178
- </div>
2179
- <DirPicker onSelect={(workspaceId) => {
2180
- setAddingWorkspace(false);
2181
- void loadWorkspaces();
2182
- void switchWorkspace(workspaceId);
2183
- }} />
2184
- </div>
2185
- )}
2186
- {rootConfigured === true && !addingWorkspace && <>
2187
- <SearchCommandDialog
2188
- onOpenFile={openFromSearch}
2189
- onToggleSidebar={() =>
2190
- setSidebarCollapsed((v) => {
2191
- const next = !v;
2192
- if (isMobile && next === false) useAIPanelStore.getState().close();
2193
- return next;
2194
- })
2195
- }
2196
- onNewFile={() => setNewFileParent("")}
2197
- onCopyPath={() => {
2198
- if (openFile) void navigator.clipboard.writeText(openFile.path);
2199
- }}
2200
- />
2201
- {/* Tree sidebar */}
2202
- {!sidebarCollapsed && isMobile && (
2203
- <div
2204
- className="fixed inset-0 z-40 bg-overlay backdrop-blur-[1px] md:hidden"
2205
- onClick={() => setSidebarCollapsed(true)}
2206
- aria-hidden
2207
- />
2208
- )}
2209
- {!sidebarCollapsed && (
2210
- <Card
2211
- style={isMobile ? undefined : { width: sidebarWidth }}
2212
- className="fixed inset-y-0 left-0 z-50 w-[85vw] max-w-[20rem] md:relative md:z-auto md:w-auto md:max-w-none flex flex-col shrink-0 overflow-hidden rounded-none border-r border-l-0 border-t-0 border-b-0">
2213
- {/* Row 1: brand + collapse */}
2214
- <div className="flex items-center justify-between gap-2 px-3 py-2 border-b bg-muted shrink-0">
2215
- <div className="flex min-w-0 items-center gap-1.5">
2216
- <img src="/logo.svg" alt="Wiki Viewer" className="h-5 w-5 shrink-0" />
2217
- <span className="truncate text-xs font-semibold leading-5 tracking-tight translate-y-[0.5px]">
2218
- Wiki Viewer
2219
- </span>
2220
- </div>
2221
- <Button
2222
- size="sm"
2223
- variant="ghost"
2224
- className="h-7 w-7 p-0 shrink-0"
2225
- title="Collapse sidebar"
2226
- onClick={() => setSidebarCollapsed(true)}
2227
- >
2228
- <PanelLeftClose className="h-3.5 w-3.5" />
2229
- </Button>
2230
- </div>
2231
-
2232
- {/* Row 2: actions toolbar */}
2233
- <div className="flex items-center justify-between gap-1 px-3 py-1.5 border-b bg-muted/50 shrink-0">
2234
- <DropdownMenu>
2235
- <DropdownMenuTrigger asChild>
2236
- <Button
2237
- size="sm"
2238
- variant="ghost"
2239
- className="h-7 gap-1 px-2 text-xs"
2240
- disabled={uploading}
2241
- >
2242
- {uploading ? (
2243
- <Loader2 className="h-3.5 w-3.5 animate-spin" />
2244
- ) : (
2245
- <Plus className="h-3.5 w-3.5" />
2246
- )}
2247
- New
2248
- <ChevronDown className="h-3 w-3 opacity-60" />
2249
- </Button>
2250
- </DropdownMenuTrigger>
2251
- <DropdownMenuContent align="start" className="w-44">
2252
- <DropdownMenuItem
2253
- onClick={() => {
2254
- setNewFileParent("");
2255
- setNewFileName("");
2256
- setFileCreateError(null);
2257
- }}
2258
- >
2259
- <FilePlus className="mr-2 h-3.5 w-3.5" />
2260
- New file
2261
- </DropdownMenuItem>
2262
- <DropdownMenuItem
2263
- onClick={() => {
2264
- setNewFolderParent("");
2265
- setNewFolderName("");
2266
- setFolderError(null);
2267
- }}
2268
- >
2269
- <FolderPlus className="mr-2 h-3.5 w-3.5" />
2270
- New folder
2271
- </DropdownMenuItem>
2272
- <DropdownMenuItem
2273
- onClick={() => triggerUpload("")}
2274
- disabled={uploading}
2275
- >
2276
- <Upload className="mr-2 h-3.5 w-3.5" />
2277
- Upload
2278
- </DropdownMenuItem>
2279
- </DropdownMenuContent>
2280
- </DropdownMenu>
2281
-
2282
- <div className="flex items-center gap-0.5">
2283
- <Button
2284
- size="sm"
2285
- variant="ghost"
2286
- className="h-7 w-7 p-0"
2287
- title="Refresh tree"
2288
- onClick={refreshTree}
2289
- disabled={refreshingTree}
2290
- >
2291
- {refreshingTree ? (
2292
- <Loader2 className="h-3.5 w-3.5 animate-spin" />
2293
- ) : (
2294
- <RefreshCw className="h-3.5 w-3.5" />
2295
- )}
2296
- </Button>
2297
- <Button
2298
- size="sm"
2299
- variant="ghost"
2300
- className="h-7 w-7 p-0"
2301
- title={showHidden ? "Hide hidden files" : "Show hidden files"}
2302
- onClick={() => useShowHiddenStore.getState().toggle()}
2303
- >
2304
- {showHidden ? (
2305
- <Eye className="h-3.5 w-3.5" />
2306
- ) : (
2307
- <EyeOff className="h-3.5 w-3.5" />
2308
- )}
2309
- </Button>
2310
- <ThemeToggle />
2311
- <Button
2312
- size="sm"
2313
- variant="ghost"
2314
- className="h-7 w-7 p-0"
2315
- title="AI Agent panel"
2316
- onClick={() => {
2317
- useAIPanelStore.getState().toggle();
2318
- if (isMobile) setSidebarCollapsed(true);
2319
- }}
2320
- >
2321
- <Bot className="h-3.5 w-3.5" />
2322
- </Button>
2323
- <Button
2324
- size="sm"
2325
- variant="ghost"
2326
- className="h-7 w-7 p-0"
2327
- title="Settings"
2328
- onClick={() => setSettingsOpen(true)}
2329
- >
2330
- <Settings className="h-3.5 w-3.5" />
2331
- </Button>
2332
- </div>
2333
- </div>
2334
-
2335
- {uploadError && (
2336
- <div className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-destructive bg-destructive/10 shrink-0">
2337
- <AlertCircle className="h-3.5 w-3.5 shrink-0" />
2338
- {uploadError}
2339
- </div>
2340
- )}
2341
-
2342
- {newFolderParent === "" && (
2343
- <div className="flex items-center gap-1.5 px-2 py-1 border-b shrink-0">
2344
- <Folder className="h-4 w-4 shrink-0 text-warning" />
2345
- <input
2346
- className="flex-1 bg-transparent text-sm outline-none border-b border-border min-w-0"
2347
- placeholder="Folder name"
2348
- value={newFolderName}
2349
- onChange={(e) => setNewFolderName(e.target.value)}
2350
- onKeyDown={(e) => {
2351
- if (e.key === "Enter") handleCreateFolder();
2352
- if (e.key === "Escape") {
2353
- setNewFolderParent(null);
2354
- setNewFolderName("");
2355
- }
2356
- }}
2357
- />
2358
- {folderError && (
2359
- <span className="text-xs text-destructive">{folderError}</span>
2360
- )}
2361
- <Button
2362
- size="sm"
2363
- variant="ghost"
2364
- className="h-6 w-6 p-0"
2365
- onClick={handleCreateFolder}
2366
- >
2367
- <Check className="h-3 w-3" />
2368
- </Button>
2369
- <Button
2370
- size="sm"
2371
- variant="ghost"
2372
- className="h-6 w-6 p-0"
2373
- onClick={() => {
2374
- setNewFolderParent(null);
2375
- setNewFolderName("");
2376
- }}
2377
- >
2378
- <X className="h-3 w-3" />
2379
- </Button>
2380
- </div>
2381
- )}
2382
-
2383
- {newFileParent === "" && (
2384
- <div className="flex items-center gap-1.5 px-2 py-1 border-b shrink-0">
2385
- <FileText className="h-4 w-4 shrink-0 text-accent" />
2386
- <input
2387
- autoFocus
2388
- className="flex-1 bg-transparent text-sm outline-none border-b border-border min-w-0"
2389
- placeholder="filename (default .md)"
2390
- value={newFileName}
2391
- onChange={(e) => setNewFileName(e.target.value)}
2392
- onKeyDown={(e) => {
2393
- if (e.key === "Enter") handleCreateFile();
2394
- if (e.key === "Escape") {
2395
- setNewFileParent(null);
2396
- setNewFileName("");
2397
- }
2398
- }}
2399
- />
2400
- {fileCreateError && (
2401
- <span className="text-xs text-destructive">{fileCreateError}</span>
2402
- )}
2403
- <Button
2404
- size="sm"
2405
- variant="ghost"
2406
- className="h-6 w-6 p-0"
2407
- onClick={handleCreateFile}
2408
- >
2409
- <Check className="h-3 w-3" />
2410
- </Button>
2411
- <Button
2412
- size="sm"
2413
- variant="ghost"
2414
- className="h-6 w-6 p-0"
2415
- onClick={() => {
2416
- setNewFileParent(null);
2417
- setNewFileName("");
2418
- }}
2419
- >
2420
- <X className="h-3 w-3" />
2421
- </Button>
2422
- </div>
2423
- )}
2424
-
2425
- <div className="border-b">
2426
- <SidebarSearchBox onOpenFile={openFromSearch} />
2427
- </div>
2428
-
2429
- <div
2430
- ref={sidebarScrollRef}
2431
- className={cn(
2432
- "flex-1 overflow-auto py-1 editorial-file-tree",
2433
- dragOverPath === "" &&
2434
- "ring-2 ring-inset ring-primary bg-primary/5",
2435
- )}
2436
- onDragOver={(e) => handleDragOver(e, "", "root")}
2437
- onDragLeave={(e) => {
2438
- if (!e.currentTarget.contains(e.relatedTarget as Node))
2439
- setDragOverPath(null);
2440
- }}
2441
- onDrop={(e) => handleDropOnFolder(e, "")}
2442
- >
2443
- {/* Pinned section */}
2444
- {pins.length > 0 && (
2445
- <div className="border-b mb-1">
2446
- <button
2447
- type="button"
2448
- className="flex w-full items-center gap-1 px-2 py-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground hover:text-foreground"
2449
- onClick={() => setPinnedCollapsed((c) => !c)}
2450
- >
2451
- {pinnedCollapsed ? <ChevronRight className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
2452
- <Pin className="h-3 w-3" />
2453
- Pinned
2454
- <span className="ml-auto text-[9px] tabular-nums opacity-60">{pins.length}</span>
2455
- </button>
2456
- {!pinnedCollapsed && pins.map((p) => (
2457
- <div
2458
- key={p.path}
2459
- role="button"
2460
- tabIndex={0}
2461
- className={cn(
2462
- "group flex items-center gap-1.5 rounded-sm px-2 py-1 text-sm cursor-pointer transition-colors select-none",
2463
- openFile?.path === p.path ? "bg-accent-soft text-foreground font-medium" : "hover:bg-muted",
2464
- )}
2465
- onClick={() => { void openViewer({ path: p.path, name: p.name, type: (p.type ?? "file") as TreeNode["type"], modifiedAt: "" } as TreeNode); if (isMobile) setSidebarCollapsed(true); }}
2466
- onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); void openViewer({ path: p.path, name: p.name, type: (p.type ?? "file") as TreeNode["type"], modifiedAt: "" } as TreeNode); } }}
2467
- >
2468
- <Star className="h-3 w-3 shrink-0 fill-current text-amber-400" />
2469
- <span className="min-w-0 flex-1 truncate text-xs">{p.name}</span>
2470
- <span className="max-w-[80px] truncate text-[10px] text-muted-foreground/60">{p.path.split("/").slice(0, -1).join("/")}</span>
2471
- <button
2472
- type="button"
2473
- className="hover-reveal shrink-0 rounded p-0.5 text-muted-foreground/50 opacity-0 transition-colors hover:bg-muted hover:text-amber-400 group-hover:opacity-100 focus:opacity-100"
2474
- title="Remove from pinned"
2475
- onClick={(e) => {
2476
- e.stopPropagation();
2477
- usePinStore
2478
- .getState()
2479
- .toggle({ path: p.path, name: p.name }, activeWorkspaceId);
2480
- }}
2481
- >
2482
- <X className="h-3 w-3" />
2483
- </button>
2484
- </div>
2485
- ))}
2486
- </div>
2487
- )}
2488
- {/* Recent files section */}
2489
- {recents.length > 0 && (
2490
- <div className="border-b mb-1">
2491
- <button
2492
- type="button"
2493
- className="flex w-full items-center gap-1 px-2 py-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground hover:text-foreground"
2494
- onClick={() => setRecentCollapsed((c) => !c)}
2495
- >
2496
- {recentCollapsed ? <ChevronRight className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
2497
- Recent
2498
- <span className="ml-auto text-[9px] tabular-nums opacity-60">{recents.length}</span>
2499
- </button>
2500
- {!recentCollapsed && recents.slice(0, 8).map((r) => (
2501
- <div
2502
- key={r.path}
2503
- role="button"
2504
- tabIndex={0}
2505
- className={cn(
2506
- "flex items-center gap-1.5 rounded-sm px-2 py-1 text-sm cursor-pointer transition-colors select-none",
2507
- openFile?.path === r.path ? "bg-accent-soft text-foreground font-medium" : "hover:bg-muted",
2508
- )}
2509
- onClick={() => { void openViewer({ path: r.path, name: r.name, type: (r.type ?? "file") as TreeNode["type"], modifiedAt: "" } as TreeNode); if (isMobile) setSidebarCollapsed(true); }}
2510
- onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); void openViewer({ path: r.path, name: r.name, type: (r.type ?? "file") as TreeNode["type"], modifiedAt: "" } as TreeNode); } }}
2511
- >
2512
- <FileText className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
2513
- <span className="flex-1 truncate text-xs">{r.name}</span>
2514
- <span className="text-[10px] text-muted-foreground/60 truncate max-w-[80px]">{r.path.split("/").slice(0, -1).join("/")}</span>
2515
- </div>
2516
- ))}
2517
- </div>
2518
- )}
2519
- {rootLoading ? (
2520
- <div className="flex justify-center py-6">
2521
- <Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
2522
- </div>
2523
- ) : roots.length === 0 ? (
2524
- <div className="flex flex-col items-center gap-3 px-4 py-8 text-center">
2525
- <div className="rounded-full bg-muted p-3">
2526
- <FileText className="h-6 w-6 text-muted-foreground" />
2527
- </div>
2528
- <div className="space-y-1">
2529
- <p className="text-sm font-medium">No files yet</p>
2530
- <p className="text-xs text-muted-foreground">
2531
- Upload files or add them to the configured directory
2532
- </p>
2533
- </div>
2534
- <Button
2535
- size="sm"
2536
- variant="outline"
2537
- className="w-full gap-1.5 max-w-[180px]"
2538
- onClick={() => triggerUpload("")}
2539
- disabled={uploading}
2540
- >
2541
- {uploading ? (
2542
- <Loader2 className="h-3.5 w-3.5 animate-spin" />
2543
- ) : (
2544
- <Upload className="h-3.5 w-3.5" />
2545
- )}
2546
- Upload Files
2547
- </Button>
2548
- </div>
2549
- ) : (
2550
- <FileTree
2551
- ctx={treeCtx}
2552
- nodes={roots}
2553
- openPath={openFile?.path ?? null}
2554
- dragOverPath={dragOverPath}
2555
- branchDropdownNode={branchDropdownNode}
2556
- branchDropdownPos={branchDropdownPos}
2557
- nodeBranches={nodeBranches}
2558
- branchesLoading={branchesLoading}
2559
- checkingOutBranch={checkingOutBranch}
2560
- pullingRepo={pullingRepo}
2561
- activePaths={activePaths}
2562
- pins={pins}
2563
- isMobile={isMobile}
2564
- activeWorkspaceId={activeWorkspaceId}
2565
- newFileParent={newFileParent}
2566
- newFileName={newFileName}
2567
- fileCreateError={fileCreateError}
2568
- newFolderParent={newFolderParent}
2569
- newFolderName={newFolderName}
2570
- folderError={folderError}
2571
- sidebarScrollRef={sidebarScrollRef}
2572
- />
2573
- )}
2574
- </div>
2575
- <div className="border-t px-2 py-2 bg-muted shrink-0">
2576
- <DropdownMenu>
2577
- <DropdownMenuTrigger asChild>
2578
- <Button
2579
- size="sm"
2580
- variant="ghost"
2581
- className="w-full h-auto justify-between gap-2 px-2 py-1.5 text-left"
2582
- title={rootPath ?? ""}
2583
- >
2584
- <span className="flex flex-col min-w-0">
2585
- <span className="truncate text-xs font-medium">
2586
- {workspaces.find((w) => w.id === activeWorkspaceId)?.name ?? "Workspace"}
2587
- </span>
2588
- <span className="truncate text-[10px] text-muted-foreground font-mono">
2589
- {rootPath ?? ""}
2590
- </span>
2591
- </span>
2592
- <ChevronDown className="h-3.5 w-3.5 shrink-0 opacity-60" />
2593
- </Button>
2594
- </DropdownMenuTrigger>
2595
- <DropdownMenuContent align="start" className="w-60">
2596
- {workspaces.map((w) => (
2597
- <DropdownMenuItem
2598
- key={w.id}
2599
- onClick={() => void switchWorkspace(w.id)}
2600
- className={cn("gap-2", w.id === activeWorkspaceId && "font-medium")}
2601
- >
2602
- {w.id === activeWorkspaceId ? (
2603
- <Check className="h-3.5 w-3.5 shrink-0" />
2604
- ) : (
2605
- <span className="w-3.5 shrink-0" />
2606
- )}
2607
- <span className="flex flex-col min-w-0 flex-1">
2608
- <span className="flex items-center gap-1.5 truncate">
2609
- <span className="truncate">{w.name}</span>
2610
- {w.readOnly && (
2611
- <span className="inline-flex items-center gap-0.5 rounded bg-muted px-1.5 text-[10px] text-muted-foreground font-normal shrink-0">
2612
- <GitBranch className="h-2.5 w-2.5" /> read-only
2613
- </span>
2614
- )}
2615
- </span>
2616
- <span className="truncate text-[10px] text-muted-foreground font-mono">{w.rootDir}</span>
2617
- {w.git?.lastPulledAt && timeAgo(w.git.lastPulledAt) && (
2618
- <span className="text-[10px] text-muted-foreground/70">synced {timeAgo(w.git.lastPulledAt)}</span>
2619
- )}
2620
- </span>
2621
- {isWsAdmin && w.git && (
2622
- <button
2623
- className={cn(
2624
- "shrink-0 rounded p-0.5 hover:bg-accent transition-colors",
2625
- w.git.lastError ? "text-destructive" : "text-muted-foreground hover:text-foreground",
2626
- )}
2627
- title={w.git.lastError ? `Last refresh failed: ${w.git.lastError}` : "Refresh"}
2628
- disabled={refreshingWsId === w.id}
2629
- onClick={(e) => {
2630
- e.stopPropagation();
2631
- e.preventDefault();
2632
- void handleRefreshWorkspace(w.id);
2633
- }}
2634
- >
2635
- <RefreshCw className={cn("h-3.5 w-3.5", refreshingWsId === w.id && "animate-spin")} />
2636
- </button>
2637
- )}
2638
- {isWsAdmin && (
2639
- <button
2640
- className="shrink-0 rounded p-0.5 text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors"
2641
- title="Delete workspace (does not delete files)"
2642
- onClick={(e) => {
2643
- e.stopPropagation();
2644
- setDeletingWorkspaceId(w.id);
2645
- }}
2646
- >
2647
- <Trash2 className="h-3.5 w-3.5" />
2648
- </button>
2649
- )}
2650
- </DropdownMenuItem>
2651
- ))}
2652
- {isWsAdmin && (
2653
- <>
2654
- <DropdownMenuSeparator />
2655
- <DropdownMenuItem onClick={() => setAddingWorkspace(true)}>
2656
- <FolderPlus className="mr-2 h-3.5 w-3.5" />
2657
- Add workspace…
2658
- </DropdownMenuItem>
2659
- </>
2660
- )}
2661
- </DropdownMenuContent>
2662
- </DropdownMenu>
2663
- </div>
2664
- {/* Resize handle */}
2665
- <div
2666
- role="separator"
2667
- aria-orientation="vertical"
2668
- aria-label="Resize sidebar"
2669
- title="Drag to resize"
2670
- onMouseDown={(e) => {
2671
- e.preventDefault();
2672
- const startX = e.clientX;
2673
- const startW = sidebarWidth;
2674
- setSidebarResizing(true);
2675
- const prevCursor = document.body.style.cursor;
2676
- const prevSelect = document.body.style.userSelect;
2677
- document.body.style.cursor = "col-resize";
2678
- document.body.style.userSelect = "none";
2679
- const onMove = (ev: MouseEvent) => {
2680
- setSidebarWidth(startW + (ev.clientX - startX));
2681
- };
2682
- const onUp = () => {
2683
- setSidebarResizing(false);
2684
- document.body.style.cursor = prevCursor;
2685
- document.body.style.userSelect = prevSelect;
2686
- window.removeEventListener("mousemove", onMove);
2687
- window.removeEventListener("mouseup", onUp);
2688
- };
2689
- window.addEventListener("mousemove", onMove);
2690
- window.addEventListener("mouseup", onUp);
2691
- }}
2692
- onDoubleClick={() => setSidebarWidth(288)}
2693
- onKeyDown={(e) => {
2694
- if (e.key === "ArrowLeft") {
2695
- e.preventDefault();
2696
- setSidebarWidth(sidebarWidth - 16);
2697
- } else if (e.key === "ArrowRight") {
2698
- e.preventDefault();
2699
- setSidebarWidth(sidebarWidth + 16);
2700
- }
2701
- }}
2702
- tabIndex={0}
2703
- aria-valuemin={SIDEBAR_MIN_WIDTH}
2704
- aria-valuemax={SIDEBAR_MAX_WIDTH}
2705
- aria-valuenow={sidebarWidth}
2706
- className={cn(
2707
- "absolute right-0 top-0 z-20 h-full w-1.5 cursor-col-resize -mr-px transition-colors hover:bg-primary/40 focus:bg-primary/40 focus:outline-none hidden md:block",
2708
- sidebarResizing && "bg-primary/60",
2709
- )}
2710
- />
2711
- </Card>
2712
- )}
2713
-
2714
- {/* Right panel */}
2715
- <div className="flex-1 flex flex-col min-w-0 relative">
2716
- {/* Desktop: floating reopen button when sidebar is collapsed */}
2717
- {sidebarCollapsed && (
2718
- <Button
2719
- size="sm"
2720
- variant="ghost"
2721
- className="hidden md:flex absolute left-2 top-2 z-10 h-7 w-7 p-0"
2722
- title="Show sidebar"
2723
- onClick={() => setSidebarCollapsed(false)}
2724
- >
2725
- <PanelLeftOpen className="h-3.5 w-3.5" />
2726
- </Button>
2727
- )}
2728
- {/* Mobile: dedicated top bar hosting the drawer + AI panel toggles */}
2729
- <div className="md:hidden flex h-11 shrink-0 items-center justify-between gap-2 border-b bg-muted px-1">
2730
- <Button
2731
- size="sm"
2732
- variant="ghost"
2733
- className="h-9 w-9 p-0"
2734
- title="Show sidebar"
2735
- onClick={() => {
2736
- setSidebarCollapsed(false);
2737
- useAIPanelStore.getState().close();
2738
- }}
2739
- >
2740
- <PanelLeftOpen className="h-4 w-4" />
2741
- </Button>
2742
- <span
2743
- className="flex min-w-0 items-center gap-1.5 text-xs font-semibold tracking-tight text-muted-foreground"
2744
- title={openFile?.path}
2745
- >
2746
- {openFile && (
2747
- <span className="editorial-tree-typeicon shrink-0">
2748
- {openFileViewerKind === "app" || openFileViewerKind === "html" ? (
2749
- <Globe className="h-4 w-4 text-foreground/70" />
2750
- ) : isImage(openFile.name) ? (
2751
- <ImageIcon className="h-4 w-4 text-sunshine-700" />
2752
- ) : isText(openFile.name) ? (
2753
- <FileText className="h-4 w-4 text-foreground/70" />
2754
- ) : (
2755
- <File className="h-4 w-4 text-foreground/60" />
2756
- )}
2757
- </span>
2758
- )}
2759
- <span className="truncate">
2760
- {openFile ? openFile.name : "Wiki Viewer"}
2761
- </span>
2762
- </span>
2763
- <Button
2764
- size="sm"
2765
- variant="ghost"
2766
- className="h-9 w-9 p-0"
2767
- title="AI Agent panel"
2768
- onClick={() => {
2769
- useAIPanelStore.getState().open();
2770
- setSidebarCollapsed(true);
2771
- }}
2772
- >
2773
- <Bot className="h-4 w-4" />
2774
- </Button>
2775
- </div>
2776
- {openFile ? (
2777
- openFileViewerKind === "node-app" ? (
2778
- <NodeAppViewer path={openFile.path} title={openFile.name} />
2779
- ) : (openFileViewerKind === "app" || openFileViewerKind === "html") ? (
2780
- // html files → direct asset URL; app folders → index.html (default)
2781
- (() => {
2782
- const websiteSrc =
2783
- openFileViewerKind === "html"
2784
- ? `/api/assets/${openFile.path}`
2785
- : undefined;
2786
- return appFullscreen ? (
2787
- <WebsiteViewer
2788
- path={openFile.path}
2789
- title={openFile.name}
2790
- src={websiteSrc}
2791
- fullscreen
2792
- onExit={() => setAppFullscreen(false)}
2793
- />
2794
- ) : (
2795
- <div className="flex-1 flex flex-col overflow-hidden min-w-0">
2796
- <div className={cn("flex items-center justify-between px-4 py-2 border-b bg-muted shrink-0 editorial-doc-header", !isMobile && sidebarCollapsed && "pl-11")}>
2797
- <div className="flex items-center gap-2 min-w-0">
2798
- <span className="hidden md:inline-flex">
2799
- <span className="editorial-tree-typeicon">
2800
- <Globe className="h-4 w-4 shrink-0 text-foreground/70" />
2801
- </span>
2802
- </span>
2803
- <span
2804
- className="hidden md:inline text-sm font-normal truncate"
2805
- title={openFile.path}
2806
- >
2807
- {openFile.path}
2808
- </span>
2809
- </div>
2810
- <div className="flex items-center gap-1 shrink-0">
2811
- {renderCopyMenu(openFile)}
2812
- {openFileViewerKind === "html" &&
2813
- !editing &&
2814
- fileContent !== null && (
2815
- <Button
2816
- size="sm"
2817
- variant="ghost"
2818
- className="h-7 w-7 p-0"
2819
- title="Edit source"
2820
- onClick={() => {
2821
- setEditing(true);
2822
- setEditContent(fileContent);
2823
- setSaveError(null);
2824
- }}
2825
- >
2826
- <Pencil className="h-3.5 w-3.5" />
2827
- </Button>
2828
- )}
2829
- <Button
2830
- size="sm"
2831
- variant="ghost"
2832
- className="h-7 w-7 p-0"
2833
- title="Refresh"
2834
- onClick={() => setAppKey((k) => k + 1)}
2835
- >
2836
- <RefreshCw className="h-3.5 w-3.5" />
2837
- </Button>
2838
- <Button
2839
- size="sm"
2840
- variant="ghost"
2841
- className="h-7 gap-1.5 text-xs"
2842
- onClick={() => setAppFullscreen(true)}
2843
- >
2844
- <Maximize2 className="h-3.5 w-3.5" />
2845
- Open fullscreen
2846
- </Button>
2847
- <Button
2848
- size="sm"
2849
- variant="ghost"
2850
- className="h-7 w-7 p-0"
2851
- onClick={() => {
2852
- setOpenFile(null);
2853
- setEditing(false);
2854
- }}
2855
- >
2856
- <X className="h-3.5 w-3.5" />
2857
- </Button>
2858
- </div>
2859
- </div>
2860
- {editing && openFileViewerKind === "html" ? (
2861
- <div className="flex-1 flex flex-col overflow-hidden min-h-0">
2862
- <textarea
2863
- value={editContent}
2864
- onChange={(e) => setEditContent(e.target.value)}
2865
- spellCheck={false}
2866
- className="flex-1 w-full min-h-0 resize-none bg-background text-foreground px-4 py-3 font-mono text-[13px] leading-relaxed outline-none border-0"
2867
- />
2868
- <div className="border-t px-4 py-2 flex items-center justify-end gap-2 bg-muted shrink-0">
2869
- {saveError && (
2870
- <span className="text-xs text-destructive mr-auto">
2871
- {saveError}
2872
- </span>
2873
- )}
2874
- <Button
2875
- size="sm"
2876
- variant="ghost"
2877
- onClick={() => {
2878
- setEditing(false);
2879
- setSaveError(null);
2880
- }}
2881
- >
2882
- Cancel
2883
- </Button>
2884
- <Button
2885
- size="sm"
2886
- className="gap-1"
2887
- onClick={handleSave}
2888
- disabled={saving}
2889
- >
2890
- {saving && (
2891
- <Loader2 className="h-3 w-3 animate-spin" />
2892
- )}
2893
- Save
2894
- </Button>
2895
- </div>
2896
- </div>
2897
- ) : (
2898
- <WebsiteViewer
2899
- key={appKey}
2900
- path={openFile.path}
2901
- title={openFile.name}
2902
- src={websiteSrc}
2903
- />
2904
- )}
2905
- </div>
2906
- );
2907
- })()
2908
- ) : (
2909
- <div className="flex-1 flex flex-col overflow-hidden min-w-0">
2910
- <div className={cn("flex items-center justify-between px-4 py-2 border-b bg-muted shrink-0 editorial-doc-header", !isMobile && sidebarCollapsed && "pl-11")}>
2911
- <div className="flex items-center gap-2 min-w-0">
2912
- <span className="hidden md:inline-flex"><span className="editorial-tree-typeicon">
2913
- {isImage(openFile.name) ? (
2914
- <ImageIcon className="h-4 w-4 shrink-0 text-sunshine-700" />
2915
- ) : isText(openFile.name) ? (
2916
- <FileText className="h-4 w-4 shrink-0 text-foreground/70" />
2917
- ) : (
2918
- <File className="h-4 w-4 shrink-0 text-foreground/60" />
2919
- )}
2920
- </span></span>
2921
- <span
2922
- className="hidden md:inline text-sm font-normal truncate"
2923
- title={openFile.path}
2924
- >
2925
- {openFile.path}
2926
- </span>
2927
- {gitFileInfo && (
2928
- <span className="hidden md:flex items-center gap-1 text-[11px] text-muted-foreground shrink-0 ml-1">
2929
- <User className="h-3 w-3 shrink-0" />
2930
- <span className="truncate max-w-[100px]">{gitFileInfo.author}</span>
2931
- <span title={new Date(gitFileInfo.date).toLocaleString()} className="shrink-0">{timeAgo(gitFileInfo.date)}</span>
2932
- </span>
2933
- )}
2934
- </div>
2935
- <div className="flex items-center gap-1 shrink-0">
2936
- {renderCopyMenu(openFile)}
2937
- <Button
2938
- size="sm"
2939
- variant="ghost"
2940
- className="h-7 w-7 p-0"
2941
- title="File history"
2942
- onClick={() => { if (showHistory) setShowHistory(false); else void loadHistory(); }}
2943
- >
2944
- <History className="h-3.5 w-3.5" />
2945
- </Button>
2946
- <Button
2947
- size="sm"
2948
- variant="ghost"
2949
- className="h-7 w-7 p-0"
2950
- title="Share"
2951
- onClick={() => setShareDialogOpen(true)}
2952
- >
2953
- <Share className="h-3.5 w-3.5" />
2954
- </Button>
2955
- {isText(openFile.name) &&
2956
- !editing &&
2957
- (fileContent !== null || isMarkdown(openFile.name)) && (
2958
- <Button
2959
- size="sm"
2960
- variant="ghost"
2961
- className="h-7 w-7 p-0"
2962
- onClick={() => {
2963
- setEditing(true);
2964
- setEditContent(fileContent ?? "");
2965
- setSaveError(null);
2966
- if (isMarkdown(openFile.name)) {
2967
- void useEditorStore
2968
- .getState()
2969
- .loadPage(openFile.path);
2970
- }
2971
- }}
2972
- >
2973
- <Pencil className="h-3.5 w-3.5" />
2974
- </Button>
2975
- )}
2976
- {isText(openFile.name) &&
2977
- editing &&
2978
- isMarkdown(openFile.name) && (
2979
- <Button
2980
- size="sm"
2981
- variant="ghost"
2982
- className="h-7 w-7 p-0"
2983
- title="Done editing"
2984
- onClick={() => {
2985
- setEditing(false);
2986
- setSaveError(null);
2987
- }}
2988
- >
2989
- <Eye className="h-3.5 w-3.5" />
2990
- </Button>
2991
- )}
2992
- {isText(openFile.name) &&
2993
- !editing &&
2994
- (fileContent !== null || isMarkdown(openFile.name)) && (
2995
- <Button
2996
- size="sm"
2997
- variant="ghost"
2998
- className="h-7 w-7 p-0"
2999
- title="Refresh"
3000
- onClick={refreshViewer}
3001
- disabled={fileLoading}
3002
- >
3003
- {fileLoading ? (
3004
- <Loader2 className="h-3.5 w-3.5 animate-spin" />
3005
- ) : (
3006
- <RefreshCw className="h-3.5 w-3.5" />
3007
- )}
3008
- </Button>
3009
- )}
3010
- {widthAwareViewer && <ViewWidthToggle />}
3011
- <Button
3012
- size="sm"
3013
- variant="ghost"
3014
- className="h-7 w-7 p-0"
3015
- onClick={() => {
3016
- setOpenFile(null);
3017
- setFileContent(null);
3018
- setEditing(false);
3019
- }}
3020
- >
3021
- <X className="h-3.5 w-3.5" />
3022
- </Button>
3023
- </div>
3024
- </div>
3025
-
3026
- {/* History panel */}
3027
- {showHistory && (
3028
- <div className="border-b bg-muted/30 shrink-0 max-h-[40vh] overflow-auto">
3029
- <div className="flex items-center justify-between px-4 py-1.5 border-b">
3030
- <span className="text-xs font-semibold text-muted-foreground">History</span>
3031
- <button type="button" className="text-muted-foreground hover:text-foreground" onClick={() => setShowHistory(false)}>
3032
- <X className="h-3.5 w-3.5" />
3033
- </button>
3034
- </div>
3035
- {historyLoading ? (
3036
- <div className="flex justify-center py-4">
3037
- <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
3038
- </div>
3039
- ) : historyCommits.length === 0 ? (
3040
- <p className="px-4 py-3 text-xs text-muted-foreground">No history found.</p>
3041
- ) : (
3042
- <div>
3043
- {historyCommits.map((c) => (
3044
- <div key={c.sha}>
3045
- <button
3046
- type="button"
3047
- className={cn(
3048
- "w-full text-left px-4 py-2 hover:bg-muted transition-colors",
3049
- selectedDiffSha === c.sha && "bg-muted",
3050
- )}
3051
- onClick={() => void selectDiff(c.sha)}
3052
- >
3053
- <div className="flex items-center gap-2">
3054
- <code className="text-[11px] font-mono text-muted-foreground shrink-0">{c.shortSha}</code>
3055
- <span className="flex-1 truncate text-xs">{c.message}</span>
3056
- <span className="shrink-0 text-[11px] text-muted-foreground">{c.author}</span>
3057
- <span className="shrink-0 text-[11px] text-muted-foreground" title={new Date(c.date).toLocaleString()}>{timeAgo(c.date)}</span>
3058
- </div>
3059
- </button>
3060
- {selectedDiffSha === c.sha && (
3061
- <div className="border-t">
3062
- {diffLoading ? (
3063
- <div className="flex justify-center py-3">
3064
- <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
3065
- </div>
3066
- ) : diffContent !== null ? (
3067
- <pre className="overflow-auto px-4 py-2 text-[11px] font-mono leading-relaxed whitespace-pre text-foreground/80 max-h-60">{diffContent}</pre>
3068
- ) : null}
3069
- </div>
3070
- )}
3071
- </div>
3072
- ))}
3073
- </div>
3074
- )}
3075
- </div>
3076
- )}
3077
-
3078
- {showLargeFileGate ? (
3079
- <LargeFileGate
3080
- path={openFile.path}
3081
- size={openFile.size ?? 0}
3082
- onOpen={() => setGateBypassPath(openFile.path)}
3083
- />
3084
- ) : editing ? (
3085
- <div className="flex-1 flex flex-col overflow-hidden min-h-0">
3086
- {isMarkdown(openFile.name) ? (
3087
- <KBEditor />
3088
- ) : (
3089
- <textarea
3090
- value={editContent}
3091
- onChange={(e) => setEditContent(e.target.value)}
3092
- spellCheck={false}
3093
- className="flex-1 w-full min-h-0 resize-none bg-background text-foreground px-4 py-3 font-mono text-[13px] leading-relaxed outline-none border-0"
3094
- />
3095
- )}
3096
- </div>
3097
- ) : isMarkdown(openFile.name) ? (
3098
- <div className="flex-1 flex flex-col overflow-hidden min-h-0">
3099
- {fileLoading ? (
3100
- <div className="flex justify-center py-8">
3101
- <Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
3102
- </div>
3103
- ) : (
3104
- <KBEditor mode="viewing" />
3105
- )}
3106
- </div>
3107
- ) : (
3108
- <div className="flex-1 overflow-auto p-4 min-h-0">
3109
- <div className={cn("w-full", contentAlignClass, contentWidthClass)}>
3110
- {fileLoading ? (
3111
- <div className="flex justify-center py-8">
3112
- <Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
3113
- </div>
3114
- ) : openFileViewerKind === "csv" ? (
3115
- <CsvViewer path={openFile.path} title={openFile.name} />
3116
- ) : openFileViewerKind === "pdf" ? (
3117
- <PdfViewer path={openFile.path} title={openFile.name} />
3118
- ) : openFileViewerKind === "mermaid" ? (
3119
- <MermaidViewer
3120
- path={openFile.path}
3121
- title={openFile.name}
3122
- />
3123
- ) : openFileViewerKind === "notebook" ? (
3124
- <NotebookViewer
3125
- path={openFile.path}
3126
- title={openFile.name}
3127
- />
3128
- ) : openFileViewerKind === "image" ? (
3129
- <ImageViewer path={openFile.path} title={openFile.name} />
3130
- ) : openFileViewerKind === "media" ? (
3131
- <MediaViewer
3132
- path={openFile.path}
3133
- title={openFile.name}
3134
- type={
3135
- ["mp4", "webm", "mov", "m4v"].includes(
3136
- ext(openFile.name),
3137
- )
3138
- ? "video"
3139
- : "audio"
3140
- }
3141
- />
3142
- ) : openFileViewerKind === "docx" ? (
3143
- <DocxViewer path={openFile.path} title={openFile.name} />
3144
- ) : openFileViewerKind === "xlsx" ? (
3145
- <XlsxViewer path={openFile.path} title={openFile.name} />
3146
- ) : openFileViewerKind === "pptx" ? (
3147
- <PptxViewer path={openFile.path} title={openFile.name} />
3148
- ) : openFileViewerKind === "source" ? (
3149
- <SourceViewer
3150
- path={openFile.path}
3151
- title={openFile.name}
3152
- />
3153
- ) : openFileViewerKind === "fallback" ? (
3154
- <FileFallbackViewer
3155
- path={openFile.path}
3156
- title={openFile.name}
3157
- />
3158
- ) : fileContent !== null ? (
3159
- <pre className="text-xs font-mono whitespace-pre-wrap break-words leading-relaxed">
3160
- {fileContent}
3161
- </pre>
3162
- ) : isText(openFile.name) ? (
3163
- <p className="text-sm text-muted-foreground">
3164
- Could not load file.
3165
- </p>
3166
- ) : (
3167
- <p className="text-sm text-muted-foreground">
3168
- Preview not available for this file type.
3169
- </p>
3170
- )}
3171
- </div>
3172
- </div>
3173
- )}
3174
-
3175
- {editing && !isMarkdown(openFile.name) && (
3176
- <div className="border-t px-4 py-2 flex items-center justify-end gap-2 bg-muted shrink-0">
3177
- {saveError && (
3178
- <span className="text-xs text-destructive mr-auto">
3179
- {saveError}
3180
- </span>
3181
- )}
3182
- <Button
3183
- size="sm"
3184
- variant="ghost"
3185
- onClick={() => {
3186
- setEditing(false);
3187
- setSaveError(null);
3188
- }}
3189
- >
3190
- Cancel
3191
- </Button>
3192
- <Button
3193
- size="sm"
3194
- className="gap-1"
3195
- onClick={handleSave}
3196
- disabled={saving}
3197
- >
3198
- {saving && <Loader2 className="h-3 w-3 animate-spin" />}
3199
- Save
3200
- </Button>
3201
- </div>
3202
- )}
3203
- </div>
3204
- )
3205
- ) : (
3206
- <div className="flex-1 flex flex-col items-center justify-center">
3207
- <div className="flex flex-col items-center gap-2 text-center px-4">
3208
- <FileText className="h-8 w-8 text-muted-foreground" />
3209
- <p className="text-sm text-muted-foreground">
3210
- Select a file to view or edit
3211
- </p>
3212
- </div>
3213
- </div>
3214
- )}
3215
- </div>
3216
-
3217
- <ShareDialog
3218
- open={shareDialogOpen}
3219
- onOpenChange={setShareDialogOpen}
3220
- filePath={openFile?.path ?? ""}
3221
- />
3222
- <AuthSettingsSheet open={settingsOpen} onOpenChange={setSettingsOpen} />
3223
- <AIPanel currentPath={openFile?.path} />
3224
- <input
3225
- ref={fileInputRef}
3226
- type="file"
3227
- multiple
3228
- className="hidden"
3229
- onChange={(e) => {
3230
- if (e.target.files) doUpload(e.target.files, uploadDirRef.current);
3231
- }}
3232
- />
3233
-
3234
- <ConfirmDialog
3235
- open={!!deletingPath}
3236
- onOpenChange={(open) => {
3237
- if (!open) setDeletingPath(null);
3238
- }}
3239
- title={deletingIsDir ? "Delete folder?" : "Delete file?"}
3240
- description={
3241
- deletingIsDir
3242
- ? `"${deletingPath?.split("/").pop()}" and all its contents will be permanently deleted.`
3243
- : `"${deletingPath?.split("/").pop()}" will be permanently removed.`
3244
- }
3245
- onConfirm={handleDelete}
3246
- />
3247
-
3248
- <ConfirmDialog
3249
- open={!!deletingWorkspaceId}
3250
- onOpenChange={(open) => {
3251
- if (!open) setDeletingWorkspaceId(null);
3252
- }}
3253
- title="Delete workspace?"
3254
- description={`"${workspaces.find((w) => w.id === deletingWorkspaceId)?.name ?? ""}" will be removed from the workspace list. Files on disk are NOT deleted.`}
3255
- onConfirm={handleDeleteWorkspace}
3256
- />
3257
- </>}
3258
- </div>
3259
- );
3260
- }