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",
"recursive_delete_confirm": "Delete folder '{name}' and all contents?",
"drive": "Drive",
"active_sessions": "Active: {count}",
}
_RU = {
@@ -545,6 +546,7 @@ _RU = {
"drop_to_download": "Отпустите для скачивания",
"recursive_delete_confirm": "Удалить папку '{name}' со всем содержимым?",
"drive": "Диск",
"active_sessions": "Активных: {count}",
}
_ZH = {
@@ -806,6 +808,7 @@ _ZH = {
"drop_to_download": "释放以下载",
"recursive_delete_confirm": "删除文件夹 '{name}' 及所有内容?",
"drive": "驱动器",
"active_sessions": "活跃: {count}",
}
_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 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
@@ -35,6 +36,7 @@ class App(ctk.CTk):
# Core
self.store = ServerStore()
self.checker = StatusChecker(self.store)
self.session_pool = SessionPool(max_sessions=5) # Create session pool
# Layout
self._build_layout()
@@ -49,7 +51,7 @@ class App(ctk.CTk):
def _build_layout(self):
# 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.add_callback = self._add_server
self.sidebar.edit_callback = self._edit_server
@@ -91,10 +93,10 @@ class App(ctk.CTk):
for key in self._tab_keys:
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.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.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.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)
@@ -129,11 +133,14 @@ class App(ctk.CTk):
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):
@@ -176,9 +183,8 @@ class App(ctk.CTk):
saved_local_path = self.files_tab._local_path
had_sftp = self.files_tab._sftp is not None and self.files_tab._sftp.connected
# Disconnect terminal and SFTP before destroying tabs
self.terminal_tab._disconnect()
self.files_tab._disconnect_sftp()
# Disconnect all sessions in the pool
self.session_pool.disconnect_all()
# Detach tab contents
self.terminal_tab.pack_forget()
@@ -200,10 +206,10 @@ class App(ctk.CTk):
self.tabview.add(t(key))
# 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.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.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()
def _on_close(self):
self.terminal_tab._disconnect()
self.files_tab._disconnect_sftp()
# Disconnect all sessions before closing
self.session_pool.disconnect_all()
self.checker.stop()
self.destroy()

View File

@@ -8,13 +8,15 @@ from gui.widgets.status_badge import StatusBadge
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)
self.store = store
self.on_select = on_select
self.session_pool = session_pool
self._selected_alias: str | None = None
self._server_frames: dict[str, ctk.CTkFrame] = {}
self._badges: dict[str, StatusBadge] = {}
self._session_indicators: dict[str, ctk.CTkLabel] = {}
self.pack_propagate(False)
@@ -32,6 +34,13 @@ class Sidebar(ctk.CTkFrame):
self.list_frame = ctk.CTkScrollableFrame(self, fg_color="transparent")
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
btn_frame = ctk.CTkFrame(self, fg_color="transparent")
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.edit_btn.configure(text=t("edit"))
self.del_btn.configure(text=t("delete"))
self._update_sessions_label()
def _refresh_list(self):
# Clear
@@ -64,6 +74,12 @@ class Sidebar(ctk.CTkFrame):
widget.destroy()
self._server_frames.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()
servers = self.store.get_all()
@@ -85,6 +101,16 @@ class Sidebar(ctk.CTkFrame):
badge.pack(side="left", padx=(10, 5), pady=10)
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 = ctk.CTkFrame(frame, fg_color="transparent")
info.pack(side="left", fill="both", expand=True, padx=5)
@@ -96,12 +122,13 @@ class Sidebar(ctk.CTkFrame):
detail_label.pack(fill="x")
# 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))
self._server_frames[alias] = frame
self._highlight_selected()
self._update_sessions_label()
def _select(self, alias: str):
self._selected_alias = alias
@@ -123,6 +150,29 @@ class Sidebar(ctk.CTkFrame):
for alias, badge in self._badges.items():
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):
if self.add_callback:
self.add_callback()

View File

@@ -58,9 +58,10 @@ def _get_windows_drives() -> list[str]:
class FilesTab(ctk.CTkFrame):
def __init__(self, master, store):
def __init__(self, master, store, session_pool=None):
super().__init__(master, fg_color="transparent")
self.store = store
self.session_pool = session_pool
self._current_alias: str | None = None
self._sftp: SFTPSession | None = None
self._local_path = os.path.expanduser("~")
@@ -271,11 +272,27 @@ class FilesTab(ctk.CTkFrame):
# ── Server selection ──
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:
return
self._disconnect_sftp()
self._current_alias = 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()
else:
self._remote_list.populate([])
@@ -293,10 +310,40 @@ class FilesTab(ctk.CTkFrame):
def _do():
try:
sftp = SFTPSession(server, self.store.get_ssh_key_path())
sftp.connect()
home = sftp.normalize(".")
self.after(0, lambda: self._on_sftp_connected(sftp, home))
# Use session pool if available
if self.session_pool:
sftp, is_new = self.session_pool.get_or_create_sftp_session(
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:
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):
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_history.clear()
self._remote_status.configure(
@@ -318,12 +367,17 @@ class FilesTab(ctk.CTkFrame):
self._log_msg(t("sftp_error").format(e=error))
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:
self._sftp.disconnect()
except Exception:
pass
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 ──

View File

@@ -11,9 +11,10 @@ from core.i18n import t
class TerminalTab(ctk.CTkFrame):
def __init__(self, master, store):
def __init__(self, master, store, session_pool=None):
super().__init__(master, fg_color="transparent")
self.store = store
self.session_pool = session_pool
self._current_alias: str | None = None
self._session: ShellSession | None = None
self._reconnect_count = 0
@@ -39,7 +40,16 @@ class TerminalTab(ctk.CTkFrame):
def set_server(self, alias: str | None):
if alias == self._current_alias:
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._current_alias = alias
if alias:
self._connect()
@@ -65,15 +75,32 @@ class TerminalTab(ctk.CTkFrame):
def _do_connect():
try:
key_path = self.store.get_ssh_key_path()
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()
# Use session pool if available
if self.session_pool:
cols, rows = self._terminal.get_size()
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
def _set_session():
self._session = session
self._reconnect_count = 0
self._terminal.set_status(
t("term_connected").format(alias=alias), "#44cc44"
@@ -89,10 +116,16 @@ class TerminalTab(ctk.CTkFrame):
def _disconnect(self):
self._intentional_disconnect = True
session = self._session
self._session = None
if session:
session.disconnect()
# Only disconnect if we don't have a session pool (otherwise session stays alive)
if not self.session_pool and self._session:
self._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):
"""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")
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
if self._reconnect_count < self._max_reconnect:

View File

@@ -361,6 +361,23 @@ class TerminalWidget(tk.Frame):
def get_size(self) -> tuple[int, int]:
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 ──────────────────────────────────────────────────────────
def _render(self):

Binary file not shown.

View File

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