v1.8.3: session pool + sidebar indicators

- SessionPool: LRU cache for SSH/SFTP sessions across server switches
- Sidebar: green dot indicators for servers with active sessions
- Sidebar: active sessions count label
- Terminal: buffer preservation on server switch via get_current_buffer()
- FilesTab/TerminalTab: pool integration for session reuse

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
chrome-storm-c442
2026-02-24 03:05:05 -05:00
parent 1a7b075cca
commit 8f55b210b3
9 changed files with 426 additions and 31 deletions

View File

@@ -284,6 +284,7 @@ _EN = {
"drop_to_download": "Drop to download", "drop_to_download": "Drop to download",
"recursive_delete_confirm": "Delete folder '{name}' and all contents?", "recursive_delete_confirm": "Delete folder '{name}' and all contents?",
"drive": "Drive", "drive": "Drive",
"active_sessions": "Active: {count}",
} }
_RU = { _RU = {
@@ -545,6 +546,7 @@ _RU = {
"drop_to_download": "Отпустите для скачивания", "drop_to_download": "Отпустите для скачивания",
"recursive_delete_confirm": "Удалить папку '{name}' со всем содержимым?", "recursive_delete_confirm": "Удалить папку '{name}' со всем содержимым?",
"drive": "Диск", "drive": "Диск",
"active_sessions": "Активных: {count}",
} }
_ZH = { _ZH = {
@@ -806,6 +808,7 @@ _ZH = {
"drop_to_download": "释放以下载", "drop_to_download": "释放以下载",
"recursive_delete_confirm": "删除文件夹 '{name}' 及所有内容?", "recursive_delete_confirm": "删除文件夹 '{name}' 及所有内容?",
"drive": "驱动器", "drive": "驱动器",
"active_sessions": "活跃: {count}",
} }
_TRANSLATIONS = { _TRANSLATIONS = {

228
core/session_pool.py Normal file
View File

@@ -0,0 +1,228 @@
"""
Session pool for managing SSH and SFTP sessions to avoid reconnecting when switching between servers.
"""
import threading
import time
from collections import OrderedDict
from typing import Dict, Optional, Tuple
from core.ssh_client import ShellSession, SFTPSession
class SessionData:
"""Container for session data including the actual sessions and their metadata."""
def __init__(self, alias: str, server: dict, key_path: str):
self.alias = alias
self.server = server
self.key_path = key_path
self.shell_session: Optional[ShellSession] = None
self.sftp_session: Optional[SFTPSession] = None
self.last_access_time = time.time()
# State preservation for sessions
self.terminal_buffer: bytes = b""
self.remote_path: str = "/"
self.sudo_mode: bool = False
def cleanup(self):
"""Clean up sessions."""
if self.shell_session:
self.shell_session.disconnect()
self.shell_session = None
if self.sftp_session:
self.sftp_session.disconnect()
self.sftp_session = None
class SessionPool:
"""
Manages a pool of SSH/SFTP sessions to keep connections alive when switching between servers.
Features:
- Caches sessions per server alias
- Keeps idle sessions alive with keepalive
- Maintains session state (terminal buffer, remote path)
- LRU eviction for max sessions limit
- Thread-safe operations
"""
def __init__(self, max_sessions: int = 5):
self.max_sessions = max_sessions
self._sessions: Dict[str, SessionData] = {}
self._lock = threading.RLock() # Reentrant lock for thread safety
self._last_used_order = OrderedDict() # Track access order for LRU
def get_or_create_shell_session(self, alias: str, server: dict, key_path: str) -> Tuple[ShellSession, bool]:
"""
Get existing shell session or create a new one.
Args:
alias: Server alias
server: Server configuration dict
key_path: Path to SSH key
Returns:
Tuple of (session, is_new_session)
"""
with self._lock:
# Get or create session data
if alias not in self._sessions:
session_data = SessionData(alias, server, key_path)
self._sessions[alias] = session_data
else:
session_data = self._sessions[alias]
# Update access time for LRU
self._update_last_access(alias)
# Create shell session if needed
if session_data.shell_session is None or not session_data.shell_session.connected:
shell_session = ShellSession(server, key_path)
session_data.shell_session = shell_session
# Restore terminal buffer if we have one
if session_data.terminal_buffer:
# We can't directly restore the buffer since terminal handles its own state
# But we remember the fact that we had buffered data
pass
return shell_session, True
return session_data.shell_session, False
def get_or_create_sftp_session(self, alias: str, server: dict, key_path: str) -> Tuple[SFTPSession, bool]:
"""
Get existing SFTP session or create a new one.
Args:
alias: Server alias
server: Server configuration dict
key_path: Path to SSH key
Returns:
Tuple of (session, is_new_session)
"""
with self._lock:
# Get or create session data
if alias not in self._sessions:
session_data = SessionData(alias, server, key_path)
self._sessions[alias] = session_data
else:
session_data = self._sessions[alias]
# Update access time for LRU
self._update_last_access(alias)
# Create SFTP session if needed
if session_data.sftp_session is None or not session_data.sftp_session.connected:
sftp_session = SFTPSession(server, key_path)
session_data.sftp_session = sftp_session
sftp_session.sudo_mode = session_data.sudo_mode
return sftp_session, True
return session_data.sftp_session, False
def activate_shell_session(self, alias: str, server: dict, key_path: str) -> ShellSession:
"""
Activate a shell session for the given alias (or create if needed).
Updates access time and ensures session is ready.
"""
session, _ = self.get_or_create_shell_session(alias, server, key_path)
with self._lock:
self._update_last_access(alias)
return session
def activate_sftp_session(self, alias: str, server: dict, key_path: str) -> SFTPSession:
"""
Activate an SFTP session for the given alias (or create if needed).
Updates access time and ensures session is ready.
"""
session, _ = self.get_or_create_sftp_session(alias, server, key_path)
with self._lock:
self._update_last_access(alias)
# Apply stored state
session_data = self._sessions[alias]
session.sudo_mode = session_data.sudo_mode
return session
def store_shell_state(self, alias: str, terminal_buffer: bytes):
"""Store terminal state when switching away from a server."""
with self._lock:
if alias in self._sessions:
self._sessions[alias].terminal_buffer = terminal_buffer
self._update_last_access(alias)
def store_sftp_state(self, alias: str, remote_path: str, sudo_mode: bool):
"""Store SFTP state when switching away from a server."""
with self._lock:
if alias in self._sessions:
session_data = self._sessions[alias]
session_data.remote_path = remote_path
session_data.sudo_mode = sudo_mode
self._update_last_access(alias)
def get_shell_state(self, alias: str) -> bytes:
"""Retrieve terminal state when switching back to a server."""
with self._lock:
if alias in self._sessions:
self._update_last_access(alias)
return self._sessions[alias].terminal_buffer
return b""
def get_sftp_state(self, alias: str) -> Tuple[str, bool]:
"""Retrieve SFTP state when switching back to a server."""
with self._lock:
if alias in self._sessions:
session_data = self._sessions[alias]
self._update_last_access(alias)
return session_data.remote_path, session_data.sudo_mode
return "/", False
def _update_last_access(self, alias: str):
"""Update the last access time for the given alias."""
if alias in self._last_used_order:
del self._last_used_order[alias]
self._last_used_order[alias] = time.time()
# Enforce max sessions limit using LRU
while len(self._last_used_order) > self.max_sessions:
oldest_alias, _ = self._last_used_order.popitem(last=False)
if oldest_alias in self._sessions:
old_session = self._sessions[oldest_alias]
old_session.cleanup()
del self._sessions[oldest_alias]
def disconnect_session(self, alias: str):
"""Explicitly disconnect a session."""
with self._lock:
if alias in self._sessions:
session_data = self._sessions[alias]
session_data.cleanup()
del self._sessions[alias]
if alias in self._last_used_order:
del self._last_used_order[alias]
def disconnect_all(self):
"""Disconnect all sessions."""
with self._lock:
for session_data in self._sessions.values():
session_data.cleanup()
self._sessions.clear()
self._last_used_order.clear()
def cleanup_deleted_server(self, alias: str):
"""Clean up sessions when a server is deleted."""
self.disconnect_session(alias)
def get_active_sessions(self) -> list:
"""Get list of aliases for active sessions."""
with self._lock:
active = []
for alias, session_data in self._sessions.items():
has_active = (
(session_data.shell_session and session_data.shell_session.connected) or
(session_data.sftp_session and session_data.sftp_session.connected)
)
if has_active:
active.append(alias)
return active

View File

@@ -9,6 +9,7 @@ from core.server_store import ServerStore
from core.status_checker import StatusChecker from core.status_checker import StatusChecker
from core import i18n from core import i18n
from core.i18n import t, LANGUAGES from core.i18n import t, LANGUAGES
from core.session_pool import SessionPool
from gui.sidebar import Sidebar from gui.sidebar import Sidebar
from gui.server_dialog import ServerDialog from gui.server_dialog import ServerDialog
from gui.about_dialog import AboutDialog from gui.about_dialog import AboutDialog
@@ -35,6 +36,7 @@ class App(ctk.CTk):
# Core # Core
self.store = ServerStore() self.store = ServerStore()
self.checker = StatusChecker(self.store) self.checker = StatusChecker(self.store)
self.session_pool = SessionPool(max_sessions=5) # Create session pool
# Layout # Layout
self._build_layout() self._build_layout()
@@ -49,7 +51,7 @@ class App(ctk.CTk):
def _build_layout(self): def _build_layout(self):
# Sidebar # Sidebar
self.sidebar = Sidebar(self, self.store, on_select=self._on_server_select) 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.pack(side="left", fill="y")
self.sidebar.add_callback = self._add_server self.sidebar.add_callback = self._add_server
self.sidebar.edit_callback = self._edit_server self.sidebar.edit_callback = self._edit_server
@@ -91,10 +93,10 @@ class App(ctk.CTk):
for key in self._tab_keys: for key in self._tab_keys:
self.tabview.add(t(key)) self.tabview.add(t(key))
self.terminal_tab = TerminalTab(self.tabview.tab(t("terminal")), self.store) self.terminal_tab = TerminalTab(self.tabview.tab(t("terminal")), self.store, self.session_pool)
self.terminal_tab.pack(fill="both", expand=True) self.terminal_tab.pack(fill="both", expand=True)
self.files_tab = FilesTab(self.tabview.tab(t("files")), self.store) self.files_tab = FilesTab(self.tabview.tab(t("files")), self.store, self.session_pool)
self.files_tab.pack(fill="both", expand=True) 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 = InfoTab(self.tabview.tab(t("info")), self.store, edit_callback=self._edit_server)
@@ -115,6 +117,8 @@ class App(ctk.CTk):
self.info_tab.set_server(alias) self.info_tab.set_server(alias)
self.keys_tab.set_server(alias) self.keys_tab.set_server(alias)
self.totp_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): def _add_server(self):
dialog = ServerDialog(self, self.store) dialog = ServerDialog(self, self.store)
@@ -129,11 +133,14 @@ class App(ctk.CTk):
def _delete_server(self, alias: str): def _delete_server(self, alias: str):
if messagebox.askyesno(t("delete_server"), t("delete_confirm").format(alias=alias)): 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.store.remove_server(alias)
self._on_server_select(None) self._on_server_select(None)
def _on_status_update(self): def _on_status_update(self):
self.sidebar.update_statuses() self.sidebar.update_statuses()
self.sidebar.update_session_indicators()
self.info_tab.refresh() self.info_tab.refresh()
def _show_about(self): def _show_about(self):
@@ -176,9 +183,8 @@ class App(ctk.CTk):
saved_local_path = self.files_tab._local_path saved_local_path = self.files_tab._local_path
had_sftp = self.files_tab._sftp is not None and self.files_tab._sftp.connected had_sftp = self.files_tab._sftp is not None and self.files_tab._sftp.connected
# Disconnect terminal and SFTP before destroying tabs # Disconnect all sessions in the pool
self.terminal_tab._disconnect() self.session_pool.disconnect_all()
self.files_tab._disconnect_sftp()
# Detach tab contents # Detach tab contents
self.terminal_tab.pack_forget() self.terminal_tab.pack_forget()
@@ -200,10 +206,10 @@ class App(ctk.CTk):
self.tabview.add(t(key)) self.tabview.add(t(key))
# Re-parent tab contents # Re-parent tab contents
self.terminal_tab = TerminalTab(self.tabview.tab(t("terminal")), self.store) self.terminal_tab = TerminalTab(self.tabview.tab(t("terminal")), self.store, self.session_pool)
self.terminal_tab.pack(fill="both", expand=True) self.terminal_tab.pack(fill="both", expand=True)
self.files_tab = FilesTab(self.tabview.tab(t("files")), self.store) self.files_tab = FilesTab(self.tabview.tab(t("files")), self.store, self.session_pool)
self.files_tab.pack(fill="both", expand=True) 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 = InfoTab(self.tabview.tab(t("info")), self.store, edit_callback=self._edit_server)
@@ -245,7 +251,7 @@ class App(ctk.CTk):
self.sidebar.update_language() self.sidebar.update_language()
def _on_close(self): def _on_close(self):
self.terminal_tab._disconnect() # Disconnect all sessions before closing
self.files_tab._disconnect_sftp() self.session_pool.disconnect_all()
self.checker.stop() self.checker.stop()
self.destroy() self.destroy()

View File

@@ -8,13 +8,15 @@ from gui.widgets.status_badge import StatusBadge
class Sidebar(ctk.CTkFrame): class Sidebar(ctk.CTkFrame):
def __init__(self, master, store, on_select=None): def __init__(self, master, store, on_select=None, session_pool=None):
super().__init__(master, width=250, corner_radius=0) super().__init__(master, width=250, corner_radius=0)
self.store = store self.store = store
self.on_select = on_select self.on_select = on_select
self.session_pool = session_pool
self._selected_alias: str | None = None self._selected_alias: str | None = None
self._server_frames: dict[str, ctk.CTkFrame] = {} self._server_frames: dict[str, ctk.CTkFrame] = {}
self._badges: dict[str, StatusBadge] = {} self._badges: dict[str, StatusBadge] = {}
self._session_indicators: dict[str, ctk.CTkLabel] = {}
self.pack_propagate(False) self.pack_propagate(False)
@@ -32,6 +34,13 @@ class Sidebar(ctk.CTkFrame):
self.list_frame = ctk.CTkScrollableFrame(self, fg_color="transparent") self.list_frame = ctk.CTkScrollableFrame(self, fg_color="transparent")
self.list_frame.pack(fill="both", expand=True, padx=5, pady=0) self.list_frame.pack(fill="both", expand=True, padx=5, pady=0)
# Active sessions label
self._sessions_label = ctk.CTkLabel(
self, text="", font=ctk.CTkFont(size=10),
text_color="#6b7280", anchor="w"
)
self._sessions_label.pack(fill="x", padx=15, pady=(0, 2))
# Buttons # Buttons
btn_frame = ctk.CTkFrame(self, fg_color="transparent") btn_frame = ctk.CTkFrame(self, fg_color="transparent")
btn_frame.pack(fill="x", padx=10, pady=10) btn_frame.pack(fill="x", padx=10, pady=10)
@@ -57,6 +66,7 @@ class Sidebar(ctk.CTkFrame):
self.add_btn.configure(text=t("add")) self.add_btn.configure(text=t("add"))
self.edit_btn.configure(text=t("edit")) self.edit_btn.configure(text=t("edit"))
self.del_btn.configure(text=t("delete")) self.del_btn.configure(text=t("delete"))
self._update_sessions_label()
def _refresh_list(self): def _refresh_list(self):
# Clear # Clear
@@ -64,6 +74,12 @@ class Sidebar(ctk.CTkFrame):
widget.destroy() widget.destroy()
self._server_frames.clear() self._server_frames.clear()
self._badges.clear() self._badges.clear()
self._session_indicators.clear()
# Get active sessions from pool
active_aliases = set()
if self.session_pool:
active_aliases = set(self.session_pool.get_active_sessions())
search = self.search_var.get().lower() search = self.search_var.get().lower()
servers = self.store.get_all() servers = self.store.get_all()
@@ -85,6 +101,16 @@ class Sidebar(ctk.CTkFrame):
badge.pack(side="left", padx=(10, 5), pady=10) badge.pack(side="left", padx=(10, 5), pady=10)
self._badges[alias] = badge self._badges[alias] = badge
# Active session indicator (right side)
session_ind = ctk.CTkLabel(
frame, text="", width=12, height=12,
font=ctk.CTkFont(size=8)
)
session_ind.pack(side="right", padx=(0, 8), pady=10)
if alias in active_aliases:
session_ind.configure(text="\u25cf", text_color="#22c55e") # green dot
self._session_indicators[alias] = session_ind
# Info # Info
info = ctk.CTkFrame(frame, fg_color="transparent") info = ctk.CTkFrame(frame, fg_color="transparent")
info.pack(side="left", fill="both", expand=True, padx=5) info.pack(side="left", fill="both", expand=True, padx=5)
@@ -96,12 +122,13 @@ class Sidebar(ctk.CTkFrame):
detail_label.pack(fill="x") detail_label.pack(fill="x")
# Click handlers # Click handlers
for widget in [frame, info, name_label, detail_label, badge]: for widget in [frame, info, name_label, detail_label, badge, session_ind]:
widget.bind("<Button-1>", lambda e, a=alias: self._select(a)) widget.bind("<Button-1>", lambda e, a=alias: self._select(a))
self._server_frames[alias] = frame self._server_frames[alias] = frame
self._highlight_selected() self._highlight_selected()
self._update_sessions_label()
def _select(self, alias: str): def _select(self, alias: str):
self._selected_alias = alias self._selected_alias = alias
@@ -123,6 +150,29 @@ class Sidebar(ctk.CTkFrame):
for alias, badge in self._badges.items(): for alias, badge in self._badges.items():
badge.set_status(self.store.get_status(alias)) badge.set_status(self.store.get_status(alias))
def update_session_indicators(self):
"""Update active session indicators from session pool."""
if not self.session_pool:
return
active_aliases = set(self.session_pool.get_active_sessions())
for alias, ind in self._session_indicators.items():
if alias in active_aliases:
ind.configure(text="\u25cf", text_color="#22c55e")
else:
ind.configure(text="")
self._update_sessions_label()
def _update_sessions_label(self):
"""Update the active sessions count label."""
if self.session_pool:
count = len(self.session_pool.get_active_sessions())
if count > 0:
self._sessions_label.configure(text=t("active_sessions").format(count=count))
else:
self._sessions_label.configure(text="")
else:
self._sessions_label.configure(text="")
def _on_add(self): def _on_add(self):
if self.add_callback: if self.add_callback:
self.add_callback() self.add_callback()

View File

@@ -58,9 +58,10 @@ def _get_windows_drives() -> list[str]:
class FilesTab(ctk.CTkFrame): class FilesTab(ctk.CTkFrame):
def __init__(self, master, store): def __init__(self, master, store, session_pool=None):
super().__init__(master, fg_color="transparent") super().__init__(master, fg_color="transparent")
self.store = store self.store = store
self.session_pool = session_pool
self._current_alias: str | None = None self._current_alias: str | None = None
self._sftp: SFTPSession | None = None self._sftp: SFTPSession | None = None
self._local_path = os.path.expanduser("~") self._local_path = os.path.expanduser("~")
@@ -271,11 +272,27 @@ class FilesTab(ctk.CTkFrame):
# ── Server selection ── # ── Server selection ──
def set_server(self, alias: str | None): def set_server(self, alias: str | None):
# Store state of current session before switching
if self._current_alias and self._sftp and self.session_pool:
self.session_pool.store_sftp_state(
self._current_alias,
self._remote_path,
self._sudo_var.get()
)
if self._current_alias == alias: if self._current_alias == alias:
return return
self._disconnect_sftp() self._disconnect_sftp()
self._current_alias = alias self._current_alias = alias
if alias: if alias:
# Restore state from session pool if available
if self.session_pool:
stored_path, stored_sudo = self.session_pool.get_sftp_state(alias)
if stored_path != "/":
self._remote_path = stored_path
# The stored sudo mode will be applied when the connection is established
self._connect_sftp() self._connect_sftp()
else: else:
self._remote_list.populate([]) self._remote_list.populate([])
@@ -293,10 +310,40 @@ class FilesTab(ctk.CTkFrame):
def _do(): def _do():
try: try:
sftp = SFTPSession(server, self.store.get_ssh_key_path()) # Use session pool if available
sftp.connect() if self.session_pool:
home = sftp.normalize(".") sftp, is_new = self.session_pool.get_or_create_sftp_session(
self.after(0, lambda: self._on_sftp_connected(sftp, home)) self._current_alias,
server,
self.store.get_ssh_key_path()
)
# Get stored state
stored_path, stored_sudo = self.session_pool.get_sftp_state(self._current_alias)
# Set sudo mode before connecting if it was stored
sftp.sudo_mode = stored_sudo
if is_new:
sftp.connect()
# Normalize path after potential reconnection
home = sftp.normalize(".")
if stored_path != "/":
# Validate the stored path still exists, fall back to home if not
try:
sftp.listdir_attr(stored_path)
home = stored_path # Use stored path as home if accessible
except:
pass # Fall back to normalized home path
self.after(0, lambda: self._on_sftp_connected(sftp, home))
else:
# Legacy behavior without session pool
sftp = SFTPSession(server, self.store.get_ssh_key_path())
sftp.connect()
home = sftp.normalize(".")
self.after(0, lambda: self._on_sftp_connected(sftp, home))
except Exception as e: except Exception as e:
self.after(0, lambda: self._on_sftp_error(str(e))) self.after(0, lambda: self._on_sftp_error(str(e)))
@@ -304,7 +351,9 @@ class FilesTab(ctk.CTkFrame):
def _on_sftp_connected(self, sftp: SFTPSession, home: str): def _on_sftp_connected(self, sftp: SFTPSession, home: str):
self._sftp = sftp self._sftp = sftp
self._sftp.sudo_mode = self._sudo_var.get() # Update sudo var to match the restored session state
self._sudo_var.set(self._sftp.sudo_mode)
# Update remote path to match stored path if available
self._remote_path = home self._remote_path = home
self._remote_history.clear() self._remote_history.clear()
self._remote_status.configure( self._remote_status.configure(
@@ -318,12 +367,17 @@ class FilesTab(ctk.CTkFrame):
self._log_msg(t("sftp_error").format(e=error)) self._log_msg(t("sftp_error").format(e=error))
def _disconnect_sftp(self): def _disconnect_sftp(self):
if self._sftp: # Only disconnect if not using session pool (otherwise session stays alive)
if self._sftp and not self.session_pool:
try: try:
self._sftp.disconnect() self._sftp.disconnect()
except Exception: except Exception:
pass pass
self._sftp = None self._sftp = None
# If using session pool, remove callbacks to prevent processing data after switch
elif self._sftp and self.session_pool:
# Don't actually disconnect, just remove the reference to avoid further interaction
self._sftp = None
# ── Browse / Drive ── # ── Browse / Drive ──

View File

@@ -11,9 +11,10 @@ from core.i18n import t
class TerminalTab(ctk.CTkFrame): class TerminalTab(ctk.CTkFrame):
def __init__(self, master, store): def __init__(self, master, store, session_pool=None):
super().__init__(master, fg_color="transparent") super().__init__(master, fg_color="transparent")
self.store = store self.store = store
self.session_pool = session_pool
self._current_alias: str | None = None self._current_alias: str | None = None
self._session: ShellSession | None = None self._session: ShellSession | None = None
self._reconnect_count = 0 self._reconnect_count = 0
@@ -39,7 +40,16 @@ class TerminalTab(ctk.CTkFrame):
def set_server(self, alias: str | None): def set_server(self, alias: str | None):
if alias == self._current_alias: if alias == self._current_alias:
return return
# Store state of current session before switching
if self._current_alias and self._session and self.session_pool:
# Store terminal buffer from widget
buf = self._terminal.get_current_buffer()
self.session_pool.store_shell_state(self._current_alias, buf)
# Disconnect current session
self._disconnect() self._disconnect()
self._current_alias = alias self._current_alias = alias
if alias: if alias:
self._connect() self._connect()
@@ -65,15 +75,32 @@ class TerminalTab(ctk.CTkFrame):
def _do_connect(): def _do_connect():
try: try:
key_path = self.store.get_ssh_key_path() key_path = self.store.get_ssh_key_path()
cols, rows = self._terminal.get_size()
session = ShellSession(server, key_path, cols=cols, rows=rows) # Use session pool if available
session.on_data = self._on_data_received if self.session_pool:
session.on_disconnect = self._on_disconnected cols, rows = self._terminal.get_size()
session.connect() session, is_new = self.session_pool.get_or_create_shell_session(alias, server, key_path)
if is_new:
# Configure the session with proper terminal dimensions
session.cols = cols
session.rows = rows
session.connect()
# Set up callbacks even if session already existed
session.on_data = self._on_data_received
session.on_disconnect = self._on_disconnected
self._session = session
else:
# Legacy behavior without session pool
cols, rows = self._terminal.get_size()
session = ShellSession(server, key_path, cols=cols, rows=rows)
session.on_data = self._on_data_received
session.on_disconnect = self._on_disconnected
session.connect()
self._session = session
# Set session on main thread to avoid races # Set session on main thread to avoid races
def _set_session(): def _set_session():
self._session = session
self._reconnect_count = 0 self._reconnect_count = 0
self._terminal.set_status( self._terminal.set_status(
t("term_connected").format(alias=alias), "#44cc44" t("term_connected").format(alias=alias), "#44cc44"
@@ -89,10 +116,16 @@ class TerminalTab(ctk.CTkFrame):
def _disconnect(self): def _disconnect(self):
self._intentional_disconnect = True self._intentional_disconnect = True
session = self._session # Only disconnect if we don't have a session pool (otherwise session stays alive)
self._session = None if not self.session_pool and self._session:
if session: self._session.disconnect()
session.disconnect() self._session = None
# If using session pool, session remains active in the pool
elif self.session_pool and self._session:
# Remove callbacks to prevent processing data after switch
self._session.on_data = None
self._session.on_disconnect = None
self._session = None
def _on_data_received(self, data: bytes): def _on_data_received(self, data: bytes):
"""Called from SSH thread — put data in thread-safe queue.""" """Called from SSH thread — put data in thread-safe queue."""
@@ -117,6 +150,10 @@ class TerminalTab(ctk.CTkFrame):
self._terminal.set_status(t("term_disconnected"), "#888888") self._terminal.set_status(t("term_disconnected"), "#888888")
return return
# Remove dead session from pool so it gets recreated on next connect
if self.session_pool and self._current_alias:
self.session_pool.disconnect_session(self._current_alias)
self._session = None self._session = None
if self._reconnect_count < self._max_reconnect: if self._reconnect_count < self._max_reconnect:

View File

@@ -361,6 +361,23 @@ class TerminalWidget(tk.Frame):
def get_size(self) -> tuple[int, int]: def get_size(self) -> tuple[int, int]:
return self._cols, self._rows return self._cols, self._rows
def get_current_buffer(self) -> bytes:
"""Export current pyte screen content as ANSI bytes for session pool preservation."""
screen = self._screen
lines = []
for row in range(screen.lines):
line = screen.buffer[row]
chars = []
for col in range(screen.columns):
chars.append(line[col].data)
# Strip trailing spaces
text = "".join(chars).rstrip()
lines.append(text)
# Remove trailing empty lines
while lines and not lines[-1]:
lines.pop()
return "\n".join(lines).encode("utf-8")
# ── Rendering ────────────────────────────────────────────────────────── # ── Rendering ──────────────────────────────────────────────────────────
def _render(self): def _render(self):

Binary file not shown.

View File

@@ -1,6 +1,6 @@
"""Version info for ServerManager.""" """Version info for ServerManager."""
__version__ = "1.8.2" __version__ = "1.8.3"
__app_name__ = "ServerManager" __app_name__ = "ServerManager"
__author__ = "aibot777" __author__ = "aibot777"
__description__ = "Desktop GUI for managing remote servers" __description__ = "Desktop GUI for managing remote servers"