343 lines
13 KiB
Python
343 lines
13 KiB
Python
import os
|
|
import shutil
|
|
import tkinter as tk
|
|
from tkinter import ttk
|
|
from typing import Optional, Any, TYPE_CHECKING
|
|
|
|
try:
|
|
import send2trash
|
|
SEND2TRASH_AVAILABLE = True
|
|
except ImportError:
|
|
SEND2TRASH_AVAILABLE = False
|
|
|
|
from shared_libs.message import MessageDialog
|
|
from .cfd_app_config import LocaleStrings
|
|
|
|
|
|
if TYPE_CHECKING:
|
|
from custom_file_dialog import CustomFileDialog
|
|
|
|
|
|
class FileOperationsManager:
|
|
"""Manages file operations like delete, create, and rename."""
|
|
|
|
def __init__(self, dialog: 'CustomFileDialog') -> None:
|
|
"""
|
|
Initializes the FileOperationsManager.
|
|
|
|
Args:
|
|
dialog: The main CustomFileDialog instance.
|
|
"""
|
|
self.dialog = dialog
|
|
|
|
def delete_selected_item(self, event: Optional[tk.Event] = None) -> None:
|
|
"""
|
|
Deletes the selected item or moves it to the trash.
|
|
|
|
This method checks user settings to determine whether to move the item
|
|
to the system's trash (if available) or delete it permanently.
|
|
It also handles the confirmation dialog based on user preferences.
|
|
|
|
Args:
|
|
event: The event that triggered the deletion (optional).
|
|
"""
|
|
if not self.dialog.selected_file or not os.path.exists(self.dialog.selected_file):
|
|
return
|
|
|
|
use_trash = self.dialog.settings.get(
|
|
"use_trash", False) and SEND2TRASH_AVAILABLE
|
|
confirm = self.dialog.settings.get("confirm_delete", False)
|
|
|
|
action_text = LocaleStrings.FILE["move_to_trash"] if use_trash else LocaleStrings.FILE["delete_permanently"]
|
|
item_name = os.path.basename(self.dialog.selected_file)
|
|
|
|
if not confirm:
|
|
dialog = MessageDialog(
|
|
master=self.dialog,
|
|
title=LocaleStrings.FILE["confirm_delete_title"],
|
|
text=f"{LocaleStrings.FILE['are_you_sure']} '{item_name}' {action_text}?",
|
|
message_type="question"
|
|
)
|
|
if not dialog.show():
|
|
return
|
|
|
|
try:
|
|
if use_trash:
|
|
send2trash.send2trash(self.dialog.selected_file)
|
|
else:
|
|
if os.path.isdir(self.dialog.selected_file):
|
|
shutil.rmtree(self.dialog.selected_file)
|
|
else:
|
|
os.remove(self.dialog.selected_file)
|
|
|
|
self.dialog.view_manager.populate_files()
|
|
self.dialog.widget_manager.search_status_label.config(
|
|
text=f"'{item_name}' {LocaleStrings.FILE['was_successfully_removed']}")
|
|
|
|
except Exception as e:
|
|
MessageDialog(
|
|
master=self.dialog,
|
|
title=LocaleStrings.FILE["error_title"],
|
|
text=f"{LocaleStrings.FILE['error_removing']} '{item_name}':\n{e}",
|
|
message_type="error"
|
|
).show()
|
|
|
|
def create_new_folder(self) -> None:
|
|
"""Creates a new folder in the current directory."""
|
|
self._create_new_item(is_folder=True)
|
|
|
|
def create_new_file(self) -> None:
|
|
"""Creates a new empty file in the current directory."""
|
|
self._create_new_item(is_folder=False)
|
|
|
|
def _create_new_item(self, is_folder: bool) -> None:
|
|
"""
|
|
Internal helper to create a new file or folder.
|
|
|
|
It generates a unique name and creates the item, then refreshes the view.
|
|
|
|
Args:
|
|
is_folder (bool): True to create a folder, False to create a file.
|
|
"""
|
|
base_name = LocaleStrings.FILE["new_folder_title"] if is_folder else LocaleStrings.FILE["new_document_txt"]
|
|
new_name = self._get_unique_name(base_name)
|
|
new_path = os.path.join(self.dialog.current_dir, new_name)
|
|
|
|
try:
|
|
if is_folder:
|
|
os.mkdir(new_path)
|
|
else:
|
|
open(new_path, 'a').close()
|
|
self.dialog.view_manager.populate_files(item_to_rename=new_name)
|
|
except Exception as e:
|
|
self.dialog.widget_manager.search_status_label.config(
|
|
text=f"{LocaleStrings.FILE['error_creating']}: {e}")
|
|
|
|
def _get_unique_name(self, base_name: str) -> str:
|
|
"""
|
|
Generates a unique name for a file or folder.
|
|
|
|
If a file or folder with `base_name` already exists, it appends
|
|
a counter (e.g., "New Folder 2") until a unique name is found.
|
|
|
|
Args:
|
|
base_name (str): The initial name for the item.
|
|
|
|
Returns:
|
|
str: A unique name for the item in the current directory.
|
|
"""
|
|
name, ext = os.path.splitext(base_name)
|
|
counter = 1
|
|
new_name = base_name
|
|
while os.path.exists(os.path.join(self.dialog.current_dir, new_name)):
|
|
counter += 1
|
|
new_name = f"{name} {counter}{ext}"
|
|
return new_name
|
|
|
|
def _copy_to_clipboard(self, data: str) -> None:
|
|
"""
|
|
Copies the given data to the system clipboard.
|
|
|
|
Args:
|
|
data (str): The text to be copied.
|
|
"""
|
|
self.dialog.clipboard_clear()
|
|
self.dialog.clipboard_append(data)
|
|
self.dialog.widget_manager.search_status_label.config(
|
|
text=f"'{self.dialog.shorten_text(data, 50)}' {LocaleStrings.FILE['copied_to_clipboard']}")
|
|
|
|
def _show_context_menu(self, event: tk.Event, item_path: str) -> str:
|
|
"""
|
|
Displays a context menu for the selected item.
|
|
|
|
Args:
|
|
event: The mouse event that triggered the menu.
|
|
item_path (str): The full path to the item.
|
|
|
|
Returns:
|
|
str: "break" to prevent further event propagation.
|
|
"""
|
|
if not item_path:
|
|
return "break"
|
|
|
|
if hasattr(self.dialog, 'context_menu') and self.dialog.context_menu.winfo_exists():
|
|
self.dialog.context_menu.destroy()
|
|
|
|
self.dialog.context_menu = tk.Menu(self.dialog, tearoff=0, background=self.dialog.style_manager.header, foreground=self.dialog.style_manager.color_foreground,
|
|
activebackground=self.dialog.style_manager.selection_color, activeforeground=self.dialog.style_manager.color_foreground, relief='flat', borderwidth=0)
|
|
|
|
self.dialog.context_menu.add_command(label=LocaleStrings.UI["copy_filename_to_clipboard"],
|
|
command=lambda: self._copy_to_clipboard(os.path.basename(item_path)),
|
|
image=self.dialog.icon_manager.get_icon('copy'), compound='left')
|
|
self.dialog.context_menu.add_command(
|
|
label=LocaleStrings.UI["copy_path_to_clipboard"], command=lambda: self._copy_to_clipboard(item_path),
|
|
image=self.dialog.icon_manager.get_icon('copy'), compound='left')
|
|
|
|
self.dialog.context_menu.add_separator()
|
|
self.dialog.context_menu.add_command(
|
|
label=LocaleStrings.UI["open_file_location"], command=lambda: self._open_file_location_from_context(item_path),
|
|
image=self.dialog.icon_manager.get_icon('stair'), compound='left')
|
|
|
|
self.dialog.context_menu.tk_popup(event.x_root, event.y_root)
|
|
return "break"
|
|
|
|
def _open_file_location_from_context(self, file_path: str) -> None:
|
|
"""
|
|
Navigates to the location of the given file path.
|
|
|
|
This is used by the context menu to jump to a file's directory,
|
|
which is especially useful when in search mode.
|
|
|
|
Args:
|
|
file_path (str): The full path to the file.
|
|
"""
|
|
directory = os.path.dirname(file_path)
|
|
filename = os.path.basename(file_path)
|
|
|
|
if self.dialog.search_mode:
|
|
self.dialog.search_manager.hide_search_bar()
|
|
|
|
self.dialog.navigation_manager.navigate_to(directory)
|
|
self.dialog.after(
|
|
100, lambda: self.dialog.view_manager._select_file_in_view(filename))
|
|
|
|
def on_rename_request(self, event: tk.Event, item_path: Optional[str] = None, item_frame: Optional[tk.Widget] = None) -> None:
|
|
"""
|
|
Handles the initial request to rename an item.
|
|
|
|
This method is triggered by an event (e.g., F2 key press) and
|
|
initiates the renaming process based on the current view mode.
|
|
|
|
Args:
|
|
event: The event that triggered the rename.
|
|
item_path (str, optional): The path of the item in icon view.
|
|
item_frame (tk.Widget, optional): The frame of the item in icon view.
|
|
"""
|
|
if self.dialog.view_mode.get() == "list":
|
|
if not self.dialog.tree.selection():
|
|
return
|
|
item_id = self.dialog.tree.selection()[0]
|
|
self.start_rename(item_id)
|
|
else: # icon view
|
|
if item_path and item_frame:
|
|
self.start_rename(item_frame, item_path)
|
|
|
|
def start_rename(self, item_widget: Any, item_path: Optional[str] = None) -> None:
|
|
"""
|
|
Starts the renaming UI for an item.
|
|
|
|
Dispatches to the appropriate method based on the current view mode.
|
|
|
|
Args:
|
|
item_widget: The widget representing the item (item_id for list view,
|
|
item_frame for icon view).
|
|
item_path (str, optional): The full path to the item being renamed.
|
|
Required for icon view.
|
|
"""
|
|
if self.dialog.view_mode.get() == "icons":
|
|
if item_path:
|
|
self._start_rename_icon_view(item_widget, item_path)
|
|
else: # list view
|
|
self._start_rename_list_view(item_widget) # item_widget is item_id
|
|
|
|
def _start_rename_icon_view(self, item_frame: ttk.Frame, item_path: str) -> None:
|
|
"""
|
|
Initiates the in-place rename UI for an item in icon view.
|
|
|
|
It replaces the item's label with an Entry widget.
|
|
|
|
Args:
|
|
item_frame (tk.Widget): The frame containing the item's icon and label.
|
|
item_path (str): The full path to the item.
|
|
"""
|
|
for child in item_frame.winfo_children():
|
|
child.destroy()
|
|
|
|
entry = ttk.Entry(item_frame)
|
|
entry.pack(fill="both", expand=True, padx=2, pady=20)
|
|
entry.insert(0, os.path.basename(item_path))
|
|
entry.select_range(0, tk.END)
|
|
entry.focus_set()
|
|
|
|
def finish_rename(event: tk.Event) -> None:
|
|
new_name = entry.get()
|
|
self._finish_rename_logic(item_path, new_name)
|
|
|
|
def cancel_rename(event: tk.Event) -> None:
|
|
self.dialog.view_manager.populate_files()
|
|
|
|
entry.bind("<Return>", finish_rename)
|
|
entry.bind("<FocusOut>", finish_rename)
|
|
entry.bind("<Escape>", cancel_rename)
|
|
|
|
def _start_rename_list_view(self, item_id: str) -> None:
|
|
"""
|
|
Initiates the in-place rename UI for an item in list view.
|
|
|
|
It places an Entry widget over the Treeview item's cell.
|
|
|
|
Args:
|
|
item_id: The ID of the treeview item to be renamed.
|
|
"""
|
|
self.dialog.tree.see(item_id)
|
|
self.dialog.tree.update_idletasks()
|
|
|
|
bbox = self.dialog.tree.bbox(item_id, column="#0")
|
|
|
|
if not bbox:
|
|
return
|
|
|
|
x, y, width, height = bbox
|
|
entry = ttk.Entry(self.dialog.tree)
|
|
entry_width = self.dialog.tree.column("#0", "width")
|
|
entry.place(x=x, y=y, width=entry_width, height=height)
|
|
|
|
item_text = self.dialog.tree.item(item_id, "text").strip()
|
|
entry.insert(0, item_text)
|
|
entry.select_range(0, tk.END)
|
|
entry.focus_set()
|
|
|
|
old_path = os.path.join(self.dialog.current_dir, item_text)
|
|
|
|
def finish_rename(event: tk.Event) -> None:
|
|
new_name = entry.get()
|
|
entry.destroy()
|
|
self._finish_rename_logic(old_path, new_name)
|
|
|
|
def cancel_rename(event: tk.Event) -> None:
|
|
entry.destroy()
|
|
|
|
entry.bind("<Return>", finish_rename)
|
|
entry.bind("<FocusOut>", finish_rename)
|
|
entry.bind("<Escape>", cancel_rename)
|
|
|
|
def _finish_rename_logic(self, old_path: str, new_name: str) -> None:
|
|
"""
|
|
Handles the core logic of renaming a file or folder after the user
|
|
submits the new name from an Entry widget.
|
|
|
|
Args:
|
|
old_path (str): The original full path of the item.
|
|
new_name (str): The new name for the item.
|
|
"""
|
|
new_path = os.path.join(self.dialog.current_dir, new_name)
|
|
old_name = os.path.basename(old_path)
|
|
|
|
if not new_name or new_path == old_path:
|
|
self.dialog.view_manager.populate_files(item_to_select=old_name)
|
|
return
|
|
|
|
if os.path.exists(new_path):
|
|
self.dialog.widget_manager.search_status_label.config(
|
|
text=f"'{new_name}' {LocaleStrings.FILE['folder_exists_error']}")
|
|
self.dialog.view_manager.populate_files(item_to_select=old_name)
|
|
return
|
|
|
|
try:
|
|
os.rename(old_path, new_path)
|
|
self.dialog.view_manager.populate_files(item_to_select=new_name)
|
|
except Exception as e:
|
|
self.dialog.widget_manager.search_status_label.config(
|
|
text=f"{LocaleStrings.FILE['error_renaming']}: {e}")
|
|
self.dialog.view_manager.populate_files()
|