Coverage for vibefpmgui/window/project.py: 91%
347 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 project: opening an fpm.toml, recent projects, the open files' session.
3Also the File menu, the Project files panel and reloading the dependency graph.
4"""
6from __future__ import annotations
8import json
9import os
10import tomllib
11from pathlib import Path
13from qtpy.QtCore import QProcess, QProcessEnvironment, QTimer
14from qtpy.QtGui import QAction, QKeySequence
15from qtpy.QtWidgets import QDialog, QFileDialog, QMessageBox, QVBoxLayout, QWidget
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
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))
34class ProjectMixin:
35 """The open project and its session (a part of `vibefpmgui.main_window.MainWindow`)."""
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)
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
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: 118 ↛ 119line 118 didn't jump to line 119 because the condition on line 118 was never true
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)
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)
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: 140 ↛ exitline 140 didn't return from function 'browse_project' because the condition on line 140 was always true
141 self.open_project(folder, new_window)
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) 163 ↛ 169line 163 didn't jump to line 169 because the condition on line 163 was always true
164 if not self.maybe_save_configuration(): 164 ↛ 165line 164 didn't jump to line 165 because the condition on line 164 was never true
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) 171 ↛ 172line 171 didn't jump to line 172 because the condition on line 171 was never true
172 self.settings.app.setValue(key, before)
173 return False
174 return True
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: 193 ↛ 196line 193 didn't jump to line 196 because the condition on line 193 was always true
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}.")
199 def open_manifest(self, filename: str | Path):
200 path = Path(filename).resolve()
201 new_project = path != self.manifest_path
202 if new_project: 202 ↛ 223line 202 didn't jump to line 223 because the condition on line 202 was always true
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: 211 ↛ 213line 211 didn't jump to line 213 because the condition on line 211 was always true
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(): 217 ↛ 218line 217 didn't jump to line 218 because the condition on line 217 was never true
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: 231 ↛ exitline 231 didn't return from function 'open_manifest' because the condition on line 231 was always true
232 self._restore_session()
234 MAX_RECENT = 10
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))
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]
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])
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)
283 def open_recent(self, folder: str):
284 if not (Path(folder) / "fpm.toml").is_file(): 284 ↛ 286line 284 didn't jump to line 286 because the condition on line 284 was never true
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)
292 def clear_recent_projects(self):
293 self.settings.setValue("recent_projects", [])
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)
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))
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(): 336 ↛ 337line 336 didn't jump to line 337 because the condition on line 336 was never true
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: 340 ↛ 341line 340 didn't jump to line 341 because the condition on line 340 was never true
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)
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))
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)
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)
386 def reload(self):
387 if not self.manifest_path: 387 ↛ 388line 387 didn't jump to line 388 because the condition on line 387 was never true
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)
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