vibefpmgui.project_tree
Sidebar tree of the project's files and those of its dependencies.
The project folder is listed first; each dependency (local path or a git
download in build/dependencies) gets its own node under Dependencies.
Build output, hidden folders and compiled files are left out. A filter box
narrows the tree to matching file names. Double-click (or Enter) opens a file.
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(): 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): 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): 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: 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(): 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: 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: 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: 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(): 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: 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: 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: 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(): 690 self.empty.setText("No files found.") 691 elif self.include_ignored.isChecked(): 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(): 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: 753 continue 754 if item.data(KIND_ROLE) == "package": 755 packages.append(item) 756 elif item.data(KIND_ROLE) == "group": 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): 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: 875 menu.addAction("Show Compile Command…", 876 lambda: self.compile_command_requested.emit(path)) 877 if path: 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
82def fpm_folders(manifest_path: Path) -> dict[str, str]: 83 """Absolute folder -> role for the standard fpm folders of one package, 84 following the manifest's source-dir / include-dir settings (defaults: 85 src, include, app, test, example).""" 86 root = manifest_path.parent 87 try: 88 data = m.load_toml(manifest_path) 89 except Exception: # noqa: BLE001 - an unreadable manifest just gets defaults 90 data = {} 91 roles: dict[str, str] = {} 92 93 def add(folder, role): 94 for f in folder if isinstance(folder, list) else [folder]: 95 if isinstance(f, str) and f.strip(): 96 path = (root / f).resolve() 97 if path.is_dir() and path != root.resolve(): 98 roles.setdefault(str(path), role) 99 100 library = data.get("library") 101 library = library if isinstance(library, dict) else {} 102 add(library.get("source-dir", "src"), "library") 103 add(library.get("include-dir", "include"), "include") 104 for section, role, default in (("executable", "app", "app"), ("test", "test", "test"), 105 ("example", "example", "example")): 106 targets = data.get(section) 107 targets = targets if isinstance(targets, list) else [] 108 dirs = {t.get("source-dir", default) for t in targets if isinstance(t, dict)} or {default} 109 for folder in dirs: 110 add(folder, role) 111 return roles
Absolute folder -> role for the standard fpm folders of one package, following the manifest's source-dir / include-dir settings (defaults: src, include, app, test, example).
114@dataclass(frozen=True) 115class ProjectFile: 116 """A file of the project (or a dependency), as Go to File lists it.""" 117 path: str # (absolute) 118 relative: str # in its package's folder ("src/x.f90") 119 package: str # its package's name 120 dependency: bool # a dependency's (not the project's own) 121 hidden: bool = False # ignored by git, or greyed out (listed only with Ignored ticked)
A file of the project (or a dependency), as Go to File lists it.
124class GitIgnore: 125 """git's ignore rules for one package: the .gitignore files from the root 126 of its git repository (or the package folder, if it isn't in one) down to 127 each folder, combined the way git does (deeper files win, "!" re-includes, 128 and everything inside an ignored folder is ignored).""" 129 130 def __init__(self, package_dir: Path): 131 self.package_dir = package_dir.resolve() 132 self.root = self._repo_root(self.package_dir) 133 self._specs: dict[Path, object] = {} 134 self._chains: dict[Path, list] = {} # folder -> the rules that apply to its entries (see _chain) 135 136 @staticmethod 137 def _repo_root(start: Path) -> Path: 138 home = Path.home().resolve() 139 for folder in (start, *start.parents): 140 if (folder / ".git").exists(): 141 return folder 142 if folder == home or folder.parent == folder: 143 break 144 return start 145 146 def _spec(self, folder: Path): 147 """(spec, line number of each pattern) for folder/.gitignore, or None.""" 148 if folder not in self._specs: 149 entry = None 150 ignore_file = folder / ".gitignore" 151 if ignore_file.is_file(): 152 try: 153 import pathspec 154 lines = ignore_file.read_text(errors="replace").splitlines() 155 spec = pathspec.GitIgnoreSpec.from_lines(lines) 156 # pathspec skips blank lines; map each pattern back to its line. 157 line_of, k = [], 0 158 for number, line in enumerate(lines, 1): 159 if k < len(spec.patterns) and spec.patterns[k].pattern == line: 160 line_of.append(number) 161 k += 1 162 entry = (spec, line_of) 163 except (ImportError, OSError, ValueError): 164 entry = None 165 self._specs[folder] = entry 166 return self._specs[folder] 167 168 def check(self, path: Path, is_dir: bool) -> str | None: 169 """Where ``path`` is ignored ("…/.gitignore:LINE"), or None if it isn't.""" 170 path = Path(path) 171 return self.check_name(path.parent.resolve(), path.name, is_dir) 172 173 def check_name(self, folder: Path, name: str, is_dir: bool) -> str | None: 174 """Where the entry ``name`` of ``folder`` (resolved) is ignored, or None. 175 The deepest .gitignore with a matching rule decides.""" 176 suffix = "/" if is_dir else "" 177 for ignore_folder, spec, line_of, prefix in self._chain(folder): # (deepest first) 178 result = spec.check_file(prefix + name + suffix) 179 if result.include is not None: 180 if not result.include: 181 return None # re-included by a "!" rule 182 line = line_of[result.index] if result.index is not None and result.index < len(line_of) else None 183 return f"{ignore_folder / '.gitignore'}:{line}" if line else str(ignore_folder / ".gitignore") 184 return None 185 186 def _chain(self, folder: Path) -> list[tuple[Path, object, list[int], str]]: 187 """The .gitignore files that apply to ``folder``'s entries, deepest 188 first: (their folder, rules, rules' lines, ``folder``'s path from there 189 with a "/"). Worked out once per folder: per entry, its paths and 190 parents took most of a big project's first filter.""" 191 chain = self._chains.get(folder) 192 if chain is None: 193 chain = [] 194 if folder == self.root or self.root in folder.parents: 195 for above in (folder, *folder.parents): 196 entry = self._spec(above) 197 if entry is not None: 198 relative = folder.relative_to(above).as_posix() 199 chain.append((above, *entry, "" if relative == "." else relative + "/")) 200 if above == self.root: 201 break 202 self._chains[folder] = chain 203 return chain
git's ignore rules for one package: the .gitignore files from the root of its git repository (or the package folder, if it isn't in one) down to each folder, combined the way git does (deeper files win, "!" re-includes, and everything inside an ignored folder is ignored).
168 def check(self, path: Path, is_dir: bool) -> str | None: 169 """Where ``path`` is ignored ("…/.gitignore:LINE"), or None if it isn't.""" 170 path = Path(path) 171 return self.check_name(path.parent.resolve(), path.name, is_dir)
Where path is ignored ("…/.gitignore:LINE"), or None if it isn't.
173 def check_name(self, folder: Path, name: str, is_dir: bool) -> str | None: 174 """Where the entry ``name`` of ``folder`` (resolved) is ignored, or None. 175 The deepest .gitignore with a matching rule decides.""" 176 suffix = "/" if is_dir else "" 177 for ignore_folder, spec, line_of, prefix in self._chain(folder): # (deepest first) 178 result = spec.check_file(prefix + name + suffix) 179 if result.include is not None: 180 if not result.include: 181 return None # re-included by a "!" rule 182 line = line_of[result.index] if result.index is not None and result.index < len(line_of) else None 183 return f"{ignore_folder / '.gitignore'}:{line}" if line else str(ignore_folder / ".gitignore") 184 return None
Where the entry name of folder (resolved) is ignored, or None.
The deepest .gitignore with a matching rule decides.
206def reveal_in_file_manager(path: str): 207 """Show a file (selected) or folder in Finder / Explorer / the file manager.""" 208 if sys.platform == "darwin": 209 subprocess.run(["open", "-R", path], check=False) 210 elif sys.platform == "win32": 211 subprocess.run(["explorer", "/select,", os.path.normpath(path)], check=False) 212 else: 213 folder = path if Path(path).is_dir() else str(Path(path).parent) 214 QDesktopServices.openUrl(QUrl.fromLocalFile(folder))
Show a file (selected) or folder in Finder / Explorer / the file manager.
250class ProjectTree(QWidget): 251 """The file tree. Emits `open_requested` with a file path.""" 252 253 open_requested = Signal(str) 254 compile_command_requested = Signal(str) 255 preview_requested = Signal(str) # a file clicked once: shown in a preview tab 256 rename_requested = Signal(str) # a file to rename (the window asks for the new name) 257 delete_requested = Signal(str) # a file to move to the trash (the window asks first) 258 259 def __init__(self, parent=None): 260 super().__init__(parent) 261 self.project_dir: Path | None = None 262 self.graph: m.DependencyGraph | None = None 263 self._items: dict[str, QStandardItem] = {} # path -> file/dir item 264 self._marks: dict[str, str] = {} # path -> "error" / "warning" 265 self._real_dirs: dict[str, str] = {} # folder -> its resolved path (see _real) 266 self._icons = QFileIconProvider() 267 self._folder_icon = self._icons.icon(QFileIconProvider.Folder) 268 self._icon_cache: dict = {} 269 self._skip_dirs: set[Path] = set() 270 self._roles: dict[str, str] = {} # folder -> fpm role 271 self._ignores: dict[str, GitIgnore] = {} # package folder -> its rules 272 self._role_icons: dict = {} 273 self._all_loaded: bool | None = None # None, or whether ignored folders were loaded too 274 275 self.filter_edit = QLineEdit() 276 self.filter_edit.setPlaceholderText("Filter files…") 277 self.filter_edit.setClearButtonEnabled(True) 278 # Filter once typing pauses, not on every keystroke. 279 self._filter_timer = QTimer(self) 280 self._filter_timer.setSingleShot(True) 281 self._filter_timer.setInterval(200) 282 self._filter_timer.timeout.connect(lambda: self._apply_filter(self.filter_edit.text())) 283 self.filter_edit.textChanged.connect(self._filter_timer.start) 284 self.include_ignored = QCheckBox("Ignored") 285 self.include_ignored.setToolTip("Also match files ignored by git and greyed-out files " 286 "(hidden, compiled, build/) — slower on big projects") 287 self.include_ignored.toggled.connect(lambda: self._apply_filter(self.filter_edit.text())) 288 289 self.model = QStandardItemModel(self) 290 self.proxy = _Filter(self) 291 self.proxy.setSourceModel(self.model) 292 self.view = QTreeView() 293 self.view.setModel(self.proxy) 294 self.view.setHeaderHidden(True) 295 self.view.setUniformRowHeights(True) 296 self.view.setEditTriggers(QAbstractItemView.NoEditTriggers) 297 self.view.setContextMenuPolicy(Qt.CustomContextMenu) 298 self.view.customContextMenuRequested.connect(self._context_menu) 299 self.view.activated.connect(self._activated) # double-click / Enter 300 self.view.clicked.connect(self._clicked) # one click: a preview 301 # Moving through the files with the arrow keys previews them too, once 302 # the selection rests (holding ↓ doesn't open every file on the way). 303 # Only keys count: the app moves the selection itself too (revealing 304 # the current tab's file, a refresh), and that mustn't open anything. 305 self._key_moving = False 306 self._key_preview_timer = QTimer(self) 307 self._key_preview_timer.setSingleShot(True) 308 self._key_preview_timer.setInterval(KEY_PREVIEW_MS) 309 self._key_preview_timer.timeout.connect(lambda: self._clicked(self.view.currentIndex())) 310 self.view.installEventFilter(self) 311 self.view.selectionModel().currentChanged.connect(self._current_changed) 312 self.view.expanded.connect(self._expanded) # folders load when opened 313 314 self.empty = QLabel("Open an fpm.toml to see its files.") 315 self.empty.setWordWrap(True) 316 self.empty.setStyleSheet("color: gray;") 317 318 layout = QVBoxLayout(self) 319 layout.setContentsMargins(0, 0, 0, 0) 320 layout.setSpacing(4) 321 filter_row = QHBoxLayout() 322 filter_row.setSpacing(6) 323 filter_row.addWidget(self.filter_edit, 1) 324 filter_row.addWidget(self.include_ignored) 325 layout.addLayout(filter_row) 326 layout.addWidget(self.view, 1) 327 layout.addWidget(self.empty) 328 self._update_empty() 329 330 # ------------------------------------------------------------- content 331 def set_project(self, graph: m.DependencyGraph | None, project_dir: Path | None): 332 new_project = project_dir != self.project_dir 333 self.graph, self.project_dir = graph, project_dir 334 if new_project: 335 self._marks.clear() 336 self.refresh(keep_state=not new_project) 337 338 def refresh(self, keep_state: bool = True): 339 """Rescan the folders (keeping open folders and the selection).""" 340 expanded = self._expanded_paths() if keep_state else None 341 selected = self._selected_path() if keep_state else None 342 self.model.clear() 343 self._items.clear() 344 self._real_dirs.clear() 345 self._all_loaded = None 346 if self.project_dir is None: 347 self._update_empty() 348 return 349 packages = self._packages() 350 package_dirs = {p.resolve() for _, p, _ in packages} 351 # A folder that is another package (e.g. a path dependency inside the 352 # project) is listed under that package, not inside this one. 353 self._skip_dirs = package_dirs 354 self._ignores = {str(d.resolve()): GitIgnore(d) for _, d, _ in packages} 355 self._roles = {} 356 for _, directory, _ in packages: 357 manifest = directory / "fpm.toml" 358 if manifest.is_file(): 359 self._roles.update(fpm_folders(manifest)) 360 root_item = None 361 deps_group = None 362 for name, directory, is_root in packages: 363 item = self._package_item(name, directory, is_root) 364 self._fill(item, directory) 365 if is_root: 366 self.model.appendRow(item) 367 root_item = item 368 else: 369 if deps_group is None: 370 deps_group = QStandardItem("Dependencies") 371 deps_group.setData("group", KIND_ROLE) 372 deps_group.setIcon(self._icons.icon(QFileIconProvider.Folder)) 373 font = QFont(deps_group.font()) 374 font.setBold(True) 375 deps_group.setFont(font) 376 self.model.appendRow(deps_group) 377 deps_group.appendRow(item) 378 self.refresh_marks() 379 self._update_empty() 380 if expanded is None: 381 if root_item is not None: 382 self.view.expand(self.proxy.mapFromSource(root_item.index())) 383 else: 384 self._restore_expanded(expanded) 385 if selected: 386 self.reveal(selected, expand=False) 387 self._apply_filter(self.filter_edit.text()) 388 389 def _packages(self) -> list[tuple[str, Path, bool]]: 390 """(name, folder, is_root) for the project and dependencies on disk.""" 391 out = [] 392 if self.graph is not None: 393 root = self.graph.packages.get(self.graph.root) 394 name = root.name if root else self.project_dir.name 395 else: 396 name = self.project_dir.name 397 out.append((name, self.project_dir, True)) 398 if self.graph is not None: 399 for pkg in self.graph.packages.values(): 400 if pkg.name == self.graph.root or pkg.kind in (m.META, m.MISSING): 401 continue 402 if pkg.directory and pkg.directory.is_dir(): 403 out.append((pkg.name, pkg.directory, False)) 404 return out 405 406 def _package_item(self, name: str, directory: Path, is_root: bool) -> QStandardItem: 407 item = QStandardItem(name) 408 item.setData(str(directory), PATH_ROLE) 409 item.setData("package", KIND_ROLE) 410 item.setIcon(self._icons.icon(QFileIconProvider.Folder)) 411 item.setToolTip(str(directory)) 412 font = QFont(item.font()) 413 font.setBold(True) 414 item.setFont(font) 415 if not is_root: 416 kind = self.graph.packages[name].kind if self.graph and name in self.graph.packages else "" 417 item.setText(f"{name} ({'local path' if kind == m.PATH else 'git'})" if kind else name) 418 return item 419 420 def _fill(self, parent: QStandardItem, directory: Path): 421 """List one folder: subfolders first, then files. Subfolders are 422 loaded when opened (see `_load`), so big trees stay fast.""" 423 parent.setData(str(directory), PATH_ROLE) 424 self._load(parent) 425 426 def _load(self, item: QStandardItem): 427 """Fill a folder item with its contents (once).""" 428 if item.data(LOADED_ROLE): 429 return 430 item.setData(True, LOADED_ROLE) 431 item.removeRows(0, item.rowCount()) # the "loading" placeholder 432 folder = Path(item.data(PATH_ROLE)) 433 try: 434 entries = sorted(os.scandir(folder), key=lambda e: (not e.is_dir(), e.name.lower())) 435 except OSError: 436 return 437 rules = self._rules_for(folder) 438 real_folder = folder.resolve() 439 parent_ignored = item.data(IGNORED_ROLE) # inside an ignored folder 440 441 def ignored(name: str, is_dir: bool) -> str | None: 442 return parent_ignored or (rules.check_name(real_folder, name, is_dir) if rules else None) 443 for entry in entries: 444 name = entry.name 445 path = Path(entry.path) 446 if entry.is_dir(follow_symlinks=False): 447 if path.resolve() in self._skip_dirs: 448 self._add_hidden(item, path, "dir", 449 "A package of its own — listed under Dependencies " 450 "(double-click to go there)", link=True) 451 continue 452 if name.startswith(".") or name in SKIP_DIRS: 453 why = ("fpm's build output — see the Build outputs tab" if name == "build" 454 else "Hidden folder" if name.startswith(".") else "Generated folder") 455 self._add_hidden(item, path, "dir", why, ignored=ignored(name, True)) 456 continue 457 child = QStandardItem(name) 458 child.setData(str(path), PATH_ROLE) 459 child.setData("dir", KIND_ROLE) 460 self._set_ignored(child, path, ignored(name, True)) 461 role = self._roles.get(str(path.resolve())) 462 if role: 463 child.setIcon(self._role_icon(role)) 464 child.setToolTip(f"{ROLES[role][1]} — {path}") 465 else: 466 child.setIcon(self._folder_icon) 467 placeholder = QStandardItem("…") 468 placeholder.setData("placeholder", KIND_ROLE) 469 placeholder.setEnabled(False) 470 child.appendRow(placeholder) # gives the folder an expand arrow 471 item.appendRow(child) 472 self._items[str(path)] = child 473 elif entry.is_file(): 474 hidden = ("Hidden file" if name.startswith(".") else 475 "Compiled / generated file" 476 if path.suffix in SKIP_SUFFIXES or path.suffix.lower() in SKIP_SUFFIXES else "") 477 if hidden: 478 self._add_hidden(item, path, "file", hidden, ignored=ignored(name, False)) 479 continue 480 child = QStandardItem(name) 481 child.setData(str(path), PATH_ROLE) 482 child.setData("file", KIND_ROLE) 483 self._set_ignored(child, path, ignored(name, False)) 484 child.setIcon(self._manifest_icon() if name == "fpm.toml" else self._file_icon(path)) 485 item.appendRow(child) 486 self._items[str(path)] = child 487 self._mark_item(str(path), child) 488 489 def _rules_for(self, folder: Path) -> GitIgnore | None: 490 """The ignore rules of the package a folder belongs to.""" 491 resolved = folder.resolve() 492 best = None 493 for pkg, rules in self._ignores.items(): 494 pkg_path = Path(pkg) 495 if (resolved == pkg_path or pkg_path in resolved.parents) and \ 496 (best is None or len(pkg) > len(str(best.package_dir))): 497 best = rules 498 return best 499 500 def _set_ignored(self, item: QStandardItem, path: Path, source: str | None): 501 """``source``: the .gitignore (and line) that ignores it, if one does.""" 502 if source: 503 item.setData(str(source), IGNORED_ROLE) 504 item.setForeground(self._ignored_grey()) 505 item.setToolTip(f"Ignored by git: {source}\n{path}") 506 507 def _ignored_grey(self) -> QColor: 508 """Between normal text and the hidden-entry grey. The alpha is mixed 509 too: macOS's dark palette uses white at different opacities for 510 normal (~85%) and disabled (~25%) text, so mixing only RGB gave white.""" 511 pal = QApplication.palette() 512 a, b = pal.color(QPalette.Text), pal.color(QPalette.Disabled, QPalette.Text) 513 return QColor((a.red() + b.red()) // 2, (a.green() + b.green()) // 2, 514 (a.blue() + b.blue()) // 2, (a.alpha() + b.alpha()) // 2) 515 516 def _add_hidden(self, parent: QStandardItem, path: Path, kind: str, why: str, link: bool = False, 517 ignored: str | None = None): 518 """A greyed-out entry: listed, but folders can't be opened in the tree 519 (double-click shows them in the file manager; files still open).""" 520 child = QStandardItem(path.name) 521 child.setData(str(path), PATH_ROLE) 522 child.setData(kind, KIND_ROLE) 523 child.setData(why, HIDDEN_ROLE) 524 if link: 525 child.setData(str(path.resolve()), LINK_ROLE) 526 also = f"\nAlso ignored by git: {ignored}" if ignored else "" 527 if ignored: 528 child.setData(ignored, IGNORED_ROLE) 529 if kind == "dir": 530 child.setData(True, LOADED_ROLE) # never filled, so no expand arrow 531 child.setIcon(self._folder_icon) 532 action = ("double-click to go there" if link 533 else f"double-click to show it in {file_manager_name()}") 534 child.setToolTip(f"{why}{also}\n{path}" if link else f"{why} ({action}){also}\n{path}") 535 else: 536 child.setIcon(self._file_icon(path)) 537 child.setToolTip(f"{why}{also}\n{path}") 538 child.setForeground(self._grey()) 539 parent.appendRow(child) 540 self._items[str(path)] = child 541 542 def _grey(self) -> QColor: 543 return QApplication.palette().color(QPalette.Disabled, QPalette.Text) 544 545 def _load_all(self, include_ignored: bool = False): 546 """Load every folder (for filtering, which must see all files); 547 folders ignored by git only if ``include_ignored``.""" 548 if self._all_loaded or self._all_loaded == include_ignored: 549 return 550 QApplication.setOverrideCursor(Qt.WaitCursor) 551 try: 552 stack = [self.model.item(r) for r in range(self.model.rowCount())] 553 while stack: 554 item = stack.pop() 555 if item is None: 556 continue 557 if item.data(KIND_ROLE) in ("package", "dir"): 558 if item.data(IGNORED_ROLE) and not include_ignored: 559 continue 560 self._load(item) 561 for r in range(item.rowCount()): 562 stack.append(item.child(r)) 563 self._all_loaded = include_ignored 564 finally: 565 QApplication.restoreOverrideCursor() 566 567 def all_files(self) -> list[ProjectFile]: 568 """Every file the tree lists (the project's and its dependencies'), 569 for Go to File (⌘P): the greyed-out and git-ignored ones only with 570 *Ignored* ticked, as the filter has them. Loads every folder first.""" 571 include = self.include_ignored.isChecked() 572 self._load_all(include) 573 out, seen = [], set() 574 stack = [(self.model.item(r), None) for r in range(self.model.rowCount())] 575 while stack: 576 item, package = stack.pop() 577 if item is None: 578 continue 579 kind = item.data(KIND_ROLE) 580 if kind == "package": 581 package = (item.text().split(" (")[0], Path(item.data(PATH_ROLE)), 582 item.parent() is not None) # (a dependency's: under the Dependencies group) 583 elif kind == "file" and package is not None: 584 hidden = bool(item.data(IGNORED_ROLE) or item.data(HIDDEN_ROLE)) 585 path = item.data(PATH_ROLE) 586 if (include or not hidden) and path not in seen: 587 seen.add(path) 588 name, folder, dependency = package 589 try: 590 relative = Path(path).relative_to(folder).as_posix() 591 except ValueError: 592 relative = Path(path).name 593 out.append(ProjectFile(path, relative, name, dependency, hidden)) 594 continue 595 elif kind == "dir" and item.data(IGNORED_ROLE) and not include: 596 continue 597 stack.extend((item.child(r), package) for r in range(item.rowCount())) 598 return out 599 600 def _expanded(self, proxy_index): 601 item = self.model.itemFromIndex(self.proxy.mapToSource(proxy_index)) 602 if item is not None and item.data(KIND_ROLE) in ("package", "dir"): 603 self._load(item) 604 605 def _role_icon(self, role: str): 606 if role not in self._role_icons: 607 self._role_icons[role] = badged_icon(self._folder_icon, ROLES[role][0]) 608 return self._role_icons[role] 609 610 def _manifest_icon(self): 611 if "manifest" not in self._role_icons: 612 base = self._icons.icon(QFileIconProvider.File) 613 self._role_icons["manifest"] = badged_icon(base, "manifest") 614 return self._role_icons["manifest"] 615 616 def icon_for(self, path: Path): 617 """A file's icon, as in the tree (e.g. for the Search tab).""" 618 return self._manifest_icon() if path.name == "fpm.toml" else self._file_icon(path) 619 620 def _file_icon(self, path: Path): 621 """Icons by file type (asking the OS once per type, not per file).""" 622 key = path.suffix.lower() or path.name 623 icon = self._icon_cache.get(key) 624 if icon is None: 625 icon = self._icon_cache[key] = ( 626 fortran_file_icon() if key in FORTRAN_SUFFIXES 627 else fortran_file_icon(include=True) if key in FORTRAN_INCLUDE_SUFFIXES 628 else self._icons.icon(QFileInfo(str(path)))) 629 return icon 630 631 # -------------------------------------------------- build results marks 632 def set_marks(self, marks: dict[str, str]): 633 """Color files by the last build: {path: "error" | "warning"}.""" 634 self._marks = {self._real(os.path.abspath(p)): v for p, v in marks.items()} 635 self.refresh_marks() 636 637 def refresh_marks(self): 638 for path, item in self._items.items(): 639 if item.data(KIND_ROLE) == "file" or item.data(HIDDEN_ROLE) or item.data(IGNORED_ROLE): 640 self._mark_item(path, item) 641 642 def _real(self, path: str) -> str: 643 """A file's path with its folder resolved (as a build's file paths are: 644 its package's folder resolved, then the file in it). The folder is 645 resolved once: resolving each of a big project's files, again after 646 every build, held the window (slowest on Windows).""" 647 folder, name = os.path.split(path) 648 real = self._real_dirs.get(folder) 649 if real is None: 650 real = self._real_dirs[folder] = str(Path(folder).resolve()) 651 return os.path.join(real, name) 652 653 def _mark_item(self, path: str, item: QStandardItem): 654 if item.data(HIDDEN_ROLE): 655 item.setForeground(self._grey()) # greyed entries stay grey 656 return 657 mark = self._marks.get(self._real(path)) if self._marks else None 658 ignored = item.data(IGNORED_ROLE) 659 if mark: 660 item.setForeground(QColor(FILE_COLORS[mark])) 661 elif ignored: 662 item.setForeground(self._ignored_grey()) 663 else: 664 item.setData(None, Qt.ForegroundRole) # the normal text color 665 tip = (f"Ignored by git: {ignored}\n" if ignored else "") + path + { 666 "error": "\n(errors in the last build)", 667 "warning": "\n(warnings in the last build)"}.get(mark, "") 668 item.setToolTip(tip) 669 670 # -------------------------------------------------------------- filter 671 def _apply_filter(self, text: str): 672 self._filter_timer.stop() 673 needle = text.strip() 674 include = self.include_ignored.isChecked() 675 if needle: 676 self._load_all(include) 677 # Re-filtering a fully expanded tree of thousands of rows is slow 678 # in QTreeView (seconds); everything is expanded again below. 679 self.view.collapseAll() 680 self.proxy.set_filter(needle, include) 681 if needle: 682 self.view.expandAll() 683 self._update_empty() 684 685 def _update_empty(self): 686 has_rows = self.proxy.rowCount() > 0 687 if self.project_dir is None: 688 self.empty.setText("Open an fpm.toml to see its files.") 689 elif not has_rows: 690 if not self.filter_edit.text().strip(): 691 self.empty.setText("No files found.") 692 elif self.include_ignored.isChecked(): 693 self.empty.setText("No files match the filter.") 694 else: 695 self.empty.setText("No files match the filter (tick Ignored to also " 696 "search files ignored by git and greyed-out files).") 697 self.empty.setVisible(not has_rows) 698 self.view.setVisible(has_rows) 699 700 # ---------------------------------------------------------- selection 701 def reveal(self, path: str, expand: bool = True) -> bool: 702 """Select a file's row (e.g. the file in the current editor tab); 703 False if it isn't in the tree (outside the project, or filtered out).""" 704 item = self._items.get(str(path)) or self._items.get(str(Path(path).resolve())) 705 if item is None: 706 item = self._load_down_to(Path(path)) 707 if item is None: 708 return False 709 index = self.proxy.mapFromSource(item.index()) 710 if not index.isValid(): 711 return False 712 if expand: 713 parent = index.parent() 714 while parent.isValid(): 715 self.view.expand(parent) 716 parent = parent.parent() 717 self.view.setCurrentIndex(index) 718 self.view.scrollTo(index) 719 return True 720 721 def reveal_package(self, directory: Path) -> bool: 722 """Select a package's row (the project, or one under Dependencies), 723 open it, and scroll to it. False if it isn't in the tree.""" 724 target = str(Path(directory).resolve()) 725 stack = [self.model.item(r) for r in range(self.model.rowCount())] 726 while stack: 727 item = stack.pop() 728 if item is None: 729 continue 730 if item.data(KIND_ROLE) == "package" and str(Path(item.data(PATH_ROLE)).resolve()) == target: 731 index = self.proxy.mapFromSource(item.index()) 732 if not index.isValid(): 733 return False 734 parent = index.parent() 735 while parent.isValid(): 736 self.view.expand(parent) 737 parent = parent.parent() 738 self.view.expand(index) 739 self.view.setCurrentIndex(index) 740 self.view.scrollTo(index, QAbstractItemView.PositionAtTop) 741 return True 742 if item.data(KIND_ROLE) == "group": 743 stack.extend(item.child(r) for r in range(item.rowCount())) 744 return False 745 746 def _load_down_to(self, path: Path) -> QStandardItem | None: 747 """Load the folders between a package and ``path``; return its item.""" 748 target = path.resolve() 749 packages = [] 750 stack = [self.model.item(r) for r in range(self.model.rowCount())] 751 while stack: 752 item = stack.pop() 753 if item is None: 754 continue 755 if item.data(KIND_ROLE) == "package": 756 packages.append(item) 757 elif item.data(KIND_ROLE) == "group": 758 stack.extend(item.child(r) for r in range(item.rowCount())) 759 for package in packages: 760 root = Path(package.data(PATH_ROLE)).resolve() 761 try: 762 parts = target.relative_to(root).parts 763 except ValueError: 764 continue 765 item = package 766 for depth in range(len(parts)): 767 self._load(item) 768 wanted = str(root.joinpath(*parts[:depth + 1])) 769 item = self._items.get(wanted) 770 if item is None: 771 break 772 else: 773 return item 774 return None 775 776 def view_state(self) -> dict | None: 777 """The open folders and the selected entry (to restore next time), 778 or None while the tree is filtered (everything is expanded then).""" 779 if self.filter_edit.text().strip(): 780 return None 781 return {"expanded": sorted(self._expanded_paths()), "selected": self._selected_path()} 782 783 def restore_view_state(self, state: dict): 784 self._restore_expanded(set(state.get("expanded") or [])) 785 selected = state.get("selected") 786 if selected and Path(selected).exists(): 787 self.reveal(selected, expand=False) 788 789 def _selected_path(self) -> str | None: 790 index = self.view.currentIndex() 791 return index.data(PATH_ROLE) if index.isValid() else None 792 793 def _expanded_paths(self) -> set[str]: 794 paths = set() 795 796 def visit(parent): 797 for row in range(self.proxy.rowCount(parent)): 798 index = self.proxy.index(row, 0, parent) 799 if self.view.isExpanded(index): 800 paths.add(index.data(PATH_ROLE) or "group:" + str(index.data())) 801 visit(index) 802 803 visit(self.view.rootIndex()) 804 return paths 805 806 def _restore_expanded(self, paths: set[str]): 807 def visit(parent): 808 for row in range(self.proxy.rowCount(parent)): 809 index = self.proxy.index(row, 0, parent) 810 key = index.data(PATH_ROLE) or "group:" + str(index.data()) 811 if key in paths: 812 self.view.expand(index) 813 visit(index) 814 815 visit(self.view.rootIndex()) 816 817 # ------------------------------------------------------------- actions 818 def eventFilter(self, obj, event): 819 if obj is self.view and event.type() == QEvent.KeyPress and event.key() in NAVIGATION_KEYS: 820 self._key_moving = True # (the view moves the selection now: see _current_changed) 821 QTimer.singleShot(0, self, lambda: setattr(self, "_key_moving", False)) 822 return super().eventFilter(obj, event) 823 824 def _current_changed(self, current, _previous): 825 if self._key_moving: 826 self._key_preview_timer.start() 827 828 def _clicked(self, index): 829 if index.data(KIND_ROLE) == "file" and not index.data(HIDDEN_ROLE) and index.data(PATH_ROLE): 830 self.preview_requested.emit(index.data(PATH_ROLE)) 831 832 def _activated(self, index): 833 path = index.data(PATH_ROLE) 834 if index.data(KIND_ROLE) == "file": 835 self.open_requested.emit(path) 836 elif index.data(LINK_ROLE): 837 self._go_to_package(index.data(LINK_ROLE)) 838 elif index.data(HIDDEN_ROLE) and path: 839 reveal_in_file_manager(path) 840 841 def _go_to_package(self, directory: str): 842 """Select the Dependencies entry for a package folder.""" 843 stack = [self.model.item(r) for r in range(self.model.rowCount())] 844 while stack: 845 item = stack.pop() 846 if item is None: 847 continue 848 if item.data(KIND_ROLE) == "package" and \ 849 str(Path(item.data(PATH_ROLE)).resolve()) == directory: 850 index = self.proxy.mapFromSource(item.index()) 851 parent = index.parent() 852 while parent.isValid(): 853 self.view.expand(parent) 854 parent = parent.parent() 855 self.view.setCurrentIndex(index) 856 self.view.scrollTo(index) 857 return 858 stack.extend(item.child(r) for r in range(item.rowCount())) 859 860 def _context_menu(self, pos): 861 menu = self.context_menu(pos) 862 menu.exec(self.view.viewport().mapToGlobal(pos)) 863 menu.deleteLater() 864 865 def context_menu(self, pos) -> QMenu: 866 """The right-click menu for the row at ``pos`` (viewport coordinates).""" 867 index = self.view.indexAt(pos) 868 path = index.data(PATH_ROLE) if index.isValid() else None 869 is_file = bool(path) and index.data(KIND_ROLE) == "file" 870 menu = QMenu(self) 871 if is_file: 872 menu.addAction("Open", lambda: self.open_requested.emit(path)) 873 menu.addAction("Open in Default App", 874 lambda: QDesktopServices.openUrl(QUrl.fromLocalFile(path))) 875 if Path(path).suffix.lower() in SOURCE_EXTS: 876 menu.addAction("Show Compile Command…", 877 lambda: self.compile_command_requested.emit(path)) 878 if path: 879 menu.addAction(reveal_label(), lambda: reveal_in_file_manager(path)) 880 menu.addAction("Copy Path", lambda: QApplication.clipboard().setText(path)) 881 menu.addSeparator() 882 if is_file: 883 menu.addAction("Rename…", lambda: self.rename_requested.emit(path)) 884 menu.addAction("Move to Trash…", lambda: self.delete_requested.emit(path)) 885 menu.addSeparator() 886 menu.addAction("Refresh", self.refresh) 887 menu.addAction("Collapse All", self.view.collapseAll) 888 return menu
The file tree. Emits open_requested with a file path.
259 def __init__(self, parent=None): 260 super().__init__(parent) 261 self.project_dir: Path | None = None 262 self.graph: m.DependencyGraph | None = None 263 self._items: dict[str, QStandardItem] = {} # path -> file/dir item 264 self._marks: dict[str, str] = {} # path -> "error" / "warning" 265 self._real_dirs: dict[str, str] = {} # folder -> its resolved path (see _real) 266 self._icons = QFileIconProvider() 267 self._folder_icon = self._icons.icon(QFileIconProvider.Folder) 268 self._icon_cache: dict = {} 269 self._skip_dirs: set[Path] = set() 270 self._roles: dict[str, str] = {} # folder -> fpm role 271 self._ignores: dict[str, GitIgnore] = {} # package folder -> its rules 272 self._role_icons: dict = {} 273 self._all_loaded: bool | None = None # None, or whether ignored folders were loaded too 274 275 self.filter_edit = QLineEdit() 276 self.filter_edit.setPlaceholderText("Filter files…") 277 self.filter_edit.setClearButtonEnabled(True) 278 # Filter once typing pauses, not on every keystroke. 279 self._filter_timer = QTimer(self) 280 self._filter_timer.setSingleShot(True) 281 self._filter_timer.setInterval(200) 282 self._filter_timer.timeout.connect(lambda: self._apply_filter(self.filter_edit.text())) 283 self.filter_edit.textChanged.connect(self._filter_timer.start) 284 self.include_ignored = QCheckBox("Ignored") 285 self.include_ignored.setToolTip("Also match files ignored by git and greyed-out files " 286 "(hidden, compiled, build/) — slower on big projects") 287 self.include_ignored.toggled.connect(lambda: self._apply_filter(self.filter_edit.text())) 288 289 self.model = QStandardItemModel(self) 290 self.proxy = _Filter(self) 291 self.proxy.setSourceModel(self.model) 292 self.view = QTreeView() 293 self.view.setModel(self.proxy) 294 self.view.setHeaderHidden(True) 295 self.view.setUniformRowHeights(True) 296 self.view.setEditTriggers(QAbstractItemView.NoEditTriggers) 297 self.view.setContextMenuPolicy(Qt.CustomContextMenu) 298 self.view.customContextMenuRequested.connect(self._context_menu) 299 self.view.activated.connect(self._activated) # double-click / Enter 300 self.view.clicked.connect(self._clicked) # one click: a preview 301 # Moving through the files with the arrow keys previews them too, once 302 # the selection rests (holding ↓ doesn't open every file on the way). 303 # Only keys count: the app moves the selection itself too (revealing 304 # the current tab's file, a refresh), and that mustn't open anything. 305 self._key_moving = False 306 self._key_preview_timer = QTimer(self) 307 self._key_preview_timer.setSingleShot(True) 308 self._key_preview_timer.setInterval(KEY_PREVIEW_MS) 309 self._key_preview_timer.timeout.connect(lambda: self._clicked(self.view.currentIndex())) 310 self.view.installEventFilter(self) 311 self.view.selectionModel().currentChanged.connect(self._current_changed) 312 self.view.expanded.connect(self._expanded) # folders load when opened 313 314 self.empty = QLabel("Open an fpm.toml to see its files.") 315 self.empty.setWordWrap(True) 316 self.empty.setStyleSheet("color: gray;") 317 318 layout = QVBoxLayout(self) 319 layout.setContentsMargins(0, 0, 0, 0) 320 layout.setSpacing(4) 321 filter_row = QHBoxLayout() 322 filter_row.setSpacing(6) 323 filter_row.addWidget(self.filter_edit, 1) 324 filter_row.addWidget(self.include_ignored) 325 layout.addLayout(filter_row) 326 layout.addWidget(self.view, 1) 327 layout.addWidget(self.empty) 328 self._update_empty()
__init__(self, /, parent: PySide6.QtWidgets.QWidget | None = None, f: PySide6.QtCore.Qt.WindowType = Default(Qt.WindowFlags), *, modal: bool | None = None, windowModality: PySide6.QtCore.Qt.WindowModality | None = None, enabled: bool | None = None, geometry: PySide6.QtCore.QRect | None = None, frameGeometry: PySide6.QtCore.QRect | None = None, normalGeometry: PySide6.QtCore.QRect | None = None, x: int | None = None, y: int | None = None, pos: PySide6.QtCore.QPoint | None = None, frameSize: PySide6.QtCore.QSize | None = None, size: PySide6.QtCore.QSize | None = None, width: int | None = None, height: int | None = None, rect: PySide6.QtCore.QRect | None = None, childrenRect: PySide6.QtCore.QRect | None = None, childrenRegion: PySide6.QtGui.QRegion | None = None, sizePolicy: PySide6.QtWidgets.QSizePolicy | None = None, minimumSize: PySide6.QtCore.QSize | None = None, maximumSize: PySide6.QtCore.QSize | None = None, minimumWidth: int | None = None, minimumHeight: int | None = None, maximumWidth: int | None = None, maximumHeight: int | None = None, sizeIncrement: PySide6.QtCore.QSize | None = None, baseSize: PySide6.QtCore.QSize | None = None, palette: PySide6.QtGui.QPalette | None = None, font: PySide6.QtGui.QFont | None = None, cursor: PySide6.QtGui.QCursor | None = None, mouseTracking: bool | None = None, tabletTracking: bool | None = None, isActiveWindow: bool | None = None, focusPolicy: PySide6.QtCore.Qt.FocusPolicy | None = None, focus: bool | None = None, contextMenuPolicy: PySide6.QtCore.Qt.ContextMenuPolicy | None = None, updatesEnabled: bool | None = None, visible: bool | None = None, minimized: bool | None = None, maximized: bool | None = None, fullScreen: bool | None = None, sizeHint: PySide6.QtCore.QSize | None = None, minimumSizeHint: PySide6.QtCore.QSize | None = None, acceptDrops: bool | None = None, windowTitle: str | None = None, windowIcon: PySide6.QtGui.QIcon | None = None, windowIconText: str | None = None, windowOpacity: float | None = None, windowModified: bool | None = None, toolTip: str | None = None, toolTipDuration: int | None = None, statusTip: str | None = None, whatsThis: str | None = None, accessibleName: str | None = None, accessibleDescription: str | None = None, accessibleIdentifier: str | None = None, layoutDirection: PySide6.QtCore.Qt.LayoutDirection | None = None, autoFillBackground: bool | None = None, styleSheet: str | None = None, locale: PySide6.QtCore.QLocale | None = None, windowFilePath: str | None = None, inputMethodHints: PySide6.QtCore.Qt.InputMethodHint | None = None) -> None
Initialize self. See help(type(self)) for accurate signature.
338 def refresh(self, keep_state: bool = True): 339 """Rescan the folders (keeping open folders and the selection).""" 340 expanded = self._expanded_paths() if keep_state else None 341 selected = self._selected_path() if keep_state else None 342 self.model.clear() 343 self._items.clear() 344 self._real_dirs.clear() 345 self._all_loaded = None 346 if self.project_dir is None: 347 self._update_empty() 348 return 349 packages = self._packages() 350 package_dirs = {p.resolve() for _, p, _ in packages} 351 # A folder that is another package (e.g. a path dependency inside the 352 # project) is listed under that package, not inside this one. 353 self._skip_dirs = package_dirs 354 self._ignores = {str(d.resolve()): GitIgnore(d) for _, d, _ in packages} 355 self._roles = {} 356 for _, directory, _ in packages: 357 manifest = directory / "fpm.toml" 358 if manifest.is_file(): 359 self._roles.update(fpm_folders(manifest)) 360 root_item = None 361 deps_group = None 362 for name, directory, is_root in packages: 363 item = self._package_item(name, directory, is_root) 364 self._fill(item, directory) 365 if is_root: 366 self.model.appendRow(item) 367 root_item = item 368 else: 369 if deps_group is None: 370 deps_group = QStandardItem("Dependencies") 371 deps_group.setData("group", KIND_ROLE) 372 deps_group.setIcon(self._icons.icon(QFileIconProvider.Folder)) 373 font = QFont(deps_group.font()) 374 font.setBold(True) 375 deps_group.setFont(font) 376 self.model.appendRow(deps_group) 377 deps_group.appendRow(item) 378 self.refresh_marks() 379 self._update_empty() 380 if expanded is None: 381 if root_item is not None: 382 self.view.expand(self.proxy.mapFromSource(root_item.index())) 383 else: 384 self._restore_expanded(expanded) 385 if selected: 386 self.reveal(selected, expand=False) 387 self._apply_filter(self.filter_edit.text())
Rescan the folders (keeping open folders and the selection).
567 def all_files(self) -> list[ProjectFile]: 568 """Every file the tree lists (the project's and its dependencies'), 569 for Go to File (⌘P): the greyed-out and git-ignored ones only with 570 *Ignored* ticked, as the filter has them. Loads every folder first.""" 571 include = self.include_ignored.isChecked() 572 self._load_all(include) 573 out, seen = [], set() 574 stack = [(self.model.item(r), None) for r in range(self.model.rowCount())] 575 while stack: 576 item, package = stack.pop() 577 if item is None: 578 continue 579 kind = item.data(KIND_ROLE) 580 if kind == "package": 581 package = (item.text().split(" (")[0], Path(item.data(PATH_ROLE)), 582 item.parent() is not None) # (a dependency's: under the Dependencies group) 583 elif kind == "file" and package is not None: 584 hidden = bool(item.data(IGNORED_ROLE) or item.data(HIDDEN_ROLE)) 585 path = item.data(PATH_ROLE) 586 if (include or not hidden) and path not in seen: 587 seen.add(path) 588 name, folder, dependency = package 589 try: 590 relative = Path(path).relative_to(folder).as_posix() 591 except ValueError: 592 relative = Path(path).name 593 out.append(ProjectFile(path, relative, name, dependency, hidden)) 594 continue 595 elif kind == "dir" and item.data(IGNORED_ROLE) and not include: 596 continue 597 stack.extend((item.child(r), package) for r in range(item.rowCount())) 598 return out
Every file the tree lists (the project's and its dependencies'), for Go to File (⌘P): the greyed-out and git-ignored ones only with Ignored ticked, as the filter has them. Loads every folder first.
616 def icon_for(self, path: Path): 617 """A file's icon, as in the tree (e.g. for the Search tab).""" 618 return self._manifest_icon() if path.name == "fpm.toml" else self._file_icon(path)
A file's icon, as in the tree (e.g. for the Search tab).
632 def set_marks(self, marks: dict[str, str]): 633 """Color files by the last build: {path: "error" | "warning"}.""" 634 self._marks = {self._real(os.path.abspath(p)): v for p, v in marks.items()} 635 self.refresh_marks()
Color files by the last build: {path: "error" | "warning"}.
701 def reveal(self, path: str, expand: bool = True) -> bool: 702 """Select a file's row (e.g. the file in the current editor tab); 703 False if it isn't in the tree (outside the project, or filtered out).""" 704 item = self._items.get(str(path)) or self._items.get(str(Path(path).resolve())) 705 if item is None: 706 item = self._load_down_to(Path(path)) 707 if item is None: 708 return False 709 index = self.proxy.mapFromSource(item.index()) 710 if not index.isValid(): 711 return False 712 if expand: 713 parent = index.parent() 714 while parent.isValid(): 715 self.view.expand(parent) 716 parent = parent.parent() 717 self.view.setCurrentIndex(index) 718 self.view.scrollTo(index) 719 return True
Select a file's row (e.g. the file in the current editor tab); False if it isn't in the tree (outside the project, or filtered out).
721 def reveal_package(self, directory: Path) -> bool: 722 """Select a package's row (the project, or one under Dependencies), 723 open it, and scroll to it. False if it isn't in the tree.""" 724 target = str(Path(directory).resolve()) 725 stack = [self.model.item(r) for r in range(self.model.rowCount())] 726 while stack: 727 item = stack.pop() 728 if item is None: 729 continue 730 if item.data(KIND_ROLE) == "package" and str(Path(item.data(PATH_ROLE)).resolve()) == target: 731 index = self.proxy.mapFromSource(item.index()) 732 if not index.isValid(): 733 return False 734 parent = index.parent() 735 while parent.isValid(): 736 self.view.expand(parent) 737 parent = parent.parent() 738 self.view.expand(index) 739 self.view.setCurrentIndex(index) 740 self.view.scrollTo(index, QAbstractItemView.PositionAtTop) 741 return True 742 if item.data(KIND_ROLE) == "group": 743 stack.extend(item.child(r) for r in range(item.rowCount())) 744 return False
Select a package's row (the project, or one under Dependencies), open it, and scroll to it. False if it isn't in the tree.
776 def view_state(self) -> dict | None: 777 """The open folders and the selected entry (to restore next time), 778 or None while the tree is filtered (everything is expanded then).""" 779 if self.filter_edit.text().strip(): 780 return None 781 return {"expanded": sorted(self._expanded_paths()), "selected": self._selected_path()}
The open folders and the selected entry (to restore next time), or None while the tree is filtered (everything is expanded then).
818 def eventFilter(self, obj, event): 819 if obj is self.view and event.type() == QEvent.KeyPress and event.key() in NAVIGATION_KEYS: 820 self._key_moving = True # (the view moves the selection now: see _current_changed) 821 QTimer.singleShot(0, self, lambda: setattr(self, "_key_moving", False)) 822 return super().eventFilter(obj, event)
eventFilter(self, watched: PySide6.QtCore.QObject, event: PySide6.QtCore.QEvent, /) -> bool