|
| 1 | +from PySide6.QtCore import QObject, Signal, Property, Slot, QUrl, QMimeData, Qt |
| 2 | +from PySide6.QtWidgets import QMenu |
| 3 | +from PySide6.QtGui import QCursor, QIcon, QDrag |
| 4 | +from ui.services.conflict_resolver import ConflictResolver |
| 5 | +from ui.dialogs.conflicts import ConflictAction |
| 6 | +from core.search_worker import SearchWorker |
| 7 | +from ui.models.shortcuts import ShortcutAction |
| 8 | +from core.gio_bridge.desktop import open_with_default_app as _open_file |
| 9 | + |
| 10 | + |
| 11 | +class AppBridge(QObject): |
| 12 | + pathChanged = Signal(str) |
| 13 | + targetCellWidthChanged = Signal(int) |
| 14 | + renameRequested = Signal(str) |
| 15 | + cutPathsChanged = Signal() # Emitted when clipboard cut state changes |
| 16 | + |
| 17 | + # Search signals |
| 18 | + searchResultsFound = Signal(list) # Batch of paths |
| 19 | + searchFinished = Signal(int) # Total count |
| 20 | + searchError = Signal(str) # Error message |
| 21 | + |
| 22 | + def __init__(self, main_window): |
| 23 | + super().__init__() |
| 24 | + self.mw = main_window |
| 25 | + self._target_cell_width = 75 |
| 26 | + self._pending_select_paths = [] # Paths to select after next directory refresh |
| 27 | + self._pending_rename_path = None # Path to trigger rename after createFolder completes |
| 28 | + |
| 29 | + # Initialize search worker |
| 30 | + self._search_worker = SearchWorker(self) |
| 31 | + self._search_worker.resultsFound.connect(self.searchResultsFound) |
| 32 | + self._search_worker.searchFinished.connect(self.searchFinished) |
| 33 | + self._search_worker.searchError.connect(self.searchError) |
| 34 | + |
| 35 | + # Connect to clipboard changes |
| 36 | + self.mw.clipboard.clipboardChanged.connect(self._on_clipboard_changed) |
| 37 | + |
| 38 | + def _on_clipboard_changed(self): |
| 39 | + self.cutPathsChanged.emit() |
| 40 | + |
| 41 | + @Property(list, notify=cutPathsChanged) |
| 42 | + def cutPaths(self): |
| 43 | + """Returns list of paths currently in cut state.""" |
| 44 | + # Delegated to FileManager |
| 45 | + return self.mw.file_manager.get_cut_paths() |
| 46 | + |
| 47 | + @Slot(list) |
| 48 | + def startDrag(self, paths): |
| 49 | + """ |
| 50 | + Initiates a system drag-and-drop operation for the given paths. |
| 51 | + This is a blocking call (exec) until drag ends. |
| 52 | + """ |
| 53 | + if not paths: |
| 54 | + return |
| 55 | + |
| 56 | + drag = QDrag(self.mw) |
| 57 | + mime_data = QMimeData() |
| 58 | + |
| 59 | + # Format as text/uri-list |
| 60 | + urls = [QUrl.fromLocalFile(p) for p in paths] |
| 61 | + mime_data.setUrls(urls) |
| 62 | + |
| 63 | + drag.setMimeData(mime_data) |
| 64 | + |
| 65 | + # VISUAL FEEDBACK: Create a pixmap that looks like a file/stack of files |
| 66 | + # We grab a standard icon from the theme |
| 67 | + icon = QIcon.fromTheme("text-x-generic") |
| 68 | + pixmap = icon.pixmap(64, 64) |
| 69 | + drag.setPixmap(pixmap) |
| 70 | + drag.setHotSpot(pixmap.rect().center()) |
| 71 | + |
| 72 | + # Execute Drag |
| 73 | + # Qt.MoveAction | Qt.CopyAction allows both. default to Copy. |
| 74 | + drag.exec(Qt.CopyAction | Qt.MoveAction, Qt.CopyAction) |
| 75 | + |
| 76 | + @Slot(list, str) |
| 77 | + def handleDrop(self, urls, dest_dir=None): |
| 78 | + """ |
| 79 | + Handles files dropped onto the view or a folder. |
| 80 | + Delegated to FileManager. |
| 81 | + """ |
| 82 | + self.mw.file_manager.handle_drop(urls, dest_dir) |
| 83 | + |
| 84 | + @Slot(str) |
| 85 | + def openPath(self, path): |
| 86 | + self.mw.navigate_to(path) |
| 87 | + |
| 88 | + @Slot(list) |
| 89 | + def showContextMenu(self, paths): |
| 90 | + """ |
| 91 | + Shows a native QMenu using ActionManager actions. |
| 92 | + """ |
| 93 | + if not paths: return |
| 94 | + |
| 95 | + menu = QMenu(self.mw) |
| 96 | + am = self.mw.action_manager |
| 97 | + |
| 98 | + is_single = len(paths) == 1 |
| 99 | + |
| 100 | + # Open (single only) - we don't have an action for "Open" in AM yet (it's logic here) |
| 101 | + # But we can keep specific logic or move it. |
| 102 | + # For now, let's keep Open logic or create a one-off action. |
| 103 | + if is_single: |
| 104 | + act_open = menu.addAction(QIcon.fromTheme("document-open"), "Open") |
| 105 | + act_open.triggered.connect(lambda checked=False, p=paths[0]: _open_file(p)) |
| 106 | + menu.addSeparator() |
| 107 | + |
| 108 | + menu.addAction(am.get_action(ShortcutAction.COPY)) |
| 109 | + menu.addAction(am.get_action(ShortcutAction.CUT)) |
| 110 | + |
| 111 | + # Paste needs enabled check |
| 112 | + act_paste = am.get_action(ShortcutAction.PASTE) |
| 113 | + act_paste.setEnabled(self.mw.file_manager.get_clipboard_files() != []) |
| 114 | + menu.addAction(act_paste) |
| 115 | + |
| 116 | + menu.addSeparator() |
| 117 | + |
| 118 | + if is_single: |
| 119 | + menu.addAction(am.get_action(ShortcutAction.RENAME)) |
| 120 | + |
| 121 | + menu.addSeparator() |
| 122 | + menu.addAction(am.get_action(ShortcutAction.TRASH)) |
| 123 | + |
| 124 | + menu.exec(QCursor.pos()) |
| 125 | + |
| 126 | + @Slot() |
| 127 | + def showBackgroundContextMenu(self): |
| 128 | + """Shows a context menu for empty space.""" |
| 129 | + menu = QMenu(self.mw) |
| 130 | + am = self.mw.action_manager |
| 131 | + |
| 132 | + act_paste = am.get_action(ShortcutAction.PASTE) |
| 133 | + act_paste.setEnabled(self.mw.file_manager.get_clipboard_files() != []) |
| 134 | + menu.addAction(act_paste) |
| 135 | + |
| 136 | + menu.addSeparator() |
| 137 | + menu.addAction(am.get_action(ShortcutAction.NEW_FOLDER)) |
| 138 | + |
| 139 | + menu.exec(QCursor.pos()) |
| 140 | + |
| 141 | + # _create_new_folder moved to FileManager |
| 142 | + |
| 143 | + @Slot(str, result=str) |
| 144 | + def getThumbnailPath(self, path: str) -> str: |
| 145 | + """ |
| 146 | + [DEPRECATED] Check if a native GNOME thumbnail exists for the file. |
| 147 | + Returns direct file:// URL if cached, else fallback to image:// provider. |
| 148 | + |
| 149 | + NOTE: This method is no longer called from QML during scroll. |
| 150 | + Thumbnail URL resolution has been moved to RowBuilder._resolve_thumbnail_url() |
| 151 | + which pre-computes the URL at load time, eliminating blocking I/O from |
| 152 | + the render path. Kept for backward compatibility. |
| 153 | + """ |
| 154 | + import hashlib |
| 155 | + import urllib.parse |
| 156 | + from pathlib import Path |
| 157 | + |
| 158 | + try: |
| 159 | + # 1. Construct canonical URI (file:///path/to/file) |
| 160 | + # Must be quote-encoded (e.g. " " -> "%20") |
| 161 | + uri = "file://" + urllib.parse.quote(path) |
| 162 | + |
| 163 | + # 2. GNOME Thumbnail Spec: MD5 of URI |
| 164 | + md5_hash = hashlib.md5(uri.encode('utf-8')).hexdigest() |
| 165 | + |
| 166 | + # 3. Check Large Cache (256px) |
| 167 | + # The standard location is ~/.cache/thumbnails/large/ |
| 168 | + cache_dir = Path.home() / ".cache" / "thumbnails" / "large" |
| 169 | + thumb_path = cache_dir / f"{md5_hash}.png" |
| 170 | + |
| 171 | + if thumb_path.exists(): |
| 172 | + # SUCCESS: Return direct path to bypass Python loader |
| 173 | + return f"file://{thumb_path}" |
| 174 | + |
| 175 | + except Exception as e: |
| 176 | + print(f"[AppBridge] Thumbnail lookup failed: {e}") |
| 177 | + |
| 178 | + # FALLBACK: Ask the generator to make one |
| 179 | + return f"image://thumbnail/{path}" |
| 180 | + |
| 181 | + @Slot(str, str) |
| 182 | + def renameFile(self, old_path, new_name): |
| 183 | + """ |
| 184 | + Renames a file. Called from QML after user finishes editing. |
| 185 | + """ |
| 186 | + if not old_path or not new_name: |
| 187 | + return |
| 188 | + |
| 189 | + from gi.repository import Gio |
| 190 | + gfile = Gio.File.parse_name(old_path) |
| 191 | + parent = gfile.get_parent() |
| 192 | + if not parent: |
| 193 | + return |
| 194 | + |
| 195 | + new_gfile = parent.get_child(new_name) |
| 196 | + new_path = new_gfile.get_path() or new_gfile.get_uri() |
| 197 | + |
| 198 | + # Check if name actually changed |
| 199 | + if old_path == new_path: |
| 200 | + return |
| 201 | + |
| 202 | + print(f"[AppBridge] Renaming '{old_path}' -> '{new_name}'") |
| 203 | + |
| 204 | + # Creates a single-use resolver for this rename op |
| 205 | + resolver = ConflictResolver(self.mw) |
| 206 | + action, final_dest = resolver.resolve_rename(old_path, new_path) |
| 207 | + |
| 208 | + if action == ConflictAction.CANCEL or action == ConflictAction.SKIP: |
| 209 | + return |
| 210 | + |
| 211 | + # Overwrite or Rename logic |
| 212 | + final_gfile = Gio.File.parse_name(final_dest) |
| 213 | + final_name = final_gfile.get_basename() |
| 214 | + self.mw.file_ops.rename(old_path, final_name) |
| 215 | + |
| 216 | + @Slot() |
| 217 | + def paste(self): |
| 218 | + self.mw.file_manager.paste_to_current() |
| 219 | + |
| 220 | + def queueSelectionAfterRefresh(self, paths): |
| 221 | + """Queue paths to be selected after the next directory refresh.""" |
| 222 | + self._pending_select_paths = paths |
| 223 | + |
| 224 | + def selectPendingPaths(self): |
| 225 | + """Called after directory refresh to select queued files. Returns and clears the queue.""" |
| 226 | + paths = self._pending_select_paths |
| 227 | + self._pending_select_paths = [] |
| 228 | + return paths |
| 229 | + |
| 230 | + @Property(int, notify=targetCellWidthChanged) |
| 231 | + def targetCellWidth(self): |
| 232 | + return self._target_cell_width |
| 233 | + |
| 234 | + @targetCellWidth.setter |
| 235 | + def targetCellWidth(self, val): |
| 236 | + if self._target_cell_width != val: |
| 237 | + self._target_cell_width = val |
| 238 | + self.targetCellWidthChanged.emit(val) |
| 239 | + |
| 240 | + @Slot(int) |
| 241 | + def zoom(self, delta): |
| 242 | + # FIX: Wheel Up (Positive) should Zoom In |
| 243 | + self.mw.view_manager.zoom_in() if delta > 0 else self.mw.view_manager.zoom_out() |
| 244 | + |
| 245 | + # ------------------------------------------------------------------------- |
| 246 | + # SEARCH API |
| 247 | + # ------------------------------------------------------------------------- |
| 248 | + |
| 249 | + @Slot(str, str, bool) |
| 250 | + def startSearch(self, directory: str, pattern: str, recursive: bool = True): |
| 251 | + """ |
| 252 | + Start a file search in the background. |
| 253 | + |
| 254 | + Results are emitted via searchResultsFound signal in batches. |
| 255 | + """ |
| 256 | + self._search_worker.start_search(directory, pattern, recursive) |
| 257 | + |
| 258 | + @Slot() |
| 259 | + def cancelSearch(self): |
| 260 | + """Cancel the current search.""" |
| 261 | + self._search_worker.cancel() |
| 262 | + |
| 263 | + @Property(str, constant=True) |
| 264 | + def searchEngineName(self) -> str: |
| 265 | + """Returns the name of the current search engine (fd or scandir).""" |
| 266 | + return self._search_worker.engine_name |
| 267 | + |
0 commit comments