feat: unify tui manager screens
This commit is contained in:
parent
d8ae2d4236
commit
18d7a7c07a
|
|
@ -1213,8 +1213,7 @@ func (m *tuiModel) View() string {
|
||||||
b.WriteString(m.viewServerList())
|
b.WriteString(m.viewServerList())
|
||||||
|
|
||||||
case screenSearch:
|
case screenSearch:
|
||||||
b.WriteString("Search: " + m.searchInput.View() + "\n")
|
b.WriteString(m.viewSearch())
|
||||||
b.WriteString(renderHelp([]helpItem{{Key: "Type", Action: "search"}, {Key: "Enter", Action: "confirm"}, {Key: "Esc", Action: "cancel"}}, m.width))
|
|
||||||
|
|
||||||
case screenForm:
|
case screenForm:
|
||||||
b.WriteString(m.form.View())
|
b.WriteString(m.form.View())
|
||||||
|
|
@ -1274,13 +1273,6 @@ func (m *tuiModel) View() string {
|
||||||
b.WriteString(m.viewConfirm())
|
b.WriteString(m.viewConfirm())
|
||||||
}
|
}
|
||||||
|
|
||||||
if m.screen != screenList && m.err != nil {
|
|
||||||
b.WriteString("\n" + errorStyle.Render(fmt.Sprintf("Error: %v", m.err)))
|
|
||||||
}
|
|
||||||
if m.screen != screenList && m.success != "" {
|
|
||||||
b.WriteString("\n" + successStyle.Render(m.success))
|
|
||||||
}
|
|
||||||
|
|
||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1771,6 +1763,36 @@ func (m *tuiModel) viewServerList() string {
|
||||||
return m.renderServerDashboard()
|
return m.renderServerDashboard()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *tuiModel) rootNotification() string {
|
||||||
|
if m.err != nil {
|
||||||
|
return errorStyle.Render("Error: " + m.err.Error())
|
||||||
|
}
|
||||||
|
if m.success != "" {
|
||||||
|
return successStyle.Render(m.success)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *tuiModel) viewSearch() string {
|
||||||
|
return renderScreenShell(screenShell{
|
||||||
|
breadcrumb: "Search",
|
||||||
|
status: shellStatus(m.vaultUnlocked, fmt.Sprintf("%d profiles", len(m.servers))),
|
||||||
|
notification: m.rootNotification(),
|
||||||
|
width: m.width,
|
||||||
|
height: m.height,
|
||||||
|
body: func(width, height int) string {
|
||||||
|
return renderPaddedPanel(width, height, []string{
|
||||||
|
dashboardSection("Find server"),
|
||||||
|
"",
|
||||||
|
m.searchInput.View(),
|
||||||
|
"",
|
||||||
|
dashboardHelp("Search alias, host, display name, group, tags, notes, and route."),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
footer: []helpItem{{Key: "Type", Action: "search"}, {Key: "Enter", Action: "confirm"}, {Key: "Ctrl+H", Action: "help"}, {Key: "Esc", Action: "cancel"}},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (m *tuiModel) viewInlineBackgroundResults() string {
|
func (m *tuiModel) viewInlineBackgroundResults() string {
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
b.WriteString(sectionStyle.Render("Last Background Run"))
|
b.WriteString(sectionStyle.Render("Last Background Run"))
|
||||||
|
|
@ -1891,37 +1913,34 @@ func (m *tuiModel) viewSelectedServer(server *model.Server) string {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *tuiModel) viewTags() string {
|
func (m *tuiModel) viewTags() string {
|
||||||
var b strings.Builder
|
return renderScreenShell(screenShell{
|
||||||
b.WriteString(titleStyle.Render("Tags"))
|
breadcrumb: "Tags",
|
||||||
b.WriteString("\n\n")
|
status: shellStatus(m.vaultUnlocked, fmt.Sprintf("%d tags", len(m.tags))),
|
||||||
if len(m.tags) == 0 {
|
notification: m.rootNotification(),
|
||||||
b.WriteString(helpStyle.Render(" No tags yet. Press Ctrl+A to add one to the selected servers."))
|
width: m.width,
|
||||||
b.WriteString("\n")
|
height: m.height,
|
||||||
} else {
|
body: func(width, height int) string {
|
||||||
for i, item := range m.tagList.Items() {
|
if len(m.tags) == 0 {
|
||||||
tag, ok := item.(groupItem)
|
return renderPaddedPanel(width, height, []string{dashboardHelp("No tags yet. Ctrl+A adds one to the selected servers.")})
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
marker := " "
|
capacity := max(1, height-2)
|
||||||
style := normalStyle
|
start, end := visibleServerRange(len(m.tagList.Items()), m.tagList.Index(), capacity)
|
||||||
if i == m.tagList.Index() {
|
lines := make([]string, 0, capacity)
|
||||||
marker = "> "
|
for index := start; index < end; index++ {
|
||||||
style = selectedRowStyle
|
tag, ok := m.tagList.Items()[index].(groupItem)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
marker := " "
|
||||||
|
if index == m.tagList.Index() {
|
||||||
|
marker = "> "
|
||||||
|
}
|
||||||
|
lines = append(lines, marker+tag.name)
|
||||||
}
|
}
|
||||||
b.WriteString(style.Render(marker + tag.name))
|
return renderPaddedPanel(width, height, lines)
|
||||||
b.WriteString("\n")
|
},
|
||||||
}
|
footer: []helpItem{{Key: "Enter", Action: "toggle"}, {Key: "Ctrl+A", Action: "add"}, {Key: "Ctrl+E", Action: "rename"}, {Key: "Ctrl+D", Action: "delete"}, {Key: "Ctrl+H", Action: "help"}, {Key: "Esc", Action: "back"}},
|
||||||
}
|
})
|
||||||
b.WriteString("\n")
|
|
||||||
b.WriteString(renderHelp([]helpItem{
|
|
||||||
{Key: "Enter", Action: "toggle for selected/current"},
|
|
||||||
{Key: "Ctrl+A", Action: "add"},
|
|
||||||
{Key: "Ctrl+E", Action: "rename"},
|
|
||||||
{Key: "Ctrl+D", Action: "delete"},
|
|
||||||
{Key: "Esc", Action: "back"},
|
|
||||||
}, m.width))
|
|
||||||
return b.String()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *tuiModel) viewTagInput() string {
|
func (m *tuiModel) viewTagInput() string {
|
||||||
|
|
@ -1929,74 +1948,82 @@ func (m *tuiModel) viewTagInput() string {
|
||||||
if m.tagMode == "rename" {
|
if m.tagMode == "rename" {
|
||||||
title = "Rename Tag"
|
title = "Rename Tag"
|
||||||
}
|
}
|
||||||
return titleStyle.Render(title) + "\n\n" + m.tagInput.View() + "\n\n" + renderHelp([]helpItem{{Key: "Enter", Action: "save"}, {Key: "Esc", Action: "cancel"}}, m.width)
|
return renderScreenShell(screenShell{
|
||||||
|
breadcrumb: title,
|
||||||
|
status: shellStatus(m.vaultUnlocked, "Tag editor"),
|
||||||
|
width: m.width,
|
||||||
|
height: m.height,
|
||||||
|
body: func(width, height int) string {
|
||||||
|
return renderPaddedPanel(width, height, []string{dashboardSection(title), "", m.tagInput.View()})
|
||||||
|
},
|
||||||
|
footer: []helpItem{{Key: "Enter", Action: "save"}, {Key: "Ctrl+H", Action: "help"}, {Key: "Esc", Action: "cancel"}},
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *tuiModel) viewTemplates() string {
|
func (m *tuiModel) viewTemplates() string {
|
||||||
var b strings.Builder
|
return renderScreenShell(screenShell{
|
||||||
b.WriteString(titleStyle.Render("Command Templates"))
|
breadcrumb: "Command Templates",
|
||||||
b.WriteString("\n\n")
|
status: shellStatus(m.vaultUnlocked, fmt.Sprintf("%d templates", len(m.templates))),
|
||||||
if len(m.templates) == 0 {
|
notification: m.rootNotification(),
|
||||||
b.WriteString(helpStyle.Render(" No command templates yet. Press Ctrl+A to add one."))
|
width: m.width,
|
||||||
b.WriteString("\n")
|
height: m.height,
|
||||||
} else {
|
body: func(width, height int) string {
|
||||||
for i, item := range m.templateList.Items() {
|
if len(m.templates) == 0 {
|
||||||
tpl, ok := item.(templateItem)
|
return renderPaddedPanel(width, height, []string{dashboardHelp("No command templates yet. Ctrl+A adds one.")})
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
marker := " "
|
capacity := max(1, height-2)
|
||||||
style := normalStyle
|
start, end := visibleServerRange(len(m.templateList.Items()), m.templateList.Index(), capacity)
|
||||||
if i == m.templateList.Index() {
|
lines := make([]string, 0, capacity)
|
||||||
marker = "> "
|
for index := start; index < end; index++ {
|
||||||
style = selectedRowStyle
|
tpl, ok := m.templateList.Items()[index].(templateItem)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
marker := " "
|
||||||
|
if index == m.templateList.Index() {
|
||||||
|
marker = "> "
|
||||||
|
}
|
||||||
|
lines = append(lines, marker+tpl.template.Name+" "+tpl.template.Command)
|
||||||
|
if tpl.template.Description != "" && classifyTerminal(width, height) != sizeNarrow {
|
||||||
|
lines = append(lines, " "+dashboardHelp(tpl.template.Description))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
line := fmt.Sprintf("%s%-24s %s", marker, truncate(tpl.template.Name, 24), tpl.template.Command)
|
return renderPaddedPanel(width, height, lines)
|
||||||
b.WriteString(style.Render(line))
|
},
|
||||||
b.WriteString("\n")
|
footer: []helpItem{{Key: "Ctrl+A", Action: "add"}, {Key: "Ctrl+E", Action: "edit"}, {Key: "Ctrl+D", Action: "delete"}, {Key: "Ctrl+H", Action: "help"}, {Key: "Esc", Action: "back"}},
|
||||||
if tpl.template.Description != "" {
|
})
|
||||||
b.WriteString(helpStyle.Render(" " + tpl.template.Description))
|
|
||||||
b.WriteString("\n")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
b.WriteString("\n")
|
|
||||||
b.WriteString(renderHelp([]helpItem{
|
|
||||||
{Key: "Ctrl+A", Action: "add"},
|
|
||||||
{Key: "Ctrl+E", Action: "edit"},
|
|
||||||
{Key: "Ctrl+D", Action: "delete"},
|
|
||||||
{Key: "Esc", Action: "back"},
|
|
||||||
}, m.width))
|
|
||||||
return b.String()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *tuiModel) viewTemplatePicker() string {
|
func (m *tuiModel) viewTemplatePicker() string {
|
||||||
var b strings.Builder
|
targets := strings.Join(serverAliases(m.targetServers()), ", ")
|
||||||
b.WriteString(titleStyle.Render("Run Template"))
|
return renderScreenShell(screenShell{
|
||||||
b.WriteString("\n")
|
breadcrumb: "Run Template",
|
||||||
b.WriteString(helpStyle.Render(fmt.Sprintf("Targets: %s", strings.Join(serverAliases(m.targetServers()), ", "))))
|
status: "Targets: " + targets,
|
||||||
b.WriteString("\n\n")
|
width: m.width,
|
||||||
if len(m.templates) == 0 {
|
height: m.height,
|
||||||
b.WriteString(helpStyle.Render(" No command templates. Press Esc, then Ctrl+P to add one."))
|
body: func(width, height int) string {
|
||||||
} else {
|
lines := []string{dashboardSection("Choose template"), dashboardHelp("Targets: " + targets), ""}
|
||||||
for i, item := range m.templateList.Items() {
|
if len(m.templates) == 0 {
|
||||||
tpl, ok := item.(templateItem)
|
lines = append(lines, dashboardHelp("No command templates. Press Esc, then Ctrl+P to add one."))
|
||||||
if !ok {
|
} else {
|
||||||
continue
|
capacity := max(1, height-len(lines)-2)
|
||||||
|
start, end := visibleServerRange(len(m.templateList.Items()), m.templateList.Index(), capacity)
|
||||||
|
for index := start; index < end; index++ {
|
||||||
|
tpl, ok := m.templateList.Items()[index].(templateItem)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
marker := " "
|
||||||
|
if index == m.templateList.Index() {
|
||||||
|
marker = "> "
|
||||||
|
}
|
||||||
|
lines = append(lines, marker+tpl.template.Name+" "+tpl.template.Command)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
marker := " "
|
return renderPaddedPanel(width, height, lines)
|
||||||
style := normalStyle
|
},
|
||||||
if i == m.templateList.Index() {
|
footer: []helpItem{{Key: "Enter", Action: "choose"}, {Key: "Ctrl+H", Action: "help"}, {Key: "Esc", Action: "back"}},
|
||||||
marker = "> "
|
})
|
||||||
style = selectedRowStyle
|
|
||||||
}
|
|
||||||
b.WriteString(style.Render(fmt.Sprintf("%s%-24s %s", marker, truncate(tpl.template.Name, 24), tpl.template.Command)))
|
|
||||||
b.WriteString("\n")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
b.WriteString("\n")
|
|
||||||
b.WriteString(renderHelp([]helpItem{{Key: "Enter", Action: "choose"}, {Key: "Esc", Action: "back"}}, m.width))
|
|
||||||
return b.String()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *tuiModel) viewTemplateMode() string {
|
func (m *tuiModel) viewTemplateMode() string {
|
||||||
|
|
@ -2006,30 +2033,44 @@ func (m *tuiModel) viewTemplateMode() string {
|
||||||
name = m.pendingTemplate.Name
|
name = m.pendingTemplate.Name
|
||||||
command = m.pendingTemplate.Command
|
command = m.pendingTemplate.Command
|
||||||
}
|
}
|
||||||
return titleStyle.Render("Run Mode") + "\n\n" +
|
targets := strings.Join(serverAliases(m.targetServers()), ", ")
|
||||||
fmt.Sprintf("Template: %s\nCommand: %s\nTargets: %s\n\n", name, command, strings.Join(serverAliases(m.targetServers()), ", ")) +
|
return renderScreenShell(screenShell{
|
||||||
renderHelp([]helpItem{{Key: "Ctrl+F (Enter)", Action: "Foreground"}, {Key: "Ctrl+B", Action: "Background"}, {Key: "Esc", Action: "back"}}, m.width)
|
breadcrumb: "Run Template / Mode",
|
||||||
|
status: "Targets: " + targets,
|
||||||
|
width: m.width,
|
||||||
|
height: m.height,
|
||||||
|
body: func(width, height int) string {
|
||||||
|
return renderPaddedPanel(width, height, []string{dashboardSection("Execution"), "", "Template: " + name, "Command: " + command, "Targets: " + targets, "", "Choose foreground for an interactive run or background to keep using sshkeeper."})
|
||||||
|
},
|
||||||
|
footer: []helpItem{{Key: "Ctrl+F (Enter)", Action: "Foreground"}, {Key: "Ctrl+B", Action: "Background"}, {Key: "Ctrl+H", Action: "help"}, {Key: "Esc", Action: "back"}},
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *tuiModel) viewBackgroundResults() string {
|
func (m *tuiModel) viewBackgroundResults() string {
|
||||||
var b strings.Builder
|
return renderScreenShell(screenShell{
|
||||||
b.WriteString(titleStyle.Render("Background Results"))
|
breadcrumb: "Background Results",
|
||||||
b.WriteString("\n\n")
|
status: fmt.Sprintf("%d results", len(m.bgResults)),
|
||||||
for _, result := range m.bgResults {
|
width: m.width,
|
||||||
status := "OK"
|
height: m.height,
|
||||||
if result.Err != "" {
|
body: func(width, height int) string {
|
||||||
status = "FAIL: " + result.Err
|
lines := make([]string, 0)
|
||||||
}
|
for _, result := range m.bgResults {
|
||||||
b.WriteString(sectionStyle.Render(result.Alias + " " + status))
|
status := "OK"
|
||||||
b.WriteString("\n")
|
if result.Err != "" {
|
||||||
if result.Output != "" {
|
status = "FAIL: " + result.Err
|
||||||
b.WriteString(result.Output)
|
}
|
||||||
b.WriteString("\n")
|
lines = append(lines, dashboardSection(result.Alias+" "+status))
|
||||||
}
|
if result.Output != "" {
|
||||||
}
|
for _, line := range strings.Split(result.Output, "\n") {
|
||||||
b.WriteString("\n")
|
lines = append(lines, strings.ReplaceAll(line, "\t", " "))
|
||||||
b.WriteString(renderHelp([]helpItem{{Key: "Enter/Esc", Action: "back"}}, m.width))
|
}
|
||||||
return b.String()
|
}
|
||||||
|
lines = append(lines, "")
|
||||||
|
}
|
||||||
|
return renderPaddedPanel(width, height, lines)
|
||||||
|
},
|
||||||
|
footer: []helpItem{{Key: "Enter/Esc", Action: "back"}, {Key: "Ctrl+H", Action: "help"}},
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *tuiModel) selectedServers() []*model.Server {
|
func (m *tuiModel) selectedServers() []*model.Server {
|
||||||
|
|
|
||||||
|
|
@ -183,6 +183,40 @@ func TestHelpScreensUseUnifiedShell(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestManagerScreensUseUnifiedShell(t *testing.T) {
|
||||||
|
for _, size := range []struct{ width, height int }{{120, 40}, {80, 24}, {60, 16}} {
|
||||||
|
m := New([]*model.Server{{Alias: "prod", Host: "prod.example", Port: 22, User: "ops"}})
|
||||||
|
m.width, m.height = size.width, size.height
|
||||||
|
template := &model.CommandTemplate{Name: "Disk usage", Command: "df -h", Description: "Show mounted filesystems"}
|
||||||
|
m.setTemplates([]*model.CommandTemplate{template})
|
||||||
|
m.setTags([]string{"production"})
|
||||||
|
m.pendingTemplate = template
|
||||||
|
m.bgResults = []templateRunResult{{Alias: "prod", Output: "ok\n数据库 ready"}}
|
||||||
|
|
||||||
|
screens := []struct {
|
||||||
|
name string
|
||||||
|
screen screen
|
||||||
|
}{
|
||||||
|
{"search", screenSearch},
|
||||||
|
{"tags", screenTags},
|
||||||
|
{"tag-input", screenTagInput},
|
||||||
|
{"templates", screenTemplates},
|
||||||
|
{"template-picker", screenTemplatePicker},
|
||||||
|
{"template-mode", screenTemplateMode},
|
||||||
|
{"background-results", screenBackgroundResults},
|
||||||
|
}
|
||||||
|
for _, tt := range screens {
|
||||||
|
m.screen = tt.screen
|
||||||
|
t.Run(tt.name+itoa(size.width), func(t *testing.T) {
|
||||||
|
assertUnifiedScreen(t, m.View(), size.width, size.height)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
tunnelScreen := newTunnelScreenModel(size.width, size.height)
|
||||||
|
assertUnifiedScreen(t, tunnelScreen.View(), size.width, size.height)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestTemplateFormFitsSupportedTerminalSizes(t *testing.T) {
|
func TestTemplateFormFitsSupportedTerminalSizes(t *testing.T) {
|
||||||
for _, size := range []struct{ width, height int }{{120, 40}, {80, 24}, {60, 16}} {
|
for _, size := range []struct{ width, height int }{{120, 40}, {80, 24}, {60, 16}} {
|
||||||
form := newTemplateFormModel(nil, size.width, size.height)
|
form := newTemplateFormModel(nil, size.width, size.height)
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ package tui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/list"
|
"github.com/charmbracelet/bubbles/list"
|
||||||
|
|
@ -94,19 +93,41 @@ func (m *tunnelScreenModel) stopSelected() tea.Cmd {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *tunnelScreenModel) View() string {
|
func (m *tunnelScreenModel) View() string {
|
||||||
var b strings.Builder
|
notification := ""
|
||||||
b.WriteString(m.list.View())
|
|
||||||
b.WriteString("\n\n")
|
|
||||||
if m.err != nil {
|
if m.err != nil {
|
||||||
b.WriteString(errorStyle.Render(fmt.Sprintf("Error: %v", m.err)))
|
notification = errorStyle.Render(fmt.Sprintf("Error: %v", m.err))
|
||||||
b.WriteString("\n\n")
|
|
||||||
}
|
}
|
||||||
b.WriteString(renderHelp([]helpItem{
|
body := func(width, height int) string {
|
||||||
{Key: "Ctrl+D (s)", Action: "stop tunnel"},
|
if len(m.tunnels) == 0 {
|
||||||
{Key: "Ctrl+R (r)", Action: "refresh"},
|
return renderPaddedPanel(width, height, []string{dashboardHelp("No running tunnels.")})
|
||||||
{Key: "Esc", Action: "back"},
|
}
|
||||||
}, m.width))
|
capacity := max(1, height-2)
|
||||||
return b.String()
|
start, end := visibleServerRange(len(m.tunnels), m.list.Index(), max(1, capacity/2))
|
||||||
|
lines := make([]string, 0, capacity)
|
||||||
|
for index := start; index < end; index++ {
|
||||||
|
item := tunnelItem{state: m.tunnels[index]}
|
||||||
|
marker := " "
|
||||||
|
if index == m.list.Index() {
|
||||||
|
marker = "> "
|
||||||
|
}
|
||||||
|
lines = append(lines, marker+item.Title(), " "+item.Description())
|
||||||
|
}
|
||||||
|
return renderPaddedPanel(width, height, lines)
|
||||||
|
}
|
||||||
|
return renderScreenShell(screenShell{
|
||||||
|
breadcrumb: "Tunnel Manager",
|
||||||
|
status: fmt.Sprintf("%d running", len(m.tunnels)),
|
||||||
|
notification: notification,
|
||||||
|
width: m.width,
|
||||||
|
height: m.height,
|
||||||
|
body: body,
|
||||||
|
footer: []helpItem{
|
||||||
|
{Key: "Ctrl+D (s)", Action: "stop tunnel"},
|
||||||
|
{Key: "Ctrl+R (r)", Action: "refresh"},
|
||||||
|
{Key: "Ctrl+H", Action: "help"},
|
||||||
|
{Key: "Esc", Action: "back"},
|
||||||
|
},
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
type tunnelsLoadedMsg struct {
|
type tunnelsLoadedMsg struct {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue