mirror of
https://github.com/dredx/prole.git
synced 2026-09-24 19:04:31 +00:00
- Add build-context helper to copy Docker context safely (ignore runtime data, keep symlinks) - Update UI and core actions to use ~/.prole/build and shared copy helper - Add/adjust tests and scripts; introduce knoe ops helpers and update manifests Co-authored-by: Junie <junie@jetbrains.com>
636 lines
24 KiB
Python
636 lines
24 KiB
Python
"""Dependency scanning, summary and per-dependency install screens."""
|
|
|
|
import re
|
|
import threading
|
|
import time
|
|
import tkinter as tk
|
|
from tkinter import ttk, messagebox, filedialog
|
|
from knoe import config as inst_config
|
|
from knoe import screen as ui
|
|
|
|
|
|
class DependenciesScreenMixin:
|
|
"""Dependency scanning, summary and per-dependency install screens."""
|
|
|
|
def _render_deps_summary_page(self):
|
|
self._render_title("Dependencies", y=40)
|
|
y = 100
|
|
# Draw each dependency row: status dot (blank initially), name, info
|
|
row_gap = 36
|
|
|
|
self.dep_status_items = {} # Store canvas IDs to update later
|
|
self.dep_install_buttons = {} # Store button window items
|
|
|
|
for dep in self.dependencies:
|
|
indent = 0
|
|
if dep.get("parent"):
|
|
indent = 24
|
|
|
|
left = 56 + indent
|
|
text_x = left + 28
|
|
|
|
# blank box (outline only initially)
|
|
dot = ui.canvas_rectangle(
|
|
self, left, y, left + 18, y + 18, outline="#6e6e73", width=2
|
|
)
|
|
self._canvas_items.append(dot)
|
|
# name
|
|
self._canvas_items.append(
|
|
ui.canvas_text(
|
|
self,
|
|
text_x,
|
|
y - 2,
|
|
dep["name"],
|
|
fill="black",
|
|
font=("SF Pro Text", 12, "bold"),
|
|
)
|
|
)
|
|
# info placeholder
|
|
info = ui.canvas_text(
|
|
self,
|
|
text_x + 180,
|
|
y,
|
|
"Checking...",
|
|
fill="#6e6e73",
|
|
font=("SF Pro Text", 11),
|
|
)
|
|
self._canvas_items.append(info)
|
|
|
|
self.dep_status_items[dep["id"]] = {
|
|
"dot": dot,
|
|
"info": info,
|
|
"y": y,
|
|
"left": left,
|
|
}
|
|
y += row_gap
|
|
|
|
# Message line
|
|
msg_y = y + 30
|
|
self.deps_msg_item = ui.canvas_text(
|
|
self,
|
|
48,
|
|
msg_y,
|
|
"Scanning system...",
|
|
fill="#6e6e73",
|
|
font=("SF Pro Text", 11),
|
|
)
|
|
self._canvas_items.append(self.deps_msg_item)
|
|
|
|
# Start async check
|
|
threading.Thread(target=self._run_delayed_deps_check, daemon=True).start()
|
|
|
|
def _render_dependency_page(self, dep):
|
|
self._render_title(dep["name"], y=40)
|
|
self._render_paragraph(dep["description"], y=88)
|
|
# Status
|
|
ok, location, version = self.get_dep_info(dep)
|
|
y = 180
|
|
left = 56
|
|
if ok:
|
|
self._canvas_items.append(
|
|
ui.canvas_oval(
|
|
self, left, y, left + 18, y + 18, fill="#34c759", outline=""
|
|
)
|
|
)
|
|
self._canvas_items.append(
|
|
ui.canvas_text(
|
|
self,
|
|
left + 32,
|
|
y - 2,
|
|
"Installed",
|
|
fill="black",
|
|
font=("SF Pro Text", 12, "bold"),
|
|
)
|
|
)
|
|
if location:
|
|
self._canvas_items.append(
|
|
ui.canvas_text(
|
|
self,
|
|
left + 32,
|
|
y + 32,
|
|
f"Location: {location}",
|
|
fill="#6e6e73",
|
|
font=("SF Pro Text", 11),
|
|
)
|
|
)
|
|
if version:
|
|
ver = self.normalize_version(version)
|
|
self._canvas_items.append(
|
|
ui.canvas_text(
|
|
self,
|
|
left + 32,
|
|
y + 56,
|
|
f"Version: {ver}",
|
|
fill="#6e6e73",
|
|
font=("SF Pro Text", 11),
|
|
)
|
|
)
|
|
else:
|
|
# Check if we should auto-install
|
|
install_cmd = dep.get("install_cmd")
|
|
if install_cmd and self._installing_dep_id != dep["id"]:
|
|
# Launch directly into installation
|
|
self._canvas_items.append(
|
|
ui.canvas_oval(
|
|
self, left, y, left + 18, y + 18, fill="#ffd60a", outline=""
|
|
)
|
|
)
|
|
self._canvas_items.append(
|
|
ui.canvas_text(
|
|
self,
|
|
left + 32,
|
|
y - 2,
|
|
"Installing...",
|
|
fill="black",
|
|
font=("SF Pro Text", 12, "bold"),
|
|
)
|
|
)
|
|
# Trigger console-based install for brew/pip
|
|
if "brew install" in install_cmd or "pip install" in install_cmd:
|
|
self.root.after(500, lambda: self._install_dep_in_console(dep))
|
|
else:
|
|
self.root.after(
|
|
500, lambda: self.open_terminal_with_command(install_cmd)
|
|
)
|
|
elif self._installing_dep_id == dep["id"]:
|
|
self._canvas_items.append(
|
|
ui.canvas_oval(
|
|
self, left, y, left + 18, y + 18, fill="#ffd60a", outline=""
|
|
)
|
|
)
|
|
self._canvas_items.append(
|
|
ui.canvas_text(
|
|
self,
|
|
left + 32,
|
|
y - 2,
|
|
"Installing...",
|
|
fill="black",
|
|
font=("SF Pro Text", 12, "bold"),
|
|
)
|
|
)
|
|
else:
|
|
self._canvas_items.append(
|
|
ui.canvas_oval(
|
|
self, left, y, left + 18, y + 18, fill="#ff9f0a", outline=""
|
|
)
|
|
)
|
|
self._canvas_items.append(
|
|
ui.canvas_text(
|
|
self,
|
|
left + 32,
|
|
y - 2,
|
|
"Not installed",
|
|
fill="black",
|
|
font=("SF Pro Text", 12, "bold"),
|
|
)
|
|
)
|
|
# Click to install link (if available)
|
|
if install_cmd:
|
|
link_y = y + 40
|
|
link_text = ui.render_link(
|
|
self, left + 32, link_y, "Click to install"
|
|
)
|
|
self._canvas_items.append(link_text)
|
|
self.bg_canvas.config(cursor="hand2")
|
|
|
|
def _on_click(event):
|
|
ex, ey = event.x, event.y
|
|
bbox = self.bg_canvas.bbox(link_text)
|
|
if (
|
|
bbox
|
|
and bbox[0] <= ex <= bbox[2]
|
|
and bbox[1] <= ey <= bbox[3]
|
|
):
|
|
self.open_terminal_with_command(install_cmd)
|
|
|
|
self.bg_canvas.bind("<Button-1>", _on_click)
|
|
|
|
def _install_dep_in_console(self, dep):
|
|
"""Run dependency installation in the embedded console."""
|
|
if self._installing_dep_id == dep["id"]:
|
|
return
|
|
try:
|
|
self._action_flags[f"dependencies.{dep['id']}.install"] = True
|
|
except Exception:
|
|
pass
|
|
|
|
install_cmd = dep.get("install_cmd")
|
|
if not install_cmd:
|
|
return
|
|
|
|
self._installing_dep_id = dep["id"]
|
|
# Prepare log file
|
|
logs_dir = self._resolve_env_dir("PROLE_LOGS", "logs")
|
|
try:
|
|
logs_dir.mkdir(parents=True, exist_ok=True)
|
|
except Exception:
|
|
pass
|
|
ts = time.strftime("%Y%m%d-%H%M%S")
|
|
log_path = logs_dir / f"install-{dep['id']}-{ts}.log"
|
|
|
|
# Show console
|
|
self._ensure_console_overlay(radio_bottom_y=160)
|
|
self._console_text.configure(state="normal")
|
|
self._console_text.delete("1.0", tk.END)
|
|
self._console_text.insert("end", f"Starting installation of {dep['name']}...\n")
|
|
self._console_text.insert("end", f"Command: {install_cmd}\n\n")
|
|
self._console_text.configure(state="disabled")
|
|
|
|
def on_done(rc):
|
|
self._installing_dep_id = None
|
|
if rc == 0:
|
|
self._append_console(f"\nSuccessfully installed {dep['name']}.\n")
|
|
# Refresh status and re-render page
|
|
self.root.after(1500, lambda: self.show_page(f"dep_{dep['id']}"))
|
|
else:
|
|
self._append_console(f"\nInstallation failed with exit code {rc}.\n")
|
|
# Enable next button so user can retry or proceed if they fixed it manually
|
|
try:
|
|
self.next_button.configure(state="normal", text="Next")
|
|
except Exception:
|
|
pass
|
|
|
|
self._run_in_console(install_cmd, str(log_path), on_complete=on_done)
|
|
|
|
# ---------------- Initialize Screen Handlers ----------------
|
|
|
|
def _run_delayed_deps_check(self):
|
|
results = {}
|
|
any_missing = False
|
|
for dep in self.dependencies:
|
|
ok, location, version = self.get_dep_info(dep)
|
|
results[dep["id"]] = (ok, location, version)
|
|
|
|
# Update UI for this item
|
|
def update_item(did=dep["id"], ok=ok, version=version):
|
|
items = self.dep_status_items.get(did)
|
|
if not items:
|
|
return
|
|
|
|
# Replace box with colored dot/check
|
|
self.bg_canvas.delete(items["dot"])
|
|
left = items.get("left", 56)
|
|
y = items["y"]
|
|
if ok:
|
|
items["dot"] = ui.canvas_oval(
|
|
self, left, y, left + 18, y + 18, fill="#34c759", outline=""
|
|
)
|
|
# Add a small white checkmark inside the green dot
|
|
self.bg_canvas.create_line(
|
|
left + 5,
|
|
y + 9,
|
|
left + 8,
|
|
y + 12,
|
|
fill="white",
|
|
width=2,
|
|
tags=f"page_item_{self.pages[self.page_index][0]}",
|
|
)
|
|
self.bg_canvas.create_line(
|
|
left + 8,
|
|
y + 12,
|
|
left + 13,
|
|
y + 6,
|
|
fill="white",
|
|
width=2,
|
|
tags=f"page_item_{self.pages[self.page_index][0]}",
|
|
)
|
|
else:
|
|
items["dot"] = ui.canvas_oval(
|
|
self, left, y, left + 18, y + 18, fill="#ff3b30", outline=""
|
|
)
|
|
# Add "Click to install" button
|
|
btn = tk.Button(
|
|
self.bg_canvas,
|
|
text="Install",
|
|
command=lambda d=did: self.show_page(f"dep_{d}"),
|
|
bg="#F5F5DC",
|
|
fg="black",
|
|
activebackground="#E5E5D5",
|
|
highlightbackground="#F5F5DC",
|
|
highlightthickness=0,
|
|
relief="flat",
|
|
font=("SF Pro Text", 10),
|
|
padx=8,
|
|
pady=2,
|
|
)
|
|
btn_window = self.bg_canvas.create_window(
|
|
left + 350, y, window=btn, anchor="nw"
|
|
)
|
|
self._overlay_widgets.append(btn)
|
|
self._canvas_items.append(btn_window)
|
|
self.dep_install_buttons[did] = btn_window
|
|
|
|
info_text = (
|
|
self.normalize_version(version)
|
|
if ok and version
|
|
else ("Not installed" if not ok else "")
|
|
)
|
|
self.bg_canvas.itemconfig(items["info"], text=info_text)
|
|
|
|
self.safe_after(update_item)
|
|
time.sleep(0.1) # small delay to show it checking one by one
|
|
|
|
def final_update():
|
|
missing = [
|
|
d["name"]
|
|
for d in self.dependencies
|
|
if not results.get(d["id"], (False, None, None))[0]
|
|
]
|
|
if missing:
|
|
msg = f"Preparing to install ... {', '.join(missing)}"
|
|
else:
|
|
msg = "All dependencies installed."
|
|
try:
|
|
if (
|
|
self.bg_canvas.winfo_exists()
|
|
and self.deps_msg_item in self.bg_canvas.find_all()
|
|
):
|
|
self.bg_canvas.itemconfig(self.deps_msg_item, text=msg)
|
|
except (tk.TclError, RuntimeError):
|
|
pass
|
|
self.update_footer()
|
|
|
|
self.safe_after(final_update)
|
|
|
|
def _create_page_dependencies_summary(self):
|
|
f = self._page_container()
|
|
ttk.Label(f, text="Dependencies", style="Title.TLabel").pack(
|
|
anchor="w", padx=24, pady=(24, 6)
|
|
)
|
|
self.deps_container = ttk.Frame(f)
|
|
self.deps_container.pack(fill="both", expand=True, padx=16, pady=8)
|
|
|
|
self.dep_status = {}
|
|
for dep in self.dependencies:
|
|
row = ttk.Frame(self.deps_container)
|
|
row.pack(fill="x", pady=6)
|
|
indicator = tk.Canvas(row, width=18, height=18, highlightthickness=0)
|
|
indicator.pack(side="left", padx=8)
|
|
name = ttk.Label(row, text=dep["name"], style="Body.TLabel")
|
|
name.pack(side="left")
|
|
info = ttk.Label(row, text="", style="Dim.TLabel")
|
|
info.pack(side="left", padx=10)
|
|
self.dep_status[dep["id"]] = {"canvas": indicator, "info": info, "dep": dep}
|
|
# Verify all checkbox (controls navigation behavior)
|
|
chk = ttk.Checkbutton(
|
|
f,
|
|
text="Verify all dependencies",
|
|
variable=self.verify_mode,
|
|
command=self.update_footer,
|
|
)
|
|
chk.pack(anchor="w", padx=24, pady=(4, 0))
|
|
|
|
# Message label must be created before refresh to avoid AttributeError
|
|
self.deps_msg = ttk.Label(f, text="", style="Dim.TLabel")
|
|
self.deps_msg.pack(anchor="w", padx=24, pady=(8, 8))
|
|
# Now we can safely populate the UI. Start async scan to avoid startup delay
|
|
self.deps_msg.configure(text="Checking dependencies...")
|
|
self.start_dependency_scan()
|
|
# self._register_page('deps_summary', f)
|
|
|
|
def _create_page_dependency(self, dep):
|
|
f = self._page_container()
|
|
ttk.Label(f, text=dep["name"], style="Title.TLabel").pack(
|
|
anchor="w", padx=24, pady=(24, 6)
|
|
)
|
|
ttk.Label(
|
|
f,
|
|
text=dep["description"],
|
|
style="Body.TLabel",
|
|
wraplength=800,
|
|
justify="left",
|
|
).pack(anchor="w", padx=24)
|
|
status_var = tk.StringVar(value="Checking...")
|
|
loc_var = tk.StringVar(value="")
|
|
ver_var = tk.StringVar(value="")
|
|
|
|
info_frame = ttk.Frame(f)
|
|
info_frame.pack(fill="x", padx=24, pady=12)
|
|
# Pretty status row with an icon
|
|
status_row = ttk.Frame(info_frame)
|
|
status_row.pack(anchor="w", fill="x")
|
|
status_icon = tk.Canvas(status_row, width=18, height=18, highlightthickness=0)
|
|
status_icon.pack(side="left", padx=(0, 8), pady=(2, 0))
|
|
ttk.Label(status_row, textvariable=status_var, style="Body.TLabel").pack(
|
|
side="left"
|
|
)
|
|
|
|
# Detail rows (hidden when not installed)
|
|
loc_label = ttk.Label(info_frame, textvariable=loc_var, style="Dim.TLabel")
|
|
ver_label = ttk.Label(info_frame, textvariable=ver_var, style="Dim.TLabel")
|
|
loc_label.pack(anchor="w")
|
|
ver_label.pack(anchor="w")
|
|
|
|
link = ttk.Label(
|
|
f,
|
|
text="Click to install",
|
|
foreground="#0a84ff",
|
|
cursor="hand2",
|
|
style="Body.TLabel",
|
|
)
|
|
link.pack(anchor="w", padx=24, pady=(12, 0))
|
|
|
|
def _draw_status_icon(canvas, status):
|
|
ui.canvas_clear(canvas)
|
|
if status == "installed":
|
|
ui.canvas_oval_on(canvas, 1, 1, 17, 17, fill="#34c759", outline="")
|
|
ui.canvas_line_on(canvas, 4, 9, 8, 13, fill="white", width=2)
|
|
ui.canvas_line_on(canvas, 8, 13, 15, 5, fill="white", width=2)
|
|
elif status == "missing":
|
|
# Friendly warning dot with exclamation
|
|
ui.canvas_oval_on(canvas, 1, 1, 17, 17, fill="#ff9f0a", outline="")
|
|
ui.canvas_line_on(canvas, 9, 5, 9, 11, fill="white", width=2)
|
|
ui.canvas_oval_on(canvas, 8, 13, 10, 15, fill="white", outline="white")
|
|
|
|
def check_then_update():
|
|
ok, location, version = self.get_dep_info(dep)
|
|
if ok:
|
|
status_var.set("Installed")
|
|
_draw_status_icon(status_icon, "installed")
|
|
loc_var.set(f'Location: {location or ""}')
|
|
norm_ver = self.normalize_version(version) if version else None
|
|
ver_var.set(f'Version: {norm_ver or ""}')
|
|
# Ensure details visible
|
|
try:
|
|
loc_label.pack_configure()
|
|
ver_label.pack_configure()
|
|
link.pack_forget()
|
|
except Exception:
|
|
pass
|
|
else:
|
|
status_var.set("Not installed")
|
|
_draw_status_icon(status_icon, "missing")
|
|
# Hide undefined details instead of showing dashes
|
|
try:
|
|
loc_label.pack_forget()
|
|
ver_label.pack_forget()
|
|
except Exception:
|
|
pass
|
|
try:
|
|
# Keep install link visible if we have a command
|
|
if dep.get("install_cmd") and not link.winfo_ismapped():
|
|
link.pack(anchor="w", padx=24, pady=(12, 0))
|
|
except Exception:
|
|
pass
|
|
|
|
self.root.after(50, check_then_update)
|
|
|
|
def do_install(event=None):
|
|
self.open_terminal_with_command(dep.get("install_cmd"))
|
|
|
|
if dep.get("install_cmd"):
|
|
link.bind("<Button-1>", do_install)
|
|
else:
|
|
try:
|
|
link.pack_forget()
|
|
except Exception:
|
|
pass
|
|
|
|
# self._register_page(f'dep_{dep["id"]}', f)
|
|
|
|
def _dep_navigation_sequence(self, force_all: bool = False):
|
|
"""Return a list of dependency page ids to traverse next.
|
|
- If force_all is True or verify_mode is True: include all dep pages in defined order.
|
|
- Else: include only missing dependency pages based on current checks.
|
|
"""
|
|
if force_all or self.verify_mode.get():
|
|
res = [f'dep_{d["id"]}' for d in self.dependencies]
|
|
print(f"[DEBUG] _dep_navigation_sequence (force_all={force_all}): {res}")
|
|
return res
|
|
seq = []
|
|
for d in self.dependencies:
|
|
ok, _, _ = self.get_dep_info(d)
|
|
if not ok:
|
|
seq.append(f'dep_{d["id"]}')
|
|
print(f"[DEBUG] _dep_navigation_sequence: {seq}")
|
|
return seq
|
|
|
|
def start_dependency_scan(self):
|
|
"""Scan dependencies in a background thread to avoid blocking UI startup."""
|
|
if self.validation_running:
|
|
return
|
|
self.validation_running = True
|
|
|
|
# Hold incremental results so we can update the status line and icons progressively
|
|
self._dep_scan_results = {}
|
|
|
|
def worker():
|
|
for dep in self.dependencies:
|
|
try:
|
|
ok, location, version = self.get_dep_info(dep)
|
|
except Exception:
|
|
ok, location, version = False, None, None
|
|
did = dep["id"]
|
|
# Save and apply incrementally
|
|
self._dep_scan_results[did] = (ok, location, version)
|
|
self.safe_after(
|
|
lambda d=did, o=ok, l=location, v=version: self._apply_dependency_incremental(
|
|
d, o, l, v
|
|
)
|
|
)
|
|
try:
|
|
time.sleep(0.02)
|
|
except Exception:
|
|
pass
|
|
# After all are processed, finalize pass to unify any remaining labels
|
|
self.safe_after(
|
|
lambda: self._apply_dependency_scan(dict(self._dep_scan_results))
|
|
)
|
|
|
|
t = threading.Thread(target=worker, daemon=True)
|
|
self.validation_thread = t
|
|
t.start()
|
|
|
|
def _apply_dependency_incremental(self, dep_id: str, ok: bool, location, version):
|
|
"""Update the dependencies page row and message as each check completes."""
|
|
if not hasattr(self, "dep_status"):
|
|
return
|
|
slot = self.dep_status.get(dep_id)
|
|
if not slot:
|
|
return
|
|
# Update dot icon and info text for this row
|
|
self._draw_status(slot["canvas"], "success" if ok else "error")
|
|
if ok:
|
|
norm_ver = self.normalize_version(version) if version else ""
|
|
slot["info"].configure(text=(norm_ver or ""))
|
|
else:
|
|
slot["info"].configure(text="Not installed")
|
|
|
|
# Update the message line with current missing list
|
|
if hasattr(self, "_dep_scan_results"):
|
|
missing = [
|
|
self.dep_status[d]["dep"]["name"]
|
|
for d, res in self._dep_scan_results.items()
|
|
if not res[0] and d in self.dep_status
|
|
]
|
|
if hasattr(self, "deps_msg") and self.deps_msg is not None:
|
|
if missing:
|
|
self.deps_msg.configure(
|
|
text=f"Preparing to install ... {', '.join(missing)}"
|
|
)
|
|
else:
|
|
self.deps_msg.configure(text="All dependencies installed.")
|
|
# Footer may need to react if verify mode is enabled
|
|
self.update_footer()
|
|
|
|
# Update prole.cfg data
|
|
try:
|
|
status_str = f"installed (version {version})" if ok else "not installed"
|
|
self.prole_cfg_data["Dependencies"][dep_id] = status_str
|
|
self._save_prole_cfg()
|
|
except Exception:
|
|
pass
|
|
|
|
def _apply_dependency_scan(self, results: dict):
|
|
"""Apply scan results to the summary UI and footer labels."""
|
|
self.validation_running = False
|
|
any_missing = False
|
|
for did, slot in self.dep_status.items():
|
|
ok, location, version = results.get(did, (False, None, None))
|
|
self._draw_status(slot["canvas"], "success" if ok else "error")
|
|
if ok:
|
|
norm_ver = self.normalize_version(version) if version else ""
|
|
slot["info"].configure(text=(norm_ver or ""))
|
|
else:
|
|
any_missing = True
|
|
slot["info"].configure(text="Not installed")
|
|
|
|
has_msg = hasattr(self, "deps_msg") and self.deps_msg is not None
|
|
if any_missing:
|
|
missing = [
|
|
slot["dep"]["name"]
|
|
for did, slot in self.dep_status.items()
|
|
if not results.get(did, (False, None, None))[0]
|
|
]
|
|
if missing and has_msg:
|
|
self.deps_msg.configure(
|
|
text=f"Preparing to install ... {', '.join(missing)}"
|
|
)
|
|
else:
|
|
if has_msg:
|
|
self.deps_msg.configure(text="All dependencies installed.")
|
|
self.update_footer()
|
|
|
|
def refresh_dependencies_ui(self):
|
|
"""Deprecated synchronous refresh retained for compatibility.
|
|
Prefer start_dependency_scan -> _apply_dependency_scan.
|
|
"""
|
|
self.start_dependency_scan()
|
|
|
|
def all_dependencies_installed(self):
|
|
for dep in self.dependencies:
|
|
ok, _, _ = self.get_dep_info(dep)
|
|
if not ok:
|
|
print(f"[DEBUG] all_dependencies_installed: '{dep['id']}' is MISSING")
|
|
return False
|
|
print("[DEBUG] all_dependencies_installed: YES (all OK)")
|
|
return True
|
|
|
|
def get_dep_info(self, dep):
|
|
"""Delegate dependency probing to knoe.config.get_dep_info."""
|
|
return inst_config.get_dep_info(dep)
|
|
|
|
def normalize_version(self, text: str) -> str:
|
|
"""Normalize versions via knoe.config.normalize_version."""
|
|
return inst_config.normalize_version(text)
|