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

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