hunk_review_changes 0.2.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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +33 -0
- data/LICENSE.txt +27 -0
- data/README.md +126 -0
- data/exe/hunk-review-changes +6 -0
- data/lib/hunk_review_changes/app.rb +138 -0
- data/lib/hunk_review_changes/assets.rb +34 -0
- data/lib/hunk_review_changes/bundle.rb +122 -0
- data/lib/hunk_review_changes/cli.rb +106 -0
- data/lib/hunk_review_changes/diff.rb +205 -0
- data/lib/hunk_review_changes/export.rb +84 -0
- data/lib/hunk_review_changes/installer/base.rb +39 -0
- data/lib/hunk_review_changes/installer/claude_code.rb +44 -0
- data/lib/hunk_review_changes/installer/codex.rb +21 -0
- data/lib/hunk_review_changes/installer/cursor.rb +22 -0
- data/lib/hunk_review_changes/installer/directory_installer.rb +36 -0
- data/lib/hunk_review_changes/installer/opencode.rb +27 -0
- data/lib/hunk_review_changes/installer/runner.rb +122 -0
- data/lib/hunk_review_changes/installer/skill_source.rb +53 -0
- data/lib/hunk_review_changes/lifecycle.rb +42 -0
- data/lib/hunk_review_changes/markdown.rb +69 -0
- data/lib/hunk_review_changes/public/app.css +237 -0
- data/lib/hunk_review_changes/public/fonts/AtkinsonHyperlegibleMono.woff2 +0 -0
- data/lib/hunk_review_changes/public/fonts/AtkinsonHyperlegibleNext-Italic.woff2 +0 -0
- data/lib/hunk_review_changes/public/fonts/AtkinsonHyperlegibleNext.woff2 +0 -0
- data/lib/hunk_review_changes/public/fonts/OFL.txt +98 -0
- data/lib/hunk_review_changes/server.rb +99 -0
- data/lib/hunk_review_changes/state.rb +99 -0
- data/lib/hunk_review_changes/version.rb +5 -0
- data/lib/hunk_review_changes/views/index.erb +395 -0
- data/lib/hunk_review_changes.rb +35 -0
- metadata +185 -0
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module HunkReviewChanges
|
|
6
|
+
# Per-piece review state (comment, flag, reviewed), persisted as state.json next to
|
|
7
|
+
# the bundle so a closed tab loses nothing and re-running the same bundle resumes.
|
|
8
|
+
#
|
|
9
|
+
# State is scoped to a bundle fingerprint: when a directory is reused for a
|
|
10
|
+
# different (or overwritten) bundle, the previous review's comments are discarded
|
|
11
|
+
# rather than replayed against unrelated pieces that happen to share an id.
|
|
12
|
+
#
|
|
13
|
+
# Status precedence, highest first:
|
|
14
|
+
# flag - checked "flag for discussion"
|
|
15
|
+
# change - has a non-blank comment
|
|
16
|
+
# ok - reviewed and left as-is ("Looks good")
|
|
17
|
+
# unreviewed - not yet looked at
|
|
18
|
+
class State
|
|
19
|
+
STATUSES = %w[flag change ok unreviewed].freeze
|
|
20
|
+
|
|
21
|
+
# One Mutex per state.json path, shared across the request threads Puma runs, so
|
|
22
|
+
# overlapping saves serialize instead of clobbering each other's pieces.
|
|
23
|
+
@locks = {}
|
|
24
|
+
@locks_guard = Mutex.new
|
|
25
|
+
|
|
26
|
+
def self.lock_for(path)
|
|
27
|
+
@locks_guard.synchronize { @locks[path] ||= Mutex.new }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def initialize(path, bundle_id: nil)
|
|
31
|
+
@path = path
|
|
32
|
+
@bundle_id = bundle_id
|
|
33
|
+
@entries = load_entries
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def [](id)
|
|
37
|
+
@entries[id.to_s] || {}
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def update(id, comment:, flag:, reviewed:)
|
|
41
|
+
entry = {
|
|
42
|
+
"comment" => comment.to_s,
|
|
43
|
+
"flag" => !!flag,
|
|
44
|
+
"reviewed" => !!reviewed
|
|
45
|
+
}
|
|
46
|
+
# Serialize the whole read-modify-write: re-read the current file so a save
|
|
47
|
+
# racing on another thread merges its piece instead of overwriting it.
|
|
48
|
+
self.class.lock_for(@path).synchronize do
|
|
49
|
+
@entries = load_entries
|
|
50
|
+
@entries[id.to_s] = entry
|
|
51
|
+
save
|
|
52
|
+
end
|
|
53
|
+
self.class.status_for(entry)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def status(id)
|
|
57
|
+
self.class.status_for(self[id])
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def self.status_for(entry)
|
|
61
|
+
return "flag" if entry && entry["flag"]
|
|
62
|
+
return "change" if entry && entry["comment"].to_s.strip != ""
|
|
63
|
+
return "ok" if entry && entry["reviewed"]
|
|
64
|
+
|
|
65
|
+
"unreviewed"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Status counts a piece needs the agent to act on (everything but ok/unreviewed).
|
|
69
|
+
def self.actionable?(entry)
|
|
70
|
+
status = status_for(entry)
|
|
71
|
+
%w[flag change].include?(status)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
# Reads the entries for the current bundle. State written for a different bundle
|
|
77
|
+
# (same directory, new/overwritten bundle.json) is treated as absent so stale
|
|
78
|
+
# comments never leak into an unrelated review.
|
|
79
|
+
def load_entries
|
|
80
|
+
return {} unless File.exist?(@path)
|
|
81
|
+
|
|
82
|
+
data = JSON.parse(File.read(@path))
|
|
83
|
+
return {} unless data.is_a?(Hash)
|
|
84
|
+
return {} if @bundle_id && data["bundle_id"] && data["bundle_id"] != @bundle_id
|
|
85
|
+
|
|
86
|
+
data["entries"].is_a?(Hash) ? data["entries"] : {}
|
|
87
|
+
rescue JSON::ParserError
|
|
88
|
+
{}
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Atomic write (tmp + rename) so a crash or a concurrent reader never sees a
|
|
92
|
+
# half-written file. Callers hold lock_for(@path) around the read-modify-write.
|
|
93
|
+
def save
|
|
94
|
+
tmp = "#{@path}.#{Process.pid}.#{object_id}.tmp"
|
|
95
|
+
File.write(tmp, JSON.pretty_generate("bundle_id" => @bundle_id, "entries" => @entries))
|
|
96
|
+
File.rename(tmp, @path)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en" class="h-full">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>Review — <%= esc(@target) %></title>
|
|
7
|
+
<link rel="stylesheet" href="/app.css">
|
|
8
|
+
</head>
|
|
9
|
+
<body class="h-full bg-slate-50 text-slate-800 dark:bg-slate-950 dark:text-slate-200 antialiased">
|
|
10
|
+
<%# Pieces the adversary actually objected to. Static for the life of the page — it
|
|
11
|
+
describes the bundle, not the review — so it is counted once here rather than
|
|
12
|
+
recomputed by the JS that tracks reviewed/change/flag state. %>
|
|
13
|
+
<% contested = @pieces.count { |piece| %w[concern blocking].include?(piece[:challenge]&.fetch(:stance, nil)) } %>
|
|
14
|
+
<div class="flex h-full">
|
|
15
|
+
|
|
16
|
+
<!-- sidebar: browsable index of pieces -->
|
|
17
|
+
<aside class="hidden lg:flex lg:w-80 shrink-0 flex-col border-r border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-900">
|
|
18
|
+
<div class="px-5 py-4 border-b border-slate-200 dark:border-slate-800">
|
|
19
|
+
<div class="text-xs uppercase tracking-wide text-slate-400 dark:text-slate-500">Reviewing</div>
|
|
20
|
+
<div class="font-semibold text-sm break-words" title="<%= esc(@target) %>"><%= esc(@target) %></div>
|
|
21
|
+
<div class="text-xs text-slate-500 dark:text-slate-400 mt-1"><%= @pieces.size %> pieces</div>
|
|
22
|
+
</div>
|
|
23
|
+
<nav class="flex-1 overflow-y-auto py-2">
|
|
24
|
+
<% @pieces.each do |p| %>
|
|
25
|
+
<%# The row's left border was already reserved and unused, so an objection
|
|
26
|
+
colours it instead of adding a fourth thing to an already busy row. %>
|
|
27
|
+
<a href="#piece-<%= p[:id] %>" data-nav="<%= p[:id] %>"
|
|
28
|
+
class="group flex items-start gap-3 px-5 py-2.5 hover:bg-slate-50 dark:hover:bg-slate-800/60 border-l-2 <%= {
|
|
29
|
+
'blocking' => 'border-red-400',
|
|
30
|
+
'concern' => 'border-amber-400'
|
|
31
|
+
}.fetch(p[:challenge]&.fetch(:stance, nil), 'border-transparent') %>">
|
|
32
|
+
<span id="dot-<%= p[:id] %>" data-dot
|
|
33
|
+
class="mt-1.5 h-2.5 w-2.5 shrink-0 rounded-full <%= { 'flag' => 'bg-rose-500', 'change' => 'bg-amber-400', 'ok' => 'bg-emerald-500' }.fetch(p[:status], 'bg-slate-300 dark:bg-slate-600') %>"></span>
|
|
34
|
+
<span class="min-w-0">
|
|
35
|
+
<span class="block text-xs text-slate-400 dark:text-slate-500">Piece <%= p[:id] %></span>
|
|
36
|
+
<span class="block text-sm text-slate-700 dark:text-slate-200 group-hover:text-slate-900 dark:group-hover:text-white"><%= esc(p[:label]) %></span>
|
|
37
|
+
<span class="block text-xs font-mono text-slate-400 dark:text-slate-500 break-all"><%= esc(p[:file]) %></span>
|
|
38
|
+
</span>
|
|
39
|
+
</a>
|
|
40
|
+
<% end %>
|
|
41
|
+
</nav>
|
|
42
|
+
<div class="px-5 py-3 border-t border-slate-200 dark:border-slate-800 text-xs text-slate-400 dark:text-slate-500">
|
|
43
|
+
Press <kbd class="font-mono">?</kbd> for keyboard shortcuts
|
|
44
|
+
</div>
|
|
45
|
+
</aside>
|
|
46
|
+
|
|
47
|
+
<!-- main column -->
|
|
48
|
+
<div class="flex-1 flex flex-col min-w-0">
|
|
49
|
+
<header class="sticky top-0 z-10 flex items-center gap-4 border-b border-slate-200 bg-white/90 backdrop-blur px-6 py-3 dark:border-slate-800 dark:bg-slate-900/90">
|
|
50
|
+
<div class="min-w-0">
|
|
51
|
+
<h1 class="font-semibold">Hunk review</h1>
|
|
52
|
+
<p class="text-xs font-mono text-slate-500 dark:text-slate-400 truncate"><%= esc(@resolved_by) %></p>
|
|
53
|
+
</div>
|
|
54
|
+
<div class="ml-auto flex items-center gap-3">
|
|
55
|
+
<% if contested.positive? %>
|
|
56
|
+
<span class="text-xs rounded px-2 py-0.5 bg-indigo-100 text-indigo-700 dark:bg-indigo-500/20 dark:text-indigo-300" title="pieces the adversary objected to"><%= contested %> contested</span>
|
|
57
|
+
<% end %>
|
|
58
|
+
<span id="counts" class="text-xs text-slate-500 dark:text-slate-400 tabular-nums"></span>
|
|
59
|
+
<button id="copy-btn"
|
|
60
|
+
class="rounded-md border border-slate-300 bg-white px-3 py-1.5 text-sm font-medium hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-800 dark:hover:bg-slate-700">
|
|
61
|
+
Copy for LLM
|
|
62
|
+
</button>
|
|
63
|
+
<button id="done-btn"
|
|
64
|
+
class="rounded-md bg-emerald-600 px-4 py-1.5 text-sm font-medium text-white hover:bg-emerald-700">
|
|
65
|
+
Done
|
|
66
|
+
</button>
|
|
67
|
+
</div>
|
|
68
|
+
</header>
|
|
69
|
+
|
|
70
|
+
<main class="flex-1 overflow-y-auto px-6 py-8">
|
|
71
|
+
<div class="mx-auto max-w-[1440px] space-y-10">
|
|
72
|
+
<%# Framing and provenance are one block so the attribution sits tight under
|
|
73
|
+
the through-line, and so an absent framing does not leave it orphaned. %>
|
|
74
|
+
<% if !@framing.empty? || @adversary %>
|
|
75
|
+
<div class="space-y-2">
|
|
76
|
+
<% unless @framing.empty? %>
|
|
77
|
+
<div class="markdown text-[15px] leading-relaxed text-slate-600 dark:text-slate-300 bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl px-5 py-4"><%= @framing %></div>
|
|
78
|
+
<% end %>
|
|
79
|
+
<% if @adversary %>
|
|
80
|
+
<div class="px-1 text-xs text-indigo-700 dark:text-indigo-300">
|
|
81
|
+
Challenged by <span class="font-mono"><%= esc(@adversary) %></span> — the pushback on each piece argues against the change, so you hear both sides.
|
|
82
|
+
</div>
|
|
83
|
+
<% end %>
|
|
84
|
+
</div>
|
|
85
|
+
<% end %>
|
|
86
|
+
|
|
87
|
+
<% @pieces.each do |p| %>
|
|
88
|
+
<section id="piece-<%= p[:id] %>" data-piece="<%= p[:id] %>" data-reviewed="<%= p[:reviewed] %>"
|
|
89
|
+
class="scroll-mt-24 rounded-2xl border border-slate-200 bg-white shadow-sm overflow-hidden dark:border-slate-800 dark:bg-slate-900">
|
|
90
|
+
<div class="flex flex-wrap items-center gap-x-3 gap-y-1 px-6 py-4 border-b border-slate-200 dark:border-slate-800">
|
|
91
|
+
<span class="text-xs font-mono text-slate-400 dark:text-slate-500">Piece <%= p[:n] %></span>
|
|
92
|
+
<span class="text-[15px] font-semibold"><%= esc(p[:label]) %></span>
|
|
93
|
+
<span class="text-xs rounded px-2 py-0.5 <%= p[:kind] == 'test' ? 'bg-violet-100 text-violet-700 dark:bg-violet-500/15 dark:text-violet-300' : 'bg-sky-100 text-sky-700 dark:bg-sky-500/15 dark:text-sky-300' %>"><%= esc(p[:kind]) %></span>
|
|
94
|
+
<span class="ml-auto text-xs font-mono text-slate-400 dark:text-slate-500 break-all"><%= esc(p[:file]) %></span>
|
|
95
|
+
</div>
|
|
96
|
+
|
|
97
|
+
<div class="px-6 py-4 space-y-2.5">
|
|
98
|
+
<% unless p[:what].empty? %>
|
|
99
|
+
<div class="markdown text-[15px] leading-relaxed"><span class="font-bold text-slate-700 dark:text-slate-200">What it does — </span><%= p[:what] %></div>
|
|
100
|
+
<% end %>
|
|
101
|
+
<% unless p[:why].empty? %>
|
|
102
|
+
<div class="markdown text-[15px] leading-relaxed"><span class="font-bold text-slate-700 dark:text-slate-200">Why it is here — </span><%= p[:why] %></div>
|
|
103
|
+
<% end %>
|
|
104
|
+
<% unless p[:flags].empty? %>
|
|
105
|
+
<ul class="markdown text-[13px] text-amber-800 bg-amber-50 border border-amber-200 rounded-lg px-4 py-2.5 space-y-1 dark:text-amber-200 dark:bg-amber-500/10 dark:border-amber-500/30">
|
|
106
|
+
<% p[:flags].each do |f| %><li>⚠ <%= f %></li><% end %>
|
|
107
|
+
</ul>
|
|
108
|
+
<% end %>
|
|
109
|
+
|
|
110
|
+
<%# The adversary gets its own indigo-walled block so it never reads as
|
|
111
|
+
the author's voice continuing. The container stays one calm colour on
|
|
112
|
+
every challenged piece; severity is the stance badge's job alone, or a
|
|
113
|
+
page of nitpicks would look as alarming as a page of blockers. %>
|
|
114
|
+
<% if (c = p[:challenge]) %>
|
|
115
|
+
<% if c[:stance] == "concede" %>
|
|
116
|
+
<div class="text-[13px] text-slate-500 dark:text-slate-400">
|
|
117
|
+
Adversary reviewed — no objection<% unless c[:argument].empty? %>: <span class="markdown inline"><%= c[:argument] %></span><% end %>
|
|
118
|
+
</div>
|
|
119
|
+
<% else %>
|
|
120
|
+
<div class="rounded-lg border-l-4 border-indigo-400 bg-indigo-50 px-4 py-3 dark:border-indigo-400/70 dark:bg-indigo-500/10">
|
|
121
|
+
<div class="flex items-center gap-2 mb-1.5">
|
|
122
|
+
<%# No icon: the bundled Atkinson Hyperlegible has no glyph for the
|
|
123
|
+
obvious symbols here (⚖ renders as tofu), and the app exists to
|
|
124
|
+
be legible. The label and badge carry the signal on their own. %>
|
|
125
|
+
<span class="text-xs font-bold uppercase tracking-wide text-indigo-700 dark:text-indigo-300">Pushback</span>
|
|
126
|
+
<span class="text-xs rounded px-2 py-0.5 <%= {
|
|
127
|
+
'nitpick' => 'bg-slate-200 text-slate-700 dark:bg-slate-700 dark:text-slate-200',
|
|
128
|
+
'concern' => 'bg-amber-200 text-amber-900 dark:bg-amber-500/25 dark:text-amber-200',
|
|
129
|
+
'blocking' => 'bg-red-200 text-red-900 dark:bg-red-500/25 dark:text-red-200'
|
|
130
|
+
}.fetch(c[:stance], 'bg-slate-200 text-slate-700 dark:bg-slate-700 dark:text-slate-200') %>"><%= esc(c[:stance]) %></span>
|
|
131
|
+
</div>
|
|
132
|
+
<div class="markdown text-[15px] leading-relaxed text-slate-700 dark:text-slate-200"><%= c[:argument] %></div>
|
|
133
|
+
</div>
|
|
134
|
+
<% end %>
|
|
135
|
+
<% end %>
|
|
136
|
+
</div>
|
|
137
|
+
|
|
138
|
+
<div class="border-t border-slate-200 dark:border-slate-800 overflow-x-auto">
|
|
139
|
+
<%= p[:diff] %>
|
|
140
|
+
</div>
|
|
141
|
+
|
|
142
|
+
<div class="px-6 py-4 border-t border-slate-200 bg-slate-50 dark:border-slate-800 dark:bg-slate-900/40 space-y-3">
|
|
143
|
+
<textarea data-comment="<%= p[:id] %>" rows="2"
|
|
144
|
+
placeholder="Comment — describe the change you want here, or leave blank and mark Looks good"
|
|
145
|
+
class="w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-[14px] focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500 outline-none dark:border-slate-700 dark:bg-slate-800"><%= esc(p[:comment]) %></textarea>
|
|
146
|
+
<div class="flex flex-wrap items-center gap-3">
|
|
147
|
+
<button data-review="<%= p[:id] %>"
|
|
148
|
+
class="review-btn rounded-md border px-3 py-1.5 text-sm font-medium border-slate-300 bg-white hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-800 dark:hover:bg-slate-700">
|
|
149
|
+
<span class="review-label">Looks good ✓</span>
|
|
150
|
+
</button>
|
|
151
|
+
<label class="flex items-center gap-2 text-sm text-slate-600 dark:text-slate-300">
|
|
152
|
+
<input type="checkbox" data-flag="<%= p[:id] %>" <%= p[:flagged] ? 'checked' : '' %>
|
|
153
|
+
class="rounded border-slate-300 text-rose-600 focus:ring-rose-500 dark:border-slate-600 dark:bg-slate-800">
|
|
154
|
+
Flag for discussion (do not implement yet)
|
|
155
|
+
</label>
|
|
156
|
+
<span data-saved="<%= p[:id] %>" class="ml-auto text-xs text-emerald-600 dark:text-emerald-400 opacity-0 transition-opacity">saved ✓</span>
|
|
157
|
+
</div>
|
|
158
|
+
</div>
|
|
159
|
+
</section>
|
|
160
|
+
<% end %>
|
|
161
|
+
</div>
|
|
162
|
+
</main>
|
|
163
|
+
</div>
|
|
164
|
+
</div>
|
|
165
|
+
|
|
166
|
+
<!-- done overlay -->
|
|
167
|
+
<div id="done-overlay" class="hidden fixed inset-0 z-20 bg-slate-900/60 flex items-center justify-center">
|
|
168
|
+
<div class="bg-white dark:bg-slate-900 rounded-2xl px-8 py-6 text-center shadow-xl max-w-sm">
|
|
169
|
+
<div class="text-3xl mb-2">✓</div>
|
|
170
|
+
<p class="font-semibold">Review saved.</p>
|
|
171
|
+
<p class="text-sm text-slate-500 dark:text-slate-400 mt-1">You can close this tab and return to your agent — it will implement your comments.</p>
|
|
172
|
+
</div>
|
|
173
|
+
</div>
|
|
174
|
+
|
|
175
|
+
<!-- keyboard shortcuts overlay -->
|
|
176
|
+
<div id="help-overlay" class="hidden fixed inset-0 z-30 bg-slate-900/60 flex items-center justify-center" data-help>
|
|
177
|
+
<div class="bg-white dark:bg-slate-900 rounded-2xl px-8 py-6 shadow-xl max-w-xs w-full">
|
|
178
|
+
<div class="flex items-center justify-between mb-4">
|
|
179
|
+
<p class="font-semibold">Keyboard shortcuts</p>
|
|
180
|
+
<button data-help-close class="text-slate-400 hover:text-slate-600 dark:hover:text-slate-200">✕</button>
|
|
181
|
+
</div>
|
|
182
|
+
<dl class="space-y-2 text-sm">
|
|
183
|
+
<div class="flex justify-between"><dt>Next piece</dt><dd><kbd class="font-mono">j</kbd></dd></div>
|
|
184
|
+
<div class="flex justify-between"><dt>Previous piece</dt><dd><kbd class="font-mono">k</kbd></dd></div>
|
|
185
|
+
<div class="flex justify-between"><dt>Comment on piece</dt><dd><kbd class="font-mono">c</kbd></dd></div>
|
|
186
|
+
<div class="flex justify-between"><dt>Toggle Looks good</dt><dd><kbd class="font-mono">g</kbd></dd></div>
|
|
187
|
+
<div class="flex justify-between"><dt>Leave comment box</dt><dd><kbd class="font-mono">Esc</kbd></dd></div>
|
|
188
|
+
<div class="flex justify-between"><dt>This help</dt><dd><kbd class="font-mono">?</kbd></dd></div>
|
|
189
|
+
</dl>
|
|
190
|
+
</div>
|
|
191
|
+
</div>
|
|
192
|
+
|
|
193
|
+
<div id="toast" class="hidden fixed bottom-6 left-1/2 -translate-x-1/2 z-40 rounded-lg bg-slate-900 text-white text-sm px-4 py-2 shadow-lg dark:bg-slate-700"></div>
|
|
194
|
+
|
|
195
|
+
<script>
|
|
196
|
+
const TOTAL = <%= @pieces.size %>;
|
|
197
|
+
const dotClass = {
|
|
198
|
+
flag: "bg-rose-500",
|
|
199
|
+
change: "bg-amber-400",
|
|
200
|
+
ok: "bg-emerald-500",
|
|
201
|
+
unreviewed: "bg-slate-300 dark:bg-slate-600"
|
|
202
|
+
};
|
|
203
|
+
const reviewActive = "border-emerald-500 bg-emerald-50 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-300 dark:border-emerald-500/50";
|
|
204
|
+
|
|
205
|
+
function toast(msg) {
|
|
206
|
+
const t = document.getElementById("toast");
|
|
207
|
+
t.textContent = msg;
|
|
208
|
+
t.classList.remove("hidden");
|
|
209
|
+
clearTimeout(t._timer);
|
|
210
|
+
t._timer = setTimeout(() => t.classList.add("hidden"), 1900);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Derive a piece's status the same way the server does, so the UI stays in sync
|
|
214
|
+
// without a round-trip: flag > change > ok > unreviewed.
|
|
215
|
+
function statusOf(section) {
|
|
216
|
+
const id = section.dataset.piece;
|
|
217
|
+
if (document.querySelector(`[data-flag="${id}"]`).checked) return "flag";
|
|
218
|
+
if (document.querySelector(`[data-comment="${id}"]`).value.trim() !== "") return "change";
|
|
219
|
+
if (section.dataset.reviewed === "true") return "ok";
|
|
220
|
+
return "unreviewed";
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function paintPiece(section) {
|
|
224
|
+
const id = section.dataset.piece;
|
|
225
|
+
const status = statusOf(section);
|
|
226
|
+
const dot = document.getElementById(`dot-${id}`);
|
|
227
|
+
if (dot) dot.className = dot.className.replace(/bg-\S+(\s+dark:bg-\S+)?/, dotClass[status]);
|
|
228
|
+
const btn = document.querySelector(`[data-review="${id}"]`);
|
|
229
|
+
const reviewed = section.dataset.reviewed === "true";
|
|
230
|
+
reviewActive.split(" ").forEach(c => btn.classList.toggle(c, reviewed));
|
|
231
|
+
btn.querySelector(".review-label").textContent = reviewed ? "Reviewed ✓" : "Looks good ✓";
|
|
232
|
+
updateCounts();
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function updateCounts() {
|
|
236
|
+
let reviewed = 0, change = 0, flag = 0;
|
|
237
|
+
document.querySelectorAll("[data-piece]").forEach(s => {
|
|
238
|
+
const st = statusOf(s);
|
|
239
|
+
if (st !== "unreviewed") reviewed++;
|
|
240
|
+
if (st === "change") change++;
|
|
241
|
+
if (st === "flag") flag++;
|
|
242
|
+
});
|
|
243
|
+
document.getElementById("counts").textContent =
|
|
244
|
+
`${reviewed} of ${TOTAL} reviewed · ${change} to change · ${flag} flagged`;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// In-flight save requests, so Done can wait for every save to land before it
|
|
248
|
+
// asks the server to write the export.
|
|
249
|
+
const inflight = new Set();
|
|
250
|
+
|
|
251
|
+
async function save(id) {
|
|
252
|
+
const section = document.querySelector(`[data-piece="${id}"]`);
|
|
253
|
+
const comment = document.querySelector(`[data-comment="${id}"]`).value;
|
|
254
|
+
const flag = document.querySelector(`[data-flag="${id}"]`).checked;
|
|
255
|
+
const reviewed = section.dataset.reviewed === "true";
|
|
256
|
+
const request = fetch(`/pieces/${id}`, {
|
|
257
|
+
method: "POST",
|
|
258
|
+
headers: { "Content-Type": "application/json" },
|
|
259
|
+
body: JSON.stringify({ comment, flag, reviewed })
|
|
260
|
+
}).finally(() => inflight.delete(request));
|
|
261
|
+
inflight.add(request);
|
|
262
|
+
await request;
|
|
263
|
+
const saved = document.querySelector(`[data-saved="${id}"]`);
|
|
264
|
+
if (saved) { saved.style.opacity = 1; setTimeout(() => saved.style.opacity = 0, 1200); }
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Persist a piece during page unload, where an async fetch can't be awaited:
|
|
268
|
+
// sendBeacon queues the POST so a comment typed within the debounce window (its
|
|
269
|
+
// timer not yet fired) still reaches state.json before the tab goes away.
|
|
270
|
+
function beaconSave(id) {
|
|
271
|
+
const section = document.querySelector(`[data-piece="${id}"]`);
|
|
272
|
+
if (!section) return;
|
|
273
|
+
const comment = document.querySelector(`[data-comment="${id}"]`).value;
|
|
274
|
+
const flag = document.querySelector(`[data-flag="${id}"]`).checked;
|
|
275
|
+
const reviewed = section.dataset.reviewed === "true";
|
|
276
|
+
const body = new Blob([JSON.stringify({ comment, flag, reviewed })], { type: "application/json" });
|
|
277
|
+
navigator.sendBeacon(`/pieces/${id}`, body);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// --- wiring ---------------------------------------------------------------
|
|
281
|
+
// timers holds only genuinely-pending debounced saves: each timer removes its own
|
|
282
|
+
// entry when it fires, so Done and pagehide can tell what still needs flushing.
|
|
283
|
+
let timers = {};
|
|
284
|
+
document.querySelectorAll("[data-comment]").forEach(el => {
|
|
285
|
+
const id = el.dataset.comment;
|
|
286
|
+
el.addEventListener("input", () => {
|
|
287
|
+
paintPiece(el.closest("[data-piece]"));
|
|
288
|
+
clearTimeout(timers[id]);
|
|
289
|
+
timers[id] = setTimeout(() => { delete timers[id]; save(id); }, 500);
|
|
290
|
+
});
|
|
291
|
+
el.addEventListener("blur", () => save(id));
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
document.querySelectorAll("[data-flag]").forEach(el => {
|
|
295
|
+
el.addEventListener("change", () => {
|
|
296
|
+
paintPiece(el.closest("[data-piece]"));
|
|
297
|
+
save(el.dataset.flag);
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
document.querySelectorAll("[data-review]").forEach(btn => {
|
|
302
|
+
btn.addEventListener("click", () => {
|
|
303
|
+
const section = btn.closest("[data-piece]");
|
|
304
|
+
section.dataset.reviewed = section.dataset.reviewed === "true" ? "false" : "true";
|
|
305
|
+
paintPiece(section);
|
|
306
|
+
save(section.dataset.piece);
|
|
307
|
+
});
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
document.getElementById("copy-btn").addEventListener("click", async () => {
|
|
311
|
+
const md = await (await fetch("/export.md")).text();
|
|
312
|
+
try {
|
|
313
|
+
await navigator.clipboard.writeText(md);
|
|
314
|
+
toast("Copied review to clipboard — paste it into your agent");
|
|
315
|
+
} catch {
|
|
316
|
+
toast("Copy failed — the review is also saved to export.md");
|
|
317
|
+
}
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
// --- keyboard navigation --------------------------------------------------
|
|
321
|
+
const sections = [...document.querySelectorAll("[data-piece]")];
|
|
322
|
+
let current = 0;
|
|
323
|
+
|
|
324
|
+
function focusPiece(index, { scroll = true } = {}) {
|
|
325
|
+
current = Math.max(0, Math.min(sections.length - 1, index));
|
|
326
|
+
const section = sections[current];
|
|
327
|
+
if (!section) return;
|
|
328
|
+
sections.forEach(s => s.classList.remove("piece-active"));
|
|
329
|
+
section.classList.add("piece-active");
|
|
330
|
+
setTimeout(() => section.classList.remove("piece-active"), 900);
|
|
331
|
+
if (scroll) section.scrollIntoView({ behavior: "smooth", block: "start" });
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const help = document.getElementById("help-overlay");
|
|
335
|
+
function toggleHelp(show) { help.classList.toggle("hidden", show === false ? true : show === true ? false : undefined); }
|
|
336
|
+
document.querySelector("[data-help-close]").addEventListener("click", () => help.classList.add("hidden"));
|
|
337
|
+
help.addEventListener("click", e => { if (e.target === help) help.classList.add("hidden"); });
|
|
338
|
+
|
|
339
|
+
document.addEventListener("keydown", e => {
|
|
340
|
+
const typing = ["TEXTAREA", "INPUT"].includes(document.activeElement.tagName);
|
|
341
|
+
if (e.key === "Escape") {
|
|
342
|
+
if (!help.classList.contains("hidden")) return help.classList.add("hidden");
|
|
343
|
+
if (typing) document.activeElement.blur();
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
if (typing || e.metaKey || e.ctrlKey || e.altKey) return;
|
|
347
|
+
|
|
348
|
+
switch (e.key) {
|
|
349
|
+
case "j": e.preventDefault(); focusPiece(current + 1); break;
|
|
350
|
+
case "k": e.preventDefault(); focusPiece(current - 1); break;
|
|
351
|
+
case "c": {
|
|
352
|
+
e.preventDefault();
|
|
353
|
+
const box = sections[current].querySelector("[data-comment]");
|
|
354
|
+
box.focus();
|
|
355
|
+
box.setSelectionRange(box.value.length, box.value.length);
|
|
356
|
+
break;
|
|
357
|
+
}
|
|
358
|
+
case "g": {
|
|
359
|
+
e.preventDefault();
|
|
360
|
+
sections[current].querySelector("[data-review]").click();
|
|
361
|
+
break;
|
|
362
|
+
}
|
|
363
|
+
case "?": e.preventDefault(); help.classList.toggle("hidden"); break;
|
|
364
|
+
}
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
// --- server lifecycle -----------------------------------------------------
|
|
368
|
+
let heartbeat = setInterval(() => fetch("/heartbeat", { method: "POST", keepalive: true }), 5000);
|
|
369
|
+
fetch("/heartbeat", { method: "POST" });
|
|
370
|
+
document.addEventListener("visibilitychange", () => {
|
|
371
|
+
if (!document.hidden) fetch("/heartbeat", { method: "POST" });
|
|
372
|
+
});
|
|
373
|
+
window.addEventListener("pagehide", () => {
|
|
374
|
+
// Flush any comment still inside its debounce window, then say goodbye.
|
|
375
|
+
for (const id of Object.keys(timers)) beaconSave(id);
|
|
376
|
+
navigator.sendBeacon("/bye");
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
document.getElementById("done-btn").addEventListener("click", async () => {
|
|
380
|
+
let unreviewed = sections.filter(s => statusOf(s) === "unreviewed").length;
|
|
381
|
+
if (unreviewed > 0 && !confirm(`${unreviewed} piece(s) not yet reviewed. Finish anyway?`)) return;
|
|
382
|
+
clearInterval(heartbeat);
|
|
383
|
+
// Fire any debounced-but-unsent comment saves now, then wait for every save
|
|
384
|
+
// to land before /done writes the export — otherwise a comment still in
|
|
385
|
+
// flight (debounce timer or blur) can be missing from export.md.
|
|
386
|
+
for (const id of Object.keys(timers)) { clearTimeout(timers[id]); delete timers[id]; save(id); }
|
|
387
|
+
await Promise.allSettled([...inflight]);
|
|
388
|
+
await fetch("/done", { method: "POST" });
|
|
389
|
+
document.getElementById("done-overlay").classList.remove("hidden");
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
updateCounts();
|
|
393
|
+
</script>
|
|
394
|
+
</body>
|
|
395
|
+
</html>
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "hunk_review_changes/version"
|
|
4
|
+
require_relative "hunk_review_changes/markdown"
|
|
5
|
+
require_relative "hunk_review_changes/diff"
|
|
6
|
+
require_relative "hunk_review_changes/bundle"
|
|
7
|
+
require_relative "hunk_review_changes/state"
|
|
8
|
+
require_relative "hunk_review_changes/export"
|
|
9
|
+
require_relative "hunk_review_changes/lifecycle"
|
|
10
|
+
|
|
11
|
+
module HunkReviewChanges
|
|
12
|
+
# The git repo that hosts the companion skill as a Claude Code plugin marketplace and
|
|
13
|
+
# the source of truth for the skill across every agent the installer supports.
|
|
14
|
+
MARKETPLACE_REPO = "https://github.com/lucianghinda/hunk-review-changes-skills"
|
|
15
|
+
|
|
16
|
+
# Names used across the marketplace manifest and the installed skill folder.
|
|
17
|
+
MARKETPLACE_NAME = "hunk-review-changes-skills"
|
|
18
|
+
PLUGIN_NAME = "hunk-review-changes"
|
|
19
|
+
SKILL_NAME = "hunk-review-changes"
|
|
20
|
+
|
|
21
|
+
autoload :App, "hunk_review_changes/app"
|
|
22
|
+
autoload :Server, "hunk_review_changes/server"
|
|
23
|
+
autoload :CLI, "hunk_review_changes/cli"
|
|
24
|
+
autoload :Assets, "hunk_review_changes/assets"
|
|
25
|
+
|
|
26
|
+
module Installer
|
|
27
|
+
autoload :Base, "hunk_review_changes/installer/base"
|
|
28
|
+
autoload :ClaudeCode, "hunk_review_changes/installer/claude_code"
|
|
29
|
+
autoload :Codex, "hunk_review_changes/installer/codex"
|
|
30
|
+
autoload :Cursor, "hunk_review_changes/installer/cursor"
|
|
31
|
+
autoload :OpenCode, "hunk_review_changes/installer/opencode"
|
|
32
|
+
autoload :Runner, "hunk_review_changes/installer/runner"
|
|
33
|
+
autoload :SkillSource, "hunk_review_changes/installer/skill_source"
|
|
34
|
+
end
|
|
35
|
+
end
|