11 Commits

Author SHA1 Message Date
48034626f1 sftp works with keyring, bookmark and edit bookmark 2025-08-16 01:06:34 +02:00
cc48f874ac add trash icon to contexmenu remove bookmark 2025-08-15 08:49:46 +02:00
27f74e6a77 add update installer on custom_file_dialog 2025-08-14 13:14:02 +02:00
ba38ea4b87 - Added window on custom_file_dialog to query if there is
no other folder in the selected folder. So that the folder
   can still be entered

 - Fixes multi and dir mode in custom_file_dialog

 - Add "select" in MessageDialog on list for Button and add grab_set()
   after update_idletasks() to fix Error Traceback
2025-08-14 12:59:07 +02:00
ff1aede356 fix multiselect on icon view in mode="multi" 2025-08-14 00:44:02 +02:00
f565132074 feat(cfd): Implementiert Multi-Auswahl in der Icon-Ansicht 2025-08-14 00:39:35 +02:00
d548b545e3 add update url in cfd_app_config 2025-08-14 00:27:42 +02:00
66202310ec fix(cfd): Behebt UnboundLocalError beim UI-Aufbau 2025-08-14 00:11:58 +02:00
d79e4c9e01 refactor(cfd): Entkoppelt die Update-Konfiguration und bereinigt Imports 2025-08-14 00:06:34 +02:00
0ef94de077 feat(cfd): Integriert Gitea-Update-Prüfung für die Bibliothek 2025-08-13 23:48:37 +02:00
fbc3c8e051 feat(cfd): Implementiert 'dir' Modus und vervollständigt 'multi' Modus 2025-08-13 23:45:17 +02:00
12 changed files with 1500 additions and 585 deletions

View File

@@ -4,6 +4,27 @@ Changelog for shared_libs
-
### Added
14.08.2025
- Added window on custom_file_dialog to query if there is
no other folder in the selected folder. So that the folder
can still be entered
- Fixes multi and dir mode in custom_file_dialog
- Add "select" in MessageDialog on list for Button and add grab_set()
after update_idletasks() to fix Error Traceback
### Added
13.08.2025
- Rename get methode and mode argument in custom_file_dialog
- Add new mode "multi" and "dir" on custom_file_dialog
### Added
12.08.2025

View File

@@ -471,10 +471,17 @@ class AnimatedIcon(tk.Canvas):
return
# Do not animate if a grab is active on a different window.
toplevel = self.winfo_toplevel()
grab_widget = toplevel.grab_current()
if grab_widget is not None and grab_widget != toplevel:
self.after(100, self._animate) # Check again after a short delay
try:
toplevel = self.winfo_toplevel()
grab_widget = toplevel.grab_current()
if grab_widget is not None and grab_widget != toplevel:
self.after(100, self._animate) # Check again after a short delay
return
except Exception:
# This can happen if a grabbed widget (like a combobox dropdown)
# is destroyed at the exact moment this check runs.
# It's safest to just skip this animation frame.
self.after(30, self._animate)
return
self.angle += 0.1

View File

@@ -635,6 +635,8 @@ class IconManager:
'up': '32/arrow-up.png',
'copy': '32/copy.png',
'stair': '32/stair.png',
'star': '32/star.png',
'connect': '32/connect.png',
'audio_small': '32/audio.png',
'icon_view': '32/carrel.png',
'computer_small': '32/computer.png',
@@ -695,6 +697,8 @@ class IconManager:
'up_large': '48/arrow-up.png',
'copy_large': '48/copy.png',
'stair_large': '48/stair.png',
'star_large': '48/star.png',
'connect_large': '48/connect.png',
'icon_view_large': '48/carrel.png',
'computer_large': '48/computer.png',
'device_large': '48/device.png',
@@ -743,7 +747,9 @@ class IconManager:
'forward_extralarge': '64/arrow-right.png',
'up_extralarge': '64/arrow-up.png',
'copy_extralarge': '64/copy.png',
'stairextralarge': '64/stair.png',
'stair_extralarge': '64/stair.png',
'star_extralarge': '64/star.png',
'connect_extralarge': '64/connect.png',
'audio_large': '64/audio.png',
'icon_view_extralarge': '64/carrel.png',
'computer_extralarge': '64/computer.png',

View File

@@ -16,7 +16,8 @@ class CfdConfigManager:
Manages CFD-specific settings using a JSON file for flexibility.
"""
# 1 = 1. Year, 09 = Month of the Year, 2924 = Day and Year of the Year
VERSION: str = "v. 1.08.1325"
UPDATE_URL: str = "https://git.ilunix.de/api/v1/repos/punix/shared_libs/releases"
VERSION: str = "v. 1.07.0125"
MAX_ITEMS_TO_DISPLAY = 1000
@@ -35,6 +36,7 @@ class CfdConfigManager:
_config: Optional[Dict[str, Any]] = None
_config_file: Path = CONFIG_DIR / "cfd_settings.json"
_bookmarks_file: Path = CONFIG_DIR / "cfd_bookmarks.json"
_default_settings: Dict[str, Any] = {
"search_icon_pos": "left", # 'left' or 'right'
"button_box_pos": "left", # 'left' or 'right'
@@ -44,7 +46,8 @@ class CfdConfigManager:
"use_trash": False, # True or False
"confirm_delete": False, # True or False
"recursive_search": True,
"use_pillow_animation": True
"use_pillow_animation": True,
"keep_bookmarks_on_reset": True # Keep bookmarks when resetting settings
}
@classmethod
@@ -83,6 +86,51 @@ class CfdConfigManager:
except IOError as e:
print(f"Error saving settings: {e}")
@classmethod
def _ensure_bookmarks_file(cls: Type['CfdConfigManager']) -> None:
"""Ensures the bookmarks file exists."""
if not cls._bookmarks_file.exists():
try:
cls._bookmarks_file.parent.mkdir(parents=True, exist_ok=True)
with open(cls._bookmarks_file, 'w', encoding='utf-8') as f:
json.dump({}, f, indent=4)
except IOError as e:
print(f"Error creating bookmarks file: {e}")
@classmethod
def load_bookmarks(cls: Type['CfdConfigManager']) -> Dict[str, Any]:
"""Loads bookmarks from the JSON file."""
cls._ensure_bookmarks_file()
try:
with open(cls._bookmarks_file, 'r', encoding='utf-8') as f:
return json.load(f)
except (IOError, json.JSONDecodeError):
return {}
@classmethod
def save_bookmarks(cls: Type['CfdConfigManager'], bookmarks: Dict[str, Any]) -> None:
"""Saves the given bookmarks dictionary to the JSON file."""
try:
with open(cls._bookmarks_file, 'w', encoding='utf-8') as f:
json.dump(bookmarks, f, indent=4)
except IOError as e:
print(f"Error saving bookmarks: {e}")
@classmethod
def add_bookmark(cls: Type['CfdConfigManager'], name: str, data: Dict[str, Any]) -> None:
"""Adds or updates a bookmark."""
bookmarks = cls.load_bookmarks()
bookmarks[name] = data
cls.save_bookmarks(bookmarks)
@classmethod
def remove_bookmark(cls: Type['CfdConfigManager'], name: str) -> None:
"""Removes a bookmark by name."""
bookmarks = cls.load_bookmarks()
if name in bookmarks:
del bookmarks[name]
cls.save_bookmarks(bookmarks)
class LocaleStrings:
"""
@@ -115,6 +163,12 @@ class LocaleStrings:
"not_found": _("not found."),
"access_to": _("Access to"),
"denied": _("denied."),
"items_selected": _("items selected"),
"select_or_enter_title": _("Select or Enter?"),
"select_or_enter_prompt": _("The folder '{folder_name}' contains no subdirectories. Do you want to select this folder or enter it?"),
"select_button": _("Select"),
"enter_button": _("Enter"),
"cancel_button": _("Cancel"),
}
# Strings from cfd_view_manager.py
@@ -160,6 +214,10 @@ class LocaleStrings:
"no_results_for": _("No results for"),
"error_during_search": _("Error during search"),
"search_error": _("Search Error"),
"install_new_version": _("Install new version {version}"),
"sftp_connection": _("SFTP Connection"),
"sftp_bookmarks": _("SFTP Bookmarks"),
"remove_bookmark": _("Remove Bookmark"),
}
# Strings from cfd_settings_dialog.py
@@ -194,6 +252,11 @@ class LocaleStrings:
"blink": _("Blink"),
"deletion_options_info": _("Deletion options are only available in save mode"),
"reset_to_default": _("Reset to Default"),
"sftp_settings": _("SFTP Settings"),
"paramiko_not_found": _("Paramiko library not found."),
"sftp_disabled": _("SFTP functionality is disabled. Please install 'paramiko'."),
"paramiko_found": _("Paramiko library found. SFTP is enabled."),
"keep_sftp_bookmarks": _("Keep SFTP bookmarks on reset"),
}
# Strings from cfd_file_operations.py

View File

@@ -41,7 +41,45 @@ class FileOperationsManager:
Args:
event: The event that triggered the deletion (optional).
"""
if not self.dialog.selected_file or not os.path.exists(self.dialog.selected_file):
if not self.dialog.result or not isinstance(self.dialog.result, str):
return
selected_path = self.dialog.result
if self.dialog.current_fs_type == 'sftp':
item_name = os.path.basename(selected_path)
dialog = MessageDialog(
master=self.dialog,
title=LocaleStrings.FILE["confirm_delete_title"],
text=f"{LocaleStrings.FILE['are_you_sure']} '{item_name}' {LocaleStrings.FILE['delete_permanently']}?",
message_type="question"
)
if not dialog.show():
return
try:
if self.dialog.sftp_manager.path_is_dir(selected_path):
success, msg = self.dialog.sftp_manager.rm_recursive(selected_path)
else:
success, msg = self.dialog.sftp_manager.rm(selected_path)
if not success:
raise OSError(msg)
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()
return
# Local deletion logic
if not os.path.exists(selected_path):
return
use_trash = self.dialog.settings.get(
@@ -49,7 +87,7 @@ class FileOperationsManager:
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)
item_name = os.path.basename(selected_path)
if not confirm:
dialog = MessageDialog(
@@ -63,12 +101,12 @@ class FileOperationsManager:
try:
if use_trash:
send2trash.send2trash(self.dialog.selected_file)
send2trash.send2trash(selected_path)
else:
if os.path.isdir(self.dialog.selected_file):
shutil.rmtree(self.dialog.selected_file)
if os.path.isdir(selected_path):
shutil.rmtree(selected_path)
else:
os.remove(self.dialog.selected_file)
os.remove(selected_path)
self.dialog.view_manager.populate_files()
self.dialog.widget_manager.search_status_label.config(
@@ -104,10 +142,20 @@ class FileOperationsManager:
new_path = os.path.join(self.dialog.current_dir, new_name)
try:
if is_folder:
os.mkdir(new_path)
if self.dialog.current_fs_type == 'sftp':
if is_folder:
success, msg = self.dialog.sftp_manager.mkdir(new_path)
if not success:
raise OSError(msg)
else:
success, msg = self.dialog.sftp_manager.touch(new_path)
if not success:
raise OSError(msg)
else:
open(new_path, 'a').close()
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(
@@ -129,7 +177,10 @@ class FileOperationsManager:
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)):
path_exists = self.dialog.sftp_manager.exists if self.dialog.current_fs_type == 'sftp' else os.path.exists
while path_exists(os.path.join(self.dialog.current_dir, new_name)):
counter += 1
new_name = f"{name} {counter}{ext}"
return new_name
@@ -327,16 +378,32 @@ class FileOperationsManager:
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
if self.dialog.current_fs_type == 'sftp':
if self.dialog.sftp_manager.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:
success, msg = self.dialog.sftp_manager.rename(old_path, new_path)
if not success:
raise OSError(msg)
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()
else:
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()
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()

View File

@@ -23,50 +23,63 @@ class NavigationManager:
def handle_path_entry_return(self, event: tk.Event) -> None:
"""
Handles the Return key press in the path entry field.
It attempts to navigate to the entered path. If the path is a file,
it navigates to the containing directory and selects the file.
Args:
event: The tkinter event that triggered this handler.
"""
path_text = self.dialog.widget_manager.path_entry.get().strip()
potential_path = os.path.realpath(os.path.expanduser(path_text))
is_sftp = self.dialog.current_fs_type == "sftp"
if os.path.isdir(potential_path):
self.navigate_to(potential_path)
elif os.path.isfile(potential_path):
directory = os.path.dirname(potential_path)
filename = os.path.basename(potential_path)
self.navigate_to(directory, file_to_select=filename)
if is_sftp:
self.navigate_to(path_text)
else:
self.dialog.widget_manager.search_status_label.config(
text=f"{LocaleStrings.CFD['path_not_found']}: {self.dialog.shorten_text(path_text, 50)}")
potential_path = os.path.realpath(os.path.expanduser(path_text))
if os.path.isdir(potential_path):
self.navigate_to(potential_path)
elif os.path.isfile(potential_path):
directory = os.path.dirname(potential_path)
filename = os.path.basename(potential_path)
self.navigate_to(directory, file_to_select=filename)
else:
self.dialog.widget_manager.search_status_label.config(
text=f"{LocaleStrings.CFD['path_not_found']}: {self.dialog.shorten_text(path_text, 50)}")
def navigate_to(self, path: str, file_to_select: Optional[str] = None) -> None:
"""
Navigates to a specified directory path.
This is the core navigation method. It validates the path, checks for
read permissions, updates the dialog's current directory, manages the
navigation history, and refreshes the file view.
Args:
path (str): The absolute path to navigate to.
file_to_select (str, optional): If provided, this filename will be
selected after navigation. Defaults to None.
Navigates to a specified directory path, supporting both local and SFTP filesystems.
"""
try:
real_path = os.path.realpath(
os.path.abspath(os.path.expanduser(path)))
if not os.path.isdir(real_path):
self.dialog.widget_manager.search_status_label.config(
text=f"{LocaleStrings.CFD['error_title']}: {LocaleStrings.CFD['directory']} '{os.path.basename(path)}' {LocaleStrings.CFD['not_found']}")
return
if not os.access(real_path, os.R_OK):
self.dialog.widget_manager.search_status_label.config(
text=f"{LocaleStrings.CFD['access_to']} '{os.path.basename(path)}' {LocaleStrings.CFD['denied']}")
return
is_sftp = self.dialog.current_fs_type == "sftp"
if is_sftp:
# Resolve tilde to the remote home directory for SFTP
if path == '~' or path.startswith('~/'):
home_dir = self.dialog.sftp_manager.home_dir
if home_dir:
# Manual path joining with forward slashes
if path.startswith('~/'):
# home_dir might be '/', so avoid '//'
path = home_dir.rstrip('/') + '/' + path[2:]
else:
path = home_dir
else: # Fallback if home_dir is not set
path = '/'
# The SFTP manager will handle path validation.
if not self.dialog.sftp_manager.path_is_dir(path):
self.dialog.widget_manager.search_status_label.config(
text=f"Error: Directory '{os.path.basename(path)}' not found on SFTP server.")
return
real_path = path
else:
# Local filesystem logic
real_path = os.path.realpath(os.path.abspath(os.path.expanduser(path)))
if not os.path.isdir(real_path):
self.dialog.widget_manager.search_status_label.config(
text=f"{LocaleStrings.CFD['error_title']}: {LocaleStrings.CFD['directory']} '{os.path.basename(path)}' {LocaleStrings.CFD['not_found']}")
return
if not os.access(real_path, os.R_OK):
self.dialog.widget_manager.search_status_label.config(
text=f"{LocaleStrings.CFD['access_to']} '{os.path.basename(path)}' {LocaleStrings.CFD['denied']}")
return
self.dialog.current_dir = real_path
if self.dialog.history_pos < len(self.dialog.history) - 1:
self.dialog.history = self.dialog.history[:self.dialog.history_pos + 1]
@@ -75,15 +88,17 @@ class NavigationManager:
self.dialog.history_pos = len(self.dialog.history) - 1
self.dialog.widget_manager.search_animation.stop()
self.dialog.selected_item_frames.clear()
self.dialog.result = None
self.dialog.view_manager.populate_files(
item_to_select=file_to_select)
self.dialog.view_manager.populate_files(item_to_select=file_to_select)
self.update_nav_buttons()
self.dialog.update_status_bar()
self.dialog.update_selection_info()
self.dialog.update_action_buttons_state()
except Exception as e:
self.dialog.widget_manager.search_status_label.config(
text=f"{LocaleStrings.CFD['error_title']}: {e}")
error_message = f"Error navigating to '{path}': {e}"
self.dialog.widget_manager.search_status_label.config(text=error_message)
def go_back(self) -> None:
"""Navigates to the previous directory in the history."""
@@ -101,15 +116,22 @@ class NavigationManager:
def go_up_level(self) -> None:
"""Navigates to the parent directory of the current directory."""
new_path = os.path.dirname(self.dialog.current_dir)
if new_path != self.dialog.current_dir:
self.navigate_to(new_path)
if self.dialog.current_fs_type == "sftp":
if self.dialog.current_dir and self.dialog.current_dir != "/":
new_path = self.dialog.current_dir.rsplit('/', 1)[0]
if not new_path:
new_path = "/"
self.navigate_to(new_path)
else:
new_path = os.path.dirname(self.dialog.current_dir)
if new_path != self.dialog.current_dir:
self.navigate_to(new_path)
def _update_ui_after_navigation(self) -> None:
"""Updates all necessary UI components after a navigation action."""
self.dialog.view_manager.populate_files()
self.update_nav_buttons()
self.dialog.update_status_bar()
self.dialog.update_selection_info()
self.dialog.update_action_buttons_state()
def update_nav_buttons(self) -> None:
@@ -117,4 +139,4 @@ class NavigationManager:
self.dialog.widget_manager.back_button.config(
state=tk.NORMAL if self.dialog.history_pos > 0 else tk.DISABLED)
self.dialog.widget_manager.forward_button.config(state=tk.NORMAL if self.dialog.history_pos < len(
self.dialog.history) - 1 else tk.DISABLED)
self.dialog.history) - 1 else tk.DISABLED)

View File

@@ -4,6 +4,7 @@ from typing import TYPE_CHECKING
from .cfd_app_config import CfdConfigManager, LocaleStrings
from shared_libs.animated_icon import PIL_AVAILABLE
from .cfd_sftp_manager import PARAMIKO_AVAILABLE
if TYPE_CHECKING:
from custom_file_dialog import CustomFileDialog
@@ -56,6 +57,8 @@ class SettingsDialog(tk.Toplevel):
value=self.settings.get("use_pillow_animation", False))
self.animation_type = tk.StringVar(
value=self.settings.get("animation_type", "double_arc"))
self.keep_bookmarks_on_reset = tk.BooleanVar(
value=self.settings.get("keep_bookmarks_on_reset", True))
# --- UI Elements ---
main_frame = ttk.Frame(self, padding=10)
@@ -140,6 +143,39 @@ class SettingsDialog(tk.Toplevel):
ttk.Radiobutton(anim_type_frame, text=LocaleStrings.SET["blink"], variable=self.animation_type,
value="blink").pack(side="left", padx=5)
# SFTP Settings
sftp_frame = ttk.LabelFrame(
main_frame, text=LocaleStrings.SET["sftp_settings"], padding=10)
sftp_frame.pack(fill="x", pady=5)
if not PARAMIKO_AVAILABLE:
ttk.Label(sftp_frame, text=LocaleStrings.SET["paramiko_not_found"],
font=("TkDefaultFont", 9, "italic")).pack(anchor="w")
ttk.Label(sftp_frame, text=LocaleStrings.SET["sftp_disabled"],
font=("TkDefaultFont", 9, "italic")).pack(anchor="w")
else:
ttk.Label(sftp_frame, text=LocaleStrings.SET["paramiko_found"],
font=("TkDefaultFont", 9)).pack(anchor="w")
# Keyring status
try:
import keyring
keyring_available = True
except ImportError:
keyring_available = False
if keyring_available:
ttk.Label(sftp_frame, text="Keyring library found. Passwords will be stored securely.",
font=("TkDefaultFont", 9)).pack(anchor="w", pady=(5,0))
else:
ttk.Label(sftp_frame, text="Keyring library not found. Passwords cannot be saved.",
font=("TkDefaultFont", 9, "italic")).pack(anchor="w", pady=(5,0))
self.keep_bookmarks_checkbutton = ttk.Checkbutton(sftp_frame, text=LocaleStrings.SET["keep_sftp_bookmarks"],
variable=self.keep_bookmarks_on_reset)
self.keep_bookmarks_checkbutton.pack(anchor="w", pady=(5,0))
# Disable deletion options in "open" mode
if not self.dialog_mode == "save":
self.use_trash_checkbutton.config(state=tk.DISABLED)
@@ -174,7 +210,8 @@ class SettingsDialog(tk.Toplevel):
"use_trash": self.use_trash.get(),
"confirm_delete": self.confirm_delete.get(),
"use_pillow_animation": self.use_pillow_animation.get(),
"animation_type": self.animation_type.get()
"animation_type": self.animation_type.get(),
"keep_bookmarks_on_reset": self.keep_bookmarks_on_reset.get()
}
CfdConfigManager.save(new_settings)
self.master.reload_config_and_rebuild_ui()
@@ -193,3 +230,4 @@ class SettingsDialog(tk.Toplevel):
self.use_pillow_animation.set(defaults.get(
"use_pillow_animation", True) and PIL_AVAILABLE)
self.animation_type.set(defaults.get("animation_type", "counter_arc"))
self.keep_bookmarks_on_reset.set(defaults.get("keep_bookmarks_on_reset", True))

View File

@@ -0,0 +1,173 @@
# cfd_sftp_manager.py
try:
import paramiko
PARAMIKO_AVAILABLE = True
except ImportError:
paramiko = None
PARAMIKO_AVAILABLE = False
try:
import keyring
KEYRING_AVAILABLE = True
except ImportError:
keyring = None
KEYRING_AVAILABLE = False
class SFTPManager:
def __init__(self):
self.client = None
self.sftp = None
self.home_dir = None
def connect(self, host, port, username, password=None, key_file=None, passphrase=None):
if not PARAMIKO_AVAILABLE:
raise ImportError("Paramiko library is not installed. SFTP functionality is disabled.")
try:
self.client = paramiko.SSHClient()
self.client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
self.client.connect(
hostname=host,
port=port,
username=username,
password=password,
key_filename=key_file,
passphrase=passphrase,
timeout=10,
allow_agent=False,
look_for_keys=False
)
self.sftp = self.client.open_sftp()
self.home_dir = self.get_home_directory()
return True, "Connection successful."
except Exception as e:
self.client = None
self.sftp = None
return False, str(e)
def get_home_directory(self):
if not self.sftp:
return None
try:
# normalize('.') is a common way to get the default directory, usually home.
return self.sftp.normalize('.')
except Exception:
# Fallback to root if normalize fails
return "/"
def disconnect(self):
if self.sftp:
self.sftp.close()
self.sftp = None
if self.client:
self.client.close()
self.client = None
self.home_dir = None
def list_directory(self, path):
if not self.sftp:
return [], "Not connected."
try:
items = self.sftp.listdir_attr(path)
# Sort directories first, then files
items.sort(key=lambda x: (not self.item_is_dir(x), x.filename.lower()))
return items, None
except Exception as e:
return [], str(e)
def item_is_dir(self, item):
# Helper to check if an SFTP attribute object is a directory
import stat
return stat.S_ISDIR(item.st_mode)
def path_is_dir(self, path):
if not self.sftp:
return False
try:
import stat
return stat.S_ISDIR(self.sftp.stat(path).st_mode)
except Exception:
return False
def exists(self, path):
if not self.sftp:
return False
try:
self.sftp.stat(path)
return True
except FileNotFoundError:
return False
except Exception:
return False
def mkdir(self, path):
if not self.sftp:
return False, "Not connected."
try:
self.sftp.mkdir(path)
return True, ""
except Exception as e:
return False, str(e)
def rmdir(self, path):
if not self.sftp:
return False, "Not connected."
try:
self.sftp.rmdir(path)
return True, ""
except Exception as e:
return False, str(e)
def rm(self, path):
if not self.sftp:
return False, "Not connected."
try:
self.sftp.remove(path)
return True, ""
except Exception as e:
return False, str(e)
def rename(self, old_path, new_path):
if not self.sftp:
return False, "Not connected."
try:
self.sftp.rename(old_path, new_path)
return True, ""
except Exception as e:
return False, str(e)
def touch(self, path):
if not self.sftp:
return False, "Not connected."
try:
with self.sftp.open(path, 'w') as f:
pass
return True, ""
except Exception as e:
return False, str(e)
def rm_recursive(self, path):
if not self.sftp:
return False, "Not connected."
try:
items = self.sftp.listdir_attr(path)
for item in items:
remote_path = f"{path}/{item.filename}"
if self.item_is_dir(item):
success, msg = self.rm_recursive(remote_path)
if not success:
return False, msg
else:
self.sftp.remove(remote_path)
self.sftp.rmdir(path)
return True, ""
except Exception as e:
return False, str(e)
@property
def is_connected(self):
return self.sftp is not None

View File

@@ -10,21 +10,14 @@ if TYPE_CHECKING:
from custom_file_dialog import CustomFileDialog
from shared_libs.common_tools import Tooltip
from shared_libs.animated_icon import AnimatedIcon
from shared_libs.animated_icon import AnimatedIcon, PIL_AVAILABLE
from .cfd_app_config import LocaleStrings
from .cfd_sftp_manager import PARAMIKO_AVAILABLE
def get_xdg_user_dir(dir_key: str, fallback_name: str) -> str:
"""
Retrieves a user directory path from the XDG user-dirs.dirs config file.
Args:
dir_key (str): The key for the directory (e.g., "XDG_DOWNLOAD_DIR").
fallback_name (str): The name of the directory to use as a fallback
if the key is not found (e.g., "Downloads").
Returns:
str: The absolute path to the user directory.
"""
home = os.path.expanduser("~")
fallback_path = os.path.join(home, fallback_name)
@@ -52,19 +45,10 @@ class StyleManager:
"""Manages the visual styling of the application using ttk styles."""
def __init__(self, dialog: 'CustomFileDialog') -> None:
"""
Initializes the StyleManager.
Args:
dialog: The main CustomFileDialog instance.
"""
self.dialog = dialog
self.setup_styles()
def setup_styles(self) -> None:
"""
Configures all the ttk styles for the dialog based on a light or dark theme.
"""
style = ttk.Style(self.dialog)
base_bg = self.dialog.cget('background')
self.is_dark = sum(self.dialog.winfo_rgb(base_bg)) / 3 < 32768
@@ -129,40 +113,29 @@ class StyleManager:
('selected', "black" if not self.is_dark else "white")])
style.configure("TButton.Borderless.Round", anchor="w")
style.configure("Small.Horizontal.TProgressbar", thickness=8)
style.configure("Bottom.TButton.Borderless.Round",
background=self.bottom_color)
style.map("Bottom.TButton.Borderless.Round",
background=[('active', self.hover_extrastyle)])
style.map("Bottom.TButton.Borderless.Round", background=[
('active', self.hover_extrastyle)])
style.layout("Bottom.TButton.Borderless.Round",
style.layout("Header.TButton.Borderless.Round")
)
style.layout("Header.TButton.Borderless.Round"))
class WidgetManager:
"""Manages the creation, layout, and management of all widgets in the dialog."""
def __init__(self, dialog: 'CustomFileDialog', settings: Dict[str, Any]) -> None:
"""
Initializes the WidgetManager.
Args:
dialog: The main CustomFileDialog instance.
settings (dict): The application settings.
"""
self.dialog = dialog
self.style_manager = dialog.style_manager
self.settings = settings
self.setup_widgets()
def _setup_top_bar(self, parent_frame: ttk.Frame) -> None:
"""Sets up the top bar with navigation and control buttons."""
top_bar = ttk.Frame(
parent_frame, style='Accent.TFrame', padding=(0, 5, 0, 5))
top_bar.grid(row=0, column=0, columnspan=2, sticky="ew")
top_bar.grid_columnconfigure(1, weight=1)
# Left navigation
left_nav_container = ttk.Frame(top_bar, style='Accent.TFrame')
left_nav_container.grid(row=0, column=0, sticky="w")
left_nav_container.grid_propagate(False)
@@ -187,23 +160,29 @@ class WidgetManager:
self.home_button.pack(side="left", padx=(5, 10))
Tooltip(self.home_button, LocaleStrings.UI["home"])
# Path and search
path_search_container = ttk.Frame(top_bar, style='Accent.TFrame')
path_search_container.grid(row=0, column=1, sticky="ew")
path_search_container.grid_columnconfigure(0, weight=1)
self.path_entry = ttk.Entry(path_search_container)
self.path_entry.grid(row=0, column=0, sticky="ew")
self.path_entry.bind(
"<Return>", lambda e: self.dialog.navigation_manager.navigate_to(self.path_entry.get()))
search_icon_pos = self.settings.get("search_icon_pos", "left")
if search_icon_pos == 'left':
path_search_container.grid_columnconfigure(1, weight=1)
self.path_entry.grid(row=0, column=1, sticky="ew")
else: # right
path_search_container.grid_columnconfigure(0, weight=1)
self.path_entry.grid(row=0, column=0, sticky="ew")
self.update_animation_icon = AnimatedIcon(
path_search_container,
width=20,
height=20,
animation_type="blink",
use_pillow=PIL_AVAILABLE,
bg=self.style_manager.header
)
self.update_animation_icon.grid(
row=0, column=1, sticky='e', padx=(5, 0))
self.update_animation_icon.grid_remove() # Initially hidden
# Right controls
right_controls_container = ttk.Frame(top_bar, style='Accent.TFrame')
right_controls_container = ttk.Frame(
top_bar, style='Accent.TFrame')
right_controls_container.grid(row=0, column=2, sticky="e")
self.responsive_buttons_container = ttk.Frame(
right_controls_container, style='Accent.TFrame')
@@ -219,6 +198,19 @@ class WidgetManager:
self.new_file_button.pack(side="left", padx=5)
Tooltip(self.new_file_button, LocaleStrings.UI["new_document"])
sftp_icon = self.dialog.icon_manager.get_icon('connect')
if sftp_icon:
self.sftp_button = ttk.Button(self.responsive_buttons_container, image=sftp_icon,
command=self.dialog.open_sftp_dialog, style="Header.TButton.Borderless.Round")
else:
self.sftp_button = ttk.Button(self.responsive_buttons_container, text="SFTP",
command=self.dialog.open_sftp_dialog, style="Header.TButton.Borderless.Round")
self.sftp_button.pack(side="left", padx=5)
Tooltip(self.sftp_button, LocaleStrings.UI["sftp_connection"])
if not PARAMIKO_AVAILABLE:
self.sftp_button.config(state=tk.DISABLED)
if self.dialog.dialog_mode == "open":
self.new_folder_button.config(state=tk.DISABLED)
self.new_file_button.config(state=tk.DISABLED)
@@ -246,13 +238,12 @@ class WidgetManager:
command=self.dialog.show_more_menu, style="Header.TButton.Borderless.Round", width=3)
def _setup_sidebar(self, parent_paned_window: ttk.PanedWindow) -> None:
"""Sets up the sidebar with bookmarks and devices."""
sidebar_frame = ttk.Frame(
parent_paned_window, style="Sidebar.TFrame", padding=(0, 0, 0, 0), width=200)
sidebar_frame.grid_propagate(False)
sidebar_frame.bind("<Configure>", self.dialog.on_sidebar_resize)
parent_paned_window.add(sidebar_frame, weight=0)
sidebar_frame.grid_rowconfigure(2, weight=1)
sidebar_frame.grid_rowconfigure(4, weight=1)
self._setup_sidebar_bookmarks(sidebar_frame)
@@ -265,10 +256,14 @@ class WidgetManager:
tk.Frame(sidebar_frame, height=1, bg=separator_color).grid(
row=3, column=0, sticky="ew", padx=20, pady=15)
self._setup_sidebar_sftp_bookmarks(sidebar_frame)
tk.Frame(sidebar_frame, height=1, bg=separator_color).grid(
row=5, column=0, sticky="ew", padx=20, pady=15)
self._setup_sidebar_storage(sidebar_frame)
def _setup_sidebar_bookmarks(self, sidebar_frame: ttk.Frame) -> None:
"""Sets up the bookmark buttons in the sidebar."""
sidebar_buttons_frame = ttk.Frame(
sidebar_frame, style="Sidebar.TFrame", padding=(0, 15, 0, 0))
sidebar_buttons_frame.grid(row=0, column=0, sticky="nsew")
@@ -288,13 +283,65 @@ class WidgetManager:
]
self.sidebar_buttons = []
for config in sidebar_buttons_config:
# Special case for "Computer" button to not disconnect SFTP
if config['path'] == '/':
command = lambda p=config['path']: self.dialog.navigation_manager.navigate_to(p)
else:
command = lambda p=config['path']: self.dialog.handle_sidebar_bookmark_click(p)
btn = ttk.Button(sidebar_buttons_frame, text=f" {config['name']}", image=config['icon'], compound="left",
command=lambda p=config['path']: self.dialog.navigation_manager.navigate_to(p), style="Dark.TButton.Borderless")
command=command, style="Dark.TButton.Borderless")
btn.pack(fill="x", pady=1)
self.sidebar_buttons.append((btn, f" {config['name']}"))
def _setup_sidebar_sftp_bookmarks(self, sidebar_frame: ttk.Frame) -> None:
self.sftp_bookmarks_frame = ttk.Frame(
sidebar_frame, style="Sidebar.TFrame")
self.sftp_bookmarks_frame.grid(row=4, column=0, sticky="nsew", padx=10)
self.sftp_bookmarks_frame.grid_columnconfigure(0, weight=1)
bookmarks = self.dialog.config_manager.load_bookmarks()
if not bookmarks:
return
ttk.Label(self.sftp_bookmarks_frame, text=LocaleStrings.UI["sftp_bookmarks"], background=self.style_manager.sidebar_color,
foreground=self.style_manager.color_foreground).grid(row=0, column=0, sticky="ew", padx=10, pady=(5, 0))
self.sftp_bookmark_buttons = []
sftp_bookmark_icon = self.dialog.icon_manager.get_icon('star')
row_counter = 1
for name, data in bookmarks.items():
if sftp_bookmark_icon:
btn = ttk.Button(self.sftp_bookmarks_frame, text=f" {name}", image=sftp_bookmark_icon, compound="left",
command=lambda d=data: self.dialog.connect_sftp_bookmark(d), style="Dark.TButton.Borderless")
else:
btn = ttk.Button(self.sftp_bookmarks_frame, text=f" {name}", compound="left",
command=lambda d=data: self.dialog.connect_sftp_bookmark(d), style="Dark.TButton.Borderless")
btn.grid(row=row_counter, column=0, sticky="ew")
row_counter += 1
btn.bind("<Button-3>", lambda event, n=name, d=data: self._show_sftp_bookmark_context_menu(event, n, d))
self.sftp_bookmark_buttons.append(btn)
def _show_sftp_bookmark_context_menu(self, event, name, data):
context_menu = tk.Menu(self.dialog, tearoff=0)
edit_icon = self.dialog.icon_manager.get_icon('key_small')
context_menu.add_command(
label="Edit Bookmark", # Replace with LocaleString later
image=edit_icon,
compound=tk.LEFT,
command=lambda: self.dialog.edit_sftp_bookmark(name, data))
trash_icon = self.dialog.icon_manager.get_icon('trash_small2')
context_menu.add_command(
label=LocaleStrings.UI["remove_bookmark"],
image=trash_icon,
compound=tk.LEFT,
command=lambda: self.dialog.remove_sftp_bookmark(name))
context_menu.tk_popup(event.x_root, event.y_root)
def _setup_sidebar_devices(self, sidebar_frame: ttk.Frame) -> None:
"""Sets up the mounted devices section in the sidebar."""
mounted_devices_frame = ttk.Frame(
sidebar_frame, style="Sidebar.TFrame")
mounted_devices_frame.grid(row=2, column=0, sticky="nsew", padx=10)
@@ -316,13 +363,6 @@ class WidgetManager:
self.devices_canvas_window = self.devices_canvas.create_window(
(0, 0), window=self.devices_scrollable_frame, anchor="nw")
self.devices_canvas.bind("<Enter>", self.dialog._on_devices_enter)
self.devices_canvas.bind("<Leave>", self.dialog._on_devices_leave)
self.devices_scrollable_frame.bind(
"<Enter>", self.dialog._on_devices_enter)
self.devices_scrollable_frame.bind(
"<Leave>", self.dialog._on_devices_leave)
def _configure_devices_canvas(event: tk.Event) -> None:
self.devices_canvas.configure(
scrollregion=self.devices_canvas.bbox("all"))
@@ -357,7 +397,7 @@ class WidgetManager:
button_text = f" {device_name[:15]}\n{device_name[15:]}"
btn = ttk.Button(self.devices_scrollable_frame, text=button_text, image=icon, compound="left",
command=lambda p=mount_point: self.dialog.navigation_manager.navigate_to(p), style="Dark.TButton.Borderless")
command=lambda p=mount_point: self.dialog.handle_sidebar_bookmark_click(p), style="Dark.TButton.Borderless")
btn.pack(fill="x", pady=1)
self.device_buttons.append((btn, button_text))
@@ -365,8 +405,6 @@ class WidgetManager:
w.bind("<MouseWheel>", _on_devices_mouse_wheel)
w.bind("<Button-4>", _on_devices_mouse_wheel)
w.bind("<Button-5>", _on_devices_mouse_wheel)
w.bind("<Enter>", self.dialog._on_devices_enter)
w.bind("<Leave>", self.dialog._on_devices_leave)
try:
total, used, _ = shutil.disk_usage(mount_point)
@@ -378,24 +416,20 @@ class WidgetManager:
w.bind("<MouseWheel>", _on_devices_mouse_wheel)
w.bind("<Button-4>", _on_devices_mouse_wheel)
w.bind("<Button-5>", _on_devices_mouse_wheel)
w.bind("<Enter>", self.dialog._on_devices_enter)
w.bind("<Leave>", self.dialog._on_devices_leave)
except (FileNotFoundError, PermissionError):
pass
def _setup_sidebar_storage(self, sidebar_frame: ttk.Frame) -> None:
"""Sets up the storage indicator in the sidebar."""
storage_frame = ttk.Frame(sidebar_frame, style="Sidebar.TFrame")
storage_frame.grid(row=5, column=0, sticky="sew", padx=10, pady=10)
storage_frame.grid(row=6, column=0, sticky="sew", padx=10, pady=10)
self.storage_label = ttk.Label(
storage_frame, text=f"{LocaleStrings.CFD["free_space"]}:", background=self.style_manager.freespace_background)
storage_frame, text=f"{LocaleStrings.CFD['free_space']}", background=self.style_manager.freespace_background)
self.storage_label.pack(fill="x", padx=10)
self.storage_bar = ttk.Progressbar(
storage_frame, orient="horizontal", length=100, mode="determinate")
self.storage_bar.pack(fill="x", pady=(2, 5), padx=15)
def _setup_bottom_bar(self) -> None:
"""Sets up the bottom bar including filename entry, action buttons, and status info."""
self.action_status_frame = ttk.Frame(
self.content_frame, style="AccentBottom.TFrame")
self.action_status_frame.grid(
@@ -423,7 +457,6 @@ class WidgetManager:
sticky="ew", pady=(4, 0))
self.status_container.grid(row=2, column=0, columnspan=3, sticky='ew')
# --- Define Widgets ---
self.search_status_label = ttk.Label(
self.status_container, text="", style="AccentBottom.TLabel")
self.filename_entry = ttk.Entry(self.center_container)
@@ -450,7 +483,7 @@ class WidgetManager:
self.cancel_button = ttk.Button(
self.action_status_frame, text=LocaleStrings.SET["cancel_button"], command=self.dialog.on_cancel)
self.filter_combobox = ttk.Combobox(self.center_container, values=[
ft[0] for ft in self.dialog.filetypes], state="readonly")
ft[0] for ft in self.dialog.filetypes], state="readonly")
self.filter_combobox.bind(
"<<ComboboxSelected>>", self.dialog.view_manager.on_filter_change)
self.filter_combobox.set(self.dialog.filetypes[0][0])
@@ -466,7 +499,7 @@ class WidgetManager:
self.cancel_button = ttk.Button(
self.action_status_frame, text=LocaleStrings.CFD["cancel"], command=self.dialog.on_cancel)
self.filter_combobox = ttk.Combobox(self.center_container, values=[
ft[0] for ft in self.dialog.filetypes], state="readonly")
ft[0] for ft in self.dialog.filetypes], state="readonly")
self.filter_combobox.bind(
"<<ComboboxSelected>>", self.dialog.view_manager.on_filter_change)
self.filter_combobox.set(self.dialog.filetypes[0][0])
@@ -478,21 +511,16 @@ class WidgetManager:
self._layout_bottom_buttons(button_box_pos)
def _layout_bottom_buttons(self, button_box_pos: str) -> None:
"""Lays out the bottom action buttons based on user settings."""
# Configure container weights
self.left_container.grid_rowconfigure(0, weight=1)
self.right_container.grid_rowconfigure(0, weight=1)
# Determine action button and its container
action_button = self.save_button if self.dialog.dialog_mode == "save" else self.open_button
action_container = self.left_container if button_box_pos == 'left' else self.right_container
other_container = self.right_container if button_box_pos == 'left' else self.left_container
# Place main action buttons
action_button.grid(in_=action_container, row=0, column=0, pady=(0, 5))
self.cancel_button.grid(in_=action_container, row=1, column=0)
# Place settings and trash buttons
if button_box_pos == 'left':
self.settings_button.grid(
in_=other_container, row=0, column=0, sticky="ne")
@@ -506,7 +534,6 @@ class WidgetManager:
self.trash_button.grid(
in_=other_container, row=0, column=0, sticky="sw")
# Layout for the center container (filename, filter, status)
if button_box_pos == 'left':
self.center_container.grid_columnconfigure(0, weight=1)
self.filter_combobox.grid(
@@ -517,8 +544,6 @@ class WidgetManager:
in_=self.center_container, row=1, column=1, sticky="e", pady=(5, 0))
def setup_widgets(self) -> None:
"""Creates and arranges all widgets in the main dialog window."""
# Main container
main_frame = ttk.Frame(self.dialog, style='Accent.TFrame')
main_frame.pack(fill="both", expand=True)
main_frame.grid_rowconfigure(2, weight=1)
@@ -526,12 +551,10 @@ class WidgetManager:
self._setup_top_bar(main_frame)
# Horizontal separator
separator_color = "#000000" if self.style_manager.is_dark else "#9c9c9c"
tk.Frame(main_frame, height=1, bg=separator_color).grid(
row=1, column=0, columnspan=2, sticky="ew")
# PanedWindow for resizable sidebar and content
paned_window = ttk.PanedWindow(
main_frame, orient=tk.HORIZONTAL, style="Sidebar.TFrame")
paned_window.grid(row=2, column=0, columnspan=2, sticky="nsew")
@@ -549,5 +572,4 @@ class WidgetManager:
self.file_list_frame.grid(row=0, column=0, sticky="nsew")
self.dialog.bind("<Configure>", self.dialog.on_window_resize)
# --- Bottom Bar ---
self._setup_bottom_bar()

View File

@@ -2,7 +2,7 @@ import os
import tkinter as tk
from tkinter import ttk
from datetime import datetime
from typing import Optional, List, Tuple, Callable, Any
from typing import Optional, List, Tuple, Callable, Any, Dict
# To avoid circular import with custom_file_dialog.py
from typing import TYPE_CHECKING
@@ -10,6 +10,7 @@ if TYPE_CHECKING:
from custom_file_dialog import CustomFileDialog
from shared_libs.common_tools import Tooltip
from shared_libs.message import MessageDialog
from .cfd_app_config import CfdConfigManager, LocaleStrings
@@ -25,18 +26,66 @@ class ViewManager:
"""
self.dialog = dialog
def _get_file_info_list(self) -> Tuple[List[Dict], Optional[str], Optional[str]]:
"""
Gets a sorted list of file information dictionaries from the current source.
"""
if self.dialog.current_fs_type == "sftp":
items, error = self.dialog.sftp_manager.list_directory(self.dialog.current_dir)
if error:
return [], error, None
file_info_list = []
import stat
for item in items:
if item.filename in ['.', '..']:
continue
is_dir = stat.S_ISDIR(item.st_mode)
# Manually construct SFTP path to ensure forward slashes
path = f"{self.dialog.current_dir}/{item.filename}".replace("//", "/")
file_info_list.append({
'name': item.filename,
'path': path,
'is_dir': is_dir,
'size': item.st_size,
'modified': item.st_mtime
})
return file_info_list, None, None
else:
try:
items = list(os.scandir(self.dialog.current_dir))
num_items = len(items)
warning_message = None
if num_items > CfdConfigManager.MAX_ITEMS_TO_DISPLAY:
warning_message = f"{LocaleStrings.CFD['showing']} {CfdConfigManager.MAX_ITEMS_TO_DISPLAY} {LocaleStrings.CFD['of']} {num_items} {LocaleStrings.CFD['entries']}."
items = items[:CfdConfigManager.MAX_ITEMS_TO_DISPLAY]
items.sort(key=lambda e: (not e.is_dir(), e.name.lower()))
file_info_list = []
for item in items:
try:
stat_result = item.stat()
file_info_list.append({
'name': item.name,
'path': item.path,
'is_dir': item.is_dir(),
'size': stat_result.st_size,
'modified': stat_result.st_mtime
})
except (FileNotFoundError, PermissionError):
continue
return file_info_list, None, warning_message
except PermissionError:
return ([], LocaleStrings.CFD["access_denied"], None)
except FileNotFoundError:
return ([], LocaleStrings.CFD["directory_not_found"], None)
def populate_files(self, item_to_rename: Optional[str] = None, item_to_select: Optional[str] = None) -> None:
"""
Populates the main file display area.
This method clears the current view and then calls the appropriate
method to populate either the list or icon view.
Args:
item_to_rename (str, optional): The name of an item to immediately
put into rename mode. Defaults to None.
item_to_select (str, optional): The name of an item to select
after populating. Defaults to None.
"""
self._unbind_mouse_wheel_events()
@@ -46,54 +95,35 @@ class ViewManager:
self.dialog.widget_manager.path_entry.insert(
0, self.dialog.current_dir)
self.dialog.result = None
self.dialog.update_status_bar()
self.dialog.selected_item_frames.clear()
self.dialog.update_selection_info()
if self.dialog.view_mode.get() == "list":
self.populate_list_view(item_to_rename, item_to_select)
else:
self.populate_icon_view(item_to_rename, item_to_select)
def _get_sorted_items(self) -> Tuple[List[str], Optional[str], Optional[str]]:
"""
Gets a sorted list of items from the current directory.
Returns:
tuple: A tuple containing (list of items, error message, warning message).
"""
try:
items = os.listdir(self.dialog.current_dir)
num_items = len(items)
warning_message = None
if num_items > CfdConfigManager.MAX_ITEMS_TO_DISPLAY:
warning_message = f"{LocaleStrings.CFD['showing']} {CfdConfigManager.MAX_ITEMS_TO_DISPLAY} {LocaleStrings.CFD['of']} {num_items} {LocaleStrings.CFD['entries']}."
items = items[:CfdConfigManager.MAX_ITEMS_TO_DISPLAY]
dirs = sorted([d for d in items if os.path.isdir(
os.path.join(self.dialog.current_dir, d))], key=str.lower)
files = sorted([f for f in items if not os.path.isdir(
os.path.join(self.dialog.current_dir, f))], key=str.lower)
return (dirs + files, None, warning_message)
except PermissionError:
return ([], LocaleStrings.CFD["access_denied"], None)
except FileNotFoundError:
return ([], LocaleStrings.CFD["directory_not_found"], None)
def _get_folder_content_count(self, folder_path: str) -> Optional[int]:
"""
Counts the number of items in a given folder.
Args:
folder_path (str): The path to the folder.
Returns:
int or None: The number of items, or None if an error occurs.
Counts the number of items in a given folder, supporting both local and SFTP.
"""
try:
if not os.path.isdir(folder_path) or not os.access(folder_path, os.R_OK):
return None
items = os.listdir(folder_path)
if self.dialog.current_fs_type == "sftp":
if not self.dialog.sftp_manager.path_is_dir(folder_path):
return None
items, error = self.dialog.sftp_manager.list_directory(folder_path)
if error:
return None
else:
if not os.path.isdir(folder_path) or not os.access(folder_path, os.R_OK):
return None
items = os.listdir(folder_path)
if not self.dialog.show_hidden_files.get():
items = [item for item in items if not item.startswith('.')]
# For SFTP, items are attrs, for local they are strings
if self.dialog.current_fs_type == "sftp":
items = [item for item in items if not item.filename.startswith('.')]
else:
items = [item for item in items if not item.startswith('.')]
return len(items)
except (PermissionError, FileNotFoundError):
@@ -102,17 +132,21 @@ class ViewManager:
def _get_item_path_from_widget(self, widget: tk.Widget) -> Optional[str]:
"""
Traverses up the widget hierarchy to find the item_path attribute.
Args:
widget: The widget to start from.
Returns:
str or None: The associated file path, or None if not found.
"""
while widget and not hasattr(widget, 'item_path'):
widget = widget.master
return getattr(widget, 'item_path', None)
def _is_dir(self, path: str) -> bool:
"""Checks if a given path is a directory, supporting both local and SFTP."""
if self.dialog.current_fs_type == 'sftp':
for item in self.dialog.all_items:
if item['path'] == path:
return item['is_dir']
return False
else:
return os.path.isdir(path)
def _handle_icon_click(self, event: tk.Event) -> None:
"""Handles a single click on an icon view item."""
item_path = self._get_item_path_from_widget(event.widget)
@@ -120,7 +154,7 @@ class ViewManager:
item_frame = event.widget
while not hasattr(item_frame, 'item_path'):
item_frame = item_frame.master
self.on_item_select(item_path, item_frame)
self.on_item_select(item_path, item_frame, event)
def _handle_icon_double_click(self, event: tk.Event) -> None:
"""Handles a double click on an icon view item."""
@@ -147,12 +181,8 @@ class ViewManager:
def populate_icon_view(self, item_to_rename: Optional[str] = None, item_to_select: Optional[str] = None) -> None:
"""
Populates the file display with items in an icon grid layout.
Args:
item_to_rename (str, optional): Item to enter rename mode.
item_to_select (str, optional): Item to select.
"""
self.dialog.all_items, error, warning = self._get_sorted_items()
self.dialog.all_items, error, warning = self._get_file_info_list()
self.dialog.currently_loaded_count = 0
self.dialog.icon_canvas = tk.Canvas(self.dialog.widget_manager.file_list_frame,
@@ -223,15 +253,6 @@ class ViewManager:
def _load_more_items_icon_view(self, container: ttk.Frame, scroll_handler: Callable, item_to_rename: Optional[str] = None, item_to_select: Optional[str] = None) -> Optional[ttk.Frame]:
"""
Loads a batch of items into the icon view.
Args:
container: The parent widget for the items.
scroll_handler: The function to handle mouse wheel events.
item_to_rename (str, optional): Item to enter rename mode.
item_to_select (str, optional): Item to select.
Returns:
The widget that was focused (renamed or selected), or None.
"""
start_index = self.dialog.currently_loaded_count
end_index = min(len(self.dialog.all_items), start_index +
@@ -250,11 +271,13 @@ class ViewManager:
widget_to_focus = None
for i in range(start_index, end_index):
name = self.dialog.all_items[i]
file_info = self.dialog.all_items[i]
name = file_info['name']
path = file_info['path']
is_dir = file_info['is_dir']
if not self.dialog.show_hidden_files.get() and name.startswith('.'):
continue
path = os.path.join(self.dialog.current_dir, name)
is_dir = os.path.isdir(path)
if not is_dir and not self.dialog._matches_filetype(name):
continue
@@ -283,7 +306,7 @@ class ViewManager:
widget.bind("<Double-Button-1>", lambda e,
p=path: self._handle_item_double_click(p))
widget.bind("<Button-1>", lambda e, p=path,
f=item_frame: self.on_item_select(p, f))
f=item_frame: self.on_item_select(p, f, e))
widget.bind("<ButtonRelease-3>", lambda e,
p=path: self.dialog.file_op_manager._show_context_menu(e, p))
widget.bind("<F2>", lambda e, p=path,
@@ -309,12 +332,8 @@ class ViewManager:
def populate_list_view(self, item_to_rename: Optional[str] = None, item_to_select: Optional[str] = None) -> None:
"""
Populates the file display with items in a list (Treeview) layout.
Args:
item_to_rename (str, optional): Item to enter rename mode.
item_to_select (str, optional): Item to select.
"""
self.dialog.all_items, error, warning = self._get_sorted_items()
self.dialog.all_items, error, warning = self._get_file_info_list()
self.dialog.currently_loaded_count = 0
tree_frame = ttk.Frame(self.dialog.widget_manager.file_list_frame)
@@ -325,6 +344,9 @@ class ViewManager:
columns = ("size", "type", "modified")
self.dialog.tree = ttk.Treeview(
tree_frame, columns=columns, show="tree headings")
if self.dialog.dialog_mode == 'multi':
self.dialog.tree.config(selectmode="extended")
self.dialog.tree.heading(
"#0", text=LocaleStrings.VIEW["name"], anchor="w")
@@ -381,13 +403,6 @@ class ViewManager:
def _load_more_items_list_view(self, item_to_rename: Optional[str] = None, item_to_select: Optional[str] = None) -> bool:
"""
Loads a batch of items into the list view.
Args:
item_to_rename (str, optional): Item to enter rename mode.
item_to_select (str, optional): Item to select.
Returns:
bool: True if the item to rename/select was found and processed.
"""
start_index = self.dialog.currently_loaded_count
end_index = min(len(self.dialog.all_items), start_index +
@@ -398,25 +413,28 @@ class ViewManager:
item_found = False
for i in range(start_index, end_index):
name = self.dialog.all_items[i]
file_info = self.dialog.all_items[i]
name = file_info['name']
path = file_info['path']
is_dir = file_info['is_dir']
if not self.dialog.show_hidden_files.get() and name.startswith('.'):
continue
path = os.path.join(self.dialog.current_dir, name)
is_dir = os.path.isdir(path)
if not is_dir and not self.dialog._matches_filetype(name):
continue
try:
stat = os.stat(path)
modified_time = datetime.fromtimestamp(
stat.st_mtime).strftime('%d.%m.%Y %H:%M')
file_info['modified']).strftime('%d.%m.%Y %H:%M')
if is_dir:
icon, file_type, size = self.dialog.icon_manager.get_icon(
'folder_small'), LocaleStrings.FILE["folder"], ""
else:
icon, file_type, size = self.dialog.get_file_icon(
name, 'small'), LocaleStrings.FILE["file"], self.dialog._format_size(stat.st_size)
name, 'small'), LocaleStrings.FILE["file"], self.dialog._format_size(file_info['size'])
item_id = self.dialog.tree.insert("", "end", text=f" {name}", image=icon, values=(
size, file_type, modified_time))
self.dialog.item_path_map[item_id] = path # Store path for later retrieval
if name == item_to_rename:
self.dialog.tree.selection_set(item_id)
self.dialog.tree.focus(item_id)
@@ -434,70 +452,158 @@ class ViewManager:
self.dialog.currently_loaded_count = end_index
return item_found
def on_item_select(self, path: str, item_frame: ttk.Frame) -> None:
def on_item_select(self, path: str, item_frame: ttk.Frame, event: Optional[tk.Event] = None) -> None:
"""
Handles the selection of an item in the icon view.
Args:
path (str): The path of the selected item.
item_frame: The widget frame of the selected item.
"""
if hasattr(self.dialog, 'selected_item_frame') and self.dialog.selected_item_frame.winfo_exists():
self.dialog.selected_item_frame.state(['!selected'])
for child in self.dialog.selected_item_frame.winfo_children():
if self.dialog.dialog_mode == 'dir' and not self._is_dir(path):
return
if self.dialog.dialog_mode == 'multi':
ctrl_pressed = (event.state & 0x4) != 0 if event else False
if ctrl_pressed:
if item_frame in self.dialog.selected_item_frames:
item_frame.state(['!selected'])
for child in item_frame.winfo_children():
if isinstance(child, ttk.Label):
child.state(['!selected'])
self.dialog.selected_item_frames.remove(item_frame)
else:
item_frame.state(['selected'])
for child in item_frame.winfo_children():
if isinstance(child, ttk.Label):
child.state(['selected'])
self.dialog.selected_item_frames.append(item_frame)
else:
for f in self.dialog.selected_item_frames:
f.state(['!selected'])
for child in f.winfo_children():
if isinstance(child, ttk.Label):
child.state(['!selected'])
self.dialog.selected_item_frames.clear()
item_frame.state(['selected'])
for child in item_frame.winfo_children():
if isinstance(child, ttk.Label):
child.state(['selected'])
self.dialog.selected_item_frames.append(item_frame)
selected_paths = [frame.item_path for frame in self.dialog.selected_item_frames]
self.dialog.result = selected_paths
self.dialog.update_selection_info()
else: # Single selection mode
if hasattr(self.dialog, 'selected_item_frame') and self.dialog.selected_item_frame.winfo_exists():
self.dialog.selected_item_frame.state(['!selected'])
for child in self.dialog.selected_item_frame.winfo_children():
if isinstance(child, ttk.Label):
child.state(['!selected'])
item_frame.state(['selected'])
for child in item_frame.winfo_children():
if isinstance(child, ttk.Label):
child.state(['!selected'])
item_frame.state(['selected'])
for child in item_frame.winfo_children():
if isinstance(child, ttk.Label):
child.state(['selected'])
self.dialog.selected_item_frame = item_frame
self.dialog.selected_file = path
self.dialog.update_status_bar(path)
child.state(['selected'])
self.dialog.selected_item_frame = item_frame
self.dialog.update_selection_info(path)
self.dialog.search_manager.show_search_ready()
if not os.path.isdir(path):
self.dialog.widget_manager.filename_entry.delete(0, tk.END)
self.dialog.widget_manager.filename_entry.insert(
0, os.path.basename(path))
def on_list_select(self, event: tk.Event) -> None:
"""Handles the selection of an item in the list view."""
if not self.dialog.tree.selection():
"""
Handles the selection of an item in the list view.
"""
selections = self.dialog.tree.selection()
if not selections:
self.dialog.result = [] if self.dialog.dialog_mode == 'multi' else None
self.dialog.update_selection_info()
return
item_id = self.dialog.tree.selection()[0]
item_text = self.dialog.tree.item(item_id, 'text').strip()
path = os.path.join(self.dialog.current_dir, item_text)
self.dialog.selected_file = path
self.dialog.update_status_bar(path)
self.dialog.search_manager.show_search_ready()
if not os.path.isdir(self.dialog.selected_file):
self.dialog.widget_manager.filename_entry.delete(0, tk.END)
self.dialog.widget_manager.filename_entry.insert(0, item_text)
if self.dialog.dialog_mode == 'multi':
selected_paths = []
for item_id in selections:
path = self.dialog.item_path_map.get(item_id)
if path:
selected_paths.append(path)
self.dialog.result = selected_paths
self.dialog.update_selection_info()
else:
item_id = selections[0]
path = self.dialog.item_path_map.get(item_id)
if not path:
return
if self.dialog.dialog_mode == 'dir' and not self._is_dir(path):
self.dialog.result = None
self.dialog.tree.selection_remove(item_id)
self.dialog.update_selection_info()
return
self.dialog.update_selection_info(path)
def on_list_context_menu(self, event: tk.Event) -> str:
"""Shows the context menu for a list view item."""
"""
Shows the context menu for a list view item.
"""
iid = self.dialog.tree.identify_row(event.y)
if not iid:
return "break"
self.dialog.tree.selection_set(iid)
item_text = self.dialog.tree.item(iid, "text").strip()
item_path = os.path.join(self.dialog.current_dir, item_text)
self.dialog.file_op_manager._show_context_menu(event, item_path)
path = self.dialog.item_path_map.get(iid)
if path:
self.dialog.file_op_manager._show_context_menu(event, path)
return "break"
def _handle_item_double_click(self, path: str) -> None:
"""
Handles the logic for a double-click on any item, regardless of view.
Navigates into directories or selects files.
Args:
path (str): The full path of the double-clicked item.
"""
if os.path.isdir(path):
self.dialog.navigation_manager.navigate_to(path)
elif self.dialog.dialog_mode == "open":
self.dialog.selected_file = path
self.dialog.destroy()
if self._is_dir(path):
if self.dialog.dialog_mode == 'dir':
has_subdirs = False
try:
if self.dialog.current_fs_type == "sftp":
import stat
items, _ = self.dialog.sftp_manager.list_directory(path)
for item in items:
if item.filename not in ['.', '..'] and stat.S_ISDIR(item.st_mode):
has_subdirs = True
break
else:
for item in os.listdir(path):
if os.path.isdir(os.path.join(path, item)) and not item.startswith('.'):
has_subdirs = True
break
except OSError:
self.dialog.navigation_manager.navigate_to(path)
return
if has_subdirs:
self.dialog.navigation_manager.navigate_to(path)
else:
dialog = MessageDialog(
master=self.dialog,
message_type="ask",
title=LocaleStrings.CFD["select_or_enter_title"],
text=LocaleStrings.CFD["select_or_enter_prompt"].format(folder_name=os.path.basename(path)),
buttons=[
LocaleStrings.CFD["select_button"],
LocaleStrings.CFD["enter_button"],
LocaleStrings.CFD["cancel_button"],
]
)
choice = dialog.show()
if choice is True:
self.dialog.result = path
self.dialog.on_open()
elif choice is False:
self.dialog.navigation_manager.navigate_to(path)
else:
self.dialog.navigation_manager.navigate_to(path)
elif self.dialog.dialog_mode in ["open", "multi"]:
self.dialog.result = path
self.dialog.on_open()
elif self.dialog.dialog_mode == "save":
self.dialog.widget_manager.filename_entry.delete(0, tk.END)
self.dialog.widget_manager.filename_entry.insert(
@@ -505,24 +611,27 @@ class ViewManager:
self.dialog.on_save()
def on_list_double_click(self, event: tk.Event) -> None:
"""Handles a double-click on a list view item."""
if not self.dialog.tree.selection():
"""
Handles a double-click on a list view item.
"""
selection = self.dialog.tree.selection()
if not selection:
return
item_id = self.dialog.tree.selection()[0]
item_text = self.dialog.tree.item(item_id, 'text').strip()
path = os.path.join(self.dialog.current_dir, item_text)
self._handle_item_double_click(path)
item_id = selection[0]
path = self.dialog.item_path_map.get(item_id)
if path:
self._handle_item_double_click(path)
def _select_file_in_view(self, filename: str) -> None:
"""
Programmatically selects a file in the current view.
Args:
filename (str): The name of the file to select.
"""
is_sftp = self.dialog.current_fs_type == "sftp"
if self.dialog.view_mode.get() == "list":
for item_id in self.dialog.tree.get_children():
if self.dialog.tree.item(item_id, "text").strip() == filename:
for item_id, path in self.dialog.item_path_map.items():
basename = path.split('/')[-1] if is_sftp else os.path.basename(path)
if basename == filename:
self.dialog.tree.selection_set(item_id)
self.dialog.tree.focus(item_id)
self.dialog.tree.see(item_id)
@@ -532,7 +641,12 @@ class ViewManager:
return
container_frame = self.dialog.icon_canvas.winfo_children()[0]
target_path = os.path.join(self.dialog.current_dir, filename)
if is_sftp:
# Ensure forward slashes for SFTP paths
target_path = f"{self.dialog.current_dir}/{filename}".replace("//", "/")
else:
target_path = os.path.join(self.dialog.current_dir, filename)
for widget in container_frame.winfo_children():
if hasattr(widget, 'item_path') and widget.item_path == target_path:
@@ -610,4 +724,4 @@ class ViewManager:
"""Unbinds all mouse wheel events from the dialog."""
self.dialog.unbind_all("<MouseWheel>")
self.dialog.unbind_all("<Button-4>")
self.dialog.unbind_all("<Button-5>")
self.dialog.unbind_all("<Button-5>")

View File

@@ -4,8 +4,13 @@ import tkinter as tk
import subprocess
import json
import threading
import webbrowser
from typing import Optional, List, Tuple, Dict, Union
from shared_libs.common_tools import IconManager, Tooltip, LxTools
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
@@ -14,6 +19,8 @@ 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):
@@ -22,35 +29,23 @@ class CustomFileDialog(tk.Toplevel):
directory navigation, search, and file operations.
"""
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 __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.
Args:
parent: The parent widget.
initial_dir: The initial directory to display.
filetypes: A list of filetype tuples, e.g., [('Text files', '*.txt')].
mode: The dialog mode. Can be "open" (select single file),
"save" (select single file for saving),
"multi" (select multiple files/directories),
or "dir" (select a single directory).
title: The title of the dialog window.
"""
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()
@@ -81,6 +76,7 @@ class CustomFileDialog(tk.Toplevel):
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 = ""
@@ -113,11 +109,28 @@ class CustomFileDialog(tk.Toplevel):
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()
@@ -129,18 +142,14 @@ class CustomFileDialog(tk.Toplevel):
def get_min_size_from_preset(self, preset: str) -> Tuple[int, int]:
"""
Calculates the minimum window size based on a preset string.
Args:
preset: The size preset string (e.g., "1050x850").
Returns:
A tuple containing the minimum width and height.
"""
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"])
@@ -156,6 +165,10 @@ class CustomFileDialog(tk.Toplevel):
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()
@@ -175,6 +188,150 @@ class CustomFileDialog(tk.Toplevel):
"""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)
@@ -207,16 +364,57 @@ class CustomFileDialog(tk.Toplevel):
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.
Args:
filename: The name of the file.
size: The desired icon size ('large' or 'small').
Returns:
A PhotoImage object for the corresponding file type.
"""
ext = os.path.splitext(filename)[1].lower()
@@ -240,9 +438,6 @@ class CustomFileDialog(tk.Toplevel):
def on_window_resize(self, event: tk.Event) -> None:
"""
Handles the window resize event.
Args:
event: The event object.
"""
if event.widget is self:
if self.view_mode.get() == "icons" and not self.search_mode:
@@ -264,9 +459,6 @@ class CustomFileDialog(tk.Toplevel):
def _handle_responsive_buttons(self, window_width: int) -> None:
"""
Shows or hides buttons based on the window width.
Args:
window_width: The current width of the window.
"""
threshold = 850
container = self.widget_manager.responsive_buttons_container
@@ -317,9 +509,6 @@ class CustomFileDialog(tk.Toplevel):
def on_sidebar_resize(self, event: tk.Event) -> None:
"""
Handles the sidebar resize event, adjusting button text visibility.
Args:
event: The event object.
"""
current_width = event.width
threshold_width = 100
@@ -338,9 +527,6 @@ class CustomFileDialog(tk.Toplevel):
def _on_devices_enter(self, event: tk.Event) -> None:
"""
Shows the scrollbar when the mouse enters the devices area.
Args:
event: The event object.
"""
self.widget_manager.devices_scrollbar.grid(
row=1, column=1, sticky="ns")
@@ -348,9 +534,6 @@ class CustomFileDialog(tk.Toplevel):
def _on_devices_leave(self, event: tk.Event) -> None:
"""
Hides the scrollbar when the mouse leaves the devices area.
Args:
event: The event object.
"""
x, y = event.x_root, event.y_root
widget_x = self.widget_manager.devices_canvas.winfo_rootx()
@@ -375,33 +558,99 @@ class CustomFileDialog(tk.Toplevel):
self.widget_manager.recursive_button.configure(
style="Header.TButton.Borderless.Round")
def update_status_bar(self, status_info: Optional[str] = None) -> None:
def update_selection_info(self, status_info: Optional[str] = None) -> None:
"""
Updates the status bar with disk usage and selected item information.
Updates status bar, filename entry, and result based on current selection.
"""
self._update_disk_usage()
status_text = ""
is_sftp = self.current_fs_type == 'sftp'
Args:
status_info: The path of the currently selected item or a custom string.
"""
# 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
status_text = ""
if status_info and os.path.exists(status_info):
selected_path = status_info
if os.path.isdir(selected_path):
content_count = self.view_manager._get_folder_content_count(
selected_path)
if content_count is not None:
status_text = f
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' action, closing the dialog if a file is selected."""
if self.result and isinstance(self.result, str) and os.path.isfile(self.result):
self.destroy()
"""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."""
@@ -416,32 +665,42 @@ class CustomFileDialog(tk.Toplevel):
self.destroy()
def get_result(self) -> Optional[Union[str, List[str]]]:
"""
Returns the result of the dialog.
Returns:
- A string containing a single path for modes 'open', 'save', 'dir'.
- A list of strings for mode 'multi'.
- None if the dialog was cancelled.
"""
"""Returns the result of the dialog."""
return self.result
def update_action_buttons_state(self) -> None:
"""Updates the state of action buttons (e.g., 'New Folder') based on directory permissions."""
is_writable = os.access(self.current_dir, os.W_OK)
state = tk.NORMAL if is_writable and self.dialog_mode != "open" else tk.DISABLED
self.widget_manager.new_folder_button.config(state=state)
self.widget_manager.new_file_button.config(state=state)
"""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.
Args:
filename: The name of the file to check.
Returns:
True if the file matches the filter, False otherwise.
"""
if self.current_filter_pattern == "*.*":
return True
@@ -464,12 +723,6 @@ class CustomFileDialog(tk.Toplevel):
def _format_size(self, size_bytes: Optional[int]) -> str:
"""
Formats a size in bytes into a human-readable string (KB, MB, GB).
Args:
size_bytes: The size in bytes.
Returns:
A formatted string representing the size.
"""
if size_bytes is None:
return ""
@@ -484,23 +737,12 @@ class CustomFileDialog(tk.Toplevel):
def shorten_text(self, text: str, max_len: int) -> str:
"""
Shortens a string to a maximum length, adding '...' if truncated.
Args:
text: The text to shorten.
max_len: The maximum allowed length.
Returns:
The shortened text.
"""
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.
Returns:
A list of tuples, where each tuple contains the display name,
mount point, and a boolean indicating if it's removable.
"""
devices: List[Tuple[str, str, bool]] = []
root_disk_name: Optional[str] = None
@@ -519,7 +761,8 @@ class CustomFileDialog(tk.Toplevel):
break
for block_device in data.get('blockdevices', []):
if (block_device.get('mountpoint') and
if (
block_device.get('mountpoint') and
block_device.get('type') not in ['loop', 'rom'] and
block_device.get('mountpoint') != '/'):
@@ -536,7 +779,8 @@ class CustomFileDialog(tk.Toplevel):
if 'children' in block_device:
for child_device in block_device['children']:
if (child_device.get('mountpoint') and
if (
child_device.get('mountpoint') and
child_device.get('type') not in ['loop', 'rom'] and
child_device.get('mountpoint') != '/'):
@@ -554,4 +798,4 @@ class CustomFileDialog(tk.Toplevel):
except Exception as e:
print(f"Error getting mounted devices: {e}")
return devices
return devices

View File

@@ -12,95 +12,7 @@ except (ModuleNotFoundError, NameError):
class MessageDialog:
"""
A customizable message dialog window using tkinter for user interaction.
This class creates modal dialogs for displaying information, warnings, errors,
or questions to the user. It supports multiple button configurations, custom
icons, keyboard navigation, and command binding. The dialog is centered on the
screen and handles user interactions with focus management and accessibility.
Attributes:
message_type (str): Type of message ("info", "error", "warning", "ask").
text (str): Main message content to display.
buttons (List[str]): List of button labels (e.g., ["OK", "Cancel"]).
result (bool or None):
- True for positive actions (Yes, OK)
- False for negative actions (No, Cancel)
- None if "Cancel" was clicked with ≥3 buttons
icons: Dictionary mapping message types to tkinter.PhotoImage objects
Parameters:
message_type: Type of message dialog (default: "info")
text: Message content to display
buttons: List of button labels (default: ["OK"])
master: Parent tkinter window (optional)
commands: List of callables for each button (default: [None])
icon: Custom icon path (overrides default icons if provided)
title: Window title (default: derived from message_type)
font: Font tuple for text styling
wraplength: Text wrapping width in pixels
Methods:
_get_title(): Returns the default window title based on message type.
_on_button_click(button_text): Sets result and closes the dialog.
show(): Displays the dialog and waits for user response.
Example Usage:
1. Basic Info Dialog:
>>> MessageDialog(
... text="This is an information message.")
>>> result = dialog.show()
>>> print("User clicked OK:", result)
Notes:
My Favorite Example,
for simply information message:
>>> MessageDialog(text="This is an information message.")
>>> result = MessageDialog(text="This is an information message.").show()
Example Usage:
2. Error Dialog with Custom Command:
>>> def on_retry():
... print("User selected Retry")
>>> dialog = MessageDialog(
... message_type="error",
... text="An error occurred during processing.",
... buttons=["Retry", "Cancel"],
... commands=[on_retry, None],
... title="Critical Error"
... )
>>> result = dialog.show()
>>> print("User selected Retry:", result)
Example Usage:
3. And a special example for a "open link" button:
Be careful not to forget to import it into the script in which
this dialog is used!!! import webbrowser from functools import partial
>>> MessageDialog(
... "info"
... text="This is an information message.",
... buttons=["Yes", "Go to Exapmle"],
... commands=[
... None, # Default on "OK"
... partial(webbrowser.open, "https://exapmle.com"),
... ],
... icon="/pathh/to/custom/icon.png",
... title="Example",
... )
Notes:
- Returns None if "Cancel" was clicked with ≥3 buttons
- Supports keyboard navigation (Left/Right arrows and Enter)
- Dialog automatically centers on screen
- Result is False for window close (X) with 2 buttons
- Font and wraplength parameters enable text styling
"""
def __init__(
self,
message_type: str = "info",
@@ -113,23 +25,20 @@ class MessageDialog:
font: tuple = None,
wraplength: int = None,
):
self.message_type = message_type or "info" # Default is "info"
self.message_type = message_type or "info"
self.text = text
self.buttons = buttons
self.master = master
self.result: bool = False # Default is False
self.result: bool = False
self.icon = icon
self.title = title
# Window creation
self.window = tk.Toplevel(master)
self.window.grab_set()
self.window.resizable(False, False)
ttk.Style().configure("TButton")
self.buttons_widgets = []
self.current_button_index = 0
# Load icons using IconManager
icon_manager = IconManager()
self.icons = {
"error": icon_manager.get_icon("error_extralarge"),
@@ -138,36 +47,27 @@ class MessageDialog:
"ask": icon_manager.get_icon("question_mark_extralarge"),
}
# Handle custom icon override
if self.icon:
if isinstance(self.icon, str) and os.path.exists(self.icon):
# If it's a path, load it
try:
self.icons[self.message_type] = tk.PhotoImage(
file=self.icon)
self.icons[self.message_type] = tk.PhotoImage(file=self.icon)
except tk.TclError as e:
print(
f"Error loading custom icon from path '{self.icon}': {e}")
print(f"Error loading custom icon from path '{self.icon}': {e}")
elif isinstance(self.icon, tk.PhotoImage):
# If it's already a PhotoImage, use it directly
self.icons[self.message_type] = self.icon
# Window title and icon
self.window.title(self._get_title() if not self.title else self.title)
window_icon = self.icons.get(self.message_type)
if window_icon:
self.window.iconphoto(False, window_icon)
# Layout
frame = ttk.Frame(self.window)
frame.pack(expand=True, fill="both", padx=15, pady=8)
# Grid-Configuration
frame.grid_rowconfigure(0, weight=1)
frame.grid_columnconfigure(0, weight=1)
frame.grid_columnconfigure(1, weight=3)
# Icon and Text
icon_label = ttk.Label(frame, image=self.icons.get(self.message_type))
pady_value = 5 if self.icon is not None else 15
icon_label.grid(
@@ -183,118 +83,356 @@ class MessageDialog:
font=font if font else ("Helvetica", 12),
pady=20,
)
text_label.grid(
row=0,
column=1,
padx=(10, 20),
sticky="nsew",
)
text_label.grid(row=0, column=1, padx=(10, 20), sticky="nsew")
# Create button frame
self.button_frame = ttk.Frame(frame)
self.button_frame.grid(row=1, columnspan=2, pady=(8, 10))
for i, btn_text in enumerate(buttons):
if commands and len(commands) > i and commands[i] is not None:
# Button with individual command
btn = ttk.Button(
self.button_frame,
text=btn_text,
command=commands[i],
)
btn = ttk.Button(self.button_frame, text=btn_text, command=commands[i])
else:
# Default button set self.result and close window
btn = ttk.Button(
self.button_frame,
text=btn_text,
command=lambda t=btn_text: self._on_button_click(t),
)
btn = ttk.Button(self.button_frame, text=btn_text, command=lambda t=btn_text: self._on_button_click(t))
padx_value = 50 if self.icon is not None and len(
buttons) == 2 else 10
btn.pack(side="left" if i == 0 else "right",
padx=padx_value, pady=5)
btn.focus_set() if i == 0 else None # Set focus on first button
padx_value = 50 if self.icon is not None and len(buttons) == 2 else 10
btn.pack(side="left" if i == 0 else "right", padx=padx_value, pady=5)
if i == 0: btn.focus_set()
self.buttons_widgets.append(btn)
self.window.bind("<Return>", lambda event: self._on_enter_pressed())
self.window.bind("<Left>", lambda event: self._navigate_left())
self.window.bind("<Right>", lambda event: self._navigate_right())
self.window.update_idletasks()
self.window.attributes("-alpha", 0.0) # 100% Transparencence
self.window.grab_set()
self.window.attributes("-alpha", 0.0)
self.window.after(200, lambda: self.window.attributes("-alpha", 100.0))
self.window.update() # Window update before centering!
LxTools.center_window_cross_platform(
self.window, self.window.winfo_width(), self.window.winfo_height()
)
# Close Window on Cancel
self.window.protocol(
"WM_DELETE_WINDOW", lambda: self._on_button_click("Cancel")
)
self.window.update()
LxTools.center_window_cross_platform(self.window, self.window.winfo_width(), self.window.winfo_height())
self.window.protocol("WM_DELETE_WINDOW", lambda: self._on_button_click("Cancel"))
def _get_title(self) -> str:
return {
"error": "Error",
"info": "Info",
"ask": "Question",
"warning": "Warning",
}[self.message_type]
return {"error": "Error", "info": "Info", "ask": "Question", "warning": "Warning"}[self.message_type]
def _navigate_left(self):
if not self.buttons_widgets:
return
self.current_button_index = (self.current_button_index - 1) % len(
self.buttons_widgets
)
if not self.buttons_widgets: return
self.current_button_index = (self.current_button_index - 1) % len(self.buttons_widgets)
self.buttons_widgets[self.current_button_index].focus_set()
def _navigate_right(self):
if not self.buttons_widgets:
return
self.current_button_index = (self.current_button_index + 1) % len(
self.buttons_widgets
)
if not self.buttons_widgets: return
self.current_button_index = (self.current_button_index + 1) % len(self.buttons_widgets)
self.buttons_widgets[self.current_button_index].focus_set()
def _on_enter_pressed(self):
focused = self.window.focus_get()
if isinstance(focused, ttk.Button):
focused.invoke()
if isinstance(focused, ttk.Button): focused.invoke()
def _on_button_click(self, button_text: str) -> None:
"""
Sets `self.result` based on the clicked button.
- Returns `None` if the button is "Cancel", "Abort", or "Exit" **and** there are 3 or more buttons.
- Returns `True` if the button is "Yes", "Ok", "Continue", "Next", or "Start".
- Returns `False` in all other cases (e.g., "No", closing with X, or fewer than 3 buttons).
"""
# Check: If there are 3+ buttons and the button text matches "Cancel", "Abort", or "Exit"
if len(self.buttons) >= 3 and button_text.lower() in [
"cancel",
"abort",
"exit",
]:
if len(self.buttons) >= 3 and button_text.lower() in ["cancel", "abort", "exit"]:
self.result = None
# Check: Button text is "Yes", "Ok", "Continue", "Next", or "Start"
elif button_text.lower() in ["yes", "ok", "continue", "next", "start"]:
elif button_text.lower() in ["yes", "ok", "continue", "next", "start", "select"]:
self.result = True
else:
# Fallback for all other cases (e.g., "No", closing with X, or fewer than 3 buttons)
self.result = False
self.window.destroy()
def show(self) -> Optional[bool]:
"""
Displays the dialog window and waits for user interaction.
Returns:
bool or None:
- `True` if "Yes", "Ok", etc. was clicked.
- `False` if "No" was clicked, or the window was closed with X (when there are 2 buttons).
- `None` if "Cancel", "Abort", or "Exit" was clicked **and** there are 3+ buttons,
or the window was closed with X (when there are 3+ buttons).
"""
self.window.wait_window()
return self.result
class CredentialsDialog:
"""
A dialog for securely entering and editing SSH/SFTP credentials.
"""
def __init__(self, master: Optional[tk.Tk] = None, title: str = "SFTP Connection",
initial_data: Optional[dict] = None, is_edit_mode: bool = False):
self.master = master
self.result = None
self.is_edit_mode = is_edit_mode
self.initial_data = initial_data or {}
self.window = tk.Toplevel(master)
self.window.title(title)
self.window.resizable(False, False)
try:
import keyring
self.keyring_available = True
except ImportError:
self.keyring_available = False
style = ttk.Style(self.window)
style.configure("Creds.TEntry", padding=(5, 2))
frame = ttk.Frame(self.window, padding=15)
frame.pack(expand=True, fill="both")
frame.grid_columnconfigure(1, weight=1)
# Host
ttk.Label(frame, text="Host:").grid(row=0, column=0, sticky="w", pady=2)
self.host_entry = ttk.Entry(frame, width=40, style="Creds.TEntry")
self.host_entry.grid(row=0, column=1, sticky="ew", pady=2)
# Port
ttk.Label(frame, text="Port:").grid(row=1, column=0, sticky="w", pady=2)
self.port_entry = ttk.Entry(frame, width=10, style="Creds.TEntry")
self.port_entry.grid(row=1, column=1, sticky="w", pady=2)
# Username
ttk.Label(frame, text="Username:").grid(row=2, column=0, sticky="w", pady=2)
self.username_entry = ttk.Entry(frame, width=40, style="Creds.TEntry")
self.username_entry.grid(row=2, column=1, sticky="ew", pady=2)
# Initial Path
ttk.Label(frame, text="Initial Remote Directory:").grid(row=3, column=0, sticky="w", pady=2)
self.path_entry = ttk.Entry(frame, width=40, style="Creds.TEntry")
self.path_entry.grid(row=3, column=1, sticky="ew", pady=2)
# Auth Method
ttk.Label(frame, text="Auth Method:").grid(row=4, column=0, sticky="w", pady=5)
auth_frame = ttk.Frame(frame)
auth_frame.grid(row=4, column=1, sticky="w", pady=2)
self.auth_method = tk.StringVar(value="password")
ttk.Radiobutton(auth_frame, text="Password", variable=self.auth_method, value="password", command=self._toggle_auth_fields).pack(side="left")
ttk.Radiobutton(auth_frame, text="Key File", variable=self.auth_method, value="keyfile", command=self._toggle_auth_fields).pack(side="left", padx=10)
# Password
self.password_label = ttk.Label(frame, text="Password:")
self.password_label.grid(row=5, column=0, sticky="w", pady=2)
self.password_entry = ttk.Entry(frame, show="*", width=40, style="Creds.TEntry")
self.password_entry.grid(row=5, column=1, sticky="ew", pady=2)
# Key File
self.keyfile_label = ttk.Label(frame, text="Key File:")
self.keyfile_label.grid(row=6, column=0, sticky="w", pady=2)
key_frame = ttk.Frame(frame)
key_frame.grid(row=6, column=1, sticky="ew", pady=2)
self.keyfile_entry = ttk.Entry(key_frame, width=36, style="Creds.TEntry")
self.keyfile_entry.pack(side="left", fill="x", expand=True)
self.keyfile_button = ttk.Button(key_frame, text="", width=2, command=self._show_key_menu)
self.keyfile_button.pack(side="left", padx=(2,0))
# Passphrase
self.passphrase_label = ttk.Label(frame, text="Passphrase:")
self.passphrase_label.grid(row=7, column=0, sticky="w", pady=2)
self.passphrase_entry = ttk.Entry(frame, show="*", width=40, style="Creds.TEntry")
self.passphrase_entry.grid(row=7, column=1, sticky="ew", pady=2)
# Bookmark
self.bookmark_frame = ttk.LabelFrame(frame, text="Bookmark", padding=10)
self.bookmark_frame.grid(row=8, column=0, columnspan=2, sticky="ew", pady=5)
self.save_bookmark_var = tk.BooleanVar()
self.save_bookmark_check = ttk.Checkbutton(self.bookmark_frame, text="Save as bookmark", variable=self.save_bookmark_var, command=self._toggle_bookmark_name)
self.save_bookmark_check.pack(anchor="w")
if not self.keyring_available:
keyring_info_label = ttk.Label(self.bookmark_frame,
text="Python 'keyring' library not found.\nPasswords will not be saved.",
font=("TkDefaultFont", 9, "italic"))
keyring_info_label.pack(anchor="w", pady=(5,0))
self.save_bookmark_check.config(state=tk.DISABLED)
self.bookmark_name_label = ttk.Label(self.bookmark_frame, text="Bookmark Name:")
self.bookmark_name_entry = ttk.Entry(self.bookmark_frame, style="Creds.TEntry")
# Buttons
button_frame = ttk.Frame(frame)
button_frame.grid(row=9, column=1, sticky="e", pady=(15, 0))
connect_text = "Save Changes" if self.is_edit_mode else "Connect"
connect_button = ttk.Button(button_frame, text=connect_text, command=self._on_connect)
connect_button.pack(side="left", padx=5)
cancel_button = ttk.Button(button_frame, text="Cancel", command=self._on_cancel)
cancel_button.pack(side="left")
self._populate_initial_data()
self._toggle_auth_fields()
self.window.bind("<Return>", lambda event: self._on_connect())
self.window.protocol("WM_DELETE_WINDOW", self._on_cancel)
self.window.update_idletasks()
self.window.grab_set()
self.window.attributes("-alpha", 0.0)
self.window.after(200, lambda: self.window.attributes("-alpha", 100.0))
self.window.update()
LxTools.center_window_cross_platform(self.window, self.window.winfo_width(), self.window.winfo_height())
self.host_entry.focus_set()
def _populate_initial_data(self):
if not self.initial_data:
self.port_entry.insert(0, "22")
self.path_entry.insert(0, "~")
return
self.host_entry.insert(0, self.initial_data.get("host", ""))
self.port_entry.insert(0, self.initial_data.get("port", "22"))
self.username_entry.insert(0, self.initial_data.get("username", ""))
self.path_entry.insert(0, self.initial_data.get("initial_path", "~"))
if self.initial_data.get("key_file"):
self.auth_method.set("keyfile")
self.keyfile_entry.insert(0, self.initial_data.get("key_file", ""))
else:
self.auth_method.set("password")
if self.is_edit_mode:
# In edit mode, we don't show the "save as bookmark" option,
# as we are already editing one. The name is fixed.
self.bookmark_frame.grid_remove()
# We still need to know the bookmark name for saving.
self.bookmark_name_entry.insert(0, self.initial_data.get("bookmark_name", ""))
def _get_ssh_keys(self) -> List[str]:
ssh_path = os.path.expanduser("~/.ssh")
keys = []
if os.path.isdir(ssh_path):
try:
for item in os.listdir(ssh_path):
full_path = os.path.join(ssh_path, item)
if os.path.isfile(full_path) and not item.endswith('.pub') and 'known_hosts' not in item:
keys.append(full_path)
except OSError:
pass
return keys
def _show_key_menu(self):
keys = self._get_ssh_keys()
if not keys:
return
menu = tk.Menu(self.window, tearoff=0)
for key_path in keys:
menu.add_command(label=key_path, command=lambda k=key_path: self._select_key_from_menu(k))
x = self.keyfile_button.winfo_rootx()
y = self.keyfile_button.winfo_rooty() + self.keyfile_button.winfo_height()
menu.tk_popup(x, y)
def _select_key_from_menu(self, key_path):
self.keyfile_entry.delete(0, tk.END)
self.keyfile_entry.insert(0, key_path)
def _toggle_auth_fields(self):
method = self.auth_method.get()
if method == "password":
self.password_label.grid()
self.password_entry.grid()
self.keyfile_label.grid_remove()
self.keyfile_entry.master.grid_remove()
self.passphrase_label.grid_remove()
self.passphrase_entry.grid_remove()
else:
self.password_label.grid_remove()
self.password_entry.grid_remove()
self.keyfile_label.grid()
self.keyfile_entry.master.grid()
self.passphrase_label.grid()
self.passphrase_entry.grid()
self.window.update_idletasks()
self.window.geometry("")
def _toggle_bookmark_name(self):
if self.save_bookmark_var.get():
self.bookmark_name_label.pack(anchor="w", pady=(5,0))
self.bookmark_name_entry.pack(fill="x")
else:
self.bookmark_name_label.pack_forget()
self.bookmark_name_entry.pack_forget()
self.window.update_idletasks()
self.window.geometry("")
def _on_connect(self):
save_bookmark = self.save_bookmark_var.get() or self.is_edit_mode
bookmark_name = self.bookmark_name_entry.get()
if save_bookmark and not bookmark_name:
# In edit mode, the bookmark name comes from initial_data, so this check is for new bookmarks
if not self.is_edit_mode:
MessageDialog(message_type="error", text="Bookmark name cannot be empty.", master=self.window).show()
return
self.result = {
"host": self.host_entry.get(),
"port": int(self.port_entry.get() or 22),
"username": self.username_entry.get(),
"initial_path": self.path_entry.get() or "/",
"password": self.password_entry.get() if self.auth_method.get() == "password" else None,
"key_file": self.keyfile_entry.get() if self.auth_method.get() == "keyfile" else None,
"passphrase": self.passphrase_entry.get() if self.auth_method.get() == "keyfile" else None,
"save_bookmark": save_bookmark,
"bookmark_name": bookmark_name
}
self.window.destroy()
def _on_cancel(self):
self.result = None
self.window.destroy()
def show(self) -> Optional[dict]:
self.window.wait_window()
return self.result
class InputDialog:
"""
A simple dialog for getting a single line of text input from the user.
"""
def __init__(self, parent, title: str, prompt: str, initial_value: str = ""):
self.result = None
self.window = tk.Toplevel(parent)
self.window.title(title)
self.window.transient(parent)
self.window.resizable(False, False)
frame = ttk.Frame(self.window, padding=15)
frame.pack(expand=True, fill="both")
ttk.Label(frame, text=prompt, wraplength=250).pack(pady=(0, 10))
self.entry = ttk.Entry(frame, width=40)
self.entry.insert(0, initial_value)
self.entry.pack(pady=5)
self.entry.focus_set()
self.entry.selection_range(0, tk.END)
button_frame = ttk.Frame(frame)
button_frame.pack(pady=(10, 0))
ok_button = ttk.Button(button_frame, text="OK", command=self._on_ok)
ok_button.pack(side="left", padx=5)
cancel_button = ttk.Button(
button_frame, text="Cancel", command=self._on_cancel)
cancel_button.pack(side="left", padx=5)
self.window.bind("<Return>", lambda e: self._on_ok())
self.window.protocol("WM_DELETE_WINDOW", self._on_cancel)
self.window.update_idletasks()
self.window.grab_set()
self.window.attributes("-alpha", 0.0)
self.window.after(200, lambda: self.window.attributes("-alpha", 100.0))
self.window.update()
LxTools.center_window_cross_platform(
self.window, self.window.winfo_width(), self.window.winfo_height()
)
def _on_ok(self):
self.result = self.entry.get()
if self.result:
self.window.destroy()
def _on_cancel(self):
self.result = None
self.window.destroy()
def show(self) -> Optional[str]:
self.window.wait_window()
return self.result