Selaa lähdekoodia

feat(ui/chat): persist guild expand state and focus messages on channel select

- Save expanded/collapsed guild state to ~/.cache/discordo/state.json
  so guilds stay expanded across restarts
- On channel select with AutoFocus, focus messages list instead of
  message input
- Minor: add toml:"-" tag to Config.Path, nil guard for empty editor

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
claude 1 kuukausi sitten
vanhempi
sitoutus
8645bcaf97

+ 10 - 2
CLAUDE.md

@@ -24,7 +24,7 @@ This is the persistent context file for Claude Code. Keep it concise and useful.
 - Discord API: `arikawa/v3` + `ningen/v3` (state management + Discord markdown)
 - Markdown: `goldmark` parser + `chroma/v2` syntax highlighting
 - Config: TOML via `BurntSushi/toml`, file at `~/.config/discordo/config.toml`
-- Cache: `~/.cache/discordo/` (attachments, logs)
+- Cache: `~/.cache/discordo/` (attachments, logs, state)
 
 ## Architecture
 - `main.go` → `cmd/root.go` (app init) → `internal/ui/` (TUI layers)
@@ -32,13 +32,14 @@ This is the persistent context file for Claude Code. Keep it concise and useful.
 - `internal/markdown/renderer.go` — AST→styled lines, handles Discord markdown flavors
 - `internal/config/` — `config.go` (struct + loader), `config.toml` (defaults, embedded), `keybinds.go`, `theme.go`
 - `internal/consts/` — app name, cache dir
+- `internal/ui/chat/guildstate.go` — persists guild expand/collapse state to `~/.cache/discordo/state.json`
 - Rendering pipeline: messages → `tview.LineBuilder` → `[]tview.Line` (segments with `tcell.Style`)
 - URLs get `style.Url(rawURL)` metadata for OSC 8 terminal hyperlinks
 - External commands (editor, image viewer) use `app.Suspend()` pattern — suspends TUI, runs command, resumes
 
 ## Key Patterns
 - Keybinds: defined in `config/keybinds.go` structs, matched in `HandleEvent()` via `keybind.Matches()`
-- Help tooltips: `ShortHelp()` (bottom bar, contextual) and `FullHelp()` (full overlay via ctrl+.)
+- Help tooltips: `ShortHelp()` (bottom bar, contextual) and `FullHelp()` (full overlay via `?`)
 - Attachments: downloaded to `~/.cache/discordo/attachments/`, opened via configured viewer or `open.Start()`
 - Embeds: rendered with `▎` bar prefix, wrapped to viewport width, markdown in descriptions
 - Config defaults embedded via `//go:embed config.toml`
@@ -50,11 +51,18 @@ This is the persistent context file for Claude Code. Keep it concise and useful.
 - **Attachment URL fix**: split `builder.Write(filename+"\n"+url)` into proper `NewLine()` + `.Url()` style
 - **Supported image types**: jpeg, png, webp, gif (via `supportedImageTypes` map)
 - **ShortHelp**: `o` (open) now shows in bottom tooltip bar when message has attachments/URLs
+- **Help keybind**: changed from `ctrl+.` to `?` — `ctrl+.` not reliably handled by terminals
+- **Edit config**: `E` keybind (only active in help overlay) opens config in `$EDITOR`/vim via `app.Suspend()`
+- **Editor default**: falls back to `vim` when `editor = "default"` and `$EDITOR` is unset
+- **Guild state persistence**: expanded/collapsed guild state saved to `~/.cache/discordo/state.json`, restored on launch via `guildstate.go`
+- **Focus on channel select**: AutoFocus now targets messages list instead of message input when selecting a channel
 
 ## Config Fields We Added
 - `image_viewer` — external image viewer command (default: `"mpv"`, `"default"` = system opener)
 - `image_save_dir` — directory for saved images (supports `~/`, default: current dir)
 - `keybinds.messages_list.save_image` — save image keybind (default: `S`)
+- `keybinds.edit_config` — open config in editor from help overlay (default: `E`)
+- `keybinds.toggle_help` — changed default from `ctrl+.` to `?`
 
 ## Build & Run
 - Build: `go build -o discordo-plus .`

+ 2 - 0
README.md

@@ -8,6 +8,8 @@ A fork of [discordo](https://github.com/ayn2op/discordo) — a lightweight Disco
 - **Save image**: `S` keybind saves the selected message's image attachment to a configurable directory
 - **Attachment URL fix**: Links no longer break due to bad newline handling
 - **Tooltip**: `o open` appears in the bottom help bar when a message has attachments or URLs
+- **Guild state persistence**: Remembers which guilds are expanded/collapsed between sessions (saved to `~/.cache/discordo/state.json`)
+- **Focus on channel select**: When AutoFocus is enabled, selecting a channel focuses the messages list instead of the message input
 
 ## Building on Arch Linux
 

+ 1 - 1
internal/config/config.go

@@ -86,7 +86,7 @@ type (
 	}
 
 	Config struct {
-		Path string // set programmatically, not from TOML
+		Path string `toml:"-"` // set programmatically, not from TOML
 
 		AutoFocus bool   `toml:"auto_focus"`
 		Mouse     bool   `toml:"mouse"`

+ 3 - 0
internal/config/editor_default.go

@@ -7,5 +7,8 @@ import (
 )
 
 func (cfg *Config) CreateEditorCommand(path string) *exec.Cmd {
+	if cfg.Editor == "" {
+		return nil
+	}
 	return exec.Command(cfg.Editor, path)
 }

+ 13 - 1
internal/ui/chat/guilds_tree.go

@@ -30,6 +30,8 @@ type guildsTree struct {
 	guildNodeByID   map[discord.GuildID]*tview.TreeNode
 	channelNodeByID map[discord.ChannelID]*tview.TreeNode
 	dmRootNode      *tview.TreeNode
+
+	guildState *guildState
 }
 
 var _ help.KeyMap = (*guildsTree)(nil)
@@ -42,6 +44,8 @@ func newGuildsTree(cfg *config.Config, chatView *Model) *guildsTree {
 
 		guildNodeByID:   make(map[discord.GuildID]*tview.TreeNode),
 		channelNodeByID: make(map[discord.ChannelID]*tview.TreeNode),
+
+		guildState: loadGuildState(),
 	}
 
 	gt.Box = ui.ConfigureBox(gt.Box, &cfg.Theme)
@@ -193,10 +197,11 @@ func (gt *guildsTree) getChannelNodeStyle(channelID discord.ChannelID) tcell.Sty
 }
 
 func (gt *guildsTree) createGuildNode(n *tview.TreeNode, guild discord.Guild) {
+	expanded := gt.guildState.isExpanded(guild.ID)
 	guildNode := tview.NewTreeNode(guild.Name).
 		SetReference(guild.ID).
 		SetExpandable(true).
-		SetExpanded(false).
+		SetExpanded(expanded).
 		SetIndent(gt.cfg.Theme.GuildsTree.Indents.Guild)
 	gt.setNodeLineStyle(guildNode, gt.getGuildNodeStyle(guild.ID))
 	n.AddChild(guildNode)
@@ -280,6 +285,9 @@ func (gt *guildsTree) createChannelNodes(node *tview.TreeNode, channels []discor
 func (gt *guildsTree) onSelected(node *tview.TreeNode) tview.Command {
 	if len(node.GetChildren()) != 0 {
 		node.SetExpanded(!node.IsExpanded())
+		if guildID, ok := node.GetReference().(discord.GuildID); ok {
+			go gt.guildState.setExpanded(guildID, node.IsExpanded())
+		}
 		return nil
 	}
 
@@ -296,6 +304,7 @@ func (gt *guildsTree) onSelected(node *tview.TreeNode) tview.Command {
 		ui.SortGuildChannels(channels)
 		gt.createChannelNodes(node, channels)
 		node.Expand()
+		go gt.guildState.setExpanded(ref, true)
 		return nil
 	case discord.ChannelID:
 		channel, err := gt.chat.state.Cabinet.Channel(ref)
@@ -374,6 +383,9 @@ func (gt *guildsTree) collapseParentNode(node *tview.TreeNode) {
 		Walk(func(n, parent *tview.TreeNode) bool {
 			if n == node && parent.GetLevel() != 0 {
 				parent.Collapse()
+				if guildID, ok := parent.GetReference().(discord.GuildID); ok {
+					go gt.guildState.setExpanded(guildID, false)
+				}
 				gt.SetCurrentNode(parent)
 				return false
 			}

+ 65 - 0
internal/ui/chat/guildstate.go

@@ -0,0 +1,65 @@
+package chat
+
+import (
+	"encoding/json"
+	"log/slog"
+	"os"
+	"path/filepath"
+	"sync"
+
+	"github.com/ayn2op/discordo/internal/consts"
+	"github.com/diamondburned/arikawa/v3/discord"
+)
+
+type guildState struct {
+	ExpandedGuilds map[discord.GuildID]bool `json:"expanded_guilds"`
+	mu             sync.Mutex
+}
+
+var stateFilePath = filepath.Join(consts.CacheDir(), "state.json")
+
+func loadGuildState() *guildState {
+	gs := &guildState{ExpandedGuilds: make(map[discord.GuildID]bool)}
+	data, err := os.ReadFile(stateFilePath)
+	if err != nil {
+		return gs
+	}
+	if err := json.Unmarshal(data, gs); err != nil {
+		slog.Warn("failed to parse guild state", "err", err)
+		return &guildState{ExpandedGuilds: make(map[discord.GuildID]bool)}
+	}
+	if gs.ExpandedGuilds == nil {
+		gs.ExpandedGuilds = make(map[discord.GuildID]bool)
+	}
+	return gs
+}
+
+func (gs *guildState) save() {
+	gs.mu.Lock()
+	defer gs.mu.Unlock()
+	data, err := json.Marshal(gs)
+	if err != nil {
+		slog.Error("failed to marshal guild state", "err", err)
+		return
+	}
+	if err := os.WriteFile(stateFilePath, data, 0644); err != nil {
+		slog.Error("failed to write guild state", "err", err)
+	}
+}
+
+func (gs *guildState) setExpanded(id discord.GuildID, expanded bool) {
+	gs.mu.Lock()
+	if expanded {
+		gs.ExpandedGuilds[id] = true
+	} else {
+		delete(gs.ExpandedGuilds, id)
+	}
+	gs.mu.Unlock()
+	gs.save()
+}
+
+func (gs *guildState) isExpanded(id discord.GuildID) bool {
+	gs.mu.Lock()
+	defer gs.mu.Unlock()
+	return gs.ExpandedGuilds[id]
+}

+ 1 - 1
internal/ui/chat/model.go

@@ -304,7 +304,7 @@ func (m *Model) HandleEvent(event tview.Event) tview.Command {
 		if hasNoPerm {
 			text = "You do not have permission to send messages in this channel."
 		} else if m.cfg.AutoFocus {
-			focusCommand = m.focusMessageInput()
+			focusCommand = m.focusMessagesList()
 		}
 		m.messageInput.SetPlaceholder(tview.NewLine(tview.NewSegment(text, tcell.StyleDefault.Dim(true))))
 		return focusCommand

+ 14 - 0
internal/ui/chat/state.go

@@ -5,6 +5,7 @@ import (
 	"slices"
 
 	"github.com/ayn2op/discordo/internal/notifications"
+	"github.com/ayn2op/discordo/internal/ui"
 	"github.com/ayn2op/tview"
 	"github.com/diamondburned/arikawa/v3/discord"
 	"github.com/diamondburned/arikawa/v3/gateway"
@@ -91,6 +92,19 @@ func (m *Model) onReady(event *gateway.ReadyEvent) {
 		}
 	}
 
+	// Restore channels for guilds that were previously expanded.
+	for guildID, guildNode := range m.guildsTree.guildNodeByID {
+		if m.guildsTree.guildState.isExpanded(guildID) && len(guildNode.GetChildren()) == 0 {
+			channels, err := m.state.Cabinet.Channels(guildID)
+			if err != nil {
+				slog.Error("failed to restore guild channels", "err", err, "guild_id", guildID)
+				continue
+			}
+			ui.SortGuildChannels(channels)
+			m.guildsTree.createChannelNodes(guildNode, channels)
+		}
+	}
+
 	m.guildsTree.SetCurrentNode(root)
 	m.app.SetFocus(m.guildsTree)
 }