Coverage for vibefpmgui/problems_panel.py: 93%
104 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-01 20:51 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-01 20:51 +0000
1"""The *Problems* tab: everything flagged in the file in the editor.
3The last build's compiler messages, fortitude's and fortls's issues,
4misspelled words, gremlins (non-ASCII and invisible characters) and the AI
5review's findings, in line order, drawn like
6the Search tab's results (`vibefpmgui.result_tree`). A button per tool
7(with its count) shows or hides its problems. Click a problem to go there.
8"""
10from __future__ import annotations
12from pathlib import Path
14from qtpy.QtCore import Qt, Signal
15from qtpy.QtWidgets import (
16 QApplication,
17 QHBoxLayout,
18 QMenu,
19 QToolButton,
20 QVBoxLayout,
21 QWidget,
22)
24from .result_tree import RESULT_ROLE, Problem, ResultTree
25from .widgets import StatusLabel
27# (source, button label), in the order the buttons are shown.
28SOURCES = [("build", "Build"), ("fortitude", "fortitude"), ("fortls", "fortls"),
29 ("spelling", "Spelling"), ("gremlins", "Gremlins"), ("review", "AI review")]
30HINT = "Open a file in the editor to see its problems here."
31EXPLAINABLE = ("build", "fortitude", "fortls") # (sources Explain with AI is offered for)
34class ProblemsPanel(QWidget):
35 open_requested = Signal(str, int, int, int, int) # file, line, column, end line, end column
36 explain_requested = Signal(object) # a Problem (right-click → Explain with AI)
37 fix_requested = Signal(object, object) # its file's path and a Problem (right-click → Fix with AI…)
38 apply_fix_requested = Signal(object, object) # its file's path and a Problem (right-click → Apply Fix)
40 def __init__(self, settings=None, parent=None):
41 super().__init__(parent)
42 self.settings = settings
43 self.path: Path | None = None
44 self.problems: list[Problem] = []
45 self.labels: tuple[str, str] = ("", "") # (folder, package) shown after the file's name
46 self.title = StatusLabel(HINT)
47 self.title.setContentsMargins(8, 4, 8, 2)
48 self.buttons: dict[str, QToolButton] = {}
49 header = QHBoxLayout()
50 header.setContentsMargins(0, 0, 6, 0)
51 header.setSpacing(4)
52 header.addWidget(self.title, 1)
53 for source, label in SOURCES:
54 button = QToolButton()
55 button.setCheckable(True)
56 button.setAutoRaise(True)
57 shown = settings.value(f"problems/{source}", True, type=bool) if settings is not None else True
58 button.setChecked(shown)
59 button.setToolTip(f"Show or hide {label}'s problems")
60 button.toggled.connect(self._filter_changed)
61 button.setProperty("label", label)
62 self.buttons[source] = button
63 header.addWidget(button)
64 self.tree = ResultTree()
65 self.tree.set_wrapping(True) # (long messages wrap: an AI review's can be a few sentences)
66 self.tree.open_requested.connect(self.open_requested)
67 self.tree.setContextMenuPolicy(Qt.CustomContextMenu)
68 self.tree.customContextMenuRequested.connect(self._context_menu)
69 layout = QVBoxLayout(self)
70 layout.setContentsMargins(0, 0, 0, 0)
71 layout.setSpacing(2)
72 layout.addLayout(header)
73 layout.addWidget(self.tree, 1)
74 self.show_problems(None, [])
76 def _context_menu(self, pos):
77 menu = self.context_menu(pos)
78 if menu is not None:
79 menu.exec(self.tree.viewport().mapToGlobal(pos))
80 menu.deleteLater()
82 def context_menu(self, pos) -> QMenu | None:
83 """A problem's right-click menu (None: not on a problem)."""
84 item = self.tree.itemAt(pos)
85 data = item.data(0, RESULT_ROLE) if item is not None else None
86 path, problem = data if data else (None, None)
87 if not isinstance(problem, Problem): 87 ↛ 88line 87 didn't jump to line 88 because the condition on line 87 was never true
88 return None
89 menu = QMenu(self)
90 issue = problem.issue
91 if issue is not None and issue.fixable: # (fortitude's own fix, as its hover card's Apply fix)
92 label = f"Apply Fix: {issue.fix or issue.message}" + ("" if issue.fix_safe else " (unsafe — check it)")
93 apply = menu.addAction(label, lambda: self.apply_fix_requested.emit(path, problem))
94 apply.setToolTip("Change the code as fortitude suggests (one Undo step)")
95 menu.addSeparator()
96 explain = menu.addAction("Explain with AI", lambda: self.explain_requested.emit(problem))
97 explain.setEnabled(problem.source in EXPLAINABLE)
98 explain.setToolTip("Ask the local AI model what this means here, and how to fix it")
99 fix = menu.addAction("Fix with AI…", lambda: self.fix_requested.emit(path, problem))
100 fix.setEnabled(problem.source in (*EXPLAINABLE, "review"))
101 fix.setToolTip("Ask the local AI model for an edit that addresses this (you accept or reject "
102 "each change)")
103 menu.addAction("Copy Message", lambda: QApplication.clipboard().setText(problem.message))
104 menu.setToolTipsVisible(True)
105 return menu
107 def _filter_changed(self):
108 if self.settings is not None: 108 ↛ 111line 108 didn't jump to line 111 because the condition on line 108 was always true
109 for source, button in self.buttons.items():
110 self.settings.setValue(f"problems/{source}", button.isChecked())
111 self._refresh()
113 def show_problems(self, path: Path | None, problems: list[Problem], folder: str = "", package: str = ""):
114 """The file in the editor and its problems (``path`` None: no file)."""
115 self.path, self.problems, self.labels = path, list(problems), (folder, package)
116 self._refresh()
118 def shown(self) -> list[Problem]:
119 """The problems whose tool's button is on."""
120 return [p for p in self.problems if self.buttons[p.source].isChecked()]
122 def _refresh(self):
123 counts = {source: 0 for source in self.buttons}
124 for p in self.problems:
125 counts[p.source] += 1
126 for source, button in self.buttons.items():
127 button.setText(f"{button.property('label')} {counts[source]}")
128 button.setVisible(counts[source] > 0 or not button.isChecked()) # (a hidden kind stays findable)
129 if self.path is None:
130 self.tree.clear_results()
131 self.title.setText(HINT)
132 return
133 shown = self.shown()
134 self.tree.set_problems(self.path, shown, *self.labels)
135 total = len(self.problems)
136 if not total:
137 self.title.setText(f"No problems in <b>{self.path.name}</b>")
138 else:
139 hidden = total - len(shown)
140 self.title.setText(f"<b>{total}</b> problem{'s' * (total != 1)}"
141 + (f" ({hidden} hidden)" if hidden else ""))