feat: implemented the panels
This commit is contained in:
parent
ea201b4c91
commit
958fc7308a
@ -13,6 +13,7 @@ import (
|
||||
|
||||
func StartProcess(cmd model.Command, addr string, ch chan<- model.Event) (*model.Process, error) {
|
||||
ch <- model.Event{
|
||||
Time: time.Now().Local(),
|
||||
Type: model.EventTypeProcessStarting,
|
||||
Body: fmt.Sprintf("spawning process '%s'", process.CommandString(cmd)),
|
||||
}
|
||||
@ -35,6 +36,7 @@ func StopProcess(proc *model.Process, ch chan<- model.Event, sig syscall.Signal)
|
||||
}
|
||||
|
||||
ch <- model.Event{
|
||||
Time: time.Now().Local(),
|
||||
Type: model.EventTypeProcessSignaled,
|
||||
Body: fmt.Sprintf("process with pid '%d' is being killed", proc.Exec.Process.Pid),
|
||||
PID: proc.Exec.Process.Pid,
|
||||
@ -58,6 +60,7 @@ func waitForProcessExit(proc *model.Process, ch chan<- model.Event) {
|
||||
if err := proc.Exec.Wait(); err != nil {
|
||||
if exitErr, ok := errors.AsType[*exec.ExitError](err); ok {
|
||||
ch <- model.Event{
|
||||
Time: time.Now().Local(),
|
||||
Type: model.EventTypeProcessExited,
|
||||
Body: fmt.Sprintf("process pid '%d' exited", proc.Exec.Process.Pid),
|
||||
PID: proc.Exec.Process.Pid,
|
||||
@ -68,6 +71,7 @@ func waitForProcessExit(proc *model.Process, ch chan<- model.Event) {
|
||||
}
|
||||
|
||||
ch <- model.Event{
|
||||
Time: time.Now().Local(),
|
||||
Type: model.EventTypeFatal,
|
||||
Body: fmt.Sprintf("%q", err),
|
||||
}
|
||||
@ -76,6 +80,7 @@ func waitForProcessExit(proc *model.Process, ch chan<- model.Event) {
|
||||
}
|
||||
|
||||
ch <- model.Event{
|
||||
Time: time.Now().Local(),
|
||||
Type: model.EventTypeProcessExited,
|
||||
Body: fmt.Sprintf("process pid '%d' exited", proc.Exec.Process.Pid),
|
||||
PID: proc.Exec.Process.Pid,
|
||||
|
||||
@ -4,6 +4,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"termtap.dev/internal/model"
|
||||
)
|
||||
@ -14,6 +15,7 @@ func StartProxy(ps *model.ProxyServer, ch chan<- model.Event) {
|
||||
}
|
||||
|
||||
ch <- model.Event{
|
||||
Time: time.Now().Local(),
|
||||
Type: model.EventTypeProxyStarting,
|
||||
Body: fmt.Sprintf("proxy server started on %s", (*ps.Listener).Addr().String()),
|
||||
}
|
||||
@ -24,6 +26,7 @@ func StartProxy(ps *model.ProxyServer, ch chan<- model.Event) {
|
||||
}
|
||||
|
||||
ch <- model.Event{
|
||||
Time: time.Now().Local(),
|
||||
Type: model.EventTypeFatal,
|
||||
Body: fmt.Sprintf("fatal error in proxy server: %q", err),
|
||||
}
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
type EventType string
|
||||
|
||||
const (
|
||||
@ -26,6 +28,7 @@ const (
|
||||
)
|
||||
|
||||
type Event struct {
|
||||
Time time.Time
|
||||
Type EventType
|
||||
Body string
|
||||
PID int
|
||||
|
||||
@ -7,6 +7,7 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"termtap.dev/internal/model"
|
||||
)
|
||||
@ -24,6 +25,7 @@ func NewProcess(cmd model.Command, addr string, ch chan<- model.Event) *model.Pr
|
||||
stdout, err := proc.StdoutPipe()
|
||||
if err != nil {
|
||||
ch <- model.Event{
|
||||
Time: time.Now().Local(),
|
||||
Type: model.EventTypeWarn,
|
||||
Body: fmt.Sprintf("could not open stdout pipe: %q", err),
|
||||
PID: proc.Process.Pid,
|
||||
@ -35,6 +37,7 @@ func NewProcess(cmd model.Command, addr string, ch chan<- model.Event) *model.Pr
|
||||
stderr, err := proc.StderrPipe()
|
||||
if err != nil {
|
||||
ch <- model.Event{
|
||||
Time: time.Now().Local(),
|
||||
Type: model.EventTypeWarn,
|
||||
Body: fmt.Sprintf("could not open stderr pipe: %q", err),
|
||||
PID: proc.Process.Pid,
|
||||
@ -70,6 +73,7 @@ func readPipe(pipe io.Reader, t model.EventType, ch chan<- model.Event) {
|
||||
scanner := bufio.NewScanner(pipe)
|
||||
for scanner.Scan() {
|
||||
ch <- model.Event{
|
||||
Time: time.Now().Local(),
|
||||
Type: t,
|
||||
Body: scanner.Text(),
|
||||
}
|
||||
@ -100,6 +104,7 @@ func UpdateStatus(proc *model.Process, running bool, ch chan<- model.Event) {
|
||||
}
|
||||
|
||||
ch <- model.Event{
|
||||
Time: time.Now().Local(),
|
||||
Type: t,
|
||||
Body: fmt.Sprintf("Set process pid '%d' status to %s", proc.Exec.Process.Pid, status),
|
||||
PID: proc.Exec.Process.Pid,
|
||||
|
||||
@ -28,6 +28,7 @@ func proxyHandler(ch chan<- model.Event) http.Handler {
|
||||
if req.Method == http.MethodConnect {
|
||||
http.Error(w, "CONNECT is not supported yet", http.StatusNotImplemented)
|
||||
ch <- model.Event{
|
||||
Time: time.Now().Local(),
|
||||
Type: model.EventTypeWarn,
|
||||
Body: fmt.Sprintf("CONNECT is not supported: %s", req.Host),
|
||||
}
|
||||
@ -37,6 +38,7 @@ func proxyHandler(ch chan<- model.Event) http.Handler {
|
||||
if req.URL.Scheme == "" || req.URL.Host == "" {
|
||||
http.Error(w, "request must use absolute-form URLs through the proxy", http.StatusBadRequest)
|
||||
ch <- model.Event{
|
||||
Time: time.Now().Local(),
|
||||
Type: model.EventTypeWarn,
|
||||
Body: fmt.Sprintf("rejected non-proxy request %s %s", req.Method, req.URL.String()),
|
||||
}
|
||||
@ -61,6 +63,7 @@ func proxyHandler(ch chan<- model.Event) http.Handler {
|
||||
requestPreview, err := readAndRestoreBody(&req.Body)
|
||||
if err != nil {
|
||||
ch <- model.Event{
|
||||
Time: time.Now().Local(),
|
||||
Type: model.EventTypeWarn,
|
||||
Body: fmt.Sprintf("(%s) failed to read request body", request.ID),
|
||||
Request: request,
|
||||
@ -81,6 +84,7 @@ func proxyHandler(ch chan<- model.Event) http.Handler {
|
||||
request.RawURL = outReq.URL.String()
|
||||
|
||||
ch <- model.Event{
|
||||
Time: time.Now().Local(),
|
||||
Type: model.EventTypeRequestStarted,
|
||||
Body: fmt.Sprintf("-> %+v", request),
|
||||
Request: request,
|
||||
@ -97,6 +101,7 @@ func proxyHandler(ch chan<- model.Event) http.Handler {
|
||||
request.Status = status
|
||||
|
||||
ch <- model.Event{
|
||||
Time: time.Now().Local(),
|
||||
Type: model.EventTypeRequestFailed,
|
||||
Body: fmt.Sprintf("upstream error for %s %s: %v", outReq.Method, outReq.URL.String(), err),
|
||||
Request: request,
|
||||
@ -108,6 +113,7 @@ func proxyHandler(ch chan<- model.Event) http.Handler {
|
||||
responsePreview, err := readAndRestoreBody(&resp.Body)
|
||||
if err != nil {
|
||||
ch <- model.Event{
|
||||
Time: time.Now().Local(),
|
||||
Type: model.EventTypeWarn,
|
||||
Body: fmt.Sprintf("(%s) failed to read response body", request.ID),
|
||||
Request: request,
|
||||
@ -125,6 +131,7 @@ func proxyHandler(ch chan<- model.Event) http.Handler {
|
||||
request.Status = resp.StatusCode
|
||||
|
||||
ch <- model.Event{
|
||||
Time: time.Now().Local(),
|
||||
Type: model.EventTypeRequestFailed,
|
||||
Body: fmt.Sprintf("write response body %s %s: %v", outReq.Method, outReq.URL.String(), err),
|
||||
}
|
||||
@ -137,6 +144,7 @@ func proxyHandler(ch chan<- model.Event) http.Handler {
|
||||
request.Pending = false
|
||||
|
||||
ch <- model.Event{
|
||||
Time: time.Now().Local(),
|
||||
Type: model.EventTypeRequestFinished,
|
||||
Body: fmt.Sprintf("<- %+v %s", request, formatHeaders(resp.Request.Header)),
|
||||
Request: request,
|
||||
|
||||
@ -35,6 +35,7 @@ func Destroy(ps *model.ProxyServer, ch chan<- model.Event) {
|
||||
if ps != nil && ps.Server != nil {
|
||||
_ = ps.Server.Shutdown(ctx)
|
||||
ch <- model.Event{
|
||||
Time: time.Now().Local(),
|
||||
Type: model.EventTypeProxyStarted,
|
||||
Body: "proxy server was destroyed",
|
||||
}
|
||||
|
||||
@ -1,6 +1,11 @@
|
||||
package tui
|
||||
|
||||
import "termtap.dev/internal/model"
|
||||
import (
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"termtap.dev/internal/model"
|
||||
)
|
||||
|
||||
type EventMsg struct {
|
||||
value model.Event
|
||||
@ -9,3 +14,15 @@ type EventMsg struct {
|
||||
type ErrMsg struct {
|
||||
err error
|
||||
}
|
||||
|
||||
type TickMsg struct {
|
||||
Now time.Time
|
||||
}
|
||||
|
||||
const tick = 20 * time.Millisecond
|
||||
|
||||
func tickCmd() tea.Cmd {
|
||||
return tea.Tick(tick, func(t time.Time) tea.Msg {
|
||||
return TickMsg{Now: t}
|
||||
})
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@ package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"termtap.dev/internal/model"
|
||||
@ -25,6 +26,8 @@ type Model struct {
|
||||
showEvents bool
|
||||
showStd bool
|
||||
showSearch bool
|
||||
|
||||
now time.Time
|
||||
}
|
||||
|
||||
func NewModel(ch <-chan model.Event) Model {
|
||||
@ -47,7 +50,7 @@ func Run(ch <-chan model.Event) error {
|
||||
}
|
||||
|
||||
func (m Model) Init() tea.Cmd {
|
||||
return waitForEvent(m.channel)
|
||||
return tea.Batch(waitForEvent(m.channel), tickCmd())
|
||||
}
|
||||
|
||||
func waitForEvent(ch <-chan model.Event) tea.Cmd {
|
||||
|
||||
@ -3,10 +3,12 @@ package tui
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"termtap.dev/internal/model"
|
||||
)
|
||||
|
||||
func (m Model) renderStatusBar(w int) string {
|
||||
// TODO: Optimize somehow
|
||||
var errCount int
|
||||
for _, req := range m.requests {
|
||||
if req.Failed || (req.Status >= 400 && req.Status < 600) {
|
||||
@ -32,28 +34,58 @@ func (m Model) renderSearchPane(w, h int) []string {
|
||||
}
|
||||
|
||||
func (m Model) renderRequestPane(w, h int) []string {
|
||||
if w < 0 {
|
||||
w = 0
|
||||
var lines []string
|
||||
|
||||
// Render header
|
||||
headerLeft := fmt.Sprintf(" %-7s %-24s %s", "METHOD", "HOST", "PATH")
|
||||
headerRight := fmt.Sprintf("%4s %8s ", "CODE", "TIME")
|
||||
headerSpace := strings.Repeat(" ", max(0, w-len(headerLeft+headerRight)))
|
||||
header := headerLeft + headerSpace + headerRight
|
||||
lines = append(lines, header)
|
||||
|
||||
for i := len(m.requests) - 1; i >= 0; i-- {
|
||||
req := m.requests[i]
|
||||
|
||||
// Some formatting magic here maybe
|
||||
left := fmt.Sprintf(
|
||||
" %-7s %-24s %s",
|
||||
strings.ToUpper(req.Method),
|
||||
req.Host,
|
||||
req.URL,
|
||||
)
|
||||
right := fmt.Sprintf(
|
||||
"%4d %8s ",
|
||||
req.Status,
|
||||
formatDuration(req.Duration),
|
||||
)
|
||||
if req.Pending && !req.StartTime.IsZero() {
|
||||
right = fmt.Sprintf(
|
||||
"%4s %8s ",
|
||||
"",
|
||||
formatDuration(time.Since(req.StartTime)),
|
||||
)
|
||||
}
|
||||
if h < 0 {
|
||||
h = 0
|
||||
space := strings.Repeat(" ", max(0, w-len(left+right)))
|
||||
|
||||
line := left + space + right
|
||||
lines = append(lines, line)
|
||||
}
|
||||
|
||||
lines := make([]string, h)
|
||||
for y := range lines {
|
||||
lines[y] = strings.Repeat(".", w)
|
||||
// Cleanup
|
||||
if len(lines) < h {
|
||||
for i := len(lines); i < h; i++ {
|
||||
lines = append(lines, strings.Repeat(" ", w))
|
||||
}
|
||||
}
|
||||
|
||||
if len(lines) > h {
|
||||
lines = lines[:h]
|
||||
}
|
||||
|
||||
return lines
|
||||
}
|
||||
|
||||
func (m Model) renderDetailsPane(w, h int) []string {
|
||||
if w < 0 {
|
||||
w = 0
|
||||
}
|
||||
if h < 0 {
|
||||
h = 0
|
||||
}
|
||||
|
||||
lines := make([]string, h)
|
||||
for y := range lines {
|
||||
lines[y] = strings.Repeat("^", w)
|
||||
@ -61,32 +93,101 @@ func (m Model) renderDetailsPane(w, h int) []string {
|
||||
return lines
|
||||
}
|
||||
|
||||
// TODO: This can be done better
|
||||
// TODO: Should h be max or defined?
|
||||
func (m Model) renderEventsPane(w, h int) []string {
|
||||
if w < 0 {
|
||||
w = 0
|
||||
// Remove the stdout or stderr logs
|
||||
var events []model.Event
|
||||
for _, ev := range m.events {
|
||||
if ev.Type != model.EventTypeProcessStderr &&
|
||||
ev.Type != model.EventTypeProcessStdout {
|
||||
events = append(events, ev)
|
||||
}
|
||||
if h < 0 {
|
||||
h = 0
|
||||
}
|
||||
|
||||
lines := make([]string, h)
|
||||
for y := range lines {
|
||||
lines[y] = strings.Repeat("~", w)
|
||||
displayCount := max(h-1, 0)
|
||||
|
||||
if displayCount < len(events) {
|
||||
events = events[len(events)-displayCount:]
|
||||
}
|
||||
|
||||
lines := []string{
|
||||
fmt.Sprintf("EVENT LOG - %d EVENTS", len(events)),
|
||||
}
|
||||
|
||||
for _, event := range events {
|
||||
line := fmt.Sprintf(
|
||||
"%s %-15s %s",
|
||||
event.Time.Format("15:04:05"),
|
||||
event.Type,
|
||||
event.Body,
|
||||
)
|
||||
if event.PID > 0 {
|
||||
line = fmt.Sprintf(
|
||||
"%s %-15s %d %s",
|
||||
event.Time.Format("15:04:05"),
|
||||
event.Type,
|
||||
event.PID,
|
||||
event.Body,
|
||||
)
|
||||
}
|
||||
lines = append(lines, truncate(line, w))
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
if len(lines) < h {
|
||||
for i := len(lines); i < h; i++ {
|
||||
lines = append(lines, "")
|
||||
}
|
||||
}
|
||||
|
||||
return lines
|
||||
}
|
||||
|
||||
// TODO: Should h be max or defined?
|
||||
func (m Model) renderStdPane(w, h int) []string {
|
||||
if w < 0 {
|
||||
w = 0
|
||||
// Only the stdout or stderr logs
|
||||
var logs []model.Event
|
||||
for _, ev := range m.events {
|
||||
if ev.Type == model.EventTypeProcessStderr ||
|
||||
ev.Type == model.EventTypeProcessStdout {
|
||||
logs = append(logs, ev)
|
||||
}
|
||||
if h < 0 {
|
||||
h = 0
|
||||
}
|
||||
|
||||
lines := make([]string, h)
|
||||
for y := range lines {
|
||||
lines[y] = strings.Repeat(" ", w)
|
||||
displayCount := max(h-1, 0)
|
||||
|
||||
if displayCount < len(logs) {
|
||||
logs = logs[len(logs)-displayCount:]
|
||||
}
|
||||
|
||||
lines := []string{
|
||||
fmt.Sprintf("STDOUT/STDERR LOG - %d LINES", len(logs)),
|
||||
}
|
||||
|
||||
for _, log := range logs {
|
||||
var t string
|
||||
if log.Type == model.EventTypeProcessStderr {
|
||||
t = "STDERR"
|
||||
}
|
||||
if log.Type == model.EventTypeProcessStdout {
|
||||
t = "STDOUT"
|
||||
}
|
||||
line := fmt.Sprintf(
|
||||
"%s %6s %s",
|
||||
log.Time.Format("15:04:05"),
|
||||
t,
|
||||
log.Body,
|
||||
)
|
||||
lines = append(lines, truncate(line, w))
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
if len(lines) < h {
|
||||
for i := len(lines); i < h; i++ {
|
||||
lines = append(lines, "")
|
||||
}
|
||||
}
|
||||
|
||||
return lines
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@ package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"termtap.dev/internal/model"
|
||||
@ -14,6 +15,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.height = msg.Height
|
||||
return m, nil
|
||||
|
||||
case TickMsg:
|
||||
m.now = msg.Now
|
||||
if m.hasPendingRequests() {
|
||||
return m, tickCmd()
|
||||
}
|
||||
return m, nil
|
||||
|
||||
// TODO: Abstract the keymaps
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
@ -32,6 +40,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
|
||||
case ErrMsg:
|
||||
m.events = append(m.events, model.Event{
|
||||
Time: time.Now().Local(),
|
||||
Type: model.EventTypeWarn,
|
||||
Body: fmt.Sprintf("tui event stream closed: %v", msg.err),
|
||||
})
|
||||
@ -40,6 +49,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
case EventMsg:
|
||||
m.pushEvent(msg.value)
|
||||
m.applyMessage(msg.value)
|
||||
if m.hasPendingRequests() {
|
||||
return m, tea.Batch(waitForEvent(m.channel), tickCmd())
|
||||
}
|
||||
return m, waitForEvent(m.channel)
|
||||
}
|
||||
|
||||
@ -82,3 +94,14 @@ func (m *Model) updateRequest(req model.Request) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m Model) hasPendingRequests() bool {
|
||||
// Traverse backward to be a bit more efficient, the most recent requests are more
|
||||
// like to be pending.
|
||||
for i := len(m.requests) - 1; i >= 0; i-- {
|
||||
if m.requests[i].Pending {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@ -2,95 +2,28 @@ package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"termtap.dev/internal/model"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TODO: This is all temporary
|
||||
func (m Model) View() string {
|
||||
return m.renderAppPane()
|
||||
|
||||
// eventLines := m.renderEvents(8)
|
||||
// requestLines := m.renderRequests(12)
|
||||
//
|
||||
// return strings.Join([]string{
|
||||
// "termtap - live session",
|
||||
// fmt.Sprintf("events=%d requests=%d", len(m.events), len(m.requests)),
|
||||
// fmt.Sprintf("%dx%d", m.height, m.width),
|
||||
// "keys: q/esc/ctrl+c quit",
|
||||
// "",
|
||||
// "Recent events:",
|
||||
// eventLines,
|
||||
// "",
|
||||
// "Recent requests:",
|
||||
// requestLines,
|
||||
// }, "\n")
|
||||
}
|
||||
|
||||
func (m Model) renderEvents(limit int) string {
|
||||
if len(m.events) == 0 {
|
||||
return " (none yet)"
|
||||
func formatDuration(d time.Duration) string {
|
||||
if d == 0 {
|
||||
return "PENDING"
|
||||
}
|
||||
|
||||
start := max(len(m.events)-limit, 0)
|
||||
|
||||
rows := make([]string, 0, len(m.events)-start)
|
||||
for i := start; i < len(m.events); i++ {
|
||||
e := m.events[i]
|
||||
rows = append(rows, fmt.Sprintf(" [%s] %s", e.Type, truncate(e.Body, 100)))
|
||||
if d >= 10*time.Second {
|
||||
return fmt.Sprintf("%.2fs", d.Seconds())
|
||||
}
|
||||
|
||||
return strings.Join(rows, "\n")
|
||||
}
|
||||
|
||||
func (m Model) renderRequests(limit int) string {
|
||||
if len(m.requests) == 0 {
|
||||
return " (none yet)"
|
||||
if d >= time.Millisecond {
|
||||
return fmt.Sprintf("%dms", d.Milliseconds())
|
||||
}
|
||||
|
||||
start := max(0, len(m.requests)-limit)
|
||||
|
||||
// Traverse backwards since we don't have a stack
|
||||
rows := make([]string, 0, len(m.requests)-start)
|
||||
for i := len(m.requests) - 1; i >= start; i-- {
|
||||
req := m.requests[i]
|
||||
|
||||
state := "done"
|
||||
if req.Pending {
|
||||
state = "pending"
|
||||
} else if req.Failed {
|
||||
state = "failed"
|
||||
}
|
||||
|
||||
rows = append(rows, fmt.Sprintf(
|
||||
" %s %s status=%d duration=%s state=%s",
|
||||
req.Method,
|
||||
requestPath(req),
|
||||
req.Status,
|
||||
req.Duration,
|
||||
state,
|
||||
))
|
||||
}
|
||||
|
||||
if len(rows) == 0 {
|
||||
return " (none yet)"
|
||||
}
|
||||
|
||||
return strings.Join(rows, "\n")
|
||||
}
|
||||
|
||||
func requestPath(req model.Request) string {
|
||||
if req.URL != "" {
|
||||
return truncate(req.URL, 80)
|
||||
}
|
||||
if req.RawURL != "" {
|
||||
return truncate(req.RawURL, 80)
|
||||
}
|
||||
if req.Host != "" {
|
||||
return truncate(req.Host, 80)
|
||||
}
|
||||
return "<unknown>"
|
||||
return fmt.Sprintf("%dus", d.Microseconds())
|
||||
}
|
||||
|
||||
func truncate(s string, max int) string {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user