Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,17 @@ auto-scrolled to the cheat's heading. `↑/↓`/`PgUp/PgDn` scroll, `Esc` or `q`
returns. Useful for reading the surrounding notes (descriptions, links,
warnings) without leaving the TUI.

### Execution history

Every time you run a cheat, the final substituted command plus the cheat's
file/header reference and the resolved variable values are appended to
`$XDG_DATA_HOME/cheatmd/history.jsonl` (falling back to
`~/.local/share/cheatmd/history.jsonl`). Press `Ctrl-H` in the cheat picker
to open the history overlay, or launch directly with `cheatmd --history`.
Pick an entry with `Enter` to re-open the original cheat with its previous
values pre-filled, so you can confirm or edit any variable before running
again. `Esc` cancels.

## DSL

```
Expand Down
10 changes: 10 additions & 0 deletions cheatmd.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,16 @@ key_substitute: "ctrl+t"
# key_preview: opens a full-screen markdown preview of the current cheat's
# source file, scrolled to the cheat's section. Esc / q closes.
key_preview: "ctrl+y"
# key_history: opens the execution history picker. Enter on an entry re-opens
# the original cheat with its previous variable values pre-filled.
key_history: "ctrl+h"

# Execution history. Each cheat run appends a line to history_file as JSONL.
# history_file - path override; empty means $XDG_DATA_HOME/cheatmd/history.jsonl
# (or ~/.local/share/cheatmd/history.jsonl as a fallback).
# history_max - max entries shown in the picker; 0 = unlimited.
history_file: ""
history_max: 1000

# Substitute search sources. Valid entries: "env", "history".
# Empty list disables the feature.
Expand Down
6 changes: 5 additions & 1 deletion cmd/cheatmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ func init() {
rootCmd.PersistentFlags().Bool("exec", false, "Execute command (shorthand for -o exec)")
rootCmd.PersistentFlags().Bool("auto", false, "Auto-select if query matches exactly one result")
rootCmd.PersistentFlags().BoolP("benchmark", "b", false, "Benchmark load time and exit")
rootCmd.PersistentFlags().Bool("history", false, "Open the execution history picker")

viper.BindPFlag("output", rootCmd.PersistentFlags().Lookup("output"))
}
Expand Down Expand Up @@ -276,7 +277,10 @@ func runCheats(cmd *cobra.Command, args []string) error {
return nil
}

// Run the TUI
// Run the TUI (history view if --history was passed)
if historyFlag, _ := cmd.Flags().GetBool("history"); historyFlag {
return ui.RunHistory(index, exec)
}
return ui.Run(index, exec, query, match)
}

Expand Down
34 changes: 34 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ type Config struct {
KeyOpen string `mapstructure:"key_open"`
KeySubstitute string `mapstructure:"key_substitute"`
KeyPreview string `mapstructure:"key_preview"`
KeyHistory string `mapstructure:"key_history"`

// Execution history
HistoryFile string `mapstructure:"history_file"`
HistoryMax int `mapstructure:"history_max"`

// Substitute search
SubstituteSources []string `mapstructure:"substitute_sources"`
Expand Down Expand Up @@ -88,6 +93,9 @@ var defaults = struct {
keyOpen string
keySubstitute string
keyPreview string
keyHistory string
historyFile string
historyMax int
substituteSources []string
showFolder bool
showFile bool
Expand All @@ -110,6 +118,9 @@ var defaults = struct {
keyOpen: "ctrl+o", // Ctrl+O in TUI
keySubstitute: "ctrl+t", // Ctrl+T opens substitute search during var resolution
keyPreview: "ctrl+y", // Ctrl+Y opens markdown preview of current cheat's file
keyHistory: "ctrl+h", // Ctrl+H opens execution history
historyFile: "", // Empty -> $XDG_DATA_HOME/cheatmd/history.jsonl
historyMax: 1000,
substituteSources: []string{"env", "history"},
showFolder: true,
showFile: true,
Expand Down Expand Up @@ -177,6 +188,11 @@ func setDefaults() {
viper.SetDefault("key_open", defaults.keyOpen)
viper.SetDefault("key_substitute", defaults.keySubstitute)
viper.SetDefault("key_preview", defaults.keyPreview)
viper.SetDefault("key_history", defaults.keyHistory)

// Execution history
viper.SetDefault("history_file", defaults.historyFile)
viper.SetDefault("history_max", defaults.historyMax)

// Substitute search
viper.SetDefault("substitute_sources", defaults.substituteSources)
Expand Down Expand Up @@ -330,6 +346,24 @@ func GetKeyPreview() string {
return viper.GetString("key_preview")
}

// GetKeyHistory returns the keybinding for opening the execution history
// overlay (e.g., "ctrl+h").
func GetKeyHistory() string {
return viper.GetString("key_history")
}

// GetHistoryFile returns the override path for the history file, or "" for
// the default ($XDG_DATA_HOME/cheatmd/history.jsonl).
func GetHistoryFile() string {
return viper.GetString("history_file")
}

// GetHistoryMax returns the cap on history entries shown in the picker.
// Zero or negative means unlimited.
func GetHistoryMax() int {
return viper.GetInt("history_max")
}

// GetSubstituteSources returns the enabled sources for substitute search.
// Valid entries: "env", "history". Empty disables the feature.
func GetSubstituteSources() []string {
Expand Down
128 changes: 128 additions & 0 deletions internal/history/history.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
// Package history records and reads the user's cheat execution history.
//
// Entries are stored newline-delimited JSON in $XDG_DATA_HOME/cheatmd/history.jsonl
// (falling back to ~/.local/share/cheatmd/history.jsonl). Each entry captures
// the final substituted command plus the source cheat reference and the
// resolved scope, so re-running can re-prefill variables.
package history

import (
"bufio"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"time"
)

// Entry is one recorded execution of a cheat.
type Entry struct {
Timestamp time.Time `json:"ts"`
Command string `json:"cmd"`
File string `json:"file"`
Header string `json:"header"`
Scope map[string]string `json:"scope,omitempty"`
}

// DefaultPath returns the canonical history file path. The override is used
// verbatim if non-empty; otherwise $XDG_DATA_HOME/cheatmd/history.jsonl is
// preferred, with ~/.local/share/cheatmd/history.jsonl as the fallback.
func DefaultPath(override string) (string, error) {
if override = strings.TrimSpace(override); override != "" {
if strings.HasPrefix(override, "~/") {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, override[2:]), nil
}
return override, nil
}
if xdg := strings.TrimSpace(os.Getenv("XDG_DATA_HOME")); xdg != "" {
return filepath.Join(xdg, "cheatmd", "history.jsonl"), nil
}
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, ".local", "share", "cheatmd", "history.jsonl"), nil
}

// Append writes one entry to the history file. The file and any parent
// directories are created on demand. Errors writing history are non-fatal
// to the caller; surface them only for logging/diagnostics.
func Append(path string, e Entry) error {
if e.Timestamp.IsZero() {
e.Timestamp = time.Now()
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
if err != nil {
return err
}
defer f.Close()
enc := json.NewEncoder(f)
enc.SetEscapeHTML(false)
return enc.Encode(e)
}

// Load returns up to maxEntries most-recent entries from path, newest first.
// A missing file is not an error; an empty slice is returned.
func Load(path string, maxEntries int) ([]Entry, error) {
f, err := os.Open(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
return nil, err
}
defer f.Close()

entries := make([]Entry, 0, 256)
scanner := bufio.NewScanner(f)
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for scanner.Scan() {
line := scanner.Bytes()
if len(line) == 0 {
continue
}
var e Entry
if err := json.Unmarshal(line, &e); err != nil {
continue // skip corrupt lines silently
}
entries = append(entries, e)
}
if err := scanner.Err(); err != nil && !errors.Is(err, io.EOF) {
return entries, err
}

// Newest first.
for i, j := 0, len(entries)-1; i < j; i, j = i+1, j-1 {
entries[i], entries[j] = entries[j], entries[i]
}
if maxEntries > 0 && len(entries) > maxEntries {
entries = entries[:maxEntries]
}
return entries, nil
}

// Display renders an entry as a single line for picker display. Long commands
// are truncated to keep each row to one screen line.
func (e Entry) Display(maxWidth int) string {
ts := e.Timestamp.Local().Format("2006-01-02 15:04")
cmd := strings.ReplaceAll(e.Command, "\n", " ")
prefix := fmt.Sprintf("%s ", ts)
avail := maxWidth - len(prefix)
if avail < 10 {
avail = 10
}
if len(cmd) > avail {
cmd = cmd[:avail-1] + "…"
}
return prefix + cmd
}
5 changes: 5 additions & 0 deletions internal/ui/cheat_select.go
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,11 @@ func (m *mainModel) handleCheatSelectKey(msg tea.KeyMsg) tea.Cmd {
}
}
}
if msg.String() == config.GetKeyHistory() {
if m.enterHistory() {
return tea.ClearScreen
}
}
}
return nil
}
Expand Down
Loading