archive
This commit is contained in:
182 files changed
+18062
-277
No files matched your search
@@ -0,0 +1,419 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
|
||||
from fabric.utils import (
|
||||
Gdk,
|
||||
exec_shell_command_async,
|
||||
idle_add,
|
||||
os,
|
||||
re,
|
||||
remove_handler,
|
||||
)
|
||||
from fabric.widgets.box import Box
|
||||
from fabric.widgets.button import Button
|
||||
from fabric.widgets.label import Label
|
||||
|
||||
from modules.launcher.base import LauncherPlugin
|
||||
from utils.functions import copy_text, terminal_exec_command
|
||||
|
||||
|
||||
class SSHPlugin(LauncherPlugin):
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "ssh"
|
||||
|
||||
@property
|
||||
def icon(self) -> str:
|
||||
return "network-server-symbolic"
|
||||
|
||||
@property
|
||||
def keywords(self) -> list[str]:
|
||||
return ["ssh"]
|
||||
|
||||
def __init__(self, handler):
|
||||
super().__init__(handler)
|
||||
self._query_handler: int = 0
|
||||
self.current_slots: list = []
|
||||
self.terminal = "kitty" # default terminal
|
||||
|
||||
self._hosts_cache: list[dict] = []
|
||||
self._hosts_cache_hash: int | None = None
|
||||
|
||||
def on_search(self, text: str) -> None:
|
||||
self.query_ssh(text)
|
||||
|
||||
def stop(self):
|
||||
if self._query_handler:
|
||||
remove_handler(self._query_handler)
|
||||
self._query_handler = 0
|
||||
self.current_slots.clear()
|
||||
|
||||
def query_ssh(self, text: str) -> None:
|
||||
self.handler.start("ssh")
|
||||
self.current_slots.clear()
|
||||
s = text.strip()
|
||||
|
||||
commands = {
|
||||
"terminal": self._handle_terminal_command,
|
||||
"help": lambda t: self._show_command_help(),
|
||||
}
|
||||
|
||||
for prefix, handler in commands.items():
|
||||
if s.startswith(prefix):
|
||||
handler(text)
|
||||
return
|
||||
|
||||
self._load_and_display_hosts(text)
|
||||
|
||||
def _load_and_display_hosts(self, text: str) -> None:
|
||||
hosts = self._load_hosts()
|
||||
if not hosts:
|
||||
self._show_no_hosts()
|
||||
return
|
||||
|
||||
filtered = self._filter_hosts(text, hosts)
|
||||
# Convert to list to check if empty
|
||||
filtered_list = list(filtered)
|
||||
if not filtered_list:
|
||||
self._show_no_results()
|
||||
return
|
||||
|
||||
self._query_handler = idle_add(
|
||||
self._bake_next_slot, iter(filtered_list), pin=True
|
||||
)
|
||||
|
||||
def _filter_hosts(self, text: str, hosts: list[dict]) -> Iterator[dict]:
|
||||
if not text.strip():
|
||||
return iter(hosts)
|
||||
q = text.lower()
|
||||
return iter(
|
||||
h
|
||||
for h in hosts
|
||||
if q in h["name"].lower()
|
||||
or (h.get("user") and q in h["user"].lower())
|
||||
or (h.get("hostname") and q in h["hostname"].lower())
|
||||
)
|
||||
|
||||
def _bake_next_slot(self, iterator: Iterator[dict]) -> bool:
|
||||
try:
|
||||
host = next(iterator)
|
||||
except StopIteration:
|
||||
idle_add(self.handler.done)
|
||||
return False
|
||||
self._create_slot(host)
|
||||
return True
|
||||
|
||||
def _create_slot(self, host: dict) -> None:
|
||||
display = host["name"]
|
||||
subtitle = " · ".join(
|
||||
list(
|
||||
filter(
|
||||
None,
|
||||
[
|
||||
host.get("user"),
|
||||
host.get("hostname")
|
||||
if host.get("hostname") != display
|
||||
else None,
|
||||
],
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
name_label = Label(
|
||||
label=display,
|
||||
style_classes="ssh-host-name",
|
||||
h_align="start",
|
||||
max_chars_width=35,
|
||||
ellipsization="end",
|
||||
)
|
||||
|
||||
info_label = Label(
|
||||
label=subtitle or "",
|
||||
style_classes="ssh-host-info",
|
||||
h_align="start",
|
||||
max_chars_width=40,
|
||||
ellipsization="end",
|
||||
)
|
||||
|
||||
slot_widget = Button(
|
||||
style_classes="app-slot ssh-slot",
|
||||
child=Box(
|
||||
orientation="v",
|
||||
spacing=4,
|
||||
children=[name_label, info_label],
|
||||
),
|
||||
on_clicked=lambda *_: self._connect(host),
|
||||
tooltip_text=(
|
||||
f"Enter: connect via {self.terminal}\nShift+Enter: copy ssh command"
|
||||
),
|
||||
)
|
||||
|
||||
slot_widget.connect(
|
||||
"key-press-event",
|
||||
lambda widget, event, h=host: self._on_slot_key_press(widget, event, h),
|
||||
)
|
||||
|
||||
self.current_slots.append({"widget": slot_widget, "host": host})
|
||||
self.handler.slot_ready(slot_widget, "ssh")
|
||||
|
||||
def _on_slot_key_press(self, widget, event, host: dict) -> bool:
|
||||
if not event:
|
||||
return False
|
||||
return bool(
|
||||
(event.keyval == Gdk.KEY_Return or event.keyval == Gdk.KEY_KP_Enter)
|
||||
and event.state & Gdk.ModifierType.SHIFT_MASK
|
||||
and not self._copy_command(host)
|
||||
)
|
||||
|
||||
def _copy_command(self, host: dict) -> None:
|
||||
cmd = self._build_ssh_command(host)
|
||||
copy_text(cmd)
|
||||
self.handler.launched()
|
||||
|
||||
def _connect(self, host: dict) -> None:
|
||||
cmd = self._build_ssh_command(host)
|
||||
term_cmd = self._terminal_command(cmd)
|
||||
exec_shell_command_async(term_cmd)
|
||||
self.handler.launched()
|
||||
|
||||
def _terminal_command(self, inner_cmd: str) -> str:
|
||||
return terminal_exec_command(self.terminal, inner_cmd)
|
||||
|
||||
def _build_ssh_command(self, host: dict) -> str:
|
||||
user = host.get("user")
|
||||
name = host["name"]
|
||||
return f"ssh {user}@{name}" if user else f"ssh {name}"
|
||||
|
||||
def _show_no_hosts(self) -> None:
|
||||
self.handler.slot_ready(
|
||||
Button(
|
||||
style_classes="app-slot ssh-no-hosts",
|
||||
child=Box(
|
||||
orientation="h",
|
||||
spacing=12,
|
||||
children=[
|
||||
Label(
|
||||
label=(
|
||||
"No SSH hosts found. Add entries to ~/.ssh/config or connect once to populate known_hosts."
|
||||
),
|
||||
style_classes="ssh-no-hosts-text",
|
||||
h_align="center",
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
"ssh",
|
||||
)
|
||||
self.handler.done()
|
||||
|
||||
def _show_error_message(self, message: str) -> None:
|
||||
self.handler.slot_ready(
|
||||
Button(
|
||||
style_classes="app-slot ssh-error",
|
||||
child=Box(
|
||||
orientation="h",
|
||||
spacing=12,
|
||||
children=[
|
||||
Label(
|
||||
label=message,
|
||||
style_classes="ssh-error-text",
|
||||
h_align="center",
|
||||
),
|
||||
],
|
||||
),
|
||||
on_clicked=lambda *_: self.handler.launched(),
|
||||
),
|
||||
"ssh",
|
||||
)
|
||||
self.handler.done()
|
||||
|
||||
def _show_no_results(self) -> None:
|
||||
self.handler.slot_ready(
|
||||
Button(
|
||||
style_classes="app-slot ssh-no-results",
|
||||
child=Box(
|
||||
orientation="h",
|
||||
spacing=12,
|
||||
children=[
|
||||
Label(
|
||||
label="No SSH hosts match your search",
|
||||
style_classes="ssh-no-results-text",
|
||||
h_align="center",
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
"ssh",
|
||||
)
|
||||
self.handler.done()
|
||||
|
||||
def _handle_terminal_command(self, text: str) -> None:
|
||||
parts = text.strip().split()
|
||||
if len(parts) < 2:
|
||||
self._show_command_help("terminal")
|
||||
return
|
||||
self.terminal = parts[1]
|
||||
# show refreshed list after changing terminal
|
||||
self._refresh_host_list()
|
||||
|
||||
def _show_command_help(self, command: str | None = None) -> None:
|
||||
help_texts = {
|
||||
"terminal": "Usage: terminal terminal_name\nExample: terminal kitty",
|
||||
}
|
||||
help_text = help_texts.get(
|
||||
command,
|
||||
"""Available commands:
|
||||
• terminal terminal_name (Set terminal emulator)
|
||||
• help
|
||||
|
||||
Enter: connect via terminal
|
||||
Shift+Enter: copy ssh command
|
||||
""",
|
||||
)
|
||||
|
||||
self.handler.slot_ready(
|
||||
Button(
|
||||
style_classes="app-slot ssh-help",
|
||||
child=Box(
|
||||
orientation="h",
|
||||
spacing=12,
|
||||
children=[
|
||||
Label(
|
||||
label=help_text,
|
||||
style_classes="ssh-help-text",
|
||||
h_align="center",
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
"ssh",
|
||||
)
|
||||
self.handler.done()
|
||||
|
||||
def _refresh_host_list(self) -> None:
|
||||
self.current_slots.clear()
|
||||
if hasattr(self.handler, "clear_viewport"):
|
||||
self.handler.clear_viewport()
|
||||
idle_add(self._show_host_list)
|
||||
|
||||
def _show_host_list(self) -> None:
|
||||
self.handler.start("ssh")
|
||||
self._load_and_display_hosts("")
|
||||
|
||||
def _load_hosts(self) -> list[dict]:
|
||||
user_config_path = Path(os.path.expanduser("~/.ssh/config"))
|
||||
system_config_paths = [
|
||||
Path("/etc/ssh/config"),
|
||||
Path("/etc/ssh/ssh_config"),
|
||||
]
|
||||
known_hosts_path = Path(os.path.expanduser("~/.ssh/known_hosts"))
|
||||
|
||||
cfg_mtime = self._stat_ns(user_config_path)
|
||||
sys_mtime_total = 0
|
||||
for p in system_config_paths:
|
||||
sys_mtime_total ^= self._stat_ns(p)
|
||||
kh_mtime = self._stat_ns(known_hosts_path)
|
||||
fingerprint = cfg_mtime ^ kh_mtime ^ sys_mtime_total
|
||||
if self._hosts_cache_hash == fingerprint and self._hosts_cache:
|
||||
return self._hosts_cache
|
||||
|
||||
hosts_by_name: dict[str, dict] = {}
|
||||
|
||||
for sys_cfg in system_config_paths:
|
||||
self._parse_ssh_config_content(
|
||||
self._read_text(sys_cfg),
|
||||
"system_config",
|
||||
hosts_by_name,
|
||||
overwrite=False,
|
||||
)
|
||||
|
||||
self._parse_ssh_config_content(
|
||||
self._read_text(user_config_path),
|
||||
"user_config",
|
||||
hosts_by_name,
|
||||
overwrite=True,
|
||||
)
|
||||
|
||||
self._parse_known_hosts(self._read_text(known_hosts_path), hosts_by_name)
|
||||
hosts = list(hosts_by_name.values())
|
||||
rank = {"user_config": 0, "system_config": 1}
|
||||
hosts.sort(key=lambda h: (rank.get(h.get("source"), 2), h["name"].lower()))
|
||||
|
||||
self._hosts_cache = hosts
|
||||
self._hosts_cache_hash = fingerprint
|
||||
return hosts
|
||||
|
||||
def _stat_ns(self, path: Path) -> int:
|
||||
return path.stat().st_mtime_ns if path.exists() else 0
|
||||
|
||||
def _read_text(self, path: Path) -> str:
|
||||
return path.read_text() if path.exists() else ""
|
||||
|
||||
def _parse_ssh_config_content(
|
||||
self,
|
||||
content: str,
|
||||
source: str,
|
||||
hosts_by_name: dict[str, dict],
|
||||
overwrite: bool,
|
||||
) -> None:
|
||||
if not content:
|
||||
return
|
||||
blocks = re.split(r"\n(?=Host\s)", content, flags=re.IGNORECASE)
|
||||
for block in blocks:
|
||||
lines = [
|
||||
ln
|
||||
for ln in block.strip().splitlines()
|
||||
if ln.strip() and not ln.strip().startswith("#")
|
||||
]
|
||||
if not lines:
|
||||
continue
|
||||
header = lines[0]
|
||||
m = re.match(r"Host\s+(.+)$", header, re.I)
|
||||
if not m:
|
||||
continue
|
||||
names = [n for n in m.group(1).split() if n != "*"]
|
||||
if not names:
|
||||
continue
|
||||
attrs = {}
|
||||
for ln in lines[1:]:
|
||||
parts = ln.split(None, 1)
|
||||
if len(parts) != 2:
|
||||
continue
|
||||
key, val = parts[0].lower(), parts[1].strip()
|
||||
if key in ("user", "hostname"):
|
||||
attrs[key] = val
|
||||
for name in names:
|
||||
if not overwrite and name in hosts_by_name:
|
||||
continue
|
||||
hosts_by_name[name] = {
|
||||
"name": name,
|
||||
"user": attrs.get("user"),
|
||||
"hostname": attrs.get("hostname", name),
|
||||
"source": source,
|
||||
}
|
||||
|
||||
def _parse_known_hosts(self, content: str, hosts_by_name: dict[str, dict]) -> None:
|
||||
if not content:
|
||||
return
|
||||
for line in content.splitlines():
|
||||
line_s = line.strip()
|
||||
if not line_s or line_s.startswith("#"):
|
||||
continue
|
||||
first = line_s.split()[0]
|
||||
if first.startswith("|"):
|
||||
continue # hashed name
|
||||
for hostpart in first.split(","):
|
||||
host_clean = hostpart.strip()
|
||||
if host_clean and host_clean not in hosts_by_name:
|
||||
hosts_by_name[host_clean] = {
|
||||
"name": host_clean,
|
||||
"user": None,
|
||||
"hostname": host_clean,
|
||||
"source": "known_hosts",
|
||||
}
|
||||
|
||||
def handle_external(self, command: str, args: str) -> None:
|
||||
# Map external commands if needed
|
||||
pass
|
||||
Reference in new issue
Block a user