"""Filesystem pane with breadcrumb navigation, history and live filter. This is one half of ISO Scalpel's split-nav interface. It browses the host filesystem and emits "add to image" requests when the user drags selections onto the ISO pane (or invokes the context-menu action). The pane provides: * a clickable breadcrumb path bar (jump to any ancestor), * back / forward / up navigation buttons with a per-pane history, * a live filter box that narrows the current listing by name, * drag-out support so entries can be dropped onto the ISO pane. """ # This file is part of ISO Scalpel. # Copyright (C) 2025 Jeremy Anderson # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, see # or write to the Free Software Foundation, Inc., 51 Franklin Street, # Fifth Floor, Boston, MA 02110-1301 USA. from __future__ import annotations import os from PySide6.QtCore import QDir, QModelIndex, QSortFilterProxyModel, Qt, Signal from PySide6.QtGui import QAction, QKeySequence from PySide6.QtWidgets import ( QApplication, QFileSystemModel, QFrame, QHBoxLayout, QHeaderView, QLabel, QLineEdit, QMenu, QPushButton, QSizePolicy, QToolButton, QTreeView, QVBoxLayout, QWidget, ) from ..config import Settings class FsPane(QWidget): """Browse the host filesystem; emit add-to-image requests.""" addRequested = Signal(list) # list[str] of local paths to add pathChanged = Signal(str) # current directory navChanged = Signal() # back/forward availability changed def __init__(self, settings: Settings, parent=None): super().__init__(parent) self._settings = settings self._history: list[str] = [] self._history_idx = -1 # --- model + proxy filter ---------------------------------------- self._model = QFileSystemModel() self._model.setRootPath("") self._filter = _NameFilterProxy() self._filter.setSourceModel(self._model) # --- view -------------------------------------------------------- self.view = QTreeView() self.view.setModel(self._filter) self.view.setSortingEnabled(True) self.view.setRootIsDecorated(True) self.view.setAlternatingRowColors(True) self.view.setSelectionMode(QTreeView.ExtendedSelection) self.view.setDragEnabled(True) self.view.setDragDropMode(QTreeView.DragOnly) self.view.setUniformRowHeights(True) self.view.sortByColumn(0, Qt.AscendingOrder) self.view.setColumnWidth(0, 260) self.view.doubleClicked.connect(self._on_double_click) self.view.setContextMenuPolicy(Qt.CustomContextMenu) self.view.customContextMenuRequested.connect(self._on_context) # Column sizing: the Name column stretches to fill available # width; Size / Type / Date Modified auto-fit to their contents # so they're never cramped or absurdly wide. hdr = self.view.header() hdr.setStretchLastSection(False) hdr.setSectionResizeMode(0, QHeaderView.Stretch) for col in (1, 2, 3): if col < hdr.count(): hdr.setSectionResizeMode(col, QHeaderView.ResizeToContents) # --- navigation buttons ----------------------------------------- self.back_btn = QToolButton(arrowType=Qt.LeftArrow) self.back_btn.setToolTip("Back (Alt+Left)") self.back_btn.clicked.connect(self.go_back) self.fwd_btn = QToolButton(arrowType=Qt.RightArrow) self.fwd_btn.setToolTip("Forward (Alt+Right)") self.fwd_btn.clicked.connect(self.go_forward) self.up_btn = QToolButton(arrowType=Qt.UpArrow) self.up_btn.setToolTip("Up (Alt+Up)") self.up_btn.clicked.connect(self.go_up) # --- breadcrumb bar --------------------------------------------- self._crumb_layout = QHBoxLayout() self._crumb_layout.setSpacing(0) self._crumb_layout.setContentsMargins(0, 0, 0, 0) self._crumb_frame = QFrame() self._crumb_frame.setObjectName("Breadcrumb") self._crumb_frame.setLayout(self._crumb_layout) # --- filter box -------------------------------------------------- self.filter_edit = QLineEdit() self.filter_edit.setPlaceholderText("Filter…") self.filter_edit.setClearButtonEnabled(True) self.filter_edit.textChanged.connect(self._filter.set_filter) self.filter_edit.setMaxLength(200) # Cap the filter box width so it doesn't stretch absurdly wide # on large windows -- the breadcrumb bar should get the bulk of # the available horizontal space. self.filter_edit.setMaximumWidth(220) self.filter_edit.setMinimumWidth(120) # --- nav bar (buttons + breadcrumb + filter) -------------------- bar = QHBoxLayout() bar.setSpacing(4) bar.setContentsMargins(4, 2, 4, 2) bar.addWidget(self.back_btn) bar.addWidget(self.fwd_btn) bar.addWidget(self.up_btn) bar.addWidget(self._crumb_frame, 1) bar.addWidget(self.filter_edit, 0) layout = QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(0) layout.addLayout(bar) layout.addWidget(self.view, 1) self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) self.set_path(settings.last_dir) # ------------------------------------------------------------------ navigation def set_path(self, path: str) -> None: path = os.path.abspath(path or os.getcwd()) if not os.path.isdir(path): return idx = self._model.index(path) if not idx.isValid(): return src_root = idx proxy_root = self._filter.mapFromSource(src_root) self.view.setRootIndex(proxy_root if proxy_root.isValid() else src_root) if not self._history or self._history[self._history_idx] != path: self._history = self._history[: self._history_idx + 1] self._history.append(path) self._history_idx = len(self._history) - 1 self._settings.last_dir = path self._rebuild_breadcrumbs(path) self.pathChanged.emit(path) self.navChanged.emit() def current_path(self) -> str: if 0 <= self._history_idx < len(self._history): return self._history[self._history_idx] return self._settings.last_dir def go_up(self) -> None: parent = os.path.dirname(self.current_path()) if parent and parent != self.current_path(): self.set_path(parent) def go_back(self) -> None: if self._history_idx > 0: self._history_idx -= 1 self._apply_history() def go_forward(self) -> None: if self._history_idx < len(self._history) - 1: self._history_idx += 1 self._apply_history() def can_back(self) -> bool: return self._history_idx > 0 def can_forward(self) -> bool: return self._history_idx < len(self._history) - 1 def _apply_history(self) -> None: path = self._history[self._history_idx] idx = self._model.index(path) if idx.isValid(): proxy = self._filter.mapFromSource(idx) self.view.setRootIndex(proxy if proxy.isValid() else idx) self._settings.last_dir = path self._rebuild_breadcrumbs(path) self.pathChanged.emit(path) self.navChanged.emit() def _on_double_click(self, proxy_idx: QModelIndex) -> None: src = self._filter.mapToSource(proxy_idx) path = self._model.filePath(src) if os.path.isdir(path): self.set_path(path) # ------------------------------------------------------------------ breadcrumbs def _rebuild_breadcrumbs(self, path: str) -> None: # clear while self._crumb_layout.count(): it = self._crumb_layout.takeAt(0) w = it.widget() if w is not None: w.deleteLater() parts = [] cur = path guard = 0 while cur and guard < 64: parts.append((os.path.basename(cur) or cur, cur)) parent = os.path.dirname(cur) if parent == cur: break cur = parent guard += 1 parts.reverse() for i, (label, target) in enumerate(parts): if i > 0: sep = QLabel("›") # noqa: RUF001 -- breadcrumb separator glyph sep.setStyleSheet("color:#999; padding:0 1px;") self._crumb_layout.addWidget(sep) btn = QPushButton(label) btn.setFlat(True) btn.setStyleSheet( "QPushButton { border:0; padding:2px 4px; text-align:left; " "color:#1a73e8; } QPushButton:hover { text-decoration:underline; }") btn.clicked.connect(lambda _=False, t=target: self.set_path(t)) self._crumb_layout.addWidget(btn) self._crumb_layout.addStretch(1) # ------------------------------------------------------------------ selection def selected_paths(self) -> list[str]: """Local filesystem paths of the selected rows (empty strings dropped).""" return [ self._model.filePath(self._filter.mapToSource(pi)) for pi in self.view.selectionModel().selectedRows() if self._model.filePath(self._filter.mapToSource(pi)) ] def selection_count(self) -> int: return len(self.view.selectionModel().selectedRows()) # ------------------------------------------------------------------ filter def focus_filter(self) -> None: self.filter_edit.setFocus() self.filter_edit.selectAll() def clear_filter(self) -> None: self.filter_edit.clear() # ------------------------------------------------------------------ options def set_show_hidden(self, on: bool) -> None: flt = QDir.AllEntries | QDir.NoDotAndDotDot if on: flt |= QDir.Hidden self._model.setFilter(flt) self._settings.show_hidden = on # ------------------------------------------------------------------ context menu def _on_context(self, pos) -> None: idx = self.view.indexAt(pos) menu = QMenu(self) act_add = QAction("Add to ISO image", self) act_add.triggered.connect(self._emit_add) menu.addAction(act_add) menu.addSeparator() act_open = QAction("Open", self) act_open.triggered.connect(self._open_current) menu.addAction(act_open) if idx.isValid(): act_copy = QAction("Copy path", self) act_copy.triggered.connect(lambda: self._copy_path(idx)) menu.addAction(act_copy) menu.addSeparator() act_refresh = QAction("Refresh", self) act_refresh.setShortcut(QKeySequence.Refresh) act_refresh.triggered.connect(lambda: self.set_path(self.current_path())) menu.addAction(act_refresh) menu.exec(self.view.viewport().mapToGlobal(pos)) def _emit_add(self) -> None: paths = self.selected_paths() if paths: self.addRequested.emit(paths) def _open_current(self) -> None: paths = self.selected_paths() if paths and os.path.isdir(paths[0]): self.set_path(paths[0]) def _copy_path(self, idx) -> None: src = self._filter.mapToSource(idx) QApplication.clipboard().setText(self._model.filePath(src)) class _NameFilterProxy(QSortFilterProxyModel): """Case-insensitive substring filter on the filename column.""" def __init__(self): super().__init__() self._needle = "" def set_filter(self, text: str) -> None: self._needle = (text or "").lower() self.invalidateFilter() def filterAcceptsRow(self, source_row, source_parent): if not self._needle: return True idx = self.sourceModel().index(source_row, 0, source_parent) name = self.sourceModel().fileName(idx) return self._needle in name.lower()