vibefpmgui.window.packages

The dependency graph: the Graph menu, a package's details and menu, updates.

  1"""The dependency graph: the Graph menu, a package's details and menu, updates."""
  2
  3from __future__ import annotations
  4
  5import html
  6import json
  7import re
  8import tomllib
  9from itertools import pairwise
 10from pathlib import Path
 11
 12from qtpy.QtCore import Qt, QUrl
 13from qtpy.QtGui import QAction, QActionGroup, QDesktopServices, QKeySequence, QPalette
 14from qtpy.QtWidgets import (
 15    QApplication,
 16    QFileDialog,
 17    QLabel,
 18    QMenu,
 19    QMessageBox,
 20    QSplitter,
 21    QTextBrowser,
 22    QVBoxLayout,
 23    QWidget,
 24)
 25
 26from .. import dep_updates
 27from .. import manifest as m
 28from ..compat import reveal_label
 29from ..graph_view import (
 30    EDGE_STYLES,
 31    KIND_LABELS,
 32    LAYOUTS,
 33    THEME_CHOICES,
 34    DependencyGraphView,
 35)
 36from ..project_tree import reveal_in_file_manager
 37from ..widgets import run_dialog
 38
 39
 40def repo_web_url(git_url: str) -> str | None:
 41    """The web page of a GitHub/GitLab/Codeberg/Bitbucket repository."""
 42    url = git_url.strip()
 43    match = re.match(r"^git@([^:]+):(.+?)(?:\.git)?/?$", url)
 44    if match:
 45        host, path = match.groups()
 46    else:
 47        match = re.match(r"^(?:https?|git|ssh)://(?:[^@/]+@)?([^/:]+)(?::\d+)?/(.+?)(?:\.git)?/?$", url)
 48        if not match:
 49            return None
 50        host, path = match.groups()
 51    if not any(h in host for h in ("github.", "gitlab.", "codeberg.", "bitbucket.")):
 52        return None
 53    return f"https://{host}/{path}"
 54
 55
 56def commit_web_url(git_url: str, rev: str) -> str | None:
 57    base = repo_web_url(git_url)
 58    if not base or not re.fullmatch(r"[0-9a-fA-F]{7,40}", rev or ""):
 59        return None
 60    if "bitbucket." in base:
 61        return f"{base}/commits/{rev}"
 62    if "gitlab." in base:
 63        return f"{base}/-/commit/{rev}"
 64    return f"{base}/commit/{rev}"
 65
 66
 67def compare_web_url(git_url: str, old: str, new: str) -> str | None:
 68    """A page listing the commits between two revisions (GitHub/GitLab)."""
 69    base = repo_web_url(git_url)
 70    if not base or not old or not new:
 71        return None
 72    if "gitlab." in base:
 73        return f"{base}/-/compare/{old}...{new}"
 74    if "github." in base or "codeberg." in base:
 75        return f"{base}/compare/{old}...{new}"
 76    return None
 77
 78
 79def toml_line(name: str, spec) -> str:
 80    """A dependency as one fpm.toml line: ``name = "1.0"`` or
 81    ``name = { git = "…", tag = "…" }``."""
 82    def key(k):
 83        return k if re.fullmatch(r"[A-Za-z0-9_-]+", str(k)) else json.dumps(str(k))
 84
 85    def value(v):
 86        if isinstance(v, bool):
 87            return "true" if v else "false"
 88        if isinstance(v, (int, float)):
 89            return str(v)
 90        if isinstance(v, dict):
 91            return "{ " + ", ".join(f"{key(k)} = {value(x)}" for k, x in v.items()) + " }"
 92        if isinstance(v, list):
 93            return "[" + ", ".join(value(x) for x in v) + "]"
 94        return json.dumps(str(v))  # (a TOML basic string)
 95
 96    return f"{key(name)} = {value(spec)}"
 97
 98
 99class PackagesMixin:
100    """The dependency graph and its packages (a part of `vibefpmgui.main_window.MainWindow`)."""
101
102    def _build_graph_panel(self):
103        """The Dependency graph panel: the graph, the selected package's details and the legend."""
104        self.graph_view = DependencyGraphView()
105        self.graph_view.package_selected.connect(self.show_package)
106        self.graph_view.package_activated.connect(self.open_package_manifest)
107        self.graph_view.package_context_menu.connect(self._package_menu)
108
109        self.graph_menu = self._build_graph_menu()
110        self.menuBar().addMenu(self.graph_menu)
111
112        self.legend = QLabel()
113        self.legend.setWordWrap(True)
114
115        self.details = QTextBrowser()
116        self.details.setOpenLinks(False)
117        self.details.anchorClicked.connect(QDesktopServices.openUrl)
118        graph_split = QSplitter(Qt.Horizontal)
119        graph_split.addWidget(self.graph_view)
120        graph_split.addWidget(self.details)
121        graph_split.setStretchFactor(0, 3)
122        graph_split.setStretchFactor(1, 1)
123        graph_split.setSizes([700, 300])
124
125        graph_tab = QWidget()
126        gl = QVBoxLayout(graph_tab)
127        gl.addWidget(graph_split, 1)
128        gl.addWidget(self.legend)
129        return graph_tab
130
131    def _build_graph_menu(self) -> QMenu:
132        """Graph menu: layout, theme and edge style choices (radio groups)."""
133        menu = QMenu("&Graph", self)
134        self.graph_style_actions: dict[str, dict[str, QAction]] = {}
135        for title, key, choices, setter in [
136            ("Layout", "layout", LAYOUTS, self._set_graph_layout),
137            ("Theme", "theme", THEME_CHOICES, self._set_graph_theme),
138            ("Edges", "edges", EDGE_STYLES, self._set_graph_edges),
139        ]:
140            sub = menu.addMenu(title)
141            group = QActionGroup(self)
142            group.setExclusive(True)
143            self.graph_style_actions[key] = {}
144            for value, label in choices.items():
145                action = QAction(label, self, checkable=True)
146                action.triggered.connect(lambda _=False, v=value, f=setter: f(v))
147                group.addAction(action)
148                sub.addAction(action)
149                self.graph_style_actions[key][value] = action
150        menu.addSeparator()
151        self.dependencies_dev_action = QAction("Show Dependencies' Dev-Dependencies", self, checkable=True)
152        self.dependencies_dev_action.setChecked(self.settings.value("graph_dependencies_dev", False, type=bool))
153        self.dependencies_dev_action.setStatusTip(
154            "Also show what the dependencies list as dev-dependencies (fpm doesn't download those: "
155            "only the project's own)")
156        self.dependencies_dev_action.toggled.connect(self._set_dependencies_dev)
157        menu.addAction(self.dependencies_dev_action)
158        menu.addSeparator()
159        for text, slot, shortcut in [("Fit to window", lambda: self.graph_view.fit(), "Ctrl+0"),
160                                     ("Re-layout", lambda: self.graph_view.relayout(), "Ctrl+Shift+L"),
161                                     ("Export image…", self.export_graph, "Ctrl+E")]:
162            action = QAction(text, self)
163            action.setShortcut(QKeySequence(shortcut))
164            action.triggered.connect(slot)
165            menu.addAction(action)
166        return menu
167
168    def _check_style_action(self, key: str, value: str):
169        action = self.graph_style_actions[key].get(value)
170        if action:
171            action.setChecked(True)
172
173    def _set_graph_layout(self, value: str):
174        self.graph_view.set_layout(value)
175        self._check_style_action("layout", value)
176        self.settings.setValue("graph_layout", value)
177
178    def _set_graph_theme(self, value: str):
179        self.graph_view.set_theme(value)
180        self._check_style_action("theme", value)
181        self.settings.setValue("graph_theme", value)
182        self._update_legend()
183        self.build_monitor.set_package_colors(self.graph_view.package_colors())
184
185    def _set_graph_edges(self, value: str):
186        self.graph_view.set_edge_style(value)
187        self._check_style_action("edges", value)
188        self.settings.setValue("graph_edges", value)
189
190    def _set_dependencies_dev(self, shown: bool):
191        self.settings.setValue("graph_dependencies_dev", shown)
192        self.reload()
193
194    def _update_legend(self):
195        self.legend.setText(self.graph_view.legend_html())
196
197    def export_graph(self):
198        if not self.graph:
199            return
200        default = str((self.manifest_path.parent if self.manifest_path else Path.home())
201                      / f"{self.graph.root}-dependencies.png")
202        filename, _ = QFileDialog.getSaveFileName(
203            self, "Export dependency graph", default, "PNG image (*.png);;SVG image (*.svg)")
204        if filename:
205            if self.graph_view.export_image(filename):
206                self.statusBar().showMessage(f"Saved {filename}")
207            else:
208                QMessageBox.warning(self, "Export failed", f"Could not write {filename}")
209
210    def check_dependency_updates(self, only: str | None = None):
211        """Commands → Check for Dependency Updates… (only runs when asked);
212        ``only``: just that dependency (from its menu in the graph)."""
213        if not self.graph:
214            return
215        checks = dep_updates.git_dependencies(self.graph)
216        if only:
217            checks = [c for c in checks if c.name == only]
218        dialog = dep_updates.UpdatesDialog(checks, compare_web_url, self)
219        dialog.update_tag_requested.connect(lambda c: self._update_dependency_tag(c, dialog))
220        dialog.update_deps_requested.connect(lambda: (dialog.accept(), self.run_fpm(["update"], False, True)))
221        if checks:
222            self._update_check_thread = dep_updates.start_check(checks, dialog, self)
223        run_dialog(dialog)  # (results still coming then go nowhere: Qt disconnects them)
224
225    def _update_dependency_tag(self, check, dialog):
226        """Point one dependency at its newest tag in its fpm.toml."""
227        if self.busy():
228            QMessageBox.information(dialog, "Update dependency", "Wait for the running command to finish first.")
229            return
230        # A tag pin moves to the newest tag; a commit pin becomes that tag.
231        if not dep_updates.set_ref(check.declared_in, check.name, check.ref_kind, check.ref,
232                                   "tag", check.latest_tag):
233            QMessageBox.warning(
234                dialog, "Update dependency",
235                f"Couldn't find {check.ref_kind} = \"{check.ref}\" for {check.name} in "
236                f"{check.declared_in} to change it automatically; it is opened in the editor instead.")
237            dialog.accept()
238            self.open_file(str(check.declared_in), force_editor=True)
239            return
240        was = check.ref if check.ref_kind == "tag" else f"commit {check.ref[:7]}"
241        self.statusBar().showMessage(f"{check.name}: {was} → tag {check.latest_tag} in "
242                                     f"{Path(check.declared_in).name}", 6000)
243        answer = QMessageBox.question(
244            dialog, "Update dependency",
245            f"{check.name} now asks for tag {check.latest_tag} (was {was}) in {check.declared_in}.\n\n"
246            "Run Update deps now to download it?")
247        dialog.accept()
248        self.reload()  # fpm.toml changed
249        if answer == QMessageBox.Yes:
250            self.run_fpm(["update"], False, True)
251
252    def _report_dependency_changes(self):
253        """After "fpm update": which dependencies moved to another commit."""
254        before, self._revs_before = getattr(self, "_revs_before", None), None
255        if before is None or not self.manifest_path:
256            return
257        after = dep_updates.read_cache_revs(self.manifest_path.parent)
258        changed = {n: (before.get(n), rev) for n, rev in after.items() if before.get(n) != rev}
259        if not changed:
260            self._append("\nNo dependency changed commit.\n")
261            return
262        lines = ["\nDependency changes:"]
263        for name, (old, new) in sorted(changed.items()):
264            pkg = self.graph.packages.get(name) if self.graph else None
265            url = compare_web_url(pkg.git, old, new) if pkg and pkg.git and old else None
266            lines.append(f"  {name}: {(old or 'new')[:7]} → {new[:7]}" + (f"  {url}" if url else ""))
267        self._append("\n".join(lines) + "\n", bold=False)
268        self.statusBar().showMessage(f"Update deps: {len(changed)} dependenc"
269                                     f"{'ies' if len(changed) != 1 else 'y'} changed", 6000)
270
271    def show_package(self, name: str):
272        if not self.graph or name not in self.graph.packages:
273            return
274        pkg = self.graph.packages[name]
275        e = html.escape
276        rows = [("Type", KIND_LABELS[pkg.kind])]
277        # The version: a dependency's own fpm.toml's (which the tag it's pinned to,
278        # below, takes precedence over), or else the one asked for or fpm recorded.
279        own = pkg.manifest_version if pkg.kind != m.ROOT else ""
280        other = pkg.version if pkg.version != own else ""
281        for label, value in [("Version", other), ("Manifest version", own), ("License", pkg.license),
282                             ("Author", pkg.author)]:
283            if value:
284                rows.append((label, e(value)))
285        if pkg.git:
286            web = repo_web_url(pkg.git)
287            rows.append(("Git", f'<a href="{e(web)}">{e(pkg.git)}</a>' if web else e(pkg.git)))
288        if pkg.git_ref:
289            rows.append(("Requested", e(pkg.git_ref)))
290        if pkg.git_rev:
291            # The exact commit fpm checked out (from build/cache.toml).
292            commit_url = commit_web_url(pkg.git, pkg.git_rev)
293            short = e(pkg.git_rev[:7])
294            rows.append(("Commit", (f'<a href="{e(commit_url)}" title="{e(pkg.git_rev)}">{short}</a>'
295                                    if commit_url else f'<span title="{e(pkg.git_rev)}">{short}</span>')
296                         + f' <span style="color:gray">({e(pkg.git_rev)})</span>'))
297        if pkg.homepage:
298            rows.append(("Homepage", f'<a href="{e(pkg.homepage)}">{e(pkg.homepage)}</a>'))
299        if pkg.directory:
300            url = QUrl.fromLocalFile(str(pkg.directory)).toString()
301            rows.append(("Location", f'<a href="{e(url)}">{e(str(pkg.directory))}</a>'))
302        deps = self.graph.children(name)
303        users = self.graph.parents(name)
304        if deps:
305            rows.append(("Depends on", ", ".join(
306                e(d.child) + (" <i>(dev)</i>" if d.dev else "") for d in deps)))
307        if users:
308            rows.append(("Used by", ", ".join(e(u.parent) + (" <i>(dev)</i>" if u.dev else "") for u in users)))
309        table = "".join(f"<tr><td valign='top'><b>{k}</b>&nbsp;</td><td>{v}</td></tr>"
310                        for k, v in rows)
311        body = f"<h3>{e(pkg.name)}</h3>"
312        if pkg.description:
313            body += f"<p>{e(pkg.description)}</p>"
314        body += f"<table>{table}</table>"
315        if pkg.error:
316            body += f"<p style='color:#c92a2a'>{e(pkg.error)}</p>"
317        conflict = m.conflicts(self.graph).get(name)
318        if conflict:
319            used = m.spec_key(conflict[0])
320            items = "".join(f"<li><b>{e(d.parent)}</b>{' (dev)' if d.dev else ''} asks for "
321                            f"{e(m.describe_spec(d.spec))}"
322                            + (" — <b>used</b>" if i == 0 else " <i>(the same)</i>" if m.spec_key(d) == used
323                               else "") + "</li>"
324                            for i, d in enumerate(conflict))
325            # In a red box, so it stands out (Qt's rich text draws borders on tables only).
326            dark = self.details.palette().color(QPalette.Base).lightness() < 128
327            red, fill = ("#ff6b6b", "#3d2224") if dark else ("#e03131", "#fff0f0")
328            body += (f"<table width='100%' cellpadding='6' cellspacing='0' "
329                     f"style='margin-top:10px; border:1.5px solid {red}; border-collapse:collapse'><tr><td bgcolor='{fill}'>"
330                     f"<p style='color:{red}; margin-top:0'><b>⚠ Version conflict</b></p>"
331                     f"<p>{e(name)} is asked for in different ways:</p><ul>{items}</ul>"
332                     "<p style='margin-bottom:0'>fpm uses only one of them (the declaration closest "
333                     "to the project), so the others get a version they didn't ask for. Pinning the "
334                     "same version everywhere — or overriding it in the project's own fpm.toml — "
335                     "avoids surprises.</p></td></tr></table>")
336        if pkg.manifest:
337            body += "<p><i>Double-click the node to open its fpm.toml.</i></p>"
338        self.details.setHtml(body)
339
340    def _package_menu(self, name: str, pos):
341        """Right-click on a package in the dependency graph."""
342        menu = self.package_menu(name)
343        if menu is not None:
344            menu.exec(pos)
345            menu.deleteLater()
346
347    def package_menu(self, name: str) -> QMenu | None:
348        """The menu for a package in the dependency graph (None: not one)."""
349        pkg = self.graph.packages.get(name) if self.graph else None
350        if pkg is None:
351            return None
352        is_root = pkg.kind == m.ROOT
353        on_disk = pkg.directory is not None and pkg.directory.is_dir()
354        menu = QMenu(self)
355
356        def add(text, slot, enabled=True, tip=""):
357            action = menu.addAction(text)
358            action.triggered.connect(slot)
359            action.setEnabled(bool(enabled))
360            if tip:
361                action.setToolTip(tip)
362            return action
363
364        add("Open fpm.toml", lambda: self.open_package_manifest(name), pkg.manifest and pkg.manifest.is_file())
365        add("Show in Project Files", lambda: self._show_package_files(pkg), on_disk)
366        add(reveal_label(), lambda: reveal_in_file_manager(str(pkg.directory)), on_disk)
367        if not is_root:
368            has_manifest = pkg.manifest and pkg.manifest.is_file()
369            add("Open as Project in New Window", lambda: self.open_in_new_window(pkg.manifest), has_manifest,
370                "Open this package as a project in its own window (e.g. to work on a local "
371                "library), keeping this project open")
372            add("Open as Project Here", lambda: self.open_manifest(pkg.manifest), has_manifest,
373                "Replace the project in this window with this package")
374        links = []
375        if pkg.git and (url := repo_web_url(pkg.git)):
376            links.append(("Open Repository", url))
377        if pkg.git and pkg.git_rev and (url := commit_web_url(pkg.git, pkg.git_rev)):
378            links.append((f"Open Checked-out Commit ({pkg.git_rev[:7]})", url))
379        if pkg.homepage:
380            links.append(("Open Homepage", pkg.homepage))
381        if links:
382            menu.addSeparator()
383            for text, url in links:
384                add(text, lambda _=False, u=url: QDesktopServices.openUrl(QUrl(u)))
385        if pkg.git and not is_root and pkg.kind != m.DEV_ONLY:  # (fpm doesn't fetch a dependency's dev-dependency)
386            menu.addSeparator()
387            add("Check for Updates", lambda: self.check_dependency_updates(only=name))
388            add("Update This Dependency", lambda: self.run_fpm(["update", name], False, True), not self.busy(),
389                f"fpm update {name}: fetch it again (or download it if it's missing)")
390        if not is_root:
391            menu.addSeparator()
392            line = self._dependency_line(name)
393            add("Copy Dependency Line", lambda: self._copy_dependency_line(line), line,
394                f"Copy “{line}” to paste into another fpm.toml" if line else "")
395            add("Why Is This Here?", lambda: self._why_is_this_here(name))
396        menu.setToolTipsVisible(True)
397        return menu
398
399    def _show_package_files(self, pkg):
400        if not self.project_tree.reveal_package(pkg.directory):
401            self.statusBar().showMessage(f"{pkg.name} isn't listed in Project Files", 4000)
402
403    def _dependency_line(self, name: str) -> str:
404        """How ``name`` is declared, as one fpm.toml line (e.g. ``fmin = {
405        git = "…", tag = "1.1.3" }``), from the manifest of a package that
406        uses it (preferring the project's own); "" if not found."""
407        parents = sorted((self.graph.packages[e.parent] for e in self.graph.parents(name)
408                          if e.parent in self.graph.packages),
409                         key=lambda p: (p.kind != m.ROOT, p.kind != m.PATH))
410        for parent in parents:
411            try:
412                data = m.load_toml(parent.manifest) if parent.manifest else {}
413            except (OSError, tomllib.TOMLDecodeError):
414                continue
415            tables = [data.get("dependencies"), data.get("dev-dependencies")]
416            for section in ("executable", "test", "example"):
417                tables += [t.get("dependencies") for t in data.get(section) or [] if isinstance(t, dict)]
418            for table in tables:
419                if isinstance(table, dict) and name in table:
420                    return toml_line(name, table[name])
421        return ""
422
423    def _copy_dependency_line(self, line: str):
424        QApplication.clipboard().setText(line)
425        self.statusBar().showMessage(f"Copied: {line}", 5000)
426
427    def _why_is_this_here(self, name: str):
428        """Every chain of dependencies from the project down to ``name``,
429        highlighted in the graph and listed in the details panel."""
430        chains: list[list[str]] = []
431
432        def walk(node, path):
433            if len(chains) >= 50:  # (plenty to see why)
434                return
435            if node == name:
436                chains.append(path)
437                return
438            for edge in self.graph.children(node):
439                if edge.child not in path:
440                    walk(edge.child, [*path, edge.child])
441
442        walk(self.graph.root, [self.graph.root])
443        if not chains:
444            self.statusBar().showMessage(f"{name} isn't reached from {self.graph.root}", 5000)
445            return
446        dev = {(e.parent, e.child) for e in self.graph.edges if e.dev}
447        e = html.escape
448
449        def show(chain):
450            out = e(chain[0])
451            for a, b in pairwise(chain):
452                out += f" → <b>{e(b)}</b>" if b == name else f" → {e(b)}"
453                if (a, b) in dev:
454                    out += " <i>(dev)</i>"
455            return out
456
457        direct = any(len(c) == 2 for c in chains)
458        summary = ("a direct dependency of the project" if direct and len(chains) == 1 else
459                   f"needed through {len(chains)} chain{'s' * (len(chains) != 1)}")
460        self.details.setHtml(f"<h3>Why is {e(name)} here?</h3><p>{e(name)} is {summary}:</p><ul>"
461                             + "".join(f"<li>{show(c)}</li>" for c in chains)
462                             + "</ul><p><i>Click the background of the graph to clear the highlight.</i></p>")
463        self.graph_view.show_chains(name, chains)
464        self.statusBar().showMessage(f"{name}: {summary}", 6000)
465
466    def open_package_manifest(self, name: str):
467        pkg = self.graph.packages.get(name) if self.graph else None
468        if pkg and pkg.manifest:
469            self.open_file(str(pkg.manifest))
def repo_web_url(git_url: str) -> str | None:
41def repo_web_url(git_url: str) -> str | None:
42    """The web page of a GitHub/GitLab/Codeberg/Bitbucket repository."""
43    url = git_url.strip()
44    match = re.match(r"^git@([^:]+):(.+?)(?:\.git)?/?$", url)
45    if match:
46        host, path = match.groups()
47    else:
48        match = re.match(r"^(?:https?|git|ssh)://(?:[^@/]+@)?([^/:]+)(?::\d+)?/(.+?)(?:\.git)?/?$", url)
49        if not match:
50            return None
51        host, path = match.groups()
52    if not any(h in host for h in ("github.", "gitlab.", "codeberg.", "bitbucket.")):
53        return None
54    return f"https://{host}/{path}"

The web page of a GitHub/GitLab/Codeberg/Bitbucket repository.

def commit_web_url(git_url: str, rev: str) -> str | None:
57def commit_web_url(git_url: str, rev: str) -> str | None:
58    base = repo_web_url(git_url)
59    if not base or not re.fullmatch(r"[0-9a-fA-F]{7,40}", rev or ""):
60        return None
61    if "bitbucket." in base:
62        return f"{base}/commits/{rev}"
63    if "gitlab." in base:
64        return f"{base}/-/commit/{rev}"
65    return f"{base}/commit/{rev}"
def compare_web_url(git_url: str, old: str, new: str) -> str | None:
68def compare_web_url(git_url: str, old: str, new: str) -> str | None:
69    """A page listing the commits between two revisions (GitHub/GitLab)."""
70    base = repo_web_url(git_url)
71    if not base or not old or not new:
72        return None
73    if "gitlab." in base:
74        return f"{base}/-/compare/{old}...{new}"
75    if "github." in base or "codeberg." in base:
76        return f"{base}/compare/{old}...{new}"
77    return None

A page listing the commits between two revisions (GitHub/GitLab).

def toml_line(name: str, spec) -> str:
80def toml_line(name: str, spec) -> str:
81    """A dependency as one fpm.toml line: ``name = "1.0"`` or
82    ``name = { git = "…", tag = "…" }``."""
83    def key(k):
84        return k if re.fullmatch(r"[A-Za-z0-9_-]+", str(k)) else json.dumps(str(k))
85
86    def value(v):
87        if isinstance(v, bool):
88            return "true" if v else "false"
89        if isinstance(v, (int, float)):
90            return str(v)
91        if isinstance(v, dict):
92            return "{ " + ", ".join(f"{key(k)} = {value(x)}" for k, x in v.items()) + " }"
93        if isinstance(v, list):
94            return "[" + ", ".join(value(x) for x in v) + "]"
95        return json.dumps(str(v))  # (a TOML basic string)
96
97    return f"{key(name)} = {value(spec)}"

A dependency as one fpm.toml line: name = "1.0" or name = { git = "…", tag = "…" }.

class PackagesMixin:
100class PackagesMixin:
101    """The dependency graph and its packages (a part of `vibefpmgui.main_window.MainWindow`)."""
102
103    def _build_graph_panel(self):
104        """The Dependency graph panel: the graph, the selected package's details and the legend."""
105        self.graph_view = DependencyGraphView()
106        self.graph_view.package_selected.connect(self.show_package)
107        self.graph_view.package_activated.connect(self.open_package_manifest)
108        self.graph_view.package_context_menu.connect(self._package_menu)
109
110        self.graph_menu = self._build_graph_menu()
111        self.menuBar().addMenu(self.graph_menu)
112
113        self.legend = QLabel()
114        self.legend.setWordWrap(True)
115
116        self.details = QTextBrowser()
117        self.details.setOpenLinks(False)
118        self.details.anchorClicked.connect(QDesktopServices.openUrl)
119        graph_split = QSplitter(Qt.Horizontal)
120        graph_split.addWidget(self.graph_view)
121        graph_split.addWidget(self.details)
122        graph_split.setStretchFactor(0, 3)
123        graph_split.setStretchFactor(1, 1)
124        graph_split.setSizes([700, 300])
125
126        graph_tab = QWidget()
127        gl = QVBoxLayout(graph_tab)
128        gl.addWidget(graph_split, 1)
129        gl.addWidget(self.legend)
130        return graph_tab
131
132    def _build_graph_menu(self) -> QMenu:
133        """Graph menu: layout, theme and edge style choices (radio groups)."""
134        menu = QMenu("&Graph", self)
135        self.graph_style_actions: dict[str, dict[str, QAction]] = {}
136        for title, key, choices, setter in [
137            ("Layout", "layout", LAYOUTS, self._set_graph_layout),
138            ("Theme", "theme", THEME_CHOICES, self._set_graph_theme),
139            ("Edges", "edges", EDGE_STYLES, self._set_graph_edges),
140        ]:
141            sub = menu.addMenu(title)
142            group = QActionGroup(self)
143            group.setExclusive(True)
144            self.graph_style_actions[key] = {}
145            for value, label in choices.items():
146                action = QAction(label, self, checkable=True)
147                action.triggered.connect(lambda _=False, v=value, f=setter: f(v))
148                group.addAction(action)
149                sub.addAction(action)
150                self.graph_style_actions[key][value] = action
151        menu.addSeparator()
152        self.dependencies_dev_action = QAction("Show Dependencies' Dev-Dependencies", self, checkable=True)
153        self.dependencies_dev_action.setChecked(self.settings.value("graph_dependencies_dev", False, type=bool))
154        self.dependencies_dev_action.setStatusTip(
155            "Also show what the dependencies list as dev-dependencies (fpm doesn't download those: "
156            "only the project's own)")
157        self.dependencies_dev_action.toggled.connect(self._set_dependencies_dev)
158        menu.addAction(self.dependencies_dev_action)
159        menu.addSeparator()
160        for text, slot, shortcut in [("Fit to window", lambda: self.graph_view.fit(), "Ctrl+0"),
161                                     ("Re-layout", lambda: self.graph_view.relayout(), "Ctrl+Shift+L"),
162                                     ("Export image…", self.export_graph, "Ctrl+E")]:
163            action = QAction(text, self)
164            action.setShortcut(QKeySequence(shortcut))
165            action.triggered.connect(slot)
166            menu.addAction(action)
167        return menu
168
169    def _check_style_action(self, key: str, value: str):
170        action = self.graph_style_actions[key].get(value)
171        if action:
172            action.setChecked(True)
173
174    def _set_graph_layout(self, value: str):
175        self.graph_view.set_layout(value)
176        self._check_style_action("layout", value)
177        self.settings.setValue("graph_layout", value)
178
179    def _set_graph_theme(self, value: str):
180        self.graph_view.set_theme(value)
181        self._check_style_action("theme", value)
182        self.settings.setValue("graph_theme", value)
183        self._update_legend()
184        self.build_monitor.set_package_colors(self.graph_view.package_colors())
185
186    def _set_graph_edges(self, value: str):
187        self.graph_view.set_edge_style(value)
188        self._check_style_action("edges", value)
189        self.settings.setValue("graph_edges", value)
190
191    def _set_dependencies_dev(self, shown: bool):
192        self.settings.setValue("graph_dependencies_dev", shown)
193        self.reload()
194
195    def _update_legend(self):
196        self.legend.setText(self.graph_view.legend_html())
197
198    def export_graph(self):
199        if not self.graph:
200            return
201        default = str((self.manifest_path.parent if self.manifest_path else Path.home())
202                      / f"{self.graph.root}-dependencies.png")
203        filename, _ = QFileDialog.getSaveFileName(
204            self, "Export dependency graph", default, "PNG image (*.png);;SVG image (*.svg)")
205        if filename:
206            if self.graph_view.export_image(filename):
207                self.statusBar().showMessage(f"Saved {filename}")
208            else:
209                QMessageBox.warning(self, "Export failed", f"Could not write {filename}")
210
211    def check_dependency_updates(self, only: str | None = None):
212        """Commands → Check for Dependency Updates… (only runs when asked);
213        ``only``: just that dependency (from its menu in the graph)."""
214        if not self.graph:
215            return
216        checks = dep_updates.git_dependencies(self.graph)
217        if only:
218            checks = [c for c in checks if c.name == only]
219        dialog = dep_updates.UpdatesDialog(checks, compare_web_url, self)
220        dialog.update_tag_requested.connect(lambda c: self._update_dependency_tag(c, dialog))
221        dialog.update_deps_requested.connect(lambda: (dialog.accept(), self.run_fpm(["update"], False, True)))
222        if checks:
223            self._update_check_thread = dep_updates.start_check(checks, dialog, self)
224        run_dialog(dialog)  # (results still coming then go nowhere: Qt disconnects them)
225
226    def _update_dependency_tag(self, check, dialog):
227        """Point one dependency at its newest tag in its fpm.toml."""
228        if self.busy():
229            QMessageBox.information(dialog, "Update dependency", "Wait for the running command to finish first.")
230            return
231        # A tag pin moves to the newest tag; a commit pin becomes that tag.
232        if not dep_updates.set_ref(check.declared_in, check.name, check.ref_kind, check.ref,
233                                   "tag", check.latest_tag):
234            QMessageBox.warning(
235                dialog, "Update dependency",
236                f"Couldn't find {check.ref_kind} = \"{check.ref}\" for {check.name} in "
237                f"{check.declared_in} to change it automatically; it is opened in the editor instead.")
238            dialog.accept()
239            self.open_file(str(check.declared_in), force_editor=True)
240            return
241        was = check.ref if check.ref_kind == "tag" else f"commit {check.ref[:7]}"
242        self.statusBar().showMessage(f"{check.name}: {was} → tag {check.latest_tag} in "
243                                     f"{Path(check.declared_in).name}", 6000)
244        answer = QMessageBox.question(
245            dialog, "Update dependency",
246            f"{check.name} now asks for tag {check.latest_tag} (was {was}) in {check.declared_in}.\n\n"
247            "Run Update deps now to download it?")
248        dialog.accept()
249        self.reload()  # fpm.toml changed
250        if answer == QMessageBox.Yes:
251            self.run_fpm(["update"], False, True)
252
253    def _report_dependency_changes(self):
254        """After "fpm update": which dependencies moved to another commit."""
255        before, self._revs_before = getattr(self, "_revs_before", None), None
256        if before is None or not self.manifest_path:
257            return
258        after = dep_updates.read_cache_revs(self.manifest_path.parent)
259        changed = {n: (before.get(n), rev) for n, rev in after.items() if before.get(n) != rev}
260        if not changed:
261            self._append("\nNo dependency changed commit.\n")
262            return
263        lines = ["\nDependency changes:"]
264        for name, (old, new) in sorted(changed.items()):
265            pkg = self.graph.packages.get(name) if self.graph else None
266            url = compare_web_url(pkg.git, old, new) if pkg and pkg.git and old else None
267            lines.append(f"  {name}: {(old or 'new')[:7]} → {new[:7]}" + (f"  {url}" if url else ""))
268        self._append("\n".join(lines) + "\n", bold=False)
269        self.statusBar().showMessage(f"Update deps: {len(changed)} dependenc"
270                                     f"{'ies' if len(changed) != 1 else 'y'} changed", 6000)
271
272    def show_package(self, name: str):
273        if not self.graph or name not in self.graph.packages:
274            return
275        pkg = self.graph.packages[name]
276        e = html.escape
277        rows = [("Type", KIND_LABELS[pkg.kind])]
278        # The version: a dependency's own fpm.toml's (which the tag it's pinned to,
279        # below, takes precedence over), or else the one asked for or fpm recorded.
280        own = pkg.manifest_version if pkg.kind != m.ROOT else ""
281        other = pkg.version if pkg.version != own else ""
282        for label, value in [("Version", other), ("Manifest version", own), ("License", pkg.license),
283                             ("Author", pkg.author)]:
284            if value:
285                rows.append((label, e(value)))
286        if pkg.git:
287            web = repo_web_url(pkg.git)
288            rows.append(("Git", f'<a href="{e(web)}">{e(pkg.git)}</a>' if web else e(pkg.git)))
289        if pkg.git_ref:
290            rows.append(("Requested", e(pkg.git_ref)))
291        if pkg.git_rev:
292            # The exact commit fpm checked out (from build/cache.toml).
293            commit_url = commit_web_url(pkg.git, pkg.git_rev)
294            short = e(pkg.git_rev[:7])
295            rows.append(("Commit", (f'<a href="{e(commit_url)}" title="{e(pkg.git_rev)}">{short}</a>'
296                                    if commit_url else f'<span title="{e(pkg.git_rev)}">{short}</span>')
297                         + f' <span style="color:gray">({e(pkg.git_rev)})</span>'))
298        if pkg.homepage:
299            rows.append(("Homepage", f'<a href="{e(pkg.homepage)}">{e(pkg.homepage)}</a>'))
300        if pkg.directory:
301            url = QUrl.fromLocalFile(str(pkg.directory)).toString()
302            rows.append(("Location", f'<a href="{e(url)}">{e(str(pkg.directory))}</a>'))
303        deps = self.graph.children(name)
304        users = self.graph.parents(name)
305        if deps:
306            rows.append(("Depends on", ", ".join(
307                e(d.child) + (" <i>(dev)</i>" if d.dev else "") for d in deps)))
308        if users:
309            rows.append(("Used by", ", ".join(e(u.parent) + (" <i>(dev)</i>" if u.dev else "") for u in users)))
310        table = "".join(f"<tr><td valign='top'><b>{k}</b>&nbsp;</td><td>{v}</td></tr>"
311                        for k, v in rows)
312        body = f"<h3>{e(pkg.name)}</h3>"
313        if pkg.description:
314            body += f"<p>{e(pkg.description)}</p>"
315        body += f"<table>{table}</table>"
316        if pkg.error:
317            body += f"<p style='color:#c92a2a'>{e(pkg.error)}</p>"
318        conflict = m.conflicts(self.graph).get(name)
319        if conflict:
320            used = m.spec_key(conflict[0])
321            items = "".join(f"<li><b>{e(d.parent)}</b>{' (dev)' if d.dev else ''} asks for "
322                            f"{e(m.describe_spec(d.spec))}"
323                            + (" — <b>used</b>" if i == 0 else " <i>(the same)</i>" if m.spec_key(d) == used
324                               else "") + "</li>"
325                            for i, d in enumerate(conflict))
326            # In a red box, so it stands out (Qt's rich text draws borders on tables only).
327            dark = self.details.palette().color(QPalette.Base).lightness() < 128
328            red, fill = ("#ff6b6b", "#3d2224") if dark else ("#e03131", "#fff0f0")
329            body += (f"<table width='100%' cellpadding='6' cellspacing='0' "
330                     f"style='margin-top:10px; border:1.5px solid {red}; border-collapse:collapse'><tr><td bgcolor='{fill}'>"
331                     f"<p style='color:{red}; margin-top:0'><b>⚠ Version conflict</b></p>"
332                     f"<p>{e(name)} is asked for in different ways:</p><ul>{items}</ul>"
333                     "<p style='margin-bottom:0'>fpm uses only one of them (the declaration closest "
334                     "to the project), so the others get a version they didn't ask for. Pinning the "
335                     "same version everywhere — or overriding it in the project's own fpm.toml — "
336                     "avoids surprises.</p></td></tr></table>")
337        if pkg.manifest:
338            body += "<p><i>Double-click the node to open its fpm.toml.</i></p>"
339        self.details.setHtml(body)
340
341    def _package_menu(self, name: str, pos):
342        """Right-click on a package in the dependency graph."""
343        menu = self.package_menu(name)
344        if menu is not None:
345            menu.exec(pos)
346            menu.deleteLater()
347
348    def package_menu(self, name: str) -> QMenu | None:
349        """The menu for a package in the dependency graph (None: not one)."""
350        pkg = self.graph.packages.get(name) if self.graph else None
351        if pkg is None:
352            return None
353        is_root = pkg.kind == m.ROOT
354        on_disk = pkg.directory is not None and pkg.directory.is_dir()
355        menu = QMenu(self)
356
357        def add(text, slot, enabled=True, tip=""):
358            action = menu.addAction(text)
359            action.triggered.connect(slot)
360            action.setEnabled(bool(enabled))
361            if tip:
362                action.setToolTip(tip)
363            return action
364
365        add("Open fpm.toml", lambda: self.open_package_manifest(name), pkg.manifest and pkg.manifest.is_file())
366        add("Show in Project Files", lambda: self._show_package_files(pkg), on_disk)
367        add(reveal_label(), lambda: reveal_in_file_manager(str(pkg.directory)), on_disk)
368        if not is_root:
369            has_manifest = pkg.manifest and pkg.manifest.is_file()
370            add("Open as Project in New Window", lambda: self.open_in_new_window(pkg.manifest), has_manifest,
371                "Open this package as a project in its own window (e.g. to work on a local "
372                "library), keeping this project open")
373            add("Open as Project Here", lambda: self.open_manifest(pkg.manifest), has_manifest,
374                "Replace the project in this window with this package")
375        links = []
376        if pkg.git and (url := repo_web_url(pkg.git)):
377            links.append(("Open Repository", url))
378        if pkg.git and pkg.git_rev and (url := commit_web_url(pkg.git, pkg.git_rev)):
379            links.append((f"Open Checked-out Commit ({pkg.git_rev[:7]})", url))
380        if pkg.homepage:
381            links.append(("Open Homepage", pkg.homepage))
382        if links:
383            menu.addSeparator()
384            for text, url in links:
385                add(text, lambda _=False, u=url: QDesktopServices.openUrl(QUrl(u)))
386        if pkg.git and not is_root and pkg.kind != m.DEV_ONLY:  # (fpm doesn't fetch a dependency's dev-dependency)
387            menu.addSeparator()
388            add("Check for Updates", lambda: self.check_dependency_updates(only=name))
389            add("Update This Dependency", lambda: self.run_fpm(["update", name], False, True), not self.busy(),
390                f"fpm update {name}: fetch it again (or download it if it's missing)")
391        if not is_root:
392            menu.addSeparator()
393            line = self._dependency_line(name)
394            add("Copy Dependency Line", lambda: self._copy_dependency_line(line), line,
395                f"Copy “{line}” to paste into another fpm.toml" if line else "")
396            add("Why Is This Here?", lambda: self._why_is_this_here(name))
397        menu.setToolTipsVisible(True)
398        return menu
399
400    def _show_package_files(self, pkg):
401        if not self.project_tree.reveal_package(pkg.directory):
402            self.statusBar().showMessage(f"{pkg.name} isn't listed in Project Files", 4000)
403
404    def _dependency_line(self, name: str) -> str:
405        """How ``name`` is declared, as one fpm.toml line (e.g. ``fmin = {
406        git = "…", tag = "1.1.3" }``), from the manifest of a package that
407        uses it (preferring the project's own); "" if not found."""
408        parents = sorted((self.graph.packages[e.parent] for e in self.graph.parents(name)
409                          if e.parent in self.graph.packages),
410                         key=lambda p: (p.kind != m.ROOT, p.kind != m.PATH))
411        for parent in parents:
412            try:
413                data = m.load_toml(parent.manifest) if parent.manifest else {}
414            except (OSError, tomllib.TOMLDecodeError):
415                continue
416            tables = [data.get("dependencies"), data.get("dev-dependencies")]
417            for section in ("executable", "test", "example"):
418                tables += [t.get("dependencies") for t in data.get(section) or [] if isinstance(t, dict)]
419            for table in tables:
420                if isinstance(table, dict) and name in table:
421                    return toml_line(name, table[name])
422        return ""
423
424    def _copy_dependency_line(self, line: str):
425        QApplication.clipboard().setText(line)
426        self.statusBar().showMessage(f"Copied: {line}", 5000)
427
428    def _why_is_this_here(self, name: str):
429        """Every chain of dependencies from the project down to ``name``,
430        highlighted in the graph and listed in the details panel."""
431        chains: list[list[str]] = []
432
433        def walk(node, path):
434            if len(chains) >= 50:  # (plenty to see why)
435                return
436            if node == name:
437                chains.append(path)
438                return
439            for edge in self.graph.children(node):
440                if edge.child not in path:
441                    walk(edge.child, [*path, edge.child])
442
443        walk(self.graph.root, [self.graph.root])
444        if not chains:
445            self.statusBar().showMessage(f"{name} isn't reached from {self.graph.root}", 5000)
446            return
447        dev = {(e.parent, e.child) for e in self.graph.edges if e.dev}
448        e = html.escape
449
450        def show(chain):
451            out = e(chain[0])
452            for a, b in pairwise(chain):
453                out += f" → <b>{e(b)}</b>" if b == name else f" → {e(b)}"
454                if (a, b) in dev:
455                    out += " <i>(dev)</i>"
456            return out
457
458        direct = any(len(c) == 2 for c in chains)
459        summary = ("a direct dependency of the project" if direct and len(chains) == 1 else
460                   f"needed through {len(chains)} chain{'s' * (len(chains) != 1)}")
461        self.details.setHtml(f"<h3>Why is {e(name)} here?</h3><p>{e(name)} is {summary}:</p><ul>"
462                             + "".join(f"<li>{show(c)}</li>" for c in chains)
463                             + "</ul><p><i>Click the background of the graph to clear the highlight.</i></p>")
464        self.graph_view.show_chains(name, chains)
465        self.statusBar().showMessage(f"{name}: {summary}", 6000)
466
467    def open_package_manifest(self, name: str):
468        pkg = self.graph.packages.get(name) if self.graph else None
469        if pkg and pkg.manifest:
470            self.open_file(str(pkg.manifest))

The dependency graph and its packages (a part of vibefpmgui.main_window.MainWindow).

def export_graph(self):
198    def export_graph(self):
199        if not self.graph:
200            return
201        default = str((self.manifest_path.parent if self.manifest_path else Path.home())
202                      / f"{self.graph.root}-dependencies.png")
203        filename, _ = QFileDialog.getSaveFileName(
204            self, "Export dependency graph", default, "PNG image (*.png);;SVG image (*.svg)")
205        if filename:
206            if self.graph_view.export_image(filename):
207                self.statusBar().showMessage(f"Saved {filename}")
208            else:
209                QMessageBox.warning(self, "Export failed", f"Could not write {filename}")
def check_dependency_updates(self, only: str | None = None):
211    def check_dependency_updates(self, only: str | None = None):
212        """Commands → Check for Dependency Updates… (only runs when asked);
213        ``only``: just that dependency (from its menu in the graph)."""
214        if not self.graph:
215            return
216        checks = dep_updates.git_dependencies(self.graph)
217        if only:
218            checks = [c for c in checks if c.name == only]
219        dialog = dep_updates.UpdatesDialog(checks, compare_web_url, self)
220        dialog.update_tag_requested.connect(lambda c: self._update_dependency_tag(c, dialog))
221        dialog.update_deps_requested.connect(lambda: (dialog.accept(), self.run_fpm(["update"], False, True)))
222        if checks:
223            self._update_check_thread = dep_updates.start_check(checks, dialog, self)
224        run_dialog(dialog)  # (results still coming then go nowhere: Qt disconnects them)

Commands → Check for Dependency Updates… (only runs when asked); only: just that dependency (from its menu in the graph).

def show_package(self, name: str):
272    def show_package(self, name: str):
273        if not self.graph or name not in self.graph.packages:
274            return
275        pkg = self.graph.packages[name]
276        e = html.escape
277        rows = [("Type", KIND_LABELS[pkg.kind])]
278        # The version: a dependency's own fpm.toml's (which the tag it's pinned to,
279        # below, takes precedence over), or else the one asked for or fpm recorded.
280        own = pkg.manifest_version if pkg.kind != m.ROOT else ""
281        other = pkg.version if pkg.version != own else ""
282        for label, value in [("Version", other), ("Manifest version", own), ("License", pkg.license),
283                             ("Author", pkg.author)]:
284            if value:
285                rows.append((label, e(value)))
286        if pkg.git:
287            web = repo_web_url(pkg.git)
288            rows.append(("Git", f'<a href="{e(web)}">{e(pkg.git)}</a>' if web else e(pkg.git)))
289        if pkg.git_ref:
290            rows.append(("Requested", e(pkg.git_ref)))
291        if pkg.git_rev:
292            # The exact commit fpm checked out (from build/cache.toml).
293            commit_url = commit_web_url(pkg.git, pkg.git_rev)
294            short = e(pkg.git_rev[:7])
295            rows.append(("Commit", (f'<a href="{e(commit_url)}" title="{e(pkg.git_rev)}">{short}</a>'
296                                    if commit_url else f'<span title="{e(pkg.git_rev)}">{short}</span>')
297                         + f' <span style="color:gray">({e(pkg.git_rev)})</span>'))
298        if pkg.homepage:
299            rows.append(("Homepage", f'<a href="{e(pkg.homepage)}">{e(pkg.homepage)}</a>'))
300        if pkg.directory:
301            url = QUrl.fromLocalFile(str(pkg.directory)).toString()
302            rows.append(("Location", f'<a href="{e(url)}">{e(str(pkg.directory))}</a>'))
303        deps = self.graph.children(name)
304        users = self.graph.parents(name)
305        if deps:
306            rows.append(("Depends on", ", ".join(
307                e(d.child) + (" <i>(dev)</i>" if d.dev else "") for d in deps)))
308        if users:
309            rows.append(("Used by", ", ".join(e(u.parent) + (" <i>(dev)</i>" if u.dev else "") for u in users)))
310        table = "".join(f"<tr><td valign='top'><b>{k}</b>&nbsp;</td><td>{v}</td></tr>"
311                        for k, v in rows)
312        body = f"<h3>{e(pkg.name)}</h3>"
313        if pkg.description:
314            body += f"<p>{e(pkg.description)}</p>"
315        body += f"<table>{table}</table>"
316        if pkg.error:
317            body += f"<p style='color:#c92a2a'>{e(pkg.error)}</p>"
318        conflict = m.conflicts(self.graph).get(name)
319        if conflict:
320            used = m.spec_key(conflict[0])
321            items = "".join(f"<li><b>{e(d.parent)}</b>{' (dev)' if d.dev else ''} asks for "
322                            f"{e(m.describe_spec(d.spec))}"
323                            + (" — <b>used</b>" if i == 0 else " <i>(the same)</i>" if m.spec_key(d) == used
324                               else "") + "</li>"
325                            for i, d in enumerate(conflict))
326            # In a red box, so it stands out (Qt's rich text draws borders on tables only).
327            dark = self.details.palette().color(QPalette.Base).lightness() < 128
328            red, fill = ("#ff6b6b", "#3d2224") if dark else ("#e03131", "#fff0f0")
329            body += (f"<table width='100%' cellpadding='6' cellspacing='0' "
330                     f"style='margin-top:10px; border:1.5px solid {red}; border-collapse:collapse'><tr><td bgcolor='{fill}'>"
331                     f"<p style='color:{red}; margin-top:0'><b>⚠ Version conflict</b></p>"
332                     f"<p>{e(name)} is asked for in different ways:</p><ul>{items}</ul>"
333                     "<p style='margin-bottom:0'>fpm uses only one of them (the declaration closest "
334                     "to the project), so the others get a version they didn't ask for. Pinning the "
335                     "same version everywhere — or overriding it in the project's own fpm.toml — "
336                     "avoids surprises.</p></td></tr></table>")
337        if pkg.manifest:
338            body += "<p><i>Double-click the node to open its fpm.toml.</i></p>"
339        self.details.setHtml(body)
def package_menu(self, name: str) -> PySide6.QtWidgets.QMenu | None:
348    def package_menu(self, name: str) -> QMenu | None:
349        """The menu for a package in the dependency graph (None: not one)."""
350        pkg = self.graph.packages.get(name) if self.graph else None
351        if pkg is None:
352            return None
353        is_root = pkg.kind == m.ROOT
354        on_disk = pkg.directory is not None and pkg.directory.is_dir()
355        menu = QMenu(self)
356
357        def add(text, slot, enabled=True, tip=""):
358            action = menu.addAction(text)
359            action.triggered.connect(slot)
360            action.setEnabled(bool(enabled))
361            if tip:
362                action.setToolTip(tip)
363            return action
364
365        add("Open fpm.toml", lambda: self.open_package_manifest(name), pkg.manifest and pkg.manifest.is_file())
366        add("Show in Project Files", lambda: self._show_package_files(pkg), on_disk)
367        add(reveal_label(), lambda: reveal_in_file_manager(str(pkg.directory)), on_disk)
368        if not is_root:
369            has_manifest = pkg.manifest and pkg.manifest.is_file()
370            add("Open as Project in New Window", lambda: self.open_in_new_window(pkg.manifest), has_manifest,
371                "Open this package as a project in its own window (e.g. to work on a local "
372                "library), keeping this project open")
373            add("Open as Project Here", lambda: self.open_manifest(pkg.manifest), has_manifest,
374                "Replace the project in this window with this package")
375        links = []
376        if pkg.git and (url := repo_web_url(pkg.git)):
377            links.append(("Open Repository", url))
378        if pkg.git and pkg.git_rev and (url := commit_web_url(pkg.git, pkg.git_rev)):
379            links.append((f"Open Checked-out Commit ({pkg.git_rev[:7]})", url))
380        if pkg.homepage:
381            links.append(("Open Homepage", pkg.homepage))
382        if links:
383            menu.addSeparator()
384            for text, url in links:
385                add(text, lambda _=False, u=url: QDesktopServices.openUrl(QUrl(u)))
386        if pkg.git and not is_root and pkg.kind != m.DEV_ONLY:  # (fpm doesn't fetch a dependency's dev-dependency)
387            menu.addSeparator()
388            add("Check for Updates", lambda: self.check_dependency_updates(only=name))
389            add("Update This Dependency", lambda: self.run_fpm(["update", name], False, True), not self.busy(),
390                f"fpm update {name}: fetch it again (or download it if it's missing)")
391        if not is_root:
392            menu.addSeparator()
393            line = self._dependency_line(name)
394            add("Copy Dependency Line", lambda: self._copy_dependency_line(line), line,
395                f"Copy “{line}” to paste into another fpm.toml" if line else "")
396            add("Why Is This Here?", lambda: self._why_is_this_here(name))
397        menu.setToolTipsVisible(True)
398        return menu

The menu for a package in the dependency graph (None: not one).

def open_package_manifest(self, name: str):
467    def open_package_manifest(self, name: str):
468        pkg = self.graph.packages.get(name) if self.graph else None
469        if pkg and pkg.manifest:
470            self.open_file(str(pkg.manifest))