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 d2fa71f..3fd8ea9 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,5 @@ /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 58109b9..8adf79d 100644 --- a/README.md +++ b/README.md @@ -1,183 +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. -- Copy: drag over the code to copy its lines. Double-click copies a word, - triple-click a line. Double-click a file in the file list to copy its path. - rexdiff shows "copied 12 chars". -- 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. - -rexdiff checks the files about once a second without running git, and runs git -only after a change. In a large repository it checks less often, so its checks -use at most about 5% of a core. `r` reloads at once. - -The pull request tab reads the pull request again each minute while it shows, -and after a commit, a push, or a fetch. A switch to another branch clears it -until the new pull request loads. - -Last turn shows the changes since the start of the last agent turn that -changed a file. A turn starts when an agent in this repository starts to work, -and ends when all of them rest. Until then it shows "waiting for the first -turn", or "no agent works here". If Rex cannot list the agents, rexdiff asks -again after 1 s, then waits twice as long each time, up to a minute. - -A long run of unchanged lines folds into one row. Three lines show beside each -change. - -A comment stays on its line numbers and keeps the code it was made on. It -shows only where you made it: in the changes or in all files, and on the same -commits. If its file leaves the changes (or, for all files, is gone), the -comment list marks it (stale). The list edits only a comment on the commits -that show. - -| Key | Action | -| --- | --- | -| `u` `b` `t` | uncommitted, branch, or last-turn changes | -| `G` | pick commits: `v` marks the start of a range, enter opens, esc clears or closes | -| `]` `[` | next or previous change. Past the last one, press it again to go to the next file | -| `→` or click | show the unchanged lines of a fold (`⋯ 12 unmodified lines`) | -| `f` `F` | next or previous file | -| `→` `←` or click | expand or collapse a directory in the file list | -| `c` | new comment, also on a line that has one | -| `e` | edit the comment at the cursor, else open the file in the editor | -| `d` | delete the comment at the cursor | -| `n` `N` | next or previous comment in the file | -| `l` | list the comments: `e` edit, `d` delete, `s` send, `y` copy, enter jump | -| `s` | send the comments to the agent | -| `y` | copy the comments and clear them, as a send does | -| `\|` | side by side | -| `:` | go to a line (`0` first, `$` last) | -| `/` | search files. `tab` switches to code, `↑` `↓` or `ctrl+n` `ctrl+p` move, `pgup` `pgdn` scroll the preview, enter opens | -| `ctrl+f` | find in the file (smart case): enter or `↓` next, `↑` previous, esc closes | -| `m` | rendered markdown | -| `p` | move the file list: right, bottom, left, top | -| `z` | hide or show the file list | -| `<` `>` | make the file list larger or smaller | -| `1` `2` `3` | changes, all files, or pull request | -| `esc` | clear the selection, then the armed `]` or `[`, then the key list | -| `?` | all keys | -| `q` | quit, or ask first if comments are unsent | - -The search shows up to 50 files and 200 lines of code, then "… more". With no -query, it lists the files you opened from the search in this session first. -The preview below the results shows the file, with the match in the middle. - -The file list is a tree. Directories start expanded in the changes and -collapsed in all files. There, a collapsed directory with a change has a dot. -Ignored directories are dim and read their files when you expand them. An -expanded one shows new and removed files. - -At a side, the file list takes 15% to 60% of the width. Above or below, it takes -a quarter of the rows, and `<` `>` change that from 15% to 50%. - -Side by side needs 135 columns. A narrower pane shows one column. - -An edit with blank text keeps the comment as it was. - -rexdiff keeps the base pick and the last turn in `refs/worktree/rexdiff/`, so -they stay after a restart. With `--base`, the base picker does not open. - -The commit picker lists the branch's own commits (first parent only), or the last 50 on -the base branch. - -When `q` asks first, `Q` quits, `s` sends, `y` copies, and any other key closes the -question. - -A copy uses the first of `pbcopy`, `wl-copy`, `xclip`, or `xsel` on your PATH. - -## 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`: `right`, `bottom`, `left`, or `top` -- `file_list_width`: the width of the file list at a side, in columns -- `diff_signs`: `true` marks changed lines with `+` and `-`, in place of a coloured bar -- `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. - -A key in `keybindings` is one character, or `left`, `right`, `up`, `down`, `pageup`, or -`pagedown`, alone or after `ctrl+` or `alt+`. A key bound to two actions is an error that -names both. - -`e` opens the file at the cursor line in `editor`, else in `$VISUAL`, `$EDITOR`, or git's -`core.editor`. If none is set, rexdiff shows "no editor" and opens nothing. 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. -rexdiff opens an editor that it does not know in a pane. To open such an editor in a window, -start it with `open`, for example `editor = "open -a Zed {file}"`. - -rexdiff gets `$VISUAL` and `$EDITOR` from your login shell. With zsh, set them in -`~/.zprofile` or `~/.zshenv`, not in `~/.zshrc`. - -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 2.31 or later -- `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 55f536e..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,215 +24,81 @@ 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. 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 +// 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 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, 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 current content of the file. 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 @@ -250,91 +119,122 @@ func executable() (string, error) { 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)) + 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 @@ -344,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 } @@ -367,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 how to reload, 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 ec91c29..0000000 --- a/cmd/rexdiff/install_test.go +++ /dev/null @@ -1,117 +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. It 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 without rexdiff.lua: the file exists, want no file") - } - - // 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 got:\n%s\nwant:\n%s", - got, settings+luaCode(bin)) - } - 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.Fatalf("the test file has no %s, want it", luaQuote(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, want %q in it", n, want) - } - } - if got := read(); got != noMark { - t.Errorf("rexdiff.lua without the line above the code, after upgrade "+ - "got:\n%s\nwant:\n%s", got, noMark) - } -} - -// TestLuaQuote checks that a path that Go escapes stays the same in Lua 5.1. -// Lua 5.1 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 267e573..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 that code 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 // an interface with a nil *update.Updater is not nil + 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,16 +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 - } + _ = 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.Print("\x1b[?1049l\x1b[?25h") - return fmt.Errorf("start the new rexdiff: %w; your comments are saved in %s", err, resume) + 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 @@ -132,14 +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 "". It unsets the variable, -// so that the programs that rexdiff starts, such as an editor, do not get -// the variable. -func resumeFile() string { - path := os.Getenv(resumeEnv) - // Unsetenv fails only for a bad name. resumeEnv is a good name. - _ = os.Unsetenv(resumeEnv) - 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 @@ -148,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 @@ -160,66 +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") + // 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", 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") + 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 74c6cd6..0000000 --- a/cmd/rexdiff/script_test.go +++ /dev/null @@ -1,79 +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. A script reads the log to -// see what rexdiff asks Rex to do. fakeRex 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. -func TestScripts(t *testing.T) { - // If the real rex tool is installed, $REALREX is its path, and the - // condition [realrex] is true. - 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 { - // Each script runs in a new directory, $WORK. $WORK is also - // $HOME, so rexdiff does not see the files of the user. - 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 1880108..0000000 --- a/cmd/rexdiff/testdata/script/install.txt +++ /dev/null @@ -1,93 +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. A "shell" option can stop that. -! 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/cache_test.go b/diff/cache_test.go deleted file mode 100644 index 6d7203e..0000000 --- a/diff/cache_test.go +++ /dev/null @@ -1,20 +0,0 @@ -package diff - -import ( - "strings" - "testing" -) - -// TestCacheKeepsFile checks that RenderFile uses the colours that the cache -// keeps for the same file. RenderFile colours a changed file again. -func TestCacheKeepsFile(t *testing.T) { - var c Cache - RenderFile("a.go", "x := 1\n", Options{Cache: &c}) - c.file.out = "kept" - if got := RenderFile("a.go", "x := 1\n", Options{Cache: &c}).Lines[0]; !strings.Contains(got, "kept") { - t.Errorf("the same file shows %q, want the kept colours", got) - } - if got := RenderFile("a.go", "x := 2\n", Options{Cache: &c}).Lines[0]; strings.Contains(got, "kept") { - t.Errorf("a changed file shows %q, want new colours", got) - } -} diff --git a/diff/cell.go b/diff/cell.go index 67fe577..710f9e1 100644 --- a/diff/cell.go +++ b/diff/cell.go @@ -2,6 +2,7 @@ package diff import ( "strings" + "unicode/utf8" "github.com/charmbracelet/x/ansi" @@ -14,7 +15,7 @@ import ( // 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, char int, ok bool) { +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 } @@ -30,16 +31,15 @@ func (d *Diff) Cell(row, x int) (s Side, line, char int, ok bool) { if onNumber || line == 0 || x < d.codeAt[s] { return 0, 0, 0, false } - lines := Lines(d.src[s]) - if line > len(lines) { + text, ok := lineAt(d.src[s], line) + if !ok { return 0, 0, 0, false } - text := lines[line-1] 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, len([]rune(text)), true + return s, line, utf8.RuneCountInString(text), true } col += start } @@ -85,23 +85,36 @@ func (d *Diff) segment(i int, s Side) string { // 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(" ", tabWidth))) + return diffview.Escape(strings.ReplaceAll(line, "\t", strings.Repeat(" ", diffview.TabWidth))) } -// runeAt returns the index of the rune of line that covers column col. -// It returns the number of runes if col is past the end. +// 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 _, r := range line { - w := tabWidth - if r != '\t' { - w = ansi.StringWidth(diffview.Escape(string(r))) + 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++ + 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 index 6d911b5..a041ed4 100644 --- a/diff/cell_test.go +++ b/diff/cell_test.go @@ -22,11 +22,21 @@ func screenRune(plain string, x int) (rune, bool) { 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 := ansi.Strip(d.Lines[i]) + plain := plainLine(t, d, i) k := strings.Index(plain, needle) if last { k = strings.LastIndex(plain, needle) @@ -40,11 +50,12 @@ func column(t *testing.T, d *diff.Diff, i int, needle string, last bool) int { // TestCell checks the source character of a cell, with tabs, an offset, // the split layout, and a file. func TestCell(t *testing.T) { - const old, new = "a\n\told_name := 1\n", "a\n\tnew_name := 1\n" - u := diff.Render("x.go", old, new, diff.Options{Width: 80}) - off := diff.Render("x.go", old, new, diff.Options{Width: 80, Offset: 6}) - s := diff.Render("x.go", old, new, diff.Options{Width: 80, Split: true}) - f := diff.RenderFile("x.go", new, diff.Options{Width: 80}) + 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 @@ -64,12 +75,16 @@ func TestCell(t *testing.T) { {"file", f, 1, "new_name", false, diff.SideNew, 2, 1}, } for _, tt := range tests { - 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("%s: Cell(%d, %d) = %v, %d, %d, %v; want %v, %d, %d, true", - tt.name, tt.row, x, side, line, char, ok, tt.side, tt.line, tt.char) - } + 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) @@ -77,7 +92,7 @@ func TestCell(t *testing.T) { 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) + 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}} { @@ -95,59 +110,120 @@ func TestCell(t *testing.T) { // 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" - old, new := "a\nb\n", "a\n"+long+"\nb\n" - renders := map[string]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) }, - } + 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(old), diff.SideNew: diff.Lines(new)} - for rn, render := range renders { - for on, o := range opts { - d := render(o) - checked := 0 + 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 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 %s: line %d col %d shows %q, want past the end", - rn, on, i, x, got) - } + for k := range len(plain) { + if plain[k] != 'x' { continue } - want := text[char] - if want == '\t' { - want = ' ' + 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) } - if got != want && got != '…' { - t.Errorf("%s %s: line %d col %d shows %q, Cell gives %q", - rn, on, i, x, got, want) - } - checked++ + found++ } } - if checked < 20 { - t.Errorf("%s %s: %d code cells, want 20 or more", rn, on, checked) + 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 259c4f2..3b4e2cc 100644 --- a/diff/comment.go +++ b/diff/comment.go @@ -2,6 +2,7 @@ package diff import ( "cmp" + "encoding/json/jsontext" "encoding/json/v2" "fmt" "slices" @@ -27,6 +28,8 @@ type Comment struct { 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 @@ -40,15 +43,16 @@ type Anchor struct { } // 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 { @@ -62,7 +66,7 @@ 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 } @@ -91,12 +95,11 @@ type Comments struct { // Len returns the number of comments. func (cs *Comments) Len() int { return len(cs.items) } -// Add adds c with a new ID, and returns the comment in the set. -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. @@ -106,28 +109,35 @@ 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 with anchor a whose range includes line, or // nil. -func (cs *Comments) At(a Anchor, path string, side Side, line int) *Comment { +func (cs *Comments) At(a Anchor, path string, s Side, line int) *Comment { for _, c := range cs.items { - if c.Anchor == a && c.Path == path && c.Side == side && + if c.Anchor == a && c.Path == path && c.Side == s && line >= c.Start && line <= c.End { return c } @@ -136,10 +146,10 @@ func (cs *Comments) At(a Anchor, 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, side Side, line int) []*Comment { +func (cs *Comments) EndingAt(a Anchor, path string, s Side, line int) []*Comment { var ending []*Comment for _, c := range cs.items { - if c.Anchor == a && 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) } } @@ -157,12 +167,13 @@ func (cs *Comments) Count(a Anchor, path string) int { return n } -// 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 before } - return new + return after } // savedComment is a [Comment] with its private fields, for MarshalJSON. @@ -189,7 +200,8 @@ 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 { @@ -199,7 +211,7 @@ func (cs *Comments) MarshalJSON() ([]byte, error) { Code: c.code, Snippet: c.snippet, }) } - return json.Marshal(saved) + return json.Marshal(saved, jsontext.AllowInvalidUTF8(true)) } // UnmarshalJSON reads comments that MarshalJSON wrote. @@ -214,7 +226,7 @@ func (cs *Comments) UnmarshalJSON(b []byte) error { cs.items = append(cs.items, &Comment{ ID: c.ID, Path: c.Path, Side: c.Side, Start: c.Start, End: c.End, Text: c.Text, Anchor: Anchor{InDiff: inDiff, Rev: c.Rev}, - code: c.Code, snippet: c.Snippet, + 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 a58dc96..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,9 +37,10 @@ func review(c diff.Comment) string { return cs.Review("") } -// TestReviewMatchesHerdr checks that Review writes the text format of -// herdr-reviewr. -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", @@ -57,7 +61,7 @@ func TestReviewMatchesHerdr(t *testing.T) { "c.go:1-2\n" + " ctx\n-\told[31m\n+\tnew\nfix]7501;state=idle this" if got != want { - t.Errorf("Review got:\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 with no comments = %q, want \"\"", got) @@ -66,31 +70,34 @@ func TestReviewMatchesHerdr(t *testing.T) { // 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" // The comment has no diff lines, so Review writes the lines of its range. - want := "a.go:1\n+" + strings.TrimSuffix(new, "\n") + "\nlong" + 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 got:\n%s\nwant:\n%s", + 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 d := diff.Anchor{InDiff: true} - a := cs.Add(diff.Comment{Path: "a.go", Side: diff.SideNew, Start: 2, End: 4, Anchor: d}) - b := cs.Add(diff.Comment{Path: "a.go", Side: diff.SideOld, Start: 4, End: 4, Anchor: d}) + 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", @@ -122,10 +129,11 @@ func TestComments(t *testing.T) { } cs.Remove(b.ID) if cs.Find(b.ID) != nil || cs.Len() != 2 { - t.Errorf("after Remove(%d): Find = %v, Len = %d; want nil, 2", + 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 { + 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) } @@ -134,14 +142,15 @@ func TestComments(t *testing.T) { // 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" + 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) } @@ -151,7 +160,7 @@ 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 got:\n%s\nwant:\n%s", + t.Errorf("split %v, side %d: Review = \n%s\nwant:\n%s", split, s, got, want) } } @@ -162,15 +171,15 @@ func TestNewComment(t *testing.T) { ins = append(ins, r) } } - got := 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) } } // 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) } @@ -179,33 +188,36 @@ func TestNewComment(t *testing.T) { // 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: snippet got:\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 got:\n%s\nwant:\n%s", body, want) + t.Errorf("git diff = \n%s\nwant:\n%s", body, want) } } @@ -213,6 +225,7 @@ func TestSnippetIsTheRealDiff(t *testing.T) { // 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 { @@ -239,7 +252,48 @@ func TestCommentsJSON(t *testing.T) { t.Fatalf("UnmarshalJSON of an old file: %v", err) } c := cs2.Find(1) - if c == nil || c.Anchor != (diff.Anchor{InDiff: true}) || c.Start != 2 { + 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 241b60d..337e2a4 100644 --- a/diff/diff.go +++ b/diff/diff.go @@ -18,6 +18,7 @@ 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, with no source line KindEqual // a line that is the same on the two sides @@ -29,6 +30,7 @@ const ( // 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 @@ -75,8 +77,8 @@ 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 tells if the lines of a fold show. If Expanded is nil, no - // fold shows. RenderFile does not use it. + // 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. @@ -96,9 +98,6 @@ type Cache struct { file struct{ path, src, out string } // the last file of RenderFile } -// tabWidth is the number of spaces that replace a tab. -const tabWidth = 4 - // 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 // versions are the same. @@ -118,34 +117,26 @@ type Diff struct { 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) + 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.Expanded != nil { dv = dv.Expand(func(f diffview.Fold) bool { return o.Expanded(foldOf(f)) }) } - if o.Split { - dv = dv.Split() - } - if o.Signs { - dv = dv.Signs() - } - if o.Wrap { - dv = dv.Wrap() - } - dv = o.Theme.apply(dv) out, rows, g := dv.Render() - d := &Diff{split: o.Split, src: [2]string{old, new}, wrap: o.Wrap} + d := &Diff{split: o.Split, src: [2]string{before, after}, wrap: o.Wrap} if !o.Wrap { d.offset = o.Offset } @@ -166,10 +157,12 @@ func Render(path, old, new string, o Options) *Diff { 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: @@ -204,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 } @@ -231,8 +223,22 @@ func Count(old, new string) (added, deleted int) { 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 9e62a72..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,9 +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{ {}, @@ -79,47 +103,78 @@ 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, want one line for each row", - 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, want %d or fewer", - name, i, w, width) + 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) + } + } + }) + } + } +} + +// 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) } } } -// TestRenderSameContentIsEmpty checks that two equal versions give no lines. -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)) +// 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") { + 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, want \"= 1\" and no \"x :=\"", got) } @@ -128,6 +183,7 @@ func TestRenderTabsAndOffset(t *testing.T) { // 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 @@ -160,26 +216,30 @@ func TestMarks(t *testing.T) { }}, } for _, tt := range tests { - tt.o.Theme = theme - d := diff.Render("x.txt", before, after, tt.o) - if len(d.Lines) != 1+len(tt.lines) { - t.Fatalf("%s: %d lines, want %d", tt.name, 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("%s: line %d = %q, want %q", tt.name, i+1, got, want) + 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)) } - } - // The marks have the colour of their lines: ANSI red for the - // delete, then ANSI green for the insert. - if got := markColours(d.Lines[1:]); !slices.Equal(got, []string{"31", "32"}) { - t.Errorf("%s: the marks have colours %q, want 31 and 32", tt.name, got) - } + 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 := ansi.Strip(d.Lines[1]); !strings.Contains(got, "▌…cdefgh") { + 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. @@ -203,7 +263,7 @@ func markColours(lines []string) []string { if i < 0 { break } - sgr := l[strings.LastIndex(l[:i], "\x1b[")+2 : i] + _, sgr, _ := strings.CutLast(l[:i], "\x1b[") fg, _, _ := strings.Cut(strings.TrimSuffix(sgr, "m"), ";") out = append(out, fg) l = l[i+1:] @@ -215,6 +275,7 @@ func markColours(lines []string) []string { // 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}) @@ -241,29 +302,33 @@ 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) } } @@ -271,10 +336,12 @@ func TestCount(t *testing.T) { // 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, want %+v", @@ -286,78 +353,66 @@ func TestRenderFile(t *testing.T) { if got := ansi.Strip(d.Lines[0]); got != " 1 package x" { t.Errorf("line 0 = %q, want %q", got, " 1 package x") } - // 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, want no escape", d.Lines[0]) - } } // 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, want %d or fewer", - name, i, w, width) + 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)) + } + 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) + } } - r := d.Rows[i] - if r.New == 2 { - text.WriteString(ansi.Strip(l)) + noSpace := func(s string) string { + return strings.Join(strings.Fields(s), "") } - // 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("%s: continued row %d = %+v, want the lines of %+v", - name, i, r, prev) + if !strings.Contains(noSpace(text.String()), noSpace(long)) { + t.Errorf("wrapped lines = \n%s\nwant all of:\n%s", + text.String(), long) } - } - 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 got:\n%s\nwant all of:\n%s", - name, text.String(), long) - } - // Wrap does not change the snippet of a comment. - got, want := snippet(d.Rows, old, new), snippet(cut.Rows, old, new) - if !slices.Equal(got, want) { - t.Errorf("%s: snippet with wrap = %q, want %q", name, got, want) - } + // 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" - renders := []struct { + tests := []struct { path, old, new string file bool // RenderFile, of new offset int @@ -370,16 +425,17 @@ func TestCache(t *testing.T) { {path: "a.go", new: src + "y\n", file: true}, } var c diff.Cache - for _, r := range renders { + for _, tt := range tests { render := func(c *diff.Cache) []string { - o := diff.Options{Width: 40, Offset: r.offset, Cache: c} - if r.file { - return diff.RenderFile(r.path, r.new, o).Lines + 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(r.path, r.old, r.new, o).Lines + return diff.Render(tt.path, tt.old, tt.new, o).Lines } if got, want := render(&c), render(nil); !slices.Equal(got, want) { - t.Errorf("%+v: with the cache got:\n%q\nwant:\n%q", r, 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 049e49c..02f90fe 100644 --- a/diff/file.go +++ b/diff/file.go @@ -18,6 +18,7 @@ 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. @@ -25,13 +26,15 @@ func RenderFile(path, src string, o Options) *Diff { src = strings.NewReplacer( "\r\n", "\n", "\r", "\u240d", - "\t", strings.Repeat(" ", tabWidth), + "\t", strings.Repeat(" ", diffview.TabWidth), ).Replace(src) - hl := strings.Split(o.Cache.fileColours(path, src), "\n") - hl = hl[:min(len(hl), len(Lines(src)))] // chroma can add a final newline + 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)) + // 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 { @@ -43,7 +46,7 @@ func RenderFile(path, src string, o Options) *Diff { 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 @@ -74,14 +77,14 @@ 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{ansi.TruncateLeft(l, o.Offset, "")} + return []string{diffview.CutLeft(l, o.Offset)} } -// fileColours returns highlight(path, src), and keeps it for the next +// fileColors returns highlight(path, src), and keeps it for the next // call. A nil c keeps nothing. -func (c *Cache) fileColours(path, src string) string { +func (c *Cache) fileColors(path, src string) string { if c == nil { return highlight(path, src) } diff --git a/diff/fold_test.go b/diff/fold_test.go index 89875d3..ec1d143 100644 --- a/diff/fold_test.go +++ b/diff/fold_test.go @@ -28,8 +28,8 @@ func numbered(n int, upper ...int) 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 the rows of d as one entry each: "@" and its fold for a -// hunk header, else the old and the new line number. +// 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 { @@ -56,7 +56,8 @@ func headers(d *diff.Diff) string { // TestFolds checks that a long run of equal lines folds into the hunk // header below it. func TestFolds(t *testing.T) { - old, new := numbered(40), numbered(40, 10, 30) + 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 @@ -92,10 +93,14 @@ func TestFolds(t *testing.T) { )}, } for _, tt := range tests { - d := diff.Render("f.txt", old, new, diff.Options{Width: 60, Expanded: tt.expanded}) - if got := layout(d); !slices.Equal(got, tt.want) { - t.Errorf("%s:\n got %v\nwant %v", tt.name, got, tt.want) - } + 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 @@ -112,9 +117,9 @@ func TestFolds(t *testing.T) { "… ⋯ 13 unmodified lines @@ -27,7 +27,7 @@\n" + "… ⋯ 7 unmodified lines"}, } { - d := diff.Render("f.txt", old, new, tt.o) + d := diff.Render("f.txt", before, after, tt.o) if got := headers(d); got != tt.want { - t.Errorf("split %v: headers got:\n%s\nwant:\n%s", + t.Errorf("split %v: headers = \n%s\nwant:\n%s", tt.o.Split, got, tt.want) } for i, l := range d.Lines { @@ -128,6 +133,7 @@ func TestFolds(t *testing.T) { // TestFoldEdges checks the folds that show no line. func TestFoldEdges(t *testing.T) { + t.Parallel() tests := []struct { name string old, new string @@ -135,24 +141,32 @@ func TestFoldEdges(t *testing.T) { }{ // 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"}, + { + "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"}, + { + "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 { - d := diff.Render("f.txt", tt.old, tt.new, diff.Options{Width: 80}) - if got := strings.Join(layout(d), " "); got != tt.want { - t.Errorf("%s:\n got %s\nwant %s", tt.name, got, tt.want) - } - // The split layout has the same folds. - split := diff.Render("f.txt", tt.old, tt.new, diff.Options{Width: 80, Split: true}) - if got, want := folds(split), folds(d); !slices.Equal(got, want) { - t.Errorf("%s: the split layout has folds %v, want %v", tt.name, got, want) - } + 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) + } + }) } } diff --git a/diff/fuzz_test.go b/diff/fuzz_test.go index 0ab006c..2207078 100644 --- a/diff/fuzz_test.go +++ b/diff/fuzz_test.go @@ -66,7 +66,7 @@ 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("got %d lines for %d rows, want one line for each row", + 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 { @@ -82,9 +82,9 @@ func checkLines(t *testing.T, d *diff.Diff, o diff.Options) { } // 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 { @@ -120,15 +120,15 @@ 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), "…") @@ -138,14 +138,14 @@ func FuzzRender(f *testing.F) { } } - 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 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++ @@ -166,11 +166,11 @@ 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, want the unified snippet %q", got, want) @@ -256,11 +256,13 @@ func FuzzComments(f *testing.F) { s := int(start) % len(lines) e := min(s+int(size), len(lines)-1) var cs diff.Comments - 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 if c.Start != s+1 || c.End != e+1 { - t.Errorf("commentOn lines %d-%d: %+v", s+1, e+1, c) + 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) { diff --git a/diff/hostile_test.go b/diff/hostile_test.go index 149da81..9ac0746 100644 --- a/diff/hostile_test.go +++ b/diff/hostile_test.go @@ -13,6 +13,7 @@ import ( // 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}, @@ -40,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"}, @@ -49,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{}) @@ -63,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}, @@ -84,18 +88,10 @@ func TestWrapWideText(t *testing.T) { } } -// 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 @@ -119,6 +115,7 @@ 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) @@ -128,6 +125,7 @@ func TestRenderFileNarrow(t *testing.T) { // 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", @@ -142,6 +140,7 @@ func TestReviewHostileComment(t *testing.T) { // 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}) 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/match_test.go b/diff/match_test.go deleted file mode 100644 index cf1e194..0000000 --- a/diff/match_test.go +++ /dev/null @@ -1,32 +0,0 @@ -package diff_test - -import ( - "testing" - - "github.com/rednafi/rexdiff/diff" -) - -// TestMatch checks the smart case of herdr-reviewr. -func TestMatch(t *testing.T) { - 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 { - if got := diff.Match(tt.text, tt.query); got != tt.want { - t.Errorf("Match(%q, %q) = %v, want %v", tt.text, tt.query, got, tt.want) - } - } -} diff --git a/diff/review.go b/diff/review.go index 12931ff..4fa69f6 100644 --- a/diff/review.go +++ b/diff/review.go @@ -8,7 +8,9 @@ import ( // 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 @@ -33,7 +35,8 @@ func format(c *Comment) string { // diff. if len(c.snippet) > 0 { for _, l := range c.snippet { - b.WriteString("\n" + termtext.Plain(l)) + b.WriteString("\n") + b.WriteString(termtext.Plain(l)) } } else { mark := "+" @@ -41,7 +44,9 @@ func format(c *Comment) string { mark = "-" } for _, l := range c.code { - b.WriteString("\n" + mark + termtext.Plain(l)) + b.WriteString("\n") + b.WriteString(mark) + b.WriteString(termtext.Plain(l)) } } var text []string @@ -50,13 +55,14 @@ func format(c *Comment) string { 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() } // 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 f11dc47..0000000 --- a/internal/VENDORED.md +++ /dev/null @@ -1,43 +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 - - a coloured bar `▌` marks a changed line, in place of crush's `+` and `-`; `Signs` shows - `+` and `-` in place of the bar - - `fold.go` (added): the diff has the whole file in one hunk, and `foldHunk` folds the - long runs of equal lines, as in herdr-reviewr; `Expand` shows a fold, and `Row.Fold` tells - which lines a hunk header hides - - the syntax cache key tells "no background" from black, so a black tint does not - leak onto equal lines - - the syntax cache is a `Cache` that views can share (`View.Cache`), so that a new - render does not highlight the same lines again; its key has the lexer name, it keeps - the last lexer, and a `Cache` empties before its lines go above 32 MB diff --git a/internal/clipboard/clipboard.go b/internal/clipboard/clipboard.go index 4096dd5..7aee185 100644 --- a/internal/clipboard/clipboard.go +++ b/internal/clipboard/clipboard.go @@ -1,17 +1,19 @@ // Package clipboard writes text to the system clipboard through the first -// clipboard tool on PATH, as herdr-reviewr does. +// 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" - "os/exec" "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 found (wl-clipboard, xclip, or xsel)") +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{ @@ -21,21 +23,22 @@ var tools = [][]string{ {"xsel", "--clipboard", "--input"}, } -// Write pipes text into the first clipboard tool on PATH. It fails if the -// tool exits with a status that is not zero. -func Write(ctx context.Context, text string) error { +// 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 { - path, err := exec.LookPath(t[0]) - if err != nil { + if _, err := proc.LookPath(env, t[0]); err != nil { continue } - cmd := exec.CommandContext(ctx, path, t[1:]...) + 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. + // runs. Thus the error has no stderr. if err := cmd.Run(); err != nil { - return fmt.Errorf("%s: %w", t[0], err) + return fmt.Errorf("%s: %w", t[0], proc.Error(ctx, t[0], err, nil)) } return nil } diff --git a/internal/clipboard/clipboard_test.go b/internal/clipboard/clipboard_test.go index 882c873..7a840a7 100644 --- a/internal/clipboard/clipboard_test.go +++ b/internal/clipboard/clipboard_test.go @@ -7,24 +7,23 @@ import ( "testing" "github.com/rednafi/rexdiff/internal/clipboard" + "github.com/rednafi/rexdiff/internal/testgit" ) -// fakeTool writes an executable script name in a new directory, and sets -// PATH to only that directory. -func fakeTool(t *testing.T, name, script string) string { +// 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() - path := filepath.Join(dir, name) - if err := os.WriteFile(path, []byte("#!/bin/sh\n"+script), 0o755); err != nil { - t.Fatal(err) - } - t.Setenv("PATH", dir) - return dir + 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"}, @@ -33,9 +32,10 @@ func TestWrite(t *testing.T) { } 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 := fakeTool(t, tt.name, `{ for a; do printf '%s|' "$a"; done; /bin/cat; } > "${0%/*}/out"`+"\n") - if err := clipboard.Write(t.Context(), "hi\n"); err != nil { + 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")) @@ -43,7 +43,8 @@ func TestWrite(t *testing.T) { t.Fatal(err) } if string(got) != tt.want { - t.Errorf("%s got %q, want %q", tt.name, got, tt.want) + t.Errorf("Write(hi) with %s: output = %q, want %q", + tt.name, got, tt.want) } }) } @@ -52,12 +53,14 @@ func TestWrite(t *testing.T) { // TestWriteFails checks the errors with no tool, and with a tool that // fails. func TestWriteFails(t *testing.T) { - t.Setenv("PATH", t.TempDir()) - if err := clipboard.Write(t.Context(), "x"); !errors.Is(err, clipboard.ErrNoTool) { - t.Errorf("no tool: Write = %v, want ErrNoTool", err) + 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) + } } - fakeTool(t, "pbcopy", "exit 1\n") - err := clipboard.Write(t.Context(), "x") + _, 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 cebfc32..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, ANSIFormatter calls it 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 := 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/cache_test.go b/internal/diffview/cache_test.go deleted file mode 100644 index 2a24eb4..0000000 --- a/internal/diffview/cache_test.go +++ /dev/null @@ -1,73 +0,0 @@ -package diffview - -import ( - "strings" - "testing" - - "github.com/alecthomas/chroma/v2/lexers" -) - -// TestCacheShared checks that a View uses the lines that another View puts -// in the same Cache. -func TestCacheShared(t *testing.T) { - render := func(c *Cache) string { - out, _, _ := New().Cache(c).Before("a.go", "x := 1\n").After("a.go", "x := 2\n").Render() - return out - } - c := &Cache{} - render(c) - if len(c.lines) == 0 { - t.Fatal("the cache is empty after a render, want lines") - } - for k := range c.lines { - c.lines[k] = "kept" - } - if got := render(c); strings.Count(got, "kept") != 2 { - t.Errorf("a View with the same cache shows\n%s\nwant the two kept lines", got) - } - if got := render(&Cache{}); strings.Contains(got, "kept") { - t.Errorf("a View with a new cache shows\n%s\nwant no kept lines", got) - } -} - -// TestCacheLimit checks that a full Cache empties, so that its memory stays -// small. -func TestCacheLimit(t *testing.T) { - var c Cache - line := strings.Repeat("x", 1<<20) - for i := range maxCacheSize >> 20 { - c.put(syntaxKey{r: uint32(i)}, line) - } - if got, want := len(c.lines), maxCacheSize>>20; got != want { - t.Errorf("the cache has %d lines, want %d", got, want) - } - c.put(syntaxKey{source: "a"}, "") - if len(c.lines) != 1 || c.size != 1 { - t.Errorf("the full cache has %d lines of %d bytes after a put, want 1 of 1", len(c.lines), c.size) - } -} - -// TestCacheNil checks that a View with a nil Cache renders. -func TestCacheNil(t *testing.T) { - if out, _, _ := New().Cache(nil).Before("a.go", "x\n").After("a.go", "y\n").Render(); out == "" { - t.Error(`Render with a nil Cache = "", want lines`) - } -} - -// TestCacheLexer checks that a View uses the last lexer of the Cache for -// the same file. It selects a new lexer for a changed file. -func TestCacheLexer(t *testing.T) { - c := &Cache{} - lexer := func(content string) string { - v := New().Cache(c).Before("a.zzq", content) - return v.chromaLexer().Config().Name - } - lexer("x\n") - c.lexer.l = lexers.Get("python") - if got := lexer("x\n"); got != "Python" { - t.Errorf("the same file has the lexer %q, want the kept Python", got) - } - if got := lexer("y\n"); got == "Python" { - t.Errorf("a changed file has the lexer %q, want a new lexer", got) - } -} 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 a9c54fe..0000000 --- a/internal/diffview/diffview.go +++ /dev/null @@ -1,642 +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 -) - -// 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 = "- " -) - -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 - signs bool // mark a changed line with + or -, not with a bar - - isComputed bool - err error - unified udiff.UnifiedDiff - folds []Fold // folds[i] hides above unified.Hunks[i] - expanded func(Fold) bool // the folds that show, see [View.Expand] - - 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 - - cache *Cache -} - -// 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 -} - -// New returns a View with the unified layout and a tab width of 8. -func New() *View { - return &View{ - layout: layoutUnified, - tabWidth: defaultTabWidth, - cache: &Cache{}, - } -} - -// Cache makes v keep its highlighted lines in c. A nil c gives v a new -// Cache. -func (v *View) Cache(c *Cache) *View { - if c == nil { - c = &Cache{} - } - v.cache = c - return v -} - -// Split sets the split layout. The old lines are on the left and the new -// lines are 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 diff. Call it when the content changes. -func (v *View) clearCaches() { - 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. A later call -// 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) - // 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.content, "\n") + 1 - v.unified, v.err = udiff.ToUnifiedDiff( - v.before.path, - v.after.path, - v.before.content, - edits, - context, - ) - hunks := v.unified.Hunks - v.unified.Hunks, v.folds = nil, nil - for _, h := range hunks { - hs, fs := foldHunk(h, v.expanded) - v.unified.Hunks = append(v.unified.Hunks, hs...) - v.folds = append(v.folds, fs...) - } - 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 i, h := range v.unified.Hunks { - v.codeWidth = max(v.codeWidth, ansi.StringWidth(v.headerText(i))) - for _, l := range h.Lines { - v.codeWidth = max(v.codeWidth, codeWidthOf(&l)) - } - } -} - -// 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 { - v.codeWidth = max(v.codeWidth, ansi.StringWidth(v.headerText(i))) - for _, l := range h.lines { - if l.before != nil { - v.codeWidth = max(v.codeWidth, codeWidthOf(l.before)) - } - if l.after != nil { - v.codeWidth = max(v.codeWidth, codeWidthOf(l.after)) - } - } - } -} - -// 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. 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 v.lineNum(ls, before, v.beforeNumDigits) + - v.lineNum(ls, after, v.afterNumDigits) - } - for i, h := range v.unified.Hunks { - b.WriteString(v.unifiedHeader(i) + "\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 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.splitHunks { - b.WriteString(v.splitHeader(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 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 := 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, 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 -} - -// 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 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 = ansi.GraphemeWidth.Cut(code, v.xOffset, len(code)) - 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 -} - -// 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 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 -} - -// 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 -} - -// chromaLexer returns the lexer of the old file, and keeps it in the -// cache. -func (v *View) chromaLexer() chroma.Lexer { - lx := &v.cache.lexer - if lx.l == nil || lx.path != v.before.path || lx.content != v.before.content { - lx.path, lx.content = v.before.path, v.before.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) -} - -// 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")) -} diff --git a/internal/diffview/escape.go b/internal/diffview/escape.go deleted file mode 100644 index 49bbe3f..0000000 --- a/internal/diffview/escape.go +++ /dev/null @@ -1,37 +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 ( - // 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 { - 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 index ec0e084..b3396fc 100644 --- a/internal/diffview/fold.go +++ b/internal/diffview/fold.go @@ -1,10 +1,6 @@ package diffview -import ( - "fmt" - - "github.com/aymanbagabas/go-udiff" -) +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. @@ -17,13 +13,6 @@ type Fold struct { Before, After, Lines int } -// Expand shows the lines of each fold for which expanded returns true. -func (v *View) Expand(expanded func(Fold) bool) *View { - v.expanded = expanded - v.isComputed = false - return v -} - // 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 @@ -81,18 +70,3 @@ func foldHunk(h *udiff.Hunk, expanded func(Fold) bool) (hunks []*udiff.Hunk, fol } return hunks, folds } - -// 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.unified.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) -} 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 4343f67..0000000 --- a/internal/diffview/rows.go +++ /dev/null @@ -1,150 +0,0 @@ -package diffview - -import "github.com/aymanbagabas/go-udiff" - -// 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 -} - -// 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 i, h := range v.unified.Hunks { - rows = append(rows, v.headerRow(i)) - 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 i, h := range v.splitHunks { - rows = append(rows, v.headerRow(i)) - 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 -} - -// headerRow returns the row of the header of hunk i. -func (v *View) headerRow(i int) Row { - return Row{Kind: RowHunk, Fold: v.folds[i]} -} - -// 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 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. 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 - 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/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 96a9f28..0000000 --- a/internal/diffview/terminal.go +++ /dev/null @@ -1,103 +0,0 @@ -package diffview - -import ( - "cmp" - "image/color" - - "charm.land/lipgloss/v2" -) - -// Signs marks an inserted line with + and a deleted line with -, in place -// of a bar. -func (v *View) Signs() *View { - v.signs = true - return v -} - -// 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{R: f(ar, br), G: f(ag, bg), B: f(ab, bb), A: 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 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. 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. -// 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 188f3b5..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.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/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 index f136b14..235e8aa 100644 --- a/internal/editor/editor.go +++ b/internal/editor/editor.go @@ -1,9 +1,11 @@ -// Package editor makes the sh command that opens a file at a line in a -// text editor. It also tells if the editor opens a window or draws in the -// terminal. +// 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" @@ -18,26 +20,56 @@ type Command struct { Window bool // the editor opens a window, not a terminal editor } -// Template returns the command that opens file at line with template, +// 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 Template(template, file string, line int) Command { - r := strings.NewReplacer("{file}", ShellQuote(file), "{line}", strconv.Itoa(line)) +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) + script += " " + shellQuote(file) } - return Command{script, program(template), dialectOf(template).window} + return Command{Script: script, Program: program(template), Window: dialectOf(template).window} } -// Program returns the command that opens file at line with command, the -// command of an editor program such as the value of $EDITOR. Program adds -// file and line in the form that the editor expects. -func Program(command, file string, line int) Command { +// 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) + n, f := strconv.Itoa(line), shellQuote(file) + at := shellQuote(file + ":" + n) var args string switch d.line { case argNone: @@ -51,11 +83,11 @@ func Program(command, file string, line int) Command { case argFlag: args = "--line " + n + " " + f } - return Command{command + " " + args, program(command), d.window} + 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, "'", `'\''`) + "'" } +// 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 @@ -76,9 +108,10 @@ type dialect struct { // 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 = makeDialects() +var dialects = knownDialects() -func makeDialects() map[string]dialect { +// 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 { diff --git a/internal/editor/editor_test.go b/internal/editor/editor_test.go index db21f0a..4709e0f 100644 --- a/internal/editor/editor_test.go +++ b/internal/editor/editor_test.go @@ -8,6 +8,7 @@ import ( // 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 @@ -39,15 +40,10 @@ func TestCommands(t *testing.T) { {"", "myeditor --wait", `myeditor --wait +12 '/r/a b.go'`, true}, } for _, tt := range tests { - var got editor.Command - if tt.template != "" { - got = editor.Template(tt.template, abs, 12) - } else { - got = editor.Program(tt.editor, abs, 12) - } - if got.Script != tt.script || got.Window != tt.window { - t.Errorf("editor %q%q = %+v, want script %q, window %v", - tt.template, tt.editor, got, tt.script, tt.window) + 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) } } } @@ -55,6 +51,7 @@ func TestCommands(t *testing.T) { // 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"}, @@ -63,12 +60,42 @@ func TestCommandProgram(t *testing.T) { {`""`, ""}, {"'' vim", ""}, } + const noProgram = "the editor command names no program" for _, tt := range tests { - if got := editor.Program(tt.command, "/a", 1).Program; got != tt.want { - t.Errorf("Program(%q).Program = %q, want %q", tt.command, got, tt.want) + 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) + } } - if got := editor.Template(tt.command, "/a", 1).Program; got != tt.want { - t.Errorf("Template(%q).Program = %q, want %q", tt.command, got, 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/diff/match.go b/internal/search/match.go similarity index 89% rename from diff/match.go rename to internal/search/match.go index ef80d10..acf5e21 100644 --- a/diff/match.go +++ b/internal/search/match.go @@ -1,4 +1,4 @@ -package diff +package search import ( "strings" @@ -36,6 +36,8 @@ func foldEqual(a, b string) bool { 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' diff --git a/internal/search/search.go b/internal/search/search.go index 816320a..1eb10b3 100644 --- a/internal/search/search.go +++ b/internal/search/search.go @@ -1,5 +1,5 @@ -// Package search finds the files of the search screen. The limits come -// from herdr-reviewr. +// 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 ( @@ -8,11 +8,13 @@ import ( "github.com/sahilm/fuzzy" ) -// The search shows at most these numbers of results. A "… more" row -// tells that there are others. +// The limits of the results. A "… more" row tells that there are others. const ( - FileLimit = 50 - CodeLimit = 200 + // 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 @@ -44,3 +46,14 @@ func Files(paths, recent []string, q string, limit int) (hits []string, total in } 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 index 2151b68..2a6ca13 100644 --- a/internal/search/search_test.go +++ b/internal/search/search_test.go @@ -1,49 +1,88 @@ -package search +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", "", nil, 50, paths, 4}, - {"recent first", "", []string{"main.go", "b.go"}, 50, []string{"main.go", "b.go", "a.go", "c/d.go"}, 4}, - {"a gone recent pick", "", []string{"gone.go", "b.go"}, 50, []string{"b.go", "a.go", "c/d.go", "main.go"}, 4}, - {"empty query limit", "", []string{"main.go"}, 2, []string{"main.go", "a.go"}, 4}, - {"fuzzy", "dgo", nil, 50, []string{"c/d.go"}, 1}, - {"recent does not rank a query", "go", []string{"main.go"}, 2, []string{"a.go", "b.go"}, 4}, - {"no match", "zzz", nil, 50, nil, 0}, + {"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) { - got, total := Files(paths, tt.recent, tt.q, tt.limit) + 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", + t.Errorf("Files(%q, %q) = %q, %d, want %q, %d", tt.q, tt.recent, got, total, tt.want, tt.wantTotal) } }) } } -// TestFilesLimit checks that a query shows at most the limit, and counts -// all the matches. -func TestFilesLimit(t *testing.T) { - var paths []string - for i := range 60 { - paths = append(paths, fmt.Sprintf("f%02d.txt", i)) +// 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) } - got, total := Files(paths, nil, "f", FileLimit) - if len(got) != FileLimit || total != 60 { - t.Errorf("Files(f) = %d paths, total %d; want %d, 60", len(got), total, FileLimit) + 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 index 343a8fd..bb65853 100644 --- a/internal/termtext/termtext.go +++ b/internal/termtext/termtext.go @@ -8,9 +8,10 @@ import ( "unicode/utf8" ) -// Plain returns s without control characters and escape sequences. It keeps -// tabs and newlines. Use Plain on text from other people before you show or -// send the text. +// 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) { diff --git a/internal/termtext/termtext_test.go b/internal/termtext/termtext_test.go index e37ddd3..58b2e4e 100644 --- a/internal/termtext/termtext_test.go +++ b/internal/termtext/termtext_test.go @@ -1,16 +1,14 @@ package termtext_test import ( - "strings" "testing" - "unicode" - "unicode/utf8" "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"}, @@ -24,33 +22,3 @@ func TestQuote(t *testing.T) { } } } - -// 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/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 index 65af917..aff3c72 100644 --- a/internal/tree/tree.go +++ b/internal/tree/tree.go @@ -31,7 +31,7 @@ type Row struct { Expanded bool // Changed is true for a directory that holds a changed file. Changed bool - Ignored bool + Ignored bool // git ignores the path } // node is a directory of the tree. @@ -40,8 +40,12 @@ type node struct { 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 @@ -53,9 +57,18 @@ func Build(entries []Entry, toggled map[string]bool, expanded bool) []Row { for i, e := range entries { insert(root, e, i) } - var rows []Row - flatten(&rows, root, "", 0, toggled, expanded, entries) - return rows + 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. @@ -66,7 +79,7 @@ func insert(root *node, e Entry, i int) { for _, p := range parts { cur = cur.child(p) } - cur.ignored = e.Ignored + cur.ignored, cur.listed = e.Ignored, true return } if len(parts) == 0 { @@ -92,17 +105,16 @@ func (n *node) child(name string) *node { } // flatten adds the rows of the children of dir at path prefix. -func flatten(rows *[]Row, dir *node, prefix string, depth int, toggled map[string]bool, - expanded bool, entries []Entry) { +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. - *rows = append(*rows, fileRow(depth, display+"/"+fname, i, entries)) + b.rows = append(b.rows, b.fileRow(depth, display+"/"+fname, i)) continue } - open := expanded != toggled[path] - *rows = append(*rows, Row{ + open := b.expanded != b.toggled[path] + b.rows = append(b.rows, Row{ Depth: depth, Name: display, Dir: path, @@ -112,18 +124,19 @@ func flatten(rows *[]Row, dir *node, prefix string, depth int, toggled map[strin Ignored: n.ignored, }) if open { - flatten(rows, n, path, depth+1, toggled, expanded, entries) + b.flatten(n, path, depth+1) } } for _, name := range slices.Sorted(maps.Keys(dir.files)) { - *rows = append(*rows, fileRow(depth, name, dir.files[name], entries)) + 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 -// returns the joined name, the path, and the directory where it stops. +// 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 len(n.files) == 0 && len(n.dirs) == 1 { + 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 } @@ -131,10 +144,10 @@ func compress(name, path string, n *node) (string, string, *node) { return name, path, n } -// loneFile returns the name and the index of the file of n, if n has one -// file and no directories. +// 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 len(n.dirs) == 0 && len(n.files) == 1 { + if !n.listed && len(n.dirs) == 0 && len(n.files) == 1 { for name, i := range n.files { return name, i, true } @@ -142,10 +155,13 @@ func loneFile(n *node) (name string, i int, ok bool) { return "", 0, false } -func fileRow(depth int, name string, i int, entries []Entry) Row { - return Row{Depth: depth, Name: name, File: i, Ignored: entries[i].Ignored} +// 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 diff --git a/internal/tree/tree_test.go b/internal/tree/tree_test.go index 8f7b963..19b4bbe 100644 --- a/internal/tree/tree_test.go +++ b/internal/tree/tree_test.go @@ -1,31 +1,60 @@ -package tree +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) []Entry { - var es []Entry +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, Entry{Path: p, Changed: true}) + es = append(es, tree.Entry{Path: p}) } return es } -// shape returns the rows as "depth:dir:name" or "depth:file:name". -func shape(rows []Row) []string { +// 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" } - out = append(out, fmt.Sprintf("%d:%s:%s", r.Depth, kind, r.Name)) + 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 } @@ -39,10 +68,12 @@ func set(paths ...string) map[string]bool { return m } -func TestBuildShape(t *testing.T) { +// TestBuild checks the rows that Build makes. +func TestBuild(t *testing.T) { + t.Parallel() tests := []struct { name string - entries []Entry + entries []tree.Entry toggled map[string]bool expanded bool want []string @@ -51,7 +82,10 @@ func TestBuildShape(t *testing.T) { name: "directories before files", entries: changed("src/app.rs", "src/ui.rs", "Cargo.toml"), expanded: true, - want: []string{"0:dir:src", "1:file:app.rs", "1:file:ui.rs", "0:file:Cargo.toml"}, + 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", @@ -63,30 +97,82 @@ func TestBuildShape(t *testing.T) { 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", "1:file:one.rs", "1:file:two.rs"}, + 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"}, + want: []string{"0:dir:src +"}, }, { name: "all files starts collapsed", entries: changed("src/app.rs", "src/ui.rs"), - want: []string{"0:dir:src"}, + 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", "1:file:app.rs", "1:file:ui.rs"}, + 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) { - got := shape(Build(tt.entries, tt.toggled, tt.expanded)) + 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) } @@ -94,83 +180,23 @@ func TestBuildShape(t *testing.T) { } } -func TestBuildFileIndex(t *testing.T) { - rows := Build(changed("z.rs", "a.rs"), nil, true) - // The rows are in alphabetical order, so a.rs is first. - if rows[0].File != 1 || rows[1].File != 0 { - t.Errorf("file indices %d, %d; want 1, 0", rows[0].File, rows[1].File) - } - if rows[0].Dir != "" { - t.Errorf("a file row has the directory %q, want none", rows[0].Dir) - } -} - -func TestBuildChangeMark(t *testing.T) { - es := append(changed("src/ui.rs"), - Entry{Path: "src/app.rs"}, Entry{Path: "docs/a.md"}, Entry{Path: "docs/b.md"}) - rows := Build(es, nil, false) - if got, want := shape(rows), []string{"0:dir:docs", "0:dir:src"}; !slices.Equal(got, want) { - t.Fatalf("Build = %q, want %q", got, want) - } - if rows[0].Changed || !rows[1].Changed { - t.Errorf("changed docs %v, src %v; want false, true", rows[0].Changed, rows[1].Changed) - } - if rows[1].Dir != "src" || rows[1].File != -1 || rows[1].Expanded { - t.Errorf("src row %+v, want a collapsed directory", rows[1]) - } - - // The mark stays when the directory expands. - rows = Build(es, set("src"), false) - want := []string{"0:dir:docs", "0:dir:src", "1:file:app.rs", "1:file:ui.rs"} - if got := shape(rows); !slices.Equal(got, want) { - t.Fatalf("Build = %q, want %q", got, want) - } - if !rows[1].Changed || !rows[1].Expanded { - t.Errorf("expanded src: changed %v, expanded %v; want true, true", - rows[1].Changed, rows[1].Expanded) - } - - // A change deep in the tree marks each directory above it. - es = append(changed("a/b/deep.rs"), Entry{Path: "a/top.rs"}, Entry{Path: "a/b/other.rs"}) - rows = Build(es, set("a"), false) - want = []string{"0:dir:a", "1:dir:b", "1:file:top.rs"} - if got := shape(rows); !slices.Equal(got, want) { - t.Fatalf("Build = %q, want %q", got, want) - } - if !rows[0].Changed || !rows[1].Changed { - t.Errorf("changed a %v, a/b %v; want true, true", rows[0].Changed, rows[1].Changed) - } - if rows[1].Dir != "a/b" { - t.Errorf("the path of b is %q, want a/b", rows[1].Dir) - } -} - -func TestBuildIgnored(t *testing.T) { - rows := Build([]Entry{{Path: "target", Ignored: true, Dir: true}}, nil, false) - if len(rows) != 1 { - t.Fatalf("Build = %q, want one row", shape(rows)) - } - if r := rows[0]; !r.Ignored || r.File != -1 || r.Expanded || r.Changed { - t.Errorf("ignored directory %+v, want an ignored, collapsed directory", r) - } - - // The entries of an expanded ignored directory show under it. - es := []Entry{ - {Path: "target", Ignored: true, Dir: true}, - {Path: "target/a.o", Ignored: true}, - {Path: "target/b.o", Ignored: true}, - } - rows = Build(es, set("target"), false) - want := []string{"0:dir:target", "1:file:a.o", "1:file:b.o"} - if got := shape(rows); !slices.Equal(got, want) { - t.Fatalf("Build = %q, want %q", got, want) - } - if !rows[0].Ignored || !rows[1].Ignored { - t.Error("the rows under an ignored directory are not ignored, want ignored") +// 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}) } - - rows = Build([]Entry{{Path: "build.log", Ignored: true}}, nil, false) - if !rows[0].Ignored || rows[0].File != 0 { - t.Errorf("ignored file %+v, want an ignored file row", rows[0]) + // 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 index e550e47..d925308 100644 --- a/internal/turn/turn.go +++ b/internal/turn/turn.go @@ -1,66 +1,66 @@ -// Package turn finds the turns of the agents in a repository. A turn -// starts when the repository goes from rest to work. It ends when the -// repository rests again. The rules come from herdr-reviewr. +// 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 "github.com/rednafi/rexdiff/rex" +import "slices" -// State is the work state of a repository. Fold makes it from the states +// state is the work state of a repository. fold makes it from the states // of all the agents in the repository. -type State int +type state int const ( - // Resting tells that each agent rests, or that there is no agent. - Resting State = iota - // Working tells that at least one agent works. - Working - // Held tells that no agent works, but one does not rest. For example, - // it waits at a prompt. A turn that is open stays open. - Held + // 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 ) -// resting tells if s is a state between turns. A blocked agent and an +// rests reports whether s is a state between turns. A blocked agent and an // unknown state are in the middle of a turn. -func resting(s rex.State) bool { return s == rex.StateIdle || s == rex.StateDone } +func rests(s string) bool { return s == "idle" || s == "done" } -// Fold returns the state of a repository whose agents are in the states +// fold returns the state of a repository whose agents are in the states // states. One working agent makes the repository work. -func Fold(states ...rex.State) State { - held := false - for _, s := range states { - if s == rex.StateWorking { - return Working - } - held = held || !resting(s) +func fold(states ...string) state { + if slices.Contains(states, "working") { + return stateWorking } - if held { - return Held + if slices.ContainsFunc(states, func(s string) bool { return !rests(s) }) { + return stateHeld } - return Resting + 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 tells if the last state is Resting. It is false at + // prevResting is true if the last state is resting. It is false at // first, so the first state starts no turn. prevResting bool - // worked tells if the repository works after its last rest. An end - // can come through Held, but a start cannot. Thus the two edges keep + // 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 next state. A turn starts when s is Working after -// Resting. A turn ends when s is Resting after work, also through Held. -func (t *Tracker) Observe(s State) (started, ended bool) { - started = s == Working && t.prevResting - ended = t.worked && s == Resting - t.prevResting = s == Resting +// 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 Working: + case stateWorking: t.worked = true - case Resting: + case stateResting: t.worked = false } return started, ended diff --git a/internal/turn/turn_test.go b/internal/turn/turn_test.go index 37df7c2..843757e 100644 --- a/internal/turn/turn_test.go +++ b/internal/turn/turn_test.go @@ -1,105 +1,130 @@ -package turn +package turn_test import ( "testing" - "github.com/rednafi/rexdiff/rex" + "github.com/rednafi/rexdiff/internal/turn" ) // The tests come from turn.rs of herdr-reviewr. -// TestFold checks how the states of the agents fold into one state. +// 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 []rex.State - want State + 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, Resting}, - {"idle and working", []rex.State{rex.StateIdle, rex.StateWorking}, Working}, - {"blocked and working", []rex.State{rex.StateBlocked, rex.StateWorking}, Working}, - {"idle and done", []rex.State{rex.StateIdle, rex.StateDone}, Resting}, - {"blocked and idle", []rex.State{rex.StateBlocked, rex.StateIdle}, Held}, + {"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", []rex.State{"compacting"}, Held}, + {"unknown", []string{"compacting"}, false, false}, } for _, tt := range tests { - if got := Fold(tt.states...); got != tt.want { - t.Errorf("%s: Fold(%q) = %v, want %v", tt.name, tt.states, got, tt.want) - } + 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) + } + }) } } -// step is one state for a Tracker and the edges that it gives. -type step struct { - s State - started, ended bool -} - -// run gives the states of steps to a new Tracker, and checks the edges. -func run(t *testing.T, steps ...step) { - t.Helper() - var tr Tracker - for i, st := range steps { - started, ended := tr.Observe(st.s) - if started != st.started || ended != st.ended { - t.Errorf("step %d (%v): started %v, ended %v; want %v, %v", - i, st.s, started, ended, st.started, st.ended) - } +// 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}, + }}, } -} - -func TestEmptyRepoStartsOnFirstWorker(t *testing.T) { - run(t, step{Fold(), false, false}, step{Fold(rex.StateWorking), true, false}) -} - -func TestStartAfterRest(t *testing.T) { - // The first state never starts a turn. - run(t, step{Resting, false, false}, step{Working, true, false}) -} - -func TestHeldBackToWorkContinues(t *testing.T) { - // A prompt in the middle of a turn does not start another turn. - run(t, - step{Resting, false, false}, - step{Working, true, false}, - step{Held, false, false}, - step{Working, false, false}, - ) -} - -func TestEndOnlyAfterWork(t *testing.T) { - run(t, - step{Resting, false, false}, // no work before, so no end - step{Working, true, false}, // a start is never an end - step{Held, false, false}, // a pause, not an end - step{Working, false, false}, - step{Resting, false, true}, - ) -} - -func TestEndThroughHeld(t *testing.T) { - // The prompt keeps the turn open. A rest after it ends the turn once. - run(t, - step{Resting, false, false}, - step{Working, true, false}, - step{Held, false, false}, - step{Resting, false, true}, - step{Resting, false, false}, - ) -} - -func TestLoneFirstWorkingStartsNothing(t *testing.T) { - // The start of this turn is not seen. - run(t, step{Working, false, false}) -} - -func TestGap(t *testing.T) { - var tr Tracker - tr.Observe(Resting) - tr.Gap() - if started, _ := tr.Observe(Working); started { - t.Error("the first state after a gap starts a turn") + 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 bb0f60b..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,52 +18,65 @@ 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, 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 -// returns the rex of the Rex server app on macOS, if it is there. -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 "" } + // 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 "" @@ -69,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 @@ -79,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 @@ -119,21 +127,25 @@ 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 @@ -150,39 +162,40 @@ 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 holds the terminal, for // example an agent that runs in a shell. The child is the shell. @@ -204,7 +217,7 @@ type Agent struct { 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. @@ -212,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 @@ -236,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() { @@ -244,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, @@ -254,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) @@ -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 // 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". diff --git a/rex/rex_test.go b/rex/rex_test.go index 1e4ec55..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,25 +99,25 @@ 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 got:\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" { @@ -125,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("calls got:\n%s\nwant a call %s", calls, want) + t.Errorf("calls = \n%s\nwant a call:\n%s", calls, want) } } } @@ -149,111 +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 reports 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 = %v, want context.Canceled", err) - } - if !slices.Equal(got, want) { - t.Errorf("events got:\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. 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()) + 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. 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/anchor_test.go b/tui/anchor_test.go deleted file mode 100644 index fdd648c..0000000 --- a/tui/anchor_test.go +++ /dev/null @@ -1,122 +0,0 @@ -package tui - -import ( - "os" - "path/filepath" - "strings" - "testing" - - "github.com/rednafi/rexdiff/diff" -) - -// TestCommentKeepsLine checks that a comment stays on its line numbers -// when lines come above it, as in herdr-reviewr. -func TestCommentKeepsLine(t *testing.T) { - m := commented(t) - c := m.comments.All()[0] - start, end := c.Start, c.End - write(t, m.repo.Root(), "a.go", - "// one\n// two\npackage a\n\nfunc A() int {\n\tx := 2\n\treturn x\n}\n") - m.reload() - if c.Start != start || c.End != end { - t.Errorf("after two lines above: lines %d-%d, want %d-%d", c.Start, c.End, start, end) - } - if m.comments.At(m.anchor(), "a.go", c.Side, start) != c { - t.Errorf("no comment at line %d after the edit, want it", start) - } - if strings.Contains(view(m), "(stale)") { - t.Errorf("the diff shows (stale):\n%s", view(m)) - } -} - -// TestStaleInList checks that a comment whose file leaves the changes -// shows "(stale)" in the comment list only. -func TestStaleInList(t *testing.T) { - m := commented(t) - git(t, m.repo.Root(), "checkout", "--", "a.go") - m.reload() - if shows(m, "(stale)") { - t.Errorf("the diff shows (stale):\n%s", view(m)) - } - if m = press(t, m, "l"); !shows(m, "a.go:4", "(stale)") { - t.Errorf("l after a revert got:\n%s\nwant the comment marked (stale)", view(m)) - } -} - -// TestFileCommentView checks that a comment on the Files tab shows only -// there, and is stale only when its file is gone. -func TestFileCommentView(t *testing.T) { - m, dir := newTestModel(t) - m = press(t, m, "2", "j", "c", "f", "i", "l", "e", "enter") - if m.comments.Len() != 1 || !shows(m, "💬 file") { - t.Fatalf("a comment in Files: %d comments, view:\n%s", m.comments.Len(), view(m)) - } - if c := m.comments.All()[0]; c.Anchor != (diff.Anchor{}) { - t.Errorf("anchor = %+v, want a file view on the working tree", c.Anchor) - } - if m = press(t, m, "1"); shows(m, "💬 file") || shows(m, "●1") { - t.Errorf("the Changes tab shows the Files comment:\n%s", view(m)) - } - if m = press(t, m, "l"); shows(m, "(stale)") { - t.Errorf("the list marks the comment (stale):\n%s", view(m)) - } - m = press(t, m, "esc") - if err := os.Remove(filepath.Join(dir, "a.go")); err != nil { - t.Fatal(err) - } - if m = press(t, m, "l"); !shows(m, "(stale)") { - t.Errorf("l after a delete got:\n%s\nwant (stale)", view(m)) - } -} - -// TestCommitsCommentHidden checks that a comment on a commit pick shows -// only at that pick, and that the comment list takes no edit of it in -// another scope. -func TestCommitsCommentHidden(t *testing.T) { - m, _ := newTestModel(t) - m = press(t, m, "G", "enter") // the newest commit adds b.go - if m.scope != scopeCommits { - t.Fatalf("scope = %s, want commits", m.scope) - } - m = press(t, m, "]", "c", "p", "i", "c", "k", "enter") - if m.comments.Len() != 1 || !shows(m, "💬 pick") { - t.Fatalf("a comment on the pick: %d comments, view:\n%s", m.comments.Len(), view(m)) - } - // The branch scope also changes b.go. - m = press(t, m, "b") - if i := m.fileIndex("b.go"); i < 0 { - t.Fatalf("branch scope files %v, want b.go", paths(m)) - } else { - m.pickFile(i) - } - if shows(m, "💬 pick") { - t.Errorf("the branch scope shows the comment of the pick:\n%s", view(m)) - } - m = press(t, m, "l") - if shows(m, "e edit") { - t.Errorf("the list offers an edit of another diff:\n%s", view(m)) - } - if m = press(t, m, "e"); m.mode != modeOverlay { - t.Errorf("e on a comment of another diff: mode %v, want the list open", m.mode) - } - m = press(t, m, "esc", "g") - if i := m.fileIndex("b.go"); i >= 0 { - m.pickFile(i) - } - if !shows(m, "💬 pick") { - t.Errorf("back on the pick got:\n%s\nwant the comment", view(m)) - } - if m = press(t, m, "l"); !shows(m, "e edit") { - t.Errorf("the list at the pick got:\n%s\nwant e edit", view(m)) - } -} - -// fileIndex returns the index of path in the Changes tab, or -1. -func (m *model) fileIndex(path string) int { - for i, f := range m.files { - if f.Path == path { - return i - } - } - return -1 -} 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 4806a59..0000000 --- a/tui/bench_test.go +++ /dev/null @@ -1,97 +0,0 @@ -package tui - -import ( - "fmt" - "slices" - "strings" - "testing" - - tea "charm.land/bubbletea/v2" - - "github.com/rednafi/rexdiff/diff" - "github.com/rednafi/rexdiff/vcs" -) - -// 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 { - m = step(b, m, down) - _ = m.View() - } - } -} - -// BenchmarkScroll scrolls a large change sideways. It scrolls in the Changes -// tab with the folds closed and open, and in the Files tab. -// -// go test ./tui -run '^$' -bench Scroll -func BenchmarkScroll(b *testing.B) { - for _, name := range []string{"folded", "open", "file"} { - b.Run(name, func(b *testing.B) { - m := bigChange(b) - switch name { - case "open": - for { - i := slices.IndexFunc(m.changes.rows, func(r diff.Row) bool { return r.Fold.Lines > 0 }) - if i < 0 { - break - } - m.expandFold(m.changes.rows[i].Fold) - } - case "file": - m.setTab(tabFiles) - m.pickFile(slices.IndexFunc(m.allFiles, func(e vcs.Entry) bool { return e.Path == "big.go" })) - } - right := tea.KeyPressMsg{Code: tea.KeyRight} - left := tea.KeyPressMsg{Code: tea.KeyLeft} - for b.Loop() { - m = step(b, m, right) - _ = m.View() - m = step(b, m, left) - _ = m.View() - } - }) - } -} - -// bigChange returns a model that shows a change to big.go, a file of 5,000 -// long lines. The change edits each 100th line. -func bigChange(b *testing.B) model { - m, dir := newTestModel(b) - var old, cur strings.Builder - for i := range 5000 { - l := fmt.Sprintf("\tx%d := compute(%q, %d) // a long line with words\n", i, strings.Repeat("a", i%40), i*7) - old.WriteString(l) - if i%100 == 0 { - l = "\ty := 1\n" - } - cur.WriteString(l) - } - write(b, dir, "big.go", "package a\nfunc f() {\n"+old.String()+"}\n") - git(b, dir, "add", ".") - git(b, dir, "commit", "-qm", "big") - write(b, dir, "big.go", "package a\nfunc f() {\n"+cur.String()+"}\n") - m.reload() - m.pickFile(slices.IndexFunc(m.files, func(f changed) bool { return f.Path == "big.go" })) - return m -} diff --git a/tui/click_test.go b/tui/click_test.go deleted file mode 100644 index 7a19332..0000000 --- a/tui/click_test.go +++ /dev/null @@ -1,105 +0,0 @@ -package tui - -import ( - "os" - "slices" - "strings" - "testing" - "time" - - tea "charm.land/bubbletea/v2" - "github.com/charmbracelet/x/ansi" -) - -// codeCell returns the screen cell of the first needle in the main pane. -func codeCell(t *testing.T, m model, needle string) (x, y int) { - t.Helper() - p := m.cur() - for i, l := range p.lines { - plain := ansi.Strip(l) - if before, _, ok := strings.Cut(plain, needle); ok { - return m.mainX() + gutter + ansi.StringWidth(before), screenY(t, m, i) - } - } - t.Fatalf("no %q in the main pane", needle) - return 0, 0 -} - -// clicks clicks n times at x, y, and runs the command of the last click. -func clicks(t *testing.T, m model, n, x, y int) model { - t.Helper() - for range n - 1 { - m = click(t, m, x, y) - } - next, cmd := m.Update(tea.MouseClickMsg{Button: tea.MouseLeft, X: x, Y: y}) - if m = *next.(*model); cmd != nil { - m, _ = run(t, m, cmd) - } - return step(t, m, tea.MouseReleaseMsg{Button: tea.MouseLeft, X: x, Y: y}) -} - -// TestMultiClickCopies checks that a double click copies the word, a -// triple click copies the source line, and a double click on the file list -// copies the path. -func TestMultiClickCopies(t *testing.T) { - tests := []struct { - name, needle string - n int - want string - }{ - {"double", "return", 2, "return"}, - {"double on one letter", "x := 2", 2, "x"}, - {"triple", "return x", 3, "\treturn x"}, - {"four", "return x", 4, "\treturn x"}, - {"double on a symbol", ":= 2", 2, ""}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - out := fakeClipboard(t, false) - m, _ := newTestModel(t) - x, y := codeCell(t, m, tt.needle) - m = clicks(t, m, tt.n, x, y) - got, _ := os.ReadFile(out) - if string(got) != tt.want { - t.Errorf("clipboard %q, want %q", got, tt.want) - } - if tt.want != "" && m.status != "copied "+plural(len([]rune(tt.want)), "char") { - t.Errorf("status %q, want the count of chars", m.status) - } - }) - } - - t.Run("path", func(t *testing.T) { - out := fakeClipboard(t, false) - m, _ := newTestModel(t) - y := m.listY() + 1 + slices.Index(rowNames(m), "a.go") - m = clicks(t, m, 2, m.listX()+3, y) - if got, _ := os.ReadFile(out); string(got) != "a.go" || m.status != "copied 4 chars" { - t.Errorf("clipboard %q, status %q; want a.go, copied 4 chars", got, m.status) - } - }) -} - -// TestClickChain checks that the clicks count only on the same cell and -// within the window. -func TestClickChain(t *testing.T) { - m, _ := newTestModel(t) - at := tea.Mouse{X: 40, Y: 10} - for i, want := range []int{1, 2, 3, 3} { - if n := m.countClick(at); n != want { - t.Errorf("press %d: count %d, want %d", i+1, n, want) - } - } - if n := m.countClick(tea.Mouse{X: 41, Y: 10}); n != 1 { - t.Errorf("another cell: count %d, want 1", n) - } - m.click.at = time.Now().Add(-clickWindow) - if n := m.countClick(tea.Mouse{X: 41, Y: 10}); n != 1 { - t.Errorf("after the window: count %d, want 1", n) - } - m.countClick(at) - m = press(t, m, "j") - if n := m.countClick(at); n != 1 { - t.Errorf("after a key: count %d, want 1", n) - } -} diff --git a/tui/comment.go b/tui/comment.go index 8c8c371..00e8aff 100644 --- a/tui/comment.go +++ b/tui/comment.go @@ -39,25 +39,28 @@ 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 } - // An unreadable file gives a comment with no code. - content, _ := m.repo.ReadFile(path) - 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 @@ -92,7 +95,7 @@ func (m *model) revCurrent(c *diff.Comment) bool { // stale if its file is gone. func (m *model) stale(c *diff.Comment) bool { if c.Anchor.InDiff { - return !slices.ContainsFunc(m.files, func(f changed) bool { return f.Path == c.Path }) + 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 @@ -105,7 +108,7 @@ func (m *model) commentAtCursor() *diff.Comment { if t == nil { return nil } - return m.comments.At(m.anchor(), p.path, t.side, t.line) + return m.commentOn(p.cur, t) } // selection returns the first and the last row of the visual selection. @@ -117,6 +120,20 @@ 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) { @@ -131,16 +148,16 @@ func (m *model) startComment(pref diff.Side) tea.Cmd { if t == nil { return nil } - 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), ) 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 @@ -153,27 +170,33 @@ func (m *model) startComment(pref diff.Side) tea.Cmd { // 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. + // comment, its ID, and its place in the list. d := m.draft text := strings.TrimSpace(m.editor.Value()) + edited := m.comments.Find(d.editID) switch { case text == "": - case d.editID > 0: - m.comments.Remove(d.editID) - c := d.Comment - c.Text = text - m.comments.Add(c) + case edited != nil: + edited.Text = text m.flash("comment updated") default: c := d.Comment @@ -189,26 +212,29 @@ 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. After an -// edit from the comment list, it opens the list again. +// 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) } } -// newLineNear returns the new line of row i. A row with no new line, such +// 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 newLineNear(rows []diff.Row, i int) int { +func nearNewLine(rows []diff.Row, i int) int { for _, r := range rows[i:] { if r.New > 0 { return r.New @@ -223,8 +249,12 @@ func newLineNear(rows []diff.Row, i int) int { } // 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, -1) } @@ -232,5 +262,5 @@ func (m *model) editOrOpen() tea.Cmd { if p.path == "" { return nil } - return m.openEditor(p.path, newLineNear(p.rows, p.cur)) + return m.openEditor(p.path, nearNewLine(p.rows, p.cur)) } diff --git a/tui/commentkeys_test.go b/tui/commentkeys_test.go deleted file mode 100644 index 0ae5447..0000000 --- a/tui/commentkeys_test.go +++ /dev/null @@ -1,157 +0,0 @@ -package tui - -import ( - "strings" - "testing" - - tea "charm.land/bubbletea/v2" -) - -// commented returns a model with the comment "hi" on the first change. -func commented(t *testing.T) model { - t.Helper() - m, _ := newTestModel(t) - m = press(t, m, "]", "c", "h", "i", "enter") - if m.comments.Len() != 1 { - t.Fatalf("got %d comments, want 1", m.comments.Len()) - } - return m -} - -// TestCommentAddsNew checks that c on a line with a comment adds a second -// comment, as in herdr-reviewr. -func TestCommentAddsNew(t *testing.T) { - m := press(t, commented(t), "c", "o", "k", "enter") - if m.comments.Len() != 2 || m.status != "comment added" { - t.Errorf("c on a comment: %d comments, status %q; want 2, \"comment added\"", - m.comments.Len(), m.status) - } -} - -// TestEditBlankKeeps checks that an edit with blank text cancels the edit -// and keeps the comment, as in herdr-reviewr. -func TestEditBlankKeeps(t *testing.T) { - m := press(t, commented(t), "e") - if m.mode != modeComment || m.editor.Value() != "hi" { - t.Fatalf("e on a comment: mode %v, text %q; want the box with hi", - m.mode, m.editor.Value()) - } - m.status = "" - m.editor.SetValue(" ") - m = press(t, m, "enter") - cs := m.comments.All() - if len(cs) != 1 || cs[0].Text != "hi" || m.status != "" || m.mode != modeNormal { - t.Errorf("a blank edit: %d comments, status %q, mode %v; want hi kept, no status", - len(cs), m.status, m.mode) - } -} - -// TestDeleteNeedsDiffFocus checks that c and d act only with the focus on -// the diff. -func TestDeleteNeedsDiffFocus(t *testing.T) { - m := commented(t) - tab := tea.KeyPressMsg{Code: tea.KeyTab} - if m = step(t, m, tab); !m.focusList { - t.Fatal("tab: the focus stays on the diff, want the file list") - } - if m = press(t, m, "d"); m.comments.Len() != 1 { - t.Errorf("d with the list focus: %d comments, want 1", m.comments.Len()) - } - if m = press(t, m, "c"); m.mode != modeNormal { - t.Errorf("c with the list focus: mode %v, want no comment box", m.mode) - } - m = press(t, step(t, m, tab), "d") - if m.comments.Len() != 0 || m.status != "comment deleted" { - t.Errorf("d on the diff: %d comments, status %q; want 0, \"comment deleted\"", - m.comments.Len(), m.status) - } -} - -// TestCommentList checks the keys of the comment list. -func TestCommentList(t *testing.T) { - m := press(t, commented(t), "c", "o", "k", "enter", "l") - if !shows(m, "Comments (2)", "s send 2 · esc close · y copy · e edit · d delete") { - t.Errorf("the list got:\n%s\nwant the title and the keys", view(m)) - } - if m = press(t, m, "j"); m.overlay.cur != 1 { - t.Errorf("j in the list: row %d, want 1", m.overlay.cur) - } - if m = press(t, m, "k"); m.overlay.cur != 0 { - t.Errorf("k in the list: row %d, want 0", m.overlay.cur) - } - for _, k := range []string{"esc", "l"} { - if m := press(t, m, k); m.overlay != nil { - t.Errorf("%s in the list: the list stays open, want it closed", k) - } - } - if m := press(t, m, "s"); m.overlay == nil || !strings.Contains(m.status, "not inside Rex") { - t.Errorf("s in the list: overlay %v, status %q; want a send", m.overlay, m.status) - } - - // e edits the comment, and the list opens again after the save. - m = press(t, m, "j", "e") - if m.mode != modeComment || m.editor.Value() != "ok" { - t.Fatalf("e in the list: mode %v, text %q; want the box with ok", - m.mode, m.editor.Value()) - } - m = press(t, m, "!", "enter") - if m.overlay == nil || m.overlay.kind != overlayComments || m.overlay.cur != 1 { - t.Fatalf("after the edit: overlay %v, want the list at row 1", m.overlay) - } - if got := m.comments.All()[1].Text; got != "ok!" || m.status != "comment updated" { - t.Errorf("after the edit: text %q, status %q; want ok!, \"comment updated\"", - got, m.status) - } - - // d deletes, and the list closes with the last comment. - if m = press(t, m, "d"); m.comments.Len() != 1 || !shows(m, "Comments (1)") { - t.Errorf("d in the list: %d comments, view:\n%s\nwant 1", m.comments.Len(), view(m)) - } - if m = press(t, m, "d"); m.overlay != nil || m.comments.Len() != 0 { - t.Errorf("d on the last comment: overlay %v, want the list closed", m.overlay) - } -} - -// TestCommentListCopy checks that y in the comment list copies, clears the -// comments, and closes the list. -func TestCommentListCopy(t *testing.T) { - fakeClipboard(t, false) - m := press(t, commented(t), "l") - m = stepRun(t, m, tea.KeyPressMsg{Code: 'y', Text: "y"}) - if m.overlay != nil || m.comments.Len() != 0 || m.status != "copied 1 comment" { - t.Errorf("y in the list: overlay %v, %d comments, status %q; want a copy", - m.overlay, m.comments.Len(), m.status) - } -} - -// TestStepCommentInFile checks that n and N step only through the -// comments of the open file and wrap around in it, as in herdr-reviewr. -func TestStepCommentInFile(t *testing.T) { - m, _ := newTestModel(t) - m = press(t, m, "b") // the branch changes a.go and adds b.go - if len(m.files) != 2 || shownPath(m) != "a.go" { - t.Fatalf("branch: %d files, shows %s; want 2, a.go", len(m.files), shownPath(m)) - } - m = press(t, m, "]", "c", "x", "enter", "j", "j", "c", "y", "enter") - m = press(t, m, "f", "j", "c", "z", "enter", "F") - if m.comments.Len() != 3 || shownPath(m) != "a.go" { - t.Fatalf("%d comments, shows %s; want 3, a.go", m.comments.Len(), shownPath(m)) - } - var rows []int - for _, c := range m.comments.All() { - if c.Path == "a.go" { - rows = append(rows, m.cur().rowOf(c.Side, c.Start)) - } - } - m.cur().cur = 0 - for i, want := range []int{rows[0], rows[1], rows[0]} { - if m = press(t, m, "n"); m.cur().cur != want || shownPath(m) != "a.go" { - t.Errorf("n %d: row %d in %s, want row %d in a.go", i+1, m.cur().cur, shownPath(m), want) - } - } - for i, want := range []int{rows[1], rows[0], rows[1]} { - if m = press(t, m, "N"); m.cur().cur != want || shownPath(m) != "a.go" { - t.Errorf("N %d: row %d in %s, want row %d in a.go", i+1, m.cur().cur, shownPath(m), want) - } - } -} 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/commits_test.go b/tui/commits_test.go deleted file mode 100644 index d7f196a..0000000 --- a/tui/commits_test.go +++ /dev/null @@ -1,60 +0,0 @@ -package tui - -import ( - "testing" -) - -// TestCommitPicker checks the commit picker of herdr-reviewr: no filter, -// v anchors a range, and esc clears the anchor, then closes. -func TestCommitPicker(t *testing.T) { - m, dir := newTestModel(t) - for _, s := range []string{"c1", "c2", "c3"} { - git(t, dir, "commit", "-q", "--allow-empty", "-m", s) - } - m = press(t, m, "G") - if m.overlay == nil || m.overlay.kind != overlayCommits { - t.Fatal("G: no commit picker, want the picker") - } - if !shows(m, "commits · 4 over main", "enter open · esc cancel · j k move · v select") { - t.Errorf("the picker got:\n%s\nwant the title and the keys", view(m)) - } - // The picker has no filter, so a letter does not hide rows. - if m = press(t, m, "x"); len(m.overlay.shown) != 4 || m.overlay.input.Value() != "" { - t.Errorf("x in the picker: %d rows, input %q; want 4 rows, no input", - len(m.overlay.shown), m.overlay.input.Value()) - } - - m = press(t, m, "v", "j", "j") - if m.overlay.mark != 0 || m.overlay.cur != 2 || !shows(m, "enter open 3 · esc clear") { - t.Errorf("v j j: mark %d, cursor %d, view:\n%s\nwant mark 0, cursor 2", - m.overlay.mark, m.overlay.cur, view(m)) - } - if m = press(t, m, "esc"); m.overlay == nil || m.overlay.mark != -1 { - t.Fatalf("esc with an anchor: overlay %v, want it open with no anchor", m.overlay) - } - if m = press(t, m, "esc"); m.overlay != nil { - t.Fatal("esc with no anchor: the picker stays open, want it closed") - } - - // A range of two commits, then the picker opens on it. - m = press(t, m, "G", "j", "v", "k", "enter") - items := func(m model) []pickItem { return m.overlay.items } - m = press(t, m, "G") - if m.scope != scopeCommits || m.to != items(m)[0].id || m.from != items(m)[1].id { - t.Errorf("the pick: from %s to %s, want the two newest commits", m.from, m.to) - } - if m.overlay.cur != 0 || m.overlay.mark != 1 { - t.Errorf("G on a pick: cursor %d, mark %d; want 0, 1", m.overlay.cur, m.overlay.mark) - } -} - -// TestCommitPickerOnBase checks that the picker lists the last commits on -// the base branch. -func TestCommitPickerOnBase(t *testing.T) { - m, dir := newTestModel(t) - git(t, dir, "stash", "-q") - git(t, dir, "checkout", "-q", "main") - if m = press(t, m, "G"); !shows(m, "commits · last 50") || len(m.overlay.shown) != 1 { - t.Errorf("G on main: view:\n%s\nwant the last 50 commits", view(m)) - } -} diff --git a/tui/config.go b/tui/config.go index 1916a8f..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" @@ -74,9 +75,9 @@ var ( positions = []position{positionRight, positionBottom, positionLeft, positionTop} ) -// config holds the settings of rexdiff. The settings are at the top of -// rexdiff.lua, beside the configuration file (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"` @@ -92,12 +93,12 @@ type config struct { // top. FileList position `json:"file_list"` // FileListWidth is the width of the file list at a side, in columns. - // It is minListWidth or more. + // It is minListWidth or more, or 0 if the text does not set it. FileListWidth int `json:"file_list_width"` // DiffSigns marks a changed line with + or -, in place of a bar. DiffSigns bool `json:"diff_signs"` - // FocusAgent tells if the focus moves to the agent after a send. Nil - // means true. + // 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. @@ -163,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. @@ -172,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, } } @@ -197,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) } @@ -210,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) } @@ -246,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 { @@ -259,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)) } @@ -304,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) } @@ -322,7 +356,7 @@ 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) } } @@ -357,11 +391,11 @@ func (c config) checkKeybindings() error { for _, key := range b.key.Keys() { switch a, ok := first[key]; { case ok && a == b.name: - return fmt.Errorf("invalid value for keybindings: `%s` is bound twice to `%s`", - herdrKeyName(key), 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: `%s` is bound to both `%s` and `%s`", - herdrKeyName(key), a, b.name) + return fmt.Errorf("invalid value for keybindings: %q is bound to both %q and %q", + herdrReviewrKeyName(key), a, b.name) } first[key] = b.name } @@ -375,12 +409,12 @@ 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 } } @@ -393,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 } @@ -418,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/copy_test.go b/tui/copy_test.go deleted file mode 100644 index fc7b000..0000000 --- a/tui/copy_test.go +++ /dev/null @@ -1,114 +0,0 @@ -package tui - -import ( - "os" - "path/filepath" - "strings" - "testing" - "unicode/utf8" - - tea "charm.land/bubbletea/v2" -) - -// fakeClipboard puts a pbcopy first on PATH. It writes its input to the -// file that fakeClipboard returns. With fail, it exits 1. -func fakeClipboard(t *testing.T, fail bool) string { - t.Helper() - dir := t.TempDir() - out := filepath.Join(dir, "out") - script := "#!/bin/sh\n/bin/cat > " + out + "\n" - if fail { - script = "#!/bin/sh\nexit 1\n" - } - if err := os.WriteFile(filepath.Join(dir, "pbcopy"), []byte(script), 0o755); err != nil { - t.Fatal(err) - } - t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) - return out -} - -// stepRun gives msg to m, then gives m the message of the command that -// m returns. -func stepRun(t *testing.T, m model, msg tea.Msg) model { - t.Helper() - next, cmd := m.Update(msg) - m, _ = run(t, *next.(*model), cmd) - return m -} - -// TestCopyClearsComments checks that y copies the review and clears the -// comments, as a send does. -func TestCopyClearsComments(t *testing.T) { - out := fakeClipboard(t, false) - m, _ := newTestModel(t) - m = press(t, m, "]", "c", "h", "i", "enter") - want := m.reviewText() - m = stepRun(t, m, tea.KeyPressMsg{Code: 'y', Text: "y"}) - if m.status != "copied 1 comment" || m.comments.Len() != 0 { - t.Errorf("after y: status %q, %d comments; want %q, 0", - m.status, m.comments.Len(), "copied 1 comment") - } - got, err := os.ReadFile(out) - if err != nil || string(got) != want { - t.Errorf("the clipboard got %q (%v), want %q", got, err, want) - } -} - -// TestCopyFails checks that a failed copy keeps the comments and shows -// why. -func TestCopyFails(t *testing.T) { - tests := []struct { - name string - setup func(t *testing.T) - want string - }{ - {"no tool", func(t *testing.T) { t.Setenv("PATH", t.TempDir()) }, - "copy failed: install wl-clipboard, xclip, or xsel"}, - {"exit 1", func(t *testing.T) { fakeClipboard(t, true) }, "copy failed"}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - m, _ := newTestModel(t) - m = press(t, m, "]", "c", "h", "i", "enter") - tt.setup(t) - m = stepRun(t, m, tea.KeyPressMsg{Code: 'y', Text: "y"}) - if m.status != tt.want || m.comments.Len() != 1 || m.sends != 0 { - t.Errorf("after y: status %q, %d comments, %d sends; want %q, 1, 0", - m.status, m.comments.Len(), m.sends, tt.want) - } - }) - } -} - -// TestCopyFromList checks that a copy closes the comment list when it -// clears the last comment. -func TestCopyFromList(t *testing.T) { - fakeClipboard(t, false) - m, _ := newTestModel(t) - m = press(t, m, "]", "c", "h", "i", "enter", "l") - if m.overlay == nil || m.overlay.kind != overlayComments { - t.Fatal("after l: no comment list, want the list") - } - m.onCopied(copiedMsg{ids: []int{m.comments.All()[0].ID}, done: "copied 1 comment"}) - if m.overlay != nil || m.mode != modeNormal { - t.Errorf("after the copy: overlay %v, want the list closed", m.overlay) - } -} - -// TestDragCopies checks that a drag on the code copies the lines through -// the clipboard tool, and shows the count of the characters. -func TestDragCopies(t *testing.T) { - out := fakeClipboard(t, false) - m, _ := newTestModel(t) - x := m.mainX() + m.w/2 - y0 := screenY(t, m, 0) - m = step(t, m, tea.MouseClickMsg{Button: tea.MouseLeft, X: x, Y: y0}) - m = step(t, m, tea.MouseMotionMsg{Button: tea.MouseLeft, X: x, Y: y0 + 1}) - m = stepRun(t, m, tea.MouseReleaseMsg{Button: tea.MouseLeft, X: x, Y: y0 + 1}) - got, err := os.ReadFile(out) - want := "copied " + plural(utf8.RuneCount(got), "char") - if err != nil || m.status != want || strings.Count(string(got), "\n") != 1 { - t.Errorf("after a drag: status %q, clipboard %q (%v); want %q and 2 lines", - m.status, got, err, want) - } -} diff --git a/tui/cross_test.go b/tui/cross_test.go deleted file mode 100644 index a6dec6f..0000000 --- a/tui/cross_test.go +++ /dev/null @@ -1,99 +0,0 @@ -package tui - -import ( - "testing" - - "github.com/rednafi/rexdiff/diff" -) - -// twoFiles returns a model whose Changes tab has a.go and b.go, each with -// one block of changes. The cursor is on the change of a.go. -func twoFiles(t *testing.T) model { - t.Helper() - m, dir := newTestModel(t) - write(t, dir, "b.go", "package b\n") - m.reload() - if len(m.files) != 2 { - t.Fatalf("got %d changed files, want 2", len(m.files)) - } - return press(t, m, "]") -} - -// onChange tells if the cursor of the Changes tab is on a changed row. -func onChange(m model) bool { - k := m.changes.rows[m.changes.cur].Kind - return k == diff.KindInsert || k == diff.KindDelete || k == diff.KindChange -} - -// TestHunkCrossing checks that ] past the last change arms a crossing, and -// a second ] crosses, as in herdr-reviewr. [ does the same backward. -func TestHunkCrossing(t *testing.T) { - m := twoFiles(t) - cur := m.changes.cur - m = press(t, m, "]") - if m.armed != 1 || m.changeSel != 0 || m.changes.cur != cur { - t.Fatalf("] at the last change: armed %d, file %d, cursor %d; want 1, 0, %d", - m.armed, m.changeSel, m.changes.cur, cur) - } - if !shows(m, "] next file") { - t.Errorf("armed: view got:\n%s\nwant the hint \"] next file\"", view(m)) - } - m = press(t, m, "]") - if m.armed != 0 || m.changeSel != 1 || !onChange(m) { - t.Fatalf("] again: armed %d, file %d; want a crossing to b.go on its change", - m.armed, m.changeSel) - } - - if m = press(t, m, "["); m.armed != -1 || m.changeSel != 1 || !shows(m, "[ prev file") { - t.Fatalf("[ at the first change: armed %d, file %d; want -1, 1", m.armed, m.changeSel) - } - if m = press(t, m, "["); m.changeSel != 0 || !onChange(m) { - t.Errorf("[ again: file %d, want a.go on its change", m.changeSel) - } -} - -// TestHunkCrossingDisarms checks that another key drops an armed crossing. -func TestHunkCrossingDisarms(t *testing.T) { - m := press(t, twoFiles(t), "]", "j") - if m.armed != 0 { - t.Fatalf("] then j: armed %d, want 0", m.armed) - } - if m = press(t, m, "k", "]"); m.armed != 1 || m.changeSel != 0 { - t.Errorf("] after j: armed %d, file %d; want an arm, not a crossing", - m.armed, m.changeSel) - } - // A [ after a ] arms backward, if there is a file that way. - if m = press(t, m, "["); m.armed != 0 { - t.Errorf("[ after ]: armed %d, want no arm with no file above", m.armed) - } - // A selection holds the cursor. - cur := m.changes.cur - if m = press(t, m, "v", "]", "]"); m.changeSel != 0 || m.changes.cur != cur || m.armed != 0 { - t.Errorf("] with a selection: file %d, cursor %d, armed %d; want no move", - m.changeSel, m.changes.cur, m.armed) - } -} - -// TestEscLadder checks that esc drops one layer with each press: the -// selection, the armed crossing, then the list of all keys. -func TestEscLadder(t *testing.T) { - m := press(t, twoFiles(t), "?", "]") - if !m.help.ShowAll || m.armed != 1 { - t.Fatalf("? ]: all keys %v, armed %d; want both", m.help.ShowAll, m.armed) - } - if m = press(t, m, "esc"); m.armed != 0 || !m.help.ShowAll { - t.Errorf("esc: armed %d, all keys %v; want 0, true", m.armed, m.help.ShowAll) - } - if m = press(t, m, "esc"); m.help.ShowAll { - t.Error("esc again: the list of all keys stays, want it closed") - } - - m = press(t, m, "?", "v") - if m = press(t, m, "esc"); m.changes.anchor != -1 || !m.help.ShowAll { - t.Errorf("esc with a selection: anchor %d, all keys %v; want -1, true", - m.changes.anchor, m.help.ShowAll) - } - if m = press(t, m, "esc"); m.help.ShowAll { - t.Error("esc again: the list of all keys stays, want it closed") - } -} diff --git a/tui/editor.go b/tui/editor.go index bc3bd35..6dbd3eb 100644 --- a/tui/editor.go +++ b/tui/editor.go @@ -2,7 +2,7 @@ package tui import ( "bytes" - "cmp" + "context" "errors" "fmt" "os" @@ -11,36 +11,33 @@ import ( "runtime" "strings" "syscall" + "unicode" tea "charm.land/bubbletea/v2" "github.com/rednafi/rexdiff/internal/editor" + "github.com/rednafi/rexdiff/internal/proc" ) // openEditor opens the file at path, at line. It uses the editor of the -// settings, else $VISUAL, $EDITOR, or core.editor. 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. +// 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) - var ed editor.Command - if m.cfg.Editor != "" { - ed = editor.Template(m.cfg.Editor, abs, line) - } else { - e := cmp.Or( - strings.TrimSpace(os.Getenv("VISUAL")), - strings.TrimSpace(os.Getenv("EDITOR")), - strings.TrimSpace(m.repo.Editor(m.ctx)), - ) - if e == "" { - m.flash("no editor: set `editor` in rexdiff.lua, $EDITOR, or git's core.editor") - return nil - } - ed = editor.Program(e, abs, line) + src := editor.Sources{ + Template: m.cfg.Editor, + Visual: proc.Getenv(m.env, "VISUAL"), + Editor: proc.Getenv(m.env, "EDITOR"), + } + if src.Template == "" { + src.Git = m.repo.Editor(m.ctx) } - if ed.Program == "" { - m.flash("the editor command names no program") + 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 @@ -49,23 +46,43 @@ func (m *model) openEditor(path string, line int) tea.Cmd { m.flash(path + " is gone") return nil } - if _, err := exec.LookPath(ed.Program); err != nil { - m.flash("editor not found: " + ed.Program) + 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 { - err := m.rex.Split(m.ctx, m.repo.Root(), "sh", "-c", ed.Script) - if err == nil { - m.flash("opened " + path) - return nil - } - m.log.Debug("editor split", "err", err) + 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} + } +} + +// 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 } - return tea.ExecProcess(exec.Command("sh", "-c", ed.Script), func(err error) tea.Msg { + m.log.Debug("editor split failed", "err", msg.err) + return m.runHere(msg.path, msg.script) +} + +// 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} }) } @@ -75,37 +92,49 @@ func (m *model) openEditor(path string, line int) tea.Cmd { // 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 { - cmd := exec.Command("sh", "-c", script) + wait, err := detach(m.env, "editor", "sh", "-c", script) + if err != nil { + m.flash("editor failed: " + err.Error()) + return nil + } + m.flash("opened " + path) + return wait +} + +// 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 { - m.flash("editor failed: " + err.Error()) - return nil + return nil, err } - m.flash("opened " + path) 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{window: true, err: err} - } + return detachedMsg{what: what, err: err} + }, nil } -// onEditorDone shows how the editor stops. A terminal editor that stops -// with an error code can still save the file. +// 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 !msg.window && isExit: - m.flash("editor exited with " + exit.Error()) + case isExit: + m.flash("editor stopped: " + exit.Error()) default: m.flash("editor failed: " + msg.err.Error()) } @@ -113,27 +142,90 @@ func (m *model) onEditorDone(msg editorDoneMsg) { } // 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}", editor.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" } - return start(opener, url) + 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 detach(env, "open", name, args...) } -// start starts a program and does not wait for it to stop. A goroutine -// waits for the program, so that the program 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 +} + +// 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 831a395..0000000 --- a/tui/editor_test.go +++ /dev/null @@ -1,227 +0,0 @@ -package tui - -import ( - "os" - "os/exec" - "path/filepath" - "strings" - "testing" - - tea "charm.land/bubbletea/v2" - - "github.com/rednafi/rexdiff/diff" - "github.com/rednafi/rexdiff/rex" -) - -// writeScript writes an executable sh script with body to dir/name, and -// returns its path. -func writeScript(t *testing.T, dir, name, body string) string { - t.Helper() - path := filepath.Join(dir, name) - if err := os.WriteFile(path, []byte("#!/bin/sh\n"+body), 0o755); err != nil { - t.Fatal(err) - } - return path -} - -// fakeRex makes m use a rex in a new directory that runs body. It returns -// the directory. -func fakeRex(t *testing.T, m *model, body string) string { - t.Helper() - dir := t.TempDir() - writeScript(t, dir, "rex", body) - t.Setenv("REX_SESSION", "S") - t.Setenv("REX_BLOCK", "B") - t.Setenv("REX_BIN_DIR", dir) - m.rex = rex.FromEnv() - return dir -} - -// logRex makes m use a rex that writes its arguments to a log. It returns -// the path of the log. -func logRex(t *testing.T, m *model) string { - t.Helper() - dir := fakeRex(t, m, `printf '%s\n' "$*" >> "$(dirname "$0")/rex.log"`+"\n") - return filepath.Join(dir, "rex.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 at the line -// in a new Rex block, and that e returns no command. -func TestTerminalEditorInRex(t *testing.T) { - bin := t.TempDir() - nvim := writeScript(t, bin, "nvim", "") - t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) - tests := []struct { - name, editor, env, want string - }{ - {"settings", nvim + " +{line} {file}", "", nvim + " +4 "}, - {"environment", "", "nvim", "nvim +4 "}, - } - 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 := logRex(t, &m) - m, cmd := pressE(t, m) - if cmd != nil { - t.Errorf("e returns a command %T, want none", cmd) - } - if !shows(m, "opened a.go") { - t.Errorf("view got:\n%s\nwant \"opened a.go\"", view(m)) - } - b, err := os.ReadFile(log) - if err != nil { - t.Fatal(err) - } - root := m.repo.Root() - want := "split -s S -b B --cwd " + root + " -- sh -c " + tt.want + - "'" + filepath.Join(root, "a.go") + "'" - 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. -func TestWindowEditor(t *testing.T) { - m, _ := newTestModel(t) - bin := t.TempDir() - out := filepath.Join(bin, "out") - code := writeScript(t, bin, "code", `{ echo "$*"; if (exec 3/dev/null; then echo tty; else echo no tty; fi; } > '`+out+"'\n") - m.cfg.Editor = code + " -g {file}:{line}" - log := logRex(t, &m) - m, cmd := pressE(t, m) - if cmd == nil { - t.Fatal("e returns no command, want a command") - } - if msg := cmd(); msg != nil { - t.Fatalf("the editor command = %v, want nil", 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.Errorf("rex log %s exists, want no Rex block for a window editor", - log) - } -} - -// TestEditorError checks that the footer tells why an editor fails. -func TestEditorError(t *testing.T) { - code := writeScript(t, t.TempDir(), "code", "echo 'code: no such file' >&2\nexit 3\n") - tests := []struct { - name, editor, path, want string - }{ - {"fails", code + " -g {file}", os.Getenv("PATH"), "editor failed: exit status 3: code: no such file"}, - // sh is not in PATH. - {"cannot start", code + " -g {file}", "", "editor failed: "}, - } - 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 { - m = step(t, m, cmd()) - } - if !shows(m, tt.want) { - t.Errorf("view got:\n%s\nwant %q in the footer", view(m), tt.want) - } - }) - } -} - -// TestEditorNotOpened checks the messages when rexdiff opens no editor. -func TestEditorNotOpened(t *testing.T) { - tests := []struct { - name, editor, env string - remove bool - want string - }{ - {"unset", "", "", false, "no editor: set `editor` in rexdiff.lua, $EDITOR, or git's core.editor"}, - {"no program", "'' {file}", "", false, "the editor command names no program"}, - {"no program env", "", `""`, false, "the editor command names no program"}, - {"file gone", "", "nvim", true, "a.go is gone"}, - {"not found", "", "no-such-editor-x", false, "editor not found: no-such-editor-x"}, - } - 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) - t.Setenv("GIT_CONFIG_GLOBAL", os.DevNull) - t.Setenv("GIT_CONFIG_NOSYSTEM", "1") - log := logRex(t, &m) - if tt.remove { - if err := os.Remove(filepath.Join(m.repo.Root(), "a.go")); err != nil { - t.Fatal(err) - } - } - m, cmd := pressE(t, m) - if cmd != nil { - t.Errorf("e returns a command %T, want none", cmd) - } - if !shows(m, tt.want) { - t.Errorf("view got:\n%s\nwant %q in the footer", view(m), tt.want) - } - if _, err := os.Stat(log); err == nil { - t.Errorf("rex log %s exists, want no Rex block", log) - } - }) - } -} - -// TestTerminalEditorDone checks the message after a terminal editor stops -// outside Rex. -func TestTerminalEditorDone(t *testing.T) { - m, _ := newTestModel(t) - m = step(t, m, editorDoneMsg{path: "a.go"}) - if !shows(m, "edited a.go") { - t.Errorf("view got:\n%s\nwant \"edited a.go\"", view(m)) - } - err := exec.Command("sh", "-c", "exit 2").Run() - m = step(t, m, editorDoneMsg{path: "a.go", err: err}) - if !shows(m, "editor exited with exit status 2") { - t.Errorf("view got:\n%s\nwant \"editor exited with exit status 2\"", view(m)) - } -} - -// TestNewLineNear checks the line that the editor opens for each kind of -// row. -func TestNewLineNear(t *testing.T) { - rows := []diff.Row{ - {Kind: diff.KindHunk}, - {Kind: diff.KindEqual, Old: 1, New: 1}, - {Kind: diff.KindDelete, Old: 2}, - {Kind: diff.KindInsert, New: 2}, - {Kind: diff.KindDelete, Old: 3}, - } - for i, want := range []int{1, 1, 2, 2, 2} { - if got := newLineNear(rows, i); got != want { - t.Errorf("newLineNear(row %d) = %d, want %d", i, got, want) - } - } - if got := newLineNear(rows[2:3], 0); got != 1 { - t.Errorf("newLineNear(a lone deleted line) = %d, want 1", got) - } -} diff --git a/tui/errors_test.go b/tui/errors_test.go deleted file mode 100644 index 1710c38..0000000 --- a/tui/errors_test.go +++ /dev/null @@ -1,94 +0,0 @@ -package tui - -import ( - "bytes" - "log/slog" - "strings" - "testing" - - "github.com/rednafi/rexdiff/rex" -) - -// noGit makes git not found for the rest of the test. -func noGit(t *testing.T) { - t.Helper() - t.Setenv("PATH", t.TempDir()) -} - -// TestPickerErrors checks that a picker shows the error of git and does -// not open. -func TestPickerErrors(t *testing.T) { - for _, k := range []string{"B", "G"} { - t.Run(k, func(t *testing.T) { - m, _ := newTestModel(t) - noGit(t) - m = press(t, m, k) - if m.overlay != nil { - t.Errorf("%s opens an overlay, want none", k) - } - if !shows(m, "git isn't installed") { - t.Errorf("view got:\n%s\nwant \"git isn't installed\"", view(m)) - } - }) - } -} - -// TestFilesRefreshError checks that the Files tab shows the error of -// git. -func TestFilesRefreshError(t *testing.T) { - m, _ := newTestModel(t) - noGit(t) - if m = press(t, m, "2"); !shows(m, "refresh failed: ", "git isn't installed") { - t.Errorf("view got:\n%s\nwant \"refresh failed: …git isn't installed\"", view(m)) - } -} - -// TestFilesRefreshKeepsList checks that a failed refresh keeps the list of -// files. -func TestFilesRefreshKeepsList(t *testing.T) { - m, _ := newTestModel(t) - m = press(t, m, "2") - n := len(m.allFiles) - noGit(t) - m.reloadFiles() - if len(m.allFiles) != n || n == 0 { - t.Errorf("after a failed refresh, %d files, want %d", len(m.allFiles), n) - } -} - -// TestSearchError checks that the search shows the error of the code -// search in place of the results. -func TestSearchError(t *testing.T) { - m, _ := newTestModel(t) - m = press(t, m, "/") - noGit(t) - m.overlay.input.SetValue("pac") - cmd := m.inputChanged() - if cmd == nil { - t.Fatal("typing in the search returns no command, want a grep") - } - m = step(t, m, cmd()) - if !shows(m, "git isn't installed") { - t.Errorf("view got:\n%s\nwant \"git isn't installed\"", view(m)) - } - if len(m.overlay.shown) != 0 { - t.Errorf("the search shows %d results under the error, want 0", len(m.overlay.shown)) - } -} - -// TestTurnSnapshotErrorLogs checks that a failed snapshot at the start of -// a turn goes to the log. -func TestTurnSnapshotErrorLogs(t *testing.T) { - m, dir := newTestModel(t) - var buf bytes.Buffer - m.log = slog.New(slog.NewTextHandler(&buf, nil)) - m = step(t, m, sample("block:agent", "pi", dir)) - noGit(t) - m = step(t, m, status("block:agent", "pi", rex.StateWorking)) - if m.turn != nil || m.pending != nil { - t.Errorf("turn = %+v, pending %+v, want none", m.turn, m.pending) - } - if !strings.Contains(buf.String(), "turn snapshot failed") { - t.Errorf("log got:\n%s\nwant \"turn snapshot failed\"", buf.String()) - } -} diff --git a/tui/find.go b/tui/find.go index 5ca3458..0228f88 100644 --- a/tui/find.go +++ b/tui/find.go @@ -1,11 +1,9 @@ package tui -// 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. - import ( "fmt" + "slices" + "strconv" "strings" "charm.land/bubbles/v2/textinput" @@ -13,8 +11,13 @@ import ( "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 @@ -35,17 +38,17 @@ func (m *model) findable() bool { } p := m.cur() for i := range p.rows { - if p.content(i) { + if p.hasContent(i) { return true } } return false } -// content reports whether row i of p shows text that a find can match. -// A note has no such text. A row of rendered markdown has no line, so -// its text is the rendered line. -func (p *pane) content(i int) bool { +// 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)) } @@ -93,18 +96,33 @@ func (m *model) updateFind(msg tea.KeyPressMsg) tea.Cmd { // paneSource returns the lines of the two sides of the file in the // current pane. -func (m *model) paneSource() (old, new []string) { +func (m *model) paneSource() (before, after []string) { switch m.tab { case tabChanges: - if f := m.changedFile(); f != nil { - return diff.Lines(f.Old), diff.Lines(f.New) + if f := m.selectedChange(); f != nil { + return m.findLines.of(f.Old, f.New) } case tabFiles: - return nil, diff.Lines(m.viewed) + 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. @@ -113,7 +131,7 @@ func (m *model) findHits(q string) []findHit { return nil } p := m.cur() - old, new := m.paneSource() + before, after := m.paneSource() line := func(ls []string, n int) string { if n >= 1 && n <= len(ls) { return ls[n-1] @@ -123,20 +141,27 @@ func (m *model) findHits(q string) []findHit { 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 diff.Match(line(new, n), q) { + if search.Match(line(after, n), q) { hits = append(hits, findHit{i, n}) } } case r.Old > 0 || r.New > 0: - if (r.New > 0 && diff.Match(line(new, r.New), q)) || - (r.Old > 0 && diff.Match(line(old, r.Old), q)) { + 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.content(i): - if diff.Match(ansi.Strip(p.lines[i]), q) { + case p.hasContent(i): + if search.Match(ansi.Strip(p.lines[i]), q) { hits = append(hits, findHit{i, 0}) } } @@ -185,27 +210,21 @@ func (m *model) stepFind(d int) { cursorToLine(p, diff.SideNew, h.line) } -// findRows returns the rows of the hits that show, for the marks in the -// gutter. -func (m *model) findRows() map[int]bool { +// 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 } - rows := map[int]bool{} - for _, h := range m.findHits(m.find.input.Value()) { - if h.line == 0 { - rows[h.row] = true - } - } - return rows + 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". -func (m *model) findBand() string { +// 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 q := m.find.input.Value(); q != "" { - hits := m.findHits(q) + if m.find.input.Value() != "" { before, on := m.findPlace(hits) switch { case len(hits) == 0: @@ -213,7 +232,7 @@ func (m *model) findBand() string { case on: count = fmt.Sprintf("%d/%d", before+1, len(hits)) default: - count = fmt.Sprint(len(hits)) + count = strconv.Itoa(len(hits)) } } w := max(m.w-ansi.StringWidth(count)-1, 1) diff --git a/tui/find_test.go b/tui/find_test.go deleted file mode 100644 index 96265fb..0000000 --- a/tui/find_test.go +++ /dev/null @@ -1,137 +0,0 @@ -package tui - -import ( - "strings" - "testing" - - tea "charm.land/bubbletea/v2" - - "github.com/rednafi/rexdiff/diff" -) - -// find opens the find band and types q. -func find(t *testing.T, m model, q string) model { - t.Helper() - m = step(t, m, tea.KeyPressMsg{Code: 'f', Mod: tea.ModCtrl}) - if m.mode != modeFind { - t.Fatalf("ctrl+f: mode %v, want the find band", m.mode) - } - for _, r := range q { - m = step(t, m, tea.KeyPressMsg{Code: r, Text: string(r)}) - } - return m -} - -// curRow returns the row at the cursor of the current pane. -func curRow(m model) diff.Row { - p := m.cur() - return p.rows[p.cur] -} - -// TestFindSmartCase checks that a query in lower case ignores case, and a -// query with an upper case letter does not. -func TestFindSmartCase(t *testing.T) { - m := newFoldModel(t) - for _, tt := range []struct { - q string - want int - }{ - {"LINE", 2}, // the new lines 10 and 30 - {"line 1", 12}, // lines 1 and 11 to 19, and both sides of 10 - {"Line", 0}, - } { - if got := len(m.findHits(tt.q)); got != tt.want { - t.Errorf("%d hits of %q, want %d", got, tt.q, tt.want) - } - } -} - -// TestFindOpensFold checks that a step to a hit in a fold opens the fold. -func TestFindOpensFold(t *testing.T) { - m := newFoldModel(t) - m = toTop(m) // the fold of lines 1 to 6 - m = find(t, m, "line 2") - m = press(t, m, "enter") - if r := curRow(m); r.New != 2 { - t.Errorf("enter: the cursor is on %+v, want line 2", r) - } - if f := m.changes.rows[0].Fold; f.Lines > 0 { - t.Errorf("the fold %+v stays closed, want it open", f) - } - if l := lastLine(m); !strings.HasSuffix(l, "1/11") { - t.Errorf("footer %q, want 1/11", l) - } -} - -// TestFindSkipsLineNumbers checks that a find matches the text of the -// lines, and not their numbers. -func TestFindSkipsLineNumbers(t *testing.T) { - m, _ := newTestModel(t) - if m = find(t, m, "5"); !strings.HasSuffix(lastLine(m), "no matches") { - t.Errorf("a find of a line number: footer %q, want no matches", lastLine(m)) - } - m = press(t, m, "enter") - if m.changes.cur != 0 { - t.Errorf("enter with no hit: cursor %d, want 0", m.changes.cur) - } -} - -// TestFindSteps checks that enter and down go to the next hit, up goes -// to the previous hit, and both wrap around. Esc drops the query. -func TestFindSteps(t *testing.T) { - m, _ := newTestModel(t) - m = toTop(m) - m = find(t, m, "return") - if l := lastLine(m); !strings.HasPrefix(l, "find return") || !strings.HasSuffix(l, " 2") { - t.Errorf("off a hit: footer %q, want the query and 2", l) - } - wantOld := func(m model, old int, count string) { - t.Helper() - if r := curRow(m); (old > 0 && r.Old != old) || (old == 0 && r.New != 5) { - t.Errorf("the cursor is on %+v, want old line %d or new line 5", r, old) - } - if l := lastLine(m); !strings.HasSuffix(l, count) { - t.Errorf("footer %q, want %s", l, count) - } - } - m = press(t, m, "enter") - wantOld(m, 4, "1/2") - m = step(t, m, tea.KeyPressMsg{Code: tea.KeyDown}) - wantOld(m, 0, "2/2") - m = press(t, m, "enter") - wantOld(m, 4, "1/2") - m = step(t, m, tea.KeyPressMsg{Code: tea.KeyUp}) - wantOld(m, 0, "2/2") - - if m = press(t, m, "esc"); m.mode != modeNormal { - t.Fatalf("esc: mode %v, want normal", m.mode) - } - if m = find(t, m, ""); m.find.input.Value() != "" { - t.Errorf("ctrl+f after esc: query %q, want none", m.find.input.Value()) - } -} - -// TestFindKeepsCommentKeys checks that n types into the band, and that n -// goes to the next comment after the band closes. -func TestFindKeepsCommentKeys(t *testing.T) { - m := commented(t) - line := curRow(m).New - m = toTop(m) - if m = find(t, m, "n"); m.find.input.Value() != "n" || m.mode != modeFind { - t.Fatalf("n in the band: query %q, mode %v; want n typed", m.find.input.Value(), m.mode) - } - m = press(t, m, "esc") - m = toTop(m) - if m = press(t, m, "n"); curRow(m).New != line { - t.Errorf("n: the cursor is on %+v, want the comment on line %d", curRow(m), line) - } -} - -// TestFindNotOnPR checks that the PR tab has no find band. -func TestFindNotOnPR(t *testing.T) { - m, _ := newTestModel(t) - m = press(t, m, "3") - if m = step(t, m, tea.KeyPressMsg{Code: 'f', Mod: tea.ModCtrl}); m.mode != modeNormal { - t.Errorf("ctrl+f on the PR tab: mode %v, want normal", m.mode) - } -} diff --git a/tui/fold_test.go b/tui/fold_test.go deleted file mode 100644 index 6095d77..0000000 --- a/tui/fold_test.go +++ /dev/null @@ -1,240 +0,0 @@ -package tui - -import ( - "fmt" - "os" - "path/filepath" - "slices" - "strings" - "testing" - - tea "charm.land/bubbletea/v2" - - "github.com/rednafi/rexdiff/diff" - "github.com/rednafi/rexdiff/vcs" -) - -// newFoldModel returns a model that shows f.txt, a file of 40 lines with -// changes at lines 10 and 30. The diff folds lines 1 to 6, 14 to 26, and -// 34 to 40. -func newFoldModel(t *testing.T) model { - t.Helper() - dir := t.TempDir() - lines := func(upper ...int) string { - var b strings.Builder - for i := 1; i <= 40; i++ { - word := "line" - if slices.Contains(upper, i) { - word = "LINE" - } - fmt.Fprintf(&b, "%s %d\n", word, i) - } - return b.String() - } - git(t, dir, "init", "-q", "-b", "main") - write(t, dir, "f.txt", lines()) - git(t, dir, "add", ".") - git(t, dir, "commit", "-qm", "init") - write(t, dir, "f.txt", lines(10, 30)) - repo, err := vcs.Open(t.Context(), dir) - if err != nil { - t.Fatal(err) - } - m := newModel(t.Context(), Options{Repo: repo, Layout: LayoutUnified}, defaultConfig()) - m.reload() - return step(t, m, tea.WindowSizeMsg{Width: 100, Height: 70}) -} - -// foldsOf returns the number of lines of each fold that hides in p. -func foldsOf(p *pane) []int { - var out []int - for _, r := range p.rows { - if r.Fold.Lines > 0 { - out = append(out, r.Fold.Lines) - } - } - return out -} - -// TestExpandFold checks that the right arrow key shows the lines of a fold. -// On a line of code, the key scrolls sideways. -func TestExpandFold(t *testing.T) { - m := newFoldModel(t) - p := &m.changes - if got := foldsOf(p); !slices.Equal(got, []int{6, 13, 7}) { - t.Fatalf("folds = %v, want [6 13 7]", got) - } - if !shows(m, "⋯ 13 unmodified lines", "→ show the lines") { - t.Fatalf("view got:\n%s\nwant the fold and its key", view(m)) - } - m = press(t, m, "]") // the first change, line 10 - if shows(m, "→ show the lines") { - t.Error("on a line of code: the footer shows the → hint, want no hint") - } - // Go to the second change, then up to its header. - m = press(t, m, "]", "k", "k", "k", "k") - if r := m.changes.rows[m.changes.cur]; r.Fold.Lines != 13 || !shows(m, "→ show the lines") { - t.Fatalf("the cursor is on %+v, want the middle fold", r) - } - m = step(t, m, tea.KeyPressMsg{Code: tea.KeyRight}) - p = &m.changes - if got := foldsOf(p); !slices.Equal(got, []int{6, 7}) { - t.Errorf("after →, folds = %v, want [6 7]", got) - } - if r := p.rows[p.cur]; r.New != 14 { - t.Errorf("after →, the cursor is on %+v, want line 14", r) - } - for n := 14; n <= 26; n++ { - if p.rowOf(diff.SideNew, n) < 0 { - t.Errorf("line %d is hidden, want it shown", n) - } - } - // On a line of code, the right arrow key scrolls. - m = step(t, m, tea.KeyPressMsg{Code: tea.KeyRight}) - if m.changes.xoff == 0 || !slices.Equal(foldsOf(&m.changes), []int{6, 7}) { - t.Errorf("→ on code: xoff %d, folds %v; want a scroll and no change", - m.changes.xoff, foldsOf(&m.changes)) - } -} - -// TestGotoLineInFold checks that : with a line in a fold shows the fold and -// moves the cursor to the line. -func TestGotoLineInFold(t *testing.T) { - m := newFoldModel(t) - m = press(t, m, ":", "2", "0", "enter") - p := &m.changes - if r := p.rows[p.cur]; r.New != 20 { - t.Errorf("the cursor is on %+v, want line 20", r) - } - if got := foldsOf(p); !slices.Equal(got, []int{6, 7}) { - t.Errorf("folds = %v, want [6 7]", got) - } -} - -// TestExpandKeepsPlaces checks the cursor and the selection after a fold -// shows. -func TestExpandKeepsPlaces(t *testing.T) { - m := newFoldModel(t) - m.changes.cur = m.changes.rowOf(diff.SideNew, 29) - m = press(t, m, "v") // the selection starts on line 29 - m.changes.cur = 0 // the fold at the top - m = step(t, m, tea.KeyPressMsg{Code: tea.KeyRight}) - p := &m.changes - if r := p.rows[p.cur]; r.New != 1 { - t.Errorf("the cursor is on %+v, want line 1", r) - } - if r := p.rows[p.anchor]; r.New != 29 { - t.Errorf("the selection starts on %+v, want line 29", r) - } -} - -// TestFoldStaysOpen checks that an expanded fold stays open after edits of -// the agent and closes after a commit. -func TestFoldStaysOpen(t *testing.T) { - m := newFoldModel(t) - m.changes.cur = slices.IndexFunc(m.changes.rows, func(r diff.Row) bool { return r.Fold.Lines == 13 }) - m = step(t, m, tea.KeyPressMsg{Code: tea.KeyRight}) - dir := m.repo.Root() - edit := func(f func(string) string) { - b, err := os.ReadFile(filepath.Join(dir, "f.txt")) - if err != nil { - t.Fatal(err) - } - write(t, dir, "f.txt", f(string(b))) - m.reload() - } - edit(func(s string) string { return "new\n" + s }) - if got := foldsOf(&m.changes); !slices.Equal(got, []int{3, 7}) { - t.Errorf("after a line above, folds = %v, want [3 7]", got) - } - edit(func(s string) string { return strings.Replace(s, "line 20\n", "LINE 20\n", 1) }) - if got := foldsOf(&m.changes); !slices.Equal(got, []int{3, 7}) { - t.Errorf("after a change in the fold, folds = %v, want [3 7]", got) - } - // An undo of the change at line 31 makes one fold of old lines 14 to - // 40. It overlaps the open range, so it shows. - edit(func(s string) string { return strings.Replace(s, "LINE 30\n", "line 30\n", 1) }) - if got := foldsOf(&m.changes); !slices.Equal(got, []int{3}) { - t.Errorf("after an undo next to the fold, folds = %v, want [3]", got) - } - // After the commit, the file has 41 lines. Changes at lines 12 and 26 - // make a fold of old lines 16 to 22, inside the open range. - git(t, dir, "commit", "-qam", "agent") - edit(func(s string) string { - lines := strings.Split(s, "\n") - lines[11], lines[25] = "changed", "changed" - return strings.Join(lines, "\n") - }) - if got := foldsOf(&m.changes); !slices.Equal(got, []int{8, 7, 12}) { - t.Errorf("after a commit, folds = %v, want [8 7 12]", got) - } -} - -// TestFoldsSurviveRestart checks that the expanded folds and the cursor -// line come back after a restart for an update. -func TestFoldsSurviveRestart(t *testing.T) { - m := newFoldModel(t) - m.changes.cur = slices.IndexFunc(m.changes.rows, func(r diff.Row) bool { return r.Fold.Lines == 13 }) - m = step(t, m, tea.KeyPressMsg{Code: tea.KeyRight}) - m.changes.cur = m.changes.rowOf(diff.SideNew, 20) - path, err := m.saveState() - if err != nil { - t.Fatal(err) - } - saved, err := loadState(path) - if err != nil { - t.Fatal(err) - } - m2 := newModel(t.Context(), Options{Repo: m.repo, Layout: LayoutUnified}, defaultConfig()) - m2.restoreSettings(saved) - m2.reload() - m2.restorePlaces(saved) - if got := foldsOf(&m2.changes); !slices.Equal(got, []int{6, 7}) { - t.Errorf("after the restart, folds = %v, want [6 7]", got) - } - if r := m2.changes.rows[m2.changes.cur]; r.New != 20 { - t.Errorf("after the restart, the cursor is on %+v, want line 20", r) - } -} - -// TestClickFold checks that a click on a fold shows its lines, also in the -// split layout and with wrap. -func TestClickFold(t *testing.T) { - for _, keys := range [][]string{nil, {"|"}, {"w"}} { - m := step(t, newFoldModel(t), tea.WindowSizeMsg{Width: 200, Height: 70}) - m = press(t, m, keys...) - tail := slices.IndexFunc(m.changes.rows, func(r diff.Row) bool { return r.Fold.Lines == 7 }) - m = click(t, m, m.mainX()+10, screenY(t, m, tail)) - if got := foldsOf(&m.changes); !slices.Equal(got, []int{6, 13}) { - t.Errorf("keys %v: after the click, folds = %v, want [6 13]", keys, got) - } - if m.changes.rowOf(diff.SideNew, 40) < 0 { - t.Errorf("keys %v: line 40 is hidden, want it shown", keys) - } - } -} - -// TestFoldKeepsComment checks that a fold with a comment on one of its -// lines shows, also after a new scope closes the expanded folds. -func TestFoldKeepsComment(t *testing.T) { - m := newFoldModel(t) - m.changes.cur = slices.IndexFunc(m.changes.rows, func(r diff.Row) bool { return r.Fold.Lines == 13 }) - m = step(t, m, tea.KeyPressMsg{Code: tea.KeyRight}) - m.changes.cur = m.changes.rowOf(diff.SideNew, 20) - m = press(t, m, "c") - m = step(t, m, tea.KeyPressMsg{Text: "why", Code: 'w'}) - m = press(t, m, "enter") - if m.comments.Len() != 1 { - t.Fatalf("%d comments, want 1", m.comments.Len()) - } - m = press(t, m, "b", "u") // the branch scope, then back - if m.folds != nil { - t.Errorf("after a new scope, expanded folds = %v, want nil", m.folds) - } - if m.changes.rowOf(diff.SideNew, 20) < 0 || !shows(m, "why") { - t.Errorf("view got:\n%s\nwant the commented line 20", view(m)) - } - if got := foldsOf(&m.changes); !slices.Equal(got, []int{6, 7}) { - t.Errorf("folds = %v, want [6 7]", got) - } -} diff --git a/tui/keys.go b/tui/keys.go index c14bbd5..03fde7d 100644 --- a/tui/keys.go +++ b/tui/keys.go @@ -1,39 +1,40 @@ 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, Left, Right key.Binding - NextHunk, PrevHunk, NextFile, PrevFile, Focus 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 @@ -51,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"), @@ -64,22 +68,22 @@ func defaultKeys() keyMap { 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"}, "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( @@ -114,6 +118,8 @@ func defaultKeys() keyMap { } } +// overlayKeyMap has the keys of the overlays. The settings do not change +// them. type overlayKeyMap struct { Choose, Close, Up, Down key.Binding @@ -150,7 +156,8 @@ type binding struct { // 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. +// 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}, @@ -159,8 +166,8 @@ func (k *keyMap) bindings(c keybindings) []binding { {"prev-hunk", c.PrevHunk, &k.PrevHunk}, {"next-file", c.NextFile, &k.NextFile}, {"prev-file", c.PrevFile, &k.PrevFile}, - {"collapse", c.Collapse, &k.Left}, - {"expand", c.Expand, &k.Right}, + {"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}, @@ -168,20 +175,20 @@ func (k *keyMap) bindings(c keybindings) []binding { {"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.NavPosition}, - {"navigator-hide", c.NavigatorHide, &k.NavHide}, - {"navigator-grow", c.NavigatorGrow, &k.NavGrow}, - {"navigator-shrink", c.NavigatorShrink, &k.NavShrink}, + {"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}, @@ -199,14 +206,18 @@ func (k *keyMap) bindings(c keybindings) []binding { {"refresh", c.Refresh, &k.Refresh}, {"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. The check of the -// setting accepts each key, so keyName does not fail. +// 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)) @@ -214,33 +225,72 @@ func (k *keyMap) applyConfig(c keybindings) { keys[i], _ = keyName(s) } b.key.SetKeys(keys...) - if len(b.keys) > 0 { - b.key.SetHelp(b.keys[0], b.key.Help().Desc) + 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 + } + 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 names of the keys that are not one character, as -// herdr-reviewr spells them, with the names that bubbletea uses. -var namedKeys = []struct{ name, tea string }{ - {"left", "left"}, - {"right", "right"}, - {"up", "up"}, - {"down", "down"}, - {"pageup", "pgup"}, - {"pagedown", "pgdown"}, +// 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. 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. +// 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) - for _, k := range namedKeys { - if rest == k.name || rest == k.tea { - return mod + k.tea, true - } + 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) || @@ -250,14 +300,12 @@ func keyName(s string) (name string, ok bool) { return s, true } -// herdrKeyName returns the bubbletea key name as herdr-reviewr spells it, -// for example "pageup" for "pgup". -func herdrKeyName(name string) string { - mod, rest := cutModifier(name) - for _, k := range namedKeys { - if rest == k.tea { - return mod + k.name - } +// 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 } @@ -289,7 +337,7 @@ type helpKeys struct { tab tab split bool visual bool - onFold bool // the cursor is on a fold + atFold bool // the cursor is on a fold armed int // the direction of an armed crossing, or 0 } @@ -303,22 +351,27 @@ func (h helpKeys) ShortHelp() []key.Binding { case h.mode == modeComment: return []key.Binding{k.Save, k.Newline, k.Cancel} case h.armed > 0: - return []key.Binding{hint(k.NextHunk, firstKey(k.NextHunk), "next file"), - k.Comment, k.Send, k.Keys, k.Quit} + return []key.Binding{ + 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} + 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.onFold: + case h.atFold: return []key.Binding{ - hint(k.Right, k.Right.Help().Key, "show the lines"), k.Comment, k.Send, k.NextHunk, k.Keys, k.Quit, + 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} @@ -328,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/keys_test.go b/tui/keys_test.go deleted file mode 100644 index 558fac6..0000000 --- a/tui/keys_test.go +++ /dev/null @@ -1,70 +0,0 @@ -package tui - -import ( - "testing" - - tea "charm.land/bubbletea/v2" -) - -// TestKeybindingErrors checks the errors of the keybindings setting. The -// messages are the same as in herdr-reviewr. -func TestKeybindingErrors(t *testing.T) { - const names = "a list of keys, each a character or a named key " + - "(left, right, up, down, pageup, pagedown), alone or after ctrl+ or alt+" - tests := []struct{ text, want string }{ - {`{"comment": ["v"]}`, "config: invalid value for keybindings: `v` is bound to both `select` and `comment`"}, - {`{"comment": ["c", "c"]}`, "config: invalid value for keybindings: `c` is bound twice to `comment`"}, - {`{"refresh": ["ctrl+f"]}`, "config: invalid value for keybindings: `ctrl+f` is bound to both `find` and `refresh`"}, - {`{"wrap": ["|"]}`, "config: invalid value for keybindings: `|` is bound to both `wrap` and `split`"}, - {`{"next-file": ["pageup"]}`, "config: invalid value for keybindings: `pageup` is bound to both `next-file` and `page-up`"}, - {`{"page-up": ["PageUp"]}`, `config: invalid value for keybindings.page-up: expected ` + names + `, not "PageUp"`}, - {`{"send": ["ss"]}`, `keybindings.send: expected ` + names + `, not "ss"`}, - {`{"send": [" "]}`, `keybindings.send: expected ` + names + `, not " "`}, - {`{"send": ["ctrl+"]}`, `keybindings.send: expected ` + names + `, not "ctrl+"`}, - {`{"send": [""]}`, `keybindings.send: expected ` + names + `, not ""`}, - {`{"send": []}`, `keybindings.send: expected ` + names + `, not "[]"`}, - } - for _, tt := range tests { - _, err := parseConfig(`{"keybindings": ` + tt.text + `}`) - if !errorHas(err, tt.want) { - t.Errorf("keybindings %s: error %v, want %q", tt.text, err, tt.want) - } - } -} - -// TestKeybindingsValid checks settings with no error: a swap of two keys, -// and the names of keys. -func TestKeybindingsValid(t *testing.T) { - for _, text := range []string{ - `{"comment": ["v"], "select": ["c"]}`, - `{"page-up": ["pageup", "alt+up"], "page-down": ["pgdown"]}`, - `{"find": ["alt+f"], "send": ["é"]}`, - } { - if _, err := parseConfig(`{"keybindings": ` + text + `}`); err != nil { - t.Errorf("keybindings %s: %v, want no error", text, err) - } - } -} - -// TestKeybindingPageUp checks that the herdr-reviewr name pageup binds the -// page up key. -func TestKeybindingPageUp(t *testing.T) { - m, _ := newTestModel(t) - cfg, err := parseConfig(`{"keybindings": {"page-up": ["pageup"], "half-up": ["pgup"]}}`) - if err == nil { - t.Fatal("pageup and pgup on two actions: no error, want an error") - } - cfg, err = parseConfig(`{"keybindings": {"page-up": ["pageup"]}}`) - if err != nil { - t.Fatal(err) - } - m.keys = defaultKeys() - m.keys.applyConfig(cfg.Keybindings) - if m = press(t, m, "j", "j"); m.changes.cur != 2 { - t.Fatalf("after j j: cursor %d, want 2", m.changes.cur) - } - m = step(t, m, tea.KeyPressMsg{Code: tea.KeyPgUp}) - if m.changes.cur != 0 { - t.Errorf("pageup bound to page-up: cursor %d, want 0", m.changes.cur) - } -} diff --git a/tui/model.go b/tui/model.go index 7679e9d..8a766b8 100644 --- a/tui/model.go +++ b/tui/model.go @@ -3,9 +3,11 @@ package tui import ( "cmp" "context" + "fmt" "image/color" "log/slog" "slices" + "strconv" "strings" "time" @@ -23,6 +25,7 @@ import ( "github.com/rednafi/rexdiff/vcs" ) +// tab is a tab of the program: Changes, Files, or PR. type tab int const ( @@ -51,6 +54,21 @@ const ( modeFind // the find band ) +// 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 { @@ -58,6 +76,7 @@ type pane struct { rows []diff.Row // rows[i] is the source line that lines[i] shows 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 @@ -71,7 +90,7 @@ type pane struct { // 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 @@ -79,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. @@ -117,17 +136,18 @@ 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 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 // the first agent that works in the turn - app string // the name of that 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 @@ -140,6 +160,9 @@ type dragState struct { 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. It is a new comment, or an @@ -153,17 +176,18 @@ type draft struct { // 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 { 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. @@ -174,41 +198,53 @@ 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, each tick reloads the current tab. - watch *vcs.Watcher + // 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 retryAt, after a failure. - reloadOwed bool - filesOwed bool // the same for the list of all files - reloadAt time.Time - retryAt time.Time - retryWait time.Duration // the wait after the next failure, or 0 + // 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 + // 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 @@ -219,32 +255,44 @@ type model struct { // scope compares with it. pending is a turn that starts but changes // no file yet, or nil. turn, pending *turnState - tracker turn.Tracker + // 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 at sampleAt. - // Each failure doubles the wait, as in herdr-reviewr. - sampleAt time.Time - sampleWait time.Duration // the wait after the next failure, or 0 - // agentsHere tells if an agent works in the repository, or is nil if - // the program does not know. + // 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 - - files []changed // the Changes tab - changeSel int // the file that shows in the Changes tab - shownTrees string // the two trees of files, to notice a change - changeTree navTree - allFiles []vcs.Entry // the Files tab - allSel int - fileTree navTree - viewed string // the content in the Files tab, to notice edits - - changes, viewer, prPane pane - pr prState - prf prFetch - prPlace *savedPane // the place of prPane after a restart + ownsErr string // the last error of owns, which the log has + + 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 + + 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 @@ -256,9 +304,11 @@ type model struct { // crossing to the file at armedPath. Else it is 0. armed int armedPath string - listW int // the width of the file list at a side, in columns - listPct int // the share of the body rows, above or below - cache diff.Cache // the syntax colours, for a fast sideways scroll + // 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 @@ -268,8 +318,11 @@ type model struct { // recent has the files that the user picks in the search in this // session, the last pick first. recent []string - grepCancel context.CancelFunc // stops the code search that runs + 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 @@ -297,11 +350,11 @@ func newModel(ctx context.Context, o Options, cfg config) model { repo: o.Repo, rex: o.Rex, log: o.Log, + env: o.Env, notice: o.Notice, updater: o.Updater, w: 120, h: 40, - focused: true, base: o.Base, baseFlag: o.Base != "", wrap: o.Wrap, @@ -321,8 +374,9 @@ func newModel(ctx context.Context, o Options, cfg config) model { } } } + m.defaultBase = o.Repo.DefaultBase(ctx) if m.base == "" { - m.base = o.Repo.DefaultBase(ctx) + m.base = m.defaultBase } if tree := o.Repo.SavedTurn(ctx); tree != "" { m.turn = &turnState{tree: tree} @@ -330,9 +384,14 @@ func newModel(ctx context.Context, o Options, cfg config) model { 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) @@ -351,22 +410,18 @@ type ( 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 } - copiedMsg struct { - ids []int // the comments to remove, or nil - done string // the status if the copy works - err error - } grepMsg struct { - query string - hits []vcs.Match - more bool // git finds more matches than hits has - err error + 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 @@ -374,12 +429,33 @@ type ( err error } eventMsg rex.Event - // editorDoneMsg tells that the editor of path stops. A window editor - // sends it only if it fails. + // 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 - window bool + 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 } ) @@ -391,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{} }) @@ -399,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.requestPR(true)} + 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{})) @@ -420,92 +502,221 @@ func (m *model) flash(s string) { 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 { - return "", "", m.turnWaitMessage(), 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 do not -// change. -func (m *model) reload() { - m.shownTrees = "" // no trees are "", so reloadIfChanged reads them - m.reloadIfChanged() +// 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) + } + c.File = f + } + return out, nil } -// reloadIfChanged reads the changes of the scope again if the trees differ -// from the shown trees. After a failure, the next tick tries again after a -// wait. -func (m *model) reloadIfChanged() { - m.reloadOwed = false - from, to, note, err := m.trees() - trees := from + ".." + to - var files []vcs.File - if err == nil && note == "" && trees != m.shownTrees { - files, err = m.repo.Diff(m.ctx, from, to) - } - m.retry(err) - if err == nil && note == "" && trees == m.shownTrees { +// 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, -1, "" - 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.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.changedFile(); f == nil || f.Path != selPath { + 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" + if len(m.changeFiles) == 0 { + m.changePane.note = "no changes" } m.renderChanges() } @@ -550,14 +761,15 @@ func (m *model) buildTrees() { } } -// Update handles a message. After an install, Update restarts the program +// 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 _, ok := msg.(tickMsg); ok { pending = nil @@ -570,30 +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.(type) { - case tea.KeyPressMsg, tea.WindowSizeMsg: // these end a chain of clicks - m.click = clickChain{} - } 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: @@ -607,37 +820,53 @@ func (m *model) handle(msg tea.Msg) tea.Cmd { cmd = m.onAgents(msg) case sentMsg: m.onSent(msg) - case copiedMsg: - m.onCopied(msg) case eventMsg: - m.onEvent(rex.Event(msg)) + cmd = m.onEvent(rex.Event(msg)) case prMsg: 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: 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) @@ -659,48 +888,6 @@ func (m *model) onKey(msg tea.KeyPressMsg) tea.Cmd { 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 { - 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() - } - ov.showSearch() -} - -// 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() - } - 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 -} - // onPaste adds text to the comment box or to the input of the overlay. func (m *model) onPaste(text string) tea.Cmd { switch { @@ -725,77 +912,120 @@ const colorTicks = 3 // The waits of a reload, as in herdr-reviewr. const ( - // budgetFactor sets the pause after a reload on a tick. The next one - // waits this many times as long as the last one took. - budgetFactor = 20 - retryFirst = time.Second // the first wait after a failure - retryMax = time.Minute // the longest wait after a failure + // 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 ) +// 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.retryWait, m.retryAt = 0, time.Time{} + m.reloadRetry = retryTimer{} return } - wait := max(m.retryWait, retryFirst) - m.retryWait = min(2*wait, retryMax) - m.retryAt = time.Now().Add(wait) + m.reloadRetry.fail(time.Now()) m.reloadOwed = true } -// changed tells what the watcher sees. Without a watcher, or after an -// error of the watcher, it tells that both change. -func (m *model) changed() vcs.Change { +// 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.shownIgnored()) - start := time.Now() + m.watch.Show(m.shownPaths()) ch, err := m.watch.Changed(m.ctx) - m.log.Debug("watch", "took", time.Since(start), "files", ch.Files, "refs", ch.Refs) + m.log.Debug("watch", "took", time.Since(now), "files", ch.Files, "refs", ch.Refs) if err != nil { - m.log.Warn("watch failed", "err", err) + m.dropWatch(err) + m.polled = now return all } + m.watchRetry = retryTimer{} return ch } -// onTick reloads the current tab if the watcher sees a change. On the -// Changes tab, a reload waits for the pause after the last one, and for -// the wait after a failure. +// 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++ - ch := m.changed() + ch := m.watchChanges() + var cmds []tea.Cmd if ch.Files && m.pending != nil { - m.checkTurn() + cmds = append(cmds, m.checkTurnLater(false)) } m.reloadOwed = m.reloadOwed || ch.Files || ch.Refs m.filesOwed = m.filesOwed || ch.Files now := time.Now() - switch { - case m.tab == tabChanges && m.reloadOwed && !now.Before(m.reloadAt) && !now.Before(m.retryAt): - m.reloadIfChanged() - m.reloadAt = time.Now().Add(budgetFactor * time.Since(now)) - case m.tab == tabFiles && m.filesOwed: - m.reloadFiles() // reloadFiles renders the file too - case m.tab == tabFiles: - m.refreshViewer() - } - if m.status != "" && time.Now().After(m.statusTill) { + 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.sampleAt.IsZero() && !now.Before(m.sampleAt) { - m.sampleAt = time.Time{} // one list at a time + 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 { @@ -807,32 +1037,36 @@ func (m *model) onTick() tea.Cmd { 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 6e04126..ea2d547 100644 --- a/tui/mouse.go +++ b/tui/mouse.go @@ -3,7 +3,7 @@ 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. 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. +// open it. Double-click a file to copy its path. Click a link to open it. import ( "strings" @@ -13,6 +13,7 @@ import ( tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" + uv "github.com/charmbracelet/ultraviolet" "github.com/charmbracelet/x/ansi" "github.com/rednafi/rexdiff/diff" @@ -23,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) } @@ -40,26 +42,37 @@ func (m *model) onClick(mouse tea.Mouse) tea.Cmd { return nil } if y := mouse.Y - bodyY; y > m.bodyHeight() { // the footer, under the rule - return m.install() + return m.startUpdate() } + // 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 != "" { - return m.copyChars(path) + m.copyChars(path) + return nil } } m.clickRow(r) } return nil } + 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 } @@ -67,12 +80,19 @@ func (m *model) onClick(mouse tea.Mouse) tea.Cmd { x := mouse.X - m.mainX() - gutter if n := m.countClick(mouse); n >= 2 { if text := m.clickText(rowAt[y], x, n); text != "" { - return m.copyChars(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 - if m.onFold() { + if m.atFold() { m.expandFold(p.rows[p.cur].Fold) return nil } @@ -82,12 +102,29 @@ func (m *model) onClick(mouse tea.Mouse) tea.Cmd { start: p.cur, gutter: onNumber, pref: side, + row: rowAt[y], + x: x, + toRow: rowAt[y], + toX: x, + link: link, } return nil } -// inList tells if the screen cell at column x and line y is on the file -// list. +// 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() @@ -110,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] @@ -118,9 +155,10 @@ 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 { + if m.drag.moved || m.drag.toX != m.drag.x { m.click = clickChain{} // a drag is not a click } } @@ -137,7 +175,8 @@ type clickChain struct { } // countClick adds a press to the chain and returns the count. A press on -// another cell, or after [clickWindow], starts a new chain. +// 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 @@ -215,36 +254,74 @@ func (m *model) textAt(i, x int) (line string, char int, ok bool) { // copyChars copies text and then shows the count of its characters, as // herdr-reviewr does. -func (m *model) copyChars(text string) tea.Cmd { - if text == "" { - return nil +func (m *model) copyChars(text string) { + if text != "" { + m.copyText(text, "copied "+plural(utf8.RuneCountInString(text), "char"), nil) } - return 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 drag on the line numbers opens the comment box + 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 drag on the code copies the range - lo, hi := p.span(d.start, p.cur) - return m.copyChars(strings.Join(m.sourceLines(lo, hi, d.pref), "\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 picks the side of each row as [model.target] does with pref. A // row with no source line gives its text on the screen. @@ -276,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 { @@ -287,12 +367,19 @@ func (m *model) onWheel(mouse tea.Mouse) { default: return } - if m.mode == modeOverlay && m.overlay != nil { - m.overlay.move(d) + 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, mouse.Y) { - m.stepRow(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() @@ -315,7 +402,8 @@ func (m *model) clickOverlay(mouse tea.Mouse) tea.Cmd { return nil } idx := mouse.Y - y - listY - if idx < 0 || idx >= min(len(m.overlay.shown), m.overlay.rowsTo) || !m.overlay.pickable(idx) { + 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 @@ -338,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 2210c96..b29f82b 100644 --- a/tui/nav.go +++ b/tui/nav.go @@ -11,7 +11,6 @@ import ( tea "charm.land/bubbletea/v2" "github.com/rednafi/rexdiff/diff" - "github.com/rednafi/rexdiff/internal/termtext" "github.com/rednafi/rexdiff/vcs" ) @@ -23,13 +22,13 @@ const ( 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 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() @@ -81,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 takes 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) @@ -95,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: @@ -119,6 +122,10 @@ func (m *model) moveKey(msg tea.KeyPressMsg) (tea.Cmd, bool) { 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: @@ -145,23 +152,28 @@ func (m *model) moveKey(msg tea.KeyPressMsg) (tea.Cmd, bool) { m.stepFile(1) case key.Matches(msg, k.PrevFile): m.stepFile(-1) - case key.Matches(msg, k.Right) && m.onDir(): + case key.Matches(msg, k.Expand) && m.atDir(): m.setDir(true) - case key.Matches(msg, k.Left) && m.onDir(): + case key.Matches(msg, k.Collapse) && m.atDir(): m.setDir(false) - case key.Matches(msg, k.Right) && m.onFold(): + case key.Matches(msg, k.Expand) && m.atFold(): m.expandFold(p.rows[p.cur].Fold) - case key.Matches(msg, k.Right) && !m.wrap: + 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 @@ -200,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 } @@ -241,20 +254,20 @@ func (m *model) viewKey(msg tea.KeyPressMsg) (tea.Cmd, bool) { case m.splitAllowed(): m.flash("too narrow for split") } - case key.Matches(msg, k.NavPosition): + case key.Matches(msg, k.NavigatorPosition): m.moveList() - case key.Matches(msg, k.NavHide): + case key.Matches(msg, k.NavigatorHide): m.hideList() - case key.Matches(msg, k.NavGrow): + case key.Matches(msg, k.NavigatorGrow): m.resizeList(listStep) - case key.Matches(msg, k.NavShrink): + 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 } @@ -302,14 +315,30 @@ func (m *model) resizeList(d int) { m.rerender() } -// openPR opens the pull request in the browser. -func (m *model) openPR() { - if m.pr.pr == nil || m.pr.pr.URL == "" { - return +// 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" } - if err := openURL(m.cfg, m.pr.pr.URL); err != nil { - m.flash("open: " + err.Error()) + 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. @@ -321,9 +350,9 @@ func (m *model) refresh() tea.Cmd { if m.tab != tabPR { return nil } - m.pr.loading = true + cmd := m.requestPR(true) m.renderPR() - return m.requestPR(true) + return cmd } // setTab shows tab t. @@ -347,15 +376,17 @@ 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, m.folds = tabChanges, s, nil + // 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 } -// onFold tells if the cursor is on a fold of the Changes tab. -func (m *model) onFold() bool { +// 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 } @@ -370,15 +401,16 @@ type openFolds struct { Ranges [][2]int `json:"ranges"` // the first and the last line of each range } -// hash returns the FNV-1a hash of s. +// 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 + _, _ = h.Write([]byte(s)) // a hash.Hash does not fail return h.Sum64() } -// expanded tells if 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. +// 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. @@ -402,8 +434,8 @@ func (m *model) expanded(path string, comments []*diff.Comment, f diff.Fold) boo // 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.changes - file := m.changedFile() + p := &m.changePane + file := m.selectedChange() if file == nil { return } @@ -412,7 +444,7 @@ func (m *model) expandFold(f diff.Fold) { } o := m.folds[file.Path] if o == nil { - o = &openFolds{Old: hash(file.Old)} + o = &openFolds{Old: file.oldHash} m.folds[file.Path] = o } o.Ranges = append(o.Ranges, [2]int{f.Old, f.Old + f.Lines - 1}) @@ -429,27 +461,29 @@ func (m *model) listSel() int { } // 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. +// 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.drag = dragState{} @@ -463,7 +497,7 @@ func (m *model) pickFile(i int) { func (m *model) stepChange(d int) { armed := m.armed == d m.armed = 0 - p := &m.changes + p := &m.changePane if m.tab != tabChanges || p.anchor >= 0 { return } @@ -474,13 +508,13 @@ func (m *model) stepChange(d int) { target := m.crossTarget(d) if !armed { if target >= 0 { - m.armed, m.armedPath = d, m.files[target].Path + m.armed, m.armedPath = d, m.changeFiles[target].Path } return } // A reload can drop the armed file. - isArmed := func(f changed) bool { return f.Path == m.armedPath } - if i := slices.IndexFunc(m.files, isArmed); i >= 0 { + isArmed := func(f changedFile) bool { return f.Path == m.armedPath } + if i := slices.IndexFunc(m.changeFiles, isArmed); i >= 0 { target = i } if target < 0 { @@ -494,8 +528,9 @@ func (m *model) stepChange(d int) { p.cur = max(changeRow(p.rows, from, d), 0) } -// changeRow returns the first row of the next (d = 1) or the previous -// (d = -1) block of changed rows from row from, or -1. +// 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 := rows[i].Kind @@ -519,7 +554,7 @@ func (m *model) crossTarget(d int) int { from = t.cur } for r := t.nextFile(from, d); r >= 0; r = t.nextFile(r, d) { - if i := t.rows[r].File; m.files[i].adds+m.files[i].dels > 0 { + if i := t.rows[r].File; m.changeFiles[i].adds+m.changeFiles[i].dels > 0 { return i } } @@ -569,23 +604,34 @@ func (m *model) jumpToComment(c *diff.Comment) { // 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: " + termtext.Quote(path)) + m.flash("no such file: " + path) return } m.tab = tabFiles @@ -595,7 +641,7 @@ func (m *model) jumpToFile(path string, s diff.Side, line int) { 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. @@ -605,20 +651,26 @@ 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 }) + 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)) + } + return i } -// gotoLine moves the cursor to the line in s, as herdr-reviewr does. s 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. Without line numbers, for example in rendered markdown, s is the -// row on the screen. -func (m *model) gotoLine(s 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() - n, ok := lineNumber(s) + n, ok := lineNumber(text) if !ok || len(p.rows) == 0 { return } @@ -636,24 +688,19 @@ func (m *model) gotoLine(s string) { } n = min(max(n, 1), last) if m.tab == tabChanges { - for _, r := range p.rows { - if f := r.Fold; f.Lines > 0 && n >= foldStart(f, side) && n <= foldEnd(f, side) { - m.expandFold(f) - break - } - } + m.openFoldAt(side, n) } - p.cur = lineRow(p.rows, n, side) + p.cur = p.head(lineRow(p.rows, side, n)) } -// lineRow returns the row of line n on side. If no row has the line, it +// 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, n int, side diff.Side) int { - if i := slices.IndexFunc(rows, func(r diff.Row) bool { return r.Line(side) == n }); i >= 0 { +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) > n }); i >= 0 { + 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) { @@ -664,8 +711,7 @@ func lineRow(rows []diff.Row, n int, side diff.Side) int { return len(rows) - 1 } -// foldStart and foldEnd return the first and the last line of fold f on -// side. foldEnd returns 0 for no fold. +// 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 @@ -673,6 +719,7 @@ func foldStart(f diff.Fold, side diff.Side) int { 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 @@ -696,9 +743,9 @@ func lineNumber(s string) (n int, ok bool) { return n, true } -// lineKey reports whether the go-to-line prompt takes the key msg. It +// 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 lineKey(msg tea.KeyPressMsg) bool { +func isLineKey(msg tea.KeyPressMsg) bool { if msg.Mod&^tea.ModShift != 0 { return false } @@ -706,7 +753,7 @@ func lineKey(msg tea.KeyPressMsg) bool { case tea.KeyBackspace, tea.KeyDelete, tea.KeyLeft, tea.KeyRight, tea.KeyHome, tea.KeyEnd: return true } - return len(msg.Text) == 1 && strings.Contains("0123456789$", msg.Text) + return len(msg.Text) == 1 && strings.ContainsAny(msg.Text, "0123456789$") } // pastedLine returns the line in pasted text for the go-to-line prompt. diff --git a/tui/navpos_test.go b/tui/navpos_test.go deleted file mode 100644 index 6a595fc..0000000 --- a/tui/navpos_test.go +++ /dev/null @@ -1,138 +0,0 @@ -package tui - -import ( - "os" - "path/filepath" - "strings" - "testing" -) - -// screenLine returns line y of the screen of m. -func screenLine(m model, y int) string { - lines := strings.Split(view(m), "\n") - if y < 0 || y >= len(lines) { - return "" - } - return lines[y] -} - -// TestListPositions checks that p moves the file list clockwise, and that -// a list above or below takes a quarter of the body rows. -func TestListPositions(t *testing.T) { - m, _ := newTestModel(t) - want := []position{positionBottom, positionLeft, positionTop, positionRight} - for _, w := range want { - m = press(t, m, "p") - if m.listPos != w { - t.Fatalf("after p: position %q, want %q", m.listPos, w) - } - title := screenLine(m, m.listY()) - if !strings.Contains(title, "Changes (1)") { - t.Errorf("%s: line %d is %q, want the title of the list", w, m.listY(), title) - } - if w.stacked() { - h := m.bodyHeight() - if got, want := m.listHeight(h), h*listStackPct/100; got != want { - t.Errorf("%s: the list takes %d of %d rows, want %d", w, got, h, want) - } - if m.mainWidth() != m.w { - t.Errorf("%s: main width %d, want the screen width %d", w, m.mainWidth(), m.w) - } - if m.mainHeight()+m.listHeight(h) != h { - t.Errorf("%s: main %d + list %d rows, want %d", w, m.mainHeight(), m.listHeight(h), h) - } - } - } - // A click on the file in the list above the main pane selects it. - m = press(t, m, "p", "p", "p") // top - m.focusList = false - m = click(t, m, 5, m.listY()+1) - if !m.focusList { - t.Error("a click on the file in the list above: the list has no focus, want focus") - } -} - -// TestListSizes checks the bounds of the file list at a side and above or -// below the main pane. -func TestListSizes(t *testing.T) { - m, _ := newTestModel(t) // 120 columns - m = press(t, m, "<") - if got := m.sideWidth(); got != 36 { - t.Errorf("one grow from 32 columns: width %d, want 36", got) - } - for range 20 { - m = press(t, m, "<") - } - if got := m.sideWidth(); got != 120*listMaxSidePct/100 { - t.Errorf("after many grows: width %d, want %d", got, 120*listMaxSidePct/100) - } - for range 30 { - m = press(t, m, ">") - } - if got := m.sideWidth(); got != 120*listMinPct/100 { - t.Errorf("after many shrinks: width %d, want %d", got, 120*listMinPct/100) - } - - m = press(t, m, "p") // bottom - m = press(t, m, "<") - if m.listPct != listStackPct+listStep { - t.Errorf("one grow below: share %d%%, want %d%%", m.listPct, listStackPct+listStep) - } - for range 20 { - m = press(t, m, "<") - } - if m.listPct != listMaxStackPct { - t.Errorf("after many grows below: share %d%%, want %d%%", m.listPct, listMaxStackPct) - } - for range 20 { - m = press(t, m, ">") - } - if m.listPct != listMinPct { - t.Errorf("after many shrinks below: share %d%%, want %d%%", m.listPct, listMinPct) - } -} - -// TestListHide checks that z hides the list and focuses the main pane, -// that a hidden list does not move or resize, and that the PR tab does -// not hide the list. -func TestListHide(t *testing.T) { - m, _ := newTestModel(t) - m.focusList = true - m = press(t, m, "z") - if !m.listHidden || m.focusList || m.listWidth() != 0 { - t.Fatalf("after z: hidden %v, list focus %v, width %d; want true, false, 0", - m.listHidden, m.focusList, m.listWidth()) - } - w, pos := m.listW, m.listPos - m = press(t, m, "p", "<") - if m.listPos != pos || m.listW != w { - t.Errorf("p and < on a hidden list: %q %d, want %q %d", m.listPos, m.listW, pos, w) - } - m = press(t, m, "z") - if m.listHidden { - t.Error("after a second z: the list is hidden, want it shown") - } - m = press(t, m, "3", "z") - if m.listHidden { - t.Error("z on the PR tab hides the list, want no change") - } -} - -// TestResumeListLeft checks that a saved state of an older version, with -// list_left, restores the list at the left. -func TestResumeListLeft(t *testing.T) { - m, _ := newTestModel(t) - path := filepath.Join(t.TempDir(), "state.json") - if err := os.WriteFile(path, []byte(`{"list_left": true, "list_w": 40}`), 0o600); err != nil { - t.Fatal(err) - } - s, err := loadState(path) - if err != nil { - t.Fatalf("loadState: %v", err) - } - m.restoreSettings(s) - if m.listPos != positionLeft || m.listW != 40 || m.listPct != listStackPct { - t.Errorf("restored %q, width %d, share %d; want left, 40, %d", - m.listPos, m.listW, m.listPct, listStackPct) - } -} diff --git a/tui/notes_test.go b/tui/notes_test.go deleted file mode 100644 index e753f9a..0000000 --- a/tui/notes_test.go +++ /dev/null @@ -1,193 +0,0 @@ -package tui - -import ( - "os" - "path/filepath" - "strings" - "testing" - "time" - - tea "charm.land/bubbletea/v2" - - "github.com/rednafi/rexdiff/vcs" -) - -// TestSkippedFileNotes checks the notes in place of a binary file and of a -// file that is too large, in both tabs. -func TestSkippedFileNotes(t *testing.T) { - tests := []struct{ content, want string }{ - {"a\x00b\n", "binary file · no line comments"}, - {strings.Repeat("x\n", 50_001), "file too large to show"}, - } - for _, tt := range tests { - t.Run(tt.want, func(t *testing.T) { - m, dir := newTestModel(t) - // The name sorts first, so that both tabs select the file. - write(t, dir, "0.txt", tt.content) - m.reload() - m.pickFile(0) - if !shows(m, tt.want) { - t.Errorf("Changes got:\n%s\nwant %q", view(m), tt.want) - } - if m = press(t, m, "2"); !shows(m, tt.want) { - t.Errorf("Files got:\n%s\nwant %q", view(m), tt.want) - } - }) - } -} - -// TestEmptyNotes checks the notes of an empty scope, of an empty list of -// files, of an empty file, and of a file that rexdiff cannot read. -func TestEmptyNotes(t *testing.T) { - m, dir := newTestModel(t) - write(t, dir, "a.go", "package a\n\nfunc A() int {\n\treturn 1\n}\n") - if m.reload(); !shows(m, "no changes") { - t.Errorf("an empty scope got:\n%s\nwant \"no changes\"", view(m)) - } - - write(t, dir, "0.txt", "") - if m = press(t, m, "2"); !shows(m, "empty file") { - t.Errorf("an empty file got:\n%s\nwant \"empty file\"", view(m)) - } - if err := os.Remove(filepath.Join(dir, "0.txt")); err != nil { - t.Fatal(err) - } - if m.refreshViewer(); !shows(m, "couldn't read this file") { - t.Errorf("a removed file got:\n%s\nwant \"couldn't read this file\"", view(m)) - } - - m.allFiles = []vcs.Entry{} - m.buildFileTree() - if m.renderViewer(); !shows(m, "no files") { - t.Errorf("no files got:\n%s\nwant \"no files\"", view(m)) - } -} - -// TestStatusMessages checks the short messages in the footer, and how -// long they show. -func TestStatusMessages(t *testing.T) { - m, _ := newTestModel(t) - m = press(t, m, "]", "c", "h", "i", "enter") - if m.status != "comment added" { - t.Errorf("a new comment: status %q, want \"comment added\"", m.status) - } - if d := time.Until(m.statusTill); d <= 3*time.Second || d > 4*time.Second { - t.Errorf("the status shows for %v, want 4s", d) - } - m = press(t, m, "e", "!", "enter") - if m.status != "comment updated" { - t.Errorf("an edit: status %q, want \"comment updated\"", m.status) - } - m = press(t, m, "d") - if m.status != "comment deleted" || m.comments.Len() != 0 { - t.Errorf("d: status %q, %d comments; want \"comment deleted\", 0", - m.status, m.comments.Len()) - } - - m = press(t, m, "]", "c", "x", "enter", "l", "d") - if m.status != "comment deleted" || m.comments.Len() != 0 { - t.Errorf("d in the list: status %q, %d comments; want \"comment deleted\", 0", - m.status, m.comments.Len()) - } - - m = step(t, m, tea.KeyPressMsg{Code: 'f', Mod: tea.ModCtrl}) - m = press(t, m, "z", "z", "z", "enter") - if !strings.HasSuffix(lastLine(m), "no matches") { - t.Errorf("a find with no hit: footer %q, want \"no matches\"", lastLine(m)) - } - m = press(t, m, "esc") - - if m = press(t, m, "B", "z", "z", "z", "z"); !shows(m, "no matches") { - t.Errorf("a picker with no match got:\n%s\nwant \"no matches\"", view(m)) - } -} - -// TestRenderedKey checks that m acts only on a markdown file that renders -// some text. -func TestRenderedKey(t *testing.T) { - m, dir := newTestModel(t) - if m = press(t, m, "m"); m.rendered { - t.Error("m on a.go turns on rendered markdown, want no change") - } - write(t, dir, "0.md", "\n\n") - m.reload() - m.pickFile(0) - m = press(t, m, "m") - if m.rendered || m.status != "nothing here renders" { - t.Errorf("m on a blank 0.md: rendered %v, status %q; want false, \"nothing here renders\"", - m.rendered, m.status) - } - write(t, dir, "0.md", "# Title\n") - m.reload() - if m = press(t, m, "m"); !m.rendered { - t.Error("m on 0.md does not turn on rendered markdown") - } -} - -// TestGotoLineInput checks the go-to-line prompt. It takes only digits -// and "$", and a paste keeps only a line number. -func TestGotoLineInput(t *testing.T) { - m := newFoldModel(t) - m = press(t, m, ":", "a", "b", "c") - if got := m.overlay.input.Value(); got != "" { - t.Errorf(":abc types %q, want nothing", got) - } - m = press(t, m, "1", "$") - if got := m.overlay.input.Value(); got != "$" { - t.Errorf(":1$ types %q, want \"$\"", got) - } - m = press(t, m, "7") - if got := m.overlay.input.Value(); got != "7" { - t.Errorf(":$7 types %q, want \"7\"", got) - } - m = step(t, m, tea.PasteMsg{Content: " `12`, "}) - if got := m.overlay.input.Value(); got != "12" { - t.Errorf("a paste of `12`, gives %q, want \"12\"", got) - } - m = step(t, m, tea.PasteMsg{Content: "f.txt:3"}) - if got := m.overlay.input.Value(); got != "12" { - t.Errorf("a paste of f.txt:3 gives %q, want \"12\" kept", got) - } -} - -// TestGotoLineClamps checks that : clamps the line to the file, and opens -// the fold that has the line. -func TestGotoLineClamps(t *testing.T) { - tests := []struct { - input string - want int - }{ - {"999999", 40}, - {"99999999999999999999", 40}, - {"$", 40}, - {"0", 1}, - {"1", 1}, - } - for _, tt := range tests { - t.Run(tt.input, func(t *testing.T) { - m := newFoldModel(t) - m = press(t, m, ":") - m = step(t, m, tea.PasteMsg{Content: tt.input}) - m = press(t, m, "enter") - p := &m.changes - if r := p.rows[p.cur]; r.New != tt.want { - t.Errorf(":%s: the cursor is on %+v, want line %d", tt.input, r, tt.want) - } - }) - } -} - -// TestGotoLineDeletedFile checks that : uses the old lines of a deleted -// file. -func TestGotoLineDeletedFile(t *testing.T) { - m := newFoldModel(t) - if err := os.Remove(filepath.Join(m.repo.Root(), "f.txt")); err != nil { - t.Fatal(err) - } - m.reload() - m = press(t, m, ":", "2", "5", "enter") - p := &m.changes - if r := p.rows[p.cur]; r.Old != 25 { - t.Errorf("the cursor is on %+v, want old line 25", r) - } -} diff --git a/tui/overlay.go b/tui/overlay.go index 6f94492..857ece3 100644 --- a/tui/overlay.go +++ b/tui/overlay.go @@ -3,20 +3,17 @@ 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. @@ -64,21 +61,9 @@ type overlay struct { mark int // the commit picker: the item at the start of the range, or -1 agents []rex.Agent search searchState - rowsTo int // the index after the last row that shows -} - -// 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 + // 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 @@ -123,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)) } @@ -138,10 +121,6 @@ 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.move(1) } -func (o *overlay) up() { o.move(-1) } - // 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) { @@ -167,64 +146,6 @@ func (o *overlay) pickable(i int) bool { return o.kind != overlaySearch || o.items[o.shown[i]].id != "" } -// 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, and 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) -} - // open shows ov and gives the keys to it. func (m *model) open(ov *overlay) { m.overlay, m.mode = ov, modeOverlay @@ -233,15 +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() -} - -// stopGrep stops the code search that runs, if any. -func (m *model) stopGrep() { - if m.grepCancel != nil { - m.grepCancel() - m.grepCancel = nil - } + m.stopGrep(fmt.Errorf("code search: the search closes: %w", context.Canceled)) } // openInput opens a prompt of kind with the text value. @@ -257,253 +170,6 @@ func (m *model) openConfirmQuit() { m.open(newOverlay(overlayQuit, title)) } -// 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.FileLimit) - s.err = "" - m.stopGrep() - if q == "" { - s.hits, s.hitsMore = nil, false - ov.showSearch() - return nil - } - ov.showSearch() - ctx, cancel := context.WithCancel(m.ctx) - m.grepCancel = cancel - repo := m.repo - return func() tea.Msg { - hits, more, err := repo.Grep(ctx, q, search.CodeLimit) - return grepMsg{q, hits, more, 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.FileLimit-1)]...) -} - -// 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. As in herdr-reviewr, it does nothing if the base -// comes from the flag. -func (m *model) openBasePicker() { - if m.baseFlag { - return - } - ov := newOverlay(overlayBase, "base branch for the branch scope (or type any ref)") - branches, err := m.repo.Branches(m.ctx) - if err != nil { - m.flash(err.Error()) - return - } - 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, 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.ForkPoint(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(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)) -} - -// firstKey returns the first key of b, as herdr-reviewr names it, or "". -func firstKey(b key.Binding) string { - if ks := b.Keys(); len(ks) > 0 { - return herdrKeyName(ks[0]) - } - return "" -} - -// 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) -} - // updateOverlay handles a key while an overlay is open. func (m *model) updateOverlay(msg tea.KeyPressMsg) tea.Cmd { ov, k := m.overlay, overlayKeys @@ -517,14 +183,14 @@ func (m *model) updateOverlay(msg tea.KeyPressMsg) tea.Cmd { case overlaySearch: pv := ov.search.preview switch { - case key.Matches(msg, overlayKeys.Flip): + case key.Matches(msg, k.Flip): ov.search.code = !ov.search.code ov.showSearch() return nil - case key.Matches(msg, overlayKeys.PreviewDown) && pv != nil: + case key.Matches(msg, k.PreviewDown) && pv != nil: pv.scroll(pickerPage) return nil - case key.Matches(msg, overlayKeys.PreviewUp) && pv != nil: + case key.Matches(msg, k.PreviewUp) && pv != nil: pv.scroll(-pickerPage) return nil } @@ -536,17 +202,17 @@ 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 == overlayGoto { - if !lineKey(msg) { + if !isLineKey(msg) { return nil } // "$" stands alone, and a digit after "$" starts a number. @@ -580,67 +246,25 @@ func (m *model) updateQuit(msg tea.KeyPressMsg) tea.Cmd { case key.Matches(msg, k.Send): return m.startSend() case key.Matches(msg, k.Copy): - return m.copyComments() - } - return nil -} - -// 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.down() - case key.Matches(msg, k.Up): - ov.up() - case key.Matches(msg, k.Send): - return m.startSend() - case key.Matches(msg, k.Copy): - return 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 - 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) + 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() + 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 @@ -680,7 +304,7 @@ func (m *model) chooseOverlay() tea.Cmd { return nil } if err := m.repo.SaveBase(m.ctx, ref); err != nil { - m.flash(err.Error()) + m.flash("base save failed: " + err.Error()) return nil } m.base = ref @@ -710,44 +334,6 @@ 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 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 -} - // 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) { @@ -781,7 +367,7 @@ func (m *model) overlayBox() (box string, x, y, listY int) { } box = lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("8")). + BorderForeground(dimColor). Padding(0, 1). Width(w). Render(strings.Join(lines, "\n")) @@ -795,23 +381,7 @@ func (m *model) overlayBox() (box string, x, y, listY int) { 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", - k.QuitDiscard.Help().Key, n, k.Send.Help().Key, n, k.Copy.Help().Key) -} - -// 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", k.Edit.Help().Key) - } - } - return fmt.Sprintf("%s send %d · esc close · %s copy%s · %s delete · enter jump", - k.Send.Help().Key, m.comments.Len(), k.Copy.Help().Key, edit, - k.Delete.Help().Key) + firstKey(k.QuitDiscard), n, firstKey(k.Send), n, firstKey(k.Copy)) } // overlayRows renders at most limit rows of the list of the overlay, @@ -820,7 +390,7 @@ func (m *model) overlayRows(inner, limit int) (rows []string, start int) { ov := m.overlay maxRows := min(len(ov.shown), max(limit, 1)) start = max(0, ov.cur-maxRows+1) - ov.rowsTo = start + maxRows + 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) { 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 0f7e73d..e43ee61 100644 --- a/tui/pr.go +++ b/tui/pr.go @@ -1,11 +1,11 @@ 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" @@ -16,23 +16,24 @@ import ( "github.com/rednafi/rexdiff/vcs" ) +// 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 - // prHang is the time after which a new request drops a read that - // does not end. - prHang = 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; others are stale + gen int // the read whose result shows. Other reads are stale. inFlight bool // the read gen runs - started time.Time // the start of the read gen polled time.Time // the last request, for prPoll - cancel context.CancelFunc + cancel context.CancelCauseFunc // trailing is true if an ambient request comes while a read runs. // Then one more read follows that read. trailing bool @@ -42,12 +43,11 @@ type prFetch struct { // 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, unless the read hangs. +// after it. A read that hangs ends after prTimeout. func (m *model) requestPR(forced bool) tea.Cmd { f := &m.prf f.polled = time.Now() - hung := f.inFlight && time.Since(f.started) >= prHang - if !forced && f.inFlight && !hung { + if !forced && f.inFlight { f.trailing = true return nil } @@ -59,11 +59,13 @@ func (m *model) requestPR(forced bool) tea.Cmd { func (m *model) loadPR() tea.Cmd { f := &m.prf if f.cancel != nil { - f.cancel() + f.cancel(fmt.Errorf("pull request read: a newer read replaces it: %w", context.Canceled)) } - ctx, cancel := context.WithCancel(m.ctx) + 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.started, f.cancel = true, false, time.Now(), cancel + f.inFlight, f.trailing, f.cancel = true, false, cancel repo, gen := m.repo, f.gen hosts := vcs.Hosts{ GitHub: m.cfg.GitHubHost, @@ -71,9 +73,10 @@ func (m *model) loadPR() tea.Cmd { Azure: m.cfg.AzureHost, } return func() tea.Msg { - defer cancel() + defer cancelTimeout() + defer cancel(fmt.Errorf("pull request read: done: %w", context.Canceled)) pr, err := repo.PullRequest(ctx, hosts) - return prMsg{gen, pr, err} + return prMsg{gen: gen, pr: pr, err: err} } } @@ -93,21 +96,22 @@ func (m *model) refsChanged() tea.Cmd { return nil } if old != "" && b != old { - m.pr = prState{loading: true} + m.pr = prState{} + cmd := m.requestPR(true) m.renderPR() - return m.requestPR(true) + 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 @@ -142,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 @@ -153,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{ @@ -199,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 index d57b85e..ca4b9af 100644 --- a/tui/preview.go +++ b/tui/preview.go @@ -1,14 +1,12 @@ package tui import ( - "errors" "slices" "strings" "charm.land/lipgloss/v2" "github.com/rednafi/rexdiff/diff" - "github.com/rednafi/rexdiff/vcs" ) // searchShare is the percent of the space of the search that the results @@ -48,6 +46,7 @@ func (m *model) searchLines(inner int) (lines []string, listY int) { } 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 { @@ -60,8 +59,8 @@ func (m *model) searchLines(inner int) (lines []string, listY int) { return lines, listY } -// splitAxis returns the part of n that pct percent takes, with at least -// 3 for each part if n has room, as herdr-reviewr splits a pane. +// 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 @@ -101,11 +100,10 @@ func (m *model) searchPreview(width int) *preview { } 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 errors.Is(err, vcs.ErrBinary): - pv.lines = []string{dimStyle.Render(" " + noteBinary)} - case errors.Is(err, vcs.ErrTooBig): - pv.lines = []string{dimStyle.Render(" " + noteTooBig)} + case note != "": + pv.lines = []string{dimStyle.Render(" " + note)} case err != nil || content == "": pv.lines = []string{dimStyle.Render(" no preview")} default: diff --git a/tui/prfetch_test.go b/tui/prfetch_test.go deleted file mode 100644 index 69cc0f5..0000000 --- a/tui/prfetch_test.go +++ /dev/null @@ -1,99 +0,0 @@ -package tui - -import ( - "testing" - "time" - - "github.com/rednafi/rexdiff/vcs" -) - -// TestPRStaleResult checks that the result of an earlier read does not -// show. -func TestPRStaleResult(t *testing.T) { - m, _ := newTestModel(t) - m = press(t, m, "3") - m.loadPR() - m.loadPR() - m = step(t, m, prMsg{gen: 1, pr: &vcs.PullRequest{Title: "old"}}) - if m.pr.pr != nil || !m.prf.inFlight { - t.Fatalf("a stale result shows: %+v", m.pr.pr) - } - m = step(t, m, prMsg{gen: 2, pr: &vcs.PullRequest{Title: "new"}}) - if m.pr.pr == nil || m.pr.pr.Title != "new" || m.prf.inFlight { - t.Errorf("pr %+v, in flight %v, want the new one", m.pr.pr, m.prf.inFlight) - } -} - -// TestPRPoll checks that a tick reads the pull request again a minute -// after the last request, but only on the PR tab. -func TestPRPoll(t *testing.T) { - m, _ := newTestModel(t) - m.prf.polled = time.Now().Add(-61 * time.Second) - if m = step(t, m, tickMsg{}); m.prf.inFlight { - t.Error("a tick on the Changes tab reads the pull request") - } - m = press(t, m, "3") - m.prf.polled = time.Now().Add(-59 * time.Second) - if m = step(t, m, tickMsg{}); m.prf.inFlight { - t.Error("a tick before a minute reads the pull request") - } - m.prf.polled = time.Now().Add(-61 * time.Second) - if m = step(t, m, tickMsg{}); !m.prf.inFlight || m.prf.gen != 1 { - t.Errorf("after a minute: in flight %v, gen %d, want a read", m.prf.inFlight, m.prf.gen) - } -} - -// TestPRTrailing checks that an ambient request during a read starts one -// more read after it, and that r and a hung read start one at once. -func TestPRTrailing(t *testing.T) { - m, _ := newTestModel(t) - m = press(t, m, "3") - m.requestPR(true) - if cmd := m.requestPR(false); cmd != nil || !m.prf.trailing || m.prf.gen != 1 { - t.Fatalf("an ambient request during a read: trailing %v, gen %d, want 1 trailing read", - m.prf.trailing, m.prf.gen) - } - next, cmd := m.Update(prMsg{gen: 1}) - m = *next.(*model) - if cmd == nil || m.prf.gen != 2 || !m.prf.inFlight || m.prf.trailing { - t.Fatalf("after the read: gen %d, in flight %v, trailing %v, want the trailing read", - m.prf.gen, m.prf.inFlight, m.prf.trailing) - } - if m = step(t, m, prMsg{gen: 2}); m.prf.inFlight { - t.Error("the trailing read starts another read") - } - - m.requestPR(false) - if m = press(t, m, "r"); m.prf.gen != 4 { - t.Errorf("r during a read: gen %d, want a new read", m.prf.gen) - } - m.prf.started = time.Now().Add(-61 * time.Second) - if m.requestPR(false); m.prf.gen != 5 || m.prf.trailing { - t.Errorf("a request after a hung read: gen %d, want a new read", m.prf.gen) - } -} - -// TestPRBranchSwitch checks that a switch to another branch blanks the PR -// tab, and that a commit on the branch keeps the pull request. -func TestPRBranchSwitch(t *testing.T) { - m, dir, pause := watched(t) - m = press(t, m, "3") - m = step(t, m, prMsg{gen: m.prf.gen, pr: &vcs.PullRequest{Title: "T"}}) - - git(t, dir, "commit", "-qam", "more") - time.Sleep(pause) - m = step(t, m, tickMsg{}) - if m.pr.pr == nil || !m.prf.inFlight { - t.Fatalf("after a commit: pr %+v, in flight %v, want the PR kept and a read", - m.pr.pr, m.prf.inFlight) - } - m = step(t, m, prMsg{gen: m.prf.gen, pr: &vcs.PullRequest{Title: "T"}}) - - git(t, dir, "checkout", "-q", "main") - gen := m.prf.gen - time.Sleep(pause) - m = step(t, m, tickMsg{}) - if m.pr.pr != nil || m.prf.gen == gen || !shows(m, "loading the pull request") { - t.Errorf("after a switch: pr %+v, gen %d, want a blank tab and a read", m.pr.pr, m.prf.gen) - } -} diff --git a/tui/quit_test.go b/tui/quit_test.go deleted file mode 100644 index 2ea98e6..0000000 --- a/tui/quit_test.go +++ /dev/null @@ -1,92 +0,0 @@ -package tui - -import ( - "strings" - "testing" - - tea "charm.land/bubbletea/v2" -) - -// quitPrompt returns a model with one comment and the prompt before a -// quit open. -func quitPrompt(t *testing.T) model { - t.Helper() - m, _ := newTestModel(t) - m = press(t, m, "]", "c", "h", "i", "enter", "q") - if m.overlay == nil || m.overlay.kind != overlayQuit { - t.Fatal("q with a comment: no quit prompt, want the prompt") - } - return m -} - -// quits tells if cmd quits the program. -func quits(cmd tea.Cmd) bool { - if cmd == nil { - return false - } - _, ok := cmd().(tea.QuitMsg) - return ok -} - -// TestQuitPrompt checks the keys of the prompt before a quit. They are -// the keys of herdr-reviewr. -func TestQuitPrompt(t *testing.T) { - m := quitPrompt(t) - const hint = "Q quit (1 pending) · esc cancel · s send 1 · y copy" - if !shows(m, hint) { - t.Errorf("the prompt got:\n%s\nwant %q", view(m), hint) - } - - // A held q does not quit, and the prompt stays open. - for range 3 { - next, cmd := m.Update(tea.KeyPressMsg{Code: 'q', Text: "q"}) - if m = *next.(*model); quits(cmd) || m.overlay == nil { - t.Fatalf("q in the prompt: quit %v, overlay %v; want no quit, the prompt open", - quits(cmd), m.overlay) - } - } - if _, cmd := m.Update(tea.KeyPressMsg{Code: 'Q', Text: "Q"}); !quits(cmd) { - t.Error("Q in the prompt: no quit, want a quit") - } - - // Any other key closes the prompt and keeps the comment. - for _, k := range []string{"j", "esc", "enter", "n"} { - m := press(t, quitPrompt(t), k) - if m.overlay != nil || m.mode != modeNormal || m.comments.Len() != 1 { - t.Errorf("%q in the prompt: overlay %v, %d comments; want it closed, 1", - k, m.overlay, m.comments.Len()) - } - } -} - -// TestQuitPromptSendCopy checks that s and y close the prompt and send or -// copy the comments. -func TestQuitPromptSendCopy(t *testing.T) { - m := press(t, quitPrompt(t), "s") - if m.overlay != nil || !strings.Contains(m.status, "not inside Rex") { - t.Errorf("s in the prompt: overlay %v, status %q; want it closed, a send", - m.overlay, m.status) - } - - fakeClipboard(t, false) - m = stepRun(t, quitPrompt(t), tea.KeyPressMsg{Code: 'y', Text: "y"}) - if m.overlay != nil || m.status != "copied 1 comment" || m.comments.Len() != 0 { - t.Errorf("y in the prompt: overlay %v, status %q, %d comments; "+ - "want it closed, a copy, 0", m.overlay, m.status, m.comments.Len()) - } -} - -// TestNoCtrlCHomeEnd checks that ctrl+c, home, and end do nothing, as in -// herdr-reviewr. -func TestNoCtrlCHomeEnd(t *testing.T) { - m, _ := newTestModel(t) - if _, cmd := m.Update(tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl}); quits(cmd) { - t.Error("ctrl+c: a quit, want nothing") - } - m = press(t, m, "j") - cur := m.changes.cur - if m = press(t, m, "home", "end"); m.changes.cur != cur { - t.Errorf("home and end: the cursor moves from %d to %d, want no move", - cur, m.changes.cur) - } -} diff --git a/tui/release.go b/tui/release.go index 9fc6b3f..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 reports the end of the install of the release. -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 fails. 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 faf5798..0000000 --- a/tui/release_test.go +++ /dev/null @@ -1,406 +0,0 @@ -package tui - -import ( - "context" - "errors" - "io/fs" - "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 // the error of Latest - installErr error // the error of Install - installed []string // the tags that Install gets -} - -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("run: no command, want a 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. The footer shows the install. - 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, " + - "want no restart") - } - // 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, want a restart") - } - 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); !errors.Is(err, fs.ErrNotExist) { - t.Errorf("after loadState: Stat = %v, want fs.ErrNotExist", err) - } - 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 got:\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: no install, want 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, want no restart") - } - 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 after a failed install: no command, want a new install") - } -} - -// 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 returns a command, want none", u) - } - } - m, _ := newTestModel(t) - if cmd := m.Init(); m.updater != nil || cmd == nil { - t.Errorf("updater = %v, Init returns a command %v; want nil, true", - m.updater, cmd != nil) - } -} - -// TestCommentsJSON checks that the comments give the same review after -// JSON encoding and decoding. -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, want no restart") - } - m = step(t, m, sentMsg{agent: rex.Agent{Name: "pi"}}) - if m.restartState == "" { - t.Fatal("no restart after the send, want a restart") - } - os.Remove(m.restartState) - - // In the PR tab, the key r returns the command that loads the PR. So r - // does not restart. A message without a command starts the restart. - 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, want no restart") - } - m = step(t, m, tea.FocusMsg{}) - if m.restartState == "" { - t.Fatal("no restart on a message without a command, want a restart") - } - 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 got := step(t, m, tea.MouseClickMsg{Button: tea.MouseLeft, X: 3, Y: rule}); got.phase == phaseInstalling { - t.Error("a click on the rule starts the install, want no install") - } - if got := step(t, m, tea.MouseClickMsg{Button: tea.MouseLeft, X: 3, Y: rule + 1}); got.phase != phaseInstalling { - t.Error("a click on the footer: no install, want the install") - } -} - -// TestRestartSaveFails checks that a failed save of the state tells the -// user and stops the restart. Then 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{}) - m = *next.(*model) - if m.restartState == "" || cmd == nil { - t.Fatal("no restart on a tick, want a restart") - } - os.Remove(m.restartState) -} - -// TestSavePendingPRPlace checks that a restart before the PR loads saves -// the PR place of the previous restart. -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 that the user edits while its -// send runs stays after the send. The user can save the edit before or after -// the send ends. -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 does 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 8123969..02cec37 100644 --- a/tui/render.go +++ b/tui/render.go @@ -4,7 +4,11 @@ package tui // file viewer, and Markdown. import ( + "cmp" "errors" + "fmt" + "iter" + "regexp" "slices" "strings" @@ -20,11 +24,13 @@ import ( // left of the main pane. const gutter = 2 -// The sizes of the file list, as in herdr-reviewr. At a side, the width -// stays between listMinPct and listMaxSidePct of the screen width. Above -// or below, the list takes listStackPct of the body rows by default, and -// stays between listMinPct and listMaxStackPct. +// 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 @@ -36,7 +42,10 @@ const ( func (m *model) listShown() bool { return !m.listHidden && m.tab != tabPR } // sideWidth returns the width of the file list at a side, in columns. -func (m *model) sideWidth() int { return m.clampSide(m.listW) } +// 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. @@ -58,21 +67,29 @@ func (m *model) listWidth() int { // 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. +// 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 } - return splitAxis(h, m.listPct) + 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 && !m.listPos.stacked() { w -= lw + sepWidth } - return max(w, 24) + return max(w, mainMinWidth) } // mainHeight returns the number of rows of the main pane. @@ -90,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 { @@ -117,6 +135,9 @@ 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. @@ -129,8 +150,8 @@ func (m *model) splitAllowed() bool { if m.tab != tabChanges { return false } - f := m.changedFile() - return f != nil && !f.Binary && !f.TooBig && !m.showsMarkdown(f) && + f := m.selectedChange() + return f != nil && f.Skip == nil && !m.isRendered(f) && f.Old != "" && f.New != "" && f.Old != f.New } @@ -141,9 +162,9 @@ func (m *model) splitShown() bool { return m.split && m.codeWidth() >= splitMinWidth && m.splitAllowed() } -// showsMarkdown reports whether the Changes tab shows f as rendered +// isRendered reports whether the Changes tab shows f as rendered // markdown. -func (m *model) showsMarkdown(f *changed) bool { +func (m *model) isRendered(f *changedFile) bool { return m.rendered && isMarkdown(f.Path) && f.Status != vcs.StatusDeleted } @@ -179,10 +200,22 @@ const ( 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) @@ -190,10 +223,8 @@ func (m *model) renderChanges() { } p.path = f.Path switch { - case f.Binary: - p.setNote(noteBinary) - case f.TooBig: - p.setNote(noteTooBig) + case f.Skip != nil: + p.setNote(fileNote(f.Skip)) case f.Old == f.New: // The change is a rename with no edit, or a change of the file mode. note := "no change to the content" @@ -201,10 +232,12 @@ func (m *model) renderChanges() { note = "renamed from " + termtext.Quote(f.From) + ", no change to the content" } p.setNote(note) - case m.showsMarkdown(f): - 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: - if o := m.folds[f.Path]; o != nil && o.Old != hash(f.Old) { + if o := m.folds[f.Path]; o != nil && o.Old != f.oldHash { delete(m.folds, f.Path) } o := m.diffOptions(p.xoff) @@ -226,51 +259,62 @@ func (p *pane) setNote(note string) { // renderViewer shows the selected file in the Files tab, even if the file // 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 // 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 err == nil && 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): - p.path = "" - p.set([]string{dimStyle.Render(" " + noteBinary)}, nil) - case errors.Is(err, vcs.ErrTooBig): + case note != "": p.path = "" - p.set([]string{dimStyle.Render(" " + noteTooBig)}, nil) + p.set([]string{dimStyle.Render(" " + note)}, nil) case err != nil: - m.log.Debug("read file", "path", path, "err", err) + // 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(" couldn't read this file")}, 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), @@ -278,9 +322,17 @@ func (m *model) markdown(wrap int) *glamour.TermRenderer { if err != nil { return nil } + *md = mdRenderer{r: r, style: style, wrap: wrap} return r } +// 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 { @@ -297,7 +349,7 @@ func renderMarkdown(r *glamour.TermRenderer, src string) []string { func (m *model) markdownSource() (src string, ok bool) { switch m.tab { case tabChanges: - f := m.changedFile() + f := m.selectedChange() if f == nil || !isMarkdown(f.Path) || f.Status == vcs.StatusDeleted { return "", false } @@ -321,10 +373,180 @@ func (m *model) rendersNothing(src string) bool { return true } -// 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), termtext.Plain(src)) - p.path = "" - p.set(lines, make([]diff.Row, len(lines))) +// 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 7503cdc..e63959f 100644 --- a/tui/resume.go +++ b/tui/resume.go @@ -1,9 +1,5 @@ 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. The new -// program reads the file, so that the user continues from the same place. - import ( "encoding/json/v2" "os" @@ -11,26 +7,31 @@ import ( "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, the expanded folds, +// 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 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"` +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"` + 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"` @@ -51,48 +52,62 @@ type savedState struct { 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, - 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, +// 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) { @@ -102,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 } @@ -130,10 +146,10 @@ 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.listPos, m.listHidden, m.focusList, m.listW = s.ListPos, s.ListHidden, s.FocusList, s.ListW @@ -150,30 +166,34 @@ func (m *model) restoreSettings(s *savedState) { 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 moves the cursors to the places // in s. Call it after the first reload. -func (m *model) restorePlaces(s *savedState) { - if i := slices.IndexFunc(m.files, func(f changed) bool { return f.Path == s.ChangePath }); i >= 0 { +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 + 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, and the directories above - // them expand. - m.changeTree.reveal(s.ChangePath) - m.fileTree.reveal(s.FilePath) - m.changeTree.cur, m.fileTree.cur = -1, -1 + // 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/saved_test.go b/tui/saved_test.go deleted file mode 100644 index 5081343..0000000 --- a/tui/saved_test.go +++ /dev/null @@ -1,81 +0,0 @@ -package tui - -import ( - "slices" - "testing" - - "github.com/rednafi/rexdiff/rex" -) - -// reopen returns a model of the repository of m, as after a restart, with -// the base flag base. -func reopen(t *testing.T, m model, base string) model { - t.Helper() - return newModel(t.Context(), Options{Repo: m.repo, Base: base}, defaultConfig()) -} - -// pickBase picks the base ref in the base picker. -func pickBase(t *testing.T, m model, ref string) model { - t.Helper() - m = press(t, m, "B") - if m.overlay == nil { - t.Fatal("B opens no picker") - } - m.overlay.input.SetValue(ref) - m.overlay.filter() - return press(t, m, "enter") -} - -// TestSavedBase checks that a restart keeps the picked base, and that the -// default branch clears it. -func TestSavedBase(t *testing.T) { - m, _ := newTestModel(t) - if m = pickBase(t, m, "feat"); m.base != "feat" { - t.Fatalf("base = %q, want feat", m.base) - } - if got := reopen(t, m, "").base; got != "feat" { - t.Errorf("after a restart, base = %q, want feat", got) - } - m = pickBase(t, m, "main") - if got := m.repo.SavedBase(t.Context()); got != "" { - t.Errorf("after a pick of the default, SavedBase = %q, want \"\"", got) - } -} - -// TestBaseFlagWins checks that the flag wins over the saved base, and that -// B then opens no picker. -func TestBaseFlagWins(t *testing.T) { - m, _ := newTestModel(t) - m = pickBase(t, m, "feat") - m = reopen(t, m, "main") - if m.base != "main" { - t.Errorf("base = %q, want the flag, main", m.base) - } - if m = press(t, m, "B"); m.overlay != nil { - t.Error("with the flag, B opens a picker") - } -} - -// TestSavedTurn checks that a restart keeps the tree at the start of the -// last turn. -func TestSavedTurn(t *testing.T) { - m, dir := newTestModel(t) - m = step(t, m, sample("block:agent", "pi", dir)) - m = step(t, m, status("block:agent", "pi", rex.StateWorking)) - if m.repo.SavedTurn(t.Context()) != "" { - t.Error("a turn with no change saves its tree") - } - write(t, dir, "c.go", "package a\n") - m = step(t, m, status("block:agent", "pi", rex.StateIdle)) - if m.turn == nil { - t.Fatal("no turn after the agent changes a file") - } - m2 := reopen(t, m, "") - if m2.turn == nil || m2.turn.tree != m.turn.tree { - t.Fatalf("after a restart, turn = %+v, want the tree %s", m2.turn, m.turn.tree) - } - m2.setScope(ScopeLastTurn) - if got := paths(m2); !slices.Equal(got, []string{"c.go"}) { - t.Errorf("the saved turn shows %v, want [c.go]", got) - } -} 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/search_test.go b/tui/search_test.go deleted file mode 100644 index 9c3df4c..0000000 --- a/tui/search_test.go +++ /dev/null @@ -1,287 +0,0 @@ -package tui - -import ( - "fmt" - "slices" - "strings" - "testing" - - tea "charm.land/bubbletea/v2" - - "github.com/rednafi/rexdiff/vcs" -) - -// typeQuery types q in the search and returns the command of the last key. -func typeQuery(t *testing.T, m model, q string) (model, tea.Cmd) { - t.Helper() - var cmd tea.Cmd - for _, r := range q { - next, c := m.Update(tea.KeyPressMsg{Code: r, Text: string(r)}) - m, cmd = *next.(*model), c - } - return m, cmd -} - -// openQuery opens the search and types q. It runs the code search and -// gives its result to the model. -func openQuery(t *testing.T, m model, q string) model { - t.Helper() - m = press(t, m, "/") - m, cmd := typeQuery(t, m, q) - if cmd == nil { - return m - } - return step(t, m, findGrep(cmd())) -} - -// findGrep returns the grepMsg in msg, which can be a batch. -func findGrep(msg tea.Msg) tea.Msg { - if b, ok := msg.(tea.BatchMsg); ok { - for _, c := range b { - if c == nil { - continue - } - if g, ok := c().(grepMsg); ok { - return g - } - } - } - return msg -} - -// labels returns the labels of the rows of the search. -func labels(m model) []string { - var ls []string - for _, i := range m.overlay.shown { - ls = append(ls, m.overlay.items[i].label) - } - return ls -} - -var tabKey = tea.KeyPressMsg{Code: tea.KeyTab} - -// TestSearchFlip checks that tab switches the mode of the search and -// keeps the query, as in herdr-reviewr. -func TestSearchFlip(t *testing.T) { - m, _ := newTestModel(t) - m = openQuery(t, m, "package") - if m.overlay.search.code { - t.Fatal("the search opens in the code mode, want files") - } - if !shows(m, "files 0 │ code 2") { - t.Errorf("view got:\n%s\nwant the chips \"files 0 │ code 2\"", view(m)) - } - m = step(t, m, tabKey) - if !m.overlay.search.code || m.overlay.input.Value() != "package" { - t.Errorf("after tab: code %t, query %q; want true, %q", - m.overlay.search.code, m.overlay.input.Value(), "package") - } - if want := []string{"a.go", " 1: package a", "b.go", " 1: package a"}; !slices.Equal(labels(m), want) { - t.Errorf("the code rows are %q, want %q", labels(m), want) - } - m = step(t, m, tabKey) - if m.overlay.search.code { - t.Error("a second tab stays in the code mode, want files") - } -} - -// TestSearchFileLimit checks that the search shows 50 files, then a -// "… more" row, and the count of all the matches in the chip. -func TestSearchFileLimit(t *testing.T) { - m, dir := newTestModel(t) - for i := range 60 { - write(t, dir, fmt.Sprintf("zz%02d.txt", i), "x\n") - } - m = openQuery(t, m, "zz") - ls := labels(m) - if len(ls) != 51 || ls[50] != "… more" { - t.Errorf("the search shows %d rows, the last %q; want 51, \"… more\"", len(ls), ls[len(ls)-1]) - } - if !shows(m, "files 60 │") { - t.Errorf("view got:\n%s\nwant \"files 60 │\"", view(m)) - } - // The cursor stops on the last file, not on the "… more" row. - for range 60 { - m = step(t, m, tea.KeyPressMsg{Code: tea.KeyDown}) - } - if m.overlay.cur != 49 { - t.Errorf("after 60 downs the cursor is on row %d, want 49", m.overlay.cur) - } -} - -// TestSearchCodeGroups checks that the code matches show under a header -// for each file, and that the cursor skips the headers. -func TestSearchCodeGroups(t *testing.T) { - m, _ := newTestModel(t) - m = openQuery(t, m, "package") - m = step(t, m, tabKey) - if it := m.overlay.selected(); it == nil || it.id != "a.go" || it.line != 1 { - t.Fatalf("the cursor is on %+v, want the match in a.go", it) - } - m = step(t, m, tea.KeyPressMsg{Code: 'n', Mod: tea.ModCtrl}) - if it := m.overlay.selected(); it == nil || it.id != "b.go" || m.overlay.cur != 3 { - t.Errorf("after ctrl+n the cursor is on row %d, %+v; want 3, b.go", m.overlay.cur, it) - } - m = step(t, m, tea.KeyPressMsg{Code: 'p', Mod: tea.ModCtrl}) - if m.overlay.cur != 1 { - t.Errorf("after ctrl+p the cursor is on row %d, want 1", m.overlay.cur) - } - m = step(t, m, tea.KeyPressMsg{Code: tea.KeyUp}) - if m.overlay.cur != 1 { - t.Errorf("up from the first match moves the cursor to row %d, want 1", m.overlay.cur) - } -} - -// TestSearchCodeMore checks the "+" of the chip and the "… more" row when -// git finds more matches than the search shows. -func TestSearchCodeMore(t *testing.T) { - m, dir := newTestModel(t) - write(t, dir, "many.txt", strings.Repeat("hit\n", 300)) - m = openQuery(t, m, "hit") - m = step(t, m, tabKey) - ls := labels(m) - if len(ls) != 202 || ls[201] != "… more" { - t.Errorf("the search shows %d rows; want 202, the last \"… more\"", len(ls)) - } - if !shows(m, "code 200+") { - t.Errorf("view got:\n%s\nwant \"code 200+\"", view(m)) - } -} - -// TestSearchStaleGrep checks that a code search for an old query shows -// nothing, and that a change of the query stops the old search. -func TestSearchStaleGrep(t *testing.T) { - m, _ := newTestModel(t) - m = press(t, m, "/") - m, first := typeQuery(t, m, "pack") - m, _ = typeQuery(t, m, "z") - msg := findGrep(first()) - if g, ok := msg.(grepMsg); !ok || g.err == nil { - t.Errorf("the old code search returns %+v, want a canceled search", msg) - } - m = step(t, m, grepMsg{query: "pack", hits: []vcs.Match{{Path: "a.go", Line: 1, Text: "old"}}}) - if len(m.overlay.search.hits) != 0 { - t.Errorf("an old result shows %+v, want nothing", m.overlay.search.hits) - } -} - -// TestSearchEmptyQuery checks that an empty query lists the files that -// the user picks in this session first, then the other files, and no -// code. -func TestSearchEmptyQuery(t *testing.T) { - m, _ := newTestModel(t) - m = press(t, m, "/") - if want := []string{"a.go", "b.go"}; !slices.Equal(labels(m), want) { - t.Errorf("an empty query lists %q, want %q", labels(m), want) - } - m, _ = typeQuery(t, m, "b.go") - m = press(t, m, "enter", "/") - if want := []string{"b.go", "a.go"}; !slices.Equal(labels(m), want) { - t.Errorf("after a pick of b.go, an empty query lists %q, want %q", labels(m), want) - } - m = step(t, m, tabKey) - if len(labels(m)) != 0 || shows(m, "no matches") { - t.Errorf("the code mode of an empty query shows %q, want nothing", labels(m)) - } -} - -// TestSearchNoMatches checks the message of a search with no result. -func TestSearchNoMatches(t *testing.T) { - m, _ := newTestModel(t) - m = openQuery(t, m, "qqqq") - if !shows(m, "no matches") { - t.Errorf("view got:\n%s\nwant \"no matches\"", view(m)) - } - m = step(t, m, tabKey) - if !shows(m, "no matches") { - t.Errorf("code mode view got:\n%s\nwant \"no matches\"", view(m)) - } -} - -// longFile writes a file of 100 lines, with "needle" on line 62. -func longFile(t *testing.T, dir string) { - t.Helper() - var b strings.Builder - for i := range 100 { - if i == 61 { - b.WriteString("needle here\n") - continue - } - fmt.Fprintf(&b, "line %d\n", i+1) - } - write(t, dir, "long.go", b.String()) -} - -// lineOf returns the index of the first line of the view that has s, or -// -1. -func lineOf(m model, s string) int { - return slices.IndexFunc(strings.Split(view(m), "\n"), func(l string) bool { - return strings.Contains(l, s) - }) -} - -// TestSearchPreview checks that the preview shows the file of the code -// match below the results, with the match in the middle, and that the -// results and the preview share the space, as in herdr-reviewr. -func TestSearchPreview(t *testing.T) { - m, dir := newTestModel(t) - longFile(t, dir) - m = openQuery(t, m, "needle") - m = step(t, m, tabKey) - results, rule := lineOf(m, "─ results ─"), lineOf(m, "─ preview · long.go ─") - hit, end := lineOf(m, "▶ 62 needle here"), lineOf(m, "╰") - if results < 0 || rule < 0 || hit < 0 { - t.Fatalf("view got:\n%s\nwant the results, the preview of long.go, and line 62 marked", view(m)) - } - if top, bottom := rule-results, end-rule; top-bottom > 1 || bottom-top > 1 { - t.Errorf("the results take %d lines and the preview %d, want the same", top, bottom) - } - if mid := (rule + end) / 2; hit < mid-1 || hit > mid+1 { - t.Errorf("the match is on line %d of the view, want the middle of the preview, %d", hit, mid) - } -} - -// TestSearchPreviewScroll checks that pgdown and pgup scroll the preview. -func TestSearchPreviewScroll(t *testing.T) { - m, dir := newTestModel(t) - longFile(t, dir) - m = openQuery(t, m, "needle") - m = step(t, m, tabKey) - _ = view(m) - top := m.overlay.search.preview.top - m = step(t, m, tea.KeyPressMsg{Code: tea.KeyPgDown}) - _ = view(m) - if got := m.overlay.search.preview.top; got != top+pickerPage { - t.Errorf("after pgdown the preview starts at %d, want %d", got, top+pickerPage) - } - m = step(t, m, tea.KeyPressMsg{Code: tea.KeyPgUp}) - m = step(t, m, tea.KeyPressMsg{Code: tea.KeyPgUp}) - _ = view(m) - if got := m.overlay.search.preview.top; got != top-pickerPage { - t.Errorf("after 2 pgups the preview starts at %d, want %d", got, top-pickerPage) - } - if m.overlay.input.Value() != "needle" { - t.Errorf("the query is %q after the scroll, want \"needle\"", m.overlay.input.Value()) - } -} - -// TestSearchPreviewFile checks the preview of a file in the files mode, -// and the note of a binary file and of no result. -func TestSearchPreviewFile(t *testing.T) { - m, dir := newTestModel(t) - write(t, dir, "bin.dat", "a\x00b") - m = openQuery(t, m, "a.go") - if !shows(m, "─ preview · a.go ─", "1 package a") { - t.Errorf("view got:\n%s\nwant the preview of a.go from line 1", view(m)) - } - m = press(t, m, "esc") - m = openQuery(t, m, "bin.dat") - if !shows(m, noteBinary) { - t.Errorf("view got:\n%s\nwant %q", view(m), noteBinary) - } - m = press(t, m, "esc") - m = openQuery(t, m, "qqqq") - if !shows(m, "─ preview ─", "no preview") { - t.Errorf("view got:\n%s\nwant \"no preview\"", view(m)) - } -} diff --git a/tui/send.go b/tui/send.go index 850a685..d4ad9db 100644 --- a/tui/send.go +++ b/tui/send.go @@ -1,16 +1,16 @@ package tui import ( + "cmp" + "context" "errors" "fmt" - "maps" "slices" "time" tea "charm.land/bubbletea/v2" "github.com/rednafi/rexdiff/internal/clipboard" - "github.com/rednafi/rexdiff/internal/turn" "github.com/rednafi/rexdiff/rex" ) @@ -23,17 +23,27 @@ 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. If the block is new, agent @@ -48,196 +58,57 @@ func (m *model) agent(block string) *agentState { } // onEvent keeps the state of the agent in the block of e, and follows the -// turns. -func (m *model) onEvent(e rex.Event) { +// 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 + } + if e.Reconnect { + m.tracker.Gap() + clear(m.agents) + m.sampled = false + m.agentsGen++ + return m.listAgents(agentsMsg{}) + } 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 = app, state - } - m.observeTurn() -} - -// member is an agent that works in the repository. -type member struct { - block, app string - state rex.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. -func (m *model) members() ([]member, bool) { - var out []member - 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 - } - cwd, err := m.rex.Cwd(m.ctx, block) - if err != nil { - m.log.Debug("agent cwd failed", "block", block, "err", err) - return nil, false // ask again with the next state - } - a.cwd, a.hasCwd = cwd, true - } - in, err := m.owns(a.cwd) - if err != nil { - m.log.Warn("agent membership failed", "err", err) - return nil, false - } - if in { - out = append(out, member{block, a.app, a.state}) - } - } - return out, true -} - -// owns tells if 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. -// -// herdr-reviewr also drops a turn whose first write can be in its start -// snapshot. That needs the time of each write, and the watcher has no -// such times. Thus a write just before the snapshot does not show in the -// turn. -func (m *model) observeTurn() { - if !m.sampled { - return - } - ms, ok := m.members() - if !ok { - return // 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([]rex.State, len(ms)) - for i, a := range ms { - states[i] = a.state - } - started, ended := m.tracker.Observe(turn.Fold(states...)) - if started { - m.startTurn(ms) - } - if ended { - m.endTurn() - } -} - -// turnWaitMessage tells why the last-turn scope has no turn. The messages -// come from herdr-reviewr. -func (m *model) turnWaitMessage() string { - if m.agentsHere != nil && !*m.agentsHere { - return "no agent works here" - } - return "waiting for the first turn" -} - -// startTurn takes a snapshot of the working tree at the start of a turn -// of the members ms. The turn stays pending until it changes a file. -func (m *model) startTurn(ms []member) { - m.pending = nil - tree, err := m.repo.Snapshot(m.ctx) - if err != nil { - m.log.Warn("turn snapshot failed", "err", err) - return - } - i := slices.IndexFunc(ms, func(a member) bool { return a.state == rex.StateWorking }) - m.pending = &turnState{ - block: ms[i].block, - app: ms[i].app, - tree: tree, - running: true, - at: time.Now(), - } -} - -// checkTurn makes the pending turn the last turn if the working tree -// differs from its start. Else the last turn stays. -func (m *model) checkTurn() { - tree, err := m.repo.Snapshot(m.ctx) - if err != nil { - m.log.Warn("turn change check failed", "err", err) - return - } - if tree != m.pending.tree { - m.promote() - } -} - -// promote makes the pending turn the last turn, and saves its tree. -func (m *model) promote() { - m.turn, m.pending = m.pending, nil - if err := m.repo.SaveTurn(m.ctx, m.turn.tree); err != nil { - m.log.Warn("turn baseline ref write failed", "err", err) - } - if m.scope == ScopeLastTurn { - m.reload() - } -} - -// endTurn ends the turn. A pending turn that changes the working tree -// becomes the last turn. After the end, a change is not from the turn. -func (m *model) endTurn() { - if m.pending != nil { - m.checkTurn() - m.pending = nil - } - if m.turn != nil { - m.turn.running = false - } - if m.scope == ScopeLastTurn { - m.reload() + a.app, a.state, a.since = app, state, cmp.Or(e.Status.At, time.Now()) } + return m.observeTurn() } // onAgents keeps the states of the agents. For a send, onAgents sends the -// review to the agent, or lets the user pick one. +// 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 { - for _, a := range msg.agents { - *m.agent(a.Block) = agentState{app: a.App, state: a.State, cwd: a.Cwd, hasCwd: true} + stale := msg.gen != m.agentsGen + if stale && !msg.send { + return nil // the list after the reconnect follows + } + if !stale { + m.keepAgents(msg.agents) } 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) - wait := max(m.sampleWait, retryFirst) - m.sampleWait = min(2*wait, retryMax) - m.sampleAt = time.Now().Add(wait) + m.sampleRetry.fail(time.Now()) } - m.observeTurn() + // 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 { @@ -266,13 +137,36 @@ func (m *model) onAgents(msg agentsMsg) tea.Cmd { // 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 s to pick one") + 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) @@ -284,13 +178,14 @@ func (m *model) reviewText() string { 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)} @@ -303,28 +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 { // 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 adds or changes 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 } - m.clearComments(msg.ids) + 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)) } @@ -338,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…") @@ -348,58 +245,57 @@ func (m *model) startSend() tea.Cmd { // copyComments copies the review to the clipboard. A good copy clears // the comments, as a send does. -func (m *model) copyComments() tea.Cmd { +func (m *model) copyComments() { if m.comments.Len() == 0 { m.flash("no comments yet") - return nil - } - var ids []int - for _, c := range m.comments.All() { - ids = append(ids, c.ID) + return } - m.sends++ - return m.copyText(m.reviewText(), "copied "+plural(len(ids), "comment"), ids) + texts := m.commentTexts() + m.copyText(m.reviewText(), "copied "+plural(len(texts), "comment"), texts) } -// copyText returns a command that writes text to the clipboard. If the -// copy works, onCopied removes the comments ids and shows done. -func (m *model) copyText(text, done string, ids []int) tea.Cmd { - ctx := m.ctx - return func() tea.Msg { - err := clipboard.Write(ctx, text) - return copiedMsg{ids: ids, done: done, err: err} - } -} +// copyTimeout is the longest time of a copy. +const copyTimeout = 2 * time.Second -// onCopied shows the result of a copy. A good copy of the comments -// removes them. The comments that the user adds or changes during the -// copy stay. -func (m *model) onCopied(msg copiedMsg) { - if msg.ids != nil { - m.sends-- - } +// 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(msg.err, clipboard.ErrNoTool): + case errors.Is(err, clipboard.ErrNoTool): m.flash("copy failed: install wl-clipboard, xclip, or xsel") - case msg.err != nil: - m.log.Warn("copy failed", "err", msg.err) + case err != nil: + m.log.Warn("copy failed", "err", err) m.flash("copy failed") default: - m.clearComments(msg.ids) - m.flash(msg.done) + m.clearComments(texts) + m.flash(done) } } -// inCommentList tells if the comment list is open. +// 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 ids after a send or a copy. An open -// comment list shows the rest, or closes if no comment stays. -func (m *model) clearComments(ids []int) { - for _, id := range ids { - m.comments.Remove(id) +// 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 { @@ -412,7 +308,9 @@ func (m *model) clearComments(ids []int) { // 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/split_test.go b/tui/split_test.go deleted file mode 100644 index ca47a9b..0000000 --- a/tui/split_test.go +++ /dev/null @@ -1,74 +0,0 @@ -package tui - -import ( - "testing" - - tea "charm.land/bubbletea/v2" -) - -// newSplitModel returns a test model that chooses the split layout, with -// a pane w columns wide. -func newSplitModel(t *testing.T, w int) model { - t.Helper() - m, _ := newTestModel(t) - m.split = true - return step(t, m, tea.WindowSizeMsg{Width: w, Height: 40}) -} - -// TestSplitNeedsWidth checks that a narrow pane shows one column, and that -// | tells why. The choice of split stays. -func TestSplitNeedsWidth(t *testing.T) { - m := newSplitModel(t, 120) - if m.splitShown() { - t.Errorf("width 120 (code %d): split shows, want unified", m.codeWidth()) - } - if m = press(t, m, "|"); m.status != "too narrow for split" || !m.split { - t.Errorf("| at width 120: status %q, split %v; want \"too narrow for split\", true", - m.status, m.split) - } -} - -// TestSplitFollowsWidth checks that split shows again when the pane gets -// wide again. -func TestSplitFollowsWidth(t *testing.T) { - m := newSplitModel(t, 200) - if !m.splitShown() { - t.Fatalf("width 200 (code %d): unified shows, want split", m.codeWidth()) - } - if m = step(t, m, tea.WindowSizeMsg{Width: 120, Height: 40}); m.splitShown() { - t.Error("after a resize to 120: split shows, want unified") - } - if m = step(t, m, tea.WindowSizeMsg{Width: 200, Height: 40}); !m.splitShown() { - t.Error("after a resize to 200: unified shows, want split") - } - if m = press(t, m, "|"); m.split || m.splitShown() { - t.Error("| at width 200: split shows, want unified") - } - if m = press(t, m, "|"); !m.splitShown() { - t.Error("| again at width 200: unified shows, want split") - } -} - -// TestSplitAddedFile checks that a file with lines on one side shows one -// column, and that | does nothing. -func TestSplitAddedFile(t *testing.T) { - m := newSplitModel(t, 200) - m = press(t, m, "b") // the branch scope adds b.go - i := -1 - for j, f := range m.files { - if f.Path == "b.go" { - i = j - } - } - if i < 0 { - t.Fatalf("the branch scope has %v, want b.go", paths(m)) - } - m.pickFile(i) - if m.splitShown() { - t.Error("an added file: split shows, want unified") - } - if m = press(t, m, "|"); m.status != "" || !m.split { - t.Errorf("| on an added file: status %q, split %v; want no status, true", - m.status, m.split) - } -} diff --git a/tui/tree.go b/tui/tree.go index 4c730df..38bf1c5 100644 --- a/tui/tree.go +++ b/tui/tree.go @@ -12,11 +12,16 @@ import ( "github.com/rednafi/rexdiff/vcs" ) -// navTree is the file list of a tab. -type navTree struct { +// 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 @@ -26,7 +31,7 @@ type navTree struct { // 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 *navTree) rebuild(entries []tree.Entry, expanded bool, open int) { +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 { @@ -53,7 +58,7 @@ func (t *navTree) rebuild(entries []tree.Entry, expanded bool, open int) { } // file returns the index of the file at the cursor, or -1 on a directory. -func (t *navTree) file() int { +func (t *fileListTree) file() int { if t.cur >= len(t.rows) { return -1 } @@ -61,7 +66,7 @@ func (t *navTree) file() int { } // dir returns the row at the cursor if it is a directory. -func (t *navTree) dir() (tree.Row, bool) { +func (t *fileListTree) dir() (tree.Row, bool) { if t.cur >= len(t.rows) || t.rows[t.cur].File >= 0 { return tree.Row{}, false } @@ -70,7 +75,7 @@ func (t *navTree) dir() (tree.Row, bool) { // rowOf returns the row of the file with index i, or -1 if no row shows // it. -func (t *navTree) rowOf(i int) int { +func (t *fileListTree) rowOf(i int) int { if i < 0 { return -1 } @@ -79,7 +84,7 @@ func (t *navTree) rowOf(i int) int { // nextFile returns the first file row after row from in direction d (1 or // -1), or -1. -func (t *navTree) nextFile(from, d int) int { +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 @@ -90,7 +95,7 @@ func (t *navTree) nextFile(from, d int) int { // setExpanded expands or collapses the directory dir. It reports whether // the state changes. -func (t *navTree) setExpanded(dir string, want bool) bool { +func (t *fileListTree) setExpanded(dir string, want bool) bool { if (t.expanded != t.toggled[dir]) == want { return false } @@ -107,25 +112,21 @@ func (t *navTree) setExpanded(dir string, want bool) bool { // reveal expands each directory above path. It reports whether one // changes. -func (t *navTree) reveal(path string) bool { +func (t *fileListTree) reveal(path string) bool { changed := false - for dir := path; ; { - i := strings.LastIndexByte(dir, '/') - if i < 0 { - return changed - } - dir = dir[:i] + 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() *navTree { +func (m *model) list() *fileListTree { switch m.tab { case tabChanges: return &m.changeTree case tabFiles: - return &m.fileTree + return &m.allTree } return nil } @@ -133,8 +134,8 @@ func (m *model) list() *navTree { // 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.files)) - for i, f := range m.files { + 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 @@ -149,8 +150,8 @@ func (m *model) buildChangeTree() { // 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.files)) - for _, f := range m.files { + changed := make(map[string]bool, len(m.changeFiles)) + for _, f := range m.changeFiles { changed[f.Path] = true } es := make([]tree.Entry, len(m.allFiles)) @@ -158,7 +159,7 @@ func (m *model) buildFileTree() { p, isDir := strings.CutSuffix(e.Path, "/") es[i] = tree.Entry{Path: p, Changed: changed[p], Ignored: e.Ignored, Dir: isDir} } - t := &m.fileTree + t := &m.allTree t.rebuild(es, false, m.allSel) if i := t.file(); i >= 0 { m.allSel = i @@ -182,8 +183,7 @@ 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++ { - dir, isDir := strings.CutSuffix(es[i].Path, "/") - if isDir && es[i].Ignored && m.fileTree.toggled[dir] { + if dir, ok := m.openIgnored(es[i]); ok { es = append(es, m.repo.IgnoredDir(dir)...) } } @@ -191,25 +191,38 @@ func (m *model) withIgnored(es []vcs.Entry) []vcs.Entry { return es } -// shownIgnored returns the ignored directories that the user expands in -// the Files tab, when that tab shows. The watcher reports their changes. -func (m *model) shownIgnored() []string { +// 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 dirs []string + var paths []string for _, e := range m.allFiles { - dir, isDir := strings.CutSuffix(e.Path, "/") - if isDir && e.Ignored && m.fileTree.toggled[dir] { - dirs = append(dirs, dir) + 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 dirs + 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] } -// onDir reports whether the file list has the focus and its cursor is on +// atDir reports whether the file list has the focus and its cursor is on // a directory. -func (m *model) onDir() bool { +func (m *model) atDir() bool { if t := m.list(); t != nil && m.focusList { _, ok := t.dir() return ok diff --git a/tui/tree_test.go b/tui/tree_test.go deleted file mode 100644 index f2c4c89..0000000 --- a/tui/tree_test.go +++ /dev/null @@ -1,204 +0,0 @@ -package tui - -import ( - "os" - "path/filepath" - "slices" - "strings" - "testing" - - tea "charm.land/bubbletea/v2" - "github.com/charmbracelet/x/ansi" - - "github.com/rednafi/rexdiff/vcs" -) - -// newTreeModel makes a repository with changes in src/a.go, src/b.go, and -// top.go, and an ignored directory out. The model shows the Changes tab -// with the focus on the file list. -func newTreeModel(t *testing.T) (model, string) { - t.Helper() - dir := t.TempDir() - run := func(args ...string) { git(t, dir, args...) } - run("init", "-q", "-b", "main") - if err := os.MkdirAll(filepath.Join(dir, "src"), 0o755); err != nil { - t.Fatal(err) - } - if err := os.MkdirAll(filepath.Join(dir, "out", "sub"), 0o755); err != nil { - t.Fatal(err) - } - for _, p := range []string{"src/a.go", "src/b.go", "top.go"} { - write(t, dir, p, "package a\n") - } - write(t, dir, ".gitignore", "out/\n") - run("add", ".") - run("commit", "-qm", "init") - for _, p := range []string{"src/a.go", "src/b.go", "top.go"} { - write(t, dir, p, "package a\n\nvar x = 1\n") - } - write(t, dir, "out/x.txt", "x\n") - write(t, dir, "out/sub/y.txt", "y\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}) - m.focusList = true - return m, dir -} - -// rowNames returns the names of the rows of the file list of the current -// tab, with "/" after a directory. -func rowNames(m model) []string { - var out []string - for _, r := range m.list().rows { - n := r.Name - if r.File < 0 { - n += "/" - } - out = append(out, n) - } - return out -} - -// shownPath returns the path of the file that the current tab shows. -func shownPath(m model) string { - if m.tab == tabFiles { - return m.allFiles[m.allSel].Path - } - return m.changedFile().Path -} - -// TestTreeKeys checks the cursor, the expand and collapse keys, and the -// keys f and F in the tree of the Changes tab. -func TestTreeKeys(t *testing.T) { - m, _ := newTreeModel(t) - if got, want := rowNames(m), []string{"src/", "a.go", "b.go", "top.go"}; !slices.Equal(got, want) { - t.Fatalf("rows %q, want %q", got, want) - } - if m.changeTree.cur != 1 || shownPath(m) != "src/a.go" { - t.Fatalf("start: cursor row %d on %s, want row 1 on src/a.go", m.changeTree.cur, shownPath(m)) - } - - // On a directory, the pane keeps the file. - m = press(t, m, "k") - if m.changeTree.cur != 0 || shownPath(m) != "src/a.go" { - t.Errorf("k: cursor row %d, shows %s; want row 0, src/a.go", m.changeTree.cur, shownPath(m)) - } - m = step(t, m, tea.KeyPressMsg{Code: tea.KeyLeft}) - if got, want := rowNames(m), []string{"src/", "top.go"}; !slices.Equal(got, want) { - t.Errorf("← on src: rows %q, want %q", got, want) - } - m = step(t, m, tea.KeyPressMsg{Code: tea.KeyRight}) - if got := rowNames(m); len(got) != 4 { - t.Errorf("→ on src: rows %q, want src expanded", got) - } - - // f and F skip the directory. - m = press(t, m, "f") - if shownPath(m) != "src/a.go" || m.changeTree.cur != 1 { - t.Errorf("f from src: shows %s at row %d, want src/a.go at row 1", shownPath(m), m.changeTree.cur) - } - m = press(t, m, "f", "f") - if shownPath(m) != "top.go" { - t.Errorf("f f: shows %s, want top.go", shownPath(m)) - } - m = press(t, m, "F", "F", "F") - if shownPath(m) != "src/a.go" || m.changeTree.cur != 1 { - t.Errorf("F past the first file: shows %s at row %d, want src/a.go at row 1", - shownPath(m), m.changeTree.cur) - } - - // A file row keeps its +N -N, and a collapsed directory keeps the - // cursor after a reload. - if !shows(m, "a.go", "+2 -0") { - t.Errorf("view got:\n%s\nwant a.go with +2 -0", view(m)) - } - m = press(t, m, "k") - m = step(t, m, tea.KeyPressMsg{Code: tea.KeyLeft}) - m.reload() - if r, ok := m.changeTree.dir(); !ok || r.Dir != "src" || r.Expanded { - t.Errorf("after a reload: cursor on %+v, want on the collapsed src", r) - } -} - -// TestFileTree checks the tree of the Files tab: the directories start -// collapsed, a collapsed directory with a change has a dot, a click -// toggles, and an ignored directory reads its entries when it expands. -func TestFileTree(t *testing.T) { - m, _ := newTreeModel(t) - m = press(t, m, "2") - m.focusList = true - if got, want := rowNames(m), []string{"out/", "src/", ".gitignore", "top.go"}; !slices.Equal(got, want) { - t.Fatalf("rows %q, want %q", got, want) - } - if got := listLine(m, "src/"); !strings.Contains(got, "•") { - t.Errorf("collapsed src is %q, want a dot for its change", got) - } - if got := listLine(m, "out/"); strings.Contains(got, "•") { - t.Errorf("out is %q, want no dot", got) - } - - // A click on src expands it, and a second click collapses it. - y := m.listY() + 1 + slices.Index(rowNames(m), "src/") - m = click(t, m, m.listX()+3, y) - if got := rowNames(m); !slices.Contains(got, "a.go") { - t.Errorf("after a click on src: rows %q, want its files", got) - } - m.click = clickChain{} // not a double click - m = click(t, m, m.listX()+3, y) - if got := rowNames(m); slices.Contains(got, "a.go") { - t.Errorf("after a second click on src: rows %q, want it collapsed", got) - } - - // → on the ignored out reads its entries. - m.fileTree.cur = 0 - m = step(t, m, tea.KeyPressMsg{Code: tea.KeyRight}) - want := []string{"out/", "sub/", "x.txt", "src/", ".gitignore", "top.go"} - if got := rowNames(m); !slices.Equal(got, want) { - t.Fatalf("→ on out: rows %q, want %q", got, want) - } - if r := m.fileTree.rows[2]; !r.Ignored || m.allFiles[r.File].Path != "out/x.txt" { - t.Errorf("x.txt row %+v, want the ignored out/x.txt", r) - } - // The expanded directory stays open after a reload. - m.reloadFiles() - if got := rowNames(m); !slices.Equal(got, want) { - t.Errorf("after a reload: rows %q, want %q", got, want) - } - m = press(t, m, "j", "j") - if shownPath(m) != "out/x.txt" || !shows(m, "1 x") { - t.Errorf("j j: shows %s, view:\n%s\nwant out/x.txt", shownPath(m), view(m)) - } -} - -// listLine returns the line of the file list that has text. -func listLine(m model, text string) string { - for _, l := range m.listLines(m.listRows()) { - if l := ansi.Strip(l); strings.Contains(l, text) { - return l - } - } - return "" -} - -// TestShownIgnored checks that the watcher gets the expanded ignored -// directories of the Files tab, as in herdr-reviewr. -func TestShownIgnored(t *testing.T) { - m, _ := newTreeModel(t) - m = press(t, m, "2") - if got := m.shownIgnored(); len(got) != 0 { - t.Errorf("collapsed: %q, want none", got) - } - m.fileTree.cur = 0 - m = step(t, m, tea.KeyPressMsg{Code: tea.KeyRight}) // expand out - if got := m.shownIgnored(); !slices.Equal(got, []string{"out"}) { - t.Errorf("out expanded: %q, want [out]", got) - } - if m = press(t, m, "1"); len(m.shownIgnored()) != 0 { - t.Errorf("in the Changes tab: %q, want none", m.shownIgnored()) - } -} diff --git a/tui/tui.go b/tui/tui.go index d63b016..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 } @@ -80,7 +95,10 @@ func CheckConfig(text string) error { // settings are not valid. It also returns an error if Repo is nil, or if // 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,43 +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 := 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), tea.WithEnvironment(env)) + var wg sync.WaitGroup + if o.Rex != nil { + // 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.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.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 m.watch, err = o.Repo.Watch(ctx); err != nil { - // Without the watcher, each tick reloads. - m.log.Warn("watch failed", "err", err) + if err := m.startWatch(); err != nil { + m.dropWatch(err) } - var saved *savedState + var saved *resumeState if o.Resume != "" { - if saved, err = loadState(o.Resume); err != nil { - m.flash("cannot read the comments from before the update: " + err.Error()) + 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.reload() + m.loadNow() 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) - } - } - program := tea.NewProgram(&m, tea.WithContext(ctx)) - if o.Rex != nil { - go o.Rex.Watch(ctx, func(e rex.Event) { program.Send(eventMsg(e)) }) - } - final, err := program.Run() - if fm, ok := final.(*model); ok && err == nil { - if fm.restartState != "" { - // 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) + 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) } - return fm.restartState, nil + cancel(fmt.Errorf("rex label: done: %w", context.Canceled)) + cancelTimeout() } - return "", err + return m } diff --git a/tui/tui_test.go b/tui/tui_test.go deleted file mode 100644 index d75d2ba..0000000 --- a/tui/tui_test.go +++ /dev/null @@ -1,906 +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 -} - -// toTop moves the cursor of the current pane to the first row. -func toTop(m model) model { - m.cur().cur = 0 - m.ensureVisible() - 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}} -} - -// sample returns the first list of the agents: the agent app in block, -// in dir, at rest. -func sample(block, app, dir string) agentsMsg { - a := rex.Agent{Block: block, Cwd: dir, App: app, State: rex.StateIdle} - return agentsMsg{agents: []rex.Agent{a}} -} - -// 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 -} - -// TestScopes checks the files that each scope shows. -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("after a click on the commit in the header: mode %v, "+ - "want the commit picker", m.mode) - } - m = press(t, m, "esc") - - // The turn scope shows only the changes of the turn of an agent. - m = press(t, m, "t") - if m.changes.note != "waiting for the first turn" { - t.Errorf("turn scope before a turn: note %q, want a note that no turn ran", m.changes.note) - } - m = step(t, m, sample("block:agent", "pi", dir)) - 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("view got:\n%s\nwant the agent of the turn in the header", v) - } -} - -// TestComment checks comments in the two layouts, and the send and quit -// keys with unsent comments. -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.Fatalf("after c: mode %v, want the comment box", m.mode) - } - m = press(t, m, "h", "i", "enter") - // The split layout uses two rows for the same change. It needs a wide - // pane. - m = step(t, m, tea.WindowSizeMsg{Width: 200, Height: 40}) - m = press(t, toTop(press(t, m, "|")), "]", "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 got:\n%s\nwant:\n%s", got, want) - } - if m.status != "comment added" { - t.Errorf("after a save: status %q, want \"comment added\"", m.status) - } - m.status = "" // the status hides the count - if !shows(m, "💬 hi", "2 unsent comments") { - t.Errorf("view got:\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) - } - // 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: no quit prompt, want a prompt") - } -} - -// TestTabs checks the Files tab and the PR tab. -func TestTabs(t *testing.T) { - m, _ := newTestModel(t) - - m = press(t, m, "2") - if !shows(m, "Files (2)", "package a") { - t.Errorf("Files tab got:\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 got:\n%s\nwant the note that there is no git remote", - v) - } -} - -// TestPRChecks checks that the PR tab shows the counts of the checks of a -// pull request, and the names of the checks that fail. -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 got:\n%s\nwant the checks", view(m)) - } -} - -// TestSmallScreens checks that every key works on screens down to 1x1 and -// that the view fits 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, want %d or fewer", - size.w, size.h, k, len(lines), max(size.h, 1)) - } - for _, l := range lines { - if w := ansi.StringWidth(l); w > max(size.w, 1) { - t.Errorf("%dx%d after %q: line width %d, want %d or fewer: %q", - size.w, size.h, k, w, max(size.w, 1), l) - break - } - } - } - } -} - -// TestDragAfterReload checks that a drag does not panic when the pane gets -// shorter. -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 finds 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.mainHeight()) - y := slices.Index(rowAt, i) - if y < 0 { - t.Fatalf("row %d is not on the screen, want it on the screen", i) - } - return m.mainY() + 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 checks a comment from a click on the old line number of -// an unchanged line. -func TestClickOldNumber(t *testing.T) { - m, _ := newTestModel(t) - // The comment goes on the new side, so that it shows and d can delete it. - 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("view got:\n%s\nwant the comment", v) - } - if m = press(t, m, "d"); m.comments.Len() != 0 { - t.Errorf("after d: comments %+v, want none", m.comments.All()) - } -} - -// TestClickCommentBox checks that a click does not change the draft or close -// the comment box. -func TestClickCommentBox(t *testing.T) { - m, _ := newTestModel(t) - m = press(t, m, "]", "c", "h", "i") - _, rowAt := m.mainLines(m.mainHeight()) - 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 checks that the footer shows the error of Rex after a send -// fails. -func TestSendError(t *testing.T) { - m, _ := newTestModel(t) - fakeRex(t, &m, "echo 'Error: no Rex server' >&2\nexit 1\n") - 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 checks that the Changes tab shows an error, and not each -// file as added, when the object of the HEAD commit is missing. -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. -func TestParseConfig(t *testing.T) { - // Each action in src/keymap.rs of herdr-reviewr has a key. - 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 i, n := range names { - keys[n] = []string{string(rune('A' + i))} // a key for each action - } - all, err := json.Marshal(map[string]any{"keybindings": keys}) - if err != nil { - t.Fatal(err) - } - if _, err := parseConfig(string(all)); err != nil { - t.Errorf("parseConfig with every action: %v, want no error", err) - } - - // An unknown key is an error. - unknown := []struct{ text, key string }{ - {`{"layuot": "split"}`, `"layuot"`}, - {`{"keybindings": {"next-fiel": ["x"]}}`, `"keybindings.next-fiel"`}, - {`{"theme": "monokai"}`, `"theme"`}, - // Lua keys are case-sensitive. 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": "middle"}`, `file_list: expected one of right, bottom, left, top, not "middle"`}, - {`{"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", - "diff_signs": true - }` - c, err := parseConfig(good) - if err != nil { - t.Fatalf("parseConfig(good) = %v, want no error", err) - } - if c.focusAgent() || c.FileList != positionLeft || c.Layout != LayoutUnified || !c.DiffSigns { - t.Errorf("good config = %+v, want no focus, the list on the left, "+ - "the default layout, and signs", c) - } - if c, err := parseConfig(""); err != nil || c.Layout != defaultConfig().Layout { - t.Errorf(`parseConfig("") = %+v, %v; want the defaults`, c, err) - } -} - -// TestDiffSigns checks that a bar marks a changed line by default, and that -// the setting diff_signs changes the bar to + and -. -func TestDiffSigns(t *testing.T) { - line := func(m model, code string) string { - for l := range strings.Lines(view(m)) { - if strings.Contains(l, code) { - return l - } - } - return "" - } - m, _ := newTestModel(t) - if l := line(m, "x := 2"); !strings.Contains(l, "▌ x := 2") { - t.Errorf("by default, the inserted line is %q, want a bar", l) - } - cfg, err := parseConfig(`{"diff_signs": true}`) - if err != nil { - t.Fatal(err) - } - m.cfg = cfg - m.rerender() - if l := line(m, "x := 2"); !strings.Contains(l, "+ x := 2") { - t.Errorf("with diff_signs, the inserted line is %q, want +", l) - } - if l := line(m, "return 1"); !strings.Contains(l, "- return 1") { - t.Errorf("with diff_signs, the deleted line is %q, want -", l) - } -} - -// TestConfigFlags checks that the flags override 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.listPos != positionLeft || m.listW != 40 { - t.Errorf("split %v, scope %q, list %q, width %d; want true, branch, left, 40", - m.split, m.scope, m.listPos, 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.reload() - // The pane is wide enough for split. - m = step(t, m, tea.WindowSizeMsg{Width: 200, Height: 40}) - m = press(t, m, "X") - if m.split { - t.Errorf("after the key X of the configuration: split %v, want false", - m.split) - } -} - -// TestNotice checks that the footer shows all of a long notice on its own -// lines. -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}) - v := view(m) - // Each word of the notice is in the view, so a hyphen does not push - // "--" past the edge of the screen. - words := strings.Fields(v) - for _, w := range strings.Fields(notice)[:30] { - if !slices.Contains(words, w) { - t.Errorf("view got:\n%s\nwant %q of the notice", v, w) - } - } - if !strings.Contains(v, strings.Repeat("x", 80)) { - t.Errorf("view got:\n%s\nwant the start of a word wider than the screen", - v) - } - for line := range strings.SplitSeq(v, "\n") { - if ansi.StringWidth(line) > 80 { - t.Errorf("line %q is %d cells wide, want 80 or fewer", - line, ansi.StringWidth(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, " + - "want no tint") - } - m = step(t, m, uv.UnknownOscEvent("\x1b]4;2;rgb:0000/ffff/0000\x1b\\")) - if !strings.Contains(m.View().Content, tint) { - t.Errorf("the view has no tint %q, want the green of the terminal", tint) - } -} - -// wrapModel returns a model that shows a change that makes line 4 of a.go -// too long for the screen. The screen 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 turns wrap on and off. -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("view got:\n%s\nwant the end of the long line", v) - } - // The cursor moves by source lines. - m = press(t, m, "]") - for range len(m.changes.lines) { - if m.changes.cont(m.changes.cur) { - t.Fatalf("the cursor is on continued row %d, want a source line", - m.changes.cur) - } - m = press(t, m, "j") - } - - // A comment on wrapped lines gets the same snippet. - m = press(t, toTop(m), "]", "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("view got:\n%s\nwant the comment below the end of the line", - 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: xoff %d, want 0", 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.changes.cur = len(m.changes.rows) - 1 - m = press(t, m, "w") - if got := m.target(m.changes.cur, diff.SideNew).line; got != 5 { - t.Errorf("after w: the cursor is on line %d, want 5", 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("after w: the cursor is on row %+v, want the second hunk header", - 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 = toTop(m) - 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 stops at row %d of %d, want a scroll", - 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.mainHeight(), 0); m.changes.top > last { - t.Errorf("top %d of %d rows on a screen of %d rows, want %d or less", - m.changes.top, len(m.changes.lines), m.mainHeight(), last) - } - if m = press(t, m, "j"); m.changes.top > m.changes.cur { - t.Errorf("after j: top %d, cursor %d; want the cursor on the screen", - m.changes.top, m.changes.cur) - } - - // A cursor on the line shows its first rows. The comment box shows below - // the last row. An e on a line with a comment edits the comment. - m = step(t, m, tea.WindowSizeMsg{Width: 30, Height: 12}) - m = toTop(m) - 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("view got:\n%s\nwant the first rows of the line", v) - } - m = press(t, m, "c", "o", "k", "enter", "e", "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("view got:\n%s\nwant the comment box below the end of the line", - view(m)) - } - if strings.Contains(view(m), "💬 ok") { - t.Errorf("view got:\n%s\nwant no comment above the box that edits it", - view(m)) - } -} - -// TestHostileText checks that the screen does not show raw escape sequences -// from a file name, a comment, an agent, or an error from Rex. -func TestHostileText(t *testing.T) { - // The diff tests check the review text. - 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 raw escape sequence, want none", 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, sample("b", "pi"+osc, dir)) - m = step(t, m, status("b", "pi"+osc, rex.StateWorking)) - write(t, dir, "turn.go", "x\n") - m = step(t, m, status("b", "pi"+osc, rex.StateIdle)) - m = press(t, m, "t") - if m.turn == nil { - t.Fatal("no turn after the agent writes a file") - } - raw("the turn in the header", m) -} - -// TestMouseOffScreen checks that mouse events outside the screen do not -// panic, also while an overlay is open and the screen changes size. -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 checks that keys do not panic in a repository with no -// commits and no files, with key bindings that conflict. -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 that the footer shows the keys of the select mode, -// and the find band. -func TestFooterHints(t *testing.T) { - m, _ := newTestModel(t) - if m = press(t, m, "]", "v"); !strings.Contains(view(m), "extend") { - t.Errorf("select mode: view got:\n%s\nwant the hint extend", view(m)) - } - m = press(t, m, "esc") - m = step(t, m, tea.KeyPressMsg{Code: 'f', Mod: tea.ModCtrl}) - if !strings.HasPrefix(lastLine(m), "find Find in file…") { - t.Errorf("find band: footer %q, want the empty band", lastLine(m)) - } - m = press(t, m, "r", "e", "t", "u", "r", "n", "enter") - if l := lastLine(m); !strings.HasPrefix(l, "find return") || !strings.HasSuffix(l, "2/2") { - t.Errorf("find band: footer %q, want the query and 2/2", l) - } -} 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/turn_test.go b/tui/turn_test.go deleted file mode 100644 index ee9c393..0000000 --- a/tui/turn_test.go +++ /dev/null @@ -1,216 +0,0 @@ -package tui - -import ( - "slices" - "testing" - "time" - - tea "charm.land/bubbletea/v2" - - "github.com/rednafi/rexdiff/rex" -) - -// turnModel returns a test model in the last-turn scope, after the first -// list of the agents with pi at rest in the repository. -func turnModel(t *testing.T) (model, string) { - t.Helper() - m, dir := newTestModel(t) - m = press(t, m, "t") - return step(t, m, sample("p", "pi", dir)), dir -} - -// TestTurnNeedsSample checks that a state before the first list of the -// agents starts no turn. The start of that turn is not seen. -func TestTurnNeedsSample(t *testing.T) { - m, dir := newTestModel(t) - m.agents["p"] = &agentState{cwd: dir, hasCwd: true} - m = step(t, m, status("p", "pi", rex.StateIdle)) - m = step(t, m, status("p", "pi", rex.StateWorking)) - if m.pending != nil { - t.Errorf("pending %+v before the first list, want none", m.pending) - } - // The first list sees pi at work. That is no start. - working := sample("p", "pi", dir) - working.agents[0].State = rex.StateWorking - m = step(t, m, working) - if m.pending != nil { - t.Errorf("pending %+v after a first list at work, want none", m.pending) - } -} - -// TestTurnWithoutChange checks that a turn that changes no file does not -// become the last turn. -func TestTurnWithoutChange(t *testing.T) { - m, _ := turnModel(t) - m = step(t, m, status("p", "pi", rex.StateWorking)) - if m.pending == nil { - t.Fatal("no pending turn after the start") - } - m = step(t, m, status("p", "pi", rex.StateIdle)) - if m.turn != nil || m.pending != nil { - t.Errorf("turn %+v, pending %+v, want none", m.turn, m.pending) - } - if m.changes.note != "waiting for the first turn" { - t.Errorf("note %q, want \"waiting for the first turn\"", m.changes.note) - } -} - -// TestTurnKeepsOldBaseline checks that a turn with no change keeps the -// last turn that changes a file. -func TestTurnKeepsOldBaseline(t *testing.T) { - m, dir := turnModel(t) - m = step(t, m, status("p", "pi", rex.StateWorking)) - write(t, dir, "c.go", "package a\n") - m = step(t, m, status("p", "pi", rex.StateIdle)) - if m.turn == nil { - t.Fatal("no turn after a change") - } - first := m.turn.tree - m = step(t, m, status("p", "pi", rex.StateWorking)) - m = step(t, m, status("p", "pi", rex.StateIdle)) - if m.turn.tree != first { - t.Error("a turn with no change replaces the last turn") - } - if got := paths(m); !slices.Equal(got, []string{"c.go"}) { - t.Errorf("last turn shows %v, want [c.go]", got) - } -} - -// TestTurnPromotesOnChange checks that the first change in a turn makes -// it the last turn, before the turn ends. -func TestTurnPromotesOnChange(t *testing.T) { - m, dir := turnModel(t) - m = step(t, m, status("p", "pi", rex.StateWorking)) - m = step(t, m, tickMsg{}) - if m.turn != nil { - t.Fatal("a tick with no change promotes the turn") - } - write(t, dir, "c.go", "package a\n") - m = step(t, m, tickMsg{}) // without a watcher, each tick sees a change - if m.turn == nil || !m.turn.running || m.pending != nil { - t.Fatalf("turn %+v, pending %+v, want a running turn", m.turn, m.pending) - } - if m.repo.SavedTurn(t.Context()) != m.turn.tree { - t.Error("the promoted turn is not saved") - } - if got := paths(m); !slices.Equal(got, []string{"c.go"}) { - t.Errorf("last turn shows %v, want [c.go]", got) - } - if !shows(m, "pi working") { - t.Errorf("view got:\n%s\nwant the running turn in the header", view(m)) - } -} - -// TestTurnTwoAgents checks that a turn ends only when all the agents in -// the repository rest. -func TestTurnTwoAgents(t *testing.T) { - m, dir := turnModel(t) - m = step(t, m, agentsMsg{agents: []rex.Agent{ - {Block: "p", Cwd: dir, App: "pi", State: rex.StateIdle}, - {Block: "q", Cwd: dir, App: "cc", State: rex.StateIdle}, - }}) - m = step(t, m, status("p", "pi", rex.StateWorking)) - m = step(t, m, status("q", "cc", rex.StateWorking)) - write(t, dir, "c.go", "package a\n") - m = step(t, m, status("p", "pi", rex.StateIdle)) - if m.pending == nil || m.turn != nil { - t.Fatalf("turn %+v, pending %+v, want the turn still pending", m.turn, m.pending) - } - m = step(t, m, status("q", "cc", rex.StateBlocked)) - if m.pending == nil { - t.Fatal("a blocked agent ends the turn") - } - m = step(t, m, status("q", "cc", rex.StateDone)) - if m.turn == nil || m.turn.app != "pi" || m.turn.running { - t.Errorf("turn %+v, want the ended turn of pi", m.turn) - } -} - -// TestTurnOutsideAgent checks that an agent outside the repository starts -// no turn, and that the scope tells that no agent works here. -func TestTurnOutsideAgent(t *testing.T) { - m, dir := newTestModel(t) - m = press(t, m, "t") - m = step(t, m, sample("p", "pi", t.TempDir())) - if m.changes.note != "no agent works here" { - t.Errorf("note %q, want \"no agent works here\"", m.changes.note) - } - m = step(t, m, status("p", "pi", rex.StateWorking)) - write(t, dir, "c.go", "package a\n") - m = step(t, m, status("p", "pi", rex.StateIdle)) - if m.turn != nil || m.pending != nil { - t.Errorf("turn %+v, pending %+v, want none", m.turn, m.pending) - } - // A relative or unknown directory is not in the repository. - m = step(t, m, sample("p", "pi", ".")) - if m.changes.note != "no agent works here" { - t.Errorf("relative cwd: note %q, want \"no agent works here\"", m.changes.note) - } -} - -// TestTurnUnknownCwdHolds checks that an agent whose directory is not -// known holds the state, so it does not end a turn. -func TestTurnUnknownCwdHolds(t *testing.T) { - m, dir := turnModel(t) - m = step(t, m, status("p", "pi", rex.StateWorking)) - write(t, dir, "c.go", "package a\n") - // Outside Rex, the program cannot ask for the directory of block q. - m = step(t, m, status("q", "cc", rex.StateIdle)) - m = step(t, m, status("p", "pi", rex.StateIdle)) - if m.pending == nil || m.turn != nil { - t.Fatalf("turn %+v, pending %+v, want the turn still pending", m.turn, m.pending) - } - m = step(t, m, eventMsg{Block: "q", Closed: true}) - if m.turn == nil { - t.Error("the turn does not end after the unknown agent closes") - } -} - -// tickAgents runs a tick of m and returns the agent lists that its -// commands give. -func tickAgents(t *testing.T, m *model) []agentsMsg { - t.Helper() - batch, _ := m.onTick()().(tea.BatchMsg) - var out []agentsMsg - for _, c := range batch { - got := make(chan tea.Msg, 1) - go func() { got <- c() }() - select { - case msg := <-got: - if a, ok := msg.(agentsMsg); ok { - out = append(out, a) - } - case <-time.After(300 * time.Millisecond): // the next tick - } - } - return out -} - -// TestSampleRetry checks that a failed first list of the agents is -// tried again after a wait that doubles, as in herdr-reviewr. Without -// the list, the turns do not start. -func TestSampleRetry(t *testing.T) { - m, dir := newTestModel(t) - logRex(t, &m) // its output is no list - m = step(t, m, m.listAgents(agentsMsg{})()) - if m.sampled || m.sampleAt.IsZero() || m.sampleWait != 2*time.Second { - t.Fatalf("after a failed list: sampled %v, at %v, wait %v; want a retry", - m.sampled, m.sampleAt, m.sampleWait) - } - if got := tickAgents(t, &m); len(got) != 0 { - t.Errorf("a tick before the wait lists %d times, want 0", len(got)) - } - m.sampleAt = time.Now().Add(-time.Millisecond) - got := tickAgents(t, &m) - if len(got) != 1 || got[0].err == nil { - t.Fatalf("a tick after the wait lists %d times, want 1 that fails", len(got)) - } - if m = step(t, m, got[0]); m.sampleWait != 4*time.Second { - t.Errorf("after a second failure: wait %v, want 4s", m.sampleWait) - } - m = step(t, m, sample("p", "pi", dir)) - m.sampleAt = time.Now().Add(-time.Millisecond) - if !m.sampled || len(tickAgents(t, &m)) != 0 { - t.Errorf("after a good list: sampled %v, want true and no more lists", m.sampled) - } -} diff --git a/tui/view.go b/tui/view.go index f3f9f10..dfddb46 100644 --- a/tui/view.go +++ b/tui/view.go @@ -20,7 +20,10 @@ import ( // 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) @@ -33,6 +36,12 @@ var ( // truncate cuts s to w columns, with an ellipsis. func truncate(s string, w int) string { return ansi.Truncate(s, max(w, 0), "…") } +// 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 { @@ -40,7 +49,7 @@ func (m *model) selStyle() lipgloss.Style { 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) @@ -177,7 +186,7 @@ func (m *model) agentLabel() string { } 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++ } } @@ -188,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") @@ -210,7 +219,7 @@ func (m *model) listLines(h int) []string { return nil } w := m.listWidth() - title := fmt.Sprintf("Changes (%d)", len(m.files)) + title := fmt.Sprintf("Changes (%d)", len(m.changeFiles)) if m.tab == tabFiles { n := 0 for _, e := range m.allFiles { @@ -238,7 +247,7 @@ func (m *model) treeRow(r tree.Row, w int) string { case r.File < 0: return m.dirRow(r, indent, w) case m.tab == tabChanges: - return m.changedRow(m.files[r.File], indent, r.Name, w) + return m.changedRow(m.changeFiles[r.File], indent, r.Name, w) } return m.entryRow(m.allFiles[r.File], indent, r.Name, w) } @@ -255,7 +264,7 @@ func (m *model) dirRow(r tree.Row, indent string, w int) string { mark = noteStyle.Render("•") } room := w - ansi.StringWidth(indent) - lipgloss.Width(mark) - 4 - name := lipgloss.NewStyle().Bold(true).Render(fitName(r.Name+"/", room)) + name := boldStyle.Render(fitName(r.Name+"/", room)) if r.Ignored { name = dimStyle.Render(fitName(r.Name+"/", room)) } @@ -264,7 +273,7 @@ func (m *model) dirRow(r tree.Row, indent string, w int) string { // 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 changed, indent, name string, w int) string { +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(m.anchorOf(tabChanges), f.Path); c > 0 { @@ -276,14 +285,14 @@ func (m *model) changedRow(f changed, indent, name string, w int) string { return fileRow(indent, statusMark(f.Status), name, stat, w) } -// entryRow renders file f of the Files tab in the file list, w columns +// 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(f vcs.Entry, indent, name string, w int) string { - if f.Ignored { +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(m.anchorOf(tabFiles), 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(indent, " ", name, stat, w) @@ -300,10 +309,14 @@ func (m *model) selectedRow(r string, w int) string { } // 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() - if t == nil { + 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)) } @@ -331,8 +344,9 @@ func fitName(p string, room int) string { } // 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} @@ -350,14 +364,19 @@ func (m *model) mainLines(h int) (lines []string, rowAt []int) { add(l, -1) } } - hits := m.findRows() + 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, hits[i])+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()) @@ -373,8 +392,8 @@ 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" @@ -412,18 +431,43 @@ func (m *model) rowMark(i int, t *targetLine, lo, hi int, hit bool) string { case t == nil || p.cont(i): return commentMark(nil) } - return commentMark(m.comments.At(m.anchor(), 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.anchor(), 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 } @@ -480,7 +524,7 @@ func (m *model) ensureVisible() { p.top = max(p.top, last-h+1) 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 @@ -490,12 +534,14 @@ 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. @@ -531,9 +577,9 @@ func wrapWords(s string, w int) string { // footerLine renders the status and the help of the keys, or the find // band. -func (m *model) footerLine() string { +func (m *model) footerLine(hits []findHit) string { if m.mode == modeFind { - return m.findBand() + return m.findBand(hits) } m.help.SetWidth(m.w) var badge string @@ -553,7 +599,7 @@ func (m *model) footerLine() string { tab: m.tab, split: m.splitShown(), visual: m.cur().anchor >= 0, - onFold: m.onFold(), + atFold: m.atFold(), armed: m.armed, } if m.help.ShowAll { @@ -571,7 +617,7 @@ func (m *model) footerLine() string { } // bodyHeight returns the number of lines between the header and the footer. -func (m *model) bodyHeight() int { return m.heightAbove(m.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 { @@ -580,12 +626,19 @@ 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() mh := m.mainHeightOf(h) - ml, _ := m.mainLines(mh) + ml, _ := m.mainLines(mh, hits) main := box(ml, m.mainWidth(), mh) body := main rule := dimStyle.Render(strings.Repeat("─", m.w)) @@ -636,7 +689,7 @@ func box(lines []string, w, h int) string { // 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") } @@ -684,8 +737,8 @@ func (m *model) listY() int { // listRows returns the number of lines of the file list, with its title. func (m *model) listRows() int { h := m.bodyHeight() - if lh := m.listHeight(h); lh > 0 { - return lh - 1 + if m.listPos.stacked() { + return max(m.listHeight(h)-1, 0) } return h } diff --git a/tui/watch_test.go b/tui/watch_test.go deleted file mode 100644 index 09cea4f..0000000 --- a/tui/watch_test.go +++ /dev/null @@ -1,96 +0,0 @@ -package tui - -import ( - "errors" - "os" - "testing" - "time" -) - -// watched returns a test model with a watcher, after its first tick. The -// next check of the watcher is due after the time that it returns. -func watched(t *testing.T) (m model, dir string, pause time.Duration) { - t.Helper() - m, dir = newTestModel(t) - w, err := m.repo.Watch(t.Context()) - if err != nil { - t.Fatal(err) - } - m.watch = w - start := time.Now() - m = step(t, m, tickMsg{}) - // The pause of the watcher is 20 times its check, which is a part of - // the tick. - return m, dir, 21 * time.Since(start) -} - -// TestTickRunsNoGitWhenIdle checks that a tick runs no git process while -// nothing changes. -func TestTickRunsNoGitWhenIdle(t *testing.T) { - m, _, pause := watched(t) - noGit(t) - for range 3 { - time.Sleep(pause) - m = step(t, m, tickMsg{}) - } - if shows(m, "isn't installed") { - t.Errorf("view got:\n%s\nwant no git error", view(m)) - } -} - -// TestTickSeesEdit checks that a tick shows an edit. -func TestTickSeesEdit(t *testing.T) { - m, dir, pause := watched(t) - write(t, dir, "a.go", "package a\n\nfunc A() int {\n\treturn 42\n}\n") - time.Sleep(pause) - if m = step(t, m, tickMsg{}); !shows(m, "return 42") { - t.Errorf("view got:\n%s\nwant the edit", view(m)) - } -} - -// TestRefreshForces checks that r reloads in the pause after a reload. -func TestRefreshForces(t *testing.T) { - m, dir, _ := watched(t) - m.reloadAt = time.Now().Add(time.Hour) - write(t, dir, "a.go", "package a\n\nfunc A() int {\n\treturn 42\n}\n") - if m = press(t, m, "r"); !shows(m, "return 42") { - t.Errorf("view got:\n%s\nwant the edit", view(m)) - } -} - -// TestTickRetries checks that a tick retries a failed reload after the -// wait, and that the error then clears. -func TestTickRetries(t *testing.T) { - m, _, _ := watched(t) - path := os.Getenv("PATH") - noGit(t) - if m = press(t, m, "r"); !shows(m, "unknown revision") { - t.Fatalf("view got:\n%s\nwant the git error", view(m)) - } - t.Setenv("PATH", path) - if m = step(t, m, tickMsg{}); !shows(m, "unknown revision") { - t.Errorf("view got:\n%s\nwant the error until the wait ends", view(m)) - } - m.retryAt, m.reloadAt = time.Now(), time.Now() // the waits end - if m = step(t, m, tickMsg{}); shows(m, "unknown revision") { - t.Errorf("view got:\n%s\nwant no error after the retry", view(m)) - } - if !m.retryAt.IsZero() || m.retryWait != 0 { - t.Errorf("after a good reload, retryAt %v, retryWait %v; want zero", m.retryAt, m.retryWait) - } -} - -// TestRetryBackoff checks that each failure doubles the wait, from 1 s to -// 60 s. -func TestRetryBackoff(t *testing.T) { - m, _ := newTestModel(t) - want := []int{1, 2, 4, 8, 16, 32, 60, 60} - for i, w := range want { - before := time.Now() - m.retry(errors.New("fail")) - got := m.retryAt.Sub(before).Round(time.Second) - if got != time.Duration(w)*time.Second { - t.Errorf("failure %d: wait %v, want %ds", i+1, got, w) - } - } -} diff --git a/update/update.go b/update/update.go index baa9370..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 ( @@ -31,6 +35,8 @@ import ( "strings" "syscall" "time" + + "github.com/rednafi/rexdiff/internal/proc" ) // latestURL is the GitHub API address of the latest release of rexdiff. @@ -77,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 @@ -106,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. Set it to test a release before it -// is public. -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 } @@ -123,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) @@ -143,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 @@ -162,11 +172,15 @@ func (u *Updater) Latest(ctx context.Context) (Release, bool, error) { // 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 -// ignores a cancel of ctx, because a brew that stops early can leave a broken -// cask. Install stops after five minutes. +// 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: @@ -177,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 } @@ -202,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) @@ -212,53 +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. If dirs do not have -// the program, tool returns name, and exec looks for name in PATH. tool -// looks in dirs first, because Rex starts panes with a PATH without -// Homebrew or Go. +// 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") @@ -294,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 "" } @@ -330,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 } - return b, err + if len(b) > maxBinary { + return nil, fmt.Errorf("rexdiff is larger than %d bytes", maxBinary) + } + return b, nil } } } @@ -346,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 { @@ -374,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) } @@ -388,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. diff --git a/update/update_test.go b/update/update_test.go index 5577814..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,185 +7,218 @@ import ( "context" "crypto/sha256" "encoding/hex" - "errors" + "encoding/json/v2" "fmt" "net/http" "net/http/httptest" "os" "path/filepath" "runtime" - "slices" + "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 an error with the status", err) - } - if _, _, err := answer(200, `not json`).Latest(t.Context()); err == nil { - t.Error("Latest of bad JSON: no error, want an 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 !slices.Equal(brew.calls, want) { - t.Errorf("brew calls got:\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, want %q", - err, "brew: exit status 1: Error: another brew is running") - } +// 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, want an error", - 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) + } + }) } } @@ -197,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) @@ -216,178 +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.Errorf("after Install: %s is not a symlink, want a symlink", 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, want a checksum error", - 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, "+ - "want a version error", 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("after a failed check: %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, want an error", - err) - } -} - -// TestInstallOutlivesCancel checks that the cancel of the context of the -// program does not stop a brew that runs. 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, _ ...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 sees the cancel %v; "+ - "want nil, false", 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, want an error", - err) - } - if b, err := binaryOf(archive(t, "ok")); err != nil || string(b) != "ok" { - t.Errorf("binaryOf = %q, %v, want \"ok\", nil", b, err) - } -} - -// TestVersion checks the version from the release build. A test binary has -// no version, like a build for development. -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, "+ - "want a download from the test URL", u) - } -} - -// TestDetectBrew checks that a Homebrew install uses the brew beside its -// Caskroom. 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 index 5b8b58a..9f10dc0 100644 --- a/vcs/export_test.go +++ b/vcs/export_test.go @@ -2,6 +2,6 @@ package vcs import "time" -// SetClock sets the clock of w. A clock that does not move turns off the -// pause between the checks. -func SetClock(w *Watcher, now func() time.Time) { w.now = now } +// 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 a011aca..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,33 +40,38 @@ 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, want an error", rev) } } - if b, _ := os.ReadFile(out); string(b) != "keep\n" { + 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 { // For example, macOS needs UTF-8 names. @@ -77,28 +88,25 @@ func TestHostilePaths(t *testing.T) { } got[f.Path] = f.New } - if !reflect.DeepEqual(got, want) { - t.Errorf("Diff got:\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] { + 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) } } } @@ -106,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() @@ -128,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"}, @@ -149,26 +163,26 @@ 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 got:\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) } } @@ -176,11 +190,12 @@ func TestSnapshotNestedRepo(t *testing.T) { // TestReadFileStaysInRoot checks that ReadFile reads only the regular files // 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", @@ -191,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"} @@ -204,77 +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) - } -} - -// 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) + t.Errorf("ReadFile(to-a) = %q, %v, want the content of a.txt", s, err) } } // 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("git status in the worktree got:\n%s\nwant:\n%s", + 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, want A.gitignore Ma.txt Anew.txt", + 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) + 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 @@ -289,9 +287,11 @@ 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, want no error", tt.q, err) continue @@ -300,7 +300,7 @@ func TestGrepIsLiteral(t *testing.T) { 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) } } @@ -309,10 +309,11 @@ 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(), "") if err != nil { @@ -326,7 +327,7 @@ func TestCommitsHostileSubject(t *testing.T) { } } want := []string{subjects[2], subjects[1], subjects[0], "b", "init"} - if !reflect.DeepEqual(got, want) { + if !slices.Equal(got, want) { t.Errorf("Commits subjects = %q, want %q", got, want) } } @@ -334,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}`}, { @@ -351,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 377f149..73f7fca 100644 --- a/vcs/pr.go +++ b/vcs/pr.go @@ -12,16 +12,20 @@ import ( ) 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. @@ -34,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 @@ -42,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. @@ -62,15 +66,15 @@ const ( 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 State. +// 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. @@ -84,33 +88,35 @@ 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. The text of the pull request has no control -// characters. +// 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 @@ -119,12 +125,29 @@ func (r *Repo) PullRequest(ctx context.Context, h Hosts) (*PullRequest, error) { return nil, err } if pr.Title == "" { - return nil, ErrNotFound + return nil, ErrNoPullRequest } cleanPR(pr) return pr, nil } +// 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) { @@ -141,20 +164,25 @@ func cleanPR(pr *PullRequest) { } } -// 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, nil, name, args...) +// 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 @@ -202,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 } @@ -218,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, @@ -236,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 @@ -318,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 } @@ -337,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, @@ -387,13 +421,13 @@ func (r *Repo) azure(ctx context.Context) (*PullRequest, error) { 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 0b7271d..5b33a94 100644 --- a/vcs/pr_test.go +++ b/vcs/pr_test.go @@ -2,13 +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" ) @@ -17,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 } @@ -48,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", @@ -86,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", @@ -108,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", @@ -139,81 +140,134 @@ 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, want no error", tt.remote, err) - continue - } - if !reflect.DeepEqual(pr, tt.want) { - t.Errorf("PullRequest(%s) got:\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, + }, + { + name: "other corp host", + remote: "https://git.corp.example/r", + branch: "feat", + hosts: corp, + errText: unsupported, + }, + { + 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", + }, { - remote: "https://example.com/r.git", - branch: "feat", - msg: "the remote is not on GitHub, GitLab, or Azure DevOps", + name: "visualstudio other branch", + remote: "https://u@org.visualstudio.com/p/_git/r", + branch: "other", + errText: "unexpected call: " + azArgs("other"), }, - {remote: azure, branch: "none", want: vcs.ErrNotFound}, + {name: "no remote", branch: "feat", errText: "no git remote"}, + {name: "azure no pull request", remote: azure, branch: "none", wantErr: vcs.ErrNoPullRequest}, { - remote: azure, - branch: "other", - msg: "unexpected call: " + azArgs("other"), + 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, want an 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) + } + }) } } // TestPullRequestHostile checks that the text of a pull request has no // control characters. func TestPullRequestHostile(t *testing.T) { + t.Parallel() dir := t.TempDir() - fakePath(t, dir) 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, "https://github.com/o/r") + 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 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) { @@ -221,16 +275,40 @@ func TestPullRequestHostile(t *testing.T) { } } -// 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") +// 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) } - if err := os.Symlink(p, filepath.Join(dir, "git")); err != nil { - t.Fatal(err) + 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) + } } - t.Setenv("PATH", dir+":/bin:/usr/bin") } diff --git a/vcs/refs.go b/vcs/refs.go index b3fc054..488f2da 100644 --- a/vcs/refs.go +++ b/vcs/refs.go @@ -2,17 +2,19 @@ package vcs import ( "bufio" - "bytes" - "cmp" "context" "errors" "fmt" "os" - "os/exec" "path/filepath" "slices" "strconv" "strings" + "sync/atomic" + "time" + "unicode" + + "github.com/rednafi/rexdiff/internal/proc" ) // Commit is a commit in the log. @@ -37,7 +39,7 @@ func (r *Repo) Commits(ctx context.Context, since string) ([]Commit, error) { nFields = 5 // the number of fields in format ) _, err := r.run(ctx, nil, "rev-parse", "-q", "--verify", "HEAD") - if exitedWith(err, 1) { + if exitedOne(err) { return nil, nil } args := []string{"log", "--first-parent", format} @@ -67,30 +69,48 @@ func (r *Repo) Commits(ctx context.Context, since string) ([]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 { @@ -103,8 +123,14 @@ func (r *Repo) DefaultBase(ctx context.Context) string { // 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, "rev-parse", "--abbrev-ref", "HEAD") - return strings.TrimSpace(out), err + 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 @@ -132,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) { @@ -139,9 +170,7 @@ func (r *Repo) Files(ctx context.Context) ([]Entry, error) { args []string ignored bool }{ - // In a merge conflict, git lists a path once for each stage. - // --deduplicate lists it once. - {[]string{"-co", "--deduplicate", "--exclude-standard"}, false}, + {listArgs, false}, {[]string{"-oi", "--exclude-standard", "--directory"}, true}, } var es []Entry @@ -189,9 +218,15 @@ 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. When -// it finds limit lines, it stops git, and more tells if there are others. -func (r *Repo) Grep(ctx context.Context, q string, limit int) (ms []Match, more bool, err 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 of q as a pattern. A match is one line, so no // match can have the newline of q. @@ -199,56 +234,86 @@ func (r *Repo) Grep(ctx context.Context, q string, limit int) (ms []Match, more } // --no-column overrides the git setting grep.column. That setting adds a // field to each match. - cmd := exec.CommandContext(ctx, "git", - "grep", "-n", "--no-column", "-z", "-I", "-i", "-F", "--untracked", "--no-color", - "-e", q) - cmd.Dir = r.root - var stderr bytes.Buffer - cmd.Stderr = &stderr + 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, false, err } if err := cmd.Start(); err != nil { - if errors.Is(err, exec.ErrNotFound) { - err = errors.New("git isn't installed") + if pastDeadline(ctx) { + return nil, true, nil } - return nil, false, fmt.Errorf("git grep: %w", err) + return nil, false, fmt.Errorf("git grep: %w", proc.Error(ctx, "git", err, nil)) } - ms, more = readMatches(bufio.NewReader(out), limit) + // 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() + _ = cmd.Process.Kill() } err = cmd.Wait() switch { - case ctx.Err() != nil: - return nil, false, ctx.Err() - case more, exitedWith(err, 1): // stopped, or no match + case err == nil, more, exitedOne(err): // done, stopped, or no match return ms, more, nil - case err != nil: - msg, _, _ := strings.Cut(strings.TrimSpace(stderr.String()), "\n") - return nil, false, fmt.Errorf("git grep: %w", &runError{cmp.Or(msg, err.Error()), err}) + case pastDeadline(ctx): + return ms, true, nil + case ctx.Err() != nil: + return nil, false, context.Cause(ctx) } - return ms, false, nil + 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. more tells if there is more output. -func readMatches(r *bufio.Reader, limit int) (ms []Match, more bool) { +// 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 } - num, _ := r.ReadString(0) - text, _ := r.ReadString('\n') + // 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 @@ -261,8 +326,9 @@ const ( turnRef = "refs/worktree/rexdiff/turn-base" ) -// SavedBase returns the base that [Repo.SaveBase] keeps for this worktree, -// or "" if there is none. +// 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) @@ -297,8 +363,8 @@ func (r *Repo) SaveBase(ctx context.Context, name string) error { return err } -// SavedTurn returns the tree that [Repo.SaveTurn] keeps for this worktree, -// or "" if there is none. +// 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 { diff --git a/vcs/repo.go b/vcs/repo.go index dd2c0ff..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,6 +26,8 @@ import ( "strconv" "strings" "time" + + "github.com/rednafi/rexdiff/internal/proc" ) // emptyTree is the ID of the tree that has no files. @@ -39,18 +46,40 @@ var ( ErrBinary = errors.New("binary file") // ErrTooBig means that a file is larger than 2,000,000 bytes or has // more than 50,000 lines. - ErrTooBig = errors.New("file too big") + 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. @@ -58,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 { @@ -68,81 +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) { 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) { - out, err := command(ctx, r.root, env, in, "git", args...) +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, and stdin is its standard -// input, or nil for none. 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, - stdin io.Reader, - name string, - args ...string, -) (string, error) { - cmd := exec.CommandContext(ctx, name, args...) - cmd.Dir, cmd.Stdin = dir, stdin - 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 } @@ -152,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 @@ -162,15 +190,15 @@ 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 @@ -187,44 +215,55 @@ 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) { - c, err := r.ForkPoint(ctx, base) +// 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) } -// ForkPoint returns the best common ancestor commit of base and HEAD. -func (r *Repo) ForkPoint(ctx context.Context, base string) (string, error) { +// 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 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) { + if exitedOne(err) { err = nil // git cannot add some files, but it adds the others } if err != nil { @@ -237,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 @@ -256,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 @@ -279,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 has a NUL byte. - Binary bool - // TooBig is true if the two versions together are larger than - // 2,000,000 bytes or have 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. @@ -311,7 +412,7 @@ func (r *Repo) Diff(ctx context.Context, from, to string) ([]File, error) { 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 } @@ -332,8 +433,10 @@ 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]) - f.TooBig = f.TooBig || tooBig(f.Old, f.New) - if f.Binary || f.TooBig { + if f.Skip == nil && isTooBig(f.Old, f.New) { + f.Skip = ErrTooBig + } + if f.Skip != nil { f.Old, f.New = "", "" } } @@ -354,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 } @@ -377,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) @@ -406,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 { @@ -450,10 +557,10 @@ func readBatch(r *bufio.Reader, batch []int, blobs []blob) error { // isBinary reports whether s has a NUL byte. func isBinary(s string) bool { return strings.Contains(s, "\x00") } -// tooBig reports whether the versions together are larger than +// 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 tooBig(versions ...string) bool { +func isTooBig(versions ...string) bool { size, lines := 0, 0 for _, s := range versions { size += len(s) @@ -467,21 +574,22 @@ func tooBig(versions ...string) bool { // 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 @@ -493,7 +601,7 @@ func (r *Repo) ReadFile(path string) (string, error) { switch s := string(b); { case isBinary(s): return "", ErrBinary - case tooBig(s): + case isTooBig(s): return "", ErrTooBig default: return s, nil @@ -502,29 +610,32 @@ func (r *Repo) ReadFile(path string) (string, error) { // 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, only an -// absolute dir can be in it. Owns runs git only for a dir below the root. -// An error tells that git cannot run, so the answer is not known. +// 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 } - out, err := command(ctx, r.root, nil, nil, "git", "-C", dir, "rev-parse", "--show-toplevel") - if _, ok := errors.AsType[*exec.ExitError](err); ok { + 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, err + return false, fmt.Errorf("git rev-parse: %w", err) } top := strings.TrimSuffix(out, "\n") - return top != "" && evalSymlinks(top) == evalSymlinks(r.root), nil + 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 685bc30..b98804d 100644 --- a/vcs/repo_test.go +++ b/vcs/repo_test.go @@ -7,71 +7,91 @@ import ( "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) } } @@ -89,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) } @@ -99,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) } @@ -126,42 +146,64 @@ 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, want A.gitignore Ma.txt Anew.txt", 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: Old %q, New %q; want %q, %q", + 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, want A.gitignore Ma.txt Ab.txt Anew.txt", got) } - if _, err := r.MergeBase(ctx, "nosuchbranch"); err == nil { - t.Error("MergeBase(nosuchbranch): no error, want an 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) } // In a turn, rexdiff takes a snapshot and then the agent edits a file. - write(t, dir, "new.txt", "untracked\nmore\n") + 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, 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") { + st := testgit.Git(t, dir, "status", "--porcelain") + if !strings.Contains(st, "?? new.txt") { t.Errorf("git status after Snapshot = %q, want ?? new.txt", st) } @@ -183,9 +225,10 @@ func TestScopes(t *testing.T) { } } -// TestMissingCommit checks that Head and Parent fail when a commit object -// is missing. +// 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() @@ -201,15 +244,15 @@ 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) } } @@ -217,43 +260,50 @@ func TestMissingCommit(t *testing.T) { // 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) 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) - write(t, dir, "bytes", half) - write(t, dir, "lines", lines) - write(t, dir, "limit", limit) - git(t, dir, "add", ".") - git(t, dir, "commit", "-qm", "base") - write(t, dir, "bytes", "y"+half) - write(t, dir, "lines", "y"+lines) - write(t, dir, "limit", "z"+limit) - write(t, dir, "bin", "a\x00b") - write(t, dir, "late", strings.Repeat("x", 9000)+"\x00") // NUL at byte 9000 - write(t, dir, "big", strings.Repeat("x\n", 50_001)) - write(t, dir, "huge", strings.Repeat("x", 2_000_001)) - // Diff reads this file after the skipped content. - write(t, dir, "small", "after\n") + 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 } for _, p := range []string{"bin", "late"} { - if f := got[p]; !f.Binary || f.New != "" { - t.Errorf("%s: %+v, want Binary with no New", p, f) + 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{"bytes", "lines", "big", "huge"} { - if f := got[p]; !f.TooBig || f.Old != "" || f.New != "" { - t.Errorf("%s: TooBig = %v, Old has %d bytes, New has %d bytes; want true, 0, 0", - p, f.TooBig, len(f.Old), len(f.New)) + 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.TooBig || f.New != "z"+limit { - t.Errorf("limit: TooBig = %v, New has %d bytes; want false, %d", - f.TooBig, len(f.New), len(limit)+1) + 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") @@ -274,36 +324,33 @@ func TestDiffSkipsBinaryAndBigFiles(t *testing.T) { // TestFilesInConflict checks that Files lists a path in a merge conflict // once. func TestFilesInConflict(t *testing.T) { + t.Parallel() dir := t.TempDir() - run := func(args ...string) { git(t, dir, args...) } - run("init", "-q", "-b", "main") - write(t, dir, "f.txt", "base\n") + testgit.InitRepo(t, dir) + run := func(args ...string) { + t.Helper() + testgit.Git(t, dir, args...) + } + testgit.WriteFile(t, dir, "f.txt", "base\n") run("add", ".") run("commit", "-qm", "base") run("checkout", "-qb", "other") - write(t, dir, "f.txt", "other\n") + testgit.WriteFile(t, dir, "f.txt", "other\n") run("commit", "-qam", "other") run("checkout", "-q", "main") - write(t, dir, "f.txt", "main\n") + testgit.WriteFile(t, dir, "f.txt", "main\n") run("commit", "-qam", "main") - cmd := exec.Command("git", "merge", "other") + 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, err := vcs.Open(t.Context(), dir) - if err != nil { - t.Fatal(err) - } + r := open(t, dir) es, err := r.Files(t.Context()) if err != nil { t.Fatal(err) } - var got []string - for _, e := range es { - got = append(got, e.Path) - } - if want := []string{"f.txt"}; !slices.Equal(got, want) { + if got, want := paths(es), []string{"f.txt"}; !slices.Equal(got, want) { t.Errorf("Files = %q, want %q", got, want) } } @@ -311,6 +358,7 @@ func TestFilesInConflict(t *testing.T) { // 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() @@ -318,43 +366,33 @@ 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) } - write(t, dir, "build/sub/x", "x\n") - var ignored []string - for _, e := range r.IgnoredDir("build") { - ignored = append(ignored, e.Path) - } - if want := []string{"build/out", "build/sub/"}; !slices.Equal(ignored, want) { + 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) } - 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, want [%+v], nil", ms, err, match) + // 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) } - 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"} + // 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, want [%+v], nil", ms, err, match) } - if ms, _, err := r.Grep(ctx, "nothing has this", 10); err != nil || len(ms) != 0 { + 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) } @@ -362,7 +400,7 @@ func TestWorktree(t *testing.T) { t.Errorf("ReadFile(a.txt) = %q, %v, want %q, nil", s, err, "one\ntwo\n") } - contains := []struct { + tests := []struct { path string want bool }{ @@ -370,7 +408,7 @@ 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) } @@ -378,15 +416,13 @@ func TestWorktree(t *testing.T) { } // TestOwns checks which directories are in the working tree, as in -// herdr-reviewr: a subdirectory is, a nested repository and a relative or -// outside path are not, and without git the answer is an error. +// 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") - if err := os.MkdirAll(nested, 0o755); err != nil { - t.Fatal(err) - } - git(t, nested, "init", "-q") + testgit.InitRepo(t, nested) tests := []struct { dir string want bool @@ -404,19 +440,27 @@ func TestOwns(t *testing.T) { t.Errorf("Owns(%q) = %v, %v, want %v, nil", tt.dir, got, err, tt.want) } } - t.Setenv("PATH", t.TempDir()) +} + +// 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") } - // No git runs for a path outside the root. 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 Branches, DefaultBase, and Resolve. +// 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, want [feat main], nil", bs, err) @@ -424,20 +468,47 @@ func TestRefs(t *testing.T) { if b := r.DefaultBase(ctx); b != "main" { 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, 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) } @@ -446,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 { @@ -485,43 +554,75 @@ func TestDiffRenameAndSubmodule(t *testing.T) { New: "Subproject commit " + b + "\n", }, } - if !reflect.DeepEqual(got, want) { - t.Errorf("Diff got:\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.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 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() @@ -529,25 +630,29 @@ 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") // The index entry gets the time of b.txt. - git(t, dir, "update-index", "--really-refresh") + 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) } } -// TestCommitsLikeHerdr checks the commits of the commit picker. They are -// the first-parent commits after since, else the last 50. -func TestCommitsLikeHerdr(t *testing.T) { +// 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) { git(t, dir, args...) } + 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") @@ -563,7 +668,7 @@ func TestCommitsLikeHerdr(t *testing.T) { return s } - since, err := r.ForkPoint(ctx, "main") + since, err := r.MergeBaseCommit(ctx, "main") if err != nil { t.Fatal(err) } @@ -573,31 +678,29 @@ func TestCommitsLikeHerdr(t *testing.T) { } 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[:3]) + 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) + t.Errorf("Commits(\"\") = %d commits, %v, want the last %d", len(cs), err, vcs.LastCommits) } empty := t.TempDir() - git(t, empty, "init", "-q") - er, err := vcs.Open(ctx, empty) - if err != nil { - t.Fatal(err) - } + 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) + 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() - write(t, dir, "many.txt", strings.Repeat("hit\n", 5000)) + testgit.WriteFile(t, dir, "many.txt", strings.Repeat("hit\n", 5000)) tests := []struct { limit int wantN int @@ -609,24 +712,47 @@ func TestGrepLimit(t *testing.T) { {6000, 5000, false}, } for _, tt := range tests { - ms, more, err := r.Grep(ctx, "hit", tt.limit) + 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", + t.Errorf("Grep(hit, %d) = %d matches, more %t, %v, want %d, %t, nil", tt.limit, len(ms), more, err, tt.wantN, tt.wantMore) } } - if ms, _, _ := r.Grep(ctx, "hit", 2); ms[1] != (vcs.Match{Path: "many.txt", Line: 2, Text: "hit"}) { - t.Errorf("Grep(hit, 2)[1] = %+v, want many.txt:2", ms[1]) + 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) } } -// TestGrepCanceled checks that Grep returns the error of a canceled +// TestGrepCancelled checks that Grep returns the cause of a cancelled // context. -func TestGrepCanceled(t *testing.T) { +func TestGrepCancelled(t *testing.T) { + t.Parallel() r, _ := newRepo(t) - ctx, cancel := context.WithCancel(t.Context()) - cancel() - if _, _, err := r.Grep(ctx, "one", 10); !errors.Is(err, context.Canceled) { - t.Errorf("Grep with a canceled context = %v, want %v", err, context.Canceled) + 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 index 074ad64..251d565 100644 --- a/vcs/saved_test.go +++ b/vcs/saved_test.go @@ -1,17 +1,17 @@ package vcs_test import ( - "os/exec" "path/filepath" "strings" "testing" - "github.com/rednafi/rexdiff/vcs" + "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 != "" { @@ -36,7 +36,7 @@ func TestSavedBase(t *testing.T) { } } blob := hashObject(t, dir, "--upload-pack=x") - git(t, dir, "update-ref", "refs/worktree/rexdiff/base-pick", blob) + 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) } @@ -45,25 +45,23 @@ func TestSavedBase(t *testing.T) { // 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() - cmd := exec.Command("git", "hash-object", "-w", "--stdin") - cmd.Dir, cmd.Stdin = dir, strings.NewReader(s) - out, err := cmd.Output() - if err != nil { - t.Fatal(err) - } - return strings.TrimSpace(string(out)) + 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) } - git(t, dir, "gc", "-q", "--prune=now") + testgit.Git(t, dir, "gc", "-q", "--prune=now") if got := r.SavedTurn(ctx); got != tree { t.Errorf("SavedTurn = %q, want %q", got, tree) } @@ -83,14 +81,12 @@ func TestSavedTurn(t *testing.T) { // 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") - git(t, dir, "worktree", "add", "-q", other, "main") - r2, err := vcs.Open(ctx, other) - if err != nil { - t.Fatal(err) - } + testgit.Git(t, dir, "worktree", "add", "-q", other, "main") + r2 := open(t, other) if err := r.SaveBase(ctx, "feat"); err != nil { t.Fatal(err) } diff --git a/vcs/watch.go b/vcs/watch.go index 6da2f3a..5fb2fdf 100644 --- a/vcs/watch.go +++ b/vcs/watch.go @@ -2,6 +2,10 @@ package vcs import ( "context" + "encoding/binary" + "hash" + "hash/fnv" + "io/fs" "os" "path/filepath" "slices" @@ -10,29 +14,31 @@ import ( "time" ) -// budgetFactor sets the pause after a check of a [Watcher]. The next check +// 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 budgetFactor = 20 +const pauseFactor = 20 -// Watcher tells, without a git process, if 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. It runs git only -// after a directory or a git file changes. +// 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, or a -// copy that keeps the size and the modification time of a file. +// 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, which a test can set + now func() time.Time // the clock, time.Now next time.Time // no check before this time started bool - 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 + 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 @@ -56,17 +62,26 @@ func (r *Repo) Watch(ctx context.Context) (*Watcher, error) { if err != nil { return nil, err } - return &Watcher{r: r, gitDir: gitDir, common: common, now: time.Now}, nil + 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 ignored directories that the user expands. dirs are -// relative to the root. Git does not list the paths in an ignored -// directory, thus Changed also reports a file that a shown directory gets -// or loses. A new set reports no change. -func (w *Watcher) Show(dirs []string) { - abs := make([]string, len(dirs)) - for i, d := range dirs { - abs[i] = filepath.Join(w.r.root, filepath.FromSlash(d)) +// 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) @@ -76,7 +91,10 @@ func (w *Watcher) Show(dirs []string) { // 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) - return strings.TrimSuffix(out, "\n"), err + if err != nil { + return "", err + } + return strings.TrimSuffix(out, "\n"), nil } // Changed tells what can have changed since the last call. The first call @@ -90,7 +108,7 @@ func (w *Watcher) Changed(ctx context.Context) (Change, error) { } defer func() { end := w.now() - w.next = end.Add(budgetFactor * end.Sub(start)) + w.next = end.Add(pauseFactor * end.Sub(start)) }() refs, index := w.sumRefs(), w.sumIndex() @@ -127,16 +145,45 @@ func (w *Watcher) Changed(ctx context.Context) (Change, error) { 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, "-co", "--deduplicate", "--exclude-standard") + 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() @@ -145,13 +192,74 @@ func (w *Watcher) relist(ctx context.Context) (uint64, error) { // git lists a nested repository with a slash at the end. p = filepath.Join(root, strings.TrimSuffix(p, "/")) w.files = append(w.files, p) - for d := filepath.Dir(p); !seen[d]; d = filepath.Dir(d) { - seen[d] = true - w.dirs = append(w.dirs, d) - } + addDirs(filepath.Dir(p)) + } + for _, d := range untracked { + addDirs(filepath.Join(root, d)) } w.listed = true - return uint64(s), nil + 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 @@ -169,8 +277,9 @@ func (w *Watcher) sumRefs() uint64 { // 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: ") - if ok && !strings.Contains(ref, "..") { - paths = append(paths, filepath.Join(w.common, filepath.FromSlash(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) @@ -182,63 +291,59 @@ func (w *Watcher) sumIndex() uint64 { } // sumPaths returns the sum of the path, the size, the modification time, -// the mode, and the inode of each file. It does not follow a symbolic -// link. +// 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) + s.stat(p, os.Stat) } - return uint64(s) + 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, because a commit there -// does not change the directory. +// 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) { + if s.stat(p, os.Lstat) { s.nested(p) } } - return uint64(s) + return s.Sum64() } -// sum is an FNV-64a hash. It does not allocate, as hash/fnv does for a -// string. -type sum uint64 +// 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 } -func newSum() sum { return 14695981039346656037 } - -func (s *sum) byte(b byte) { *s = (*s ^ sum(b)) * 1099511628211 } +// 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) { - for i := range len(v) { - s.byte(v[i]) - } - s.byte(0) -} +func (s sum) str(v string) { _, _ = s.Write(append([]byte(v), 0)) } -func (s *sum) num(v uint64) { - for range 8 { - s.byte(byte(v)) - v >>= 8 - } +// 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 of p. A missing file adds a -// marker. stat reports whether p is a directory. -func (s *sum) stat(p string) (isDir bool) { +// 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 := os.Lstat(p) + fi, err := stat(p) if err != nil { s.num(0) return false } - s.num(uint64(fi.Size())) - s.num(uint64(fi.ModTime().UnixNano())) + 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) @@ -249,7 +354,7 @@ func (s *sum) stat(p string) (isDir bool) { // 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) { +func (s sum) nested(p string) { dotGit := filepath.Join(p, ".git") fi, err := os.Lstat(dotGit) if err != nil { @@ -274,8 +379,8 @@ func (s *sum) nested(p string) { s.num(0) continue } - s.num(uint64(fi.Size())) - s.num(uint64(fi.ModTime().UnixNano())) + s.int(fi.Size()) + s.int(fi.ModTime().UnixNano()) } } diff --git a/vcs/watch_test.go b/vcs/watch_test.go index f5a3e35..c2fa8d5 100644 --- a/vcs/watch_test.go +++ b/vcs/watch_test.go @@ -3,9 +3,11 @@ package vcs_test import ( "os" "path/filepath" + "strings" "testing" "time" + "github.com/rednafi/rexdiff/internal/testgit" "github.com/rednafi/rexdiff/vcs" ) @@ -17,7 +19,7 @@ func newWatcher(t *testing.T, r *vcs.Repo) *vcs.Watcher { if err != nil { t.Fatal(err) } - vcs.SetClock(w, func() time.Time { return time.Time{} }) + 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) } @@ -37,6 +39,7 @@ func changed(t *testing.T, w *vcs.Watcher) vcs.Change { // 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 @@ -45,7 +48,8 @@ func TestWatcher(t *testing.T) { }{ {"nothing", func(*testing.T, string) {}, vcs.Change{}}, {"same size, new time", func(t *testing.T, dir string) { - write(t, dir, "a.txt", "one\nxyz\n") // the same size + 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 { @@ -53,28 +57,35 @@ func TestWatcher(t *testing.T) { } }, files}, {"new file", func(t *testing.T, dir string) { - write(t, dir, "c.txt", "c\n") + t.Helper() + testgit.WriteFile(t, dir, "c.txt", "c\n") }, files}, {"new directory", func(t *testing.T, dir string) { - write(t, dir, "x/y/c.txt", "c\n") + t.Helper() + testgit.WriteFile(t, dir, "x/y/c.txt", "c\n") }, files}, {"ignored", func(t *testing.T, dir string) { - write(t, dir, "build/more", "x\n") + 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) { - git(t, dir, "commit", "-qam", "c") + t.Helper() + testgit.Git(t, dir, "commit", "-qam", "c") }, vcs.Change{Refs: true}}, {"checkout", func(t *testing.T, dir string) { - git(t, dir, "checkout", "-qb", "other") + 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) @@ -90,14 +101,46 @@ func TestWatcher(t *testing.T) { } } +// 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) - write(t, dir, "x/c.txt", "c\n") + testgit.WriteFile(t, dir, "x/c.txt", "c\n") changed(t, w) - write(t, dir, "x/c.txt", "c\nd\n") + 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) } @@ -106,28 +149,31 @@ func TestWatcherNewDirectory(t *testing.T) { // 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) - write(t, dir, "ignored.txt", "xx\n") + 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) } - write(t, dir, ".gitignore", "build/\n") + testgit.WriteFile(t, dir, ".gitignore", "build/\n") if got := changed(t, w); !got.Files { t.Errorf("after the .gitignore edit: %+v, want Files", got) } - write(t, dir, "ignored.txt", "xxx\n") + 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, as in herdr-reviewr. +// 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) - write(t, dir, "build/new", "x\n") + 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) } @@ -135,7 +181,7 @@ func TestWatcherShown(t *testing.T) { if got := changed(t, w); got.Files { t.Errorf("after Show: %+v, want no Files", got) } - write(t, dir, "build/more", "x\n") + 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) } @@ -146,34 +192,50 @@ func TestWatcherShown(t *testing.T) { t.Errorf("a removed file in a shown build: %+v, want Files", got) } w.Show(nil) - write(t, dir, "build/more", "x\n") + 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() - git(t, other, "init", "-q") - write(t, other, "f", "1\n") - git(t, other, "add", ".") - git(t, other, "commit", "-qm", "1") + 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 { - git(t, dir, "-c", "protocol.file.allow=always", + testgit.Git(t, dir, "-c", "protocol.file.allow=always", "submodule", "add", "-q", other, "inner") } else { - git(t, dir, "clone", "-q", other, "inner") + testgit.Git(t, dir, "clone", "-q", other, "inner") } w := newWatcher(t, r) - write(t, inner, "f", "2\n") - git(t, inner, "commit", "-qam", "2") + 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) } @@ -184,13 +246,15 @@ func TestWatcherNested(t *testing.T) { // TestWatcherRunsNoGit checks that a Watcher runs no git process while // nothing changes. func TestWatcherRunsNoGit(t *testing.T) { - r, _ := newRepo(t) + t.Parallel() + env, link := gitLink(t) + r, _ := newRepoEnv(t, env) w := newWatcher(t, r) - t.Setenv("PATH", t.TempDir()) + 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) + t.Fatalf("idle call: %+v, %v, want none, nil", ch, err) } } } @@ -198,6 +262,7 @@ func TestWatcherRunsNoGit(t *testing.T) { // 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 { @@ -206,12 +271,12 @@ func TestWatcherBudget(t *testing.T) { // 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() - vcs.SetClock(w, func() time.Time { + w.SetClock(func() time.Time { now = now.Add(10 * time.Millisecond) return now }) changed(t, w) - write(t, dir, "c.txt", "c\n") + 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) } @@ -220,3 +285,40 @@ func TestWatcherBudget(t *testing.T) { 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) + } + }) + } +}