@grifhinz/logics-manager 2.4.0 → 2.5.0
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.
- package/README.md +8 -1
- package/VERSION +1 -1
- package/clients/shared-web/media/renderMarkdown.js +1 -1
- package/clients/viewer/browser-host.js +884 -22
- package/clients/viewer/index.html +21 -5
- package/clients/viewer/viewer.css +547 -2
- package/logics_manager/audit.py +2 -2
- package/logics_manager/insights.py +1 -1
- package/logics_manager/lint.py +2 -2
- package/logics_manager/viewer.py +184 -12
- package/package.json +1 -1
- package/pyproject.toml +1 -1
package/logics_manager/viewer.py
CHANGED
|
@@ -334,7 +334,7 @@ def viewer_data_payload(
|
|
|
334
334
|
repo_root: Path,
|
|
335
335
|
selected_id: str | None = None,
|
|
336
336
|
*,
|
|
337
|
-
auto_refresh_interval_seconds: int =
|
|
337
|
+
auto_refresh_interval_seconds: int = 15,
|
|
338
338
|
) -> dict[str, Any]:
|
|
339
339
|
return {
|
|
340
340
|
"root": str(repo_root.resolve()),
|
|
@@ -399,6 +399,20 @@ def _run_read_only_git(repo_root: Path, args: list[str], *, runner: Any | None =
|
|
|
399
399
|
return git_runner(command, cwd=repo_root, text=True, capture_output=True, timeout=5)
|
|
400
400
|
|
|
401
401
|
|
|
402
|
+
def _run_read_only_cdx(repo_root: Path, args: list[str], *, runner: Any | None = None) -> subprocess.CompletedProcess[str]:
|
|
403
|
+
command = ["cdx", *args]
|
|
404
|
+
cdx_runner = runner or subprocess.run
|
|
405
|
+
return cdx_runner(command, cwd=repo_root, text=True, capture_output=True, timeout=5)
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _logics_doc_type(rel_path: str) -> str:
|
|
409
|
+
normalized = rel_path.replace("\\", "/").lstrip("/")
|
|
410
|
+
for family in DOC_FAMILIES:
|
|
411
|
+
if normalized.startswith(f"{family.directory}/"):
|
|
412
|
+
return family.stage
|
|
413
|
+
return ""
|
|
414
|
+
|
|
415
|
+
|
|
402
416
|
def _sanitize_git_ref(value: str) -> str:
|
|
403
417
|
ref = value.strip()
|
|
404
418
|
ref = re.sub(r"://[^/@\s]+@", "://", ref)
|
|
@@ -410,7 +424,8 @@ def _classify_porcelain_entry(line: str) -> tuple[str, dict[str, str]] | None:
|
|
|
410
424
|
if not line or line.startswith("## "):
|
|
411
425
|
return None
|
|
412
426
|
if line.startswith("?? "):
|
|
413
|
-
|
|
427
|
+
path = line[3:].strip()
|
|
428
|
+
return "untracked", {"path": path, "logicsType": _logics_doc_type(path)}
|
|
414
429
|
if len(line) < 4:
|
|
415
430
|
return None
|
|
416
431
|
staged = line[0]
|
|
@@ -418,15 +433,16 @@ def _classify_porcelain_entry(line: str) -> tuple[str, dict[str, str]] | None:
|
|
|
418
433
|
raw_path = line[3:].strip()
|
|
419
434
|
if " -> " in raw_path:
|
|
420
435
|
before, after = raw_path.split(" -> ", 1)
|
|
421
|
-
|
|
436
|
+
path = after.strip()
|
|
437
|
+
return "renamed", {"path": path, "from": before.strip(), "logicsType": _logics_doc_type(path)}
|
|
422
438
|
if staged == "R":
|
|
423
|
-
return "renamed", {"path": raw_path}
|
|
439
|
+
return "renamed", {"path": raw_path, "logicsType": _logics_doc_type(raw_path)}
|
|
424
440
|
if staged not in {" ", "?", "!"}:
|
|
425
|
-
return "staged", {"path": raw_path, "code": staged}
|
|
441
|
+
return "staged", {"path": raw_path, "code": staged, "logicsType": _logics_doc_type(raw_path)}
|
|
426
442
|
if worktree == "D":
|
|
427
|
-
return "deleted", {"path": raw_path, "code": worktree}
|
|
443
|
+
return "deleted", {"path": raw_path, "code": worktree, "logicsType": _logics_doc_type(raw_path)}
|
|
428
444
|
if worktree not in {" ", "?", "!"}:
|
|
429
|
-
return "modified", {"path": raw_path, "code": worktree}
|
|
445
|
+
return "modified", {"path": raw_path, "code": worktree, "logicsType": _logics_doc_type(raw_path)}
|
|
430
446
|
return None
|
|
431
447
|
|
|
432
448
|
|
|
@@ -454,6 +470,59 @@ def _parse_git_branch_line(line: str) -> dict[str, Any]:
|
|
|
454
470
|
}
|
|
455
471
|
|
|
456
472
|
|
|
473
|
+
def _parse_recent_git_commits(output: str) -> list[dict[str, str]]:
|
|
474
|
+
commits: list[dict[str, str]] = []
|
|
475
|
+
for line in output.splitlines():
|
|
476
|
+
parts = line.split("\x1f")
|
|
477
|
+
if len(parts) < 5:
|
|
478
|
+
continue
|
|
479
|
+
commit_hash, subject, author, date, refs = parts[:5]
|
|
480
|
+
commits.append(
|
|
481
|
+
{
|
|
482
|
+
"hash": _sanitize_git_ref(commit_hash),
|
|
483
|
+
"subject": subject.strip()[:240],
|
|
484
|
+
"author": author.strip()[:120],
|
|
485
|
+
"date": date.strip()[:40],
|
|
486
|
+
"refs": _sanitize_git_ref(refs),
|
|
487
|
+
}
|
|
488
|
+
)
|
|
489
|
+
return commits
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def _count_unique_git_status_paths(groups: dict[str, list[dict[str, str]]]) -> int:
|
|
493
|
+
paths: set[str] = set()
|
|
494
|
+
for entries in groups.values():
|
|
495
|
+
for entry in entries:
|
|
496
|
+
path = entry.get("path", "").strip()
|
|
497
|
+
if path:
|
|
498
|
+
paths.add(path)
|
|
499
|
+
return len(paths)
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def _git_unpushed_commit_count(repo_root: Path, *, runner: Any | None = None) -> dict[str, Any]:
|
|
503
|
+
try:
|
|
504
|
+
upstream = _run_read_only_git(repo_root, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], runner=runner)
|
|
505
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
506
|
+
return {"available": False, "count": 0, "message": f"Unable to inspect upstream: {exc}"}
|
|
507
|
+
if upstream.returncode != 0:
|
|
508
|
+
return {"available": False, "count": 0, "message": "No upstream branch detected."}
|
|
509
|
+
|
|
510
|
+
tracking = _sanitize_git_ref(upstream.stdout.strip())
|
|
511
|
+
try:
|
|
512
|
+
unpushed = _run_read_only_git(repo_root, ["rev-list", "--count", "@{u}..HEAD"], runner=runner)
|
|
513
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
514
|
+
return {"available": False, "count": 0, "tracking": tracking, "message": f"Unable to count unpushed commits: {exc}"}
|
|
515
|
+
if unpushed.returncode != 0:
|
|
516
|
+
message = (unpushed.stderr or unpushed.stdout or "Unable to count unpushed commits.").strip().splitlines()[0]
|
|
517
|
+
return {"available": False, "count": 0, "tracking": tracking, "message": message}
|
|
518
|
+
|
|
519
|
+
try:
|
|
520
|
+
count = max(0, int(unpushed.stdout.strip() or "0"))
|
|
521
|
+
except ValueError:
|
|
522
|
+
count = 0
|
|
523
|
+
return {"available": True, "count": count, "tracking": tracking, "message": ""}
|
|
524
|
+
|
|
525
|
+
|
|
457
526
|
def git_status_payload(repo_root: Path, *, runner: Any | None = None, which: Any | None = None) -> dict[str, Any]:
|
|
458
527
|
git_which = which or shutil.which
|
|
459
528
|
if not git_which("git"):
|
|
@@ -468,6 +537,12 @@ def git_status_payload(repo_root: Path, *, runner: Any | None = None, which: Any
|
|
|
468
537
|
try:
|
|
469
538
|
status = _run_read_only_git(repo_root, ["status", "--porcelain=v1", "-b"], runner=runner)
|
|
470
539
|
commit = _run_read_only_git(repo_root, ["log", "-1", "--pretty=format:%h %s"], runner=runner)
|
|
540
|
+
recent_commits = _run_read_only_git(
|
|
541
|
+
repo_root,
|
|
542
|
+
["log", "-8", "--date=short", "--pretty=format:%h%x1f%s%x1f%an%x1f%ad%x1f%D"],
|
|
543
|
+
runner=runner,
|
|
544
|
+
)
|
|
545
|
+
unpushed = _git_unpushed_commit_count(repo_root, runner=runner)
|
|
471
546
|
except (OSError, subprocess.SubprocessError) as exc:
|
|
472
547
|
return {"state": "error", "message": f"Unable to collect Git status: {exc}"}
|
|
473
548
|
if status.returncode != 0:
|
|
@@ -483,6 +558,7 @@ def git_status_payload(repo_root: Path, *, runner: Any | None = None, which: Any
|
|
|
483
558
|
group, entry = classified
|
|
484
559
|
groups[group].append(entry)
|
|
485
560
|
counts = {key: len(value) for key, value in groups.items()}
|
|
561
|
+
uncommitted_files = _count_unique_git_status_paths(groups)
|
|
486
562
|
dirty = any(counts.values())
|
|
487
563
|
return {
|
|
488
564
|
"state": "ok",
|
|
@@ -490,11 +566,98 @@ def git_status_payload(repo_root: Path, *, runner: Any | None = None, which: Any
|
|
|
490
566
|
"clean": not dirty,
|
|
491
567
|
"dirty": dirty,
|
|
492
568
|
"counts": counts,
|
|
569
|
+
"badgeCounts": {
|
|
570
|
+
"unpushedCommits": int(unpushed.get("count", 0)),
|
|
571
|
+
"uncommittedFiles": uncommitted_files,
|
|
572
|
+
},
|
|
573
|
+
"badgeAvailability": {
|
|
574
|
+
"unpushedCommits": bool(unpushed.get("available")),
|
|
575
|
+
"uncommittedFiles": True,
|
|
576
|
+
},
|
|
577
|
+
"badgeMessages": {
|
|
578
|
+
"unpushedCommits": str(unpushed.get("message", "")),
|
|
579
|
+
"uncommittedFiles": "",
|
|
580
|
+
},
|
|
493
581
|
"groups": groups,
|
|
494
582
|
"latestCommit": (commit.stdout.strip() if commit.returncode == 0 else "")[:300],
|
|
583
|
+
"recentCommits": _parse_recent_git_commits(recent_commits.stdout) if recent_commits.returncode == 0 else [],
|
|
495
584
|
}
|
|
496
585
|
|
|
497
586
|
|
|
587
|
+
def git_diff_payload(
|
|
588
|
+
repo_root: Path,
|
|
589
|
+
rel_path: str,
|
|
590
|
+
*,
|
|
591
|
+
cached: bool = False,
|
|
592
|
+
max_chars: int = 20000,
|
|
593
|
+
runner: Any | None = None,
|
|
594
|
+
which: Any | None = None,
|
|
595
|
+
) -> dict[str, Any]:
|
|
596
|
+
git_which = which or shutil.which
|
|
597
|
+
if not git_which("git"):
|
|
598
|
+
return {"state": "unavailable", "message": "Git is not available on PATH."}
|
|
599
|
+
normalized = unquote(rel_path).replace("\\", "/").lstrip("/")
|
|
600
|
+
if not normalized or normalized.startswith("~") or normalized.startswith("/") or ".." in normalized.split("/"):
|
|
601
|
+
return {"state": "error", "message": "Unsafe Git path."}
|
|
602
|
+
try:
|
|
603
|
+
inside = _run_read_only_git(repo_root, ["rev-parse", "--is-inside-work-tree"], runner=runner)
|
|
604
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
605
|
+
return {"state": "error", "message": f"Unable to run Git diff: {exc}"}
|
|
606
|
+
if inside.returncode != 0 or inside.stdout.strip().lower() != "true":
|
|
607
|
+
return {"state": "not-repository", "message": "This folder is not inside a Git worktree."}
|
|
608
|
+
|
|
609
|
+
args = ["diff", "--no-ext-diff", "--unified=80"]
|
|
610
|
+
if cached:
|
|
611
|
+
args.append("--cached")
|
|
612
|
+
args.extend(["--", normalized])
|
|
613
|
+
try:
|
|
614
|
+
diff = _run_read_only_git(repo_root, args, runner=runner)
|
|
615
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
616
|
+
return {"state": "error", "message": f"Unable to collect Git diff: {exc}"}
|
|
617
|
+
if diff.returncode != 0:
|
|
618
|
+
message = (diff.stderr or diff.stdout or "Git diff failed.").strip().splitlines()[0]
|
|
619
|
+
return {"state": "error", "message": message}
|
|
620
|
+
content = diff.stdout
|
|
621
|
+
truncated = len(content) > max_chars
|
|
622
|
+
if truncated:
|
|
623
|
+
content = content[:max_chars]
|
|
624
|
+
return {
|
|
625
|
+
"state": "ok",
|
|
626
|
+
"path": normalized,
|
|
627
|
+
"mode": "staged" if cached else "worktree",
|
|
628
|
+
"diff": content,
|
|
629
|
+
"truncated": truncated,
|
|
630
|
+
"logicsType": _logics_doc_type(normalized),
|
|
631
|
+
"message": "" if content else "No diff is available for this file in the selected mode.",
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
def cdx_status_payload(repo_root: Path, *, runner: Any | None = None, which: Any | None = None) -> dict[str, Any]:
|
|
636
|
+
cdx_which = which or shutil.which
|
|
637
|
+
if not cdx_which("cdx"):
|
|
638
|
+
return {"state": "unavailable", "message": "CDX is not available on PATH.", "status": {}}
|
|
639
|
+
|
|
640
|
+
try:
|
|
641
|
+
status = _run_read_only_cdx(repo_root, ["status", "--json"], runner=runner)
|
|
642
|
+
except subprocess.TimeoutExpired:
|
|
643
|
+
return {"state": "timeout", "message": "CDX status timed out.", "status": {}}
|
|
644
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
645
|
+
return {"state": "error", "message": f"Unable to run CDX status: {exc}", "status": {}}
|
|
646
|
+
|
|
647
|
+
if status.returncode != 0:
|
|
648
|
+
message = (status.stderr or status.stdout or "CDX status failed.").strip().splitlines()[0]
|
|
649
|
+
return {"state": "error", "message": message, "status": {}}
|
|
650
|
+
|
|
651
|
+
try:
|
|
652
|
+
parsed = json.loads(status.stdout or "{}")
|
|
653
|
+
except json.JSONDecodeError:
|
|
654
|
+
return {"state": "invalid-json", "message": "CDX status returned invalid JSON.", "status": {}}
|
|
655
|
+
if not isinstance(parsed, dict):
|
|
656
|
+
return {"state": "invalid-json", "message": "CDX status JSON must be an object.", "status": {}}
|
|
657
|
+
|
|
658
|
+
return {"state": "ok", "message": "", "status": parsed}
|
|
659
|
+
|
|
660
|
+
|
|
498
661
|
def _json_bytes(payload: Any) -> bytes:
|
|
499
662
|
return json.dumps(payload, indent=2, sort_keys=True).encode("utf-8")
|
|
500
663
|
|
|
@@ -505,7 +668,7 @@ class LogicsViewerServer(ThreadingHTTPServer):
|
|
|
505
668
|
server_address: tuple[str, int],
|
|
506
669
|
repo_root: Path,
|
|
507
670
|
*,
|
|
508
|
-
auto_refresh_interval_seconds: int =
|
|
671
|
+
auto_refresh_interval_seconds: int = 15,
|
|
509
672
|
):
|
|
510
673
|
self.repo_root = repo_root.resolve()
|
|
511
674
|
self.auto_refresh_interval_seconds = auto_refresh_interval_seconds
|
|
@@ -597,6 +760,15 @@ class LogicsViewerRequestHandler(BaseHTTPRequestHandler):
|
|
|
597
760
|
if route == "/api/git-status":
|
|
598
761
|
self._send_json({"ok": True, "payload": git_status_payload(self.server.repo_root)})
|
|
599
762
|
return
|
|
763
|
+
if route == "/api/cdx-status":
|
|
764
|
+
self._send_json({"ok": True, "payload": cdx_status_payload(self.server.repo_root)})
|
|
765
|
+
return
|
|
766
|
+
if route == "/api/git-diff":
|
|
767
|
+
params = parse_qs(parsed.query)
|
|
768
|
+
rel_path = params.get("path", [""])[0]
|
|
769
|
+
cached = params.get("cached", [""])[0].lower() in {"1", "true", "yes"}
|
|
770
|
+
self._send_json({"ok": True, "payload": git_diff_payload(self.server.repo_root, rel_path, cached=cached)})
|
|
771
|
+
return
|
|
600
772
|
self._send_error_json(HTTPStatus.NOT_FOUND, "Not found")
|
|
601
773
|
|
|
602
774
|
def do_POST(self) -> None:
|
|
@@ -629,7 +801,7 @@ def create_viewer_server(
|
|
|
629
801
|
host: str = "127.0.0.1",
|
|
630
802
|
port: int = 8765,
|
|
631
803
|
*,
|
|
632
|
-
auto_refresh_interval_seconds: int =
|
|
804
|
+
auto_refresh_interval_seconds: int = 15,
|
|
633
805
|
) -> LogicsViewerServer:
|
|
634
806
|
return LogicsViewerServer(
|
|
635
807
|
(host, port),
|
|
@@ -657,7 +829,7 @@ def render_start_status(
|
|
|
657
829
|
focus: str | None = None,
|
|
658
830
|
network_url: str | None = None,
|
|
659
831
|
bind_host: str = "localhost",
|
|
660
|
-
auto_refresh_interval_seconds: int =
|
|
832
|
+
auto_refresh_interval_seconds: int = 15,
|
|
661
833
|
) -> str:
|
|
662
834
|
lines = [
|
|
663
835
|
"Logics viewer running:",
|
|
@@ -682,8 +854,8 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
682
854
|
parser.add_argument(
|
|
683
855
|
"--refresh-interval",
|
|
684
856
|
type=int,
|
|
685
|
-
default=
|
|
686
|
-
help="Automatic refresh interval in seconds. Defaults to
|
|
857
|
+
default=15,
|
|
858
|
+
help="Automatic refresh interval in seconds. Defaults to 15; positive intervals are allowed.",
|
|
687
859
|
)
|
|
688
860
|
parser.add_argument("--focus", help="Open the viewer focused on a workflow ref or repo-relative Logics Markdown path.")
|
|
689
861
|
parser.add_argument("--read", action="store_true", help="Open the focused item in the read preview. Requires --focus.")
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@grifhinz/logics-manager",
|
|
3
3
|
"displayName": "Logics Orchestrator",
|
|
4
4
|
"description": "Visual orchestration for Logics workflows inside VS Code.",
|
|
5
|
-
"version": "2.
|
|
5
|
+
"version": "2.5.0",
|
|
6
6
|
"publisher": "cdx-logics",
|
|
7
7
|
"icon": "clients/shared-web/media/icon.png",
|
|
8
8
|
"repository": {
|