Files
shared_libs/custom_file_dialog/custom_file_dialog.py

802 lines
34 KiB
Python

import os
import shutil
import tkinter as tk
import subprocess
import json
import threading
import webbrowser
from typing import Optional, List, Tuple, Dict, Union
import requests
from shared_libs.common_tools import IconManager, Tooltip, LxTools, message_box_animation
from shared_libs.gitea import GiteaUpdater, GiteaApiUrlError, GiteaVersionParseError, GiteaApiResponseError
from .cfd_app_config import CfdConfigManager, LocaleStrings
from .cfd_ui_setup import StyleManager, WidgetManager
from shared_libs.animated_icon import AnimatedIcon
from .cfd_settings_dialog import SettingsDialog
from .cfd_file_operations import FileOperationsManager
from .cfd_search_manager import SearchManager
from .cfd_navigation_manager import NavigationManager
from .cfd_view_manager import ViewManager
from .cfd_sftp_manager import SFTPManager, PARAMIKO_AVAILABLE
from shared_libs.message import CredentialsDialog, MessageDialog, InputDialog
class CustomFileDialog(tk.Toplevel):
"""
A custom file dialog window that provides functionalities for file selection,
directory navigation, search, and file operations.
"""
def __init__(self, parent: tk.Widget, initial_dir: Optional[str] = None,
filetypes: Optional[List[Tuple[str, str]]] = None,
mode: str = "open", title: str = LocaleStrings.CFD["title"]):
"""
Initializes the CustomFileDialog.
"""
super().__init__(parent)
self.current_fs_type = "local" # "local" or "sftp"
self.sftp_manager = SFTPManager()
self.config_manager = CfdConfigManager()
self.my_tool_tip: Optional[Tooltip] = None
self.dialog_mode: str = mode
self.gitea_api_url = CfdConfigManager.UPDATE_URL
self.lib_version = CfdConfigManager.VERSION
self.update_status: str = ""
self.load_settings()
self.geometry(self.settings["window_size_preset"])
min_width, min_height = self.get_min_size_from_preset(
self.settings["window_size_preset"])
self.minsize(min_width, min_height)
self.title(title)
self.image: IconManager = IconManager()
width, height = map(
int, self.settings["window_size_preset"].split('x'))
LxTools.center_window_cross_platform(self, width, height)
self.parent: tk.Widget = parent
self.transient(parent)
self.grab_set()
self.result: Optional[Union[str, List[str]]] = None
self.current_dir: str = os.path.abspath(
initial_dir) if initial_dir else os.path.expanduser("~")
self.filetypes: List[Tuple[str, str]] = filetypes if filetypes else [
(LocaleStrings.CFD["all_files"], "*.* ")]
self.current_filter_pattern: str = self.filetypes[0][1]
self.history: List[str] = []
self.history_pos: int = -1
self.view_mode: tk.StringVar = tk.StringVar(
value=self.settings.get("default_view_mode", "icons"))
self.show_hidden_files: tk.BooleanVar = tk.BooleanVar(value=False)
self.resize_job: Optional[str] = None
self.last_width: int = 0
self.selected_item_frames: List[ttk.Frame] = []
self.search_results: List[str] = []
self.search_mode: bool = False
self.original_path_text: str = ""
self.items_to_load_per_batch: int = 250
self.item_path_map: Dict[int, str] = {}
self.responsive_buttons_hidden: Optional[bool] = None
self.search_job: Optional[str] = None
self.search_thread: Optional[threading.Thread] = None
self.search_process: Optional[subprocess.Popen] = None
self.icon_manager: IconManager = IconManager()
self._initialize_managers()
self.widget_manager.filename_entry.bind(
"<Return>", self.search_manager.execute_search)
self.update_animation_settings()
self.view_manager._update_view_mode_buttons()
def initial_load() -> None:
"""Performs the initial loading and UI setup."""
self.update_idletasks()
self.last_width = self.widget_manager.file_list_frame.winfo_width()
self._handle_responsive_buttons(self.winfo_width())
self.navigation_manager.navigate_to(self.current_dir)
self.after(10, initial_load)
self.widget_manager.path_entry.bind(
"<Return>", self.navigation_manager.handle_path_entry_return)
self.widget_manager.home_button.config(command=self.go_to_local_home)
self.bind("<Key>", self.search_manager.show_search_bar)
if self.dialog_mode == "save":
self.bind("<Delete>", self.file_op_manager.delete_selected_item)
if self.gitea_api_url and self.lib_version:
self.update_thread = threading.Thread(
target=self.check_for_updates, daemon=True)
self.update_thread.start()
def _initialize_managers(self) -> None:
"""Initializes or re-initializes all the manager classes."""
self.style_manager: StyleManager = StyleManager(self)
self.file_op_manager: FileOperationsManager = FileOperationsManager(
self)
self.search_manager: SearchManager = SearchManager(self)
self.navigation_manager: NavigationManager = NavigationManager(self)
self.view_manager: ViewManager = ViewManager(self)
self.widget_manager: WidgetManager = WidgetManager(self, self.settings)
def load_settings(self) -> None:
"""Loads settings from the configuration file."""
self.settings = CfdConfigManager.load()
size_preset = self.settings.get("window_size_preset", "1050x850")
self.settings["window_size_preset"] = size_preset
if hasattr(self, 'view_mode'):
self.view_mode.set(self.settings.get("default_view_mode", "icons"))
def get_min_size_from_preset(self, preset: str) -> Tuple[int, int]:
"""
Calculates the minimum window size based on a preset string.
"""
w, h = map(int, preset.split('x'))
return max(650, w - 400), max(450, h - 400)
def reload_config_and_rebuild_ui(self) -> None:
"""Reloads the configuration and rebuilds the entire UI."""
is_sftp_connected = (self.current_fs_type == "sftp")
self.load_settings()
self.geometry(self.settings["window_size_preset"])
min_width, min_height = self.get_min_size_from_preset(
self.settings["window_size_preset"])
self.minsize(min_width, min_height)
width, height = map(
int, self.settings["window_size_preset"].split('x'))
LxTools.center_window_cross_platform(self, width, height)
for widget in self.winfo_children():
widget.destroy()
self._initialize_managers()
if is_sftp_connected:
self.widget_manager.sftp_button.config(
command=self.disconnect_sftp, style="Header.TButton.Active.Round")
self.widget_manager.filename_entry.bind(
"<Return>", self.search_manager.execute_search)
self.view_manager._update_view_mode_buttons()
self.responsive_buttons_hidden = None
self.update_idletasks()
self._handle_responsive_buttons(self.winfo_width())
self.update_animation_settings()
if self.search_mode:
self.search_manager.show_search_results_treeview()
else:
self.navigation_manager.navigate_to(self.current_dir)
def open_settings_dialog(self) -> None:
"""Opens the settings dialog."""
SettingsDialog(self, dialog_mode=self.dialog_mode)
def open_sftp_dialog(self):
if not PARAMIKO_AVAILABLE:
MessageDialog(message_type="error",
text="Paramiko library is not installed.").show()
return
dialog = CredentialsDialog(self)
credentials = dialog.show()
if credentials:
self.connect_sftp(credentials, is_new_connection=True)
def connect_sftp(self, credentials, is_new_connection: bool = False):
self.config(cursor="watch")
self.update_idletasks()
if is_new_connection and credentials.get("save_bookmark"):
bookmark_name = credentials["bookmark_name"]
bookmark_data = {
"host": credentials["host"],
"port": credentials["port"],
"username": credentials["username"],
"initial_path": credentials["initial_path"],
"key_file": credentials["key_file"],
}
try:
import keyring
service_name = f"customfiledialog-sftp"
if credentials["password"]:
keyring.set_password(service_name, f"{bookmark_name}_password", credentials["password"])
bookmark_data["password_in_keyring"] = True
if credentials["passphrase"]:
keyring.set_password(service_name, f"{bookmark_name}_passphrase", credentials["passphrase"])
bookmark_data["passphrase_in_keyring"] = True
self.config_manager.add_bookmark(bookmark_name, bookmark_data)
self.after(100, self.reload_config_and_rebuild_ui)
except Exception as e:
MessageDialog(message_type="error", text=f"Could not save bookmark: {e}").show()
success, message = self.sftp_manager.connect(
host=credentials.get('host'),
port=credentials.get('port'),
username=credentials.get('username'),
password=credentials.get('password'),
key_file=credentials.get('key_file'),
passphrase=credentials.get('passphrase')
)
self.config(cursor="")
if success:
self.current_fs_type = "sftp"
self.widget_manager.sftp_button.config(
command=self.disconnect_sftp, style="Header.TButton.Active.Round")
initial_path = credentials.get("initial_path", "/")
self.navigation_manager.navigate_to(initial_path)
else:
MessageDialog(message_type="error",
text=f"Connection failed: {message}").show()
def connect_sftp_bookmark(self, data):
credentials = data.copy()
try:
import keyring
service_name = f"customfiledialog-sftp"
bookmark_name = next(name for name, b_data in self.config_manager.load_bookmarks().items() if b_data == data)
if credentials.get("password_in_keyring"):
credentials["password"] = keyring.get_password(service_name, f"{bookmark_name}_password")
if credentials.get("passphrase_in_keyring"):
credentials["passphrase"] = keyring.get_password(service_name, f"{bookmark_name}_passphrase")
except (ImportError, StopIteration, Exception) as e:
MessageDialog(message_type="error", text=f"Could not retrieve credentials: {e}").show()
return
self.connect_sftp(credentials, is_new_connection=False)
def edit_sftp_bookmark(self, name: str, data: dict):
"""Opens the credentials dialog to edit an existing SFTP bookmark."""
data['bookmark_name'] = name
dialog = CredentialsDialog(self, title=f"Edit Bookmark: {name}", initial_data=data, is_edit_mode=True)
new_data = dialog.show()
if new_data:
self.remove_sftp_bookmark(name, confirm=False)
self.connect_sftp(new_data, is_new_connection=True)
def remove_sftp_bookmark(self, name: str, confirm: bool = True):
"""Removes an SFTP bookmark and its credentials from the keyring."""
do_remove = False
if confirm:
confirm_dialog = MessageDialog(
message_type="ask",
text=f"Remove bookmark '{name}'?",
buttons=["Yes", "No"])
if confirm_dialog.show():
do_remove = True
else:
do_remove = True
if do_remove:
try:
import keyring
service_name = f"customfiledialog-sftp"
try:
keyring.delete_password(service_name, f"{name}_password")
except keyring.errors.PasswordDeleteError:
pass
try:
keyring.delete_password(service_name, f"{name}_passphrase")
except keyring.errors.PasswordDeleteError:
pass
except ImportError:
pass
except Exception as e:
print(f"Could not remove credentials from keyring for {name}: {e}")
self.config_manager.remove_bookmark(name)
self.reload_config_and_rebuild_ui()
def disconnect_sftp(self, path_to_navigate_to: Optional[str] = None):
self.sftp_manager.disconnect()
self.current_fs_type = "local"
self.widget_manager.sftp_button.config(
command=self.open_sftp_dialog, style="Header.TButton.Borderless.Round")
target_path = path_to_navigate_to if path_to_navigate_to else os.path.expanduser("~")
self.navigation_manager.navigate_to(target_path)
def go_to_local_home(self):
if self.current_fs_type == "sftp":
self.disconnect_sftp()
else:
self.navigation_manager.navigate_to(os.path.expanduser("~"))
def handle_sidebar_bookmark_click(self, local_path: str):
if self.current_fs_type == "sftp":
self.disconnect_sftp(path_to_navigate_to=local_path)
else:
self.navigation_manager.navigate_to(local_path)
def update_animation_settings(self) -> None:
"""Updates the search animation icon based on current settings."""
use_pillow = self.settings.get('use_pillow_animation', False)
anim_type = self.settings.get('animation_type', 'double')
is_running = self.widget_manager.search_animation.running
if is_running:
self.widget_manager.search_animation.stop()
self.widget_manager.search_animation.destroy()
self.widget_manager.search_animation = AnimatedIcon(
self.widget_manager.status_container,
width=23,
height=23,
use_pillow=use_pillow,
animation_type=anim_type,
color="#2a6fde",
highlight_color="#5195ff",
bg=self.style_manager.bottom_color
)
self.widget_manager.search_animation.grid(
row=0, column=0, sticky='w', padx=(0, 5), pady=(4, 0))
self.widget_manager.search_animation.bind(
"<Button-1>", lambda e: self.search_manager.activate_search())
self.my_tool_tip = Tooltip(
self.widget_manager.search_animation,
text=lambda: LocaleStrings.UI["cancel_search"] if self.widget_manager.search_animation.running else LocaleStrings.UI["start_search"]
)
if is_running:
self.widget_manager.search_animation.start()
def check_for_updates(self) -> None:
"""Checks for library updates via the Gitea API in a background thread."""
try:
new_version = GiteaUpdater.check_for_update(
self.gitea_api_url,
self.lib_version,
)
self.after(0, self.update_ui_for_update, new_version)
except (requests.exceptions.RequestException, GiteaApiUrlError, GiteaVersionParseError, GiteaApiResponseError):
self.after(0, self.update_ui_for_update, "ERROR")
except Exception:
self.after(0, self.update_ui_for_update, "ERROR")
def _run_installer(self, event: Optional[tk.Event] = None) -> None:
"""Runs the LxTools installer if it exists."""
installer_path = '/usr/local/bin/lxtools_installer'
if os.path.exists(installer_path):
try:
subprocess.Popen([installer_path])
self.widget_manager.search_status_label.config(
text="Installer started...")
except OSError as e:
self.widget_manager.search_status_label.config(
text=f"Error starting installer: {e}")
else:
self.widget_manager.search_status_label.config(
text=f"Installer not found at {installer_path}")
def update_ui_for_update(self, new_version: Optional[str]) -> None:
"""
Updates the UI based on the result of the library update check.
"""
self.update_status = new_version
icon = self.widget_manager.update_animation_icon
icon.grid_remove()
icon.hide()
if new_version is None or new_version == "ERROR":
return
icon.grid(row=0, column=2, sticky='e', padx=(10, 5))
tooltip_msg = LocaleStrings.UI["install_new_version"].format(
version=new_version)
icon.start()
icon.bind("<Button-1>", self._run_installer)
Tooltip(icon, tooltip_msg)
def get_file_icon(self, filename: str, size: str = 'large') -> tk.PhotoImage:
"""
Gets the appropriate icon for a given filename.
"""
ext = os.path.splitext(filename)[1].lower()
if ext == '.py':
return self.icon_manager.get_icon(f'python_{size}')
if ext == '.pdf':
return self.icon_manager.get_icon(f'pdf_{size}')
if ext in ['.tar', '.zip', '.rar', '.7z', '.gz']:
return self.icon_manager.get_icon(f'archive_{size}')
if ext in ['.mp3', '.wav', '.ogg', '.flac']:
return self.icon_manager.get_icon(f'audio_{size}')
if ext in ['.mp4', '.mkv', '.avi', '.mov']:
return self.icon_manager.get_icon(f'video_{size}') if size == 'large' else self.icon_manager.get_icon(
'video_small_file')
if ext in ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.svg']:
return self.icon_manager.get_icon(f'picture_{size}')
if ext == '.iso':
return self.icon_manager.get_icon(f'iso_{size}')
return self.icon_manager.get_icon(f'file_{size}')
def on_window_resize(self, event: tk.Event) -> None:
"""
Handles the window resize event.
"""
if event.widget is self:
if self.view_mode.get() == "icons" and not self.search_mode:
new_width = self.widget_manager.file_list_frame.winfo_width()
if abs(new_width - self.last_width) > 50:
if self.resize_job:
self.after_cancel(self.resize_job)
def repopulate_icons() -> None:
"""Repopulates the file list icons."""
self.update_idletasks()
self.view_manager.populate_files()
self.resize_job = self.after(150, repopulate_icons)
self.last_width = new_width
self._handle_responsive_buttons(event.width)
def _handle_responsive_buttons(self, window_width: int) -> None:
"""
Shows or hides buttons based on the window width.
"""
threshold = 850
container = self.widget_manager.responsive_buttons_container
more_button = self.widget_manager.more_button
should_be_hidden = window_width < threshold
if should_be_hidden != self.responsive_buttons_hidden:
if should_be_hidden:
container.pack_forget()
more_button.pack(side="left", padx=5)
else:
more_button.pack_forget()
container.pack(side="left")
self.responsive_buttons_hidden = should_be_hidden
def show_more_menu(self) -> None:
"""Displays a 'more options' menu."""
more_menu = tk.Menu(self, tearoff=0, background=self.style_manager.header, foreground=self.style_manager.color_foreground,
activebackground=self.style_manager.selection_color, activeforeground=self.style_manager.color_foreground, relief='flat', borderwidth=0)
is_writable = os.access(self.current_dir, os.W_OK)
creation_state = tk.NORMAL if is_writable and self.dialog_mode != "open" else tk.DISABLED
more_menu.add_command(label=LocaleStrings.UI["new_folder"], command=self.file_op_manager.create_new_folder,
image=self.icon_manager.get_icon('new_folder_small'), compound='left', state=creation_state)
more_menu.add_command(label=LocaleStrings.UI["new_document"], command=self.file_op_manager.create_new_file,
image=self.icon_manager.get_icon('new_document_small'), compound='left', state=creation_state)
more_menu.add_separator()
more_menu.add_command(label=LocaleStrings.VIEW["icon_view"], command=self.view_manager.set_icon_view,
image=self.icon_manager.get_icon('icon_view'), compound='left')
more_menu.add_command(label=LocaleStrings.VIEW["list_view"], command=self.view_manager.set_list_view,
image=self.icon_manager.get_icon('list_view'), compound='left')
more_menu.add_separator()
hidden_files_label = LocaleStrings.UI["hide_hidden_files"] if self.show_hidden_files.get(
) else LocaleStrings.UI["show_hidden_files"]
hidden_files_icon = self.icon_manager.get_icon(
'unhide') if self.show_hidden_files.get() else self.icon_manager.get_icon('hide')
more_menu.add_command(label=hidden_files_label, command=self.view_manager.toggle_hidden_files,
image=hidden_files_icon, compound='left')
more_button = self.widget_manager.more_button
x = more_button.winfo_rootx()
y = more_button.winfo_rooty() + more_button.winfo_height()
more_menu.tk_popup(x, y)
def on_sidebar_resize(self, event: tk.Event) -> None:
"""
Handles the sidebar resize event, adjusting button text visibility.
"""
current_width = event.width
threshold_width = 100
if current_width < threshold_width:
for btn, original_text in self.widget_manager.sidebar_buttons:
btn.config(text="", compound="top")
for btn, original_text in self.widget_manager.device_buttons:
btn.config(text="", compound="top")
else:
for btn, original_text in self.widget_manager.sidebar_buttons:
btn.config(text=original_text, compound="left")
for btn, original_text in self.widget_manager.device_buttons:
btn.config(text=original_text, compound="left")
def _on_devices_enter(self, event: tk.Event) -> None:
"""
Shows the scrollbar when the mouse enters the devices area.
"""
self.widget_manager.devices_scrollbar.grid(
row=1, column=1, sticky="ns")
def _on_devices_leave(self, event: tk.Event) -> None:
"""
Hides the scrollbar when the mouse leaves the devices area.
"""
x, y = event.x_root, event.y_root
widget_x = self.widget_manager.devices_canvas.winfo_rootx()
widget_y = self.widget_manager.devices_canvas.winfo_rooty()
widget_width = self.widget_manager.devices_canvas.winfo_width()
widget_height = self.widget_manager.devices_canvas.winfo_height()
buffer = 5
if not (widget_x - buffer <= x <= widget_x + widget_width + buffer and
widget_y - buffer <= y <= widget_y + widget_height + buffer):
self.widget_manager.devices_scrollbar.grid_remove()
def toggle_recursive_search(self) -> None:
"""
Toggles the recursive search option on or off."""
self.widget_manager.recursive_search.set(
not self.widget_manager.recursive_search.get())
if self.widget_manager.recursive_search.get():
self.widget_manager.recursive_button.configure(
style="Header.TButton.Active.Round")
else:
self.widget_manager.recursive_button.configure(
style="Header.TButton.Borderless.Round")
def update_selection_info(self, status_info: Optional[str] = None) -> None:
"""
Updates status bar, filename entry, and result based on current selection.
"""
self._update_disk_usage()
status_text = ""
is_sftp = self.current_fs_type == 'sftp'
# Helper to get basename safely
def get_basename(path):
if not path:
return ""
if is_sftp:
return path.split('/')[-1]
return os.path.basename(path)
if self.dialog_mode == 'multi':
selected_paths = self.result if isinstance(
self.result, list) else []
self.widget_manager.filename_entry.delete(0, tk.END)
if selected_paths:
filenames = [
f'"{get_basename(p)}"' for p in selected_paths]
self.widget_manager.filename_entry.insert(
0, " ".join(filenames))
count = len(selected_paths)
status_text = f"{count} {LocaleStrings.CFD['items_selected']}"
else:
status_text = ""
else:
path_exists = False
if status_info:
if is_sftp:
path_exists = self.sftp_manager.exists(status_info)
else:
path_exists = os.path.exists(status_info)
if status_info and path_exists:
self.result = status_info
basename = get_basename(status_info)
self.widget_manager.filename_entry.delete(0, tk.END)
self.widget_manager.filename_entry.insert(0, basename)
if self.view_manager._is_dir(status_info):
content_count = self.view_manager._get_folder_content_count(status_info)
if content_count is not None:
status_text = f"'{basename}' ({content_count} {LocaleStrings.CFD['entries']})"
else:
status_text = f"'{basename}'"
else:
status_text = f"'{basename}'"
elif status_info:
status_text = status_info
self.widget_manager.search_status_label.config(text=status_text)
self.update_action_buttons_state()
def _update_disk_usage(self) -> None:
"""Updates only the disk usage part of the status bar."""
if self.current_fs_type == "sftp":
self.widget_manager.storage_label.config(text="SFTP Storage: N/A")
self.widget_manager.storage_bar['value'] = 0
return
try:
# This can fail on certain file types like symlinks to other filesystems.
total, used, free = shutil.disk_usage(self.current_dir)
free_str = self._format_size(free)
self.widget_manager.storage_label.config(
text=f"{LocaleStrings.CFD['free_space']}: {free_str}")
self.widget_manager.storage_bar['value'] = (used / total) * 100
except (FileNotFoundError, PermissionError):
# If disk usage cannot be determined, just show N/A instead of an error.
self.widget_manager.storage_label.config(
text=f"{LocaleStrings.CFD['free_space']}: N/A")
self.widget_manager.storage_bar['value'] = 0
def on_open(self) -> None:
"""Handles the 'Open' or 'OK' action based on the dialog mode."""
if self.dialog_mode == 'multi':
if self.result and isinstance(self.result, list) and self.result:
self.destroy()
return
selected_path = self.result
if not selected_path or not isinstance(selected_path, str):
return
if self.dialog_mode == 'dir':
if self.view_manager._is_dir(selected_path):
self.destroy()
elif self.dialog_mode == 'open':
if not self.view_manager._is_dir(selected_path):
self.destroy()
def on_save(self) -> None:
"""Handles the 'Save' action, setting the selected file and closing the dialog."""
file_name = self.widget_manager.filename_entry.get()
if file_name:
self.result = os.path.join(self.current_dir, file_name)
self.destroy()
def on_cancel(self) -> None:
"""Handles the 'Cancel' action, clearing the selection and closing the dialog."""
self.result = None
self.destroy()
def get_result(self) -> Optional[Union[str, List[str]]]:
"""Returns the result of the dialog."""
return self.result
def update_action_buttons_state(self) -> None:
"""Updates the state of action buttons based on current context."""
new_folder_state = tk.DISABLED
new_file_state = tk.DISABLED
trash_state = tk.DISABLED
is_writable = False
if self.dialog_mode != "open":
if self.current_fs_type == 'sftp':
is_writable = True
else:
is_writable = os.access(self.current_dir, os.W_OK)
if is_writable:
new_folder_state = tk.NORMAL
new_file_state = tk.NORMAL
if self.dialog_mode == "save":
trash_state = tk.NORMAL
if hasattr(self.widget_manager, 'new_folder_button'):
self.widget_manager.new_folder_button.config(
state=new_folder_state)
if hasattr(self.widget_manager, 'new_file_button'):
self.widget_manager.new_file_button.config(state=new_file_state)
if hasattr(self.widget_manager, 'trash_button'):
self.widget_manager.trash_button.config(state=trash_state)
def _matches_filetype(self, filename: str) -> bool:
"""
Checks if a filename matches the current filetype filter.
"""
if self.current_filter_pattern == "*.*":
return True
patterns = self.current_filter_pattern.lower().split()
fn_lower = filename.lower()
for p in patterns:
if p.startswith('*.'):
if fn_lower.endswith(p[1:]):
return True
elif p.startswith('.'):
if fn_lower.endswith(p):
return True
else:
if fn_lower == p:
return True
return False
def _format_size(self, size_bytes: Optional[int]) -> str:
"""
Formats a size in bytes into a human-readable string (KB, MB, GB).
"""
if size_bytes is None:
return ""
if size_bytes < 1024:
return f"{size_bytes} B"
if size_bytes < 1024**2:
return f"{size_bytes/1024:.1f} KB"
if size_bytes < 1024**3:
return f"{size_bytes/1024**2:.1f} MB"
return f"{size_bytes/1024**3:.1f} GB"
def shorten_text(self, text: str, max_len: int) -> str:
"""
Shortens a string to a maximum length, adding '...' if truncated.
"""
return text if len(text) <= max_len else text[:max_len-3] + "..."
def _get_mounted_devices(self) -> List[Tuple[str, str, bool]]:
"""
Retrieves a list of mounted devices on the system.
"""
devices: List[Tuple[str, str, bool]] = []
root_disk_name: Optional[str] = None
try:
result = subprocess.run(['lsblk', '-J', '-o', 'NAME,MOUNTPOINT,FSTYPE,SIZE,RO,RM,TYPE,LABEL,PKNAME'],
capture_output=True, text=True, check=True)
data = json.loads(result.stdout)
for block_device in data.get('blockdevices', []):
if 'children' in block_device:
for child_device in block_device['children']:
if child_device.get('mountpoint') == '/':
root_disk_name = block_device.get('name')
break
if root_disk_name:
break
for block_device in data.get('blockdevices', []):
if (
block_device.get('mountpoint') and
block_device.get('type') not in ['loop', 'rom'] and
block_device.get('mountpoint') != '/'):
if block_device.get('name').startswith(root_disk_name) and not block_device.get('rm', False):
pass
else:
name = block_device.get('name')
mountpoint = block_device.get('mountpoint')
label = block_device.get('label')
removable = block_device.get('rm', False)
display_name = label if label else name
devices.append((display_name, mountpoint, removable))
if 'children' in block_device:
for child_device in block_device['children']:
if (
child_device.get('mountpoint') and
child_device.get('type') not in ['loop', 'rom'] and
child_device.get('mountpoint') != '/'):
if block_device.get('name') == root_disk_name and not child_device.get('rm', False):
pass
else:
name = child_device.get('name')
mountpoint = child_device.get('mountpoint')
label = child_device.get('label')
removable = child_device.get('rm', False)
display_name = label if label else name
devices.append(
(display_name, mountpoint, removable))
except Exception as e:
print(f"Error getting mounted devices: {e}")
return devices