diff-easy 0.1.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.
Files changed (172) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +142 -0
  3. package/dist/cli/cmux.d.ts +9 -0
  4. package/dist/cli/cmux.js +274 -0
  5. package/dist/cli/comment.d.ts +2 -0
  6. package/dist/cli/comment.js +91 -0
  7. package/dist/cli/github.d.ts +84 -0
  8. package/dist/cli/github.js +364 -0
  9. package/dist/cli/index.d.ts +2 -0
  10. package/dist/cli/index.js +397 -0
  11. package/dist/cli/turns.d.ts +14 -0
  12. package/dist/cli/turns.js +333 -0
  13. package/dist/cli/utils.d.ts +27 -0
  14. package/dist/cli/utils.js +204 -0
  15. package/dist/client/assets/arc-CHYjdFTk.js +1 -0
  16. package/dist/client/assets/architecture-7EHR7CIX-weHBYDgM.js +1 -0
  17. package/dist/client/assets/architectureDiagram-3BPJPVTR-DRa2msuJ.js +36 -0
  18. package/dist/client/assets/array-BifhSqXX.js +1 -0
  19. package/dist/client/assets/blockDiagram-GPEHLZMM-BTdKsXy5.js +132 -0
  20. package/dist/client/assets/c4Diagram-AAUBKEIU-CwTvCkOg.js +10 -0
  21. package/dist/client/assets/channel-Cv40nQW1.js +1 -0
  22. package/dist/client/assets/chunk-2J33WTMH-BFsjrTtc.js +1 -0
  23. package/dist/client/assets/chunk-3OPIFGDE-V_xVw1dq.js +62 -0
  24. package/dist/client/assets/chunk-4BX2VUAB-DoLO6LLy.js +1 -0
  25. package/dist/client/assets/chunk-55IACEB6-CHgBwbdB.js +1 -0
  26. package/dist/client/assets/chunk-5ZQYHXKU-DMixAgQG.js +2 -0
  27. package/dist/client/assets/chunk-727SXJPM-C-3FZTmm.js +206 -0
  28. package/dist/client/assets/chunk-AQP2D5EJ-DpQZhXt-.js +231 -0
  29. package/dist/client/assets/chunk-BSJP7CBP-BnO9E_G4.js +1 -0
  30. package/dist/client/assets/chunk-CSCIHK7Q-BxItphyO.js +124 -0
  31. package/dist/client/assets/chunk-FMBD7UC4-4hEcfvfT.js +15 -0
  32. package/dist/client/assets/chunk-KSCS5N6A-DnArkpOI.js +10 -0
  33. package/dist/client/assets/chunk-L5ZTLDWV-DQoYJ1BW.js +1 -0
  34. package/dist/client/assets/chunk-LZXEDZCA-CdCevBX-.js +2 -0
  35. package/dist/client/assets/chunk-ND2GUHAM-Dt89U_nU.js +1 -0
  36. package/dist/client/assets/chunk-NNHCCRGN-DlpIbxXb.js +159 -0
  37. package/dist/client/assets/chunk-NZK2D7GU-C2Ev_f_w.js +1 -0
  38. package/dist/client/assets/chunk-O5CBEL6O-BeZ3MFnJ.js +70 -0
  39. package/dist/client/assets/chunk-QZHKN3VN-BmDOSWHp.js +1 -0
  40. package/dist/client/assets/chunk-WU5MYG2G-gLuInTk4.js +1 -0
  41. package/dist/client/assets/chunk-XPW4576I-DPbDc8_T.js +32 -0
  42. package/dist/client/assets/classDiagram-4FO5ZUOK-DOpVKUrb.js +1 -0
  43. package/dist/client/assets/classDiagram-v2-Q7XG4LA2-DOpVKUrb.js +1 -0
  44. package/dist/client/assets/cose-bilkent-S5V4N54A-CCPyalms.js +1 -0
  45. package/dist/client/assets/cytoscape.esm-FqbQrHcz.js +321 -0
  46. package/dist/client/assets/dagre-BM42HDAG-6jrYMjX3.js +4 -0
  47. package/dist/client/assets/dagre-Bx709z4p.js +1 -0
  48. package/dist/client/assets/defaultLocale-C8Fc0cco.js +1 -0
  49. package/dist/client/assets/diagram-2AECGRRQ-dCQ_KoXl.js +43 -0
  50. package/dist/client/assets/diagram-5GNKFQAL-B3Xj4-XE.js +10 -0
  51. package/dist/client/assets/diagram-KO2AKTUF-DaGgF2yL.js +3 -0
  52. package/dist/client/assets/diagram-LMA3HP47-CF_c4tPT.js +24 -0
  53. package/dist/client/assets/diagram-OG6HWLK6-BgBHbJkT.js +24 -0
  54. package/dist/client/assets/dist-7wVkzZne.js +1 -0
  55. package/dist/client/assets/erDiagram-TEJ5UH35-BZgX1grR.js +85 -0
  56. package/dist/client/assets/eventmodeling-FCH6USID-BaI6RNKr.js +1 -0
  57. package/dist/client/assets/flowDiagram-I6XJVG4X-BFNUa12V.js +162 -0
  58. package/dist/client/assets/ganttDiagram-6RSMTGT7-C8t6BThI.js +292 -0
  59. package/dist/client/assets/gitGraph-WXDBUCRP-BlicfDkM.js +1 -0
  60. package/dist/client/assets/gitGraphDiagram-PVQCEYII-BmnIXuXP.js +106 -0
  61. package/dist/client/assets/graphlib-B8gBHxth.js +1 -0
  62. package/dist/client/assets/index-BfVtbKTZ.css +2 -0
  63. package/dist/client/assets/index-p8zzfjdu.js +87 -0
  64. package/dist/client/assets/info-J43DQDTF-DHLG38N3.js +1 -0
  65. package/dist/client/assets/infoDiagram-5YYISTIA-oQ-lq-45.js +2 -0
  66. package/dist/client/assets/init-D6jRqBbL.js +1 -0
  67. package/dist/client/assets/ishikawaDiagram-YF4QCWOH-DrODlBLD.js +70 -0
  68. package/dist/client/assets/journeyDiagram-JHISSGLW-Bxg5wCQz.js +139 -0
  69. package/dist/client/assets/kanban-definition-UN3LZRKU-DAdvUDIB.js +89 -0
  70. package/dist/client/assets/katex-Vhh-h91d.js +257 -0
  71. package/dist/client/assets/line-D8kNd46u.js +1 -0
  72. package/dist/client/assets/linear-jwGZSrg4.js +1 -0
  73. package/dist/client/assets/mermaid-parser.core-PVPfUfyU.js +4 -0
  74. package/dist/client/assets/mermaid.core-DwdP6CFg.js +9 -0
  75. package/dist/client/assets/mindmap-definition-RKZ34NQL-DeOoL56Q.js +96 -0
  76. package/dist/client/assets/ordinal-hYBb2elL.js +1 -0
  77. package/dist/client/assets/packet-YPE3B663-BBEaxdJP.js +1 -0
  78. package/dist/client/assets/path-BWPyau1x.js +1 -0
  79. package/dist/client/assets/pie-LRSECV5Y-fvTRFm-2.js +1 -0
  80. package/dist/client/assets/pieDiagram-4H26LBE5-oep1px2r.js +30 -0
  81. package/dist/client/assets/prism-bash-D6zCJ74D.js +1 -0
  82. package/dist/client/assets/prism-clojure-_z-zoNcE.js +1 -0
  83. package/dist/client/assets/prism-csharp-XPn2bw0d.js +1 -0
  84. package/dist/client/assets/prism-dart-CDv16Fbu.js +1 -0
  85. package/dist/client/assets/prism-elixir-DhHhV5Rj.js +1 -0
  86. package/dist/client/assets/prism-haskell-5hq1Nh9c.js +1 -0
  87. package/dist/client/assets/prism-hcl-B6Z0Ouoo.js +1 -0
  88. package/dist/client/assets/prism-java-6wtusvIl.js +1 -0
  89. package/dist/client/assets/prism-markup-templating-KkSMBj6Y.js +1 -0
  90. package/dist/client/assets/prism-nix-C88dTX93.js +1 -0
  91. package/dist/client/assets/prism-perl-Lsbi34sH.js +1 -0
  92. package/dist/client/assets/prism-php-Co4fM40K.js +1 -0
  93. package/dist/client/assets/prism-protobuf-yzSHlhwy.js +1 -0
  94. package/dist/client/assets/prism-ruby-DFCASkuD.js +1 -0
  95. package/dist/client/assets/prism-scala-VkM3wCOL.js +1 -0
  96. package/dist/client/assets/prism-solidity-DTom4k5p.js +1 -0
  97. package/dist/client/assets/prism-sql-CYhVDMEE.js +1 -0
  98. package/dist/client/assets/prism-vim-BgpkjCK5.js +1 -0
  99. package/dist/client/assets/quadrantDiagram-W4KKPZXB-D9l66CHb.js +7 -0
  100. package/dist/client/assets/radar-GUYGQ44K-DAU5EPVd.js +1 -0
  101. package/dist/client/assets/requirementDiagram-4Y6WPE33-helpQlwb.js +84 -0
  102. package/dist/client/assets/rough.esm-CSKSodPl.js +1 -0
  103. package/dist/client/assets/sankeyDiagram-5OEKKPKP-C9sNuuvA.js +40 -0
  104. package/dist/client/assets/sequenceDiagram-3UESZ5HK-BP0bhCwQ.js +162 -0
  105. package/dist/client/assets/src-CDAPGf2l.js +1 -0
  106. package/dist/client/assets/stateDiagram-AJRCARHV-CsFizpla.js +1 -0
  107. package/dist/client/assets/stateDiagram-v2-BHNVJYJU-Bvn8tanl.js +1 -0
  108. package/dist/client/assets/timeline-definition-PNZ67QCA-BnkmHs1F.js +120 -0
  109. package/dist/client/assets/treeView-BLDUP644-DD9HZO5W.js +1 -0
  110. package/dist/client/assets/treemap-LRROVOQU-BBKuQ1lo.js +1 -0
  111. package/dist/client/assets/vennDiagram-CIIHVFJN-BPFTHflF.js +34 -0
  112. package/dist/client/assets/wardley-L42UT6IY-y88gDkwb.js +1 -0
  113. package/dist/client/assets/wardleyDiagram-YWT4CUSO-BlcA7IVh.js +78 -0
  114. package/dist/client/assets/xychartDiagram-2RQKCTM6-DX5vUCRH.js +7 -0
  115. package/dist/client/favicon.png +0 -0
  116. package/dist/client/index.html +14 -0
  117. package/dist/client/logo.png +0 -0
  118. package/dist/client/site-data/blobs/080c0e6/cHVibGljL3NpdGUtZGF0YS9vZy1pbWFnZS5wbmc.png +0 -0
  119. package/dist/client/site-data/blobs/55f23a1/bGFuZGluZy9wdWJsaWMvZGlmaXQvbG9nby5wbmc.png +0 -0
  120. package/dist/client/site-data/blobs/66ff7c6/cHVibGljL2xvZ28ucG5n.png +0 -0
  121. package/dist/client/site-data/blobs/e6977fe/cHVibGljL2xvZ28ucG5n.png +0 -0
  122. package/dist/client/site-data/manifest.json +1 -0
  123. package/dist/client/site-data/og-image.png +0 -0
  124. package/dist/client/site-data/snapshots/55f23a1...080c0e6.json +1 -0
  125. package/dist/client/site-data/snapshots/66ff7c6...e6977fe.json +1 -0
  126. package/dist/client/site-data/snapshots/7d40fd4...a72112f-comments.json +1 -0
  127. package/dist/client/site-data/snapshots/7d40fd4...a72112f.json +1 -0
  128. package/dist/server/file-watcher.d.ts +33 -0
  129. package/dist/server/file-watcher.js +340 -0
  130. package/dist/server/generated-file-check.d.ts +17 -0
  131. package/dist/server/generated-file-check.js +280 -0
  132. package/dist/server/git-diff.d.ts +94 -0
  133. package/dist/server/git-diff.js +909 -0
  134. package/dist/server/grouping/cache.d.ts +3 -0
  135. package/dist/server/grouping/cache.js +22 -0
  136. package/dist/server/grouping/claudeCli.d.ts +10 -0
  137. package/dist/server/grouping/claudeCli.js +95 -0
  138. package/dist/server/grouping/digest.d.ts +15 -0
  139. package/dist/server/grouping/digest.js +38 -0
  140. package/dist/server/grouping/reconcile.d.ts +6 -0
  141. package/dist/server/grouping/reconcile.js +34 -0
  142. package/dist/server/grouping/types.d.ts +9 -0
  143. package/dist/server/grouping/types.js +1 -0
  144. package/dist/server/server.d.ts +37 -0
  145. package/dist/server/server.js +1283 -0
  146. package/dist/shared/grouping.d.ts +4 -0
  147. package/dist/shared/grouping.js +170 -0
  148. package/dist/types/diff.d.ts +263 -0
  149. package/dist/types/diff.js +1 -0
  150. package/dist/types/watch.d.ts +59 -0
  151. package/dist/types/watch.js +8 -0
  152. package/dist/utils/commentFormatting.d.ts +6 -0
  153. package/dist/utils/commentFormatting.js +90 -0
  154. package/dist/utils/commentImports.d.ts +11 -0
  155. package/dist/utils/commentImports.js +386 -0
  156. package/dist/utils/createId.d.ts +1 -0
  157. package/dist/utils/createId.js +5 -0
  158. package/dist/utils/diffMode.d.ts +3 -0
  159. package/dist/utils/diffMode.js +13 -0
  160. package/dist/utils/diffSelection.d.ts +13 -0
  161. package/dist/utils/diffSelection.js +39 -0
  162. package/dist/utils/editorOptions.d.ts +60 -0
  163. package/dist/utils/editorOptions.js +171 -0
  164. package/dist/utils/fileUtils.d.ts +12 -0
  165. package/dist/utils/fileUtils.js +21 -0
  166. package/dist/utils/hubConfig.d.ts +32 -0
  167. package/dist/utils/hubConfig.js +51 -0
  168. package/dist/utils/suggestionUtils.d.ts +19 -0
  169. package/dist/utils/suggestionUtils.js +31 -0
  170. package/dist/utils/turnLog.d.ts +47 -0
  171. package/dist/utils/turnLog.js +133 -0
  172. package/package.json +105 -0
package/LICENSE ADDED
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sai Mageshvar (diff-easy)
4
+ Copyright (c) 2025 @yoshiko-pg/difit (original difit project this is based on)
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ of this software and associated documentation files (the "Software"), to deal
8
+ in the Software without restriction, including without limitation the rights
9
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ copies of the Software, and to permit persons to whom the Software is
11
+ furnished to do so, subject to the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be included in all
14
+ copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,142 @@
1
+ # diff-easy
2
+
3
+ A local, browser-based **git diff viewer built for AI-agent workflows**. It runs a
4
+ small web server on your machine (view it in any browser — or Cmux's inline
5
+ browser) and adds two things on top of a plain diff view:
6
+
7
+ 1. **Filter by filename / type, and search within the diff.** Filter the file
8
+ list by substring, by extension chips (`.ts`, `.tsx`, `.css`, …), and by change
9
+ status; search diff _content_ across all files with highlight + next/prev.
10
+ 2. **Review the diff produced by each AI-agent turn.** A capture hook snapshots
11
+ your working tree at every Claude Code turn boundary, so you can browse a full
12
+ timeline of turns and see exactly what each one changed — with **live refresh**
13
+ as the agent works.
14
+
15
+ diff-easy is built on a vendored copy of [difit](https://github.com/yoshiko-pg/difit)
16
+ (MIT). See [`NOTICE.md`](./NOTICE.md) for attribution.
17
+
18
+ ## Install
19
+
20
+ ```bash
21
+ # from a clone of this repo
22
+ corepack enable # provides pnpm
23
+ pnpm install
24
+ pnpm run build
25
+ npm link # makes `diff-easy` available on your PATH (optional)
26
+ ```
27
+
28
+ If you don't `npm link`, run it via `node dist/cli/index.js …` (or `pnpm start`).
29
+
30
+ ## Usage
31
+
32
+ Run inside any git repository:
33
+
34
+ ```bash
35
+ diff-easy # default: ALL uncommitted changes (staged + unstaged) vs HEAD
36
+ diff-easy working # unstaged changes only
37
+ diff-easy staged # staged changes only
38
+ diff-easy HEAD # the last commit (HEAD^..HEAD)
39
+ diff-easy HEAD~3 HEAD # a commit range
40
+ diff-easy --port 9100 # pick a port (handy with Cmux per-workspace ports)
41
+ ```
42
+
43
+ It prints a `http://localhost:<port>` URL and opens your browser. In **Cmux**,
44
+ enable "open http/https in the cmux browser" (Settings) and the URL opens right in
45
+ the inline browser pane — or run `cmux browser open <url>`.
46
+
47
+ ### Filter & search
48
+
49
+ - **File list** (left): type in _Filter files…_, click extension chips to narrow by
50
+ type, and use the status chips (Added / Modified / …) when more than one status
51
+ is present.
52
+ - **In-diff search**: press **Cmd/Ctrl+F** (or click the search icon). Enter steps
53
+ to the next match, Shift+Enter the previous; toggle case sensitivity. Matches are
54
+ highlighted across every file.
55
+
56
+ ## Use from CMUX
57
+
58
+ Run this in a repo's CMUX workspace to open/close the hub — scoped to that repo —
59
+ in a browser split pane beside your terminal:
60
+
61
+ ```bash
62
+ diff-easy cmux toggle # open the split for $PWD if closed, close it if open
63
+ diff-easy cmux open # just open (re-points the same pane; never closes)
64
+ diff-easy cmux open --no-open # ensure the hub is up and print the URL
65
+ ```
66
+
67
+ `toggle` starts the always-on hub if needed and opens the diff-easy split scoped
68
+ via a `?repo=` deep-link; running it again closes that split. As the agent edits
69
+ code, the diff (and the agent-turn timeline) update live beside your terminal.
70
+
71
+ The hub is shared and shows one repo at a time, so toggling in another workspace
72
+ re-points it to that repo. All three accept `--port <n>` (default `4966`; also
73
+ honors `$DIFIT_HUB_PORT`).
74
+
75
+ All accept `--port <n>` (default `4966`; also honors `$DIFIT_HUB_PORT`).
76
+
77
+ ## Per-turn diffs (Claude Code)
78
+
79
+ Install the capture hooks once:
80
+
81
+ ```bash
82
+ diff-easy init # installs into ~/.claude/settings.json (works in every repo)
83
+ diff-easy init --project # or commit them to ./.claude/settings.json for a single repo
84
+ ```
85
+
86
+ This adds two Claude Code hooks — `UserPromptSubmit` (turn start) and `Stop`
87
+ (turn end). On each boundary, `diff-easy snapshot` records the **entire working
88
+ tree** as a git snapshot (capturing edits from _both_ the Edit/Write tools and
89
+ Bash commands like `sed`/`mv`). Snapshots are anchored under `refs/difit/snapshots`
90
+ so they survive `git gc`, and turn metadata is written to `.difit-turns/log.json`.
91
+ The hook is a no-op outside a git repo and never blocks the agent.
92
+
93
+ Then, in the viewer, use the **Agent turns** dropdown (top toolbar) to:
94
+
95
+ - pick **Working dir** for the live diff, or any **Turn N** to see exactly what that
96
+ turn changed (its diff is `git diff <snapshotBefore> <snapshotAfter>`);
97
+ - stay on **Live** to auto-advance to each new turn as the agent finishes it
98
+ (powered by an SSE ping from the `Stop` hook).
99
+
100
+ Capture is decoupled from viewing: turns are recorded whether or not the viewer is
101
+ open, so you can review after the fact.
102
+
103
+ ### Where it writes & data retention
104
+
105
+ - **`<repo>/.difit-turns/`** — `log.json` (turn metadata) and `server.json`
106
+ (live-refresh port). Local-only and self-ignored from version control.
107
+ - **`<repo>/.git`** — snapshot commits/trees/blobs, anchored by the single ref
108
+ `refs/difit/snapshots`. Never touches your branches or normal history. Git
109
+ deduplicates unchanged file content across snapshots.
110
+ - **OS temp dir (`$TMPDIR`)** — a short-lived git index file per snapshot,
111
+ deleted immediately (nothing persists there).
112
+
113
+ **Retention:** each session keeps its **most recent 50 turns**
114
+ (`MAX_TURNS_PER_SESSION`). Older turns are dropped from the log, and their
115
+ snapshot commits stop being referenced by `refs/difit/snapshots`, so a routine
116
+ `git gc` reclaims them — keeping `.git` bounded. To wipe all snapshots manually:
117
+
118
+ ```bash
119
+ git update-ref -d refs/difit/snapshots && git gc --prune=now && rm -rf .difit-turns
120
+ ```
121
+
122
+ ## Develop
123
+
124
+ ```bash
125
+ pnpm run build # build CLI + client
126
+ pnpm run build:cli # CLI only (type-checks the server/CLI)
127
+ npx tsc --noEmit -p tsconfig.json # type-check the client
128
+ pnpm test # run tests
129
+ ```
130
+
131
+ Key diff-easy additions over difit:
132
+
133
+ - `src/client/components/FileList.tsx` — extension/status filter chips
134
+ - `src/client/hooks/useDiffSearch.ts`, `src/client/components/DiffSearch.tsx` —
135
+ in-diff search (CSS Custom Highlight API)
136
+ - `src/cli/turns.ts`, `src/utils/turnLog.ts` — `snapshot` / `init` commands + log
137
+ - `src/client/hooks/useTurns.ts`, `src/client/components/TurnTimeline.tsx` — turn UI
138
+ - `src/server/server.ts` — `/api/turns`, `/events/turn-complete`, `server.json`
139
+
140
+ ## License
141
+
142
+ MIT — see [`LICENSE`](./LICENSE) and [`NOTICE.md`](./NOTICE.md).
@@ -0,0 +1,9 @@
1
+ import { Command } from 'commander';
2
+ export declare const DEFAULT_HUB_PORT = 4966;
3
+ export declare function resolveHubPort(flagPort: number | undefined, env: NodeJS.ProcessEnv): number;
4
+ export declare function hubOrigin(port: number): string;
5
+ export declare function buildHubUrl(port: number, repoPath: string): string;
6
+ export declare function shouldReuseSurface(storedRef: string | null, surfaceUrl: string | null, origin: string): boolean;
7
+ export declare function isInsideCmux(env: NodeJS.ProcessEnv): boolean;
8
+ /** Builds the `diff-easy cmux` subcommand (open/toggle). */
9
+ export declare function createCmuxCommand(): Command;
@@ -0,0 +1,274 @@
1
+ import { execFileSync, spawn } from 'child_process';
2
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs';
3
+ import { get as httpGet } from 'http';
4
+ import { tmpdir } from 'os';
5
+ import { dirname, join } from 'path';
6
+ import { Command } from 'commander';
7
+ export const DEFAULT_HUB_PORT = 4966;
8
+ export function resolveHubPort(flagPort, env) {
9
+ if (typeof flagPort === 'number' && Number.isFinite(flagPort))
10
+ return flagPort;
11
+ const fromEnv = env.DIFIT_HUB_PORT ? Number.parseInt(env.DIFIT_HUB_PORT, 10) : Number.NaN;
12
+ return Number.isFinite(fromEnv) ? fromEnv : DEFAULT_HUB_PORT;
13
+ }
14
+ export function hubOrigin(port) {
15
+ return `http://localhost:${port}`;
16
+ }
17
+ export function buildHubUrl(port, repoPath) {
18
+ return `${hubOrigin(port)}/?repo=${encodeURIComponent(repoPath)}`;
19
+ }
20
+ export function shouldReuseSurface(storedRef, surfaceUrl, origin) {
21
+ return Boolean(storedRef) && Boolean(surfaceUrl?.startsWith(origin));
22
+ }
23
+ export function isInsideCmux(env) {
24
+ return Boolean(env.CMUX_WORKSPACE_ID);
25
+ }
26
+ // ---- surface state (per-workspace pane reuse) ----
27
+ function surfaceStatePath(workspaceId) {
28
+ return join(tmpdir(), 'diff-easy-cmux', `${workspaceId.replace(/[^\w:-]/g, '_')}.surface`);
29
+ }
30
+ function readStoredSurface(workspaceId) {
31
+ try {
32
+ return readFileSync(surfaceStatePath(workspaceId), 'utf8').trim() || null;
33
+ }
34
+ catch {
35
+ return null;
36
+ }
37
+ }
38
+ function writeStoredSurface(workspaceId, ref) {
39
+ const p = surfaceStatePath(workspaceId);
40
+ if (!existsSync(dirname(p)))
41
+ mkdirSync(dirname(p), { recursive: true });
42
+ writeFileSync(p, `${ref}\n`);
43
+ }
44
+ function clearStoredSurface(workspaceId) {
45
+ try {
46
+ rmSync(surfaceStatePath(workspaceId), { force: true });
47
+ }
48
+ catch {
49
+ // best-effort
50
+ }
51
+ }
52
+ // ---- hub liveness ----
53
+ async function isHubUp(port) {
54
+ return await new Promise((resolve) => {
55
+ let settled = false;
56
+ const finish = (up) => {
57
+ if (settled)
58
+ return;
59
+ settled = true;
60
+ req.destroy();
61
+ resolve(up);
62
+ };
63
+ const req = httpGet(`${hubOrigin(port)}/api/workspaces`, (res) => {
64
+ res.resume();
65
+ finish(true);
66
+ });
67
+ req.setTimeout(500, () => finish(false));
68
+ req.on('error', () => finish(false));
69
+ });
70
+ }
71
+ async function waitForHub(port, timeoutMs) {
72
+ const deadline = Date.now() + timeoutMs;
73
+ while (Date.now() < deadline) {
74
+ if (await isHubUp(port))
75
+ return true;
76
+ await new Promise((r) => setTimeout(r, 250));
77
+ }
78
+ return false;
79
+ }
80
+ function startHubDetached(port) {
81
+ const scriptPath = process.argv[1];
82
+ if (!scriptPath) {
83
+ throw new Error('Unable to determine difit entrypoint for hub background process');
84
+ }
85
+ const child = spawn(process.execPath, [scriptPath, 'hub', '--no-open', '--port', String(port)], {
86
+ detached: true,
87
+ stdio: 'ignore',
88
+ });
89
+ child.unref();
90
+ }
91
+ /** Ensure the hub answers on `port`, starting it detached if needed. */
92
+ async function ensureHub(port) {
93
+ if (await isHubUp(port))
94
+ return true;
95
+ console.log(`diff-easy: starting hub on port ${port}…`);
96
+ startHubDetached(port);
97
+ if (await waitForHub(port, 5000))
98
+ return true;
99
+ console.error(`diff-easy: hub did not come up on port ${port}. Try \`diff-easy hub --port ${port}\`.`);
100
+ return false;
101
+ }
102
+ // ---- cmux browser surface control ----
103
+ /** Read a CMUX browser surface's current URL, or null if it doesn't resolve. */
104
+ function cmuxSurfaceUrl(ref) {
105
+ try {
106
+ return (execFileSync('cmux', ['browser', '--surface', ref, 'url'], {
107
+ stdio: ['ignore', 'pipe', 'ignore'],
108
+ })
109
+ .toString()
110
+ .trim() || null);
111
+ }
112
+ catch {
113
+ return null;
114
+ }
115
+ }
116
+ /** Open a new browser split and return the created surface ref (best-effort). */
117
+ function cmuxOpenSplit(url) {
118
+ try {
119
+ const out = execFileSync('cmux', ['browser', 'open-split', url, '--focus', 'true'], {
120
+ stdio: ['ignore', 'pipe', 'ignore'],
121
+ }).toString();
122
+ const match = out.match(/surface:\d+/);
123
+ return match ? match[0] : null;
124
+ }
125
+ catch {
126
+ return null;
127
+ }
128
+ }
129
+ /**
130
+ * Re-point an existing browser surface at `url` and bring it forward. Returns
131
+ * false if the surface can't be navigated (e.g. it was closed), so the caller
132
+ * can fall back to opening a fresh split. Note: `cmux browser navigate` does
133
+ * NOT accept `--focus`; focusing is a separate best-effort call.
134
+ */
135
+ function cmuxNavigate(ref, url) {
136
+ try {
137
+ execFileSync('cmux', ['browser', '--surface', ref, 'navigate', url], {
138
+ stdio: ['ignore', 'pipe', 'ignore'],
139
+ });
140
+ }
141
+ catch {
142
+ return false;
143
+ }
144
+ try {
145
+ execFileSync('cmux', ['browser', '--surface', ref, 'focus-webview'], {
146
+ stdio: ['ignore', 'pipe', 'ignore'],
147
+ });
148
+ }
149
+ catch {
150
+ // focus is best-effort — the navigate already succeeded
151
+ }
152
+ return true;
153
+ }
154
+ /** Close a CMUX browser surface (best-effort). */
155
+ function cmuxCloseSurface(ref) {
156
+ try {
157
+ execFileSync('cmux', ['browser', '--surface', ref, 'tab', 'close'], {
158
+ stdio: ['ignore', 'pipe', 'ignore'],
159
+ });
160
+ }
161
+ catch {
162
+ // best-effort
163
+ }
164
+ }
165
+ function isGitWorkTree(cwd) {
166
+ try {
167
+ return (execFileSync('git', ['-C', cwd, 'rev-parse', '--is-inside-work-tree'], {
168
+ stdio: ['ignore', 'pipe', 'ignore'],
169
+ })
170
+ .toString()
171
+ .trim() === 'true');
172
+ }
173
+ catch {
174
+ return false;
175
+ }
176
+ }
177
+ // ---- open / close / toggle ----
178
+ /** Open (or re-point) the diff-easy browser split for `cwd` in this workspace. */
179
+ function openPane(port, cwd, workspaceId) {
180
+ const url = buildHubUrl(port, cwd);
181
+ const stored = readStoredSurface(workspaceId);
182
+ if (shouldReuseSurface(stored, stored ? cmuxSurfaceUrl(stored) : null, hubOrigin(port)) &&
183
+ cmuxNavigate(stored, url)) {
184
+ console.log(`diff-easy: re-pointed diff-easy pane to ${cwd}`);
185
+ return;
186
+ }
187
+ const ref = cmuxOpenSplit(url);
188
+ if (ref)
189
+ writeStoredSurface(workspaceId, ref);
190
+ console.log(`diff-easy: opened diff-easy split pane for ${cwd}`);
191
+ }
192
+ /**
193
+ * Close the diff-easy pane if it's currently open on our hub. Returns true when
194
+ * a pane was closed. Clears a stale stored ref either way.
195
+ */
196
+ function closePane(workspaceId, port) {
197
+ const stored = readStoredSurface(workspaceId);
198
+ const open = shouldReuseSurface(stored, stored ? cmuxSurfaceUrl(stored) : null, hubOrigin(port));
199
+ if (open) {
200
+ cmuxCloseSurface(stored);
201
+ clearStoredSurface(workspaceId);
202
+ return true;
203
+ }
204
+ if (stored)
205
+ clearStoredSurface(workspaceId);
206
+ return false;
207
+ }
208
+ function warnIfNotGit(cwd) {
209
+ if (!isGitWorkTree(cwd)) {
210
+ console.warn(`diff-easy: ${cwd} is not a git repository — the hub can't scope to it.`);
211
+ }
212
+ }
213
+ function notInsideCmuxMessage(port, cwd) {
214
+ console.log(`diff-easy: not inside CMUX. Open this URL to review ${cwd}:\n ${buildHubUrl(port, cwd)}`);
215
+ }
216
+ async function runCmuxOpen(options) {
217
+ const port = resolveHubPort(options.port, process.env);
218
+ const cwd = process.cwd();
219
+ warnIfNotGit(cwd);
220
+ if (!(await ensureHub(port)))
221
+ process.exit(1);
222
+ if (options.open === false) {
223
+ console.log(buildHubUrl(port, cwd));
224
+ return;
225
+ }
226
+ const workspaceId = process.env.CMUX_WORKSPACE_ID;
227
+ if (!isInsideCmux(process.env) || !workspaceId) {
228
+ notInsideCmuxMessage(port, cwd);
229
+ return;
230
+ }
231
+ openPane(port, cwd, workspaceId);
232
+ }
233
+ async function runCmuxToggle(options) {
234
+ const port = resolveHubPort(options.port, process.env);
235
+ const cwd = process.cwd();
236
+ const workspaceId = process.env.CMUX_WORKSPACE_ID;
237
+ if (!isInsideCmux(process.env) || !workspaceId) {
238
+ warnIfNotGit(cwd);
239
+ if (!(await ensureHub(port)))
240
+ process.exit(1);
241
+ notInsideCmuxMessage(port, cwd);
242
+ return;
243
+ }
244
+ // Open → close; closed → open.
245
+ if (closePane(workspaceId, port)) {
246
+ console.log('diff-easy: closed diff-easy pane');
247
+ return;
248
+ }
249
+ warnIfNotGit(cwd);
250
+ if (!(await ensureHub(port)))
251
+ process.exit(1);
252
+ openPane(port, cwd, workspaceId);
253
+ }
254
+ /** Builds the `diff-easy cmux` subcommand (open/toggle). */
255
+ export function createCmuxCommand() {
256
+ const cmux = new Command('cmux').description('CMUX integration: open/toggle diff-easy for the current repo in a browser split pane');
257
+ const portOption = (value) => Number.parseInt(value, 10);
258
+ cmux
259
+ .command('open', { isDefault: true })
260
+ .description('Ensure the hub is running and open it (scoped to $PWD) in a CMUX browser split pane')
261
+ .option('--port <port>', 'hub port (default 4966; also honors $DIFIT_HUB_PORT)', portOption)
262
+ .option('--no-open', 'ensure the hub is up and print the URL, without opening a pane')
263
+ .action(async (options) => {
264
+ await runCmuxOpen(options);
265
+ });
266
+ cmux
267
+ .command('toggle')
268
+ .description('Toggle the diff-easy browser split for $PWD (open if closed, close if open)')
269
+ .option('--port <port>', 'hub port (default 4966; also honors $DIFIT_HUB_PORT)', portOption)
270
+ .action(async (options) => {
271
+ await runCmuxToggle(options);
272
+ });
273
+ return cmux;
274
+ }
@@ -0,0 +1,2 @@
1
+ import { Command } from 'commander';
2
+ export declare function createCommentCommand(): Command;
@@ -0,0 +1,91 @@
1
+ import { Command, Option } from 'commander';
2
+ import { parseCommentImportValue } from '../utils/commentImports.js';
3
+ import { detectStdinSource, readStdin } from './utils.js';
4
+ async function parseCommentAddInput(json) {
5
+ if (typeof json === 'string') {
6
+ return json;
7
+ }
8
+ if (detectStdinSource() === 'tty') {
9
+ throw new Error('Provide comment JSON as an argument or via stdin');
10
+ }
11
+ const stdin = await readStdin();
12
+ if (!stdin.trim()) {
13
+ throw new Error('No comment JSON received from stdin');
14
+ }
15
+ return stdin;
16
+ }
17
+ export function createCommentCommand() {
18
+ const comment = new Command('comment').description('Add or retrieve comments on a running difit server');
19
+ comment
20
+ .command('add')
21
+ .description('Add comments to a running difit server')
22
+ .argument('[json]', 'comment import JSON (object or array)')
23
+ .requiredOption('--port <port>', 'port of the running difit server', parseInt)
24
+ .action(async (json, opts) => {
25
+ try {
26
+ const input = await parseCommentAddInput(json);
27
+ const imports = parseCommentImportValue(input);
28
+ const response = await fetch(`http://localhost:${opts.port}/api/comment-imports`, {
29
+ method: 'POST',
30
+ headers: { 'Content-Type': 'application/json' },
31
+ body: JSON.stringify(imports),
32
+ });
33
+ if (!response.ok) {
34
+ const errorBody = (await response.json().catch(() => ({})));
35
+ console.error(`Error: ${errorBody.error ?? 'Failed to add comments'}`);
36
+ process.exit(1);
37
+ }
38
+ const result = (await response.json());
39
+ console.log(JSON.stringify({
40
+ success: result.success ?? true,
41
+ importId: result.importId,
42
+ count: result.count ?? imports.length,
43
+ warnings: result.warnings ?? [],
44
+ }));
45
+ }
46
+ catch (error) {
47
+ if (error instanceof TypeError && error.message.includes('fetch failed')) {
48
+ console.error(`Error: Cannot connect to difit server on port ${opts.port}. Is the server running?`);
49
+ }
50
+ else {
51
+ console.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
52
+ }
53
+ process.exit(1);
54
+ }
55
+ });
56
+ comment
57
+ .command('get')
58
+ .description('Retrieve comments from a running difit server')
59
+ .requiredOption('--port <port>', 'port of the running difit server', parseInt)
60
+ .addOption(new Option('--format <format>', 'output format').choices(['text', 'json']).default('text'))
61
+ .action(async (opts) => {
62
+ try {
63
+ const endpoint = opts.format === 'json' ? '/api/comments-json' : '/api/comments-output';
64
+ const response = await fetch(`http://localhost:${opts.port}${endpoint}`);
65
+ if (!response.ok) {
66
+ console.error('Error: Failed to retrieve comments');
67
+ process.exit(1);
68
+ }
69
+ if (opts.format === 'json') {
70
+ const data = await response.json();
71
+ console.log(JSON.stringify(data));
72
+ }
73
+ else {
74
+ const text = await response.text();
75
+ if (text.trim()) {
76
+ console.log(text);
77
+ }
78
+ }
79
+ }
80
+ catch (error) {
81
+ if (error instanceof TypeError && error.message.includes('fetch failed')) {
82
+ console.error(`Error: Cannot connect to difit server on port ${opts.port}. Is the server running?`);
83
+ }
84
+ else {
85
+ console.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
86
+ }
87
+ process.exit(1);
88
+ }
89
+ });
90
+ return comment;
91
+ }
@@ -0,0 +1,84 @@
1
+ import type { CommentImport } from '../types/diff.js';
2
+ interface PullRequestInfo {
3
+ owner: string;
4
+ repo: string;
5
+ pullNumber: number;
6
+ hostname: string;
7
+ }
8
+ interface GitHubReviewThreadAuthor {
9
+ login?: string | null;
10
+ }
11
+ interface GitHubReviewThreadCommentNode {
12
+ id?: string | null;
13
+ body?: string | null;
14
+ createdAt?: string | null;
15
+ updatedAt?: string | null;
16
+ author?: GitHubReviewThreadAuthor | null;
17
+ }
18
+ interface GitHubReviewThreadNode {
19
+ id?: string | null;
20
+ isResolved?: boolean | null;
21
+ isOutdated?: boolean | null;
22
+ subjectType?: string | null;
23
+ path?: string | null;
24
+ diffSide?: string | null;
25
+ startDiffSide?: string | null;
26
+ line?: number | null;
27
+ startLine?: number | null;
28
+ originalLine?: number | null;
29
+ originalStartLine?: number | null;
30
+ comments?: {
31
+ nodes?: GitHubReviewThreadCommentNode[] | null;
32
+ } | null;
33
+ }
34
+ interface GitHubReviewThreadsPageInfo {
35
+ hasNextPage?: boolean | null;
36
+ endCursor?: string | null;
37
+ }
38
+ interface GitHubReviewThreadsConnection {
39
+ nodes?: GitHubReviewThreadNode[] | null;
40
+ pageInfo?: GitHubReviewThreadsPageInfo | null;
41
+ }
42
+ interface GitHubReviewThreadsGraphqlResponse {
43
+ data?: {
44
+ repository?: {
45
+ pullRequest?: {
46
+ reviewThreads?: GitHubReviewThreadsConnection | null;
47
+ } | null;
48
+ } | null;
49
+ } | null;
50
+ errors?: Array<{
51
+ message?: string | null;
52
+ }> | null;
53
+ }
54
+ interface PrCommentImportsPage {
55
+ commentImports: CommentImport[];
56
+ pageInfo: {
57
+ hasNextPage: boolean;
58
+ endCursor: string | null;
59
+ };
60
+ }
61
+ export interface PullRequestDetails {
62
+ title: string;
63
+ body: string;
64
+ }
65
+ interface GitHubPrDetailsGraphqlResponse {
66
+ data?: {
67
+ repository?: {
68
+ pullRequest?: {
69
+ title?: string | null;
70
+ body?: string | null;
71
+ } | null;
72
+ } | null;
73
+ } | null;
74
+ errors?: Array<{
75
+ message?: string | null;
76
+ }> | null;
77
+ }
78
+ export declare function parseGitHubPrUrl(url: string): PullRequestInfo | null;
79
+ export declare function getPrPatch(prArg: string): string;
80
+ export declare function parsePrCommentImportsResponse(response: GitHubReviewThreadsGraphqlResponse): PrCommentImportsPage;
81
+ export declare function getPrCommentImports(prArg: string): Promise<CommentImport[]>;
82
+ export declare function parsePrDetailsResponse(response: GitHubPrDetailsGraphqlResponse): PullRequestDetails | null;
83
+ export declare function getPrDetails(prArg: string): Promise<PullRequestDetails | null>;
84
+ export {};