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>
This commit is contained in:
shuki
2026-03-06 02:53:31 +02:00
parent 733bb5de62
commit e6aa828111
4 changed files with 119 additions and 6 deletions

View File

@@ -241,3 +241,47 @@ Switch {
color: #00cc00; color: #00cc00;
margin: 1 0 0 0; margin: 1 0 0 0;
} }
/* SSH key browse row */
#re-key-row {
height: auto;
margin: 0 0 1 0;
}
#re-key-row Input {
width: 1fr;
}
#re-key-row Button {
width: auto;
min-width: 12;
margin: 0 0 0 1;
}
/* File picker */
#file-picker {
width: 70;
height: 30;
padding: 1;
background: $panel;
border: thick $accent;
}
#fip-title {
text-style: bold;
color: #00cc00;
margin: 0 0 1 0;
}
#fip-tree {
height: 1fr;
}
#fip-buttons {
height: auto;
margin: 1 0 0 0;
}
#fip-buttons Button {
margin: 0 1 0 0;
}

View File

@@ -1,4 +1,5 @@
import re import re
from pathlib import Path
from textual.app import ComposeResult from textual.app import ComposeResult
from textual.screen import Screen from textual.screen import Screen
from textual.widgets import Header, Footer, Static, Button, Input, Select, RadioSet, RadioButton from textual.widgets import Header, Footer, Static, Button, Input, Select, RadioSet, RadioButton
@@ -6,6 +7,7 @@ from textual.containers import Vertical, Horizontal
from tui.config import parse_conf, write_conf, CONFIG_DIR from tui.config import parse_conf, write_conf, CONFIG_DIR
from tui.models import Remote from tui.models import Remote
from tui.widgets import FilePicker
_NAME_RE = re.compile(r'^[a-zA-Z][a-zA-Z0-9_-]{0,31}$') _NAME_RE = re.compile(r'^[a-zA-Z][a-zA-Z0-9_-]{0,31}$')
@@ -54,10 +56,12 @@ class RemoteEditScreen(Screen):
value=remote.auth_method, value=remote.auth_method,
classes="ssh-field", classes="ssh-field",
) )
yield Static("SSH Key path:", id="lbl-key", classes="ssh-field") yield Static("SSH Key path:", id="lbl-key", classes="ssh-field ssh-key-field")
yield Input(value=remote.key, placeholder="~/.ssh/id_rsa", id="re-key", classes="ssh-field") with Horizontal(id="re-key-row", classes="ssh-field ssh-key-field"):
yield Static("Password:", id="lbl-password", classes="ssh-field") yield Input(value=remote.key, placeholder="~/.ssh/id_rsa", id="re-key")
yield Input(value=remote.password, placeholder="SSH password", password=True, id="re-password", classes="ssh-field") 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 # Common fields
yield Static("Base path:") yield Static("Base path:")
yield Input(value=remote.base, placeholder="/backups", id="re-base") yield Input(value=remote.base, placeholder="/backups", id="re-base")
@@ -90,25 +94,43 @@ class RemoteEditScreen(Screen):
self._update_field_visibility() self._update_field_visibility()
def on_select_changed(self, event: Select.Changed) -> None: def on_select_changed(self, event: Select.Changed) -> None:
if event.select.id == "re-type": if event.select.id in ("re-type", "re-auth"):
self._update_field_visibility() self._update_field_visibility()
def _update_field_visibility(self) -> None: def _update_field_visibility(self) -> None:
type_sel = self.query_one("#re-type", Select) type_sel = self.query_one("#re-type", Select)
rtype = str(type_sel.value) if isinstance(type_sel.value, str) else "ssh" rtype = str(type_sel.value) if isinstance(type_sel.value, str) else "ssh"
is_ssh = rtype == "ssh"
for w in self.query(".ssh-field"): for w in self.query(".ssh-field"):
w.display = rtype == "ssh" w.display = is_ssh
for w in self.query(".s3-field"): for w in self.query(".s3-field"):
w.display = rtype == "s3" w.display = rtype == "s3"
for w in self.query(".gdrive-field"): for w in self.query(".gdrive-field"):
w.display = rtype == "gdrive" 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: def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "btn-cancel": if event.button.id == "btn-cancel":
self.dismiss(None) 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": elif event.button.id == "btn-save":
self._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: def _save(self) -> None:
if self._is_new: if self._is_new:
name = self.query_one("#re-name", Input).value.strip() name = self.query_one("#re-name", Input).value.strip()

View File

@@ -1,3 +1,4 @@
from tui.widgets.folder_picker import FolderPicker from tui.widgets.folder_picker import FolderPicker
from tui.widgets.file_picker import FilePicker
from tui.widgets.confirm_dialog import ConfirmDialog from tui.widgets.confirm_dialog import ConfirmDialog
from tui.widgets.operation_log import OperationLog from tui.widgets.operation_log import OperationLog

View File

@@ -0,0 +1,46 @@
from pathlib import Path
from textual.app import ComposeResult
from textual.screen import ModalScreen
from textual.widgets import DirectoryTree, Static, Button
from textual.containers import Horizontal, Vertical
class FilePicker(ModalScreen[str | None]):
BINDINGS = [("escape", "cancel", "Cancel")]
def __init__(self, title: str = "Select file", start: str = "/"):
super().__init__()
self._title = title
self._start = start
def compose(self) -> ComposeResult:
with Vertical(id="file-picker"):
yield Static(self._title, id="fip-title")
yield DirectoryTree(self._start, id="fip-tree")
with Horizontal(id="fip-buttons"):
yield Button("Select", variant="primary", id="fip-select")
yield Button("Cancel", variant="default", id="fip-cancel")
def _get_selected_path(self) -> Path | None:
tree = self.query_one("#fip-tree", DirectoryTree)
node = tree.cursor_node
if node and node.data and node.data.path:
return node.data.path
return None
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "fip-select":
path = self._get_selected_path()
if path and path.is_file():
self.dismiss(str(path))
elif path and path.is_dir():
self.notify("Please select a file, not a directory", severity="warning")
else:
self.dismiss(None)
else:
self.dismiss(None)
def action_cancel(self) -> None:
self.dismiss(None)