vibefpmgui.window.project

The project: opening an fpm.toml, recent projects, the open files' session.

Also the File menu, the Project files panel and reloading the dependency graph.

  1"""The project: opening an fpm.toml, recent projects, the open files' session.
  2
  3Also the File menu, the Project files panel and reloading the dependency graph.
  4"""
  5
  6from __future__ import annotations
  7
  8import json
  9import os
 10import tomllib
 11from pathlib import Path
 12
 13from qtpy.QtCore import QProcess, QProcessEnvironment, QTimer
 14from qtpy.QtGui import QAction, QKeySequence
 15from qtpy.QtWidgets import QDialog, QFileDialog, QMessageBox, QVBoxLayout, QWidget
 16
 17from .. import build_configs as bc
 18from .. import console
 19from .. import manifest as m
 20from ..background import call_in_background
 21from ..console import gui_python
 22from ..editor import EditorTab
 23from ..new_project import NewProjectDialog
 24from ..project_tree import ProjectTree
 25from ..widgets import run_dialog
 26from .options import project_key
 27
 28
 29def _signature(graph: m.DependencyGraph):
 30    return (sorted((p.name, p.kind, p.version) for p in graph.packages.values()),
 31            sorted((e.parent, e.child, e.dev) for e in graph.edges))
 32
 33
 34class ProjectMixin:
 35    """The open project and its session (a part of `vibefpmgui.main_window.MainWindow`)."""
 36
 37    def _build_file_menu(self):
 38        """File menu."""
 39        open_action = QAction("Open Project…", self)
 40        open_action.setShortcut(QKeySequence.Open)
 41        open_action.setStatusTip("Open an fpm project: a folder with an fpm.toml (and its vibefpm.json, if it has one)")
 42        open_action.triggered.connect(lambda: self.browse_project())
 43        reload_action = QAction("Reload", self)
 44        reload_action.setShortcut(QKeySequence("Ctrl+Shift+R"))  # Ctrl/⌘+R is Run
 45        reload_action.triggered.connect(self.reload)
 46        open_new_action = QAction("Open Project in New Window…", self)
 47        open_new_action.setShortcut(QKeySequence("Ctrl+Shift+N"))  # (⌘⇧O: Open File in Editor)
 48        open_new_action.setStatusTip("Open another project in its own window, keeping this one open")
 49        open_new_action.triggered.connect(lambda: self.browse_project(new_window=True))
 50        new_project_action = QAction("New fpm Project…", self)
 51        new_project_action.setStatusTip("Make a new fpm project (fpm new), and open it")
 52        new_project_action.triggered.connect(self.new_project)
 53        self.new_file_action = QAction("New File", self)
 54        self.new_file_action.setShortcut(QKeySequence.New)
 55        self.new_file_action.setStatusTip("An empty file in a new editor tab (it gets its name, and its type, "
 56                                          "when it's saved)")
 57        self.new_file_action.triggered.connect(self.new_file)
 58        open_file_action = QAction("Open File in Editor…", self)
 59        open_file_action.setShortcut(QKeySequence("Ctrl+Shift+O"))
 60        open_file_action.triggered.connect(self.browse_file)
 61        self.save_action = QAction("Save", self)
 62        self.save_action.setShortcut(QKeySequence.Save)
 63        self.save_action.triggered.connect(lambda: self._with_editor(lambda e: e.save()))
 64        self.save_as_action = QAction("Save As…", self)
 65        self.save_as_action.setShortcut(QKeySequence("Ctrl+Shift+S"))
 66        self.save_as_action.triggered.connect(lambda: self._with_editor(lambda e: e.save_as()))
 67        self.close_tab_action = QAction("Close Tab", self)
 68        self.close_tab_action.setShortcut(QKeySequence.Close)
 69        self.close_tab_action.triggered.connect(lambda: self.close_editor(self.tabs.currentIndex()))
 70        # New, then Open, then Save (each for a file and for a project), then the rest.
 71        file_menu = self.menuBar().addMenu("&File")
 72        file_menu.addActions([self.new_file_action, new_project_action])
 73        file_menu.addSeparator()
 74        file_menu.addActions([open_file_action, open_action, open_new_action])
 75        self.recent_menu = file_menu.addMenu("Recent Projects")
 76        self.recent_menu.aboutToShow.connect(self._fill_recent_menu)
 77        self.recent_menu.setToolTipsVisible(True)
 78        file_menu.addSeparator()
 79        self.save_config_action = QAction("Save Build Configuration…", self)
 80        self.save_config_action.setStatusTip("Save the Build options as a configuration in the project's "
 81                                             "vibefpm.json")
 82        self.save_config_action.triggered.connect(lambda: self.save_configuration())
 83        file_menu.addActions([self.save_action, self.save_as_action, self.save_config_action])
 84        file_menu.addSeparator()
 85        file_menu.addActions([reload_action, self.close_tab_action])
 86        file_menu.addSeparator()
 87        clear_settings_action = QAction("Clear Settings and Quit…", self)
 88        clear_settings_action.setMenuRole(QAction.NoRole)  # (macOS: not moved to the app menu as Quit)
 89        clear_settings_action.setStatusTip("Forget everything the app has saved (recent projects, layout, "
 90                                           "options, dictionary…) and quit: it starts as if for the first time")
 91        clear_settings_action.triggered.connect(self.clear_settings_and_quit)
 92        file_menu.addAction(clear_settings_action)
 93        quit_action = QAction("Quit", self)
 94        quit_action.setShortcut(QKeySequence.Quit)
 95        quit_action.triggered.connect(self.close)
 96        file_menu.addAction(quit_action)
 97
 98    def _build_project_files(self):
 99        """The Project files panel."""
100        self.project_tree = ProjectTree()
101        self.project_tree.open_requested.connect(lambda path: self.open_file(path))
102        self.project_tree.preview_requested.connect(self.preview_file)
103        self._preview: EditorTab | None = None  # the preview tab (one click in Project files), if any
104        self.project_tree.compile_command_requested.connect(self.show_compile_command)
105        self.project_tree.rename_requested.connect(self.rename_file)
106        self.project_tree.delete_requested.connect(self.delete_file)
107        files_box = QWidget()  # (in the "Project files" panel)
108        files_layout = QVBoxLayout(files_box)
109        files_layout.setContentsMargins(4, 4, 4, 4)
110        files_layout.addWidget(self.project_tree)
111        return files_box
112
113    def new_project(self):
114        """File → New fpm Project…: fpm new with the options chosen, in the
115        background; then the new project is opened here."""
116        start = self.manifest_path.parent.parent if self.manifest_path else Path.home()
117        dialog = NewProjectDialog(self._fpm_executable(), start, self)
118        if run_dialog(dialog) != QDialog.Accepted:
119            return
120        command, target = dialog.command(), dialog.project_folder()
121        self.statusBar().showMessage(f"Making {target.name} (fpm new)…")
122        call_in_background(lambda: _run_fpm_new(command, target.parent),
123                           lambda result: self._project_made(target, *result), self)
124
125    def _project_made(self, target: Path, ok: bool, message: str):
126        manifest = target / "fpm.toml"
127        if not ok or not manifest.is_file():
128            self.statusBar().clearMessage()
129            QMessageBox.warning(self, "New fpm Project", f"fpm couldn't make {target.name}:\n\n"
130                                f"{message or 'it made no fpm.toml'}")
131            return
132        self.open_manifest(manifest)
133        self.statusBar().showMessage(f"Made {target.name}: {target}", 6000)
134
135    def browse_project(self, new_window: bool = False):
136        """File → Open Project… (in New Window…): a project's folder."""
137        start = str(self.manifest_path.parent.parent) if self.manifest_path else str(Path.home())
138        folder = QFileDialog.getExistingDirectory(
139            self, "Open Project in a New Window" if new_window else "Open Project", start)
140        if folder:
141            self.open_project(folder, new_window)
142
143    def open_project(self, folder: str | Path, new_window: bool = False) -> bool:
144        """Open the project in ``folder``: with its vibefpm.json (its build
145        configurations and options) if it has one, else its fpm.toml; a
146        folder without an fpm.toml isn't one (an error message). False: not
147        opened (that, or Cancel)."""
148        folder = Path(folder).expanduser().resolve()
149        manifest, config = folder / "fpm.toml", folder / bc.FILE_NAME
150        if not manifest.is_file():
151            also = (f" It has a {bc.FILE_NAME}, but that goes beside a project's fpm.toml."
152                    if config.is_file() else "")
153            QMessageBox.warning(self, "Open Project", f"{folder}\n\nisn't an fpm project: it has no fpm.toml.{also}")
154            return False
155        key = project_key(manifest, "build_config_file")
156        before = self.settings.app.value(key, "")
157        if config.is_file():
158            self.settings.app.setValue(key, "")  # (its vibefpm.json: not another file opened for it before)
159        if new_window:
160            self.open_in_new_window(manifest)
161            return True
162        if manifest == self.manifest_path:
163            if config.is_file() and config != self.config_path:  # (it had another configuration file)
164                if not self.maybe_save_configuration():
165                    self.settings.app.setValue(key, before)
166                    return False
167                self._switch_config_file(manifest, force=True)
168                self._load_configurations()
169            return True
170        self.open_manifest(manifest)
171        if self.manifest_path != manifest:  # (not opened: Cancel, or fpm running)
172            self.settings.app.setValue(key, before)
173            return False
174        return True
175
176    def open_in_new_window(self, manifest: str | Path):
177        """Open a project in another VibeFPMGUI window (a separate process, so
178        each window has its own fpm runs, language server and so on), keeping
179        this one as it is."""
180        manifest = Path(manifest).resolve()
181        process = QProcess()
182        process.setProgram(gui_python())  # (on Windows pythonw: no console window beside it)
183        process.setArguments(["-m", "vibefpmgui", "--new-window", str(manifest)])
184        process.setWorkingDirectory(str(manifest.parent))
185        # Make sure the new process finds this package even when it isn't
186        # installed (run from a checkout, e.g. "pixi run start").
187        env = QProcessEnvironment.systemEnvironment()
188        package_root = str(Path(__file__).resolve().parents[2])  # (the folder vibefpmgui is in)
189        env.insert("PYTHONPATH", os.pathsep.join(p for p in (package_root, env.value("PYTHONPATH")) if p))
190        process.setProcessEnvironment(env)
191        ok = process.startDetached()
192        ok = ok[0] if isinstance(ok, tuple) else ok  # (PySide returns (ok, pid))
193        if ok:
194            self.statusBar().showMessage(f"Opening {manifest.parent.name} in a new window…", 5000)
195        else:
196            QMessageBox.warning(self, "Open in New Window",
197                                f"Could not start a new VibeFPMGUI window for {manifest}.")
198
199    def open_manifest(self, filename: str | Path):
200        path = Path(filename).resolve()
201        new_project = path != self.manifest_path
202        if new_project:
203            # Every question before anything changes: No or Cancel keeps the
204            # current project as it is, its files still open.
205            if not self.maybe_save_configuration():
206                return
207            if self.busy():
208                answer = QMessageBox.question(
209                    self, "fpm is running",
210                    "An fpm command is still running for the current project. Stop it and open the new one?")
211                if answer != QMessageBox.Yes:
212                    return
213            self._save_session()  # the old project's open files, for next time
214            # The open files belong to the old project: ask about unsaved changes.
215            if not self.close_all_editors():
216                return
217            if self.busy():
218                self._stop_and_wait(3000)
219            self._clear_run_panels(show_build=self.manifest_path is not None)
220            self.search_panel.reset()
221            self.references_panel.reset()
222            self._switch_config_file(path)  # (this one's options written, the other's applied: see window.options)
223        self.manifest_path = path
224        if self.remember_last:
225            self.settings.setValue("last_manifest", str(self.manifest_path))
226        self._remember_project(self.manifest_path.parent)
227        self.settings.sync()  # write now, so it survives the app being killed
228        self.setWindowFilePath(str(self.manifest_path))  # macOS title-bar proxy icon
229        self.update_window_title()
230        self.reload()
231        if new_project:
232            self._restore_session()
233
234    MAX_RECENT = 10
235
236    def update_window_title(self):
237        """"VibeFPMGUI · project", and " · configuration" while a saved one is chosen."""
238        parts = ["VibeFPMGUI"]
239        if self.manifest_path is not None:
240            parts.append(self.manifest_path.parent.name)
241            if self.config_shown():
242                parts.append(self.config_combo.currentText())
243        self.setWindowTitle(" · ".join(parts))
244
245    def recent_projects(self) -> list[str]:
246        """The folders of the projects last opened, latest first (File → Recent Projects)."""
247        if not self.settings.contains("recent_projects"):  # (kept before as fpm.toml and .json files: their folders)
248            old = [str(Path(p).parent) for key in ("recent_manifests", "recent_configs")
249                   for p in self.settings.value(key, [], type=list) or [] if p]
250            self.settings.setValue("recent_projects", list(dict.fromkeys(old))[:self.MAX_RECENT])
251            for key in ("recent_manifests", "recent_configs"):
252                self.settings.remove(key)
253        value = self.settings.value("recent_projects", [], type=list) or []
254        return [str(p) for p in value if p]
255
256    def _remember_project(self, folder: Path):
257        recent = [p for p in self.recent_projects() if Path(p) != folder]
258        self.settings.setValue("recent_projects", [str(folder), *recent][:self.MAX_RECENT])
259
260    def _fill_recent_menu(self):
261        """Rebuilt each time it opens, so missing projects show as such."""
262        menu = self.recent_menu
263        menu.clear()
264        recent = self.recent_projects()
265        if not recent:
266            menu.addAction("No recent projects").setEnabled(False)
267        home = str(Path.home())
268        for folder in recent:
269            shown = "~" + folder[len(home):] if folder.startswith(home + os.sep) else folder
270            exists = (Path(folder) / "fpm.toml").is_file()
271            label = f"{Path(folder).name} — {shown}" + ("" if exists else "  (not found)")
272            action = menu.addAction(label.replace("&", "&&"))  # "&" would be a mnemonic
273            action.setToolTip(folder if exists else f"{folder}\nIt's not there any more (or has no fpm.toml).")
274            action.setEnabled(exists)
275            action.setCheckable(True)
276            action.setChecked(self.manifest_path is not None and Path(folder) == self.manifest_path.parent)
277            action.triggered.connect(lambda _=False, f=folder: self.open_recent(f))
278        menu.addSeparator()
279        clear = menu.addAction("Clear Menu")
280        clear.setEnabled(bool(recent))
281        clear.triggered.connect(self.clear_recent_projects)
282
283    def open_recent(self, folder: str):
284        if not (Path(folder) / "fpm.toml").is_file():
285            # It was there when the menu opened, but not any more.
286            QMessageBox.warning(self, "Project not found",
287                                f"{folder}\n\nisn't there any more, so it was removed from Recent Projects.")
288            self.settings.setValue("recent_projects", [p for p in self.recent_projects() if p != folder])
289            return
290        self.open_project(folder)
291
292    def clear_recent_projects(self):
293        self.settings.setValue("recent_projects", [])
294
295    # Per project: the files open in the editor (with cursor and scroll
296    # positions), the tab in front, and the Project files tree's open
297    # folders, restored when the project is opened again.
298    def _session_key(self) -> str:
299        return "session"  # (the project's: see project_options.PER_PROJECT)
300
301    def _save_session(self):
302        if self.manifest_path is None or self._session_busy:
303            return
304        files = []
305        for editor in self.editors():
306            if editor.untitled:
307                continue  # (nothing on disk to open next time)
308            cursor = editor.editor.textCursor()
309            files.append({"path": str(editor.path), "line": cursor.blockNumber() + 1,
310                          "column": cursor.positionInBlock(),
311                          "scroll": editor.editor.verticalScrollBar().value()})
312        current = self.current_editor()
313        state = {"files": files, "current": str(current.path) if current and not current.untitled else None}
314        tree = self.project_tree.view_state()
315        if tree is None:  # filtered: keep what was saved before
316            try:
317                tree = json.loads(self.settings.value(self._session_key(), "{}")).get("tree")
318            except (TypeError, ValueError):
319                tree = None
320        state["tree"] = tree
321        state["breakpoints"] = self.session_breakpoints()  # (see window.debugging)
322        self.settings.setValue(self._session_key(), json.dumps(state))
323
324    def _restore_session(self):
325        try:
326            state = json.loads(self.settings.value(self._session_key(), "") or "{}")
327        except (TypeError, ValueError):
328            state = {}
329        self.set_session_breakpoints(state.get("breakpoints"))  # (before the files: they show theirs)
330        if state.get("tree"):
331            self.project_tree.restore_view_state(state["tree"])
332        self._session_busy = True
333        try:
334            for f in state.get("files") or []:
335                path = Path(f.get("path", ""))
336                if not path.is_file():
337                    continue  # deleted or moved since
338                self.open_file(str(path), force_editor=True)
339                editor = self._open_editor_for(path)
340                if editor is None:
341                    continue
342                editor.editor.go_to_position(int(f.get("line", 1)), int(f.get("column", 0)))
343                scroll = int(f.get("scroll", 0))
344                # (after the tab's first layout, or the scroll position is clamped)
345                QTimer.singleShot(0, editor, lambda e=editor, v=scroll: e.editor.verticalScrollBar().setValue(v))
346        finally:
347            self._session_busy = False
348        current = self._open_editor_for(Path(state["current"])) if state.get("current") else None
349        if current is not None:
350            self.tabs.setCurrentWidget(current)
351
352    def _reveal_current_file(self, *_):
353        editor = self.current_editor()
354        if editor is not None and not editor.untitled:
355            self.project_tree.reveal(str(editor.path))
356
357    def _update_tree_marks(self):
358        """Color files in the tree that had errors/warnings in the last build."""
359        marks = {}
360        for t in self.build_monitor.targets:
361            if t.path is not None and t.status in ("Failed", "Warnings"):
362                marks[str(t.path)] = "error" if t.status == "Failed" else "warning"
363        self.project_tree.set_marks(marks)
364
365    def _clear_run_panels(self, show_build: bool = True):
366        """Empty the output console and the build/test panels (``show_build``:
367        and bring Build to the front; not for the first project opened, so
368        the panels in front are those of the layout restored)."""
369        self.debug_session.stop()  # (another project's program)
370        self.show_test_output("")
371        self.console.clear()
372        self.test_output.clear()
373        self.build_monitor.reset()
374        self.test_monitor.reset()
375        self.run_monitor.reset()
376        self._stop_coverage()  # (another project's: its result would land here)
377        self._apply_coverage({})
378        self.coverage_panel.clear()
379        self.stop_review()
380        self._ai_editor = None
381        self._forget()
382        self.review_monitor.reset()
383        if show_build:
384            self.show_panel(self.build_monitor)
385
386    def reload(self):
387        if not self.manifest_path:
388            return
389        try:
390            self.graph = m.resolve(self.manifest_path,
391                                   dependencies_dev=self.dependencies_dev_action.isChecked())
392        except (OSError, tomllib.TOMLDecodeError) as exc:
393            self.graph = None
394            self.graph_view.clear(f"Could not read {self.manifest_path.name}:\n{exc}")
395            self.details.clear()
396            self._set_project_enabled(False)
397            self.statusBar().showMessage(f"Error reading manifest: {exc}")
398            return
399        # Keep the current layout (and any nodes the user moved) unless the
400        # set of packages or their relationships actually changed.
401        if self.graph_view.graph is None or _signature(self.graph_view.graph) != _signature(self.graph):
402            self.graph_view.set_graph(self.graph)
403        else:
404            self.graph_view.update_packages(self.graph)
405        self._update_legend()  # (it mentions version conflicts when there are any)
406        self.show_package(self.graph.root)
407        self.project_tree.set_project(self.graph, self.manifest_path.parent)
408        self.terminal_panel.set_directory(self.manifest_path.parent)
409        self._update_language_server()
410        self.refresh_build_outputs()
411        self.refresh_git()
412        self.refresh_complexity()
413        self._set_project_enabled(True)
414        self._load_features_and_profiles()
415        self._load_configurations()  # (after: a configuration's profile may be one of fpm.toml's)
416        missing = sum(1 for p in self.graph.packages.values() if p.kind == m.MISSING)
417        dev_only = sum(1 for p in self.graph.packages.values() if p.kind == m.DEV_ONLY)
418        msg = f"{len(self.graph.packages) - 1 - dev_only} dependencies"
419        if missing:
420            msg += f" ({missing} not downloaded — run Update deps or Build)"
421        if dev_only:
422            msg += f" · {dev_only} of their dev-dependencies shown (fpm doesn't download those)"
423        conflicts = m.conflicts(self.graph)
424        if conflicts:
425            msg += (f" · ⚠ {len(conflicts)} version conflict{'s' * (len(conflicts) != 1)}: "
426                    + ", ".join(sorted(conflicts)) + " (see the graph)")
427        self.statusBar().showMessage(msg)
428
429
430def _run_fpm_new(command: list[str], folder: Path) -> tuple[bool, str]:
431    """(whether it worked, what fpm said) of ``fpm new`` run in ``folder`` (on a pool thread)."""
432    try:
433        code, output, message = console.run(command, timeout=120, cwd=str(folder))
434    except (OSError, TimeoutError) as error:
435        return False, str(error)
436    said = "\n".join(part for part in ((output or b"").decode("utf-8", "replace").strip(), message) if part)
437    return code == 0, said
class ProjectMixin:
 35class ProjectMixin:
 36    """The open project and its session (a part of `vibefpmgui.main_window.MainWindow`)."""
 37
 38    def _build_file_menu(self):
 39        """File menu."""
 40        open_action = QAction("Open Project…", self)
 41        open_action.setShortcut(QKeySequence.Open)
 42        open_action.setStatusTip("Open an fpm project: a folder with an fpm.toml (and its vibefpm.json, if it has one)")
 43        open_action.triggered.connect(lambda: self.browse_project())
 44        reload_action = QAction("Reload", self)
 45        reload_action.setShortcut(QKeySequence("Ctrl+Shift+R"))  # Ctrl/⌘+R is Run
 46        reload_action.triggered.connect(self.reload)
 47        open_new_action = QAction("Open Project in New Window…", self)
 48        open_new_action.setShortcut(QKeySequence("Ctrl+Shift+N"))  # (⌘⇧O: Open File in Editor)
 49        open_new_action.setStatusTip("Open another project in its own window, keeping this one open")
 50        open_new_action.triggered.connect(lambda: self.browse_project(new_window=True))
 51        new_project_action = QAction("New fpm Project…", self)
 52        new_project_action.setStatusTip("Make a new fpm project (fpm new), and open it")
 53        new_project_action.triggered.connect(self.new_project)
 54        self.new_file_action = QAction("New File", self)
 55        self.new_file_action.setShortcut(QKeySequence.New)
 56        self.new_file_action.setStatusTip("An empty file in a new editor tab (it gets its name, and its type, "
 57                                          "when it's saved)")
 58        self.new_file_action.triggered.connect(self.new_file)
 59        open_file_action = QAction("Open File in Editor…", self)
 60        open_file_action.setShortcut(QKeySequence("Ctrl+Shift+O"))
 61        open_file_action.triggered.connect(self.browse_file)
 62        self.save_action = QAction("Save", self)
 63        self.save_action.setShortcut(QKeySequence.Save)
 64        self.save_action.triggered.connect(lambda: self._with_editor(lambda e: e.save()))
 65        self.save_as_action = QAction("Save As…", self)
 66        self.save_as_action.setShortcut(QKeySequence("Ctrl+Shift+S"))
 67        self.save_as_action.triggered.connect(lambda: self._with_editor(lambda e: e.save_as()))
 68        self.close_tab_action = QAction("Close Tab", self)
 69        self.close_tab_action.setShortcut(QKeySequence.Close)
 70        self.close_tab_action.triggered.connect(lambda: self.close_editor(self.tabs.currentIndex()))
 71        # New, then Open, then Save (each for a file and for a project), then the rest.
 72        file_menu = self.menuBar().addMenu("&File")
 73        file_menu.addActions([self.new_file_action, new_project_action])
 74        file_menu.addSeparator()
 75        file_menu.addActions([open_file_action, open_action, open_new_action])
 76        self.recent_menu = file_menu.addMenu("Recent Projects")
 77        self.recent_menu.aboutToShow.connect(self._fill_recent_menu)
 78        self.recent_menu.setToolTipsVisible(True)
 79        file_menu.addSeparator()
 80        self.save_config_action = QAction("Save Build Configuration…", self)
 81        self.save_config_action.setStatusTip("Save the Build options as a configuration in the project's "
 82                                             "vibefpm.json")
 83        self.save_config_action.triggered.connect(lambda: self.save_configuration())
 84        file_menu.addActions([self.save_action, self.save_as_action, self.save_config_action])
 85        file_menu.addSeparator()
 86        file_menu.addActions([reload_action, self.close_tab_action])
 87        file_menu.addSeparator()
 88        clear_settings_action = QAction("Clear Settings and Quit…", self)
 89        clear_settings_action.setMenuRole(QAction.NoRole)  # (macOS: not moved to the app menu as Quit)
 90        clear_settings_action.setStatusTip("Forget everything the app has saved (recent projects, layout, "
 91                                           "options, dictionary…) and quit: it starts as if for the first time")
 92        clear_settings_action.triggered.connect(self.clear_settings_and_quit)
 93        file_menu.addAction(clear_settings_action)
 94        quit_action = QAction("Quit", self)
 95        quit_action.setShortcut(QKeySequence.Quit)
 96        quit_action.triggered.connect(self.close)
 97        file_menu.addAction(quit_action)
 98
 99    def _build_project_files(self):
100        """The Project files panel."""
101        self.project_tree = ProjectTree()
102        self.project_tree.open_requested.connect(lambda path: self.open_file(path))
103        self.project_tree.preview_requested.connect(self.preview_file)
104        self._preview: EditorTab | None = None  # the preview tab (one click in Project files), if any
105        self.project_tree.compile_command_requested.connect(self.show_compile_command)
106        self.project_tree.rename_requested.connect(self.rename_file)
107        self.project_tree.delete_requested.connect(self.delete_file)
108        files_box = QWidget()  # (in the "Project files" panel)
109        files_layout = QVBoxLayout(files_box)
110        files_layout.setContentsMargins(4, 4, 4, 4)
111        files_layout.addWidget(self.project_tree)
112        return files_box
113
114    def new_project(self):
115        """File → New fpm Project…: fpm new with the options chosen, in the
116        background; then the new project is opened here."""
117        start = self.manifest_path.parent.parent if self.manifest_path else Path.home()
118        dialog = NewProjectDialog(self._fpm_executable(), start, self)
119        if run_dialog(dialog) != QDialog.Accepted:
120            return
121        command, target = dialog.command(), dialog.project_folder()
122        self.statusBar().showMessage(f"Making {target.name} (fpm new)…")
123        call_in_background(lambda: _run_fpm_new(command, target.parent),
124                           lambda result: self._project_made(target, *result), self)
125
126    def _project_made(self, target: Path, ok: bool, message: str):
127        manifest = target / "fpm.toml"
128        if not ok or not manifest.is_file():
129            self.statusBar().clearMessage()
130            QMessageBox.warning(self, "New fpm Project", f"fpm couldn't make {target.name}:\n\n"
131                                f"{message or 'it made no fpm.toml'}")
132            return
133        self.open_manifest(manifest)
134        self.statusBar().showMessage(f"Made {target.name}: {target}", 6000)
135
136    def browse_project(self, new_window: bool = False):
137        """File → Open Project… (in New Window…): a project's folder."""
138        start = str(self.manifest_path.parent.parent) if self.manifest_path else str(Path.home())
139        folder = QFileDialog.getExistingDirectory(
140            self, "Open Project in a New Window" if new_window else "Open Project", start)
141        if folder:
142            self.open_project(folder, new_window)
143
144    def open_project(self, folder: str | Path, new_window: bool = False) -> bool:
145        """Open the project in ``folder``: with its vibefpm.json (its build
146        configurations and options) if it has one, else its fpm.toml; a
147        folder without an fpm.toml isn't one (an error message). False: not
148        opened (that, or Cancel)."""
149        folder = Path(folder).expanduser().resolve()
150        manifest, config = folder / "fpm.toml", folder / bc.FILE_NAME
151        if not manifest.is_file():
152            also = (f" It has a {bc.FILE_NAME}, but that goes beside a project's fpm.toml."
153                    if config.is_file() else "")
154            QMessageBox.warning(self, "Open Project", f"{folder}\n\nisn't an fpm project: it has no fpm.toml.{also}")
155            return False
156        key = project_key(manifest, "build_config_file")
157        before = self.settings.app.value(key, "")
158        if config.is_file():
159            self.settings.app.setValue(key, "")  # (its vibefpm.json: not another file opened for it before)
160        if new_window:
161            self.open_in_new_window(manifest)
162            return True
163        if manifest == self.manifest_path:
164            if config.is_file() and config != self.config_path:  # (it had another configuration file)
165                if not self.maybe_save_configuration():
166                    self.settings.app.setValue(key, before)
167                    return False
168                self._switch_config_file(manifest, force=True)
169                self._load_configurations()
170            return True
171        self.open_manifest(manifest)
172        if self.manifest_path != manifest:  # (not opened: Cancel, or fpm running)
173            self.settings.app.setValue(key, before)
174            return False
175        return True
176
177    def open_in_new_window(self, manifest: str | Path):
178        """Open a project in another VibeFPMGUI window (a separate process, so
179        each window has its own fpm runs, language server and so on), keeping
180        this one as it is."""
181        manifest = Path(manifest).resolve()
182        process = QProcess()
183        process.setProgram(gui_python())  # (on Windows pythonw: no console window beside it)
184        process.setArguments(["-m", "vibefpmgui", "--new-window", str(manifest)])
185        process.setWorkingDirectory(str(manifest.parent))
186        # Make sure the new process finds this package even when it isn't
187        # installed (run from a checkout, e.g. "pixi run start").
188        env = QProcessEnvironment.systemEnvironment()
189        package_root = str(Path(__file__).resolve().parents[2])  # (the folder vibefpmgui is in)
190        env.insert("PYTHONPATH", os.pathsep.join(p for p in (package_root, env.value("PYTHONPATH")) if p))
191        process.setProcessEnvironment(env)
192        ok = process.startDetached()
193        ok = ok[0] if isinstance(ok, tuple) else ok  # (PySide returns (ok, pid))
194        if ok:
195            self.statusBar().showMessage(f"Opening {manifest.parent.name} in a new window…", 5000)
196        else:
197            QMessageBox.warning(self, "Open in New Window",
198                                f"Could not start a new VibeFPMGUI window for {manifest}.")
199
200    def open_manifest(self, filename: str | Path):
201        path = Path(filename).resolve()
202        new_project = path != self.manifest_path
203        if new_project:
204            # Every question before anything changes: No or Cancel keeps the
205            # current project as it is, its files still open.
206            if not self.maybe_save_configuration():
207                return
208            if self.busy():
209                answer = QMessageBox.question(
210                    self, "fpm is running",
211                    "An fpm command is still running for the current project. Stop it and open the new one?")
212                if answer != QMessageBox.Yes:
213                    return
214            self._save_session()  # the old project's open files, for next time
215            # The open files belong to the old project: ask about unsaved changes.
216            if not self.close_all_editors():
217                return
218            if self.busy():
219                self._stop_and_wait(3000)
220            self._clear_run_panels(show_build=self.manifest_path is not None)
221            self.search_panel.reset()
222            self.references_panel.reset()
223            self._switch_config_file(path)  # (this one's options written, the other's applied: see window.options)
224        self.manifest_path = path
225        if self.remember_last:
226            self.settings.setValue("last_manifest", str(self.manifest_path))
227        self._remember_project(self.manifest_path.parent)
228        self.settings.sync()  # write now, so it survives the app being killed
229        self.setWindowFilePath(str(self.manifest_path))  # macOS title-bar proxy icon
230        self.update_window_title()
231        self.reload()
232        if new_project:
233            self._restore_session()
234
235    MAX_RECENT = 10
236
237    def update_window_title(self):
238        """"VibeFPMGUI · project", and " · configuration" while a saved one is chosen."""
239        parts = ["VibeFPMGUI"]
240        if self.manifest_path is not None:
241            parts.append(self.manifest_path.parent.name)
242            if self.config_shown():
243                parts.append(self.config_combo.currentText())
244        self.setWindowTitle(" · ".join(parts))
245
246    def recent_projects(self) -> list[str]:
247        """The folders of the projects last opened, latest first (File → Recent Projects)."""
248        if not self.settings.contains("recent_projects"):  # (kept before as fpm.toml and .json files: their folders)
249            old = [str(Path(p).parent) for key in ("recent_manifests", "recent_configs")
250                   for p in self.settings.value(key, [], type=list) or [] if p]
251            self.settings.setValue("recent_projects", list(dict.fromkeys(old))[:self.MAX_RECENT])
252            for key in ("recent_manifests", "recent_configs"):
253                self.settings.remove(key)
254        value = self.settings.value("recent_projects", [], type=list) or []
255        return [str(p) for p in value if p]
256
257    def _remember_project(self, folder: Path):
258        recent = [p for p in self.recent_projects() if Path(p) != folder]
259        self.settings.setValue("recent_projects", [str(folder), *recent][:self.MAX_RECENT])
260
261    def _fill_recent_menu(self):
262        """Rebuilt each time it opens, so missing projects show as such."""
263        menu = self.recent_menu
264        menu.clear()
265        recent = self.recent_projects()
266        if not recent:
267            menu.addAction("No recent projects").setEnabled(False)
268        home = str(Path.home())
269        for folder in recent:
270            shown = "~" + folder[len(home):] if folder.startswith(home + os.sep) else folder
271            exists = (Path(folder) / "fpm.toml").is_file()
272            label = f"{Path(folder).name} — {shown}" + ("" if exists else "  (not found)")
273            action = menu.addAction(label.replace("&", "&&"))  # "&" would be a mnemonic
274            action.setToolTip(folder if exists else f"{folder}\nIt's not there any more (or has no fpm.toml).")
275            action.setEnabled(exists)
276            action.setCheckable(True)
277            action.setChecked(self.manifest_path is not None and Path(folder) == self.manifest_path.parent)
278            action.triggered.connect(lambda _=False, f=folder: self.open_recent(f))
279        menu.addSeparator()
280        clear = menu.addAction("Clear Menu")
281        clear.setEnabled(bool(recent))
282        clear.triggered.connect(self.clear_recent_projects)
283
284    def open_recent(self, folder: str):
285        if not (Path(folder) / "fpm.toml").is_file():
286            # It was there when the menu opened, but not any more.
287            QMessageBox.warning(self, "Project not found",
288                                f"{folder}\n\nisn't there any more, so it was removed from Recent Projects.")
289            self.settings.setValue("recent_projects", [p for p in self.recent_projects() if p != folder])
290            return
291        self.open_project(folder)
292
293    def clear_recent_projects(self):
294        self.settings.setValue("recent_projects", [])
295
296    # Per project: the files open in the editor (with cursor and scroll
297    # positions), the tab in front, and the Project files tree's open
298    # folders, restored when the project is opened again.
299    def _session_key(self) -> str:
300        return "session"  # (the project's: see project_options.PER_PROJECT)
301
302    def _save_session(self):
303        if self.manifest_path is None or self._session_busy:
304            return
305        files = []
306        for editor in self.editors():
307            if editor.untitled:
308                continue  # (nothing on disk to open next time)
309            cursor = editor.editor.textCursor()
310            files.append({"path": str(editor.path), "line": cursor.blockNumber() + 1,
311                          "column": cursor.positionInBlock(),
312                          "scroll": editor.editor.verticalScrollBar().value()})
313        current = self.current_editor()
314        state = {"files": files, "current": str(current.path) if current and not current.untitled else None}
315        tree = self.project_tree.view_state()
316        if tree is None:  # filtered: keep what was saved before
317            try:
318                tree = json.loads(self.settings.value(self._session_key(), "{}")).get("tree")
319            except (TypeError, ValueError):
320                tree = None
321        state["tree"] = tree
322        state["breakpoints"] = self.session_breakpoints()  # (see window.debugging)
323        self.settings.setValue(self._session_key(), json.dumps(state))
324
325    def _restore_session(self):
326        try:
327            state = json.loads(self.settings.value(self._session_key(), "") or "{}")
328        except (TypeError, ValueError):
329            state = {}
330        self.set_session_breakpoints(state.get("breakpoints"))  # (before the files: they show theirs)
331        if state.get("tree"):
332            self.project_tree.restore_view_state(state["tree"])
333        self._session_busy = True
334        try:
335            for f in state.get("files") or []:
336                path = Path(f.get("path", ""))
337                if not path.is_file():
338                    continue  # deleted or moved since
339                self.open_file(str(path), force_editor=True)
340                editor = self._open_editor_for(path)
341                if editor is None:
342                    continue
343                editor.editor.go_to_position(int(f.get("line", 1)), int(f.get("column", 0)))
344                scroll = int(f.get("scroll", 0))
345                # (after the tab's first layout, or the scroll position is clamped)
346                QTimer.singleShot(0, editor, lambda e=editor, v=scroll: e.editor.verticalScrollBar().setValue(v))
347        finally:
348            self._session_busy = False
349        current = self._open_editor_for(Path(state["current"])) if state.get("current") else None
350        if current is not None:
351            self.tabs.setCurrentWidget(current)
352
353    def _reveal_current_file(self, *_):
354        editor = self.current_editor()
355        if editor is not None and not editor.untitled:
356            self.project_tree.reveal(str(editor.path))
357
358    def _update_tree_marks(self):
359        """Color files in the tree that had errors/warnings in the last build."""
360        marks = {}
361        for t in self.build_monitor.targets:
362            if t.path is not None and t.status in ("Failed", "Warnings"):
363                marks[str(t.path)] = "error" if t.status == "Failed" else "warning"
364        self.project_tree.set_marks(marks)
365
366    def _clear_run_panels(self, show_build: bool = True):
367        """Empty the output console and the build/test panels (``show_build``:
368        and bring Build to the front; not for the first project opened, so
369        the panels in front are those of the layout restored)."""
370        self.debug_session.stop()  # (another project's program)
371        self.show_test_output("")
372        self.console.clear()
373        self.test_output.clear()
374        self.build_monitor.reset()
375        self.test_monitor.reset()
376        self.run_monitor.reset()
377        self._stop_coverage()  # (another project's: its result would land here)
378        self._apply_coverage({})
379        self.coverage_panel.clear()
380        self.stop_review()
381        self._ai_editor = None
382        self._forget()
383        self.review_monitor.reset()
384        if show_build:
385            self.show_panel(self.build_monitor)
386
387    def reload(self):
388        if not self.manifest_path:
389            return
390        try:
391            self.graph = m.resolve(self.manifest_path,
392                                   dependencies_dev=self.dependencies_dev_action.isChecked())
393        except (OSError, tomllib.TOMLDecodeError) as exc:
394            self.graph = None
395            self.graph_view.clear(f"Could not read {self.manifest_path.name}:\n{exc}")
396            self.details.clear()
397            self._set_project_enabled(False)
398            self.statusBar().showMessage(f"Error reading manifest: {exc}")
399            return
400        # Keep the current layout (and any nodes the user moved) unless the
401        # set of packages or their relationships actually changed.
402        if self.graph_view.graph is None or _signature(self.graph_view.graph) != _signature(self.graph):
403            self.graph_view.set_graph(self.graph)
404        else:
405            self.graph_view.update_packages(self.graph)
406        self._update_legend()  # (it mentions version conflicts when there are any)
407        self.show_package(self.graph.root)
408        self.project_tree.set_project(self.graph, self.manifest_path.parent)
409        self.terminal_panel.set_directory(self.manifest_path.parent)
410        self._update_language_server()
411        self.refresh_build_outputs()
412        self.refresh_git()
413        self.refresh_complexity()
414        self._set_project_enabled(True)
415        self._load_features_and_profiles()
416        self._load_configurations()  # (after: a configuration's profile may be one of fpm.toml's)
417        missing = sum(1 for p in self.graph.packages.values() if p.kind == m.MISSING)
418        dev_only = sum(1 for p in self.graph.packages.values() if p.kind == m.DEV_ONLY)
419        msg = f"{len(self.graph.packages) - 1 - dev_only} dependencies"
420        if missing:
421            msg += f" ({missing} not downloaded — run Update deps or Build)"
422        if dev_only:
423            msg += f" · {dev_only} of their dev-dependencies shown (fpm doesn't download those)"
424        conflicts = m.conflicts(self.graph)
425        if conflicts:
426            msg += (f" · ⚠ {len(conflicts)} version conflict{'s' * (len(conflicts) != 1)}: "
427                    + ", ".join(sorted(conflicts)) + " (see the graph)")
428        self.statusBar().showMessage(msg)

The open project and its session (a part of vibefpmgui.main_window.MainWindow).

def new_project(self):
114    def new_project(self):
115        """File → New fpm Project…: fpm new with the options chosen, in the
116        background; then the new project is opened here."""
117        start = self.manifest_path.parent.parent if self.manifest_path else Path.home()
118        dialog = NewProjectDialog(self._fpm_executable(), start, self)
119        if run_dialog(dialog) != QDialog.Accepted:
120            return
121        command, target = dialog.command(), dialog.project_folder()
122        self.statusBar().showMessage(f"Making {target.name} (fpm new)…")
123        call_in_background(lambda: _run_fpm_new(command, target.parent),
124                           lambda result: self._project_made(target, *result), self)

File → New fpm Project…: fpm new with the options chosen, in the background; then the new project is opened here.

def browse_project(self, new_window: bool = False):
136    def browse_project(self, new_window: bool = False):
137        """File → Open Project… (in New Window…): a project's folder."""
138        start = str(self.manifest_path.parent.parent) if self.manifest_path else str(Path.home())
139        folder = QFileDialog.getExistingDirectory(
140            self, "Open Project in a New Window" if new_window else "Open Project", start)
141        if folder:
142            self.open_project(folder, new_window)

File → Open Project… (in New Window…): a project's folder.

def open_project(self, folder: str | pathlib.Path, new_window: bool = False) -> bool:
144    def open_project(self, folder: str | Path, new_window: bool = False) -> bool:
145        """Open the project in ``folder``: with its vibefpm.json (its build
146        configurations and options) if it has one, else its fpm.toml; a
147        folder without an fpm.toml isn't one (an error message). False: not
148        opened (that, or Cancel)."""
149        folder = Path(folder).expanduser().resolve()
150        manifest, config = folder / "fpm.toml", folder / bc.FILE_NAME
151        if not manifest.is_file():
152            also = (f" It has a {bc.FILE_NAME}, but that goes beside a project's fpm.toml."
153                    if config.is_file() else "")
154            QMessageBox.warning(self, "Open Project", f"{folder}\n\nisn't an fpm project: it has no fpm.toml.{also}")
155            return False
156        key = project_key(manifest, "build_config_file")
157        before = self.settings.app.value(key, "")
158        if config.is_file():
159            self.settings.app.setValue(key, "")  # (its vibefpm.json: not another file opened for it before)
160        if new_window:
161            self.open_in_new_window(manifest)
162            return True
163        if manifest == self.manifest_path:
164            if config.is_file() and config != self.config_path:  # (it had another configuration file)
165                if not self.maybe_save_configuration():
166                    self.settings.app.setValue(key, before)
167                    return False
168                self._switch_config_file(manifest, force=True)
169                self._load_configurations()
170            return True
171        self.open_manifest(manifest)
172        if self.manifest_path != manifest:  # (not opened: Cancel, or fpm running)
173            self.settings.app.setValue(key, before)
174            return False
175        return True

Open the project in folder: with its vibefpm.json (its build configurations and options) if it has one, else its fpm.toml; a folder without an fpm.toml isn't one (an error message). False: not opened (that, or Cancel).

def open_in_new_window(self, manifest: str | pathlib.Path):
177    def open_in_new_window(self, manifest: str | Path):
178        """Open a project in another VibeFPMGUI window (a separate process, so
179        each window has its own fpm runs, language server and so on), keeping
180        this one as it is."""
181        manifest = Path(manifest).resolve()
182        process = QProcess()
183        process.setProgram(gui_python())  # (on Windows pythonw: no console window beside it)
184        process.setArguments(["-m", "vibefpmgui", "--new-window", str(manifest)])
185        process.setWorkingDirectory(str(manifest.parent))
186        # Make sure the new process finds this package even when it isn't
187        # installed (run from a checkout, e.g. "pixi run start").
188        env = QProcessEnvironment.systemEnvironment()
189        package_root = str(Path(__file__).resolve().parents[2])  # (the folder vibefpmgui is in)
190        env.insert("PYTHONPATH", os.pathsep.join(p for p in (package_root, env.value("PYTHONPATH")) if p))
191        process.setProcessEnvironment(env)
192        ok = process.startDetached()
193        ok = ok[0] if isinstance(ok, tuple) else ok  # (PySide returns (ok, pid))
194        if ok:
195            self.statusBar().showMessage(f"Opening {manifest.parent.name} in a new window…", 5000)
196        else:
197            QMessageBox.warning(self, "Open in New Window",
198                                f"Could not start a new VibeFPMGUI window for {manifest}.")

Open a project in another VibeFPMGUI window (a separate process, so each window has its own fpm runs, language server and so on), keeping this one as it is.

def open_manifest(self, filename: str | pathlib.Path):
200    def open_manifest(self, filename: str | Path):
201        path = Path(filename).resolve()
202        new_project = path != self.manifest_path
203        if new_project:
204            # Every question before anything changes: No or Cancel keeps the
205            # current project as it is, its files still open.
206            if not self.maybe_save_configuration():
207                return
208            if self.busy():
209                answer = QMessageBox.question(
210                    self, "fpm is running",
211                    "An fpm command is still running for the current project. Stop it and open the new one?")
212                if answer != QMessageBox.Yes:
213                    return
214            self._save_session()  # the old project's open files, for next time
215            # The open files belong to the old project: ask about unsaved changes.
216            if not self.close_all_editors():
217                return
218            if self.busy():
219                self._stop_and_wait(3000)
220            self._clear_run_panels(show_build=self.manifest_path is not None)
221            self.search_panel.reset()
222            self.references_panel.reset()
223            self._switch_config_file(path)  # (this one's options written, the other's applied: see window.options)
224        self.manifest_path = path
225        if self.remember_last:
226            self.settings.setValue("last_manifest", str(self.manifest_path))
227        self._remember_project(self.manifest_path.parent)
228        self.settings.sync()  # write now, so it survives the app being killed
229        self.setWindowFilePath(str(self.manifest_path))  # macOS title-bar proxy icon
230        self.update_window_title()
231        self.reload()
232        if new_project:
233            self._restore_session()
MAX_RECENT = 10
def update_window_title(self):
237    def update_window_title(self):
238        """"VibeFPMGUI · project", and " · configuration" while a saved one is chosen."""
239        parts = ["VibeFPMGUI"]
240        if self.manifest_path is not None:
241            parts.append(self.manifest_path.parent.name)
242            if self.config_shown():
243                parts.append(self.config_combo.currentText())
244        self.setWindowTitle(" · ".join(parts))

"VibeFPMGUI · project", and " · configuration" while a saved one is chosen.

def recent_projects(self) -> list[str]:
246    def recent_projects(self) -> list[str]:
247        """The folders of the projects last opened, latest first (File → Recent Projects)."""
248        if not self.settings.contains("recent_projects"):  # (kept before as fpm.toml and .json files: their folders)
249            old = [str(Path(p).parent) for key in ("recent_manifests", "recent_configs")
250                   for p in self.settings.value(key, [], type=list) or [] if p]
251            self.settings.setValue("recent_projects", list(dict.fromkeys(old))[:self.MAX_RECENT])
252            for key in ("recent_manifests", "recent_configs"):
253                self.settings.remove(key)
254        value = self.settings.value("recent_projects", [], type=list) or []
255        return [str(p) for p in value if p]

The folders of the projects last opened, latest first (File → Recent Projects).

def open_recent(self, folder: str):
284    def open_recent(self, folder: str):
285        if not (Path(folder) / "fpm.toml").is_file():
286            # It was there when the menu opened, but not any more.
287            QMessageBox.warning(self, "Project not found",
288                                f"{folder}\n\nisn't there any more, so it was removed from Recent Projects.")
289            self.settings.setValue("recent_projects", [p for p in self.recent_projects() if p != folder])
290            return
291        self.open_project(folder)
def clear_recent_projects(self):
293    def clear_recent_projects(self):
294        self.settings.setValue("recent_projects", [])
def reload(self):
387    def reload(self):
388        if not self.manifest_path:
389            return
390        try:
391            self.graph = m.resolve(self.manifest_path,
392                                   dependencies_dev=self.dependencies_dev_action.isChecked())
393        except (OSError, tomllib.TOMLDecodeError) as exc:
394            self.graph = None
395            self.graph_view.clear(f"Could not read {self.manifest_path.name}:\n{exc}")
396            self.details.clear()
397            self._set_project_enabled(False)
398            self.statusBar().showMessage(f"Error reading manifest: {exc}")
399            return
400        # Keep the current layout (and any nodes the user moved) unless the
401        # set of packages or their relationships actually changed.
402        if self.graph_view.graph is None or _signature(self.graph_view.graph) != _signature(self.graph):
403            self.graph_view.set_graph(self.graph)
404        else:
405            self.graph_view.update_packages(self.graph)
406        self._update_legend()  # (it mentions version conflicts when there are any)
407        self.show_package(self.graph.root)
408        self.project_tree.set_project(self.graph, self.manifest_path.parent)
409        self.terminal_panel.set_directory(self.manifest_path.parent)
410        self._update_language_server()
411        self.refresh_build_outputs()
412        self.refresh_git()
413        self.refresh_complexity()
414        self._set_project_enabled(True)
415        self._load_features_and_profiles()
416        self._load_configurations()  # (after: a configuration's profile may be one of fpm.toml's)
417        missing = sum(1 for p in self.graph.packages.values() if p.kind == m.MISSING)
418        dev_only = sum(1 for p in self.graph.packages.values() if p.kind == m.DEV_ONLY)
419        msg = f"{len(self.graph.packages) - 1 - dev_only} dependencies"
420        if missing:
421            msg += f" ({missing} not downloaded — run Update deps or Build)"
422        if dev_only:
423            msg += f" · {dev_only} of their dev-dependencies shown (fpm doesn't download those)"
424        conflicts = m.conflicts(self.graph)
425        if conflicts:
426            msg += (f" · ⚠ {len(conflicts)} version conflict{'s' * (len(conflicts) != 1)}: "
427                    + ", ".join(sorted(conflicts)) + " (see the graph)")
428        self.statusBar().showMessage(msg)