package picker import ( "fmt" "strings" "time" "github.com/charmbracelet/lipgloss" "gitea.apointless.space/bsncubed/dial/internal/sshconf" "gitea.apointless.space/bsncubed/dial/internal/store" ) func (m *model) View() string { if m.quitting { return "" } // renderList must run first: it computes the scroll window and sets // winFirst/winLast, which the header's "showing" hint reads. list := m.renderList() var b strings.Builder b.WriteString(m.renderHeader()) b.WriteString("\n\n") b.WriteString(list) b.WriteString("\n") b.WriteString(m.renderPreview()) b.WriteString("\n") b.WriteString(m.renderFooter()) return b.String() } func (m *model) renderHeader() string { th := m.th sortName := "recently used" switch m.sortM { case store.SortFrequent: sortName = "most used" case store.SortAlpha: sortName = "name" } left := th.Title.Render("dial") + th.Meta.Render( fmt.Sprintf(" %d hosts · sorted by %s", len(m.rows), sortName)) if m.grouped { left += th.Meta.Render(" · grouped") } // Orientation hint when the list is scrolled/windowed. if m.winFirst >= 0 && (m.winFirst > 0 || m.winLast < len(m.view)-1) { left += th.Meta.Render(fmt.Sprintf(" · showing %d–%d of %d", m.winFirst+1, m.winLast+1, len(m.view))) } if m.status != "" { left += " " + th.Star.Render(m.status) } return m.truncate(" " + left) } // renderList builds the whole scrollable body as lines (section headers, host // blocks, and spacers), then windows those lines so the cursor's host — and its // section header, if any — stays visible. Line-based windowing lets grouped and // flat modes share one code path and keeps the preview/footer from being pushed // off-screen regardless of how many headers are on screen. func (m *model) renderList() string { th := m.th m.winFirst, m.winLast = -1, -1 if len(m.view) == 0 { msg := "no hosts in ~/.ssh/config" if m.query != "" { msg = "no hosts match \"" + m.query + "\"" } return " " + th.Meta.Render(msg) } var lines []string hostLine := make([]int, len(m.view)) // index of a host's first content line headerLine := make([]int, len(m.view)) // index of the header line before it, or -1 for i := range m.view { headerLine[i] = -1 if m.grouped { if h, ok := m.headers[i]; ok { if len(lines) > 0 { lines = append(lines, "") // gap before a new section } headerLine[i] = len(lines) lines = append(lines, m.truncate(m.renderSectionHeader(h)), "") } } hostLine[i] = len(lines) l1, l2 := m.renderHostLines(i) lines = append(lines, m.truncate(l1), m.truncate(l2), "") } // Scroll so the cursor block (plus its header, if any) stays visible: start // from the previous offset and nudge up or down only as needed. budget := m.visibleListLines() top := m.top blockTop := hostLine[m.cursor] if headerLine[m.cursor] >= 0 { blockTop = headerLine[m.cursor] } if blockTop < top { top = blockTop // scroll up to reveal the cursor (and its header) } blockBottom := hostLine[m.cursor] + 1 // the detail line if blockBottom >= top+budget { top = blockBottom - budget + 1 // scroll down to reveal the cursor } if maxTop := len(lines) - budget; top > maxTop { top = maxTop } if top < 0 { top = 0 } m.top = top end := top + budget if end > len(lines) { end = len(lines) } for i := range m.view { if hostLine[i] >= top && hostLine[i] < end { if m.winFirst == -1 { m.winFirst = i } m.winLast = i } } return strings.Join(lines[top:end], "\n") } // renderHostLines returns the two rendered lines for the host at view position i. func (m *model) renderHostLines(i int) (string, string) { th := m.th r := m.rows[m.view[i]] selected := i == m.cursor marker := " " if selected { marker = th.Cursor.Render("▸ ") } numStr := th.Number.Render(fmt.Sprintf("%2d", i+1)) if selected { numStr = th.NumberSel.Render(fmt.Sprintf("%2d", i+1)) } star := " " if r.fav { star = th.Star.Render("★ ") } alias := th.Alias.Render(r.host.Alias) if selected { alias = th.AliasSel.Render(r.host.Alias) } line1 := fmt.Sprintf("%s%s %s%s", marker, numStr, star, alias) if len(r.host.Tags) > 0 { line1 += " " + m.renderPills(r.host.Tags) } dstyle := th.Detail if selected { dstyle = th.DetailSel } return line1, " " + dstyle.Render(summary(r.host)) } // renderPills renders each tag in its own deterministic colour. func (m *model) renderPills(tags []string) string { parts := make([]string, len(tags)) for i, t := range tags { parts[i] = m.th.TagStyle(t).Render(t) } return strings.Join(parts, " ") } // renderSectionHeader renders a grouped-mode section label, coloured to match // the tag's pill (neutral for the Favourites/untagged sections). func (m *model) renderSectionHeader(h sectionHead) string { return " " + m.th.TagStyle(h.tag).Bold(true).Render("▊ "+h.title) } // summary builds a compact "user@hostname:port (via jump)" line for the list. func summary(h sshconf.Host) string { target := h.HostName if target == "" { target = h.Alias } s := target if h.User != "" { s = h.User + "@" + target } if h.Port != "" && h.Port != "22" { s += ":" + h.Port } if h.ProxyJump != "" { s += " via " + h.ProxyJump } return s } func (m *model) renderPreview() string { th := m.th if m.cursor < 0 || m.cursor >= len(m.view) { return th.Preview.Render(th.Meta.Render("no selection")) } r := m.rows[m.view[m.cursor]] h := r.host rowsKV := [][2]string{ {"Host", h.Alias}, {"HostName", orDash(h.HostName)}, {"User", orDash(h.User)}, {"Port", orDash(defaultPort(h.Port))}, {"ProxyJump", orDash(h.ProxyJump)}, {"Last used", humanizeSince(r.last)}, } var lines []string for _, kv := range rowsKV { lines = append(lines, fmt.Sprintf("%s %s", th.PreviewK.Render(pad(kv[0], 10)), th.PreviewV.Render(kv[1]))) } box := th.Preview.Width(m.previewWidth()).Render(strings.Join(lines, "\n")) return box } func (m *model) previewWidth() int { // Subtract the box border so the whole box fits the terminal. w := m.width - 6 if w > 100 { w = 100 // don't stretch the box uncomfortably wide } if w < 10 { w = 10 } return w } func (m *model) renderFooter() string { th := m.th if m.filtering { bar := th.FilterBar.Render("/ " + m.query + "▌") hint := th.Help.Render(" type to filter · enter connect · esc cancel") return m.truncate(" " + bar + hint) } hints := []string{ "↑↓ move", "enter connect", "/ search", "s sort", "g group", "f fav", "q quit", } return m.truncate(" " + th.Help.Render(strings.Join(hints, " · "))) } // truncate clamps a rendered line to the terminal width to avoid wrapping, // which would break the fixed 2-lines-per-row layout. func (m *model) truncate(s string) string { if m.width <= 0 { return s } return lipgloss.NewStyle().MaxWidth(m.width).Render(s) } // humanizeSince renders a connection time as a compact relative phrase for the // preview pane. A zero time means the host has never been connected to. func humanizeSince(t time.Time) string { if t.IsZero() { return "never" } d := time.Since(t) switch { case d < 0: return "just now" case d < time.Minute: return "just now" case d < time.Hour: return plural(int(d.Minutes()), "minute") case d < 24*time.Hour: return plural(int(d.Hours()), "hour") case d < 30*24*time.Hour: return plural(int(d.Hours()/24), "day") case d < 365*24*time.Hour: return plural(int(d.Hours()/(24*30)), "month") default: return plural(int(d.Hours()/(24*365)), "year") } } func plural(n int, unit string) string { if n <= 1 { return fmt.Sprintf("%d %s ago", n, unit) } return fmt.Sprintf("%d %ss ago", n, unit) } func orDash(s string) string { if s == "" { return "—" } return s } func defaultPort(p string) string { if p == "" { return "22" } return p } func pad(s string, n int) string { if len(s) >= n { return s } return s + strings.Repeat(" ", n-len(s)) }