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:
@@ -90,6 +90,7 @@ _EN = {
|
||||
"show": "Show",
|
||||
"hide": "Hide",
|
||||
"alias_required": "Alias is required",
|
||||
"alias_exists": "Alias '{alias}' already exists",
|
||||
"ip_required": "IP is required",
|
||||
"port_must_be_number": "Port must be a number",
|
||||
"error_prefix": "Error: {msg}",
|
||||
@@ -355,6 +356,7 @@ _RU = {
|
||||
"show": "Показать",
|
||||
"hide": "Скрыть",
|
||||
"alias_required": "Алиас обязателен",
|
||||
"alias_exists": "Алиас '{alias}' уже существует",
|
||||
"ip_required": "IP обязателен",
|
||||
"port_must_be_number": "Порт должен быть числом",
|
||||
"error_prefix": "Ошибка: {msg}",
|
||||
@@ -620,6 +622,7 @@ _ZH = {
|
||||
"show": "显示",
|
||||
"hide": "隐藏",
|
||||
"alias_required": "别名不能为空",
|
||||
"alias_exists": "别名 '{alias}' 已存在",
|
||||
"ip_required": "IP不能为空",
|
||||
"port_must_be_number": "端口必须是数字",
|
||||
"error_prefix": "错误:{msg}",
|
||||
|
||||
@@ -357,7 +357,14 @@ class ServerStore:
|
||||
servers = self._data.get("servers", [])
|
||||
for i, s in enumerate(servers):
|
||||
if s["alias"] == alias:
|
||||
new_alias = updated.get("alias", alias)
|
||||
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._notify()
|
||||
return
|
||||
|
||||
@@ -214,6 +214,17 @@ class SessionPool:
|
||||
"""Clean up sessions when a server is deleted."""
|
||||
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:
|
||||
"""Get list of aliases for active sessions."""
|
||||
with self._lock:
|
||||
|
||||
@@ -129,6 +129,14 @@ class App(ctk.CTk):
|
||||
if server:
|
||||
dialog = ServerDialog(self, self.store, server=server)
|
||||
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()
|
||||
|
||||
def _delete_server(self, alias: str):
|
||||
|
||||
@@ -26,6 +26,7 @@ class ServerDialog(ctk.CTkToplevel):
|
||||
super().__init__(master)
|
||||
self.store = store
|
||||
self.editing = server
|
||||
self._original_alias = server["alias"] if server else None
|
||||
self.result = None
|
||||
|
||||
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)
|
||||
self.alias_entry = ctk.CTkEntry(self, placeholder_text=t("placeholder_alias"))
|
||||
self.alias_entry.pack(fill="x", **entry_pad)
|
||||
# Enable undo functionality
|
||||
self.alias_entry._entry.config(undo=True)
|
||||
|
||||
# IP
|
||||
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.pack(fill="x", **entry_pad)
|
||||
# Enable undo functionality
|
||||
self.ip_entry._entry.config(undo=True)
|
||||
|
||||
# Type + Port row
|
||||
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")
|
||||
self.port_entry = ctk.CTkEntry(port_frame, placeholder_text=t("placeholder_port"))
|
||||
self.port_entry.pack(fill="x")
|
||||
# Enable undo functionality
|
||||
self.port_entry._entry.config(undo=True)
|
||||
|
||||
# Network interface
|
||||
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)
|
||||
self.user_entry = ctk.CTkEntry(self, placeholder_text=t("placeholder_user"))
|
||||
self.user_entry.pack(fill="x", **entry_pad)
|
||||
# Enable undo functionality
|
||||
self.user_entry._entry.config(undo=True)
|
||||
|
||||
# Password
|
||||
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))
|
||||
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))
|
||||
# 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.pack(side="right")
|
||||
self._pass_visible = False
|
||||
@@ -106,6 +117,8 @@ class ServerDialog(ctk.CTkToplevel):
|
||||
self.totp_entry = ctk.CTkEntry(self, placeholder_text=t("placeholder_totp_secret"),
|
||||
font=ctk.CTkFont(family="Consolas", size=12))
|
||||
self.totp_entry.pack(fill="x", **entry_pad)
|
||||
# Enable undo functionality
|
||||
self.totp_entry._entry.config(undo=True)
|
||||
|
||||
# Skip status checks
|
||||
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)
|
||||
self.notes_entry = ctk.CTkEntry(self, placeholder_text=t("placeholder_notes"))
|
||||
self.notes_entry.pack(fill="x", **entry_pad)
|
||||
# Enable undo functionality
|
||||
self.notes_entry._entry.config(undo=True)
|
||||
|
||||
# Buttons
|
||||
btn_frame = ctk.CTkFrame(self, fg_color="transparent")
|
||||
@@ -128,7 +143,6 @@ class ServerDialog(ctk.CTkToplevel):
|
||||
# Fill values if editing
|
||||
if server:
|
||||
self.alias_entry.insert(0, server.get("alias", ""))
|
||||
self.alias_entry.configure(state="disabled")
|
||||
self.ip_entry.insert(0, server.get("ip", ""))
|
||||
self.type_var.set(server.get("type", "ssh"))
|
||||
self.port_entry.insert(0, str(server.get("port", 22)))
|
||||
@@ -213,7 +227,11 @@ class ServerDialog(ctk.CTkToplevel):
|
||||
|
||||
try:
|
||||
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:
|
||||
self.store.add_server(server_data)
|
||||
self.result = server_data
|
||||
|
||||
@@ -29,6 +29,8 @@ class Sidebar(ctk.CTkFrame):
|
||||
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.pack(fill="x", padx=10, pady=(5, 10))
|
||||
# Enable undo functionality
|
||||
self.search_entry._entry.config(undo=True)
|
||||
|
||||
# Server list
|
||||
self.list_frame = ctk.CTkScrollableFrame(self, fg_color="transparent")
|
||||
|
||||
@@ -131,6 +131,8 @@ class FilesTab(ctk.CTkFrame):
|
||||
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.bind("<Return>", lambda e: self._local_go_to_path())
|
||||
# Enable undo functionality
|
||||
self._local_path_entry._entry.config(undo=True)
|
||||
|
||||
self._local_list = FileListWidget(
|
||||
left_pane,
|
||||
@@ -177,6 +179,8 @@ class FilesTab(ctk.CTkFrame):
|
||||
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.bind("<Return>", lambda e: self._remote_go_to_path())
|
||||
# Enable undo functionality
|
||||
self._remote_path_entry._entry.config(undo=True)
|
||||
|
||||
self._remote_list = FileListWidget(
|
||||
right_pane,
|
||||
|
||||
@@ -106,6 +106,8 @@ class TOTPTab(ctk.CTkFrame):
|
||||
font=ctk.CTkFont(family="Consolas", size=12)
|
||||
)
|
||||
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(
|
||||
entry_row, text=t("show"), width=70,
|
||||
|
||||
182
plans/ctrl-z-undo-audit.md
Normal file
182
plans/ctrl-z-undo-audit.md
Normal 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)
|
||||
Reference in New Issue
Block a user