657 lines
17 KiB
Go
657 lines
17 KiB
Go
package tui
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"charm.land/bubbles/v2/list"
|
|
"charm.land/bubbles/v2/textarea"
|
|
"charm.land/bubbles/v2/textinput"
|
|
tea "charm.land/bubbletea/v2"
|
|
"charm.land/lipgloss/v2"
|
|
|
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/cache"
|
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/netbox"
|
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/notes"
|
|
)
|
|
|
|
// SelectedHost is returned when the user confirms a host in the TUI.
|
|
type SelectedHost struct {
|
|
Kind string
|
|
Name string
|
|
IP string
|
|
User string // empty = use config default
|
|
Port string // empty = use default port
|
|
}
|
|
|
|
// --- bubbletea messages ---
|
|
|
|
type debounceMsg struct {
|
|
query string
|
|
seq int
|
|
}
|
|
type noteResultMsg struct {
|
|
note notes.Note
|
|
err error
|
|
saved bool
|
|
}
|
|
|
|
type searchResultMsg struct {
|
|
query string
|
|
seq int
|
|
entries []netbox.HostEntry
|
|
err error
|
|
recent bool // true when this is a recently-used list, not a search result
|
|
}
|
|
|
|
// --- list item ---
|
|
|
|
type hostItem struct {
|
|
name string
|
|
id int
|
|
ip string
|
|
kind string
|
|
tags []string
|
|
}
|
|
|
|
func (h hostItem) Title() string { return h.name }
|
|
func (h hostItem) Description() string { return fmt.Sprintf("%s [%s]", h.ip, h.kind) }
|
|
func (h hostItem) FilterValue() string { return h.name }
|
|
|
|
// --- compact list delegate ---
|
|
|
|
type compactDelegate struct{}
|
|
|
|
func (d compactDelegate) Height() int { return 1 }
|
|
func (d compactDelegate) Spacing() int { return 0 }
|
|
func (d compactDelegate) Update(_ tea.Msg, _ *list.Model) tea.Cmd { return nil }
|
|
func (d compactDelegate) Render(w io.Writer, m list.Model, index int, item list.Item) {
|
|
h, ok := item.(hostItem)
|
|
if !ok {
|
|
return
|
|
}
|
|
line := fmt.Sprintf(" %s %s", h.name, lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render(h.ip))
|
|
if index == m.Index() {
|
|
line = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("86")).Render("> " + strings.TrimPrefix(line, " "))
|
|
}
|
|
fmt.Fprint(w, line)
|
|
}
|
|
|
|
// --- filter ---
|
|
|
|
type filterOpts struct {
|
|
tags []string
|
|
kind string
|
|
}
|
|
|
|
func parseFilter(s string) filterOpts {
|
|
var f filterOpts
|
|
for _, part := range strings.Fields(s) {
|
|
if after, ok := strings.CutPrefix(part, "tag:"); ok {
|
|
f.tags = append(f.tags, after)
|
|
} else if after, ok := strings.CutPrefix(part, "kind:"); ok {
|
|
f.kind = after
|
|
}
|
|
}
|
|
return f
|
|
}
|
|
|
|
func applyFilter(entries []netbox.HostEntry, f filterOpts) []netbox.HostEntry {
|
|
if len(f.tags) == 0 && f.kind == "" {
|
|
return entries
|
|
}
|
|
out := make([]netbox.HostEntry, 0, len(entries))
|
|
for _, e := range entries {
|
|
if f.kind != "" && e.Kind != f.kind {
|
|
continue
|
|
}
|
|
if len(f.tags) > 0 {
|
|
tagSet := make(map[string]bool, len(e.Tags))
|
|
for _, t := range e.Tags {
|
|
tagSet[strings.ToLower(t)] = true
|
|
}
|
|
allMatch := true
|
|
for _, want := range f.tags {
|
|
if !tagSet[strings.ToLower(want)] {
|
|
allMatch = false
|
|
break
|
|
}
|
|
}
|
|
if !allMatch {
|
|
continue
|
|
}
|
|
}
|
|
out = append(out, e)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// --- Model ---
|
|
|
|
type Model struct {
|
|
noteErr error
|
|
editHost *hostItem
|
|
noteInput textarea.Model
|
|
noteService *notes.Service
|
|
note notes.Note
|
|
noteMode, noteRemote, noteBusy bool
|
|
noteLoaded bool
|
|
defaultPort int
|
|
input textinput.Model
|
|
filterInput textinput.Model // tag:X kind:Y filter, toggled with ctrl+f
|
|
editInput textinput.Model // user@host:port inline editor
|
|
list list.Model
|
|
client *netbox.Client
|
|
cache *cache.Cache
|
|
defaultUser string
|
|
lastSent string // last query sent to NetBox (or served from cache)
|
|
lastResults []netbox.HostEntry // raw results before filter applied
|
|
seq int // sequence number to discard stale results
|
|
loading bool
|
|
err error
|
|
selected *SelectedHost
|
|
width int
|
|
height int
|
|
recentMode bool // true when showing recent hosts (empty search, initial state)
|
|
filterOpen bool // Ctrl+F toggles
|
|
editMode bool // Ctrl+E on the selected item
|
|
}
|
|
|
|
func New(client *netbox.Client, c *cache.Cache, defaultUser string, defaultPorts ...int) *Model {
|
|
ti := textinput.New()
|
|
ti.Placeholder = "Search hostname…"
|
|
ti.Focus()
|
|
|
|
fi := textinput.New()
|
|
fi.Placeholder = "tag:prod kind:vm"
|
|
fi.Prompt = "Filter: "
|
|
|
|
ei := textinput.New()
|
|
ei.Prompt = ""
|
|
|
|
l := list.New(nil, compactDelegate{}, 0, 0)
|
|
l.SetShowHelp(false)
|
|
l.SetShowTitle(false)
|
|
l.SetShowStatusBar(false)
|
|
l.SetFilteringEnabled(false)
|
|
l.DisableQuitKeybindings()
|
|
port := 22
|
|
if len(defaultPorts) > 0 && defaultPorts[0] > 0 {
|
|
port = defaultPorts[0]
|
|
}
|
|
|
|
ni := textarea.New()
|
|
ni.CharLimit = 0
|
|
ni.SetHeight(10)
|
|
return &Model{
|
|
noteInput: ni,
|
|
defaultPort: port,
|
|
input: ti,
|
|
filterInput: fi,
|
|
editInput: ei,
|
|
list: l,
|
|
client: client,
|
|
cache: c,
|
|
defaultUser: defaultUser,
|
|
recentMode: true,
|
|
}
|
|
}
|
|
|
|
func (m *Model) Init() tea.Cmd {
|
|
return tea.Batch(textinput.Blink, m.loadRecent())
|
|
}
|
|
|
|
// loadRecent returns a Cmd that immediately resolves to the recently-used host list.
|
|
func (m *Model) loadRecent() tea.Cmd {
|
|
return func() tea.Msg {
|
|
if m.cache == nil {
|
|
return searchResultMsg{query: "", entries: nil, recent: true}
|
|
}
|
|
recent := m.cache.RecentlyUsed(10)
|
|
entries := make([]netbox.HostEntry, len(recent))
|
|
for i, e := range recent {
|
|
entries[i] = netbox.HostEntry{Name: e.Name, PrimaryIP4: e.IP, Kind: e.Kind, Tags: e.Tags}
|
|
}
|
|
return searchResultMsg{query: "", entries: entries, recent: true}
|
|
}
|
|
}
|
|
|
|
func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
defer m.resizeList()
|
|
switch msg := msg.(type) {
|
|
case noteResultMsg:
|
|
m.noteBusy = false
|
|
m.noteErr = msg.err
|
|
if msg.err == nil {
|
|
m.noteLoaded = true
|
|
m.note = msg.note
|
|
m.noteInput.SetValue(msg.note.Text)
|
|
if msg.saved {
|
|
m.noteMode = false
|
|
m.noteInput.Blur()
|
|
m.input.Focus()
|
|
}
|
|
}
|
|
return m, nil
|
|
|
|
case tea.WindowSizeMsg:
|
|
m.width = msg.Width
|
|
m.height = msg.Height
|
|
m.input.SetWidth(max(1, msg.Width-lipgloss.Width(m.input.Prompt)))
|
|
m.filterInput.SetWidth(max(1, msg.Width-lipgloss.Width(m.filterInput.Prompt)))
|
|
m.editInput.SetWidth(max(1, msg.Width-lipgloss.Width("Connect as: ")))
|
|
m.noteInput.SetWidth(max(10, msg.Width-4))
|
|
return m, nil
|
|
|
|
case tea.KeyPressMsg:
|
|
if msg.String() == "ctrl+c" {
|
|
return m, tea.Quit
|
|
}
|
|
if m.noteMode {
|
|
if m.noteBusy {
|
|
return m, nil
|
|
}
|
|
switch msg.String() {
|
|
case "esc":
|
|
m.noteMode = false
|
|
m.noteInput.Blur()
|
|
m.input.Focus()
|
|
m.noteErr = nil
|
|
return m, nil
|
|
case "ctrl+s":
|
|
if !m.noteLoaded {
|
|
return m, nil
|
|
}
|
|
note := m.note
|
|
note.Text = m.noteInput.Value()
|
|
remote, service := m.noteRemote, m.noteService
|
|
m.noteBusy = true
|
|
return m, func() tea.Msg {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
|
defer cancel()
|
|
return noteResultMsg{note: note, saved: true, err: service.Write(ctx, note, remote)}
|
|
}
|
|
}
|
|
var cmd tea.Cmd
|
|
m.noteInput, cmd = m.noteInput.Update(msg)
|
|
return m, cmd
|
|
}
|
|
// --- Edit mode ---
|
|
if m.editMode {
|
|
switch msg.String() {
|
|
case "esc":
|
|
m.editMode = false
|
|
m.editInput.Blur()
|
|
m.input.Focus()
|
|
return m, nil
|
|
case "enter":
|
|
if m.editHost != nil {
|
|
item := *m.editHost
|
|
value := m.editInput.Value()
|
|
if idx := strings.LastIndex(value, ":"); idx >= 0 {
|
|
port, err := strconv.Atoi(strings.TrimSpace(value[idx+1:]))
|
|
if err != nil || port < 1 || port > 65535 {
|
|
m.err = fmt.Errorf("port must be between 1 and 65535")
|
|
return m, nil
|
|
}
|
|
}
|
|
m.selected = m.buildSelected(item)
|
|
}
|
|
return m, tea.Quit
|
|
}
|
|
var cmd tea.Cmd
|
|
m.editInput, cmd = m.editInput.Update(msg)
|
|
return m, cmd
|
|
}
|
|
|
|
// --- Filter focused ---
|
|
if m.filterOpen {
|
|
switch msg.String() {
|
|
case "ctrl+f", "esc":
|
|
m.filterOpen = false
|
|
m.filterInput.Blur()
|
|
m.filterInput.SetValue("")
|
|
m.input.Focus()
|
|
m.updateListItems(m.lastResults)
|
|
return m, nil
|
|
case "enter":
|
|
m.filterOpen = false
|
|
m.filterInput.Blur()
|
|
m.input.Focus()
|
|
m.updateListItems(m.lastResults)
|
|
return m, nil
|
|
}
|
|
var cmd tea.Cmd
|
|
m.filterInput, cmd = m.filterInput.Update(msg)
|
|
m.updateListItems(m.lastResults)
|
|
return m, cmd
|
|
}
|
|
|
|
// --- Normal mode ---
|
|
switch msg.String() {
|
|
case "ctrl+c", "esc":
|
|
return m, tea.Quit
|
|
|
|
case "ctrl+f":
|
|
m.filterOpen = true
|
|
m.input.Blur()
|
|
m.filterInput.Focus()
|
|
return m, nil
|
|
|
|
case "enter":
|
|
if item, ok := m.list.SelectedItem().(hostItem); ok {
|
|
m.selected = &SelectedHost{Name: item.name, IP: item.ip, Kind: item.kind}
|
|
return m, tea.Quit
|
|
}
|
|
|
|
case "ctrl+e":
|
|
if item, ok := m.list.SelectedItem().(hostItem); ok {
|
|
m.openEdit(item)
|
|
return m, nil
|
|
}
|
|
|
|
case "ctrl+n", "ctrl+b":
|
|
if item, ok := m.list.SelectedItem().(hostItem); ok && m.noteService != nil {
|
|
m.noteMode = true
|
|
m.noteLoaded = false
|
|
m.noteBusy = true
|
|
m.noteRemote = msg.String() == "ctrl+b"
|
|
m.input.Blur()
|
|
m.noteInput.Focus()
|
|
m.noteInput.SetValue("")
|
|
m.noteErr = nil
|
|
host := netbox.HostEntry{ID: item.id, Name: item.name, Kind: item.kind}
|
|
m.note = notes.Note{Host: host}
|
|
remote, service := m.noteRemote, m.noteService
|
|
return m, func() tea.Msg {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
|
defer cancel()
|
|
note, err := service.Read(ctx, host, remote)
|
|
return noteResultMsg{note: note, err: err}
|
|
}
|
|
}
|
|
return m, nil
|
|
case "tab":
|
|
if m.list.Items() != nil && len(m.list.Items()) > 0 {
|
|
if item, ok := m.list.Items()[0].(hostItem); ok {
|
|
m.input.SetValue(item.name)
|
|
m.input.CursorEnd()
|
|
m.seq++
|
|
return m, m.startDebounce()
|
|
}
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
case debounceMsg:
|
|
q := m.input.Value()
|
|
if msg.seq != m.seq || msg.query != q {
|
|
return m, nil
|
|
}
|
|
m.lastSent = q
|
|
m.recentMode = false
|
|
m.loading = true
|
|
seq := m.seq
|
|
return m, m.doSearch(q, seq)
|
|
|
|
case searchResultMsg:
|
|
if msg.recent {
|
|
if m.input.Value() != "" || m.seq > 0 {
|
|
return m, nil
|
|
}
|
|
m.recentMode = true
|
|
m.lastResults = msg.entries
|
|
m.updateListItems(msg.entries)
|
|
return m, nil
|
|
}
|
|
if msg.seq != m.seq || msg.query != m.input.Value() {
|
|
return m, nil // discard stale result
|
|
}
|
|
m.loading = false
|
|
m.err = msg.err
|
|
m.lastResults = msg.entries
|
|
m.updateListItems(msg.entries)
|
|
return m, nil
|
|
}
|
|
|
|
if m.noteMode {
|
|
if m.noteBusy {
|
|
return m, nil
|
|
}
|
|
var cmd tea.Cmd
|
|
m.noteInput, cmd = m.noteInput.Update(msg)
|
|
return m, cmd
|
|
}
|
|
// Paste and cursor events are separate from key presses in Bubble Tea v2.
|
|
// Route them to the focused editor as well.
|
|
if m.editMode {
|
|
var cmd tea.Cmd
|
|
m.editInput, cmd = m.editInput.Update(msg)
|
|
return m, cmd
|
|
}
|
|
if m.filterOpen {
|
|
var cmd tea.Cmd
|
|
m.filterInput, cmd = m.filterInput.Update(msg)
|
|
m.updateListItems(m.lastResults)
|
|
return m, cmd
|
|
}
|
|
// Forward to search input and restart debounce timer.
|
|
var cmds []tea.Cmd
|
|
var inputCmd tea.Cmd
|
|
oldQuery := m.input.Value()
|
|
m.input, inputCmd = m.input.Update(msg)
|
|
cmds = append(cmds, inputCmd)
|
|
if m.input.Value() != oldQuery {
|
|
m.seq++
|
|
cmds = append(cmds, m.startDebounce())
|
|
}
|
|
|
|
var listCmd tea.Cmd
|
|
m.list, listCmd = m.list.Update(msg)
|
|
cmds = append(cmds, listCmd)
|
|
|
|
return m, tea.Batch(cmds...)
|
|
}
|
|
|
|
func (m *Model) View() tea.View {
|
|
view := tea.NewView(m.viewContent())
|
|
view.AltScreen = true
|
|
return view
|
|
}
|
|
|
|
func (m *Model) viewContent() string {
|
|
if m.noteMode {
|
|
target := "local"
|
|
if m.noteRemote {
|
|
target = "NetBox comments"
|
|
}
|
|
status := "ctrl+s save esc cancel"
|
|
if m.noteBusy {
|
|
status = "Loading / saving…"
|
|
}
|
|
errText := ""
|
|
if m.noteErr != nil {
|
|
errText = "\nError: " + m.noteErr.Error()
|
|
}
|
|
return fmt.Sprintf("Notes [%s] — %s\n\n%s\n%s%s\n", target, m.note.Host.Name, m.noteInput.View(), status, errText)
|
|
}
|
|
var sb strings.Builder
|
|
|
|
title := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("86")).Render("netssh")
|
|
sb.WriteString(title + "\n\n")
|
|
sb.WriteString(m.input.View() + "\n")
|
|
|
|
if m.filterOpen {
|
|
sb.WriteString(m.filterInput.View() + "\n")
|
|
}
|
|
|
|
if m.loading {
|
|
sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render(" searching…") + "\n")
|
|
} else {
|
|
if m.err != nil {
|
|
warning := " warning: " + strings.ReplaceAll(m.err.Error(), "\n", "; ")
|
|
style := lipgloss.NewStyle().Foreground(lipgloss.Color("9"))
|
|
if m.width > 0 {
|
|
style = style.MaxWidth(m.width)
|
|
}
|
|
sb.WriteString(style.Render(warning) + "\n")
|
|
}
|
|
if m.recentMode && m.input.Value() == "" {
|
|
if len(m.list.Items()) > 0 {
|
|
sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render(" Recent connections") + "\n")
|
|
}
|
|
}
|
|
sb.WriteString(m.list.View())
|
|
}
|
|
|
|
if m.editMode {
|
|
sb.WriteString("\n")
|
|
sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render("Connect as: "))
|
|
sb.WriteString(m.editInput.View() + "\n")
|
|
sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render(" enter connect esc cancel") + "\n")
|
|
} else {
|
|
hint := "ctrl+c quit enter connect ctrl+e edit ctrl+f filter ctrl+n notes ctrl+b NetBox"
|
|
sb.WriteString("\n" + lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render(hint) + "\n")
|
|
}
|
|
|
|
return sb.String()
|
|
}
|
|
|
|
// Selected returns the host chosen by the user, or nil if none was selected.
|
|
func (m *Model) Selected() *SelectedHost {
|
|
return m.selected
|
|
}
|
|
|
|
func (m *Model) startDebounce() tea.Cmd {
|
|
query, seq := m.input.Value(), m.seq
|
|
return tea.Tick(300*time.Millisecond, func(_ time.Time) tea.Msg {
|
|
return debounceMsg{query: query, seq: seq}
|
|
})
|
|
}
|
|
|
|
func (m *Model) doSearch(query string, seq int) tea.Cmd {
|
|
opts := m.netboxSearchOpts()
|
|
return func() tea.Msg {
|
|
var cached []netbox.HostEntry
|
|
if m.cache != nil {
|
|
for _, entry := range m.cache.Search(query) {
|
|
source, name := netbox.SplitTarget(entry.Name)
|
|
cached = append(cached, netbox.HostEntry{Source: source, Name: name, PrimaryIP4: entry.IP, Kind: entry.Kind, Tags: entry.Tags})
|
|
}
|
|
}
|
|
if m.client == nil || (len(cached) > 0 && !m.client.MultipleSources()) {
|
|
return searchResultMsg{query: query, seq: seq, entries: cached}
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
entries, err := m.client.Search(ctx, query, opts)
|
|
// A cached hit in one instance must not hide live hosts in other instances.
|
|
seen := make(map[string]bool)
|
|
for _, e := range entries {
|
|
seen[e.Target()+"\x00"+e.Kind] = true
|
|
}
|
|
for _, e := range cached {
|
|
if !seen[e.Target()+"\x00"+e.Kind] {
|
|
entries = append(entries, e)
|
|
}
|
|
}
|
|
sort.SliceStable(entries, func(i, j int) bool { return entries[i].Target() < entries[j].Target() })
|
|
return searchResultMsg{query: query, seq: seq, entries: entries, err: err}
|
|
}
|
|
}
|
|
|
|
// netboxSearchOpts derives SearchOptions from the current filter input.
|
|
func (m *Model) netboxSearchOpts() netbox.SearchOptions {
|
|
f := parseFilter(m.filterInput.Value())
|
|
var tag string
|
|
if len(f.tags) > 0 {
|
|
tag = f.tags[0]
|
|
}
|
|
return netbox.SearchOptions{Tag: tag, Kind: f.kind}
|
|
}
|
|
|
|
// updateListItems applies the active filter and sets the list items.
|
|
func (m *Model) updateListItems(entries []netbox.HostEntry) {
|
|
f := parseFilter(m.filterInput.Value())
|
|
filtered := applyFilter(entries, f)
|
|
items := make([]list.Item, len(filtered))
|
|
for i, e := range filtered {
|
|
ip := e.PrimaryIP4
|
|
if ip == "" {
|
|
ip = e.PrimaryIP6
|
|
}
|
|
items[i] = hostItem{id: e.ID, name: e.Target(), ip: ip, kind: e.Kind, tags: e.Tags}
|
|
}
|
|
m.list.SetItems(items)
|
|
m.resizeList()
|
|
}
|
|
|
|
// resizeList keeps the footer visible when the recent heading or editors change.
|
|
func (m *Model) resizeList() {
|
|
if m.width <= 0 || m.height <= 0 {
|
|
return
|
|
}
|
|
extraRows := 5 // title, blank line, search, footer separator, footer
|
|
if m.err != nil {
|
|
extraRows++
|
|
}
|
|
if m.recentMode && m.input.Value() == "" && len(m.list.Items()) > 0 {
|
|
extraRows++
|
|
}
|
|
if m.filterOpen {
|
|
extraRows++
|
|
}
|
|
if m.editMode {
|
|
extraRows++
|
|
}
|
|
height := max(1, m.height-extraRows)
|
|
if m.list.Width() != m.width || m.list.Height() != height {
|
|
m.list.SetSize(m.width, height)
|
|
}
|
|
}
|
|
|
|
// openEdit switches to edit mode for the given list item.
|
|
func (m *Model) openEdit(item hostItem) {
|
|
m.editMode = true
|
|
m.editHost = &item
|
|
m.input.Blur()
|
|
|
|
user := m.defaultUser
|
|
prefix := ""
|
|
if user != "" {
|
|
prefix = user + "@"
|
|
}
|
|
m.editInput.SetValue(fmt.Sprintf("%s%s:%d", prefix, item.name, m.defaultPort))
|
|
m.editInput.CursorEnd()
|
|
m.editInput.Focus()
|
|
}
|
|
|
|
// buildSelected parses the editInput to extract user/port overrides.
|
|
func (m *Model) buildSelected(item hostItem) *SelectedHost {
|
|
s := strings.TrimSpace(m.editInput.Value())
|
|
sel := &SelectedHost{Name: item.name, IP: item.ip, Kind: item.kind}
|
|
|
|
// Extract user: everything before @
|
|
if idx := strings.Index(s, "@"); idx != -1 {
|
|
sel.User = strings.TrimSpace(s[:idx])
|
|
s = s[idx+1:]
|
|
}
|
|
|
|
// Extract port: after the last colon, if it's a number
|
|
if idx := strings.LastIndex(s, ":"); idx != -1 {
|
|
portStr := strings.TrimSpace(s[idx+1:])
|
|
if port, err := strconv.Atoi(portStr); err == nil && port > 0 && port <= 65535 {
|
|
sel.Port = portStr
|
|
}
|
|
}
|
|
|
|
return sel
|
|
}
|
|
|
|
func (m *Model) SetNotes(service *notes.Service) { m.noteService = service }
|