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

@@ -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: