ログ更新の自動追従を追加 (ON/OFF 切替つき)
ヘッダの「自動追従」で有効/無効を切り替える。状態は localStorage に保持し、 既定は有効。無効時は従来どおり再読込ボタンで更新する。 - Watcher スレッド: --interval 秒ごとに jsonl を stat し、ファイル数・合計 サイズ・最新 mtime の組が変わったら rev を進めて待機中の SSE を起こす - /api/watch (SSE) で change を通知、20 秒ごとに ping で接続維持。 /api/rev で現在の版も取れる - ブラウザ側は通知を 1.2 秒まとめてから表示中のものだけ黙って作り直す。 プロジェクト/タスクの選択・スクロール位置・絞り込み・タブは維持し、 やりとりを末尾まで見ていた場合は追記行に追従する - 日別集計・ガントの更新は placeholder を出さない silent 経路を追加 - 後から有効にした時に、無効だった間の変更を版の比較で取り込む Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+99
@@ -21,6 +21,7 @@ import json
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
import webbrowser
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
@@ -586,6 +587,72 @@ class Store:
|
||||
self._cache.clear()
|
||||
|
||||
|
||||
class Watcher(threading.Thread):
|
||||
"""projects 配下の jsonl を定期的に stat して変更を検出する (自動追従の通知用)。
|
||||
|
||||
ファイル数・合計サイズ・最新 mtime の組を「版」として持ち、変わったら rev を進めて
|
||||
待っているクライアント (SSE) を起こす。解析結果は Store 側が (mtime, size) で
|
||||
キャッシュしているので、ここでは通知だけを担う。
|
||||
"""
|
||||
|
||||
def __init__(self, root: Path, interval=2.0):
|
||||
super().__init__(daemon=True)
|
||||
self.root = root
|
||||
self.interval = max(0.5, interval)
|
||||
self.rev = 0
|
||||
self.newest = None # (project_id, session_id)
|
||||
self._cv = threading.Condition()
|
||||
self._token, self.newest = self._scan()
|
||||
|
||||
def _scan(self):
|
||||
files = 0
|
||||
total = 0
|
||||
newest_mtime = 0.0
|
||||
newest = None
|
||||
if self.root.is_dir():
|
||||
for entry in self.root.iterdir():
|
||||
if not entry.is_dir():
|
||||
continue
|
||||
for path in entry.glob("*.jsonl"):
|
||||
try:
|
||||
stat = path.stat()
|
||||
except OSError:
|
||||
continue
|
||||
files += 1
|
||||
total += stat.st_size
|
||||
if stat.st_mtime > newest_mtime:
|
||||
newest_mtime = stat.st_mtime
|
||||
newest = (entry.name, path.stem)
|
||||
return (files, total, round(newest_mtime, 3)), newest
|
||||
|
||||
def run(self):
|
||||
while True:
|
||||
time.sleep(self.interval)
|
||||
try:
|
||||
token, newest = self._scan()
|
||||
except OSError:
|
||||
continue
|
||||
with self._cv:
|
||||
if token != self._token:
|
||||
self._token = token
|
||||
self.newest = newest
|
||||
self.rev += 1
|
||||
self._cv.notify_all()
|
||||
|
||||
def wait_change(self, rev, timeout):
|
||||
"""rev より進むまで待って、現在の rev を返す (タイムアウト時は同じ値)。"""
|
||||
with self._cv:
|
||||
if self.rev != rev:
|
||||
return self.rev
|
||||
self._cv.wait(timeout)
|
||||
return self.rev
|
||||
|
||||
def info(self):
|
||||
with self._cv:
|
||||
project, session = self.newest or (None, None)
|
||||
return {"rev": self.rev, "project": project, "session": session, "interval": self.interval}
|
||||
|
||||
|
||||
CWD_RE = re.compile(rb'"cwd":"((?:[^"\\]|\\.)*)"')
|
||||
|
||||
|
||||
@@ -750,8 +817,37 @@ class Handler(BaseHTTPRequestHandler):
|
||||
if route == "/api/reload":
|
||||
store.clear()
|
||||
return self.send_json({"ok": True})
|
||||
if route == "/api/rev":
|
||||
return self.send_json(self.server.watcher.info())
|
||||
if route == "/api/watch":
|
||||
return self.watch()
|
||||
raise FileNotFoundError(route)
|
||||
|
||||
def watch(self):
|
||||
"""SSE。ログに変更があるまでブロックし、あれば change イベントを流す。"""
|
||||
watcher = self.server.watcher
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/event-stream; charset=utf-8")
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.send_header("X-Accel-Buffering", "no")
|
||||
self.end_headers()
|
||||
rev = watcher.rev
|
||||
try:
|
||||
self.stream(f"retry: 3000\n\nevent: hello\ndata: {json.dumps(watcher.info())}\n\n")
|
||||
while True:
|
||||
current = watcher.wait_change(rev, 20)
|
||||
if current != rev:
|
||||
rev = current
|
||||
self.stream(f"event: change\ndata: {json.dumps(watcher.info())}\n\n")
|
||||
else:
|
||||
self.stream(": ping\n\n") # 接続維持
|
||||
except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError, OSError, ValueError):
|
||||
return # タブを閉じた等
|
||||
|
||||
def stream(self, text):
|
||||
self.wfile.write(text.encode("utf-8"))
|
||||
self.wfile.flush()
|
||||
|
||||
def static(self, route):
|
||||
name = "index.html" if route in ("/", "") else route.lstrip("/")
|
||||
target = (WEB_DIR / name).resolve()
|
||||
@@ -793,6 +889,7 @@ def main():
|
||||
parser.add_argument("--port", type=int, default=8787)
|
||||
parser.add_argument("--host", default="127.0.0.1")
|
||||
parser.add_argument("--dir", default=str(Path.home() / ".claude" / "projects"), help="projects ディレクトリ")
|
||||
parser.add_argument("--interval", type=float, default=2.0, help="ログ更新を見に行く間隔(秒)")
|
||||
parser.add_argument("--no-browser", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
@@ -812,6 +909,8 @@ def main():
|
||||
webbrowser.open(url)
|
||||
return
|
||||
server.store = Store(root)
|
||||
server.watcher = Watcher(root, args.interval)
|
||||
server.watcher.start()
|
||||
print(f"chktime: {root}")
|
||||
print(f" -> {url} (Ctrl+C で終了)")
|
||||
if not args.no_browser:
|
||||
|
||||
Reference in New Issue
Block a user