feat: Add delete file functionality with confirmation screen
This commit is contained in:
BIN
dist/renamer-0.6.8-py3-none-any.whl
vendored
Normal file
BIN
dist/renamer-0.6.8-py3-none-any.whl
vendored
Normal file
Binary file not shown.
@@ -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"
|
||||||
|
|||||||
124
renamer/app.py
124
renamer/app.py
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user