@heyhuynhgiabuu/pi-diff 0.7.6 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -20,15 +20,14 @@
20
20
  * • Large-diff fallback (skip highlighting, still show diff)
21
21
  * • Async rendering with invalidate() for non-blocking preview
22
22
  */
23
- import { existsSync, readFileSync, writeFileSync } from "node:fs";
23
+ import { constants, existsSync, readFileSync } from "node:fs";
24
+ import { access as accessFile, readFile, writeFile } from "node:fs/promises";
24
25
  import { extname, relative } from "node:path";
25
26
  import { codeToANSI } from "@shikijs/cli";
26
27
  import * as Diff from "diff";
27
28
  import { executeApplyPatch, formatApplyPatchResult } from "./core/apply-patch.js";
28
29
  import { configIndicatorStyle, loadPiDiffConfig } from "./core/config.js";
29
30
  import { computeHunkBlocks, getSepStyle, parseDiff, parsePatchFiles, resolveSepStyle, sepLabelSplit, sepLabelUnified, } from "./core/diff.js";
30
- import { replace } from "./core/replace.js";
31
- import { registerEditGuard } from "./edit-guard.js";
32
31
  import { applyDiffPalette as applySharedDiffPalette, lang as detectDiffLanguage, renderSplit as renderSharedSplit, resolveDiffColors as resolveSharedDiffColors, themeCacheKey as sharedThemeCacheKey, } from "./review/hunk-preview.js";
33
32
  const ARROW_PREFIXED_TOOL_HEADERS = new Set(["write", "create", "edit", "apply_patch"]);
34
33
  function formatToolHeaderName(name) {
@@ -1659,16 +1658,42 @@ export default async function diffRendererExtension(pi) {
1659
1658
  const newText = typeof input?.newText === "string" ? input.newText : typeof input?.new_text === "string" ? input.new_text : "";
1660
1659
  return oldText && oldText !== newText ? [{ oldText, newText }] : [];
1661
1660
  }
1662
- function summarizeEditOperations(operations) {
1663
- const diffs = operations.map((edit) => parseDiff(edit.oldText, edit.newText));
1664
- const totalAdded = diffs.reduce((sum, diff) => sum + diff.added, 0);
1665
- const totalRemoved = diffs.reduce((sum, diff) => sum + diff.removed, 0);
1666
- return {
1667
- diffs,
1668
- totalAdded,
1669
- totalRemoved,
1670
- summary: summarize(totalAdded, totalRemoved),
1671
- };
1661
+ function normalizeEditMatchText(text) {
1662
+ return text
1663
+ .replace(/\r\n/g, "\n")
1664
+ .replace(/\r/g, "\n")
1665
+ .normalize("NFKC")
1666
+ .split("\n")
1667
+ .map((line) => line.trimEnd())
1668
+ .join("\n")
1669
+ .replace(/[\u2018\u2019\u201A\u201B]/g, "'")
1670
+ .replace(/[\u201C\u201D\u201E\u201F]/g, '"')
1671
+ .replace(/[\u2010\u2011\u2012\u2013\u2014\u2015\u2212]/g, "-")
1672
+ .replace(/[\u00A0\u2002-\u200A\u202F\u205F\u3000]/g, " ");
1673
+ }
1674
+ function rejectOverlappingEditMatches(content, filePath, operations) {
1675
+ if (!filePath || operations.length === 0)
1676
+ return;
1677
+ if (content.startsWith("\uFEFF"))
1678
+ content = content.slice(1);
1679
+ const normalizedContent = normalizeEditMatchText(content);
1680
+ for (const [index, operation] of operations.entries()) {
1681
+ const oldText = normalizeEditMatchText(operation.oldText);
1682
+ if (!oldText)
1683
+ continue;
1684
+ let occurrences = 0;
1685
+ let offset = 0;
1686
+ while (true) {
1687
+ const match = normalizedContent.indexOf(oldText, offset);
1688
+ if (match === -1)
1689
+ break;
1690
+ occurrences++;
1691
+ if (occurrences > 1) {
1692
+ throw new Error(`Found ambiguous overlapping occurrences for edits[${index}] in ${filePath}. Provide more context to make oldText unique.`);
1693
+ }
1694
+ offset = match + 1;
1695
+ }
1696
+ }
1672
1697
  }
1673
1698
  registerToolIfEnabled("edit", {
1674
1699
  ...origEdit,
@@ -1692,175 +1717,37 @@ export default async function diffRendererExtension(pi) {
1692
1717
  async execute(tid, params, sig, upd, ctx) {
1693
1718
  const fp = params.path ?? params.file_path ?? "";
1694
1719
  const operations = getEditOperations(params);
1695
- // Try cascading replace() first — smarter matching than SDK's exact-only edit
1696
- if (fp && operations.length > 0 && existsSync(fp)) {
1697
- try {
1698
- let content = readFileSync(fp, "utf-8");
1699
- let firstStrategy = "";
1700
- let replaceOk = true;
1701
- for (const op of operations) {
1702
- const r = replace(content, op.oldText, op.newText);
1703
- if (r.changed) {
1704
- content = r.content;
1705
- if (!firstStrategy)
1706
- firstStrategy = r.strategy;
1707
- }
1708
- else {
1709
- replaceOk = false;
1710
- break;
1711
- }
1712
- }
1713
- if (replaceOk) {
1714
- writeFileSync(fp, content, "utf-8");
1715
- const { diffs, summary } = summarizeEditOperations(operations);
1716
- const lg = detectDiffLanguage(fp);
1717
- if (operations.length === 1) {
1718
- let editLine = 0;
1719
- try {
1720
- const idx = content.indexOf(operations[0].newText);
1721
- if (idx >= 0)
1722
- editLine = content.slice(0, idx).split("\n").length;
1723
- }
1724
- catch {
1725
- editLine = 0;
1726
- }
1727
- const useFull = !!params._expandGaps;
1728
- const diffData = useFull ? parseDiff(operations[0].oldText, operations[0].newText, undefined) : diffs[0];
1729
- return {
1730
- content: [{ type: "text", text: `Edited ${sp(fp)}` }],
1731
- details: {
1732
- _type: "editInfo",
1733
- summary: editLine > 0 ? `${summary} at line ${editLine}` : summary,
1734
- filePath: fp,
1735
- editLine,
1736
- diff: diffData,
1737
- language: lg,
1738
- oldContent: operations[0].oldText,
1739
- newContent: operations[0].newText,
1740
- _replaceStrategy: firstStrategy,
1741
- },
1742
- };
1743
- }
1744
- // Compute the line of the first edit for the title summary
1745
- const firstEditLine = (() => {
1746
- if (!operations[0]?.newText)
1747
- return 0;
1748
- const idx = content.indexOf(operations[0].newText);
1749
- if (idx < 0)
1750
- return 0;
1751
- return content.slice(0, idx).split("\n").length;
1752
- })();
1753
- // Merge all diffs into one combined view for rendering
1754
- const merged = {
1755
- lines: diffs.flatMap((diff, i) => [
1756
- ...(i > 0
1757
- ? [
1758
- {
1759
- type: "sep",
1760
- oldNum: null,
1761
- newNum: null,
1762
- content: `───── Edit ${i + 1} ─────`,
1763
- },
1764
- ]
1765
- : []),
1766
- ...diff.lines,
1767
- ]),
1768
- added: diffs.reduce((sum, diff) => sum + diff.added, 0),
1769
- removed: diffs.reduce((sum, diff) => sum + diff.removed, 0),
1770
- chars: diffs.reduce((sum, diff) => sum + diff.chars, 0),
1771
- };
1772
- return {
1773
- content: [{ type: "text", text: `Edited ${sp(fp)}` }],
1774
- details: {
1775
- _type: "multiEditInfo",
1776
- summary: firstEditLine > 0 ? `${summary} at line ${firstEditLine}` : summary,
1777
- filePath: fp,
1778
- editCount: operations.length,
1779
- diffLineCount: merged.lines.length,
1780
- diff: merged,
1781
- language: lg,
1782
- },
1783
- };
1784
- }
1785
- }
1786
- catch (replaceError) {
1787
- // replace() failed; fall through to SDK edit tool
1788
- console.warn(`[pi-diff] replace() failed, falling back to SDK: ${replaceError instanceof Error ? replaceError.message : String(replaceError)}`);
1789
- }
1790
- }
1791
- const result = await origEdit.execute(tid, params, sig, upd, ctx);
1792
- if (operations.length === 0)
1793
- return result;
1794
- const { diffs, summary } = summarizeEditOperations(operations);
1795
- const lg = detectDiffLanguage(fp);
1796
- if (operations.length === 1) {
1797
- let editLine = 0;
1798
- try {
1799
- if (fp && existsSync(fp)) {
1800
- const f = readFileSync(fp, "utf-8");
1801
- const idx = f.indexOf(operations[0].newText);
1802
- if (idx >= 0)
1803
- editLine = f.slice(0, idx).split("\n").length;
1804
- }
1805
- }
1806
- catch {
1807
- editLine = 0;
1808
- }
1809
- const useFull = !!params._expandGaps;
1810
- const diffData = useFull ? parseDiff(operations[0].oldText, operations[0].newText, undefined) : diffs[0];
1811
- result.details = {
1812
- _type: "editInfo",
1813
- summary: editLine > 0 ? `${summary} at line ${editLine}` : summary,
1814
- filePath: fp,
1815
- editLine,
1816
- diff: diffData,
1817
- language: lg,
1818
- oldContent: operations[0].oldText,
1819
- newContent: operations[0].newText,
1820
- };
1720
+ const guardedEdit = createEditTool(cwd, {
1721
+ operations: {
1722
+ access: (filePath) => accessFile(filePath, constants.R_OK | constants.W_OK),
1723
+ readFile: async (filePath) => {
1724
+ const content = await readFile(filePath);
1725
+ rejectOverlappingEditMatches(content.toString("utf8"), fp, operations);
1726
+ return content;
1727
+ },
1728
+ writeFile: (filePath, content) => writeFile(filePath, content, "utf8"),
1729
+ },
1730
+ });
1731
+ const result = await guardedEdit.execute(tid, params, sig, upd, ctx);
1732
+ const patch = result?.details?.patch;
1733
+ const diff = typeof patch === "string" ? parsePatchFiles(patch)[0] : undefined;
1734
+ if (!diff || (diff.added === 0 && diff.removed === 0))
1821
1735
  return result;
1822
- }
1823
- // Merge all diffs into one combined view for rendering
1824
- const merged = {
1825
- lines: diffs.flatMap((diff, i) => [
1826
- // Add separator between multiple edits
1827
- ...(i > 0
1828
- ? [
1829
- {
1830
- type: "sep",
1831
- oldNum: null,
1832
- newNum: null,
1833
- content: `───── Edit ${i + 1} ─────`,
1834
- },
1835
- ]
1836
- : []),
1837
- ...diff.lines,
1838
- ]),
1839
- added: diffs.reduce((sum, diff) => sum + diff.added, 0),
1840
- removed: diffs.reduce((sum, diff) => sum + diff.removed, 0),
1841
- chars: diffs.reduce((sum, diff) => sum + diff.chars, 0),
1842
- };
1843
- let firstEditLine = 0;
1844
- try {
1845
- if (fp) {
1846
- const f = readFileSync(fp, "utf8");
1847
- const idx = f.indexOf(operations[0].newText);
1848
- if (idx >= 0)
1849
- firstEditLine = f.slice(0, idx).split("\n").length;
1850
- }
1851
- }
1852
- catch {
1853
- firstEditLine = 0;
1854
- }
1855
- result.details = {
1856
- _type: "multiEditInfo",
1857
- summary: firstEditLine > 0 ? `${summary} at line ${firstEditLine}` : summary,
1736
+ const editCount = operations.length || (Array.isArray(params?.edits) ? params.edits.length : 1);
1737
+ stashEditHeaderStats(tid, editCount, diff.lines.length, diff.added, diff.removed);
1738
+ const details = {
1739
+ ...result.details,
1740
+ _type: editCount > 1 ? "multiEditInfo" : "editInfo",
1858
1741
  filePath: fp,
1859
- editCount: operations.length,
1860
- diffLineCount: merged.lines.length,
1861
- diff: merged,
1862
- language: lg,
1742
+ diff,
1743
+ diffLineCount: diff.lines.length,
1744
+ editCount,
1745
+ linesAdded: diff.added,
1746
+ linesRemoved: diff.removed,
1747
+ language: detectDiffLanguage(fp),
1748
+ summary: summarize(diff.added, diff.removed),
1863
1749
  };
1750
+ result.details = details;
1864
1751
  return result;
1865
1752
  },
1866
1753
  renderCall(args, theme, ctx) {
@@ -1870,25 +1757,12 @@ export default async function diffRendererExtension(pi) {
1870
1757
  resolvePreviewDiffColors(theme);
1871
1758
  const stats = editCallStatsSuffix(ctx.toolCallId, theme);
1872
1759
  if (ctx.argsComplete && operations.length > 0) {
1873
- let previewLine = 0;
1874
- try {
1875
- if (fp && existsSync(fp)) {
1876
- const cur = readFileSync(fp, "utf-8");
1877
- const idx = cur.indexOf(operations[0].oldText);
1878
- if (idx >= 0)
1879
- previewLine = cur.slice(0, idx).split("\n").length;
1880
- }
1881
- }
1882
- catch {
1883
- previewLine = 0;
1884
- }
1885
- const loc = previewLine > 0 ? `${TOOL_RESULT_INDENT}${theme.fg("muted", `at line ${previewLine}`)}` : "";
1886
1760
  setToolHeaderBg(text);
1887
1761
  text.setText(formatToolFrameHeaderText({
1888
1762
  label: "edit",
1889
1763
  filePath: fp,
1890
1764
  theme,
1891
- suffix: loc,
1765
+ suffix: stats,
1892
1766
  topPad: EDIT_DIFF_RESULT_FRAME.topPad,
1893
1767
  bottomPad: EDIT_DIFF_RESULT_FRAME.bottomPad,
1894
1768
  headerLeftPad: EDIT_DIFF_RESULT_FRAME.headerLeftPad,
@@ -2037,7 +1911,5 @@ export default async function diffRendererExtension(pi) {
2037
1911
  return text;
2038
1912
  },
2039
1913
  });
2040
- if (!disabledTools.has("edit"))
2041
- registerEditGuard(pi);
2042
1914
  }
2043
1915
  //# sourceMappingURL=index.js.map