localtools-sql-formatter 1.0.0__tar.gz → 1.0.1__tar.gz

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.
Files changed (15) hide show
  1. {localtools_sql_formatter-1.0.0 → localtools_sql_formatter-1.0.1}/PKG-INFO +3 -2
  2. {localtools_sql_formatter-1.0.0 → localtools_sql_formatter-1.0.1}/README.md +2 -1
  3. {localtools_sql_formatter-1.0.0 → localtools_sql_formatter-1.0.1}/localtools_sql_formatter/__main__.py +83 -6
  4. {localtools_sql_formatter-1.0.0 → localtools_sql_formatter-1.0.1}/localtools_sql_formatter/engine.py +25 -7
  5. {localtools_sql_formatter-1.0.0 → localtools_sql_formatter-1.0.1}/pyproject.toml +1 -1
  6. {localtools_sql_formatter-1.0.0 → localtools_sql_formatter-1.0.1}/ui/index.html +29 -10
  7. {localtools_sql_formatter-1.0.0 → localtools_sql_formatter-1.0.1}/.gitignore +0 -0
  8. {localtools_sql_formatter-1.0.0 → localtools_sql_formatter-1.0.1}/LICENSE +0 -0
  9. {localtools_sql_formatter-1.0.0 → localtools_sql_formatter-1.0.1}/localtools_sql_formatter/__init__.py +0 -0
  10. {localtools_sql_formatter-1.0.0 → localtools_sql_formatter-1.0.1}/localtools_sql_formatter/localtools.py +0 -0
  11. {localtools_sql_formatter-1.0.0 → localtools_sql_formatter-1.0.1}/ui/kit/fonts/OFL.txt +0 -0
  12. {localtools_sql_formatter-1.0.0 → localtools_sql_formatter-1.0.1}/ui/kit/fonts/jetbrains-mono-latin-bold.woff2 +0 -0
  13. {localtools_sql_formatter-1.0.0 → localtools_sql_formatter-1.0.1}/ui/kit/fonts/jetbrains-mono-latin-regular.woff2 +0 -0
  14. {localtools_sql_formatter-1.0.0 → localtools_sql_formatter-1.0.1}/ui/kit/localtools.css +0 -0
  15. {localtools_sql_formatter-1.0.0 → localtools_sql_formatter-1.0.1}/ui/kit/localtools.js +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: localtools-sql-formatter
3
- Version: 1.0.0
3
+ Version: 1.0.1
4
4
  Summary: Lint, fix and transpile SQL in any major dialect with sqlfluff and sqlglot (localtools, offline)
5
5
  Project-URL: Source, https://github.com/chayprabs/localtools
6
6
  License-Expression: MIT
@@ -34,7 +34,7 @@ The script installs a self-contained build (bundled Python) into `~/.localtools/
34
34
  - **Fix**: the fixed SQL with a diff, Copy and Download, plus what still needs a human.
35
35
  - **Transpile**: rewrite between dialects (e.g. T-SQL `TOP`/`ISNULL` to Postgres `LIMIT`/`COALESCE`), with notes on unsupported constructs.
36
36
 
37
- Options: rule selection/exclusion (`LT01,CP01`, `core`, `layout`), templater (Jinja, or placeholders `:name`, `?`, `$1`, `%(name)s`, `@name`), and an uploaded `.sqlfluff` / `setup.cfg` / `tox.ini` / `pyproject.toml`.
37
+ Options: rule selection/exclusion (`LT01,CP01`, `core`, `layout`, names, legacy aliases and wildcards), templater (Raw, Jinja, or placeholders `:name`, `?`, `$1`, `$name`, `%(name)s`, `%s`), and an uploaded `.sqlfluff` / `setup.cfg` / `tox.ini` / `pyproject.toml`. Config/default uses SQLFluff's Jinja default unless the uploaded config chooses another templater. Unknown rule selectors and placeholder styles are rejected.
38
38
 
39
39
  ```sh
40
40
  localtools-sql-formatter lint models/*.sql --dialect postgres --format json # or human | sarif; exit 1 on violations
@@ -67,6 +67,7 @@ API: `POST /api/lint`, `/api/fix`, `/api/transpile` (multipart: `sql` and/or `fi
67
67
  - Config files are parsed, never executed: `library_path`, macro/loader paths (any `*_path` / `*_dir` key), the python and
68
68
  dbt templaters and `processes` are ignored and listed in the result. sqlfluff's 20 KB "skip large file" default is off.
69
69
  - Files that do not parse are not fixed (like the sqlfluff CLI); the parse error shows line and column.
70
+ - In-place fixes use an atomic replacement and retain the file mode, UTF-8 BOM and CRLF convention. Old crashed request folders are swept after one hour at startup; active owners and linked folders are preserved.
70
71
 
71
72
  ## Cut
72
73
 
@@ -21,7 +21,7 @@ The script installs a self-contained build (bundled Python) into `~/.localtools/
21
21
  - **Fix**: the fixed SQL with a diff, Copy and Download, plus what still needs a human.
22
22
  - **Transpile**: rewrite between dialects (e.g. T-SQL `TOP`/`ISNULL` to Postgres `LIMIT`/`COALESCE`), with notes on unsupported constructs.
23
23
 
24
- Options: rule selection/exclusion (`LT01,CP01`, `core`, `layout`), templater (Jinja, or placeholders `:name`, `?`, `$1`, `%(name)s`, `@name`), and an uploaded `.sqlfluff` / `setup.cfg` / `tox.ini` / `pyproject.toml`.
24
+ Options: rule selection/exclusion (`LT01,CP01`, `core`, `layout`, names, legacy aliases and wildcards), templater (Raw, Jinja, or placeholders `:name`, `?`, `$1`, `$name`, `%(name)s`, `%s`), and an uploaded `.sqlfluff` / `setup.cfg` / `tox.ini` / `pyproject.toml`. Config/default uses SQLFluff's Jinja default unless the uploaded config chooses another templater. Unknown rule selectors and placeholder styles are rejected.
25
25
 
26
26
  ```sh
27
27
  localtools-sql-formatter lint models/*.sql --dialect postgres --format json # or human | sarif; exit 1 on violations
@@ -54,6 +54,7 @@ API: `POST /api/lint`, `/api/fix`, `/api/transpile` (multipart: `sql` and/or `fi
54
54
  - Config files are parsed, never executed: `library_path`, macro/loader paths (any `*_path` / `*_dir` key), the python and
55
55
  dbt templaters and `processes` are ignored and listed in the result. sqlfluff's 20 KB "skip large file" default is off.
56
56
  - Files that do not parse are not fixed (like the sqlfluff CLI); the parse error shows line and column.
57
+ - In-place fixes use an atomic replacement and retain the file mode, UTF-8 BOM and CRLF convention. Old crashed request folders are swept after one hour at startup; active owners and linked folders are preserved.
57
58
 
58
59
  ## Cut
59
60
 
@@ -1,6 +1,9 @@
1
1
  import argparse
2
2
  import json
3
3
  import os
4
+ import re
5
+ import shutil
6
+ import stat
4
7
  import select
5
8
  import socket
6
9
  import subprocess
@@ -49,6 +52,50 @@ JOBS = int(os.environ.get("LOCALTOOLS_SQL_JOBS") or max(1, min(4, (os.cpu_count(
49
52
  SLOTS = threading.BoundedSemaphore(JOBS)
50
53
 
51
54
 
55
+ def process_alive(pid):
56
+ if os.name == "nt":
57
+ import ctypes
58
+ from ctypes import wintypes
59
+ kernel = ctypes.WinDLL("kernel32", use_last_error=True)
60
+ kernel.OpenProcess.argtypes = (wintypes.DWORD, wintypes.BOOL, wintypes.DWORD)
61
+ kernel.OpenProcess.restype = wintypes.HANDLE
62
+ kernel.GetExitCodeProcess.argtypes = (wintypes.HANDLE, ctypes.POINTER(wintypes.DWORD))
63
+ kernel.CloseHandle.argtypes = (wintypes.HANDLE,)
64
+ handle = kernel.OpenProcess(0x1000, False, pid)
65
+ if not handle:
66
+ return ctypes.get_last_error() != 87 # access denied is not proof of death
67
+ try:
68
+ code = wintypes.DWORD()
69
+ return not kernel.GetExitCodeProcess(handle, ctypes.byref(code)) or code.value == 259
70
+ finally:
71
+ kernel.CloseHandle(handle)
72
+ try:
73
+ os.kill(pid, 0)
74
+ return True
75
+ except ProcessLookupError:
76
+ return False
77
+ except PermissionError:
78
+ return True
79
+
80
+
81
+ def sweep_jobs():
82
+ """Recover crashed jobs only: exact tool names, >1h, never traverse links/junctions."""
83
+ for path in Path(tempfile.gettempdir()).iterdir():
84
+ match = re.fullmatch(r"lt-sqlf-(?:(\d+)-)?[a-z0-9_]{8}", path.name)
85
+ if not match:
86
+ continue
87
+ try:
88
+ info = path.lstat()
89
+ if not stat.S_ISDIR(info.st_mode) or path.is_symlink() or (hasattr(path, "is_junction") and path.is_junction()):
90
+ continue
91
+ if time.time() - info.st_mtime <= 3600 or (match[1] and process_alive(int(match[1]))):
92
+ continue
93
+ # rmtree does not follow directory symlinks (or Windows junctions on Python >=3.8).
94
+ shutil.rmtree(path)
95
+ except OSError:
96
+ pass # a busy/locked or concurrently removed folder is safe to leave for next startup
97
+
98
+
52
99
  def text(data, name):
53
100
  try:
54
101
  return engine.newlines(data.decode("utf-8-sig"))
@@ -93,11 +140,13 @@ def client_gone(req):
93
140
 
94
141
  def run_worker(req, job):
95
142
  """One worker process per request: killed at the deadline, when the client disconnects, and (stdin EOF)
96
- when this server exits. At most JOBS run at once; more get 503."""
97
- if not SLOTS.acquire(blocking=False):
143
+ when this server exits. At most JOBS run at once; more get 503.
144
+ An aborted fetch frees its slot on the next poll, after the follow-up click, so wait for that
145
+ cancellation instead of answering 503."""
146
+ if not (SLOTS.acquire(blocking=False) or SLOTS.acquire(timeout=1)):
98
147
  raise HTTPError(503, f"busy: {JOBS} SQL job(s) already running; try again when they finish")
99
148
  try:
100
- with tempfile.TemporaryDirectory(prefix="lt-sqlf-") as d:
149
+ with tempfile.TemporaryDirectory(prefix=f"lt-sqlf-{os.getpid()}-") as d:
101
150
  jp = Path(d, "job.json")
102
151
  jp.write_text(json.dumps(job), encoding="utf-8")
103
152
  cmd = [sys.executable, *(["-I"] if sys.flags.isolated else []), "-m", __package__, "_worker", str(jp)]
@@ -112,7 +161,7 @@ def run_worker(req, job):
112
161
  if client_gone(req):
113
162
  raise HTTPError(499, "client disconnected")
114
163
  try:
115
- p.wait(0.2)
164
+ p.wait(0.05)
116
165
  except subprocess.TimeoutExpired:
117
166
  pass
118
167
  finally:
@@ -143,7 +192,12 @@ def job(op, files, f):
143
192
  def api(op):
144
193
  def handler(req):
145
194
  files, f = read_form(req)
146
- res = run_worker(req, job(op, files, f))
195
+ try:
196
+ res = run_worker(req, job(op, files, f))
197
+ except HTTPError as e:
198
+ if e.code == 499:
199
+ return # the cancelled browser is gone; sending its error would raise a socket traceback
200
+ raise
147
201
  if op == "lint" and f.get("format") == "sarif":
148
202
  body = json.dumps(engine.sarif(res["files"], sqlfluff_version()), indent=2).encode()
149
203
  req.send_response(200)
@@ -173,6 +227,27 @@ def api_meta(req):
173
227
 
174
228
 
175
229
  # ---------- CLI ----------
230
+ def atomic_rewrite(name, sql):
231
+ target = Path(name).resolve() # preserve a user's symlink and replace its actual file
232
+ original = target.read_bytes()
233
+ encoding = "utf-8-sig" if original.startswith(b"\xef\xbb\xbf") else "utf-8"
234
+ if b"\r\n" in original and b"\n" not in original.replace(b"\r\n", b""):
235
+ sql = sql.replace("\n", "\r\n")
236
+ tmp = None
237
+ try:
238
+ with tempfile.NamedTemporaryFile(prefix=f".{target.name}.", suffix=".tmp", dir=target.parent,
239
+ mode="w", encoding=encoding, newline="", delete=False) as f:
240
+ tmp = Path(f.name)
241
+ f.write(sql)
242
+ f.flush()
243
+ os.fsync(f.fileno())
244
+ os.chmod(tmp, stat.S_IMODE(target.stat().st_mode))
245
+ os.replace(tmp, target)
246
+ finally:
247
+ if tmp is not None:
248
+ tmp.unlink(missing_ok=True)
249
+
250
+
176
251
  def read_inputs(paths):
177
252
  out = []
178
253
  for p in paths:
@@ -251,7 +326,7 @@ def cli_fix(args):
251
326
  for f in res:
252
327
  if a.in_place:
253
328
  if f["changed"]:
254
- Path(f["name"]).write_text(f["fixed"], encoding="utf-8", newline="")
329
+ atomic_rewrite(f["name"], f["fixed"])
255
330
  print(f"{f['name']}: {'fixed' if f['changed'] else 'unchanged'}", file=sys.stderr)
256
331
  elif a.diff:
257
332
  sys.stdout.write(f["diff"])
@@ -304,6 +379,8 @@ tool = Tool(slug="sql-formatter", port=47117, usage=USAGE, ui=UI,
304
379
 
305
380
 
306
381
  def main():
382
+ if len(sys.argv) == 1 or sys.argv[1] == "serve" or sys.argv[1] in ("--no-open", "--port"):
383
+ sweep_jobs()
307
384
  sys.exit(run(tool))
308
385
 
309
386
 
@@ -1,5 +1,6 @@
1
1
  """sqlfluff lint/fix and sqlglot transpile, safe config loading, SARIF output and the worker process entry."""
2
2
  import difflib
3
+ import fnmatch
3
4
  import json
4
5
  import logging
5
6
  import os
@@ -12,8 +13,9 @@ MAX_BYTES = 512 << 10 # per request (server); sqlfluff lints roughly 1-2 KB/s o
12
13
  MAX_STATEMENTS = 2000
13
14
  DOCS = "https://docs.sqlfluff.com/en/stable/reference/rules/{group}.html#{code}"
14
15
  TEMPLATERS = ("raw", "jinja", "placeholder")
15
- PARAM_STYLES = ("colon", "colon_nospaces", "numeric_colon", "question_mark", "numeric_dollar", "dollar",
16
- "percent", "pyformat", "at", "ampersand", "apache_ignite", "flyway_var", "dollar_brace")
16
+ from sqlfluff.core.templaters.placeholder import KNOWN_STYLES
17
+
18
+ PARAM_STYLES = tuple(KNOWN_STYLES)
17
19
 
18
20
 
19
21
  class SQLError(ValueError):
@@ -84,7 +86,7 @@ def load_user_config(text, name):
84
86
 
85
87
 
86
88
  def make_config(dialect="", rules="", exclude_rules="", templater="", config="", config_name=".sqlfluff"):
87
- """templater: raw | jinja | placeholder:<param_style>; empty dialect/templater = from the config file, else ansi/raw."""
89
+ """Empty dialect/templater uses the uploaded config, then SQLFluff's ansi/Jinja defaults."""
88
90
  from sqlfluff.core import FluffConfig
89
91
  from sqlfluff.core.errors import SQLFluffUserError
90
92
  cfg, dropped = load_user_config(config, config_name) if config.strip() else ({}, [])
@@ -108,7 +110,20 @@ def make_config(dialect="", rules="", exclude_rules="", templater="", config="",
108
110
  if style:
109
111
  cfg.setdefault("templater", {}).setdefault("placeholder", {})["param_style"] = style
110
112
  try:
111
- return FluffConfig(configs=cfg, overrides=ov, ignore_local_config=True), dropped
113
+ result = FluffConfig(configs=cfg, overrides=ov, ignore_local_config=True)
114
+ from sqlfluff.core.rules import get_ruleset
115
+ references = get_ruleset().rule_reference_map()
116
+ for key in ("rule_allowlist", "rule_denylist"):
117
+ for selector in result.get(key) or []:
118
+ if not fnmatch.filter(references, selector):
119
+ raise SQLError(f'unknown rule selector "{selector}" (use a SQLFluff code, name, alias or group)')
120
+ if result.get("templater") == "placeholder":
121
+ from sqlfluff.core.templaters.placeholder import PlaceholderTemplater
122
+ try:
123
+ PlaceholderTemplater().get_context(fname=None, config=result)
124
+ except (ValueError, KeyError) as e:
125
+ raise SQLError(f"invalid placeholder options: {e}") from None
126
+ return result, dropped
112
127
  except SQLFluffUserError as e:
113
128
  raise SQLError(str(e)) from None
114
129
 
@@ -128,8 +143,6 @@ def violation(v):
128
143
  def lint_string(linter, sql, name, fix=False):
129
144
  try:
130
145
  return linter.lint_string(sql, fname=name, fix=fix)
131
- except Exception:
132
- raise
133
146
  except BaseException as e: # sqlfluffrs raises pyo3 PanicException (a BaseException) when its parser gives up
134
147
  if type(e).__name__ != "PanicException":
135
148
  raise
@@ -171,10 +184,15 @@ def fix(files, cfg, passes=5):
171
184
  out.append({"name": name, "fixed": cur, "changed": cur != sql, "unparsable": unparsable,
172
185
  "fixed_count": max(0, len(first or []) - len(left)),
173
186
  "remaining": [violation(v) for v in left],
174
- "diff": "".join(difflib.unified_diff(sql.splitlines(True), cur.splitlines(True), f"a/{name}", f"b/{name}"))})
187
+ "diff": unified_diff(sql, cur, name)})
175
188
  return out
176
189
 
177
190
 
191
+ def unified_diff(before, after, name):
192
+ return "".join(line if line.endswith("\n") else line + "\n\\n"
193
+ for line in difflib.unified_diff(before.splitlines(True), after.splitlines(True), f"a/{name}", f"b/{name}"))
194
+
195
+
178
196
  class _Collect(logging.Handler):
179
197
  def __init__(self):
180
198
  super().__init__(logging.WARNING)
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "localtools-sql-formatter"
7
- version = "1.0.0"
7
+ version = "1.0.1"
8
8
  description = "Lint, fix and transpile SQL in any major dialect with sqlfluff and sqlglot (localtools, offline)"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -7,6 +7,7 @@
7
7
  <link rel="stylesheet" href="kit/localtools.css">
8
8
  <style>
9
9
  #sql { min-height: 240px; }
10
+ #sql, .lt-pre { font-variant-ligatures: none; } /* show SQL operators (>=, <>) as typed */
10
11
  .lt-files button { border: 0; background: none; color: var(--muted); font: 700 13px var(--mono); cursor: pointer; padding: 0 var(--s1); }
11
12
  .lt-files button:hover { color: var(--accent); }
12
13
  .lt-files li span:first-child { margin-right: auto; }
@@ -67,7 +68,8 @@
67
68
  <div class="lt-field">
68
69
  <label for="templater">Templater</label>
69
70
  <select class="lt-select" id="templater">
70
- <option value="">none (or config)</option>
71
+ <option value="">config / default (Jinja)</option>
72
+ <option value="raw">raw (no templating)</option>
71
73
  <option value="jinja">jinja {{ }}</option>
72
74
  <option value="placeholder:colon">:name</option>
73
75
  <option value="placeholder:question_mark">?</option>
@@ -75,7 +77,6 @@
75
77
  <option value="placeholder:dollar">$name</option>
76
78
  <option value="placeholder:pyformat">%(name)s</option>
77
79
  <option value="placeholder:percent">%s</option>
78
- <option value="placeholder:at">@name</option>
79
80
  </select>
80
81
  </div>
81
82
  <div class="lt-field">
@@ -133,6 +134,17 @@ const el = (tag, props = {}, ...kids) => { const e = Object.assign(document.crea
133
134
  const MAX_ROWS = 2000;
134
135
  const picked = [];
135
136
  let cfgFile = null;
137
+ let revision = 0, pending = null;
138
+ function invalidate() {
139
+ revision++;
140
+ pending?.abort();
141
+ pending = null;
142
+ $("out").replaceChildren(el("div", { className: "lt-empty", textContent: "No output. Run with the current input and options." }));
143
+ $("status").textContent = "";
144
+ $("err").hidden = true;
145
+ }
146
+ for (const id of ["sql", "dialect", "templater", "rules", "exclude", "read", "write", "mode"])
147
+ $(id).addEventListener("input", invalidate);
136
148
 
137
149
  function fileList(ul, list, onRemove) {
138
150
  ul.replaceChildren(...list.map((f, i) => {
@@ -142,10 +154,10 @@ function fileList(ul, list, onRemove) {
142
154
  return el("li", {}, el("span", { textContent: f.name }), el("span", { textContent: fmtBytes(f.size) }), x);
143
155
  }));
144
156
  }
145
- const renderPicked = () => fileList($("files"), picked, (i) => { picked.splice(i, 1); renderPicked(); });
146
- const renderCfg = () => fileList($("cfg-files"), cfgFile ? [cfgFile] : [], () => { cfgFile = null; renderCfg(); });
147
- dropZone($("drop"), null, (fs) => { picked.push(...fs); $("file").value = ""; renderPicked(); });
148
- dropZone($("cfg-drop"), null, (fs) => { cfgFile = fs[0] || cfgFile; $("cfg").value = ""; renderCfg(); });
157
+ const renderPicked = () => fileList($("files"), picked, (i) => { picked.splice(i, 1); invalidate(); renderPicked(); });
158
+ const renderCfg = () => fileList($("cfg-files"), cfgFile ? [cfgFile] : [], () => { cfgFile = null; invalidate(); renderCfg(); });
159
+ dropZone($("drop"), null, (fs) => { picked.push(...fs); $("file").value = ""; invalidate(); renderPicked(); });
160
+ dropZone($("cfg-drop"), null, (fs) => { cfgFile = fs[0] || cfgFile; $("cfg").value = ""; invalidate(); renderCfg(); });
149
161
 
150
162
  const mode = () => document.querySelector('input[name="mode"]:checked').value;
151
163
  $("mode").onchange = () => {
@@ -240,11 +252,11 @@ const render = {
240
252
  ...body, ...(r ? table(f.remaining) : []));
241
253
  });
242
254
  },
243
- transpile(res) {
255
+ transpile(res, submitted) {
244
256
  return res.files.map((f) => {
245
257
  const blob = new Blob([f.sql], { type: "application/sql" });
246
258
  return el("article", { className: "file" },
247
- bar(f.name, [0, `${$("read").value || "generic"} > ${$("write").value || "generic"}`],
259
+ bar(f.name, [0, `${submitted.read || "generic"} > ${submitted.write || "generic"}`],
248
260
  btn("Copy", (b) => copy(f.sql, b)), btn("Download", () => download(blob, sqlName(f.name)))),
249
261
  ...f.warnings.map((w) => el("div", { className: "note", textContent: "Unsupported: " + w })),
250
262
  el("pre", { className: "lt-pre", textContent: f.sql }));
@@ -253,6 +265,8 @@ const render = {
253
265
  };
254
266
 
255
267
  async function go(m = mode()) {
268
+ invalidate();
269
+ const submittedRevision = revision;
256
270
  const t0 = performance.now();
257
271
  const { files, sql } = sources();
258
272
  const fields = { sql };
@@ -265,13 +279,18 @@ async function go(m = mode()) {
265
279
  $("out").replaceChildren(el("div", { className: "lt-empty", textContent: m === "transpile" ? "Transpiling..." : "sqlfluff is working. Big files take a while." }));
266
280
  $("status").textContent = "";
267
281
  let res;
282
+ const controller = pending = new AbortController();
268
283
  try {
269
- const r = await fetch("/api/" + m, { method: "POST", body });
284
+ const r = await fetch("/api/" + m, { method: "POST", body, signal: controller.signal });
270
285
  res = await r.json().catch(() => ({ error: `${r.status} ${r.statusText}` }));
286
+ if (submittedRevision !== revision) return;
271
287
  if (!r.ok) throw new Error(res.error);
272
288
  } catch (e) {
289
+ if (submittedRevision !== revision) return;
273
290
  $("out").replaceChildren(el("div", { className: "lt-empty", textContent: "No output." }));
274
291
  throw e.message === "Failed to fetch" ? new Error("Cannot reach the local server. Is the tool still running?") : e;
292
+ } finally {
293
+ if (pending === controller) pending = null;
275
294
  }
276
295
  const head = [];
277
296
  if (res.ignored_config?.length) head.push(el("div", { className: "note", textContent: "Ignored config settings: " + res.ignored_config.join(", ") }));
@@ -285,7 +304,7 @@ async function go(m = mode()) {
285
304
  } else {
286
305
  $("status").textContent = `${res.files.length} file${res.files.length > 1 ? "s" : ""} · ${fmtMs(performance.now() - t0)}`;
287
306
  }
288
- $("out").replaceChildren(...head, ...render[m](res));
307
+ $("out").replaceChildren(...head, ...render[m](res, fields));
289
308
  }
290
309
 
291
310
  $("go").onclick = () => run($("go"), $("err"), () => go());