Added watchdog support

This commit is contained in:
Ignacio Serantes
2026-03-28 07:13:16 +01:00
parent 096cee6ca3
commit d4f3732aa4
7 changed files with 661 additions and 92 deletions

View File

@@ -78,6 +78,7 @@ from widgets import (
FavoritesWidget
)
from metadatamanager import load_common_metadata
from filesystemwatcher import FileSystemWatcher
class ShortcutHelpDialog(QDialog):
@@ -1067,6 +1068,11 @@ class MainWindow(QMainWindow):
self.progress_bar.hide()
bot.addWidget(self.progress_bar)
self.fs_watcher_status_lbl = QLabel()
self.fs_watcher_status_lbl.setToolTip(UITexts.FS_WATCHER_TOOLTIP)
self.fs_watcher_status_lbl.hide()
bot.addWidget(self.fs_watcher_status_lbl)
# Timer to hide progress bar with delay
self.hide_progress_timer = QTimer(self)
self.hide_progress_timer.setSingleShot(True)
@@ -1236,12 +1242,6 @@ class MainWindow(QMainWindow):
self.favorites_tab.favorites_changed.connect(
self.shortcut_controller.refresh_favorite_shortcuts)
# Initialize FileSystemWatcher
self.fs_watcher = FileSystemWatcher()
self.fs_watcher.file_created.connect(self.on_fs_file_created)
self.fs_watcher.file_deleted.connect(self.on_fs_file_deleted)
self.fs_watcher.file_modified.connect(self.on_fs_file_modified)
self.main_dock.setWidget(self.tags_tabs)
self.addDockWidget(Qt.RightDockWidgetArea, self.main_dock)
@@ -1289,6 +1289,30 @@ class MainWindow(QMainWindow):
self.thumbnail_view.verticalScrollBar().valueChanged.connect(
self._on_scroll_interaction)
# Initialize FileSystemWatcher
self.fs_watcher = FileSystemWatcher()
self.fs_watcher.file_created.connect(self.on_fs_file_created)
self.fs_watcher.file_deleted.connect(self.on_fs_file_deleted)
self.fs_watcher.file_modified.connect(self.on_fs_file_modified)
self.fs_watcher.directory_modified.connect(self.on_fs_directory_modified)
self.fs_watcher.file_moved.connect(self.on_fs_file_moved)
self.fs_watcher.directory_moved.connect(self.on_fs_directory_moved)
self.fs_watcher.monitoring_status_changed.connect(
self.on_fs_watcher_status_changed)
# Batching for file creation events
self._fs_created_queue = set()
self._fs_created_timer = QTimer(self)
self._fs_created_timer.setSingleShot(True)
self._fs_created_timer.setInterval(1000) # 1 second debounce
self._fs_created_timer.timeout.connect(self._process_fs_created_batch)
# Debounce timer for full refreshes on directory modifications
self._fs_dir_refresh_timer = QTimer(self)
self._fs_dir_refresh_timer.setSingleShot(True)
self._fs_dir_refresh_timer.setInterval(2500) # 2.5 seconds debounce
self._fs_dir_refresh_timer.timeout.connect(self.refresh_content)
# Initial configuration loading
self.load_config()
self.load_full_history()
@@ -1607,15 +1631,17 @@ class MainWindow(QMainWindow):
# 5. Start scanning all parent directories of the images in the layout
unique_dirs = list({str(Path(p).parent) for p in paths})
for d in unique_dirs:
paths.append(d)
if d not in paths:
paths.append(d)
self.start_scan([p.strip() for p in paths if p.strip()
and os.path.exists(os.path.expanduser(p.strip()))],
select_paths=select_paths)
if search_text:
self.search_input.setEditText(search_text)
# --- UI and Menu Logic ---
def show_main_menu(self):
"""Displays the main application menu."""
menu = QMenu(self)
@@ -1817,6 +1843,7 @@ class MainWindow(QMainWindow):
def perform_shutdown(self):
"""Performs cleanup operations before the application closes."""
self.is_cleaning = True
self.fs_watcher.stop()
# 1. Stop all worker threads interacting with the cache
# Signal all threads to stop first
@@ -2059,7 +2086,13 @@ class MainWindow(QMainWindow):
def find_and_select_path(self, path_to_select):
"""Finds an item by its path in the model and selects it using a cache."""
if not path_to_select or path_to_select not in self._path_to_model_index:
if not path_to_select:
return False
# Ensure path is normalized for reliable cache lookup
path_to_select = os.path.abspath(os.path.expanduser(path_to_select))
if path_to_select not in self._path_to_model_index:
return False
persistent_index = self._path_to_model_index[path_to_select]
@@ -2072,6 +2105,11 @@ class MainWindow(QMainWindow):
proxy_index = self.proxy_model.mapFromSource(source_index)
if proxy_index.isValid():
# Optimization: skip if already selected and current to avoid
# unnecessary signals and view updates.
if self.thumbnail_view.currentIndex() == proxy_index and \
self.thumbnail_view.selectionModel().isSelected(proxy_index):
return True
self.set_selection(proxy_index)
return True
@@ -2368,16 +2406,16 @@ class MainWindow(QMainWindow):
inode=new_inode, device_id=new_dev)
# Update model item
for row in range(self.thumbnail_model.rowCount()):
item = self.thumbnail_model.item(row)
if item and item.data(PATH_ROLE) == path:
if path in self._path_to_model_index:
p_idx = self._path_to_model_index[path]
if p_idx.isValid():
item = self.thumbnail_model.itemFromIndex(QModelIndex(p_idx))
item.setIcon(QIcon(QPixmap.fromImage(thumb_img)))
item.setData(new_mtime, MTIME_ROLE)
item.setData(new_inode, INODE_ROLE)
item.setData(new_dev, DEVICE_ROLE)
self._update_internal_data(path, qi=thumb_img, mtime=new_mtime,
inode=new_inode, dev=new_dev)
break
except Exception:
pass
@@ -2418,6 +2456,7 @@ class MainWindow(QMainWindow):
self.proxy_model.clear_cache()
self._model_update_queue.clear()
self._model_update_timer.stop()
self.fs_watcher.clear_paths()
# Stop any pending hide action from previous scan
self.hide_progress_timer.stop()
@@ -2437,6 +2476,12 @@ class MainWindow(QMainWindow):
self.scanner.set_auto_load(True)
self._is_loading = True
self.scanner.images_found.connect(self.collect_found_images)
# Add directories to file system watcher
for p in paths:
if os.path.isdir(os.path.abspath(os.path.expanduser(p))):
self.fs_watcher.add_path(os.path.abspath(os.path.expanduser(p)))
self.scanner.progress_percent.connect(self.update_progress_bar)
self.scanner.progress_msg.connect(self.status_lbl.setText)
self.scanner.more_files_available.connect(self.more_files_available)
@@ -2625,12 +2670,9 @@ class MainWindow(QMainWindow):
# Check for Header match
# target format: ('HEADER', (key, header_text, count))
if isinstance(target, tuple) and len(target) == 2 and target[0] == 'HEADER':
_, (_, header_text, _) = target
# Strict match including group name to ensure roles are updated
target_group_name = target[1][0]
return (item.data(ITEM_TYPE_ROLE) == 'header' and
item.data(GROUP_NAME_ROLE) == target_group_name and
item.data(DIR_ROLE) == header_text)
item.data(GROUP_NAME_ROLE) == target_group_name)
# Check for Thumbnail match
# target format: (path, qi, mtime, tags, rating, inode, dev)
@@ -2641,48 +2683,94 @@ class MainWindow(QMainWindow):
return False
def _get_group_info(self, path, mtime, rating):
"""Calculates the grouping key and display name for a file.
Args:
path (str): File path.
mtime (float): Modification time.
rating (int): Rating value.
Returns:
tuple: (stable_group_key, display_name)
"""
cache_key = (path, mtime, rating)
if cache_key in self._group_info_cache:
return self._group_info_cache[cache_key]
stable_group_key = None
display_name = None
"""Calculates the grouping key and display name for a file with optimized
caching."""
# Determine resolution criteria for shared caching across all files in same
# group
if self.proxy_model.group_by_folder:
stable_group_key = display_name = os.path.dirname(path)
elif self.proxy_model.group_by_day:
stable_group_key = display_name = datetime.fromtimestamp(
mtime).strftime("%Y-%m-%d")
elif self.proxy_model.group_by_week:
dt = datetime.fromtimestamp(mtime)
stable_group_key = dt.strftime("%Y-%W")
display_name = UITexts.GROUP_BY_WEEK_FORMAT.format(
year=dt.strftime("%Y"), week=dt.strftime("%W"))
elif self.proxy_model.group_by_month:
dt = datetime.fromtimestamp(mtime)
stable_group_key = dt.strftime("%Y-%m")
display_name = dt.strftime("%B %Y").capitalize()
elif self.proxy_model.group_by_year:
stable_group_key = display_name = datetime.fromtimestamp(
mtime).strftime("%Y")
crit = os.path.dirname(path)
mode = 'F'
elif self.proxy_model.group_by_rating:
r = rating if rating is not None else 0
stars = (r + 1) // 2
stable_group_key = str(stars)
display_name = UITexts.GROUP_BY_RATING_FORMAT.format(stars=stars)
crit = (rating + 1) // 2 if rating is not None else 0
mode = 'R'
else:
# Date modes: use datetime object parts as hashable criteria
dt = datetime.fromtimestamp(int(mtime) if mtime else 0)
if self.proxy_model.group_by_day:
crit = (dt.year, dt.month, dt.day)
mode = 'D'
elif self.proxy_model.group_by_week:
crit = (dt.year, int(dt.strftime("%W")))
mode = 'W'
elif self.proxy_model.group_by_month:
crit = (dt.year, dt.month)
mode = 'M'
elif self.proxy_model.group_by_year:
crit = dt.year
mode = 'Y'
else:
return (None, None)
self._group_info_cache[cache_key] = (stable_group_key, display_name)
return stable_group_key, display_name
# Shared cache by criteria ensures expensive formatting happens only once per
# group
shared_key = (crit, mode)
if shared_key in self._group_info_cache:
return self._group_info_cache[shared_key]
# Perform actual calculation
if mode == 'F':
res = (crit, crit)
elif mode == 'R':
res = (str(crit), UITexts.GROUP_BY_RATING_FORMAT.format(stars=crit))
else:
if mode == 'D':
sk = dn = dt.strftime("%Y-%m-%d")
elif mode == 'W':
sk = dt.strftime("%Y-%W")
dn = UITexts.GROUP_BY_WEEK_FORMAT.format(
year=dt.strftime("%Y"), week=dt.strftime("%W"))
elif mode == 'M':
sk = dt.strftime("%Y-%m")
dn = dt.strftime("%B %Y").capitalize()
else: # Year
sk = dn = dt.strftime("%Y")
res = (sk, dn)
self._group_info_cache[shared_key] = res
return res
def _find_sorted_index_in_data(self, new_item_data):
"""Finds the correct index to insert an item to keep found_items_data sorted."""
mode = self.sort_combo.currentText()
rev = "" in mode
sort_by_name = "Name" in mode
def get_key(item):
# item structure: (path, qi, mtime, tags, rating, inode, dev)
path, _, mtime, _, _, _, _ = item
if sort_by_name:
return os.path.basename(path).lower()
return mtime if mtime is not None else 0
target_key = get_key(new_item_data)
# Binary search for the insertion point (O(log N))
lo = 0
hi = len(self.found_items_data)
while lo < hi:
mid = (lo + hi) // 2
mid_key = get_key(self.found_items_data[mid])
if not rev:
if mid_key < target_key:
lo = mid + 1
else:
hi = mid
else:
if mid_key > target_key:
lo = mid + 1
else:
hi = mid
return lo
def rebuild_view(self, full_reset=False):
"""
@@ -2779,13 +2867,26 @@ class MainWindow(QMainWindow):
self.thumbnail_model.clear()
self._path_to_model_index.clear()
# Optimize grouped insertion: Decorate-Sort-Group
# 1. Decorate: Calculate group info once per item
# 1. Decorate: Calculate group info once per item with local memoization
decorated_data = []
local_memo = {}
# Cache grouping flags to avoid property lookups in loop
g_folder = self.proxy_model.group_by_folder
for item in self.found_items_data:
# item structure: (path, qi, mtime, tags, rating, inode, dev)
stable_key, display_name = self._get_group_info(
item[0], item[2], item[4])
path, _, mtime, _, rating, _, _ = item
# Local cache key: path for folders, (int_mtime, rating) for others
m_key = path if g_folder else (int(mtime) if mtime else 0, rating)
if m_key in local_memo:
stable_key, display_name = local_memo[m_key]
else:
stable_key, display_name = self._get_group_info(
path, mtime, rating)
local_memo[m_key] = (stable_key, display_name)
# Use empty string for None keys to ensure sortability
sort_key = stable_key if stable_key is not None else ""
decorated_data.append((sort_key, display_name, item))
@@ -2824,13 +2925,82 @@ class MainWindow(QMainWindow):
total_targets = len(target_structure)
new_items_batch = []
# Optimization: Pre-calculate sets for fast lookup of needed items
target_paths_set = {t[0] for t in target_structure
if isinstance(t, tuple) and len(t) >= 5}
target_headers_set = {t[1][0] for t in target_structure
if isinstance(t, tuple)
and len(t) == 2 and t[0] == 'HEADER'}
while target_idx < total_targets:
target = target_structure[target_idx]
current_item = self.thumbnail_model.item(model_idx)
if self._match_item(target, current_item):
# Si es una cabecera, actualizamos el texto por si cambió el
# contador
if isinstance(target, tuple) and target[0] == 'HEADER':
_, (_, header_text, _) = target
if current_item.data(DIR_ROLE) != header_text:
current_item.setData(header_text, DIR_ROLE)
model_idx += 1
target_idx += 1
continue
# 1. Identify and remove stale items (items in model but not in
# target structure)
if current_item:
is_needed = False
if current_item.data(ITEM_TYPE_ROLE) == 'thumbnail':
is_needed = current_item.data(PATH_ROLE) in target_paths_set
else: # header
is_needed = current_item.data(GROUP_NAME_ROLE) \
in target_headers_set
if not is_needed:
path = current_item.data(PATH_ROLE)
if path and path in self._path_to_model_index:
del self._path_to_model_index[path]
self.thumbnail_model.removeRow(model_idx)
# Stay at same model_idx, check next model item against
# same target
continue
# 2. Try to MOVE target from later in the model (reordering
# optimization)
found_model_row = -1
if isinstance(target, tuple) and len(target) >= 5: # Thumbnail
path = target[0]
p_idx = self._path_to_model_index.get(path)
if p_idx and p_idx.isValid() and p_idx.row() > model_idx:
found_model_row = p_idx.row()
elif isinstance(target, tuple) and target[0] == 'HEADER':
target_group_name = target[1][0]
for r in range(model_idx + 1, self.thumbnail_model.rowCount()):
it = self.thumbnail_model.item(r)
if it and it.data(ITEM_TYPE_ROLE) == 'header' and \
it.data(GROUP_NAME_ROLE) == target_group_name:
found_model_row = r
break
if found_model_row != -1:
# Move existing row to current position.
# Persistent indices and selection model will update
# automatically.
self.thumbnail_model.moveRow(QModelIndex(), found_model_row,
QModelIndex(), model_idx)
if isinstance(target, tuple) and target[0] == 'HEADER':
_, (_, header_text, _) = target
moved_item = self.thumbnail_model.item(model_idx)
if moved_item and moved_item.data(DIR_ROLE) != header_text:
moved_item.setData(header_text, DIR_ROLE)
model_idx += 1
target_idx += 1
continue
# 3. Target is truly NEW - use batch insertion
else:
# Prepare new item
if isinstance(target, tuple) and len(target) == 2 \
@@ -2857,12 +3027,21 @@ class MainWindow(QMainWindow):
# recalculations
while target_idx < total_targets:
next_target = target_structure[target_idx]
# Check if next_target matches current model position
# (re-sync)
if self._match_item(
next_target, self.thumbnail_model.item(model_idx)):
break
# Check if next_target is a MOVE (exists elsewhere)
if isinstance(next_target, tuple) and len(next_target) >= 5:
n_p_idx = self._path_to_model_index.get(next_target[0])
if n_p_idx and n_p_idx.isValid() \
and n_p_idx.row() > model_idx:
break
# (simplified lookahead: headers always break batch)
# If not matching, it's another new item to insert
if isinstance(next_target, tuple) \
and len(next_target) == 2 and next_target[0] == 'HEADER':
@@ -3473,22 +3652,23 @@ class MainWindow(QMainWindow):
res = load_common_metadata(path)
tags, rating = res.tags, res.rating
# Find the item in the source model and update its data
for row in range(self.thumbnail_model.rowCount()):
item = self.thumbnail_model.item(row)
if item and item.data(PATH_ROLE) == path:
# Use cache for O(1) lookup in the source model
path = os.path.abspath(os.path.expanduser(path))
if path in self._path_to_model_index:
p_idx = self._path_to_model_index[path]
if p_idx.isValid():
item = self.thumbnail_model.itemFromIndex(QModelIndex(p_idx))
item.setData(tags, TAGS_ROLE)
item.setData(rating, RATING_ROLE)
tooltip_text = f"{os.path.basename(path)}\n{path}"
if tags:
display_tags = [t.split('/')[-1] for t in tags]
tooltip_text += f"\n{UITexts.TAGS_TAB}: {', '.join(
display_tags)}"
tooltip_text += f"\n{UITexts.TAGS_TAB}: {', '.join(display_tags)}"
item.setToolTip(tooltip_text)
# Notify the view that the data has changed
source_idx = self.thumbnail_model.indexFromItem(item)
source_idx = QModelIndex(p_idx)
self.thumbnail_model.dataChanged.emit(
source_idx, source_idx, [TAGS_ROLE, RATING_ROLE])
@@ -3497,7 +3677,6 @@ class MainWindow(QMainWindow):
# Update proxy filter cache to prevent stale filtering
self.proxy_model.add_to_cache(path, tags)
break
if self.main_dock.isVisible():
self.on_tags_tab_changed(self.tags_tabs.currentIndex())
@@ -3528,14 +3707,14 @@ class MainWindow(QMainWindow):
self.thumbnail_view.setGridSize(QSize())
else:
self.thumbnail_view.setGridSize(self.delegate.sizeHint(None, None))
self.rebuild_view(full_reset=True)
self.rebuild_view()
self.save_config()
self.setFocus()
def on_sort_changed(self):
"""Callback for when the sort order dropdown changes."""
self.rebuild_view(full_reset=True)
self.rebuild_view()
self.save_config()
if hasattr(self, 'history_tab'):
self.history_tab.refresh_list()
@@ -4274,8 +4453,169 @@ class MainWindow(QMainWindow):
self.cache.clear_cache()
self.status_lbl.setText(UITexts.CACHE_CLEARED)
def on_fs_file_created(self, path):
"""Handles a new file being created in a monitored directory."""
# Add to batch queue and (re)start the debounce timer
self._fs_created_queue.add(path)
self._fs_created_timer.start()
def _process_fs_created_batch(self):
"""Processes all accumulated file creation events at once."""
paths = list(self._fs_created_queue)
self._fs_created_queue.clear()
valid_new_items = []
for p in paths:
p = os.path.abspath(p)
if os.path.exists(p) and p not in self._known_paths:
if os.path.splitext(p)[1].lower() in constants.IMAGE_EXTENSIONS:
valid_new_items.append(p)
if not valid_new_items:
return
# If the batch is very large, a full scan is more efficient than individual
# stats
if len(valid_new_items) > 50:
self.refresh_content()
return
# For smaller batches, process metadata and update model
for path in valid_new_items:
try:
res = load_common_metadata(path)
stat_res = os.stat(path)
mtime = stat_res.st_mtime
inode = stat_res.st_ino
dev = stat_res.st_dev
# tuple: (path, qi, mtime, tags, rating, inode, dev)
new_item_data = (path, None, mtime, res.tags, res.rating, inode, dev)
self.found_items_data.append(new_item_data)
self._known_paths.add(path)
self.proxy_model.add_to_cache(path, res.tags)
except Exception:
continue
# Trigger an incremental rebuild of the view
self.rebuild_view()
# Start background generation for the new items
self.generate_missing_thumbnails(self._current_thumb_tier)
if len(valid_new_items) == 1:
msg = f"New file detected: {os.path.basename(valid_new_items[0])}"
else:
msg = f"Detected {len(valid_new_items)} new files"
self.status_lbl.setText(msg)
def on_fs_file_deleted(self, path):
"""Handles a file being deleted from a monitored directory."""
path = os.path.abspath(path)
if path not in self._known_paths:
return # Not a file we're tracking
# Remove from internal data structures
self.found_items_data = [item for item in self.found_items_data
if item[0] != path]
self._known_paths.discard(path)
self.proxy_model._data_cache.pop(path, None)
self.cache.invalidate_path(path) # Clear from cache
# Update any open viewers
for w in QApplication.topLevelWidgets():
if isinstance(w, ImageViewer):
if path in w.controller.image_list:
try:
deleted_idx = w.controller.image_list.index(path)
new_list = list(w.controller.image_list)
new_list.remove(path)
w.refresh_after_delete(new_list, deleted_idx)
except (ValueError, RuntimeError):
pass
# Invalidate caches that might hold references to the deleted path
self._visible_paths_cache = None
keys_to_remove = [k for k in self._group_info_cache if k[0] == path]
for k in keys_to_remove:
del self._group_info_cache[k]
self.rebuild_view()
self.status_lbl.setText(f"File deleted: {os.path.basename(path)}")
def on_fs_file_moved(self, old_path, new_path):
"""Handles a file being renamed or moved."""
old_path = os.path.abspath(old_path)
new_path = os.path.abspath(new_path)
is_old_img = os.path.splitext(old_path)[1].lower() in constants.IMAGE_EXTENSIONS
is_new_img = os.path.splitext(new_path)[1].lower() in constants.IMAGE_EXTENSIONS
if is_old_img and is_new_img:
if old_path in self._known_paths:
self.propagate_rename(old_path, new_path)
else:
self.on_fs_file_created(new_path)
elif is_old_img:
# Moved out or renamed to non-image
self.on_fs_file_deleted(old_path)
elif is_new_img:
# Moved in from outside
self.on_fs_file_created(new_path)
def on_fs_file_modified(self, path):
"""Handles a file being modified in a monitored directory."""
path = os.path.abspath(path)
if path not in self._known_paths:
return # Not a file we're tracking
# Invalidate cache and trigger a refresh of its metadata and thumbnail
self.cache.invalidate_path(path)
# Re-read metadata and thumbnail
res = load_common_metadata(path)
mtime = os.path.getmtime(path)
stat_res = os.stat(path)
inode = stat_res.st_ino
dev = stat_res.st_dev
# Update internal data and model
self._update_internal_data(path, mtime=mtime, tags=res.tags, rating=res.rating,
inode=inode, dev=dev)
self.proxy_model.add_to_cache(path, res.tags)
self.rebuild_view()
self.status_lbl.setText(f"File modified: {os.path.basename(path)}")
def on_fs_watcher_status_changed(self, is_monitoring):
"""Updates the UI indicator for the FileSystemWatcher."""
if is_monitoring:
self.fs_watcher_status_lbl.setPixmap(
QIcon.fromTheme("folder-open").pixmap(16, 16))
self.fs_watcher_status_lbl.show()
else:
self.fs_watcher_status_lbl.hide()
def on_fs_directory_modified(self, path):
"""Handles a directory being modified (e.g., new subfolder, mass changes)."""
path = os.path.abspath(path)
# Trigger a debounced full refresh. This is useful for syncing large
# external changes (bulk operations, directory deletions) that are
# more robustly handled by a full scan than incremental updates.
if not self._is_loading and not self.is_cleaning:
self._fs_dir_refresh_timer.start()
def on_fs_directory_moved(self, old_path, new_path):
"""Handles a directory being renamed or moved."""
# For directory moves, a full refresh is the most reliable way to sync
# since all child paths have changed and individual file move signals
# might not be emitted for every item inside.
self.on_fs_directory_modified(new_path)
def propagate_rename(self, old_path, new_path, source_viewer=None):
"""Propagates a file rename across the application."""
old_path = os.path.abspath(old_path)
new_path = os.path.abspath(new_path)
self._visible_paths_cache = None
# Update found_items_data to ensure consistency on future rebuilds
current_tags = None
@@ -4302,15 +4642,16 @@ class MainWindow(QMainWindow):
os.path.basename(new_path).lower())
# Update the main model
for row in range(self.thumbnail_model.rowCount()):
item = self.thumbnail_model.item(row)
if item and item.data(PATH_ROLE) == old_path:
item.setData(new_path, PATH_ROLE)
item.setText(os.path.basename(new_path))
# No need to update the icon, it's the same image data
source_index = self.thumbnail_model.indexFromItem(item)
self.thumbnail_model.dataChanged.emit(source_index, source_index)
break
if old_path in self._path_to_model_index:
p_idx = self._path_to_model_index.pop(old_path)
if p_idx.isValid():
item = self.thumbnail_model.itemFromIndex(QModelIndex(p_idx))
if item:
item.setData(new_path, PATH_ROLE)
item.setText(os.path.basename(new_path))
self._path_to_model_index[new_path] = p_idx
source_index = QModelIndex(p_idx)
self.thumbnail_model.dataChanged.emit(source_index, source_index)
# Update the cache entry
self.cache.rename_entry(old_path, new_path)