Files
gniza4linux/tui/screens/remote_edit.py
shuki e6aa828111 Toggle SSH key/password fields by auth method and add key file browser
Show only the SSH key path input (with Browse button) when auth method
is "key", and only the password input when auth method is "password".
Adds a FilePicker widget for browsing SSH key files, defaulting to
~/.ssh directory.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 02:53:31 +02:00

184 lines
8.9 KiB
Python

import re
from pathlib import Path
from textual.app import ComposeResult
from textual.screen import Screen
from textual.widgets import Header, Footer, Static, Button, Input, Select, RadioSet, RadioButton
from textual.containers import Vertical, Horizontal
from tui.config import parse_conf, write_conf, CONFIG_DIR
from tui.models import Remote
from tui.widgets import FilePicker
_NAME_RE = re.compile(r'^[a-zA-Z][a-zA-Z0-9_-]{0,31}$')
REMOTE_TYPES = [("SSH", "ssh"), ("Local directory", "local"), ("Amazon S3", "s3"), ("Google Drive", "gdrive")]
class RemoteEditScreen(Screen):
BINDINGS = [("escape", "go_back", "Back")]
def __init__(self, name: str = ""):
super().__init__()
self._edit_name = name
self._is_new = not name
def compose(self) -> ComposeResult:
yield Header()
title = "Add Remote" if self._is_new else f"Edit Remote: {self._edit_name}"
remote = Remote()
if not self._is_new:
data = parse_conf(CONFIG_DIR / "remotes.d" / f"{self._edit_name}.conf")
remote = Remote.from_conf(self._edit_name, data)
with Vertical(id="remote-edit"):
yield Static(title, id="screen-title")
if self._is_new:
yield Static("Name:")
yield Input(value="", placeholder="Remote name", id="re-name")
yield Static("Type:")
yield Select(
REMOTE_TYPES,
id="re-type",
value=remote.type,
)
# SSH fields
yield Static("Host:", id="lbl-host", classes="ssh-field")
yield Input(value=remote.host, placeholder="hostname or IP", id="re-host", classes="ssh-field")
yield Static("Port:", id="lbl-port", classes="ssh-field")
yield Input(value=remote.port, placeholder="22", id="re-port", classes="ssh-field")
yield Static("User:", id="lbl-user", classes="ssh-field")
yield Input(value=remote.user, placeholder="root", id="re-user", classes="ssh-field")
yield Static("Auth method:", id="lbl-auth", classes="ssh-field")
yield Select(
[("SSH Key", "key"), ("Password", "password")],
id="re-auth",
value=remote.auth_method,
classes="ssh-field",
)
yield Static("SSH Key path:", id="lbl-key", classes="ssh-field ssh-key-field")
with Horizontal(id="re-key-row", classes="ssh-field ssh-key-field"):
yield Input(value=remote.key, placeholder="~/.ssh/id_rsa", id="re-key")
yield Button("Browse...", id="btn-browse-key")
yield Static("Password:", id="lbl-password", classes="ssh-field ssh-password-field")
yield Input(value=remote.password, placeholder="SSH password", password=True, id="re-password", classes="ssh-field ssh-password-field")
# Common fields
yield Static("Base path:")
yield Input(value=remote.base, placeholder="/backups", id="re-base")
yield Static("Bandwidth limit (KB/s, 0=unlimited):")
yield Input(value=remote.bwlimit, placeholder="0", id="re-bwlimit")
yield Static("Retention count:")
yield Input(value=remote.retention_count, placeholder="30", id="re-retention")
# S3 fields
yield Static("S3 Bucket:", id="lbl-s3bucket", classes="s3-field")
yield Input(value=remote.s3_bucket, placeholder="bucket-name", id="re-s3bucket", classes="s3-field")
yield Static("S3 Region:", id="lbl-s3region", classes="s3-field")
yield Input(value=remote.s3_region, placeholder="us-east-1", id="re-s3region", classes="s3-field")
yield Static("S3 Endpoint:", id="lbl-s3endpoint", classes="s3-field")
yield Input(value=remote.s3_endpoint, placeholder="Leave empty for AWS", id="re-s3endpoint", classes="s3-field")
yield Static("Access Key ID:", id="lbl-s3key", classes="s3-field")
yield Input(value=remote.s3_access_key_id, id="re-s3key", classes="s3-field")
yield Static("Secret Access Key:", id="lbl-s3secret", classes="s3-field")
yield Input(value=remote.s3_secret_access_key, password=True, id="re-s3secret", classes="s3-field")
# GDrive fields
yield Static("Service Account JSON:", id="lbl-gdsa", classes="gdrive-field")
yield Input(value=remote.gdrive_sa_file, placeholder="/path/to/sa.json", id="re-gdsa", classes="gdrive-field")
yield Static("Root Folder ID:", id="lbl-gdfolder", classes="gdrive-field")
yield Input(value=remote.gdrive_root_folder_id, id="re-gdfolder", classes="gdrive-field")
with Horizontal(id="re-buttons"):
yield Button("Save", variant="primary", id="btn-save")
yield Button("Cancel", id="btn-cancel")
yield Footer()
def on_mount(self) -> None:
self._update_field_visibility()
def on_select_changed(self, event: Select.Changed) -> None:
if event.select.id in ("re-type", "re-auth"):
self._update_field_visibility()
def _update_field_visibility(self) -> None:
type_sel = self.query_one("#re-type", Select)
rtype = str(type_sel.value) if isinstance(type_sel.value, str) else "ssh"
is_ssh = rtype == "ssh"
for w in self.query(".ssh-field"):
w.display = is_ssh
for w in self.query(".s3-field"):
w.display = rtype == "s3"
for w in self.query(".gdrive-field"):
w.display = rtype == "gdrive"
# Toggle key vs password fields based on auth method
if is_ssh:
auth_sel = self.query_one("#re-auth", Select)
auth = str(auth_sel.value) if isinstance(auth_sel.value, str) else "key"
for w in self.query(".ssh-key-field"):
w.display = auth == "key"
for w in self.query(".ssh-password-field"):
w.display = auth == "password"
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "btn-cancel":
self.dismiss(None)
elif event.button.id == "btn-browse-key":
self.app.push_screen(
FilePicker("Select SSH key file", start=str(Path.home() / ".ssh")),
callback=self._key_file_selected,
)
elif event.button.id == "btn-save":
self._save()
def _key_file_selected(self, path: str | None) -> None:
if path:
self.query_one("#re-key", Input).value = path
def _save(self) -> None:
if self._is_new:
name = self.query_one("#re-name", Input).value.strip()
if not name:
self.notify("Name is required", severity="error")
return
if not _NAME_RE.match(name):
self.notify("Invalid name.", severity="error")
return
if (CONFIG_DIR / "remotes.d" / f"{name}.conf").exists():
self.notify(f"Remote '{name}' already exists.", severity="error")
return
else:
name = self._edit_name
type_sel = self.query_one("#re-type", Select)
rtype = str(type_sel.value) if isinstance(type_sel.value, str) else "ssh"
remote = Remote(
name=name,
type=rtype,
host=self.query_one("#re-host", Input).value.strip(),
port=self.query_one("#re-port", Input).value.strip() or "22",
user=self.query_one("#re-user", Input).value.strip() or "root",
auth_method=str(self.query_one("#re-auth", Select).value) if isinstance(self.query_one("#re-auth", Select).value, str) else "key",
key=self.query_one("#re-key", Input).value.strip(),
password=self.query_one("#re-password", Input).value,
base=self.query_one("#re-base", Input).value.strip() or "/backups",
bwlimit=self.query_one("#re-bwlimit", Input).value.strip() or "0",
retention_count=self.query_one("#re-retention", Input).value.strip() or "30",
s3_bucket=self.query_one("#re-s3bucket", Input).value.strip(),
s3_region=self.query_one("#re-s3region", Input).value.strip() or "us-east-1",
s3_endpoint=self.query_one("#re-s3endpoint", Input).value.strip(),
s3_access_key_id=self.query_one("#re-s3key", Input).value.strip(),
s3_secret_access_key=self.query_one("#re-s3secret", Input).value,
gdrive_sa_file=self.query_one("#re-gdsa", Input).value.strip(),
gdrive_root_folder_id=self.query_one("#re-gdfolder", Input).value.strip(),
)
if rtype == "ssh" and not remote.host:
self.notify("Host is required for SSH remotes", severity="error")
return
conf = CONFIG_DIR / "remotes.d" / f"{name}.conf"
write_conf(conf, remote.to_conf())
self.notify(f"Remote '{name}' saved.")
self.dismiss(name)
def action_go_back(self) -> None:
self.dismiss(None)