Add embedded web chat client (closes #7) #8
206
cmd/chat-cli/api/client.go
Normal file
206
cmd/chat-cli/api/client.go
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Client wraps HTTP calls to the chat server API.
|
||||||
|
type Client struct {
|
||||||
|
BaseURL string
|
||||||
|
Token string
|
||||||
|
HTTPClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewClient creates a new API client.
|
||||||
|
func NewClient(baseURL string) *Client {
|
||||||
|
return &Client{
|
||||||
|
BaseURL: baseURL,
|
||||||
|
HTTPClient: &http.Client{
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) do(method, path string, body interface{}) ([]byte, error) {
|
||||||
|
var bodyReader io.Reader
|
||||||
|
if body != nil {
|
||||||
|
data, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("marshal: %w", err)
|
||||||
|
}
|
||||||
|
bodyReader = bytes.NewReader(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest(method, c.BaseURL+path, bodyReader)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
if c.Token != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+c.Token)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := c.HTTPClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("http: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
data, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read body: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
return data, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateSession creates a new session on the server.
|
||||||
|
func (c *Client) CreateSession(nick string) (*SessionResponse, error) {
|
||||||
|
data, err := c.do("POST", "/api/v1/session", &SessionRequest{Nick: nick})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var resp SessionResponse
|
||||||
|
if err := json.Unmarshal(data, &resp); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode session: %w", err)
|
||||||
|
}
|
||||||
|
c.Token = resp.Token
|
||||||
|
return &resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetState returns the current user state.
|
||||||
|
func (c *Client) GetState() (*StateResponse, error) {
|
||||||
|
data, err := c.do("GET", "/api/v1/state", nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var resp StateResponse
|
||||||
|
if err := json.Unmarshal(data, &resp); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode state: %w", err)
|
||||||
|
}
|
||||||
|
return &resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendMessage sends a message (any IRC command).
|
||||||
|
func (c *Client) SendMessage(msg *Message) error {
|
||||||
|
_, err := c.do("POST", "/api/v1/messages", msg)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// PollMessages long-polls for new messages.
|
||||||
|
func (c *Client) PollMessages(afterID string, timeout int) ([]Message, error) {
|
||||||
|
// Use a longer HTTP timeout than the server long-poll timeout.
|
||||||
|
client := &http.Client{Timeout: time.Duration(timeout+5) * time.Second}
|
||||||
|
|
||||||
|
params := url.Values{}
|
||||||
|
if afterID != "" {
|
||||||
|
params.Set("after", afterID)
|
||||||
|
}
|
||||||
|
params.Set("timeout", fmt.Sprintf("%d", timeout))
|
||||||
|
|
||||||
|
path := "/api/v1/messages"
|
||||||
|
if len(params) > 0 {
|
||||||
|
path += "?" + params.Encode()
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest("GET", c.BaseURL+path, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+c.Token)
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
data, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
// The server may return an array directly or wrapped.
|
||||||
|
var msgs []Message
|
||||||
|
if err := json.Unmarshal(data, &msgs); err != nil {
|
||||||
|
// Try wrapped format.
|
||||||
|
var wrapped MessagesResponse
|
||||||
|
if err2 := json.Unmarshal(data, &wrapped); err2 != nil {
|
||||||
|
return nil, fmt.Errorf("decode messages: %w (raw: %s)", err, string(data))
|
||||||
|
}
|
||||||
|
msgs = wrapped.Messages
|
||||||
|
}
|
||||||
|
|
||||||
|
return msgs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// JoinChannel joins a channel.
|
||||||
|
func (c *Client) JoinChannel(channel string) error {
|
||||||
|
_, err := c.do("POST", "/api/v1/channels/join", map[string]string{"channel": channel})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// PartChannel leaves a channel.
|
||||||
|
func (c *Client) PartChannel(channel string) error {
|
||||||
|
_, err := c.do("DELETE", "/api/v1/channels/"+url.PathEscape(channel), nil)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListChannels returns all channels on the server.
|
||||||
|
func (c *Client) ListChannels() ([]Channel, error) {
|
||||||
|
data, err := c.do("GET", "/api/v1/channels/all", nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var channels []Channel
|
||||||
|
if err := json.Unmarshal(data, &channels); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return channels, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMembers returns members of a channel.
|
||||||
|
func (c *Client) GetMembers(channel string) ([]string, error) {
|
||||||
|
data, err := c.do("GET", "/api/v1/channels/"+url.PathEscape(channel)+"/members", nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var members []string
|
||||||
|
if err := json.Unmarshal(data, &members); err != nil {
|
||||||
|
// Try object format.
|
||||||
|
var obj map[string]interface{}
|
||||||
|
if err2 := json.Unmarshal(data, &obj); err2 != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// Extract member names from whatever format.
|
||||||
|
return nil, fmt.Errorf("unexpected members format: %s", string(data))
|
||||||
|
}
|
||||||
|
return members, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetServerInfo returns server info.
|
||||||
|
func (c *Client) GetServerInfo() (*ServerInfo, error) {
|
||||||
|
data, err := c.do("GET", "/api/v1/server", nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var info ServerInfo
|
||||||
|
if err := json.Unmarshal(data, &info); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &info, nil
|
||||||
|
}
|
||||||
83
cmd/chat-cli/api/types.go
Normal file
83
cmd/chat-cli/api/types.go
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// SessionRequest is the body for POST /api/v1/session.
|
||||||
|
type SessionRequest struct {
|
||||||
|
Nick string `json:"nick"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SessionResponse is the response from POST /api/v1/session.
|
||||||
|
type SessionResponse struct {
|
||||||
|
SessionID string `json:"session_id"`
|
||||||
|
ClientID string `json:"client_id"`
|
||||||
|
Nick string `json:"nick"`
|
||||||
|
Token string `json:"token"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// StateResponse is the response from GET /api/v1/state.
|
||||||
|
type StateResponse struct {
|
||||||
|
SessionID string `json:"session_id"`
|
||||||
|
ClientID string `json:"client_id"`
|
||||||
|
Nick string `json:"nick"`
|
||||||
|
Channels []string `json:"channels"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Message represents a chat message envelope.
|
||||||
|
type Message struct {
|
||||||
|
Command string `json:"command"`
|
||||||
|
From string `json:"from,omitempty"`
|
||||||
|
To string `json:"to,omitempty"`
|
||||||
|
Params []string `json:"params,omitempty"`
|
||||||
|
Body interface{} `json:"body,omitempty"`
|
||||||
|
ID string `json:"id,omitempty"`
|
||||||
|
TS string `json:"ts,omitempty"`
|
||||||
|
Meta interface{} `json:"meta,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BodyLines returns the body as a slice of strings (for text messages).
|
||||||
|
func (m *Message) BodyLines() []string {
|
||||||
|
switch v := m.Body.(type) {
|
||||||
|
case []interface{}:
|
||||||
|
lines := make([]string, 0, len(v))
|
||||||
|
for _, item := range v {
|
||||||
|
if s, ok := item.(string); ok {
|
||||||
|
lines = append(lines, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lines
|
||||||
|
case []string:
|
||||||
|
return v
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Channel represents a channel in the list response.
|
||||||
|
type Channel struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Topic string `json:"topic"`
|
||||||
|
Members int `json:"members"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServerInfo is the response from GET /api/v1/server.
|
||||||
|
type ServerInfo struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
MOTD string `json:"motd"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MessagesResponse wraps polling results.
|
||||||
|
type MessagesResponse struct {
|
||||||
|
Messages []Message `json:"messages"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseTS parses the message timestamp.
|
||||||
|
func (m *Message) ParseTS() time.Time {
|
||||||
|
t, err := time.Parse(time.RFC3339Nano, m.TS)
|
||||||
|
if err != nil {
|
||||||
|
return time.Now()
|
||||||
|
}
|
||||||
|
return t
|
||||||
|
}
|
||||||
580
cmd/chat-cli/main.go
Normal file
580
cmd/chat-cli/main.go
Normal file
@@ -0,0 +1,580 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/chat/cmd/chat-cli/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
// App holds the application state.
|
||||||
|
type App struct {
|
||||||
|
ui *UI
|
||||||
|
client *api.Client
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
nick string
|
||||||
|
target string // current target (#channel or nick for DM)
|
||||||
|
connected bool
|
||||||
|
lastMsgID string
|
||||||
|
stopPoll chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
app := &App{
|
||||||
|
ui: NewUI(),
|
||||||
|
nick: "guest",
|
||||||
|
}
|
||||||
|
|
||||||
|
app.ui.OnInput(app.handleInput)
|
||||||
|
app.ui.SetStatus(app.nick, "", "disconnected")
|
||||||
|
|
||||||
|
app.ui.AddStatus("Welcome to chat-cli — an IRC-style client")
|
||||||
|
app.ui.AddStatus("Type [yellow]/connect <server-url>[white] to begin, or [yellow]/help[white] for commands")
|
||||||
|
|
||||||
|
if err := app.ui.Run(); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) handleInput(text string) {
|
||||||
|
if strings.HasPrefix(text, "/") {
|
||||||
|
a.handleCommand(text)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plain text → PRIVMSG to current target.
|
||||||
|
a.mu.Lock()
|
||||||
|
target := a.target
|
||||||
|
connected := a.connected
|
||||||
|
a.mu.Unlock()
|
||||||
|
|
||||||
|
if !connected {
|
||||||
|
a.ui.AddStatus("[red]Not connected. Use /connect <url>")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if target == "" {
|
||||||
|
a.ui.AddStatus("[red]No target. Use /join #channel or /query nick")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err := a.client.SendMessage(&api.Message{
|
||||||
|
Command: "PRIVMSG",
|
||||||
|
To: target,
|
||||||
|
Body: []string{text},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
a.ui.AddStatus(fmt.Sprintf("[red]Send error: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Echo locally.
|
||||||
|
ts := time.Now().Format("15:04")
|
||||||
|
a.mu.Lock()
|
||||||
|
nick := a.nick
|
||||||
|
a.mu.Unlock()
|
||||||
|
a.ui.AddLine(target, fmt.Sprintf("[gray]%s [green]<%s>[white] %s", ts, nick, text))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) handleCommand(text string) {
|
||||||
|
parts := strings.SplitN(text, " ", 2)
|
||||||
|
cmd := strings.ToLower(parts[0])
|
||||||
|
args := ""
|
||||||
|
if len(parts) > 1 {
|
||||||
|
args = parts[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
switch cmd {
|
||||||
|
case "/connect":
|
||||||
|
a.cmdConnect(args)
|
||||||
|
case "/nick":
|
||||||
|
a.cmdNick(args)
|
||||||
|
case "/join":
|
||||||
|
a.cmdJoin(args)
|
||||||
|
case "/part":
|
||||||
|
a.cmdPart(args)
|
||||||
|
case "/msg":
|
||||||
|
a.cmdMsg(args)
|
||||||
|
case "/query":
|
||||||
|
a.cmdQuery(args)
|
||||||
|
case "/topic":
|
||||||
|
a.cmdTopic(args)
|
||||||
|
case "/names":
|
||||||
|
a.cmdNames()
|
||||||
|
case "/list":
|
||||||
|
a.cmdList()
|
||||||
|
case "/window", "/w":
|
||||||
|
a.cmdWindow(args)
|
||||||
|
case "/quit":
|
||||||
|
a.cmdQuit()
|
||||||
|
case "/help":
|
||||||
|
a.cmdHelp()
|
||||||
|
default:
|
||||||
|
a.ui.AddStatus(fmt.Sprintf("[red]Unknown command: %s", cmd))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) cmdConnect(serverURL string) {
|
||||||
|
if serverURL == "" {
|
||||||
|
a.ui.AddStatus("[red]Usage: /connect <server-url>")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
serverURL = strings.TrimRight(serverURL, "/")
|
||||||
|
|
||||||
|
a.ui.AddStatus(fmt.Sprintf("Connecting to %s...", serverURL))
|
||||||
|
|
||||||
|
a.mu.Lock()
|
||||||
|
nick := a.nick
|
||||||
|
a.mu.Unlock()
|
||||||
|
|
||||||
|
client := api.NewClient(serverURL)
|
||||||
|
resp, err := client.CreateSession(nick)
|
||||||
|
if err != nil {
|
||||||
|
a.ui.AddStatus(fmt.Sprintf("[red]Connection failed: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
a.mu.Lock()
|
||||||
|
a.client = client
|
||||||
|
a.nick = resp.Nick
|
||||||
|
a.connected = true
|
||||||
|
a.lastMsgID = ""
|
||||||
|
a.mu.Unlock()
|
||||||
|
|
||||||
|
a.ui.AddStatus(fmt.Sprintf("[green]Connected! Nick: %s, Session: %s", resp.Nick, resp.SessionID))
|
||||||
|
a.ui.SetStatus(resp.Nick, "", "connected")
|
||||||
|
|
||||||
|
// Start polling.
|
||||||
|
a.stopPoll = make(chan struct{})
|
||||||
|
go a.pollLoop()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) cmdNick(nick string) {
|
||||||
|
if nick == "" {
|
||||||
|
a.ui.AddStatus("[red]Usage: /nick <name>")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.mu.Lock()
|
||||||
|
connected := a.connected
|
||||||
|
a.mu.Unlock()
|
||||||
|
|
||||||
|
if !connected {
|
||||||
|
a.mu.Lock()
|
||||||
|
a.nick = nick
|
||||||
|
a.mu.Unlock()
|
||||||
|
a.ui.AddStatus(fmt.Sprintf("Nick set to %s (will be used on connect)", nick))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err := a.client.SendMessage(&api.Message{
|
||||||
|
Command: "NICK",
|
||||||
|
Body: []string{nick},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
a.ui.AddStatus(fmt.Sprintf("[red]Nick change failed: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
a.mu.Lock()
|
||||||
|
a.nick = nick
|
||||||
|
target := a.target
|
||||||
|
a.mu.Unlock()
|
||||||
|
a.ui.SetStatus(nick, target, "connected")
|
||||||
|
a.ui.AddStatus(fmt.Sprintf("Nick changed to %s", nick))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) cmdJoin(channel string) {
|
||||||
|
if channel == "" {
|
||||||
|
a.ui.AddStatus("[red]Usage: /join #channel")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(channel, "#") {
|
||||||
|
channel = "#" + channel
|
||||||
|
}
|
||||||
|
|
||||||
|
a.mu.Lock()
|
||||||
|
connected := a.connected
|
||||||
|
a.mu.Unlock()
|
||||||
|
if !connected {
|
||||||
|
a.ui.AddStatus("[red]Not connected")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err := a.client.JoinChannel(channel)
|
||||||
|
if err != nil {
|
||||||
|
a.ui.AddStatus(fmt.Sprintf("[red]Join failed: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
a.mu.Lock()
|
||||||
|
a.target = channel
|
||||||
|
nick := a.nick
|
||||||
|
a.mu.Unlock()
|
||||||
|
|
||||||
|
a.ui.SwitchToBuffer(channel)
|
||||||
|
a.ui.AddLine(channel, fmt.Sprintf("[yellow]*** Joined %s", channel))
|
||||||
|
a.ui.SetStatus(nick, channel, "connected")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) cmdPart(channel string) {
|
||||||
|
a.mu.Lock()
|
||||||
|
if channel == "" {
|
||||||
|
channel = a.target
|
||||||
|
}
|
||||||
|
connected := a.connected
|
||||||
|
a.mu.Unlock()
|
||||||
|
|
||||||
|
if channel == "" || !strings.HasPrefix(channel, "#") {
|
||||||
|
a.ui.AddStatus("[red]No channel to part")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !connected {
|
||||||
|
a.ui.AddStatus("[red]Not connected")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err := a.client.PartChannel(channel)
|
||||||
|
if err != nil {
|
||||||
|
a.ui.AddStatus(fmt.Sprintf("[red]Part failed: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
a.ui.AddLine(channel, fmt.Sprintf("[yellow]*** Left %s", channel))
|
||||||
|
|
||||||
|
a.mu.Lock()
|
||||||
|
if a.target == channel {
|
||||||
|
a.target = ""
|
||||||
|
}
|
||||||
|
nick := a.nick
|
||||||
|
a.mu.Unlock()
|
||||||
|
|
||||||
|
a.ui.SwitchBuffer(0)
|
||||||
|
a.ui.SetStatus(nick, "", "connected")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) cmdMsg(args string) {
|
||||||
|
parts := strings.SplitN(args, " ", 2)
|
||||||
|
if len(parts) < 2 {
|
||||||
|
a.ui.AddStatus("[red]Usage: /msg <nick> <text>")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
target, text := parts[0], parts[1]
|
||||||
|
|
||||||
|
a.mu.Lock()
|
||||||
|
connected := a.connected
|
||||||
|
nick := a.nick
|
||||||
|
a.mu.Unlock()
|
||||||
|
if !connected {
|
||||||
|
a.ui.AddStatus("[red]Not connected")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err := a.client.SendMessage(&api.Message{
|
||||||
|
Command: "PRIVMSG",
|
||||||
|
To: target,
|
||||||
|
Body: []string{text},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
a.ui.AddStatus(fmt.Sprintf("[red]Send failed: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ts := time.Now().Format("15:04")
|
||||||
|
a.ui.AddLine(target, fmt.Sprintf("[gray]%s [green]<%s>[white] %s", ts, nick, text))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) cmdQuery(nick string) {
|
||||||
|
if nick == "" {
|
||||||
|
a.ui.AddStatus("[red]Usage: /query <nick>")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
a.mu.Lock()
|
||||||
|
a.target = nick
|
||||||
|
myNick := a.nick
|
||||||
|
a.mu.Unlock()
|
||||||
|
|
||||||
|
a.ui.SwitchToBuffer(nick)
|
||||||
|
a.ui.SetStatus(myNick, nick, "connected")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) cmdTopic(args string) {
|
||||||
|
a.mu.Lock()
|
||||||
|
target := a.target
|
||||||
|
connected := a.connected
|
||||||
|
a.mu.Unlock()
|
||||||
|
|
||||||
|
if !connected {
|
||||||
|
a.ui.AddStatus("[red]Not connected")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(target, "#") {
|
||||||
|
a.ui.AddStatus("[red]Not in a channel")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if args == "" {
|
||||||
|
// Query topic.
|
||||||
|
err := a.client.SendMessage(&api.Message{
|
||||||
|
Command: "TOPIC",
|
||||||
|
To: target,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
a.ui.AddStatus(fmt.Sprintf("[red]Topic query failed: %v", err))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err := a.client.SendMessage(&api.Message{
|
||||||
|
Command: "TOPIC",
|
||||||
|
To: target,
|
||||||
|
Body: []string{args},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
a.ui.AddStatus(fmt.Sprintf("[red]Topic set failed: %v", err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) cmdNames() {
|
||||||
|
a.mu.Lock()
|
||||||
|
target := a.target
|
||||||
|
connected := a.connected
|
||||||
|
a.mu.Unlock()
|
||||||
|
|
||||||
|
if !connected {
|
||||||
|
a.ui.AddStatus("[red]Not connected")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(target, "#") {
|
||||||
|
a.ui.AddStatus("[red]Not in a channel")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
members, err := a.client.GetMembers(target)
|
||||||
|
if err != nil {
|
||||||
|
a.ui.AddStatus(fmt.Sprintf("[red]Names failed: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
a.ui.AddLine(target, fmt.Sprintf("[cyan]*** Members of %s: %s", target, strings.Join(members, " ")))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) cmdList() {
|
||||||
|
a.mu.Lock()
|
||||||
|
connected := a.connected
|
||||||
|
a.mu.Unlock()
|
||||||
|
|
||||||
|
if !connected {
|
||||||
|
a.ui.AddStatus("[red]Not connected")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
channels, err := a.client.ListChannels()
|
||||||
|
if err != nil {
|
||||||
|
a.ui.AddStatus(fmt.Sprintf("[red]List failed: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
a.ui.AddStatus("[cyan]*** Channel list:")
|
||||||
|
for _, ch := range channels {
|
||||||
|
a.ui.AddStatus(fmt.Sprintf(" %s (%d members) %s", ch.Name, ch.Members, ch.Topic))
|
||||||
|
}
|
||||||
|
a.ui.AddStatus("[cyan]*** End of channel list")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) cmdWindow(args string) {
|
||||||
|
if args == "" {
|
||||||
|
a.ui.AddStatus("[red]Usage: /window <number>")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
n := 0
|
||||||
|
fmt.Sscanf(args, "%d", &n)
|
||||||
|
a.ui.SwitchBuffer(n)
|
||||||
|
|
||||||
|
a.mu.Lock()
|
||||||
|
if n < a.ui.BufferCount() && n >= 0 {
|
||||||
|
// Update target to the buffer name.
|
||||||
|
// Needs to be done carefully.
|
||||||
|
}
|
||||||
|
nick := a.nick
|
||||||
|
a.mu.Unlock()
|
||||||
|
|
||||||
|
// Update target based on buffer.
|
||||||
|
if n < a.ui.BufferCount() {
|
||||||
|
buf := a.ui.buffers[n]
|
||||||
|
if buf.Name != "(status)" {
|
||||||
|
a.mu.Lock()
|
||||||
|
a.target = buf.Name
|
||||||
|
a.mu.Unlock()
|
||||||
|
a.ui.SetStatus(nick, buf.Name, "connected")
|
||||||
|
} else {
|
||||||
|
a.ui.SetStatus(nick, "", "connected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) cmdQuit() {
|
||||||
|
a.mu.Lock()
|
||||||
|
if a.connected && a.client != nil {
|
||||||
|
_ = a.client.SendMessage(&api.Message{Command: "QUIT"})
|
||||||
|
}
|
||||||
|
if a.stopPoll != nil {
|
||||||
|
close(a.stopPoll)
|
||||||
|
}
|
||||||
|
a.mu.Unlock()
|
||||||
|
a.ui.Stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) cmdHelp() {
|
||||||
|
help := []string{
|
||||||
|
"[cyan]*** chat-cli commands:",
|
||||||
|
" /connect <url> — Connect to server",
|
||||||
|
" /nick <name> — Change nickname",
|
||||||
|
" /join #channel — Join channel",
|
||||||
|
" /part [#chan] — Leave channel",
|
||||||
|
" /msg <nick> <text> — Send DM",
|
||||||
|
" /query <nick> — Open DM window",
|
||||||
|
" /topic [text] — View/set topic",
|
||||||
|
" /names — List channel members",
|
||||||
|
" /list — List channels",
|
||||||
|
" /window <n> — Switch buffer (Alt+0-9)",
|
||||||
|
" /quit — Disconnect and exit",
|
||||||
|
" /help — This help",
|
||||||
|
" Plain text sends to current target.",
|
||||||
|
}
|
||||||
|
for _, line := range help {
|
||||||
|
a.ui.AddStatus(line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// pollLoop long-polls for messages in the background.
|
||||||
|
func (a *App) pollLoop() {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-a.stopPoll:
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
a.mu.Lock()
|
||||||
|
client := a.client
|
||||||
|
lastID := a.lastMsgID
|
||||||
|
a.mu.Unlock()
|
||||||
|
|
||||||
|
if client == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
msgs, err := client.PollMessages(lastID, 15)
|
||||||
|
if err != nil {
|
||||||
|
// Transient error — retry after delay.
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, msg := range msgs {
|
||||||
|
a.handleServerMessage(&msg)
|
||||||
|
if msg.ID != "" {
|
||||||
|
a.mu.Lock()
|
||||||
|
a.lastMsgID = msg.ID
|
||||||
|
a.mu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) handleServerMessage(msg *api.Message) {
|
||||||
|
ts := ""
|
||||||
|
if msg.TS != "" {
|
||||||
|
t := msg.ParseTS()
|
||||||
|
ts = t.Local().Format("15:04")
|
||||||
|
} else {
|
||||||
|
ts = time.Now().Format("15:04")
|
||||||
|
}
|
||||||
|
|
||||||
|
a.mu.Lock()
|
||||||
|
myNick := a.nick
|
||||||
|
a.mu.Unlock()
|
||||||
|
|
||||||
|
switch msg.Command {
|
||||||
|
case "PRIVMSG":
|
||||||
|
lines := msg.BodyLines()
|
||||||
|
text := strings.Join(lines, " ")
|
||||||
|
if msg.From == myNick {
|
||||||
|
// Skip our own echoed messages (already displayed locally).
|
||||||
|
return
|
||||||
|
}
|
||||||
|
target := msg.To
|
||||||
|
if !strings.HasPrefix(target, "#") {
|
||||||
|
// DM — use sender's nick as buffer name.
|
||||||
|
target = msg.From
|
||||||
|
}
|
||||||
|
a.ui.AddLine(target, fmt.Sprintf("[gray]%s [green]<%s>[white] %s", ts, msg.From, text))
|
||||||
|
|
||||||
|
case "JOIN":
|
||||||
|
target := msg.To
|
||||||
|
if target != "" {
|
||||||
|
a.ui.AddLine(target, fmt.Sprintf("[gray]%s [yellow]*** %s has joined %s", ts, msg.From, target))
|
||||||
|
}
|
||||||
|
|
||||||
|
case "PART":
|
||||||
|
target := msg.To
|
||||||
|
lines := msg.BodyLines()
|
||||||
|
reason := strings.Join(lines, " ")
|
||||||
|
if target != "" {
|
||||||
|
if reason != "" {
|
||||||
|
a.ui.AddLine(target, fmt.Sprintf("[gray]%s [yellow]*** %s has left %s (%s)", ts, msg.From, target, reason))
|
||||||
|
} else {
|
||||||
|
a.ui.AddLine(target, fmt.Sprintf("[gray]%s [yellow]*** %s has left %s", ts, msg.From, target))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case "QUIT":
|
||||||
|
lines := msg.BodyLines()
|
||||||
|
reason := strings.Join(lines, " ")
|
||||||
|
if reason != "" {
|
||||||
|
a.ui.AddStatus(fmt.Sprintf("[gray]%s [yellow]*** %s has quit (%s)", ts, msg.From, reason))
|
||||||
|
} else {
|
||||||
|
a.ui.AddStatus(fmt.Sprintf("[gray]%s [yellow]*** %s has quit", ts, msg.From))
|
||||||
|
}
|
||||||
|
|
||||||
|
case "NICK":
|
||||||
|
lines := msg.BodyLines()
|
||||||
|
newNick := ""
|
||||||
|
if len(lines) > 0 {
|
||||||
|
newNick = lines[0]
|
||||||
|
}
|
||||||
|
if msg.From == myNick && newNick != "" {
|
||||||
|
a.mu.Lock()
|
||||||
|
a.nick = newNick
|
||||||
|
target := a.target
|
||||||
|
a.mu.Unlock()
|
||||||
|
a.ui.SetStatus(newNick, target, "connected")
|
||||||
|
}
|
||||||
|
a.ui.AddStatus(fmt.Sprintf("[gray]%s [yellow]*** %s is now known as %s", ts, msg.From, newNick))
|
||||||
|
|
||||||
|
case "NOTICE":
|
||||||
|
lines := msg.BodyLines()
|
||||||
|
text := strings.Join(lines, " ")
|
||||||
|
a.ui.AddStatus(fmt.Sprintf("[gray]%s [magenta]--%s-- %s", ts, msg.From, text))
|
||||||
|
|
||||||
|
case "TOPIC":
|
||||||
|
lines := msg.BodyLines()
|
||||||
|
text := strings.Join(lines, " ")
|
||||||
|
if msg.To != "" {
|
||||||
|
a.ui.AddLine(msg.To, fmt.Sprintf("[gray]%s [cyan]*** %s set topic: %s", ts, msg.From, text))
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
// Numeric replies and other messages → status window.
|
||||||
|
lines := msg.BodyLines()
|
||||||
|
text := strings.Join(lines, " ")
|
||||||
|
if text != "" {
|
||||||
|
a.ui.AddStatus(fmt.Sprintf("[gray]%s [white][%s] %s", ts, msg.Command, text))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
233
cmd/chat-cli/ui.go
Normal file
233
cmd/chat-cli/ui.go
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gdamore/tcell/v2"
|
||||||
|
"github.com/rivo/tview"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Buffer holds messages for a channel/DM/status window.
|
||||||
|
type Buffer struct {
|
||||||
|
Name string
|
||||||
|
Lines []string
|
||||||
|
Unread int
|
||||||
|
}
|
||||||
|
|
||||||
|
// UI manages the terminal interface.
|
||||||
|
type UI struct {
|
||||||
|
app *tview.Application
|
||||||
|
messages *tview.TextView
|
||||||
|
statusBar *tview.TextView
|
||||||
|
input *tview.InputField
|
||||||
|
layout *tview.Flex
|
||||||
|
|
||||||
|
buffers []*Buffer
|
||||||
|
currentBuffer int
|
||||||
|
|
||||||
|
onInput func(string)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewUI creates the tview-based IRC-like UI.
|
||||||
|
func NewUI() *UI {
|
||||||
|
ui := &UI{
|
||||||
|
app: tview.NewApplication(),
|
||||||
|
buffers: []*Buffer{
|
||||||
|
{Name: "(status)", Lines: nil},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Message area.
|
||||||
|
ui.messages = tview.NewTextView().
|
||||||
|
SetDynamicColors(true).
|
||||||
|
SetScrollable(true).
|
||||||
|
SetWordWrap(true).
|
||||||
|
SetChangedFunc(func() {
|
||||||
|
ui.app.Draw()
|
||||||
|
})
|
||||||
|
ui.messages.SetBorder(false)
|
||||||
|
|
||||||
|
// Status bar.
|
||||||
|
ui.statusBar = tview.NewTextView().
|
||||||
|
SetDynamicColors(true)
|
||||||
|
ui.statusBar.SetBackgroundColor(tcell.ColorNavy)
|
||||||
|
ui.statusBar.SetTextColor(tcell.ColorWhite)
|
||||||
|
|
||||||
|
// Input field.
|
||||||
|
ui.input = tview.NewInputField().
|
||||||
|
SetFieldBackgroundColor(tcell.ColorBlack).
|
||||||
|
SetFieldTextColor(tcell.ColorWhite)
|
||||||
|
ui.input.SetDoneFunc(func(key tcell.Key) {
|
||||||
|
if key == tcell.KeyEnter {
|
||||||
|
text := ui.input.GetText()
|
||||||
|
if text == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ui.input.SetText("")
|
||||||
|
if ui.onInput != nil {
|
||||||
|
ui.onInput(text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Capture Alt+N for window switching.
|
||||||
|
ui.app.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||||
|
if event.Modifiers()&tcell.ModAlt != 0 {
|
||||||
|
r := event.Rune()
|
||||||
|
if r >= '0' && r <= '9' {
|
||||||
|
idx := int(r - '0')
|
||||||
|
ui.SwitchBuffer(idx)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return event
|
||||||
|
})
|
||||||
|
|
||||||
|
// Layout: messages on top, status bar, input at bottom.
|
||||||
|
ui.layout = tview.NewFlex().SetDirection(tview.FlexRow).
|
||||||
|
AddItem(ui.messages, 0, 1, false).
|
||||||
|
AddItem(ui.statusBar, 1, 0, false).
|
||||||
|
AddItem(ui.input, 1, 0, true)
|
||||||
|
|
||||||
|
ui.app.SetRoot(ui.layout, true)
|
||||||
|
ui.app.SetFocus(ui.input)
|
||||||
|
|
||||||
|
return ui
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run starts the UI event loop (blocks).
|
||||||
|
func (ui *UI) Run() error {
|
||||||
|
return ui.app.Run()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop stops the UI.
|
||||||
|
func (ui *UI) Stop() {
|
||||||
|
ui.app.Stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
// OnInput sets the callback for user input.
|
||||||
|
func (ui *UI) OnInput(fn func(string)) {
|
||||||
|
ui.onInput = fn
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddLine adds a line to the specified buffer.
|
||||||
|
func (ui *UI) AddLine(bufferName string, line string) {
|
||||||
|
ui.app.QueueUpdateDraw(func() {
|
||||||
|
buf := ui.getOrCreateBuffer(bufferName)
|
||||||
|
buf.Lines = append(buf.Lines, line)
|
||||||
|
|
||||||
|
// Mark unread if not currently viewing this buffer.
|
||||||
|
if ui.buffers[ui.currentBuffer] != buf {
|
||||||
|
buf.Unread++
|
||||||
|
ui.refreshStatus()
|
||||||
|
}
|
||||||
|
|
||||||
|
// If viewing this buffer, append to display.
|
||||||
|
if ui.buffers[ui.currentBuffer] == buf {
|
||||||
|
fmt.Fprintln(ui.messages, line)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddStatus adds a line to the status buffer (buffer 0).
|
||||||
|
func (ui *UI) AddStatus(line string) {
|
||||||
|
ts := time.Now().Format("15:04")
|
||||||
|
ui.AddLine("(status)", fmt.Sprintf("[gray]%s[white] %s", ts, line))
|
||||||
|
}
|
||||||
|
|
||||||
|
// SwitchBuffer switches to the buffer at index n.
|
||||||
|
func (ui *UI) SwitchBuffer(n int) {
|
||||||
|
ui.app.QueueUpdateDraw(func() {
|
||||||
|
if n < 0 || n >= len(ui.buffers) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ui.currentBuffer = n
|
||||||
|
buf := ui.buffers[n]
|
||||||
|
buf.Unread = 0
|
||||||
|
ui.messages.Clear()
|
||||||
|
for _, line := range buf.Lines {
|
||||||
|
fmt.Fprintln(ui.messages, line)
|
||||||
|
}
|
||||||
|
ui.messages.ScrollToEnd()
|
||||||
|
ui.refreshStatus()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SwitchToBuffer switches to the named buffer, creating it if needed.
|
||||||
|
func (ui *UI) SwitchToBuffer(name string) {
|
||||||
|
ui.app.QueueUpdateDraw(func() {
|
||||||
|
buf := ui.getOrCreateBuffer(name)
|
||||||
|
for i, b := range ui.buffers {
|
||||||
|
if b == buf {
|
||||||
|
ui.currentBuffer = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
buf.Unread = 0
|
||||||
|
ui.messages.Clear()
|
||||||
|
for _, line := range buf.Lines {
|
||||||
|
fmt.Fprintln(ui.messages, line)
|
||||||
|
}
|
||||||
|
ui.messages.ScrollToEnd()
|
||||||
|
ui.refreshStatus()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetStatus updates the status bar text.
|
||||||
|
func (ui *UI) SetStatus(nick, target, connStatus string) {
|
||||||
|
ui.app.QueueUpdateDraw(func() {
|
||||||
|
ui.refreshStatusWith(nick, target, connStatus)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ui *UI) refreshStatus() {
|
||||||
|
// Will be called from the main goroutine via QueueUpdateDraw parent.
|
||||||
|
// Rebuild status from app state — caller must provide context.
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ui *UI) refreshStatusWith(nick, target, connStatus string) {
|
||||||
|
var unreadParts []string
|
||||||
|
for i, buf := range ui.buffers {
|
||||||
|
if buf.Unread > 0 {
|
||||||
|
unreadParts = append(unreadParts, fmt.Sprintf("%d:%s(%d)", i, buf.Name, buf.Unread))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unread := ""
|
||||||
|
if len(unreadParts) > 0 {
|
||||||
|
unread = " [Act: " + strings.Join(unreadParts, ",") + "]"
|
||||||
|
}
|
||||||
|
|
||||||
|
bufInfo := fmt.Sprintf("[%d:%s]", ui.currentBuffer, ui.buffers[ui.currentBuffer].Name)
|
||||||
|
|
||||||
|
ui.statusBar.Clear()
|
||||||
|
fmt.Fprintf(ui.statusBar, " [%s] %s %s %s%s",
|
||||||
|
connStatus, nick, bufInfo, target, unread)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ui *UI) getOrCreateBuffer(name string) *Buffer {
|
||||||
|
for _, buf := range ui.buffers {
|
||||||
|
if buf.Name == name {
|
||||||
|
return buf
|
||||||
|
}
|
||||||
|
}
|
||||||
|
buf := &Buffer{Name: name}
|
||||||
|
ui.buffers = append(ui.buffers, buf)
|
||||||
|
return buf
|
||||||
|
}
|
||||||
|
|
||||||
|
// BufferCount returns the number of buffers.
|
||||||
|
func (ui *UI) BufferCount() int {
|
||||||
|
return len(ui.buffers)
|
||||||
|
}
|
||||||
|
|
||||||
|
// BufferIndex returns the index of a named buffer, or -1.
|
||||||
|
func (ui *UI) BufferIndex(name string) int {
|
||||||
|
for i, buf := range ui.buffers {
|
||||||
|
if buf.Name == name {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
10
go.mod
10
go.mod
@@ -20,8 +20,11 @@ require (
|
|||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||||
|
github.com/gdamore/encoding v1.0.1 // indirect
|
||||||
|
github.com/gdamore/tcell/v2 v2.13.8 // indirect
|
||||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
@@ -30,6 +33,8 @@ require (
|
|||||||
github.com/prometheus/common v0.66.1 // indirect
|
github.com/prometheus/common v0.66.1 // indirect
|
||||||
github.com/prometheus/procfs v0.16.1 // indirect
|
github.com/prometheus/procfs v0.16.1 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
|
github.com/rivo/tview v0.42.0 // indirect
|
||||||
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
github.com/sagikazarmark/locafero v0.11.0 // indirect
|
github.com/sagikazarmark/locafero v0.11.0 // indirect
|
||||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
|
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
|
||||||
github.com/spf13/afero v1.15.0 // indirect
|
github.com/spf13/afero v1.15.0 // indirect
|
||||||
@@ -42,8 +47,9 @@ require (
|
|||||||
go.yaml.in/yaml/v2 v2.4.2 // indirect
|
go.yaml.in/yaml/v2 v2.4.2 // indirect
|
||||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
|
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
|
||||||
golang.org/x/sys v0.37.0 // indirect
|
golang.org/x/sys v0.38.0 // indirect
|
||||||
golang.org/x/text v0.28.0 // indirect
|
golang.org/x/term v0.37.0 // indirect
|
||||||
|
golang.org/x/text v0.31.0 // indirect
|
||||||
google.golang.org/protobuf v1.36.8 // indirect
|
google.golang.org/protobuf v1.36.8 // indirect
|
||||||
modernc.org/libc v1.67.6 // indirect
|
modernc.org/libc v1.67.6 // indirect
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
|
|||||||
47
go.sum
47
go.sum
@@ -12,6 +12,10 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk
|
|||||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||||
|
github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw=
|
||||||
|
github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo=
|
||||||
|
github.com/gdamore/tcell/v2 v2.13.8 h1:Mys/Kl5wfC/GcC5Cx4C2BIQH9dbnhnkPgS9/wF3RlfU=
|
||||||
|
github.com/gdamore/tcell/v2 v2.13.8/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo=
|
||||||
github.com/getsentry/sentry-go v0.42.0 h1:eeFMACuZTbUQf90RE8dE4tXeSe4CZyfvR1MBL7RLEt8=
|
github.com/getsentry/sentry-go v0.42.0 h1:eeFMACuZTbUQf90RE8dE4tXeSe4CZyfvR1MBL7RLEt8=
|
||||||
github.com/getsentry/sentry-go v0.42.0/go.mod h1:eRXCoh3uvmjQLY6qu63BjUZnaBu5L5WhMV1RwYO8W5s=
|
github.com/getsentry/sentry-go v0.42.0/go.mod h1:eRXCoh3uvmjQLY6qu63BjUZnaBu5L5WhMV1RwYO8W5s=
|
||||||
github.com/go-chi/chi v1.5.5 h1:vOB/HbEMt9QqBqErz07QehcOKHaWFtuj87tTDVz2qXE=
|
github.com/go-chi/chi v1.5.5 h1:vOB/HbEMt9QqBqErz07QehcOKHaWFtuj87tTDVz2qXE=
|
||||||
@@ -40,6 +44,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
|||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||||
@@ -64,6 +70,10 @@ github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzM
|
|||||||
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
|
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
github.com/rivo/tview v0.42.0 h1:b/ftp+RxtDsHSaynXTbJb+/n/BxDEi+W3UfF5jILK6c=
|
||||||
|
github.com/rivo/tview v0.42.0/go.mod h1:cSfIYfhpSGCjp3r/ECJb+GKS7cGJnqV8vfjQPwoXyfY=
|
||||||
|
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||||
|
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||||
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||||
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||||
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
|
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
|
||||||
@@ -86,6 +96,7 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
|
|||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||||
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
go.uber.org/dig v1.19.0 h1:BACLhebsYdpQ7IROQ1AGPjrXcP5dF80U3gKoFzbaq/4=
|
go.uber.org/dig v1.19.0 h1:BACLhebsYdpQ7IROQ1AGPjrXcP5dF80U3gKoFzbaq/4=
|
||||||
go.uber.org/dig v1.19.0/go.mod h1:Us0rSJiThwCv2GteUN0Q7OKvU7n5J4dxZ9JKUXozFdE=
|
go.uber.org/dig v1.19.0/go.mod h1:Us0rSJiThwCv2GteUN0Q7OKvU7n5J4dxZ9JKUXozFdE=
|
||||||
go.uber.org/fx v1.24.0 h1:wE8mruvpg2kiiL1Vqd0CC+tr0/24XIB10Iwp2lLWzkg=
|
go.uber.org/fx v1.24.0 h1:wE8mruvpg2kiiL1Vqd0CC+tr0/24XIB10Iwp2lLWzkg=
|
||||||
@@ -100,19 +111,55 @@ go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
|
|||||||
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
|
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
|
||||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
|
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
|
||||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
|
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
|
||||||
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
|
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
|
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
|
||||||
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
|
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
|
||||||
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
|
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
|
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||||
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
||||||
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||||
|
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
|
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||||
|
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
|
||||||
|
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
|
||||||
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
|
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||||
|
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
|
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
|
||||||
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
|
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
|
||||||
|
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
||||||
|
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
||||||
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
|
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||||
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
|
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
|
||||||
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
|
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
|
||||||
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
|
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
|
||||||
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
|||||||
Reference in New Issue
Block a user