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/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&hellip;</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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
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("") : "&nbsp;";
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()