Reviewed: 2026-03-20 | Reviewer: Claude (automated)
Scope: all source files | Standard: General (Code Quality, Performance, Go Best Practices)
Reference: research/TECHFILE.md for dependency and version context.
internal/cache/cache.go:27 -- The Get method performs an unchecked type assertion i.(uint) on the result of sync.Map.Load. If the key does not exist, i is nil and the assertion panics. While Exists is called before Get in the current codebase (message_input.go:499-500), there is no guarantee callers will always check first, and the race window between Exists and Get on a sync.Map means concurrent Invalidate could delete the key between the two calls, causing a runtime panic.
Fix: Change Get to return (uint, bool) or use a comma-ok assertion: if i, ok := c.items.Load(query); ok { return i.(uint) }; return 0. Update callers to handle the zero/missing case.
Implementation Risk: Low. Only message_input.go calls Get, and the change is a straightforward API update. The fix also closes a potential TOCTOU race.
internal/http/transport.go:28-32 -- When the response has Content-Encoding: br, the original resp.Body is wrapped with brotli.NewReader via io.NopCloser. This means calling Close() on the new body is a no-op and the original body's connection is never returned to the pool. For long-running applications like a Discord client, this can exhaust available connections over time.
Fix: Replace io.NopCloser(brotli.NewReader(resp.Body)) with a custom ReadCloser that wraps the brotli reader but delegates Close() to the original resp.Body. Example: type brotliReadCloser struct { io.Reader; closer io.Closer } with func (b *brotliReadCloser) Close() error { return b.closer.Close() }.
Implementation Risk: Low. The fix is a small wrapper struct. Verify with a test that the underlying body is closed when the brotli-wrapped response body is closed.
internal/notifications/notifications.go:93 -- http.Get(url) uses the default HTTP client which has no timeout. A slow or unresponsive Discord CDN server will block the goroutine indefinitely. Since notifications are triggered from gateway events, this could accumulate blocked goroutines during network issues.
Fix: Use http.Client{Timeout: 10 * time.Second} or create a request with context.WithTimeout and pass it to http.DefaultClient.Do(req). Apply the same fix to downloadToCache in messages_list.go:1191 which also uses bare http.Get.
Implementation Risk: Low. Adding a timeout may cause some avatar downloads to fail under poor network conditions, but this is preferable to indefinite blocking. Existing error handling already covers the failure path.
internal/logger/logger.go:24 -- The log file is opened with os.ModePerm (0777), meaning it is world-readable and world-writable. Log files may contain sensitive debugging information (user IDs, channel names, error messages). Similarly, internal/consts/consts.go:25 creates the cache directory with os.ModePerm.
Fix: Use 0600 for the log file (owner read/write only) and 0700 for the cache directory (owner only). Apply the same change to guildstate.go:45 which writes state with 0644 (already better but could be 0600 for consistency).
Implementation Risk: Low. Users running in shared environments benefit from restrictive permissions. Existing users may need to manually fix permissions on existing files if they switch.
internal/ui/chat/messages_list.go -- Extracted into focused files: (a) embed_renderer.go for embed types, styles, line wrapping, URL helpers, and markdown unescape; (b) attachment_handler.go for download, view, save, and overlay methods; (c) url_extractor.go for URL extraction from message content and embeds. The main file now focuses on message rendering and keybinds.
internal/ui/chat/guilds_tree.go:67-132 -- The ShortHelp() and FullHelp() methods contain nearly identical logic for determining the selectDesc string based on node state (lines 69-86 duplicated at 100-117). This 18-line block is copy-pasted.
Fix: Extract a helper method func (gt *guildsTree) selectActionDesc() string that returns "collapse", "expand", or the default description based on node state. Call it from both ShortHelp and FullHelp.
Implementation Risk: Low. Pure refactoring of display logic with no behavioral change.
internal/ui/chat/messages_list.go:1178-1187 and internal/ui/chat/messages_list.go:1318-1327 -- The code to show the attachments picker overlay is identical in showAttachmentsList and saveImage: create centered layer, add with overlay options, send to front, set focus. This 10-line pattern is duplicated verbatim.
Fix: Extract a method func (ml *messagesList) showAttachmentsOverlay() that handles the layer setup and focus. Call it from both showAttachmentsList and saveImage after setting items.
Implementation Risk: Low. Simple extraction of identical code.
internal/ui/chat/messages_list.go:1409 -- me, _ := ml.chatView.state.Cabinet.Me() discards the error. If the state is not ready, me is nil and me.ID will panic on the next line. This pattern appears in multiple places: state.go:195, messages_list.go:1446, messages_list.go:1539, messages_list.go:1563.
Fix: Handle the error case: me, err := ml.chatView.state.Cabinet.Me(); if err != nil { slog.Error(...); return }. Apply to all instances where Me() is called with a discarded error.
Implementation Risk: Low. Adding error checks prevents nil-pointer panics. The Me() call should always succeed after login, but defensive coding prevents crashes during edge-case race conditions (e.g., state reconnection).
internal/ui/chat/messages_list.go:670 -- [8]tcell.Style{} is a magic number that must manually stay in sync with the number of embedLineKind constants (lines 660-668). Adding a new embed line kind without updating the array size causes a silent bug (zero-value style).
Fix: Define a sentinel const embedLineKindCount = 8 after the last iota constant, or use a slice. Example: add embedLineKindCount as the last iota entry and use [embedLineKindCount]tcell.Style{}.
Implementation Risk: Low. Compile-time enforcement prevents future mismatches.
internal/ui/chat/guildstate.go:14-17 -- guildState uses sync.Mutex for all access, but isExpanded (line 61) is a read-only operation that could use RLock. Guild state is read frequently (every node creation during onReady) and written infrequently (user expand/collapse action).
Fix: Change mu sync.Mutex to mu sync.RWMutex and use gs.mu.RLock()/gs.mu.RUnlock() in isExpanded.
Implementation Risk: Low. Standard Go concurrency pattern upgrade.
Multiple files -- Fixed: keyring retrieval failure (root/events.go) and avatar cache failure (notifications.go) now log at slog.Warn instead of slog.Info. Presence lookup (message_input.go) remains at slog.Info as it is a normal condition. Added nil guard for Me() in state.go:onTypingStart to prevent potential panic.
cmd/root.go:29-39 -- The switch on logLevel has no default case. An invalid value like --log-level=trace silently results in slog.Level(0) which is slog.LevelInfo. The user gets no feedback that their log level was invalid.
Fix: Add a default case that either returns an error (return fmt.Errorf("unknown log level: %q", logLevel)) or logs a warning and falls back to info.
Implementation Risk: Low. Adds user feedback for misconfiguration.
Added package-level doc comments to internal/ui/chat/ (package comment in model.go) and internal/markdown/ (package comment in renderer.go). File-level doc comment added to events.go explaining the event/command architecture.
Added doc comment to internal/ui/chat/events.go explaining the command/event pattern: commands run off the main goroutine and return events dispatched to HandleEvent on the UI thread. Package-level doc in model.go describes the overall architecture.
Added package-level doc comment to internal/markdown/renderer.go explaining the AST→styled lines pipeline: goldmark AST walking, tcell style application for inline/block elements, and Discord-specific nodes (mentions, emoji, OSC 8 URLs).
Added validation comments to AutocompleteLimit and MessagesLimit fields in config.go. Added DISCORDO_TOKEN security warning to config.toml. Improved image_viewer_args documentation in config.toml.
internal/ui/chat/messages_list.go:58 -- itemByID map[discord.MessageID]*tview.TextView caches rendered message TextViews and is only cleared on channel switch (reset()) or when messages are modified. For channels with heavy traffic, this map grows without bound as new messages arrive. Each tview.TextView holds rendered line data, styles, and a text buffer. Over a long session in a busy channel, this can accumulate thousands of entries.
Fix: Add a size cap. When len(itemByID) > maxCacheSize (e.g., 500), evict entries for message IDs no longer in the current messages slice. Alternatively, clear the cache when it exceeds a threshold during addMessage.
Implementation Risk: Low-medium. Cache eviction during message additions may cause brief re-rendering of older messages if the user scrolls up. The visual impact is negligible since re-rendering is the fallback path.
internal/ui/chat/guilds_tree.go:380-395 -- collapseParentNode walks the entire tree from root to find the parent of the given node, even though GetPath(node) (used in canCollapseParent) returns the full path including the parent. The O(n) walk runs on every - keypress.
Fix: Use gt.GetPath(node) to get the parent directly: path := gt.GetPath(node); if len(path) >= 3 { parent := path[len(path)-2]; ... }. This is O(depth) instead of O(total nodes).
Implementation Risk: Low. GetPath is already used in canCollapseParent and expandPathToNode, so it is a known-reliable method. Verify the path includes the root node as the first element.
internal/ui/chat/guilds_tree.go:134-145 -- canCollapseParent is called from both ShortHelp() and FullHelp(), which run on every draw cycle to update the help bar. Each call invokes gt.GetPath(node) which walks the tree. Combined with ShortHelp and FullHelp duplicating the node-state checks, the help bar triggers 2+ tree walks per render frame.
Fix: Cache the path result for the current node, or compute canCollapseParent once and store it. Since the current node only changes on explicit user navigation, the cached value remains valid between navigation events.
Implementation Risk: Low. Cache invalidation happens naturally when SetCurrentNode is called.
internal/ui/chat/messages_list.go:1094-1115 and internal/ui/chat/messages_list.go:511-519 -- When rendering a message with embeds, drawEmbeds calls extractURLs(message.Content) to build a dedup set, and then drawContent also parses the same content through discordmd.ParseWithMessage for markdown rendering. The goldmark parser is invoked twice on the same content. For messages with complex markdown, this is measurable.
Fix: Parse content once and pass both the rendered lines and the extracted URLs to the embed renderer. This requires restructuring drawDefaultMessage to call a combined parse+render function that returns both outputs.
Implementation Risk: Medium. The rendering pipeline currently separates content rendering from embed rendering. Merging them requires careful handling of the forceMarkdown flag used in embed descriptions.
.github/workflows/ci.yml:25 -- As documented in research/TECHFILE.md (finding #5), the CI uses go-version: stable which auto-advances when Go releases a new version. This can introduce unexpected breakage from new vet rules or behavior changes.
Fix: Pin to go-version: "1.26" or "1.26.x". Update intentionally when upgrading.
Implementation Risk: None from pinning. The risk is the current unpinned behavior.
.github/workflows/ci.yml -- The CI pipeline only runs go test and go build. There is no linter (golangci-lint, staticcheck, or go vet beyond what go build implicitly checks). Issues like unused variables, shadowed errors, and inefficient patterns are not caught in CI.
Fix: Add a lint step using golangci-lint with a .golangci.yml config. Start with default linters (vet, errcheck, staticcheck, unused) and gradually enable more.
Implementation Risk: Low for adding the step. Initial lint run may produce many findings that need to be triaged. Use --new-from-rev=HEAD~1 for incremental enforcement during transition.
.github/workflows/ci.yml:41-43 -- CI uploads artifact named discordo_$OS_$ARCH but the binary is discordo (or discordo.exe). The project README and CLAUDE.md refer to the binary as discordo-plus. There is a naming inconsistency between the upstream module path (github.com/ayn2op/discordo), the CI artifact name, and the intended installation name.
Fix: Update the build step to go build -trimpath -ldflags=-s -o discordo-plus . (or discordo-plus.exe on Windows) to match the documented binary name. Update the artifact upload to reference the correct file.
Implementation Risk: Low. Downstream users of CI artifacts will see the renamed binary. Scoop/AUR packages may need path updates.
internal/config/config_test.go, internal/keyring/keyring_test.go -- Out of 12 internal packages, only 2 have any tests. Zero test coverage for: markdown rendering, clipboard operations, HTTP transport, notifications, cache, guild state persistence, UI components, embed processing, URL extraction, and the entire event handling pipeline.
Critical untested code paths:
cache.go:Get -- can panic (finding #1)transport.go -- body leak (finding #2)guildstate.go -- JSON marshal/unmarshal roundtripmessages_list.go -- extractURLs, messageURLs, wrapStyledLine, unescapeMarkdownEscapes, linkDisplayText, sameLocalDate are all pure functions ideal for unit testingconfig/theme.go -- TOML unmarshaling for all wrapper typesutil.go -- MergeStyle, SortGuildChannels, ChannelToStringFix: Prioritize tests for: (a) pure functions in messages_list.go (URL extraction, wrapping, escaping), (b) cache.go roundtrip and race conditions, (c) guildstate.go persistence roundtrip, (d) theme.go unmarshaling edge cases. These require no mocking and have clear input/output contracts.
Implementation Risk: None. Tests are additive.
%w is used consistently in config loading, HTTP transport, and attachment handling.//go:embed, eliminating runtime file-not-found failures.time.AfterFunc with cleanup, preventing timer leaks.selectedChannelMu RWMutex correctly protects the selected channel across goroutines.embedLineDedupKey) prevents duplicate content across embed fields.ConfigurePicker helper in util.go avoids duplicating picker setup across three picker types.wrapStyledLine correctly uses grapheme cluster width via uniseg for accurate wrapping with wide glyphs and emoji.| Severity | Total |
|---|---|
| Critical | 4 |
| Code Quality | 8 |
| Documentation Gaps | 4 |
| Performance | 4 |
| Infrastructure | 3 |
| Test Coverage | 1 |
| Compliance | N/A |
| # | File | Type |
|---|---|---|
| 1 | main.go |
Entry point |
| 2 | cmd/root.go |
CLI / app init |
| 3 | internal/config/config.go |
Config struct + loader |
| 4 | internal/config/config.toml |
Embedded default config |
| 5 | internal/config/config_test.go |
Config tests |
| 6 | internal/config/keybinds.go |
Keybind definitions |
| 7 | internal/config/theme.go |
Theme TOML unmarshaling |
| 8 | internal/config/editor.go |
Editor command (merged from editor_unix.go + editor_default.go) |
| 10 | internal/consts/consts.go |
App name + cache dir |
| 11 | internal/logger/logger.go |
slog file logger |
| 12 | internal/keyring/keyring.go |
Token keyring wrapper |
| 13 | internal/keyring/keyring_test.go |
Keyring tests |
| 14 | internal/cache/cache.go |
Autocomplete cache |
| 15 | internal/http/transport.go |
HTTP transport (gzip + brotli) |
| 16 | internal/http/client.go |
API client constructor |
| 17 | internal/http/headers.go |
Request headers |
| 18 | internal/http/props.go |
Discord identify properties |
| 19 | internal/notifications/notifications.go |
Notification dispatch |
| 20 | internal/notifications/desktop_toast.go |
Non-darwin notification |
| 21 | internal/notifications/desktop_toast_darwin.go |
macOS notification |
| 22 | internal/clipboard/clipboard.go |
Clipboard format types |
| 23 | internal/clipboard/clipboard_default.go |
Default clipboard impl |
| 24 | internal/clipboard/clipboard_wayland.go |
Wayland clipboard impl |
| 25 | internal/markdown/renderer.go |
Discord markdown renderer |
| 26 | internal/ui/util.go |
Shared UI helpers |
| 27 | internal/ui/root/model.go |
Root model + help overlay |
| 28 | internal/ui/root/events.go |
Root events (token, clipboard) |
| 29 | internal/ui/root/keybinds.go |
Root keymap |
| 30 | internal/ui/root/suspend_unix.go |
Unix SIGTSTP suspend |
| 31 | internal/ui/root/suspend_default.go |
No-op suspend |
| 32 | internal/ui/chat/model.go |
Chat model + layout |
| 33 | internal/ui/chat/state.go |
Gateway state commands |
| 34 | internal/ui/chat/events.go |
Chat event handlers |
| 35 | internal/ui/chat/keybinds.go |
Chat keymap |
| 36 | internal/ui/chat/guilds_tree.go |
Guild/channel tree |
| 37 | internal/ui/chat/guildstate.go |
Guild expand persistence |
| 38 | internal/ui/chat/messages_list.go |
Message rendering + keybinds |
| 38a | internal/ui/chat/attachment_handler.go |
Attachment download/view/save |
| 38b | internal/ui/chat/embed_renderer.go |
Embed types + line wrapping |
| 38c | internal/ui/chat/url_extractor.go |
URL extraction from messages |
| 39 | internal/ui/chat/message_input.go |
Message input + mentions |
| 40 | internal/ui/chat/attachments_picker.go |
Attachment picker |
| 41 | internal/ui/chat/channels_picker.go |
Channel picker |
| 42 | internal/ui/chat/mentions_list.go |
Mentions autocomplete list |
| 43 | internal/ui/chat/util.go |
Picker config + humanJoin |
| 44 | internal/ui/login/model.go |
Login model |
| 45 | internal/ui/login/events.go |
Login events |
| 46 | internal/ui/login/keybinds.go |
Login keymap |
| 47 | internal/ui/login/token/model.go |
Token login form |
| 48 | internal/ui/login/token/events.go |
Token events |
| 49 | internal/ui/login/qr/model.go |
QR login model |
| 50 | internal/ui/login/qr/events.go |
QR login events + crypto |
| 51 | go.mod |
Module manifest |
| 52 | .github/workflows/ci.yml |
CI configuration |
| 53 | .gitignore |
Git ignore rules |
| 54 | README.md |
Project readme |
| 55 | CLAUDE.md |
Project context |
| 56 | internal/config/config.toml |
Default config (embedded) |