@tequila99/githuman 0.2.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (248) hide show
  1. package/README.md +27 -13
  2. package/dist/cli/commands/export.js +4 -3
  3. package/dist/cli/index.js +7 -4
  4. package/dist/cli/server-runtime.js +13 -1
  5. package/dist/cli/startup-message.js +3 -2
  6. package/dist/server/adapters/agents/event-mapper.js +140 -0
  7. package/dist/server/app.js +27 -14
  8. package/dist/server/config/agents.js +141 -0
  9. package/dist/server/errors/agents.js +12 -0
  10. package/dist/server/errors/comments.js +2 -0
  11. package/dist/server/errors/export.js +2 -0
  12. package/dist/server/errors/http.js +4 -0
  13. package/dist/server/errors/reviews.js +3 -0
  14. package/dist/server/hooks/agents/local-origin.js +33 -0
  15. package/dist/server/repositories/review.repo.js +1 -3
  16. package/dist/server/routes/agent.js +135 -0
  17. package/dist/server/routes/comments.js +25 -72
  18. package/dist/server/routes/diff.js +5 -6
  19. package/dist/server/routes/events.js +9 -3
  20. package/dist/server/routes/export.js +7 -16
  21. package/dist/server/routes/git.js +12 -21
  22. package/dist/server/routes/reviews.js +11 -43
  23. package/dist/server/services/agent/agent-session.js +497 -0
  24. package/dist/server/services/agent/context-builder.js +137 -0
  25. package/dist/server/services/agent/file-index.js +58 -0
  26. package/dist/server/services/agent/session-registry.js +100 -0
  27. package/dist/server/services/comment.service.js +1 -2
  28. package/dist/server/services/diff.service.js +2 -1
  29. package/dist/server/services/export.service.js +208 -28
  30. package/dist/server/services/git.service.js +9 -7
  31. package/dist/server/services/review.service.js +3 -3
  32. package/dist/server/utils/executable.js +26 -0
  33. package/dist/server/utils/file-name.js +6 -0
  34. package/dist/server/utils/sse.js +11 -0
  35. package/dist/server/utils/text.js +29 -0
  36. package/dist/shared/agents/chat-names.js +17 -0
  37. package/dist/shared/agents/constants.js +6 -0
  38. package/dist/shared/agents/context-format.js +17 -0
  39. package/dist/shared/agents/file-search.js +48 -0
  40. package/dist/shared/agents/schemas.js +51 -0
  41. package/dist/shared/agents/types.js +11 -0
  42. package/dist/shared/app/types.js +1 -0
  43. package/dist/shared/comments/types.js +1 -0
  44. package/dist/shared/events/types.js +14 -0
  45. package/dist/shared/git/types.js +1 -0
  46. package/dist/shared/http/types.js +1 -0
  47. package/dist/shared/network/loopback.js +4 -0
  48. package/dist/shared/reviews/types.js +1 -0
  49. package/dist/shared/utils/error-message.js +3 -0
  50. package/dist/shared/utils/guards.js +4 -0
  51. package/dist/web/assets/AppPage-CSb8txs5.js +1 -0
  52. package/dist/web/assets/CreateReviewFabButton-B18DLuH6.js +1 -0
  53. package/dist/web/assets/CreateReviewFabButton-Cufd8hVJ.css +1 -0
  54. package/dist/web/assets/{ErrorNotFound-DMYc7a3H.js → ErrorNotFound-B5uYICL4.js} +1 -1
  55. package/dist/web/assets/IndexPage-B-hkrnvt.css +1 -0
  56. package/dist/web/assets/IndexPage-Cm_ssBeC.js +1 -0
  57. package/dist/web/assets/MainLayout-ChxXEt_Q.css +1 -0
  58. package/dist/web/assets/MainLayout-ClBPF545.js +7 -0
  59. package/dist/web/assets/QBtn-E32fUOKx.js +1 -0
  60. package/dist/web/assets/QItemLabel-Co6oCEUr.js +1 -0
  61. package/dist/web/assets/QMenu-BZ1eK_rS.js +1 -0
  62. package/dist/web/assets/QSelect-D1V4tbDr.js +1 -0
  63. package/dist/web/assets/ReviewDetailPage-BWa3EEkv.css +1 -0
  64. package/dist/web/assets/ReviewDetailPage-Ct0hXwZL.js +1 -0
  65. package/dist/web/assets/ReviewsPage-CbLAU6OM.js +1 -0
  66. package/dist/web/assets/ReviewsPage-Lb74-4L2.css +1 -0
  67. package/dist/web/assets/StatusSwitcher-C0k4efwK.js +4 -0
  68. package/dist/web/assets/StatusSwitcher-CZYtI0ID.css +1 -0
  69. package/dist/web/assets/abnfDiagram-O67JEVCF-DkOrqZLn.js +1 -0
  70. package/dist/web/assets/agent-icon-BKoq2a6T.js +17 -0
  71. package/dist/web/assets/agent-icon-BUd-vCRS.css +1 -0
  72. package/dist/web/assets/agent-store-CEQ6MmTC.js +1 -0
  73. package/dist/web/assets/app-info-store-CIqmSeWK.js +1 -0
  74. package/dist/web/assets/arc-BALAYY3f.js +1 -0
  75. package/dist/web/assets/architecture-7GRP2DOG-CTkghkmX.js +1 -0
  76. package/dist/web/assets/architectureDiagram-NJMV4G6O-C-xtrMea.js +36 -0
  77. package/dist/web/assets/array-BifhSqXX.js +1 -0
  78. package/dist/web/assets/blockDiagram-BEXU5L5S-DYo9Os-m.js +140 -0
  79. package/dist/web/assets/c4Diagram-YGBWAQC7-BpSFjUAX.js +38 -0
  80. package/dist/web/assets/channel-f9cE6NV9.js +1 -0
  81. package/dist/web/assets/chunk-2Q5K7J3B-C1jixKkw.js +1 -0
  82. package/dist/web/assets/chunk-3FUC2YCW-DtUqbu5K.js +2 -0
  83. package/dist/web/assets/chunk-5DYCD2WN-DeYTIM8k.js +1 -0
  84. package/dist/web/assets/chunk-5VM5RSS4-ZNzvKenW.js +15 -0
  85. package/dist/web/assets/chunk-742MDFTN-8BfKAsu6.js +1 -0
  86. package/dist/web/assets/chunk-7INBJB4K-PXK092EZ.js +62 -0
  87. package/dist/web/assets/chunk-7M6MHVWA-Db9reT-V.js +213 -0
  88. package/dist/web/assets/chunk-7PRAP22T-CNFpjNaH.js +1 -0
  89. package/dist/web/assets/chunk-DUW6YSOI-Dgx8z5s3.js +1 -0
  90. package/dist/web/assets/chunk-FOHPRMQF-CoA4358r.js +161 -0
  91. package/dist/web/assets/chunk-GTNCS2PH-C5gqOp1W.js +88 -0
  92. package/dist/web/assets/chunk-GWA4HPMP-BquHLG8L.js +1 -0
  93. package/dist/web/assets/chunk-J5ZVWO5B-oDCd-gWO.js +1 -0
  94. package/dist/web/assets/chunk-JWPE2WC7-DVXcaiue.js +1 -0
  95. package/dist/web/assets/chunk-MBY4JIJT-DyOEFgCh.js +72 -0
  96. package/dist/web/assets/chunk-NETBCI7D-Bd3ij3-b.js +1 -0
  97. package/dist/web/assets/chunk-O7XYJQB3-DQvIvDe6.js +126 -0
  98. package/dist/web/assets/chunk-UA2S7LBM-BDIHwgCZ.js +1 -0
  99. package/dist/web/assets/chunk-WEXAMYUT-DwQKTsK-.js +1 -0
  100. package/dist/web/assets/chunk-XXDRQBXY-DTdiON6C.js +1 -0
  101. package/dist/web/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +1 -0
  102. package/dist/web/assets/chunk-Z7XXMR3K-CIvnaGnU.js +10 -0
  103. package/dist/web/assets/chunk-ZIGJFQKS-BdT_T5CV.js +2 -0
  104. package/dist/web/assets/classDiagram-v2-NBCMYWYE-BUoclHlO.js +217 -0
  105. package/dist/web/assets/{core-Ruj7F7dx.js → core-B8kUiZoM.js} +1 -1
  106. package/dist/web/assets/cose-bilkent-JH36ORCC-C16eW6pc.js +1 -0
  107. package/dist/web/assets/create-B9ZqyTxV.js +1 -0
  108. package/dist/web/assets/cynefin-OW5HDTMX-1mS7ZeoM.js +1 -0
  109. package/dist/web/assets/cynefinDiagram-VND7K2PF-D1PC4YT4.js +62 -0
  110. package/dist/web/assets/cytoscape.esm-B-1iW2ft.js +321 -0
  111. package/dist/web/assets/dagre-6A5THRUB-amODOP74.js +4 -0
  112. package/dist/web/assets/debounce-CDIE7CV6.js +1 -0
  113. package/dist/web/assets/defaultLocale-BFoDCU3G.js +1 -0
  114. package/dist/web/assets/diagram-22UHCM2B-wUvcsN_B.js +200 -0
  115. package/dist/web/assets/diagram-3UASUU5V-CLiOVs2l.js +24 -0
  116. package/dist/web/assets/diagram-ATOU4E4O-Cs5cFGgz.js +3 -0
  117. package/dist/web/assets/diagram-CDSNMT55-C8Jb-YeE.js +30 -0
  118. package/dist/web/assets/diagram-MLGK6HIB-DA6C9fVB.js +24 -0
  119. package/dist/web/assets/diagram-MPIPVDR6-BOaFKuUi.js +41 -0
  120. package/dist/web/assets/dist-DuFVAX6Y.js +1 -0
  121. package/dist/web/assets/ebnfDiagram-ZINNZB2B-e5jvn-EE.js +1 -0
  122. package/dist/web/assets/elk-276RUBZZ-D7Gcu17D.js +27 -0
  123. package/dist/web/assets/engine-oniguruma-HbxpIAu4.js +1 -0
  124. package/dist/web/assets/erDiagram-OPXOYQCR-Cn1bBUF8.js +99 -0
  125. package/dist/web/assets/event-rR76ioXz.js +1 -0
  126. package/dist/web/assets/eventmodeling-NTZA5JFV-BjGpd42n.js +1 -0
  127. package/dist/web/assets/export-file-Di_2jvjP.js +1 -0
  128. package/dist/web/assets/fira-code-symbols2-400-normal-9X26I2LK.woff2 +0 -0
  129. package/dist/web/assets/fira-code-symbols2-500-normal-B5s_njw3.woff2 +0 -0
  130. package/dist/web/assets/fira-code-symbols2-600-normal-Bu3RnO39.woff2 +0 -0
  131. package/dist/web/assets/flowDiagram-KWPJA3E3-a3N8MVB8.js +1 -0
  132. package/dist/web/assets/ganttDiagram-FUAMR5RP-C3U8jeJB.js +292 -0
  133. package/dist/web/assets/gitGraph-4MIJSDKK-C165DC0H.js +1 -0
  134. package/dist/web/assets/gitGraphDiagram-X574FWY7-CVHdT1OC.js +106 -0
  135. package/dist/web/assets/golos-text-cyrillic-400-normal-BwL4n7Pb.woff +0 -0
  136. package/dist/web/assets/golos-text-cyrillic-400-normal-C7us6pn1.woff2 +0 -0
  137. package/dist/web/assets/golos-text-cyrillic-500-normal-BSLQUuP1.woff2 +0 -0
  138. package/dist/web/assets/golos-text-cyrillic-500-normal-hXinzVVQ.woff +0 -0
  139. package/dist/web/assets/golos-text-cyrillic-ext-400-normal-Bo67NYBq.woff2 +0 -0
  140. package/dist/web/assets/golos-text-cyrillic-ext-400-normal-D592xEk3.woff +0 -0
  141. package/dist/web/assets/golos-text-cyrillic-ext-500-normal-CLsu_eDI.woff2 +0 -0
  142. package/dist/web/assets/golos-text-cyrillic-ext-500-normal-InHNvB7v.woff +0 -0
  143. package/dist/web/assets/golos-text-latin-400-normal-Coi1FYaD.woff2 +0 -0
  144. package/dist/web/assets/golos-text-latin-400-normal-DOuJOmdK.woff +0 -0
  145. package/dist/web/assets/golos-text-latin-500-normal-BQo4s7Kn.woff +0 -0
  146. package/dist/web/assets/golos-text-latin-500-normal-BznAvurO.woff2 +0 -0
  147. package/dist/web/assets/golos-text-latin-ext-400-normal-BXgyX_iE.woff +0 -0
  148. package/dist/web/assets/golos-text-latin-ext-400-normal-BjKcJWKY.woff2 +0 -0
  149. package/dist/web/assets/golos-text-latin-ext-500-normal-9vZPKadr.woff2 +0 -0
  150. package/dist/web/assets/golos-text-latin-ext-500-normal-BmtMVntR.woff +0 -0
  151. package/dist/web/assets/graphlib-DS17s2tU.js +1 -0
  152. package/dist/web/assets/i18n-CyUPETur.js +1 -0
  153. package/dist/web/assets/{index-JAYe_SFI.css → index-C6ArNnKs.css} +1 -1
  154. package/dist/web/assets/index-C_B6Z2lc.js +2 -0
  155. package/dist/web/assets/info-A6RAGUB7-cbWtF3FJ.js +1 -0
  156. package/dist/web/assets/infoDiagram-VRGFBTTK-C_kWn3Ai.js +2 -0
  157. package/dist/web/assets/init-C-OQMol4.js +1 -0
  158. package/dist/web/assets/init-app-DnfYD-0k.js +1 -0
  159. package/dist/web/assets/is-ChHvR1Z-.js +1 -0
  160. package/dist/web/assets/ishikawaDiagram-OU5B5YK6-Xb9cDRr7.js +70 -0
  161. package/dist/web/assets/jetbrains-mono-cyrillic-ext-400-normal-Bh0R7Dhr.woff +0 -0
  162. package/dist/web/assets/jetbrains-mono-cyrillic-ext-400-normal-C_uLvvQ5.woff2 +0 -0
  163. package/dist/web/assets/jetbrains-mono-cyrillic-ext-500-normal-C9ShMxGR.woff2 +0 -0
  164. package/dist/web/assets/jetbrains-mono-cyrillic-ext-500-normal-CEVyPk4Y.woff +0 -0
  165. package/dist/web/assets/jetbrains-mono-cyrillic-ext-600-normal-DVvC-yKp.woff +0 -0
  166. package/dist/web/assets/jetbrains-mono-cyrillic-ext-600-normal-Dj6eZy5f.woff2 +0 -0
  167. package/dist/web/assets/jetbrains-mono-vietnamese-400-normal-ByoDsISC.woff2 +0 -0
  168. package/dist/web/assets/jetbrains-mono-vietnamese-500-normal-BvH7FW2L.woff2 +0 -0
  169. package/dist/web/assets/jetbrains-mono-vietnamese-600-normal-Dtizs43-.woff2 +0 -0
  170. package/dist/web/assets/journeyDiagram-ZHPQQLJL-6eZcefap.js +139 -0
  171. package/dist/web/assets/kanban-definition-PNTS6WVX-D_z_AsKR.js +89 -0
  172. package/dist/web/assets/katex-ZlcWpGUi.js +257 -0
  173. package/dist/web/assets/line-v7JtZ-Za.js +1 -0
  174. package/dist/web/assets/linear-hp1KWmiC.js +1 -0
  175. package/dist/web/assets/mermaid-parser.core-Cwwk5NqO.js +7 -0
  176. package/dist/web/assets/mermaid.core-BjPJhwW0.js +44 -0
  177. package/dist/web/assets/mindmap-definition-NLK3R4M7-lQohhDXq.js +96 -0
  178. package/dist/web/assets/ordinal-BDEzSJ7C.js +1 -0
  179. package/dist/web/assets/packet-AYTQ26CC-DxiGe6nt.js +1 -0
  180. package/dist/web/assets/path-fybaL0A-.js +1 -0
  181. package/dist/web/assets/pegDiagram-GJSIUBJH-XZRpQTLx.js +1 -0
  182. package/dist/web/assets/pie-WAS4IAKB-fxN0O90k.js +1 -0
  183. package/dist/web/assets/pieDiagram-5QR66LMP-M_bBKE-n.js +39 -0
  184. package/dist/web/assets/pinia-0B8sIOgD.js +1 -0
  185. package/dist/web/assets/pinia-DbmXdZbT.js +1 -0
  186. package/dist/web/assets/quadrantDiagram-O4NWA36T-BNxVPrq3.js +7 -0
  187. package/dist/web/assets/radar-RG4KPBEZ-Cd-5aokk.js +1 -0
  188. package/dist/web/assets/railroad-74A4TZTK-BvSKL8Ze.js +1 -0
  189. package/dist/web/assets/railroad-abnf-HS5TGJTU-CB32K25L.js +1 -0
  190. package/dist/web/assets/railroad-ebnf-LZEXJU2U-CqQR3zRl.js +1 -0
  191. package/dist/web/assets/railroad-peg-WCYAUIDC-DRSdB-oL.js +1 -0
  192. package/dist/web/assets/railroadDiagram-XR7U4H2S-D664lPSL.js +1 -0
  193. package/dist/web/assets/requirementDiagram-PLB6GJNP-B_1Dl0-3.js +84 -0
  194. package/dist/web/assets/rough.esm-Dy-Kn_BL.js +1 -0
  195. package/dist/web/assets/runtime-core.esm-bundler-Carm8LUR.js +1 -0
  196. package/dist/web/assets/safe-storage-CWaD6Dq3.js +1 -0
  197. package/dist/web/assets/sankeyDiagram-IPEJSGJF-BvFmMC_F.js +40 -0
  198. package/dist/web/assets/sequenceDiagram-PO4LG4MO-DMK1ey4g.js +169 -0
  199. package/dist/web/assets/single-flight-r8B7stt1.js +1 -0
  200. package/dist/web/assets/sizeCapture-INFHLROL-B0uUizjq.js +1 -0
  201. package/dist/web/assets/src-Cy0hT8a0.js +1 -0
  202. package/dist/web/assets/stateDiagram-v2-GCMORJYK-VVElU-_E.js +291 -0
  203. package/dist/web/assets/swimlanes-2SLR337P-DyfYJvHL.js +1 -0
  204. package/dist/web/assets/swimlanesDiagram-TC7HE7FX-BQ0mK2BI.js +8 -0
  205. package/dist/web/assets/theme-BFceB3b9.js +1 -0
  206. package/dist/web/assets/timeline-definition-EJHVYXUP-DBoRb7uC.js +120 -0
  207. package/dist/web/assets/treeView-Q6P3EWNA-D8AoZg1Y.js +1 -0
  208. package/dist/web/assets/treemap-WGGIJYW6-DhcVtZsu.js +1 -0
  209. package/dist/web/assets/use-app-theme-DxZW-eOR.js +1 -0
  210. package/dist/web/assets/use-panel-width-CY75Fomr.js +1 -0
  211. package/dist/web/assets/usecaseDiagram-POWQR4AR-BCfvWOH4.js +328 -0
  212. package/dist/web/assets/vennDiagram-UO4OBE2U-BIk6-FLo.js +34 -0
  213. package/dist/web/assets/{vue-i18n.runtime-DSeWNwrW.js → vue-i18n.runtime-BQwnBdmC.js} +2 -2
  214. package/dist/web/assets/wardley-WFR3VGLG-BMHH70A3.js +1 -0
  215. package/dist/web/assets/wardleyDiagram-VNRHLVJA-DB-cxlSq.js +78 -0
  216. package/dist/web/assets/xychartDiagram-PMCCYNJV-BejE-3gi.js +7 -0
  217. package/dist/web/index.html +8 -6
  218. package/package.json +18 -8
  219. package/dist/web/assets/AppPage-BeqKILJT.js +0 -1
  220. package/dist/web/assets/CreateReviewFabButton-BqWdgfEf.css +0 -1
  221. package/dist/web/assets/CreateReviewFabButton-wjxKeOL1.js +0 -1
  222. package/dist/web/assets/IndexPage-BQ0T9rLY.css +0 -1
  223. package/dist/web/assets/IndexPage-CSaToIzA.js +0 -1
  224. package/dist/web/assets/MainLayout-BRPoh8y0.css +0 -1
  225. package/dist/web/assets/MainLayout-xQ92tgGe.js +0 -1
  226. package/dist/web/assets/Platform-CEDL_PgC.js +0 -1
  227. package/dist/web/assets/QBtn-Cf6WtiKt.js +0 -1
  228. package/dist/web/assets/QTooltip-IUM54Nhj.js +0 -1
  229. package/dist/web/assets/ReviewDetailPage-DJHzinT1.css +0 -1
  230. package/dist/web/assets/ReviewDetailPage-DdufL7Rh.js +0 -1
  231. package/dist/web/assets/ReviewsPage-D8u1IeQ4.js +0 -1
  232. package/dist/web/assets/ReviewsPage-D9TFy2OP.css +0 -1
  233. package/dist/web/assets/StatusSwitcher-7Yqs8azq.css +0 -1
  234. package/dist/web/assets/StatusSwitcher-CUOMaD34.js +0 -3
  235. package/dist/web/assets/app-info-store-BpeVVjNB.js +0 -1
  236. package/dist/web/assets/client-De34x_P_.js +0 -1
  237. package/dist/web/assets/engine-oniguruma-Bon2Icja.js +0 -1
  238. package/dist/web/assets/event-BQt_DXaR.js +0 -1
  239. package/dist/web/assets/format-BWCY39oA.js +0 -1
  240. package/dist/web/assets/i18n-DHrLpaJV.js +0 -1
  241. package/dist/web/assets/index-DphTOTJs.js +0 -2
  242. package/dist/web/assets/init-app-5wT4mURp.js +0 -1
  243. package/dist/web/assets/pinia-CrBjT8X4.js +0 -1
  244. package/dist/web/assets/pinia-DK4OjQ7o.js +0 -1
  245. package/dist/web/assets/runtime-core.esm-bundler-Wxr8ezGp.js +0 -1
  246. package/dist/web/assets/theme-CrTbazqt.js +0 -1
  247. package/dist/web/assets/use-app-theme-CsAmLZfp.js +0 -1
  248. /package/dist/shared/{types.js → diff/types.js} +0 -0
@@ -0,0 +1,100 @@
1
+ import { UnknownAgentPresetError, TooManyAgentSessionsError } from "../../errors/agents.js";
2
+ import { MAX_AGENT_SESSIONS } from "../../../shared/agents/constants.js";
3
+ import { uniqueChatName } from "../../../shared/agents/chat-names.js";
4
+ import { AgentSession } from "./agent-session.js";
5
+ import { describePreset } from "../../config/agents.js";
6
+ export function createSessionRegistry(options) {
7
+ const sessions = new Map();
8
+ const unsubscribeSession = new Map();
9
+ const listeners = new Set();
10
+ let lastEventId = 0;
11
+ function broadcast(message) {
12
+ for (const listener of listeners) {
13
+ listener(message);
14
+ }
15
+ }
16
+ function drop(id) {
17
+ unsubscribeSession.get(id)?.();
18
+ unsubscribeSession.delete(id);
19
+ sessions.delete(id);
20
+ }
21
+ function closeAll() {
22
+ for (const session of sessions.values()) {
23
+ session.close();
24
+ }
25
+ for (const id of sessions.keys()) {
26
+ drop(id);
27
+ }
28
+ }
29
+ return {
30
+ listPresets: () => options.presets.map(describePreset),
31
+ create(presetId, reviewId, name) {
32
+ const preset = options.presets.find(p => p.id === presetId);
33
+ if (!preset) {
34
+ throw new UnknownAgentPresetError(`Unknown agent "${presetId}"`);
35
+ }
36
+ if (sessions.size >= MAX_AGENT_SESSIONS) {
37
+ throw new TooManyAgentSessionsError(`At most ${MAX_AGENT_SESSIONS} chats can be open at once`);
38
+ }
39
+ const session = new AgentSession({
40
+ preset,
41
+ // Open chats are told apart by name: a taken one gets "(1)", "(2)", …
42
+ name: uniqueChatName(name?.trim() || preset.title, [...sessions.values()].map(s => s.name)),
43
+ cwd: options.repositoryPath,
44
+ reviewId,
45
+ nextEventId: () => ++lastEventId,
46
+ ...(options.bufferLimit === undefined
47
+ ? {}
48
+ : { bufferLimit: options.bufferLimit })
49
+ });
50
+ sessions.set(session.id, session);
51
+ unsubscribeSession.set(session.id, session.onEvent(envelope => {
52
+ broadcast({
53
+ type: 'event',
54
+ envelope: { ...envelope, sessionId: session.id }
55
+ });
56
+ }));
57
+ broadcast({ type: 'sessions' });
58
+ void session.start();
59
+ return session;
60
+ },
61
+ get: id => sessions.get(id),
62
+ list: () => [...sessions.values()].map(s => s.info()),
63
+ close(id) {
64
+ const session = sessions.get(id);
65
+ if (!session) {
66
+ return false;
67
+ }
68
+ session.close();
69
+ drop(id);
70
+ broadcast({ type: 'sessions' });
71
+ return true;
72
+ },
73
+ closeAll,
74
+ subscribe(listener, afterId = 0) {
75
+ const replays = [...sessions.values()].map(session => ({
76
+ sessionId: session.id,
77
+ ...session.replay(afterId)
78
+ }));
79
+ for (const { sessionId, gap } of replays) {
80
+ if (gap) {
81
+ listener({ type: 'gap', sessionId });
82
+ }
83
+ // The buffer starts late for a new client or after a gap, so it may
84
+ // lack the events that set the status, settings and open requests.
85
+ if (gap || afterId === 0) {
86
+ const state = sessions.get(sessionId)?.state();
87
+ if (state) {
88
+ listener({ type: 'state', state });
89
+ }
90
+ }
91
+ }
92
+ replays
93
+ .flatMap(({ sessionId, envelopes }) => envelopes.map(envelope => ({ ...envelope, sessionId })))
94
+ .sort((a, b) => a.id - b.id)
95
+ .forEach(envelope => listener({ type: 'event', envelope }));
96
+ listeners.add(listener);
97
+ return () => listeners.delete(listener);
98
+ }
99
+ };
100
+ }
@@ -1,6 +1,5 @@
1
+ import { ValidationError } from "../errors/comments.js";
1
2
  import { createComment as insertComment, findCommentById, listCommentsByReview, updateComment as updateCommentRepo, deleteComment as deleteCommentRepo, setCommentResolved } from "../repositories/comment.repo.js";
2
- export class ValidationError extends Error {
3
- }
4
3
  export function createComment(db, reviewId, input, eventBus) {
5
4
  if (!input.content) {
6
5
  throw new ValidationError('"content" is required');
@@ -1,5 +1,7 @@
1
1
  import { diffLines } from 'diff';
2
2
  import { assertSafeRef, getFileAtRef, getFilesAtRefBatch, listChangedPaths, listUntrackedPaths } from "./git.service.js";
3
+ /** Unchanged lines kept around each change in a hunk, as in `git diff`. */
4
+ const CONTEXT_LINES = 3;
3
5
  function splitLines(text) {
4
6
  if (text === '')
5
7
  return [];
@@ -8,7 +10,6 @@ function splitLines(text) {
8
10
  lines.pop();
9
11
  return lines;
10
12
  }
11
- const CONTEXT_LINES = 3;
12
13
  function windowHunks(lines, contextLines = CONTEXT_LINES) {
13
14
  if (lines.length === 0)
14
15
  return [];
@@ -1,7 +1,7 @@
1
+ import { ExportNotFoundError } from "../errors/export.js";
1
2
  import { findReviewById } from "../repositories/review.repo.js";
2
3
  import { listCommentsByReview } from "../repositories/comment.repo.js";
3
- export class ExportNotFoundError extends Error {
4
- }
4
+ import { getFileAtRef } from "./git.service.js";
5
5
  const STATUS_LABELS = {
6
6
  in_progress: 'в процессе',
7
7
  approved: 'одобрено',
@@ -26,46 +26,226 @@ function diffLinePrefix(line) {
26
26
  return '-';
27
27
  return ' ';
28
28
  }
29
- export function formatReviewAsMarkdown(review, files, comments) {
29
+ function filePathOf(file) {
30
+ return file.newPath || file.oldPath;
31
+ }
32
+ /**
33
+ * A fence one backtick longer than the longest backtick run inside `text`, so
34
+ * code or a suggestion that itself contains ``` can't close the block early.
35
+ */
36
+ function fenceFor(text) {
37
+ return '`'.repeat(Math.max(3, longestBacktickRun(text) + 1));
38
+ }
39
+ function longestBacktickRun(text) {
40
+ return Math.max(0, ...(text.match(/`+/g) ?? []).map(run => run.length));
41
+ }
42
+ /** Inline code that survives backticks in `text` (legal in git paths). */
43
+ function inlineCode(text) {
44
+ const delimiter = '`'.repeat(longestBacktickRun(text) + 1);
45
+ // CommonMark strips one space from each end when both ends have one.
46
+ const pad = text.startsWith('`') ||
47
+ text.endsWith('`') ||
48
+ (text.startsWith(' ') && text.endsWith(' '))
49
+ ? ' '
50
+ : '';
51
+ return `${delimiter}${pad}${text}${pad}${delimiter}`;
52
+ }
53
+ function extensionOf(path) {
54
+ const name = path.slice(path.lastIndexOf('/') + 1);
55
+ const dot = name.lastIndexOf('.');
56
+ return dot > 0 ? name.slice(dot + 1).toLowerCase() : '';
57
+ }
58
+ /**
59
+ * `lineNumberEnd` is null on comments saved before ranges existed (ADR 0017).
60
+ * Line 0 is how the API used to store a whole-file comment's null (a type
61
+ * coercion bug, since fixed), so existing databases still contain it.
62
+ */
63
+ function rangeOf(comment) {
64
+ if (comment.lineNumber === null || comment.lineNumber <= 0)
65
+ return null;
66
+ return [comment.lineNumber, comment.lineNumberEnd ?? comment.lineNumber];
67
+ }
68
+ function rangeLabel([start, end]) {
69
+ return start === end ? `Строка ${start}` : `Строки ${start}–${end}`;
70
+ }
71
+ /**
72
+ * Same line selection as ReviewDetailPage's comments-only view. Anchor column
73
+ * as in DiffHunkView.vue: 'removed' comments count old-side numbers.
74
+ */
75
+ function diffLinesFor(file, comment, [start, end]) {
76
+ const out = [];
77
+ // Where the first covered line sits in the snapshot: old- and new-side
78
+ // numbers aren't comparable, so items are ordered by this instead.
79
+ let position = Number.MAX_SAFE_INTEGER;
80
+ if (!file)
81
+ return { lines: out, position };
82
+ const numberOf = (line) => comment.lineType === 'removed' ? line.oldLineNumber : line.newLineNumber;
83
+ let offset = 0;
84
+ for (const hunk of file.hunks) {
85
+ const covered = hunk.lines.flatMap((line, index) => {
86
+ const n = numberOf(line);
87
+ return n !== null && n >= start && n <= end ? [{ line, index }] : [];
88
+ });
89
+ if (covered.length > 0) {
90
+ position = Math.min(position, offset + covered[0].index);
91
+ // A range spanning hunks (not possible from the UI) keeps the gap visible.
92
+ if (out.length > 0)
93
+ out.push('@@ … @@');
94
+ for (const { line } of covered) {
95
+ out.push(`${diffLinePrefix(line)}${line.content}`);
96
+ }
97
+ }
98
+ offset += hunk.lines.length;
99
+ }
100
+ return { lines: out, position };
101
+ }
102
+ /** A trailing newline doesn't start another line; CRLF endings are dropped. */
103
+ function splitFileLines(content) {
104
+ const lines = content.split('\n').map(line => line.replace(/\r$/, ''));
105
+ if (lines.at(-1) === '')
106
+ lines.pop();
107
+ return lines;
108
+ }
109
+ function indentBlock(text, indent) {
110
+ return text.split('\n').map(line => (line === '' ? '' : `${indent}${line}`));
111
+ }
112
+ function renderItem(item, index) {
113
+ const marker = `${index + 1}. `;
114
+ const indent = ' '.repeat(marker.length);
115
+ const out = [`${marker}${item.heading}`];
116
+ if (item.code) {
117
+ const body = item.code.lines.join('\n');
118
+ const fence = fenceFor(body);
119
+ out.push('');
120
+ out.push(`${indent}${fence}${item.code.lang}`);
121
+ out.push(...item.code.lines.map(line => `${indent}${line}`));
122
+ out.push(`${indent}${fence}`);
123
+ }
124
+ else if (item.missingNote) {
125
+ out.push('');
126
+ out.push(`${indent}_${item.missingNote}_`);
127
+ }
128
+ out.push('');
129
+ out.push(...indentBlock(item.comment.content, indent));
130
+ if (item.comment.suggestion) {
131
+ const fence = fenceFor(item.comment.suggestion);
132
+ out.push('');
133
+ out.push(`${indent}Предложение:`);
134
+ out.push('');
135
+ out.push(`${indent}${fence}`);
136
+ out.push(...item.comment.suggestion.split('\n').map(l => `${indent}${l}`));
137
+ out.push(`${indent}${fence}`);
138
+ }
139
+ out.push('');
140
+ return out;
141
+ }
142
+ /**
143
+ * Full-file comments (lineType null) quote `fileLines`, the file as on disk
144
+ * (null = unreadable), not the snapshot — the same exception to "review =
145
+ * frozen snapshot" as the UI's full-file view (#30).
146
+ */
147
+ export function formatReviewAsMarkdown(review, files, comments, fileLines = new Map()) {
30
148
  const lines = [];
31
- lines.push(`# Ревью: ${review.repositoryPath}`);
149
+ // A user-given name may contain newlines; keep it on the heading line.
150
+ const title = (review.name ?? review.repositoryPath).replace(/\s*[\r\n]+\s*/g, ' ');
151
+ lines.push(`# Ревью: ${title}`);
32
152
  lines.push('');
153
+ lines.push(`- Репозиторий: ${review.repositoryPath}`);
33
154
  lines.push(`- Источник: ${review.sourceType}${review.sourceRef ? ` (${review.sourceRef})` : ''}`);
34
155
  lines.push(`- Статус: ${STATUS_LABELS[review.status]}`);
35
156
  lines.push(`- Создано: ${review.createdAt}`);
36
157
  lines.push('');
37
- const commentsByFile = new Map();
38
- for (const comment of comments) {
39
- const existing = commentsByFile.get(comment.filePath) ?? [];
40
- commentsByFile.set(comment.filePath, [...existing, comment]);
41
- }
42
- for (const file of files) {
43
- lines.push(`## ${file.newPath} (+${file.additions}/-${file.deletions})`);
158
+ const open = comments.filter(comment => !comment.resolved);
159
+ if (open.length === 0) {
160
+ lines.push('Открытых замечаний нет.');
44
161
  lines.push('');
45
- lines.push('```diff');
46
- for (const hunk of file.hunks) {
47
- for (const line of hunk.lines) {
48
- lines.push(`${diffLinePrefix(line)}${line.content}`);
49
- }
162
+ return lines.join('\n');
163
+ }
164
+ const filesByPath = new Map(files.map(file => [filePathOf(file), file]));
165
+ const byFile = new Map();
166
+ for (const comment of open) {
167
+ const list = byFile.get(comment.filePath);
168
+ if (list) {
169
+ list.push(comment);
50
170
  }
51
- lines.push('```');
52
- lines.push('');
53
- const fileComments = commentsByFile.get(file.newPath) ?? [];
54
- if (fileComments.length > 0) {
55
- lines.push('**Комментарии:**');
56
- lines.push('');
57
- for (const comment of fileComments) {
58
- const location = comment.lineNumber !== null ? `Строка ${comment.lineNumber}` : 'Файл';
59
- lines.push(`- ${location}: ${comment.content}`);
171
+ else {
172
+ byFile.set(comment.filePath, [comment]);
173
+ }
174
+ }
175
+ // Snapshot order first; a commented file missing from the snapshot still
176
+ // gets its section rather than silently dropping the comment.
177
+ const paths = [
178
+ ...files.map(filePathOf).filter(path => byFile.has(path)),
179
+ ...[...byFile.keys()].filter(path => !filesByPath.has(path))
180
+ ];
181
+ for (const path of paths) {
182
+ const file = filesByPath.get(path);
183
+ const items = [];
184
+ for (const comment of byFile.get(path) ?? []) {
185
+ const range = rangeOf(comment);
186
+ if (!range) {
187
+ items.push({
188
+ heading: 'Файл целиком',
189
+ code: null,
190
+ missingNote: null,
191
+ comment,
192
+ order: -1
193
+ });
194
+ continue;
195
+ }
196
+ if (comment.lineType === null) {
197
+ const content = fileLines.get(path) ?? null;
198
+ const [start, end] = range;
199
+ const available = content !== null && start <= end && end <= content.length;
200
+ items.push({
201
+ heading: `${rangeLabel(range)} (файл целиком)`,
202
+ code: available
203
+ ? { lang: extensionOf(path), lines: content.slice(start - 1, end) }
204
+ : null,
205
+ missingNote: available
206
+ ? null
207
+ : 'Этих строк нет в текущей версии файла — код не показан',
208
+ comment,
209
+ // After all diff comments, in file-line order.
210
+ order: Number.MAX_SAFE_INTEGER / 2 + start
211
+ });
212
+ continue;
60
213
  }
61
- lines.push('');
214
+ const { lines: code, position } = diffLinesFor(file, comment, range);
215
+ items.push({
216
+ heading: rangeLabel(range),
217
+ code: code.length > 0 ? { lang: 'diff', lines: code } : null,
218
+ missingNote: code.length > 0 ? null : 'Строки не найдены в снимке diff',
219
+ comment,
220
+ order: code.length > 0 ? position : Number.MAX_SAFE_INTEGER / 4
221
+ });
62
222
  }
223
+ items.sort((a, b) => a.order - b.order);
224
+ lines.push(`## ${inlineCode(path)}`);
225
+ lines.push('');
226
+ items.forEach((item, index) => lines.push(...renderItem(item, index)));
63
227
  }
64
228
  return lines.join('\n');
65
229
  }
66
- export function exportAsMarkdown(db, reviewId) {
230
+ /**
231
+ * Reads from `repositoryPath` (where the server/CLI runs), not the path stored
232
+ * with the review: that one goes stale if the repository was moved.
233
+ */
234
+ export async function exportAsMarkdown(db, reviewId, repositoryPath) {
67
235
  const review = getReviewOrThrow(db, reviewId);
68
236
  const comments = listCommentsByReview(db, reviewId);
69
237
  const files = JSON.parse(review.snapshotData);
70
- return formatReviewAsMarkdown(review, files, comments);
238
+ const fullFilePaths = new Set(comments
239
+ .filter(c => !c.resolved && c.lineType === null && rangeOf(c) !== null)
240
+ .map(c => c.filePath));
241
+ const fileLines = new Map(await Promise.all([...fullFilePaths].map(async (path) => {
242
+ try {
243
+ const { content, isBinary } = await getFileAtRef(repositoryPath, 'WORKTREE', path);
244
+ return [path, isBinary ? null : splitFileLines(content)];
245
+ }
246
+ catch {
247
+ return [path, null];
248
+ }
249
+ })));
250
+ return formatReviewAsMarkdown(review, files, comments, fileLines);
71
251
  }
@@ -3,13 +3,14 @@ import { promisify } from 'node:util';
3
3
  import { readFile } from 'node:fs/promises';
4
4
  import { basename, resolve, sep } from 'node:path';
5
5
  import { simpleGit } from 'simple-git';
6
+ import { isBinaryBuffer } from "../utils/text.js";
6
7
  const execFileAsync = promisify(execFile);
7
8
  /**
8
9
  * Resolves `path` against `repoPath` and rejects anything that escapes the
9
10
  * repository root (e.g. `../../etc/passwd`), since `path` here is untrusted
10
11
  * input coming straight off the HTTP request.
11
12
  */
12
- function resolveWithinRepo(repoPath, path) {
13
+ export function resolveWithinRepo(repoPath, path) {
13
14
  const repoRoot = resolve(repoPath);
14
15
  const resolved = resolve(repoRoot, path);
15
16
  if (resolved !== repoRoot && !resolved.startsWith(repoRoot + sep)) {
@@ -62,10 +63,8 @@ export async function getFileAtRef(repoPath, ref, path) {
62
63
  try {
63
64
  const absolutePath = resolveWithinRepo(repoPath, path);
64
65
  const buffer = await readFile(absolutePath);
65
- return {
66
- content: buffer.includes(0) ? '' : buffer.toString('utf-8'),
67
- isBinary: buffer.includes(0)
68
- };
66
+ const isBinary = isBinaryBuffer(buffer);
67
+ return { content: isBinary ? '' : buffer.toString('utf-8'), isBinary };
69
68
  }
70
69
  catch {
71
70
  return { content: '', isBinary: false };
@@ -78,7 +77,7 @@ export async function getFileAtRef(repoPath, ref, path) {
78
77
  encoding: 'buffer',
79
78
  maxBuffer: 1024 * 1024 * 100
80
79
  });
81
- const isBinary = stdout.includes(0);
80
+ const isBinary = isBinaryBuffer(stdout);
82
81
  return {
83
82
  content: isBinary ? '' : stdout.toString('utf-8'),
84
83
  isBinary
@@ -119,7 +118,7 @@ function parseCatFileBatchOutput(buffer, count) {
119
118
  throw new Error('git cat-file --batch: truncated output (short content)');
120
119
  }
121
120
  offset += size + 1; // skip content + its trailing LF
122
- const isBinary = content.includes(0);
121
+ const isBinary = isBinaryBuffer(content);
123
122
  results.push({
124
123
  content: isBinary ? '' : content.toString('utf-8'),
125
124
  isBinary
@@ -167,6 +166,9 @@ export async function getFilesAtRefBatch(repoPath, requests) {
167
166
  reject(err instanceof Error ? err : new Error(String(err)));
168
167
  }
169
168
  });
169
+ // git may exit before reading stdin (e.g. not a repository): the EPIPE
170
+ // would crash the process, and 'close' already reports the failure.
171
+ child.stdin.on('error', () => { });
170
172
  child.stdin.write(specs.join('\n') + '\n');
171
173
  child.stdin.end();
172
174
  });
@@ -1,10 +1,10 @@
1
- import { createReview as insertReview, findReviewById, listReviews, updateReviewStatus as updateReviewStatusRepo, deleteReview as deleteReviewRepo, UniqueNameError } from "../repositories/review.repo.js";
1
+ import { ValidationError } from "../errors/reviews.js";
2
+ import { createReview as insertReview, findReviewById, listReviews, updateReviewStatus as updateReviewStatusRepo, deleteReview as deleteReviewRepo } from "../repositories/review.repo.js";
3
+ import { UniqueNameError } from "../errors/reviews.js";
2
4
  import { listCommentsByReview, deleteComment } from "../repositories/comment.repo.js";
3
5
  import { insertReviewFiles, deleteReviewFiles } from "../repositories/review-file.repo.js";
4
6
  import { getStagedDiff, getUnstagedDiff, getBranchDiff, getCommitsDiff } from "./diff.service.js";
5
7
  import { getRepositoryInfo } from "./git.service.js";
6
- export class ValidationError extends Error {
7
- }
8
8
  const REVIEW_NAME_MONTHS_RU = [
9
9
  'янв',
10
10
  'февр',
@@ -0,0 +1,26 @@
1
+ import { accessSync, constants } from 'node:fs';
2
+ import { delimiter, isAbsolute, join } from 'node:path';
3
+ export function isOnPath(binary, pathEnv = process.env.PATH) {
4
+ if (isAbsolute(binary)) {
5
+ try {
6
+ accessSync(binary, constants.X_OK);
7
+ return true;
8
+ }
9
+ catch {
10
+ return false;
11
+ }
12
+ }
13
+ for (const dir of (pathEnv ?? '').split(delimiter)) {
14
+ if (dir === '') {
15
+ continue;
16
+ }
17
+ try {
18
+ accessSync(join(dir, binary), constants.X_OK);
19
+ return true;
20
+ }
21
+ catch {
22
+ // not in this directory
23
+ }
24
+ }
25
+ return false;
26
+ }
@@ -0,0 +1,6 @@
1
+ import { basename } from 'node:path';
2
+ /** The client-supplied name must not escape the directory it is written into. */
3
+ export function safeFileName(name) {
4
+ const base = basename(name.replaceAll('\\', '/')).replaceAll('\0', '');
5
+ return base === '' || base === '.' || base === '..' ? 'attachment' : base;
6
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Sends one SSE message and never fails. A client can leave at any time, and
3
+ * `send` then rejects. Nothing awaits that promise, so Node would end the
4
+ * process on the unhandled rejection.
5
+ */
6
+ export function safeSend(sse, message) {
7
+ if (!sse.isConnected) {
8
+ return;
9
+ }
10
+ sse.send(message).catch(() => { });
11
+ }
@@ -0,0 +1,29 @@
1
+ /** Ends a cut text when the caller gives no marker of its own. */
2
+ const DEFAULT_TRUNCATION_MARKER = '\n… (truncated)';
3
+ /**
4
+ * Cuts `text` to `max` characters. `marker` gets the number of characters
5
+ * dropped and returns what is appended, so each caller can tell its reader
6
+ * (browser, agent) what happened.
7
+ */
8
+ export function truncateText(text, max, marker = () => DEFAULT_TRUNCATION_MARKER) {
9
+ return text.length > max
10
+ ? `${text.slice(0, max)}${marker(text.length - max)}`
11
+ : text;
12
+ }
13
+ /** True when the bytes contain a NUL: the quick test git itself uses to call a file binary. */
14
+ export function isBinaryBuffer(bytes) {
15
+ return bytes.includes(0);
16
+ }
17
+ /**
18
+ * The text of `bytes` if they are valid UTF-8 without NUL, else `null`.
19
+ * Stricter than `Buffer#toString`, which turns bad bytes into U+FFFD.
20
+ */
21
+ export function decodeText(bytes) {
22
+ try {
23
+ const text = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
24
+ return text.includes('\0') ? null : text;
25
+ }
26
+ catch {
27
+ return null;
28
+ }
29
+ }
@@ -0,0 +1,17 @@
1
+ const normalize = (name) => name.trim().toLowerCase();
2
+ /**
3
+ * `base` if no chat has that name yet, else `base (1)`, `base (2)`, … — the
4
+ * first free one. Names compare without regard to case or surrounding blanks,
5
+ * so open chats can always be told apart in the tabs and the menu.
6
+ */
7
+ export function uniqueChatName(base, taken) {
8
+ const used = new Set(taken.map(normalize));
9
+ const wanted = base.trim();
10
+ if (!used.has(normalize(wanted)))
11
+ return wanted;
12
+ for (let n = 1;; n++) {
13
+ const candidate = `${wanted} (${n})`;
14
+ if (!used.has(normalize(candidate)))
15
+ return candidate;
16
+ }
17
+ }
@@ -0,0 +1,6 @@
1
+ /** Chats open at once (ADR 0024); the server refuses more, the UI hides "add". */
2
+ export const MAX_AGENT_SESSIONS = 5;
3
+ /** Raw bytes per attachment; shared by the upload UI and the server. */
4
+ export const MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024;
5
+ export const DEFAULT_FILE_SEARCH_LIMIT = 30;
6
+ export const MAX_FILE_SEARCH_LIMIT = 100;
@@ -0,0 +1,17 @@
1
+ export function formatDiffFile(file) {
2
+ const lines = [
3
+ `--- ${file.oldPath ? `a/${file.oldPath}` : '/dev/null'}`,
4
+ `+++ ${file.newPath ? `b/${file.newPath}` : '/dev/null'}`
5
+ ];
6
+ if (file.isBinary) {
7
+ lines.push('(binary file)');
8
+ }
9
+ for (const hunk of file.hunks) {
10
+ lines.push(`@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`);
11
+ for (const line of hunk.lines) {
12
+ const prefix = line.type === 'added' ? '+' : line.type === 'removed' ? '-' : ' ';
13
+ lines.push(prefix + line.content);
14
+ }
15
+ }
16
+ return lines.join('\n');
17
+ }
@@ -0,0 +1,48 @@
1
+ function isSubsequence(needle, haystack) {
2
+ let at = 0;
3
+ for (const char of haystack) {
4
+ if (char === needle[at]) {
5
+ at++;
6
+ }
7
+ if (at === needle.length) {
8
+ return true;
9
+ }
10
+ }
11
+ return needle.length === 0;
12
+ }
13
+ /** Lower is better; undefined = no match. */
14
+ function score(path, query) {
15
+ const lower = path.toLowerCase();
16
+ const base = lower.slice(lower.lastIndexOf('/') + 1);
17
+ if (base === query)
18
+ return 0;
19
+ if (base.startsWith(query))
20
+ return 1;
21
+ if (base.includes(query))
22
+ return 2;
23
+ if (lower.includes(query))
24
+ return 3;
25
+ if (isSubsequence(query, lower))
26
+ return 4;
27
+ return undefined;
28
+ }
29
+ /**
30
+ * Orders `files` by how well they match `query`: file name first (exact,
31
+ * prefix, substring), then anywhere in the path, then as scattered letters.
32
+ * Ties go to the shorter path. An empty query matches everything.
33
+ */
34
+ export function rankFiles(files, query) {
35
+ const needle = query.trim().toLowerCase();
36
+ const scored = [];
37
+ for (const path of files) {
38
+ const rank = needle === '' ? 0 : score(path, needle);
39
+ if (rank !== undefined) {
40
+ scored.push({ path, rank });
41
+ }
42
+ }
43
+ return scored
44
+ .sort((a, b) => a.rank - b.rank ||
45
+ a.path.length - b.path.length ||
46
+ a.path.localeCompare(b.path))
47
+ .map(entry => entry.path);
48
+ }