Coverage for vibefpmgui/project_tree.py: 87%

656 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-11-01 20:51 +0000

1"""Sidebar tree of the project's files and those of its dependencies. 

2 

3The project folder is listed first; each dependency (local path or a git 

4download in ``build/dependencies``) gets its own node under *Dependencies*. 

5Build output, hidden folders and compiled files are left out. A filter box 

6narrows the tree to matching file names. Double-click (or Enter) opens a file. 

7""" 

8 

9from __future__ import annotations 

10 

11import os 

12import subprocess 

13import sys 

14from dataclasses import dataclass 

15from pathlib import Path 

16 

17from qtpy.QtCore import ( 

18 QEvent, 

19 QFileInfo, 

20 QSortFilterProxyModel, 

21 Qt, 

22 QTimer, 

23 QUrl, 

24 Signal, 

25) 

26from qtpy.QtGui import ( 

27 QColor, 

28 QDesktopServices, 

29 QFont, 

30 QPalette, 

31 QStandardItem, 

32 QStandardItemModel, 

33) 

34from qtpy.QtWidgets import ( 

35 QAbstractItemView, 

36 QApplication, 

37 QCheckBox, 

38 QFileIconProvider, 

39 QHBoxLayout, 

40 QLabel, 

41 QLineEdit, 

42 QMenu, 

43 QTreeView, 

44 QVBoxLayout, 

45 QWidget, 

46) 

47 

48from . import manifest as m 

49from .build_monitor import SOURCE_EXTS 

50from .compat import file_manager_name, reveal_label 

51from .icons import ( 

52 FORTRAN_INCLUDE_SUFFIXES, 

53 FORTRAN_SUFFIXES, 

54 badged_icon, 

55 fortran_file_icon, 

56) 

57 

58PATH_ROLE = Qt.UserRole + 1 

59KIND_ROLE = Qt.UserRole + 2 # "package", "group", "dir", "file", "placeholder" 

60LOADED_ROLE = Qt.UserRole + 3 # folder contents have been read 

61HIDDEN_ROLE = Qt.UserRole + 4 # why an entry is greyed out (empty: it isn't) 

62IGNORED_ROLE = Qt.UserRole + 6 # the .gitignore that ignores this entry (if any) 

63LINK_ROLE = Qt.UserRole + 5 # a folder that is listed as a package elsewhere 

64 

65SKIP_DIRS = {"build", "__pycache__", "node_modules"} 

66SKIP_SUFFIXES = {".o", ".obj", ".mod", ".smod", ".a", ".so", ".dylib", ".dll", ".lib", 

67 ".exe", ".pyc", ".gcda", ".gcno", ".DS_Store"} 

68 

69FILE_COLORS = {"error": "#e03131", "warning": "#e8590c"} 

70 

71# The folders fpm knows about: role -> (badge, tooltip). 

72ROLES = { 

73 "library": ("code", "Library sources"), 

74 "include": ("include", "Include files"), 

75 "app": ("run", "Programs (executables)"), 

76 "test": ("test", "Tests"), 

77 "example": ("example", "Example programs"), 

78} 

79 

80 

81def fpm_folders(manifest_path: Path) -> dict[str, str]: 

82 """Absolute folder -> role for the standard fpm folders of one package, 

83 following the manifest's source-dir / include-dir settings (defaults: 

84 src, include, app, test, example).""" 

85 root = manifest_path.parent 

86 try: 

87 data = m.load_toml(manifest_path) 

88 except Exception: # noqa: BLE001 - an unreadable manifest just gets defaults 

89 data = {} 

90 roles: dict[str, str] = {} 

91 

92 def add(folder, role): 

93 for f in folder if isinstance(folder, list) else [folder]: 

94 if isinstance(f, str) and f.strip(): 94 ↛ 93line 94 didn't jump to line 93 because the condition on line 94 was always true

95 path = (root / f).resolve() 

96 if path.is_dir() and path != root.resolve(): 

97 roles.setdefault(str(path), role) 

98 

99 library = data.get("library") 

100 library = library if isinstance(library, dict) else {} 

101 add(library.get("source-dir", "src"), "library") 

102 add(library.get("include-dir", "include"), "include") 

103 for section, role, default in (("executable", "app", "app"), ("test", "test", "test"), 

104 ("example", "example", "example")): 

105 targets = data.get(section) 

106 targets = targets if isinstance(targets, list) else [] 

107 dirs = {t.get("source-dir", default) for t in targets if isinstance(t, dict)} or {default} 

108 for folder in dirs: 

109 add(folder, role) 

110 return roles 

111 

112 

113@dataclass(frozen=True) 

114class ProjectFile: 

115 """A file of the project (or a dependency), as Go to File lists it.""" 

116 path: str # (absolute) 

117 relative: str # in its package's folder ("src/x.f90") 

118 package: str # its package's name 

119 dependency: bool # a dependency's (not the project's own) 

120 hidden: bool = False # ignored by git, or greyed out (listed only with Ignored ticked) 

121 

122 

123class GitIgnore: 

124 """git's ignore rules for one package: the .gitignore files from the root 

125 of its git repository (or the package folder, if it isn't in one) down to 

126 each folder, combined the way git does (deeper files win, "!" re-includes, 

127 and everything inside an ignored folder is ignored).""" 

128 

129 def __init__(self, package_dir: Path): 

130 self.package_dir = package_dir.resolve() 

131 self.root = self._repo_root(self.package_dir) 

132 self._specs: dict[Path, object] = {} 

133 self._chains: dict[Path, list] = {} # folder -> the rules that apply to its entries (see _chain) 

134 

135 @staticmethod 

136 def _repo_root(start: Path) -> Path: 

137 home = Path.home().resolve() 

138 for folder in (start, *start.parents): 138 ↛ 143line 138 didn't jump to line 143 because the loop on line 138 didn't complete

139 if (folder / ".git").exists(): 

140 return folder 

141 if folder == home or folder.parent == folder: 

142 break 

143 return start 

144 

145 def _spec(self, folder: Path): 

146 """(spec, line number of each pattern) for folder/.gitignore, or None.""" 

147 if folder not in self._specs: 

148 entry = None 

149 ignore_file = folder / ".gitignore" 

150 if ignore_file.is_file(): 

151 try: 

152 import pathspec 

153 lines = ignore_file.read_text(errors="replace").splitlines() 

154 spec = pathspec.GitIgnoreSpec.from_lines(lines) 

155 # pathspec skips blank lines; map each pattern back to its line. 

156 line_of, k = [], 0 

157 for number, line in enumerate(lines, 1): 

158 if k < len(spec.patterns) and spec.patterns[k].pattern == line: 

159 line_of.append(number) 

160 k += 1 

161 entry = (spec, line_of) 

162 except (ImportError, OSError, ValueError): 

163 entry = None 

164 self._specs[folder] = entry 

165 return self._specs[folder] 

166 

167 def check(self, path: Path, is_dir: bool) -> str | None: 

168 """Where ``path`` is ignored ("…/.gitignore:LINE"), or None if it isn't.""" 

169 path = Path(path) 

170 return self.check_name(path.parent.resolve(), path.name, is_dir) 

171 

172 def check_name(self, folder: Path, name: str, is_dir: bool) -> str | None: 

173 """Where the entry ``name`` of ``folder`` (resolved) is ignored, or None. 

174 The deepest .gitignore with a matching rule decides.""" 

175 suffix = "/" if is_dir else "" 

176 for ignore_folder, spec, line_of, prefix in self._chain(folder): # (deepest first) 

177 result = spec.check_file(prefix + name + suffix) 

178 if result.include is not None: 

179 if not result.include: 

180 return None # re-included by a "!" rule 

181 line = line_of[result.index] if result.index is not None and result.index < len(line_of) else None 

182 return f"{ignore_folder / '.gitignore'}:{line}" if line else str(ignore_folder / ".gitignore") 

183 return None 

184 

185 def _chain(self, folder: Path) -> list[tuple[Path, object, list[int], str]]: 

186 """The .gitignore files that apply to ``folder``'s entries, deepest 

187 first: (their folder, rules, rules' lines, ``folder``'s path from there 

188 with a "/"). Worked out once per folder: per entry, its paths and 

189 parents took most of a big project's first filter.""" 

190 chain = self._chains.get(folder) 

191 if chain is None: 

192 chain = [] 

193 if folder == self.root or self.root in folder.parents: 

194 for above in (folder, *folder.parents): 194 ↛ 201line 194 didn't jump to line 201 because the loop on line 194 didn't complete

195 entry = self._spec(above) 

196 if entry is not None: 

197 relative = folder.relative_to(above).as_posix() 

198 chain.append((above, *entry, "" if relative == "." else relative + "/")) 

199 if above == self.root: 

200 break 

201 self._chains[folder] = chain 

202 return chain 

203 

204 

205def reveal_in_file_manager(path: str): 

206 """Show a file (selected) or folder in Finder / Explorer / the file manager.""" 

207 if sys.platform == "darwin": 

208 subprocess.run(["open", "-R", path], check=False) 

209 elif sys.platform == "win32": 

210 subprocess.run(["explorer", "/select,", os.path.normpath(path)], check=False) 

211 else: 

212 folder = path if Path(path).is_dir() else str(Path(path).parent) 

213 QDesktopServices.openUrl(QUrl.fromLocalFile(folder)) 

214 

215 

216# Keys that move the tree's selection: a file reached with them is previewed 

217# once the selection has rested this long. 

218NAVIGATION_KEYS = {Qt.Key_Up, Qt.Key_Down, Qt.Key_PageUp, Qt.Key_PageDown, Qt.Key_Home, Qt.Key_End} 

219KEY_PREVIEW_MS = 150 

220 

221 

222class _Filter(QSortFilterProxyModel): 

223 """Case-insensitive name filter that keeps the parents of matches. 

224 Files ignored by git and greyed-out entries only match when 

225 ``include_ignored`` is set (they are most of a big project's files).""" 

226 

227 def __init__(self, parent=None): 

228 super().__init__(parent) 

229 self.setRecursiveFilteringEnabled(True) 

230 self.needle = "" 

231 self.include_ignored = False 

232 

233 def set_filter(self, needle: str, include_ignored: bool): 

234 self.beginFilterChange() 

235 self.needle, self.include_ignored = needle.lower(), include_ignored 

236 self.endFilterChange(QSortFilterProxyModel.Direction.Rows) 

237 

238 def filterAcceptsRow(self, row, parent): 

239 if not self.needle: 

240 return True 

241 item = self.sourceModel().itemFromIndex(self.sourceModel().index(row, 0, parent)) 

242 if item is None or item.data(KIND_ROLE) != "file": 

243 return False # folders are shown when something inside matches 

244 if not self.include_ignored and (item.data(IGNORED_ROLE) or item.data(HIDDEN_ROLE)): 

245 return False 

246 return self.needle in item.text().lower() 

247 

248 

249class ProjectTree(QWidget): 

250 """The file tree. Emits `open_requested` with a file path.""" 

251 

252 open_requested = Signal(str) 

253 compile_command_requested = Signal(str) 

254 preview_requested = Signal(str) # a file clicked once: shown in a preview tab 

255 rename_requested = Signal(str) # a file to rename (the window asks for the new name) 

256 delete_requested = Signal(str) # a file to move to the trash (the window asks first) 

257 

258 def __init__(self, parent=None): 

259 super().__init__(parent) 

260 self.project_dir: Path | None = None 

261 self.graph: m.DependencyGraph | None = None 

262 self._items: dict[str, QStandardItem] = {} # path -> file/dir item 

263 self._marks: dict[str, str] = {} # path -> "error" / "warning" 

264 self._real_dirs: dict[str, str] = {} # folder -> its resolved path (see _real) 

265 self._icons = QFileIconProvider() 

266 self._folder_icon = self._icons.icon(QFileIconProvider.Folder) 

267 self._icon_cache: dict = {} 

268 self._skip_dirs: set[Path] = set() 

269 self._roles: dict[str, str] = {} # folder -> fpm role 

270 self._ignores: dict[str, GitIgnore] = {} # package folder -> its rules 

271 self._role_icons: dict = {} 

272 self._all_loaded: bool | None = None # None, or whether ignored folders were loaded too 

273 

274 self.filter_edit = QLineEdit() 

275 self.filter_edit.setPlaceholderText("Filter files…") 

276 self.filter_edit.setClearButtonEnabled(True) 

277 # Filter once typing pauses, not on every keystroke. 

278 self._filter_timer = QTimer(self) 

279 self._filter_timer.setSingleShot(True) 

280 self._filter_timer.setInterval(200) 

281 self._filter_timer.timeout.connect(lambda: self._apply_filter(self.filter_edit.text())) 

282 self.filter_edit.textChanged.connect(self._filter_timer.start) 

283 self.include_ignored = QCheckBox("Ignored") 

284 self.include_ignored.setToolTip("Also match files ignored by git and greyed-out files " 

285 "(hidden, compiled, build/) — slower on big projects") 

286 self.include_ignored.toggled.connect(lambda: self._apply_filter(self.filter_edit.text())) 

287 

288 self.model = QStandardItemModel(self) 

289 self.proxy = _Filter(self) 

290 self.proxy.setSourceModel(self.model) 

291 self.view = QTreeView() 

292 self.view.setModel(self.proxy) 

293 self.view.setHeaderHidden(True) 

294 self.view.setUniformRowHeights(True) 

295 self.view.setEditTriggers(QAbstractItemView.NoEditTriggers) 

296 self.view.setContextMenuPolicy(Qt.CustomContextMenu) 

297 self.view.customContextMenuRequested.connect(self._context_menu) 

298 self.view.activated.connect(self._activated) # double-click / Enter 

299 self.view.clicked.connect(self._clicked) # one click: a preview 

300 # Moving through the files with the arrow keys previews them too, once 

301 # the selection rests (holding ↓ doesn't open every file on the way). 

302 # Only keys count: the app moves the selection itself too (revealing 

303 # the current tab's file, a refresh), and that mustn't open anything. 

304 self._key_moving = False 

305 self._key_preview_timer = QTimer(self) 

306 self._key_preview_timer.setSingleShot(True) 

307 self._key_preview_timer.setInterval(KEY_PREVIEW_MS) 

308 self._key_preview_timer.timeout.connect(lambda: self._clicked(self.view.currentIndex())) 

309 self.view.installEventFilter(self) 

310 self.view.selectionModel().currentChanged.connect(self._current_changed) 

311 self.view.expanded.connect(self._expanded) # folders load when opened 

312 

313 self.empty = QLabel("Open an fpm.toml to see its files.") 

314 self.empty.setWordWrap(True) 

315 self.empty.setStyleSheet("color: gray;") 

316 

317 layout = QVBoxLayout(self) 

318 layout.setContentsMargins(0, 0, 0, 0) 

319 layout.setSpacing(4) 

320 filter_row = QHBoxLayout() 

321 filter_row.setSpacing(6) 

322 filter_row.addWidget(self.filter_edit, 1) 

323 filter_row.addWidget(self.include_ignored) 

324 layout.addLayout(filter_row) 

325 layout.addWidget(self.view, 1) 

326 layout.addWidget(self.empty) 

327 self._update_empty() 

328 

329 # ------------------------------------------------------------- content 

330 def set_project(self, graph: m.DependencyGraph | None, project_dir: Path | None): 

331 new_project = project_dir != self.project_dir 

332 self.graph, self.project_dir = graph, project_dir 

333 if new_project: 

334 self._marks.clear() 

335 self.refresh(keep_state=not new_project) 

336 

337 def refresh(self, keep_state: bool = True): 

338 """Rescan the folders (keeping open folders and the selection).""" 

339 expanded = self._expanded_paths() if keep_state else None 

340 selected = self._selected_path() if keep_state else None 

341 self.model.clear() 

342 self._items.clear() 

343 self._real_dirs.clear() 

344 self._all_loaded = None 

345 if self.project_dir is None: 345 ↛ 346line 345 didn't jump to line 346 because the condition on line 345 was never true

346 self._update_empty() 

347 return 

348 packages = self._packages() 

349 package_dirs = {p.resolve() for _, p, _ in packages} 

350 # A folder that is another package (e.g. a path dependency inside the 

351 # project) is listed under that package, not inside this one. 

352 self._skip_dirs = package_dirs 

353 self._ignores = {str(d.resolve()): GitIgnore(d) for _, d, _ in packages} 

354 self._roles = {} 

355 for _, directory, _ in packages: 

356 manifest = directory / "fpm.toml" 

357 if manifest.is_file(): 357 ↛ 355line 357 didn't jump to line 355 because the condition on line 357 was always true

358 self._roles.update(fpm_folders(manifest)) 

359 root_item = None 

360 deps_group = None 

361 for name, directory, is_root in packages: 

362 item = self._package_item(name, directory, is_root) 

363 self._fill(item, directory) 

364 if is_root: 

365 self.model.appendRow(item) 

366 root_item = item 

367 else: 

368 if deps_group is None: 

369 deps_group = QStandardItem("Dependencies") 

370 deps_group.setData("group", KIND_ROLE) 

371 deps_group.setIcon(self._icons.icon(QFileIconProvider.Folder)) 

372 font = QFont(deps_group.font()) 

373 font.setBold(True) 

374 deps_group.setFont(font) 

375 self.model.appendRow(deps_group) 

376 deps_group.appendRow(item) 

377 self.refresh_marks() 

378 self._update_empty() 

379 if expanded is None: 

380 if root_item is not None: 380 ↛ 384line 380 didn't jump to line 384 because the condition on line 380 was always true

381 self.view.expand(self.proxy.mapFromSource(root_item.index())) 

382 else: 

383 self._restore_expanded(expanded) 

384 if selected: 

385 self.reveal(selected, expand=False) 

386 self._apply_filter(self.filter_edit.text()) 

387 

388 def _packages(self) -> list[tuple[str, Path, bool]]: 

389 """(name, folder, is_root) for the project and dependencies on disk.""" 

390 out = [] 

391 if self.graph is not None: 391 ↛ 395line 391 didn't jump to line 395 because the condition on line 391 was always true

392 root = self.graph.packages.get(self.graph.root) 

393 name = root.name if root else self.project_dir.name 

394 else: 

395 name = self.project_dir.name 

396 out.append((name, self.project_dir, True)) 

397 if self.graph is not None: 397 ↛ 403line 397 didn't jump to line 403 because the condition on line 397 was always true

398 for pkg in self.graph.packages.values(): 

399 if pkg.name == self.graph.root or pkg.kind in (m.META, m.MISSING): 

400 continue 

401 if pkg.directory and pkg.directory.is_dir(): 

402 out.append((pkg.name, pkg.directory, False)) 

403 return out 

404 

405 def _package_item(self, name: str, directory: Path, is_root: bool) -> QStandardItem: 

406 item = QStandardItem(name) 

407 item.setData(str(directory), PATH_ROLE) 

408 item.setData("package", KIND_ROLE) 

409 item.setIcon(self._icons.icon(QFileIconProvider.Folder)) 

410 item.setToolTip(str(directory)) 

411 font = QFont(item.font()) 

412 font.setBold(True) 

413 item.setFont(font) 

414 if not is_root: 

415 kind = self.graph.packages[name].kind if self.graph and name in self.graph.packages else "" 

416 item.setText(f"{name} ({'local path' if kind == m.PATH else 'git'})" if kind else name) 

417 return item 

418 

419 def _fill(self, parent: QStandardItem, directory: Path): 

420 """List one folder: subfolders first, then files. Subfolders are 

421 loaded when opened (see `_load`), so big trees stay fast.""" 

422 parent.setData(str(directory), PATH_ROLE) 

423 self._load(parent) 

424 

425 def _load(self, item: QStandardItem): 

426 """Fill a folder item with its contents (once).""" 

427 if item.data(LOADED_ROLE): 

428 return 

429 item.setData(True, LOADED_ROLE) 

430 item.removeRows(0, item.rowCount()) # the "loading" placeholder 

431 folder = Path(item.data(PATH_ROLE)) 

432 try: 

433 entries = sorted(os.scandir(folder), key=lambda e: (not e.is_dir(), e.name.lower())) 

434 except OSError: 

435 return 

436 rules = self._rules_for(folder) 

437 real_folder = folder.resolve() 

438 parent_ignored = item.data(IGNORED_ROLE) # inside an ignored folder 

439 

440 def ignored(name: str, is_dir: bool) -> str | None: 

441 return parent_ignored or (rules.check_name(real_folder, name, is_dir) if rules else None) 

442 for entry in entries: 

443 name = entry.name 

444 path = Path(entry.path) 

445 if entry.is_dir(follow_symlinks=False): 

446 if path.resolve() in self._skip_dirs: 

447 self._add_hidden(item, path, "dir", 

448 "A package of its own — listed under Dependencies " 

449 "(double-click to go there)", link=True) 

450 continue 

451 if name.startswith(".") or name in SKIP_DIRS: 

452 why = ("fpm's build output — see the Build outputs tab" if name == "build" 

453 else "Hidden folder" if name.startswith(".") else "Generated folder") 

454 self._add_hidden(item, path, "dir", why, ignored=ignored(name, True)) 

455 continue 

456 child = QStandardItem(name) 

457 child.setData(str(path), PATH_ROLE) 

458 child.setData("dir", KIND_ROLE) 

459 self._set_ignored(child, path, ignored(name, True)) 

460 role = self._roles.get(str(path.resolve())) 

461 if role: 

462 child.setIcon(self._role_icon(role)) 

463 child.setToolTip(f"{ROLES[role][1]} — {path}") 

464 else: 

465 child.setIcon(self._folder_icon) 

466 placeholder = QStandardItem("…") 

467 placeholder.setData("placeholder", KIND_ROLE) 

468 placeholder.setEnabled(False) 

469 child.appendRow(placeholder) # gives the folder an expand arrow 

470 item.appendRow(child) 

471 self._items[str(path)] = child 

472 elif entry.is_file(): 472 ↛ 442line 472 didn't jump to line 442 because the condition on line 472 was always true

473 hidden = ("Hidden file" if name.startswith(".") else 

474 "Compiled / generated file" 

475 if path.suffix in SKIP_SUFFIXES or path.suffix.lower() in SKIP_SUFFIXES else "") 

476 if hidden: 

477 self._add_hidden(item, path, "file", hidden, ignored=ignored(name, False)) 

478 continue 

479 child = QStandardItem(name) 

480 child.setData(str(path), PATH_ROLE) 

481 child.setData("file", KIND_ROLE) 

482 self._set_ignored(child, path, ignored(name, False)) 

483 child.setIcon(self._manifest_icon() if name == "fpm.toml" else self._file_icon(path)) 

484 item.appendRow(child) 

485 self._items[str(path)] = child 

486 self._mark_item(str(path), child) 

487 

488 def _rules_for(self, folder: Path) -> GitIgnore | None: 

489 """The ignore rules of the package a folder belongs to.""" 

490 resolved = folder.resolve() 

491 best = None 

492 for pkg, rules in self._ignores.items(): 

493 pkg_path = Path(pkg) 

494 if (resolved == pkg_path or pkg_path in resolved.parents) and \ 

495 (best is None or len(pkg) > len(str(best.package_dir))): 

496 best = rules 

497 return best 

498 

499 def _set_ignored(self, item: QStandardItem, path: Path, source: str | None): 

500 """``source``: the .gitignore (and line) that ignores it, if one does.""" 

501 if source: 

502 item.setData(str(source), IGNORED_ROLE) 

503 item.setForeground(self._ignored_grey()) 

504 item.setToolTip(f"Ignored by git: {source}\n{path}") 

505 

506 def _ignored_grey(self) -> QColor: 

507 """Between normal text and the hidden-entry grey. The alpha is mixed 

508 too: macOS's dark palette uses white at different opacities for 

509 normal (~85%) and disabled (~25%) text, so mixing only RGB gave white.""" 

510 pal = QApplication.palette() 

511 a, b = pal.color(QPalette.Text), pal.color(QPalette.Disabled, QPalette.Text) 

512 return QColor((a.red() + b.red()) // 2, (a.green() + b.green()) // 2, 

513 (a.blue() + b.blue()) // 2, (a.alpha() + b.alpha()) // 2) 

514 

515 def _add_hidden(self, parent: QStandardItem, path: Path, kind: str, why: str, link: bool = False, 

516 ignored: str | None = None): 

517 """A greyed-out entry: listed, but folders can't be opened in the tree 

518 (double-click shows them in the file manager; files still open).""" 

519 child = QStandardItem(path.name) 

520 child.setData(str(path), PATH_ROLE) 

521 child.setData(kind, KIND_ROLE) 

522 child.setData(why, HIDDEN_ROLE) 

523 if link: 

524 child.setData(str(path.resolve()), LINK_ROLE) 

525 also = f"\nAlso ignored by git: {ignored}" if ignored else "" 

526 if ignored: 526 ↛ 527line 526 didn't jump to line 527 because the condition on line 526 was never true

527 child.setData(ignored, IGNORED_ROLE) 

528 if kind == "dir": 

529 child.setData(True, LOADED_ROLE) # never filled, so no expand arrow 

530 child.setIcon(self._folder_icon) 

531 action = ("double-click to go there" if link 

532 else f"double-click to show it in {file_manager_name()}") 

533 child.setToolTip(f"{why}{also}\n{path}" if link else f"{why} ({action}){also}\n{path}") 

534 else: 

535 child.setIcon(self._file_icon(path)) 

536 child.setToolTip(f"{why}{also}\n{path}") 

537 child.setForeground(self._grey()) 

538 parent.appendRow(child) 

539 self._items[str(path)] = child 

540 

541 def _grey(self) -> QColor: 

542 return QApplication.palette().color(QPalette.Disabled, QPalette.Text) 

543 

544 def _load_all(self, include_ignored: bool = False): 

545 """Load every folder (for filtering, which must see all files); 

546 folders ignored by git only if ``include_ignored``.""" 

547 if self._all_loaded or self._all_loaded == include_ignored: 

548 return 

549 QApplication.setOverrideCursor(Qt.WaitCursor) 

550 try: 

551 stack = [self.model.item(r) for r in range(self.model.rowCount())] 

552 while stack: 

553 item = stack.pop() 

554 if item is None: 554 ↛ 555line 554 didn't jump to line 555 because the condition on line 554 was never true

555 continue 

556 if item.data(KIND_ROLE) in ("package", "dir"): 

557 if item.data(IGNORED_ROLE) and not include_ignored: 

558 continue 

559 self._load(item) 

560 for r in range(item.rowCount()): 

561 stack.append(item.child(r)) 

562 self._all_loaded = include_ignored 

563 finally: 

564 QApplication.restoreOverrideCursor() 

565 

566 def all_files(self) -> list[ProjectFile]: 

567 """Every file the tree lists (the project's and its dependencies'), 

568 for Go to File (⌘P): the greyed-out and git-ignored ones only with 

569 *Ignored* ticked, as the filter has them. Loads every folder first.""" 

570 include = self.include_ignored.isChecked() 

571 self._load_all(include) 

572 out, seen = [], set() 

573 stack = [(self.model.item(r), None) for r in range(self.model.rowCount())] 

574 while stack: 

575 item, package = stack.pop() 

576 if item is None: 576 ↛ 577line 576 didn't jump to line 577 because the condition on line 576 was never true

577 continue 

578 kind = item.data(KIND_ROLE) 

579 if kind == "package": 

580 package = (item.text().split(" (")[0], Path(item.data(PATH_ROLE)), 

581 item.parent() is not None) # (a dependency's: under the Dependencies group) 

582 elif kind == "file" and package is not None: 

583 hidden = bool(item.data(IGNORED_ROLE) or item.data(HIDDEN_ROLE)) 

584 path = item.data(PATH_ROLE) 

585 if (include or not hidden) and path not in seen: 

586 seen.add(path) 

587 name, folder, dependency = package 

588 try: 

589 relative = Path(path).relative_to(folder).as_posix() 

590 except ValueError: 

591 relative = Path(path).name 

592 out.append(ProjectFile(path, relative, name, dependency, hidden)) 

593 continue 

594 elif kind == "dir" and item.data(IGNORED_ROLE) and not include: 

595 continue 

596 stack.extend((item.child(r), package) for r in range(item.rowCount())) 

597 return out 

598 

599 def _expanded(self, proxy_index): 

600 item = self.model.itemFromIndex(self.proxy.mapToSource(proxy_index)) 

601 if item is not None and item.data(KIND_ROLE) in ("package", "dir"): 

602 self._load(item) 

603 

604 def _role_icon(self, role: str): 

605 if role not in self._role_icons: 

606 self._role_icons[role] = badged_icon(self._folder_icon, ROLES[role][0]) 

607 return self._role_icons[role] 

608 

609 def _manifest_icon(self): 

610 if "manifest" not in self._role_icons: 

611 base = self._icons.icon(QFileIconProvider.File) 

612 self._role_icons["manifest"] = badged_icon(base, "manifest") 

613 return self._role_icons["manifest"] 

614 

615 def icon_for(self, path: Path): 

616 """A file's icon, as in the tree (e.g. for the Search tab).""" 

617 return self._manifest_icon() if path.name == "fpm.toml" else self._file_icon(path) 

618 

619 def _file_icon(self, path: Path): 

620 """Icons by file type (asking the OS once per type, not per file).""" 

621 key = path.suffix.lower() or path.name 

622 icon = self._icon_cache.get(key) 

623 if icon is None: 

624 icon = self._icon_cache[key] = ( 

625 fortran_file_icon() if key in FORTRAN_SUFFIXES 

626 else fortran_file_icon(include=True) if key in FORTRAN_INCLUDE_SUFFIXES 

627 else self._icons.icon(QFileInfo(str(path)))) 

628 return icon 

629 

630 # -------------------------------------------------- build results marks 

631 def set_marks(self, marks: dict[str, str]): 

632 """Color files by the last build: {path: "error" | "warning"}.""" 

633 self._marks = {self._real(os.path.abspath(p)): v for p, v in marks.items()} 

634 self.refresh_marks() 

635 

636 def refresh_marks(self): 

637 for path, item in self._items.items(): 

638 if item.data(KIND_ROLE) == "file" or item.data(HIDDEN_ROLE) or item.data(IGNORED_ROLE): 

639 self._mark_item(path, item) 

640 

641 def _real(self, path: str) -> str: 

642 """A file's path with its folder resolved (as a build's file paths are: 

643 its package's folder resolved, then the file in it). The folder is 

644 resolved once: resolving each of a big project's files, again after 

645 every build, held the window (slowest on Windows).""" 

646 folder, name = os.path.split(path) 

647 real = self._real_dirs.get(folder) 

648 if real is None: 

649 real = self._real_dirs[folder] = str(Path(folder).resolve()) 

650 return os.path.join(real, name) 

651 

652 def _mark_item(self, path: str, item: QStandardItem): 

653 if item.data(HIDDEN_ROLE): 

654 item.setForeground(self._grey()) # greyed entries stay grey 

655 return 

656 mark = self._marks.get(self._real(path)) if self._marks else None 

657 ignored = item.data(IGNORED_ROLE) 

658 if mark: 

659 item.setForeground(QColor(FILE_COLORS[mark])) 

660 elif ignored: 

661 item.setForeground(self._ignored_grey()) 

662 else: 

663 item.setData(None, Qt.ForegroundRole) # the normal text color 

664 tip = (f"Ignored by git: {ignored}\n" if ignored else "") + path + { 

665 "error": "\n(errors in the last build)", 

666 "warning": "\n(warnings in the last build)"}.get(mark, "") 

667 item.setToolTip(tip) 

668 

669 # -------------------------------------------------------------- filter 

670 def _apply_filter(self, text: str): 

671 self._filter_timer.stop() 

672 needle = text.strip() 

673 include = self.include_ignored.isChecked() 

674 if needle: 

675 self._load_all(include) 

676 # Re-filtering a fully expanded tree of thousands of rows is slow 

677 # in QTreeView (seconds); everything is expanded again below. 

678 self.view.collapseAll() 

679 self.proxy.set_filter(needle, include) 

680 if needle: 

681 self.view.expandAll() 

682 self._update_empty() 

683 

684 def _update_empty(self): 

685 has_rows = self.proxy.rowCount() > 0 

686 if self.project_dir is None: 

687 self.empty.setText("Open an fpm.toml to see its files.") 

688 elif not has_rows: 

689 if not self.filter_edit.text().strip(): 689 ↛ 690line 689 didn't jump to line 690 because the condition on line 689 was never true

690 self.empty.setText("No files found.") 

691 elif self.include_ignored.isChecked(): 691 ↛ 692line 691 didn't jump to line 692 because the condition on line 691 was never true

692 self.empty.setText("No files match the filter.") 

693 else: 

694 self.empty.setText("No files match the filter (tick Ignored to also " 

695 "search files ignored by git and greyed-out files).") 

696 self.empty.setVisible(not has_rows) 

697 self.view.setVisible(has_rows) 

698 

699 # ---------------------------------------------------------- selection 

700 def reveal(self, path: str, expand: bool = True) -> bool: 

701 """Select a file's row (e.g. the file in the current editor tab); 

702 False if it isn't in the tree (outside the project, or filtered out).""" 

703 item = self._items.get(str(path)) or self._items.get(str(Path(path).resolve())) 

704 if item is None: 

705 item = self._load_down_to(Path(path)) 

706 if item is None: 

707 return False 

708 index = self.proxy.mapFromSource(item.index()) 

709 if not index.isValid(): 709 ↛ 710line 709 didn't jump to line 710 because the condition on line 709 was never true

710 return False 

711 if expand: 

712 parent = index.parent() 

713 while parent.isValid(): 

714 self.view.expand(parent) 

715 parent = parent.parent() 

716 self.view.setCurrentIndex(index) 

717 self.view.scrollTo(index) 

718 return True 

719 

720 def reveal_package(self, directory: Path) -> bool: 

721 """Select a package's row (the project, or one under Dependencies), 

722 open it, and scroll to it. False if it isn't in the tree.""" 

723 target = str(Path(directory).resolve()) 

724 stack = [self.model.item(r) for r in range(self.model.rowCount())] 

725 while stack: 

726 item = stack.pop() 

727 if item is None: 

728 continue 

729 if item.data(KIND_ROLE) == "package" and str(Path(item.data(PATH_ROLE)).resolve()) == target: 

730 index = self.proxy.mapFromSource(item.index()) 

731 if not index.isValid(): 

732 return False 

733 parent = index.parent() 

734 while parent.isValid(): 

735 self.view.expand(parent) 

736 parent = parent.parent() 

737 self.view.expand(index) 

738 self.view.setCurrentIndex(index) 

739 self.view.scrollTo(index, QAbstractItemView.PositionAtTop) 

740 return True 

741 if item.data(KIND_ROLE) == "group": 

742 stack.extend(item.child(r) for r in range(item.rowCount())) 

743 return False 

744 

745 def _load_down_to(self, path: Path) -> QStandardItem | None: 

746 """Load the folders between a package and ``path``; return its item.""" 

747 target = path.resolve() 

748 packages = [] 

749 stack = [self.model.item(r) for r in range(self.model.rowCount())] 

750 while stack: 

751 item = stack.pop() 

752 if item is None: 752 ↛ 753line 752 didn't jump to line 753 because the condition on line 752 was never true

753 continue 

754 if item.data(KIND_ROLE) == "package": 

755 packages.append(item) 

756 elif item.data(KIND_ROLE) == "group": 756 ↛ 750line 756 didn't jump to line 750 because the condition on line 756 was always true

757 stack.extend(item.child(r) for r in range(item.rowCount())) 

758 for package in packages: 

759 root = Path(package.data(PATH_ROLE)).resolve() 

760 try: 

761 parts = target.relative_to(root).parts 

762 except ValueError: 

763 continue 

764 item = package 

765 for depth in range(len(parts)): 

766 self._load(item) 

767 wanted = str(root.joinpath(*parts[:depth + 1])) 

768 item = self._items.get(wanted) 

769 if item is None: 

770 break 

771 else: 

772 return item 

773 return None 

774 

775 def view_state(self) -> dict | None: 

776 """The open folders and the selected entry (to restore next time), 

777 or None while the tree is filtered (everything is expanded then).""" 

778 if self.filter_edit.text().strip(): 

779 return None 

780 return {"expanded": sorted(self._expanded_paths()), "selected": self._selected_path()} 

781 

782 def restore_view_state(self, state: dict): 

783 self._restore_expanded(set(state.get("expanded") or [])) 

784 selected = state.get("selected") 

785 if selected and Path(selected).exists(): 

786 self.reveal(selected, expand=False) 

787 

788 def _selected_path(self) -> str | None: 

789 index = self.view.currentIndex() 

790 return index.data(PATH_ROLE) if index.isValid() else None 

791 

792 def _expanded_paths(self) -> set[str]: 

793 paths = set() 

794 

795 def visit(parent): 

796 for row in range(self.proxy.rowCount(parent)): 

797 index = self.proxy.index(row, 0, parent) 

798 if self.view.isExpanded(index): 

799 paths.add(index.data(PATH_ROLE) or "group:" + str(index.data())) 

800 visit(index) 

801 

802 visit(self.view.rootIndex()) 

803 return paths 

804 

805 def _restore_expanded(self, paths: set[str]): 

806 def visit(parent): 

807 for row in range(self.proxy.rowCount(parent)): 

808 index = self.proxy.index(row, 0, parent) 

809 key = index.data(PATH_ROLE) or "group:" + str(index.data()) 

810 if key in paths: 

811 self.view.expand(index) 

812 visit(index) 

813 

814 visit(self.view.rootIndex()) 

815 

816 # ------------------------------------------------------------- actions 

817 def eventFilter(self, obj, event): 

818 if obj is self.view and event.type() == QEvent.KeyPress and event.key() in NAVIGATION_KEYS: 

819 self._key_moving = True # (the view moves the selection now: see _current_changed) 

820 QTimer.singleShot(0, self, lambda: setattr(self, "_key_moving", False)) 

821 return super().eventFilter(obj, event) 

822 

823 def _current_changed(self, current, _previous): 

824 if self._key_moving: 

825 self._key_preview_timer.start() 

826 

827 def _clicked(self, index): 

828 if index.data(KIND_ROLE) == "file" and not index.data(HIDDEN_ROLE) and index.data(PATH_ROLE): 828 ↛ exitline 828 didn't return from function '_clicked' because the condition on line 828 was always true

829 self.preview_requested.emit(index.data(PATH_ROLE)) 

830 

831 def _activated(self, index): 

832 path = index.data(PATH_ROLE) 

833 if index.data(KIND_ROLE) == "file": 

834 self.open_requested.emit(path) 

835 elif index.data(LINK_ROLE): 

836 self._go_to_package(index.data(LINK_ROLE)) 

837 elif index.data(HIDDEN_ROLE) and path: 

838 reveal_in_file_manager(path) 

839 

840 def _go_to_package(self, directory: str): 

841 """Select the Dependencies entry for a package folder.""" 

842 stack = [self.model.item(r) for r in range(self.model.rowCount())] 

843 while stack: 

844 item = stack.pop() 

845 if item is None: 

846 continue 

847 if item.data(KIND_ROLE) == "package" and \ 

848 str(Path(item.data(PATH_ROLE)).resolve()) == directory: 

849 index = self.proxy.mapFromSource(item.index()) 

850 parent = index.parent() 

851 while parent.isValid(): 

852 self.view.expand(parent) 

853 parent = parent.parent() 

854 self.view.setCurrentIndex(index) 

855 self.view.scrollTo(index) 

856 return 

857 stack.extend(item.child(r) for r in range(item.rowCount())) 

858 

859 def _context_menu(self, pos): 

860 menu = self.context_menu(pos) 

861 menu.exec(self.view.viewport().mapToGlobal(pos)) 

862 menu.deleteLater() 

863 

864 def context_menu(self, pos) -> QMenu: 

865 """The right-click menu for the row at ``pos`` (viewport coordinates).""" 

866 index = self.view.indexAt(pos) 

867 path = index.data(PATH_ROLE) if index.isValid() else None 

868 is_file = bool(path) and index.data(KIND_ROLE) == "file" 

869 menu = QMenu(self) 

870 if is_file: 

871 menu.addAction("Open", lambda: self.open_requested.emit(path)) 

872 menu.addAction("Open in Default App", 

873 lambda: QDesktopServices.openUrl(QUrl.fromLocalFile(path))) 

874 if Path(path).suffix.lower() in SOURCE_EXTS: 874 ↛ 877line 874 didn't jump to line 877 because the condition on line 874 was always true

875 menu.addAction("Show Compile Command…", 

876 lambda: self.compile_command_requested.emit(path)) 

877 if path: 877 ↛ 881line 877 didn't jump to line 881 because the condition on line 877 was always true

878 menu.addAction(reveal_label(), lambda: reveal_in_file_manager(path)) 

879 menu.addAction("Copy Path", lambda: QApplication.clipboard().setText(path)) 

880 menu.addSeparator() 

881 if is_file: 

882 menu.addAction("Rename…", lambda: self.rename_requested.emit(path)) 

883 menu.addAction("Move to Trash…", lambda: self.delete_requested.emit(path)) 

884 menu.addSeparator() 

885 menu.addAction("Refresh", self.refresh) 

886 menu.addAction("Collapse All", self.view.collapseAll) 

887 return menu