Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 91 additions & 23 deletions faststack/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -512,6 +512,7 @@ def __init__(

# -- Stacking State --
self.stack_start_index: Optional[int] = None
self.stack_end_index: Optional[int] = None
self.stacks: List[List[int]] = []

# -- Batch Selection State (for drag-and-drop) --
Expand Down Expand Up @@ -896,6 +897,13 @@ def set_sort_mode(self, mode: str):
and 0 <= self.stack_start_index < len(self.image_files)
else None
)
old_stack_end_path = (
self.image_files[self.stack_end_index].path
if not clear_stacks
and self.stack_end_index is not None
and 0 <= self.stack_end_index < len(self.image_files)
else None
)
old_batch_start_path = (
self.image_files[self.batch_start_index].path
if self.batch_start_index is not None
Expand All @@ -917,6 +925,7 @@ def set_sort_mode(self, mode: str):
if clear_stacks:
self.stacks = []
self.stack_start_index = None
self.stack_end_index = None
self.sidecar.data.stacks = []
self.sidecar.save()
elif have_stacks:
Expand All @@ -931,6 +940,9 @@ def set_sort_mode(self, mode: str):
if not clear_stacks and old_stack_start_path:
key = self._key(old_stack_start_path)
self.stack_start_index = self._path_to_index.get(key)
if not clear_stacks and old_stack_end_path:
key = self._key(old_stack_end_path)
self.stack_end_index = self._path_to_index.get(key)

if self.image_files and preserved_path:
target_key = self._key(preserved_path)
Expand Down Expand Up @@ -4429,8 +4441,13 @@ def jump_to_last_uploaded(self):
# for idx in range(last_index, -1, -1)
for idx in range(len(self.image_files) - 1, -1, -1):
img = self.image_files[idx]
# Dynamic look-up of self.sidecar as requested (important for mocks in tests)
meta = self.sidecar.get_metadata(img.path, create=False)
# This is a read-only sweep across the folder. Skipping migration
# avoids an O(images x sidecar entries) filesystem scan on misses.
meta = self.sidecar.get_metadata(
img.path,
create=False,
migrate=False,
)
Comment on lines +4446 to +4450

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve metadata migration for jump action

For folders with older sidecars that still store uploaded flags under legacy keys, this lookup now returns None instead of letting get_metadata migrate/find the entry, so “Jump to Last Uploaded” can report no upload or jump to an earlier image even though an uploaded image exists. The sidecar API reserves migrate=False for bulk grid-style reads, while this is a user action that depends on accurate flags.

Useful? React with 👍 / 👎.


uploaded = meta.uploaded if meta else False

Expand Down Expand Up @@ -4763,6 +4780,8 @@ def duplicate_current_image(self):
and self.stack_start_index >= insert_index
):
self.stack_start_index += 1
if self.stack_end_index is not None and self.stack_end_index >= insert_index:
self.stack_end_index += 1
if stacks_changed:
self.sidecar.data.stacks = self.stacks
self.sidecar.save()
Expand Down Expand Up @@ -5286,32 +5305,48 @@ def _on_exif_brief_ready(self, exif_key: tuple[str, str], brief: str):
if self.ui_state:
self.ui_state.metadataChanged.emit()

def _define_pending_stack(self) -> bool:
if self.stack_start_index is None or self.stack_end_index is None:
return False

start = min(self.stack_start_index, self.stack_end_index)
end = max(self.stack_start_index, self.stack_end_index)
self.stacks.append([start, end])
self.stacks.sort() # Keep stacks sorted by start index
self.sidecar.data.stacks = self.stacks
self.sidecar.save()
log.info("Defined new stack: [%d, %d]", start, end)
self.stack_start_index = None
self.stack_end_index = None
self._metadata_cache_index = (-1, -1) # Invalidate cache
self.dataChanged.emit() # Notify QML of data change
self.ui_state.stackSummaryChanged.emit() # Update stack summary in dialog
self.sync_ui_state()
count = end - start + 1
self.update_status_message(
f"Stack defined: {count} image{'' if count == 1 else 's'}"
)
return True

def begin_new_stack(self):
self.stack_start_index = self.current_index
log.info("Stack start marked at index %d", self.stack_start_index)
if self._define_pending_stack():
return
self._metadata_cache_index = (-1, -1) # Invalidate cache
self.dataChanged.emit() # Update UI to show start marker
self.sync_ui_state()
self.update_status_message("Stack start marked")

def end_current_stack(self):
log.info(
"end_current_stack called. stack_start_index: %s", self.stack_start_index
)
if self.stack_start_index is not None:
start = min(self.stack_start_index, self.current_index)
end = max(self.stack_start_index, self.current_index)
self.stacks.append([start, end])
self.stacks.sort() # Keep stacks sorted by start index
self.sidecar.data.stacks = self.stacks
self.sidecar.save()
log.info("Defined new stack: [%d, %d]", start, end)
self.stack_start_index = None
self._metadata_cache_index = (-1, -1) # Invalidate cache
self.dataChanged.emit() # Notify QML of data change
self.ui_state.stackSummaryChanged.emit() # Update stack summary in dialog
self.sync_ui_state()
else:
log.warning("No stack start marked. Press '[' first.")
self.stack_end_index = self.current_index
log.info("Stack end marked at index %d", self.stack_end_index)
if self._define_pending_stack():
return
self._metadata_cache_index = (-1, -1) # Invalidate cache
self.dataChanged.emit() # Update UI to show end marker
self.sync_ui_state()
self.update_status_message("Stack end marked")

def begin_new_batch(self):
"""Mark the start of a new batch for drag-and-drop."""
Expand Down Expand Up @@ -6091,6 +6126,7 @@ def clear_all_stacks(self):
log.info("Clearing all defined stacks.")
self.stacks = []
self.stack_start_index = None
self.stack_end_index = None
# Do NOT clear batches here

self.sidecar.data.stacks = self.stacks
Expand Down Expand Up @@ -6996,6 +7032,7 @@ def _switch_to_directory(
self.batches = []
self.batch_start_index = None
self.stack_start_index = None
self.stack_end_index = None

# Clear caches since they reference old directory's images
with self._last_image_lock:
Expand Down Expand Up @@ -7763,10 +7800,23 @@ def _shift_start_index(

def _rollback_ui_items(self, items: List[Tuple[int, Any]], job: DeleteJob) -> None:
"""Restore items to the UI list in correct order."""
# Insert in ascending index order so each insertion shifts subsequent
# indices correctly, restoring the original list positions.
# Each saved idx is an ORIGINAL list position. During a partial rollback
# some earlier-deleted items stay removed, so a raw insert at idx would
# overshoot a still-missing lower position. Shift each idx left by the
# count of still-missing lower positions to land it in the compressed
# list. Insert in ascending index order so prior inserts settle first.
present_keys = {self._key(x.path) for x in self.image_files}
restoring_keys = {self._key(img.path) for _, img in items}
still_missing = sorted(
idx
for idx, img in job.removed_items
if self._key(img.path) not in present_keys
and self._key(img.path) not in restoring_keys
)
for idx, img in sorted(items, key=lambda x: x[0]):
self.image_files.insert(min(idx, len(self.image_files)), img)
shift = sum(1 for m in still_missing if m < idx)
pos = min(max(idx - shift, 0), len(self.image_files))
self.image_files.insert(pos, img)

# Restore selection/focus (approximated)
self.current_index = min(job.previous_index, len(self.image_files) - 1)
Expand Down Expand Up @@ -7815,6 +7865,7 @@ def _rollback_ui_items(self, items: List[Tuple[int, Any]], job: DeleteJob) -> No
if restored == original:
self.stacks = [s[:] for s in ui.saved_stacks]
self.stack_start_index = ui.saved_stack_start_index
self.stack_end_index = ui.saved_stack_end_index
else:
still_deleted = sorted(original - restored)
self.stacks = self._recompute_batches_after_deletions(
Expand All @@ -7823,6 +7874,9 @@ def _rollback_ui_items(self, items: List[Tuple[int, Any]], job: DeleteJob) -> No
self.stack_start_index = self._shift_start_index(
ui.saved_stack_start_index, still_deleted
)
self.stack_end_index = self._shift_start_index(
ui.saved_stack_end_index, still_deleted
)
self.sidecar.data.stacks = self.stacks
self._metadata_cache_index = (-1, -1)

Expand Down Expand Up @@ -7944,6 +7998,7 @@ def _delete_indices(self, indices: List[int], action_type: str) -> dict:
pre_batch_start_snapshot = self.batch_start_index
pre_stack_snapshot = [s[:] for s in self.stacks]
pre_stack_start_snapshot = self.stack_start_index
pre_stack_end_snapshot = self.stack_end_index

# Shared helper: compute post-deletion index for a surviving index.
deleted_ascending = sorted(validated_sorted)
Expand Down Expand Up @@ -8017,6 +8072,11 @@ def _shift(orig_idx: int) -> int:
self.stack_start_index = None
else:
self.stack_start_index = _shift(pre_stack_start_snapshot)
if pre_stack_end_snapshot is not None:
if pre_stack_end_snapshot in deleted_set:
self.stack_end_index = None
else:
self.stack_end_index = _shift(pre_stack_end_snapshot)

# Update UI immediately - this is fast since it just reads from memory
# Check for existence, not truthiness (empty cache is falsy)
Expand Down Expand Up @@ -8100,6 +8160,7 @@ def _shift(orig_idx: int) -> int:
saved_batch_start_index=pre_batch_start_snapshot,
saved_stacks=pre_stack_snapshot,
saved_stack_start_index=pre_stack_start_snapshot,
saved_stack_end_index=pre_stack_end_snapshot,
),
)

Expand Down Expand Up @@ -8453,6 +8514,7 @@ def undo_delete(self):
if ui is not None and ui.saved_stacks is not None and removed_items:
self.stacks = ui.saved_stacks
self.stack_start_index = ui.saved_stack_start_index
self.stack_end_index = ui.saved_stack_end_index
self.sidecar.data.stacks = self.stacks
self._metadata_cache_index = (-1, -1)
self.sync_ui_state()
Expand Down Expand Up @@ -12107,6 +12169,12 @@ def _get_stack_info(self, index: int) -> str:
and self.stack_start_index == index
):
info = "Stack Start Marked"
elif (
not info
and self.stack_end_index is not None
and self.stack_end_index == index
):
info = "Stack End Marked"
log.debug("_get_stack_info for index %d: %s", index, info)
return info

Expand Down
1 change: 1 addition & 0 deletions faststack/deletion_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ class UIStateRestoration:
saved_batch_start_index: Optional[int] = None
saved_stacks: Optional[list] = None
saved_stack_start_index: Optional[int] = None
saved_stack_end_index: Optional[int] = None


@dataclass
Expand Down
Loading
Loading