mirror of
https://github.com/andrey271192/ssh-manage.git
synced 2026-09-20 11:55:37 +00:00
feat: remove API AI, add SSH key auth, Termius-style UI, export/import
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
493
ssh_manager.py
493
ssh_manager.py
@@ -15,8 +15,6 @@ import re
|
|||||||
import base64
|
import base64
|
||||||
import socket
|
import socket
|
||||||
import urllib.request
|
import urllib.request
|
||||||
import urllib.error
|
|
||||||
import ssl
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import stat
|
import stat
|
||||||
@@ -733,11 +731,47 @@ class SessionStore:
|
|||||||
self.sessions[index].update(kwargs)
|
self.sessions[index].update(kwargs)
|
||||||
self.save()
|
self.save()
|
||||||
|
|
||||||
|
def duplicate(self, index):
|
||||||
|
"""Duplicate a session (copy with ' (копия)' suffix)."""
|
||||||
|
if 0 <= index < len(self.sessions):
|
||||||
|
s = dict(self.sessions[index])
|
||||||
|
s["name"] = s.get("name", "") + " (копия)"
|
||||||
|
s["created"] = datetime.now().isoformat()
|
||||||
|
self.sessions.insert(index + 1, s)
|
||||||
|
self.save()
|
||||||
|
return s
|
||||||
|
|
||||||
def move_to_group(self, index, group):
|
def move_to_group(self, index, group):
|
||||||
if 0 <= index < len(self.sessions):
|
if 0 <= index < len(self.sessions):
|
||||||
self.sessions[index]["group"] = group
|
self.sessions[index]["group"] = group
|
||||||
self.save()
|
self.save()
|
||||||
|
|
||||||
|
def export_json(self):
|
||||||
|
"""Export sessions + groups as dict (passwords decoded for portability)."""
|
||||||
|
data = {"groups": list(self.groups), "sessions": []}
|
||||||
|
for s in self.sessions:
|
||||||
|
e = dict(s)
|
||||||
|
e["password"] = self._decode(e.get("password", ""))
|
||||||
|
data["sessions"].append(e)
|
||||||
|
return data
|
||||||
|
|
||||||
|
def import_json(self, data):
|
||||||
|
"""Import sessions from export dict. Merges, doesn't overwrite."""
|
||||||
|
for g in data.get("groups", []):
|
||||||
|
if g and g not in self.groups:
|
||||||
|
self.groups.append(g)
|
||||||
|
for s in data.get("sessions", []):
|
||||||
|
s["password"] = self._encode(s.get("password", ""))
|
||||||
|
# Skip exact duplicates
|
||||||
|
dup = False
|
||||||
|
for existing in self.sessions:
|
||||||
|
if existing["host"] == s.get("host") and existing["port"] == s.get("port") and existing["user"] == s.get("user"):
|
||||||
|
dup = True
|
||||||
|
break
|
||||||
|
if not dup:
|
||||||
|
self.sessions.append(s)
|
||||||
|
self.save()
|
||||||
|
|
||||||
def get_password(self, session):
|
def get_password(self, session):
|
||||||
return self._decode(session.get("password", ""))
|
return self._decode(session.get("password", ""))
|
||||||
|
|
||||||
@@ -838,20 +872,28 @@ class TerminalWidget(tk.Frame):
|
|||||||
self.text.tag_configure("error", foreground="#f38ba8")
|
self.text.tag_configure("error", foreground="#f38ba8")
|
||||||
self.text.tag_configure("info", foreground="#89b4fa")
|
self.text.tag_configure("info", foreground="#89b4fa")
|
||||||
|
|
||||||
def connect(self, host, port, user, password):
|
def connect(self, host, port, user, password, key_file=None):
|
||||||
self.running = True
|
self.running = True
|
||||||
self._write(f"Подключение к {user}@{host}:{port}...\n", "info")
|
self._write(f"Подключение к {user}@{host}:{port}...\n", "info")
|
||||||
t = threading.Thread(target=self._connect_thread, args=(host, port, user, password), daemon=True)
|
t = threading.Thread(target=self._connect_thread, args=(host, port, user, password, key_file), daemon=True)
|
||||||
t.start()
|
t.start()
|
||||||
|
|
||||||
def _connect_thread(self, host, port, user, password):
|
def _connect_thread(self, host, port, user, password, key_file=None):
|
||||||
try:
|
try:
|
||||||
self.ssh = paramiko.SSHClient()
|
self.ssh = paramiko.SSHClient()
|
||||||
self.ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
self.ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||||
self.ssh.connect(
|
connect_kw = dict(host=host, port=port, username=user, timeout=10)
|
||||||
host, port=port, username=user, password=password,
|
if key_file and os.path.isfile(key_file):
|
||||||
timeout=10, look_for_keys=False, allow_agent=False,
|
connect_kw["key_filename"] = key_file
|
||||||
)
|
connect_kw["look_for_keys"] = False
|
||||||
|
connect_kw["allow_agent"] = False
|
||||||
|
if password:
|
||||||
|
connect_kw["passphrase"] = password
|
||||||
|
else:
|
||||||
|
connect_kw["password"] = password
|
||||||
|
connect_kw["look_for_keys"] = False
|
||||||
|
connect_kw["allow_agent"] = False
|
||||||
|
self.ssh.connect(**connect_kw)
|
||||||
cols = max(80, self.text.winfo_width() // 8)
|
cols = max(80, self.text.winfo_width() // 8)
|
||||||
rows = max(24, self.text.winfo_height() // 16)
|
rows = max(24, self.text.winfo_height() // 16)
|
||||||
self.channel = self.ssh.invoke_shell(
|
self.channel = self.ssh.invoke_shell(
|
||||||
@@ -1007,6 +1049,7 @@ class SessionDialog(tk.Toplevel):
|
|||||||
("Порт:", "port", str(session.get("port", 22)) if session else "22"),
|
("Порт:", "port", str(session.get("port", 22)) if session else "22"),
|
||||||
("Пользователь:", "user", session.get("user", "") if session else "root"),
|
("Пользователь:", "user", session.get("user", "") if session else "root"),
|
||||||
("Пароль:", "password", ""),
|
("Пароль:", "password", ""),
|
||||||
|
("SSH ключ:", "key_file", session.get("key_file", "") if session else ""),
|
||||||
]
|
]
|
||||||
|
|
||||||
self.entries = {}
|
self.entries = {}
|
||||||
@@ -1023,6 +1066,14 @@ class SessionDialog(tk.Toplevel):
|
|||||||
setup_entry_clipboard(entry)
|
setup_entry_clipboard(entry)
|
||||||
self.entries[key] = entry
|
self.entries[key] = entry
|
||||||
|
|
||||||
|
# Browse button for SSH key file
|
||||||
|
key_entry = self.entries["key_file"]
|
||||||
|
browse_btn = tk.Button(frame, text="...", bg="#45475a", fg="#cdd6f4",
|
||||||
|
relief=tk.FLAT, font=("Consolas", 9), padx=4,
|
||||||
|
command=self._browse_key)
|
||||||
|
key_row = len(fields) - 1 # last field
|
||||||
|
browse_btn.grid(row=key_row, column=2, padx=4, pady=3)
|
||||||
|
|
||||||
row_group = len(fields)
|
row_group = len(fields)
|
||||||
tk.Label(frame, text="Группа:", bg="#1e1e2e", fg="#cdd6f4",
|
tk.Label(frame, text="Группа:", bg="#1e1e2e", fg="#cdd6f4",
|
||||||
font=("Consolas", 10)).grid(row=row_group, column=0, sticky="w", pady=3)
|
font=("Consolas", 10)).grid(row=row_group, column=0, sticky="w", pady=3)
|
||||||
@@ -1053,6 +1104,16 @@ class SessionDialog(tk.Toplevel):
|
|||||||
self.bind("<Escape>", lambda e: self.destroy())
|
self.bind("<Escape>", lambda e: self.destroy())
|
||||||
self.wait_window()
|
self.wait_window()
|
||||||
|
|
||||||
|
def _browse_key(self):
|
||||||
|
path = filedialog.askopenfilename(
|
||||||
|
parent=self, title="SSH ключ",
|
||||||
|
initialdir=os.path.expanduser("~/.ssh"),
|
||||||
|
filetypes=[("All", "*"), ("PEM", "*.pem"), ("Key", "*.key")],
|
||||||
|
)
|
||||||
|
if path:
|
||||||
|
self.entries["key_file"].delete(0, tk.END)
|
||||||
|
self.entries["key_file"].insert(0, path)
|
||||||
|
|
||||||
def _ok(self):
|
def _ok(self):
|
||||||
host = self.entries["host"].get().strip()
|
host = self.entries["host"].get().strip()
|
||||||
if not host:
|
if not host:
|
||||||
@@ -1070,145 +1131,12 @@ class SessionDialog(tk.Toplevel):
|
|||||||
"port": port,
|
"port": port,
|
||||||
"user": self.entries["user"].get().strip() or "root",
|
"user": self.entries["user"].get().strip() or "root",
|
||||||
"password": self.entries["password"].get(),
|
"password": self.entries["password"].get(),
|
||||||
|
"key_file": self.entries["key_file"].get().strip(),
|
||||||
"group": self.group_var.get().strip(),
|
"group": self.group_var.get().strip(),
|
||||||
}
|
}
|
||||||
self.destroy()
|
self.destroy()
|
||||||
|
|
||||||
|
|
||||||
# ── AI Agent Settings Dialog ──────────────────────────────────
|
|
||||||
|
|
||||||
class AISettingsDialog(tk.Toplevel):
|
|
||||||
def __init__(self, parent, config):
|
|
||||||
super().__init__(parent)
|
|
||||||
self.title("Настройки AI-агента")
|
|
||||||
self.config = config
|
|
||||||
self.result = None
|
|
||||||
self.resizable(False, False)
|
|
||||||
self.configure(bg="#1e1e2e")
|
|
||||||
self.grab_set()
|
|
||||||
|
|
||||||
frame = tk.Frame(self, bg="#1e1e2e", padx=20, pady=20)
|
|
||||||
frame.pack(fill=tk.BOTH, expand=True)
|
|
||||||
|
|
||||||
tk.Label(frame, text="AI-провайдер", bg="#1e1e2e", fg="#cdd6f4",
|
|
||||||
font=("Consolas", 12, "bold")).pack(anchor="w", pady=(0, 10))
|
|
||||||
|
|
||||||
self.provider_var = tk.StringVar(value=config.get("ai_provider", "local"))
|
|
||||||
|
|
||||||
providers = [
|
|
||||||
("local", "Локальная база (без API)"),
|
|
||||||
("claude", "Claude API (Anthropic)"),
|
|
||||||
("deepseek", "DeepSeek API"),
|
|
||||||
]
|
|
||||||
for val, label in providers:
|
|
||||||
rb = tk.Radiobutton(
|
|
||||||
frame, text=label, variable=self.provider_var, value=val,
|
|
||||||
bg="#1e1e2e", fg="#cdd6f4", selectcolor="#313244",
|
|
||||||
activebackground="#1e1e2e", activeforeground="#cdd6f4",
|
|
||||||
font=("Consolas", 10),
|
|
||||||
)
|
|
||||||
rb.pack(anchor="w", pady=2)
|
|
||||||
|
|
||||||
tk.Frame(frame, bg="#45475a", height=1).pack(fill=tk.X, pady=10)
|
|
||||||
|
|
||||||
tk.Label(frame, text="Claude API Key:", bg="#1e1e2e", fg="#cdd6f4",
|
|
||||||
font=("Consolas", 10)).pack(anchor="w")
|
|
||||||
self.claude_key = tk.Entry(frame, width=50, bg="#313244", fg="#cdd6f4",
|
|
||||||
insertbackground="#cdd6f4", relief=tk.FLAT,
|
|
||||||
font=("Consolas", 9), show="*")
|
|
||||||
self.claude_key.insert(0, config.get("claude_api_key", ""))
|
|
||||||
self.claude_key.pack(fill=tk.X, pady=(2, 8))
|
|
||||||
setup_entry_clipboard(self.claude_key)
|
|
||||||
|
|
||||||
tk.Label(frame, text="DeepSeek API Key:", bg="#1e1e2e", fg="#cdd6f4",
|
|
||||||
font=("Consolas", 10)).pack(anchor="w")
|
|
||||||
self.deepseek_key = tk.Entry(frame, width=50, bg="#313244", fg="#cdd6f4",
|
|
||||||
insertbackground="#cdd6f4", relief=tk.FLAT,
|
|
||||||
font=("Consolas", 9), show="*")
|
|
||||||
self.deepseek_key.insert(0, config.get("deepseek_api_key", ""))
|
|
||||||
self.deepseek_key.pack(fill=tk.X, pady=(2, 12))
|
|
||||||
setup_entry_clipboard(self.deepseek_key)
|
|
||||||
|
|
||||||
btn_frame = tk.Frame(frame, bg="#1e1e2e")
|
|
||||||
btn_frame.pack()
|
|
||||||
tk.Button(btn_frame, text="Сохранить", bg="#89b4fa", fg="#1e1e2e",
|
|
||||||
activebackground="#6c8fff", relief=tk.FLAT,
|
|
||||||
font=("Consolas", 10, "bold"), padx=20, pady=4,
|
|
||||||
command=self._save).pack(side=tk.LEFT, padx=4)
|
|
||||||
tk.Button(btn_frame, text="Отмена", bg="#45475a", fg="#cdd6f4",
|
|
||||||
activebackground="#585b70", relief=tk.FLAT,
|
|
||||||
font=("Consolas", 10), padx=20, pady=4,
|
|
||||||
command=self.destroy).pack(side=tk.LEFT, padx=4)
|
|
||||||
|
|
||||||
self.wait_window()
|
|
||||||
|
|
||||||
def _save(self):
|
|
||||||
self.result = {
|
|
||||||
"ai_provider": self.provider_var.get(),
|
|
||||||
"claude_api_key": self.claude_key.get().strip(),
|
|
||||||
"deepseek_api_key": self.deepseek_key.get().strip(),
|
|
||||||
}
|
|
||||||
self.destroy()
|
|
||||||
|
|
||||||
|
|
||||||
# ── AI API Caller ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
def _ssl_context():
|
|
||||||
"""Create SSL context — skip verify on Windows/frozen (often missing certs)."""
|
|
||||||
if sys.platform == "win32" or getattr(sys, "frozen", False):
|
|
||||||
ctx = ssl.create_default_context()
|
|
||||||
ctx.check_hostname = False
|
|
||||||
ctx.verify_mode = ssl.CERT_NONE
|
|
||||||
return ctx
|
|
||||||
return None # use default on macOS/Linux
|
|
||||||
|
|
||||||
|
|
||||||
def call_claude_api(api_key, query):
|
|
||||||
url = "https://api.anthropic.com/v1/messages"
|
|
||||||
payload = json.dumps({
|
|
||||||
"model": "claude-sonnet-4-20250514",
|
|
||||||
"max_tokens": 1024,
|
|
||||||
"system": AI_SYSTEM_PROMPT,
|
|
||||||
"messages": [{"role": "user", "content": query}],
|
|
||||||
}).encode("utf-8")
|
|
||||||
|
|
||||||
req = urllib.request.Request(url, data=payload, method="POST")
|
|
||||||
req.add_header("x-api-key", api_key)
|
|
||||||
req.add_header("anthropic-version", "2023-06-01")
|
|
||||||
req.add_header("content-type", "application/json")
|
|
||||||
|
|
||||||
ctx = _ssl_context()
|
|
||||||
resp = urllib.request.urlopen(req, timeout=30, context=ctx)
|
|
||||||
data = json.loads(resp.read().decode("utf-8"))
|
|
||||||
text = ""
|
|
||||||
for block in data.get("content", []):
|
|
||||||
if block.get("type") == "text":
|
|
||||||
text += block["text"]
|
|
||||||
return text
|
|
||||||
|
|
||||||
|
|
||||||
def call_deepseek_api(api_key, query):
|
|
||||||
url = "https://api.deepseek.com/v1/chat/completions"
|
|
||||||
payload = json.dumps({
|
|
||||||
"model": "deepseek-chat",
|
|
||||||
"messages": [
|
|
||||||
{"role": "system", "content": AI_SYSTEM_PROMPT},
|
|
||||||
{"role": "user", "content": query},
|
|
||||||
],
|
|
||||||
"max_tokens": 1024,
|
|
||||||
}).encode("utf-8")
|
|
||||||
|
|
||||||
req = urllib.request.Request(url, data=payload, method="POST")
|
|
||||||
req.add_header("Authorization", f"Bearer {api_key}")
|
|
||||||
req.add_header("Content-Type", "application/json")
|
|
||||||
|
|
||||||
ctx = _ssl_context()
|
|
||||||
resp = urllib.request.urlopen(req, timeout=30, context=ctx)
|
|
||||||
data = json.loads(resp.read().decode("utf-8"))
|
|
||||||
choices = data.get("choices", [])
|
|
||||||
if choices:
|
|
||||||
return choices[0].get("message", {}).get("content", "")
|
|
||||||
return ""
|
|
||||||
|
|
||||||
|
|
||||||
# ── SFTP File Manager Widget ──────────────────────────────────
|
# ── SFTP File Manager Widget ──────────────────────────────────
|
||||||
@@ -1266,10 +1194,11 @@ class FileManagerWidget(tk.Frame):
|
|||||||
cols = ("size", "modified", "perms")
|
cols = ("size", "modified", "perms")
|
||||||
self.tree = ttk.Treeview(tree_frame, columns=cols, show="tree headings",
|
self.tree = ttk.Treeview(tree_frame, columns=cols, show="tree headings",
|
||||||
selectmode="browse")
|
selectmode="browse")
|
||||||
self.tree.heading("#0", text="Имя")
|
self.tree.heading("#0", text="Имя", command=lambda: self._sort_column("#0"))
|
||||||
self.tree.heading("size", text="Размер")
|
self.tree.heading("size", text="Размер", command=lambda: self._sort_column("size"))
|
||||||
self.tree.heading("modified", text="Изменён")
|
self.tree.heading("modified", text="Изменён", command=lambda: self._sort_column("modified"))
|
||||||
self.tree.heading("perms", text="Права")
|
self.tree.heading("perms", text="Права", command=lambda: self._sort_column("perms"))
|
||||||
|
self._sort_reverse = False
|
||||||
self.tree.column("#0", width=250, minwidth=150)
|
self.tree.column("#0", width=250, minwidth=150)
|
||||||
self.tree.column("size", width=80, minwidth=60)
|
self.tree.column("size", width=80, minwidth=60)
|
||||||
self.tree.column("modified", width=140, minwidth=100)
|
self.tree.column("modified", width=140, minwidth=100)
|
||||||
@@ -1354,6 +1283,15 @@ class FileManagerWidget(tk.Frame):
|
|||||||
n /= 1024
|
n /= 1024
|
||||||
return f"{n:.1f} ТБ"
|
return f"{n:.1f} ТБ"
|
||||||
|
|
||||||
|
def _sort_column(self, col):
|
||||||
|
"""Sort tree by column, toggle ascending/descending."""
|
||||||
|
items = [(self.tree.set(k, col) if col != "#0" else self.tree.item(k, "text"),
|
||||||
|
k) for k in self.tree.get_children("")]
|
||||||
|
self._sort_reverse = not self._sort_reverse
|
||||||
|
items.sort(key=lambda t: t[0].lower(), reverse=self._sort_reverse)
|
||||||
|
for idx, (_, k) in enumerate(items):
|
||||||
|
self.tree.move(k, "", idx)
|
||||||
|
|
||||||
def _get_selected_name(self):
|
def _get_selected_name(self):
|
||||||
sel = self.tree.selection()
|
sel = self.tree.selection()
|
||||||
if not sel:
|
if not sel:
|
||||||
@@ -1801,40 +1739,15 @@ class App(tk.Tk):
|
|||||||
canvas.create_oval(dx - 1, dy - 1, dx + 1, dy + 1, fill="#6c8fff", outline="")
|
canvas.create_oval(dx - 1, dy - 1, dx + 1, dy + 1, fill="#6c8fff", outline="")
|
||||||
|
|
||||||
def _build_ui(self):
|
def _build_ui(self):
|
||||||
# ── Top: PCA brand bar ──
|
# ── Top: minimal title bar (Termius style) ──
|
||||||
top_bar = tk.Frame(self, bg="#181825", height=48)
|
top_bar = tk.Frame(self, bg="#14141e", height=40)
|
||||||
top_bar.pack(fill=tk.X, side=tk.TOP)
|
top_bar.pack(fill=tk.X, side=tk.TOP)
|
||||||
top_bar.pack_propagate(False)
|
top_bar.pack_propagate(False)
|
||||||
|
|
||||||
logo_canvas = tk.Canvas(top_bar, width=36, height=36, bg="#181825", highlightthickness=0)
|
tk.Label(top_bar, text=f"{APP_NAME}", bg="#14141e", fg="#e0e0e0",
|
||||||
logo_canvas.pack(side=tk.LEFT, padx=(10, 6), pady=6)
|
font=("Helvetica", 13, "bold")).pack(side=tk.LEFT, padx=12, pady=6)
|
||||||
self._draw_pca_logo(logo_canvas, 18, 16, 30)
|
tk.Label(top_bar, text=f"v{VERSION}", bg="#14141e", fg="#555",
|
||||||
|
font=("Helvetica", 10)).pack(side=tk.LEFT, pady=6)
|
||||||
brand_frame = tk.Frame(top_bar, bg="#181825")
|
|
||||||
brand_frame.pack(side=tk.LEFT, pady=4)
|
|
||||||
tk.Label(brand_frame, text=APP_NAME, bg="#181825", fg="#cdd6f4",
|
|
||||||
font=("Consolas", 13, "bold")).pack(anchor="w")
|
|
||||||
tk.Label(brand_frame, text=APP_SUBTITLE, bg="#181825", fg="#6c7086",
|
|
||||||
font=("Consolas", 9)).pack(anchor="w")
|
|
||||||
|
|
||||||
tk.Frame(top_bar, bg="#45475a", width=1).pack(side=tk.LEFT, fill=tk.Y, padx=12, pady=8)
|
|
||||||
|
|
||||||
links = [
|
|
||||||
("GitHub", "https://github.com/nickolay-frolov"),
|
|
||||||
("Boosty", "https://boosty.to/lot_andrey"),
|
|
||||||
("Telegram", "https://t.me/lot_andrey"),
|
|
||||||
("Поддержать (СБП)", "https://finance.ozon.ru/apps/sbp/ozonbankpay/019dc200-2a5d-7931-a619-782d285f6798"),
|
|
||||||
]
|
|
||||||
for i, (text, url) in enumerate(links):
|
|
||||||
if i > 0:
|
|
||||||
tk.Label(top_bar, text="·", bg="#181825", fg="#6c7086", font=("Consolas", 9)).pack(side=tk.LEFT)
|
|
||||||
lnk = tk.Label(top_bar, text=text, bg="#181825", fg="#89b4fa",
|
|
||||||
font=("Consolas", 9, "underline"), cursor="hand2")
|
|
||||||
lnk.pack(side=tk.LEFT, padx=6)
|
|
||||||
lnk.bind("<Button-1>", lambda e, u=url: self._open_url(u))
|
|
||||||
|
|
||||||
tk.Label(top_bar, text="@lot_andrey", bg="#181825", fg="#6c7086",
|
|
||||||
font=("Consolas", 9)).pack(side=tk.RIGHT, padx=10)
|
|
||||||
|
|
||||||
# ── Main layout ──
|
# ── Main layout ──
|
||||||
main_frame = tk.Frame(self, bg="#1e1e2e")
|
main_frame = tk.Frame(self, bg="#1e1e2e")
|
||||||
@@ -1855,17 +1768,18 @@ class App(tk.Tk):
|
|||||||
|
|
||||||
# Welcome tab
|
# Welcome tab
|
||||||
welcome = tk.Frame(self.notebook, bg="#1e1e2e")
|
welcome = tk.Frame(self.notebook, bg="#1e1e2e")
|
||||||
self.notebook.add(welcome, text="Добро пожаловать")
|
self.notebook.add(welcome, text="Welcome")
|
||||||
tk.Label(
|
tk.Label(
|
||||||
welcome,
|
welcome,
|
||||||
text=f"{APP_NAME} v{VERSION}\n\n"
|
text=f"{APP_NAME} v{VERSION}\n\n"
|
||||||
"• Двойной клик по карточке = подключение\n"
|
"Double-click a card to connect\n"
|
||||||
"• Быстрое подключение внизу слева\n"
|
"Quick connect panel at bottom left\n"
|
||||||
"• Все сессии сохраняются автоматически\n"
|
"Sessions auto-saved\n"
|
||||||
"• Ctrl+C/D/L работают в терминале\n"
|
"SSH key auth supported\n"
|
||||||
"• AI-агент справа — спроси что угодно\n"
|
"SFTP file manager per connection\n"
|
||||||
"• Панель команд можно скрыть/показать",
|
"Export/Import sessions via toolbar\n"
|
||||||
font=("Consolas", 12),
|
"Cmd+/- to zoom",
|
||||||
|
font=("Helvetica", 12),
|
||||||
justify=tk.CENTER,
|
justify=tk.CENTER,
|
||||||
bg="#1e1e2e",
|
bg="#1e1e2e",
|
||||||
fg="#cdd6f4",
|
fg="#cdd6f4",
|
||||||
@@ -1892,6 +1806,8 @@ class App(tk.Tk):
|
|||||||
tk.Button(toolbar, text="+ Группа", command=self._new_group, **btn_style).pack(side=tk.LEFT, padx=2, pady=3)
|
tk.Button(toolbar, text="+ Группа", command=self._new_group, **btn_style).pack(side=tk.LEFT, padx=2, pady=3)
|
||||||
tk.Button(toolbar, text="✎", command=self._edit_session, width=3, **btn_style).pack(side=tk.LEFT, padx=1, pady=3)
|
tk.Button(toolbar, text="✎", command=self._edit_session, width=3, **btn_style).pack(side=tk.LEFT, padx=1, pady=3)
|
||||||
tk.Button(toolbar, text="✕", command=self._delete_session, width=3, **btn_style).pack(side=tk.LEFT, padx=1, pady=3)
|
tk.Button(toolbar, text="✕", command=self._delete_session, width=3, **btn_style).pack(side=tk.LEFT, padx=1, pady=3)
|
||||||
|
tk.Button(toolbar, text="↑", command=self._export_sessions, width=3, **btn_style).pack(side=tk.RIGHT, padx=1, pady=3)
|
||||||
|
tk.Button(toolbar, text="↓", command=self._import_sessions, width=3, **btn_style).pack(side=tk.RIGHT, padx=1, pady=3)
|
||||||
|
|
||||||
# Scrollable card area
|
# Scrollable card area
|
||||||
self.card_canvas = tk.Canvas(parent, bg="#1e1e2e", highlightthickness=0)
|
self.card_canvas = tk.Canvas(parent, bg="#1e1e2e", highlightthickness=0)
|
||||||
@@ -2146,6 +2062,7 @@ class App(tk.Tk):
|
|||||||
move_menu.add_command(label=g, command=lambda g=g: self._move_and_refresh(index, g))
|
move_menu.add_command(label=g, command=lambda g=g: self._move_and_refresh(index, g))
|
||||||
menu.add_cascade(label="Переместить в...", menu=move_menu)
|
menu.add_cascade(label="Переместить в...", menu=move_menu)
|
||||||
menu.add_separator()
|
menu.add_separator()
|
||||||
|
menu.add_command(label="Дублировать", command=lambda: self._duplicate_session(index))
|
||||||
menu.add_command(label="Изменить", command=lambda: self._edit_session_by_idx(index))
|
menu.add_command(label="Изменить", command=lambda: self._edit_session_by_idx(index))
|
||||||
menu.add_command(label="Удалить", command=lambda: self._delete_session_by_idx(index))
|
menu.add_command(label="Удалить", command=lambda: self._delete_session_by_idx(index))
|
||||||
|
|
||||||
@@ -2216,29 +2133,16 @@ class App(tk.Tk):
|
|||||||
|
|
||||||
self._populate_commands()
|
self._populate_commands()
|
||||||
|
|
||||||
# ── Agent Panel ──
|
# ── Agent Panel (local knowledge base) ──
|
||||||
agent_wrapper = tk.Frame(self._right_paned, bg="#1e1e2e")
|
agent_wrapper = tk.Frame(self._right_paned, bg="#1e1e2e")
|
||||||
self._right_paned.add(agent_wrapper, minsize=120)
|
self._right_paned.add(agent_wrapper, minsize=120)
|
||||||
|
|
||||||
agent_header = tk.Frame(agent_wrapper, bg="#181825")
|
agent_header = tk.Frame(agent_wrapper, bg="#181825")
|
||||||
agent_header.pack(fill=tk.X)
|
agent_header.pack(fill=tk.X)
|
||||||
|
|
||||||
tk.Label(agent_header, text="AI Агент", bg="#181825", fg="#a6e3a1",
|
tk.Label(agent_header, text="Поиск команд", bg="#181825", fg="#a6e3a1",
|
||||||
font=("Consolas", 11, "bold")).pack(side=tk.LEFT, padx=6, pady=4)
|
font=("Consolas", 11, "bold")).pack(side=tk.LEFT, padx=6, pady=4)
|
||||||
|
|
||||||
# Provider indicator
|
|
||||||
provider = self.config.get("ai_provider", "local")
|
|
||||||
provider_names = {"local": "Локальный", "claude": "Claude", "deepseek": "DeepSeek"}
|
|
||||||
self._provider_label = tk.Label(
|
|
||||||
agent_header, text=f"[{provider_names.get(provider, provider)}]",
|
|
||||||
bg="#181825", fg="#6c7086", font=("Consolas", 8),
|
|
||||||
)
|
|
||||||
self._provider_label.pack(side=tk.LEFT, padx=2)
|
|
||||||
|
|
||||||
tk.Button(agent_header, text="⚙", bg="#181825", fg="#6c7086",
|
|
||||||
activebackground="#181825", relief=tk.FLAT, font=("Consolas", 11),
|
|
||||||
cursor="hand2", command=self._ai_settings).pack(side=tk.RIGHT, padx=6)
|
|
||||||
|
|
||||||
# Agent content
|
# Agent content
|
||||||
self.agent_frame = tk.Frame(agent_wrapper, bg="#1e1e2e")
|
self.agent_frame = tk.Frame(agent_wrapper, bg="#1e1e2e")
|
||||||
self.agent_frame.pack(fill=tk.BOTH, expand=True, padx=4, pady=2)
|
self.agent_frame.pack(fill=tk.BOTH, expand=True, padx=4, pady=2)
|
||||||
@@ -2295,40 +2199,19 @@ class App(tk.Tk):
|
|||||||
self._right_paned.forget(self.commands_container)
|
self._right_paned.forget(self.commands_container)
|
||||||
self._toggle_btn.configure(text="Команды ▶")
|
self._toggle_btn.configure(text="Команды ▶")
|
||||||
|
|
||||||
# ── AI Agent ──────────────────────────────────────────────
|
# ── Command Search (local knowledge base) ─────────────────
|
||||||
|
|
||||||
def _ai_settings(self):
|
|
||||||
dlg = AISettingsDialog(self, self.config.data)
|
|
||||||
if dlg.result:
|
|
||||||
for k, v in dlg.result.items():
|
|
||||||
self.config.set(k, v)
|
|
||||||
provider = self.config.get("ai_provider", "local")
|
|
||||||
provider_names = {"local": "Локальный", "claude": "Claude", "deepseek": "DeepSeek"}
|
|
||||||
self._provider_label.configure(text=f"[{provider_names.get(provider, provider)}]")
|
|
||||||
|
|
||||||
def _agent_ask(self):
|
def _agent_ask(self):
|
||||||
query = self.agent_entry.get().strip()
|
query = self.agent_entry.get().strip()
|
||||||
if not query:
|
if not query:
|
||||||
return
|
return
|
||||||
|
|
||||||
provider = self.config.get("ai_provider", "local")
|
|
||||||
|
|
||||||
if provider == "local":
|
|
||||||
self._agent_ask_local(query)
|
|
||||||
elif provider in ("claude", "deepseek"):
|
|
||||||
self._agent_ask_api(query, provider)
|
|
||||||
|
|
||||||
def _agent_ask_local(self, query):
|
|
||||||
results = agent_search(query)
|
results = agent_search(query)
|
||||||
self.agent_result.configure(state=tk.NORMAL)
|
self.agent_result.configure(state=tk.NORMAL)
|
||||||
self.agent_result.delete("1.0", tk.END)
|
self.agent_result.delete("1.0", tk.END)
|
||||||
if not results:
|
if not results:
|
||||||
self.agent_result.insert(tk.END, "Не нашёл команд. Попробуй другие слова:\n", "desc")
|
self.agent_result.insert(tk.END, "Ничего не найдено. Попробуй:\n", "desc")
|
||||||
self.agent_result.insert(tk.END, "обновить, диск, память, порт, пароль,\n", "hint")
|
self.agent_result.insert(tk.END, "обновить, диск, память, порт, пароль,\n", "hint")
|
||||||
self.agent_result.insert(tk.END, "docker, nginx, ssl, vpn, wifi, dhcp\n\n", "hint")
|
self.agent_result.insert(tk.END, "docker, nginx, ssl, vpn, wifi, dhcp\n", "hint")
|
||||||
self.agent_result.insert(tk.END, "Совет: ", "desc")
|
|
||||||
self.agent_result.insert(tk.END, "подключи Claude или DeepSeek API\n", "ai")
|
|
||||||
self.agent_result.insert(tk.END, "для умного ассистента (⚙ настройки)", "ai")
|
|
||||||
else:
|
else:
|
||||||
self.agent_result.insert(tk.END, "Двойной клик по команде = отправить\n\n", "desc")
|
self.agent_result.insert(tk.END, "Двойной клик по команде = отправить\n\n", "desc")
|
||||||
for _, platform, cmd, desc in results:
|
for _, platform, cmd, desc in results:
|
||||||
@@ -2337,82 +2220,6 @@ class App(tk.Tk):
|
|||||||
self.agent_result.insert(tk.END, f" {desc}\n", "desc")
|
self.agent_result.insert(tk.END, f" {desc}\n", "desc")
|
||||||
self.agent_result.configure(state=tk.DISABLED)
|
self.agent_result.configure(state=tk.DISABLED)
|
||||||
|
|
||||||
def _agent_ask_api(self, query, provider):
|
|
||||||
self.agent_result.configure(state=tk.NORMAL)
|
|
||||||
self.agent_result.delete("1.0", tk.END)
|
|
||||||
self.agent_result.insert(tk.END, "Думаю...\n", "thinking")
|
|
||||||
self.agent_result.configure(state=tk.DISABLED)
|
|
||||||
|
|
||||||
def _call():
|
|
||||||
try:
|
|
||||||
if provider == "claude":
|
|
||||||
api_key = self.config.get("claude_api_key", "")
|
|
||||||
if not api_key:
|
|
||||||
return None, "API-ключ Claude не задан. Нажми ⚙"
|
|
||||||
response = call_claude_api(api_key, query)
|
|
||||||
elif provider == "deepseek":
|
|
||||||
api_key = self.config.get("deepseek_api_key", "")
|
|
||||||
if not api_key:
|
|
||||||
return None, "API-ключ DeepSeek не задан. Нажми ⚙"
|
|
||||||
response = call_deepseek_api(api_key, query)
|
|
||||||
else:
|
|
||||||
return None, "Неизвестный провайдер"
|
|
||||||
return response, None
|
|
||||||
except urllib.error.HTTPError as e:
|
|
||||||
body = e.read().decode("utf-8", errors="replace")[:200]
|
|
||||||
return None, f"HTTP {e.code}: {body}"
|
|
||||||
except Exception as e:
|
|
||||||
return None, str(e)
|
|
||||||
|
|
||||||
def _on_result(response, error):
|
|
||||||
self.agent_result.configure(state=tk.NORMAL)
|
|
||||||
self.agent_result.delete("1.0", tk.END)
|
|
||||||
if error:
|
|
||||||
self.agent_result.insert(tk.END, f"Ошибка: {error}\n", "error")
|
|
||||||
else:
|
|
||||||
self.agent_result.insert(tk.END, "Двойной клик по команде = отправить\n\n", "desc")
|
|
||||||
# Parse response for commands
|
|
||||||
lines = response.strip().split("\n")
|
|
||||||
for line in lines:
|
|
||||||
line = line.strip()
|
|
||||||
if not line:
|
|
||||||
continue
|
|
||||||
# Try to detect command lines: starts with [linux]/[keenetic] or contains command pattern
|
|
||||||
if line.startswith("[linux]") or line.startswith("[keenetic]"):
|
|
||||||
parts = line.split("] ", 1)
|
|
||||||
platform = parts[0] + "]"
|
|
||||||
rest = parts[1] if len(parts) > 1 else ""
|
|
||||||
if " — " in rest:
|
|
||||||
cmd, desc = rest.split(" — ", 1)
|
|
||||||
self.agent_result.insert(tk.END, f"{platform} ", "platform")
|
|
||||||
self.agent_result.insert(tk.END, f"{cmd.strip()}\n", "cmd")
|
|
||||||
self.agent_result.insert(tk.END, f" {desc.strip()}\n", "desc")
|
|
||||||
else:
|
|
||||||
self.agent_result.insert(tk.END, f"{platform} ", "platform")
|
|
||||||
self.agent_result.insert(tk.END, f"{rest}\n", "cmd")
|
|
||||||
elif line.startswith("`") or line.startswith("$") or line.startswith("sudo ") or line.startswith("show "):
|
|
||||||
# Looks like a command
|
|
||||||
cmd = line.strip("`").strip("$ ")
|
|
||||||
self.agent_result.insert(tk.END, f"{cmd}\n", "cmd")
|
|
||||||
elif " — " in line or " - " in line:
|
|
||||||
sep = " — " if " — " in line else " - "
|
|
||||||
cmd, desc = line.split(sep, 1)
|
|
||||||
cmd = cmd.strip().strip("`").strip("$ ")
|
|
||||||
if cmd:
|
|
||||||
self.agent_result.insert(tk.END, f"{cmd}\n", "cmd")
|
|
||||||
self.agent_result.insert(tk.END, f" {desc.strip()}\n", "desc")
|
|
||||||
else:
|
|
||||||
self.agent_result.insert(tk.END, f"{line}\n", "ai")
|
|
||||||
else:
|
|
||||||
self.agent_result.insert(tk.END, f"{line}\n", "ai")
|
|
||||||
self.agent_result.configure(state=tk.DISABLED)
|
|
||||||
|
|
||||||
def _thread():
|
|
||||||
response, error = _call()
|
|
||||||
self.after(0, lambda: _on_result(response, error))
|
|
||||||
|
|
||||||
threading.Thread(target=_thread, daemon=True).start()
|
|
||||||
|
|
||||||
def _agent_send_cmd(self, event):
|
def _agent_send_cmd(self, event):
|
||||||
idx = self.agent_result.index(f"@{event.x},{event.y}")
|
idx = self.agent_result.index(f"@{event.x},{event.y}")
|
||||||
line = self.agent_result.get(f"{idx} linestart", f"{idx} lineend").strip()
|
line = self.agent_result.get(f"{idx} linestart", f"{idx} lineend").strip()
|
||||||
@@ -2491,7 +2298,8 @@ class App(tk.Tk):
|
|||||||
def _connect_by_idx(self, idx):
|
def _connect_by_idx(self, idx):
|
||||||
s = self.store.sessions[idx]
|
s = self.store.sessions[idx]
|
||||||
pw = self.store.get_password(s)
|
pw = self.store.get_password(s)
|
||||||
self._open_terminal(s["host"], s["port"], s["user"], pw, s["name"])
|
self._open_terminal(s["host"], s["port"], s["user"], pw, s["name"],
|
||||||
|
key_file=s.get("key_file"))
|
||||||
|
|
||||||
def _new_session(self):
|
def _new_session(self):
|
||||||
pregroup = ""
|
pregroup = ""
|
||||||
@@ -2502,8 +2310,11 @@ class App(tk.Tk):
|
|||||||
groups=self.store.groups)
|
groups=self.store.groups)
|
||||||
if dlg.result:
|
if dlg.result:
|
||||||
r = dlg.result
|
r = dlg.result
|
||||||
self.store.add(r["host"], r["port"], r["user"], r["password"],
|
s = self.store.add(r["host"], r["port"], r["user"], r["password"],
|
||||||
r["name"], r.get("group", ""))
|
r["name"], r.get("group", ""))
|
||||||
|
if r.get("key_file"):
|
||||||
|
idx = len(self.store.sessions) - 1
|
||||||
|
self.store.update(idx, key_file=r["key_file"])
|
||||||
self._refresh_cards()
|
self._refresh_cards()
|
||||||
|
|
||||||
def _edit_session(self):
|
def _edit_session(self):
|
||||||
@@ -2518,7 +2329,8 @@ class App(tk.Tk):
|
|||||||
r = dlg.result
|
r = dlg.result
|
||||||
update = {"name": r["name"], "description": r.get("description", ""),
|
update = {"name": r["name"], "description": r.get("description", ""),
|
||||||
"host": r["host"], "port": r["port"],
|
"host": r["host"], "port": r["port"],
|
||||||
"user": r["user"], "group": r.get("group", "")}
|
"user": r["user"], "group": r.get("group", ""),
|
||||||
|
"key_file": r.get("key_file", "")}
|
||||||
if r["password"]:
|
if r["password"]:
|
||||||
update["password"] = r["password"]
|
update["password"] = r["password"]
|
||||||
self.store.update(idx, **update)
|
self.store.update(idx, **update)
|
||||||
@@ -2535,6 +2347,35 @@ class App(tk.Tk):
|
|||||||
self._selected_card_idx = None
|
self._selected_card_idx = None
|
||||||
self._refresh_cards()
|
self._refresh_cards()
|
||||||
|
|
||||||
|
def _duplicate_session(self, idx):
|
||||||
|
self.store.duplicate(idx)
|
||||||
|
self._refresh_cards()
|
||||||
|
|
||||||
|
def _export_sessions(self):
|
||||||
|
path = filedialog.asksaveasfilename(
|
||||||
|
parent=self, title="Экспорт сессий",
|
||||||
|
defaultextension=".json", filetypes=[("JSON", "*.json")],
|
||||||
|
initialfile="pca_sessions.json")
|
||||||
|
if path:
|
||||||
|
data = self.store.export_json()
|
||||||
|
with open(path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||||
|
messagebox.showinfo("Экспорт", f"Экспортировано {len(data['sessions'])} сессий", parent=self)
|
||||||
|
|
||||||
|
def _import_sessions(self):
|
||||||
|
path = filedialog.askopenfilename(
|
||||||
|
parent=self, title="Импорт сессий",
|
||||||
|
filetypes=[("JSON", "*.json")])
|
||||||
|
if path:
|
||||||
|
try:
|
||||||
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
self.store.import_json(data)
|
||||||
|
self._refresh_cards()
|
||||||
|
messagebox.showinfo("Импорт", "Сессии импортированы", parent=self)
|
||||||
|
except Exception as e:
|
||||||
|
messagebox.showerror("Ошибка импорта", str(e), parent=self)
|
||||||
|
|
||||||
def _new_group(self):
|
def _new_group(self):
|
||||||
name = simpledialog.askstring("Новая группа", "Название группы:", parent=self)
|
name = simpledialog.askstring("Новая группа", "Название группы:", parent=self)
|
||||||
if name and name.strip():
|
if name and name.strip():
|
||||||
@@ -2570,27 +2411,31 @@ class App(tk.Tk):
|
|||||||
self._open_terminal(host, port, user, pw)
|
self._open_terminal(host, port, user, pw)
|
||||||
self.q_pass.delete(0, tk.END)
|
self.q_pass.delete(0, tk.END)
|
||||||
|
|
||||||
def _open_terminal(self, host, port, user, password, name=None):
|
def _open_terminal(self, host, port, user, password, name=None, key_file=None):
|
||||||
tab_name = name or f"{user}@{host}"
|
tab_name = name or f"{user}@{host}"
|
||||||
|
|
||||||
term = TerminalWidget(self.notebook, on_close=lambda: None)
|
term = TerminalWidget(self.notebook, on_close=lambda: None)
|
||||||
self.notebook.add(term, text=f"● {tab_name}")
|
self.notebook.add(term, text=f"● {tab_name}")
|
||||||
self.notebook.select(term)
|
self.notebook.select(term)
|
||||||
term.connect(host, port, user, password)
|
term.connect(host, port, user, password, key_file=key_file)
|
||||||
|
|
||||||
# Store connection info for file manager
|
# Store connection info for file manager and reconnect
|
||||||
term._conn_info = {"host": host, "port": port, "user": user, "password": password}
|
term._conn_info = {"host": host, "port": port, "user": user,
|
||||||
|
"password": password, "key_file": key_file}
|
||||||
|
|
||||||
close_btn_frame = tk.Frame(term, bg="#1e1e2e")
|
close_btn_frame = tk.Frame(term, bg="#1e1e2e")
|
||||||
|
|
||||||
btn_s = {"bg": "#313244", "fg": "#89b4fa", "activebackground": "#45475a",
|
btn_s = {"bg": "#313244", "fg": "#89b4fa", "activebackground": "#45475a",
|
||||||
"relief": tk.FLAT, "font": ("Consolas", 9), "padx": 6, "pady": 2}
|
"relief": tk.FLAT, "font": ("Consolas", 9), "padx": 6, "pady": 2}
|
||||||
tk.Button(close_btn_frame, text="📁 Файлы",
|
tk.Button(close_btn_frame, text="SFTP",
|
||||||
command=lambda: self._open_file_manager(term),
|
command=lambda: self._open_file_manager(term),
|
||||||
**btn_s).pack(side=tk.LEFT, padx=4, pady=2)
|
**btn_s).pack(side=tk.LEFT, padx=4, pady=2)
|
||||||
|
tk.Button(close_btn_frame, text="Reconnect",
|
||||||
|
command=lambda: self._reconnect_terminal(term),
|
||||||
|
**btn_s).pack(side=tk.LEFT, padx=4, pady=2)
|
||||||
|
|
||||||
tk.Button(
|
tk.Button(
|
||||||
close_btn_frame, text="✕ Закрыть",
|
close_btn_frame, text="Close",
|
||||||
bg="#313244", fg="#cdd6f4", activebackground="#45475a",
|
bg="#313244", fg="#cdd6f4", activebackground="#45475a",
|
||||||
relief=tk.FLAT, font=("Consolas", 9), padx=6, pady=2,
|
relief=tk.FLAT, font=("Consolas", 9), padx=6, pady=2,
|
||||||
command=lambda: self._close_tab(term),
|
command=lambda: self._close_tab(term),
|
||||||
@@ -2618,17 +2463,37 @@ class App(tk.Tk):
|
|||||||
self.notebook.add(fm, text=tab_name)
|
self.notebook.add(fm, text=tab_name)
|
||||||
self.notebook.select(fm)
|
self.notebook.select(fm)
|
||||||
|
|
||||||
# Connect in background
|
# Connect in background (with key auth support)
|
||||||
def _connect():
|
def _connect():
|
||||||
try:
|
try:
|
||||||
ssh2.connect(info["host"], port=info["port"],
|
connect_kw = dict(hostname=info["host"], port=info["port"],
|
||||||
username=info["user"], password=info["password"],
|
username=info["user"], timeout=10)
|
||||||
timeout=10, look_for_keys=False, allow_agent=False)
|
key_file = info.get("key_file")
|
||||||
|
if key_file and os.path.isfile(key_file):
|
||||||
|
connect_kw["key_filename"] = key_file
|
||||||
|
connect_kw["look_for_keys"] = False
|
||||||
|
connect_kw["allow_agent"] = False
|
||||||
|
if info.get("password"):
|
||||||
|
connect_kw["passphrase"] = info["password"]
|
||||||
|
else:
|
||||||
|
connect_kw["password"] = info["password"]
|
||||||
|
connect_kw["look_for_keys"] = False
|
||||||
|
connect_kw["allow_agent"] = False
|
||||||
|
ssh2.connect(**connect_kw)
|
||||||
fm.after(0, fm._connect_sftp)
|
fm.after(0, fm._connect_sftp)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
fm.after(0, lambda: fm.status_var.set(f"Ошибка SSH: {e}"))
|
fm.after(0, lambda: fm.status_var.set(f"Ошибка SSH: {e}"))
|
||||||
threading.Thread(target=_connect, daemon=True).start()
|
threading.Thread(target=_connect, daemon=True).start()
|
||||||
|
|
||||||
|
def _reconnect_terminal(self, term):
|
||||||
|
info = getattr(term, "_conn_info", None)
|
||||||
|
if not info:
|
||||||
|
return
|
||||||
|
term.disconnect()
|
||||||
|
term._write("\n--- Переподключение ---\n", "info")
|
||||||
|
term.connect(info["host"], info["port"], info["user"], info["password"],
|
||||||
|
key_file=info.get("key_file"))
|
||||||
|
|
||||||
def _close_tab(self, term):
|
def _close_tab(self, term):
|
||||||
term.disconnect()
|
term.disconnect()
|
||||||
self.notebook.forget(term)
|
self.notebook.forget(term)
|
||||||
|
|||||||
Reference in New Issue
Block a user