@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.
@@ -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 = 60,
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
- return "untracked", {"path": line[3:].strip()}
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
- return "renamed", {"path": after.strip(), "from": before.strip()}
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 = 60,
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 = 60,
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 = 60,
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=60,
686
- help="Automatic refresh interval in seconds. Defaults to 60; positive shorter intervals are allowed.",
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.4.0",
5
+ "version": "2.5.0",
6
6
  "publisher": "cdx-logics",
7
7
  "icon": "clients/shared-web/media/icon.png",
8
8
  "repository": {
package/pyproject.toml CHANGED
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "logics-manager"
7
- version = "2.4.0"
7
+ version = "2.5.0"
8
8
  description = "Canonical Logics CLI"
9
9
  requires-python = ">=3.10"
10
10