diff --git a/custom_file_dialog/cfd_navigation_manager.py b/custom_file_dialog/cfd_navigation_manager.py index 228200d..2317ef3 100644 --- a/custom_file_dialog/cfd_navigation_manager.py +++ b/custom_file_dialog/cfd_navigation_manager.py @@ -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,19 +88,17 @@ class NavigationManager: self.dialog.history_pos = len(self.dialog.history) - 1 self.dialog.widget_manager.search_animation.stop() - - # Clear previous selection state before populating new view 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_selection_info() # Use the new central update method + 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.""" @@ -128,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) \ No newline at end of file diff --git a/custom_file_dialog/cfd_settings_dialog.py b/custom_file_dialog/cfd_settings_dialog.py index 5711c15..f5c6736 100644 --- a/custom_file_dialog/cfd_settings_dialog.py +++ b/custom_file_dialog/cfd_settings_dialog.py @@ -157,6 +157,21 @@ class SettingsDialog(tk.Toplevel): 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)) diff --git a/custom_file_dialog/cfd_sftp_manager.py b/custom_file_dialog/cfd_sftp_manager.py index da25d40..264d0b2 100644 --- a/custom_file_dialog/cfd_sftp_manager.py +++ b/custom_file_dialog/cfd_sftp_manager.py @@ -7,10 +7,18 @@ 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: @@ -27,15 +35,28 @@ class SFTPManager: password=password, key_filename=key_file, passphrase=passphrase, - timeout=10 + 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() @@ -43,6 +64,7 @@ class SFTPManager: if self.client: self.client.close() self.client = None + self.home_dir = None def list_directory(self, path): if not self.sftp: @@ -148,4 +170,4 @@ class SFTPManager: @property def is_connected(self): - return self.sftp is not None + return self.sftp is not None \ No newline at end of file diff --git a/custom_file_dialog/cfd_ui_setup.py b/custom_file_dialog/cfd_ui_setup.py index 5847f97..e56b9e2 100644 --- a/custom_file_dialog/cfd_ui_setup.py +++ b/custom_file_dialog/cfd_ui_setup.py @@ -283,8 +283,14 @@ 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']}")) @@ -314,12 +320,19 @@ class WidgetManager: 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("", lambda event, - n=name: self._show_sftp_bookmark_context_menu(event, n)) + btn.bind("", 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): + 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"], @@ -384,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)) diff --git a/custom_file_dialog/cfd_view_manager.py b/custom_file_dialog/cfd_view_manager.py index 150afdd..9ab8b6d 100644 --- a/custom_file_dialog/cfd_view_manager.py +++ b/custom_file_dialog/cfd_view_manager.py @@ -104,18 +104,26 @@ class ViewManager: def _get_folder_content_count(self, folder_path: str) -> Optional[int]: """ - Counts the number of items in a given folder. + Counts the number of items in a given folder, supporting both local and SFTP. """ - if self.dialog.current_fs_type == "sftp": - return None 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): @@ -618,9 +626,12 @@ class ViewManager: """ Programmatically selects a file in the current view. """ + is_sftp = self.dialog.current_fs_type == "sftp" + if self.dialog.view_mode.get() == "list": for item_id, path in self.dialog.item_path_map.items(): - if os.path.basename(path) == filename: + 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) @@ -630,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: diff --git a/custom_file_dialog/custom_file_dialog.py b/custom_file_dialog/custom_file_dialog.py index 5afde0c..bad6bbf 100644 --- a/custom_file_dialog/custom_file_dialog.py +++ b/custom_file_dialog/custom_file_dialog.py @@ -148,6 +148,8 @@ class CustomFileDialog(tk.Toplevel): 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"]) @@ -163,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( "", self.search_manager.execute_search) self.view_manager._update_view_mode_buttons() @@ -198,13 +204,38 @@ class CustomFileDialog(tk.Toplevel): 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['host'], - port=credentials['port'], - username=credentials['username'], - password=credentials['password'], - key_file=credentials['key_file'], - passphrase=credentials['passphrase'] + 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="") @@ -216,48 +247,78 @@ class CustomFileDialog(tk.Toplevel): initial_path = credentials.get("initial_path", "/") self.navigation_manager.navigate_to(initial_path) - - if is_new_connection: - save_bookmark = MessageDialog( - message_type="ask", - text="Connection successful. Save as bookmark?", - buttons=["Yes", "No"] - ).show() - - if save_bookmark: - bookmark_name = InputDialog( - self, - title="Save Bookmark", - prompt="Enter a name for the bookmark:", - initial_value=credentials['host'] - ).show() - - if bookmark_name: - self.config_manager.add_bookmark( - bookmark_name, credentials) - self.reload_config_and_rebuild_ui() else: MessageDialog(message_type="error", text=f"Connection failed: {message}").show() def connect_sftp_bookmark(self, data): - self.connect_sftp(data, is_new_connection=False) + 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) - def remove_sftp_bookmark(self, name): - confirm = MessageDialog( - message_type="ask", - text=f"Remove bookmark '{name}'?", - buttons=["Yes", "No"]).show() + 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): + 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") - self.navigation_manager.navigate_to(os.path.expanduser("~")) + 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": @@ -265,6 +326,12 @@ class CustomFileDialog(tk.Toplevel): 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) @@ -497,6 +564,15 @@ class CustomFileDialog(tk.Toplevel): """ self._update_disk_usage() status_text = "" + is_sftp = self.current_fs_type == 'sftp' + + # Helper to get basename safely + def get_basename(path): + if not path: + return "" + if is_sftp: + return path.split('/')[-1] + return os.path.basename(path) if self.dialog_mode == 'multi': selected_paths = self.result if isinstance( @@ -504,7 +580,7 @@ class CustomFileDialog(tk.Toplevel): self.widget_manager.filename_entry.delete(0, tk.END) if selected_paths: filenames = [ - f'"{os.path.basename(p)}"' for p in selected_paths] + f'"{get_basename(p)}"' for p in selected_paths] self.widget_manager.filename_entry.insert( 0, " ".join(filenames)) count = len(selected_paths) @@ -512,20 +588,27 @@ class CustomFileDialog(tk.Toplevel): else: status_text = "" else: - if status_info and (self.current_fs_type == 'sftp' or os.path.exists(status_info)): - self.result = status_info - self.widget_manager.filename_entry.delete(0, tk.END) - self.widget_manager.filename_entry.insert( - 0, os.path.basename(status_info)) - 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"'{os.path.basename(status_info)}' ({content_count} {LocaleStrings.CFD['entries']})" - else: - status_text = f"'{os.path.basename(status_info)}'" + path_exists = False + if status_info: + if is_sftp: + path_exists = self.sftp_manager.exists(status_info) else: - status_text = f"'{os.path.basename(status_info)}'" + 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 @@ -539,17 +622,17 @@ class CustomFileDialog(tk.Toplevel): self.widget_manager.storage_bar['value'] = 0 return try: + # This can fail on certain file types like symlinks to other filesystems. total, used, free = shutil.disk_usage(self.current_dir) free_str = self._format_size(free) self.widget_manager.storage_label.config( text=f"{LocaleStrings.CFD['free_space']}: {free_str}") self.widget_manager.storage_bar['value'] = (used / total) * 100 - except FileNotFoundError: + 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']}: {LocaleStrings.CFD['unknown']}") + text=f"{LocaleStrings.CFD['free_space']}: N/A") self.widget_manager.storage_bar['value'] = 0 - self.widget_manager.search_status_label.config( - text=LocaleStrings.CFD["directory_not_found"]) def on_open(self) -> None: """Handles the 'Open' or 'OK' action based on the dialog mode.""" diff --git a/message.py b/message.py index 5a0db90..1744158 100644 --- a/message.py +++ b/message.py @@ -143,16 +143,25 @@ class MessageDialog: class CredentialsDialog: """ - A dialog for securely entering SSH/SFTP credentials. + A dialog for securely entering and editing SSH/SFTP credentials. """ - def __init__(self, master: Optional[tk.Tk] = None, title: str = "SFTP Connection"): + 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)) @@ -168,7 +177,6 @@ class CredentialsDialog: # 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.insert(0, "22") self.port_entry.grid(row=1, column=1, sticky="w", pady=2) # Username @@ -177,9 +185,8 @@ class CredentialsDialog: self.username_entry.grid(row=2, column=1, sticky="ew", pady=2) # Initial Path - ttk.Label(frame, text="Initial Path:").grid(row=3, column=0, sticky="w", pady=2) + 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.insert(0, "~") self.path_entry.grid(row=3, column=1, sticky="ew", pady=2) # Auth Method @@ -213,15 +220,37 @@ class CredentialsDialog: 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=8, column=1, sticky="e", pady=(15, 0)) - connect_button = ttk.Button(button_frame, text="Connect", command=self._on_connect) + 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("", lambda event: self._on_connect()) self.window.protocol("WM_DELETE_WINDOW", self._on_cancel) @@ -233,6 +262,30 @@ class CredentialsDialog: 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 = [] @@ -283,7 +336,26 @@ class CredentialsDialog: 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), @@ -292,6 +364,8 @@ class CredentialsDialog: "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() @@ -304,6 +378,8 @@ class CredentialsDialog: return self.result + + class InputDialog: """ A simple dialog for getting a single line of text input from the user.