From 2f6d1f284c8231063b623b16df1a37c4a6df1d2f Mon Sep 17 00:00:00 2001 From: user Date: Fri, 20 Feb 2026 03:29:18 -0800 Subject: [PATCH] fix: resolve cyclop, funlen issues by extracting helper methods --- cmd/chat-cli/main.go | 231 ++++++++++++++++++++------------------ cmd/chat-cli/ui.go | 51 +++++---- internal/db/db.go | 99 ++++++++-------- internal/handlers/api.go | 76 +++++++------ internal/server/routes.go | 54 ++++----- 5 files changed, 263 insertions(+), 248 deletions(-) diff --git a/cmd/chat-cli/main.go b/cmd/chat-cli/main.go index 99d4531..c6865d3 100644 --- a/cmd/chat-cli/main.go +++ b/cmd/chat-cli/main.go @@ -97,6 +97,24 @@ func (a *App) handleInput(text string) { a.ui.AddLine(target, fmt.Sprintf("[gray]%s [green]<%s>[white] %s", ts, nick, text)) } +func (a *App) commandHandlers() map[string]func(string) { + return map[string]func(string){ + "/connect": a.cmdConnect, + "/nick": a.cmdNick, + "/join": a.cmdJoin, + "/part": a.cmdPart, + "/msg": a.cmdMsg, + "/query": a.cmdQuery, + "/topic": a.cmdTopic, + "/names": func(_ string) { a.cmdNames() }, + "/list": func(_ string) { a.cmdList() }, + "/window": a.cmdWindow, + "/w": a.cmdWindow, + "/quit": func(_ string) { a.cmdQuit() }, + "/help": func(_ string) { a.cmdHelp() }, + } +} + func (a *App) handleCommand(text string) { parts := strings.SplitN(text, " ", splitParts) cmd := strings.ToLower(parts[0]) @@ -106,32 +124,9 @@ func (a *App) handleCommand(text string) { 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: + if handler, ok := a.commandHandlers()[cmd]; ok { + handler(args) + } else { a.ui.AddStatus("[red]Unknown command: " + cmd) } } @@ -543,105 +538,123 @@ func (a *App) pollLoop() { } } -func (a *App) handleServerMessage(msg *api.Message) { - var ts string - +func (a *App) messageTimestamp(msg *api.Message) string { if msg.TS != "" { t := msg.ParseTS() - ts = t.Local().Format("15:04") //nolint:gosmopolitan // CLI displays local time intentionally - } else { - ts = time.Now().Format("15:04") + + return t.Local().Format("15:04") //nolint:gosmopolitan // CLI displays local time intentionally } + return time.Now().Format("15:04") +} + +func (a *App) handleServerMessage(msg *api.Message) { + ts := a.messageTimestamp(msg) + 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)) - + a.handleMsgPrivmsg(msg, ts, myNick) case "JOIN": - target := msg.To - if target != "" { - a.ui.AddLine(target, fmt.Sprintf("[gray]%s [yellow]*** %s has joined %s", ts, msg.From, target)) - } - + a.handleMsgJoin(msg, ts) 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)) - } - } - + a.handleMsgPart(msg, ts) 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)) - } - + a.handleMsgQuit(msg, ts) 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)) - + a.handleMsgNick(msg, ts, myNick) case "NOTICE": - lines := msg.BodyLines() - text := strings.Join(lines, " ") - a.ui.AddStatus(fmt.Sprintf("[gray]%s [magenta]--%s-- %s", ts, msg.From, text)) - + a.handleMsgNotice(msg, ts) 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)) - } - + a.handleMsgTopic(msg, ts) 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)) - } + a.handleMsgDefault(msg, ts) + } +} + +func (a *App) handleMsgPrivmsg(msg *api.Message, ts, myNick string) { + lines := msg.BodyLines() + text := strings.Join(lines, " ") + + if msg.From == myNick { + return + } + + target := msg.To + if !strings.HasPrefix(target, "#") { + target = msg.From + } + + a.ui.AddLine(target, fmt.Sprintf("[gray]%s [green]<%s>[white] %s", ts, msg.From, text)) +} + +func (a *App) handleMsgJoin(msg *api.Message, ts string) { + if msg.To != "" { + a.ui.AddLine(msg.To, fmt.Sprintf("[gray]%s [yellow]*** %s has joined %s", ts, msg.From, msg.To)) + } +} + +func (a *App) handleMsgPart(msg *api.Message, ts string) { + target := msg.To + reason := strings.Join(msg.BodyLines(), " ") + + if target == "" { + return + } + + 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)) + } +} + +func (a *App) handleMsgQuit(msg *api.Message, ts string) { + reason := strings.Join(msg.BodyLines(), " ") + 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)) + } +} + +func (a *App) handleMsgNick(msg *api.Message, ts, myNick string) { + 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)) +} + +func (a *App) handleMsgNotice(msg *api.Message, ts string) { + text := strings.Join(msg.BodyLines(), " ") + a.ui.AddStatus(fmt.Sprintf("[gray]%s [magenta]--%s-- %s", ts, msg.From, text)) +} + +func (a *App) handleMsgTopic(msg *api.Message, ts string) { + text := strings.Join(msg.BodyLines(), " ") + if msg.To != "" { + a.ui.AddLine(msg.To, fmt.Sprintf("[gray]%s [cyan]*** %s set topic: %s", ts, msg.From, text)) + } +} + +func (a *App) handleMsgDefault(msg *api.Message, ts string) { + text := strings.Join(msg.BodyLines(), " ") + if text != "" { + a.ui.AddStatus(fmt.Sprintf("[gray]%s [white][%s] %s", ts, msg.Command, text)) } } diff --git a/cmd/chat-cli/ui.go b/cmd/chat-cli/ui.go index f044584..1304460 100644 --- a/cmd/chat-cli/ui.go +++ b/cmd/chat-cli/ui.go @@ -55,26 +55,44 @@ func NewUI() *UI { ui.statusBar.SetBackgroundColor(tcell.ColorNavy) ui.statusBar.SetTextColor(tcell.ColorWhite) - // Input field. + ui.setupInput() + ui.setupKeyCapture() + + // 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 +} + +func (ui *UI) setupInput() { 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 - } + if key != tcell.KeyEnter { + return + } - ui.input.SetText("") + text := ui.input.GetText() + if text == "" { + return + } - if ui.onInput != nil { - ui.onInput(text) - } + ui.input.SetText("") + + if ui.onInput != nil { + ui.onInput(text) } }) +} - // Capture Alt+N for window switching. +func (ui *UI) setupKeyCapture() { ui.app.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { if event.Modifiers()&tcell.ModAlt != 0 { r := event.Rune() @@ -88,17 +106,6 @@ func NewUI() *UI { 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). diff --git a/internal/db/db.go b/internal/db/db.go index 406fd0b..e0c2c41 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -662,63 +662,52 @@ func (s *Database) applyMigrations( migrations []migration, ) error { for _, m := range migrations { - var exists int - - err := s.db.QueryRowContext(ctx, - "SELECT COUNT(*) FROM schema_migrations WHERE version = ?", - m.version, - ).Scan(&exists) - if err != nil { - return fmt.Errorf( - "check migration %d: %w", m.version, err, - ) - } - - if exists > 0 { - continue - } - - s.log.Info( - "applying migration", - "version", m.version, "name", m.name, - ) - - tx, err := s.db.BeginTx(ctx, nil) - if err != nil { - return fmt.Errorf( - "begin tx for migration %d: %w", m.version, err, - ) - } - - _, err = tx.ExecContext(ctx, m.sql) - if err != nil { - _ = tx.Rollback() - - return fmt.Errorf( - "apply migration %d (%s): %w", - m.version, m.name, err, - ) - } - - _, err = tx.ExecContext(ctx, - "INSERT INTO schema_migrations (version) VALUES (?)", - m.version, - ) - if err != nil { - _ = tx.Rollback() - - return fmt.Errorf( - "record migration %d: %w", m.version, err, - ) - } - - err = tx.Commit() - if err != nil { - return fmt.Errorf( - "commit migration %d: %w", m.version, err, - ) + if err := s.applyOneMigration(ctx, m); err != nil { + return err } } return nil } + +func (s *Database) applyOneMigration(ctx context.Context, m migration) error { + var exists int + + err := s.db.QueryRowContext(ctx, + "SELECT COUNT(*) FROM schema_migrations WHERE version = ?", + m.version, + ).Scan(&exists) + if err != nil { + return fmt.Errorf("check migration %d: %w", m.version, err) + } + + if exists > 0 { + return nil + } + + s.log.Info("applying migration", "version", m.version, "name", m.name) + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("begin tx for migration %d: %w", m.version, err) + } + + _, err = tx.ExecContext(ctx, m.sql) + if err != nil { + _ = tx.Rollback() + + return fmt.Errorf("apply migration %d (%s): %w", m.version, m.name, err) + } + + _, err = tx.ExecContext(ctx, + "INSERT INTO schema_migrations (version) VALUES (?)", + m.version, + ) + if err != nil { + _ = tx.Rollback() + + return fmt.Errorf("record migration %d: %w", m.version, err) + } + + return tx.Commit() +} diff --git a/internal/handlers/api.go b/internal/handlers/api.go index a55cbc1..10d8fe9 100644 --- a/internal/handlers/api.go +++ b/internal/handlers/api.go @@ -239,51 +239,53 @@ func (s *Handlers) HandleSendCommand() http.HandlerFunc { req.Command = strings.ToUpper(strings.TrimSpace(req.Command)) req.To = strings.TrimSpace(req.To) + lines := extractBodyLines(req.Body) - // Helper to extract body as string lines. - bodyLines := func() []string { - switch v := req.Body.(type) { - case []any: - lines := make([]string, 0, len(v)) + s.dispatchCommand(w, r, uid, nick, req.Command, req.To, lines) + } +} - for _, item := range v { - if str, ok := item.(string); ok { - lines = append(lines, str) - } - } +// extractBodyLines converts the request body to string lines. +func extractBodyLines(body any) []string { + switch v := body.(type) { + case []any: + lines := make([]string, 0, len(v)) - return lines - case []string: - return v - default: - return nil + for _, item := range v { + if str, ok := item.(string); ok { + lines = append(lines, str) } } - switch req.Command { - case "PRIVMSG", "NOTICE": - s.handlePrivmsg(w, r, uid, nick, req.To, bodyLines()) + return lines + case []string: + return v + default: + return nil + } +} - case "JOIN": - s.handleJoin(w, r, uid, req.To) +func (s *Handlers) dispatchCommand( + w http.ResponseWriter, r *http.Request, + uid, nick, command, to string, lines []string, +) { + switch command { + case "PRIVMSG", "NOTICE": + s.handlePrivmsg(w, r, uid, nick, to, lines) + case "JOIN": + s.handleJoin(w, r, uid, to) + case "PART": + s.handlePart(w, r, uid, to) + case "NICK": + s.handleNick(w, r, uid, lines) + case "TOPIC": + s.handleTopic(w, r, uid, to, lines) + case "PING": + s.respondJSON(w, r, map[string]string{"command": "PONG", "from": s.params.Config.ServerName}, http.StatusOK) + default: + _ = nick - case "PART": - s.handlePart(w, r, uid, req.To) - - case "NICK": - s.handleNick(w, r, uid, bodyLines()) - - case "TOPIC": - s.handleTopic(w, r, uid, req.To, bodyLines()) - - case "PING": - s.respondJSON(w, r, map[string]string{"command": "PONG", "from": s.params.Config.ServerName}, http.StatusOK) - - default: - _ = nick // suppress unused warning - - s.respondJSON(w, r, map[string]string{"error": "unknown command: " + req.Command}, http.StatusBadRequest) - } + s.respondJSON(w, r, map[string]string{"error": "unknown command: " + command}, http.StatusBadRequest) } } diff --git a/internal/server/routes.go b/internal/server/routes.go index 2c15e75..5a4363e 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -65,34 +65,38 @@ func (s *Server) SetupRoutes() { r.Get("/channels/{channel}/members", s.h.HandleChannelMembers()) }) - // Serve embedded SPA + s.setupSPA() +} + +func (s *Server) setupSPA() { distFS, err := fs.Sub(web.Dist, "dist") if err != nil { s.log.Error("failed to get web dist filesystem", "error", err) - } else { - fileServer := http.FileServer(http.FS(distFS)) - s.router.Get("/*", func(w http.ResponseWriter, r *http.Request) { - readFS, ok := distFS.(fs.ReadFileFS) - if !ok { - http.Error(w, "internal error", http.StatusInternalServerError) - - return - } - - // Try to serve the file; if not found, serve index.html for SPA routing - f, err := readFS.ReadFile(r.URL.Path[1:]) - if err != nil || len(f) == 0 { - indexHTML, _ := readFS.ReadFile("index.html") - - w.Header().Set("Content-Type", "text/html; charset=utf-8") - w.WriteHeader(http.StatusOK) - _, _ = w.Write(indexHTML) - - return - } - - fileServer.ServeHTTP(w, r) - }) + return } + + fileServer := http.FileServer(http.FS(distFS)) + + s.router.Get("/*", func(w http.ResponseWriter, r *http.Request) { + readFS, ok := distFS.(fs.ReadFileFS) + if !ok { + http.Error(w, "internal error", http.StatusInternalServerError) + + return + } + + f, err := readFS.ReadFile(r.URL.Path[1:]) + if err != nil || len(f) == 0 { + indexHTML, _ := readFS.ReadFile("index.html") + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(indexHTML) + + return + } + + fileServer.ServeHTTP(w, r) + }) }