diff --git a/.asset/arch.excalidraw b/.asset/arch.excalidraw new file mode 100644 index 0000000..ba32588 --- /dev/null +++ b/.asset/arch.excalidraw @@ -0,0 +1,1585 @@ +{ + "type": "excalidraw", + "version": 2, + "source": "http://127.0.0.1:57953", + "elements": [ + { + "id": "title", + "type": "text", + "x": 360, + "y": -100, + "width": 332.9478759765625, + "height": 35, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "a0", + "roundness": null, + "seed": 10834183, + "version": 2, + "versionNonce": 578711913, + "isDeleted": false, + "boundElements": null, + "updated": 1792952018506, + "link": null, + "locked": false, + "text": "The packages of rexdiff", + "fontSize": 28, + "fontFamily": 5, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "The packages of rexdiff", + "autoResize": true, + "lineHeight": 1.25 + }, + { + "id": "action", + "type": "rectangle", + "x": 380, + "y": 0, + "width": 280, + "height": 80, + "angle": 0, + "strokeColor": "#868e96", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "dashed", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "a1", + "roundness": null, + "seed": 1138321129, + "version": 3, + "versionNonce": 375558473, + "isDeleted": false, + "boundElements": [ + { + "type": "text", + "id": "_pnHSHzS5QJsZPZe-wzBF" + }, + { + "id": "a-action", + "type": "arrow" + } + ], + "updated": 1792952018507, + "link": null, + "locked": false + }, + { + "id": "cmd", + "type": "rectangle", + "x": 380, + "y": 160, + "width": 280, + "height": 80, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "a2", + "roundness": null, + "seed": 235116583, + "version": 4, + "versionNonce": 742626057, + "isDeleted": false, + "boundElements": [ + { + "type": "text", + "id": "yXyTstSk4oPXD4ultzBHQ" + }, + { + "id": "a-action", + "type": "arrow" + }, + { + "id": "a-cmd", + "type": "arrow" + } + ], + "updated": 1792952018507, + "link": null, + "locked": false + }, + { + "id": "tui", + "type": "rectangle", + "x": 380, + "y": 320, + "width": 280, + "height": 80, + "angle": 0, + "strokeColor": "#1971c2", + "backgroundColor": "#a5d8ff", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "a3", + "roundness": null, + "seed": 2008981961, + "version": 8, + "versionNonce": 1594297289, + "isDeleted": false, + "boundElements": [ + { + "type": "text", + "id": "-0nbC-L_RuoARkEhwc8yu" + }, + { + "id": "a-cmd", + "type": "arrow" + }, + { + "id": "a-helpers", + "type": "arrow" + }, + { + "id": "a-diff", + "type": "arrow" + }, + { + "id": "a-vcs", + "type": "arrow" + }, + { + "id": "a-rex", + "type": "arrow" + }, + { + "id": "a-update", + "type": "arrow" + } + ], + "updated": 1792952018507, + "link": null, + "locked": false + }, + { + "id": "helpers", + "type": "rectangle", + "x": 760, + "y": 320, + "width": 280, + "height": 80, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "a4", + "roundness": null, + "seed": 1649146695, + "version": 3, + "versionNonce": 332506025, + "isDeleted": false, + "boundElements": [ + { + "type": "text", + "id": "7pHsGUCfjmZUkaN_nhh45" + }, + { + "id": "a-helpers", + "type": "arrow" + } + ], + "updated": 1792952018507, + "link": null, + "locked": false + }, + { + "id": "diff", + "type": "rectangle", + "x": 0, + "y": 520, + "width": 200, + "height": 80, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "a5", + "roundness": null, + "seed": 888766633, + "version": 4, + "versionNonce": 350973321, + "isDeleted": false, + "boundElements": [ + { + "type": "text", + "id": "vL9ymJgaUTD-i7pyNtkZ-" + }, + { + "id": "a-diff", + "type": "arrow" + }, + { + "id": "b-diff", + "type": "arrow" + } + ], + "updated": 1792952018507, + "link": null, + "locked": false + }, + { + "id": "vcs", + "type": "rectangle", + "x": 280, + "y": 520, + "width": 200, + "height": 80, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "a6", + "roundness": null, + "seed": 1373305447, + "version": 4, + "versionNonce": 653418313, + "isDeleted": false, + "boundElements": [ + { + "type": "text", + "id": "RaMj_L41u-zbSYihabeof" + }, + { + "id": "a-vcs", + "type": "arrow" + }, + { + "id": "b-vcs", + "type": "arrow" + } + ], + "updated": 1792952018507, + "link": null, + "locked": false + }, + { + "id": "rex", + "type": "rectangle", + "x": 560, + "y": 520, + "width": 200, + "height": 80, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "a7", + "roundness": null, + "seed": 154502025, + "version": 4, + "versionNonce": 968352009, + "isDeleted": false, + "boundElements": [ + { + "type": "text", + "id": "0wfdpH4IBZCjEM1zBLfzF" + }, + { + "id": "a-rex", + "type": "arrow" + }, + { + "id": "b-rex", + "type": "arrow" + } + ], + "updated": 1792952018507, + "link": null, + "locked": false + }, + { + "id": "update", + "type": "rectangle", + "x": 840, + "y": 520, + "width": 200, + "height": 80, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "a8", + "roundness": null, + "seed": 1904022919, + "version": 4, + "versionNonce": 2060972745, + "isDeleted": false, + "boundElements": [ + { + "type": "text", + "id": "fJ4fsTwF1FB1D1XCLrXKP" + }, + { + "id": "a-update", + "type": "arrow" + }, + { + "id": "b-update", + "type": "arrow" + } + ], + "updated": 1792952018507, + "link": null, + "locked": false + }, + { + "id": "diffview", + "type": "rectangle", + "x": 0, + "y": 680, + "width": 200, + "height": 80, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "a9", + "roundness": null, + "seed": 1085155945, + "version": 3, + "versionNonce": 1914098793, + "isDeleted": false, + "boundElements": [ + { + "type": "text", + "id": "Wla9gjl6aGmlu0hBiafLN" + }, + { + "id": "b-diff", + "type": "arrow" + } + ], + "updated": 1792952018507, + "link": null, + "locked": false + }, + { + "id": "git", + "type": "rectangle", + "x": 280, + "y": 680, + "width": 200, + "height": 80, + "angle": 0, + "strokeColor": "#868e96", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "dashed", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "aA", + "roundness": null, + "seed": 141479079, + "version": 3, + "versionNonce": 299470377, + "isDeleted": false, + "boundElements": [ + { + "type": "text", + "id": "qGxR8S6ftdekpJAzIOVs9" + }, + { + "id": "b-vcs", + "type": "arrow" + } + ], + "updated": 1792952018507, + "link": null, + "locked": false + }, + { + "id": "rexcli", + "type": "rectangle", + "x": 560, + "y": 680, + "width": 200, + "height": 80, + "angle": 0, + "strokeColor": "#868e96", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "dashed", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "aB", + "roundness": null, + "seed": 338621769, + "version": 3, + "versionNonce": 1007212521, + "isDeleted": false, + "boundElements": [ + { + "type": "text", + "id": "d1pthS-Y3x9_1msBa5Ylp" + }, + { + "id": "b-rex", + "type": "arrow" + } + ], + "updated": 1792952018507, + "link": null, + "locked": false + }, + { + "id": "gh", + "type": "rectangle", + "x": 840, + "y": 680, + "width": 200, + "height": 80, + "angle": 0, + "strokeColor": "#868e96", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "dashed", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "aC", + "roundness": null, + "seed": 1113366471, + "version": 3, + "versionNonce": 180400553, + "isDeleted": false, + "boundElements": [ + { + "type": "text", + "id": "EjPQN8_Z2Wb3s9Gctbhmr" + }, + { + "id": "b-update", + "type": "arrow" + } + ], + "updated": 1792952018507, + "link": null, + "locked": false + }, + { + "id": "a-action", + "type": "arrow", + "x": 520, + "y": 80, + "width": 0, + "height": 80, + "angle": 0, + "strokeColor": "#868e96", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "aD", + "roundness": null, + "seed": 387146793, + "version": 4, + "versionNonce": 1673577415, + "isDeleted": false, + "boundElements": null, + "updated": 1792952018507, + "link": null, + "locked": false, + "points": [ + [ + 0, + 0.5 + ], + [ + 0, + 79.5 + ] + ], + "lastCommittedPoint": null, + "startBinding": { + "elementId": "action", + "focus": 0, + "gap": 1 + }, + "endBinding": { + "elementId": "cmd", + "focus": 0, + "gap": 1 + }, + "startArrowhead": null, + "endArrowhead": "arrow", + "elbowed": false + }, + { + "id": "a-cmd", + "type": "arrow", + "x": 520, + "y": 240, + "width": 0, + "height": 80, + "angle": 0, + "strokeColor": "#868e96", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "aE", + "roundness": null, + "seed": 560140007, + "version": 4, + "versionNonce": 1505345031, + "isDeleted": false, + "boundElements": null, + "updated": 1792952018507, + "link": null, + "locked": false, + "points": [ + [ + 0, + 0.5 + ], + [ + 0, + 79.5 + ] + ], + "lastCommittedPoint": null, + "startBinding": { + "elementId": "cmd", + "focus": 0, + "gap": 1 + }, + "endBinding": { + "elementId": "tui", + "focus": 0, + "gap": 1 + }, + "startArrowhead": null, + "endArrowhead": "arrow", + "elbowed": false + }, + { + "id": "a-helpers", + "type": "arrow", + "x": 660, + "y": 360, + "width": 100, + "height": 0, + "angle": 0, + "strokeColor": "#868e96", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "aF", + "roundness": null, + "seed": 1699149577, + "version": 4, + "versionNonce": 686368839, + "isDeleted": false, + "boundElements": null, + "updated": 1792952018507, + "link": null, + "locked": false, + "points": [ + [ + 0.5, + 0 + ], + [ + 99.5, + 0 + ] + ], + "lastCommittedPoint": null, + "startBinding": { + "elementId": "tui", + "focus": 0, + "gap": 1 + }, + "endBinding": { + "elementId": "helpers", + "focus": 0, + "gap": 1 + }, + "startArrowhead": null, + "endArrowhead": "arrow", + "elbowed": false + }, + { + "id": "a-diff", + "type": "arrow", + "x": 420, + "y": 400, + "width": 320, + "height": 120, + "angle": 0, + "strokeColor": "#1971c2", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "aG", + "roundness": null, + "seed": 806263303, + "version": 4, + "versionNonce": 1785922183, + "isDeleted": false, + "boundElements": null, + "updated": 1792952018507, + "link": null, + "locked": false, + "points": [ + [ + -0.5, + 0.5 + ], + [ + -319.5, + 119.5 + ] + ], + "lastCommittedPoint": null, + "startBinding": { + "elementId": "tui", + "focus": -0.02702702702702681, + "gap": 1 + }, + "endBinding": { + "elementId": "diff", + "focus": -0.5161290322580637, + "gap": 1 + }, + "startArrowhead": null, + "endArrowhead": "arrow", + "elbowed": false + }, + { + "id": "a-vcs", + "type": "arrow", + "x": 480, + "y": 400, + "width": 100, + "height": 120, + "angle": 0, + "strokeColor": "#1971c2", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "aH", + "roundness": null, + "seed": 289746409, + "version": 4, + "versionNonce": 1048267975, + "isDeleted": false, + "boundElements": null, + "updated": 1792952018507, + "link": null, + "locked": false, + "points": [ + [ + -0.5, + 0.5 + ], + [ + -99.5, + 119.5 + ] + ], + "lastCommittedPoint": null, + "startBinding": { + "elementId": "tui", + "focus": 0.03846153846153916, + "gap": 1 + }, + "endBinding": { + "elementId": "vcs", + "focus": -0.24999999999999953, + "gap": 1 + }, + "startArrowhead": null, + "endArrowhead": "arrow", + "elbowed": false + }, + { + "id": "a-rex", + "type": "arrow", + "x": 560, + "y": 400, + "width": 100, + "height": 120, + "angle": 0, + "strokeColor": "#1971c2", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "aI", + "roundness": null, + "seed": 1935393063, + "version": 4, + "versionNonce": 203818759, + "isDeleted": false, + "boundElements": null, + "updated": 1792952018507, + "link": null, + "locked": false, + "points": [ + [ + 0.5, + 0.5 + ], + [ + 99.5, + 119.5 + ] + ], + "lastCommittedPoint": null, + "startBinding": { + "elementId": "tui", + "focus": -0.038461538461538415, + "gap": 1 + }, + "endBinding": { + "elementId": "rex", + "focus": 0.25000000000000006, + "gap": 1 + }, + "startArrowhead": null, + "endArrowhead": "arrow", + "elbowed": false + }, + { + "id": "a-update", + "type": "arrow", + "x": 620, + "y": 400, + "width": 320, + "height": 120, + "angle": 0, + "strokeColor": "#1971c2", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "aJ", + "roundness": null, + "seed": 1377405129, + "version": 4, + "versionNonce": 1100427591, + "isDeleted": false, + "boundElements": null, + "updated": 1792952018507, + "link": null, + "locked": false, + "points": [ + [ + 0.5, + 0.5 + ], + [ + 319.5, + 119.5 + ] + ], + "lastCommittedPoint": null, + "startBinding": { + "elementId": "tui", + "focus": 0.02702702702702745, + "gap": 1 + }, + "endBinding": { + "elementId": "update", + "focus": 0.5161290322580646, + "gap": 1 + }, + "startArrowhead": null, + "endArrowhead": "arrow", + "elbowed": false + }, + { + "id": "b-diff", + "type": "arrow", + "x": 100, + "y": 600, + "width": 0, + "height": 80, + "angle": 0, + "strokeColor": "#868e96", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "aK", + "roundness": null, + "seed": 481756231, + "version": 4, + "versionNonce": 334678919, + "isDeleted": false, + "boundElements": null, + "updated": 1792952018507, + "link": null, + "locked": false, + "points": [ + [ + 0, + 0.5 + ], + [ + 0, + 79.5 + ] + ], + "lastCommittedPoint": null, + "startBinding": { + "elementId": "diff", + "focus": 0, + "gap": 1 + }, + "endBinding": { + "elementId": "diffview", + "focus": 0, + "gap": 1 + }, + "startArrowhead": null, + "endArrowhead": "arrow", + "elbowed": false + }, + { + "id": "b-vcs", + "type": "arrow", + "x": 380, + "y": 600, + "width": 0, + "height": 80, + "angle": 0, + "strokeColor": "#868e96", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "aL", + "roundness": null, + "seed": 1902086057, + "version": 4, + "versionNonce": 1063048647, + "isDeleted": false, + "boundElements": null, + "updated": 1792952018507, + "link": null, + "locked": false, + "points": [ + [ + 0, + 0.5 + ], + [ + 0, + 79.5 + ] + ], + "lastCommittedPoint": null, + "startBinding": { + "elementId": "vcs", + "focus": 0, + "gap": 1 + }, + "endBinding": { + "elementId": "git", + "focus": 0, + "gap": 1 + }, + "startArrowhead": null, + "endArrowhead": "arrow", + "elbowed": false + }, + { + "id": "b-rex", + "type": "arrow", + "x": 660, + "y": 600, + "width": 0, + "height": 80, + "angle": 0, + "strokeColor": "#868e96", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "aM", + "roundness": null, + "seed": 2080170855, + "version": 4, + "versionNonce": 1190744071, + "isDeleted": false, + "boundElements": null, + "updated": 1792952018507, + "link": null, + "locked": false, + "points": [ + [ + 0, + 0.5 + ], + [ + 0, + 79.5 + ] + ], + "lastCommittedPoint": null, + "startBinding": { + "elementId": "rex", + "focus": 0, + "gap": 1 + }, + "endBinding": { + "elementId": "rexcli", + "focus": 0, + "gap": 1 + }, + "startArrowhead": null, + "endArrowhead": "arrow", + "elbowed": false + }, + { + "id": "b-update", + "type": "arrow", + "x": 940, + "y": 600, + "width": 0, + "height": 80, + "angle": 0, + "strokeColor": "#868e96", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "aN", + "roundness": null, + "seed": 2034412169, + "version": 4, + "versionNonce": 887896647, + "isDeleted": false, + "boundElements": null, + "updated": 1792952018507, + "link": null, + "locked": false, + "points": [ + [ + 0, + 0.5 + ], + [ + 0, + 79.5 + ] + ], + "lastCommittedPoint": null, + "startBinding": { + "elementId": "update", + "focus": 0, + "gap": 1 + }, + "endBinding": { + "elementId": "gh", + "focus": 0, + "gap": 1 + }, + "startArrowhead": null, + "endArrowhead": "arrow", + "elbowed": false + }, + { + "id": "note", + "type": "text", + "x": 0, + "y": 800, + "width": 677.5194091796875, + "height": 20, + "angle": 0, + "strokeColor": "#868e96", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "aO", + "roundness": null, + "seed": 830070407, + "version": 2, + "versionNonce": 179813353, + "isDeleted": false, + "boundElements": null, + "updated": 1792952018506, + "link": null, + "locked": false, + "text": "Dashed boxes are programs outside rexdiff. Arrows point from the caller to the callee.", + "fontSize": 16, + "fontFamily": 5, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "Dashed boxes are programs outside rexdiff. Arrows point from the caller to the callee.", + "autoResize": true, + "lineHeight": 1.25 + }, + { + "id": "_pnHSHzS5QJsZPZe-wzBF", + "type": "text", + "x": 457.8400421142578, + "y": 15, + "width": 124.31991577148438, + "height": 50, + "angle": 0, + "strokeColor": "#868e96", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "aP", + "roundness": null, + "seed": 1788941095, + "version": 3, + "versionNonce": 68901223, + "isDeleted": false, + "boundElements": null, + "updated": 1792952018507, + "link": null, + "locked": false, + "text": "Rex action\nShift+Cmd+R", + "fontSize": 20, + "fontFamily": 5, + "textAlign": "center", + "verticalAlign": "middle", + "containerId": "action", + "originalText": "Rex action\nShift+Cmd+R", + "autoResize": true, + "lineHeight": 1.25 + }, + { + "id": "yXyTstSk4oPXD4ultzBHQ", + "type": "text", + "x": 460.550048828125, + "y": 175, + "width": 118.89990234375, + "height": 50, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "aQ", + "roundness": null, + "seed": 1669469767, + "version": 3, + "versionNonce": 1626248329, + "isDeleted": false, + "boundElements": null, + "updated": 1792952018507, + "link": null, + "locked": false, + "text": "cmd/rexdiff\nflags, install", + "fontSize": 20, + "fontFamily": 5, + "textAlign": "center", + "verticalAlign": "middle", + "containerId": "cmd", + "originalText": "cmd/rexdiff\nflags, install", + "autoResize": true, + "lineHeight": 1.25 + }, + { + "id": "-0nbC-L_RuoARkEhwc8yu", + "type": "text", + "x": 434.99005126953125, + "y": 335, + "width": 170.0198974609375, + "height": 50, + "angle": 0, + "strokeColor": "#1971c2", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "aR", + "roundness": null, + "seed": 462796135, + "version": 3, + "versionNonce": 1515820167, + "isDeleted": false, + "boundElements": null, + "updated": 1792952018507, + "link": null, + "locked": false, + "text": "tui\nBubble Tea model", + "fontSize": 20, + "fontFamily": 5, + "textAlign": "center", + "verticalAlign": "middle", + "containerId": "tui", + "originalText": "tui\nBubble Tea model", + "autoResize": true, + "lineHeight": 1.25 + }, + { + "id": "7pHsGUCfjmZUkaN_nhh45", + "type": "text", + "x": 814.8900604248047, + "y": 335, + "width": 170.21987915039062, + "height": 50, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "aS", + "roundness": null, + "seed": 1244164231, + "version": 3, + "versionNonce": 1092426601, + "isDeleted": false, + "boundElements": null, + "updated": 1792952018507, + "link": null, + "locked": false, + "text": "internal helpers\ntree, search, turn", + "fontSize": 20, + "fontFamily": 5, + "textAlign": "center", + "verticalAlign": "middle", + "containerId": "helpers", + "originalText": "internal helpers\ntree, search, turn", + "autoResize": true, + "lineHeight": 1.25 + }, + { + "id": "vL9ymJgaUTD-i7pyNtkZ-", + "type": "text", + "x": 18.05005645751953, + "y": 535, + "width": 163.89988708496094, + "height": 50, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "aT", + "roundness": null, + "seed": 150390695, + "version": 3, + "versionNonce": 1063480231, + "isDeleted": false, + "boundElements": null, + "updated": 1792952018507, + "link": null, + "locked": false, + "text": "diff\nrender, comments", + "fontSize": 20, + "fontFamily": 5, + "textAlign": "center", + "verticalAlign": "middle", + "containerId": "diff", + "originalText": "diff\nrender, comments", + "autoResize": true, + "lineHeight": 1.25 + }, + { + "id": "RaMj_L41u-zbSYihabeof", + "type": "text", + "x": 306.44005584716797, + "y": 535, + "width": 147.11988830566406, + "height": 50, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "aU", + "roundness": null, + "seed": 103627463, + "version": 3, + "versionNonce": 1805108809, + "isDeleted": false, + "boundElements": null, + "updated": 1792952018507, + "link": null, + "locked": false, + "text": "vcs\ntrees, files, PR", + "fontSize": 20, + "fontFamily": 5, + "textAlign": "center", + "verticalAlign": "middle", + "containerId": "vcs", + "originalText": "vcs\ntrees, files, PR", + "autoResize": true, + "lineHeight": 1.25 + }, + { + "id": "0wfdpH4IBZCjEM1zBLfzF", + "type": "text", + "x": 593.0700531005859, + "y": 535, + "width": 133.85989379882812, + "height": 50, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "aV", + "roundness": null, + "seed": 1986546151, + "version": 3, + "versionNonce": 258301639, + "isDeleted": false, + "boundElements": null, + "updated": 1792952018507, + "link": null, + "locked": false, + "text": "rex\nagents, paste", + "fontSize": 20, + "fontFamily": 5, + "textAlign": "center", + "verticalAlign": "middle", + "containerId": "rex", + "originalText": "rex\nagents, paste", + "autoResize": true, + "lineHeight": 1.25 + }, + { + "id": "fJ4fsTwF1FB1D1XCLrXKP", + "type": "text", + "x": 901.1000366210938, + "y": 535, + "width": 77.7999267578125, + "height": 50, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "aW", + "roundness": null, + "seed": 1186231559, + "version": 3, + "versionNonce": 188355881, + "isDeleted": false, + "boundElements": null, + "updated": 1792952018507, + "link": null, + "locked": false, + "text": "update\nreleases", + "fontSize": 20, + "fontFamily": 5, + "textAlign": "center", + "verticalAlign": "middle", + "containerId": "update", + "originalText": "update\nreleases", + "autoResize": true, + "lineHeight": 1.25 + }, + { + "id": "Wla9gjl6aGmlu0hBiafLN", + "type": "text", + "x": 21.39007568359375, + "y": 695, + "width": 157.2198486328125, + "height": 50, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "aX", + "roundness": null, + "seed": 244202535, + "version": 3, + "versionNonce": 1828449767, + "isDeleted": false, + "boundElements": null, + "updated": 1792952018507, + "link": null, + "locked": false, + "text": "internal/diffview\nchroma, lipgloss", + "fontSize": 20, + "fontFamily": 5, + "textAlign": "center", + "verticalAlign": "middle", + "containerId": "diffview", + "originalText": "internal/diffview\nchroma, lipgloss", + "autoResize": true, + "lineHeight": 1.25 + }, + { + "id": "qGxR8S6ftdekpJAzIOVs9", + "type": "text", + "x": 325.05003356933594, + "y": 695, + "width": 109.89993286132812, + "height": 50, + "angle": 0, + "strokeColor": "#868e96", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "aY", + "roundness": null, + "seed": 1105338183, + "version": 3, + "versionNonce": 1668253705, + "isDeleted": false, + "boundElements": null, + "updated": 1792952018507, + "link": null, + "locked": false, + "text": "git\ngh, glab, az", + "fontSize": 20, + "fontFamily": 5, + "textAlign": "center", + "verticalAlign": "middle", + "containerId": "git", + "originalText": "git\ngh, glab, az", + "autoResize": true, + "lineHeight": 1.25 + }, + { + "id": "d1pthS-Y3x9_1msBa5Ylp", + "type": "text", + "x": 599.4300384521484, + "y": 695, + "width": 121.13992309570312, + "height": 50, + "angle": 0, + "strokeColor": "#868e96", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "aZ", + "roundness": null, + "seed": 588029543, + "version": 3, + "versionNonce": 1932761351, + "isDeleted": false, + "boundElements": null, + "updated": 1792952018507, + "link": null, + "locked": false, + "text": "rex command\nRex server", + "fontSize": 20, + "fontFamily": 5, + "textAlign": "center", + "verticalAlign": "middle", + "containerId": "rexcli", + "originalText": "rex command\nRex server", + "autoResize": true, + "lineHeight": 1.25 + }, + { + "id": "EjPQN8_Z2Wb3s9Gctbhmr", + "type": "text", + "x": 864.5700607299805, + "y": 695, + "width": 150.85987854003906, + "height": 50, + "angle": 0, + "strokeColor": "#868e96", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "aa", + "roundness": null, + "seed": 1960458631, + "version": 3, + "versionNonce": 1043570409, + "isDeleted": false, + "boundElements": null, + "updated": 1792952018507, + "link": null, + "locked": false, + "text": "GitHub releases\nbrew, go", + "fontSize": 20, + "fontFamily": 5, + "textAlign": "center", + "verticalAlign": "middle", + "containerId": "gh", + "originalText": "GitHub releases\nbrew, go", + "autoResize": true, + "lineHeight": 1.25 + } + ], + "appState": { + "viewBackgroundColor": "#ffffff" + }, + "files": {} +} \ No newline at end of file diff --git a/.asset/arch.json b/.asset/arch.json new file mode 100644 index 0000000..ef237ae --- /dev/null +++ b/.asset/arch.json @@ -0,0 +1,63 @@ +[ + { "type": "text", "id": "title", "x": 360, "y": -100, "text": "The packages of rexdiff", "fontSize": 28 }, + + { "type": "rectangle", "id": "action", "x": 380, "y": 0, "width": 280, "height": 80, + "strokeStyle": "dashed", "strokeColor": "muted", + "label": { "text": "Rex action\nShift+Cmd+R" } }, + { "type": "rectangle", "id": "cmd", "x": 380, "y": 160, "width": 280, "height": 80, + "label": { "text": "cmd/rexdiff\nflags, install" } }, + { "type": "rectangle", "id": "tui", "x": 380, "y": 320, "width": 280, "height": 80, + "strokeColor": "accent", "backgroundColor": "accent-fill", + "label": { "text": "tui\nBubble Tea model" } }, + { "type": "rectangle", "id": "helpers", "x": 760, "y": 320, "width": 280, "height": 80, + "label": { "text": "internal helpers\ntree, search, turn" } }, + + { "type": "rectangle", "id": "diff", "x": 0, "y": 520, "width": 200, "height": 80, + "label": { "text": "diff\nrender, comments" } }, + { "type": "rectangle", "id": "vcs", "x": 280, "y": 520, "width": 200, "height": 80, + "label": { "text": "vcs\ntrees, files, PR" } }, + { "type": "rectangle", "id": "rex", "x": 560, "y": 520, "width": 200, "height": 80, + "label": { "text": "rex\nagents, paste" } }, + { "type": "rectangle", "id": "update", "x": 840, "y": 520, "width": 200, "height": 80, + "label": { "text": "update\nreleases" } }, + + { "type": "rectangle", "id": "diffview", "x": 0, "y": 680, "width": 200, "height": 80, + "label": { "text": "internal/diffview\nchroma, lipgloss" } }, + { "type": "rectangle", "id": "git", "x": 280, "y": 680, "width": 200, "height": 80, + "strokeStyle": "dashed", "strokeColor": "muted", + "label": { "text": "git\ngh, glab, az" } }, + { "type": "rectangle", "id": "rexcli", "x": 560, "y": 680, "width": 200, "height": 80, + "strokeStyle": "dashed", "strokeColor": "muted", + "label": { "text": "rex command\nRex server" } }, + { "type": "rectangle", "id": "gh", "x": 840, "y": 680, "width": 200, "height": 80, + "strokeStyle": "dashed", "strokeColor": "muted", + "label": { "text": "GitHub releases\nbrew, go" } }, + + { "type": "arrow", "id": "a-action", "x": 520, "y": 80, "points": [[0, 0], [0, 80]], + "start": { "id": "action" }, "end": { "id": "cmd" }, "strokeColor": "muted" }, + { "type": "arrow", "id": "a-cmd", "x": 520, "y": 240, "points": [[0, 0], [0, 80]], + "start": { "id": "cmd" }, "end": { "id": "tui" }, "strokeColor": "muted" }, + { "type": "arrow", "id": "a-helpers", "x": 660, "y": 360, "points": [[0, 0], [100, 0]], + "start": { "id": "tui" }, "end": { "id": "helpers" }, "strokeColor": "muted" }, + + { "type": "arrow", "id": "a-diff", "x": 420, "y": 400, "points": [[0, 0], [-320, 120]], + "start": { "id": "tui" }, "end": { "id": "diff" }, "strokeColor": "accent" }, + { "type": "arrow", "id": "a-vcs", "x": 480, "y": 400, "points": [[0, 0], [-100, 120]], + "start": { "id": "tui" }, "end": { "id": "vcs" }, "strokeColor": "accent" }, + { "type": "arrow", "id": "a-rex", "x": 560, "y": 400, "points": [[0, 0], [100, 120]], + "start": { "id": "tui" }, "end": { "id": "rex" }, "strokeColor": "accent" }, + { "type": "arrow", "id": "a-update", "x": 620, "y": 400, "points": [[0, 0], [320, 120]], + "start": { "id": "tui" }, "end": { "id": "update" }, "strokeColor": "accent" }, + + { "type": "arrow", "id": "b-diff", "x": 100, "y": 600, "points": [[0, 0], [0, 80]], + "start": { "id": "diff" }, "end": { "id": "diffview" }, "strokeColor": "muted" }, + { "type": "arrow", "id": "b-vcs", "x": 380, "y": 600, "points": [[0, 0], [0, 80]], + "start": { "id": "vcs" }, "end": { "id": "git" }, "strokeColor": "muted" }, + { "type": "arrow", "id": "b-rex", "x": 660, "y": 600, "points": [[0, 0], [0, 80]], + "start": { "id": "rex" }, "end": { "id": "rexcli" }, "strokeColor": "muted" }, + { "type": "arrow", "id": "b-update", "x": 940, "y": 600, "points": [[0, 0], [0, 80]], + "start": { "id": "update" }, "end": { "id": "gh" }, "strokeColor": "muted" }, + + { "type": "text", "id": "note", "x": 0, "y": 800, "fontSize": 16, "strokeColor": "muted", + "text": "Dashed boxes are programs outside rexdiff. Arrows point from the caller to the callee." } +] diff --git a/.asset/arch.png b/.asset/arch.png new file mode 100644 index 0000000..cd641e2 Binary files /dev/null and b/.asset/arch.png differ diff --git a/.asset/arch.svg b/.asset/arch.svg new file mode 100644 index 0000000..178b8ab --- /dev/null +++ b/.asset/arch.svg @@ -0,0 +1,2 @@ +The packages of rexdiffRex actionShift+Cmd+Rcmd/rexdiffflags, installtuiBubble Tea modelinternal helperstree, search, turndiffrender, commentsvcstrees, files, PRrexagents, pasteupdatereleasesinternal/diffviewchroma, lipglossgitgh, glab, azrex commandRex serverGitHub releasesbrew, goDashed boxes are programs outside rexdiff. Arrows point from the caller to the callee. \ No newline at end of file diff --git a/.asset/changes.png b/.asset/changes.png new file mode 100644 index 0000000..079c660 Binary files /dev/null and b/.asset/changes.png differ diff --git a/.asset/comment.png b/.asset/comment.png new file mode 100644 index 0000000..3b1f70e Binary files /dev/null and b/.asset/comment.png differ diff --git a/.asset/demo.mp4 b/.asset/demo.mp4 new file mode 100644 index 0000000..28e9afa Binary files /dev/null and b/.asset/demo.mp4 differ diff --git a/.asset/files.png b/.asset/files.png new file mode 100644 index 0000000..08248ea Binary files /dev/null and b/.asset/files.png differ diff --git a/.asset/help.png b/.asset/help.png new file mode 100644 index 0000000..fb41fbb Binary files /dev/null and b/.asset/help.png differ diff --git a/.asset/list.png b/.asset/list.png new file mode 100644 index 0000000..b400f32 Binary files /dev/null and b/.asset/list.png differ diff --git a/assets/logo.png b/.asset/logo.png similarity index 100% rename from assets/logo.png rename to .asset/logo.png diff --git a/.asset/review.png b/.asset/review.png new file mode 100644 index 0000000..3d48986 Binary files /dev/null and b/.asset/review.png differ diff --git a/.asset/search.png b/.asset/search.png new file mode 100644 index 0000000..7ba5324 Binary files /dev/null and b/.asset/search.png differ diff --git a/.asset/sent.png b/.asset/sent.png new file mode 100644 index 0000000..45c9fc4 Binary files /dev/null and b/.asset/sent.png differ diff --git a/.asset/split.png b/.asset/split.png new file mode 100644 index 0000000..f08f721 Binary files /dev/null and b/.asset/split.png differ diff --git a/.asset/test.excalidraw b/.asset/test.excalidraw new file mode 100644 index 0000000..c22f27d --- /dev/null +++ b/.asset/test.excalidraw @@ -0,0 +1,1024 @@ +{ + "type": "excalidraw", + "version": 2, + "source": "http://127.0.0.1:61583", + "elements": [ + { + "id": "title", + "type": "text", + "x": 0, + "y": -100, + "width": 292.683837890625, + "height": 35, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "a0", + "roundness": null, + "seed": 640218145, + "version": 2, + "versionNonce": 525079361, + "isDeleted": false, + "boundElements": null, + "updated": 1792963585203, + "link": null, + "locked": false, + "text": "How rexdiff is tested", + "fontSize": 28, + "fontFamily": 5, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "How rexdiff is tested", + "autoResize": true, + "lineHeight": 1.25 + }, + { + "id": "bb", + "type": "rectangle", + "x": 0, + "y": 0, + "width": 280, + "height": 80, + "angle": 0, + "strokeColor": "#1971c2", + "backgroundColor": "#a5d8ff", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "a1", + "roundness": null, + "seed": 1680382575, + "version": 3, + "versionNonce": 781956879, + "isDeleted": false, + "boundElements": [ + { + "type": "text", + "id": "w0RuaxrPrgcCWA4s2TiME" + }, + { + "id": "a1", + "type": "arrow" + } + ], + "updated": 1792963585203, + "link": null, + "locked": false + }, + { + "id": "bin", + "type": "rectangle", + "x": 0, + "y": 160, + "width": 280, + "height": 80, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "a2", + "roundness": null, + "seed": 1142409217, + "version": 5, + "versionNonce": 645688751, + "isDeleted": false, + "boundElements": [ + { + "type": "text", + "id": "H6pAheHcjrHJiwbqzQ2IW" + }, + { + "id": "a1", + "type": "arrow" + }, + { + "id": "a2", + "type": "arrow" + }, + { + "id": "a3", + "type": "arrow" + } + ], + "updated": 1792963585204, + "link": null, + "locked": false + }, + { + "id": "tui", + "type": "rectangle", + "x": 0, + "y": 320, + "width": 280, + "height": 80, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "a3", + "roundness": null, + "seed": 2716815, + "version": 4, + "versionNonce": 1547204623, + "isDeleted": false, + "boundElements": [ + { + "type": "text", + "id": "njlFDPjz27Wm1YBvTrXqs" + }, + { + "id": "a2", + "type": "arrow" + }, + { + "id": "a4", + "type": "arrow" + } + ], + "updated": 1792963585204, + "link": null, + "locked": false + }, + { + "id": "fakes", + "type": "rectangle", + "x": -480, + "y": 160, + "width": 280, + "height": 80, + "angle": 0, + "strokeColor": "#868e96", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "dashed", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "a4", + "roundness": null, + "seed": 146874337, + "version": 3, + "versionNonce": 1193336783, + "isDeleted": false, + "boundElements": [ + { + "type": "text", + "id": "2VBLioJqFt_Cf01Qw5VXN" + }, + { + "id": "a3", + "type": "arrow" + } + ], + "updated": 1792963585204, + "link": null, + "locked": false + }, + { + "id": "lt", + "type": "rectangle", + "x": 520, + "y": 0, + "width": 280, + "height": 80, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "a5", + "roundness": null, + "seed": 927599279, + "version": 3, + "versionNonce": 1928339055, + "isDeleted": false, + "boundElements": [ + { + "type": "text", + "id": "ebgDz_PLu46R45427hL4Y" + }, + { + "id": "a5", + "type": "arrow" + } + ], + "updated": 1792963585204, + "link": null, + "locked": false + }, + { + "id": "libs", + "type": "rectangle", + "x": 520, + "y": 320, + "width": 280, + "height": 80, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "a6", + "roundness": null, + "seed": 1110735809, + "version": 5, + "versionNonce": 352358063, + "isDeleted": false, + "boundElements": [ + { + "type": "text", + "id": "eIhuGsq0FNm9Y56oD_HML" + }, + { + "id": "a4", + "type": "arrow" + }, + { + "id": "a5", + "type": "arrow" + }, + { + "id": "a6", + "type": "arrow" + } + ], + "updated": 1792963585204, + "link": null, + "locked": false + }, + { + "id": "git", + "type": "rectangle", + "x": 520, + "y": 480, + "width": 280, + "height": 80, + "angle": 0, + "strokeColor": "#868e96", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "dashed", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "a7", + "roundness": null, + "seed": 103996623, + "version": 3, + "versionNonce": 1920524495, + "isDeleted": false, + "boundElements": [ + { + "type": "text", + "id": "ZcWeP_EU14j3C2iugjy6h" + }, + { + "id": "a6", + "type": "arrow" + } + ], + "updated": 1792963585204, + "link": null, + "locked": false + }, + { + "id": "a1", + "type": "arrow", + "x": 140, + "y": 80, + "width": 0, + "height": 80, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "a8", + "roundness": null, + "seed": 1351703457, + "version": 4, + "versionNonce": 1667908961, + "isDeleted": false, + "boundElements": null, + "updated": 1792963585203, + "link": null, + "locked": false, + "points": [ + [ + 0, + 0.5 + ], + [ + 0, + 79.5 + ] + ], + "lastCommittedPoint": null, + "startBinding": { + "elementId": "bb", + "focus": 0, + "gap": 1 + }, + "endBinding": { + "elementId": "bin", + "focus": 0, + "gap": 1 + }, + "startArrowhead": null, + "endArrowhead": "arrow", + "elbowed": false + }, + { + "id": "a2", + "type": "arrow", + "x": 140, + "y": 240, + "width": 0, + "height": 80, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "a9", + "roundness": null, + "seed": 1081895663, + "version": 4, + "versionNonce": 206512417, + "isDeleted": false, + "boundElements": null, + "updated": 1792963585204, + "link": null, + "locked": false, + "points": [ + [ + 0, + 0.5 + ], + [ + 0, + 79.5 + ] + ], + "lastCommittedPoint": null, + "startBinding": { + "elementId": "bin", + "focus": 0, + "gap": 1 + }, + "endBinding": { + "elementId": "tui", + "focus": 0, + "gap": 1 + }, + "startArrowhead": null, + "endArrowhead": "arrow", + "elbowed": false + }, + { + "id": "a3", + "type": "arrow", + "x": 0, + "y": 200, + "width": 200, + "height": 0, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "dashed", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "aA", + "roundness": null, + "seed": 1678196609, + "version": 4, + "versionNonce": 1611516097, + "isDeleted": false, + "boundElements": [ + { + "type": "text", + "id": "5pk65H0rB_Qs3SgGCSYZL" + } + ], + "updated": 1792963585204, + "link": null, + "locked": false, + "points": [ + [ + -0.5, + 0 + ], + [ + -199.5, + 0 + ] + ], + "lastCommittedPoint": null, + "startBinding": { + "elementId": "bin", + "focus": 0, + "gap": 1 + }, + "endBinding": { + "elementId": "fakes", + "focus": 0, + "gap": 1 + }, + "startArrowhead": null, + "endArrowhead": "arrow", + "elbowed": false + }, + { + "id": "a4", + "type": "arrow", + "x": 280, + "y": 360, + "width": 240, + "height": 0, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "aB", + "roundness": null, + "seed": 850343183, + "version": 4, + "versionNonce": 2075530337, + "isDeleted": false, + "boundElements": [ + { + "type": "text", + "id": "3xGCOiSnMgqRghwWEUaGN" + } + ], + "updated": 1792963585204, + "link": null, + "locked": false, + "points": [ + [ + 0.5, + 0 + ], + [ + 239.5, + 0 + ] + ], + "lastCommittedPoint": null, + "startBinding": { + "elementId": "tui", + "focus": 0, + "gap": 1 + }, + "endBinding": { + "elementId": "libs", + "focus": 0, + "gap": 1 + }, + "startArrowhead": null, + "endArrowhead": "arrow", + "elbowed": false + }, + { + "id": "a5", + "type": "arrow", + "x": 660, + "y": 80, + "width": 0, + "height": 240, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "aC", + "roundness": null, + "seed": 2060822369, + "version": 4, + "versionNonce": 175801345, + "isDeleted": false, + "boundElements": [ + { + "type": "text", + "id": "wseFpIN0L02dGlR0S37mR" + } + ], + "updated": 1792963585204, + "link": null, + "locked": false, + "points": [ + [ + 0, + 0.5 + ], + [ + 0, + 239.5 + ] + ], + "lastCommittedPoint": null, + "startBinding": { + "elementId": "lt", + "focus": 0, + "gap": 1 + }, + "endBinding": { + "elementId": "libs", + "focus": 0, + "gap": 1 + }, + "startArrowhead": null, + "endArrowhead": "arrow", + "elbowed": false + }, + { + "id": "a6", + "type": "arrow", + "x": 660, + "y": 400, + "width": 0, + "height": 80, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "dashed", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "aD", + "roundness": null, + "seed": 71547695, + "version": 4, + "versionNonce": 605566913, + "isDeleted": false, + "boundElements": null, + "updated": 1792963585204, + "link": null, + "locked": false, + "points": [ + [ + 0, + 0.5 + ], + [ + 0, + 79.5 + ] + ], + "lastCommittedPoint": null, + "startBinding": { + "elementId": "libs", + "focus": 0, + "gap": 1 + }, + "endBinding": { + "elementId": "git", + "focus": 0, + "gap": 1 + }, + "startArrowhead": null, + "endArrowhead": "arrow", + "elbowed": false + }, + { + "id": "w0RuaxrPrgcCWA4s2TiME", + "type": "text", + "x": 63.410057067871094, + "y": 15, + "width": 153.1798858642578, + "height": 50, + "angle": 0, + "strokeColor": "#1971c2", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "aE", + "roundness": null, + "seed": 1210021473, + "version": 3, + "versionNonce": 916297633, + "isDeleted": false, + "boundElements": null, + "updated": 1792963585204, + "link": null, + "locked": false, + "text": "black-box tests\ntest/", + "fontSize": 20, + "fontFamily": 5, + "textAlign": "center", + "verticalAlign": "middle", + "containerId": "bb", + "originalText": "black-box tests\ntest/", + "autoResize": true, + "lineHeight": 1.25 + }, + { + "id": "H6pAheHcjrHJiwbqzQ2IW", + "type": "text", + "x": 74.85005950927734, + "y": 175, + "width": 130.2998809814453, + "height": 50, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "aF", + "roundness": null, + "seed": 1619264065, + "version": 3, + "versionNonce": 1029828335, + "isDeleted": false, + "boundElements": null, + "updated": 1792963585204, + "link": null, + "locked": false, + "text": "rexdiff binary\nin a pty", + "fontSize": 20, + "fontFamily": 5, + "textAlign": "center", + "verticalAlign": "middle", + "containerId": "bin", + "originalText": "rexdiff binary\nin a pty", + "autoResize": true, + "lineHeight": 1.25 + }, + { + "id": "njlFDPjz27Wm1YBvTrXqs", + "type": "text", + "x": 42.89006042480469, + "y": 335, + "width": 194.21987915039062, + "height": 50, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "aG", + "roundness": null, + "seed": 942412321, + "version": 3, + "versionNonce": 892074881, + "isDeleted": false, + "boundElements": null, + "updated": 1792963585204, + "link": null, + "locked": false, + "text": "tui\nno tests of its own", + "fontSize": 20, + "fontFamily": 5, + "textAlign": "center", + "verticalAlign": "middle", + "containerId": "tui", + "originalText": "tui\nno tests of its own", + "autoResize": true, + "lineHeight": 1.25 + }, + { + "id": "2VBLioJqFt_Cf01Qw5VXN", + "type": "text", + "x": -437.61993408203125, + "y": 175, + "width": 195.2398681640625, + "height": 50, + "angle": 0, + "strokeColor": "#868e96", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "aH", + "roundness": null, + "seed": 604448257, + "version": 3, + "versionNonce": 4471055, + "isDeleted": false, + "boundElements": null, + "updated": 1792963585204, + "link": null, + "locked": false, + "text": "fake rex, gh, pbcopy\nrecorded output", + "fontSize": 20, + "fontFamily": 5, + "textAlign": "center", + "verticalAlign": "middle", + "containerId": "fakes", + "originalText": "fake rex, gh, pbcopy\nrecorded output", + "autoResize": true, + "lineHeight": 1.25 + }, + { + "id": "ebgDz_PLu46R45427hL4Y", + "type": "text", + "x": 583.3500595092773, + "y": 15, + "width": 153.2998809814453, + "height": 50, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "aI", + "roundness": null, + "seed": 789888481, + "version": 3, + "versionNonce": 1073931105, + "isDeleted": false, + "boundElements": null, + "updated": 1792963585204, + "link": null, + "locked": false, + "text": "library tests\npackage x_test", + "fontSize": 20, + "fontFamily": 5, + "textAlign": "center", + "verticalAlign": "middle", + "containerId": "lt", + "originalText": "library tests\npackage x_test", + "autoResize": true, + "lineHeight": 1.25 + }, + { + "id": "eIhuGsq0FNm9Y56oD_HML", + "type": "text", + "x": 558.2900695800781, + "y": 335, + "width": 203.41986083984375, + "height": 50, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "aJ", + "roundness": null, + "seed": 1482971585, + "version": 3, + "versionNonce": 1620590383, + "isDeleted": false, + "boundElements": null, + "updated": 1792963585204, + "link": null, + "locked": false, + "text": "libraries\ndiff, vcs, rex, update", + "fontSize": 20, + "fontFamily": 5, + "textAlign": "center", + "verticalAlign": "middle", + "containerId": "libs", + "originalText": "libraries\ndiff, vcs, rex, update", + "autoResize": true, + "lineHeight": 1.25 + }, + { + "id": "ZcWeP_EU14j3C2iugjy6h", + "type": "text", + "x": 595.3100433349609, + "y": 495, + "width": 129.37991333007812, + "height": 50, + "angle": 0, + "strokeColor": "#868e96", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "aK", + "roundness": null, + "seed": 286620065, + "version": 3, + "versionNonce": 1131532097, + "isDeleted": false, + "boundElements": null, + "updated": 1792963585204, + "link": null, + "locked": false, + "text": "real git\nin temp repos", + "fontSize": 20, + "fontFamily": 5, + "textAlign": "center", + "verticalAlign": "middle", + "containerId": "git", + "originalText": "real git\nin temp repos", + "autoResize": true, + "lineHeight": 1.25 + }, + { + "id": "5pk65H0rB_Qs3SgGCSYZL", + "type": "text", + "x": -120.28997802734375, + "y": 187.5, + "width": 40.5799560546875, + "height": 25, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "aL", + "roundness": null, + "seed": 1055937793, + "version": 3, + "versionNonce": 1086751055, + "isDeleted": false, + "boundElements": null, + "updated": 1792963585204, + "link": null, + "locked": false, + "text": "runs", + "fontSize": 20, + "fontFamily": 5, + "textAlign": "center", + "verticalAlign": "middle", + "containerId": "a3", + "originalText": "runs", + "autoResize": true, + "lineHeight": 1.25 + }, + { + "id": "3xGCOiSnMgqRghwWEUaGN", + "type": "text", + "x": 378.29002380371094, + "y": 347.5, + "width": 43.419952392578125, + "height": 25, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "aM", + "roundness": null, + "seed": 1605961889, + "version": 3, + "versionNonce": 1977106209, + "isDeleted": false, + "boundElements": null, + "updated": 1792963585204, + "link": null, + "locked": false, + "text": "uses", + "fontSize": 20, + "fontFamily": 5, + "textAlign": "center", + "verticalAlign": "middle", + "containerId": "a4", + "originalText": "uses", + "autoResize": true, + "lineHeight": 1.25 + }, + { + "id": "wseFpIN0L02dGlR0S37mR", + "type": "text", + "x": 610.6800384521484, + "y": 187.5, + "width": 98.63992309570312, + "height": 25, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "index": "aN", + "roundness": null, + "seed": 1670832193, + "version": 3, + "versionNonce": 573013871, + "isDeleted": false, + "boundElements": null, + "updated": 1792963585204, + "link": null, + "locked": false, + "text": "public API", + "fontSize": 20, + "fontFamily": 5, + "textAlign": "center", + "verticalAlign": "middle", + "containerId": "a5", + "originalText": "public API", + "autoResize": true, + "lineHeight": 1.25 + } + ], + "appState": { + "viewBackgroundColor": "#ffffff" + }, + "files": {} +} \ No newline at end of file diff --git a/.asset/test.json b/.asset/test.json new file mode 100644 index 0000000..045a803 --- /dev/null +++ b/.asset/test.json @@ -0,0 +1,36 @@ +[ + { "type": "text", "id": "title", "x": 0, "y": -100, "text": "How rexdiff is tested", "fontSize": 28 }, + + { "type": "rectangle", "id": "bb", "x": 0, "y": 0, "width": 280, "height": 80, + "strokeColor": "accent", "backgroundColor": "accent-fill", + "label": { "text": "black-box tests\ntest/" } }, + { "type": "rectangle", "id": "bin", "x": 0, "y": 160, "width": 280, "height": 80, + "label": { "text": "rexdiff binary\nin a pty" } }, + { "type": "rectangle", "id": "tui", "x": 0, "y": 320, "width": 280, "height": 80, + "label": { "text": "tui\nno tests of its own" } }, + { "type": "rectangle", "id": "fakes", "x": -480, "y": 160, "width": 280, "height": 80, + "strokeStyle": "dashed", "strokeColor": "muted", + "label": { "text": "fake rex, gh, pbcopy\nrecorded output" } }, + + { "type": "rectangle", "id": "lt", "x": 520, "y": 0, "width": 280, "height": 80, + "label": { "text": "library tests\npackage x_test" } }, + { "type": "rectangle", "id": "libs", "x": 520, "y": 320, "width": 280, "height": 80, + "label": { "text": "libraries\ndiff, vcs, rex, update" } }, + { "type": "rectangle", "id": "git", "x": 520, "y": 480, "width": 280, "height": 80, + "strokeStyle": "dashed", "strokeColor": "muted", + "label": { "text": "real git\nin temp repos" } }, + + { "type": "arrow", "id": "a1", "x": 140, "y": 80, "points": [[0, 0], [0, 80]], + "start": { "id": "bb" }, "end": { "id": "bin" } }, + { "type": "arrow", "id": "a2", "x": 140, "y": 240, "points": [[0, 0], [0, 80]], + "start": { "id": "bin" }, "end": { "id": "tui" } }, + { "type": "arrow", "id": "a3", "x": 0, "y": 200, "points": [[0, 0], [-200, 0]], + "strokeStyle": "dashed", "start": { "id": "bin" }, "end": { "id": "fakes" }, + "label": { "text": "runs" } }, + { "type": "arrow", "id": "a4", "x": 280, "y": 360, "points": [[0, 0], [240, 0]], + "start": { "id": "tui" }, "end": { "id": "libs" }, "label": { "text": "uses" } }, + { "type": "arrow", "id": "a5", "x": 660, "y": 80, "points": [[0, 0], [0, 240]], + "start": { "id": "lt" }, "end": { "id": "libs" }, "label": { "text": "public API" } }, + { "type": "arrow", "id": "a6", "x": 660, "y": 400, "points": [[0, 0], [0, 80]], + "strokeStyle": "dashed", "start": { "id": "libs" }, "end": { "id": "git" } } +] diff --git a/.asset/test.png b/.asset/test.png new file mode 100644 index 0000000..1578fa6 Binary files /dev/null and b/.asset/test.png differ diff --git a/.asset/test.svg b/.asset/test.svg new file mode 100644 index 0000000..2f84852 --- /dev/null +++ b/.asset/test.svg @@ -0,0 +1,2 @@ +How rexdiff is testedblack-box teststest/rexdiff binaryin a ptytuino tests of its ownfake rex, gh, pbcopyrecorded outputlibrary testspackage x_testlibrariesdiff, vcs, rex, updatereal gitin temp reposrunsusespublic API \ No newline at end of file diff --git a/.doc/arch.md b/.doc/arch.md new file mode 100644 index 0000000..fec638f --- /dev/null +++ b/.doc/arch.md @@ -0,0 +1,33 @@ +# Architecture + +rexdiff is a Bubble Tea program in a Rex pane. It reads the repository with git, finds +the agents with rex, and pastes the review into the agent with rex. It talks to both +through their command-line tools. + +![The packages of rexdiff](../.asset/arch.svg) + +The diagram comes from `.asset/arch.json`. To edit it, open `.asset/arch.excalidraw` on +excalidraw.com. + +## Packages + +- `tui` has the screen and all the state. It only uses the packages below. +- `diff` draws a file diff in the terminal and keeps the comments. +- `vcs` runs git, and gh, glab, or az for the pull request. +- `rex` runs rex. +- `update` installs a new release and restarts rexdiff. +- `internal/` has small helpers for these packages. +- `cmd/rexdiff` reads the flags, runs `rexdiff install`, and starts `tui`. + +## Rules of the design + +- The libraries do not import each other, and `tui` uses only their public API. +- Only `cmd/rexdiff` reads the environment of the process. It gives the environment to + the libraries as a value. +- Bubble Tea runs the model on one goroutine. Slow work runs in a command with a time + limit, and sends back a message. +- Each scope compares two git trees. rexdiff makes the tree of your files with a private + index, so the real index does not change. +- Lua does only the Rex part: the key and the action that opens the pane. Your settings + are in `rexdiff.lua`. rexdiff writes the action to `rexdiff_toggle.lua`. +- When the right behaviour is not clear, rexdiff does what herdr-reviewr does. diff --git a/.doc/dev.md b/.doc/dev.md new file mode 100644 index 0000000..b766706 --- /dev/null +++ b/.doc/dev.md @@ -0,0 +1,52 @@ +# Development + +You need Go at the version in `go.mod`, git, and golangci-lint v2.15.0: + +```sh +go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.15.0 +``` + +## Run a change in Rex + +```sh +./dev.sh +``` + +This builds rexdiff and opens it beside an "agent" pane in the Rex session +`rexreview-test`. Run it again after each change. The log is in `/tmp/rexdiff.log`. Set +`REXDIFF_DEMO` to show another repository. + +Outside Rex, `go run ./cmd/rexdiff` works in any git repository, but cannot send. + +## Check + +```sh +go vet ./... +go test -race -count=1 ./... +golangci-lint run +go fix -diff ./... +go mod tidy -diff +go run golang.org/x/vuln/cmd/govulncheck@v1.9.0 ./... +``` + +To run the tests against a real Rex too: + +```sh +REXDIFF_REX=$(which rex) go test ./test/ +``` + +## Release + +```sh +gh release create v0.3.0 +``` + +GitHub Actions builds the binaries and updates the Homebrew cask. + +## Rules + +- Clean text from git, Rex, or a pull request with `internal/termtext` before you show + it. +- Start a program with `internal/proc`. +- Keep the crush notice in each file that `internal/DERIVED.md` lists. +- Write commit messages in this form: `fix(tui): keep the view on failed reloads`. diff --git a/.doc/test.md b/.doc/test.md new file mode 100644 index 0000000..a934a15 --- /dev/null +++ b/.doc/test.md @@ -0,0 +1,28 @@ +# Tests + +![How rexdiff is tested](../.asset/test.svg) + +rexdiff has library tests and black-box tests. + +## Library tests + +- Each library is tested through its public API only, in `package x_test`. +- A test that needs git runs real git in a temporary repository. `internal/testgit` + hides your git config, so hooks and signing do not change the result. +- A test gives the library its own environment, with fake tools on its own `PATH`. No + test changes the environment of the process, so all of them run in parallel. + +## Black-box tests + +- `tui` has no unit tests. The tests in `test/` build the binary and use it as you do. +- Each test types keys and clicks through a terminal emulator, and reads the screen. +- A test waits for the screen or for a file, never for a fixed time. +- One test waits for the log: `TestLastTurn` waits for the line `msg="turn start"` in + the debug log. +- The scripts in `test/testdata/script` test the commands, for example `install`, with + testscript. +- Shell scripts stand in for rex, gh, the clipboard, and the editors. The fake rex + prints output recorded from a real Rex. +- `TestRealRex` runs rexdiff in a real Rex, to find a fake that no longer matches Rex. +- The black-box tests run one at a time, because each one is a real program in a + terminal. diff --git a/.doc/user.md b/.doc/user.md new file mode 100644 index 0000000..ad77c51 --- /dev/null +++ b/.doc/user.md @@ -0,0 +1,136 @@ +# User guide + +rexdiff shows the changes of a coding agent in a Rex pane beside the agent. You comment +on lines and send the comments to the agent. + +## 1. Install + +```sh +brew tap rednafi/rexdiff https://github.com/rednafi/rexdiff +brew trust rednafi/rexdiff +brew install --cask rexdiff +rexdiff install +``` + +With Go, use `go install github.com/rednafi/rexdiff/cmd/rexdiff@latest`, then run +`rexdiff install`. + +## 2. Open rexdiff + +Focus the pane of the agent and press Shift+Cmd+R. Press it again to close rexdiff. + +![The Changes tab](../.asset/changes.png) + +## 3. Read the diff + +The diff is on the left. The changed files are on the right. + +- `j` and `k` move one line. +- `]` and `[` go to the next or previous change. +- `f` and `F` go to the next or previous file. +- The right arrow key on a folded row shows the unchanged lines. +- `:` goes to a line number. Ctrl+F finds text in the file. +- `m` shows a Markdown file as rendered text. You can comment there too. +- Drag over the code with the mouse to copy it. Click a link to open it. + +rexdiff reloads when a file changes. + +## 4. Pick what to compare + +- `u`: the uncommitted changes +- `b`: all the changes on the branch +- `t`: the last turn of the agent +- `g`: the commits that you pick + +Press `B` to pick the base branch, and `G` to pick the commits. + +## 5. See both sides + +Press `|` to see the old and the new code side by side. Press `z` to hide the file list +if the pane is narrow. + +![Side by side](../.asset/split.png) + +## 6. Comment + +Put the cursor on a line, press `c`, type the comment, and press Enter. For more lines, +press `v` and move down first. + +![Writing a comment](../.asset/comment.png) + +The comment shows under its code. + +![Comments in the diff](../.asset/review.png) + +## 7. Change the comments + +- `e` edits the comment under the cursor. +- `d` deletes it. +- `l` lists all the comments. + +![The comment list](../.asset/list.png) + +## 8. Send the review + +Press `s`. rexdiff pastes the review into the input box of the agent. You press Enter. +If there are two or more agents, pick one with the arrow keys or a digit, then press +Enter. + +![The review in the agent](../.asset/sent.png) + +Press `y` to copy the review instead. + +## 9. See all the files + +Press `2` to see all the files of the repository. You can comment there too. + +![All files](../.asset/files.png) + +## 10. Search + +Press `/` to find a file. Press Tab to find code. + +![Search](../.asset/search.png) + +## 11. See the pull request + +Press `3` to see the pull request of the branch, with its checks and comments. This needs +gh, glab, or az. + +- `o` opens the pull request in the browser. +- The right arrow key opens the folded `
` blocks. The left arrow key closes + them. + +## 12. Change the settings + +The settings are in `~/.config/rex/rexdiff.lua`: + +```lua +local settings = { + key = "shift+cmd+r", -- the toggle key, or false for none + placement = "split", -- split, zoomed, tab, or overlay + direction = "right", -- of the split: right or down + layout = "unified", -- or split + editor = "code -g {file}:{line}", + keybindings = { send = { "S" } }, +} +``` + +The other settings are as in +[herdr-reviewr](https://github.com/persiyanov/herdr-reviewr), with these changes: + +- `layout`, `placement`, and `direction` are its `diff_view`, `toggle_placement`, + and `toggle_direction`. +- `file_list` is its `navigator_position`. +- rexdiff adds `key`, `file_list_width`, `diff_signs`, and `focus_agent_after_send`. +- rexdiff adds the `focus` key action. +- rexdiff has no `theme` and no `auto_open`. + +After a change, run `rex config reload`. + +Press `?` to see all the keys. + +![All keys](../.asset/help.png) + +Press `U` to update when the bottom line shows a new version. Press `q` to quit. +`rexdiff uninstall` removes rexdiff from Rex. diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..01fffd3 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,10 @@ +version: 2 +updates: + - package-ecosystem: gomod + directory: / + schedule: + interval: weekly + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c84da4a..b0bd620 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,14 +4,23 @@ on: push: branches: [master] pull_request: + # Each week, check the code against the new vulnerability reports. + schedule: + - cron: "0 6 * * 1" permissions: contents: read +# On a pull request, a new run cancels the run before it. +concurrency: + group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: # Rex runs only on macOS, so rexdiff is tested there. test: runs-on: macos-latest + timeout-minutes: 20 steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 @@ -19,21 +28,41 @@ jobs: go-version-file: go.mod - run: go vet ./... - run: go test -race -count=1 ./... + # A second run in a random order finds a test that depends on another. + - run: go test -count=2 -shuffle=on ./diff/ ./vcs/ ./rex/ ./update/ ./internal/... lint: runs-on: ubuntu-latest + timeout-minutes: 10 steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: go-version-file: go.mod + # Each check fails if the files differ from what the tool makes. + - name: go mod tidy + run: go mod tidy -diff + - name: go fix + run: test -z "$(go fix -diff ./... | tee /dev/stderr)" + # golangci-lint also runs the formatters gofmt and gofumpt. - uses: golangci/golangci-lint-action@v8 with: # Older releases cannot read the export data of a newer Go. version: v2.15.0 + govulncheck: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - run: go run golang.org/x/vuln/cmd/govulncheck@v1.9.0 ./... + goreleaser: runs-on: ubuntu-latest + timeout-minutes: 15 steps: - uses: actions/checkout@v4 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e137c58..5975b4c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,12 +7,13 @@ on: types: [published] permissions: - contents: write + contents: read jobs: # CI runs only on branches, so a release runs the tests again. test: runs-on: macos-latest + timeout-minutes: 20 steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 @@ -24,6 +25,11 @@ jobs: release: needs: test runs-on: ubuntu-latest + timeout-minutes: 20 + # GoReleaser uploads the files to the release and pushes + # Casks/rexdiff.rb to master with GITHUB_TOKEN. + permissions: + contents: write steps: - uses: actions/checkout@v4 with: diff --git a/.gitignore b/.gitignore index f8be09c..3fd8ea9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,7 @@ /rexdiff /.pi/ /dist/ +*.test +*.out +*.prof +.DS_Store diff --git a/.golangci.yml b/.golangci.yml index 75948a8..fdd7427 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -7,6 +7,31 @@ linters: - ineffassign - staticcheck - unused + - errcheck + - errorlint + - nilerr + - noctx + - bodyclose + - forcetypeassert + - durationcheck + - makezero + - reassign + - nakedret + - gocritic + - revive + - misspell + - usestdlibvars + - perfsprint + - intrange + - copyloopvar + - modernize + - thelper + - dupword + - nolintlint + - gosec + - unparam + - paralleltest + - tparallel settings: staticcheck: checks: @@ -18,7 +43,40 @@ linters: - -ST1021 - -ST1022 - -QF1001 # De Morgan: the current form reads better + errcheck: + exclude-functions: + - fmt.Fprint + - fmt.Fprintf + - fmt.Fprintln + misspell: + # No locale: the comments use British spelling, for example "colour". + ignore-rules: + - reviewr + gosec: + # rexdiff runs git and rex, and reads and writes the user's files. + excludes: + - G204 # subprocess with a variable + - G301 # directory permissions + - G302 # file permissions on chmod + - G304 # file path from a variable + - G306 # file permissions on write + - G702 # subprocess with a variable, found by taint analysis + - G703 # file path from a variable, found by taint analysis + nolintlint: + require-explanation: true + require-specific: true + exclusions: + rules: + - path: _test\.go + linters: [forcetypeassert, unparam] + - linters: [revive] + text: redefines-builtin-id + # The tests in test/ run one at a time. The tests of the libraries + # run in parallel. + - path: ^test/ + linters: [paralleltest, tparallel] formatters: enable: - gofmt + - gofumpt diff --git a/README.md b/README.md index afab372..8adf79d 100644 --- a/README.md +++ b/README.md @@ -1,107 +1,29 @@ -rexdiff +rexdiff A diff viewer for coding agents in [Rex]. Think [herdr-reviewr], but for Rex. - -It opens next to the agent and shows the changes. You comment on lines and send -the comments to the agent. +It opens next to the agent and shows its changes. You comment on lines and send +the comments back to the agent. https://github.com/user-attachments/assets/e44159eb-3b02-4ea8-88ca-f6314261c1aa ## Install -With Homebrew: - ```sh brew tap rednafi/rexdiff https://github.com/rednafi/rexdiff brew trust rednafi/rexdiff brew install --cask rexdiff -``` - -With Go: - -```sh -go install github.com/rednafi/rexdiff/cmd/rexdiff@latest -``` - -Add rexdiff to Rex: - -```sh rexdiff install ``` -This adds the "Toggle rexdiff" action and binds it to Shift+Cmd+R. To remove -them, run `rexdiff uninstall`. - -## Use - -- Open: press Shift+Cmd+R next to an agent, or run "Toggle rexdiff" from the - command palette. Press it again to close rexdiff. -- Comment: move to a line and press `c`. For a range, press `v` or drag over - the line numbers. -- Send: press `s`. rexdiff pastes the comments into the agent's input. It does - not press Enter. -- Update: when rexdiff shows a new version at the bottom, press `U`. Your - comments stay. - -When the agent edits a file, the comments move with their lines. If the lines -are gone, rexdiff marks the comment as stale. - -| Key | Action | -| --- | --- | -| `u` `b` `t` | uncommitted, branch, or last-turn changes | -| `]` `[` | next or previous change | -| `f` `F` | next or previous file | -| `c` | comment | -| `s` | send the comments to the agent | -| `y` | copy the comments | -| `\|` | side by side | -| `1` `2` `3` | changes, all files, or pull request | -| `?` | all keys | - -## Configure - -Edit the settings at the top of `~/.config/rex/rexdiff.lua`: - -```lua -local settings = { - key = "shift+cmd+r", -- toggle key, or false for none - placement = "split", -- split, zoomed, tab, or overlay - layout = "split", -- unified or split - editor = "code -g {file}:{line}", - keybindings = { send = { "S" } }, -} -``` - -Other settings: - -- `default_scope` -- `markdown_view` -- `file_list` -- `file_list_width` -- `review_prefix` -- `url_opener` -- `focus_agent_after_send` -- `github_host` -- `gitlab_host` -- `azure_devops_host` - -See [herdr-reviewr] for the values and the action names. - -`e` opens the file at the cursor line in `editor`, else in `$VISUAL`, `$EDITOR`, or git's -`core.editor`. A terminal editor, such as `nvim`, `micro`, or `fresh`, opens in a new pane -beside rexdiff. A window editor, such as `code`, opens in its window. - -After a change: - -- Run `rex config reload`. -- Do not edit the line `-- rexdiff code below` or the code under it. +Then press Shift+Cmd+R next to an agent. -## Requirements +## Docs -- Rex -- git -- `gh`, `glab`, or `az` for the pull request tab. You must be logged in. +- [User guide](.doc/user.md) +- [Architecture](.doc/arch.md) +- [Development](.doc/dev.md) +- [Tests](.doc/test.md) diff --git a/cmd/rexdiff/install.go b/cmd/rexdiff/install.go index 6c1b584..d9eb447 100644 --- a/cmd/rexdiff/install.go +++ b/cmd/rexdiff/install.go @@ -2,14 +2,17 @@ package main import ( "context" + _ "embed" "errors" "flag" "fmt" "io" + "io/fs" "os" "path/filepath" "slices" "strings" + "time" "github.com/rednafi/rexdiff/rex" ) @@ -21,224 +24,89 @@ import ( // status that rexdiff reports. A Rex session is the same as a herdr // workspace. // -// The action is in rexdiff.lua, beside Rex's init.lua, and init.lua loads -// it with one line. rexdiff.lua has the settings of the user at the top and -// the code of rexdiff under codeMark. Install and upgrades replace the code -// and keep the settings. +// The action is in two files beside Rex's init.lua, and init.lua loads them +// with one line. rexdiff.lua has the settings of the user. Install writes +// it only if it is not there, and rexdiff does not change it after that. +// rexdiff_toggle.lua has the toggle action. Install and upgrades write +// it whole. const ( // requireLine is the line of init.lua that loads rexdiff.lua. requireLine = `require("rexdiff")` - luaFile = "rexdiff.lua" // beside init.lua - // codeMark is the line of rexdiff.lua above the code of rexdiff. - codeMark = "-- rexdiff code below. Install and upgrades replace it. Do not edit this line." + luaFile = "rexdiff.lua" // beside init.lua + toggleFile = "rexdiff_toggle.lua" // beside init.lua ) -// defaultSettings is the top of a new rexdiff.lua. -const defaultSettings = `-- "rexdiff install" wrote this file. To remove it, run "rexdiff uninstall". --- --- These are the settings of rexdiff. The README of rexdiff lists them. --- To change a setting, edit it, then run "rex config reload". --- Install and upgrades keep this part. -local settings = { - -- key = "shift+cmd+r", -- the key of the toggle, or false for none - -- placement = "split", -- split, zoomed, tab, or overlay - -- layout = "split", -} - -` - -// codeTemplate is the code of rexdiff.lua, under codeMark. Its %s is the -// line from binLine. -const codeTemplate = `%s -local PLACEMENTS = { split = true, zoomed = true, tab = true, overlay = true } -local TERM = "com.superlogical.terminal" - --- must returns res, or fails the action with err. A Rex call returns nil --- and an error message when it fails. -local function must(res, err) - if err ~= nil then error(err, 0) end - return res -end - --- call calls method on the terminal block with the ID id. -local function call(method, id) - return must(rex.block.call(TERM, method, { block_id = id })) -end - --- runs_rexdiff returns true if the terminal block id runs rexdiff. rexdiff --- reports app=rexdiff to Rex with OSC 7501. Rex keeps an idle status after --- its program stops, so the block counts only while the process that --- reported the status is in the foreground. -local function runs_rexdiff(id) - for _, r in ipairs(call("program_status", id).records or {}) do - if r.app == "rexdiff" and r.owner then - local fg = call("process", id).foreground - return fg ~= nil and fg.pid == r.owner.pid - end - end - return false -end - --- reviewers returns the IDs of the blocks in the session that run rexdiff. -local function reviewers() - local ids = {} - for _, b in ipairs(must(rex.session.list_blocks{}).blocks or {}) do - if b.creator_name == TERM and runs_rexdiff(b.block_id) then - table.insert(ids, b.block_id) - end - end - return ids -end - --- check fails the action if rexdiff does not accept config, the settings --- as a JSON object. Here the user sees the error. If rexdiff stops at once --- in a new pane, the pane closes and the error is lost. -local function check(config) - local r, err = rex.exec({ BIN, "--check", "--config", config }) - if r == nil then error(err, 0) end - if not r.ok then error((r.stderr:gsub("%%s+$", "")), 0) end -end +// settingsLua is the content of a new rexdiff.lua: the settings, then the +// loader of rexdiff_toggle.lua. If rexdiff_toggle.lua fails, the loader keeps +// the rest of Rex's config, and the key shows why. +// +//go:embed rexdiff.lua +var settingsLua string --- open_rexdiff opens rexdiff at placement, beside the agent in ctx, with --- the settings in config. rexdiff starts in the current directory of the --- agent. The rex package of rexdiff finds the directory the same way. --- rexdiff starts through the shell of the user, so that it gets the --- environment of the user, for example EDITOR and PATH. -local function open_rexdiff(ctx, placement, config) - local p = call("process", ctx.block_id) - local cwd = (p.foreground or {}).cwd or (p.child or {}).cwd - local node = rex.layout.block{ - flavor = TERM .. ".shell", - label = "rexdiff", - options = { - command = { BIN, "--agent", ctx.block_id, "--config", config }, - cwd = cwd, - }, - } - if placement == "tab" then - return must(rex.session.new_window{ window_label = "rexdiff", layout = node }) - elseif placement == "overlay" then - return must(rex.session.new_layer{ - bounds = { 0.04, 0.04, 0.92, 0.92 }, - layout = node, - focus = true, - }) - end - local s = must(rex.session.new_split{ - anchor_id = ctx.block_id, - direction = "horizontal", - side = "after", - ratio = 0.45, - focus = true, - layout = node, - }) - if placement == "zoomed" then - must(rex.session.zoom_block{ block_id = s.block_ids[1], zoom = true }) - end - return s -end +// loaderMark is the first line of the loader in settingsLua. +const loaderMark = "-- rexdiff_toggle.lua has the toggle action" --- The key and the placement are for Rex. rexdiff gets the other settings --- as JSON. -if type(settings) ~= "table" then - error("rexdiff.lua: settings must be a table, as in: local settings = { }", 0) -end -local opts = {} -for k, v in pairs(settings) do opts[k] = v end -local key = opts.key -if key == nil then key = "shift+cmd+r" end -local default_placement = opts.placement or "split" -opts.key, opts.placement = nil, nil -local config, encode_err = rex.json.encode(opts) +// oldMark is the line of a rexdiff.lua from rexdiff 0.2.7 above its code. +// The text above the line is the settings of the user. +const oldMark = "-- rexdiff code below. Install and upgrades replace it. Do not edit this line." -rex.action{ - name = "rexdiff_toggle", - title = "Toggle rexdiff", - description = "Review the agent's changes beside it and send comments back", - category = "rexdiff", - keywords = "diff review git comments agent", - args = { placement = "string?" }, -- split, zoomed, tab, or overlay - run = function(ctx, args) - local placement = args.placement or default_placement - if not PLACEMENTS[placement] then - error("unknown placement " .. tostring(placement) - .. "; use split, zoomed, tab, or overlay", 0) - end - local open = reviewers() - if #open == 0 then - if config == nil then error("rexdiff settings: " .. encode_err, 0) end - check(config) - return open_rexdiff(ctx, placement, config) - end - for _, id in ipairs(open) do - must(rex.call("block.close", { session_id = ctx.session_id, block_id = id })) - end - return { closed = open } - end, +// fromOld returns the new rexdiff.lua for old, a rexdiff.lua from rexdiff +// 0.2.7. It keeps the settings of old, and puts the loader in place of the +// code. ok is false if old is not from 0.2.7. +func fromOld(old string) (s string, ok bool) { + above, _, ok := strings.Cut(old, oldMark) + if !ok { + return "", false + } + _, loader, _ := strings.Cut(settingsLua, loaderMark) + return strings.TrimRight(above, "\n") + "\n\n" + loaderMark + loader, true } -if key then rex.bind(key, "rexdiff_toggle") end -` -// luaCode returns the code part of rexdiff.lua for bin, the path of -// rexdiff, from codeMark to the end. -func luaCode(bin string) string { - return codeMark + "\n" + fmt.Sprintf(codeTemplate, binLine(bin)) +// toggleCode is the code of the toggle action. It needs BIN, the path of +// rexdiff. +// +//go:embed rexdiff_toggle.lua +var toggleCode string + +// toggleLua returns the content of rexdiff_toggle.lua for bin, the path of +// rexdiff. +func toggleLua(bin string) string { + return "-- rexdiff writes this file. Do not edit it. Your settings are in rexdiff.lua.\n" + + binLine(bin) + "\n" + toggleCode } -// binLine returns the line of rexdiff.lua that names bin. upgrade uses it -// to find which rexdiff the file runs. +// binLine returns the line of rexdiff_toggle.lua that names bin. upgrade uses +// it to find which rexdiff the file runs. func binLine(bin string) string { return "local BIN = " + luaQuote(bin) } +// luaEscapes escapes the characters that a Lua string cannot have as they +// are. A Replacer is safe for concurrent use. +var luaEscapes = strings.NewReplacer(`\`, `\\`, `"`, `\"`, "\n", `\n`, "\r", `\r`, "\x00", `\0`) + // luaQuote returns s as a Lua string. Go's %q writes escapes, such as // \u200b, that Lua 5.1 does not know. -func luaQuote(s string) string { - r := strings.NewReplacer(`\`, `\\`, `"`, `\"`, "\n", `\n`, "\r", `\r`, "\x00", `\0`) - return `"` + r.Replace(s) + `"` -} +func luaQuote(s string) string { return `"` + luaEscapes.Replace(s) + `"` } -// splitLua returns the settings part of rexdiff.lua, above codeMark, and the -// code part, from codeMark on. It returns an error if codeMark is gone, -// because then the settings have no end. -func splitLua(path, content string) (settings, code string, err error) { - i := 0 - for line := range strings.Lines(content) { - if strings.TrimSpace(line) == codeMark { - return content[:i], content[i:], nil - } - i += len(line) - } - return "", "", fmt.Errorf("%s has no line %q, so rexdiff cannot tell your "+ - "settings from its code. Put that line back under your settings, or move "+ - "the file away and run \"rexdiff install\" again", path, codeMark) +// luaPath returns the path of the file name in the environment env, +// beside the init.lua of Rex. +func luaPath(env []string, name string) string { + return filepath.Join(filepath.Dir(rex.ConfigPath(env)), name) } -// newLua returns the content of rexdiff.lua with the code for bin. It -// keeps the settings of old, the content that the file has now. If old is -// empty, it uses the default settings. -func newLua(path, old, bin string) (string, error) { - if old == "" { - return defaultSettings + luaCode(bin), nil - } - settings, _, err := splitLua(path, old) - return settings + luaCode(bin), err -} - -func luaPath() string { return filepath.Join(filepath.Dir(rex.ConfigPath()), luaFile) } - // readFile returns the content of the file at path, or "" if there is no // file. func readFile(path string) (string, error) { b, err := os.ReadFile(path) - if errors.Is(err, os.ErrNotExist) { + if errors.Is(err, fs.ErrNotExist) { return "", nil } return string(b), err } // executable returns the path of the rexdiff binary, for the Rex action. It -// keeps a symlink, such as the one from Homebrew. The target of the link -// changes at each upgrade. +// keeps a symlink, such as the one from Homebrew, because the target of the +// link changes at each upgrade. func executable() (string, error) { bin, err := os.Executable() if err != nil { @@ -246,103 +114,127 @@ func executable() (string, error) { } // go run deletes its binary at exit, so the action cannot run it. if strings.Contains(bin, string(filepath.Separator)+"go-build") { - return "", errors.New("install the binary first, for example with go install, " + - "then run rexdiff install") + return "", errors.New("install the binary first, for example with go install, then run rexdiff install") } return bin, nil } -// install writes rexdiff.lua with the action that runs bin, the path of -// rexdiff. It adds the require line to init.lua. -func install( - ctx context.Context, - bin string, - args []string, - stdout, stderr io.Writer, -) error { - fs := flag.NewFlagSet("rexdiff install", flag.ContinueOnError) - fs.SetOutput(stderr) - if err := fs.Parse(args); err != nil { - if errors.Is(err, flag.ErrHelp) { - return nil - } +// install writes rexdiff_toggle.lua with the action that runs bin, the path +// of rexdiff. It writes rexdiff.lua if it is not there, and moves the code +// out of a rexdiff.lua from rexdiff 0.2.7. It adds the require line to +// init.lua. env is the environment of rexdiff. +func install(ctx context.Context, env []string, bin string, args []string, stdout, stderr io.Writer) error { + if err := parseSub("install", args, env, stderr); err != nil { return err } - if fs.NArg() > 0 { - return fmt.Errorf("unknown argument %s (set the key and the placement "+ - "in the settings in %s)", fs.Arg(0), luaPath()) - } - // Make the new content first. Then a bad rexdiff.lua stops the install - // before it changes a file. - lua := luaPath() - old, err := readFile(lua) + settingsPath, togglePath := luaPath(env, luaFile), luaPath(env, toggleFile) + old, err := readFile(settingsPath) if err != nil { return err } - code, err := newLua(lua, old, bin) - if err != nil { + if err := os.MkdirAll(filepath.Dir(togglePath), 0o755); err != nil { return err } - if err := os.MkdirAll(filepath.Dir(lua), 0o755); err != nil { + if err := os.WriteFile(togglePath, []byte(toggleLua(bin)), 0o644); err != nil { return err } - if err := os.WriteFile(lua, []byte(code), 0o644); err != nil { - return err + fmt.Fprintf(stdout, "wrote %s\n", togglePath) + next, fromV027 := fromOld(old) + switch { + case old == "": + next = settingsLua + fmt.Fprintf(stdout, "wrote %s with your settings\n", settingsPath) + case fromV027: + fmt.Fprintf(stdout, "kept your settings in %s, and moved the code out\n", settingsPath) + default: + fmt.Fprintf(stdout, "kept your settings in %s\n", settingsPath) + } + if next != "" { + if err := os.WriteFile(settingsPath, []byte(next), 0o644); err != nil { + return err + } } - initPath := rex.ConfigPath() - if err := addRequire(initPath); err != nil { + initPath := rex.ConfigPath(env) + added, err := addRequire(initPath) + if err != nil { return err } - fmt.Fprintf(stdout, "wrote %s; the settings are at its top\n", lua) - fmt.Fprintf(stdout, "added %s to %s\n", requireLine, initPath) - fmt.Fprint(stdout, reload(ctx, stdout)) - fmt.Fprintln(stdout, "press the key of the settings (shift+cmd+r by default), "+ - "or run \"Toggle rexdiff\" from the command palette, beside an agent") + if added { + fmt.Fprintf(stdout, "added %s to %s\n", requireLine, initPath) + } + reload(ctx, env, stdout, stderr) + fmt.Fprintln(stdout, "press the key of the settings (shift+cmd+r by default), or run \"Toggle rexdiff\" from the command palette, beside an agent") return nil } -// upgrade replaces the code part of rexdiff.lua with the code for bin, if -// they differ, and keeps the settings. It does nothing if there is no -// rexdiff.lua, or if the file runs another rexdiff, for example a build -// for development. It returns what the user must know, or "". -func upgrade(ctx context.Context, bin string) string { - lua := luaPath() - old, err := readFile(lua) - if err != nil || !strings.Contains(old, binLine(bin)+"\n") { +// upgradeReloadTimeout is the longest time of the reload of the Rex +// config after an upgrade. +const upgradeReloadTimeout = 10 * time.Second + +// upgrade writes rexdiff_toggle.lua with the code for bin, if the file is +// there and differs, and reloads Rex's config. A rexdiff.lua from rexdiff +// 0.2.7 gets its code moved to rexdiff_toggle.lua, and keeps its settings. +// It is not the update of rexdiff itself (see the update package). It +// upgrades only the Lua code of the action. It does nothing if the files +// run another rexdiff, for example a build for development. It returns +// what the user must know, or "". env is the environment of rexdiff. +func upgrade(ctx context.Context, env []string, bin string) string { + settingsPath, togglePath := luaPath(env, luaFile), luaPath(env, toggleFile) + old, err := readFile(settingsPath) + if err != nil { return "" } - code, err := newLua(lua, old, bin) + code, err := readFile(togglePath) if err != nil { - return "cannot upgrade rexdiff.lua: " + err.Error() + return "" } - if code == old { + // Each file names the rexdiff that it runs on this line. + mine := "\n" + binLine(bin) + "\n" + next, fromV027 := fromOld(old) + fromV027 = fromV027 && strings.Contains(old, mine) + want := toggleLua(bin) + if !fromV027 && (code == want || !strings.Contains(code, mine)) { return "" } - if err := os.WriteFile(lua, []byte(code), 0o644); err != nil { - return "cannot upgrade rexdiff.lua: " + err.Error() + if err := os.WriteFile(togglePath, []byte(want), 0o644); err != nil { + return toggleFile + " upgrade failed: " + err.Error() + } + if fromV027 { + if err := os.WriteFile(settingsPath, []byte(next), 0o644); err != nil { + return luaFile + " upgrade failed: " + err.Error() + } } - if _, err := rex.ReloadConfig(ctx); err != nil { - return "upgraded rexdiff.lua; reload Rex's config yourself: rex config reload" + // The upgrade runs before the first frame, and the user does not see + // it. Thus the reload has a deadline. + ctx, cancel := context.WithCancelCause(ctx) + ctx, cancelTimeout := context.WithTimeoutCause(ctx, upgradeReloadTimeout, + fmt.Errorf("rex config reload: %v timeout: %w", upgradeReloadTimeout, context.DeadlineExceeded)) + defer cancelTimeout() + defer cancel(fmt.Errorf("rex config reload: done: %w", context.Canceled)) + if err := rex.ReloadConfig(ctx, env); err != nil { + return "upgraded " + toggleFile + ". Reload Rex's config yourself: rex config reload" } return "" } // addRequire adds the require line to the end of the init.lua at path. It // creates the file if it is not there. If the file has the line, addRequire -// does not change it. -func addRequire(path string) error { +// does not change it. It reports whether it adds the line. +func addRequire(path string) (bool, error) { s, err := readFile(path) if err != nil { - return err + return false, err } - if slices.ContainsFunc(strings.Split(s, "\n"), isRequire) { - return nil + for line := range strings.Lines(s) { + if isRequire(line) { + return false, nil + } } if s != "" && !strings.HasSuffix(s, "\n") { s += "\n" } - return os.WriteFile(path, []byte(s+requireLine+"\n"), 0o644) + return true, os.WriteFile(path, []byte(s+requireLine+"\n"), 0o644) } // isRequire reports whether line is the require line of rexdiff, maybe @@ -352,19 +244,44 @@ func isRequire(line string) bool { return t == requireLine || strings.HasPrefix(t, requireLine+" --") } +// parseSub parses the arguments of the subcommand name, which has no +// flags. It returns the errors of parseFlags. env is the environment of +// rexdiff. +func parseSub(name string, args, env []string, stderr io.Writer) error { + fs := flag.NewFlagSet("rexdiff "+name, flag.ContinueOnError) + fs.SetOutput(stderr) + if err := fs.Parse(args); err != nil { + return usageError(err) + } + if fs.NArg() > 0 { + // Show the usage, as the flag package does for a bad flag. + fmt.Fprintf(stderr, "unknown argument %q (set the key and the placement in the settings in %s)\n", + fs.Arg(0), luaPath(env, luaFile)) + fs.Usage() + return errUsage + } + return nil +} + // uninstall removes the require line from init.lua, then rexdiff.lua with -// the settings. In this order, a failure does not leave init.lua with a -// require of a file that is not there. -func uninstall(ctx context.Context, stdout io.Writer) error { - initPath := rex.ConfigPath() - if err := removeRequire(initPath); err != nil { +// the settings, and rexdiff_toggle.lua. In this order, a failure does not +// leave init.lua with a require of a file that is not there. env is the +// environment of rexdiff. +func uninstall(ctx context.Context, env, args []string, stdout, stderr io.Writer) error { + if err := parseSub("uninstall", args, env, stderr); err != nil { return err } - if err := os.Remove(luaPath()); err != nil && !errors.Is(err, os.ErrNotExist) { + initPath := rex.ConfigPath(env) + if err := removeRequire(initPath); err != nil { return err } + for _, name := range []string{luaFile, toggleFile} { + if err := os.Remove(luaPath(env, name)); err != nil && !errors.Is(err, fs.ErrNotExist) { + return err + } + } fmt.Fprintln(stdout, "removed the rexdiff action, its key, and its settings from Rex") - reload(ctx, stdout) + reload(ctx, env, stdout, stderr) return nil } @@ -375,18 +292,19 @@ func removeRequire(path string) error { if err != nil || s == "" { return err } + // Split, not Lines, because Join needs the lines without their ends. lines := slices.DeleteFunc(strings.Split(s, "\n"), isRequire) return os.WriteFile(path, []byte(strings.Join(lines, "\n")), 0o644) } -// reload tells Rex to read its configuration file again, and returns the -// report of Rex. If Rex fails, reload tells the user to reload by hand and -// returns "". -func reload(ctx context.Context, stdout io.Writer) string { - out, err := rex.ReloadConfig(ctx) - if err != nil { - fmt.Fprintf(stdout, "reload Rex's config yourself: rex config reload (%v)\n", err) - return "" +// reload tells Rex to read its configuration file again. rex runs with +// the environment env. If Rex fails, reload tells the user on stderr how +// to reload. The reload has no deadline, because the user sees it in a +// terminal and can press Ctrl-C. +func reload(ctx context.Context, env []string, stdout, stderr io.Writer) { + if err := rex.ReloadConfig(ctx, env); err != nil { + fmt.Fprintf(stderr, "reload Rex's config yourself: rex config reload (%v)\n", err) + return } - return out + fmt.Fprintln(stdout, "reloaded Rex's config") } diff --git a/cmd/rexdiff/install_test.go b/cmd/rexdiff/install_test.go deleted file mode 100644 index 264c082..0000000 --- a/cmd/rexdiff/install_test.go +++ /dev/null @@ -1,115 +0,0 @@ -package main - -import ( - "os" - "path/filepath" - "strings" - "testing" -) - -// fakeRexDir returns a directory with a rex that writes its arguments to -// log and exits 0. Set REX_BIN_DIR to it, so that no test reloads the real -// Rex. -func fakeRexDir(t *testing.T, log string) string { - t.Helper() - dir := t.TempDir() - script := "#!/bin/sh\necho \"$@\" >> " + log + "\n" - if err := os.WriteFile(filepath.Join(dir, "rex"), []byte(script), 0o755); err != nil { - t.Fatal(err) - } - return dir -} - -// TestUpgrade checks that upgrade replaces the code of rexdiff.lua if it -// differs from the code of the binary, and keeps the settings. -func TestUpgrade(t *testing.T) { - dir := t.TempDir() - reloads := filepath.Join(dir, "reloads") - t.Setenv("REX_CONFIG", filepath.Join(dir, "init.lua")) - t.Setenv("REX_BIN_DIR", fakeRexDir(t, reloads)) - lua := filepath.Join(dir, luaFile) - const bin = "/opt/rexdiff" - const settings = "local settings = { layout = \"split\" }\n\n" - write := func(content string) { - t.Helper() - if err := os.WriteFile(lua, []byte(content), 0o644); err != nil { - t.Fatal(err) - } - } - read := func() string { - t.Helper() - b, err := os.ReadFile(lua) - if err != nil { - t.Fatal(err) - } - return string(b) - } - ctx := t.Context() - - // Without rexdiff.lua, upgrade does nothing. - if n := upgrade(ctx, bin); n != "" { - t.Fatalf("upgrade without rexdiff.lua = %q, want no notice", n) - } - if _, err := os.Stat(lua); err == nil { - t.Fatal("upgrade made a rexdiff.lua") - } - - // If the file has old code for the same binary, upgrade replaces the - // code, keeps the settings, and reloads Rex. - old := strings.Replace(luaCode(bin), "local function must", "local function old_must", 1) - write(settings + old) - if n := upgrade(ctx, bin); n != "" { - t.Fatalf("upgrade = %q, want no notice", n) - } - if got := read(); got != settings+luaCode(bin) { - t.Errorf("rexdiff.lua after upgrade:\n%s\nwant the settings and the new code", got) - } - if b, _ := os.ReadFile(reloads); string(b) != "config reload\n" { - t.Errorf("rex calls = %q, want one config reload", b) - } - - // If the code is the same, upgrade does not write the file or reload Rex. - if n := upgrade(ctx, bin); n != "" { - t.Fatalf("second upgrade = %q, want no notice", n) - } - if b, _ := os.ReadFile(reloads); string(b) != "config reload\n" { - t.Errorf("rex calls = %q, want no second reload", b) - } - - // If the file runs another rexdiff, for example a build for - // development, upgrade does not change it. - other := settings + old - other = strings.Replace(other, luaQuote(bin), luaQuote("/tmp/dev/rexdiff"), 1) - write(other) - if n := upgrade(ctx, bin); n != "" || read() != other { - t.Errorf("upgrade of the file of another rexdiff = %q; want no notice, no change", n) - } - - // Without the line above the code, upgrade changes nothing. Its notice - // tells what is wrong and what to do. - noMark := strings.Replace(settings+old, codeMark+"\n", "", 1) - if !strings.Contains(noMark, luaQuote(bin)) { - t.Fatal("the test file does not run bin") - } - write(noMark) - n := upgrade(ctx, bin) - for _, want := range []string{"cannot upgrade rexdiff.lua", lua, codeMark, - "cannot tell your settings from its code", "Put that line back"} { - if !strings.Contains(n, want) { - t.Errorf("notice %q does not have %q", n, want) - } - } - if read() != noMark { - t.Error("upgrade changed rexdiff.lua without the line above the code") - } -} - -// TestLuaQuote checks that a path that Go would escape stays the same in -// Lua 5.1, which knows only a few escapes. -func TestLuaQuote(t *testing.T) { - got := luaQuote("/a\u200bb/\"x\"\\y\nz") - want := "\"/a\u200bb/\\\"x\\\"\\\\y\\nz\"" - if got != want { - t.Errorf("luaQuote = %s, want %s", got, want) - } -} diff --git a/cmd/rexdiff/main.go b/cmd/rexdiff/main.go index 7315881..8aae8ff 100644 --- a/cmd/rexdiff/main.go +++ b/cmd/rexdiff/main.go @@ -1,9 +1,9 @@ // Rexdiff shows the changes that a coding agent makes, beside the agent in // Rex. You can comment on the changed lines, then paste the comments into the // agent. "rexdiff install" adds a "Toggle rexdiff" action and a key to the Rex -// configuration, in rexdiff.lua beside Rex's init.lua. The settings of rexdiff -// are at the top of rexdiff.lua. The action gives them to rexdiff in the -// --config flag. +// configuration, in two files beside Rex's init.lua. rexdiff.lua has the +// settings of rexdiff, and rexdiff_toggle.lua has the code of the action. The +// action gives the settings to rexdiff in the --config flag. // // rexdiff [flags] [repo] // rexdiff install @@ -22,7 +22,9 @@ import ( "os/signal" "slices" "strings" + "syscall" + "github.com/rednafi/rexdiff/internal/proc" "github.com/rednafi/rexdiff/rex" "github.com/rednafi/rexdiff/tui" "github.com/rednafi/rexdiff/update" @@ -30,15 +32,42 @@ import ( ) func main() { - ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) - defer stop() - if err := run(ctx, os.Args[1:], os.Stdout, os.Stderr); err != nil { + // A closed pane sends SIGHUP. The stop of ctx lets rexdiff remove its + // files before it exits. + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM, syscall.SIGHUP) + // rexdiff reads the environment once, here. The packages get it as a + // value. + err := run(ctx, os.Environ(), os.Args[1:], os.Stdout, os.Stderr) + if err != nil && ctx.Err() != nil { + // The error of a stopped program tells only "context canceled". + // The cause names the signal. + err = context.Cause(ctx) + } + stop() + switch { + case errors.Is(err, flag.ErrHelp): + case errors.Is(err, errUsage): + os.Exit(2) // stderr already has the error + case err != nil: fmt.Fprintln(os.Stderr, "rexdiff:", err) - stop() os.Exit(1) } } +// errUsage is the error of a bad flag or argument. stderr already has the +// error and the usage. +var errUsage = errors.New("bad usage") + +// usageError returns err of flag parsing for -h, and errUsage for the +// other errors. +func usageError(err error) error { + if errors.Is(err, flag.ErrHelp) { + return err + } + return errUsage +} + +// usage is the text above the flags in the help of the diff viewer. const usage = `usage: rexdiff [flags] [repo] rexdiff install rexdiff uninstall @@ -46,8 +75,9 @@ const usage = `usage: rexdiff [flags] [repo] flags: ` -// run runs the subcommand in args, or else the diff viewer. -func run(ctx context.Context, args []string, stdout, stderr io.Writer) error { +// run runs the subcommand in args, or else the diff viewer. env is the +// environment of rexdiff, as "key=value" pairs. +func run(ctx context.Context, env, args []string, stdout, stderr io.Writer) error { if len(args) > 0 { switch args[0] { case "install": @@ -55,15 +85,12 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) error { if err != nil { return err } - return install(ctx, bin, args[1:], stdout, stderr) + return install(ctx, env, bin, args[1:], stdout, stderr) case "uninstall": - return uninstall(ctx, stdout) + return uninstall(ctx, env, args[1:], stdout, stderr) } } - f, err := parseFlags(args, stderr) - if errors.Is(err, flag.ErrHelp) { - return nil - } + f, err := parseFlags(args, env, stderr) if err != nil { return err } @@ -74,39 +101,43 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) error { if f.check { return tui.CheckConfig(f.config) } + if err := tui.CheckFlags(tui.Layout(f.layout), tui.Scope(f.scope)); err != nil { + return err + } + // The programs that rexdiff starts, such as an editor, do not get the + // variable of the resume file. + resumePath, env := takeResume(env) dir := cmp.Or(f.repo, ".") - repo, err := vcs.Open(ctx, dir) + repo, err := vcs.Open(ctx, env, dir) if err != nil { return fmt.Errorf("open %s: %w", dir, err) } + defer func() { _ = repo.Close() }() log, closeLog, err := openLog(f.logPath) if err != nil { return err } defer closeLog() - // In Rex, upgrade replaces the code of the action if it differs from - // the code of this rexdiff. - client := rex.FromEnv() + // In Rex, upgrade writes rexdiff_toggle.lua if it differs from the code + // of this rexdiff. + client := rex.New(env) var notice string bin, binErr := executable() if binErr == nil && client != nil { - notice = upgrade(ctx, bin) - } - var updater tui.Updater - if u := update.Detect(bin, version); u != nil { - updater = u // a nil *update.Updater would make a non-nil interface + notice = upgrade(ctx, env, bin) } resume, err := tui.Run(ctx, tui.Options{ Repo: repo, Rex: client, Notice: notice, - Updater: updater, - Resume: resumeFile(), + Updater: update.Detect(env, bin, version), + Resume: resumePath, + Env: env, Config: f.config, - Layout: f.layout, - Scope: f.scope, + Layout: tui.Layout(f.layout), + Scope: tui.Scope(f.scope), Agent: f.agent, Base: f.base, Wrap: f.wrap, @@ -115,14 +146,22 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) error { if err != nil || resume == "" { return err } - // After an update, run the new rexdiff in this process with the same - // flags. resumeEnv gives it the saved state. + // After an update, the new rexdiff runs in this process. closeLog() - if err = os.Setenv(resumeEnv, resume); err == nil { - err = update.Restart(bin, args) // returns only if it fails - } - fmt.Print("\x1b[?1049l\x1b[?25h") // leave the screen that Run kept, show the cursor - return fmt.Errorf("start the new rexdiff: %w; your comments are saved in %s", err, resume) + _ = repo.Close() // the new process does not run the deferred calls + return restart(bin, args, env, resume, stdout) +} + +// restart runs the new rexdiff at bin in this process after an update, +// with the same args and env. resumeEnv gives it the saved state in the +// file resume. restart returns only if it fails. +func restart(bin string, args, env []string, resume string, stdout io.Writer) error { + env = slices.Concat(env, []string{resumeEnv + "=" + resume}) + err := update.Restart(bin, args, env) // returns only if it fails + // Run keeps the alternate screen for the restart. Leave that screen and + // show the cursor. + fmt.Fprint(stdout, "\x1b[?1049l\x1b[?25h") + return fmt.Errorf("start the new rexdiff (your comments are saved in %s): %w", resume, err) } // resumeEnv is the variable that gives a restarted rexdiff the path of the @@ -130,13 +169,12 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) error { // flag, so that the arguments of the user do not change. const resumeEnv = "REXDIFF_RESUME" -// resumeFile returns the file in resumeEnv, or "", and unsets the variable, -// so that the programs that rexdiff starts, such as an editor, do not get -// it. -func resumeFile() string { - path := os.Getenv(resumeEnv) - _ = os.Unsetenv(resumeEnv) // fails only for a bad name, and resumeEnv is good - return path +// takeResume returns the file in the variable resumeEnv of env, or "". It +// also returns a copy of env without the variable. +func takeResume(env []string) (path string, rest []string) { + path = proc.Getenv(env, resumeEnv) + isResume := func(kv string) bool { return strings.HasPrefix(kv, resumeEnv+"=") } + return path, slices.DeleteFunc(slices.Clone(env), isResume) } // version is the version of rexdiff that GoReleaser sets, for example @@ -145,8 +183,8 @@ var version string // flags are the command-line arguments of the diff viewer. type flags struct { - layout tui.Layout - scope tui.Scope + layout string + scope string wrap bool base string agent string @@ -157,70 +195,56 @@ type flags struct { repo string // the repo argument, or "" } -// parseFlags parses the arguments of the diff viewer. It writes the usage -// and errors to stderr. For -h, it returns flag.ErrHelp. -func parseFlags(args []string, stderr io.Writer) (flags, error) { +// parseFlags parses the arguments of the diff viewer. The defaults of +// -agent and -log come from env. It writes the usage and the errors of the +// flags to stderr. For -h, it returns [flag.ErrHelp], and for a bad flag +// or argument errUsage. +func parseFlags(args, env []string, stderr io.Writer) (flags, error) { var f flags fs := flag.NewFlagSet("rexdiff", flag.ContinueOnError) fs.SetOutput(stderr) - fs.Func("layout", "diff layout: unified (top to bottom) or split (side by side)", - oneOf(&f.layout, tui.LayoutUnified, tui.LayoutSplit)) - fs.Func("scope", "start scope: uncommitted, branch, or last-turn", - oneOf(&f.scope, tui.ScopeUncommitted, tui.ScopeBranch, tui.ScopeLastTurn)) - fs.BoolVar(&f.wrap, "wrap", true, "wrap long lines; w toggles it") - fs.StringVar(&f.base, "base", "", - "base for the branch scope (default: the repo's default branch)") - fs.StringVar(&f.agent, "agent", os.Getenv("REX_AGENT_BLOCK"), - "the agent's Rex block (id or label) this pane was opened beside") - fs.StringVar(&f.logPath, "log", os.Getenv("REXDIFF_LOG"), - "append a debug log to this file") - fs.StringVar(&f.config, "config", "", - "the settings as JSON; the Rex action passes them from rexdiff.lua") - fs.BoolVar(&f.check, "check", false, "check the settings of -config and exit") - fs.BoolVar(&f.version, "version", false, "print the version and exit") + // tui.Run checks the layout and the scope, as in the settings. + fs.StringVar(&f.layout, "layout", "", "diff layout: unified (top to bottom) or split (side by side)") + fs.StringVar(&f.scope, "scope", "", "start scope: uncommitted, branch, or last-turn") + // As in herdr-reviewr, -wrap takes on or off, so "-wrap off" works. + wrap := fs.String("wrap", "on", "wrap of long lines: on or off (the key w toggles it)") + fs.StringVar(&f.base, "base", "", "base for the branch scope (default: the picked base, else the repo's default branch)") + fs.StringVar(&f.agent, "agent", proc.Getenv(env, "REXDIFF_AGENT"), "Rex block (ID or label) of the agent beside this pane") + fs.StringVar(&f.logPath, "log", proc.Getenv(env, "REXDIFF_LOG"), "file for a debug log") + fs.StringVar(&f.config, "config", "", "settings as JSON (the Rex action gives them from rexdiff.lua)") + fs.BoolVar(&f.check, "check", false, "settings check only: check -config, then exit") + fs.BoolVar(&f.version, "version", false, "version only: print it, then exit") fs.Usage = func() { fmt.Fprint(stderr, usage) fs.PrintDefaults() } - err := fs.Parse(args) - f.repo = fs.Arg(0) - return f, err -} - -// oneOf returns a flag function that sets *p to a value from allowed. -// Other values are an error, as in the settings. -func oneOf[T ~string](p *T, allowed ...T) func(string) error { - return func(v string) error { - if !slices.Contains(allowed, T(v)) { - return fmt.Errorf("expected one of %s", join(allowed)) - } - *p = T(v) - return nil + if err := fs.Parse(args); err != nil { + return flags{}, usageError(err) } -} - -// join returns the values separated by ", ". -func join[T ~string](values []T) string { - var b strings.Builder - for i, v := range values { - if i > 0 { - b.WriteString(", ") - } - b.WriteString(string(v)) + if fs.NArg() > 1 { + // Show the usage, as the flag package does for a bad flag. + fmt.Fprintf(stderr, "unknown argument %q (flags go before the repo)\n", fs.Arg(1)) + fs.Usage() + return flags{}, errUsage + } + if *wrap != "on" && *wrap != "off" { + return flags{}, fmt.Errorf("invalid value for -wrap: expected one of on, off, not %q", *wrap) } - return b.String() + f.wrap, f.repo = *wrap == "on", fs.Arg(0) + return f, nil } -// openLog returns a logger that appends to the file at path. If path is -// empty, the logger discards everything. -func openLog(path string) (*slog.Logger, func() error, error) { +// openLog returns a logger that appends to the file at path, and a function +// that closes the file. If path is empty, the logger discards everything. +// The log is only for debugging, so the close ignores errors. +func openLog(path string) (*slog.Logger, func(), error) { if path == "" { - return slog.New(slog.DiscardHandler), func() error { return nil }, nil + return slog.New(slog.DiscardHandler), func() {}, nil } f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) if err != nil { return nil, nil, err } h := slog.NewTextHandler(f, &slog.HandlerOptions{Level: slog.LevelDebug}) - return slog.New(h), f.Close, nil + return slog.New(h), func() { _ = f.Close() }, nil } diff --git a/cmd/rexdiff/rexdiff.lua b/cmd/rexdiff/rexdiff.lua new file mode 100644 index 0000000..d01bad6 --- /dev/null +++ b/cmd/rexdiff/rexdiff.lua @@ -0,0 +1,23 @@ +-- "rexdiff install" wrote this file. To remove it, run "rexdiff uninstall". +-- +-- These are your settings of rexdiff. For help, see the user guide: +-- https://github.com/rednafi/rexdiff/blob/master/.doc/user.md#12-change-the-settings +-- To change a setting, edit it, then run "rex config reload". +-- rexdiff does not change this file. The action is in rexdiff_toggle.lua. +local settings = { + -- key = "shift+cmd+r", -- the key of the toggle, or false for none + -- placement = "split", -- split, zoomed, tab, or overlay + -- direction = "right", -- of the split: right or down + -- layout = "unified", -- or split +} + +-- rexdiff_toggle.lua has the toggle action, and rexdiff writes it. If it +-- fails, the rest of Rex's config still loads, and the key shows why. +local ok, err = pcall(function() require("rexdiff_toggle")(settings) end) +if not ok then + rex.action{ name = "rexdiff_toggle", title = "Toggle rexdiff", + run = function() error("rexdiff: " .. tostring(err), 0) end } + local key = type(settings) == "table" and settings.key + if key == nil then key = "shift+cmd+r" end + if key then rex.bind(key, "rexdiff_toggle") end +end diff --git a/cmd/rexdiff/rexdiff_toggle.lua b/cmd/rexdiff/rexdiff_toggle.lua new file mode 100644 index 0000000..020ad5f --- /dev/null +++ b/cmd/rexdiff/rexdiff_toggle.lua @@ -0,0 +1,141 @@ +-- This is the code of the "Toggle rexdiff" action. rexdiff writes it to +-- rexdiff_toggle.lua, under the line that sets BIN, the path of rexdiff. +-- rexdiff.lua calls the function at the end with the settings. + +local PLACEMENTS = { split = true, zoomed = true, tab = true, overlay = true } +local DIRECTIONS = { right = "horizontal", down = "vertical" } +local TERM = "com.superlogical.terminal" + +-- must returns res, or fails the action with err. A Rex call returns nil +-- and an error message when it fails. +local function must(res, err) + if err ~= nil then error(err, 0) end + return res +end + +-- call calls method on the terminal block with the ID id. +local function call(method, id) + return must(rex.block.call(TERM, method, { block_id = id })) +end + +-- runs_rexdiff returns true if the terminal block id runs rexdiff. rexdiff +-- reports app=rexdiff to Rex with OSC 7501. Rex keeps an idle status after +-- its program stops. Thus the block counts only while the process that +-- reported the status is in the foreground. +local function runs_rexdiff(id) + for _, r in ipairs(call("program_status", id).records or {}) do + if r.app == "rexdiff" and r.owner then + local fg = call("process", id).foreground + return fg ~= nil and fg.pid == r.owner.pid + end + end + return false +end + +-- reviewers returns the IDs of the blocks in the session that run rexdiff. +local function reviewers() + local ids = {} + for _, b in ipairs(must(rex.session.list_blocks{}).blocks or {}) do + if b.creator_name == TERM and runs_rexdiff(b.block_id) then + table.insert(ids, b.block_id) + end + end + return ids +end + +-- check fails the action if rexdiff does not accept config, the settings +-- as a JSON object. The user sees an error of the action. If rexdiff stops +-- at once in a new pane, the pane closes and the user does not see the error. +local function check(config) + local r, err = rex.exec({ BIN, "--check", "--config", config }) + if r == nil then error(err, 0) end + if not r.ok then error((r.stderr:gsub("%s+$", "")), 0) end +end + +-- open_rexdiff opens rexdiff at placement, beside the agent in ctx, with +-- the settings in config. A split opens in direction, right or down. +-- rexdiff starts in the current directory of the agent. The rex package +-- of rexdiff finds the directory the same way. +-- rexdiff starts through the login shell of the user, so that it gets the +-- login environment of the user, for example EDITOR and PATH. +local function open_rexdiff(ctx, placement, direction, config) + local p = call("process", ctx.block_id) + local cwd = (p.foreground or {}).cwd or (p.child or {}).cwd + local node = rex.layout.block{ + flavor = TERM .. ".shell", + label = "rexdiff", + options = { + command = { BIN, "--agent", ctx.block_id, "--config", config }, + cwd = cwd, + }, + } + if placement == "tab" then + return must(rex.session.new_window{ window_label = "rexdiff", layout = node }) + elseif placement == "overlay" then + return must(rex.session.new_layer{ + bounds = { 0.04, 0.04, 0.92, 0.92 }, + layout = node, + focus = true, + }) + end + local s = must(rex.session.new_split{ + anchor_id = ctx.block_id, + direction = DIRECTIONS[direction], + side = "after", + ratio = 0.45, + focus = true, + layout = node, + }) + if placement == "zoomed" then + must(rex.session.zoom_block{ block_id = s.block_ids[1], zoom = true }) + end + return s +end + +-- The function gets the settings of rexdiff.lua, and adds the action +-- and its key. +return function(settings) + -- The key, the placement, and the direction are for Rex. rexdiff gets + -- the other settings as JSON. + if type(settings) ~= "table" then + error("config: settings must be a table, as in: local settings = { }", 0) + end + local opts = {} + for k, v in pairs(settings) do opts[k] = v end + local key = opts.key + if key == nil then key = "shift+cmd+r" end + local default_placement = opts.placement or "split" + local direction = opts.direction or "right" + if not DIRECTIONS[direction] then + error("config: unknown direction " .. tostring(direction) .. "; use right or down", 0) + end + opts.key, opts.placement, opts.direction = nil, nil, nil + local config, encode_err = rex.json.encode(opts) + + rex.action{ + name = "rexdiff_toggle", + title = "Toggle rexdiff", + description = "Review the agent's changes beside it and send comments back", + category = "rexdiff", + keywords = "diff review git comments agent", + args = { placement = "string?" }, -- split, zoomed, tab, or overlay + run = function(ctx, args) + local placement = args.placement or default_placement + if not PLACEMENTS[placement] then + error("unknown placement " .. tostring(placement) + .. "; use split, zoomed, tab, or overlay", 0) + end + local open = reviewers() + if #open == 0 then + if config == nil then error("config: " .. encode_err, 0) end + check(config) + return open_rexdiff(ctx, placement, direction, config) + end + for _, id in ipairs(open) do + must(rex.call("block.close", { session_id = ctx.session_id, block_id = id })) + end + return { closed = open } + end, + } + if key then rex.bind(key, "rexdiff_toggle") end +end diff --git a/cmd/rexdiff/script_test.go b/cmd/rexdiff/script_test.go deleted file mode 100644 index a1b924e..0000000 --- a/cmd/rexdiff/script_test.go +++ /dev/null @@ -1,78 +0,0 @@ -package main - -import ( - "fmt" - "os" - "path/filepath" - "strings" - "testing" - - "github.com/rogpeppe/go-internal/testscript" -) - -// TestMain lets the scripts run rexdiff, and a fake rex, as commands. Each -// command is a copy of the test binary that calls the function here. -func TestMain(m *testing.M) { - testscript.Main(m, map[string]func(){ - "rexdiff": main, - "rex": fakeRex, - }) -} - -// fakeRex writes its arguments to $WORK/rex.log, so that a script can see -// what rexdiff asked Rex to do. It fails if $REX_FAIL is set. -func fakeRex() { - log := filepath.Join(os.Getenv("WORK"), "rex.log") - f, err := os.OpenFile(log, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) - if err != nil { - panic(err) - } - fmt.Fprintln(f, strings.Join(os.Args[1:], " ")) - f.Close() - if os.Getenv("REX_FAIL") != "" { - fmt.Fprintln(os.Stderr, "no Rex server") - os.Exit(1) - } - fmt.Println("config reloaded") -} - -// userEnv lists the variables of the user that change what rexdiff and rex -// do. Each script starts with them empty. -var userEnv = []string{ - "REX_BIN_DIR", - "REX_CONFIG", - "REX_SESSION", - "REX_BLOCK", - "REX_AGENT_BLOCK", - "REXDIFF_LOG", - "REXDIFF_RESUME", -} - -// TestScripts runs the scripts in testdata/script. Each script runs in a -// new directory, $WORK. $WORK is also $HOME, so rexdiff does not see the -// files of the user. If the real rex tool is installed, $REALREX is its -// path, and the condition [realrex] is true. -func TestScripts(t *testing.T) { - realRex := filepath.Join(os.Getenv("REX_BIN_DIR"), "rex") - _, err := os.Stat(realRex) - hasRex := os.Getenv("REX_BIN_DIR") != "" && err == nil - testscript.Run(t, testscript.Params{ - Dir: "testdata/script", - RequireExplicitExec: true, - Setup: func(env *testscript.Env) error { - env.Setenv("HOME", env.WorkDir) - env.Setenv("XDG_CONFIG_HOME", "") - env.Setenv("REALREX", realRex) - for _, k := range userEnv { - env.Setenv(k, "") - } - return nil - }, - Condition: func(cond string) (bool, error) { - if cond == "realrex" { - return hasRex, nil - } - return false, fmt.Errorf("unknown condition %q", cond) - }, - }) -} diff --git a/cmd/rexdiff/testdata/script/errors.txt b/cmd/rexdiff/testdata/script/errors.txt deleted file mode 100644 index 9426a89..0000000 --- a/cmd/rexdiff/testdata/script/errors.txt +++ /dev/null @@ -1,32 +0,0 @@ -# rexdiff does not start when its input is wrong. Each error says what is -# wrong, and which values are allowed. - -! exec rexdiff -layout sideways -stderr 'invalid value "sideways" for flag -layout: expected one of unified, split' -! exec rexdiff -scope=week -stderr 'invalid value "week" for flag -scope: expected one of uncommitted, branch, last-turn' -! exec rexdiff -bogus -stderr 'flag provided but not defined: -bogus' - -# rexdiff needs a git repository. -! exec rexdiff $WORK -stderr '^rexdiff: open .+: git rev-parse: fatal: not a git repository' - -# -check checks the settings without a repository, for the Rex action. -# TestParseConfig has the other bad settings. -! exec rexdiff -check -config '{"layout": "sideways"}' -stderr '^rexdiff: config: invalid value for layout: expected one of unified, split, not "sideways"$' -exec rexdiff -check -config '{"layout": "split"}' -! stderr . - -# rexdiff does not start with bad settings. -exec git init -q repo -! exec rexdiff -config '{"layuot": "split"}' repo -stderr '^rexdiff: config: unknown key "layuot"$' - -# install takes no arguments. The key and the placement are settings. -! exec rexdiff install --placement=tab -stderr 'flag provided but not defined: -placement' -! exec rexdiff install extra -stderr 'unknown argument extra' -! exists .config/rex diff --git a/cmd/rexdiff/testdata/script/help.txt b/cmd/rexdiff/testdata/script/help.txt deleted file mode 100644 index 36d75f4..0000000 --- a/cmd/rexdiff/testdata/script/help.txt +++ /dev/null @@ -1,14 +0,0 @@ -# -h shows the usage and the flags, and exits 0. -exec rexdiff -h -stderr '^usage: rexdiff \[flags\] \[repo\]' -stderr '-layout value' -stderr '-config string' -! stdout . - -# -version prints the version. A build for development has none. -exec rexdiff -version -stdout '^\(devel\)$' - -# install -h writes no file. -exec rexdiff install -h -! exists .config/rex diff --git a/cmd/rexdiff/testdata/script/install.txt b/cmd/rexdiff/testdata/script/install.txt deleted file mode 100644 index 7b49654..0000000 --- a/cmd/rexdiff/testdata/script/install.txt +++ /dev/null @@ -1,94 +0,0 @@ -# install writes rexdiff.lua, with the default settings at its top, and -# adds one line to init.lua. It keeps the rest of init.lua. -mkdir .config/rex -cp mine.lua .config/rex/init.lua -exec rexdiff install -stdout 'wrote '$WORK'/.config/rex/rexdiff.lua; the settings are at its top' -stdout 'added require\("rexdiff"\) to '$WORK'/.config/rex/init.lua' -stdout 'config reloaded' -stdout 'press the key of the settings' -cmp .config/rex/init.lua installed.lua -grep '^local settings = \{$' .config/rex/rexdiff.lua -grep '^-- rexdiff code below. Install and upgrades replace it. Do not edit this line.$' .config/rex/rexdiff.lua - -# The action starts rexdiff through the shell of the user, so that -# rexdiff gets EDITOR, VISUAL, and PATH. With shell "none", it gets only -# the environment of the Rex server. -! grep 'shell = ' .config/rex/rexdiff.lua - -# Rex accepts the action and its key. -[realrex] exec $REALREX config check .config/rex/init.lua -[realrex] stdout 'bindings: 2' -[realrex] stdout 'actions: 1' -[realrex] ! stderr . - -# A second install keeps the settings and the one line of init.lua, and -# replaces the code. -exec sh -c 'sed "s/^local settings = {$/local settings = { key = \"ctrl+cmd+d\", layout = \"split\",/" .config/rex/rexdiff.lua > edited.lua' -exec sh -c 'sed "s/^local function must/local function old_must/" edited.lua > .config/rex/rexdiff.lua' -exec rexdiff install -cmp .config/rex/init.lua installed.lua -grep 'local settings = \{ key = "ctrl\+cmd\+d", layout = "split",' .config/rex/rexdiff.lua -! grep 'old_must' .config/rex/rexdiff.lua -cmp rex.log reloads.log - -# Rex binds the key of the settings. -[realrex] exec $REALREX config check .config/rex/init.lua -[realrex] stdout 'bindings: 2' -[realrex] ! stderr . - -# Without the line above the code, install cannot tell the settings from -# the code. It says so, and changes no file. -exec sh -c 'grep -v "^-- rexdiff code below" .config/rex/rexdiff.lua > nomark.lua' -cp nomark.lua .config/rex/rexdiff.lua -! exec rexdiff install -stderr 'rexdiff.lua has no line "-- rexdiff code below. Install and upgrades replace it. Do not edit this line.", so rexdiff cannot tell your settings from its code. Put that line back under your settings, or move the file away and run "rexdiff install" again' -cmp .config/rex/rexdiff.lua nomark.lua -cmp .config/rex/init.lua installed.lua - -# If uninstall cannot change init.lua, it keeps rexdiff.lua, so that -# init.lua does not require a missing file. -chmod 0444 .config/rex/init.lua -! exec rexdiff uninstall -exists .config/rex/rexdiff.lua -chmod 0644 .config/rex/init.lua - -# uninstall removes the line and rexdiff.lua with the settings, and keeps -# the rest. -exec rexdiff uninstall -stdout 'removed the rexdiff action, its key, and its settings from Rex' -cmp .config/rex/init.lua mine.lua -! exists .config/rex/rexdiff.lua - -# If Rex does not reload, install still writes the files and says what to do. -env REX_FAIL=1 -exec rexdiff install -stdout 'reload Rex''s config yourself: rex config reload' -exists .config/rex/rexdiff.lua - -# REX_CONFIG sets the path of the configuration file of Rex. -env REX_CONFIG=$WORK/other/init.lua -exec rexdiff install -exists other/rexdiff.lua -grep 'require\("rexdiff"\)' other/init.lua - -# On macOS, the action runs a symlinked rexdiff through the link, so that -# it still works after brew upgrade. -[darwin] exec sh -c 'mkdir brew && ln -s "$(command -v rexdiff)" brew/rexdiff' -[darwin] exec $WORK/brew/rexdiff install -[darwin] grep 'local BIN = "'$WORK'/brew/rexdiff"' other/rexdiff.lua - -# uninstall without an install changes nothing and does not fail. -env REX_CONFIG=$WORK/none/init.lua -exec rexdiff uninstall -stdout 'removed the rexdiff action, its key, and its settings from Rex' -! exists none - --- mine.lua -- -rex.bind("cmd+e", "pane.zoom") --- installed.lua -- -rex.bind("cmd+e", "pane.zoom") -require("rexdiff") --- reloads.log -- -config reload -config reload diff --git a/dev.sh b/dev.sh index 0104105..a613b73 100755 --- a/dev.sh +++ b/dev.sh @@ -5,7 +5,7 @@ set -e BIN=/tmp/rexdiff-dev/rexdiff cd "$(dirname "$0")" && go build -o $BIN ./cmd/rexdiff -export PATH="$REX_BIN_DIR:$PATH" +export PATH="${REX_BIN_DIR:+$REX_BIN_DIR:}$PATH" S=rexreview-test; R=${REXDIFF_DEMO:-/tmp/rexreview-demo} has() { rex block inspect "$1" -s $S --json >/dev/null 2>&1; } rex ls | grep -q "^$S " || rex new $S --cwd $R -- fish >/dev/null diff --git a/diff/cell.go b/diff/cell.go new file mode 100644 index 0000000..710f9e1 --- /dev/null +++ b/diff/cell.go @@ -0,0 +1,120 @@ +package diff + +import ( + "strings" + "unicode/utf8" + + "github.com/charmbracelet/x/ansi" + + "github.com/rednafi/rexdiff/internal/diffview" +) + +// Cell tells which source character the cell at column x of line row +// shows. Column 0 is the first column of the line. It returns the side, +// the line number from 1, and the index of the rune in [Lines] of that +// side. The index can be past the end of the line, for a cell after the +// code. ok is false if the cell is not on code, for example on a line +// number, a mark, or a hunk header. +func (d *Diff) Cell(row, x int) (s Side, line, index int, ok bool) { + if row < 0 || row >= len(d.Rows) || d.Rows[row].Kind == KindHunk { + return 0, 0, 0, false + } + r := d.Rows[row] + s, onNumber := d.SideAt(x) + if !d.split { + s = SideNew + if r.New == 0 { + s = SideOld + } + } + line = r.Line(s) + if onNumber || line == 0 || x < d.codeAt[s] { + return 0, 0, 0, false + } + text, ok := lineAt(d.src[s], line) + if !ok { + return 0, 0, 0, false + } + col := x - d.codeAt[s] + d.offset + if d.wrap { + start, width := d.segmentStart(row, s, text) + if col >= width { // the blank cells after a wrapped part + return s, line, utf8.RuneCountInString(text), true + } + col += start + } + return s, line, runeAt(text, col), true +} + +// segmentStart returns the column in the source line text where the +// screen line row starts on side s, and the width of its code. A wrapped +// line can drop the spaces where it breaks. Thus it finds each screen line +// in the text, in order. +func (d *Diff) segmentStart(row int, s Side, text string) (start, width int) { + first := row + for first > 0 && d.Rows[first].Continued { + first-- + } + shown := expand(text) + at := 0 // the byte in shown after the last screen line + for i := first; i <= row; i++ { + seg := d.segment(i, s) + k := strings.Index(shown[at:], seg) + if k < 0 { + break + } + if i == row { + return ansi.StringWidth(shown[:at+k]), ansi.StringWidth(seg) + } + at += k + len(seg) + } + return ansi.StringWidth(shown[:at]), 0 +} + +// segment returns the code of side s on screen line i, without the +// spaces at the end. +func (d *Diff) segment(i int, s Side) string { + plain := ansi.Strip(d.Lines[i]) + end := ansi.StringWidth(plain) + if d.split && s == SideOld { + end = d.newNumAt + } + return strings.TrimRight(ansi.Cut(plain, d.codeAt[s], end), " ") +} + +// expand returns line as the screen shows it, with the tabs as spaces and +// the control characters as symbols. +func expand(line string) string { + return diffview.Escape(strings.ReplaceAll(line, "\t", strings.Repeat(" ", diffview.TabWidth))) +} + +// runeAt returns the index of the first rune of the grapheme cluster of +// line that covers column col. It returns the number of runes if col is +// past the end. +func runeAt(line string, col int) int { + i, c := 0, 0 + for line != "" { + g, _ := ansi.FirstGraphemeCluster(line, ansi.GraphemeWidth) + w := diffview.TabWidth + if g != "\t" { + w = ansi.StringWidth(diffview.Escape(g)) + } + if col < c+w { + return i + } + c += w + i += utf8.RuneCountInString(g) + line = line[len(g):] + } + return i +} + +// lineAt returns line n of src, from 1, as [Lines] splits it. +func lineAt(src string, n int) (string, bool) { + for l := range strings.Lines(src) { + if n--; n == 0 { + return trimEOL(l), true + } + } + return "", false +} diff --git a/diff/cell_test.go b/diff/cell_test.go new file mode 100644 index 0000000..a041ed4 --- /dev/null +++ b/diff/cell_test.go @@ -0,0 +1,230 @@ +package diff_test + +import ( + "strings" + "testing" + + "github.com/charmbracelet/x/ansi" + + "github.com/rednafi/rexdiff/diff" +) + +// screenRune returns the rune of the plain line that covers column x. +func screenRune(plain string, x int) (rune, bool) { + c := 0 + for _, r := range plain { + w := ansi.StringWidth(string(r)) + if x < c+w { + return r, true + } + c += w + } + return 0, false +} + +// plainLine returns line i of d without the escapes. It stops the test if d +// has no line i. +func plainLine(t *testing.T, d *diff.Diff, i int) string { + t.Helper() + if i < 0 || i >= len(d.Lines) { + t.Fatalf("line %d of a diff with %d lines", i, len(d.Lines)) + } + return ansi.Strip(d.Lines[i]) +} + +// column returns the column of the first cell of needle in line i of d. +// With last, it finds the last needle. +func column(t *testing.T, d *diff.Diff, i int, needle string, last bool) int { + t.Helper() + plain := plainLine(t, d, i) + k := strings.Index(plain, needle) + if last { + k = strings.LastIndex(plain, needle) + } + if k < 0 { + t.Fatalf("line %d = %q, want %q in it", i, plain, needle) + } + return ansi.StringWidth(plain[:k]) +} + +// TestCell checks the source character of a cell, with tabs, an offset, +// the split layout, and a file. +func TestCell(t *testing.T) { + t.Parallel() + const before, after = "a\n\told_name := 1\n", "a\n\tnew_name := 1\n" + u := diff.Render("x.go", before, after, diff.Options{Width: 80}) + off := diff.Render("x.go", before, after, diff.Options{Width: 80, Offset: 6}) + s := diff.Render("x.go", before, after, diff.Options{Width: 80, Split: true}) + f := diff.RenderFile("x.go", after, diff.Options{Width: 80}) + tests := []struct { + name string + d *diff.Diff + row int + needle string + last bool + side diff.Side + line int + char int + }{ + {"unified delete", u, 2, "old_name", false, diff.SideOld, 2, 1}, + {"unified insert", u, 3, "name", false, diff.SideNew, 2, 5}, + {"unified equal", u, 1, "a", false, diff.SideNew, 1, 0}, + {"offset", off, 3, "e :=", false, diff.SideNew, 2, 8}, + {"split old", s, 2, "old_name", false, diff.SideOld, 2, 1}, + {"split new", s, 2, "name", true, diff.SideNew, 2, 5}, + {"file", f, 1, "new_name", false, diff.SideNew, 2, 1}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + x := column(t, tt.d, tt.row, tt.needle, tt.last) + side, line, char, ok := tt.d.Cell(tt.row, x) + if !ok || side != tt.side || line != tt.line || char != tt.char { + t.Errorf("Cell(%d, %d) = %v, %d, %d, %v, want %v, %d, %d, true", + tt.row, x, side, line, char, ok, + tt.side, tt.line, tt.char) + } + }) + } + // A cell of the tab gives the tab. A cell after the code gives the end. + x := column(t, u, 3, "new", false) + if _, _, char, _ := u.Cell(3, x-1); char != 0 { + t.Errorf("the tab: char %d, want 0", char) + } + if _, _, char, ok := u.Cell(3, 79); !ok || char != 14 { + t.Errorf("after the code: char %d, %v, want 14, true", char, ok) + } + // The numbers, the mark, and the hunk header are not code. + for _, c := range []struct{ row, x int }{{3, 0}, {3, 4}, {3, x - 5}, {0, 20}, {9, 20}} { + if _, _, _, ok := u.Cell(c.row, c.x); ok { + t.Errorf("Cell(%d, %d) is code, want not code", c.row, c.x) + } + } + // The other side of a split insert has no line. + ins := diff.Render("x.go", "a\n", "a\nb\n", diff.Options{Width: 80, Split: true}) + if _, _, _, ok := ins.Cell(2, 10); ok { + t.Error("the empty old side of an insert is code, want not code") + } +} + +// TestCellMatchesScreen checks that each code cell shows the character +// that Cell gives, in each layout, with wrap, and with an offset. +func TestCellMatchesScreen(t *testing.T) { + t.Parallel() + var words []string + for i := range 30 { + words = append(words, "w"+string(rune('a'+i%26))+strings.Repeat("x", i%4)) + } + long := "\t" + strings.Join(words, " ") + " 日本 end" + before, after := "a\nb\n", "a\n"+long+"\nb\n" + opts := map[string]diff.Options{ + "cut": {Width: 60}, + "offset": {Width: 60, Offset: 13}, + "wrap": {Width: 60, Wrap: true}, + } + src := map[diff.Side][]string{diff.SideOld: diff.Lines(before), diff.SideNew: diff.Lines(after)} + for _, tt := range renderers(before, after) { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + for on, o := range opts { + checkCells(t, on, tt.render(o), src) + } + }) + } +} + +// checkCells checks that each code cell of d shows the character that +// Cell gives. The lines of src are the source of each side. +func checkCells(t *testing.T, on string, d *diff.Diff, src map[diff.Side][]string) { + t.Helper() + checked := 0 + for i, l := range d.Lines { + plain := ansi.Strip(l) + for x := range ansi.StringWidth(plain) { + side, line, char, ok := d.Cell(i, x) + if !ok { + continue + } + got, _ := screenRune(plain, x) + text := []rune(src[side][line-1]) + if char >= len(text) { + if got != ' ' { + t.Errorf("%s: line %d col %d shows %q, want past the end", + on, i, x, got) + } + continue + } + want := text[char] + if want == '\t' { + want = ' ' + } + if got != want && got != '…' { + t.Errorf("%s: line %d col %d shows %q, want %q from Cell", + on, i, x, got, want) + } + checked++ + } + } + if checked < 20 { + t.Errorf("%s: %d code cells, want 20 or more", on, checked) + } +} + +// TestCellGraphemes checks Cell after grapheme clusters of more than one +// code point, in a cut line and in a wrapped line. +func TestCellGraphemes(t *testing.T) { + t.Parallel() + for _, g := range []string{"❤️", "👨‍👩‍👧", "🇺🇸"} { + line := strings.Repeat(g+"x ", 20) + src := []rune(line) + for _, o := range []diff.Options{{Width: 200}, {Width: 40, Wrap: true}} { + d := diff.RenderFile("x.txt", line+"\n", o) + found := 0 + for i, l := range d.Lines { + plain := ansi.Strip(l) + for k := range len(plain) { + if plain[k] != 'x' { + continue + } + x := ansi.StringWidth(plain[:k]) + _, _, char, ok := d.Cell(i, x) + if !ok || char >= len(src) || src[char] != 'x' { + t.Errorf("%q wrap %v: Cell(%d, %d) = %d, %v, want the index of an x", + g, o.Wrap, i, x, char, ok) + } + found++ + } + } + if found != 20 { + t.Errorf("%q wrap %v: %d x cells, want 20", g, o.Wrap, found) + } + } + } +} + +// TestCellOffsetInWideChar checks Cell when the offset cuts a wide +// character. The content moves by the offset, so Cell agrees with the +// screen. +func TestCellOffsetInWideChar(t *testing.T) { + t.Parallel() + const line = "日本x" + for _, off := range []int{1, 3} { + o := diff.Options{Width: 40, Offset: off} + for name, d := range map[string]*diff.Diff{ + "diff": diff.Render("x.txt", "", line+"\n", o), + "file": diff.RenderFile("x.txt", line+"\n", o), + } { + row := len(d.Rows) - 1 + for char, needle := range []rune(line) { + if !strings.ContainsRune(plainLine(t, d, row), needle) { + continue + } + x := column(t, d, row, string(needle), false) + if _, _, got, ok := d.Cell(row, x); !ok || got != char { + t.Errorf("%s offset %d: Cell on %q = %d, %v, want %d, true", + name, off, needle, got, ok, char) + } + } + } + } +} diff --git a/diff/comment.go b/diff/comment.go index c2786a7..3b4e2cc 100644 --- a/diff/comment.go +++ b/diff/comment.go @@ -2,40 +2,57 @@ package diff import ( "cmp" + "encoding/json/jsontext" "encoding/json/v2" "fmt" "slices" "strings" + + "github.com/rednafi/rexdiff/internal/termtext" ) // Comment is a review note on a range of lines on one side of a file. Use -// [NewComment] to make one. A comment keeps the text of its lines, for -// [Comments.Relocate], and its diff rows, for [Comments.Review]. +// [NewComment] to make one. A comment keeps the text of its lines and its +// diff rows, for [Comments.Review]. As in herdr-reviewr, a comment does not +// move when its file changes. It stays on its line numbers. type Comment struct { - ID int // set by [Comments.Add] + ID int // [Comments.Add] sets it Path string Side Side // Start and End are the first and the last line of the range. They // start at 1. Start, End int Text string - // Stale is true if Relocate cannot find the lines of the comment. - Stale bool + // Anchor tells the view that the comment shows in. + Anchor Anchor code []string // the text of the lines Start to End snippet []string // the rows as unified diff lines, or empty to use code + // noAnchor is true for a comment from a file with no anchors. + noAnchor bool +} + +// Anchor is the view that a comment is made in. A comment shows only in a +// view with the same anchor. +type Anchor struct { + // InDiff is true for a comment on a diff, and false for a comment on + // a file view. + InDiff bool + // Rev names the commits of the diff, or is "" for the working tree. + Rev string } // NewComment returns a comment with no text on side s of the file at path. -// Rows are rows of the [Diff] of old and new. Start and End are the first and -// the last line of the rows on side s. They are 0 if no row has a line on s. +// Rows are rows of the [Diff] of before and after. Start and End are the +// first and the last line of the rows on side s. They are 0 if no row has +// a line on s. // // In the diff rows of the comment, equal, deleted, and inserted lines start -// with " ", "-", and "+". The text comes from old and new, so tabs stay. In -// each block of changes, deleted lines come before inserted lines, also in -// the split layout. Continued rows add nothing. -func NewComment(path string, s Side, rows []Row, old, new string) Comment { - c := Comment{Path: path, Side: s, snippet: snippet(rows, old, new)} +// with " ", "-", and "+". The text comes from before and after, so tabs +// stay. In each block of changes, deleted lines come before inserted +// lines, also in the split layout. Continued rows add nothing. +func NewComment(path string, s Side, rows []Row, before, after string) Comment { + c := Comment{Path: path, Side: s, snippet: snippet(rows, before, after)} for _, r := range rows { n := r.Line(s) if n == 0 { @@ -49,16 +66,16 @@ func NewComment(path string, s Side, rows []Row, old, new string) Comment { if c.Start == 0 { return c } - lines := Lines(version(s, old, new)) + lines := Lines(version(s, before, after)) c.code = slices.Clone(lines[min(c.Start-1, len(lines)):min(c.End, len(lines))]) return c } // Location returns the path and the lines of c, for example "a.go:3-4". -// A comment on the old side has " (removed)" at the end. The path is -// quoted with [Quote]. +// A comment on the old side has " (removed)" at the end. [termtext.Quote] +// quotes the path. func (c *Comment) Location() string { - loc := fmt.Sprintf("%s:%d", Quote(c.Path), c.Start) + loc := fmt.Sprintf("%s:%d", termtext.Quote(c.Path), c.Start) if c.End != c.Start { loc += fmt.Sprintf("-%d", c.End) } @@ -78,12 +95,11 @@ type Comments struct { // Len returns the number of comments. func (cs *Comments) Len() int { return len(cs.items) } -// Add adds a copy of c with a new ID, and returns the copy. -func (cs *Comments) Add(c Comment) *Comment { +// Add adds c with a new ID. +func (cs *Comments) Add(c Comment) { cs.last++ c.ID = cs.last cs.items = append(cs.items, &c) - return &c } // Remove removes the comment with the given ID, if there is one. @@ -93,129 +109,85 @@ func (cs *Comments) Remove(id int) { // All returns the comments, sorted by path and then by start line. func (cs *Comments) All() []*Comment { - all := slices.Clone(cs.items) - slices.SortStableFunc(all, func(a, b *Comment) int { + return slices.SortedStableFunc(slices.Values(cs.items), func(a, b *Comment) int { return cmp.Or(strings.Compare(a.Path, b.Path), cmp.Compare(a.Start, b.Start)) }) - return all } -// Find returns the comment with the given ID, or nil. -func (cs *Comments) Find(id int) *Comment { +// SetOldRev sets the Rev of each comment that [Comments.UnmarshalJSON] +// reads from the file of an older rexdiff. Such a file has no anchors, and +// its comments are on the diff of the scope that the file saves. +func (cs *Comments) SetOldRev(rev string) { for _, c := range cs.items { - if c.ID == id { - return c + if c.noAnchor { + c.Anchor.Rev, c.noAnchor = rev, false } } +} + +// Find returns the comment with the given ID, or nil. +func (cs *Comments) Find(id int) *Comment { + if i := slices.IndexFunc(cs.items, func(c *Comment) bool { return c.ID == id }); i >= 0 { + return cs.items[i] + } return nil } -// At returns the first comment whose range includes line, or nil. -func (cs *Comments) At(path string, side Side, line int) *Comment { +// At returns the first comment with anchor a whose range includes line, or +// nil. +func (cs *Comments) At(a Anchor, path string, s Side, line int) *Comment { for _, c := range cs.items { - if c.Path == path && c.Side == side && line >= c.Start && line <= c.End { + if c.Anchor == a && c.Path == path && c.Side == s && + line >= c.Start && line <= c.End { return c } } return nil } -// EndingAt returns the comments whose range ends on line. -func (cs *Comments) EndingAt(path string, side Side, line int) []*Comment { +// EndingAt returns the comments with anchor a whose range ends on line. +func (cs *Comments) EndingAt(a Anchor, path string, s Side, line int) []*Comment { var ending []*Comment for _, c := range cs.items { - if c.Path == path && c.Side == side && c.End == line { + if c.Anchor == a && c.Path == path && c.Side == s && c.End == line { ending = append(ending, c) } } return ending } -// Count returns the number of comments on the file at path. -func (cs *Comments) Count(path string) int { +// Count returns the number of comments with anchor a on the file at path. +func (cs *Comments) Count(a Anchor, path string) int { n := 0 for _, c := range cs.items { - if c.Path == path { + if c.Anchor == a && c.Path == path { n++ } } return n } -// Relocate moves each comment on path to the lines that have its text. -// Old and new are the two versions of the file. A comment on the [SideOld] -// side looks in old, and a comment on the [SideNew] side looks in new. If -// the text is at more than one location, the comment moves to the nearest -// one. If the version does not have the text, the comment stays and -// becomes stale. -func (cs *Comments) Relocate(path, old, new string) { - // The lines of each side. Relocate splits a side only if a comment - // needs it. - var lines [2][]string - for _, c := range cs.items { - if c.Path != path || len(c.code) == 0 { - continue - } - if lines[c.Side] == nil { - lines[c.Side] = Lines(version(c.Side, old, new)) - } - c.relocate(lines[c.Side]) - } -} - -// version returns old for the [SideOld] side, and new for the [SideNew] side. -func version(s Side, old, new string) string { +// version returns before for the [SideOld] side, and after for the +// [SideNew] side. +func version(s Side, before, after string) string { if s == SideOld { - return old - } - return new -} - -// relocate moves c to the location of its code in lines that is nearest to -// Start. If lines does not have the code, c becomes stale. -func (c *Comment) relocate(lines []string) { - if matchAt(lines, c.code, c.Start-1) { - c.Stale = false - return - } - best, bestDist := -1, 0 - for i := range len(lines) - len(c.code) + 1 { - d := abs(i - (c.Start - 1)) - nearer := best < 0 || d < bestDist - if nearer && matchAt(lines, c.code, i) { - best, bestDist = i, d - } - } - if best < 0 { - c.Stale = true - return - } - n := c.End - c.Start - c.Start, c.End, c.Stale = best+1, best+1+n, false -} - -// matchAt reports whether lines has want at index at. -func matchAt(lines, want []string, at int) bool { - end := at + len(want) - return at >= 0 && end <= len(lines) && slices.Equal(lines[at:end], want) -} - -func abs(x int) int { - if x < 0 { - return -x + return before } - return x + return after } // savedComment is a [Comment] with its private fields, for MarshalJSON. type savedComment struct { - ID int `json:"id"` - Path string `json:"path"` - Side Side `json:"side"` - Start int `json:"start"` - End int `json:"end"` - Text string `json:"text"` - Stale bool `json:"stale"` + ID int `json:"id"` + Path string `json:"path"` + Side Side `json:"side"` + Start int `json:"start"` + End int `json:"end"` + Text string `json:"text"` + // InDiff is nil in a file of an older rexdiff. Most comments of + // such a file are on a diff. + InDiff *bool `json:"in_diff"` + Rev string `json:"rev"` Code []string `json:"code"` Snippet []string `json:"snippet"` } @@ -228,16 +200,18 @@ type savedComments struct { // MarshalJSON encodes the comments with their code and their diff rows, // so that UnmarshalJSON restores the same comments, for example after a -// restart. +// restart. Bytes that are not UTF-8, for example in a Latin-1 file, +// become U+FFFD. func (cs *Comments) MarshalJSON() ([]byte, error) { saved := savedComments{Last: cs.last} for _, c := range cs.items { saved.Items = append(saved.Items, savedComment{ ID: c.ID, Path: c.Path, Side: c.Side, Start: c.Start, End: c.End, - Text: c.Text, Stale: c.Stale, Code: c.code, Snippet: c.snippet, + Text: c.Text, InDiff: &c.Anchor.InDiff, Rev: c.Anchor.Rev, + Code: c.code, Snippet: c.snippet, }) } - return json.Marshal(saved) + return json.Marshal(saved, jsontext.AllowInvalidUTF8(true)) } // UnmarshalJSON reads comments that MarshalJSON wrote. @@ -248,9 +222,11 @@ func (cs *Comments) UnmarshalJSON(b []byte) error { } *cs = Comments{last: saved.Last} for _, c := range saved.Items { + inDiff := c.InDiff == nil || *c.InDiff cs.items = append(cs.items, &Comment{ ID: c.ID, Path: c.Path, Side: c.Side, Start: c.Start, End: c.End, - Text: c.Text, Stale: c.Stale, code: c.Code, snippet: c.Snippet, + Text: c.Text, Anchor: Anchor{InDiff: inDiff, Rev: c.Rev}, + code: c.Code, snippet: c.Snippet, noAnchor: c.InDiff == nil, }) } return nil diff --git a/diff/comment_test.go b/diff/comment_test.go index b47002e..73d3752 100644 --- a/diff/comment_test.go +++ b/diff/comment_test.go @@ -1,7 +1,8 @@ package diff_test import ( - "os" + "encoding/json/v2" + "errors" "os/exec" "path/filepath" "slices" @@ -9,13 +10,15 @@ import ( "testing" "github.com/rednafi/rexdiff/diff" + "github.com/rednafi/rexdiff/internal/proc" + "github.com/rednafi/rexdiff/internal/testgit" ) -// comment returns a comment with text on all rows of the diff of old and -// new. -func comment(path string, s diff.Side, old, new, text string) diff.Comment { - rows := diff.Render(path, old, new, diff.Options{}).Rows - c := diff.NewComment(path, s, rows, old, new) +// comment returns a comment with text on all rows of the diff of before +// and after. +func comment(path string, s diff.Side, before, after, text string) diff.Comment { + rows := diff.Render(path, before, after, diff.Options{}).Rows + c := diff.NewComment(path, s, rows, before, after) c.Text = text return c } @@ -34,7 +37,10 @@ func review(c diff.Comment) string { return cs.Review("") } -func TestReviewMatchesHerdr(t *testing.T) { +// TestReviewMatchesHerdrReviewr checks that Review writes the text format +// of herdr-reviewr. +func TestReviewMatchesHerdrReviewr(t *testing.T) { + t.Parallel() var cs diff.Comments cs.Add(comment("c.go", diff.SideNew, "ctx\n\told\x1b[31m\n", @@ -47,83 +53,104 @@ func TestReviewMatchesHerdr(t *testing.T) { "x := 1\ny := 2\n", "merge these\n\n \nplease ", )) - cs.Relocate("c.go", "", "other\n") // makes the c.go comment stale got := cs.Review("Fix each comment.\r\n") want := "Fix each comment.\n\n" + "a.go:1-2\n+x := 1\n+y := 2\nmerge these\nplease\n\n" + "b.go:1 (removed)\n-gone()\nwhy?\n\n" + - "c.go:1-2 (stale: the code has changed since)\n" + + "c.go:1-2\n" + " ctx\n-\told[31m\n+\tnew\nfix]7501;state=idle this" if got != want { - t.Errorf("Review:\n%s\nwant:\n%s", got, want) + t.Errorf("Review = \n%s\nwant:\n%s", got, want) } if got := new(diff.Comments).Review(""); got != "" { - t.Errorf("Review of nothing = %q", got) + t.Errorf("Review with no comments = %q, want \"\"", got) } } -// TestReviewContinuedRows checks a comment on only continued rows. The -// comment has no diff lines, so Review writes the lines of its range. +// TestReviewContinuedRows checks a comment on only continued rows. func TestReviewContinuedRows(t *testing.T) { - old, new := "", strings.Repeat("word ", 20)+"\n" - d := diff.Render("a.go", old, new, diff.Options{Width: 30, Wrap: true}) + t.Parallel() + before, after := "", strings.Repeat("word ", 20)+"\n" + d := diff.Render("a.go", before, after, diff.Options{Width: 30, Wrap: true}) var cont []diff.Row for _, r := range d.Rows { if r.Continued { cont = append(cont, r) } } - c := diff.NewComment("a.go", diff.SideNew, cont, old, new) + c := diff.NewComment("a.go", diff.SideNew, cont, before, after) c.Text = "long" - want := "a.go:1\n+" + strings.TrimSuffix(new, "\n") + "\nlong" + // The comment has no diff lines, so Review writes the lines of its range. + want := "a.go:1\n+" + strings.TrimSuffix(after, "\n") + "\nlong" if got := review(c); len(cont) == 0 || got != want { - t.Errorf("%d continued rows, Review:\n%s\nwant:\n%s", len(cont), got, want) + t.Errorf("%d continued rows, Review = \n%s\nwant:\n%s", + len(cont), got, want) } } +// TestComments checks Add, Find, At, EndingAt, Count, and Remove. func TestComments(t *testing.T) { + t.Parallel() var cs diff.Comments - a := cs.Add(diff.Comment{Path: "a.go", Side: diff.SideNew, Start: 2, End: 4}) - b := cs.Add(diff.Comment{Path: "a.go", Side: diff.SideOld, Start: 4, End: 4}) - cs.Add(diff.Comment{Path: "b.go", Side: diff.SideNew, Start: 1, End: 1}) - if cs.Len() != 3 || cs.Count("a.go") != 2 || a.ID == b.ID { - t.Fatalf("Len = %d, Count(a.go) = %d, IDs %d %d", - cs.Len(), cs.Count("a.go"), a.ID, b.ID) + d := diff.Anchor{InDiff: true} + cs.Add(diff.Comment{Path: "a.go", Side: diff.SideNew, Start: 2, End: 4, Anchor: d}) + cs.Add(diff.Comment{Path: "a.go", Side: diff.SideOld, Start: 4, End: 4, Anchor: d}) + cs.Add(diff.Comment{Path: "b.go", Side: diff.SideNew, Start: 1, End: 1, Anchor: d}) + a, b := cs.All()[0], cs.All()[1] + if cs.Len() != 3 || cs.Count(d, "a.go") != 2 || a.ID == b.ID { + t.Fatalf("Len = %d, Count(a.go) = %d, IDs %d %d; "+ + "want 3, 2, two different IDs", + cs.Len(), cs.Count(d, "a.go"), a.ID, b.ID) } - if c := cs.At("a.go", diff.SideNew, 3); c != a { - t.Errorf("At(a.go, New, 3) = %v", c) + if c := cs.At(d, "a.go", diff.SideNew, 3); c != a { + t.Errorf("At(a.go, New, 3) = %v, want %v", c, a) } - if c := cs.At("a.go", diff.SideNew, 5); c != nil { + if c := cs.At(d, "a.go", diff.SideNew, 5); c != nil { t.Errorf("At(a.go, New, 5) = %v, want nil", c) } - if got := cs.EndingAt("a.go", diff.SideOld, 4); len(got) != 1 || got[0] != b { - t.Errorf("EndingAt(a.go, Old, 4) = %v", got) + if got := cs.EndingAt(d, "a.go", diff.SideOld, 4); len(got) != 1 || got[0] != b { + t.Errorf("EndingAt(a.go, Old, 4) = %v, want [%v]", got, b) + } + // A comment shows only in its kind of view, and at its revision. + for _, other := range []diff.Anchor{{}, {InDiff: true, Rev: "a..b"}} { + if c := cs.At(other, "a.go", diff.SideNew, 3); c != nil { + t.Errorf("At(%+v, a.go, New, 3) = %v, want nil", other, c) + } + if got := cs.EndingAt(other, "a.go", diff.SideOld, 4); got != nil { + t.Errorf("EndingAt(%+v, a.go, Old, 4) = %v, want nil", other, got) + } + if n := cs.Count(other, "a.go"); n != 0 { + t.Errorf("Count(%+v, a.go) = %d, want 0", other, n) + } } if cs.Find(b.ID) != b { - t.Error("Find did not find b") + t.Errorf("Find(%d) = %v, want %v", b.ID, cs.Find(b.ID), b) } cs.Remove(b.ID) if cs.Find(b.ID) != nil || cs.Len() != 2 { - t.Error("Remove did not remove b") + t.Errorf("after Remove(%d): Find = %v, Len = %d, want nil, 2", + b.ID, cs.Find(b.ID), cs.Len()) } - if c := cs.Add(diff.Comment{}); c.ID == a.ID || c.ID == b.ID { - t.Error("Add reused an ID") + cs.Add(diff.Comment{Path: "c.go"}) + if c := cs.All()[2]; c.ID == a.ID || c.ID == b.ID { + t.Errorf("Add gives ID %d, want an ID other than %d and %d", + c.ID, a.ID, b.ID) } } // TestNewComment checks comments from the rows of a change, in the two // layouts, on each side. func TestNewComment(t *testing.T) { - old := "a\nb\nc\nd\n" - new := "a\nB\nC\nd\n" - moved := "x\n" // a line above the comment moves it down by one + t.Parallel() + before := "a\nb\nc\nd\n" + after := "a\nB\nC\nd\n" for _, split := range []bool{false, true} { - rows := diff.Render("x.txt", old, new, diff.Options{Split: split}).Rows + rows := diff.Render("x.txt", before, after, diff.Options{Split: split}).Rows // The rows from "a" to "d". lo := slices.IndexFunc(rows, isEqual) for _, s := range []diff.Side{diff.SideNew, diff.SideOld} { - c := diff.NewComment("x.txt", s, rows[lo:], old, new) + c := diff.NewComment("x.txt", s, rows[lo:], before, after) if c.Path != "x.txt" || c.Side != s || c.Start != 1 || c.End != 4 { t.Errorf("split %v, side %d: %+v, want lines 1-4", split, s, c) } @@ -133,17 +160,9 @@ func TestNewComment(t *testing.T) { } want += "\n a\n-b\n-c\n+B\n+C\n d\n" if got := review(c); got != want { - t.Errorf("split %v, side %d: Review:\n%s\nwant:\n%s", + t.Errorf("split %v, side %d: Review = \n%s\nwant:\n%s", split, s, got, want) } - // The comment follows the lines of its side. - var cs diff.Comments - got := cs.Add(c) - cs.Relocate("x.txt", moved+old, moved+new) - if got.Start != 2 || got.End != 5 || got.Stale { - t.Errorf("split %v, side %d: after a move %+v, want lines 2-5", - split, s, got) - } } // A comment on only the inserted lines has its range on the new side. var ins []diff.Row @@ -152,84 +171,129 @@ func TestNewComment(t *testing.T) { ins = append(ins, r) } } - var cs diff.Comments - got := cs.Add(diff.NewComment("x.txt", diff.SideNew, ins, old, new)) + got := diff.NewComment("x.txt", diff.SideNew, ins, before, after) if got.Start != 2 || got.End != 3 { t.Errorf("split %v, the new lines: %+v, want lines 2-3", split, got) } - if cs.Relocate("x.txt", "", "B\nC\n"); got.Start != 1 || got.Stale { - t.Errorf("split %v, the new lines did not follow B and C: %+v", split, got) - } } // A hunk header has no line. - rows := diff.Render("x.txt", old, new, diff.Options{}).Rows + rows := diff.Render("x.txt", before, after, diff.Options{}).Rows hunk := rows[:slices.IndexFunc(rows, isEqual)] - got := diff.NewComment("x.txt", diff.SideNew, hunk, old, new) + got := diff.NewComment("x.txt", diff.SideNew, hunk, before, after) if len(hunk) == 0 || got.Start != 0 || got.End != 0 { t.Errorf("a hunk header %v: %+v, want no lines", hunk, got) } } -func TestRelocateFollowsMovedCode(t *testing.T) { - var cs diff.Comments - c := cs.Add(commentOn("a.go", diff.SideNew, "a\nb\nc\n", 2, 3)) - other := cs.Add(commentOn("a.go", diff.SideOld, "a\nb\n", 2, 2)) - - // Each comment looks in the version of its side. - cs.Relocate("a.go", "x\nx\nx\nb\n", "new\nnew\na\nb\nc\n") - if c.Start != 4 || c.End != 5 || c.Stale { - t.Errorf("moved: %+v", c) - } - if other.Start != 4 || other.Stale { - t.Errorf("the comment on the old side did not follow the old version: %+v", other) - } - cs.Relocate("a.go", "b\n", "a\nB\nc\n") - if !c.Stale || c.Start != 4 { - t.Errorf("not stale: %+v", c) - } - cs.Relocate("a.go", "b\n", "b\nc\nx\nx\nx\nb\nc\n") - if c.Stale || c.Start != 6 { - t.Errorf("did not go to the nearest copy: %+v", c) - } - // The end of a file is not an empty line, and CRLF is the same as LF. - blank := cs.Add(commentOn("b.go", diff.SideNew, "x\n\n", 2, 2)) - if cs.Relocate("b.go", "", "x\n"); !blank.Stale { - t.Errorf("a comment on a deleted blank line is not stale: %+v", blank) - } - if cs.Relocate("b.go", "", "x\r\n\r\n"); blank.Stale || blank.Start != 2 { - t.Errorf("a comment on a CRLF blank line: %+v", blank) - } -} - -// TestSnippetIsTheRealDiff checks that the snippet is the real diff, with -// raw text and tabs, in both layouts. The snippet must match git diff. +// TestSnippetIsTheRealDiff checks that the snippet matches git diff, with raw +// text and tabs, in both layouts. func TestSnippetIsTheRealDiff(t *testing.T) { - old := "func a() {\n\treturn 1\n}\n" - new := "func a() {\n\t// two\n\treturn 2\n}\n" + t.Parallel() + before := "func a() {\n\treturn 1\n}\n" + after := "func a() {\n\t// two\n\treturn 2\n}\n" want := " func a() {\n-\treturn 1\n+\t// two\n+\treturn 2\n }" for _, split := range []bool{false, true} { - d := diff.Render("x.go", old, new, diff.Options{Split: split}) - got := strings.Join(snippet(d.Rows, old, new), "\n") + d := diff.Render("x.go", before, after, diff.Options{Split: split}) + got := strings.Join(snippet(d.Rows, before, after), "\n") if got != want { - t.Errorf("split %v:\n%s\nwant:\n%s", split, got, want) + t.Errorf("split %v: snippet = \n%s\nwant:\n%s", split, got, want) } } dir := t.TempDir() - for name, src := range map[string]string{"old": old, "new": new} { - if err := os.WriteFile(filepath.Join(dir, name), []byte(src), 0o644); err != nil { - t.Fatal(err) - } + for name, src := range map[string]string{"old": before, "new": after} { + testgit.WriteFile(t, dir, name, src) } // git diff exits with 1 when the files differ. - git := exec.Command( + // No diff setting of the user applies. + git := proc.Command(t.Context(), testgit.ToolEnv(t), "git", "diff", "--no-index", "-U1", filepath.Join(dir, "old"), filepath.Join(dir, "new"), ) - out, _ := git.Output() + out, err := git.Output() + if ee, ok := errors.AsType[*exec.ExitError](err); err != nil && (!ok || ee.ExitCode() != 1) { + t.Fatalf("git diff --no-index: %v", err) + } _, body, _ := strings.Cut(string(out), "@@\n") if strings.TrimSuffix(body, "\n") != want { - t.Errorf("git diff:\n%s\nwant:\n%s", body, want) + t.Errorf("git diff = \n%s\nwant:\n%s", body, want) + } +} + +// TestCommentsJSON checks that the JSON keeps the anchor of a comment. It +// also checks a file of an older rexdiff, which has a stale field and no +// anchor. +func TestCommentsJSON(t *testing.T) { + t.Parallel() + var cs diff.Comments + anchors := []diff.Anchor{{}, {InDiff: true}, {InDiff: true, Rev: "a..b"}} + for _, a := range anchors { + cs.Add(diff.Comment{Path: "a.go", Start: 1, End: 1, Anchor: a}) + } + b, err := cs.MarshalJSON() + if err != nil { + t.Fatal(err) + } + var back diff.Comments + if err := back.UnmarshalJSON(b); err != nil { + t.Fatal(err) + } + for i, c := range back.All() { + if c.Anchor != anchors[i] { + t.Errorf("comment %d: anchor %+v, want %+v", i, c.Anchor, anchors[i]) + } + } + + old := `{"last":1,"items":[{"id":1,"path":"a.go","side":1,"start":2,` + + `"end":3,"text":"x","stale":true,"code":["a","b"],"snippet":null}]}` + var cs2 diff.Comments + if err := cs2.UnmarshalJSON([]byte(old)); err != nil { + t.Fatalf("UnmarshalJSON of an old file: %v", err) + } + c := cs2.Find(1) + if c == nil { + t.Fatal("Find(1) = nil, want the comment of an old file") + } + if c.Anchor != (diff.Anchor{InDiff: true}) || c.Start != 2 { + t.Errorf("comment of an old file = %+v, want lines 2-3 on a diff", c) + } + back.SetOldRev("x..y") + cs2.SetOldRev("a..b") + for i, c := range back.All() { + if c.Anchor != anchors[i] { + t.Errorf("comment %d after SetOldRev: anchor %+v, want %+v", i, c.Anchor, anchors[i]) + } + } + if want := (diff.Anchor{InDiff: true, Rev: "a..b"}); c.Anchor != want { + t.Errorf("comment of an old file after SetOldRev: anchor %+v, want %+v", c.Anchor, want) + } +} + +// TestCommentsJSONLatin1 checks that a comment on a file that is not UTF-8, +// for example a Latin-1 file, survives a JSON round trip. The bytes that +// are not UTF-8 become U+FFFD. +func TestCommentsJSONLatin1(t *testing.T) { + t.Parallel() + const path = "caf\xe9.txt" + var cs diff.Comments + c := commentOn(path, diff.SideNew, "caf\xe9 cr\xe8me\n", 1, 1) + c.Text = "na\xefve" + cs.Add(c) + b, err := json.Marshal(&cs) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + var back diff.Comments + if err := json.Unmarshal(b, &back); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + got := back.Find(1) + if got == nil { + t.Fatal("Find(1) = nil after a round trip") + } + want := "caf\uFFFD.txt:1\n caf\uFFFD cr\uFFFDme\nna\uFFFDve" + if r := review(*got); r != want { + t.Errorf("Review after a round trip = %q, want %q", r, want) } } diff --git a/diff/diff.go b/diff/diff.go index a243075..337e2a4 100644 --- a/diff/diff.go +++ b/diff/diff.go @@ -18,17 +18,19 @@ import ( // Kind is the type of a rendered row. type Kind int +// These are the kinds of a row. const ( - KindHunk Kind = iota // a hunk header. It shows no source line. + KindHunk Kind = iota // a hunk header, with no source line KindEqual // a line that is the same on the two sides KindInsert // a line that only the new side has KindDelete // a line that only the old side has - KindChange // in the split layout, a deleted line beside an inserted line + KindChange // a deleted line beside an inserted line (split) ) // Side identifies one version of a file. type Side int +// These are the two sides of a file. const ( SideOld Side = iota // the version before the change SideNew // the version after the change @@ -37,11 +39,22 @@ const ( // Row tells which source lines one rendered line shows. Old and New are // line numbers that start at 1. A value of 0 means that the row has no line // on that side. Continued is true if the line continues the source lines of -// the line above, because [Options.Wrap] wrapped them. +// the line above, because [Options.Wrap] wraps them. +// +// A long run of equal lines folds into the hunk header below it. For a +// hunk header, Fold is that run. type Row struct { Kind Kind Old, New int Continued bool + Fold Fold +} + +// Fold is a run of equal lines that the diff hides. Old and New are the +// line numbers of its first line. Lines is the number of lines. Lines is 0 +// if the diff hides no line. +type Fold struct { + Old, New, Lines int } // Line returns the line number of the row on side s. @@ -64,12 +77,26 @@ type Options struct { // Wrap continues a line that is wider than Width on the next lines, // instead of cutting it. Then Offset does not apply. Wrap bool + // Expanded reports whether the lines of a fold show. If Expanded is + // nil, no fold shows. RenderFile does not use it. + Expanded func(Fold) bool + // Signs marks a changed line with + or -. Without Signs, a bar in the + // colour of the line marks it. + Signs bool // Theme sets the colours. Theme Theme + // Cache makes a new render of the same lines fast. If Cache is nil, the + // render keeps nothing. + Cache *Cache } -// tabWidth is the number of spaces that replace a tab. -const tabWidth = 4 +// Cache keeps the syntax colours of the lines between renders, for example +// for a sideways scroll. The zero value is an empty Cache. A Cache is not +// safe for concurrent use. +type Cache struct { + lines diffview.Cache // the lines of Render + file struct{ path, src, out string } // the last file of RenderFile +} // Diff is the rendered output. Lines[i] is the screen line for Rows[i]. // Lines is empty when there is nothing to show, for example when the two @@ -82,42 +109,60 @@ type Diff struct { oldNum int // width of the old line-number column newNum int // width of the new line-number column newNumAt int // column where the new line numbers start + + // The fields below are for [Diff.Cell]. + src [2]string // the content of each side + codeAt [2]int // column where the code of each side starts + offset int // the sideways scroll, or 0 with wrap + wrap bool } -// Render compares the old and the new content of the file at path. The path -// selects the syntax highlighter. -func Render(path, old, new string, o Options) *Diff { - dv := diffview.New(). - Before(path, old). - After(path, new). +// Render compares before, the old content of the file at path, with after, +// its new content. The path selects the syntax highlighter. +func Render(path, before, after string, o Options) *Diff { + dv := diffview.New(path, before, after). Width(o.Width). - TabWidth(tabWidth). - XOffset(o.Offset) - if o.Split { - dv = dv.Split() + XOffset(o.Offset). + Split(o.Split). + Signs(o.Signs). + Wrap(o.Wrap). + Theme(o.Theme.Background, o.Theme.Foreground, o.Theme.Green, + o.Theme.Red, o.Theme.dark()) + if o.Cache != nil { + dv = dv.Cache(&o.Cache.lines) } - if o.Wrap { - dv = dv.Wrap() + if o.Expanded != nil { + dv = dv.Expand(func(f diffview.Fold) bool { return o.Expanded(foldOf(f)) }) } - dv = o.Theme.apply(dv) out, rows, g := dv.Render() - d := &Diff{split: o.Split} + d := &Diff{split: o.Split, src: [2]string{before, after}, wrap: o.Wrap} + if !o.Wrap { + d.offset = o.Offset + } for _, r := range rows { d.Rows = append(d.Rows, Row{ Kind: kindOf(r.Kind), Old: r.Before, New: r.After, Continued: r.Continued, + Fold: foldOf(r.Fold), }) } if len(d.Rows) > 0 { d.Lines = strings.Split(out, "\n") } d.oldNum, d.newNum, d.newNumAt = g.BeforeNum, g.AfterNum, g.AfterNumStart + d.codeAt = [2]int{g.BeforeCode, g.AfterCode} return d } +// foldOf returns the Fold of the diffview fold f. +func foldOf(f diffview.Fold) Fold { + return Fold{Old: f.Before, New: f.After, Lines: f.Lines} +} + +// kindOf returns the Kind of the diffview row kind k. func kindOf(k diffview.RowKind) Kind { switch k { case diffview.RowHunk: @@ -152,12 +197,11 @@ func (d *Diff) SideAt(x int) (s Side, onNumber bool) { return SideNew, x < d.oldNum+d.newNum } -// Count returns the number of lines that a change from old to new adds and -// deletes. It ignores the difference between CRLF and LF line endings. -func Count(old, new string) (added, deleted int) { - old = strings.ReplaceAll(old, "\r\n", "\n") - new = strings.ReplaceAll(new, "\r\n", "\n") - u, err := udiff.ToUnifiedDiff("", "", old, udiff.Lines(old, new), 0) +// Count returns the number of lines that a change from before to after adds +// and deletes. As in herdr-reviewr, a change from a CRLF to an LF line +// ending, or back, is a change of the line. +func Count(before, after string) (added, deleted int) { + u, err := udiff.ToUnifiedDiff("", "", before, udiff.Lines(before, after), 0) if err != nil { return 0, 0 } @@ -175,12 +219,26 @@ func Count(old, new string) (added, deleted int) { } // Lines splits s into lines, without the LF or CRLF line endings. Line n of -// s is Lines(s)[n-1]. A comment counts lines in this way. +// s is Lines(s)[n-1]. The lines of a [Comment] count in this way. func Lines(s string) []string { var lines []string for l := range strings.Lines(s) { - l = strings.TrimSuffix(l, "\n") - lines = append(lines, strings.TrimSuffix(l, "\r")) + lines = append(lines, trimEOL(l)) } return lines } + +// trimEOL returns line without its LF or CRLF line ending. +func trimEOL(line string) string { + return strings.TrimSuffix(strings.TrimSuffix(line, "\n"), "\r") +} + +// countLines returns the number of lines of s, as [Lines] counts them. A +// last line without a newline counts as a line. +func countLines(s string) int { + n := strings.Count(s, "\n") + if s != "" && !strings.HasSuffix(s, "\n") { + n++ + } + return n +} diff --git a/diff/diff_test.go b/diff/diff_test.go index 83fa0ad..3d5a2bc 100644 --- a/diff/diff_test.go +++ b/diff/diff_test.go @@ -19,9 +19,9 @@ const ( // snippet returns the diff lines of a comment on rows, as // [diff.Comments.Review] writes them. -func snippet(rows []diff.Row, old, new string) []string { +func snippet(rows []diff.Row, before, after string) []string { var cs diff.Comments - cs.Add(diff.NewComment("x", diff.SideNew, rows, old, new)) + cs.Add(diff.NewComment("x", diff.SideNew, rows, before, after)) lines := strings.Split(cs.Review(""), "\n") return lines[1 : len(lines)-1] // not the location and the empty text } @@ -38,7 +38,33 @@ func kinds(rows []diff.Row) string { return b.String() } +// renderer is a way to show two versions of a file. +type renderer struct { + name string + render func(o diff.Options) *diff.Diff +} + +// renderers returns the unified and the split diff of before and after, +// and the file after. +func renderers(before, after string) []renderer { + return []renderer{ + {"unified", func(o diff.Options) *diff.Diff { + return diff.Render("x.go", before, after, o) + }}, + {"split", func(o diff.Options) *diff.Diff { + o.Split = true + return diff.Render("x.go", before, after, o) + }}, + {"file", func(o diff.Options) *diff.Diff { + return diff.RenderFile("x.go", after, o) + }}, + } +} + +// TestRenderRowsFollowLines checks the row of each line, in each layout and +// theme. func TestRenderRowsFollowLines(t *testing.T) { + t.Parallel() const width = 80 themes := []diff.Theme{ {}, @@ -77,48 +103,179 @@ func TestRenderRowsFollowLines(t *testing.T) { for ti, th := range themes { for _, tt := range tests { name := fmt.Sprintf("split %v, theme %d", tt.split, ti) - o := diff.Options{Split: tt.split, Width: width, Theme: th} - d := diff.Render("x.go", oldSrc, newSrc, o) - if got := kinds(d.Rows); got != tt.kinds { - t.Errorf("%s: kinds = %q, want %q", name, got, tt.kinds) - } - for i, r := range tt.rows { - if i < len(d.Rows) && d.Rows[i] != r { - t.Errorf("%s: row %d = %+v, want %+v", name, i, d.Rows[i], r) + t.Run(name, func(t *testing.T) { + t.Parallel() + o := diff.Options{Split: tt.split, Width: width, Theme: th} + d := diff.Render("x.go", oldSrc, newSrc, o) + if got := kinds(d.Rows); got != tt.kinds { + t.Errorf("kinds = %q, want %q", got, tt.kinds) } - } - if len(d.Lines) != len(d.Rows) { - t.Fatalf("%s: %d lines for %d rows", name, len(d.Lines), len(d.Rows)) - } - for i, l := range d.Lines { - if w := ansi.StringWidth(l); w > width { - t.Errorf("%s: line %d is %d cells wide", name, i, w) + for i, r := range tt.rows { + if i < len(d.Rows) && d.Rows[i] != r { + t.Errorf("row %d = %+v, want %+v", i, d.Rows[i], r) + } } - } + if len(d.Lines) != len(d.Rows) { + t.Errorf("%d lines for %d rows, want one line for each row", + len(d.Lines), len(d.Rows)) + } + for i, l := range d.Lines { + if w := ansi.StringWidth(l); w > width { + t.Errorf("line %d is %d cells wide, want %d or fewer", + i, w, width) + } + } + }) } } } -func TestRenderSameContentIsEmpty(t *testing.T) { - d := diff.Render("x.go", oldSrc, oldSrc, diff.Options{Width: 80}) - if len(d.Lines) != 0 || len(d.Rows) != 0 { - t.Fatalf("got %d lines and %d rows, want none", len(d.Lines), len(d.Rows)) +// TestRenderKinds checks the kinds of the rows of a diff. Equal versions +// give no rows. A change of a tab to spaces is a change, as Count and git +// tell. +func TestRenderKinds(t *testing.T) { + t.Parallel() + tests := []struct{ old, new, want string }{ + {oldSrc, oldSrc, ""}, + {"\tx\n", " x\n", "@-+"}, + } + for _, tt := range tests { + d := diff.Render("x.go", tt.old, tt.new, diff.Options{Width: 80}) + if got := kinds(d.Rows); got != tt.want || len(d.Lines) != len(d.Rows) { + t.Errorf("Render(%q, %q): kinds %q, %d lines, want %q, one line each", + tt.old, tt.new, got, len(d.Lines), tt.want) + } + } +} + +// TestRenderLineEnding checks that a change of only the line ending shows, +// as in herdr-reviewr. The line with the CR has a ^M. A CRLF that does not +// change shows nothing. +func TestRenderLineEnding(t *testing.T) { + t.Parallel() + d := diff.Render("x.txt", "a\nb\r\nc\r\n", "a\r\nb\nc\r\n", diff.Options{Width: 80}) + var got []string // the kind and the last word of each row + for i, r := range d.Rows { + words := strings.Fields(plainLine(t, d, i)) + got = append(got, fmt.Sprintf("%c %s", "@=+-~"[r.Kind], words[len(words)-1])) + } + want := []string{"@ @@", "- a", "- b^M", "+ a^M", "+ b", "= c"} + if !slices.Equal(got, want) { + t.Errorf("Render rows = %q, want %q", got, want) } } +// TestRenderTabsAndOffset checks that a tab becomes spaces and that Offset +// scrolls the code. func TestRenderTabsAndOffset(t *testing.T) { + t.Parallel() d := diff.Render("x.go", "", "\tx := 1\n", diff.Options{Width: 80}) - if got := ansi.Strip(d.Lines[1]); !strings.Contains(got, "+ x := 1") { - t.Errorf("tab: line = %q", got) + if got := plainLine(t, d, 1); !strings.Contains(got, "▌ x := 1") { + t.Errorf("tab: line = %q, want %q in it", got, "▌ x := 1") } d = diff.Render("x.go", "", "\tx := 1\n", diff.Options{Width: 80, Offset: 6}) - got := ansi.Strip(d.Lines[1]) + got := plainLine(t, d, 1) if strings.Contains(got, "x :=") || !strings.Contains(got, "= 1") { - t.Errorf("offset 6: line = %q", got) + t.Errorf("offset 6: line = %q, want \"= 1\" and no \"x :=\"", got) + } +} + +// TestMarks checks the mark at the start of the code of each line, in the two +// layouts. +func TestMarks(t *testing.T) { + t.Parallel() + const before, after = "a\nb\nc\n", "a\nB\nc\n" + // By default a bar marks a changed line, as in herdr-reviewr. Signs gives + // + and -. The mark takes two cells in both cases, so the columns do not + // move. + theme := diff.Theme{ + Background: color.Black, + Green: color.RGBA{G: 0xff, A: 0xff}, + Red: color.RGBA{R: 0xff, A: 0xff}, + } + tests := []struct { + name string + o diff.Options + lines []string // lines under the hunk header, no colours + }{ + {"unified bar", diff.Options{Width: 40}, []string{ + " 1 1 a", " 2 ▌ b", " 2 ▌ B", " 3 3 c", + }}, + {"unified signs", diff.Options{Width: 40, Signs: true}, []string{ + " 1 1 a", " 2 - b", " 2 + B", " 3 3 c", + }}, + {"split bar", diff.Options{Width: 40, Split: true}, []string{ + " 1 a 1 a", + " 2 ▌ b 2 ▌ B", + " 3 c 3 c", + }}, + {"split signs", diff.Options{Width: 40, Split: true, Signs: true}, []string{ + " 1 a 1 a", + " 2 - b 2 + B", + " 3 c 3 c", + }}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + tt.o.Theme = theme + d := diff.Render("x.txt", before, after, tt.o) + if len(d.Lines) != 1+len(tt.lines) { + t.Fatalf("%d lines, want %d", len(d.Lines), 1+len(tt.lines)) + } + for i, want := range tt.lines { + got := strings.TrimRight(ansi.Strip(d.Lines[i+1]), " ") + if got != want { + t.Errorf("line %d = %q, want %q", i+1, got, want) + } + } + // The marks have the colour of their lines: ANSI red for the + // delete, then ANSI green for the insert. + got := markColours(d.Lines[1:]) + if !slices.Equal(got, []string{"31", "32"}) { + t.Errorf("the marks have colours %q, want 31 and 32", got) + } + }) + } + // With a scroll to the right, an ellipsis shows after the bar. + d := diff.Render("x.txt", "", "abcdefgh\n", diff.Options{Width: 40, Offset: 2}) + if got := plainLine(t, d, 1); !strings.Contains(got, "▌…cdefgh") { + t.Errorf("offset: line = %q, want ▌…cdefgh", got) + } + // The review text of a comment uses + and - for both kinds of mark. + for _, signs := range []bool{false, true} { + d := diff.Render("x.txt", before, after, diff.Options{Width: 40, Signs: signs}) + got := snippet(d.Rows[2:5], before, after) + if want := []string{"-b", "+B", " c"}; !slices.Equal(got, want) { + t.Errorf("signs %v: snippet = %q, want %q", signs, got, want) + } + } +} + +// markColours returns the foreground of each bar, + or - in lines, in +// order, for example "31". The foreground is in the SGR sequence in front +// of the mark. +func markColours(lines []string) []string { + var out []string + for _, l := range lines { + for { + i := strings.IndexAny(l, "▌+-") + if i < 0 { + break + } + _, sgr, _ := strings.CutLast(l[:i], "\x1b[") + fg, _, _ := strings.Cut(strings.TrimSuffix(sgr, "m"), ";") + out = append(out, fg) + l = l[i+1:] + } } + return out } +// TestSideAt checks the side of each column, and if the column is on a line +// number. func TestSideAt(t *testing.T) { + t.Parallel() // The unified layout has " 1 " old numbers, " 1 " new numbers, and the // code. u := diff.Render("x.go", oldSrc, newSrc, diff.Options{Width: 80}) @@ -145,109 +302,140 @@ func TestSideAt(t *testing.T) { {"file code", f, 5, diff.SideNew, false}, } for _, tt := range tests { - side, on := tt.d.SideAt(tt.x) - if side != tt.side || on != tt.onNumber { - t.Errorf("%s: SideAt(%d) = %v, %v; want %v, %v", - tt.name, tt.x, side, on, tt.side, tt.onNumber) - } + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + side, on := tt.d.SideAt(tt.x) + if side != tt.side || on != tt.onNumber { + t.Errorf("SideAt(%d) = %v, %v, want %v, %v", + tt.x, side, on, tt.side, tt.onNumber) + } + }) } } +// TestCount checks the numbers of added and deleted lines. func TestCount(t *testing.T) { + t.Parallel() tests := []struct { old, new string added, deleted int }{ {oldSrc, newSrc, 2, 1}, {"", "a\nb\n", 2, 0}, - {"a\r\nb\r\n", "a\nb\n", 0, 0}, + {"a\r\nb\r\n", "a\nb\n", 2, 2}, {oldSrc, oldSrc, 0, 0}, } for _, tt := range tests { a, d := diff.Count(tt.old, tt.new) if a != tt.added || d != tt.deleted { - t.Errorf("Count(%q, %q) = %d, %d; want %d, %d", + t.Errorf("Count(%q, %q) = %d, %d, want %d, %d", tt.old, tt.new, a, d, tt.added, tt.deleted) } } } +// TestRenderFile checks the line numbers and the rows of a file. func TestRenderFile(t *testing.T) { + t.Parallel() src := "package x\n\nfunc f() {\n\treturn\n}\n" d := diff.RenderFile("x.go", src, diff.Options{Width: 14}) if len(d.Lines) != 5 || len(d.Rows) != 5 { - t.Fatalf("got %d lines and %d rows, want 5", len(d.Lines), len(d.Rows)) + t.Fatalf("RenderFile: %d lines and %d rows, want 5", + len(d.Lines), len(d.Rows)) } if d.Rows[3] != (diff.Row{Kind: diff.KindEqual, Old: 4, New: 4}) { - t.Errorf("row 3 = %+v", d.Rows[3]) + t.Errorf("row 3 = %+v, want %+v", + d.Rows[3], diff.Row{Kind: diff.KindEqual, Old: 4, New: 4}) } if got := ansi.Strip(d.Lines[2]); got != " 3 func f() {" { - t.Errorf("line 2 = %q", got) + t.Errorf("line 2 = %q, want %q", got, " 3 func f() {") } if got := ansi.Strip(d.Lines[0]); got != " 1 package x" { - t.Errorf("line 0 = %q", got) - } - // Control characters must not reach the terminal. - d = diff.RenderFile("x.txt", "a\x1b[31mb\n", diff.Options{}) - if strings.Contains(ansi.Strip(d.Lines[0]), "\x1b") { - t.Errorf("line 0 = %q, has an escape", d.Lines[0]) + t.Errorf("line 0 = %q, want %q", got, " 1 package x") } } -// TestWrap wraps a long line. All of its text shows, and each screen line -// fits the width. The continued rows point at the same source lines, and -// the snippet of a comment does not change. +// TestWrap checks that a wrapped long line shows all of its text and fits the +// width. func TestWrap(t *testing.T) { + t.Parallel() long := "x := []string{" + strings.Repeat(`"word", `, 12) + "}" - old, new := "a\nb\n", "a\n"+long+"\nb\n" + before, after := "a\nb\n", "a\n"+long+"\nb\n" const width = 40 - renders := []struct { - name string - render func(o diff.Options) *diff.Diff - }{ - {"unified", func(o diff.Options) *diff.Diff { - return diff.Render("x.go", old, new, o) - }}, - {"split", func(o diff.Options) *diff.Diff { - o.Split = true - return diff.Render("x.go", old, new, o) - }}, - {"file", func(o diff.Options) *diff.Diff { - return diff.RenderFile("x.go", new, o) - }}, - } - for _, tt := range renders { - name, render := tt.name, tt.render - cut := render(diff.Options{Width: width}) - d := render(diff.Options{Width: width, Wrap: true}) - if len(d.Lines) != len(d.Rows) || len(d.Lines) <= len(cut.Lines) { - t.Fatalf("%s: %d lines and %d rows, want more than the %d lines without wrap", - name, len(d.Lines), len(d.Rows), len(cut.Lines)) - } - var text strings.Builder - for i, l := range d.Lines { - if w := ansi.StringWidth(l); w > width { - t.Errorf("%s: line %d is %d cells wide", name, i, w) + for _, tt := range renderers(before, after) { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + cut := tt.render(diff.Options{Width: width}) + d := tt.render(diff.Options{Width: width, Wrap: true}) + if len(d.Lines) != len(d.Rows) || len(d.Lines) <= len(cut.Lines) { + t.Fatalf("%d lines and %d rows, "+ + "want more than the %d lines without wrap", + len(d.Lines), len(d.Rows), len(cut.Lines)) } - r := d.Rows[i] - if r.New == 2 { - text.WriteString(ansi.Strip(l)) + var text strings.Builder + for i, l := range d.Lines { + if w := ansi.StringWidth(l); w > width { + t.Errorf("line %d is %d cells wide, want %d or fewer", + i, w, width) + } + r := d.Rows[i] + if r.New == 2 { + text.WriteString(ansi.Strip(l)) + } + // A continued row points at the same source lines as the + // row above. + prev := d.Rows[max(i-1, 0)] + sameLines := prev.Old == r.Old && prev.New == r.New + if r.Continued && !sameLines { + t.Errorf("continued row %d = %+v, want the lines of %+v", + i, r, prev) + } } - prev := d.Rows[max(i-1, 0)] - sameLines := prev.Old == r.Old && prev.New == r.New - if r.Continued && !sameLines { - t.Errorf("%s: continued row %d = %+v, the row above = %+v", - name, i, r, prev) + noSpace := func(s string) string { + return strings.Join(strings.Fields(s), "") } + if !strings.Contains(noSpace(text.String()), noSpace(long)) { + t.Errorf("wrapped lines = \n%s\nwant all of:\n%s", + text.String(), long) + } + // Wrap does not change the snippet of a comment. + got, want := snippet(d.Rows, before, after), snippet(cut.Rows, before, after) + if !slices.Equal(got, want) { + t.Errorf("snippet with wrap = %q, want %q", got, want) + } + }) + } +} + +// TestCache checks that a shared [diff.Cache] gives the same lines as no +// cache. It tries other offsets, other languages, and new content. +func TestCache(t *testing.T) { + t.Parallel() + const src = "def f(): # a comment\n\treturn 1\n" + tests := []struct { + path, old, new string + file bool // RenderFile, of new + offset int + }{ + {path: "a.go", old: src, new: src + "x\n"}, + {path: "a.go", old: src, new: src + "x\n", offset: 4}, + {path: "a.py", old: src, new: src + "x\n"}, + {path: "a.py", new: src, file: true}, + {path: "a.py", new: src + "y\n", file: true}, + {path: "a.go", new: src + "y\n", file: true}, + } + var c diff.Cache + for _, tt := range tests { + render := func(c *diff.Cache) []string { + o := diff.Options{Width: 40, Offset: tt.offset, Cache: c} + if tt.file { + return diff.RenderFile(tt.path, tt.new, o).Lines + } + return diff.Render(tt.path, tt.old, tt.new, o).Lines } - noSpace := func(s string) string { return strings.Join(strings.Fields(s), "") } - if !strings.Contains(noSpace(text.String()), noSpace(long)) { - t.Errorf("%s: the wrapped lines do not show all of the line:\n%s", - name, text.String()) - } - got, want := snippet(d.Rows, old, new), snippet(cut.Rows, old, new) - if !slices.Equal(got, want) { - t.Errorf("%s: snippet with wrap %q, without %q", name, got, want) + if got, want := render(&c), render(nil); !slices.Equal(got, want) { + t.Errorf("%+v: lines with the cache = \n%q\nwant:\n%q", + tt, got, want) } } } diff --git a/diff/file.go b/diff/file.go index b02aaae..02f90fe 100644 --- a/diff/file.go +++ b/diff/file.go @@ -2,6 +2,7 @@ package diff import ( "fmt" + "strconv" "strings" "charm.land/lipgloss/v2" @@ -10,34 +11,42 @@ import ( "github.com/rednafi/rexdiff/internal/diffview" ) -// lineNumberColor is the ANSI palette colour of the line numbers: bright -// black. +// lineNumberColor is the ANSI palette colour of the line numbers. Colour 8 +// is bright black. const lineNumberColor = "8" // RenderFile shows src, the content of the file at path, with line numbers // and syntax colours. Each row is a [KindEqual] row, with the same line // number on the two sides. The line numbers count as the [SideNew] side. +// RenderFile uses only the Width, Offset, Wrap, and Cache fields of o. func RenderFile(path, src string, o Options) *Diff { // Chroma makes a lone CR a line break. RenderFile shows a lone CR as a // symbol, so that the lines are the same as [Lines] gives. + raw := src src = strings.NewReplacer( "\r\n", "\n", "\r", "\u240d", - "\t", strings.Repeat(" ", tabWidth), + "\t", strings.Repeat(" ", diffview.TabWidth), ).Replace(src) - hl := strings.Split(highlight(path, src), "\n") - hl = hl[:min(len(hl), len(Lines(src)))] // chroma can add a line break at the end - digits := len(fmt.Sprint(len(hl))) + hl := strings.Split(o.Cache.fileColors(path, src), "\n") + hl = hl[:min(len(hl), countLines(src))] // chroma can add a final newline + digits := len(strconv.Itoa(len(hl))) numWidth := digits + 2 // a space on each side of the number - numStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(lineNumberColor)) - d := &Diff{newNum: numWidth} - code := o.Width - numWidth - 1 // the width that the number and a space leave + // The style renders once, around a format for the number. + numFmt := lipgloss.NewStyle().Foreground(lipgloss.Color(lineNumberColor)). + Render(" %*d ") + " " + d := &Diff{newNum: numWidth, src: [2]string{raw, raw}, wrap: o.Wrap} + d.codeAt = [2]int{numWidth + 1, numWidth + 1} + if !o.Wrap { + d.offset = o.Offset + } + code := o.Width - numWidth - 1 // the width after the number and a space blank := strings.Repeat(" ", numWidth+1) for i, l := range hl { for k, p := range fileLineParts(l, code, o) { num := blank if k == 0 { - num = numStyle.Render(fmt.Sprintf(" %*d ", digits, i+1)) + " " + num = fmt.Sprintf(numFmt, digits, i+1) } line := num + p if o.Width > 0 { // the number or a wide character can be too wide @@ -57,8 +66,8 @@ func RenderFile(path, src string, o Options) *Diff { // fileLineParts returns the screen lines of the highlighted line l. If o has // a width, each part fits in code cells. With [Options.Wrap], a long line -// gives more than one part. Else the line skips [Options.Offset] cells and -// is cut. +// gives more than one part. Without Wrap, the line skips [Options.Offset] +// cells and is cut. func fileLineParts(l string, code int, o Options) []string { switch { case o.Width > 0 && code <= 0: @@ -68,14 +77,27 @@ func fileLineParts(l string, code int, o Options) []string { wrapped := ansi.Hardwrap(lipgloss.Wrap(l, code, ""), code, true) return strings.Split(wrapped, "\n") case o.Width > 0: - return []string{ansi.Truncate(ansi.TruncateLeft(l, o.Offset, ""), code, "…")} + return []string{ansi.Truncate(diffview.CutLeft(l, o.Offset), code, "…")} + } + return []string{diffview.CutLeft(l, o.Offset)} +} + +// fileColors returns highlight(path, src), and keeps it for the next +// call. A nil c keeps nothing. +func (c *Cache) fileColors(path, src string) string { + if c == nil { + return highlight(path, src) + } + f := &c.file + if f.path != path || f.src != src { + f.path, f.src, f.out = path, src, highlight(path, src) } - return []string{ansi.TruncateLeft(l, o.Offset, "")} + return f.out } -// highlight colours the whole file at once, so that tokens that span lines -// get the correct colour. If it cannot highlight src, it returns src with -// its control characters replaced. +// highlight colours src, the whole file at path, at once, so that tokens +// that span lines get the correct colour. If it cannot highlight src, it +// returns src with its control characters replaced. func highlight(path, src string) string { it, err := diffview.Lexer(path, src).Tokenise(nil, src) if err != nil { diff --git a/diff/fold_test.go b/diff/fold_test.go new file mode 100644 index 0000000..ec1d143 --- /dev/null +++ b/diff/fold_test.go @@ -0,0 +1,182 @@ +package diff_test + +import ( + "fmt" + "slices" + "strings" + "testing" + + "github.com/charmbracelet/x/ansi" + + "github.com/rednafi/rexdiff/diff" +) + +// numbered returns lines "line 1" to "line n". The lines in upper are +// "LINE 1" and so on. +func numbered(n int, upper ...int) string { + var b strings.Builder + for i := 1; i <= n; i++ { + word := "line" + if slices.Contains(upper, i) { + word = "LINE" + } + fmt.Fprintf(&b, "%s %d\n", word, i) + } + return b.String() +} + +// foldText writes a hunk header with fold f, for example "@14/14+13". +func foldText(f diff.Fold) string { return fmt.Sprintf("@%d/%d+%d", f.Old, f.New, f.Lines) } + +// layout writes each row of d as one entry. A hunk header is "@" and its +// fold. Another row is its old and its new line number. +func layout(d *diff.Diff) []string { + var out []string + for _, r := range d.Rows { + if r.Kind == diff.KindHunk { + out = append(out, foldText(r.Fold)) + continue + } + out = append(out, fmt.Sprintf("%d:%d", r.Old, r.New)) + } + return out +} + +// headers returns the text of the hunk headers of d. +func headers(d *diff.Diff) string { + var out []string + for i, r := range d.Rows { + if r.Kind == diff.KindHunk { + out = append(out, strings.TrimSpace(ansi.Strip(d.Lines[i]))) + } + } + return strings.Join(out, "\n") +} + +// TestFolds checks that a long run of equal lines folds into the hunk +// header below it. +func TestFolds(t *testing.T) { + t.Parallel() + before, after := numbered(40), numbered(40, 10, 30) + header := func(o, n, lines int) []string { return []string{foldText(diff.Fold{Old: o, New: n, Lines: lines})} } + lines := func(lo, hi int) []string { + var out []string + for i := lo; i <= hi; i++ { + out = append(out, fmt.Sprintf("%d:%d", i, i)) + } + return out + } + change := func(n int) []string { return []string{fmt.Sprintf("%d:0", n), fmt.Sprintf("0:%d", n)} } + // Three lines show beside each change. A fold that Expanded accepts shows + // all of its lines. + top, middle, tail, none := header(1, 1, 6), header(14, 14, 13), header(34, 34, 7), header(0, 0, 0) + tests := []struct { + name string + expanded func(diff.Fold) bool + want []string + }{ + {"all folded", nil, slices.Concat( + top, lines(7, 9), change(10), lines(11, 13), + middle, lines(27, 29), change(30), lines(31, 33), + tail, + )}, + {"middle shows", func(f diff.Fold) bool { return f.New == 14 }, slices.Concat( + top, lines(7, 9), change(10), lines(11, 29), change(30), lines(31, 33), + tail, + )}, + {"top and tail show", func(f diff.Fold) bool { return f.New != 14 }, slices.Concat( + none, lines(1, 9), change(10), lines(11, 13), + middle, lines(27, 29), change(30), lines(31, 40), + )}, + {"all show", func(diff.Fold) bool { return true }, slices.Concat( + none, lines(1, 9), change(10), lines(11, 29), change(30), lines(31, 40), + )}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + o := diff.Options{Width: 60, Expanded: tt.expanded} + d := diff.Render("f.txt", before, after, o) + if got := layout(d); !slices.Equal(got, tt.want) { + t.Errorf("layout = \n%v\nwant:\n%v", got, tt.want) + } + }) + } + + // The header gives the number of hidden lines. A fold at the end has no + // hunk header. In the split layout, the header continues across the + // right side. No line is wider than the screen. + for _, tt := range []struct { + o diff.Options + want string + }{ + {diff.Options{Width: 60}, "… … ⋯ 6 unmodified lines @@ -7,7 +7,7 @@\n" + + "… … ⋯ 13 unmodified lines @@ -27,7 +27,7 @@\n" + + "… … ⋯ 7 unmodified lines"}, + {diff.Options{Width: 60, Split: true}, "… ⋯ 6 unmodified lines @@ -7,7 +7,7 @@\n" + + "… ⋯ 13 unmodified lines @@ -27,7 +27,7 @@\n" + + "… ⋯ 7 unmodified lines"}, + } { + d := diff.Render("f.txt", before, after, tt.o) + if got := headers(d); got != tt.want { + t.Errorf("split %v: headers = \n%s\nwant:\n%s", + tt.o.Split, got, tt.want) + } + for i, l := range d.Lines { + if w := ansi.StringWidth(l); w > tt.o.Width { + t.Errorf("split %v: line %d is %d cells wide, want %d or fewer", + tt.o.Split, i, w, tt.o.Width) + } + } + } +} + +// TestFoldEdges checks the folds that show no line. +func TestFoldEdges(t *testing.T) { + t.Parallel() + tests := []struct { + name string + old, new string + want string + }{ + // Changes at 10 and 18 leave line 14 alone between the margins. The + // line shows, because a fold of one line saves no row. + { + "one line", numbered(21), numbered(21, 10, 18), + "@1/1+6 7:7 8:8 9:9 10:0 0:10 11:11 12:12 13:13 14:14 15:15 16:16 17:17 18:0 0:18 19:19 20:20 21:21", + }, + // After an insert, the old and the new numbers of a fold differ. + { + "insert at the top", numbered(12), "new\n" + numbered(12), + "@0/0+0 0:1 1:2 2:3 3:4 @4/5+9", + }, + // A file without equal lines has no fold. + {"all new", "", "a\nb\n", "@0/0+0 0:1 0:2"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + d := diff.Render("f.txt", tt.old, tt.new, diff.Options{Width: 80}) + if got := strings.Join(layout(d), " "); got != tt.want { + t.Errorf("layout = \n%s\nwant:\n%s", got, tt.want) + } + // The split layout has the same folds. + o := diff.Options{Width: 80, Split: true} + split := diff.Render("f.txt", tt.old, tt.new, o) + if got, want := folds(split), folds(d); !slices.Equal(got, want) { + t.Errorf("split folds = %v, want %v", got, want) + } + }) + } +} + +// folds returns the folds of the hunk headers of d. +func folds(d *diff.Diff) []diff.Fold { + var out []diff.Fold + for _, r := range d.Rows { + if r.Kind == diff.KindHunk { + out = append(out, r.Fold) + } + } + return out +} diff --git a/diff/fuzz_test.go b/diff/fuzz_test.go index e5b7fb7..2207078 100644 --- a/diff/fuzz_test.go +++ b/diff/fuzz_test.go @@ -7,7 +7,6 @@ import ( "strings" "testing" "unicode" - "unicode/utf8" "github.com/charmbracelet/x/ansi" @@ -67,44 +66,50 @@ func fuzzOptions(layout, width, offset uint8, colored bool) diff.Options { func checkLines(t *testing.T, d *diff.Diff, o diff.Options) { t.Helper() if len(d.Lines) != len(d.Rows) { - t.Fatalf("%d lines for %d rows", len(d.Lines), len(d.Rows)) + t.Fatalf("%d lines for %d rows, want one line for each row", + len(d.Lines), len(d.Rows)) } for i, l := range d.Lines { if w := ansi.StringWidth(l); o.Width > 0 && w > o.Width { - t.Errorf("line %d is %d cells wide, width is %d: %q", i, w, o.Width, l) + t.Errorf("line %d is %d cells wide, want %d or fewer: %q", + i, w, o.Width, l) } rest := sgr.ReplaceAllString(l, "") if strings.ContainsFunc(rest, unicode.IsControl) { - t.Errorf("line %d has a control character: %q", i, l) + t.Errorf("line %d = %q, want no control characters", i, l) } } } // checkRows checks that each row points at real lines, in order. -func checkRows(t *testing.T, rows []diff.Row, old, new string) { +func checkRows(t *testing.T, rows []diff.Row, before, after string) { t.Helper() - nOld, nNew := len(diff.Lines(old)), len(diff.Lines(new)) + nOld, nNew := len(diff.Lines(before)), len(diff.Lines(after)) lastOld, lastNew := 0, 0 for i, r := range rows { if r.Old < 0 || r.Old > nOld || r.New < 0 || r.New > nNew { - t.Fatalf("row %d = %+v, the files have %d and %d lines", i, r, nOld, nNew) + t.Fatalf("row %d = %+v, want Old in 0-%d and New in 0-%d", + i, r, nOld, nNew) } if r.Continued { if i == 0 || rows[i-1].Old != r.Old || rows[i-1].New != r.New { - t.Fatalf("continued row %d = %+v does not continue the row above", i, r) + t.Fatalf("continued row %d = %+v, want the lines of the row above", + i, r) } continue } oldBack := r.Old != 0 && r.Old <= lastOld newBack := r.New != 0 && r.New <= lastNew if oldBack || newBack { - t.Fatalf("row %d = %+v is not after old line %d and new line %d", + t.Fatalf("row %d = %+v, want it after old line %d and new line %d", i, r, lastOld, lastNew) } lastOld, lastNew = max(lastOld, r.Old), max(lastNew, r.New) } } +// FuzzRender checks the invariants of Render. It also checks that Count +// agrees with the snippet. func FuzzRender(f *testing.F) { for i, s := range hostile { f.Add(uint8(i), "a\nb\n", s, uint8(i), uint8(i*7), uint8(i%3), i%2 == 0) @@ -115,31 +120,32 @@ func FuzzRender(f *testing.F) { f.Fuzz(func( t *testing.T, p uint8, - old, new string, + before, after string, layout, width, offset uint8, colored bool, ) { o := fuzzOptions(layout, width, offset, colored) - d := diff.Render(paths[int(p)%len(paths)], old, new, o) + d := diff.Render(paths[int(p)%len(paths)], before, after, o) checkLines(t, d, o) - checkRows(t, d.Rows, old, new) - if o.Width == 0 && o.Offset == 0 && !strings.Contains(old+new, "…") { + checkRows(t, d.Rows, before, after) + if o.Width == 0 && o.Offset == 0 && !strings.Contains(before+after, "…") { // With no width limit, no line is cut. for i, l := range d.Lines { cut := strings.Contains(ansi.Strip(l), "…") if cut && d.Rows[i].Kind != diff.KindHunk { - t.Fatalf("line %d is cut with no width limit: %q", i, l) + t.Fatalf("line %d = %q, want no cut with no width limit", i, l) } } } - added, deleted := diff.Count(old, new) + added, deleted := diff.Count(before, after) if (added == 0 && deleted == 0) != (len(d.Lines) == 0) { - t.Fatalf("Count = %d, %d, but Render has %d lines", + t.Fatalf("Count = %d, %d and Render has %d lines, "+ + "want zero counts only for zero lines", added, deleted, len(d.Lines)) } var plus, minus int - for _, l := range snippet(d.Rows, old, new) { + for _, l := range snippet(d.Rows, before, after) { switch l[0] { case '+': plus++ @@ -148,27 +154,31 @@ func FuzzRender(f *testing.F) { } } if plus != added || minus != deleted { - t.Fatalf("the snippet has +%d -%d, Count = +%d -%d", + t.Fatalf("the snippet has +%d -%d, want Count = +%d -%d", plus, minus, added, deleted) } }) } +// FuzzSnippetSameInLayouts checks that the split and the unified layouts give +// the same snippet. func FuzzSnippetSameInLayouts(f *testing.F) { f.Add(oldSrc, newSrc) f.Add("a\nb\nc\nd\n", "x\nb\ny\nz\nd\n") f.Add("\tx\r\n", "\ty\n") - f.Fuzz(func(t *testing.T, old, new string) { - u := diff.Render("x.txt", old, new, diff.Options{}) + f.Fuzz(func(t *testing.T, before, after string) { + u := diff.Render("x.txt", before, after, diff.Options{}) o := diff.Options{Split: true, Width: 30, Wrap: true} - s := diff.Render("x.txt", old, new, o) - got, want := snippet(s.Rows, old, new), snippet(u.Rows, old, new) + s := diff.Render("x.txt", before, after, o) + got, want := snippet(s.Rows, before, after), snippet(u.Rows, before, after) if !slices.Equal(got, want) { - t.Fatalf("split snippet %q, unified snippet %q", got, want) + t.Fatalf("split snippet = %q, want the unified snippet %q", + got, want) } }) } +// FuzzRenderFile checks that RenderFile gives one row for each source line. func FuzzRenderFile(f *testing.F) { for i, s := range hostile { f.Add(uint8(i), s, uint8(i), uint8(i*7), uint8(i%3), i%2 == 0) @@ -193,52 +203,39 @@ func FuzzRenderFile(f *testing.F) { } } if want := len(diff.Lines(src)); n != want { - t.Fatalf("%d rows for a file of %d lines", n, want) - } - }) -} - -func FuzzPlain(f *testing.F) { - for _, s := range hostile { - f.Add(s) - } - f.Fuzz(func(t *testing.T, s string) { - p := diff.Plain(s) - if strings.ContainsFunc(p, isUnsafeControl) { - t.Errorf("Plain(%q) = %q, has a control character", s, p) - } - if pp := diff.Plain(p); pp != p { - t.Errorf("Plain is not idempotent: %q, then %q", p, pp) - } - q := diff.Quote(s) - if strings.ContainsFunc(q, unicode.IsControl) || !utf8.ValidString(q) { - t.Errorf("Quote(%q) = %q, has a control character", s, q) + t.Fatalf("RenderFile gives %d rows, want %d", n, want) } }) } +// FuzzCount checks that the counts of Count are in the range of the line +// counts. func FuzzCount(f *testing.F) { for _, s := range hostile { f.Add(s, "a\n") } f.Fuzz(func(t *testing.T, a, b string) { if added, deleted := diff.Count(a, a); added != 0 || deleted != 0 { - t.Errorf("Count(s, s) = %d, %d", added, deleted) + t.Errorf("Count(%q, %q) = %d, %d, want 0, 0", a, a, added, deleted) } na, nb := len(diff.Lines(a)), len(diff.Lines(b)) if added, deleted := diff.Count("", a); added != na || deleted != 0 { - t.Errorf("Count(\"\", %q) = %d, %d; want %d, 0", a, added, deleted, na) + t.Errorf("Count(\"\", %q) = %d, %d, want %d, 0", + a, added, deleted, na) } if added, deleted := diff.Count(a, ""); added != 0 || deleted != na { - t.Errorf("Count(%q, \"\") = %d, %d; want 0, %d", a, added, deleted, na) + t.Errorf("Count(%q, \"\") = %d, %d, want 0, %d", + a, added, deleted, na) } added, deleted := diff.Count(a, b) if added < 0 || deleted < 0 || added > nb || deleted > na { - t.Errorf("Count(%q, %q) = %d, %d", a, b, added, deleted) + t.Errorf("Count(%q, %q) = %d, %d, want 0-%d, 0-%d", + a, b, added, deleted, nb, na) } }) } +// FuzzComments checks that the review text has no control characters. func FuzzComments(f *testing.F) { for i, s := range hostile { f.Add("a\nb\nc\n", s, "a.go", s, uint8(i), uint8(i%3)) @@ -259,27 +256,21 @@ func FuzzComments(f *testing.F) { s := int(start) % len(lines) e := min(s+int(size), len(lines)-1) var cs diff.Comments - code := lines[s : e+1] - c := cs.Add(commentOn(path, diff.SideNew, before, s+1, e+1)) + c := commentOn(path, diff.SideNew, before, s+1, e+1) c.Text = text - cs.Add(comment(path, diff.SideOld, before, after, text)) - - cs.Relocate(path, before, after) - n := len(diff.Lines(after)) - badRange := c.Start < 1 || c.Start > c.End - if badRange || (!c.Stale && c.End > n) { - t.Errorf("after Relocate: %+v, the file has %d lines", c, n) - } - if !c.Stale && !slices.Equal(diff.Lines(after)[c.Start-1:c.End], code) { - t.Errorf("after Relocate: lines %d-%d are not the code", c.Start, c.End) + if c.Start != s+1 || c.End != e+1 { + t.Errorf("commentOn lines %d-%d = %+v, want lines %d-%d", + s+1, e+1, c, s+1, e+1) } + cs.Add(c) + cs.Add(comment(path, diff.SideOld, before, after, text)) r := cs.Review(text) if strings.ContainsFunc(r, isUnsafeControl) { - t.Errorf("Review has a control character: %q", r) + t.Errorf("Review = %q, want no control characters", r) } for _, c := range cs.All() { if l := c.Location(); strings.ContainsFunc(l, unicode.IsControl) { - t.Errorf("Location = %q, has a control character", l) + t.Errorf("Location = %q, want no control characters", l) } } }) diff --git a/diff/hostile_test.go b/diff/hostile_test.go index 09fd7bf..9ac0746 100644 --- a/diff/hostile_test.go +++ b/diff/hostile_test.go @@ -10,10 +10,10 @@ import ( "github.com/rednafi/rexdiff/diff" ) -// TestHostileContent renders each hostile file in all layouts. No control -// character from the file can reach the terminal, and the rows must point -// at real lines. +// TestHostileContent checks the lines and the rows of each hostile file, in +// all layouts. func TestHostileContent(t *testing.T) { + t.Parallel() opts := []diff.Options{ {}, {Width: 20}, @@ -41,8 +41,10 @@ func TestHostileContent(t *testing.T) { // TestEscapesAreVisible checks that an escape in a file shows as a symbol, // so that the reader sees it. func TestEscapesAreVisible(t *testing.T) { + t.Parallel() tests := []struct{ src, want string }{ {"\x1b]52;c;aGk=\x07\n", "␛]52;c;aGk=␇"}, + {"a\x1b[31mb\n", "a␛[31mb"}, {"\x1b]7501;state=idle\x1b\\\n", "␛]7501;state=idle␛\\"}, {"a\x00b\n", "a␀b"}, {"\u009b31m\n", "\ufffd31m"}, @@ -50,7 +52,7 @@ func TestEscapesAreVisible(t *testing.T) { } for _, tt := range tests { d := diff.Render("x.txt", "", tt.src, diff.Options{}) - if got := ansi.Strip(d.Lines[1]); !strings.Contains(got, tt.want) { + if got := plainLine(t, d, 1); !strings.Contains(got, tt.want) { t.Errorf("Render(%q) = %q, want it to show %q", tt.src, got, tt.want) } f := diff.RenderFile("x.txt", tt.src, diff.Options{}) @@ -64,6 +66,7 @@ func TestEscapesAreVisible(t *testing.T) { // TestWrapWideText checks that wrapped CJK and emoji text fits the width // and that no character is lost. func TestWrapWideText(t *testing.T) { + t.Parallel() line := strings.Repeat("日本語😀", 10) for _, o := range []diff.Options{ {Width: 25, Wrap: true}, @@ -79,21 +82,16 @@ func TestWrapWideText(t *testing.T) { } got := strings.Join(strings.Fields(text.String()), "") if !strings.Contains(got, line) { - t.Errorf("split %v: the wrapped lines lose text: %q", o.Split, got) + t.Errorf("split %v: the wrapped lines = %q, want all of %q", + o.Split, got, line) } } } -// TestTabToSpacesIsAChange checks that a diff shows a change of a tab to -// spaces, as Count and git do. -func TestTabToSpacesIsAChange(t *testing.T) { - d := diff.Render("x.go", "\tx\n", " x\n", diff.Options{}) - if got := kinds(d.Rows); got != "@-+" { - t.Errorf("kinds = %q, want %q", got, "@-+") - } -} - +// TestRenderFileLineCount checks the number of rows for each kind of line +// ending. func TestRenderFileLineCount(t *testing.T) { + t.Parallel() tests := []struct { src string rows int @@ -115,29 +113,19 @@ func TestRenderFileLineCount(t *testing.T) { } } +// TestRenderFileNarrow checks that wide text fits a width of 1 to 7 cells. func TestRenderFileNarrow(t *testing.T) { + t.Parallel() for w := 1; w < 8; w++ { o := diff.Options{Width: w, Wrap: true} checkLines(t, diff.RenderFile("x.txt", "日本\n 0000\n", o), o) } } -func TestQuote(t *testing.T) { - tests := []struct{ in, want string }{ - {"a.go", "a.go"}, - {"日本.go", "日本.go"}, - {"a\x1b]0;x\x07.go", `"a\x1b]0;x\a.go"`}, - {"a\n.go", `"a\n.go"`}, - {"a\x9b31m.go", `"a\x9b31m.go"`}, - } - for _, tt := range tests { - if got := diff.Quote(tt.in); got != tt.want { - t.Errorf("Quote(%q) = %s, want %s", tt.in, got, tt.want) - } - } -} - +// TestReviewHostileComment checks that Review quotes a hostile path and +// removes the control characters from the code and the text. func TestReviewHostileComment(t *testing.T) { + t.Parallel() c := comment("a\x1b]52;c;aGk=\x07.go", diff.SideNew, "", "x\x1b[2J\n", @@ -149,15 +137,16 @@ func TestReviewHostileComment(t *testing.T) { } } -// TestBlackTint renders a theme whose tint of deleted lines is black. An -// equal line with the same text as a deleted line has no background. +// TestBlackTint checks that an equal line with the text of a deleted line has +// no background when the tint of deleted lines is black. func TestBlackTint(t *testing.T) { + t.Parallel() black := color.RGBA{A: 255} th := diff.Theme{Background: black, Foreground: color.White, Red: black} d := diff.Render("a.txt", "x\ny\nx\n", "y\nx\n", diff.Options{Theme: th}) for i, r := range d.Rows { if r.Kind == diff.KindEqual && strings.Contains(d.Lines[i], "48;2;0;0;0mx") { - t.Errorf("equal line %d has the background of a deleted line: %q", + t.Errorf("equal line %d = %q, want no background of a deleted line", r.New, d.Lines[i]) } } diff --git a/diff/main_test.go b/diff/main_test.go new file mode 100644 index 0000000..b37f0ec --- /dev/null +++ b/diff/main_test.go @@ -0,0 +1,9 @@ +package diff_test + +import ( + "testing" + + "github.com/rednafi/rexdiff/internal/testgit" +) + +func TestMain(m *testing.M) { testgit.Main(m) } diff --git a/diff/review.go b/diff/review.go index e00f640..4fa69f6 100644 --- a/diff/review.go +++ b/diff/review.go @@ -1,15 +1,16 @@ package diff import ( - "strconv" "strings" - "unicode" - "unicode/utf8" + + "github.com/rednafi/rexdiff/internal/termtext" ) // Review returns the comments as herdr-reviewr text for a coding agent. It // writes the prefix, if not empty, then the comments in [Comments.All] order. -// Blank lines separate them. The text has no control characters. A comment is: +// Blank lines separate them. The text has no control characters. +// +// This is an example of a comment: // // a.go:3-4 // x := 1 @@ -17,7 +18,7 @@ import ( // Merge these lines. func (cs *Comments) Review(prefix string) string { var parts []string - if p := strings.TrimRight(Plain(prefix), " \n\t"); p != "" { + if p := strings.TrimRight(termtext.Plain(prefix), " \n\t"); p != "" { parts = append(parts, p) } for _, c := range cs.All() { @@ -29,14 +30,13 @@ func (cs *Comments) Review(prefix string) string { // format returns c as text for [Comments.Review]. func format(c *Comment) string { var b strings.Builder - b.WriteString(Plain(c.Location())) - if c.Stale { - b.WriteString(" (stale: the code has changed since)") - } - // Write the rows as the diff shows them, so that the agent reads a real diff. + b.WriteString(termtext.Plain(c.Location())) + // Write the rows as the diff shows them, so that the agent reads a real + // diff. if len(c.snippet) > 0 { for _, l := range c.snippet { - b.WriteString("\n" + Plain(l)) + b.WriteString("\n") + b.WriteString(termtext.Plain(l)) } } else { mark := "+" @@ -44,44 +44,25 @@ func format(c *Comment) string { mark = "-" } for _, l := range c.code { - b.WriteString("\n" + mark + Plain(l)) + b.WriteString("\n") + b.WriteString(mark) + b.WriteString(termtext.Plain(l)) } } var text []string - for l := range strings.SplitSeq(Plain(c.Text), "\n") { + for l := range strings.SplitSeq(termtext.Plain(c.Text), "\n") { if strings.TrimSpace(l) != "" { text = append(text, strings.TrimRight(l, " \t")) } } - b.WriteString("\n" + strings.Join(text, "\n")) + b.WriteString("\n") + b.WriteString(strings.Join(text, "\n")) return b.String() } -// Plain returns s without control characters. This removes the escape -// sequences too, but keeps tabs and newlines. Use it on text from other -// people before you show or send it. -func Plain(s string) string { - return strings.Map(func(r rune) rune { - if r == '\t' || r == '\n' || !unicode.IsControl(r) { - return r - } - return -1 - }, s) -} - -// Quote returns s in Go quotes if it has control characters or is not valid -// UTF-8. git shows such a path the same way. Then s stays on one line and -// cannot change the terminal. -func Quote(s string) string { - if strings.ContainsFunc(s, unicode.IsControl) || !utf8.ValidString(s) { - return strconv.Quote(s) - } - return s -} - // snippet returns rows as unified diff lines. See [NewComment]. -func snippet(rows []Row, old, new string) []string { - oldLines, newLines := Lines(old), Lines(new) +func snippet(rows []Row, before, after string) []string { + oldLines, newLines := Lines(before), Lines(after) line := func(ls []string, n int) string { if n >= 1 && n-1 < len(ls) { return ls[n-1] diff --git a/diff/theme.go b/diff/theme.go index 40510c6..b3025e5 100644 --- a/diff/theme.go +++ b/diff/theme.go @@ -2,8 +2,6 @@ package diff import ( "image/color" - - "github.com/rednafi/rexdiff/internal/diffview" ) // Theme sets the colours of the output. The lightness of Background selects @@ -13,7 +11,7 @@ type Theme struct { // if the terminal does not report them. Then the diff tints no background. Background, Foreground color.Color // Green and Red are the ANSI colours 2 and 1 of the terminal. Inserted and - // deleted lines get a tint toward them. If they are nil, the diff uses + // deleted lines get a tint towards them. If they are nil, the diff uses // fixed colours. Green, Red color.Color } @@ -29,9 +27,3 @@ func (t Theme) dark() bool { // The HSL lightness, from 0 to 1. return float64(hi+lo)/2/0xffff < 0.5 } - -// apply sets the colours of t on dv. The syntax colours come from the -// terminal palette of 16 colours. -func (t Theme) apply(dv *diffview.View) *diffview.View { - return dv.Style(diffview.TerminalStyle(t.Background, t.Foreground, t.Green, t.Red, t.dark())) -} diff --git a/go.mod b/go.mod index 4d0e120..d1ffb2a 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/rednafi/rexdiff -go 1.27.2 +go 1.27.3 require ( charm.land/bubbles/v2 v2.2.1 @@ -11,6 +11,8 @@ require ( github.com/aymanbagabas/go-udiff v0.4.1 github.com/charmbracelet/ultraviolet v0.0.0-20261008173134-6b8d4baf91b4 github.com/charmbracelet/x/ansi v0.11.9 + github.com/charmbracelet/x/vt v0.1.0 + github.com/creack/pty v1.1.24 github.com/rogpeppe/go-internal v1.16.0 github.com/sahilm/fuzzy v0.1.3 ) @@ -19,6 +21,7 @@ require ( github.com/atotto/clipboard v0.1.4 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/charmbracelet/colorprofile v0.4.3 // indirect + github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect github.com/charmbracelet/x/exp/slice v0.1.0 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect github.com/charmbracelet/x/termios v0.1.1 // indirect @@ -33,11 +36,11 @@ require ( github.com/muesli/cancelreader v0.2.2 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/xo/terminfo v1.0.0 // indirect - github.com/yuin/goldmark v1.7.8 // indirect + github.com/yuin/goldmark v1.8.6 // indirect github.com/yuin/goldmark-emoji v1.0.5 // indirect - golang.org/x/net v0.42.0 // indirect - golang.org/x/sync v0.22.0 // indirect - golang.org/x/sys v0.47.0 // indirect - golang.org/x/text v0.27.0 // indirect - golang.org/x/tools v0.35.0 // indirect + golang.org/x/net v0.59.0 // indirect + golang.org/x/sync v0.24.0 // indirect + golang.org/x/sys v0.48.0 // indirect + golang.org/x/text v0.43.0 // indirect + golang.org/x/tools v0.51.0 // indirect ) diff --git a/go.sum b/go.sum index b925765..27a9607 100644 --- a/go.sum +++ b/go.sum @@ -28,18 +28,24 @@ github.com/charmbracelet/x/ansi v0.11.9 h1:kIrqLBxtTVbLyyHO2Evoi93EjQo1mylWmpNEs github.com/charmbracelet/x/ansi v0.11.9/go.mod h1:DG/hpOnaI2QJ/KMMqLL2h0KVhluuJLazf8973P+Vy5w= github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA= github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= +github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE= +github.com/charmbracelet/x/exp/ordered v0.1.0/go.mod h1:5UHwmG+is5THxMyCJHNPCn2/ecI07aKNrW+LcResjJ8= github.com/charmbracelet/x/exp/slice v0.1.0 h1:m46dwuDf3M5SFYMRhxYkLC6z9WnENGVKbcbJlp13/og= github.com/charmbracelet/x/exp/slice v0.1.0/go.mod h1:vqEfX6xzqW1pKKZUUiFOKg0OQ7bCh54Q2vR/tserrRA= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/vt v0.1.0 h1:oVueL3dwH/DlY/z6R6XHA9PQ/w3LdCCq99600Vs48vA= +github.com/charmbracelet/x/vt v0.1.0/go.mod h1:u1LOIABor9JqY54oZdktK3TCRrgzP6tzHrDYx1nd3wY= github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/dlclark/regexp2/v2 v2.2.1 h1:mf4KkFUj0gJuarK8P+LgiS+Lit7m9N1yAwEfPbee7R0= github.com/dlclark/regexp2/v2 v2.2.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= @@ -65,19 +71,19 @@ github.com/sahilm/fuzzy v0.1.3/go.mod h1:au6//VbVSqu6DFrkL2CfjlJ5iURpNCPeE+1GwY3 github.com/xo/terminfo v1.0.0 h1:2ZpYzqWzyyytjk3TP6aJVDhkMAkc99/1xKQdA3TDTBY= github.com/xo/terminfo v1.0.0/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= -github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic= -github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= +github.com/yuin/goldmark v1.8.6 h1:d0VcaP1sx9GkFVkoW+KtggpGi2KZ965i14b0+bDQST4= +github.com/yuin/goldmark v1.8.6/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk= github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= -golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= -golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= -golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= -golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= -golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= -golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= -golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0= -golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw= +golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues= +golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= +golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA= +golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc= +golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s= +golang.org/x/tools v0.51.0 h1:k4Xc/1Om9jwkBJBo4NVLMSARBoWtK10mx+W5BnXCeAI= +golang.org/x/tools v0.51.0/go.mod h1:9eEncMayCV6zRMGhR5eZEC2iBx98qWcF1HZ9Z7wJOoA= diff --git a/internal/DERIVED.md b/internal/DERIVED.md new file mode 100644 index 0000000..7b6d389 --- /dev/null +++ b/internal/DERIVED.md @@ -0,0 +1,12 @@ +# Derived from charmbracelet/crush + +Parts of `internal/diffview` derive from charmbracelet/crush. + +- Source: https://github.com/charmbracelet/crush @ 17fccc0c6d4289f159384d3aaa7315ef1f71015b + (`internal/ui/diffview`, `internal/ansiext`, `internal/ui/xchroma`). +- License: FSL-1.1-MIT. Copyright 2025-2026 Charmbracelet, Inc. See `LICENSE-crush.md`. +- Derived files: `view.go`, `render.go`, `highlight.go`, `style.go`, `split.go`, and + `text.go`. Each one has a notice in its header comment. `fold.go` is not derived. + +The code is now rexdiff's. It changes as rexdiff needs, and this file does not record +each change. diff --git a/internal/VENDORED.md b/internal/VENDORED.md deleted file mode 100644 index 8de5730..0000000 --- a/internal/VENDORED.md +++ /dev/null @@ -1,35 +0,0 @@ -# Vendored from charmbracelet/crush - -- Source: https://github.com/charmbracelet/crush @ 17fccc0c6d4289f159384d3aaa7315ef1f71015b -- Paths: internal/ui/diffview, internal/ansiext, internal/ui/xchroma, all merged into - `internal/diffview`, because they only serve the diff view -- License: FSL-1.1-MIT (see LICENSE-crush.md). Copyright Charmbracelet, Inc. -- Local changes: - - package and import paths (module `github.com/rednafi/rexdiff`) - - `escape.go` is crush's `ansiext/ansi.go`; `chroma.go` is crush's `xchroma/chroma.go` - - `rows.go` (added): the row to source line mapping (with the continued rows of wrapped - lines) and the column geometry, which `Render` returns with the text - - `View` is crush's `DiffView`, and `Render` is its `String` - - `terminal.go` (added): `TerminalStyle`, the colours from the terminal palette, and the - `Wrap` option - - `ansi.go` (added): a chroma formatter that uses the ANSI palette colours - - `diffview.go`: the code is always highlighted with the formatter of - `ansi.go` (crush's `ChromaStyle` and chroma style formatter are removed); - `Lexer`, the lexer selection that `diff.RenderFile` shares; the - syntax cache key is a struct, not an xxh3 hash; `New` sets no default style; the code - lines of the two renderers go through one `codeLines`, which cuts or wraps a line, and - the split renderer builds each side with one `splitSide` method; named constants - for the symbols and the ellipsis; smaller render helpers - - `split.go`: uses `slices` in place of `charmbracelet/x/exp/slice`; `takeInsert` finds - the inserted line beside a deleted line - - `chroma.go`: only the lexer cache `matchLexer` and `renderLines`, which - the ANSI formatter uses; crush's `Formatter` and its token loop are removed - - `style.go`: `DefaultDarkStyle` and `DefaultLightStyle` removed (and with them - `charmtone`); the `diff` package always uses `TerminalStyle` - - API cut to what `diff` uses: removed `ChromaStyle`, `Formatter`, `Unified`, - `FileName` (and the file name header), `ContextLines`, `LineNumbers`, - `Height`, `YOffset`, `InfiniteYScroll` (and the height cut and the scroll - code); `MatchLexer`, `LineStyle` and the `Style` - fields are unexported; `RowFile` removed - - the syntax cache key tells "no background" from black, so a black tint does not - leak onto equal lines diff --git a/internal/clipboard/clipboard.go b/internal/clipboard/clipboard.go new file mode 100644 index 0000000..7aee185 --- /dev/null +++ b/internal/clipboard/clipboard.go @@ -0,0 +1,46 @@ +// Package clipboard writes text to the system clipboard through the first +// clipboard tool on PATH, as herdr-reviewr does. The caller gives the +// environment of the tool, with its PATH. +package clipboard + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/rednafi/rexdiff/internal/proc" +) + +// ErrNoTool tells that PATH has no clipboard tool. The user can fix it. +var ErrNoTool = errors.New("no clipboard tool is installed (pbcopy, wl-clipboard, xclip, or xsel)") + +// tools are the clipboard tools that read stdin. The first on PATH wins. +var tools = [][]string{ + {"pbcopy"}, + {"wl-copy"}, + {"xclip", "-selection", "clipboard"}, + {"xsel", "--clipboard", "--input"}, +} + +// Write pipes text into the first clipboard tool on the PATH of env. The +// tool runs with the environment env. If PATH has no clipboard tool, Write +// returns [ErrNoTool]. Write fails if the tool exits with a status that is +// not zero. If ctx is done, the error has the text of [context.Cause]. +func Write(ctx context.Context, env []string, text string) error { + for _, t := range tools { + if _, err := proc.LookPath(env, t[0]); err != nil { + continue + } + cmd := proc.Command(ctx, env, t[0], t[1:]...) + cmd.Stdin = strings.NewReader(text) + // The output goes to the null device. xclip and wl-copy leave a + // process that keeps the clipboard, and a pipe stays open while it + // runs. Thus the error has no stderr. + if err := cmd.Run(); err != nil { + return fmt.Errorf("%s: %w", t[0], proc.Error(ctx, t[0], err, nil)) + } + return nil + } + return ErrNoTool +} diff --git a/internal/clipboard/clipboard_test.go b/internal/clipboard/clipboard_test.go new file mode 100644 index 0000000..7a840a7 --- /dev/null +++ b/internal/clipboard/clipboard_test.go @@ -0,0 +1,67 @@ +package clipboard_test + +import ( + "errors" + "os" + "path/filepath" + "testing" + + "github.com/rednafi/rexdiff/internal/clipboard" + "github.com/rednafi/rexdiff/internal/testgit" +) + +// fakeTool writes an executable script name in a new directory. It +// returns the directory, and an environment with a PATH of only that +// directory. +func fakeTool(t *testing.T, name, script string) (string, []string) { + t.Helper() + dir := t.TempDir() + testgit.Script(t, dir, name, script) + return dir, []string{"PATH=" + dir} +} + +// TestWrite checks that the first tool on PATH gets the text and the +// arguments. +func TestWrite(t *testing.T) { + t.Parallel() + tests := []struct{ name, want string }{ + {"pbcopy", "hi\n"}, + {"wl-copy", "hi\n"}, + {"xclip", "-selection|clipboard|hi\n"}, + {"xsel", "--clipboard|--input|hi\n"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + // The script writes each argument and a bar, then the input. + dir, env := fakeTool(t, tt.name, `{ for a; do printf '%s|' "$a"; done; /bin/cat; } > "${0%/*}/out"`+"\n") + if err := clipboard.Write(t.Context(), env, "hi\n"); err != nil { + t.Fatalf("Write: %v", err) + } + got, err := os.ReadFile(filepath.Join(dir, "out")) + if err != nil { + t.Fatal(err) + } + if string(got) != tt.want { + t.Errorf("Write(hi) with %s: output = %q, want %q", + tt.name, got, tt.want) + } + }) + } +} + +// TestWriteFails checks the errors with no tool, and with a tool that +// fails. +func TestWriteFails(t *testing.T) { + t.Parallel() + for _, env := range [][]string{nil, {"PATH=" + t.TempDir()}} { + if err := clipboard.Write(t.Context(), env, "x"); !errors.Is(err, clipboard.ErrNoTool) { + t.Errorf("no tool in %q: Write = %v, want ErrNoTool", env, err) + } + } + _, env := fakeTool(t, "pbcopy", "exit 1\n") + err := clipboard.Write(t.Context(), env, "x") + if err == nil || errors.Is(err, clipboard.ErrNoTool) { + t.Errorf("pbcopy exits 1: Write = %v, want an error that is not ErrNoTool", err) + } +} diff --git a/internal/diffview/ansi.go b/internal/diffview/ansi.go deleted file mode 100644 index ef75c80..0000000 --- a/internal/diffview/ansi.go +++ /dev/null @@ -1,67 +0,0 @@ -package diffview - -import ( - "image/color" - "io" - - "charm.land/lipgloss/v2" - "github.com/alecthomas/chroma/v2" -) - -// The colours of the ANSI palette of the terminal, as Lip Gloss colours. -const ( - ansiRed = "1" - ansiGreen = "2" - ansiYellow = "3" - ansiBlue = "4" - ansiMagenta = "5" - ansiCyan = "6" - ansiGrey = "8" // bright black -) - -// ansiFor returns the ANSI palette colour of a token type. If ok is false, -// the token has the default foreground of the terminal. -func ansiFor(t chroma.TokenType) (c string, italic, ok bool) { - switch { - case t.InCategory(chroma.Comment): - return ansiGrey, true, true - case t == chroma.KeywordType, t == chroma.NameBuiltin, - t.InSubCategory(chroma.NameClass): - return ansiCyan, false, true - case t.InCategory(chroma.Keyword): - return ansiMagenta, false, true - case t.InSubCategory(chroma.LiteralString): - return ansiGreen, false, true - case t.InSubCategory(chroma.LiteralNumber), t == chroma.NameConstant: - return ansiYellow, false, true - case t == chroma.NameFunction, t == chroma.NameFunctionMagic, - t == chroma.NameTag, t == chroma.NameAttribute: - return ansiBlue, false, true - } - return "", false, false -} - -// ANSIFormatter returns a chroma formatter that colours the tokens with the -// ANSI palette colours, on the background bgColor. It ignores the chroma -// style. If processValue is not nil, it changes the text of each token -// first. -func ANSIFormatter(bgColor color.Color, processValue func(string) string) chroma.Formatter { - format := func(w io.Writer, _ *chroma.Style, it chroma.Iterator) error { - plain := lipgloss.NewStyle().Background(bgColor) - for token := it(); token != chroma.EOF; token = it() { - value := token.Value - if processValue != nil { - value = processValue(value) - } - s := plain - if c, italic, ok := ansiFor(token.Type); ok { - s = plain.Foreground(lipgloss.Color(c)).Italic(italic) - } - if _, err := io.WriteString(w, renderLines(s, value)); err != nil { - return err - } - } - return nil - } - return chroma.FormatterFunc(format) -} diff --git a/internal/diffview/chroma.go b/internal/diffview/chroma.go deleted file mode 100644 index 4dfd7e7..0000000 --- a/internal/diffview/chroma.go +++ /dev/null @@ -1,52 +0,0 @@ -package diffview - -import ( - "strings" - "sync" - - "charm.land/lipgloss/v2" - "github.com/alecthomas/chroma/v2" - "github.com/alecthomas/chroma/v2/lexers" -) - -// lexerCache keeps the lexer of each file name, or nil if none matches. To -// match a name is slow, because lexers.Match tests the patterns of each -// lexer. The mutex makes the cache safe for more than one goroutine. -var ( - lexerCacheMu sync.RWMutex - lexerCache = map[string]chroma.Lexer{} -) - -// matchLexer returns the coalesced chroma lexer for the file name, or nil if -// no lexer matches. The lexer is safe to use again. -func matchLexer(fileName string) chroma.Lexer { - lexerCacheMu.RLock() - l, ok := lexerCache[fileName] - lexerCacheMu.RUnlock() - if ok { - return l - } - if l = lexers.Match(fileName); l != nil { - l = chroma.Coalesce(l) - } - lexerCacheMu.Lock() - lexerCache[fileName] = l - lexerCacheMu.Unlock() - return l -} - -// renderLines styles each line of value alone, and joins them with plain -// newlines. A token can contain newlines. For example, a chroma -// CommentSingle ends with one. Lip Gloss pads all lines of a multi-line -// string to one width. Without renderLines, this padding adds spaces to -// the line after a comment. -func renderLines(s lipgloss.Style, value string) string { - if !strings.Contains(value, "\n") { - return s.Render(value) - } - lines := strings.Split(value, "\n") - for i, line := range lines { - lines[i] = s.Render(line) - } - return strings.Join(lines, "\n") -} diff --git a/internal/diffview/diffview.go b/internal/diffview/diffview.go deleted file mode 100644 index 5ecc547..0000000 --- a/internal/diffview/diffview.go +++ /dev/null @@ -1,578 +0,0 @@ -// Package diffview draws a diff in the terminal. The code comes from -// charmbracelet/crush, with changes for rexdiff. See ../VENDORED.md. -package diffview - -import ( - "fmt" - "image/color" - "slices" - "strconv" - "strings" - - "charm.land/lipgloss/v2" - "github.com/alecthomas/chroma/v2" - "github.com/alecthomas/chroma/v2/lexers" - "github.com/aymanbagabas/go-udiff" - "github.com/charmbracelet/x/ansi" -) - -const ( - leadingSymbolsSize = 2 // the width of a symbol, such as symInsert - lineNumPadding = 1 // the space on each side of a line number - defaultTabWidth = 8 - ellipsis = "…" // shows where a line is cut -) - -// The symbols before the code of a line. -const ( - symNone = " " // an equal line, a missing line, or a continued line - symInsert = "+ " - symDelete = "- " -) - -type file struct { - path string - content string -} - -type layout int - -const ( - layoutUnified layout = iota + 1 - layoutSplit -) - -// View draws the difference between two versions of a file. -type View struct { - layout layout - before file - after file - width int - xOffset int - style Style - tabWidth int - wrap bool // wrap long lines instead of cutting them - - isComputed bool - err error - unified udiff.UnifiedDiff - - splitHunks []splitHunk - - codeWidth int - fullCodeWidth int // with the leading symbols - extraColOnAfter bool // the new side of the split layout is one cell wider - beforeNumDigits int - afterNumDigits int - heights []int // the number of screen lines of each row, for rows - - // The View keeps the lexer, because to match a file name is slow. - cachedLexer chroma.Lexer - - // syntaxCache keeps the highlighted lines, so that the View highlights - // each equal line once. - syntaxCache map[syntaxKey]string -} - -// New returns a View with the unified layout and a tab width of 8. -func New() *View { - return &View{ - layout: layoutUnified, - tabWidth: defaultTabWidth, - syntaxCache: make(map[syntaxKey]string), - } -} - -// Split sets the split layout: the old lines on the left, the new lines on -// the right. -func (v *View) Split() *View { - v.layout = layoutSplit - return v -} - -// Before sets the old version of the file. -func (v *View) Before(path, content string) *View { - v.before = file{path: path, content: content} - v.clearCaches() - return v -} - -// After sets the new version of the file. -func (v *View) After(path, content string) *View { - v.after = file{path: path, content: content} - v.clearCaches() - return v -} - -// clearCaches clears the lexer, the syntax cache, and the diff. Call it when -// the content changes. -func (v *View) clearCaches() { - v.cachedLexer = nil - clear(v.syntaxCache) - v.isComputed = false -} - -// Style sets the colours of the lines. -func (v *View) Style(style Style) *View { - v.style = style - return v -} - -// Width sets the maximum width of the output, in cells. Zero means no limit. -func (v *View) Width(width int) *View { - v.width = width - return v -} - -// XOffset sets the number of cells to scroll the code to the right. -func (v *View) XOffset(xOffset int) *View { - v.xOffset = xOffset - return v -} - -// TabWidth sets the number of spaces that replace a tab. -func (v *View) TabWidth(tabWidth int) *View { - v.tabWidth = tabWidth - return v -} - -// Render returns the rendered diff, one row for each of its lines, and the -// columns of the line numbers. -func (v *View) Render() (string, []Row, Geometry) { - out := v.text() - return out, v.rows(), v.geometry() -} - -// text returns the rendered diff. It also sets the fields that rows and -// geometry use. -func (v *View) text() string { - v.heights = v.heights[:0] - v.normalizeLineEndings() - if err := v.computeDiff(); err != nil { - return err.Error() - } - v.replaceTabs() - v.convertDiffToSplit() - v.adjustStyles() - v.detectNumDigits() - - if v.width <= 0 { - v.detectCodeWidth() - } else { - v.resizeCodeWidth() - } - - style := lipgloss.NewStyle() - if v.width > 0 { - style = style.MaxWidth(v.width) - } - if v.layout == layoutSplit { - return style.Render(strings.TrimSuffix(v.renderSplit(), "\n")) - } - return style.Render(strings.TrimSuffix(v.renderUnified(), "\n")) -} - -// normalizeLineEndings changes CRLF line endings to LF. -func (v *View) normalizeLineEndings() { - v.before.content = strings.ReplaceAll(v.before.content, "\r\n", "\n") - v.after.content = strings.ReplaceAll(v.after.content, "\r\n", "\n") -} - -// replaceTabs replaces the tabs in the lines of the diff with spaces. It runs -// after computeDiff, so that a change from a tab to spaces is a change. -func (v *View) replaceTabs() { - spaces := strings.Repeat(" ", v.tabWidth) - for _, h := range v.unified.Hunks { - for i := range h.Lines { - h.Lines[i].Content = strings.ReplaceAll(h.Lines[i].Content, "\t", spaces) - } - } -} - -// computeDiff computes the diff of the two versions once. Then it returns -// the error of the first call. -func (v *View) computeDiff() error { - if v.isComputed { - return v.err - } - v.isComputed = true - edits := udiff.Lines(v.before.content, v.after.content) - v.unified, v.err = udiff.ToUnifiedDiff( - v.before.path, - v.after.path, - v.before.content, - edits, - udiff.DefaultContextLines, - ) - return v.err -} - -// convertDiffToSplit makes the split hunks, if the layout is split. -func (v *View) convertDiffToSplit() { - if v.layout != layoutSplit { - return - } - - v.splitHunks = make([]splitHunk, len(v.unified.Hunks)) - for i, h := range v.unified.Hunks { - v.splitHunks[i] = hunkToSplit(h) - } -} - -// adjustStyles adds padding and alignment to the line number styles. -func (v *View) adjustStyles() { - setPadding := func(s lipgloss.Style) lipgloss.Style { - return s.Padding(0, lineNumPadding).Align(lipgloss.Right) - } - v.style.missingLine.lineNumber = setPadding(v.style.missingLine.lineNumber) - v.style.dividerLine.lineNumber = setPadding(v.style.dividerLine.lineNumber) - v.style.equalLine.lineNumber = setPadding(v.style.equalLine.lineNumber) - v.style.insertLine.lineNumber = setPadding(v.style.insertLine.lineNumber) - v.style.deleteLine.lineNumber = setPadding(v.style.deleteLine.lineNumber) -} - -// detectNumDigits finds the number of digits of the largest old and new line -// numbers. -func (v *View) detectNumDigits() { - v.beforeNumDigits = 0 - v.afterNumDigits = 0 - - for _, h := range v.unified.Hunks { - before := len(strconv.Itoa(h.FromLine + len(h.Lines))) - after := len(strconv.Itoa(h.ToLine + len(h.Lines))) - v.beforeNumDigits = max(v.beforeNumDigits, before) - v.afterNumDigits = max(v.afterNumDigits, after) - } -} - -// detectCodeWidth finds the width of the widest code line. -func (v *View) detectCodeWidth() { - switch v.layout { - case layoutUnified: - v.detectUnifiedCodeWidth() - case layoutSplit: - v.detectSplitCodeWidth() - } - v.fullCodeWidth = v.codeWidth + leadingSymbolsSize -} - -// codeWidthOf returns the width of the code of l, plus one cell. -func codeWidthOf(l *udiff.Line) int { - return ansi.StringWidth(Escape(strings.TrimSuffix(l.Content, "\n"))) + 1 -} - -// detectUnifiedCodeWidth finds the width of the widest code line or hunk -// header in the unified layout. -func (v *View) detectUnifiedCodeWidth() { - v.codeWidth = 0 - for _, h := range v.unified.Hunks { - headerWidth := ansi.StringWidth(hunkHeader(h)) - for _, l := range h.Lines { - v.codeWidth = max(v.codeWidth, codeWidthOf(&l), headerWidth) - } - } -} - -// detectSplitCodeWidth finds the width of the widest code line or hunk -// header in the split layout. -func (v *View) detectSplitCodeWidth() { - v.codeWidth = 0 - for i, h := range v.splitHunks { - headerWidth := ansi.StringWidth(hunkHeader(v.unified.Hunks[i])) - for _, l := range h.lines { - if l.before != nil { - v.codeWidth = max(v.codeWidth, codeWidthOf(l.before), headerWidth) - } - if l.after != nil { - v.codeWidth = max(v.codeWidth, codeWidthOf(l.after), headerWidth) - } - } - } -} - -// resizeCodeWidth sets the code width so that the lines fit in the width. -func (v *View) resizeCodeWidth() { - fullNumWidth := v.beforeNumDigits + v.afterNumDigits - fullNumWidth += lineNumPadding * 4 // two sides of two line numbers - - switch v.layout { - case layoutUnified: - v.codeWidth = v.width - fullNumWidth - leadingSymbolsSize - case layoutSplit: - remainingWidth := v.width - fullNumWidth - leadingSymbolsSize*2 - v.codeWidth = remainingWidth / 2 - v.extraColOnAfter = remainingWidth%2 != 0 - } - - v.fullCodeWidth = v.codeWidth + leadingSymbolsSize -} - -// renderUnified renders the unified layout: the old and the new line -// numbers, then the code. -func (v *View) renderUnified() string { - var b strings.Builder - nums := func(ls lineStyle, before, after any) string { - return v.lineNum(ls, before, v.beforeNumDigits) + - v.lineNum(ls, after, v.afterNumDigits) - } - for _, h := range v.unified.Hunks { - b.WriteString(v.unifiedHeader(h) + "\n") - oldN, newN := h.FromLine, h.ToLine - for _, l := range h.Lines { - ls, sym := v.kindStyle(l.Kind) - var num string - switch l.Kind { - case udiff.Equal: - num = nums(ls, oldN, newN) - oldN++ - newN++ - case udiff.Insert: - num = nums(ls, " ", newN) - newN++ - case udiff.Delete: - num = nums(ls, oldN, " ") - oldN++ - } - blank := nums(ls, " ", " ") - v.writeRow(&b, v.codeLines(ls, num, blank, sym, l.Content, v.codeWidth)) - } - } - return b.String() -} - -// unifiedHeader renders the divider line of hunk h in the unified layout. -func (v *View) unifiedHeader(h *udiff.Hunk) string { - div := v.style.dividerLine - header := ansi.Truncate(hunkHeader(h), v.fullCodeWidth, ellipsis) - return div.lineNumber.Render(pad(ellipsis, v.beforeNumDigits)) + - div.lineNumber.Render(pad(ellipsis, v.afterNumDigits)) + - div.code.Width(v.fullCodeWidth).Render(header) -} - -// renderSplit renders the split layout: the old lines on the left, the new -// lines on the right. -func (v *View) renderSplit() string { - var b strings.Builder - afterWidth := v.codeWidth + btoi(v.extraColOnAfter) - for i, h := range v.splitHunks { - b.WriteString(v.splitHeader(v.unified.Hunks[i]) + "\n") - oldN, newN := h.fromLine, h.toLine - for _, l := range h.lines { - left, leftFill := v.splitSide(l.before, oldN, v.beforeNumDigits, v.codeWidth) - right, rightFill := v.splitSide(l.after, newN, v.afterNumDigits, afterWidth) - if l.before != nil { - oldN++ - } - if l.after != nil { - newN++ - } - n := max(len(left), len(right)) - left, right = fillTo(left, n, leftFill), fillTo(right, n, rightFill) - lines := make([]string, n) - for k := range n { - lines[k] = left[k] + right[k] - } - v.writeRow(&b, lines) - } - } - return b.String() -} - -// splitHeader renders the divider line of hunk h in the split layout. The -// header shows on the left side only. -func (v *View) splitHeader(h *udiff.Hunk) string { - div := v.style.dividerLine - header := ansi.Truncate(hunkHeader(h), v.fullCodeWidth, ellipsis) - afterWidth := v.fullCodeWidth + btoi(v.extraColOnAfter) - return div.lineNumber.Render(pad(ellipsis, v.beforeNumDigits)) + - div.code.Width(v.fullCodeWidth).Render(header) + - div.lineNumber.Render(pad(ellipsis, v.afterNumDigits)) + - div.code.Width(afterWidth).Render(" ") -} - -// splitSide renders line l on one side of the split layout, one string for -// each screen line. l is nil if that side has no line, and n is its line -// number. The fill function renders an empty screen line. Use it when the -// other side has more screen lines. -func (v *View) splitSide( - l *udiff.Line, - n, digits, width int, -) (lines []string, fill func() string) { - ls, sym, content := v.style.missingLine, symNone, "" - if l != nil { - ls, sym = v.kindStyle(l.Kind) - content = l.Content - } - noNum := v.lineNum(ls, " ", digits) - num := noNum - if l != nil { - num = v.lineNum(ls, n, digits) - } - fill = func() string { return v.codeLines(ls, noNum, "", symNone, "", width)[0] } - return v.codeLines(ls, num, noNum, sym, content, width), fill -} - -// fillTo adds the line that fill renders to lines, until lines has n lines. -func fillTo(lines []string, n int, fill func() string) []string { - if len(lines) >= n { - return lines - } - return append(lines, slices.Repeat([]string{fill()}, n-len(lines))...) -} - -// kindStyle returns the style and the symbol of a line of kind k. -func (v *View) kindStyle(k udiff.OpKind) (lineStyle, string) { - switch k { - case udiff.Insert: - return v.style.insertLine, symInsert - case udiff.Delete: - return v.style.deleteLine, symDelete - } - return v.style.equalLine, symNone -} - -// writeRow writes the screen lines of one row to b, and keeps their number -// for rows. -func (v *View) writeRow(b *strings.Builder, lines []string) { - for _, l := range lines { - b.WriteString(l) - b.WriteByte('\n') - } - v.heights = append(v.heights, len(lines)) -} - -// lineNum renders the line number n in a column of digits cells. -func (v *View) lineNum(ls lineStyle, n any, digits int) string { - return ls.lineNumber.Render(pad(n, digits)) -} - -// codeLines renders one source line: nums, sym, and the code in width -// cells. It cuts a long line. With wrap, the line continues on more screen -// lines, which show blank in place of nums and no sym. -func (v *View) codeLines( - ls lineStyle, - nums, blank, sym, content string, - width int, -) []string { - code := v.highlightCode(strings.TrimSuffix(content, "\n"), ls.code.GetBackground()) - parts := []string{code} - if v.wrap { - // Wrap can keep a leading space on a full line, so cut again. - w := max(width, 1) - parts = strings.Split(ansi.Hardwrap(lipgloss.Wrap(code, w, ""), w, true), "\n") - for i, p := range parts { - parts[i] = ansi.Truncate(p, w, "") // a wide character can be wider than w - } - } else { - code = ansi.GraphemeWidth.Cut(code, v.xOffset, len(code)) - parts[0] = ansi.Truncate(code, width, ellipsis) - if v.xOffset > 0 && strings.TrimSpace(parts[0]) != "" { - sym = sym[:1] + ellipsis - } - } - full := lipgloss.NewStyle().MaxWidth(width + leadingSymbolsSize) - lines := make([]string, len(parts)) - for i, p := range parts { - if i > 0 { - nums, sym = blank, symNone - } - lines[i] = nums + full.Render(ls.symbol.Render(sym)+ls.code.Width(width).Render(p)) - } - return lines -} - -// hunkHeader returns the header line of hunk h, for example -// " @@ -1,3 +1,4 @@ ". -func hunkHeader(h *udiff.Hunk) string { - before, after := hunkLineCounts(h) - return fmt.Sprintf(" @@ -%d,%d +%d,%d @@ ", h.FromLine, before, h.ToLine, after) -} - -// hunkLineCounts returns the number of old and new lines in hunk h. -func hunkLineCounts(h *udiff.Hunk) (before, after int) { - for _, l := range h.Lines { - switch l.Kind { - case udiff.Equal: - before++ - after++ - case udiff.Insert: - after++ - case udiff.Delete: - before++ - } - } - return before, after -} - -// highlightCode returns source with syntax colours on the background -// bgColor. The syntax cache keeps the result, so highlightCode highlights -// each equal line once. If chroma fails, it returns source as it is. -func (v *View) highlightCode(source string, bgColor color.Color) string { - // Chroma makes a lone CR a line break, so show it as a symbol first. - source = strings.ReplaceAll(source, "\r", "\u240d") - key := newSyntaxKey(source, bgColor) - if cached, ok := v.syntaxCache[key]; ok { - return cached - } - it, err := v.chromaLexer().Tokenise(nil, source) - if err != nil { - return source - } - var b strings.Builder - f := ANSIFormatter(bgColor, processChromaValue) - if err := f.Format(&b, nil, it); err != nil { - return source - } - out := b.String() - v.syntaxCache[key] = out - return out -} - -// syntaxKey is the key of the syntax cache: the source and its background -// colour. noColor is true for no background, because lipgloss.NoColor has -// the same RGBA value as black. -type syntaxKey struct { - source string - noColor bool - r, g, b, a uint32 -} - -// newSyntaxKey returns the cache key of source on the background bgColor. -func newSyntaxKey(source string, bgColor color.Color) syntaxKey { - k := syntaxKey{source: source} - if _, none := bgColor.(lipgloss.NoColor); bgColor == nil || none { - k.noColor = true - return k - } - k.r, k.g, k.b, k.a = bgColor.RGBA() - return k -} - -// chromaLexer returns the lexer of the old file, and keeps it. -func (v *View) chromaLexer() chroma.Lexer { - if v.cachedLexer == nil { - v.cachedLexer = Lexer(v.before.path, v.before.content) - } - return v.cachedLexer -} - -// Lexer returns the coalesced lexer for the file at path. If no lexer -// matches the name, it selects one from the content. If no lexer matches -// the content, it returns the fallback lexer. -func Lexer(path, content string) chroma.Lexer { - // matchLexer caches the lexer, and the lexer is already coalesced. - if l := matchLexer(path); l != nil { - return l - } - l := lexers.Analyse(content) - if l == nil { - l = lexers.Fallback - } - return chroma.Coalesce(l) -} - -// processChromaValue escapes the text of a token for the terminal. A code -// line has no line break, so it removes the trailing ones. -func processChromaValue(value string) string { - return Escape(strings.TrimRight(value, "\n")) -} diff --git a/internal/diffview/escape.go b/internal/diffview/escape.go deleted file mode 100644 index a9f16b1..0000000 --- a/internal/diffview/escape.go +++ /dev/null @@ -1,36 +0,0 @@ -package diffview - -import ( - "strings" - "unicode/utf8" - - "github.com/charmbracelet/x/ansi" -) - -// The Unicode Control Pictures that Escape shows in place of a control -// character. -const ( - controlPictures = '\u2400' // the picture of U+0000; U+0001 to U+001F follow - delPicture = '\u2421' -) - -// Escape replaces each control character with its Unicode Control Picture, -// so the terminal shows it and does not run it. A C1 control becomes -// U+FFFD, because it has no picture. -func Escape(content string) string { - var sb strings.Builder - sb.Grow(len(content)) - for _, r := range content { - switch { - case r >= 0 && r <= 0x1f: // C0 controls - sb.WriteRune(controlPictures + r) - case r == ansi.DEL: - sb.WriteRune(delPicture) - case r >= 0x80 && r <= 0x9f: // C1 controls - sb.WriteRune(utf8.RuneError) - default: - sb.WriteRune(r) - } - } - return sb.String() -} diff --git a/internal/diffview/fold.go b/internal/diffview/fold.go new file mode 100644 index 0000000..b3396fc --- /dev/null +++ b/internal/diffview/fold.go @@ -0,0 +1,72 @@ +package diffview + +import "github.com/aymanbagabas/go-udiff" + +// foldMargin is the number of equal lines that show beside a change. A +// longer run of equal lines folds into one row, as in herdr-reviewr. +const foldMargin = 3 + +// Fold is a run of equal lines that the view hides. Before and After are +// the line numbers of its first line. Lines is the number of lines. Lines +// is 0 if the view hides no line. +type Fold struct { + Before, After, Lines int +} + +// foldHunk splits h into the hunks that show. folds[i] is the run of lines +// that hides above hunks[i]. If the last run hides, the last hunk has no +// lines. A run of equal lines hides if it has more than one line and no +// line of it is within foldMargin of a change. A run does not hide if +// expanded returns true for it. +func foldHunk(h *udiff.Hunk, expanded func(Fold) bool) (hunks []*udiff.Hunk, folds []Fold) { + n := len(h.Lines) + keep := make([]bool, n) + for i, l := range h.Lines { + if l.Kind != udiff.Equal { + for j := max(i-foldMargin, 0); j <= min(i+foldMargin, n-1); j++ { + keep[j] = true + } + } + } + var cur *udiff.Hunk + var above Fold + b, a := h.FromLine, h.ToLine + i := 0 + for i < n { + if !keep[i] { + j := i + for j < n && !keep[j] { + j++ + } + f := Fold{Before: b, After: a, Lines: j - i} + if f.Lines > 1 && (expanded == nil || !expanded(f)) { + above, cur = f, nil + b, a, i = b+f.Lines, a+f.Lines, j + continue + } + for k := i; k < j; k++ { + keep[k] = true // the run shows as a whole + } + } + if cur == nil { + cur = &udiff.Hunk{FromLine: b, ToLine: a} + hunks = append(hunks, cur) + folds = append(folds, above) + above = Fold{} + } + l := h.Lines[i] + cur.Lines = append(cur.Lines, l) + if l.Kind != udiff.Insert { + b++ + } + if l.Kind != udiff.Delete { + a++ + } + i++ + } + if above.Lines > 0 { + hunks = append(hunks, &udiff.Hunk{FromLine: b, ToLine: a}) + folds = append(folds, above) + } + return hunks, folds +} diff --git a/internal/diffview/highlight.go b/internal/diffview/highlight.go new file mode 100644 index 0000000..fff826f --- /dev/null +++ b/internal/diffview/highlight.go @@ -0,0 +1,213 @@ +// Derived from charmbracelet/crush (FSL-1.1-MIT). See ../DERIVED.md. + +package diffview + +import ( + "cmp" + "image/color" + "io" + "strings" + "sync" + + "charm.land/lipgloss/v2" + "github.com/alecthomas/chroma/v2" + "github.com/alecthomas/chroma/v2/lexers" +) + +// Cache keeps highlighted lines, so that chroma highlights each line once. +// Views that share a Cache reuse its lines and its last lexer. The zero +// value is an empty Cache. A Cache is not safe for concurrent use. +type Cache struct { + lines map[syntaxKey]string + size int // the bytes of the lines and their sources + + // lexer keeps the last lexer, because to select a lexer from the + // content is slow. + lexer struct { + path, content string + l chroma.Lexer + } +} + +// maxCacheSize limits the bytes of the lines and the sources in a Cache. +// If a put goes above this size, put empties the Cache first. +const maxCacheSize = 32 << 20 + +// put keeps the highlighted line s for key k. +func (c *Cache) put(k syntaxKey, s string) { + // k.source is a part of the file string. A copy lets the cache keep + // the line without the whole file. + k.source = strings.Clone(k.source) + n := len(k.source) + len(s) + if c.lines == nil || c.size+n > maxCacheSize { + c.lines, c.size = make(map[syntaxKey]string), 0 + } + c.lines[k] = s + c.size += n +} + +// syntaxKey is the key of a [Cache]. It has the lexer, the source, and the +// background colour of the source. noColor is true for no background, because +// lipgloss.NoColor has the same RGBA value as black. +type syntaxKey struct { + lexer, source string + noColor bool + r, g, b, a uint32 +} + +// newSyntaxKey returns the key of source for the lexer named lexer, on +// bgColor. +func newSyntaxKey(lexer, source string, bgColor color.Color) syntaxKey { + k := syntaxKey{lexer: lexer, source: source} + if _, none := bgColor.(lipgloss.NoColor); bgColor == nil || none { + k.noColor = true + return k + } + k.r, k.g, k.b, k.a = bgColor.RGBA() + return k +} + +// highlightCode returns source with syntax colours on the background +// bgColor. The cache keeps the result. If chroma fails, highlightCode +// returns source as it is. +func (v *View) highlightCode(source string, bgColor color.Color) string { + // Chroma makes a lone CR a line break. Thus show each CR as a symbol + // first. + source = strings.ReplaceAll(source, "\r", "\u240d") + lexer := v.chromaLexer() + key := newSyntaxKey(lexer.Config().Name, source, bgColor) + if cached, ok := v.cache.lines[key]; ok { + return cached + } + it, err := lexer.Tokenise(nil, source) + if err != nil { + return source + } + var b strings.Builder + f := ANSIFormatter(bgColor, processChromaValue) + if err := f.Format(&b, nil, it); err != nil { + return source + } + out := b.String() + v.cache.put(key, out) + return out +} + +// processChromaValue escapes the text of a token for the terminal. A code +// line has no line break. Thus processChromaValue removes the trailing line +// breaks. +func processChromaValue(value string) string { + return Escape(strings.TrimRight(value, "\n")) +} + +// chromaLexer returns the lexer of the old file, or of the new file if +// the old file is empty, and keeps it in the cache. +func (v *View) chromaLexer() chroma.Lexer { + lx := &v.cache.lexer + content := cmp.Or(v.before, v.after) + if lx.l == nil || lx.path != v.path || lx.content != content { + lx.path, lx.content = v.path, content + lx.l = Lexer(lx.path, lx.content) + } + return lx.l +} + +// Lexer returns the coalesced lexer for the file at path. If no lexer +// matches the name, it selects one from the content. If no lexer matches +// the content, it returns the fallback lexer. +func Lexer(path, content string) chroma.Lexer { + // matchLexer caches the lexer, and the lexer is already coalesced. + if l := matchLexer(path); l != nil { + return l + } + l := lexers.Analyse(content) + if l == nil { + l = lexers.Fallback + } + return chroma.Coalesce(l) +} + +// lexerCache keeps the lexer of each file name, or nil if none matches. To +// match a name is slow, because lexers.Match tests the patterns of each +// lexer. The mutex makes the cache safe for more than one goroutine. +var ( + lexerCacheMu sync.RWMutex + lexerCache = map[string]chroma.Lexer{} +) + +// matchLexer returns the coalesced chroma lexer for the file name, or nil if +// no lexer matches. The lexer is safe to use again. +func matchLexer(fileName string) chroma.Lexer { + lexerCacheMu.RLock() + l, ok := lexerCache[fileName] + lexerCacheMu.RUnlock() + if ok { + return l + } + if l = lexers.Match(fileName); l != nil { + l = chroma.Coalesce(l) + } + lexerCacheMu.Lock() + lexerCache[fileName] = l + lexerCacheMu.Unlock() + return l +} + +// ANSIFormatter returns a chroma formatter that colours the tokens with the +// ANSI palette colours, on the background bgColor. It ignores the chroma +// style. It calls processValue on the text of each token first. +func ANSIFormatter(bgColor color.Color, processValue func(string) string) chroma.Formatter { + format := func(w io.Writer, _ *chroma.Style, it chroma.Iterator) error { + plain := lipgloss.NewStyle().Background(bgColor) + for token := it(); token != chroma.EOF; token = it() { + value := processValue(token.Value) + s := plain + if c, italic, ok := ansiFor(token.Type); ok { + s = plain.Foreground(lipgloss.Color(c)).Italic(italic) + } + if _, err := io.WriteString(w, renderLines(s, value)); err != nil { + return err + } + } + return nil + } + return chroma.FormatterFunc(format) +} + +// ansiFor returns the ANSI palette colour of a token type. If ok is false, +// the token has the default foreground of the terminal. +func ansiFor(t chroma.TokenType) (c string, italic, ok bool) { + switch { + case t.InCategory(chroma.Comment): + return ansiGrey, true, true + case t == chroma.KeywordType, t == chroma.NameBuiltin, + t.InSubCategory(chroma.NameClass): + return ansiCyan, false, true + case t.InCategory(chroma.Keyword): + return ansiMagenta, false, true + case t.InSubCategory(chroma.LiteralString): + return ansiGreen, false, true + case t.InSubCategory(chroma.LiteralNumber), t == chroma.NameConstant: + return ansiYellow, false, true + case t == chroma.NameFunction, t == chroma.NameFunctionMagic, + t == chroma.NameTag, t == chroma.NameAttribute: + return ansiBlue, false, true + } + return "", false, false +} + +// renderLines styles each line of value alone, and joins them with plain +// newlines. A token can contain newlines. For example, a chroma +// CommentSingle ends with one. Lip Gloss pads all lines of a multi-line +// string to one width. Without renderLines, this padding adds spaces to +// the line after a comment. +func renderLines(s lipgloss.Style, value string) string { + if !strings.Contains(value, "\n") { + return s.Render(value) + } + lines := strings.Split(value, "\n") + for i, line := range lines { + lines[i] = s.Render(line) + } + return strings.Join(lines, "\n") +} diff --git a/internal/diffview/render.go b/internal/diffview/render.go new file mode 100644 index 0000000..1f8e247 --- /dev/null +++ b/internal/diffview/render.go @@ -0,0 +1,291 @@ +// Derived from charmbracelet/crush (FSL-1.1-MIT). See ../DERIVED.md. + +package diffview + +import ( + "fmt" + "slices" + "strings" + + "charm.land/lipgloss/v2" + "github.com/aymanbagabas/go-udiff" + "github.com/charmbracelet/x/ansi" +) + +const ( + leadingSymbolsSize = 2 // the width of a symbol, such as symInsert + lineNumPadding = 1 // the space on each side of a line number + ellipsis = "…" // shows where a line is cut +) + +// These symbols are at the start of the code of a line. A bar in the colour +// of the line marks each changed line. [View.Signs] shows + and - in place +// of the bar. +const ( + symNone = " " // an equal line, a missing line, or a continued line + symBar = "▌ " + symInsert = "+ " + symDelete = "- " +) + +// RowKind is the type of a rendered row. +type RowKind int + +// The values of RowKind. +const ( + RowHunk RowKind = iota // a hunk header + RowEqual // a line that is the same in the two versions + RowInsert // an inserted line + RowDelete // a deleted line + RowChange // a deleted line beside an inserted line (split) +) + +// Row tells which source lines one rendered line shows. Before and After are +// line numbers from 1, or 0 if the row has no line on that side. Continued +// is true for the second and the next screen lines of a wrapped line. +// For a hunk header, Fold is the run of lines that hides above the header. +type Row struct { + Kind RowKind + Before, After int + Continued bool + Fold Fold +} + +// renderUnified renders the unified layout. Each line has the old and the +// new line numbers, then the code. +func (v *View) renderUnified() string { + var b strings.Builder + nums := func(ls lineStyle, before, after any) string { + return lineNum(ls, before, v.beforeNumDigits) + + lineNum(ls, after, v.afterNumDigits) + } + for i, h := range v.hunks { + v.writeHeader(&b, i, v.unifiedHeader(i)) + oldN, newN := h.FromLine, h.ToLine + for _, l := range h.Lines { + ls, sym := v.kindStyle(l.Kind) + var r Row + var num string + switch l.Kind { + case udiff.Equal: + r = Row{Kind: RowEqual, Before: oldN, After: newN} + num = nums(ls, oldN, newN) + oldN++ + newN++ + case udiff.Insert: + r = Row{Kind: RowInsert, After: newN} + num = nums(ls, " ", newN) + newN++ + case udiff.Delete: + r = Row{Kind: RowDelete, Before: oldN} + num = nums(ls, oldN, " ") + oldN++ + } + blank := nums(ls, " ", " ") + v.writeRow(&b, r, v.codeLines(ls, num, blank, sym, l.Content, v.codeWidth)) + } + } + return b.String() +} + +// unifiedHeader renders the divider line of hunk i in the unified layout. +func (v *View) unifiedHeader(i int) string { + div := v.style.dividerLine + header := ansi.Truncate(v.headerText(i), v.fullCodeWidth, ellipsis) + return div.lineNumber.Render(pad(ellipsis, v.beforeNumDigits)) + + div.lineNumber.Render(pad(ellipsis, v.afterNumDigits)) + + div.code.Width(v.fullCodeWidth).Render(header) +} + +// renderSplit renders the split layout. The old lines are on the left and +// the new lines are on the right. +func (v *View) renderSplit() string { + var b strings.Builder + afterWidth := v.codeWidth + btoi(v.extraColOnAfter) + for i, h := range v.hunks { + v.writeHeader(&b, i, v.splitHeader(i)) + oldN, newN := h.FromLine, h.ToLine + for _, l := range hunkToSplit(h) { + r := Row{Kind: splitKind(l)} + left, leftFill := v.splitSide(l.before, oldN, v.beforeNumDigits, v.codeWidth) + right, rightFill := v.splitSide(l.after, newN, v.afterNumDigits, afterWidth) + if l.before != nil { + r.Before = oldN + oldN++ + } + if l.after != nil { + r.After = newN + newN++ + } + n := max(len(left), len(right)) + left, right = fillTo(left, n, leftFill), fillTo(right, n, rightFill) + lines := make([]string, n) + for k := range n { + lines[k] = left[k] + right[k] + } + v.writeRow(&b, r, lines) + } + } + return b.String() +} + +// splitHeader renders the divider line of hunk i in the split layout. The +// header starts on the left side and can go on across the right side. +func (v *View) splitHeader(i int) string { + div := v.style.dividerLine + // The header covers the two code columns and the new line numbers. + width := 2*v.fullCodeWidth + btoi(v.extraColOnAfter) + v.afterNumDigits + lineNumPadding*2 + return div.lineNumber.Render(pad(ellipsis, v.beforeNumDigits)) + + div.code.Width(width).Render(ansi.Truncate(v.headerText(i), width, ellipsis)) +} + +// splitSide renders line l on one side of the split layout, one string for +// each screen line. l is nil if that side has no line. n is the line number +// of l. The fill function renders an empty screen line. Use fill when the +// other side has more screen lines. +func (v *View) splitSide(l *udiff.Line, n, digits, width int) (lines []string, fill func() string) { + ls, sym, content := v.style.missingLine, symNone, "" + if l != nil { + ls, sym = v.kindStyle(l.Kind) + content = l.Content + } + noNum := lineNum(ls, " ", digits) + num := noNum + if l != nil { + num = lineNum(ls, n, digits) + } + fill = func() string { return v.codeLines(ls, noNum, "", symNone, "", width)[0] } + return v.codeLines(ls, num, noNum, sym, content, width), fill +} + +// splitKind returns the kind of the row of l in the split layout. +func splitKind(l splitLine) RowKind { + switch { + case l.before != nil && l.after != nil && l.before.Kind == udiff.Equal: + return RowEqual + case l.before != nil && l.after != nil: + return RowChange + case l.before != nil: + return RowDelete + } + return RowInsert +} + +// fillTo adds the line that fill renders to lines, until lines has n lines. +func fillTo(lines []string, n int, fill func() string) []string { + if len(lines) >= n { + return lines + } + return append(lines, slices.Repeat([]string{fill()}, n-len(lines))...) +} + +// kindStyle returns the style and the symbol of a line of kind k. +func (v *View) kindStyle(k udiff.OpKind) (lineStyle, string) { + switch k { + case udiff.Insert: + return v.style.insertLine, v.mark(symInsert) + case udiff.Delete: + return v.style.deleteLine, v.mark(symDelete) + } + return v.style.equalLine, symNone +} + +// mark returns sign if the View shows signs, else the bar. +func (v *View) mark(sign string) string { + if v.signs { + return sign + } + return symBar +} + +// writeHeader writes header, the divider line of hunk i, to b, with its +// row. +func (v *View) writeHeader(b *strings.Builder, i int, header string) { + v.writeRow(b, Row{Kind: RowHunk, Fold: v.folds[i]}, []string{header}) +} + +// writeRow writes the screen lines of one row to b. It adds r to the rows +// once for each screen line. +func (v *View) writeRow(b *strings.Builder, r Row, lines []string) { + for k, l := range lines { + b.WriteString(l) + b.WriteByte('\n') + r.Continued = k > 0 + v.rows = append(v.rows, r) + } +} + +// lineNum renders the line number n in a column of digits cells. +func lineNum(ls lineStyle, n any, digits int) string { + return ls.lineNumber.Render(pad(n, digits)) +} + +// codeLines renders one source line with nums, sym, and the code in width +// cells. It cuts a long line. With wrap, the line continues on more screen +// lines, which show blank in place of nums and no sym. +func (v *View) codeLines(ls lineStyle, nums, blank, sym, content string, width int) []string { + code := v.highlightCode(strings.TrimSuffix(content, "\n"), ls.code.GetBackground()) + parts := []string{code} + if v.wrap { + // Wrap can keep a leading space on a full line, so cut again. + w := max(width, 1) + parts = strings.Split(ansi.Hardwrap(lipgloss.Wrap(code, w, ""), w, true), "\n") + for i, p := range parts { + // A wide character can be wider than w. + parts[i] = ansi.Truncate(p, w, "") + } + } else { + code = CutLeft(code, v.xOffset) + parts[0] = ansi.Truncate(code, width, ellipsis) + if v.xOffset > 0 && strings.TrimSpace(parts[0]) != "" { + sym = strings.TrimSuffix(sym, " ") + ellipsis + } + } + full := lipgloss.NewStyle().MaxWidth(width + leadingSymbolsSize) + lines := make([]string, len(parts)) + for i, p := range parts { + if i > 0 { + nums, sym = blank, symNone + } + lines[i] = nums + full.Render(ls.symbol.Render(sym)+ls.code.Width(width).Render(p)) + } + return lines +} + +// headerText returns the text of the header of hunk i, for example +// " ⋯ 12 unmodified lines @@ -15,7 +15,8 @@ ". The text has the fold +// above the hunk if lines hide. It has the hunk header if the hunk has +// lines. The text ends with a space, as the hunk header does. +func (v *View) headerText(i int) string { + h, s := v.hunks[i], "" + if f := v.folds[i]; f.Lines > 0 { + s = fmt.Sprintf(" ⋯ %d unmodified lines", f.Lines) + } + if len(h.Lines) == 0 { + return s + " " + } + return s + hunkHeader(h) +} + +// hunkHeader returns the header line of hunk h, for example +// " @@ -1,3 +1,4 @@ ". +func hunkHeader(h *udiff.Hunk) string { + before, after := hunkLineCounts(h) + return fmt.Sprintf(" @@ -%d,%d +%d,%d @@ ", h.FromLine, before, h.ToLine, after) +} + +// hunkLineCounts returns the number of old and new lines in hunk h. +func hunkLineCounts(h *udiff.Hunk) (before, after int) { + for _, l := range h.Lines { + switch l.Kind { + case udiff.Equal: + before++ + after++ + case udiff.Insert: + after++ + case udiff.Delete: + before++ + } + } + return before, after +} diff --git a/internal/diffview/rows.go b/internal/diffview/rows.go deleted file mode 100644 index 3a3b433..0000000 --- a/internal/diffview/rows.go +++ /dev/null @@ -1,134 +0,0 @@ -package diffview - -import "github.com/aymanbagabas/go-udiff" - -// RowKind is the type of a rendered row. -type RowKind int - -const ( - RowHunk RowKind = iota // a hunk header - RowEqual // a line that is the same in the two versions - RowInsert // an inserted line - RowDelete // a deleted line - RowChange // in the split layout, a deleted line beside an inserted line -) - -// Row tells which source lines one rendered line shows. Before and After are -// line numbers from 1, or 0 if the row has no line on that side. Continued -// is true for the second and the next screen lines of a wrapped line. -type Row struct { - Kind RowKind - Before, After int - Continued bool -} - -// rows returns one row for each line of the output of text. Call it after -// text. -func (v *View) rows() []Row { - rows := v.lineRows() - if !v.wrap { - return rows - } - var out []Row - h := v.heights - for _, r := range rows { - n := 1 - if r.Kind != RowHunk && len(h) > 0 { - n, h = h[0], h[1:] - } - for k := range n { - r.Continued = k > 0 - out = append(out, r) - } - } - return out -} - -// lineRows returns one row for each source line, and one for each hunk -// header. -func (v *View) lineRows() []Row { - if v.layout == layoutSplit { - return v.splitRows() - } - return v.unifiedRows() -} - -// unifiedRows returns the rows of the unified layout. -func (v *View) unifiedRows() []Row { - var rows []Row - for _, h := range v.unified.Hunks { - rows = append(rows, Row{Kind: RowHunk}) - b, a := h.FromLine, h.ToLine - for _, l := range h.Lines { - switch l.Kind { - case udiff.Equal: - rows = append(rows, Row{Kind: RowEqual, Before: b, After: a}) - b++ - a++ - case udiff.Insert: - rows = append(rows, Row{Kind: RowInsert, After: a}) - a++ - case udiff.Delete: - rows = append(rows, Row{Kind: RowDelete, Before: b}) - b++ - } - } - } - return rows -} - -// splitRows returns the rows of the split layout. -func (v *View) splitRows() []Row { - var rows []Row - for _, h := range v.splitHunks { - rows = append(rows, Row{Kind: RowHunk}) - b, a := h.fromLine, h.toLine - for _, l := range h.lines { - r := Row{Kind: splitKind(l)} - if l.before != nil { - r.Before = b - b++ - } - if l.after != nil { - r.After = a - a++ - } - rows = append(rows, r) - } - } - return rows -} - -// splitKind returns the kind of the row of l in the split layout. -func splitKind(l *splitLine) RowKind { - switch { - case l.before != nil && l.after != nil && l.before.Kind == udiff.Equal: - return RowEqual - case l.before != nil && l.after != nil: - return RowChange - case l.before != nil: - return RowDelete - } - return RowInsert -} - -// Geometry gives the columns of the line numbers in the output of String. -// Use it to tell a line number from the code. -type Geometry struct { - BeforeNum int // the width of the old line-number column, in cells - AfterNum int // the width of the new line-number column, in cells - AfterNumStart int // the column where the new line numbers start, from 0 -} - -// geometry returns the columns of the line numbers. Call it after text. -func (v *View) geometry() Geometry { - g := Geometry{ - BeforeNum: v.beforeNumDigits + lineNumPadding*2, - AfterNum: v.afterNumDigits + lineNumPadding*2, - } - g.AfterNumStart = g.BeforeNum - if v.layout == layoutSplit { - g.AfterNumStart = g.BeforeNum + v.fullCodeWidth - } - return g -} diff --git a/internal/diffview/split.go b/internal/diffview/split.go index cdd310f..5bc7d7a 100644 --- a/internal/diffview/split.go +++ b/internal/diffview/split.go @@ -1,3 +1,5 @@ +// Derived from charmbracelet/crush (FSL-1.1-MIT). See ../DERIVED.md. + package diffview import ( @@ -6,13 +8,6 @@ import ( "github.com/aymanbagabas/go-udiff" ) -// splitHunk is a hunk in the split layout. -type splitHunk struct { - fromLine int - toLine int - lines []*splitLine -} - // splitLine is one row of the split layout. A nil side has no line. type splitLine struct { before *udiff.Line @@ -22,13 +17,9 @@ type splitLine struct { // hunkToSplit pairs the lines of h for the split layout. An equal line is on // both sides. A deleted line gets the next inserted line beside it, if no // equal line comes first. -func hunkToSplit(h *udiff.Hunk) splitHunk { +func hunkToSplit(h *udiff.Hunk) []splitLine { lines := slices.Clone(h.Lines) - sh := splitHunk{ - fromLine: h.FromLine, - toLine: h.ToLine, - lines: make([]*splitLine, 0, len(lines)), - } + out := make([]splitLine, 0, len(lines)) for len(lines) > 0 { ul := lines[0] lines = lines[1:] @@ -42,9 +33,9 @@ func hunkToSplit(h *udiff.Hunk) splitHunk { sl.before = &ul sl.after, lines = takeInsert(lines) } - sh.lines = append(sh.lines, &sl) + out = append(out, sl) } - return sh + return out } // takeInsert removes the first inserted line before the next equal line from diff --git a/internal/diffview/style.go b/internal/diffview/style.go index a27e88e..694fcfe 100644 --- a/internal/diffview/style.go +++ b/internal/diffview/style.go @@ -1,6 +1,11 @@ +// Derived from charmbracelet/crush (FSL-1.1-MIT). See ../DERIVED.md. + package diffview import ( + "cmp" + "image/color" + "charm.land/lipgloss/v2" ) @@ -11,11 +16,103 @@ type lineStyle struct { code lipgloss.Style } -// Style is the style of each type of line. Make one with [TerminalStyle]. -type Style struct { +// style is the style of each type of line. +type style struct { dividerLine lineStyle missingLine lineStyle equalLine lineStyle insertLine lineStyle deleteLine lineStyle } + +// The colours of the ANSI palette of the terminal, as Lip Gloss colours. +const ( + ansiRed = "1" + ansiGreen = "2" + ansiYellow = "3" + ansiBlue = "4" + ansiMagenta = "5" + ansiCyan = "6" + ansiGrey = "8" // bright black +) + +// The amounts that terminalStyle mixes into the background. +const ( + tintDark = 0.16 // inserted and deleted lines on a dark background + tintLight = 0.13 // inserted and deleted lines on a light background + tintNumber = 1.7 // multiplies the tint for the numbers of those lines + shadeDivider = 0.07 // the hunk headers, toward the foreground + shadeMissing = 0.03 // the empty side of a split row, toward the foreground +) + +// The tint colours when the terminal does not report its green or red. +const ( + fallbackGreen = "#2ea043" + fallbackRed = "#f85149" +) + +// terminalStyle makes the style of [View.Theme] from the terminal colours. +func terminalStyle(bg, fg, green, red color.Color, dark bool) style { + none := lipgloss.NoColor{} + green = cmp.Or(green, lipgloss.Color(fallbackGreen)) + red = cmp.Or(red, lipgloss.Color(fallbackRed)) + amt := tintDark + if !dark { + amt = tintLight + } + tint := func(c color.Color, t float64) color.Color { + if bg == nil { + return none + } + return mix(bg, c, t) + } + subtle := func(t float64) color.Color { + if bg == nil || fg == nil { + return none + } + return mix(bg, fg, t) + } + plain := lipgloss.NewStyle() + // number is the style of a line number: right-aligned, with a space on + // each side. + number := func(s lipgloss.Style) lipgloss.Style { + return s.Padding(0, lineNumPadding).Align(lipgloss.Right) + } + // changed is the style of an inserted or a deleted line, tinted toward c. + changed := func(text string, c color.Color) lineStyle { + fg := plain.Foreground(lipgloss.Color(text)) + return lineStyle{ + lineNumber: number(fg.Background(tint(c, amt*tintNumber))), + symbol: fg.Background(tint(c, amt)), + code: plain.Background(tint(c, amt)), + } + } + divider := plain.Background(subtle(shadeDivider)) + missing := plain.Background(subtle(shadeMissing)) + return style{ + dividerLine: lineStyle{ + lineNumber: number(divider.Foreground(lipgloss.Color(ansiGrey))), + code: divider.Foreground(lipgloss.Color(ansiCyan)), + }, + missingLine: lineStyle{ + lineNumber: number(missing), + symbol: missing, + code: missing, + }, + equalLine: lineStyle{ + lineNumber: number(plain.Foreground(lipgloss.Color(ansiGrey))), + code: plain, + }, + insertLine: changed(ansiGreen, green), + deleteLine: changed(ansiRed, red), + } +} + +// mix mixes a toward b by the amount t, from 0 to 1. +func mix(a, b color.Color, t float64) color.Color { + ar, ag, ab, _ := a.RGBA() + br, bg, bb, _ := b.RGBA() + // RGBA has 16 bits for each channel. Divide by 257 to get 8 bits. + f := func(x, y uint32) uint8 { return uint8((float64(x)*(1-t) + float64(y)*t) / 257) } + return color.RGBA{R: f(ar, br), G: f(ag, bg), B: f(ab, bb), A: 255} +} diff --git a/internal/diffview/terminal.go b/internal/diffview/terminal.go deleted file mode 100644 index ac07fc1..0000000 --- a/internal/diffview/terminal.go +++ /dev/null @@ -1,96 +0,0 @@ -package diffview - -import ( - "cmp" - "image/color" - - "charm.land/lipgloss/v2" -) - -// Wrap makes long lines continue on the next screen lines, instead of -// cutting them. The x offset does not apply. -func (v *View) Wrap() *View { - v.wrap = true - return v -} - -// mix mixes a toward b by the amount t, from 0 to 1. -func mix(a, b color.Color, t float64) color.Color { - ar, ag, ab, _ := a.RGBA() - br, bg, bb, _ := b.RGBA() - // RGBA has 16 bits for each channel. Divide by 257 to get 8 bits. - f := func(x, y uint32) uint8 { return uint8((float64(x)*(1-t) + float64(y)*t) / 257) } - return color.RGBA{f(ar, br), f(ag, bg), f(ab, bb), 255} -} - -// The amounts that TerminalStyle mixes into the background. -const ( - tintDark = 0.16 // inserted and deleted lines on a dark background - tintLight = 0.13 // inserted and deleted lines on a light background - tintNumber = 1.7 // multiplies the tint for the line numbers of those lines - shadeDivider = 0.07 // the hunk headers, toward the foreground - shadeMissing = 0.03 // the empty side of a split row, toward the foreground -) - -// The tint colours when the terminal does not report its green or red. -const ( - fallbackGreen = "#2ea043" - fallbackRed = "#f85149" -) - -// TerminalStyle makes a diff style from the terminal colours: the -// background, the foreground, and the ANSI green and red. The text colours -// are ANSI palette indices, so the terminal selects the real colours. -// TerminalStyle mixes the tints of inserted and deleted lines from the -// background toward green and red. If green or red is nil, it uses a fixed -// green or red. If bg is nil, it adds no tint. -func TerminalStyle(bg, fg, green, red color.Color, dark bool) Style { - none := lipgloss.NoColor{} - green = cmp.Or(green, lipgloss.Color(fallbackGreen)) - red = cmp.Or(red, lipgloss.Color(fallbackRed)) - amt := tintDark - if !dark { - amt = tintLight - } - tint := func(c color.Color, t float64) color.Color { - if bg == nil { - return none - } - return mix(bg, c, t) - } - subtle := func(t float64) color.Color { - if bg == nil || fg == nil { - return none - } - return mix(bg, fg, t) - } - plain := lipgloss.NewStyle() - // changed is the style of an inserted or a deleted line, tinted toward c. - changed := func(text string, c color.Color) lineStyle { - fg := plain.Foreground(lipgloss.Color(text)) - return lineStyle{ - lineNumber: fg.Background(tint(c, amt*tintNumber)), - symbol: fg.Background(tint(c, amt)), - code: plain.Background(tint(c, amt)), - } - } - divider := plain.Background(subtle(shadeDivider)) - missing := plain.Background(subtle(shadeMissing)) - return Style{ - dividerLine: lineStyle{ - lineNumber: divider.Foreground(lipgloss.Color(ansiGrey)), - code: divider.Foreground(lipgloss.Color(ansiCyan)), - }, - missingLine: lineStyle{ - lineNumber: missing, - symbol: missing, - code: missing, - }, - equalLine: lineStyle{ - lineNumber: plain.Foreground(lipgloss.Color(ansiGrey)), - code: plain, - }, - insertLine: changed(ansiGreen, green), - deleteLine: changed(ansiRed, red), - } -} diff --git a/internal/diffview/text.go b/internal/diffview/text.go new file mode 100644 index 0000000..552410c --- /dev/null +++ b/internal/diffview/text.go @@ -0,0 +1,70 @@ +// Derived from charmbracelet/crush (FSL-1.1-MIT). See ../DERIVED.md. + +package diffview + +import ( + "fmt" + "strings" + "unicode/utf8" + + "github.com/charmbracelet/x/ansi" +) + +// TabWidth is the number of spaces that replace a tab. +const TabWidth = 4 + +// The Unicode Control Pictures that Escape shows in place of a control +// character. +const ( + // The picture of U+0000. The pictures of U+0001 to U+001F follow it. + controlPictures = '\u2400' + delPicture = '\u2421' +) + +// Escape replaces each control character with its Unicode Control Picture, +// so the terminal shows it and does not run it. A C1 control becomes +// U+FFFD, because it has no picture. +func Escape(content string) string { + return strings.Map(func(r rune) rune { + switch { + case r <= 0x1f: // C0 controls + return controlPictures + r + case r == ansi.DEL: + return delPicture + case r >= 0x80 && r <= 0x9f: // C1 controls + return utf8.RuneError + } + return r + }, content) +} + +// CutLeft removes the first n cells of s, which can have ANSI codes. If +// the cut is inside a wide character, a space takes the place of the rest +// of that character. Thus the text after the cut moves by n cells. +func CutLeft(s string, n int) string { + want := max(ansi.StringWidth(s)-n, 0) + cut := ansi.TruncateLeft(s, n, "") + for ansi.StringWidth(cut) > want { + n++ + cut = ansi.TruncateLeft(s, n, "") + } + return strings.Repeat(" ", want-ansi.StringWidth(cut)) + cut +} + +// pad right-aligns v in a column of width cells. +func pad(v any, width int) string { + s := fmt.Sprint(v) + w := ansi.StringWidth(s) + if w >= width { + return s + } + return strings.Repeat(" ", width-w) + s +} + +// btoi returns 1 if b is true, else 0. +func btoi(b bool) int { + if b { + return 1 + } + return 0 +} diff --git a/internal/diffview/util.go b/internal/diffview/util.go deleted file mode 100644 index 3c388e6..0000000 --- a/internal/diffview/util.go +++ /dev/null @@ -1,26 +0,0 @@ -package diffview - -import ( - "fmt" - "strings" - - "github.com/charmbracelet/x/ansi" -) - -// pad right-aligns v in a column of width cells. -func pad(v any, width int) string { - s := fmt.Sprintf("%v", v) - w := ansi.StringWidth(s) - if w >= width { - return s - } - return strings.Repeat(" ", width-w) + s -} - -// btoi returns 1 if b is true, else 0. -func btoi(b bool) int { - if b { - return 1 - } - return 0 -} diff --git a/internal/diffview/view.go b/internal/diffview/view.go new file mode 100644 index 0000000..e2af0ec --- /dev/null +++ b/internal/diffview/view.go @@ -0,0 +1,296 @@ +// Derived from charmbracelet/crush (FSL-1.1-MIT). See ../DERIVED.md. + +// Package diffview draws the difference between two versions of a file in +// the terminal, with syntax colours. +package diffview + +import ( + "image/color" + "strconv" + "strings" + + "charm.land/lipgloss/v2" + "github.com/aymanbagabas/go-udiff" + "github.com/charmbracelet/x/ansi" +) + +// View draws the difference between two versions of a file. Make one with +// [New], set its options, and call [View.Render]. +type View struct { + path string // selects the lexer + before, after string + split bool // the old and the new lines side by side + width int + xOffset int + wrap bool // wrap long lines instead of cutting them + signs bool // mark a changed line with + or -, not with a bar + style style + expanded func(Fold) bool // the folds that show, see [View.Expand] + cache *Cache + + // Render sets the fields below. + hunks []*udiff.Hunk + folds []Fold // folds[i] hides above hunks[i] + rows []Row + codeWidth int + fullCodeWidth int // with the leading symbols + extraColOnAfter bool // the new side of the split layout is one cell wider + beforeNumDigits int + afterNumDigits int +} + +// New returns a View of the change from before to after, the old and the +// new content of the file at path. The path selects the syntax colours. +// The View has the unified layout, no width limit, and no tints. +func New(path, before, after string) *View { + return &View{ + path: path, + before: before, + after: after, + style: terminalStyle(nil, nil, nil, nil, true), + cache: &Cache{}, + } +} + +// Split sets the split layout if split is true. The old lines are on the +// left and the new lines are on the right. +func (v *View) Split(split bool) *View { + v.split = split + return v +} + +// Width sets the maximum width of the output, in cells. Zero means no limit. +func (v *View) Width(width int) *View { + v.width = width + return v +} + +// XOffset sets the number of cells to scroll the code to the right. +func (v *View) XOffset(xOffset int) *View { + v.xOffset = xOffset + return v +} + +// Wrap makes long lines continue on the next screen lines if wrap is +// true, instead of cutting them. Then the x offset does not apply. +func (v *View) Wrap(wrap bool) *View { + v.wrap = wrap + return v +} + +// Signs marks an inserted line with + and a deleted line with - if signs +// is true, in place of a bar. +func (v *View) Signs(signs bool) *View { + v.signs = signs + return v +} + +// Expand shows the lines of each fold for which expanded returns true. +func (v *View) Expand(expanded func(Fold) bool) *View { + v.expanded = expanded + return v +} + +// Cache makes v keep its highlighted lines in c. The Cache c must not be +// nil. +func (v *View) Cache(c *Cache) *View { + v.cache = c + return v +} + +// Theme sets the colours from the colours of the terminal. These are the +// background, the foreground, and the ANSI green and red. The text colours +// are ANSI palette indices, so the terminal selects the real colours. +// Theme mixes the tints of inserted and deleted lines from the background +// toward green and red. If green or red is nil, it uses a fixed green or +// red. If bg is nil, it adds no tint. Dark selects the tint amounts for a +// dark background. +func (v *View) Theme(bg, fg, green, red color.Color, dark bool) *View { + v.style = terminalStyle(bg, fg, green, red, dark) + return v +} + +// Render returns the rendered diff, one row for each of its lines, and the +// columns of the line numbers. +func (v *View) Render() (string, []Row, Geometry) { + v.rows = nil + if err := v.computeDiff(); err != nil { + return err.Error(), nil, v.geometry() + } + v.markEndings() + v.replaceTabs() + v.detectNumDigits() + if v.width <= 0 { + v.detectCodeWidth() + } else { + v.resizeCodeWidth() + } + + style := lipgloss.NewStyle() + if v.width > 0 { + style = style.MaxWidth(v.width) + } + var out string + if v.split { + out = v.renderSplit() + } else { + out = v.renderUnified() + } + return style.Render(strings.TrimSuffix(out, "\n")), v.rows, v.geometry() +} + +// computeDiff computes the hunks and the folds of the diff. +func (v *View) computeDiff() error { + edits := udiff.Lines(v.before, v.after) + // With a context as long as the file, the diff has one hunk with all + // the lines. foldHunk then hides the long runs of equal lines. + context := strings.Count(v.before, "\n") + 1 + unified, err := udiff.ToUnifiedDiff("", "", v.before, edits, context) + if err != nil { + return err + } + v.hunks, v.folds = nil, nil + for _, h := range unified.Hunks { + hs, fs := foldHunk(h, v.expanded) + v.hunks = append(v.hunks, hs...) + v.folds = append(v.folds, fs...) + } + return nil +} + +// crMark shows a CR at the end of a line, as less and vim show it. +const crMark = "^M" + +// markEndings removes the CR of each line ending, so that it does not +// show. In a block of changes, the deleted lines and the inserted lines +// pair up in their order. As in herdr-reviewr, a line of a pair that +// differs from the other line only in a CR gets crMark. A change of only +// the line ending shows. +func (v *View) markEndings() { + for _, h := range v.hunks { + ls := h.Lines + for i := 0; i < len(ls); { + dels := i + for i < len(ls) && ls[i].Kind == udiff.Delete { + i++ + } + ins := i + for i < len(ls) && ls[i].Kind == udiff.Insert { + i++ + } + for k := 0; dels+k < ins && ins+k < i; k++ { + markPair(&ls[dels+k], &ls[ins+k]) + } + if i == dels { // an equal line + i++ + } + } + for i := range ls { + ls[i].Content, _ = cutCR(ls[i].Content, "") + } + } +} + +// markPair adds crMark to old or to new if the two lines differ only in +// the CR of the line ending. +func markPair(old, new *udiff.Line) { + o, oCR := cutCR(old.Content, "") + n, nCR := cutCR(new.Content, "") + if oCR != nCR && o == n { + old.Content, _ = cutCR(old.Content, crMark) + new.Content, _ = cutCR(new.Content, crMark) + } +} + +// cutCR returns line with mark in place of the CR of its line ending, and +// reports whether line has that CR. The last line of a file can have a CR +// and no LF. +func cutCR(line, mark string) (string, bool) { + body, cr := strings.CutSuffix(strings.TrimSuffix(line, "\n"), "\r") + if !cr { + return line, false + } + return body + mark + line[len(body)+1:], true +} + +// replaceTabs replaces the tabs in the lines of the diff with spaces. It runs +// after computeDiff, so that a change from a tab to spaces is a change. +func (v *View) replaceTabs() { + spaces := strings.Repeat(" ", TabWidth) + for _, h := range v.hunks { + for i := range h.Lines { + h.Lines[i].Content = strings.ReplaceAll(h.Lines[i].Content, "\t", spaces) + } + } +} + +// detectNumDigits finds the number of digits of the largest old and new line +// numbers. +func (v *View) detectNumDigits() { + v.beforeNumDigits = 0 + v.afterNumDigits = 0 + for _, h := range v.hunks { + before := len(strconv.Itoa(h.FromLine + len(h.Lines))) + after := len(strconv.Itoa(h.ToLine + len(h.Lines))) + v.beforeNumDigits = max(v.beforeNumDigits, before) + v.afterNumDigits = max(v.afterNumDigits, after) + } +} + +// detectCodeWidth finds the width of the widest code line or hunk header. +// The split layout shows the same lines, so the width is the same for the +// two layouts. +func (v *View) detectCodeWidth() { + v.codeWidth = 0 + for i, h := range v.hunks { + v.codeWidth = max(v.codeWidth, ansi.StringWidth(v.headerText(i))) + for _, l := range h.Lines { + w := ansi.StringWidth(Escape(strings.TrimSuffix(l.Content, "\n"))) + 1 + v.codeWidth = max(v.codeWidth, w) + } + } + v.fullCodeWidth = v.codeWidth + leadingSymbolsSize +} + +// resizeCodeWidth sets the code width so that the lines fit in the width. +func (v *View) resizeCodeWidth() { + fullNumWidth := v.beforeNumDigits + v.afterNumDigits + fullNumWidth += lineNumPadding * 4 // two sides of two line numbers + + if v.split { + remainingWidth := v.width - fullNumWidth - leadingSymbolsSize*2 + v.codeWidth = remainingWidth / 2 + v.extraColOnAfter = remainingWidth%2 != 0 + } else { + v.codeWidth = v.width - fullNumWidth - leadingSymbolsSize + } + v.fullCodeWidth = v.codeWidth + leadingSymbolsSize +} + +// Geometry gives the columns of the line numbers and of the code in the +// output of [View.Render]. Use it to tell a line number from the code. +type Geometry struct { + BeforeNum int // the width of the old line-number column, in cells + AfterNum int // the width of the new line-number column, in cells + AfterNumStart int // the column where the new line numbers start, from 0 + BeforeCode int // the column where the old code starts, from 0 + AfterCode int // the column where the new code starts, from 0 +} + +// geometry returns the columns of the line numbers and of the code. +func (v *View) geometry() Geometry { + g := Geometry{ + BeforeNum: v.beforeNumDigits + lineNumPadding*2, + AfterNum: v.afterNumDigits + lineNumPadding*2, + } + g.AfterNumStart = g.BeforeNum + if v.split { + g.AfterNumStart = g.BeforeNum + v.fullCodeWidth + g.BeforeCode = g.BeforeNum + leadingSymbolsSize + g.AfterCode = g.AfterNumStart + g.AfterNum + leadingSymbolsSize + return g + } + g.BeforeCode = g.BeforeNum + g.AfterNum + leadingSymbolsSize + g.AfterCode = g.BeforeCode + return g +} diff --git a/internal/editor/editor.go b/internal/editor/editor.go new file mode 100644 index 0000000..235e8aa --- /dev/null +++ b/internal/editor/editor.go @@ -0,0 +1,167 @@ +// Package editor picks the text editor of the user, and makes the sh +// command that opens a file at a line in it. It also reports whether the +// editor opens a window or draws in the terminal. +package editor + +import ( + "cmp" + "errors" + "path/filepath" + "runtime" + "slices" + "strconv" + "strings" +) + +// Command is a command that opens a file at a line. +type Command struct { + Script string // the command, for sh -c + Program string // the first word of the command, or "" if it has none + Window bool // the editor opens a window, not a terminal editor +} + +// Sources are the places that can name the editor, in the order that +// Resolve tries them. +type Sources struct { + Template string // the editor command of the settings + Visual string // $VISUAL + Editor string // $EDITOR + Git string // core.editor +} + +// Resolve returns the command that opens file at line. It uses +// s.Template if it is not empty. Else it uses the first of s.Visual, +// s.Editor, and s.Git that is not blank. Each error is a message for the +// user. +func Resolve(s Sources, file string, line int) (Command, error) { + var c Command + if s.Template != "" { + c = fromTemplate(s.Template, file, line) + } else { + e := cmp.Or(strings.TrimSpace(s.Visual), strings.TrimSpace(s.Editor), strings.TrimSpace(s.Git)) + if e == "" { + return Command{}, errors.New(`no editor: set "editor" in rexdiff.lua, $EDITOR, or git's core.editor`) + } + c = fromEditor(e, file, line) + } + if c.Program == "" { + return Command{}, errors.New("the editor command names no program") + } + return c, nil +} + +// fromTemplate returns the command that opens file at line with template, +// a command that the user writes. The template gets file in place of +// {file}, and line in place of {line}. Without {file}, file goes at the +// end. +func fromTemplate(template, file string, line int) Command { + r := strings.NewReplacer("{file}", shellQuote(file), "{line}", strconv.Itoa(line)) + script := r.Replace(template) + if !strings.Contains(template, "{file}") { + script += " " + shellQuote(file) + } + return Command{Script: script, Program: program(template), Window: dialectOf(template).window} +} + +// fromEditor returns the command that opens file at line in an editor. +// command starts the editor, for example the value of $EDITOR. fromEditor +// adds file and line in the form that the editor expects. +func fromEditor(command, file string, line int) Command { + d := dialectOf(command) + n, f := strconv.Itoa(line), shellQuote(file) + at := shellQuote(file + ":" + n) + var args string + switch d.line { + case argNone: + args = f + case argPlus: + args = "+" + n + " " + f + case argSuffix: + args = at + case argGoto: + args = "-g " + at + case argFlag: + args = "--line " + n + " " + f + } + return Command{Script: command + " " + args, Program: program(command), Window: d.window} +} + +// shellQuote quotes s as one word for sh. +func shellQuote(s string) string { return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" } + +// lineArg is how an editor takes the line to open at. +type lineArg int + +const ( + argNone lineArg = iota // file + argPlus // +42 file + argSuffix // file:42 + argGoto // -g file:42 + argFlag // --line 42 file +) + +// dialect tells how an editor takes the line, and if it opens a window. +type dialect struct { + line lineArg + window bool +} + +// dialects has the known editors, by program name. The table is the same +// as in herdr-reviewr. The table also lists fresh, open, and xdg-open. +var dialects = knownDialects() + +// knownDialects returns the table of dialects. +func knownDialects() map[string]dialect { + d := map[string]dialect{} + add := func(dl dialect, names ...string) { + for _, n := range names { + d[n] = dl + } + } + add(dialect{argPlus, false}, "vi", "vim", "nvim", "lvim", "vis", "joe", "nano", "micro", "kak") + // Emacs can open a window or not. In a pane, it works in both cases. + add(dialect{argPlus, false}, "emacs", "emacsclient") + add(dialect{argSuffix, false}, "hx", "helix", "fresh") + // MacVim and gVim open a window, unlike the other vi editors. + add(dialect{argPlus, true}, "mvim", "gvim", "bbedit", "gedit") + add(dialect{argGoto, true}, + "code", "code-insiders", "codium", "vscodium", "cursor", "windsurf", "positron") + add(dialect{argSuffix, true}, "subl", "sublime_text", "zed", "zeditor", "zedit") + add(dialect{argFlag, true}, "mate", "kate", "idea", "pycharm", "webstorm", "goland", + "clion", "phpstorm", "rubymine", "rider", "datagrip", "rustrover", "dataspell", "fleet") + add(dialect{argNone, true}, "open", "xdg-open") + if runtime.GOOS == "darwin" { + add(dialect{argFlag, true}, "xed") // the opener of Xcode + } + return d +} + +// dialectOf returns the dialect of the program of command. An unknown +// editor takes +line and draws in the terminal. Most terminal editors take +// +line. A window editor also works in a pane. A wait flag tells that the +// editor has a window. +func dialectOf(command string) dialect { + d := dialect{line: argPlus} + if known, ok := dialects[filepath.Base(program(command))]; ok { + d = known + } + words := strings.Fields(command) + if slices.Contains(words, "--wait") || slices.Contains(words, "--block") { + d.window = true + } + return d +} + +// program returns the first word of command. A word in quotes can have +// spaces, for example "/Applications/Visual Studio Code.app/.../code". +func program(command string) string { + command = strings.TrimSpace(command) + for _, q := range []string{`"`, "'"} { + if rest, ok := strings.CutPrefix(command, q); ok { + word, _, _ := strings.Cut(rest, q) + return word + } + } + word, _, _ := strings.Cut(command, " ") + return word +} diff --git a/internal/editor/editor_test.go b/internal/editor/editor_test.go new file mode 100644 index 0000000..4709e0f --- /dev/null +++ b/internal/editor/editor_test.go @@ -0,0 +1,101 @@ +package editor_test + +import ( + "testing" + + "github.com/rednafi/rexdiff/internal/editor" +) + +// TestCommands checks the command and the kind of each editor. +func TestCommands(t *testing.T) { + t.Parallel() + const abs = "/r/a b.go" + tests := []struct { + template, editor string + script string + window bool + }{ + // The editor of the settings gets the placeholders. + {"/opt/homebrew/bin/nvim +{line} {file}", "", `/opt/homebrew/bin/nvim +12 '/r/a b.go'`, false}, + {"code -g {file}:{line}", "", `code -g '/r/a b.go':12`, true}, + {"zed {file}:{line}", "", `zed '/r/a b.go':12`, true}, + {"open -a Zed {file}", "", `open -a Zed '/r/a b.go'`, true}, + {"myeditor --at {line}", "", `myeditor --at 12 '/r/a b.go'`, false}, + // The editor of the environment gets the line as it expects it. + {"", "nvim", `nvim +12 '/r/a b.go'`, false}, + {"", "/usr/bin/vim -u NONE", `/usr/bin/vim -u NONE +12 '/r/a b.go'`, false}, + {"", "micro", `micro +12 '/r/a b.go'`, false}, + {"", "hx", `hx '/r/a b.go:12'`, false}, + {"", "fresh", `fresh '/r/a b.go:12'`, false}, + {"", "code --wait", `code --wait -g '/r/a b.go:12'`, true}, + {"", "subl", `subl '/r/a b.go:12'`, true}, + {"", "idea", `idea --line 12 '/r/a b.go'`, true}, + {"", "mvim", `mvim +12 '/r/a b.go'`, true}, + // A path in quotes can have spaces. + {"", `"/Apps/VS Code.app/bin/code" -w`, `"/Apps/VS Code.app/bin/code" -w -g '/r/a b.go:12'`, true}, + {"", `'/my apps/nvim'`, `'/my apps/nvim' +12 '/r/a b.go'`, false}, + // An unknown editor gets +line, and draws in the terminal. + {"", "vim.basic", `vim.basic +12 '/r/a b.go'`, false}, + // A wait flag tells that an unknown editor has a window. + {"", "myeditor --wait", `myeditor --wait +12 '/r/a b.go'`, true}, + } + for _, tt := range tests { + got, err := editor.Resolve(editor.Sources{Template: tt.template, Editor: tt.editor}, abs, 12) + if err != nil || got.Script != tt.script || got.Window != tt.window { + t.Errorf("editor %q%q = %+v, %v, want script %q, window %v", + tt.template, tt.editor, got, err, tt.script, tt.window) + } + } +} + +// TestCommandProgram checks the program of a command. An empty word in +// quotes names no program. +func TestCommandProgram(t *testing.T) { + t.Parallel() + tests := []struct{ command, want string }{ + {"nvim", "nvim"}, + {" /usr/bin/vim -u NONE", "/usr/bin/vim"}, + {`"/Apps/VS Code.app/bin/code" -w`, "/Apps/VS Code.app/bin/code"}, + {`'/my apps/nvim'`, "/my apps/nvim"}, + {`""`, ""}, + {"'' vim", ""}, + } + const noProgram = "the editor command names no program" + for _, tt := range tests { + for _, c := range []struct{ template, env string }{{tt.command, ""}, {"", tt.command}} { + got, err := editor.Resolve(editor.Sources{Template: c.template, Editor: c.env}, "/a", 1) + switch { + case tt.want == "" && (err == nil || err.Error() != noProgram): + t.Errorf("Resolve(%q, %q) error = %v, want %q", c.template, c.env, err, noProgram) + case tt.want != "" && got.Program != tt.want: + t.Errorf("Resolve(%q, %q).Program = %q, want %q", c.template, c.env, got.Program, tt.want) + } + } + } +} + +// TestResolveOrder checks that the settings come first, then $VISUAL, +// $EDITOR, and core.editor. A blank value does not count. +func TestResolveOrder(t *testing.T) { + t.Parallel() + tests := []struct { + template, visual, env, git string + want string + }{ + {"zed {file}", "vim", "nano", "hx", "zed"}, + {"", "vim", "nano", "hx", "vim"}, + {"", " ", "nano", "hx", "nano"}, + {"", "", "\t", "hx", "hx"}, + } + for _, tt := range tests { + got, err := editor.Resolve(editor.Sources{Template: tt.template, Visual: tt.visual, Editor: tt.env, Git: tt.git}, "/a", 1) + if err != nil || got.Program != tt.want { + t.Errorf("Resolve(%q, %q, %q, %q) = %q, %v, want %q", + tt.template, tt.visual, tt.env, tt.git, got.Program, err, tt.want) + } + } + const noEditor = `no editor: set "editor" in rexdiff.lua, $EDITOR, or git's core.editor` + if _, err := editor.Resolve(editor.Sources{Visual: " ", Git: "\n"}, "/a", 1); err == nil || err.Error() != noEditor { + t.Errorf("Resolve with no editor: error = %v, want %q", err, noEditor) + } +} diff --git a/internal/proc/proc.go b/internal/proc/proc.go new file mode 100644 index 0000000..ef429e7 --- /dev/null +++ b/internal/proc/proc.go @@ -0,0 +1,148 @@ +// Package proc runs programs and makes their errors short and clear. +// +// The caller gives the environment as a value, as in [exec.Cmd.Env]. A +// program gets only that environment, and proc finds the program in the +// PATH of that environment. proc does not read the environment of the +// process. +package proc + +import ( + "cmp" + "context" + "errors" + "os" + "os/exec" + "path/filepath" + "slices" + "strings" + "syscall" + "time" +) + +// waitDelay is how long a stopped program has to end. It is also how long +// a program that ends can keep its output open. A child of the program +// can keep the output open after the program ends. +const waitDelay = time.Second + +// Command returns the command "name args..." with the environment env. +// LookPath finds name in the PATH of env. If LookPath fails, Start returns +// its error. A nil env is an empty environment, not the environment of the +// process. When ctx is done, the command sends SIGTERM to the program, so +// that git can remove its lock files. A SIGKILL leaves index.lock, and then +// each later git add fails. If the program does not end in waitDelay, the +// command kills it. Wait returns at most waitDelay after the program ends. +func Command(ctx context.Context, env []string, name string, args ...string) *exec.Cmd { + path, err := LookPath(env, name) + if err != nil { + // Start returns Err before it runs anything, so the command needs no + // context. + return &exec.Cmd{Path: name, Args: append([]string{name}, args...), Err: err} + } + cmd := exec.CommandContext(ctx, path, args...) + cmd.Args[0] = name + if env == nil { + env = []string{} // exec gives a nil Env the environment of the process + } + cmd.Env = env + cmd.Cancel = func() error { return cmd.Process.Signal(syscall.SIGTERM) } + cmd.WaitDelay = waitDelay + return cmd +} + +// Getenv returns the value of the variable key in env, a list of +// "key=value" pairs. The last pair with key wins, as in [exec.Cmd.Env]. +// Getenv returns "" if env does not have key. +func Getenv(env []string, key string) string { + for _, kv := range slices.Backward(env) { + if v, ok := strings.CutPrefix(kv, key+"="); ok { + return v + } + } + return "" +} + +// LookPath returns the path of the program file, as [exec.LookPath] does, +// but it looks in the PATH of env. If file has a separator, LookPath checks +// only file. LookPath skips the empty entries of PATH. The program must be +// an executable regular file. If LookPath finds no program, the error is an +// [*exec.Error] that wraps [exec.ErrNotFound]. +func LookPath(env []string, file string) (string, error) { + if strings.ContainsRune(file, filepath.Separator) { + if executable(file) { + return file, nil + } + return "", &exec.Error{Name: file, Err: exec.ErrNotFound} + } + for dir := range strings.SplitSeq(Getenv(env, "PATH"), string(os.PathListSeparator)) { + if !filepath.IsAbs(dir) { + // A relative entry, as ".", names a directory of the working + // tree that rexdiff shows. exec.LookPath refuses it too. + continue + } + if path := filepath.Join(dir, file); executable(path) { + return path, nil + } + } + return "", &exec.Error{Name: file, Err: exec.ErrNotFound} +} + +// executable reports whether path is a regular file that a user can run. +func executable(path string) bool { + info, err := os.Stat(path) + return err == nil && info.Mode().IsRegular() && info.Mode()&0o111 != 0 +} + +// NotInstalled returns the error of the program name that PATH does not +// have. The text has the base name of the program. +func NotInstalled(name string) error { + return errors.New(filepath.Base(name) + " is not installed") +} + +// Error returns the error err of a run of the program name. If ctx is +// done, the text is that of [context.Cause], and the error wraps err and +// the cause. Thus errors.Is finds [context.DeadlineExceeded], and +// errors.As finds the [*exec.ExitError]. Else, if the program is not on +// PATH, the text is " is not installed". Else the text is the first +// line of stderr that is not blank, without a leading "Error: ". If stderr +// has no text, the text is that of err. The error wraps err, so a caller +// can examine the exit code. +func Error(ctx context.Context, name string, err error, stderr []byte) error { + if ctx.Err() != nil { + cause := context.Cause(ctx) + return &runError{msg: cause.Error(), errs: []error{err, cause}} + } + if errors.Is(err, exec.ErrNotFound) { + return &runError{msg: NotInstalled(name).Error(), errs: []error{err}} + } + msg := strings.TrimPrefix(firstLine(string(stderr)), "Error: ") + return &runError{msg: cmp.Or(msg, err.Error()), errs: []error{err}} +} + +// runError is a failed run of a program. It has a short text. It wraps +// the error of the process, and the cause of the context if the context +// is done. +type runError struct { + msg string + errs []error +} + +func (e *runError) Error() string { return e.msg } +func (e *runError) Unwrap() []error { return e.errs } + +// firstLine returns the first line of s that is not blank, without the +// leading and trailing space. +func firstLine(s string) string { + for line := range strings.Lines(s) { + if line = strings.TrimSpace(line); line != "" { + return line + } + } + return "" +} + +// LastLine returns the last line of s that is not blank, without the +// leading and trailing space. +func LastLine(s string) string { + s = strings.TrimSpace(s) + return strings.TrimSpace(s[strings.LastIndexByte(s, '\n')+1:]) +} diff --git a/internal/proc/proc_test.go b/internal/proc/proc_test.go new file mode 100644 index 0000000..9be28ca --- /dev/null +++ b/internal/proc/proc_test.go @@ -0,0 +1,238 @@ +package proc_test + +import ( + "bufio" + "context" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "slices" + "strings" + "testing" + "time" + + "github.com/rednafi/rexdiff/internal/proc" +) + +// shEnv is an environment with the PATH of sh. +var shEnv = []string{"PATH=/usr/bin:/bin"} + +// TestError checks the text of the error of a run, and that the error +// keeps the error of the process. +func TestError(t *testing.T) { + t.Parallel() + exit := proc.Command(t.Context(), shEnv, "sh", "-c", "exit 3").Run() + tests := []struct { + name string + err error + stderr string + want string + }{ + {"/usr/bin/gh", exec.ErrNotFound, "", "gh is not installed"}, + {"git", exit, "\n fatal: bad\nmore\n", "fatal: bad"}, + {"rex", exit, "Error: no Rex server\n", "no Rex server"}, + {"git", exit, " \n", exit.Error()}, + } + for _, tt := range tests { + err := proc.Error(t.Context(), tt.name, tt.err, []byte(tt.stderr)) + if err.Error() != tt.want { + t.Errorf("Error(%q, %v, %q) = %q, want %q", tt.name, tt.err, tt.stderr, err, tt.want) + } + if !errors.Is(err, tt.err) { + t.Errorf("Error(%q, %v, %q) does not wrap %v", tt.name, tt.err, tt.stderr, tt.err) + } + } +} + +// TestErrorCause checks that the error of a run that stops with its +// context has the text of the cause. The error also keeps the error of +// the process and the cause. +func TestErrorCause(t *testing.T) { + t.Parallel() + cause := fmt.Errorf("test run: 1ms timeout: %w", context.DeadlineExceeded) + ctx, cancel := context.WithTimeoutCause(t.Context(), time.Millisecond, cause) + defer cancel() + <-ctx.Done() + exit := proc.Command(t.Context(), shEnv, "sh", "-c", "exit 3").Run() + err := proc.Error(ctx, "git", exit, []byte("fatal: bad\n")) + if err.Error() != cause.Error() { + t.Errorf("Error = %q, want %q", err, cause) + } + if !errors.Is(err, context.DeadlineExceeded) { + t.Errorf("Error = %v, want it to wrap context.DeadlineExceeded", err) + } + if ee, ok := errors.AsType[*exec.ExitError](err); !ok || ee.ExitCode() != 3 { + t.Errorf("Error = %v, want it to wrap the exit error with code 3", err) + } +} + +// TestCommandTerm checks that a stop of the context sends SIGTERM, so the +// program can clean up before it ends. +func TestCommandTerm(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithCancelCause(t.Context()) + cmd := proc.Command(ctx, shEnv, "sh", "-c", `trap 'echo term; exit 0' TERM; echo ready; while :; do sleep 0.01; done`) + out, err := cmd.StdoutPipe() + if err != nil { + t.Fatal(err) + } + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + r := bufio.NewReader(out) + if line, err := r.ReadString('\n'); err != nil || line != "ready\n" { + t.Fatalf("first line = %q, %v, want %q", line, err, "ready\n") + } + cancel(fmt.Errorf("test stops the program: %w", context.Canceled)) + rest, _ := io.ReadAll(r) + _ = cmd.Wait() + if string(rest) != "term\n" { + t.Errorf("output after the stop = %q, want %q", rest, "term\n") + } +} + +// TestLastLine checks that LastLine skips the blank lines at the end. +func TestLastLine(t *testing.T) { + t.Parallel() + tests := []struct{ in, want string }{ + {"", ""}, + {"one", "one"}, + {"one\n two \n\n", "two"}, + {"one\r\ntwo\r\n", "two"}, + } + for _, tt := range tests { + if got := proc.LastLine(tt.in); got != tt.want { + t.Errorf("LastLine(%q) = %q, want %q", tt.in, got, tt.want) + } + } +} + +// TestCommand checks that Command limits the wait for the output, and +// that the program gets only env. +func TestCommand(t *testing.T) { + t.Parallel() + env := slices.Concat(shEnv, []string{"A=1"}) + cmd := proc.Command(t.Context(), env, "sh", "-c", "env") + if cmd.WaitDelay <= 0 { + t.Errorf("WaitDelay = %v, want more than 0", cmd.WaitDelay) + } + if cmd.Args[0] != "sh" { + t.Errorf("Args[0] = %q, want sh", cmd.Args[0]) + } + out, err := cmd.Output() + if err != nil { + t.Fatal(err) + } + // sh can add variables of its own, for example PWD. + for _, kv := range env { + if !strings.Contains(string(out), kv+"\n") { + t.Errorf("the environment of sh is\n%s\nwant %s", out, kv) + } + } +} + +// TestCommandNotFound checks that Start returns the error of LookPath if +// the PATH of env has no program name. +func TestCommandNotFound(t *testing.T) { + t.Parallel() + for _, env := range [][]string{nil, {"PATH=" + t.TempDir()}} { + cmd := proc.Command(t.Context(), env, "sh", "-c", "true") + if cmd.Path != "sh" { + t.Errorf("env %q: Path = %q, want sh", env, cmd.Path) + } + if err := cmd.Run(); !errors.Is(err, exec.ErrNotFound) { + t.Errorf("env %q: Run = %v, want exec.ErrNotFound", env, err) + } + } +} + +// TestGetenv checks that the last pair of a key wins, and that a key +// does not match the start of another key. +func TestGetenv(t *testing.T) { + t.Parallel() + env := []string{"A=1", "AB=2", "A=3", "C=", "D"} + tests := []struct{ key, want string }{ + {"A", "3"}, + {"AB", "2"}, + {"C", ""}, + {"D", ""}, + {"B", ""}, + {"", ""}, + } + for _, tt := range tests { + if got := proc.Getenv(env, tt.key); got != tt.want { + t.Errorf("Getenv(%q, %q) = %q, want %q", env, tt.key, got, tt.want) + } + } + if got := proc.Getenv(nil, "A"); got != "" { + t.Errorf("Getenv(nil, A) = %q, want \"\"", got) + } +} + +// TestLookPath checks that LookPath finds an executable regular file in +// the PATH of env, in the order of PATH. +func TestLookPath(t *testing.T) { + t.Parallel() + first, second := t.TempDir(), t.TempDir() + write := func(path string, mode os.FileMode) string { + t.Helper() + if err := os.WriteFile(path, []byte("#!/bin/sh\n"), mode); err != nil { + t.Fatal(err) + } + return path + } + write(filepath.Join(first, "plain"), 0o644) + plain := write(filepath.Join(second, "plain"), 0o755) + both := write(filepath.Join(first, "both"), 0o755) + write(filepath.Join(second, "both"), 0o755) + if err := os.Mkdir(filepath.Join(first, "dir"), 0o755); err != nil { + t.Fatal(err) + } + path := "PATH=" + strings.Join([]string{"", first, "", second}, string(os.PathListSeparator)) + env := []string{"PATH=/nowhere", path} + tests := []struct { + file, want string // a want of "" is exec.ErrNotFound + }{ + {"both", both}, + {"plain", plain}, // the file in first cannot run + {"dir", ""}, + {"none", ""}, + {"", ""}, + {both, both}, + {filepath.Join(first, "plain"), ""}, + {filepath.Join(first, "none"), ""}, + } + for _, tt := range tests { + got, err := proc.LookPath(env, tt.file) + if tt.want == "" { + ee, ok := errors.AsType[*exec.Error](err) + if got != "" || !errors.Is(err, exec.ErrNotFound) || !ok || ee.Name != tt.file { + t.Errorf("LookPath(%q) = %q, %v, want an *exec.Error with ErrNotFound", + tt.file, got, err) + } + continue + } + if got != tt.want || err != nil { + t.Errorf("LookPath(%q) = %q, %v, want %q, nil", tt.file, got, err, tt.want) + } + } + if got, err := proc.LookPath(nil, "sh"); !errors.Is(err, exec.ErrNotFound) { + t.Errorf("LookPath(nil, sh) = %q, %v, want exec.ErrNotFound", got, err) + } + // A relative entry names a directory of the working directory, which + // can be the repository under review. + wd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + rel, err := filepath.Rel(wd, first) + if err != nil { + t.Fatal(err) + } + if got, err := proc.LookPath([]string{"PATH=" + rel}, "both"); !errors.Is(err, exec.ErrNotFound) { + t.Errorf("LookPath(PATH=%s, both) = %q, %v, want exec.ErrNotFound", rel, got, err) + } +} diff --git a/internal/search/match.go b/internal/search/match.go new file mode 100644 index 0000000..acf5e21 --- /dev/null +++ b/internal/search/match.go @@ -0,0 +1,46 @@ +package search + +import ( + "strings" + "unicode" +) + +// Match reports whether text has query, with smart case as in +// herdr-reviewr. A query with an upper case letter matches only the same +// case. Else Match ignores the case of ASCII letters only. An empty query +// matches nothing. +func Match(text, query string) bool { + if query == "" { + return false + } + if strings.ContainsFunc(query, unicode.IsUpper) { + return strings.Contains(text, query) + } + for i := 0; i+len(query) <= len(text); i++ { + if foldEqual(text[i:i+len(query)], query) { + return true + } + } + return false +} + +// foldEqual reports whether a and b, which have the same length, are equal +// when ASCII letters ignore case. The bytes of a UTF-8 character other +// than ASCII never fold, so a byte compare is correct. +func foldEqual(a, b string) bool { + for i := range len(a) { + if lowerASCII(a[i]) != lowerASCII(b[i]) { + return false + } + } + return true +} + +// lowerASCII returns the lower case of the ASCII letter c. It returns +// other bytes as they are. +func lowerASCII(c byte) byte { + if 'A' <= c && c <= 'Z' { + return c + 'a' - 'A' + } + return c +} diff --git a/internal/search/search.go b/internal/search/search.go new file mode 100644 index 0000000..1eb10b3 --- /dev/null +++ b/internal/search/search.go @@ -0,0 +1,59 @@ +// Package search finds the files of the search screen, and the lines of +// the find band. The limits and the smart case come from herdr-reviewr. +package search + +import ( + "slices" + + "github.com/sahilm/fuzzy" +) + +// The limits of the results. A "… more" row tells that there are others. +const ( + // MaxFiles is the largest number of files that the search shows. + MaxFiles = 50 + // MaxCodeLines is the largest number of matching lines that the search + // shows. + MaxCodeLines = 200 +) + +// Files returns at most limit of paths that match q, the best match +// first, and the number of all the matches. For an empty q, the paths in +// recent come first, in their order, and then the other paths. +// herdr-reviewr ranks the files of an empty query by use. rexdiff keeps +// no history, so recent has the picks of this session. +func Files(paths, recent []string, q string, limit int) (hits []string, total int) { + if q == "" { + for _, p := range recent { + if slices.Contains(paths, p) && !slices.Contains(hits, p) { + hits = append(hits, p) + } + } + total = len(paths) + for _, p := range paths { + if len(hits) >= limit { + break + } + if !slices.Contains(hits, p) { + hits = append(hits, p) + } + } + return hits[:min(len(hits), limit)], total + } + ms := fuzzy.Find(q, paths) + for _, m := range ms[:min(len(ms), limit)] { + hits = append(hits, m.Str) + } + return hits, len(ms) +} + +// Indices returns the indices of the labels that match q, the best match +// first. The match is the same fuzzy match as in Files. +func Indices(q string, labels []string) []int { + ms := fuzzy.Find(q, labels) + is := make([]int, len(ms)) + for i, m := range ms { + is[i] = m.Index + } + return is +} diff --git a/internal/search/search_test.go b/internal/search/search_test.go new file mode 100644 index 0000000..2a6ca13 --- /dev/null +++ b/internal/search/search_test.go @@ -0,0 +1,88 @@ +package search_test + +import ( + "fmt" + "slices" + "testing" + + "github.com/rednafi/rexdiff/internal/search" +) + +// TestFiles checks the order and the limit of the files. +func TestFiles(t *testing.T) { + t.Parallel() + paths := []string{"a.go", "b.go", "c/d.go", "main.go"} + var many []string + for i := range 60 { + many = append(many, fmt.Sprintf("f%02d.txt", i)) + } + tests := []struct { + name, q string + paths []string + recent []string + limit int + want []string + wantTotal int + }{ + {"empty query", "", paths, nil, 50, paths, 4}, + {"recent first", "", paths, []string{"main.go", "b.go"}, 50, []string{"main.go", "b.go", "a.go", "c/d.go"}, 4}, + {"a gone recent pick", "", paths, []string{"gone.go", "b.go"}, 50, []string{"b.go", "a.go", "c/d.go", "main.go"}, 4}, + {"empty query limit", "", paths, []string{"main.go"}, 2, []string{"main.go", "a.go"}, 4}, + {"fuzzy", "dgo", paths, nil, 50, []string{"c/d.go"}, 1}, + {"recent does not rank a query", "go", paths, []string{"main.go"}, 2, []string{"a.go", "b.go"}, 4}, + {"no match", "zzz", paths, nil, 50, nil, 0}, + // A query shows at most the limit, and counts all the matches. + {"query limit", "f", many, nil, search.MaxFiles, many[:search.MaxFiles], 60}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got, total := search.Files(tt.paths, tt.recent, tt.q, tt.limit) + if !slices.Equal(got, tt.want) || total != tt.wantTotal { + t.Errorf("Files(%q, %q) = %q, %d, want %q, %d", + tt.q, tt.recent, got, total, tt.want, tt.wantTotal) + } + }) + } +} + +// TestMatch checks the smart case of herdr-reviewr. +func TestMatch(t *testing.T) { + t.Parallel() + tests := []struct { + text, query string + want bool + }{ + {"Foo bar", "foo", true}, + {"foo bar", "Foo", false}, + {"Foo bar", "Foo", true}, + {"FOO", "foo", true}, + {"x := 1", "", false}, + {"", "a", false}, + {"ÉCOLE", "école", false}, // only ASCII ignores case + {"école", "école", true}, + {"Éa", "Éa", true}, + {"\tx", "\tx", true}, + {"ab", "abc", false}, + } + for _, tt := range tests { + got := search.Match(tt.text, tt.query) + if got != tt.want { + t.Errorf("Match(%q, %q) = %v, want %v", + tt.text, tt.query, got, tt.want) + } + } +} + +// TestIndices checks that Indices returns the indices of the labels that +// match, the best match first. +func TestIndices(t *testing.T) { + t.Parallel() + labels := []string{"main feat", "dev", "feature x"} + if got, want := search.Indices("feat", labels), []int{2, 0}; !slices.Equal(got, want) { + t.Errorf("Indices(%q, %q) = %v, want %v", "feat", labels, got, want) + } + if got := search.Indices("zzz", labels); len(got) != 0 { + t.Errorf("Indices(%q, %q) = %v, want none", "zzz", labels, got) + } +} diff --git a/internal/termtext/fuzz_test.go b/internal/termtext/fuzz_test.go new file mode 100644 index 0000000..1be1f2d --- /dev/null +++ b/internal/termtext/fuzz_test.go @@ -0,0 +1,40 @@ +package termtext_test + +import ( + "strings" + "testing" + "unicode" + "unicode/utf8" + + "github.com/rednafi/rexdiff/internal/termtext" +) + +// FuzzPlain checks that Plain and Quote remove all unsafe control characters. +func FuzzPlain(f *testing.F) { + for _, s := range []string{ + "a\x00b\n", + "\tx\r\n", + "\x1b[31mred\x1b[0m\n", + "\x1b]52;c;aGVsbG8=\x07\n", + "\u009b31m\u009d0;title\u009c\n", + "\x9b31m\xff\xfe\n", + "日本語😀👨‍👩‍👧\u202e\n", + } { + f.Add(s) + } + f.Fuzz(func(t *testing.T, s string) { + p := termtext.Plain(s) + unsafe := func(r rune) bool { return r != '\t' && r != '\n' && unicode.IsControl(r) } + if strings.ContainsFunc(p, unsafe) { + t.Errorf("Plain(%q) = %q, want no control characters", s, p) + } + if pp := termtext.Plain(p); pp != p { + t.Errorf("Plain(Plain(%q)) = %q, want %q", s, pp, p) + } + q := termtext.Quote(s) + if strings.ContainsFunc(q, unicode.IsControl) || !utf8.ValidString(q) { + t.Errorf("Quote(%q) = %q, want valid UTF-8 with no control characters", + s, q) + } + }) +} diff --git a/internal/termtext/termtext.go b/internal/termtext/termtext.go new file mode 100644 index 0000000..bb65853 --- /dev/null +++ b/internal/termtext/termtext.go @@ -0,0 +1,32 @@ +// Package termtext makes text from other people safe to show in a terminal. +package termtext + +import ( + "strconv" + "strings" + "unicode" + "unicode/utf8" +) + +// Plain returns s without control characters. It keeps tabs and +// newlines. The printable rest of an escape sequence stays, as in +// herdr-reviewr, but it cannot change the terminal. Use Plain on text from +// other people before you show or send the text. +func Plain(s string) string { + return strings.Map(func(r rune) rune { + if r == '\t' || r == '\n' || !unicode.IsControl(r) { + return r + } + return -1 + }, s) +} + +// Quote returns s in Go quotes if it has control characters or is not valid +// UTF-8. git shows such a path the same way. Then s stays on one line and +// cannot change the terminal. +func Quote(s string) string { + if strings.ContainsFunc(s, unicode.IsControl) || !utf8.ValidString(s) { + return strconv.Quote(s) + } + return s +} diff --git a/internal/termtext/termtext_test.go b/internal/termtext/termtext_test.go new file mode 100644 index 0000000..58b2e4e --- /dev/null +++ b/internal/termtext/termtext_test.go @@ -0,0 +1,24 @@ +package termtext_test + +import ( + "testing" + + "github.com/rednafi/rexdiff/internal/termtext" +) + +// TestQuote checks that Quote quotes only the paths that are not safe. +func TestQuote(t *testing.T) { + t.Parallel() + tests := []struct{ in, want string }{ + {"a.go", "a.go"}, + {"日本.go", "日本.go"}, + {"a\x1b]0;x\x07.go", `"a\x1b]0;x\a.go"`}, + {"a\n.go", `"a\n.go"`}, + {"a\x9b31m.go", `"a\x9b31m.go"`}, + } + for _, tt := range tests { + if got := termtext.Quote(tt.in); got != tt.want { + t.Errorf("Quote(%q) = %s, want %s", tt.in, got, tt.want) + } + } +} diff --git a/internal/testgit/testgit.go b/internal/testgit/testgit.go new file mode 100644 index 0000000..73bfcc2 --- /dev/null +++ b/internal/testgit/testgit.go @@ -0,0 +1,195 @@ +// Package testgit has the test helpers that run git. The settings, hooks, +// and templates of the user do not change what git does in a test. +package testgit + +import ( + "errors" + "os" + "os/exec" + "path/filepath" + "slices" + "strings" + "sync" + "testing" + + "github.com/rednafi/rexdiff/internal/proc" +) + +var ( + // root is the directory of the fixtures. It is "" until Main runs. + root string + // gitPath is the path of git, from the PATH of the process. It is "" + // until Main runs, and also if PATH has no git. + gitPath string +) + +// Main runs the tests of m. Each package that runs git calls it from its +// TestMain. Main does not change the environment of the process. +// +// Main finds git in the PATH of the process, before the tests run. If +// PATH has no git, each test that uses a helper of this package skips. +// The helpers run git with [ToolEnv], so the setup of the user does not +// apply. The tests also need /bin/sh and the tools in /usr/bin and /bin. +func Main(m *testing.M) { + dir, err := os.MkdirTemp("", "testgit") + if err != nil { + panic(err) + } + defer func() { _ = os.RemoveAll(dir) }() + if path, err := exec.LookPath("git"); err == nil { + if err := os.Mkdir(filepath.Join(dir, "bin"), 0o755); err != nil { + panic(err) + } + if err := os.Symlink(path, filepath.Join(dir, "bin", "git")); err != nil { + panic(err) + } + gitPath = path + } + root = dir + m.Run() +} + +// Env returns the git variables of a test, as "key=value" pairs. A test +// that runs a program with an environment of its own adds them. +// +// Git reads no global or system config, so no hooksPath, signing, +// fsmonitor, or diff setting applies. Git reads no system attributes +// file. An empty GIT_TEMPLATE_DIR copies no template, so a new repository +// gets no hooks. Git does no maintenance in the background. The commits +// have a fixed author. +func Env() []string { + return []string{ + "GIT_CONFIG_GLOBAL=" + os.DevNull, + "GIT_CONFIG_NOSYSTEM=1", + "GIT_ATTR_NOSYSTEM=1", + "GIT_TEMPLATE_DIR=", + "GIT_AUTHOR_NAME=t", + "GIT_AUTHOR_EMAIL=t@t", + "GIT_COMMITTER_NAME=t", + "GIT_COMMITTER_EMAIL=t@t", + // git starts its maintenance in the background after some commands. + // It then writes into .git while a test copies or reads the + // repository. + "GIT_CONFIG_COUNT=2", + "GIT_CONFIG_KEY_0=maintenance.auto", "GIT_CONFIG_VALUE_0=false", + "GIT_CONFIG_KEY_1=gc.auto", "GIT_CONFIG_VALUE_1=0", + } +} + +// ToolEnv returns the environment of a library that runs git in a test. +// It has the variables of [Env] and PATH. PATH has dirs, then a directory +// with only git, then /usr/bin and /bin. A test puts its fake tools in +// dirs. ToolEnv has no HOME, so no file of the user applies. +func ToolEnv(tb testing.TB, dirs ...string) []string { + tb.Helper() + ready(tb) + path := slices.Concat(dirs, []string{filepath.Join(root, "bin"), "/usr/bin", "/bin"}) + return append(Env(), "PATH="+strings.Join(path, string(os.PathListSeparator))) +} + +// LinkGit makes a link to git in dir. A test that removes the link takes +// git away from a PATH with dir. +func LinkGit(tb testing.TB, dir string) { + tb.Helper() + ready(tb) + if err := os.Symlink(gitPath, filepath.Join(dir, "git")); err != nil { + tb.Fatal(err) + } +} + +// ready fails tb if Main does not run. It skips tb if PATH has no git. +func ready(tb testing.TB) { + tb.Helper() + if root == "" { + tb.Fatal("testgit: TestMain does not call testgit.Main") + } + if gitPath == "" { + tb.Skip("testgit: git is not on PATH") + } +} + +// Git runs git in dir with [ToolEnv] and returns its standard output. It +// fails tb if git fails. +func Git(tb testing.TB, dir string, args ...string) string { + tb.Helper() + cmd := proc.Command(tb.Context(), ToolEnv(tb), "git", args...) + cmd.Dir = dir + out, err := cmd.Output() + if err != nil { + var stderr []byte + if ee, ok := errors.AsType[*exec.ExitError](err); ok { + stderr = ee.Stderr + } + tb.Fatalf("git %v: %v: %s", args, err, stderr) + } + return string(out) +} + +// InitRepo makes a repository in dir with the branch main. It makes dir +// if it does not exist. +func InitRepo(tb testing.TB, dir string) { + tb.Helper() + if err := os.MkdirAll(dir, 0o755); err != nil { + tb.Fatal(err) + } + Git(tb, dir, "init", "-q", "-b", "main") +} + +// WriteFile writes content to the file name in dir. It makes the parent +// directories. +func WriteFile(tb testing.TB, dir, name, content string) { + tb.Helper() + p := filepath.Join(dir, filepath.FromSlash(name)) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + tb.Fatal(err) + } + if err := os.WriteFile(p, []byte(content), 0o644); err != nil { + tb.Fatal(err) + } +} + +// Script writes an executable sh script with body to the file name in +// dir, and returns its path. A test puts dir on PATH to fake a tool. +func Script(tb testing.TB, dir, name, body string) string { + tb.Helper() + p := filepath.Join(dir, name) + if err := os.WriteFile(p, []byte("#!/bin/sh\n"+body), 0o755); err != nil { + tb.Fatal(err) + } + return p +} + +// Fixture is a repository that a package makes once and copies for each +// test. A copy costs less than the git processes that make it. +type Fixture struct { + build func(tb testing.TB, dir string) + once sync.Once + dir string // the repository, or "" if build fails +} + +// NewFixture returns a fixture that build makes in an empty directory. +func NewFixture(build func(tb testing.TB, dir string)) *Fixture { + return &Fixture{build: build} +} + +// Copy copies the repository of f to dir. The first call makes the +// repository with the build function of f. If the build fails, tb of the +// first call gets the failure, and each later call fails. +func (f *Fixture) Copy(tb testing.TB, dir string) { + tb.Helper() + ready(tb) + f.once.Do(func() { + tmp, err := os.MkdirTemp(root, "fixture") + if err != nil { + tb.Fatal(err) + } + f.build(tb, tmp) + f.dir = tmp + }) + if f.dir == "" { + tb.Fatal("testgit: the fixture fails to build") + } + if err := os.CopyFS(dir, os.DirFS(f.dir)); err != nil { + tb.Fatal(err) + } +} diff --git a/internal/tree/tree.go b/internal/tree/tree.go new file mode 100644 index 0000000..aff3c72 --- /dev/null +++ b/internal/tree/tree.go @@ -0,0 +1,170 @@ +// Package tree makes the rows of the file list. The rows show the files +// in a tree of directories that the user can expand and collapse. The +// rules come from herdr-reviewr. +package tree + +import ( + "maps" + "slices" + "strings" +) + +// Entry is a path in the file list. +type Entry struct { + Path string + Changed bool // the file has a change + Ignored bool // git ignores the path + // Dir is true for an ignored directory that git lists as one path. + // Its entries load when the user expands it. + Dir bool +} + +// Row is a directory or a file that the file list shows. +type Row struct { + Depth int // the level of the row in the tree, from 0 + // Name is a directory name or a file name. A chain of directories that + // each have one child shows in one row, for example "a/b". + Name string + Dir string // the path of a directory, or "" for a file + File int // the index of the entry of a file, or -1 for a directory + // Expanded is true for a directory that shows its children. + Expanded bool + // Changed is true for a directory that holds a changed file. + Changed bool + Ignored bool // git ignores the path +} + +// node is a directory of the tree. +type node struct { + dirs map[string]*node + files map[string]int // the entry index of each file, by name + ignored bool + changed bool + // listed is true for a directory from an Entry with Dir. Its row + // stays, so that the user can collapse it. + listed bool +} + +// newNode returns a directory with no entries. +func newNode() *node { return &node{dirs: map[string]*node{}, files: map[string]int{}} } + +// Build returns the rows of entries. Each directory starts expanded if +// expanded is true, else collapsed. toggled has the paths of the +// directories that the user changes from that start. A directory shows +// before the files, and each group is in alphabetical order. +func Build(entries []Entry, toggled map[string]bool, expanded bool) []Row { + root := newNode() + for i, e := range entries { + insert(root, e, i) + } + b := builder{toggled: toggled, expanded: expanded, entries: entries} + b.flatten(root, "", 0) + return b.rows +} + +// builder makes the rows of a tree. The fields are the arguments of +// [Build]. +type builder struct { + rows []Row + toggled map[string]bool + expanded bool + entries []Entry +} + +// insert adds entry e with index i to the tree under root. +func insert(root *node, e Entry, i int) { + parts := strings.FieldsFunc(e.Path, func(r rune) bool { return r == '/' }) + if e.Dir { + cur := root + for _, p := range parts { + cur = cur.child(p) + } + cur.ignored, cur.listed = e.Ignored, true + return + } + if len(parts) == 0 { + return + } + cur := root + for _, p := range parts[:len(parts)-1] { + cur = cur.child(p) + cur.changed = cur.changed || e.Changed + } + cur.files[parts[len(parts)-1]] = i +} + +// child returns the directory name of n. It adds the directory if n does +// not have it. +func (n *node) child(name string) *node { + c := n.dirs[name] + if c == nil { + c = newNode() + n.dirs[name] = c + } + return c +} + +// flatten adds the rows of the children of dir at path prefix. +func (b *builder) flatten(dir *node, prefix string, depth int) { + for _, name := range slices.Sorted(maps.Keys(dir.dirs)) { + display, path, n := compress(name, join(prefix, name), dir.dirs[name]) + if fname, i, ok := loneFile(n); ok { + // A chain that ends in one file shows as one file row. + b.rows = append(b.rows, b.fileRow(depth, display+"/"+fname, i)) + continue + } + open := b.expanded != b.toggled[path] + b.rows = append(b.rows, Row{ + Depth: depth, + Name: display, + Dir: path, + File: -1, + Expanded: open, + Changed: n.changed, + Ignored: n.ignored, + }) + if open { + b.flatten(n, path, depth+1) + } + } + for _, name := range slices.Sorted(maps.Keys(dir.files)) { + b.rows = append(b.rows, b.fileRow(depth, name, dir.files[name])) + } +} + +// compress follows the directories with one child and no files from n. It +// stops at a listed directory. It returns the joined name, the path, and +// the directory where it stops. +func compress(name, path string, n *node) (string, string, *node) { + for !n.listed && len(n.files) == 0 && len(n.dirs) == 1 { + for c, sub := range n.dirs { + name, path, n = name+"/"+c, path+"/"+c, sub + } + } + return name, path, n +} + +// loneFile returns the name and the index of the file of n, if n is not +// listed and has one file and no directories. +func loneFile(n *node) (name string, i int, ok bool) { + if !n.listed && len(n.dirs) == 0 && len(n.files) == 1 { + for name, i := range n.files { + return name, i, true + } + } + return "", 0, false +} + +// fileRow returns the row of entry i at depth, with the name name. +func (b *builder) fileRow(depth int, name string, i int) Row { + return Row{Depth: depth, Name: name, File: i, Ignored: b.entries[i].Ignored} +} + +// join returns the path of name in the directory prefix. An empty prefix +// is the root. +func join(prefix, name string) string { + if prefix == "" { + return name + } + return prefix + "/" + name +} diff --git a/internal/tree/tree_test.go b/internal/tree/tree_test.go new file mode 100644 index 0000000..19b4bbe --- /dev/null +++ b/internal/tree/tree_test.go @@ -0,0 +1,202 @@ +package tree_test + +import ( + "fmt" + "slices" + "strings" + "testing" + + "github.com/rednafi/rexdiff/internal/tree" +) + +// The tests come from file_list.rs of herdr-reviewr. + +// changed returns changed entries for paths. +func changed(paths ...string) []tree.Entry { + var es []tree.Entry + for _, p := range paths { + es = append(es, tree.Entry{Path: p, Changed: true}) + } + return es +} + +// plain returns entries for paths with no change. +func plain(paths ...string) []tree.Entry { + var es []tree.Entry + for _, p := range paths { + es = append(es, tree.Entry{Path: p}) + } + return es +} + +// ignoredDir returns an ignored directory that git lists as one path. +func ignoredDir(path string) tree.Entry { + return tree.Entry{Path: path, Ignored: true, Dir: true} +} + +// shape returns the rows as "depth:dir:name" or "depth:file:name". The +// marks "+" (changed), "!" (ignored), and "v" (expanded) follow the name. +func shape(rows []tree.Row) []string { + var out []string + for _, r := range rows { + kind := "file" + if r.File < 0 { + kind = "dir" + } + var b strings.Builder + fmt.Fprintf(&b, "%d:%s:%s", r.Depth, kind, r.Name) + for _, m := range []struct { + on bool + mark string + }{{r.Changed, "+"}, {r.Ignored, "!"}, {r.Expanded, "v"}} { + if m.on { + b.WriteString(" ") + b.WriteString(m.mark) + } + } + out = append(out, b.String()) + } + return out +} + +// set returns a set of paths. +func set(paths ...string) map[string]bool { + m := map[string]bool{} + for _, p := range paths { + m[p] = true + } + return m +} + +// TestBuild checks the rows that Build makes. +func TestBuild(t *testing.T) { + t.Parallel() + tests := []struct { + name string + entries []tree.Entry + toggled map[string]bool + expanded bool + want []string + }{ + { + name: "directories before files", + entries: changed("src/app.rs", "src/ui.rs", "Cargo.toml"), + expanded: true, + want: []string{ + "0:dir:src + v", "1:file:app.rs", "1:file:ui.rs", + "0:file:Cargo.toml", + }, + }, + { + name: "a chain of one child ends in a file row", + entries: changed("docs/plans/2026/plan.md"), + expanded: true, + want: []string{"0:file:docs/plans/2026/plan.md"}, + }, + { + name: "a chain stops at a branch", + entries: changed("a/b/c/one.rs", "a/b/c/two.rs"), + expanded: true, + want: []string{"0:dir:a/b/c + v", "1:file:one.rs", "1:file:two.rs"}, + }, + { + name: "a collapsed directory hides its children", + entries: changed("src/app.rs", "src/ui.rs"), + toggled: set("src"), + expanded: true, + want: []string{"0:dir:src +"}, + }, + { + name: "all files starts collapsed", + entries: changed("src/app.rs", "src/ui.rs"), + want: []string{"0:dir:src +"}, + }, + { + name: "a toggle expands in all files", + entries: changed("src/app.rs", "src/ui.rs"), + toggled: set("src"), + want: []string{"0:dir:src + v", "1:file:app.rs", "1:file:ui.rs"}, + }, + { + name: "only a directory with a change has the mark", + entries: append(changed("src/ui.rs"), plain("src/app.rs", "docs/a.md", "docs/b.md")...), + want: []string{"0:dir:docs", "0:dir:src +"}, + }, + { + name: "the mark stays when the directory expands", + entries: append(changed("src/ui.rs"), plain("src/app.rs", "docs/a.md", "docs/b.md")...), + toggled: set("src"), + want: []string{ + "0:dir:docs", "0:dir:src + v", "1:file:app.rs", + "1:file:ui.rs", + }, + }, + { + name: "a deep change marks each directory above it", + entries: append(changed("a/b/deep.rs"), plain("a/top.rs", "a/b/other.rs")...), + toggled: set("a"), + want: []string{"0:dir:a + v", "1:dir:b +", "1:file:top.rs"}, + }, + { + name: "an ignored directory starts collapsed", + entries: []tree.Entry{ignoredDir("target")}, + want: []string{"0:dir:target !"}, + }, + { + name: "the entries of an expanded ignored directory show under it", + entries: []tree.Entry{ + ignoredDir("target"), + {Path: "target/a.o", Ignored: true}, + {Path: "target/b.o", Ignored: true}, + }, + toggled: set("target"), + want: []string{"0:dir:target ! v", "1:file:a.o !", "1:file:b.o !"}, + }, + { + // The user can collapse such a directory. + name: "an ignored directory with one child keeps its row", + entries: []tree.Entry{ + ignoredDir("a"), + ignoredDir("a/b"), + {Path: "a/b/c.o", Ignored: true}, + }, + toggled: set("a", "a/b"), + want: []string{"0:dir:a ! v", "1:dir:b ! v", "2:file:c.o !"}, + }, + { + name: "an ignored file", + entries: []tree.Entry{{Path: "build.log", Ignored: true}}, + want: []string{"0:file:build.log !"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := shape(tree.Build(tt.entries, tt.toggled, tt.expanded)) + if !slices.Equal(got, tt.want) { + t.Errorf("Build = %q, want %q", got, tt.want) + } + }) + } +} + +// TestBuildIndex checks the entry index of a file row, and the path of a +// directory row. +func TestBuildIndex(t *testing.T) { + t.Parallel() + es := append(changed("z.rs", "a/b/deep.rs"), plain("a/top.rs", "a/b/other.rs")...) + rows := tree.Build(es, nil, true) + type place struct { + dir string + file int + } + var got []place + for _, r := range rows { + got = append(got, place{r.Dir, r.File}) + } + // The rows are in alphabetical order, so z.rs is last. + want := []place{{"a", -1}, {"a/b", -1}, {"", 1}, {"", 3}, {"", 2}, {"", 0}} + if !slices.Equal(got, want) { + t.Errorf("Build places = %v, want %v (rows %q)", got, want, shape(rows)) + } +} diff --git a/internal/turn/turn.go b/internal/turn/turn.go new file mode 100644 index 0000000..d925308 --- /dev/null +++ b/internal/turn/turn.go @@ -0,0 +1,71 @@ +// Package turn finds the start and the end of each turn of the agents in +// a repository, from the states of the agents. A turn starts when the +// agents go from rest to work. It ends when they rest again. The rules +// come from herdr-reviewr. +package turn + +import "slices" + +// state is the work state of a repository. fold makes it from the states +// of all the agents in the repository. +type state int + +const ( + // stateResting tells that each agent rests, or that there is no agent. + stateResting state = iota + // stateWorking tells that at least one agent works. + stateWorking + // stateHeld tells that no agent works, but one does not rest. For + // example, it waits at a prompt. A turn that is open stays open. + stateHeld +) + +// rests reports whether s is a state between turns. A blocked agent and an +// unknown state are in the middle of a turn. +func rests(s string) bool { return s == "idle" || s == "done" } + +// fold returns the state of a repository whose agents are in the states +// states. One working agent makes the repository work. +func fold(states ...string) state { + if slices.Contains(states, "working") { + return stateWorking + } + if slices.ContainsFunc(states, func(s string) bool { return !rests(s) }) { + return stateHeld + } + return stateResting +} + +// Tracker finds the start and the end of each turn in a series of states. +// The zero Tracker is ready to use. +type Tracker struct { + // prevResting is true if the last state is resting. It is false at + // first, so the first state starts no turn. + prevResting bool + // worked is true if the repository works after its last rest. An end + // can come through held, but a start cannot. Thus the two edges keep + // different memories. + worked bool +} + +// Observe takes the states of all the agents in the repository at one +// time. The states are those of the OSC 7501 protocol, for example +// "idle". A turn starts when one agent works after all rest. A turn ends +// when all rest after work, also through a prompt. +func (t *Tracker) Observe(states ...string) (started, ended bool) { + s := fold(states...) + started = s == stateWorking && t.prevResting + ended = t.worked && s == stateResting + t.prevResting = s == stateResting + switch s { + case stateWorking: + t.worked = true + case stateResting: + t.worked = false + } + return started, ended +} + +// Gap tells the Tracker that nobody sees the agents for some time. Then +// the next state cannot start a turn. +func (t *Tracker) Gap() { t.prevResting = false } diff --git a/internal/turn/turn_test.go b/internal/turn/turn_test.go new file mode 100644 index 0000000..843757e --- /dev/null +++ b/internal/turn/turn_test.go @@ -0,0 +1,130 @@ +package turn_test + +import ( + "testing" + + "github.com/rednafi/rexdiff/internal/turn" +) + +// The tests come from turn.rs of herdr-reviewr. + +// The states of one agent that give each state of a repository. +var ( + resting = []string{"idle"} + working = []string{"working"} + held = []string{"blocked"} +) + +// step is one call to a Tracker and the edges that it gives. With gap, +// the step calls Gap and not Observe. +type step struct { + states []string + gap bool + started, ended bool +} + +// TestFold checks how the states of the agents fold into one state. After a +// rest, only work starts a turn. After work, only a rest ends it. +func TestFold(t *testing.T) { + t.Parallel() + tests := []struct { + name string + states []string + // starts tells that the states start a turn after a rest. ends + // tells that they end a turn after work. A held state does neither. + starts, ends bool + }{ + // No agent rests, so a fresh pane tracks the next turn it sees. + {"no agent", nil, false, true}, + {"idle and working", []string{"idle", "working"}, true, false}, + {"blocked and working", []string{"blocked", "working"}, true, false}, + {"idle and done", []string{"idle", "done"}, false, true}, + {"blocked and idle", []string{"blocked", "idle"}, false, false}, + // An unknown state never rests, so it starts no turn. + {"unknown", []string{"compacting"}, false, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + var rest turn.Tracker + rest.Observe(resting...) + if started, _ := rest.Observe(tt.states...); started != tt.starts { + t.Errorf("after a rest: started %v, want %v", started, tt.starts) + } + var work turn.Tracker + work.Observe(resting...) + work.Observe(working...) + if _, ended := work.Observe(tt.states...); ended != tt.ends { + t.Errorf("after work: ended %v, want %v", ended, tt.ends) + } + }) + } +} + +// TestTracker checks the edges of a series of states. +func TestTracker(t *testing.T) { + t.Parallel() + tests := []struct { + name string + steps []step + }{ + {"empty repo starts on the first worker", []step{ + {states: nil}, + {states: working, started: true}, + }}, + // The first state never starts a turn. + {"start after a rest", []step{ + {states: resting}, + {states: working, started: true}, + }}, + // A prompt in the middle of a turn does not start another turn. + {"held back to work continues", []step{ + {states: resting}, + {states: working, started: true}, + {states: held}, + {states: working}, + }}, + {"end only after work", []step{ + {states: resting}, // no work before, so no end + {states: working, started: true}, // a start is never an end + {states: held}, // a pause, not an end + {states: working}, + {states: resting, ended: true}, + }}, + // The prompt keeps the turn open. A rest after it ends the turn once. + {"end through held", []step{ + {states: resting}, + {states: working, started: true}, + {states: held}, + {states: resting, ended: true}, + {states: resting}, + }}, + // The start of this turn is not seen. + {"lone first working starts nothing", []step{ + {states: working}, + }}, + // Nobody sees the agents in a gap, so the next work starts no turn. + {"gap", []step{ + {states: resting}, + {gap: true}, + {states: working}, + }}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + var tr turn.Tracker + for i, st := range tt.steps { + if st.gap { + tr.Gap() + continue + } + started, ended := tr.Observe(st.states...) + if started != st.started || ended != st.ended { + t.Errorf("step %d (%q): started %v, ended %v, want %v, %v", + i, st.states, started, ended, st.started, st.ended) + } + } + }) + } +} diff --git a/rex/rex.go b/rex/rex.go index 115823d..8a31eb1 100644 --- a/rex/rex.go +++ b/rex/rex.go @@ -6,6 +6,10 @@ // for example "pi" and "working". This is the status of the program. A // [Client] finds the agents in a session, pastes text into an agent, and // gets each status change. +// +// The caller gives the environment as a value, as in [exec.Cmd.Env]. The +// package finds rex with it and runs rex with it. It does not read the +// environment of the process. package rex import ( @@ -14,54 +18,66 @@ import ( "cmp" "context" "encoding/json/v2" + "fmt" "os" "os/exec" "path/filepath" + "runtime" "slices" "strings" "sync" "time" + + "github.com/rednafi/rexdiff/internal/proc" ) // Client runs rex commands in one Rex session. type Client struct { - Block string // the block of this process, or "" - session string // the ID of the session - bin string // the path of the rex command, or "" for "rex" in PATH -} - -// FromEnv returns a client for the session of this process. Rex sets -// REX_SESSION and REX_BLOCK in each block. FromEnv returns nil if the -// process does not run in Rex. -func FromEnv() *Client { - session := os.Getenv("REX_SESSION") + block string // the block of this process, or "" + session string // the ID of the session + bin string // the path of the rex command, or "" for "rex" in PATH + env []string // the environment of rex +} + +// New returns a client for the session in the environment env, for +// example the environment of the process from [os.Environ]. Rex sets +// REX_SESSION and REX_BLOCK in each block. New returns nil if env is not +// the environment of a Rex block. The client runs rex with env. +func New(env []string) *Client { + session := proc.Getenv(env, "REX_SESSION") if session == "" { return nil } return &Client{ - Block: os.Getenv("REX_BLOCK"), + block: proc.Getenv(env, "REX_BLOCK"), session: session, - bin: binFromEnv(), + bin: binFromEnv(env), + env: env, } } -// binFromEnv returns the path of the rex command. Rex sets REX_BIN_DIR -// in its terminals. Other programs, for example brew, do not have it. -// Then the path is "" for rex in PATH. If rex is not in PATH, the path -// is the rex of the Rex server app on macOS. -func binFromEnv() string { - if dir := os.Getenv("REX_BIN_DIR"); dir != "" { +// Block returns the block of this process, or "" if Rex does not tell it. +func (c *Client) Block() string { return c.block } + +// binFromEnv returns the path of the rex command in env, or "" for the rex +// in PATH. Rex sets REX_BIN_DIR in its terminals. Other programs, for +// example brew, do not have it. If REX_BIN_DIR is empty and PATH has no +// rex, binFromEnv looks in HOME on macOS. It returns the rex of the Rex +// server app, if it is there. +func binFromEnv(env []string) string { + if dir := proc.Getenv(env, "REX_BIN_DIR"); dir != "" { return filepath.Join(dir, "rex") } - if _, err := exec.LookPath("rex"); err == nil { + if _, err := proc.LookPath(env, "rex"); err == nil { return "" } - dir, err := os.UserConfigDir() - if err != nil { + home := proc.Getenv(env, "HOME") + if runtime.GOOS != "darwin" || home == "" { return "" } - bin := filepath.Join(dir, "com.superlogical.rex", "RexServer", "Versions", - "Current", "Rex Server.app", "Contents", "MacOS", "rex") + // The app is in the directory of os.UserConfigDir on macOS. + dir := filepath.Join(home, "Library", "Application Support") + bin := filepath.Join(dir, "com.superlogical.rex", "RexServer", "Versions", "Current", "Rex Server.app", "Contents", "MacOS", "rex") if _, err := os.Stat(bin); err != nil { return "" } @@ -70,7 +86,7 @@ func binFromEnv() string { // command returns the command "rex args...". func (c *Client) command(ctx context.Context, args ...string) *exec.Cmd { - return exec.CommandContext(ctx, cmp.Or(c.bin, "rex"), args...) + return proc.Command(ctx, c.env, cmp.Or(c.bin, "rex"), args...) } // runRex runs "rex args..." and returns the output. If rex fails, the @@ -80,32 +96,23 @@ func (c *Client) runRex(ctx context.Context, args ...string) ([]byte, error) { var stderr bytes.Buffer cmd.Stderr = &stderr out, err := cmd.Output() - if err == nil { - return out, nil + if err != nil { + return nil, proc.Error(ctx, "rex", err, stderr.Bytes()) } - msg := strings.TrimSpace(stderr.String()) - msg = strings.TrimPrefix(msg, "Error: ") - msg, _, _ = strings.Cut(msg, "\n") - return nil, &runError{msg: cmp.Or(msg, err.Error()), err: err} + return out, nil } -// runError is a failed run of rex. Its text is the first line that rex -// writes to stderr. It wraps the error of the process, for errors.As. -type runError struct { - msg string - err error -} - -func (e *runError) Error() string { return e.msg } -func (e *runError) Unwrap() error { return e.err } - -// runRexJSON runs "rex args..." and decodes the JSON output into v. +// runRexJSON runs "rex args..." and decodes the JSON output into v. An +// error of the decoder names the rex command. func (c *Client) runRexJSON(ctx context.Context, v any, args ...string) error { out, err := c.runRex(ctx, args...) if err != nil { return err } - return json.Unmarshal(out, v) + if err := json.Unmarshal(out, v); err != nil { + return fmt.Errorf("rex %s: %w", strings.Join(args[:min(len(args), 2)], " "), err) + } + return nil } // request sends a request to the API of the session. It runs @@ -120,26 +127,30 @@ func (c *Client) request(ctx context.Context, method string, args, reply any) er if err != nil || reply == nil { return err } - return json.Unmarshal(out, reply) + if err := json.Unmarshal(out, reply); err != nil { + return fmt.Errorf("rex api call %s: %w", method, err) + } + return nil } // Status is the status that a program sends to Rex with OSC 7501. type Status struct { App string // the name of the program, for example "pi" or "claude-code" State State + At time.Time // when Rex got the status, or zero if Rex does not tell } // State tells what a program does. The zero State means that the program // sends no status. type State string -// The states of the OSC 7501 protocol that rexdiff uses. A State can have -// other values, for example "error". +// The known states of the OSC 7501 protocol. A State can have other +// values, for example "error". const ( StateIdle State = "idle" // waits for the next instruction of the user StateWorking State = "working" // does a task - StateDone State = "done" // finished a task, and the user did not look yet - StateBlocked State = "blocked" // cannot continue until the user does something + StateDone State = "done" // waits for the user to see the done task + StateBlocked State = "blocked" // waits for an action of the user ) // statusEntry is a status as Rex sends it. A program can send one status @@ -151,41 +162,42 @@ type statusEntry struct { App string `json:"app"` // EffectiveApp is the name that Rex shows. It can differ from App. EffectiveApp string `json:"effective_app"` + UpdatedAt string `json:"updated_at"` // RFC 3339 } // status returns the entry as a Status, with the name that Rex shows. func (e statusEntry) status() Status { + at, _ := time.Parse(time.RFC3339Nano, e.UpdatedAt) // zero if bad return Status{ App: cmp.Or(e.EffectiveApp, e.App), State: e.State, + At: at, } } // Status returns the status of the program in block. If the program sends -// no status, Status returns the zero Status. +// no status, Status returns the zero Status. The statuses of the +// sub-agents of the program do not count, as in [Client.Watch]. func (c *Client) Status(ctx context.Context, block string) (Status, error) { var reply struct { Entries []statusEntry `json:"records"` } args := []string{"block", "call", "-s", c.session, "-b", block, "program_status"} - err := c.runRexJSON(ctx, &reply, args...) - if err != nil || len(reply.Entries) == 0 { + if err := c.runRexJSON(ctx, &reply, args...); err != nil { return Status{}, err } - for _, e := range reply.Entries { - if e.ID == "" { - return e.status(), nil - } + if i := slices.IndexFunc(reply.Entries, func(e statusEntry) bool { return e.ID == "" }); i >= 0 { + return reply.Entries[i].status(), nil } - return reply.Entries[0].status(), nil + return Status{}, nil } // Cwd returns the working directory of the program in block. func (c *Client) Cwd(ctx context.Context, block string) (string, error) { type process struct { - Cwd string `json:"cwd"` // a file:// URL + Cwd string `json:"cwd"` // a path, or a file:// URL } - // The foreground process is the program that has the terminal now, for + // The foreground process is the program that holds the terminal, for // example an agent that runs in a shell. The child is the shell. var reply struct { Foreground process `json:"foreground"` @@ -202,10 +214,10 @@ func (c *Client) Cwd(ctx context.Context, block string) (string, error) { // Agent is a block whose program sends a status with an app name. type Agent struct { Block string // the ID of the block - Name string // the label of the block, or the app if the block has no label + Name string // the label of the block, or else the app name Window string // the label of the window of the block, or "" Cwd string // the working directory of the program - Status + Status Status } // terminalCreator is the creator that Rex gives to a terminal block. @@ -213,7 +225,8 @@ type Agent struct { const terminalCreator = "com.superlogical.terminal" // Agents returns the agents in the session, in the order of the layout. -// It does not return the block of the client. +// It does not return the block of the client. If ctx ends, Agents returns +// the cause of ctx. func (c *Client) Agents(ctx context.Context) ([]Agent, error) { // Get the windows at the same time as the blocks. var windows []window @@ -237,7 +250,7 @@ func (c *Client) Agents(ctx context.Context) ([]Agent, error) { // an agent if its program sends an app name. agents := make([]Agent, len(reply.Blocks)) for i, b := range reply.Blocks { - if b.ID == c.Block || b.Creator != terminalCreator { + if b.ID == c.block || b.Creator != terminalCreator { continue } wg.Go(func() { @@ -245,6 +258,8 @@ func (c *Client) Agents(ctx context.Context) ([]Agent, error) { if err != nil || status.App == "" { return } + // If Rex cannot tell the directory, Cwd is "". The agent stays + // in the list. cwd, _ := c.Cwd(ctx, b.ID) agents[i] = Agent{ Block: b.ID, @@ -255,6 +270,11 @@ func (c *Client) Agents(ctx context.Context) ([]Agent, error) { }) } wg.Wait() + if ctx.Err() != nil { + // A status call that fails at the deadline does not tell that the + // block is not an agent. + return nil, context.Cause(ctx) + } for i, b := range reply.Blocks { agents[i].Window = windowLabel(windows, b.WindowID) @@ -269,7 +289,8 @@ type window struct { Label string `json:"label"` } -// windows returns the windows of the session. If rex fails, it returns nil. +// windows returns the windows of the session. If rex fails, windows returns +// nil. func (c *Client) windows(ctx context.Context) []window { var reply struct { Windows []window `json:"windows"` @@ -283,10 +304,8 @@ func (c *Client) windows(ctx context.Context) []window { // windowLabel returns the label of the window with the ID id. If no window // has this ID, it returns "". func windowLabel(windows []window, id string) string { - for _, w := range windows { - if w.ID == id { - return w.Label - } + if i := slices.IndexFunc(windows, func(w window) bool { return w.ID == id }); i >= 0 { + return windows[i].Label } return "" } @@ -349,41 +368,47 @@ func (c *Client) SetLabel(ctx context.Context, block, label string) error { // Split opens a new terminal block next to the block of the client. The // new block runs the command argv in the directory cwd. func (c *Client) Split(ctx context.Context, cwd string, argv ...string) error { - args := []string{"split", "-s", c.session, "-b", c.Block, "--cwd", cwd, "--"} + args := []string{"split", "-s", c.session, "-b", c.block, "--cwd", cwd, "--"} _, err := c.runRex(ctx, append(args, argv...)...) return err } -// ConfigPath returns the path of the configuration file of Rex. This is -// $REX_CONFIG if it is set. If not, it is init.lua in the rex directory of -// $XDG_CONFIG_HOME, or of ~/.config. -func ConfigPath() string { - if path := os.Getenv("REX_CONFIG"); path != "" { +// ConfigPath returns the path of the configuration file of Rex in the +// environment env. This is $REX_CONFIG if it is set. If not, it is +// init.lua in the rex directory of $XDG_CONFIG_HOME, or of $HOME/.config. +func ConfigPath(env []string) string { + if path := proc.Getenv(env, "REX_CONFIG"); path != "" { return path } - dir := os.Getenv("XDG_CONFIG_HOME") + dir := proc.Getenv(env, "XDG_CONFIG_HOME") if dir == "" { - home, _ := os.UserHomeDir() - dir = filepath.Join(home, ".config") + dir = filepath.Join(proc.Getenv(env, "HOME"), ".config") } return filepath.Join(dir, "rex", "init.lua") } -// ReloadConfig tells Rex to read its configuration file again. It returns -// the report of Rex. It also works outside a Rex session. -func ReloadConfig(ctx context.Context) (string, error) { - c := &Client{bin: binFromEnv()} - out, err := c.runRex(ctx, "config", "reload") - return string(out), err +// ReloadConfig tells Rex to read its configuration file again. It runs +// rex with the environment env. It also works outside a Rex session. +func ReloadConfig(ctx context.Context, env []string) error { + c := &Client{bin: binFromEnv(env), env: env} + _, err := c.runRex(ctx, "config", "reload") + return err } -// Event is a change to a block in the session. +// Event is a change to a block in the session, or a reconnect. type Event struct { Block string // the ID of the block Closed bool // true if the block closed // Status is the new status of the program in the block. If the program - // removed its status, Status is the zero Status. + // removes its status, Status is the zero Status. Status Status + // Reconnect is true if Watch starts rex again. The events between the + // two runs of rex are lost. A Reconnect event has no block. + Reconnect bool + // Err is the error of a run of rex that fails, with the first line + // of its stderr. An event with Err has no block. Watch starts rex + // again after the event. + Err error } const ( @@ -394,39 +419,58 @@ const ( ) // Watch calls fn for each event in the session, from one goroutine. It -// runs "rex events" to get the events. If rex stops, Watch starts it again -// after 2 seconds. Watch returns the error of ctx when ctx is done. +// runs "rex events" to get the events. If rex fails, Watch calls fn with +// an event that has the error. If rex stops, Watch starts it again after +// 2 seconds. Then it calls fn with a Reconnect event before the next +// events. Watch returns the cause of ctx when ctx is done. func (c *Client) Watch(ctx context.Context, fn func(Event)) error { - for { - c.watchOnce(ctx, fn) + for again := false; ; again = true { + if err := c.watchOnce(ctx, again, fn); err != nil && ctx.Err() == nil { + fn(Event{Err: err}) + } select { case <-ctx.Done(): - return ctx.Err() + return context.Cause(ctx) case <-time.After(reconnectDelay): } } } // watchOnce runs "rex events" and calls fn for each event until rex stops. -func (c *Client) watchOnce(ctx context.Context, fn func(Event)) { +// If again is true, the first event is a Reconnect event. It returns the +// error of rex, with the first line of its stderr. +func (c *Client) watchOnce(ctx context.Context, again bool, fn func(Event)) error { cmd := c.command(ctx, "events", "-s", c.session, "--json") + var stderr bytes.Buffer + cmd.Stderr = &stderr out, err := cmd.StdoutPipe() - if err != nil || cmd.Start() != nil { - return + if err != nil { + return fmt.Errorf("rex events: %w", err) + } + if err := cmd.Start(); err != nil { + return fmt.Errorf("rex events: %w", proc.Error(ctx, "rex", err, nil)) + } + if again { + fn(Event{Reconnect: true}) } lines := bufio.NewScanner(out) - lines.Buffer(make([]byte, maxEventLine), maxEventLine) + lines.Buffer(nil, maxEventLine) for lines.Scan() { if ev, ok := parseEvent(lines.Bytes()); ok { fn(ev) } } - if lines.Err() != nil { + if err := lines.Err(); err != nil { // A line is too long. Stop rex, because rex can wait until Watch // reads the rest of its output. - cmd.Process.Kill() + _ = cmd.Process.Kill() + _ = cmd.Wait() + return fmt.Errorf("rex events: %w", err) + } + if err := cmd.Wait(); err != nil { + return fmt.Errorf("rex events: %w", proc.Error(ctx, "rex", err, stderr.Bytes())) } - cmd.Wait() + return nil } // blockEvent is an event of one block in "rex events --json". @@ -450,7 +494,8 @@ func parseEvent(line []byte) (Event, bool) { var msg struct { Payload struct { Event struct { - Type string `json:"type"` // "block_event" or "block_closed" + // Type is "block_event" or "block_closed". + Type string `json:"type"` Block blockEvent `json:"payload"` } `json:"event"` } `json:"payload"` diff --git a/rex/rex_test.go b/rex/rex_test.go index 549353b..93db25a 100644 --- a/rex/rex_test.go +++ b/rex/rex_test.go @@ -3,6 +3,7 @@ package rex_test import ( "context" "errors" + "fmt" "os" "os/exec" "path/filepath" @@ -10,18 +11,39 @@ import ( "slices" "strings" "testing" + "testing/synctest" "time" + "github.com/rednafi/rexdiff/internal/testgit" "github.com/rednafi/rexdiff/rex" ) -// fakeRex writes a shell script that answers like the rex tool, with -// recorded output. It logs each call to the file "calls" in its directory. -func fakeRex(t *testing.T) (*rex.Client, string) { +// client writes a rex script with body in a new directory. It returns the +// client of New in the environment of the block block:self in the session +// S, and the directory. +func client(t *testing.T, body string) (*rex.Client, string) { t.Helper() dir := t.TempDir() - script := `#!/bin/sh -printf "%s\n" "$*" >> "` + dir + `/calls" + testgit.Script(t, dir, "rex", body) + env := []string{ + "PATH=/usr/bin:/bin", + "REX_SESSION=S", + "REX_BLOCK=block:self", + "REX_BIN_DIR=" + dir, + } + c := rex.New(env) + if c == nil || c.Block() != "block:self" { + t.Fatalf("New(%q) = %+v, want the block block:self", env, c) + } + return c, dir +} + +// fakeRex returns a client of a rex script that answers with recorded +// output. The script logs each call to the file "calls" in its directory, +// and prints the file "events" for "rex events". +func fakeRex(t *testing.T) (*rex.Client, string) { + t.Helper() + script := `printf "%s\n" "$*" >> "${0%/*}/calls" case "$*" in "api call -s S session.list_blocks {}") echo '{"blocks":[ @@ -55,24 +77,17 @@ case "$*" in echo 'Error: no block matches nosuch more detail' >&2; exit 1 ;; "events -s S --json") - cat "` + dir + `/events" ;; + cat "${0%/*}/events" ;; *) ;; esac ` - bin := filepath.Join(dir, "rex") - if err := os.WriteFile(bin, []byte(script), 0o755); err != nil { - t.Fatal(err) - } - // The client comes from the environment that Rex gives a block. - t.Setenv("REX_SESSION", "S") - t.Setenv("REX_BLOCK", "block:self") - t.Setenv("REX_BIN_DIR", dir) - return rex.FromEnv(), dir + return client(t, script) } // TestAgents checks that Agents returns only the terminal blocks that send // an app name, without the block of the client. func TestAgents(t *testing.T) { + t.Parallel() c, _ := fakeRex(t) agents, err := c.Agents(t.Context()) if err != nil { @@ -84,36 +99,37 @@ func TestAgents(t *testing.T) { Name: "agent", Window: "main", Cwd: "/repo", - App: "pi", - State: rex.StateBlocked, + Status: rex.Status{App: "pi", State: rex.StateBlocked}, }, { Block: "block:cc", Name: "claude-code", Window: "side", Cwd: "/repo", - App: "claude-code", - State: rex.StateWorking, + Status: rex.Status{App: "claude-code", State: rex.StateWorking}, }, } if !slices.Equal(agents, want) { - t.Fatalf("Agents:\n%+v\nwant:\n%+v", agents, want) + t.Errorf("Agents = \n%+v\nwant:\n%+v", agents, want) } } -// TestCommands checks the rex calls of Resolve, Paste, Focus, and Split. +// TestCommands checks the rex calls of Resolve, Paste, Focus, Split, and +// SetLabel. func TestCommands(t *testing.T) { + t.Parallel() c, dir := fakeRex(t) ctx := t.Context() if id, err := c.Resolve(ctx, "agent"); err != nil || id != "block:pi" { - t.Errorf("Resolve(agent) = %q, %v", id, err) + t.Errorf("Resolve(agent) = %q, %v, want block:pi, nil", id, err) } if id, _ := c.Resolve(ctx, "block:x"); id != "block:x" { - t.Errorf("Resolve(block:x) = %q", id) + t.Errorf("Resolve(block:x) = %q, want block:x", id) } _, err := c.Resolve(ctx, "nosuch") if err == nil || err.Error() != "no block matches nosuch" { - t.Errorf("Resolve(nosuch): err = %v", err) + t.Errorf("Resolve(nosuch): err = %v, want %q", + err, "no block matches nosuch") } if err := c.Paste(ctx, "block:pi", "a\n\tb"); err != nil { t.Fatal(err) @@ -124,17 +140,24 @@ func TestCommands(t *testing.T) { if err := c.Split(ctx, "/repo", "sh", "-c", "vi x"); err != nil { t.Fatal(err) } + if err := c.SetLabel(ctx, "block:self", "rex\"diff"); err != nil { + t.Fatal(err) + } - b, _ := os.ReadFile(filepath.Join(dir, "calls")) + b, err := os.ReadFile(filepath.Join(dir, "calls")) + if err != nil { + t.Fatal(err) + } calls := string(b) for _, want := range []string{ `api call -s S block.event {"block_id":"block:pi",` + `"event":{"type":"paste","payload":{"text":"a\n\tb"}}}`, `api call -s S session.focus_block {"block_id":"block:pi"}`, `split -s S -b block:self --cwd /repo -- sh -c vi x`, + `api call -s S session.set_block_label {"block_id":"block:self","label":"rex\"diff"}`, } { if !strings.Contains(calls, want) { - t.Errorf("no call %s in:\n%s", want, calls) + t.Errorf("calls = \n%s\nwant a call:\n%s", calls, want) } } } @@ -148,112 +171,126 @@ func blockEvent(block, name, payload string) string { } // TestWatch checks that Watch reports only the changes of the status of -// the program, and closed blocks. +// the program, with the time of Rex, and closed blocks. The fake rex stops +// after the events, so Watch reports a reconnect and then the events +// again. func TestWatch(t *testing.T) { - c, dir := fakeRex(t) - events := strings.Join([]string{ - blockEvent("block:pi", "program_status_changed", `{"id":"",`+ - `"record":{"state":"working","kind":"","app":"x","effective_app":"pi"},`+ - `"reason":"report"}`), - blockEvent("block:pi", "program_status_changed", - `{"id":"sub","record":{"state":"idle"}}`), - `not json`, - // The removal of a sub record is not a change of the agent. - blockEvent("block:pi", "program_status_removed", - `{"ids":["sub"],"reason":"clear"}`), - blockEvent("block:pi", "program_status_removed", - `{"ids":[""],"reason":"clear"}`), - `{"type":"session_event","payload":{"event":{"type":"block_closed",` + - `"payload":{"block_id":"block:pi"}}}}`, - }, "\n") - path := filepath.Join(dir, "events") - if err := os.WriteFile(path, []byte(events+"\n"), 0o644); err != nil { - t.Fatal(err) - } + t.Parallel() + // synctest has a fake clock. Thus the test does not wait 2 seconds + // before rex starts again. + synctest.Test(t, func(t *testing.T) { + c, dir := fakeRex(t) + events := strings.Join([]string{ + blockEvent("block:pi", "program_status_changed", `{"id":"",`+ + `"record":{"state":"working","kind":"","app":"x","effective_app":"pi",`+ + `"updated_at":"2026-10-25T21:11:03.5Z"},`+ + `"reason":"report"}`), + blockEvent("block:pi", "program_status_changed", + `{"id":"sub","record":{"state":"idle"}}`), + `not json`, + // The removal of a sub record is not a change of the agent. + blockEvent("block:pi", "program_status_removed", + `{"ids":["sub"],"reason":"clear"}`), + blockEvent("block:pi", "program_status_removed", + `{"ids":[""],"reason":"clear"}`), + `{"type":"session_event","payload":{"event":{"type":"block_closed",` + + `"payload":{"block_id":"block:pi"}}}}`, + }, "\n") + testgit.WriteFile(t, dir, "events", events+"\n") - want := []rex.Event{ - {Block: "block:pi", Status: rex.Status{App: "pi", State: rex.StateWorking}}, - {Block: "block:pi"}, - {Block: "block:pi", Closed: true}, - } - // Stop Watch when it has reported all the events. - ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) - defer cancel() - var got []rex.Event - err := c.Watch(ctx, func(e rex.Event) { - got = append(got, e) - if len(got) == len(want) { - cancel() + at := time.Date(2026, 10, 25, 21, 11, 3, 5e8, time.UTC) + once := []rex.Event{ + {Block: "block:pi", Status: rex.Status{App: "pi", State: rex.StateWorking, At: at}}, + {Block: "block:pi"}, + {Block: "block:pi", Closed: true}, + } + want := slices.Concat(once, []rex.Event{{Reconnect: true}}, once) + // Stop Watch when it reports all the events. + ctx, cancel := context.WithCancelCause(t.Context()) + defer cancel(fmt.Errorf("test: done: %w", context.Canceled)) + errAll := errors.New("the test has all the events") + var got []rex.Event + err := c.Watch(ctx, func(e rex.Event) { + if got = append(got, e); len(got) == len(want) { + cancel(errAll) + } + }) + if !errors.Is(err, errAll) { + t.Errorf("Watch = %v, want the cause %v", err, errAll) + } + if !slices.Equal(got, want) { + t.Errorf("events = \n%+v\nwant:\n%+v", got, want) } }) - if !errors.Is(err, context.Canceled) { - t.Errorf("Watch returned %v, want context.Canceled", err) - } - if !slices.Equal(got, want) { - t.Errorf("events:\n%+v\nwant:\n%+v", got, want) - } } -// TestFromEnv checks that FromEnv uses the environment that Rex sets. -func TestFromEnv(t *testing.T) { - t.Setenv("REX_SESSION", "") - if c := rex.FromEnv(); c != nil { - t.Errorf("FromEnv outside Rex = %+v, want nil", c) - } - // fakeRex sets the environment of a block. The client runs the rex - // command of REX_BIN_DIR, in the session REX_SESSION. - c, dir := fakeRex(t) - if c == nil || c.Block != "block:self" { - t.Fatalf("FromEnv = %+v, want the block block:self", c) +// TestNew checks that New returns nil outside Rex. The client of the +// other tests comes from New in a fake block. +func TestNew(t *testing.T) { + t.Parallel() + for _, env := range [][]string{nil, {"REX_SESSION=S", "REX_SESSION="}} { + if c := rex.New(env); c != nil { + t.Errorf("New(%q) outside Rex = %+v, want nil", env, c) + } } - if err := c.Focus(t.Context(), "block:pi"); err != nil { - t.Fatal(err) +} + +// TestConfigPath checks the places of the configuration file of Rex. +func TestConfigPath(t *testing.T) { + t.Parallel() + tests := []struct { + env []string + want string + }{ + {[]string{"HOME=/h", "XDG_CONFIG_HOME=/x", "REX_CONFIG=/r.lua"}, "/r.lua"}, + {[]string{"HOME=/h", "XDG_CONFIG_HOME=/x"}, "/x/rex/init.lua"}, + {[]string{"HOME=/h", "XDG_CONFIG_HOME="}, "/h/.config/rex/init.lua"}, } - calls, err := os.ReadFile(filepath.Join(dir, "calls")) - if err != nil || !strings.Contains(string(calls), "api call -s S ") { - t.Errorf("calls %q, want a call to the fake rex in session S", calls) + for _, tt := range tests { + if got := rex.ConfigPath(tt.env); got != tt.want { + t.Errorf("ConfigPath(%q) = %q, want %q", tt.env, got, tt.want) + } } } // TestReloadConfigOutsideRex checks that ReloadConfig runs the rex of the -// Rex server app outside a Rex terminal. There, for example under brew, -// REX_BIN_DIR is not set and rex is not in PATH. +// Rex server app outside a Rex terminal. func TestReloadConfigOutsideRex(t *testing.T) { + t.Parallel() if runtime.GOOS != "darwin" { t.Skip("the Rex server app is on macOS") } + // Outside a Rex terminal, for example under brew, REX_BIN_DIR is not set + // and rex is not in PATH. home := t.TempDir() - t.Setenv("HOME", home) - t.Setenv("REX_BIN_DIR", "") - t.Setenv("PATH", t.TempDir()) - dir := filepath.Join(home, "Library", "Application Support", - "com.superlogical.rex", "RexServer", "Versions", "Current", - "Rex Server.app", "Contents", "MacOS") + env := []string{"HOME=" + home, "REX_BIN_DIR=", "PATH=" + t.TempDir()} + dir := filepath.Join(home, "Library", "Application Support", "com.superlogical.rex", "RexServer", "Versions", "Current", "Rex Server.app", "Contents", "MacOS") if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatal(err) } - script := "#!/bin/sh\necho \"reloaded: $*\"\n" - if err := os.WriteFile(filepath.Join(dir, "rex"), []byte(script), 0o755); err != nil { + log := filepath.Join(home, "log") + testgit.Script(t, dir, "rex", "echo \"$*\" > '"+log+"'\n") + if err := rex.ReloadConfig(t.Context(), env); err != nil { + t.Fatal(err) + } + b, err := os.ReadFile(log) + if err != nil { t.Fatal(err) } - out, err := rex.ReloadConfig(t.Context()) - if err != nil || out != "reloaded: config reload\n" { - t.Errorf("ReloadConfig = %q, %v, want the report of the app rex", out, err) + if string(b) != "config reload\n" { + t.Errorf("the app rex ran with %q, want config reload", b) } } // TestErrorKeepsCause checks that the error of a failed rex is the message -// of rex, and still wraps the exit error of the process. +// of rex. The error also wraps the exit error of the process. func TestErrorKeepsCause(t *testing.T) { - dir := t.TempDir() - script := "#!/bin/sh\necho 'Error: no Rex server' >&2\nexit 3\n" - if err := os.WriteFile(filepath.Join(dir, "rex"), []byte(script), 0o755); err != nil { - t.Fatal(err) - } - t.Setenv("REX_BIN_DIR", dir) - _, err := rex.ReloadConfig(t.Context()) + t.Parallel() + c, _ := client(t, "echo 'Error: no Rex server' >&2\nexit 3\n") + err := c.Focus(t.Context(), "block:pi") ee, ok := errors.AsType[*exec.ExitError](err) if err == nil || err.Error() != "no Rex server" || !ok || ee.ExitCode() != 3 { - t.Errorf("ReloadConfig error = %v, exit error %v; want %q wrapping exit 3", err, ok, "no Rex server") + t.Errorf("Focus error = %v, exit error %v, want %q wrapping exit 3", + err, ok, "no Rex server") } } diff --git a/test/changes_test.go b/test/changes_test.go new file mode 100644 index 0000000..49ebc43 --- /dev/null +++ b/test/changes_test.go @@ -0,0 +1,261 @@ +package test + +import ( + "os" + "path/filepath" + "slices" + "strings" + "testing" + + "github.com/rednafi/rexdiff/internal/testgit" +) + +// TestChanges checks the Changes tab. It checks the file list with its +// counts, the diff of a file, and the next and the previous file. It also +// checks a fold that opens. +func TestChanges(t *testing.T) { + tm := start(t, newRepo(t), opts{}) + tm.waitFor("Changes (2)", "M a.go", "+2 -1", "M long.txt", "+2 -2", + "return 1", "x := 2", "not in Rex") + tm.keys("f") + tm.waitFor("first", "last", "⋯ 32 unmodified lines") + tm.keys("j", "j", "j", "j", "j", "j") + tm.waitFor("→ expand fold") + tm.keys("right") + tm.waitFor("line 20") + tm.waitGone("unmodified lines") + tm.keys("F") + tm.waitFor("x := 2") +} + +// TestMove checks the keys that move the cursor. They go to the next and +// the previous change, a page, half a page, a line, and a line number. +func TestMove(t *testing.T) { + tm := start(t, newRepo(t), opts{height: 16}) + tm.keys("f", "j", "j", "j", "j", "j", "j", "right") // all of long.txt + tm.waitFor("line 10") + tm.keys("]", "]") + tm.waitCursor("line 40") + tm.keys("[") + tm.waitCursor("line 1") + for _, keys := range [][2]string{{"pgdown", "pgup"}, {"ctrl+d", "ctrl+u"}} { + tm.keys(keys[0], keys[0], keys[0]) + tm.waitGone(" first") + tm.keys(keys[1], keys[1], keys[1]) + tm.waitFor(" first") + } + tm.keys(":", "3", "3", "enter") + tm.waitCursor("line 33") + tm.keys(":", "$", "enter") + tm.waitCursor("last") + tm.keys(":", "0", "enter") + tm.waitCursor("first") + tm.keys("down") + tm.waitCursor("line 2") + tm.keys("up") + tm.waitCursor("first") +} + +// TestScopes checks that each scope shows its changes, and the pickers of +// the commits and the base. +func TestScopes(t *testing.T) { + repo := newRepo(t) + // origin/main hides main in the base picker. + testgit.Git(t, repo, "update-ref", "refs/remotes/origin/main", "main") + tm := start(t, repo, opts{}) + tm.keys("b") + tm.waitFor("vs main", "Changes (3)", "A b.go") + tm.keys("u") + tm.waitFor("Changes (2)") + tm.waitGone("b.go") + tm.keys("t") + tm.waitFor("waiting for the first turn") + tm.keys("g") + tm.waitFor("commits · 1 over main", "add b") + tm.keys("enter") + tm.waitFor("Changes (1)", "A b.go", "var B = 2") + tm.keys("G") + tm.waitFor("commits · 1 over main") + tm.keys("esc") + tm.waitGone("commits · 1 over main") + tm.keys("u", "B") + if s := tm.waitFor("base branch for the branch scope", "origin/main"); len(lines(s, "default current")) != 1 { + t.Errorf("the base picker has no current tag on origin/main. It shows:\n%s", s) + } + tm.typed("feat") + tm.keys("enter") + tm.waitFor("vs feat", "Changes (2)") +} + +// TestFlags checks that -scope, -base, -layout, and -wrap set the start, +// and that -log writes a log. As in herdr-reviewr, -wrap off cuts a long +// line at the start. +func TestFlags(t *testing.T) { + repo := newRepo(t) + long := "var long = \"" + strings.Repeat("ab", 100) + "end\"\n" + testgit.WriteFile(t, repo, "b.go", "package a\n\n"+long) + log := filepath.Join(t.TempDir(), "log") + tm := start(t, repo, opts{width: 220, args: []string{ + "-scope", "branch", "-base", "main", "-layout", "split", "-wrap", "off", "-log", log, + }}) + s := tm.waitFor("vs main", "Changes (3)") + if !sameLine(s, "return 1", "x := 2") { + t.Errorf("the screen does not show return 1 beside x := 2. It shows:\n%s", s) + } + tm.keys("f") + if s := tm.waitFor("var long"); strings.Contains(s, "end\"") { + t.Errorf("-wrap off wraps the long line. The screen shows:\n%s", s) + } + tm.keys("q") + if err := tm.exited(); err != nil { + t.Fatal(err) + } + if b, err := os.ReadFile(log); err != nil || len(b) == 0 { + t.Errorf("the log has %d bytes (%v), want a log", len(b), err) + } +} + +// TestSplit checks that | shows the old and the new lines side by side +// on a wide screen. On a narrow screen, it tells why it cannot. +func TestSplit(t *testing.T) { + tm := start(t, newRepo(t), opts{width: 220}) + tm.keys("|") + tm.waitUntil(func(s string) bool { return sameLine(s, "return 1", "x := 2") }, + "show return 1 beside x := 2") + + narrow := start(t, newRepo(t), opts{width: 120}) + narrow.keys("|") + narrow.waitFor("too narrow for split") +} + +// TestWrap checks that rexdiff wraps a long line, that w cuts it, and +// that → scrolls to its end. +func TestWrap(t *testing.T) { + repo := newRepo(t) + long := "var long = \"" + strings.Repeat("ab", 60) + "end\"\n" + testgit.WriteFile(t, repo, "b.go", "package a\n\n"+long) + tm := start(t, repo, opts{width: 120}) + tm.keys("f") + tm.waitFor("end\"") + tm.keys("w") + tm.waitGone("end\"") + // Each → scrolls 8 columns. 12 of them show the end of the line, and + // keep its start in the view. + tm.keys(slices.Repeat([]string{"right"}, 12)...) + tm.waitFor("end\"") +} + +// column returns the column of text in the first line of s that has it, +// or -1. +func column(s, text string) int { + if x, _, ok := position(s, text); ok { + return x + } + return -1 +} + +// TestFileList checks that < and > change the width of the file list, p +// moves it, z hides it, and tab shows it again. +func TestFileList(t *testing.T) { + tm := start(t, newRepo(t), opts{}) + right := column(tm.waitFor("Changes (2)"), "Changes (2)") + tm.keys("<") + tm.waitUntil(func(s string) bool { return column(s, "Changes (2)") < right }, "widen the list") + tm.keys(">", ">") + tm.waitUntil(func(s string) bool { return column(s, "Changes (2)") > right }, "narrow the list") + tm.keys("p") // bottom + tm.waitUntil(func(s string) bool { + _, y, ok := position(s, "Changes (2)") + return ok && y > 10 + }, "show the list at the bottom") + tm.keys("z") + tm.waitGone("Changes (2)") + tm.keys("tab") // the focus shows a hidden list + tm.waitFor("Changes (2)") +} + +// TestKeys checks that ? shows all the keys. +func TestKeys(t *testing.T) { + tm := start(t, newRepo(t), opts{}) + tm.keys("?") + tm.waitFor("search files and code", "pick commits", "move list") + tm.keys("esc") + tm.waitGone("pick commits") +} + +// TestFind checks that ctrl+f counts the lines with a text, and that +// enter moves to a hit. +func TestFind(t *testing.T) { + tm := start(t, newRepo(t), opts{}) + tm.keys("f", "ctrl+f") + tm.typed("line 3") + // line 3, and line 30 to line 39 + tm.waitUntil(func(s string) bool { return sameLine(s, "find line 3", " 11") }, + "count 11 lines") + tm.keys("enter") + tm.waitCursor("line 3") +} + +// TestSettings checks that the settings change the layout and the marks +// of the changed lines. They also change the place and the width of the +// file list, the scope, and the keys. +func TestSettings(t *testing.T) { + tm := start(t, newRepo(t), opts{width: 220, args: []string{ + "-config", + `{"layout": "split", "diff_signs": true, "file_list": "left",` + + ` "file_list_width": 50, "default_scope": "branch",` + + ` "keybindings": {"next-file": ["x"]}}`, + }}) + s := tm.waitFor("vs main", "4 - return 1", "4 + x := 2", "x/F next/prev file") + if !sameLine(s, "return 1", "x := 2") { + t.Errorf("the screen does not show the old and the new line side by side. It shows:\n%s", s) + } + if c := column(s, "Changes (3)"); c < 0 || c > 5 { + t.Errorf("the list starts at column %d, want it at the left", c) + } + if ls := lines(s, "M a.go"); len(ls) == 0 || column(ls[0], "│") < 48 || column(ls[0], "│") > 52 { + t.Errorf("the list rows %q, want a width of 50", ls) + } + tm.keys("x") + tm.waitFor("var B = 2") +} + +// TestReviewPrefix checks that review_prefix comes before the review. +func TestReviewPrefix(t *testing.T) { + bin := clipboard(t) + tm := start(t, newRepo(t), opts{path: bin, env: clipEnv, args: []string{ + "-config", + `{"review_prefix": "Please fix:"}`, + }}) + comment(tm, "hi") + tm.keys("y") + waitFile(t, filepath.Join(bin, "clip"), func(s string) bool { + return s == "Please fix:\n\n"+review + }) +} + +// TestTinyScreen checks that rexdiff draws and takes keys in a small +// terminal. +func TestTinyScreen(t *testing.T) { + tm := start(t, newRepo(t), opts{width: 40, height: 8}) + tm.keys("f", "j", "2", "/") + tm.waitFor("›") + tm.keys("esc") + tm.waitGone("›") + tm.keys("?") + tm.waitFor("ctrl+d/u") + tm.keys("esc") + tm.waitGone("ctrl+d/u") + tm.keys("1") + tm.waitFor("1 Changes") +} + +// TestHostileText checks that the escape codes in a file show as text, so +// a file cannot change the terminal. +func TestHostileText(t *testing.T) { + repo := newRepo(t) + testgit.WriteFile(t, repo, "evil.txt", "a\x1b[2Jb\x1b]0;title\x07c\n") + tm := start(t, repo, opts{}) + tm.keys("f") + tm.waitFor("a␛[2Jb␛]0;title␇c") +} diff --git a/test/cli_test.go b/test/cli_test.go new file mode 100644 index 0000000..9e11f59 --- /dev/null +++ b/test/cli_test.go @@ -0,0 +1,66 @@ +package test + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/rogpeppe/go-internal/testscript" + + "github.com/rednafi/rexdiff/internal/testgit" +) + +// fakeRexCLI is the rex of the scripts. +const fakeRexCLI = `#!/bin/sh +printf '%s\n' "$*" >> "$WORK/rex.log" +if [ -n "$REX_FAIL" ]; then echo 'no Rex server' >&2; exit 1; fi +echo 'config reloaded' +` + +// TestScripts runs the scripts in testdata/script. A script runs the +// rexdiff binary and a fake rex. The fake writes its arguments to +// $WORK/rex.log, and fails if $REX_FAIL is set. If REXDIFF_REX is the +// path of a real rex, $REALREX is that rex, and the condition [realrex] is +// true. +func TestScripts(t *testing.T) { + bin := filepath.Dir(binary(t)) + realRex := os.Getenv("REXDIFF_REX") + hasRex := realRex != "" + testscript.Run(t, testscript.Params{ + Dir: "testdata/script", + RequireExplicitExec: true, + Setup: func(env *testscript.Env) error { + fake := filepath.Join(env.WorkDir, ".fake") + if err := os.Mkdir(fake, 0o755); err != nil { + return err + } + err := os.WriteFile(filepath.Join(fake, "rex"), []byte(fakeRexCLI), 0o755) + if err != nil { + return err + } + // $WORK is also $HOME, so rexdiff and git see no file of the + // user. + env.Setenv("HOME", env.WorkDir) + env.Setenv("XDG_CONFIG_HOME", "") + env.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + for _, k := range userEnv { + env.Setenv(k, "") + } + env.Setenv("REX_BIN_DIR", fake) + env.Setenv("REALREX", realRex) + for _, kv := range testgit.Env() { + k, v, _ := strings.Cut(kv, "=") + env.Setenv(k, v) + } + return nil + }, + Condition: func(cond string) (bool, error) { + if cond != "realrex" { + return false, fmt.Errorf("unknown condition %q", cond) + } + return hasRex, nil + }, + }) +} diff --git a/test/comment_test.go b/test/comment_test.go new file mode 100644 index 0000000..263999a --- /dev/null +++ b/test/comment_test.go @@ -0,0 +1,321 @@ +package test + +import ( + "os" + "path/filepath" + "slices" + "strings" + "syscall" + "testing" + + "github.com/rednafi/rexdiff/internal/testgit" +) + +// review is the text of the comment "hi" on the first change of a.go. +const review = "a.go:4 (removed)\n-\treturn 1\nhi" + +// comment adds the comment text on the first changed line of a.go. +func comment(tm *term, text string) { + tm.t.Helper() + tm.keys("]", "c") + tm.typed(text) + tm.keys("enter") + tm.waitFor("💬 " + text) +} + +// TestCommentList checks that a comment shows on its line and in the +// list. n and N move to it. The list edits the comment and deletes it. As +// in herdr-reviewr, an edit keeps the place of the comment in the list. An +// edit with blank text keeps the comment. +func TestCommentList(t *testing.T) { + tm := start(t, newRepo(t), opts{}) + comment(tm, "hi") + tm.waitFor("comment added", "●1") + tm.keys("k") + tm.waitCursor("func A") + tm.keys("n") // n stays in the file, as in herdr-reviewr + tm.waitCursor("return 1") + tm.keys("j", "j") + tm.waitCursor("return x") + tm.keys("N") + tm.waitCursor("return 1") + tm.keys("c", "z", "enter") // a second comment on the line + tm.waitFor("💬 z") + tm.keys("l") + tm.waitFor("Comments (2)", "a.go:4 (removed) hi") + tm.keys("e", " ") + tm.typed("more") + tm.keys("enter") + s := tm.waitFor("a.go:4 (removed) hi more", "a.go:4 (removed) z") + _, edited, _ := position(s, "(removed) hi more") + if _, other, _ := position(s, "(removed) z"); edited > other { + t.Errorf("the edit moves the comment down. The screen shows:\n%s", s) + } + // The flash of the edit hides the badge of the draft, so wait for + // the keys of the box. + tm.keys("e") + tm.waitFor("enter save") + tm.keys(slices.Repeat([]string{"bs"}, len("hi more"))...) + tm.keys("enter") + tm.waitGone("enter save") + tm.waitFor("a.go:4 (removed) hi more") + tm.keys("d", "d") // the list closes with its last comment + tm.waitGone("Comments (", "💬") +} + +// TestCommentDrag checks that a drag on the line numbers comments on a +// range of lines. +func TestCommentDrag(t *testing.T) { + tm := start(t, newRepo(t), opts{}) + tm.drag(" 1 1 ", " 3 3 ") + tm.waitFor("✎ a.go:1-3") + tm.keys("esc") + tm.waitGone("✎ a.go:1-3") +} + +// TestCommentBox checks that the box takes more than one line, and that +// esc drops a new comment. It also checks that v comments on a range of +// lines, and that e does not end the range. +func TestCommentBox(t *testing.T) { + tm := start(t, newRepo(t), opts{}) + // The draft comes first, because the flash of an added comment hides + // the badge of the draft. + tm.keys("]", "j", "c", "x") + tm.waitFor("✎ a.go:4 ") + tm.keys("esc") + tm.waitGone("✎") + tm.keys("k", "c", "a", "ctrl+j", "b", "enter") + tm.waitFor("💬 a", "b") + // As in herdr-reviewr, e does nothing while a selection is active. + tm.keys("j", "v", "j", "e", "c") + if s := tm.waitFor("enter save"); strings.Contains(s, "editor failed") { + t.Errorf("e with a selection opens the editor. The screen shows:\n%s", s) + } + tm.keys("r", "enter", "l") + tm.waitFor("a.go:4-5 r") +} + +// TestSend checks that s pastes the review into the agent and gives it +// the focus. S does the same, and so does s in the question before a quit. +// A comment that the user edits during the send stays. +func TestSend(t *testing.T) { + repo := newRepo(t) + rex := newRex(t, repo) + tm := start(t, repo, opts{rex: rex}) + tm.waitFor("send → 1 agent") + for _, keys := range [][]string{{"s"}, {"S"}, {"q", "s"}} { + text := "hi " + strings.Join(keys, "") + comment(tm, text) + tm.waitGone("sent 1 comment") + tm.keys(keys...) + tm.waitFor("sent 1 comment to agent") + tm.waitGone("💬") + rex.expectCalls(t, `"payload":{"text":"a.go:4 (removed)\n-\treturn 1\n`+text+`"}`) + } + rex.expectCalls(t, + `block.event {"block_id":"block:pi","event":{"type":"paste",`+ + `"payload":{"text":"a.go:4 (removed)\n-\treturn 1\nhi s"}}}`, + `session.focus_block {"block_id":"block:pi"}`) + gate := filepath.Join(rex.dir, "gate") + if err := syscall.Mkfifo(gate, 0o600); err != nil { + t.Fatal(err) + } + comment(tm, "hi") + tm.keys("s") + rex.expectCalls(t, `"payload":{"text":"a.go:4 (removed)\n-\treturn 1\nhi"}`) + tm.keys("l", "e", " ") + tm.typed("edit") + tm.keys("enter") + tm.waitFor("a.go:4 (removed) hi edit") + if err := os.WriteFile(gate, nil, 0o600); err != nil { + t.Fatal(err) + } + tm.keys("esc") + tm.waitFor("sent 1 comment to agent", "💬 hi edit") +} + +// TestSendWithoutAgent checks that s outside Rex tells the user to copy. +func TestSendWithoutAgent(t *testing.T) { + tm := start(t, newRepo(t), opts{}) + comment(tm, "hi") + tm.keys("s") + tm.waitFor("not in Rex") + tm.waitFor("💬 hi") +} + +// TestAgentPicker checks that a send with two agents asks which one. As +// in herdr-reviewr, a digit moves the cursor to an agent, and enter sends. +func TestAgentPicker(t *testing.T) { + repo := newRepo(t) + rex := newRex(t, repo) + rex.addAgent(t, "block:cc", "cc", "claude-code") + tm := start(t, repo, opts{rex: rex}) + tm.waitFor("send → 2 agents") + comment(tm, "hi") + tm.keys("s") + tm.waitFor("send to which agent?", "1 agent", "2 cc") + tm.keys("2") + tm.waitCursor("2 cc") + tm.keys("enter") + tm.waitFor("sent 1 comment to cc") + rex.expectCalls(t, `block.event {"block_id":"block:cc"`) +} + +// TestAgentElsewhere checks that the last-turn scope tells when no agent +// works in the repository. +func TestAgentElsewhere(t *testing.T) { + repo := newRepo(t) + rex := newRex(t, repo) + rex.write(t, "process", recorded(t, "process.json", "{{CWD}}", t.TempDir())) + tm := start(t, repo, opts{rex: rex}) + tm.keys("t") + tm.waitFor("no agent works here") +} + +// TestCopy checks that y and Y copy the review and clear the comments. y +// in the question before a quit does the same. +func TestCopy(t *testing.T) { + bin := clipboard(t) + clip := filepath.Join(bin, "clip") + tm := start(t, newRepo(t), opts{path: bin, env: clipEnv}) + for _, keys := range [][]string{{"y"}, {"Y"}, {"q", "y"}} { + comment(tm, "hi") + tm.waitGone("copied 1 comment") + if err := os.WriteFile(clip, nil, 0o600); err != nil { + t.Fatal(err) + } + tm.keys(keys...) + tm.waitFor("copied 1 comment") + tm.waitGone("💬") + waitFile(t, clip, func(s string) bool { return s == review }) + } +} + +// TestMouse checks that a click picks a file, and a triple click copies a +// line. It also checks that a drag on the code copies the lines, and that +// the wheel scrolls. As in herdr-reviewr, the wheel on the file list does +// not pick a file. +func TestMouse(t *testing.T) { + // The fake pbcopy is slow, as a real tool can be. Its copy takes longer + // than the time between the clicks of a triple click. The triple click + // still copies the line. + slow := `sleep 0.5; cat > "${0%/*}/clip"` + "\n" + bin := tools(t, map[string]string{"pbcopy": slow, "wl-copy": slow}) + clip := filepath.Join(bin, "clip") + tm := start(t, newRepo(t), opts{path: bin, env: clipEnv, height: 12}) + tm.click("M long.txt", 1) + tm.waitFor("⋯ 32 unmodified lines") + tm.click("M a.go", 1) + tm.wheel("M a.go", 1) + tm.click("func A() int {", 3) + tm.waitFor("copied 14 chars") + waitFile(t, clip, func(s string) bool { return s == "func A() int {" }) + tm.drag("package a", "func A()") + waitFile(t, clip, func(s string) bool { return s == "package a\n\nf" }) + tm.drag("A() int", "func A") + waitFile(t, clip, func(s string) bool { return s == "func A" }) + + tm.click("M long.txt", 1) + tm.keys(":", "2", "0", "enter") // opens the fold + tm.waitCursor("line 20") + tm.keys(":", "1", "enter") + tm.waitFor(" first") + tm.wheel("line 2", 10) + tm.waitGone(" first") +} + +// TestQuit checks that q with a comment asks first. +func TestQuit(t *testing.T) { + tm := start(t, newRepo(t), opts{}) + comment(tm, "hi") + tm.keys("Q", "q") // Q does nothing outside the prompt + tm.waitFor("1 unsent comment will be lost", + "Q quit (1 pending) · esc cancel · s send 1 · y copy") + tm.keys("esc") + tm.waitGone("unsent comment") + tm.waitFor("💬 hi") + tm.keys("q", "Q") + if err := tm.exited(); err != nil { + t.Errorf("q, Q exits with %v, want no error", err) + } +} + +// TestCommentStays checks that a comment stays when the agent edits the +// file. As in herdr-reviewr, it keeps its line number. When the file +// leaves the changes, the list marks the comment stale. +func TestCommentStays(t *testing.T) { + repo := newRepo(t) + tm := start(t, repo, opts{}) + tm.keys("]", "j", "c", "h", "i", "enter") // on x := 2 + tm.waitFor("💬 hi") + testgit.WriteFile(t, repo, "a.go", + "package a\n\n// A adds.\n// It is new.\nfunc A() int {\n\tx := 2\n\treturn x\n}\n") + tm.waitFor("It is new.") + tm.keys("l") + tm.waitFor("a.go:4 hi") + tm.waitGone("stale") + tm.keys("esc") + tm.waitGone("Comments (") + testgit.Git(t, repo, "checkout", "--", "a.go") + tm.waitFor("Changes (1)") + tm.keys("l") + tm.waitUntil(func(s string) bool { return sameLine(s, "a.go:4 hi", "stale") }, + "mark the comment stale") +} + +// TestEditorInPane checks that e opens a terminal editor at the line, in +// a new Rex pane. +func TestEditorInPane(t *testing.T) { + repo := newRepo(t) + rex := newRex(t, repo) + bin := tools(t, map[string]string{"nvim": ""}) + tm := start(t, repo, opts{rex: rex, path: bin, env: []string{"EDITOR=nvim"}}) + tm.keys("]", "j", "e") + rex.expectCalls(t, "split -s S -b block:self --cwd "+repo+" -- sh -c nvim +4 '"+ + filepath.Join(repo, "a.go")+"'") + tm.waitFor("opened a.go") +} + +// TestWindowEditor checks that e starts a window editor at the line, and +// that the editor setting picks the editor. +func TestWindowEditor(t *testing.T) { + bin := tools(t, map[string]string{ + "code": `printf '%s\n' "$@" > "${0%/*}/code"` + "\n", + "mine": `printf '%s\n' "$@" > "${0%/*}/mine"` + "\n", + }) + tm := start(t, newRepo(t), opts{path: bin, env: []string{"VISUAL=code --wait"}}) + tm.keys("]", "j", "e") + waitFile(t, filepath.Join(bin, "code"), func(s string) bool { + return strings.HasPrefix(s, "--wait\n-g\n") && strings.HasSuffix(s, "a.go:4\n") + }) + + tm = start(t, newRepo(t), opts{path: bin, args: []string{ + "-config", + `{"editor": "mine --line {line} {file}"}`, + }}) + tm.keys("]", "j", "e") + waitFile(t, filepath.Join(bin, "mine"), func(s string) bool { + return strings.HasPrefix(s, "--line\n4\n") && strings.HasSuffix(s, "a.go\n") + }) +} + +// TestNoEditor checks that e tells how to set an editor when none is set. +func TestNoEditor(t *testing.T) { + tm := start(t, newRepo(t), opts{}) + tm.keys("]", "j", "e") + tm.waitFor(`editor failed: no editor: set "editor" in rexdiff.lua, $EDITOR, or git's core.editor`) +} + +// TestCopyHangs checks that a clipboard tool that does not answer fails +// the copy, and rexdiff still takes keys. +func TestCopyHangs(t *testing.T) { + hang := "exec sleep 60\n" + bin := tools(t, map[string]string{"pbcopy": hang, "wl-copy": hang}) + tm := start(t, newRepo(t), opts{path: bin, env: clipEnv}) + comment(tm, "hi") + tm.keys("y") + tm.waitFor("copy failed") + tm.waitFor("💬 hi") + tm.keys("f") + tm.waitFor("⋯ 32 unmodified lines") +} diff --git a/test/fake_test.go b/test/fake_test.go new file mode 100644 index 0000000..1c4259c --- /dev/null +++ b/test/fake_test.go @@ -0,0 +1,214 @@ +package test + +import ( + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/rednafi/rexdiff/internal/testgit" +) + +// repoFixture is a repository with a commit on main and on the branch +// feat. It has uncommitted edits of a.go, and of the first and the last +// line of long.txt. +var repoFixture = testgit.NewFixture(func(tb testing.TB, dir string) { + tb.Helper() + testgit.InitRepo(tb, dir) + testgit.WriteFile(tb, dir, "a.go", "package a\n\nfunc A() int {\n\treturn 1\n}\n") + testgit.WriteFile(tb, dir, "README.md", "# Title\n\nSome text.\n") + testgit.WriteFile(tb, dir, "docs/guide.txt", "guide\n") + testgit.WriteFile(tb, dir, ".gitignore", "out/\n") + testgit.WriteFile(tb, dir, "long.txt", numbered("line", 1, 40)) + testgit.Git(tb, dir, "add", ".") + testgit.Git(tb, dir, "commit", "-qm", "first") + testgit.Git(tb, dir, "checkout", "-qb", "feat") + testgit.WriteFile(tb, dir, "b.go", "package a\n\nvar B = 2\n") + testgit.Git(tb, dir, "add", ".") + testgit.Git(tb, dir, "commit", "-qm", "add b") + testgit.WriteFile(tb, dir, "a.go", "package a\n\nfunc A() int {\n\tx := 2\n\treturn x\n}\n") + testgit.WriteFile(tb, dir, "out/build.log", "built\n") + testgit.WriteFile(tb, dir, "long.txt", + "first\n"+numbered("line", 2, 39)+"last\n") +}) + +// numbered returns the lines " " for n from first to last. +func numbered(word string, first, last int) string { + var b strings.Builder + for n := first; n <= last; n++ { + fmt.Fprintf(&b, "%s %d\n", word, n) + } + return b.String() +} + +// newRepo returns a copy of the fixture repository. The path has no +// symbolic link, as the root that git gives. +func newRepo(t *testing.T) string { + t.Helper() + tmp, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + dir := filepath.Join(tmp, "repo") + repoFixture.Copy(t, dir) + return dir +} + +// fakeRex is a rex for the session S. rexdiff runs in the block +// block:self. The agents run in repo. The fake logs each call and prints +// the output of a real rex from testdata/rex, with the values of the test. +// "rex events" prints the lines that the test adds. If the FIFO gate +// exists, a paste waits until the test writes to it. +type fakeRex struct { + dir string + blocks []string // the JSON of each block +} + +// newRex returns a fake rex with the agent pi in the block block:pi. +func newRex(t *testing.T, repo string) *fakeRex { + t.Helper() + r := &fakeRex{dir: t.TempDir()} + testgit.Script(t, r.dir, "rex", `d="${0%/*}" +printf '%s\n' "$*" >> "$d/calls" +case "$*" in +"api call -s S session.list_blocks {}") cat "$d/blocks" ;; +"session inspect S --json") cat "$d/session" ;; +"block call -s S -b "*" program_status") cat "$d/status.$6" ;; +"block call -s S -b "*" process") cat "$d/process" ;; +"block inspect "*) cat "$d/inspect.$3" ;; +"api call -s S block.event "*) if [ -p "$d/gate" ]; then cat "$d/gate" >/dev/null; fi ;; +"events -s S --json") touch "$d/events"; exec tail -n +1 -f "$d/events" ;; +esac +`) + r.write(t, "session", recorded(t, "session.json")) + r.write(t, "process", recorded(t, "process.json", "{{CWD}}", repo)) + r.addBlock(t, "block:self", "rexdiff") + r.addAgent(t, "block:pi", "agent", "pi") + return r +} + +// recorded returns the content of the file name in testdata/rex, with +// each pair of old and new values replaced. +func recorded(t *testing.T, name string, oldNew ...string) string { + t.Helper() + b, err := os.ReadFile(filepath.Join("testdata", "rex", name)) + if err != nil { + t.Fatal(err) + } + return strings.TrimSpace(strings.NewReplacer(oldNew...).Replace(string(b))) +} + +// addBlock adds a terminal block with the label. +func (r *fakeRex) addBlock(t *testing.T, block, label string) { + t.Helper() + r.blocks = append(r.blocks, recorded(t, "block.json", "{{ID}}", block, "{{LABEL}}", label)) + r.write(t, "blocks", `{"blocks":[`+strings.Join(r.blocks, ",")+`]}`) + r.write(t, "inspect."+label, recorded(t, "inspect.json", "{{ID}}", block, "{{LABEL}}", label)) +} + +// addAgent adds a block with the label, and an idle agent app in it. +func (r *fakeRex) addAgent(t *testing.T, block, label, app string) { + t.Helper() + r.addBlock(t, block, label) + r.status(t, block, app, "idle") +} + +// status sets the state of the agent app in block. It returns the values +// for the recorded files. +func (r *fakeRex) status(t *testing.T, block, app, state string) []string { + t.Helper() + values := []string{ + "{{ID}}", block, "{{STATE}}", state, "{{APP}}", app, + "{{AT}}", time.Now().Format(time.RFC3339Nano), + } + r.write(t, "status."+block, recorded(t, "status.json", values...)) + return values +} + +// write writes content to the file name of the fake. +func (r *fakeRex) write(t *testing.T, name, content string) { + t.Helper() + if err := os.WriteFile(filepath.Join(r.dir, name), []byte(content+"\n"), 0o644); err != nil { + t.Fatal(err) + } +} + +// add adds the line to the file name of the fake. +func (r *fakeRex) add(t *testing.T, name, line string) { + t.Helper() + f, err := os.OpenFile(filepath.Join(r.dir, name), os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0o644) + if err != nil { + t.Fatal(err) + } + if _, err := f.WriteString(line + "\n"); err != nil { + _ = f.Close() + t.Fatal(err) + } + if err := f.Close(); err != nil { + t.Fatal(err) + } +} + +// calls returns the calls of rex, one a line. +func (r *fakeRex) calls(t *testing.T) string { + t.Helper() + b, err := os.ReadFile(filepath.Join(r.dir, "calls")) + if err != nil && !errors.Is(err, fs.ErrNotExist) { + t.Fatal(err) + } + return string(b) +} + +// expectCalls waits until the calls of rex have each of wants. +func (r *fakeRex) expectCalls(t *testing.T, wants ...string) { + t.Helper() + var calls string + poll(t, func() bool { calls = r.calls(t); return hasAll(calls, wants...) }, func() string { + return "rex calls = \n" + calls + "\nwant calls with:\n" + strings.Join(wants, "\n") + }) +} + +// setState sets the state of the agent pi and sends the event of the +// change. +func (r *fakeRex) setState(t *testing.T, state string) { + t.Helper() + values := r.status(t, "block:pi", "pi", state) + r.add(t, "events", recorded(t, "event.json", values...)) +} + +// tools writes a fake program for each name in bodies, with its body, to +// a new directory. It returns the directory, for opts.path. +func tools(t *testing.T, bodies map[string]string) string { + t.Helper() + dir := t.TempDir() + for name, body := range bodies { + testgit.Script(t, dir, name, body) + } + return dir +} + +// clipboard returns a directory with a fake pbcopy and wl-copy, for +// opts.path. Each writes what it gets to the file clip in the directory. +func clipboard(t *testing.T) string { + t.Helper() + const body = `cat > "${0%/*}/clip"` + "\n" + return tools(t, map[string]string{"pbcopy": body, "wl-copy": body}) +} + +// clipEnv makes rexdiff take the fake clipboard on Linux too. +var clipEnv = []string{"WAYLAND_DISPLAY=w"} + +// lines returns the lines of s that contain text. +func lines(s, text string) []string { + var out []string + for l := range strings.Lines(s) { + if strings.Contains(l, text) { + out = append(out, strings.TrimRight(l, "\n")) + } + } + return out +} diff --git a/test/files_test.go b/test/files_test.go new file mode 100644 index 0000000..ece0849 --- /dev/null +++ b/test/files_test.go @@ -0,0 +1,228 @@ +package test + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/rednafi/rexdiff/internal/testgit" +) + +// TestFilesTab checks that the Files tab lists all the files, expands and +// collapses an ignored directory, opens a file, and renders markdown. +func TestFilesTab(t *testing.T) { + tm := start(t, newRepo(t), opts{}) + tm.keys("2") + tm.waitFor("Files (6)", "docs/guide.txt", "▸ out/", "long.txt") + tm.keys("tab", "j", "right") + tm.waitFor("▾ out/", "build.log") + tm.keys("left") + tm.waitFor("▸ out/") + tm.waitGone("build.log") + tm.keys("j", "j") + tm.waitFor("# Title") + tm.keys("m") + tm.waitGone("# Title") + tm.waitFor("Title", "Some text.") +} + +// TestMarkdownSetting checks that markdown_view shows rendered markdown +// at the start. As in herdr-reviewr, a changed block of rendered markdown +// has the bar, and a comment on it is on all the lines of the block. A +// drag copies the rendered text. n goes to a comment that starts in a +// block. +func TestMarkdownSetting(t *testing.T) { + repo := newRepo(t) + testgit.WriteFile(t, repo, "README.md", "# Title\n\nGone.\n\nSome text.\n\n\n") + testgit.Git(t, repo, "commit", "-qam", "readme") + testgit.WriteFile(t, repo, "README.md", + "# Title\n\nSome text.\n\n\n\nNew text\non\nthree lines.\n") + bin := clipboard(t) + tm := start(t, repo, opts{path: bin, env: clipEnv, args: []string{ + "-config", `{"markdown_view": "rendered"}`, + }}) + tm.keys("2", "tab", "j", "j", "j") + tm.waitFor("Some text.") + tm.waitGone("# Title") + tm.keys("1", "tab") + tm.waitFor("Some text.") + tm.waitUntil(func(s string) bool { + return sameLine(s, "▌", "New text on three lines.") && !sameLine(s, "▌", "Some text.") && + sameLine(s, "▌", "− 1 line removed") && + sameLine(s, "▌", "⚠ 1 changed line doesn't render · m to see") + }, "mark the changed blocks, the removed block, and the changed comment") + tm.keys(":", "8", "enter") + tm.waitCursor("New text on three lines.") + tm.keys("c") + tm.typed("hi") + tm.keys("enter") + tm.waitFor("💬 hi") + tm.keys("y") + waitFile(t, filepath.Join(bin, "clip"), func(s string) bool { + return s == "README.md:7-9\n+New text\n+on\n+three lines.\nhi" + }) + tm.drag("Some", "text.") + waitFile(t, filepath.Join(bin, "clip"), func(s string) bool { return s == "Some t" }) + tm.keys("m", ":", "8", "enter") + tm.waitCursor("on") + tm.keys("c") + tm.typed("x") + tm.keys("enter") + tm.waitFor("💬 x") + tm.keys("m", ":", "0", "enter") + tm.waitCursor("Title") + tm.keys("n") + tm.waitCursor("New text on three lines.") +} + +// TestSearch checks that / finds files by name and lines by code, shows a +// preview, and opens the pick. The wheel scrolls the preview. A changed +// file opens in the Changes tab, at the line, even in a fold. +func TestSearch(t *testing.T) { + tm := start(t, newRepo(t), opts{}) + tm.keys("/") + tm.typed("long") + tm.waitFor("files 1", "▶ long.txt", "preview · long.txt", "first") + tm.wheel("line 5", 1) + tm.waitGone("first") + tm.keys("enter") + tm.waitGone("preview ·") + tm.waitFor("first", "⋯ 32 unmodified lines") + + tm.keys("/", "tab") + tm.typed("line 7") + tm.waitFor("code 1", "7: line 7", "▶ 7 line 7") + tm.keys("enter") + tm.waitCursor("line 7") +} + +// TestLiveReload checks that the Changes tab shows an edit, a new file, +// and a removed file without a key. +func TestLiveReload(t *testing.T) { + repo := newRepo(t) + tm := start(t, repo, opts{}) + tm.waitFor("Changes (2)") + testgit.WriteFile(t, repo, "new/c.go", "package c\n") + tm.waitFor("Changes (3)", "c.go") + testgit.WriteFile(t, repo, "a.go", "package a\n\nfunc A() int { return 3 }\n") + tm.waitFor("return 3 }") + if err := os.Remove(filepath.Join(repo, "new", "c.go")); err != nil { + t.Fatal(err) + } + tm.waitFor("Changes (2)") +} + +// TestLastTurn checks that the last-turn scope shows what the agent +// changes in its last turn. +func TestLastTurn(t *testing.T) { + repo := newRepo(t) + rex := newRex(t, repo) + log := filepath.Join(t.TempDir(), "log") + tm := start(t, repo, opts{rex: rex, args: []string{"-log", log}}) + tm.keys("t") + // rexdiff takes the states of an agent after its first list of them. + tm.waitFor("waiting for the first turn", "send → 1 agent") + rex.setState(t, "working") + // The turn starts with a snapshot of the files. + waitFile(t, log, func(s string) bool { return strings.Contains(s, "msg=\"turn start\"") }) + testgit.WriteFile(t, repo, "turn.go", "package a\n") + rex.setState(t, "idle") + tm.waitFor("Changes (1)", "turn.go", "pi") +} + +// TestTurnRaced checks that a turn whose start comes right after a write +// is not known, because its start snapshot can have the write. The scope +// tells so, as herdr-reviewr does. +func TestTurnRaced(t *testing.T) { + repo := newRepo(t) + rex := newRex(t, repo) + tm := start(t, repo, opts{rex: rex}) + tm.keys("t") + tm.waitFor("waiting for the first turn", "send → 1 agent") + testgit.WriteFile(t, repo, "turn.go", "package a\n") + rex.setState(t, "working") + tm.waitFor("the last turn started before rexdiff could snapshot it") +} + +// TestPR checks that the PR tab shows the pull request of the branch from +// gh, with its checks and comments, and the age of each comment. It also +// checks that o opens it, as in herdr-reviewr. Without {url}, the URL goes +// at the end of the opener command. o does not open a URL that is not +// http or https. The keys of the scopes and of the base picker do nothing +// on the PR tab, and r reads the pull request again. +func TestPR(t *testing.T) { + repo := newRepo(t) + testgit.Git(t, repo, "remote", "add", "origin", "https://github.com/o/r.git") + created := time.Now().Add(-3 * time.Hour).UTC().Format(time.RFC3339) + bin := tools(t, map[string]string{ + "gh": `cat </dev/null)", "state": "OPEN", "isDraft": false, + "url": "${PR_URL:-https://github.com/o/r/pull/7}", "body": "The body of the PR. See [the docs](https://example.com/docs).", + "author": {"login": "ann"}, "headRefName": "feat", "baseRefName": "main", + "comments": [{"author": {"login": "bob"}, "body": "Looks good.\n\n
The logs\n\nA hidden line.\n\n
Inner\n\nDeep.\n\n
\n\nAfter inner.\n\n
", + "createdAt": "` + created + `"}], + "statusCheckRollup": [{"name": "test", "conclusion": "SUCCESS"}, + {"name": "lint", "conclusion": "FAILURE"}]} +EOF +`, + "opener": `printf '%s\n' "$@" > "${0%/*}/opened"` + "\n", + }) + config := []string{"-config", `{"url_opener": "opener"}`} + tm := start(t, repo, opts{path: bin, args: config}) + tm.keys("3") + s := tm.waitFor("Add b #7", "● open", "feat → main", "by ann", + "checks: ✓ 1 ✗ 1", "✗ lint", "The body of the PR.", "bob 3h", + "Looks good.", "▸ The logs") + if strings.Contains(s, "A hidden line.") || strings.Contains(s, "After inner.") { + t.Errorf("a closed details block shows its body. The screen shows:\n%s", s) + } + tm.keys("right") + tm.waitFor("▾ The logs", "A hidden line.", "▾ Inner", "Deep.", "After inner.") + tm.keys("left") + tm.waitFor("▸ The logs") + tm.waitGone("A hidden line.") + tm.keys("u", "b", "t", "g", "B", "o") + if s := tm.waitFor("opened PR in browser"); !strings.Contains(s, "Add b #7") { + t.Errorf("a key of a scope leaves the PR tab. The screen shows:\n%s", s) + } + waitFile(t, filepath.Join(bin, "opened"), func(s string) bool { + return s == "https://github.com/o/r/pull/7\n" + }) + tm.click("the docs", 1) + tm.waitFor("opened link in browser") + waitFile(t, filepath.Join(bin, "opened"), func(s string) bool { + return s == "https://example.com/docs\n" + }) + + repo = newRepo(t) + testgit.Git(t, repo, "remote", "add", "origin", "https://github.com/o/r.git") + tm = start(t, repo, opts{path: bin, args: config, env: []string{"PR_URL=file:///etc/passwd"}}) + tm.keys("3") + tm.waitFor("Add b #7") + tm.keys("o") + tm.waitFor("open failed: unsupported link scheme") + if err := os.WriteFile(filepath.Join(bin, "suffix"), []byte(" again"), 0o600); err != nil { + t.Fatal(err) + } + tm.keys("r") + tm.waitFor("Add b again #7") +} + +// TestNoPR checks that the PR tab tells why it has no pull request. The +// repository has no remote, or the tool of the remote is not installed. +func TestNoPR(t *testing.T) { + tm := start(t, newRepo(t), opts{}) + tm.keys("3") + tm.waitFor("no git remote") + + repo := newRepo(t) + testgit.Git(t, repo, "remote", "add", "origin", "https://github.com/o/r.git") + // The PATH has only git, as /usr/bin can have gh. + bin := t.TempDir() + testgit.LinkGit(t, bin) + tm = start(t, repo, opts{env: []string{"PATH=" + bin}}) + tm.keys("3") + tm.waitFor("gh is not installed") +} diff --git a/test/main_test.go b/test/main_test.go new file mode 100644 index 0000000..ba06638 --- /dev/null +++ b/test/main_test.go @@ -0,0 +1,29 @@ +package test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/rednafi/rexdiff/internal/testgit" +) + +var ( + buildDir string // the directory of the binaries that the tests build + homeDir string // the HOME of rexdiff, an empty directory +) + +// TestMain makes the directories of the binaries and of HOME. Then it +// runs the tests with testgit. +func TestMain(m *testing.M) { + var err error + if buildDir, err = os.MkdirTemp("", "rexdiff-test"); err != nil { + panic(err) + } + defer func() { _ = os.RemoveAll(buildDir) }() + homeDir = filepath.Join(buildDir, "home") + if err := os.Mkdir(homeDir, 0o755); err != nil { + panic(err) + } + testgit.Main(m) +} diff --git a/test/norace_test.go b/test/norace_test.go new file mode 100644 index 0000000..6283e17 --- /dev/null +++ b/test/norace_test.go @@ -0,0 +1,6 @@ +//go:build !race + +package test + +// raceOn is true in a run with the race detector. +const raceOn = false diff --git a/test/race_test.go b/test/race_test.go new file mode 100644 index 0000000..63cb408 --- /dev/null +++ b/test/race_test.go @@ -0,0 +1,6 @@ +//go:build race + +package test + +// raceOn is true in a run with the race detector. +const raceOn = true diff --git a/test/rex_test.go b/test/rex_test.go new file mode 100644 index 0000000..313e63a --- /dev/null +++ b/test/rex_test.go @@ -0,0 +1,122 @@ +package test + +import ( + "context" + "encoding/json/v2" + "fmt" + "os" + "os/exec" + "strconv" + "strings" + "testing" + "time" +) + +// realRex runs rex in the session of a test with a real Rex. +type realRex struct { + t *testing.T + bin string // the path of rex + session string +} + +// run runs rex with args and returns its output. It fails the test if rex +// fails. +func (r *realRex) run(args ...string) string { + r.t.Helper() + cmd := exec.CommandContext(r.t.Context(), r.bin, append([]string{"--autostart=false"}, args...)...) + out, err := cmd.Output() + if err != nil { + r.t.Fatalf("rex %s: %v", strings.Join(args, " "), err) + } + return string(out) +} + +// blocks returns the IDs of the blocks of the session, by label. +func (r *realRex) blocks() map[string]string { + r.t.Helper() + var reply struct { + Blocks []struct { + ID string `json:"block_id"` + Label string `json:"label"` + } `json:"blocks"` + } + out := r.run("api", "call", "-s", r.session, "session.list_blocks", "{}") + if err := json.Unmarshal([]byte(out), &reply); err != nil { + r.t.Fatal(err) + } + ids := map[string]string{} + for _, b := range reply.Blocks { + ids[b.Label] = b.ID + } + return ids +} + +// write types data into block. +func (r *realRex) write(block, data string) { + r.t.Helper() + args, err := json.Marshal(map[string]string{"data": data}) + if err != nil { + r.t.Fatal(err) + } + r.run("block", "call", "-s", r.session, "-b", block, "write", string(args)) +} + +// waitFor waits until the screen of block shows each of texts. +func (r *realRex) waitFor(block string, texts ...string) { + r.t.Helper() + var s string + poll(r.t, func() bool { + s = r.run("capture", "-s", r.session, "-b", block) + return hasAll(s, texts...) + }, func() string { return "block " + block + " does not show " + strings.Join(texts, ", ") + ":\n" + s }) +} + +// TestRealRex runs rexdiff in a pane of a real Rex, beside a shell that +// says it is the agent pi. It checks that rexdiff finds the agent, and +// that a send pastes the review into the shell. It runs only if +// REXDIFF_REX is the path of rex and Rex runs. It makes a session of its +// own, and does not change the config of Rex. +func TestRealRex(t *testing.T) { + bin := os.Getenv("REXDIFF_REX") + if bin == "" { + t.Skip("set REXDIFF_REX to the path of rex to run rexdiff in a real Rex") + } + repo := newRepo(t) + r := &realRex{t: t, bin: bin, session: "rexdiff-test-" + strconv.Itoa(os.Getpid())} + r.run("new", r.session, "--cwd", repo, "--", "sh") + t.Cleanup(func() { + // t.Context is done in a cleanup, so drop its cancel. + ctx, cancel := context.WithTimeoutCause(context.WithoutCancel(t.Context()), 10*time.Second, + fmt.Errorf("rex kill: 10s timeout: %w", context.DeadlineExceeded)) + defer cancel() + kill := exec.CommandContext(ctx, bin, "--autostart=false", "kill", r.session) + _ = kill.Run() + }) + shell := r.blocks()["shell"] + r.run("block", "rename", shell, "agent", "-s", r.session) + r.write(shell, `printf '\033]7501;state=idle:app=pi\007'`+"\n") + + r.run("split", "-s", r.session, "-b", shell, "--cwd", repo, "--shell=none", + "--", binary(t), "-config", `{"focus_agent_after_send": false}`, repo) + var pane string + poll(t, func() bool { + for label, id := range r.blocks() { + if label != "agent" { + pane = id + } + } + return pane != "" + }, func() string { return "rexdiff does not start in a pane" }) + r.waitFor(pane, "Changes (2)") + r.write(pane, "]") + r.write(pane, "c") + r.write(pane, "hi from rexdiff") + r.write(pane, "\r") + r.waitFor(pane, "💬 hi from rexdiff") + r.write(pane, "s") + r.waitFor(pane, "sent 1 comment to agent") + r.waitFor(shell, "a.go:4 (removed)", "hi from rexdiff") + if got := r.blocks(); got["rexdiff"] == "" { + t.Errorf("blocks %v, want the label rexdiff on the pane", got) + } +} diff --git a/test/rexdiff_test.go b/test/rexdiff_test.go new file mode 100644 index 0000000..6e8e3e3 --- /dev/null +++ b/test/rexdiff_test.go @@ -0,0 +1,410 @@ +// Package test runs the rexdiff program as a user does. Each test starts +// the binary in a terminal, types keys, clicks, and reads the screen. A +// fake rex stands in for Rex, and the other tools are fakes on PATH. git +// is real. The tests run one at a time, because each runs a program in a +// terminal. +package test + +import ( + "cmp" + "context" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "slices" + "strconv" + "strings" + "sync" + "syscall" + "testing" + "time" + "unicode/utf8" + + "github.com/charmbracelet/x/vt" + "github.com/creack/pty" + + "github.com/rednafi/rexdiff/internal/testgit" +) + +// buildDev builds rexdiff once. The build serves all the tests, as +// TestMain does, so it has the context of no test. +var buildDev = sync.OnceValues(func() (string, error) { + return build(context.Background(), filepath.Join(buildDir, "dev"), "") +}) + +// binary returns the path of a rexdiff that the package builds once. +func binary(t *testing.T) string { + t.Helper() + bin, err := buildDev() + if err != nil { + t.Fatal(err) + } + return bin +} + +// build builds rexdiff with the version into dir, and returns its path. +// A run with the race detector builds a rexdiff with it. The build stops +// when ctx is done. -buildvcs=false keeps the version of a git tag out, +// so a build with no version does not look for updates. +func build(ctx context.Context, dir, version string) (string, error) { + bin := filepath.Join(dir, "rexdiff") + args := []string{"build", "-buildvcs=false", "-o", bin, "-ldflags", "-X main.version=" + version} + if raceOn { + args = append(args, "-race") + } + cmd := exec.CommandContext(ctx, "go", append(args, "../cmd/rexdiff")...) + if out, err := cmd.CombinedOutput(); err != nil { + return "", fmt.Errorf("go build: %w\n%s", err, out) + } + return bin, nil +} + +// userEnv are the variables of the user that change what rexdiff does. +// Each run starts with them empty. +var userEnv = []string{ + "REX_SESSION", "REX_BLOCK", "REX_BIN_DIR", "REX_CONFIG", + "REXDIFF_AGENT", "REXDIFF_LOG", "REXDIFF_RESUME", "EDITOR", "VISUAL", +} + +// cleanEnv returns the environment of the process for a run of rexdiff. +// It has no git variable of the user, and the variables of userEnv are +// empty. HOME is homeDir, so no file of the user applies. Git gets the +// variables of testgit.Env. +func cleanEnv() []string { + var env []string + for _, kv := range os.Environ() { + if !strings.HasPrefix(kv, "GIT_") { + env = append(env, kv) + } + } + for _, k := range userEnv { + env = append(env, k+"=") + } + env = append(env, "HOME="+homeDir, "XDG_CONFIG_HOME=") + return append(env, testgit.Env()...) +} + +// opts are the options of a rexdiff run. +type opts struct { + args []string // the flags. start adds the repo. + bin string // the rexdiff to run, or "" for the one of binary + width uint16 // 0 means 160 + height uint16 // 0 means 40 + rex *fakeRex // nil runs rexdiff outside Rex + path string // a directory of fake tools, or "" + env []string // more variables +} + +// term is a rexdiff that runs in a terminal. +type term struct { + t *testing.T + emu *vt.Emulator + mu sync.Mutex // guards emu + wait func() error // waits for rexdiff once, and keeps its error + // changed gets a value after each write to the screen. It holds one + // value, so a write that comes before a wait is not lost. + changed chan struct{} + done chan struct{} // closed when the screen gets no more output +} + +// waitTime is how long a test waits for the screen or for a file. +const waitTime = 15 * time.Second + +// start runs rexdiff on repo in a new terminal. The test stops it at the +// end. +func start(t *testing.T, repo string, o opts) *term { + t.Helper() + w, h := cmp.Or(o.width, 160), cmp.Or(o.height, 40) + bin := o.bin + if bin == "" { + bin = binary(t) + } + cmd := exec.CommandContext(t.Context(), bin, append(o.args, repo)...) + cmd.Dir = repo + // A stop lets rexdiff remove its files before it exits. + cmd.Cancel = func() error { return cmd.Process.Signal(syscall.SIGTERM) } + cmd.WaitDelay = 2 * time.Second + var dirs []string + if o.path != "" { + dirs = append(dirs, o.path) + } + // The PATH of ToolEnv has git, but no other tool of the user. + cmd.Env = append(cleanEnv(), "TERM=xterm-256color") + cmd.Env = append(cmd.Env, testgit.ToolEnv(t, dirs...)...) + if o.rex != nil { + cmd.Env = append(cmd.Env, "REX_SESSION=S", "REX_BLOCK=block:self", + "REX_BIN_DIR="+o.rex.dir) + } + cmd.Env = append(cmd.Env, o.env...) + f, err := pty.StartWithSize(cmd, &pty.Winsize{Cols: w, Rows: h}) + if err != nil { + t.Fatal(err) + } + tm := &term{ + t: t, + emu: vt.NewEmulator(int(w), int(h)), + wait: sync.OnceValue(cmd.Wait), + changed: make(chan struct{}, 1), + done: make(chan struct{}), + } + go func() { + defer close(tm.done) + buf := make([]byte, 32<<10) + for { + n, err := f.Read(buf) + if n > 0 { + tm.mu.Lock() + _, _ = tm.emu.Write(buf[:n]) + tm.mu.Unlock() + select { + case tm.changed <- struct{}{}: + default: // the wait has a value that it does not take yet + } + } + if err != nil { + return + } + } + }() + // The input of rexdiff comes from the emulator, as from a terminal. It + // has the keys and the mouse of the test. It also has the answers to + // the queries of rexdiff, for example for the colours. + input := make(chan struct{}) + go func() { + defer close(input) + _, _ = io.Copy(f, tm.emu) + }() + t.Cleanup(func() { + _ = tm.wait() // t.Context is done, so Cancel stops rexdiff + _ = f.Close() + <-tm.done + // Close of the pipe, not of the emulator, ends the copy. The Close + // of the emulator races with its Read. + _ = tm.emu.InputPipe().(io.Closer).Close() + <-input + }) + tm.waitFor("1 Changes") + return tm +} + +// screen returns the text on the screen. +func (tm *term) screen() string { + tm.mu.Lock() + defer tm.mu.Unlock() + return tm.emu.String() +} + +// keys types each key. A key is a name in keyCodes, or else a text. As +// in a terminal, an esc and the key after it can join into one key, so esc +// must be the last key. Wait for the screen after it, as a user does. +func (tm *term) keys(keys ...string) { + tm.t.Helper() + if i := slices.Index(keys, "esc"); i >= 0 && i != len(keys)-1 { + tm.t.Fatalf("keys %q: esc must be the last key", keys) + } + tm.mu.Lock() + defer tm.mu.Unlock() + for _, k := range keys { + if code, ok := keyCodes[k]; ok { + tm.emu.SendKey(code) + } else { + tm.emu.SendText(k) + } + } +} + +// typed types each character of text. +func (tm *term) typed(text string) { + tm.t.Helper() + tm.keys(strings.Split(text, "")...) +} + +// keyCodes are the named keys. +var keyCodes = map[string]vt.KeyPressEvent{ + "enter": {Code: vt.KeyEnter}, + "esc": {Code: vt.KeyEscape}, + "tab": {Code: vt.KeyTab}, + "right": {Code: vt.KeyRight}, + "left": {Code: vt.KeyLeft}, + "pgdown": {Code: vt.KeyPgDown}, + "pgup": {Code: vt.KeyPgUp}, + "ctrl+d": {Code: 'd', Mod: vt.ModCtrl}, + "ctrl+u": {Code: 'u', Mod: vt.ModCtrl}, + "ctrl+f": {Code: 'f', Mod: vt.ModCtrl}, + "ctrl+j": {Code: 'j', Mod: vt.ModCtrl}, + "up": {Code: vt.KeyUp}, + "down": {Code: vt.KeyDown}, + "bs": {Code: vt.KeyBackspace}, +} + +// at returns the column and the line of text on the screen, from 0. +func (tm *term) at(text string) (x, y int) { + tm.t.Helper() + x, y, _ = position(tm.waitFor(text), text) + return x, y +} + +// position returns the column and the line of the first text in s, from +// 0. ok is false if s does not have text. +func position(s, text string) (x, y int, ok bool) { + for i, l := range strings.Split(s, "\n") { + if before, _, found := strings.Cut(l, text); found { + return utf8.RuneCountInString(before), i, true + } + } + return 0, 0, false +} + +// mouse sends the mouse event m, as the terminal encodes it. +func (tm *term) mouse(m vt.Mouse) { + tm.mu.Lock() + defer tm.mu.Unlock() + tm.emu.SendMouse(m) +} + +// click clicks text on the screen the given number of times in a row. +func (tm *term) click(text string, times int) { + tm.t.Helper() + x, y := tm.at(text) + for range times { + tm.mouse(vt.MouseClick{X: x, Y: y, Button: vt.MouseLeft}) + tm.mouse(vt.MouseRelease{X: x, Y: y, Button: vt.MouseLeft}) + } +} + +// drag drags the mouse from the start of text from to the start of text +// to. +func (tm *term) drag(from, to string) { + tm.t.Helper() + x0, y0 := tm.at(from) + x1, y1 := tm.at(to) + tm.mouse(vt.MouseClick{X: x0, Y: y0, Button: vt.MouseLeft}) + tm.mouse(vt.MouseMotion{X: x1, Y: y1, Button: vt.MouseLeft}) + tm.mouse(vt.MouseRelease{X: x1, Y: y1, Button: vt.MouseLeft}) +} + +// wheel turns the mouse wheel down n times over text. +func (tm *term) wheel(text string, n int) { + tm.t.Helper() + x, y := tm.at(text) + for range n { + tm.mouse(vt.MouseWheel{X: x, Y: y, Button: vt.MouseWheelDown}) + } +} + +// waitFor waits until the screen shows each of texts, and returns the +// screen. +func (tm *term) waitFor(texts ...string) string { + tm.t.Helper() + return tm.waitUntil(func(s string) bool { return hasAll(s, texts...) }, + "show "+strings.Join(texts, ", ")) +} + +// waitGone waits until the screen shows none of texts. +func (tm *term) waitGone(texts ...string) string { + tm.t.Helper() + return tm.waitUntil(func(s string) bool { return !hasAny(s, texts...) }, + "not show "+strings.Join(texts, ", ")) +} + +// hasAll reports whether s has each of texts. +func hasAll(s string, texts ...string) bool { + return !slices.ContainsFunc(texts, func(t string) bool { return !strings.Contains(s, t) }) +} + +// hasAny reports whether s has one of texts. +func hasAny(s string, texts ...string) bool { + return slices.ContainsFunc(texts, func(t string) bool { return strings.Contains(s, t) }) +} + +// sameLine reports whether a line of s has each of texts. +func sameLine(s string, texts ...string) bool { + for l := range strings.Lines(s) { + if hasAll(l, texts...) { + return true + } + } + return false +} + +// waitCursor waits until the line of the cursor shows text. +func (tm *term) waitCursor(text string) { + tm.t.Helper() + tm.waitUntil(func(s string) bool { return sameLine(s, "▶", text) }, + "put the cursor on "+text) +} + +// waitUntil waits until ok is true for the screen, and returns the screen. +// It checks the screen after each write to it. +func (tm *term) waitUntil(ok func(string) bool, what string) string { + tm.t.Helper() + timeout := time.NewTimer(waitTime) + defer timeout.Stop() + for { + s := tm.screen() + if ok(s) { + return s + } + select { + case <-tm.changed: + case <-tm.done: + // rexdiff writes no more. The last write can come after the + // check. + if s = tm.screen(); ok(s) { + return s + } + tm.t.Fatalf("rexdiff stops, and the screen does not %s. It shows:\n%s", what, s) + case <-timeout.C: + tm.t.Fatalf("the screen does not %s. It shows:\n%s", what, s) + } + } +} + +// pollTime is the time between two checks of poll. +const pollTime = 20 * time.Millisecond + +// poll checks again and again until ok is true. It is for the files that +// the fake tools write, and for other programs, because they give no +// event. It fails the test with the text of why after waitTime. +func poll(t *testing.T, ok func() bool, why func() string) { + t.Helper() + timeout := time.NewTimer(waitTime) + defer timeout.Stop() + tick := time.NewTicker(pollTime) + defer tick.Stop() + for !ok() { + select { + case <-tick.C: + case <-timeout.C: + t.Fatal(why()) + } + } +} + +// waitFile waits until the file at path has content, and returns it. +func waitFile(t *testing.T, path string, content func(string) bool) string { + t.Helper() + var s string + poll(t, func() bool { + b, _ := os.ReadFile(path) + s = string(b) + return content(s) + }, func() string { return path + " has " + strconv.Quote(s) }) + return s +} + +// exited waits until rexdiff exits and returns its error. +func (tm *term) exited() error { + tm.t.Helper() + errc := make(chan error, 1) + go func() { errc <- tm.wait() }() + select { + case err := <-errc: + return err + case <-time.After(waitTime): + tm.t.Fatalf("rexdiff does not exit. The screen shows:\n%s", tm.screen()) + return nil + } +} diff --git a/test/testdata/rex/README.md b/test/testdata/rex/README.md new file mode 100644 index 0000000..2cdd2f4 --- /dev/null +++ b/test/testdata/rex/README.md @@ -0,0 +1,13 @@ +These files are the output of a real rex (0.0.1792787366-g623b2d). The +fake rex of the tests prints them. Each {{NAME}} is a value that a test +fills in: a block ID, a label, a path, an app, a state, or a time. + +- block.json: one block of `rex api call -s S session.list_blocks {}` +- session.json: `rex session inspect S --json`, without the layout +- process.json: `rex block call -s S -b ID process` +- status.json: `rex block call -s S -b ID program_status` +- event.json: a line of `rex events -s S --json` when the status changes +- inspect.json: `rex block inspect LABEL -s S --json`, without the API + +To record them again, run a shell in a new Rex session, report a status +with `printf '\033]7501;state=working:app=pi\007'`, and run the commands. diff --git a/test/testdata/rex/block.json b/test/testdata/rex/block.json new file mode 100644 index 0000000..a16a98c --- /dev/null +++ b/test/testdata/rex/block.json @@ -0,0 +1 @@ +{"block_id": "{{ID}}", "label": "{{LABEL}}", "creator_name": "com.superlogical.terminal", "flavor": "com.superlogical.terminal.shell", "host_id": "6615b6b5-455d-4643-821a-e3708549a8a9", "placement": "placed", "window_id": "window:w1", "owner": "client:01a174af-41e7-7c83-8334-3ff8f508a957"} diff --git a/test/testdata/rex/event.json b/test/testdata/rex/event.json new file mode 100644 index 0000000..85738e1 --- /dev/null +++ b/test/testdata/rex/event.json @@ -0,0 +1 @@ +{"type": "session_event", "payload": {"session_id": "session:S", "event": {"type": "block_event", "payload": {"block_id": "{{ID}}", "creator_name": "com.superlogical.terminal", "name": "program_status_changed", "payload": {"id": "", "record": {"state": "{{STATE}}", "app": "{{APP}}", "effective_app": "{{APP}}", "updated_at": "{{AT}}", "owner": {"pid": 61344, "name": "bash"}}, "reason": "report"}}}}} diff --git a/test/testdata/rex/inspect.json b/test/testdata/rex/inspect.json new file mode 100644 index 0000000..52d2828 --- /dev/null +++ b/test/testdata/rex/inspect.json @@ -0,0 +1 @@ +{"session_id": "session:S", "block": {"block_id": "{{ID}}", "label": "{{LABEL}}", "creator_name": "com.superlogical.terminal", "flavor": "com.superlogical.terminal.shell", "host_id": "6615b6b5-455d-4643-821a-e3708549a8a9", "placement": "placed", "window_id": "window:w1", "owner": "client:01a174af-41e7-7c83-8334-3ff8f508a957"}} diff --git a/test/testdata/rex/process.json b/test/testdata/rex/process.json new file mode 100644 index 0000000..1b680fa --- /dev/null +++ b/test/testdata/rex/process.json @@ -0,0 +1 @@ +{"last_exit": null, "child": {"pid": 61344, "user_id": 501, "user": "u", "name": "bash", "path": "/bin/bash", "invoked_path": "/bin/bash", "argv0": "sh", "cwd": "{{CWD}}"}, "foreground": {"pid": 61344, "user_id": 501, "user": "u", "name": "bash", "path": "/bin/bash", "invoked_path": "/bin/bash", "argv0": "sh", "cwd": "{{CWD}}"}, "home": "/Users/u"} diff --git a/test/testdata/rex/session.json b/test/testdata/rex/session.json new file mode 100644 index 0000000..c16fe7a --- /dev/null +++ b/test/testdata/rex/session.json @@ -0,0 +1 @@ +{"revision": 3, "session_id": "session:S", "label": "S", "windows": [{"window_id": "window:w1", "label": "main"}]} diff --git a/test/testdata/rex/status.json b/test/testdata/rex/status.json new file mode 100644 index 0000000..a5d9516 --- /dev/null +++ b/test/testdata/rex/status.json @@ -0,0 +1 @@ +{"records": [{"state": "{{STATE}}", "app": "{{APP}}", "effective_app": "{{APP}}", "updated_at": "{{AT}}", "owner": {"pid": 61344, "name": "bash"}}]} diff --git a/test/testdata/rexdiff-0.2.7.lua b/test/testdata/rexdiff-0.2.7.lua new file mode 100644 index 0000000..e1aa56b --- /dev/null +++ b/test/testdata/rexdiff-0.2.7.lua @@ -0,0 +1,138 @@ +-- Installed by "rexdiff install". Remove with "rexdiff uninstall". +-- +-- The settings of rexdiff. Edit them, then run "rex config reload". The +-- README of rexdiff lists them. Install and upgrades keep this part. +local settings = { + -- key = "shift+cmd+r", -- the key of the toggle, or false for none + -- placement = "split", -- split, zoomed, tab, or overlay + -- layout = "split", +} + +-- rexdiff code below. Install and upgrades replace it. Do not edit this line. +local BIN = "{{BIN}}" +local PLACEMENTS = { split = true, zoomed = true, tab = true, overlay = true } +local TERM = "com.superlogical.terminal" + +-- must returns res, or fails the action with err. A Rex call returns nil +-- and an error message when it fails. +local function must(res, err) + if err ~= nil then error(err, 0) end + return res +end + +-- call calls method on the terminal block with the ID id. +local function call(method, id) + return must(rex.block.call(TERM, method, { block_id = id })) +end + +-- runs_rexdiff returns true if the terminal block id runs rexdiff. rexdiff +-- reports app=rexdiff to Rex with OSC 7501. Rex keeps an idle status after +-- its program stops, so the block counts only while the process that +-- reported the status is in the foreground. +local function runs_rexdiff(id) + for _, r in ipairs(call("program_status", id).records or {}) do + if r.app == "rexdiff" and r.owner then + local fg = call("process", id).foreground + return fg ~= nil and fg.pid == r.owner.pid + end + end + return false +end + +-- reviewers returns the IDs of the blocks in the session that run rexdiff. +local function reviewers() + local ids = {} + for _, b in ipairs(must(rex.session.list_blocks{}).blocks or {}) do + if b.creator_name == TERM and runs_rexdiff(b.block_id) then + table.insert(ids, b.block_id) + end + end + return ids +end + +-- check fails the action if rexdiff does not accept config, the settings +-- as a JSON object. Here the user sees the error. If rexdiff stops at once +-- in a new pane, the pane closes and the error is lost. +local function check(config) + local r, err = rex.exec({ BIN, "--check", "--config", config }) + if r == nil then error(err, 0) end + if not r.ok then error((r.stderr:gsub("%s+$", "")), 0) end +end + +-- open_rexdiff opens rexdiff at placement, beside the agent in ctx, with +-- the settings in config. rexdiff starts in the current directory of the +-- agent. The rex package of rexdiff finds the directory the same way. +local function open_rexdiff(ctx, placement, config) + local p = call("process", ctx.block_id) + local cwd = (p.foreground or {}).cwd or (p.child or {}).cwd + local node = rex.layout.block{ + flavor = TERM .. ".shell", + label = "rexdiff", + options = { + command = { BIN, "--agent", ctx.block_id, "--config", config }, + cwd = cwd, + shell = "none", + }, + } + if placement == "tab" then + return must(rex.session.new_window{ window_label = "rexdiff", layout = node }) + elseif placement == "overlay" then + return must(rex.session.new_layer{ + bounds = { 0.04, 0.04, 0.92, 0.92 }, + layout = node, + focus = true, + }) + end + local s = must(rex.session.new_split{ + anchor_id = ctx.block_id, + direction = "horizontal", + side = "after", + ratio = 0.45, + focus = true, + layout = node, + }) + if placement == "zoomed" then + must(rex.session.zoom_block{ block_id = s.block_ids[1], zoom = true }) + end + return s +end + +-- The key and the placement are for Rex. rexdiff gets the other settings +-- as JSON. +if type(settings) ~= "table" then + error("rexdiff.lua: settings must be a table, as in: local settings = { }", 0) +end +local opts = {} +for k, v in pairs(settings) do opts[k] = v end +local key = opts.key +if key == nil then key = "shift+cmd+r" end +local default_placement = opts.placement or "split" +opts.key, opts.placement = nil, nil +local config, encode_err = rex.json.encode(opts) + +rex.action{ + name = "rexdiff_toggle", + title = "Toggle rexdiff", + description = "Review the agent's changes beside it and send comments back", + category = "rexdiff", + keywords = "diff review git comments agent", + args = { placement = "string?" }, -- split, zoomed, tab, or overlay + run = function(ctx, args) + local placement = args.placement or default_placement + if not PLACEMENTS[placement] then + error("unknown placement " .. tostring(placement) + .. "; use split, zoomed, tab, or overlay", 0) + end + local open = reviewers() + if #open == 0 then + if config == nil then error("rexdiff settings: " .. encode_err, 0) end + check(config) + return open_rexdiff(ctx, placement, config) + end + for _, id in ipairs(open) do + must(rex.call("block.close", { session_id = ctx.session_id, block_id = id })) + end + return { closed = open } + end, +} +if key then rex.bind(key, "rexdiff_toggle") end diff --git a/test/testdata/script/cli.txt b/test/testdata/script/cli.txt new file mode 100644 index 0000000..04d2de2 --- /dev/null +++ b/test/testdata/script/cli.txt @@ -0,0 +1,32 @@ +# rexdiff does not start when its input is wrong, and says what is wrong. +! exec rexdiff $WORK +stderr '^rexdiff: open .+: git rev-parse: fatal: not a git repository' +exec git init -q repo +! exec rexdiff repo -wrap=false +stderr '^unknown argument "-wrap=false" \(flags go before the repo\)$' +stderr '^usage: rexdiff' +! exec rexdiff -nope +stderr -count=1 'flag provided but not defined: -nope' + +# -check checks the settings without a repository, for the Rex action. +! exec rexdiff -check -config '{"layuot": "split"}' +stderr '^rexdiff: config: unknown key "layuot"$' +! exec rexdiff -check -config '{"layout": "sideways"}' +stderr '^rexdiff: config: invalid value for layout: expected one of unified, split, not "sideways"$' +! exec rexdiff -check -config '{"keybindings": {"next-file": ["x"], "prev-file": ["x"]}}' +stderr '^rexdiff: config: invalid value for keybindings: "x" is bound to both "next-file" and "prev-file"$' +! exec rexdiff -check -config '{"url_opener": "{url} --new"}' +stderr '^rexdiff: config: invalid value for url_opener: expected a command that names a program first, not "{url} --new"$' + +# As in herdr-reviewr, the settings take the old names of three actions. +exec rexdiff -check -config '{"keybindings": {"list-wider": ["W"], "preview": ["M"]}}' +! exec rexdiff -check -config '{"keybindings": {"rendered": ["M"], "preview": ["P"]}}' +stderr '^rexdiff: config: invalid value for keybindings: "rendered" and "preview" name the same action$' + +# A bad value of a flag names the flag, as the settings do. +! exec rexdiff -layout sideways repo +stderr '^rexdiff: invalid value for -layout: expected one of unified, split, not "sideways"$' +! exec rexdiff -scope all repo +stderr '^rexdiff: invalid value for -scope: expected one of uncommitted, branch, last-turn, not "all"$' +! exec rexdiff -wrap no repo +stderr '^rexdiff: invalid value for -wrap: expected one of on, off, not "no"$' diff --git a/test/testdata/script/install.txt b/test/testdata/script/install.txt new file mode 100644 index 0000000..a9cb300 --- /dev/null +++ b/test/testdata/script/install.txt @@ -0,0 +1,115 @@ +# install writes rexdiff.lua with the settings and rexdiff_toggle.lua with the +# code. It adds one line to init.lua, keeps the rest of init.lua, and +# reloads Rex. +env REX_CONFIG=$WORK/.config/rex/init.lua +mkdir .config/rex +cp mine.lua .config/rex/init.lua +exec rexdiff install +stdout 'wrote '$WORK'/.config/rex/rexdiff_toggle.lua' +stdout 'wrote '$WORK'/.config/rex/rexdiff.lua with your settings' +stdout 'added require\("rexdiff"\) to '$WORK'/.config/rex/init.lua' +stdout 'press the key of the settings' +cmp .config/rex/init.lua installed.lua +grep '^local settings = \{' .config/rex/rexdiff.lua +grep 'require\("rexdiff_toggle"\)\(settings\)' .config/rex/rexdiff.lua +grep '^-- rexdiff writes this file. Do not edit it.' .config/rex/rexdiff_toggle.lua +grep '^local BIN = "' .config/rex/rexdiff_toggle.lua +grep 'config reload' rex.log + +# Rex accepts the action and its keys. +[realrex] exec $REALREX config check +[realrex] stdout 'bindings: 2' +[realrex] stdout 'actions: 1' +[realrex] ! stderr . + +# With a broken rexdiff_toggle.lua, the rest of init.lua still loads, and the +# key runs an action of rexdiff.lua that shows the error. +[realrex] cp after.lua .config/rex/init.lua +[realrex] cp broken.lua .config/rex/rexdiff_toggle.lua +[realrex] exec $REALREX config check --json +[realrex] stdout '"name": "after_x"' +[realrex] stdout '"source": "rexdiff.lua:' +[realrex] stdout '"bindings": 2' +[realrex] stdout '"diagnostics": \[\]' +[realrex] ! stderr . +cp installed.lua .config/rex/init.lua + +# A second install keeps an edited rexdiff.lua, and writes rexdiff_toggle.lua +# again. It keeps the one line of init.lua, and does not say it adds it. +cp edited.lua .config/rex/rexdiff.lua +cp broken.lua .config/rex/rexdiff_toggle.lua +exec rexdiff install +stdout 'kept your settings in '$WORK'/.config/rex/rexdiff.lua' +! stdout 'added' +cmp .config/rex/rexdiff.lua edited.lua +grep '^local BIN = "' .config/rex/rexdiff_toggle.lua +! grep 'broken' .config/rex/rexdiff_toggle.lua +cmp .config/rex/init.lua installed.lua + +# install moves a rexdiff.lua from rexdiff 0.2.7 to the two files. It +# keeps the settings above the line of the code, and puts the loader in +# place of the code. +cp old.lua .config/rex/rexdiff.lua +cp broken.lua .config/rex/rexdiff_toggle.lua +exec rexdiff install +stdout 'kept your settings in '$WORK'/.config/rex/rexdiff.lua, and moved the code out' +grep '^local settings = \{ layout = "split" \}$' .config/rex/rexdiff.lua +grep 'require\("rexdiff_toggle"\)\(settings\)' .config/rex/rexdiff.lua +! grep 'rexdiff code below|old_code' .config/rex/rexdiff.lua +! grep 'broken' .config/rex/rexdiff_toggle.lua +[realrex] exec $REALREX config check --json +[realrex] stdout '"source": "rexdiff_toggle.lua:' +[realrex] stdout '"diagnostics": \[\]' + +# uninstall -h only shows the usage. +exec rexdiff uninstall -h +exists .config/rex/rexdiff.lua + +# If uninstall cannot change init.lua, it keeps rexdiff.lua, so that +# init.lua does not require a missing file. +chmod 0444 .config/rex/init.lua +! exec rexdiff uninstall +exists .config/rex/rexdiff.lua +chmod 0644 .config/rex/init.lua + +# uninstall removes the line, rexdiff.lua, and rexdiff_toggle.lua, and keeps +# the rest. +exec rexdiff uninstall +stdout 'removed the rexdiff action, its key, and its settings from Rex' +cmp .config/rex/init.lua mine.lua +! exists .config/rex/rexdiff.lua +! exists .config/rex/rexdiff_toggle.lua + +# If Rex does not reload, install still writes the files and says what to do. +env REX_FAIL=1 +exec rexdiff install +stderr 'reload Rex''s config yourself: rex config reload' + +# On macOS, the action runs a symlinked rexdiff through the link, so that +# it still works after brew upgrade. +[darwin] exec sh -c 'mkdir brew && ln -s "$(command -v rexdiff)" brew/rexdiff' +[darwin] exec $WORK/brew/rexdiff install +[darwin] grep 'local BIN = "'$WORK'/brew/rexdiff"' .config/rex/rexdiff_toggle.lua + +-- mine.lua -- +rex.bind("cmd+e", "pane.zoom") +-- installed.lua -- +rex.bind("cmd+e", "pane.zoom") +require("rexdiff") +-- after.lua -- +rex.bind("cmd+e", "pane.zoom") +require("rexdiff") +rex.action{ name = "after_x", title = "After x", run = function() end } +-- broken.lua -- +-- broken code +local x = = 1 +-- edited.lua -- +local settings = { layout = "split" } +require("rexdiff_toggle")(settings) +-- old.lua -- +-- The settings of rexdiff. +local settings = { layout = "split" } + +-- rexdiff code below. Install and upgrades replace it. Do not edit this line. +local BIN = "/old/rexdiff" +local function old_code() end diff --git a/test/update_test.go b/test/update_test.go new file mode 100644 index 0000000..426078a --- /dev/null +++ b/test/update_test.go @@ -0,0 +1,88 @@ +package test + +import ( + "archive/tar" + "bytes" + "cmp" + "compress/gzip" + "crypto/sha256" + "encoding/hex" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "runtime" + "strings" + "sync" + "testing" + "testing/fstest" +) + +// release serves the latest release tag with the binary at bin, as GitHub +// does. It returns the address of the latest release. +func release(t *testing.T, tag, bin string) string { + t.Helper() + content, err := os.ReadFile(bin) + if err != nil { + t.Fatal(err) + } + var b bytes.Buffer + gz := gzip.NewWriter(&b) + tw := tar.NewWriter(gz) + hdr := &tar.Header{Name: "rexdiff", Mode: 0o755, Size: int64(len(content)), Typeflag: tar.TypeReg} + if err := tw.WriteHeader(hdr); err != nil { + t.Fatal(err) + } + if _, err := tw.Write(content); err != nil { + t.Fatal(err) + } + if err := tw.Close(); err != nil { + t.Fatal(err) + } + if err := gz.Close(); err != nil { + t.Fatal(err) + } + name := "rexdiff-" + tag + "-" + runtime.GOOS + "-" + runtime.GOARCH + ".tar.gz" + archive := b.Bytes() + sum := sha256.Sum256(archive) + files := fstest.MapFS{ + name: {Data: archive}, + "sha256sums.txt": {Data: []byte(hex.EncodeToString(sum[:]) + " " + name + "\n")}, + } + srv := httptest.NewServer(http.FileServerFS(files)) + t.Cleanup(srv.Close) + files["latest"] = &fstest.MapFile{Data: []byte(`{"tag_name": "` + tag + `", "assets": [` + + `{"name": "` + name + `", "browser_download_url": "` + srv.URL + "/" + name + `"},` + + `{"name": "sha256sums.txt", "browser_download_url": "` + srv.URL + `/sha256sums.txt"}]}`)} + return srv.URL + "/latest" +} + +// TestUpdate checks that U installs the latest release and starts it in +// the same terminal, with the comments and the place of the user. +func TestUpdate(t *testing.T) { + // The two builds run at once. + var next string + var nextErr error + var wg sync.WaitGroup + nextDir := t.TempDir() + wg.Go(func() { next, nextErr = build(t.Context(), nextDir, "0.2.0") }) + bin, err := build(t.Context(), t.TempDir(), "0.1.0") + wg.Wait() + if err = cmp.Or(err, nextErr); err != nil { + t.Fatal(err) + } + latest := release(t, "v0.2.0", next) + tm := start(t, newRepo(t), opts{bin: bin, env: []string{"REXDIFF_LATEST_URL=" + latest}}) + tm.waitFor("rexdiff v0.2.0 is out: press U or click here to update") + comment(tm, "hi") + tm.keys("U") + // The new rexdiff is the latest, so it shows no update. It has the + // comment, but not the message of the old one. + tm.waitUntil(func(s string) bool { + return strings.Contains(s, "💬 hi") && !hasAny(s, "is out", "updating", "comment added") + }, "show the comment in the new rexdiff") + out, err := exec.CommandContext(t.Context(), bin, "-version").Output() + if got := strings.TrimSpace(string(out)); err != nil || got != "v0.2.0" { + t.Errorf("rexdiff -version = %q (%v), want v0.2.0", got, err) + } +} diff --git a/test/upgrade_test.go b/test/upgrade_test.go new file mode 100644 index 0000000..3081861 --- /dev/null +++ b/test/upgrade_test.go @@ -0,0 +1,135 @@ +package test + +import ( + "errors" + "io/fs" + "os" + "os/exec" + "path/filepath" + "slices" + "strings" + "testing" +) + +// TestUpgradeAtStart checks that rexdiff in Rex writes a changed +// rexdiff_toggle.lua again, keeps rexdiff.lua, and reloads Rex. It does not +// change a rexdiff_toggle.lua that runs another rexdiff. Without +// rexdiff_toggle.lua, it writes no file. +func TestUpgradeAtStart(t *testing.T) { + repo := newRepo(t) + rex := newRex(t, repo) + config := filepath.Join(t.TempDir(), "init.lua") + env := []string{"REX_CONFIG=" + config} + install := exec.CommandContext(t.Context(), binary(t), "install") + install.Env = slices.Concat(cleanEnv(), env, []string{"REX_BIN_DIR=" + rex.dir}) + if out, err := install.CombinedOutput(); err != nil { + t.Fatalf("rexdiff install: %v\n%s", err, out) + } + lua := filepath.Join(filepath.Dir(config), "rexdiff.lua") + code := filepath.Join(filepath.Dir(config), "rexdiff_toggle.lua") + edited := strings.Replace(read(t, lua), "local settings = {", "local settings = { layout = \"split\",", 1) + write(t, lua, edited) + installed := read(t, code) + write(t, code, strings.Replace(installed, "local function must", "local function old_must", 1)) + + tm := start(t, repo, opts{rex: rex, env: env}) + tm.keys("q") + if err := tm.exited(); err != nil { + t.Fatal(err) + } + if got := read(t, code); got != installed { + t.Errorf("rexdiff_toggle.lua after the start = \n%s\nwant:\n%s", got, installed) + } + if got := read(t, lua); got != edited { + t.Errorf("rexdiff.lua after the start = \n%s\nwant:\n%s", got, edited) + } + // The install reloads once, and the upgrade once. + if n := strings.Count(rex.calls(t), "config reload"); n != 2 { + t.Errorf("config reloads = %d, want 2", n) + } + + other := strings.Replace(installed, "local BIN = ", "local BIN = \"/other/rexdiff\" -- ", 1) + write(t, code, other) + tm = start(t, repo, opts{rex: rex, env: env}) + tm.keys("q") + if err := tm.exited(); err != nil { + t.Fatal(err) + } + if got := read(t, code); got != other { + t.Errorf("rexdiff_toggle.lua of another rexdiff after the start = \n%s\nwant:\n%s", got, other) + } + + if err := os.Remove(code); err != nil { + t.Fatal(err) + } + tm = start(t, repo, opts{rex: rex, env: env}) + tm.keys("q") + if err := tm.exited(); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(code); !errors.Is(err, fs.ErrNotExist) { + t.Errorf("rexdiff_toggle.lua after a start without it: %v, want no file", err) + } +} + +// TestUpgradeFrom027 checks the first start of rexdiff in Rex after an +// upgrade from rexdiff 0.2.7. The rexdiff.lua of 0.2.7 has the settings, +// then the code under a marker line. rexdiff keeps the settings, puts the +// loader in place of the code, writes rexdiff_toggle.lua, and reloads Rex. +func TestUpgradeFrom027(t *testing.T) { + repo := newRepo(t) + rex := newRex(t, repo) + dir := t.TempDir() + config := filepath.Join(dir, "init.lua") + write(t, config, "require(\"rexdiff\")\n") + lua := filepath.Join(dir, "rexdiff.lua") + old := strings.Replace(read(t, filepath.Join("testdata", "rexdiff-0.2.7.lua")), + "{{BIN}}", binary(t), 1) + old = strings.Replace(old, " -- layout = \"split\",", " layout = \"split\",", 1) + write(t, lua, old) + settings, _, _ := strings.Cut(old, "-- rexdiff code below.") + + tm := start(t, repo, opts{rex: rex, env: []string{"REX_CONFIG=" + config}}) + tm.keys("q") + if err := tm.exited(); err != nil { + t.Fatal(err) + } + got := read(t, lua) + if !strings.HasPrefix(got, strings.TrimRight(settings, "\n")) || + !strings.Contains(got, `require("rexdiff_toggle")(settings)`) || + strings.Contains(got, "rexdiff code below") { + t.Errorf("rexdiff.lua after the start = \n%s\nwant the settings of 0.2.7 and the loader", got) + } + if got := read(t, filepath.Join(dir, "rexdiff_toggle.lua")); !strings.Contains(got, "local BIN = \""+binary(t)+"\"") { + t.Errorf("rexdiff_toggle.lua after the start = \n%s\nwant the code for %s", got, binary(t)) + } + if n := strings.Count(rex.calls(t), "config reload"); n != 1 { + t.Errorf("config reloads = %d, want 1", n) + } + if bin := os.Getenv("REXDIFF_REX"); bin != "" { + check := exec.CommandContext(t.Context(), bin, "config", "check") + check.Env = append(cleanEnv(), "REX_CONFIG="+config) + out, err := check.CombinedOutput() + if err != nil || !strings.Contains(string(out), "actions: 1") { + t.Errorf("rex config check after the upgrade: %v\n%s", err, out) + } + } +} + +// read returns the content of the file at path. +func read(t *testing.T, path string) string { + t.Helper() + b, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + return string(b) +} + +// write writes content to the file at path. +func write(t *testing.T, path, content string) { + t.Helper() + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } +} diff --git a/tui/agentpick.go b/tui/agentpick.go new file mode 100644 index 0000000..06c3a02 --- /dev/null +++ b/tui/agentpick.go @@ -0,0 +1,60 @@ +package tui + +// The agent picker picks the agent that gets the review. + +import ( + "fmt" + "slices" + + "github.com/rednafi/rexdiff/rex" +) + +// openAgentPicker lets the user pick the agent to send to. The cursor +// starts on the agent of the last send, else the agent beside the pane. +func (m *model) openAgentPicker(agents []rex.Agent) { + ov := newOverlay(overlayAgents, "send to which agent?") + ov.agents = agents + var items []pickItem + armed := 0 + for i, a := range agents { + detail := a.Status.App + " · " + string(a.Status.State) + if a.Status.State == rex.StateBlocked { + detail = a.Status.App + " · at a prompt" + } + if a.Window != "" { + detail += " · " + a.Window + } + tag := "" + switch a.Block { + case m.lastSent: + tag, armed = "last used", i + case m.beside: + tag = "opened beside" + if m.lastSent == "" { + armed = i + } + } + items = append(items, pickItem{ + label: fmt.Sprintf("%d %s", i+1, a.Name), + detail: detail, + tag: tag, + id: a.Block, + }) + } + ov.setItems(items) + ov.cur = armed + m.open(ov) +} + +// agent returns the agent of item it of the agent picker. If it is nil, +// ok is false. +func (o *overlay) agent(it *pickItem) (a rex.Agent, ok bool) { + if it == nil { + return rex.Agent{}, false + } + i := slices.IndexFunc(o.agents, func(a rex.Agent) bool { return a.Block == it.id }) + if i < 0 { + return rex.Agent{}, false + } + return o.agents[i], true +} diff --git a/tui/basepick.go b/tui/basepick.go new file mode 100644 index 0000000..158cbf0 --- /dev/null +++ b/tui/basepick.go @@ -0,0 +1,95 @@ +package tui + +// The base picker picks the base of the branch scope. + +import ( + "context" + "fmt" + "slices" + "strings" + "time" + + tea "charm.land/bubbletea/v2" + + "github.com/rednafi/rexdiff/vcs" +) + +// branchTimeout is the longest time of the branch list. +const branchTimeout = 5 * time.Second + +// branchesMsg has the branch list of the base picker. +type branchesMsg struct { + branches []string + err error +} + +// openBasePicker lists the branches for the base picker. The list does +// nothing on the PR tab, or if the base comes from the flag. +func (m *model) openBasePicker() tea.Cmd { + if m.baseFlag || m.tab == tabPR { + return nil + } + ctx, cancel := context.WithCancelCause(m.ctx) + ctx, cancelTimeout := context.WithTimeoutCause(ctx, branchTimeout, + fmt.Errorf("branch list: %v timeout: %w", branchTimeout, context.DeadlineExceeded)) + repo := m.repo + return func() tea.Msg { + defer cancelTimeout() + defer cancel(fmt.Errorf("branch list: done: %w", context.Canceled)) + branches, err := repo.Branches(ctx) + return branchesMsg{branches: branches, err: err} + } +} + +// onBranches opens the base picker with the branch list of msg. The +// current base has a tag. As in herdr-reviewr, the base of an open pull +// request and the default branch come first, with a tag. +func (m *model) onBranches(msg branchesMsg) { + if msg.err != nil { + m.flash("branch list failed: " + msg.err.Error()) + return + } + if m.overlay != nil || m.tab == tabPR { + return + } + ov := newOverlay(overlayBase, "base branch for the branch scope (or type any ref)") + prBase := "" + if pr := m.pr.pr; pr != nil && pr.State == vcs.PROpen { + prBase = pr.Base + } + defaultBase := strings.TrimPrefix(m.defaultBase, "origin/") + current := strings.TrimPrefix(m.base, "origin/") + // groups has the PR base, then the default branch, then the others. + // Each group keeps the newest first. + var groups [3][]pickItem + for _, b := range msg.branches { + var tags []string + name, g := strings.TrimPrefix(b, "origin/"), 2 + if name == prBase { + tags, g = append(tags, "pr base"), 0 + } + if name == defaultBase { + tags, g = append(tags, "default"), min(g, 1) + } + if name == current { + tags = append(tags, "current") + } + groups[g] = append(groups[g], pickItem{label: b, id: b, tag: strings.Join(tags, " ")}) + } + ov.setItems(slices.Concat(groups[:]...)) + m.open(ov) +} + +// baseRef returns the ref that the base picker picks, with the selected +// item it. A typed ref, for example a tag, wins over the first match. A +// match that the user moves to wins over the typed text. If the ref is +// not valid, ok is false. +func (m *model) baseRef(it *pickItem) (ref string, ok bool) { + ref = strings.TrimSpace(m.overlay.input.Value()) + _, err := m.repo.Resolve(m.ctx, ref) + if it != nil && (ref == "" || err != nil || m.overlay.cur > 0) { + ref = it.id + _, err = m.repo.Resolve(m.ctx, ref) + } + return ref, ref != "" && err == nil +} diff --git a/tui/bench_test.go b/tui/bench_test.go deleted file mode 100644 index ce71da4..0000000 --- a/tui/bench_test.go +++ /dev/null @@ -1,37 +0,0 @@ -package tui - -import ( - "strings" - "testing" - - tea "charm.land/bubbletea/v2" -) - -// BenchmarkReview reloads a large change and scrolls through it, as a user -// does. Use it to profile the program: -// -// go test ./tui -run '^$' -bench Review -cpuprofile cpu.out -memprofile mem.out -func BenchmarkReview(b *testing.B) { - m, dir := newTestModel(b) - // The benchmark makes 20 new files of 2,000 lines each. - var src strings.Builder - for i := range 2000 { - src.WriteString("\tx := compute(" + strings.Repeat("a", i%40) + ")\n") - } - content := "package a\nfunc f() {\n" + src.String() + "}\n" - for i := range 20 { - write(b, dir, "big"+string(rune('a'+i))+".go", content) - } - // Select a large file. The reload keeps the selected file. - m.reload() - m.pickFile(1) - down := tea.KeyPressMsg{Code: 'j', Text: "j"} - for b.Loop() { - m.reload() - for range 100 { - next, _ := m.Update(down) - m = *next.(*model) - _ = m.View() - } - } -} diff --git a/tui/comment.go b/tui/comment.go index 7561a5d..00e8aff 100644 --- a/tui/comment.go +++ b/tui/comment.go @@ -1,6 +1,9 @@ package tui import ( + "os" + "path/filepath" + "slices" "strings" "charm.land/bubbles/v2/key" @@ -16,8 +19,8 @@ type targetLine struct { } // target returns the line that a comment on row i of the current pane -// attaches to, or nil. If the row changes a line on side pref, it uses -// that side. Else it prefers the new side. So a comment on an unchanged +// attaches to, or nil. If the row changes a line on side pref, target uses +// that side. Else target uses the new side. Thus a comment on an unchanged // line is on the new side. func (m *model) target(i int, pref diff.Side) *targetLine { p := m.cur() @@ -36,24 +39,66 @@ func (m *model) target(i int, pref diff.Side) *targetLine { return nil } -// changedFile returns the selected file of the Changes tab, or nil. -func (m *model) changedFile() *changed { - if m.changeSel >= len(m.files) { +// selectedChange returns the selected file of the Changes tab, or nil. +func (m *model) selectedChange() *changedFile { + if m.changeSel >= len(m.changeFiles) { return nil } - return &m.files[m.changeSel] + return &m.changeFiles[m.changeSel] } -// contentFor returns the text of side s of the file at path. +// contentFor returns the text of side s of the file at path, as the pane +// shows it. Thus a comment quotes the lines on the screen, and not a newer +// version of the file. A file that the pane does not show gives "". func (m *model) contentFor(path string, s diff.Side) string { - if f := m.changedFile(); m.tab == tabChanges && f != nil && f.Path == path { + if f := m.selectedChange(); m.tab == tabChanges && f != nil && f.Path == path { if s == diff.SideOld { return f.Old } return f.New } - content, _ := m.repo.ReadFile(path) // an unreadable file gives a comment no code - return content + if m.tab == tabFiles && path == m.viewedPath { + return m.viewed + } + return "" +} + +// diffRev returns the revision of the diff in the Changes tab, for +// [diff.Anchor]. That is the picked commits, or "" for the working tree. +func (m *model) diffRev() string { + if m.scope == scopeCommits { + return m.from + ".." + m.to + } + return "" +} + +// anchorOf returns the anchor of a comment made in tab t. As in +// herdr-reviewr, a comment on a file view is always on the working tree. +func (m *model) anchorOf(t tab) diff.Anchor { + if t == tabChanges { + return diff.Anchor{InDiff: true, Rev: m.diffRev()} + } + return diff.Anchor{} +} + +// anchor returns the anchor of a comment made in the current tab. +func (m *model) anchor() diff.Anchor { return m.anchorOf(m.tab) } + +// revCurrent reports whether the scope shows the diff that c is made on. +// Only then can the user edit c from the comment list. +func (m *model) revCurrent(c *diff.Comment) bool { + return !c.Anchor.InDiff || c.Anchor.Rev == m.diffRev() +} + +// stale reports whether the lines of c can have moved. A comment on a diff +// is stale if its file leaves the changes. A comment on a file view is +// stale if its file is gone. +func (m *model) stale(c *diff.Comment) bool { + if c.Anchor.InDiff { + return !slices.ContainsFunc(m.changeFiles, func(f changedFile) bool { return f.Path == c.Path }) + } + _, err := os.Lstat(filepath.Join(m.repo.Root(), c.Path)) + return err != nil } // commentAtCursor returns the comment on the cursor line, or nil. @@ -63,7 +108,7 @@ func (m *model) commentAtCursor() *diff.Comment { if t == nil { return nil } - return m.comments.At(p.path, t.side, t.line) + return m.commentOn(p.cur, t) } // selection returns the first and the last row of the visual selection. @@ -75,32 +120,44 @@ func (p *pane) selection() (lo, hi int) { return p.span(p.anchor, p.cur) } +// selectedRows returns the source rows of the selection. In rendered +// markdown, they are all the rows of the selected blocks, with the +// deleted lines between them. +func (p *pane) selectedRows() []diff.Row { + lo, hi := p.selection() + if p.src == nil { + return p.rows[lo : hi+1] + } + first, last := p.rows[p.head(lo)].New, p.rows[hi].New + i := slices.IndexFunc(p.src, func(r diff.Row) bool { return r.New == first }) + j := slices.IndexFunc(p.src, func(r diff.Row) bool { return r.New == last }) + return p.src[i : j+1] +} + // span returns the first and the last row of the lines between rows a and // b, in either order. func (p *pane) span(a, b int) (lo, hi int) { return min(a, b), p.end(max(a, b)) } -// startComment opens the comment box for the selection, or for the cursor -// line. On a line that has a comment, it edits the comment. +// startComment opens the comment box for a new comment on the selection, +// or on the cursor line. A line can have more than one comment. func (m *model) startComment(pref diff.Side) tea.Cmd { p := m.cur() t := m.target(p.cur, pref) if t == nil { return nil } - if c := m.comments.At(p.path, t.side, t.line); c != nil && p.anchor < 0 { - return m.editComment(c) - } - lo, hi := p.selection() c := diff.NewComment( p.path, t.side, - p.rows[lo:hi+1], + p.selectedRows(), m.contentFor(p.path, diff.SideOld), m.contentFor(p.path, diff.SideNew), ) - m.draft = &draft{Comment: c} + c.Anchor = m.anchor() + m.draft = &draft{Comment: c, listRow: -1} + m.sizeEditor() m.editor.SetValue("") m.mode = modeComment // Put the cursor on the last line of the range, so that the box opens @@ -109,27 +166,43 @@ func (m *model) startComment(pref diff.Side) tea.Cmd { return m.editor.Focus() } -// editComment opens the comment box with the text of c. -func (m *model) editComment(c *diff.Comment) tea.Cmd { - m.draft = &draft{Comment: *c, editID: c.ID} +// editComment opens the comment box with the text of c. After the edit, +// the comment list opens at listRow, if listRow is 0 or more. +func (m *model) editComment(c *diff.Comment, listRow int) tea.Cmd { + m.draft = &draft{Comment: *c, editID: c.ID, listRow: listRow} + m.sizeEditor() m.editor.SetValue(c.Text) m.mode = modeComment cursorToLine(m.cur(), c.Side, c.End) return m.editor.Focus() } +// showsLine reports whether the current pane shows the last line of c, +// with the anchor of c. +func (m *model) showsLine(c *diff.Comment) bool { + p := m.cur() + return m.anchor() == c.Anchor && p.path == c.Path && p.rowOf(c.Side, c.End) >= 0 +} + // updateComment handles a key while the comment box is open. func (m *model) updateComment(msg tea.KeyPressMsg) tea.Cmd { switch { case key.Matches(msg, m.keys.Save): + // As in herdr-reviewr, blank text cancels. An edit keeps the + // comment, its ID, and its place in the list. d := m.draft - if d.editID > 0 { - m.comments.Remove(d.editID) - } - if text := strings.TrimSpace(m.editor.Value()); text != "" { + text := strings.TrimSpace(m.editor.Value()) + edited := m.comments.Find(d.editID) + switch { + case text == "": + case edited != nil: + edited.Text = text + m.flash("comment updated") + default: c := d.Comment - c.Text, c.Stale = text, false + c.Text = text m.comments.Add(c) + m.flash("comment added") } m.closeComment() return nil @@ -139,29 +212,55 @@ func (m *model) updateComment(msg tea.KeyPressMsg) tea.Cmd { } var cmd tea.Cmd m.editor, cmd = m.editor.Update(msg) + m.ensureVisible() return cmd } -// closeComment closes the comment box and clears the selection. +// closeComment closes the comment box and clears the selection. Then it +// does the reload that waits for the box. After an edit from the comment +// list, it opens the list again. func (m *model) closeComment() { + row := m.draft.listRow m.mode, m.draft = modeNormal, nil m.cur().anchor = -1 m.editor.Blur() + m.reloadDue() + if row >= 0 && m.comments.Len() > 0 { + m.openCommentList() + m.overlay.cur = min(row, len(m.overlay.shown)-1) + } +} + +// nearNewLine returns the new line of row i. A row with no new line, such +// as a deleted line, gives the next new line below it. If there is none, it +// gives the last new line above it. If there is none, it gives 1. +func nearNewLine(rows []diff.Row, i int) int { + for _, r := range rows[i:] { + if r.New > 0 { + return r.New + } + } + for _, r := range slices.Backward(rows[:i]) { + if r.New > 0 { + return r.New + } + } + return 1 } // editOrOpen edits the comment at the cursor. If there is no comment, it -// opens the file in an editor at the cursor line. +// opens the file in an editor at the cursor line. As in herdr-reviewr, it +// does nothing while a selection is active, so the selection stays. func (m *model) editOrOpen() tea.Cmd { + if m.cur().anchor >= 0 { + return nil + } if c := m.commentAtCursor(); c != nil { - return m.editComment(c) + return m.editComment(c, -1) } p := m.cur() if p.path == "" { return nil } - line := 1 - if t := m.target(p.cur, diff.SideNew); t != nil && t.side == diff.SideNew { - line = t.line - } - return m.openEditor(p.path, line) + return m.openEditor(p.path, nearNewLine(p.rows, p.cur)) } diff --git a/tui/commentlist.go b/tui/commentlist.go new file mode 100644 index 0000000..0216f0a --- /dev/null +++ b/tui/commentlist.go @@ -0,0 +1,107 @@ +package tui + +// The comment list shows all the comments. The user can jump to a +// comment, edit it, delete it, or send or copy all the comments. + +import ( + "fmt" + "strings" + + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" +) + +// openCommentList lists the comments, to jump to one, edit one, delete +// one, or send or copy them all. +func (m *model) openCommentList() { + if m.comments.Len() == 0 { + m.flash("no comments yet") + return + } + ov := newOverlay(overlayComments, "") + m.fillCommentList(ov) + m.open(ov) +} + +// fillCommentList sets the comments as the items of ov, and the count in +// the title, as in herdr-reviewr. +func (m *model) fillCommentList(ov *overlay) { + ov.title = fmt.Sprintf("Comments (%d)", m.comments.Len()) + var items []pickItem + for _, c := range m.comments.All() { + tag := "" + if m.stale(c) { + tag = "stale" + } + text, _, _ := strings.Cut(c.Text, "\n") + items = append(items, pickItem{ + label: c.Location(), + detail: text, + tag: tag, + comment: c.ID, + }) + } + ov.setItems(items) +} + +// updateCommentList handles a key in the comment list. The keys are those +// of herdr-reviewr. Enter also jumps to the comment. +func (m *model) updateCommentList(msg tea.KeyPressMsg) tea.Cmd { + ov, k := m.overlay, m.keys + it := ov.selected() + switch { + case key.Matches(msg, k.Comments, k.Cancel): + m.closeOverlay() + case key.Matches(msg, overlayKeys.Choose): + return m.chooseOverlay() + case key.Matches(msg, k.Down): + ov.move(1) + case key.Matches(msg, k.Up): + ov.move(-1) + case key.Matches(msg, k.Send): + return m.startSend() + case key.Matches(msg, k.Copy): + m.copyComments() + case key.Matches(msg, k.Edit) && it != nil: + // As in herdr-reviewr, a comment on another diff takes no edit. + c := m.comments.Find(it.comment) + if c == nil || !m.revCurrent(c) { + return nil + } + row := ov.cur + m.closeOverlay() + m.jumpToComment(c) + m.focusList = false + if !m.showsLine(c) { + m.flash("the line of the comment does not show") + return nil + } + m.ensureVisible() + return m.editComment(c, row) + case key.Matches(msg, k.Delete) && it != nil: + m.comments.Remove(it.comment) + m.flash("comment deleted") + if m.comments.Len() == 0 { + m.closeOverlay() + return nil + } + m.fillCommentList(ov) + } + return nil +} + +// listHint returns the keys of the comment list, as the footer of +// herdr-reviewr shows them. The edit key shows only if the selected +// comment takes an edit. +func (m *model) listHint() string { + k := m.keys + edit := "" + if it := m.overlay.selected(); it != nil { + if c := m.comments.Find(it.comment); c != nil && m.revCurrent(c) { + edit = fmt.Sprintf(" · %s edit", firstKey(k.Edit)) + } + } + return fmt.Sprintf("%s send %d · esc close · %s copy%s · %s delete · enter jump", + firstKey(k.Send), m.comments.Len(), firstKey(k.Copy), edit, + firstKey(k.Delete)) +} diff --git a/tui/commitpick.go b/tui/commitpick.go new file mode 100644 index 0000000..697c4e4 --- /dev/null +++ b/tui/commitpick.go @@ -0,0 +1,123 @@ +package tui + +// The commit picker picks a commit or a range of commits for the commits +// scope. + +import ( + "fmt" + "slices" + + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + + "github.com/rednafi/rexdiff/vcs" +) + +// openCommitPicker lets the user pick a commit or a range of commits for +// the commits scope, as in herdr-reviewr. It lists the first-parent +// commits after the fork point of the base, the newest first. On the base +// itself, or with no fork point, it lists the last commits. The current +// pick has the cursor and the anchor. +func (m *model) openCommitPicker() { + if m.tab == tabPR { + return + } + since, err := m.repo.MergeBaseCommit(m.ctx, m.base) + if head, herr := m.repo.Resolve(m.ctx, "HEAD"); err != nil || herr != nil || since == head { + since = "" + } + commits, err := m.repo.Commits(m.ctx, since) + if err != nil { + m.flash("commit list failed: " + err.Error()) + return + } + title := fmt.Sprintf("commits · last %d", vcs.LastCommits) + if since != "" { + title = fmt.Sprintf("commits · %d over %s", len(commits), m.base) + } + ov := newOverlay(overlayCommits, title) + var items []pickItem + for _, c := range commits { + items = append(items, pickItem{ + label: c.Short + " " + c.Subject, + detail: c.Author + ", " + c.When, + id: c.SHA, + }) + } + ov.setItems(items) + isSHA := func(sha string) func(pickItem) bool { + return func(it pickItem) bool { return it.id == sha } + } + newest := slices.IndexFunc(items, isSHA(m.to)) + oldest := slices.IndexFunc(items, isSHA(m.from)) + if m.to != "" && newest >= 0 && oldest >= 0 { + ov.cur = newest + if oldest > newest { + ov.mark = oldest + } + } + m.open(ov) +} + +// updateCommitPicker handles a key in the commit picker, as herdr-reviewr +// does. The key of Select sets or clears the anchor of a range. Esc clears +// the anchor, else it closes the picker. +func (m *model) updateCommitPicker(msg tea.KeyPressMsg) tea.Cmd { + ov, k := m.overlay, m.keys + switch { + case key.Matches(msg, overlayKeys.Close) && ov.mark >= 0: + ov.mark = -1 + case key.Matches(msg, overlayKeys.Close): + m.closeOverlay() + case key.Matches(msg, overlayKeys.Choose): + return m.chooseOverlay() + case key.Matches(msg, k.Down): + ov.move(1) + case key.Matches(msg, k.Up): + ov.move(-1) + case key.Matches(msg, k.PageDown): + ov.move(pickerPage) + case key.Matches(msg, k.PageUp): + ov.move(-pickerPage) + case key.Matches(msg, k.HalfDown): + ov.move(pickerHalf) + case key.Matches(msg, k.HalfUp): + ov.move(-pickerHalf) + case key.Matches(msg, k.Select) && ov.cur < len(ov.shown): + if ov.mark == ov.shown[ov.cur] { + ov.mark = -1 + } else { + ov.mark = ov.shown[ov.cur] + } + } + return nil +} + +// commitHint returns the keys of the commit picker, as the footer of +// herdr-reviewr shows them. +func (m *model) commitHint() string { + ov, k := m.overlay, m.keys + if len(ov.shown) == 0 { + return "esc cancel" + } + open, esc := "open", "cancel" + if ov.mark >= 0 { + esc = "clear" + if n := max(ov.mark, ov.cur) - min(ov.mark, ov.cur) + 1; n > 1 { + open = fmt.Sprintf("open %d", n) + } + } + return fmt.Sprintf("enter %s · esc %s · %s %s move · %s select", + open, esc, firstKey(k.Down), firstKey(k.Up), firstKey(k.Select)) +} + +// commitRange returns the oldest and the newest commit that the commit +// picker picks with item it. That is one commit, or the range from the +// mark to it. The list shows the newest commit first. +func (o *overlay) commitRange(it *pickItem) (oldest, newest string) { + if o.mark < 0 { + return it.id, it.id + } + a, b := o.mark, o.shown[o.cur] + return o.items[max(a, b)].id, o.items[min(a, b)].id +} diff --git a/tui/config.go b/tui/config.go index 791db45..35379f6 100644 --- a/tui/config.go +++ b/tui/config.go @@ -1,8 +1,9 @@ package tui import ( + "cmp" "encoding/json/jsontext" - json "encoding/json/v2" + "encoding/json/v2" "errors" "fmt" "reflect" @@ -27,7 +28,7 @@ type Scope string const ( ScopeUncommitted Scope = "uncommitted" // HEAD ScopeBranch Scope = "branch" // the base branch - ScopeLastTurn Scope = "last-turn" // the start of the last turn of the agent + ScopeLastTurn Scope = "last-turn" // the start of the last agent turn ) // markdownView tells how the program shows a Markdown file. @@ -38,25 +39,45 @@ const ( markdownViewRendered markdownView = "rendered" ) -// position is a side of the screen. +// position is the place of the file list around the main pane. type position string const ( - positionRight position = "right" - positionLeft position = "left" + positionRight position = "right" + positionBottom position = "bottom" + positionLeft position = "left" + positionTop position = "top" ) +// next returns the position after p, clockwise around the main pane, as +// the key p of herdr-reviewr moves it. +func (p position) next() position { + switch p { + case positionRight: + return positionBottom + case positionBottom: + return positionLeft + case positionLeft: + return positionTop + } + return positionRight +} + +// stacked reports whether the file list is above or below the main pane. +// Then the list splits the rows of the body, not the columns. +func (p position) stacked() bool { return p == positionTop || p == positionBottom } + // The values that the settings accept, in the order of the error messages. var ( layouts = []Layout{LayoutUnified, LayoutSplit} configScopes = []Scope{ScopeUncommitted, ScopeBranch, ScopeLastTurn} markdownViews = []markdownView{markdownViewSource, markdownViewRendered} - positions = []position{positionRight, positionLeft} + positions = []position{positionRight, positionBottom, positionLeft, positionTop} ) -// config holds the settings of rexdiff. The settings are at the top of -// rexdiff.lua, beside the init.lua of Rex. The toggle action gives them to -// rexdiff as JSON in --config. +// config holds the settings of rexdiff. The settings are in rexdiff.lua, +// beside the configuration file (init.lua) of Rex. The toggle action gives +// them to rexdiff as JSON in --config. type config struct { Layout Layout `json:"layout"` DefaultScope Scope `json:"default_scope"` @@ -68,12 +89,16 @@ type config struct { // URLOpener is a command, for example "open {url}". URLOpener string `json:"url_opener"` MarkdownView markdownView `json:"markdown_view"` - // FileList is the side of the screen that shows the list of files. + // FileList is the place of the list of files: right, bottom, left, or + // top. FileList position `json:"file_list"` - // FileListWidth is in columns, minListWidth or more. + // FileListWidth is the width of the file list at a side, in columns. + // It is minListWidth or more, or 0 if the text does not set it. FileListWidth int `json:"file_list_width"` - // FocusAgent tells if the focus moves to the agent after a send. Nil - // means true. + // DiffSigns marks a changed line with + or -, in place of a bar. + DiffSigns bool `json:"diff_signs"` + // FocusAgent is true if the focus moves to the agent after a send. + // Nil means true. FocusAgent *bool `json:"focus_agent_after_send"` // GitHubHost, GitLabHost, and AzureHost are bare hostnames of // self-hosted forges. @@ -139,6 +164,36 @@ type keybindings struct { Keys []string `json:"keys"` Quit []string `json:"quit"` QuitDiscard []string `json:"quit-discard"` + + // The old names of navigator-grow, navigator-shrink, and rendered. + // herdr-reviewr also takes them. + ListWider []string `json:"list-wider"` + ListNarrower []string `json:"list-narrower"` + Preview []string `json:"preview"` +} + +// useOldNames gives the keys of each old name to its action. An action +// and its old name cannot both have keys. +func (kb *keybindings) useOldNames() error { + names := []struct { + name, old string + keys, oldKeys *[]string + }{ + {"navigator-grow", "list-wider", &kb.NavigatorGrow, &kb.ListWider}, + {"navigator-shrink", "list-narrower", &kb.NavigatorShrink, &kb.ListNarrower}, + {"rendered", "preview", &kb.Rendered, &kb.Preview}, + } + for _, n := range names { + switch { + case *n.oldKeys == nil: + case *n.keys != nil: + return fmt.Errorf("invalid value for keybindings: %q and %q name the same action", + n.name, n.old) + default: + *n.keys, *n.oldKeys = *n.oldKeys, nil + } + } + return nil } // minListWidth is the smallest width of the file list, in columns. @@ -148,11 +203,10 @@ const minListWidth = 16 // set. func defaultConfig() config { return config{ - Layout: LayoutUnified, - DefaultScope: ScopeUncommitted, - MarkdownView: markdownViewSource, - FileList: positionRight, - FileListWidth: 32, + Layout: LayoutUnified, + DefaultScope: ScopeUncommitted, + MarkdownView: markdownViewSource, + FileList: positionRight, } } @@ -164,8 +218,8 @@ func parseConfig(text string) (config, error) { if text == "" { return c, nil } - // set tells which keys the text has. A blank text that the user sets - // is an error. A text that the user does not set is not an error. + // set tells which keys the text has. A blank text is an error only if + // the user sets it. var set map[string]jsontext.Value if err := json.Unmarshal([]byte(text), &set); err != nil { return config{}, configError(err) @@ -173,6 +227,9 @@ func parseConfig(text string) (config, error) { if err := json.Unmarshal([]byte(text), &c, json.RejectUnknownMembers(true)); err != nil { return config{}, configError(err) } + if err := c.Keybindings.useOldNames(); err != nil { + return config{}, fmt.Errorf("config: %w", err) + } if err := c.check(set); err != nil { return config{}, fmt.Errorf("config: %w", err) } @@ -186,7 +243,7 @@ func configError(err error) error { if !ok { return fmt.Errorf("config: %w", err) } - key := strings.ReplaceAll(strings.TrimPrefix(string(se.JSONPointer), "/"), "/", ".") + key := strings.Join(slices.Collect(se.JSONPointer.Tokens()), ".") if errors.Is(err, json.ErrUnknownName) { return fmt.Errorf("config: unknown key %q", key) } @@ -222,7 +279,7 @@ var jsonKinds = map[jsontext.Kind]string{ // check returns an error for the first value that rexdiff does not accept. // set has the keys that the text sets. func (c config) check(set map[string]jsontext.Value) error { - if err := c.checkChoices(); err != nil { + if err := c.checkChoices(set); err != nil { return err } if err := c.checkTexts(set); err != nil { @@ -235,23 +292,18 @@ func (c config) check(set map[string]jsontext.Value) error { } // checkChoices checks the values that have fixed choices or a range. They -// are the layout, the scope, the markdown view, and the file list. -func (c config) checkChoices() error { - choices := []struct { - key, value string - allowed []string - }{ - {"layout", string(c.Layout), names(layouts...)}, - {"default_scope", string(c.DefaultScope), names(configScopes...)}, - {"markdown_view", string(c.MarkdownView), names(markdownViews...)}, - {"file_list", string(c.FileList), names(positions...)}, - } - for _, k := range choices { - if !slices.Contains(k.allowed, k.value) { - return invalid(k.key, "one of "+strings.Join(k.allowed, ", "), k.value) - } +// are the layout, the scope, the markdown view, and the file list. set +// has the keys that the text sets. +func (c config) checkChoices(set map[string]jsontext.Value) error { + if err := cmp.Or( + choice("layout", c.Layout, layouts), + choice("default_scope", c.DefaultScope, configScopes), + choice("markdown_view", c.MarkdownView, markdownViews), + choice("file_list", c.FileList, positions), + ); err != nil { + return err } - if c.FileListWidth < minListWidth { + if set["file_list_width"] != nil && c.FileListWidth < minListWidth { want := strconv.Itoa(minListWidth) + " or more" return invalid("file_list_width", want, strconv.Itoa(c.FileListWidth)) } @@ -280,10 +332,16 @@ func (c config) checkTexts(set map[string]jsontext.Value) error { } for _, k := range commands { blank := set[k.key] != nil && strings.TrimSpace(k.value) == "" - if blank || !placeholders(k.value, k.known...) { + if blank || !onlyPlaceholders(k.value, k.known...) { return invalid(k.key, k.want, k.value) } } + // As in herdr-reviewr, the first word of the URL opener is a program, + // and not the URL. + if w := splitWords(c.URLOpener); c.URLOpener != "" && + (len(w) == 0 || w[0] == "" || strings.Contains(w[0], "{url}")) { + return invalid("url_opener", "a command that names a program first", c.URLOpener) + } if set["review_prefix"] != nil && strings.TrimSpace(c.ReviewPrefix) == "" { return invalid("review_prefix", "text", c.ReviewPrefix) } @@ -298,34 +356,65 @@ func (c config) checkHosts(set map[string]jsontext.Value) error { {"azure_devops_host", c.AzureHost}, } for _, k := range hosts { - if set[k.key] != nil && !hostname(k.value) { + if set[k.key] != nil && !isHostname(k.value) { return invalid(k.key, "a bare hostname", k.value) } } return nil } -// checkKeybindings checks the keys of the actions. The decoder rejects an -// unknown action. A list that is set must have keys. +// checkKeybindings checks the keys of the actions, as herdr-reviewr does. +// The decoder rejects an unknown action. A list that is set must have +// keys, and each key must have a valid name. Then no key can have two +// actions, after the setting changes the default keys. func (c config) checkKeybindings() error { - for _, b := range new(keyMap).bindings(c.Keybindings) { - if b.keys != nil && (len(b.keys) == 0 || slices.Contains(b.keys, "")) { - return invalid("keybindings."+b.name, "a list of keys", fmt.Sprint(b.keys)) + k := defaultKeys() + bs := k.bindings(c.Keybindings) + for _, b := range bs { + if b.keys == nil { + continue + } + want := "a list of keys, each a character or a named key (" + keyNames() + + "), alone or after ctrl+ or alt+" + if len(b.keys) == 0 { + return invalid("keybindings."+b.name, want, "[]") + } + for _, s := range b.keys { + if _, ok := keyName(s); !ok { + return invalid("keybindings."+b.name, want, s) + } + } + } + k.applyConfig(c.Keybindings) + first := map[string]string{} // the first action of each key + for _, b := range bs { + for _, key := range b.key.Keys() { + switch a, ok := first[key]; { + case ok && a == b.name: + return fmt.Errorf("invalid value for keybindings: %q is bound twice to %q", + herdrReviewrKeyName(key), b.name) + case ok: + return fmt.Errorf("invalid value for keybindings: %q is bound to both %q and %q", + herdrReviewrKeyName(key), a, b.name) + } + first[key] = b.name } } return nil } +// invalid returns the error for a value of the setting key that rexdiff +// does not accept. want tells the values that rexdiff accepts. func invalid(key, want, value string) error { return fmt.Errorf("invalid value for %s: expected %s, not %q", key, want, value) } -// placeholders reports whether each { in command opens one of the known -// placeholders, for example {file}. -func placeholders(command string, known ...string) bool { - for _, s := range strings.Split(command, "{")[1:] { - name, _, ok := strings.Cut(s, "}") - if !ok || !slices.Contains(known, name) { +// onlyPlaceholders reports whether each { in command opens one of the +// known placeholders, for example {file}. +func onlyPlaceholders(command string, known ...string) bool { + for _, rest, ok := strings.Cut(command, "{"); ok; _, rest, ok = strings.Cut(rest, "{") { + name, _, closed := strings.Cut(rest, "}") + if !closed || !slices.Contains(known, name) { return false } } @@ -338,22 +427,22 @@ const ( maxHostLabel = 63 ) -// hostname reports whether s is a bare DNS name, for example +// isHostname reports whether s is a bare DNS name, for example // "git.example.com", with no scheme, port, or path. -func hostname(s string) bool { +func isHostname(s string) bool { if len(s) > maxHostname { return false } for label := range strings.SplitSeq(s, ".") { - if !hostLabel(label) { + if !isHostLabel(label) { return false } } return true } -// hostLabel reports whether s is one valid label of a DNS name. -func hostLabel(s string) bool { +// isHostLabel reports whether s is one valid label of a DNS name. +func isHostLabel(s string) bool { if s == "" || len(s) > maxHostLabel || s[0] == '-' || s[len(s)-1] == '-' { return false } @@ -363,13 +452,18 @@ func hostLabel(s string) bool { }) } -// focusAgent tells if the focus moves to the agent after a send. -func (c config) focusAgent() bool { return c.FocusAgent == nil || *c.FocusAgent } +// focusesAgent reports whether the focus moves to the agent after a send. +func (c config) focusesAgent() bool { return c.FocusAgent == nil || *c.FocusAgent } -func names[T ~string](values ...T) []string { - s := make([]string, len(values)) - for i, v := range values { - s[i] = string(v) +// choice returns an error if value is not one of allowed. key is the name +// of the value in the error. +func choice[T ~string](key string, value T, allowed []T) error { + if slices.Contains(allowed, value) { + return nil + } + want := make([]string, len(allowed)) + for i, a := range allowed { + want[i] = string(a) } - return s + return invalid(key, "one of "+strings.Join(want, ", "), string(value)) } diff --git a/tui/editor.go b/tui/editor.go index b6fefa4..6dbd3eb 100644 --- a/tui/editor.go +++ b/tui/editor.go @@ -2,202 +2,230 @@ package tui import ( "bytes" - "cmp" + "context" + "errors" "fmt" "os" "os/exec" "path/filepath" "runtime" - "strconv" "strings" "syscall" + "unicode" tea "charm.land/bubbletea/v2" -) - -// lineArg is how an editor takes the line to open at. -type lineArg int -const ( - argNone lineArg = iota // file - argPlus // +42 file - argSuffix // file:42 - argGoto // -g file:42 - argFlag // --line 42 file + "github.com/rednafi/rexdiff/internal/editor" + "github.com/rednafi/rexdiff/internal/proc" ) -// dialect tells how an editor takes the line, and if it opens a window. -// An editor without a window draws in the terminal, so it needs a pane. -type dialect struct { - line lineArg - window bool -} - -// dialects has the editors that rexdiff knows, by program name. The table -// is the one of herdr-reviewr, with fresh added. -var dialects = map[string]dialect{} - -func init() { - add := func(d dialect, names ...string) { - for _, n := range names { - dialects[n] = d - } +// openEditor opens the file at path, at line. It uses the editor of the +// settings, else $VISUAL, $EDITOR, or core.editor. The variables come from +// m.env. The editor runs with m.env. A terminal editor opens in a new +// block beside rexdiff in Rex. Outside Rex, a terminal editor takes the +// screen until the editor stops. A window editor starts in the background. +// The messages are the same as in herdr-reviewr. +func (m *model) openEditor(path string, line int) tea.Cmd { + abs := filepath.Join(m.repo.Root(), path) + src := editor.Sources{ + Template: m.cfg.Editor, + Visual: proc.Getenv(m.env, "VISUAL"), + Editor: proc.Getenv(m.env, "EDITOR"), } - add(dialect{argPlus, false}, "vi", "vim", "nvim", "lvim", "vis", "joe", "nano", "micro", "kak") - // Emacs can open a window or not. In a pane, it works in both cases. - add(dialect{argPlus, false}, "emacs", "emacsclient") - add(dialect{argSuffix, false}, "hx", "helix", "fresh") - // MacVim and gVim open a window, unlike the other vi editors. - add(dialect{argPlus, true}, "mvim", "gvim", "bbedit", "gedit") - add(dialect{argGoto, true}, - "code", "code-insiders", "codium", "vscodium", "cursor", "windsurf", "positron") - add(dialect{argSuffix, true}, "subl", "sublime_text", "zed", "zeditor", "zedit") - add(dialect{argFlag, true}, "mate", "kate", "idea", "pycharm", "webstorm", "goland", - "clion", "phpstorm", "rubymine", "rider", "datagrip", "rustrover", "dataspell", "fleet") - if runtime.GOOS == "darwin" { - add(dialect{argFlag, true}, "xed") // the opener of Xcode + if src.Template == "" { + src.Git = m.repo.Editor(m.ctx) + } + ed, err := editor.Resolve(src, abs, line) + if err != nil { + m.flash("editor failed: " + err.Error()) + return nil + } + // The editor makes the file again when it saves, so do not open a + // file that is not on disk. + if fi, err := os.Stat(abs); err != nil || !fi.Mode().IsRegular() { + m.flash(path + " is gone") + return nil + } + if _, err := proc.LookPath(m.env, ed.Program); err != nil { + m.flash(proc.NotInstalled(ed.Program).Error()) + return nil + } + m.log.Debug("editor", "command", ed.Script, "window", ed.Window) + if ed.Window { + return m.runWindowEditor(path, ed.Script) + } + if m.rex == nil { + return m.runHere(path, ed.Script) + } + c, ctx, root := m.rex, m.ctx, m.repo.Root() + return func() tea.Msg { + ctx, cancel := context.WithCancelCause(ctx) + ctx, cancelTimeout := context.WithTimeoutCause(ctx, rexTimeout, + fmt.Errorf("editor split: %v timeout: %w", rexTimeout, context.DeadlineExceeded)) + defer cancelTimeout() + defer cancel(fmt.Errorf("editor split: done: %w", context.Canceled)) + err := c.Split(ctx, root, "sh", "-c", ed.Script) + return splitMsg{path: path, script: ed.Script, err: err} } } -// editorCommand is a command that opens a file at a line. -type editorCommand struct { - script string // the command, for sh -c - terminal bool // the editor draws in the terminal -} - -// resolveEditor returns the command that opens the file abs at line. It -// uses template, the editor of the settings, else value, the editor of -// the environment or of git. A template gets abs in place of {file} and -// line in place of {line}. For value, rexdiff adds abs and line as the -// editor expects them. -func resolveEditor(template, value, abs string, line int) editorCommand { - if template != "" { - r := strings.NewReplacer("{file}", shellQuote(abs), "{line}", strconv.Itoa(line)) - script := r.Replace(template) - if !strings.Contains(template, "{file}") { - script += " " + shellQuote(abs) - } - return editorCommand{script, wantsTerminal(template)} - } - n, file := strconv.Itoa(line), shellQuote(abs) - var args string - switch dialects[program(value)].line { - case argNone: - args = file - case argPlus: - args = "+" + n + " " + file - case argSuffix: - args = shellQuote(abs + ":" + n) - case argGoto: - args = "-g " + shellQuote(abs+":"+n) - case argFlag: - args = "--line " + n + " " + file - } - return editorCommand{value + " " + args, wantsTerminal(value)} +// onSplit tells that the editor opens in a new pane. If Rex cannot open +// the pane, the editor runs in this pane. +func (m *model) onSplit(msg splitMsg) tea.Cmd { + if msg.err == nil { + m.flash("opened " + msg.path) + return nil + } + m.log.Debug("editor split failed", "err", msg.err) + return m.runHere(msg.path, msg.script) } -// program returns the name of the program of the command, without its -// directory. -func program(command string) string { - words := strings.Fields(command) - if len(words) == 0 { - return "" - } - return filepath.Base(words[0]) +// runHere runs the editor script in this pane, in place of rexdiff. +func (m *model) runHere(path, script string) tea.Cmd { + return tea.ExecProcess(command(m.env, "sh", "-c", script), func(err error) tea.Msg { + return editorDoneMsg{path: path, err: err} + }) } -// wantsTerminal reports whether the command draws in the terminal. A wait -// flag tells that the editor has a window. An editor that rexdiff does not -// know draws in the terminal, because in a pane a window editor also works. -func wantsTerminal(command string) bool { - for _, w := range strings.Fields(command) { - if w == "--wait" || w == "--block" { - return false - } +// runWindowEditor starts a window editor for path. The editor gets its +// own session and no terminal, so that it cannot read the keys of rexdiff. +// If the editor fails, the returned command gives the error and the last +// line of the stderr of the editor. +func (m *model) runWindowEditor(path, script string) tea.Cmd { + wait, err := detach(m.env, "editor", "sh", "-c", script) + if err != nil { + m.flash("editor failed: " + err.Error()) + return nil } - d, ok := dialects[program(command)] - return !ok || !d.window + m.flash("opened " + path) + return wait } -// openEditor opens the file at path, at line. It uses the editor of the -// settings, else $VISUAL, $EDITOR, core.editor, or vi. A terminal editor -// opens in a new block beside rexdiff in Rex. Outside Rex, it takes the -// screen until it stops. A window editor starts in the background. -func (m *model) openEditor(path string, line int) tea.Cmd { - abs := filepath.Join(m.repo.Root(), path) - value := "" - if m.cfg.Editor == "" { - value = cmp.Or( - strings.TrimSpace(os.Getenv("VISUAL")), - strings.TrimSpace(os.Getenv("EDITOR")), - strings.TrimSpace(m.repo.Editor(m.ctx)), - "vi", - ) - } - ed := resolveEditor(m.cfg.Editor, value, abs, line) - m.log.Debug("editor", "command", ed.script, "terminal", ed.terminal) - if !ed.terminal { - return runWindowEditor(ed.script) - } - if m.rex != nil { - err := m.rex.Split(m.ctx, m.repo.Root(), "sh", "-c", ed.script) - if err == nil { - return nil - } - m.log.Debug("editor split", "err", err) - } - done := func(err error) tea.Msg { return editorDoneMsg{err} } - return tea.ExecProcess(exec.Command("sh", "-c", ed.script), done) -} - -// runWindowEditor starts a window editor. The editor gets its own session -// and no terminal, so that it cannot read the keys of rexdiff. The command -// reports an error if the editor fails, with the last line of its stderr. -func runWindowEditor(script string) tea.Cmd { - cmd := exec.Command("sh", "-c", script) +// detach starts a program with the environment env in its own session. +// The program has no terminal, so it cannot read the keys of rexdiff. The +// returned command waits for the program. If the program fails, the +// command gives a detachedMsg with the last line of the stderr of the +// program. +func detach(env []string, what, name string, args ...string) (tea.Cmd, error) { + cmd := command(env, name, args...) var stderr bytes.Buffer cmd.Stderr = &stderr cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} if err := cmd.Start(); err != nil { - return func() tea.Msg { return editorDoneMsg{err} } + return nil, err } return func() tea.Msg { err := cmd.Wait() - if err == nil { + if err == nil || errors.Is(err, exec.ErrWaitDelay) { return nil } - lines := strings.Split(strings.TrimSpace(stderr.String()), "\n") - if last := lines[len(lines)-1]; last != "" { - err = fmt.Errorf("%w: %s", err, last) + if last := proc.LastLine(stderr.String()); last != "" { + err = fmt.Errorf("%s: %w", last, err) } - return editorDoneMsg{err} + return detachedMsg{what: what, err: err} + }, nil +} + +// onEditorDone shows how the editor in this pane stops. An editor that +// stops with an error code can still save the file. +func (m *model) onEditorDone(msg editorDoneMsg) { + exit, isExit := errors.AsType[*exec.ExitError](msg.err) + switch { + case msg.err == nil: + m.flash("edited " + msg.path) + case isExit: + m.flash("editor stopped: " + exit.Error()) + default: + m.flash("editor failed: " + msg.err.Error()) } + m.rerender() } // openURL opens url with the URL opener of the settings, else with -// the opener of the system. -func openURL(cfg config, url string) error { - if cfg.URLOpener != "" { - script := strings.ReplaceAll(cfg.URLOpener, "{url}", shellQuote(url)) - return start("sh", "-c", script) +// the opener of the system. As in herdr-reviewr, it opens only an http or +// an https URL. The opener runs with the environment env, and with no +// shell. A missing opener gives the error of [proc.NotInstalled]. The +// command waits for the opener. +func openURL(env []string, cfg config, url string) (tea.Cmd, error) { + url = strings.TrimSpace(url) + if !isWebURL(url) { + return nil, errors.New("unsupported link scheme") } - opener := "xdg-open" + name, args := "xdg-open", []string{url} if runtime.GOOS == "darwin" { - opener = "open" + name = "open" + } + if cfg.URLOpener != "" { + w := openerArgs(cfg.URLOpener, url) + name, args = w[0], w[1:] + } + if _, err := proc.LookPath(env, name); err != nil { + return nil, proc.NotInstalled(name) } - return start(opener, url) + return detach(env, "open", name, args...) } -// start starts a program and does not wait for it to stop. A goroutine -// waits for it, so that it does not stay a zombie process. -func start(name string, args ...string) error { - cmd := exec.Command(name, args...) - if err := cmd.Start(); err != nil { - return err +// isWebURL reports whether url is http or https with text after the +// scheme, as herdr-reviewr checks it. A control character or a bidi +// mark can hide a part of the URL, so url must have none. +func isWebURL(url string) bool { + lower := strings.ToLower(url) + web := len(url) > len("http://") && strings.HasPrefix(lower, "http://") || + len(url) > len("https://") && strings.HasPrefix(lower, "https://") + return web && !strings.ContainsFunc(url, func(r rune) bool { + return unicode.IsControl(r) || unicode.Is(unicode.Bidi_Control, r) + }) +} + +// openerArgs returns the program and the arguments of the URL opener +// command, as herdr-reviewr splits it. The words split at spaces, and a +// quoted text is one word. Each {url} becomes url. Without {url}, url is +// the last argument. The check of the settings makes sure that the first +// word is a program. +func openerArgs(command, url string) []string { + var args []string + for _, w := range splitWords(command) { + args = append(args, strings.ReplaceAll(w, "{url}", url)) + } + if !strings.Contains(command, "{url}") { + args = append(args, url) } - go cmd.Wait() - return nil + return args } -// shellQuote quotes s as one word for sh. -func shellQuote(s string) string { return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" } +// splitWords splits command into words at spaces, as herdr-reviewr does. +// Single or double quotes keep the spaces in a word. The quotes do not +// stay in the word. +func splitWords(command string) []string { + var words []string + var quote rune + inWord := false + for _, r := range command { + switch { + case quote != 0 && r == quote: + quote = 0 + case quote != 0: + words[len(words)-1] += string(r) + case unicode.IsSpace(r): + inWord = false + default: + if !inWord { + words, inWord = append(words, ""), true + } + if r == '"' || r == '\'' { + quote = r + } else { + words[len(words)-1] += string(r) + } + } + } + return words +} + +// command returns the command for an editor or a URL opener, with the +// environment env. The context of the command has no end, because the +// program must not stop when rexdiff stops. For example, a kill of the +// editor can lose the edits of the user. +func command(env []string, name string, args ...string) *exec.Cmd { + return proc.Command(context.Background(), env, name, args...) +} diff --git a/tui/editor_test.go b/tui/editor_test.go deleted file mode 100644 index ee164c5..0000000 --- a/tui/editor_test.go +++ /dev/null @@ -1,179 +0,0 @@ -package tui - -import ( - "os" - "path/filepath" - "strings" - "testing" - "time" - - tea "charm.land/bubbletea/v2" - - "github.com/rednafi/rexdiff/rex" -) - -// TestResolveEditor checks the command and the kind of each editor. -func TestResolveEditor(t *testing.T) { - const abs = "/r/a b.go" - tests := []struct { - template, value string - script string - terminal bool - }{ - // The editor of the settings gets the placeholders. - {"/opt/homebrew/bin/nvim +{line} {file}", "", `/opt/homebrew/bin/nvim +12 '/r/a b.go'`, true}, - {"code -g {file}:{line}", "", `code -g '/r/a b.go':12`, false}, - {"zed {file}:{line}", "", `zed '/r/a b.go':12`, false}, - {"myeditor --at {line}", "", `myeditor --at 12 '/r/a b.go'`, true}, - // The editor of the environment gets the line as it expects it. - {"", "nvim", `nvim +12 '/r/a b.go'`, true}, - {"", "/usr/bin/vim -u NONE", `/usr/bin/vim -u NONE +12 '/r/a b.go'`, true}, - {"", "micro", `micro +12 '/r/a b.go'`, true}, - {"", "hx", `hx '/r/a b.go:12'`, true}, - {"", "fresh", `fresh '/r/a b.go:12'`, true}, - {"", "code --wait", `code --wait -g '/r/a b.go:12'`, false}, - {"", "subl", `subl '/r/a b.go:12'`, false}, - {"", "idea", `idea --line 12 '/r/a b.go'`, false}, - {"", "mvim", `mvim +12 '/r/a b.go'`, false}, - // An unknown editor gets only the file, in the terminal. - {"", "myeditor", `myeditor '/r/a b.go'`, true}, - // A wait flag tells that an unknown editor has a window. - {"", "myeditor --wait", `myeditor --wait '/r/a b.go'`, false}, - } - for _, tt := range tests { - got := resolveEditor(tt.template, tt.value, abs, 12) - if got.script != tt.script || got.terminal != tt.terminal { - t.Errorf("resolveEditor(%q, %q) = %q, terminal %v; want %q, terminal %v", - tt.template, tt.value, got.script, got.terminal, tt.script, tt.terminal) - } - } -} - -// fakeRex makes m use a rex in dir that writes its arguments to rex.log -// in dir. It returns the path of rex.log. -func fakeRex(t *testing.T, m *model, dir string) string { - t.Helper() - log := filepath.Join(dir, "rex.log") - script := "#!/bin/sh\nprintf '%s\\n' \"$*\" >> '" + log + "'\n" - if err := os.WriteFile(filepath.Join(dir, "rex"), []byte(script), 0o755); err != nil { - t.Fatal(err) - } - t.Setenv("REX_SESSION", "S") - t.Setenv("REX_BLOCK", "B") - t.Setenv("REX_BIN_DIR", dir) - m.rex = rex.FromEnv() - return log -} - -// pressE moves the cursor to the inserted line 4 of a.go and presses e. It -// returns the command of the key. -func pressE(t *testing.T, m model) (model, tea.Cmd) { - t.Helper() - m = press(t, m, "j", "j", "j", "j", "j") - next, cmd := m.Update(tea.KeyPressMsg{Code: 'e', Text: "e"}) - return *next.(*model), cmd -} - -// TestTerminalEditorInRex checks that a terminal editor opens in a new Rex -// block, at the line. A terminal editor in the background does not show, -// and it takes keys from rexdiff. -func TestTerminalEditorInRex(t *testing.T) { - tests := []struct { - name, editor, env, want string - }{ - {"settings", "/opt/homebrew/bin/nvim +{line} {file}", "", "sh -c /opt/homebrew/bin/nvim +4 '%s'"}, - {"environment", "", "nvim", "sh -c nvim +4 '%s'"}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - m, _ := newTestModel(t) - m.cfg.Editor = tt.editor - t.Setenv("VISUAL", "") - t.Setenv("EDITOR", tt.env) - log := fakeRex(t, &m, t.TempDir()) - if _, cmd := pressE(t, m); cmd != nil { - t.Errorf("e returned a command %T, want none", cmd) - } - b, err := os.ReadFile(log) - if err != nil { - t.Fatal(err) - } - abs := filepath.Join(m.repo.Root(), "a.go") - want := "split -s S -b B --cwd " + m.repo.Root() + " -- " + - strings.Replace(tt.want, "%s", abs, 1) - if got := strings.TrimSpace(string(b)); got != want { - t.Errorf("rex got\n%s\nwant\n%s", got, want) - } - }) - } -} - -// TestWindowEditor checks that a window editor gets the file and the line, -// and runs without a terminal. So it cannot take the keys of rexdiff. -func TestWindowEditor(t *testing.T) { - m, _ := newTestModel(t) - bin := t.TempDir() - out := filepath.Join(bin, "out") - script := "#!/bin/sh\n" + - "{ echo \"$*\"; if (exec 3/dev/null; then echo tty; else echo no tty; fi; } > '" + out + "'\n" - if err := os.WriteFile(filepath.Join(bin, "code"), []byte(script), 0o755); err != nil { - t.Fatal(err) - } - m.cfg.Editor = filepath.Join(bin, "code") + " -g {file}:{line}" - log := fakeRex(t, &m, t.TempDir()) - m, cmd := pressE(t, m) - if cmd == nil { - t.Fatal("e returned no command") - } - if msg := cmd(); msg != nil { - t.Fatalf("the editor failed: %v", msg) - } - b, err := os.ReadFile(out) - if err != nil { - t.Fatal(err) - } - abs := filepath.Join(m.repo.Root(), "a.go") - if want := "-g " + abs + ":4\nno tty\n"; string(b) != want { - t.Errorf("the editor got\n%q\nwant\n%q", b, want) - } - if _, err := os.Stat(log); err == nil { - t.Error("a window editor opened a Rex block") - } -} - -// TestEditorError checks that the footer tells why an editor failed. -func TestEditorError(t *testing.T) { - bin := t.TempDir() - script := "#!/bin/sh\necho 'code: no such file' >&2\nexit 3\n" - if err := os.WriteFile(filepath.Join(bin, "code"), []byte(script), 0o755); err != nil { - t.Fatal(err) - } - tests := []struct { - name, editor, path, want string - }{ - {"fails", filepath.Join(bin, "code") + " -g {file}", os.Getenv("PATH"), "editor: exit status 3: code: no such file"}, - {"cannot start", "code -g {file}", "", "editor: "}, // sh is not in PATH - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - m, _ := newTestModel(t) - m.cfg.Editor = tt.editor - t.Setenv("PATH", tt.path) - m, cmd := pressE(t, m) - if cmd == nil { - t.Fatal("e returned no command") - } - done := make(chan tea.Msg) - go func() { done <- cmd() }() - select { - case msg := <-done: - m = step(t, m, msg) - case <-time.After(5 * time.Second): - t.Fatal("the command did not return") - } - if !strings.Contains(view(m), tt.want) { - t.Errorf("the footer does not show %q:\n%s", tt.want, view(m)) - } - }) - } -} diff --git a/tui/find.go b/tui/find.go new file mode 100644 index 0000000..0228f88 --- /dev/null +++ b/tui/find.go @@ -0,0 +1,243 @@ +package tui + +import ( + "fmt" + "slices" + "strconv" + "strings" + + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/x/ansi" + + "github.com/rednafi/rexdiff/diff" + "github.com/rednafi/rexdiff/internal/search" +) + +// This file has the find band of herdr-reviewr. The band opens at the +// bottom of the screen and stays open. Enter or down goes to the next hit, +// up goes to the previous hit, and esc closes the band. + +// findState is the query of the find band. The band is open in modeFind. +type findState struct { + input textinput.Model +} + +// findHit is a line that matches the query. row is its first row in the +// pane. A line in a fold that hides it has the row of the fold, and its +// new line in line. Else line is 0. +type findHit struct { + row, line int +} + +// findable reports whether the find band can open: on a file tab, with +// a row that shows text. +func (m *model) findable() bool { + if m.tab == tabPR { + return false + } + p := m.cur() + for i := range p.rows { + if p.hasContent(i) { + return true + } + } + return false +} + +// hasContent reports whether row i of p shows text that a find can match. +// A note has no such text. The text of a row of rendered markdown is the +// rendered line. +func (p *pane) hasContent(i int) bool { + r := p.rows[i] + return r.Old > 0 || r.New > 0 || r.Fold.Lines > 0 || (p.path == "" && i < len(p.lines)) +} + +// openFind opens the find band with an empty query. +func (m *model) openFind() tea.Cmd { + if !m.findable() { + return nil + } + ti := textinput.New() + ti.Prompt = "find " + ti.Placeholder = "Find in file…" + m.find = findState{input: ti} + m.mode, m.focusList = modeFind, false + m.cur().anchor = -1 + return m.find.input.Focus() +} + +// closeFind closes the find band and drops the query. The cursor stays. +func (m *model) closeFind() { + if m.mode == modeFind { + m.mode = modeNormal + } + m.find = findState{} +} + +// updateFind handles a key while the find band is open. As in +// herdr-reviewr, the other keys edit the query. +func (m *model) updateFind(msg tea.KeyPressMsg) tea.Cmd { + switch msg.Code { + case tea.KeyEscape: + m.closeFind() + return nil + case tea.KeyEnter, tea.KeyDown: + m.stepFind(1) + return nil + case tea.KeyUp: + m.stepFind(-1) + return nil + } + var cmd tea.Cmd + m.find.input, cmd = m.find.input.Update(msg) + return cmd +} + +// paneSource returns the lines of the two sides of the file in the +// current pane. +func (m *model) paneSource() (before, after []string) { + switch m.tab { + case tabChanges: + if f := m.selectedChange(); f != nil { + return m.findLines.of(f.Old, f.New) + } + case tabFiles: + return m.findLines.of("", m.viewed) + } + return nil, nil +} + +// splitLines keeps the lines of the last two sides, so a find does not +// split the file again on each frame. +type splitLines struct { + old, new string + oldLines, newLines []string +} + +// of returns the lines of before and after. +func (s *splitLines) of(before, after string) ([]string, []string) { + if before != s.old || after != s.new { + *s = splitLines{before, after, diff.Lines(before), diff.Lines(after)} + } + return s.oldLines, s.newLines +} + +// findHits returns the lines of the pane that match q, in order. It +// matches the source text of a line, and not the line numbers. A line on +// both sides, as in the split layout, is one hit. +func (m *model) findHits(q string) []findHit { + if q == "" { + return nil + } + p := m.cur() + before, after := m.paneSource() + line := func(ls []string, n int) string { + if n >= 1 && n <= len(ls) { + return ls[n-1] + } + return "" + } + var hits []findHit + for i, r := range p.rows { + switch { + case p.src != nil: + // A hit in rendered markdown is on its block, as the cursor is. + if !r.Continued && slices.ContainsFunc(p.lines[i:p.end(i)+1], func(l string) bool { + return search.Match(ansi.Strip(l), q) + }) { + hits = append(hits, findHit{i, 0}) + } + case r.Continued: + case r.Fold.Lines > 0: + for n := r.Fold.New; n < r.Fold.New+r.Fold.Lines; n++ { + if search.Match(line(after, n), q) { + hits = append(hits, findHit{i, n}) + } + } + case r.Old > 0 || r.New > 0: + if (r.New > 0 && search.Match(line(after, r.New), q)) || + (r.Old > 0 && search.Match(line(before, r.Old), q)) { + hits = append(hits, findHit{i, 0}) + } + case p.hasContent(i): + if search.Match(ansi.Strip(p.lines[i]), q) { + hits = append(hits, findHit{i, 0}) + } + } + } + return hits +} + +// findPlace returns the number of hits before the cursor, and if the +// cursor is on a hit. +func (m *model) findPlace(hits []findHit) (before int, on bool) { + cur := m.cur().head(m.cur().cur) + for _, h := range hits { + switch { + case h.row < cur: + before++ + case h.row == cur && h.line == 0: + return before, true + } + } + return before, false +} + +// stepFind moves the cursor to the next (d = 1) or the previous (d = -1) +// hit. It wraps around. Forward skips the hit at the cursor. A hit in a +// fold opens the fold. +func (m *model) stepFind(d int) { + hits := m.findHits(m.find.input.Value()) + if len(hits) == 0 { + return + } + before, on := m.findPlace(hits) + n := len(hits) + i := (before + n - 1) % n + if d > 0 { + i = before % n + if on { + i = (before + 1) % n + } + } + h, p := hits[i], m.cur() + if h.line == 0 { + p.cur = h.row + return + } + m.expandFold(p.rows[h.row].Fold) + cursorToLine(p, diff.SideNew, h.line) +} + +// shownHits returns the hits of the query in the find band, or nil if +// the band is closed. +func (m *model) shownHits() []findHit { + if m.mode != modeFind { + return nil + } + return m.findHits(m.find.input.Value()) +} + +// findBand renders the find band: the query, and the count at the right. +// The count is "i/n" on a hit, n off a hit, or "no matches". hits are the +// hits of the query. +func (m *model) findBand(hits []findHit) string { + count := "" + if m.find.input.Value() != "" { + before, on := m.findPlace(hits) + switch { + case len(hits) == 0: + count = "no matches" + case on: + count = fmt.Sprintf("%d/%d", before+1, len(hits)) + default: + count = strconv.Itoa(len(hits)) + } + } + w := max(m.w-ansi.StringWidth(count)-1, 1) + m.find.input.SetWidth(max(w-ansi.StringWidth(m.find.input.Prompt)-1, 1)) + band := truncate(m.find.input.View(), w) + pad := strings.Repeat(" ", max(m.w-ansi.StringWidth(band)-ansi.StringWidth(count), 0)) + return band + pad + dimStyle.Render(count) +} diff --git a/tui/keys.go b/tui/keys.go index e582716..03fde7d 100644 --- a/tui/keys.go +++ b/tui/keys.go @@ -1,40 +1,43 @@ package tui import ( + "slices" + "strings" + "unicode" + "unicode/utf8" + "charm.land/bubbles/v2/help" "charm.land/bubbles/v2/key" - "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" ) -// keyMap uses the action names and the default keys of herdr-reviewr. The -// keybindings setting uses the same names. The settings do not change the -// keys of the comment box and of the find. +// keyMap uses the action names and the default keys of herdr-reviewr. +// The fields have the names of the actions in the keybindings setting. +// Focus and Update are actions of rexdiff only. The settings do not change +// the keys of the comment box and of the find. type keyMap struct { // Move. - Down, Up, PageDown, PageUp, HalfDown, HalfUp, Top, Bottom key.Binding - NextHunk, PrevHunk, NextFile, PrevFile, Focus, Left, Right key.Binding + Down, Up, PageDown, PageUp, HalfDown, HalfUp, Collapse, Expand key.Binding + NextHunk, PrevHunk, NextFile, PrevFile, Focus key.Binding // Scopes and tabs. - ScopeUncommitted, ScopeBranch, ScopeTurn, ScopeCommits key.Binding - BasePick, CommitPick, TabChanges, TabFiles, TabPR key.Binding + ScopeUncommitted, ScopeBranch, ScopeLastTurn, ScopeCommits key.Binding + BasePick, CommitPick, TabChanges, TabAllFiles, TabPR key.Binding // Comments. Select, Comment, Edit, Delete, NextComment, PrevComment key.Binding Comments, Send, Copy key.Binding // View and other actions. - Search, Find, GotoLine, Rendered, Split, Wrap key.Binding - NavPosition, NavHide, NavGrow, NavShrink key.Binding - OpenPR, Refresh, Keys, Quit, QuitDiscard key.Binding - // Upgrade installs a newer release. It works only while the footer + Search, Find, GotoLine, Rendered, Split, Wrap key.Binding + NavigatorPosition, NavigatorHide, NavigatorGrow, NavigatorShrink key.Binding + OpenPR, Refresh, Keys, Quit, QuitDiscard key.Binding + // Update installs a newer release. It works only while the footer // tells about a newer release. - Upgrade key.Binding + Update key.Binding // The comment box. Cancel also clears the selection. Save, Newline, Cancel key.Binding - - // FindNext and FindPrev move to the next and the previous hit of a find. - FindNext, FindPrev key.Binding } // bind returns a binding of keys, with helpKey and desc in the help. @@ -49,6 +52,9 @@ func hint(b key.Binding, helpKey, desc string) key.Binding { return b } +// defaultKeys returns the keys of herdr-reviewr, with the help text of +// each action. Some actions share one help label, for example "j/k move". +// Then the label is on the first action of the pair. func defaultKeys() keyMap { return keyMap{ Down: bind([]string{"j", "down"}, "j/k", "move"), @@ -57,29 +63,27 @@ func defaultKeys() keyMap { PageUp: bind([]string{"pgup"}, "pgup", "page up"), HalfDown: bind([]string{"ctrl+d"}, "ctrl+d/u", "half page"), HalfUp: bind([]string{"ctrl+u"}, "ctrl+u", "half page up"), - Top: bind([]string{"home"}, "home", "top"), - Bottom: bind([]string{"end"}, "end", "bottom"), NextHunk: bind([]string{"]"}, "]/[", "next/prev change"), PrevHunk: bind([]string{"["}, "[", "prev change"), NextFile: bind([]string{"f"}, "f/F", "next/prev file"), PrevFile: bind([]string{"F"}, "F", "prev file"), Focus: bind([]string{"tab"}, "tab", "switch pane"), - Left: bind([]string{"left"}, "←/→", "scroll sideways"), - Right: bind([]string{"right"}, "→", "scroll right"), + Collapse: bind([]string{"left"}, "←/→", "scroll sideways"), + Expand: bind([]string{"right"}, "→", "scroll right"), ScopeUncommitted: bind([]string{"u"}, "u", "uncommitted"), ScopeBranch: bind([]string{"b"}, "b", "branch"), - ScopeTurn: bind([]string{"t"}, "t", "last turn"), + ScopeLastTurn: bind([]string{"t"}, "t", "last turn"), ScopeCommits: bind([]string{"g"}, "g", "commits"), BasePick: bind([]string{"B"}, "B", "pick base branch"), CommitPick: bind([]string{"G"}, "G", "pick commits"), TabChanges: bind([]string{"1"}, "1/2/3", "changes/files/PR"), - TabFiles: bind([]string{"2"}, "2", "all files"), + TabAllFiles: bind([]string{"2"}, "2", "all files"), TabPR: bind([]string{"3"}, "3", "PR"), Select: bind([]string{"v"}, "v", "select lines"), Comment: bind([]string{"c"}, "c", "comment"), - Edit: bind([]string{"e"}, "e", "edit comment / open editor"), + Edit: bind([]string{"e"}, "e", "edit comment/open editor"), Delete: bind([]string{"d"}, "d", "delete comment"), NextComment: bind([]string{"n"}, "n/N", "next/prev comment"), PrevComment: bind([]string{"N"}, "N", "prev comment"), @@ -87,22 +91,22 @@ func defaultKeys() keyMap { Send: bind([]string{"s", "S"}, "s", "send to agent"), Copy: bind([]string{"y", "Y"}, "y", "copy comments"), - Search: bind([]string{"/"}, "/", "search files & code"), - Find: bind([]string{"ctrl+f"}, "ctrl+f", "find in file"), - GotoLine: bind([]string{":"}, ":", "go to line"), - Rendered: bind([]string{"m"}, "m", "rendered markdown"), - Split: bind([]string{"|"}, "|", "side by side"), - Wrap: bind([]string{"w"}, "w", "wrap lines"), - NavPosition: bind([]string{"p"}, "p", "move file list"), - NavHide: bind([]string{"z"}, "z", "hide file list"), - NavGrow: bind([]string{"<"}, "", "widen/narrow list"), - NavShrink: bind([]string{">"}, ">", "narrow list"), - OpenPR: bind([]string{"o"}, "o", "open PR in browser"), - Refresh: bind([]string{"r"}, "r", "refresh"), - Upgrade: bind([]string{"U"}, "U", "update rexdiff"), - Keys: bind([]string{"?"}, "?", "all keys"), - Quit: bind([]string{"q", "ctrl+c"}, "q", "quit"), - QuitDiscard: bind([]string{"Q"}, "Q", "quit, drop comments"), + Search: bind([]string{"/"}, "/", "search files and code"), + Find: bind([]string{"ctrl+f"}, "ctrl+f", "find in file"), + GotoLine: bind([]string{":"}, ":", "go to line"), + Rendered: bind([]string{"m"}, "m", "rendered markdown"), + Split: bind([]string{"|"}, "|", "side by side"), + Wrap: bind([]string{"w"}, "w", "wrap lines"), + NavigatorPosition: bind([]string{"p"}, "p", "move list"), + NavigatorHide: bind([]string{"z"}, "z", "hide list"), + NavigatorGrow: bind([]string{"<"}, "", "resize list"), + NavigatorShrink: bind([]string{">"}, ">", "narrow list"), + OpenPR: bind([]string{"o"}, "o", "open PR in browser"), + Refresh: bind([]string{"r"}, "r", "refresh"), + Update: bind([]string{"U"}, "U", "update rexdiff"), + Keys: bind([]string{"?"}, "?", "all keys"), + Quit: bind([]string{"q"}, "q", "quit"), + QuitDiscard: bind([]string{"Q"}, "Q", "quit, drop comments"), Save: bind([]string{"enter"}, "enter", "save"), Newline: bind( @@ -111,38 +115,34 @@ func defaultKeys() keyMap { "new line", ), Cancel: bind([]string{"esc"}, "esc", "cancel"), - - FindNext: key.NewBinding(key.WithKeys("n")), - FindPrev: key.NewBinding(key.WithKeys("N")), } } +// overlayKeyMap has the keys of the overlays. The settings do not change +// them. type overlayKeyMap struct { Choose, Close, Up, Down key.Binding - // ListDown, ListUp, and Delete work in the lists that have no input. - ListDown, ListUp, Delete key.Binding - - // Mark starts or clears a range in the commit picker. - Mark key.Binding - - // Quit and Stay are the answers to the prompt before a quit. - Quit, Stay key.Binding + // ListDown and ListUp work in the agent picker. + ListDown, ListUp key.Binding + // Flip switches the mode of the search. PreviewUp and PreviewDown + // scroll the preview of the search. + Flip, PreviewUp, PreviewDown key.Binding } -// overlayKeys is the keys of the overlays. The settings do not change -// them. +// overlayKeys has the keys of the overlays. The settings do not change +// these keys. var overlayKeys = overlayKeyMap{ - Choose: key.NewBinding(key.WithKeys("enter")), - Close: key.NewBinding(key.WithKeys("esc")), - Up: key.NewBinding(key.WithKeys("up", "ctrl+p", "ctrl+k")), - Down: key.NewBinding(key.WithKeys("down", "ctrl+n", "ctrl+j", "tab")), - ListDown: key.NewBinding(key.WithKeys("j")), - ListUp: key.NewBinding(key.WithKeys("k")), - Delete: key.NewBinding(key.WithKeys("d")), - Mark: key.NewBinding(key.WithKeys("space")), - Quit: key.NewBinding(key.WithKeys("q", "y", "enter", "Q")), - Stay: key.NewBinding(key.WithKeys("esc", "n")), + Choose: key.NewBinding(key.WithKeys("enter")), + Close: key.NewBinding(key.WithKeys("esc")), + Up: key.NewBinding(key.WithKeys("up", "ctrl+p", "ctrl+k")), + Down: key.NewBinding(key.WithKeys("down", "ctrl+n", "ctrl+j")), + Flip: key.NewBinding(key.WithKeys("tab")), + // The keys of the search are the same as in herdr-reviewr. + PreviewUp: key.NewBinding(key.WithKeys("pgup")), + PreviewDown: key.NewBinding(key.WithKeys("pgdown")), + ListDown: key.NewBinding(key.WithKeys("j")), + ListUp: key.NewBinding(key.WithKeys("k")), } // binding is an action of the keybindings setting and the key binding that @@ -153,31 +153,42 @@ type binding struct { key *key.Binding } -// bindings returns each action of c with its key binding in k. +// bindings returns each action of c with its key binding in k, in the +// order of the table of herdr-reviewr. The check of the keybindings +// setting uses this order to name the actions of a key that two actions +// use. herdr-reviewr has no focus action. The update is last. Its key is +// fixed, and the check keeps it free. func (k *keyMap) bindings(c keybindings) []binding { return []binding{ {"down", c.Down, &k.Down}, {"up", c.Up, &k.Up}, - {"page-down", c.PageDown, &k.PageDown}, - {"page-up", c.PageUp, &k.PageUp}, - {"half-down", c.HalfDown, &k.HalfDown}, - {"half-up", c.HalfUp, &k.HalfUp}, - {"collapse", c.Collapse, &k.Left}, - {"expand", c.Expand, &k.Right}, {"next-hunk", c.NextHunk, &k.NextHunk}, {"prev-hunk", c.PrevHunk, &k.PrevHunk}, {"next-file", c.NextFile, &k.NextFile}, {"prev-file", c.PrevFile, &k.PrevFile}, + {"collapse", c.Collapse, &k.Collapse}, + {"expand", c.Expand, &k.Expand}, + {"page-up", c.PageUp, &k.PageUp}, + {"page-down", c.PageDown, &k.PageDown}, + {"half-up", c.HalfUp, &k.HalfUp}, + {"half-down", c.HalfDown, &k.HalfDown}, {"focus", c.Focus, &k.Focus}, {"scope-uncommitted", c.ScopeUncommitted, &k.ScopeUncommitted}, {"scope-branch", c.ScopeBranch, &k.ScopeBranch}, - {"scope-last-turn", c.ScopeLastTurn, &k.ScopeTurn}, + {"scope-last-turn", c.ScopeLastTurn, &k.ScopeLastTurn}, {"scope-commits", c.ScopeCommits, &k.ScopeCommits}, {"base-pick", c.BasePick, &k.BasePick}, {"commit-pick", c.CommitPick, &k.CommitPick}, {"tab-changes", c.TabChanges, &k.TabChanges}, - {"tab-all-files", c.TabAllFiles, &k.TabFiles}, + {"tab-all-files", c.TabAllFiles, &k.TabAllFiles}, {"tab-pr", c.TabPR, &k.TabPR}, + {"wrap", c.Wrap, &k.Wrap}, + {"rendered", c.Rendered, &k.Rendered}, + {"split", c.Split, &k.Split}, + {"navigator-position", c.NavigatorPosition, &k.NavigatorPosition}, + {"navigator-hide", c.NavigatorHide, &k.NavigatorHide}, + {"navigator-grow", c.NavigatorGrow, &k.NavigatorGrow}, + {"navigator-shrink", c.NavigatorShrink, &k.NavigatorShrink}, {"select", c.Select, &k.Select}, {"comment", c.Comment, &k.Comment}, {"edit", c.Edit, &k.Edit}, @@ -185,47 +196,149 @@ func (k *keyMap) bindings(c keybindings) []binding { {"next-comment", c.NextComment, &k.NextComment}, {"prev-comment", c.PrevComment, &k.PrevComment}, {"comments", c.Comments, &k.Comments}, - {"send", c.Send, &k.Send}, - {"copy", c.Copy, &k.Copy}, {"search", c.Search, &k.Search}, {"find", c.Find, &k.Find}, {"goto-line", c.GotoLine, &k.GotoLine}, - {"rendered", c.Rendered, &k.Rendered}, - {"split", c.Split, &k.Split}, - {"wrap", c.Wrap, &k.Wrap}, - {"navigator-position", c.NavigatorPosition, &k.NavPosition}, - {"navigator-hide", c.NavigatorHide, &k.NavHide}, - {"navigator-grow", c.NavigatorGrow, &k.NavGrow}, - {"navigator-shrink", c.NavigatorShrink, &k.NavShrink}, + {"keys", c.Keys, &k.Keys}, + {"send", c.Send, &k.Send}, + {"copy", c.Copy, &k.Copy}, {"open-pr", c.OpenPR, &k.OpenPR}, {"refresh", c.Refresh, &k.Refresh}, - {"keys", c.Keys, &k.Keys}, {"quit", c.Quit, &k.Quit}, {"quit-discard", c.QuitDiscard, &k.QuitDiscard}, + {"update", nil, &k.Update}, } } -// applyConfig sets the keys of each action that c sets. +// applyConfig sets the keys of each action that c sets. The check of the +// setting accepts each key, so keyName does not fail. The help shows the +// first key of a changed action, as herdr-reviewr spells it. A label of a +// pair, for example "j/k move", shows the first key of each action. func (k *keyMap) applyConfig(c keybindings) { + changed := map[*key.Binding]bool{} for _, b := range k.bindings(c) { - if b.keys == nil { + if len(b.keys) == 0 { + continue + } + keys := make([]string, len(b.keys)) + for i, s := range b.keys { + keys[i], _ = keyName(s) + } + b.key.SetKeys(keys...) + b.key.SetHelp(firstKey(*b.key), b.key.Help().Desc) + changed[b.key] = true + } + for _, group := range k.pairs() { + if !slices.ContainsFunc(group, func(b *key.Binding) bool { return changed[b] }) { continue } - b.key.SetKeys(b.keys...) - if len(b.keys) > 0 { - b.key.SetHelp(b.keys[0], b.key.Help().Desc) + names := make([]string, len(group)) + for i, b := range group { + names[i] = firstKey(*b) } + group[0].SetHelp(strings.Join(names, "/"), group[0].Help().Desc) } } +// pairs returns the groups of actions that share one help label, for +// example "j/k move". The label is on the first action of each group. +func (k *keyMap) pairs() [][]*key.Binding { + return [][]*key.Binding{ + {&k.Down, &k.Up}, + {&k.HalfDown, &k.HalfUp}, + {&k.NextHunk, &k.PrevHunk}, + {&k.NextFile, &k.PrevFile}, + {&k.Collapse, &k.Expand}, + {&k.TabChanges, &k.TabAllFiles, &k.TabPR}, + {&k.NextComment, &k.PrevComment}, + {&k.NavigatorGrow, &k.NavigatorShrink}, + } +} + +// namedKeys are the keys that are not one character. Each has its name as +// herdr-reviewr spells it, its bubbletea name, and its label on the +// screen. +var namedKeys = []namedKey{ + {"left", "left", "←"}, + {"right", "right", "→"}, + {"up", "up", "↑"}, + {"down", "down", "↓"}, + {"pageup", "pgup", "PageUp"}, + {"pagedown", "pgdown", "PageDown"}, +} + +// namedKey is a key that is not one character. +type namedKey struct{ name, tea, label string } + +// teaKey returns the named key whose bubbletea name is name without its +// modifier, and the modifier. ok is false if no named key has this name. +func teaKey(name string) (mod string, k namedKey, ok bool) { + mod, rest := cutModifier(name) + i := slices.IndexFunc(namedKeys, func(k namedKey) bool { return k.tea == rest }) + if i < 0 { + return mod, namedKey{}, false + } + return mod, namedKeys[i], true +} + +// keyName returns the bubbletea name of the key s of the keybindings +// setting. It is the inverse of [herdrReviewrKeyName]. s is one character +// or a named key, alone or after "ctrl+" or "alt+", as in herdr-reviewr. +// The bubbletea names "pgup" and "pgdown" are also valid. ok is false if s +// is not a valid key. +func keyName(s string) (name string, ok bool) { + mod, rest := cutModifier(s) + named := func(k namedKey) bool { return rest == k.name || rest == k.tea } + if i := slices.IndexFunc(namedKeys, named); i >= 0 { + return mod + namedKeys[i].tea, true + } + r, size := utf8.DecodeRuneInString(rest) + if size == 0 || size != len(rest) || r == utf8.RuneError || unicode.IsSpace(r) || + ansi.StringWidth(rest) == 0 { + return "", false + } + return s, true +} + +// herdrReviewrKeyName returns the bubbletea key name as herdr-reviewr +// spells it, for example "pageup" for "pgup". It is the inverse of +// [keyName]. +func herdrReviewrKeyName(name string) string { + if mod, k, ok := teaKey(name); ok { + return mod + k.name + } + return name +} + +// cutModifier returns the "ctrl+" or "alt+" at the start of s, if any, and +// the rest of s. +func cutModifier(s string) (mod, rest string) { + for _, m := range []string{"ctrl+", "alt+"} { + if r, ok := strings.CutPrefix(s, m); ok { + return m, r + } + } + return "", s +} + +// keyNames lists the named keys for an error message. +func keyNames() string { + names := make([]string, len(namedKeys)) + for i, k := range namedKeys { + names[i] = k.name + } + return strings.Join(names, ", ") +} + // helpKeys implements help.KeyMap for the mode and the tab of the program. type helpKeys struct { - k keyMap - mode mode - tab tab - split bool - visual bool - finding bool + k keyMap + mode mode + tab tab + split bool + visual bool + atFold bool // the cursor is on a fold + armed int // the direction of an armed crossing, or 0 } // ShortHelp returns the keys of the footer. @@ -237,19 +350,29 @@ func (h helpKeys) ShortHelp() []key.Binding { switch { case h.mode == modeComment: return []key.Binding{k.Save, k.Newline, k.Cancel} - case h.finding: + case h.armed > 0: return []key.Binding{ - hint(k.FindNext, "n/N", "next/prev match"), - hint(k.Cancel, "esc", "stop finding"), + hint(k.NextHunk, firstKey(k.NextHunk), "next file"), + k.Comment, k.Send, k.Keys, k.Quit, + } + case h.armed < 0: + return []key.Binding{ + hint(k.PrevHunk, firstKey(k.PrevHunk), "prev file"), + k.Comment, k.Send, k.Keys, k.Quit, } case h.visual: return []key.Binding{ k.Comment, - hint(k.Down, "j/k", "extend"), - hint(k.Cancel, "esc", "clear"), + hint(k.Down, k.Down.Help().Key, "extend"), + hint(k.Cancel, firstKey(k.Cancel), "clear"), } case h.tab == tabPR: return []key.Binding{k.Down, k.OpenPR, k.Refresh, k.TabChanges, k.Keys, k.Quit} + case h.atFold: + return []key.Binding{ + hint(k.Expand, firstKey(k.Expand), "expand fold"), + k.Comment, k.Send, k.NextHunk, k.Keys, k.Quit, + } } return []key.Binding{k.Comment, k.Send, k.NextHunk, k.NextFile, k.Split, k.Keys, k.Quit} } @@ -258,31 +381,42 @@ func (h helpKeys) ShortHelp() []key.Binding { func (h helpKeys) FullHelp() [][]key.Binding { k := h.k move := []key.Binding{ - k.Down, k.HalfDown, k.NextHunk, k.NextFile, k.Focus, k.Left, k.GotoLine, + k.Down, k.HalfDown, k.NextHunk, k.NextFile, k.Focus, k.Collapse, k.GotoLine, } scopes := []key.Binding{ - k.TabChanges, k.ScopeUncommitted, k.ScopeBranch, k.ScopeTurn, k.ScopeCommits, + k.TabChanges, k.ScopeUncommitted, k.ScopeBranch, k.ScopeLastTurn, k.ScopeCommits, k.BasePick, k.CommitPick, } comments := []key.Binding{ k.Select, k.Comment, k.Edit, k.Delete, k.NextComment, k.Comments, k.Send, k.Copy, } view := []key.Binding{ - k.Search, k.Find, k.Rendered, k.Split, k.Wrap, k.NavPosition, k.NavHide, k.NavGrow, + k.Search, k.Find, k.Rendered, k.Split, k.Wrap, k.NavigatorPosition, k.NavigatorHide, k.NavigatorGrow, } - other := []key.Binding{k.OpenPR, k.Refresh, k.Keys, k.Quit, k.QuitDiscard} + other := []key.Binding{k.OpenPR, k.Refresh, k.Keys, k.Quit} return [][]key.Binding{move, scopes, comments, view, other} } // newHelp styles the footer with the terminal palette. func newHelp() help.Model { h := help.New() - keyS := lipgloss.NewStyle().Bold(true) - descS := lipgloss.NewStyle().Foreground(lipgloss.Color("8")) - h.Styles.ShortKey, h.Styles.FullKey = keyS, keyS - h.Styles.ShortDesc, h.Styles.FullDesc = descS, descS - h.Styles.ShortSeparator, h.Styles.FullSeparator = descS, descS - h.Styles.Ellipsis = descS + h.Styles.ShortKey, h.Styles.FullKey = boldStyle, boldStyle + h.Styles.ShortDesc, h.Styles.FullDesc = dimStyle, dimStyle + h.Styles.ShortSeparator, h.Styles.FullSeparator = dimStyle, dimStyle + h.Styles.Ellipsis = dimStyle h.ShortSeparator, h.FullSeparator = " ", " " return h } + +// firstKey returns the label of the first key of b, as herdr-reviewr +// shows it, or "". +func firstKey(b key.Binding) string { + ks := b.Keys() + if len(ks) == 0 { + return "" + } + if mod, k, ok := teaKey(ks[0]); ok { + return mod + k.label + } + return ks[0] +} diff --git a/tui/model.go b/tui/model.go index 8162632..8a766b8 100644 --- a/tui/model.go +++ b/tui/model.go @@ -3,22 +3,29 @@ package tui import ( "cmp" "context" + "fmt" "image/color" "log/slog" + "slices" + "strconv" "strings" "time" "charm.land/bubbles/v2/help" + "charm.land/bubbles/v2/key" "charm.land/bubbles/v2/textarea" tea "charm.land/bubbletea/v2" uv "github.com/charmbracelet/ultraviolet" "github.com/rednafi/rexdiff/diff" + "github.com/rednafi/rexdiff/internal/termtext" + "github.com/rednafi/rexdiff/internal/turn" "github.com/rednafi/rexdiff/rex" "github.com/rednafi/rexdiff/update" "github.com/rednafi/rexdiff/vcs" ) +// tab is a tab of the program: Changes, Files, or PR. type tab int const ( @@ -27,7 +34,7 @@ const ( tabPR ) -// scopeCommits compares the commits that the user picked. It is not a +// scopeCommits compares the commits that the user picks. It is not a // value of Config.DefaultScope. const scopeCommits Scope = "commits" @@ -44,29 +51,46 @@ const ( modeNormal mode = iota modeComment modeOverlay + modeFind // the find band ) -// pane is one view that scrolls and takes comments: the rendered lines and -// the source lines that they show. +// String returns the name of the mode, for the log. +func (md mode) String() string { + switch md { + case modeNormal: + return "normal" + case modeComment: + return "comment" + case modeOverlay: + return "overlay" + case modeFind: + return "find" + } + return "mode(" + strconv.Itoa(int(md)) + ")" +} + +// pane is one view that scrolls and takes comments. It has the rendered +// lines and the source lines that they show. type pane struct { lines []string rows []diff.Row // rows[i] is the source line that lines[i] shows - diff *diff.Diff // the render, for mouse hits; nil for markdown and the PR - path string // the file of the rows; "" if the pane takes no comments + diff *diff.Diff // the render for mouse hits, or nil for Markdown or PR + path string // the file of the rows, or "" for a pane with no comments + src []diff.Row // the source rows of rendered markdown, or nil cur int // the row of the cursor top int // the first row on the screen anchor int // the start of the visual selection, or -1 xoff int // the sideways scroll, in columns - note string // shown instead of the lines, if there are no lines + note string // the text that shows if there are no lines // free is true after the wheel moves the view. Then the cursor can // be off the screen until the next key or click. free bool } -// set shows lines that show rows. It keeps the cursor, the top, and the -// selection in the new lines. +// set shows lines, and rows tells which source lines they show. It keeps +// the cursor, the top, and the selection in the new lines. func (p *pane) set(lines []string, rows []diff.Row) { - p.lines, p.rows, p.diff = lines, rows, nil + p.lines, p.rows, p.diff, p.src = lines, rows, nil, nil p.cur = min(p.cur, max(len(lines)-1, 0)) p.top = min(p.top, max(len(lines)-1, 0)) if p.anchor >= len(rows) { // the file is shorter than before @@ -74,7 +98,7 @@ func (p *pane) set(lines []string, rows []diff.Row) { } } -// cont tells if line i continues the source line of the line above. +// cont reports whether line i continues the source line of the line above. func (p *pane) cont(i int) bool { return i > 0 && i < len(p.rows) && p.rows[i].Continued } // head returns the first line of the source line that line i shows. @@ -112,60 +136,58 @@ func (p *pane) side(x int) (s diff.Side, onNumber bool) { return p.diff.SideAt(x) } -// changed is a changed file with its line counts. -type changed struct { +// changedFile is a changed file with its line counts and the hash of its +// old content, which names its folds. +type changedFile struct { vcs.File adds, dels int + oldHash uint64 } -// turnState is the latest turn of an agent: a snapshot of the working tree -// when the agent started work. +// turnState is a turn of the agents in the repository. It keeps a +// snapshot of the working tree from the start of the turn. type turnState struct { - block string - app string // the name of the agent, for example "pi" + app string // the name of the first agent of the turn, for example "pi" tree string // the snapshot at the start of the turn running bool at time.Time // the start of the turn } -// findState is the result of a find in the current pane. -type findState struct { - query string - hits []int // the first rows of the lines that match, in order - i int // the hit at the cursor -} - -// active tells if the find has hits, so that n and N step through them. -func (f *findState) active() bool { return len(f.hits) > 0 } - // dragState is a drag of the left mouse button in the main pane. type dragState struct { active bool // the button is down - moved bool // the cursor moved off the start row - gutter bool // the drag started on the line numbers - start int // the row where the drag started + moved bool // the cursor has left the start row + gutter bool // the drag starts on the line numbers + start int // the row where the drag starts pref diff.Side // the side of the line numbers or the code at the start + // The screen line and the code column of the press and of the pointer. + row, x, toRow, toX int + link string // the URL under the press, or "" } -// draft is the comment that the user writes: a new one, or an edit of the -// comment with ID editID. +// draft is the comment that the user writes. It is a new comment, or an +// edit of the comment with ID editID. An edit from the comment list goes +// back to the list at row listRow. Else listRow is -1. type draft struct { diff.Comment - editID int + editID int + listRow int } // prState is the content of the PR tab. type prState struct { - loading bool - pr *vcs.PullRequest - err string + pr *vcs.PullRequest + err string } // agentState is what the program knows about the agent in a block. type agentState struct { - state rex.State // "" after the block closed - cwd string // the working directory; "" if unknown - hasCwd bool // the program asked Rex for cwd + app string // the name of the agent, for example "pi" + state rex.State // "" if the program sends no status + since time.Time // when Rex got the state + cwd string // the working directory, or "" if Rex does not know it + hasCwd bool // the program has the answer of Rex for cwd + asking bool // a question to Rex for cwd runs } // model is the state of the program. It implements tea.Model. @@ -176,66 +198,142 @@ type model struct { repo *vcs.Repo rex *rex.Client // nil outside Rex log *slog.Logger + env []string // for the editor, the URL opener, and the clipboard cfg config keys keyMap notice string // a problem that the footer shows, or "" - updater Updater // nil if rexdiff cannot update itself - release *update.Release // a newer release, or nil - phase updatePhase - restartState string // the file of the state for the restart, or "" + updater *update.Updater // nil if rexdiff cannot update itself + release *update.Release // a newer release, or nil + phase updatePhase + resumePath string // the file of the resume state for the restart, or "" - w, h int - bg, fg color.Color - red color.Color // the ANSI red of the terminal, or nil - green color.Color // the ANSI green of the terminal, or nil - dark bool - focused bool - ticks int + // The size of the terminal. Until the terminal tells it, w and h are + // a guess, and sized is false. + w, h int + sized bool + bg, fg color.Color + red color.Color // the ANSI red of the terminal, or nil + green color.Color // the ANSI green of the terminal, or nil + ticks int + + // watch tells when the files or the refs change, or is nil. If it is + // nil, a tick reloads the current tab every reloadPoll, after polled. + // After a failure of the watcher, a tick starts it again. + watch *vcs.Watcher + polled time.Time + watchRetry retryTimer + // reloadOwed is true if a change is seen but the Changes tab does not + // show it yet. A reload waits until reloadAt, after the pause of the + // last one, and until the retry, after a failure. + reloadOwed bool + filesOwed bool // the same for the list of all files + reloadAt time.Time + reloadRetry retryTimer + loads reloads + // turnChecking is true while a check of the pending turn runs. + turnChecking bool + // turnGen counts the starts and the ends of the turns. A snapshot + // from before the last start or end is stale. + turnGen int tab tab scope Scope + // folds has the folds that the user expands in the Changes tab, by path. + folds map[string]*openFolds base string - from string // the commits scope: the oldest commit that the user picked - to string // the commits scope: the newest commit that the user picked - turn *turnState + // defaultBase is the default branch at the start, for the base picker. + defaultBase string + // baseFlag is true if the base comes from the flag. Then the user + // cannot pick another. + baseFlag bool + from string // the commits scope: the oldest commit that the user picks + to string // the commits scope: the newest commit that the user picks + + // turn is the last turn that changes a file, or nil. The last-turn + // scope compares with it. pending is a turn that starts but changes + // no file yet, or nil. + turn, pending *turnState + // raced is true if the last turn starts too close to a write. Then + // its start snapshot can have the write, and the scope shows no turn. + raced bool + tracker turn.Tracker + // sampled is true after the first list of the agents. An edge before + // it can be wrong, so the tracker gets no states before it. + sampled bool + // After a failed list, a tick lists the agents again, as in + // herdr-reviewr. + sampleRetry retryTimer + // agentsGen counts the reconnects to Rex. A list of the agents with + // an older gen is from before a gap. + agentsGen int + // agentsHere points to true if an agent works in the repository. It + // is nil if the program does not know. + agentsHere *bool + memberDirs map[string]bool // the working directories of the members + ownsErr string // the last error of owns, which the log has - files []changed // the Changes tab - changeSel int - shownTrees string // the two trees of files, to notice a change - allFiles []vcs.Entry // the Files tab - allSel int - viewed string // the content in the Files tab, to notice edits + changeFiles []changedFile // the Changes tab + changeSel int // the file that shows in the Changes tab + shownTrees string // the two trees of files, to notice a change + shownScope Scope // the scope of changeFiles + changeTree fileListTree + allFiles []vcs.Entry // the Files tab + allSel int + allTree fileListTree + // viewed and viewedPath are the content and the path in the Files + // tab, to notice edits. + viewed string + viewedPath string + readErr string // the last error of the read, which the log has - changes, viewer, prPane pane - pr prState - prPlace *savedPane // the place of prPane after a restart + changePane, allPane, prPane pane + pr prState + prf prFetch + prPlace *resumePane // prPane's place after a restart + prDetails bool // the PR
blocks are open split bool // show the diff side by side wrap bool rendered bool // show markdown rendered - listLeft bool + listPos position listHidden bool focusList bool // the keys j and k move in the file list - listW int + // armed is 1 or -1 after a ] or a [ past the last change arms a + // crossing to the file at armedPath. Else it is 0. + armed int + armedPath string + // listW is the width of the file list at a side, in columns, or 0 for + // the default share of the width. + listW int + listPct int // the share of the body rows, above or below + cache diff.Cache // the syntax colours, for a fast sideways scroll comments diff.Comments editor textarea.Model draft *draft mode mode overlay *overlay - find findState - drag dragState - help help.Model + // recent has the files that the user picks in the search in this + // session, the last pick first. + recent []string + grepCancel context.CancelCauseFunc // stops the code search that runs + grepGen int // the last code search + find findState + findLines splitLines // the lines that find searches + md mdRenderer // the last markdown renderer + drag dragState + click clickChain + help help.Model status string statusTill time.Time agents map[string]*agentState // by block - sends int // the steps of sends that run now + sends int // the steps of the sends that run lastSent string // the block of the last send - beside string // the agent block that the pane opened beside + beside string // the agent block beside the pane } // newModel returns the model of the program with the options o and the @@ -248,38 +346,56 @@ func newModel(ctx context.Context, o Options, cfg config) model { ta.KeyMap.InsertNewline.SetKeys(defaultKeys().Newline.Keys()...) m := model{ - ctx: ctx, - repo: o.Repo, - rex: o.Rex, - log: o.Log, - notice: o.Notice, - updater: o.Updater, - w: 120, - h: 40, - focused: true, - base: o.Base, - wrap: o.Wrap, - editor: ta, - help: newHelp(), - agents: map[string]*agentState{}, + ctx: ctx, + repo: o.Repo, + rex: o.Rex, + log: o.Log, + env: o.Env, + notice: o.Notice, + updater: o.Updater, + w: 120, + h: 40, + base: o.Base, + baseFlag: o.Base != "", + wrap: o.Wrap, + editor: ta, + help: newHelp(), + agents: map[string]*agentState{}, } if m.log == nil { m.log = slog.New(slog.DiscardHandler) } + // As in herdr-reviewr, the flag wins over the saved base, and a saved + // base that does not resolve does not count. if m.base == "" { - m.base = o.Repo.DefaultBase(ctx) + if b := o.Repo.SavedBase(ctx); b != "" { + if _, err := o.Repo.Resolve(ctx, b); err == nil { + m.base = b + } + } + } + m.defaultBase = o.Repo.DefaultBase(ctx) + if m.base == "" { + m.base = m.defaultBase + } + if tree := o.Repo.SavedTurn(ctx); tree != "" { + m.turn = &turnState{tree: tree} } if o.Rex != nil && o.Agent != "" { // If Rex does not know the agent, the picker selects no agent // first. The picker still lists all the agents. + ctx, cancel := context.WithCancelCause(ctx) + ctx, cancelTimeout := context.WithTimeoutCause(ctx, rexTimeout, + fmt.Errorf("rex resolve: %v timeout: %w", rexTimeout, context.DeadlineExceeded)) m.beside, _ = o.Rex.Resolve(ctx, o.Agent) + cancel(fmt.Errorf("rex resolve: done: %w", context.Canceled)) + cancelTimeout() } - m.changes.anchor, m.viewer.anchor, m.prPane.anchor = -1, -1, -1 + m.changePane.anchor, m.allPane.anchor, m.prPane.anchor = -1, -1, -1 m.cfg = cfg m.keys = defaultKeys() m.keys.applyConfig(cfg.Keybindings) - m.listLeft = cfg.FileList == positionLeft - m.listW = cfg.FileListWidth + m.listPos, m.listW, m.listPct = cfg.FileList, cfg.FileListWidth, listStackPct m.rendered = cfg.MarkdownView == markdownViewRendered // The flags win over the settings. m.split = cmp.Or(o.Layout, cfg.Layout) == LayoutSplit @@ -287,29 +403,60 @@ func newModel(ctx context.Context, o Options, cfg config) model { return m } -// Messages. +// These are the messages of the program. type ( tickMsg struct{} agentsMsg struct { agents []rex.Agent err error send bool // true if the list is for a send, not to learn the states + gen int // the agentsGen at the start of the list } sentMsg struct { agent rex.Agent - ids []int // the IDs of the sent comments + sent map[int]string // the texts of the sent comments, by ID err error } grepMsg struct { - query string - hits []vcs.Match + gen int // the search whose result shows. Other searches are stale. + hits []vcs.Match + more bool // git finds more matches than hits has + err error } prMsg struct { + gen int // the read, see prFetch pr *vcs.PullRequest err error } - eventMsg rex.Event - editorDoneMsg struct{ err error } + eventMsg rex.Event + // splitMsg is the answer of Rex to the split for the editor of path. + splitMsg struct { + path, script string + err error + } + // cwdMsg is the answer of Rex for the working directory of an agent. + cwdMsg struct { + block, cwd string + err error + } + // editorDoneMsg tells that the editor of path in this pane stops. + editorDoneMsg struct { + path string + err error + } + // detachedMsg tells that a detached program, for what, fails. + detachedMsg struct { + what string + err error + } + // releaseMsg has a release that is newer than this rexdiff. + releaseMsg struct { + release update.Release + } + // updatedMsg reports the end of the update to the release. + updatedMsg struct { + err error + } ) // requestColors are the commands that ask the terminal for its colours. @@ -320,6 +467,9 @@ var requestColors = []tea.Cmd{ requestPalette, } +// tickInterval is the time between two ticks. +const tickInterval = time.Second + // tick returns a command that sends a tickMsg after d. func tick(d time.Duration) tea.Cmd { return tea.Tick(d, func(time.Time) tea.Msg { return tickMsg{} }) @@ -328,7 +478,10 @@ func tick(d time.Duration) tea.Cmd { // Init starts the ticks, asks for the colours of the terminal, and loads // the pull request and the agents. func (m *model) Init() tea.Cmd { - cmds := []tea.Cmd{tick(time.Second), m.loadPR()} + cmds := []tea.Cmd{tick(tickInterval), m.requestPR(true)} + // After a restart, the PR tab can show first. It tells that the read + // runs. + m.renderPR() cmds = append(cmds, requestColors...) if m.rex != nil { cmds = append(cmds, m.listAgents(agentsMsg{})) @@ -339,98 +492,231 @@ func (m *model) Init() tea.Cmd { return tea.Batch(cmds...) } -// flashTime is how long a flash shows in the footer. -const flashTime = 6 * time.Second +// flashTime is how long a flash shows in the footer. herdr-reviewr uses +// the same time. +const flashTime = 4 * time.Second // flash shows s in the footer for a short time. The text can come from Rex // or from git, so flash quotes it if it has control characters. func (m *model) flash(s string) { - m.status, m.statusTill = diff.Quote(s), time.Now().Add(flashTime) + m.status, m.statusTill = termtext.Quote(s), time.Now().Add(flashTime) } -// trees returns the two trees that the Changes tab compares in the scope. -// If there is nothing to compare, note tells why. -func (m *model) trees() (from, to, note string, err error) { - ctx := m.ctx - switch m.scope { - case scopeCommits: - if m.to == "" { - return "", "", "no commits picked: press G to pick one or a range", nil +// reload reads the changes of the scope again, even if the trees do not +// change. +// A reload that runs is then stale, because the view can change. +func (m *model) reload() { + l := &m.loads + l.want, l.force, l.stale = true, true, l.running +} + +// reloadIfChanged reads the changes of the scope again if the trees differ +// from the shown trees. +func (m *model) reloadIfChanged() { m.loads.want = true } + +// reloads follows the reloads of the Changes tab. A reload runs git, so +// it runs in a command, one at a time. A request during a reload starts +// one more reload after it. +type reloads struct { + gen int // the reload whose result shows. Other reloads are stale. + running bool // the reload gen runs + want bool // a reload is due + // The due reload shows the changes, also for equal trees. + force bool + // The view changes while the reload gen runs, so its result is old. + stale bool +} + +// loadNow reads the changes at once, for the first screen. Nothing shows +// yet, so the wait blocks nothing. +func (m *model) loadNow() { + m.loads.gen++ + m.loads.want, m.loads.force = false, false + m.onChanges(m.reloadInput(true).run(m.ctx)) +} + +// reloadTimeout is the longest time of a reload. A git that hangs then +// fails the reload, and the next tick tries again. +const reloadTimeout = time.Minute + +// startReload returns a command for the reload that is due, if no reload +// runs. While the comment box is open, the reload waits until the box +// closes, as in herdr-reviewr. +func (m *model) startReload() tea.Cmd { + l := &m.loads + if !l.want || l.running || m.mode == modeComment { + return nil + } + l.gen++ + l.running, l.want, l.stale = true, false, false + in, ctx := m.reloadInput(l.force), m.ctx + l.force = false + return func() tea.Msg { return in.run(ctx) } +} + +// reloadInput returns what a reload needs, copied from the model, so that +// the reload can run in a command. +func (m *model) reloadInput(force bool) reloadInput { + in := reloadInput{ + repo: m.repo, gen: m.loads.gen, scope: m.scope, + base: m.base, from: m.from, to: m.to, force: force, shown: m.shownTrees, + prev: make(map[string]changedFile, len(m.changeFiles)), + } + for _, c := range m.changeFiles { + in.prev[c.Path] = c + } + switch { + case m.scope == scopeCommits && m.to == "": + in.note = "no commits picked: press " + firstKey(m.keys.CommitPick) + + " to pick one or a range" + case m.scope == ScopeLastTurn && m.turn == nil: + in.note = m.turnWaitMessage() + case m.scope == ScopeLastTurn: + in.turnTree = m.turn.tree + } + return in +} + +// reloadInput is the input of a reload. note tells why there is nothing +// to compare, or is "". +type reloadInput struct { + repo *vcs.Repo + gen int + scope Scope + base, from, to, note string + turnTree, shown string + force bool + prev map[string]changedFile // the shown files, by path +} + +// changesMsg is the result of a reload. +type changesMsg struct { + gen int + trees string // the two trees, as "from..to" + same bool // the trees are the shown trees, and the reload is not forced + files []changedFile + note string + err error + took time.Duration +} + +// run reads the changes of in. It counts the lines of each file that is +// not in in.prev with the same content. +func (in reloadInput) run(ctx context.Context) changesMsg { + start := time.Now() + ctx, cancel := context.WithCancelCause(ctx) + ctx, cancelTimeout := context.WithTimeoutCause(ctx, reloadTimeout, + fmt.Errorf("reload: %v timeout: %w", reloadTimeout, context.DeadlineExceeded)) + defer cancelTimeout() + defer cancel(fmt.Errorf("reload: done: %w", context.Canceled)) + msg := changesMsg{gen: in.gen, note: in.note} + if msg.note == "" { + from, to, err := in.trees(ctx) + msg.trees, msg.err = from+".."+to, err + msg.same = err == nil && msg.trees == in.shown && !in.force + if err == nil && !msg.same { + msg.files, msg.err = in.changedFiles(ctx, from, to) } - if from, err = m.repo.Parent(ctx, m.from); err != nil { - return "", "", "", err + } + msg.took = time.Since(start) + return msg +} + +// trees returns the IDs of the two trees that the Changes tab compares in +// the scope. +func (in reloadInput) trees(ctx context.Context) (from, to string, err error) { + switch in.scope { + case scopeCommits: + if from, err = in.repo.ParentTree(ctx, in.from); err != nil { + return "", "", err } - to, err = m.repo.Tree(ctx, m.to) - return from, to, "", err + to, err = in.repo.Tree(ctx, in.to) + return from, to, err case ScopeLastTurn: - if m.turn == nil { - note := "no agent turn seen yet: the next turn's changes will show here" - return "", "", note, nil - } - from = m.turn.tree + from = in.turnTree case ScopeBranch: - if from, err = m.repo.MergeBase(ctx, m.base); err != nil { - return "", "", "", err - } + from, err = in.repo.MergeBaseTree(ctx, in.base) default: - if from, err = m.repo.Head(ctx); err != nil { - return "", "", "", err - } + from, err = in.repo.HeadTree(ctx) + } + if err != nil { + return "", "", err } - to, err = m.repo.Snapshot(ctx) - return from, to, "", err + to, err = in.repo.Snapshot(ctx) + return from, to, err } -// reload reads the changes of the scope again, even if the trees did not -// change. -func (m *model) reload() { - m.shownTrees = "" // no trees are "", so reloadIfChanged reads them - m.reloadIfChanged() -} - -// reloadIfChanged reads the changes of the scope again if the trees -// changed. -func (m *model) reloadIfChanged() { - from, to, note, err := m.trees() - trees := from + ".." + to - var files []vcs.File - if err == nil && note == "" { - if trees == m.shownTrees { - return +// changedFiles returns the changes from the tree from to the tree to, +// with their line counts. +func (in reloadInput) changedFiles(ctx context.Context, from, to string) ([]changedFile, error) { + files, err := in.repo.Diff(ctx, from, to) + if err != nil { + return nil, err + } + out := make([]changedFile, len(files)) + for i, f := range files { + c := &out[i] + if p, ok := in.prev[f.Path]; ok && p.Old == f.Old && p.New == f.New { + *c = p // the same change, so the counts stay + } else if f.Skip == nil { + c.adds, c.dels = diff.Count(f.Old, f.New) + c.oldHash = hash(f.Old) } - files, err = m.repo.Diff(m.ctx, from, to) + c.File = f + } + return out, nil +} + +// onChanges shows the result of a reload. After a failure, the next tick +// tries again after a wait. As in herdr-reviewr, a failure keeps the files +// of the scope that show, and the error shows in their place only if no +// files show. A change of the view during the reload starts one more. +func (m *model) onChanges(msg changesMsg) { + l := &m.loads + if msg.gen != l.gen { + return + } + l.running = false + if l.stale || m.mode == modeComment { + // As in herdr-reviewr, the view does not change under the comment + // box. The reload runs again later. + l.want, l.stale = true, false + return + } + m.reloadAt = time.Now().Add(reloadPauseFactor * msg.took) + m.retry(msg.err) + note, err := msg.note, msg.err + if err == nil && note == "" && msg.same { + return + } + if err != nil && len(m.changeFiles) > 0 && m.shownScope == m.scope { + m.flash("refresh failed: " + err.Error()) + return } if err != nil { note = err.Error() } + m.shownScope = m.scope if note != "" { - m.changes.note, m.files, m.shownTrees = note, nil, "" + m.changePane.note, m.changeFiles, m.shownTrees = note, nil, "" + m.buildTrees() m.renderChanges() return } var selPath string - if f := m.changedFile(); f != nil { + if f := m.selectedChange(); f != nil { selPath = f.Path } - m.files = make([]changed, len(files)) - m.shownTrees, m.changeSel, m.changes.note = trees, 0, "" - for i, f := range files { - c := &m.files[i] - c.File = f - if !f.Binary && !f.TooBig { - c.adds, c.dels = diff.Count(f.Old, f.New) - } - if f.Path == selPath { - m.changeSel = i - } - m.comments.Relocate(f.Path, f.Old, f.New) - } - if f := m.changedFile(); f == nil || f.Path != selPath { + m.changeFiles = msg.files + m.shownTrees, m.changePane.note = msg.trees, "" + m.changeSel = slices.IndexFunc(m.changeFiles, func(c changedFile) bool { return c.Path == selPath }) + m.buildTrees() + if f := m.selectedChange(); f == nil || f.Path != selPath { // Another file shows, so the cursor and the selection do not apply. - m.changes.cur, m.changes.top, m.changes.anchor = 0, 0, -1 + m.changePane.cur, m.changePane.top, m.changePane.anchor = 0, 0, -1 } - if len(m.files) == 0 { - m.changes.note = "no changes in this scope · watching…" + if len(m.changeFiles) == 0 { + m.changePane.note = "no changes" } m.renderChanges() } @@ -438,27 +724,54 @@ func (m *model) reloadIfChanged() { // loadFiles reads the list of all files if the model does not have it. func (m *model) loadFiles() { if m.allFiles == nil { - m.allFiles, _ = m.repo.Files(m.ctx) // an error shows an empty list + m.readFiles() } } // reloadFiles reads the list of all files again. func (m *model) reloadFiles() { - m.allFiles, _ = m.repo.Files(m.ctx) // an error shows an empty list - m.allSel = min(m.allSel, max(len(m.allFiles)-1, 0)) + m.filesOwed = false + m.readFiles() m.refreshViewer() } -// Update handles a message. After an install, it restarts the program +// readFiles reads the list of all files. The selected file stays. On an +// error, it keeps the old list and shows the error. +func (m *model) readFiles() { + files, err := m.repo.Files(m.ctx) + if err != nil { + m.flash("refresh failed: " + err.Error()) + return + } + var sel string + if m.allSel < len(m.allFiles) { + sel = m.allFiles[m.allSel].Path + } + m.allFiles = m.withIgnored(files) + m.allSel = slices.IndexFunc(m.allFiles, func(e vcs.Entry) bool { return e.Path == sel }) + m.buildFileTree() +} + +// buildTrees makes the rows of the file lists after a change of the +// Changes tab. The Files tab marks the directories with a change. +func (m *model) buildTrees() { + m.buildChangeTree() + if m.allFiles != nil { + m.buildFileTree() + } +} + +// Update handles a message. After an update, Update restarts the program // when no work can get lost. func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmd := m.handle(msg) - if m.phase != phaseInstalled { + if m.phase != phaseUpdated { return m, cmd } - // A tick only starts the next tick, so it is no work. + // The work of a tick runs again in the new process, so it is not + // pending work. pending := cmd - if _, tick := msg.(tickMsg); tick { + if _, ok := msg.(tickMsg); ok { pending = nil } if m.restartIfReady(pending) { @@ -469,26 +782,31 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // handle does the work of Update. Update then checks for a restart. func (m *model) handle(msg tea.Msg) tea.Cmd { + cmd := m.handleMsg(msg) + return tea.Batch(cmd, m.startReload()) +} + +// handleMsg handles msg. A reload that it asks for starts after it. +func (m *model) handleMsg(msg tea.Msg) tea.Cmd { var cmd tea.Cmd - switch msg.(type) { - case tea.KeyPressMsg, tea.MouseClickMsg: // these can move the cursor - m.changes.free, m.viewer.free, m.prPane.free = false, false, false - } switch msg := msg.(type) { + case changesMsg: + m.onChanges(msg) + case turnStartMsg: + cmd = m.onTurnStart(msg) + case turnCheckMsg: + cmd = m.onTurnCheck(msg) case tea.WindowSizeMsg: - m.w, m.h = msg.Width, msg.Height - m.editor.SetWidth(max(m.mainWidth()-8, 20)) + m.click = clickChain{} + m.w, m.h, m.sized = msg.Width, msg.Height, true m.rerender() case tea.FocusMsg: - m.focused = true m.reloadIfChanged() - case tea.BlurMsg: - m.focused = false case tickMsg: - cmd = m.onTick() + cmd = tea.Batch(tick(tickInterval), m.onTick()) case tea.BackgroundColorMsg: if !sameColor(m.bg, msg.Color) { - m.bg, m.dark = msg.Color, msg.IsDark() + m.bg = msg.Color m.rerender() } case tea.ForegroundColorMsg: @@ -503,145 +821,252 @@ func (m *model) handle(msg tea.Msg) tea.Cmd { case sentMsg: m.onSent(msg) case eventMsg: - m.onEvent(rex.Event(msg)) + cmd = m.onEvent(rex.Event(msg)) case prMsg: - m.onPR(msg) + cmd = m.onPR(msg) case grepMsg: m.onGrep(msg) - case *update.Release: - m.release = msg - case installedMsg: - m.onInstalled(msg) + case branchesMsg: + m.onBranches(msg) + case releaseMsg: + m.release = &msg.release + case updatedMsg: + m.onUpdated(msg) case editorDoneMsg: - if msg.err != nil { - m.flash("editor: " + msg.err.Error()) - } - m.rerender() + m.onEditorDone(msg) + case detachedMsg: + m.flash(msg.what + " failed: " + msg.err.Error()) + case cwdMsg: + cmd = m.onCwd(msg) + case splitMsg: + cmd = m.onSplit(msg) case tea.MouseClickMsg: + m.followCursor() return m.onClick(msg.Mouse()) case tea.MouseMotionMsg: m.onDrag(msg.Mouse()) case tea.MouseReleaseMsg: - return m.onRelease() + cmd = m.onMouseUp(msg.Mouse()) // the comment box can open below the screen case tea.MouseWheelMsg: m.onWheel(msg.Mouse()) case tea.PasteMsg: cmd = m.onPaste(msg.Content) case tea.KeyPressMsg: + m.click = clickChain{} + m.followCursor() return m.onKey(msg) } m.ensureVisible() return cmd } +// followCursor makes the panes keep the cursor on the screen again. A key +// or a click can move the cursor. +func (m *model) followCursor() { + m.changePane.free, m.allPane.free, m.prPane.free = false, false, false + m.changeTree.free, m.allTree.free = false, false +} + // onKey gives a key to the part of the screen that takes the keys. func (m *model) onKey(msg tea.KeyPressMsg) tea.Cmd { m.log.Debug("key", "key", msg.String(), "mode", m.mode, "scope", m.scope) + // As in herdr-reviewr, a key other than ], [, or esc drops an armed + // crossing. The keys of the prompt before a quit do not. + inQuit := m.mode == modeOverlay && m.overlay != nil && m.overlay.kind == overlayQuit + step := key.Matches(msg, m.keys.NextHunk, m.keys.PrevHunk) + if !inQuit && !step && msg.Code != tea.KeyEscape { + m.armed = 0 + } switch m.mode { case modeComment: return m.updateComment(msg) case modeOverlay: return m.updateOverlay(msg) + case modeFind: + return m.updateFind(msg) } return m.updateNormal(msg) } -// onGrep shows the code matches of the search, but only if the search -// still shows their query. -func (m *model) onGrep(msg grepMsg) { - ov := m.overlay - if ov != nil && ov.kind == overlaySearch && ov.input.Value() == msg.query { - ov.setSearch(m.allFiles, msg.hits) - } -} - -// onPR shows the pull request that loadPR read. -func (m *model) onPR(msg prMsg) { - m.pr = prState{pr: msg.pr} - if msg.err != nil { - m.pr.err = msg.err.Error() - } - m.renderPR() - if p := m.prPlace; p != nil && msg.err == nil && msg.pr != nil { // from before a restart - m.prPlace = nil - m.prPane.restore(*p) - } -} - // onPaste adds text to the comment box or to the input of the overlay. func (m *model) onPaste(text string) tea.Cmd { switch { case m.mode == modeComment: m.editor.InsertString(text) + case m.mode == modeOverlay && m.overlay != nil && m.overlay.kind == overlayGoto: + if n, ok := pastedLine(text); ok { + m.overlay.input.SetValue(n) + } case m.mode == modeOverlay && m.overlay != nil && m.overlay.hasInput(): m.overlay.input.SetValue(m.overlay.input.Value() + text) return m.inputChanged() + case m.mode == modeFind: + m.find.input.SetValue(m.find.input.Value() + text) } return nil } -// The intervals of the work of onTick, in ticks of one second. +// colorTicks is the interval of the requests for the colours of the +// terminal, in ticks of one second. +const colorTicks = 3 + +// The waits of a reload, as in herdr-reviewr. const ( - blurredTicks = 5 // a reload while the program does not have the focus - filesTicks = 10 // a read of the list of all files - colorTicks = 3 // a request for the colours of the terminal + // reloadPauseFactor sets the pause after a reload on a tick. The + // next reload waits this many times as long as the last one took. + // The pause of the watcher in vcs has the same value, but it is a + // different pause. Thus each package has its own constant. + reloadPauseFactor = 20 + retryFirst = time.Second // the first wait after a failure + retryMax = time.Minute // the longest wait after a failure ) -// onTick reloads the current tab, if it changed. +// reloadPoll is the time between two reloads without a watcher, as in +// herdr-reviewr. +const reloadPoll = 5 * time.Second + +// retryTimer tells when to try again after a failure. Each failure +// doubles the wait, from retryFirst to retryMax. +type retryTimer struct { + at time.Time // the next try, or zero + wait time.Duration // the wait after the next failure, or 0 +} + +// fail sets the next try after a failure at now. +func (r *retryTimer) fail(now time.Time) { + w := max(r.wait, retryFirst) + r.at, r.wait = now.Add(w), min(2*w, retryMax) +} + +// due reports whether a try waits and its time is at or before now. +func (r *retryTimer) due(now time.Time) bool { + return !r.at.IsZero() && !now.Before(r.at) +} + +// retry sets the wait before the next reload after the error err. Each +// failure doubles the wait, from 1 s to 60 s. A reload with no error +// clears the wait. +func (m *model) retry(err error) { + if err == nil { + m.reloadRetry = retryTimer{} + return + } + m.reloadRetry.fail(time.Now()) + m.reloadOwed = true +} + +// dropWatch drops the watcher after the error err. A tick starts it again +// after a wait. +func (m *model) dropWatch(err error) { + m.log.Warn("watch failed", "err", err) + m.watch = nil + m.watchRetry.fail(time.Now()) +} + +// startWatch starts the watcher of the repository. +func (m *model) startWatch() error { + w, err := m.repo.Watch(m.ctx) + if err != nil { + return err + } + m.watch, m.watchRetry.at = w, time.Time{} + return nil +} + +// watchChanges tells what the watcher sees. Without a watcher, it tells +// that both change every reloadPoll. After an error, it drops the watcher +// and tells that both change. +func (m *model) watchChanges() vcs.Change { + all := vcs.Change{Files: true, Refs: true} + now := time.Now() + if m.watch == nil && m.watchRetry.due(now) { + if err := m.startWatch(); err != nil { + m.dropWatch(err) + } + } + if m.watch == nil { + if now.Sub(m.polled) < reloadPoll { + return vcs.Change{} + } + m.polled = now + return all + } + m.watch.Show(m.shownPaths()) + ch, err := m.watch.Changed(m.ctx) + m.log.Debug("watch", "took", time.Since(now), "files", ch.Files, "refs", ch.Refs) + if err != nil { + m.dropWatch(err) + m.polled = now + return all + } + m.watchRetry = retryTimer{} + return ch +} + +// onTick reloads the current tab if the watcher sees a change. It +// returns the work of the tick. Update adds the next tick. func (m *model) onTick() tea.Cmd { m.ticks++ - every := 1 - if !m.focused { - every = blurredTicks - } - if m.ticks%every == 0 { - switch m.tab { - case tabChanges: - m.reloadIfChanged() - case tabFiles: - if m.ticks%filesTicks == 0 { - m.reloadFiles() // it renders the file too - } else { - m.refreshViewer() - } - } + ch := m.watchChanges() + var cmds []tea.Cmd + if ch.Files && m.pending != nil { + cmds = append(cmds, m.checkTurnLater(false)) } - if m.status != "" && time.Now().After(m.statusTill) { + m.reloadOwed = m.reloadOwed || ch.Files || ch.Refs + m.filesOwed = m.filesOwed || ch.Files + now := time.Now() + m.reloadDue() + if m.status != "" && now.After(m.statusTill) { m.status = "" } - cmds := []tea.Cmd{tick(time.Second)} + // Without a watcher, each tick reports a change of the refs. Then only + // the poll reads the pull request. + if ch.Refs && m.watch != nil { + cmds = append(cmds, m.refsChanged()) + } + if m.rex != nil && !m.sampled && m.sampleRetry.due(now) { + m.sampleRetry.at = time.Time{} // one list at a time + cmds = append(cmds, m.listAgents(agentsMsg{})) + } + if m.tab == tabPR && time.Since(m.prf.polled) >= prPoll { + cmds = append(cmds, m.requestPR(false)) + } if m.ticks%colorTicks == 0 { // follow the changes of the terminal theme cmds = append(cmds, requestColors...) } return tea.Batch(cmds...) } +// reloadDue reloads the current tab if a change waits for it. On the +// Changes tab, a reload waits for the pause after the last one, and for +// the wait after a failure. As in herdr-reviewr, the view does not change +// while the comment box is open. +func (m *model) reloadDue() { + if m.mode == modeComment { + return + } + now := time.Now() + switch { + case m.tab == tabChanges && m.reloadOwed && !m.loads.running && + !now.Before(m.reloadAt) && !now.Before(m.reloadRetry.at): + m.reloadOwed = false + m.reloadIfChanged() // onChanges sets reloadAt after the pause + case m.tab == tabFiles && m.filesOwed: + m.reloadFiles() // reloadFiles renders the file too + case m.tab == tabFiles && m.watch == nil: + m.refreshViewer() // without a watcher, each tick reads the file + } +} + // cur returns the pane of the current tab. func (m *model) cur() *pane { switch m.tab { case tabFiles: - return &m.viewer + return &m.allPane case tabPR: return &m.prPane } - return &m.changes -} - -// onPalette takes the red or the green from an OSC 4 reply. -func (m *model) onPalette(reply string) { - i, c, ok := paletteReply(reply) - if !ok { - return - } - switch { - case i == 1 && !sameColor(m.red, c): - m.red = c - case i == 2 && !sameColor(m.green, c): - m.green = c - default: - return - } - m.rerender() + return &m.changePane } // sameColor reports whether a and b are the same colour. Nil is the same diff --git a/tui/mouse.go b/tui/mouse.go index 5c80208..ea2d547 100644 --- a/tui/mouse.go +++ b/tui/mouse.go @@ -1,14 +1,19 @@ package tui // The mouse works as in herdr-reviewr. Click or drag on the line numbers to -// comment on a line or a range. Drag on the code to copy it. Click a file -// or a tab to open it. +// comment on a line or a range. Drag on the code to copy it. Double-click +// to copy a word, and triple-click to copy a line. Click a file or a tab to +// open it. Double-click a file to copy its path. Click a link to open it. import ( "strings" + "time" + "unicode" + "unicode/utf8" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" + uv "github.com/charmbracelet/ultraviolet" "github.com/charmbracelet/x/ansi" "github.com/rednafi/rexdiff/diff" @@ -19,6 +24,7 @@ func (m *model) onClick(mouse tea.Mouse) tea.Cmd { if mouse.Button != tea.MouseLeft { return nil } + m.armed = 0 // as a key does if m.mode == modeOverlay && m.overlay != nil { return m.clickOverlay(mouse) } @@ -35,42 +41,93 @@ func (m *model) onClick(mouse tea.Mouse) tea.Cmd { } return nil } - h := m.bodyHeight() - y := mouse.Y - bodyY - if y > h { // the footer, under the rule at y == h - return m.install() + if y := mouse.Y - bodyY; y > m.bodyHeight() { // the footer, under the rule + return m.startUpdate() } - if y < 0 || y >= h { + // The window of the next click starts after the work of this one, for + // example a copy. A slow copy then does not break a triple click. + defer func() { m.click.at = time.Now() }() + if m.inList(mouse.X, mouse.Y) { + if y := mouse.Y - m.listY(); y >= 1 { + r := m.listStart(m.listRows()) + y - 1 + if m.countClick(mouse) >= 2 { + if path := m.rowPath(r); path != "" { + m.copyChars(path) + return nil + } + } + m.clickRow(r) + } return nil } - if m.inList(mouse.X) { - if y >= 1 { - m.focusList = true - m.pickFile(m.listStart(h) + y - 1) - } + return m.clickMain(mouse) +} + +// clickMain handles a press of the left button on the main pane. A click +// moves the cursor and starts a drag. A double or a triple click copies +// the text. +func (m *model) clickMain(mouse tea.Mouse) tea.Cmd { + h := m.mainHeight() + y := mouse.Y - m.mainY() + if y < 0 || y >= h || mouse.X < m.mainX() || mouse.X >= m.mainX()+m.mainWidth() { return nil } - _, rowAt := m.mainLines(h) + _, rowAt := m.mainLines(h, nil) if y >= len(rowAt) || rowAt[y] < 0 { return nil } p := m.cur() + x := mouse.X - m.mainX() - gutter + if n := m.countClick(mouse); n >= 2 { + if text := m.clickText(rowAt[y], x, n); text != "" { + m.copyChars(text) + return nil + } + } m.focusList = false + link := "" + if p.diff == nil { // a markdown view or the PR tab + if url := linkAt(p.lines[rowAt[y]], x); isWebURL(url) { + link = url + } + } p.cur, p.anchor = p.head(rowAt[y]), -1 - side, onNumber := p.side(mouse.X - m.mainX() - gutter) + if m.atFold() { + m.expandFold(p.rows[p.cur].Fold) + return nil + } + side, onNumber := p.side(x) m.drag = dragState{ active: true, start: p.cur, gutter: onNumber, pref: side, + row: rowAt[y], + x: x, + toRow: rowAt[y], + toX: x, + link: link, } return nil } -// inList tells if screen column x is on the file list. -func (m *model) inList(x int) bool { - lw := m.listWidth() - return lw > 0 && x >= m.listX() && x < m.listX()+lw +// linkAt returns the URL of the hyperlink at column x of line, or "". +// The markdown renderer puts a hyperlink on each link. +func linkAt(line string, x int) string { + w := ansi.StringWidth(line) + if x < 0 || x >= w { + return "" + } + buf := uv.NewScreenBuffer(w, 1) + uv.NewStyledString(line).Draw(buf, buf.Bounds()) + return buf.CellAt(x, 0).Link.URL +} + +// inList reports whether the screen cell at column x and line y is on +// the file list. +func (m *model) inList(x, y int) bool { + lw, ly := m.listWidth(), m.listY() + return lw > 0 && x >= m.listX() && x < m.listX()+lw && y >= ly && y < ly+m.listRows() } // onDrag moves the cursor with a drag. A drag past the top or the bottom @@ -80,8 +137,8 @@ func (m *model) onDrag(mouse tea.Mouse) { return } p := m.cur() - h := m.bodyHeight() - y := mouse.Y - bodyY + h := m.mainHeight() + y := mouse.Y - m.mainY() switch { case y < 0: p.top = max(p.top-1, 0) @@ -90,7 +147,7 @@ func (m *model) onDrag(mouse tea.Mouse) { p.top = min(p.top+1, max(len(p.lines)-1, 0)) p.cur = max(min(p.top+h-1, len(p.lines)-1), 0) default: - _, rowAt := m.mainLines(h) + _, rowAt := m.mainLines(h, nil) for yy := min(y, len(rowAt)-1); yy >= 0; yy-- { if rowAt[yy] >= 0 { p.cur = rowAt[yy] @@ -98,38 +155,176 @@ func (m *model) onDrag(mouse tea.Mouse) { } } } + m.drag.toRow, m.drag.toX = p.cur, mouse.X-m.mainX()-gutter p.cur = p.head(p.cur) // the drag moves by source lines m.drag.moved = p.cur != m.drag.start + if m.drag.moved || m.drag.toX != m.drag.x { + m.click = clickChain{} // a drag is not a click + } +} + +// clickWindow is the longest time between the clicks of a double or a +// triple click, as in herdr-reviewr. +const clickWindow = 400 * time.Millisecond + +// clickChain is the last press of the left button on text. +type clickChain struct { + at time.Time + x, y int + n int // the number of presses in the chain, up to 3 +} + +// countClick adds a press to the chain and returns the count. A press on +// another cell, or after [clickWindow], starts a new chain. The time of +// the chain is the end of the last press. +func (m *model) countClick(mouse tea.Mouse) int { + c, now := m.click, time.Now() + n := 1 + if c.n > 0 && c.x == mouse.X && c.y == mouse.Y && now.Sub(c.at) < clickWindow { + n = min(c.n+1, 3) + } + m.click = clickChain{at: now, x: mouse.X, y: mouse.Y, n: n} + return n +} + +// rowPath returns the path of row r of the file list, or "". +func (m *model) rowPath(r int) string { + t := m.list() + if t == nil || r < 0 || r >= len(t.rows) { + return "" + } + if row := t.rows[r]; row.File >= 0 { + return t.entries[row.File].Path + } + return t.rows[r].Dir +} + +// clickText returns the text that a click of count n copies at column x +// of line i of the main pane. A double click gives the word under x, and +// a triple click gives the source line. It returns "" off a word or on an +// empty line. +func (m *model) clickText(i, x, n int) string { + line, char, ok := m.textAt(i, x) + if !ok || n >= 3 { + return line + } + runes := []rune(line) + isWord := func(r rune) bool { return unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_' } + if char >= len(runes) || !isWord(runes[char]) { + return "" + } + s, e := char, char+1 + for s > 0 && isWord(runes[s-1]) { + s-- + } + for e < len(runes) && isWord(runes[e]) { + e++ + } + return string(runes[s:e]) +} + +// textAt returns the source line that column x of line i of the main pane +// shows, and the index of the rune under x. A pane with no source shows +// its text on the screen. +func (m *model) textAt(i, x int) (line string, char int, ok bool) { + p := m.cur() + if i < 0 || i >= len(p.lines) { + return "", 0, false + } + if p.diff == nil { + line = strings.TrimRight(ansi.Strip(p.lines[i]), " ") + c := 0 + for _, r := range line { + if c += ansi.StringWidth(string(r)); x < c { + return line, char, true + } + char++ + } + return line, char, true + } + s, n, char, ok := p.diff.Cell(i, x) + if !ok { + return "", 0, false + } + if lines := diff.Lines(m.contentFor(p.path, s)); n <= len(lines) { + return lines[n-1], char, true + } + return "", 0, false +} + +// copyChars copies text and then shows the count of its characters, as +// herdr-reviewr does. +func (m *model) copyChars(text string) { + if text != "" { + m.copyText(text, "copied "+plural(utf8.RuneCountInString(text), "char"), nil) + } } -// onRelease ends a drag. A drag on the line numbers opens the comment -// box. A drag on the code copies the lines. -func (m *model) onRelease() tea.Cmd { +// onMouseUp ends a drag at mouse. As in herdr-reviewr, a release on the +// cell of the press opens the link there. A drag on the line numbers +// opens the comment box. A drag on the code copies the text from the +// press to the pointer. +func (m *model) onMouseUp(mouse tea.Mouse) tea.Cmd { d := m.drag m.drag.active = false + if d.active && d.link != "" && m.click.x == mouse.X && m.click.y == mouse.Y { + return m.browse(d.link, "link") + } p := m.cur() // A reload during the drag can make the pane shorter than the start. if !d.active || d.start >= len(p.rows) { return nil } - switch { - case d.gutter: // a line or a range on the line numbers: comment + if d.gutter { // a drag on the line numbers opens the comment box if d.moved { p.anchor = d.start } return m.startComment(d.pref) - case d.moved: // a range on the code: copy it - lo, hi := p.span(d.start, p.cur) - out := m.sourceLines(lo, hi, d.pref) - m.flash("copied " + plural(len(out), "line")) - return tea.SetClipboard(strings.Join(out, "\n")) } + m.copyChars(m.dragText(d)) return nil } +// dragText returns the text of drag d on the code, as herdr-reviewr +// copies it. The text starts at the rune under the press and ends at the +// rune under the pointer, both included. A drag that ends on its start +// rune is a click, and gives "". +func (m *model) dragText(d dragState) string { + p := m.cur() + a, b := d.row, d.toRow // rendered markdown gives the text on the screen + if p.src == nil { + a, b = p.head(a), p.head(b) + } + _, ca, _ := m.textAt(d.row, d.x) + _, cb, _ := m.textAt(d.toRow, d.toX) + if a > b || a == b && ca > cb { + a, b, ca, cb = b, a, cb, ca + } + // A reload during the drag can make the pane shorter than the drag. + if a == b && ca == cb || b >= len(p.lines) { + return "" + } + var lines []string + if p.src != nil { + for _, l := range p.lines[a : b+1] { + lines = append(lines, strings.TrimRight(ansi.Strip(l), " ")) + } + } else { + lines = m.sourceLines(a, p.end(b), d.pref) + } + if len(lines) == 0 { + return "" + } + last := []rune(lines[len(lines)-1]) + lines[len(lines)-1] = string(last[:min(cb+1, len(last))]) + first := []rune(lines[0]) + lines[0] = string(first[min(ca, len(first)):]) + return strings.Join(lines, "\n") +} + // sourceLines returns the source lines of rows lo to hi of the current -// pane. It uses side pref if they change it. A row with no source line -// gives its text on the screen. +// pane. It picks the side of each row as [model.target] does with pref. A +// row with no source line gives its text on the screen. func (m *model) sourceLines(lo, hi int, pref diff.Side) []string { p := m.cur() var out []string @@ -158,7 +353,10 @@ func (m *model) sourceLines(lo, hi int, pref diff.Side) []string { // wheelStep is the number of rows that one step of the wheel scrolls. const wheelStep = 3 -// onWheel scrolls the overlay, the file list, or the main pane. +// onWheel scrolls the overlay, the file list, or the main pane. As in +// herdr-reviewr, the wheel moves the view of the file list, of the main +// pane, and of the preview of the search, and not the cursor. In the other +// parts of an overlay, it moves the cursor. func (m *model) onWheel(mouse tea.Mouse) { var d int switch mouse.Button { @@ -169,23 +367,30 @@ func (m *model) onWheel(mouse tea.Mouse) { default: return } - if m.mode == modeOverlay && m.overlay != nil { - m.overlay.cur = min(max(m.overlay.cur+d, 0), max(len(m.overlay.shown)-1, 0)) + if ov := m.overlay; m.mode == modeOverlay && ov != nil { + _, _, y, _ := m.overlayBox() + if pv := ov.search.preview; pv != nil && mouse.Y >= y+ov.search.previewY { + pv.scroll(wheelStep * d) + return + } + ov.move(d) return } - if m.inList(mouse.X) { - m.pickFile(m.listSel() + d) + if t := m.list(); t != nil && m.inList(mouse.X, mouse.Y) { + h := m.listRows() + top := m.listStart(h) + wheelStep*d + t.top, t.free = min(max(top, 0), max(len(t.rows)-(h-1), 0)), true return } p := m.cur() - h := m.bodyHeight() + h := m.mainHeight() p.top = min(max(p.top+wheelStep*d, 0), max(len(p.lines)-h, 0)) // The wheel moves the view and not the cursor. p.free = true } // clickOverlay handles a click while an overlay is open. A click outside -// the box closes it, but not the prompt before a quit. +// the box closes the overlay. The prompt before a quit does not close. func (m *model) clickOverlay(mouse tea.Mouse) tea.Cmd { box, x, y, listY := m.overlayBox() w, h := lipgloss.Width(box), lipgloss.Height(box) @@ -197,7 +402,8 @@ func (m *model) clickOverlay(mouse tea.Mouse) tea.Cmd { return nil } idx := mouse.Y - y - listY - if idx < 0 || idx >= len(m.overlay.shown) { + if idx < m.overlay.rowsFrom || idx >= min(len(m.overlay.shown), m.overlay.rowsTo) || + !m.overlay.pickable(idx) { return nil } if idx == m.overlay.cur { // a click on the selected row picks it @@ -220,7 +426,7 @@ func (m *model) clickHeader(a headerAction) tea.Cmd { m.setScope(s) } case a == headerBase: - m.openBasePicker() + return m.openBasePicker() case a == headerCommits: m.openCommitPicker() case a == headerAgent: diff --git a/tui/nav.go b/tui/nav.go index c6c30e7..b29f82b 100644 --- a/tui/nav.go +++ b/tui/nav.go @@ -1,45 +1,34 @@ package tui import ( - "fmt" + "hash/fnv" + "math" "slices" "strconv" "strings" "charm.land/bubbles/v2/key" tea "charm.land/bubbletea/v2" - "github.com/charmbracelet/x/ansi" "github.com/rednafi/rexdiff/diff" "github.com/rednafi/rexdiff/vcs" ) -// Steps of the keys that move or resize, in columns. +// These are the steps of the keys that move or resize. const ( - scrollStep = 8 // the sideways scroll - listWidthStep = 4 // the width of the file list + scrollStep = 8 // the sideways scroll, in columns + // listStep resizes the file list. It is in columns at a side, and in + // percent of the body rows above or below. + listStep = 4 ) -// updateNormal handles a key when no box is open. +// updateNormal handles a key when no box is open. As in herdr-reviewr, +// the key of QuitDiscard does nothing here. It only answers the prompt +// before a quit. func (m *model) updateNormal(msg tea.KeyPressMsg) tea.Cmd { k := m.keys - if m.find.active() { - switch { - case key.Matches(msg, k.FindNext): - m.stepFind(1) - return nil - case key.Matches(msg, k.FindPrev): - m.stepFind(-1) - return nil - case key.Matches(msg, k.Cancel): - m.find = findState{} - return nil - } - } var cmd tea.Cmd switch { - case key.Matches(msg, k.QuitDiscard): - return tea.Quit case key.Matches(msg, k.Quit): if m.comments.Len() > 0 { m.openConfirmQuit() @@ -47,8 +36,7 @@ func (m *model) updateNormal(msg tea.KeyPressMsg) tea.Cmd { } return tea.Quit case key.Matches(msg, k.Cancel): - m.cur().anchor = -1 - m.help.ShowAll = false + m.escape() case key.Matches(msg, k.Keys): m.help.ShowAll = !m.help.ShowAll default: @@ -58,6 +46,22 @@ func (m *model) updateNormal(msg tea.KeyPressMsg) tea.Cmd { return cmd } +// escape drops one layer with each press, as in herdr-reviewr: the +// selection, then the armed crossing, then the list of all keys. +func (m *model) escape() { + if p := m.cur(); m.tab != tabPR { + switch { + case p.anchor >= 0: + p.anchor = -1 + return + case m.armed != 0: + m.armed = 0 + return + } + } + m.help.ShowAll = false +} + // actionKey gives a key to the handlers of the actions, by topic. The // first handler that takes the key wins, as in one switch. func (m *model) actionKey(msg tea.KeyPressMsg) tea.Cmd { @@ -76,13 +80,17 @@ func (m *model) actionKey(msg tea.KeyPressMsg) tea.Cmd { } // scopeKey handles a key that changes the tab or the scope. It reports -// whether it took the key. +// whether it takes the key. As in herdr-reviewr, the keys of the scopes +// and of the pickers do nothing on the PR tab. func (m *model) scopeKey(msg tea.KeyPressMsg) (tea.Cmd, bool) { k := m.keys + onPR := m.tab == tabPR && key.Matches(msg, k.ScopeUncommitted, k.ScopeBranch, + k.ScopeLastTurn, k.ScopeCommits, k.BasePick, k.CommitPick) switch { + case onPR: case key.Matches(msg, k.TabChanges): m.setTab(tabChanges) - case key.Matches(msg, k.TabFiles): + case key.Matches(msg, k.TabAllFiles): m.setTab(tabFiles) case key.Matches(msg, k.TabPR): m.setTab(tabPR) @@ -90,14 +98,14 @@ func (m *model) scopeKey(msg tea.KeyPressMsg) (tea.Cmd, bool) { m.setScope(ScopeUncommitted) case key.Matches(msg, k.ScopeBranch): m.setScope(ScopeBranch) - case key.Matches(msg, k.ScopeTurn): + case key.Matches(msg, k.ScopeLastTurn): m.setScope(ScopeLastTurn) case key.Matches(msg, k.ScopeCommits) && m.to == "": m.openCommitPicker() case key.Matches(msg, k.ScopeCommits): m.setScope(scopeCommits) case key.Matches(msg, k.BasePick): - m.openBasePicker() + return m.openBasePicker(), true case key.Matches(msg, k.CommitPick): m.openCommitPicker() default: @@ -108,53 +116,64 @@ func (m *model) scopeKey(msg tea.KeyPressMsg) (tea.Cmd, bool) { // moveKey handles a key that moves the cursor, the file selection, or // the sideways scroll. The wrap is here because it ends the sideways -// scroll. moveKey reports whether it took the key. +// scroll. moveKey reports whether it takes the key. func (m *model) moveKey(msg tea.KeyPressMsg) (tea.Cmd, bool) { k, p := m.keys, m.cur() last := max(len(p.lines)-1, 0) inList := m.focusList && m.tab != tabPR switch { + case key.Matches(msg, k.Focus) && m.listHiddenHere(): + // As in herdr-reviewr, tab shows a hidden list with the focus. + m.listHidden, m.focusList = false, true + m.rerender() case key.Matches(msg, k.Focus): m.focusList = !m.focusList case key.Matches(msg, k.Down) && inList: - m.pickFile(m.listSel() + 1) + m.stepRow(1) case key.Matches(msg, k.Down): p.cur = min(p.end(p.cur)+1, last) case key.Matches(msg, k.Up) && inList: - m.pickFile(m.listSel() - 1) + m.stepRow(-1) case key.Matches(msg, k.Up): p.cur = max(p.cur-1, 0) case key.Matches(msg, k.PageDown): - p.cur = min(p.cur+m.bodyHeight(), last) + p.cur = min(p.cur+m.mainHeight(), last) case key.Matches(msg, k.PageUp): - p.cur = max(p.cur-m.bodyHeight(), 0) + p.cur = max(p.cur-m.mainHeight(), 0) case key.Matches(msg, k.HalfDown): - p.cur = min(p.cur+m.bodyHeight()/2, last) + p.cur = min(p.cur+m.mainHeight()/2, last) case key.Matches(msg, k.HalfUp): - p.cur = max(p.cur-m.bodyHeight()/2, 0) - case key.Matches(msg, k.Top): - p.cur = 0 - case key.Matches(msg, k.Bottom): - p.cur = last + p.cur = max(p.cur-m.mainHeight()/2, 0) case key.Matches(msg, k.NextHunk): m.stepChange(1) case key.Matches(msg, k.PrevHunk): m.stepChange(-1) case key.Matches(msg, k.NextFile): - m.pickFile(m.listSel() + 1) + m.stepFile(1) case key.Matches(msg, k.PrevFile): - m.pickFile(m.listSel() - 1) - case key.Matches(msg, k.Right) && !m.wrap: + m.stepFile(-1) + case key.Matches(msg, k.Expand) && m.atDir(): + m.setDir(true) + case key.Matches(msg, k.Collapse) && m.atDir(): + m.setDir(false) + case key.Matches(msg, k.Expand) && m.atFold(): + m.expandFold(p.rows[p.cur].Fold) + case key.Matches(msg, k.Expand, k.Collapse) && m.tab == tabPR: + // As in herdr-reviewr, right opens all the
blocks of + // the PR tab, and left closes them. + m.prDetails = key.Matches(msg, k.Expand) + m.renderPR() + case key.Matches(msg, k.Expand) && !m.wrap: p.xoff += scrollStep m.rerender() - case key.Matches(msg, k.Left) && !m.wrap: + case key.Matches(msg, k.Collapse) && !m.wrap: p.xoff = max(p.xoff-scrollStep, 0) m.rerender() case key.Matches(msg, k.Wrap) && p.diff != nil: // The wrap key works only on a diff, because rendered markdown // has its own wrap. The sideways scroll works only without wrap. m.wrap = !m.wrap - m.changes.xoff, m.viewer.xoff = 0, 0 + m.changePane.xoff, m.allPane.xoff = 0, 0 m.rerender() default: return nil, false @@ -163,7 +182,7 @@ func (m *model) moveKey(msg tea.KeyPressMsg) (tea.Cmd, bool) { } // commentKey handles a key that adds, changes, or sends comments. It -// reports whether it took the key. +// reports whether it takes the key. func (m *model) commentKey(msg tea.KeyPressMsg) (tea.Cmd, bool) { k, p := m.keys, m.cur() switch { @@ -173,13 +192,16 @@ func (m *model) commentKey(msg tea.KeyPressMsg) (tea.Cmd, bool) { if m.target(p.cur, diff.SideNew) != nil { p.anchor = p.cur } - case key.Matches(msg, k.Comment): + case key.Matches(msg, k.Comment) && !m.focusList: + // As in herdr-reviewr, a new comment and a delete need the focus + // on the diff. return m.startComment(diff.SideNew), true case key.Matches(msg, k.Edit): return m.editOrOpen(), true - case key.Matches(msg, k.Delete): + case key.Matches(msg, k.Delete) && !m.focusList: if c := m.commentAtCursor(); c != nil { m.comments.Remove(c.ID) + m.flash("comment deleted") } case key.Matches(msg, k.NextComment): m.stepComment(1) @@ -190,7 +212,8 @@ func (m *model) commentKey(msg tea.KeyPressMsg) (tea.Cmd, bool) { case key.Matches(msg, k.Send): return m.startSend(), true case key.Matches(msg, k.Copy): - return m.copyComments(), true + m.copyComments() + return nil, true default: return nil, false } @@ -198,7 +221,7 @@ func (m *model) commentKey(msg tea.KeyPressMsg) (tea.Cmd, bool) { } // viewKey handles a key that searches, changes the view, or opens or -// reloads something. It reports whether it took the key. +// reloads something. It reports whether it takes the key. func (m *model) viewKey(msg tea.KeyPressMsg) (tea.Cmd, bool) { k := m.keys switch { @@ -206,46 +229,116 @@ func (m *model) viewKey(msg tea.KeyPressMsg) (tea.Cmd, bool) { m.loadFiles() m.openSearch() case key.Matches(msg, k.Find): - m.openInput(overlayFind, "find in file", m.find.query) + return m.openFind(), true case key.Matches(msg, k.GotoLine): m.openInput(overlayGoto, "go to line (:0 first, :$ last)", "") case key.Matches(msg, k.Rendered): - m.rendered = !m.rendered - m.rerender() + // As in herdr-reviewr, m acts only on a markdown file with text. + src, ok := m.markdownSource() + switch { + case !ok: + case m.rendersNothing(src): + m.flash("nothing here renders") + default: + m.rendered = !m.rendered + m.rerender() + } case key.Matches(msg, k.Split): - m.split = !m.split - m.rerender() - case key.Matches(msg, k.NavPosition): - m.listLeft = !m.listLeft - case key.Matches(msg, k.NavHide): - m.listHidden = !m.listHidden - m.rerender() - case key.Matches(msg, k.NavGrow): - m.listW = min(m.listW+listWidthStep, m.w/2) - m.rerender() - case key.Matches(msg, k.NavShrink): - m.listW = max(m.listW-listWidthStep, minListWidth) - m.rerender() + // As in herdr-reviewr, | acts only if the file can show in split + // at this width. Only a pane that is too narrow tells why. + shown := m.splitShown() + switch { + case shown || m.codeWidth() >= splitMinWidth && m.splitAllowed(): + m.split = !shown + m.rerender() + case m.splitAllowed(): + m.flash("too narrow for split") + } + case key.Matches(msg, k.NavigatorPosition): + m.moveList() + case key.Matches(msg, k.NavigatorHide): + m.hideList() + case key.Matches(msg, k.NavigatorGrow): + m.resizeList(listStep) + case key.Matches(msg, k.NavigatorShrink): + m.resizeList(-listStep) case key.Matches(msg, k.OpenPR): - m.openPR() + return m.openPR(), true case key.Matches(msg, k.Refresh): return m.refresh(), true - case m.release != nil && key.Matches(msg, k.Upgrade): - return m.install(), true + case m.release != nil && key.Matches(msg, k.Update): + return m.startUpdate(), true default: return nil, false } return nil, true } -// openPR opens the pull request in the browser. -func (m *model) openPR() { - if m.pr.pr == nil || m.pr.pr.URL == "" { +// listHiddenHere reports whether the user hides the file list on this +// tab. The PR tab never hides it. +func (m *model) listHiddenHere() bool { return m.listHidden && m.tab != tabPR } + +// moveList moves the file list clockwise around the main pane. A hidden +// list does not move. +func (m *model) moveList() { + if m.listHiddenHere() { return } - if err := openURL(m.cfg, m.pr.pr.URL); err != nil { - m.flash("open: " + err.Error()) + m.listPos = m.listPos.next() + m.rerender() +} + +// hideList hides the file list and gives the focus to the main pane, or +// shows the list again. The PR tab does not hide it. +func (m *model) hideList() { + if m.tab == tabPR { + return + } + m.listHidden = !m.listHidden + if m.listHidden { + m.focusList = false } + m.rerender() +} + +// resizeList makes the file list larger by d, or smaller for a negative +// d. A hidden list keeps its size. +func (m *model) resizeList(d int) { + if m.listHiddenHere() { + return + } + if m.listPos.stacked() { + m.listPct = min(max(m.listPct+d, listMinPct), listMaxStackPct) + } else { + m.listW = m.clampSide(m.sideWidth() + d) + } + m.rerender() +} + +// openPR opens the pull request in the browser, and tells it as +// herdr-reviewr does. +func (m *model) openPR() tea.Cmd { + pr := m.pr.pr + if pr == nil || pr.URL == "" { + return nil + } + kind := "PR" + if pr.Forge == vcs.ForgeGitLab { + kind = "MR" + } + return m.browse(pr.URL, kind) +} + +// browse opens url in the browser, and tells that it opened what, as +// herdr-reviewr does. +func (m *model) browse(url, what string) tea.Cmd { + wait, err := openURL(m.env, m.cfg, url) + if err != nil { + m.flash("open failed: " + err.Error()) + return nil + } + m.flash("opened " + what + " in browser") + return wait } // refresh reads the current tab again. @@ -257,18 +350,22 @@ func (m *model) refresh() tea.Cmd { if m.tab != tabPR { return nil } - m.pr.loading = true + cmd := m.requestPR(true) m.renderPR() - return m.loadPR() + return cmd } // setTab shows tab t. func (m *model) setTab(t tab) { m.tab = t - m.find, m.drag = findState{}, dragState{} + m.drag = dragState{} + // As in herdr-reviewr, the PR tab has no find band. + if t == tabPR { + m.closeFind() + } switch t { case tabFiles: - if m.allFiles == nil { + if m.allFiles == nil || m.filesOwed { m.reloadFiles() } case tabChanges: @@ -279,11 +376,80 @@ func (m *model) setTab(t tab) { m.rerender() } -// setScope shows the Changes tab in scope s. +// setScope shows the Changes tab in scope s, from its first row. func (m *model) setScope(s Scope) { - m.tab, m.scope = tabChanges, s + // The files of the last scope do not show while the new one fails. + m.tab, m.scope, m.folds, m.shownTrees = tabChanges, s, nil, "" m.reload() - m.changes.cur, m.changes.top = 0, 0 + m.changePane.toStart() + m.armed = 0 +} + +// atFold reports whether the cursor is on a fold of the Changes tab. +func (m *model) atFold() bool { + p := m.cur() + return m.tab == tabChanges && p.cur < len(p.rows) && p.rows[p.cur].Fold.Lines > 0 +} + +// openFolds has the folds that the user expands in one file, as ranges of +// old line numbers. The old side does not change while the agent edits the +// file, so an edit keeps the lines of a range open. The ranges are for the +// old content with the hash Old. A different old content, for example +// after a commit, closes the ranges. +type openFolds struct { + Old uint64 `json:"old"` + Ranges [][2]int `json:"ranges"` // the first and the last line of each range +} + +// hash returns the 64-bit FNV-1a hash of s. +func hash(s string) uint64 { + h := fnv.New64a() + _, _ = h.Write([]byte(s)) // a hash.Hash does not fail + return h.Sum64() +} + +// expanded reports whether the lines of fold f of the file at path show. +// They show if the user expands the fold, or if a comment is on one of +// them. +func (m *model) expanded(path string, comments []*diff.Comment, f diff.Fold) bool { + // A fold that overlaps a range shows. An undo of a change next to a + // range makes a larger fold, which shows as a whole. + if o := m.folds[path]; o != nil && slices.ContainsFunc(o.Ranges, func(r [2]int) bool { + return f.Old <= r[1] && f.Old+f.Lines-1 >= r[0] + }) { + return true + } + for _, c := range comments { + lo := f.New + if c.Side == diff.SideOld { + lo = f.Old + } + if c.Path == path && c.Start < lo+f.Lines && c.End >= lo { + return true + } + } + return false +} + +// expandFold shows the lines of fold f in the Changes tab, and moves the +// cursor to the first of them. The selection stays on its lines. +func (m *model) expandFold(f diff.Fold) { + p := &m.changePane + file := m.selectedChange() + if file == nil { + return + } + if m.folds == nil { + m.folds = map[string]*openFolds{} + } + o := m.folds[file.Path] + if o == nil { + o = &openFolds{Old: file.oldHash} + m.folds[file.Path] = o + } + o.Ranges = append(o.Ranges, [2]int{f.Old, f.Old + f.Lines - 1}) + m.rerender() + p.cur = max(p.rowOf(diff.SideNew, f.New), 0) } // listSel returns the index of the selected file in the file list. @@ -294,106 +460,188 @@ func (m *model) listSel() int { return m.changeSel } -// pickFile selects file i of the file list of the current tab. +// pickFile selects file i of the file list of the current tab. The +// cursor of the list goes to the row of the file, if the row shows. The +// pick drops an armed crossing. func (m *model) pickFile(i int) { + if t := m.list(); t != nil { + if r := t.rowOf(i); r >= 0 { + t.cur = r + } + } + m.armed = 0 switch m.tab { case tabChanges: - if i < 0 || i >= len(m.files) { + if i < 0 || i >= len(m.changeFiles) { return } m.changeSel = i - m.changes.toStart() + m.changePane.toStart() m.renderChanges() case tabFiles: if i < 0 || i >= len(m.allFiles) { return } m.allSel = i - m.viewer.toStart() + m.allPane.toStart() m.renderViewer() } - m.find, m.drag = findState{}, dragState{} + m.drag = dragState{} } // stepChange moves the cursor to the next (d = 1) or the previous (d = -1) -// block of changed rows. +// block of changed rows, as in herdr-reviewr. Past the last block, one +// press arms a crossing to the next file with a change in that direction. +// A second press the same way crosses, and lands on the first block in +// that direction. A selection holds the cursor. func (m *model) stepChange(d int) { - p := &m.changes - if m.tab != tabChanges || len(p.rows) == 0 { + armed := m.armed == d + m.armed = 0 + p := &m.changePane + if m.tab != tabChanges || p.anchor >= 0 { + return + } + if i := changeRow(p.rows, p.cur, d); i >= 0 { + p.cur = i + return + } + target := m.crossTarget(d) + if !armed { + if target >= 0 { + m.armed, m.armedPath = d, m.changeFiles[target].Path + } return } + // A reload can drop the armed file. + isArmed := func(f changedFile) bool { return f.Path == m.armedPath } + if i := slices.IndexFunc(m.changeFiles, isArmed); i >= 0 { + target = i + } + if target < 0 { + return + } + m.pickFile(target) + from := -1 + if d < 0 { + from = len(p.rows) + } + p.cur = max(changeRow(p.rows, from, d), 0) +} + +// changeRow returns the first row of a block of changed rows, or -1. It +// starts at row from and goes in the direction d. A d of 1 finds the next +// block, and a d of -1 finds the previous block. +func changeRow(rows []diff.Row, from, d int) int { isChange := func(i int) bool { - k := p.rows[i].Kind + k := rows[i].Kind return k == diff.KindInsert || k == diff.KindDelete || k == diff.KindChange } - for i := p.cur + d; i >= 0 && i < len(p.rows); i += d { + for i := from + d; i >= 0 && i < len(rows); i += d { if isChange(i) && (i == 0 || !isChange(i-1)) { - p.cur = i - return + return i } } - // After the last change, go to the first change of the next file. - if d > 0 && m.changeSel+1 < len(m.files) { - m.pickFile(m.changeSel + 1) - m.stepChange(1) + return -1 +} + +// crossTarget returns the index of the nearest file in direction d that +// has a changed line, or -1. It goes in the order of the rows of the file +// list, from the row of the file that shows. +func (m *model) crossTarget(d int) int { + t := &m.changeTree + from := t.rowOf(m.changeSel) + if from < 0 { + from = t.cur + } + for r := t.nextFile(from, d); r >= 0; r = t.nextFile(r, d) { + if i := t.rows[r].File; m.changeFiles[i].adds+m.changeFiles[i].dels > 0 { + return i + } } + return -1 } // stepComment moves the cursor to the next (d = 1) or previous (d = -1) -// comment, by file and line. It wraps around. +// comment of the open file, by row. It wraps around in the file. As in +// herdr-reviewr, n and N step only through the comments on the rows that +// show. func (m *model) stepComment(d int) { - cs := m.comments.All() - if len(cs) == 0 { - return - } p := m.cur() - curPath, curLine := p.path, 0 - if t := m.target(p.cur, diff.SideNew); t != nil { - curLine = t.line - } - idx := -1 - for i, c := range cs { - after := c.Path > curPath || (c.Path == curPath && c.Start > curLine) - if d > 0 && after { - idx = i - break + a := m.anchor() + var stops []int // the first row of each comment + for _, c := range m.comments.All() { + if c.Anchor != a || c.Path != p.path || p.path == "" { + continue } - if d < 0 && !after && !(c.Path == curPath && c.Start == curLine) { - idx = i + if r := p.rowOf(c.Side, c.Start); r >= 0 { + stops = append(stops, r) } } - if idx < 0 { - idx = len(cs) - 1 - if d > 0 { - idx = 0 + if len(stops) == 0 { + return + } + slices.Sort(stops) + k := max(slices.IndexFunc(stops, func(r int) bool { return r > p.cur }), 0) + if d < 0 { + k = len(stops) - 1 + if i := slices.IndexFunc(stops, func(r int) bool { return r >= p.cur }); i > 0 { + k = i - 1 } } - m.jumpTo(cs[idx].Path, cs[idx].Side, cs[idx].Start) + m.focusList = false + p.cur, p.anchor = stops[k], -1 +} + +// jumpToComment shows comment c in the tab of its anchor. +func (m *model) jumpToComment(c *diff.Comment) { + if c.Anchor.InDiff { + m.jumpTo(c.Path, c.Side, c.Start) + return + } + m.jumpToFile(c.Path, c.Side, c.Start) } // jumpTo shows path with the cursor on line. It uses the Changes tab if // the file has changes, else the Files tab. func (m *model) jumpTo(path string, s diff.Side, line int) { - inChanges := func(f changed) bool { return f.Path == path } - if i := slices.IndexFunc(m.files, inChanges); i >= 0 { + inChanges := func(f changedFile) bool { return f.Path == path } + if i := slices.IndexFunc(m.changeFiles, inChanges); i >= 0 { m.tab = tabChanges + m.revealFile(path) m.pickFile(i) - cursorToLine(&m.changes, s, line) + m.openFoldAt(s, line) + cursorToLine(&m.changePane, s, line) return } + m.jumpToFile(path, s, line) +} + +// openFoldAt opens the fold of the Changes tab that has line on side s. +func (m *model) openFoldAt(s diff.Side, line int) { + for _, r := range m.changePane.rows { + if f := r.Fold; f.Lines > 0 && line >= foldStart(f, s) && line <= foldEnd(f, s) { + m.expandFold(f) + return + } + } +} + +// jumpToFile shows path in the Files tab with the cursor on line. +func (m *model) jumpToFile(path string, s diff.Side, line int) { m.loadFiles() i := slices.IndexFunc(m.allFiles, func(e vcs.Entry) bool { return e.Path == path }) if i < 0 { - m.flash("no such file: " + diff.Quote(path)) + m.flash("no such file: " + path) return } m.tab = tabFiles + m.revealFile(path) m.pickFile(i) if s == diff.SideOld { // the Files tab shows only the new side m.flash("the removed line is not in the file now") return } - cursorToLine(&m.viewer, diff.SideNew, line) + cursorToLine(&m.allPane, diff.SideNew, line) } // cursorToLine puts the cursor of p on the row that shows line on side s. @@ -403,75 +651,116 @@ func cursorToLine(p *pane, s diff.Side, line int) { } } -// rowOf returns the first row of p that shows line on side s, or -1. +// rowOf returns the first row of p that shows line on side s, or -1. In +// rendered markdown, a line in a block is on the first row of the block, +// as in gotoLine. func (p *pane) rowOf(s diff.Side, line int) int { - return slices.IndexFunc(p.rows, func(r diff.Row) bool { return r.Line(s) == line }) -} - -// stepFind moves the cursor to the next (d = 1) or the previous (d = -1) -// hit of the find. It wraps around. -func (m *model) stepFind(d int) { - f, p := &m.find, m.cur() - // A reload can make the pane shorter than the rows that matched. - f.hits = slices.DeleteFunc(f.hits, func(h int) bool { return h >= len(p.lines) }) - if len(f.hits) == 0 { - m.find = findState{} - return + i := slices.IndexFunc(p.rows, func(r diff.Row) bool { return r.Line(s) == line }) + if i < 0 && p.src != nil && slices.ContainsFunc(p.src, func(r diff.Row) bool { return r.Line(s) == line }) { + return p.head(lineRow(p.rows, s, line)) } - f.i = (min(f.i, len(f.hits)-1) + d + len(f.hits)) % len(f.hits) - p.cur = f.hits[f.i] + return i } -// runFind finds q in the lines of the current pane, without case. It moves -// the cursor to the first hit at or below it. -func (m *model) runFind(q string) { +// gotoLine moves the cursor to the line in text, as herdr-reviewr does. +// text is digits or "$" for the last line. The line is clamped to the +// file, so "0" is the first line. The line numbers are of the new side, +// or of the old side if no row has a new line. gotoLine opens the fold +// that has the line. In rendered markdown, the cursor goes to the block +// of the line. Without line numbers, text is the row on the screen. +func (m *model) gotoLine(text string) { p := m.cur() - m.find = findState{query: q} - if q == "" { + n, ok := lineNumber(text) + if !ok || len(p.rows) == 0 { return } - lq := strings.ToLower(q) - for i, l := range p.lines { - if !strings.Contains(strings.ToLower(ansi.Strip(l)), lq) { - continue - } - // The rows of a wrapped line make one hit. - hits := m.find.hits - if h := p.head(i); len(hits) == 0 || hits[len(hits)-1] != h { - m.find.hits = append(hits, h) - } + side := diff.SideOld + if slices.ContainsFunc(p.rows, func(r diff.Row) bool { return r.New > 0 }) { + side = diff.SideNew } - if !m.find.active() { - m.flash("no match for " + q) + last := 0 + for _, r := range p.rows { + last = max(last, r.Line(side), foldEnd(r.Fold, side)) + } + if last == 0 { + p.cur = min(max(n, 1), len(p.rows)) - 1 return } - atCursor := func(h int) bool { return h >= p.cur } - m.find.i = max(slices.IndexFunc(m.find.hits, atCursor), 0) - p.cur = m.find.hits[m.find.i] + n = min(max(n, 1), last) + if m.tab == tabChanges { + m.openFoldAt(side, n) + } + p.cur = p.head(lineRow(p.rows, side, n)) } -// gotoLine moves the cursor to the line number in s: "0" is the first -// row, "$" the last row. -func (m *model) gotoLine(s string) { - p := m.cur() - switch s = strings.TrimSpace(s); s { - case "0": - p.cur = 0 - return - case "$": - p.cur = max(len(p.lines)-1, 0) - return +// lineRow returns the row of line on side. If no row has the line, it +// returns the first row with a larger line, else the last row with a +// line. +func lineRow(rows []diff.Row, side diff.Side, line int) int { + if i := slices.IndexFunc(rows, func(r diff.Row) bool { return r.Line(side) == line }); i >= 0 { + return i + } + if i := slices.IndexFunc(rows, func(r diff.Row) bool { return r.Line(side) > line }); i >= 0 { + return i + } + for i, r := range slices.Backward(rows) { + if r.Line(side) > 0 { + return i + } + } + return len(rows) - 1 +} + +// foldStart returns the first line of fold f on side. +func foldStart(f diff.Fold, side diff.Side) int { + if side == diff.SideOld { + return f.Old + } + return f.New +} + +// foldEnd returns the last line of fold f on side, or 0 for no fold. +func foldEnd(f diff.Fold, side diff.Side) int { + if f.Lines == 0 { + return 0 + } + return foldStart(f, side) + f.Lines - 1 +} + +// lineNumber returns the line in s, the text of the go-to-line prompt. "$" +// gives the largest int. ok is false if s is not digits or "$". +func lineNumber(s string) (n int, ok bool) { + if s == "$" { + return math.MaxInt, true + } + if s == "" || strings.Trim(s, "0123456789") != "" { + return 0, false } n, err := strconv.Atoi(s) if err != nil { - m.flash("not a line number: " + s) - return + n = math.MaxInt // too many digits } - for i, r := range p.rows { - if r.New == n || (r.New == 0 && r.Old == n) { - p.cur = i - return - } + return n, true +} + +// isLineKey reports whether the go-to-line prompt takes the key msg. It +// takes a digit, "$", and the keys that move in the text or delete. +func isLineKey(msg tea.KeyPressMsg) bool { + if msg.Mod&^tea.ModShift != 0 { + return false + } + switch msg.Code { + case tea.KeyBackspace, tea.KeyDelete, tea.KeyLeft, tea.KeyRight, tea.KeyHome, tea.KeyEnd: + return true } - m.flash(fmt.Sprintf("line %d isn't shown", n)) + return len(msg.Text) == 1 && strings.ContainsAny(msg.Text, "0123456789$") +} + +// pastedLine returns the line in pasted text for the go-to-line prompt. +// It removes the quotes, the brackets, and the punctuation around the +// text. ok is false if the rest is not digits or "$". +func pastedLine(text string) (line string, ok bool) { + line = strings.Trim(strings.TrimSpace(text), "`'\"“”‘’()[]<>:.,;") + _, ok = lineNumber(line) + return line, ok } diff --git a/tui/overlay.go b/tui/overlay.go index 44e68fa..857ece3 100644 --- a/tui/overlay.go +++ b/tui/overlay.go @@ -1,19 +1,19 @@ package tui import ( + "context" "fmt" - "slices" "strings" "charm.land/bubbles/v2/key" "charm.land/bubbles/v2/textinput" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" - "github.com/sahilm/fuzzy" "github.com/rednafi/rexdiff/diff" + "github.com/rednafi/rexdiff/internal/search" + "github.com/rednafi/rexdiff/internal/termtext" "github.com/rednafi/rexdiff/rex" - "github.com/rednafi/rexdiff/vcs" ) // overlayKind tells what an overlay is for. @@ -26,16 +26,14 @@ const ( overlayComments overlaySearch overlayGoto - overlayFind overlayQuit ) -// The limits of the lists in the overlays. +// These are the steps of the page keys in the commit picker, in rows, as +// in herdr-reviewr. const ( - maxFileHits = 15 // the files that match by name in the search - maxCodeHits = 60 // the code matches in the search - maxCommits = 300 // the commits in the commit picker - minGrepLen = 2 // the search finds code only for a query this long + pickerPage = 15 + pickerHalf = 8 ) // pickItem is one row of the list of an overlay. @@ -43,13 +41,16 @@ type pickItem struct { label string detail string // the dim text after the label tag string // a short mark at the end, for example "current" - // id is what the row picks: a path, a ref, a commit, or a block. + // id is what the row picks, for example a path, a ref, a commit, or a + // block. In the search, a row with no id cannot have the cursor. id string - line int // the search: the line of a code match - comment int // the comment list: the ID of the comment + line int // the search: the line of a code match + head bool // the search: the header of the code matches of a file + comment int // the comment list: the ID of the comment } -// overlay is a modal box: a list to pick from, a search, or a prompt. +// overlay is a modal box. It shows a list to pick from, a search, or a +// prompt. type overlay struct { kind overlayKind title string @@ -59,6 +60,10 @@ type overlay struct { cur int mark int // the commit picker: the item at the start of the range, or -1 agents []rex.Agent + search searchState + // rowsFrom and rowsTo are the index of the first row that shows and + // the index after the last one. + rowsFrom, rowsTo int } // newOverlay returns an overlay. If its kind has an input, the input has @@ -74,10 +79,10 @@ func newOverlay(kind overlayKind, title string) *overlay { } // hasInput reports whether the overlay has a text input. The lists of -// agents and comments, and the prompt before a quit, have none. +// agents, comments, and commits, and the prompt before a quit, have none. func (o *overlay) hasInput() bool { switch o.kind { - case overlayAgents, overlayComments, overlayQuit: + case overlayAgents, overlayComments, overlayCommits, overlayQuit: return false } return true @@ -103,9 +108,7 @@ func (o *overlay) filter() { for i, it := range o.items { labels[i] = it.label + " " + it.detail } - for _, mt := range fuzzy.Find(q, labels) { - o.shown = append(o.shown, mt.Index) - } + o.shown = append(o.shown, search.Indices(q, labels)...) } o.cur = min(o.cur, max(len(o.shown)-1, 0)) } @@ -118,40 +121,29 @@ func (o *overlay) selected() *pickItem { return &o.items[o.shown[o.cur]] } -// down and up move the cursor of the list by one row. -func (o *overlay) down() { o.cur = min(o.cur+1, max(len(o.shown)-1, 0)) } -func (o *overlay) up() { o.cur = max(o.cur-1, 0) } - -// setSearch shows the search results: the files that match by name, then -// the matches in the code. -func (o *overlay) setSearch(files []vcs.Entry, hits []vcs.Match) { - q := o.input.Value() - var items []pickItem - if q != "" { - paths := make([]string, 0, len(files)) - for _, f := range files { - if !f.Ignored { - paths = append(paths, f.Path) - } +// move moves the cursor of the list by d rows. It skips the rows that +// cannot have the cursor, and it stops at the ends. +func (o *overlay) move(d int) { + step := 1 + if d < 0 { + step = -1 + } + for ; d != 0; d -= step { + i := o.cur + step + for i >= 0 && i < len(o.shown) && !o.pickable(i) { + i += step } - for i, mt := range fuzzy.Find(q, paths) { - if i == maxFileHits { - break - } - items = append(items, pickItem{label: mt.Str, tag: "file", id: mt.Str}) + if i < 0 || i >= len(o.shown) { + return } + o.cur = i } - for _, h := range hits { - items = append(items, pickItem{ - label: fmt.Sprintf("%s:%d", h.Path, h.Line), - detail: h.Text, - tag: "code", - id: h.Path, - line: h.Line, - }) - } - o.cur = 0 - o.setItems(items) +} + +// pickable reports whether row i of the list can have the cursor. In the +// search, the headers of the files and the "… more" row cannot. +func (o *overlay) pickable(i int) bool { + return o.kind != overlaySearch || o.items[o.shown[i]].id != "" } // open shows ov and gives the keys to it. @@ -162,6 +154,7 @@ func (m *model) open(ov *overlay) { // closeOverlay closes the overlay and gives the keys back to the panes. func (m *model) closeOverlay() { m.overlay, m.mode = nil, modeNormal + m.stopGrep(fmt.Errorf("code search: the search closes: %w", context.Canceled)) } // openInput opens a prompt of kind with the text value. @@ -177,123 +170,30 @@ func (m *model) openConfirmQuit() { m.open(newOverlay(overlayQuit, title)) } -// openSearch opens the search of the files and the code. -func (m *model) openSearch() { - m.open(newOverlay(overlaySearch, "search files and code")) -} - -// openAgentPicker lets the user pick the agent to send to. The cursor -// starts on the agent of the last send, else the agent beside the pane. -func (m *model) openAgentPicker(agents []rex.Agent) { - ov := newOverlay(overlayAgents, "send to which agent?") - ov.agents = agents - var items []pickItem - armed := 0 - for i, a := range agents { - detail := a.App + " · " + string(a.State) - if a.State == rex.StateBlocked { - detail = a.App + " · at a prompt" - } - if a.Window != "" { - detail += " · " + a.Window - } - tag := "" - switch a.Block { - case m.lastSent: - tag, armed = "last used", i - case m.beside: - tag = "opened beside" - if m.lastSent == "" { - armed = i - } - } - items = append(items, pickItem{ - label: fmt.Sprintf("%d %s", i+1, a.Name), - detail: detail, - tag: tag, - id: a.Block, - }) - } - ov.setItems(items) - ov.cur = armed - m.open(ov) -} - -// openBasePicker lets the user pick the base of the branch scope. The -// current base has a tag. -func (m *model) openBasePicker() { - ov := newOverlay(overlayBase, "base branch for the branch scope (or type any ref)") - branches, _ := m.repo.Branches(m.ctx) // an error shows an empty picker - var items []pickItem - for _, b := range branches { - tag := "" - if b == m.base { - tag = "current" - } - items = append(items, pickItem{label: b, id: b, tag: tag}) - } - ov.setItems(items) - m.open(ov) -} - -// openCommitPicker lets the user pick a commit or a range of commits for -// the commits scope. It lists the newest commits first. -func (m *model) openCommitPicker() { - ov := newOverlay(overlayCommits, "pick a commit, or space to start a range") - commits, _ := m.repo.Commits(m.ctx, maxCommits) // an error shows an empty picker - var items []pickItem - for _, c := range commits { - items = append(items, pickItem{ - label: c.Short + " " + c.Subject, - detail: c.Author + ", " + c.When, - id: c.SHA, - }) - } - ov.setItems(items) - m.open(ov) -} - -// openCommentList lists the comments, to jump to one or to delete one. -func (m *model) openCommentList() { - if m.comments.Len() == 0 { - m.flash("no comments yet") - return - } - ov := newOverlay(overlayComments, "comments: enter to jump, d to delete") - m.fillCommentList(ov) - m.open(ov) -} - -// fillCommentList sets the comments as the items of ov. -func (m *model) fillCommentList(ov *overlay) { - var items []pickItem - for _, c := range m.comments.All() { - tag := "" - if c.Stale { - tag = "stale" - } - text, _, _ := strings.Cut(c.Text, "\n") - items = append(items, pickItem{ - label: c.Location(), - detail: text, - tag: tag, - comment: c.ID, - }) - } - ov.setItems(items) -} - // updateOverlay handles a key while an overlay is open. func (m *model) updateOverlay(msg tea.KeyPressMsg) tea.Cmd { ov, k := m.overlay, overlayKeys - if ov.kind == overlayQuit { + switch ov.kind { + case overlayQuit: + return m.updateQuit(msg) + case overlayComments: + return m.updateCommentList(msg) + case overlayCommits: + return m.updateCommitPicker(msg) + case overlaySearch: + pv := ov.search.preview switch { - case key.Matches(msg, k.Quit): - return tea.Quit - case key.Matches(msg, k.Stay): - m.closeOverlay() + case key.Matches(msg, k.Flip): + ov.search.code = !ov.search.code + ov.showSearch() + return nil + case key.Matches(msg, k.PreviewDown) && pv != nil: + pv.scroll(pickerPage) + return nil + case key.Matches(msg, k.PreviewUp) && pv != nil: + pv.scroll(-pickerPage) + return nil } - return nil } switch { case key.Matches(msg, k.Close): @@ -302,22 +202,23 @@ func (m *model) updateOverlay(msg tea.KeyPressMsg) tea.Cmd { case key.Matches(msg, k.Choose): return m.chooseOverlay() case key.Matches(msg, k.Up): - ov.up() + ov.move(-1) return nil case key.Matches(msg, k.Down): - ov.down() + ov.move(1) return nil } if !ov.hasInput() { return m.updateList(msg) } - if ov.kind == overlayCommits && key.Matches(msg, k.Mark) { - if ov.mark >= 0 { - ov.mark = -1 - } else if ov.cur < len(ov.shown) { - ov.mark = ov.shown[ov.cur] + if ov.kind == overlayGoto { + if !isLineKey(msg) { + return nil + } + // "$" stands alone, and a digit after "$" starts a number. + if msg.Text == "$" || (msg.Text != "" && ov.input.Value() == "$") { + ov.input.SetValue("") } - return nil } var cmd tea.Cmd before := ov.input.Value() @@ -328,29 +229,42 @@ func (m *model) updateOverlay(msg tea.KeyPressMsg) tea.Cmd { return cmd } +// updateQuit handles a key in the prompt before a quit, as herdr-reviewr +// does. The key of QuitDiscard quits. The key of Quit does nothing, so a +// held q does not quit. The keys of Send and Copy close the prompt and +// send or copy. Any other key closes the prompt. +func (m *model) updateQuit(msg tea.KeyPressMsg) tea.Cmd { + k := m.keys + switch { + case key.Matches(msg, k.QuitDiscard): + return tea.Quit + case key.Matches(msg, k.Quit): + return nil + } + m.closeOverlay() + switch { + case key.Matches(msg, k.Send): + return m.startSend() + case key.Matches(msg, k.Copy): + m.copyComments() + } + return nil +} + // updateList handles a key in an overlay that has no input. In the agent -// picker, the digits 1 to 9 pick an agent. +// picker, the digits 1 to 9 move the cursor to an agent, as in +// herdr-reviewr. Only enter sends. func (m *model) updateList(msg tea.KeyPressMsg) tea.Cmd { ov, k, s := m.overlay, overlayKeys, msg.String() digit := len(s) == 1 && s[0] >= '1' && s[0] <= '9' switch { case key.Matches(msg, k.ListDown): - ov.down() + ov.move(1) case key.Matches(msg, k.ListUp): - ov.up() - case ov.kind == overlayComments && key.Matches(msg, k.Delete): - if it := ov.selected(); it != nil { - m.comments.Remove(it.comment) - if m.comments.Len() == 0 { - m.closeOverlay() - return nil - } - m.fillCommentList(ov) - } + ov.move(-1) case ov.kind == overlayAgents && digit: if i := int(s[0] - '1'); i < len(ov.shown) { ov.cur = i - return m.chooseOverlay() } } return nil @@ -361,17 +275,8 @@ func (m *model) inputChanged() tea.Cmd { ov := m.overlay switch ov.kind { case overlaySearch: - q := ov.input.Value() - ov.setSearch(m.allFiles, nil) - if len(q) < minGrepLen { - return nil - } - repo, ctx := m.repo, m.ctx - return func() tea.Msg { - hits, _ := repo.Grep(ctx, q, maxCodeHits) - return grepMsg{q, hits} - } - case overlayBase, overlayCommits, overlayComments, overlayAgents: + return m.runSearch() + case overlayBase: ov.filter() } return nil @@ -385,12 +290,10 @@ func (m *model) chooseOverlay() tea.Cmd { case overlayGoto: m.closeOverlay() m.gotoLine(ov.input.Value()) - case overlayFind: - m.closeOverlay() - m.runFind(ov.input.Value()) case overlaySearch: m.closeOverlay() - if it != nil { + if it != nil && it.id != "" { + m.addRecent(it.id) m.jumpTo(it.id, diff.SideNew, max(it.line, 1)) } case overlayBase: @@ -400,6 +303,10 @@ func (m *model) chooseOverlay() tea.Cmd { m.flash("no such ref: " + ref) return nil } + if err := m.repo.SaveBase(m.ctx, ref); err != nil { + m.flash("base save failed: " + err.Error()) + return nil + } m.base = ref m.setScope(ScopeBranch) case overlayCommits: @@ -415,7 +322,7 @@ func (m *model) chooseOverlay() tea.Cmd { break } if c := m.comments.Find(it.comment); c != nil { - m.jumpTo(c.Path, c.Side, c.Start) + m.jumpToComment(c) } case overlayAgents: m.closeOverlay() @@ -427,65 +334,40 @@ func (m *model) chooseOverlay() tea.Cmd { return nil } -// commitRange returns the oldest and the newest commit that the commit -// picker picks with item it. That is one commit, or the range from the -// mark to it. The list shows the newest commit first. -func (o *overlay) commitRange(it *pickItem) (oldest, newest string) { - if o.mark < 0 { - return it.id, it.id - } - a, b := o.mark, o.shown[o.cur] - return o.items[max(a, b)].id, o.items[min(a, b)].id -} - -// agent returns the agent of item it of the agent picker. If it is nil, -// ok is false. -func (o *overlay) agent(it *pickItem) (a rex.Agent, ok bool) { - if it == nil { - return rex.Agent{}, false - } - i := slices.IndexFunc(o.agents, func(a rex.Agent) bool { return a.Block == it.id }) - if i < 0 { - return rex.Agent{}, false - } - return o.agents[i], true -} - -// baseRef returns the ref that the base picker picks, with the selected -// item it. A typed ref, for example a tag, wins over the first match. A -// match that the user moved to wins over the typed text. If the ref is -// not valid, ok is false. -func (m *model) baseRef(it *pickItem) (ref string, ok bool) { - ref = strings.TrimSpace(m.overlay.input.Value()) - _, err := m.repo.Resolve(m.ctx, ref) - if it != nil && (ref == "" || err != nil || m.overlay.cur > 0) { - ref = it.id - _, err = m.repo.Resolve(m.ctx, ref) - } - return ref, ref != "" && err == nil -} - // overlayBox renders the box of the overlay, and returns its position. // Row i of the list shows at line listY+i of the box, for mouse hits. func (m *model) overlayBox() (box string, x, y, listY int) { ov := m.overlay w := min(max(m.w*2/3, 50), m.w-4) + if ov.kind == overlaySearch { + w = m.w - 4 + } inner := w - 4 - lines := []string{truncate(headerStyle.Render(ov.title), inner)} // one line, for listY - if ov.kind == overlayQuit { - lines = append(lines, "", dimStyle.Render("q/enter quit · esc stay")) - } else { + // The title takes one line, for listY. + lines := []string{truncate(headerStyle.Render(ov.title), inner)} + switch ov.kind { + case overlayQuit: + lines = append(lines, "", dimStyle.Render(m.quitHint())) + case overlaySearch: + lines, listY = m.searchLines(inner) + default: if ov.hasInput() { ov.input.SetWidth(inner - 2) lines = append(lines, ov.input.View()) } - rows, start := m.overlayRows(inner) + rows, start := m.overlayRows(inner, m.h-10) listY = len(lines) + 1 - start // 1 for the border lines = append(lines, rows...) + switch ov.kind { + case overlayComments: + lines = append(lines, "", truncate(dimStyle.Render(m.listHint()), inner)) + case overlayCommits: + lines = append(lines, "", truncate(dimStyle.Render(m.commitHint()), inner)) + } } box = lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("8")). + BorderForeground(dimColor). Padding(0, 1). Width(w). Render(strings.Join(lines, "\n")) @@ -494,14 +376,31 @@ func (m *model) overlayBox() (box string, x, y, listY int) { return box, x, y, listY } -// overlayRows renders the rows of the list of the overlay that fit the -// screen, inner columns wide. start is the index of the first row. -func (m *model) overlayRows(inner int) (rows []string, start int) { +// quitHint returns the keys of the prompt before a quit, as the footer +// of herdr-reviewr shows them. +func (m *model) quitHint() string { + k, n := m.keys, m.comments.Len() + return fmt.Sprintf("%s quit (%d pending) · esc cancel · %s send %d · %s copy", + firstKey(k.QuitDiscard), n, firstKey(k.Send), n, firstKey(k.Copy)) +} + +// overlayRows renders at most limit rows of the list of the overlay, +// inner columns wide. start is the index of the first row. +func (m *model) overlayRows(inner, limit int) (rows []string, start int) { ov := m.overlay - maxRows := min(len(ov.shown), max(m.h-10, 1)) + maxRows := min(len(ov.shown), max(limit, 1)) start = max(0, ov.cur-maxRows+1) + ov.rowsFrom, ov.rowsTo = start, start+maxRows for i := start; i < len(ov.shown) && i < start+maxRows; i++ { it := ov.items[ov.shown[i]] + if !ov.pickable(i) { + style := dimStyle + if it.head { + style = headerStyle + } + rows = append(rows, " "+truncate(style.Render(termtext.Quote(it.label)), inner-2)) + continue + } tag := "" if it.tag != "" { tag = " " + cursorStyle.Render(it.tag) @@ -509,7 +408,7 @@ func (m *model) overlayRows(inner int) (rows []string, start int) { if ov.kind == overlayCommits && ov.mark == ov.shown[i] { tag += " " + cursorStyle.Render("range start") } - text := diff.Quote(it.label) + " " + dimStyle.Render(diff.Quote(it.detail)) + tag + text := termtext.Quote(it.label) + " " + dimStyle.Render(termtext.Quote(it.detail)) + tag row := truncate(text, inner-2) if i == ov.cur { row = m.selStyle().Width(inner).Render("▶ " + row) @@ -518,8 +417,19 @@ func (m *model) overlayRows(inner int) (rows []string, start int) { } rows = append(rows, row) } - if len(ov.shown) == 0 && ov.kind != overlayGoto && ov.kind != overlayFind { - rows = append(rows, dimStyle.Render(" nothing matches")) + switch { + case ov.search.err != "": + rows = append(rows, delStyle.Render(" "+termtext.Quote(ov.search.err))) + case len(ov.shown) == 0 && ov.kind == overlayCommits: + rows = append(rows, dimStyle.Render(" no commits yet")) + case len(ov.shown) == 0 && ov.kind == overlaySearch: + // As in herdr-reviewr, an empty query finds no code, and that is + // not a miss. + if !ov.search.code || ov.input.Value() != "" { + rows = append(rows, dimStyle.Render(" no matches")) + } + case len(ov.shown) == 0 && ov.kind != overlayGoto: + rows = append(rows, dimStyle.Render(" no matches")) } return rows, start } diff --git a/tui/palette.go b/tui/palette.go index 0813033..c81503f 100644 --- a/tui/palette.go +++ b/tui/palette.go @@ -38,3 +38,20 @@ func paletteReply(s string) (i int, c color.Color, ok bool) { } return i, c, true } + +// onPalette takes the red or the green from an OSC 4 reply. +func (m *model) onPalette(reply string) { + i, c, ok := paletteReply(reply) + if !ok { + return + } + switch { + case i == 1 && !sameColor(m.red, c): + m.red = c + case i == 2 && !sameColor(m.green, c): + m.green = c + default: + return + } + m.rerender() +} diff --git a/tui/pr.go b/tui/pr.go index b4d7b7b..e43ee61 100644 --- a/tui/pr.go +++ b/tui/pr.go @@ -1,11 +1,13 @@ package tui -// The PR tab shows the pull request of the branch. The tab cannot change -// the pull request. - import ( + "context" + "errors" "fmt" + "regexp" + "slices" "strings" + "time" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" @@ -14,55 +16,110 @@ import ( "github.com/rednafi/rexdiff/vcs" ) -// loadPR returns a command that reads the pull request of the branch. +// The PR tab shows the pull request of the branch. The tab cannot change +// the pull request. + +// The times of the reads of the pull request, as in herdr-reviewr. +const ( + // prPoll is the time between two reads while the PR tab shows. A + // change on the forge has no local sign. + prPoll = time.Minute + // prTimeout is the longest time of a read. + prTimeout = time.Minute +) + +// prFetch follows the reads of the pull request. +type prFetch struct { + gen int // the read whose result shows. Other reads are stale. + inFlight bool // the read gen runs + polled time.Time // the last request, for prPoll + cancel context.CancelCauseFunc + // trailing is true if an ambient request comes while a read runs. + // Then one more read follows that read. + trailing bool + branch string // the branch at the last change of the refs + seen bool // the watcher reports the refs once +} + +// requestPR asks for a read of the pull request. A forced request starts +// a read at once. An ambient request during a read starts one more read +// after it. A read that hangs ends after prTimeout. +func (m *model) requestPR(forced bool) tea.Cmd { + f := &m.prf + f.polled = time.Now() + if !forced && f.inFlight { + f.trailing = true + return nil + } + return m.loadPR() +} + +// loadPR stops the read that runs, and returns a command that reads the +// pull request of the branch. func (m *model) loadPR() tea.Cmd { - repo, ctx := m.repo, m.ctx + f := &m.prf + if f.cancel != nil { + f.cancel(fmt.Errorf("pull request read: a newer read replaces it: %w", context.Canceled)) + } + ctx, cancel := context.WithCancelCause(m.ctx) + ctx, cancelTimeout := context.WithTimeoutCause(ctx, prTimeout, + fmt.Errorf("pull request read: %v timeout: %w", prTimeout, context.DeadlineExceeded)) + f.gen++ + f.inFlight, f.trailing, f.cancel = true, false, cancel + repo, gen := m.repo, f.gen hosts := vcs.Hosts{ GitHub: m.cfg.GitHubHost, GitLab: m.cfg.GitLabHost, Azure: m.cfg.AzureHost, } return func() tea.Msg { + defer cancelTimeout() + defer cancel(fmt.Errorf("pull request read: done: %w", context.Canceled)) pr, err := repo.PullRequest(ctx, hosts) - if pr != nil { - cleanPR(pr) - } - return prMsg{pr, err} + return prMsg{gen: gen, pr: pr, err: err} } } -// cleanPR removes the control characters from the text of pr, because the -// text comes from other users. -func cleanPR(pr *vcs.PullRequest) { - texts := []*string{&pr.Title, &pr.URL, &pr.Author, &pr.Head, &pr.Base, &pr.Body} - for i := range pr.Checks { - texts = append(texts, &pr.Checks[i].Name) +// refsChanged reads the pull request again after a change of the refs. +// After a switch to another branch, the PR tab is blank until the read +// ends. A detached HEAD keeps the branch, so a rebase is not a switch. +func (m *model) refsChanged() tea.Cmd { + f := &m.prf + b, err := m.repo.Branch(m.ctx) + if err != nil || b == "HEAD" { + b = f.branch } - for i := range pr.Comments { - c := &pr.Comments[i] - texts = append(texts, &c.Author, &c.When, &c.Body) + old := f.branch + f.branch = b + if !f.seen { // the first report is the start, and Init reads the PR + f.seen = true + return nil } - for _, s := range texts { - *s = diff.Plain(*s) + if old != "" && b != old { + m.pr = prState{} + cmd := m.requestPR(true) + m.renderPR() + return cmd } + return m.requestPR(false) } // prStateStyle returns the style that shows the state of a pull request. -func prStateStyle(s vcs.State) lipgloss.Style { +func prStateStyle(s vcs.PRState) lipgloss.Style { switch s { - case vcs.StateOpen: + case vcs.PROpen: return addStyle - case vcs.StateMerged: + case vcs.PRMerged: return headerStyle - case vcs.StateClosed: + case vcs.PRClosed: return delStyle } return dimStyle } -// checkSummary counts the checks that passed, failed, or are pending, and -// returns the names of the checks that failed. A skipped check counts as -// passed. +// checkSummary counts the checks that pass, fail, or are pending, and +// returns the names of the checks that fail. A skipped check counts as a +// pass. func checkSummary(checks []vcs.Check) (pass, fail, pend int, failed []string) { for _, c := range checks { switch c.Result { @@ -89,8 +146,8 @@ func (m *model) renderPR() { p.set(nil, nil) return case pr == nil: - if !m.pr.loading { - p.note = vcs.ErrNotFound.Error() + if !m.prf.inFlight { + p.note = vcs.ErrNoPullRequest.Error() } p.set(nil, nil) return @@ -100,19 +157,71 @@ func (m *model) renderPR() { lines = append(lines, checkLines(pr.Checks)...) lines = append(lines, "") if strings.TrimSpace(pr.Body) != "" { - lines = append(lines, renderMarkdown(md, pr.Body)...) + lines = append(lines, renderMarkdown(md, m.details(pr.Body))...) } + now := time.Now() for _, c := range pr.Comments { - author := headerStyle.Render(c.Author) + " " + dimStyle.Render(c.When) + author := headerStyle.Render(c.Author) + " " + dimStyle.Render(age(c.When, now)) lines = append(lines, "", " "+author) - lines = append(lines, renderMarkdown(md, c.Body)...) + lines = append(lines, renderMarkdown(md, m.details(c.Body))...) } p.set(lines, make([]diff.Row, len(lines))) } +// detailsRE matches a
block with its summary and its body. +// detailsOpenRE matches the start of a block. +var ( + detailsRE = regexp.MustCompile( + `(?is)\A]*>\s*]*>(.*?)(.*?)
`) + detailsOpenRE = regexp.MustCompile(`(?i)]*>`) +) + +// details returns src with each
block as its summary line, as +// herdr-reviewr shows it. The line starts with ▸ for a closed block, and +// with ▾ for an open block. The body of an open block follows the line. +// The last block is done first, so an inner block is done before the +// block that holds it. +func (m *model) details(src string) string { + for _, o := range slices.Backward(detailsOpenRE.FindAllStringIndex(src, -1)) { + b := src[o[0]:] + sub := detailsRE.FindStringSubmatchIndex(b) + if sub == nil { + continue + } + summary := strings.Join(strings.Fields(b[sub[2]:sub[3]]), " ") + block := "\n\n▸ " + summary + "\n\n" + if m.prDetails { + block = "\n\n▾ " + summary + "\n\n" + b[sub[4]:sub[5]] + "\n\n" + } + src = src[:o[0]] + block + b[sub[1]:] + } + return src +} + +// age returns the time from when to now, as herdr-reviewr shows it, for +// example "5m" or "3d". when is an RFC 3339 time. A bad time gives "". A +// time after now gives "0s". +func age(when string, now time.Time) string { + t, err := time.Parse(time.RFC3339, when) + if err != nil { + return "" + } + const day = 24 * time.Hour + d := max(now.Sub(t), 0) + for _, u := range []struct { + size time.Duration + name string + }{{365 * day, "y"}, {7 * day, "w"}, {day, "d"}, {time.Hour, "h"}, {time.Minute, "m"}} { + if d >= u.size { + return fmt.Sprintf("%d%s", int64(d/u.size), u.name) + } + } + return fmt.Sprintf("%ds", int64(d/time.Second)) +} + // prHeader returns the title line and the state line of pr. func prHeader(pr *vcs.PullRequest) []string { - title := lipgloss.NewStyle().Bold(true).Render(pr.Title) + title := boldStyle.Render(pr.Title) number := dimStyle.Render(fmt.Sprintf("#%d", pr.Number)) state := prStateStyle(pr.State).Render("● " + string(pr.State)) return []string{ @@ -129,7 +238,7 @@ func prHeader(pr *vcs.PullRequest) []string { } // checkLines returns the counts of the checks, and the names of the -// checks that failed. With no checks, it returns no lines. +// checks that fail. With no checks, it returns no lines. func checkLines(checks []vcs.Check) []string { if len(checks) == 0 { return nil @@ -146,3 +255,36 @@ func checkLines(checks []vcs.Check) []string { } return lines } + +// onPR shows the pull request that loadPR reads, unless a later read +// replaces it. After a read with a trailing request, onPR starts the next +// read. +func (m *model) onPR(msg prMsg) tea.Cmd { + f := &m.prf + if msg.gen != f.gen { + return nil + } + f.inFlight, f.cancel = false, nil + var next tea.Cmd + if f.trailing { + next = m.loadPR() + } + // As in herdr-reviewr, a failed read keeps the last good pull request. + // The end of the pull request is not a failure. + if msg.err != nil && m.pr.pr != nil && !errors.Is(msg.err, vcs.ErrNoPullRequest) { + m.flash("refresh failed: " + msg.err.Error()) + m.renderPR() + return next + } + m.pr = prState{pr: msg.pr} + if msg.err != nil { + m.pr.err = msg.err.Error() + } + m.renderPR() + // prPlace is from before a restart. + if p := m.prPlace; p != nil && msg.err == nil && msg.pr != nil { + m.prPlace = nil + m.prPane.restore(*p) + } + return next +} diff --git a/tui/preview.go b/tui/preview.go new file mode 100644 index 0000000..ca4b9af --- /dev/null +++ b/tui/preview.go @@ -0,0 +1,146 @@ +package tui + +import ( + "slices" + "strings" + + "charm.land/lipgloss/v2" + + "github.com/rednafi/rexdiff/diff" +) + +// searchShare is the percent of the space of the search that the results +// take. The preview takes the rest. The value comes from herdr-reviewr. +const searchShare = 50 + +// preview is the file of the selected result of the search. +type preview struct { + path string + line int // the line of the code match, or 0 for a file + width int + lines []string // the file with line numbers, or one note + hit int // the index in lines of the line of the match, or -1 + top int // the first line that shows + // center is true until the first draw. That draw puts the match in + // the middle. + center bool +} + +// searchLines renders the search, inner columns wide, as herdr-reviewr +// lays it out. The first line has the input and the chips. Below it are +// the results, a rule with the name of the file, and the preview of the +// file. Row i of the list shows at line listY+i of the box. +func (m *model) searchLines(inner int) (lines []string, listY int) { + ov := m.overlay + lines = []string{ov.searchBand(inner)} + // avail is the height of the results and the preview. It leaves out + // the box, the band, and the rule of the preview. + avail := max(m.h-4, 6) - 2 + results := splitAxis(avail, searchShare) + lines = append(lines, paneRule("results", inner)) + rows, start := m.overlayRows(inner, results-1) + listY = len(lines) + 1 - start // 1 for the border + lines = append(lines, rows[:min(len(rows), results-1)]...) + for len(lines) < results+1 { + lines = append(lines, "") + } + pv := m.searchPreview(inner) + h := avail - results + ov.search.previewY = len(lines) + 1 // 1 for the border + if pv == nil { + lines = append(lines, paneRule("preview", inner), dimStyle.Render(" no preview")) + } else { + lines = append(lines, paneRule("preview · "+pv.path, inner)) + lines = append(lines, pv.show(h)...) + } + for len(lines) < avail+2 { + lines = append(lines, "") + } + return lines, listY +} + +// splitAxis returns the part of n that pct percent takes, as +// herdr-reviewr splits a pane. Each part gets at least 3 if n has room. +func splitAxis(n, pct int) int { + if n < 6 { + return n / 2 + } + return min(max(n*pct/100, 3), n-3) +} + +// paneRule returns a rule "─ label ───" with label, w columns wide. +func paneRule(label string, w int) string { + label = " " + label + " " + rest := max(w-lipgloss.Width(label)-1, 0) + return truncate(dimStyle.Render("─")+label+dimStyle.Render(strings.Repeat("─", rest)), w) +} + +// searchBand returns the first line of the search, inner columns wide. +// It has the input, and the chips at the right end. +func (o *overlay) searchBand(inner int) string { + chips := o.chips() + o.input.SetWidth(max(inner-lipgloss.Width(chips)-4, 1)) + in := o.input.View() + pad := max(inner-lipgloss.Width(in)-lipgloss.Width(chips), 1) + return truncate(in+strings.Repeat(" ", pad)+chips, inner) +} + +// searchPreview returns the preview of the selected result of the search, +// width columns wide, or nil if no result is selected. It reads the file +// again only for another result or another width. +func (m *model) searchPreview(width int) *preview { + s := &m.overlay.search + it := m.overlay.selected() + if it == nil || it.id == "" { + s.preview = nil + return nil + } + if pv := s.preview; pv != nil && pv.path == it.id && pv.line == it.line && pv.width == width { + return pv + } + pv := &preview{path: it.id, line: it.line, width: width, hit: -1, center: true} + content, err := m.repo.ReadFile(it.id) + note := fileNote(err) + switch { + case note != "": + pv.lines = []string{dimStyle.Render(" " + note)} + case err != nil || content == "": + pv.lines = []string{dimStyle.Render(" no preview")} + default: + o := m.diffOptions(0) + o.Width, o.Wrap = width-2, false // 2 for the mark of the match + d := diff.RenderFile(it.id, content, o) + pv.lines = d.Lines + if it.line > 0 { + pv.hit = slices.IndexFunc(d.Rows, func(r diff.Row) bool { + return r.New == it.line && !r.Continued + }) + } + } + s.preview = pv + return pv +} + +// show returns the h lines of pv that show. The line of the match has a +// mark. At the first draw, the match is in the middle. +func (pv *preview) show(h int) []string { + if pv.center { + pv.top, pv.center = max(pv.hit-h/2, 0), false + } + pv.top = max(min(pv.top, len(pv.lines)-h), 0) + var out []string + for i := pv.top; i < len(pv.lines) && len(out) < h; i++ { + mark := " " + if i == pv.hit { + mark = cursorStyle.Render("▶ ") + } + out = append(out, mark+pv.lines[i]) + } + return out +} + +// scroll moves the preview of the search by d lines. +func (pv *preview) scroll(d int) { + pv.center = false + pv.top = max(pv.top+d, 0) +} diff --git a/tui/release.go b/tui/release.go index 3e0ae7d..01b4120 100644 --- a/tui/release.go +++ b/tui/release.go @@ -1,41 +1,27 @@ package tui -// The footer tells the user about a newer release. A key or a click -// installs it in the background. Then rexdiff starts again in the same -// pane, with the comments and the place of the user. The update package -// installs the release. This file shows the state of the update. - import ( - "context" "fmt" tea "charm.land/bubbletea/v2" - - "github.com/rednafi/rexdiff/update" ) -// Updater finds and installs releases. *update.Updater implements it. -type Updater interface { - // Latest returns the latest release, and whether it is newer. - Latest(ctx context.Context) (update.Release, bool, error) - // Install installs the release, so that the next start runs it. - Install(ctx context.Context, r update.Release) error -} +// The footer tells the user about a newer release. A key or a click +// starts the update to it in the background. Then rexdiff starts again in +// the same pane, with the comments and the place of the user. The update +// package installs the release. This file shows the state of the update. // updatePhase is the step of the update of rexdiff. type updatePhase int const ( - phaseIdle updatePhase = iota - phaseInstalling // the release installs in the background - phaseInstalled // rexdiff restarts when the user is ready + phaseIdle updatePhase = iota + phaseUpdating // the release installs in the background + phaseUpdated // rexdiff restarts when the user is ready ) -// installedMsg tells that the install of the release ended. -type installedMsg struct{ err error } - // checkRelease returns a command that asks for the latest release. Its -// message is the *update.Release if it is newer. A failure shows nothing, +// message is a releaseMsg if the release is newer. A failure shows nothing, // because rexdiff works the same without the check. func (m *model) checkRelease() tea.Cmd { u, ctx := m.updater, m.ctx @@ -44,58 +30,59 @@ func (m *model) checkRelease() tea.Cmd { if err != nil || !newer { return nil } - return &r + return releaseMsg{release: r} } } // releaseBadge returns the text of the footer about m.release. func (m *model) releaseBadge() string { switch m.phase { - case phaseInstalling: + case phaseUpdating: return fmt.Sprintf("updating rexdiff to %s…", m.release.Tag) - case phaseInstalled: - return fmt.Sprintf("rexdiff %s is installed; it starts when you are done", m.release.Tag) + case phaseUpdated: + return fmt.Sprintf("rexdiff %s is installed. It starts when you are done", m.release.Tag) } - return fmt.Sprintf("rexdiff %s is out: press U or click here to update", m.release.Tag) + return fmt.Sprintf("rexdiff %s is out: press %s or click here to update", + m.release.Tag, firstKey(m.keys.Update)) } -// install starts the install of the release in the background. -func (m *model) install() tea.Cmd { +// startUpdate starts the update to the release in the background. +func (m *model) startUpdate() tea.Cmd { if m.release == nil || m.phase != phaseIdle { return nil } - m.phase = phaseInstalling + m.phase = phaseUpdating u, ctx, r := m.updater, m.ctx, *m.release - return func() tea.Msg { return installedMsg{u.Install(ctx, r)} } + return func() tea.Msg { return updatedMsg{err: u.Install(ctx, r)} } } -// onInstalled prepares the restart, or tells why the install failed. After +// onUpdated prepares the restart, or tells why the update fails. After // a failure, the user can try again. -func (m *model) onInstalled(msg installedMsg) { +func (m *model) onUpdated(msg updatedMsg) { if msg.err != nil { m.phase = phaseIdle m.flash("update failed: " + msg.err.Error()) return } - m.phase = phaseInstalled + m.phase = phaseUpdated } -// restartIfReady saves the state for the restart into m.restartState. It -// reports whether rexdiff can quit for the restart. It waits while the -// user writes a comment or uses an overlay. It also waits while a send -// runs, and while pending (the command of the last message) runs. Thus no -// work is lost. +// restartIfReady saves the resume state for the restart, and keeps its +// file in m.resumePath. It reports whether rexdiff can quit for the +// restart. It waits while the user writes a comment or uses an overlay. +// It also waits while a send runs, and while pending (the command of the +// last message) runs. Thus no work is lost. func (m *model) restartIfReady(pending tea.Cmd) bool { if m.mode != modeNormal || pending != nil || m.sends > 0 { return false } - path, err := m.saveState() + path, err := m.saveResume() if err != nil { m.phase, m.release = phaseIdle, nil - m.flash("updated, but cannot save your comments for the restart: " + err.Error() + - "; open rexdiff again for the new version") + m.flash("updated, but the save of your comments failed: " + err.Error() + + ". Open rexdiff again for the new version") return false } - m.restartState = path + m.resumePath = path return true } diff --git a/tui/release_test.go b/tui/release_test.go deleted file mode 100644 index 079b2dc..0000000 --- a/tui/release_test.go +++ /dev/null @@ -1,403 +0,0 @@ -package tui - -import ( - "context" - "errors" - "os" - "path/filepath" - "strings" - "testing" - - tea "charm.land/bubbletea/v2" - - "github.com/rednafi/rexdiff/diff" - "github.com/rednafi/rexdiff/rex" - "github.com/rednafi/rexdiff/update" - "github.com/rednafi/rexdiff/vcs" -) - -// fakeUpdater is an Updater with a fixed latest release. -type fakeUpdater struct { - latest update.Release - newer bool - err error // of Latest - installErr error - installed []string // the tags that Install got -} - -func (f *fakeUpdater) Latest(context.Context) (update.Release, bool, error) { - return f.latest, f.newer, f.err -} - -func (f *fakeUpdater) Install(_ context.Context, r update.Release) error { - f.installed = append(f.installed, r.Tag) - return f.installErr -} - -// updaterModel returns a model with u, after the release check. -func updaterModel(t *testing.T, u *fakeUpdater) model { - t.Helper() - m, _ := newTestModel(t) - m = newModel(t.Context(), Options{Repo: m.repo, Updater: u}, defaultConfig()) - m.reload() - m = step(t, m, tea.WindowSizeMsg{Width: 120, Height: 30}) - return step(t, m, m.checkRelease()()) -} - -// lastLine returns the last line of the view, without colours. -func lastLine(m model) string { - lines := strings.Split(view(m), "\n") - return lines[len(lines)-1] -} - -// run runs cmd and gives its message to m. -func run(t *testing.T, m model, cmd tea.Cmd) (model, tea.Cmd) { - t.Helper() - if cmd == nil { - t.Fatal("no command") - } - next, cmd := m.Update(cmd()) - return *next.(*model), cmd -} - -// TestUpdate checks the prompt, the install, and the restart of an update -// from the footer. -func TestUpdate(t *testing.T) { - u := &fakeUpdater{latest: update.Release{Tag: "v0.2.0"}, newer: true} - m := updaterModel(t, u) - const prompt = "rexdiff v0.2.0 is out: press U or click here to update" - if got := lastLine(m); !strings.Contains(got, prompt) { - t.Fatalf("footer = %q, want %q", got, prompt) - } - - // The key U starts the install, and the footer tells about it. - next, cmd := m.Update(tea.KeyPressMsg{Code: 'U', Text: "U"}) - m = *next.(*model) - if got := lastLine(m); !strings.Contains(got, "updating rexdiff to v0.2.0…") { - t.Errorf("footer during the install = %q, want updating rexdiff to v0.2.0…", got) - } - // The user starts a comment while the release installs. - m = press(t, m, "j", "j", "j", "j", "c") - m, restart := run(t, m, cmd) - if len(u.installed) != 1 || u.installed[0] != "v0.2.0" { - t.Fatalf("Install got %v, want v0.2.0", u.installed) - } - if restart != nil || m.restartState != "" { - t.Fatal("rexdiff restarts while the user writes a comment") - } - // The restart waits until the user saves the comment. - m = press(t, m, "f", "i", "x", "enter") - if m.restartState == "" { - t.Fatal("no restart after the comment") - } - t.Cleanup(func() { os.Remove(m.restartState) }) - - // The new rexdiff reads back the comment and the place. - saved, err := loadState(m.restartState) - if err != nil { - t.Fatal(err) - } - if _, err := os.Stat(m.restartState); !os.IsNotExist(err) { - t.Error("loadState did not remove the file") - } - m2 := newModel(t.Context(), Options{Repo: m.repo}, defaultConfig()) - m2.restoreSettings(saved) - m2.reload() - m2.restorePlaces(saved) - if got, want := comments(m2), comments(m); got != want { - t.Errorf("comments after the restart = %q, want %q", got, want) - } - if m2.changes.cur != m.changes.cur || m2.scope != m.scope || m2.tab != m.tab { - t.Errorf("after the restart: cursor %d, scope %q, tab %d; want %d, %q, %d", - m2.changes.cur, m2.scope, m2.tab, m.changes.cur, m.scope, m.tab) - } - if got, want := m2.comments.Review(""), m.comments.Review(""); got != want { - t.Errorf("the review after the restart:\n%s\nwant:\n%s", got, want) - } -} - -// comments returns the locations and the texts of the comments of m. -func comments(m model) string { - var b strings.Builder - for _, c := range m.comments.All() { - b.WriteString(c.Location() + " " + c.Text + "\n") - } - return b.String() -} - -// TestUpdateClick checks that a click on the footer starts the install. -func TestUpdateClick(t *testing.T) { - u := &fakeUpdater{latest: update.Release{Tag: "v0.2.0"}, newer: true} - m := updaterModel(t, u) - next, cmd := m.Update(tea.MouseClickMsg{Button: tea.MouseLeft, X: 3, Y: m.h - 1}) - m = *next.(*model) - if cmd == nil || m.phase != phaseInstalling { - t.Fatal("a click on the footer did not start the install") - } -} - -// TestUpdateFails checks that a failed install shows the reason, does not -// restart, and lets the user try again. -func TestUpdateFails(t *testing.T) { - u := &fakeUpdater{ - latest: update.Release{Tag: "v0.2.0"}, - newer: true, - installErr: errors.New("brew: exit status 1: Error: another brew is running"), - } - m := updaterModel(t, u) - next, cmd := m.Update(tea.KeyPressMsg{Code: 'U', Text: "U"}) - m, restart := run(t, *next.(*model), cmd) - if restart != nil || m.restartState != "" { - t.Fatal("rexdiff restarts after a failed install") - } - if !strings.Contains(lastLine(m), "update failed: brew: exit status 1: Error: another brew is running") { - t.Errorf("footer = %q, want the reason", lastLine(m)) - } - if _, cmd := m.Update(tea.KeyPressMsg{Code: 'U', Text: "U"}); cmd == nil { - t.Error("U does not try again") - } -} - -// TestNoUpdate checks that the footer shows no release and U does nothing -// without a newer release, after a failed check, or without an updater. -func TestNoUpdate(t *testing.T) { - for _, u := range []*fakeUpdater{ - {latest: update.Release{Tag: "v0.1.0"}}, - {err: errors.New("no network")}, - } { - m := updaterModel(t, u) - if m.release != nil || strings.Contains(lastLine(m), "is out") { - t.Errorf("updater %+v: footer %q, want no release", u, lastLine(m)) - } - if _, cmd := m.Update(tea.KeyPressMsg{Code: 'U', Text: "U"}); cmd != nil { - t.Errorf("updater %+v: U started a command", u) - } - } - m, _ := newTestModel(t) - if cmd := m.Init(); m.updater != nil || cmd == nil { - t.Error("the test model has an updater") - } -} - -// TestCommentsJSON checks that comments keep their code and their rows -// in JSON, so that they follow their lines and give the same review after -// a restart. -func TestCommentsJSON(t *testing.T) { - m, _ := newTestModel(t) - m = press(t, m, "j", "j", "j", "j", "c", "x", "enter") - b, err := m.comments.MarshalJSON() - if err != nil { - t.Fatal(err) - } - var back diff.Comments - if err := back.UnmarshalJSON(b); err != nil { - t.Fatal(err) - } - if back.Review("p") != m.comments.Review("p") || back.Len() != 1 { - t.Errorf("review after JSON = %q, want %q", back.Review("p"), m.comments.Review("p")) - } - // After the restart, a new comment also gets a new ID. - if c := back.Add(diff.Comment{Path: "a.go", Start: 1, End: 1}); c.ID != 2 { - t.Errorf("ID of a new comment = %d, want 2", c.ID) - } -} - -// TestRestartWaits checks that the restart waits for a running send and for -// a message whose command is not done. -func TestRestartWaits(t *testing.T) { - u := &fakeUpdater{latest: update.Release{Tag: "v0.2.0"}, newer: true} - m := updaterModel(t, u) - m.sends = 1 // a send runs while the install ends - m = step(t, m, installedMsg{}) - if m.restartState != "" { - t.Fatal("restart while a send runs") - } - m = step(t, m, sentMsg{agent: rex.Agent{Name: "pi"}}) - if m.restartState == "" { - t.Fatal("no restart after the send") - } - os.Remove(m.restartState) - - // In the PR tab, the key r returns the command that loads the PR. Thus - // r does not restart, but a message without a command does. - m = updaterModel(t, u) - m.tab = tabPR - m.phase = phaseInstalled - if next, cmd := m.Update(tea.KeyPressMsg{Code: 'r', Text: "r"}); cmd == nil || next.(*model).restartState != "" { - t.Fatal("restart on a message with a command") - } - m = step(t, m, tea.FocusMsg{}) - if m.restartState == "" { - t.Fatal("no restart on a message without a command") - } - os.Remove(m.restartState) -} - -// TestRestorePRPlace checks that the place in the PR tab comes back after -// a restart, although the PR loads after the restore. -func TestRestorePRPlace(t *testing.T) { - m, _ := newTestModel(t) - saved := &savedState{PR: savedPane{Cur: 7, Top: 3}, Comments: new(diff.Comments)} - m.restoreSettings(saved) - m.restorePlaces(saved) - // A failed first load keeps the place for the next load. - m = step(t, m, prMsg{err: errors.New("no network")}) - body := strings.Repeat("line\n\n", 40) - m = step(t, m, prMsg{pr: &vcs.PullRequest{Title: "t", Body: body}}) - if m.prPane.cur != 7 || m.prPane.top != 3 { - t.Errorf("PR place = %d, %d; want 7, 3", m.prPane.cur, m.prPane.top) - } -} - -// TestUpdateClickRule checks that a click on the footer starts the install, -// and a click on the rule above it does nothing. -func TestUpdateClickRule(t *testing.T) { - u := &fakeUpdater{latest: update.Release{Tag: "v0.2.0"}, newer: true} - m := updaterModel(t, u) - rule := bodyY + m.bodyHeight() - if next, _ := m.Update(tea.MouseClickMsg{Button: tea.MouseLeft, X: 3, Y: rule}); next.(*model).phase == phaseInstalling { - t.Error("a click on the rule started the install") - } - if next, _ := m.Update(tea.MouseClickMsg{Button: tea.MouseLeft, X: 3, Y: rule + 1}); next.(*model).phase != phaseInstalling { - t.Error("a click on the footer did not start the install") - } -} - -// TestRestartSaveFails checks that a failed save of the state tells the -// user and stops the restart, so that rexdiff continues with the old version. -func TestRestartSaveFails(t *testing.T) { - u := &fakeUpdater{latest: update.Release{Tag: "v0.2.0"}, newer: true} - m := updaterModel(t, u) - m.mode = modeComment // the install ends while the user writes - m = step(t, m, installedMsg{}) - if got := lastLine(m); !strings.Contains(got, "rexdiff v0.2.0 is installed; it starts when you are done") { - t.Errorf("footer while the restart waits = %q, want that v0.2.0 is installed", got) - } - m.mode = modeNormal - t.Setenv("TMPDIR", filepath.Join(t.TempDir(), "missing")) - m = step(t, m, tea.FocusMsg{}) - if m.restartState != "" || m.phase != phaseIdle || m.release != nil { - t.Errorf("after a failed save: state %q, phase %d, release %v; want none, idle, nil", - m.restartState, m.phase, m.release) - } - if !strings.Contains(m.status, "cannot save your comments for the restart") { - t.Errorf("status = %q, want the reason", m.status) - } -} - -// TestRestartOnTick checks that a tick can start the restart, though its -// command schedules the next tick. -func TestRestartOnTick(t *testing.T) { - u := &fakeUpdater{latest: update.Release{Tag: "v0.2.0"}, newer: true} - m := updaterModel(t, u) - m.phase = phaseInstalled - next, cmd := m.Update(tickMsg{}) - if next.(*model).restartState == "" || cmd == nil { - t.Fatal("no restart on a tick") - } - os.Remove(next.(*model).restartState) -} - -// TestSavePendingPRPlace checks that a restart before the PR loads keeps -// the PR place of the restart before it. -func TestSavePendingPRPlace(t *testing.T) { - m, _ := newTestModel(t) - m.prPlace = &savedPane{Cur: 7, Top: 3} - path, err := m.saveState() - if err != nil { - t.Fatal(err) - } - s, err := loadState(path) - if err != nil || s.PR != (savedPane{Cur: 7, Top: 3}) { - t.Errorf("saved PR place = %+v, %v; want 7, 3", s.PR, err) - } -} - -// TestWrapWordsNarrow checks that a width of 0, or a character wider than -// the width, does not loop forever. -func TestWrapWordsNarrow(t *testing.T) { - tests := []struct { - s string - w int - want string - }{ - {"ab c", 0, "a\nb\nc"}, - {"中 a", 1, "中\na"}, - {"a中b", 2, "a\n中\nb"}, - } - for _, tc := range tests { - if got := wrapWords(tc.s, tc.w); got != tc.want { - t.Errorf("wrapWords(%q, %d) = %q, want %q", tc.s, tc.w, got, tc.want) - } - } -} - -// TestRunChecksOptions checks that Run refuses options that it cannot use, -// before it starts the program. -func TestRunChecksOptions(t *testing.T) { - m, _ := newTestModel(t) - tests := []struct { - o Options - want string - }{ - {Options{}, "no repository"}, - {Options{Repo: m.repo, Layout: "sideways"}, `invalid value for layout: expected one of unified, split, not "sideways"`}, - {Options{Repo: m.repo, Scope: "commits"}, `invalid value for scope: expected one of uncommitted, branch, last-turn, not "commits"`}, - } - for _, tc := range tests { - if _, err := Run(t.Context(), tc.o); err == nil || err.Error() != tc.want { - t.Errorf("Run(%+v) error = %v, want %s", tc.o, err, tc.want) - } - } -} - -// TestSendKeepsEdits checks that a comment edited while its send runs -// stays after the send, whether the user saved the edit before the send -// ended or after it. -func TestSendKeepsEdits(t *testing.T) { - for _, saveFirst := range []bool{true, false} { - m, _ := newTestModel(t) - m = press(t, m, "j", "j", "j", "j", "c", "o", "l", "d", "enter") - sent := sentMsg{agent: rex.Agent{Name: "pi"}, ids: []int{m.comments.All()[0].ID}} - m.sends = 1 // the send runs - m = press(t, m, "e", "!") // edit the comment - if saveFirst { - m = press(t, m, "enter") - m = step(t, m, sent) - } else { - m = step(t, m, sent) - m = press(t, m, "enter") - } - if got := comments(m); !strings.HasSuffix(strings.TrimSpace(got), "old!") { - t.Errorf("save first %v: comments after the send = %q, want the edited comment", saveFirst, got) - } - } - // The send removes a comment that the user did not change. - m, _ := newTestModel(t) - m = press(t, m, "j", "j", "j", "j", "c", "x", "enter") - m.sends = 1 - m = step(t, m, sentMsg{agent: rex.Agent{Name: "pi"}, ids: []int{m.comments.All()[0].ID}}) - if m.comments.Len() != 0 { - t.Errorf("comments after the send = %q, want none", comments(m)) - } -} - -// TestPickerWaitsForComment checks that the agent picker does not open over -// a comment that the user starts while the agents load. -func TestPickerWaitsForComment(t *testing.T) { - m, _ := newTestModel(t) - m = press(t, m, "j", "j", "j", "j", "c", "h", "i") - m.sends = 1 - agents := agentsMsg{send: true, agents: []rex.Agent{{Block: "a", Name: "pi"}, {Block: "b", Name: "cc"}}} - m = step(t, m, agents) - if m.mode != modeComment || m.overlay != nil { - t.Fatalf("mode %d, picker open %v; want the comment box, no picker", m.mode, m.overlay != nil) - } - if !strings.Contains(m.status, "then press s to pick one") || m.sends != 0 { - t.Errorf("status %q, sends %d; want the hint and no send", m.status, m.sends) - } - m = press(t, m, "enter") - if got := comments(m); !strings.Contains(got, "hi") { - t.Errorf("comments = %q, want the comment that the user wrote", got) - } -} diff --git a/tui/render.go b/tui/render.go index bbfb99b..02cec37 100644 --- a/tui/render.go +++ b/tui/render.go @@ -1,15 +1,22 @@ package tui -// This file turns files into the lines of a pane: diffs, the file viewer, -// and markdown. +// This file turns files into the lines of a pane. It renders diffs, the +// file viewer, and Markdown. import ( + "cmp" "errors" + "fmt" + "iter" + "regexp" + "slices" "strings" "charm.land/glamour/v2" + "github.com/charmbracelet/x/ansi" "github.com/rednafi/rexdiff/diff" + "github.com/rednafi/rexdiff/internal/termtext" "github.com/rednafi/rexdiff/vcs" ) @@ -17,26 +24,82 @@ import ( // left of the main pane. const gutter = 2 +// The sizes of the file list, as in herdr-reviewr. At a side, the list +// takes listSidePct of the screen width by default, and stays between +// listMinPct and listMaxSidePct. Above or below, the list takes +// listStackPct of the body rows by default, and stays between listMinPct +// and listMaxStackPct. +const ( + listSidePct = 32 + listStackPct = 25 + listMinPct = 15 + listMaxSidePct = 60 + listMaxStackPct = 50 +) + +// listShown reports whether the file list shows. The PR tab has no file +// list. +func (m *model) listShown() bool { return !m.listHidden && m.tab != tabPR } + +// sideWidth returns the width of the file list at a side, in columns. +// Without a width in columns, the list takes listSidePct of the screen. +func (m *model) sideWidth() int { + return m.clampSide(cmp.Or(m.listW, m.w*listSidePct/100)) +} + +// clampSide returns w within the bounds of the width of the file list at +// a side. +func (m *model) clampSide(w int) int { + return min(max(w, m.w*listMinPct/100), m.w*listMaxSidePct/100) +} + // listWidth returns the width of the file list, or 0 if the list does not // show. func (m *model) listWidth() int { - if m.listHidden || m.tab == tabPR { + switch { + case !m.listShown(): return 0 + case m.listPos.stacked(): + return m.w } - // The list never takes more than a third of a narrow pane. - return min(m.listW, max(m.w/3, minListWidth)) + return m.sideWidth() } -// mainWidth is the width of the main pane, with its gutter. +// listHeight returns the number of body rows that the file list takes +// above or below the main pane, with its rule. The body has h rows. It +// returns 0 if the list does not show there, or if the rule leaves no +// row for the list. +func (m *model) listHeight(h int) int { + if !m.listShown() || !m.listPos.stacked() { + return 0 + } + if lh := splitAxis(h, m.listPct); lh > 1 { + return lh + } + return 0 +} + +// mainMinWidth is the width of the main pane, with its gutter, if the +// file list leaves less, or if the terminal is narrower. +const mainMinWidth = 24 + +// mainWidth returns the width of the main pane, with its gutter. func (m *model) mainWidth() int { w := m.w - if lw := m.listWidth(); lw > 0 { + if lw := m.listWidth(); lw > 0 && !m.listPos.stacked() { w -= lw + sepWidth } - return max(w, 24) + return max(w, mainMinWidth) } -// codeWidth is the width of the main pane, without its gutter. +// mainHeight returns the number of rows of the main pane. +func (m *model) mainHeight() int { return m.mainHeightOf(m.bodyHeight()) } + +// mainHeightOf returns the number of rows of the main pane in a body of h +// rows. +func (m *model) mainHeightOf(h int) int { return h - m.listHeight(h) } + +// codeWidth returns the width of the main pane, without its gutter. func (m *model) codeWidth() int { return m.mainWidth() - gutter } // rerender renders the current pane again, for example after a change of @@ -44,6 +107,7 @@ func (m *model) codeWidth() int { return m.mainWidth() - gutter } // their source lines. A cursor on a header stays above the first line // below the header. func (m *model) rerender() { + m.sizeEditor() p := m.cur() next := p.cur for next < len(p.rows) && m.target(next, diff.SideNew) == nil { @@ -71,6 +135,39 @@ func (m *model) rerender() { } } +// sizeEditor fits the comment box to the width of the main pane. +func (m *model) sizeEditor() { m.editor.SetWidth(max(m.mainWidth()-8, 20)) } + +// splitMinWidth is the narrowest code width that shows the split layout. +// It is the same as in herdr-reviewr: two halves of 60 columns, each with +// its line numbers, and a column between them. +const splitMinWidth = 135 + +// splitAllowed reports whether the current file can show in the split +// layout. The file must be a diff in the Changes tab, with lines on both +// sides and a change. Rendered markdown cannot split. +func (m *model) splitAllowed() bool { + if m.tab != tabChanges { + return false + } + f := m.selectedChange() + return f != nil && f.Skip == nil && !m.isRendered(f) && + f.Old != "" && f.New != "" && f.Old != f.New +} + +// splitShown reports whether the current file shows in the split layout. +// The user must choose split, and the pane must be wide enough. The choice +// stays, so a wider pane shows split again. +func (m *model) splitShown() bool { + return m.split && m.codeWidth() >= splitMinWidth && m.splitAllowed() +} + +// isRendered reports whether the Changes tab shows f as rendered +// markdown. +func (m *model) isRendered(f *changedFile) bool { + return m.rendered && isMarkdown(f.Path) && f.Status != vcs.StatusDeleted +} + // diffOptions returns the options to render a diff with sideways scroll // xoff. func (m *model) diffOptions(xoff int) diff.Options { @@ -78,13 +175,15 @@ func (m *model) diffOptions(xoff int) diff.Options { Width: m.codeWidth(), Offset: xoff, Wrap: m.wrap, - Split: m.split, + Split: m.splitShown(), + Signs: m.cfg.DiffSigns, Theme: diff.Theme{ Background: m.bg, Foreground: m.fg, Green: m.green, Red: m.red, }, + Cache: &m.cache, } } @@ -94,10 +193,29 @@ func isMarkdown(path string) bool { return strings.HasSuffix(p, ".md") || strings.HasSuffix(p, ".markdown") } +// The notes in place of a file that rexdiff does not show. They are the +// same as in herdr-reviewr. +const ( + noteBinary = "binary file · no line comments" + noteTooBig = "file too large to show" +) + +// fileNote returns the note for a file that vcs does not read, or "" if +// err has no note. err is the error of ReadFile, or File.Skip. +func fileNote(err error) string { + switch { + case errors.Is(err, vcs.ErrBinary): + return noteBinary + case errors.Is(err, vcs.ErrTooBig): + return noteTooBig + } + return "" +} + // renderChanges shows the selected file in the Changes tab. func (m *model) renderChanges() { - p := &m.changes - f := m.changedFile() + p := &m.changePane + f := m.selectedChange() if f == nil { p.path = "" p.set(nil, nil) @@ -105,21 +223,30 @@ func (m *model) renderChanges() { } p.path = f.Path switch { - case f.Binary: - p.setNote("binary file, not diffed") - case f.TooBig: - p.setNote("file over 2 MB or 50,000 lines, not diffed") + case f.Skip != nil: + p.setNote(fileNote(f.Skip)) case f.Old == f.New: - // A rename without changes, or a change of the file mode. + // The change is a rename with no edit, or a change of the file mode. note := "no change to the content" if f.From != "" { - note = "renamed from " + diff.Quote(f.From) + ", no change to the content" + note = "renamed from " + termtext.Quote(f.From) + ", no change to the content" } p.setNote(note) - case m.rendered && isMarkdown(f.Path) && f.Status != vcs.StatusDeleted: - m.setMarkdown(p, f.New) + case m.isRendered(f): + all := func(diff.Fold) bool { return true } + d := diff.Render(f.Path, f.Old, f.New, diff.Options{Expanded: all, Cache: &m.cache}) + m.setMarkdown(p, f.Old, f.New, d.Rows) default: - p.setDiff(diff.Render(f.Path, f.Old, f.New, m.diffOptions(p.xoff))) + if o := m.folds[f.Path]; o != nil && o.Old != f.oldHash { + delete(m.folds, f.Path) + } + o := m.diffOptions(p.xoff) + a := m.anchorOf(tabChanges) + comments := slices.DeleteFunc(m.comments.All(), func(c *diff.Comment) bool { + return c.Anchor != a + }) + o.Expanded = func(fd diff.Fold) bool { return m.expanded(f.Path, comments, fd) } + p.setDiff(diff.Render(f.Path, f.Old, f.New, o)) } } @@ -130,49 +257,64 @@ func (p *pane) setNote(note string) { } // renderViewer shows the selected file in the Files tab, even if the file -// did not change. +// does not change. func (m *model) renderViewer() { - m.viewer.path = "" // no file has the path "", so refreshViewer renders + m.viewedPath = "" // no file has the path "", so refreshViewer renders m.refreshViewer() } // refreshViewer shows the selected file in the Files tab if the file -// changed. +// changes. func (m *model) refreshViewer() { - p := &m.viewer + p := &m.allPane if m.allSel >= len(m.allFiles) { - p.path = "" + p.path, m.viewedPath = "", "" p.set(nil, nil) return } path := m.allFiles[m.allSel].Path content, err := m.repo.ReadFile(path) - if content == m.viewed && p.path == path { + if err == nil { + m.readErr = "" + } + if err == nil && content == m.viewed && m.viewedPath == path { return } - m.viewed = content + m.viewed, m.viewedPath = content, path p.path = path + note := fileNote(err) switch { - case errors.Is(err, vcs.ErrBinary): + case note != "": p.path = "" - p.set([]string{dimStyle.Render(" binary file")}, nil) - case errors.Is(err, vcs.ErrTooBig): + p.set([]string{dimStyle.Render(" " + note)}, nil) + case err != nil: + // Without a watcher, each tick reads the file again. The log has + // each error once. + if text := err.Error(); text != m.readErr { + m.readErr = text + m.log.Warn("read file failed", "path", path, "err", err) + } p.path = "" - p.set([]string{dimStyle.Render(" file over 2 MB, not shown")}, nil) + p.set([]string{dimStyle.Render(" could not read this file")}, nil) case m.rendered && isMarkdown(path): - m.setMarkdown(p, content) + m.setMarkdown(p, "", content, nil) default: p.setDiff(diff.RenderFile(path, content, m.diffOptions(p.xoff))) } } // markdown returns a glamour renderer for the terminal theme, or nil. The -// renderer wraps at wrap columns. +// renderer wraps at wrap columns. The model keeps the last renderer, +// because a new one is slow to make. func (m *model) markdown(wrap int) *glamour.TermRenderer { style := "dark" - if m.bg != nil && !m.dark { + if !m.dark() { style = "light" } + md := &m.md + if md.r != nil && md.style == style && md.wrap == wrap { + return md.r + } r, err := glamour.NewTermRenderer( glamour.WithStandardStyle(style), glamour.WithWordWrap(wrap), @@ -180,10 +322,19 @@ func (m *model) markdown(wrap int) *glamour.TermRenderer { if err != nil { return nil } + *md = mdRenderer{r: r, style: style, wrap: wrap} return r } -// renderMarkdown renders src with r. If r is nil or fails, it returns src. +// mdRenderer is a glamour renderer with its style and wrap width. +type mdRenderer struct { + r *glamour.TermRenderer + style string + wrap int +} + +// renderMarkdown returns the lines of src, rendered with r. If r is nil or +// fails, renderMarkdown returns the lines of src as they are. func renderMarkdown(r *glamour.TermRenderer, src string) []string { if r != nil { if out, err := r.Render(src); err == nil { @@ -193,10 +344,209 @@ func renderMarkdown(r *glamour.TermRenderer, src string) []string { return strings.Split(strings.TrimRight(src, "\n"), "\n") } -// setMarkdown shows src as rendered markdown. The rendered lines take no -// comments. Press m to see the source. -func (m *model) setMarkdown(p *pane, src string) { - lines := renderMarkdown(m.markdown(m.codeWidth()-2), diff.Plain(src)) - p.path = "" - p.set(lines, make([]diff.Row, len(lines))) +// markdownSource returns the markdown text of the current file. ok is +// false if the file is not markdown, or if it has no text to render. +func (m *model) markdownSource() (src string, ok bool) { + switch m.tab { + case tabChanges: + f := m.selectedChange() + if f == nil || !isMarkdown(f.Path) || f.Status == vcs.StatusDeleted { + return "", false + } + src = f.New + case tabFiles: + if m.allSel >= len(m.allFiles) || !isMarkdown(m.allFiles[m.allSel].Path) { + return "", false + } + src = m.viewed + } + return src, src != "" +} + +// rendersNothing reports whether the markdown src renders no text. +func (m *model) rendersNothing(src string) bool { + for _, l := range renderMarkdown(m.markdown(m.codeWidth()-2), termtext.Plain(src)) { + if strings.TrimSpace(ansi.Strip(l)) != "" { + return false + } + } + return true +} + +// setMarkdown shows src as rendered markdown, block by block, as +// herdr-reviewr does. rows are the source rows that the view stands for, +// with old as their old side and src as their new side. If rows is nil, +// no line of src changes. Each rendered line shows the first source line +// of its block. The blank line after a block shows its last source line. +// A comment on a block covers all its lines. A block with a change has +// the bar. A changed block that renders nothing, and the lines of a +// removed block, show as one dim marker row. +func (m *model) setMarkdown(p *pane, old, src string, rows []diff.Row) { + lines, oldLines := diff.Lines(src), diff.Lines(old) + if rows == nil { + for n := range lines { + rows = append(rows, diff.Row{Kind: diff.KindEqual, New: n + 1}) + } + } + // Each block gets the link reference definitions of all the blocks. + var defs []string + for _, l := range lines { + if linkDef.MatchString(l) { + defs = append(defs, l) + } + } + refs := "\n\n" + strings.Join(defs, "\n") + r := m.markdown(m.codeWidth() - 2) + var out []string + var outRows []diff.Row + blank := func(l string) bool { return strings.TrimSpace(ansi.Strip(l)) == "" } + // removed adds a marker row for the deleted lines after the new line + // at, from lo to hi. + removed := func(lo, hi int) { + if n := removedLines(rows, oldLines, lo, hi); n > 0 { + row := diff.Row{Kind: diff.KindEqual, New: max(lo, 1), Continued: true} + text := fmt.Sprintf(" − %s removed", plural(n, "line")) + out = append(out, delStyle.Render("▌")+dimStyle.Render(text), "") + outRows = append(outRows, row, row) + } + } + see := m.keys.Rendered.Help().Key + prev := 0 // the last line of the block before + for first, last := range markdownBlocks(lines) { + removed(prev, first) + prev = last + block := renderMarkdown(r, termtext.Plain(strings.Join(lines[first-1:last], "\n")+refs)) + for len(block) > 0 && blank(block[0]) { + block = block[1:] + } + for len(block) > 0 && blank(block[len(block)-1]) { + block = block[:len(block)-1] + } + bar, n := changeBar(rows, first, last) + switch { + case len(block) > 0: + case n == 1: + block = []string{dimStyle.Render(" ⚠ 1 changed line doesn't render · " + see + " to see")} + case n > 1: + block = []string{dimStyle.Render(fmt.Sprintf( + " ⚠ %d changed lines don't render · %s to see", n, see))} + default: + continue + } + for i, l := range block { + if bar != "" { + l = bar + ansi.TruncateLeft(l, 1, "") + } + out = append(out, l) + outRows = append(outRows, diff.Row{Kind: diff.KindEqual, New: first, Continued: i > 0}) + } + out = append(out, "") + outRows = append(outRows, diff.Row{Kind: diff.KindEqual, New: last, Continued: true}) + } + removed(prev, len(lines)+1) + p.set(out, outRows) + p.src = rows +} + +// linkDef matches the first line of a link reference definition. +var linkDef = regexp.MustCompile(`^\s{0,3}\[[^\]]+\]:`) + +// removedLines returns the number of deleted lines of old, not blank, +// that rows put after a new line from lo to hi, hi not included. A +// deletion right before a new line is a change of that line, not a +// removal. +func removedLines(rows []diff.Row, old []string, lo, hi int) int { + n, run, at := 0, 0, 0 // at is the last new line before the row + for _, r := range rows { + switch { + case r.Continued: + case r.Kind == diff.KindDelete: + if at >= lo && at < hi && r.Old <= len(old) && strings.TrimSpace(old[r.Old-1]) != "" { + run++ + } + default: + if r.Kind == diff.KindEqual { + n += run + } + run, at = 0, r.New + } + } + return n + run +} + +// markdownBlocks yields the first and the last line of each block of +// markdown lines, from 1. A blank line outside a code fence ends a block, +// unless the next line is indented, as in a list item. +func markdownBlocks(lines []string) iter.Seq2[int, int] { + return func(yield func(int, int) bool) { + first, last, fence := 0, 0, "" + for i, l := range lines { + if fence == "" { + if strings.TrimSpace(l) == "" { + continue + } + if first > 0 && last < i && !strings.HasPrefix(l, " ") && !strings.HasPrefix(l, "\t") { + if !yield(first, last) { + return + } + first = 0 + } + } + switch t := strings.TrimLeft(l, " "); { + case fence != "": + if strings.HasPrefix(t, fence) { + fence = "" + } + case strings.HasPrefix(t, "```"), strings.HasPrefix(t, "~~~"): + fence = t[:3] + } + if first == 0 { + first = i + 1 + } + last = i + 1 + } + if first > 0 { + yield(first, last) + } + } +} + +// changeBar returns the bar for the source lines first to last, or "" if +// rows do not change them. The bar is green if rows only add lines there. +// It is yellow if rows also change or delete a line there. n is the +// number of the changed lines there, as herdr-reviewr counts them: the +// new lines, or else the deleted lines. +func changeBar(rows []diff.Row, first, last int) (bar string, n int) { + added, changed, deleted := 0, false, false + gone := 0 // the deleted lines there + at := 0 // the last new line before the row + for _, r := range rows { + if r.Continued { + continue + } + if r.Kind == diff.KindDelete { + deleted = true + if at >= first && at < last { + changed = true + gone++ + } + continue + } + if r.New >= first && r.New <= last && r.Kind != diff.KindEqual { + added++ + changed = changed || deleted || r.Kind == diff.KindChange + } + deleted, at = false, r.New + if at >= last { + break + } + } + n = cmp.Or(added, gone) + switch { + case changed: + return noteStyle.Render("▌"), n + case added > 0: + return addStyle.Render("▌"), n + } + return "", 0 } diff --git a/tui/resume.go b/tui/resume.go index 9daaf80..e63959f 100644 --- a/tui/resume.go +++ b/tui/resume.go @@ -1,90 +1,113 @@ package tui -// After an update, rexdiff starts again in the same terminal. The old -// program saves the comments and the place of the user in a file, and the -// new one reads it, so that the user continues from the same place. - import ( "encoding/json/v2" "os" + "slices" "time" "github.com/rednafi/rexdiff/diff" + "github.com/rednafi/rexdiff/internal/proc" + "github.com/rednafi/rexdiff/vcs" ) -// savedState holds the comments, the scope, the view, and the places in -// the panes for a restart. -type savedState struct { - Tab tab `json:"tab"` - Scope Scope `json:"scope"` - Base string `json:"base"` - From string `json:"from"` - To string `json:"to"` - Turn *savedTurn `json:"turn"` +// After an update, rexdiff starts again in the same terminal. The old +// program saves the comments and the place of the user in a file. The new +// program reads the file, so that the user continues from the same place. + +// resumeState holds the comments, the scope, the view, the expanded folds, +// and the places in the panes for a restart. +type resumeState struct { + Tab tab `json:"tab"` + Scope Scope `json:"scope"` + Base string `json:"base"` + From string `json:"from"` + To string `json:"to"` + Turn *resumeTurn `json:"turn"` // The state keeps the files by path, because the lists can change in a // restart. - ChangePath string `json:"change_path"` - FilePath string `json:"file_path"` - Changes savedPane `json:"changes"` - Viewer savedPane `json:"viewer"` - PR savedPane `json:"pr"` - - Split bool `json:"split"` - Wrap bool `json:"wrap"` - Rendered bool `json:"rendered"` - ListLeft bool `json:"list_left"` + ChangePath string `json:"change_path"` + FilePath string `json:"file_path"` + Changes resumePane `json:"changes"` + Viewer resumePane `json:"viewer"` + PR resumePane `json:"pr"` + + Split bool `json:"split"` + Wrap bool `json:"wrap"` + Rendered bool `json:"rendered"` + ListPos position `json:"list_pos"` + // ListLeft is the place of the list in the state of an older version. + // Then ListPos is "". + ListLeft bool `json:"list_left,omitzero"` ListHidden bool `json:"list_hidden"` FocusList bool `json:"focus_list"` ListW int `json:"list_w"` + ListPct int `json:"list_pct,omitzero"` + + Folds map[string]*openFolds `json:"folds"` Comments *diff.Comments `json:"comments"` LastSent string `json:"last_sent"` Beside string `json:"beside"` } -// savedTurn is a [turnState], with exported fields for JSON. -type savedTurn struct { - Block string `json:"block"` +// resumeTurn is a [turnState], with exported fields for JSON. The state +// of an older version can have the member "block", which Unmarshal skips. +type resumeTurn struct { App string `json:"app"` Tree string `json:"tree"` Running bool `json:"running"` At time.Time `json:"at"` } -// savedPane is the place in a pane. -type savedPane struct { +// resumePane is the place in a pane. +type resumePane struct { Cur int `json:"cur"` Top int `json:"top"` Xoff int `json:"xoff"` } -func (p *pane) place() savedPane { return savedPane{p.cur, p.top, p.xoff} } +// place returns the place of p, for the resume state. +func (p *pane) place() resumePane { return resumePane{Cur: p.cur, Top: p.top, Xoff: p.xoff} } // restore moves p to the place s, within its lines. -func (p *pane) restore(s savedPane) { +func (p *pane) restore(s resumePane) { last := max(len(p.lines)-1, 0) p.cur, p.top, p.xoff = min(s.Cur, last), min(s.Top, last), s.Xoff } -// saveState writes the state to a new file, and returns its path. -func (m *model) saveState() (string, error) { - s := savedState{ - Tab: m.tab, Scope: m.scope, Base: m.base, From: m.from, To: m.to, - Changes: m.changes.place(), - Viewer: m.viewer.place(), - PR: m.prPane.place(), - Split: m.split, Wrap: m.wrap, Rendered: m.rendered, - ListLeft: m.listLeft, ListHidden: m.listHidden, FocusList: m.focusList, - ListW: m.listW, Comments: &m.comments, LastSent: m.lastSent, Beside: m.beside, +// saveResume writes the state to a new file, and returns its path. +func (m *model) saveResume() (string, error) { + s := resumeState{ + Tab: m.tab, + Scope: m.scope, + Base: m.base, + From: m.from, + To: m.to, + Changes: m.changePane.place(), + Viewer: m.allPane.place(), + PR: m.prPane.place(), + Split: m.split, + Wrap: m.wrap, + Rendered: m.rendered, + ListPos: m.listPos, + ListHidden: m.listHidden, + FocusList: m.focusList, + ListW: m.listW, + ListPct: m.listPct, + Folds: m.folds, + Comments: &m.comments, + LastSent: m.lastSent, + Beside: m.beside, } if t := m.turn; t != nil { - s.Turn = &savedTurn{t.block, t.app, t.tree, t.running, t.at} + s.Turn = &resumeTurn{App: t.app, Tree: t.tree, Running: t.running, At: t.at} } if m.prPlace != nil { // the PR has not loaded since the last restart s.PR = *m.prPlace } - if f := m.changedFile(); f != nil { + if f := m.selectedChange(); f != nil { s.ChangePath = f.Path } if m.allSel < len(m.allFiles) { @@ -94,26 +117,27 @@ func (m *model) saveState() (string, error) { if err != nil { return "", err } - f, err := os.CreateTemp("", "rexdiff-state-*.json") + f, err := os.CreateTemp(proc.Getenv(m.env, "TMPDIR"), "rexdiff-state-*.json") if err != nil { return "", err } if _, err := f.Write(b); err != nil { - f.Close() - os.Remove(f.Name()) + _ = f.Close() + _ = os.Remove(f.Name()) return "", err } return f.Name(), f.Close() } -// loadState reads the file that saveState wrote, and removes it. -func loadState(path string) (*savedState, error) { +// loadResume reads the file that saveResume wrote, and removes it. +func loadResume(path string) (*resumeState, error) { b, err := os.ReadFile(path) if err != nil { return nil, err } - os.Remove(path) - s := &savedState{Comments: new(diff.Comments)} + // A file that stays in the temporary directory does no harm. + _ = os.Remove(path) + s := &resumeState{Comments: new(diff.Comments)} if err := json.Unmarshal(b, s); err != nil { return nil, err } @@ -122,37 +146,54 @@ func loadState(path string) (*savedState, error) { // restoreSettings sets the scope, the view, and the comments of s. Call // it before the first reload, which reads the files of the scope. -func (m *model) restoreSettings(s *savedState) { +func (m *model) restoreSettings(s *resumeState) { m.tab, m.scope, m.base, m.from, m.to = s.Tab, s.Scope, s.Base, s.From, s.To if t := s.Turn; t != nil { - m.turn = &turnState{block: t.Block, app: t.App, tree: t.Tree, running: t.Running, at: t.At} + m.turn = &turnState{app: t.App, tree: t.Tree, running: t.Running, at: t.At} } m.split, m.wrap, m.rendered = s.Split, s.Wrap, s.Rendered - m.listLeft, m.listHidden, m.focusList, m.listW = s.ListLeft, s.ListHidden, s.FocusList, s.ListW + m.listPos, m.listHidden, m.focusList, m.listW = s.ListPos, s.ListHidden, s.FocusList, s.ListW + switch { + case m.listPos != "": + case s.ListLeft: + m.listPos = positionLeft + default: + m.listPos = positionRight + } + if s.ListPct > 0 { + m.listPct = s.ListPct + } + m.folds = s.Folds if s.Comments != nil { m.comments = *s.Comments + m.comments.SetOldRev(m.diffRev()) } m.lastSent, m.beside = s.LastSent, s.Beside } -// restorePlaces selects the files of s and puts the cursors where they -// were. Call it after the first reload. -func (m *model) restorePlaces(s *savedState) { - for i, f := range m.files { - if f.Path == s.ChangePath { - m.changeSel = i - } +// restorePlaces selects the files of s and moves the cursors to the places +// in s. Call it after the first reload. +func (m *model) restorePlaces(s *resumeState) { + // The directories above the selected files expand. Expand them before + // the list of all files is read, so that the list has a file in an + // ignored directory. + m.changeTree.reveal(s.ChangePath) + m.allTree.reveal(s.FilePath) + if i := slices.IndexFunc(m.changeFiles, func(f changedFile) bool { return f.Path == s.ChangePath }); i >= 0 { + m.changeSel = i } - m.allFiles, _ = m.repo.Files(m.ctx) // an error shows an empty list - for i, e := range m.allFiles { - if e.Path == s.FilePath { - m.allSel = i - } + files, _ := m.repo.Files(m.ctx) // an error shows an empty list + m.allFiles = m.withIgnored(files) + if i := slices.IndexFunc(m.allFiles, func(e vcs.Entry) bool { return e.Path == s.FilePath }); i >= 0 { + m.allSel = i } + // The cursors go to the selected files. + m.changeTree.cur, m.allTree.cur = -1, -1 + m.buildTrees() m.renderChanges() m.refreshViewer() - m.changes.restore(s.Changes) - m.viewer.restore(s.Viewer) + m.changePane.restore(s.Changes) + m.allPane.restore(s.Viewer) m.prPlace = &s.PR // the PR loads later, and onPR restores the place m.rerender() } diff --git a/tui/search.go b/tui/search.go new file mode 100644 index 0000000..5578b3c --- /dev/null +++ b/tui/search.go @@ -0,0 +1,182 @@ +package tui + +// The search finds files by name and lines of code, as in herdr-reviewr. +// It shows in an overlay. + +import ( + "context" + "fmt" + "slices" + "strings" + "time" + + tea "charm.land/bubbletea/v2" + + "github.com/rednafi/rexdiff/internal/search" + "github.com/rednafi/rexdiff/vcs" +) + +// The times of a code search, as in herdr-reviewr. After codeBudget, the +// search shows the lines that it finds, and a "+" tells that there can be +// others. A search with no line yet goes on until its first line, or +// until codeTimeout. +const ( + codeBudget = 80 * time.Millisecond + codeTimeout = 2 * time.Second +) + +// searchState has the results of the search. As in herdr-reviewr, the +// search has two modes. It shows the files that match by name, or the +// lines of the code that contain the query. +type searchState struct { + code bool // the mode that shows the code matches + paths []string // the files that the search can find + files []string // the files that match + fileTotal int // the number of the files that match + hits []vcs.Match + hitsMore bool // git finds more matches than hits has + err string // the error of the code search + preview *preview + // previewY is the line of the box of the search where the preview + // starts, with its rule. + previewY int +} + +// showSearch shows the results of the search in its mode, with the +// cursor on the first result. As in herdr-reviewr, the code matches show +// under a header for each file. A "… more" row tells that the list does +// not show all the results. An error shows in place of the results. +func (o *overlay) showSearch() { + s := &o.search + var items []pickItem + switch { + case s.err != "": + case !s.code: + for _, p := range s.files { + items = append(items, pickItem{label: p, id: p}) + } + if s.fileTotal > len(s.files) { + items = append(items, pickItem{label: "… more"}) + } + default: + for i, h := range s.hits { + if i == 0 || h.Path != s.hits[i-1].Path { + items = append(items, pickItem{label: h.Path, head: true}) + } + items = append(items, pickItem{ + label: fmt.Sprintf("%5d: %s", h.Line, h.Text), + id: h.Path, + line: h.Line, + }) + } + if s.hitsMore { + items = append(items, pickItem{label: "… more"}) + } + } + o.setItems(items) + o.cur = 0 + if len(o.shown) > 0 && !o.pickable(0) { + o.move(1) + } +} + +// chips returns the chips of the modes of the search, as in +// herdr-reviewr: "files N │ code M". A "+" after M tells that git finds +// more matches. The chip of the mode in use is bright. +func (o *overlay) chips() string { + s := &o.search + files, code := "files", "code" + if s.err == "" { + files = fmt.Sprintf("files %d", s.fileTotal) + code = fmt.Sprintf("code %d", len(s.hits)) + if s.hitsMore { + code += "+" + } + } + on := cursorStyle.Underline(true) + if s.code { + return dimStyle.Render(files+" │ ") + on.Render(code) + } + return on.Render(files) + dimStyle.Render(" │ "+code) +} + +// openSearch opens the search of the files and the code. With no query, +// it lists the files. +func (m *model) openSearch() { + ov := newOverlay(overlaySearch, "") + ov.input.Placeholder = "Search files and code…" + for _, e := range m.allFiles { + if !e.Ignored { + ov.search.paths = append(ov.search.paths, e.Path) + } + } + m.open(ov) + m.runSearch() +} + +// runSearch finds the files that match the query of the search, and +// starts a code search for the query. It stops the code search of the +// last query. Until the new code search ends, the old code matches show. +// An empty query finds no code. +func (m *model) runSearch() tea.Cmd { + ov := m.overlay + s, q := &ov.search, ov.input.Value() + s.files, s.fileTotal = search.Files(s.paths, m.recent, q, search.MaxFiles) + s.err = "" + m.stopGrep(fmt.Errorf("code search: a newer query replaces it: %w", context.Canceled)) + // A blank grep is noise, as in herdr-reviewr. + if strings.TrimSpace(q) == "" { + s.hits, s.hitsMore = nil, false + ov.showSearch() + return nil + } + ov.showSearch() + ctx, cancel := context.WithCancelCause(m.ctx) + ctx, cancelTimeout := context.WithTimeoutCause(ctx, codeTimeout, + fmt.Errorf("code search: %v timeout: %w", codeTimeout, context.DeadlineExceeded)) + m.grepCancel = cancel + repo, gen := m.repo, m.grepGen + return func() tea.Msg { + defer cancelTimeout() + defer cancel(fmt.Errorf("code search: done: %w", context.Canceled)) + hits, more, err := repo.Grep(ctx, q, search.MaxCodeLines, codeBudget) + return grepMsg{gen: gen, hits: hits, more: more, err: err} + } +} + +// addRecent puts path first in the list of the files that the search +// shows for an empty query. +func (m *model) addRecent(path string) { + rest := slices.DeleteFunc(m.recent, func(p string) bool { return p == path }) + m.recent = append([]string{path}, rest[:min(len(rest), search.MaxFiles-1)]...) +} + +// stopGrep stops the code search that runs, if any. Its result then does +// not show. cause tells why it stops. +func (m *model) stopGrep(cause error) { + m.grepGen++ + if m.grepCancel != nil { + m.grepCancel(cause) + m.grepCancel = nil + } +} + +// onGrep keeps the code matches of the search, but only if the search +// still shows their query. The list shows them in the code mode. +func (m *model) onGrep(msg grepMsg) { + ov := m.overlay + if ov == nil || ov.kind != overlaySearch || msg.gen != m.grepGen { + return + } + s := &ov.search + s.hits, s.hitsMore, s.err = msg.hits, msg.more, "" + if msg.err != nil { + // Show only the error, and no old results under it. + s.hits, s.hitsMore, s.err = nil, false, msg.err.Error() + } + // The list of the file matches stays, with its cursor. The chips + // show the count of the code matches. + if s.code { + ov.showSearch() + } +} diff --git a/tui/send.go b/tui/send.go index f0390f3..d4ad9db 100644 --- a/tui/send.go +++ b/tui/send.go @@ -1,12 +1,16 @@ package tui import ( + "cmp" + "context" + "errors" "fmt" "slices" "time" tea "charm.land/bubbletea/v2" + "github.com/rednafi/rexdiff/internal/clipboard" "github.com/rednafi/rexdiff/rex" ) @@ -19,21 +23,31 @@ const maxSend = 256 << 10 // msg.send to send the review to them. func (m *model) listAgents(msg agentsMsg) tea.Cmd { c, ctx := m.rex, m.ctx + msg.gen = m.agentsGen return func() tea.Msg { + ctx, cancel := context.WithCancelCause(ctx) + ctx, cancelTimeout := context.WithTimeoutCause(ctx, rexTimeout, + fmt.Errorf("agent list: %v timeout: %w", rexTimeout, context.DeadlineExceeded)) + defer cancelTimeout() + defer cancel(fmt.Errorf("agent list: done: %w", context.Canceled)) agents, err := c.Agents(ctx) - isRexdiff := func(a rex.Agent) bool { return a.App == appName } + isRexdiff := func(a rex.Agent) bool { return a.Status.App == appName } agents = slices.DeleteFunc(agents, isRexdiff) msg.agents, msg.err = agents, err return msg } } -// inRepo tells if dir is in the repository. An empty dir is unknown, and -// counts as in the repository. +// rexTimeout is the longest time of the calls to Rex for one action. A +// Rex that does not answer then fails the action. +const rexTimeout = 10 * time.Second + +// inRepo reports whether dir is in the repository. An empty dir is +// unknown, and counts as in the repository. func (m *model) inRepo(dir string) bool { return dir == "" || m.repo.Contains(dir) } -// agent returns the state of the agent in block. It adds the block if it is -// new. +// agent returns the state of the agent in block. If the block is new, agent +// adds it. func (m *model) agent(block string) *agentState { a := m.agents[block] if a == nil { @@ -43,81 +57,66 @@ func (m *model) agent(block string) *agentState { return a } -// onEvent follows the turns of the agents. When an agent in this -// repository starts to work, onEvent takes a snapshot of the working tree. -// The "last turn" scope compares with this snapshot. -func (m *model) onEvent(e rex.Event) { - if e.Closed { - if a := m.agents[e.Block]; a != nil { - a.state = "" - } - return +// onEvent keeps the state of the agent in the block of e, and follows the +// turns. After a reconnect, it forgets the agents and lists them again, +// as herdr-reviewr does after a lost connection. Nobody sees the agents +// in the gap, so the next state starts no turn. A list from before the +// gap is stale. An event with an error only goes to the log. +func (m *model) onEvent(e rex.Event) tea.Cmd { + if e.Err != nil { + m.log.Warn("rex events failed", "err", e.Err) + return nil } - app, state := e.Status.App, e.Status.State - m.log.Debug("status", "block", e.Block, "app", app, "state", state) - a := m.agent(e.Block) - prev := a.state - a.state = state - if app == appName || (m.rex != nil && e.Block == m.rex.Block) { - return + if e.Reconnect { + m.tracker.Gap() + clear(m.agents) + m.sampled = false + m.agentsGen++ + return m.listAgents(agentsMsg{}) } - if state == rex.StateWorking && prev != rex.StateWorking { - m.startTurn(e.Block, app) - return - } - done := state == rex.StateIdle || state == rex.StateDone || state == "" - ended := m.turn != nil && m.turn.block == e.Block && prev == rex.StateWorking - if ended && done { - m.turn.running = false - if m.scope == ScopeLastTurn { - m.reload() - } + if e.Closed { + delete(m.agents, e.Block) + } else { + app, state := e.Status.App, e.Status.State + m.log.Debug("status", "block", e.Block, "app", app, "state", state) + a := m.agent(e.Block) + a.app, a.state, a.since = app, state, cmp.Or(e.Status.At, time.Now()) } + return m.observeTurn() } -// startTurn takes a snapshot of the working tree when the agent app in -// block starts a turn in this repository. -func (m *model) startTurn(block, app string) { - a := m.agent(block) - if !a.hasCwd && m.rex != nil { - // An error gives "", and inRepo counts "" as in the repository. - a.cwd, _ = m.rex.Cwd(m.ctx, block) - a.hasCwd = true - } - if !m.inRepo(a.cwd) { - return - } - tree, err := m.repo.Snapshot(m.ctx) - if err != nil { - return - } - m.turn = &turnState{ - block: block, - app: app, - tree: tree, - running: true, - at: time.Now(), +// onAgents keeps the states of the agents. For a send, onAgents sends the +// review to the agent, or lets the user pick one. A list from before a +// reconnect does not change the states, because they can be old. +func (m *model) onAgents(msg agentsMsg) tea.Cmd { + stale := msg.gen != m.agentsGen + if stale && !msg.send { + return nil // the list after the reconnect follows } - if m.scope == ScopeLastTurn { - m.reload() + if !stale { + m.keepAgents(msg.agents) } -} - -// onAgents keeps the states of the agents. For a send, it sends the -// review to the agent, or lets the user pick one. -func (m *model) onAgents(msg agentsMsg) tea.Cmd { - for _, a := range msg.agents { - *m.agent(a.Block) = agentState{state: a.State, cwd: a.Cwd, hasCwd: true} + switch { + case stale: + case msg.err == nil: + m.sampled = true + case !m.sampled && !msg.send: + // Without a list, the turns do not start. Thus list again later. + m.log.Warn("agent list failed", "err", msg.err) + m.sampleRetry.fail(time.Now()) } + // A send drops the question for a cwd. The next state asks again. + turn := m.observeTurn() if !msg.send { - return nil + return turn } m.sends-- if msg.err != nil { m.flashCopy(msg.err.Error()) return nil } - // Prefer the agents in this repository. Else use all of the session. + // Prefer the agents in this repository. Else use all the agents of the + // session. var here []rex.Agent for _, a := range msg.agents { if m.inRepo(a.Cwd) { @@ -133,34 +132,60 @@ func (m *model) onAgents(msg agentsMsg) tea.Cmd { return nil case len(here) == 1: return m.deliver(here[0]) - case m.mode != modeNormal: + case m.mode != modeNormal && !m.inCommentList(): // The user can open the comment box or an overlay while the agents - // load. A picker on top would hide the box and lose the comment. - m.flash("several agents: close the box, then press s to pick one") + // load. A picker on top can hide the box and lose the comment. The + // picker can take the place of the comment list, as in + // herdr-reviewr. + m.flash("several agents: close the box, then press " + + firstKey(m.keys.Send) + " to pick one") return nil } m.openAgentPicker(here) return nil } +// keepAgents keeps the states of the agents of a list. +func (m *model) keepAgents(agents []rex.Agent) { + for _, a := range agents { + *m.agent(a.Block) = agentState{ + app: a.Status.App, + state: a.Status.State, + since: cmp.Or(a.Status.At, time.Now()), + cwd: a.Cwd, + hasCwd: a.Cwd != "", // else askCwd asks Rex again + } + } +} + +// commentTexts returns the texts of the comments, by ID. +func (m *model) commentTexts() map[int]string { + texts := map[int]string{} + for _, c := range m.comments.All() { + texts[c.ID] = c.Text + } + return texts +} + // reviewText returns the text of the review that a send pastes. func (m *model) reviewText() string { return m.comments.Review(m.cfg.ReviewPrefix) } -// deliver returns a command that pastes the review into agent a. Before -// the paste, it checks the agent again. A prompt of the agent would take +// deliver returns a command that pastes the review into agent a. It checks +// the agent again before the paste, because a prompt of the agent can take // the text. func (m *model) deliver(a rex.Agent) tea.Cmd { m.sends++ c, ctx := m.rex, m.ctx - text := m.reviewText() - var ids []int - for _, cm := range m.comments.All() { - ids = append(ids, cm.ID) - } - focus := m.cfg.focusAgent() + text, sent := m.reviewText(), m.commentTexts() + focus := m.cfg.focusesAgent() return func() tea.Msg { + ctx, cancel := context.WithCancelCause(ctx) + ctx, cancelTimeout := context.WithTimeoutCause(ctx, rexTimeout, + fmt.Errorf("send: %v timeout: %w", rexTimeout, context.DeadlineExceeded)) + defer cancelTimeout() + defer cancel(fmt.Errorf("send: done: %w", context.Canceled)) status, err := c.Status(ctx, a.Block) if err != nil { return sentMsg{agent: a, err: fmt.Errorf("check %s: %w", a.Name, err)} @@ -173,29 +198,27 @@ func (m *model) deliver(a rex.Agent) tea.Cmd { return sentMsg{agent: a, err: err} } if err := c.Paste(ctx, a.Block, text); err != nil { - return sentMsg{agent: a, err: err} + return sentMsg{agent: a, err: fmt.Errorf("paste into %s: %w", a.Name, err)} } if focus { - _ = c.Focus(ctx, a.Block) // ignore the error, because the paste worked + // Ignore the error, because the paste works. + _ = c.Focus(ctx, a.Block) } - return sentMsg{agent: a, ids: ids} + return sentMsg{agent: a, sent: sent} } } // onSent removes the sent comments if the send worked. The comments that -// the user added or changed during the send stay. A saved edit gives a -// comment a new ID, and msg does not have that ID. +// the user adds or changes during the send stay. func (m *model) onSent(msg sentMsg) { m.sends-- if msg.err != nil { m.flashCopy(msg.err.Error()) return } - for _, id := range msg.ids { - m.comments.Remove(id) - } + m.clearComments(msg.sent) m.lastSent = msg.agent.Block - sent := plural(len(msg.ids), "comment") + sent := plural(len(msg.sent), "comment") m.flash(fmt.Sprintf("sent %s to %s", sent, msg.agent.Name)) } @@ -209,7 +232,10 @@ func (m *model) startSend() tea.Cmd { m.flashCopy("review too large to send") return nil case m.rex == nil: - m.flashCopy("not inside Rex") + m.flashCopy("not in Rex") + return nil + case m.sends > 0: + m.flash("a send runs") return nil } m.flash("sending…") @@ -217,19 +243,74 @@ func (m *model) startSend() tea.Cmd { return m.listAgents(agentsMsg{send: true}) } -// copyComments copies the review to the clipboard. -func (m *model) copyComments() tea.Cmd { +// copyComments copies the review to the clipboard. A good copy clears +// the comments, as a send does. +func (m *model) copyComments() { if m.comments.Len() == 0 { m.flash("no comments yet") - return nil + return + } + texts := m.commentTexts() + m.copyText(m.reviewText(), "copied "+plural(len(texts), "comment"), texts) +} + +// copyTimeout is the longest time of a copy. +const copyTimeout = 2 * time.Second + +// copyText writes text to the clipboard. If the copy works, it removes +// the comments texts and shows done. As in herdr-reviewr, the copy runs +// before the next frame, so two copies keep their order. A clipboard tool +// that does not answer fails after copyTimeout. As in herdr-reviewr, the +// message of a failure names only the fix that the user can make. The log +// has the error. +func (m *model) copyText(text, done string, texts map[int]string) { + ctx, cancel := context.WithCancelCause(m.ctx) + ctx, cancelTimeout := context.WithTimeoutCause(ctx, copyTimeout, + fmt.Errorf("copy: %v timeout: %w", copyTimeout, context.DeadlineExceeded)) + defer cancelTimeout() + defer cancel(fmt.Errorf("copy: done: %w", context.Canceled)) + err := clipboard.Write(ctx, m.env, text) + switch { + case errors.Is(err, clipboard.ErrNoTool): + m.flash("copy failed: install wl-clipboard, xclip, or xsel") + case err != nil: + m.log.Warn("copy failed", "err", err) + m.flash("copy failed") + default: + m.clearComments(texts) + m.flash(done) + } +} + +// inCommentList reports whether the comment list is open. +func (m *model) inCommentList() bool { + return m.mode == modeOverlay && m.overlay != nil && m.overlay.kind == overlayComments +} + +// clearComments removes the comments of texts after a send or a copy. A +// comment whose text is not the text in texts stays, because the user +// edits it during the send. An open comment list shows the rest, or +// closes if no comment stays. +func (m *model) clearComments(texts map[int]string) { + for id, text := range texts { + if c := m.comments.Find(id); c != nil && c.Text == text { + m.comments.Remove(id) + } + } + if m.inCommentList() { + if m.comments.Len() == 0 { + m.closeOverlay() + return + } + m.fillCommentList(m.overlay) } - m.flash("copied " + plural(m.comments.Len(), "comment")) - return tea.SetClipboard(m.reviewText()) } // flashCopy shows why the review cannot go to an agent, and tells the // user to copy it. -func (m *model) flashCopy(why string) { m.flash(why + ", press y to copy") } +func (m *model) flashCopy(why string) { + m.flash(why + ", press " + firstKey(m.keys.Copy) + " to copy") +} // plural returns n and word, with an s if n is not 1. func plural(n int, word string) string { diff --git a/tui/tree.go b/tui/tree.go new file mode 100644 index 0000000..38bf1c5 --- /dev/null +++ b/tui/tree.go @@ -0,0 +1,299 @@ +package tui + +// This file keeps the file list of each tab as a tree of directories, as +// in herdr-reviewr. The cursor of the list moves over the directories and +// the files. The pane shows the last file that the cursor is on. + +import ( + "slices" + "strings" + + "github.com/rednafi/rexdiff/internal/tree" + "github.com/rednafi/rexdiff/vcs" +) + +// fileListTree is the file list of a tab. +type fileListTree struct { + entries []tree.Entry + rows []tree.Row + cur int // the row of the cursor + // top is the first row on the screen while free is true. free is true + // after the wheel moves the view. Then the cursor can be off the + // screen until the next key. + top int + free bool + // expanded is true if the directories start expanded. toggled has the + // directories that the user changes from that start. + expanded bool + toggled map[string]bool +} + +// rebuild makes the rows of entries. The cursor stays on its directory or +// its file. If the row is gone, or the cursor is -1, the cursor goes to +// the file with index open, else to the first file. +func (t *fileListTree) rebuild(entries []tree.Entry, expanded bool, open int) { + var dir, path string + if t.cur >= 0 && t.cur < len(t.rows) { + if r := t.rows[t.cur]; r.File < 0 { + dir = r.Dir + } else { + path = t.entries[r.File].Path + } + } + t.entries, t.expanded = entries, expanded + t.rows = tree.Build(entries, t.toggled, expanded) + t.cur = slices.IndexFunc(t.rows, func(r tree.Row) bool { + if r.File < 0 { + return dir != "" && r.Dir == dir + } + return path != "" && entries[r.File].Path == path + }) + if t.cur < 0 { + t.cur = t.rowOf(open) + } + if t.cur < 0 { + t.cur = t.nextFile(-1, 1) + } + t.cur = max(t.cur, 0) +} + +// file returns the index of the file at the cursor, or -1 on a directory. +func (t *fileListTree) file() int { + if t.cur >= len(t.rows) { + return -1 + } + return t.rows[t.cur].File +} + +// dir returns the row at the cursor if it is a directory. +func (t *fileListTree) dir() (tree.Row, bool) { + if t.cur >= len(t.rows) || t.rows[t.cur].File >= 0 { + return tree.Row{}, false + } + return t.rows[t.cur], true +} + +// rowOf returns the row of the file with index i, or -1 if no row shows +// it. +func (t *fileListTree) rowOf(i int) int { + if i < 0 { + return -1 + } + return slices.IndexFunc(t.rows, func(r tree.Row) bool { return r.File == i }) +} + +// nextFile returns the first file row after row from in direction d (1 or +// -1), or -1. +func (t *fileListTree) nextFile(from, d int) int { + for r := from + d; r >= 0 && r < len(t.rows); r += d { + if t.rows[r].File >= 0 { + return r + } + } + return -1 +} + +// setExpanded expands or collapses the directory dir. It reports whether +// the state changes. +func (t *fileListTree) setExpanded(dir string, want bool) bool { + if (t.expanded != t.toggled[dir]) == want { + return false + } + if t.toggled == nil { + t.toggled = map[string]bool{} + } + if t.toggled[dir] { + delete(t.toggled, dir) + } else { + t.toggled[dir] = true + } + return true +} + +// reveal expands each directory above path. It reports whether one +// changes. +func (t *fileListTree) reveal(path string) bool { + changed := false + for dir, _, ok := strings.CutLast(path, "/"); ok; dir, _, ok = strings.CutLast(dir, "/") { + changed = t.setExpanded(dir, true) || changed + } + return changed +} + +// list returns the file list of the current tab, or nil on the PR tab. +func (m *model) list() *fileListTree { + switch m.tab { + case tabChanges: + return &m.changeTree + case tabFiles: + return &m.allTree + } + return nil +} + +// buildChangeTree makes the rows of the Changes tab. The selected file is +// the file at the cursor, if the cursor is on a file. +func (m *model) buildChangeTree() { + es := make([]tree.Entry, len(m.changeFiles)) + for i, f := range m.changeFiles { + es[i] = tree.Entry{Path: f.Path, Changed: true} + } + t := &m.changeTree + t.rebuild(es, true, m.changeSel) + if i := t.file(); i >= 0 { + m.changeSel = i + } + m.changeSel = max(m.changeSel, 0) +} + +// buildFileTree makes the rows of the Files tab. A directory holds a +// change if a file under it is in the Changes tab. The selected file is +// the file at the cursor, if the cursor is on a file. +func (m *model) buildFileTree() { + changed := make(map[string]bool, len(m.changeFiles)) + for _, f := range m.changeFiles { + changed[f.Path] = true + } + es := make([]tree.Entry, len(m.allFiles)) + for i, e := range m.allFiles { + p, isDir := strings.CutSuffix(e.Path, "/") + es[i] = tree.Entry{Path: p, Changed: changed[p], Ignored: e.Ignored, Dir: isDir} + } + t := &m.allTree + t.rebuild(es, false, m.allSel) + if i := t.file(); i >= 0 { + m.allSel = i + } + m.allSel = min(max(m.allSel, 0), max(len(m.allFiles)-1, 0)) +} + +// buildTree makes the rows of the file list of the current tab. +func (m *model) buildTree() { + switch m.tab { + case tabChanges: + m.buildChangeTree() + case tabFiles: + m.buildFileTree() + } +} + +// withIgnored adds to es the entries of each ignored directory that the +// user expands in the Files tab, and sorts es by path. +func (m *model) withIgnored(es []vcs.Entry) []vcs.Entry { + // The loop also reads the entries that it adds, for the directories + // in an ignored directory. + for i := 0; i < len(es); i++ { + if dir, ok := m.openIgnored(es[i]); ok { + es = append(es, m.repo.IgnoredDir(dir)...) + } + } + slices.SortStableFunc(es, func(a, b vcs.Entry) int { return strings.Compare(a.Path, b.Path) }) + return es +} + +// shownPaths returns the paths for Watcher.Show when the Files tab +// shows: the ignored directories that the user expands and the open file. +// The watcher reports their changes, as in herdr-reviewr. +func (m *model) shownPaths() []string { + if m.tab != tabFiles { + return nil + } + var paths []string + for _, e := range m.allFiles { + if dir, ok := m.openIgnored(e); ok { + paths = append(paths, dir) + } + } + if m.allSel < len(m.allFiles) { + if e := m.allFiles[m.allSel]; !strings.HasSuffix(e.Path, "/") { + paths = append(paths, e.Path) + } + } + return paths +} + +// openIgnored returns the path of e without the last "/". It also reports +// whether e is an ignored directory that the user expands in the Files +// tab. +func (m *model) openIgnored(e vcs.Entry) (string, bool) { + dir, isDir := strings.CutSuffix(e.Path, "/") + return dir, isDir && e.Ignored && m.allTree.toggled[dir] +} + +// atDir reports whether the file list has the focus and its cursor is on +// a directory. +func (m *model) atDir() bool { + if t := m.list(); t != nil && m.focusList { + _, ok := t.dir() + return ok + } + return false +} + +// setDir expands or collapses the directory at the cursor of the file +// list. An ignored directory reads its entries when it expands. +func (m *model) setDir(want bool) { + t := m.list() + r, ok := t.dir() + if !ok || !t.setExpanded(r.Dir, want) { + return + } + if m.tab == tabFiles && r.Ignored && want { + m.readFiles() + } + m.buildTree() +} + +// stepRow moves the cursor of the file list by d rows. On a file, the pane +// shows the file. +func (m *model) stepRow(d int) { + t := m.list() + if t == nil || len(t.rows) == 0 { + return + } + t.cur = min(max(t.cur+d, 0), len(t.rows)-1) + if i := t.file(); i >= 0 && i != m.listSel() { + m.pickFile(i) + } +} + +// stepFile shows the next (d = 1) or the previous (d = -1) file of the +// list, past the directories. It starts from the cursor of the list when +// the list has the focus, else from the file that shows. +func (m *model) stepFile(d int) { + t := m.list() + if t == nil { + return + } + from := t.cur + if r := t.rowOf(m.listSel()); !m.focusList && r >= 0 { + from = r + } + if r := t.nextFile(from, d); r >= 0 { + m.pickFile(t.rows[r].File) + } +} + +// clickRow handles a click on row r of the file list. A file shows, and a +// directory expands or collapses. +func (m *model) clickRow(r int) { + t := m.list() + if t == nil || r < 0 || r >= len(t.rows) { + return + } + m.focusList = true + t.cur = r + if d, ok := t.dir(); ok { + m.setDir(!d.Expanded) + return + } + m.pickFile(t.rows[r].File) +} + +// revealFile expands the directories above path in the file list of the +// current tab, so that its row shows. +func (m *model) revealFile(path string) { + if t := m.list(); t != nil && t.reveal(path) { + m.buildTree() + } +} diff --git a/tui/tui.go b/tui/tui.go index 26107fa..1b83744 100644 --- a/tui/tui.go +++ b/tui/tui.go @@ -8,12 +8,12 @@ import ( "errors" "fmt" "log/slog" - "slices" - "strings" + "sync" tea "charm.land/bubbletea/v2" "github.com/rednafi/rexdiff/rex" + "github.com/rednafi/rexdiff/update" "github.com/rednafi/rexdiff/vcs" ) @@ -27,11 +27,12 @@ type Options struct { // Rex is the Rex session of the process, or nil outside Rex. Without // Rex, the user cannot send the review. The user can only copy it. Rex *rex.Client - // Config is the settings as JSON, from the top of rexdiff.lua. An - // empty text means the default settings. + // Config is the settings as JSON, from rexdiff.lua. An empty text + // means the default settings. Config string // Layout and Scope override the settings if they are not empty. Set - // them from the command-line flags. + // them from the command-line flags -layout and -scope. The error of a + // bad value names the flag. Layout Layout Scope Scope // Agent is the block ID or the label of the agent that the program @@ -49,21 +50,35 @@ type Options struct { Notice string // Updater installs a newer release, or is nil if rexdiff cannot update // itself. If it is not nil, the program asks for the latest release. - Updater Updater + Updater *update.Updater // Resume is the file of the state that Run returned before an update, // or "". Run reads it and removes it. Resume string + // Env is the environment of the editor, the URL opener, and the + // clipboard tool, for example the environment of the process from + // os.Environ. Run finds them in its PATH, and reads $VISUAL, $EDITOR, + // $TMPDIR, and the variables of the terminal from it. + Env []string } // check returns an error for options that Run cannot use. func (o Options) check() error { - switch { - case o.Repo == nil: + if o.Repo == nil { return errors.New("no repository") - case o.Layout != "" && !slices.Contains(layouts, o.Layout): - return invalid("layout", "one of "+strings.Join(names(layouts...), ", "), string(o.Layout)) - case o.Scope != "" && !slices.Contains(configScopes, o.Scope): - return invalid("scope", "one of "+strings.Join(names(configScopes...), ", "), string(o.Scope)) + } + return CheckFlags(o.Layout, o.Scope) +} + +// CheckFlags returns the error of the values of the flags -layout and +// -scope, or nil if rexdiff accepts them. An empty value is the default. +func CheckFlags(layout Layout, scope Scope) error { + if layout != "" { + if err := choice("-layout", layout, layouts); err != nil { + return err + } + } + if scope != "" { + return choice("-scope", scope, configScopes) } return nil } @@ -78,9 +93,12 @@ func CheckConfig(text string) error { // Run shows the review program in the terminal. It returns when the user // quits or when ctx is done. Run returns an error before it starts if the // settings are not valid. It also returns an error if Repo is nil, or if -// it does not know the Layout or the Scope. After an update, Run returns +// Run does not know the Layout or the Scope. After an update, Run returns // the file of the saved state. The caller then runs the new rexdiff with -// Options.Resume set to that file. +// Options.Resume set to that file. In that case Run keeps the alternate +// screen on, with the last frame and no cursor, so that the restart does +// not show the shell. If the restart fails, the caller leaves the +// alternate screen and shows the cursor. func Run(ctx context.Context, o Options) (resume string, err error) { if err := o.check(); err != nil { return "", err @@ -89,39 +107,71 @@ func Run(ctx context.Context, o Options) (resume string, err error) { if err != nil { return "", err } - ctx, cancel := context.WithCancel(ctx) - defer cancel() + ctx, cancel := context.WithCancelCause(ctx) + defer cancel(errQuit) - m := newModel(ctx, o, cfg) - var saved *savedState - if o.Resume != "" { - if saved, err = loadState(o.Resume); err != nil { - m.flash("cannot read the comments from before the update: " + err.Error()) - } else { - m.restoreSettings(saved) - } - } - m.reload() - if saved != nil { - m.restorePlaces(saved) - } - if o.Rex != nil && o.Rex.Block != "" { - if err := o.Rex.SetLabel(ctx, o.Rex.Block, appName); err != nil { - m.log.Debug("set label", "err", err) - } + m := startModel(ctx, o, cfg) + // Bubble Tea reads TERM and COLORTERM from env. A nil env makes it + // read the environment of the process. + env := o.Env + if env == nil { + env = []string{} } - program := tea.NewProgram(&m, tea.WithContext(ctx)) + program := tea.NewProgram(&m, tea.WithContext(ctx), tea.WithEnvironment(env)) + var wg sync.WaitGroup if o.Rex != nil { - go o.Rex.Watch(ctx, func(e rex.Event) { program.Send(eventMsg(e)) }) + // Watch returns only the cause of ctx, when ctx is done. + wg.Go(func() { + _ = o.Rex.Watch(ctx, func(e rex.Event) { program.Send(eventMsg(e)) }) + }) } final, err := program.Run() + cancel(errQuit) + wg.Wait() if fm, ok := final.(*model); ok && err == nil { - if fm.restartState != "" { + if fm.resumePath != "" { // Keep the last screen until the new rexdiff draws, so that // the restart does not show the shell of the pane. fmt.Print("\x1b[?1049h\x1b[?25l\x1b[H" + fm.View().Content) } - return fm.restartState, nil + return fm.resumePath, nil } return "", err } + +// errQuit is the cause of the stop of the work of Run when rexdiff quits. +var errQuit = fmt.Errorf("rexdiff quits: %w", context.Canceled) + +// startModel returns the model that Run shows. It starts the watcher, +// reads the state of o.Resume, loads the changes, and labels the block of +// the program in Rex. +func startModel(ctx context.Context, o Options, cfg config) model { + m := newModel(ctx, o, cfg) + if err := m.startWatch(); err != nil { + m.dropWatch(err) + } + var saved *resumeState + if o.Resume != "" { + var err error + if saved, err = loadResume(o.Resume); err != nil { + m.flash("restore of the comments failed: " + err.Error()) + } else { + m.restoreSettings(saved) + } + } + m.loadNow() + if saved != nil { + m.restorePlaces(saved) + } + if o.Rex != nil && o.Rex.Block() != "" { + ctx, cancel := context.WithCancelCause(ctx) + ctx, cancelTimeout := context.WithTimeoutCause(ctx, rexTimeout, + fmt.Errorf("rex label: %v timeout: %w", rexTimeout, context.DeadlineExceeded)) + if err := o.Rex.SetLabel(ctx, o.Rex.Block(), appName); err != nil { + m.log.Debug("set label failed", "err", err) + } + cancel(fmt.Errorf("rex label: done: %w", context.Canceled)) + cancelTimeout() + } + return m +} diff --git a/tui/tui_test.go b/tui/tui_test.go deleted file mode 100644 index 78f1e1d..0000000 --- a/tui/tui_test.go +++ /dev/null @@ -1,840 +0,0 @@ -package tui - -import ( - "encoding/json" - "errors" - "image/color" - "os" - "os/exec" - "path/filepath" - "slices" - "strings" - "testing" - - tea "charm.land/bubbletea/v2" - uv "github.com/charmbracelet/ultraviolet" - "github.com/charmbracelet/x/ansi" - - "github.com/rednafi/rexdiff/diff" - "github.com/rednafi/rexdiff/rex" - "github.com/rednafi/rexdiff/vcs" -) - -// newTestModel makes a repository and a model that shows it. The branch -// main has a.go. The branch feat adds b.go. The working tree changes a.go. -func newTestModel(t testing.TB) (model, string) { - t.Helper() - dir := t.TempDir() - run := func(args ...string) { git(t, dir, args...) } - run("init", "-q", "-b", "main") - write(t, dir, "a.go", "package a\n\nfunc A() int {\n\treturn 1\n}\n") - run("add", ".") - run("commit", "-qm", "init") - run("checkout", "-qb", "feat") - write(t, dir, "b.go", "package a\n") - run("add", ".") - run("commit", "-qm", "add b") - write(t, dir, "a.go", "package a\n\nfunc A() int {\n\tx := 2\n\treturn x\n}\n") - - repo, err := vcs.Open(t.Context(), dir) - if err != nil { - t.Fatal(err) - } - m := newModel(t.Context(), Options{Repo: repo}, defaultConfig()) - m.reload() - m = step(t, m, tea.WindowSizeMsg{Width: 120, Height: 40}) - return m, dir -} - -// git runs git in dir and fails the test if git fails. -func git(t testing.TB, dir string, args ...string) { - t.Helper() - cmd := exec.Command("git", args...) - cmd.Dir = dir - cmd.Env = append( - os.Environ(), - "GIT_AUTHOR_NAME=t", - "GIT_AUTHOR_EMAIL=t@t", - "GIT_COMMITTER_NAME=t", - "GIT_COMMITTER_EMAIL=t@t", - ) - if out, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("git %v: %s", args, out) - } -} - -// write writes a file in dir and fails the test if it cannot. -func write(t testing.TB, dir, name, content string) { - t.Helper() - if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil { - t.Fatal(err) - } -} - -// step gives msg to m and returns the new model. -func step(t testing.TB, m model, msg tea.Msg) model { - t.Helper() - next, _ := m.Update(msg) - return *next.(*model) -} - -// press sends keys to m. A key is a name, such as "enter", or the text -// that the key types, such as "j". -func press(t *testing.T, m model, keys ...string) model { - t.Helper() - named := map[string]rune{ - "enter": tea.KeyEnter, - "esc": tea.KeyEscape, - "home": tea.KeyHome, - "end": tea.KeyEnd, - } - for _, k := range keys { - code, ok := named[k] - switch { - case ok: - m = step(t, m, tea.KeyPressMsg{Code: code}) - case len(k) > 1 && strings.Trim(k, "abcdefghijklmnopqrstuvwxyz") == "": - t.Fatalf("press: no key is named %q", k) - default: - m = step(t, m, tea.KeyPressMsg{Code: []rune(k)[0], Text: k}) - } - } - return m -} - -// view returns the screen of m without escape sequences. -func view(m model) string { return ansi.Strip(m.View().Content) } - -// shows reports whether the screen of m has each of texts. -func shows(m model, texts ...string) bool { - v := view(m) - for _, s := range texts { - if !strings.Contains(v, s) { - return false - } - } - return true -} - -// status returns the event that the agent app in block is in state. -func status(block, app string, state rex.State) eventMsg { - return eventMsg{Block: block, Status: rex.Status{App: app, State: state}} -} - -// paths returns the paths of the files in the Changes tab. -func paths(m model) []string { - var ps []string - for _, f := range m.files { - ps = append(ps, f.Path) - } - return ps -} - -func TestScopes(t *testing.T) { - m, dir := newTestModel(t) - if got := paths(m); !slices.Equal(got, []string{"a.go"}) { - t.Fatalf("uncommitted scope shows %v, want [a.go]", got) - } - if f := m.files[0]; f.adds != 2 || f.dels != 1 { - t.Errorf("a.go: +%d -%d, want +2 -1", f.adds, f.dels) - } - - m = press(t, m, "b") - if got := paths(m); !slices.Equal(got, []string{"a.go", "b.go"}) { - t.Errorf("branch scope vs %s shows %v, want [a.go b.go]", m.base, got) - } - - // The commit picker lists the newest commit first. The enter key picks it. - m = press(t, m, "G", "enter") - if got := paths(m); m.scope != scopeCommits || !slices.Equal(got, []string{"b.go"}) { - t.Errorf("commits scope shows %v, want [b.go]", got) - } - // A click on the commit in the header opens the commit picker again. - top, _, _ := strings.Cut(view(m), "\n") - i := strings.Index(top, shortSHA(m.to)) - m = step(t, m, tea.MouseClickMsg{Button: tea.MouseLeft, X: ansi.StringWidth(top[:i])}) - if m.mode != modeOverlay || m.overlay.kind != overlayCommits { - t.Errorf("a click on the commit in the header did not open the commit picker") - } - m = press(t, m, "esc") - - // The turn scope shows only what changed during the turn of an agent. - m = press(t, m, "t") - if !strings.Contains(m.changes.note, "no agent turn") { - t.Errorf("turn scope before a turn: note %q, want a note that no turn ran", m.changes.note) - } - m = step(t, m, status("block:agent", "pi", rex.StateWorking)) - write(t, dir, "c.go", "package a\n") - m = step(t, m, status("block:agent", "pi", rex.StateIdle)) - if got := paths(m); !slices.Equal(got, []string{"c.go"}) || m.turn.running { - t.Errorf("turn scope shows %v (running %v), want [c.go]", got, m.turn.running) - } - if v := view(m); !strings.Contains(v, "pi · ") { - t.Errorf("header does not name the agent of the turn:\n%s", v) - } -} - -func TestComment(t *testing.T) { - m, _ := newTestModel(t) - - // The unified layout uses three rows for the change. - m = press(t, m, "]", "v", "j", "j", "c") - if m.mode != modeComment { - t.Fatal("c did not open the comment box") - } - m = press(t, m, "h", "i", "enter") - // The split layout uses two rows for the same change. - m = press(t, m, "|", "home", "]", "v", "j", "c", "o", "k", "enter") - - cs := m.comments.All() - if len(cs) != 2 { - t.Fatalf("got %d comments, want 2", len(cs)) - } - rows := "a.go:4-5\n-\treturn 1\n+\tx := 2\n+\treturn x\n" - if got, want := m.comments.Review(""), rows+"hi\n\n"+rows+"ok"; got != want { - t.Errorf("Review:\n%s\nwant:\n%s", got, want) - } - if !shows(m, "💬 hi", "2 unsent comments") { - t.Errorf("view:\n%s\nwant the comments", view(m)) - } - - m = press(t, m, "s") - if !strings.Contains(m.status, "not inside Rex") { - t.Errorf("send outside Rex: status %q, want \"not inside Rex\"", m.status) - } - next, cmd := m.Update(tea.KeyPressMsg{Code: 'y', Text: "y"}) - if m = *next.(*model); cmd == nil || m.status != "copied 2 comments" { - t.Errorf("copy: status %q, command %v", m.status, cmd) - } - - // Quit asks first, because the comments are not sent. - if m = press(t, m, "q"); m.overlay == nil || m.overlay.kind != overlayQuit { - t.Error("q with unsent comments did not ask to confirm") - } -} - -func TestTabs(t *testing.T) { - m, _ := newTestModel(t) - - m = press(t, m, "2") - if !shows(m, "Files (2)", "package a") { - t.Errorf("Files tab:\n%s\nwant 2 files, with a.go shown", view(m)) - } - - // The repository has no remote, so the PR tab shows why. - m = press(t, m, "3") - m = step(t, m, m.loadPR()()) - if v := view(m); !strings.Contains(v, "no git remote") { - t.Errorf("the PR tab does not say that there is no git remote:\n%s", v) - } -} - -// TestPRChecks checks that the PR tab shows the counts of the checks of a -// pull request, and the names of the checks that failed. -func TestPRChecks(t *testing.T) { - m, _ := newTestModel(t) - m = press(t, m, "3") - pr := &vcs.PullRequest{ - Title: "T", - State: vcs.StateOpen, - Checks: []vcs.Check{ - {Name: "test", Result: vcs.ResultPass}, - {Name: "docs", Result: vcs.ResultSkip}, - {Name: "build", Result: vcs.ResultFail}, - {Name: "e2e", Result: vcs.ResultPending}, - }, - } - m = step(t, m, prMsg{pr: pr}) - if !shows(m, "checks: ✓ 2 ✗ 1 ◌ 1", "✗ build") { - t.Errorf("the PR tab does not show the checks:\n%s", view(m)) - } -} - -// TestSmallScreens presses every key on screens down to 1x1. The program -// must not panic, and the view must fit the screen. -func TestSmallScreens(t *testing.T) { - keys := []string{ - "w", "j", "v", "j", "c", "\x1b[31mx\x07", "enter", "f", "F", "]", "[", "n", "N", - "|", "p", "m", "t", "b", "u", "G", "esc", "/", "a", "esc", ":", "2", "enter", "?", - "esc", "2", "j", "m", "3", "1", "x", "d", "y", "s", "esc", "q", "esc", "home", - } - sizes := []struct{ w, h int }{ - {0, 0}, {1, 1}, {2, 2}, {10, 3}, {20, 5}, {40, 4}, {200, 6}, - } - for _, size := range sizes { - m, _ := newTestModel(t) - m = step(t, m, tea.WindowSizeMsg{Width: size.w, Height: size.h}) - for _, k := range keys { - m = press(t, m, k) - lines := strings.Split(view(m), "\n") - if len(lines) > max(size.h, 1) { - t.Errorf("%dx%d after %q: %d lines", size.w, size.h, k, len(lines)) - } - for _, l := range lines { - if w := ansi.StringWidth(l); w > max(size.w, 1) { - t.Errorf("%dx%d after %q: line width %d: %q", size.w, size.h, k, w, l) - break - } - } - } - } -} - -// TestDragAfterReload drags while the pane gets shorter. The program must -// not panic. -func TestDragAfterReload(t *testing.T) { - m, _ := newTestModel(t) - m.drag = dragState{active: true, gutter: true, moved: true, start: 48} - m = step(t, m, tea.MouseReleaseMsg{Button: tea.MouseLeft}) - - m.changes.set(nil, nil) // a reload found no changes - m.drag = dragState{active: true} - m = step(t, m, tea.MouseMotionMsg{Button: tea.MouseLeft, Y: m.h}) - view(m) -} - -// screenY returns the screen line of row i of the current pane. -func screenY(t *testing.T, m model, i int) int { - t.Helper() - _, rowAt := m.mainLines(m.bodyHeight()) - y := slices.Index(rowAt, i) - if y < 0 { - t.Fatalf("row %d is not on the screen", i) - } - return bodyY + y -} - -// click presses and releases the left button at x, y. -func click(t *testing.T, m model, x, y int) model { - t.Helper() - m = step(t, m, tea.MouseClickMsg{Button: tea.MouseLeft, X: x, Y: y}) - return step(t, m, tea.MouseReleaseMsg{Button: tea.MouseLeft, X: x, Y: y}) -} - -// TestClickOldNumber clicks the old line number of an unchanged line. The -// comment goes on the new side, so that it shows and d can delete it. -func TestClickOldNumber(t *testing.T) { - m, _ := newTestModel(t) - equal := func(r diff.Row) bool { return r.Kind == diff.KindEqual } - i := slices.IndexFunc(m.changes.rows, equal) - m = click(t, m, m.mainX()+gutter, screenY(t, m, i)) - m = press(t, m, "h", "i", "enter") - if v := view(m); !strings.Contains(v, "💬 hi") { - t.Errorf("the comment does not show:\n%s", v) - } - if m = press(t, m, "d"); m.comments.Len() != 0 { - t.Errorf("d did not delete the comment: %+v", m.comments.All()) - } -} - -// TestClickCommentBox clicks while the comment box is open. No click -// changes the draft or closes the box. -func TestClickCommentBox(t *testing.T) { - m, _ := newTestModel(t) - m = press(t, m, "]", "c", "h", "i") - _, rowAt := m.mainLines(m.bodyHeight()) - box := bodyY + slices.Index(rowAt, m.changes.cur) + 1 - clicks := []struct { - where string - y int - }{ - {"on", box}, - {"outside", screenY(t, m, 0)}, - } - for _, c := range clicks { - m = click(t, m, m.mainX()+6, c.y) - if m.mode != modeComment || m.editor.Value() != "hi" { - t.Errorf("a click %s the box: mode %v, draft %q; want the box open with hi", - c.where, m.mode, m.editor.Value()) - } - } -} - -// TestSendError sends the review when Rex fails. The footer shows the -// error of Rex. -func TestSendError(t *testing.T) { - m, dir := newTestModel(t) - bin := filepath.Join(dir, "rex") - script := "#!/bin/sh\necho 'Error: no Rex server' >&2\nexit 1\n" - if err := os.WriteFile(bin, []byte(script), 0o755); err != nil { - t.Fatal(err) - } - t.Setenv("REX_SESSION", "S") - t.Setenv("REX_BIN_DIR", dir) - m.rex = rex.FromEnv() - m = press(t, m, "]", "c", "h", "i", "enter") - m = step(t, m, m.deliver(rex.Agent{Block: "b", Name: "pi"})()) - if !strings.Contains(m.status, "no Rex server") { - t.Errorf("status %q, want the error of Rex", m.status) - } -} - -// TestMissingHead removes the object of the HEAD commit. The Changes tab -// shows the error, and not each file as added. -func TestMissingHead(t *testing.T) { - m, dir := newTestModel(t) - sha, err := exec.Command("git", "-C", dir, "rev-parse", "HEAD").Output() - if err != nil { - t.Fatal(err) - } - hex := strings.TrimSpace(string(sha)) - obj := filepath.Join(dir, ".git", "objects", hex[:2], hex[2:]) - if err := os.Remove(obj); err != nil { - t.Fatal(err) - } - m.reload() - if v := view(m); len(m.files) > 0 || !strings.Contains(v, "unknown revision HEAD") { - t.Errorf("files %v, want none and the error:\n%s", paths(m), v) - } -} - -// errorHas reports whether err is not nil and has each of texts. -func errorHas(err error, texts ...string) bool { - if err == nil { - return false - } - for _, s := range texts { - if !strings.Contains(err.Error(), s) { - return false - } - } - return true -} - -// TestParseConfig checks the settings that the Rex action sends as JSON. -// Each action of herdr-reviewr has a key. An unknown key or a bad value is -// an error. -func TestParseConfig(t *testing.T) { - // The actions in herdr-reviewr's src/keymap.rs. - names := strings.Fields(`base-pick collapse comment comments commit-pick copy - delete down edit expand find goto-line half-down half-up keys navigator-grow - navigator-hide navigator-position navigator-shrink next-comment next-file - next-hunk open-pr page-down page-up prev-comment prev-file prev-hunk quit - quit-discard refresh rendered scope-branch scope-commits scope-last-turn - scope-uncommitted search select send split tab-all-files tab-changes tab-pr up - wrap`) - keys := map[string][]string{} - for _, n := range names { - keys[n] = []string{"x"} - } - all, err := json.Marshal(map[string]any{"keybindings": keys}) - if err != nil { - t.Fatal(err) - } - if _, err := parseConfig(string(all)); err != nil { - t.Errorf("every action: %v", err) - } - - unknown := []struct{ text, key string }{ - {`{"layuot": "split"}`, `"layuot"`}, - {`{"keybindings": {"next-fiel": ["x"]}}`, `"keybindings.next-fiel"`}, - {`{"theme": "monokai"}`, `"theme"`}, - // Lua keys are case-sensitive, so a key in another case is unknown, - // and cannot skip the checks. - {`{"EDITOR": " "}`, `"EDITOR"`}, - {`{"keybindings": {"Send": ["x"]}}`, `"keybindings.Send"`}, - } - for _, c := range unknown { - if _, err := parseConfig(c.text); !errorHas(err, "config: unknown key "+c.key) { - t.Errorf("config %s: error %v, want unknown key %s", c.text, err, c.key) - } - } - - // Each bad value is an error that names the key, the allowed values, - // and the value. - const ( - editor = "editor: expected a command with only {file} and {line}" - opener = "url_opener: expected a command with only {url}" - scopes = "default_scope: expected one of uncommitted, branch, last-turn" - mdView = "markdown_view: expected one of source, rendered" - host = "gitlab_host: expected a bare hostname" - keyList = "keybindings.send: expected a list of keys" - ) - bad := []struct{ text, want string }{ - {`{"layout": "sideways"}`, `layout: expected one of unified, split, not "sideways"`}, - {`{"default_scope": "commits"}`, scopes + `, not "commits"`}, - {`{"markdown_view": "preview"}`, mdView + `, not "preview"`}, - {`{"file_list": "top"}`, `file_list: expected one of right, left, not "top"`}, - {`{"file_list_width": 3}`, `file_list_width: expected 16 or more, not "3"`}, - {`{"editor": " "}`, editor + `, not " "`}, - {`{"editor": "code {fil"}`, editor + `, not "code {fil"`}, - {`{"editor": "code {FILE}"}`, `editor: expected`}, - {`{"url_opener": "open {ur}"}`, opener + `, not "open {ur}"`}, - {`{"review_prefix": ""}`, `review_prefix: expected text, not ""`}, - {`{"gitlab_host": "https://git.x"}`, host + `, not "https://git.x"`}, - {`{"keybindings": {"send": []}}`, keyList}, - // Lua encodes an empty table as an object. - {`{"keybindings": {"send": {}}}`, keyList + `, not a table`}, - {`{"keybindings": {"send": [1]}}`, `keybindings.send.0: expected text, not a number`}, - {`{"layout": 1}`, `layout: expected text, not a number`}, - {`{"file_list_width": 40.5}`, `file_list_width: expected a whole number, not a number`}, - {`{"file_list_width": "wide"}`, `file_list_width: expected a whole number, not text`}, - {`{"focus_agent_after_send": "yes"}`, `expected true or false, not text`}, - {`["layout"]`, `config: expected a table of settings`}, - {`{"layout": `, `config: `}, - {`{"layout": "split", "layout": "unified"}`, `duplicate`}, - } - for _, c := range bad { - if _, err := parseConfig(c.text); !errorHas(err, c.want) { - t.Errorf("config %s: error %v, want %s", c.text, err, c.want) - } - } - - good := `{ - "editor": "code -g {file}:{line}", - "gitlab_host": "git.corp.example", - "focus_agent_after_send": false, - "file_list": "left" - }` - c, err := parseConfig(good) - if err != nil { - t.Fatalf("good config: %v", err) - } - if c.focusAgent() || c.FileList != positionLeft || c.Layout != LayoutUnified { - t.Errorf("good config = %+v, want no focus, the list on the left, "+ - "and the default layout", c) - } - if c, err := parseConfig(""); err != nil || c.Layout != defaultConfig().Layout { - t.Errorf(`parseConfig("") = %+v, %v; want the defaults`, c, err) - } -} - -// TestConfigFlags checks that the flags win over the settings, and that -// the settings set the keys and the file list. -func TestConfigFlags(t *testing.T) { - m, _ := newTestModel(t) - cfg, err := parseConfig(`{ - "layout": "unified", - "default_scope": "branch", - "file_list": "left", - "file_list_width": 40, - "keybindings": {"split": ["X"]} - }`) - if err != nil { - t.Fatal(err) - } - o := Options{Repo: m.repo, Layout: LayoutSplit} - m = newModel(t.Context(), o, cfg) - if !m.split || m.scope != ScopeBranch || !m.listLeft || m.listW != 40 { - t.Errorf("split %v, scope %q, list left %v, width %d; want true, branch, true, 40", - m.split, m.scope, m.listLeft, m.listW) - } - o.Scope = ScopeLastTurn - if m := newModel(t.Context(), o, cfg); m.scope != ScopeLastTurn { - t.Errorf("scope %q; want the flag, last-turn", m.scope) - } - m = step(t, m, tea.WindowSizeMsg{Width: 120, Height: 40}) - m = press(t, m, "X") - if m.split { - t.Error("the key X of the configuration did not change the layout") - } -} - -// TestNotice checks that the footer shows all of a long notice, on its own -// lines, so that the user can read what went wrong. -func TestNotice(t *testing.T) { - m, _ := newTestModel(t) - // The path makes the first line end in "--" at column 82, past the - // edge, if the wrap breaks at the hyphen. - notice := `cannot upgrade rexdiff.lua: /Users/rednafi/.config/rex/rexdiff.lua has no line ` + - `"-- rexdiff code below. Install and upgrades replace it. Do not edit this line.", ` + - strings.Repeat("the end word ", 20) + "LAST " + strings.Repeat("x", 100) - m = newModel(t.Context(), Options{Repo: m.repo, Notice: notice}, defaultConfig()) - m = step(t, m, tea.WindowSizeMsg{Width: 80, Height: 30}) - view := ansi.Strip(m.View().Content) - // Each word of the notice is in the view, so a hyphen does not push - // "--" past the edge of the screen. - words := strings.Fields(view) - for _, w := range strings.Fields(notice)[:30] { - if !slices.Contains(words, w) { - t.Errorf("the view does not show %q of the notice:\n%s", w, view) - } - } - if !strings.Contains(view, strings.Repeat("x", 80)) { - t.Errorf("the view does not show the start of a word wider than the screen") - } - for line := range strings.SplitSeq(view, "\n") { - if ansi.StringWidth(line) > 80 { - t.Errorf("line wider than the screen: %q", line) - } - } -} - -// TestPalette checks that inserted lines get their tint from the green of -// the terminal, as in pi's system theme. -func TestPalette(t *testing.T) { - m, _ := newTestModel(t) - m = step(t, m, tea.BackgroundColorMsg{Color: color.Black}) - // The tint is black, mixed 16% toward pure green. - const tint = "48;2;0;40;0m" - if strings.Contains(m.View().Content, tint) { - t.Fatal("the view has the tint before the terminal reports its green") - } - m = step(t, m, uv.UnknownOscEvent("\x1b]4;2;rgb:0000/ffff/0000\x1b\\")) - if !strings.Contains(m.View().Content, tint) { - t.Error("inserted lines are not tinted toward the green of the terminal") - } -} - -// wrapModel shows a change that makes line 4 of a.go too long for the -// screen, which is 80 columns wide. Wrap is off. -func wrapModel(t *testing.T) (m model, dir, long string) { - t.Helper() - m, dir = newTestModel(t) - long = "\treturn " + strings.Repeat("x + ", 40) + "1" - write(t, dir, "a.go", "package a\n\nfunc A() int {\n"+long+"\n}\n") - m.reload() - m = step(t, m, tea.WindowSizeMsg{Width: 80, Height: 40}) - return m, dir, long -} - -// twoHunks commits 20 equal lines below line 4 of a.go. Then it changes -// line 4 and the line below the 20 lines, so the change has two hunks. -func twoHunks(t *testing.T, m model, dir, long string) model { - t.Helper() - same := strings.Repeat("\tx++\n", 20) - write(t, dir, "a.go", "package a\n\nfunc A() int {\n\treturn 1\n"+same+"}\n") - git(t, dir, "commit", "-qam", "same") - write(t, dir, "a.go", "package a\n\nfunc A() int {\n"+long+"\n"+same+"\treturn 9\n}\n") - m.reload() - return m -} - -// TestWrap checks that the w key toggles wrap, and that the cursor moves -// by source lines. A comment on wrapped lines gets the same -// snippet. -func TestWrap(t *testing.T) { - m, _, long := wrapModel(t) - cut := len(m.changes.lines) - - m = press(t, m, "w") - if !m.wrap || len(m.changes.lines) <= cut { - t.Fatalf("w: wrap %v, %d lines, want more than %d", - m.wrap, len(m.changes.lines), cut) - } - if v := view(m); !strings.Contains(v, "x + 1") { - t.Errorf("the end of the long line does not show:\n%s", v) - } - m = press(t, m, "]") - for range len(m.changes.lines) { - if m.changes.cont(m.changes.cur) { - t.Fatalf("the cursor is on a continued row %d", m.changes.cur) - } - m = press(t, m, "j") - } - - m = press(t, m, "home", "]", "v", "j", "c", "o", "k", "enter") - cs := m.comments.All() - want := "\n-\treturn 1\n+" + long + "\nok" - if r := m.comments.Review(""); len(cs) != 1 || !strings.HasSuffix(r, want) { - t.Fatalf("Review:\n%s\nwant one comment that ends with:%s", r, want) - } - // The comment shows below the last row of the wrapped line. - lines := strings.Split(view(m), "\n") - i := slices.IndexFunc(lines, func(l string) bool { - return strings.Contains(l, "💬 ok") - }) - if i < 1 || !strings.Contains(lines[i-1], "x + 1") { - t.Errorf("the comment is not below the end of the line:\n%s", view(m)) - } - - // The sideways scroll applies only without wrap. - if m = step(t, m, tea.KeyPressMsg{Code: tea.KeyRight}); m.changes.xoff != 0 { - t.Errorf("→ with wrap scrolled to %d", m.changes.xoff) - } - if m = press(t, m, "w"); m.wrap || len(m.changes.lines) != cut { - t.Errorf("w again: wrap %v, %d lines, want %d", m.wrap, len(m.changes.lines), cut) - } - // A w in the Files tab applies to the Changes tab too. - if m = press(t, m, "2", "w", "w", "1"); len(m.changes.lines) != cut { - t.Errorf("w in the Files tab: the Changes tab has %d lines, want %d", - len(m.changes.lines), cut) - } -} - -// TestWrapKeepsCursor checks that w keeps the cursor on its source line or -// on its hunk header. -func TestWrapKeepsCursor(t *testing.T) { - m, dir, long := wrapModel(t) - m = press(t, m, "end", "w") - if got := m.target(m.changes.cur, diff.SideNew).line; got != 5 { - t.Errorf("w moved the cursor from line 5 to line %d", got) - } - - m = twoHunks(t, m, dir, long) - m.changes.cur = hunks(m)[1] - if m = press(t, m, "w"); m.changes.cur != hunks(m)[1] { - t.Errorf("w moved the cursor from the second hunk header to row %+v", - m.changes.rows[m.changes.cur]) - } -} - -// hunks returns the rows of the hunk headers in the Changes tab. -func hunks(m model) []int { - var rs []int - for i, r := range m.changes.rows { - if r.Kind == diff.KindHunk { - rs = append(rs, i) - } - } - return rs -} - -// TestTallLine checks a wrapped line that is taller than the screen. -func TestTallLine(t *testing.T) { - m, dir, long := wrapModel(t) - m = press(t, m, "w") - m = twoHunks(t, m, dir, long) - m = step(t, m, tea.WindowSizeMsg{Width: 30, Height: 12}) - - // The wheel scrolls past the line. - m = press(t, m, "home") - wheel := []tea.MouseButton{tea.MouseWheelDown, tea.MouseWheelDown, tea.MouseWheelUp} - for _, b := range wheel { - top := m.changes.top - m = step(t, m, tea.MouseWheelMsg{Button: b, X: 1, Y: 5}) - m = step(t, m, tea.BlurMsg{}) // a later event keeps the view - if m.changes.top == top { - t.Fatalf("the wheel stopped at row %d of %d", top, len(m.changes.lines)) - } - } - // A taller screen shows more rows, not blank space below the view. - m = step(t, m, tea.WindowSizeMsg{Width: 30, Height: 40}) - if last := max(len(m.changes.lines)-m.bodyHeight(), 0); m.changes.top > last { - t.Errorf("top %d of %d rows on a screen of %d rows", - m.changes.top, len(m.changes.lines), m.bodyHeight()) - } - if m = press(t, m, "j"); m.changes.top > m.changes.cur { - t.Errorf("j does not show the cursor: top %d, cursor %d", - m.changes.top, m.changes.cur) - } - - // A cursor on the line shows its first rows. The - // comment box shows below the last row. A c on a line with a comment - // edits the comment. - m = step(t, m, tea.WindowSizeMsg{Width: 30, Height: 12}) - m = press(t, m, "home") - for m.changes.rows[m.changes.cur].New != 4 { - m = press(t, m, "j") - } - if v := view(m); !strings.Contains(v, "return x") { - t.Errorf("the first rows of the line do not show:\n%s", v) - } - m = press(t, m, "c", "o", "k", "enter", "c", "z", "z") - lines := strings.Split(view(m), "\n") - has := func(s string) func(string) bool { - return func(l string) bool { return strings.Contains(l, s) } - } - i := slices.IndexFunc(lines, has("okzz")) - if i < 0 || !slices.ContainsFunc(lines[:i], has("+ 1")) { - t.Errorf("the comment box is not below the end of the line:\n%s", view(m)) - } - if strings.Contains(view(m), "💬 ok") { - t.Errorf("the comment shows above the box that edits it:\n%s", view(m)) - } -} - -// TestHostileText puts escape sequences in outside data: a file name, a -// comment, an agent, and an error from Rex. The screen must not get them -// raw. The diff tests check the review text. -func TestHostileText(t *testing.T) { - const osc = "\x1b]0;owned\x07" // sets the title of the terminal - raw := func(where string, m model) { - t.Helper() - if strings.Contains(m.View().Content, osc) { - t.Errorf("%s: the screen has the escape sequence raw", where) - } - } - m, dir := newTestModel(t) - write(t, dir, "e"+osc+"\n.go", "x"+osc+"\n") - m.reload() - raw("file list", m) - m = press(t, m, "f", "]", "c") - m = step(t, m, tea.PasteMsg{Content: "hi" + osc + "\x1b[201~\r"}) - m = press(t, m, "enter") - raw("comment", m) - if cs := m.comments.All(); len(cs) != 1 || !strings.HasPrefix(cs[0].Path, "e\x1b") { - t.Fatalf("comments %+v, want one on the file with the escape sequence", cs) - } - - m = step(t, m, sentMsg{agent: rex.Agent{Name: "pi" + osc}}) - raw("the status after a send", m) - m = step(t, m, sentMsg{err: errors.New("Rex" + osc)}) - raw("the status after an error", m) - m = step(t, m, status("b", "pi"+osc, rex.StateWorking)) - m = press(t, m, "t") - raw("the turn in the header", m) -} - -// TestMouseOffScreen sends mouse events outside the screen, also while an -// overlay is open and the screen changes size. The program must not panic. -func TestMouseOffScreen(t *testing.T) { - m, _ := newTestModel(t) - for _, keys := range [][]string{{}, {"G"}, {"/"}, {"2"}, {"3"}} { - m = press(t, m, keys...) - points := []struct{ x, y int }{ - {-1, -1}, {-50, 3}, {3, -50}, {1 << 30, 1 << 30}, {m.w, m.h}, - } - for _, pt := range points { - mouse := tea.Mouse{X: pt.x, Y: pt.y, Button: tea.MouseLeft} - m = step(t, m, tea.MouseClickMsg(mouse)) - m = step(t, m, tea.MouseMotionMsg(mouse)) - m = step(t, m, tea.MouseReleaseMsg(mouse)) - mouse.Button = tea.MouseWheelDown - m = step(t, m, tea.MouseWheelMsg(mouse)) - } - m = step(t, m, tea.WindowSizeMsg{Width: 5, Height: 3}) - m = press(t, m, "w", "w", "w") - m = step(t, m, tea.WindowSizeMsg{Width: 120, Height: 40}) - view(m) - m = press(t, m, "esc", "1") - } -} - -// TestEmptyRepo presses keys in each tab of a repository with no commits -// and no files, with key bindings that conflict. The program must not -// panic. -func TestEmptyRepo(t *testing.T) { - dir := t.TempDir() - git(t, dir, "init", "-q", "-b", "main") - repo, err := vcs.Open(t.Context(), dir) - if err != nil { - t.Fatal(err) - } - cfg := defaultConfig() - cfg.Keybindings = keybindings{ - Down: []string{}, - Comment: []string{"j", "c"}, - Send: []string{"x"}, - Copy: []string{"x"}, - } - m := newModel(t.Context(), Options{Repo: repo}, cfg) - m.reload() - keys := []string{ - "j", "k", "v", "c", "x", "enter", "f", "]", "n", "|", "m", "t", "b", "g", "G", - "enter", "B", "enter", "/", "a", "enter", ":", "9", "enter", "l", "esc", "e", "d", - "y", "s", "o", "r", "z", "<", "home", "end", "w", "esc", - } - for _, tab := range []string{"1", "2", "3"} { - m = press(t, m, tab) - for _, k := range keys { - m = press(t, m, k) - view(m) - } - } -} - -// TestFooterHints checks the footer in the select and the find modes. It -// shows the keys of the mode. -func TestFooterHints(t *testing.T) { - m, _ := newTestModel(t) - if m = press(t, m, "]", "v"); !strings.Contains(view(m), "extend") { - t.Errorf("select mode: the footer has no hint:\n%s", view(m)) - } - m = press(t, m, "esc") - m = step(t, m, tea.KeyPressMsg{Code: 'f', Mod: tea.ModCtrl}) - m = press(t, m, "r", "e", "t", "u", "r", "n", "enter") - if !strings.Contains(view(m), "next/prev match") { - t.Errorf("find mode: the footer has no hint:\n%s", view(m)) - } -} diff --git a/tui/turn.go b/tui/turn.go new file mode 100644 index 0000000..e6b1b85 --- /dev/null +++ b/tui/turn.go @@ -0,0 +1,322 @@ +package tui + +import ( + "context" + "fmt" + "maps" + "slices" + "time" + + tea "charm.land/bubbletea/v2" + + "github.com/rednafi/rexdiff/rex" +) + +// member is an agent that works in the repository. +type member struct { + block, app string + state rex.State + since time.Time // when Rex got the state +} + +// members returns the agents that work in the repository, in the order +// of their blocks. As in herdr-reviewr, an agent is a member if its +// working directory is absolute and in this working tree. If the place of +// an agent is not known, members returns false, and the block of the +// agent if Rex can tell its place. +func (m *model) members() (ms []member, ask string, ok bool) { + for _, block := range slices.Sorted(maps.Keys(m.agents)) { + a := m.agents[block] + if a.state == "" || a.app == appName || (m.rex != nil && block == m.rex.Block()) { + continue + } + if !a.hasCwd { + if m.rex == nil { + return nil, "", false + } + return nil, block, false + } + in, err := m.owns(a.cwd) + if err != nil { + // Each event asks again. The log has each error once. + if text := err.Error(); text != m.ownsErr { + m.ownsErr = text + m.log.Warn("agent membership failed", "err", err) + } + return nil, "", false + } + if in { + ms = append(ms, member{block, a.app, a.state, a.since}) + } + } + m.ownsErr = "" + return ms, "", true +} + +// askCwd returns a command that asks Rex for the working directory of the +// agent in block. The answer comes as a cwdMsg. +func (m *model) askCwd(block string) tea.Cmd { + a := m.agents[block] + if a.asking { + return nil + } + a.asking = true + c, ctx := m.rex, m.ctx + return func() tea.Msg { + ctx, cancel := context.WithCancelCause(ctx) + ctx, cancelTimeout := context.WithTimeoutCause(ctx, rexTimeout, + fmt.Errorf("rex cwd: %v timeout: %w", rexTimeout, context.DeadlineExceeded)) + defer cancelTimeout() + defer cancel(fmt.Errorf("rex cwd: done: %w", context.Canceled)) + cwd, err := c.Cwd(ctx, block) + return cwdMsg{block: block, cwd: cwd, err: err} + } +} + +// onCwd keeps the working directory of an agent, and follows the turns +// with it. After an error, the next state asks again. +func (m *model) onCwd(msg cwdMsg) tea.Cmd { + a := m.agents[msg.block] + if a == nil { + return nil // the block closed + } + a.asking = false + if msg.err != nil { + m.log.Debug("agent cwd failed", "block", msg.block, "err", msg.err) + return nil + } + a.cwd, a.hasCwd = msg.cwd, true + return m.observeTurn() +} + +// owns reports whether dir is in this working tree. It keeps the +// directories that are, because their top directory does not move. +func (m *model) owns(dir string) (bool, error) { + if m.memberDirs[dir] { + return true, nil + } + in, err := m.repo.Owns(m.ctx, dir) + if in { + if m.memberDirs == nil { + m.memberDirs = map[string]bool{} + } + m.memberDirs[dir] = true + } + return in, err +} + +// observeTurn gives the state of the repository to the tracker, and +// follows the start and the end of a turn. +func (m *model) observeTurn() tea.Cmd { + if !m.sampled { + return nil + } + ms, ask, ok := m.members() + if ask != "" { + return m.askCwd(ask) + } + if !ok { + return nil // keep the last state, as herdr-reviewr does + } + here := len(ms) > 0 + if m.agentsHere == nil || *m.agentsHere != here { + m.agentsHere = &here + if m.scope == ScopeLastTurn && m.turn == nil { + m.reload() // show the new wait message + } + } + states := make([]string, len(ms)) + for i, a := range ms { + states[i] = string(a.state) + } + started, ended := m.tracker.Observe(states...) + if started { + return m.startTurn(ms) + } + if ended { + return m.endTurn() + } + return nil +} + +// turnWaitMessage tells why the last-turn scope has no turn. The messages +// come from herdr-reviewr. +func (m *model) turnWaitMessage() string { + if m.raced { + return "the last turn started before rexdiff could snapshot it" + } + if m.agentsHere != nil && !*m.agentsHere { + return "no agent works here" + } + return "waiting for the first turn" +} + +// raceBefore is how long before Rex gets the working state the agent can +// already write, as in herdr-reviewr. +const raceBefore = time.Second + +// startTurn returns a command that takes a snapshot of the working tree +// at the start of a turn of the members ms. The snapshot comes as a +// turnStartMsg. +func (m *model) startTurn(ms []member) tea.Cmd { + m.pending = nil + m.turnGen++ + i := slices.IndexFunc(ms, func(a member) bool { return a.state == rex.StateWorking }) + gen, a, repo, ctx := m.turnGen, ms[i], m.repo, m.ctx + return func() tea.Msg { + ctx, cancel := context.WithCancelCause(ctx) + ctx, cancelTimeout := context.WithTimeoutCause(ctx, reloadTimeout, + fmt.Errorf("turn snapshot: %v timeout: %w", reloadTimeout, context.DeadlineExceeded)) + defer cancelTimeout() + defer cancel(fmt.Errorf("turn snapshot: done: %w", context.Canceled)) + tree, err := repo.Snapshot(ctx) + return turnStartMsg{gen: gen, agent: a, tree: tree, snapped: time.Now(), err: err} + } +} + +// turnStartMsg is the snapshot at the start of a turn. +type turnStartMsg struct { + gen int // the value of model.turnGen at the start + agent member // the first agent of the turn that works + tree string + snapped time.Time // the end of the snapshot + err error +} + +// onTurnStart makes the turn of msg pending. The turn stays pending until +// it changes a file. As in herdr-reviewr, a write from raceBefore the start +// to the snapshot can be in the snapshot. Then the turn is not known, and +// the scope says so. A start or an end after the start of msg drops msg. +func (m *model) onTurnStart(msg turnStartMsg) tea.Cmd { + if msg.gen != m.turnGen { + return nil + } + if msg.err != nil { + m.log.Warn("turn snapshot failed", "err", msg.err) + return nil + } + a := msg.agent + if m.watch != nil { + // A write after the snapshot does not hide a write before it. + w := m.watch.LastWrite(msg.snapped) + if !w.Before(a.since.Add(-raceBefore)) && !w.After(msg.snapped) { + m.log.Debug("turn raced", "block", a.block, "write", w) + return m.dropTurn() + } + } + m.log.Debug("turn start", "block", a.block, "tree", msg.tree) + m.pending = &turnState{ + app: a.app, + tree: msg.tree, + running: true, + at: time.Now(), + } + return nil +} + +// checkTurnLater returns a command that takes a snapshot for the pending +// turn. onTurnCheck makes the turn the last turn if the working tree +// differs from its start. A check at the end of the turn always runs. +// Else one check runs at a time. +func (m *model) checkTurnLater(end bool) tea.Cmd { + if m.turnChecking && !end { + return nil + } + m.turnChecking = m.turnChecking || !end + p, gen, repo, ctx := m.pending, m.turnGen, m.repo, m.ctx + return func() tea.Msg { + ctx, cancel := context.WithCancelCause(ctx) + ctx, cancelTimeout := context.WithTimeoutCause(ctx, reloadTimeout, + fmt.Errorf("turn check: %v timeout: %w", reloadTimeout, context.DeadlineExceeded)) + defer cancelTimeout() + defer cancel(fmt.Errorf("turn check: done: %w", context.Canceled)) + tree, err := repo.Snapshot(ctx) + return turnCheckMsg{turn: p, gen: gen, end: end, tree: tree, err: err} + } +} + +// turnCheckMsg is a snapshot for the pending turn. +type turnCheckMsg struct { + turn *turnState + gen int // the value of model.turnGen at the check + end bool // the check is at the end of the turn + tree string + err error +} + +// onTurnCheck makes the turn of msg the last turn if the working tree +// differs from its start. Before the end, the turn must still be pending. +// After the end, no new turn must start. Else the last turn stays. +func (m *model) onTurnCheck(msg turnCheckMsg) tea.Cmd { + if !msg.end { + m.turnChecking = false + } + switch { + case msg.err != nil: + m.log.Warn("turn change check failed", "err", msg.err) + case msg.tree == msg.turn.tree: + case msg.end && msg.gen == m.turnGen, !msg.end && msg.turn == m.pending: + return m.promote(msg.turn) + } + return nil +} + +// dropTurn forgets the last turn, because a new turn starts that is not +// known. It returns a command that deletes the saved tree. +func (m *model) dropTurn() tea.Cmd { + m.turn, m.raced = nil, true + if m.scope == ScopeLastTurn { + m.reload() + } + return m.saveTurn("") +} + +// promote makes the turn t the last turn. It returns a command that saves +// its tree. +func (m *model) promote(t *turnState) tea.Cmd { + m.turn, m.raced = t, false + if m.pending == t { + m.pending = nil + } + if m.scope == ScopeLastTurn { + m.reload() + } + return m.saveTurn(t.tree) +} + +// saveTurn returns a command that saves tree as the tree of the last turn. +// An empty tree deletes the saved tree. +func (m *model) saveTurn(tree string) tea.Cmd { + repo, ctx, log := m.repo, m.ctx, m.log + return func() tea.Msg { + ctx, cancel := context.WithCancelCause(ctx) + ctx, cancelTimeout := context.WithTimeoutCause(ctx, reloadTimeout, + fmt.Errorf("turn save: %v timeout: %w", reloadTimeout, context.DeadlineExceeded)) + defer cancelTimeout() + defer cancel(fmt.Errorf("turn save: done: %w", context.Canceled)) + if err := repo.SaveTurn(ctx, tree); err != nil { + log.Warn("turn baseline ref write failed", "err", err) + } + return nil + } +} + +// endTurn ends the turn. It returns a command that checks the pending turn. +// The turn becomes the last turn if it changes the working tree. After the +// end, a change is not from the turn. +func (m *model) endTurn() tea.Cmd { + m.turnGen++ // a start that is not pending yet stays out + var cmd tea.Cmd + if m.pending != nil { + m.pending.running = false + cmd = m.checkTurnLater(true) + m.pending = nil + } + if m.turn != nil { + m.turn.running = false + } + if m.scope == ScopeLastTurn { + m.reload() + } + return cmd +} diff --git a/tui/view.go b/tui/view.go index b36b677..dfddb46 100644 --- a/tui/view.go +++ b/tui/view.go @@ -13,17 +13,21 @@ import ( "github.com/charmbracelet/x/ansi" "github.com/rednafi/rexdiff/diff" + "github.com/rednafi/rexdiff/internal/termtext" + "github.com/rednafi/rexdiff/internal/tree" "github.com/rednafi/rexdiff/vcs" ) // The colours are ANSI palette indices, so they follow the terminal theme. var ( - dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) + // dimColor is the colour of the dim text and of the borders. + dimColor = lipgloss.Color("8") + dimStyle = lipgloss.NewStyle().Foreground(dimColor) + boldStyle = lipgloss.NewStyle().Bold(true) addStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("2")) delStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("1")) cursorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("3")).Bold(true) noteStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("3")) - staleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Strikethrough(true) headerStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("4")) activeStyle = lipgloss.NewStyle().Bold(true).Underline(true) findStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("6")).Bold(true) @@ -32,14 +36,20 @@ var ( // truncate cuts s to w columns, with an ellipsis. func truncate(s string, w int) string { return ansi.Truncate(s, max(w, 0), "…") } -// selStyle returns the style of a selected row: a tint of the terminal -// background. +// dark reports whether the terminal background is dark. A background that +// is not known counts as dark. +func (m *model) dark() bool { + return m.bg == nil || tea.BackgroundColorMsg{Color: m.bg}.IsDark() +} + +// selStyle returns the style of a selected row. The style is a tint of the +// terminal background. func (m *model) selStyle() lipgloss.Style { if m.bg == nil { return lipgloss.NewStyle().Reverse(true) } tint := lipgloss.Lighten(m.bg, 0.15) - if !m.dark { + if !m.dark() { tint = lipgloss.Darken(m.bg, 0.15) } return lipgloss.NewStyle().Bold(true).Background(tint) @@ -132,11 +142,13 @@ func (m *model) headerScopes(l *headerLine) { case scopeCommits: l.add(m.commitsLabel(), headerCommits) case ScopeLastTurn: - l.add(m.turnLabel(), headerNone) + if label := m.turnLabel(); label != "" { + l.add(label, headerNone) + } } } -// commitsLabel names the commits of the commits scope in the header. +// commitsLabel returns the header text for the commits of the commits scope. func (m *model) commitsLabel() string { if m.to == "" { return "pick ▾" @@ -151,28 +163,30 @@ func (m *model) commitsLabel() string { // shortSHA returns the first 7 characters of a commit hash. func shortSHA(s string) string { return s[:min(len(s), 7)] } -// turnLabel returns the text about the last turn of an agent, for the -// header. +// turnLabel returns the text about the last turn, for the header. As in +// herdr-reviewr, a saved turn from an earlier run has no label. With no +// turn, the Changes tab tells why, so the header shows nothing. func (m *model) turnLabel() string { - if m.turn == nil { - return dimStyle.Render("waiting for a turn") + if m.turn == nil || m.turn.at.IsZero() { + return "" } ago := time.Since(m.turn.at).Round(time.Second) - app := diff.Quote(m.turn.app) // the app name comes from the agent + app := termtext.Quote(m.turn.app) // the app name comes from the agent if m.turn.running { return noteStyle.Render("● ") + app + dimStyle.Render(" working · "+ago.String()) } return app + dimStyle.Render(" · "+ago.String()+" ago") } -// agentLabel tells how many agents can get the next send. +// agentLabel returns the header text that tells how many agents can get +// the next send. func (m *model) agentLabel() string { if m.rex == nil { return dimStyle.Render("not in Rex") } n := 0 for block, a := range m.agents { - if a.state != "" && block != m.rex.Block { + if a.state != "" && a.app != appName && block != m.rex.Block() { n++ } } @@ -183,7 +197,7 @@ func (m *model) agentLabel() string { } // statusMark returns the mark of a changed file in the file list. -func statusMark(s vcs.Status) string { +func statusMark(s vcs.FileStatus) string { switch s { case vcs.StatusAdded: return addStyle.Render("A") @@ -197,25 +211,28 @@ func statusMark(s vcs.Status) string { return " " } -// listLines renders h lines of the file list: a title and the files. +// listLines renders h lines of the file list. The lines are a title and the +// rows of the tree. func (m *model) listLines(h int) []string { + t := m.list() + if t == nil { + return nil + } w := m.listWidth() - var title string - var n int - var row func(i int) string // the loop makes only the rows on the screen - switch m.tab { - case tabChanges: - title, n = fmt.Sprintf("Changes (%d)", len(m.files)), len(m.files) - row = func(i int) string { return m.changedRow(m.files[i], w) } - case tabFiles: - title, n = fmt.Sprintf("Files (%d)", len(m.allFiles)), len(m.allFiles) - row = func(i int) string { return m.entryRow(m.allFiles[i], w) } + title := fmt.Sprintf("Changes (%d)", len(m.changeFiles)) + if m.tab == tabFiles { + n := 0 + for _, e := range m.allFiles { + if !strings.HasSuffix(e.Path, "/") { + n++ + } + } + title = fmt.Sprintf("Files (%d)", n) } out := []string{headerStyle.Render(title)} - sel := m.listSel() - for i := m.listStart(h); i < n && len(out) < h; i++ { - r := row(i) - if i == sel { + for i := m.listStart(h); i < len(t.rows) && len(out) < h; i++ { + r := m.treeRow(t.rows[i], w) + if i == t.cur { r = m.selectedRow(r, w) } out = append(out, r) @@ -223,31 +240,62 @@ func (m *model) listLines(h int) []string { return out } -// changedRow renders changed file f in the file list, w columns wide. -func (m *model) changedRow(f changed, w int) string { +// treeRow renders row r of the file list, w columns wide. +func (m *model) treeRow(r tree.Row, w int) string { + indent := strings.Repeat(" ", r.Depth) + switch { + case r.File < 0: + return m.dirRow(r, indent, w) + case m.tab == tabChanges: + return m.changedRow(m.changeFiles[r.File], indent, r.Name, w) + } + return m.entryRow(m.allFiles[r.File], indent, r.Name, w) +} + +// dirRow renders directory r of the file list, w columns wide. As in +// herdr-reviewr, a collapsed directory of the Files tab has a dot if it +// holds a change. +func (m *model) dirRow(r tree.Row, indent string, w int) string { + arrow, mark := "▸", "" + if r.Expanded { + arrow = "▾" + } + if m.tab == tabFiles && !r.Expanded && r.Changed { + mark = noteStyle.Render("•") + } + room := w - ansi.StringWidth(indent) - lipgloss.Width(mark) - 4 + name := boldStyle.Render(fitName(r.Name+"/", room)) + if r.Ignored { + name = dimStyle.Render(fitName(r.Name+"/", room)) + } + return " " + indent + dimStyle.Render(arrow) + " " + name + " " + mark +} + +// changedRow renders changed file f in the file list, w columns wide. The +// row shows the name in the tree, after indent. +func (m *model) changedRow(f changedFile, indent, name string, w int) string { adds := addStyle.Render(fmt.Sprintf("+%d", f.adds)) stat := adds + " " + delStyle.Render(fmt.Sprintf("-%d", f.dels)) - if c := m.comments.Count(f.Path); c > 0 { + if c := m.comments.Count(m.anchorOf(tabChanges), f.Path); c > 0 { stat = cursorStyle.Render(fmt.Sprintf("●%d ", c)) + stat } - name := f.Path if f.From != "" { - name = f.From + " → " + f.Path + name = f.From + " → " + name } - return fileRow(statusMark(f.Status), name, stat, w) + return fileRow(indent, statusMark(f.Status), name, stat, w) } -// entryRow renders file f of the Files tab in the file list, w columns -// wide. -func (m *model) entryRow(f vcs.Entry, w int) string { - if f.Ignored { - return dimStyle.Render(fileRow(" ", f.Path, "", w)) +// entryRow renders entry e of the Files tab in the file list, w columns +// wide. The row shows the name in the tree, after indent. +func (m *model) entryRow(e vcs.Entry, indent, name string, w int) string { + if e.Ignored { + return dimStyle.Render(fileRow(indent, " ", name, "", w)) } stat := "" - if c := m.comments.Count(f.Path); c > 0 { + if c := m.comments.Count(m.anchorOf(tabFiles), e.Path); c > 0 { stat = cursorStyle.Render(fmt.Sprintf("●%d", c)) } - return fileRow(" ", f.Path, stat, w) + return fileRow(indent, " ", name, stat, w) } // selectedRow highlights row r of the selected file, w columns wide. @@ -260,14 +308,30 @@ func (m *model) selectedRow(r string, w int) string { return style.Render(cursorStyle.Render("▌") + ansi.TruncateLeft(r, 1, "")) } -// listStart returns the index of the first file that the list shows. -func (m *model) listStart(h int) int { return max(0, m.listSel()-(h-2)) } +// listStart returns the first row that the file list shows in h lines. +// The first line is the title. +func (m *model) listStart(h int) int { + t := m.list() + switch { + case t == nil: + return 0 + case t.free: + return min(max(t.top, 0), max(len(t.rows)-(h-1), 0)) + } + return max(0, t.cur-(h-2)) +} -// fileRow returns " M name/of/file.go +3 -1". If the path is too long, it -// removes the directory first, then the start of the name. -func fileRow(mark, p, stat string, w int) string { - p = diff.Quote(p) - room := w - lipgloss.Width(stat) - 4 +// fileRow returns " M name/of/file.go +3 -1", with indent before the +// mark. +func fileRow(indent, mark, p, stat string, w int) string { + room := w - ansi.StringWidth(indent) - lipgloss.Width(stat) - 4 + return " " + indent + mark + " " + fitName(p, room) + " " + stat +} + +// fitName returns p in room columns, with spaces after it. If p is too +// long, fitName removes the directory first, then the start of the name. +func fitName(p string, room int) string { + p = termtext.Quote(p) if n := ansi.StringWidth(p); n > room { if base := path.Base(p); ansi.StringWidth(base)+2 <= room { p = "…/" + base @@ -275,13 +339,14 @@ func fileRow(mark, p, stat string, w int) string { p = "…" + ansi.TruncateLeft(p, n-room+1, "") } } - pad := strings.Repeat(" ", max(room-ansi.StringWidth(p), 0)) // by width, not runes - return " " + mark + " " + p + pad + " " + stat + // Pad by width, not by runes. + return p + strings.Repeat(" ", max(room-ansi.StringWidth(p), 0)) } // mainLines renders the visible part of the main pane. rowAt[y] is the row -// on screen line y, or -1 for a note, a comment, or the comment box. -func (m *model) mainLines(h int) (lines []string, rowAt []int) { +// on screen line y, or -1 for a note, a comment, or the comment box. hits +// are the find hits that get a mark. The marks do not change rowAt. +func (m *model) mainLines(h int, hits []findHit) (lines []string, rowAt []int) { p := m.cur() if note := m.emptyNote(); note != "" { return []string{"", " " + dimStyle.Render(note)}, []int{-1, -1} @@ -299,13 +364,19 @@ func (m *model) mainLines(h int) (lines []string, rowAt []int) { add(l, -1) } } + marked := map[int]bool{} + for _, hit := range hits { + if hit.line == 0 { // a hit in a fold has no row of its own + marked[hit.row] = true + } + } for i := p.top; i < len(p.lines) && len(lines) < h; i++ { // A wrapped line shows its marks on its first row, and its // comments below its last row. t := m.target(i, diff.SideNew) - add(m.rowMark(i, t, lo, hi)+p.lines[i], i) + add(m.rowMark(i, t, lo, hi, marked[i])+p.lines[i], i) if t != nil && p.end(i) == i { - addBelow(m.commentsBelow(t)) + addBelow(m.commentsBelow(i, t)) } if m.mode == modeComment && i == p.end(p.cur) { addBelow(m.commentBox()) @@ -321,9 +392,13 @@ func (m *model) emptyNote() string { case len(p.lines) > 0: return "" case p.note != "": - return p.note - case m.tab == tabPR && m.pr.loading: + return termtext.Quote(p.note) // the note can come from git or a forge + case m.tab == tabPR && m.prf.inFlight: return "loading the pull request…" + case m.tab == tabFiles && len(m.allFiles) == 0: + return "no files" + case m.tab == tabFiles && p.path != "": + return "empty file" } return "" } @@ -342,11 +417,10 @@ func (m *model) highlighted() (lo, hi int) { } // rowMark returns the gutter mark of row i. t is the line that row i -// shows, or nil. The mark shows the cursor, the rows lo to hi, a find hit, -// or a comment. -func (m *model) rowMark(i int, t *targetLine, lo, hi int) string { +// shows, or nil. hit is true for a find hit. The mark shows the cursor, +// the rows lo to hi, a find hit, or a comment. +func (m *model) rowMark(i int, t *targetLine, lo, hi int, hit bool) string { p := m.cur() - _, hit := slices.BinarySearch(m.find.hits, i) // the hits are in order switch { case i == p.cur && m.tab != tabPR: return cursorStyle.Render("▶ ") @@ -357,18 +431,43 @@ func (m *model) rowMark(i int, t *targetLine, lo, hi int) string { case t == nil || p.cont(i): return commentMark(nil) } - return commentMark(m.comments.At(p.path, t.side, t.line)) + return commentMark(m.commentOn(i, t)) +} + +// commentOn returns a comment on row i, which shows line t, or nil. In +// rendered markdown, it is a comment on a line of the block of row i. +func (m *model) commentOn(i int, t *targetLine) *diff.Comment { + p := m.cur() + first, last := t.line, t.line + if p.src != nil { + first, last = p.rows[p.head(i)].New, p.rows[p.end(i)].New + } + for line := first; line <= last; line++ { + if c := m.comments.At(m.anchor(), p.path, t.side, line); c != nil { + return c + } + } + return nil } // commentsBelow returns the lines of the comments whose range ends on -// line t. The comment that the box edits does not show. -func (m *model) commentsBelow(t *targetLine) []string { +// row i, which shows line t. In rendered markdown, they are the comments +// that end in the block of row i. The comment that the box edits does not +// show. +func (m *model) commentsBelow(i int, t *targetLine) []string { + p := m.cur() + first := t.line + if p.src != nil { + first = p.rows[p.head(i)].New + } var out []string - for _, c := range m.comments.EndingAt(m.cur().path, t.side, t.line) { - if m.mode == modeComment && c.ID == m.draft.editID { - continue + for line := first; line <= t.line; line++ { + for _, c := range m.comments.EndingAt(m.anchor(), p.path, t.side, line) { + if m.mode == modeComment && c.ID == m.draft.editID { + continue + } + out = append(out, m.commentLines(c)...) } - out = append(out, m.commentLines(c)...) } return out } @@ -376,11 +475,8 @@ func (m *model) commentsBelow(t *targetLine) []string { // commentMark returns the gutter mark of a line with comment c, which can // be nil. func commentMark(c *diff.Comment) string { - switch { - case c == nil: + if c == nil { return " " - case c.Stale: - return dimStyle.Render("● ") } return cursorStyle.Render("● ") } @@ -398,17 +494,11 @@ func (m *model) commentBox() []string { func (m *model) commentLines(c *diff.Comment) []string { var out []string for i, l := range strings.Split(c.Text, "\n") { - prefix, st := " 💬 ", noteStyle + prefix := " 💬 " if i > 0 { prefix = " " } - if c.Stale { - st = staleStyle - if i == 0 { - l = "(stale) " + l - } - } - out = append(out, truncate(prefix+st.Render(l), m.mainWidth())) + out = append(out, truncate(prefix+noteStyle.Render(l), m.mainWidth())) } return out } @@ -419,7 +509,7 @@ func (m *model) commentLines(c *diff.Comment) []string { // the box is closed, the line shows only its first rows. func (m *model) ensureVisible() { p := m.cur() - h := m.bodyHeight() + h := m.mainHeight() if p.free { p.top = min(p.top, max(len(p.lines)-h, 0)) return @@ -429,12 +519,12 @@ func (m *model) ensureVisible() { if m.mode == modeComment { last, box = p.end(p.cur), lipgloss.Height(m.editor.View()) } - // Each row takes one line or more. So the last row cannot show if it is + // Each row takes one line or more. Thus the last row cannot show if it is // h rows or more below the top. p.top = max(p.top, last-h+1) - p.top = min(last, min(p.top, max(len(p.lines)-1, 0))) + p.top = min(last, p.top, max(len(p.lines)-1, 0)) for p.top < last { - _, rowAt := m.mainLines(h) + _, rowAt := m.mainLines(h, nil) idx := slices.Index(rowAt, p.end(p.cur)) if idx >= 0 && idx+1+box <= h { return @@ -444,17 +534,19 @@ func (m *model) ensureVisible() { } // footer renders the notice, the status, and the help of the keys. The -// notice wraps, so that the user can read all of it. -func (m *model) footer() string { +// notice wraps, so that the user can read all of it. hits are the hits of +// the find band. +func (m *model) footer(hits []findHit) string { if m.notice != "" { - return delStyle.Render(wrapWords(m.notice, m.w)) + "\n" + m.footerLine() + notice := termtext.Quote(m.notice) // the notice can have a path + return delStyle.Render(wrapWords(notice, m.w)) + "\n" + m.footerLine(hits) } - return m.footerLine() + return m.footerLine(hits) } // wrapWords breaks s into lines of width w or less, at spaces only. // ansi.Wrap and lipgloss let a line end past w at a hyphen. Then the edge -// of the screen cuts off a "--". A word wider than w is cut, and a +// of the screen cuts off a "--". wrapWords cuts a word wider than w. A // character wider than w gets a line of its own. func wrapWords(s string, w int) string { w = max(w, 1) @@ -483,8 +575,12 @@ func wrapWords(s string, w int) string { return strings.Join(append(lines, line), "\n") } -// footerLine renders the status and the help of the keys. -func (m *model) footerLine() string { +// footerLine renders the status and the help of the keys, or the find +// band. +func (m *model) footerLine(hits []findHit) string { + if m.mode == modeFind { + return m.findBand(hits) + } m.help.SetWidth(m.w) var badge string switch { @@ -492,21 +588,19 @@ func (m *model) footerLine() string { badge = cursorStyle.Render(m.status) case m.release != nil: badge = cursorStyle.Render(m.releaseBadge()) - case m.find.active(): - f := m.find - badge = findStyle.Render(fmt.Sprintf("≈ %q %d/%d", f.query, f.i+1, len(f.hits))) case m.mode == modeComment && m.draft != nil: badge = cursorStyle.Render("✎ " + m.draft.Location()) case m.comments.Len() > 0: badge = cursorStyle.Render("● " + plural(m.comments.Len(), "unsent comment")) } hk := helpKeys{ - k: m.keys, - mode: m.mode, - tab: m.tab, - split: m.split, - visual: m.cur().anchor >= 0, - finding: m.find.active(), + k: m.keys, + mode: m.mode, + tab: m.tab, + split: m.splitShown(), + visual: m.cur().anchor >= 0, + atFold: m.atFold(), + armed: m.armed, } if m.help.ShowAll { h := m.help.View(hk) @@ -522,8 +616,8 @@ func (m *model) footerLine() string { return m.help.View(hk) } -// bodyHeight is the number of lines between the header and the footer. -func (m *model) bodyHeight() int { return m.heightAbove(m.footer()) } +// bodyHeight returns the number of lines between the header and the footer. +func (m *model) bodyHeight() int { return m.heightAbove(m.footer(m.shownHits())) } // heightAbove returns the height of the body above footer. func (m *model) heightAbove(footer string) int { @@ -532,30 +626,46 @@ func (m *model) heightAbove(footer string) int { // View renders the screen. func (m *model) View() tea.View { - footer := m.footer() + if !m.sized { + // A frame of the guessed size shows wrong for a moment. + v := tea.NewView("") + v.AltScreen = true + return v + } + hits := m.shownHits() + footer := m.footer(hits) h := m.heightAbove(footer) hdr, _ := m.header() - ml, _ := m.mainLines(h) - main := box(ml, m.mainWidth(), h) + mh := m.mainHeightOf(h) + ml, _ := m.mainLines(mh, hits) + main := box(ml, m.mainWidth(), mh) body := main - if lw := m.listWidth(); lw > 0 { + rule := dimStyle.Render(strings.Repeat("─", m.w)) + switch lw, lh := m.listWidth(), m.listHeight(h); { + case lh > 0: + list := box(m.listLines(lh-1), lw, lh-1) + if m.listPos == positionTop { + body = lipgloss.JoinVertical(lipgloss.Left, list, rule, main) + } else { + body = lipgloss.JoinVertical(lipgloss.Left, main, rule, list) + } + case lw > 0: list := box(m.listLines(h), lw, h) sep := dimStyle.Render(strings.TrimSuffix(strings.Repeat(" │ \n", h), "\n")) - if m.listLeft { + if m.listPos == positionLeft { body = lipgloss.JoinHorizontal(lipgloss.Top, list, sep, main) } else { body = lipgloss.JoinHorizontal(lipgloss.Top, main, sep, list) } } - rule := dimStyle.Render(strings.Repeat("─", m.w)) screen := hdr + "\n" + body + "\n" + rule + "\n" + footer if m.mode == modeOverlay && m.overlay != nil { - box, x, y, _ := m.overlayBox() + ov, x, y, _ := m.overlayBox() c := lipgloss.NewCanvas(m.w, m.h) c.Compose(lipgloss.NewLayer(screen)) - c.Compose(lipgloss.NewLayer(box).X(x).Y(y).Z(1)) + c.Compose(lipgloss.NewLayer(ov).X(x).Y(y).Z(1)) screen = c.Render() } @@ -576,10 +686,10 @@ func box(lines []string, w, h int) string { return st.Render(strings.Join(lines, "\n")) } -// programStatus is the status that rexdiff reports to Rex with OSC 7501. +// programStatus returns the status that rexdiff reports to Rex with OSC 7501. // Rex shows it in its tabs and its sidebar. func (m *model) programStatus() *tea.ProgramStatus { - msg := "reviewing " + plural(len(m.files), "file") + msg := "reviewing " + plural(len(m.changeFiles), "file") if n := m.comments.Len(); n > 0 { msg = plural(n, "unsent comment") } @@ -591,22 +701,48 @@ func (m *model) programStatus() *tea.ProgramStatus { } } -// mainX is the screen column where the main pane starts. +// mainX returns the screen column where the main pane starts. func (m *model) mainX() int { - if lw := m.listWidth(); lw > 0 && m.listLeft { - return lw + sepWidth + if m.listPos == positionLeft && m.listWidth() > 0 { + return m.listWidth() + sepWidth } return 0 } -// listX is the screen column where the file list starts. +// mainY returns the screen line where the main pane starts. +func (m *model) mainY() int { + if m.listPos == positionTop { + return bodyY + m.listHeight(m.bodyHeight()) + } + return bodyY +} + +// listX returns the screen column where the file list starts. func (m *model) listX() int { - if !m.listLeft { + if m.listPos == positionRight { return m.mainWidth() + sepWidth } return 0 } +// listY returns the screen line where the file list starts. Below the +// main pane, the list starts under the rule. +func (m *model) listY() int { + if m.listPos == positionBottom { + return bodyY + m.mainHeight() + 1 + } + return bodyY +} + +// listRows returns the number of lines of the file list, with its title. +func (m *model) listRows() int { + h := m.bodyHeight() + if m.listPos.stacked() { + return max(m.listHeight(h)-1, 0) + } + return h +} + // sepWidth is the width of the line " │ " between the panes. const sepWidth = 3 diff --git a/update/update.go b/update/update.go index e6f1e4a..787e500 100644 --- a/update/update.go +++ b/update/update.go @@ -6,6 +6,10 @@ // install. Else it downloads the archive of the release from GitHub and // checks it with the checksums of the release. [Restart] runs the new binary // in the same process, so the terminal and the process ID stay. +// +// The caller gives the environment as a value, as in [exec.Cmd.Env]. The +// package does not read the environment of the process. But net/http reads +// the proxy variables of the process, for example HTTPS_PROXY. package update import ( @@ -26,10 +30,13 @@ import ( "path/filepath" "runtime" "runtime/debug" + "slices" "strconv" "strings" "syscall" "time" + + "github.com/rednafi/rexdiff/internal/proc" ) // latestURL is the GitHub API address of the latest release of rexdiff. @@ -58,8 +65,8 @@ const ( ) // Release is a release of rexdiff on GitHub. Get one from [Updater.Latest]. -// A Release that you make yourself has no files, so Install cannot install -// it. +// A Release that you make yourself has no files. Install can install such a +// Release with Homebrew or go install. Install cannot download it. type Release struct { Tag string // for example "v0.2.0" assets []asset @@ -76,10 +83,9 @@ type Updater struct { bin string // the path of rexdiff as the user runs it, maybe a symlink version string // the version of the running rexdiff, for example "v0.1.0" method method - brew string // the brew of the Caskroom of bin, for methodBrew + brew string // the brew of the Caskroom of bin, for methodBrew + env []string // the environment of brew, go, and rexdiff latestURL string - // run runs a command and returns its output. Tests replace it. - run func(ctx context.Context, env []string, name string, args ...string) ([]byte, error) } // Version returns the version of rexdiff, for example "v0.2.0". stamped is @@ -105,16 +111,17 @@ func Version(stamped string) string { // The Updater installs a release the same way as the user installed // rexdiff. If bin links into a Caskroom, it uses Homebrew. If stamped is "" // and the version comes from go install, it uses go install. Else it -// downloads the release. If REXDIFF_LATEST_URL is set, the Updater gets the -// latest release from that address. Use it to test a release before it is -// out. -func Detect(bin, stamped string) *Updater { - return detect(bin, Version(stamped), stamped == "") +// downloads the release. The Updater runs brew, go, and rexdiff with the +// environment env, and finds them in the PATH of env. If env sets +// REXDIFF_LATEST_URL, the Updater gets the latest release from that +// address. Set it to test a release before it is public. +func Detect(env []string, bin, stamped string) *Updater { + return detect(bin, Version(stamped), stamped == "", env) } // detect is [Detect] with the version already found. goInstall is true if // the version comes from go install. -func detect(bin, version string, goInstall bool) *Updater { +func detect(bin, version string, goInstall bool, env []string) *Updater { if _, valid := numbers(version); bin == "" || !valid { return nil } @@ -122,7 +129,8 @@ func detect(bin, version string, goInstall bool) *Updater { bin: bin, version: version, method: methodDownload, - latestURL: cmp.Or(os.Getenv("REXDIFF_LATEST_URL"), latestURL), + env: env, + latestURL: cmp.Or(proc.Getenv(env, "REXDIFF_LATEST_URL"), latestURL), } target, err := filepath.EvalSymlinks(bin) caskroom := string(filepath.Separator) + "Caskroom" + string(filepath.Separator) @@ -142,8 +150,11 @@ func detect(bin, version string, goInstall bool) *Updater { // Latest returns the latest release, and whether it is newer than the // running rexdiff. Latest stops and returns an error after five seconds. func (u *Updater) Latest(ctx context.Context) (Release, bool, error) { - ctx, cancel := context.WithTimeout(ctx, checkTimeout) - defer cancel() + ctx, cancel := context.WithCancelCause(ctx) + ctx, cancelTimeout := context.WithTimeoutCause(ctx, checkTimeout, + fmt.Errorf("release check: %v timeout: %w", checkTimeout, context.DeadlineExceeded)) + defer cancelTimeout() + defer cancel(fmt.Errorf("release check: done: %w", context.Canceled)) body, err := get(ctx, u.latestURL, 1<<20) if err != nil { return Release{}, false, err @@ -159,13 +170,17 @@ func (u *Updater) Latest(ctx context.Context) (Release, bool, error) { return r, newer(r.Tag, u.version), nil } -// Install installs the release r, and checks that the binary of rexdiff is -// then the version of r. Install does not stop the running rexdiff. A -// cancel of ctx does not stop an install that runs, because a stopped brew -// can leave a broken cask. Install stops after five minutes. +// Install installs the release r. Then it checks that the binary of rexdiff +// has the version of r. Install does not stop the running rexdiff. Install +// uses the values of ctx, but ignores its cancel. An install that stops +// early can leave a broken cask or binary. Install stops after five +// minutes. func (u *Updater) Install(ctx context.Context, r Release) error { - ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), installTimeout) - defer cancel() + ctx, cancel := context.WithCancelCause(context.WithoutCancel(ctx)) + ctx, cancelTimeout := context.WithTimeoutCause(ctx, installTimeout, + fmt.Errorf("install: %v timeout: %w", installTimeout, context.DeadlineExceeded)) + defer cancelTimeout() + defer cancel(fmt.Errorf("install: done: %w", context.Canceled)) var err error switch u.method { case methodBrew: @@ -176,19 +191,19 @@ func (u *Updater) Install(ctx context.Context, r Release) error { // brew updates its taps at most once a day. Thus brew upgrade can // miss a new release. Update the taps first. Then // HOMEBREW_NO_AUTO_UPDATE stops a second update. - if _, err = u.command(ctx, env, brew, "update", "--quiet"); err == nil { + if _, err = u.runProgram(ctx, env, brew, "update", "--quiet"); err == nil { env = append(env, "HOMEBREW_NO_AUTO_UPDATE=1") - _, err = u.command(ctx, env, brew, "upgrade", "--cask", "rexdiff") + _, err = u.runProgram(ctx, env, brew, "upgrade", "--cask", "rexdiff") } case methodGo: // GOBIN puts the new binary where the old one is. env := []string{"GOBIN=" + filepath.Dir(u.bin)} goTool := "go" // Rex starts panes with a PATH without Go. - if _, err := exec.LookPath(goTool); err != nil { + if _, lerr := proc.LookPath(u.env, goTool); lerr != nil { goTool = tool("go", "/opt/homebrew/bin", "/usr/local/go/bin", "/usr/local/bin") } - _, err = u.command(ctx, env, goTool, "install", modulePath+"@"+r.Tag) + _, err = u.runProgram(ctx, env, goTool, "install", modulePath+"@"+r.Tag) default: return u.download(ctx, r) // it checks the version before the replace } @@ -201,9 +216,10 @@ func (u *Updater) Install(ctx context.Context, r Release) error { // checkVersion checks that the binary at bin is the version tag, so that a // restart does not run a broken or an old binary. func (u *Updater) checkVersion(ctx context.Context, bin, tag string) error { - out, err := u.command(ctx, nil, bin, "-version") + out, err := u.runProgram(ctx, nil, bin, "-version") if err != nil { - return fmt.Errorf("check the new rexdiff: %w", err) + // The error of runProgram names rexdiff. + return fmt.Errorf("check the new version: %w", err) } if got := strings.TrimSpace(string(out)); got != tag { return fmt.Errorf("the update left rexdiff %s, not %s", cmp.Or(got, "of no version"), tag) @@ -211,52 +227,42 @@ func (u *Updater) checkVersion(ctx context.Context, bin, tag string) error { return nil } -// command runs name with args, with env added to the environment. Its -// error has the end of the output, which tells what went wrong. -func (u *Updater) command(ctx context.Context, env []string, name string, args ...string) ([]byte, error) { - run := u.run - if run == nil { - run = runCommand - } - out, err := run(ctx, env, name, args...) +// runProgram runs name with args, with env added to the environment of u. +// Its error has the end of the output, which tells what went wrong. +func (u *Updater) runProgram(ctx context.Context, env []string, name string, args ...string) ([]byte, error) { + // A copy keeps u.env as it is. + cmd := proc.Command(ctx, slices.Concat(u.env, env), name, args...) + out, err := cmd.CombinedOutput() if err == nil { return out, nil } - if last := lastLine(out); last != "" { - return out, fmt.Errorf("%s: %w: %s", filepath.Base(name), err, last) + if errors.Is(err, exec.ErrNotFound) { + return out, proc.NotInstalled(name) + } + if ctx.Err() != nil { + return out, fmt.Errorf("%s: %w", filepath.Base(name), proc.Error(ctx, name, err, nil)) + } + if last := proc.LastLine(string(out)); last != "" { + return out, fmt.Errorf("%s: %s: %w", filepath.Base(name), last, err) } return out, fmt.Errorf("%s: %w", filepath.Base(name), err) } -// tool returns the path of the program name in dirs. Else it returns name, -// and exec looks for it in PATH. tool looks in dirs first, because Rex -// starts panes with a PATH without Homebrew or Go. +// tool returns the path of the program name in dirs. If dirs do not have +// the program, tool returns name, and the Updater looks for name in the +// PATH of its environment. tool looks in dirs first, because Rex starts +// panes with a PATH without Homebrew or Go. func tool(name string, dirs ...string) string { - for _, dir := range dirs { - path := filepath.Join(dir, name) - if info, err := os.Stat(path); err == nil && info.Mode().IsRegular() && info.Mode()&0o111 != 0 { - return path - } + env := []string{"PATH=" + strings.Join(dirs, string(os.PathListSeparator))} + if path, err := proc.LookPath(env, name); err == nil { + return path } return name } -// runCommand is the run of an Updater outside tests. -func runCommand(ctx context.Context, env []string, name string, args ...string) ([]byte, error) { - cmd := exec.CommandContext(ctx, name, args...) - cmd.Env = append(os.Environ(), env...) - return cmd.CombinedOutput() -} - -// lastLine returns the last line of out that is not blank. -func lastLine(out []byte) string { - lines := strings.Split(strings.TrimSpace(string(out)), "\n") - return strings.TrimSpace(lines[len(lines)-1]) -} - -// download downloads the archive of r for this system, checks it with the -// checksums of r, and puts its binary in place of the file that u.bin -// links to. +// download downloads the archive of r for this system and checks it with +// the checksums of r. Then it puts its binary in place of the file that +// u.bin links to. func (u *Updater) download(ctx context.Context, r Release) error { name := fmt.Sprintf("rexdiff-%s-%s-%s.tar.gz", r.Tag, runtime.GOOS, runtime.GOARCH) archiveURL, sumsURL := assetURL(r, name), assetURL(r, "sha256sums.txt") @@ -292,10 +298,8 @@ func (u *Updater) download(ctx context.Context, r Release) error { // assetURL returns the address of the asset of r with the name, or "". func assetURL(r Release, name string) string { - for _, a := range r.assets { - if a.Name == name { - return a.URL - } + if i := slices.IndexFunc(r.assets, func(a asset) bool { return a.Name == name }); i >= 0 { + return r.assets[i].URL } return "" } @@ -328,10 +332,13 @@ func binaryOf(archive []byte) ([]byte, error) { } if h.Typeflag == tar.TypeReg && filepath.Base(h.Name) == "rexdiff" { b, err := io.ReadAll(io.LimitReader(tr, maxBinary+1)) - if err == nil && len(b) > maxBinary { - err = fmt.Errorf("rexdiff is larger than %d bytes", maxBinary) + if err != nil { + return nil, err + } + if len(b) > maxBinary { + return nil, fmt.Errorf("rexdiff is larger than %d bytes", maxBinary) } - return b, err + return b, nil } } } @@ -344,15 +351,15 @@ func replaceFile(path string, content []byte, check func(tmp string) error) erro if err != nil { return err } - defer os.Remove(tmp.Name()) // does nothing after the rename - if _, err := tmp.Write(content); err != nil { - tmp.Close() - return err + defer func() { _ = os.Remove(tmp.Name()) }() // fails after the rename + _, err = tmp.Write(content) + if err == nil { + err = tmp.Chmod(0o755) } - if err := tmp.Close(); err != nil { - return err + if cerr := tmp.Close(); err == nil { + err = cerr } - if err := os.Chmod(tmp.Name(), 0o755); err != nil { + if err != nil { return err } if err := check(tmp.Name()); err != nil { @@ -372,7 +379,7 @@ func get(ctx context.Context, url string, limit int64) ([]byte, error) { if err != nil { return nil, err } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("GET %s: %s", url, resp.Status) } @@ -386,11 +393,12 @@ func get(ctx context.Context, url string, limit int64) ([]byte, error) { return body, nil } -// Restart runs the rexdiff at bin with args in place of this process. The -// process ID and the terminal stay, so Rex sees the same program. Restart -// returns only if it fails. -func Restart(bin string, args []string) error { - return syscall.Exec(bin, append([]string{bin}, args...), os.Environ()) +// Restart runs the rexdiff at bin with args and the environment env in +// place of this process. The process ID and the terminal stay, so Rex sees +// the same program. Restart returns only if it fails. Its parameters have +// the order of [syscall.Exec], so env comes last. +func Restart(bin string, args, env []string) error { + return syscall.Exec(bin, append([]string{bin}, args...), env) } // newer reports whether the version tag is newer than the version have. @@ -400,15 +408,7 @@ func Restart(bin string, args []string) error { func newer(tag, have string) bool { t, ok1 := numbers(tag) h, ok2 := numbers(have) - if !ok1 || !ok2 { - return false - } - for i := range t { - if t[i] != h[i] { - return t[i] > h[i] - } - } - return false + return ok1 && ok2 && slices.Compare(t[:], h[:]) > 0 } // numbers returns the three numbers of a version like "v1.2.3". diff --git a/update/update_test.go b/update/update_test.go index abc9dda..b46679c 100644 --- a/update/update_test.go +++ b/update/update_test.go @@ -1,4 +1,4 @@ -package update +package update_test import ( "archive/tar" @@ -7,182 +7,218 @@ import ( "context" "crypto/sha256" "encoding/hex" - "errors" + "encoding/json/v2" "fmt" "net/http" "net/http/httptest" "os" "path/filepath" "runtime" + "strconv" "strings" "testing" + "testing/fstest" + + "github.com/rednafi/rexdiff/internal/testgit" + "github.com/rednafi/rexdiff/update" ) -// TestNewer checks which tags are newer than the running version. -func TestNewer(t *testing.T) { - tests := []struct { - tag, have string - want bool - }{ - {"v0.2.0", "v0.1.0", true}, - {"v0.1.1", "v0.1.0", true}, - {"v1.0.0", "v0.9.9", true}, - {"v0.10.0", "v0.9.0", true}, - {"0.2.0", "v0.1.0", false}, // GoReleaser names archives with the v - {"v0.1.0", "v0.1.0", false}, - {"v0.1.0", "v0.2.0", false}, - {"v0.2.0-rc1", "v0.1.0", false}, - {"v0.2.0", "v0.0.0-20261024-abcdef", false}, - {"", "v0.1.0", false}, - {"v0.2.0", "", false}, - {"v+1.0.0", "v0.9.0", false}, // Atoi accepts a sign - {"v1.-0.0", "v0.9.0", false}, - } - for _, tc := range tests { - if got := newer(tc.tag, tc.have); got != tc.want { - t.Errorf("newer(%q, %q) = %v, want %v", tc.tag, tc.have, got, tc.want) - } +// mkdir makes the directory dir and its parents, and returns dir. +func mkdir(t *testing.T, dir string) string { + t.Helper() + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) } + return dir } -// file writes content to a new file at path with mode 0755, and returns -// path. -func file(t *testing.T, path, content string) string { +// detect returns the Updater of the rexdiff at bin, with the latest +// release at latest. Its environment has REXDIFF_LATEST_URL and the PATH +// /usr/bin:/bin, then the variables of env. A later variable wins. +func detect(t *testing.T, latest, bin, stamped string, env ...string) *update.Updater { t.Helper() - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(path, []byte(content), 0o755); err != nil { - t.Fatal(err) + env = append([]string{"REXDIFF_LATEST_URL=" + latest, "PATH=/usr/bin:/bin"}, env...) + u := update.Detect(env, bin, stamped) + if u == nil { + t.Fatalf("Detect(%q, %s, %s) = nil, want an Updater", env, bin, stamped) } - return path + return u } -// TestDetect checks that detect finds a Homebrew cask by the Caskroom of -// its link, and go install by its flag. -func TestDetect(t *testing.T) { - dir := t.TempDir() - cask := file(t, filepath.Join(dir, "Caskroom", "rexdiff", "0.1.0", "rexdiff"), "") - link := filepath.Join(dir, "bin", "rexdiff") - if err := os.MkdirAll(filepath.Dir(link), 0o755); err != nil { - t.Fatal(err) - } - if err := os.Symlink(cask, link); err != nil { - t.Fatal(err) - } - plain := file(t, filepath.Join(dir, "plain", "rexdiff"), "") - tests := []struct { - bin, version string - goInstall bool - want method - }{ - {link, "v0.1.0", false, methodBrew}, - {link, "v0.1.0", true, methodBrew}, - {plain, "v0.1.0", true, methodGo}, - {plain, "v0.1.0", false, methodDownload}, - } - for _, tc := range tests { - u := detect(tc.bin, tc.version, tc.goInstall) - if u == nil || u.method != tc.want { - t.Errorf("detect(%s, %s, %v) = %+v, want %s", tc.bin, tc.version, tc.goInstall, u, tc.want) +// serve starts a server with the handler h, and returns its address. The +// test stops the server at the end. +func serve(t *testing.T, h http.Handler) string { + t.Helper() + srv := httptest.NewServer(h) + t.Cleanup(srv.Close) + return srv.URL +} + +// TestVersion checks the version from the release build, and the builds +// that cannot update. A test binary has no version, like a build for +// development. +func TestVersion(t *testing.T) { + t.Parallel() + for stamped, want := range map[string]string{"0.2.0": "v0.2.0", "v0.2.0": "v0.2.0", "": ""} { + if got := update.Version(stamped); got != want { + t.Errorf("Version(%q) = %q, want %q", stamped, got, want) } } - for _, v := range []string{"", "(devel)", "v0.0.0-20261024-abcdef"} { - if u := detect(plain, v, true); u != nil { - t.Errorf("detect of version %q = %+v, want nil", v, u) - } + tests := []struct{ bin, stamped string }{ + {"/bin/rexdiff", ""}, + {"/bin/rexdiff", "(devel)"}, + {"/bin/rexdiff", "0.0.0-20261024-abcdef"}, + {"", "0.1.0"}, } - if u := detect("", "v0.1.0", false); u != nil { - t.Errorf("detect without a path = %+v, want nil", u) + for _, tt := range tests { + if u := update.Detect(nil, tt.bin, tt.stamped); u != nil { + t.Errorf("Detect(nil, %q, %q) = %+v, want nil", tt.bin, tt.stamped, u) + } } } -// TestLatest checks Latest against a fake GitHub API. +// TestLatest checks Latest against a fake GitHub API, and which tags are +// newer than the running version. func TestLatest(t *testing.T) { - answer := func(code int, body string) *Updater { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(code) - fmt.Fprint(w, body) - })) - t.Cleanup(srv.Close) - return &Updater{version: "v0.1.0", latestURL: srv.URL} - } - r, isNewer, err := answer(200, `{"tag_name": "v0.2.0", "assets": [{"name": "a", "browser_download_url": "u"}], "other": 1}`).Latest(t.Context()) - if err != nil || !isNewer || r.Tag != "v0.2.0" || len(r.assets) != 1 || r.assets[0].URL != "u" { - t.Errorf("Latest = %+v, %v, %v; want v0.2.0 with one asset, newer", r, isNewer, err) - } - if _, isNewer, err := answer(200, `{"tag_name": "v0.1.0"}`).Latest(t.Context()); err != nil || isNewer { - t.Errorf("Latest of the same version: newer %v, %v; want false, nil", isNewer, err) + t.Parallel() + release := func(tag string) string { + return `{"tag_name": "` + tag + `", "assets": [], "other": 1}` } - if _, _, err := answer(403, `rate limit`).Latest(t.Context()); err == nil || !strings.Contains(err.Error(), "403") { - t.Errorf("Latest after 403: %v, want the status", err) - } - if _, _, err := answer(200, `not json`).Latest(t.Context()); err == nil { - t.Error("Latest of bad JSON: no error") - } -} - -// fakeRun records the commands, and answers -version with version. -type fakeRun struct { - calls []string - version string - fail string // a command name that fails -} - -func (f *fakeRun) run(_ context.Context, env []string, name string, args ...string) ([]byte, error) { - f.calls = append(f.calls, strings.TrimSpace(strings.Join(env, " ")+" "+name+" "+strings.Join(args, " "))) - if name == f.fail { - return []byte("Updating Homebrew...\nError: another brew is running\n"), errors.New("exit status 1") + tests := []struct { + name, have, body string // a body of "" is a 404 + tag string // the tag of the release, or "" + newer bool + errText string // a part of the error, or "" + }{ + {"minor", "0.1.0", release("v0.2.0"), "v0.2.0", true, ""}, + {"patch", "v0.1.0", release("v0.1.1"), "v0.1.1", true, ""}, + {"major", "0.9.9", release("v1.0.0"), "v1.0.0", true, ""}, + {"two digits", "0.9.0", release("v0.10.0"), "v0.10.0", true, ""}, + // GoReleaser names archives with the v. + {"no v", "0.1.0", release("0.2.0"), "0.2.0", false, ""}, + {"same", "0.1.0", release("v0.1.0"), "v0.1.0", false, ""}, + {"older", "0.2.0", release("v0.1.0"), "v0.1.0", false, ""}, + {"release candidate", "0.1.0", release("v0.2.0-rc1"), "v0.2.0-rc1", false, ""}, + {"no tag", "0.1.0", release(""), "", false, ""}, + // Atoi accepts a sign, but a version has none. + {"sign", "0.9.0", release("v+1.0.0"), "v+1.0.0", false, ""}, + {"minus", "0.9.0", release("v1.-0.0"), "v1.-0.0", false, ""}, + {"status", "0.1.0", "", "", false, "404"}, + {"bad JSON", "0.1.0", "not json", "", false, "read the latest release"}, + } + files := fstest.MapFS{} + for i, tt := range tests { + if tt.body != "" { + files[strconv.Itoa(i)] = &fstest.MapFile{Data: []byte(tt.body)} + } } - if len(args) == 1 && args[0] == "-version" { - return []byte(f.version + "\n"), nil + base := serve(t, http.FileServerFS(files)) + for i, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + u := detect(t, fmt.Sprint(base, "/", i), "/bin/rexdiff", tt.have) + r, newer, err := u.Latest(t.Context()) + if tt.errText != "" { + if err == nil || !strings.Contains(err.Error(), tt.errText) { + t.Errorf("Latest: %v, want an error with %q", err, tt.errText) + } + return + } + if err != nil || r.Tag != tt.tag || newer != tt.newer { + t.Errorf("Latest = %q, %v, %v, want %q, %v, nil", + r.Tag, newer, err, tt.tag, tt.newer) + } + }) } - return nil, nil } -// TestInstallCommands checks the commands of Homebrew and go install, and -// the check of the new version. -func TestInstallCommands(t *testing.T) { - r := Release{Tag: "v0.2.0"} - brew := &fakeRun{version: "v0.2.0"} - u := &Updater{bin: "/opt/homebrew/bin/rexdiff", version: "v0.1.0", method: methodBrew, run: brew.run} - if err := u.Install(t.Context(), r); err != nil { - t.Fatal(err) - } - want := []string{ - "HOMEBREW_NO_ENV_HINTS=1 NONINTERACTIVE=1 brew update --quiet", - "HOMEBREW_NO_ENV_HINTS=1 NONINTERACTIVE=1 HOMEBREW_NO_AUTO_UPDATE=1 brew upgrade --cask rexdiff", - "/opt/homebrew/bin/rexdiff -version", - } - if strings.Join(brew.calls, "\n") != strings.Join(want, "\n") { - t.Errorf("brew calls:\n%s\nwant:\n%s", strings.Join(brew.calls, "\n"), strings.Join(want, "\n")) - } - - gorun := &fakeRun{version: "v0.2.0"} - u = &Updater{bin: "/home/u/go/bin/rexdiff", version: "v0.1.0", method: methodGo, run: gorun.run} - if err := u.Install(t.Context(), r); err != nil { - t.Fatal(err) - } - env, rest, _ := strings.Cut(gorun.calls[0], " ") - prog, args, _ := strings.Cut(rest, " ") - if env != "GOBIN=/home/u/go/bin" || filepath.Base(prog) != "go" || args != "install github.com/rednafi/rexdiff/cmd/rexdiff@v0.2.0" { - t.Errorf("go call = %q, want GOBIN=/home/u/go/bin go install github.com/rednafi/rexdiff/cmd/rexdiff@v0.2.0", gorun.calls[0]) - } - - // The error of a failed command has the last line of the output. - failing := &fakeRun{fail: "brew"} - u = &Updater{bin: "/b/rexdiff", method: methodBrew, run: failing.run} - err := u.Install(t.Context(), r) - if err == nil || err.Error() != "brew: exit status 1: Error: another brew is running" { - t.Errorf("Install with a failing brew: %v", err) - } +// TestInstallBrew checks the commands of a Homebrew install, and the check +// of the new version. +func TestInstallBrew(t *testing.T) { + t.Parallel() + // The fake brew logs the flags of brew and its arguments. Rex starts + // rexdiff with a PATH without Homebrew, so the brew beside the + // Caskroom comes first. The environment of the Updater has none of the + // flags of brew. + const logBrew = `echo "hints=$HOMEBREW_NO_ENV_HINTS ` + + `ni=$NONINTERACTIVE auto=$HOMEBREW_NO_AUTO_UPDATE ${0##*/} $*" ` + + `>> "${0%/*}/../calls"` + "\n" + const busy = "echo Updating Homebrew...\necho 'Error: another brew is running'\nexit 1\n" + const calls = "hints=1 ni=1 auto= brew update --quiet\n" + + "hints=1 ni=1 auto=1 brew upgrade --cask rexdiff\n" + tests := []struct { + name string + beside bool // the brew is beside the Caskroom, else in PATH + brew string // the script of the fake brew + version string // the version of the new rexdiff + cancel bool // cancel the context before Install + calls string // the log of brew, or "" for no check + errText string // a part of the error, or "" for none + }{ + {"beside the Caskroom", true, logBrew, "v0.2.0", false, calls, ""}, + {"in PATH", false, logBrew, "v0.2.0", false, calls, ""}, + // The error has the last line of the output. + { + "brew fails", true, busy, "v0.2.0", false, "", + "brew: Error: another brew is running: exit status 1", + }, + // The error has no empty part at its end. + { + "brew fails with no output", true, "exit 1\n", "v0.2.0", false, "", + "brew: exit status 1", + }, + // A new binary of another version is an error, so rexdiff does not + // restart into it. + { + "old version", true, logBrew, "v0.1.0", false, "", + "the update left rexdiff v0.1.0, not v0.2.0", + }, + // A stopped brew can leave a broken cask. The user can quit + // rexdiff while brew runs. + {"outlives a cancel", true, logBrew, "v0.2.0", true, calls, ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + prefix := t.TempDir() + caskDir := mkdir(t, filepath.Join(prefix, "Caskroom", "rexdiff", "0.2.0")) + cask := testgit.Script(t, caskDir, "rexdiff", "echo "+tt.version+"\n") + path := filepath.Join(prefix, "path") + if tt.beside { + path = filepath.Join(prefix, "bin") + } + testgit.Script(t, mkdir(t, path), "brew", tt.brew) + link := filepath.Join(mkdir(t, filepath.Join(prefix, "bin")), "rexdiff") + if err := os.Symlink(cask, link); err != nil { + t.Fatal(err) + } + pathEnv := "PATH=" + filepath.Join(prefix, "path") + ":/usr/bin:/bin" - // A new binary of another version is an error, so rexdiff does not - // restart into it. - stale := &fakeRun{version: "v0.1.0"} - u = &Updater{bin: "/b/rexdiff", method: methodBrew, run: stale.run} - if err := u.Install(t.Context(), r); err == nil || !strings.Contains(err.Error(), "left rexdiff v0.1.0, not v0.2.0") { - t.Errorf("Install that leaves the old version: %v", err) + ctx, cancel := context.WithCancelCause(t.Context()) + defer cancel(fmt.Errorf("test: done: %w", context.Canceled)) + if tt.cancel { + cancel(fmt.Errorf("test stops the install: %w", context.Canceled)) + } + u := detect(t, "http://127.0.0.1:1/latest", link, "0.1.0", pathEnv) + err := u.Install(ctx, update.Release{Tag: "v0.2.0"}) + failed := err != nil + if tt.errText != "" { + failed = err == nil || !strings.Contains(err.Error(), tt.errText) + } + if failed { + t.Errorf("Install = %v, want an error with %q", err, tt.errText) + } + if tt.calls == "" { + return + } + got, err := os.ReadFile(filepath.Join(prefix, "calls")) + if err != nil { + t.Fatal(err) + } + if string(got) != tt.calls { + t.Errorf("brew calls = \n%s\nwant:\n%s", got, tt.calls) + } + }) } } @@ -194,6 +230,7 @@ func archive(t *testing.T, content string) []byte { gz := gzip.NewWriter(&b) tw := tar.NewWriter(gz) add := func(name, content string, mode int64) { + t.Helper() hdr := &tar.Header{Name: name, Mode: mode, Size: int64(len(content)), Typeflag: tar.TypeReg} if err := tw.WriteHeader(hdr); err != nil { t.Fatal(err) @@ -213,171 +250,105 @@ func archive(t *testing.T, content string) []byte { return b.Bytes() } -// TestDownload checks the download of a release: the checksum, the binary -// in the archive, and the replacement of the target file of a link. +// TestDownload checks the download of a release. It checks the checksum, +// the binary in the archive, and the check of its version. It also checks +// the replacement of the target file of a link. func TestDownload(t *testing.T) { + t.Parallel() name := fmt.Sprintf("rexdiff-v0.2.0-%s-%s.tar.gz", runtime.GOOS, runtime.GOARCH) - good := archive(t, "new binary") - sum := sha256.Sum256(good) - files := map[string][]byte{ - "/" + name: good, - "/sha256sums.txt": []byte(hex.EncodeToString(sum[:]) + " " + name + "\nabc other.tar.gz\n"), - "/bad/" + name: []byte("tampered"), - "/bad/sha256sums.txt": []byte(hex.EncodeToString(sum[:]) + " " + name + "\n"), - } - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { - b, ok := files[req.URL.Path] - if !ok { - http.NotFound(w, req) - return + newBin := "#!/bin/sh\necho v0.2.0\n" + good := archive(t, newBin) + sums := func(b []byte) string { + sum := sha256.Sum256(b) + return hex.EncodeToString(sum[:]) + " " + name + "\nabc other.tar.gz\n" + } + stale := archive(t, "#!/bin/sh\necho v0.1.9\n") + // The limit of a binary is 128 MiB. + big := archive(t, strings.Repeat("x", 128<<20+1)) + files := fstest.MapFS{} + for dir, a := range map[string][]byte{"good": good, "stale": stale, "big": big, "bad": good} { + files[dir+"/"+name] = &fstest.MapFile{Data: a} + files[dir+"/sha256sums.txt"] = &fstest.MapFile{Data: []byte(sums(a))} + } + files["bad/"+name] = &fstest.MapFile{Data: []byte("tampered")} + mux := http.NewServeMux() + mux.Handle("/", http.FileServerFS(files)) + // The release of a directory has its files, at the URLs of the server. + // The release of "none" has no files. + mux.HandleFunc("GET /{dir}/latest", func(w http.ResponseWriter, req *http.Request) { + type asset struct { + Name string `json:"name"` + URL string `json:"browser_download_url"` } - w.Write(b) - })) - t.Cleanup(srv.Close) - release := func(prefix string) Release { - return Release{Tag: "v0.2.0", assets: []asset{ - {Name: name, URL: srv.URL + prefix + "/" + name}, - {Name: "sha256sums.txt", URL: srv.URL + prefix + "/sha256sums.txt"}, - }} - } - - dir := t.TempDir() - target := file(t, filepath.Join(dir, "real", "rexdiff"), "old binary") - link := filepath.Join(dir, "rexdiff") - if err := os.Symlink(target, link); err != nil { - t.Fatal(err) - } - fake := &fakeRun{version: "v0.2.0"} - u := &Updater{bin: link, version: "v0.1.0", method: methodDownload, run: fake.run} - if err := u.Install(t.Context(), release("")); err != nil { - t.Fatal(err) - } - if b, _ := os.ReadFile(target); string(b) != "new binary" { - t.Errorf("target file = %q, want the new binary", b) - } - if info, _ := os.Lstat(link); info.Mode()&os.ModeSymlink == 0 { - t.Error("the link is no longer a link") - } - if info, _ := os.Stat(target); info.Mode().Perm() != 0o755 { - t.Errorf("mode = %v, want 0755", info.Mode().Perm()) - } - if entries, _ := os.ReadDir(filepath.Dir(target)); len(entries) != 1 { - t.Errorf("the directory has %d files, want only rexdiff", len(entries)) - } - - // A file that does not match its checksum is not installed. - file(t, target, "old binary") - if err := u.Install(t.Context(), release("/bad")); err == nil || !strings.Contains(err.Error(), "does not match its checksum") { - t.Errorf("Install of a tampered archive: %v", err) - } - if b, _ := os.ReadFile(target); string(b) != "old binary" { - t.Errorf("target file after a bad download = %q, want the old binary", b) - } - - // download does not replace the old binary with a binary of another - // version, so the next start does not run it. - stale := &fakeRun{version: "v0.1.9"} - u.run = stale.run - if err := u.Install(t.Context(), release("")); err == nil || !strings.Contains(err.Error(), "left rexdiff v0.1.9, not v0.2.0") { - t.Errorf("Install of a binary of another version: %v", err) - } - if b, _ := os.ReadFile(target); string(b) != "old binary" { - t.Errorf("target file after a failed check = %q, want the old binary", b) - } - if entries, _ := os.ReadDir(filepath.Dir(target)); len(entries) != 1 { - t.Errorf("a failed check left %d files, want only rexdiff", len(entries)) - } - u.run = fake.run - - // A release without the archive of this system is an error. - if err := u.Install(t.Context(), Release{Tag: "v0.2.0"}); err == nil || !strings.Contains(err.Error(), "has no "+name) { - t.Errorf("Install of a release without assets: %v", err) - } -} - -// TestInstallOutlivesCancel checks that the cancel of the context of the -// program does not stop a brew that runs, because a stopped brew can leave -// a broken cask. -func TestInstallOutlivesCancel(t *testing.T) { - ctx, cancel := context.WithCancel(t.Context()) - var sawCancel bool - run := func(ctx context.Context, _ []string, name string, args ...string) ([]byte, error) { - if name == "brew" { - cancel() // the user quits rexdiff while brew runs - sawCancel = ctx.Err() != nil - return nil, nil + dir := req.PathValue("dir") + assets := []asset{} + for _, f := range []string{name, "sha256sums.txt"} { + if _, ok := files[dir+"/"+f]; ok { + assets = append(assets, asset{f, "http://" + req.Host + "/" + dir + "/" + f}) + } } - return []byte("v0.2.0\n"), nil - } - u := &Updater{bin: "/b/rexdiff", method: methodBrew, run: run} - if err := u.Install(ctx, Release{Tag: "v0.2.0"}); err != nil || sawCancel { - t.Errorf("Install after a cancel: %v; brew saw the cancel: %v", err, sawCancel) - } -} - -// TestBinaryTooLarge checks that a binary larger than the limit is an -// error, not a binary that is cut short. -func TestBinaryTooLarge(t *testing.T) { - big := strings.Repeat("x", maxBinary+1) - if _, err := binaryOf(archive(t, big)); err == nil || !strings.Contains(err.Error(), "larger than") { - t.Errorf("binaryOf of a binary over the limit: %v", err) - } - if b, err := binaryOf(archive(t, "ok")); err != nil || string(b) != "ok" { - t.Errorf("binaryOf = %q, %v", b, err) - } -} - -// TestVersion checks the version from the release build, and that a test -// binary, like a build for development, has none. -func TestVersion(t *testing.T) { - for stamped, want := range map[string]string{"0.2.0": "v0.2.0", "v0.2.0": "v0.2.0", "": ""} { - if got := Version(stamped); got != want { - t.Errorf("Version(%q) = %q, want %q", stamped, got, want) + release := map[string]any{"tag_name": "v0.2.0", "assets": assets} + if err := json.MarshalWrite(w, release); err != nil { + t.Error(err) } - } - if u := Detect("/bin/rexdiff", ""); u != nil { - t.Errorf("Detect of a build for development = %+v, want nil", u) - } - t.Setenv("REXDIFF_LATEST_URL", "http://127.0.0.1:1/latest") - if u := Detect("/bin/rexdiff", "0.2.0"); u == nil || u.latestURL != "http://127.0.0.1:1/latest" || u.method != methodDownload { - t.Errorf("Detect of a release build = %+v", u) - } -} - -// TestDetectBrew checks that a Homebrew install uses the brew beside its -// Caskroom, because Rex starts rexdiff with a PATH without Homebrew. -func TestDetectBrew(t *testing.T) { - prefix := t.TempDir() - cask := file(t, filepath.Join(prefix, "Caskroom", "rexdiff", "0.2.0", "rexdiff"), "") - brew := file(t, filepath.Join(prefix, "bin", "brew"), "#!/bin/sh\n") - brew, _ = filepath.EvalSymlinks(brew) // detect follows /var on macOS - link := filepath.Join(prefix, "bin", "rexdiff") - if err := os.Symlink(cask, link); err != nil { - t.Fatal(err) - } - t.Setenv("PATH", "/usr/bin:/bin") // the PATH of the Rex app - u := detect(link, "v0.2.0", false) - if u == nil || u.method != methodBrew || u.brew != brew { - t.Fatalf("detect = %+v, want brew %s", u, brew) - } - fake := &fakeRun{version: "v0.2.1"} - u.run = fake.run - if err := u.Install(t.Context(), Release{Tag: "v0.2.1"}); err != nil { - t.Fatal(err) - } - if want := "HOMEBREW_NO_ENV_HINTS=1 NONINTERACTIVE=1 " + brew + " update --quiet"; fake.calls[0] != want { - t.Errorf("first brew call = %q, want %q", fake.calls[0], want) - } -} + }) + base := serve(t, mux) -// TestErrorWithoutOutput checks that a command that fails with no output -// has no empty part at the end of its error. -func TestErrorWithoutOutput(t *testing.T) { - u := &Updater{run: func(context.Context, []string, string, ...string) ([]byte, error) { - return nil, errors.New(`exec: "brew": executable file not found in $PATH`) - }} - _, err := u.command(t.Context(), nil, "/x/brew", "upgrade") - if want := `brew: exec: "brew": executable file not found in $PATH`; err == nil || err.Error() != want { - t.Errorf("error = %v, want %s", err, want) + tests := []struct { + name, dir string + errText string // a part of the error, or "" for none + }{ + {"good", "good", ""}, + {"tampered", "bad", "does not match its checksum"}, + // The old binary stays, so the next start does not run a binary of + // another version. + {"other version", "stale", "the update left rexdiff v0.1.9, not v0.2.0"}, + // A binary over the limit is an error, not a binary that is cut. + {"too large", "big", "larger than"}, + {"no archive", "none", "has no " + name}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + const oldBin = "echo v0.1.0\n" + realDir := mkdir(t, filepath.Join(dir, "real")) + target := testgit.Script(t, realDir, "rexdiff", oldBin) + link := filepath.Join(dir, "rexdiff") + if err := os.Symlink(target, link); err != nil { + t.Fatal(err) + } + u := detect(t, base+"/"+tt.dir+"/latest", link, "0.1.0") + r, newer, err := u.Latest(t.Context()) + if err != nil || !newer { + t.Fatalf("Latest = %+v, %v, %v, want a newer release", r, newer, err) + } + err = u.Install(t.Context(), r) + want := newBin + if tt.errText != "" { + want = "#!/bin/sh\n" + oldBin + if err == nil || !strings.Contains(err.Error(), tt.errText) { + t.Errorf("Install: %v, want an error with %q", err, tt.errText) + } + } else if err != nil { + t.Fatal(err) + } + if b, err := os.ReadFile(target); err != nil || string(b) != want { + t.Errorf("target file = %q, %v, want %q", b, err, want) + } + if info, err := os.Lstat(link); err != nil || info.Mode()&os.ModeSymlink == 0 { + t.Errorf("after Install: %s is not a symlink, want a symlink", link) + } + if info, err := os.Stat(target); err != nil || info.Mode().Perm() != 0o755 { + t.Errorf("Stat = %v, %v, want mode 0755", info, err) + } + entries, err := os.ReadDir(filepath.Dir(target)) + if err != nil { + t.Fatal(err) + } + if len(entries) != 1 { + t.Errorf("the directory has %d files, want only rexdiff", len(entries)) + } + }) } } diff --git a/vcs/export_test.go b/vcs/export_test.go new file mode 100644 index 0000000..9f10dc0 --- /dev/null +++ b/vcs/export_test.go @@ -0,0 +1,7 @@ +package vcs + +import "time" + +// SetClock sets the clock of w. The pause between two checks follows it. +// A clock that does not move turns off the pause. +func (w *Watcher) SetClock(now func() time.Time) { w.now = now } diff --git a/vcs/hostile_test.go b/vcs/hostile_test.go index c00a549..8c9cd62 100644 --- a/vcs/hostile_test.go +++ b/vcs/hostile_test.go @@ -1,32 +1,38 @@ package vcs_test import ( + "context" "errors" "fmt" + "maps" "os" - "os/exec" "path/filepath" - "reflect" + "slices" "strings" + "syscall" "testing" + "testing/fstest" + "time" + "github.com/rednafi/rexdiff/internal/testgit" "github.com/rednafi/rexdiff/vcs" ) // commitAll commits all the changes in the working tree of dir. func commitAll(t *testing.T, dir string) { t.Helper() - git(t, dir, "add", "-A") - git(t, dir, "commit", "-qm", "all") + testgit.Git(t, dir, "add", "-A") + testgit.Git(t, dir, "commit", "-qm", "all") } // TestRevIsNotAnOption checks that git does not read a rev that starts // with "-" as an option. func TestRevIsNotAnOption(t *testing.T) { + t.Parallel() r, _ := newRepo(t) ctx := t.Context() out := filepath.Join(t.TempDir(), "out") - write(t, filepath.Dir(out), "out", "keep\n") + testgit.WriteFile(t, filepath.Dir(out), "out", "keep\n") for _, rev := range []string{"--output=" + out, "--independent", "--all", "-h"} { if id, err := r.Resolve(ctx, rev); err == nil { t.Errorf("Resolve(%q) = %s, want an error", rev, id) @@ -34,36 +40,42 @@ func TestRevIsNotAnOption(t *testing.T) { if id, err := r.Tree(ctx, rev); err == nil { t.Errorf("Tree(%q) = %s, want an error", rev, id) } - if id, err := r.MergeBase(ctx, rev); err == nil { - t.Errorf("MergeBase(%q) = %s, want an error", rev, id) + if id, err := r.MergeBaseTree(ctx, rev); err == nil { + t.Errorf("MergeBaseTree(%q) = %s, want an error", rev, id) } - if id, err := r.Parent(ctx, rev); err == nil { - t.Errorf("Parent(%q) = %s, want an error", rev, id) + if id, err := r.ParentTree(ctx, rev); err == nil { + t.Errorf("ParentTree(%q) = %s, want an error", rev, id) } if _, err := r.Diff(ctx, rev, head(t, r)); err == nil { - t.Errorf("Diff(%q, HEAD): no error", rev) + t.Errorf("Diff(%q, HEAD): no error, want an error", rev) } } - if b, _ := os.ReadFile(out); string(b) != "keep\n" { - t.Errorf("git changed the file of --output: %q", b) + b, err := os.ReadFile(out) + if err != nil { + t.Fatal(err) + } + if string(b) != "keep\n" { + t.Errorf("the file of --output = %q, want %q", b, "keep\n") } } // TestHostilePaths checks that each part of the API keeps a path as it is. func TestHostilePaths(t *testing.T) { + t.Parallel() r, dir := newRepo(t) ctx := t.Context() commitAll(t, dir) - paths := []string{ + hostile := []string{ "-n", "--output=x", "-", "tab\tname", "new\nline", "trail\n", `say "hi"`, `back\slash`, "ünï cödé", " spaces ", "*", "[a]", ":(glob)x", "\xff\xfe", } want := map[string]string{} // the content of each path - for i, p := range paths { + for i, p := range hostile { s := fmt.Sprintf("id%02d\n", i) if err := os.WriteFile(filepath.Join(dir, p), []byte(s), 0o644); err != nil { - t.Logf("skip %q: %v", p, err) // for example, macOS needs UTF-8 names + // For example, macOS needs UTF-8 names. + t.Logf("skip %q: %v", p, err) continue } want[p] = s @@ -76,28 +88,25 @@ func TestHostilePaths(t *testing.T) { } got[f.Path] = f.New } - if !reflect.DeepEqual(got, want) { - t.Errorf("Diff:\n%q\nwant:\n%q", got, want) + if !maps.Equal(got, want) { + t.Errorf("Diff = \n%q\nwant:\n%q", got, want) } es, err := r.Files(ctx) if err != nil { t.Fatal(err) } - files := map[string]bool{} - for _, e := range es { - files[e.Path] = true - } + files := paths(es) for p, s := range want { - if !files[p] { - t.Errorf("Files does not have %q", p) + if !slices.Contains(files, p) { + t.Errorf("Files has no %q, want it", p) } if got, err := r.ReadFile(p); got != s || err != nil { - t.Errorf("ReadFile(%q) = %q, %v; want %q", p, got, err, s) + t.Errorf("ReadFile(%q) = %q, %v, want %q", p, got, err, s) } q := strings.TrimSpace(s) - if ms, err := r.Grep(ctx, q, 10); err != nil || len(ms) != 1 || ms[0].Path != p { - t.Errorf("Grep(%q) = %+v, %v; want one match in %q", q, ms, err, p) + if ms, _, err := r.Grep(ctx, q, 10, time.Minute); err != nil || len(ms) != 1 || ms[0].Path != p { + t.Errorf("Grep(%q) = %+v, %v, want one match in %q", q, ms, err, p) } } } @@ -105,11 +114,12 @@ func TestHostilePaths(t *testing.T) { // TestDiffFileKinds checks what Diff gives for links, file modes, // directories, line ends, and empty files. func TestDiffFileKinds(t *testing.T) { + t.Parallel() r, dir := newRepo(t) - git(t, dir, "config", "core.autocrlf", "false") - write(t, dir, "mode.sh", "echo\n") - write(t, dir, "dir", "a file\n") - write(t, dir, "type", "a file\n") + testgit.Git(t, dir, "config", "core.autocrlf", "false") + testgit.WriteFile(t, dir, "mode.sh", "echo\n") + testgit.WriteFile(t, dir, "dir", "a file\n") + testgit.WriteFile(t, dir, "type", "a file\n") commitAll(t, dir) outside := t.TempDir() @@ -127,14 +137,19 @@ func TestDiffFileKinds(t *testing.T) { if err := os.Remove(filepath.Join(dir, "dir")); err != nil { t.Fatal(err) } - write(t, dir, "dir/in", "in\n") + testgit.WriteFile(t, dir, "dir/in", "in\n") if err := os.Remove(filepath.Join(dir, "type")); err != nil { t.Fatal(err) } link("a.txt", "type") - write(t, dir, "crlf", "a\r\nb\r\n") - write(t, dir, "empty", "") - write(t, dir, "no-newline", "end") + err := os.CopyFS(dir, fstest.MapFS{ + "crlf": {Data: []byte("a\r\nb\r\n")}, + "empty": {}, + "no-newline": {Data: []byte("end")}, + }) + if err != nil { + t.Fatal(err) + } want := []vcs.File{ {Path: "crlf", Status: vcs.StatusAdded, New: "a\r\nb\r\n"}, @@ -148,37 +163,39 @@ func TestDiffFileKinds(t *testing.T) { {Path: "out", Status: vcs.StatusAdded, New: outside}, {Path: "type", Status: vcs.StatusModified, Old: "a file\n", New: "a.txt"}, } - if got := changes(t, r, head(t, r), snapshot(t, r)); !reflect.DeepEqual(got, want) { - t.Errorf("Diff:\n%+v\nwant:\n%+v", got, want) + if got := changes(t, r, head(t, r), snapshot(t, r)); !slices.Equal(got, want) { + t.Errorf("Diff = \n%+v\nwant:\n%+v", got, want) } } // TestSnapshotNestedRepo checks that Snapshot adds the other files when git // cannot add a nested repository with no commit. func TestSnapshotNestedRepo(t *testing.T) { + t.Parallel() r, dir := newRepo(t) commitAll(t, dir) - git(t, dir, "init", "-q", "empty") - write(t, dir, "empty/x", "x\n") - write(t, dir, "full/y", "y\n") - git(t, filepath.Join(dir, "full"), "init", "-q") + testgit.InitRepo(t, filepath.Join(dir, "empty")) + testgit.WriteFile(t, dir, "empty/x", "x\n") + testgit.WriteFile(t, dir, "full/y", "y\n") + testgit.InitRepo(t, filepath.Join(dir, "full")) commitAll(t, filepath.Join(dir, "full")) - write(t, dir, "z.txt", "z\n") + testgit.WriteFile(t, dir, "z.txt", "z\n") got := changes(t, r, head(t, r), snapshot(t, r)) - isSubmodule := strings.HasPrefix(got[0].New, "Subproject commit ") - if names(got) != "Afull Az.txt" || !isSubmodule { + if names(got) != "Afull Az.txt" || !strings.HasPrefix(got[0].New, "Subproject commit ") { t.Errorf("Diff = %+v, want full as a submodule and z.txt", got) } } // TestReadFileStaysInRoot checks that ReadFile reads only the regular files -// in the root. A repository can link to a secret file, or to a device that -// does not end, for example /dev/zero. +// in the root. func TestReadFileStaysInRoot(t *testing.T) { + t.Parallel() + // A repository can link to a secret file, or to a device that does not + // end, for example /dev/zero. r, dir := newRepo(t) secret := filepath.Join(filepath.Dir(dir), "secret") - write(t, filepath.Dir(dir), "secret", "secret\n") + testgit.WriteFile(t, filepath.Dir(dir), "secret", "secret\n") links := map[string]string{ "to-secret": secret, "to-zero": "/dev/zero", @@ -189,7 +206,7 @@ func TestReadFileStaysInRoot(t *testing.T) { t.Fatal(err) } } - if err := exec.Command("mkfifo", filepath.Join(dir, "fifo")).Run(); err != nil { + if err := syscall.Mkfifo(filepath.Join(dir, "fifo"), 0o644); err != nil { t.Fatal(err) } bad := []string{"../secret", secret, "to-secret", "to-zero", "to-dir", "fifo"} @@ -202,74 +219,60 @@ func TestReadFileStaysInRoot(t *testing.T) { t.Fatal(err) } if s, err := r.ReadFile("to-a"); s != "one\ntwo\n" || err != nil { - t.Errorf("ReadFile(to-a) = %q, %v; want the content of a.txt", s, err) + t.Errorf("ReadFile(to-a) = %q, %v, want the content of a.txt", s, err) } } -// TestOpenRootWithNewline checks Open and Diff in a root whose path has a -// newline. -func TestOpenRootWithNewline(t *testing.T) { - dir := filepath.Join(t.TempDir(), "a\nb") - git(t, filepath.Dir(dir), "init", "-q", dir) - write(t, dir, "a.txt", "one\n") - r, err := vcs.Open(t.Context(), dir) - if err != nil { - t.Fatal(err) - } - if want, _ := filepath.EvalSymlinks(dir); r.Root() != want { - t.Errorf("Root = %q, want %q", r.Root(), want) - } - if got := names(changes(t, r, head(t, r), snapshot(t, r))); got != "Aa.txt" { - t.Errorf("Diff(Head, Snapshot) = %s, want Aa.txt", got) - } -} - -// TestGitWorktree checks a working tree that "git worktree add" made. Such -// a working tree has its own index. +// TestGitWorktree checks Diff and Snapshot in a working tree that +// "git worktree add" creates. func TestGitWorktree(t *testing.T) { + t.Parallel() + // Such a working tree has its own index. Snapshot must not change it. _, dir := newRepo(t) - ctx := t.Context() wt := filepath.Join(t.TempDir(), "wt") - git(t, dir, "worktree", "add", "-q", "-b", "other", wt, "main") - write(t, wt, "a.txt", "changed\n") - write(t, wt, "staged.txt", "s\n") - git(t, wt, "add", "staged.txt") + testgit.Git(t, dir, "worktree", "add", "-q", "-b", "other", wt, "main") + testgit.WriteFile(t, wt, "a.txt", "changed\n") + testgit.WriteFile(t, wt, "staged.txt", "s\n") + testgit.Git(t, wt, "add", "staged.txt") - r, err := vcs.Open(ctx, wt) - if err != nil { - t.Fatal(err) + r := open(t, wt) + if got := uncommitted(t, r); got != "Ma.txt Astaged.txt" { + t.Errorf("Diff(HEAD, Snapshot) = %s, want Ma.txt Astaged.txt", got) } - got := names(changes(t, r, head(t, r), snapshot(t, r))) - if got != "Ma.txt Astaged.txt" { - t.Errorf("Diff(Head, Snapshot) = %s, want Ma.txt Astaged.txt", got) - } - st, _ := exec.Command("git", "-C", wt, "status", "--porcelain").Output() - if string(st) != " M a.txt\nA staged.txt\n" { - t.Errorf("the index of the worktree changed:\n%s", st) + st := testgit.Git(t, wt, "status", "--porcelain") + if st != " M a.txt\nA staged.txt\n" { + t.Errorf("git status in the worktree = \n%s\nwant:\n%s", + st, " M a.txt\nA staged.txt\n") } } -// TestDetachedHead checks Diff and Commits when HEAD is detached. +// TestDetachedHead checks Diff, Commits, and Branch when HEAD is detached. func TestDetachedHead(t *testing.T) { + t.Parallel() r, dir := newRepo(t) ctx := t.Context() - git(t, dir, "checkout", "-q", "--detach", "main") - got := names(changes(t, r, head(t, r), snapshot(t, r))) - if got != "A.gitignore Ma.txt Anew.txt" { - t.Errorf("Diff(Head, Snapshot) = %s", got) + testgit.Git(t, dir, "checkout", "-q", "--detach", "main") + if got := uncommitted(t, r); got != "A.gitignore Ma.txt Anew.txt" { + t.Errorf("Diff(HEAD, Snapshot) = %s, want A.gitignore Ma.txt Anew.txt", + got) + } + if cs, err := r.Commits(ctx, ""); err != nil || len(cs) != 1 || cs[0].Subject != "init" { + t.Errorf("Commits = %+v, %v, want init", cs, err) } - if cs, err := r.Commits(ctx, 5); err != nil || len(cs) != 1 || cs[0].Subject != "init" { - t.Errorf("Commits = %+v, %v; want init", cs, err) + if b, err := r.Branch(ctx); err != nil || b != "HEAD" { + t.Errorf("Branch = %q, %v, want HEAD, nil", b, err) } } // TestGrepIsLiteral checks that Grep finds the text, and does not read it -// as a pattern or as an option. +// as a pattern or as an option. It also checks the smart case. func TestGrepIsLiteral(t *testing.T) { + t.Parallel() r, dir := newRepo(t) ctx := t.Context() - write(t, dir, "g.txt", "abc\na.c\n-v\n--help\n[x\n(y\n\\d\n*\n^a\nx$\n") - git(t, dir, "config", "grep.column", "true") // adds a field to the output + testgit.WriteFile(t, dir, "g.txt", "abc\na.c\n-v\n--help\n[x\n(y\n\\d\n*\n^a\nx$\nAbc\n") + // grep.column adds a field to the output. + testgit.Git(t, dir, "config", "grep.column", "true") tests := []struct { q string want []string @@ -284,18 +287,20 @@ func TestGrepIsLiteral(t *testing.T) { {"^a", []string{"^a"}}, {"x$", []string{"x$"}}, {"abc\na.c", nil}, // no line has a newline + {"abc", []string{"abc", "Abc"}}, + {"Abc", []string{"Abc"}}, } for _, tt := range tests { - ms, err := r.Grep(ctx, tt.q, 10) + ms, _, err := r.Grep(ctx, tt.q, 10, time.Minute) if err != nil { - t.Errorf("Grep(%q): %v", tt.q, err) + t.Errorf("Grep(%q) = %v, want no error", tt.q, err) continue } var got []string for _, m := range ms { got = append(got, m.Text) } - if !reflect.DeepEqual(got, tt.want) { + if !slices.Equal(got, tt.want) { t.Errorf("Grep(%q) = %q, want %q", tt.q, got, tt.want) } } @@ -304,12 +309,13 @@ func TestGrepIsLiteral(t *testing.T) { // TestCommitsHostileSubject checks that Commits keeps each byte of a // subject, for example the field separator of the log. func TestCommitsHostileSubject(t *testing.T) { + t.Parallel() r, dir := newRepo(t) subjects := []string{"a\x1fb", "\x1b[31mred\x1b[0m", "tab\there"} for _, s := range subjects { - git(t, dir, "commit", "-q", "--allow-empty", "-m", s) + testgit.Git(t, dir, "commit", "-q", "--allow-empty", "-m", s) } - cs, err := r.Commits(t.Context(), 3) + cs, err := r.Commits(t.Context(), "") if err != nil { t.Fatal(err) } @@ -320,8 +326,8 @@ func TestCommitsHostileSubject(t *testing.T) { t.Errorf("Author of %q = %q, want t", c.Subject, c.Author) } } - want := []string{subjects[2], subjects[1], subjects[0]} - if !reflect.DeepEqual(got, want) { + want := []string{subjects[2], subjects[1], subjects[0], "b", "init"} + if !slices.Equal(got, want) { t.Errorf("Commits subjects = %q, want %q", got, want) } } @@ -329,12 +335,13 @@ func TestCommitsHostileSubject(t *testing.T) { // TestPullRequestBadJSON checks the output of gh that is not a normal pull // request. func TestPullRequestBadJSON(t *testing.T) { + t.Parallel() tests := []struct { name, out string - want error // nil for an error of any type + wantErr error // nil for an error of any type ok bool // true if PullRequest must not fail }{ - {name: "empty object", out: `{}`, want: vcs.ErrNotFound}, + {name: "empty object", out: `{}`, wantErr: vcs.ErrNoPullRequest}, {name: "not JSON", out: `warning: no pull request`}, {name: "huge number", out: `{"title": "T", "number": 1e30}`}, { @@ -346,19 +353,109 @@ func TestPullRequestBadJSON(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() bin := t.TempDir() - fakePath(t, bin) fakeTool(t, bin, "gh", map[string]string{ghArgs: tt.out}) - r, _ := repoAt(t, "https://github.com/o/r") + r, _ := repoAt(t, testgit.ToolEnv(t, bin), "https://github.com/o/r") pr, err := r.PullRequest(t.Context(), vcs.Hosts{}) switch { case tt.ok && (err != nil || pr.Title != "T"): - t.Errorf("PullRequest = %+v, %v; want the title T", pr, err) + t.Errorf("PullRequest = %+v, %v, want the title T", pr, err) case !tt.ok && err == nil: t.Errorf("PullRequest = %+v, want an error", pr) - case tt.want != nil && !errors.Is(err, tt.want): - t.Errorf("PullRequest: err = %v, want %v", err, tt.want) + case tt.wantErr != nil && !errors.Is(err, tt.wantErr): + t.Errorf("PullRequest: err = %v, want %v", err, tt.wantErr) } }) } } + +// TestGrepDeadline checks that Grep returns the matches that it finds +// before the deadline of ctx. more is true, as with the time budget of +// herdr-reviewr. +func TestGrepDeadline(t *testing.T) { + t.Parallel() + env, link := gitLink(t) + r, _ := newRepoEnv(t, env) + // The fake git takes the place of git after Open. It prints one match + // and a cut record, then hangs. With the file slow beside it, it first + // waits much longer than the budget. With the FIFO block beside it, it + // waits in the shell until it stops, so it prints nothing. + removeGit(t, link) + bin := filepath.Dir(link) + testgit.Script(t, bin, "git", `d="${0%/*}" +[ -e "$d/slow" ] && /bin/sleep 0.3 +[ -p "$d/block" ] && read -r _ < "$d/block" +printf 'a.go\0001\000hit\nb.go\00012' +exec /bin/sleep 10 +`) + grep := func(timeout, budget time.Duration) ([]vcs.Match, bool, error) { + ctx, cancel := context.WithTimeoutCause(t.Context(), timeout, + fmt.Errorf("test grep: %v timeout: %w", timeout, context.DeadlineExceeded)) + defer cancel() + return r.Grep(ctx, "hit", 10, budget) + } + if ms, more, err := grep(time.Minute, 50*time.Millisecond); err != nil || !more || + len(ms) != 1 || ms[0].Text != "hit" { + t.Errorf("Grep past the budget = %+v, %v, %v, want the hit, more, no error", ms, more, err) + } + testgit.WriteFile(t, bin, "slow", "") + if ms, more, err := grep(time.Minute, time.Millisecond); err != nil || !more || len(ms) != 1 { + t.Errorf("Grep with no line at the budget = %+v, %v, %v, want the first hit, more", ms, more, err) + } + if err := os.Remove(filepath.Join(bin, "slow")); err != nil { + t.Fatal(err) + } + if err := syscall.Mkfifo(filepath.Join(bin, "block"), 0o644); err != nil { + t.Fatal(err) + } + if ms, more, err := grep(200*time.Millisecond, time.Millisecond); err != nil || !more || len(ms) != 0 { + t.Errorf("Grep past the deadline = %+v, %v, %v, want none, more, no error", ms, more, err) + } +} + +// TestSnapshotWaitStops checks that a Snapshot that waits for another +// Snapshot returns the cause of its context when the context ends. +func TestSnapshotWaitStops(t *testing.T) { + t.Parallel() + env, link := gitLink(t) + r, _ := newRepoEnv(t, env) + // The fake git takes the place of git after Open. It writes to the FIFO + // started, so the test knows that the first Snapshot holds the lock. + // Then it waits on the FIFO block until it stops. + removeGit(t, link) + bin := filepath.Dir(link) + for _, name := range []string{"started", "block"} { + if err := syscall.Mkfifo(filepath.Join(bin, name), 0o644); err != nil { + t.Fatal(err) + } + } + testgit.Script(t, bin, "git", `d="${0%/*}" +echo started > "$d/started" +read -r _ < "$d/block" +`) + errFirst := errors.New("the test stops the first snapshot") + ctx, cancel := context.WithCancelCause(t.Context()) + defer cancel(fmt.Errorf("test: done: %w", context.Canceled)) + done := make(chan error, 1) + go func() { + _, err := r.Snapshot(ctx) + done <- err + }() + if _, err := os.ReadFile(filepath.Join(bin, "started")); err != nil { + t.Fatal(err) + } + + ctx2, cancel2 := context.WithTimeoutCause(t.Context(), 50*time.Millisecond, + fmt.Errorf("second snapshot: 50ms timeout: %w", context.DeadlineExceeded)) + defer cancel2() + _, err := r.Snapshot(ctx2) + if !errors.Is(err, context.DeadlineExceeded) { + t.Errorf("Snapshot that waits = %v, want the cause of its context", err) + } + + cancel(errFirst) + if err := <-done; !errors.Is(err, errFirst) { + t.Errorf("Snapshot that holds the lock = %v, want the cause %v", err, errFirst) + } +} diff --git a/vcs/main_test.go b/vcs/main_test.go new file mode 100644 index 0000000..b2f89b9 --- /dev/null +++ b/vcs/main_test.go @@ -0,0 +1,9 @@ +package vcs_test + +import ( + "testing" + + "github.com/rednafi/rexdiff/internal/testgit" +) + +func TestMain(m *testing.M) { testgit.Main(m) } diff --git a/vcs/pr.go b/vcs/pr.go index fc246b6..73f7fca 100644 --- a/vcs/pr.go +++ b/vcs/pr.go @@ -7,19 +7,25 @@ import ( "errors" "fmt" "strings" + + "github.com/rednafi/rexdiff/internal/termtext" ) var ( - // ErrNotFound means that the branch has no pull request. - ErrNotFound = errors.New("no pull request for this branch") + // ErrNoPullRequest means that the branch has no pull request. + ErrNoPullRequest = errors.New("no pull request for this branch") errNoRemote = errors.New("no git remote") errUnsupported = errors.New("the remote is not on GitHub, GitLab, or Azure DevOps") ) // maxComments is the number of the most recent comments that -// [Repo.PullRequest] gets. -const maxComments = 100 +// [Repo.PullRequest] gets. maxChecks is the number of the checks that it +// gets. The two limits are the same as in herdr-reviewr. +const ( + maxComments = 100 + maxChecks = 100 +) // Hosts are the hosts of self-hosted forges. An empty value means only the // public host. @@ -32,7 +38,7 @@ type PullRequest struct { Forge Forge Number int Title string - State State + State PRState URL string Author string Head string // the source branch @@ -40,7 +46,7 @@ type PullRequest struct { Body string // Markdown Checks []Check // Comments are the most recent comments, the oldest first. - Comments []Comment + Comments []PRComment } // Check is a CI check. @@ -53,25 +59,28 @@ type Check struct { // the service. type Forge string +// The forges that [Repo.PullRequest] supports. const ( ForgeGitHub Forge = "GitHub" ForgeGitLab Forge = "GitLab" ForgeAzure Forge = "Azure DevOps" ) -// State is the state of a pull request. It is the same for each forge. -type State string +// PRState is the state of a pull request. It is the same for each forge. +type PRState string +// The values of PRState. const ( - StateOpen State = "open" - StateDraft State = "draft" // open, but not ready for review - StateMerged State = "merged" - StateClosed State = "closed" // closed without a merge + PROpen PRState = "open" + PRDraft PRState = "draft" // open, but not ready for review + PRMerged PRState = "merged" + PRClosed PRState = "closed" // closed without a merge ) // Result is the result of a CI check. It is the same for each forge. type Result string +// The values of Result. const ( ResultPass Result = "pass" ResultFail Result = "fail" // also an error, a cancel, or a timeout @@ -79,56 +88,101 @@ const ( ResultSkip Result = "skip" // skipped, or neutral ) -// Comment is a comment on a pull request. -type Comment struct { +// PRComment is a comment on a pull request. +type PRComment struct { Author string When string // the time, as the forge gives it Body string // Markdown } // PullRequest returns the pull request of the current branch. It selects the -// forge from the URL of the remote "upstream", or else "origin". Then it runs +// forge from the host of the remote "upstream", or else "origin". Then it runs // the command-line tool of the forge: gh, glab, or az. The tool must be -// installed and signed in. +// installed and signed in. The text of the pull request has no control +// characters. If the branch has no pull request, PullRequest returns +// [ErrNoPullRequest]. func (r *Repo) PullRequest(ctx context.Context, h Hosts) (*PullRequest, error) { - remote := strings.ToLower(r.remoteURL(ctx)) // host names ignore case - has := func(host string) bool { - return host != "" && strings.Contains(remote, strings.ToLower(host)) + remote := r.remoteURL(ctx) + if remote == "" { + return nil, errNoRemote } + host := remoteHost(remote) + is := func(name string) bool { return name != "" && strings.EqualFold(host, name) } var pr *PullRequest var err error switch { - case remote == "": - return nil, errNoRemote - case has("github.com") || has(h.GitHub): + case is("github.com") || is(h.GitHub): pr, err = r.gitHub(ctx, h.GitHub) - case has("gitlab") || has(h.GitLab): + case is("gitlab.com") || is(h.GitLab): pr, err = r.gitLab(ctx) - case has("dev.azure.com") || has("visualstudio.com") || has(h.Azure): + case is("dev.azure.com") || is("ssh.dev.azure.com") || + strings.HasSuffix(host, ".visualstudio.com") || is(h.Azure): pr, err = r.azure(ctx) default: return nil, errUnsupported } - if err == nil && pr.Title == "" { - return nil, ErrNotFound + if err != nil { + return nil, err + } + if pr.Title == "" { + return nil, ErrNoPullRequest } - return pr, err + cleanPR(pr) + return pr, nil } -// decode runs a forge tool in the root directory and decodes its JSON -// output into v. env adds to the environment. -func (r *Repo) decode( - ctx context.Context, - env []string, - v any, - name string, - args ...string, -) error { - out, err := command(ctx, r.root, env, name, args...) +// remoteHost returns the host of the remote URL u, in lower case. u is a +// URL such as "ssh://git@host:22/path", or has the scp form +// "git@host:path", as herdr-reviewr reads it. A local path has no host. +func remoteHost(u string) string { + var auth string // [user@]host[:port] + if _, rest, ok := strings.Cut(u, "://"); ok { + auth, _, _ = strings.Cut(rest, "/") + } else if a, _, ok := strings.Cut(u, ":"); ok { + auth = a + } + if _, h, ok := strings.CutLast(auth, "@"); ok { + auth = h + } + host, _, _ := strings.Cut(auth, ":") + return strings.ToLower(host) +} + +// cleanPR removes the control characters from the text of pr, because the +// text comes from other users. +func cleanPR(pr *PullRequest) { + texts := []*string{&pr.Title, &pr.URL, &pr.Author, &pr.Head, &pr.Base, &pr.Body} + for i := range pr.Checks { + texts = append(texts, &pr.Checks[i].Name) + } + for i := range pr.Comments { + c := &pr.Comments[i] + texts = append(texts, &c.Author, &c.When, &c.Body) + } + for _, s := range texts { + *s = termtext.Plain(*s) + } +} + +// runJSON runs a forge tool in the root directory and decodes its JSON +// output into v. env adds to the environment. The error names the tool +// and its subcommand, for example "gh pr". +func (r *Repo) runJSON(ctx context.Context, env []string, v any, name string, args ...string) error { + cmd, stderr := r.command(ctx, env, name, args...) + out, err := output(ctx, cmd, stderr) + if err == nil { + err = json.Unmarshal([]byte(out), v) + } if err != nil { - return err + return fmt.Errorf("%s %s: %w", name, args[0], err) } - return json.Unmarshal([]byte(out), v) + return nil +} + +// noPR reports whether err is the error of a tool that finds no pull +// request. Such a tool exits with 1 and says so with the text msg. +func noPR(err error, msg string) bool { + return exitedOne(err) && strings.Contains(strings.ToLower(err.Error()), msg) } // lastN returns the last n elements of xs, or all of them if there are @@ -176,7 +230,10 @@ func (r *Repo) gitHub(ctx context.Context, host string) (*PullRequest, error) { const fields = "number,title,state,isDraft,url,body,author," + "headRefName,baseRefName,comments,statusCheckRollup" var v gitHubPR - err := r.decode(ctx, env, &v, "gh", "pr", "view", "--json", fields) + err := r.runJSON(ctx, env, &v, "gh", "pr", "view", "--json", fields) + if noPR(err, "no pull requests found") { + return nil, ErrNoPullRequest + } if err != nil { return nil, err } @@ -192,14 +249,15 @@ func (r *Repo) gitHub(ctx context.Context, host string) (*PullRequest, error) { Body: v.Body, } // A check run has a conclusion when it is done. A status has a state. - for _, c := range v.StatusCheckRollup { + // As in herdr-reviewr, the checks are the first that gh gives. + for _, c := range v.StatusCheckRollup[:min(len(v.StatusCheckRollup), maxChecks)] { pr.Checks = append(pr.Checks, Check{ Name: cmp.Or(c.Name, c.Context), Result: gitHubResult(cmp.Or(c.Conclusion, c.State)), }) } for _, c := range lastN(v.Comments, maxComments) { - pr.Comments = append(pr.Comments, Comment{ + pr.Comments = append(pr.Comments, PRComment{ Author: c.Author.Login, When: c.CreatedAt, Body: c.Body, @@ -210,40 +268,40 @@ func (r *Repo) gitHub(ctx context.Context, host string) (*PullRequest, error) { // gitHubState returns the state of a GitHub pull request from its state // and its draft flag. -func gitHubState(s string, draft bool) State { +func gitHubState(s string, draft bool) PRState { switch { case s == "MERGED": - return StateMerged + return PRMerged case s == "CLOSED": - return StateClosed + return PRClosed case draft: - return StateDraft + return PRDraft } - return StateOpen + return PROpen } // gitLabState returns the state of a GitLab merge request. A locked merge // request is closed. -func gitLabState(s string) State { +func gitLabState(s string) PRState { switch s { case "opened": - return StateOpen + return PROpen case "merged": - return StateMerged + return PRMerged } - return StateClosed + return PRClosed } // azureState returns the state of an Azure DevOps pull request from its // status. An abandoned pull request is closed. -func azureState(s string) State { +func azureState(s string) PRState { switch s { case "active": - return StateOpen + return PROpen case "completed": - return StateMerged + return PRMerged } - return StateClosed + return PRClosed } // gitHubResult returns the result of a GitHub check from its conclusion or @@ -292,7 +350,10 @@ type gitLabDiscussion struct { // gitLab gets the merge request with glab. func (r *Repo) gitLab(ctx context.Context) (*PullRequest, error) { var v gitLabMR - err := r.decode(ctx, nil, &v, "glab", "mr", "view", "--output", "json") + err := r.runJSON(ctx, nil, &v, "glab", "mr", "view", "--output", "json") + if noPR(err, "no open merge request") { + return nil, ErrNoPullRequest + } if err != nil { return nil, err } @@ -311,24 +372,23 @@ func (r *Repo) gitLab(ctx context.Context) (*PullRequest, error) { // request without them. var ds []gitLabDiscussion args := []string{"mr", "note", "list", "--output", "json"} - if err := r.decode(ctx, nil, &ds, "glab", args...); err != nil { - return pr, nil + if err := r.runJSON(ctx, nil, &ds, "glab", args...); err == nil { + pr.Comments = gitLabComments(ds) } - pr.Comments = gitLabComments(ds) return pr, nil } // gitLabComments returns the most recent notes of the discussions, the // oldest first. It skips the notes that GitLab writes, for example "added // 1 commit". -func gitLabComments(ds []gitLabDiscussion) []Comment { - var cs []Comment +func gitLabComments(ds []gitLabDiscussion) []PRComment { + var cs []PRComment for _, d := range ds { for _, n := range d.Notes { if n.System { continue } - cs = append(cs, Comment{ + cs = append(cs, PRComment{ Author: n.Author.Username, When: n.CreatedAt, Body: n.Body, @@ -356,18 +416,18 @@ type azurePR struct { // azure gets the pull request of the current branch with az. func (r *Repo) azure(ctx context.Context) (*PullRequest, error) { - branch, err := r.currentBranch(ctx) + branch, err := r.Branch(ctx) if err != nil { return nil, err } var v []azurePR - err = r.decode(ctx, nil, &v, "az", "repos", "pr", "list", "--source-branch", branch, + err = r.runJSON(ctx, nil, &v, "az", "repos", "pr", "list", "--source-branch", branch, "--status", "all", "--top", "1", "--output", "json") if err != nil { return nil, err } if len(v) == 0 { - return nil, ErrNotFound + return nil, ErrNoPullRequest } p := v[0] return &PullRequest{ diff --git a/vcs/pr_test.go b/vcs/pr_test.go index ea5dfe4..5b33a94 100644 --- a/vcs/pr_test.go +++ b/vcs/pr_test.go @@ -2,12 +2,13 @@ package vcs_test import ( "errors" - "os" - "os/exec" - "path/filepath" + "fmt" "reflect" + "slices" + "strings" "testing" + "github.com/rednafi/rexdiff/internal/testgit" "github.com/rednafi/rexdiff/vcs" ) @@ -16,22 +17,21 @@ import ( // "unexpected call". func fakeTool(t *testing.T, dir, name string, calls map[string]string) { t.Helper() - script := "#!/bin/sh\ncase \"$*\" in\n" + var script strings.Builder + script.WriteString("case \"$*\" in\n") for args, out := range calls { - script += "'" + args + "') cat <<'EOF'\n" + out + "\nEOF\n;;\n" - } - script += "*) echo unexpected call: \"$*\" >&2; exit 1 ;;\nesac\n" - if err := os.WriteFile(filepath.Join(dir, name), []byte(script), 0o755); err != nil { - t.Fatal(err) + fmt.Fprintf(&script, "'%s') cat <<'EOF'\n%s\nEOF\n;;\n", args, out) } + script.WriteString("*) echo unexpected call: \"$*\" >&2; exit 1 ;;\nesac\n") + testgit.Script(t, dir, name, script.String()) } -// repoAt returns the test repository, on the branch feat, with origin at -// the URL remote. -func repoAt(t *testing.T, remote string) (*vcs.Repo, string) { +// repoAt returns the test repository with the environment env, on the +// branch feat, with origin at the URL remote. +func repoAt(t *testing.T, env []string, remote string) (*vcs.Repo, string) { t.Helper() - r, dir := newRepo(t) - git(t, dir, "remote", "add", "origin", remote) + r, dir := newRepoEnv(t, env) + testgit.Git(t, dir, "remote", "add", "origin", remote) return r, dir } @@ -47,8 +47,9 @@ func azArgs(branch string) string { // TestPullRequest checks the pull request that each forge tool gives. func TestPullRequest(t *testing.T) { + t.Parallel() dir := t.TempDir() - fakePath(t, dir) + env := testgit.ToolEnv(t, dir) fakeTool(t, dir, "gh", map[string]string{ghArgs: `{ "number": 7, "title": "Add x", "state": "OPEN", "isDraft": true, "url": "https://github.com/o/r/pull/7", @@ -85,15 +86,16 @@ func TestPullRequest(t *testing.T) { "repository": {"webUrl": "https://dev.azure.com/o/p/_git/r"}}]`}) tests := []struct { + name string remote string hosts vcs.Hosts want *vcs.PullRequest }{ - {"git@github.com:o/r.git", vcs.Hosts{}, &vcs.PullRequest{ + {"github ssh", "git@github.com:o/r.git", vcs.Hosts{}, &vcs.PullRequest{ Forge: vcs.ForgeGitHub, Number: 7, Title: "Add x", - State: vcs.StateDraft, + State: vcs.PRDraft, URL: "https://github.com/o/r/pull/7", Author: "ann", Head: "feat", @@ -107,30 +109,30 @@ func TestPullRequest(t *testing.T) { {Name: "docs", Result: vcs.ResultSkip}, {Name: "e2e", Result: vcs.ResultPending}, }, - Comments: []vcs.Comment{ + Comments: []vcs.PRComment{ {Author: "bob", When: "2026-01-02T03:04:05Z", Body: "LGTM"}, }, }}, - {"https://Git.Corp/o/r", vcs.Hosts{GitLab: "git.CORP"}, &vcs.PullRequest{ + {"gitlab custom host", "https://Git.Corp/o/r", vcs.Hosts{GitLab: "git.CORP"}, &vcs.PullRequest{ Forge: vcs.ForgeGitLab, Number: 3, Title: "Fix y", - State: vcs.StateOpen, + State: vcs.PROpen, URL: "https://gitlab.com/o/r/-/merge_requests/3", Author: "cy", Head: "fix", Base: "main", Body: "D", - Comments: []vcs.Comment{ + Comments: []vcs.PRComment{ {Author: "eve", When: "2026-01-02T03:04:05Z", Body: "Why?"}, {Author: "cy", When: "2026-01-03T03:04:05Z", Body: "Speed."}, }, }}, - {"https://dev.azure.com/o/p/_git/r", vcs.Hosts{}, &vcs.PullRequest{ + {"azure", "https://dev.azure.com/o/p/_git/r", vcs.Hosts{}, &vcs.PullRequest{ Forge: vcs.ForgeAzure, Number: 9, Title: "Z", - State: vcs.StateOpen, + State: vcs.PROpen, URL: "https://dev.azure.com/o/p/_git/r/pullrequest/9", Author: "Dee", Head: "feat", @@ -138,74 +140,175 @@ func TestPullRequest(t *testing.T) { }}, } for _, tt := range tests { - r, _ := repoAt(t, tt.remote) - pr, err := r.PullRequest(t.Context(), tt.hosts) - if err != nil { - t.Errorf("PullRequest(%s): %v", tt.remote, err) - continue - } - if !reflect.DeepEqual(pr, tt.want) { - t.Errorf("PullRequest(%s):\n%+v\nwant:\n%+v", tt.remote, pr, tt.want) - } + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + r, _ := repoAt(t, env, tt.remote) + pr, err := r.PullRequest(t.Context(), tt.hosts) + if err != nil { + t.Fatalf("PullRequest = %v, want no error", err) + } + if !reflect.DeepEqual(pr, tt.want) { + t.Errorf("PullRequest = \n%+v\nwant:\n%+v", pr, tt.want) + } + }) } } // TestPullRequestErrors checks the error for a missing remote, an unknown // forge, a missing pull request, and a missing tool. func TestPullRequestErrors(t *testing.T) { - dir := t.TempDir() - fakePath(t, dir) - fakeTool(t, dir, "az", map[string]string{azArgs("none"): `[]`}) + t.Parallel() + bin := t.TempDir() + env := testgit.ToolEnv(t, bin) + fakeTool(t, bin, "az", map[string]string{azArgs("none"): `[]`}) const azure = "https://dev.azure.com/o/p/_git/r" + const unsupported = "the remote is not on GitHub, GitLab, or Azure DevOps" + corp := vcs.Hosts{GitLab: "Git.Corp"} tests := []struct { + name string remote, branch string - want error // if not nil, err must wrap it - msg string // if not empty, the text of err + hosts vcs.Hosts + wantErr error // if not nil, err must wrap it + errText string // if not empty, a part of the text of err }{ - {remote: "", branch: "feat", msg: "no git remote"}, + // The host selects the forge, not a name in the path. + { + name: "github in the path", + remote: "https://example.com/github.com/r.git", + branch: "feat", + errText: unsupported, + }, + { + name: "gitlab in the path", + remote: "git@example.com:gitlab/r.git", + branch: "feat", + errText: unsupported, + }, { - remote: "https://example.com/r.git", - branch: "feat", - msg: "the remote is not on GitHub, GitLab, or Azure DevOps", + name: "other corp host", + remote: "https://git.corp.example/r", + branch: "feat", + hosts: corp, + errText: unsupported, }, - {remote: azure, branch: "none", want: vcs.ErrNotFound}, { - remote: azure, - branch: "other", - msg: "unexpected call: " + azArgs("other"), + name: "gitlab custom host", + remote: "git@git.corp:o/r.git", + branch: "feat", + hosts: corp, + errText: "glab is not installed", + }, + { + name: "github ssh with port", + remote: "ssh://git@GitHub.com:22/o/r", + branch: "feat", + errText: "gh is not installed", + }, + { + name: "visualstudio other branch", + remote: "https://u@org.visualstudio.com/p/_git/r", + branch: "other", + errText: "unexpected call: " + azArgs("other"), + }, + {name: "no remote", branch: "feat", errText: "no git remote"}, + {name: "azure no pull request", remote: azure, branch: "none", wantErr: vcs.ErrNoPullRequest}, + { + name: "azure other branch", + remote: azure, + branch: "other", + errText: "unexpected call: " + azArgs("other"), + }, + { + name: "github with no gh", + remote: "https://github.com/o/r", + branch: "feat", + errText: "gh is not installed", }, - {remote: "https://github.com/o/r", branch: "feat", msg: "gh isn't installed"}, } for _, tt := range tests { - r, dir := newRepo(t) - if tt.remote != "" { - git(t, dir, "remote", "add", "origin", tt.remote) - } - if tt.branch != "feat" { - git(t, dir, "checkout", "-qb", tt.branch) - } - _, err := r.PullRequest(t.Context(), vcs.Hosts{}) - switch { - case err == nil: - t.Errorf("PullRequest(%q): no error", tt.remote) - case tt.want != nil && !errors.Is(err, tt.want): - t.Errorf("PullRequest(%q): err = %v, want %v", tt.remote, err, tt.want) - case tt.msg != "" && err.Error() != tt.msg: - t.Errorf("PullRequest(%q): err = %q, want %q", tt.remote, err, tt.msg) - } + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + r, dir := newRepoEnv(t, env) + if tt.remote != "" { + testgit.Git(t, dir, "remote", "add", "origin", tt.remote) + } + if tt.branch != "feat" { + testgit.Git(t, dir, "checkout", "-qb", tt.branch) + } + _, err := r.PullRequest(t.Context(), tt.hosts) + switch { + case err == nil: + t.Error("PullRequest: no error, want an error") + case tt.wantErr != nil && !errors.Is(err, tt.wantErr): + t.Errorf("PullRequest: err = %v, want %v", err, tt.wantErr) + case tt.errText != "" && !strings.Contains(err.Error(), tt.errText): + t.Errorf("PullRequest: err = %q, want %q in it", err, tt.errText) + } + }) } } -// fakePath sets PATH to dir and the system directories, and links git into -// dir. Then PATH has the fake forge tools and no real ones. -func fakePath(t *testing.T, dir string) { - t.Helper() - p, err := exec.LookPath("git") +// TestPullRequestHostile checks that the text of a pull request has no +// control characters. +func TestPullRequestHostile(t *testing.T) { + t.Parallel() + dir := t.TempDir() + const osc = `\u001b]0;owned\u0007` + fakeTool(t, dir, "gh", map[string]string{ghArgs: `{ + "number": 7, "title": "Add` + osc + `", "state": "OPEN", + "body": "a` + osc + `\nb", "author": {"login": "ann` + osc + `"}, + "comments": [{"author": {"login": "bob"}, "body": "x` + osc + `"}], + "statusCheckRollup": [{"name": "t` + osc + `", "conclusion": "SUCCESS"}]}`}) + r, _ := repoAt(t, testgit.ToolEnv(t, dir), "https://github.com/o/r") + pr, err := r.PullRequest(t.Context(), vcs.Hosts{}) if err != nil { t.Fatal(err) } - if err := os.Symlink(p, filepath.Join(dir, "git")); err != nil { + if len(pr.Comments) != 1 || len(pr.Checks) != 1 { + t.Fatalf("PullRequest has %d comments and %d checks, want 1 and 1", + len(pr.Comments), len(pr.Checks)) + } + got := []string{pr.Title, pr.Body, pr.Author, pr.Comments[0].Body, pr.Checks[0].Name} + want := []string{"Add]0;owned", "a]0;owned\nb", "ann]0;owned", "x]0;owned", "t]0;owned"} + if !slices.Equal(got, want) { + t.Errorf("PullRequest texts = %q, want %q", got, want) + } +} + +// TestPullRequestCheckLimit checks that a pull request has at most 100 +// checks, the first that gh gives, as in herdr-reviewr. +func TestPullRequestCheckLimit(t *testing.T) { + t.Parallel() + dir := t.TempDir() + checks := make([]string, 101) + for i := range checks { + checks[i] = fmt.Sprintf(`{"name": "c%d", "conclusion": "SUCCESS"}`, i) + } + fakeTool(t, dir, "gh", map[string]string{ghArgs: `{"number": 7, "title": "T", + "statusCheckRollup": [` + strings.Join(checks, ",") + `]}`}) + r, _ := repoAt(t, testgit.ToolEnv(t, dir), "https://github.com/o/r") + pr, err := r.PullRequest(t.Context(), vcs.Hosts{}) + if err != nil { t.Fatal(err) } - t.Setenv("PATH", dir+":/bin:/usr/bin") + if n := len(pr.Checks); n != 100 || pr.Checks[99].Name != "c99" { + t.Errorf("PullRequest has %d checks %+v, want 100, the last c99", + n, pr.Checks[max(n-3, 0):]) + } +} + +// TestNoPullRequest checks that a gh or a glab that finds no pull request +// gives ErrNoPullRequest. Both exit with 1 and say so. +func TestNoPullRequest(t *testing.T) { + t.Parallel() + dir := t.TempDir() + env := testgit.ToolEnv(t, dir) + testgit.Script(t, dir, "gh", "echo 'no pull requests found for branch \"feat\"' >&2\nexit 1\n") + testgit.Script(t, dir, "glab", "echo 'no open merge request available for \"feat\"' >&2\nexit 1\n") + for _, remote := range []string{"https://github.com/o/r", "https://gitlab.com/o/r"} { + r, _ := repoAt(t, env, remote) + if _, err := r.PullRequest(t.Context(), vcs.Hosts{}); !errors.Is(err, vcs.ErrNoPullRequest) { + t.Errorf("PullRequest(%s) = %v, want ErrNoPullRequest", remote, err) + } + } } diff --git a/vcs/refs.go b/vcs/refs.go index 96751e7..488f2da 100644 --- a/vcs/refs.go +++ b/vcs/refs.go @@ -1,10 +1,20 @@ package vcs import ( + "bufio" "context" + "errors" + "fmt" + "os" + "path/filepath" "slices" "strconv" "strings" + "sync/atomic" + "time" + "unicode" + + "github.com/rednafi/rexdiff/internal/proc" ) // Commit is a commit in the log. @@ -15,14 +25,30 @@ type Commit struct { When string // the relative date, for example "2 hours ago" } -// Commits returns the last n commits of HEAD, the newest first. -func (r *Repo) Commits(ctx context.Context, n int) ([]Commit, error) { +// LastCommits is the number of commits that [Repo.Commits] returns with no +// since. +const LastCommits = 50 + +// Commits returns the first-parent commits of HEAD after the commit since, +// the newest first, as herdr-reviewr lists them. If since is "", it returns +// the last [LastCommits] commits. A HEAD with no commit yet has no commits. +func (r *Repo) Commits(ctx context.Context, since string) ([]Commit, error) { // The subject is last, because it can have the separator. const ( format = "--format=%H%x1f%h%x1f%ar%x1f%an%x1f%s" nFields = 5 // the number of fields in format ) - out, err := r.run(ctx, nil, "log", "-n", strconv.Itoa(n), format) + _, err := r.run(ctx, nil, "rev-parse", "-q", "--verify", "HEAD") + if exitedOne(err) { + return nil, nil + } + args := []string{"log", "--first-parent", format} + if since == "" { + args = append(args, "-n", strconv.Itoa(LastCommits), "--end-of-options", "HEAD") + } else { + args = append(args, "--end-of-options", since+"..HEAD") + } + out, err := r.run(ctx, nil, args...) if err != nil { return nil, err } @@ -43,30 +69,48 @@ func (r *Repo) Commits(ctx context.Context, n int) ([]Commit, error) { return cs, nil } -// Branches returns the names of the local and the remote branches. +// Branches returns the names of the local branches and of the branches +// of origin, the newest commit first, as herdr-reviewr lists them. A +// branch of origin, for example "origin/main", hides the local branch +// with the same name. func (r *Repo) Branches(ctx context.Context) ([]string, error) { - out, err := r.run(ctx, nil, - "for-each-ref", "--format=%(refname:short)", "refs/heads", "refs/remotes") + out, err := r.run(ctx, nil, "for-each-ref", "--sort=-committerdate", + "--format=%(refname)", "refs/remotes/origin", "refs/heads") if err != nil { return nil, err } + refs := strings.Fields(out) // a ref name has no space + onOrigin := map[string]bool{} + for _, ref := range refs { + if name, ok := strings.CutPrefix(ref, "refs/remotes/origin/"); ok { + onOrigin[name] = true + } + } var bs []string - // The short name of refs/remotes/origin/HEAD is "origin". - for b := range strings.FieldsSeq(out) { - if !strings.HasSuffix(b, "/HEAD") && b != "origin" { - bs = append(bs, b) + for _, ref := range refs { + local, isLocal := strings.CutPrefix(ref, "refs/heads/") + remote := strings.TrimPrefix(ref, "refs/remotes/") + switch { + case isLocal && !onOrigin[local]: + bs = append(bs, local) + case !isLocal && remote != "origin/HEAD": + bs = append(bs, remote) } } return bs, nil } // DefaultBase returns the default branch of the repository. This is the -// target of origin/HEAD, or the first that exists of main, master, trunk, -// origin/main, and origin/master. If there is none, it returns "HEAD". +// target of origin/HEAD if it exists, or the first that exists of main, +// master, trunk, origin/main, and origin/master. If there is none, it +// returns "HEAD". func (r *Repo) DefaultBase(ctx context.Context) string { - ref, err := r.run(ctx, nil, "symbolic-ref", "--short", "refs/remotes/origin/HEAD") - if err == nil { - return strings.TrimSpace(ref) + if ref, err := r.run(ctx, nil, "symbolic-ref", "--short", "refs/remotes/origin/HEAD"); err == nil { + // fetch --prune can leave the symbolic ref without a target. + ref = strings.TrimSpace(ref) + if _, err := r.Resolve(ctx, ref); err == nil { + return ref + } } for _, b := range []string{"main", "master", "trunk", "origin/main", "origin/master"} { if _, err := r.Resolve(ctx, b); err == nil { @@ -76,10 +120,17 @@ func (r *Repo) DefaultBase(ctx context.Context) string { return "HEAD" } -// currentBranch returns the name of the branch that HEAD points to. -func (r *Repo) currentBranch(ctx context.Context) (string, error) { - out, err := r.run(ctx, nil, "rev-parse", "--abbrev-ref", "HEAD") - return strings.TrimSpace(out), err +// Branch returns the name of the branch that HEAD points to. With a +// detached HEAD, it returns "HEAD". +func (r *Repo) Branch(ctx context.Context) (string, error) { + out, err := r.run(ctx, nil, "symbolic-ref", "--quiet", "--short", "HEAD") + if exitedOne(err) { + return "HEAD", nil + } + if err != nil { + return "", err + } + return strings.TrimSpace(out), nil } // remoteURL returns the URL of the remote "upstream", so that forks work, or @@ -107,6 +158,11 @@ type Entry struct { Ignored bool } +// listArgs are the arguments of git ls-files that list the tracked and +// the untracked files. In a merge conflict, git lists a path once for each +// stage. --deduplicate lists it once. +var listArgs = []string{"-co", "--deduplicate", "--exclude-standard"} + // Files returns the tracked and the untracked files, and the ignored paths, // sorted by path. func (r *Repo) Files(ctx context.Context) ([]Entry, error) { @@ -114,19 +170,17 @@ func (r *Repo) Files(ctx context.Context) ([]Entry, error) { args []string ignored bool }{ - {[]string{"ls-files", "-co", "--exclude-standard", "-z"}, false}, - {[]string{"ls-files", "-oi", "--exclude-standard", "--directory", "-z"}, true}, + {listArgs, false}, + {[]string{"-oi", "--exclude-standard", "--directory"}, true}, } var es []Entry for _, l := range lists { - out, err := r.run(ctx, nil, l.args...) + ps, err := r.lsFiles(ctx, l.args...) if err != nil { return nil, err } - for p := range strings.SplitSeq(out, "\x00") { - if p != "" { - es = append(es, Entry{Path: p, Ignored: l.ignored}) - } + for _, p := range ps { + es = append(es, Entry{Path: p, Ignored: l.ignored}) } } slices.SortStableFunc(es, func(a, b Entry) int { @@ -135,7 +189,28 @@ func (r *Repo) Files(ctx context.Context) ([]Entry, error) { return es, nil } -// Match is a line that [Repo.Grep] found. +// IgnoredDir returns the entries of the ignored directory dir, sorted by +// name. It reads them from the disk, as herdr-reviewr does, because git +// lists an ignored directory as one path. The path of a directory ends +// with a slash, as in [Repo.Files]. A directory that it cannot read has +// no entries. +func (r *Repo) IgnoredDir(dir string) []Entry { + des, err := os.ReadDir(filepath.Join(r.root, filepath.FromSlash(dir))) + if err != nil { + return nil + } + es := make([]Entry, 0, len(des)) + for _, de := range des { + p := dir + "/" + de.Name() + if de.IsDir() { + p += "/" + } + es = append(es, Entry{Path: p, Ignored: true}) + } + return es +} + +// Match is a line that [Repo.Grep] finds. type Match struct { Path string Line int // the line number, from 1 @@ -143,33 +218,174 @@ type Match struct { } // Grep returns at most limit lines of the tracked and the untracked files -// that contain the text q. The search ignores case and binary files. -func (r *Repo) Grep(ctx context.Context, q string, limit int) ([]Match, error) { +// that contain the text q. The search ignores binary files. As in +// herdr-reviewr, it has smart case. A q with an upper case letter matches +// only the same case. Else the search ignores case. When it finds limit +// lines, it stops git, and more is true if there are others. +// +// After the time budget, Grep stops git when it has a line, and returns +// the lines with more set to true. At the deadline of ctx, Grep stops git +// and returns the lines that it finds before it, with more set to true. +func (r *Repo) Grep(ctx context.Context, q string, limit int, budget time.Duration) (ms []Match, more bool, err error) { if strings.Contains(q, "\n") { - // git reads each line as a pattern, but no line has a newline. - return nil, nil + // git reads each line of q as a pattern. A match is one line, so no + // match can have the newline of q. + return nil, false, nil } // --no-column overrides the git setting grep.column. That setting adds a // field to each match. - out, err := r.run(ctx, nil, - "grep", "-n", "--no-column", "-z", "-I", "-i", "-F", "--untracked", "--no-color", - "-e", q) - if exitedWith(err, 1) { - return nil, nil // no match + args := []string{"grep", "-n", "--no-column", "-z", "-I", "-F", "--untracked", "--no-color"} + if !strings.ContainsFunc(q, unicode.IsUpper) { + args = append(args, "-i") } + cmd, stderr := r.command(ctx, nil, "git", append(args, "-e", q)...) + out, err := cmd.StdoutPipe() if err != nil { - return nil, err + return nil, false, err + } + if err := cmd.Start(); err != nil { + if pastDeadline(ctx) { + return nil, true, nil + } + return nil, false, fmt.Errorf("git grep: %w", proc.Error(ctx, "git", err, nil)) + } + // Past the budget, Grep stops git if it has a line, or else at the + // next line. + var found, late, killed atomic.Bool + timer := time.AfterFunc(budget, func() { + late.Store(true) + if found.Load() { + killed.Store(true) + _ = cmd.Process.Kill() + } + }) + ms, more = readMatches(bufio.NewReader(out), limit, func() bool { + found.Store(true) + return late.Load() + }) + timer.Stop() + more = more || killed.Load() + if more { + _ = cmd.Process.Kill() + } + err = cmd.Wait() + switch { + case err == nil, more, exitedOne(err): // done, stopped, or no match + return ms, more, nil + case pastDeadline(ctx): + return ms, true, nil + case ctx.Err() != nil: + return nil, false, context.Cause(ctx) + } + return nil, false, fmt.Errorf("git grep: %w", proc.Error(ctx, "git", err, stderr.Bytes())) +} + +// pastDeadline reports whether ctx ends at its deadline. +func pastDeadline(ctx context.Context) bool { + return errors.Is(ctx.Err(), context.DeadlineExceeded) +} + +// readMatches reads at most limit matches of "git grep -n -z". Each match +// is "path NUL line NUL text LF". Only the text ends with a newline, +// because the path can have a newline. After each match, a true stop ends +// the read. more is true if there is more output. +func readMatches(r *bufio.Reader, limit int, stop func() bool) (ms []Match, more bool) { + for len(ms) < limit { + path, err := r.ReadString(0) + if err != nil { + return ms, false + } + // A cut at the deadline can end the output in a record. Such a + // record is not a match. + num, err := r.ReadString(0) + if err != nil { + return ms, true + } + text, err := r.ReadString('\n') + if err != nil { + return ms, true + } + n, _ := strconv.Atoi(strings.TrimSuffix(num, "\x00")) + ms = append(ms, Match{ + Path: strings.TrimSuffix(path, "\x00"), + Line: n, + Text: strings.TrimSpace(text), + }) + if stop() { + return ms, true + } + } + _, err := r.Peek(1) + return ms, err == nil +} + +// The refs that keep the choices of rexdiff. git keeps a ref under +// refs/worktree/ for each worktree. +const ( + baseRef = "refs/worktree/rexdiff/base-pick" + turnRef = "refs/worktree/rexdiff/turn-base" +) + +// SavedBase returns the base that [Repo.SaveBase] keeps for this worktree. +// It returns "" if no base is saved, or if the saved value is not a ref +// name. +func (r *Repo) SavedBase(ctx context.Context) string { + out, err := r.run(ctx, nil, "cat-file", "blob", baseRef) + name := strings.TrimSpace(out) + if err != nil || !refShaped(name) { + return "" } - // Each match is "path NUL line NUL text LF". Only the text ends with a - // newline, because the path can have one. - var ms []Match - for len(ms) < limit && out != "" { - var path, num, text string - path, out, _ = strings.Cut(out, "\x00") - num, out, _ = strings.Cut(out, "\x00") - text, out, _ = strings.Cut(out, "\n") - n, _ := strconv.Atoi(num) - ms = append(ms, Match{Path: path, Line: n, Text: strings.TrimSpace(text)}) - } - return ms, nil + return name +} + +// refShaped reports whether s is one line of printable text that is not a +// git option, as herdr-reviewr checks a saved base. +func refShaped(s string) bool { + return s != "" && !strings.HasPrefix(s, "-") && + !strings.ContainsFunc(s, func(c rune) bool { return c <= ' ' || c == 0x7f }) +} + +// SaveBase keeps name as the base of this worktree. For the default branch +// ([Repo.DefaultBase]), it deletes the saved base. +func (r *Repo) SaveBase(ctx context.Context, name string) error { + if name == r.DefaultBase(ctx) { + return r.deleteRef(ctx, baseRef) + } + if !refShaped(name) { + return fmt.Errorf("bad base %q", name) + } + blob, err := r.runInput(ctx, nil, strings.NewReader(name), + "hash-object", "-w", "--stdin") + if err != nil { + return err + } + _, err = r.run(ctx, nil, "update-ref", baseRef, strings.TrimSpace(blob)) + return err +} + +// SavedTurn returns the tree that [Repo.SaveTurn] keeps for this worktree. +// It returns "" if no tree is saved, or if git cannot resolve the ref. +func (r *Repo) SavedTurn(ctx context.Context) string { + tree, err := r.Resolve(ctx, turnRef) + if err != nil { + return "" + } + return tree +} + +// SaveTurn keeps tree as the tree at the start of the last turn in this +// worktree. The ref keeps the tree from git gc. An empty tree deletes the +// saved tree. +func (r *Repo) SaveTurn(ctx context.Context, tree string) error { + if tree == "" { + return r.deleteRef(ctx, turnRef) + } + _, err := r.run(ctx, nil, "update-ref", turnRef, tree) + return err +} + +// deleteRef deletes ref. A missing ref is no error. +func (r *Repo) deleteRef(ctx context.Context, ref string) error { + _, err := r.run(ctx, nil, "update-ref", "-d", ref) + return err } diff --git a/vcs/repo.go b/vcs/repo.go index 4eef588..b9e2c6d 100644 --- a/vcs/repo.go +++ b/vcs/repo.go @@ -4,6 +4,10 @@ // A [Repo] compares trees. A tree is the ID of a git tree object. // [Repo.Snapshot] gets a tree of the working tree. [Repo.Diff] gets the files // that differ between two trees. +// +// [Open] takes the environment of git and of the forge tools as a value, +// as in [exec.Cmd.Env]. The package does not read the environment of the +// process. package vcs import ( @@ -14,6 +18,7 @@ import ( "errors" "fmt" "io" + "io/fs" "os" "os/exec" "path/filepath" @@ -21,39 +26,60 @@ import ( "strconv" "strings" "time" + + "github.com/rednafi/rexdiff/internal/proc" ) // emptyTree is the ID of the tree that has no files. const emptyTree = "4b825dc642cb6eb9a060e54bf8d69288fbee4904" -// maxFileSize is the largest file, in bytes, that [Repo.Diff] and -// [Repo.ReadFile] read. -const maxFileSize = 2 << 20 - -// maxLines is the largest number of lines in a file that [Repo.Diff] reads. -const maxLines = 50_000 - -// binaryCheckLen is the number of bytes at the start of a file that git -// checks for a NUL byte. -const binaryCheckLen = 8000 +// The size limits are the same as in herdr-reviewr. [Repo.Diff] applies +// them to the sum of the two versions of a file. [Repo.ReadFile] applies +// them to one file. +const ( + maxFileSize = 2_000_000 // bytes + maxLines = 50_000 +) var ( // ErrBinary means that a file is binary. ErrBinary = errors.New("binary file") - // ErrTooBig means that a file is larger than 2 MiB or has more than 50,000 - // lines. - ErrTooBig = errors.New("file too big") + // ErrTooBig means that a file is larger than 2,000,000 bytes or has + // more than 50,000 lines. + ErrTooBig = errors.New("file too large") ) // Repo is a git repository. type Repo struct { root string - index string // the path of the real index + index string // the path of the real index + env []string // the environment of git and of the forge tools + + // snapLock is the lock of the private index of Snapshot. A send takes + // the lock, and a receive gives it back. A channel lets Snapshot stop + // its wait when ctx ends. The lock guards snapDir, snapSeed and + // closed. snapDir is the directory of the private index, or "" before + // the first Snapshot. snapSeed is the file status of the real index at + // the last copy. + snapLock chan struct{} + snapDir string + snapSeed string + closed bool } -// Open returns the repository that contains dir. -func Open(ctx context.Context, dir string) (*Repo, error) { - r := &Repo{root: dir} +// errClosed is the error of a Snapshot after Close. +var errClosed = errors.New("the repository is closed") + +// Open returns the repository that contains dir. The repository runs git +// and the forge tools with the environment env, and finds them in the +// PATH of env. Open does not change env. +func Open(ctx context.Context, env []string, dir string) (*Repo, error) { + // git gets the root in PWD. PWD must be an absolute path. + abs, err := filepath.Abs(dir) + if err != nil { + return nil, err + } + r := &Repo{root: abs, env: env, snapLock: make(chan struct{}, 1)} // Get each path with its own git process, because a path can have a // newline. A path can also end with a space, so remove only the last // newline. @@ -61,7 +87,8 @@ func Open(ctx context.Context, dir string) (*Repo, error) { if err != nil { return nil, err } - r.root = strings.TrimSuffix(root, "\n") + // Resolve the links once, so Contains and Owns compare real paths. + r.root = evalSymlinks(strings.TrimSuffix(root, "\n")) index, err := r.run(ctx, nil, "rev-parse", "--path-format=absolute", "--git-path", "index") if err != nil { @@ -71,69 +98,79 @@ func Open(ctx context.Context, dir string) (*Repo, error) { return r, nil } +// Close removes the files that the repository keeps for [Repo.Snapshot]. +// Close waits for the Snapshot that runs. +func (r *Repo) Close() error { + r.snapLock <- struct{}{} + defer func() { <-r.snapLock }() + dir := r.snapDir + r.snapDir, r.closed = "", true + if dir == "" { + return nil + } + return os.RemoveAll(dir) +} + // Root returns the top directory of the working tree. func (r *Repo) Root() string { return r.root } // run runs git in the root directory and returns its standard output. env -// adds to the environment of the process. +// adds to the environment of the repository. func (r *Repo) run(ctx context.Context, env []string, args ...string) (string, error) { - out, err := command(ctx, r.root, env, "git", args...) + return r.runInput(ctx, env, nil, args...) +} + +// runInput is like run. git reads its standard input from in. +func (r *Repo) runInput(ctx context.Context, env []string, in io.Reader, args ...string) (string, error) { + cmd, stderr := r.command(ctx, env, "git", args...) + cmd.Stdin = in + out, err := output(ctx, cmd, stderr) if err != nil { return out, fmt.Errorf("git %s: %w", args[0], err) } return out, nil } -// command runs the program name in dir and returns its standard output. -// env adds to the environment of the process. The error has the first line -// of the standard error. It wraps the error of the process, so callers can -// check its exit code. -func command( - ctx context.Context, - dir string, - env []string, - name string, - args ...string, -) (string, error) { - cmd := exec.CommandContext(ctx, name, args...) - cmd.Dir = dir - if env != nil { - cmd.Env = append(os.Environ(), env...) - } +// command returns the command "name args..." in the root directory. env +// adds to the environment of the repository. The buffer gets the standard +// error. +func (r *Repo) command(ctx context.Context, env []string, name string, args ...string) (*exec.Cmd, *bytes.Buffer) { + // PWD is the directory of the command, as exec sets it for a nil Env. + // A copy keeps r.env as it is. + env = slices.Concat(r.env, []string{"PWD=" + r.root}, env) + cmd := proc.Command(ctx, env, name, args...) + cmd.Dir = r.root var stderr bytes.Buffer cmd.Stderr = &stderr + return cmd, &stderr +} + +// output runs cmd and returns its standard output. ctx is the context of +// cmd. The error has the first line of the standard error, or the cause of +// ctx. It wraps the error of the process, so callers can examine its exit +// code. +func output(ctx context.Context, cmd *exec.Cmd, stderr *bytes.Buffer) (string, error) { out, err := cmd.Output() - if errors.Is(err, exec.ErrNotFound) { - return "", fmt.Errorf("%s isn't installed", name) - } if err != nil { - msg, _, _ := strings.Cut(strings.TrimSpace(stderr.String()), "\n") - return string(out), &runError{cmp.Or(msg, err.Error()), err} + return string(out), proc.Error(ctx, cmd.Args[0], err, stderr.Bytes()) } return string(out), nil } -// runError shows the message of the program, but keeps the error of the -// process. -type runError struct { - msg string - err error -} - -func (e *runError) Error() string { return e.msg } -func (e *runError) Unwrap() error { return e.err } - -// exitedWith reports whether err is from a process that exited with code. -func exitedWith(err error, code int) bool { +// exitedOne reports whether err is from a process that exited with code 1. +func exitedOne(err error) bool { ee, ok := errors.AsType[*exec.ExitError](err) - return ok && ee.ExitCode() == code + return ok && ee.ExitCode() == 1 } // Resolve returns the object ID of rev, for example "HEAD" or "main^{tree}". func (r *Repo) Resolve(ctx context.Context, rev string) (string, error) { out, err := r.run(ctx, nil, "rev-parse", "--verify", "--quiet", rev) + if exitedOne(err) { + return "", fmt.Errorf("git: unknown revision %q", rev) + } if err != nil { - return "", fmt.Errorf("git: unknown revision %s", rev) + return "", err } return strings.TrimSpace(out), nil } @@ -143,9 +180,9 @@ func (r *Repo) Tree(ctx context.Context, rev string) (string, error) { return r.Resolve(ctx, rev+"^{tree}") } -// Head returns the tree of HEAD. If HEAD is a branch with no commits yet, +// HeadTree returns the tree of HEAD. If HEAD is a branch with no commits yet, // as in a new repository, it returns the empty tree. -func (r *Repo) Head(ctx context.Context) (string, error) { +func (r *Repo) HeadTree(ctx context.Context) (string, error) { t, err := r.Tree(ctx, "HEAD") if err == nil { return t, nil @@ -153,20 +190,21 @@ func (r *Repo) Head(ctx context.Context) (string, error) { // rev-parse exits with 1 if HEAD names no commit yet. A broken HEAD, // for example one whose commit is missing, is an error. _, rerr := r.run(ctx, nil, "rev-parse", "-q", "--verify", "HEAD") - if exitedWith(rerr, 1) { + if exitedOne(rerr) { return emptyTree, nil } return "", err } -// Parent returns the tree of the first parent of the commit rev. If rev has +// ParentTree returns the tree of the first parent of the commit rev. If rev has // no parent, it returns the empty tree. -func (r *Repo) Parent(ctx context.Context, rev string) (string, error) { +func (r *Repo) ParentTree(ctx context.Context, rev string) (string, error) { c, err := r.Resolve(ctx, rev+"^{commit}") if err != nil { return "", err } - out, err := r.run(ctx, nil, "rev-parse", c+"^@") // the parents, one on each line + // The output has the parents, one on each line. + out, err := r.run(ctx, nil, "rev-parse", c+"^@") if err != nil { return "", err } @@ -177,36 +215,56 @@ func (r *Repo) Parent(ctx context.Context, rev string) (string, error) { return r.Tree(ctx, first) } -// MergeBase returns the tree of the best common ancestor of base and HEAD. -func (r *Repo) MergeBase(ctx context.Context, base string) (string, error) { +// MergeBaseTree returns the tree of the commit that [Repo.MergeBaseCommit] +// returns. +func (r *Repo) MergeBaseTree(ctx context.Context, base string) (string, error) { + c, err := r.MergeBaseCommit(ctx, base) + if err != nil { + return "", err + } + return r.Tree(ctx, c) +} + +// MergeBaseCommit returns the best common ancestor commit of base and +// HEAD, as git merge-base finds it. If git exits with an error, the error +// wraps the [*exec.ExitError] of git. +func (r *Repo) MergeBaseCommit(ctx context.Context, base string) (string, error) { out, err := r.run(ctx, nil, "merge-base", "--end-of-options", base, "HEAD") if err != nil { - return "", fmt.Errorf("no merge base with %s", base) + return "", fmt.Errorf("no merge base with %s: %w", base, err) } - return r.Tree(ctx, strings.TrimSpace(out)) + return strings.TrimSpace(out), nil } // Snapshot writes the working tree as a tree object and returns its ID. -// The tree has the tracked and the untracked files. It does not have -// ignored files, or files that git cannot add, for example a git repository -// with no commit. Snapshot uses a temporary index, so the real -// index does not change. +// The tree has the tracked and the untracked files. It does not have the +// ignored files. It also does not have the files that git cannot add, for +// example a nested repository with no commit. Snapshot uses a private +// index, so the real index does not change. The private index keeps the +// file status of the last Snapshot, also of the untracked files, so git +// reads only the files that change. [Repo.Close] removes it. One Snapshot +// runs at a time. If ctx ends while Snapshot waits for another, +// Snapshot returns the cause of ctx. func (r *Repo) Snapshot(ctx context.Context) (string, error) { - // git does not read an empty index file, but it reads a missing one - // as empty. A new repository has no index. - dir, err := os.MkdirTemp("", "rexdiff-index-*") + select { + case r.snapLock <- struct{}{}: + case <-ctx.Done(): + return "", context.Cause(ctx) + } + defer func() { <-r.snapLock }() + if r.closed { + return "", errClosed + } + env, err := r.privateIndex() if err != nil { - return "", err + return "", fmt.Errorf("snapshot: %w", err) } - defer os.RemoveAll(dir) - idx := filepath.Join(dir, "index") - if err := r.copyIndex(idx); err != nil { - return "", err + if err := r.dropIgnored(ctx, env); err != nil { + return "", fmt.Errorf("snapshot: %w", err) } - env := []string{"GIT_INDEX_FILE=" + idx} _, err = r.run(ctx, env, "add", "-A", "--ignore-errors") - if exitedWith(err, 1) { - err = nil // git could not add some files, but it added the others + if exitedOne(err) { + err = nil // git cannot add some files, but it adds the others } if err != nil { return "", fmt.Errorf("snapshot: %w", err) @@ -218,15 +276,77 @@ func (r *Repo) Snapshot(ctx context.Context) (string, error) { return strings.TrimSpace(out), nil } +// privateIndex returns the environment that points git at the private +// index of Snapshot. It copies the real index when the real index changes, +// for example after a commit or a "git add -f". A clean of the temporary +// directory makes a new directory. The caller must hold snapLock. +func (r *Repo) privateIndex() ([]string, error) { + seed := "" + if st, err := os.Stat(r.index); err == nil { + seed = fmt.Sprint(st.Size(), st.ModTime().UnixNano()) + } + _, err := os.Stat(r.snapDir) + if r.snapDir == "" || err != nil || seed != r.snapSeed { + if r.snapDir != "" { + _ = os.RemoveAll(r.snapDir) + } + dir, err := os.MkdirTemp(proc.Getenv(r.env, "TMPDIR"), "rexdiff-index-*") + if err != nil { + return nil, err + } + if err := r.copyIndex(filepath.Join(dir, "index")); err != nil { + _ = os.RemoveAll(dir) + return nil, err + } + r.snapDir, r.snapSeed = dir, seed + } + return []string{"GIT_INDEX_FILE=" + filepath.Join(r.snapDir, "index")}, nil +} + +// dropIgnored removes from the private index of env the files that git +// ignores now and that the real index does not have. "add -A" keeps such +// files, for example after an edit of a .gitignore file. +func (r *Repo) dropIgnored(ctx context.Context, env []string) error { + ignored := []string{"ls-files", "-z", "-c", "-i", "--exclude-standard"} + out, err := r.run(ctx, env, ignored...) + if err != nil || out == "" { + return err + } + tracked, err := r.run(ctx, nil, ignored...) // force-added files stay + if err != nil { + return err + } + keep := map[string]bool{} + for p := range strings.SplitSeq(tracked, "\x00") { + keep[p] = true + } + var drop strings.Builder + for p := range strings.SplitSeq(strings.TrimSuffix(out, "\x00"), "\x00") { + if !keep[p] { + drop.WriteString(p) + drop.WriteString("\x00") + } + } + if drop.Len() == 0 { + return nil + } + _, err = r.runInput(ctx, env, strings.NewReader(drop.String()), + "rm", "--cached", "-q", "--ignore-unmatch", "--pathspec-from-file=-", "--pathspec-file-nul") + return err +} + // copyIndex copies the real index to dst. If there is no real index, it -// does nothing. With the copy, "add -A" hashes only the changed files. -// The copy keeps the modification time of the index. git uses this time -// to find files that changed in the same second as the index. +// does nothing. The copy keeps the modification time of the index. git +// uses this time to find files that changed in the same second as the +// index. func (r *Repo) copyIndex(dst string) error { st, err := os.Stat(r.index) - if err != nil { + if errors.Is(err, fs.ErrNotExist) { return nil // a new repository has no index } + if err != nil { + return err + } b, err := os.ReadFile(r.index) if err != nil { return err @@ -237,20 +357,20 @@ func (r *Repo) copyIndex(dst string) error { return os.Chtimes(dst, time.Time{}, st.ModTime()) } -// Status is the type of change to a file. Its value is the letter that git +// FileStatus is the type of change to a file. Its value is the letter that git // uses, for example 'A'. -type Status byte +type FileStatus byte -// The values of Status that [Repo.Diff] returns. +// The values of FileStatus that [Repo.Diff] returns. const ( - StatusAdded Status = 'A' - StatusModified Status = 'M' - StatusDeleted Status = 'D' - StatusRenamed Status = 'R' + StatusAdded FileStatus = 'A' + StatusModified FileStatus = 'M' + StatusDeleted FileStatus = 'D' + StatusRenamed FileStatus = 'R' // statusTypeChange is a change of type, for example from a file to a // symbolic link. [Repo.Diff] shows it as [StatusModified]. - statusTypeChange Status = 'T' + statusTypeChange FileStatus = 'T' ) // File is a file that is different between two trees. Old and New are the @@ -260,13 +380,13 @@ const ( type File struct { Path string From string // for a rename, the old path - Status Status + Status FileStatus Old, New string - // Binary is true if a version of the file is binary. - Binary bool - // TooBig is true if a version of the file is larger than 2 MiB or has - // more than 50,000 lines. - TooBig bool + // Skip is ErrBinary if a version of the file has a NUL byte. It is + // ErrTooBig if the two versions together are too big. Diff does not + // read a version larger than 2,000,000 bytes. Thus that version gives + // ErrTooBig, also if it is binary. Else Skip is nil. + Skip error } // gitlink is the mode of a submodule in a tree. @@ -285,14 +405,14 @@ func (r *Repo) Diff(ctx context.Context, from, to string) ([]File, error) { // then its path. A rename has its old and new paths. fields := strings.Split(strings.TrimSuffix(out, "\x00"), "\x00") var files []File - var ids []string // old and new object of each file, or "" for none + var ids []string // the old and the new object of each file, or "" for none var blobs []blob // the content of each ID for len(fields) >= 2 { meta := strings.Fields(strings.TrimPrefix(fields[0], ":")) if len(meta) != 5 { return nil, fmt.Errorf("git diff: bad output %q", fields[0]) } - f := File{Status: Status(meta[4][0]), Path: fields[1]} + f := File{Status: FileStatus(meta[4][0]), Path: fields[1]} if f.Status == statusTypeChange { f.Status = StatusModified } @@ -313,6 +433,12 @@ func (r *Repo) Diff(ctx context.Context, from, to string) ([]File, error) { for i := range files { f := &files[i] f.Old, f.New = f.use(blobs[2*i]), f.use(blobs[2*i+1]) + if f.Skip == nil && isTooBig(f.Old, f.New) { + f.Skip = ErrTooBig + } + if f.Skip != nil { + f.Old, f.New = "", "" + } } slices.SortFunc(files, func(a, b File) int { return strings.Compare(a.Path, b.Path) }) return files, nil @@ -331,10 +457,12 @@ func diffSide(mode, id string) (string, blob) { return id, blob{} } -// use returns the content of b, and marks f if b is binary or too big. +// use returns the content of b. If b is binary or too big, use sets +// f.Skip. A binary side wins over a side that is too big. func (f *File) use(b blob) string { - f.Binary = f.Binary || errors.Is(b.err, ErrBinary) - f.TooBig = f.TooBig || errors.Is(b.err, ErrTooBig) + if f.Skip == nil || errors.Is(b.err, ErrBinary) { + f.Skip = cmp.Or(b.err, f.Skip) + } return b.data } @@ -354,28 +482,28 @@ func (r *Repo) readBlobs(ctx context.Context, ids []string, blobs []blob) error for i, id := range ids { if id != "" { batch = append(batch, i) - in.WriteString(id + "\n") + in.WriteString(id) + in.WriteString("\n") } } if len(batch) == 0 { return nil } - cmd := exec.CommandContext(ctx, "git", "cat-file", "--batch") - cmd.Dir = r.root + cmd, stderr := r.command(ctx, nil, "git", "cat-file", "--batch") cmd.Stdin = strings.NewReader(in.String()) out, err := cmd.StdoutPipe() if err != nil { return err } if err := cmd.Start(); err != nil { - return fmt.Errorf("git cat-file: %w", err) + return fmt.Errorf("git cat-file: %w", proc.Error(ctx, "git", err, nil)) } err = readBatch(bufio.NewReader(out), batch, blobs) if err != nil { - cmd.Process.Kill() + _ = cmd.Process.Kill() } - if werr := cmd.Wait(); err == nil { - err = werr + if werr := cmd.Wait(); err == nil && werr != nil { + err = proc.Error(ctx, "git", werr, stderr.Bytes()) } if err != nil { return fmt.Errorf("git cat-file: %w", err) @@ -383,12 +511,14 @@ func (r *Repo) readBlobs(ctx context.Context, ids []string, blobs []blob) error return nil } +// errShort is the error of output that ends too soon. +var errShort = errors.New("short output") + // readBatch reads the output of "git cat-file --batch" into blobs[i] for // each i in batch. For each object, the output has // " \n\n", or one line that ends with "missing" // or "ambiguous". func readBatch(r *bufio.Reader, batch []int, blobs []blob) error { - errShort := errors.New("short output") for _, i := range batch { hdr, err := r.ReadString('\n') if err != nil { @@ -415,45 +545,51 @@ func readBatch(r *bufio.Reader, batch []int, blobs []blob) error { if _, err := io.ReadFull(r, b); err != nil { return errShort } - blobs[i].data, blobs[i].err = checkBlob(string(b[:n])) + if s := string(b[:n]); isBinary(s) { + blobs[i].err = ErrBinary + } else { + blobs[i].data = s + } } return nil } -// checkBlob returns s, or an error if s is binary or has too many lines. -// The caller checks the size. -func checkBlob(s string) (string, error) { - switch { - case isBinary(s): - return "", ErrBinary - case strings.Count(s, "\n") > maxLines: - return "", ErrTooBig +// isBinary reports whether s has a NUL byte. +func isBinary(s string) bool { return strings.Contains(s, "\x00") } + +// isTooBig reports whether the versions together are larger than +// maxFileSize bytes or have more than maxLines lines. A last line without +// a newline counts as a line, as in diff.Lines. +func isTooBig(versions ...string) bool { + size, lines := 0, 0 + for _, s := range versions { + size += len(s) + lines += strings.Count(s, "\n") + if s != "" && !strings.HasSuffix(s, "\n") { + lines++ + } } - return s, nil -} - -// isBinary reports whether s has a NUL byte near its start, as git does. -func isBinary(s string) bool { - return strings.IndexByte(s[:min(len(s), binaryCheckLen)], 0) >= 0 + return size > maxFileSize || lines > maxLines } // ReadFile reads the file at path in the working tree. The path is relative // to the root. ReadFile reads only a regular file in the root, also through -// a symbolic link. If the file is binary or too big, ReadFile returns -// ErrBinary or ErrTooBig. +// a symbolic link. For another file, for example a directory or a named +// pipe, it returns an error. If the file is binary or too big, ReadFile +// returns ErrBinary or ErrTooBig. func (r *Repo) ReadFile(path string) (string, error) { root, err := os.OpenRoot(r.root) if err != nil { return "", err } - defer root.Close() + defer func() { _ = root.Close() }() st, err := root.Stat(path) if err != nil { return "", err } // Do not read a device or a named pipe. The read can block or not end. if !st.Mode().IsRegular() { - return "", fmt.Errorf("%s is not a regular file", path) + return "", fmt.Errorf("%q is not a regular file", path) } if st.Size() > maxFileSize { return "", ErrTooBig @@ -462,18 +598,44 @@ func (r *Repo) ReadFile(path string) (string, error) { if err != nil { return "", err } - if isBinary(string(b)) { + switch s := string(b); { + case isBinary(s): return "", ErrBinary + case isTooBig(s): + return "", ErrTooBig + default: + return s, nil } - return string(b), nil } // Contains reports whether dir is the root or a directory below it. It // follows symbolic links, so /tmp and /private/tmp on macOS are the same. +// Contains does not run git, thus it does not see a nested repository. func (r *Repo) Contains(dir string) bool { - root := evalSymlinks(r.root) dir = evalSymlinks(dir) - return dir == root || strings.HasPrefix(dir, root+string(filepath.Separator)) + return dir == r.root || strings.HasPrefix(dir, r.root+string(filepath.Separator)) +} + +// Owns reports whether dir is in the working tree of r, and not in another +// repository or worktree below the root. As in herdr-reviewr, a relative +// dir is not in it. Owns runs git only if [Repo.Contains] reports true. +// An error means that git cannot run, and the answer is not known. +func (r *Repo) Owns(ctx context.Context, dir string) (bool, error) { + if !filepath.IsAbs(dir) || !r.Contains(dir) { + return false, nil + } + cmd, stderr := r.command(ctx, nil, "git", "-C", dir, "rev-parse", "--show-toplevel") + out, err := output(ctx, cmd, stderr) + // A git that a signal stops, for example at the end of ctx, does not + // answer. + if ee, ok := errors.AsType[*exec.ExitError](err); ok && ee.Exited() { + return false, nil // dir is in no working tree + } + if err != nil { + return false, fmt.Errorf("git rev-parse: %w", err) + } + top := strings.TrimSuffix(out, "\n") + return top != "" && evalSymlinks(top) == r.root, nil } // evalSymlinks returns p with its symbolic links resolved, or p if that diff --git a/vcs/repo_test.go b/vcs/repo_test.go index 070e417..b98804d 100644 --- a/vcs/repo_test.go +++ b/vcs/repo_test.go @@ -1,74 +1,97 @@ package vcs_test import ( + "context" "errors" + "fmt" "os" "os/exec" "path/filepath" - "reflect" + "slices" "strings" "testing" + "testing/fstest" "time" + "github.com/rednafi/rexdiff/internal/proc" + "github.com/rednafi/rexdiff/internal/testgit" "github.com/rednafi/rexdiff/vcs" ) -// newRepo makes a repository with two commits: "init" on main and "b" on -// the branch feat. The working tree has uncommitted changes, an untracked -// file, and an ignored file. -func newRepo(t *testing.T) (*vcs.Repo, string) { - t.Helper() - dir := filepath.Join(t.TempDir(), "a repo ") // git keeps the space - if err := os.Mkdir(dir, 0o755); err != nil { - t.Fatal(err) - } - run := func(args ...string) { git(t, dir, args...) } - run("init", "-q", "-b", "main") - write(t, dir, "a.txt", "one\n") +// testRepo is the repository of newRepo. It has two commits: "init" on +// main and "b" on the branch feat. The working tree has uncommitted +// changes, an untracked file, and an ignored file. +var testRepo = testgit.NewFixture(func(tb testing.TB, dir string) { + tb.Helper() + testgit.InitRepo(tb, dir) + run := func(args ...string) { + tb.Helper() + testgit.Git(tb, dir, args...) + } + testgit.WriteFile(tb, dir, "a.txt", "one\n") run("add", ".") run("commit", "-qm", "init") run("checkout", "-qb", "feat") - write(t, dir, "b.txt", "bee\n") + testgit.WriteFile(tb, dir, "b.txt", "bee\n") run("add", ".") run("commit", "-qm", "b") - write(t, dir, "a.txt", "one\ntwo\n") - write(t, dir, "new.txt", "untracked\n") - write(t, dir, ".gitignore", "ignored.txt\nbuild/\n") - write(t, dir, "ignored.txt", "x\n") - write(t, dir, "build/out", "x\n") + testgit.WriteFile(tb, dir, "a.txt", "one\ntwo\n") + testgit.WriteFile(tb, dir, "new.txt", "untracked\n") + testgit.WriteFile(tb, dir, ".gitignore", "ignored.txt\nbuild/\n") + testgit.WriteFile(tb, dir, "ignored.txt", "x\n") + testgit.WriteFile(tb, dir, "build/out", "x\n") +}) + +// newRepo copies testRepo to a new directory, and returns the repository +// and its directory. The repository has the environment of +// [testgit.ToolEnv]. +func newRepo(t *testing.T) (*vcs.Repo, string) { + t.Helper() + return newRepoEnv(t, testgit.ToolEnv(t)) +} + +// newRepoEnv is like newRepo, but the repository has the environment env. +func newRepoEnv(t *testing.T, env []string) (*vcs.Repo, string) { + t.Helper() + dir := filepath.Join(t.TempDir(), "a repo ") // git keeps the space + testRepo.Copy(t, dir) // Open from a subdirectory. Open must find the root. - r, err := vcs.Open(t.Context(), filepath.Join(dir, "build")) + return openEnv(t, filepath.Join(dir, "build"), env), dir +} + +// open opens the repository at dir with the environment of +// [testgit.ToolEnv], and closes it at the end of the test. +func open(t *testing.T, dir string) *vcs.Repo { + t.Helper() + return openEnv(t, dir, testgit.ToolEnv(t)) +} + +// openEnv is like open, but the repository has the environment env. +func openEnv(t *testing.T, dir string, env []string) *vcs.Repo { + t.Helper() + r, err := vcs.Open(t.Context(), env, dir) if err != nil { t.Fatal(err) } - return r, dir + t.Cleanup(func() { _ = r.Close() }) + return r } -// git runs git in dir and fails the test if git fails. -func git(t *testing.T, dir string, args ...string) { +// gitLink returns an environment with a PATH of only a new directory, and +// the link to git in that directory. A test removes the link to take git +// away from a repository that is open. +func gitLink(t *testing.T) (env []string, link string) { t.Helper() - cmd := exec.Command("git", args...) - cmd.Dir = dir - cmd.Env = append(os.Environ(), - "GIT_AUTHOR_NAME=t", - "GIT_AUTHOR_EMAIL=t@t", - "GIT_COMMITTER_NAME=t", - "GIT_COMMITTER_EMAIL=t@t", - ) - if out, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("git %v: %s", args, out) - } + bin := t.TempDir() + testgit.LinkGit(t, bin) + return append(testgit.Env(), "PATH="+bin), filepath.Join(bin, "git") } -// write writes s to the file name in dir, and makes its parent directories. -func write(t *testing.T, dir, name, s string) { +// removeGit removes the link to git of gitLink. +func removeGit(t *testing.T, link string) { t.Helper() - p := filepath.Join(dir, name) - if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(p, []byte(s), 0o644); err != nil { + if err := os.Remove(link); err != nil { t.Fatal(err) } } @@ -86,7 +109,7 @@ func snapshot(t *testing.T, r *vcs.Repo) string { // head returns the tree of HEAD. func head(t *testing.T, r *vcs.Repo) string { t.Helper() - tree, err := r.Head(t.Context()) + tree, err := r.HeadTree(t.Context()) if err != nil { t.Fatal(err) } @@ -96,7 +119,7 @@ func head(t *testing.T, r *vcs.Repo) string { // parent returns the tree of the parent of rev. func parent(t *testing.T, r *vcs.Repo, rev string) string { t.Helper() - tree, err := r.Parent(t.Context(), rev) + tree, err := r.ParentTree(t.Context(), rev) if err != nil { t.Fatal(err) } @@ -123,71 +146,95 @@ func names(fs []vcs.File) string { return strings.Join(n, " ") } +// uncommitted returns the names of the files that differ from HEAD to the +// working tree. +func uncommitted(t *testing.T, r *vcs.Repo) string { + t.Helper() + return names(changes(t, r, head(t, r), snapshot(t, r))) +} + +// paths returns the path of each entry. An ignored path starts with "!". +func paths(es []vcs.Entry) []string { + var out []string + for _, e := range es { + if e.Ignored { + out = append(out, "!"+e.Path) + } else { + out = append(out, e.Path) + } + } + return out +} + // TestScopes checks the trees that each scope of a review compares. func TestScopes(t *testing.T) { + t.Parallel() r, dir := newRepo(t) ctx := t.Context() now := snapshot(t, r) unc := changes(t, r, head(t, r), now) if got := names(unc); got != "A.gitignore Ma.txt Anew.txt" { - t.Errorf("uncommitted: %s", got) + t.Fatalf("uncommitted: %s, want A.gitignore Ma.txt Anew.txt", got) } if unc[1].Old != "one\n" || unc[1].New != "one\ntwo\n" { - t.Errorf("a.txt: %q -> %q", unc[1].Old, unc[1].New) + t.Errorf("a.txt: Old %q, New %q, want %q, %q", + unc[1].Old, unc[1].New, "one\n", "one\ntwo\n") } - mb, err := r.MergeBase(ctx, "main") + mb, err := r.MergeBaseTree(ctx, "main") if err != nil { t.Fatal(err) } if got := names(changes(t, r, mb, now)); got != "A.gitignore Ma.txt Ab.txt Anew.txt" { - t.Errorf("branch: %s", got) + t.Errorf("branch: %s, want A.gitignore Ma.txt Ab.txt Anew.txt", got) } - if _, err := r.MergeBase(ctx, "nosuchbranch"); err == nil { - t.Error("MergeBase(nosuchbranch): no error") + _, err = r.MergeBaseTree(ctx, "nosuchbranch") + if _, ok := errors.AsType[*exec.ExitError](err); !ok { + t.Errorf("MergeBaseTree(nosuchbranch) = %v, want the error of git", err) } - // A turn: a snapshot, then the agent edits one file. - write(t, dir, "new.txt", "untracked\nmore\n") + // In a turn, rexdiff takes a snapshot and then the agent edits a file. + testgit.WriteFile(t, dir, "new.txt", "untracked\nmore\n") tf := changes(t, r, now, snapshot(t, r)) if got := names(tf); got != "Mnew.txt" || tf[0].New != "untracked\nmore\n" { - t.Errorf("turn: %s", got) + t.Errorf("turn: %s, want Mnew.txt with the new line", got) } // Snapshot must not change the real index. - st, _ := exec.Command("git", "-C", dir, "status", "--porcelain").Output() - if !strings.Contains(string(st), "?? new.txt") { - t.Errorf("the index changed: %s", st) + st := testgit.Git(t, dir, "status", "--porcelain") + if !strings.Contains(st, "?? new.txt") { + t.Errorf("git status after Snapshot = %q, want ?? new.txt", st) } - cs, err := r.Commits(ctx, 5) + cs, err := r.Commits(ctx, "") if err != nil || len(cs) != 2 || cs[0].Subject != "b" || cs[1].Subject != "init" { - t.Fatalf("Commits = %+v, %v", cs, err) + t.Fatalf("Commits = %+v, %v, want b and init", cs, err) } tree, err := r.Tree(ctx, cs[0].SHA) if err != nil { t.Fatal(err) } if got := names(changes(t, r, parent(t, r, cs[0].SHA), tree)); got != "Ab.txt" { - t.Errorf("commit b: %s", got) + t.Errorf("commit b: %s, want Ab.txt", got) } - // The ID of the tree that has no files. + // emptyTree is the ID of the tree that has no files. const emptyTree = "4b825dc642cb6eb9a060e54bf8d69288fbee4904" if got := parent(t, r, cs[1].SHA); got != emptyTree { t.Errorf("parent of the root commit = %s, want the empty tree", got) } } -// TestMissingCommit checks that Head and Parent fail when a commit object -// is missing. The empty tree is not correct, because it shows every file -// as added. +// TestMissingCommit checks that HeadTree and ParentTree fail when a commit +// object is missing. func TestMissingCommit(t *testing.T) { + t.Parallel() + // The empty tree is not a correct result. It shows every file as added. r, dir := newRepo(t) ctx := t.Context() - cs, err := r.Commits(ctx, 2) + cs, err := r.Commits(ctx, "") if err != nil || len(cs) != 2 { - t.Fatalf("Commits = %+v, %v", cs, err) + t.Fatalf("Commits = %+v, %v, want two commits", cs, err) } remove := func(sha string) { t.Helper() @@ -197,52 +244,121 @@ func TestMissingCommit(t *testing.T) { } } remove(cs[1].SHA) - if id, err := r.Parent(ctx, "HEAD"); err == nil { - t.Errorf("Parent(HEAD) = %s with no parent commit, want an error", id) + if id, err := r.ParentTree(ctx, "HEAD"); err == nil { + t.Errorf("ParentTree(HEAD) = %s with no parent commit, want an error", id) } - if id, err := r.Parent(ctx, "nope"); err == nil { - t.Errorf("Parent(nope) = %s, want an error", id) + if id, err := r.ParentTree(ctx, "nope"); err == nil { + t.Errorf("ParentTree(nope) = %s, want an error", id) } remove(cs[0].SHA) - if id, err := r.Head(ctx); err == nil { - t.Errorf("Head = %s with no HEAD commit, want an error", id) + if id, err := r.HeadTree(ctx); err == nil { + t.Errorf("HeadTree = %s with no HEAD commit, want an error", id) } } // TestDiffSkipsBinaryAndBigFiles checks that Diff and ReadFile mark binary -// and big files, and do not read their content. +// and big files, and do not read their content. The limits are the same as +// in herdr-reviewr. Diff adds the sizes of the two versions. func TestDiffSkipsBinaryAndBigFiles(t *testing.T) { + t.Parallel() r, dir := newRepo(t) - write(t, dir, "bin", "a\x00b") - write(t, dir, "big", strings.Repeat("x\n", 50_001)) - write(t, dir, "huge", strings.Repeat("x", 2<<20+1)) // over 2 MiB - write(t, dir, "small", "after\n") // read after the skipped content + half := strings.Repeat("x", 1_100_000) + "\n" // two make 2,200,002 bytes + lines := strings.Repeat("x\n", 30_000) // two make 60,000 lines + // Two versions with 25,000 lines each are not too big. + limit := strings.Repeat("y\n", 25_000) + testgit.WriteFile(t, dir, "bytes", half) + testgit.WriteFile(t, dir, "lines", lines) + testgit.WriteFile(t, dir, "limit", limit) + testgit.Git(t, dir, "add", ".") + testgit.Git(t, dir, "commit", "-qm", "base") + testgit.WriteFile(t, dir, "bytes", "y"+half) + testgit.WriteFile(t, dir, "lines", "y"+lines) + testgit.WriteFile(t, dir, "limit", "z"+limit) + err := os.CopyFS(dir, fstest.MapFS{ + "bin": {Data: []byte("a\x00b")}, + // The NUL of "late" is at byte 9000. + "late": {Data: []byte(strings.Repeat("x", 9000) + "\x00")}, + "big": {Data: []byte(strings.Repeat("x\n", 50_001))}, + "huge": {Data: []byte(strings.Repeat("x", 2_000_001))}, + // Diff reads this file after the skipped content. + "small": {Data: []byte("after\n")}, + }) + if err != nil { + t.Fatal(err) + } got := map[string]vcs.File{} for _, f := range changes(t, r, head(t, r), snapshot(t, r)) { got[f.Path] = f } - if f := got["bin"]; !f.Binary || f.New != "" { - t.Errorf("bin: %+v", f) + for _, p := range []string{"bin", "late"} { + if f := got[p]; !errors.Is(f.Skip, vcs.ErrBinary) || f.New != "" { + t.Errorf("%s: %+v, want Skip ErrBinary with no New", p, f) + } } - for _, p := range []string{"big", "huge"} { - if f := got[p]; !f.TooBig || f.New != "" { - t.Errorf("%s: TooBig = %v, New has %d bytes", p, f.TooBig, len(f.New)) + for _, p := range []string{"bytes", "lines", "big", "huge"} { + if f := got[p]; !errors.Is(f.Skip, vcs.ErrTooBig) || f.Old != "" || f.New != "" { + t.Errorf("%s: Skip = %v, Old has %d bytes, New has %d bytes, want ErrTooBig, 0, 0", + p, f.Skip, len(f.Old), len(f.New)) } } + if f := got["limit"]; f.Skip != nil || f.New != "z"+limit { + t.Errorf("limit: Skip = %v, New has %d bytes, want nil, %d", + f.Skip, len(f.New), len(limit)+1) + } if f := got["small"]; f.New != "after\n" { t.Errorf("small: New = %q, want %q", f.New, "after\n") } - if _, err := r.ReadFile("bin"); !errors.Is(err, vcs.ErrBinary) { - t.Errorf("ReadFile(bin): err = %v, want ErrBinary", err) + for p, want := range map[string]error{ + "bin": vcs.ErrBinary, + "late": vcs.ErrBinary, + "big": vcs.ErrTooBig, + "huge": vcs.ErrTooBig, + "bytes": nil, + } { + if _, err := r.ReadFile(p); !errors.Is(err, want) { + t.Errorf("ReadFile(%s): err = %v, want %v", p, err, want) + } + } +} + +// TestFilesInConflict checks that Files lists a path in a merge conflict +// once. +func TestFilesInConflict(t *testing.T) { + t.Parallel() + dir := t.TempDir() + testgit.InitRepo(t, dir) + run := func(args ...string) { + t.Helper() + testgit.Git(t, dir, args...) } - if _, err := r.ReadFile("huge"); !errors.Is(err, vcs.ErrTooBig) { - t.Errorf("ReadFile(huge): err = %v, want ErrTooBig", err) + testgit.WriteFile(t, dir, "f.txt", "base\n") + run("add", ".") + run("commit", "-qm", "base") + run("checkout", "-qb", "other") + testgit.WriteFile(t, dir, "f.txt", "other\n") + run("commit", "-qam", "other") + run("checkout", "-q", "main") + testgit.WriteFile(t, dir, "f.txt", "main\n") + run("commit", "-qam", "main") + cmd := proc.Command(t.Context(), testgit.ToolEnv(t), "git", "merge", "other") + cmd.Dir = dir + if err := cmd.Run(); err == nil { + t.Fatal("git merge other: no error, want a conflict") + } + r := open(t, dir) + es, err := r.Files(t.Context()) + if err != nil { + t.Fatal(err) + } + if got, want := paths(es), []string{"f.txt"}; !slices.Equal(got, want) { + t.Errorf("Files = %q, want %q", got, want) } } // TestWorktree checks Files, Grep, ReadFile, and Contains in the working // tree. func TestWorktree(t *testing.T) { + t.Parallel() r, dir := newRepo(t) ctx := t.Context() @@ -250,38 +366,41 @@ func TestWorktree(t *testing.T) { if err != nil { t.Fatal(err) } - var got []string - for _, e := range es { - if e.Ignored { - got = append(got, "!"+e.Path) - } else { - got = append(got, e.Path) - } - } want := ".gitignore a.txt b.txt !build/ !ignored.txt new.txt" - if got := strings.Join(got, " "); got != want { + if got := strings.Join(paths(es), " "); got != want { t.Errorf("Files = %s, want %s", got, want) } - ms, err := r.Grep(ctx, "TWO", 10) - match := vcs.Match{Path: "a.txt", Line: 2, Text: "two"} - if err != nil || len(ms) != 1 || ms[0] != match { - t.Errorf("Grep(TWO) = %+v, %v", ms, err) + testgit.WriteFile(t, dir, "build/sub/x", "x\n") + ignored := paths(r.IgnoredDir("build")) + if want := []string{"!build/out", "!build/sub/"}; !slices.Equal(ignored, want) { + t.Errorf("IgnoredDir(build) = %q, want %q", ignored, want) + } + if es := r.IgnoredDir("no such dir"); len(es) != 0 { + t.Errorf("IgnoredDir(no such dir) = %v, want none", es) } - write(t, dir, "a:é\n.txt", "x: two\n") // git quotes this name unless -z - ms, err = r.Grep(ctx, "x: two", 10) - match = vcs.Match{Path: "a:é\n.txt", Line: 1, Text: "x: two"} + + // The search has smart case. An upper case letter matches only the + // same case. A query in lower case ignores case. + if ms, _, err := r.Grep(ctx, "TWO", 10, time.Minute); err != nil || len(ms) != 0 { + t.Errorf("Grep(TWO) = %+v, %v, want none, nil", ms, err) + } + // git quotes this name unless -z. + testgit.WriteFile(t, dir, "a:é\n.txt", "x: Two\n") + ms, _, err := r.Grep(ctx, "x: two", 10, time.Minute) + match := vcs.Match{Path: "a:é\n.txt", Line: 1, Text: "x: Two"} if err != nil || len(ms) != 1 || ms[0] != match { - t.Errorf("Grep(x: two) = %+v, %v", ms, err) + t.Errorf("Grep(x: two) = %+v, %v, want [%+v], nil", ms, err, match) } - if ms, err := r.Grep(ctx, "nothing has this", 10); err != nil || len(ms) != 0 { - t.Errorf("Grep with no match = %+v, %v", ms, err) + if ms, _, err := r.Grep(ctx, "nothing has this", 10, time.Minute); err != nil || len(ms) != 0 { + t.Errorf("Grep with no match = %+v, %v, want none, nil", ms, err) } if s, err := r.ReadFile("a.txt"); err != nil || s != "one\ntwo\n" { - t.Errorf("ReadFile(a.txt) = %q, %v", s, err) + t.Errorf("ReadFile(a.txt) = %q, %v, want %q, nil", + s, err, "one\ntwo\n") } - contains := []struct { + tests := []struct { path string want bool }{ @@ -289,37 +408,107 @@ func TestWorktree(t *testing.T) { {filepath.Dir(dir), false}, {dir + "x", false}, // a sibling with the same prefix } - for _, tt := range contains { + for _, tt := range tests { if got := r.Contains(tt.path); got != tt.want { t.Errorf("Contains(%q) = %v, want %v", tt.path, got, tt.want) } } } -// TestRefs checks Branches, DefaultBase, and Resolve. +// TestOwns checks which directories are in the working tree, as in +// herdr-reviewr. A subdirectory is in it. A nested repository, a relative +// path, and an outside path are not. +func TestOwns(t *testing.T) { + t.Parallel() + r, dir := newRepo(t) + nested := filepath.Join(dir, "build", "nested") + testgit.InitRepo(t, nested) + tests := []struct { + dir string + want bool + }{ + {dir, true}, + {filepath.Join(dir, "build"), true}, + {nested, false}, + {"build", false}, // relative + {"", false}, + {filepath.Dir(dir), false}, + {filepath.Join(dir, "gone"), false}, + } + for _, tt := range tests { + if got, err := r.Owns(t.Context(), tt.dir); got != tt.want || err != nil { + t.Errorf("Owns(%q) = %v, %v, want %v, nil", tt.dir, got, err, tt.want) + } + } +} + +// TestOwnsWithoutGit checks that Owns fails without git. No git runs for +// a path outside the root. +func TestOwnsWithoutGit(t *testing.T) { + t.Parallel() + env, link := gitLink(t) + r, dir := newRepoEnv(t, env) + removeGit(t, link) + if _, err := r.Owns(t.Context(), dir); err == nil { + t.Error("Owns without git gives no error") + } + if got, err := r.Owns(t.Context(), filepath.Dir(dir)); got || err != nil { + t.Errorf("Owns(outside) without git = %v, %v, want false, nil", got, err) + } +} + +// TestRefs checks Branch, Branches, DefaultBase, and Resolve. func TestRefs(t *testing.T) { - r, _ := newRepo(t) + t.Parallel() + r, dir := newRepo(t) ctx := t.Context() if bs, err := r.Branches(ctx); err != nil || strings.Join(bs, " ") != "feat main" { - t.Errorf("Branches = %v, %v", bs, err) + t.Errorf("Branches = %v, %v, want [feat main], nil", bs, err) } if b := r.DefaultBase(ctx); b != "main" { - t.Errorf("DefaultBase = %q", b) + t.Errorf("DefaultBase = %q, want main", b) + } + // As in herdr-reviewr, the list has only the branches of origin, and + // origin/main hides main. The HEAD of origin is not a branch. The + // branch old has the oldest commit, so it is last. + for _, remote := range []string{"origin", "up"} { + testgit.Git(t, dir, "update-ref", "refs/remotes/"+remote+"/main", "main") + testgit.Git(t, dir, "symbolic-ref", "refs/remotes/"+remote+"/HEAD", + "refs/remotes/"+remote+"/main") + } + obj := t.TempDir() + const who = "A 946684800 +0000\n" + testgit.WriteFile(t, obj, "old", "tree "+testgit.Git(t, dir, "rev-parse", "main^{tree}")+ + "author "+who+"committer "+who+"\nold\n") + old := testgit.Git(t, dir, "hash-object", "-t", "commit", "-w", filepath.Join(obj, "old")) + testgit.Git(t, dir, "update-ref", "refs/heads/old", strings.TrimSpace(old)) + want := "feat origin/main old" + if bs, err := r.Branches(ctx); err != nil || strings.Join(bs, " ") != want { + t.Errorf("Branches with remotes = %v, %v, want %s", bs, err, want) } if _, err := r.Resolve(ctx, "nosuchref"); err == nil { - t.Error("Resolve(nosuchref): no error") + t.Error("Resolve(nosuchref): no error, want an error") + } + testgit.Git(t, dir, "symbolic-ref", "refs/remotes/origin/HEAD", "refs/remotes/origin/gone") + if b := r.DefaultBase(ctx); b != "main" { + t.Errorf("DefaultBase with a dangling origin/HEAD = %q, want main", b) + } + + empty := t.TempDir() + testgit.InitRepo(t, empty) + if b, err := open(t, empty).Branch(ctx); err != nil || b != "main" { + t.Errorf("Branch with no commit = %q, %v, want main, nil", b, err) } } // TestEditor checks that Editor returns core.editor, or "" if it is not set. func TestEditor(t *testing.T) { - t.Setenv("GIT_CONFIG_GLOBAL", os.DevNull) - t.Setenv("GIT_CONFIG_NOSYSTEM", "1") + t.Parallel() r, dir := newRepo(t) if got := r.Editor(t.Context()); got != "" { t.Errorf("Editor with no core.editor = %q, want \"\"", got) } - git(t, dir, "config", "core.editor", "vim -f") + testgit.Git(t, dir, "config", "core.editor", "vim -f") if got := r.Editor(t.Context()); got != "vim -f" { t.Errorf("Editor = %q, want \"vim -f\"", got) } @@ -328,25 +517,23 @@ func TestEditor(t *testing.T) { // TestDiffRenameAndSubmodule checks the old path of a rename and the // content that Diff gives for a submodule. func TestDiffRenameAndSubmodule(t *testing.T) { + t.Parallel() dir := t.TempDir() - git(t, dir, "init", "-q") - write(t, dir, "old.txt", "one\ntwo\nthree\n") + testgit.InitRepo(t, dir) + testgit.WriteFile(t, dir, "old.txt", "one\ntwo\nthree\n") const ( a = "1111111111111111111111111111111111111111" b = "2222222222222222222222222222222222222222" ) - git(t, dir, "add", ".") - git(t, dir, "update-index", "--add", "--cacheinfo", "160000,"+a+",sub") - git(t, dir, "commit", "-qm", "one") - git(t, dir, "mv", "old.txt", "new.txt") - git(t, dir, "update-index", "--cacheinfo", "160000,"+b+",sub") - git(t, dir, "commit", "-qm", "two") + testgit.Git(t, dir, "add", ".") + testgit.Git(t, dir, "update-index", "--add", "--cacheinfo", "160000,"+a+",sub") + testgit.Git(t, dir, "commit", "-qm", "one") + testgit.Git(t, dir, "mv", "old.txt", "new.txt") + testgit.Git(t, dir, "update-index", "--cacheinfo", "160000,"+b+",sub") + testgit.Git(t, dir, "commit", "-qm", "two") ctx := t.Context() - r, err := vcs.Open(ctx, dir) - if err != nil { - t.Fatal(err) - } + r := open(t, dir) from, to := parent(t, r, "HEAD"), head(t, r) got, err := r.Diff(ctx, from, to) if err != nil { @@ -367,45 +554,75 @@ func TestDiffRenameAndSubmodule(t *testing.T) { New: "Subproject commit " + b + "\n", }, } - if !reflect.DeepEqual(got, want) { - t.Errorf("Diff:\n%+v\nwant:\n%+v", got, want) + if !slices.Equal(got, want) { + t.Errorf("Diff = \n%+v\nwant:\n%+v", got, want) } } // TestOpenOutsideARepo checks that Open fails outside a repository. func TestOpenOutsideARepo(t *testing.T) { - if _, err := vcs.Open(t.Context(), t.TempDir()); err == nil { - t.Error("Open: no error outside a repository") + t.Parallel() + if _, err := vcs.Open(t.Context(), testgit.ToolEnv(t), t.TempDir()); err == nil { + t.Error("Open outside a repository: no error, want an error") } } -// TestNewRepo checks Head and Snapshot in a repository with no commit and -// no index. +// TestNewRepo checks Open, Root, HeadTree, and Snapshot in a repository +// with no commit and no index. The path of a root can have a newline. func TestNewRepo(t *testing.T) { - dir := t.TempDir() - git(t, dir, "init", "-q") - write(t, dir, "a.txt", "one\n") - r, err := vcs.Open(t.Context(), dir) - if err != nil { + t.Parallel() + tests := []struct{ name, root string }{ + {"plain root", "repo"}, + {"newline in root", "a\nb"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + dir := filepath.Join(t.TempDir(), tt.root) + testgit.InitRepo(t, dir) + testgit.WriteFile(t, dir, "a.txt", "one\n") + r, err := vcs.Open(t.Context(), testgit.ToolEnv(t), dir) + if err != nil { + t.Fatal(err) + } + if want, _ := filepath.EvalSymlinks(dir); r.Root() != want { + t.Errorf("Root = %q, want %q", r.Root(), want) + } + if got := uncommitted(t, r); got != "Aa.txt" { + t.Errorf("Diff(HEAD, Snapshot) = %s, want Aa.txt", got) + } + }) + } +} + +// TestSnapshotBadIndex checks that Snapshot fails if it cannot read the +// real index. Only a missing index means no index. +func TestSnapshotBadIndex(t *testing.T) { + t.Parallel() + r, dir := newRepo(t) + idx := filepath.Join(dir, ".git", "index") + if err := os.Remove(idx); err != nil { t.Fatal(err) } - if got := names(changes(t, r, head(t, r), snapshot(t, r))); got != "Aa.txt" { - t.Errorf("Diff(Head, Snapshot) = %s, want Aa.txt", got) + if err := os.Symlink("index", idx); err != nil { // a loop + t.Fatal(err) + } + if tree, err := r.Snapshot(t.Context()); err == nil { + t.Errorf("Snapshot with a bad index = %s, want an error", tree) } } -// TestSnapshotRacyEdit checks that Snapshot sees an edit that keeps the -// size of a file. The edit is in the same second as the last write of the -// index. -// -// git checks such "racily clean" files by their content. It finds them by -// the modification time of the index, so the copy of the index must keep -// that time. +// TestSnapshotRacyEdit checks that Snapshot sees a same-size edit in the +// same second as the last write of the index. func TestSnapshotRacyEdit(t *testing.T) { + t.Parallel() + // git checks such "racily clean" files by their content. It finds them by + // the modification time of the index. The copy of the index must keep + // that time. r, dir := newRepo(t) - git(t, dir, "config", "core.trustctime", "false") - git(t, dir, "add", "-A") - git(t, dir, "commit", "-qm", "all") + testgit.Git(t, dir, "config", "core.trustctime", "false") + testgit.Git(t, dir, "add", "-A") + testgit.Git(t, dir, "commit", "-qm", "all") then := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC) touch := func(name string) { t.Helper() @@ -413,14 +630,129 @@ func TestSnapshotRacyEdit(t *testing.T) { t.Fatal(err) } } - write(t, dir, "b.txt", "bee\n") + testgit.WriteFile(t, dir, "b.txt", "bee\n") touch("b.txt") - git(t, dir, "update-index", "--really-refresh") // the entry gets the time of b.txt + // The index entry gets the time of b.txt. + testgit.Git(t, dir, "update-index", "--really-refresh") touch(".git/index") - write(t, dir, "b.txt", "BEE\n") // the same size and time + testgit.WriteFile(t, dir, "b.txt", "BEE\n") // the same size and time touch("b.txt") - if got := names(changes(t, r, head(t, r), snapshot(t, r))); got != "Mb.txt" { + if got := uncommitted(t, r); got != "Mb.txt" { t.Errorf("Diff(HEAD, Snapshot) = %s, want Mb.txt", got) } } + +// TestCommitsLikeHerdrReviewr checks the commits of the commit picker. +// They are the first-parent commits after since, else the last 50. +func TestCommitsLikeHerdrReviewr(t *testing.T) { + t.Parallel() + r, dir := newRepo(t) + ctx := t.Context() + run := func(args ...string) { + t.Helper() + testgit.Git(t, dir, args...) + } + run("checkout", "-qb", "side", "main") + run("commit", "-q", "--allow-empty", "-m", "side") + run("checkout", "-q", "feat") + run("merge", "-q", "--no-ff", "-m", "merge side", "side") + for i := range 60 { + run("commit", "-q", "--allow-empty", "-m", fmt.Sprint("c", i)) + } + subjects := func(cs []vcs.Commit) []string { + var s []string + for _, c := range cs { + s = append(s, c.Subject) + } + return s + } + + since, err := r.MergeBaseCommit(ctx, "main") + if err != nil { + t.Fatal(err) + } + cs, err := r.Commits(ctx, since) + if err != nil { + t.Fatal(err) + } + got := subjects(cs) + if len(got) != 62 || got[0] != "c59" || got[60] != "merge side" || got[61] != "b" { + t.Errorf("Commits(fork point) = %d commits %q…, want c59 to b, no side", len(got), got[:min(3, len(got))]) + } + + cs, err = r.Commits(ctx, "") + if err != nil || len(cs) != vcs.LastCommits || cs[0].Subject != "c59" { + t.Errorf("Commits(\"\") = %d commits, %v, want the last %d", len(cs), err, vcs.LastCommits) + } + + empty := t.TempDir() + testgit.InitRepo(t, empty) + er := open(t, empty) + if cs, err := er.Commits(ctx, ""); err != nil || len(cs) != 0 { + t.Errorf("Commits with no commit = %v, %v, want none, no error", cs, err) + } +} + +// TestGrepLimit checks that Grep stops at the limit and tells that there +// are more matches. +func TestGrepLimit(t *testing.T) { + t.Parallel() + r, dir := newRepo(t) + ctx := t.Context() + testgit.WriteFile(t, dir, "many.txt", strings.Repeat("hit\n", 5000)) + tests := []struct { + limit int + wantN int + wantMore bool + }{ + {10, 10, true}, + {4999, 4999, true}, + {5000, 5000, false}, + {6000, 5000, false}, + } + for _, tt := range tests { + ms, more, err := r.Grep(ctx, "hit", tt.limit, time.Minute) + if err != nil || len(ms) != tt.wantN || more != tt.wantMore { + t.Errorf("Grep(hit, %d) = %d matches, more %t, %v, want %d, %t, nil", + tt.limit, len(ms), more, err, tt.wantN, tt.wantMore) + } + } + want := vcs.Match{Path: "many.txt", Line: 2, Text: "hit"} + if ms, _, _ := r.Grep(ctx, "hit", 2, time.Minute); len(ms) != 2 || ms[1] != want { + t.Errorf("Grep(hit, 2) = %+v, want many.txt:2 second", ms) + } +} + +// TestGrepCancelled checks that Grep returns the cause of a cancelled +// context. +func TestGrepCancelled(t *testing.T) { + t.Parallel() + r, _ := newRepo(t) + ctx, cancel := context.WithCancelCause(t.Context()) + errStop := errors.New("the test stops the search") + cancel(errStop) + if _, _, err := r.Grep(ctx, "one", 10, time.Minute); !errors.Is(err, errStop) { + t.Errorf("Grep with a cancelled context = %v, want the cause %v", err, errStop) + } +} + +// TestSnapshotFollowsIndex checks that a Snapshot after an earlier one +// drops a file that git now ignores, and keeps a file that the user adds +// with "git add -f". +func TestSnapshotFollowsIndex(t *testing.T) { + t.Parallel() + r, dir := newRepo(t) + testgit.WriteFile(t, dir, "build.log", "x\n") + testgit.WriteFile(t, dir, "keep.env", "k\n") + if got := uncommitted(t, r); got != "A.gitignore Ma.txt Abuild.log Akeep.env Anew.txt" { + t.Fatalf("Diff(HEAD, Snapshot) = %s, want A.gitignore Ma.txt Abuild.log Akeep.env Anew.txt", got) + } + testgit.WriteFile(t, dir, ".gitignore", "ignored.txt\nbuild/\n*.log\n*.env\n") + testgit.Git(t, dir, "add", ".gitignore") + testgit.Git(t, dir, "add", "-f", "keep.env") + testgit.Git(t, dir, "commit", "-qm", "ignore") + if got := uncommitted(t, r); got != "Ma.txt Anew.txt" { + t.Errorf("Diff(HEAD, Snapshot) after the ignore = %s, want Ma.txt Anew.txt", got) + } +} diff --git a/vcs/saved_test.go b/vcs/saved_test.go new file mode 100644 index 0000000..251d565 --- /dev/null +++ b/vcs/saved_test.go @@ -0,0 +1,102 @@ +package vcs_test + +import ( + "path/filepath" + "strings" + "testing" + + "github.com/rednafi/rexdiff/internal/testgit" +) + +// TestSavedBase checks that SaveBase keeps a base, and that the default +// branch deletes it. +func TestSavedBase(t *testing.T) { + t.Parallel() + r, dir := newRepo(t) + ctx := t.Context() + if got := r.SavedBase(ctx); got != "" { + t.Errorf("SavedBase before a save = %q, want \"\"", got) + } + if err := r.SaveBase(ctx, "feat"); err != nil { + t.Fatal(err) + } + if got := r.SavedBase(ctx); got != "feat" { + t.Errorf("SavedBase = %q, want feat", got) + } + if err := r.SaveBase(ctx, "main"); err != nil { + t.Fatal(err) + } + if got := r.SavedBase(ctx); got != "" { + t.Errorf("SavedBase after a save of the default = %q, want \"\"", got) + } + // A value that is not a base is not read. + for _, bad := range []string{"-x", "a b", "a\x7f"} { + if err := r.SaveBase(ctx, bad); err == nil { + t.Errorf("SaveBase(%q): no error, want an error", bad) + } + } + blob := hashObject(t, dir, "--upload-pack=x") + testgit.Git(t, dir, "update-ref", "refs/worktree/rexdiff/base-pick", blob) + if got := r.SavedBase(ctx); got != "" { + t.Errorf("SavedBase of an option = %q, want \"\"", got) + } +} + +// hashObject writes s as a blob in the repository dir and returns its ID. +func hashObject(t *testing.T, dir, s string) string { + t.Helper() + tmp := t.TempDir() + testgit.WriteFile(t, tmp, "blob", s) + out := testgit.Git(t, dir, "hash-object", "-w", filepath.Join(tmp, "blob")) + return strings.TrimSpace(out) +} + +// TestSavedTurn checks that SaveTurn keeps a tree, also after git gc, and +// that "" deletes it. +func TestSavedTurn(t *testing.T) { + t.Parallel() + r, dir := newRepo(t) + ctx := t.Context() + tree := snapshot(t, r) // a tree that no commit has + if err := r.SaveTurn(ctx, tree); err != nil { + t.Fatal(err) + } + testgit.Git(t, dir, "gc", "-q", "--prune=now") + if got := r.SavedTurn(ctx); got != tree { + t.Errorf("SavedTurn = %q, want %q", got, tree) + } + if _, err := r.Diff(ctx, tree, head(t, r)); err != nil { + t.Errorf("the tree after gc: %v", err) + } + for range 2 { // a second delete is no error + if err := r.SaveTurn(ctx, ""); err != nil { + t.Fatal(err) + } + } + if got := r.SavedTurn(ctx); got != "" { + t.Errorf("SavedTurn after the delete = %q, want \"\"", got) + } +} + +// TestSavedPerWorktree checks that each worktree has its own saved base +// and turn. +func TestSavedPerWorktree(t *testing.T) { + t.Parallel() + r, dir := newRepo(t) + ctx := t.Context() + other := filepath.Join(t.TempDir(), "wt") + testgit.Git(t, dir, "worktree", "add", "-q", other, "main") + r2 := open(t, other) + if err := r.SaveBase(ctx, "feat"); err != nil { + t.Fatal(err) + } + if err := r.SaveTurn(ctx, snapshot(t, r)); err != nil { + t.Fatal(err) + } + if got := r2.SavedBase(ctx); got != "" { + t.Errorf("SavedBase in the other worktree = %q, want \"\"", got) + } + if got := r2.SavedTurn(ctx); got != "" { + t.Errorf("SavedTurn in the other worktree = %q, want \"\"", got) + } +} diff --git a/vcs/watch.go b/vcs/watch.go new file mode 100644 index 0000000..5fb2fdf --- /dev/null +++ b/vcs/watch.go @@ -0,0 +1,400 @@ +package vcs + +import ( + "context" + "encoding/binary" + "hash" + "hash/fnv" + "io/fs" + "os" + "path/filepath" + "slices" + "strings" + "syscall" + "time" +) + +// pauseFactor sets the pause after a check of a [Watcher]. The next check +// waits this many times as long as the last check took. Thus the checks +// use less than 1/21 of a core. herdr-reviewr uses the same factor. +const pauseFactor = 20 + +// Watcher reports whether the working tree or the refs can have changed. It +// compares the file status of the files that git lists, of their +// directories, and of the git files. While nothing changes, it runs no +// git process. After a change, it runs git to list the files again. +// +// A Watcher does not see a change of the global core.excludesFile. It +// also does not see a copy that keeps the size and the modification time +// of a file. +type Watcher struct { + r *Repo + gitDir, common string // absolute paths + now func() time.Time // the clock, time.Now + next time.Time // no check before this time + + started bool + since time.Time // the start of the watch + listed bool // files and dirs are from the last list + files []string // the absolute paths that git lists + dirs []string // the root and each directory above a listed path + shown []string // the absolute paths of the ignored dirs that show + + // The sums of the last check. + list, refs, index, dirSum, fileSum, shownSum uint64 +} + +// Change tells what can have changed since the last call to +// [Watcher.Changed]. +type Change struct { + Files bool // the working tree or the index + Refs bool // HEAD, a ref, or the config +} + +// Watch returns a Watcher of the repository. Its first call to +// [Watcher.Changed] reports a change of both. +func (r *Repo) Watch(ctx context.Context) (*Watcher, error) { + gitDir, err := r.gitPath(ctx, "--git-dir") + if err != nil { + return nil, err + } + common, err := r.gitPath(ctx, "--git-common-dir") + if err != nil { + return nil, err + } + w := &Watcher{r: r, gitDir: gitDir, common: common, now: time.Now, since: time.Now()} + // The list is ready at once, for LastWrite. + w.index = w.sumIndex() + if w.list, err = w.relist(ctx); err != nil { + return nil, err + } + w.dirSum = sumPaths(w.dirs) + return w, nil +} + +// Show sets the paths that show and that git does not list. These are the +// ignored directories that the user expands, and the open file. The open +// file can be ignored or a link. paths are relative to the root. Changed +// also reports an edit of a shown file or of the target of a shown link. +// It also reports a file that a shown directory gets or loses. A new set +// reports no change. +func (w *Watcher) Show(paths []string) { + abs := make([]string, len(paths)) + for i, p := range paths { + abs[i] = filepath.Join(w.r.root, filepath.FromSlash(p)) + } + if !slices.Equal(abs, w.shown) { + w.shown, w.shownSum = abs, sumPaths(abs) + } +} + +// gitPath returns the absolute path that rev-parse gives for flag. +func (r *Repo) gitPath(ctx context.Context, flag string) (string, error) { + out, err := r.run(ctx, nil, "rev-parse", "--path-format=absolute", flag) + if err != nil { + return "", err + } + return strings.TrimSuffix(out, "\n"), nil +} + +// Changed tells what can have changed since the last call. The first call +// reports a change of both. After a call that takes the time d, Changed +// reports no change for 20 times d. A change in that time shows in the +// next call after it. +func (w *Watcher) Changed(ctx context.Context) (Change, error) { + start := w.now() + if start.Before(w.next) { + return Change{}, nil + } + defer func() { + end := w.now() + w.next = end.Add(pauseFactor * end.Sub(start)) + }() + + refs, index := w.sumRefs(), w.sumIndex() + dirs := sumPaths(w.dirs) + listed := false + list := w.list + if !w.listed || dirs != w.dirSum || index != w.index { + var err error + if list, err = w.relist(ctx); err != nil { + return Change{}, err + } + dirs, listed = sumPaths(w.dirs), true + } + files, shown := sumFiles(w.files), sumPaths(w.shown) + ch := Change{ + Files: index != w.index || list != w.list || files != w.fileSum, + Refs: refs != w.refs, + } + if ch.Files && !listed { + // An edit of a .gitignore file can change the list. + var err error + if list, err = w.relist(ctx); err != nil { + return Change{}, err + } + dirs, files = sumPaths(w.dirs), sumFiles(w.files) + } + if !w.started { + w.started, ch = true, Change{Files: true, Refs: true} + } + w.list, w.refs, w.index, w.dirSum, w.fileSum = list, refs, index, dirs, files + if shown != w.shownSum { + w.shownSum, ch.Files = shown, true + } + return ch, nil +} + +// LastWrite returns the newest modification time, not after until, of the +// listed files and of their directories, as they are now. A new or a +// removed file changes the time of its directory. LastWrite returns the +// zero time if no such time is after the start of the watch. It does not +// change what the next call to Changed reports. +func (w *Watcher) LastWrite(until time.Time) time.Time { + var last time.Time + for _, p := range slices.Concat(w.files, w.dirs) { + fi, err := os.Lstat(p) + if err == nil && fi.ModTime().After(last) && !fi.ModTime().After(until) { + last = fi.ModTime() + } + } + if !last.After(w.since) { + return time.Time{} + } + return last +} + +// relist reads the list of the files and their directories again. It +// returns the sum of the list. +func (w *Watcher) relist(ctx context.Context) (uint64, error) { + w.listed = false + paths, err := w.r.lsFiles(ctx, listArgs...) + if err != nil { + return 0, err + } + untracked, err := w.r.untrackedDirs(ctx) + if err != nil { + return 0, err + } + root := w.r.root + seen := map[string]bool{root: true} + addDirs := func(p string) { + for d := p; !seen[d]; d = filepath.Dir(d) { + seen[d] = true + w.dirs = append(w.dirs, d) + } + } + w.dirs = append(w.dirs[:0], root) + w.files = w.files[:0] + s := newSum() + for _, p := range paths { + s.str(p) + // git lists a nested repository with a slash at the end. + p = filepath.Join(root, strings.TrimSuffix(p, "/")) + w.files = append(w.files, p) + addDirs(filepath.Dir(p)) + } + for _, d := range untracked { + addDirs(filepath.Join(root, d)) + } + w.listed = true + return s.Sum64(), nil +} + +// untrackedDirs returns the untracked directories, relative to the root, +// and each directory below them that git does not ignore. git lists no +// file in a directory that is empty or has only ignored files. A new file +// there changes only that directory, thus the [Watcher] must watch it. +// untrackedDirs does not go into a nested repository. +func (r *Repo) untrackedDirs(ctx context.Context) ([]string, error) { + ps, err := r.lsFiles(ctx, "-o", "--directory", "--exclude-standard") + if err != nil { + return nil, err + } + var level, dirs []string + for _, p := range ps { + if d, ok := strings.CutSuffix(p, "/"); ok { + level = append(level, d) + } + } + for len(level) > 0 { + dirs = append(dirs, level...) + var next []string + for _, d := range level { + abs := filepath.Join(r.root, filepath.FromSlash(d)) + if _, err := os.Lstat(filepath.Join(abs, ".git")); err == nil { + continue // a nested repository + } + // A directory that goes away has no directories to watch. + des, _ := os.ReadDir(abs) + for _, de := range des { + if de.IsDir() { + next = append(next, d+"/"+de.Name()) + } + } + } + if level, err = r.notIgnored(ctx, next); err != nil { + return nil, err + } + } + return dirs, nil +} + +// notIgnored returns the paths that git does not ignore. Each path gets +// the prefix "./", because git reads a path that starts with ":" as +// pathspec magic. git writes the paths with the prefix. +func (r *Repo) notIgnored(ctx context.Context, paths []string) ([]string, error) { + if len(paths) == 0 { + return nil, nil + } + in := strings.NewReader("./" + strings.Join(paths, "\x00./")) + out, err := r.runInput(ctx, nil, in, "check-ignore", "-z", "--stdin") + if exitedOne(err) { + return paths, nil // git ignores none + } + if err != nil { + return nil, err + } + ignored := map[string]bool{} + for p := range strings.SplitSeq(out, "\x00") { + ignored[p] = true + } + return slices.DeleteFunc(paths, func(p string) bool { return ignored["./"+p] }), nil +} + +// sumRefs returns the sum of the files that change with HEAD, a ref, or +// the config. +func (w *Watcher) sumRefs() uint64 { + paths := []string{ + filepath.Join(w.gitDir, "HEAD"), + filepath.Join(w.gitDir, "logs", "HEAD"), + filepath.Join(w.common, "packed-refs"), + filepath.Join(w.common, "FETCH_HEAD"), + filepath.Join(w.common, "config"), + filepath.Join(w.common, "reftable", "tables.list"), + } + // The branch of HEAD has its own file. The branches are in the common + // directory, which the worktrees of the repository share. + if b, err := os.ReadFile(paths[0]); err == nil { + ref, ok := strings.CutPrefix(strings.TrimSpace(string(b)), "ref: ") + // Localize refuses a ".." or a "/" at the start. + if p, err := filepath.Localize(ref); ok && err == nil { + paths = append(paths, filepath.Join(w.common, p)) + } + } + return sumPaths(paths) +} + +// sumIndex returns the sum of the files that change the list of files. +func (w *Watcher) sumIndex() uint64 { + return sumPaths([]string{w.r.index, filepath.Join(w.common, "info", "exclude")}) +} + +// sumPaths returns the sum of the path, the size, the modification time, +// the mode, and the inode of each file. It follows a symbolic link, so an +// edit of the target changes the sum. +func sumPaths(paths []string) uint64 { + s := newSum() + for _, p := range paths { + s.stat(p, os.Stat) + } + return s.Sum64() +} + +// sumFiles is like sumPaths. For a directory, it also adds the git files +// of the nested repository or the submodule in it. A commit there does not +// change the directory. +func sumFiles(paths []string) uint64 { + s := newSum() + for _, p := range paths { + if s.stat(p, os.Lstat) { + s.nested(p) + } + } + return s.Sum64() +} + +// sum is a 64-bit FNV-1a hash of paths and of the states of files. The +// sums stay in the process, so their values can change between versions. +// The Write of a hash never fails. +type sum struct{ hash.Hash64 } + +// newSum returns the sum of no bytes. +func newSum() sum { return sum{fnv.New64a()} } + +// str adds v and a NUL, so that two strings do not join. +func (s sum) str(v string) { _, _ = s.Write(append([]byte(v), 0)) } + +// num adds the 8 bytes of v. +func (s sum) num(v uint64) { _, _ = s.Write(binary.LittleEndian.AppendUint64(nil, v)) } + +// int adds v, for example a size or a time. +func (s sum) int(v int64) { + s.num(uint64(v)) //nolint:gosec // a hash keeps all bits, so a wrap is fine +} + +// stat adds the path and the file status that stat gives for p. A missing +// file adds a marker. stat reports whether p is a directory. +func (s sum) stat(p string, stat func(string) (fs.FileInfo, error)) (isDir bool) { + s.str(p) + fi, err := stat(p) + if err != nil { + s.num(0) + return false + } + s.int(fi.Size()) + s.int(fi.ModTime().UnixNano()) + s.num(uint64(fi.Mode())) + if st, ok := fi.Sys().(*syscall.Stat_t); ok { + s.num(st.Ino) + } + return fi.IsDir() +} + +// nested adds the git files of the repository in the directory p, if p +// has one. A commit there changes HEAD or its log, and the directory of +// the git files. +func (s sum) nested(p string) { + dotGit := filepath.Join(p, ".git") + fi, err := os.Lstat(dotGit) + if err != nil { + return + } + s.str(dotGit) + dir := dotGit + if !fi.IsDir() { + // A submodule has a file "gitdir: " in place of a directory. + b, err := os.ReadFile(dotGit) + gd, ok := strings.CutPrefix(strings.TrimSpace(string(b)), "gitdir: ") + if err != nil || !ok { + return + } + if dir = gd; !filepath.IsAbs(dir) { + dir = filepath.Join(p, dir) + } + } + for _, f := range []string{dir, filepath.Join(dir, "HEAD"), filepath.Join(dir, "logs", "HEAD")} { + fi, err := os.Lstat(f) + if err != nil { + s.num(0) + continue + } + s.int(fi.Size()) + s.int(fi.ModTime().UnixNano()) + } +} + +// lsFiles runs git ls-files with args and returns the listed paths. +func (r *Repo) lsFiles(ctx context.Context, args ...string) ([]string, error) { + out, err := r.run(ctx, nil, slices.Concat([]string{"ls-files", "-z"}, args)...) + if err != nil { + return nil, err + } + var ps []string + for p := range strings.SplitSeq(out, "\x00") { + if p != "" { + ps = append(ps, p) + } + } + return ps, nil +} diff --git a/vcs/watch_test.go b/vcs/watch_test.go new file mode 100644 index 0000000..c2fa8d5 --- /dev/null +++ b/vcs/watch_test.go @@ -0,0 +1,324 @@ +package vcs_test + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/rednafi/rexdiff/internal/testgit" + "github.com/rednafi/rexdiff/vcs" +) + +// newWatcher returns a Watcher of r after its first call. Its clock does +// not move, so no call waits for the pause. +func newWatcher(t *testing.T, r *vcs.Repo) *vcs.Watcher { + t.Helper() + w, err := r.Watch(t.Context()) + if err != nil { + t.Fatal(err) + } + w.SetClock(func() time.Time { return time.Time{} }) + if ch := changed(t, w); ch != (vcs.Change{Files: true, Refs: true}) { + t.Fatalf("first call: %+v, want both", ch) + } + return w +} + +// changed returns the change that w sees. +func changed(t *testing.T, w *vcs.Watcher) vcs.Change { + t.Helper() + ch, err := w.Changed(t.Context()) + if err != nil { + t.Fatal(err) + } + return ch +} + +// TestWatcher checks the changes that a Watcher sees and does not see. A +// change must have each part of want. No change must be none. +func TestWatcher(t *testing.T) { + t.Parallel() + files := vcs.Change{Files: true} + tests := []struct { + name string + edit func(t *testing.T, dir string) + want vcs.Change + }{ + {"nothing", func(*testing.T, string) {}, vcs.Change{}}, + {"same size, new time", func(t *testing.T, dir string) { + t.Helper() + testgit.WriteFile(t, dir, "a.txt", "one\nxyz\n") // the same size + p := filepath.Join(dir, "a.txt") + later := time.Now().Add(time.Hour) + if err := os.Chtimes(p, later, later); err != nil { + t.Fatal(err) + } + }, files}, + {"new file", func(t *testing.T, dir string) { + t.Helper() + testgit.WriteFile(t, dir, "c.txt", "c\n") + }, files}, + {"new directory", func(t *testing.T, dir string) { + t.Helper() + testgit.WriteFile(t, dir, "x/y/c.txt", "c\n") + }, files}, + {"ignored", func(t *testing.T, dir string) { + t.Helper() + testgit.WriteFile(t, dir, "build/more", "x\n") + }, vcs.Change{}}, + {"deleted", func(t *testing.T, dir string) { + t.Helper() + if err := os.Remove(filepath.Join(dir, "b.txt")); err != nil { + t.Fatal(err) + } + }, files}, + {"commit", func(t *testing.T, dir string) { + t.Helper() + testgit.Git(t, dir, "commit", "-qam", "c") + }, vcs.Change{Refs: true}}, + {"checkout", func(t *testing.T, dir string) { + t.Helper() + testgit.Git(t, dir, "checkout", "-qb", "other") + }, vcs.Change{Refs: true}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + r, dir := newRepo(t) + w := newWatcher(t, r) + tt.edit(t, dir) + got := changed(t, w) + if tt.want.Files && !got.Files || tt.want.Refs && !got.Refs || + tt.want == (vcs.Change{}) && got != tt.want { + t.Errorf("after the edit: %+v, want %+v", got, tt.want) + } + if got := changed(t, w); got != (vcs.Change{}) { + t.Errorf("second call: %+v, want none", got) + } + }) + } +} + +// TestWatcherLastWrite checks that LastWrite sees a new file after the +// start of the watch, but not after its limit, and that the change still +// shows in Changed. +func TestWatcherLastWrite(t *testing.T) { + t.Parallel() + r, dir := newRepo(t) + w := newWatcher(t, r) + if got := w.LastWrite(time.Now()); !got.IsZero() { + t.Errorf("LastWrite before a write = %v, want zero", got) + } + testgit.WriteFile(t, dir, "c.txt", "c\n") + // The clock of the file system can be behind time.Now. So the test sets + // the times of the new file and of its directory to an hour after now. + // This is clearly after the start of the watch. + later := time.Now().Add(time.Hour) + for _, p := range []string{filepath.Join(dir, "c.txt"), dir} { + if err := os.Chtimes(p, later, later); err != nil { + t.Fatal(err) + } + } + if w.LastWrite(later).IsZero() { + t.Error("LastWrite after a write is zero") + } + if got := w.LastWrite(later.Add(-time.Minute)); !got.IsZero() { + t.Errorf("LastWrite before the write = %v, want zero", got) + } + if got := changed(t, w); !got.Files { + t.Errorf("Changed after LastWrite: %+v, want Files", got) + } +} + +// TestWatcherNewDirectory checks that an edit in a new untracked directory +// is a change. +func TestWatcherNewDirectory(t *testing.T) { + t.Parallel() + r, dir := newRepo(t) + w := newWatcher(t, r) + testgit.WriteFile(t, dir, "x/c.txt", "c\n") + changed(t, w) + testgit.WriteFile(t, dir, "x/c.txt", "c\nd\n") + if got := changed(t, w); !got.Files { + t.Errorf("after an edit in x: %+v, want Files", got) + } +} + +// TestWatcherUnignore checks that a file shows after a .gitignore edit +// stops git from ignoring it. +func TestWatcherUnignore(t *testing.T) { + t.Parallel() + r, dir := newRepo(t) + w := newWatcher(t, r) + testgit.WriteFile(t, dir, "ignored.txt", "xx\n") + if got := changed(t, w); got.Files { + t.Errorf("after an edit of an ignored file: %+v, want no Files", got) + } + testgit.WriteFile(t, dir, ".gitignore", "build/\n") + if got := changed(t, w); !got.Files { + t.Errorf("after the .gitignore edit: %+v, want Files", got) + } + testgit.WriteFile(t, dir, "ignored.txt", "xxx\n") + if got := changed(t, w); !got.Files { + t.Errorf("after an edit of the file: %+v, want Files", got) + } +} + +// TestWatcherShown checks that a file that an expanded ignored directory +// gets or loses is a change. An edit of a shown ignored file is also a +// change, as in herdr-reviewr. +func TestWatcherShown(t *testing.T) { + t.Parallel() + r, dir := newRepo(t) + w := newWatcher(t, r) + testgit.WriteFile(t, dir, "build/new", "x\n") + if got := changed(t, w); got.Files { + t.Errorf("a new file in a hidden build: %+v, want no Files", got) + } + w.Show([]string{"build"}) + if got := changed(t, w); got.Files { + t.Errorf("after Show: %+v, want no Files", got) + } + testgit.WriteFile(t, dir, "build/more", "x\n") + if got := changed(t, w); !got.Files { + t.Errorf("a new file in a shown build: %+v, want Files", got) + } + if err := os.Remove(filepath.Join(dir, "build", "more")); err != nil { + t.Fatal(err) + } + if got := changed(t, w); !got.Files { + t.Errorf("a removed file in a shown build: %+v, want Files", got) + } + w.Show(nil) + testgit.WriteFile(t, dir, "build/more", "x\n") + if got := changed(t, w); got.Files { + t.Errorf("a new file after Show(nil): %+v, want no Files", got) + } + w.Show([]string{"ignored.txt"}) + testgit.WriteFile(t, dir, "ignored.txt", "an edit\n") + if got := changed(t, w); !got.Files { + t.Errorf("an edit of a shown file: %+v, want Files", got) + } + if err := os.Symlink("ignored.txt", filepath.Join(dir, "link")); err != nil { + t.Fatal(err) + } + w.Show([]string{"link"}) + changed(t, w) + testgit.WriteFile(t, dir, "ignored.txt", "one more edit\n") + if got := changed(t, w); !got.Files { + t.Errorf("an edit of the target of a shown link: %+v, want Files", got) + } +} + +// TestWatcherNested checks that a commit in a nested repository or in a +// submodule is a change. +func TestWatcherNested(t *testing.T) { + t.Parallel() + for _, sub := range []bool{false, true} { + name := map[bool]string{false: "nested", true: "submodule"}[sub] + t.Run(name, func(t *testing.T) { + t.Parallel() + r, dir := newRepo(t) + other := t.TempDir() + testgit.InitRepo(t, other) + testgit.WriteFile(t, other, "f", "1\n") + testgit.Git(t, other, "add", ".") + testgit.Git(t, other, "commit", "-qm", "1") + inner := filepath.Join(dir, "inner") + if sub { + testgit.Git(t, dir, "-c", "protocol.file.allow=always", + "submodule", "add", "-q", other, "inner") + } else { + testgit.Git(t, dir, "clone", "-q", other, "inner") + } + w := newWatcher(t, r) + testgit.WriteFile(t, inner, "f", "2\n") + testgit.Git(t, inner, "commit", "-qam", "2") + if got := changed(t, w); !got.Files { + t.Errorf("after a commit in %s: %+v, want Files", name, got) + } + }) + } +} + +// TestWatcherRunsNoGit checks that a Watcher runs no git process while +// nothing changes. +func TestWatcherRunsNoGit(t *testing.T) { + t.Parallel() + env, link := gitLink(t) + r, _ := newRepoEnv(t, env) + w := newWatcher(t, r) + removeGit(t, link) + for range 3 { + ch, err := w.Changed(t.Context()) + if err != nil || ch != (vcs.Change{}) { + t.Fatalf("idle call: %+v, %v, want none, nil", ch, err) + } + } +} + +// TestWatcherBudget checks that a call waits 20 times as long as the last +// call took. +func TestWatcherBudget(t *testing.T) { + t.Parallel() + r, dir := newRepo(t) + w, err := r.Watch(t.Context()) + if err != nil { + t.Fatal(err) + } + // Each reading of the clock moves it by 10 ms, so each call takes 10 + // ms and the next call waits 200 ms. + now := time.Now() + w.SetClock(func() time.Time { + now = now.Add(10 * time.Millisecond) + return now + }) + changed(t, w) + testgit.WriteFile(t, dir, "c.txt", "c\n") + if got := changed(t, w); got != (vcs.Change{}) { + t.Errorf("call in the pause: %+v, want none", got) + } + now = now.Add(200 * time.Millisecond) + if got := changed(t, w); !got.Files { + t.Errorf("call after the pause: %+v, want Files", got) + } +} + +// TestWatcherUntrackedDir checks that a new file in an untracked directory +// that git lists no file in is a change. A new file in an ignored +// directory in it is not. +func TestWatcherUntrackedDir(t *testing.T) { + t.Parallel() + tests := []struct { + name, setup, edit string + want bool + }{ + {"empty", "e/", "e/c.txt", true}, + {"only ignored files", "g/ignored.txt", "g/c.txt", true}, + {"empty below empty", "d/e/", "d/e/c.txt", true}, + {"ignored below untracked", "u/build/", "u/build/c.txt", false}, + // A path that starts with ":" is pathspec magic to git. + {"colon in the name", ":(x/e/", ":(x/e/c.txt", true}, + {"colon and exclude", ":!x/e/", ":!x/e/c.txt", true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + r, dir := newRepo(t) + if d, ok := strings.CutSuffix(tt.setup, "/"); ok { + if err := os.MkdirAll(filepath.Join(dir, d), 0o755); err != nil { + t.Fatal(err) + } + } else { + testgit.WriteFile(t, dir, tt.setup, "x\n") + } + w := newWatcher(t, r) + testgit.WriteFile(t, dir, tt.edit, "c\n") + if got := changed(t, w); got.Files != tt.want { + t.Errorf("after a new %s: %+v, want Files %v", tt.edit, got, tt.want) + } + }) + } +}