draftwatch 0.1.0__py3-none-any.whl
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.
- draftwatch/__init__.py +9 -0
- draftwatch/__main__.py +6 -0
- draftwatch/app.py +3208 -0
- draftwatch/assets/codemirror.js +28 -0
- draftwatch/assets/marked.js +6 -0
- draftwatch/assets/purify.js +3 -0
- draftwatch/assets/turndown.js +800 -0
- draftwatch-0.1.0.dist-info/METADATA +112 -0
- draftwatch-0.1.0.dist-info/RECORD +13 -0
- draftwatch-0.1.0.dist-info/WHEEL +5 -0
- draftwatch-0.1.0.dist-info/entry_points.txt +2 -0
- draftwatch-0.1.0.dist-info/licenses/LICENSE +21 -0
- draftwatch-0.1.0.dist-info/top_level.txt +1 -0
draftwatch/app.py
ADDED
|
@@ -0,0 +1,3208 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""draftwatch — watch a single .md file and review an agent's edits as a real git diff.
|
|
3
|
+
|
|
4
|
+
A read-and-review tool for writers whose .md files are being edited by an
|
|
5
|
+
autonomous AI agent. It shows the exact, git-backed diff of what changed and lets
|
|
6
|
+
the writer keep or revert the agent's changes hunk by hunk, or write into the file directly.
|
|
7
|
+
|
|
8
|
+
The diff is always real `git diff --word-diff`, never a JS/Python approximation.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import hashlib
|
|
13
|
+
import html
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import queue
|
|
17
|
+
import secrets
|
|
18
|
+
import subprocess
|
|
19
|
+
import sys
|
|
20
|
+
import threading
|
|
21
|
+
import time
|
|
22
|
+
import webbrowser
|
|
23
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
24
|
+
from urllib.parse import parse_qs
|
|
25
|
+
|
|
26
|
+
# Single source of truth for the version and the date of the latest release.
|
|
27
|
+
# __init__.py re-exports __version__; the About panel shows both (injected into
|
|
28
|
+
# the served page from these constants).
|
|
29
|
+
__version__ = "0.1.0"
|
|
30
|
+
RELEASE_DATE = "2026-07-02"
|
|
31
|
+
|
|
32
|
+
# --------------------------------------------------------------------------- #
|
|
33
|
+
# git helpers
|
|
34
|
+
# --------------------------------------------------------------------------- #
|
|
35
|
+
|
|
36
|
+
def run_git(args, cwd):
|
|
37
|
+
"""Run a git command with an argument list (never shell=True). Returns (rc, out, err)."""
|
|
38
|
+
p = subprocess.run(
|
|
39
|
+
["git"] + args,
|
|
40
|
+
cwd=cwd,
|
|
41
|
+
stdout=subprocess.PIPE,
|
|
42
|
+
stderr=subprocess.PIPE,
|
|
43
|
+
text=True,
|
|
44
|
+
)
|
|
45
|
+
return p.returncode, p.stdout, p.stderr
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def git_toplevel(cwd):
|
|
49
|
+
rc, out, _ = run_git(["rev-parse", "--show-toplevel"], cwd)
|
|
50
|
+
if rc != 0:
|
|
51
|
+
return None
|
|
52
|
+
return os.path.realpath(out.strip())
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def is_inside_work_tree(cwd):
|
|
56
|
+
rc, out, _ = run_git(["rev-parse", "--is-inside-work-tree"], cwd)
|
|
57
|
+
return rc == 0 and out.strip() == "true"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def is_tracked(root, relpath):
|
|
61
|
+
rc, _, _ = run_git(["ls-files", "--error-unmatch", "--", relpath], root)
|
|
62
|
+
return rc == 0
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def head_label(root):
|
|
66
|
+
"""Return (short_sha, relative_date) for HEAD, or (None, None) if no commits."""
|
|
67
|
+
rc, out, _ = run_git(["log", "-1", "--format=%h%x09%cr"], root)
|
|
68
|
+
if rc != 0 or not out.strip():
|
|
69
|
+
return None, None
|
|
70
|
+
parts = out.strip().split("\t")
|
|
71
|
+
if len(parts) == 2:
|
|
72
|
+
return parts[0], parts[1]
|
|
73
|
+
return out.strip(), ""
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def commit_list(root, relpath):
|
|
77
|
+
"""Recent commits touching the file: list of {ref, label}."""
|
|
78
|
+
rc, out, _ = run_git(
|
|
79
|
+
["log", "-n", "30", "--format=%H%x09%h%x09%cr%x09%s", "--", relpath], root
|
|
80
|
+
)
|
|
81
|
+
commits = []
|
|
82
|
+
if rc != 0:
|
|
83
|
+
return commits
|
|
84
|
+
for line in out.splitlines():
|
|
85
|
+
if not line.strip():
|
|
86
|
+
continue
|
|
87
|
+
parts = line.split("\t")
|
|
88
|
+
if len(parts) < 4:
|
|
89
|
+
continue
|
|
90
|
+
full, short, reldate, subject = parts[0], parts[1], parts[2], parts[3]
|
|
91
|
+
subject = subject.strip()
|
|
92
|
+
if len(subject) > 48:
|
|
93
|
+
subject = subject[:47] + "…"
|
|
94
|
+
label = "{} · {} · {}".format(short, reldate, subject)
|
|
95
|
+
commits.append({"ref": full, "label": label})
|
|
96
|
+
return commits
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def push_ref(root):
|
|
100
|
+
"""Resolve the 'last pushed' baseline: the remote-tracking ref the current branch
|
|
101
|
+
pushes to. Returns (refname, short_sha, reldate), e.g. ("origin/main", "1a2b3c4",
|
|
102
|
+
"3 hours ago"), or (None, None, None) when there is no push/upstream target (no
|
|
103
|
+
remote, detached HEAD, or the branch was never pushed/fetched).
|
|
104
|
+
|
|
105
|
+
`@{push}` is the push destination, which is the right notion for triangular
|
|
106
|
+
workflows; git falls back to `@{upstream}` automatically when no separate push
|
|
107
|
+
target is configured. We also try `@{upstream}` explicitly as a second chance.
|
|
108
|
+
The remote-tracking ref this resolves to (e.g. origin/main) advances on both push
|
|
109
|
+
and fetch, so it reflects what the remote currently holds — git keeps no record of
|
|
110
|
+
push events themselves, so this ref is the standard proxy for "last pushed."
|
|
111
|
+
"""
|
|
112
|
+
for spec in ("@{push}", "@{upstream}"):
|
|
113
|
+
rc, name, _ = run_git(["rev-parse", "--abbrev-ref", spec], root)
|
|
114
|
+
name = name.strip()
|
|
115
|
+
if rc != 0 or not name:
|
|
116
|
+
continue
|
|
117
|
+
# confirm the remote-tracking ref actually exists locally (it may not if the
|
|
118
|
+
# branch has an upstream configured but was never pushed/fetched)
|
|
119
|
+
rc, out, _ = run_git(["log", "-1", "--format=%h%x09%cr", name], root)
|
|
120
|
+
if rc != 0 or not out.strip():
|
|
121
|
+
continue
|
|
122
|
+
parts = out.strip().split("\t")
|
|
123
|
+
short = parts[0]
|
|
124
|
+
reldate = parts[1] if len(parts) > 1 else ""
|
|
125
|
+
return name, short, reldate
|
|
126
|
+
return None, None, None
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def git_diff_text(root, baseline, relpath, abspath):
|
|
130
|
+
"""Run the appropriate word-diff command. Returns diff text ('' if no diff).
|
|
131
|
+
|
|
132
|
+
Uses `--word-diff=porcelain`, the machine-readable variant: every token sits on
|
|
133
|
+
its own line prefixed with `+`/`-`/` ` (space), and the one-character line `~`
|
|
134
|
+
marks a newline. Unlike plain `--word-diff`, source text can never be mistaken
|
|
135
|
+
for markup — a source line always carries a prefix, so literal `[-`/`{+`
|
|
136
|
+
sequences in unchanged text parse correctly. `--no-color` guards against a
|
|
137
|
+
user's color.ui=always config leaking ANSI codes into the output we parse.
|
|
138
|
+
|
|
139
|
+
Baselines are always git versions:
|
|
140
|
+
"head" -> diff against HEAD (last local commit)
|
|
141
|
+
"push" -> diff against the remote-tracking ref the branch pushes to
|
|
142
|
+
(e.g. origin/main); shows everything changed since the last push
|
|
143
|
+
"commit" -> diff against a specific commit ref
|
|
144
|
+
"empty" -> file is not committed yet; diff against git's empty tree so the
|
|
145
|
+
whole file reads as new. Implemented with `--no-index` against
|
|
146
|
+
os.devnull because an untracked path never appears in
|
|
147
|
+
`git diff <tree> -- path`.
|
|
148
|
+
|
|
149
|
+
git diff returns exit code 1 when differences exist; 0 and 1 are both success,
|
|
150
|
+
any other code is an error.
|
|
151
|
+
"""
|
|
152
|
+
kind = baseline["kind"]
|
|
153
|
+
if kind == "head":
|
|
154
|
+
args = ["diff", "-U1000000", "--no-color", "--word-diff=porcelain", "HEAD", "--", relpath]
|
|
155
|
+
rc, out, err = run_git(args, root)
|
|
156
|
+
elif kind in ("commit", "push"):
|
|
157
|
+
# both diff against a named ref (a commit sha, or a remote-tracking ref)
|
|
158
|
+
args = ["diff", "-U1000000", "--no-color", "--word-diff=porcelain", baseline["ref"], "--", relpath]
|
|
159
|
+
rc, out, err = run_git(args, root)
|
|
160
|
+
elif kind == "empty":
|
|
161
|
+
args = ["diff", "-U1000000", "--no-index", "--no-color", "--word-diff=porcelain", os.devnull, abspath]
|
|
162
|
+
rc, out, err = run_git(args, root)
|
|
163
|
+
else:
|
|
164
|
+
raise ValueError("unknown baseline kind: %r" % kind)
|
|
165
|
+
|
|
166
|
+
if rc not in (0, 1):
|
|
167
|
+
raise RuntimeError("git diff failed (rc=%d): %s" % (rc, err.strip()))
|
|
168
|
+
return out
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def resolve_target(root, path):
|
|
172
|
+
"""Resolve a target path to (abspath, relpath) inside the repo, or raise ValueError.
|
|
173
|
+
|
|
174
|
+
Relative paths resolve against the repo root (the file picker sends repo-relative
|
|
175
|
+
paths); absolute paths are used as-is. Symlinks are resolved and the result must
|
|
176
|
+
live inside the repo root (no escape).
|
|
177
|
+
"""
|
|
178
|
+
if not path or not path.strip():
|
|
179
|
+
raise ValueError("no path given")
|
|
180
|
+
p = path.strip()
|
|
181
|
+
cand = os.path.realpath(p if os.path.isabs(p) else os.path.join(root, p))
|
|
182
|
+
if not os.path.exists(cand):
|
|
183
|
+
raise ValueError("file does not exist: %s" % path)
|
|
184
|
+
if os.path.isdir(cand):
|
|
185
|
+
raise ValueError("path is a directory, not a file: %s" % path)
|
|
186
|
+
root_prefix = root.rstrip(os.sep) + os.sep
|
|
187
|
+
if not (cand == root or cand.startswith(root_prefix)):
|
|
188
|
+
raise ValueError("refusing a path outside the repository")
|
|
189
|
+
return cand, os.path.relpath(cand, root)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def resolve_new_file(root, path):
|
|
193
|
+
"""Resolve a path for a NEW file inside the repo, or raise ValueError.
|
|
194
|
+
|
|
195
|
+
Same containment rules as resolve_target (resolved path must live inside the
|
|
196
|
+
repo root), but the file must NOT already exist — creating over an existing
|
|
197
|
+
file would silently clobber it; the picker is the way to open one.
|
|
198
|
+
"""
|
|
199
|
+
if not path or not path.strip():
|
|
200
|
+
raise ValueError("no filename given")
|
|
201
|
+
p = path.strip()
|
|
202
|
+
cand = os.path.realpath(p if os.path.isabs(p) else os.path.join(root, p))
|
|
203
|
+
root_prefix = root.rstrip(os.sep) + os.sep
|
|
204
|
+
if not cand.startswith(root_prefix):
|
|
205
|
+
raise ValueError("refusing a path outside the repository")
|
|
206
|
+
if os.path.isdir(cand):
|
|
207
|
+
raise ValueError("path is a directory, not a file: %s" % path)
|
|
208
|
+
if os.path.exists(cand):
|
|
209
|
+
raise ValueError("file already exists: %s — pick it with the file control instead" % path)
|
|
210
|
+
return cand
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def list_repo_files(root, limit=5000):
|
|
214
|
+
"""Repo files for the picker: tracked + non-ignored untracked, sorted, deduped."""
|
|
215
|
+
files = []
|
|
216
|
+
rc, out, _ = run_git(["ls-files"], root)
|
|
217
|
+
if rc == 0:
|
|
218
|
+
files.extend(out.splitlines())
|
|
219
|
+
rc, out, _ = run_git(["ls-files", "--others", "--exclude-standard"], root)
|
|
220
|
+
if rc == 0:
|
|
221
|
+
files.extend(out.splitlines())
|
|
222
|
+
seen = set()
|
|
223
|
+
result = []
|
|
224
|
+
for f in sorted(files):
|
|
225
|
+
if not f or f in seen:
|
|
226
|
+
continue
|
|
227
|
+
seen.add(f)
|
|
228
|
+
result.append(f)
|
|
229
|
+
if len(result) >= limit:
|
|
230
|
+
break
|
|
231
|
+
return result
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
# --------------------------------------------------------------------------- #
|
|
235
|
+
# word-diff parser (`--word-diff=porcelain`: one token per line, `~` = newline)
|
|
236
|
+
# --------------------------------------------------------------------------- #
|
|
237
|
+
|
|
238
|
+
def parse_word_diff(text):
|
|
239
|
+
"""Parse `git diff --word-diff=porcelain` output into typed segments + hunk ids.
|
|
240
|
+
|
|
241
|
+
Segment shapes (unchanged from the original plain-word-diff parser; the
|
|
242
|
+
reconstruction engine and the whole frontend consume this schema as-is):
|
|
243
|
+
{"type": "context", "text": s}
|
|
244
|
+
{"type": "add", "text": s, "hunk": id}
|
|
245
|
+
{"type": "del", "text": s, "hunk": id}
|
|
246
|
+
{"type": "newline", "text": "\\n", "side": "common"|"add"|"del", "hunk": id|None}
|
|
247
|
+
|
|
248
|
+
Porcelain format: after the `@@` hunk header, every body line is one of
|
|
249
|
+
` text` (space prefix) an unchanged token
|
|
250
|
+
`-text` a removed token
|
|
251
|
+
`+text` an added token
|
|
252
|
+
`~` end of a display line (a newline)
|
|
253
|
+
Because every source token carries a prefix and the newline marker is exactly
|
|
254
|
+
the one-character line `~`, source text can never be mistaken for markup —
|
|
255
|
+
literal `[-`/`{+` sequences in unchanged prose parse correctly (the defining
|
|
256
|
+
fix over plain `--word-diff`).
|
|
257
|
+
|
|
258
|
+
A hunk is a maximal run of consecutive add/del tokens not interrupted by a
|
|
259
|
+
context token or a `~` (= end of display line). Each add/del token is tagged
|
|
260
|
+
with its hunk id.
|
|
261
|
+
|
|
262
|
+
NEWLINE SIDING (composition rule, identical to the plain-format parser and
|
|
263
|
+
empirically re-confirmed against porcelain output):
|
|
264
|
+
a display line's trailing newline depends on the line's token composition:
|
|
265
|
+
- the line has any context token -> "common" (line in both)
|
|
266
|
+
- purely added (+ tokens only) -> "add" (emit on accept)
|
|
267
|
+
- purely deleted (- tokens only) -> "del" (emit on reject)
|
|
268
|
+
- full replacement (add+del, no context) -> "common" (both end in \\n)
|
|
269
|
+
- a bare `~` (blank line; ambiguous) -> "common" (documented limit;
|
|
270
|
+
porcelain emits identical bodies for a pure blank-line insert and delete,
|
|
271
|
+
distinguishable only by the @@ header counts, so the ambiguity survives)
|
|
272
|
+
A side-add/del newline carries the hunk id of the add/del run on its line so it
|
|
273
|
+
follows that hunk's accept/reject decision during reconstruction.
|
|
274
|
+
"""
|
|
275
|
+
segments = []
|
|
276
|
+
lines = text.split("\n")
|
|
277
|
+
|
|
278
|
+
# Find the body: everything after the first "@@" hunk header. If there is no
|
|
279
|
+
# "@@" line at all (empty diff), there are no segments. Everything before it
|
|
280
|
+
# (diff --git / index / --- / +++ headers) is skipped, so the `+++ b/...`
|
|
281
|
+
# header can't be mistaken for an added token.
|
|
282
|
+
start = None
|
|
283
|
+
for i, line in enumerate(lines):
|
|
284
|
+
if line.startswith("@@"):
|
|
285
|
+
start = i + 1
|
|
286
|
+
break
|
|
287
|
+
if start is None:
|
|
288
|
+
return segments, []
|
|
289
|
+
|
|
290
|
+
body = lines[start:]
|
|
291
|
+
# git terminates the output with a trailing newline -> split() yields a final
|
|
292
|
+
# "" artifact. Drop exactly that one. (Interior "" lines do not occur in
|
|
293
|
+
# porcelain: even an empty token would carry its prefix character.)
|
|
294
|
+
if body and body[-1] == "":
|
|
295
|
+
body = body[:-1]
|
|
296
|
+
|
|
297
|
+
next_hunk = 0
|
|
298
|
+
cur_hunk = None # hunk id of the current add/del run
|
|
299
|
+
has_context = has_add = has_del = False
|
|
300
|
+
addel_hunk = None
|
|
301
|
+
|
|
302
|
+
for raw in body:
|
|
303
|
+
if raw == "~":
|
|
304
|
+
# end of display line: emit the newline segment, sided by composition
|
|
305
|
+
if has_context:
|
|
306
|
+
side, hunk = "common", None
|
|
307
|
+
elif has_add and not has_del:
|
|
308
|
+
side, hunk = "add", addel_hunk
|
|
309
|
+
elif has_del and not has_add:
|
|
310
|
+
side, hunk = "del", addel_hunk
|
|
311
|
+
else:
|
|
312
|
+
# full-line replacement (add+del, no context) or blank line -> common
|
|
313
|
+
side, hunk = "common", None
|
|
314
|
+
segments.append({"type": "newline", "text": "\n", "side": side, "hunk": hunk})
|
|
315
|
+
cur_hunk = None
|
|
316
|
+
has_context = has_add = has_del = False
|
|
317
|
+
addel_hunk = None
|
|
318
|
+
continue
|
|
319
|
+
if raw.startswith("@@"): # extra hunk header (rare with -U1000000)
|
|
320
|
+
continue
|
|
321
|
+
if raw.startswith("\\"): # ""
|
|
322
|
+
continue
|
|
323
|
+
if not raw: # defensive: stray empty line
|
|
324
|
+
continue
|
|
325
|
+
|
|
326
|
+
prefix, tok = raw[0], raw[1:]
|
|
327
|
+
if prefix == " ":
|
|
328
|
+
segments.append({"type": "context", "text": tok})
|
|
329
|
+
cur_hunk = None
|
|
330
|
+
has_context = True
|
|
331
|
+
elif prefix == "+":
|
|
332
|
+
if cur_hunk is None:
|
|
333
|
+
cur_hunk = next_hunk
|
|
334
|
+
next_hunk += 1
|
|
335
|
+
segments.append({"type": "add", "text": tok, "hunk": cur_hunk})
|
|
336
|
+
has_add = True
|
|
337
|
+
addel_hunk = cur_hunk
|
|
338
|
+
elif prefix == "-":
|
|
339
|
+
if cur_hunk is None:
|
|
340
|
+
cur_hunk = next_hunk
|
|
341
|
+
next_hunk += 1
|
|
342
|
+
segments.append({"type": "del", "text": tok, "hunk": cur_hunk})
|
|
343
|
+
has_del = True
|
|
344
|
+
addel_hunk = cur_hunk
|
|
345
|
+
# any other prefix is outside the porcelain vocabulary; ignore it
|
|
346
|
+
|
|
347
|
+
return _assign_content_ids(segments)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _assign_content_ids(segments):
|
|
351
|
+
"""Replace positional hunk ids with content-addressed string ids.
|
|
352
|
+
|
|
353
|
+
id = short SHA-1 of (deleted text, added text, surrounding working-text
|
|
354
|
+
context window, occurrence index for identical changes). Content addressing
|
|
355
|
+
keeps a hunk's id stable when *other* parts of the document change, so the
|
|
356
|
+
client's review decisions survive an agent save mid-review. (Positional ids
|
|
357
|
+
forced the client to reset all decisions on every SSE payload — an agent
|
|
358
|
+
save silently wiped every revert mark.)
|
|
359
|
+
|
|
360
|
+
Returns (segments, hunks) with hunks in document order (the client relies
|
|
361
|
+
on this order for next/prev navigation and the change map).
|
|
362
|
+
"""
|
|
363
|
+
WINDOW = 32 # chars of surrounding working text folded into the hash
|
|
364
|
+
|
|
365
|
+
# per-hunk token texts + first/last segment index, in document order
|
|
366
|
+
order = []
|
|
367
|
+
info = {}
|
|
368
|
+
for idx, s in enumerate(segments):
|
|
369
|
+
if s["type"] not in ("add", "del"):
|
|
370
|
+
continue
|
|
371
|
+
h = s["hunk"]
|
|
372
|
+
if h not in info:
|
|
373
|
+
info[h] = {"del": [], "add": [], "first": idx, "last": idx}
|
|
374
|
+
order.append(h)
|
|
375
|
+
info[h]["last"] = idx
|
|
376
|
+
info[h][s["type"]].append(s["text"])
|
|
377
|
+
|
|
378
|
+
def working_char(s):
|
|
379
|
+
"""The text a segment contributes to the working (accept) side."""
|
|
380
|
+
t = s["type"]
|
|
381
|
+
if t == "context":
|
|
382
|
+
return s["text"]
|
|
383
|
+
if t == "add":
|
|
384
|
+
return s["text"]
|
|
385
|
+
if t == "newline" and s["side"] in ("common", "add"):
|
|
386
|
+
return "\n"
|
|
387
|
+
return ""
|
|
388
|
+
|
|
389
|
+
mapping = {}
|
|
390
|
+
seen = {}
|
|
391
|
+
for h in order:
|
|
392
|
+
rec = info[h]
|
|
393
|
+
dtext = "".join(rec["del"])
|
|
394
|
+
atext = "".join(rec["add"])
|
|
395
|
+
# context window: working-side text immediately before and after the hunk
|
|
396
|
+
before = []
|
|
397
|
+
need = WINDOW
|
|
398
|
+
for i in range(rec["first"] - 1, -1, -1):
|
|
399
|
+
if need <= 0:
|
|
400
|
+
break
|
|
401
|
+
if segments[i].get("hunk") == h:
|
|
402
|
+
continue
|
|
403
|
+
piece = working_char(segments[i])
|
|
404
|
+
if piece:
|
|
405
|
+
before.append(piece[-need:])
|
|
406
|
+
need -= len(piece[-need:])
|
|
407
|
+
before = "".join(reversed(before))
|
|
408
|
+
after = []
|
|
409
|
+
need = WINDOW
|
|
410
|
+
for i in range(rec["last"] + 1, len(segments)):
|
|
411
|
+
if need <= 0:
|
|
412
|
+
break
|
|
413
|
+
if segments[i].get("hunk") == h:
|
|
414
|
+
continue
|
|
415
|
+
piece = working_char(segments[i])
|
|
416
|
+
if piece:
|
|
417
|
+
after.append(piece[:need])
|
|
418
|
+
need -= len(piece[:need])
|
|
419
|
+
after = "".join(after)
|
|
420
|
+
|
|
421
|
+
key = (dtext, atext, before, after)
|
|
422
|
+
occ = seen.get(key, 0)
|
|
423
|
+
seen[key] = occ + 1
|
|
424
|
+
raw = "\x00".join((dtext, atext, before, after, str(occ)))
|
|
425
|
+
mapping[h] = hashlib.sha1(raw.encode("utf-8")).hexdigest()[:10]
|
|
426
|
+
|
|
427
|
+
for s in segments:
|
|
428
|
+
if s.get("hunk") is not None:
|
|
429
|
+
s["hunk"] = mapping.get(s["hunk"], s["hunk"])
|
|
430
|
+
|
|
431
|
+
return segments, [mapping[h] for h in order]
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
# --------------------------------------------------------------------------- #
|
|
435
|
+
# reconstruction (see spec section 7)
|
|
436
|
+
# --------------------------------------------------------------------------- #
|
|
437
|
+
|
|
438
|
+
def _decide(hunk_id, decisions, pending_is_accept):
|
|
439
|
+
d = decisions.get(str(hunk_id))
|
|
440
|
+
if d in ("accept", "reject"):
|
|
441
|
+
return d
|
|
442
|
+
return "accept" if pending_is_accept else "reject"
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def reconstruct(segments, decisions, pending_is_accept=True):
|
|
446
|
+
"""Walk segments and produce file contents for the given accept/reject decisions.
|
|
447
|
+
|
|
448
|
+
decisions: {str(hunk_id): "accept"|"reject"}. Hunks not present are treated as
|
|
449
|
+
pending; pending defaults to "accept" (keep what the agent did) unless
|
|
450
|
+
pending_is_accept=False.
|
|
451
|
+
|
|
452
|
+
Invariants (verified by the test harness):
|
|
453
|
+
accept-all reconstructs the working file
|
|
454
|
+
reject-all reconstructs the baseline file
|
|
455
|
+
"""
|
|
456
|
+
out = []
|
|
457
|
+
for s in segments:
|
|
458
|
+
t = s["type"]
|
|
459
|
+
if t == "context":
|
|
460
|
+
out.append(s["text"])
|
|
461
|
+
elif t == "newline":
|
|
462
|
+
side = s["side"]
|
|
463
|
+
if side == "common":
|
|
464
|
+
out.append("\n")
|
|
465
|
+
elif side == "add":
|
|
466
|
+
if _decide(s["hunk"], decisions, pending_is_accept) == "accept":
|
|
467
|
+
out.append("\n")
|
|
468
|
+
elif side == "del":
|
|
469
|
+
if _decide(s["hunk"], decisions, pending_is_accept) == "reject":
|
|
470
|
+
out.append("\n")
|
|
471
|
+
elif t == "add":
|
|
472
|
+
if _decide(s["hunk"], decisions, pending_is_accept) == "accept":
|
|
473
|
+
out.append(s["text"])
|
|
474
|
+
elif t == "del":
|
|
475
|
+
if _decide(s["hunk"], decisions, pending_is_accept) == "reject":
|
|
476
|
+
out.append(s["text"])
|
|
477
|
+
return "".join(out)
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
# --------------------------------------------------------------------------- #
|
|
481
|
+
# file io
|
|
482
|
+
# --------------------------------------------------------------------------- #
|
|
483
|
+
|
|
484
|
+
def read_text(path):
|
|
485
|
+
"""Read file as text, preserving newlines. Retries once on failure (mid-save)."""
|
|
486
|
+
for attempt in range(2):
|
|
487
|
+
try:
|
|
488
|
+
with open(path, "r", encoding="utf-8", errors="replace", newline="") as f:
|
|
489
|
+
return f.read()
|
|
490
|
+
except OSError:
|
|
491
|
+
if attempt == 0:
|
|
492
|
+
time.sleep(0.05)
|
|
493
|
+
continue
|
|
494
|
+
raise
|
|
495
|
+
return ""
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def write_file(abspath, new_text):
|
|
499
|
+
"""Write new_text to the file verbatim. No backup is kept; recovery of
|
|
500
|
+
overwritten content is via git. Writes are always explicit (a button) and
|
|
501
|
+
never auto-commit."""
|
|
502
|
+
with open(abspath, "w", encoding="utf-8", newline="") as f:
|
|
503
|
+
f.write(new_text)
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def file_mtime(path):
|
|
507
|
+
try:
|
|
508
|
+
return os.path.getmtime(path)
|
|
509
|
+
except OSError:
|
|
510
|
+
return 0.0
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
# --------------------------------------------------------------------------- #
|
|
514
|
+
# shared state
|
|
515
|
+
# --------------------------------------------------------------------------- #
|
|
516
|
+
|
|
517
|
+
class State:
|
|
518
|
+
def __init__(self, root, relpath=None, abspath=None):
|
|
519
|
+
self.root = root
|
|
520
|
+
self.relpath = relpath # None until a file is opened
|
|
521
|
+
self.abspath = abspath # None until a file is opened
|
|
522
|
+
self.lock = threading.RLock()
|
|
523
|
+
# baseline: {"kind": "head"|"commit"|"empty", "ref": ..., "label": ...}
|
|
524
|
+
self.baseline = {"kind": "head", "ref": "HEAD", "label": "HEAD"}
|
|
525
|
+
self.untracked = False # file not committed; forces the empty-tree baseline
|
|
526
|
+
self.last_mtime = file_mtime(abspath) if abspath else 0.0
|
|
527
|
+
self.client_dirty = False
|
|
528
|
+
# SSE subscribers
|
|
529
|
+
self.subscribers = [] # list of queue.Queue
|
|
530
|
+
|
|
531
|
+
def has_file(self):
|
|
532
|
+
return self.abspath is not None
|
|
533
|
+
|
|
534
|
+
# -- subscriber management --
|
|
535
|
+
def subscribe(self):
|
|
536
|
+
q = queue.Queue()
|
|
537
|
+
with self.lock:
|
|
538
|
+
self.subscribers.append(q)
|
|
539
|
+
return q
|
|
540
|
+
|
|
541
|
+
def unsubscribe(self, q):
|
|
542
|
+
with self.lock:
|
|
543
|
+
if q in self.subscribers:
|
|
544
|
+
self.subscribers.remove(q)
|
|
545
|
+
|
|
546
|
+
def broadcast(self, event):
|
|
547
|
+
data = json.dumps(event)
|
|
548
|
+
with self.lock:
|
|
549
|
+
subs = list(self.subscribers)
|
|
550
|
+
for q in subs:
|
|
551
|
+
try:
|
|
552
|
+
q.put_nowait(data)
|
|
553
|
+
except Exception:
|
|
554
|
+
pass
|
|
555
|
+
|
|
556
|
+
# -- open / switch the watched file --
|
|
557
|
+
def open_file(self, path):
|
|
558
|
+
"""Switch to watching `path` (resolved inside the repo). Raises ValueError on a
|
|
559
|
+
bad path. The baseline is always a git version: HEAD when the file is
|
|
560
|
+
committed, otherwise the empty tree (whole file reads as new). Resets the
|
|
561
|
+
baseline and mtime."""
|
|
562
|
+
abspath, relpath = resolve_target(self.root, path) # may raise ValueError
|
|
563
|
+
tracked = is_tracked(self.root, relpath)
|
|
564
|
+
short, reldate = head_label(self.root)
|
|
565
|
+
no_head = (not tracked) or (short is None)
|
|
566
|
+
with self.lock:
|
|
567
|
+
self.relpath = relpath
|
|
568
|
+
self.abspath = abspath
|
|
569
|
+
self.untracked = no_head
|
|
570
|
+
self.last_mtime = file_mtime(abspath)
|
|
571
|
+
if no_head:
|
|
572
|
+
self.baseline = {"kind": "empty", "ref": "empty",
|
|
573
|
+
"label": "empty tree · file not yet committed"}
|
|
574
|
+
else:
|
|
575
|
+
# Default to "last push" (the remote-tracking ref) so the diff shows
|
|
576
|
+
# everything not yet pushed. Fall back to HEAD when the branch has no
|
|
577
|
+
# push/upstream target (no remote, detached HEAD, never pushed).
|
|
578
|
+
pname, pshort, preldate = push_ref(self.root)
|
|
579
|
+
if pname:
|
|
580
|
+
self.baseline = {"kind": "push", "ref": pname,
|
|
581
|
+
"label": "last push · {} · {}".format(pname, preldate or "")}
|
|
582
|
+
else:
|
|
583
|
+
self.baseline = {"kind": "head", "ref": "HEAD",
|
|
584
|
+
"label": "HEAD · {}".format(reldate or "")}
|
|
585
|
+
return relpath
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
# --------------------------------------------------------------------------- #
|
|
589
|
+
# diff engine
|
|
590
|
+
# --------------------------------------------------------------------------- #
|
|
591
|
+
|
|
592
|
+
def compute_diff_epoch(root, baseline, raw_text):
|
|
593
|
+
"""Fingerprint of the exact inputs a rendered diff was computed from: the
|
|
594
|
+
working file content plus the baseline (kind, ref, and the commit the ref
|
|
595
|
+
currently resolves to — "HEAD" moves after a commit). The client echoes
|
|
596
|
+
this on /api/apply so the server can refuse to apply decisions that were
|
|
597
|
+
made against a diff that no longer reflects reality (e.g. the agent wrote
|
|
598
|
+
to disk during the SSE debounce window)."""
|
|
599
|
+
if baseline["kind"] == "empty":
|
|
600
|
+
resolved = "empty"
|
|
601
|
+
else:
|
|
602
|
+
rc, out, _ = run_git(["rev-parse", baseline["ref"]], root)
|
|
603
|
+
resolved = out.strip() if rc == 0 else baseline["ref"]
|
|
604
|
+
raw = "\x00".join((raw_text, baseline["kind"], baseline.get("ref", ""), resolved))
|
|
605
|
+
return hashlib.sha1(raw.encode("utf-8")).hexdigest()[:12]
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def compute_payload(state, event_type):
|
|
609
|
+
"""Read the file, run git diff, parse, and build an SSE payload dict."""
|
|
610
|
+
with state.lock:
|
|
611
|
+
baseline = dict(state.baseline)
|
|
612
|
+
relpath = state.relpath
|
|
613
|
+
abspath = state.abspath
|
|
614
|
+
if abspath is None:
|
|
615
|
+
return {
|
|
616
|
+
"type": event_type,
|
|
617
|
+
"file": None,
|
|
618
|
+
"raw_text": "",
|
|
619
|
+
"baseline": None,
|
|
620
|
+
"segments": [],
|
|
621
|
+
"hunks": [],
|
|
622
|
+
"file_mtime": 0.0,
|
|
623
|
+
"diff_epoch": None,
|
|
624
|
+
}
|
|
625
|
+
raw_text = read_text(abspath)
|
|
626
|
+
diff_text = git_diff_text(state.root, baseline, relpath, abspath)
|
|
627
|
+
segments, hunks = parse_word_diff(diff_text)
|
|
628
|
+
pub_baseline = {k: baseline[k] for k in ("kind", "ref", "label") if k in baseline}
|
|
629
|
+
return {
|
|
630
|
+
"type": event_type,
|
|
631
|
+
"file": relpath,
|
|
632
|
+
"raw_text": raw_text,
|
|
633
|
+
"baseline": pub_baseline,
|
|
634
|
+
"segments": segments,
|
|
635
|
+
"hunks": hunks,
|
|
636
|
+
"file_mtime": file_mtime(abspath),
|
|
637
|
+
"diff_epoch": compute_diff_epoch(state.root, baseline, raw_text),
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
def broadcast_diff(state, event_type):
|
|
642
|
+
payload = compute_payload(state, event_type)
|
|
643
|
+
state.broadcast(payload)
|
|
644
|
+
return payload
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
# --------------------------------------------------------------------------- #
|
|
648
|
+
# watch loop (mtime poll + debounce)
|
|
649
|
+
# --------------------------------------------------------------------------- #
|
|
650
|
+
|
|
651
|
+
def watch_loop(state, stop_event):
|
|
652
|
+
POLL = 0.30
|
|
653
|
+
DEBOUNCE = 0.18
|
|
654
|
+
while not stop_event.is_set():
|
|
655
|
+
time.sleep(POLL)
|
|
656
|
+
with state.lock:
|
|
657
|
+
abspath = state.abspath
|
|
658
|
+
last = state.last_mtime
|
|
659
|
+
if abspath is None:
|
|
660
|
+
continue
|
|
661
|
+
try:
|
|
662
|
+
mt = file_mtime(abspath)
|
|
663
|
+
except Exception:
|
|
664
|
+
continue
|
|
665
|
+
if mt == last:
|
|
666
|
+
continue
|
|
667
|
+
# change detected; debounce so a burst of saves coalesces
|
|
668
|
+
time.sleep(DEBOUNCE)
|
|
669
|
+
with state.lock:
|
|
670
|
+
if state.abspath != abspath:
|
|
671
|
+
continue # file was switched mid-tick; let next tick handle it
|
|
672
|
+
mt2 = file_mtime(abspath)
|
|
673
|
+
state.last_mtime = mt2
|
|
674
|
+
dirty = state.client_dirty
|
|
675
|
+
# With a dirty buffer, notify rather than overwrite (section 8).
|
|
676
|
+
event_type = "disk_changed" if dirty else "update"
|
|
677
|
+
try:
|
|
678
|
+
broadcast_diff(state, event_type)
|
|
679
|
+
except Exception as e:
|
|
680
|
+
sys.stderr.write("draftwatch: diff error: %s\n" % e)
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
# --------------------------------------------------------------------------- #
|
|
684
|
+
# vendored static assets (served from the package; no CDN, works offline)
|
|
685
|
+
# --------------------------------------------------------------------------- #
|
|
686
|
+
|
|
687
|
+
# Fixed allowlist: name -> MIME type. The URL path is never interpreted as a
|
|
688
|
+
# filesystem path; anything not literally in this dict is a 404.
|
|
689
|
+
STATIC_ASSETS = {
|
|
690
|
+
"codemirror.js": "application/javascript; charset=utf-8",
|
|
691
|
+
"marked.js": "application/javascript; charset=utf-8",
|
|
692
|
+
"purify.js": "application/javascript; charset=utf-8",
|
|
693
|
+
"turndown.js": "application/javascript; charset=utf-8",
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
def load_asset(name):
|
|
698
|
+
"""Read a vendored asset from the installed package (or the source tree)."""
|
|
699
|
+
try:
|
|
700
|
+
from importlib import resources
|
|
701
|
+
return (resources.files("draftwatch") / "assets" / name).read_bytes()
|
|
702
|
+
except Exception:
|
|
703
|
+
pass
|
|
704
|
+
try:
|
|
705
|
+
here = os.path.join(os.path.dirname(os.path.abspath(__file__)), "assets", name)
|
|
706
|
+
with open(here, "rb") as f:
|
|
707
|
+
return f.read()
|
|
708
|
+
except OSError:
|
|
709
|
+
return None
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
# --------------------------------------------------------------------------- #
|
|
713
|
+
# HTTP handler
|
|
714
|
+
# --------------------------------------------------------------------------- #
|
|
715
|
+
|
|
716
|
+
class Handler(BaseHTTPRequestHandler):
|
|
717
|
+
state = None # set on the class before serving
|
|
718
|
+
token = None # per-session secret; required on /api/* and /events
|
|
719
|
+
allowed_hosts = set() # exact Host header values we will serve
|
|
720
|
+
|
|
721
|
+
# quiet default logging
|
|
722
|
+
def log_message(self, fmt, *args):
|
|
723
|
+
pass
|
|
724
|
+
|
|
725
|
+
# ---- access control ----
|
|
726
|
+
# Any local process, or any web page you happen to have open (drive-by CSRF;
|
|
727
|
+
# DNS rebinding defeats the browser's same-origin protection), could
|
|
728
|
+
# otherwise POST to /api/save and overwrite the file. Three checks:
|
|
729
|
+
# 1. Host must be exactly our loopback host:port (kills DNS rebinding).
|
|
730
|
+
# 2. Origin, when the browser sends one, must match (kills cross-site XHR).
|
|
731
|
+
# 3. /api/* and /events require the per-session token, sent as the
|
|
732
|
+
# X-Draftwatch-Token header or the `t` query parameter (EventSource
|
|
733
|
+
# cannot set headers). The token is minted in main() and only ever
|
|
734
|
+
# printed to the local terminal / embedded in the opened URL.
|
|
735
|
+
def _query(self):
|
|
736
|
+
parts = self.path.split("?", 1)
|
|
737
|
+
return parse_qs(parts[1]) if len(parts) == 2 else {}
|
|
738
|
+
|
|
739
|
+
def _guard(self, path):
|
|
740
|
+
"""Return True if the request may proceed; otherwise send 403."""
|
|
741
|
+
host = (self.headers.get("Host") or "").strip()
|
|
742
|
+
if host not in self.allowed_hosts:
|
|
743
|
+
self._send(403, json.dumps({"error": "forbidden: bad Host"}))
|
|
744
|
+
return False
|
|
745
|
+
origin = self.headers.get("Origin")
|
|
746
|
+
if origin is not None:
|
|
747
|
+
if origin not in {"http://" + h for h in self.allowed_hosts}:
|
|
748
|
+
self._send(403, json.dumps({"error": "forbidden: bad Origin"}))
|
|
749
|
+
return False
|
|
750
|
+
if path.startswith("/api/") or path == "/events":
|
|
751
|
+
tok = self.headers.get("X-Draftwatch-Token")
|
|
752
|
+
if not tok:
|
|
753
|
+
vals = self._query().get("t")
|
|
754
|
+
tok = vals[0] if vals else ""
|
|
755
|
+
if not (self.token and secrets.compare_digest(tok, self.token)):
|
|
756
|
+
self._send(403, json.dumps({"error": "forbidden: missing or bad session token"}))
|
|
757
|
+
return False
|
|
758
|
+
return True
|
|
759
|
+
|
|
760
|
+
# ---- helpers ----
|
|
761
|
+
def _send(self, code, body, ctype="application/json"):
|
|
762
|
+
if isinstance(body, str):
|
|
763
|
+
body = body.encode("utf-8")
|
|
764
|
+
self.send_response(code)
|
|
765
|
+
self.send_header("Content-Type", ctype)
|
|
766
|
+
self.send_header("Content-Length", str(len(body)))
|
|
767
|
+
self.send_header("Cache-Control", "no-store")
|
|
768
|
+
self.end_headers()
|
|
769
|
+
try:
|
|
770
|
+
self.wfile.write(body)
|
|
771
|
+
except (BrokenPipeError, ConnectionResetError):
|
|
772
|
+
pass
|
|
773
|
+
|
|
774
|
+
def _json(self, obj, code=200):
|
|
775
|
+
self._send(code, json.dumps(obj))
|
|
776
|
+
|
|
777
|
+
def _read_json(self):
|
|
778
|
+
length = int(self.headers.get("Content-Length", 0) or 0)
|
|
779
|
+
if length <= 0:
|
|
780
|
+
return {}
|
|
781
|
+
raw = self.rfile.read(length)
|
|
782
|
+
try:
|
|
783
|
+
return json.loads(raw.decode("utf-8"))
|
|
784
|
+
except Exception:
|
|
785
|
+
return {}
|
|
786
|
+
|
|
787
|
+
# ---- GET ----
|
|
788
|
+
def do_GET(self):
|
|
789
|
+
path = self.path.split("?", 1)[0]
|
|
790
|
+
if not self._guard(path):
|
|
791
|
+
return
|
|
792
|
+
if path == "/":
|
|
793
|
+
# inject version/date (literal tokens, so no clash with CSS/JS braces)
|
|
794
|
+
page = (INDEX_HTML
|
|
795
|
+
.replace("{{VERSION}}", __version__)
|
|
796
|
+
.replace("{{RELEASE_DATE}}", RELEASE_DATE))
|
|
797
|
+
self._send(200, page, "text/html; charset=utf-8")
|
|
798
|
+
elif path.startswith("/static/"):
|
|
799
|
+
self._serve_static(path[len("/static/"):])
|
|
800
|
+
elif path == "/events":
|
|
801
|
+
self._serve_events()
|
|
802
|
+
elif path == "/api/baselines":
|
|
803
|
+
self._api_baselines()
|
|
804
|
+
elif path == "/api/files":
|
|
805
|
+
self._api_files()
|
|
806
|
+
else:
|
|
807
|
+
self._send(404, "not found", "text/plain")
|
|
808
|
+
|
|
809
|
+
def _serve_static(self, name):
|
|
810
|
+
"""Vendored library code only — token-exempt (no state, no secrets).
|
|
811
|
+
`name` must literally match the allowlist; it is never interpreted as
|
|
812
|
+
a filesystem path, so traversal is structurally impossible."""
|
|
813
|
+
ctype = STATIC_ASSETS.get(name)
|
|
814
|
+
if ctype is None:
|
|
815
|
+
return self._send(404, "not found", "text/plain")
|
|
816
|
+
data = load_asset(name)
|
|
817
|
+
if data is None:
|
|
818
|
+
return self._send(404, "not found", "text/plain")
|
|
819
|
+
self._send(200, data, ctype)
|
|
820
|
+
|
|
821
|
+
def _serve_events(self):
|
|
822
|
+
st = self.state
|
|
823
|
+
self.send_response(200)
|
|
824
|
+
self.send_header("Content-Type", "text/event-stream")
|
|
825
|
+
self.send_header("Cache-Control", "no-cache")
|
|
826
|
+
self.send_header("Connection", "keep-alive")
|
|
827
|
+
self.send_header("X-Accel-Buffering", "no")
|
|
828
|
+
self.end_headers()
|
|
829
|
+
q = st.subscribe()
|
|
830
|
+
# send current state immediately so the page populates
|
|
831
|
+
try:
|
|
832
|
+
payload = compute_payload(st, "update")
|
|
833
|
+
self._sse_write(json.dumps(payload))
|
|
834
|
+
except Exception as e:
|
|
835
|
+
sys.stderr.write("draftwatch: initial diff error: %s\n" % e)
|
|
836
|
+
try:
|
|
837
|
+
while True:
|
|
838
|
+
try:
|
|
839
|
+
data = q.get(timeout=15)
|
|
840
|
+
self._sse_write(data)
|
|
841
|
+
except queue.Empty:
|
|
842
|
+
# heartbeat to keep the connection alive
|
|
843
|
+
self.wfile.write(b": ping\n\n")
|
|
844
|
+
self.wfile.flush()
|
|
845
|
+
except (BrokenPipeError, ConnectionResetError):
|
|
846
|
+
pass
|
|
847
|
+
finally:
|
|
848
|
+
st.unsubscribe(q)
|
|
849
|
+
|
|
850
|
+
def _sse_write(self, data):
|
|
851
|
+
self.wfile.write(b"data: ")
|
|
852
|
+
self.wfile.write(data.encode("utf-8"))
|
|
853
|
+
self.wfile.write(b"\n\n")
|
|
854
|
+
self.wfile.flush()
|
|
855
|
+
|
|
856
|
+
def _api_baselines(self):
|
|
857
|
+
st = self.state
|
|
858
|
+
with st.lock:
|
|
859
|
+
baseline = {k: st.baseline[k] for k in ("kind", "ref", "label")
|
|
860
|
+
if k in st.baseline}
|
|
861
|
+
untracked = st.untracked
|
|
862
|
+
relpath = st.relpath
|
|
863
|
+
if relpath is None:
|
|
864
|
+
return self._json({"current": None, "head": None, "push": None,
|
|
865
|
+
"commits": [], "untracked": False})
|
|
866
|
+
short, reldate = head_label(st.root)
|
|
867
|
+
head = None
|
|
868
|
+
if short is not None:
|
|
869
|
+
head = {"ref": "HEAD", "short": short,
|
|
870
|
+
"label": "HEAD · {}".format(reldate)}
|
|
871
|
+
pname, pshort, preldate = push_ref(st.root)
|
|
872
|
+
push = None
|
|
873
|
+
if pname is not None:
|
|
874
|
+
push = {"ref": pname, "short": pshort,
|
|
875
|
+
"label": "last push · {} · {}".format(pname, preldate)}
|
|
876
|
+
commits = commit_list(st.root, relpath)
|
|
877
|
+
self._json({
|
|
878
|
+
"current": baseline,
|
|
879
|
+
"head": head,
|
|
880
|
+
"push": push,
|
|
881
|
+
"commits": commits,
|
|
882
|
+
"untracked": untracked,
|
|
883
|
+
})
|
|
884
|
+
|
|
885
|
+
def _api_files(self):
|
|
886
|
+
st = self.state
|
|
887
|
+
with st.lock:
|
|
888
|
+
current = st.relpath
|
|
889
|
+
self._json({"files": list_repo_files(st.root), "current": current})
|
|
890
|
+
|
|
891
|
+
# ---- POST ----
|
|
892
|
+
def do_POST(self):
|
|
893
|
+
path = self.path.split("?", 1)[0]
|
|
894
|
+
if not self._guard(path):
|
|
895
|
+
return
|
|
896
|
+
if path == "/api/baseline":
|
|
897
|
+
self._api_set_baseline()
|
|
898
|
+
elif path == "/api/apply":
|
|
899
|
+
self._api_apply()
|
|
900
|
+
elif path == "/api/save":
|
|
901
|
+
self._api_save()
|
|
902
|
+
elif path == "/api/commit":
|
|
903
|
+
self._api_commit()
|
|
904
|
+
elif path == "/api/client_state":
|
|
905
|
+
self._api_client_state()
|
|
906
|
+
elif path == "/api/open":
|
|
907
|
+
self._api_open()
|
|
908
|
+
elif path == "/api/new":
|
|
909
|
+
self._api_new()
|
|
910
|
+
elif path == "/api/close":
|
|
911
|
+
self._api_close()
|
|
912
|
+
else:
|
|
913
|
+
self._send(404, "not found", "text/plain")
|
|
914
|
+
|
|
915
|
+
def _api_open(self):
|
|
916
|
+
st = self.state
|
|
917
|
+
body = self._read_json()
|
|
918
|
+
path = body.get("path", "")
|
|
919
|
+
try:
|
|
920
|
+
relpath = st.open_file(path)
|
|
921
|
+
except ValueError as e:
|
|
922
|
+
return self._json({"error": str(e)}, 400)
|
|
923
|
+
except Exception as e:
|
|
924
|
+
return self._json({"error": str(e)}, 500)
|
|
925
|
+
with st.lock:
|
|
926
|
+
st.client_dirty = False # the old buffer belonged to the old file
|
|
927
|
+
payload = broadcast_diff(st, "update")
|
|
928
|
+
self._json({"ok": True, "file": relpath, "baseline": payload["baseline"]})
|
|
929
|
+
|
|
930
|
+
def _api_set_baseline(self):
|
|
931
|
+
st = self.state
|
|
932
|
+
if not st.has_file():
|
|
933
|
+
return self._json({"error": "no file open"}, 400)
|
|
934
|
+
body = self._read_json()
|
|
935
|
+
kind = body.get("kind")
|
|
936
|
+
try:
|
|
937
|
+
if kind == "head":
|
|
938
|
+
if st.untracked:
|
|
939
|
+
return self._json({"error": "file is not committed; HEAD baseline unavailable"}, 400)
|
|
940
|
+
with st.lock:
|
|
941
|
+
short, reldate = head_label(st.root)
|
|
942
|
+
st.baseline = {"kind": "head", "ref": "HEAD",
|
|
943
|
+
"label": "HEAD · {}".format(reldate or "")}
|
|
944
|
+
elif kind == "push":
|
|
945
|
+
if st.untracked:
|
|
946
|
+
return self._json({"error": "file is not committed; push baseline unavailable"}, 400)
|
|
947
|
+
# re-resolve the push target server-side rather than trust the client
|
|
948
|
+
pname, pshort, preldate = push_ref(st.root)
|
|
949
|
+
if not pname:
|
|
950
|
+
return self._json({"error": "no push/upstream target configured for this branch"}, 400)
|
|
951
|
+
with st.lock:
|
|
952
|
+
st.baseline = {"kind": "push", "ref": pname,
|
|
953
|
+
"label": "last push · {} · {}".format(pname, preldate or "")}
|
|
954
|
+
elif kind == "commit":
|
|
955
|
+
ref = body.get("ref")
|
|
956
|
+
if not ref:
|
|
957
|
+
return self._json({"error": "missing ref"}, 400)
|
|
958
|
+
label = body.get("label", ref)
|
|
959
|
+
with st.lock:
|
|
960
|
+
st.baseline = {"kind": "commit", "ref": ref, "label": label}
|
|
961
|
+
elif kind == "empty":
|
|
962
|
+
if not st.untracked:
|
|
963
|
+
return self._json({"error": "file is committed; use HEAD or a commit baseline"}, 400)
|
|
964
|
+
with st.lock:
|
|
965
|
+
st.baseline = {"kind": "empty", "ref": "empty",
|
|
966
|
+
"label": "empty tree · file not yet committed"}
|
|
967
|
+
else:
|
|
968
|
+
return self._json({"error": "unknown baseline kind"}, 400)
|
|
969
|
+
payload = broadcast_diff(st, "baseline_changed")
|
|
970
|
+
self._json({"ok": True, "baseline": payload["baseline"]})
|
|
971
|
+
except Exception as e:
|
|
972
|
+
self._json({"error": str(e)}, 500)
|
|
973
|
+
|
|
974
|
+
def _api_apply(self):
|
|
975
|
+
st = self.state
|
|
976
|
+
if not st.has_file():
|
|
977
|
+
return self._json({"error": "no file open"}, 400)
|
|
978
|
+
body = self._read_json()
|
|
979
|
+
decisions = body.get("decisions", {}) or {}
|
|
980
|
+
# normalise keys to strings
|
|
981
|
+
decisions = {str(k): v for k, v in decisions.items()}
|
|
982
|
+
client_epoch = body.get("diff_epoch")
|
|
983
|
+
try:
|
|
984
|
+
with st.lock:
|
|
985
|
+
baseline = dict(st.baseline)
|
|
986
|
+
# staleness guard: the decisions were made against a rendered diff;
|
|
987
|
+
# refuse to apply them if the file or baseline has changed since that
|
|
988
|
+
# render (e.g. an agent save during the SSE debounce window would
|
|
989
|
+
# otherwise revert the wrong spans). The client echoes the epoch it
|
|
990
|
+
# rendered; we recompute from current reality and compare.
|
|
991
|
+
raw_now = read_text(st.abspath)
|
|
992
|
+
epoch_now = compute_diff_epoch(st.root, baseline, raw_now)
|
|
993
|
+
if client_epoch != epoch_now:
|
|
994
|
+
broadcast_diff(st, "update") # re-render clients (decisions survive)
|
|
995
|
+
return self._json({
|
|
996
|
+
"error": "stale diff: the file or baseline changed since this "
|
|
997
|
+
"view was rendered; the view has been refreshed — "
|
|
998
|
+
"check your marks and apply again",
|
|
999
|
+
"stale": True,
|
|
1000
|
+
"diff_epoch": epoch_now,
|
|
1001
|
+
}, 409)
|
|
1002
|
+
diff_text = git_diff_text(st.root, baseline, st.relpath, st.abspath)
|
|
1003
|
+
segments, _ = parse_word_diff(diff_text)
|
|
1004
|
+
if not segments:
|
|
1005
|
+
# Empty diff: nothing to apply. Never write (would truncate the file).
|
|
1006
|
+
return self._json({"ok": True, "no_changes": True})
|
|
1007
|
+
new_text = reconstruct(segments, decisions, pending_is_accept=True)
|
|
1008
|
+
write_file(st.abspath, new_text)
|
|
1009
|
+
with st.lock:
|
|
1010
|
+
st.last_mtime = file_mtime(st.abspath)
|
|
1011
|
+
st.client_dirty = False
|
|
1012
|
+
broadcast_diff(st, "update")
|
|
1013
|
+
self._json({"ok": True})
|
|
1014
|
+
except Exception as e:
|
|
1015
|
+
self._json({"error": str(e)}, 500)
|
|
1016
|
+
|
|
1017
|
+
def _api_close(self):
|
|
1018
|
+
"""Detach from the watched file and return to the no-file scratch
|
|
1019
|
+
state. Used by the picker's "start a new file": the buffer opens blank
|
|
1020
|
+
and unnamed, and the name is asked at save time (via /api/new), not up
|
|
1021
|
+
front. The file on disk is untouched."""
|
|
1022
|
+
st = self.state
|
|
1023
|
+
with st.lock:
|
|
1024
|
+
st.relpath = None
|
|
1025
|
+
st.abspath = None
|
|
1026
|
+
st.untracked = False
|
|
1027
|
+
st.last_mtime = 0.0
|
|
1028
|
+
st.client_dirty = False
|
|
1029
|
+
st.baseline = {"kind": "head", "ref": "HEAD", "label": "HEAD"}
|
|
1030
|
+
broadcast_diff(st, "update")
|
|
1031
|
+
self._json({"ok": True})
|
|
1032
|
+
|
|
1033
|
+
def _api_new(self):
|
|
1034
|
+
"""Create a NEW file inside the repo and switch to watching it. This is
|
|
1035
|
+
how "start typing with no file open" becomes a real file, and what the
|
|
1036
|
+
picker's "start a new file" option calls. Never overwrites an existing
|
|
1037
|
+
file (resolve_new_file refuses); empty text is fine — you can create
|
|
1038
|
+
the file first and write into it after."""
|
|
1039
|
+
st = self.state
|
|
1040
|
+
body = self._read_json()
|
|
1041
|
+
text = body.get("text", "")
|
|
1042
|
+
if not isinstance(text, str):
|
|
1043
|
+
return self._json({"error": "text must be a string"}, 400)
|
|
1044
|
+
try:
|
|
1045
|
+
abspath = resolve_new_file(st.root, body.get("path", ""))
|
|
1046
|
+
except ValueError as e:
|
|
1047
|
+
return self._json({"error": str(e)}, 400)
|
|
1048
|
+
try:
|
|
1049
|
+
parent = os.path.dirname(abspath)
|
|
1050
|
+
if parent:
|
|
1051
|
+
os.makedirs(parent, exist_ok=True)
|
|
1052
|
+
write_file(abspath, text)
|
|
1053
|
+
relpath = st.open_file(abspath)
|
|
1054
|
+
with st.lock:
|
|
1055
|
+
st.client_dirty = False
|
|
1056
|
+
payload = broadcast_diff(st, "update")
|
|
1057
|
+
return self._json({"ok": True, "file": relpath,
|
|
1058
|
+
"baseline": payload["baseline"]})
|
|
1059
|
+
except Exception as e:
|
|
1060
|
+
return self._json({"error": str(e)}, 500)
|
|
1061
|
+
|
|
1062
|
+
def _api_save(self):
|
|
1063
|
+
st = self.state
|
|
1064
|
+
if not st.has_file():
|
|
1065
|
+
return self._json({"error": "no file open — use /api/new to create one"}, 400)
|
|
1066
|
+
body = self._read_json()
|
|
1067
|
+
if "text" not in body:
|
|
1068
|
+
return self._json({"error": "missing text"}, 400)
|
|
1069
|
+
if (body.get("path") or "").strip():
|
|
1070
|
+
# creation goes through /api/new; refusing here means a stray
|
|
1071
|
+
# "path" can never silently overwrite the open file
|
|
1072
|
+
return self._json({"error": "save does not take a path — use /api/new to create a file"}, 400)
|
|
1073
|
+
text = body["text"]
|
|
1074
|
+
try:
|
|
1075
|
+
write_file(st.abspath, text)
|
|
1076
|
+
with st.lock:
|
|
1077
|
+
st.last_mtime = file_mtime(st.abspath)
|
|
1078
|
+
st.client_dirty = False
|
|
1079
|
+
broadcast_diff(st, "update")
|
|
1080
|
+
self._json({"ok": True})
|
|
1081
|
+
except Exception as e:
|
|
1082
|
+
self._json({"error": str(e)}, 500)
|
|
1083
|
+
|
|
1084
|
+
def _api_commit(self):
|
|
1085
|
+
"""Commit the current state of the watched file. This is the final step
|
|
1086
|
+
of the review loop — on success the baseline auto-advances to HEAD, so
|
|
1087
|
+
the diff clears to zero (review -> revert -> commit -> clean). Earlier
|
|
1088
|
+
commits and the push baseline remain available in the dropdown."""
|
|
1089
|
+
st = self.state
|
|
1090
|
+
if not st.has_file():
|
|
1091
|
+
return self._json({"error": "no file open"}, 400)
|
|
1092
|
+
body = self._read_json()
|
|
1093
|
+
msg = (body.get("message") or "").strip() or "Update {}".format(st.relpath)
|
|
1094
|
+
try:
|
|
1095
|
+
rc, _, err = run_git(["add", "--", st.relpath], st.root)
|
|
1096
|
+
if rc != 0:
|
|
1097
|
+
return self._json({"error": "git add failed: " + err.strip()}, 500)
|
|
1098
|
+
rc, out, err = run_git(["commit", "-m", msg, "--", st.relpath], st.root)
|
|
1099
|
+
if rc != 0:
|
|
1100
|
+
combined = (err.strip() or out.strip())
|
|
1101
|
+
if "nothing to commit" in combined or "nothing added to commit" in combined \
|
|
1102
|
+
or "no changes added to commit" in combined:
|
|
1103
|
+
return self._json({"error": "nothing to commit — no changes since the last commit"}, 400)
|
|
1104
|
+
return self._json({"error": "git commit failed: " + combined}, 500)
|
|
1105
|
+
# the file is now committed; advance the baseline to the new HEAD so
|
|
1106
|
+
# the review loop closes with an empty diff.
|
|
1107
|
+
short, reldate = head_label(st.root)
|
|
1108
|
+
with st.lock:
|
|
1109
|
+
st.untracked = False
|
|
1110
|
+
st.baseline = {"kind": "head", "ref": "HEAD",
|
|
1111
|
+
"label": "HEAD · {}".format(reldate or "")}
|
|
1112
|
+
broadcast_diff(st, "baseline_changed")
|
|
1113
|
+
self._json({"ok": True, "short": short})
|
|
1114
|
+
except Exception as e:
|
|
1115
|
+
self._json({"error": str(e)}, 500)
|
|
1116
|
+
|
|
1117
|
+
def _api_client_state(self):
|
|
1118
|
+
st = self.state
|
|
1119
|
+
body = self._read_json()
|
|
1120
|
+
with st.lock:
|
|
1121
|
+
if "dirty" in body:
|
|
1122
|
+
st.client_dirty = bool(body["dirty"])
|
|
1123
|
+
self._json({"ok": True})
|
|
1124
|
+
|
|
1125
|
+
|
|
1126
|
+
# --------------------------------------------------------------------------- #
|
|
1127
|
+
# frontend (embedded; no CDN, no framework, no build)
|
|
1128
|
+
# --------------------------------------------------------------------------- #
|
|
1129
|
+
|
|
1130
|
+
INDEX_HTML = r"""<!DOCTYPE html>
|
|
1131
|
+
<html lang="en">
|
|
1132
|
+
<head>
|
|
1133
|
+
<meta charset="utf-8">
|
|
1134
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
1135
|
+
<title>Draftwatch</title>
|
|
1136
|
+
<script src="/static/codemirror.js"></script>
|
|
1137
|
+
<script src="/static/marked.js"></script>
|
|
1138
|
+
<script src="/static/purify.js"></script>
|
|
1139
|
+
<script src="/static/turndown.js"></script>
|
|
1140
|
+
<script>
|
|
1141
|
+
// Resolve the saved theme + accent before first paint so there is no
|
|
1142
|
+
// light/dark (or color) flash. "auto" (or nothing saved) leaves the theme to
|
|
1143
|
+
// prefers-color-scheme via CSS; no saved accent means the default blue. An
|
|
1144
|
+
// unknown stored accent value simply matches no CSS rule -> blue.
|
|
1145
|
+
try {
|
|
1146
|
+
var _t = localStorage.getItem("draftwatch-theme");
|
|
1147
|
+
if (_t === "light" || _t === "dark") {
|
|
1148
|
+
document.documentElement.setAttribute("data-theme", _t);
|
|
1149
|
+
}
|
|
1150
|
+
var _a = localStorage.getItem("draftwatch-accent");
|
|
1151
|
+
if (_a) document.documentElement.setAttribute("data-accent", _a);
|
|
1152
|
+
} catch (e) {}
|
|
1153
|
+
</script>
|
|
1154
|
+
<style>
|
|
1155
|
+
/* Draftwatch identity: a cool, light-blue editorial palette — blue-tinted
|
|
1156
|
+
paper and slate ink, with a single steel-blue accent (no SaaS indigo). The
|
|
1157
|
+
accent is kept deep enough to hold white button text (>=4.5:1); the green/red
|
|
1158
|
+
add/del pair stays strictly semantic, and the disk-changed warning stays
|
|
1159
|
+
amber so it pops against the cool UI. Theme resolves in this order: manual
|
|
1160
|
+
choice (data-theme on <html>) wins; otherwise follow the OS
|
|
1161
|
+
(prefers-color-scheme); light is the base. */
|
|
1162
|
+
:root {
|
|
1163
|
+
--bg: #f3f7fc;
|
|
1164
|
+
--panel: #e7eef8;
|
|
1165
|
+
--border: #d0dcec;
|
|
1166
|
+
--text: #1e2733;
|
|
1167
|
+
--muted: #5f6b7a;
|
|
1168
|
+
--add-bg: #d7ecd6;
|
|
1169
|
+
--add-fg: #1f5a2c;
|
|
1170
|
+
--del-bg: #f6d8d3;
|
|
1171
|
+
--del-fg: #8a2f22;
|
|
1172
|
+
--accent: #2f6fb0; /* steel blue: buttons, links, focus */
|
|
1173
|
+
--accent-fg: #f5f9fe; /* text on a filled --accent button */
|
|
1174
|
+
--warn-bg: #fdf0cf;
|
|
1175
|
+
--warn-border: #d8b24e;
|
|
1176
|
+
--mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
|
|
1177
|
+
}
|
|
1178
|
+
/* dark values, declared once and reused by both the auto (OS) path and the
|
|
1179
|
+
manual override below */
|
|
1180
|
+
@media (prefers-color-scheme: dark) {
|
|
1181
|
+
:root:not([data-theme]) {
|
|
1182
|
+
--bg: #121824;
|
|
1183
|
+
--panel: #1a2231;
|
|
1184
|
+
--border: #2e3a4c;
|
|
1185
|
+
--text: #e6ecf4;
|
|
1186
|
+
--muted: #94a1b4;
|
|
1187
|
+
--add-bg: #1f3f26;
|
|
1188
|
+
--add-fg: #b7e6b3;
|
|
1189
|
+
--del-bg: #4f2620;
|
|
1190
|
+
--del-fg: #f0c2b8;
|
|
1191
|
+
--accent: #6aa9e0; /* lighter sky blue reads well on the dark ground */
|
|
1192
|
+
--accent-fg: #0e1826; /* dark text on the lighter accent button */
|
|
1193
|
+
--warn-bg: #3c3620;
|
|
1194
|
+
--warn-border: #8a7320;
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
:root[data-theme="dark"] {
|
|
1198
|
+
--bg: #121824;
|
|
1199
|
+
--panel: #1a2231;
|
|
1200
|
+
--border: #2e3a4c;
|
|
1201
|
+
--text: #e6ecf4;
|
|
1202
|
+
--muted: #94a1b4;
|
|
1203
|
+
--add-bg: #1f3f26;
|
|
1204
|
+
--add-fg: #b7e6b3;
|
|
1205
|
+
--del-bg: #4f2620;
|
|
1206
|
+
--del-fg: #f0c2b8;
|
|
1207
|
+
--accent: #6aa9e0;
|
|
1208
|
+
--accent-fg: #0e1826;
|
|
1209
|
+
--warn-bg: #3c3620;
|
|
1210
|
+
--warn-border: #8a7320;
|
|
1211
|
+
}
|
|
1212
|
+
/* Color themes: the picker restyles the WHOLE look, not just the accent —
|
|
1213
|
+
each color re-tints the paper (--bg), panels, borders, and ink the same
|
|
1214
|
+
way the default blue does: tinted paper, darker panel, deeper accent that
|
|
1215
|
+
holds button text at >=4.5:1. All stay in the same muted editorial
|
|
1216
|
+
register; "graphite" is the monochrome one (Cursor-style neutral greys).
|
|
1217
|
+
The add-green / del-red / warn-amber stay strictly semantic and identical
|
|
1218
|
+
across themes. Chosen via data-accent on <html> (persisted in
|
|
1219
|
+
localStorage, applied pre-paint by the script in <head>); no attribute =
|
|
1220
|
+
blue. Dark values are declared twice (manual override + auto/OS path),
|
|
1221
|
+
mirroring how the base theme does it. */
|
|
1222
|
+
:root[data-accent="teal"] {
|
|
1223
|
+
--bg: #f1faf7; --panel: #e2f1ec; --border: #c8e0d8;
|
|
1224
|
+
--text: #1c2b27; --muted: #5c6f69;
|
|
1225
|
+
--accent: #1e7b70; --accent-fg: #f2fbf9;
|
|
1226
|
+
}
|
|
1227
|
+
:root[data-accent="iris"] {
|
|
1228
|
+
--bg: #f6f5fc; --panel: #eceaf6; --border: #d8d4ea;
|
|
1229
|
+
--text: #24222f; --muted: #67637a;
|
|
1230
|
+
--accent: #6a5a96; --accent-fg: #f7f5fc;
|
|
1231
|
+
}
|
|
1232
|
+
:root[data-accent="plum"] {
|
|
1233
|
+
--bg: #faf5f9; --panel: #f2e8ef; --border: #e2d0dc;
|
|
1234
|
+
--text: #2c222a; --muted: #74616e;
|
|
1235
|
+
--accent: #8a4a78; --accent-fg: #fdf6fb;
|
|
1236
|
+
}
|
|
1237
|
+
:root[data-accent="graphite"] {
|
|
1238
|
+
--bg: #f6f6f7; --panel: #ebebed; --border: #d7d7db;
|
|
1239
|
+
--text: #222327; --muted: #64666d;
|
|
1240
|
+
--accent: #44464d; --accent-fg: #f7f7f8;
|
|
1241
|
+
}
|
|
1242
|
+
@media (prefers-color-scheme: dark) {
|
|
1243
|
+
:root:not([data-theme])[data-accent="teal"] {
|
|
1244
|
+
--bg: #101c19; --panel: #172622; --border: #2a3f38;
|
|
1245
|
+
--text: #e3efeb; --muted: #8fa8a0;
|
|
1246
|
+
--accent: #5cbcae; --accent-fg: #07211c;
|
|
1247
|
+
}
|
|
1248
|
+
:root:not([data-theme])[data-accent="iris"] {
|
|
1249
|
+
--bg: #16141f; --panel: #1f1c2b; --border: #353046;
|
|
1250
|
+
--text: #e9e6f2; --muted: #9d97b4;
|
|
1251
|
+
--accent: #a89bd8; --accent-fg: #171129;
|
|
1252
|
+
}
|
|
1253
|
+
:root:not([data-theme])[data-accent="plum"] {
|
|
1254
|
+
--bg: #1d141a; --panel: #291c25; --border: #43303e;
|
|
1255
|
+
--text: #f0e7ed; --muted: #ab93a3;
|
|
1256
|
+
--accent: #c893b6; --accent-fg: #260f1f;
|
|
1257
|
+
}
|
|
1258
|
+
:root:not([data-theme])[data-accent="graphite"] {
|
|
1259
|
+
--bg: #161718; --panel: #1e1f21; --border: #323438;
|
|
1260
|
+
--text: #e8e9ea; --muted: #989aa1;
|
|
1261
|
+
--accent: #b3b6bd; --accent-fg: #141517;
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
:root[data-theme="dark"][data-accent="teal"] {
|
|
1265
|
+
--bg: #101c19; --panel: #172622; --border: #2a3f38;
|
|
1266
|
+
--text: #e3efeb; --muted: #8fa8a0;
|
|
1267
|
+
--accent: #5cbcae; --accent-fg: #07211c;
|
|
1268
|
+
}
|
|
1269
|
+
:root[data-theme="dark"][data-accent="iris"] {
|
|
1270
|
+
--bg: #16141f; --panel: #1f1c2b; --border: #353046;
|
|
1271
|
+
--text: #e9e6f2; --muted: #9d97b4;
|
|
1272
|
+
--accent: #a89bd8; --accent-fg: #171129;
|
|
1273
|
+
}
|
|
1274
|
+
:root[data-theme="dark"][data-accent="plum"] {
|
|
1275
|
+
--bg: #1d141a; --panel: #291c25; --border: #43303e;
|
|
1276
|
+
--text: #f0e7ed; --muted: #ab93a3;
|
|
1277
|
+
--accent: #c893b6; --accent-fg: #260f1f;
|
|
1278
|
+
}
|
|
1279
|
+
:root[data-theme="dark"][data-accent="graphite"] {
|
|
1280
|
+
--bg: #161718; --panel: #1e1f21; --border: #323438;
|
|
1281
|
+
--text: #e8e9ea; --muted: #989aa1;
|
|
1282
|
+
--accent: #b3b6bd; --accent-fg: #141517;
|
|
1283
|
+
}
|
|
1284
|
+
* { box-sizing: border-box; }
|
|
1285
|
+
html, body { height: 100%; margin: 0; overscroll-behavior: none; }
|
|
1286
|
+
|
|
1287
|
+
/* app chrome (native window via pywebview, ?app=1): the OS title bar
|
|
1288
|
+
already says "draftwatch · file", so the in-page wordmark is redundant —
|
|
1289
|
+
drop it, tighten the toolbar, and make the chrome feel native (no text
|
|
1290
|
+
selection on controls, no web-page tells). */
|
|
1291
|
+
body.app-mode header .title { display: none; }
|
|
1292
|
+
body.app-mode header { padding: 5px 10px; }
|
|
1293
|
+
body.app-mode header,
|
|
1294
|
+
body.app-mode footer {
|
|
1295
|
+
-webkit-user-select: none;
|
|
1296
|
+
user-select: none;
|
|
1297
|
+
}
|
|
1298
|
+
body.app-mode footer input[type="text"] {
|
|
1299
|
+
-webkit-user-select: auto;
|
|
1300
|
+
user-select: auto; /* the commit message field stays typable/selectable */
|
|
1301
|
+
}
|
|
1302
|
+
body {
|
|
1303
|
+
font-family: var(--mono);
|
|
1304
|
+
background: var(--bg);
|
|
1305
|
+
color: var(--text);
|
|
1306
|
+
font-size: 13px;
|
|
1307
|
+
display: flex;
|
|
1308
|
+
flex-direction: column;
|
|
1309
|
+
}
|
|
1310
|
+
header {
|
|
1311
|
+
display: flex;
|
|
1312
|
+
flex-direction: column;
|
|
1313
|
+
gap: 6px;
|
|
1314
|
+
padding: 8px 12px;
|
|
1315
|
+
border-bottom: 1px solid var(--border);
|
|
1316
|
+
background: var(--panel);
|
|
1317
|
+
}
|
|
1318
|
+
/* Two fixed rows that never wrap into each other, so the layout stays put
|
|
1319
|
+
regardless of title length or window width:
|
|
1320
|
+
row 1 = what you're looking at (title, file picker, baseline selector —
|
|
1321
|
+
file and baseline split the row roughly in half)
|
|
1322
|
+
row 2 = actions (preview/format/save on the left; theme, accent color,
|
|
1323
|
+
about on the right; transient status in between)
|
|
1324
|
+
The variable-length status line ellipsizes instead of pushing the
|
|
1325
|
+
controls around. */
|
|
1326
|
+
header .bar {
|
|
1327
|
+
display: flex;
|
|
1328
|
+
align-items: center;
|
|
1329
|
+
gap: 12px;
|
|
1330
|
+
min-width: 0;
|
|
1331
|
+
}
|
|
1332
|
+
header .title {
|
|
1333
|
+
font-weight: 700;
|
|
1334
|
+
white-space: nowrap;
|
|
1335
|
+
display: inline-flex;
|
|
1336
|
+
align-items: center;
|
|
1337
|
+
gap: 7px;
|
|
1338
|
+
letter-spacing: .02em;
|
|
1339
|
+
}
|
|
1340
|
+
/* the +/− mark: the product in two characters (keep the add, strike the del) */
|
|
1341
|
+
.mark {
|
|
1342
|
+
display: inline-flex;
|
|
1343
|
+
align-items: center;
|
|
1344
|
+
background: var(--bg);
|
|
1345
|
+
border: 1px solid var(--border);
|
|
1346
|
+
border-radius: 5px;
|
|
1347
|
+
padding: 0 5px;
|
|
1348
|
+
font-weight: 700;
|
|
1349
|
+
line-height: 1.45;
|
|
1350
|
+
}
|
|
1351
|
+
.mark .ma { color: var(--add-fg); }
|
|
1352
|
+
.mark .md { color: var(--del-fg); text-decoration: line-through; }
|
|
1353
|
+
header .status { color: var(--muted); }
|
|
1354
|
+
header #status {
|
|
1355
|
+
flex: 1 1 auto;
|
|
1356
|
+
min-width: 0;
|
|
1357
|
+
overflow: hidden;
|
|
1358
|
+
text-overflow: ellipsis;
|
|
1359
|
+
white-space: nowrap;
|
|
1360
|
+
}
|
|
1361
|
+
header .spacer { flex: 1; }
|
|
1362
|
+
#edit-toolbar .fmt { display: inline-flex; gap: 4px; }
|
|
1363
|
+
#edit-toolbar .fmt button { min-width: 30px; }
|
|
1364
|
+
select, button {
|
|
1365
|
+
font-family: var(--mono);
|
|
1366
|
+
font-size: 12px;
|
|
1367
|
+
color: var(--text);
|
|
1368
|
+
background: var(--bg);
|
|
1369
|
+
border: 1px solid var(--border);
|
|
1370
|
+
border-radius: 5px;
|
|
1371
|
+
padding: 4px 8px;
|
|
1372
|
+
cursor: pointer;
|
|
1373
|
+
}
|
|
1374
|
+
button:hover, select:hover { border-color: var(--accent); }
|
|
1375
|
+
/* the accent-picker's color dot — bound to var(--accent), so it always
|
|
1376
|
+
shows the live color */
|
|
1377
|
+
#accent-toggle .swatch {
|
|
1378
|
+
display: inline-block; width: 10px; height: 10px; border-radius: 50%;
|
|
1379
|
+
background: var(--accent); border: 1px solid var(--border);
|
|
1380
|
+
margin-right: 6px; vertical-align: -1px;
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
#banner {
|
|
1384
|
+
display: none;
|
|
1385
|
+
align-items: center;
|
|
1386
|
+
gap: 10px;
|
|
1387
|
+
padding: 7px 12px;
|
|
1388
|
+
background: var(--warn-bg);
|
|
1389
|
+
border-bottom: 1px solid var(--warn-border);
|
|
1390
|
+
}
|
|
1391
|
+
#banner.show { display: flex; }
|
|
1392
|
+
main {
|
|
1393
|
+
flex: 1;
|
|
1394
|
+
display: grid;
|
|
1395
|
+
grid-template-columns: 1fr 1fr;
|
|
1396
|
+
min-height: 0;
|
|
1397
|
+
}
|
|
1398
|
+
.panel { display: flex; flex-direction: column; min-height: 0; min-width: 0; }
|
|
1399
|
+
.panel.left { border-right: 1px solid var(--border); }
|
|
1400
|
+
.panel h2 {
|
|
1401
|
+
margin: 0;
|
|
1402
|
+
padding: 6px 12px;
|
|
1403
|
+
font-size: 11px;
|
|
1404
|
+
font-weight: 700;
|
|
1405
|
+
text-transform: uppercase;
|
|
1406
|
+
letter-spacing: .06em;
|
|
1407
|
+
color: var(--muted);
|
|
1408
|
+
border-bottom: 1px solid var(--border);
|
|
1409
|
+
background: var(--panel);
|
|
1410
|
+
}
|
|
1411
|
+
.scroll { flex: 1; overflow: auto; padding: 12px; }
|
|
1412
|
+
pre {
|
|
1413
|
+
margin: 0;
|
|
1414
|
+
font-family: var(--mono);
|
|
1415
|
+
font-size: 13px;
|
|
1416
|
+
line-height: 1.5;
|
|
1417
|
+
white-space: pre-wrap;
|
|
1418
|
+
word-break: break-word;
|
|
1419
|
+
color: var(--text);
|
|
1420
|
+
background: transparent;
|
|
1421
|
+
}
|
|
1422
|
+
/* CodeMirror host: the editor owns scrolling inside the left panel */
|
|
1423
|
+
#editor-host { flex: 1; min-height: 0; overflow: hidden; }
|
|
1424
|
+
#editor-host .cm-editor { height: 100%; background: transparent; }
|
|
1425
|
+
#editor-host .cm-editor.cm-focused { outline: none; }
|
|
1426
|
+
#editor-host .cm-scroller {
|
|
1427
|
+
overflow: auto;
|
|
1428
|
+
padding: 12px;
|
|
1429
|
+
font-family: var(--mono);
|
|
1430
|
+
font-size: 13px;
|
|
1431
|
+
line-height: 1.5;
|
|
1432
|
+
}
|
|
1433
|
+
#editor-host .cm-content { caret-color: var(--text); }
|
|
1434
|
+
#editor-host .cm-cursor { border-left-color: var(--text); }
|
|
1435
|
+
#editor-host .cm-selectionBackground,
|
|
1436
|
+
#editor-host .cm-editor.cm-focused .cm-selectionBackground {
|
|
1437
|
+
background: rgba(127, 127, 127, .28);
|
|
1438
|
+
}
|
|
1439
|
+
#editor-host .cm-placeholder { color: var(--muted); font-style: italic; }
|
|
1440
|
+
/* CM search panel, themed with the app variables (light/dark for free) */
|
|
1441
|
+
.cm-panels {
|
|
1442
|
+
background: var(--panel) !important;
|
|
1443
|
+
color: var(--text) !important;
|
|
1444
|
+
border-color: var(--border) !important;
|
|
1445
|
+
}
|
|
1446
|
+
.cm-panels input, .cm-panels button, .cm-panels label {
|
|
1447
|
+
font-family: var(--mono);
|
|
1448
|
+
font-size: 12px;
|
|
1449
|
+
color: var(--text);
|
|
1450
|
+
}
|
|
1451
|
+
.cm-panels input {
|
|
1452
|
+
background: var(--bg);
|
|
1453
|
+
border: 1px solid var(--border);
|
|
1454
|
+
border-radius: 4px;
|
|
1455
|
+
}
|
|
1456
|
+
.cm-panels button {
|
|
1457
|
+
background: var(--bg);
|
|
1458
|
+
border: 1px solid var(--border) !important;
|
|
1459
|
+
border-radius: 4px;
|
|
1460
|
+
cursor: pointer;
|
|
1461
|
+
}
|
|
1462
|
+
.cm-searchMatch { background: var(--warn-bg); outline: 1px solid var(--warn-border); }
|
|
1463
|
+
.cm-searchMatch-selected { background: var(--warn-border); }
|
|
1464
|
+
|
|
1465
|
+
/* rendered markdown preview: reading typography, sanitized content only */
|
|
1466
|
+
#preview {
|
|
1467
|
+
font-family: Georgia, "Iowan Old Style", "Times New Roman", serif;
|
|
1468
|
+
font-size: 16px;
|
|
1469
|
+
line-height: 1.65;
|
|
1470
|
+
padding: 18px 24px;
|
|
1471
|
+
}
|
|
1472
|
+
#preview > * { max-width: 42em; }
|
|
1473
|
+
#preview h1, #preview h2, #preview h3, #preview h4 {
|
|
1474
|
+
line-height: 1.25;
|
|
1475
|
+
margin: 1.2em 0 .5em;
|
|
1476
|
+
}
|
|
1477
|
+
#preview h1:first-child, #preview h2:first-child { margin-top: 0; }
|
|
1478
|
+
#preview p { margin: .8em 0; }
|
|
1479
|
+
#preview a { color: var(--accent); }
|
|
1480
|
+
#preview code {
|
|
1481
|
+
font-family: var(--mono);
|
|
1482
|
+
font-size: .85em;
|
|
1483
|
+
background: var(--panel);
|
|
1484
|
+
border: 1px solid var(--border);
|
|
1485
|
+
border-radius: 3px;
|
|
1486
|
+
padding: 0 4px;
|
|
1487
|
+
}
|
|
1488
|
+
#preview pre {
|
|
1489
|
+
background: var(--panel);
|
|
1490
|
+
border: 1px solid var(--border);
|
|
1491
|
+
border-radius: 5px;
|
|
1492
|
+
padding: 10px 12px;
|
|
1493
|
+
overflow: auto;
|
|
1494
|
+
}
|
|
1495
|
+
#preview pre code { border: none; background: none; padding: 0; }
|
|
1496
|
+
#preview blockquote {
|
|
1497
|
+
margin: .8em 0;
|
|
1498
|
+
padding-left: 14px;
|
|
1499
|
+
border-left: 3px solid var(--border);
|
|
1500
|
+
color: var(--muted);
|
|
1501
|
+
}
|
|
1502
|
+
#preview img { max-width: 100%; }
|
|
1503
|
+
#preview hr { border: none; border-top: 1px solid var(--border); }
|
|
1504
|
+
#preview table { border-collapse: collapse; }
|
|
1505
|
+
#preview th, #preview td { border: 1px solid var(--border); padding: 4px 9px; }
|
|
1506
|
+
#diff { white-space: pre-wrap; word-break: break-word; line-height: 1.6; }
|
|
1507
|
+
#diff .add { background: var(--add-bg); color: var(--add-fg); border-radius: 2px; }
|
|
1508
|
+
#diff .del { background: var(--del-bg); color: var(--del-fg); text-decoration: line-through; border-radius: 2px; }
|
|
1509
|
+
.hunk { border-radius: 3px; padding: 0 1px; }
|
|
1510
|
+
/* keep (default): show the agent's edit normally (del struck, add green).
|
|
1511
|
+
revert: the add won't be written (dim + strike), the baseline del is what stays. */
|
|
1512
|
+
.hunk-revert .add { opacity: .3; }
|
|
1513
|
+
.hunk-revert .del { text-decoration: none; opacity: 1; }
|
|
1514
|
+
.ctrl {
|
|
1515
|
+
display: inline-flex;
|
|
1516
|
+
gap: 1px;
|
|
1517
|
+
margin: 0 2px;
|
|
1518
|
+
vertical-align: baseline;
|
|
1519
|
+
white-space: nowrap;
|
|
1520
|
+
}
|
|
1521
|
+
.ctrl button {
|
|
1522
|
+
padding: 0 5px;
|
|
1523
|
+
font-size: 11px;
|
|
1524
|
+
line-height: 1.5;
|
|
1525
|
+
border-radius: 3px;
|
|
1526
|
+
}
|
|
1527
|
+
.ctrl button.rev { color: var(--del-fg); }
|
|
1528
|
+
.ctrl button.rev.on { background: var(--del-bg); color: var(--del-fg); border-color: var(--del-fg); font-weight: 700; }
|
|
1529
|
+
/* diff panel header: title left, the one-line how-to right. The hint
|
|
1530
|
+
inherits the h2 type exactly (same font, size, weight, uppercase,
|
|
1531
|
+
letter-spacing) so the two read as one header line. The hint's ✗ is drawn
|
|
1532
|
+
like the per-hunk buttons so the mapping is visual, not verbal — the
|
|
1533
|
+
buttons themselves stay a bare ✗ to keep dense diffs calm. The chip's
|
|
1534
|
+
fixed line-height + negative margin keep it from stretching the header
|
|
1535
|
+
taller than the left panel's. */
|
|
1536
|
+
h2.diffhead { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; }
|
|
1537
|
+
.diffhint { white-space: nowrap; }
|
|
1538
|
+
.diffhint .xdemo {
|
|
1539
|
+
display: inline-block; background: var(--bg); border: 1px solid var(--border);
|
|
1540
|
+
border-radius: 3px; padding: 0 4px; margin: -2px 0; color: var(--del-fg);
|
|
1541
|
+
font-size: 10px; line-height: 13px;
|
|
1542
|
+
}
|
|
1543
|
+
.empty { color: var(--muted); font-style: italic; }
|
|
1544
|
+
footer {
|
|
1545
|
+
border-top: 1px solid var(--border);
|
|
1546
|
+
background: var(--panel);
|
|
1547
|
+
padding: 7px 12px;
|
|
1548
|
+
display: flex;
|
|
1549
|
+
align-items: center;
|
|
1550
|
+
gap: 10px;
|
|
1551
|
+
}
|
|
1552
|
+
footer .counts { color: var(--muted); }
|
|
1553
|
+
footer .spacer { flex: 1; }
|
|
1554
|
+
.apply { background: var(--accent); color: var(--accent-fg); border-color: var(--accent); font-weight: 700; }
|
|
1555
|
+
.apply:hover { filter: brightness(1.06); }
|
|
1556
|
+
footer input[type="text"] {
|
|
1557
|
+
font-family: var(--mono);
|
|
1558
|
+
font-size: 12px;
|
|
1559
|
+
color: var(--text);
|
|
1560
|
+
background: var(--bg);
|
|
1561
|
+
border: 1px solid var(--border);
|
|
1562
|
+
border-radius: 5px;
|
|
1563
|
+
padding: 4px 8px;
|
|
1564
|
+
max-width: 210px;
|
|
1565
|
+
min-width: 60px;
|
|
1566
|
+
flex: 0 1 auto;
|
|
1567
|
+
}
|
|
1568
|
+
footer input[type="text"]:focus { outline: none; border-color: var(--accent); }
|
|
1569
|
+
#commit-btn { color: var(--add-fg); border-color: var(--add-fg); font-weight: 700; }
|
|
1570
|
+
#commit-btn.armed { background: var(--add-bg); }
|
|
1571
|
+
.hidden { display: none !important; }
|
|
1572
|
+
h2.unsaved { color: var(--del-fg); }
|
|
1573
|
+
button.attn { box-shadow: 0 0 0 2px var(--warn-border); }
|
|
1574
|
+
/* file + baseline share the top row, half each (flex-basis 0 splits the
|
|
1575
|
+
leftover space evenly regardless of content width) */
|
|
1576
|
+
.filepick, .basepick { display: inline-flex; align-items: center; gap: 5px; flex: 1 1 0; min-width: 0; }
|
|
1577
|
+
.filepick select, .basepick select { flex: 1 1 auto; min-width: 0; width: 100%; }
|
|
1578
|
+
#empty-msg { color: var(--muted); font-style: italic; padding: 16px 4px; }
|
|
1579
|
+
|
|
1580
|
+
/* right panel: scroll area + change-map gutter side by side */
|
|
1581
|
+
.right-body { flex: 1; display: flex; min-height: 0; min-width: 0; }
|
|
1582
|
+
.right-body #right-scroll { flex: 1; position: relative; } /* positioned: tick offsetTop reference */
|
|
1583
|
+
#diff .ln { display: block; padding-left: 4px; border-left: 3px solid transparent; }
|
|
1584
|
+
#diff .ln.changed { border-left-color: var(--border); }
|
|
1585
|
+
#diff .ln.current { background: rgba(127,127,127,.14); border-left-color: var(--accent); }
|
|
1586
|
+
.collapser {
|
|
1587
|
+
display: block; width: 100%; text-align: left;
|
|
1588
|
+
color: var(--muted); font-family: var(--mono); font-size: 12px; font-style: italic;
|
|
1589
|
+
background: none; border: none; border-top: 1px dashed var(--border);
|
|
1590
|
+
border-bottom: 1px dashed var(--border); padding: 2px 4px; margin: 2px 0; cursor: pointer;
|
|
1591
|
+
}
|
|
1592
|
+
.collapser:hover { color: var(--accent); border-color: var(--accent); }
|
|
1593
|
+
/* change-map gutter (minimap) */
|
|
1594
|
+
#minimap {
|
|
1595
|
+
flex: 0 0 14px; position: relative; background: var(--panel);
|
|
1596
|
+
border-left: 1px solid var(--border); cursor: pointer;
|
|
1597
|
+
}
|
|
1598
|
+
#minimap .tick { position: absolute; left: 2px; right: 2px; min-height: 3px; border-radius: 2px; }
|
|
1599
|
+
#minimap .tick.add { background: var(--add-fg); }
|
|
1600
|
+
#minimap .tick.del { background: var(--del-fg); }
|
|
1601
|
+
#minimap .tick.mix { background: var(--accent); }
|
|
1602
|
+
#minimap .view {
|
|
1603
|
+
position: absolute; left: 0; right: 0; background: rgba(127,127,127,.20);
|
|
1604
|
+
border: 1px solid var(--border); border-radius: 2px; pointer-events: none;
|
|
1605
|
+
}
|
|
1606
|
+
.nav { display: inline-flex; align-items: center; gap: 4px; }
|
|
1607
|
+
.nav button { padding: 0 7px; }
|
|
1608
|
+
.chgonly { color: var(--muted); display: inline-flex; align-items: center; gap: 4px; cursor: pointer; }
|
|
1609
|
+
/* untracked-file onboarding */
|
|
1610
|
+
.onboard { padding: 20px 8px; max-width: 46em; line-height: 1.7; }
|
|
1611
|
+
.onboard .muted { color: var(--muted); }
|
|
1612
|
+
.onboard button.linkish {
|
|
1613
|
+
background: none; border: none; padding: 0;
|
|
1614
|
+
color: var(--accent); text-decoration: underline; cursor: pointer;
|
|
1615
|
+
font-family: var(--mono); font-size: 12px;
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
/* editable preview: the rendered reading view doubles as a writing surface.
|
|
1619
|
+
Same reading typography as the read-only preview; a soft focus ring makes
|
|
1620
|
+
it obvious the text is editable. */
|
|
1621
|
+
#preview[contenteditable="true"] { outline: none; }
|
|
1622
|
+
#preview[contenteditable="true"]:focus { box-shadow: inset 0 0 0 2px var(--border); }
|
|
1623
|
+
#preview[contenteditable="true"] a { cursor: text; }
|
|
1624
|
+
|
|
1625
|
+
/* About: a modal over a dimmed backdrop */
|
|
1626
|
+
#about {
|
|
1627
|
+
position: fixed; inset: 0; z-index: 50;
|
|
1628
|
+
display: none; align-items: center; justify-content: center;
|
|
1629
|
+
background: rgba(0, 0, 0, .45);
|
|
1630
|
+
padding: 24px;
|
|
1631
|
+
}
|
|
1632
|
+
#about.show { display: flex; }
|
|
1633
|
+
#about .card {
|
|
1634
|
+
background: var(--bg); color: var(--text);
|
|
1635
|
+
border: 1px solid var(--border); border-radius: 10px;
|
|
1636
|
+
max-width: 40em; width: 100%; max-height: 82vh; overflow: auto;
|
|
1637
|
+
padding: 22px 26px; line-height: 1.6;
|
|
1638
|
+
box-shadow: 0 18px 50px rgba(0, 0, 0, .35);
|
|
1639
|
+
}
|
|
1640
|
+
#about h2 {
|
|
1641
|
+
margin: 0 0 4px; font-size: 18px; letter-spacing: .01em;
|
|
1642
|
+
display: inline-flex; align-items: center; gap: 8px;
|
|
1643
|
+
border: none; padding: 0; background: none; text-transform: none; color: var(--text);
|
|
1644
|
+
}
|
|
1645
|
+
#about .tag { color: var(--muted); font-size: 12px; margin: 0 0 14px; }
|
|
1646
|
+
#about p { margin: .7em 0; font-family: var(--mono); font-size: 12.5px; }
|
|
1647
|
+
#about h3 {
|
|
1648
|
+
margin: 1.2em 0 .3em; font-size: 11px; text-transform: uppercase;
|
|
1649
|
+
letter-spacing: .06em; color: var(--muted);
|
|
1650
|
+
}
|
|
1651
|
+
#about code {
|
|
1652
|
+
font-family: var(--mono); font-size: .9em;
|
|
1653
|
+
background: var(--panel); border: 1px solid var(--border);
|
|
1654
|
+
border-radius: 3px; padding: 0 4px;
|
|
1655
|
+
}
|
|
1656
|
+
#about .about-close { position: sticky; top: 0; float: right; margin: -6px -8px 0 0; }
|
|
1657
|
+
#about a { color: var(--accent); }
|
|
1658
|
+
#about .about-meta {
|
|
1659
|
+
margin-top: 18px; padding-top: 12px; border-top: 1px solid var(--border);
|
|
1660
|
+
color: var(--muted); font-size: 11.5px; line-height: 1.7;
|
|
1661
|
+
}
|
|
1662
|
+
</style>
|
|
1663
|
+
</head>
|
|
1664
|
+
<body>
|
|
1665
|
+
<header>
|
|
1666
|
+
<div class="bar">
|
|
1667
|
+
<span class="title"><span class="mark"><span class="ma">+</span><span class="md">−</span></span>Draftwatch</span>
|
|
1668
|
+
<span class="filepick">
|
|
1669
|
+
<label class="status" for="file-select">file</label>
|
|
1670
|
+
<select id="file-select" title="pick a writing file in this repo"></select>
|
|
1671
|
+
</span>
|
|
1672
|
+
<span class="basepick">
|
|
1673
|
+
<label class="status" for="baseline">baseline</label>
|
|
1674
|
+
<select id="baseline"></select>
|
|
1675
|
+
</span>
|
|
1676
|
+
</div>
|
|
1677
|
+
<div class="bar" id="edit-toolbar">
|
|
1678
|
+
<button id="view-toggle" title="switch the left panel between markdown source and a rendered reading view">preview</button>
|
|
1679
|
+
<span class="status" id="fmt-label">format</span>
|
|
1680
|
+
<span class="fmt">
|
|
1681
|
+
<button id="fmt-bold" title="bold (⌘/Ctrl-B)"><b>B</b></button>
|
|
1682
|
+
<button id="fmt-italic" title="italic (⌘/Ctrl-I)"><i>I</i></button>
|
|
1683
|
+
<button id="fmt-link" title="link (⌘/Ctrl-K)">link</button>
|
|
1684
|
+
</span>
|
|
1685
|
+
<button id="save" class="apply" title="write the buffer to disk (⌘/Ctrl-S)">save to file</button>
|
|
1686
|
+
<span class="status" id="status">connecting…</span>
|
|
1687
|
+
<button id="theme-toggle" title="theme: auto / light / dark">theme: auto</button>
|
|
1688
|
+
<button id="accent-toggle" title="accent color — click to cycle"><span class="swatch"></span><span id="accent-name">blue</span></button>
|
|
1689
|
+
<button id="about-btn" title="what is Draftwatch?">about</button>
|
|
1690
|
+
</div>
|
|
1691
|
+
</header>
|
|
1692
|
+
|
|
1693
|
+
<div id="banner">
|
|
1694
|
+
<span id="banner-text"></span>
|
|
1695
|
+
<span class="spacer"></span>
|
|
1696
|
+
<button id="banner-overwrite">keep my edits</button>
|
|
1697
|
+
<button id="banner-reload">reload</button>
|
|
1698
|
+
</div>
|
|
1699
|
+
|
|
1700
|
+
<div id="about" role="dialog" aria-modal="true" aria-label="About Draftwatch">
|
|
1701
|
+
<div class="card">
|
|
1702
|
+
<button class="about-close" id="about-close" title="close (Esc)">close ✕</button>
|
|
1703
|
+
<h2><span class="mark"><span class="ma">+</span><span class="md">−</span></span>Draftwatch</h2>
|
|
1704
|
+
<p class="tag">Review an AI agent's edits as a real git diff — locally.</p>
|
|
1705
|
+
<p>An autonomous agent (Claude Code and the like) edits your files on disk.
|
|
1706
|
+
Draftwatch shows you exactly what changed, as the word-level diff
|
|
1707
|
+
<strong>git itself produces on your machine</strong> — not an AI's summary,
|
|
1708
|
+
not a JavaScript guess. You keep or revert each change, then commit.</p>
|
|
1709
|
+
<h3>The review loop</h3>
|
|
1710
|
+
<p>Read the diff on the right. Click the <code>✗</code> beside any change you
|
|
1711
|
+
don't want. <strong>Apply</strong> writes the result to disk — keeping the
|
|
1712
|
+
rest, reverting the marked spans. <strong>Commit</strong> records the file
|
|
1713
|
+
to git; the baseline advances to that commit, the diff clears to zero, and
|
|
1714
|
+
the next agent pass starts clean.</p>
|
|
1715
|
+
<h3>Two panels</h3>
|
|
1716
|
+
<p>Left is your working text: edit the markdown source directly, or switch to
|
|
1717
|
+
the rendered <strong>Preview</strong>, which is also editable. Right is the
|
|
1718
|
+
diff against the baseline — added words in green, removed words struck in
|
|
1719
|
+
red — with a change map, next/previous navigation, and a changes-only view.</p>
|
|
1720
|
+
<h3>Editing in Preview</h3>
|
|
1721
|
+
<p>Typing in the rendered Preview rewrites the markdown source in a normalized
|
|
1722
|
+
style, so heading, emphasis, and spacing conventions can shift. When you
|
|
1723
|
+
need byte-exact control of the source, edit in the <strong>Source</strong>
|
|
1724
|
+
view instead.</p>
|
|
1725
|
+
<h3>Local and private</h3>
|
|
1726
|
+
<p>Draftwatch runs a small server bound to <code>127.0.0.1</code>, guarded by a
|
|
1727
|
+
per-session token. It never talks to an LLM and never sends your file
|
|
1728
|
+
anywhere.</p>
|
|
1729
|
+
<p class="about-meta">
|
|
1730
|
+
By <strong>Mike Konczal</strong> · vibe-coded with Fable 5<br>
|
|
1731
|
+
Draftwatch v{{VERSION}} · {{RELEASE_DATE}}
|
|
1732
|
+
</p>
|
|
1733
|
+
</div>
|
|
1734
|
+
</div>
|
|
1735
|
+
|
|
1736
|
+
<main>
|
|
1737
|
+
<section class="panel left">
|
|
1738
|
+
<h2 id="left-title">working text</h2>
|
|
1739
|
+
<div id="editor-host"></div>
|
|
1740
|
+
<div id="preview" class="scroll hidden"></div>
|
|
1741
|
+
</section>
|
|
1742
|
+
<section class="panel right">
|
|
1743
|
+
<h2 class="diffhead"><span>diff vs baseline</span><span class="diffhint">click <span class="xdemo">✗</span> to revert a change</span></h2>
|
|
1744
|
+
<div class="right-body">
|
|
1745
|
+
<div class="scroll" id="right-scroll">
|
|
1746
|
+
<div id="diff"></div>
|
|
1747
|
+
</div>
|
|
1748
|
+
<div id="minimap" title="change map — click to jump"></div>
|
|
1749
|
+
</div>
|
|
1750
|
+
<footer>
|
|
1751
|
+
<span class="counts" id="counts">0 hunks</span>
|
|
1752
|
+
<span class="nav hidden" id="nav">
|
|
1753
|
+
<button id="prev-change" title="previous change (p)">▲</button>
|
|
1754
|
+
<button id="next-change" title="next change (n)">▼</button>
|
|
1755
|
+
<span class="counts" id="nav-pos"></span>
|
|
1756
|
+
</span>
|
|
1757
|
+
<label class="chgonly"><input type="checkbox" id="changes-only"> changes only</label>
|
|
1758
|
+
<span class="spacer"></span>
|
|
1759
|
+
<button id="revert-all" title="mark every change to revert">revert all</button>
|
|
1760
|
+
<button id="keep-all" title="clear all revert marks (keep every change)">keep all</button>
|
|
1761
|
+
<button id="apply" class="apply">apply</button>
|
|
1762
|
+
<input type="text" id="commit-msg" class="hidden" placeholder="commit message (optional) — Enter to commit, Esc to cancel">
|
|
1763
|
+
<button id="commit-btn" title="git commit this version — the diff clears and future edits show against it">commit</button>
|
|
1764
|
+
</footer>
|
|
1765
|
+
</section>
|
|
1766
|
+
</main>
|
|
1767
|
+
|
|
1768
|
+
<script>
|
|
1769
|
+
"use strict";
|
|
1770
|
+
(function () {
|
|
1771
|
+
// ---- state ----
|
|
1772
|
+
var dirty = false; // unsaved edits in edit mode
|
|
1773
|
+
var segments = [];
|
|
1774
|
+
var hunks = [];
|
|
1775
|
+
var decisions = {}; // {hunkId: "reject"} for hunks marked to revert; absent = keep
|
|
1776
|
+
var rawText = "";
|
|
1777
|
+
var baseline = null;
|
|
1778
|
+
var diffEpoch = null; // fingerprint of the rendered diff (echoed on apply)
|
|
1779
|
+
var currentFile = null; // relpath of the open file, or null when none
|
|
1780
|
+
var WRITING_EXT = [".md", ".markdown", ".qmd", ".txt", ".rst"];
|
|
1781
|
+
var changesOnly = false; // "just changes" collapsed view
|
|
1782
|
+
var showEmptyTreeDiff = false; // untracked file: user opted into the full "all added" diff
|
|
1783
|
+
var hunkOrder = []; // hunk ids in document order (next/prev + minimap)
|
|
1784
|
+
var hunkKind = {}; // hunkId -> "add" | "del" | "mix"
|
|
1785
|
+
var hunkLine = {}; // hunkId -> 0-based line index in the working text (rawText)
|
|
1786
|
+
var totalLines = 1; // line count of rawText; denominator for proportional positions
|
|
1787
|
+
var currentIdx = -1; // index into hunkOrder for next/prev
|
|
1788
|
+
var CONTEXT_LINES = 3; // lines kept around each change in "changes only"
|
|
1789
|
+
|
|
1790
|
+
var $ = function (id) { return document.getElementById(id); };
|
|
1791
|
+
function esc(s) {
|
|
1792
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
1793
|
+
}
|
|
1794
|
+
|
|
1795
|
+
// ---- session token (from the URL draftwatch printed/opened) ----
|
|
1796
|
+
var TOKEN = (function () {
|
|
1797
|
+
var m = /(?:\?|&)t=([^&]+)/.exec(window.location.search);
|
|
1798
|
+
return m ? decodeURIComponent(m[1]) : "";
|
|
1799
|
+
})();
|
|
1800
|
+
|
|
1801
|
+
// ---- launch surface (native window vs browser) ----
|
|
1802
|
+
// app=1: running inside the pywebview window -> app chrome styling.
|
|
1803
|
+
// appfallback=1: the native window was requested but unavailable -> say so
|
|
1804
|
+
// in the UI instead of leaving the silent switch to the browser unexplained.
|
|
1805
|
+
var APP_MODE = /(?:\?|&)app=1(?:&|$)/.test(window.location.search);
|
|
1806
|
+
var APP_FALLBACK = /(?:\?|&)appfallback=1(?:&|$)/.test(window.location.search);
|
|
1807
|
+
if (APP_MODE) document.body.classList.add("app-mode");
|
|
1808
|
+
|
|
1809
|
+
// ---- server notification of client state (turn-based contract) ----
|
|
1810
|
+
function postJSON(url, obj, cb) {
|
|
1811
|
+
fetch(url, {
|
|
1812
|
+
method: "POST",
|
|
1813
|
+
headers: { "Content-Type": "application/json", "X-Draftwatch-Token": TOKEN },
|
|
1814
|
+
body: JSON.stringify(obj || {})
|
|
1815
|
+
}).then(function (r) { return r.json().catch(function () { return {}; }); })
|
|
1816
|
+
.then(function (d) { if (cb) cb(d); })
|
|
1817
|
+
.catch(function () { if (cb) cb({ error: "request failed" }); });
|
|
1818
|
+
}
|
|
1819
|
+
function getJSON(url) {
|
|
1820
|
+
return fetch(url, { headers: { "X-Draftwatch-Token": TOKEN } })
|
|
1821
|
+
.then(function (r) { return r.json(); });
|
|
1822
|
+
}
|
|
1823
|
+
function reportClientState() {
|
|
1824
|
+
postJSON("/api/client_state", { dirty: dirty });
|
|
1825
|
+
}
|
|
1826
|
+
|
|
1827
|
+
// ---- left panel: CodeMirror 6 editor ----
|
|
1828
|
+
// Markdown syntax highlighting, real undo history, search panel (Mod-F),
|
|
1829
|
+
// soft wrap. Theme rides on the app's CSS variables, so dark mode is free.
|
|
1830
|
+
var applyingRemote = false; // programmatic doc swaps must not mark dirty
|
|
1831
|
+
var editorFile = null; // file the current editor state belongs to
|
|
1832
|
+
|
|
1833
|
+
var mdHighlight = CM.HighlightStyle.define([
|
|
1834
|
+
{ tag: CM.tags.heading, fontWeight: "700", color: "var(--accent)" },
|
|
1835
|
+
{ tag: CM.tags.strong, fontWeight: "700" },
|
|
1836
|
+
{ tag: CM.tags.emphasis, fontStyle: "italic" },
|
|
1837
|
+
{ tag: CM.tags.strikethrough, textDecoration: "line-through" },
|
|
1838
|
+
{ tag: CM.tags.link, color: "var(--accent)", textDecoration: "underline" },
|
|
1839
|
+
{ tag: CM.tags.url, color: "var(--accent)" },
|
|
1840
|
+
{ tag: CM.tags.monospace, color: "var(--del-fg)" },
|
|
1841
|
+
{ tag: CM.tags.quote, color: "var(--muted)", fontStyle: "italic" },
|
|
1842
|
+
{ tag: CM.tags.contentSeparator, color: "var(--muted)" },
|
|
1843
|
+
{ tag: CM.tags.meta, color: "var(--muted)" },
|
|
1844
|
+
{ tag: CM.tags.processingInstruction, color: "var(--muted)" },
|
|
1845
|
+
{ tag: CM.tags.labelName, color: "var(--muted)" }
|
|
1846
|
+
]);
|
|
1847
|
+
|
|
1848
|
+
// typing a markup char over a selection wraps it instead of replacing it
|
|
1849
|
+
// (the same affordance the old textarea handler provided)
|
|
1850
|
+
var WRAP_PAIRS = { "*": "*", "_": "_", "`": "`", "(": ")", "[": "]", "{": "}", '"': '"', "'": "'" };
|
|
1851
|
+
var wrapInput = CM.EditorView.inputHandler.of(function (view, from, to, text) {
|
|
1852
|
+
if (!Object.prototype.hasOwnProperty.call(WRAP_PAIRS, text)) return false;
|
|
1853
|
+
var sel = view.state.selection.main;
|
|
1854
|
+
if (sel.empty) return false;
|
|
1855
|
+
view.dispatch({
|
|
1856
|
+
changes: [
|
|
1857
|
+
{ from: sel.from, insert: text },
|
|
1858
|
+
{ from: sel.to, insert: WRAP_PAIRS[text] }
|
|
1859
|
+
],
|
|
1860
|
+
selection: { anchor: sel.from + text.length, head: sel.to + text.length }
|
|
1861
|
+
});
|
|
1862
|
+
return true;
|
|
1863
|
+
});
|
|
1864
|
+
|
|
1865
|
+
// Wrap (or, when already wrapped and toggle is set, unwrap) the selection.
|
|
1866
|
+
function surroundSelection(before, after, toggle) {
|
|
1867
|
+
var st = ed.state, sel = st.selection.main;
|
|
1868
|
+
var from = sel.from, to = sel.to, doc = st.doc;
|
|
1869
|
+
if (toggle && to > from && from >= before.length &&
|
|
1870
|
+
doc.sliceString(from - before.length, from) === before &&
|
|
1871
|
+
doc.sliceString(to, to + after.length) === after) {
|
|
1872
|
+
ed.dispatch({
|
|
1873
|
+
changes: [
|
|
1874
|
+
{ from: from - before.length, to: from, insert: "" },
|
|
1875
|
+
{ from: to, to: to + after.length, insert: "" }
|
|
1876
|
+
],
|
|
1877
|
+
selection: { anchor: from - before.length, head: to - before.length }
|
|
1878
|
+
});
|
|
1879
|
+
} else {
|
|
1880
|
+
ed.dispatch({
|
|
1881
|
+
changes: [
|
|
1882
|
+
{ from: from, insert: before },
|
|
1883
|
+
{ from: to, insert: after }
|
|
1884
|
+
],
|
|
1885
|
+
selection: (to > from)
|
|
1886
|
+
? { anchor: from + before.length, head: to + before.length }
|
|
1887
|
+
: { anchor: from + before.length }
|
|
1888
|
+
});
|
|
1889
|
+
}
|
|
1890
|
+
ed.focus();
|
|
1891
|
+
}
|
|
1892
|
+
|
|
1893
|
+
// Insert a [text](url) link around the selection, caret dropped in the url slot.
|
|
1894
|
+
function insertLink() {
|
|
1895
|
+
var sel = ed.state.selection.main;
|
|
1896
|
+
var txt = ed.state.doc.sliceString(sel.from, sel.to);
|
|
1897
|
+
ed.dispatch({
|
|
1898
|
+
changes: { from: sel.from, to: sel.to, insert: "[" + txt + "]()" },
|
|
1899
|
+
selection: { anchor: sel.from + txt.length + 3 } // between ( and )
|
|
1900
|
+
});
|
|
1901
|
+
ed.focus();
|
|
1902
|
+
}
|
|
1903
|
+
|
|
1904
|
+
var fmtKeymap = [
|
|
1905
|
+
{ key: "Mod-b", run: function () { surroundSelection("**", "**", true); return true; } },
|
|
1906
|
+
{ key: "Mod-i", run: function () { surroundSelection("*", "*", true); return true; } },
|
|
1907
|
+
{ key: "Mod-e", run: function () { surroundSelection("`", "`", true); return true; } },
|
|
1908
|
+
{ key: "Mod-k", run: function () { insertLink(); return true; } }
|
|
1909
|
+
];
|
|
1910
|
+
|
|
1911
|
+
function editorExtensions() {
|
|
1912
|
+
return [
|
|
1913
|
+
CM.history(),
|
|
1914
|
+
CM.drawSelection(),
|
|
1915
|
+
CM.EditorView.lineWrapping,
|
|
1916
|
+
CM.markdown(),
|
|
1917
|
+
CM.syntaxHighlighting(mdHighlight),
|
|
1918
|
+
CM.syntaxHighlighting(CM.defaultHighlightStyle, { fallback: true }),
|
|
1919
|
+
CM.search({ top: true }),
|
|
1920
|
+
CM.highlightSelectionMatches(),
|
|
1921
|
+
wrapInput,
|
|
1922
|
+
CM.keymap.of(fmtKeymap.concat(CM.searchKeymap, CM.historyKeymap, CM.defaultKeymap)),
|
|
1923
|
+
CM.placeholder("No file open — pick one with the “file” control above, or start typing and save (⌘S) to create a new file."),
|
|
1924
|
+
CM.EditorView.updateListener.of(function (u) {
|
|
1925
|
+
if (u.docChanged && !applyingRemote) {
|
|
1926
|
+
if (!dirty) { dirty = true; reportClientState(); }
|
|
1927
|
+
updateLeftTitle();
|
|
1928
|
+
}
|
|
1929
|
+
})
|
|
1930
|
+
];
|
|
1931
|
+
}
|
|
1932
|
+
|
|
1933
|
+
var ed = new CM.EditorView({
|
|
1934
|
+
state: CM.EditorState.create({ doc: "", extensions: editorExtensions() }),
|
|
1935
|
+
parent: $("editor-host")
|
|
1936
|
+
});
|
|
1937
|
+
|
|
1938
|
+
function editorText() { return ed.state.doc.toString(); }
|
|
1939
|
+
|
|
1940
|
+
// Replace the buffer programmatically (never marks dirty). A file switch
|
|
1941
|
+
// resets the whole state so undo history can't cross files.
|
|
1942
|
+
function setEditorDoc(text, newFile) {
|
|
1943
|
+
applyingRemote = true;
|
|
1944
|
+
try {
|
|
1945
|
+
if (newFile) {
|
|
1946
|
+
ed.setState(CM.EditorState.create({ doc: text, extensions: editorExtensions() }));
|
|
1947
|
+
} else if (editorText() !== text) {
|
|
1948
|
+
var selHead = Math.min(ed.state.selection.main.head, text.length);
|
|
1949
|
+
ed.dispatch({
|
|
1950
|
+
changes: { from: 0, to: ed.state.doc.length, insert: text },
|
|
1951
|
+
selection: { anchor: selHead }
|
|
1952
|
+
});
|
|
1953
|
+
}
|
|
1954
|
+
} finally {
|
|
1955
|
+
applyingRemote = false;
|
|
1956
|
+
}
|
|
1957
|
+
}
|
|
1958
|
+
|
|
1959
|
+
function renderLeft() {
|
|
1960
|
+
var isNewFile = (editorFile !== currentFile);
|
|
1961
|
+
if (!currentFile) {
|
|
1962
|
+
editorFile = null;
|
|
1963
|
+
setEditorDoc("", isNewFile); // placeholder shows through
|
|
1964
|
+
return;
|
|
1965
|
+
}
|
|
1966
|
+
// only replace the buffer when not dirty (do not clobber edits)
|
|
1967
|
+
if (!dirty) {
|
|
1968
|
+
setEditorDoc(rawText, isNewFile);
|
|
1969
|
+
editorFile = currentFile;
|
|
1970
|
+
}
|
|
1971
|
+
// keep the reading view live, but never overwrite in-progress preview edits
|
|
1972
|
+
if (previewMode && !dirty) renderPreview();
|
|
1973
|
+
}
|
|
1974
|
+
|
|
1975
|
+
// ---- rendering: right panel (diff), line by line ----
|
|
1976
|
+
|
|
1977
|
+
// Group segments into display lines. Each line ends at a newline segment.
|
|
1978
|
+
function buildLines() {
|
|
1979
|
+
var lines = [];
|
|
1980
|
+
var cur = { segs: [], changed: false, hunks: [] };
|
|
1981
|
+
for (var i = 0; i < segments.length; i++) {
|
|
1982
|
+
var s = segments[i];
|
|
1983
|
+
if (s.type === "newline") {
|
|
1984
|
+
lines.push(cur);
|
|
1985
|
+
cur = { segs: [], changed: false, hunks: [] };
|
|
1986
|
+
} else {
|
|
1987
|
+
cur.segs.push(s);
|
|
1988
|
+
if (s.type === "add" || s.type === "del") {
|
|
1989
|
+
cur.changed = true;
|
|
1990
|
+
if (cur.hunks.indexOf(s.hunk) === -1) cur.hunks.push(s.hunk);
|
|
1991
|
+
}
|
|
1992
|
+
}
|
|
1993
|
+
}
|
|
1994
|
+
// a trailing partial line (no closing newline) — include if it has content
|
|
1995
|
+
if (cur.segs.length) lines.push(cur);
|
|
1996
|
+
return lines;
|
|
1997
|
+
}
|
|
1998
|
+
|
|
1999
|
+
// Render one display line's segments into HTML (with hunk wrappers + controls).
|
|
2000
|
+
function renderLineHTML(line) {
|
|
2001
|
+
var parts = [];
|
|
2002
|
+
var j = 0;
|
|
2003
|
+
while (j < line.segs.length) {
|
|
2004
|
+
var s = line.segs[j];
|
|
2005
|
+
if (s.type === "context") {
|
|
2006
|
+
parts.push(esc(s.text));
|
|
2007
|
+
j++;
|
|
2008
|
+
} else {
|
|
2009
|
+
var h = s.hunk;
|
|
2010
|
+
var reverted = decisions[h] === "reject";
|
|
2011
|
+
var inner = [];
|
|
2012
|
+
while (j < line.segs.length &&
|
|
2013
|
+
(line.segs[j].type === "add" || line.segs[j].type === "del") &&
|
|
2014
|
+
line.segs[j].hunk === h) {
|
|
2015
|
+
inner.push('<span class="' + line.segs[j].type + '">' + esc(line.segs[j].text) + "</span>");
|
|
2016
|
+
j++;
|
|
2017
|
+
}
|
|
2018
|
+
parts.push('<span class="hunk' + (reverted ? " hunk-revert" : "") + '" id="hunk-' + h +
|
|
2019
|
+
'" data-hunk="' + h + '">' + inner.join("") + "</span>");
|
|
2020
|
+
// a bare ✗ (state carried by the .on highlight + the title text) —
|
|
2021
|
+
// the wordy revert/reverted labels made heavily edited lines unreadable
|
|
2022
|
+
parts.push(
|
|
2023
|
+
'<span class="ctrl" data-hunk="' + h + '">' +
|
|
2024
|
+
'<button class="rev' + (reverted ? " on" : "") + '" data-hunk="' + h +
|
|
2025
|
+
'" title="' + (reverted ? "reverted — click to keep this change" : "revert this change to the baseline") +
|
|
2026
|
+
'">✗</button>' +
|
|
2027
|
+
"</span>"
|
|
2028
|
+
);
|
|
2029
|
+
}
|
|
2030
|
+
}
|
|
2031
|
+
// a blank line still needs height
|
|
2032
|
+
return parts.length ? parts.join("") : " ";
|
|
2033
|
+
}
|
|
2034
|
+
|
|
2035
|
+
function renderDiff() {
|
|
2036
|
+
var scroll = $("right-scroll");
|
|
2037
|
+
var top = scroll.scrollTop;
|
|
2038
|
+
var diff = $("diff");
|
|
2039
|
+
|
|
2040
|
+
// Untracked-file onboarding: with no committed version there is no baseline,
|
|
2041
|
+
// so the "diff" is the entire document as one added line per line — thousands
|
|
2042
|
+
// of revert controls for content that isn't a reviewable change. Offer to
|
|
2043
|
+
// start tracking (first commit) instead; the raw empty-tree diff stays
|
|
2044
|
+
// one click away.
|
|
2045
|
+
if (currentFile && baseline && baseline.kind === "empty" && !showEmptyTreeDiff) {
|
|
2046
|
+
hunkOrder = []; hunkKind = {}; hunkLine = {}; currentIdx = -1;
|
|
2047
|
+
diff.innerHTML =
|
|
2048
|
+
'<div class="onboard">' +
|
|
2049
|
+
'<p><strong>' + esc(currentFile) + " isn’t being tracked by git yet.</strong></p>" +
|
|
2050
|
+
"<p>There’s no earlier version to compare against, so the whole document " +
|
|
2051
|
+
"would show as one giant “added” change — not a useful review.</p>" +
|
|
2052
|
+
'<p><button id="start-tracking" class="apply">start tracking this file</button></p>' +
|
|
2053
|
+
'<p class="muted">This saves the current version as the first commit (a git commit). ' +
|
|
2054
|
+
"From then on, the panel shows only what actually changed.</p>" +
|
|
2055
|
+
'<p class="muted"><button id="show-empty-diff" class="linkish">show the full file as additions instead</button></p>' +
|
|
2056
|
+
"</div>";
|
|
2057
|
+
$("counts").textContent = "not tracked yet";
|
|
2058
|
+
$("nav").classList.add("hidden");
|
|
2059
|
+
buildMinimap();
|
|
2060
|
+
return;
|
|
2061
|
+
}
|
|
2062
|
+
|
|
2063
|
+
// (re)compute hunk order + per-hunk kind/line position for navigation/minimap.
|
|
2064
|
+
// Line position is derived arithmetically — counting newlines that survive into
|
|
2065
|
+
// the working text as we walk segments once — rather than by reading DOM
|
|
2066
|
+
// offsetTop per hunk. That offsetTop approach is what made the minimap (and the
|
|
2067
|
+
// left-editor scroll-sync below) freeze the tab on very large documents: it
|
|
2068
|
+
// forces a synchronous layout on every one of thousands of reads.
|
|
2069
|
+
hunkOrder = hunks.slice();
|
|
2070
|
+
if (currentIdx >= hunkOrder.length) currentIdx = -1; // clamp after live updates
|
|
2071
|
+
hunkKind = {};
|
|
2072
|
+
hunkLine = {};
|
|
2073
|
+
var lineIdx = 0;
|
|
2074
|
+
for (var a = 0; a < segments.length; a++) {
|
|
2075
|
+
var sg = segments[a];
|
|
2076
|
+
if (sg.type === "add" || sg.type === "del") {
|
|
2077
|
+
var k = hunkKind[sg.hunk];
|
|
2078
|
+
hunkKind[sg.hunk] = !k ? sg.type : (k === sg.type ? k : "mix");
|
|
2079
|
+
if (!(sg.hunk in hunkLine)) hunkLine[sg.hunk] = lineIdx;
|
|
2080
|
+
} else if (sg.type === "newline" && (sg.side === "common" || sg.side === "add")) {
|
|
2081
|
+
lineIdx++;
|
|
2082
|
+
}
|
|
2083
|
+
}
|
|
2084
|
+
totalLines = Math.max(1, rawText.split("\n").length);
|
|
2085
|
+
|
|
2086
|
+
if (!segments.length || !hunks.length) {
|
|
2087
|
+
diff.innerHTML = '<span class="empty">' +
|
|
2088
|
+
(currentFile ? "No changes vs baseline." : "No file open.") + "</span>";
|
|
2089
|
+
scroll.scrollTop = top;
|
|
2090
|
+
buildMinimap();
|
|
2091
|
+
updateCounts();
|
|
2092
|
+
updateNav();
|
|
2093
|
+
return;
|
|
2094
|
+
}
|
|
2095
|
+
|
|
2096
|
+
var lines = buildLines();
|
|
2097
|
+
|
|
2098
|
+
// decide which lines are visible (all, or changed +/- CONTEXT in "changes only")
|
|
2099
|
+
var visible = new Array(lines.length);
|
|
2100
|
+
if (!changesOnly) {
|
|
2101
|
+
for (var v = 0; v < lines.length; v++) visible[v] = true;
|
|
2102
|
+
} else {
|
|
2103
|
+
for (var v2 = 0; v2 < lines.length; v2++) visible[v2] = false;
|
|
2104
|
+
for (var c = 0; c < lines.length; c++) {
|
|
2105
|
+
if (lines[c].changed) {
|
|
2106
|
+
for (var d = Math.max(0, c - CONTEXT_LINES);
|
|
2107
|
+
d <= Math.min(lines.length - 1, c + CONTEXT_LINES); d++) {
|
|
2108
|
+
visible[d] = true;
|
|
2109
|
+
}
|
|
2110
|
+
}
|
|
2111
|
+
}
|
|
2112
|
+
}
|
|
2113
|
+
|
|
2114
|
+
var html = [];
|
|
2115
|
+
var gapIdx = 0;
|
|
2116
|
+
var li = 0;
|
|
2117
|
+
while (li < lines.length) {
|
|
2118
|
+
if (visible[li]) {
|
|
2119
|
+
var ln = lines[li];
|
|
2120
|
+
html.push('<div class="ln' + (ln.changed ? " changed" : "") + '">' +
|
|
2121
|
+
renderLineHTML(ln) + "</div>");
|
|
2122
|
+
li++;
|
|
2123
|
+
} else {
|
|
2124
|
+
// collapse a run of hidden lines into a clickable expander
|
|
2125
|
+
var start = li;
|
|
2126
|
+
while (li < lines.length && !visible[li]) li++;
|
|
2127
|
+
var count = li - start;
|
|
2128
|
+
var gid = "gap-" + (gapIdx++);
|
|
2129
|
+
var inner = [];
|
|
2130
|
+
for (var g = start; g < li; g++) {
|
|
2131
|
+
inner.push('<div class="ln">' + renderLineHTML(lines[g]) + "</div>");
|
|
2132
|
+
}
|
|
2133
|
+
gapContent[gid] = inner.join("");
|
|
2134
|
+
html.push('<button class="collapser" data-gap="' + gid + '">⋯ ' + count +
|
|
2135
|
+
" unchanged line" + (count === 1 ? "" : "s") + " — click to expand ⋯</button>");
|
|
2136
|
+
}
|
|
2137
|
+
}
|
|
2138
|
+
diff.innerHTML = html.join("");
|
|
2139
|
+
scroll.scrollTop = top;
|
|
2140
|
+
buildMinimap();
|
|
2141
|
+
updateCounts();
|
|
2142
|
+
updateNav();
|
|
2143
|
+
}
|
|
2144
|
+
|
|
2145
|
+
var gapContent = {};
|
|
2146
|
+
|
|
2147
|
+
// ---- change-map gutter (minimap) ----
|
|
2148
|
+
function buildMinimap() {
|
|
2149
|
+
var mm = $("minimap");
|
|
2150
|
+
mm.innerHTML = "";
|
|
2151
|
+
if (!hunkOrder.length) return;
|
|
2152
|
+
var mh = mm.clientHeight || 1;
|
|
2153
|
+
// Positioned by line fraction (hunkLine[id] / totalLines, computed once in
|
|
2154
|
+
// renderDiff()) instead of document.getElementById + el.offsetTop per hunk.
|
|
2155
|
+
for (var i = 0; i < hunkOrder.length; i++) {
|
|
2156
|
+
var id = hunkOrder[i];
|
|
2157
|
+
if (!(id in hunkLine)) continue;
|
|
2158
|
+
var frac = hunkLine[id] / totalLines;
|
|
2159
|
+
var tick = document.createElement("div");
|
|
2160
|
+
tick.className = "tick " + (hunkKind[id] || "mix");
|
|
2161
|
+
tick.style.top = (frac * mh) + "px";
|
|
2162
|
+
tick.setAttribute("data-idx", i);
|
|
2163
|
+
mm.appendChild(tick);
|
|
2164
|
+
}
|
|
2165
|
+
var view = document.createElement("div");
|
|
2166
|
+
view.className = "view";
|
|
2167
|
+
mm.appendChild(view);
|
|
2168
|
+
updateViewport();
|
|
2169
|
+
}
|
|
2170
|
+
|
|
2171
|
+
function updateViewport() {
|
|
2172
|
+
var mm = $("minimap");
|
|
2173
|
+
var view = mm.querySelector(".view");
|
|
2174
|
+
if (!view) return;
|
|
2175
|
+
var scroll = $("right-scroll");
|
|
2176
|
+
var total = scroll.scrollHeight || 1;
|
|
2177
|
+
var mh = mm.clientHeight || 1;
|
|
2178
|
+
view.style.top = (scroll.scrollTop / total * mh) + "px";
|
|
2179
|
+
view.style.height = Math.max(8, (scroll.clientHeight / total * mh)) + "px";
|
|
2180
|
+
}
|
|
2181
|
+
|
|
2182
|
+
// ---- navigation: prev / next change ----
|
|
2183
|
+
|
|
2184
|
+
// Exact scroll-sync: bring the same change into view in the left editor when
|
|
2185
|
+
// you jump to it on the right (minimap click, or n/p). CodeMirror knows its
|
|
2186
|
+
// line geometry, so this centers the actual line — the old <textarea> version
|
|
2187
|
+
// could only approximate by document fraction.
|
|
2188
|
+
function scrollEditorToLine(lineIdx) {
|
|
2189
|
+
if (!ed || lineIdx == null) return;
|
|
2190
|
+
var docLines = ed.state.doc.lines;
|
|
2191
|
+
var line = ed.state.doc.line(Math.max(1, Math.min(lineIdx + 1, docLines)));
|
|
2192
|
+
ed.dispatch({ effects: CM.EditorView.scrollIntoView(line.from, { y: "center" }) });
|
|
2193
|
+
}
|
|
2194
|
+
|
|
2195
|
+
function goToChange(idx) {
|
|
2196
|
+
if (!hunkOrder.length) return;
|
|
2197
|
+
if (idx < 0) idx = hunkOrder.length - 1; // wrap
|
|
2198
|
+
if (idx >= hunkOrder.length) idx = 0;
|
|
2199
|
+
currentIdx = idx;
|
|
2200
|
+
var el = document.getElementById("hunk-" + hunkOrder[idx]);
|
|
2201
|
+
if (!el) return;
|
|
2202
|
+
el.scrollIntoView({ block: "center" });
|
|
2203
|
+
scrollEditorToLine(hunkLine[hunkOrder[idx]]);
|
|
2204
|
+
var prev = $("diff").querySelector(".ln.current");
|
|
2205
|
+
if (prev) prev.classList.remove("current");
|
|
2206
|
+
var lnEl = el.closest ? el.closest(".ln") : null;
|
|
2207
|
+
if (lnEl) lnEl.classList.add("current");
|
|
2208
|
+
updateNav();
|
|
2209
|
+
}
|
|
2210
|
+
function nextChange() { goToChange(currentIdx + 1); }
|
|
2211
|
+
function prevChange() { goToChange(currentIdx - 1); }
|
|
2212
|
+
|
|
2213
|
+
function updateNav() {
|
|
2214
|
+
var nav = $("nav");
|
|
2215
|
+
if (!hunkOrder.length) { nav.classList.add("hidden"); return; }
|
|
2216
|
+
nav.classList.remove("hidden");
|
|
2217
|
+
var pos = (currentIdx >= 0 ? (currentIdx + 1) : "–");
|
|
2218
|
+
$("nav-pos").textContent = pos + " / " + hunkOrder.length;
|
|
2219
|
+
}
|
|
2220
|
+
|
|
2221
|
+
function updateCounts() {
|
|
2222
|
+
var revert = 0;
|
|
2223
|
+
for (var k = 0; k < hunks.length; k++) {
|
|
2224
|
+
if (decisions[hunks[k]] === "reject") revert++;
|
|
2225
|
+
}
|
|
2226
|
+
$("counts").textContent = hunks.length + " change" + (hunks.length === 1 ? "" : "s") +
|
|
2227
|
+
" · " + revert + " to revert";
|
|
2228
|
+
$("apply").textContent = revert ? ("apply (revert " + revert + ")") : "apply";
|
|
2229
|
+
}
|
|
2230
|
+
|
|
2231
|
+
// ---- nav / minimap / collapse event wiring ----
|
|
2232
|
+
$("next-change").addEventListener("click", nextChange);
|
|
2233
|
+
$("prev-change").addEventListener("click", prevChange);
|
|
2234
|
+
$("changes-only").addEventListener("change", function () {
|
|
2235
|
+
changesOnly = this.checked;
|
|
2236
|
+
renderDiff();
|
|
2237
|
+
if (currentIdx >= 0) goToChange(currentIdx); else $("right-scroll").scrollTop = 0;
|
|
2238
|
+
});
|
|
2239
|
+
$("right-scroll").addEventListener("scroll", updateViewport);
|
|
2240
|
+
window.addEventListener("resize", function () { buildMinimap(); });
|
|
2241
|
+
|
|
2242
|
+
$("minimap").addEventListener("click", function (e) {
|
|
2243
|
+
var scroll = $("right-scroll");
|
|
2244
|
+
if (e.target && e.target.classList.contains("tick")) {
|
|
2245
|
+
goToChange(parseInt(e.target.getAttribute("data-idx"), 10));
|
|
2246
|
+
return;
|
|
2247
|
+
}
|
|
2248
|
+
var rect = this.getBoundingClientRect();
|
|
2249
|
+
var frac = (e.clientY - rect.top) / (rect.height || 1);
|
|
2250
|
+
scroll.scrollTop = frac * scroll.scrollHeight - scroll.clientHeight / 2;
|
|
2251
|
+
});
|
|
2252
|
+
|
|
2253
|
+
// untracked-file onboarding controls (rendered inside #diff)
|
|
2254
|
+
$("diff").addEventListener("click", function (e) {
|
|
2255
|
+
var t = e.target;
|
|
2256
|
+
if (!t) return;
|
|
2257
|
+
if (t.id === "start-tracking") {
|
|
2258
|
+
t.disabled = true;
|
|
2259
|
+
postJSON("/api/commit", { message: "start tracking: " + currentFile }, function (res) {
|
|
2260
|
+
if (res.error) {
|
|
2261
|
+
t.disabled = false;
|
|
2262
|
+
setStatus("start tracking failed: " + res.error);
|
|
2263
|
+
return;
|
|
2264
|
+
}
|
|
2265
|
+
setStatus("tracking started · " + (res.short || ""));
|
|
2266
|
+
loadBaselines(); // HEAD/commit baselines just became available
|
|
2267
|
+
});
|
|
2268
|
+
} else if (t.id === "show-empty-diff") {
|
|
2269
|
+
showEmptyTreeDiff = true;
|
|
2270
|
+
renderDiff();
|
|
2271
|
+
}
|
|
2272
|
+
});
|
|
2273
|
+
|
|
2274
|
+
// expand a collapsed run of unchanged lines in place
|
|
2275
|
+
$("diff").addEventListener("click", function (e) {
|
|
2276
|
+
var c = e.target && e.target.closest ? e.target.closest(".collapser") : null;
|
|
2277
|
+
if (!c) return;
|
|
2278
|
+
var gid = c.getAttribute("data-gap");
|
|
2279
|
+
if (gapContent[gid] != null) {
|
|
2280
|
+
var wrap = document.createElement("div");
|
|
2281
|
+
wrap.innerHTML = gapContent[gid];
|
|
2282
|
+
c.parentNode.replaceChild(wrap, c);
|
|
2283
|
+
buildMinimap();
|
|
2284
|
+
}
|
|
2285
|
+
});
|
|
2286
|
+
|
|
2287
|
+
// keyboard: n / p to step through changes (ignored while typing)
|
|
2288
|
+
document.addEventListener("keydown", function (e) {
|
|
2289
|
+
var t = e.target;
|
|
2290
|
+
if (t && (t.tagName === "TEXTAREA" || t.tagName === "INPUT" || t.tagName === "SELECT")) return;
|
|
2291
|
+
if (t && t.closest && t.closest(".cm-editor")) return; // typing in CodeMirror
|
|
2292
|
+
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
|
2293
|
+
if (e.key === "n") { e.preventDefault(); nextChange(); }
|
|
2294
|
+
else if (e.key === "p") { e.preventDefault(); prevChange(); }
|
|
2295
|
+
});
|
|
2296
|
+
|
|
2297
|
+
// ---- revert toggle (event delegation); keep is the default, so the only
|
|
2298
|
+
// per-hunk decision is "reject" (= revert to baseline) or absent (= keep) ----
|
|
2299
|
+
$("diff").addEventListener("click", function (e) {
|
|
2300
|
+
var btn = e.target.closest ? e.target.closest("button.rev") : null;
|
|
2301
|
+
if (!btn) return;
|
|
2302
|
+
var h = btn.getAttribute("data-hunk");
|
|
2303
|
+
if (decisions[h] === "reject") delete decisions[h]; else decisions[h] = "reject";
|
|
2304
|
+
renderDiff();
|
|
2305
|
+
});
|
|
2306
|
+
|
|
2307
|
+
$("revert-all").addEventListener("click", function () {
|
|
2308
|
+
for (var k = 0; k < hunks.length; k++) decisions[hunks[k]] = "reject";
|
|
2309
|
+
renderDiff();
|
|
2310
|
+
});
|
|
2311
|
+
$("keep-all").addEventListener("click", function () {
|
|
2312
|
+
decisions = {}; // clear all revert marks (keep everything)
|
|
2313
|
+
renderDiff();
|
|
2314
|
+
});
|
|
2315
|
+
|
|
2316
|
+
$("apply").addEventListener("click", function () {
|
|
2317
|
+
if (dirty) {
|
|
2318
|
+
if (!window.confirm("You have unsaved edits in the editor. Applying reverts will overwrite them. Proceed?")) {
|
|
2319
|
+
return;
|
|
2320
|
+
}
|
|
2321
|
+
}
|
|
2322
|
+
var d = {};
|
|
2323
|
+
for (var k = 0; k < hunks.length; k++) {
|
|
2324
|
+
if (decisions[hunks[k]] === "reject") d[hunks[k]] = "reject";
|
|
2325
|
+
}
|
|
2326
|
+
$("apply").disabled = true;
|
|
2327
|
+
postJSON("/api/apply", { decisions: d, diff_epoch: diffEpoch }, function (res) {
|
|
2328
|
+
$("apply").disabled = false;
|
|
2329
|
+
if (res.stale) {
|
|
2330
|
+
// the file/baseline changed under the rendered view; the server already
|
|
2331
|
+
// broadcast a fresh payload (decisions survive content-addressing)
|
|
2332
|
+
setStatus("apply refused: the file changed underneath — view refreshed, check your marks and apply again");
|
|
2333
|
+
return;
|
|
2334
|
+
}
|
|
2335
|
+
if (res.error) { setStatus("apply failed: " + res.error); return; }
|
|
2336
|
+
var n = Object.keys(d).length;
|
|
2337
|
+
setStatus(n ? ("applied · reverted " + n + " change" + (n === 1 ? "" : "s")) : "applied · no reverts");
|
|
2338
|
+
});
|
|
2339
|
+
});
|
|
2340
|
+
|
|
2341
|
+
// ---- commit: the final step of the review loop ----
|
|
2342
|
+
// A git commit of the current file state; the server advances the baseline to
|
|
2343
|
+
// the new HEAD, so the diff clears to zero and the next agent pass starts clean.
|
|
2344
|
+
//
|
|
2345
|
+
// Two-step, so a commit is never a single stray click: the first click on
|
|
2346
|
+
// "commit" reveals an optional message box (and focuses it); the second click
|
|
2347
|
+
// — or Enter in the box — actually commits. Esc cancels.
|
|
2348
|
+
var commitArmed = false;
|
|
2349
|
+
function armCommit() {
|
|
2350
|
+
commitArmed = true;
|
|
2351
|
+
$("commit-msg").classList.remove("hidden");
|
|
2352
|
+
$("commit-btn").classList.add("armed");
|
|
2353
|
+
$("commit-msg").focus();
|
|
2354
|
+
setStatus("optional: type a message, then click commit again (Esc to cancel)");
|
|
2355
|
+
}
|
|
2356
|
+
function disarmCommit() {
|
|
2357
|
+
commitArmed = false;
|
|
2358
|
+
$("commit-msg").classList.add("hidden");
|
|
2359
|
+
$("commit-msg").value = "";
|
|
2360
|
+
$("commit-btn").classList.remove("armed");
|
|
2361
|
+
}
|
|
2362
|
+
function doCommit() {
|
|
2363
|
+
if (!currentFile) { setStatus("no file open"); return; }
|
|
2364
|
+
if (dirty) {
|
|
2365
|
+
if (!window.confirm("You have unsaved edits in the editor. Commit records the file as it is on disk, without them. Proceed?")) {
|
|
2366
|
+
return;
|
|
2367
|
+
}
|
|
2368
|
+
}
|
|
2369
|
+
var msg = $("commit-msg").value;
|
|
2370
|
+
$("commit-btn").disabled = true;
|
|
2371
|
+
postJSON("/api/commit", { message: msg }, function (res) {
|
|
2372
|
+
$("commit-btn").disabled = false;
|
|
2373
|
+
if (res.error) { setStatus("commit failed: " + res.error); return; }
|
|
2374
|
+
disarmCommit();
|
|
2375
|
+
decisions = {}; // reviewed content is committed; marks are spent
|
|
2376
|
+
setStatus("committed · " + (res.short || ""));
|
|
2377
|
+
});
|
|
2378
|
+
}
|
|
2379
|
+
$("commit-btn").addEventListener("click", function () {
|
|
2380
|
+
if (commitArmed) doCommit(); else armCommit();
|
|
2381
|
+
});
|
|
2382
|
+
$("commit-msg").addEventListener("keydown", function (e) {
|
|
2383
|
+
if (e.key === "Enter") { e.preventDefault(); doCommit(); }
|
|
2384
|
+
else if (e.key === "Escape") { e.preventDefault(); disarmCommit(); $("commit-btn").focus(); }
|
|
2385
|
+
});
|
|
2386
|
+
|
|
2387
|
+
// Save the buffer to disk, from either view (a pending preview edit is
|
|
2388
|
+
// flushed into the source buffer first, so what you see is what is saved).
|
|
2389
|
+
// With no file open, the buffer — even an empty one — is saved as a NEW
|
|
2390
|
+
// file: prompt for a name, create it in the repo, and start watching it.
|
|
2391
|
+
function doSave() {
|
|
2392
|
+
if (previewMode) flushPreviewSync();
|
|
2393
|
+
if (!currentFile) { saveAsNewFile(); return; }
|
|
2394
|
+
var text = editorText();
|
|
2395
|
+
$("save").disabled = true;
|
|
2396
|
+
// Our own save is not a disk conflict. Clear dirty *now*, before the save's
|
|
2397
|
+
// own "update" echoes back over SSE, so it can't trip the conflict banner.
|
|
2398
|
+
dirty = false;
|
|
2399
|
+
reportClientState();
|
|
2400
|
+
updateLeftTitle();
|
|
2401
|
+
postJSON("/api/save", { text: text }, function (res) {
|
|
2402
|
+
$("save").disabled = false;
|
|
2403
|
+
if (res.error) {
|
|
2404
|
+
dirty = true; // save failed -> we still have unsaved edits
|
|
2405
|
+
reportClientState();
|
|
2406
|
+
updateLeftTitle();
|
|
2407
|
+
setStatus("save failed: " + res.error);
|
|
2408
|
+
return;
|
|
2409
|
+
}
|
|
2410
|
+
setStatus("saved");
|
|
2411
|
+
});
|
|
2412
|
+
}
|
|
2413
|
+
|
|
2414
|
+
// Prompt for a name and create a new file (via /api/new), then watch it.
|
|
2415
|
+
// `text` seeds the file's contents ("" for a fresh empty file). Used by
|
|
2416
|
+
// save-with-no-file-open and the picker's "start a new file" option;
|
|
2417
|
+
// onDone(false) lets the caller reset UI state after a cancel/failure.
|
|
2418
|
+
function createNewFile(text, onDone) {
|
|
2419
|
+
var name = window.prompt(
|
|
2420
|
+
"Create a new file in this repository.\n\nName (relative to the repo root):",
|
|
2421
|
+
"untitled.md");
|
|
2422
|
+
if (name == null || !name.trim()) { if (onDone) onDone(false); return; }
|
|
2423
|
+
$("save").disabled = true;
|
|
2424
|
+
var wasDirty = dirty;
|
|
2425
|
+
dirty = false; // our own save is not a disk conflict
|
|
2426
|
+
reportClientState();
|
|
2427
|
+
updateLeftTitle();
|
|
2428
|
+
postJSON("/api/new", { path: name.trim(), text: text }, function (res) {
|
|
2429
|
+
$("save").disabled = false;
|
|
2430
|
+
if (res.error) {
|
|
2431
|
+
dirty = wasDirty;
|
|
2432
|
+
reportClientState();
|
|
2433
|
+
updateLeftTitle();
|
|
2434
|
+
setStatus("new file failed: " + res.error);
|
|
2435
|
+
if (onDone) onDone(false);
|
|
2436
|
+
return;
|
|
2437
|
+
}
|
|
2438
|
+
currentFile = res.file;
|
|
2439
|
+
lastBaselineKind = null;
|
|
2440
|
+
currentIdx = -1;
|
|
2441
|
+
syncFileControls();
|
|
2442
|
+
loadBaselines();
|
|
2443
|
+
loadFiles();
|
|
2444
|
+
setStatus("created " + res.file);
|
|
2445
|
+
// the server's SSE 'update' populates the panels
|
|
2446
|
+
if (onDone) onDone(true);
|
|
2447
|
+
});
|
|
2448
|
+
}
|
|
2449
|
+
|
|
2450
|
+
function saveAsNewFile() {
|
|
2451
|
+
createNewFile(editorText());
|
|
2452
|
+
}
|
|
2453
|
+
|
|
2454
|
+
// The picker's "start a new file" option: switch to a blank, unnamed
|
|
2455
|
+
// scratch buffer and just let you write. No name is asked here — saving
|
|
2456
|
+
// (button or ⌘S) prompts for one, via the same no-file path as starting
|
|
2457
|
+
// draftwatch without a target. The previously open file stays untouched
|
|
2458
|
+
// on disk.
|
|
2459
|
+
function startNewFile() {
|
|
2460
|
+
if (currentFile === null) { // already in the scratch buffer
|
|
2461
|
+
syncFileControls();
|
|
2462
|
+
ed.focus();
|
|
2463
|
+
return;
|
|
2464
|
+
}
|
|
2465
|
+
if (dirty &&
|
|
2466
|
+
!window.confirm("You have unsaved edits.\n\nOK = leave them behind and start a new file.\nCancel = stay on the current file.")) {
|
|
2467
|
+
syncFileControls();
|
|
2468
|
+
return;
|
|
2469
|
+
}
|
|
2470
|
+
var wasDirty = dirty;
|
|
2471
|
+
dirty = false; // cleared pre-post so the SSE echo can't trip the banner
|
|
2472
|
+
reportClientState();
|
|
2473
|
+
updateLeftTitle();
|
|
2474
|
+
postJSON("/api/close", {}, function (res) {
|
|
2475
|
+
if (res.error) {
|
|
2476
|
+
dirty = wasDirty;
|
|
2477
|
+
reportClientState();
|
|
2478
|
+
updateLeftTitle();
|
|
2479
|
+
setStatus("new file failed: " + res.error);
|
|
2480
|
+
syncFileControls();
|
|
2481
|
+
return;
|
|
2482
|
+
}
|
|
2483
|
+
currentFile = null;
|
|
2484
|
+
lastBaselineKind = null;
|
|
2485
|
+
currentIdx = -1;
|
|
2486
|
+
syncFileControls();
|
|
2487
|
+
loadBaselines();
|
|
2488
|
+
setStatus("new file — start typing; save (⌘S) will ask for a name");
|
|
2489
|
+
ed.focus();
|
|
2490
|
+
// the server's SSE 'update' (file: null) clears the panels
|
|
2491
|
+
});
|
|
2492
|
+
}
|
|
2493
|
+
|
|
2494
|
+
$("save").addEventListener("click", doSave);
|
|
2495
|
+
|
|
2496
|
+
// ⌘/Ctrl-S saves from anywhere — source view, editable preview, even with
|
|
2497
|
+
// focus in the diff panel. preventDefault suppresses the browser's own
|
|
2498
|
+
// save-page dialog, which is never what you want here.
|
|
2499
|
+
document.addEventListener("keydown", function (e) {
|
|
2500
|
+
if ((e.metaKey || e.ctrlKey) && !e.altKey && !e.shiftKey &&
|
|
2501
|
+
(e.key === "s" || e.key === "S")) {
|
|
2502
|
+
e.preventDefault();
|
|
2503
|
+
doSave();
|
|
2504
|
+
}
|
|
2505
|
+
});
|
|
2506
|
+
|
|
2507
|
+
// ---- edit state tracking ----
|
|
2508
|
+
function updateLeftTitle() {
|
|
2509
|
+
$("left-title").textContent = dirty
|
|
2510
|
+
? "working text — editing · UNSAVED (click “save to file”)"
|
|
2511
|
+
: "working text — editing";
|
|
2512
|
+
$("left-title").classList.toggle("unsaved", dirty);
|
|
2513
|
+
$("save").classList.toggle("attn", dirty);
|
|
2514
|
+
}
|
|
2515
|
+
|
|
2516
|
+
// ---- markdown preview: an editable rendered view ----
|
|
2517
|
+
// Renders the CURRENT BUFFER (unsaved edits included) as HTML. Sanitization
|
|
2518
|
+
// is load-bearing: the file's author is a semi-trusted AI agent, and an
|
|
2519
|
+
// unsanitized <script> in a .md would run with access to the session token.
|
|
2520
|
+
// marked -> DOMPurify -> innerHTML, nothing else.
|
|
2521
|
+
//
|
|
2522
|
+
// The preview is also a *writing surface*: it is contenteditable, and edits
|
|
2523
|
+
// are converted back to markdown (Turndown) and pushed into the CodeMirror
|
|
2524
|
+
// source. That round-trip normalizes markdown (heading/emphasis/spacing
|
|
2525
|
+
// conventions can shift), so entering preview never rewrites the source on its
|
|
2526
|
+
// own — only an actual edit here does. Source view stays byte-exact.
|
|
2527
|
+
var previewMode = false;
|
|
2528
|
+
var TURN = new TurndownService({
|
|
2529
|
+
headingStyle: "atx",
|
|
2530
|
+
hr: "---",
|
|
2531
|
+
bulletListMarker: "-",
|
|
2532
|
+
codeBlockStyle: "fenced",
|
|
2533
|
+
emDelimiter: "*",
|
|
2534
|
+
strongDelimiter: "**",
|
|
2535
|
+
linkStyle: "inlined"
|
|
2536
|
+
});
|
|
2537
|
+
function renderPreview() {
|
|
2538
|
+
var out = "";
|
|
2539
|
+
try {
|
|
2540
|
+
out = DOMPurify.sanitize(marked.parse(editorText()),
|
|
2541
|
+
{ USE_PROFILES: { html: true } });
|
|
2542
|
+
} catch (e) {
|
|
2543
|
+
out = "";
|
|
2544
|
+
}
|
|
2545
|
+
// set a flag so the resulting DOM mutation is not mistaken for a user edit
|
|
2546
|
+
// (innerHTML assignment does not fire 'input', but keep the intent explicit)
|
|
2547
|
+
$("preview").innerHTML = out ||
|
|
2548
|
+
'<p class="empty">nothing to preview</p>';
|
|
2549
|
+
}
|
|
2550
|
+
|
|
2551
|
+
// Convert the edited rendered HTML back to markdown and push it into the
|
|
2552
|
+
// source buffer, marking it dirty. Debounced so typing stays smooth.
|
|
2553
|
+
var previewSyncTimer = null;
|
|
2554
|
+
function syncPreviewToSource() {
|
|
2555
|
+
var md;
|
|
2556
|
+
try { md = TURN.turndown($("preview").innerHTML); }
|
|
2557
|
+
catch (e) { return; }
|
|
2558
|
+
if (md === editorText()) return;
|
|
2559
|
+
setEditorDoc(md, false); // applyingRemote suppresses the CM dirty hook
|
|
2560
|
+
if (!dirty) { dirty = true; reportClientState(); }
|
|
2561
|
+
updateLeftTitle();
|
|
2562
|
+
}
|
|
2563
|
+
function schedulePreviewSync() {
|
|
2564
|
+
if (previewSyncTimer) clearTimeout(previewSyncTimer);
|
|
2565
|
+
previewSyncTimer = setTimeout(syncPreviewToSource, 350);
|
|
2566
|
+
}
|
|
2567
|
+
function flushPreviewSync() {
|
|
2568
|
+
if (previewSyncTimer) { clearTimeout(previewSyncTimer); previewSyncTimer = null; syncPreviewToSource(); }
|
|
2569
|
+
}
|
|
2570
|
+
|
|
2571
|
+
function setPreviewMode(on) {
|
|
2572
|
+
if (!on) flushPreviewSync(); // don't lose a pending edit when leaving preview
|
|
2573
|
+
previewMode = on;
|
|
2574
|
+
$("preview").classList.toggle("hidden", !on);
|
|
2575
|
+
$("editor-host").classList.toggle("hidden", on);
|
|
2576
|
+
$("preview").setAttribute("contenteditable", on ? "true" : "false");
|
|
2577
|
+
// the format buttons + save stay available in preview — you can write here too
|
|
2578
|
+
$("view-toggle").textContent = on ? "source" : "preview";
|
|
2579
|
+
if (on) renderPreview();
|
|
2580
|
+
}
|
|
2581
|
+
$("view-toggle").addEventListener("click", function () { setPreviewMode(!previewMode); });
|
|
2582
|
+
|
|
2583
|
+
$("preview").addEventListener("input", function () {
|
|
2584
|
+
if (previewMode) schedulePreviewSync();
|
|
2585
|
+
});
|
|
2586
|
+
// In the editable preview a plain click places the caret; hold Cmd/Ctrl to
|
|
2587
|
+
// open a link in a new tab (navigating this tab would tear down the session).
|
|
2588
|
+
$("preview").addEventListener("click", function (e) {
|
|
2589
|
+
var a = e.target && e.target.closest ? e.target.closest("a[href]") : null;
|
|
2590
|
+
if (!a) return;
|
|
2591
|
+
if (previewMode && !(e.metaKey || e.ctrlKey)) return;
|
|
2592
|
+
e.preventDefault();
|
|
2593
|
+
window.open(a.getAttribute("href"), "_blank", "noopener");
|
|
2594
|
+
});
|
|
2595
|
+
|
|
2596
|
+
// ---- formatting toolbar ----
|
|
2597
|
+
// A toolbar button must not steal focus/selection from the editor, so cancel
|
|
2598
|
+
// the default mousedown focus shift and act on the retained selection.
|
|
2599
|
+
// (Bold/italic/code/link keyboard shortcuts and type-*-over-selection wrapping
|
|
2600
|
+
// live in the CodeMirror keymap/inputHandler above.)
|
|
2601
|
+
function wireFmt(id, fn) {
|
|
2602
|
+
var b = $(id);
|
|
2603
|
+
b.addEventListener("mousedown", function (e) { e.preventDefault(); });
|
|
2604
|
+
b.addEventListener("click", function () { fn(); });
|
|
2605
|
+
}
|
|
2606
|
+
// Wrap the current preview selection in a tag (used for inline code, which
|
|
2607
|
+
// execCommand has no command for). No-op on an empty selection.
|
|
2608
|
+
function wrapPreviewSelection(tag) {
|
|
2609
|
+
var sel = window.getSelection();
|
|
2610
|
+
if (!sel || sel.rangeCount === 0 || sel.isCollapsed) return;
|
|
2611
|
+
var range = sel.getRangeAt(0);
|
|
2612
|
+
var el = document.createElement(tag);
|
|
2613
|
+
try {
|
|
2614
|
+
range.surroundContents(el);
|
|
2615
|
+
} catch (e) {
|
|
2616
|
+
// surroundContents throws if the range crosses element boundaries;
|
|
2617
|
+
// extract-and-reinsert handles that case
|
|
2618
|
+
el.appendChild(range.extractContents());
|
|
2619
|
+
range.insertNode(el);
|
|
2620
|
+
}
|
|
2621
|
+
sel.removeAllRanges();
|
|
2622
|
+
var r = document.createRange();
|
|
2623
|
+
r.selectNodeContents(el);
|
|
2624
|
+
sel.addRange(r);
|
|
2625
|
+
}
|
|
2626
|
+
|
|
2627
|
+
// In source view these act on the CodeMirror selection; in the editable
|
|
2628
|
+
// preview they act on the rendered DOM and sync back to source. Bold/italic
|
|
2629
|
+
// use execCommand (it toggles and sets typing state); code wraps the selection
|
|
2630
|
+
// in <code>; link uses createLink.
|
|
2631
|
+
function fmtBold() {
|
|
2632
|
+
if (previewMode) { document.execCommand("bold"); schedulePreviewSync(); }
|
|
2633
|
+
else surroundSelection("**", "**", true);
|
|
2634
|
+
}
|
|
2635
|
+
function fmtItalic() {
|
|
2636
|
+
if (previewMode) { document.execCommand("italic"); schedulePreviewSync(); }
|
|
2637
|
+
else surroundSelection("*", "*", true);
|
|
2638
|
+
}
|
|
2639
|
+
function fmtCode() {
|
|
2640
|
+
if (previewMode) { wrapPreviewSelection("code"); schedulePreviewSync(); }
|
|
2641
|
+
else surroundSelection("`", "`", true);
|
|
2642
|
+
}
|
|
2643
|
+
function fmtLink() {
|
|
2644
|
+
if (previewMode) {
|
|
2645
|
+
var url = window.prompt("Link URL:", "https://");
|
|
2646
|
+
if (url) { document.execCommand("createLink", false, url); schedulePreviewSync(); }
|
|
2647
|
+
} else insertLink();
|
|
2648
|
+
}
|
|
2649
|
+
wireFmt("fmt-bold", fmtBold);
|
|
2650
|
+
wireFmt("fmt-italic", fmtItalic);
|
|
2651
|
+
wireFmt("fmt-link", fmtLink);
|
|
2652
|
+
|
|
2653
|
+
// Formatting shortcuts inside the editable preview. In source view CodeMirror's
|
|
2654
|
+
// own keymap handles these; in preview, focus is in the contenteditable (not
|
|
2655
|
+
// CodeMirror), so wire the same Cmd/Ctrl-B/I/E/K here. We preventDefault and
|
|
2656
|
+
// run the action ourselves, so behavior is identical across browsers and the
|
|
2657
|
+
// native window (some webviews don't wire Cmd-B/I to execCommand natively, and
|
|
2658
|
+
// Cmd-E/Cmd-K have no native contenteditable behavior at all).
|
|
2659
|
+
$("preview").addEventListener("keydown", function (e) {
|
|
2660
|
+
if (!previewMode) return;
|
|
2661
|
+
if (!(e.metaKey || e.ctrlKey) || e.altKey) return;
|
|
2662
|
+
var k = e.key.toLowerCase();
|
|
2663
|
+
if (k === "b") { e.preventDefault(); fmtBold(); }
|
|
2664
|
+
else if (k === "i") { e.preventDefault(); fmtItalic(); }
|
|
2665
|
+
else if (k === "e") { e.preventDefault(); fmtCode(); }
|
|
2666
|
+
else if (k === "k") { e.preventDefault(); fmtLink(); }
|
|
2667
|
+
});
|
|
2668
|
+
|
|
2669
|
+
// guard against losing unsaved edits by closing/reloading the tab
|
|
2670
|
+
window.addEventListener("beforeunload", function (e) {
|
|
2671
|
+
if (dirty) { e.preventDefault(); e.returnValue = ""; }
|
|
2672
|
+
});
|
|
2673
|
+
|
|
2674
|
+
// ---- baseline dropdown ----
|
|
2675
|
+
function loadBaselines() {
|
|
2676
|
+
getJSON("/api/baselines").then(function (d) {
|
|
2677
|
+
var sel = $("baseline");
|
|
2678
|
+
sel.innerHTML = "";
|
|
2679
|
+
var opts = [];
|
|
2680
|
+
// Uncommitted file: HEAD holds no version of it, so the only git baseline
|
|
2681
|
+
// is the empty tree (whole file reads as new). For committed files, offer
|
|
2682
|
+
// "last push" (the default), HEAD, and the file's commit history.
|
|
2683
|
+
if (d.untracked) {
|
|
2684
|
+
var emptyLabel = (d.current && d.current.kind === "empty" && d.current.label)
|
|
2685
|
+
? d.current.label : "empty tree · file not yet committed";
|
|
2686
|
+
opts.push({ value: "empty|empty", text: emptyLabel });
|
|
2687
|
+
} else {
|
|
2688
|
+
if (d.push) opts.push({ value: "push|" + d.push.ref, text: d.push.label });
|
|
2689
|
+
if (d.head) opts.push({ value: "head|HEAD", text: d.head.label });
|
|
2690
|
+
(d.commits || []).forEach(function (c) {
|
|
2691
|
+
opts.push({ value: "commit|" + c.ref, text: c.label, label: c.label });
|
|
2692
|
+
});
|
|
2693
|
+
}
|
|
2694
|
+
opts.forEach(function (o) {
|
|
2695
|
+
var el = document.createElement("option");
|
|
2696
|
+
el.value = o.value;
|
|
2697
|
+
el.textContent = o.text;
|
|
2698
|
+
if (o.label) el.setAttribute("data-label", o.label);
|
|
2699
|
+
sel.appendChild(el);
|
|
2700
|
+
});
|
|
2701
|
+
// select current
|
|
2702
|
+
if (d.current) {
|
|
2703
|
+
var want = d.current.kind + "|" + (d.current.ref || "");
|
|
2704
|
+
for (var i = 0; i < sel.options.length; i++) {
|
|
2705
|
+
if (sel.options[i].value === want) { sel.selectedIndex = i; break; }
|
|
2706
|
+
}
|
|
2707
|
+
}
|
|
2708
|
+
}).catch(function () {});
|
|
2709
|
+
}
|
|
2710
|
+
|
|
2711
|
+
$("baseline").addEventListener("change", function () {
|
|
2712
|
+
var sel = $("baseline");
|
|
2713
|
+
var parts = sel.value.split("|");
|
|
2714
|
+
var kind = parts[0];
|
|
2715
|
+
var ref = parts.slice(1).join("|");
|
|
2716
|
+
var body = { kind: kind };
|
|
2717
|
+
if (kind === "commit") {
|
|
2718
|
+
body.ref = ref;
|
|
2719
|
+
var opt = sel.options[sel.selectedIndex];
|
|
2720
|
+
if (opt) body.label = opt.getAttribute("data-label") || opt.textContent;
|
|
2721
|
+
}
|
|
2722
|
+
postJSON("/api/baseline", body, function (res) {
|
|
2723
|
+
if (res.error) setStatus("baseline error: " + res.error);
|
|
2724
|
+
});
|
|
2725
|
+
});
|
|
2726
|
+
|
|
2727
|
+
// ---- file picker (open / switch the watched file) ----
|
|
2728
|
+
function addFileOption(sel, f) {
|
|
2729
|
+
var o = document.createElement("option");
|
|
2730
|
+
o.value = f; o.textContent = f; sel.appendChild(o);
|
|
2731
|
+
}
|
|
2732
|
+
function loadFiles() {
|
|
2733
|
+
getJSON("/api/files").then(function (d) {
|
|
2734
|
+
var files = d.files || [];
|
|
2735
|
+
var cur = d.current || currentFile;
|
|
2736
|
+
var sel = $("file-select"); sel.innerHTML = ""; // dropdown: writing files
|
|
2737
|
+
var ph = document.createElement("option");
|
|
2738
|
+
ph.value = ""; ph.textContent = "— pick a file —"; sel.appendChild(ph);
|
|
2739
|
+
var nf = document.createElement("option"); // create-a-file entry, always on top
|
|
2740
|
+
nf.value = "__new__"; nf.textContent = "+ start a new file…"; sel.appendChild(nf);
|
|
2741
|
+
var listed = {};
|
|
2742
|
+
files.forEach(function (f) {
|
|
2743
|
+
var low = f.toLowerCase();
|
|
2744
|
+
for (var i = 0; i < WRITING_EXT.length; i++) {
|
|
2745
|
+
if (low.lastIndexOf(WRITING_EXT[i]) === low.length - WRITING_EXT[i].length) {
|
|
2746
|
+
addFileOption(sel, f); listed[f] = true; break;
|
|
2747
|
+
}
|
|
2748
|
+
}
|
|
2749
|
+
});
|
|
2750
|
+
if (cur && !listed[cur]) addFileOption(sel, cur);
|
|
2751
|
+
if (cur) sel.value = cur;
|
|
2752
|
+
}).catch(function () {});
|
|
2753
|
+
}
|
|
2754
|
+
function syncFileControls() {
|
|
2755
|
+
var sel = $("file-select");
|
|
2756
|
+
if (currentFile) {
|
|
2757
|
+
var found = false;
|
|
2758
|
+
for (var i = 0; i < sel.options.length; i++) {
|
|
2759
|
+
if (sel.options[i].value === currentFile) { found = true; break; }
|
|
2760
|
+
}
|
|
2761
|
+
if (!found) addFileOption(sel, currentFile);
|
|
2762
|
+
sel.value = currentFile;
|
|
2763
|
+
} else if (sel.options.length) {
|
|
2764
|
+
sel.value = "";
|
|
2765
|
+
}
|
|
2766
|
+
}
|
|
2767
|
+
function openFile(path) {
|
|
2768
|
+
if (!path) return;
|
|
2769
|
+
if (dirty) {
|
|
2770
|
+
if (!window.confirm("You have unsaved edits.\n\nOK = discard them and open " + path +
|
|
2771
|
+
".\nCancel = stay on the current file.")) return;
|
|
2772
|
+
dirty = false;
|
|
2773
|
+
}
|
|
2774
|
+
postJSON("/api/open", { path: path }, function (res) {
|
|
2775
|
+
if (res.error) { setStatus("open failed: " + res.error); return; }
|
|
2776
|
+
dirty = false;
|
|
2777
|
+
currentFile = res.file;
|
|
2778
|
+
lastBaselineKind = null;
|
|
2779
|
+
currentIdx = -1;
|
|
2780
|
+
reportClientState();
|
|
2781
|
+
updateLeftTitle();
|
|
2782
|
+
loadBaselines();
|
|
2783
|
+
// the server's SSE 'update' will populate the panels
|
|
2784
|
+
});
|
|
2785
|
+
}
|
|
2786
|
+
$("file-select").addEventListener("change", function () {
|
|
2787
|
+
if (this.value === "__new__") { startNewFile(); return; }
|
|
2788
|
+
if (this.value) openFile(this.value);
|
|
2789
|
+
});
|
|
2790
|
+
|
|
2791
|
+
// loadFiles() previously only ran once at page load, so a file added (or removed)
|
|
2792
|
+
// on disk after that never showed up in the dropdown for the rest of the session.
|
|
2793
|
+
// Refresh right before the control is used — mousedown fires before a <select>
|
|
2794
|
+
// opens its popup in every browser that matters here — plus a periodic poll as a
|
|
2795
|
+
// fallback for whichever browser doesn't actually repaint an already-open native
|
|
2796
|
+
// dropdown from a DOM mutation.
|
|
2797
|
+
$("file-select").addEventListener("mousedown", loadFiles);
|
|
2798
|
+
setInterval(loadFiles, 4000);
|
|
2799
|
+
|
|
2800
|
+
// ---- banner (disk_changed) ----
|
|
2801
|
+
var pendingDisk = null; // payload waiting for the user's decision
|
|
2802
|
+
function showBanner(payload) {
|
|
2803
|
+
pendingDisk = payload;
|
|
2804
|
+
$("banner-text").textContent = "file changed on disk while you were editing.";
|
|
2805
|
+
$("banner").classList.add("show");
|
|
2806
|
+
}
|
|
2807
|
+
function hideBanner() { $("banner").classList.remove("show"); pendingDisk = null; }
|
|
2808
|
+
$("banner-reload").addEventListener("click", function () {
|
|
2809
|
+
if (pendingDisk) applyPayload(pendingDisk, true);
|
|
2810
|
+
dirty = false;
|
|
2811
|
+
reportClientState();
|
|
2812
|
+
updateLeftTitle();
|
|
2813
|
+
hideBanner();
|
|
2814
|
+
renderLeft();
|
|
2815
|
+
});
|
|
2816
|
+
$("banner-overwrite").addEventListener("click", function () {
|
|
2817
|
+
// keep the user's edits, dismiss the banner
|
|
2818
|
+
hideBanner();
|
|
2819
|
+
});
|
|
2820
|
+
|
|
2821
|
+
// ---- apply an SSE payload to local state ----
|
|
2822
|
+
function applyPayload(p, force) {
|
|
2823
|
+
if ("file" in p) {
|
|
2824
|
+
if (p.file !== currentFile) {
|
|
2825
|
+
decisions = {}; // decisions never cross files
|
|
2826
|
+
showEmptyTreeDiff = false; // onboarding opt-out is per file
|
|
2827
|
+
}
|
|
2828
|
+
currentFile = p.file; // relpath or null
|
|
2829
|
+
}
|
|
2830
|
+
rawText = p.raw_text;
|
|
2831
|
+
segments = p.segments || [];
|
|
2832
|
+
hunks = p.hunks || [];
|
|
2833
|
+
if ("diff_epoch" in p) diffEpoch = p.diff_epoch;
|
|
2834
|
+
baseline = (p.baseline !== undefined) ? p.baseline : baseline;
|
|
2835
|
+
// Hunk ids are content-addressed (server-side hash of the change + its
|
|
2836
|
+
// context), so a decision stays valid across payloads as long as that
|
|
2837
|
+
// change still exists. Keep decisions; prune only ids that vanished.
|
|
2838
|
+
var live = {};
|
|
2839
|
+
for (var li = 0; li < hunks.length; li++) live[hunks[li]] = true;
|
|
2840
|
+
for (var dk in decisions) {
|
|
2841
|
+
if (Object.prototype.hasOwnProperty.call(decisions, dk) && !live[dk]) {
|
|
2842
|
+
delete decisions[dk];
|
|
2843
|
+
}
|
|
2844
|
+
}
|
|
2845
|
+
syncFileControls();
|
|
2846
|
+
setStatusBaseline();
|
|
2847
|
+
renderDiff();
|
|
2848
|
+
// refresh left unless we are protecting a dirty edit buffer
|
|
2849
|
+
if (force || !dirty) renderLeft();
|
|
2850
|
+
}
|
|
2851
|
+
|
|
2852
|
+
var lastTransientTs = 0;
|
|
2853
|
+
function setStatus(msg) {
|
|
2854
|
+
$("status").textContent = msg;
|
|
2855
|
+
if (msg) lastTransientTs = Date.now();
|
|
2856
|
+
}
|
|
2857
|
+
// The baseline dropdown right next to this status line already shows the full
|
|
2858
|
+
// label ("last push · origin/main · 28 minutes ago", etc.) — repeating all of it
|
|
2859
|
+
// here is pure duplication. For the two default-assigned baselines (push, head),
|
|
2860
|
+
// show just the recency, which is the one fact that's actually decision-relevant
|
|
2861
|
+
// (is this comparison fresh or stale); the ref/commit detail stays one click away
|
|
2862
|
+
// in the dropdown. Manually-picked commits and the empty-tree baseline keep their
|
|
2863
|
+
// full label, since there the specific choice is the point.
|
|
2864
|
+
// The baseline dropdown itself already shows the full label ("last push ·
|
|
2865
|
+
// origin/main · 28 minutes ago", etc.) and the file dropdown above already shows
|
|
2866
|
+
// the filename — restating either here would just be duplication. #status is left
|
|
2867
|
+
// free for what it's actually for: transient connection/save/error feedback (set
|
|
2868
|
+
// directly via setStatus() elsewhere — "disconnected – retrying…", "saved",
|
|
2869
|
+
// "apply failed: ...", and so on).
|
|
2870
|
+
function setStatusBaseline() {
|
|
2871
|
+
if (!currentFile) { setStatus("no file open — pick a file above, or start typing and save"); return; }
|
|
2872
|
+
// don't let a routine SSE payload wipe fresh transient feedback
|
|
2873
|
+
// ("saved", "committed · abc1234", ...) the instant it appears
|
|
2874
|
+
if (Date.now() - lastTransientTs > 4000) setStatus("");
|
|
2875
|
+
// keep dropdown selection in sync
|
|
2876
|
+
if (baseline) {
|
|
2877
|
+
var want = baseline.kind + "|" + (baseline.ref || "");
|
|
2878
|
+
var sel = $("baseline");
|
|
2879
|
+
for (var i = 0; i < sel.options.length; i++) {
|
|
2880
|
+
if (sel.options[i].value === want) { sel.selectedIndex = i; break; }
|
|
2881
|
+
}
|
|
2882
|
+
}
|
|
2883
|
+
}
|
|
2884
|
+
|
|
2885
|
+
// ---- SSE ----
|
|
2886
|
+
function connect() {
|
|
2887
|
+
// EventSource cannot set headers, so the token rides in the query string
|
|
2888
|
+
var es = new EventSource("/events?t=" + encodeURIComponent(TOKEN));
|
|
2889
|
+
es.onmessage = function (e) {
|
|
2890
|
+
var p;
|
|
2891
|
+
try { p = JSON.parse(e.data); } catch (err) { return; }
|
|
2892
|
+
// A banner is only warranted when the file on disk genuinely changed
|
|
2893
|
+
// *underneath unsaved edits*. We confirm the content actually differs from
|
|
2894
|
+
// what we last loaded (p.raw_text !== rawText), so neither an SSE reconnect
|
|
2895
|
+
// echo nor our own save (which re-broadcasts an "update" with no outside
|
|
2896
|
+
// change) can trip it. Without unsaved edits, just apply the update.
|
|
2897
|
+
var editingUnsaved = dirty;
|
|
2898
|
+
var diskActuallyChanged = (p.raw_text !== rawText);
|
|
2899
|
+
if (editingUnsaved && diskActuallyChanged &&
|
|
2900
|
+
(p.type === "disk_changed" || p.type === "update")) {
|
|
2901
|
+
showBanner(p); // keep the buffer untouched until the user decides
|
|
2902
|
+
return;
|
|
2903
|
+
}
|
|
2904
|
+
applyPayload(p, false);
|
|
2905
|
+
if (baseline && baseline.kind) loadBaselinesLabelRefresh();
|
|
2906
|
+
};
|
|
2907
|
+
es.onerror = function () { setStatus("disconnected – retrying…"); };
|
|
2908
|
+
es.onopen = function () { /* status set on first message */ };
|
|
2909
|
+
}
|
|
2910
|
+
|
|
2911
|
+
var lastBaselineKind = null;
|
|
2912
|
+
function loadBaselinesLabelRefresh() {
|
|
2913
|
+
// refresh the commit dropdown if the baseline kind changed (e.g. file committed)
|
|
2914
|
+
if (baseline && baseline.kind !== lastBaselineKind) {
|
|
2915
|
+
lastBaselineKind = baseline.kind;
|
|
2916
|
+
loadBaselines();
|
|
2917
|
+
}
|
|
2918
|
+
}
|
|
2919
|
+
|
|
2920
|
+
// ---- theme toggle (auto / light / dark) ----
|
|
2921
|
+
// Manual choice is stored in localStorage and set as data-theme on <html>
|
|
2922
|
+
// (the pre-paint script in <head> applies it on load, so there is no flash);
|
|
2923
|
+
// "auto" removes the attribute and defers to the OS via prefers-color-scheme.
|
|
2924
|
+
var THEMES = ["auto", "light", "dark"];
|
|
2925
|
+
function currentTheme() {
|
|
2926
|
+
var t = document.documentElement.getAttribute("data-theme");
|
|
2927
|
+
return (t === "light" || t === "dark") ? t : "auto";
|
|
2928
|
+
}
|
|
2929
|
+
function applyTheme(t) {
|
|
2930
|
+
if (t === "light" || t === "dark") {
|
|
2931
|
+
document.documentElement.setAttribute("data-theme", t);
|
|
2932
|
+
} else {
|
|
2933
|
+
document.documentElement.removeAttribute("data-theme");
|
|
2934
|
+
}
|
|
2935
|
+
try {
|
|
2936
|
+
if (t === "auto") localStorage.removeItem("draftwatch-theme");
|
|
2937
|
+
else localStorage.setItem("draftwatch-theme", t);
|
|
2938
|
+
} catch (e) {}
|
|
2939
|
+
$("theme-toggle").textContent = "theme: " + t;
|
|
2940
|
+
}
|
|
2941
|
+
applyTheme(currentTheme()); // sync the button label to the pre-paint state
|
|
2942
|
+
$("theme-toggle").addEventListener("click", function () {
|
|
2943
|
+
var i = THEMES.indexOf(currentTheme());
|
|
2944
|
+
applyTheme(THEMES[(i + 1) % THEMES.length]);
|
|
2945
|
+
});
|
|
2946
|
+
|
|
2947
|
+
// ---- color theme (blue / teal / iris / plum / graphite) ----
|
|
2948
|
+
// Same persistence pattern as light/dark: data-accent on <html>, remembered
|
|
2949
|
+
// in localStorage, applied pre-paint by the script in <head>. "blue" is the
|
|
2950
|
+
// default (attribute removed). Each color restyles the whole surface — bg,
|
|
2951
|
+
// panels, borders, ink, accent — and defines a dark-mode variant, so the
|
|
2952
|
+
// light/dark toggle keeps working per color. An unknown stored value
|
|
2953
|
+
// matches no CSS and currentAccent() maps it back to "blue".
|
|
2954
|
+
var ACCENTS = ["blue", "teal", "iris", "plum", "graphite"];
|
|
2955
|
+
function currentAccent() {
|
|
2956
|
+
var a = document.documentElement.getAttribute("data-accent");
|
|
2957
|
+
return ACCENTS.indexOf(a) > 0 ? a : "blue";
|
|
2958
|
+
}
|
|
2959
|
+
function applyAccent(a) {
|
|
2960
|
+
if (a === "blue") document.documentElement.removeAttribute("data-accent");
|
|
2961
|
+
else document.documentElement.setAttribute("data-accent", a);
|
|
2962
|
+
try {
|
|
2963
|
+
if (a === "blue") localStorage.removeItem("draftwatch-accent");
|
|
2964
|
+
else localStorage.setItem("draftwatch-accent", a);
|
|
2965
|
+
} catch (e) {}
|
|
2966
|
+
$("accent-name").textContent = a;
|
|
2967
|
+
}
|
|
2968
|
+
applyAccent(currentAccent()); // sync the label (and prune a bad stored value)
|
|
2969
|
+
$("accent-toggle").addEventListener("click", function () {
|
|
2970
|
+
var i = ACCENTS.indexOf(currentAccent());
|
|
2971
|
+
applyAccent(ACCENTS[(i + 1) % ACCENTS.length]);
|
|
2972
|
+
});
|
|
2973
|
+
|
|
2974
|
+
// ---- about modal ----
|
|
2975
|
+
function showAbout(on) { $("about").classList.toggle("show", on); }
|
|
2976
|
+
$("about-btn").addEventListener("click", function () { showAbout(true); });
|
|
2977
|
+
$("about-close").addEventListener("click", function () { showAbout(false); });
|
|
2978
|
+
$("about").addEventListener("click", function (e) {
|
|
2979
|
+
if (e.target === this) showAbout(false); // click the dimmed backdrop to close
|
|
2980
|
+
});
|
|
2981
|
+
document.addEventListener("keydown", function (e) {
|
|
2982
|
+
if (e.key === "Escape" && $("about").classList.contains("show")) showAbout(false);
|
|
2983
|
+
});
|
|
2984
|
+
|
|
2985
|
+
// ---- init ----
|
|
2986
|
+
loadFiles();
|
|
2987
|
+
loadBaselines();
|
|
2988
|
+
reportClientState();
|
|
2989
|
+
connect();
|
|
2990
|
+
if (APP_FALLBACK) {
|
|
2991
|
+
setStatus("native window unavailable — running in the browser · pip install 'draftwatch[app]'");
|
|
2992
|
+
}
|
|
2993
|
+
})();
|
|
2994
|
+
</script>
|
|
2995
|
+
</body>
|
|
2996
|
+
</html>
|
|
2997
|
+
"""
|
|
2998
|
+
|
|
2999
|
+
|
|
3000
|
+
# --------------------------------------------------------------------------- #
|
|
3001
|
+
# main
|
|
3002
|
+
# --------------------------------------------------------------------------- #
|
|
3003
|
+
|
|
3004
|
+
def fail(msg, code=2):
|
|
3005
|
+
sys.stderr.write("draftwatch: " + msg + "\n")
|
|
3006
|
+
sys.exit(code)
|
|
3007
|
+
|
|
3008
|
+
|
|
3009
|
+
def _webview_available():
|
|
3010
|
+
try:
|
|
3011
|
+
import webview # noqa: F401
|
|
3012
|
+
return True
|
|
3013
|
+
except ImportError:
|
|
3014
|
+
return False
|
|
3015
|
+
|
|
3016
|
+
|
|
3017
|
+
def _run_app_window(url, title):
|
|
3018
|
+
"""Open the UI in a native window (PyWebView). Returns True when the window
|
|
3019
|
+
ran and was closed by the user; False when unavailable, in which case the
|
|
3020
|
+
caller falls back to browser mode. The GUI event loop must own the main
|
|
3021
|
+
thread (a hard Cocoa requirement on macOS), which is why app mode moves the
|
|
3022
|
+
HTTP server to a background thread before calling this."""
|
|
3023
|
+
try:
|
|
3024
|
+
import webview
|
|
3025
|
+
except ImportError:
|
|
3026
|
+
sys.stderr.write(
|
|
3027
|
+
"draftwatch: the native window needs pywebview, which isn't installed:\n"
|
|
3028
|
+
" pip install 'draftwatch[app]'\n"
|
|
3029
|
+
"falling back to the browser.\n")
|
|
3030
|
+
return False
|
|
3031
|
+
try:
|
|
3032
|
+
webview.create_window(title, url, width=1280, height=860,
|
|
3033
|
+
min_size=(880, 560))
|
|
3034
|
+
webview.start()
|
|
3035
|
+
return True
|
|
3036
|
+
except Exception as e:
|
|
3037
|
+
sys.stderr.write(
|
|
3038
|
+
"draftwatch: could not open a native window (%s) — "
|
|
3039
|
+
"falling back to the browser.\n" % e)
|
|
3040
|
+
return False
|
|
3041
|
+
|
|
3042
|
+
|
|
3043
|
+
def main(argv=None):
|
|
3044
|
+
parser = argparse.ArgumentParser(
|
|
3045
|
+
prog="draftwatch",
|
|
3046
|
+
description="Watch a .md file and review an agent's edits as a real git diff.",
|
|
3047
|
+
)
|
|
3048
|
+
parser.add_argument(
|
|
3049
|
+
"target", nargs="?", default=None,
|
|
3050
|
+
help="path to the file to watch (relative or absolute). Optional: if omitted, "
|
|
3051
|
+
"draftwatch starts in the current git repo and you pick a file in the browser.")
|
|
3052
|
+
parser.add_argument("--port", type=int, default=8787, help="port (default 8787)")
|
|
3053
|
+
parser.add_argument(
|
|
3054
|
+
"--host", default="127.0.0.1",
|
|
3055
|
+
help="bind host (default 127.0.0.1). WARNING: changing this exposes the tool "
|
|
3056
|
+
"on your network and is not recommended.",
|
|
3057
|
+
)
|
|
3058
|
+
parser.add_argument(
|
|
3059
|
+
"--no-open", action="store_true",
|
|
3060
|
+
help="do not auto-open the browser on startup (it opens by default).",
|
|
3061
|
+
)
|
|
3062
|
+
parser.add_argument(
|
|
3063
|
+
"--app", action="store_true",
|
|
3064
|
+
help="force the native window (the default when pywebview is installed). "
|
|
3065
|
+
"Needs pywebview (pip install 'draftwatch[app]'); falls back to the "
|
|
3066
|
+
"browser if it isn't available.",
|
|
3067
|
+
)
|
|
3068
|
+
parser.add_argument(
|
|
3069
|
+
"--no-app", action="store_true",
|
|
3070
|
+
help="never open the native window; use the browser even when pywebview "
|
|
3071
|
+
"is installed.",
|
|
3072
|
+
)
|
|
3073
|
+
args = parser.parse_args(argv)
|
|
3074
|
+
|
|
3075
|
+
if args.app and args.no_app:
|
|
3076
|
+
fail("--app and --no-app are mutually exclusive")
|
|
3077
|
+
|
|
3078
|
+
# --- resolve the git repo root ---
|
|
3079
|
+
# With a target: from the target's directory. Without one: from the current dir.
|
|
3080
|
+
if args.target is not None:
|
|
3081
|
+
if not os.path.exists(args.target):
|
|
3082
|
+
fail("target file does not exist: %s" % args.target)
|
|
3083
|
+
if os.path.isdir(args.target):
|
|
3084
|
+
fail("target is a directory, not a file: %s" % args.target)
|
|
3085
|
+
base_dir = os.path.dirname(os.path.realpath(os.path.abspath(args.target)))
|
|
3086
|
+
else:
|
|
3087
|
+
base_dir = os.getcwd()
|
|
3088
|
+
|
|
3089
|
+
if not is_inside_work_tree(base_dir):
|
|
3090
|
+
fail("'%s' is not inside a git repository. Run draftwatch from a git working tree."
|
|
3091
|
+
% base_dir)
|
|
3092
|
+
root = git_toplevel(base_dir)
|
|
3093
|
+
if root is None:
|
|
3094
|
+
fail("could not resolve the git repository root for: %s" % base_dir)
|
|
3095
|
+
|
|
3096
|
+
state = State(root)
|
|
3097
|
+
|
|
3098
|
+
init_note = "no file open — choose one in the browser"
|
|
3099
|
+
if args.target is not None:
|
|
3100
|
+
try:
|
|
3101
|
+
state.open_file(os.path.abspath(args.target))
|
|
3102
|
+
except ValueError as e:
|
|
3103
|
+
fail(str(e))
|
|
3104
|
+
init_note = "baseline: " + state.baseline.get("label", "HEAD")
|
|
3105
|
+
|
|
3106
|
+
# --- HTTP server ---
|
|
3107
|
+
# per-session token: gates every /api/* and /events request (see Handler._guard)
|
|
3108
|
+
token = secrets.token_urlsafe(32)
|
|
3109
|
+
Handler.state = state
|
|
3110
|
+
Handler.token = token
|
|
3111
|
+
Handler.allowed_hosts = {"127.0.0.1:%d" % args.port, "localhost:%d" % args.port}
|
|
3112
|
+
if args.host not in ("127.0.0.1", "localhost", "0.0.0.0"):
|
|
3113
|
+
Handler.allowed_hosts.add("%s:%d" % (args.host, args.port))
|
|
3114
|
+
try:
|
|
3115
|
+
httpd = ThreadingHTTPServer((args.host, args.port), Handler)
|
|
3116
|
+
except OSError as e:
|
|
3117
|
+
fail("could not bind %s:%d (%s)" % (args.host, args.port, e), code=1)
|
|
3118
|
+
httpd.daemon_threads = True
|
|
3119
|
+
|
|
3120
|
+
stop_event = threading.Event()
|
|
3121
|
+
watcher = threading.Thread(target=watch_loop, args=(state, stop_event), daemon=True)
|
|
3122
|
+
watcher.start()
|
|
3123
|
+
|
|
3124
|
+
# a browser cannot navigate to 0.0.0.0; always send it to loopback.
|
|
3125
|
+
# The token rides in the URL so the page can authenticate its requests.
|
|
3126
|
+
open_url = "http://127.0.0.1:{}/?t={}".format(args.port, token)
|
|
3127
|
+
|
|
3128
|
+
# Launch surface: the native window is the flagship — used by default when
|
|
3129
|
+
# pywebview is installed. --app forces the attempt; --no-app disables it;
|
|
3130
|
+
# --no-open signals headless intent, so the default (but not an explicit
|
|
3131
|
+
# --app) skips the window too.
|
|
3132
|
+
want_app = False
|
|
3133
|
+
if not args.no_app:
|
|
3134
|
+
if args.app:
|
|
3135
|
+
want_app = True
|
|
3136
|
+
elif not args.no_open and _webview_available():
|
|
3137
|
+
want_app = True
|
|
3138
|
+
|
|
3139
|
+
if state.has_file():
|
|
3140
|
+
print("draftwatch · watching {}".format(state.relpath))
|
|
3141
|
+
else:
|
|
3142
|
+
print("draftwatch · no file open")
|
|
3143
|
+
print(init_note)
|
|
3144
|
+
if args.host != "127.0.0.1":
|
|
3145
|
+
print("WARNING: bound to {} — the tool is exposed on your network.".format(args.host))
|
|
3146
|
+
print("open {} (ctrl-c to stop)".format(open_url), flush=True)
|
|
3147
|
+
if want_app:
|
|
3148
|
+
print("mode: native window — close the window to stop", flush=True)
|
|
3149
|
+
elif not args.no_app and not args.no_open and not args.app:
|
|
3150
|
+
print("tip: pip install 'draftwatch[app]' to get the native window", flush=True)
|
|
3151
|
+
|
|
3152
|
+
def open_browser():
|
|
3153
|
+
# open the browser in a background thread so a slow/headless launcher
|
|
3154
|
+
# can't delay or crash startup
|
|
3155
|
+
def _open():
|
|
3156
|
+
try:
|
|
3157
|
+
webbrowser.open(open_url)
|
|
3158
|
+
except Exception:
|
|
3159
|
+
pass
|
|
3160
|
+
threading.Thread(target=_open, daemon=True).start()
|
|
3161
|
+
|
|
3162
|
+
if want_app:
|
|
3163
|
+
# native window mode: the server serves from a background thread while
|
|
3164
|
+
# the GUI owns the main thread; closing the window shuts everything down.
|
|
3165
|
+
# `app=1` lets the page style itself as app chrome (no redundant
|
|
3166
|
+
# wordmark — the native title bar carries it).
|
|
3167
|
+
server_thread = threading.Thread(target=httpd.serve_forever, daemon=True)
|
|
3168
|
+
server_thread.start()
|
|
3169
|
+
win_title = "Draftwatch · {}".format(state.relpath) if state.has_file() else "Draftwatch"
|
|
3170
|
+
if _run_app_window(open_url + "&app=1", win_title):
|
|
3171
|
+
print("\ndraftwatch: window closed — stopping")
|
|
3172
|
+
stop_event.set()
|
|
3173
|
+
httpd.shutdown()
|
|
3174
|
+
return
|
|
3175
|
+
# pywebview unavailable: keep the already-running server and behave
|
|
3176
|
+
# like browser mode; `appfallback=1` makes the page say why the native
|
|
3177
|
+
# window didn't appear instead of leaving the switch silent.
|
|
3178
|
+
if not args.no_open:
|
|
3179
|
+
def _open_fb():
|
|
3180
|
+
try:
|
|
3181
|
+
webbrowser.open(open_url + "&appfallback=1")
|
|
3182
|
+
except Exception:
|
|
3183
|
+
pass
|
|
3184
|
+
threading.Thread(target=_open_fb, daemon=True).start()
|
|
3185
|
+
try:
|
|
3186
|
+
while True:
|
|
3187
|
+
time.sleep(3600)
|
|
3188
|
+
except KeyboardInterrupt:
|
|
3189
|
+
print("\ndraftwatch: stopping")
|
|
3190
|
+
finally:
|
|
3191
|
+
stop_event.set()
|
|
3192
|
+
httpd.shutdown()
|
|
3193
|
+
return
|
|
3194
|
+
|
|
3195
|
+
if not args.no_open:
|
|
3196
|
+
open_browser()
|
|
3197
|
+
|
|
3198
|
+
try:
|
|
3199
|
+
httpd.serve_forever()
|
|
3200
|
+
except KeyboardInterrupt:
|
|
3201
|
+
print("\ndraftwatch: stopping")
|
|
3202
|
+
finally:
|
|
3203
|
+
stop_event.set()
|
|
3204
|
+
httpd.shutdown()
|
|
3205
|
+
|
|
3206
|
+
|
|
3207
|
+
if __name__ == "__main__":
|
|
3208
|
+
main()
|