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
« 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.
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"""
9from __future__ import annotations
11import os
12import subprocess
13import sys
14from dataclasses import dataclass
15from pathlib import Path
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)
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)
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
65SKIP_DIRS = {"build", "__pycache__", "node_modules"}
66SKIP_SUFFIXES = {".o", ".obj", ".mod", ".smod", ".a", ".so", ".dylib", ".dll", ".lib",
67 ".exe", ".pyc", ".gcda", ".gcno", ".DS_Store"}
69FILE_COLORS = {"error": "#e03131", "warning": "#e8590c"}
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}
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] = {}
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)
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
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)
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)."""
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)
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
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]
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)
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
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
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))
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
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)."""
227 def __init__(self, parent=None):
228 super().__init__(parent)
229 self.setRecursiveFilteringEnabled(True)
230 self.needle = ""
231 self.include_ignored = False
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)
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()
249class ProjectTree(QWidget):
250 """The file tree. Emits `open_requested` with a file path."""
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)
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
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()))
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
313 self.empty = QLabel("Open an fpm.toml to see its files.")
314 self.empty.setWordWrap(True)
315 self.empty.setStyleSheet("color: gray;")
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()
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)
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())
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
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
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)
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
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)
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
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}")
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)
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
541 def _grey(self) -> QColor:
542 return QApplication.palette().color(QPalette.Disabled, QPalette.Text)
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()
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
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)
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]
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"]
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)
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
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()
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)
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)
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)
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()
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)
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
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
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
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()}
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)
788 def _selected_path(self) -> str | None:
789 index = self.view.currentIndex()
790 return index.data(PATH_ROLE) if index.isValid() else None
792 def _expanded_paths(self) -> set[str]:
793 paths = set()
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)
802 visit(self.view.rootIndex())
803 return paths
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)
814 visit(self.view.rootIndex())
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)
823 def _current_changed(self, current, _previous):
824 if self._key_moving:
825 self._key_preview_timer.start()
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))
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)
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()))
859 def _context_menu(self, pos):
860 menu = self.context_menu(pos)
861 menu.exec(self.view.viewport().mapToGlobal(pos))
862 menu.deleteLater()
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