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
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).
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.
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.
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).
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.
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()
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.
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).
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)
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)