e38cce5f1f
Bundles the pending TUI work into a coherent batch. Bug fixes from external review: * expandPlaceholders: single-pass alternation regex over the original input prevents `#p\d+` / `#img\d+` tokens inside pasted content from being re-expanded after the bracket form is inlined. * /incognito: gate savePromptHistory and the Ctrl+V image-write branch on `!m.incognito` so the no-persistence contract holds. * history.txt: write at mode 0600 (chmod existing 0644 files), create parent dir at 0700, truncate to 500 entries on every save, slog.Warn on errors instead of swallowing. * triggerPickerAction: guard m.config.Engine before SetModel, matching the /model handler. * Picker key handler: navigation/enter/q consume, escape/ctrl+c close the picker AND fall through to global handlers (so streaming cancel and double-tap quit work with an overlay open), default swallows stray input. * Paste line count: report total non-empty lines instead of newline count, ignoring trailing newlines (no more "+0 lines" for "abc"). * Ctrl+O restored to expand-output; Ctrl+Y is the new copy-response bind. /keys help text updated; picker help entries reordered. * Tighter perms on .gnoma/pasted_image_*.png (0600). Race-safety refactor: ApplyTheme used to mutate ~25 package-level lipgloss styles in place. Replaced with an immutable themeStyles snapshot and atomic.Pointer[themeStyles] swap. Readers go through a theme() helper (one atomic load) instead of touching package vars directly. No locks, no nested-RLock risk if rendering ever moves off-thread. Includes pre-existing in-flight work: TUISection in config with persistent theme/vim settings; /copy /theme /vim slash commands; provider-name completion; session.SetProvider for the provider picker. Tests: placeholder_test.go (6 regression + happy-path cases including the pasted-content collision), history_test.go (5 cases covering perms on new and existing files, on-disk truncation, blank-input, newline flattening), provider_test.go (provider switching + picker transitions + SLM gating).
126 lines
3.2 KiB
Go
126 lines
3.2 KiB
Go
package tui
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// stageHistoryDir redirects GlobalConfigDir() to t.TempDir() by overriding
|
|
// XDG_CONFIG_HOME. Returns the resolved ~/.config/gnoma path.
|
|
func stageHistoryDir(t *testing.T) string {
|
|
t.Helper()
|
|
root := t.TempDir()
|
|
t.Setenv("XDG_CONFIG_HOME", root)
|
|
return filepath.Join(root, "gnoma")
|
|
}
|
|
|
|
func TestSavePromptHistory_WritesFileWithRestrictivePerms(t *testing.T) {
|
|
dir := stageHistoryDir(t)
|
|
|
|
savePromptHistory("first prompt")
|
|
|
|
path := filepath.Join(dir, "history.txt")
|
|
info, err := os.Stat(path)
|
|
if err != nil {
|
|
t.Fatalf("history file not created: %v", err)
|
|
}
|
|
if mode := info.Mode().Perm(); mode != 0o600 {
|
|
t.Errorf("history file mode = %o, want 0600", mode)
|
|
}
|
|
}
|
|
|
|
func TestSavePromptHistory_RewritesExistingFileTo0600(t *testing.T) {
|
|
dir := stageHistoryDir(t)
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
path := filepath.Join(dir, "history.txt")
|
|
if err := os.WriteFile(path, []byte("old entry\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
savePromptHistory("new entry")
|
|
|
|
info, err := os.Stat(path)
|
|
if err != nil {
|
|
t.Fatalf("stat failed: %v", err)
|
|
}
|
|
if mode := info.Mode().Perm(); mode != 0o600 {
|
|
t.Errorf("history file mode = %o, want 0600 after rewrite", mode)
|
|
}
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.Contains(string(data), "old entry") {
|
|
t.Error("rewrite dropped previously stored entry")
|
|
}
|
|
if !strings.Contains(string(data), "new entry") {
|
|
t.Error("rewrite missing newly appended entry")
|
|
}
|
|
}
|
|
|
|
func TestSavePromptHistory_TruncatesToLast500Entries(t *testing.T) {
|
|
dir := stageHistoryDir(t)
|
|
|
|
// Save 600 entries.
|
|
for i := 0; i < 600; i++ {
|
|
savePromptHistory(fmt.Sprintf("entry-%d", i))
|
|
}
|
|
|
|
// On-disk file must also be capped (not just the loaded view).
|
|
data, err := os.ReadFile(filepath.Join(dir, "history.txt"))
|
|
if err != nil {
|
|
t.Fatalf("read failed: %v", err)
|
|
}
|
|
onDiskLines := strings.Count(strings.TrimRight(string(data), "\n"), "\n") + 1
|
|
if onDiskLines > 500 {
|
|
t.Errorf("on-disk history has %d lines, want ≤500", onDiskLines)
|
|
}
|
|
|
|
got := loadPromptHistory()
|
|
if len(got) > 500 {
|
|
t.Errorf("history length = %d, want ≤500 after 600 writes", len(got))
|
|
}
|
|
if len(got) == 0 {
|
|
t.Fatal("history unexpectedly empty")
|
|
}
|
|
// Most recent entry should be the last one written.
|
|
if got[len(got)-1] != "entry-599" {
|
|
t.Errorf("last entry = %q, want entry-599", got[len(got)-1])
|
|
}
|
|
// Oldest retained entry should be entry-100 (600-500).
|
|
if got[0] != "entry-100" {
|
|
t.Errorf("first entry = %q, want entry-100", got[0])
|
|
}
|
|
}
|
|
|
|
func TestSavePromptHistory_IgnoresBlankInput(t *testing.T) {
|
|
dir := stageHistoryDir(t)
|
|
|
|
savePromptHistory("")
|
|
savePromptHistory(" \n\t ")
|
|
|
|
path := filepath.Join(dir, "history.txt")
|
|
if _, err := os.Stat(path); err == nil {
|
|
t.Error("blank input should not create history file")
|
|
}
|
|
}
|
|
|
|
func TestSavePromptHistory_NewlinesFlattenedToSpace(t *testing.T) {
|
|
stageHistoryDir(t)
|
|
|
|
savePromptHistory("line one\nline two")
|
|
|
|
got := loadPromptHistory()
|
|
if len(got) != 1 {
|
|
t.Fatalf("history length = %d, want 1", len(got))
|
|
}
|
|
if got[0] != "line one line two" {
|
|
t.Errorf("got %q, want 'line one line two'", got[0])
|
|
}
|
|
}
|