Files
ssh-manage/ssh_manager.py
Андрей Бобырев a7f7d8db8e fix: catch-all modifier detection for macOS Cmd+V paste in frozen .app
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-21 11:17:15 +03:00

2516 lines
114 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
PCA SSH — Private Control Administration
SSH client with card-based UI, AI agents (Claude/DeepSeek), and auto-save sessions.
Tkinter GUI + paramiko SSH + JSON session store.
"""
import tkinter as tk
from tkinter import ttk, messagebox, font as tkfont, simpledialog, filedialog
import paramiko
import threading
import json
import os
import sys
import re
import base64
import socket
import urllib.request
import urllib.error
import ssl
from datetime import datetime
from pathlib import Path
import stat
APP_NAME = "PCA SSH"
APP_SUBTITLE = "Private Control Administration"
VERSION = "2.0"
# ── Card Colors (Catppuccin) ──────────────────────────────────
CARD_COLORS = [
"#89b4fa", # blue
"#a6e3a1", # green
"#cba6f7", # purple
"#fab387", # orange
"#f38ba8", # red
"#94e2d5", # teal
"#f9e2af", # yellow
"#89dceb", # sky
]
# ── Quick Commands ─────────────────────────────────────────────
COMMANDS = {
"Ubuntu / Linux": {
"Система": [
("uname -a", "Ядро и архитектура"),
("cat /etc/os-release", "Версия ОС"),
("uptime -p", "Аптайм"),
("hostnamectl", "Имя хоста и ОС"),
("timedatectl", "Время и часовой пояс"),
("last reboot | head -5", "Последние перезагрузки"),
("who", "Кто залогинен"),
("w", "Активные сессии и нагрузка"),
],
"Ресурсы": [
("htop", "Мониторинг процессов (интерактивный)"),
("top -bn1 | head -20", "Топ процессов (снимок)"),
("free -h", "Оперативная память"),
("df -hT", "Диски и файловые системы"),
("du -sh /* 2>/dev/null | sort -rh | head -10", "Топ-10 тяжёлых директорий"),
("lsblk", "Блочные устройства"),
("iostat -x 1 3", "Нагрузка на диски"),
("vmstat 1 5", "CPU/память/swap за 5 сек"),
("cat /proc/cpuinfo | grep 'model name' | head -1", "Модель процессора"),
("nproc", "Количество ядер CPU"),
],
"Сеть": [
("ip addr", "IP-адреса интерфейсов"),
("ip route", "Таблица маршрутов"),
("ss -tulpn", "Открытые порты и процессы"),
("netstat -tulpn", "Порты (классический)"),
("curl -s ifconfig.me", "Внешний IP"),
("ping -c 4 8.8.8.8", "Пинг Google DNS"),
("ping -c 4 1.1.1.1", "Пинг Cloudflare"),
("traceroute 8.8.8.8", "Трассировка до Google"),
("dig google.com +short", "DNS-запрос"),
("nslookup google.com", "DNS (nslookup)"),
("iptables -L -n --line-numbers", "Правила файрвола"),
("ufw status verbose", "Статус UFW"),
("cat /etc/resolv.conf", "DNS-серверы"),
("arp -a", "ARP-таблица"),
("ethtool eth0", "Состояние сетевого адаптера"),
],
"Сервисы": [
("systemctl list-units --type=service --state=running", "Запущенные сервисы"),
("systemctl status", "Общий статус systemd"),
("systemctl --failed", "Упавшие сервисы"),
("journalctl -xe --no-pager | tail -50", "Последние логи"),
("journalctl -u nginx --since '1 hour ago'", "Логи nginx за час"),
("crontab -l", "Задачи cron"),
("systemctl list-timers", "Systemd-таймеры"),
],
"Docker": [
("docker ps", "Запущенные контейнеры"),
("docker ps -a", "Все контейнеры"),
("docker images", "Образы"),
("docker stats --no-stream", "Ресурсы контейнеров"),
("docker logs --tail 50 $(docker ps -q | head -1)", "Логи последнего контейнера"),
("docker system df", "Занятое место Docker"),
("docker system prune -f", "Очистка мусора Docker"),
("docker-compose ps", "Compose-сервисы"),
],
"Безопасность": [
("last -10", "Последние 10 логинов"),
("lastb -10 2>/dev/null", "Неудачные логины"),
("cat /var/log/auth.log | tail -20", "Лог авторизации"),
("find / -perm -4000 -type f 2>/dev/null", "SUID-файлы"),
("cat /etc/passwd | grep -v nologin | grep -v false", "Пользователи с шеллом"),
("ss -tulpn | grep LISTEN", "Слушающие порты"),
("fail2ban-client status 2>/dev/null", "Статус Fail2Ban"),
("openssl x509 -in /etc/ssl/certs/*.pem -noout -dates 2>/dev/null | head -4", "Сроки SSL-сертов"),
],
"Пользователи и пароли": [
("cat /etc/passwd", "Все пользователи системы"),
("cat /etc/passwd | grep -v nologin | grep -v false", "Пользователи с шеллом"),
("getent passwd | awk -F: '$3 >= 1000 {print $1}'", "Обычные пользователи (UID >= 1000)"),
("groups", "Группы текущего пользователя"),
("id", "UID/GID текущего пользователя"),
("id username", "Инфо о пользователе username"),
("passwd", "Сменить свой пароль"),
("passwd username", "Сменить пароль пользователю"),
("chage -l username", "Срок действия пароля"),
("chage -M 90 username", "Пароль истекает через 90 дней"),
("useradd -m -s /bin/bash username", "Создать пользователя с домашней папкой"),
("useradd -m -s /bin/bash -G sudo username", "Создать пользователя с sudo"),
("useradd -r -s /usr/sbin/nologin svcname", "Создать сервисного пользователя"),
("userdel username", "Удалить пользователя (без домашней)"),
("userdel -r username", "Удалить пользователя + домашнюю папку"),
("usermod -aG sudo username", "Добавить в группу sudo"),
("usermod -aG docker username", "Добавить в группу docker"),
("usermod -L username", "Заблокировать пользователя"),
("usermod -U username", "Разблокировать пользователя"),
("usermod -s /usr/sbin/nologin username", "Запретить логин"),
("usermod -s /bin/bash username", "Разрешить логин"),
("groupadd groupname", "Создать группу"),
("groupdel groupname", "Удалить группу"),
("gpasswd -d username groupname", "Убрать из группы"),
("cat /etc/shadow | grep username", "Хеш пароля (root)"),
("passwd -S username", "Статус пароля"),
("echo 'username:newpassword' | chpasswd", "Задать пароль одной командой"),
],
"SSH-ключи": [
("ssh-keygen -t ed25519 -C 'comment'", "Генерировать SSH-ключ ed25519"),
("ssh-keygen -t rsa -b 4096", "Генерировать SSH-ключ RSA 4096"),
("cat ~/.ssh/id_ed25519.pub", "Показать публичный ключ"),
("cat ~/.ssh/authorized_keys", "Авторизованные ключи"),
("ssh-copy-id user@host", "Скопировать ключ на сервер"),
("chmod 700 ~/.ssh && chmod 600 ~/.ssh/authorized_keys", "Права на SSH-директорию"),
],
"Файлы и пакеты": [
("ls -lahS / | head -20", "Большие файлы в корне"),
("find /var/log -name '*.log' -mtime -1", "Свежие логи"),
("apt list --upgradable 2>/dev/null", "Доступные обновления (apt)"),
("yum check-update 2>/dev/null", "Доступные обновления (yum)"),
("dpkg -l | wc -l", "Количество пакетов"),
("tail -f /var/log/syslog", "Сислог в реальном времени"),
],
},
"Keenetic (NDMS CLI)": {
"Информация": [
("show version", "Прошивка и модель"),
("show system", "Загрузка CPU/RAM"),
("show clock", "Время роутера"),
("show uptime", "Аптайм"),
("show log", "Системный лог"),
("show running-config", "Текущая конфигурация"),
("show startup-config", "Стартовая конфигурация"),
("show components", "Установленные компоненты"),
],
"Сеть": [
("show interface", "Все интерфейсы"),
("show interface WifiMaster0", "Wi-Fi 2.4 ГГц"),
("show interface WifiMaster1", "Wi-Fi 5 ГГц"),
("show interface PPPoE0", "PPPoE-подключение"),
("show ip route", "Таблица маршрутов"),
("show ip arp", "ARP-таблица"),
("show ip dhcp bindings", "DHCP-аренды"),
("show ip dhcp pool", "DHCP-пулы"),
("show ip hotspot", "Подключённые устройства"),
("show ip hotspot host", "Список хостов"),
("show ndns", "Статус KeenDNS"),
("show internet status", "Статус интернета"),
],
"VPN": [
("show crypto ipsec sa", "IPsec-туннели"),
("show vpn-server", "VPN-сервер"),
("show interface Wireguard0", "WireGuard статус"),
("show interface OpenVPN0", "OpenVPN статус"),
("show interface PPTP0", "PPTP статус"),
("show interface L2TP0", "L2TP статус"),
("show interface SSTP0", "SSTP статус"),
],
"Wi-Fi": [
("show associations", "Подключённые клиенты Wi-Fi"),
("show interface WifiMaster0 wps", "WPS статус 2.4"),
("show interface WifiMaster1 wps", "WPS статус 5"),
("interface WifiMaster0 down", "Выключить Wi-Fi 2.4"),
("interface WifiMaster0 up", "Включить Wi-Fi 2.4"),
("interface WifiMaster1 down", "Выключить Wi-Fi 5"),
("interface WifiMaster1 up", "Включить Wi-Fi 5"),
],
"USB и накопители": [
("show usb", "USB-устройства"),
("show media", "Накопители"),
("show printer", "USB-принтер"),
("show interface UsbModem0", "USB-модем"),
],
"Диагностика": [
("tools ping 8.8.8.8", "Пинг Google DNS"),
("tools ping 1.1.1.1", "Пинг Cloudflare"),
("tools traceroute 8.8.8.8", "Трассировка"),
("tools dns-lookup google.com", "DNS-запрос"),
("tools ntp-sync", "Синхронизация времени"),
("show speed-test", "Speedtest (если доступен)"),
],
"Пользователи и пароли": [
("show user", "Список пользователей роутера"),
("user admin", "Войти в настройки пользователя admin"),
("user admin password newpass", "Сменить пароль admin"),
("user add username", "Создать пользователя"),
("user username password newpass", "Задать пароль пользователю"),
("user username tag http", "Разрешить доступ к web-интерфейсу"),
("user username tag cli", "Разрешить доступ к CLI"),
("user username tag http,cli,ftp", "Доступ: web + CLI + FTP"),
("no user username", "Удалить пользователя"),
("user username permit http 192.168.1.0/24", "Доступ только из подсети"),
("ip http security-level private", "Web-интерфейс только из LAN"),
("ip http security-level public", "Web-интерфейс из WAN (осторожно!)"),
("ip telnet security-level private", "Telnet только из LAN"),
("ip ssh security-level private", "SSH только из LAN"),
],
"Управление": [
("system configuration save", "Сохранить конфигурацию"),
("system reboot", "Перезагрузка роутера"),
("system log clear", "Очистить лог"),
("tools firmware check", "Проверить обновления прошивки"),
("tools firmware upgrade", "Обновить прошивку"),
("copy running-config startup-config", "Применить конфигурацию"),
],
},
"Быстрые скрипты": {
"Мониторинг": [
("watch -n 2 'free -h && echo --- && df -h /'", "Память+диск каждые 2 сек"),
("while true; do date; ss -s; sleep 5; done", "Счётчик соединений каждые 5 сек"),
("tail -f /var/log/syslog | grep -i error", "Ошибки в сислоге (live)"),
("sar -u 1 10", "CPU-нагрузка 10 замеров"),
],
"Сеть (скрипты)": [
("for h in 8.8.8.8 1.1.1.1 ya.ru; do echo -n \"$h: \"; ping -c1 -W2 $h | grep time= || echo FAIL; done", "Пинг 3 хостов"),
("curl -so /dev/null -w '%{time_total}s' https://google.com && echo", "Время ответа Google"),
("ss -s", "Сводка по соединениям"),
],
"Очистка": [
("apt autoremove -y && apt clean", "Очистка apt (Debian/Ubuntu)"),
("journalctl --vacuum-size=100M", "Обрезать логи до 100MB"),
("find /tmp -mtime +7 -delete", "Удалить файлы /tmp старше 7 дней"),
("find /var/log -name '*.gz' -delete", "Удалить сжатые логи"),
],
},
}
# ── Command Agent Knowledge Base ───────────────────────────────
AGENT_KB = [
(["обновить", "update", "upgrade", "обновление", "апдейт"],
"linux", [
("sudo apt update && sudo apt upgrade -y", "Обновить все пакеты (Debian/Ubuntu)"),
("sudo apt dist-upgrade -y", "Полное обновление с зависимостями"),
("sudo yum update -y", "Обновить все пакеты (CentOS/RHEL)"),
("sudo dnf upgrade -y", "Обновить все пакеты (Fedora)"),
("sudo apt list --upgradable", "Показать доступные обновления"),
]),
(["перезагрузить", "reboot", "restart", "рестарт", "ребут"],
"linux", [
("sudo reboot", "Перезагрузить сервер"),
("sudo shutdown -r +1 'Reboot in 1 min'", "Перезагрузка через 1 минуту"),
("sudo systemctl restart nginx", "Перестартовать nginx"),
("sudo systemctl restart sshd", "Перестартовать SSH"),
]),
(["перезагрузить", "reboot", "restart"],
"keenetic", [
("system reboot", "Перезагрузить роутер"),
]),
(["место", "disk", "диск", "свободно", "занято", "очистить диск", "нет места"],
"linux", [
("df -hT", "Показать свободное место"),
("du -sh /* 2>/dev/null | sort -rh | head -10", "Топ-10 тяжёлых папок"),
("sudo apt autoremove -y && sudo apt clean", "Очистить кэш apt"),
("sudo journalctl --vacuum-size=100M", "Обрезать логи до 100MB"),
("find /var/log -name '*.gz' -delete", "Удалить сжатые логи"),
("docker system prune -af", "Очистить Docker (образы, контейнеры)"),
]),
(["память", "ram", "memory", "swap", "oom"],
"linux", [
("free -h", "Показать RAM и swap"),
("top -bn1 | head -20", "Топ процессов по CPU/RAM"),
("ps aux --sort=-%mem | head -15", "Процессы по потреблению RAM"),
("sudo swapon --show", "Активные swap-разделы"),
("sudo sync && echo 3 | sudo tee /proc/sys/vm/drop_caches", "Очистить кэш памяти"),
]),
(["пользователь", "user", "юзер", "создать пользователя", "добавить пользователя"],
"linux", [
("sudo useradd -m -s /bin/bash username", "Создать пользователя"),
("sudo useradd -m -s /bin/bash -G sudo username", "Создать с sudo"),
("sudo passwd username", "Задать пароль"),
("sudo userdel -r username", "Удалить пользователя"),
("cat /etc/passwd | grep -v nologin", "Пользователи с шеллом"),
]),
(["пользователь", "user", "юзер", "пароль"],
"keenetic", [
("show user", "Список пользователей роутера"),
("user add username", "Создать пользователя"),
("user admin password newpass", "Сменить пароль admin"),
("no user username", "Удалить пользователя"),
]),
(["пароль", "password", "сменить пароль"],
"linux", [
("passwd", "Сменить свой пароль"),
("sudo passwd username", "Сменить пароль другому"),
("sudo chage -l username", "Срок действия пароля"),
("echo 'user:pass' | sudo chpasswd", "Задать пароль одной командой"),
]),
(["порт", "port", "слушает", "listen", "открытые порты"],
"linux", [
("ss -tulpn", "Все слушающие порты"),
("ss -tulpn | grep :80", "Кто слушает порт 80"),
("sudo lsof -i :443", "Процесс на порту 443"),
("sudo ufw allow 8080/tcp", "Открыть порт 8080"),
("sudo iptables -A INPUT -p tcp --dport 3000 -j ACCEPT", "Открыть порт iptables"),
]),
(["файрвол", "firewall", "ufw", "iptables", "заблокировать"],
"linux", [
("sudo ufw status verbose", "Статус UFW"),
("sudo ufw enable", "Включить UFW"),
("sudo ufw allow ssh", "Разрешить SSH"),
("sudo ufw allow 80,443/tcp", "Разрешить HTTP/HTTPS"),
("sudo ufw deny from 1.2.3.4", "Заблокировать IP"),
("sudo iptables -L -n --line-numbers", "Правила iptables"),
]),
(["сервис", "service", "демон", "systemctl", "запустить", "остановить"],
"linux", [
("sudo systemctl status servicename", "Статус сервиса"),
("sudo systemctl start servicename", "Запустить"),
("sudo systemctl stop servicename", "Остановить"),
("sudo systemctl restart servicename", "Перезапустить"),
("sudo systemctl enable servicename", "Автозапуск"),
("sudo systemctl disable servicename", "Убрать из автозапуска"),
("journalctl -u servicename -f", "Логи сервиса (live)"),
]),
(["лог", "log", "логи", "журнал", "ошибки"],
"linux", [
("journalctl -xe --no-pager | tail -50", "Последние системные логи"),
("tail -f /var/log/syslog", "Сислог (live)"),
("tail -f /var/log/auth.log", "Логи авторизации (live)"),
("journalctl --since '1 hour ago'", "Логи за последний час"),
("dmesg | tail -30", "Сообщения ядра"),
("grep -i error /var/log/syslog | tail -20", "Ошибки в сислоге"),
]),
(["лог", "log"],
"keenetic", [
("show log", "Системный лог роутера"),
("system log clear", "Очистить лог"),
]),
(["ssh", "ключ", "key", "ssh-ключ", "авторизация"],
"linux", [
("ssh-keygen -t ed25519 -C 'my key'", "Генерировать ключ ed25519"),
("cat ~/.ssh/id_ed25519.pub", "Показать публичный ключ"),
("ssh-copy-id user@host", "Скопировать ключ на сервер"),
("chmod 700 ~/.ssh && chmod 600 ~/.ssh/authorized_keys", "Права SSH"),
]),
(["docker", "контейнер", "container"],
"linux", [
("docker ps", "Запущенные контейнеры"),
("docker ps -a", "Все контейнеры"),
("docker logs --tail 50 container_name", "Логи контейнера"),
("docker restart container_name", "Перезапустить контейнер"),
("docker system prune -f", "Очистить мусор"),
("docker stats --no-stream", "Ресурсы контейнеров"),
]),
(["ip", "адрес", "сеть", "network", "интерфейс"],
"linux", [
("ip addr", "IP-адреса"),
("ip route", "Маршруты"),
("curl -s ifconfig.me", "Внешний IP"),
("cat /etc/resolv.conf", "DNS-серверы"),
("ping -c 4 8.8.8.8", "Пинг Google"),
]),
(["ip", "адрес", "сеть", "интерфейс"],
"keenetic", [
("show interface", "Все интерфейсы"),
("show ip route", "Маршруты"),
("show ip hotspot", "Подключённые устройства"),
("show internet status", "Статус интернета"),
]),
(["vpn", "wireguard", "openvpn", "туннель"],
"keenetic", [
("show interface Wireguard0", "WireGuard статус"),
("show interface OpenVPN0", "OpenVPN статус"),
("show vpn-server", "VPN-сервер"),
("show crypto ipsec sa", "IPsec-туннели"),
]),
(["wifi", "wi-fi", "вайфай", "беспроводн"],
"keenetic", [
("show associations", "Клиенты Wi-Fi"),
("show interface WifiMaster0", "Wi-Fi 2.4 ГГц"),
("show interface WifiMaster1", "Wi-Fi 5 ГГц"),
("interface WifiMaster0 down", "Выключить 2.4"),
("interface WifiMaster0 up", "Включить 2.4"),
]),
(["dhcp", "аренда", "lease"],
"keenetic", [
("show ip dhcp bindings", "DHCP-аренды"),
("show ip dhcp pool", "DHCP-пулы"),
]),
(["dns", "днс", "resolve"],
"linux", [
("dig google.com +short", "DNS-запрос"),
("nslookup google.com", "nslookup"),
("cat /etc/resolv.conf", "DNS-серверы"),
("systemd-resolve --status | head -20", "systemd-resolved статус"),
]),
(["прошивка", "firmware", "обновить роутер"],
"keenetic", [
("show version", "Текущая прошивка"),
("tools firmware check", "Проверить обновления"),
("tools firmware upgrade", "Обновить прошивку"),
("show components", "Компоненты"),
]),
(["сохранить", "save", "конфиг", "config"],
"keenetic", [
("system configuration save", "Сохранить конфигурацию"),
("show running-config", "Текущая конфигурация"),
("copy running-config startup-config", "Применить конфиг"),
]),
(["cron", "расписание", "schedule", "автоматически"],
"linux", [
("crontab -l", "Показать задачи cron"),
("crontab -e", "Редактировать cron"),
("systemctl list-timers", "Systemd-таймеры"),
("echo '0 3 * * * /path/script.sh' | crontab -", "Запуск каждый день в 3:00"),
]),
(["nginx", "веб-сервер", "apache", "сайт"],
"linux", [
("sudo systemctl status nginx", "Статус nginx"),
("sudo nginx -t", "Проверить конфиг nginx"),
("sudo systemctl reload nginx", "Перечитать конфиг"),
("tail -f /var/log/nginx/error.log", "Ошибки nginx (live)"),
("tail -f /var/log/nginx/access.log", "Доступ nginx (live)"),
]),
(["ssl", "сертификат", "certbot", "https", "let's encrypt"],
"linux", [
("sudo certbot --nginx -d domain.com", "Получить SSL (nginx)"),
("sudo certbot renew --dry-run", "Тест обновления сертов"),
("sudo certbot certificates", "Список сертификатов"),
("openssl x509 -in cert.pem -noout -dates", "Срок действия серта"),
]),
(["процесс", "process", "kill", "убить", "завис"],
"linux", [
("ps aux | grep process_name", "Найти процесс"),
("kill -9 PID", "Убить по PID"),
("pkill -f process_name", "Убить по имени"),
("top -bn1 | head -20", "Топ процессов"),
("htop", "Интерактивный мониторинг"),
]),
]
AI_SYSTEM_PROMPT = """Ты — SSH-ассистент в программе PCA SSH. Помогай пользователю командами для Linux (Ubuntu/Debian/CentOS) и Keenetic роутеров (NDMS CLI).
Правила:
1. Отвечай ТОЛЬКО командами с кратким описанием
2. Формат ответа — каждая команда на новой строке: КОМАНДА — описание
3. Максимум 8 команд в ответе
4. Указывай платформу: [linux] или [keenetic]
5. Если вопрос про роутер/кинетик — давай команды NDMS CLI
6. Если вопрос про сервер/линукс — давай bash-команды
7. Отвечай по-русски
8. Не добавляй лишних пояснений, только команды"""
def agent_search(query):
query_lower = query.lower()
results = []
seen = set()
for keywords, platform, cmds in AGENT_KB:
score = sum(1 for kw in keywords if kw in query_lower)
if score > 0:
for cmd, desc in cmds:
if cmd not in seen:
results.append((score, platform, cmd, desc))
seen.add(cmd)
results.sort(key=lambda x: -x[0])
return results[:12]
def setup_entry_clipboard(entry):
"""Fix clipboard on all platforms. Covers every known Tk modifier mapping."""
def _paste(e=None):
try:
text = entry.clipboard_get()
if entry.selection_present():
entry.delete(tk.SEL_FIRST, tk.SEL_LAST)
entry.insert(tk.INSERT, text)
except tk.TclError:
pass
return "break"
def _copy(e=None):
if entry.selection_present():
entry.clipboard_clear()
entry.clipboard_append(entry.selection_get())
return "break"
def _cut(e=None):
_copy()
if entry.selection_present():
entry.delete(tk.SEL_FIRST, tk.SEL_LAST)
return "break"
def _select_all(e=None):
entry.select_range(0, tk.END)
entry.icursor(tk.END)
return "break"
def _context_menu(event):
menu = tk.Menu(entry, tearoff=0, bg="#313244", fg="#cdd6f4",
activebackground="#45475a")
menu.add_command(label="Cut", command=_cut)
menu.add_command(label="Copy", command=_copy)
menu.add_command(label="Paste", command=_paste)
menu.add_separator()
menu.add_command(label="Select All", command=_select_all)
menu.tk_popup(event.x_root, event.y_root)
# Bind virtual events (works on all platforms)
entry.bind("<<Paste>>", _paste)
entry.bind("<<Copy>>", _copy)
entry.bind("<<Cut>>", _cut)
entry.bind("<<SelectAll>>", _select_all)
if sys.platform == "darwin":
# macOS: physical Command key may map to Command, Mod1, Mod2, or Meta
# depending on Tk version and frozen/unfrozen state.
# Bind ALL possible modifiers to guarantee it works.
for mod in ("Command", "Mod1", "Meta", "Mod2"):
entry.bind(f"<{mod}-v>", _paste)
entry.bind(f"<{mod}-c>", _copy)
entry.bind(f"<{mod}-x>", _cut)
entry.bind(f"<{mod}-a>", _select_all)
# Also register Command-v as trigger for <<Paste>> virtual event
for seq in ("<Command-v>", "<Mod1-v>", "<Meta-v>", "<Mod2-v>"):
try:
entry.event_add("<<Paste>>", seq)
except tk.TclError:
pass
# Right-click
entry.bind("<Button-2>", _context_menu)
entry.bind("<Control-Button-1>", _context_menu)
# Catch-all: in frozen .app, Command may map to unknown modifier bit.
# Named bindings (<Command-v> etc.) won't match → add <Key> fallback.
def _mac_key_fallback(event):
if event.state & 4: # Ctrl — not Command
return
modifier_mask = event.state & ~0x07 # clear Shift, CapsLock, Ctrl
if modifier_mask:
if event.keysym == "v":
return _paste(event)
if event.keysym == "c":
return _copy(event)
if event.keysym == "x":
return _cut(event)
if event.keysym == "a":
return _select_all(event)
entry.bind("<Key>", _mac_key_fallback)
else:
# Windows/Linux: Ctrl+V
entry.bind("<Control-v>", _paste)
entry.bind("<Control-c>", _copy)
entry.bind("<Control-x>", _cut)
entry.bind("<Control-a>", _select_all)
entry.bind("<Button-3>", _context_menu)
def app_dir():
if getattr(sys, "frozen", False):
return Path(sys.executable).parent
return Path(__file__).parent
SESSIONS_FILE = app_dir() / "sessions.json"
CONFIG_FILE = app_dir() / "pca_config.json"
# ── Config Store ──────────────────────────────────────────────
class ConfigStore:
def __init__(self, path=CONFIG_FILE):
self.path = Path(path)
self.data = {
"ai_provider": "local",
"claude_api_key": "",
"deepseek_api_key": "",
"commands_visible": True,
}
self.load()
def load(self):
if self.path.exists():
try:
self.data.update(json.loads(self.path.read_text("utf-8")))
except Exception:
pass
def save(self):
self.path.write_text(json.dumps(self.data, indent=2, ensure_ascii=False), "utf-8")
def get(self, key, default=None):
return self.data.get(key, default)
def set(self, key, value):
self.data[key] = value
self.save()
# ── Session Store ──────────────────────────────────────────────
class SessionStore:
def __init__(self, path=SESSIONS_FILE):
self.path = Path(path)
self.data = {"groups": [], "sessions": []}
self.load()
@property
def sessions(self):
return self.data["sessions"]
@property
def groups(self):
return self.data["groups"]
def load(self):
if self.path.exists():
try:
raw = json.loads(self.path.read_text("utf-8"))
if isinstance(raw, list):
self.data = {"groups": ["Без группы"], "sessions": []}
for s in raw:
s.setdefault("group", "Без группы")
self.data["sessions"].append(s)
self.save()
else:
self.data = raw
self.data.setdefault("groups", [])
self.data.setdefault("sessions", [])
except Exception:
self.data = {"groups": [], "sessions": []}
def save(self):
self.path.write_text(json.dumps(self.data, indent=2, ensure_ascii=False), "utf-8")
def add_group(self, name):
if name and name not in self.groups:
self.groups.append(name)
self.save()
def rename_group(self, old_name, new_name):
if old_name in self.groups and new_name:
idx = self.groups.index(old_name)
self.groups[idx] = new_name
for s in self.sessions:
if s.get("group") == old_name:
s["group"] = new_name
self.save()
def remove_group(self, name):
if name in self.groups:
self.groups.remove(name)
for s in self.sessions:
if s.get("group") == name:
s["group"] = ""
self.save()
def add(self, host, port, user, password, name=None, group=""):
for s in self.sessions:
if s["host"] == host and s["port"] == port and s["user"] == user:
s["password"] = self._encode(password)
s["last_used"] = datetime.now().isoformat()
if name:
s["name"] = name
if group:
s["group"] = group
self.save()
return s
entry = {
"name": name or f"{user}@{host}",
"host": host,
"port": port,
"user": user,
"password": self._encode(password),
"group": group,
"created": datetime.now().isoformat(),
"last_used": datetime.now().isoformat(),
}
self.sessions.append(entry)
if group and group not in self.groups:
self.groups.append(group)
self.save()
return entry
def remove(self, index):
if 0 <= index < len(self.sessions):
self.sessions.pop(index)
self.save()
def update(self, index, **kwargs):
if 0 <= index < len(self.sessions):
if "password" in kwargs:
kwargs["password"] = self._encode(kwargs["password"])
self.sessions[index].update(kwargs)
self.save()
def move_to_group(self, index, group):
if 0 <= index < len(self.sessions):
self.sessions[index]["group"] = group
self.save()
def get_password(self, session):
return self._decode(session.get("password", ""))
@staticmethod
def _encode(pw):
if not pw:
return ""
return base64.b64encode(pw.encode()).decode()
@staticmethod
def _decode(encoded):
if not encoded:
return ""
try:
return base64.b64decode(encoded.encode()).decode()
except Exception:
return encoded
# ── ANSI Parser ────────────────────────────────────────────────
ANSI_RE = re.compile(r"\x1b\[[0-9;]*[A-Za-z]|\x1b\][^\x07]*\x07|\x1b[()][AB012]|\x1b\[[\?]?[0-9;]*[hlmJKHfGr]")
def strip_ansi(text):
return ANSI_RE.sub("", text)
# ── SSH Terminal Widget ────────────────────────────────────────
class TerminalWidget(tk.Frame):
def __init__(self, master, on_close=None, **kw):
super().__init__(master, **kw)
self.on_close = on_close
self.channel = None
self.ssh = None
self.read_thread = None
self.running = False
self.text = tk.Text(
self,
bg="#1e1e2e",
fg="#cdd6f4",
insertbackground="#cdd6f4",
selectbackground="#45475a",
font=("Consolas", 11),
wrap=tk.CHAR,
padx=6,
pady=6,
borderwidth=0,
highlightthickness=0,
)
scrollbar = ttk.Scrollbar(self, command=self.text.yview)
self.text.configure(yscrollcommand=scrollbar.set)
scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
self.text.pack(fill=tk.BOTH, expand=True)
self.text.bind("<Key>", self._on_key)
self.text.bind("<Return>", lambda e: self._send("\r"))
self.text.bind("<BackSpace>", lambda e: self._send("\x7f"))
self.text.bind("<Tab>", lambda e: self._send("\t"))
self.text.bind("<Up>", lambda e: self._send("\x1b[A"))
self.text.bind("<Down>", lambda e: self._send("\x1b[B"))
self.text.bind("<Right>", lambda e: self._send("\x1b[C"))
self.text.bind("<Left>", lambda e: self._send("\x1b[D"))
self.text.bind("<Home>", lambda e: self._send("\x1b[H"))
self.text.bind("<End>", lambda e: self._send("\x1b[F"))
self.text.bind("<Delete>", lambda e: self._send("\x1b[3~"))
self.text.bind("<Control-c>", lambda e: self._send("\x03"))
self.text.bind("<Control-d>", lambda e: self._send("\x04"))
self.text.bind("<Control-l>", lambda e: self._send("\x0c"))
self.text.bind("<Control-z>", lambda e: self._send("\x1a"))
# Paste/Copy in terminal: send clipboard to SSH / copy selection
# Bind virtual events first
self.text.bind("<<Paste>>", self._term_paste)
self.text.bind("<<Copy>>", self._term_copy)
if sys.platform == "darwin":
# Nuclear: bind ALL possible macOS modifier mappings
for mod in ("Command", "Mod1", "Meta", "Mod2"):
self.text.bind(f"<{mod}-v>", self._term_paste)
self.text.bind(f"<{mod}-c>", self._term_copy)
# Also register as virtual event triggers
for seq in ("<Command-v>", "<Mod1-v>", "<Meta-v>", "<Mod2-v>"):
try:
self.text.event_add("<<Paste>>", seq)
except tk.TclError:
pass
for seq in ("<Command-c>", "<Mod1-c>", "<Meta-c>", "<Mod2-c>"):
try:
self.text.event_add("<<Copy>>", seq)
except tk.TclError:
pass
else:
# Windows/Linux: Ctrl+V paste, Ctrl+Shift+C copy
self.text.bind("<Control-v>", self._term_paste)
self.text.bind("<Control-Shift-C>", self._term_copy)
self.text.tag_configure("error", foreground="#f38ba8")
self.text.tag_configure("info", foreground="#89b4fa")
def connect(self, host, port, user, password):
self.running = True
self._write(f"Подключение к {user}@{host}:{port}...\n", "info")
t = threading.Thread(target=self._connect_thread, args=(host, port, user, password), daemon=True)
t.start()
def _connect_thread(self, host, port, user, password):
try:
self.ssh = paramiko.SSHClient()
self.ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
self.ssh.connect(
host, port=port, username=user, password=password,
timeout=10, look_for_keys=False, allow_agent=False,
)
cols = max(80, self.text.winfo_width() // 8)
rows = max(24, self.text.winfo_height() // 16)
self.channel = self.ssh.invoke_shell(
term="xterm-256color", width=cols, height=rows
)
self.channel.settimeout(0.1)
self._write("Подключено!\n", "info")
self._read_loop()
except Exception as ex:
self._write(f"\nОшибка: {ex}\n", "error")
self.running = False
if self.on_close:
self.after(100, self.on_close)
def _read_loop(self):
while self.running and self.channel and not self.channel.closed:
try:
data = self.channel.recv(4096)
if not data:
break
text = data.decode("utf-8", errors="replace")
clean = strip_ansi(text)
# Remove control chars except \n \t \x08 \x7f (handled by _write)
clean = "".join(
ch for ch in clean
if ch in "\n\t\x08\x7f" or ord(ch) >= 32
)
self._write(clean)
except socket.timeout:
continue
except Exception:
break
self._write("\n--- Сессия завершена ---\n", "error")
self.running = False
if self.on_close:
self.after(100, self.on_close)
def _write(self, text, tag=None):
# Strip \r before closure (SSH sends \r\n)
clean = text.replace("\r", "") if not tag else text
def _do():
self.text.configure(state=tk.NORMAL)
if tag:
self.text.insert(tk.END, clean, tag)
elif "\x08" not in clean and "\x7f" not in clean:
# Fast path: no backspace chars
self.text.insert(tk.END, clean)
else:
# Handle backspace/DEL char by char
buf = []
for ch in clean:
if ch == "\x08" or ch == "\x7f":
if buf:
self.text.insert(tk.END, "".join(buf))
buf.clear()
pos = self.text.index(tk.END + "-2c")
if self.text.compare(pos, ">", "1.0"):
self.text.delete(pos)
else:
buf.append(ch)
if buf:
self.text.insert(tk.END, "".join(buf))
self.text.see(tk.END)
self.after(0, _do)
def _send(self, data):
if self.channel and not self.channel.closed:
try:
self.channel.send(data.encode() if isinstance(data, str) else data)
except Exception:
pass
return "break"
def _term_paste(self, event=None):
"""Paste clipboard into SSH terminal (send text to remote)."""
try:
text = self.text.clipboard_get()
if text:
self._send(text)
except tk.TclError:
pass
return "break"
def _term_copy(self, event=None):
"""Copy selected text from terminal."""
try:
if self.text.tag_ranges(tk.SEL):
self.text.clipboard_clear()
self.text.clipboard_append(self.text.get(tk.SEL_FIRST, tk.SEL_LAST))
except tk.TclError:
pass
return "break"
def _on_key(self, event):
if event.state & 4: # Ctrl — explicit Ctrl+C/D/L/Z bindings handle this
return
# Any non-trivial modifier? (clear Shift=1, CapsLock=2, Ctrl=4)
modifier_mask = event.state & ~0x07
if modifier_mask:
# On macOS frozen .app, Command may map to ANY modifier bit.
# Named bindings (<Command-v> etc.) might not match.
# Catch-all: if any modifier + v/c → paste/copy.
if sys.platform == "darwin":
if event.keysym == "v":
return self._term_paste()
if event.keysym == "c":
return self._term_copy()
return # Other modifier combos — let bindings handle
if event.char and ord(event.char) >= 32:
self._send(event.char)
return "break"
return "break"
def disconnect(self):
self.running = False
if self.channel:
try:
self.channel.close()
except Exception:
pass
if self.ssh:
try:
self.ssh.close()
except Exception:
pass
# ── Session Edit Dialog ────────────────────────────────────────
class SessionDialog(tk.Toplevel):
def __init__(self, parent, title="Сессия", session=None, groups=None):
super().__init__(parent)
self.title(title)
self.result = None
self.resizable(False, False)
self.configure(bg="#1e1e2e")
self.grab_set()
frame = tk.Frame(self, bg="#1e1e2e", padx=15, pady=15)
frame.pack(fill=tk.BOTH, expand=True)
fields = [
("Название:", "name", session.get("name", "") if session else ""),
("Описание:", "description", session.get("description", "") if session else ""),
("Хост:", "host", session.get("host", "") if session else ""),
("Порт:", "port", str(session.get("port", 22)) if session else "22"),
("Пользователь:", "user", session.get("user", "") if session else "root"),
("Пароль:", "password", ""),
]
self.entries = {}
for i, (label, key, default) in enumerate(fields):
tk.Label(frame, text=label, bg="#1e1e2e", fg="#cdd6f4",
font=("Consolas", 10)).grid(row=i, column=0, sticky="w", pady=3)
entry = tk.Entry(frame, width=35, bg="#313244", fg="#cdd6f4",
insertbackground="#cdd6f4", relief=tk.FLAT,
font=("Consolas", 10))
if key == "password":
entry.configure(show="*")
entry.insert(0, default)
entry.grid(row=i, column=1, sticky="ew", pady=3, padx=(8, 0))
setup_entry_clipboard(entry)
self.entries[key] = entry
row_group = len(fields)
tk.Label(frame, text="Группа:", bg="#1e1e2e", fg="#cdd6f4",
font=("Consolas", 10)).grid(row=row_group, column=0, sticky="w", pady=3)
group_frame = tk.Frame(frame, bg="#1e1e2e")
group_frame.grid(row=row_group, column=1, sticky="ew", pady=3, padx=(8, 0))
group_values = groups or []
current_group = session.get("group", "") if session else ""
self.group_var = tk.StringVar(value=current_group)
self.group_combo = ttk.Combobox(group_frame, textvariable=self.group_var,
values=group_values, width=20)
self.group_combo.pack(side=tk.LEFT, fill=tk.X, expand=True)
btn_frame = tk.Frame(frame, bg="#1e1e2e")
btn_frame.grid(row=row_group + 1, column=0, columnspan=2, pady=(12, 0))
ok_btn = tk.Button(btn_frame, text="OK", bg="#45475a", fg="#cdd6f4",
activebackground="#585b70", relief=tk.FLAT,
font=("Consolas", 10), padx=20, pady=4, command=self._ok)
ok_btn.pack(side=tk.LEFT, padx=4)
cancel_btn = tk.Button(btn_frame, text="Отмена", bg="#45475a", fg="#cdd6f4",
activebackground="#585b70", relief=tk.FLAT,
font=("Consolas", 10), padx=20, pady=4, command=self.destroy)
cancel_btn.pack(side=tk.LEFT, padx=4)
self.entries["host"].focus_set()
self.bind("<Return>", lambda e: self._ok())
self.bind("<Escape>", lambda e: self.destroy())
self.wait_window()
def _ok(self):
host = self.entries["host"].get().strip()
if not host:
messagebox.showwarning("Ошибка", "Хост обязателен", parent=self)
return
try:
port = int(self.entries["port"].get().strip() or "22")
except ValueError:
messagebox.showwarning("Ошибка", "Порт — число", parent=self)
return
self.result = {
"name": self.entries["name"].get().strip() or f"{self.entries['user'].get()}@{host}",
"description": self.entries["description"].get().strip(),
"host": host,
"port": port,
"user": self.entries["user"].get().strip() or "root",
"password": self.entries["password"].get(),
"group": self.group_var.get().strip(),
}
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 ──────────────────────────────────
class FileManagerWidget(tk.Frame):
"""SFTP file browser with upload/download, context menu, and clipboard."""
def __init__(self, master, ssh_client, on_send_cmd=None, **kw):
super().__init__(master, bg="#1e1e2e", **kw)
self.ssh = ssh_client
self.sftp = None
self.cwd = "/"
self._clipboard = None # (mode, path) where mode = "copy" or "cut"
self._on_send_cmd = on_send_cmd # callback to send command to terminal
self._build_ui()
# Don't auto-connect — caller triggers _connect_sftp after SSH is ready
def _build_ui(self):
bg, fg = "#1e1e2e", "#cdd6f4"
# Toolbar
toolbar = tk.Frame(self, bg="#181825")
toolbar.pack(fill=tk.X)
btn_s = {"bg": "#313244", "fg": "#89b4fa", "activebackground": "#45475a",
"relief": tk.FLAT, "font": ("Consolas", 9), "padx": 4, "pady": 2}
tk.Button(toolbar, text="⬆ Вверх", command=self._go_up, **btn_s).pack(side=tk.LEFT, padx=2, pady=2)
tk.Button(toolbar, text="⟳ Обновить", command=self._refresh, **btn_s).pack(side=tk.LEFT, padx=2, pady=2)
tk.Button(toolbar, text="📁 Создать папку", command=self._mkdir, **btn_s).pack(side=tk.LEFT, padx=2, pady=2)
tk.Button(toolbar, text="⬆ Загрузить", command=self._upload, **btn_s).pack(side=tk.LEFT, padx=2, pady=2)
tk.Button(toolbar, text="⬇ Скачать", command=self._download, **btn_s).pack(side=tk.LEFT, padx=2, pady=2)
# Path bar
path_frame = tk.Frame(self, bg=bg)
path_frame.pack(fill=tk.X, padx=4, pady=2)
tk.Label(path_frame, text="Путь:", bg=bg, fg="#6c7086",
font=("Consolas", 9)).pack(side=tk.LEFT)
self.path_var = tk.StringVar(value="/")
self.path_entry = tk.Entry(path_frame, textvariable=self.path_var,
bg="#313244", fg=fg, insertbackground=fg,
relief=tk.FLAT, font=("Consolas", 9))
self.path_entry.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=4)
self.path_entry.bind("<Return>", lambda e: self._navigate(self.path_var.get()))
setup_entry_clipboard(self.path_entry)
tk.Button(path_frame, text="", command=lambda: self._navigate(self.path_var.get()),
**btn_s).pack(side=tk.RIGHT)
# File tree
tree_frame = tk.Frame(self, bg=bg)
tree_frame.pack(fill=tk.BOTH, expand=True, padx=4, pady=2)
cols = ("size", "modified", "perms")
self.tree = ttk.Treeview(tree_frame, columns=cols, show="tree headings",
selectmode="browse")
self.tree.heading("#0", text="Имя")
self.tree.heading("size", text="Размер")
self.tree.heading("modified", text="Изменён")
self.tree.heading("perms", text="Права")
self.tree.column("#0", width=250, minwidth=150)
self.tree.column("size", width=80, minwidth=60)
self.tree.column("modified", width=140, minwidth=100)
self.tree.column("perms", width=80, minwidth=60)
scroll = ttk.Scrollbar(tree_frame, command=self.tree.yview)
self.tree.configure(yscrollcommand=scroll.set)
scroll.pack(side=tk.RIGHT, fill=tk.Y)
self.tree.pack(fill=tk.BOTH, expand=True)
self.tree.bind("<Double-1>", self._on_double_click)
self.tree.bind("<Button-3>", self._context_menu)
if sys.platform == "darwin":
self.tree.bind("<Button-2>", self._context_menu)
self.tree.bind("<Control-Button-1>", self._context_menu)
# Status bar
self.status_var = tk.StringVar(value="Подключение...")
tk.Label(self, textvariable=self.status_var, bg="#181825", fg="#6c7086",
font=("Consolas", 8), anchor=tk.W).pack(fill=tk.X, padx=4)
# Close button
close_frame = tk.Frame(self, bg=bg)
close_frame.pack(fill=tk.X, side=tk.BOTTOM)
tk.Button(close_frame, text="✕ Закрыть вкладку",
bg="#313244", fg="#cdd6f4", activebackground="#45475a",
relief=tk.FLAT, font=("Consolas", 9), padx=6, pady=2,
command=self._close).pack(side=tk.RIGHT, padx=4, pady=2)
def _connect_sftp(self):
def _do():
try:
self.sftp = self.ssh.open_sftp()
self.cwd = self.sftp.normalize(".")
self.after(0, self._refresh)
self.after(0, lambda: self.status_var.set(f"SFTP подключён: {self.cwd}"))
except Exception as e:
self.after(0, lambda: self.status_var.set(f"Ошибка SFTP: {e}"))
threading.Thread(target=_do, daemon=True).start()
def _refresh(self):
if not self.sftp:
return
self.tree.delete(*self.tree.get_children())
self.path_var.set(self.cwd)
def _load():
try:
items = self.sftp.listdir_attr(self.cwd)
# Sort: dirs first, then by name
dirs = sorted([f for f in items if stat.S_ISDIR(f.st_mode)],
key=lambda f: f.filename.lower())
files = sorted([f for f in items if not stat.S_ISDIR(f.st_mode)],
key=lambda f: f.filename.lower())
self.after(0, lambda: self._populate(dirs + files))
self.after(0, lambda: self.status_var.set(
f"{self.cwd}{len(dirs)} папок, {len(files)} файлов"))
except Exception as e:
self.after(0, lambda: self.status_var.set(f"Ошибка: {e}"))
threading.Thread(target=_load, daemon=True).start()
def _populate(self, items):
for f in items:
is_dir = stat.S_ISDIR(f.st_mode)
icon = "📁" if is_dir else "📄"
name = f"{icon} {f.filename}"
size = "" if is_dir else self._human_size(f.st_size)
mtime = datetime.fromtimestamp(f.st_mtime).strftime("%Y-%m-%d %H:%M")
perms = stat.filemode(f.st_mode)
self.tree.insert("", tk.END, text=name, values=(size, mtime, perms),
tags=("dir" if is_dir else "file",))
self.tree.tag_configure("dir", foreground="#89b4fa")
self.tree.tag_configure("file", foreground="#cdd6f4")
@staticmethod
def _human_size(n):
for unit in ("Б", "КБ", "МБ", "ГБ"):
if n < 1024:
return f"{n:.0f} {unit}" if n == int(n) else f"{n:.1f} {unit}"
n /= 1024
return f"{n:.1f} ТБ"
def _get_selected_name(self):
sel = self.tree.selection()
if not sel:
return None
text = self.tree.item(sel[0], "text")
# Remove icon prefix
return text.split(" ", 1)[1] if " " in text else text
def _get_selected_path(self):
name = self._get_selected_name()
if not name:
return None
return self.cwd.rstrip("/") + "/" + name
def _on_double_click(self, event):
sel = self.tree.selection()
if not sel:
return
tags = self.tree.item(sel[0], "tags")
if "dir" in tags:
name = self._get_selected_name()
self._navigate(self.cwd.rstrip("/") + "/" + name)
def _navigate(self, path):
def _do():
try:
resolved = self.sftp.normalize(path)
self.sftp.listdir(resolved) # test access
self.cwd = resolved
self.after(0, self._refresh)
except Exception as e:
self.after(0, lambda: self.status_var.set(f"Ошибка: {e}"))
threading.Thread(target=_do, daemon=True).start()
def _go_up(self):
parent = "/".join(self.cwd.rstrip("/").split("/")[:-1]) or "/"
self._navigate(parent)
def _mkdir(self):
name = simpledialog.askstring("Новая папка", "Имя:", parent=self)
if name and name.strip():
def _do():
try:
self.sftp.mkdir(self.cwd.rstrip("/") + "/" + name.strip())
self.after(0, self._refresh)
except Exception as e:
self.after(0, lambda: self.status_var.set(f"Ошибка: {e}"))
threading.Thread(target=_do, daemon=True).start()
def _upload(self):
local_path = filedialog.askopenfilename(parent=self, title="Выбрать файл для загрузки")
if not local_path:
return
fname = os.path.basename(local_path)
remote_path = self.cwd.rstrip("/") + "/" + fname
self.status_var.set(f"Загрузка {fname}...")
def _do():
try:
self.sftp.put(local_path, remote_path)
self.after(0, self._refresh)
self.after(0, lambda: self.status_var.set(f"Загружен: {fname}"))
except Exception as e:
self.after(0, lambda: self.status_var.set(f"Ошибка загрузки: {e}"))
threading.Thread(target=_do, daemon=True).start()
def _download(self):
remote = self._get_selected_path()
if not remote:
return
fname = os.path.basename(remote)
local = filedialog.asksaveasfilename(parent=self, title="Сохранить как",
initialfile=fname)
if not local:
return
self.status_var.set(f"Скачивание {fname}...")
def _do():
try:
self.sftp.get(remote, local)
self.after(0, lambda: self.status_var.set(f"Скачан: {local}"))
except Exception as e:
self.after(0, lambda: self.status_var.set(f"Ошибка скачивания: {e}"))
threading.Thread(target=_do, daemon=True).start()
def _delete_selected(self):
path = self._get_selected_path()
if not path:
return
sel = self.tree.selection()
tags = self.tree.item(sel[0], "tags") if sel else ()
name = self._get_selected_name()
if not messagebox.askyesno("Удалить?", f"Удалить «{name}»?", parent=self):
return
def _do():
try:
if "dir" in tags:
self.sftp.rmdir(path)
else:
self.sftp.remove(path)
self.after(0, self._refresh)
except Exception as e:
self.after(0, lambda: self.status_var.set(f"Ошибка удаления: {e}"))
threading.Thread(target=_do, daemon=True).start()
def _copy_path(self):
path = self._get_selected_path()
if path:
self.clipboard_clear()
self.clipboard_append(path)
self.status_var.set(f"Скопирован путь: {path}")
def _copy_file(self):
path = self._get_selected_path()
if path:
self._clipboard = ("copy", path)
self.status_var.set(f"Копировать: {path}")
def _cut_file(self):
path = self._get_selected_path()
if path:
self._clipboard = ("cut", path)
self.status_var.set(f"Вырезать: {path}")
def _paste_file(self):
if not self._clipboard:
return
mode, src = self._clipboard
fname = os.path.basename(src)
dst = self.cwd.rstrip("/") + "/" + fname
self.status_var.set(f"{'Перемещение' if mode == 'cut' else 'Копирование'} {fname}...")
def _do():
try:
if mode == "cut":
self.sftp.rename(src, dst)
else:
# Copy via SSH cp command
transport = self.ssh.get_transport()
chan = transport.open_session()
chan.exec_command(f'cp -r "{src}" "{dst}"')
chan.recv_exit_status()
chan.close()
self._clipboard = None
self.after(0, self._refresh)
self.after(0, lambda: self.status_var.set(f"Готово: {dst}"))
except Exception as e:
self.after(0, lambda: self.status_var.set(f"Ошибка: {e}"))
threading.Thread(target=_do, daemon=True).start()
def _rename_selected(self):
path = self._get_selected_path()
name = self._get_selected_name()
if not path:
return
new_name = simpledialog.askstring("Переименовать", "Новое имя:", parent=self,
initialvalue=name)
if new_name and new_name.strip() and new_name != name:
dst = self.cwd.rstrip("/") + "/" + new_name.strip()
def _do():
try:
self.sftp.rename(path, dst)
self.after(0, self._refresh)
except Exception as e:
self.after(0, lambda: self.status_var.set(f"Ошибка: {e}"))
threading.Thread(target=_do, daemon=True).start()
def _send_to_terminal(self):
"""Send selected file path to terminal for AI/command use."""
path = self._get_selected_path()
if path and self._on_send_cmd:
self._on_send_cmd(path)
def _context_menu(self, event):
sel = self.tree.identify_row(event.y)
if sel:
self.tree.selection_set(sel)
menu = tk.Menu(self, tearoff=0, bg="#313244", fg="#cdd6f4",
activebackground="#45475a")
if sel:
tags = self.tree.item(sel, "tags")
if "dir" in tags:
menu.add_command(label="📂 Открыть", command=lambda: self._on_double_click(None))
else:
menu.add_command(label="⬇ Скачать", command=self._download)
menu.add_separator()
menu.add_command(label="📋 Копировать путь", command=self._copy_path)
menu.add_command(label="📄 Копировать", command=self._copy_file)
menu.add_command(label="✂ Вырезать", command=self._cut_file)
menu.add_command(label="✏ Переименовать", command=self._rename_selected)
menu.add_command(label="🗑 Удалить", command=self._delete_selected)
menu.add_separator()
menu.add_command(label="В терминал", command=self._send_to_terminal)
if self._clipboard:
menu.add_command(label="📋 Вставить", command=self._paste_file)
menu.add_separator()
menu.add_command(label="📁 Создать папку", command=self._mkdir)
menu.add_command(label="⬆ Загрузить файл", command=self._upload)
menu.tk_popup(event.x_root, event.y_root)
def download_file(self, remote_path, local_path=None):
"""API method for AI agent to download a file."""
if not local_path:
fname = os.path.basename(remote_path)
desktop = str(Path.home() / "Desktop")
local_path = os.path.join(desktop, fname)
self.status_var.set(f"AI: скачивание {remote_path}...")
def _do():
try:
self.sftp.get(remote_path, local_path)
self.after(0, lambda: self.status_var.set(f"AI: скачан → {local_path}"))
except Exception as e:
self.after(0, lambda: self.status_var.set(f"Ошибка: {e}"))
threading.Thread(target=_do, daemon=True).start()
def _close(self):
if self.sftp:
try:
self.sftp.close()
except Exception:
pass
# Find parent notebook and remove this tab
parent = self.master
if isinstance(parent, ttk.Notebook):
parent.forget(self)
self.destroy()
# ── Main Application ───────────────────────────────────────────
class App(tk.Tk):
def __init__(self):
super().__init__()
self.title(f"{APP_NAME}{APP_SUBTITLE} v{VERSION}")
self.geometry("1200x750")
self.minsize(900, 550)
self.configure(bg="#1e1e2e")
self.store = SessionStore()
self.config = ConfigStore()
self.terminals = {}
self._commands_visible = self.config.get("commands_visible", True)
self._selected_card_idx = None
self._font_size = self.config.get("font_size", 10)
self._apply_theme()
self._build_ui()
self._refresh_cards()
# Zoom: Cmd+/- (Mac) or Ctrl+/- (Windows)
if sys.platform == "darwin":
self.bind("<Command-equal>", lambda e: self._zoom(1))
self.bind("<Command-minus>", lambda e: self._zoom(-1))
self.bind("<Command-0>", lambda e: self._zoom(0))
else:
self.bind("<Control-equal>", lambda e: self._zoom(1))
self.bind("<Control-minus>", lambda e: self._zoom(-1))
self.bind("<Control-0>", lambda e: self._zoom(0))
def _apply_theme(self):
style = ttk.Style()
style.theme_use("clam")
bg = "#1e1e2e"
fg = "#cdd6f4"
style.configure(".", background=bg, foreground=fg, fieldbackground="#313244", borderwidth=0)
style.configure("TFrame", background=bg)
style.configure("TLabel", background=bg, foreground=fg)
style.configure("TButton", background="#45475a", foreground=fg, padding=(10, 5))
style.map("TButton", background=[("active", "#585b70")])
style.configure("Treeview", background="#313244", foreground=fg, fieldbackground="#313244",
rowheight=28, borderwidth=0)
style.configure("Treeview.Heading", background="#45475a", foreground=fg)
style.map("Treeview", background=[("selected", "#45475a")])
style.configure("TNotebook", background=bg, borderwidth=0)
style.configure("TNotebook.Tab", background="#45475a", foreground=fg, padding=(12, 6))
style.map("TNotebook.Tab", background=[("selected", "#585b70")])
style.configure("TEntry", fieldbackground="#313244", foreground=fg)
style.configure("TCombobox", fieldbackground="#313244", foreground=fg)
def _zoom(self, delta):
"""Change font size. delta=+1/-1 or 0 to reset."""
if delta == 0:
self._font_size = 10
else:
self._font_size = max(7, min(18, self._font_size + delta))
self.config.set("font_size", self._font_size)
s = self._font_size
# Update all text widgets
for widget in self._all_widgets():
try:
f = widget.cget("font")
if isinstance(f, str) and f:
widget.configure(font=(f, s))
elif isinstance(f, tuple) and len(f) >= 2:
widget.configure(font=(f[0], s) + f[2:])
except (tk.TclError, TypeError):
pass
# Update terminal tabs
for term in self.terminals.values():
try:
term.text.configure(font=("Consolas", s))
except (tk.TclError, AttributeError):
pass
def _all_widgets(self):
"""Yield all widgets recursively."""
stack = [self]
while stack:
w = stack.pop()
yield w
try:
stack.extend(w.winfo_children())
except Exception:
pass
def _open_url(self, url):
import webbrowser
webbrowser.open(url)
def _draw_pca_logo(self, canvas, cx, cy, size):
r = size // 2
canvas.create_oval(cx - r, cy - r, cx + r, cy + r, fill="#2a2a3d", outline="#3b3b5c", width=2)
label_font = ("Arial", size // 3, "bold")
canvas.create_text(cx, cy - 2, text="PCA", fill="#6c8fff", font=label_font)
dots = [(cx - 4, cy + r // 2), (cx, cy + r // 2 + 4), (cx + 4, cy + r // 2),
(cx - 6, cy + r // 2 + 6), (cx, cy + r // 2 + 10), (cx + 6, cy + r // 2 + 6)]
for dx, dy in dots:
canvas.create_oval(dx - 1, dy - 1, dx + 1, dy + 1, fill="#6c8fff", outline="")
def _build_ui(self):
# ── Top: PCA brand bar ──
top_bar = tk.Frame(self, bg="#181825", height=48)
top_bar.pack(fill=tk.X, side=tk.TOP)
top_bar.pack_propagate(False)
logo_canvas = tk.Canvas(top_bar, width=36, height=36, bg="#181825", highlightthickness=0)
logo_canvas.pack(side=tk.LEFT, padx=(10, 6), pady=6)
self._draw_pca_logo(logo_canvas, 18, 16, 30)
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_frame = tk.Frame(self, bg="#1e1e2e")
main_frame.pack(fill=tk.BOTH, expand=True, padx=4, pady=4)
# Left: card-based session panel
self.left_panel = tk.Frame(main_frame, bg="#1e1e2e", width=320)
self.left_panel.pack(side=tk.LEFT, fill=tk.Y, padx=(0, 4))
self.left_panel.pack_propagate(False)
self._build_card_panel(self.left_panel)
# Center: terminal tabs
center_frame = tk.Frame(main_frame, bg="#1e1e2e")
center_frame.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
self.notebook = ttk.Notebook(center_frame)
self.notebook.pack(fill=tk.BOTH, expand=True)
# Welcome tab
welcome = tk.Frame(self.notebook, bg="#1e1e2e")
self.notebook.add(welcome, text="Добро пожаловать")
tk.Label(
welcome,
text=f"{APP_NAME} v{VERSION}\n\n"
"• Двойной клик по карточке = подключение\n"
"• Быстрое подключение внизу слева\n"
"Все сессии сохраняются автоматически\n"
"• Ctrl+C/D/L работают в терминале\n"
"• AI-агент справа — спроси что угодно\n"
"• Панель команд можно скрыть/показать",
font=("Consolas", 12),
justify=tk.CENTER,
bg="#1e1e2e",
fg="#cdd6f4",
).pack(expand=True)
# Right: commands + agent panel
self.right_panel = tk.Frame(main_frame, bg="#1e1e2e", width=360)
self.right_panel.pack(side=tk.RIGHT, fill=tk.Y, padx=(4, 0))
self.right_panel.pack_propagate(False)
self._build_right_panel(self.right_panel)
# ── Card Panel (Left) ─────────────────────────────────────
def _build_card_panel(self, parent):
# Toolbar
toolbar = tk.Frame(parent, bg="#181825")
toolbar.pack(fill=tk.X)
btn_style = {"bg": "#313244", "fg": "#89b4fa", "activebackground": "#45475a",
"activeforeground": "#b4d0fb", "relief": tk.FLAT,
"font": ("Consolas", 10), "padx": 8, "pady": 4}
tk.Button(toolbar, text=" Сессия", command=self._new_session, **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._delete_session, width=3, **btn_style).pack(side=tk.LEFT, padx=1, pady=3)
# Scrollable card area
self.card_canvas = tk.Canvas(parent, bg="#1e1e2e", highlightthickness=0)
card_scroll = ttk.Scrollbar(parent, orient=tk.VERTICAL, command=self.card_canvas.yview)
self.card_canvas.configure(yscrollcommand=card_scroll.set)
card_scroll.pack(side=tk.RIGHT, fill=tk.Y)
self.card_canvas.pack(fill=tk.BOTH, expand=True)
self.card_inner = tk.Frame(self.card_canvas, bg="#1e1e2e")
self.card_canvas_window = self.card_canvas.create_window((0, 0), window=self.card_inner, anchor="nw")
self.card_inner.bind("<Configure>", lambda e: self.card_canvas.configure(
scrollregion=self.card_canvas.bbox("all")))
self.card_canvas.bind("<Configure>", lambda e: self.card_canvas.itemconfigure(
self.card_canvas_window, width=e.width))
# Mouse wheel scrolling
def _on_mousewheel(event):
if sys.platform == "darwin":
self.card_canvas.yview_scroll(-1 * event.delta, "units")
else:
self.card_canvas.yview_scroll(-1 * (event.delta // 120), "units")
self.card_canvas.bind("<MouseWheel>", _on_mousewheel)
self.card_canvas.bind("<Button-4>", lambda e: self.card_canvas.yview_scroll(-1, "units"))
self.card_canvas.bind("<Button-5>", lambda e: self.card_canvas.yview_scroll(1, "units"))
# Quick connect at bottom
qf = tk.Frame(parent, bg="#181825", padx=8, pady=8)
qf.pack(fill=tk.X, side=tk.BOTTOM)
tk.Label(qf, text="Быстрое подключение", bg="#181825", fg="#6c7086",
font=("Consolas", 9, "bold")).pack(anchor="w", pady=(0, 4))
row1 = tk.Frame(qf, bg="#181825")
row1.pack(fill=tk.X, pady=1)
tk.Label(row1, text="Хост:", bg="#181825", fg="#6c7086", font=("Consolas", 9)).pack(side=tk.LEFT)
self.q_host = tk.Entry(row1, width=14, bg="#313244", fg="#cdd6f4",
insertbackground="#cdd6f4", relief=tk.FLAT, font=("Consolas", 9))
self.q_host.pack(side=tk.LEFT, padx=(4, 6))
setup_entry_clipboard(self.q_host)
tk.Label(row1, text="Порт:", bg="#181825", fg="#6c7086", font=("Consolas", 9)).pack(side=tk.LEFT)
self.q_port = tk.Entry(row1, width=5, bg="#313244", fg="#cdd6f4",
insertbackground="#cdd6f4", relief=tk.FLAT, font=("Consolas", 9))
self.q_port.insert(0, "22")
self.q_port.pack(side=tk.LEFT, padx=4)
setup_entry_clipboard(self.q_port)
row2 = tk.Frame(qf, bg="#181825")
row2.pack(fill=tk.X, pady=1)
tk.Label(row2, text="Юзер:", bg="#181825", fg="#6c7086", font=("Consolas", 9)).pack(side=tk.LEFT)
self.q_user = tk.Entry(row2, width=10, bg="#313244", fg="#cdd6f4",
insertbackground="#cdd6f4", relief=tk.FLAT, font=("Consolas", 9))
self.q_user.insert(0, "root")
self.q_user.pack(side=tk.LEFT, padx=(4, 6))
setup_entry_clipboard(self.q_user)
tk.Label(row2, text="Пароль:", bg="#181825", fg="#6c7086", font=("Consolas", 9)).pack(side=tk.LEFT)
self.q_pass = tk.Entry(row2, width=10, bg="#313244", fg="#cdd6f4", show="*",
insertbackground="#cdd6f4", relief=tk.FLAT, font=("Consolas", 9))
self.q_pass.pack(side=tk.LEFT, padx=4)
setup_entry_clipboard(self.q_pass)
tk.Button(qf, text="Подключиться", bg="#89b4fa", fg="#1e1e2e",
activebackground="#6c8fff", relief=tk.FLAT,
font=("Consolas", 9, "bold"), pady=3,
command=self._quick_connect).pack(fill=tk.X, pady=(4, 0))
self.q_host.bind("<Return>", lambda e: self._quick_connect())
self.q_pass.bind("<Return>", lambda e: self._quick_connect())
def _refresh_cards(self):
for widget in self.card_inner.winfo_children():
widget.destroy()
grouped = {}
ungrouped = []
for i, s in enumerate(self.store.sessions):
g = s.get("group", "")
if g:
grouped.setdefault(g, []).append((i, s))
else:
ungrouped.append((i, s))
color_idx = 0
# Grouped sessions
for g in self.store.groups:
color = CARD_COLORS[color_idx % len(CARD_COLORS)]
color_idx += 1
sessions = grouped.get(g, [])
self._create_group_section(self.card_inner, g, sessions, color)
# Extra groups from sessions
for g, items in grouped.items():
if g not in self.store.groups:
self.store.add_group(g)
color = CARD_COLORS[color_idx % len(CARD_COLORS)]
color_idx += 1
self._create_group_section(self.card_inner, g, items, color)
# Ungrouped
if ungrouped:
self._create_group_section(self.card_inner, "Без группы", ungrouped, "#6c7086")
def _create_group_section(self, parent, group_name, sessions, color):
# Group container with colored left border
group_frame = tk.Frame(parent, bg="#1e1e2e")
group_frame.pack(fill=tk.X, padx=4, pady=(6, 2))
# Group header
header = tk.Frame(group_frame, bg="#181825")
header.pack(fill=tk.X)
# Color indicator
tk.Frame(header, bg=color, width=4).pack(side=tk.LEFT, fill=tk.Y)
tk.Label(header, text=f" {group_name}", bg="#181825", fg=color,
font=("Consolas", 11, "bold"), pady=6).pack(side=tk.LEFT, fill=tk.X, expand=True, anchor="w")
count_text = f"{len(sessions)}"
tk.Label(header, text=count_text, bg="#181825", fg="#6c7086",
font=("Consolas", 9), padx=8).pack(side=tk.RIGHT)
# Group context menu on header
if group_name != "Без группы":
def _group_menu(event, gn=group_name):
menu = tk.Menu(self, tearoff=0, bg="#313244", fg="#cdd6f4",
activebackground="#45475a")
menu.add_command(label="Переименовать", command=lambda: self._rename_group_by_name(gn))
menu.add_command(label="Удалить группу", command=lambda: self._delete_group_by_name(gn))
menu.tk_popup(event.x_root, event.y_root)
header.bind("<Button-3>", _group_menu)
for w in header.winfo_children():
w.bind("<Button-3>", _group_menu)
# Cards grid
cards_frame = tk.Frame(group_frame, bg="#1e1e2e")
cards_frame.pack(fill=tk.X, padx=2, pady=(2, 4))
col = 0
row = 0
max_cols = 2
for idx, session in sessions:
card = self._create_session_card(cards_frame, session, idx, color)
card.grid(row=row, column=col, padx=3, pady=3, sticky="nsew")
col += 1
if col >= max_cols:
col = 0
row += 1
# Make columns expand evenly
for c in range(max_cols):
cards_frame.columnconfigure(c, weight=1)
def _create_session_card(self, parent, session, index, accent_color):
is_selected = (index == self._selected_card_idx)
card_bg = "#3b3b5c" if is_selected else "#313244"
card = tk.Frame(parent, bg=card_bg, padx=10, pady=8, cursor="hand2",
highlightbackground=accent_color if is_selected else "#45475a",
highlightthickness=2)
name = session.get("name", "???")
host = session.get("host", "")
port = session.get("port", 22)
desc = session.get("description", "")
# Name
name_label = tk.Label(card, text=name, bg=card_bg, fg="#cdd6f4",
font=("Consolas", 10, "bold"), anchor="w")
name_label.pack(fill=tk.X)
# Description (if any)
if desc:
desc_label = tk.Label(card, text=desc, bg=card_bg, fg="#a6adc8",
font=("Consolas", 8), anchor="w")
desc_label.pack(fill=tk.X)
# Address
addr_text = f"{host}:{port}"
addr_label = tk.Label(card, text=addr_text, bg=card_bg, fg="#6c7086",
font=("Consolas", 9), anchor="w")
addr_label.pack(fill=tk.X)
# Status dot
dot_color = accent_color
dot_canvas = tk.Canvas(card, width=8, height=8, bg=card_bg, highlightthickness=0)
dot_canvas.create_oval(1, 1, 7, 7, fill=dot_color, outline="")
dot_canvas.place(relx=1.0, rely=0.0, anchor="ne", x=-4, y=4)
# Bindings
def _enter(e):
if index != self._selected_card_idx:
card.configure(bg="#3b3b5c")
for w in card.winfo_children():
if isinstance(w, tk.Label):
w.configure(bg="#3b3b5c")
elif isinstance(w, tk.Canvas):
w.configure(bg="#3b3b5c")
def _leave(e):
bg = "#3b3b5c" if index == self._selected_card_idx else "#313244"
card.configure(bg=bg)
for w in card.winfo_children():
if isinstance(w, tk.Label):
w.configure(bg=bg)
elif isinstance(w, tk.Canvas):
w.configure(bg=bg)
def _click(e):
old_idx = self._selected_card_idx
self._selected_card_idx = index
# Visual update without destroying widgets
if old_idx != index:
card.configure(bg="#3b3b5c", highlightbackground=accent_color)
for w in card.winfo_children():
if isinstance(w, (tk.Label, tk.Canvas)):
try:
w.configure(bg="#3b3b5c")
except tk.TclError:
pass
def _double_click(e):
self._connect_by_idx(index)
def _right_click(e):
self._show_card_context_menu(e, index)
for widget in [card, name_label, addr_label, dot_canvas] + ([desc_label] if desc else []):
widget.bind("<Enter>", _enter)
widget.bind("<Leave>", _leave)
widget.bind("<Button-1>", _click)
widget.bind("<Double-1>", _double_click)
widget.bind("<Button-3>", _right_click)
if sys.platform == "darwin":
widget.bind("<Button-2>", _right_click)
widget.bind("<Control-Button-1>", _right_click)
return card
def _show_card_context_menu(self, event, index):
menu = tk.Menu(self, tearoff=0, bg="#313244", fg="#cdd6f4",
activebackground="#45475a")
menu.add_command(label="Подключиться", command=lambda: self._connect_by_idx(index))
menu.add_separator()
# Move to group submenu
move_menu = tk.Menu(menu, tearoff=0, bg="#313244", fg="#cdd6f4")
move_menu.add_command(label="Без группы", command=lambda: self._move_and_refresh(index, ""))
for g in self.store.groups:
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_separator()
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.tk_popup(event.x_root, event.y_root)
def _move_and_refresh(self, index, group):
self.store.move_to_group(index, group)
self._refresh_cards()
# ── Right Panel (Commands + Agent) ────────────────────────
def _build_right_panel(self, parent):
# Toggle button for commands
toggle_frame = tk.Frame(parent, bg="#181825")
toggle_frame.pack(fill=tk.X)
self._toggle_btn = tk.Button(
toggle_frame, text="Команды ▼" if self._commands_visible else "Команды ▶",
bg="#181825", fg="#89b4fa", activebackground="#181825", activeforeground="#6c8fff",
relief=tk.FLAT, font=("Consolas", 10, "bold"), cursor="hand2",
command=self._toggle_commands,
)
self._toggle_btn.pack(side=tk.LEFT, padx=6, pady=4)
# Paned window for commands + agent (draggable splitter)
self._right_paned = tk.PanedWindow(
parent, orient=tk.VERTICAL, bg="#45475a",
sashwidth=6, sashrelief=tk.FLAT, opaqueresize=True,
)
self._right_paned.pack(fill=tk.BOTH, expand=True)
# Commands panel (collapsible)
self.commands_container = tk.Frame(self._right_paned, bg="#1e1e2e")
if self._commands_visible:
self._right_paned.add(self.commands_container, minsize=80)
# Search bar
search_frame = tk.Frame(self.commands_container, bg="#1e1e2e")
search_frame.pack(fill=tk.X, padx=4, pady=4)
tk.Label(search_frame, text="Поиск:", bg="#1e1e2e", fg="#6c7086",
font=("Consolas", 9)).pack(side=tk.LEFT)
self.cmd_search = tk.Entry(search_frame, bg="#313244", fg="#cdd6f4",
insertbackground="#cdd6f4", relief=tk.FLAT,
font=("Consolas", 9))
self.cmd_search.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(4, 0))
setup_entry_clipboard(self.cmd_search)
self.cmd_search.bind("<KeyRelease>", self._filter_commands)
# Commands tree
cmd_tree_frame = tk.Frame(self.commands_container, bg="#1e1e2e")
cmd_tree_frame.pack(fill=tk.BOTH, expand=True)
self.cmd_tree = ttk.Treeview(cmd_tree_frame, columns=("desc",), show="tree headings",
selectmode="browse")
self.cmd_tree.heading("#0", text="Команда")
self.cmd_tree.heading("desc", text="Описание")
self.cmd_tree.column("#0", width=180, minwidth=100)
self.cmd_tree.column("desc", width=160, minwidth=80)
cmd_scroll = ttk.Scrollbar(cmd_tree_frame, command=self.cmd_tree.yview)
self.cmd_tree.configure(yscrollcommand=cmd_scroll.set)
cmd_scroll.pack(side=tk.RIGHT, fill=tk.Y)
self.cmd_tree.pack(fill=tk.BOTH, expand=True)
self.cmd_tree.bind("<Double-1>", self._send_command)
tk.Label(self.commands_container, text="Двойной клик = отправить в терминал",
bg="#1e1e2e", fg="#6c7086", font=("Consolas", 8)).pack(fill=tk.X, pady=2)
self._populate_commands()
# ── Agent Panel ──
agent_wrapper = tk.Frame(self._right_paned, bg="#1e1e2e")
self._right_paned.add(agent_wrapper, minsize=120)
agent_header = tk.Frame(agent_wrapper, bg="#181825")
agent_header.pack(fill=tk.X)
tk.Label(agent_header, text="AI Агент", bg="#181825", fg="#a6e3a1",
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
self.agent_frame = tk.Frame(agent_wrapper, bg="#1e1e2e")
self.agent_frame.pack(fill=tk.BOTH, expand=True, padx=4, pady=2)
# Input
agent_input = tk.Frame(self.agent_frame, bg="#1e1e2e")
agent_input.pack(fill=tk.X, pady=(0, 4))
self.agent_entry = tk.Entry(agent_input, bg="#313244", fg="#cdd6f4",
insertbackground="#cdd6f4", relief=tk.FLAT,
font=("Consolas", 10))
self.agent_entry.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(0, 4))
setup_entry_clipboard(self.agent_entry)
self.agent_entry.insert(0, "как обновить ubuntu?")
self.agent_entry.bind("<Return>", lambda e: self._agent_ask())
tk.Button(agent_input, text="Спросить", bg="#a6e3a1", fg="#1e1e2e",
activebackground="#89b4fa", relief=tk.FLAT,
font=("Consolas", 9, "bold"), padx=8, pady=2,
command=self._agent_ask).pack(side=tk.RIGHT)
# Result area
self.agent_result = tk.Text(
self.agent_frame, bg="#181825", fg="#cdd6f4",
font=("Consolas", 9), wrap=tk.WORD, borderwidth=0,
insertbackground="#cdd6f4", highlightthickness=0,
)
agent_scroll = ttk.Scrollbar(self.agent_frame, command=self.agent_result.yview)
self.agent_result.configure(yscrollcommand=agent_scroll.set)
agent_scroll.pack(side=tk.RIGHT, fill=tk.Y)
self.agent_result.pack(fill=tk.BOTH, expand=True)
self.agent_result.tag_configure("cmd", foreground="#a6e3a1", font=("Consolas", 9, "bold"))
self.agent_result.tag_configure("desc", foreground="#6c7086")
self.agent_result.tag_configure("platform", foreground="#89b4fa")
self.agent_result.tag_configure("hint", foreground="#f9e2af")
self.agent_result.tag_configure("error", foreground="#f38ba8")
self.agent_result.tag_configure("ai", foreground="#cba6f7")
self.agent_result.tag_configure("thinking", foreground="#f9e2af", font=("Consolas", 9, "italic"))
self.agent_result.configure(state=tk.DISABLED)
self.agent_result.bind("<Double-1>", self._agent_send_cmd)
def _toggle_commands(self):
self._commands_visible = not self._commands_visible
self.config.set("commands_visible", self._commands_visible)
if self._commands_visible:
# Add commands pane back (before agent)
panes = self._right_paned.panes()
if str(self.commands_container) not in panes:
self._right_paned.add(self.commands_container, before=panes[0] if panes else None, minsize=80)
self._toggle_btn.configure(text="Команды ▼")
else:
self._right_paned.forget(self.commands_container)
self._toggle_btn.configure(text="Команды ▶")
# ── AI Agent ──────────────────────────────────────────────
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):
query = self.agent_entry.get().strip()
if not query:
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)
self.agent_result.configure(state=tk.NORMAL)
self.agent_result.delete("1.0", tk.END)
if not results:
self.agent_result.insert(tk.END, "Не нашёл команд. Попробуй другие слова:\n", "desc")
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, "Совет: ", "desc")
self.agent_result.insert(tk.END, "подключи Claude или DeepSeek API\n", "ai")
self.agent_result.insert(tk.END, "для умного ассистента (⚙ настройки)", "ai")
else:
self.agent_result.insert(tk.END, "Двойной клик по команде = отправить\n\n", "desc")
for _, platform, cmd, desc in results:
self.agent_result.insert(tk.END, f"[{platform}] ", "platform")
self.agent_result.insert(tk.END, f"{cmd}\n", "cmd")
self.agent_result.insert(tk.END, f" {desc}\n", "desc")
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):
idx = self.agent_result.index(f"@{event.x},{event.y}")
line = self.agent_result.get(f"{idx} linestart", f"{idx} lineend").strip()
# Skip description lines and headers
if line.startswith("["):
line = line.split("] ", 1)[-1] if "] " in line else line
if not line or line.startswith("Двойной") or line.startswith("Не нашёл") or line.startswith("Думаю") or line.startswith("Ошибка") or line.startswith("Совет"):
return
# Skip description lines (indented with 2 spaces)
raw = self.agent_result.get(f"{idx} linestart", f"{idx} lineend")
if raw.startswith(" "):
return
current_tab = self.notebook.select()
if not current_tab:
return
widget = self.nametowidget(current_tab)
if isinstance(widget, TerminalWidget) and widget.channel and not widget.channel.closed:
widget._send(line + "\r")
else:
# Try to find any active terminal
for tab_id in self.notebook.tabs():
w = self.nametowidget(tab_id)
if isinstance(w, TerminalWidget) and w.channel and not w.channel.closed:
w._send(line + "\r")
self.notebook.select(w)
return
messagebox.showinfo("Нет терминала", "Сначала подключитесь к серверу")
# ── Commands Tree ─────────────────────────────────────────
def _populate_commands(self, filter_text=""):
self.cmd_tree.delete(*self.cmd_tree.get_children())
ft = filter_text.lower()
for platform, categories in COMMANDS.items():
platform_id = self.cmd_tree.insert("", tk.END, text=platform, values=("",), open=not ft)
has_children = False
for category, cmds in categories.items():
cat_id = self.cmd_tree.insert(platform_id, tk.END, text=category, values=("",), open=bool(ft))
cat_has = False
for cmd, desc in cmds:
if ft and ft not in cmd.lower() and ft not in desc.lower() and ft not in category.lower():
continue
self.cmd_tree.insert(cat_id, tk.END, text=cmd, values=(desc,))
cat_has = True
has_children = True
if not cat_has:
self.cmd_tree.delete(cat_id)
if not has_children:
self.cmd_tree.delete(platform_id)
def _filter_commands(self, event=None):
text = self.cmd_search.get().strip()
self._populate_commands(text)
def _send_command(self, event=None):
sel = self.cmd_tree.selection()
if not sel:
return
item = sel[0]
cmd = self.cmd_tree.item(item, "text")
children = self.cmd_tree.get_children(item)
if children:
return
current_tab = self.notebook.select()
if not current_tab:
return
widget = self.nametowidget(current_tab)
if isinstance(widget, TerminalWidget) and widget.channel and not widget.channel.closed:
widget._send(cmd + "\r")
else:
messagebox.showinfo("Нет терминала", "Сначала подключитесь к серверу")
# ── Session/Group CRUD ────────────────────────────────────
def _connect_by_idx(self, idx):
s = self.store.sessions[idx]
pw = self.store.get_password(s)
self._open_terminal(s["host"], s["port"], s["user"], pw, s["name"])
def _new_session(self):
pregroup = ""
if self._selected_card_idx is not None:
pregroup = self.store.sessions[self._selected_card_idx].get("group", "")
session_defaults = {"group": pregroup} if pregroup else None
dlg = SessionDialog(self, "Новая сессия", session=session_defaults,
groups=self.store.groups)
if dlg.result:
r = dlg.result
self.store.add(r["host"], r["port"], r["user"], r["password"],
r["name"], r.get("group", ""))
self._refresh_cards()
def _edit_session(self):
if self._selected_card_idx is not None:
self._edit_session_by_idx(self._selected_card_idx)
def _edit_session_by_idx(self, idx):
s = self.store.sessions[idx].copy()
s["password"] = ""
dlg = SessionDialog(self, "Изменить сессию", s, groups=self.store.groups)
if dlg.result:
r = dlg.result
update = {"name": r["name"], "description": r.get("description", ""),
"host": r["host"], "port": r["port"],
"user": r["user"], "group": r.get("group", "")}
if r["password"]:
update["password"] = r["password"]
self.store.update(idx, **update)
self._refresh_cards()
def _delete_session(self):
if self._selected_card_idx is not None:
self._delete_session_by_idx(self._selected_card_idx)
def _delete_session_by_idx(self, idx):
s = self.store.sessions[idx]
if messagebox.askyesno("Удалить?", f"Удалить сессию «{s['name']}»?"):
self.store.remove(idx)
self._selected_card_idx = None
self._refresh_cards()
def _new_group(self):
name = simpledialog.askstring("Новая группа", "Название группы:", parent=self)
if name and name.strip():
self.store.add_group(name.strip())
self._refresh_cards()
def _rename_group_by_name(self, old):
new = simpledialog.askstring("Переименовать", f"Новое имя для «{old}»:",
parent=self, initialvalue=old)
if new and new.strip() and new.strip() != old:
self.store.rename_group(old, new.strip())
self._refresh_cards()
def _delete_group_by_name(self, name):
if messagebox.askyesno("Удалить группу?",
f"Удалить группу «{name}»?\nСессии переместятся в «Без группы»."):
self.store.remove_group(name)
self._refresh_cards()
def _quick_connect(self):
host = self.q_host.get().strip()
if not host:
return
try:
port = int(self.q_port.get().strip() or "22")
except ValueError:
port = 22
user = self.q_user.get().strip() or "root"
pw = self.q_pass.get()
self.store.add(host, port, user, pw)
self._refresh_cards()
self._open_terminal(host, port, user, pw)
self.q_pass.delete(0, tk.END)
def _open_terminal(self, host, port, user, password, name=None):
tab_name = name or f"{user}@{host}"
term = TerminalWidget(self.notebook, on_close=lambda: None)
self.notebook.add(term, text=f"{tab_name}")
self.notebook.select(term)
term.connect(host, port, user, password)
# Store connection info for file manager
term._conn_info = {"host": host, "port": port, "user": user, "password": password}
close_btn_frame = tk.Frame(term, bg="#1e1e2e")
btn_s = {"bg": "#313244", "fg": "#89b4fa", "activebackground": "#45475a",
"relief": tk.FLAT, "font": ("Consolas", 9), "padx": 6, "pady": 2}
tk.Button(close_btn_frame, text="📁 Файлы",
command=lambda: self._open_file_manager(term),
**btn_s).pack(side=tk.LEFT, padx=4, pady=2)
tk.Button(
close_btn_frame, text="✕ Закрыть",
bg="#313244", fg="#cdd6f4", activebackground="#45475a",
relief=tk.FLAT, font=("Consolas", 9), padx=6, pady=2,
command=lambda: self._close_tab(term),
).pack(side=tk.RIGHT, padx=4, pady=2)
close_btn_frame.pack(fill=tk.X, side=tk.BOTTOM)
def _open_file_manager(self, term):
"""Open SFTP file manager tab with its own SSH connection."""
info = getattr(term, "_conn_info", None)
if not info:
messagebox.showerror("Ошибка", "Нет данных подключения", parent=self)
return
tab_name = f"📁 {info['user']}@{info['host']}"
def send_cmd(path):
if term.channel and not term.channel.closed:
term._send(path + "\n")
self.notebook.select(term)
# Create separate SSH for SFTP (avoid conflicts with terminal shell)
ssh2 = paramiko.SSHClient()
ssh2.set_missing_host_key_policy(paramiko.AutoAddPolicy())
fm = FileManagerWidget(self.notebook, ssh2, on_send_cmd=send_cmd)
self.notebook.add(fm, text=tab_name)
self.notebook.select(fm)
# Connect in background
def _connect():
try:
ssh2.connect(info["host"], port=info["port"],
username=info["user"], password=info["password"],
timeout=10, look_for_keys=False, allow_agent=False)
fm.after(0, fm._connect_sftp)
except Exception as e:
fm.after(0, lambda: fm.status_var.set(f"Ошибка SSH: {e}"))
threading.Thread(target=_connect, daemon=True).start()
def _close_tab(self, term):
term.disconnect()
self.notebook.forget(term)
term.destroy()
def destroy(self):
for child in self.notebook.winfo_children():
if isinstance(child, TerminalWidget):
child.disconnect()
super().destroy()
if __name__ == "__main__":
app = App()
app.mainloop()