Skip to content

Commit 31b00a2

Browse files
committed
feat(core): surgical file create/delete/rename updates via FileMonitor
- Add singleFileScanned signal and scan_single_file() to FileScanner - Connect FileMonitor signals to TabController for per-file updates - Add addSingleItem/removeSingleItem slots to RowBuilder - Pre-compute thumbnailUrl in RowBuilder (eliminates blocking Python calls during scroll) - Add selection tracking to TabController (updateSelection slot) - Deprecate AppBridge.getThumbnailPath (moved to RowBuilder load-time) - Disable full-directory reload on FileMonitor events
1 parent f136b27 commit 31b00a2

File tree

5 files changed

+160
-1
lines changed

5 files changed

+160
-1
lines changed

core/file_monitor.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,11 +109,13 @@ def _on_changed(self, monitor, file, other_file, event_type):
109109
# Map GIO events to our signals
110110
if event_type == Gio.FileMonitorEvent.CREATED:
111111
if path:
112+
print(f"[DEBUG-SURGICAL] FileMonitor: CREATED {path}")
112113
self.fileCreated.emit(path)
113114
self._debounce_timer.start()
114115

115116
elif event_type == Gio.FileMonitorEvent.DELETED:
116117
if path:
118+
print(f"[DEBUG-SURGICAL] FileMonitor: DELETED {path}")
117119
self.fileDeleted.emit(path)
118120
self._debounce_timer.start()
119121

@@ -124,6 +126,7 @@ def _on_changed(self, monitor, file, other_file, event_type):
124126

125127
elif event_type == Gio.FileMonitorEvent.RENAMED:
126128
if path and other_path:
129+
print(f"[DEBUG-SURGICAL] FileMonitor: RENAMED {path} -> {other_path}")
127130
self.fileRenamed.emit(path, other_path)
128131
self._debounce_timer.start()
129132

core/gio_bridge/scanner.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from gi.repository import Gio, GLib, GnomeDesktop
1919
import urllib.parse
2020
from uuid import uuid4
21+
import os
2122

2223
from PySide6.QtCore import QObject, Signal, Slot, QTimer
2324
# from PySide6.QtGui import QImageReader # REMOVED: No longer used in main thread
@@ -40,6 +41,7 @@ class FileScanner(QObject):
4041
scanFinished = Signal(str) # (session_id)
4142
scanError = Signal(str)
4243
fileAttributeUpdated = Signal(str, str, object) # path, attribute_name, value
44+
singleFileScanned = Signal(str, dict) # session_id, item dict
4345

4446
# Valid visual extensions (thumbnails needed)
4547
# VISUAL_EXTENSIONS removed in favor of MIME type detection
@@ -154,6 +156,27 @@ def scan_directory(self, path: str) -> None:
154156
cancellable # Pass token as user_data
155157
)
156158

159+
@Slot(str)
160+
def scan_single_file(self, path: str) -> None:
161+
"""
162+
Scans a single file synchronously and emits singleFileScanned.
163+
Used for surgical UI updates when a file is created/copied.
164+
"""
165+
gfile = Gio.File.new_for_path(path)
166+
try:
167+
info = gfile.query_info(
168+
self.QUERY_ATTRIBUTES,
169+
Gio.FileQueryInfoFlags.NONE,
170+
None
171+
)
172+
# Use the existing _process_batch logic (it expects a list of infos and parent dir)
173+
parent_path = os.path.dirname(path)
174+
batch = self._process_batch([info], parent_path)
175+
if batch:
176+
self.singleFileScanned.emit(self._session_id, batch[0])
177+
except GLib.Error as e:
178+
print(f"[FileScanner] Could not scan single file {path}: {e}")
179+
157180
@Slot()
158181
def cancel(self) -> None:
159182
"""Cancel any in-progress scan."""

ui/managers/row_builder.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@
1414

1515
from PySide6.QtCore import QObject, Slot, Signal, Property, QTimer
1616
from core.sorter import Sorter
17+
import hashlib
18+
import urllib.parse
19+
import os
1720

1821
class RowBuilder(QObject):
1922
rowsChanged = Signal()
@@ -156,6 +159,32 @@ def setFiles(self, files: list) -> None:
156159
# GNOME Thumbnail Cache Size (LARGE = 256px longest edge)
157160
THUMBNAIL_CACHE_SIZE = 256
158161

162+
# GNOME thumbnail cache directory (resolved once)
163+
_THUMB_CACHE_DIR = os.path.expanduser("~/.cache/thumbnails/large")
164+
165+
@staticmethod
166+
def _resolve_thumbnail_url(path: str) -> str:
167+
"""
168+
Pre-compute the thumbnail URL for a visual item.
169+
170+
Checks the GNOME thumbnail cache (MD5 of URI -> ~/.cache/thumbnails/large/).
171+
Returns a direct file:// URL if cached, else falls back to the async
172+
image://thumbnail/ provider.
173+
174+
This runs once per item at load time, removing all blocking I/O
175+
from the QML render path.
176+
"""
177+
try:
178+
uri = "file://" + urllib.parse.quote(path)
179+
md5_hash = hashlib.md5(uri.encode('utf-8')).hexdigest()
180+
thumb_path = os.path.join(RowBuilder._THUMB_CACHE_DIR, f"{md5_hash}.png")
181+
# [DEBUG] Force disable direct file access to test if it causes lag
182+
# if os.path.exists(thumb_path):
183+
# return f"file://{thumb_path}"
184+
except Exception:
185+
pass
186+
return f"image://thumbnail/{path}"
187+
159188
@Slot(list)
160189
def appendFiles(self, new_files: list) -> None:
161190
"""Append files (streaming mode)."""
@@ -174,6 +203,12 @@ def appendFiles(self, new_files: list) -> None:
174203

175204
# Calculate thumbnail cap dimensions
176205
self._calculate_thumbnail_cap(item)
206+
207+
# [PERF] Pre-compute thumbnail URL (removes blocking I/O from QML render path)
208+
if item.get("isVisual") and path:
209+
item["thumbnailUrl"] = self._resolve_thumbnail_url(path)
210+
else:
211+
item["thumbnailUrl"] = ""
177212

178213
self._items.extend(new_files)
179214
self._sorted_items.extend(new_files)
@@ -224,6 +259,47 @@ def clear(self) -> None:
224259
self._is_loading = False
225260
self.rowsChanged.emit()
226261

262+
@Slot(dict)
263+
def addSingleItem(self, item: dict) -> None:
264+
"""Surgically insert a single file without forcing a full scan reload."""
265+
path = item.get("path")
266+
print(f"[DEBUG-SURGICAL] RowBuilder: addSingleItem called for {path}")
267+
if not path: return
268+
269+
# Check if already exists (debounce protection)
270+
for existing in self._items:
271+
if existing.get("path") == path:
272+
print(f"[DEBUG-SURGICAL] RowBuilder: Item already exists, ignoring {path}")
273+
return
274+
275+
# Apply pending dimensions
276+
if path in self._pending_dimensions:
277+
w, h = self._pending_dimensions.pop(path)
278+
item["width"] = w
279+
item["height"] = h
280+
281+
self._calculate_thumbnail_cap(item)
282+
if item.get("isVisual"):
283+
item["thumbnailUrl"] = self._resolve_thumbnail_url(path)
284+
else:
285+
item["thumbnailUrl"] = ""
286+
287+
self._items.append(item)
288+
self._reapply_sort_and_layout()
289+
print(f"[DEBUG-SURGICAL] RowBuilder: Item added. Total items: {len(self._items)}")
290+
291+
@Slot(str)
292+
def removeSingleItem(self, path: str) -> None:
293+
"""Surgically remove a single file."""
294+
print(f"[DEBUG-SURGICAL] RowBuilder: removeSingleItem called for {path}")
295+
if not path: return
296+
for i, item in enumerate(self._items):
297+
if item.get("path") == path:
298+
self._items.pop(i)
299+
self._reapply_sort_and_layout()
300+
print(f"[DEBUG-SURGICAL] RowBuilder: Item {path} removed. Total items: {len(self._items)}")
301+
break
302+
227303
@Slot(result="QVariant") # type: ignore # Returns list of lists
228304
def getRows(self):
229305
return self._rows

ui/models/app_bridge.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,8 +141,13 @@ def showBackgroundContextMenu(self):
141141
@Slot(str, result=str)
142142
def getThumbnailPath(self, path: str) -> str:
143143
"""
144-
Check if a native GNOME thumbnail exists for the file.
144+
[DEPRECATED] Check if a native GNOME thumbnail exists for the file.
145145
Returns direct file:// URL if cached, else fallback to image:// provider.
146+
147+
NOTE: This method is no longer called from QML during scroll.
148+
Thumbnail URL resolution has been moved to RowBuilder._resolve_thumbnail_url()
149+
which pre-computes the URL at load time, eliminating blocking I/O from
150+
the render path. Kept for backward compatibility.
146151
"""
147152
import hashlib
148153
import urllib.parse

ui/models/tab_model.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,14 @@ def __init__(self, main_window, initial_path: str | None = None):
3131
self.scanner.filesFound.connect(self._on_files_found)
3232
self.scanner.scanFinished.connect(self._on_scan_finished)
3333
self.scanner.fileAttributeUpdated.connect(self.row_builder.updateItem)
34+
self.scanner.singleFileScanned.connect(self._on_single_file_scanned)
3435
self.selectAllRequested.connect(self.row_builder.selectAllRequested)
3536

37+
# 1.5. FileMonitor -> Surgical Updates
38+
self.mw.file_monitor.fileCreated.connect(self._on_file_created)
39+
self.mw.file_monitor.fileDeleted.connect(self._on_file_deleted)
40+
self.mw.file_monitor.fileRenamed.connect(self._on_file_renamed)
41+
3642
# 2. Bridge reference
3743
# Duck-type the bridge's tab reference
3844
self.bridge._tab = self
@@ -42,6 +48,18 @@ def __init__(self, main_window, initial_path: str | None = None):
4248
self.future_stack = []
4349
self._is_history_nav = False
4450
self._current_session_id = ""
51+
self._selection = []
52+
53+
@property
54+
def selection(self):
55+
"""Returns the current list of selected file paths."""
56+
return self._selection
57+
58+
@Slot(list)
59+
def updateSelection(self, paths):
60+
"""Receive selection updates from QML."""
61+
self._selection = paths
62+
4563

4664
@Property(str, notify=pathChanged)
4765
def currentPath(self):
@@ -114,6 +132,31 @@ def _on_files_found(self, session_id: str, batch: list):
114132
return
115133
self.row_builder.appendFiles(batch)
116134

135+
def _on_single_file_scanned(self, session_id: str, item: dict):
136+
if session_id != self._current_session_id:
137+
return
138+
print(f"[DEBUG-SURGICAL] TabController: Received single file scan for {item.get('path')}")
139+
self.row_builder.addSingleItem(item)
140+
141+
@Slot(str)
142+
def _on_file_created(self, path: str):
143+
print(f"[DEBUG-SURGICAL] TabController: _on_file_created: {path} | Current path: {self._current_path}")
144+
if os.path.dirname(path) == os.path.abspath(self._current_path):
145+
self.scanner.scan_single_file(path)
146+
147+
@Slot(str)
148+
def _on_file_deleted(self, path: str):
149+
print(f"[DEBUG-SURGICAL] TabController: _on_file_deleted: {path} | Current path: {self._current_path}")
150+
if os.path.dirname(path) == os.path.abspath(self._current_path):
151+
self.row_builder.removeSingleItem(path)
152+
153+
@Slot(str, str)
154+
def _on_file_renamed(self, old_path: str, new_path: str):
155+
print(f"[DEBUG-SURGICAL] TabController: _on_file_renamed: {old_path} -> {new_path}")
156+
if os.path.dirname(old_path) == os.path.abspath(self._current_path):
157+
self.row_builder.removeSingleItem(old_path)
158+
self.scanner.scan_single_file(new_path)
159+
117160
def _on_scan_finished(self, session_id: str):
118161
if session_id != self._current_session_id:
119162
return
@@ -127,6 +170,15 @@ def _on_scan_finished(self, session_id: str):
127170
def cleanup(self):
128171
"""Cleanup resources when tab is closed."""
129172
self.scanner.cancel()
173+
174+
# Disconnect surgical updates
175+
try: self.mw.file_monitor.fileCreated.disconnect(self._on_file_created)
176+
except: pass
177+
try: self.mw.file_monitor.fileDeleted.disconnect(self._on_file_deleted)
178+
except: pass
179+
try: self.mw.file_monitor.fileRenamed.disconnect(self._on_file_renamed)
180+
except: pass
181+
130182
try:
131183
self.scanner.filesFound.disconnect()
132184
except: pass

0 commit comments

Comments
 (0)