cfgit 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.
- cfg/__init__.py +13 -0
- cfg/adapters/__init__.py +25 -0
- cfg/adapters/base.py +127 -0
- cfg/adapters/mongo.py +570 -0
- cfg/adapters/postgres.py +756 -0
- cfg/approval/__init__.py +5 -0
- cfg/approval/base.py +29 -0
- cfg/cli/__init__.py +2 -0
- cfg/cli/main.py +665 -0
- cfg/core/__init__.py +13 -0
- cfg/core/authz.py +58 -0
- cfg/core/config.py +324 -0
- cfg/core/diff.py +43 -0
- cfg/core/engine.py +1388 -0
- cfg/core/hashing.py +102 -0
- cfg/core/identity.py +213 -0
- cfg/interfaces/__init__.py +2 -0
- cfg/interfaces/actions.py +598 -0
- cfg/mcp/__init__.py +10 -0
- cfg/mcp/server.py +452 -0
- cfg/ui/__init__.py +2 -0
- cfg/ui/server.py +1066 -0
- cfgit-0.1.0.dist-info/METADATA +744 -0
- cfgit-0.1.0.dist-info/RECORD +28 -0
- cfgit-0.1.0.dist-info/WHEEL +4 -0
- cfgit-0.1.0.dist-info/entry_points.txt +3 -0
- cfgit-0.1.0.dist-info/licenses/LICENSE +201 -0
- cfgit-0.1.0.dist-info/licenses/NOTICE +10 -0
cfg/ui/server.py
ADDED
|
@@ -0,0 +1,1066 @@
|
|
|
1
|
+
# Copyright 2026 Mohammad Ausaf. Licensed under the Apache License, Version 2.0.
|
|
2
|
+
"""Small localhost UI server for cfgit."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from http import HTTPStatus
|
|
6
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
7
|
+
import json
|
|
8
|
+
import socket
|
|
9
|
+
from typing import Any
|
|
10
|
+
from urllib.parse import parse_qs, urlparse
|
|
11
|
+
import webbrowser
|
|
12
|
+
|
|
13
|
+
from cfg.interfaces import actions
|
|
14
|
+
from cfg.interfaces.actions import ActionContext
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
DEFAULT_HOST = "127.0.0.1"
|
|
18
|
+
DEFAULT_PORT = 8765
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class CfgUIServer(ThreadingHTTPServer):
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
server_address: tuple[str, int],
|
|
25
|
+
*,
|
|
26
|
+
config_file: str | None,
|
|
27
|
+
env: str,
|
|
28
|
+
author: str | None,
|
|
29
|
+
):
|
|
30
|
+
super().__init__(server_address, CfgUIHandler)
|
|
31
|
+
self.config_file = config_file
|
|
32
|
+
self.env = env
|
|
33
|
+
self.author = author
|
|
34
|
+
|
|
35
|
+
def server_bind(self) -> None:
|
|
36
|
+
self.socket.bind(self.server_address)
|
|
37
|
+
self.server_address = self.socket.getsockname()
|
|
38
|
+
self.server_name = str(self.server_address[0])
|
|
39
|
+
self.server_port = int(self.server_address[1])
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class CfgUIHandler(BaseHTTPRequestHandler):
|
|
43
|
+
server: CfgUIServer
|
|
44
|
+
|
|
45
|
+
def log_message(self, fmt: str, *args: Any) -> None:
|
|
46
|
+
return
|
|
47
|
+
|
|
48
|
+
def do_GET(self) -> None: # noqa: N802
|
|
49
|
+
parsed = urlparse(self.path)
|
|
50
|
+
if parsed.path == "/":
|
|
51
|
+
self._send_text(UI_HTML, content_type="text/html; charset=utf-8")
|
|
52
|
+
return
|
|
53
|
+
if parsed.path == "/api/health":
|
|
54
|
+
self._send_json({"ok": True})
|
|
55
|
+
return
|
|
56
|
+
if parsed.path == "/api/schema":
|
|
57
|
+
params = parse_qs(parsed.query)
|
|
58
|
+
self._send_json(self._schema(params))
|
|
59
|
+
return
|
|
60
|
+
if parsed.path == "/api/state":
|
|
61
|
+
params = parse_qs(parsed.query)
|
|
62
|
+
self._send_json(self._state(params))
|
|
63
|
+
return
|
|
64
|
+
self.send_error(HTTPStatus.NOT_FOUND)
|
|
65
|
+
|
|
66
|
+
def do_POST(self) -> None: # noqa: N802
|
|
67
|
+
parsed = urlparse(self.path)
|
|
68
|
+
if parsed.path != "/api/action":
|
|
69
|
+
self.send_error(HTTPStatus.NOT_FOUND)
|
|
70
|
+
return
|
|
71
|
+
try:
|
|
72
|
+
payload = self._read_json()
|
|
73
|
+
name = str(payload.get("action") or "")
|
|
74
|
+
engine = actions.make_engine(self._ctx(payload))
|
|
75
|
+
result = actions.envelope(_run_action, name, engine, payload)
|
|
76
|
+
self._send_json(result)
|
|
77
|
+
except Exception as exc:
|
|
78
|
+
self._send_json(
|
|
79
|
+
{"status": "error", "code": actions.EXIT_STORAGE, "message": str(exc), "data": None},
|
|
80
|
+
status=HTTPStatus.INTERNAL_SERVER_ERROR,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
def _ctx(self, payload: dict[str, Any] | None = None, params: dict[str, list[str]] | None = None) -> ActionContext:
|
|
84
|
+
payload = payload or {}
|
|
85
|
+
params = params or {}
|
|
86
|
+
return ActionContext(
|
|
87
|
+
config_file=_first(params, "config_file") or payload.get("config_file") or self.server.config_file,
|
|
88
|
+
env=_first(params, "env") or str(payload.get("env") or self.server.env),
|
|
89
|
+
author=_first(params, "author") or payload.get("author") or self.server.author,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
def _schema(self, params: dict[str, list[str]]) -> dict[str, Any]:
|
|
93
|
+
try:
|
|
94
|
+
project = actions.load_config(_first(params, "config_file") or self.server.config_file)
|
|
95
|
+
return {
|
|
96
|
+
"status": "ok",
|
|
97
|
+
"data": {
|
|
98
|
+
"project": project.name,
|
|
99
|
+
"config_file": str(project.path),
|
|
100
|
+
"envs": sorted(project.envs),
|
|
101
|
+
"collections": [
|
|
102
|
+
{
|
|
103
|
+
"name": item.name,
|
|
104
|
+
"id_field": item.id_field,
|
|
105
|
+
"live_when": item.live_when,
|
|
106
|
+
}
|
|
107
|
+
for item in project.collections
|
|
108
|
+
],
|
|
109
|
+
"connections": actions.to_json(project.connections),
|
|
110
|
+
},
|
|
111
|
+
}
|
|
112
|
+
except Exception as exc:
|
|
113
|
+
return {"status": "error", "message": str(exc), "data": None}
|
|
114
|
+
|
|
115
|
+
def _state(self, params: dict[str, list[str]]) -> dict[str, Any]:
|
|
116
|
+
ctx = self._ctx(params=params)
|
|
117
|
+
engine = actions.make_engine(ctx)
|
|
118
|
+
who, _ = actions.whoami(engine)
|
|
119
|
+
rows, code = actions.status(engine)
|
|
120
|
+
branches: list[dict[str, Any]] = []
|
|
121
|
+
prs: list[dict[str, Any]] = []
|
|
122
|
+
try:
|
|
123
|
+
branches, _ = actions.branch_list(engine)
|
|
124
|
+
prs, _ = actions.pr_list(engine, status="open")
|
|
125
|
+
except Exception:
|
|
126
|
+
branches = []
|
|
127
|
+
prs = []
|
|
128
|
+
return {
|
|
129
|
+
"status": "dirty" if code == actions.EXIT_DIRTY else "ok",
|
|
130
|
+
"code": code,
|
|
131
|
+
"message": "",
|
|
132
|
+
"data": {
|
|
133
|
+
"whoami": actions.to_json(who),
|
|
134
|
+
"status": actions.to_json(rows),
|
|
135
|
+
"branches": actions.to_json(branches),
|
|
136
|
+
"prs": actions.to_json(prs),
|
|
137
|
+
},
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
def _read_json(self) -> dict[str, Any]:
|
|
141
|
+
length = int(self.headers.get("Content-Length", "0"))
|
|
142
|
+
if length > 5_000_000:
|
|
143
|
+
raise ValueError("request body is too large")
|
|
144
|
+
raw = self.rfile.read(length).decode("utf-8") if length else "{}"
|
|
145
|
+
data = json.loads(raw)
|
|
146
|
+
if not isinstance(data, dict):
|
|
147
|
+
raise ValueError("request body must be a JSON object")
|
|
148
|
+
return data
|
|
149
|
+
|
|
150
|
+
def _send_json(self, value: Any, *, status: HTTPStatus = HTTPStatus.OK) -> None:
|
|
151
|
+
body = json.dumps(actions.to_json(value), indent=2, sort_keys=True).encode("utf-8")
|
|
152
|
+
self.send_response(status)
|
|
153
|
+
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
154
|
+
self.send_header("Content-Length", str(len(body)))
|
|
155
|
+
self.send_header("Cache-Control", "no-store")
|
|
156
|
+
self.end_headers()
|
|
157
|
+
self.wfile.write(body)
|
|
158
|
+
|
|
159
|
+
def _send_text(self, value: str, *, content_type: str) -> None:
|
|
160
|
+
body = value.encode("utf-8")
|
|
161
|
+
self.send_response(HTTPStatus.OK)
|
|
162
|
+
self.send_header("Content-Type", content_type)
|
|
163
|
+
self.send_header("Content-Length", str(len(body)))
|
|
164
|
+
self.send_header("Cache-Control", "no-store")
|
|
165
|
+
self.end_headers()
|
|
166
|
+
self.wfile.write(body)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def run_ui(
|
|
170
|
+
*,
|
|
171
|
+
config_file: str | None = None,
|
|
172
|
+
env: str = "dev",
|
|
173
|
+
author: str | None = None,
|
|
174
|
+
host: str = DEFAULT_HOST,
|
|
175
|
+
port: int = DEFAULT_PORT,
|
|
176
|
+
open_browser: bool = True,
|
|
177
|
+
allow_port_fallback: bool = True,
|
|
178
|
+
) -> int:
|
|
179
|
+
server = _bind_server(
|
|
180
|
+
host=host,
|
|
181
|
+
port=port,
|
|
182
|
+
config_file=config_file,
|
|
183
|
+
env=env,
|
|
184
|
+
author=author,
|
|
185
|
+
allow_port_fallback=allow_port_fallback,
|
|
186
|
+
)
|
|
187
|
+
actual_host, actual_port = server.server_address
|
|
188
|
+
url = f"http://{actual_host}:{actual_port}/"
|
|
189
|
+
print(f"cfg ui listening on {url}", flush=True)
|
|
190
|
+
if open_browser:
|
|
191
|
+
webbrowser.open(url)
|
|
192
|
+
try:
|
|
193
|
+
server.serve_forever()
|
|
194
|
+
except KeyboardInterrupt:
|
|
195
|
+
print("\ncfg ui stopped")
|
|
196
|
+
finally:
|
|
197
|
+
server.server_close()
|
|
198
|
+
return 0
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _bind_server(
|
|
202
|
+
*,
|
|
203
|
+
host: str,
|
|
204
|
+
port: int,
|
|
205
|
+
config_file: str | None,
|
|
206
|
+
env: str,
|
|
207
|
+
author: str | None,
|
|
208
|
+
allow_port_fallback: bool = True,
|
|
209
|
+
) -> CfgUIServer:
|
|
210
|
+
last_error: OSError | None = None
|
|
211
|
+
candidates = range(port, port + 50) if allow_port_fallback else range(port, port + 1)
|
|
212
|
+
for candidate in candidates:
|
|
213
|
+
try:
|
|
214
|
+
return CfgUIServer(
|
|
215
|
+
(host, candidate),
|
|
216
|
+
config_file=config_file,
|
|
217
|
+
env=env,
|
|
218
|
+
author=author,
|
|
219
|
+
)
|
|
220
|
+
except OSError as exc:
|
|
221
|
+
last_error = exc
|
|
222
|
+
if exc.errno not in {48, 98, 10048}:
|
|
223
|
+
raise
|
|
224
|
+
if not allow_port_fallback:
|
|
225
|
+
raise OSError(f"could not bind {host}:{port}; port is already in use") from exc
|
|
226
|
+
raise OSError(f"could not bind {host}:{port}-{port + 49}") from last_error
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _run_action(name: str, engine: Any, payload: dict[str, Any]) -> tuple[Any, int]:
|
|
230
|
+
return actions.run_named_action(name, engine, payload)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _first(params: dict[str, list[str]], key: str) -> str | None:
|
|
234
|
+
values = params.get(key) or []
|
|
235
|
+
if not values:
|
|
236
|
+
return None
|
|
237
|
+
return values[0] or None
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def find_free_port(host: str = DEFAULT_HOST) -> int:
|
|
241
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
|
242
|
+
sock.bind((host, 0))
|
|
243
|
+
return int(sock.getsockname()[1])
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
UI_HTML = r"""<!doctype html>
|
|
247
|
+
<html lang="en" data-theme="dark">
|
|
248
|
+
<head>
|
|
249
|
+
<meta charset="utf-8">
|
|
250
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
251
|
+
<title>cfgit</title>
|
|
252
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
253
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
254
|
+
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;600;700&family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
|
255
|
+
<style>
|
|
256
|
+
/* ============ cfgit · git-meets-Compass · two themes ============ */
|
|
257
|
+
:root{
|
|
258
|
+
--disp:"Space Grotesk",ui-sans-serif,system-ui,sans-serif;
|
|
259
|
+
--body:"Inter",ui-sans-serif,system-ui,-apple-system,sans-serif;
|
|
260
|
+
--mono:"JetBrains Mono",ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
|
|
261
|
+
}
|
|
262
|
+
/* DARK: deep slate, never pure black; calm on the eyes */
|
|
263
|
+
[data-theme="dark"]{
|
|
264
|
+
--bg:#10151c; --chrome:#141b24; --panel:#18212c; --panel2:#1e2935; --raise:#24303d;
|
|
265
|
+
--edge:#27323f; --edge2:#33414f;
|
|
266
|
+
--ink:#e8edf2; --dim:#9aa6b2; --faint:#67727e;
|
|
267
|
+
--blue:#5b8dff; --blue2:#3d6fe0;
|
|
268
|
+
--amber:#e0a445; --amber-bg:rgba(224,164,69,.14);
|
|
269
|
+
--moss:#5bb37a; --moss-bg:rgba(91,179,122,.13);
|
|
270
|
+
--sky:#5bb1ff; --sky-bg:rgba(91,177,255,.12);
|
|
271
|
+
/* paper diff surface (the signature) stays warm even in dark */
|
|
272
|
+
--paper:#f3efe6; --paper-edge:#ddd6c6; --paper-ink:#2b2a25; --paper-dim:#7a7567;
|
|
273
|
+
--paper-del:#fbe7e3; --paper-del-ink:#9a3a2c; --paper-add:#e6f0e3; --paper-add-ink:#2f6a3d;
|
|
274
|
+
--paper-gutter:#ece6da;
|
|
275
|
+
--shadow:0 18px 40px rgba(0,0,0,.45);
|
|
276
|
+
}
|
|
277
|
+
/* LIGHT: warm off-white, soft ink; not glare-white */
|
|
278
|
+
[data-theme="light"]{
|
|
279
|
+
--bg:#eceae3; --chrome:#f3f1ea; --panel:#f7f5ef; --panel2:#efece3; --raise:#e7e3d8;
|
|
280
|
+
--edge:#dcd7ca; --edge2:#cfc9b8;
|
|
281
|
+
--ink:#24272b; --dim:#5f6670; --faint:#8b9099;
|
|
282
|
+
--blue:#2f6af0; --blue2:#1f56d8;
|
|
283
|
+
--amber:#b5781f; --amber-bg:rgba(181,120,31,.14);
|
|
284
|
+
--moss:#3f8a59; --moss-bg:rgba(63,138,89,.12);
|
|
285
|
+
--sky:#2575c8; --sky-bg:rgba(37,117,200,.10);
|
|
286
|
+
--paper:#fbfaf5; --paper-edge:#e4dfd2; --paper-ink:#2b2a25; --paper-dim:#857f70;
|
|
287
|
+
--paper-del:#fbe7e3; --paper-del-ink:#9a3a2c; --paper-add:#e6f0e3; --paper-add-ink:#2f6a3d;
|
|
288
|
+
--paper-gutter:#f1ede2;
|
|
289
|
+
--shadow:0 16px 36px rgba(60,56,44,.14);
|
|
290
|
+
}
|
|
291
|
+
*{box-sizing:border-box}
|
|
292
|
+
html,body{height:100%}
|
|
293
|
+
body{margin:0;background:var(--bg);color:var(--ink);font-family:var(--body);font-size:13.5px;line-height:1.5;
|
|
294
|
+
-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility}
|
|
295
|
+
button,input,select{font:inherit;color:inherit}
|
|
296
|
+
::selection{background:var(--blue);color:#fff}
|
|
297
|
+
.mono{font-family:var(--mono)}
|
|
298
|
+
*::-webkit-scrollbar{width:10px;height:10px}
|
|
299
|
+
*::-webkit-scrollbar-thumb{background:var(--edge2);border-radius:6px;border:2px solid transparent;background-clip:content-box}
|
|
300
|
+
*::-webkit-scrollbar-thumb:hover{background:var(--faint);background-clip:content-box}
|
|
301
|
+
|
|
302
|
+
.app{display:grid;grid-template-rows:auto 1fr;height:100vh;min-height:0}
|
|
303
|
+
|
|
304
|
+
/* ---- top bar ---- */
|
|
305
|
+
.top{display:flex;align-items:center;gap:16px;padding:0 18px;height:54px;
|
|
306
|
+
background:var(--chrome);border-bottom:1px solid var(--edge)}
|
|
307
|
+
.brand{display:flex;align-items:baseline;gap:2px;font-family:var(--disp);font-weight:700;font-size:18px;letter-spacing:-.01em}
|
|
308
|
+
.brand .dot{color:var(--blue)}
|
|
309
|
+
.who{display:flex;align-items:center;gap:9px;font-size:12.5px;color:var(--dim)}
|
|
310
|
+
.who .ava{width:22px;height:22px;border-radius:6px;display:grid;place-items:center;font-family:var(--mono);
|
|
311
|
+
font-size:10px;font-weight:600;color:#fff;background:linear-gradient(135deg,var(--blue),var(--blue2))}
|
|
312
|
+
.who b{color:var(--ink);font-weight:600}
|
|
313
|
+
.chip{font-family:var(--mono);font-size:10.5px;letter-spacing:.06em;text-transform:uppercase;
|
|
314
|
+
padding:2px 9px;border-radius:999px;border:1px solid var(--edge2);color:var(--dim)}
|
|
315
|
+
.chip.open{color:var(--moss);border-color:var(--moss-bg);background:var(--moss-bg)}
|
|
316
|
+
.top .sp{flex:1}
|
|
317
|
+
.seg{display:flex;background:var(--panel);border:1px solid var(--edge2);border-radius:8px;padding:2px;gap:2px}
|
|
318
|
+
.seg button{border:0;background:transparent;color:var(--dim);padding:4px 9px;border-radius:6px;font-size:12px;cursor:pointer;line-height:1}
|
|
319
|
+
.seg button.on{background:var(--raise);color:var(--ink)}
|
|
320
|
+
.envpick{background:var(--panel);border:1px solid var(--edge2);border-radius:8px;color:var(--ink);
|
|
321
|
+
padding:6px 9px;font-size:12.5px;font-family:var(--mono)}
|
|
322
|
+
.branchpick{max-width:170px}
|
|
323
|
+
.ghost{background:transparent;border:1px solid var(--edge2);border-radius:8px;color:var(--dim);
|
|
324
|
+
padding:6px 11px;font-size:12.5px;cursor:pointer}
|
|
325
|
+
.ghost:hover{color:var(--ink);border-color:var(--blue)}
|
|
326
|
+
|
|
327
|
+
/* ---- 3 columns ---- */
|
|
328
|
+
.cols{display:grid;grid-template-columns:300px 320px 1fr;min-height:0}
|
|
329
|
+
.pane{min-height:0;display:flex;flex-direction:column;border-right:1px solid var(--edge);overflow:hidden;background:var(--bg)}
|
|
330
|
+
.pane:last-child{border-right:0}
|
|
331
|
+
.ph{display:flex;align-items:center;gap:9px;height:42px;padding:0 14px;flex:0 0 auto;
|
|
332
|
+
border-bottom:1px solid var(--edge);background:var(--chrome)}
|
|
333
|
+
.ph .lab{font-family:var(--disp);font-weight:600;font-size:12px;letter-spacing:.04em;text-transform:uppercase;color:var(--dim)}
|
|
334
|
+
.ph .sp{flex:1}
|
|
335
|
+
.ph .ct{font-family:var(--mono);font-size:11px;color:var(--faint)}
|
|
336
|
+
.scroll{overflow:auto;flex:1;min-height:0}
|
|
337
|
+
|
|
338
|
+
/* ---- LEFT: Compass-style collection tree ---- */
|
|
339
|
+
.find{padding:10px 12px;border-bottom:1px solid var(--edge);background:var(--chrome);flex:0 0 auto}
|
|
340
|
+
.find input{width:100%;background:var(--bg);border:1px solid var(--edge2);border-radius:8px;
|
|
341
|
+
padding:7px 11px;font-size:12.5px;font-family:var(--mono)}
|
|
342
|
+
.find input:focus{outline:none;border-color:var(--blue);box-shadow:0 0 0 3px var(--sky-bg)}
|
|
343
|
+
.filterbar{display:flex;gap:6px;padding:9px 12px 5px;flex-wrap:wrap;flex:0 0 auto}
|
|
344
|
+
.fchip{background:transparent;border:1px solid var(--edge2);border-radius:7px;color:var(--dim);
|
|
345
|
+
padding:3px 9px;font-size:11.5px;cursor:pointer;display:flex;align-items:center;gap:6px}
|
|
346
|
+
.fchip.on{background:var(--panel2);color:var(--ink);border-color:var(--blue)}
|
|
347
|
+
.fchip .n{font-family:var(--mono);font-size:10.5px;color:var(--faint)}
|
|
348
|
+
.fchip.on .n{color:var(--blue)}
|
|
349
|
+
.tree{padding:6px 0 14px}
|
|
350
|
+
.coll{user-select:none}
|
|
351
|
+
.coll-h{display:flex;align-items:center;gap:8px;padding:8px 12px;cursor:pointer}
|
|
352
|
+
.coll-h:hover{background:var(--panel)}
|
|
353
|
+
.tw{width:14px;text-align:center;color:var(--faint);font-size:10px;transition:transform .12s;flex:0 0 auto}
|
|
354
|
+
.coll.open .tw{transform:rotate(90deg)}
|
|
355
|
+
.coll-ic{width:15px;height:15px;flex:0 0 auto;color:var(--dim)}
|
|
356
|
+
.coll-nm{flex:1;min-width:0;font-family:var(--mono);font-size:12.5px;font-weight:500;
|
|
357
|
+
overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
358
|
+
.coll-ct{font-family:var(--mono);font-size:10.5px;color:var(--faint)}
|
|
359
|
+
.coll-warn{width:7px;height:7px;border-radius:50%;background:var(--amber);flex:0 0 auto}
|
|
360
|
+
.docs{display:none}
|
|
361
|
+
.coll.open .docs{display:block}
|
|
362
|
+
.doc{display:flex;align-items:center;gap:9px;padding:6px 12px 6px 30px;cursor:pointer;position:relative}
|
|
363
|
+
.doc::before{content:"";position:absolute;left:18px;top:0;bottom:0;width:1px;background:var(--edge)}
|
|
364
|
+
.doc:hover{background:var(--panel)}
|
|
365
|
+
.doc.sel{background:var(--panel2)}
|
|
366
|
+
.doc.sel::after{content:"";position:absolute;left:0;top:0;bottom:0;width:2px;background:var(--blue)}
|
|
367
|
+
.doc .st{width:7px;height:7px;border-radius:50%;flex:0 0 auto}
|
|
368
|
+
.st.clean{background:var(--moss)} .st.drift{background:var(--amber);box-shadow:0 0 0 3px var(--amber-bg)} .st.new{background:var(--sky)}
|
|
369
|
+
.doc .nm{flex:1;min-width:0;font-family:var(--mono);font-size:12px;display:flex;align-items:baseline;gap:0;
|
|
370
|
+
overflow:hidden;white-space:nowrap}
|
|
371
|
+
.doc .nm .pre{color:var(--faint);flex:0 1 auto;overflow:hidden;text-overflow:ellipsis;min-width:0}
|
|
372
|
+
.doc .nm .leaf{color:var(--dim);flex:0 0 auto}
|
|
373
|
+
.doc.sel .nm .leaf{color:var(--ink)}
|
|
374
|
+
.doc .rt{font-family:var(--mono);font-size:10px;color:var(--faint);flex:0 0 auto}
|
|
375
|
+
.doc.sel .nm{color:var(--ink)}
|
|
376
|
+
/* multi-select "in context" marker (cmd/ctrl-click adds a record to the impact context) */
|
|
377
|
+
.doc.ctx{background:var(--sky-bg)}
|
|
378
|
+
.doc.ctx .ckx{color:var(--sky)}
|
|
379
|
+
.ckx{flex:0 0 auto;width:13px;height:13px;display:grid;place-items:center;font-size:10px;color:transparent}
|
|
380
|
+
.doc:hover .ckx{color:var(--edge2)}
|
|
381
|
+
.doc.ctx:hover .ckx{color:var(--sky)}
|
|
382
|
+
.tag{font-family:var(--mono);font-size:9.5px;letter-spacing:.04em;text-transform:uppercase;
|
|
383
|
+
padding:1px 6px;border-radius:5px}
|
|
384
|
+
.tag.drift{color:var(--amber);background:var(--amber-bg)}
|
|
385
|
+
.tag.new{color:var(--sky);background:var(--sky-bg)}
|
|
386
|
+
.empty{padding:28px 14px;color:var(--faint);font-size:12.5px;text-align:center}
|
|
387
|
+
|
|
388
|
+
/* ---- MIDDLE: commit graph + timeline (the signature rail) ---- */
|
|
389
|
+
.ghost-pane{padding:42px 22px;color:var(--faint);font-size:13px;text-align:center;line-height:1.7}
|
|
390
|
+
.ghost-pane .big{font-family:var(--disp);font-size:15px;color:var(--dim);margin-bottom:6px}
|
|
391
|
+
.selhdr{padding:14px;border-bottom:1px solid var(--edge);flex:0 0 auto;background:var(--chrome)}
|
|
392
|
+
.selhdr .nm{font-family:var(--mono);font-size:13px;font-weight:500;word-break:break-all;line-height:1.4}
|
|
393
|
+
.selhdr .meta{margin-top:6px;display:flex;gap:8px;align-items:center;flex-wrap:wrap;font-size:11.5px;color:var(--dim)}
|
|
394
|
+
.rail{padding:8px 0 18px}
|
|
395
|
+
.node{position:relative;padding:10px 14px 10px 40px;cursor:pointer}
|
|
396
|
+
.node:hover{background:var(--panel)}
|
|
397
|
+
.node.sel{background:var(--panel2)}
|
|
398
|
+
.node.sel::before{content:"";position:absolute;left:0;top:0;bottom:0;width:2px;background:var(--blue)}
|
|
399
|
+
/* graph rail: vertical line + node markers */
|
|
400
|
+
.node .line{position:absolute;left:21px;top:0;bottom:0;width:2px;background:var(--edge2)}
|
|
401
|
+
.node:first-child .line{top:20px}
|
|
402
|
+
.node:last-child .line{bottom:calc(100% - 20px)}
|
|
403
|
+
.node .mk{position:absolute;left:15px;top:14px;width:13px;height:13px;border-radius:50%;
|
|
404
|
+
background:var(--panel);border:2px solid var(--moss);z-index:1}
|
|
405
|
+
.node.commit .mk{border-color:var(--blue)}
|
|
406
|
+
.node.restore .mk{border-color:var(--sky)}
|
|
407
|
+
.node.adopt .mk{border-color:var(--moss)}
|
|
408
|
+
.node.importt .mk{border-color:var(--faint)}
|
|
409
|
+
/* drift = open dashed ring in amber, sitting above the committed line */
|
|
410
|
+
.node.live .mk{border:2px dashed var(--amber);background:var(--amber-bg);width:14px;height:14px;left:14.5px;animation:pulse 2.4s ease-in-out infinite}
|
|
411
|
+
@keyframes pulse{0%,100%{box-shadow:0 0 0 0 var(--amber-bg)}50%{box-shadow:0 0 0 5px transparent}}
|
|
412
|
+
.node .msg{font-size:13px;line-height:1.4;margin-bottom:4px}
|
|
413
|
+
.node.live .msg{color:var(--amber);font-weight:500}
|
|
414
|
+
.node .sub{display:flex;gap:8px;align-items:center;flex-wrap:wrap;font-family:var(--mono);font-size:11px;color:var(--faint)}
|
|
415
|
+
.op{font-family:var(--mono);font-size:9.5px;letter-spacing:.05em;text-transform:uppercase;
|
|
416
|
+
padding:1px 6px;border-radius:5px;border:1px solid var(--edge2);color:var(--dim)}
|
|
417
|
+
.op.r-restore{color:var(--sky);border-color:var(--sky-bg)}
|
|
418
|
+
.op.r-adopt{color:var(--moss);border-color:var(--moss-bg)}
|
|
419
|
+
.op.r-commit{color:var(--blue);border-color:var(--sky-bg)}
|
|
420
|
+
|
|
421
|
+
/* ---- RIGHT: paper diff (the reading surface) ---- */
|
|
422
|
+
.dhead{display:flex;align-items:center;gap:10px;height:42px;padding:0 16px;flex:0 0 auto;
|
|
423
|
+
border-bottom:1px solid var(--edge);background:var(--chrome)}
|
|
424
|
+
.dhead .t{font-size:12.5px;color:var(--dim)}
|
|
425
|
+
.dhead .t b{color:var(--ink);font-family:var(--mono)}
|
|
426
|
+
.dhead .sp{flex:1}
|
|
427
|
+
.btn{border:1px solid var(--edge2);border-radius:8px;padding:6px 13px;font-size:12.5px;cursor:pointer;
|
|
428
|
+
background:var(--panel);color:var(--ink);font-weight:500}
|
|
429
|
+
.btn:hover{border-color:var(--blue)}
|
|
430
|
+
.btn.go{background:var(--blue);border-color:var(--blue);color:#fff}
|
|
431
|
+
.btn.go:hover{background:var(--blue2)}
|
|
432
|
+
.btn.warn{color:var(--amber);border-color:var(--amber-bg)}
|
|
433
|
+
.btn:disabled{opacity:.45;cursor:default}
|
|
434
|
+
/* padding lives on .paper as margin (not on the scroll box) so the sticky field header
|
|
435
|
+
pins flush to the visible top edge — sticky top:0 references the scroll content box. */
|
|
436
|
+
.paperwrap{flex:1;min-height:0;overflow:auto;background:var(--bg)}
|
|
437
|
+
/* no overflow:hidden here — it would clip the sticky field header. Round the top via the
|
|
438
|
+
legend; the bottom rows sit flush (the paper border still reads as rounded). */
|
|
439
|
+
.paper{background:var(--paper);color:var(--paper-ink);border:1px solid var(--paper-edge);border-radius:10px;
|
|
440
|
+
box-shadow:var(--shadow);font-family:var(--mono);font-size:12.5px;margin:16px}
|
|
441
|
+
.paper-h{border-radius:10px 10px 0 0}
|
|
442
|
+
.paper-h{display:grid;grid-template-columns:1fr 1fr;border-bottom:1px solid var(--paper-edge)}
|
|
443
|
+
.paper-h>div{padding:9px 16px;font-size:10.5px;letter-spacing:.07em;text-transform:uppercase;color:var(--paper-dim);
|
|
444
|
+
display:flex;align-items:center;gap:7px}
|
|
445
|
+
.paper-h .r{border-left:1px solid var(--paper-edge)}
|
|
446
|
+
.paper-h .swatch{width:8px;height:8px;border-radius:2px}
|
|
447
|
+
.paper-h .l .swatch{background:var(--paper-del-ink)} .paper-h .r .swatch{background:var(--paper-add-ink)}
|
|
448
|
+
.frow{border-bottom:1px solid var(--paper-edge)}
|
|
449
|
+
.frow:last-child{border-bottom:0}
|
|
450
|
+
/* the field name is the sticky header for its whole diff: it pins to the top of the
|
|
451
|
+
scroll area and stays visible the entire time you scroll that field's lines.
|
|
452
|
+
its parent .frow is tall (the full field diff), so sticky has room to travel.
|
|
453
|
+
the leading fold's expand control is fused in here, so header + "expand N
|
|
454
|
+
unchanged" are a single pinned bar (not two stacked bars that scroll apart). */
|
|
455
|
+
.fname{position:sticky;top:0;z-index:4;display:flex;align-items:center;gap:12px;min-height:30px;
|
|
456
|
+
padding:5px 16px;font-size:11px;color:var(--paper-dim);background:var(--paper-gutter);
|
|
457
|
+
border-bottom:1px solid var(--paper-edge);box-shadow:0 1px 0 rgba(0,0,0,.04)}
|
|
458
|
+
.fname .fnm{letter-spacing:.04em;text-transform:uppercase;font-weight:600;flex:0 0 auto}
|
|
459
|
+
.fname .fhx{display:flex;align-items:center;gap:6px;margin-left:auto}
|
|
460
|
+
.fname .leadfold{display:flex;align-items:center;gap:6px}
|
|
461
|
+
.fpair{display:grid;grid-template-columns:1fr 1fr}
|
|
462
|
+
.fside{padding:8px 16px;white-space:pre-wrap;word-break:break-word;min-height:34px;line-height:1.55}
|
|
463
|
+
.fside.r{border-left:1px solid var(--paper-edge)}
|
|
464
|
+
.fside.del{background:var(--paper-del);color:var(--paper-del-ink)}
|
|
465
|
+
.fside.add{background:var(--paper-add);color:var(--paper-add-ink)}
|
|
466
|
+
.fside.void{background:repeating-linear-gradient(45deg,transparent,transparent 7px,rgba(0,0,0,.025) 7px,rgba(0,0,0,.025) 14px)}
|
|
467
|
+
/* line-aligned split diff for long multi-line strings (git split view) */
|
|
468
|
+
/* row-aligned split diff: each .drow has a left + right cell; folds are expandable */
|
|
469
|
+
.splitgrid{display:flex;flex-direction:column}
|
|
470
|
+
.drow{display:grid;grid-template-columns:1fr 1fr}
|
|
471
|
+
.dcell{display:grid;grid-template-columns:38px 14px 1fr;align-items:baseline;line-height:1.5;font-size:12px;
|
|
472
|
+
border-bottom:1px solid rgba(0,0,0,.04);min-width:0}
|
|
473
|
+
.dcell.r{border-left:1px solid var(--paper-edge)}
|
|
474
|
+
.dcell .gut{text-align:right;padding:3px 7px 3px 0;color:var(--paper-dim);user-select:none;font-size:10.5px;
|
|
475
|
+
border-right:1px solid var(--paper-edge);background:var(--paper-gutter)}
|
|
476
|
+
.dcell .sign{text-align:center;user-select:none;color:var(--paper-dim)}
|
|
477
|
+
.dcell .tx{padding:3px 10px;white-space:pre-wrap;word-break:break-word}
|
|
478
|
+
.dcell.ctx .tx{color:var(--paper-dim)}
|
|
479
|
+
.dcell.del{background:var(--paper-del)} .dcell.del .tx,.dcell.del .sign{color:var(--paper-del-ink)} .dcell.del .gut{background:#f3d9d3;color:#b56a5c}
|
|
480
|
+
.dcell.add{background:var(--paper-add)} .dcell.add .tx,.dcell.add .sign{color:var(--paper-add-ink)} .dcell.add .gut{background:#d9ead2;color:#5f8a64}
|
|
481
|
+
.dcell.void{background:repeating-linear-gradient(45deg,transparent,transparent 7px,rgba(0,0,0,.022) 7px,rgba(0,0,0,.022) 14px)}
|
|
482
|
+
.foldrow{grid-template-columns:1fr}
|
|
483
|
+
.foldbar{display:flex;align-items:center;justify-content:center;gap:6px;padding:4px 16px;
|
|
484
|
+
background:var(--paper-gutter);border-top:1px solid var(--paper-edge);border-bottom:1px solid var(--paper-edge)}
|
|
485
|
+
.fx{font-family:var(--mono);font-size:10.5px;color:var(--paper-dim);background:var(--paper);
|
|
486
|
+
border:1px solid var(--paper-edge);border-radius:6px;padding:2px 10px;cursor:pointer;line-height:1.5}
|
|
487
|
+
.fx:hover{color:var(--paper-ink);border-color:var(--paper-dim);background:#fff}
|
|
488
|
+
.nodiff{padding:34px 16px;color:var(--paper-dim);text-align:center;font-family:var(--body);font-size:13px}
|
|
489
|
+
/* impact / system-overview panel (dark, sits above the paper diff) */
|
|
490
|
+
.impact{margin:0 0 16px;background:var(--panel);border:1px solid var(--edge2);border-radius:12px;overflow:hidden}
|
|
491
|
+
.impact .ih{display:flex;align-items:center;gap:10px;padding:11px 15px;border-bottom:1px solid var(--edge)}
|
|
492
|
+
.impact .ih .tt{font-family:var(--disp);font-weight:600;font-size:13px}
|
|
493
|
+
.impact .ih .sp{flex:1}
|
|
494
|
+
.risk{font-family:var(--mono);font-size:10.5px;letter-spacing:.05em;text-transform:uppercase;padding:2px 9px;border-radius:999px}
|
|
495
|
+
.risk.low{color:var(--moss);background:var(--moss-bg)} .risk.medium{color:var(--amber);background:var(--amber-bg)}
|
|
496
|
+
.risk.high,.risk.breaking{color:#ff7a6b;background:rgba(248,81,73,.14)}
|
|
497
|
+
.impact .ib{padding:13px 15px;display:flex;flex-direction:column;gap:11px}
|
|
498
|
+
.impact .sum{font-size:13px;line-height:1.55;color:var(--ink)}
|
|
499
|
+
.impact .row{display:flex;gap:8px;flex-wrap:wrap;align-items:center;font-size:12px;color:var(--dim)}
|
|
500
|
+
.impact .row .k{font-family:var(--mono);font-size:10.5px;letter-spacing:.04em;text-transform:uppercase;color:var(--faint);min-width:96px}
|
|
501
|
+
.cat{font-family:var(--mono);font-size:11px;padding:1px 8px;border-radius:6px;border:1px solid var(--edge2);color:var(--dim)}
|
|
502
|
+
.aff{font-family:var(--mono);font-size:11.5px;color:var(--sky)}
|
|
503
|
+
.impact .note{font-size:12px;color:var(--faint);line-height:1.5;border-top:1px solid var(--edge);padding-top:11px}
|
|
504
|
+
.impact .llm{border-top:1px solid var(--edge);padding-top:11px}
|
|
505
|
+
.impact .llm .who{font-family:var(--mono);font-size:10.5px;text-transform:uppercase;letter-spacing:.05em;color:var(--faint);margin-bottom:6px}
|
|
506
|
+
.impact .llm .body{font-size:12.5px;line-height:1.6;color:var(--ink)}
|
|
507
|
+
.impact .llm .lk{font-size:12px;line-height:1.5;color:var(--dim);margin-top:7px}
|
|
508
|
+
.impact .llm .lkk{display:block;font-family:var(--mono);font-size:10px;letter-spacing:.04em;text-transform:uppercase;
|
|
509
|
+
color:var(--faint);margin-bottom:3px}
|
|
510
|
+
.impact .llm .llmul{margin:3px 0 0;padding-left:18px;display:flex;flex-direction:column;gap:3px}
|
|
511
|
+
.impact .llm .llmul li{font-size:12px;line-height:1.45;color:var(--dim)}
|
|
512
|
+
/* inline markdown the LLM emits: bold/italic inherit; code gets a subtle chip */
|
|
513
|
+
.impact .llm strong{color:var(--ink);font-weight:600}
|
|
514
|
+
.impact .llm code{font-family:var(--mono);font-size:.92em;background:var(--chip,rgba(127,127,127,.14));
|
|
515
|
+
padding:1px 5px;border-radius:4px}
|
|
516
|
+
.impact .off{font-size:12px;color:var(--faint)}
|
|
517
|
+
.doconly .paper-h{grid-template-columns:1fr}
|
|
518
|
+
.docbody{padding:14px 16px;white-space:pre-wrap;word-break:break-word;line-height:1.6;max-height:none}
|
|
519
|
+
|
|
520
|
+
.spin{padding:30px;color:var(--faint);font-size:13px;text-align:center}
|
|
521
|
+
.toast{position:fixed;bottom:20px;left:50%;transform:translateX(-50%) translateY(8px);background:var(--panel2);
|
|
522
|
+
border:1px solid var(--edge2);border-radius:10px;padding:10px 18px;font-size:13px;z-index:60;
|
|
523
|
+
box-shadow:var(--shadow);opacity:0;transition:all .18s;pointer-events:none;display:flex;align-items:center;gap:9px}
|
|
524
|
+
.toast.show{opacity:1;transform:translateX(-50%) translateY(0)}
|
|
525
|
+
.toast.err{border-color:var(--paper-del-ink)}
|
|
526
|
+
.toast .ok{color:var(--moss)} .toast .bad{color:var(--paper-del-ink)}
|
|
527
|
+
|
|
528
|
+
.mbg{position:fixed;inset:0;background:rgba(8,11,16,.6);backdrop-filter:blur(2px);display:none;
|
|
529
|
+
align-items:center;justify-content:center;z-index:50}
|
|
530
|
+
.mbg.show{display:flex}
|
|
531
|
+
.modal{background:var(--panel);border:1px solid var(--edge2);border-radius:14px;width:min(540px,92vw);
|
|
532
|
+
box-shadow:var(--shadow);overflow:hidden}
|
|
533
|
+
.modal h3{margin:0;padding:16px 18px;font-family:var(--disp);font-weight:600;font-size:15px;border-bottom:1px solid var(--edge)}
|
|
534
|
+
.modal .b{padding:16px 18px;display:flex;flex-direction:column;gap:12px}
|
|
535
|
+
.modal .desc{color:var(--dim);font-size:13px;line-height:1.55}
|
|
536
|
+
.modal .desc b{color:var(--ink);font-family:var(--mono);font-size:12.5px}
|
|
537
|
+
.modal label{font-size:11px;letter-spacing:.04em;text-transform:uppercase;color:var(--faint);font-weight:600}
|
|
538
|
+
.modal input{width:100%;background:var(--bg);border:1px solid var(--edge2);border-radius:8px;padding:9px 11px;font-family:var(--mono);font-size:12.5px}
|
|
539
|
+
.modal textarea{width:100%;min-height:280px;resize:vertical;background:var(--bg);border:1px solid var(--edge2);border-radius:8px;padding:9px 11px;font-family:var(--mono);font-size:12px;color:var(--ink)}
|
|
540
|
+
.modal input:focus,.modal textarea:focus{outline:none;border-color:var(--blue)}
|
|
541
|
+
.modal .f{display:flex;justify-content:flex-end;gap:9px;padding:14px 18px;border-top:1px solid var(--edge)}
|
|
542
|
+
|
|
543
|
+
@media (max-width:1080px){ .cols{grid-template-columns:240px 280px 1fr} }
|
|
544
|
+
@media (max-width:840px){ .cols{grid-template-columns:1fr;grid-auto-rows:minmax(220px,auto)} .pane{border-right:0;border-bottom:1px solid var(--edge)} }
|
|
545
|
+
@media (prefers-reduced-motion:reduce){ *{animation:none!important;transition:none!important} }
|
|
546
|
+
</style>
|
|
547
|
+
</head>
|
|
548
|
+
<body>
|
|
549
|
+
<div class="app">
|
|
550
|
+
<header class="top">
|
|
551
|
+
<div class="brand">cfg<span class="dot">·</span>it</div>
|
|
552
|
+
<div class="who" id="who"><span class="ava" id="ava">·</span><span id="whoTxt">connecting…</span></div>
|
|
553
|
+
<span class="chip open" id="mode"></span>
|
|
554
|
+
<div class="sp"></div>
|
|
555
|
+
<select class="envpick" id="env" title="environment"><option>dev</option></select>
|
|
556
|
+
<select class="envpick branchpick" id="branch" title="branch"><option>main</option></select>
|
|
557
|
+
<button class="ghost" id="newBranch" type="button">Branch</button>
|
|
558
|
+
<button class="ghost" id="draftCommit" type="button">Draft</button>
|
|
559
|
+
<button class="ghost" id="branchDiff" type="button">Diff</button>
|
|
560
|
+
<button class="ghost" id="openPr" type="button">PR</button>
|
|
561
|
+
<button class="ghost" id="mergePr" type="button">Merge</button>
|
|
562
|
+
<div class="seg" id="theme"><button data-th="dark" class="on">Dark</button><button data-th="light">Light</button></div>
|
|
563
|
+
<button class="ghost" id="refresh" type="button">Refresh</button>
|
|
564
|
+
<input id="configFile" style="display:none">
|
|
565
|
+
</header>
|
|
566
|
+
<div class="cols">
|
|
567
|
+
<!-- LEFT: collection tree -->
|
|
568
|
+
<section class="pane">
|
|
569
|
+
<div class="find"><input id="find" placeholder="find a record…" autocomplete="off" spellcheck="false"></div>
|
|
570
|
+
<div class="filterbar" id="filters"></div>
|
|
571
|
+
<div class="scroll" id="tree"><div class="spin">loading…</div></div>
|
|
572
|
+
</section>
|
|
573
|
+
<!-- MIDDLE: history graph -->
|
|
574
|
+
<section class="pane">
|
|
575
|
+
<div class="ph"><span class="lab">History</span><span class="sp"></span><span class="ct" id="histCt"></span></div>
|
|
576
|
+
<div class="scroll" id="hist"><div class="ghost-pane"><div class="big">No record selected</div>Pick a record on the left to walk its history.</div></div>
|
|
577
|
+
</section>
|
|
578
|
+
<!-- RIGHT: paper diff -->
|
|
579
|
+
<section class="pane">
|
|
580
|
+
<div class="dhead"><span class="t" id="dTitle">Diff</span><span class="sp"></span><span id="dActs"></span></div>
|
|
581
|
+
<div class="paperwrap" id="diff"><div class="ghost-pane">A version or a record's drift will render here, recorded against live.</div></div>
|
|
582
|
+
</section>
|
|
583
|
+
</div>
|
|
584
|
+
</div>
|
|
585
|
+
<div class="toast" id="toast"></div>
|
|
586
|
+
<div class="mbg" id="mbg"><div class="modal" id="modal"></div></div>
|
|
587
|
+
|
|
588
|
+
<script>
|
|
589
|
+
const S={records:[],branches:[],prs:[],filter:"all",q:"",sel:null,against:new Set(),hist:[],who:null,open:{}};
|
|
590
|
+
const $=id=>document.getElementById(id);
|
|
591
|
+
const esc=v=>String(v==null?"":v).replace(/[&<>"']/g,c=>({"&":"&","<":"<",">":">",'"':""","'":"'"}[c]));
|
|
592
|
+
// inline markdown for LLM narration: escape first (XSS-safe), then render the small
|
|
593
|
+
// subset the model actually emits — **bold**, *italic*, `code`. **bold** before
|
|
594
|
+
// *italic* so the bold markers aren't consumed by the italic rule.
|
|
595
|
+
const mdi=v=>esc(v)
|
|
596
|
+
.replace(/\*\*([^*]+)\*\*/g,"<strong>$1</strong>")
|
|
597
|
+
.replace(/(^|[^*])\*([^*\n]+)\*/g,"$1<em>$2</em>")
|
|
598
|
+
.replace(/`([^`]+)`/g,'<code>$1</code>');
|
|
599
|
+
const fmt=v=>typeof v==="object"?JSON.stringify(v):String(v);
|
|
600
|
+
const dcls=s=>s==="clean"?"clean":s==="new"?"new":"drift";
|
|
601
|
+
const isDrift=s=>s!=="clean"&&s!=="new";
|
|
602
|
+
|
|
603
|
+
function env(){return{env:$("env").value||"dev",branch:$("branch").value||"main",config_file:$("configFile").value||null};}
|
|
604
|
+
function qs(){const p=new URLSearchParams(),e=env();if(e.env)p.set("env",e.env);if(e.config_file)p.set("config_file",e.config_file);return p.toString();}
|
|
605
|
+
async function api(action,data){const r=await fetch("/api/action",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({action,...env(),...data})});return r.json();}
|
|
606
|
+
function toast(msg,bad){const t=$("toast");t.innerHTML=`<span class="${bad?"bad":"ok"}">${bad?"✕":"✓"}</span>${esc(msg)}`;t.className="toast show"+(bad?" err":"");clearTimeout(t._t);t._t=setTimeout(()=>t.className="toast",2400);}
|
|
607
|
+
function initials(s){s=String(s||"");const at=s.indexOf("@");const h=at>0?s.slice(0,at):s;return (h.replace(/[^a-zA-Z0-9]/g,"").slice(0,2)||"··").toLowerCase();}
|
|
608
|
+
|
|
609
|
+
/* theme */
|
|
610
|
+
function setTheme(t){document.documentElement.dataset.theme=t;try{localStorage.setItem("cfgit-theme",t)}catch(e){}
|
|
611
|
+
$("theme").querySelectorAll("button").forEach(b=>b.classList.toggle("on",b.dataset.th===t));}
|
|
612
|
+
$("theme").querySelectorAll("button").forEach(b=>b.onclick=()=>setTheme(b.dataset.th));
|
|
613
|
+
try{const sv=localStorage.getItem("cfgit-theme");if(sv)setTheme(sv);}catch(e){}
|
|
614
|
+
|
|
615
|
+
async function loadState(){
|
|
616
|
+
const st=await fetch("/api/state?"+qs()).then(r=>r.json()).catch(e=>({error:String(e)}));
|
|
617
|
+
if(st.data&&st.data.whoami){const w=st.data.whoami;S.who=w;const id=w.identity||{};
|
|
618
|
+
const disp=w.identity_display||w.author||"";
|
|
619
|
+
$("whoTxt").innerHTML=`<b>${esc(disp)}</b> · ${esc(w.env||"dev")}`;
|
|
620
|
+
$("ava").textContent=initials(w.author||disp);
|
|
621
|
+
$("mode").textContent=w.identity_mode||id.mode||"open";}
|
|
622
|
+
// populate env options from schema
|
|
623
|
+
const sc=await fetch("/api/schema?"+qs()).then(r=>r.json()).catch(()=>null);
|
|
624
|
+
if(sc&&sc.data&&Array.isArray(sc.data.envs)&&sc.data.envs.length){
|
|
625
|
+
const cur=$("env").value; $("env").innerHTML=sc.data.envs.map(e=>`<option ${e===cur?"selected":""}>${esc(e)}</option>`).join("");}
|
|
626
|
+
S.records=(st.data&&st.data.status)?st.data.status:[];
|
|
627
|
+
S.branches=(st.data&&st.data.branches)?st.data.branches:[];
|
|
628
|
+
S.prs=(st.data&&st.data.prs)?st.data.prs:[];
|
|
629
|
+
renderBranches();
|
|
630
|
+
// default: open every collection that has drift, else open first
|
|
631
|
+
const colls=[...new Set(S.records.map(r=>r.collection))];
|
|
632
|
+
if(Object.keys(S.open).length===0){colls.forEach(c=>{S.open[c]=S.records.some(r=>r.collection===c&&isDrift(r.state));});
|
|
633
|
+
if(!Object.values(S.open).some(Boolean)&&colls[0])S.open[colls[0]]=true;}
|
|
634
|
+
renderFilters();renderTree();
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function renderBranches(){
|
|
638
|
+
const sel=$("branch"); if(!sel)return;
|
|
639
|
+
const current=sel.value||"main";
|
|
640
|
+
const names=(S.branches.length?S.branches.map(b=>b.name):["main"]);
|
|
641
|
+
sel.innerHTML=names.map(n=>`<option ${n===current?"selected":""}>${esc(n)}</option>`).join("");
|
|
642
|
+
if(names.includes(current))sel.value=current; else sel.value=names[0]||"main";
|
|
643
|
+
const onMain=sel.value==="main";
|
|
644
|
+
$("draftCommit").disabled=onMain||!S.sel;
|
|
645
|
+
$("branchDiff").disabled=onMain;
|
|
646
|
+
$("openPr").disabled=onMain;
|
|
647
|
+
$("mergePr").disabled=onMain||!S.prs.some(p=>p.head_branch===sel.value&&p.status==="open");
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
function counts(){const c={all:S.records.length,drift:0,clean:0,new:0};
|
|
651
|
+
for(const r of S.records){if(r.state==="clean")c.clean++;else if(r.state==="new")c.new++;else c.drift++;}return c;}
|
|
652
|
+
function renderFilters(){const c=counts();
|
|
653
|
+
const d=[["all","All",c.all],["drift","Drift",c.drift],["clean","Clean",c.clean],["new","New",c.new]];
|
|
654
|
+
$("filters").innerHTML=d.map(([k,l,n])=>`<button class="fchip ${S.filter===k?"on":""}" data-f="${k}">${l}<span class="n">${n}</span></button>`).join("");
|
|
655
|
+
$("filters").querySelectorAll(".fchip").forEach(b=>b.onclick=()=>{S.filter=b.dataset.f;renderTree();});}
|
|
656
|
+
|
|
657
|
+
function visibleRecords(){let rs=S.records.slice();
|
|
658
|
+
if(S.filter==="drift")rs=rs.filter(r=>isDrift(r.state));
|
|
659
|
+
else if(S.filter==="clean")rs=rs.filter(r=>r.state==="clean");
|
|
660
|
+
else if(S.filter==="new")rs=rs.filter(r=>r.state==="new");
|
|
661
|
+
if(S.q){const q=S.q.toLowerCase();rs=rs.filter(r=>(r.collection+":"+r.record_id).toLowerCase().includes(q));}
|
|
662
|
+
return rs;}
|
|
663
|
+
|
|
664
|
+
const COLL_IC=`<svg class="coll-ic" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4"><ellipse cx="8" cy="3.7" rx="5.3" ry="2.1"/><path d="M2.7 3.7v8.6c0 1.16 2.37 2.1 5.3 2.1s5.3-.94 5.3-2.1V3.7"/><path d="M2.7 8c0 1.16 2.37 2.1 5.3 2.1s5.3-.94 5.3-2.1"/></svg>`;
|
|
665
|
+
function renderTree(){
|
|
666
|
+
const rs=visibleRecords();const el=$("tree");
|
|
667
|
+
if(!rs.length){el.innerHTML=`<div class="empty">No records match.</div>`;return;}
|
|
668
|
+
const byColl={};for(const r of rs){(byColl[r.collection]=byColl[r.collection]||[]).push(r);}
|
|
669
|
+
let html="";
|
|
670
|
+
for(const coll of Object.keys(byColl).sort()){
|
|
671
|
+
const recs=byColl[coll].sort((a,b)=>{const o={changed_outside_cfgit:0,new:1,clean:2};return (o[a.state]??0)-(o[b.state]??0)||a.record_id.localeCompare(b.record_id);});
|
|
672
|
+
const drifted=recs.filter(r=>isDrift(r.state)).length;
|
|
673
|
+
const open=S.open[coll]!==false&&!!S.open[coll]||S.open[coll]===true;
|
|
674
|
+
html+=`<div class="coll ${S.open[coll]?"open":""}" data-c="${esc(coll)}">
|
|
675
|
+
<div class="coll-h">
|
|
676
|
+
<span class="tw">▶</span>${COLL_IC}
|
|
677
|
+
<span class="coll-nm">${esc(coll)}</span>
|
|
678
|
+
${drifted?`<span class="coll-warn" title="${drifted} drifted"></span>`:""}
|
|
679
|
+
<span class="coll-ct">${recs.length}</span>
|
|
680
|
+
</div>
|
|
681
|
+
<div class="docs">${recs.map(r=>{
|
|
682
|
+
const key=r.collection+":"+r.record_id;const sel=S.sel===key?"sel":"";
|
|
683
|
+
const ctx=S.against.has(key)?"ctx":"";
|
|
684
|
+
const right=r.state==="clean"?`<span class="rt">@${r.head_seq??""}</span>`
|
|
685
|
+
:r.state==="new"?`<span class="tag new">new</span>`:`<span class="tag drift">drift</span>`;
|
|
686
|
+
const slash=r.record_id.lastIndexOf("/");
|
|
687
|
+
const nmHtml=slash>=0
|
|
688
|
+
? `<span class="pre">${esc(r.record_id.slice(0,slash+1))}</span><span class="leaf">${esc(r.record_id.slice(slash+1))}</span>`
|
|
689
|
+
: `<span class="leaf">${esc(r.record_id)}</span>`;
|
|
690
|
+
return `<div class="doc ${sel} ${ctx}" data-k="${esc(key)}" title="${esc(r.record_id)}">
|
|
691
|
+
<span class="ckx">${S.against.has(key)?"✓":"+"}</span>
|
|
692
|
+
<span class="st ${dcls(r.state)}"></span>
|
|
693
|
+
<span class="nm">${nmHtml}</span>${right}</div>`;}).join("")}</div></div>`;
|
|
694
|
+
}
|
|
695
|
+
el.innerHTML=html;
|
|
696
|
+
el.querySelectorAll(".coll-h").forEach(h=>h.onclick=()=>{const c=h.parentElement.dataset.c;S.open[c]=!S.open[c];renderTree();});
|
|
697
|
+
el.querySelectorAll(".doc").forEach(d=>d.onclick=e=>{
|
|
698
|
+
e.stopPropagation();
|
|
699
|
+
// the ✓/+ marker, or cmd/ctrl-click, toggles the record into the impact context.
|
|
700
|
+
// a plain click selects it as the primary (drives diff/history).
|
|
701
|
+
const onMarker=e.target.classList&&e.target.classList.contains("ckx");
|
|
702
|
+
if(onMarker||e.metaKey||e.ctrlKey){toggleContext(d.dataset.k);}
|
|
703
|
+
else{selectRecord(d.dataset.k);}
|
|
704
|
+
});
|
|
705
|
+
}
|
|
706
|
+
function toggleContext(key){
|
|
707
|
+
if(S.against.has(key))S.against.delete(key); else S.against.add(key);
|
|
708
|
+
// the primary record is implicitly its own diff subject; don't also keep it in `against`
|
|
709
|
+
S.against.delete(S.sel);
|
|
710
|
+
renderTree();
|
|
711
|
+
refreshImpactBtn();
|
|
712
|
+
}
|
|
713
|
+
function refreshImpactBtn(){
|
|
714
|
+
const b=$("aImpact"); if(!b)return;
|
|
715
|
+
const n=[...S.against].filter(k=>k!==S.sel).length;
|
|
716
|
+
b.textContent=n?`Impact (${n})`:"Impact";
|
|
717
|
+
b.title=n?`Reason this change against ${n} selected record(s)`:"Reason this change against the whole system";
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
async function selectRecord(key){
|
|
721
|
+
S.sel=key;renderBranches();renderTree();
|
|
722
|
+
const rec=S.records.find(r=>r.collection+":"+r.record_id===key);
|
|
723
|
+
$("hist").innerHTML=`<div class="spin">loading history…</div>`;
|
|
724
|
+
$("diff").innerHTML=`<div class="spin">…</div>`;$("dActs").innerHTML="";$("dTitle").textContent="Diff";
|
|
725
|
+
const res=await api("log",{record:key,limit:60});
|
|
726
|
+
S.hist=(res&&Array.isArray(res.data))?res.data:(res&&res.data&&Array.isArray(res.data.entries))?res.data.entries:[];
|
|
727
|
+
renderHistory(rec);
|
|
728
|
+
if(rec&&isDrift(rec.state))showDrift(rec);
|
|
729
|
+
else if(S.hist.length)selectNode(S.hist[0]);
|
|
730
|
+
else $("diff").innerHTML=`<div class="ghost-pane">No versions yet. This record is <b>${esc(rec?rec.state:"")}</b>.</div>`;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
function opClass(op){return op==="restore"?"restore":op==="adopt"?"adopt":op==="import"?"importt":"commit";}
|
|
734
|
+
function renderHistory(rec){
|
|
735
|
+
const drift=rec&&isDrift(rec.state);
|
|
736
|
+
$("histCt").textContent=S.hist.length?`${S.hist.length} version${S.hist.length>1?"s":""}`:"";
|
|
737
|
+
let h=`<div class="selhdr"><div class="nm">${esc(rec?rec.record_id:S.sel)}</div>
|
|
738
|
+
<div class="meta"><span class="mono" style="color:var(--faint)">${esc(rec?rec.collection:"")}</span>
|
|
739
|
+
${rec?`<span class="tag ${rec.state==="clean"?"":dcls(rec.state)}" style="${rec.state==="clean"?"color:var(--moss);background:var(--moss-bg)":""}">${rec.state==="clean"?"clean":rec.state==="new"?"new":"drift"}</span>`:""}</div></div>
|
|
740
|
+
<div class="rail">`;
|
|
741
|
+
if(drift){h+=`<div class="node live" data-live="1"><div class="line"></div><div class="mk"></div>
|
|
742
|
+
<div class="msg">Live now — edited outside cfgit</div>
|
|
743
|
+
<div class="sub"><span class="op">live</span><span>uncommitted change in the database</span></div></div>`;}
|
|
744
|
+
for(const e of S.hist){const sh=(e.oid||"").slice(0,7);const when=(e.recorded_at||"").replace("T"," ").slice(0,16);const op=e.op||"commit";
|
|
745
|
+
h+=`<div class="node ${opClass(op)}" data-seq="${e.seq}"><div class="line"></div><div class="mk"></div>
|
|
746
|
+
<div class="msg">${esc(e.message||"(no message)")}</div>
|
|
747
|
+
<div class="sub"><span class="op r-${op}">${esc(op)}</span><span>@${e.seq}</span><span>${esc(sh)}</span><span>${esc(e.author||"")}</span>${when?`<span>${esc(when)}</span>`:""}</div></div>`;}
|
|
748
|
+
h+=`</div>`;
|
|
749
|
+
$("hist").innerHTML=h;
|
|
750
|
+
$("hist").querySelectorAll(".node").forEach(n=>{
|
|
751
|
+
if(n.dataset.live)n.onclick=()=>showDrift(rec);
|
|
752
|
+
else{const sq=+n.dataset.seq;n.onclick=()=>selectNode(S.hist.find(x=>x.seq===sq));}});
|
|
753
|
+
}
|
|
754
|
+
function markNode(seq,live){$("hist").querySelectorAll(".node").forEach(n=>{
|
|
755
|
+
n.classList.toggle("sel",live?!!n.dataset.live:(+n.dataset.seq===seq));});}
|
|
756
|
+
|
|
757
|
+
async function showDrift(rec){
|
|
758
|
+
markNode(null,true);
|
|
759
|
+
S.diffCtx={a:"=HEAD",b:"=live",left:"recorded",right:"live",empty:"No structural difference (it may be in ignored or secret fields)."};
|
|
760
|
+
$("dTitle").innerHTML=`Drift · recorded <b>@${rec.head_seq??""}</b> → live`;
|
|
761
|
+
$("dActs").innerHTML=`<button class="btn" id="aImpact">Impact</button> <button class="btn go" id="aAdopt">Adopt</button> <button class="btn warn" id="aRestore">Restore @${rec.head_seq??""}</button>`;
|
|
762
|
+
$("diff").innerHTML=`<div class="spin">computing diff…</div>`;
|
|
763
|
+
const res=await api("diff",{record:S.sel,a:"=HEAD",b:"=live"});
|
|
764
|
+
renderDiff(res,"recorded","live",S.diffCtx.empty);
|
|
765
|
+
$("aImpact").onclick=()=>showImpact();
|
|
766
|
+
$("aAdopt").onclick=()=>openAdopt(rec);
|
|
767
|
+
$("aRestore").onclick=()=>openRestore(rec,"@"+(rec.head_seq??""));
|
|
768
|
+
refreshImpactBtn();
|
|
769
|
+
}
|
|
770
|
+
async function showImpact(){
|
|
771
|
+
if(!S.diffCtx)return;
|
|
772
|
+
const against=[...S.against].filter(k=>k!==S.sel);
|
|
773
|
+
const btn=$("aImpact"); if(btn){btn.disabled=true;btn.textContent="Analyzing…";}
|
|
774
|
+
const payload={record:S.sel,a:S.diffCtx.a,b:S.diffCtx.b,use_llm:true};
|
|
775
|
+
if(against.length)payload.against=against;
|
|
776
|
+
const r=await api("impact",payload);
|
|
777
|
+
if(btn){btn.disabled=false;}
|
|
778
|
+
refreshImpactBtn();
|
|
779
|
+
const d=r&&r.data?r.data:{};
|
|
780
|
+
const risk=(d.risk_level||"medium").toLowerCase();
|
|
781
|
+
const cats=(d.categories||[]).map(c=>`<span class="cat">${esc(c)}</span>`).join(" ")||`<span class="off">none</span>`;
|
|
782
|
+
const aff=(d.affected_records||[]);
|
|
783
|
+
const affHtml=aff.length?aff.map(a=>`<span class="aff">${esc(typeof a==="string"?a:(a.record_id||JSON.stringify(a)))}</span>`).join(", ")
|
|
784
|
+
:`<span class="off">none found by static scan</span>`;
|
|
785
|
+
const llm=d.llm||{};
|
|
786
|
+
let llmHtml;
|
|
787
|
+
if(llm.enabled){
|
|
788
|
+
const ov=llm.overview||{};
|
|
789
|
+
const parts=[];
|
|
790
|
+
const asList=v=>Array.isArray(v)?`<ul class="llmul">${v.map(x=>`<li>${typeof x==="object"?esc(JSON.stringify(x)):mdi(x)}</li>`).join("")}</ul>`:mdi(v);
|
|
791
|
+
if(ov.summary)parts.push(`<div class="body">${mdi(ov.summary)}</div>`);
|
|
792
|
+
if(ov.behavior_change)parts.push(`<div class="lk"><span class="lkk">behavior</span>${asList(ov.behavior_change)}</div>`);
|
|
793
|
+
if(ov.blast_radius)parts.push(`<div class="lk"><span class="lkk">blast radius</span>${asList(ov.blast_radius)}</div>`);
|
|
794
|
+
if(ov.unknowns&&(Array.isArray(ov.unknowns)?ov.unknowns.length:true))parts.push(`<div class="lk"><span class="lkk">unknowns</span>${asList(ov.unknowns)}</div>`);
|
|
795
|
+
const fallback=(!parts.length&&(llm.text||llm.narration))?`<div class="body">${esc(llm.text||llm.narration)}</div>`:"";
|
|
796
|
+
llmHtml=`<div class="llm"><div class="who">${esc(llm.provider||"llm")} · ${esc(llm.model||"")} narration</div>${parts.join("")||fallback||`<div class="off">no narration returned</div>`}</div>`;
|
|
797
|
+
} else {
|
|
798
|
+
llmHtml=`<div class="llm"><div class="who">LLM narration</div><div class="off">off — enable <span class="mono">[connections]</span> in .cfg.toml for a written explanation of what this change does to the system. Everything above is computed locally, no data leaves your machine.</div></div>`;
|
|
799
|
+
}
|
|
800
|
+
const scopedAgainst=[...S.against].filter(k=>k!==S.sel);
|
|
801
|
+
const scopeRow=scopedAgainst.length
|
|
802
|
+
? `<div class="row"><span class="k">reasoned vs</span>${scopedAgainst.map(k=>`<span class="cat">${esc(k.split(":").pop())}</span>`).join(" ")}</div>`
|
|
803
|
+
: `<div class="row"><span class="k">reasoned vs</span><span class="off">whole system (select records on the left to scope)</span></div>`;
|
|
804
|
+
const panel=`<div class="impact">
|
|
805
|
+
<div class="ih"><span class="tt">System impact</span><span class="sp"></span><span class="risk ${risk}">${esc(risk)} risk</span></div>
|
|
806
|
+
<div class="ib">
|
|
807
|
+
<div class="sum">${esc(d.summary||"")}</div>
|
|
808
|
+
${scopeRow}
|
|
809
|
+
<div class="row"><span class="k">changed</span>${(d.changed_paths||[]).map(p=>`<span class="cat">${esc(p)}</span>`).join(" ")||`<span class="off">—</span>`}</div>
|
|
810
|
+
<div class="row"><span class="k">categories</span>${cats}</div>
|
|
811
|
+
<div class="row"><span class="k">affects</span>${affHtml}</div>
|
|
812
|
+
${(d.declared_links_changed&&d.declared_links_changed.length)?`<div class="row"><span class="k">links changed</span>${d.declared_links_changed.map(l=>`<span class="cat">${esc(typeof l==="string"?l:JSON.stringify(l))}</span>`).join(" ")}</div>`:""}
|
|
813
|
+
${d.rollback_note?`<div class="note">↩ ${esc(d.rollback_note)}</div>`:""}
|
|
814
|
+
${llmHtml}
|
|
815
|
+
</div></div>`;
|
|
816
|
+
// prepend the panel above the existing paper diff
|
|
817
|
+
const wrap=$("diff");
|
|
818
|
+
const existing=wrap.querySelector(".impact"); if(existing)existing.remove();
|
|
819
|
+
wrap.insertAdjacentHTML("afterbegin",panel);
|
|
820
|
+
}
|
|
821
|
+
async function selectNode(e){
|
|
822
|
+
if(!e)return;markNode(e.seq,false);
|
|
823
|
+
const isHead=S.hist.length&&e.seq===S.hist[0].seq;
|
|
824
|
+
const idx=S.hist.findIndex(x=>x.seq===e.seq);
|
|
825
|
+
const parent=idx>=0&&idx<S.hist.length-1?S.hist[idx+1]:null;
|
|
826
|
+
$("dTitle").innerHTML=`Version <b>@${e.seq}</b> · ${esc((e.oid||"").slice(0,7))}`;
|
|
827
|
+
const acts=[];
|
|
828
|
+
if(parent)acts.push(`<button class="btn" id="aImpact">Impact</button>`);
|
|
829
|
+
if(!isHead)acts.push(`<button class="btn warn" id="aRestore">Restore this version</button>`);
|
|
830
|
+
$("dActs").innerHTML=acts.join(" ");
|
|
831
|
+
$("diff").innerHTML=`<div class="spin">loading…</div>`;
|
|
832
|
+
if(parent){S.diffCtx={a:"@"+parent.seq,b:"@"+e.seq,left:"@"+parent.seq,right:"@"+e.seq};
|
|
833
|
+
const res=await api("diff",{record:S.sel,a:"@"+parent.seq,b:"@"+e.seq});
|
|
834
|
+
renderDiff(res,"@"+parent.seq,"@"+e.seq,"No field-level change from the parent version.");}
|
|
835
|
+
else{S.diffCtx=null;const res=await api("show",{record:S.sel,ref:"@"+e.seq});renderDoc(res);}
|
|
836
|
+
const ib=$("aImpact");if(ib)ib.onclick=()=>showImpact();
|
|
837
|
+
const rb=$("aRestore");if(rb)rb.onclick=()=>openRestore(S.records.find(r=>r.collection+":"+r.record_id===S.sel),"@"+e.seq);
|
|
838
|
+
refreshImpactBtn();
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
function changesFrom(res){if(!res||!res.data)return null;const d=res.data;
|
|
842
|
+
if(Array.isArray(d.changes))return d.changes;if(Array.isArray(d))return d;return null;}
|
|
843
|
+
// LCS line diff -> ops array of {t:'ctx'|'del'|'add', l:leftLine|null, r:rightLine|null}
|
|
844
|
+
function lineDiff(aStr,bStr){
|
|
845
|
+
const a=String(aStr).split("\n"), b=String(bStr).split("\n");
|
|
846
|
+
const n=a.length,m=b.length;
|
|
847
|
+
// LCS table (lengths capped for safety; these strings are config text, fine)
|
|
848
|
+
const dp=Array.from({length:n+1},()=>new Uint32Array(m+1));
|
|
849
|
+
for(let i=n-1;i>=0;i--)for(let j=m-1;j>=0;j--)
|
|
850
|
+
dp[i][j]=a[i]===b[j]?dp[i+1][j+1]+1:Math.max(dp[i+1][j],dp[i][j+1]);
|
|
851
|
+
const ops=[];let i=0,j=0;
|
|
852
|
+
while(i<n&&j<m){
|
|
853
|
+
if(a[i]===b[j]){ops.push({t:"ctx",l:a[i],r:b[j]});i++;j++;}
|
|
854
|
+
else if(dp[i+1][j]>=dp[i][j+1]){ops.push({t:"del",l:a[i],r:null});i++;}
|
|
855
|
+
else{ops.push({t:"add",l:null,r:b[j]});j++;}
|
|
856
|
+
}
|
|
857
|
+
while(i<n){ops.push({t:"del",l:a[i],r:null});i++;}
|
|
858
|
+
while(j<m){ops.push({t:"add",l:null,r:b[j]});j++;}
|
|
859
|
+
return ops;
|
|
860
|
+
}
|
|
861
|
+
// number each op with its left/right line number, then collapse long unchanged runs
|
|
862
|
+
// into a fold that REMEMBERS its hidden ops so the user can expand them (git-style).
|
|
863
|
+
const FOLD_PAD=3, FOLD_STEP=10; // context kept around changes; lines revealed per expand click
|
|
864
|
+
function numberOps(ops){
|
|
865
|
+
let ln=0,rn=0;
|
|
866
|
+
for(const o of ops){
|
|
867
|
+
if(o.t==="ctx"){o.ln=++ln;o.rn=++rn;}
|
|
868
|
+
else if(o.t==="del"){o.ln=++ln;o.rn=null;}
|
|
869
|
+
else{o.ln=null;o.rn=++rn;}
|
|
870
|
+
}
|
|
871
|
+
return ops;
|
|
872
|
+
}
|
|
873
|
+
function foldContext(ops){
|
|
874
|
+
const out=[];const keep=new Array(ops.length).fill(false);
|
|
875
|
+
for(let k=0;k<ops.length;k++) if(ops[k].t!=="ctx") for(let d=-FOLD_PAD;d<=FOLD_PAD;d++){const idx=k+d;if(idx>=0&&idx<ops.length)keep[idx]=true;}
|
|
876
|
+
let i=0;
|
|
877
|
+
while(i<ops.length){
|
|
878
|
+
if(keep[i]){out.push(ops[i]);i++;}
|
|
879
|
+
else{let j=i;while(j<ops.length&&!keep[j])j++;out.push({t:"fold",hidden:ops.slice(i,j)});i=j;}
|
|
880
|
+
}
|
|
881
|
+
return out;
|
|
882
|
+
}
|
|
883
|
+
function lineRowHtml(o){
|
|
884
|
+
if(o.t==="ctx")return `<div class="drow"><div class="dcell l ctx"><span class="gut">${o.ln}</span><span class="sign"> </span><span class="tx">${esc(o.l)}</span></div><div class="dcell r ctx"><span class="gut">${o.rn}</span><span class="sign"> </span><span class="tx">${esc(o.r)}</span></div></div>`;
|
|
885
|
+
if(o.t==="del")return `<div class="drow"><div class="dcell l del"><span class="gut">${o.ln}</span><span class="sign">−</span><span class="tx">${esc(o.l)}</span></div><div class="dcell r void"></div></div>`;
|
|
886
|
+
return `<div class="drow"><div class="dcell l void"></div><div class="dcell r add"><span class="gut">${o.rn}</span><span class="sign">+</span><span class="tx">${esc(o.r)}</span></div></div>`;
|
|
887
|
+
}
|
|
888
|
+
let _foldSeq=0;
|
|
889
|
+
const _folds={}; // id -> hidden ops, for expand-in-place
|
|
890
|
+
// the expand controls for one fold; `bare` returns just the buttons (for the
|
|
891
|
+
// merged sticky header), otherwise they're wrapped in their own scrolling row.
|
|
892
|
+
function foldControls(hidden){
|
|
893
|
+
const id="fold"+(++_foldSeq);_folds[id]=hidden;const n=hidden.length;
|
|
894
|
+
const up=`<button class="fx" data-fold="${id}" data-dir="up" title="expand above">↑</button>`;
|
|
895
|
+
const dn=`<button class="fx" data-fold="${id}" data-dir="down" title="expand below">↓</button>`;
|
|
896
|
+
const all=`<button class="fx" data-fold="${id}" data-dir="all">expand ${n} unchanged</button>`;
|
|
897
|
+
return {id, html:`${n>FOLD_STEP*2?up:""}${all}${n>FOLD_STEP*2?dn:""}`};
|
|
898
|
+
}
|
|
899
|
+
function foldRowHtml(hidden){
|
|
900
|
+
const c=foldControls(hidden);
|
|
901
|
+
return `<div class="drow foldrow" data-foldid="${c.id}"><div class="foldbar">${c.html}</div></div>`;
|
|
902
|
+
}
|
|
903
|
+
// Returns {lead, body}. If the diff opens on unchanged lines, `lead` holds that
|
|
904
|
+
// first fold's expand controls so the caller can fuse them into the sticky field
|
|
905
|
+
// header (one bar, and the control stays pinned). `body` is the row grid.
|
|
906
|
+
function splitDiffHtml(before,after){
|
|
907
|
+
const ops=foldContext(numberOps(lineDiff(before,after)));
|
|
908
|
+
let lead="";let start=0;
|
|
909
|
+
if(ops.length&&ops[0].t==="fold"){const c=foldControls(ops[0].hidden);lead=`<span class="leadfold" data-foldid="${c.id}">${c.html}</span>`;start=1;}
|
|
910
|
+
const rows=ops.slice(start).map(o=>o.t==="fold"?foldRowHtml(o.hidden):lineRowHtml(o)).join("");
|
|
911
|
+
return {lead, body:`<div class="splitgrid">${rows}</div>`};
|
|
912
|
+
}
|
|
913
|
+
// expand a fold: replace it (or reveal a chunk and keep a smaller fold)
|
|
914
|
+
function expandFold(id,dir){
|
|
915
|
+
const hidden=_folds[id];if(!hidden)return;
|
|
916
|
+
let reveal,rest;
|
|
917
|
+
if(dir==="all"||hidden.length<=FOLD_STEP){reveal=hidden;rest=null;}
|
|
918
|
+
else if(dir==="up"){reveal=hidden.slice(0,FOLD_STEP);rest=hidden.slice(FOLD_STEP);}
|
|
919
|
+
else{reveal=hidden.slice(hidden.length-FOLD_STEP);rest=hidden.slice(0,hidden.length-FOLD_STEP);}
|
|
920
|
+
// leading fold lives in the sticky header: reveal lines at the TOP of the body,
|
|
921
|
+
// and keep any remainder as a fresh leading control in the header (still pinned).
|
|
922
|
+
const lead=document.querySelector(`.leadfold[data-foldid="${id}"]`);
|
|
923
|
+
if(lead){
|
|
924
|
+
const grid=lead.closest(".frow").querySelector(".splitgrid");
|
|
925
|
+
if(grid)grid.insertAdjacentHTML("afterbegin",reveal.map(lineRowHtml).join(""));
|
|
926
|
+
if(rest&&rest.length){const c=foldControls(rest);lead.dataset.foldid=c.id;lead.innerHTML=c.html;}
|
|
927
|
+
else{const wrap=lead.closest(".fhx");if(wrap)wrap.remove();else lead.remove();}
|
|
928
|
+
bindFolds();return;
|
|
929
|
+
}
|
|
930
|
+
const row=document.querySelector(`.foldrow[data-foldid="${id}"]`);
|
|
931
|
+
if(!row)return;
|
|
932
|
+
let html=reveal.map(lineRowHtml).join("");
|
|
933
|
+
// a fold expanded from one side keeps the remaining hidden lines as a new fold on the other side
|
|
934
|
+
if(rest&&rest.length){const restHtml=foldRowHtml(rest);
|
|
935
|
+
html=dir==="up"?html+restHtml:restHtml+html;}
|
|
936
|
+
row.outerHTML=html;
|
|
937
|
+
bindFolds();
|
|
938
|
+
}
|
|
939
|
+
function bindFolds(){document.querySelectorAll(".fx").forEach(b=>b.onclick=e=>{e.stopPropagation();expandFold(b.dataset.fold,b.dataset.dir);});}
|
|
940
|
+
function isLongText(v){return typeof v==="string"&&(v.length>120||v.indexOf("\n")>=0);}
|
|
941
|
+
function renderDiff(res,leftLabel,rightLabel,emptyMsg){
|
|
942
|
+
const ch=changesFrom(res);
|
|
943
|
+
if(!ch){const txt=res&&res.data&&res.data.text?res.data.text:JSON.stringify(res,null,2);
|
|
944
|
+
$("diff").innerHTML=`<div class="paper doconly"><div class="docbody">${esc(txt)}</div></div>`;return;}
|
|
945
|
+
if(!ch.length){$("diff").innerHTML=`<div class="paper"><div class="nodiff">${esc(emptyMsg||"No changes.")}</div></div>`;return;}
|
|
946
|
+
let rows="";
|
|
947
|
+
for(const c of ch){const f=c.path||c.field||c.key||"";
|
|
948
|
+
let before=("before"in c)?c.before:c.old, after=("after"in c)?c.after:c.new;
|
|
949
|
+
const op=c.op||((before==null)?"add":(after==null)?"remove":"change");
|
|
950
|
+
// long multi-line text on both sides -> git-style line-aligned split.
|
|
951
|
+
// the leading fold's expand control rides inside the sticky field-name bar,
|
|
952
|
+
// so the header and the "expand N unchanged" control are one pinned bar.
|
|
953
|
+
if(op==="change"&&(isLongText(before)||isLongText(after))){
|
|
954
|
+
const sp=splitDiffHtml(before,after);
|
|
955
|
+
const lead=sp.lead?`<span class="fhx">${sp.lead}</span>`:"";
|
|
956
|
+
rows+=`<div class="frow"><div class="fname"><span class="fnm">${esc(f)}</span>${lead}</div>${sp.body}</div>`;
|
|
957
|
+
continue;
|
|
958
|
+
}
|
|
959
|
+
const lcls=op==="add"?"void":"del", rcls=op==="remove"?"void":"add";
|
|
960
|
+
const lval=op==="add"?"":esc(fmt(before)), rval=op==="remove"?"":esc(fmt(after));
|
|
961
|
+
rows+=`<div class="frow"><div class="fname">${esc(f)}</div><div class="fpair">
|
|
962
|
+
<div class="fside ${lcls}">${lval}</div><div class="fside r ${rcls}">${rval}</div></div></div>`;}
|
|
963
|
+
$("diff").innerHTML=`<div class="paper">
|
|
964
|
+
<div class="paper-h"><div class="l"><span class="swatch"></span>${esc(leftLabel)}</div><div class="r"><span class="swatch"></span>${esc(rightLabel)}</div></div>
|
|
965
|
+
${rows}</div>`;
|
|
966
|
+
bindFolds();
|
|
967
|
+
}
|
|
968
|
+
function renderDoc(res){const d=res&&res.data?res.data:res;const doc=d&&d.doc?d.doc:(d&&d.text?d.text:d);
|
|
969
|
+
const txt=(typeof doc==="string")?doc:JSON.stringify(doc,null,2);
|
|
970
|
+
$("diff").innerHTML=`<div class="paper doconly"><div class="paper-h"><div>document</div></div><div class="docbody">${esc(txt)}</div></div>`;}
|
|
971
|
+
|
|
972
|
+
/* modals */
|
|
973
|
+
function modal(html){$("modal").innerHTML=html;$("mbg").classList.add("show");}
|
|
974
|
+
function closeModal(){$("mbg").classList.remove("show");}
|
|
975
|
+
$("mbg").addEventListener("click",e=>{if(e.target===$("mbg"))closeModal();});
|
|
976
|
+
function openAdopt(rec){modal(`<h3>Adopt out-of-band change</h3><div class="b">
|
|
977
|
+
<div class="desc">Fold the current live value of <b>${esc(rec.record_id)}</b> into history as a new version, with attribution. The drift becomes a recorded commit.</div>
|
|
978
|
+
<div><label>Reason</label><input id="mMsg" value="adopt out-of-band edit" autocomplete="off"></div></div>
|
|
979
|
+
<div class="f"><button class="btn" onclick="closeModal()">Cancel</button><button class="btn go" id="mGo">Adopt</button></div>`);
|
|
980
|
+
$("mGo").onclick=async()=>{$("mGo").disabled=true;const r=await api("adopt",{record:S.sel,message:$("mMsg").value||"adopt"});after(r,"Adopted");};}
|
|
981
|
+
function openRestore(rec,ref){modal(`<h3>Restore ${esc(ref)}</h3><div class="b">
|
|
982
|
+
<div class="desc">Re-apply the <b>${esc(ref)}</b> version of <b>${esc(rec?rec.record_id:S.sel)}</b> as a new version on top. Nothing is lost — restore is non-destructive.</div>
|
|
983
|
+
<div><label>Reason</label><input id="mMsg" value="restore ${esc(ref)}" autocomplete="off"></div></div>
|
|
984
|
+
<div class="f"><button class="btn" onclick="closeModal()">Cancel</button><button class="btn warn" id="mGo">Restore</button></div>`);
|
|
985
|
+
$("mGo").onclick=async()=>{$("mGo").disabled=true;const r=await api("restore",{record:S.sel,ref:ref,message:$("mMsg").value||("restore "+ref)});after(r,"Restored");};}
|
|
986
|
+
function selectedBranch(){return $("branch").value||"main";}
|
|
987
|
+
function openCreateBranch(){modal(`<h3>Create branch</h3><div class="b">
|
|
988
|
+
<div class="desc">Create a draft branch. This writes only cfgit branch metadata and does not mutate runtime.</div>
|
|
989
|
+
<div><label>Name</label><input id="mName" value="draft-${Date.now().toString(36)}" autocomplete="off"></div>
|
|
990
|
+
<div><label>Message</label><input id="mMsg" value="create draft branch" autocomplete="off"></div></div>
|
|
991
|
+
<div class="f"><button class="btn" onclick="closeModal()">Cancel</button><button class="btn go" id="mGo">Create</button></div>`);
|
|
992
|
+
$("mGo").onclick=async()=>{$("mGo").disabled=true;const r=await api("branch_create",{name:$("mName").value,from_branch:"main",message:$("mMsg").value});afterBranch(r,"Branch created");};}
|
|
993
|
+
async function openDraftCommit(){
|
|
994
|
+
const br=selectedBranch();
|
|
995
|
+
if(br==="main"){toast("select a non-main branch",true);return;}
|
|
996
|
+
if(!S.sel){toast("select a record first",true);return;}
|
|
997
|
+
const live=await api("show",{record:S.sel,ref:"live"});
|
|
998
|
+
const doc=live&&live.data&&live.data.doc?live.data.doc:{};
|
|
999
|
+
modal(`<h3>Draft commit to ${esc(br)}</h3><div class="b">
|
|
1000
|
+
<div class="desc">Edit the JSON for <b>${esc(S.sel)}</b>. This stores a branch commit only; runtime changes only after PR merge.</div>
|
|
1001
|
+
<div><label>Message</label><input id="mMsg" value="draft ${esc(S.sel)}" autocomplete="off"></div>
|
|
1002
|
+
<div><label>Document JSON</label><textarea id="mDoc" spellcheck="false">${esc(JSON.stringify(doc,null,2))}</textarea></div></div>
|
|
1003
|
+
<div class="f"><button class="btn" onclick="closeModal()">Cancel</button><button class="btn go" id="mGo">Commit Draft</button></div>`);
|
|
1004
|
+
$("mGo").onclick=async()=>{
|
|
1005
|
+
$("mGo").disabled=true;
|
|
1006
|
+
let doc;
|
|
1007
|
+
try{doc=JSON.parse($("mDoc").value);}catch(e){toast("invalid JSON: "+e.message,true);$("mGo").disabled=false;return;}
|
|
1008
|
+
const r=await api("commit",{record:S.sel,doc,branch:br,message:$("mMsg").value||"draft"});
|
|
1009
|
+
afterBranch(r,"Draft committed");
|
|
1010
|
+
};
|
|
1011
|
+
}
|
|
1012
|
+
async function showBranchDiff(){
|
|
1013
|
+
const br=selectedBranch();
|
|
1014
|
+
if(br==="main"){toast("select a non-main branch",true);return;}
|
|
1015
|
+
const r=await api("branch_diff",{range:"main.."+br});
|
|
1016
|
+
const rows=(r.data&&r.data.records)||[];
|
|
1017
|
+
$("dTitle").innerHTML=`Branch diff · <b>main..${esc(br)}</b>`;
|
|
1018
|
+
$("dActs").innerHTML="";
|
|
1019
|
+
if(!rows.length){$("diff").innerHTML=`<div class="paper"><div class="nodiff">No draft changes on this branch.</div></div>`;return;}
|
|
1020
|
+
$("diff").innerHTML=`<div class="paper doconly"><div class="paper-h"><div>branch draft changes</div></div><div class="docbody">${esc(JSON.stringify(rows,null,2))}</div></div>`;
|
|
1021
|
+
}
|
|
1022
|
+
function openPrModal(){
|
|
1023
|
+
const br=selectedBranch();
|
|
1024
|
+
if(br==="main"){toast("select a non-main branch",true);return;}
|
|
1025
|
+
modal(`<h3>Open PR</h3><div class="b">
|
|
1026
|
+
<div class="desc">Open a review object for <b>${esc(br)}</b>. Runtime is unchanged until merge.</div>
|
|
1027
|
+
<div><label>Message</label><input id="mMsg" value="merge ${esc(br)}" autocomplete="off"></div></div>
|
|
1028
|
+
<div class="f"><button class="btn" onclick="closeModal()">Cancel</button><button class="btn go" id="mGo">Open PR</button></div>`);
|
|
1029
|
+
$("mGo").onclick=async()=>{$("mGo").disabled=true;const r=await api("pr_create",{base:"main",head:br,message:$("mMsg").value||("merge "+br)});afterBranch(r,"PR opened");};
|
|
1030
|
+
}
|
|
1031
|
+
function openMergeModal(){
|
|
1032
|
+
const br=selectedBranch();
|
|
1033
|
+
const prs=S.prs.filter(p=>p.head_branch===br&&p.status==="open");
|
|
1034
|
+
if(!prs.length){toast("no open PR for this branch",true);return;}
|
|
1035
|
+
modal(`<h3>Merge PR</h3><div class="b">
|
|
1036
|
+
<div class="desc">This is the runtime mutation path. cfgit will refuse stale heads and out-of-band drift.</div>
|
|
1037
|
+
<div><label>Open PR</label><select class="envpick" id="mPr">${prs.map(p=>`<option value="${esc(p.id)}">${esc(p.id)} · ${esc(p.message||"")}</option>`).join("")}</select></div>
|
|
1038
|
+
<div><label>Message</label><input id="mMsg" value="merge ${esc(br)}" autocomplete="off"></div></div>
|
|
1039
|
+
<div class="f"><button class="btn" onclick="closeModal()">Cancel</button><button class="btn warn" id="mGo">Merge</button></div>`);
|
|
1040
|
+
$("mGo").onclick=async()=>{$("mGo").disabled=true;const r=await api("pr_merge",{id:$("mPr").value,message:$("mMsg").value||("merge "+br)});after(r,"Merged");};
|
|
1041
|
+
}
|
|
1042
|
+
async function afterBranch(res,verb){closeModal();
|
|
1043
|
+
const ok=res&&res.status==="ok";
|
|
1044
|
+
toast(ok?verb:(res&&res.message?res.message:verb+" failed"),!ok);
|
|
1045
|
+
await loadState();renderBranches();
|
|
1046
|
+
}
|
|
1047
|
+
async function after(res,verb){closeModal();
|
|
1048
|
+
const ok=res&&(res.status==="ok"||(res.data&&(res.data.oid||res.data.seq)));
|
|
1049
|
+
toast(ok?verb:(res&&res.message?res.message:verb+" failed"),!ok);
|
|
1050
|
+
const keep=S.sel;await loadState();if(keep)selectRecord(keep);}
|
|
1051
|
+
|
|
1052
|
+
/* wiring */
|
|
1053
|
+
$("find").addEventListener("input",e=>{S.q=e.target.value;renderTree();});
|
|
1054
|
+
$("refresh").onclick=()=>{const keep=S.sel;loadState().then(()=>{if(keep)selectRecord(keep);});};
|
|
1055
|
+
$("env").addEventListener("change",()=>{S.sel=null;S.open={};loadState();});
|
|
1056
|
+
$("branch").addEventListener("change",()=>renderBranches());
|
|
1057
|
+
$("newBranch").onclick=()=>openCreateBranch();
|
|
1058
|
+
$("draftCommit").onclick=()=>openDraftCommit();
|
|
1059
|
+
$("branchDiff").onclick=()=>showBranchDiff();
|
|
1060
|
+
$("openPr").onclick=()=>openPrModal();
|
|
1061
|
+
$("mergePr").onclick=()=>openMergeModal();
|
|
1062
|
+
loadState();
|
|
1063
|
+
</script>
|
|
1064
|
+
</body>
|
|
1065
|
+
</html>
|
|
1066
|
+
"""
|