Added watchdog support
This commit is contained in:
499
bagheeraview.py
499
bagheeraview.py
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user