diffing 0.4.1 → 0.5.1
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 +41 -7
- package/dist/cli-agent-BatQgghr.mjs +499 -0
- package/dist/cli-agent-BwZE05CQ.mjs +499 -0
- package/dist/cli-agent-CKE1Cvor.mjs +513 -0
- package/dist/cli-agent-CLvCYpmH.mjs +499 -0
- package/dist/cli-agent-CX8Fa1Ph.mjs +499 -0
- package/dist/cli-agent-SILzesQP.mjs +513 -0
- package/dist/cli-agent-USzLoAit.mjs +499 -0
- package/dist/cli-gh-BV5az425.mjs +147 -0
- package/dist/cli-gh-Bo6fOHQR.mjs +147 -0
- package/dist/cli-gh-D1LoWJl-.mjs +147 -0
- package/dist/cli-gh-D1sIZ-jX.mjs +147 -0
- package/dist/cli-gh-DROAJlHi.mjs +147 -0
- package/dist/cli-gh-DiT8BWwu.mjs +147 -0
- package/dist/cli.mjs +97 -3381
- package/dist/client/assets/{angular-html-DIKVmS-w.js → angular-html-CDZ9SrQX.js} +1 -1
- package/dist/client/assets/{angular-ts-DK13mfBQ.js → angular-ts-CNMbVia5.js} +1 -1
- package/dist/client/assets/{apl-NVZUxqwo.js → apl-BqNxQgsm.js} +1 -1
- package/dist/client/assets/{arc-kijUuucY.js → arc-BrMp-_55.js} +1 -1
- package/dist/client/assets/{architectureDiagram-3BPJPVTR-Dup-CzBS.js → architectureDiagram-3BPJPVTR-BIZJkdXp.js} +1 -1
- package/dist/client/assets/{astro-RPdCumat.js → astro-r55-Uy0e.js} +1 -1
- package/dist/client/assets/{blade-_ubz_KMD.js → blade-F8SZw05t.js} +1 -1
- package/dist/client/assets/{blockDiagram-GPEHLZMM-Cl0-G1MY.js → blockDiagram-GPEHLZMM-DlzFca7z.js} +1 -1
- package/dist/client/assets/{c-IEKcSTtW.js → c-DMmuyoXv.js} +1 -1
- package/dist/client/assets/{c4Diagram-AAUBKEIU-C3T6FV-K.js → c4Diagram-AAUBKEIU-BqJFYdcn.js} +1 -1
- package/dist/client/assets/channel-DVxVbqCb.js +1 -0
- package/dist/client/assets/{chunk-2J33WTMH-B5MFxFjX.js → chunk-2J33WTMH-CRN3t_Zt.js} +1 -1
- package/dist/client/assets/{chunk-3OPIFGDE-DWxCOZgI.js → chunk-3OPIFGDE-Bb8pGWXE.js} +1 -1
- package/dist/client/assets/{chunk-4BX2VUAB-DWdovlBC.js → chunk-4BX2VUAB--s6olL4E.js} +1 -1
- package/dist/client/assets/{chunk-55IACEB6-CFSaZijP.js → chunk-55IACEB6-C-iaNYfB.js} +1 -1
- package/dist/client/assets/{chunk-5ZQYHXKU-BH_cLkIj.js → chunk-5ZQYHXKU-Bvzi1CUr.js} +1 -1
- package/dist/client/assets/{chunk-727SXJPM-BbsJC2PT.js → chunk-727SXJPM-CjoyjZeg.js} +1 -1
- package/dist/client/assets/{chunk-AQP2D5EJ-tFAP6fC1.js → chunk-AQP2D5EJ-BJJOqjwm.js} +1 -1
- package/dist/client/assets/{chunk-BSJP7CBP-BGuhsdwy.js → chunk-BSJP7CBP-A0UL_0xV.js} +1 -1
- package/dist/client/assets/{chunk-CSCIHK7Q-DXSJWhsY.js → chunk-CSCIHK7Q-DW570vKT.js} +1 -1
- package/dist/client/assets/{chunk-FMBD7UC4-6vBylg_I.js → chunk-FMBD7UC4-CzNJ-Iy-.js} +1 -1
- package/dist/client/assets/{chunk-KSCS5N6A-C7iBFsUg.js → chunk-KSCS5N6A-DCbYTAZg.js} +1 -1
- package/dist/client/assets/{chunk-L5ZTLDWV-CRU3nvaK.js → chunk-L5ZTLDWV-BMdyyjjc.js} +1 -1
- package/dist/client/assets/{chunk-LZXEDZCA-6ppA-_4h.js → chunk-LZXEDZCA-BENQV-I4.js} +2 -2
- package/dist/client/assets/{chunk-ND2GUHAM-D1cTmgHe.js → chunk-ND2GUHAM-C1y3WzOx.js} +1 -1
- package/dist/client/assets/{chunk-NZK2D7GU-CpUab6v7.js → chunk-NZK2D7GU-BkBT6O_M.js} +1 -1
- package/dist/client/assets/{chunk-O5CBEL6O-cDxBycWe.js → chunk-O5CBEL6O-jdz4J9aA.js} +1 -1
- package/dist/client/assets/chunk-QZHKN3VN-mumapnEV.js +1 -0
- package/dist/client/assets/chunk-WU5MYG2G-Bsqs8ZKl.js +1 -0
- package/dist/client/assets/{chunk-XPW4576I-CI9GEEdy.js → chunk-XPW4576I-Bf8OI8wY.js} +1 -1
- package/dist/client/assets/classDiagram-4FO5ZUOK-CKaaw3uU.js +1 -0
- package/dist/client/assets/classDiagram-v2-Q7XG4LA2-BxwzeqDR.js +1 -0
- package/dist/client/assets/{cobol-BVhbHJgp.js → cobol-BanHX5pe.js} +1 -1
- package/dist/client/assets/{coffee-gY6asS_B.js → coffee-CACo0LEP.js} +1 -1
- package/dist/client/assets/{cose-bilkent-S5V4N54A-BOs7zxZa.js → cose-bilkent-S5V4N54A-7mNCsL0j.js} +1 -1
- package/dist/client/assets/{cpp-el3R6exS.js → cpp-4Kwtmkwn.js} +1 -1
- package/dist/client/assets/{crystal-D2_0btIo.js → crystal-D1kAaPsE.js} +1 -1
- package/dist/client/assets/{css-DtPe7dg4.js → css-CCMEBYEn.js} +1 -1
- package/dist/client/assets/{dagre-BM42HDAG-CR_0cBOz.js → dagre-BM42HDAG-BTEYZegB.js} +1 -1
- package/dist/client/assets/{diagram-2AECGRRQ-D9um0wp2.js → diagram-2AECGRRQ-Cj_XZNTN.js} +1 -1
- package/dist/client/assets/{diagram-5GNKFQAL-Dx0TEfBJ.js → diagram-5GNKFQAL-DVTFfYPd.js} +1 -1
- package/dist/client/assets/{diagram-KO2AKTUF-DjBHOuUD.js → diagram-KO2AKTUF-C7BU5Ncp.js} +1 -1
- package/dist/client/assets/{diagram-LMA3HP47-CK2MO5Gx.js → diagram-LMA3HP47-06rwT7mb.js} +1 -1
- package/dist/client/assets/{diagram-OG6HWLK6-CANQancN.js → diagram-OG6HWLK6-COEuKA9B.js} +1 -1
- package/dist/client/assets/{dist-DGlwcsQQ.js → dist-C-G1vI6B.js} +1 -1
- package/dist/client/assets/{edge-Ch_PYBsB.js → edge-BV-WbkC9.js} +1 -1
- package/dist/client/assets/{elixir-DEJrJATq.js → elixir-C6pMvu6H.js} +1 -1
- package/dist/client/assets/{elm-Brlko4-d.js → elm-D8LnUrCG.js} +1 -1
- package/dist/client/assets/{erDiagram-TEJ5UH35-Dnfrs6yk.js → erDiagram-TEJ5UH35-SPxPVg85.js} +1 -1
- package/dist/client/assets/{erb-DQPphNua.js → erb-C6ADBE0Q.js} +1 -1
- package/dist/client/assets/{flowDiagram-I6XJVG4X-BNMdFtjG.js → flowDiagram-I6XJVG4X-DyEhCtUv.js} +1 -1
- package/dist/client/assets/{ganttDiagram-6RSMTGT7-X-fPXz_A.js → ganttDiagram-6RSMTGT7-fj-zRHIk.js} +1 -1
- package/dist/client/assets/{git-rebase-BpS915SI.js → git-rebase-BhYZ4R3x.js} +1 -1
- package/dist/client/assets/{gitGraphDiagram-PVQCEYII-CRN_dRXM.js → gitGraphDiagram-PVQCEYII-CpUJh4CR.js} +1 -1
- package/dist/client/assets/{glimmer-js-DZDngVuD.js → glimmer-js-D2CVO-NF.js} +1 -1
- package/dist/client/assets/{glimmer-ts-DKNjJvyp.js → glimmer-ts-BjYisPeM.js} +1 -1
- package/dist/client/assets/{glsl-C-Y1tHoU.js → glsl-qP0lyfIY.js} +1 -1
- package/dist/client/assets/{graphql-DYVBNazO.js → graphql-BZE_lBur.js} +1 -1
- package/dist/client/assets/{hack-DewKAtLe.js → hack-C6KM9SLG.js} +1 -1
- package/dist/client/assets/{haml-CIA_AeXu.js → haml-BT3JKqL8.js} +1 -1
- package/dist/client/assets/{handlebars-Bade6JXX.js → handlebars-SHKYyRQ6.js} +1 -1
- package/dist/client/assets/{html-C4hhbuGp.js → html-CAc9HUGa.js} +1 -1
- package/dist/client/assets/{html-derivative-BXfztBtv.js → html-derivative-Cu6n_YRN.js} +1 -1
- package/dist/client/assets/{http-Cq90-CTl.js → http-o21PuOy8.js} +1 -1
- package/dist/client/assets/{hurl-DklQHYoa.js → hurl-ClrxMWQi.js} +1 -1
- package/dist/client/assets/{index-j9gqGQit.js → index-BPI8nJUS.js} +98 -98
- package/dist/client/assets/index-F_YfUisJ.css +1 -0
- package/dist/client/assets/{infoDiagram-5YYISTIA-Ht-AcvP4.js → infoDiagram-5YYISTIA-DDYGwUUs.js} +1 -1
- package/dist/client/assets/{ishikawaDiagram-YF4QCWOH-k_l70iFb.js → ishikawaDiagram-YF4QCWOH-Benmqbm6.js} +1 -1
- package/dist/client/assets/{java-BMUwLm1h.js → java-Bmlmy6B3.js} +1 -1
- package/dist/client/assets/{javascript-B8nPCUVp.js → javascript-Bdrc0lyI.js} +1 -1
- package/dist/client/assets/{jinja-CkxiRcB3.js → jinja-DBkct7nV.js} +1 -1
- package/dist/client/assets/{jison-CFnN5uQs.js → jison-Tb1xkShc.js} +1 -1
- package/dist/client/assets/{journeyDiagram-JHISSGLW-C98PUC9Y.js → journeyDiagram-JHISSGLW-CCkv1GnE.js} +1 -1
- package/dist/client/assets/{json-B7HNnjIx.js → json-DwLxGyui.js} +1 -1
- package/dist/client/assets/{jsx-BfVPuW3i.js → jsx-DWSSTqEB.js} +1 -1
- package/dist/client/assets/{julia-Bj0ETGjT.js → julia-LUN3XhcV.js} +1 -1
- package/dist/client/assets/{just-CqIe_c9k.js → just-C6LrD5tm.js} +1 -1
- package/dist/client/assets/{kanban-definition-UN3LZRKU-BE-uvH7w.js → kanban-definition-UN3LZRKU-CQSBf6Ys.js} +1 -1
- package/dist/client/assets/{latex-Cvy8E64e.js → latex-aaU-rirg.js} +1 -1
- package/dist/client/assets/{line-BpooNUgm.js → line-DG7hZYkR.js} +1 -1
- package/dist/client/assets/{linear-CM6ZoyCp.js → linear-A5s-ps1u.js} +1 -1
- package/dist/client/assets/{liquid-x09l8QqE.js → liquid-BhVOoUfG.js} +1 -1
- package/dist/client/assets/{lua-BSlDLjt5.js → lua-BTF9vmdD.js} +1 -1
- package/dist/client/assets/{marko-CoNMQlTn.js → marko-DlwBrpd_.js} +1 -1
- package/dist/client/assets/{mdc-BSzVESPu.js → mdc-H8mdhEag.js} +1 -1
- package/dist/client/assets/{mermaid-parser.core-BDXJMxHu.js → mermaid-parser.core-BKvEMhB3.js} +1 -1
- package/dist/client/assets/{mermaid.core-Dw-_vXTc.js → mermaid.core-BaDGp29W.js} +3 -3
- package/dist/client/assets/{mindmap-definition-RKZ34NQL-BTxGT8rM.js → mindmap-definition-RKZ34NQL-BCrf7cJv.js} +1 -1
- package/dist/client/assets/{nginx-CBhJTj0i.js → nginx-DoaqXoFJ.js} +1 -1
- package/dist/client/assets/{nim-CtHbOBlO.js → nim-DqoAP4Np.js} +1 -1
- package/dist/client/assets/{perl-DLMzG3tu.js → perl-D1ou0isN.js} +1 -1
- package/dist/client/assets/{php-KomVR3Hz.js → php-DL6kQj3i.js} +1 -1
- package/dist/client/assets/{pieDiagram-4H26LBE5-BBUObAJW.js → pieDiagram-4H26LBE5-CF0X4ECG.js} +1 -1
- package/dist/client/assets/{pug-T4SRLx8P.js → pug-Bn3xDPVT.js} +1 -1
- package/dist/client/assets/{qml-DJdSRUmU.js → qml-CaA5S6hT.js} +1 -1
- package/dist/client/assets/{quadrantDiagram-W4KKPZXB-B7vvauZv.js → quadrantDiagram-W4KKPZXB-A76pK4Cy.js} +1 -1
- package/dist/client/assets/{r-CwuTg9lK.js → r-DuUvfJBZ.js} +1 -1
- package/dist/client/assets/{razor-Ch4dgsGY.js → razor-DOv4xAWl.js} +1 -1
- package/dist/client/assets/{regexp-D2GTwJE0.js → regexp-DXetztjB.js} +1 -1
- package/dist/client/assets/{requirementDiagram-4Y6WPE33-DpcNiR3Y.js → requirementDiagram-4Y6WPE33-Cju--fB8.js} +1 -1
- package/dist/client/assets/{rst-D78f0DyR.js → rst-CDn5EUee.js} +1 -1
- package/dist/client/assets/{ruby-BUcw6ITs.js → ruby-40qcnJX-.js} +1 -1
- package/dist/client/assets/{sankeyDiagram-5OEKKPKP-ChzSQD2O.js → sankeyDiagram-5OEKKPKP-CvkQY1O-.js} +1 -1
- package/dist/client/assets/{sas-DoT2JDsj.js → sas-D3a9oRtu.js} +1 -1
- package/dist/client/assets/{scss-B6G_d-6b.js → scss-BJ8ZKq49.js} +1 -1
- package/dist/client/assets/{sequenceDiagram-3UESZ5HK-CzkdL8s7.js → sequenceDiagram-3UESZ5HK-DKU8lZlC.js} +1 -1
- package/dist/client/assets/{shellscript-DI252Bga.js → shellscript-yTgK8aYT.js} +1 -1
- package/dist/client/assets/{shellsession-BuO_s8FQ.js → shellsession-DrVaQSNg.js} +1 -1
- package/dist/client/assets/{soy-DZTZJTEE.js → soy-BasCadaZ.js} +1 -1
- package/dist/client/assets/{sql-C4isoJr_.js → sql-COJH6ALP.js} +1 -1
- package/dist/client/assets/{src-BMvz5HE8.js → src-BokBJ4oL.js} +1 -1
- package/dist/client/assets/{stata-Dhu6qITh.js → stata-Ble9lhMp.js} +1 -1
- package/dist/client/assets/{stateDiagram-AJRCARHV-DvYcrdap.js → stateDiagram-AJRCARHV-9IU9PT8A.js} +1 -1
- package/dist/client/assets/stateDiagram-v2-BHNVJYJU-Ce46Lf8G.js +1 -0
- package/dist/client/assets/{surrealql-CSDpq5j3.js → surrealql-BZ5cQJLk.js} +1 -1
- package/dist/client/assets/{svelte-C4ITAvxr.js → svelte-CLa9l6VK.js} +1 -1
- package/dist/client/assets/{templ-BTgKA9-7.js → templ-DVKkE2Tb.js} +1 -1
- package/dist/client/assets/{tex-Dg4N9ndP.js → tex-BET3iISl.js} +1 -1
- package/dist/client/assets/{timeline-definition-PNZ67QCA-Djt5Lwk2.js → timeline-definition-PNZ67QCA-CkkYIhNz.js} +1 -1
- package/dist/client/assets/{ts-tags-BufYnYos.js → ts-tags-D5fPAs5W.js} +1 -1
- package/dist/client/assets/{tsx-DmkFP0sw.js → tsx-CfNzz9iB.js} +1 -1
- package/dist/client/assets/{twig-BJv_IWYK.js → twig-_Ml9CBGK.js} +1 -1
- package/dist/client/assets/{typescript-BKp05Qme.js → typescript-DMiV0dhX.js} +1 -1
- package/dist/client/assets/{vennDiagram-CIIHVFJN-DDVADTIa.js → vennDiagram-CIIHVFJN-HUv6L4Dt.js} +1 -1
- package/dist/client/assets/{vue-BsHXCXzh.js → vue-BcbmWIQt.js} +1 -1
- package/dist/client/assets/{vue-html-CCsH-lmo.js → vue-html-CcUgk6WA.js} +1 -1
- package/dist/client/assets/{vue-vine-B5P1JXxo.js → vue-vine-BbIBT3jy.js} +1 -1
- package/dist/client/assets/{wardleyDiagram-YWT4CUSO-yeVKWvtX.js → wardleyDiagram-YWT4CUSO-avUfWkuC.js} +1 -1
- package/dist/client/assets/{xml-CFkhYff0.js → xml-9mtlBTUG.js} +1 -1
- package/dist/client/assets/{xsl-Dc5XRzm6.js → xsl-Dm8BN5j0.js} +1 -1
- package/dist/client/assets/{xychartDiagram-2RQKCTM6--8Ik-9v0.js → xychartDiagram-2RQKCTM6-BSZPOH7-.js} +1 -1
- package/dist/client/assets/{yaml-BTCCxCxi.js → yaml-Ccz1qKlL.js} +1 -1
- package/dist/client/index.html +2 -2
- package/dist/mcp-B6mCC7D3.mjs +1047 -0
- package/dist/mcp-BMTZLQuB.mjs +758 -0
- package/dist/mcp-BUF2nEL7.mjs +1036 -0
- package/dist/mcp-Bpx76OGU.mjs +945 -0
- package/dist/mcp-C1T7LI0H.mjs +1036 -0
- package/dist/mcp-C4Bjmf9m.mjs +1047 -0
- package/dist/mcp-CFxcdEcJ.mjs +733 -0
- package/dist/mcp-CwjThGi2.mjs +913 -0
- package/dist/mcp-D4OsB9lt.mjs +1047 -0
- package/dist/mcp-DJ2batvt.mjs +913 -0
- package/dist/mcp-D_LGvBTw.mjs +1047 -0
- package/dist/mcp-Ds_lPX_o.mjs +913 -0
- package/dist/plan-format-B6nbAw_c.mjs +132 -0
- package/dist/server-95UE5bgh.mjs +3367 -0
- package/dist/server-B0cS95Lf.mjs +3488 -0
- package/dist/server-Bj-6KlK5.mjs +3367 -0
- package/dist/server-Bpf84Il0.mjs +3367 -0
- package/dist/server-CYSpLbdA.mjs +3360 -0
- package/dist/server-ChT2DCaR.mjs +3701 -0
- package/dist/server-DJxysGxV.mjs +3701 -0
- package/dist/server-Dov7jbV3.mjs +3367 -0
- package/dist/server-eQ2E4Axo.mjs +3367 -0
- package/dist/server-lock-C_wPtEsH.mjs +909 -0
- package/dist/server-lock-Cbu3qHdu.mjs +924 -0
- package/dist/server-lock-D32inEn9.mjs +924 -0
- package/dist/server-lock-Ddcat0HF.mjs +851 -0
- package/dist/server-lock-Dq_1CJaF.mjs +1047 -0
- package/dist/server-lock-Ujbcgq28.mjs +954 -0
- package/dist/update-check-CXeSZANo.mjs +129 -0
- package/package.json +3 -2
- package/dist/client/assets/channel-CpWSvbvQ.js +0 -1
- package/dist/client/assets/chunk-QZHKN3VN-HhMIifwl.js +0 -1
- package/dist/client/assets/chunk-WU5MYG2G-CvIzXEms.js +0 -1
- package/dist/client/assets/classDiagram-4FO5ZUOK-DFu-dNVy.js +0 -1
- package/dist/client/assets/classDiagram-v2-Q7XG4LA2-D7Xcgo6q.js +0 -1
- package/dist/client/assets/index-CMyunQt6.css +0 -1
- package/dist/client/assets/stateDiagram-v2-BHNVJYJU-D5igY81I.js +0 -1
|
@@ -0,0 +1,3367 @@
|
|
|
1
|
+
import { C as listRepoFiles, E as toSafeRelativePath, S as isGitRepo, T as isSafePath, _ as getRepoRoot, b as getUntrackedFilePathsAsync, c as formatComments, d as getFileContent, f as getGitDiffAsync, g as getRepoName, h as getProjectStorageDir, l as getBranchName, m as getMergeStatus, p as getHunkHistory, u as getCustomGitDiffAsync, v as getShowDiff, w as revertHunk, x as gitAddFile, y as getTabSizeForFiles } from "./server-lock-Ujbcgq28.mjs";
|
|
2
|
+
import { n as formatPlanReview, r as sectionTitleForLine, t as extractPlanLines } from "./plan-format-B6nbAw_c.mjs";
|
|
3
|
+
import { execFile, execFileSync } from "node:child_process";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { dirname, extname, join, resolve } from "node:path";
|
|
6
|
+
import { existsSync, mkdirSync, readFileSync, watch, writeFileSync } from "node:fs";
|
|
7
|
+
import { parseArgs, promisify } from "node:util";
|
|
8
|
+
import { mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
|
|
9
|
+
import { homedir } from "node:os";
|
|
10
|
+
import { Hono } from "hono";
|
|
11
|
+
import { streamSSE } from "hono/streaming";
|
|
12
|
+
import { serve } from "@hono/node-server";
|
|
13
|
+
//#region src/lib/diff-options.ts
|
|
14
|
+
const DEFAULTS$1 = {
|
|
15
|
+
revisions: [],
|
|
16
|
+
staged: false,
|
|
17
|
+
merge: false,
|
|
18
|
+
pathspecs: [],
|
|
19
|
+
indentHeuristic: true,
|
|
20
|
+
noIndentHeuristic: false,
|
|
21
|
+
ignoreSpaceChange: false,
|
|
22
|
+
ignoreAllSpace: false,
|
|
23
|
+
ignoreBlankLines: false,
|
|
24
|
+
ignoreCrAtEol: false,
|
|
25
|
+
functionContext: false,
|
|
26
|
+
patchWithRaw: false,
|
|
27
|
+
patchWithStat: false,
|
|
28
|
+
compactSummary: false,
|
|
29
|
+
cumulative: false,
|
|
30
|
+
pickaxeAll: false,
|
|
31
|
+
exitCode: false,
|
|
32
|
+
quiet: false,
|
|
33
|
+
noPrefix: false,
|
|
34
|
+
binary: false,
|
|
35
|
+
fullIndex: false,
|
|
36
|
+
text: false,
|
|
37
|
+
textconv: false,
|
|
38
|
+
noExtDiff: false,
|
|
39
|
+
itaVisible: false,
|
|
40
|
+
noRelative: false,
|
|
41
|
+
findCopiesHarder: false,
|
|
42
|
+
check: false,
|
|
43
|
+
outputMode: "auto",
|
|
44
|
+
host: "127.0.0.1",
|
|
45
|
+
noOpen: false,
|
|
46
|
+
help: false,
|
|
47
|
+
version: false,
|
|
48
|
+
includeUnstaged: true,
|
|
49
|
+
includeUntracked: true,
|
|
50
|
+
tui: false,
|
|
51
|
+
gpu: false,
|
|
52
|
+
showMode: false,
|
|
53
|
+
showRevspecs: []
|
|
54
|
+
};
|
|
55
|
+
/**
|
|
56
|
+
* All git-diff option definitions for `node:util.parseArgs`.
|
|
57
|
+
* Categorised to match `git diff --help`.
|
|
58
|
+
*/
|
|
59
|
+
const GIT_DIFF_OPTIONS = {
|
|
60
|
+
staged: { type: "boolean" },
|
|
61
|
+
cached: { type: "boolean" },
|
|
62
|
+
merge: { type: "boolean" },
|
|
63
|
+
"diff-algorithm": { type: "string" },
|
|
64
|
+
"indent-heuristic": {
|
|
65
|
+
type: "boolean",
|
|
66
|
+
default: true
|
|
67
|
+
},
|
|
68
|
+
"no-indent-heuristic": { type: "boolean" },
|
|
69
|
+
anchored: { type: "string" },
|
|
70
|
+
"ignore-space-change": {
|
|
71
|
+
type: "boolean",
|
|
72
|
+
short: "b"
|
|
73
|
+
},
|
|
74
|
+
"ignore-all-space": {
|
|
75
|
+
type: "boolean",
|
|
76
|
+
short: "w"
|
|
77
|
+
},
|
|
78
|
+
"ignore-blank-lines": { type: "boolean" },
|
|
79
|
+
"ignore-cr-at-eol": { type: "boolean" },
|
|
80
|
+
"ws-error-highlight": { type: "string" },
|
|
81
|
+
"unified": {
|
|
82
|
+
type: "string",
|
|
83
|
+
short: "U"
|
|
84
|
+
},
|
|
85
|
+
"inter-hunk-context": { type: "string" },
|
|
86
|
+
"function-context": {
|
|
87
|
+
type: "boolean",
|
|
88
|
+
short: "W"
|
|
89
|
+
},
|
|
90
|
+
"word-diff": { type: "string" },
|
|
91
|
+
"word-diff-regex": { type: "string" },
|
|
92
|
+
"color-words": { type: "string" },
|
|
93
|
+
"color-moved": { type: "string" },
|
|
94
|
+
"color-moved-ws": { type: "string" },
|
|
95
|
+
"find-copies": {
|
|
96
|
+
type: "string",
|
|
97
|
+
short: "C"
|
|
98
|
+
},
|
|
99
|
+
"find-copies-harder": { type: "boolean" },
|
|
100
|
+
"find-renames": {
|
|
101
|
+
type: "string",
|
|
102
|
+
short: "M"
|
|
103
|
+
},
|
|
104
|
+
"break-rewrites": {
|
|
105
|
+
type: "string",
|
|
106
|
+
short: "B"
|
|
107
|
+
},
|
|
108
|
+
patch: {
|
|
109
|
+
type: "boolean",
|
|
110
|
+
short: "p"
|
|
111
|
+
},
|
|
112
|
+
"no-patch": {
|
|
113
|
+
type: "boolean",
|
|
114
|
+
short: "s"
|
|
115
|
+
},
|
|
116
|
+
raw: { type: "boolean" },
|
|
117
|
+
"patch-with-raw": { type: "boolean" },
|
|
118
|
+
"patch-with-stat": { type: "boolean" },
|
|
119
|
+
numstat: { type: "boolean" },
|
|
120
|
+
shortstat: { type: "boolean" },
|
|
121
|
+
stat: { type: "string" },
|
|
122
|
+
summary: { type: "boolean" },
|
|
123
|
+
"name-only": { type: "boolean" },
|
|
124
|
+
"name-status": { type: "boolean" },
|
|
125
|
+
check: { type: "boolean" },
|
|
126
|
+
"compact-summary": { type: "boolean" },
|
|
127
|
+
dirstat: { type: "string" },
|
|
128
|
+
"dirstat-by-file": { type: "string" },
|
|
129
|
+
cumulative: { type: "boolean" },
|
|
130
|
+
"diff-filter": { type: "string" },
|
|
131
|
+
S: { type: "string" },
|
|
132
|
+
G: { type: "string" },
|
|
133
|
+
"pickaxe-all": { type: "boolean" },
|
|
134
|
+
output: {
|
|
135
|
+
type: "string",
|
|
136
|
+
short: "o"
|
|
137
|
+
},
|
|
138
|
+
"exit-code": { type: "boolean" },
|
|
139
|
+
quiet: { type: "boolean" },
|
|
140
|
+
"src-prefix": { type: "string" },
|
|
141
|
+
"dst-prefix": { type: "string" },
|
|
142
|
+
"no-prefix": { type: "boolean" },
|
|
143
|
+
"line-prefix": { type: "string" },
|
|
144
|
+
submodule: { type: "string" },
|
|
145
|
+
binary: { type: "boolean" },
|
|
146
|
+
"no-binary": { type: "boolean" },
|
|
147
|
+
"full-index": { type: "boolean" },
|
|
148
|
+
text: {
|
|
149
|
+
type: "boolean",
|
|
150
|
+
short: "a"
|
|
151
|
+
},
|
|
152
|
+
textconv: { type: "boolean" },
|
|
153
|
+
"no-textconv": { type: "boolean" },
|
|
154
|
+
"no-ext-diff": { type: "boolean" },
|
|
155
|
+
"ext-diff": { type: "string" },
|
|
156
|
+
"ita-visible": { type: "boolean" },
|
|
157
|
+
"ita-invisible-in-index": { type: "boolean" },
|
|
158
|
+
relative: { type: "string" },
|
|
159
|
+
"no-relative": { type: "boolean" },
|
|
160
|
+
"ignore-submodules": { type: "string" }
|
|
161
|
+
};
|
|
162
|
+
/** Options that are diffing-specific, not git-diff options. */
|
|
163
|
+
const DIFFING_OPTIONS = {
|
|
164
|
+
port: { type: "string" },
|
|
165
|
+
host: { type: "string" },
|
|
166
|
+
"no-open": {
|
|
167
|
+
type: "boolean",
|
|
168
|
+
default: false
|
|
169
|
+
},
|
|
170
|
+
web: {
|
|
171
|
+
type: "boolean",
|
|
172
|
+
default: false
|
|
173
|
+
},
|
|
174
|
+
terminal: {
|
|
175
|
+
type: "boolean",
|
|
176
|
+
default: false
|
|
177
|
+
},
|
|
178
|
+
tui: {
|
|
179
|
+
type: "boolean",
|
|
180
|
+
default: false
|
|
181
|
+
},
|
|
182
|
+
gpu: {
|
|
183
|
+
type: "boolean",
|
|
184
|
+
default: false
|
|
185
|
+
},
|
|
186
|
+
"gh-pr": { type: "string" },
|
|
187
|
+
help: {
|
|
188
|
+
type: "boolean",
|
|
189
|
+
short: "h"
|
|
190
|
+
},
|
|
191
|
+
version: { type: "boolean" }
|
|
192
|
+
};
|
|
193
|
+
function getVersion() {
|
|
194
|
+
try {
|
|
195
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
196
|
+
return JSON.parse(readFileSync(resolve(__dirname, "..", "..", "package.json"), "utf-8")).version;
|
|
197
|
+
} catch {
|
|
198
|
+
return "unknown";
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
function printHelp() {
|
|
202
|
+
const version = getVersion();
|
|
203
|
+
console.log(`diffing v${version} – Local code review tool for git diffs
|
|
204
|
+
|
|
205
|
+
Usage: diffing [<git diff options>] [<revision>...] [-- <path>...]
|
|
206
|
+
diffing --web [<git diff options>] [<revision>...] [-- <path>...]
|
|
207
|
+
diffing [server options]
|
|
208
|
+
|
|
209
|
+
Git Diff Options (drop-in replacement for git diff):`);
|
|
210
|
+
printGitDiffHelp();
|
|
211
|
+
console.log(`
|
|
212
|
+
Diffing Server Options:
|
|
213
|
+
--port <port> Port to run the server on (default: random available port)
|
|
214
|
+
--host <host> Host address to bind to (default: 127.0.0.1). Pass
|
|
215
|
+
0.0.0.0 to expose the server to the local network.
|
|
216
|
+
--no-open Don't open the browser automatically
|
|
217
|
+
|
|
218
|
+
Output Modes:
|
|
219
|
+
By default diffing auto-selects the best output mode:
|
|
220
|
+
- Terminal mode: when output is a pipe, redirect, or non-TTY
|
|
221
|
+
- Web mode: when output is a TTY (interactive terminal)
|
|
222
|
+
|
|
223
|
+
Force a mode with --web, --terminal, or --tui flags.
|
|
224
|
+
--tui is experimental and opt-in. It opens a native-Rust terminal
|
|
225
|
+
interface that mirrors the web dashboard. Falls back to git diff
|
|
226
|
+
output if the environment cannot support a TUI (piped stdin, CI,
|
|
227
|
+
missing binary).
|
|
228
|
+
|
|
229
|
+
Subcommands:
|
|
230
|
+
show <revspec>... Drop-in for 'git show'. Renders commit metadata
|
|
231
|
+
(subject, author, date, message body) above the
|
|
232
|
+
diff in the web UI. Accepts single commits,
|
|
233
|
+
ranges (a..b, a...b), tags, and multiple SHAs.
|
|
234
|
+
Use '--' to separate revspecs from pathspecs.
|
|
235
|
+
|
|
236
|
+
Examples:
|
|
237
|
+
diffing Review uncommitted changes (web UI)
|
|
238
|
+
diffing --staged Review staged changes
|
|
239
|
+
diffing HEAD~3 Review last 3 commits
|
|
240
|
+
diffing main..feature Compare branches
|
|
241
|
+
diffing show HEAD Review a single commit's metadata + diff
|
|
242
|
+
diffing show HEAD~3..HEAD Review the last 3 commits as a series
|
|
243
|
+
diffing show HEAD -- src/ Limit a commit review to a directory
|
|
244
|
+
diffing --diff-algorithm=patience src/ Diff with patience algorithm
|
|
245
|
+
diffing -U10 Diff with 10 context lines
|
|
246
|
+
diffing --word-diff=color Word-level diff in color
|
|
247
|
+
diffing -b -w Ignore whitespace changes
|
|
248
|
+
diffing --host 0.0.0.0 Allow other machines on the LAN to review
|
|
249
|
+
diffing --tui Open the experimental TUI (native Rust)
|
|
250
|
+
|
|
251
|
+
Note: 'diffing <sha>' runs 'git diff <sha>' (working tree vs <sha>); it
|
|
252
|
+
is not the same as 'diffing show <sha>'. Use 'show' when you want to
|
|
253
|
+
review a commit's changes.`);
|
|
254
|
+
}
|
|
255
|
+
function printGitDiffHelp() {
|
|
256
|
+
console.log(`
|
|
257
|
+
Revision / Range:
|
|
258
|
+
--staged, --cached Show staged changes
|
|
259
|
+
--merge Show merge conflicts
|
|
260
|
+
|
|
261
|
+
Diff Algorithm:
|
|
262
|
+
--diff-algorithm=<A> Choose diff algorithm (minimal, patience, histogram, myers)
|
|
263
|
+
--indent-heuristic Enable indent heuristic (default: on)
|
|
264
|
+
--no-indent-heuristic Disable indent heuristic
|
|
265
|
+
--anchored=<TEXT> Anchor diff algorithm to <TEXT>
|
|
266
|
+
|
|
267
|
+
Whitespace:
|
|
268
|
+
-b, --ignore-space-change Ignore changes in amount of whitespace
|
|
269
|
+
-w, --ignore-all-space Ignore whitespace when comparing lines
|
|
270
|
+
--ignore-blank-lines Ignore blank lines
|
|
271
|
+
--ignore-cr-at-eol Ignore carriage-return at EOL
|
|
272
|
+
--ws-error-highlight=<KIND> Highlight whitespace errors (none, default, all)
|
|
273
|
+
|
|
274
|
+
Context:
|
|
275
|
+
-U<n>, --unified=<n> Generate diffs with <n> lines of context
|
|
276
|
+
--inter-hunk-context=<n> Show <n> lines between diff hunks
|
|
277
|
+
-W, --function-context Show whole function as context
|
|
278
|
+
|
|
279
|
+
Word Diff:
|
|
280
|
+
--word-diff=<MODE> Word diff (color, plain, porcelain, none)
|
|
281
|
+
--word-diff-regex=<REGEX> Regex to determine a word
|
|
282
|
+
--color-words[=<REGEX>] Word diff with color
|
|
283
|
+
|
|
284
|
+
Moved / Copied Detection:
|
|
285
|
+
--color-moved=<MODE> Color moved lines (no, default, plain, blocks, zebra, dimmed-zebra)
|
|
286
|
+
--color-moved-ws=<MODE> How whitespace is treated in --color-moved
|
|
287
|
+
-C[<n>], --find-copies[=<n>] Detect copies
|
|
288
|
+
--find-copies-harder Spend extra cycles to find copies
|
|
289
|
+
-M[<n>], --find-renames[=<n>] Detect renames
|
|
290
|
+
-B[<n>], --break-rewrites[=<n>] Break complete rewrite changes into delete/add pairs
|
|
291
|
+
|
|
292
|
+
Output Format:
|
|
293
|
+
-p, --patch Generate patch (default)
|
|
294
|
+
-s, --no-patch Suppress diff output
|
|
295
|
+
--raw Generate raw diff
|
|
296
|
+
--patch-with-raw Both patch and raw output
|
|
297
|
+
--patch-with-stat Both patch and stat
|
|
298
|
+
--numstat Machine-friendly stat: additions/deletions per file
|
|
299
|
+
--shortstat Summary of additions/deletions
|
|
300
|
+
--stat[=<width>] Diffstat with graph
|
|
301
|
+
--summary Extended header summary
|
|
302
|
+
--name-only Show only names of changed files
|
|
303
|
+
--name-status Show only names and status of changed files
|
|
304
|
+
--check Check if diff introduces whitespace errors
|
|
305
|
+
--compact-summary Compact summary for submodule changes
|
|
306
|
+
--dirstat[=<param>] Directory-level change stats
|
|
307
|
+
--dirstat-by-file[=<param>] Per-file directory stats
|
|
308
|
+
--cumulative Cumulative directory stats
|
|
309
|
+
|
|
310
|
+
Filtering:
|
|
311
|
+
--diff-filter=[(A|C|D|M|R|T|U|X|B)...[*]]
|
|
312
|
+
-S<string> Look for differences that change number of occurrences of <string>
|
|
313
|
+
-G<regex> Look for differences whose patch text contains <regex>
|
|
314
|
+
--pickaxe-all Show all changes with -S or -G
|
|
315
|
+
|
|
316
|
+
Output Control:
|
|
317
|
+
-o, --output=<file> Write diff to <file>
|
|
318
|
+
--exit-code Exit with 1 if differences, 0 if not
|
|
319
|
+
--quiet Disable all output, imply --exit-code
|
|
320
|
+
|
|
321
|
+
Prefixes:
|
|
322
|
+
--src-prefix=<PREFIX> Source prefix (default: "a/")
|
|
323
|
+
--dst-prefix=<PREFIX> Destination prefix (default: "b/")
|
|
324
|
+
--no-prefix Do not show source/destination prefixes
|
|
325
|
+
--line-prefix=<PREFIX> Prepend <PREFIX> to every line
|
|
326
|
+
|
|
327
|
+
Submodule:
|
|
328
|
+
--submodule=<FORMAT> Submodule diff format (diff, log, short)
|
|
329
|
+
|
|
330
|
+
Miscellaneous:
|
|
331
|
+
--binary Output binary diff (apply patch to binary files)
|
|
332
|
+
--no-binary Don't output binary diff
|
|
333
|
+
--full-index Show full blob object names
|
|
334
|
+
-a, --text Treat all files as text
|
|
335
|
+
--textconv Allow external text conversion filters
|
|
336
|
+
--no-textconv Disallow external text conversion filters
|
|
337
|
+
--no-ext-diff Disallow external diff drivers
|
|
338
|
+
--ext-diff=<CMD> Allow external diff driver
|
|
339
|
+
--ita-visible Include intent-to-add files
|
|
340
|
+
--ignore-submodules=<WHEN> Ignore submodule changes
|
|
341
|
+
--relative[=<path>] Show changes relative to <path>
|
|
342
|
+
--no-relative Don't show relative paths`);
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* Parse raw `process.argv` slice into typed DiffOptions.
|
|
346
|
+
*
|
|
347
|
+
* Strategy:
|
|
348
|
+
* 1. Split argv into: [diffing options] [git revisions] [-- pathspecs]
|
|
349
|
+
* 2. Parse known flags via node:util.parseArgs
|
|
350
|
+
* 3. Anything unknown / positional becomes a revision or pathspec
|
|
351
|
+
*/
|
|
352
|
+
function parseDiffOptions(rawArgs) {
|
|
353
|
+
const { values, positionals } = parseArgs({
|
|
354
|
+
options: {
|
|
355
|
+
...GIT_DIFF_OPTIONS,
|
|
356
|
+
...DIFFING_OPTIONS
|
|
357
|
+
},
|
|
358
|
+
allowPositionals: true,
|
|
359
|
+
strict: false,
|
|
360
|
+
args: rawArgs
|
|
361
|
+
});
|
|
362
|
+
const opts = {
|
|
363
|
+
...DEFAULTS$1,
|
|
364
|
+
revisions: [],
|
|
365
|
+
pathspecs: [],
|
|
366
|
+
showRevspecs: []
|
|
367
|
+
};
|
|
368
|
+
if (values.help) opts.help = true;
|
|
369
|
+
if (values.version) opts.version = true;
|
|
370
|
+
if (values.port) opts.port = parseInt(values.port, 10);
|
|
371
|
+
if (values.host) opts.host = values.host;
|
|
372
|
+
if (values["no-open"]) opts.noOpen = true;
|
|
373
|
+
if (values.staged || values.cached) opts.staged = true;
|
|
374
|
+
if (values.merge) opts.merge = true;
|
|
375
|
+
const algo = values["diff-algorithm"];
|
|
376
|
+
if (algo && [
|
|
377
|
+
"minimal",
|
|
378
|
+
"patience",
|
|
379
|
+
"histogram",
|
|
380
|
+
"myers"
|
|
381
|
+
].includes(algo)) opts.algorithm = algo;
|
|
382
|
+
if (values["no-indent-heuristic"]) {
|
|
383
|
+
opts.noIndentHeuristic = true;
|
|
384
|
+
opts.indentHeuristic = false;
|
|
385
|
+
}
|
|
386
|
+
if (values.anchored) opts.anchored = values.anchored;
|
|
387
|
+
if (values["ignore-space-change"]) opts.ignoreSpaceChange = true;
|
|
388
|
+
if (values["ignore-all-space"]) opts.ignoreAllSpace = true;
|
|
389
|
+
if (values["ignore-blank-lines"]) opts.ignoreBlankLines = true;
|
|
390
|
+
if (values["ignore-cr-at-eol"]) opts.ignoreCrAtEol = true;
|
|
391
|
+
const wsh = values["ws-error-highlight"];
|
|
392
|
+
if (wsh && [
|
|
393
|
+
"none",
|
|
394
|
+
"default",
|
|
395
|
+
"all"
|
|
396
|
+
].includes(wsh)) opts.wsErrorHighlight = wsh;
|
|
397
|
+
if (values.unified) opts.unifiedContext = parseInt(values.unified, 10);
|
|
398
|
+
if (values["inter-hunk-context"]) opts.interHunkContext = parseInt(values["inter-hunk-context"], 10);
|
|
399
|
+
if (values["function-context"]) opts.functionContext = true;
|
|
400
|
+
const wd = values["word-diff"];
|
|
401
|
+
if (wd && [
|
|
402
|
+
"color",
|
|
403
|
+
"plain",
|
|
404
|
+
"porcelain",
|
|
405
|
+
"none"
|
|
406
|
+
].includes(wd)) opts.wordDiff = wd;
|
|
407
|
+
if (values["word-diff-regex"]) opts.wordDiffRegex = values["word-diff-regex"];
|
|
408
|
+
if (values["color-words"]) {
|
|
409
|
+
opts.wordDiff = "color";
|
|
410
|
+
opts.wordDiffRegex = values["color-words"] || void 0;
|
|
411
|
+
}
|
|
412
|
+
const cm = values["color-moved"];
|
|
413
|
+
if (cm && [
|
|
414
|
+
"default",
|
|
415
|
+
"plain",
|
|
416
|
+
"blocks",
|
|
417
|
+
"zebra",
|
|
418
|
+
"dimmed-zebra",
|
|
419
|
+
"no"
|
|
420
|
+
].includes(cm)) opts.colorMoved = cm;
|
|
421
|
+
const cmw = values["color-moved-ws"];
|
|
422
|
+
if (cmw && [
|
|
423
|
+
"no",
|
|
424
|
+
"ignore-space-at-eol",
|
|
425
|
+
"ignore-space-change",
|
|
426
|
+
"ignore-all-space",
|
|
427
|
+
"allow-indentation-change"
|
|
428
|
+
].includes(cmw)) opts.colorMovedWs = cmw;
|
|
429
|
+
if (values["find-copies"] !== void 0) {
|
|
430
|
+
const v = values["find-copies"];
|
|
431
|
+
opts.findCopies = v ? parseInt(v, 10) : 40;
|
|
432
|
+
}
|
|
433
|
+
if (values["find-copies-harder"]) opts.findCopiesHarder = true;
|
|
434
|
+
if (values["find-renames"] !== void 0) {
|
|
435
|
+
const v = values["find-renames"];
|
|
436
|
+
opts.findRenames = v ? parseInt(v, 10) : 50;
|
|
437
|
+
}
|
|
438
|
+
if (values["break-rewrites"] !== void 0) opts.breakRewrites = values["break-rewrites"];
|
|
439
|
+
if (values.raw) opts.outputFormat = "raw";
|
|
440
|
+
if (values.numstat) opts.outputFormat = "numstat";
|
|
441
|
+
if (values.shortstat) opts.outputFormat = "shortstat";
|
|
442
|
+
if (values.stat !== void 0) opts.outputFormat = "stat";
|
|
443
|
+
if (values.summary) opts.outputFormat = "summary";
|
|
444
|
+
if (values["name-only"]) opts.outputFormat = "name-only";
|
|
445
|
+
if (values["name-status"]) opts.outputFormat = "name-status";
|
|
446
|
+
if (values["patch-with-raw"]) opts.patchWithRaw = true;
|
|
447
|
+
if (values["patch-with-stat"]) opts.patchWithStat = true;
|
|
448
|
+
if (values["compact-summary"]) opts.compactSummary = true;
|
|
449
|
+
if (values.dirstat) opts.dirstat = values.dirstat;
|
|
450
|
+
if (values["dirstat-by-file"]) opts.dirstatByFile = values["dirstat-by-file"];
|
|
451
|
+
if (values.cumulative) opts.cumulative = true;
|
|
452
|
+
if (values["no-patch"]) opts.outputFormat = void 0;
|
|
453
|
+
if (values.check) opts.outputFormat = void 0;
|
|
454
|
+
if (values["diff-filter"]) opts.diffFilter = values["diff-filter"];
|
|
455
|
+
if (values.S) opts.pickaxeString = values.S;
|
|
456
|
+
if (values.G) opts.pickaxeRegex = values.G;
|
|
457
|
+
if (values["pickaxe-all"]) opts.pickaxeAll = true;
|
|
458
|
+
if (values.output) opts.outputFile = values.output;
|
|
459
|
+
if (values["exit-code"]) opts.exitCode = true;
|
|
460
|
+
if (values.quiet) {
|
|
461
|
+
opts.quiet = true;
|
|
462
|
+
opts.exitCode = true;
|
|
463
|
+
}
|
|
464
|
+
if (values["src-prefix"]) opts.srcPrefix = values["src-prefix"];
|
|
465
|
+
if (values["dst-prefix"]) opts.dstPrefix = values["dst-prefix"];
|
|
466
|
+
if (values["no-prefix"]) opts.noPrefix = true;
|
|
467
|
+
if (values["line-prefix"]) opts.linePrefix = values["line-prefix"];
|
|
468
|
+
const sm = values.submodule;
|
|
469
|
+
if (sm && [
|
|
470
|
+
"diff",
|
|
471
|
+
"log",
|
|
472
|
+
"short"
|
|
473
|
+
].includes(sm)) opts.submodule = sm;
|
|
474
|
+
if (values.binary) opts.binary = true;
|
|
475
|
+
if (values["no-binary"]) opts.binary = false;
|
|
476
|
+
if (values["full-index"]) opts.fullIndex = true;
|
|
477
|
+
if (values.text) opts.text = true;
|
|
478
|
+
if (values.textconv) opts.textconv = true;
|
|
479
|
+
if (values["no-textconv"]) opts.textconv = false;
|
|
480
|
+
if (values["no-ext-diff"]) opts.noExtDiff = true;
|
|
481
|
+
if (values["ext-diff"]) opts.extDiff = values["ext-diff"];
|
|
482
|
+
if (values["ita-visible"] || values["ita-invisible-in-index"]) opts.itaVisible = true;
|
|
483
|
+
if (values.relative !== void 0) opts.relative = values.relative || void 0;
|
|
484
|
+
if (values["no-relative"]) opts.noRelative = true;
|
|
485
|
+
if (values["ignore-submodules"]) {
|
|
486
|
+
const ig = values["ignore-submodules"];
|
|
487
|
+
if ([
|
|
488
|
+
"none",
|
|
489
|
+
"untracked",
|
|
490
|
+
"dirty",
|
|
491
|
+
"all"
|
|
492
|
+
].includes(ig)) opts.ignoreSubmodules = ig;
|
|
493
|
+
}
|
|
494
|
+
const dashDashIdx = rawArgs.indexOf("--");
|
|
495
|
+
if (dashDashIdx >= 0) {
|
|
496
|
+
for (let i = 0; i < dashDashIdx; i++) {
|
|
497
|
+
const arg = rawArgs[i];
|
|
498
|
+
if (!arg.startsWith("-")) opts.revisions.push(arg);
|
|
499
|
+
}
|
|
500
|
+
for (let i = dashDashIdx + 1; i < rawArgs.length; i++) opts.pathspecs.push(rawArgs[i]);
|
|
501
|
+
} else for (const p of positionals) opts.revisions.push(p);
|
|
502
|
+
if (values.tui) opts.outputMode = "tui";
|
|
503
|
+
else if (values.web) opts.outputMode = "web";
|
|
504
|
+
else if (values.terminal) opts.outputMode = "terminal";
|
|
505
|
+
if (process.stdout.isTTY && opts.outputMode === "auto") opts.outputMode = "web";
|
|
506
|
+
else if (!process.stdout.isTTY && opts.outputMode === "auto") opts.outputMode = "terminal";
|
|
507
|
+
if (opts.outputFormat || opts.patchWithRaw || opts.patchWithStat || opts.exitCode || opts.quiet || opts.outputFile) opts.outputMode = "terminal";
|
|
508
|
+
return opts;
|
|
509
|
+
}
|
|
510
|
+
/**
|
|
511
|
+
* Build the `git diff` argument array from parsed options.
|
|
512
|
+
* Returns args that can be spread into `git diff <args>`.
|
|
513
|
+
*/
|
|
514
|
+
function buildGitDiffArgs(opts) {
|
|
515
|
+
const args = [];
|
|
516
|
+
if (opts.algorithm) args.push(`--diff-algorithm=${opts.algorithm}`);
|
|
517
|
+
if (opts.noIndentHeuristic) args.push("--no-indent-heuristic");
|
|
518
|
+
if (opts.anchored) args.push(`--anchored=${opts.anchored}`);
|
|
519
|
+
if (opts.staged) args.push("--staged");
|
|
520
|
+
if (opts.merge) args.push("--merge");
|
|
521
|
+
if (opts.ignoreSpaceChange) args.push("--ignore-space-change");
|
|
522
|
+
if (opts.ignoreAllSpace) args.push("--ignore-all-space");
|
|
523
|
+
if (opts.ignoreBlankLines) args.push("--ignore-blank-lines");
|
|
524
|
+
if (opts.ignoreCrAtEol) args.push("--ignore-cr-at-eol");
|
|
525
|
+
if (opts.wsErrorHighlight) args.push(`--ws-error-highlight=${opts.wsErrorHighlight}`);
|
|
526
|
+
if (opts.unifiedContext !== void 0) args.push(`--unified=${opts.unifiedContext}`);
|
|
527
|
+
if (opts.interHunkContext !== void 0) args.push(`--inter-hunk-context=${opts.interHunkContext}`);
|
|
528
|
+
if (opts.functionContext) args.push("--function-context");
|
|
529
|
+
if (opts.wordDiff) if (opts.wordDiff === "color" && opts.wordDiffRegex && !opts.colorWords) {
|
|
530
|
+
args.push(`--word-diff=color`);
|
|
531
|
+
args.push(`--word-diff-regex=${opts.wordDiffRegex}`);
|
|
532
|
+
} else if (opts.wordDiff === "color" && opts.colorWords) args.push(`--color-words=${opts.wordDiffRegex || "."}`);
|
|
533
|
+
else args.push(`--word-diff=${opts.wordDiff}`);
|
|
534
|
+
if (opts.wordDiffRegex && opts.wordDiff !== "color") args.push(`--word-diff-regex=${opts.wordDiffRegex}`);
|
|
535
|
+
if (opts.colorMoved) args.push(`--color-moved=${opts.colorMoved}`);
|
|
536
|
+
if (opts.colorMovedWs) args.push(`--color-moved-ws=${opts.colorMovedWs}`);
|
|
537
|
+
if (opts.findCopies !== void 0) args.push(opts.findCopies === 40 ? "-C" : `-C${opts.findCopies}`);
|
|
538
|
+
if (opts.findCopiesHarder) args.push("--find-copies-harder");
|
|
539
|
+
if (opts.findRenames !== void 0) args.push(opts.findRenames === 50 ? "-M" : `-M${opts.findRenames}`);
|
|
540
|
+
if (opts.breakRewrites !== void 0) args.push(`-B${opts.breakRewrites}`);
|
|
541
|
+
if (opts.outputFormat === "raw") args.push("--raw");
|
|
542
|
+
if (opts.outputFormat === "numstat") args.push("--numstat");
|
|
543
|
+
if (opts.outputFormat === "shortstat") args.push("--shortstat");
|
|
544
|
+
if (opts.outputFormat === "stat") args.push("--stat");
|
|
545
|
+
if (opts.outputFormat === "summary") args.push("--summary");
|
|
546
|
+
if (opts.outputFormat === "name-only") args.push("--name-only");
|
|
547
|
+
if (opts.outputFormat === "name-status") args.push("--name-status");
|
|
548
|
+
if (opts.patchWithRaw) args.push("--patch-with-raw");
|
|
549
|
+
if (opts.patchWithStat) args.push("--patch-with-stat");
|
|
550
|
+
if (opts.compactSummary) args.push("--compact-summary");
|
|
551
|
+
if (opts.dirstat) args.push(`--dirstat=${opts.dirstat}`);
|
|
552
|
+
if (opts.dirstatByFile) args.push(`--dirstat-by-file=${opts.dirstatByFile}`);
|
|
553
|
+
if (opts.cumulative) args.push("--cumulative");
|
|
554
|
+
if (opts.check) args.push("--check");
|
|
555
|
+
if (opts.diffFilter) args.push(`--diff-filter=${opts.diffFilter}`);
|
|
556
|
+
if (opts.pickaxeString) args.push(`-S${opts.pickaxeString}`);
|
|
557
|
+
if (opts.pickaxeRegex) args.push(`-G${opts.pickaxeRegex}`);
|
|
558
|
+
if (opts.pickaxeAll) args.push("--pickaxe-all");
|
|
559
|
+
if (opts.outputFile) args.push(`--output=${opts.outputFile}`);
|
|
560
|
+
if (opts.exitCode) args.push("--exit-code");
|
|
561
|
+
if (opts.quiet) args.push("--quiet");
|
|
562
|
+
if (opts.srcPrefix) args.push(`--src-prefix=${opts.srcPrefix}`);
|
|
563
|
+
if (opts.dstPrefix) args.push(`--dst-prefix=${opts.dstPrefix}`);
|
|
564
|
+
if (opts.noPrefix) args.push("--no-prefix");
|
|
565
|
+
if (opts.linePrefix) args.push(`--line-prefix=${opts.linePrefix}`);
|
|
566
|
+
if (opts.submodule) args.push(`--submodule=${opts.submodule}`);
|
|
567
|
+
if (opts.binary) args.push("--binary");
|
|
568
|
+
if (opts.fullIndex) args.push("--full-index");
|
|
569
|
+
if (opts.text) args.push("--text");
|
|
570
|
+
if (opts.textconv) args.push("--textconv");
|
|
571
|
+
if (opts.noExtDiff) args.push("--no-ext-diff");
|
|
572
|
+
if (opts.extDiff) args.push(`--ext-diff=${opts.extDiff}`);
|
|
573
|
+
if (opts.itaVisible) args.push("--ita-visible");
|
|
574
|
+
if (opts.relative !== void 0) args.push(opts.relative ? `--relative=${opts.relative}` : "--relative");
|
|
575
|
+
if (opts.noRelative) args.push("--no-relative");
|
|
576
|
+
if (opts.ignoreSubmodules) args.push(`--ignore-submodules=${opts.ignoreSubmodules}`);
|
|
577
|
+
for (const rev of opts.revisions) args.push(rev);
|
|
578
|
+
if (opts.pathspecs.length > 0) {
|
|
579
|
+
args.push("--");
|
|
580
|
+
args.push(...opts.pathspecs);
|
|
581
|
+
}
|
|
582
|
+
return args;
|
|
583
|
+
}
|
|
584
|
+
/**
|
|
585
|
+
* Transform a `parseDiffOptions(...)` result into a "show mode" variant:
|
|
586
|
+
* moves any parsed revisions into `showRevspecs`, clears `revisions` (so the
|
|
587
|
+
* existing diff-engine path does not try to `git diff` them), and sets the
|
|
588
|
+
* `showMode` flag. Pathspecs and every other flag survive unchanged.
|
|
589
|
+
*
|
|
590
|
+
* Returns the same object for callsite ergonomics — the mutation is in-place
|
|
591
|
+
* because `parseDiffOptions` always returns a fresh object.
|
|
592
|
+
*/
|
|
593
|
+
function intoShowMode(opts) {
|
|
594
|
+
opts.showRevspecs = opts.revisions;
|
|
595
|
+
opts.revisions = [];
|
|
596
|
+
opts.showMode = true;
|
|
597
|
+
return opts;
|
|
598
|
+
}
|
|
599
|
+
//#endregion
|
|
600
|
+
//#region src/lib/diff-engine.ts
|
|
601
|
+
const MAX_BUFFER = 50 * 1024 * 1024;
|
|
602
|
+
/**
|
|
603
|
+
* Determine whether to use the "standard" web mode (unstaged+staged+untracked)
|
|
604
|
+
* or a "custom" mode (specific revisions / pathspecs).
|
|
605
|
+
*
|
|
606
|
+
* Custom mode is used when the user provides explicit revisions or pathspecs
|
|
607
|
+
* beyond just toggling staged/untracked. `showMode` is *also* a custom mode
|
|
608
|
+
* — the "Show staged / Show untracked" toggles don't apply to a commit view.
|
|
609
|
+
*/
|
|
610
|
+
function isCustomMode$1(opts) {
|
|
611
|
+
return opts.revisions.length > 0 || opts.pathspecs.length > 0 || opts.showMode;
|
|
612
|
+
}
|
|
613
|
+
function parseFilePaths(patch) {
|
|
614
|
+
const paths = /* @__PURE__ */ new Set();
|
|
615
|
+
for (const line of patch.split("\n")) {
|
|
616
|
+
const match = line.match(/^diff --git a\/.+ b\/(.+)$/);
|
|
617
|
+
if (match) paths.add(match[1]);
|
|
618
|
+
}
|
|
619
|
+
return [...paths];
|
|
620
|
+
}
|
|
621
|
+
function parseBinaryFiles(patch, untrackedFiles) {
|
|
622
|
+
const binaryFiles = [];
|
|
623
|
+
const lines = patch.split("\n");
|
|
624
|
+
for (let i = 0; i < lines.length; i++) {
|
|
625
|
+
const line = lines[i];
|
|
626
|
+
if (!line.startsWith("Binary files ") || !line.includes(" differ")) continue;
|
|
627
|
+
let filePath = "";
|
|
628
|
+
for (let j = i - 1; j >= 0; j--) {
|
|
629
|
+
const match = lines[j].match(/^diff --git a\/.+ b\/(.+)$/);
|
|
630
|
+
if (match) {
|
|
631
|
+
filePath = match[1];
|
|
632
|
+
break;
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
if (!filePath) continue;
|
|
636
|
+
let changeType = "changed";
|
|
637
|
+
for (let j = i - 1; j >= 0; j--) {
|
|
638
|
+
if (lines[j].startsWith("diff --git")) break;
|
|
639
|
+
if (lines[j].startsWith("new file mode")) {
|
|
640
|
+
changeType = "added";
|
|
641
|
+
break;
|
|
642
|
+
}
|
|
643
|
+
if (lines[j].startsWith("deleted file mode")) {
|
|
644
|
+
changeType = "deleted";
|
|
645
|
+
break;
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
if (changeType === "added" && untrackedFiles?.has(filePath)) changeType = "untracked";
|
|
649
|
+
binaryFiles.push({
|
|
650
|
+
path: filePath,
|
|
651
|
+
type: changeType
|
|
652
|
+
});
|
|
653
|
+
}
|
|
654
|
+
return binaryFiles;
|
|
655
|
+
}
|
|
656
|
+
/**
|
|
657
|
+
* Execute a diff asynchronously — used by the server.
|
|
658
|
+
*
|
|
659
|
+
* Show mode bypasses the normal `git diff` path entirely: it resolves
|
|
660
|
+
* revspecs to a commit list and fetches per-commit metadata + patches in
|
|
661
|
+
* one `git log` call. The concatenated patch is returned in the same shape
|
|
662
|
+
* the existing UI pipeline expects (so file tree, binary detection, and
|
|
663
|
+
* tab-size detection all keep working unchanged); the per-commit data
|
|
664
|
+
* rides along on `commits` for the metadata banners.
|
|
665
|
+
*/
|
|
666
|
+
async function executeDiffAsync(opts) {
|
|
667
|
+
if (opts.showMode) {
|
|
668
|
+
const { commits, patch, truncated } = await getShowDiff(opts.showRevspecs, opts.pathspecs);
|
|
669
|
+
return {
|
|
670
|
+
patch,
|
|
671
|
+
args: [],
|
|
672
|
+
commits,
|
|
673
|
+
truncated
|
|
674
|
+
};
|
|
675
|
+
}
|
|
676
|
+
if (isCustomMode$1(opts)) {
|
|
677
|
+
const args = buildGitDiffArgs(opts);
|
|
678
|
+
return {
|
|
679
|
+
patch: await getCustomGitDiffAsync(args),
|
|
680
|
+
args
|
|
681
|
+
};
|
|
682
|
+
}
|
|
683
|
+
return {
|
|
684
|
+
patch: await getGitDiffAsync({
|
|
685
|
+
staged: opts.staged,
|
|
686
|
+
untracked: opts.includeUntracked
|
|
687
|
+
}),
|
|
688
|
+
args: []
|
|
689
|
+
};
|
|
690
|
+
}
|
|
691
|
+
/**
|
|
692
|
+
* Execute a diff and produce the full enriched result used by the web API.
|
|
693
|
+
*/
|
|
694
|
+
async function executeDiffWithMeta(opts) {
|
|
695
|
+
const { patch, commits, truncated } = await executeDiffAsync(opts);
|
|
696
|
+
const repoName = getRepoName();
|
|
697
|
+
const branch = getBranchName();
|
|
698
|
+
const untrackedFiles = opts.includeUntracked && !opts.showMode ? await getUntrackedFilePathsAsync() : [];
|
|
699
|
+
const binaryFiles = parseBinaryFiles(patch, new Set(untrackedFiles));
|
|
700
|
+
const filePaths = parseFilePaths(patch);
|
|
701
|
+
return {
|
|
702
|
+
patch,
|
|
703
|
+
binaryFiles,
|
|
704
|
+
filePaths,
|
|
705
|
+
tabSizeMap: getTabSizeForFiles(filePaths),
|
|
706
|
+
untrackedFiles,
|
|
707
|
+
repoName,
|
|
708
|
+
branch,
|
|
709
|
+
...commits ? { commits } : {},
|
|
710
|
+
...truncated ? { truncated } : {}
|
|
711
|
+
};
|
|
712
|
+
}
|
|
713
|
+
/**
|
|
714
|
+
* Run the diff in terminal mode: forward to git diff with full flags,
|
|
715
|
+
* output to stdout. Behaves identically to `git diff`. In show mode this
|
|
716
|
+
* delegates to `git show` instead so `diffing show <rev>... --terminal`
|
|
717
|
+
* is a drop-in for `git show <rev>...`.
|
|
718
|
+
*/
|
|
719
|
+
function runTerminalDiff(opts) {
|
|
720
|
+
if (opts.showMode) return runTerminalShow(opts);
|
|
721
|
+
const args = buildGitDiffArgs(opts);
|
|
722
|
+
const enforceDefaults = !opts.outputFormat && !opts.quiet;
|
|
723
|
+
const finalArgs = [];
|
|
724
|
+
if (enforceDefaults) {
|
|
725
|
+
if (!args.includes("--no-ext-diff") && !opts.extDiff) finalArgs.push("--no-ext-diff");
|
|
726
|
+
}
|
|
727
|
+
finalArgs.push(...args);
|
|
728
|
+
try {
|
|
729
|
+
execFileSync("git", ["diff", ...finalArgs], {
|
|
730
|
+
encoding: "utf-8",
|
|
731
|
+
maxBuffer: MAX_BUFFER,
|
|
732
|
+
stdio: "inherit"
|
|
733
|
+
});
|
|
734
|
+
return 0;
|
|
735
|
+
} catch (err) {
|
|
736
|
+
if (err.status === 1 && opts.exitCode) return 1;
|
|
737
|
+
return err.status ?? 1;
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
/**
|
|
741
|
+
* Terminal mode for `diffing show`: spawn `git show` with the user's revspecs
|
|
742
|
+
* and pathspecs, inherit stdio, propagate exit code. We pass `--no-ext-diff`
|
|
743
|
+
* so the output is a clean unified diff regardless of the user's git config
|
|
744
|
+
* (matches the web-mode pipeline), but otherwise hand everything to git.
|
|
745
|
+
*/
|
|
746
|
+
function runTerminalShow(opts) {
|
|
747
|
+
const args = [
|
|
748
|
+
"show",
|
|
749
|
+
"--no-ext-diff",
|
|
750
|
+
...opts.showRevspecs
|
|
751
|
+
];
|
|
752
|
+
if (opts.pathspecs.length > 0) args.push("--", ...opts.pathspecs);
|
|
753
|
+
try {
|
|
754
|
+
execFileSync("git", args, {
|
|
755
|
+
encoding: "utf-8",
|
|
756
|
+
maxBuffer: MAX_BUFFER,
|
|
757
|
+
stdio: "inherit"
|
|
758
|
+
});
|
|
759
|
+
return 0;
|
|
760
|
+
} catch (err) {
|
|
761
|
+
return err.status ?? 1;
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
/**
|
|
765
|
+
* Validate the environment: check that we're in a git repo.
|
|
766
|
+
* Returns an error message or null.
|
|
767
|
+
*/
|
|
768
|
+
function validateEnvironment() {
|
|
769
|
+
if (!isGitRepo()) return "Error: not inside a git repository";
|
|
770
|
+
return null;
|
|
771
|
+
}
|
|
772
|
+
//#endregion
|
|
773
|
+
//#region src/lib/symbols.ts
|
|
774
|
+
const SYMBOL_PATTERNS = [
|
|
775
|
+
{
|
|
776
|
+
pattern: /^\s*(?:export\s+)?(?:async\s+)?function\s+(\w[\w$]*)\s*[<(]/i,
|
|
777
|
+
kind: "function",
|
|
778
|
+
nameGroup: 1
|
|
779
|
+
},
|
|
780
|
+
{
|
|
781
|
+
pattern: /^\s*(?:export\s+)?(?:const|let|var)\s+(\w[\w$]*)\s*=\s*(?:async\s*)?\(/i,
|
|
782
|
+
kind: "function",
|
|
783
|
+
nameGroup: 1
|
|
784
|
+
},
|
|
785
|
+
{
|
|
786
|
+
pattern: /^\s*(?:export\s+)?(?:const|let|var)\s+(\w[\w$]*)\s*=\s*(?:async\s+)?function/i,
|
|
787
|
+
kind: "function",
|
|
788
|
+
nameGroup: 1
|
|
789
|
+
},
|
|
790
|
+
{
|
|
791
|
+
pattern: /^\s*(?:export\s+)?class\s+(\w[\w$]*)/i,
|
|
792
|
+
kind: "class",
|
|
793
|
+
nameGroup: 1
|
|
794
|
+
},
|
|
795
|
+
{
|
|
796
|
+
pattern: /^\s*(?:export\s+)?interface\s+(\w[\w$]*)/i,
|
|
797
|
+
kind: "interface",
|
|
798
|
+
nameGroup: 1
|
|
799
|
+
},
|
|
800
|
+
{
|
|
801
|
+
pattern: /^\s*(?:export\s+)?type\s+(\w[\w$]*)\s*=/i,
|
|
802
|
+
kind: "type",
|
|
803
|
+
nameGroup: 1
|
|
804
|
+
},
|
|
805
|
+
{
|
|
806
|
+
pattern: /^\s*(?:export\s+)?enum\s+(\w[\w$]*)/i,
|
|
807
|
+
kind: "enum",
|
|
808
|
+
nameGroup: 1
|
|
809
|
+
},
|
|
810
|
+
{
|
|
811
|
+
pattern: /^\s*(?:export\s+)?(?:const|let|var)\s+(\w[\w$]*)\s*=/i,
|
|
812
|
+
kind: "variable",
|
|
813
|
+
nameGroup: 1
|
|
814
|
+
},
|
|
815
|
+
{
|
|
816
|
+
pattern: /^\s*def\s+(\w[\w$]*)\s*\(/i,
|
|
817
|
+
kind: "function",
|
|
818
|
+
nameGroup: 1
|
|
819
|
+
},
|
|
820
|
+
{
|
|
821
|
+
pattern: /^\s*(?:pub(?:\s*\(\w+\))?\s+)?fn\s+(\w[\w$]*)\s*[<(]/i,
|
|
822
|
+
kind: "function",
|
|
823
|
+
nameGroup: 1
|
|
824
|
+
},
|
|
825
|
+
{
|
|
826
|
+
pattern: /^\s*(?:pub(?:\s*\(\w+\))?\s+)?struct\s+(\w[\w$]*)/i,
|
|
827
|
+
kind: "struct",
|
|
828
|
+
nameGroup: 1
|
|
829
|
+
},
|
|
830
|
+
{
|
|
831
|
+
pattern: /^\s*(?:pub(?:\s*\(\w+\))?\s+)?enum\s+(\w[\w$]*)/i,
|
|
832
|
+
kind: "enum",
|
|
833
|
+
nameGroup: 1
|
|
834
|
+
},
|
|
835
|
+
{
|
|
836
|
+
pattern: /^\s*(?:pub(?:\s*\(\w+\))?\s+)?impl\s+(\w[\w$]*)/i,
|
|
837
|
+
kind: "impl",
|
|
838
|
+
nameGroup: 1
|
|
839
|
+
},
|
|
840
|
+
{
|
|
841
|
+
pattern: /^\s*(?:pub(?:\s*\(\w+\))?\s+)?trait\s+(\w[\w$]*)/i,
|
|
842
|
+
kind: "trait",
|
|
843
|
+
nameGroup: 1
|
|
844
|
+
},
|
|
845
|
+
{
|
|
846
|
+
pattern: /^\s*func\s+(\w[\w$]*)\s*\(/i,
|
|
847
|
+
kind: "function",
|
|
848
|
+
nameGroup: 1
|
|
849
|
+
},
|
|
850
|
+
{
|
|
851
|
+
pattern: /^\s*(?:func\s+)?\((\w[\w$]*)\s+\*?\w+\)\s+(\w[\w$]*)\s*\(/i,
|
|
852
|
+
kind: "method",
|
|
853
|
+
nameGroup: 2
|
|
854
|
+
}
|
|
855
|
+
];
|
|
856
|
+
/**
|
|
857
|
+
* Returns the symbol defined on this line (name + kind), or null if the line
|
|
858
|
+
* is not a recognizable definition. The first matching pattern wins.
|
|
859
|
+
*/
|
|
860
|
+
function classifySymbolLine(line) {
|
|
861
|
+
for (const { pattern, kind, nameGroup } of SYMBOL_PATTERNS) {
|
|
862
|
+
const match = pattern.exec(line);
|
|
863
|
+
if (match) {
|
|
864
|
+
const name = match[nameGroup];
|
|
865
|
+
if (name) return {
|
|
866
|
+
name,
|
|
867
|
+
kind
|
|
868
|
+
};
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
return null;
|
|
872
|
+
}
|
|
873
|
+
//#endregion
|
|
874
|
+
//#region src/lib/search.ts
|
|
875
|
+
/**
|
|
876
|
+
* Server-side code search powered by `@ff-labs/fff-node` — a native (Rust)
|
|
877
|
+
* fuzzy file finder + live grep. Because it is a native Node addon it cannot
|
|
878
|
+
* run in the browser, so the React client calls the `/api/search` endpoint and
|
|
879
|
+
* this module owns the single long-lived `FileFinder` instance for the repo.
|
|
880
|
+
*
|
|
881
|
+
* Design notes:
|
|
882
|
+
* - The native binary is loaded lazily via dynamic import inside `init()` so a
|
|
883
|
+
* missing/incompatible platform binary degrades gracefully (search returns
|
|
884
|
+
* an error) instead of crashing the whole server at module load.
|
|
885
|
+
* - One finder per process, keyed implicitly to the repo root. Its frecency /
|
|
886
|
+
* history databases live under the project's `~/.diffing/<repo>` storage dir
|
|
887
|
+
* so we never write stray files into the user's repository.
|
|
888
|
+
* - fff runs its own file-system watcher, so the index stays fresh as the
|
|
889
|
+
* working tree changes during a review.
|
|
890
|
+
* - "Symbols" search is plain grep for the identifier, then each hit line is
|
|
891
|
+
* classified with the shared {@link classifySymbolLine} patterns — fff 0.8.x
|
|
892
|
+
* does not populate `isDefinition`, so we do the classification ourselves.
|
|
893
|
+
*/
|
|
894
|
+
const DEFAULT_LIMIT = 60;
|
|
895
|
+
const MAX_LIMIT = 200;
|
|
896
|
+
/** Internal page size when no path filter — generous so diff-first client
|
|
897
|
+
* ranking has enough to work with. */
|
|
898
|
+
const SCAN_PAGE = 200;
|
|
899
|
+
/** Larger page size when filtering to a path set, so the post-filter is
|
|
900
|
+
* effectively complete for normal-sized diffs/repos. */
|
|
901
|
+
const SCAN_PAGE_FILTERED = 1e3;
|
|
902
|
+
let finder = null;
|
|
903
|
+
let initPromise = null;
|
|
904
|
+
let initError = null;
|
|
905
|
+
let shutdownHooked = false;
|
|
906
|
+
function clampLimit(limit) {
|
|
907
|
+
if (!limit || limit < 1) return DEFAULT_LIMIT;
|
|
908
|
+
return Math.min(limit, MAX_LIMIT);
|
|
909
|
+
}
|
|
910
|
+
async function init() {
|
|
911
|
+
try {
|
|
912
|
+
const { FileFinder } = await import("@ff-labs/fff-node");
|
|
913
|
+
const root = getRepoRoot();
|
|
914
|
+
const dbDir = join(getProjectStorageDir(root), "fff");
|
|
915
|
+
mkdirSync(dbDir, { recursive: true });
|
|
916
|
+
const created = FileFinder.create({
|
|
917
|
+
basePath: root,
|
|
918
|
+
frecencyDbPath: join(dbDir, "frecency.db"),
|
|
919
|
+
historyDbPath: join(dbDir, "history.db"),
|
|
920
|
+
logLevel: "error"
|
|
921
|
+
});
|
|
922
|
+
if (!created.ok) {
|
|
923
|
+
initError = created.error;
|
|
924
|
+
return null;
|
|
925
|
+
}
|
|
926
|
+
const f = created.value;
|
|
927
|
+
if (!shutdownHooked) {
|
|
928
|
+
shutdownHooked = true;
|
|
929
|
+
process.once("exit", () => {
|
|
930
|
+
try {
|
|
931
|
+
f.destroy();
|
|
932
|
+
} catch {}
|
|
933
|
+
});
|
|
934
|
+
}
|
|
935
|
+
await f.waitForScan(8e3);
|
|
936
|
+
finder = f;
|
|
937
|
+
return f;
|
|
938
|
+
} catch (err) {
|
|
939
|
+
initError = err?.message ?? String(err);
|
|
940
|
+
return null;
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
async function getFinder() {
|
|
944
|
+
if (finder) return finder;
|
|
945
|
+
if (initError) return null;
|
|
946
|
+
if (!initPromise) initPromise = init();
|
|
947
|
+
return initPromise;
|
|
948
|
+
}
|
|
949
|
+
function isIndexing(f) {
|
|
950
|
+
try {
|
|
951
|
+
return f.isScanning();
|
|
952
|
+
} catch {
|
|
953
|
+
return false;
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
async function searchFiles(query, opts = {}) {
|
|
957
|
+
const f = await getFinder();
|
|
958
|
+
if (!f) return {
|
|
959
|
+
scope: "files",
|
|
960
|
+
items: [],
|
|
961
|
+
total: 0,
|
|
962
|
+
indexing: false,
|
|
963
|
+
error: initError ?? "Search unavailable"
|
|
964
|
+
};
|
|
965
|
+
const limit = clampLimit(opts.limit);
|
|
966
|
+
const pathSet = opts.paths ? new Set(opts.paths) : null;
|
|
967
|
+
const pageSize = pathSet ? SCAN_PAGE_FILTERED : Math.max(SCAN_PAGE, limit);
|
|
968
|
+
const res = f.fileSearch(query, { pageSize });
|
|
969
|
+
if (!res.ok) return {
|
|
970
|
+
scope: "files",
|
|
971
|
+
items: [],
|
|
972
|
+
total: 0,
|
|
973
|
+
indexing: isIndexing(f),
|
|
974
|
+
error: res.error
|
|
975
|
+
};
|
|
976
|
+
const items = [];
|
|
977
|
+
const { items: raw, scores } = res.value;
|
|
978
|
+
for (let i = 0; i < raw.length && items.length < limit; i++) {
|
|
979
|
+
const it = raw[i];
|
|
980
|
+
if (pathSet && !pathSet.has(it.relativePath)) continue;
|
|
981
|
+
const score = scores[i];
|
|
982
|
+
items.push({
|
|
983
|
+
path: it.relativePath,
|
|
984
|
+
fileName: it.fileName,
|
|
985
|
+
gitStatus: it.gitStatus,
|
|
986
|
+
matchType: score?.matchType ?? "",
|
|
987
|
+
exact: !!score?.exactMatch
|
|
988
|
+
});
|
|
989
|
+
}
|
|
990
|
+
return {
|
|
991
|
+
scope: "files",
|
|
992
|
+
items,
|
|
993
|
+
total: res.value.totalMatched,
|
|
994
|
+
indexing: isIndexing(f)
|
|
995
|
+
};
|
|
996
|
+
}
|
|
997
|
+
async function searchContent(query, opts = {}) {
|
|
998
|
+
const f = await getFinder();
|
|
999
|
+
if (!f) return {
|
|
1000
|
+
scope: "text",
|
|
1001
|
+
items: [],
|
|
1002
|
+
total: 0,
|
|
1003
|
+
indexing: false,
|
|
1004
|
+
error: initError ?? "Search unavailable"
|
|
1005
|
+
};
|
|
1006
|
+
if (!query) return {
|
|
1007
|
+
scope: "text",
|
|
1008
|
+
items: [],
|
|
1009
|
+
total: 0,
|
|
1010
|
+
indexing: isIndexing(f)
|
|
1011
|
+
};
|
|
1012
|
+
const limit = clampLimit(opts.limit);
|
|
1013
|
+
const pathSet = opts.paths ? new Set(opts.paths) : null;
|
|
1014
|
+
const pageSize = pathSet ? SCAN_PAGE_FILTERED : Math.max(SCAN_PAGE, limit);
|
|
1015
|
+
const res = f.grep(query, {
|
|
1016
|
+
mode: opts.regex ? "regex" : "plain",
|
|
1017
|
+
smartCase: true,
|
|
1018
|
+
beforeContext: 0,
|
|
1019
|
+
afterContext: 0,
|
|
1020
|
+
pageSize
|
|
1021
|
+
});
|
|
1022
|
+
if (!res.ok) return {
|
|
1023
|
+
scope: "text",
|
|
1024
|
+
items: [],
|
|
1025
|
+
total: 0,
|
|
1026
|
+
indexing: isIndexing(f),
|
|
1027
|
+
error: res.error
|
|
1028
|
+
};
|
|
1029
|
+
const items = [];
|
|
1030
|
+
for (const m of res.value.items) {
|
|
1031
|
+
if (items.length >= limit) break;
|
|
1032
|
+
if (pathSet && !pathSet.has(m.relativePath)) continue;
|
|
1033
|
+
items.push(toContentHit(m));
|
|
1034
|
+
}
|
|
1035
|
+
return {
|
|
1036
|
+
scope: "text",
|
|
1037
|
+
items,
|
|
1038
|
+
total: res.value.totalMatched,
|
|
1039
|
+
indexing: isIndexing(f),
|
|
1040
|
+
regexError: res.value.regexFallbackError || void 0
|
|
1041
|
+
};
|
|
1042
|
+
}
|
|
1043
|
+
function toContentHit(m) {
|
|
1044
|
+
return {
|
|
1045
|
+
path: m.relativePath,
|
|
1046
|
+
fileName: m.fileName,
|
|
1047
|
+
line: m.lineNumber,
|
|
1048
|
+
col: m.col,
|
|
1049
|
+
content: m.lineContent,
|
|
1050
|
+
matchRanges: m.matchRanges ?? [],
|
|
1051
|
+
gitStatus: m.gitStatus
|
|
1052
|
+
};
|
|
1053
|
+
}
|
|
1054
|
+
async function searchSymbols(query, opts = {}) {
|
|
1055
|
+
const f = await getFinder();
|
|
1056
|
+
if (!f) return {
|
|
1057
|
+
scope: "symbols",
|
|
1058
|
+
items: [],
|
|
1059
|
+
total: 0,
|
|
1060
|
+
indexing: false,
|
|
1061
|
+
error: initError ?? "Search unavailable"
|
|
1062
|
+
};
|
|
1063
|
+
if (!query) return {
|
|
1064
|
+
scope: "symbols",
|
|
1065
|
+
items: [],
|
|
1066
|
+
total: 0,
|
|
1067
|
+
indexing: isIndexing(f)
|
|
1068
|
+
};
|
|
1069
|
+
const limit = clampLimit(opts.limit);
|
|
1070
|
+
const pathSet = opts.paths ? new Set(opts.paths) : null;
|
|
1071
|
+
const pageSize = pathSet ? SCAN_PAGE_FILTERED : Math.max(SCAN_PAGE_FILTERED, limit * 8);
|
|
1072
|
+
const res = f.grep(query, {
|
|
1073
|
+
mode: "plain",
|
|
1074
|
+
smartCase: true,
|
|
1075
|
+
beforeContext: 0,
|
|
1076
|
+
afterContext: 0,
|
|
1077
|
+
pageSize
|
|
1078
|
+
});
|
|
1079
|
+
if (!res.ok) return {
|
|
1080
|
+
scope: "symbols",
|
|
1081
|
+
items: [],
|
|
1082
|
+
total: 0,
|
|
1083
|
+
indexing: isIndexing(f),
|
|
1084
|
+
error: res.error
|
|
1085
|
+
};
|
|
1086
|
+
const items = [];
|
|
1087
|
+
const ql = query.toLowerCase();
|
|
1088
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1089
|
+
for (const m of res.value.items) {
|
|
1090
|
+
if (items.length >= limit) break;
|
|
1091
|
+
if (pathSet && !pathSet.has(m.relativePath)) continue;
|
|
1092
|
+
const sym = classifySymbolLine(m.lineContent);
|
|
1093
|
+
if (!sym) continue;
|
|
1094
|
+
if (!sym.name.toLowerCase().includes(ql)) continue;
|
|
1095
|
+
const key = `${m.relativePath}:${m.lineNumber}:${sym.name}`;
|
|
1096
|
+
if (seen.has(key)) continue;
|
|
1097
|
+
seen.add(key);
|
|
1098
|
+
const nameIdx = m.lineContent.indexOf(sym.name);
|
|
1099
|
+
const matchRanges = nameIdx >= 0 ? [[nameIdx, nameIdx + sym.name.length]] : m.matchRanges ?? [];
|
|
1100
|
+
items.push({
|
|
1101
|
+
name: sym.name,
|
|
1102
|
+
kind: sym.kind,
|
|
1103
|
+
path: m.relativePath,
|
|
1104
|
+
fileName: m.fileName,
|
|
1105
|
+
line: m.lineNumber,
|
|
1106
|
+
content: m.lineContent,
|
|
1107
|
+
matchRanges,
|
|
1108
|
+
gitStatus: m.gitStatus
|
|
1109
|
+
});
|
|
1110
|
+
}
|
|
1111
|
+
return {
|
|
1112
|
+
scope: "symbols",
|
|
1113
|
+
items,
|
|
1114
|
+
total: items.length,
|
|
1115
|
+
indexing: isIndexing(f)
|
|
1116
|
+
};
|
|
1117
|
+
}
|
|
1118
|
+
async function searchAll(query, opts = {}) {
|
|
1119
|
+
const limit = clampLimit(opts.limit);
|
|
1120
|
+
const [filesRes, contentRes, symbolsRes] = await Promise.all([
|
|
1121
|
+
searchFiles(query, {
|
|
1122
|
+
...opts,
|
|
1123
|
+
limit
|
|
1124
|
+
}),
|
|
1125
|
+
searchContent(query, {
|
|
1126
|
+
...opts,
|
|
1127
|
+
limit
|
|
1128
|
+
}),
|
|
1129
|
+
searchSymbols(query, {
|
|
1130
|
+
...opts,
|
|
1131
|
+
limit
|
|
1132
|
+
})
|
|
1133
|
+
]);
|
|
1134
|
+
const items = [];
|
|
1135
|
+
for (const hit of filesRes.items) items.push({
|
|
1136
|
+
kind: "file",
|
|
1137
|
+
hit
|
|
1138
|
+
});
|
|
1139
|
+
for (const hit of symbolsRes.items) items.push({
|
|
1140
|
+
kind: "symbol",
|
|
1141
|
+
hit
|
|
1142
|
+
});
|
|
1143
|
+
for (const hit of contentRes.items) items.push({
|
|
1144
|
+
kind: "text",
|
|
1145
|
+
hit
|
|
1146
|
+
});
|
|
1147
|
+
const total = (filesRes.total || 0) + (contentRes.total || 0) + (symbolsRes.total || 0);
|
|
1148
|
+
const indexing = filesRes.indexing || contentRes.indexing || symbolsRes.indexing;
|
|
1149
|
+
const error = filesRes.error || contentRes.error || symbolsRes.error;
|
|
1150
|
+
const regexError = contentRes.regexError;
|
|
1151
|
+
return {
|
|
1152
|
+
scope: "all",
|
|
1153
|
+
items: items.slice(0, limit),
|
|
1154
|
+
total,
|
|
1155
|
+
indexing,
|
|
1156
|
+
error,
|
|
1157
|
+
regexError
|
|
1158
|
+
};
|
|
1159
|
+
}
|
|
1160
|
+
async function getSearchStatus() {
|
|
1161
|
+
const f = await getFinder();
|
|
1162
|
+
if (!f) return {
|
|
1163
|
+
available: false,
|
|
1164
|
+
indexing: false,
|
|
1165
|
+
indexedFiles: 0,
|
|
1166
|
+
error: initError ?? "Search unavailable"
|
|
1167
|
+
};
|
|
1168
|
+
let indexedFiles = 0;
|
|
1169
|
+
let indexing = false;
|
|
1170
|
+
try {
|
|
1171
|
+
const prog = f.getScanProgress();
|
|
1172
|
+
if (prog.ok) {
|
|
1173
|
+
indexedFiles = prog.value.scannedFilesCount;
|
|
1174
|
+
indexing = prog.value.isScanning;
|
|
1175
|
+
}
|
|
1176
|
+
} catch {}
|
|
1177
|
+
return {
|
|
1178
|
+
available: true,
|
|
1179
|
+
indexing,
|
|
1180
|
+
indexedFiles
|
|
1181
|
+
};
|
|
1182
|
+
}
|
|
1183
|
+
/** Record that the user opened `path` from a query, improving fff's frecency
|
|
1184
|
+
* ranking for future searches. Best-effort, never throws. */
|
|
1185
|
+
async function trackSelection(query, path) {
|
|
1186
|
+
const f = await getFinder();
|
|
1187
|
+
if (!f) return;
|
|
1188
|
+
try {
|
|
1189
|
+
f.trackQuery(query, path);
|
|
1190
|
+
} catch {}
|
|
1191
|
+
}
|
|
1192
|
+
//#endregion
|
|
1193
|
+
//#region src/lib/settings.ts
|
|
1194
|
+
const CONFIG_DIR = join(homedir(), ".config", "diffing");
|
|
1195
|
+
const SETTINGS_FILE = join(CONFIG_DIR, "settings.json");
|
|
1196
|
+
const DEFAULTS = {
|
|
1197
|
+
staged: true,
|
|
1198
|
+
untracked: true,
|
|
1199
|
+
diffStyle: "split",
|
|
1200
|
+
defaultTabSize: 4,
|
|
1201
|
+
theme: "nord",
|
|
1202
|
+
editorIDE: "default",
|
|
1203
|
+
lineDiffType: "word",
|
|
1204
|
+
lineWrap: false,
|
|
1205
|
+
diffIndicators: "classic",
|
|
1206
|
+
showLineNumbers: true,
|
|
1207
|
+
hunkSeparators: "line-info",
|
|
1208
|
+
lineHoverHighlight: "both",
|
|
1209
|
+
fontSize: 13,
|
|
1210
|
+
expandContextByDefault: false,
|
|
1211
|
+
collapsedContextThreshold: 10,
|
|
1212
|
+
expansionLineCount: 20,
|
|
1213
|
+
haptics: true,
|
|
1214
|
+
sounds: true,
|
|
1215
|
+
uiFont: null,
|
|
1216
|
+
monoFont: null
|
|
1217
|
+
};
|
|
1218
|
+
function loadSettings() {
|
|
1219
|
+
try {
|
|
1220
|
+
const data = readFileSync(SETTINGS_FILE, "utf-8");
|
|
1221
|
+
return {
|
|
1222
|
+
...DEFAULTS,
|
|
1223
|
+
...JSON.parse(data)
|
|
1224
|
+
};
|
|
1225
|
+
} catch {
|
|
1226
|
+
return { ...DEFAULTS };
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
function saveSettings(settings) {
|
|
1230
|
+
const merged = {
|
|
1231
|
+
...loadSettings(),
|
|
1232
|
+
...settings
|
|
1233
|
+
};
|
|
1234
|
+
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
1235
|
+
writeFileSync(SETTINGS_FILE, JSON.stringify(merged, null, 2));
|
|
1236
|
+
return merged;
|
|
1237
|
+
}
|
|
1238
|
+
//#endregion
|
|
1239
|
+
//#region src/lib/comments.ts
|
|
1240
|
+
var FileCommentStore = class {
|
|
1241
|
+
dirPath;
|
|
1242
|
+
filePath;
|
|
1243
|
+
/**
|
|
1244
|
+
* @param storageDir Absolute directory to persist `comments.json` in.
|
|
1245
|
+
* Defaults to the per-repo storage dir under `~/.diffing` — comments are
|
|
1246
|
+
* NEVER written inside the reviewed (consumer) repo, so a consumer project
|
|
1247
|
+
* stays free of any diffing-specific artifacts. The override exists only so
|
|
1248
|
+
* tests can point at a throwaway temp dir.
|
|
1249
|
+
*/
|
|
1250
|
+
constructor(storageDir) {
|
|
1251
|
+
this.dirPath = storageDir ?? getProjectStorageDir();
|
|
1252
|
+
this.filePath = join(this.dirPath, "comments.json");
|
|
1253
|
+
}
|
|
1254
|
+
async getAll() {
|
|
1255
|
+
try {
|
|
1256
|
+
const data = await readFile(this.filePath, "utf-8");
|
|
1257
|
+
return JSON.parse(data);
|
|
1258
|
+
} catch {
|
|
1259
|
+
return [];
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
async save(comments) {
|
|
1263
|
+
try {
|
|
1264
|
+
await mkdir(this.dirPath, { recursive: true });
|
|
1265
|
+
try {
|
|
1266
|
+
const repoRoot = getRepoRoot();
|
|
1267
|
+
await writeFile(join(this.dirPath, "repo_path.txt"), repoRoot, "utf-8");
|
|
1268
|
+
} catch {}
|
|
1269
|
+
await writeFile(this.filePath, JSON.stringify(comments, null, 2), "utf-8");
|
|
1270
|
+
} catch (err) {
|
|
1271
|
+
console.error("Failed to save comments to file:", err);
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
async add(comment) {
|
|
1275
|
+
const comments = await this.getAll();
|
|
1276
|
+
comments.push(comment);
|
|
1277
|
+
await this.save(comments);
|
|
1278
|
+
return comment;
|
|
1279
|
+
}
|
|
1280
|
+
async update(id, fields) {
|
|
1281
|
+
const comments = await this.getAll();
|
|
1282
|
+
const index = comments.findIndex((c) => c.id === id);
|
|
1283
|
+
if (index === -1) return null;
|
|
1284
|
+
const comment = comments[index];
|
|
1285
|
+
if (fields.body !== void 0) comment.body = fields.body;
|
|
1286
|
+
if (fields.status !== void 0) comment.status = fields.status;
|
|
1287
|
+
await this.save(comments);
|
|
1288
|
+
return comment;
|
|
1289
|
+
}
|
|
1290
|
+
async remove(id) {
|
|
1291
|
+
const comments = await this.getAll();
|
|
1292
|
+
const index = comments.findIndex((c) => c.id === id);
|
|
1293
|
+
if (index === -1) return false;
|
|
1294
|
+
comments.splice(index, 1);
|
|
1295
|
+
await this.save(comments);
|
|
1296
|
+
return true;
|
|
1297
|
+
}
|
|
1298
|
+
async addReply(commentId, reply) {
|
|
1299
|
+
const comments = await this.getAll();
|
|
1300
|
+
const index = comments.findIndex((c) => c.id === commentId);
|
|
1301
|
+
if (index === -1) return null;
|
|
1302
|
+
const comment = comments[index];
|
|
1303
|
+
if (!comment.replies) comment.replies = [];
|
|
1304
|
+
comment.replies.push(reply);
|
|
1305
|
+
await this.save(comments);
|
|
1306
|
+
return comment;
|
|
1307
|
+
}
|
|
1308
|
+
async removeReply(commentId, replyId) {
|
|
1309
|
+
const comments = await this.getAll();
|
|
1310
|
+
const index = comments.findIndex((c) => c.id === commentId);
|
|
1311
|
+
if (index === -1) return null;
|
|
1312
|
+
const comment = comments[index];
|
|
1313
|
+
const replyIndex = comment.replies.findIndex((r) => r.id === replyId);
|
|
1314
|
+
if (replyIndex === -1) return null;
|
|
1315
|
+
comment.replies.splice(replyIndex, 1);
|
|
1316
|
+
await this.save(comments);
|
|
1317
|
+
return comment;
|
|
1318
|
+
}
|
|
1319
|
+
async updateReply(commentId, replyId, body) {
|
|
1320
|
+
const comments = await this.getAll();
|
|
1321
|
+
const index = comments.findIndex((c) => c.id === commentId);
|
|
1322
|
+
if (index === -1) return null;
|
|
1323
|
+
const comment = comments[index];
|
|
1324
|
+
const reply = comment.replies.find((r) => r.id === replyId);
|
|
1325
|
+
if (!reply) return null;
|
|
1326
|
+
reply.body = body;
|
|
1327
|
+
await this.save(comments);
|
|
1328
|
+
return comment;
|
|
1329
|
+
}
|
|
1330
|
+
};
|
|
1331
|
+
//#endregion
|
|
1332
|
+
//#region src/lib/plans.ts
|
|
1333
|
+
function newId() {
|
|
1334
|
+
return crypto.randomUUID();
|
|
1335
|
+
}
|
|
1336
|
+
/**
|
|
1337
|
+
* Backfill / repair a plan loaded from disk so it matches the current schema.
|
|
1338
|
+
* Older persisted plans may be missing `versions` and `createdAtPlanVersion`;
|
|
1339
|
+
* we synthesize sensible values so callers can always rely on the new fields.
|
|
1340
|
+
*/
|
|
1341
|
+
function backfillPlan(plan) {
|
|
1342
|
+
const currentVersion = plan.version ?? 1;
|
|
1343
|
+
const have = Array.isArray(plan.versions) ? plan.versions.length : 0;
|
|
1344
|
+
if (!plan.versions || have === 0 || have < currentVersion) {
|
|
1345
|
+
const createdAt = plan.updatedAt ?? plan.createdAt ?? Date.now();
|
|
1346
|
+
const haveEntries = Array.isArray(plan.versions) ? plan.versions : [];
|
|
1347
|
+
const present = new Set(haveEntries.map((v) => v.version));
|
|
1348
|
+
const next = [];
|
|
1349
|
+
for (let i = 1; i <= currentVersion; i++) {
|
|
1350
|
+
const existing = haveEntries.find((v) => v.version === i);
|
|
1351
|
+
if (existing) next.push(existing);
|
|
1352
|
+
else next.push({
|
|
1353
|
+
version: i,
|
|
1354
|
+
body: plan.body,
|
|
1355
|
+
title: plan.title,
|
|
1356
|
+
source: plan.source,
|
|
1357
|
+
model: plan.model,
|
|
1358
|
+
createdAt
|
|
1359
|
+
});
|
|
1360
|
+
}
|
|
1361
|
+
plan.versions = next.filter((v) => v.version >= 1 && v.version <= currentVersion);
|
|
1362
|
+
if (have < currentVersion || !present.has(currentVersion)) markBackfilled(plan);
|
|
1363
|
+
}
|
|
1364
|
+
if (plan.comments) {
|
|
1365
|
+
for (const c of plan.comments) if (typeof c.createdAtPlanVersion !== "number") c.createdAtPlanVersion = plan.version ?? 1;
|
|
1366
|
+
}
|
|
1367
|
+
if (!plan.comments) plan.comments = [];
|
|
1368
|
+
}
|
|
1369
|
+
function markBackfilled(plan) {
|
|
1370
|
+
plan.__backfilledVersions = true;
|
|
1371
|
+
}
|
|
1372
|
+
/** Shared mutation helpers so both stores behave identically. */
|
|
1373
|
+
function applyUpsert(plans, input, now) {
|
|
1374
|
+
if (input.id) {
|
|
1375
|
+
const existing = plans.find((p) => p.id === input.id);
|
|
1376
|
+
if (existing) {
|
|
1377
|
+
existing.title = input.title;
|
|
1378
|
+
existing.body = input.body;
|
|
1379
|
+
if (input.source !== void 0) existing.source = input.source;
|
|
1380
|
+
if (input.model !== void 0) existing.model = input.model;
|
|
1381
|
+
existing.version += 1;
|
|
1382
|
+
existing.updatedAt = now;
|
|
1383
|
+
if (!existing.versions) existing.versions = [];
|
|
1384
|
+
existing.versions.push({
|
|
1385
|
+
version: existing.version,
|
|
1386
|
+
body: existing.body,
|
|
1387
|
+
title: existing.title,
|
|
1388
|
+
source: existing.source,
|
|
1389
|
+
model: existing.model,
|
|
1390
|
+
createdAt: now
|
|
1391
|
+
});
|
|
1392
|
+
existing.decision = "pending";
|
|
1393
|
+
existing.decisionComment = void 0;
|
|
1394
|
+
existing.decidedAt = void 0;
|
|
1395
|
+
return existing;
|
|
1396
|
+
}
|
|
1397
|
+
}
|
|
1398
|
+
const plan = {
|
|
1399
|
+
id: input.id || newId(),
|
|
1400
|
+
title: input.title,
|
|
1401
|
+
body: input.body,
|
|
1402
|
+
source: input.source,
|
|
1403
|
+
model: input.model,
|
|
1404
|
+
createdAt: now,
|
|
1405
|
+
updatedAt: now,
|
|
1406
|
+
version: 1,
|
|
1407
|
+
decision: "pending",
|
|
1408
|
+
comments: [],
|
|
1409
|
+
versions: [{
|
|
1410
|
+
version: 1,
|
|
1411
|
+
body: input.body,
|
|
1412
|
+
title: input.title,
|
|
1413
|
+
source: input.source,
|
|
1414
|
+
model: input.model,
|
|
1415
|
+
createdAt: now
|
|
1416
|
+
}]
|
|
1417
|
+
};
|
|
1418
|
+
plans.push(plan);
|
|
1419
|
+
return plan;
|
|
1420
|
+
}
|
|
1421
|
+
/**
|
|
1422
|
+
* Keep the most-recent `PlanVersion` snapshot in sync with the current `Plan`
|
|
1423
|
+
* fields after a non-resubmit edit (PUT). Doesn't add a new entry — that only
|
|
1424
|
+
* happens via `upsert` (the resubmit flow).
|
|
1425
|
+
*/
|
|
1426
|
+
function syncCurrentVersion(plan) {
|
|
1427
|
+
if (!plan.versions || plan.versions.length === 0) plan.versions = [];
|
|
1428
|
+
if (plan.versions.length === 0) {
|
|
1429
|
+
plan.versions.push({
|
|
1430
|
+
version: plan.version,
|
|
1431
|
+
body: plan.body,
|
|
1432
|
+
title: plan.title,
|
|
1433
|
+
source: plan.source,
|
|
1434
|
+
model: plan.model,
|
|
1435
|
+
createdAt: plan.updatedAt
|
|
1436
|
+
});
|
|
1437
|
+
return;
|
|
1438
|
+
}
|
|
1439
|
+
const last = plan.versions[plan.versions.length - 1];
|
|
1440
|
+
last.body = plan.body;
|
|
1441
|
+
last.title = plan.title;
|
|
1442
|
+
last.source = plan.source;
|
|
1443
|
+
last.model = plan.model;
|
|
1444
|
+
}
|
|
1445
|
+
var FilePlanStore = class {
|
|
1446
|
+
dirPath;
|
|
1447
|
+
filePath;
|
|
1448
|
+
/**
|
|
1449
|
+
* @param storageDir Absolute directory to persist `plans.json` in. Defaults
|
|
1450
|
+
* to the per-repo storage dir under `~/.diffing`, the same place comments
|
|
1451
|
+
* and media live — plans are NEVER written inside the reviewed (consumer)
|
|
1452
|
+
* repo, so a consumer project stays free of any diffing-specific artifacts.
|
|
1453
|
+
* The override exists only so tests can point at a throwaway temp dir.
|
|
1454
|
+
*/
|
|
1455
|
+
constructor(storageDir) {
|
|
1456
|
+
this.dirPath = storageDir ?? getProjectStorageDir();
|
|
1457
|
+
this.filePath = join(this.dirPath, "plans.json");
|
|
1458
|
+
}
|
|
1459
|
+
async getAll() {
|
|
1460
|
+
try {
|
|
1461
|
+
const data = await readFile(this.filePath, "utf-8");
|
|
1462
|
+
const plans = JSON.parse(data);
|
|
1463
|
+
let anyBackfilled = false;
|
|
1464
|
+
for (const p of plans) {
|
|
1465
|
+
const before = Array.isArray(p.versions) ? p.versions.length : 0;
|
|
1466
|
+
const beforeValid = Array.isArray(p.versions) && p.versions.length > 0;
|
|
1467
|
+
backfillPlan(p);
|
|
1468
|
+
const after = Array.isArray(p.versions) ? p.versions.length : 0;
|
|
1469
|
+
if ((!beforeValid || before < (p.version ?? 1)) && after > before) anyBackfilled = true;
|
|
1470
|
+
}
|
|
1471
|
+
if (anyBackfilled) try {
|
|
1472
|
+
await this.save(plans);
|
|
1473
|
+
} catch {}
|
|
1474
|
+
return plans;
|
|
1475
|
+
} catch {
|
|
1476
|
+
return [];
|
|
1477
|
+
}
|
|
1478
|
+
}
|
|
1479
|
+
async get(id) {
|
|
1480
|
+
return (await this.getAll()).find((p) => p.id === id) ?? null;
|
|
1481
|
+
}
|
|
1482
|
+
async getVersion(id, version) {
|
|
1483
|
+
const plan = await this.get(id);
|
|
1484
|
+
if (!plan) return null;
|
|
1485
|
+
return plan.versions.find((v) => v.version === version) ?? null;
|
|
1486
|
+
}
|
|
1487
|
+
async save(plans) {
|
|
1488
|
+
try {
|
|
1489
|
+
await mkdir(this.dirPath, { recursive: true });
|
|
1490
|
+
try {
|
|
1491
|
+
const repoRoot = getRepoRoot();
|
|
1492
|
+
await writeFile(join(this.dirPath, "repo_path.txt"), repoRoot, "utf-8");
|
|
1493
|
+
} catch {}
|
|
1494
|
+
const clean = plans.map((p) => {
|
|
1495
|
+
const copy = { ...p };
|
|
1496
|
+
delete copy.__backfilledVersions;
|
|
1497
|
+
return copy;
|
|
1498
|
+
});
|
|
1499
|
+
await writeFile(this.filePath, JSON.stringify(clean, null, 2), "utf-8");
|
|
1500
|
+
} catch (err) {
|
|
1501
|
+
console.error("Failed to save plans to file:", err);
|
|
1502
|
+
}
|
|
1503
|
+
}
|
|
1504
|
+
async upsert(input) {
|
|
1505
|
+
const plans = await this.getAll();
|
|
1506
|
+
const plan = applyUpsert(plans, input, Date.now());
|
|
1507
|
+
backfillPlan(plan);
|
|
1508
|
+
await this.save(plans);
|
|
1509
|
+
return plan;
|
|
1510
|
+
}
|
|
1511
|
+
async update(id, fields) {
|
|
1512
|
+
const plans = await this.getAll();
|
|
1513
|
+
const plan = plans.find((p) => p.id === id);
|
|
1514
|
+
if (!plan) return null;
|
|
1515
|
+
if (fields.title !== void 0) plan.title = fields.title;
|
|
1516
|
+
if (fields.body !== void 0) plan.body = fields.body;
|
|
1517
|
+
if (fields.source !== void 0) plan.source = fields.source;
|
|
1518
|
+
if (fields.model !== void 0) plan.model = fields.model;
|
|
1519
|
+
plan.updatedAt = Date.now();
|
|
1520
|
+
syncCurrentVersion(plan);
|
|
1521
|
+
backfillPlan(plan);
|
|
1522
|
+
await this.save(plans);
|
|
1523
|
+
return plan;
|
|
1524
|
+
}
|
|
1525
|
+
async remove(id) {
|
|
1526
|
+
const plans = await this.getAll();
|
|
1527
|
+
const idx = plans.findIndex((p) => p.id === id);
|
|
1528
|
+
if (idx === -1) return false;
|
|
1529
|
+
plans.splice(idx, 1);
|
|
1530
|
+
await this.save(plans);
|
|
1531
|
+
return true;
|
|
1532
|
+
}
|
|
1533
|
+
async setDecision(id, decision, decisionComment) {
|
|
1534
|
+
const plans = await this.getAll();
|
|
1535
|
+
const plan = plans.find((p) => p.id === id);
|
|
1536
|
+
if (!plan) return null;
|
|
1537
|
+
plan.decision = decision;
|
|
1538
|
+
plan.decisionComment = decisionComment?.trim() || void 0;
|
|
1539
|
+
plan.decidedAt = Date.now();
|
|
1540
|
+
plan.updatedAt = plan.decidedAt;
|
|
1541
|
+
await this.save(plans);
|
|
1542
|
+
return plan;
|
|
1543
|
+
}
|
|
1544
|
+
async mutate(planId, fn) {
|
|
1545
|
+
const plans = await this.getAll();
|
|
1546
|
+
const plan = plans.find((p) => p.id === planId);
|
|
1547
|
+
if (!plan) return null;
|
|
1548
|
+
if (!plan.comments) plan.comments = [];
|
|
1549
|
+
if (!fn(plan)) return null;
|
|
1550
|
+
backfillPlan(plan);
|
|
1551
|
+
await this.save(plans);
|
|
1552
|
+
return plan;
|
|
1553
|
+
}
|
|
1554
|
+
async addComment(planId, comment) {
|
|
1555
|
+
return this.mutate(planId, (plan) => {
|
|
1556
|
+
if (typeof comment.createdAtPlanVersion !== "number") comment.createdAtPlanVersion = plan.version ?? 1;
|
|
1557
|
+
plan.comments.push(comment);
|
|
1558
|
+
return true;
|
|
1559
|
+
});
|
|
1560
|
+
}
|
|
1561
|
+
async updateComment(planId, commentId, fields) {
|
|
1562
|
+
return this.mutate(planId, (plan) => {
|
|
1563
|
+
const c = plan.comments.find((x) => x.id === commentId);
|
|
1564
|
+
if (!c) return false;
|
|
1565
|
+
if (fields.body !== void 0) c.body = fields.body;
|
|
1566
|
+
if (fields.status !== void 0) c.status = fields.status;
|
|
1567
|
+
return true;
|
|
1568
|
+
});
|
|
1569
|
+
}
|
|
1570
|
+
async removeComment(planId, commentId) {
|
|
1571
|
+
return this.mutate(planId, (plan) => {
|
|
1572
|
+
const idx = plan.comments.findIndex((x) => x.id === commentId);
|
|
1573
|
+
if (idx === -1) return false;
|
|
1574
|
+
plan.comments.splice(idx, 1);
|
|
1575
|
+
return true;
|
|
1576
|
+
});
|
|
1577
|
+
}
|
|
1578
|
+
async addReply(planId, commentId, reply) {
|
|
1579
|
+
return this.mutate(planId, (plan) => {
|
|
1580
|
+
const c = plan.comments.find((x) => x.id === commentId);
|
|
1581
|
+
if (!c) return false;
|
|
1582
|
+
if (typeof c.createdAtPlanVersion === "number" && typeof reply.createdAtPlanVersion !== "number") reply.createdAtPlanVersion = c.createdAtPlanVersion;
|
|
1583
|
+
if (!c.replies) c.replies = [];
|
|
1584
|
+
c.replies.push(reply);
|
|
1585
|
+
return true;
|
|
1586
|
+
});
|
|
1587
|
+
}
|
|
1588
|
+
async removeReply(planId, commentId, replyId) {
|
|
1589
|
+
return this.mutate(planId, (plan) => {
|
|
1590
|
+
const c = plan.comments.find((x) => x.id === commentId);
|
|
1591
|
+
if (!c) return false;
|
|
1592
|
+
const idx = c.replies.findIndex((r) => r.id === replyId);
|
|
1593
|
+
if (idx === -1) return false;
|
|
1594
|
+
c.replies.splice(idx, 1);
|
|
1595
|
+
return true;
|
|
1596
|
+
});
|
|
1597
|
+
}
|
|
1598
|
+
async updateReply(planId, commentId, replyId, body) {
|
|
1599
|
+
return this.mutate(planId, (plan) => {
|
|
1600
|
+
const c = plan.comments.find((x) => x.id === commentId);
|
|
1601
|
+
if (!c) return false;
|
|
1602
|
+
const reply = c.replies.find((r) => r.id === replyId);
|
|
1603
|
+
if (!reply) return false;
|
|
1604
|
+
reply.body = body;
|
|
1605
|
+
return true;
|
|
1606
|
+
});
|
|
1607
|
+
}
|
|
1608
|
+
};
|
|
1609
|
+
//#endregion
|
|
1610
|
+
//#region src/lib/state.ts
|
|
1611
|
+
var FileUiStateStore = class {
|
|
1612
|
+
dirPath;
|
|
1613
|
+
filePath;
|
|
1614
|
+
constructor(storageDir) {
|
|
1615
|
+
this.dirPath = storageDir ?? getProjectStorageDir();
|
|
1616
|
+
this.filePath = join(this.dirPath, "ui-state.json");
|
|
1617
|
+
}
|
|
1618
|
+
async getAll() {
|
|
1619
|
+
try {
|
|
1620
|
+
const data = await readFile(this.filePath, "utf-8");
|
|
1621
|
+
return JSON.parse(data);
|
|
1622
|
+
} catch {
|
|
1623
|
+
return {};
|
|
1624
|
+
}
|
|
1625
|
+
}
|
|
1626
|
+
async setAll(state) {
|
|
1627
|
+
await this.save(state);
|
|
1628
|
+
}
|
|
1629
|
+
async save(state) {
|
|
1630
|
+
try {
|
|
1631
|
+
await mkdir(this.dirPath, { recursive: true });
|
|
1632
|
+
try {
|
|
1633
|
+
const repoRoot = getRepoRoot();
|
|
1634
|
+
await writeFile(join(this.dirPath, "repo_path.txt"), repoRoot, "utf-8");
|
|
1635
|
+
} catch {}
|
|
1636
|
+
await writeFile(this.filePath, JSON.stringify(state, null, 2), "utf-8");
|
|
1637
|
+
} catch (err) {
|
|
1638
|
+
console.error("Failed to save UI state to file:", err);
|
|
1639
|
+
}
|
|
1640
|
+
}
|
|
1641
|
+
};
|
|
1642
|
+
//#endregion
|
|
1643
|
+
//#region src/lib/editor-launcher.ts
|
|
1644
|
+
function resolveEditorCommand(editor, absolutePath, platform = process.platform, env = process.env) {
|
|
1645
|
+
switch (editor) {
|
|
1646
|
+
case "vscode": return guiEditorCommand("code", absolutePath, platform);
|
|
1647
|
+
case "zed": return guiEditorCommand("zed", absolutePath, platform);
|
|
1648
|
+
case "vim": return terminalEditorCommand("vim", absolutePath, platform, env);
|
|
1649
|
+
case "neovim": return terminalEditorCommand("nvim", absolutePath, platform, env);
|
|
1650
|
+
default: return null;
|
|
1651
|
+
}
|
|
1652
|
+
}
|
|
1653
|
+
function guiEditorCommand(bin, absolutePath, platform) {
|
|
1654
|
+
if (platform === "win32") return {
|
|
1655
|
+
cmd: `${bin}.cmd`,
|
|
1656
|
+
args: [absolutePath]
|
|
1657
|
+
};
|
|
1658
|
+
return {
|
|
1659
|
+
cmd: bin,
|
|
1660
|
+
args: [absolutePath]
|
|
1661
|
+
};
|
|
1662
|
+
}
|
|
1663
|
+
function terminalEditorCommand(bin, absolutePath, platform, env) {
|
|
1664
|
+
if (platform === "darwin") return {
|
|
1665
|
+
cmd: "osascript",
|
|
1666
|
+
args: ["-e", `tell application "Terminal" to do script "${bin} \\"${absolutePath.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}\\""`]
|
|
1667
|
+
};
|
|
1668
|
+
if (platform === "win32") return {
|
|
1669
|
+
cmd: "cmd.exe",
|
|
1670
|
+
args: [
|
|
1671
|
+
"/c",
|
|
1672
|
+
"start",
|
|
1673
|
+
"\"\"",
|
|
1674
|
+
"cmd.exe",
|
|
1675
|
+
"/k",
|
|
1676
|
+
bin,
|
|
1677
|
+
absolutePath
|
|
1678
|
+
]
|
|
1679
|
+
};
|
|
1680
|
+
const term = env.TERMINAL?.trim();
|
|
1681
|
+
if (term) return {
|
|
1682
|
+
cmd: term,
|
|
1683
|
+
args: [
|
|
1684
|
+
"-e",
|
|
1685
|
+
bin,
|
|
1686
|
+
absolutePath
|
|
1687
|
+
]
|
|
1688
|
+
};
|
|
1689
|
+
return {
|
|
1690
|
+
cmd: "x-terminal-emulator",
|
|
1691
|
+
args: [
|
|
1692
|
+
"-e",
|
|
1693
|
+
bin,
|
|
1694
|
+
absolutePath
|
|
1695
|
+
]
|
|
1696
|
+
};
|
|
1697
|
+
}
|
|
1698
|
+
//#endregion
|
|
1699
|
+
//#region src/lib/review-session.ts
|
|
1700
|
+
var ReviewSession = class {
|
|
1701
|
+
round = 0;
|
|
1702
|
+
lastSentAt = null;
|
|
1703
|
+
lastPayload = null;
|
|
1704
|
+
waiters = /* @__PURE__ */ new Set();
|
|
1705
|
+
onStatusChange;
|
|
1706
|
+
constructor(onStatusChange) {
|
|
1707
|
+
this.onStatusChange = onStatusChange;
|
|
1708
|
+
}
|
|
1709
|
+
/**
|
|
1710
|
+
* Block until the next `send()` or until `timeoutMs` elapses.
|
|
1711
|
+
*
|
|
1712
|
+
* If `sinceRound` is provided and is behind the current round, a send already
|
|
1713
|
+
* happened since the client last checked → resolve immediately with the
|
|
1714
|
+
* cached payload (the race guard). Otherwise register a waiter.
|
|
1715
|
+
*/
|
|
1716
|
+
await(opts) {
|
|
1717
|
+
const { sinceRound, timeoutMs, signal } = opts;
|
|
1718
|
+
if (sinceRound !== void 0 && sinceRound < this.round && this.lastPayload) return Promise.resolve({
|
|
1719
|
+
status: "released",
|
|
1720
|
+
payload: this.lastPayload
|
|
1721
|
+
});
|
|
1722
|
+
if (signal?.aborted) return Promise.resolve({
|
|
1723
|
+
status: "keep-waiting",
|
|
1724
|
+
round: this.round
|
|
1725
|
+
});
|
|
1726
|
+
return new Promise((resolve) => {
|
|
1727
|
+
const waiter = {
|
|
1728
|
+
resolve,
|
|
1729
|
+
timer: setTimeout(() => {
|
|
1730
|
+
this.removeWaiter(waiter);
|
|
1731
|
+
resolve({
|
|
1732
|
+
status: "keep-waiting",
|
|
1733
|
+
round: this.round
|
|
1734
|
+
});
|
|
1735
|
+
}, timeoutMs),
|
|
1736
|
+
cleanup: () => {}
|
|
1737
|
+
};
|
|
1738
|
+
if (signal) {
|
|
1739
|
+
const onAbort = () => {
|
|
1740
|
+
this.removeWaiter(waiter);
|
|
1741
|
+
resolve({
|
|
1742
|
+
status: "keep-waiting",
|
|
1743
|
+
round: this.round
|
|
1744
|
+
});
|
|
1745
|
+
};
|
|
1746
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
1747
|
+
waiter.cleanup = () => signal.removeEventListener("abort", onAbort);
|
|
1748
|
+
}
|
|
1749
|
+
this.waiters.add(waiter);
|
|
1750
|
+
this.emitStatus();
|
|
1751
|
+
});
|
|
1752
|
+
}
|
|
1753
|
+
/** Capture a review batch and release every blocked waiter. */
|
|
1754
|
+
send(input) {
|
|
1755
|
+
this.round += 1;
|
|
1756
|
+
this.lastSentAt = input.sentAt;
|
|
1757
|
+
const payload = {
|
|
1758
|
+
round: this.round,
|
|
1759
|
+
sentAt: input.sentAt,
|
|
1760
|
+
commentXml: input.commentXml,
|
|
1761
|
+
openCount: input.openCount,
|
|
1762
|
+
comments: input.comments,
|
|
1763
|
+
decision: input.decision,
|
|
1764
|
+
mode: input.mode ?? "standard"
|
|
1765
|
+
};
|
|
1766
|
+
this.lastPayload = payload;
|
|
1767
|
+
const waiters = [...this.waiters];
|
|
1768
|
+
this.waiters.clear();
|
|
1769
|
+
for (const waiter of waiters) {
|
|
1770
|
+
clearTimeout(waiter.timer);
|
|
1771
|
+
waiter.cleanup();
|
|
1772
|
+
waiter.resolve({
|
|
1773
|
+
status: "released",
|
|
1774
|
+
payload
|
|
1775
|
+
});
|
|
1776
|
+
}
|
|
1777
|
+
this.emitStatus();
|
|
1778
|
+
return payload;
|
|
1779
|
+
}
|
|
1780
|
+
snapshot() {
|
|
1781
|
+
return {
|
|
1782
|
+
round: this.round,
|
|
1783
|
+
waiters: this.waiters.size,
|
|
1784
|
+
lastSentAt: this.lastSentAt
|
|
1785
|
+
};
|
|
1786
|
+
}
|
|
1787
|
+
removeWaiter(waiter) {
|
|
1788
|
+
if (!this.waiters.delete(waiter)) return;
|
|
1789
|
+
clearTimeout(waiter.timer);
|
|
1790
|
+
waiter.cleanup();
|
|
1791
|
+
this.emitStatus();
|
|
1792
|
+
}
|
|
1793
|
+
emitStatus() {
|
|
1794
|
+
this.onStatusChange?.(this.snapshot());
|
|
1795
|
+
}
|
|
1796
|
+
};
|
|
1797
|
+
//#endregion
|
|
1798
|
+
//#region src/lib/plan-review-session.ts
|
|
1799
|
+
var PlanReviewSession = class {
|
|
1800
|
+
round = 0;
|
|
1801
|
+
lastDecidedAt = null;
|
|
1802
|
+
lastPayload = null;
|
|
1803
|
+
waiters = /* @__PURE__ */ new Set();
|
|
1804
|
+
onStatusChange;
|
|
1805
|
+
constructor(onStatusChange) {
|
|
1806
|
+
this.onStatusChange = onStatusChange;
|
|
1807
|
+
}
|
|
1808
|
+
/** Block until the next `decide()` or until `timeoutMs` elapses. */
|
|
1809
|
+
await(opts) {
|
|
1810
|
+
const { sinceRound, timeoutMs, signal } = opts;
|
|
1811
|
+
if (sinceRound !== void 0 && sinceRound < this.round && this.lastPayload) return Promise.resolve({
|
|
1812
|
+
status: "released",
|
|
1813
|
+
payload: this.lastPayload
|
|
1814
|
+
});
|
|
1815
|
+
if (signal?.aborted) return Promise.resolve({
|
|
1816
|
+
status: "keep-waiting",
|
|
1817
|
+
round: this.round
|
|
1818
|
+
});
|
|
1819
|
+
return new Promise((resolve) => {
|
|
1820
|
+
const waiter = {
|
|
1821
|
+
resolve,
|
|
1822
|
+
timer: setTimeout(() => {
|
|
1823
|
+
this.removeWaiter(waiter);
|
|
1824
|
+
resolve({
|
|
1825
|
+
status: "keep-waiting",
|
|
1826
|
+
round: this.round
|
|
1827
|
+
});
|
|
1828
|
+
}, timeoutMs),
|
|
1829
|
+
cleanup: () => {}
|
|
1830
|
+
};
|
|
1831
|
+
if (signal) {
|
|
1832
|
+
const onAbort = () => {
|
|
1833
|
+
this.removeWaiter(waiter);
|
|
1834
|
+
resolve({
|
|
1835
|
+
status: "keep-waiting",
|
|
1836
|
+
round: this.round
|
|
1837
|
+
});
|
|
1838
|
+
};
|
|
1839
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
1840
|
+
waiter.cleanup = () => signal.removeEventListener("abort", onAbort);
|
|
1841
|
+
}
|
|
1842
|
+
this.waiters.add(waiter);
|
|
1843
|
+
this.emitStatus();
|
|
1844
|
+
});
|
|
1845
|
+
}
|
|
1846
|
+
/** Capture a decision and release every blocked waiter. */
|
|
1847
|
+
decide(input) {
|
|
1848
|
+
this.round += 1;
|
|
1849
|
+
this.lastDecidedAt = input.sentAt;
|
|
1850
|
+
const payload = {
|
|
1851
|
+
round: this.round,
|
|
1852
|
+
sentAt: input.sentAt,
|
|
1853
|
+
planId: input.planId,
|
|
1854
|
+
decision: input.decision,
|
|
1855
|
+
decisionComment: input.decisionComment,
|
|
1856
|
+
reviewXml: input.reviewXml,
|
|
1857
|
+
openCommentCount: input.openCommentCount,
|
|
1858
|
+
plan: input.plan,
|
|
1859
|
+
mode: input.mode ?? "standard"
|
|
1860
|
+
};
|
|
1861
|
+
this.lastPayload = payload;
|
|
1862
|
+
const waiters = [...this.waiters];
|
|
1863
|
+
this.waiters.clear();
|
|
1864
|
+
for (const waiter of waiters) {
|
|
1865
|
+
clearTimeout(waiter.timer);
|
|
1866
|
+
waiter.cleanup();
|
|
1867
|
+
waiter.resolve({
|
|
1868
|
+
status: "released",
|
|
1869
|
+
payload
|
|
1870
|
+
});
|
|
1871
|
+
}
|
|
1872
|
+
this.emitStatus();
|
|
1873
|
+
return payload;
|
|
1874
|
+
}
|
|
1875
|
+
snapshot() {
|
|
1876
|
+
return {
|
|
1877
|
+
round: this.round,
|
|
1878
|
+
waiters: this.waiters.size,
|
|
1879
|
+
lastDecidedAt: this.lastDecidedAt
|
|
1880
|
+
};
|
|
1881
|
+
}
|
|
1882
|
+
removeWaiter(waiter) {
|
|
1883
|
+
if (!this.waiters.delete(waiter)) return;
|
|
1884
|
+
clearTimeout(waiter.timer);
|
|
1885
|
+
waiter.cleanup();
|
|
1886
|
+
this.emitStatus();
|
|
1887
|
+
}
|
|
1888
|
+
emitStatus() {
|
|
1889
|
+
this.onStatusChange?.(this.snapshot());
|
|
1890
|
+
}
|
|
1891
|
+
};
|
|
1892
|
+
//#endregion
|
|
1893
|
+
//#region src/lib/pr-session.ts
|
|
1894
|
+
var FilePrSessionStore = class {
|
|
1895
|
+
dirPath;
|
|
1896
|
+
filePath;
|
|
1897
|
+
constructor(storageDir) {
|
|
1898
|
+
this.dirPath = storageDir ?? getProjectStorageDir();
|
|
1899
|
+
this.filePath = join(this.dirPath, "pr-session.json");
|
|
1900
|
+
}
|
|
1901
|
+
async get() {
|
|
1902
|
+
try {
|
|
1903
|
+
const data = await readFile(this.filePath, "utf-8");
|
|
1904
|
+
return JSON.parse(data);
|
|
1905
|
+
} catch {
|
|
1906
|
+
return null;
|
|
1907
|
+
}
|
|
1908
|
+
}
|
|
1909
|
+
async save(session) {
|
|
1910
|
+
try {
|
|
1911
|
+
await mkdir(this.dirPath, { recursive: true });
|
|
1912
|
+
await writeFile(this.filePath, JSON.stringify(session, null, 2), "utf-8");
|
|
1913
|
+
} catch (err) {
|
|
1914
|
+
console.error("Failed to save pr-session to file:", err);
|
|
1915
|
+
}
|
|
1916
|
+
}
|
|
1917
|
+
async set(session) {
|
|
1918
|
+
await this.save(session);
|
|
1919
|
+
}
|
|
1920
|
+
async update(fields) {
|
|
1921
|
+
const current = await this.get();
|
|
1922
|
+
if (!current) return null;
|
|
1923
|
+
const next = {
|
|
1924
|
+
...current,
|
|
1925
|
+
...fields
|
|
1926
|
+
};
|
|
1927
|
+
await this.save(next);
|
|
1928
|
+
return next;
|
|
1929
|
+
}
|
|
1930
|
+
async clear() {
|
|
1931
|
+
try {
|
|
1932
|
+
const { unlink } = await import("node:fs/promises");
|
|
1933
|
+
await unlink(this.filePath);
|
|
1934
|
+
} catch {}
|
|
1935
|
+
}
|
|
1936
|
+
};
|
|
1937
|
+
//#endregion
|
|
1938
|
+
//#region src/lib/github.ts
|
|
1939
|
+
const execFileAsync = promisify(execFile);
|
|
1940
|
+
/**
|
|
1941
|
+
* Probe the `gh` CLI: is it on `$PATH`, and is the user authenticated?
|
|
1942
|
+
* Never throws — returns a permissive `available: false` on any failure.
|
|
1943
|
+
*/
|
|
1944
|
+
async function detectGhCli() {
|
|
1945
|
+
try {
|
|
1946
|
+
const { stdout } = await execFileAsync("gh", ["--version"], { encoding: "utf-8" });
|
|
1947
|
+
const version = stdout.split("\n")[0]?.trim() || void 0;
|
|
1948
|
+
try {
|
|
1949
|
+
const result = await execFileAsync("gh", ["auth", "status"], { encoding: "utf-8" });
|
|
1950
|
+
return {
|
|
1951
|
+
available: true,
|
|
1952
|
+
authenticated: true,
|
|
1953
|
+
version,
|
|
1954
|
+
user: parseGhAuthStatusUser(`${result.stdout ?? ""}\n${result.stderr ?? ""}`)
|
|
1955
|
+
};
|
|
1956
|
+
} catch {
|
|
1957
|
+
return {
|
|
1958
|
+
available: true,
|
|
1959
|
+
authenticated: false,
|
|
1960
|
+
version
|
|
1961
|
+
};
|
|
1962
|
+
}
|
|
1963
|
+
} catch {
|
|
1964
|
+
return {
|
|
1965
|
+
available: false,
|
|
1966
|
+
authenticated: false
|
|
1967
|
+
};
|
|
1968
|
+
}
|
|
1969
|
+
}
|
|
1970
|
+
/**
|
|
1971
|
+
* Extract the GitHub username from `gh auth status` output. Exported for
|
|
1972
|
+
* unit tests — see `__tests__/github-auth.test.ts`.
|
|
1973
|
+
*
|
|
1974
|
+
* Real-world samples:
|
|
1975
|
+
* `✓ Logged in to github.com account octocat (keyring)` (gh 2.40+)
|
|
1976
|
+
* `Logged in to github.com as octocat (oauth_token)` (older gh)
|
|
1977
|
+
* ` ✓ Logged in to github.com as octocat (~/.config/gh/hosts.yml)`
|
|
1978
|
+
*/
|
|
1979
|
+
function parseGhAuthStatusUser(output) {
|
|
1980
|
+
const account = /Logged in to [^\s]+ account\s+([^\s(]+)/.exec(output);
|
|
1981
|
+
if (account) return account[1];
|
|
1982
|
+
return /Logged in to [^\s]+ as\s+([^\s(]+)/.exec(output)?.[1];
|
|
1983
|
+
}
|
|
1984
|
+
/** Read the most-preferred GitHub token from the env. */
|
|
1985
|
+
function readGithubToken() {
|
|
1986
|
+
const env = process.env;
|
|
1987
|
+
return env.GH_TOKEN || env.GITHUB_TOKEN || env.GITHUB_API_TOKEN || null;
|
|
1988
|
+
}
|
|
1989
|
+
function parsePrRef(input, cwdRepo) {
|
|
1990
|
+
const trimmed = input.trim();
|
|
1991
|
+
const urlMatch = /^https?:\/\/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/i.exec(trimmed);
|
|
1992
|
+
if (urlMatch) return {
|
|
1993
|
+
owner: urlMatch[1],
|
|
1994
|
+
repo: urlMatch[2],
|
|
1995
|
+
pullNumber: Number(urlMatch[3]),
|
|
1996
|
+
ref: trimmed
|
|
1997
|
+
};
|
|
1998
|
+
const shorthand = /^([^/\s]+)\/([^#\s]+)#(\d+)$/.exec(trimmed);
|
|
1999
|
+
if (shorthand) return {
|
|
2000
|
+
owner: shorthand[1],
|
|
2001
|
+
repo: shorthand[2],
|
|
2002
|
+
pullNumber: Number(shorthand[3]),
|
|
2003
|
+
ref: trimmed
|
|
2004
|
+
};
|
|
2005
|
+
const bare = /^#?(\d+)$/.exec(trimmed);
|
|
2006
|
+
if (bare && cwdRepo) return {
|
|
2007
|
+
owner: cwdRepo.owner,
|
|
2008
|
+
repo: cwdRepo.repo,
|
|
2009
|
+
pullNumber: Number(bare[1]),
|
|
2010
|
+
ref: trimmed
|
|
2011
|
+
};
|
|
2012
|
+
if (bare) throw new Error(`Cannot resolve bare PR number "${trimmed}" — run from inside the target repo, or pass a full URL or \`owner/repo#1234\`.`);
|
|
2013
|
+
throw new Error(`Unrecognised PR ref: ${trimmed}`);
|
|
2014
|
+
}
|
|
2015
|
+
/**
|
|
2016
|
+
* Best-effort: derive `{ owner, repo }` for the current working directory
|
|
2017
|
+
* from `git remote get-url origin`. Falls back to throwing.
|
|
2018
|
+
*/
|
|
2019
|
+
async function detectCwdRepo() {
|
|
2020
|
+
try {
|
|
2021
|
+
const { stdout } = await execFileAsync("git", [
|
|
2022
|
+
"remote",
|
|
2023
|
+
"get-url",
|
|
2024
|
+
"origin"
|
|
2025
|
+
], { encoding: "utf-8" });
|
|
2026
|
+
const url = stdout.trim();
|
|
2027
|
+
const m = /github\.com[:/]([^/\s]+)\/([^/\s]+?)(?:\.git)?\s*$/.exec(url);
|
|
2028
|
+
if (m) return {
|
|
2029
|
+
owner: m[1],
|
|
2030
|
+
repo: m[2]
|
|
2031
|
+
};
|
|
2032
|
+
} catch {}
|
|
2033
|
+
return null;
|
|
2034
|
+
}
|
|
2035
|
+
/**
|
|
2036
|
+
* Fetch PR metadata + diff + existing review comments using `gh`.
|
|
2037
|
+
* Throws with a user-readable message on failure.
|
|
2038
|
+
*/
|
|
2039
|
+
async function fetchPrMetadataViaGh(resolved) {
|
|
2040
|
+
const args = ["-R", `${resolved.owner}/${resolved.repo}`];
|
|
2041
|
+
let metaJson;
|
|
2042
|
+
try {
|
|
2043
|
+
const { stdout } = await execFileAsync("gh", [
|
|
2044
|
+
"pr",
|
|
2045
|
+
"view",
|
|
2046
|
+
String(resolved.pullNumber),
|
|
2047
|
+
...args,
|
|
2048
|
+
"--json",
|
|
2049
|
+
"number,title,url,author,baseRefOid,headRefOid,additions,deletions,changedFiles,headRepositoryOwner,headRepository"
|
|
2050
|
+
], {
|
|
2051
|
+
encoding: "utf-8",
|
|
2052
|
+
maxBuffer: 20 * 1024 * 1024
|
|
2053
|
+
});
|
|
2054
|
+
metaJson = JSON.parse(stdout);
|
|
2055
|
+
} catch (err) {
|
|
2056
|
+
const stderr = err?.stderr || err?.message || "unknown error";
|
|
2057
|
+
throw new Error(`gh pr view failed: ${stderr.trim()}`);
|
|
2058
|
+
}
|
|
2059
|
+
let diff = "";
|
|
2060
|
+
try {
|
|
2061
|
+
const { stdout } = await execFileAsync("gh", [
|
|
2062
|
+
"pr",
|
|
2063
|
+
"diff",
|
|
2064
|
+
String(resolved.pullNumber),
|
|
2065
|
+
...args
|
|
2066
|
+
], {
|
|
2067
|
+
encoding: "utf-8",
|
|
2068
|
+
maxBuffer: 50 * 1024 * 1024
|
|
2069
|
+
});
|
|
2070
|
+
diff = stdout;
|
|
2071
|
+
} catch (err) {
|
|
2072
|
+
const stderr = err?.stderr || err?.message || "unknown error";
|
|
2073
|
+
throw new Error(`gh pr diff failed: ${stderr.trim()}`);
|
|
2074
|
+
}
|
|
2075
|
+
const headOwner = metaJson.headRepositoryOwner?.login || metaJson.headRepository?.owner?.login || resolved.owner;
|
|
2076
|
+
const repoName = metaJson.headRepository?.name || resolved.repo;
|
|
2077
|
+
const author = metaJson.author ? {
|
|
2078
|
+
login: metaJson.author.login,
|
|
2079
|
+
avatarUrl: metaJson.author.avatarUrl
|
|
2080
|
+
} : null;
|
|
2081
|
+
const existingComments = await fetchExistingCommentsViaGh(resolved);
|
|
2082
|
+
return {
|
|
2083
|
+
number: metaJson.number,
|
|
2084
|
+
title: metaJson.title,
|
|
2085
|
+
url: metaJson.url,
|
|
2086
|
+
author,
|
|
2087
|
+
baseSha: metaJson.baseRefOid,
|
|
2088
|
+
headSha: metaJson.headRefOid,
|
|
2089
|
+
additions: metaJson.additions,
|
|
2090
|
+
deletions: metaJson.deletions,
|
|
2091
|
+
changedFiles: metaJson.changedFiles,
|
|
2092
|
+
owner: headOwner,
|
|
2093
|
+
repo: repoName,
|
|
2094
|
+
diff,
|
|
2095
|
+
existingComments
|
|
2096
|
+
};
|
|
2097
|
+
}
|
|
2098
|
+
async function fetchExistingCommentsViaGh(resolved) {
|
|
2099
|
+
`${resolved.owner}${resolved.repo}`;
|
|
2100
|
+
const endpoint = `repos/${resolved.owner}/${resolved.repo}/pulls/${resolved.pullNumber}/comments`;
|
|
2101
|
+
const reviewsEndpoint = `repos/${resolved.owner}/${resolved.repo}/pulls/${resolved.pullNumber}/reviews`;
|
|
2102
|
+
let reviews = [];
|
|
2103
|
+
try {
|
|
2104
|
+
const { stdout } = await execFileAsync("gh", [
|
|
2105
|
+
"api",
|
|
2106
|
+
reviewsEndpoint,
|
|
2107
|
+
"--paginate"
|
|
2108
|
+
], {
|
|
2109
|
+
encoding: "utf-8",
|
|
2110
|
+
maxBuffer: 20 * 1024 * 1024
|
|
2111
|
+
});
|
|
2112
|
+
const all = JSON.parse(stdout);
|
|
2113
|
+
if (Array.isArray(all)) reviews = all;
|
|
2114
|
+
} catch {}
|
|
2115
|
+
reviews.sort((a, b) => a.submitted_at < b.submitted_at ? 1 : -1);
|
|
2116
|
+
const recentReviews = reviews.slice(0, 50);
|
|
2117
|
+
const reviewStateById = /* @__PURE__ */ new Map();
|
|
2118
|
+
for (const r of recentReviews) reviewStateById.set(r.id, r.state);
|
|
2119
|
+
let commentsRaw = [];
|
|
2120
|
+
try {
|
|
2121
|
+
const { stdout } = await execFileAsync("gh", [
|
|
2122
|
+
"api",
|
|
2123
|
+
`${endpoint}?per_page=100`,
|
|
2124
|
+
"--paginate"
|
|
2125
|
+
], {
|
|
2126
|
+
encoding: "utf-8",
|
|
2127
|
+
maxBuffer: 20 * 1024 * 1024
|
|
2128
|
+
});
|
|
2129
|
+
const parsed = JSON.parse(stdout);
|
|
2130
|
+
if (Array.isArray(parsed)) commentsRaw = parsed;
|
|
2131
|
+
} catch {
|
|
2132
|
+
return [];
|
|
2133
|
+
}
|
|
2134
|
+
const byId = /* @__PURE__ */ new Map();
|
|
2135
|
+
for (const c of commentsRaw) byId.set(c.id, c);
|
|
2136
|
+
const replyByParent = /* @__PURE__ */ new Map();
|
|
2137
|
+
for (const c of commentsRaw) if (c.in_reply_to_id && byId.has(c.in_reply_to_id)) {
|
|
2138
|
+
const list = replyByParent.get(c.in_reply_to_id) ?? [];
|
|
2139
|
+
list.push(c);
|
|
2140
|
+
replyByParent.set(c.in_reply_to_id, list);
|
|
2141
|
+
}
|
|
2142
|
+
const tops = [];
|
|
2143
|
+
for (const c of commentsRaw) {
|
|
2144
|
+
if (c.in_reply_to_id) continue;
|
|
2145
|
+
const state = c.pull_request_review_id ? reviewStateById.get(c.pull_request_review_id) ?? null : null;
|
|
2146
|
+
tops.push({
|
|
2147
|
+
id: c.id,
|
|
2148
|
+
author: c.user ? {
|
|
2149
|
+
login: c.user.login,
|
|
2150
|
+
avatarUrl: c.user.avatar_url
|
|
2151
|
+
} : null,
|
|
2152
|
+
body: c.body ?? "",
|
|
2153
|
+
path: c.path,
|
|
2154
|
+
line: typeof c.line === "number" ? c.line : null,
|
|
2155
|
+
side: c.side === "LEFT" || c.side === "RIGHT" ? c.side : null,
|
|
2156
|
+
createdAt: c.created_at,
|
|
2157
|
+
updatedAt: c.updated_at,
|
|
2158
|
+
state,
|
|
2159
|
+
replies: (replyByParent.get(c.id) ?? []).map((r) => ({
|
|
2160
|
+
id: r.id,
|
|
2161
|
+
author: r.user ? {
|
|
2162
|
+
login: r.user.login,
|
|
2163
|
+
avatarUrl: r.user.avatar_url
|
|
2164
|
+
} : null,
|
|
2165
|
+
body: r.body ?? "",
|
|
2166
|
+
createdAt: r.created_at,
|
|
2167
|
+
updatedAt: r.updated_at
|
|
2168
|
+
})),
|
|
2169
|
+
isOutdated: typeof c.position === "number" && c.position === null ? false : false
|
|
2170
|
+
});
|
|
2171
|
+
}
|
|
2172
|
+
tops.sort((a, b) => {
|
|
2173
|
+
if (a.path !== b.path) return a.path.localeCompare(b.path);
|
|
2174
|
+
return (a.line ?? 0) - (b.line ?? 0);
|
|
2175
|
+
});
|
|
2176
|
+
return tops;
|
|
2177
|
+
}
|
|
2178
|
+
/**
|
|
2179
|
+
* POST the review to GitHub. Prefers `gh api` (uses the user's existing
|
|
2180
|
+
* `gh auth`); falls back to a direct HTTP call with `$GITHUB_TOKEN` when
|
|
2181
|
+
* `gh` is unavailable.
|
|
2182
|
+
*/
|
|
2183
|
+
async function submitReview(input) {
|
|
2184
|
+
const gh = await detectGhCli();
|
|
2185
|
+
if (gh.available && gh.authenticated) return submitViaGh(input);
|
|
2186
|
+
const token = readGithubToken();
|
|
2187
|
+
if (token) return submitViaToken(input, token);
|
|
2188
|
+
return {
|
|
2189
|
+
ok: false,
|
|
2190
|
+
authSource: "none",
|
|
2191
|
+
error: "Cannot submit: no `gh` CLI on $PATH (or not authenticated) and no $GITHUB_TOKEN env var. Run `gh auth login` or set $GITHUB_TOKEN."
|
|
2192
|
+
};
|
|
2193
|
+
}
|
|
2194
|
+
/**
|
|
2195
|
+
* Map our `PrDecision` to the GitHub REST event string. Exported so the
|
|
2196
|
+
* test suite can pin the mapping (including the `rejected → REQUEST_CHANGES`
|
|
2197
|
+
* quirk, which exists because GitHub has no REJECT event).
|
|
2198
|
+
*/
|
|
2199
|
+
function decisionToEvent(decision) {
|
|
2200
|
+
if (decision === "approve") return "APPROVE";
|
|
2201
|
+
if (decision === "request-changes") return "REQUEST_CHANGES";
|
|
2202
|
+
return "COMMENT";
|
|
2203
|
+
}
|
|
2204
|
+
/**
|
|
2205
|
+
* Expand multi-line `ReviewComment`s into N single-line `gh`-shaped entries
|
|
2206
|
+
* with `[part N/M]` prefixes. Exported for the test suite — the body of the
|
|
2207
|
+
* caller (`buildReviewPayload`) is otherwise private.
|
|
2208
|
+
*
|
|
2209
|
+
* Skips: non-`open` comments, file-level comments (`lineNumber === 0`).
|
|
2210
|
+
* Strips the `a/` / `b/` prefix from the file path (GitHub expects PR-relative
|
|
2211
|
+
* paths in the `path` field).
|
|
2212
|
+
*/
|
|
2213
|
+
function expandMultiLineComments(comments) {
|
|
2214
|
+
const flat = [];
|
|
2215
|
+
for (const c of comments) {
|
|
2216
|
+
if (c.status !== "open") continue;
|
|
2217
|
+
if (c.lineNumber === 0) continue;
|
|
2218
|
+
const path = stripBPrefix(c.filePath);
|
|
2219
|
+
const start = c.startLineNumber && c.startLineNumber < c.lineNumber ? c.startLineNumber : c.lineNumber;
|
|
2220
|
+
const end = c.lineNumber;
|
|
2221
|
+
const span = end - start + 1;
|
|
2222
|
+
if (span > 1) for (let i = 0; i < span; i++) flat.push({
|
|
2223
|
+
path,
|
|
2224
|
+
line: start + i,
|
|
2225
|
+
side: "RIGHT",
|
|
2226
|
+
body: `[part ${i + 1}/${span}]\n${c.body}`
|
|
2227
|
+
});
|
|
2228
|
+
else flat.push({
|
|
2229
|
+
path,
|
|
2230
|
+
line: end,
|
|
2231
|
+
side: "RIGHT",
|
|
2232
|
+
body: c.body
|
|
2233
|
+
});
|
|
2234
|
+
}
|
|
2235
|
+
return flat;
|
|
2236
|
+
}
|
|
2237
|
+
function buildReviewPayload(input) {
|
|
2238
|
+
return {
|
|
2239
|
+
body: input.body,
|
|
2240
|
+
event: decisionToEvent(input.decision ?? "comment"),
|
|
2241
|
+
comments: expandMultiLineComments(input.comments ?? [])
|
|
2242
|
+
};
|
|
2243
|
+
}
|
|
2244
|
+
function stripBPrefix(path) {
|
|
2245
|
+
if (path.startsWith("b/")) return path.slice(2);
|
|
2246
|
+
if (path.startsWith("a/")) return path.slice(2);
|
|
2247
|
+
return path;
|
|
2248
|
+
}
|
|
2249
|
+
async function submitViaGh(input) {
|
|
2250
|
+
const payload = buildReviewPayload(input);
|
|
2251
|
+
`${input.resolved.owner}${input.resolved.repo}`;
|
|
2252
|
+
const args = [
|
|
2253
|
+
"api",
|
|
2254
|
+
"--method",
|
|
2255
|
+
"POST",
|
|
2256
|
+
`repos/${input.resolved.owner}/${input.resolved.repo}/pulls/${input.resolved.pullNumber}/reviews`,
|
|
2257
|
+
"-H",
|
|
2258
|
+
"Accept: application/vnd.github+json",
|
|
2259
|
+
"--input",
|
|
2260
|
+
"-"
|
|
2261
|
+
];
|
|
2262
|
+
try {
|
|
2263
|
+
const { stdout } = await execFileAsync("gh", args, {
|
|
2264
|
+
encoding: "utf-8",
|
|
2265
|
+
maxBuffer: 20 * 1024 * 1024,
|
|
2266
|
+
input: JSON.stringify(payload)
|
|
2267
|
+
});
|
|
2268
|
+
const result = JSON.parse(stdout);
|
|
2269
|
+
return {
|
|
2270
|
+
ok: true,
|
|
2271
|
+
authSource: "gh",
|
|
2272
|
+
reviewId: result.id,
|
|
2273
|
+
reviewUrl: result.html_url
|
|
2274
|
+
};
|
|
2275
|
+
} catch (err) {
|
|
2276
|
+
const stdout = err?.stdout || "";
|
|
2277
|
+
const stderr = err?.stderr || err?.message || "unknown error";
|
|
2278
|
+
let parsed = null;
|
|
2279
|
+
try {
|
|
2280
|
+
parsed = JSON.parse(stdout);
|
|
2281
|
+
} catch {}
|
|
2282
|
+
return {
|
|
2283
|
+
ok: false,
|
|
2284
|
+
authSource: "gh",
|
|
2285
|
+
error: parsed?.message || stderr.trim()
|
|
2286
|
+
};
|
|
2287
|
+
}
|
|
2288
|
+
}
|
|
2289
|
+
async function submitViaToken(input, token) {
|
|
2290
|
+
const payload = buildReviewPayload(input);
|
|
2291
|
+
const url = `https://api.github.com/repos/${input.resolved.owner}/${input.resolved.repo}/pulls/${input.resolved.pullNumber}/reviews`;
|
|
2292
|
+
try {
|
|
2293
|
+
const res = await fetch(url, {
|
|
2294
|
+
method: "POST",
|
|
2295
|
+
headers: {
|
|
2296
|
+
Authorization: `Bearer ${token}`,
|
|
2297
|
+
Accept: "application/vnd.github+json",
|
|
2298
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
2299
|
+
"Content-Type": "application/json",
|
|
2300
|
+
"User-Agent": "diffing-cli"
|
|
2301
|
+
},
|
|
2302
|
+
body: JSON.stringify(payload)
|
|
2303
|
+
});
|
|
2304
|
+
if (!res.ok) {
|
|
2305
|
+
const errBody = await res.text().catch(() => "");
|
|
2306
|
+
return {
|
|
2307
|
+
ok: false,
|
|
2308
|
+
authSource: "token",
|
|
2309
|
+
error: `HTTP ${res.status}: ${errBody.slice(0, 500)}`
|
|
2310
|
+
};
|
|
2311
|
+
}
|
|
2312
|
+
const result = await res.json();
|
|
2313
|
+
return {
|
|
2314
|
+
ok: true,
|
|
2315
|
+
authSource: "token",
|
|
2316
|
+
reviewId: result.id,
|
|
2317
|
+
reviewUrl: result.html_url
|
|
2318
|
+
};
|
|
2319
|
+
} catch (err) {
|
|
2320
|
+
return {
|
|
2321
|
+
ok: false,
|
|
2322
|
+
authSource: "token",
|
|
2323
|
+
error: err?.message || "Network error"
|
|
2324
|
+
};
|
|
2325
|
+
}
|
|
2326
|
+
}
|
|
2327
|
+
async function buildPrSession(ref) {
|
|
2328
|
+
const meta = await fetchPrMetadataViaGh(parsePrRef(ref, await detectCwdRepo() ?? void 0));
|
|
2329
|
+
return {
|
|
2330
|
+
ref,
|
|
2331
|
+
owner: meta.owner,
|
|
2332
|
+
repo: meta.repo,
|
|
2333
|
+
pullNumber: meta.number,
|
|
2334
|
+
baseSha: meta.baseSha,
|
|
2335
|
+
headSha: meta.headSha,
|
|
2336
|
+
title: meta.title,
|
|
2337
|
+
url: meta.url,
|
|
2338
|
+
author: meta.author,
|
|
2339
|
+
additions: meta.additions,
|
|
2340
|
+
deletions: meta.deletions,
|
|
2341
|
+
changedFiles: meta.changedFiles,
|
|
2342
|
+
diff: meta.diff,
|
|
2343
|
+
comments: [],
|
|
2344
|
+
existingComments: meta.existingComments
|
|
2345
|
+
};
|
|
2346
|
+
}
|
|
2347
|
+
/** Refresh `diff` + `existingComments` + head SHA in an existing session. */
|
|
2348
|
+
async function refreshPrSession(session) {
|
|
2349
|
+
const meta = await fetchPrMetadataViaGh({
|
|
2350
|
+
owner: session.owner,
|
|
2351
|
+
repo: session.repo,
|
|
2352
|
+
pullNumber: session.pullNumber,
|
|
2353
|
+
ref: session.ref
|
|
2354
|
+
});
|
|
2355
|
+
return {
|
|
2356
|
+
...session,
|
|
2357
|
+
baseSha: meta.baseSha,
|
|
2358
|
+
headSha: meta.headSha,
|
|
2359
|
+
title: meta.title,
|
|
2360
|
+
url: meta.url,
|
|
2361
|
+
author: meta.author,
|
|
2362
|
+
additions: meta.additions,
|
|
2363
|
+
deletions: meta.deletions,
|
|
2364
|
+
changedFiles: meta.changedFiles,
|
|
2365
|
+
diff: meta.diff,
|
|
2366
|
+
existingComments: meta.existingComments
|
|
2367
|
+
};
|
|
2368
|
+
}
|
|
2369
|
+
//#endregion
|
|
2370
|
+
//#region src/server.ts
|
|
2371
|
+
const MIME_TYPES = {
|
|
2372
|
+
".html": "text/html",
|
|
2373
|
+
".js": "application/javascript",
|
|
2374
|
+
".css": "text/css",
|
|
2375
|
+
".json": "application/json",
|
|
2376
|
+
".svg": "image/svg+xml",
|
|
2377
|
+
".png": "image/png",
|
|
2378
|
+
".ico": "image/x-icon",
|
|
2379
|
+
".jpg": "image/jpeg",
|
|
2380
|
+
".jpeg": "image/jpeg",
|
|
2381
|
+
".gif": "image/gif",
|
|
2382
|
+
".webp": "image/webp",
|
|
2383
|
+
".bmp": "image/bmp",
|
|
2384
|
+
".avif": "image/avif"
|
|
2385
|
+
};
|
|
2386
|
+
function isCustomMode(opts) {
|
|
2387
|
+
return opts.revisions.length > 0 || opts.pathspecs.length > 0 || opts.showMode;
|
|
2388
|
+
}
|
|
2389
|
+
function createApp(clientDir, diffOpts = DEFAULTS$1, commentStore, planStore, prSessionStore, prMode = false) {
|
|
2390
|
+
const app = new Hono();
|
|
2391
|
+
const customMode = isCustomMode(diffOpts);
|
|
2392
|
+
const store = commentStore ?? new FileCommentStore();
|
|
2393
|
+
const plans = planStore ?? new FilePlanStore();
|
|
2394
|
+
const prStore = prSessionStore ?? new FilePrSessionStore();
|
|
2395
|
+
const uiStateStore = new FileUiStateStore();
|
|
2396
|
+
const viewedFiles = /* @__PURE__ */ new Set();
|
|
2397
|
+
const activeClients = /* @__PURE__ */ new Set();
|
|
2398
|
+
const broadcast = (event, data) => {
|
|
2399
|
+
for (const send of activeClients) try {
|
|
2400
|
+
send(event, data);
|
|
2401
|
+
} catch {}
|
|
2402
|
+
};
|
|
2403
|
+
const reviewSession = new ReviewSession((snapshot) => broadcast("agent-status", JSON.stringify(snapshot)));
|
|
2404
|
+
const planReviewSession = new PlanReviewSession((snapshot) => broadcast("plan-review-status", JSON.stringify(snapshot)));
|
|
2405
|
+
let repoRoot;
|
|
2406
|
+
try {
|
|
2407
|
+
repoRoot = getRepoRoot();
|
|
2408
|
+
} catch {
|
|
2409
|
+
repoRoot = process.cwd();
|
|
2410
|
+
}
|
|
2411
|
+
if (!commentStore && !planStore) try {
|
|
2412
|
+
const storageDir = getProjectStorageDir();
|
|
2413
|
+
mkdirSync(storageDir, { recursive: true });
|
|
2414
|
+
let commentsDebounce = null;
|
|
2415
|
+
let plansDebounce = null;
|
|
2416
|
+
let prSessionDebounce = null;
|
|
2417
|
+
watch(storageDir, (_eventType, filename) => {
|
|
2418
|
+
if (!filename) return;
|
|
2419
|
+
if (filename.startsWith("comments.json")) {
|
|
2420
|
+
if (commentsDebounce) clearTimeout(commentsDebounce);
|
|
2421
|
+
commentsDebounce = setTimeout(() => broadcast("comments", Date.now().toString()), 120);
|
|
2422
|
+
} else if (filename.startsWith("plans.json")) {
|
|
2423
|
+
if (plansDebounce) clearTimeout(plansDebounce);
|
|
2424
|
+
plansDebounce = setTimeout(() => broadcast("plans", Date.now().toString()), 120);
|
|
2425
|
+
} else if (filename.startsWith("pr-session.json")) {
|
|
2426
|
+
if (prSessionDebounce) clearTimeout(prSessionDebounce);
|
|
2427
|
+
prSessionDebounce = setTimeout(() => broadcast("pr-session", Date.now().toString()), 120);
|
|
2428
|
+
}
|
|
2429
|
+
}).unref();
|
|
2430
|
+
} catch (err) {
|
|
2431
|
+
console.warn("Failed to initialize storage watcher:", err);
|
|
2432
|
+
}
|
|
2433
|
+
let debounceTimeout = null;
|
|
2434
|
+
try {
|
|
2435
|
+
watch(repoRoot, { recursive: true }, (eventType, filename) => {
|
|
2436
|
+
if (!filename) return;
|
|
2437
|
+
const parts = filename.split(/[/\\]/);
|
|
2438
|
+
const isGit = parts.includes(".git");
|
|
2439
|
+
const isNodeModules = parts.includes("node_modules");
|
|
2440
|
+
const isDist = parts.includes("dist");
|
|
2441
|
+
const isChangeset = parts.includes(".changeset");
|
|
2442
|
+
let shouldTrigger = false;
|
|
2443
|
+
if (isGit) {
|
|
2444
|
+
const isIndex = filename.endsWith("index");
|
|
2445
|
+
const isHead = filename.endsWith("HEAD");
|
|
2446
|
+
const isRefs = filename.includes("refs/") || filename.includes("refs\\");
|
|
2447
|
+
if (isIndex || isHead || isRefs) shouldTrigger = true;
|
|
2448
|
+
} else if (!isNodeModules && !isDist && !isChangeset) shouldTrigger = true;
|
|
2449
|
+
if (shouldTrigger) {
|
|
2450
|
+
if (debounceTimeout) clearTimeout(debounceTimeout);
|
|
2451
|
+
debounceTimeout = setTimeout(() => broadcast("change", Date.now().toString()), 200);
|
|
2452
|
+
}
|
|
2453
|
+
}).unref();
|
|
2454
|
+
} catch (err) {
|
|
2455
|
+
console.warn("Failed to initialize repository watcher:", err);
|
|
2456
|
+
}
|
|
2457
|
+
app.get("/api/live", async (c) => {
|
|
2458
|
+
return streamSSE(c, async (stream) => {
|
|
2459
|
+
const sendUpdate = (event, data) => {
|
|
2460
|
+
stream.writeSSE({
|
|
2461
|
+
event,
|
|
2462
|
+
data
|
|
2463
|
+
});
|
|
2464
|
+
};
|
|
2465
|
+
activeClients.add(sendUpdate);
|
|
2466
|
+
await stream.writeSSE({
|
|
2467
|
+
event: "heartbeat",
|
|
2468
|
+
data: Date.now().toString()
|
|
2469
|
+
});
|
|
2470
|
+
const heartbeatInterval = setInterval(() => {
|
|
2471
|
+
stream.writeSSE({
|
|
2472
|
+
event: "heartbeat",
|
|
2473
|
+
data: Date.now().toString()
|
|
2474
|
+
}).catch(() => {});
|
|
2475
|
+
}, 15e3);
|
|
2476
|
+
await new Promise((resolve) => {
|
|
2477
|
+
stream.onAbort(() => {
|
|
2478
|
+
clearInterval(heartbeatInterval);
|
|
2479
|
+
activeClients.delete(sendUpdate);
|
|
2480
|
+
resolve();
|
|
2481
|
+
});
|
|
2482
|
+
});
|
|
2483
|
+
});
|
|
2484
|
+
});
|
|
2485
|
+
app.get("/api/diff", async (c) => {
|
|
2486
|
+
const stagedQuery = c.req.query("staged");
|
|
2487
|
+
const untrackedQuery = c.req.query("untracked");
|
|
2488
|
+
const staged = stagedQuery === void 0 ? diffOpts.staged : stagedQuery === "true";
|
|
2489
|
+
const untracked = untrackedQuery === void 0 ? diffOpts.includeUntracked : untrackedQuery === "true";
|
|
2490
|
+
if (prMode) {
|
|
2491
|
+
const prSession = await prStore.get();
|
|
2492
|
+
if (prSession) {
|
|
2493
|
+
const binaryFiles = [];
|
|
2494
|
+
const filePaths = [];
|
|
2495
|
+
for (const line of prSession.diff.split("\n")) {
|
|
2496
|
+
const m = /^diff --git a\/.+ b\/(.+)$/.exec(line);
|
|
2497
|
+
if (m) filePaths.push(m[1]);
|
|
2498
|
+
}
|
|
2499
|
+
return c.json({
|
|
2500
|
+
patch: prSession.diff,
|
|
2501
|
+
repoName: prSession.repo,
|
|
2502
|
+
branch: `#${prSession.pullNumber}`,
|
|
2503
|
+
customMode: true,
|
|
2504
|
+
binaryFiles,
|
|
2505
|
+
tabSizeMap: {},
|
|
2506
|
+
untrackedFiles: [],
|
|
2507
|
+
prMode: true,
|
|
2508
|
+
prRef: prSession.ref,
|
|
2509
|
+
prOwner: prSession.owner,
|
|
2510
|
+
prRepo: prSession.repo,
|
|
2511
|
+
prPullNumber: prSession.pullNumber,
|
|
2512
|
+
prUrl: prSession.url,
|
|
2513
|
+
prTitle: prSession.title,
|
|
2514
|
+
prAuthor: prSession.author,
|
|
2515
|
+
prHeadSha: prSession.headSha,
|
|
2516
|
+
prBaseSha: prSession.baseSha
|
|
2517
|
+
});
|
|
2518
|
+
}
|
|
2519
|
+
}
|
|
2520
|
+
const optsForDiff = customMode ? diffOpts : {
|
|
2521
|
+
...diffOpts,
|
|
2522
|
+
staged,
|
|
2523
|
+
includeUntracked: untracked
|
|
2524
|
+
};
|
|
2525
|
+
const result = await executeDiffWithMeta(optsForDiff);
|
|
2526
|
+
return c.json({
|
|
2527
|
+
patch: result.patch,
|
|
2528
|
+
repoName: result.repoName,
|
|
2529
|
+
branch: result.branch,
|
|
2530
|
+
customMode,
|
|
2531
|
+
binaryFiles: result.binaryFiles,
|
|
2532
|
+
tabSizeMap: result.tabSizeMap,
|
|
2533
|
+
untrackedFiles: result.untrackedFiles,
|
|
2534
|
+
showMode: optsForDiff.showMode || void 0,
|
|
2535
|
+
commits: result.commits,
|
|
2536
|
+
truncated: result.truncated
|
|
2537
|
+
});
|
|
2538
|
+
});
|
|
2539
|
+
app.get("/api/file-content", (c) => {
|
|
2540
|
+
const path = c.req.query("path");
|
|
2541
|
+
const version = c.req.query("version");
|
|
2542
|
+
if (!path || !version) return c.json({ error: "Missing path or version" }, 400);
|
|
2543
|
+
const content = getFileContent(path, version);
|
|
2544
|
+
if (!content) return c.json({ error: "File not found" }, 404);
|
|
2545
|
+
const contentType = MIME_TYPES[extname(path)] || "application/octet-stream";
|
|
2546
|
+
return new Response(new Uint8Array(content), { headers: { "Content-Type": contentType } });
|
|
2547
|
+
});
|
|
2548
|
+
app.get("/api/file-text", (c) => {
|
|
2549
|
+
const path = c.req.query("path");
|
|
2550
|
+
const version = c.req.query("version");
|
|
2551
|
+
if (!path || !version) return c.json({ error: "Missing path or version" }, 400);
|
|
2552
|
+
const buffer = getFileContent(path, version);
|
|
2553
|
+
if (!buffer) return c.json({
|
|
2554
|
+
content: "",
|
|
2555
|
+
missing: true
|
|
2556
|
+
});
|
|
2557
|
+
const sample = buffer.subarray(0, Math.min(buffer.length, 8192));
|
|
2558
|
+
for (let i = 0; i < sample.length; i++) if (sample[i] === 0) return c.json({ error: "Binary file" }, 415);
|
|
2559
|
+
return c.json({
|
|
2560
|
+
content: buffer.toString("utf-8"),
|
|
2561
|
+
missing: false
|
|
2562
|
+
});
|
|
2563
|
+
});
|
|
2564
|
+
app.post("/api/open-file", async (c) => {
|
|
2565
|
+
const { filePath, editor } = await c.req.json();
|
|
2566
|
+
if (!filePath) return c.json({ error: "filePath is required" }, 400);
|
|
2567
|
+
try {
|
|
2568
|
+
const root = getRepoRoot();
|
|
2569
|
+
const relPath = toSafeRelativePath(filePath, root);
|
|
2570
|
+
if (!relPath) return c.json({ error: "Forbidden file path" }, 403);
|
|
2571
|
+
const absolutePath = resolve(root, relPath);
|
|
2572
|
+
if (editor && editor !== "default") {
|
|
2573
|
+
const command = resolveEditorCommand(editor, absolutePath);
|
|
2574
|
+
if (command) {
|
|
2575
|
+
const { execFile } = await import("node:child_process");
|
|
2576
|
+
execFile(command.cmd, command.args, (err) => {
|
|
2577
|
+
if (err) console.error(`Failed to launch ${editor} for ${absolutePath}: ${err.message}`);
|
|
2578
|
+
});
|
|
2579
|
+
return c.json({ ok: true });
|
|
2580
|
+
}
|
|
2581
|
+
}
|
|
2582
|
+
await (await import("open")).default(absolutePath);
|
|
2583
|
+
return c.json({ ok: true });
|
|
2584
|
+
} catch (err) {
|
|
2585
|
+
return c.json({ error: `Failed to open file: ${err.message}` }, 500);
|
|
2586
|
+
}
|
|
2587
|
+
});
|
|
2588
|
+
app.get("/api/repo-files", (c) => {
|
|
2589
|
+
try {
|
|
2590
|
+
return c.json({ files: listRepoFiles() });
|
|
2591
|
+
} catch (err) {
|
|
2592
|
+
return c.json({ error: err.message }, 500);
|
|
2593
|
+
}
|
|
2594
|
+
});
|
|
2595
|
+
app.post("/api/search", async (c) => {
|
|
2596
|
+
const body = await c.req.json().catch(() => ({}));
|
|
2597
|
+
const scope = body.scope ?? "files";
|
|
2598
|
+
const query = typeof body.query === "string" ? body.query : "";
|
|
2599
|
+
const limit = typeof body.limit === "number" ? body.limit : void 0;
|
|
2600
|
+
const paths = Array.isArray(body.changedPaths) ? body.changedPaths : void 0;
|
|
2601
|
+
try {
|
|
2602
|
+
if (scope === "all") return c.json(await searchAll(query, {
|
|
2603
|
+
limit,
|
|
2604
|
+
regex: !!body.regex,
|
|
2605
|
+
paths
|
|
2606
|
+
}));
|
|
2607
|
+
if (scope === "text") return c.json(await searchContent(query, {
|
|
2608
|
+
limit,
|
|
2609
|
+
regex: !!body.regex,
|
|
2610
|
+
paths
|
|
2611
|
+
}));
|
|
2612
|
+
if (scope === "symbols") return c.json(await searchSymbols(query, {
|
|
2613
|
+
limit,
|
|
2614
|
+
paths
|
|
2615
|
+
}));
|
|
2616
|
+
return c.json(await searchFiles(query, {
|
|
2617
|
+
limit,
|
|
2618
|
+
paths
|
|
2619
|
+
}));
|
|
2620
|
+
} catch (err) {
|
|
2621
|
+
return c.json({
|
|
2622
|
+
scope,
|
|
2623
|
+
items: [],
|
|
2624
|
+
total: 0,
|
|
2625
|
+
indexing: false,
|
|
2626
|
+
error: err?.message ?? "Search failed"
|
|
2627
|
+
}, 500);
|
|
2628
|
+
}
|
|
2629
|
+
});
|
|
2630
|
+
app.get("/api/search/status", async (c) => {
|
|
2631
|
+
return c.json(await getSearchStatus());
|
|
2632
|
+
});
|
|
2633
|
+
app.post("/api/search/track", async (c) => {
|
|
2634
|
+
const { query, path } = await c.req.json().catch(() => ({}));
|
|
2635
|
+
if (path) await trackSelection(query ?? "", path);
|
|
2636
|
+
return c.json({ ok: true });
|
|
2637
|
+
});
|
|
2638
|
+
app.get("/api/merge-status", (c) => {
|
|
2639
|
+
try {
|
|
2640
|
+
const status = getMergeStatus();
|
|
2641
|
+
return c.json(status);
|
|
2642
|
+
} catch (err) {
|
|
2643
|
+
return c.json({ error: err.message }, 500);
|
|
2644
|
+
}
|
|
2645
|
+
});
|
|
2646
|
+
app.post("/api/revert-hunk", async (c) => {
|
|
2647
|
+
const { filePath, hunkIndex } = await c.req.json();
|
|
2648
|
+
if (!filePath || typeof hunkIndex !== "number") return c.json({ error: "Missing filePath or hunkIndex" }, 400);
|
|
2649
|
+
try {
|
|
2650
|
+
const relPath = toSafeRelativePath(filePath, getRepoRoot());
|
|
2651
|
+
if (!relPath) return c.json({ error: "Forbidden file path" }, 403);
|
|
2652
|
+
revertHunk(relPath, hunkIndex);
|
|
2653
|
+
return c.json({ ok: true });
|
|
2654
|
+
} catch (err) {
|
|
2655
|
+
const stderr = typeof err?.stderr === "string" ? err.stderr : err?.stderr instanceof Buffer ? err.stderr.toString("utf-8") : "";
|
|
2656
|
+
return c.json({ error: stderr || err?.message || "Failed to revert hunk" }, 500);
|
|
2657
|
+
}
|
|
2658
|
+
});
|
|
2659
|
+
app.get("/api/hunk-history", async (c) => {
|
|
2660
|
+
const filePath = c.req.query("filePath");
|
|
2661
|
+
const deletionStart = Number(c.req.query("deletionStart"));
|
|
2662
|
+
const deletionCount = Number(c.req.query("deletionCount"));
|
|
2663
|
+
if (!filePath || Number.isNaN(deletionStart) || Number.isNaN(deletionCount)) return c.json({ error: "Missing or invalid parameters" }, 400);
|
|
2664
|
+
try {
|
|
2665
|
+
const relPath = toSafeRelativePath(filePath, getRepoRoot());
|
|
2666
|
+
if (!relPath) return c.json({ error: "Forbidden file path" }, 403);
|
|
2667
|
+
const history = getHunkHistory(relPath, deletionStart, deletionCount, diffOpts.showMode ? diffOpts.showRevspecs[diffOpts.showRevspecs.length - 1] || "HEAD" : diffOpts.revisions[0] || "HEAD");
|
|
2668
|
+
return c.json(history);
|
|
2669
|
+
} catch (err) {
|
|
2670
|
+
return c.json({ error: err.message || "Failed to fetch hunk history" }, 500);
|
|
2671
|
+
}
|
|
2672
|
+
});
|
|
2673
|
+
app.post("/api/save-file", async (c) => {
|
|
2674
|
+
const { filePath, content, gitAdd } = await c.req.json();
|
|
2675
|
+
if (!filePath || typeof content !== "string") return c.json({ error: "Missing filePath or content" }, 400);
|
|
2676
|
+
try {
|
|
2677
|
+
const root = getRepoRoot();
|
|
2678
|
+
const relPath = toSafeRelativePath(filePath, root);
|
|
2679
|
+
if (!relPath) return c.json({ error: "Forbidden file path" }, 403);
|
|
2680
|
+
await writeFile(resolve(root, relPath), content, "utf-8");
|
|
2681
|
+
if (gitAdd) try {
|
|
2682
|
+
gitAddFile(relPath);
|
|
2683
|
+
} catch (err) {
|
|
2684
|
+
return c.json({
|
|
2685
|
+
ok: true,
|
|
2686
|
+
gitAddError: err.message
|
|
2687
|
+
});
|
|
2688
|
+
}
|
|
2689
|
+
return c.json({ ok: true });
|
|
2690
|
+
} catch (err) {
|
|
2691
|
+
return c.json({ error: `Failed to save file: ${err.message}` }, 500);
|
|
2692
|
+
}
|
|
2693
|
+
});
|
|
2694
|
+
app.get("/api/settings", (c) => {
|
|
2695
|
+
return c.json(loadSettings());
|
|
2696
|
+
});
|
|
2697
|
+
app.put("/api/settings", async (c) => {
|
|
2698
|
+
const settings = saveSettings(await c.req.json());
|
|
2699
|
+
return c.json(settings);
|
|
2700
|
+
});
|
|
2701
|
+
app.get("/api/ui-state", async (c) => {
|
|
2702
|
+
return c.json(await uiStateStore.getAll());
|
|
2703
|
+
});
|
|
2704
|
+
app.put("/api/ui-state", async (c) => {
|
|
2705
|
+
const body = await c.req.json();
|
|
2706
|
+
const merged = {
|
|
2707
|
+
...await uiStateStore.getAll(),
|
|
2708
|
+
...body
|
|
2709
|
+
};
|
|
2710
|
+
for (const key of Object.keys(body)) if (body[key] === null || body[key] === void 0) delete merged[key];
|
|
2711
|
+
await uiStateStore.setAll(merged);
|
|
2712
|
+
return c.json(merged);
|
|
2713
|
+
});
|
|
2714
|
+
app.get("/api/viewed", (c) => {
|
|
2715
|
+
return c.json([...viewedFiles]);
|
|
2716
|
+
});
|
|
2717
|
+
app.put("/api/viewed", async (c) => {
|
|
2718
|
+
const { filePath, viewed } = await c.req.json();
|
|
2719
|
+
if (viewed) viewedFiles.add(filePath);
|
|
2720
|
+
else viewedFiles.delete(filePath);
|
|
2721
|
+
return c.json({ ok: true });
|
|
2722
|
+
});
|
|
2723
|
+
app.get("/api/comments", async (c) => {
|
|
2724
|
+
const comments = await store.getAll();
|
|
2725
|
+
return c.json(comments);
|
|
2726
|
+
});
|
|
2727
|
+
app.post("/api/comments", async (c) => {
|
|
2728
|
+
const body = await c.req.json();
|
|
2729
|
+
const comment = {
|
|
2730
|
+
id: crypto.randomUUID(),
|
|
2731
|
+
filePath: body.filePath,
|
|
2732
|
+
side: body.side,
|
|
2733
|
+
lineNumber: body.lineNumber,
|
|
2734
|
+
startLineNumber: body.startLineNumber,
|
|
2735
|
+
lineContent: body.lineContent,
|
|
2736
|
+
body: body.body,
|
|
2737
|
+
status: "open",
|
|
2738
|
+
createdAt: Date.now(),
|
|
2739
|
+
replies: []
|
|
2740
|
+
};
|
|
2741
|
+
const created = await store.add(comment);
|
|
2742
|
+
return c.json(created, 201);
|
|
2743
|
+
});
|
|
2744
|
+
app.put("/api/comments/:id", async (c) => {
|
|
2745
|
+
const id = c.req.param("id");
|
|
2746
|
+
const { body, status } = await c.req.json();
|
|
2747
|
+
const updated = await store.update(id, {
|
|
2748
|
+
body,
|
|
2749
|
+
status
|
|
2750
|
+
});
|
|
2751
|
+
if (!updated) return c.json({ error: "Comment not found" }, 404);
|
|
2752
|
+
return c.json(updated);
|
|
2753
|
+
});
|
|
2754
|
+
app.post("/api/comments/:id/replies", async (c) => {
|
|
2755
|
+
const commentId = c.req.param("id");
|
|
2756
|
+
const { body, role, model } = await c.req.json();
|
|
2757
|
+
const reply = {
|
|
2758
|
+
id: crypto.randomUUID(),
|
|
2759
|
+
body,
|
|
2760
|
+
createdAt: Date.now(),
|
|
2761
|
+
role: role || (model ? "agent" : "user"),
|
|
2762
|
+
model: model || void 0
|
|
2763
|
+
};
|
|
2764
|
+
const updated = await store.addReply(commentId, reply);
|
|
2765
|
+
if (!updated) return c.json({ error: "Comment not found" }, 404);
|
|
2766
|
+
return c.json(updated);
|
|
2767
|
+
});
|
|
2768
|
+
app.delete("/api/comments/:id/replies/:replyId", async (c) => {
|
|
2769
|
+
const commentId = c.req.param("id");
|
|
2770
|
+
const replyId = c.req.param("replyId");
|
|
2771
|
+
const updated = await store.removeReply(commentId, replyId);
|
|
2772
|
+
if (!updated) return c.json({ error: "Comment or reply not found" }, 404);
|
|
2773
|
+
return c.json(updated);
|
|
2774
|
+
});
|
|
2775
|
+
app.put("/api/comments/:id/replies/:replyId", async (c) => {
|
|
2776
|
+
const commentId = c.req.param("id");
|
|
2777
|
+
const replyId = c.req.param("replyId");
|
|
2778
|
+
const { body } = await c.req.json();
|
|
2779
|
+
if (!body) return c.json({ error: "Body is required" }, 400);
|
|
2780
|
+
const updated = await store.updateReply(commentId, replyId, body);
|
|
2781
|
+
if (!updated) return c.json({ error: "Comment or reply not found" }, 404);
|
|
2782
|
+
return c.json(updated);
|
|
2783
|
+
});
|
|
2784
|
+
app.post("/api/comments/:id/apply-suggestion", async (c) => {
|
|
2785
|
+
const id = c.req.param("id");
|
|
2786
|
+
const comment = (await store.getAll()).find((c) => c.id === id);
|
|
2787
|
+
if (!comment) return c.json({ error: "Comment not found" }, 404);
|
|
2788
|
+
if (comment.side !== "additions") return c.json({ error: "Suggestions can only be applied to added or modified lines" }, 400);
|
|
2789
|
+
const match = comment.body.match(/```suggestion\n([\s\S]*?)```/);
|
|
2790
|
+
if (!match) return c.json({ error: "No suggestion block found in comment body" }, 400);
|
|
2791
|
+
const suggestion = match[1];
|
|
2792
|
+
try {
|
|
2793
|
+
const root = getRepoRoot();
|
|
2794
|
+
const relPath = toSafeRelativePath(comment.filePath, root);
|
|
2795
|
+
if (!relPath) return c.json({ error: "Forbidden file path" }, 403);
|
|
2796
|
+
const absolutePath = resolve(root, relPath);
|
|
2797
|
+
const lines = (await readFile(absolutePath, "utf-8")).split(/\r?\n/);
|
|
2798
|
+
const lineIdx = comment.lineNumber - 1;
|
|
2799
|
+
if (lineIdx < 0 || lineIdx >= lines.length) return c.json({ error: "Comment line number out of range" }, 400);
|
|
2800
|
+
lines[lineIdx] = suggestion.trimEnd();
|
|
2801
|
+
await writeFile(absolutePath, lines.join("\n"), "utf-8");
|
|
2802
|
+
await store.update(id, { status: "resolved" });
|
|
2803
|
+
return c.json({ ok: true });
|
|
2804
|
+
} catch (err) {
|
|
2805
|
+
return c.json({ error: `Failed to apply suggestion: ${err.message}` }, 500);
|
|
2806
|
+
}
|
|
2807
|
+
});
|
|
2808
|
+
app.delete("/api/comments/:id", async (c) => {
|
|
2809
|
+
const id = c.req.param("id");
|
|
2810
|
+
if (!await store.remove(id)) return c.json({ error: "Comment not found" }, 404);
|
|
2811
|
+
return c.json({ ok: true });
|
|
2812
|
+
});
|
|
2813
|
+
/** Shared helper: 404 with a stable shape so the client knows "not in PR mode". */
|
|
2814
|
+
const notInPrMode = (c) => c.json({
|
|
2815
|
+
error: "Not in PR review mode",
|
|
2816
|
+
prMode: false
|
|
2817
|
+
}, 404);
|
|
2818
|
+
app.get("/api/gh/session", async (c) => {
|
|
2819
|
+
if (!prMode) return notInPrMode(c);
|
|
2820
|
+
const session = await prStore.get();
|
|
2821
|
+
if (!session) return notInPrMode(c);
|
|
2822
|
+
return c.json({
|
|
2823
|
+
prMode: true,
|
|
2824
|
+
ref: session.ref,
|
|
2825
|
+
owner: session.owner,
|
|
2826
|
+
repo: session.repo,
|
|
2827
|
+
pullNumber: session.pullNumber,
|
|
2828
|
+
baseSha: session.baseSha,
|
|
2829
|
+
headSha: session.headSha,
|
|
2830
|
+
title: session.title,
|
|
2831
|
+
url: session.url,
|
|
2832
|
+
author: session.author,
|
|
2833
|
+
additions: session.additions,
|
|
2834
|
+
deletions: session.deletions,
|
|
2835
|
+
changedFiles: session.changedFiles,
|
|
2836
|
+
existingComments: session.existingComments,
|
|
2837
|
+
submittedAt: session.submittedAt,
|
|
2838
|
+
submittedReviewId: session.submittedReviewId,
|
|
2839
|
+
submittedReviewUrl: session.submittedReviewUrl,
|
|
2840
|
+
authSource: session.authSource
|
|
2841
|
+
});
|
|
2842
|
+
});
|
|
2843
|
+
/** Re-fetch PR metadata (head SHA, diff, existing comments) and persist. */
|
|
2844
|
+
app.post("/api/gh/pr/refresh", async (c) => {
|
|
2845
|
+
if (!prMode) return notInPrMode(c);
|
|
2846
|
+
const session = await prStore.get();
|
|
2847
|
+
if (!session) return notInPrMode(c);
|
|
2848
|
+
try {
|
|
2849
|
+
const refreshed = await refreshPrSession(session);
|
|
2850
|
+
await prStore.set(refreshed);
|
|
2851
|
+
return c.json({
|
|
2852
|
+
ok: true,
|
|
2853
|
+
headSha: refreshed.headSha
|
|
2854
|
+
});
|
|
2855
|
+
} catch (err) {
|
|
2856
|
+
return c.json({ error: err?.message ?? "Refresh failed" }, 500);
|
|
2857
|
+
}
|
|
2858
|
+
});
|
|
2859
|
+
/** Initialize a PR session from a ref like `1234`, `o/r#42`, or a GitHub URL. */
|
|
2860
|
+
app.post("/api/gh/pr/init", async (c) => {
|
|
2861
|
+
if (!prMode) return notInPrMode(c);
|
|
2862
|
+
const body = await c.req.json().catch(() => ({}));
|
|
2863
|
+
const ref = typeof body?.ref === "string" ? body.ref : "";
|
|
2864
|
+
if (!ref.trim()) return c.json({ error: "ref is required" }, 400);
|
|
2865
|
+
try {
|
|
2866
|
+
parsePrRef(ref, await detectCwdRepo() ?? void 0);
|
|
2867
|
+
const session = await buildPrSession(ref);
|
|
2868
|
+
await prStore.set(session);
|
|
2869
|
+
return c.json({
|
|
2870
|
+
ok: true,
|
|
2871
|
+
ref: session.ref,
|
|
2872
|
+
owner: session.owner,
|
|
2873
|
+
repo: session.repo,
|
|
2874
|
+
pullNumber: session.pullNumber,
|
|
2875
|
+
url: session.url
|
|
2876
|
+
});
|
|
2877
|
+
} catch (err) {
|
|
2878
|
+
return c.json({ error: err?.message ?? "Failed to initialise PR session" }, 500);
|
|
2879
|
+
}
|
|
2880
|
+
});
|
|
2881
|
+
app.get("/api/gh/pr-session/comments", async (c) => {
|
|
2882
|
+
if (!prMode) return notInPrMode(c);
|
|
2883
|
+
const session = await prStore.get();
|
|
2884
|
+
if (!session) return notInPrMode(c);
|
|
2885
|
+
return c.json(session.comments ?? []);
|
|
2886
|
+
});
|
|
2887
|
+
app.post("/api/gh/pr-session/comments", async (c) => {
|
|
2888
|
+
if (!prMode) return notInPrMode(c);
|
|
2889
|
+
const body = await c.req.json();
|
|
2890
|
+
const session = await prStore.get();
|
|
2891
|
+
if (!session) return notInPrMode(c);
|
|
2892
|
+
const comment = {
|
|
2893
|
+
id: crypto.randomUUID(),
|
|
2894
|
+
filePath: body.filePath,
|
|
2895
|
+
side: body.side,
|
|
2896
|
+
lineNumber: body.lineNumber,
|
|
2897
|
+
startLineNumber: body.startLineNumber,
|
|
2898
|
+
lineContent: body.lineContent,
|
|
2899
|
+
body: body.body,
|
|
2900
|
+
status: "open",
|
|
2901
|
+
createdAt: Date.now(),
|
|
2902
|
+
replies: []
|
|
2903
|
+
};
|
|
2904
|
+
const next = {
|
|
2905
|
+
...session,
|
|
2906
|
+
comments: [...session.comments ?? [], comment]
|
|
2907
|
+
};
|
|
2908
|
+
await prStore.set(next);
|
|
2909
|
+
return c.json(comment, 201);
|
|
2910
|
+
});
|
|
2911
|
+
app.put("/api/gh/pr-session/comments/:id", async (c) => {
|
|
2912
|
+
if (!prMode) return notInPrMode(c);
|
|
2913
|
+
const id = c.req.param("id");
|
|
2914
|
+
const { body, status } = await c.req.json();
|
|
2915
|
+
const session = await prStore.get();
|
|
2916
|
+
if (!session) return notInPrMode(c);
|
|
2917
|
+
const comments = (session.comments ?? []).map((cm) => cm.id === id ? {
|
|
2918
|
+
...cm,
|
|
2919
|
+
body: body ?? cm.body,
|
|
2920
|
+
status: status ?? cm.status
|
|
2921
|
+
} : cm);
|
|
2922
|
+
await prStore.set({
|
|
2923
|
+
...session,
|
|
2924
|
+
comments
|
|
2925
|
+
});
|
|
2926
|
+
const updated = comments.find((cm) => cm.id === id) ?? null;
|
|
2927
|
+
if (!updated) return c.json({ error: "Comment not found" }, 404);
|
|
2928
|
+
return c.json(updated);
|
|
2929
|
+
});
|
|
2930
|
+
app.delete("/api/gh/pr-session/comments/:id", async (c) => {
|
|
2931
|
+
if (!prMode) return notInPrMode(c);
|
|
2932
|
+
const id = c.req.param("id");
|
|
2933
|
+
const session = await prStore.get();
|
|
2934
|
+
if (!session) return notInPrMode(c);
|
|
2935
|
+
const comments = (session.comments ?? []).filter((cm) => cm.id !== id);
|
|
2936
|
+
await prStore.set({
|
|
2937
|
+
...session,
|
|
2938
|
+
comments
|
|
2939
|
+
});
|
|
2940
|
+
return c.json({ ok: true });
|
|
2941
|
+
});
|
|
2942
|
+
app.post("/api/gh/pr-session/comments/:id/replies", async (c) => {
|
|
2943
|
+
if (!prMode) return notInPrMode(c);
|
|
2944
|
+
const id = c.req.param("id");
|
|
2945
|
+
const { body, role, model } = await c.req.json();
|
|
2946
|
+
const session = await prStore.get();
|
|
2947
|
+
if (!session) return notInPrMode(c);
|
|
2948
|
+
const comments = (session.comments ?? []).map((cm) => {
|
|
2949
|
+
if (cm.id !== id) return cm;
|
|
2950
|
+
const reply = {
|
|
2951
|
+
id: crypto.randomUUID(),
|
|
2952
|
+
body,
|
|
2953
|
+
createdAt: Date.now(),
|
|
2954
|
+
role: role || (model ? "agent" : "user"),
|
|
2955
|
+
model: model || void 0
|
|
2956
|
+
};
|
|
2957
|
+
return {
|
|
2958
|
+
...cm,
|
|
2959
|
+
replies: [...cm.replies ?? [], reply]
|
|
2960
|
+
};
|
|
2961
|
+
});
|
|
2962
|
+
await prStore.set({
|
|
2963
|
+
...session,
|
|
2964
|
+
comments
|
|
2965
|
+
});
|
|
2966
|
+
return c.json(comments.find((cm) => cm.id === id));
|
|
2967
|
+
});
|
|
2968
|
+
/** Submit the current PR session's review (new comments + verdict + body) to GitHub. */
|
|
2969
|
+
app.post("/api/gh/submit", async (c) => {
|
|
2970
|
+
if (!prMode) return notInPrMode(c);
|
|
2971
|
+
const session = await prStore.get();
|
|
2972
|
+
if (!session) return notInPrMode(c);
|
|
2973
|
+
const body = await c.req.json().catch(() => ({}));
|
|
2974
|
+
const decision = body.decision;
|
|
2975
|
+
if (decision !== "approve" && decision !== "comment" && decision !== "request-changes") return c.json({ error: "decision must be one of: approve, comment, request-changes" }, 400);
|
|
2976
|
+
const generalBody = typeof body.body === "string" ? body.body : "";
|
|
2977
|
+
const dryRun = body.dryRun === true;
|
|
2978
|
+
const result = await submitReview({
|
|
2979
|
+
resolved: {
|
|
2980
|
+
owner: session.owner,
|
|
2981
|
+
repo: session.repo,
|
|
2982
|
+
pullNumber: session.pullNumber,
|
|
2983
|
+
ref: session.ref
|
|
2984
|
+
},
|
|
2985
|
+
decision,
|
|
2986
|
+
body: generalBody,
|
|
2987
|
+
comments: session.comments ?? []
|
|
2988
|
+
});
|
|
2989
|
+
if (result.ok && !dryRun) {
|
|
2990
|
+
const next = {
|
|
2991
|
+
...session,
|
|
2992
|
+
submittedAt: Date.now(),
|
|
2993
|
+
submittedReviewId: result.reviewId,
|
|
2994
|
+
submittedReviewUrl: result.reviewUrl,
|
|
2995
|
+
authSource: result.authSource
|
|
2996
|
+
};
|
|
2997
|
+
await prStore.set(next);
|
|
2998
|
+
}
|
|
2999
|
+
return c.json({
|
|
3000
|
+
ok: result.ok,
|
|
3001
|
+
reviewId: result.reviewId,
|
|
3002
|
+
reviewUrl: result.reviewUrl,
|
|
3003
|
+
failedComments: result.failedComments ?? 0,
|
|
3004
|
+
authSource: result.authSource,
|
|
3005
|
+
error: result.error,
|
|
3006
|
+
dryRun
|
|
3007
|
+
});
|
|
3008
|
+
});
|
|
3009
|
+
app.post("/api/review/send", async (c) => {
|
|
3010
|
+
const body = await c.req.json().catch(() => ({}));
|
|
3011
|
+
const generalComment = typeof body?.generalComment === "string" ? body.generalComment : void 0;
|
|
3012
|
+
const decision = body?.decision === "approved" || body?.decision === "changes-requested" || body?.decision === "rejected" || body?.decision === "comment-only" ? body.decision : void 0;
|
|
3013
|
+
const mode = body?.mode === "comment-only" || body?.mode === "standard" ? body.mode : "standard";
|
|
3014
|
+
const all = await store.getAll();
|
|
3015
|
+
const openCount = all.filter((x) => x.status === "open").length;
|
|
3016
|
+
const payload = reviewSession.send({
|
|
3017
|
+
sentAt: Date.now(),
|
|
3018
|
+
commentXml: formatComments(all, generalComment, decision, mode),
|
|
3019
|
+
openCount,
|
|
3020
|
+
comments: all,
|
|
3021
|
+
decision,
|
|
3022
|
+
mode
|
|
3023
|
+
});
|
|
3024
|
+
return c.json({
|
|
3025
|
+
ok: true,
|
|
3026
|
+
round: payload.round,
|
|
3027
|
+
openCount: payload.openCount,
|
|
3028
|
+
decision: payload.decision,
|
|
3029
|
+
mode: payload.mode,
|
|
3030
|
+
waiters: reviewSession.snapshot().waiters
|
|
3031
|
+
});
|
|
3032
|
+
});
|
|
3033
|
+
app.get("/api/review/await", async (c) => {
|
|
3034
|
+
const sinceRaw = c.req.query("sinceRound");
|
|
3035
|
+
const sinceRound = sinceRaw !== void 0 && sinceRaw !== "" ? Number(sinceRaw) : void 0;
|
|
3036
|
+
const requested = Number(c.req.query("timeoutMs")) || 25e3;
|
|
3037
|
+
const timeoutMs = Math.min(Math.max(requested, 1e3), 5e4);
|
|
3038
|
+
const result = await reviewSession.await({
|
|
3039
|
+
sinceRound: Number.isNaN(sinceRound) ? void 0 : sinceRound,
|
|
3040
|
+
timeoutMs,
|
|
3041
|
+
signal: c.req.raw.signal
|
|
3042
|
+
});
|
|
3043
|
+
return c.json(result);
|
|
3044
|
+
});
|
|
3045
|
+
app.get("/api/review/status", (c) => {
|
|
3046
|
+
return c.json(reviewSession.snapshot());
|
|
3047
|
+
});
|
|
3048
|
+
app.get("/api/plans", async (c) => {
|
|
3049
|
+
return c.json(await plans.getAll());
|
|
3050
|
+
});
|
|
3051
|
+
app.get("/api/plans/:id", async (c) => {
|
|
3052
|
+
const plan = await plans.get(c.req.param("id"));
|
|
3053
|
+
if (!plan) return c.json({ error: "Plan not found" }, 404);
|
|
3054
|
+
return c.json(plan);
|
|
3055
|
+
});
|
|
3056
|
+
app.get("/api/plans/:id/versions", async (c) => {
|
|
3057
|
+
const plan = await plans.get(c.req.param("id"));
|
|
3058
|
+
if (!plan) return c.json({ error: "Plan not found" }, 404);
|
|
3059
|
+
return c.json(plan.versions ?? []);
|
|
3060
|
+
});
|
|
3061
|
+
app.get("/api/plans/:id/versions/:n", async (c) => {
|
|
3062
|
+
const id = c.req.param("id");
|
|
3063
|
+
const n = Number(c.req.param("n"));
|
|
3064
|
+
if (!Number.isFinite(n) || n < 1) return c.json({ error: "version must be a positive integer" }, 400);
|
|
3065
|
+
const plan = await plans.get(id);
|
|
3066
|
+
if (!plan) return c.json({ error: "Plan not found" }, 404);
|
|
3067
|
+
const version = (plan.versions ?? []).find((v) => v.version === n);
|
|
3068
|
+
if (!version) return c.json({ error: `Version ${n} not found` }, 404);
|
|
3069
|
+
return c.json({
|
|
3070
|
+
version,
|
|
3071
|
+
plan: {
|
|
3072
|
+
id: plan.id,
|
|
3073
|
+
title: plan.title,
|
|
3074
|
+
decision: plan.decision,
|
|
3075
|
+
currentVersion: plan.version
|
|
3076
|
+
}
|
|
3077
|
+
});
|
|
3078
|
+
});
|
|
3079
|
+
app.post("/api/plans", async (c) => {
|
|
3080
|
+
const body = await c.req.json().catch(() => ({}));
|
|
3081
|
+
if (typeof body.body !== "string" || !body.body.trim()) return c.json({ error: "A plan body (markdown) is required" }, 400);
|
|
3082
|
+
const title = typeof body.title === "string" && body.title.trim() ? body.title.trim() : "Untitled plan";
|
|
3083
|
+
const plan = await plans.upsert({
|
|
3084
|
+
id: typeof body.id === "string" && body.id ? body.id : void 0,
|
|
3085
|
+
title,
|
|
3086
|
+
body: body.body,
|
|
3087
|
+
source: typeof body.source === "string" ? body.source : void 0,
|
|
3088
|
+
model: typeof body.model === "string" ? body.model : void 0
|
|
3089
|
+
});
|
|
3090
|
+
return c.json(plan, 201);
|
|
3091
|
+
});
|
|
3092
|
+
app.put("/api/plans/:id", async (c) => {
|
|
3093
|
+
const id = c.req.param("id");
|
|
3094
|
+
const { title, body, source, model } = await c.req.json().catch(() => ({}));
|
|
3095
|
+
const updated = await plans.update(id, {
|
|
3096
|
+
title: typeof title === "string" ? title : void 0,
|
|
3097
|
+
body: typeof body === "string" ? body : void 0,
|
|
3098
|
+
source: typeof source === "string" ? source : void 0,
|
|
3099
|
+
model: typeof model === "string" ? model : void 0
|
|
3100
|
+
});
|
|
3101
|
+
if (!updated) return c.json({ error: "Plan not found" }, 404);
|
|
3102
|
+
return c.json(updated);
|
|
3103
|
+
});
|
|
3104
|
+
app.delete("/api/plans/:id", async (c) => {
|
|
3105
|
+
if (!await plans.remove(c.req.param("id"))) return c.json({ error: "Plan not found" }, 404);
|
|
3106
|
+
return c.json({ ok: true });
|
|
3107
|
+
});
|
|
3108
|
+
app.post("/api/plans/:id/comments", async (c) => {
|
|
3109
|
+
const planId = c.req.param("id");
|
|
3110
|
+
const plan = await plans.get(planId);
|
|
3111
|
+
if (!plan) return c.json({ error: "Plan not found" }, 404);
|
|
3112
|
+
const body = await c.req.json();
|
|
3113
|
+
const lineNumber = Number.isFinite(body.lineNumber) ? Number(body.lineNumber) : 0;
|
|
3114
|
+
const startLineNumber = Number.isFinite(body.startLineNumber) ? Number(body.startLineNumber) : void 0;
|
|
3115
|
+
const anchorStart = startLineNumber ?? lineNumber;
|
|
3116
|
+
const lineContent = typeof body.lineContent === "string" && body.lineContent ? body.lineContent : lineNumber > 0 ? extractPlanLines(plan.body, anchorStart, lineNumber) : "";
|
|
3117
|
+
const sectionTitle = typeof body.sectionTitle === "string" && body.sectionTitle ? body.sectionTitle : lineNumber > 0 ? sectionTitleForLine(plan.body, anchorStart) : void 0;
|
|
3118
|
+
const createdAtPlanVersion = Number.isFinite(body.createdAtPlanVersion) ? Number(body.createdAtPlanVersion) : plan.version;
|
|
3119
|
+
const comment = {
|
|
3120
|
+
id: crypto.randomUUID(),
|
|
3121
|
+
lineNumber,
|
|
3122
|
+
startLineNumber,
|
|
3123
|
+
lineContent,
|
|
3124
|
+
sectionTitle,
|
|
3125
|
+
body: body.body,
|
|
3126
|
+
status: "open",
|
|
3127
|
+
createdAt: Date.now(),
|
|
3128
|
+
createdAtPlanVersion,
|
|
3129
|
+
replies: []
|
|
3130
|
+
};
|
|
3131
|
+
const updated = await plans.addComment(planId, comment);
|
|
3132
|
+
if (!updated) return c.json({ error: "Plan not found" }, 404);
|
|
3133
|
+
return c.json(updated, 201);
|
|
3134
|
+
});
|
|
3135
|
+
app.put("/api/plans/:id/comments/:commentId", async (c) => {
|
|
3136
|
+
const { body, status } = await c.req.json();
|
|
3137
|
+
const updated = await plans.updateComment(c.req.param("id"), c.req.param("commentId"), {
|
|
3138
|
+
body,
|
|
3139
|
+
status
|
|
3140
|
+
});
|
|
3141
|
+
if (!updated) return c.json({ error: "Plan or comment not found" }, 404);
|
|
3142
|
+
return c.json(updated);
|
|
3143
|
+
});
|
|
3144
|
+
app.delete("/api/plans/:id/comments/:commentId", async (c) => {
|
|
3145
|
+
const updated = await plans.removeComment(c.req.param("id"), c.req.param("commentId"));
|
|
3146
|
+
if (!updated) return c.json({ error: "Plan or comment not found" }, 404);
|
|
3147
|
+
return c.json(updated);
|
|
3148
|
+
});
|
|
3149
|
+
app.post("/api/plans/:id/comments/:commentId/replies", async (c) => {
|
|
3150
|
+
const { body, role, model } = await c.req.json();
|
|
3151
|
+
const reply = {
|
|
3152
|
+
id: crypto.randomUUID(),
|
|
3153
|
+
body,
|
|
3154
|
+
createdAt: Date.now(),
|
|
3155
|
+
role: role || (model ? "agent" : "user"),
|
|
3156
|
+
model: model || void 0
|
|
3157
|
+
};
|
|
3158
|
+
const updated = await plans.addReply(c.req.param("id"), c.req.param("commentId"), reply);
|
|
3159
|
+
if (!updated) return c.json({ error: "Plan or comment not found" }, 404);
|
|
3160
|
+
return c.json(updated);
|
|
3161
|
+
});
|
|
3162
|
+
app.put("/api/plans/:id/comments/:commentId/replies/:replyId", async (c) => {
|
|
3163
|
+
const { body } = await c.req.json();
|
|
3164
|
+
if (!body) return c.json({ error: "Body is required" }, 400);
|
|
3165
|
+
const updated = await plans.updateReply(c.req.param("id"), c.req.param("commentId"), c.req.param("replyId"), body);
|
|
3166
|
+
if (!updated) return c.json({ error: "Plan, comment, or reply not found" }, 404);
|
|
3167
|
+
return c.json(updated);
|
|
3168
|
+
});
|
|
3169
|
+
app.delete("/api/plans/:id/comments/:commentId/replies/:replyId", async (c) => {
|
|
3170
|
+
const updated = await plans.removeReply(c.req.param("id"), c.req.param("commentId"), c.req.param("replyId"));
|
|
3171
|
+
if (!updated) return c.json({ error: "Plan, comment, or reply not found" }, 404);
|
|
3172
|
+
return c.json(updated);
|
|
3173
|
+
});
|
|
3174
|
+
app.post("/api/plans/:id/decision", async (c) => {
|
|
3175
|
+
const planId = c.req.param("id");
|
|
3176
|
+
const body = await c.req.json().catch(() => ({}));
|
|
3177
|
+
const decision = body.decision;
|
|
3178
|
+
if (decision !== "approved" && decision !== "rejected" && decision !== "changes-requested" && decision !== "comment-only") return c.json({ error: "decision must be one of: approved, rejected, changes-requested, comment-only" }, 400);
|
|
3179
|
+
const decisionComment = typeof body.decisionComment === "string" ? body.decisionComment : void 0;
|
|
3180
|
+
const mode = body?.mode === "comment-only" || body?.mode === "standard" ? body.mode : "standard";
|
|
3181
|
+
const plan = await plans.setDecision(planId, decision, decisionComment);
|
|
3182
|
+
if (!plan) return c.json({ error: "Plan not found" }, 404);
|
|
3183
|
+
const openCommentCount = (plan.comments ?? []).filter((x) => x.status === "open").length;
|
|
3184
|
+
const payload = planReviewSession.decide({
|
|
3185
|
+
sentAt: Date.now(),
|
|
3186
|
+
planId,
|
|
3187
|
+
decision,
|
|
3188
|
+
decisionComment: plan.decisionComment,
|
|
3189
|
+
reviewXml: formatPlanReview(plan, { mode }),
|
|
3190
|
+
openCommentCount,
|
|
3191
|
+
plan,
|
|
3192
|
+
mode
|
|
3193
|
+
});
|
|
3194
|
+
return c.json({
|
|
3195
|
+
ok: true,
|
|
3196
|
+
round: payload.round,
|
|
3197
|
+
decision,
|
|
3198
|
+
mode: payload.mode,
|
|
3199
|
+
openCommentCount,
|
|
3200
|
+
waiters: planReviewSession.snapshot().waiters
|
|
3201
|
+
});
|
|
3202
|
+
});
|
|
3203
|
+
app.get("/api/plan-review/await", async (c) => {
|
|
3204
|
+
const sinceRaw = c.req.query("sinceRound");
|
|
3205
|
+
const sinceRound = sinceRaw !== void 0 && sinceRaw !== "" ? Number(sinceRaw) : void 0;
|
|
3206
|
+
const requested = Number(c.req.query("timeoutMs")) || 25e3;
|
|
3207
|
+
const timeoutMs = Math.min(Math.max(requested, 1e3), 5e4);
|
|
3208
|
+
const result = await planReviewSession.await({
|
|
3209
|
+
sinceRound: Number.isNaN(sinceRound) ? void 0 : sinceRound,
|
|
3210
|
+
timeoutMs,
|
|
3211
|
+
signal: c.req.raw.signal
|
|
3212
|
+
});
|
|
3213
|
+
return c.json(result);
|
|
3214
|
+
});
|
|
3215
|
+
app.get("/api/plan-review/status", (c) => {
|
|
3216
|
+
return c.json(planReviewSession.snapshot());
|
|
3217
|
+
});
|
|
3218
|
+
app.post("/api/attachments", async (c) => {
|
|
3219
|
+
const file = (await c.req.parseBody())["file"];
|
|
3220
|
+
if (!file || !(file instanceof File)) return c.json({ error: "No file uploaded" }, 400);
|
|
3221
|
+
try {
|
|
3222
|
+
const storageDir = getProjectStorageDir();
|
|
3223
|
+
const attachmentsDir = join(storageDir, "attachments");
|
|
3224
|
+
await mkdir(attachmentsDir, { recursive: true });
|
|
3225
|
+
const repoRoot = getRepoRoot();
|
|
3226
|
+
await writeFile(join(storageDir, "repo_path.txt"), repoRoot, "utf-8");
|
|
3227
|
+
const ext = extname(file.name) || ".png";
|
|
3228
|
+
const filename = `pasted_image_${crypto.randomUUID()}${ext}`;
|
|
3229
|
+
const absolutePath = join(attachmentsDir, filename);
|
|
3230
|
+
const arrayBuffer = await file.arrayBuffer();
|
|
3231
|
+
await writeFile(absolutePath, new Uint8Array(arrayBuffer));
|
|
3232
|
+
return c.json({ url: `/api/attachments/${filename}` });
|
|
3233
|
+
} catch (err) {
|
|
3234
|
+
return c.json({ error: `Failed to save attachment: ${err.message}` }, 500);
|
|
3235
|
+
}
|
|
3236
|
+
});
|
|
3237
|
+
app.get("/api/attachments/:filename", async (c) => {
|
|
3238
|
+
const filename = c.req.param("filename");
|
|
3239
|
+
const attachmentsDir = join(getProjectStorageDir(), "attachments");
|
|
3240
|
+
const absolutePath = resolve(attachmentsDir, filename);
|
|
3241
|
+
if (!absolutePath.startsWith(attachmentsDir)) return c.text("Forbidden", 403);
|
|
3242
|
+
try {
|
|
3243
|
+
const content = await readFile(absolutePath);
|
|
3244
|
+
const contentType = MIME_TYPES[extname(absolutePath)] || "application/octet-stream";
|
|
3245
|
+
return new Response(content, { headers: { "Content-Type": contentType } });
|
|
3246
|
+
} catch {
|
|
3247
|
+
return c.text("Attachment not found", 404);
|
|
3248
|
+
}
|
|
3249
|
+
});
|
|
3250
|
+
app.get("/*", async (c) => {
|
|
3251
|
+
let filePath = c.req.path;
|
|
3252
|
+
if (filePath === "/") filePath = "/index.html";
|
|
3253
|
+
const relativePath = filePath.slice(1);
|
|
3254
|
+
if (!isSafePath(relativePath, clientDir)) return c.text("Forbidden", 403);
|
|
3255
|
+
const fullPath = resolve(clientDir, relativePath);
|
|
3256
|
+
try {
|
|
3257
|
+
const content = await readFile(fullPath);
|
|
3258
|
+
const contentType = MIME_TYPES[extname(fullPath)] || "application/octet-stream";
|
|
3259
|
+
return new Response(content, { headers: { "Content-Type": contentType } });
|
|
3260
|
+
} catch {
|
|
3261
|
+
const indexContent = await readFile(join(clientDir, "index.html"));
|
|
3262
|
+
return new Response(indexContent, { headers: { "Content-Type": "text/html" } });
|
|
3263
|
+
}
|
|
3264
|
+
});
|
|
3265
|
+
return app;
|
|
3266
|
+
}
|
|
3267
|
+
/**
|
|
3268
|
+
* Newest mtime (epoch ms) across everything that counts as project activity:
|
|
3269
|
+
* comments.json, plans.json, and any attachment (media) file. Plans and media
|
|
3270
|
+
* extend a project's life exactly like comments do, so retention is uniform
|
|
3271
|
+
* across all three. Returns null when none of them exist yet.
|
|
3272
|
+
*/
|
|
3273
|
+
async function newestActivityMs(projectDir) {
|
|
3274
|
+
const candidates = [];
|
|
3275
|
+
const safeStat = async (p) => {
|
|
3276
|
+
try {
|
|
3277
|
+
return (await stat(p)).mtimeMs;
|
|
3278
|
+
} catch {
|
|
3279
|
+
return null;
|
|
3280
|
+
}
|
|
3281
|
+
};
|
|
3282
|
+
for (const name of [
|
|
3283
|
+
"comments.json",
|
|
3284
|
+
"plans.json",
|
|
3285
|
+
"pr-session.json"
|
|
3286
|
+
]) {
|
|
3287
|
+
const file = join(projectDir, name);
|
|
3288
|
+
if (!existsSync(file)) continue;
|
|
3289
|
+
const m = await safeStat(file);
|
|
3290
|
+
if (m !== null) candidates.push(m);
|
|
3291
|
+
}
|
|
3292
|
+
const attachmentsDir = join(projectDir, "attachments");
|
|
3293
|
+
if (existsSync(attachmentsDir)) {
|
|
3294
|
+
const dirM = await safeStat(attachmentsDir);
|
|
3295
|
+
if (dirM !== null) candidates.push(dirM);
|
|
3296
|
+
try {
|
|
3297
|
+
for (const file of await readdir(attachmentsDir)) {
|
|
3298
|
+
const m = await safeStat(join(attachmentsDir, file));
|
|
3299
|
+
if (m !== null) candidates.push(m);
|
|
3300
|
+
}
|
|
3301
|
+
} catch {}
|
|
3302
|
+
}
|
|
3303
|
+
return candidates.length ? Math.max(...candidates) : null;
|
|
3304
|
+
}
|
|
3305
|
+
async function cleanupStaleProjects() {
|
|
3306
|
+
const baseDir = join(homedir(), ".diffing");
|
|
3307
|
+
if (!existsSync(baseDir)) return;
|
|
3308
|
+
try {
|
|
3309
|
+
const entries = await readdir(baseDir, { withFileTypes: true });
|
|
3310
|
+
const now = Date.now();
|
|
3311
|
+
const STALE_TIME = 336 * 60 * 60 * 1e3;
|
|
3312
|
+
for (const entry of entries) if (entry.isDirectory()) {
|
|
3313
|
+
const projectDir = join(baseDir, entry.name);
|
|
3314
|
+
const repoPathFile = join(projectDir, "repo_path.txt");
|
|
3315
|
+
let shouldDelete = false;
|
|
3316
|
+
if (existsSync(repoPathFile)) try {
|
|
3317
|
+
const repoPath = (await readFile(repoPathFile, "utf-8")).trim();
|
|
3318
|
+
if (!repoPath || !existsSync(repoPath)) shouldDelete = true;
|
|
3319
|
+
} catch {}
|
|
3320
|
+
if (!shouldDelete) {
|
|
3321
|
+
const newest = await newestActivityMs(projectDir);
|
|
3322
|
+
if (newest !== null && now - newest > STALE_TIME) shouldDelete = true;
|
|
3323
|
+
}
|
|
3324
|
+
if (shouldDelete) await rm(projectDir, {
|
|
3325
|
+
recursive: true,
|
|
3326
|
+
force: true
|
|
3327
|
+
});
|
|
3328
|
+
}
|
|
3329
|
+
} catch (err) {
|
|
3330
|
+
console.error("Failed to cleanup stale projects:", err);
|
|
3331
|
+
}
|
|
3332
|
+
}
|
|
3333
|
+
async function startServer(options) {
|
|
3334
|
+
cleanupStaleProjects().catch((err) => {
|
|
3335
|
+
console.error("Failed to clean up stale projects:", err);
|
|
3336
|
+
});
|
|
3337
|
+
let prMode = false;
|
|
3338
|
+
if (options.prRef) {
|
|
3339
|
+
console.error(`Building PR session for ${options.prRef}...`);
|
|
3340
|
+
try {
|
|
3341
|
+
const store = new FilePrSessionStore();
|
|
3342
|
+
const existing = await store.get();
|
|
3343
|
+
if (!existing || existing.ref !== options.prRef) {
|
|
3344
|
+
const session = await buildPrSession(options.prRef);
|
|
3345
|
+
await store.set(session);
|
|
3346
|
+
}
|
|
3347
|
+
prMode = true;
|
|
3348
|
+
} catch (err) {
|
|
3349
|
+
console.error(`[pr-session] failed to build session for ${options.prRef}: ${err?.message ?? err}`);
|
|
3350
|
+
}
|
|
3351
|
+
}
|
|
3352
|
+
const app = createApp(options.clientDir, options.diffOpts, void 0, void 0, void 0, prMode);
|
|
3353
|
+
return new Promise((resolve) => {
|
|
3354
|
+
serve({
|
|
3355
|
+
fetch: app.fetch,
|
|
3356
|
+
port: options.port,
|
|
3357
|
+
hostname: options.host
|
|
3358
|
+
}, (info) => {
|
|
3359
|
+
resolve({
|
|
3360
|
+
port: info.port,
|
|
3361
|
+
prMode
|
|
3362
|
+
});
|
|
3363
|
+
});
|
|
3364
|
+
});
|
|
3365
|
+
}
|
|
3366
|
+
//#endregion
|
|
3367
|
+
export { buildGitDiffArgs as a, printHelp as c, validateEnvironment as i, loadSettings as n, intoShowMode as o, runTerminalDiff as r, parseDiffOptions as s, startServer as t };
|