fix: editable alias in server dialog + Ctrl+Z undo for all input fields

- Alias field no longer disabled when editing server profile
- Duplicate alias check on rename, session pool migration
- Enable undo (Ctrl+Z) on all CTkEntry widgets across the project

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
chrome-storm-c442
2026-02-24 05:17:21 -05:00
parent efb508c982
commit 7b0e7dd6ac
9 changed files with 240 additions and 3 deletions

View File

@@ -90,6 +90,7 @@ _EN = {
"show": "Show", "show": "Show",
"hide": "Hide", "hide": "Hide",
"alias_required": "Alias is required", "alias_required": "Alias is required",
"alias_exists": "Alias '{alias}' already exists",
"ip_required": "IP is required", "ip_required": "IP is required",
"port_must_be_number": "Port must be a number", "port_must_be_number": "Port must be a number",
"error_prefix": "Error: {msg}", "error_prefix": "Error: {msg}",
@@ -355,6 +356,7 @@ _RU = {
"show": "Показать", "show": "Показать",
"hide": "Скрыть", "hide": "Скрыть",
"alias_required": "Алиас обязателен", "alias_required": "Алиас обязателен",
"alias_exists": "Алиас '{alias}' уже существует",
"ip_required": "IP обязателен", "ip_required": "IP обязателен",
"port_must_be_number": "Порт должен быть числом", "port_must_be_number": "Порт должен быть числом",
"error_prefix": "Ошибка: {msg}", "error_prefix": "Ошибка: {msg}",
@@ -620,6 +622,7 @@ _ZH = {
"show": "显示", "show": "显示",
"hide": "隐藏", "hide": "隐藏",
"alias_required": "别名不能为空", "alias_required": "别名不能为空",
"alias_exists": "别名 '{alias}' 已存在",
"ip_required": "IP不能为空", "ip_required": "IP不能为空",
"port_must_be_number": "端口必须是数字", "port_must_be_number": "端口必须是数字",
"error_prefix": "错误:{msg}", "error_prefix": "错误:{msg}",

View File

@@ -357,7 +357,14 @@ class ServerStore:
servers = self._data.get("servers", []) servers = self._data.get("servers", [])
for i, s in enumerate(servers): for i, s in enumerate(servers):
if s["alias"] == alias: if s["alias"] == alias:
new_alias = updated.get("alias", alias)
servers[i] = updated servers[i] = updated
# If alias changed, migrate status
if new_alias != alias:
with self._statuses_lock:
old_status = self._statuses.pop(alias, None)
if old_status:
self._statuses[new_alias] = old_status
self._save() self._save()
self._notify() self._notify()
return return

View File

@@ -214,6 +214,17 @@ class SessionPool:
"""Clean up sessions when a server is deleted.""" """Clean up sessions when a server is deleted."""
self.disconnect_session(alias) self.disconnect_session(alias)
def rename_server(self, old_alias: str, new_alias: str):
"""Rename a server's session references (after alias change)."""
with self._lock:
if old_alias in self._sessions:
session_data = self._sessions.pop(old_alias)
session_data.alias = new_alias
self._sessions[new_alias] = session_data
if old_alias in self._last_used_order:
ts = self._last_used_order.pop(old_alias)
self._last_used_order[new_alias] = ts
def get_active_sessions(self) -> list: def get_active_sessions(self) -> list:
"""Get list of aliases for active sessions.""" """Get list of aliases for active sessions."""
with self._lock: with self._lock:

View File

@@ -129,6 +129,14 @@ class App(ctk.CTk):
if server: if server:
dialog = ServerDialog(self, self.store, server=server) dialog = ServerDialog(self, self.store, server=server)
self.wait_window(dialog) self.wait_window(dialog)
# If alias was changed, re-select the new alias
if dialog.result and dialog.result.get("alias") != alias:
new_alias = dialog.result["alias"]
# Sidebar auto-refreshes via store subscription
self.sidebar._select(new_alias)
# Migrate session pool reference
self.session_pool.rename_server(alias, new_alias)
else:
self.info_tab.refresh() self.info_tab.refresh()
def _delete_server(self, alias: str): def _delete_server(self, alias: str):

View File

@@ -26,6 +26,7 @@ class ServerDialog(ctk.CTkToplevel):
super().__init__(master) super().__init__(master)
self.store = store self.store = store
self.editing = server self.editing = server
self._original_alias = server["alias"] if server else None
self.result = None self.result = None
self.title(t("edit_server") if server else t("add_server")) self.title(t("edit_server") if server else t("add_server"))
@@ -46,11 +47,15 @@ class ServerDialog(ctk.CTkToplevel):
ctk.CTkLabel(self, text=t("alias"), anchor="w").pack(fill="x", **pad) ctk.CTkLabel(self, text=t("alias"), anchor="w").pack(fill="x", **pad)
self.alias_entry = ctk.CTkEntry(self, placeholder_text=t("placeholder_alias")) self.alias_entry = ctk.CTkEntry(self, placeholder_text=t("placeholder_alias"))
self.alias_entry.pack(fill="x", **entry_pad) self.alias_entry.pack(fill="x", **entry_pad)
# Enable undo functionality
self.alias_entry._entry.config(undo=True)
# IP # IP
ctk.CTkLabel(self, text=t("ip"), anchor="w").pack(fill="x", **pad) ctk.CTkLabel(self, text=t("ip"), anchor="w").pack(fill="x", **pad)
self.ip_entry = ctk.CTkEntry(self, placeholder_text=t("placeholder_ip")) self.ip_entry = ctk.CTkEntry(self, placeholder_text=t("placeholder_ip"))
self.ip_entry.pack(fill="x", **entry_pad) self.ip_entry.pack(fill="x", **entry_pad)
# Enable undo functionality
self.ip_entry._entry.config(undo=True)
# Type + Port row # Type + Port row
row = ctk.CTkFrame(self, fg_color="transparent") row = ctk.CTkFrame(self, fg_color="transparent")
@@ -71,6 +76,8 @@ class ServerDialog(ctk.CTkToplevel):
ctk.CTkLabel(port_frame, text=t("port"), anchor="w").pack(fill="x") ctk.CTkLabel(port_frame, text=t("port"), anchor="w").pack(fill="x")
self.port_entry = ctk.CTkEntry(port_frame, placeholder_text=t("placeholder_port")) self.port_entry = ctk.CTkEntry(port_frame, placeholder_text=t("placeholder_port"))
self.port_entry.pack(fill="x") self.port_entry.pack(fill="x")
# Enable undo functionality
self.port_entry._entry.config(undo=True)
# Network interface # Network interface
ctk.CTkLabel(self, text=t("network_interface"), anchor="w").pack(fill="x", **pad) ctk.CTkLabel(self, text=t("network_interface"), anchor="w").pack(fill="x", **pad)
@@ -90,6 +97,8 @@ class ServerDialog(ctk.CTkToplevel):
ctk.CTkLabel(self, text=t("username"), anchor="w").pack(fill="x", **pad) ctk.CTkLabel(self, text=t("username"), anchor="w").pack(fill="x", **pad)
self.user_entry = ctk.CTkEntry(self, placeholder_text=t("placeholder_user")) self.user_entry = ctk.CTkEntry(self, placeholder_text=t("placeholder_user"))
self.user_entry.pack(fill="x", **entry_pad) self.user_entry.pack(fill="x", **entry_pad)
# Enable undo functionality
self.user_entry._entry.config(undo=True)
# Password # Password
ctk.CTkLabel(self, text=t("password"), anchor="w").pack(fill="x", **pad) ctk.CTkLabel(self, text=t("password"), anchor="w").pack(fill="x", **pad)
@@ -97,6 +106,8 @@ class ServerDialog(ctk.CTkToplevel):
pass_frame.pack(fill="x", padx=20, pady=(2, 5)) pass_frame.pack(fill="x", padx=20, pady=(2, 5))
self.password_entry = ctk.CTkEntry(pass_frame, show="*", placeholder_text=t("placeholder_password")) self.password_entry = ctk.CTkEntry(pass_frame, show="*", placeholder_text=t("placeholder_password"))
self.password_entry.pack(side="left", fill="x", expand=True, padx=(0, 5)) self.password_entry.pack(side="left", fill="x", expand=True, padx=(0, 5))
# Enable undo functionality
self.password_entry._entry.config(undo=True)
self.show_pass = ctk.CTkButton(pass_frame, text=t("show"), width=60, command=self._toggle_password) self.show_pass = ctk.CTkButton(pass_frame, text=t("show"), width=60, command=self._toggle_password)
self.show_pass.pack(side="right") self.show_pass.pack(side="right")
self._pass_visible = False self._pass_visible = False
@@ -106,6 +117,8 @@ class ServerDialog(ctk.CTkToplevel):
self.totp_entry = ctk.CTkEntry(self, placeholder_text=t("placeholder_totp_secret"), self.totp_entry = ctk.CTkEntry(self, placeholder_text=t("placeholder_totp_secret"),
font=ctk.CTkFont(family="Consolas", size=12)) font=ctk.CTkFont(family="Consolas", size=12))
self.totp_entry.pack(fill="x", **entry_pad) self.totp_entry.pack(fill="x", **entry_pad)
# Enable undo functionality
self.totp_entry._entry.config(undo=True)
# Skip status checks # Skip status checks
self.skip_check_var = ctk.BooleanVar(value=False) self.skip_check_var = ctk.BooleanVar(value=False)
@@ -118,6 +131,8 @@ class ServerDialog(ctk.CTkToplevel):
ctk.CTkLabel(self, text=t("notes"), anchor="w").pack(fill="x", **pad) ctk.CTkLabel(self, text=t("notes"), anchor="w").pack(fill="x", **pad)
self.notes_entry = ctk.CTkEntry(self, placeholder_text=t("placeholder_notes")) self.notes_entry = ctk.CTkEntry(self, placeholder_text=t("placeholder_notes"))
self.notes_entry.pack(fill="x", **entry_pad) self.notes_entry.pack(fill="x", **entry_pad)
# Enable undo functionality
self.notes_entry._entry.config(undo=True)
# Buttons # Buttons
btn_frame = ctk.CTkFrame(self, fg_color="transparent") btn_frame = ctk.CTkFrame(self, fg_color="transparent")
@@ -128,7 +143,6 @@ class ServerDialog(ctk.CTkToplevel):
# Fill values if editing # Fill values if editing
if server: if server:
self.alias_entry.insert(0, server.get("alias", "")) self.alias_entry.insert(0, server.get("alias", ""))
self.alias_entry.configure(state="disabled")
self.ip_entry.insert(0, server.get("ip", "")) self.ip_entry.insert(0, server.get("ip", ""))
self.type_var.set(server.get("type", "ssh")) self.type_var.set(server.get("type", "ssh"))
self.port_entry.insert(0, str(server.get("port", 22))) self.port_entry.insert(0, str(server.get("port", 22)))
@@ -213,7 +227,11 @@ class ServerDialog(ctk.CTkToplevel):
try: try:
if self.editing: if self.editing:
self.store.update_server(alias, server_data) # Check for alias conflict if alias was changed
if alias != self._original_alias and self.store.get_server(alias):
self._show_error(t("alias_exists").format(alias=alias))
return
self.store.update_server(self._original_alias, server_data)
else: else:
self.store.add_server(server_data) self.store.add_server(server_data)
self.result = server_data self.result = server_data

View File

@@ -29,6 +29,8 @@ class Sidebar(ctk.CTkFrame):
self.search_var.trace_add("write", lambda *_: self._refresh_list()) self.search_var.trace_add("write", lambda *_: self._refresh_list())
self.search_entry = ctk.CTkEntry(self, placeholder_text=t("search"), textvariable=self.search_var) self.search_entry = ctk.CTkEntry(self, placeholder_text=t("search"), textvariable=self.search_var)
self.search_entry.pack(fill="x", padx=10, pady=(5, 10)) self.search_entry.pack(fill="x", padx=10, pady=(5, 10))
# Enable undo functionality
self.search_entry._entry.config(undo=True)
# Server list # Server list
self.list_frame = ctk.CTkScrollableFrame(self, fg_color="transparent") self.list_frame = ctk.CTkScrollableFrame(self, fg_color="transparent")

View File

@@ -131,6 +131,8 @@ class FilesTab(ctk.CTkFrame):
self._local_path_entry = ctk.CTkEntry(left_header, height=28) self._local_path_entry = ctk.CTkEntry(left_header, height=28)
self._local_path_entry.pack(side="left", fill="x", expand=True, padx=(4, 0)) self._local_path_entry.pack(side="left", fill="x", expand=True, padx=(4, 0))
self._local_path_entry.bind("<Return>", lambda e: self._local_go_to_path()) self._local_path_entry.bind("<Return>", lambda e: self._local_go_to_path())
# Enable undo functionality
self._local_path_entry._entry.config(undo=True)
self._local_list = FileListWidget( self._local_list = FileListWidget(
left_pane, left_pane,
@@ -177,6 +179,8 @@ class FilesTab(ctk.CTkFrame):
self._remote_path_entry = ctk.CTkEntry(right_header, height=28) self._remote_path_entry = ctk.CTkEntry(right_header, height=28)
self._remote_path_entry.pack(side="left", fill="x", expand=True, padx=(4, 0)) self._remote_path_entry.pack(side="left", fill="x", expand=True, padx=(4, 0))
self._remote_path_entry.bind("<Return>", lambda e: self._remote_go_to_path()) self._remote_path_entry.bind("<Return>", lambda e: self._remote_go_to_path())
# Enable undo functionality
self._remote_path_entry._entry.config(undo=True)
self._remote_list = FileListWidget( self._remote_list = FileListWidget(
right_pane, right_pane,

View File

@@ -106,6 +106,8 @@ class TOTPTab(ctk.CTkFrame):
font=ctk.CTkFont(family="Consolas", size=12) font=ctk.CTkFont(family="Consolas", size=12)
) )
self.secret_entry.pack(side="left", fill="x", expand=True, padx=(0, 5)) self.secret_entry.pack(side="left", fill="x", expand=True, padx=(0, 5))
# Enable undo functionality
self.secret_entry._entry.config(undo=True)
self.show_secret_btn = ctk.CTkButton( self.show_secret_btn = ctk.CTkButton(
entry_row, text=t("show"), width=70, entry_row, text=t("show"), width=70,

182
plans/ctrl-z-undo-audit.md Normal file
View File

@@ -0,0 +1,182 @@
# Ctrl+Z Undo Functionality Audit
## Executive Summary
This audit identifies all text input fields in the ServerManager project and analyzes their undo functionality. The primary issue is that CTkEntry widgets do not have undo enabled by default, causing Ctrl+Z to fail in forms like the server edit dialog.
## Input Widgets Inventory
### 1. Server Dialog (`gui/server_dialog.py`)
- **Lines 48, 53, 73, 92, 99, 107, 120**: CTkEntry widgets for server form fields
- `alias_entry`: Server alias field
- `ip_entry`: IP address field
- `port_entry`: Port field
- `user_entry`: Username field
- `password_entry`: Password field (masked)
- `totp_entry`: TOTP secret field
- `notes_entry`: Notes field
- **Issue**: None have undo enabled by default
- **Status**: BROKEN - Ctrl+Z does not work
### 2. Sidebar (`gui/sidebar.py`)
- **Line 30**: `search_entry` - CTkEntry for server search
- **Issue**: No undo enabled
- **Status**: BROKEN - Ctrl+Z does not work
### 3. TOTP Tab (`gui/tabs/totp_tab.py`)
- **Line 103**: `secret_entry` - CTkEntry for TOTP secret (masked)
- **Issue**: No undo enabled
- **Status**: BROKEN - Ctrl+Z does not work
### 4. Files Tab (`gui/tabs/files_tab.py`)
- **Line 131**: `_local_path_entry` - CTkEntry for local path input
- **Line 177**: `_remote_path_entry` - CTkEntry for remote path input
- **Issue**: No undo enabled
- **Status**: BROKEN - Ctrl+Z does not work
### 5. Keys Tab (`gui/tabs/keys_tab.py`)
- **Lines 25, 42**: CTkTextbox widgets (used as read-only displays)
- **Status**: Not applicable (disabled state prevents editing)
### 6. Setup Tab (`gui/tabs/setup_tab.py`)
- **Line 208**: `log` - CTkTextbox for installation logs (disabled state)
- **Status**: Not applicable (disabled state prevents editing)
### 7. Terminal Widget (`gui/widgets/terminal_widget.py`)
- **Line 208**: `_text` - tk.Text widget for terminal emulation
- **Status**: FUNCTIONAL - Has dedicated Ctrl+Z handler that sends `\x1a` to SSH session (not traditional undo)
- **Note**: This is intentional behavior for terminal functionality
## Root Cause Analysis
### Primary Issue
CustomTkinter's `CTkEntry` wraps `tk.Entry` internally but does not enable undo by default. Unlike `tk.Text` which has undo enabled by default, `tk.Entry` has `undo=False` by default.
### Technical Details
1. **Default Behavior**: `tk.Entry` (and by extension `CTkEntry`) has `undo=False` by default
2. **Undo Requirement**: To enable Ctrl+Z, `undo=True` must be explicitly set on the underlying tk.Entry
3. **CustomTkinter Limitation**: CTkEntry does not expose the undo parameter in its constructor
4. **Terminal Override**: The terminal widget has a specific handler for Ctrl+Z that sends `\x1a` (EOF signal) rather than performing text undo
## Specific Fixes
### Fix 1: Server Dialog (Main Bug Report)
**File**: `gui/server_dialog.py`
**Lines**: 48, 53, 73, 92, 99, 107, 120
**Current Code**:
```python
self.alias_entry = ctk.CTkEntry(self, placeholder_text=t("placeholder_alias"))
self.ip_entry = ctk.CTkEntry(self, placeholder_text=t("placeholder_ip"))
# ... other entries
```
**Fixed Code**:
```python
self.alias_entry = ctk.CTkEntry(self, placeholder_text=t("placeholder_alias"))
self.alias_entry.configure(state="normal", undo=True) # Enable undo on internal tk.Entry
self.ip_entry = ctk.CTkEntry(self, placeholder_text=t("placeholder_ip"))
self.ip_entry.configure(state="normal", undo=True) # Enable undo on internal tk.Entry
# ... apply same fix to all other CTkEntry instances
```
**Alternative Fix** (Better approach):
Since CTkEntry doesn't directly support undo parameter, we need to access the internal tk.Entry widget:
```python
# After creating CTkEntry, access the internal tk.Entry
self.alias_entry = ctk.CTkEntry(self, placeholder_text=t("placeholder_alias"))
internal_tk_entry = self.alias_entry._entry # Access internal tk.Entry
internal_tk_entry.config(undo=True) # Enable undo
```
### Fix 2: Sidebar Search
**File**: `gui/sidebar.py`
**Line**: 30
**Current Code**:
```python
self.search_entry = ctk.CTkEntry(self, placeholder_text=t("search"), textvariable=self.search_var)
```
**Fixed Code**:
```python
self.search_entry = ctk.CTkEntry(self, placeholder_text=t("search"), textvariable=self.search_var)
internal_tk_entry = self.search_entry._entry
internal_tk_entry.config(undo=True)
```
### Fix 3: TOTP Tab Secret Entry
**File**: `gui/tabs/totp_tab.py`
**Line**: 103
**Current Code**:
```python
self.secret_entry = ctk.CTkEntry(
entry_row, show="*",
placeholder_text=t("totp_secret_placeholder"),
font=ctk.CTkFont(family="Consolas", size=12)
)
```
**Fixed Code**:
```python
self.secret_entry = ctk.CTkEntry(
entry_row, show="*",
placeholder_text=t("totp_secret_placeholder"),
font=ctk.CTkFont(family="Consolas", size=12)
)
internal_tk_entry = self.secret_entry._entry
internal_tk_entry.config(undo=True)
```
### Fix 4: Files Tab Path Entries
**File**: `gui/tabs/files_tab.py`
**Lines**: 131, 177
**Current Code**:
```python
self._local_path_entry = ctk.CTkEntry(left_header, height=28)
self._remote_path_entry = ctk.CTkEntry(right_header, height=28)
```
**Fixed Code**:
```python
self._local_path_entry = ctk.CTkEntry(left_header, height=28)
internal_tk_entry = self._local_path_entry._entry
internal_tk_entry.config(undo=True)
self._remote_path_entry = ctk.CTkEntry(right_header, height=28)
internal_tk_entry = self._remote_path_entry._entry
internal_tk_entry.config(undo=True)
```
## Additional Considerations
### Keyboard Event Handlers
The terminal widget (`gui/widgets/terminal_widget.py`) has extensive keyboard event handlers that intercept Ctrl+Z:
- **Line 222**: `<Control-z>` bound to `_on_ctrl_z` which sends `\x1a` to SSH
- **Line 228**: `<Control-Key>` handler that processes Ctrl+key combinations by keycode
- **Lines 700-719**: Physical keycode mapping for layout-independent Ctrl+key handling
This is intentional behavior for terminal functionality and should remain unchanged.
### Potential Side Effects
Enabling undo on password fields (lines 99 in server_dialog.py) could theoretically allow users to undo password entry, though this is generally harmless since passwords are masked.
## Recommended Action Plan
1. **Immediate**: Apply the internal tk.Entry configuration fix to all CTkEntry widgets
2. **Verification**: Test Ctrl+Z functionality in all form fields
3. **Documentation**: Add a utility function to simplify undo-enabled CTkEntry creation
4. **Future Enhancement**: Request CustomTkinter to add native undo support to CTkEntry
## Risk Assessment
- **Low Risk**: The fix involves accessing internal widgets and configuring them, which is safe and commonly done
- **Compatibility**: Changes are backward compatible
- **Performance**: No performance impact expected
- **Scope**: Limited to input field configuration
## Test Cases
After implementing the fix, verify:
1. Ctrl+Z works in server edit form fields
2. Ctrl+Y works for redo (if supported)
3. Multiple undo levels work properly
4. Text deletion/replacement can be undone
5. Terminal functionality remains intact (Ctrl+Z still sends EOF to SSH)