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

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: 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) 

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: 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) 

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

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: 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}.") 

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

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

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

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: 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) 

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