""" Main application window — sidebar + tabview layout. """ import tkinter import customtkinter as ctk from tkinter import messagebox from core.server_store import ServerStore from core.status_checker import StatusChecker from core import i18n from core.i18n import t, LANGUAGES from core.session_pool import SessionPool from gui.sidebar import Sidebar from gui.server_dialog import ServerDialog from gui.about_dialog import AboutDialog from gui.tabs.terminal_tab import TerminalTab from gui.tabs.files_tab import FilesTab from gui.tabs.info_tab import InfoTab from gui.tabs.keys_tab import KeysTab from gui.tabs.setup_tab import SetupTab from gui.tabs.totp_tab import TOTPTab class App(ctk.CTk): def __init__(self): super().__init__() # Window config self.title("ServerManager") self.geometry("1100x700") self.minsize(900, 500) ctk.set_appearance_mode("dark") ctk.set_default_color_theme("blue") # Core self.store = ServerStore() self.checker = StatusChecker(self.store) self.session_pool = SessionPool(max_sessions=5) # Create session pool # Layout self._build_layout() # Status checker self.checker.set_gui_callback(lambda: self.after(0, self._on_status_update)) self.checker.start() self.checker.check_all_now() # Fix Ctrl+V/C/A/X for non-Latin keyboard layouts (e.g. Russian) # Tkinter maps <> to by keysym, which fails when # the layout produces non-Latin characters. This fix uses keycodes instead. self._setup_keyboard_layout_fix() # Add Undo/Redo support for Entry widgets (tk.Entry has no built-in undo) self._setup_entry_undo() # Cleanup on close self.protocol("WM_DELETE_WINDOW", self._on_close) def _build_layout(self): # Sidebar self.sidebar = Sidebar(self, self.store, on_select=self._on_server_select, session_pool=self.session_pool) self.sidebar.pack(side="left", fill="y") self.sidebar.add_callback = self._add_server self.sidebar.edit_callback = self._edit_server self.sidebar.delete_callback = self._delete_server # Main area main = ctk.CTkFrame(self, fg_color="transparent") main.pack(side="right", fill="both", expand=True) # Header bar (language + about) header_bar = ctk.CTkFrame(main, fg_color="transparent", height=40) header_bar.pack(fill="x", padx=10, pady=(8, 0)) header_bar.pack_propagate(False) # Language selector lang_values = list(LANGUAGES.values()) current_display = LANGUAGES.get(i18n.get_language(), "English") self._lang_var = ctk.StringVar(value=current_display) self.lang_menu = ctk.CTkOptionMenu( header_bar, values=lang_values, variable=self._lang_var, width=110, height=30, command=self._change_language ) self.lang_menu.pack(side="right", padx=(5, 0)) # About button self.about_btn = ctk.CTkButton( header_bar, text="ⓘ", width=30, height=30, corner_radius=15, fg_color="#6b7280", hover_color="#4b5563", command=self._show_about ) self.about_btn.pack(side="right", padx=(5, 5)) # Tabview self.tabview = ctk.CTkTabview(main, command=self._on_tab_changed) self.tabview.pack(fill="both", expand=True, padx=10, pady=10) # Tab names stored for language updates self._tab_keys = ["terminal", "files", "info", "keys", "totp", "setup"] for key in self._tab_keys: self.tabview.add(t(key)) self.terminal_tab = TerminalTab(self.tabview.tab(t("terminal")), self.store, self.session_pool) self.terminal_tab.pack(fill="both", expand=True) self.files_tab = FilesTab(self.tabview.tab(t("files")), self.store, self.session_pool) self.files_tab.pack(fill="both", expand=True) self.info_tab = InfoTab(self.tabview.tab(t("info")), self.store, edit_callback=self._edit_server) self.info_tab.pack(fill="both", expand=True) self.keys_tab = KeysTab(self.tabview.tab(t("keys")), self.store) self.keys_tab.pack(fill="both", expand=True) self.totp_tab = TOTPTab(self.tabview.tab(t("totp")), self.store) self.totp_tab.pack(fill="both", expand=True) self.setup_tab = SetupTab(self.tabview.tab(t("setup")), self.store) self.setup_tab.pack(fill="both", expand=True) def _on_server_select(self, alias: str): self.terminal_tab.set_server(alias) self.files_tab.set_server(alias) self.info_tab.set_server(alias) self.keys_tab.set_server(alias) self.totp_tab.set_server(alias) # Update session indicators after a short delay (connection is async) self.after(1500, self.sidebar.update_session_indicators) def _add_server(self): dialog = ServerDialog(self, self.store) self.wait_window(dialog) def _edit_server(self, alias: str): server = self.store.get_server(alias) if server: dialog = ServerDialog(self, self.store, server=server) self.wait_window(dialog) if dialog.result and dialog.result.get("alias") != alias: new_alias = dialog.result["alias"] self.sidebar._select(new_alias) self.session_pool.rename_server(alias, new_alias) else: self.info_tab.refresh() def _delete_server(self, alias: str): if messagebox.askyesno(t("delete_server"), t("delete_confirm").format(alias=alias)): # Clean up sessions when deleting server self.session_pool.cleanup_deleted_server(alias) self.store.remove_server(alias) self._on_server_select(None) def _on_status_update(self): self.sidebar.update_statuses() self.sidebar.update_session_indicators() self.info_tab.refresh() def _show_about(self): AboutDialog(self) def _get_current_tab_key(self) -> str: """Get the i18n key of the currently active tab.""" try: current_name = self.tabview.get() # Match against current language translations for key in self._tab_keys: if t(key) == current_name: return key except Exception: pass return self._tab_keys[0] def _change_language(self, display_name: str): # Remember current tab KEY before language switch active_tab_key = self._get_current_tab_key() # Find lang code from display name lang_code = "en" for code, name in LANGUAGES.items(): if name == display_name: lang_code = code break i18n.set_language(lang_code) self.store._save_settings() self._apply_language(active_tab_key) def _apply_language(self, restore_tab_key: str | None = None): # Remember selected server alias = self.sidebar.get_selected() # Use provided key or default to first tab current_key = restore_tab_key or self._tab_keys[0] # Save state before destroying tabs saved_remote_path = self.files_tab._remote_path saved_local_path = self.files_tab._local_path had_sftp = self.files_tab._sftp is not None and self.files_tab._sftp.connected # Disconnect all sessions in the pool self.session_pool.disconnect_all() # Detach tab contents self.terminal_tab.pack_forget() self.files_tab.pack_forget() self.info_tab.pack_forget() self.keys_tab.pack_forget() self.totp_tab.pack_forget() self.setup_tab.pack_forget() # Get the main frame and destroy old tabview main = self.tabview.master self.tabview.destroy() # Create new tabview with translated names self.tabview = ctk.CTkTabview(main, command=self._on_tab_changed) self.tabview.pack(fill="both", expand=True, padx=10, pady=10) for key in self._tab_keys: self.tabview.add(t(key)) # Re-parent tab contents self.terminal_tab = TerminalTab(self.tabview.tab(t("terminal")), self.store, self.session_pool) self.terminal_tab.pack(fill="both", expand=True) self.files_tab = FilesTab(self.tabview.tab(t("files")), self.store, self.session_pool) self.files_tab.pack(fill="both", expand=True) self.info_tab = InfoTab(self.tabview.tab(t("info")), self.store, edit_callback=self._edit_server) self.info_tab.pack(fill="both", expand=True) self.keys_tab = KeysTab(self.tabview.tab(t("keys")), self.store) self.keys_tab.pack(fill="both", expand=True) self.totp_tab = TOTPTab(self.tabview.tab(t("totp")), self.store) self.totp_tab.pack(fill="both", expand=True) self.setup_tab = SetupTab(self.tabview.tab(t("setup")), self.store) self.setup_tab.pack(fill="both", expand=True) # Restore active tab by key try: self.tabview.set(t(current_key)) except Exception: pass # Restore file paths and reconnect properly self.files_tab._local_path = saved_local_path self.files_tab._refresh_local() if alias and had_sftp: # Had active SFTP — reconnect and restore remote path self.files_tab._remote_path = saved_remote_path self.files_tab.set_server(alias) elif alias: self.files_tab.set_server(alias) # Restore server selection for other tabs (terminal auto-reconnects) if alias: self.terminal_tab.set_server(alias) self.info_tab.set_server(alias) self.keys_tab.set_server(alias) self.totp_tab.set_server(alias) # Update sidebar self.sidebar.update_language() def _setup_entry_undo(self): """Add Undo/Redo support for tk.Entry widgets (they have no built-in undo). Tracks text changes per-widget and restores on <>/<>. """ undo_stacks: dict[str, list[str]] = {} redo_stacks: dict[str, list[str]] = {} def _save_state(event): w = event.widget wid = str(w) try: current = w.get() except Exception: return stack = undo_stacks.setdefault(wid, []) if not stack or stack[-1] != current: stack.append(current) if len(stack) > 100: stack.pop(0) # Clear redo on new input redo_stacks.pop(wid, None) def _on_undo(event): w = event.widget wid = str(w) try: current = w.get() except Exception: return "break" stack = undo_stacks.get(wid, []) # Pop current state while stack and stack[-1] == current: stack.pop() if stack: prev = stack[-1] redo_stacks.setdefault(wid, []).append(current) w.delete(0, "end") w.insert(0, prev) return "break" def _on_redo(event): w = event.widget wid = str(w) try: current = w.get() except Exception: return "break" stack = redo_stacks.get(wid, []) if stack: next_val = stack.pop() undo_stacks.setdefault(wid, []).append(current) w.delete(0, "end") w.insert(0, next_val) return "break" # Add class-level bindings for Entry self.tk.eval(''' bind Entry <> {} bind Entry <> {} ''') tkinter.Misc.bind_class(self, "Entry", "", _save_state, "+") tkinter.Misc.bind_class(self, "Entry", "<>", _on_undo) tkinter.Misc.bind_class(self, "Entry", "<>", _on_redo) def _setup_keyboard_layout_fix(self): """Fix Ctrl+V/C/X/A/Z for non-Latin keyboard layouts (Russian, Chinese, etc.). Tkinter binds <> to by keysym. When the keyboard layout is Russian, pressing Ctrl+V produces which doesn't match. This fix intercepts at the 'all' level by keycode (layout-independent) and generates the correct virtual events. """ # Keycode → virtual event mapping (physical keys, layout-independent) keycode_to_event = { 86: "<>", # V 67: "<>", # C 88: "<>", # X 65: "<>", # A 90: "<>", # Z 89: "<>", # Y } def _on_ctrl_key_global(event): # Only act when Ctrl is held (not Ctrl+Shift, etc.) if not (event.state & 0x4): return # Skip if keysym is already Latin (standard handling works) if event.keysym in ('v', 'V', 'c', 'C', 'x', 'X', 'a', 'A', 'z', 'Z', 'y', 'Y'): return virtual = keycode_to_event.get(event.keycode) if virtual: event.widget.event_generate(virtual) return "break" # Bypass CTk's bind_all restriction by calling tkinter.Misc directly tkinter.Misc.bind_all(self, "", _on_ctrl_key_global, "+") def _on_tab_changed(self): """Handle tab switch — manage terminal focus.""" try: current = self.tabview.get() if current == t("terminal"): self.terminal_tab._terminal.focus_terminal() else: self.focus_set() except Exception: pass def _on_close(self): # Disconnect all sessions before closing self.session_pool.disconnect_all() self.checker.stop() self.destroy()