diff --git a/package-lock.json b/package-lock.json index 9ffa1ed73..68f57830c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1036,9 +1036,9 @@ } }, "node_modules/@eslint/eslintrc/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -1086,9 +1086,9 @@ } }, "node_modules/@humanwhocodes/config-array/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -1209,15 +1209,14 @@ } }, "node_modules/@pierre/diffs": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/@pierre/diffs/-/diffs-1.0.6.tgz", - "integrity": "sha512-VoFvtGCSPi+wAj4Ap+0j80/KPnQbYZsvztjIP4nHsjCgcVUBF7+X1nHFqjl/xbTqSc0okZG1tHYb49CUON9ZXQ==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@pierre/diffs/-/diffs-1.1.1.tgz", + "integrity": "sha512-YJUUdYuRGN2r+rU/alMf9LEm5S6INDIOajxqJaiLpo1swDsebzsQ+UrNgfc1IspDPWkl0d62KWzt1IfUFqJBrg==", "license": "apache-2.0", "dependencies": { - "@shikijs/core": "^3.0.0", - "@shikijs/engine-javascript": "^3.0.0", + "@pierre/theme": "0.0.22", "@shikijs/transformers": "^3.0.0", - "diff": "8.0.2", + "diff": "8.0.3", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0" @@ -1227,6 +1226,15 @@ "react-dom": "^18.3.1 || ^19.0.0" } }, + "node_modules/@pierre/theme": { + "version": "0.0.22", + "resolved": "https://registry.npmjs.org/@pierre/theme/-/theme-0.0.22.tgz", + "integrity": "sha512-ePUIdQRNGjrveELTU7fY89Xa7YGHHEy5Po5jQy/18lm32eRn96+tnYJEtFooGdffrx55KBUtOXfvVy/7LDFFhA==", + "license": "MIT", + "engines": { + "vscode": "^1.0.0" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.27", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", @@ -1235,9 +1243,9 @@ "license": "MIT" }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.55.1.tgz", - "integrity": "sha512-9R0DM/ykwfGIlNu6+2U09ga0WXeZ9MRC2Ter8jnz8415VbuIykVuc6bhdrbORFZANDmTDvq26mJrEVTl8TdnDg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", "cpu": [ "arm" ], @@ -1249,9 +1257,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.55.1.tgz", - "integrity": "sha512-eFZCb1YUqhTysgW3sj/55du5cG57S7UTNtdMjCW7LwVcj3dTTcowCsC8p7uBdzKsZYa8J7IDE8lhMI+HX1vQvg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", "cpu": [ "arm64" ], @@ -1263,9 +1271,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.55.1.tgz", - "integrity": "sha512-p3grE2PHcQm2e8PSGZdzIhCKbMCw/xi9XvMPErPhwO17vxtvCN5FEA2mSLgmKlCjHGMQTP6phuQTYWUnKewwGg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", "cpu": [ "arm64" ], @@ -1277,9 +1285,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.55.1.tgz", - "integrity": "sha512-rDUjG25C9qoTm+e02Esi+aqTKSBYwVTaoS1wxcN47/Luqef57Vgp96xNANwt5npq9GDxsH7kXxNkJVEsWEOEaQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", "cpu": [ "x64" ], @@ -1291,9 +1299,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.55.1.tgz", - "integrity": "sha512-+JiU7Jbp5cdxekIgdte0jfcu5oqw4GCKr6i3PJTlXTCU5H5Fvtkpbs4XJHRmWNXF+hKmn4v7ogI5OQPaupJgOg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", "cpu": [ "arm64" ], @@ -1305,9 +1313,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.55.1.tgz", - "integrity": "sha512-V5xC1tOVWtLLmr3YUk2f6EJK4qksksOYiz/TCsFHu/R+woubcLWdC9nZQmwjOAbmExBIVKsm1/wKmEy4z4u4Bw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", "cpu": [ "x64" ], @@ -1319,9 +1327,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.55.1.tgz", - "integrity": "sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", "cpu": [ "arm" ], @@ -1333,9 +1341,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.55.1.tgz", - "integrity": "sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", "cpu": [ "arm" ], @@ -1347,9 +1355,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.55.1.tgz", - "integrity": "sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", "cpu": [ "arm64" ], @@ -1361,9 +1369,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.55.1.tgz", - "integrity": "sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", "cpu": [ "arm64" ], @@ -1375,9 +1383,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.55.1.tgz", - "integrity": "sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", "cpu": [ "loong64" ], @@ -1389,9 +1397,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.55.1.tgz", - "integrity": "sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", "cpu": [ "loong64" ], @@ -1403,9 +1411,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.55.1.tgz", - "integrity": "sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", "cpu": [ "ppc64" ], @@ -1417,9 +1425,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.55.1.tgz", - "integrity": "sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", "cpu": [ "ppc64" ], @@ -1431,9 +1439,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.55.1.tgz", - "integrity": "sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", "cpu": [ "riscv64" ], @@ -1445,9 +1453,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.55.1.tgz", - "integrity": "sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", "cpu": [ "riscv64" ], @@ -1459,9 +1467,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.55.1.tgz", - "integrity": "sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", "cpu": [ "s390x" ], @@ -1473,9 +1481,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.55.1.tgz", - "integrity": "sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", "cpu": [ "x64" ], @@ -1487,9 +1495,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.55.1.tgz", - "integrity": "sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", "cpu": [ "x64" ], @@ -1501,9 +1509,9 @@ ] }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.55.1.tgz", - "integrity": "sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", "cpu": [ "x64" ], @@ -1515,9 +1523,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.55.1.tgz", - "integrity": "sha512-xzm44KgEP11te3S2HCSyYf5zIzWmx3n8HDCc7EE59+lTcswEWNpvMLfd9uJvVX8LCg9QWG67Xt75AuHn4vgsXw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", "cpu": [ "arm64" ], @@ -1529,9 +1537,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.55.1.tgz", - "integrity": "sha512-yR6Bl3tMC/gBok5cz/Qi0xYnVbIxGx5Fcf/ca0eB6/6JwOY+SRUcJfI0OpeTpPls7f194as62thCt/2BjxYN8g==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", "cpu": [ "arm64" ], @@ -1543,9 +1551,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.55.1.tgz", - "integrity": "sha512-3fZBidchE0eY0oFZBnekYCfg+5wAB0mbpCBuofh5mZuzIU/4jIVkbESmd2dOsFNS78b53CYv3OAtwqkZZmU5nA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", "cpu": [ "ia32" ], @@ -1557,9 +1565,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.55.1.tgz", - "integrity": "sha512-xGGY5pXj69IxKb4yv/POoocPy/qmEGhimy/FoTpTSVju3FYXUQQMFCaZZXJVidsmGxRioZAwpThl/4zX41gRKg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", "cpu": [ "x64" ], @@ -1571,9 +1579,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.55.1.tgz", - "integrity": "sha512-SPEpaL6DX4rmcXtnhdrQYgzQ5W2uW3SCJch88lB2zImhJRhIIK44fkUrgIV/Q8yUNfw5oyZ5vkeQsZLhCb06lw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", "cpu": [ "x64" ], @@ -2648,9 +2656,9 @@ } }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, "license": "MIT", "dependencies": { @@ -3457,9 +3465,9 @@ } }, "node_modules/diff": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.2.tgz", - "integrity": "sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg==", + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.3.tgz", + "integrity": "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==", "license": "BSD-3-Clause", "engines": { "node": ">=0.3.1" @@ -3910,9 +3918,9 @@ } }, "node_modules/eslint-plugin-react/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -3977,9 +3985,9 @@ } }, "node_modules/eslint/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -4227,9 +4235,9 @@ } }, "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true, "license": "ISC" }, @@ -4436,9 +4444,9 @@ } }, "node_modules/glob/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -6471,13 +6479,13 @@ } }, "node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "dev": true, "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^2.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -7278,9 +7286,9 @@ } }, "node_modules/rollup": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.55.1.tgz", - "integrity": "sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", "dev": true, "license": "MIT", "dependencies": { @@ -7294,31 +7302,31 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.55.1", - "@rollup/rollup-android-arm64": "4.55.1", - "@rollup/rollup-darwin-arm64": "4.55.1", - "@rollup/rollup-darwin-x64": "4.55.1", - "@rollup/rollup-freebsd-arm64": "4.55.1", - "@rollup/rollup-freebsd-x64": "4.55.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.55.1", - "@rollup/rollup-linux-arm-musleabihf": "4.55.1", - "@rollup/rollup-linux-arm64-gnu": "4.55.1", - "@rollup/rollup-linux-arm64-musl": "4.55.1", - "@rollup/rollup-linux-loong64-gnu": "4.55.1", - "@rollup/rollup-linux-loong64-musl": "4.55.1", - "@rollup/rollup-linux-ppc64-gnu": "4.55.1", - "@rollup/rollup-linux-ppc64-musl": "4.55.1", - "@rollup/rollup-linux-riscv64-gnu": "4.55.1", - "@rollup/rollup-linux-riscv64-musl": "4.55.1", - "@rollup/rollup-linux-s390x-gnu": "4.55.1", - "@rollup/rollup-linux-x64-gnu": "4.55.1", - "@rollup/rollup-linux-x64-musl": "4.55.1", - "@rollup/rollup-openbsd-x64": "4.55.1", - "@rollup/rollup-openharmony-arm64": "4.55.1", - "@rollup/rollup-win32-arm64-msvc": "4.55.1", - "@rollup/rollup-win32-ia32-msvc": "4.55.1", - "@rollup/rollup-win32-x64-gnu": "4.55.1", - "@rollup/rollup-win32-x64-msvc": "4.55.1", + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" } }, diff --git a/src-tauri/src/bin/codex_monitor_daemon.rs b/src-tauri/src/bin/codex_monitor_daemon.rs index 59bfc00bc..66ebe9bd4 100644 --- a/src-tauri/src/bin/codex_monitor_daemon.rs +++ b/src-tauri/src/bin/codex_monitor_daemon.rs @@ -87,8 +87,9 @@ use shared::{ use storage::{read_settings, read_workspaces}; use types::{ AppSettings, GitCommitDiff, GitFileDiff, GitHubIssuesResponse, GitHubPullRequestComment, - GitHubPullRequestDiff, GitHubPullRequestsResponse, GitLogResponse, LocalUsageSnapshot, - WorkspaceEntry, WorkspaceInfo, WorkspaceSettings, WorktreeSetupStatus, + GitHubPullRequestDiff, GitHubPullRequestsResponse, GitLogResponse, GitSelectionApplyResult, + GitSelectionLine, LocalUsageSnapshot, WorkspaceEntry, WorkspaceInfo, WorkspaceSettings, + WorktreeSetupStatus, }; use workspace_settings::apply_workspace_settings_update; @@ -1087,6 +1088,41 @@ impl DaemonState { git_ui_core::stage_git_all_core(&self.workspaces, workspace_id).await } + async fn stage_git_selection( + &self, + workspace_id: String, + path: String, + op: String, + source: String, + lines: Vec, + ) -> Result { + git_ui_core::stage_git_selection_core( + &self.workspaces, + workspace_id, + path, + op, + source, + lines, + ) + .await + } + + async fn apply_git_display_hunk( + &self, + workspace_id: String, + path: String, + display_hunk_id: String, + ) -> Result { + git_ui_core::apply_git_display_hunk_core( + &self.workspaces, + &self.app_settings, + workspace_id, + path, + display_hunk_id, + ) + .await + } + async fn unstage_git_file(&self, workspace_id: String, path: String) -> Result<(), String> { git_ui_core::unstage_git_file_core(&self.workspaces, workspace_id, path).await } diff --git a/src-tauri/src/bin/codex_monitor_daemon/rpc/git.rs b/src-tauri/src/bin/codex_monitor_daemon/rpc/git.rs index 2637b7e0d..d45bf5f44 100644 --- a/src-tauri/src/bin/codex_monitor_daemon/rpc/git.rs +++ b/src-tauri/src/bin/codex_monitor_daemon/rpc/git.rs @@ -1,5 +1,6 @@ use super::*; use crate::shared::git_rpc; +use crate::types::GitSelectionLine; use serde::de::DeserializeOwned; use serde::Serialize; use std::future::Future; @@ -102,6 +103,50 @@ pub(super) async fn try_handle( let request = parse_request_or_err!(params, git_rpc::WorkspaceIdRequest); Some(serialize_ok(state.stage_git_all(request.workspace_id)).await) } + git_rpc::METHOD_APPLY_GIT_DISPLAY_HUNK => { + let request = parse_request_or_err!(params, git_rpc::GitDisplayHunkActionRequest); + Some( + state + .apply_git_display_hunk( + request.workspace_id, + request.path, + request.display_hunk_id, + ) + .await + .and_then(|result| serde_json::to_value(result).map_err(|err| err.to_string())), + ) + } + "stage_git_selection" => { + let workspace_id = match parse_string(params, "workspaceId") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + let path = match parse_string(params, "path") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + let op = match parse_string(params, "op") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + let source = match parse_string(params, "source") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + let lines = match parse_optional_value(params, "lines") + .map(serde_json::from_value::>) + .transpose() + { + Ok(value) => value.unwrap_or_default(), + Err(err) => return Some(Err(format!("invalid `lines`: {err}"))), + }; + Some( + state + .stage_git_selection(workspace_id, path, op, source, lines) + .await + .and_then(|result| serde_json::to_value(result).map_err(|err| err.to_string())), + ) + } git_rpc::METHOD_UNSTAGE_GIT_FILE => { let request = parse_request_or_err!(params, git_rpc::WorkspacePathRequest); Some(serialize_ok(state.unstage_git_file(request.workspace_id, request.path)).await) diff --git a/src-tauri/src/git/mod.rs b/src-tauri/src/git/mod.rs index b4321a5d5..9ff00ef66 100644 --- a/src-tauri/src/git/mod.rs +++ b/src-tauri/src/git/mod.rs @@ -1,6 +1,6 @@ use serde::de::DeserializeOwned; use serde::Serialize; -use serde_json::Value; +use serde_json::{json, Value}; use tauri::{AppHandle, State}; use crate::remote_backend; @@ -8,7 +8,8 @@ use crate::shared::{git_rpc, git_ui_core}; use crate::state::AppState; use crate::types::{ GitCommitDiff, GitFileDiff, GitHubIssuesResponse, GitHubPullRequestComment, - GitHubPullRequestDiff, GitHubPullRequestsResponse, GitLogResponse, + GitHubPullRequestDiff, GitHubPullRequestsResponse, GitLogResponse, GitSelectionApplyResult, + GitSelectionLine, }; fn git_remote_params(request: &T) -> Result { @@ -187,6 +188,63 @@ pub(crate) async fn stage_git_all( git_ui_core::stage_git_all_core(&state.workspaces, workspace_id).await } +#[tauri::command] +pub(crate) async fn stage_git_selection( + workspace_id: String, + path: String, + op: String, + source: String, + lines: Vec, + state: State<'_, AppState>, + app: AppHandle, +) -> Result { + try_remote_typed!( + state, + app, + "stage_git_selection", + json!({ + "workspaceId": &workspace_id, + "path": &path, + "op": &op, + "source": &source, + "lines": &lines + }), + GitSelectionApplyResult + ); + git_ui_core::stage_git_selection_core(&state.workspaces, workspace_id, path, op, source, lines) + .await +} + +#[tauri::command] +pub(crate) async fn apply_git_display_hunk( + workspace_id: String, + path: String, + display_hunk_id: String, + state: State<'_, AppState>, + app: AppHandle, +) -> Result { + let request = git_rpc::GitDisplayHunkActionRequest { + workspace_id: workspace_id.clone(), + path: path.clone(), + display_hunk_id: display_hunk_id.clone(), + }; + try_remote_typed!( + state, + app, + git_rpc::METHOD_APPLY_GIT_DISPLAY_HUNK, + git_remote_params(&request)?, + GitSelectionApplyResult + ); + git_ui_core::apply_git_display_hunk_core( + &state.workspaces, + &state.app_settings, + workspace_id, + path, + display_hunk_id, + ) + .await +} + #[tauri::command] pub(crate) async fn unstage_git_file( workspace_id: String, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 83e9dacae..7329766c9 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -240,6 +240,8 @@ pub fn run() { git::get_git_remote, git::stage_git_file, git::stage_git_all, + git::stage_git_selection, + git::apply_git_display_hunk, git::unstage_git_file, git::revert_git_file, git::revert_git_all, diff --git a/src-tauri/src/shared/git_rpc.rs b/src-tauri/src/shared/git_rpc.rs index 652a973fb..8e2f565c1 100644 --- a/src-tauri/src/shared/git_rpc.rs +++ b/src-tauri/src/shared/git_rpc.rs @@ -7,6 +7,7 @@ pub(crate) const METHOD_INIT_GIT_REPO: &str = "init_git_repo"; pub(crate) const METHOD_CREATE_GITHUB_REPO: &str = "create_github_repo"; pub(crate) const METHOD_STAGE_GIT_FILE: &str = "stage_git_file"; pub(crate) const METHOD_STAGE_GIT_ALL: &str = "stage_git_all"; +pub(crate) const METHOD_APPLY_GIT_DISPLAY_HUNK: &str = "apply_git_display_hunk"; pub(crate) const METHOD_UNSTAGE_GIT_FILE: &str = "unstage_git_file"; pub(crate) const METHOD_REVERT_GIT_FILE: &str = "revert_git_file"; pub(crate) const METHOD_REVERT_GIT_ALL: &str = "revert_git_all"; @@ -86,6 +87,14 @@ pub(crate) struct WorkspacePathRequest { pub(crate) path: String, } +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct GitDisplayHunkActionRequest { + pub(crate) workspace_id: String, + pub(crate) path: String, + pub(crate) display_hunk_id: String, +} + #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub(crate) struct ListGitRootsRequest { diff --git a/src-tauri/src/shared/git_ui_core.rs b/src-tauri/src/shared/git_ui_core.rs index 2697484de..e314c1e0b 100644 --- a/src-tauri/src/shared/git_ui_core.rs +++ b/src-tauri/src/shared/git_ui_core.rs @@ -6,7 +6,8 @@ use tokio::sync::Mutex; use crate::types::{ AppSettings, GitCommitDiff, GitFileDiff, GitHubIssuesResponse, GitHubPullRequestComment, - GitHubPullRequestDiff, GitHubPullRequestsResponse, GitLogResponse, WorkspaceEntry, + GitHubPullRequestDiff, GitHubPullRequestsResponse, GitLogResponse, GitSelectionApplyResult, + GitSelectionLine, WorkspaceEntry, }; #[path = "git_ui_core/commands.rs"] @@ -116,6 +117,38 @@ pub(crate) async fn stage_git_all_core( commands::stage_git_all_inner(workspaces, workspace_id).await } +pub(crate) async fn stage_git_selection_core( + workspaces: &Mutex>, + workspace_id: String, + path: String, + op: String, + source: String, + lines: Vec, +) -> Result { + commands::stage_git_selection_inner(workspaces, workspace_id, path, op, source, lines).await +} + +pub(crate) async fn apply_git_display_hunk_core( + workspaces: &Mutex>, + app_settings: &Mutex, + workspace_id: String, + path: String, + display_hunk_id: String, +) -> Result { + let ignore_whitespace_changes = { + let settings = app_settings.lock().await; + settings.git_diff_ignore_whitespace_changes + }; + commands::apply_git_display_hunk_inner( + workspaces, + workspace_id, + path, + display_hunk_id, + ignore_whitespace_changes, + ) + .await +} + pub(crate) async fn unstage_git_file_core( workspaces: &Mutex>, workspace_id: String, diff --git a/src-tauri/src/shared/git_ui_core/commands.rs b/src-tauri/src/shared/git_ui_core/commands.rs index 90400d74b..f8f4c77ba 100644 --- a/src-tauri/src/shared/git_ui_core/commands.rs +++ b/src-tauri/src/shared/git_ui_core/commands.rs @@ -1,20 +1,34 @@ -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::fs; use std::path::{Path, PathBuf}; +use std::process::Stdio; use git2::{BranchType, Repository, Status, StatusOptions}; use serde_json::{json, Value}; +use tokio::io::AsyncWriteExt; use tokio::sync::Mutex; use crate::git_utils::{ checkout_branch, list_git_roots as scan_git_roots, parse_github_repo, resolve_git_root, }; +use crate::shared::git_core; use crate::shared::process_core::tokio_command; -use crate::types::{BranchInfo, WorkspaceEntry}; +use crate::types::{BranchInfo, GitSelectionApplyResult, GitSelectionLine, WorkspaceEntry}; use crate::utils::{git_env_path, normalize_git_path, resolve_git_binary}; use super::context::workspace_entry_for_id; +fn git_selection_debug_enabled() -> bool { + std::env::var_os("CODEX_MONITOR_GIT_SELECTION_DEBUG").is_some() +} + +fn git_selection_debug_log(event: &str, payload: Value) { + if !git_selection_debug_enabled() { + return; + } + eprintln!("[git-selection] {event} {}", payload); +} + async fn run_git_command(repo_root: &Path, args: &[&str]) -> Result<(), String> { let git_bin = resolve_git_binary().map_err(|e| format!("Failed to run git: {e}"))?; let output = tokio_command(git_bin) @@ -397,6 +411,960 @@ async fn pull_with_default_strategy(repo_root: &Path) -> Result<(), String> { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub(super) enum SelectionLineType { + Add, + Del, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +struct SelectionLineKey { + line_type: SelectionLineType, + old_line: Option, + new_line: Option, + text: String, +} + +impl TryFrom<&GitSelectionLine> for SelectionLineKey { + type Error = String; + + fn try_from(value: &GitSelectionLine) -> Result { + let line_type = match value.line_type.as_str() { + "add" => SelectionLineType::Add, + "del" => SelectionLineType::Del, + _ => { + return Err(format!( + "Unsupported selection line type `{}`. Expected `add` or `del`.", + value.line_type + )); + } + }; + if line_type == SelectionLineType::Add && value.new_line.is_none() { + return Err("Selected `add` line is missing `newLine`.".to_string()); + } + if line_type == SelectionLineType::Del && value.old_line.is_none() { + return Err("Selected `del` line is missing `oldLine`.".to_string()); + } + Ok(Self { + line_type, + old_line: value.old_line, + new_line: value.new_line, + text: value.text.clone(), + }) + } +} + +#[derive(Debug, Clone)] +pub(super) struct ParsedPatchLine { + pub(super) line_type: SelectionLineType, + pub(super) old_line: Option, + pub(super) new_line: Option, + pub(super) old_anchor: usize, + pub(super) new_anchor: usize, + pub(super) text: String, + pub(super) no_newline_after: bool, +} + +#[derive(Debug, Clone)] +pub(super) struct ParsedPatchHunk { + pub(super) old_start: usize, + pub(super) old_count: usize, + pub(super) new_start: usize, + pub(super) new_count: usize, + pub(super) lines: Vec, +} + +#[derive(Debug, Clone)] +pub(super) struct ParsedPatch { + pub(super) headers: Vec, + pub(super) hunks: Vec, +} + +#[derive(Debug, Clone)] +struct SelectionSourceFileContext { + old_lines: Vec, + new_lines: Vec, +} + +fn parse_hunk_range(raw: &str) -> Option<(usize, usize)> { + if let Some((start, count)) = raw.split_once(',') { + Some((start.parse().ok()?, count.parse().ok()?)) + } else { + Some((raw.parse().ok()?, 1)) + } +} + +pub(super) fn parse_hunk_header(line: &str) -> Option<(usize, usize, usize, usize)> { + let suffix = line.strip_prefix("@@ -")?; + let (old_range_raw, rest) = suffix.split_once(" +")?; + let marker_index = rest.find(" @@")?; + let new_range_raw = &rest[..marker_index]; + let (old_start, old_count) = parse_hunk_range(old_range_raw)?; + let (new_start, new_count) = parse_hunk_range(new_range_raw)?; + Some((old_start, old_count, new_start, new_count)) +} + +pub(super) fn parse_zero_context_patch(diff_patch: &str) -> Result { + let lines: Vec<&str> = diff_patch.lines().collect(); + if lines.is_empty() { + return Err("No patch content to apply.".to_string()); + } + + let mut headers = Vec::new(); + let mut hunks = Vec::new(); + let mut index = 0usize; + + while index < lines.len() { + let line = lines[index]; + if let Some((old_start, _old_count, new_start, _new_count)) = parse_hunk_header(line) { + let mut old_cursor = old_start; + let mut new_cursor = new_start; + let mut parsed_lines = Vec::new(); + let mut inner_index = index + 1; + while inner_index < lines.len() { + let body_line = lines[inner_index]; + if parse_hunk_header(body_line).is_some() || body_line.starts_with("diff --git ") { + break; + } + + if let Some(text) = body_line.strip_prefix('+') { + parsed_lines.push(ParsedPatchLine { + line_type: SelectionLineType::Add, + old_line: None, + new_line: Some(new_cursor), + old_anchor: old_cursor, + new_anchor: new_cursor, + text: text.to_string(), + no_newline_after: false, + }); + new_cursor += 1; + } else if let Some(text) = body_line.strip_prefix('-') { + parsed_lines.push(ParsedPatchLine { + line_type: SelectionLineType::Del, + old_line: Some(old_cursor), + new_line: None, + old_anchor: old_cursor, + new_anchor: new_cursor, + text: text.to_string(), + no_newline_after: false, + }); + old_cursor += 1; + } else if body_line.starts_with(' ') { + old_cursor += 1; + new_cursor += 1; + } else if body_line == "\\ No newline at end of file" { + if let Some(last_line) = parsed_lines.last_mut() { + last_line.no_newline_after = true; + } + } + inner_index += 1; + } + if !parsed_lines.is_empty() { + hunks.push(ParsedPatchHunk { + old_start, + old_count: _old_count, + new_start, + new_count: _new_count, + lines: parsed_lines, + }); + } + index = inner_index; + continue; + } + + if hunks.is_empty() { + headers.push(line.to_string()); + } + index += 1; + } + + if headers.is_empty() || hunks.is_empty() { + return Err("Could not parse diff hunks for line selection.".to_string()); + } + + Ok(ParsedPatch { headers, hunks }) +} + +pub(super) fn parsed_patch_hunk_id(source: &str, hunk: &ParsedPatchHunk) -> String { + format!( + "{source}:{}:{}:{}:{}", + hunk.old_start, + hunk.old_count, + hunk.new_start, + hunk.new_count + ) +} + +fn split_text_lines(content: &str) -> Vec { + content.lines().map(ToString::to_string).collect() +} + +fn blob_to_lines(blob: git2::Blob<'_>) -> Result, String> { + let content = String::from_utf8(blob.content().to_vec()) + .map_err(|_| "Selected file contents are not valid UTF-8.".to_string())?; + Ok(split_text_lines(&content)) +} + +fn read_head_lines(repo: &Repository, path: &str) -> Result, String> { + let head = match repo.head() { + Ok(head) => head, + Err(_) => return Ok(Vec::new()), + }; + let tree = head.peel_to_tree().map_err(|e| e.to_string())?; + let entry = match tree.get_path(Path::new(path)) { + Ok(entry) => entry, + Err(_) => return Ok(Vec::new()), + }; + let blob = repo.find_blob(entry.id()).map_err(|e| e.to_string())?; + blob_to_lines(blob) +} + +fn read_index_lines(repo: &Repository, path: &str) -> Result, String> { + let index = repo.index().map_err(|e| e.to_string())?; + let entry = match index.get_path(Path::new(path), 0) { + Some(entry) => entry, + None => return Ok(Vec::new()), + }; + let blob = repo.find_blob(entry.id).map_err(|e| e.to_string())?; + blob_to_lines(blob) +} + +fn read_worktree_lines(repo_root: &Path, path: &str) -> Result, String> { + let full_path = repo_root.join(path); + let data = match fs::read(&full_path) { + Ok(data) => data, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(error) => { + return Err(format!( + "Failed to read selected worktree file {}: {error}", + full_path.display() + )); + } + }; + let content = String::from_utf8(data) + .map_err(|_| "Selected file contents are not valid UTF-8.".to_string())?; + Ok(split_text_lines(&content)) +} + +fn load_selection_source_file_context( + repo_root: &Path, + path: &str, + source: &str, +) -> Result { + let repo = Repository::open(repo_root).map_err(|e| e.to_string())?; + match source { + "unstaged" => Ok(SelectionSourceFileContext { + old_lines: read_index_lines(&repo, path)?, + new_lines: read_worktree_lines(repo_root, path)?, + }), + "staged" => Ok(SelectionSourceFileContext { + old_lines: read_head_lines(&repo, path)?, + new_lines: read_index_lines(&repo, path)?, + }), + _ => Err("Invalid selection source.".to_string()), + } +} + +fn context_before_old_end(line: &ParsedPatchLine) -> usize { + match line.line_type { + SelectionLineType::Add => line.old_anchor, + SelectionLineType::Del => line.old_anchor.saturating_sub(1), + } +} + +fn context_before_new_end(line: &ParsedPatchLine) -> usize { + match line.line_type { + SelectionLineType::Add => line.new_anchor.saturating_sub(1), + SelectionLineType::Del => line.new_anchor, + } +} + +fn context_after_old_start(line: &ParsedPatchLine) -> usize { + line.old_anchor + 1 +} + +fn context_after_new_start(line: &ParsedPatchLine) -> usize { + match line.line_type { + SelectionLineType::Add => line.new_anchor + 1, + SelectionLineType::Del => line.new_anchor, + } +} + +fn selected_old_start(line: &ParsedPatchLine) -> usize { + match line.line_type { + SelectionLineType::Add => line.old_anchor + 1, + SelectionLineType::Del => line.old_anchor, + } +} + +fn selected_new_start(line: &ParsedPatchLine) -> usize { + line.new_anchor +} + +fn shared_suffix_context_len( + old_lines: &[String], + new_lines: &[String], + old_start: usize, + old_end: usize, + new_start: usize, + new_end: usize, +) -> usize { + if old_start == 0 || new_start == 0 || old_end < old_start || new_end < new_start { + return 0; + } + let old_count = old_end - old_start + 1; + let new_count = new_end - new_start + 1; + let max_count = old_count.min(new_count); + let mut count = 0usize; + while count < max_count { + let old_index = old_end.saturating_sub(count); + let new_index = new_end.saturating_sub(count); + if old_index == 0 || new_index == 0 { + break; + } + let Some(old_line) = old_lines.get(old_index - 1) else { + break; + }; + let Some(new_line) = new_lines.get(new_index - 1) else { + break; + }; + if old_line != new_line { + break; + } + count += 1; + } + count +} + +fn shared_prefix_context_len( + old_lines: &[String], + new_lines: &[String], + old_start: usize, + old_end: usize, + new_start: usize, + new_end: usize, +) -> usize { + if old_start == 0 || new_start == 0 || old_end < old_start || new_end < new_start { + return 0; + } + let old_count = old_end - old_start + 1; + let new_count = new_end - new_start + 1; + let max_count = old_count.min(new_count); + let mut count = 0usize; + while count < max_count { + let old_index = old_start + count; + let new_index = new_start + count; + let Some(old_line) = old_lines.get(old_index - 1) else { + break; + }; + let Some(new_line) = new_lines.get(new_index - 1) else { + break; + }; + if old_line != new_line { + break; + } + count += 1; + } + count +} + +fn append_full_hunk_with_context( + output: &mut Vec, + parsed: &ParsedPatch, + hunk_index: usize, + old_lines: &[String], + new_lines: &[String], +) { + let hunk = &parsed.hunks[hunk_index]; + let Some(first) = hunk.lines.first() else { + return; + }; + let Some(last) = hunk.lines.last() else { + return; + }; + + let previous_last = hunk_index + .checked_sub(1) + .and_then(|index| parsed.hunks.get(index)) + .and_then(|previous| previous.lines.last()); + let next_first = parsed + .hunks + .get(hunk_index + 1) + .and_then(|next| next.lines.first()); + + let available_before_old_start = previous_last + .map(context_after_old_start) + .unwrap_or(1); + let available_before_new_start = previous_last + .map(context_after_new_start) + .unwrap_or(1); + let available_before_old_end = context_before_old_end(first); + let available_before_new_end = context_before_new_end(first); + let before_count = shared_suffix_context_len( + old_lines, + new_lines, + available_before_old_start, + available_before_old_end, + available_before_new_start, + available_before_new_end, + ); + let before_old_start = if before_count > 0 { + available_before_old_end - before_count + 1 + } else { + 0 + }; + let before_new_start = if before_count > 0 { + available_before_new_end - before_count + 1 + } else { + 0 + }; + + let available_after_old_start = context_after_old_start(last); + let available_after_new_start = context_after_new_start(last); + let available_after_old_end = next_first + .map(context_before_old_end) + .unwrap_or(old_lines.len()); + let available_after_new_end = next_first + .map(context_before_new_end) + .unwrap_or(new_lines.len()); + let after_count = shared_prefix_context_len( + old_lines, + new_lines, + available_after_old_start, + available_after_old_end, + available_after_new_start, + available_after_new_end, + ); + + let old_count = before_count + + hunk + .lines + .iter() + .filter(|line| line.line_type == SelectionLineType::Del) + .count() + + after_count; + let new_count = before_count + + hunk + .lines + .iter() + .filter(|line| line.line_type == SelectionLineType::Add) + .count() + + after_count; + + let old_start = if before_count > 0 { + before_old_start + } else { + selected_old_start(first) + }; + let new_start = if before_count > 0 { + before_new_start + } else { + selected_new_start(first) + }; + + output.push(format!( + "@@ -{},{} +{},{} @@", + old_start, old_count, new_start, new_count + )); + + if before_count > 0 { + for offset in 0..before_count { + if let Some(line) = old_lines.get(before_old_start + offset - 1) { + output.push(format!(" {}", line)); + } + } + } + + for line in &hunk.lines { + let prefix = if line.line_type == SelectionLineType::Add { + '+' + } else { + '-' + }; + output.push(format!("{prefix}{}", line.text)); + if line.no_newline_after { + output.push("\\ No newline at end of file".to_string()); + } + } + + if after_count > 0 { + for offset in 0..after_count { + if let Some(line) = old_lines.get(available_after_old_start + offset - 1) { + output.push(format!(" {}", line)); + } + } + } +} + +fn build_selected_patch( + diff_patch: &str, + selected_lines: &HashSet, + file_context: &SelectionSourceFileContext, +) -> Result<(String, usize), String> { + let parsed = parse_zero_context_patch(diff_patch)?; + let mut output = parsed.headers.clone(); + let mut applied_line_count = 0usize; + let debug_enabled = git_selection_debug_enabled(); + let mut debug_hunks: Vec = Vec::new(); + + for (hunk_index, hunk) in parsed.hunks.iter().enumerate() { + let mut group: Vec<&ParsedPatchLine> = Vec::new(); + let mut matched_lines: Vec = Vec::new(); + let flush_group = |group: &mut Vec<&ParsedPatchLine>, output: &mut Vec| { + if group.is_empty() { + return; + } + let first = group[0]; + let old_count = group + .iter() + .filter(|line| line.line_type == SelectionLineType::Del) + .count(); + let new_count = group + .iter() + .filter(|line| line.line_type == SelectionLineType::Add) + .count(); + output.push(format!( + "@@ -{},{} +{},{} @@", + first.old_anchor, old_count, first.new_anchor, new_count + )); + for line in group.iter() { + let prefix = if line.line_type == SelectionLineType::Add { + '+' + } else { + '-' + }; + output.push(format!("{prefix}{}", line.text)); + if line.no_newline_after { + output.push("\\ No newline at end of file".to_string()); + } + } + group.clear(); + }; + + let selected_count = hunk + .lines + .iter() + .filter(|line| { + selected_lines.contains(&SelectionLineKey { + line_type: line.line_type, + old_line: line.old_line, + new_line: line.new_line, + text: line.text.clone(), + }) + }) + .count(); + + for line in &hunk.lines { + let key = SelectionLineKey { + line_type: line.line_type, + old_line: line.old_line, + new_line: line.new_line, + text: line.text.clone(), + }; + if selected_lines.contains(&key) { + group.push(line); + applied_line_count += 1; + if debug_enabled { + matched_lines.push(json!({ + "type": if line.line_type == SelectionLineType::Add { "add" } else { "del" }, + "oldLine": line.old_line, + "newLine": line.new_line, + "oldAnchor": line.old_anchor, + "newAnchor": line.new_anchor, + "text": line.text, + })); + } + } else { + flush_group(&mut group, &mut output); + } + } + if selected_count == hunk.lines.len() && selected_count > 0 { + group.clear(); + append_full_hunk_with_context( + &mut output, + &parsed, + hunk_index, + &file_context.old_lines, + &file_context.new_lines, + ); + } else { + flush_group(&mut group, &mut output); + } + if debug_enabled { + debug_hunks.push(json!({ + "hunkIndex": hunk_index, + "hunkLineCount": hunk.lines.len(), + "matchedLineCount": matched_lines.len(), + "matchedLines": matched_lines, + })); + } + } + + if applied_line_count == 0 { + return Err("Selected lines do not match the current diff. Refresh and try again.".to_string()); + } + + let mut patch = output.join("\n"); + if !patch.ends_with('\n') { + patch.push('\n'); + } + if debug_enabled { + git_selection_debug_log( + "build-selected-patch", + json!({ + "selectedLineKeyCount": selected_lines.len(), + "appliedLineCount": applied_line_count, + "outputLineCount": patch.lines().count(), + "hunks": debug_hunks, + "patch": patch, + }), + ); + } + Ok((patch, applied_line_count)) +} + +async fn apply_cached_patch(repo_root: &Path, patch: &str, reverse: bool) -> Result<(), String> { + let git_bin = resolve_git_binary().map_err(|e| format!("Failed to run git: {e}"))?; + let mut args = vec![ + "apply", + "--cached", + "--unidiff-zero", + "--whitespace=nowarn", + ]; + if reverse { + args.push("--reverse"); + } + args.push("-"); + + let mut child = tokio_command(git_bin) + .args(args) + .current_dir(repo_root) + .env("PATH", git_env_path()) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|e| format!("Failed to run git: {e}"))?; + + if let Some(mut stdin) = child.stdin.take() { + stdin + .write_all(patch.as_bytes()) + .await + .map_err(|e| format!("Failed to write git apply input: {e}"))?; + } + + let output = child + .wait_with_output() + .await + .map_err(|e| format!("Failed to run git: {e}"))?; + if output.status.success() { + return Ok(()); + } + + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + let detail = if stderr.trim().is_empty() { + stdout.trim() + } else { + stderr.trim() + }; + if detail.is_empty() { + return Err("Git apply failed.".to_string()); + } + Err(detail.to_string()) +} + +fn selection_source_from_display_hunk_id(display_hunk_id: &str) -> Result<&str, String> { + let source = display_hunk_id + .split(':') + .next() + .ok_or_else(|| "Invalid display hunk ID.".to_string())?; + match source { + "staged" | "unstaged" => Ok(source), + _ => Err("Invalid display hunk ID source.".to_string()), + } +} + +fn build_display_hunk_patch( + diff_patch: &str, + source: &str, + display_hunk_id: &str, + file_context: &SelectionSourceFileContext, +) -> Result<(String, usize), String> { + let parsed = parse_zero_context_patch(diff_patch)?; + let Some((hunk_index, hunk)) = parsed + .hunks + .iter() + .enumerate() + .find(|(_, hunk)| parsed_patch_hunk_id(source, hunk) == display_hunk_id) + else { + return Err( + "Display hunk no longer matches the current diff. Refresh and try again.".to_string(), + ); + }; + + let mut output = parsed.headers.clone(); + append_full_hunk_with_context( + &mut output, + &parsed, + hunk_index, + &file_context.old_lines, + &file_context.new_lines, + ); + + let mut patch = output.join("\n"); + if !patch.ends_with('\n') { + patch.push('\n'); + } + + Ok((patch, hunk.lines.len())) +} + +async fn load_selection_source_patch( + repo_root: &Path, + action_path: &str, + source: &str, + ignore_whitespace_changes: bool, +) -> Result { + let repo = Repository::open(repo_root).map_err(|e| e.to_string())?; + let status = repo + .status_file(Path::new(action_path)) + .unwrap_or(Status::empty()); + let is_untracked_worktree_file = + status.contains(Status::WT_NEW) && !status.contains(Status::INDEX_NEW); + + let mut args = vec!["diff"]; + if source == "unstaged" && is_untracked_worktree_file { + args.push("--no-index"); + args.push("--no-color"); + args.push("-U0"); + if ignore_whitespace_changes { + args.push("-w"); + } + args.push("--"); + args.push(if cfg!(windows) { "NUL" } else { "/dev/null" }); + args.push(action_path); + } else { + if source == "staged" { + args.push("--cached"); + } + args.push("--no-color"); + args.push("-U0"); + if ignore_whitespace_changes { + args.push("-w"); + } + args.push("--"); + args.push(action_path); + } + + Ok(String::from_utf8_lossy( + &git_core::run_git_diff(&repo_root.to_path_buf(), &args).await?, + ) + .to_string()) +} + +pub(super) async fn stage_git_selection_inner( + workspaces: &Mutex>, + workspace_id: String, + path: String, + op: String, + source: String, + lines: Vec, +) -> Result { + if lines.is_empty() { + return Err("No selected lines provided.".to_string()); + } + + let entry = workspace_entry_for_id(workspaces, &workspace_id).await?; + let repo_root = resolve_git_root(&entry)?; + let action_paths = action_paths_for_file(&repo_root, &path); + if action_paths.len() != 1 { + return Err("Line-level stage/unstage for renamed paths is not supported yet.".to_string()); + } + let action_path = action_paths[0].clone(); + + let reverse_apply = match (op.as_str(), source.as_str()) { + ("stage", "unstaged") => false, + ("unstage", "staged") => true, + ("stage", "staged") => { + return Err("Staging selected lines requires source `unstaged`.".to_string()); + } + ("unstage", "unstaged") => { + return Err("Unstaging selected lines requires source `staged`.".to_string()); + } + _ => { + return Err("Invalid stage selection request. Expected op/source to be stage+unstaged or unstage+staged.".to_string()); + } + }; + + let source_patch = + load_selection_source_patch(&repo_root, action_path.as_str(), &source, false).await?; + if source_patch.trim().is_empty() { + return Err("No changes available for the requested selection source.".to_string()); + } + let debug_source_hunks = if git_selection_debug_enabled() { + parse_zero_context_patch(&source_patch).ok().map(|parsed| { + parsed + .hunks + .iter() + .enumerate() + .map(|(index, hunk)| { + let first = hunk.lines.first(); + let last = hunk.lines.last(); + json!({ + "hunkIndex": index, + "lineCount": hunk.lines.len(), + "firstOldLine": first.and_then(|line| line.old_line), + "firstNewLine": first.and_then(|line| line.new_line), + "lastOldLine": last.and_then(|line| line.old_line), + "lastNewLine": last.and_then(|line| line.new_line), + }) + }) + .collect::>() + }) + } else { + None + }; + + let mut selected_lines = HashSet::new(); + for line in &lines { + selected_lines.insert(SelectionLineKey::try_from(line)?); + } + if git_selection_debug_enabled() { + git_selection_debug_log( + "stage-selection-request", + json!({ + "workspaceId": workspace_id, + "path": path, + "op": op, + "source": source, + "rawLineCount": lines.len(), + "dedupedLineCount": selected_lines.len(), + "selectedLines": lines, + "sourceHunks": debug_source_hunks.unwrap_or_default(), + }), + ); + } + + let file_context = load_selection_source_file_context(&repo_root, action_path.as_str(), &source)?; + let (selected_patch, applied_line_count) = + build_selected_patch(&source_patch, &selected_lines, &file_context)?; + if git_selection_debug_enabled() { + git_selection_debug_log( + "stage-selection-apply", + json!({ + "path": path, + "reverseApply": reverse_apply, + "appliedLineCount": applied_line_count, + "selectedPatchLineCount": selected_patch.lines().count(), + }), + ); + } + apply_cached_patch(&repo_root, &selected_patch, reverse_apply).await?; + if git_selection_debug_enabled() { + let cached_after_apply = String::from_utf8_lossy( + &git_core::run_git_diff( + &repo_root.to_path_buf(), + &["diff", "--cached", "--no-color", "-U0", "--", action_path.as_str()], + ) + .await?, + ) + .to_string(); + let unstaged_after_apply = String::from_utf8_lossy( + &git_core::run_git_diff( + &repo_root.to_path_buf(), + &["diff", "--no-color", "-U0", "--", action_path.as_str()], + ) + .await?, + ) + .to_string(); + git_selection_debug_log( + "stage-selection-post-apply", + json!({ + "path": path, + "op": op, + "source": source, + "cachedDiff": cached_after_apply, + "unstagedDiff": unstaged_after_apply, + }), + ); + } + + Ok(GitSelectionApplyResult { + applied: true, + applied_line_count, + warning: None, + }) +} + +pub(super) async fn apply_git_display_hunk_inner( + workspaces: &Mutex>, + workspace_id: String, + path: String, + display_hunk_id: String, + ignore_whitespace_changes: bool, +) -> Result { + let source = selection_source_from_display_hunk_id(&display_hunk_id)?; + let op = match source { + "unstaged" => "stage", + "staged" => "unstage", + _ => unreachable!(), + }; + + let entry = workspace_entry_for_id(workspaces, &workspace_id).await?; + let repo_root = resolve_git_root(&entry)?; + let action_paths = action_paths_for_file(&repo_root, &path); + if action_paths.len() != 1 { + return Err("Line-level stage/unstage for renamed paths is not supported yet.".to_string()); + } + let action_path = action_paths[0].clone(); + + let reverse_apply = match source { + "unstaged" => false, + "staged" => true, + _ => unreachable!(), + }; + + let source_patch = load_selection_source_patch( + &repo_root, + action_path.as_str(), + source, + ignore_whitespace_changes, + ) + .await?; + if source_patch.trim().is_empty() { + return Err("No changes available for the requested display hunk.".to_string()); + } + + let file_context = + load_selection_source_file_context(&repo_root, action_path.as_str(), source)?; + let (selected_patch, applied_line_count) = + build_display_hunk_patch(&source_patch, source, &display_hunk_id, &file_context)?; + + if git_selection_debug_enabled() { + git_selection_debug_log( + "display-hunk-apply", + json!({ + "workspaceId": workspace_id, + "path": path, + "displayHunkId": display_hunk_id, + "op": op, + "source": source, + "reverseApply": reverse_apply, + "appliedLineCount": applied_line_count, + }), + ); + } + + apply_cached_patch(&repo_root, &selected_patch, reverse_apply).await?; + + Ok(GitSelectionApplyResult { + applied: true, + applied_line_count, + warning: None, + }) +} + pub(super) async fn stage_git_file_inner( workspaces: &Mutex>, workspace_id: String, @@ -779,7 +1747,78 @@ pub(super) async fn create_git_branch_inner( #[cfg(test)] mod tests { - use super::{gh_repo_create_args, validate_branch_name}; + use super::{ + build_selected_patch, gh_repo_create_args, parse_zero_context_patch, + SelectionLineKey, SelectionSourceFileContext, validate_branch_name, + }; + use std::{ + collections::HashSet, + fs, + io::Write, + path::{Path, PathBuf}, + process::{Command, Stdio}, + time::{SystemTime, UNIX_EPOCH}, + }; + + fn run_git(repo_root: &Path, args: &[&str]) -> String { + let output = Command::new("git") + .args(args) + .current_dir(repo_root) + .output() + .expect("failed to run git"); + assert!( + output.status.success(), + "git {:?} failed: {}", + args, + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8_lossy(&output.stdout).to_string() + } + + fn run_git_with_stdin(repo_root: &Path, args: &[&str], stdin_text: &str) { + let mut child = Command::new("git") + .args(args) + .current_dir(repo_root) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("failed to spawn git"); + child + .stdin + .as_mut() + .expect("missing git stdin") + .write_all(stdin_text.as_bytes()) + .expect("failed to write git stdin"); + let output = child.wait_with_output().expect("failed to wait for git"); + assert!( + output.status.success(), + "git {:?} failed: {}\n{}", + args, + String::from_utf8_lossy(&output.stderr), + String::from_utf8_lossy(&output.stdout) + ); + } + + fn create_temp_repo() -> PathBuf { + let unique = format!( + "codex_monitor_git_select_{}_{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock drift") + .as_nanos() + ); + let repo_root = std::env::temp_dir().join(unique); + fs::create_dir_all(&repo_root).expect("failed to create temp repo"); + run_git(&repo_root, &["init"]); + run_git(&repo_root, &["config", "user.name", "Codex Monitor Tests"]); + run_git( + &repo_root, + &["config", "user.email", "codex-monitor-tests@example.com"], + ); + repo_root + } #[test] fn validate_branch_name_rejects_repeated_slashes() { @@ -811,4 +1850,318 @@ mod tests { vec!["repo", "create", "owner/repo", "--public"] ); } + + #[test] + fn build_selected_patch_targets_first_identical_addition_hunk() { + let repo_root = create_temp_repo(); + let file_path = repo_root.join("CardView.swift"); + + let baseline = "pre\nanchor-one\nmid\nanchor-two\npost\n"; + fs::write(&file_path, baseline).expect("failed to write baseline"); + run_git(&repo_root, &["add", "--", "CardView.swift"]); + run_git( + &repo_root, + &["commit", "-m", "Initial baseline", "--quiet"], + ); + + let changed = "pre\nanchor-one\n.padding(6)\n.background(Color.black.opacity(0.35), in:\nCircle())\n.shadow(color: .black.opacity(0.35), radius:\n4, x: 0, y: 2)\nmid\nanchor-two\n.padding(6)\n.background(Color.black.opacity(0.35), in:\nCircle())\n.shadow(color: .black.opacity(0.35), radius:\n4, x: 0, y: 2)\npost\n"; + fs::write(&file_path, changed).expect("failed to write changed file"); + + let source_patch = run_git(&repo_root, &["diff", "--no-color", "-U0", "--", "CardView.swift"]); + let parsed = parse_zero_context_patch(&source_patch).expect("failed to parse source patch"); + assert!( + parsed.hunks.len() >= 2, + "expected at least two hunks in source patch" + ); + + let first_hunk = &parsed.hunks[0]; + let second_hunk = &parsed.hunks[1]; + let selected_lines: HashSet = first_hunk + .lines + .iter() + .map(|line| SelectionLineKey { + line_type: line.line_type, + old_line: line.old_line, + new_line: line.new_line, + text: line.text.clone(), + }) + .collect(); + + let file_context = SelectionSourceFileContext { + old_lines: baseline.lines().map(ToString::to_string).collect(), + new_lines: changed.lines().map(ToString::to_string).collect(), + }; + let (selected_patch, _) = build_selected_patch(&source_patch, &selected_lines, &file_context) + .expect("selection patch failed"); + + let second_header = format!( + "@@ -{},0 +{},{} @@", + second_hunk.lines[0].old_anchor, + second_hunk.lines[0].new_anchor, + second_hunk.lines.len() + ); + let first_header = format!( + "@@ -{},0 +{},{} @@", + first_hunk.lines[0].old_anchor, + first_hunk.lines[0].new_anchor, + first_hunk.lines.len() + ); + assert!( + selected_patch.contains(" anchor-one"), + "selection patch did not include first-hunk context: {selected_patch}" + ); + assert!( + selected_patch.contains(" mid"), + "selection patch did not include trailing context for first hunk: {selected_patch}" + ); + assert!( + selected_patch.matches("+.padding(6)").count() == 1, + "selection patch included duplicate selected additions: {selected_patch}" + ); + + run_git_with_stdin( + &repo_root, + &["apply", "--cached", "--unidiff-zero", "--whitespace=nowarn", "-"], + &selected_patch, + ); + + let cached_patch = run_git( + &repo_root, + &["diff", "--cached", "--no-color", "-U0", "--", "CardView.swift"], + ); + assert!( + cached_patch.contains(&first_header), + "cached patch did not stage first hunk: {cached_patch}" + ); + assert!( + !cached_patch.contains(&second_header), + "cached patch staged second hunk unexpectedly: {cached_patch}" + ); + + fs::remove_dir_all(&repo_root).expect("failed to cleanup temp repo"); + } + + #[test] + fn build_selected_patch_targets_first_identical_swiftui_overlay_hunk() { + let repo_root = create_temp_repo(); + let file_path = repo_root.join("CardsMediaB25ContentView.swift"); + + let baseline = r#"struct CardsMediaB25View: CardsSwiftUIContentViewInitializable { + func mediaOverlay(for type: OverlayType) { + if type.contains(.video) { + Image("video_overlay") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: state.rowWidth * 0.1, height: state.rowWidth * 0.1) + } else if type.contains(.audio) { + Image("audio_overlay") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: state.rowWidth * 0.1, height: state.rowWidth * 0.1) + } + } +} +"#; + fs::write(&file_path, baseline).expect("failed to write baseline"); + run_git(&repo_root, &["add", "--", "CardsMediaB25ContentView.swift"]); + run_git( + &repo_root, + &["commit", "-m", "Initial baseline", "--quiet"], + ); + + let changed = r#"struct CardsMediaB25View: CardsSwiftUIContentViewInitializable { + func mediaOverlay(for type: OverlayType) { + if type.contains(.video) { + Image("video_overlay") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: state.rowWidth * 0.1, height: state.rowWidth * 0.1) + .padding(6) + .background(Color.black.opacity(0.35), in: Circle()) + .shadow(color: .black.opacity(0.35), radius: 4, x: 0, y: 2) + } else if type.contains(.audio) { + Image("audio_overlay") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: state.rowWidth * 0.1, height: state.rowWidth * 0.1) + .padding(6) + .background(Color.black.opacity(0.35), in: Circle()) + .shadow(color: .black.opacity(0.35), radius: 4, x: 0, y: 2) + } + } +} +"#; + fs::write(&file_path, changed).expect("failed to write changed file"); + + let source_patch = run_git( + &repo_root, + &["diff", "--no-color", "-U0", "--", "CardsMediaB25ContentView.swift"], + ); + let parsed = parse_zero_context_patch(&source_patch).expect("failed to parse source patch"); + assert_eq!(parsed.hunks.len(), 2, "expected two identical hunks"); + + let first_hunk = &parsed.hunks[0]; + let second_hunk = &parsed.hunks[1]; + let selected_lines: HashSet = first_hunk + .lines + .iter() + .map(|line| SelectionLineKey { + line_type: line.line_type, + old_line: line.old_line, + new_line: line.new_line, + text: line.text.clone(), + }) + .collect(); + + let file_context = SelectionSourceFileContext { + old_lines: baseline.lines().map(ToString::to_string).collect(), + new_lines: changed.lines().map(ToString::to_string).collect(), + }; + let (selected_patch, _) = build_selected_patch(&source_patch, &selected_lines, &file_context) + .expect("selection patch failed"); + assert!( + selected_patch.contains(r#" Image("video_overlay")"#), + "selection patch did not anchor to the video block: {selected_patch}" + ); + assert!( + selected_patch.matches("+ .padding(6)").count() == 1, + "selection patch included duplicate selected additions: {selected_patch}" + ); + + run_git_with_stdin( + &repo_root, + &["apply", "--cached", "--unidiff-zero", "--whitespace=nowarn", "-"], + &selected_patch, + ); + + let first_header = format!( + "@@ -{},0 +{},{} @@", + first_hunk.lines[0].old_anchor, + first_hunk.lines[0].new_anchor, + first_hunk.lines.len() + ); + let second_header = format!( + "@@ -{},0 +{},{} @@", + second_hunk.lines[0].old_anchor, + second_hunk.lines[0].new_anchor, + second_hunk.lines.len() + ); + let cached_patch = run_git( + &repo_root, + &[ + "diff", + "--cached", + "--no-color", + "-U0", + "--", + "CardsMediaB25ContentView.swift", + ], + ); + assert!( + cached_patch.contains(&first_header), + "cached patch did not stage first SwiftUI hunk: {cached_patch}" + ); + assert!( + !cached_patch.contains(&second_header), + "cached patch staged second SwiftUI hunk unexpectedly: {cached_patch}" + ); + + fs::remove_dir_all(&repo_root).expect("failed to cleanup temp repo"); + } + + #[test] + fn parse_zero_context_patch_keeps_no_newline_markers() { + let diff_patch = concat!( + "diff --git a/example.txt b/example.txt\n", + "index 1111111..2222222 100644\n", + "--- a/example.txt\n", + "+++ b/example.txt\n", + "@@ -1 +1 @@\n", + "-before\n", + "\\ No newline at end of file\n", + "+after\n", + "\\ No newline at end of file\n" + ); + + let parsed = parse_zero_context_patch(diff_patch).expect("parse source patch"); + + assert_eq!(parsed.hunks.len(), 1); + assert_eq!(parsed.hunks[0].lines.len(), 2); + assert!(parsed.hunks[0].lines[0].no_newline_after); + assert!(parsed.hunks[0].lines[1].no_newline_after); + } + + #[test] + fn parse_zero_context_patch_keeps_content_lines_starting_with_patch_header_prefixes() { + let diff_patch = concat!( + "diff --git a/example.txt b/example.txt\n", + "index 1111111..2222222 100644\n", + "--- a/example.txt\n", + "+++ b/example.txt\n", + "@@ -1,2 +1,2 @@\n", + "----title\n", + "-plain\n", + "++++title\n", + "+plain updated\n" + ); + + let parsed = parse_zero_context_patch(diff_patch).expect("parse source patch"); + let texts: Vec<&str> = parsed.hunks[0].lines.iter().map(|line| line.text.as_str()).collect(); + + assert_eq!(texts, vec!["---title", "plain", "+++title", "plain updated"]); + } + + #[test] + fn build_selected_patch_preserves_no_newline_markers_for_apply() { + let repo_root = create_temp_repo(); + let file_path = repo_root.join("example.txt"); + + fs::write(&file_path, "before").expect("write baseline"); + run_git(&repo_root, &["add", "--", "example.txt"]); + run_git(&repo_root, &["commit", "-m", "Initial baseline", "--quiet"]); + + fs::write(&file_path, "after").expect("write changed file"); + + let source_patch = run_git(&repo_root, &["diff", "--no-color", "-U0", "--", "example.txt"]); + let parsed = parse_zero_context_patch(&source_patch).expect("failed to parse source patch"); + let selected_lines: HashSet = parsed.hunks[0] + .lines + .iter() + .map(|line| SelectionLineKey { + line_type: line.line_type, + old_line: line.old_line, + new_line: line.new_line, + text: line.text.clone(), + }) + .collect(); + + let file_context = SelectionSourceFileContext { + old_lines: vec!["before".to_string()], + new_lines: vec!["after".to_string()], + }; + let (selected_patch, _) = build_selected_patch(&source_patch, &selected_lines, &file_context) + .expect("selection patch failed"); + + assert!( + selected_patch.contains("\\ No newline at end of file"), + "selection patch should preserve no-newline marker: {selected_patch}" + ); + + run_git_with_stdin( + &repo_root, + &["apply", "--cached", "--unidiff-zero", "--whitespace=nowarn", "-"], + &selected_patch, + ); + + let cached_patch = run_git( + &repo_root, + &["diff", "--cached", "--no-color", "-U0", "--", "example.txt"], + ); + assert!( + cached_patch.contains("+after"), + "cached patch did not stage newline-less change: {cached_patch}" + ); + + fs::remove_dir_all(&repo_root).expect("failed to cleanup temp repo"); + } } diff --git a/src-tauri/src/shared/git_ui_core/diff.rs b/src-tauri/src/shared/git_ui_core/diff.rs index 0c6af07e8..39c01f043 100644 --- a/src-tauri/src/shared/git_ui_core/diff.rs +++ b/src-tauri/src/shared/git_ui_core/diff.rs @@ -13,15 +13,51 @@ use crate::git_utils::{ diff_patch_to_string, diff_stats_for_path, image_mime_type, resolve_git_root, }; use crate::shared::process_core::std_command; -use crate::types::{AppSettings, GitCommitDiff, GitFileDiff, GitFileStatus, WorkspaceEntry}; +use crate::types::{ + AppSettings, GitCommitDiff, GitFileDiff, GitFileDisplayHunk, GitFileStatus, WorkspaceEntry, +}; use crate::utils::{git_env_path, normalize_git_path, resolve_git_binary}; +use super::commands::{parse_zero_context_patch, parsed_patch_hunk_id, ParsedPatchHunk}; use super::context::workspace_entry_for_id; const INDEX_SKIP_WORKTREE_FLAG: u16 = 0x4000; const MAX_IMAGE_BYTES: usize = 10 * 1024 * 1024; const MAX_TEXT_DIFF_BYTES: usize = 2 * 1024 * 1024; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ParsedDisplayLineType { + Add, + Del, + Context, + Hunk, + Meta, +} + +#[derive(Debug, Clone)] +struct ParsedDisplayLine { + line_type: ParsedDisplayLineType, + old_line: Option, + new_line: Option, +} + +#[derive(Debug, Clone)] +struct ParsedDisplaySegment { + lines: Vec<(usize, ParsedDisplayLine)>, +} + +#[derive(Debug, Clone, Copy)] +struct DisplayLineRange { + start: usize, + end: usize, +} + +#[derive(Debug, Clone, Copy)] +struct DisplayMatchRange { + old_range: Option, + new_range: Option, +} + fn encode_image_base64(data: &[u8]) -> Option { if data.len() > MAX_IMAGE_BYTES { return None; @@ -123,6 +159,617 @@ fn status_for_delta(status: git2::Delta) -> &'static str { } } +fn unstaged_diff_paths_with_git(repo_root: &Path, paths: &[String]) -> Option> { + if paths.is_empty() { + return Some(HashSet::new()); + } + + const MAX_PATHS_PER_BATCH: usize = 200; + let git_bin = resolve_git_binary().ok()?; + let mut changed_paths = HashSet::new(); + + for batch in paths.chunks(MAX_PATHS_PER_BATCH) { + let mut args = vec!["diff", "--no-color", "--name-only", "-z", "--"]; + args.extend(batch.iter().map(String::as_str)); + + let output = std_command(&git_bin) + .args(args) + .current_dir(repo_root) + .env("PATH", git_env_path()) + .output() + .ok()?; + if !(output.status.success() || output.status.code() == Some(1)) { + return None; + } + + for raw_path in output.stdout.split(|byte| *byte == 0) { + if raw_path.is_empty() { + continue; + } + let path = String::from_utf8_lossy(raw_path); + changed_paths.insert(normalize_git_path(path.as_ref())); + } + } + + Some(changed_paths) +} + +fn source_diff_for_path( + repo_root: &Path, + path: &str, + cached: bool, + ignore_whitespace_changes: bool, + is_untracked_worktree_file: bool, +) -> Option { + let git_bin = resolve_git_binary().ok()?; + let mut args = vec!["diff"]; + if is_untracked_worktree_file && !cached { + args.push("--no-index"); + args.push("--no-color"); + args.push("-U0"); + if ignore_whitespace_changes { + args.push("-w"); + } + args.push("--"); + args.push(if cfg!(windows) { "NUL" } else { "/dev/null" }); + args.push(path); + } else { + if cached { + args.push("--cached"); + } + args.push("--no-color"); + args.push("-U0"); + if ignore_whitespace_changes { + args.push("-w"); + } + args.push("--"); + args.push(path); + } + + let output = std_command(git_bin) + .args(args) + .current_dir(repo_root) + .env("PATH", git_env_path()) + .output() + .ok()?; + if !(output.status.success() || output.status.code() == Some(1)) { + return None; + } + + Some(String::from_utf8_lossy(&output.stdout).to_string()) +} + +fn parse_display_diff(diff: &str) -> Vec { + let mut parsed = Vec::new(); + let mut old_line = 0usize; + let mut new_line = 0usize; + let mut in_hunk = false; + + for raw_line in diff.split('\n') { + if let Some((old_start, _, new_start, _)) = super::commands::parse_hunk_header(raw_line) { + old_line = old_start; + new_line = new_start; + parsed.push(ParsedDisplayLine { + line_type: ParsedDisplayLineType::Hunk, + old_line: None, + new_line: None, + }); + in_hunk = true; + continue; + } + + if !in_hunk { + continue; + } + + if raw_line.starts_with('+') { + parsed.push(ParsedDisplayLine { + line_type: ParsedDisplayLineType::Add, + old_line: None, + new_line: Some(new_line), + }); + new_line += 1; + continue; + } + + if raw_line.starts_with('-') { + parsed.push(ParsedDisplayLine { + line_type: ParsedDisplayLineType::Del, + old_line: Some(old_line), + new_line: None, + }); + old_line += 1; + continue; + } + + if raw_line.starts_with(' ') { + parsed.push(ParsedDisplayLine { + line_type: ParsedDisplayLineType::Context, + old_line: Some(old_line), + new_line: Some(new_line), + }); + old_line += 1; + new_line += 1; + continue; + } + + if raw_line.starts_with('\\') { + parsed.push(ParsedDisplayLine { + line_type: ParsedDisplayLineType::Meta, + old_line: None, + new_line: None, + }); + } + } + + parsed +} + +fn build_display_segments(parsed_lines: &[ParsedDisplayLine]) -> Vec { + let mut segments = Vec::new(); + let mut current: Vec<(usize, ParsedDisplayLine)> = Vec::new(); + + for (index, line) in parsed_lines.iter().cloned().enumerate() { + match line.line_type { + ParsedDisplayLineType::Add | ParsedDisplayLineType::Del => current.push((index, line)), + _ => { + if !current.is_empty() { + segments.push(ParsedDisplaySegment { lines: current }); + current = Vec::new(); + } + } + } + } + + if !current.is_empty() { + segments.push(ParsedDisplaySegment { lines: current }); + } + + segments +} + +fn hunk_old_end(hunk: &ParsedPatchHunk) -> Option { + if hunk.old_count == 0 { + None + } else { + Some(hunk.old_start + hunk.old_count - 1) + } +} + +fn hunk_new_end(hunk: &ParsedPatchHunk) -> Option { + if hunk.new_count == 0 { + None + } else { + Some(hunk.new_start + hunk.new_count - 1) + } +} + +fn map_old_to_new_line_clamped(hunks: &[ParsedPatchHunk], old_line: usize) -> usize { + let mut delta = 0isize; + + for hunk in hunks { + if hunk.old_count == 0 { + if old_line < hunk.old_start { + break; + } + delta += hunk.new_count as isize; + continue; + } + + let old_end = hunk.old_start + hunk.old_count - 1; + if old_line < hunk.old_start { + break; + } + if old_line <= old_end { + if hunk.new_count == 0 { + return hunk.new_start; + } + let relative = old_line - hunk.old_start; + return hunk.new_start + relative.min(hunk.new_count - 1); + } + + delta += hunk.new_count as isize - hunk.old_count as isize; + } + + ((old_line as isize) + delta).max(1) as usize +} + +fn map_new_to_old_line_clamped(hunks: &[ParsedPatchHunk], new_line: usize) -> usize { + let mut delta = 0isize; + + for hunk in hunks { + if hunk.new_count == 0 { + let insertion_point = hunk.new_start; + if new_line < insertion_point { + break; + } + delta += hunk.old_count as isize; + continue; + } + + let new_end = hunk.new_start + hunk.new_count - 1; + if new_line < hunk.new_start { + break; + } + if new_line <= new_end { + if hunk.old_count == 0 { + return hunk.old_start; + } + let relative = new_line - hunk.new_start; + return hunk.old_start + relative.min(hunk.old_count - 1); + } + + delta += hunk.old_count as isize - hunk.new_count as isize; + } + + ((new_line as isize) + delta).max(1) as usize +} + +fn display_match_range_for_staged_hunk( + hunk: &ParsedPatchHunk, + unstaged_hunks: &[ParsedPatchHunk], +) -> DisplayMatchRange { + DisplayMatchRange { + old_range: hunk_old_end(hunk).map(|end| DisplayLineRange { + start: hunk.old_start, + end, + }), + new_range: hunk_new_end(hunk).map(|end| DisplayLineRange { + start: map_old_to_new_line_clamped(unstaged_hunks, hunk.new_start), + end: map_old_to_new_line_clamped(unstaged_hunks, end), + }), + } +} + +fn display_match_range_for_unstaged_hunk( + hunk: &ParsedPatchHunk, + staged_hunks: &[ParsedPatchHunk], +) -> DisplayMatchRange { + DisplayMatchRange { + old_range: hunk_old_end(hunk).map(|end| DisplayLineRange { + start: map_new_to_old_line_clamped(staged_hunks, hunk.old_start), + end: map_new_to_old_line_clamped(staged_hunks, end), + }), + new_range: hunk_new_end(hunk).map(|end| DisplayLineRange { + start: hunk.new_start, + end, + }), + } +} + +fn range_contains(range: DisplayLineRange, line_number: Option) -> bool { + matches!(line_number, Some(line_number) if line_number >= range.start && line_number <= range.end) +} + +fn source_hunk_line_counts(hunk: &ParsedPatchHunk) -> (usize, usize) { + hunk.lines.iter().fold((0usize, 0usize), |(adds, dels), line| { + if line.line_type == super::commands::SelectionLineType::Add { + (adds + 1, dels) + } else { + (adds, dels + 1) + } + }) +} + +fn find_display_hunk_span( + segments: &[ParsedDisplaySegment], + min_start_index: usize, + display_range: DisplayMatchRange, + expected_add_count: usize, + expected_del_count: usize, +) -> Option<(usize, usize, usize)> { + for segment in segments { + let mut matched_indices = Vec::new(); + let mut add_count = 0usize; + let mut del_count = 0usize; + + for (display_index, line) in &segment.lines { + if *display_index < min_start_index { + continue; + } + match line.line_type { + ParsedDisplayLineType::Add => { + if display_range + .new_range + .is_some_and(|range| range_contains(range, line.new_line)) + { + matched_indices.push(*display_index); + add_count += 1; + } + } + ParsedDisplayLineType::Del => { + if display_range + .old_range + .is_some_and(|range| range_contains(range, line.old_line)) + { + matched_indices.push(*display_index); + del_count += 1; + } + } + _ => {} + } + } + + if add_count == expected_add_count && del_count == expected_del_count { + let start = matched_indices.first().copied()?; + let end = matched_indices.last().copied()?; + return Some((start, end, matched_indices.len())); + } + } + + None +} + +fn parse_source_hunks(diff: Option<&str>) -> Vec { + diff.and_then(|diff| { + if diff.trim().is_empty() { + None + } else { + parse_zero_context_patch(diff).ok() + } + }) + .map(|parsed| parsed.hunks) + .unwrap_or_default() +} + +fn build_display_hunks( + diff: &str, + staged_diff: Option<&str>, + unstaged_diff: Option<&str>, +) -> Vec { + let parsed_display_lines = parse_display_diff(diff); + if parsed_display_lines.is_empty() { + return Vec::new(); + } + let display_segments = build_display_segments(&parsed_display_lines); + if display_segments.is_empty() { + return Vec::new(); + } + + let staged_hunks = parse_source_hunks(staged_diff); + let unstaged_hunks = parse_source_hunks(unstaged_diff); + let mut display_hunks = Vec::new(); + + let mut staged_min_start_index = 0usize; + for hunk in &staged_hunks { + let display_range = display_match_range_for_staged_hunk(hunk, &unstaged_hunks); + let (expected_add_count, expected_del_count) = source_hunk_line_counts(hunk); + let Some((start, end, line_count)) = find_display_hunk_span( + &display_segments, + staged_min_start_index, + display_range, + expected_add_count, + expected_del_count, + ) else { + continue; + }; + staged_min_start_index = end.saturating_add(1); + display_hunks.push(GitFileDisplayHunk { + id: parsed_patch_hunk_id("staged", hunk), + source: "staged".to_string(), + action: "unstage".to_string(), + start_display_line_index: start, + end_display_line_index: end, + line_count, + }); + } + + let mut unstaged_min_start_index = 0usize; + for hunk in &unstaged_hunks { + let display_range = display_match_range_for_unstaged_hunk(hunk, &staged_hunks); + let (expected_add_count, expected_del_count) = source_hunk_line_counts(hunk); + let Some((start, end, line_count)) = find_display_hunk_span( + &display_segments, + unstaged_min_start_index, + display_range, + expected_add_count, + expected_del_count, + ) else { + continue; + }; + unstaged_min_start_index = end.saturating_add(1); + display_hunks.push(GitFileDisplayHunk { + id: parsed_patch_hunk_id("unstaged", hunk), + source: "unstaged".to_string(), + action: "stage".to_string(), + start_display_line_index: start, + end_display_line_index: end, + line_count, + }); + } + + display_hunks.sort_by(|left, right| { + left.start_display_line_index + .cmp(&right.start_display_line_index) + .then(left.end_display_line_index.cmp(&right.end_display_line_index)) + .then(left.action.cmp(&right.action)) + .then(left.id.cmp(&right.id)) + }); + + display_hunks +} + +#[cfg(test)] +mod display_hunk_tests { + use super::build_display_hunks; + + #[test] + fn build_display_hunks_preserves_file_order_for_mixed_disjoint_hunks() { + let diff = + "@@ -1,2 +1,4 @@\n line one\n+new staged line\n line two\n+new unstaged line"; + let staged_diff = concat!( + "diff --git a/src/main.ts b/src/main.ts\n", + "index 1111111..2222222 100644\n", + "--- a/src/main.ts\n", + "+++ b/src/main.ts\n", + "@@ -1,0 +2,1 @@\n", + "+new staged line\n" + ); + let unstaged_diff = concat!( + "diff --git a/src/main.ts b/src/main.ts\n", + "index 2222222..3333333 100644\n", + "--- a/src/main.ts\n", + "+++ b/src/main.ts\n", + "@@ -3,0 +4,1 @@\n", + "+new unstaged line\n" + ); + + let display_hunks = build_display_hunks(diff, Some(staged_diff), Some(unstaged_diff)); + + assert_eq!(display_hunks.len(), 2); + assert_eq!(display_hunks[0].id, "staged:1:0:2:1"); + assert_eq!(display_hunks[0].start_display_line_index, 2); + assert_eq!(display_hunks[0].end_display_line_index, 2); + assert_eq!(display_hunks[1].id, "unstaged:3:0:4:1"); + assert_eq!(display_hunks[1].start_display_line_index, 4); + assert_eq!(display_hunks[1].end_display_line_index, 4); + } + + #[test] + fn build_display_hunks_supports_overlapping_staged_and_unstaged_spans() { + let diff = "@@ -1,1 +1,1 @@\n-old value\n+newer value"; + let staged_diff = concat!( + "diff --git a/src/main.ts b/src/main.ts\n", + "index 1111111..2222222 100644\n", + "--- a/src/main.ts\n", + "+++ b/src/main.ts\n", + "@@ -1,1 +1,1 @@\n", + "-old value\n", + "+new value\n" + ); + let unstaged_diff = concat!( + "diff --git a/src/main.ts b/src/main.ts\n", + "index 2222222..3333333 100644\n", + "--- a/src/main.ts\n", + "+++ b/src/main.ts\n", + "@@ -1,1 +1,1 @@\n", + "-new value\n", + "+newer value\n" + ); + + let display_hunks = build_display_hunks(diff, Some(staged_diff), Some(unstaged_diff)); + + assert_eq!(display_hunks.len(), 2); + assert_eq!(display_hunks[0].start_display_line_index, 1); + assert_eq!(display_hunks[0].end_display_line_index, 2); + assert_eq!(display_hunks[1].start_display_line_index, 1); + assert_eq!(display_hunks[1].end_display_line_index, 2); + } + + #[test] + fn build_display_hunks_maps_staged_and_unstaged_insertions_in_file_order() { + let diff = concat!( + "@@ -29,6 +29,17 @@ pub(crate) struct GitSelectionApplyResult {\n", + " pub(crate) warning: Option,\n", + " }\n", + " \n", + "+#[derive(Debug, Serialize, Deserialize, Clone)]\n", + "+#[serde(rename_all = \"camelCase\")]\n", + "+pub(crate) struct GitFileDisplayHunk {\n", + "+ pub(crate) id: String,\n", + "+ pub(crate) source: String,\n", + "+ pub(crate) action: String,\n", + "+ pub(crate) start_display_line_index: usize,\n", + "+ pub(crate) end_display_line_index: usize,\n", + "+ pub(crate) line_count: usize,\n", + "+}\n", + "+\n", + " #[derive(Debug, Serialize, Deserialize, Clone)]\n", + " pub(crate) struct GitFileDiff {\n", + " pub(crate) path: String,\n", + "@@ -37,6 +48,8 @@ pub(crate) struct GitFileDiff {\n", + " pub(crate) staged_diff: Option,\n", + " #[serde(default, rename = \"unstagedDiff\")]\n", + " pub(crate) unstaged_diff: Option,\n", + "+ #[serde(default, rename = \"displayHunks\")]\n", + "+ pub(crate) display_hunks: Vec,\n", + " #[serde(default, rename = \"oldLines\")]\n", + " pub(crate) old_lines: Option>,\n", + " #[serde(default, rename = \"newLines\")]\n" + ); + let staged_diff = concat!( + "diff --git a/src-tauri/src/types.rs b/src-tauri/src/types.rs\n", + "index dfcfa92..1277207 100644\n", + "--- a/src-tauri/src/types.rs\n", + "+++ b/src-tauri/src/types.rs\n", + "@@ -31,0 +32,11 @@ pub(crate) struct GitSelectionApplyResult {\n", + "+#[derive(Debug, Serialize, Deserialize, Clone)]\n", + "+#[serde(rename_all = \"camelCase\")]\n", + "+pub(crate) struct GitFileDisplayHunk {\n", + "+ pub(crate) id: String,\n", + "+ pub(crate) source: String,\n", + "+ pub(crate) action: String,\n", + "+ pub(crate) start_display_line_index: usize,\n", + "+ pub(crate) end_display_line_index: usize,\n", + "+ pub(crate) line_count: usize,\n", + "+}\n", + "+\n" + ); + let unstaged_diff = concat!( + "diff --git a/src-tauri/src/types.rs b/src-tauri/src/types.rs\n", + "index 1277207..4d7914e 100644\n", + "--- a/src-tauri/src/types.rs\n", + "+++ b/src-tauri/src/types.rs\n", + "@@ -50,0 +51,2 @@ pub(crate) struct GitFileDiff {\n", + "+ #[serde(default, rename = \"displayHunks\")]\n", + "+ pub(crate) display_hunks: Vec,\n" + ); + + let display_hunks = build_display_hunks(diff, Some(staged_diff), Some(unstaged_diff)); + + assert_eq!(display_hunks.len(), 2); + assert_eq!(display_hunks[0].id, "staged:31:0:32:11"); + assert_eq!(display_hunks[0].source, "staged"); + assert_eq!(display_hunks[0].action, "unstage"); + assert_eq!(display_hunks[0].line_count, 11); + assert!(display_hunks[0].start_display_line_index <= display_hunks[0].end_display_line_index); + + assert_eq!(display_hunks[1].id, "unstaged:50:0:51:2"); + assert_eq!(display_hunks[1].source, "unstaged"); + assert_eq!(display_hunks[1].action, "stage"); + assert_eq!(display_hunks[1].line_count, 2); + assert!(display_hunks[1].start_display_line_index <= display_hunks[1].end_display_line_index); + + assert!(display_hunks[0].start_display_line_index < display_hunks[1].start_display_line_index); + } + + #[test] + fn build_display_hunks_maps_unstaged_hunks_after_staged_deletions() { + let diff = concat!( + "@@ -2,1 +2,0 @@\n", + "-line two\n", + "@@ -5,1 +4,1 @@\n", + "-line five\n", + "+line five updated\n" + ); + let staged_diff = concat!( + "diff --git a/example.txt b/example.txt\n", + "index 1111111..2222222 100644\n", + "--- a/example.txt\n", + "+++ b/example.txt\n", + "@@ -2,1 +2,0 @@\n", + "-line two\n" + ); + let unstaged_diff = concat!( + "diff --git a/example.txt b/example.txt\n", + "index 2222222..3333333 100644\n", + "--- a/example.txt\n", + "+++ b/example.txt\n", + "@@ -4,1 +4,1 @@\n", + "-line five\n", + "+line five updated\n" + ); + + let display_hunks = build_display_hunks(diff, Some(staged_diff), Some(unstaged_diff)); + + assert_eq!(display_hunks.len(), 2); + assert_eq!(display_hunks[0].id, "staged:2:1:2:0"); + assert_eq!(display_hunks[1].id, "unstaged:4:1:4:1"); + assert_eq!(display_hunks[1].start_display_line_index, 3); + assert_eq!(display_hunks[1].end_display_line_index, 4); + } +} + fn has_ignored_parent_directory(repo: &Repository, path: &Path) -> bool { let mut current = path.parent(); while let Some(parent) = current { @@ -361,7 +1008,12 @@ pub(super) async fn get_git_status_inner( .filter_map(|entry| entry.path().map(PathBuf::from)) .filter(|path| !path.as_os_str().is_empty()) .collect(); + let normalized_status_paths: Vec = status_paths + .iter() + .map(|path| normalize_git_path(path.to_string_lossy().as_ref())) + .collect(); let ignored_paths = collect_ignored_paths_with_git(&repo, &status_paths); + let unstaged_diff_paths = unstaged_diff_paths_with_git(&repo_root, &normalized_status_paths); let head_tree = repo.head().ok().and_then(|head| head.peel_to_tree().ok()); let index = repo.index().ok(); @@ -395,13 +1047,22 @@ pub(super) async fn get_git_status_inner( | Status::INDEX_RENAMED | Status::INDEX_TYPECHANGE, ); - let include_workdir = status.intersects( + let mut include_workdir = status.intersects( Status::WT_NEW | Status::WT_MODIFIED | Status::WT_DELETED | Status::WT_RENAMED | Status::WT_TYPECHANGE, ); + + // When the index is updated externally (for example via line-level staging), + // libgit2 can briefly report both staged and workdir status for a path. + // Verify actual unstaged diff content before keeping the workdir bucket. + if include_index && include_workdir { + if let Some(unstaged_diff_paths) = unstaged_diff_paths.as_ref() { + include_workdir = unstaged_diff_paths.contains(&normalized_path); + } + } let mut combined_additions = 0i64; let mut combined_deletions = 0i64; @@ -520,6 +1181,37 @@ pub(super) async fn get_git_diffs_inner( let is_image = old_image_mime.is_some() || new_image_mime.is_some(); let is_deleted = delta.status() == git2::Delta::Deleted; let is_added = delta.status() == git2::Delta::Added; + let file_status = repo.status_file(display_path).unwrap_or(Status::empty()); + let is_untracked_worktree_file = + file_status.contains(Status::WT_NEW) && !file_status.contains(Status::INDEX_NEW); + let staged_diff = source_diff_for_path( + &repo_root, + normalized_path.as_str(), + true, + ignore_whitespace_changes, + is_untracked_worktree_file, + ) + .and_then(|diff| { + if diff.trim().is_empty() { + None + } else { + Some(diff) + } + }); + let unstaged_diff = source_diff_for_path( + &repo_root, + normalized_path.as_str(), + false, + ignore_whitespace_changes, + is_untracked_worktree_file, + ) + .and_then(|diff| { + if diff.trim().is_empty() { + None + } else { + Some(diff) + } + }); let old_lines = if !is_added { head_tree @@ -569,6 +1261,9 @@ pub(super) async fn get_git_diffs_inner( results.push(GitFileDiff { path: normalized_path, diff: String::new(), + staged_diff, + unstaged_diff, + display_hunks: Vec::new(), old_lines: None, new_lines: None, is_binary: true, @@ -595,9 +1290,14 @@ pub(super) async fn get_git_diffs_inner( if content.trim().is_empty() { continue; } + let display_hunks = + build_display_hunks(&content, staged_diff.as_deref(), unstaged_diff.as_deref()); results.push(GitFileDiff { path: normalized_path, diff: content, + staged_diff, + unstaged_diff, + display_hunks, old_lines, new_lines, is_binary: false, diff --git a/src-tauri/src/shared/git_ui_core/tests.rs b/src-tauri/src/shared/git_ui_core/tests.rs index 3afcb120b..9ff264d3f 100644 --- a/src-tauri/src/shared/git_ui_core/tests.rs +++ b/src-tauri/src/shared/git_ui_core/tests.rs @@ -1,6 +1,7 @@ use std::collections::HashMap; use std::fs; use std::path::{Path, PathBuf}; +use std::process::Command; use git2::Repository; use serde_json::Value; @@ -9,8 +10,7 @@ use tokio::sync::Mutex; use crate::types::{AppSettings, WorkspaceEntry, WorkspaceKind, WorkspaceSettings}; -use super::commands; -use super::diff; +use super::{apply_git_display_hunk_core, commands, diff}; fn create_temp_repo() -> (PathBuf, Repository) { let root = std::env::temp_dir().join(format!("codex-monitor-test-{}", uuid::Uuid::new_v4())); @@ -226,6 +226,144 @@ fn get_git_diffs_omits_global_ignored_paths() { assert!(!has_ignored, "ignored files should not appear in diff list"); } +#[test] +fn get_git_diffs_populates_untracked_file_unstaged_diff_and_display_hunks() { + let (root, repo) = create_temp_repo(); + let tracked_path = root.join("tracked.txt"); + fs::write(&tracked_path, "tracked\n").expect("write tracked file"); + let mut index = repo.index().expect("repo index"); + index.add_path(Path::new("tracked.txt")).expect("add tracked path"); + index.write().expect("write index"); + let tree_id = index.write_tree().expect("write tree"); + let tree = repo.find_tree(tree_id).expect("find tree"); + let sig = git2::Signature::now("Test", "test@example.com").expect("signature"); + repo.commit(Some("HEAD"), &sig, &sig, "init", &tree, &[]) + .expect("commit"); + + fs::write(root.join("new-file.txt"), "first line\nsecond line\n").expect("write new file"); + + let workspace = WorkspaceEntry { + id: "w1".to_string(), + name: "w1".to_string(), + path: root.to_string_lossy().to_string(), + kind: WorkspaceKind::Main, + parent_id: None, + worktree: None, + settings: WorkspaceSettings::default(), + }; + let mut entries = HashMap::new(); + entries.insert("w1".to_string(), workspace); + let workspaces = Mutex::new(entries); + let app_settings = Mutex::new(AppSettings::default()); + + let runtime = Runtime::new().expect("create tokio runtime"); + let diffs = runtime + .block_on(diff::get_git_diffs_inner( + &workspaces, + &app_settings, + "w1".to_string(), + )) + .expect("get git diffs"); + + let diff = diffs + .iter() + .find(|diff| diff.path == "new-file.txt") + .expect("find new file diff"); + let unstaged_diff = diff + .unstaged_diff + .as_deref() + .expect("untracked file should have unstaged diff"); + + assert!( + unstaged_diff.contains("+++ b/new-file.txt"), + "expected relative untracked diff header, got: {unstaged_diff}" + ); + assert!( + unstaged_diff.contains("+first line\n+second line"), + "expected untracked diff body, got: {unstaged_diff}" + ); + assert!( + !diff.display_hunks.is_empty(), + "untracked file should expose display hunks" + ); + assert_eq!(diff.display_hunks[0].source, "unstaged"); + assert_eq!(diff.display_hunks[0].action, "stage"); +} + +#[test] +fn apply_git_display_hunk_stages_untracked_file_hunks() { + let (root, repo) = create_temp_repo(); + let tracked_path = root.join("tracked.txt"); + fs::write(&tracked_path, "tracked\n").expect("write tracked file"); + let mut index = repo.index().expect("repo index"); + index.add_path(Path::new("tracked.txt")).expect("add tracked path"); + index.write().expect("write index"); + let tree_id = index.write_tree().expect("write tree"); + let tree = repo.find_tree(tree_id).expect("find tree"); + let sig = git2::Signature::now("Test", "test@example.com").expect("signature"); + repo.commit(Some("HEAD"), &sig, &sig, "init", &tree, &[]) + .expect("commit"); + + fs::write(root.join("new-file.txt"), "first line\nsecond line\n").expect("write new file"); + + let workspace = WorkspaceEntry { + id: "w1".to_string(), + name: "w1".to_string(), + path: root.to_string_lossy().to_string(), + kind: WorkspaceKind::Main, + parent_id: None, + worktree: None, + settings: WorkspaceSettings::default(), + }; + let mut entries = HashMap::new(); + entries.insert("w1".to_string(), workspace); + let workspaces = Mutex::new(entries); + let app_settings = Mutex::new(AppSettings::default()); + + let runtime = Runtime::new().expect("create tokio runtime"); + let diffs = runtime + .block_on(diff::get_git_diffs_inner( + &workspaces, + &app_settings, + "w1".to_string(), + )) + .expect("get git diffs"); + let display_hunk_id = diffs + .iter() + .find(|diff| diff.path == "new-file.txt") + .and_then(|diff| diff.display_hunks.first()) + .map(|hunk| hunk.id.clone()) + .expect("find untracked display hunk"); + + let result = runtime + .block_on(apply_git_display_hunk_core( + &workspaces, + &app_settings, + "w1".to_string(), + "new-file.txt".to_string(), + display_hunk_id, + )) + .expect("apply display hunk"); + + assert!(result.applied, "display hunk should be applied"); + + let cached = Command::new("git") + .args(["diff", "--cached", "--no-color", "-U0", "--", "new-file.txt"]) + .current_dir(&root) + .output() + .expect("run cached diff"); + assert!( + cached.status.success(), + "cached diff failed: {}", + String::from_utf8_lossy(&cached.stderr) + ); + let cached_diff = String::from_utf8_lossy(&cached.stdout); + assert!( + cached_diff.contains("+first line\n+second line"), + "expected untracked file additions to be staged, got: {cached_diff}" + ); +} + #[test] fn check_ignore_with_git_respects_negated_rule_for_specific_file() { let (root, repo) = create_temp_repo(); @@ -435,3 +573,106 @@ fn collect_ignored_paths_with_git_handles_large_ignored_output() { assert_eq!(ignored_paths.len(), total); } + +#[test] +fn apply_git_display_hunk_uses_ignore_whitespace_mode_for_hunk_ids() { + let (root, repo) = create_temp_repo(); + let file_path = root.join("example.txt"); + fs::write(&file_path, "alpha\nbeta\ncharlie\n").expect("write baseline"); + + let mut index = repo.index().expect("repo index"); + index.add_path(Path::new("example.txt")).expect("add path"); + index.write().expect("write index"); + let tree_id = index.write_tree().expect("write tree"); + let tree = repo.find_tree(tree_id).expect("find tree"); + let sig = git2::Signature::now("Test", "test@example.com").expect("signature"); + repo.commit(Some("HEAD"), &sig, &sig, "init", &tree, &[]) + .expect("commit"); + + fs::write(&file_path, "alpha \nbeta updated\ncharlie\n").expect("write changed file"); + + let workspace = WorkspaceEntry { + id: "w1".to_string(), + name: "w1".to_string(), + path: root.to_string_lossy().to_string(), + kind: WorkspaceKind::Main, + parent_id: None, + worktree: None, + settings: WorkspaceSettings::default(), + }; + let mut entries = HashMap::new(); + entries.insert("w1".to_string(), workspace); + let workspaces = Mutex::new(entries); + + let mut settings = AppSettings::default(); + settings.git_diff_ignore_whitespace_changes = true; + let app_settings = Mutex::new(settings); + + let source_patch = Command::new("git") + .args(["diff", "--no-color", "-U0", "-w", "--", "example.txt"]) + .current_dir(&root) + .output() + .expect("run whitespace-ignored diff"); + assert!( + source_patch.status.success(), + "source diff failed: {}", + String::from_utf8_lossy(&source_patch.stderr) + ); + let source_patch = String::from_utf8_lossy(&source_patch.stdout).to_string(); + let parsed = commands::parse_zero_context_patch(&source_patch).expect("parse source patch"); + let display_hunk_id = parsed + .hunks + .first() + .map(|hunk| commands::parsed_patch_hunk_id("unstaged", hunk)) + .expect("find whitespace-ignored hunk"); + + let runtime = Runtime::new().expect("create tokio runtime"); + let result = runtime + .block_on(apply_git_display_hunk_core( + &workspaces, + &app_settings, + "w1".to_string(), + "example.txt".to_string(), + display_hunk_id, + )) + .expect("apply display hunk"); + + assert!(result.applied, "display hunk should be applied"); + assert_eq!(result.applied_line_count, 2); + + let cached = Command::new("git") + .args(["diff", "--cached", "--no-color", "-U0", "--", "example.txt"]) + .current_dir(&root) + .output() + .expect("run cached diff"); + assert!( + cached.status.success(), + "cached diff failed: {}", + String::from_utf8_lossy(&cached.stderr) + ); + let cached_diff = String::from_utf8_lossy(&cached.stdout); + assert!( + cached_diff.contains("-beta\n+beta updated"), + "expected substantive line staged, got: {cached_diff}" + ); + + let unstaged = Command::new("git") + .args(["diff", "--no-color", "-U0", "--", "example.txt"]) + .current_dir(&root) + .output() + .expect("run unstaged diff"); + assert!( + unstaged.status.success(), + "unstaged diff failed: {}", + String::from_utf8_lossy(&unstaged.stderr) + ); + let unstaged_diff = String::from_utf8_lossy(&unstaged.stdout); + assert!( + unstaged_diff.contains("-alpha\n+alpha "), + "expected whitespace-only edit to remain unstaged, got: {unstaged_diff}" + ); + assert!( + !unstaged_diff.contains("beta updated"), + "substantive edit should no longer be unstaged: {unstaged_diff}" + ); +} diff --git a/src-tauri/src/types.rs b/src-tauri/src/types.rs index 6b595106b..4d7914ee2 100644 --- a/src-tauri/src/types.rs +++ b/src-tauri/src/types.rs @@ -8,10 +8,48 @@ pub(crate) struct GitFileStatus { pub(crate) deletions: i64, } +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub(crate) struct GitSelectionLine { + #[serde(rename = "type")] + pub(crate) line_type: String, + #[serde(default, rename = "oldLine")] + pub(crate) old_line: Option, + #[serde(default, rename = "newLine")] + pub(crate) new_line: Option, + pub(crate) text: String, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub(crate) struct GitSelectionApplyResult { + pub(crate) applied: bool, + pub(crate) applied_line_count: usize, + #[serde(default)] + pub(crate) warning: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub(crate) struct GitFileDisplayHunk { + pub(crate) id: String, + pub(crate) source: String, + pub(crate) action: String, + pub(crate) start_display_line_index: usize, + pub(crate) end_display_line_index: usize, + pub(crate) line_count: usize, +} + #[derive(Debug, Serialize, Deserialize, Clone)] pub(crate) struct GitFileDiff { pub(crate) path: String, pub(crate) diff: String, + #[serde(default, rename = "stagedDiff")] + pub(crate) staged_diff: Option, + #[serde(default, rename = "unstagedDiff")] + pub(crate) unstaged_diff: Option, + #[serde(default, rename = "displayHunks")] + pub(crate) display_hunks: Vec, #[serde(default, rename = "oldLines")] pub(crate) old_lines: Option>, #[serde(default, rename = "newLines")] diff --git a/src/features/app/hooks/useMainAppGitState.ts b/src/features/app/hooks/useMainAppGitState.ts index b0088dca9..4c48aa21a 100644 --- a/src/features/app/hooks/useMainAppGitState.ts +++ b/src/features/app/hooks/useMainAppGitState.ts @@ -330,6 +330,7 @@ export function useMainAppGitState({ const { applyWorktreeChanges: handleApplyWorktreeChanges, + applyGitDisplayHunk: handleApplyGitDisplayHunk, createGitHubRepo: handleCreateGitHubRepo, createGitHubRepoLoading, initGitRepo: handleInitGitRepo, @@ -338,6 +339,7 @@ export function useMainAppGitState({ revertGitFile: handleRevertGitFile, stageGitAll: handleStageGitAll, stageGitFile: handleStageGitFile, + stageGitSelection: handleStageGitSelection, unstageGitFile: handleUnstageGitFile, worktreeApplyError, worktreeApplyLoading, @@ -503,8 +505,10 @@ export function useMainAppGitState({ initGitRepoLoading, handleRevertAllGitChanges, handleRevertGitFile, + handleApplyGitDisplayHunk, handleStageGitAll, handleStageGitFile, + handleStageGitSelection, handleUnstageGitFile, worktreeApplyError, worktreeApplyLoading, diff --git a/src/features/app/hooks/useMainAppLayoutSurfaces.ts b/src/features/app/hooks/useMainAppLayoutSurfaces.ts index b6ac05279..bf7994009 100644 --- a/src/features/app/hooks/useMainAppLayoutSurfaces.ts +++ b/src/features/app/hooks/useMainAppLayoutSurfaces.ts @@ -840,6 +840,7 @@ function buildGitSurface({ scrollRequestId: gitState.diffScrollRequestId, isLoading: gitState.activeDiffLoading, error: gitState.activeDiffError, + diffSource: gitState.diffSource, ignoreWhitespaceChanges: appSettings.gitDiffIgnoreWhitespaceChanges && gitState.diffSource !== "pr", pullRequest: gitState.diffSource === "pr" ? gitState.selectedPullRequest : null, @@ -855,6 +856,9 @@ function buildGitSurface({ gitState.handleCheckoutPullRequest(pullRequest.number), canRevert: gitState.diffSource === "local", onRevertFile: gitState.handleRevertGitFile, + stagedPaths: gitState.gitStatus.stagedFiles.map((file) => file.path), + unstagedPaths: gitState.gitStatus.unstagedFiles.map((file) => file.path), + onApplyDisplayHunk: gitState.handleApplyGitDisplayHunk, onActivePathChange: gitState.handleActiveDiffPath, onInsertComposerText: composerWorkspaceState.canInsertComposerText ? composerWorkspaceState.handleInsertComposerText diff --git a/src/features/design-system/diff/diffViewerTheme.ts b/src/features/design-system/diff/diffViewerTheme.ts index a3cff626b..0dbd83d41 100644 --- a/src/features/design-system/diff/diffViewerTheme.ts +++ b/src/features/design-system/diff/diffViewerTheme.ts @@ -1,7 +1,6 @@ export const DIFF_VIEWER_SCROLL_CSS = ` [data-column-number], [data-buffer], -[data-separator-wrapper], [data-annotation-content] { position: static !important; } diff --git a/src/features/git/components/GitDiffViewer.test.tsx b/src/features/git/components/GitDiffViewer.test.tsx index 871548afc..d5c739dd1 100644 --- a/src/features/git/components/GitDiffViewer.test.tsx +++ b/src/features/git/components/GitDiffViewer.test.tsx @@ -1,7 +1,8 @@ /** @vitest-environment jsdom */ -import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import type { ReactNode } from "react"; import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import type { GitFileDisplayHunk } from "../../../types"; import { GitDiffViewer } from "./GitDiffViewer"; vi.mock("@tanstack/react-virtual", () => ({ @@ -72,6 +73,24 @@ afterEach(() => { cleanup(); }); +function displayHunk( + id: string, + source: "staged" | "unstaged", + action: "stage" | "unstage", + startDisplayLineIndex: number, + endDisplayLineIndex: number, + lineCount: number, +): GitFileDisplayHunk { + return { + id, + source, + action, + startDisplayLineIndex, + endDisplayLineIndex, + lineCount, + }; +} + describe("GitDiffViewer", () => { it("inserts a diff line reference into composer when the line '+' action is clicked", () => { const onInsertComposerText = vi.fn(); @@ -129,4 +148,492 @@ describe("GitDiffViewer", () => { expect(rawLines[1]?.className).toContain("diff-viewer-raw-line-add"); expect(rawLines[2]?.className).toContain("diff-viewer-raw-line-del"); }); + + it("applies a backend-authored unstaged display hunk in unified view", async () => { + const onApplyDisplayHunk = vi.fn(); + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: "Stage" })); + + await waitFor(() => { + expect(onApplyDisplayHunk).toHaveBeenCalledWith({ + path: "src/main.ts", + displayHunkId: "unstaged:1:0:2:1", + }); + }); + }); + + it("applies backend-authored display hunks in split view", async () => { + const onApplyDisplayHunk = vi.fn(); + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: "Stage" })); + + await waitFor(() => { + expect(onApplyDisplayHunk).toHaveBeenCalledWith({ + path: "src/main.ts", + displayHunkId: "unstaged:1:0:2:1", + }); + }); + }); + + it("renders split-view actions on the additions side for modified hunks", () => { + render( + , + ); + + const stageButtons = screen.getAllByRole("button", { name: "Stage" }); + expect(stageButtons).toHaveLength(1); + expect( + stageButtons[0]?.closest("[data-display-line-index]")?.getAttribute( + "data-display-line-index", + ), + ).toBe("2"); + expect(stageButtons[0]?.closest(".diff-line-action-group")?.className).toContain( + "diff-line-action-group--before-gutter", + ); + }); + + it("renders split-view actions on the deletions side for deletion-only hunks", () => { + render( + , + ); + + const stageButtons = screen.getAllByRole("button", { name: "Stage" }); + expect(stageButtons).toHaveLength(1); + expect( + stageButtons[0]?.closest("[data-display-line-index]")?.getAttribute( + "data-display-line-index", + ), + ).toBe("1"); + expect(stageButtons[0]?.closest(".diff-line-action-group")?.className).toContain( + "diff-line-action-group--after-gutter", + ); + }); + + it("activates addition-only split hunks when hovering the empty side", () => { + render( + , + ); + + const emptySplitLine = document.querySelector( + '.diff-line-empty[data-display-line-index="2"]', + ); + expect(emptySplitLine).toBeTruthy(); + + fireEvent.mouseEnter(emptySplitLine as Element); + + const actionLine = document.querySelector( + '.diff-line.has-line-action[data-display-line-index="2"]', + ); + expect(actionLine?.className).toContain("chunk-action-visible"); + }); + + it("renders both staged and unstaged actions for the mixed types.rs insertion scenario", () => { + render( + ,\n" + + " }\n" + + " \n" + + "+#[derive(Debug, Serialize, Deserialize, Clone)]\n" + + "+#[serde(rename_all = \"camelCase\")]\n" + + "+pub(crate) struct GitFileDisplayHunk {\n" + + "+ pub(crate) id: String,\n" + + "+ pub(crate) source: String,\n" + + "+ pub(crate) action: String,\n" + + "+ pub(crate) start_display_line_index: usize,\n" + + "+ pub(crate) end_display_line_index: usize,\n" + + "+ pub(crate) line_count: usize,\n" + + "+}\n" + + "+\n" + + " #[derive(Debug, Serialize, Deserialize, Clone)]\n" + + " pub(crate) struct GitFileDiff {\n" + + " pub(crate) path: String,\n" + + "@@ -37,6 +48,8 @@ pub(crate) struct GitFileDiff {\n" + + " pub(crate) staged_diff: Option,\n" + + " #[serde(default, rename = \"unstagedDiff\")]\n" + + " pub(crate) unstaged_diff: Option,\n" + + "+ #[serde(default, rename = \"displayHunks\")]\n" + + "+ pub(crate) display_hunks: Vec,\n" + + " #[serde(default, rename = \"oldLines\")]\n" + + " pub(crate) old_lines: Option>,\n" + + " #[serde(default, rename = \"newLines\")]\n", + stagedDiff: + "diff --git a/src-tauri/src/types.rs b/src-tauri/src/types.rs\n" + + "index dfcfa92..1277207 100644\n" + + "--- a/src-tauri/src/types.rs\n" + + "+++ b/src-tauri/src/types.rs\n" + + "@@ -31,0 +32,11 @@ pub(crate) struct GitSelectionApplyResult {\n" + + "+#[derive(Debug, Serialize, Deserialize, Clone)]\n" + + "+#[serde(rename_all = \"camelCase\")]\n" + + "+pub(crate) struct GitFileDisplayHunk {\n" + + "+ pub(crate) id: String,\n" + + "+ pub(crate) source: String,\n" + + "+ pub(crate) action: String,\n" + + "+ pub(crate) start_display_line_index: usize,\n" + + "+ pub(crate) end_display_line_index: usize,\n" + + "+ pub(crate) line_count: usize,\n" + + "+}\n" + + "+\n", + unstagedDiff: + "diff --git a/src-tauri/src/types.rs b/src-tauri/src/types.rs\n" + + "index 1277207..4d7914e 100644\n" + + "--- a/src-tauri/src/types.rs\n" + + "+++ b/src-tauri/src/types.rs\n" + + "@@ -50,0 +51,2 @@ pub(crate) struct GitFileDiff {\n" + + "+ #[serde(default, rename = \"displayHunks\")]\n" + + "+ pub(crate) display_hunks: Vec,\n", + displayHunks: [ + displayHunk("staged:31:0:32:11", "staged", "unstage", 4, 14, 11), + displayHunk("unstaged:50:0:51:2", "unstaged", "stage", 22, 23, 2), + ], + }, + ]} + selectedPath="src-tauri/src/types.rs" + isLoading={false} + error={null} + diffStyle="split" + diffSource="local" + stagedPaths={["src-tauri/src/types.rs"]} + unstagedPaths={["src-tauri/src/types.rs"]} + onApplyDisplayHunk={vi.fn()} + />, + ); + + expect(screen.getAllByRole("button", { name: "Unstage" })).toHaveLength(1); + expect(screen.getAllByRole("button", { name: "Stage" })).toHaveLength(1); + + const nonStartLine = document.querySelector('[data-display-line-index="10"]'); + const startLine = document.querySelector( + '.diff-line.has-line-action[data-display-line-index="4"]', + ); + expect(nonStartLine).toBeTruthy(); + expect(startLine).toBeTruthy(); + + fireEvent.mouseEnter(nonStartLine as Element); + expect(startLine?.className).toContain("chunk-action-visible"); + }); + + it("keeps mixed staged and unstaged hunks in one file-ordered view", () => { + render( + , + ); + + expect(screen.queryByText("Staged changes")).toBeNull(); + expect(screen.queryByText("Unstaged changes")).toBeNull(); + expect(screen.getAllByRole("button", { name: "Unstage" })).toHaveLength(1); + expect(screen.getAllByRole("button", { name: "Stage" })).toHaveLength(1); + expect(screen.getByText((_, node) => node?.textContent === "new staged line")).toBeTruthy(); + expect(screen.getByText((_, node) => node?.textContent === "new unstaged line")).toBeTruthy(); + }); + + it("targets the correct display hunk id for mixed staged and unstaged spans", async () => { + const onApplyDisplayHunk = vi.fn(); + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: "Unstage" })); + await waitFor(() => { + expect(onApplyDisplayHunk).toHaveBeenCalledWith({ + path: "src/main.ts", + displayHunkId: "staged:1:1:1:2", + }); + }); + + fireEvent.click(screen.getByRole("button", { name: "Stage" })); + await waitFor(() => { + expect(onApplyDisplayHunk).toHaveBeenLastCalledWith({ + path: "src/main.ts", + displayHunkId: "unstaged:2:1:3:2", + }); + }); + }); + + it("splits pure unstaged actions by backend display hunk boundaries", async () => { + const onApplyDisplayHunk = vi.fn(); + render( + , + ); + + const stageButtons = screen.getAllByRole("button", { name: "Stage" }); + expect(stageButtons).toHaveLength(2); + + fireEvent.click(stageButtons[0]); + await waitFor(() => { + expect(onApplyDisplayHunk).toHaveBeenCalledWith({ + path: "src/main.ts", + displayHunkId: "unstaged:1:0:2:2", + }); + }); + }); + + it("keeps repeated identical additions distinct via display hunk ids", async () => { + const onApplyDisplayHunk = vi.fn(); + render( + , + ); + + const stageButtons = screen.getAllByRole("button", { name: "Stage" }); + expect(stageButtons).toHaveLength(2); + + fireEvent.click(stageButtons[0]); + await waitFor(() => { + expect(onApplyDisplayHunk).toHaveBeenCalledWith({ + path: "src/main.ts", + displayHunkId: "unstaged:1:0:2:3", + }); + }); + + fireEvent.click(stageButtons[1]); + await waitFor(() => { + expect(onApplyDisplayHunk).toHaveBeenLastCalledWith({ + path: "src/main.ts", + displayHunkId: "unstaged:6:0:7:3", + }); + }); + }); + + it("renders overlapping staged and unstaged actions at the same visible span", async () => { + const onApplyDisplayHunk = vi.fn(); + render( + , + ); + + expect(screen.getAllByRole("button", { name: "Unstage" })).toHaveLength(1); + expect(screen.getAllByRole("button", { name: "Stage" })).toHaveLength(1); + + fireEvent.click(screen.getByRole("button", { name: "Unstage" })); + await waitFor(() => { + expect(onApplyDisplayHunk).toHaveBeenCalledWith({ + path: "src/main.ts", + displayHunkId: "staged:1:1:1:1", + }); + }); + + fireEvent.click(screen.getByRole("button", { name: "Stage" })); + await waitFor(() => { + expect(onApplyDisplayHunk).toHaveBeenLastCalledWith({ + path: "src/main.ts", + displayHunkId: "unstaged:1:1:1:1", + }); + }); + }); }); diff --git a/src/features/git/components/GitDiffViewer.tsx b/src/features/git/components/GitDiffViewer.tsx index ff152e58c..88d41a882 100644 --- a/src/features/git/components/GitDiffViewer.tsx +++ b/src/features/git/components/GitDiffViewer.tsx @@ -7,10 +7,7 @@ import GitCommitHorizontal from "lucide-react/dist/esm/icons/git-commit-horizont import RotateCcw from "lucide-react/dist/esm/icons/rotate-ccw"; import type { ParsedDiffLine } from "../../../utils/diff"; import { workerFactory } from "../../../utils/diffsWorker"; -import type { - PullRequestReviewIntent, - PullRequestSelectionRange, -} from "../../../types"; +import type { PullRequestReviewIntent, PullRequestSelectionRange } from "../../../types"; import { DIFF_VIEWER_HIGHLIGHTER_OPTIONS, } from "../../design-system/diff/diffViewerTheme"; @@ -21,6 +18,8 @@ import { PullRequestSummary } from "./GitDiffViewerPullRequestSummary"; import type { GitDiffViewerItem, GitDiffViewerProps, + LocalLineAction, + LocalLineActionContext, } from "./GitDiffViewer.types"; import { calculateDiffStats } from "./GitDiffViewer.utils"; @@ -30,6 +29,24 @@ function isSelectableLine( return line.type === "add" || line.type === "del" || line.type === "context"; } +function gitSelectionDebugEnabled() { + if (typeof window === "undefined") { + return false; + } + try { + return window.localStorage.getItem("codexMonitor.gitSelectionDebug") === "1"; + } catch { + return false; + } +} + +function gitSelectionDebugLog(event: string, payload: unknown) { + if (!gitSelectionDebugEnabled()) { + return; + } + console.debug("[git-selection]", event, payload); +} + function findSelectionLineIndex( parsedLines: ParsedDiffLine[], lineNumber: number, @@ -130,6 +147,7 @@ export function GitDiffViewer({ scrollRequestId, isLoading, error, + diffSource = "local", diffStyle = "split", ignoreWhitespaceChanges = false, pullRequest, @@ -143,6 +161,9 @@ export function GitDiffViewer({ onCheckoutPullRequest, canRevert = false, onRevertFile, + stagedPaths = [], + unstagedPaths = [], + onApplyDisplayHunk, onActivePathChange, onInsertComposerText, }: GitDiffViewerProps) { @@ -166,6 +187,7 @@ export function GitDiffViewer({ path: string; range: SelectedLineRange; } | null>(null); + const [lineActionBusy, setLineActionBusy] = useState(false); const clearSelection = useCallback(() => { setLineSelection(null); @@ -201,6 +223,8 @@ export function GitDiffViewer({ () => DIFF_VIEWER_HIGHLIGHTER_OPTIONS, [], ); + const stagedPathSet = useMemo(() => new Set(stagedPaths), [stagedPaths]); + const unstagedPathSet = useMemo(() => new Set(unstagedPaths), [unstagedPaths]); const indexByPath = useMemo(() => { const map = new Map(); @@ -271,6 +295,85 @@ export function GitDiffViewer({ const showRevert = canRevert && Boolean(onRevertFile); + const resolveLocalLineActionContext = useCallback( + (entry: GitDiffViewerItem): LocalLineActionContext | null => { + if (diffSource !== "local" || !onApplyDisplayHunk) { + return null; + } + const path = entry.path; + const hasStaged = stagedPathSet.has(path) || Boolean(entry.stagedDiff?.trim()); + const hasUnstaged = + unstagedPathSet.has(path) || Boolean(entry.unstagedDiff?.trim()); + if (!hasStaged && !hasUnstaged) { + return null; + } + const missingStagedDiff = hasStaged && !entry.stagedDiff?.trim(); + const missingUnstagedDiff = hasUnstaged && !entry.unstagedDiff?.trim(); + const displayHunks = entry.displayHunks ?? []; + if (gitSelectionDebugEnabled()) { + gitSelectionDebugLog("display-hunks-context", { + path, + status: entry.status, + hasStaged, + hasUnstaged, + missingStagedDiff, + missingUnstagedDiff, + displayHunkCount: displayHunks.length, + displayHunks: displayHunks.map((hunk) => ({ + id: hunk.id, + source: hunk.source, + action: hunk.action, + startDisplayLineIndex: hunk.startDisplayLineIndex, + endDisplayLineIndex: hunk.endDisplayLineIndex, + lineCount: hunk.lineCount, + })), + }); + } + if (entry.status === "R") { + return { + displayHunks, + disabledReason: + "Line-level stage/unstage is not supported for renamed files.", + }; + } + if (missingStagedDiff || missingUnstagedDiff || displayHunks.length === 0) { + return { + displayHunks, + disabledReason: + "Line-level stage/unstage is unavailable until display hunks finish loading.", + }; + } + return { + displayHunks, + }; + }, + [diffSource, onApplyDisplayHunk, stagedPathSet, unstagedPathSet], + ); + + const handleApplyLineAction = useCallback( + async (path: string, action: LocalLineAction) => { + if (!onApplyDisplayHunk || action.disabledReason || lineActionBusy) { + return; + } + setLineActionBusy(true); + try { + gitSelectionDebugLog("handle-apply-line-action", { + path, + displayHunkId: action.id, + op: action.action, + source: action.source, + }); + await onApplyDisplayHunk({ + path, + displayHunkId: action.id, + }); + } finally { + setLineActionBusy(false); + } + }, + [lineActionBusy, onApplyDisplayHunk], + ); + const handleInsertLineReference = useCallback( (entry: GitDiffViewerItem, line: ParsedDiffLine, index: number) => { if (!onInsertComposerText) { @@ -554,6 +657,10 @@ export function GitDiffViewer({ > {virtualItems.map((virtualRow) => { const entry = diffs[virtualRow.index]; + const localLineActionContext = + resolveLocalLineActionContext(entry); + const hasLocalLineActions = Boolean(localLineActionContext); + const hasComposerLineActions = Boolean(onInsertComposerText); return (
{ setSelectedLinesForPath(entry.path, range); }} - onLineAction={ - onInsertComposerText + localLineActionContext={localLineActionContext} + lineActionBusy={lineActionBusy} + onLocalChunkAction={ + hasLocalLineActions + ? (action) => { + void handleApplyLineAction(entry.path, action); + } + : undefined + } + onComposerLineAction={ + !hasLocalLineActions && hasComposerLineActions ? (line, index) => { handleInsertLineReference(entry, line, index); } diff --git a/src/features/git/components/GitDiffViewer.types.ts b/src/features/git/components/GitDiffViewer.types.ts index 199eaa458..fe920f281 100644 --- a/src/features/git/components/GitDiffViewer.types.ts +++ b/src/features/git/components/GitDiffViewer.types.ts @@ -1,16 +1,22 @@ import type { + GitFileDisplayHunk, + GitSelectionApplyResult, GitHubPullRequest, GitHubPullRequestComment, PullRequestReviewAction, PullRequestReviewIntent, PullRequestSelectionRange, } from "../../../types"; +import type { GitDiffSource } from "../types"; export type GitDiffViewerItem = { path: string; displayPath?: string; status: string; diff: string; + stagedDiff?: string | null; + unstagedDiff?: string | null; + displayHunks?: GitFileDisplayHunk[]; oldLines?: string[]; newLines?: string[]; isImage?: boolean; @@ -20,6 +26,17 @@ export type GitDiffViewerItem = { newImageMime?: string | null; }; +export type LocalLineAction = Pick & { + label: "Stage" | "Unstage"; + title: string; + disabledReason?: string; +}; + +export type LocalLineActionContext = { + displayHunks: GitFileDisplayHunk[]; + disabledReason?: string; +}; + export type DiffStats = { additions: number; deletions: number; @@ -31,6 +48,7 @@ export type GitDiffViewerProps = { scrollRequestId?: number; isLoading: boolean; error: string | null; + diffSource?: GitDiffSource; diffStyle?: "split" | "unified"; ignoreWhitespaceChanges?: boolean; pullRequest?: GitHubPullRequest | null; @@ -51,6 +69,12 @@ export type GitDiffViewerProps = { ) => Promise | void; canRevert?: boolean; onRevertFile?: (path: string) => Promise | void; + stagedPaths?: string[]; + unstagedPaths?: string[]; + onApplyDisplayHunk?: (options: { + path: string; + displayHunkId: string; + }) => Promise; onActivePathChange?: (path: string) => void; onInsertComposerText?: (text: string) => void; }; diff --git a/src/features/git/components/GitDiffViewerDiffCard.tsx b/src/features/git/components/GitDiffViewerDiffCard.tsx index 0ccabf349..eebf3b26b 100644 --- a/src/features/git/components/GitDiffViewerDiffCard.tsx +++ b/src/features/git/components/GitDiffViewerDiffCard.tsx @@ -13,16 +13,19 @@ import type { } from "../../../types"; import { parseDiff, type ParsedDiffLine } from "../../../utils/diff"; import { highlightLine, languageFromPath } from "../../../utils/syntax"; -import { - DIFF_VIEWER_SCROLL_CSS, -} from "../../design-system/diff/diffViewerTheme"; +import { DIFF_VIEWER_SCROLL_CSS } from "../../design-system/diff/diffViewerTheme"; import { splitPath } from "./GitDiffPanel.utils"; -import type { GitDiffViewerItem } from "./GitDiffViewer.types"; +import type { + GitDiffViewerItem, + LocalLineAction, + LocalLineActionContext, +} from "./GitDiffViewer.types"; import { isFallbackRawDiffLineHighlightable, normalizePatchName, parseRawDiffLines, } from "./GitDiffViewer.utils"; +import { LocalActionDiffBlock } from "./LocalActionDiffBlock"; type HoveredDiffLine = | { @@ -32,12 +35,52 @@ type HoveredDiffLine = } | undefined; +type FileDiffWithSourceLines = FileDiffMetadata & { + oldLines?: string[]; + newLines?: string[]; +}; + function isSelectableLine( line: ParsedDiffLine, ): line is ParsedDiffLine & { type: "add" | "del" | "context" } { return line.type === "add" || line.type === "del" || line.type === "context"; } +function parseDiffForViewer(diff: string) { + const parsed = parseDiff(diff); + if (parsed.length > 0) { + return parsed; + } + return parseRawDiffLines(diff); +} + +function resolveFileDiff( + diff: string, + displayPath: string, + oldLines?: string[], + newLines?: string[], +): FileDiffWithSourceLines | null { + if (!diff.trim()) { + return null; + } + const patch = parsePatchFiles(diff); + const parsed = patch[0]?.files[0]; + if (!parsed) { + return null; + } + const normalizedName = normalizePatchName(parsed.name || displayPath); + const normalizedPrevName = parsed.prevName + ? normalizePatchName(parsed.prevName) + : undefined; + return { + ...parsed, + name: normalizedName, + prevName: normalizedPrevName, + oldLines, + newLines, + } as FileDiffWithSourceLines; +} + function resolveParsedLineForHover( parsedLines: ParsedDiffLine[], hovered: HoveredDiffLine, @@ -86,7 +129,10 @@ export type DiffCardProps = { interactiveSelectionEnabled: boolean; selectedLines?: SelectedLineRange | null; onSelectedLinesChange?: (range: SelectedLineRange | null) => void; - onLineAction?: (line: ParsedDiffLine, index: number) => void; + localLineActionContext?: LocalLineActionContext | null; + lineActionBusy?: boolean; + onLocalChunkAction?: (action: LocalLineAction) => void; + onComposerLineAction?: (line: ParsedDiffLine, index: number) => void; reviewActions?: PullRequestReviewAction[]; onRunReviewAction?: ( intent: PullRequestReviewIntent, @@ -109,7 +155,10 @@ export const DiffCard = memo(function DiffCard({ interactiveSelectionEnabled, selectedLines = null, onSelectedLinesChange, - onLineAction, + localLineActionContext = null, + lineActionBusy = false, + onLocalChunkAction, + onComposerLineAction, reviewActions = [], onRunReviewAction, onClearSelection, @@ -127,27 +176,30 @@ export const DiffCard = memo(function DiffCard({ [displayPath], ); - const fileDiff = useMemo(() => { - if (!entry.diff.trim()) { - return null; - } - const patch = parsePatchFiles(entry.diff); - const parsed = patch[0]?.files[0]; - if (!parsed) { - return null; - } - const normalizedName = normalizePatchName(parsed.name || displayPath); - const normalizedPrevName = parsed.prevName - ? normalizePatchName(parsed.prevName) - : undefined; - return { - ...parsed, - name: normalizedName, - prevName: normalizedPrevName, - oldLines: entry.oldLines, - newLines: entry.newLines, - } satisfies FileDiffMetadata; - }, [displayPath, entry.diff, entry.newLines, entry.oldLines]); + const parsedLines = useMemo( + () => parseDiffForViewer(entry.diff), + [entry.diff], + ); + const hasSelectableLines = useMemo( + () => parsedLines.some(isSelectableLine), + [parsedLines], + ); + const useInteractiveDiff = interactiveSelectionEnabled && hasSelectableLines; + const showLocalLineActions = Boolean( + !useInteractiveDiff && localLineActionContext && onLocalChunkAction, + ); + const composerLineActionEnabled = Boolean( + !useInteractiveDiff && + !showLocalLineActions && + onComposerLineAction && + hasSelectableLines, + ); + + const fileDiff = useMemo( + () => + resolveFileDiff(entry.diff, displayPath, entry.oldLines, entry.newLines), + [displayPath, entry.diff, entry.newLines, entry.oldLines], + ); const placeholder = useMemo(() => { if (isLoading) { @@ -159,41 +211,6 @@ export const DiffCard = memo(function DiffCard({ return "Diff unavailable."; }, [entry.diff, ignoreWhitespaceChanges, isLoading]); - const parsedLines = useMemo(() => { - const parsed = parseDiff(entry.diff); - if (parsed.length > 0) { - return parsed; - } - return parseRawDiffLines(entry.diff); - }, [entry.diff]); - - const hasSelectableLines = useMemo( - () => parsedLines.some(isSelectableLine), - [parsedLines], - ); - const useInteractiveDiff = interactiveSelectionEnabled && hasSelectableLines; - const lineActionEnabled = - diffStyle === "unified" && Boolean(onLineAction) && hasSelectableLines; - - const diffOptions = useMemo( - () => ({ - diffStyle, - hunkSeparators: "line-info" as const, - overflow: "scroll" as const, - unsafeCSS: DIFF_VIEWER_SCROLL_CSS, - disableFileHeader: true, - enableLineSelection: useInteractiveDiff, - onLineSelected: useInteractiveDiff ? onSelectedLinesChange : undefined, - enableHoverUtility: lineActionEnabled, - }), - [ - diffStyle, - lineActionEnabled, - onSelectedLinesChange, - useInteractiveDiff, - ], - ); - return (
{useInteractiveDiff && selectedLines && reviewActions.length > 0 ? ( -
+
{reviewActions.map((action) => (
- ) : entry.diff.trim().length > 0 && parsedLines.length > 0 ? ( -
- {parsedLines.map((line, index) => { - const highlighted = highlightLine( - line.text, - isFallbackRawDiffLineHighlightable(line.type) - ? fallbackLanguage - : null, - ); + ) : entry.diff.trim().length > 0 && parsedLines.length > 0 ? ( +
+ {parsedLines.map((line, index) => { + const highlighted = highlightLine( + line.text, + isFallbackRawDiffLineHighlightable(line.type) + ? fallbackLanguage + : null, + ); - return ( -
- -
- ); - })} -
- ) : ( -
{placeholder}
- )} + return ( +
+ +
+ ); + })} +
+ ) : ( +
{placeholder}
+ )} + {showLocalLineActions && localLineActionContext?.disabledReason ? ( +
+ {localLineActionContext.disabledReason} +
+ ) : null} +
); }); diff --git a/src/features/git/components/LocalActionDiffBlock.tsx b/src/features/git/components/LocalActionDiffBlock.tsx new file mode 100644 index 000000000..8921db78b --- /dev/null +++ b/src/features/git/components/LocalActionDiffBlock.tsx @@ -0,0 +1,425 @@ +import { useMemo, useRef, useState, type MouseEvent } from "react"; +import type { GitFileDisplayHunk } from "../../../types"; +import type { ParsedDiffLine } from "../../../utils/diff"; +import { highlightLine } from "../../../utils/syntax"; +import type { LocalLineAction } from "./GitDiffViewer.types"; + +type SplitLineEntry = { + line: ParsedDiffLine; + index: number; +}; + +type SplitRow = + | { type: "meta"; line: ParsedDiffLine } + | { type: "content"; left: SplitLineEntry | null; right: SplitLineEntry | null }; + +type LineDisplayHunkMeta = { + activeHunkIds: string[]; + startHunkIds: string[]; + hasStaged: boolean; +}; + +type ResolvedDisplayHunkAction = GitFileDisplayHunk & LocalLineAction; + +type LocalActionDiffBlockProps = { + filePath: string; + parsedLines: ParsedDiffLine[]; + diffStyle: "split" | "unified"; + language?: string | null; + displayHunks: GitFileDisplayHunk[]; + disabledReason?: string; + lineActionBusy?: boolean; + onChunkAction?: (action: LocalLineAction) => void; +}; + +function gitSelectionDebugEnabled() { + if (typeof window === "undefined") { + return false; + } + try { + return window.localStorage.getItem("codexMonitor.gitSelectionDebug") === "1"; + } catch { + return false; + } +} + +function gitSelectionDebugLog(event: string, payload: unknown) { + if (!gitSelectionDebugEnabled()) { + return; + } + console.debug("[git-selection]", event, payload); +} + +function isHighlightableLine(line: ParsedDiffLine) { + return line.type === "add" || line.type === "del" || line.type === "context"; +} + +function buildSplitRows(parsed: ParsedDiffLine[]): SplitRow[] { + const rows: SplitRow[] = []; + let pendingDel: SplitLineEntry[] = []; + let pendingAdd: SplitLineEntry[] = []; + + const flushPending = () => { + if (pendingDel.length === 0 && pendingAdd.length === 0) { + return; + } + const maxLen = Math.max(pendingDel.length, pendingAdd.length); + for (let index = 0; index < maxLen; index += 1) { + rows.push({ + type: "content", + left: pendingDel[index] ?? null, + right: pendingAdd[index] ?? null, + }); + } + pendingDel = []; + pendingAdd = []; + }; + + parsed.forEach((line, index) => { + if (line.type === "del") { + pendingDel.push({ line, index }); + return; + } + if (line.type === "add") { + pendingAdd.push({ line, index }); + return; + } + flushPending(); + if (line.type === "context") { + rows.push({ + type: "content", + left: { line, index }, + right: { line, index }, + }); + return; + } + rows.push({ type: "meta", line }); + }); + flushPending(); + + return rows; +} + +function toLocalLineAction( + displayHunk: GitFileDisplayHunk, + disabledReason?: string, +): ResolvedDisplayHunkAction { + const action = displayHunk.action; + return { + ...displayHunk, + id: displayHunk.id, + source: displayHunk.source, + action, + label: action === "unstage" ? "Unstage" : "Stage", + title: action === "unstage" ? "Unstage this hunk" : "Stage this hunk", + disabledReason, + }; +} + +function buildDisplayHunkMeta(displayHunks: ResolvedDisplayHunkAction[]) { + const actionsById = new Map(); + const metaByIndex = new Map(); + + const ensureMeta = (index: number) => { + const existing = metaByIndex.get(index); + if (existing) { + return existing; + } + const next: LineDisplayHunkMeta = { + activeHunkIds: [], + startHunkIds: [], + hasStaged: false, + }; + metaByIndex.set(index, next); + return next; + }; + + displayHunks.forEach((displayHunk) => { + actionsById.set(displayHunk.id, displayHunk); + + const startMeta = ensureMeta(displayHunk.startDisplayLineIndex); + startMeta.startHunkIds.push(displayHunk.id); + + for ( + let index = displayHunk.startDisplayLineIndex; + index <= displayHunk.endDisplayLineIndex; + index += 1 + ) { + const meta = ensureMeta(index); + meta.activeHunkIds.push(displayHunk.id); + if (displayHunk.source === "staged") { + meta.hasStaged = true; + } + } + }); + + return { actionsById, metaByIndex }; +} + +export function LocalActionDiffBlock({ + filePath, + parsedLines, + diffStyle, + language, + displayHunks, + disabledReason, + lineActionBusy = false, + onChunkAction, +}: LocalActionDiffBlockProps) { + const [hoveredHunkIds, setHoveredHunkIds] = useState([]); + const hoveredHunkIdsRef = useRef([]); + const splitRows = useMemo( + () => (diffStyle === "split" ? buildSplitRows(parsedLines) : []), + [diffStyle, parsedLines], + ); + const highlightedHtmlByIndex = useMemo( + () => + parsedLines.map((line) => { + const shouldHighlight = isHighlightableLine(line); + return highlightLine(line.text, shouldHighlight ? language : null); + }), + [language, parsedLines], + ); + + const displayHunkActions = useMemo( + () => + displayHunks.map((displayHunk) => toLocalLineAction(displayHunk, disabledReason)), + [disabledReason, displayHunks], + ); + + const { actionsById, metaByIndex } = useMemo( + () => buildDisplayHunkMeta(displayHunkActions), + [displayHunkActions], + ); + + const updateHoveredHunkIds = (nextHunkIds: string[]) => { + if ( + hoveredHunkIdsRef.current.length === nextHunkIds.length && + hoveredHunkIdsRef.current.every((value, index) => value === nextHunkIds[index]) + ) { + return; + } + hoveredHunkIdsRef.current = nextHunkIds; + setHoveredHunkIds(nextHunkIds); + }; + + const handleLineMouseEnter = (index: number) => { + updateHoveredHunkIds(metaByIndex.get(index)?.activeHunkIds ?? []); + }; + + const handleLineMouseLeave = (event: MouseEvent) => { + const nextTarget = + event.relatedTarget instanceof Element ? event.relatedTarget : null; + const nextLineIndex = nextTarget?.closest("[data-display-line-index]") + ?.dataset.displayLineIndex; + if (typeof nextLineIndex === "string") { + const parsedIndex = Number(nextLineIndex); + if (!Number.isNaN(parsedIndex)) { + updateHoveredHunkIds(metaByIndex.get(parsedIndex)?.activeHunkIds ?? []); + return; + } + } + updateHoveredHunkIds([]); + }; + + const renderActionButtons = ( + actions: LocalLineAction[], + side?: "left" | "right", + ) => { + if (!actions.length) { + return null; + } + + return ( +
+ {actions.map((action) => { + const actionHardDisabled = Boolean(action.disabledReason); + const actionBlocked = lineActionBusy || actionHardDisabled; + + return ( + + ); + })} +
+ ); + }; + + const renderLine = ( + line: ParsedDiffLine, + index: number, + side?: "left" | "right", + actionOverride?: LocalLineAction[], + ) => { + const html = highlightedHtmlByIndex[index] ?? ""; + const meta = metaByIndex.get(index); + const startActions = actionOverride ?? + (meta?.startHunkIds + .map((id) => actionsById.get(id)) + .filter((value): value is LocalLineAction => Boolean(value)) ?? []); + const isLineActive = Boolean( + meta?.activeHunkIds.some((id) => hoveredHunkIds.includes(id)), + ); + const lineClassName = `diff-line diff-line-${line.type}${ + startActions.length > 0 ? " has-line-action" : "" + }${isLineActive ? " chunk-action-visible" : ""}${ + meta?.hasStaged ? " diff-line-staged" : "" + }`; + + return ( +
{ + handleLineMouseEnter(index); + }} + onMouseLeave={handleLineMouseLeave} + > +
+ + {side === "right" ? "" : (line.oldLine ?? "")} + + + {side === "left" ? "" : (line.newLine ?? "")} + +
+ + {renderActionButtons(startActions, side)} +
+ ); + }; + + const renderEmptySplitLine = (hoverIndex?: number) => ( +
{ + handleLineMouseEnter(hoverIndex); + } + : undefined + } + onMouseLeave={typeof hoverIndex === "number" ? handleLineMouseLeave : undefined} + > +
+ + +
+ +
+ ); + + if (diffStyle === "split") { + return ( +
{ + updateHoveredHunkIds([]); + }} + > + {splitRows.map((row, rowIndex) => { + if (row.type === "meta") { + const metaClass = + row.line.type === "hunk" ? "diff-line-hunk" : "diff-line-meta"; + return ( +
+ {row.line.text} +
+ ); + } + const leftMeta = row.left ? metaByIndex.get(row.left.index) : undefined; + const rightMeta = row.right ? metaByIndex.get(row.right.index) : undefined; + const rowStartActions = Array.from( + new Set([ + ...(leftMeta?.startHunkIds ?? []), + ...(rightMeta?.startHunkIds ?? []), + ]), + ) + .map((id) => actionsById.get(id)) + .filter((value): value is LocalLineAction => Boolean(value)); + const preferRightActions = Boolean(row.right && row.right.line.type === "add"); + const leftActions = + rowStartActions.length > 0 + ? preferRightActions + ? [] + : row.left + ? rowStartActions + : [] + : undefined; + const rightActions = + rowStartActions.length > 0 + ? preferRightActions || !row.left + ? rowStartActions + : [] + : undefined; + return ( +
+ {row.left ? ( + renderLine(row.left.line, row.left.index, "left", leftActions) + ) : ( + renderEmptySplitLine(row.right?.index) + )} + {row.right ? ( + renderLine(row.right.line, row.right.index, "right", rightActions) + ) : ( + renderEmptySplitLine(row.left?.index) + )} +
+ ); + })} +
+ ); + } + + return ( +
{ + updateHoveredHunkIds([]); + }} + > + {parsedLines.map((line, index) => ( +
{renderLine(line, index)}
+ ))} +
+ ); +} diff --git a/src/features/git/components/PierreDiffBlock.tsx b/src/features/git/components/PierreDiffBlock.tsx index 3a2bb5345..ad349d295 100644 --- a/src/features/git/components/PierreDiffBlock.tsx +++ b/src/features/git/components/PierreDiffBlock.tsx @@ -14,6 +14,11 @@ import { parseRawDiffLines, } from "./GitDiffViewer.utils"; +type FileDiffWithSourceLines = FileDiffMetadata & { + oldLines?: string[]; + newLines?: string[]; +}; + type PierreDiffBlockProps = { diff: string; displayPath: string; @@ -55,7 +60,7 @@ export function PierreDiffBlock({ prevName: normalizedPrevName, oldLines, newLines, - } satisfies FileDiffMetadata; + } as FileDiffWithSourceLines; }, [diff, displayPath, oldLines, newLines]); const parsedLines = useMemo(() => { diff --git a/src/features/git/hooks/useGitActions.ts b/src/features/git/hooks/useGitActions.ts index 63863dcd7..a3db3da89 100644 --- a/src/features/git/hooks/useGitActions.ts +++ b/src/features/git/hooks/useGitActions.ts @@ -1,6 +1,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { ask } from "@tauri-apps/plugin-dialog"; import { + applyGitDisplayHunk as applyGitDisplayHunkService, applyWorktreeChanges as applyWorktreeChangesService, createGitHubRepo as createGitHubRepoService, initGitRepo as initGitRepoService, @@ -8,9 +9,14 @@ import { revertGitFile as revertGitFileService, stageGitAll as stageGitAllService, stageGitFile as stageGitFileService, + stageGitSelection as stageGitSelectionService, unstageGitFile as unstageGitFileService, } from "../../../services/tauri"; -import type { WorkspaceInfo } from "../../../types"; +import type { + GitSelectionApplyResult, + GitSelectionLine, + WorkspaceInfo, +} from "../../../types"; type UseGitActionsOptions = { activeWorkspace: WorkspaceInfo | null; @@ -55,9 +61,11 @@ export function useGitActions({ } }, [workspaceId]); - const refreshGitData = useCallback(() => { - onRefreshGitStatus(); - onRefreshGitDiffs(); + const refreshGitData = useCallback(async () => { + await Promise.allSettled([ + Promise.resolve(onRefreshGitStatus()), + Promise.resolve(onRefreshGitDiffs()), + ]); }, [onRefreshGitDiffs, onRefreshGitStatus]); const stageGitFile = useCallback( @@ -72,7 +80,7 @@ export function useGitActions({ onError?.(error); } finally { if (workspaceIdRef.current === actionWorkspaceId) { - refreshGitData(); + await refreshGitData(); } } }, @@ -90,11 +98,69 @@ export function useGitActions({ onError?.(error); } finally { if (workspaceIdRef.current === actionWorkspaceId) { - refreshGitData(); + await refreshGitData(); } } }, [onError, refreshGitData, workspaceId]); + const stageGitSelection = useCallback( + async (options: { + path: string; + op: "stage" | "unstage"; + source: "unstaged" | "staged"; + lines: GitSelectionLine[]; + }): Promise => { + if (!workspaceId) { + return null; + } + const actionWorkspaceId = workspaceId; + try { + return await stageGitSelectionService( + actionWorkspaceId, + options.path, + options.op, + options.source, + options.lines, + ); + } catch (error) { + onError?.(error); + return null; + } finally { + if (workspaceIdRef.current === actionWorkspaceId) { + await refreshGitData(); + } + } + }, + [onError, refreshGitData, workspaceId], + ); + + const applyGitDisplayHunk = useCallback( + async (options: { + path: string; + displayHunkId: string; + }): Promise => { + if (!workspaceId) { + return null; + } + const actionWorkspaceId = workspaceId; + try { + return await applyGitDisplayHunkService( + actionWorkspaceId, + options.path, + options.displayHunkId, + ); + } catch (error) { + onError?.(error); + return null; + } finally { + if (workspaceIdRef.current === actionWorkspaceId) { + await refreshGitData(); + } + } + }, + [onError, refreshGitData, workspaceId], + ); + const unstageGitFile = useCallback( async (path: string) => { if (!workspaceId) { @@ -107,7 +173,7 @@ export function useGitActions({ onError?.(error); } finally { if (workspaceIdRef.current === actionWorkspaceId) { - refreshGitData(); + await refreshGitData(); } } }, @@ -126,7 +192,7 @@ export function useGitActions({ onError?.(error); } finally { if (workspaceIdRef.current === actionWorkspaceId) { - refreshGitData(); + await refreshGitData(); } } }, @@ -146,7 +212,7 @@ export function useGitActions({ } try { await revertGitAll(workspaceId); - refreshGitData(); + await refreshGitData(); } catch (error) { onError?.(error); } @@ -325,8 +391,10 @@ export function useGitActions({ initGitRepoLoading, revertAllGitChanges, revertGitFile, + applyGitDisplayHunk, stageGitAll, stageGitFile, + stageGitSelection, unstageGitFile, worktreeApplyError, worktreeApplyLoading, diff --git a/src/features/git/hooks/useGitDiffs.ts b/src/features/git/hooks/useGitDiffs.ts index ff5b0a073..22f73fb39 100644 --- a/src/features/git/hooks/useGitDiffs.ts +++ b/src/features/git/hooks/useGitDiffs.ts @@ -111,6 +111,9 @@ export function useGitDiffs( path: file.path, status: file.status, diff: entry?.diff ?? "", + stagedDiff: entry?.stagedDiff ?? null, + unstagedDiff: entry?.unstagedDiff ?? null, + displayHunks: entry?.displayHunks ?? [], oldLines: entry?.oldLines, newLines: entry?.newLines, isImage: entry?.isImage, diff --git a/src/services/tauri.test.ts b/src/services/tauri.test.ts index eb411228a..d9d97130e 100644 --- a/src/services/tauri.test.ts +++ b/src/services/tauri.test.ts @@ -3,6 +3,7 @@ import { invoke } from "@tauri-apps/api/core"; import { open, save } from "@tauri-apps/plugin-dialog"; import * as notification from "@tauri-apps/plugin-notification"; import { + applyGitDisplayHunk, exportMarkdownFile, addWorkspace, compactThread, @@ -25,6 +26,7 @@ import { openWorkspaceIn, readAgentMd, stageGitAll, + stageGitSelection, respondToServerRequest, respondToUserInputRequest, sendUserMessage, @@ -199,6 +201,56 @@ describe("tauri invoke wrappers", () => { }); }); + it("maps args for stage_git_selection", async () => { + const invokeMock = vi.mocked(invoke); + invokeMock.mockResolvedValueOnce({ + applied: true, + appliedLineCount: 1, + warning: null, + }); + + await stageGitSelection("ws-1", "src/main.ts", "stage", "unstaged", [ + { + type: "add", + oldLine: null, + newLine: 7, + text: "const x = 1;", + }, + ]); + + expect(invokeMock).toHaveBeenCalledWith("stage_git_selection", { + workspaceId: "ws-1", + path: "src/main.ts", + op: "stage", + source: "unstaged", + lines: [ + { + type: "add", + oldLine: null, + newLine: 7, + text: "const x = 1;", + }, + ], + }); + }); + + it("maps args for apply_git_display_hunk", async () => { + const invokeMock = vi.mocked(invoke); + invokeMock.mockResolvedValueOnce({ + applied: true, + appliedLineCount: 2, + warning: null, + }); + + await applyGitDisplayHunk("ws-1", "src/main.ts", "unstaged:1:0:2:1"); + + expect(invokeMock).toHaveBeenCalledWith("apply_git_display_hunk", { + workspaceId: "ws-1", + path: "src/main.ts", + displayHunkId: "unstaged:1:0:2:1", + }); + }); + it("maps args for createGitHubRepo", async () => { const invokeMock = vi.mocked(invoke); invokeMock.mockResolvedValueOnce({ status: "ok", repo: "me/repo" }); diff --git a/src/services/tauri.ts b/src/services/tauri.ts index 029e0d31b..25c2732d8 100644 --- a/src/services/tauri.ts +++ b/src/services/tauri.ts @@ -26,6 +26,8 @@ import type { GitHubPullRequestDiff, GitHubPullRequestsResponse, GitLogResponse, + GitSelectionApplyResult, + GitSelectionLine, ReviewTarget, } from "../types"; @@ -642,6 +644,24 @@ export async function stageGitAll(workspaceId: string): Promise { return invoke("stage_git_all", { workspaceId }); } +export async function stageGitSelection( + workspaceId: string, + path: string, + op: "stage" | "unstage", + source: "unstaged" | "staged", + lines: GitSelectionLine[], +): Promise { + return invoke("stage_git_selection", { workspaceId, path, op, source, lines }); +} + +export async function applyGitDisplayHunk( + workspaceId: string, + path: string, + displayHunkId: string, +): Promise { + return invoke("apply_git_display_hunk", { workspaceId, path, displayHunkId }); +} + export async function unstageGitFile(workspaceId: string, path: string) { return invoke("unstage_git_file", { workspaceId, path }); } diff --git a/src/styles/diff-viewer.css b/src/styles/diff-viewer.css index 1be9e5bda..795b837e8 100644 --- a/src/styles/diff-viewer.css +++ b/src/styles/diff-viewer.css @@ -509,6 +509,25 @@ background: transparent; } +.diff-split-block { + display: flex; + flex-direction: column; +} + +.diff-split-row { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 8px; +} + +.diff-split-meta { + padding: 3px 10px; + color: var(--text-subtle); + font-size: var(--code-font-size, 11px); + font-family: var(--code-font-family); + white-space: pre-wrap; +} + .diff-line { display: grid; grid-template-columns: 64px 1fr; @@ -517,11 +536,15 @@ padding: 2px 6px 2px 4px; border-radius: 0; white-space: pre-wrap; + position: relative; +} + +.diff-line-empty { + opacity: 0.45; } .diff-line.has-line-action { position: relative; - padding-left: 30px; } .diff-line[data-has-gutter="false"] { @@ -584,21 +607,45 @@ font-variant-numeric: tabular-nums; } +.diff-gutter-single { + grid-template-columns: 1fr; +} + .diff-line-number { min-width: 0; } .diff-line-content { + display: block; + white-space: pre-wrap; min-width: 0; overflow-wrap: anywhere; word-break: break-word; } -.diff-line-action { +.diff-line-action-group { position: absolute; top: 50%; - left: 6px; - transform: translateY(-50%); + left: 7px; + transform: translate3d(0, -50%, 0); + display: inline-flex; + gap: 6px; +} + +.diff-line-action-group--before-gutter { + left: 7px; +} + +.diff-line-action-group--after-gutter { + left: auto; + right: 7px; +} + +.diff-line-action { + position: relative; + top: auto; + left: auto; + transform: none; width: 20px; height: 20px; border-radius: 999px; @@ -615,23 +662,40 @@ line-height: 1; opacity: 0; pointer-events: none; + will-change: opacity; + backface-visibility: hidden; + contain: paint; transition: - opacity 140ms ease, background 140ms ease, border-color 140ms ease, color 140ms ease; } +.diff-line-action--before-gutter { + left: auto; +} + +.diff-line-action--after-gutter { + left: auto; +} + +.diff-line-action--unstage { + color: #f5c363; + border-color: rgba(245, 195, 99, 0.48); + background: color-mix(in srgb, #f5c363 10%, var(--surface-control)); +} + .diff-line.has-line-action:hover .diff-line-action, .diff-line.has-line-action:focus-visible .diff-line-action, -.diff-line.has-line-action:focus-within .diff-line-action { +.diff-line.has-line-action:focus-within .diff-line-action, +.diff-line.has-line-action.chunk-action-visible .diff-line-action { opacity: 1; pointer-events: auto; } .diff-line-action:hover:not(:disabled), .diff-line-action:active:not(:disabled) { - transform: translateY(-50%); + transform: none; box-shadow: none; } @@ -672,7 +736,7 @@ } .diff-line.has-line-action[data-has-gutter="false"] { - padding-left: 30px; + padding-left: 10px; } .diff-line[data-has-gutter="false"] .diff-line-content { @@ -718,6 +782,28 @@ background: rgba(248, 81, 73, 0.25); } +.diff-line.diff-line-staged { + position: relative; +} + +.diff-line.diff-line-staged::after { + content: ""; + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 3px; + pointer-events: none; +} + +.diff-line.diff-line-staged.diff-line-add::after { + background: #3be98d; +} + +.diff-line.diff-line-staged.diff-line-del::after { + background: #ff6565; +} + .diff-line-meta { color: var(--text-faint); } @@ -773,6 +859,13 @@ color: #7fd1ff; } +.diff-viewer-line-action-hint { + margin-top: 6px; + padding: 0 8px; + font-size: 11px; + color: var(--text-faint); +} + .diff-viewer-placeholder, .diff-viewer-empty { color: var(--text-subtle); diff --git a/src/types.ts b/src/types.ts index 51b1515c9..ab1ec1587 100644 --- a/src/types.ts +++ b/src/types.ts @@ -432,9 +432,34 @@ export type GitFileStatus = { deletions: number; }; +export type GitSelectionLine = { + type: "add" | "del"; + oldLine: number | null; + newLine: number | null; + text: string; +}; + +export type GitSelectionApplyResult = { + applied: boolean; + appliedLineCount: number; + warning?: string | null; +}; + +export type GitFileDisplayHunk = { + id: string; + source: "staged" | "unstaged"; + action: "stage" | "unstage"; + startDisplayLineIndex: number; + endDisplayLineIndex: number; + lineCount: number; +}; + export type GitFileDiff = { path: string; diff: string; + stagedDiff?: string | null; + unstagedDiff?: string | null; + displayHunks?: GitFileDisplayHunk[]; oldLines?: string[]; newLines?: string[]; isBinary?: boolean;