@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.
- package/README.md +27 -13
- package/dist/cli/commands/export.js +4 -3
- package/dist/cli/index.js +7 -4
- package/dist/cli/server-runtime.js +13 -1
- package/dist/cli/startup-message.js +3 -2
- package/dist/server/adapters/agents/event-mapper.js +140 -0
- package/dist/server/app.js +27 -14
- package/dist/server/config/agents.js +141 -0
- package/dist/server/errors/agents.js +12 -0
- package/dist/server/errors/comments.js +2 -0
- package/dist/server/errors/export.js +2 -0
- package/dist/server/errors/http.js +4 -0
- package/dist/server/errors/reviews.js +3 -0
- package/dist/server/hooks/agents/local-origin.js +33 -0
- package/dist/server/repositories/review.repo.js +1 -3
- package/dist/server/routes/agent.js +135 -0
- package/dist/server/routes/comments.js +25 -72
- package/dist/server/routes/diff.js +5 -6
- package/dist/server/routes/events.js +9 -3
- package/dist/server/routes/export.js +7 -16
- package/dist/server/routes/git.js +12 -21
- package/dist/server/routes/reviews.js +11 -43
- package/dist/server/services/agent/agent-session.js +497 -0
- package/dist/server/services/agent/context-builder.js +137 -0
- package/dist/server/services/agent/file-index.js +58 -0
- package/dist/server/services/agent/session-registry.js +100 -0
- package/dist/server/services/comment.service.js +1 -2
- package/dist/server/services/diff.service.js +2 -1
- package/dist/server/services/export.service.js +208 -28
- package/dist/server/services/git.service.js +9 -7
- package/dist/server/services/review.service.js +3 -3
- package/dist/server/utils/executable.js +26 -0
- package/dist/server/utils/file-name.js +6 -0
- package/dist/server/utils/sse.js +11 -0
- package/dist/server/utils/text.js +29 -0
- package/dist/shared/agents/chat-names.js +17 -0
- package/dist/shared/agents/constants.js +6 -0
- package/dist/shared/agents/context-format.js +17 -0
- package/dist/shared/agents/file-search.js +48 -0
- package/dist/shared/agents/schemas.js +51 -0
- package/dist/shared/agents/types.js +11 -0
- package/dist/shared/app/types.js +1 -0
- package/dist/shared/comments/types.js +1 -0
- package/dist/shared/events/types.js +14 -0
- package/dist/shared/git/types.js +1 -0
- package/dist/shared/http/types.js +1 -0
- package/dist/shared/network/loopback.js +4 -0
- package/dist/shared/reviews/types.js +1 -0
- package/dist/shared/utils/error-message.js +3 -0
- package/dist/shared/utils/guards.js +4 -0
- package/dist/web/assets/AppPage-CSb8txs5.js +1 -0
- package/dist/web/assets/CreateReviewFabButton-B18DLuH6.js +1 -0
- package/dist/web/assets/CreateReviewFabButton-Cufd8hVJ.css +1 -0
- package/dist/web/assets/{ErrorNotFound-DMYc7a3H.js → ErrorNotFound-B5uYICL4.js} +1 -1
- package/dist/web/assets/IndexPage-B-hkrnvt.css +1 -0
- package/dist/web/assets/IndexPage-Cm_ssBeC.js +1 -0
- package/dist/web/assets/MainLayout-ChxXEt_Q.css +1 -0
- package/dist/web/assets/MainLayout-ClBPF545.js +7 -0
- package/dist/web/assets/QBtn-E32fUOKx.js +1 -0
- package/dist/web/assets/QItemLabel-Co6oCEUr.js +1 -0
- package/dist/web/assets/QMenu-BZ1eK_rS.js +1 -0
- package/dist/web/assets/QSelect-D1V4tbDr.js +1 -0
- package/dist/web/assets/ReviewDetailPage-BWa3EEkv.css +1 -0
- package/dist/web/assets/ReviewDetailPage-Ct0hXwZL.js +1 -0
- package/dist/web/assets/ReviewsPage-CbLAU6OM.js +1 -0
- package/dist/web/assets/ReviewsPage-Lb74-4L2.css +1 -0
- package/dist/web/assets/StatusSwitcher-C0k4efwK.js +4 -0
- package/dist/web/assets/StatusSwitcher-CZYtI0ID.css +1 -0
- package/dist/web/assets/abnfDiagram-O67JEVCF-DkOrqZLn.js +1 -0
- package/dist/web/assets/agent-icon-BKoq2a6T.js +17 -0
- package/dist/web/assets/agent-icon-BUd-vCRS.css +1 -0
- package/dist/web/assets/agent-store-CEQ6MmTC.js +1 -0
- package/dist/web/assets/app-info-store-CIqmSeWK.js +1 -0
- package/dist/web/assets/arc-BALAYY3f.js +1 -0
- package/dist/web/assets/architecture-7GRP2DOG-CTkghkmX.js +1 -0
- package/dist/web/assets/architectureDiagram-NJMV4G6O-C-xtrMea.js +36 -0
- package/dist/web/assets/array-BifhSqXX.js +1 -0
- package/dist/web/assets/blockDiagram-BEXU5L5S-DYo9Os-m.js +140 -0
- package/dist/web/assets/c4Diagram-YGBWAQC7-BpSFjUAX.js +38 -0
- package/dist/web/assets/channel-f9cE6NV9.js +1 -0
- package/dist/web/assets/chunk-2Q5K7J3B-C1jixKkw.js +1 -0
- package/dist/web/assets/chunk-3FUC2YCW-DtUqbu5K.js +2 -0
- package/dist/web/assets/chunk-5DYCD2WN-DeYTIM8k.js +1 -0
- package/dist/web/assets/chunk-5VM5RSS4-ZNzvKenW.js +15 -0
- package/dist/web/assets/chunk-742MDFTN-8BfKAsu6.js +1 -0
- package/dist/web/assets/chunk-7INBJB4K-PXK092EZ.js +62 -0
- package/dist/web/assets/chunk-7M6MHVWA-Db9reT-V.js +213 -0
- package/dist/web/assets/chunk-7PRAP22T-CNFpjNaH.js +1 -0
- package/dist/web/assets/chunk-DUW6YSOI-Dgx8z5s3.js +1 -0
- package/dist/web/assets/chunk-FOHPRMQF-CoA4358r.js +161 -0
- package/dist/web/assets/chunk-GTNCS2PH-C5gqOp1W.js +88 -0
- package/dist/web/assets/chunk-GWA4HPMP-BquHLG8L.js +1 -0
- package/dist/web/assets/chunk-J5ZVWO5B-oDCd-gWO.js +1 -0
- package/dist/web/assets/chunk-JWPE2WC7-DVXcaiue.js +1 -0
- package/dist/web/assets/chunk-MBY4JIJT-DyOEFgCh.js +72 -0
- package/dist/web/assets/chunk-NETBCI7D-Bd3ij3-b.js +1 -0
- package/dist/web/assets/chunk-O7XYJQB3-DQvIvDe6.js +126 -0
- package/dist/web/assets/chunk-UA2S7LBM-BDIHwgCZ.js +1 -0
- package/dist/web/assets/chunk-WEXAMYUT-DwQKTsK-.js +1 -0
- package/dist/web/assets/chunk-XXDRQBXY-DTdiON6C.js +1 -0
- package/dist/web/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +1 -0
- package/dist/web/assets/chunk-Z7XXMR3K-CIvnaGnU.js +10 -0
- package/dist/web/assets/chunk-ZIGJFQKS-BdT_T5CV.js +2 -0
- package/dist/web/assets/classDiagram-v2-NBCMYWYE-BUoclHlO.js +217 -0
- package/dist/web/assets/{core-Ruj7F7dx.js → core-B8kUiZoM.js} +1 -1
- package/dist/web/assets/cose-bilkent-JH36ORCC-C16eW6pc.js +1 -0
- package/dist/web/assets/create-B9ZqyTxV.js +1 -0
- package/dist/web/assets/cynefin-OW5HDTMX-1mS7ZeoM.js +1 -0
- package/dist/web/assets/cynefinDiagram-VND7K2PF-D1PC4YT4.js +62 -0
- package/dist/web/assets/cytoscape.esm-B-1iW2ft.js +321 -0
- package/dist/web/assets/dagre-6A5THRUB-amODOP74.js +4 -0
- package/dist/web/assets/debounce-CDIE7CV6.js +1 -0
- package/dist/web/assets/defaultLocale-BFoDCU3G.js +1 -0
- package/dist/web/assets/diagram-22UHCM2B-wUvcsN_B.js +200 -0
- package/dist/web/assets/diagram-3UASUU5V-CLiOVs2l.js +24 -0
- package/dist/web/assets/diagram-ATOU4E4O-Cs5cFGgz.js +3 -0
- package/dist/web/assets/diagram-CDSNMT55-C8Jb-YeE.js +30 -0
- package/dist/web/assets/diagram-MLGK6HIB-DA6C9fVB.js +24 -0
- package/dist/web/assets/diagram-MPIPVDR6-BOaFKuUi.js +41 -0
- package/dist/web/assets/dist-DuFVAX6Y.js +1 -0
- package/dist/web/assets/ebnfDiagram-ZINNZB2B-e5jvn-EE.js +1 -0
- package/dist/web/assets/elk-276RUBZZ-D7Gcu17D.js +27 -0
- package/dist/web/assets/engine-oniguruma-HbxpIAu4.js +1 -0
- package/dist/web/assets/erDiagram-OPXOYQCR-Cn1bBUF8.js +99 -0
- package/dist/web/assets/event-rR76ioXz.js +1 -0
- package/dist/web/assets/eventmodeling-NTZA5JFV-BjGpd42n.js +1 -0
- package/dist/web/assets/export-file-Di_2jvjP.js +1 -0
- package/dist/web/assets/fira-code-symbols2-400-normal-9X26I2LK.woff2 +0 -0
- package/dist/web/assets/fira-code-symbols2-500-normal-B5s_njw3.woff2 +0 -0
- package/dist/web/assets/fira-code-symbols2-600-normal-Bu3RnO39.woff2 +0 -0
- package/dist/web/assets/flowDiagram-KWPJA3E3-a3N8MVB8.js +1 -0
- package/dist/web/assets/ganttDiagram-FUAMR5RP-C3U8jeJB.js +292 -0
- package/dist/web/assets/gitGraph-4MIJSDKK-C165DC0H.js +1 -0
- package/dist/web/assets/gitGraphDiagram-X574FWY7-CVHdT1OC.js +106 -0
- package/dist/web/assets/golos-text-cyrillic-400-normal-BwL4n7Pb.woff +0 -0
- package/dist/web/assets/golos-text-cyrillic-400-normal-C7us6pn1.woff2 +0 -0
- package/dist/web/assets/golos-text-cyrillic-500-normal-BSLQUuP1.woff2 +0 -0
- package/dist/web/assets/golos-text-cyrillic-500-normal-hXinzVVQ.woff +0 -0
- package/dist/web/assets/golos-text-cyrillic-ext-400-normal-Bo67NYBq.woff2 +0 -0
- package/dist/web/assets/golos-text-cyrillic-ext-400-normal-D592xEk3.woff +0 -0
- package/dist/web/assets/golos-text-cyrillic-ext-500-normal-CLsu_eDI.woff2 +0 -0
- package/dist/web/assets/golos-text-cyrillic-ext-500-normal-InHNvB7v.woff +0 -0
- package/dist/web/assets/golos-text-latin-400-normal-Coi1FYaD.woff2 +0 -0
- package/dist/web/assets/golos-text-latin-400-normal-DOuJOmdK.woff +0 -0
- package/dist/web/assets/golos-text-latin-500-normal-BQo4s7Kn.woff +0 -0
- package/dist/web/assets/golos-text-latin-500-normal-BznAvurO.woff2 +0 -0
- package/dist/web/assets/golos-text-latin-ext-400-normal-BXgyX_iE.woff +0 -0
- package/dist/web/assets/golos-text-latin-ext-400-normal-BjKcJWKY.woff2 +0 -0
- package/dist/web/assets/golos-text-latin-ext-500-normal-9vZPKadr.woff2 +0 -0
- package/dist/web/assets/golos-text-latin-ext-500-normal-BmtMVntR.woff +0 -0
- package/dist/web/assets/graphlib-DS17s2tU.js +1 -0
- package/dist/web/assets/i18n-CyUPETur.js +1 -0
- package/dist/web/assets/{index-JAYe_SFI.css → index-C6ArNnKs.css} +1 -1
- package/dist/web/assets/index-C_B6Z2lc.js +2 -0
- package/dist/web/assets/info-A6RAGUB7-cbWtF3FJ.js +1 -0
- package/dist/web/assets/infoDiagram-VRGFBTTK-C_kWn3Ai.js +2 -0
- package/dist/web/assets/init-C-OQMol4.js +1 -0
- package/dist/web/assets/init-app-DnfYD-0k.js +1 -0
- package/dist/web/assets/is-ChHvR1Z-.js +1 -0
- package/dist/web/assets/ishikawaDiagram-OU5B5YK6-Xb9cDRr7.js +70 -0
- package/dist/web/assets/jetbrains-mono-cyrillic-ext-400-normal-Bh0R7Dhr.woff +0 -0
- package/dist/web/assets/jetbrains-mono-cyrillic-ext-400-normal-C_uLvvQ5.woff2 +0 -0
- package/dist/web/assets/jetbrains-mono-cyrillic-ext-500-normal-C9ShMxGR.woff2 +0 -0
- package/dist/web/assets/jetbrains-mono-cyrillic-ext-500-normal-CEVyPk4Y.woff +0 -0
- package/dist/web/assets/jetbrains-mono-cyrillic-ext-600-normal-DVvC-yKp.woff +0 -0
- package/dist/web/assets/jetbrains-mono-cyrillic-ext-600-normal-Dj6eZy5f.woff2 +0 -0
- package/dist/web/assets/jetbrains-mono-vietnamese-400-normal-ByoDsISC.woff2 +0 -0
- package/dist/web/assets/jetbrains-mono-vietnamese-500-normal-BvH7FW2L.woff2 +0 -0
- package/dist/web/assets/jetbrains-mono-vietnamese-600-normal-Dtizs43-.woff2 +0 -0
- package/dist/web/assets/journeyDiagram-ZHPQQLJL-6eZcefap.js +139 -0
- package/dist/web/assets/kanban-definition-PNTS6WVX-D_z_AsKR.js +89 -0
- package/dist/web/assets/katex-ZlcWpGUi.js +257 -0
- package/dist/web/assets/line-v7JtZ-Za.js +1 -0
- package/dist/web/assets/linear-hp1KWmiC.js +1 -0
- package/dist/web/assets/mermaid-parser.core-Cwwk5NqO.js +7 -0
- package/dist/web/assets/mermaid.core-BjPJhwW0.js +44 -0
- package/dist/web/assets/mindmap-definition-NLK3R4M7-lQohhDXq.js +96 -0
- package/dist/web/assets/ordinal-BDEzSJ7C.js +1 -0
- package/dist/web/assets/packet-AYTQ26CC-DxiGe6nt.js +1 -0
- package/dist/web/assets/path-fybaL0A-.js +1 -0
- package/dist/web/assets/pegDiagram-GJSIUBJH-XZRpQTLx.js +1 -0
- package/dist/web/assets/pie-WAS4IAKB-fxN0O90k.js +1 -0
- package/dist/web/assets/pieDiagram-5QR66LMP-M_bBKE-n.js +39 -0
- package/dist/web/assets/pinia-0B8sIOgD.js +1 -0
- package/dist/web/assets/pinia-DbmXdZbT.js +1 -0
- package/dist/web/assets/quadrantDiagram-O4NWA36T-BNxVPrq3.js +7 -0
- package/dist/web/assets/radar-RG4KPBEZ-Cd-5aokk.js +1 -0
- package/dist/web/assets/railroad-74A4TZTK-BvSKL8Ze.js +1 -0
- package/dist/web/assets/railroad-abnf-HS5TGJTU-CB32K25L.js +1 -0
- package/dist/web/assets/railroad-ebnf-LZEXJU2U-CqQR3zRl.js +1 -0
- package/dist/web/assets/railroad-peg-WCYAUIDC-DRSdB-oL.js +1 -0
- package/dist/web/assets/railroadDiagram-XR7U4H2S-D664lPSL.js +1 -0
- package/dist/web/assets/requirementDiagram-PLB6GJNP-B_1Dl0-3.js +84 -0
- package/dist/web/assets/rough.esm-Dy-Kn_BL.js +1 -0
- package/dist/web/assets/runtime-core.esm-bundler-Carm8LUR.js +1 -0
- package/dist/web/assets/safe-storage-CWaD6Dq3.js +1 -0
- package/dist/web/assets/sankeyDiagram-IPEJSGJF-BvFmMC_F.js +40 -0
- package/dist/web/assets/sequenceDiagram-PO4LG4MO-DMK1ey4g.js +169 -0
- package/dist/web/assets/single-flight-r8B7stt1.js +1 -0
- package/dist/web/assets/sizeCapture-INFHLROL-B0uUizjq.js +1 -0
- package/dist/web/assets/src-Cy0hT8a0.js +1 -0
- package/dist/web/assets/stateDiagram-v2-GCMORJYK-VVElU-_E.js +291 -0
- package/dist/web/assets/swimlanes-2SLR337P-DyfYJvHL.js +1 -0
- package/dist/web/assets/swimlanesDiagram-TC7HE7FX-BQ0mK2BI.js +8 -0
- package/dist/web/assets/theme-BFceB3b9.js +1 -0
- package/dist/web/assets/timeline-definition-EJHVYXUP-DBoRb7uC.js +120 -0
- package/dist/web/assets/treeView-Q6P3EWNA-D8AoZg1Y.js +1 -0
- package/dist/web/assets/treemap-WGGIJYW6-DhcVtZsu.js +1 -0
- package/dist/web/assets/use-app-theme-DxZW-eOR.js +1 -0
- package/dist/web/assets/use-panel-width-CY75Fomr.js +1 -0
- package/dist/web/assets/usecaseDiagram-POWQR4AR-BCfvWOH4.js +328 -0
- package/dist/web/assets/vennDiagram-UO4OBE2U-BIk6-FLo.js +34 -0
- package/dist/web/assets/{vue-i18n.runtime-DSeWNwrW.js → vue-i18n.runtime-BQwnBdmC.js} +2 -2
- package/dist/web/assets/wardley-WFR3VGLG-BMHH70A3.js +1 -0
- package/dist/web/assets/wardleyDiagram-VNRHLVJA-DB-cxlSq.js +78 -0
- package/dist/web/assets/xychartDiagram-PMCCYNJV-BejE-3gi.js +7 -0
- package/dist/web/index.html +8 -6
- package/package.json +18 -8
- package/dist/web/assets/AppPage-BeqKILJT.js +0 -1
- package/dist/web/assets/CreateReviewFabButton-BqWdgfEf.css +0 -1
- package/dist/web/assets/CreateReviewFabButton-wjxKeOL1.js +0 -1
- package/dist/web/assets/IndexPage-BQ0T9rLY.css +0 -1
- package/dist/web/assets/IndexPage-CSaToIzA.js +0 -1
- package/dist/web/assets/MainLayout-BRPoh8y0.css +0 -1
- package/dist/web/assets/MainLayout-xQ92tgGe.js +0 -1
- package/dist/web/assets/Platform-CEDL_PgC.js +0 -1
- package/dist/web/assets/QBtn-Cf6WtiKt.js +0 -1
- package/dist/web/assets/QTooltip-IUM54Nhj.js +0 -1
- package/dist/web/assets/ReviewDetailPage-DJHzinT1.css +0 -1
- package/dist/web/assets/ReviewDetailPage-DdufL7Rh.js +0 -1
- package/dist/web/assets/ReviewsPage-D8u1IeQ4.js +0 -1
- package/dist/web/assets/ReviewsPage-D9TFy2OP.css +0 -1
- package/dist/web/assets/StatusSwitcher-7Yqs8azq.css +0 -1
- package/dist/web/assets/StatusSwitcher-CUOMaD34.js +0 -3
- package/dist/web/assets/app-info-store-BpeVVjNB.js +0 -1
- package/dist/web/assets/client-De34x_P_.js +0 -1
- package/dist/web/assets/engine-oniguruma-Bon2Icja.js +0 -1
- package/dist/web/assets/event-BQt_DXaR.js +0 -1
- package/dist/web/assets/format-BWCY39oA.js +0 -1
- package/dist/web/assets/i18n-DHrLpaJV.js +0 -1
- package/dist/web/assets/index-DphTOTJs.js +0 -2
- package/dist/web/assets/init-app-5wT4mURp.js +0 -1
- package/dist/web/assets/pinia-CrBjT8X4.js +0 -1
- package/dist/web/assets/pinia-DK4OjQ7o.js +0 -1
- package/dist/web/assets/runtime-core.esm-bundler-Wxr8ezGp.js +0 -1
- package/dist/web/assets/theme-CrTbazqt.js +0 -1
- package/dist/web/assets/use-app-theme-CsAmLZfp.js +0 -1
- /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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
38
|
-
|
|
39
|
-
|
|
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.
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
66
|
-
|
|
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
|
|
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
|
|
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 {
|
|
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
|
+
}
|