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
PATH_ROLE = 257
KIND_ROLE = 258
LOADED_ROLE = 259
HIDDEN_ROLE = 260
IGNORED_ROLE = 262
SKIP_DIRS = {'__pycache__', 'node_modules', 'build'}
SKIP_SUFFIXES = {'.gcda', '.mod', '.dylib', '.obj', '.pyc', '.smod', '.a', '.so', '.o', '.lib', '.dll', '.gcno', '.exe', '.DS_Store'}
FILE_COLORS = {'error': '#e03131', 'warning': '#e8590c'}
ROLES = {'library': ('code', 'Library sources'), 'include': ('include', 'Include files'), 'app': ('run', 'Programs (executables)'), 'test': ('test', 'Tests'), 'example': ('example', 'Example programs')}
def fpm_folders(manifest_path: pathlib.Path) -> dict[str, str]:
 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).

@dataclass(frozen=True)
class ProjectFile:
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.

ProjectFile( path: str, relative: str, package: str, dependency: bool, hidden: bool = False)
path: str
relative: str
package: str
dependency: bool
hidden: bool = False
class GitIgnore:
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).

GitIgnore(package_dir: pathlib.Path)
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)
package_dir
root
def check(self, path: pathlib.Path, is_dir: bool) -> str | None:
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.

def check_name(self, folder: pathlib.Path, name: str, is_dir: bool) -> str | None:
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.

def reveal_in_file_manager(path: str):
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.

KEY_PREVIEW_MS = 150
class ProjectTree(PySide6.QtWidgets.QWidget):
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.

ProjectTree(parent=None)
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.

def open_requested(unknown):

The type of the None singleton.

def compile_command_requested(unknown):

The type of the None singleton.

def preview_requested(unknown):

The type of the None singleton.

def rename_requested(unknown):

The type of the None singleton.

def delete_requested(unknown):

The type of the None singleton.

project_dir: pathlib.Path | None
filter_edit
include_ignored
model
proxy
view
empty
def set_project( self, graph: vibefpmgui.manifest.DependencyGraph | None, project_dir: pathlib.Path | None):
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)
def refresh(self, keep_state: bool = True):
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).

def all_files(self) -> list[ProjectFile]:
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.

def icon_for(self, path: pathlib.Path):
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).

def set_marks(self, marks: dict[str, str]):
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"}.

def refresh_marks(self):
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)
def reveal(self, path: str, expand: bool = True) -> bool:
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).

def reveal_package(self, directory: pathlib.Path) -> bool:
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.

def view_state(self) -> dict | None:
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).

def restore_view_state(self, state: dict):
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)
def eventFilter(self, obj, event):
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

def context_menu(self, pos) -> PySide6.QtWidgets.QMenu:
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 right-click menu for the row at pos (viewport coordinates).

staticMetaObject = PySide6.QtCore.QMetaObject("ProjectTree" inherits "QWidget": Methods: #33 type=Signal, signature=open_requested(QString), parameters=QString #34 type=Signal, signature=compile_command_requested(QString), parameters=QString #35 type=Signal, signature=preview_requested(QString), parameters=QString #36 type=Signal, signature=rename_requested(QString), parameters=QString #37 type=Signal, signature=delete_requested(QString), parameters=QString )