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:
@@ -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
228
core/session_pool.py
Normal 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
|
||||
26
gui/app.py
26
gui/app.py
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 ──
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
BIN
releases/ServerManager-v1.8.3-win-x64.exe
Normal file
BIN
releases/ServerManager-v1.8.3-win-x64.exe
Normal file
Binary file not shown.
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user