feat: Add delete file functionality with confirmation screen

This commit is contained in:
sHa
2026-01-03 15:08:48 +00:00
parent b45e629825
commit ef1e1e06ca
5 changed files with 232 additions and 3 deletions

BIN
dist/renamer-0.6.8-py3-none-any.whl vendored Normal file

Binary file not shown.

View File

@@ -1,6 +1,6 @@
[project] [project]
name = "renamer" name = "renamer"
version = "0.6.7" version = "0.6.8"
description = "Terminal-based media file renamer and metadata viewer" description = "Terminal-based media file renamer and metadata viewer"
readme = "README.md" readme = "README.md"
requires-python = ">=3.11" requires-python = ">=3.11"

View File

@@ -70,6 +70,7 @@ class AppCommandProvider(Provider):
("refresh", "Refresh File", "Refresh metadata for selected file (f)"), ("refresh", "Refresh File", "Refresh metadata for selected file (f)"),
("rename", "Rename File", "Rename the selected file (r)"), ("rename", "Rename File", "Rename the selected file (r)"),
("convert", "Convert AVI to MKV", "Convert AVI file to MKV container with metadata (c)"), ("convert", "Convert AVI to MKV", "Convert AVI file to MKV container with metadata (c)"),
("delete", "Delete File", "Delete the selected file (d)"),
("toggle_mode", "Toggle Display Mode", "Switch between technical and catalog view (m)"), ("toggle_mode", "Toggle Display Mode", "Switch between technical and catalog view (m)"),
("expand", "Toggle Tree Expansion", "Expand or collapse all tree nodes (p)"), ("expand", "Toggle Tree Expansion", "Expand or collapse all tree nodes (p)"),
("settings", "Settings", "Open settings screen (Ctrl+S)"), ("settings", "Settings", "Open settings screen (Ctrl+S)"),
@@ -105,6 +106,7 @@ class RenamerApp(App):
("f", "refresh", "Refresh"), ("f", "refresh", "Refresh"),
("r", "rename", "Rename"), ("r", "rename", "Rename"),
("c", "convert", "Convert AVI→MKV"), ("c", "convert", "Convert AVI→MKV"),
("d", "delete", "Delete"),
("p", "expand", "Toggle Tree"), ("p", "expand", "Toggle Tree"),
("m", "toggle_mode", "Toggle Mode"), ("m", "toggle_mode", "Toggle Mode"),
("h", "help", "Help"), ("h", "help", "Help"),
@@ -394,6 +396,22 @@ By Category:"""
ConvertConfirmScreen(file_path, mkv_path, audio_languages, subtitle_files, extractor) ConvertConfirmScreen(file_path, mkv_path, audio_languages, subtitle_files, extractor)
) )
async def action_delete(self):
"""Delete a file with confirmation."""
from .screens import DeleteConfirmScreen
tree = self.query_one("#file_tree", Tree)
node = tree.cursor_node
if not (node and node.data and isinstance(node.data, Path) and node.data.is_file()):
self.notify("Please select a file first", severity="warning", timeout=3)
return
file_path = node.data
# Show confirmation screen
self.push_screen(DeleteConfirmScreen(file_path))
async def action_expand(self): async def action_expand(self):
tree = self.query_one("#file_tree", Tree) tree = self.query_one("#file_tree", Tree)
if self.tree_expanded: if self.tree_expanded:
@@ -527,9 +545,113 @@ By Category:"""
else: else:
logging.warning(f"No parent node found for {parent_dir}") logging.warning(f"No parent node found for {parent_dir}")
logging.warning(f"Rescanning entire tree instead") logging.warning(f"Rescanning entire tree instead")
# If we can't find the parent node, just rescan the whole tree # If we can't find the parent node, rescan the tree and try to select the new file
tree = self.query_one("#file_tree", Tree)
current_selection = tree.cursor_node.data if tree.cursor_node else None
self.scan_files() self.scan_files()
# Try to restore selection to the new file, or the old selection, or parent dir
def find_and_select(node, target_path):
if node.data and isinstance(node.data, Path):
if node.data.resolve() == target_path.resolve():
tree.select_node(node)
return True
for child in node.children:
if find_and_select(child, target_path):
return True
return False
# Try to select the new file first
if not find_and_select(tree.root, file_path):
# If that fails, try to restore previous selection
if current_selection:
find_and_select(tree.root, current_selection)
# Refresh details panel for selected node
if tree.cursor_node and tree.cursor_node.data:
self._start_loading_animation()
threading.Thread(
target=self._extract_and_show_details, args=(tree.cursor_node.data,)
).start()
def remove_file_from_tree(self, file_path: Path):
"""Remove a file from the tree.
Args:
file_path: Path to the file to remove
"""
logging.info(f"remove_file_from_tree called with file_path={file_path}")
tree = self.query_one("#file_tree", Tree)
# Find the node to remove
def find_node(node):
if node.data and isinstance(node.data, Path):
if node.data.resolve() == file_path.resolve():
return node
for child in node.children:
found = find_node(child)
if found:
return found
return None
node_to_remove = find_node(tree.root)
if node_to_remove:
logging.info(f"Found node to remove: {node_to_remove.data}")
# Find the parent node to select after deletion
parent_node = node_to_remove.parent
next_node = None
# Try to select next sibling, or previous sibling, or parent
if parent_node:
siblings = list(parent_node.children)
try:
current_index = siblings.index(node_to_remove)
# Try next sibling first
if current_index + 1 < len(siblings):
next_node = siblings[current_index + 1]
# Try previous sibling
elif current_index > 0:
next_node = siblings[current_index - 1]
# Fall back to parent
else:
next_node = parent_node if parent_node != tree.root else None
except ValueError:
pass
# Remove the node
node_to_remove.remove()
logging.info(f"Removed node from tree")
# Select the next appropriate node
if next_node:
tree.select_node(next_node)
logging.info(f"Selected next node: {next_node.data}")
# Refresh details if it's a file
if next_node.data and isinstance(next_node.data, Path) and next_node.data.is_file():
self._start_loading_animation()
threading.Thread(
target=self._extract_and_show_details, args=(next_node.data,)
).start()
else:
# Clear details panel
details = self.query_one("#details_technical", Static)
details.update("Select a file to view details")
proposed = self.query_one("#proposed", Static)
proposed.update("")
else:
# No node to select, clear details
details = self.query_one("#details_technical", Static)
details.update("No files in directory")
proposed = self.query_one("#proposed", Static)
proposed.update("")
else:
logging.warning(f"Node not found for {file_path}")
def on_key(self, event): def on_key(self, event):
if event.key == "right": if event.key == "right":
tree = self.query_one("#file_tree", Tree) tree = self.query_one("#file_tree", Tree)

View File

@@ -57,6 +57,8 @@ ACTIONS:
• s: Scan - Refresh the current directory • s: Scan - Refresh the current directory
• f: Refresh - Reload metadata for selected file • f: Refresh - Reload metadata for selected file
• r: Rename - Rename selected file with proposed name • r: Rename - Rename selected file with proposed name
• c: Convert - Convert AVI file to MKV container with metadata
• d: Delete - Delete selected file (with confirmation)
• p: Expand/Collapse - Toggle expansion of selected directory • p: Expand/Collapse - Toggle expansion of selected directory
• m: Toggle Mode - Switch between technical and catalog display modes • m: Toggle Mode - Switch between technical and catalog display modes
• ctrl+s: Settings - Open settings window • ctrl+s: Settings - Open settings window
@@ -495,5 +497,110 @@ Do you want to proceed with conversion?
# Simulate convert button press # Simulate convert button press
convert_button = self.query_one("#convert") convert_button = self.query_one("#convert")
self.on_button_pressed(type('Event', (), {'button': convert_button})()) self.on_button_pressed(type('Event', (), {'button': convert_button})())
elif event.key == "n" or event.key == "escape":
self.app.pop_screen() # type: ignore
class DeleteConfirmScreen(Screen):
"""Confirmation screen for file deletion."""
CSS = """
#delete_content {
text-align: center;
}
Button:focus {
background: $primary;
}
#buttons {
align: center middle;
}
#file_details {
text-align: left;
margin: 1 2;
padding: 1 2;
border: solid $error;
}
#warning_content {
text-align: center;
margin-bottom: 1;
margin-top: 1;
}
"""
def __init__(self, file_path: Path):
super().__init__()
self.file_path = file_path
def compose(self):
from .formatters.text_formatter import TextFormatter
title_text = f"{TextFormatter.bold(TextFormatter.red('DELETE FILE'))}"
# Build file details
file_size = self.file_path.stat().st_size if self.file_path.exists() else 0
from .formatters.size_formatter import SizeFormatter
size_str = SizeFormatter.format_size_full(file_size)
details_lines = [
f"{TextFormatter.bold('File:')} {TextFormatter.cyan(escape(self.file_path.name))}",
f"{TextFormatter.bold('Path:')} {TextFormatter.grey(escape(str(self.file_path.parent)))}",
f"{TextFormatter.bold('Size:')} {TextFormatter.yellow(size_str)}",
]
details_text = "\n".join(details_lines)
warning_text = f"""
{TextFormatter.bold(TextFormatter.red("WARNING: This action cannot be undone!"))}
{TextFormatter.yellow("The file will be permanently deleted from your system.")}
Are you sure you want to delete this file?
""".strip()
with Center():
with Vertical():
yield Static(title_text, id="delete_content", markup=True)
yield Static(details_text, id="file_details", markup=True)
yield Static(warning_text, id="warning_content", markup=True)
with Horizontal(id="buttons"):
yield Button("No (n)", id="cancel", variant="primary")
yield Button("Yes (y)", id="delete", variant="error")
def on_mount(self):
# Set focus to "No" button by default (safer option)
self.set_focus(self.query_one("#cancel"))
def on_button_pressed(self, event):
if event.button.id == "delete":
# Delete the file
app = self.app # type: ignore
try:
if self.file_path.exists():
self.file_path.unlink()
app.notify(f"✓ Deleted: {self.file_path.name}", severity="information", timeout=3)
logging.info(f"File deleted: {self.file_path}")
# Remove from tree
app.remove_file_from_tree(self.file_path)
else:
app.notify(f"✗ File not found: {self.file_path.name}", severity="error", timeout=3)
except PermissionError:
app.notify(f"✗ Permission denied: Cannot delete {self.file_path.name}", severity="error", timeout=5)
logging.error(f"Permission denied deleting file: {self.file_path}")
except Exception as e:
app.notify(f"✗ Error deleting file: {e}", severity="error", timeout=5)
logging.error(f"Error deleting file {self.file_path}: {e}", exc_info=True)
self.app.pop_screen() # type: ignore
else:
# Cancel
self.app.pop_screen() # type: ignore
def on_key(self, event):
if event.key == "y":
# Simulate delete button press
delete_button = self.query_one("#delete")
self.on_button_pressed(type('Event', (), {'button': delete_button})())
elif event.key == "n" or event.key == "escape": elif event.key == "n" or event.key == "escape":
self.app.pop_screen() # type: ignore self.app.pop_screen() # type: ignore

2
uv.lock generated
View File

@@ -462,7 +462,7 @@ wheels = [
[[package]] [[package]]
name = "renamer" name = "renamer"
version = "0.6.7" version = "0.6.8"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "langcodes" }, { name = "langcodes" },