├── .gitignore ├── .github ├── FUNDING.yml ├── workflows │ ├── test.yaml │ └── release.yml └── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md ├── tapes ├── nap-list.tape ├── fuzzy-find.tape ├── nap-gum.tape ├── nap-save.tape └── nap-demo.tape ├── editor.go ├── snap └── snapcraft.yaml ├── state.go ├── editor_test.go ├── go.mod ├── snippet.go ├── keys.go ├── config.go ├── main_test.go ├── list.go ├── README.md ├── go.sum ├── style.go ├── main.go └── model.go /.gitignore: -------------------------------------------------------------------------------- 1 | tapes/*.gif 2 | /nap* -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [maaslalani] 2 | -------------------------------------------------------------------------------- /tapes/nap-list.tape: -------------------------------------------------------------------------------- 1 | Output tapes/nap-list.gif 2 | 3 | Set FontSize 24 4 | 5 | Type "nap list" 6 | Sleep .5 7 | Enter 8 | 9 | Sleep 3 10 | -------------------------------------------------------------------------------- /tapes/fuzzy-find.tape: -------------------------------------------------------------------------------- 1 | Output tapes/fuzzy-find.gif 2 | 3 | Set FontSize 32 4 | Set Height 400 5 | 6 | Type "nap date format" 7 | Sleep .5 8 | Enter 9 | 10 | Sleep 3 11 | -------------------------------------------------------------------------------- /tapes/nap-gum.tape: -------------------------------------------------------------------------------- 1 | Output tapes/nap-gum.gif 2 | 3 | Set FontSize 32 4 | 5 | Type "nap $(nap list | gum filter)" Sleep .5 Enter 6 | Sleep 1 7 | Type "Bayes' Theo" 8 | Sleep .5 9 | Enter 10 | Sleep 3 11 | -------------------------------------------------------------------------------- /tapes/nap-save.tape: -------------------------------------------------------------------------------- 1 | Output tapes/nap-save.gif 2 | 3 | Set FontSize 32 4 | Set Height 400 5 | 6 | Type "nap go/example.go < snippet.go" 7 | Sleep .5 8 | Enter 9 | Sleep 1 10 | Type "nap go/example.go" 11 | Sleep .5 12 | Enter 13 | 14 | Sleep 3 15 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: Test 2 | on: 3 | push: 4 | branches: [main] 5 | pull_request: 6 | branches: [main] 7 | 8 | jobs: 9 | test: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v3 13 | 14 | - name: Set up Go 15 | uses: actions/setup-go@v3 16 | with: 17 | go-version: '>=1.19' 18 | 19 | - name: Test 20 | run: go test -v ./... 21 | 22 | - name: Build 23 | run: go build -v 24 | -------------------------------------------------------------------------------- /editor.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | "os/exec" 6 | "strings" 7 | ) 8 | 9 | const defaultEditor = "nano" 10 | 11 | // Cmd returns a *exec.Cmd editing the given path with $EDITOR or nano if no 12 | // $EDITOR is set. 13 | func editorCmd(path string) *exec.Cmd { 14 | editor, args := getEditor() 15 | return exec.Command(editor, append(args, path)...) 16 | } 17 | 18 | func getEditor() (string, []string) { 19 | editor := strings.Fields(os.Getenv("EDITOR")) 20 | if len(editor) > 0 { 21 | return editor[0], editor[1:] 22 | } 23 | return defaultEditor, nil 24 | } 25 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /tapes/nap-demo.tape: -------------------------------------------------------------------------------- 1 | Output tapes/nap-demo.gif 2 | 3 | Set FontSize 16 4 | Set Padding 30 5 | 6 | Hide 7 | Type "export NAP_HOME=.nap" Enter 8 | Type `echo -e 'package main\n\nimport "fmt"\n\nfunc main() {\n\t// Welcome to Nap!\n\tfmt.Println("Manage code snippets in the terminal")\n}' | pbcopy` Enter 9 | Type "clear" Enter 10 | Show 11 | 12 | Sleep 0.5s 13 | Type "nap" 14 | Enter 15 | Sleep 1s 16 | Down@1.5s 2 17 | Sleep 1s 18 | Up@1s 2 19 | Sleep 1s 20 | 21 | Type "n" 22 | Sleep 2.5s 23 | Type "r" 24 | Sleep 1s 25 | Type "Nap" 26 | Enter 27 | Sleep 2s 28 | Type "p" 29 | Sleep 1s 30 | Down 31 | Sleep 500ms 32 | Type "c" 33 | Sleep 1s 34 | Up 35 | Sleep 1s 36 | Type "x" 37 | Sleep 1s 38 | Type "y" 39 | Sleep 3s 40 | 41 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: goreleaser 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | permissions: 9 | contents: write 10 | # packages: write 11 | # issues: write 12 | 13 | jobs: 14 | goreleaser: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v3 18 | with: 19 | fetch-depth: 0 20 | - run: git fetch --force --tags 21 | - uses: actions/setup-go@v3 22 | with: 23 | go-version: '>=1.19.3' 24 | cache: true 25 | - uses: goreleaser/goreleaser-action@v2 26 | with: 27 | distribution: goreleaser 28 | version: latest 29 | args: release --rm-dist 30 | env: 31 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 32 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /snap/snapcraft.yaml: -------------------------------------------------------------------------------- 1 | name: nap-snippets 2 | adopt-info: nap-snippets 3 | summary: Nap is a code snippet manager for your terminal. 4 | description: | 5 | Nap is a code snippet manager for your terminal. Create and access new snippets quickly with 6 | the command-line interface or browse, manage, and organize them with the text-user interface. 7 | Keep your code snippets safe, sound, and well-rested in your terminal. 8 | 9 | To learn more, visit: https://github.com/maaslalani/nap 10 | 11 | base: core20 12 | grade: stable 13 | confinement: strict 14 | compression: lzo 15 | license: MIT 16 | 17 | apps: 18 | nap-snippets: 19 | command: bin/nap 20 | plugs: 21 | - home 22 | 23 | parts: 24 | nap-snippets: 25 | source: https://github.com/maaslalani/nap 26 | source-type: git 27 | plugin: go 28 | build-snaps: 29 | - go 30 | 31 | override-pull: | 32 | snapcraftctl pull 33 | snapcraftctl set-version "$(git describe --tags | sed 's/^v//' | cut -d "-" -f1)" 34 | -------------------------------------------------------------------------------- /state.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "io/fs" 7 | "os" 8 | 9 | "github.com/adrg/xdg" 10 | ) 11 | 12 | // State is application state between runs 13 | type State struct { 14 | CurrentFolder string 15 | CurrentSnippet string 16 | } 17 | 18 | // Save saves the state of the application 19 | func (s State) Save() error { 20 | fi, err := os.Create(defaultState()) 21 | if err != nil { 22 | return err 23 | } 24 | defer fi.Close() 25 | return json.NewEncoder(fi).Encode(s) 26 | } 27 | 28 | // defaultState returns the default state path 29 | func defaultState() string { 30 | if c := os.Getenv("NAP_STATE"); c != "" { 31 | return c 32 | } 33 | statePath, err := xdg.StateFile("nap/state.json") 34 | if err != nil { 35 | return "state.json" 36 | } 37 | return statePath 38 | } 39 | 40 | // readState returns the application state 41 | func readState() State { 42 | var s State 43 | fi, err := os.Open(defaultState()) 44 | if err != nil && !errors.Is(err, fs.ErrNotExist) { 45 | return s 46 | } 47 | defer fi.Close() 48 | 49 | if err := json.NewDecoder(fi).Decode(&s); err != nil { 50 | return s 51 | } 52 | 53 | return s 54 | } 55 | -------------------------------------------------------------------------------- /editor_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "testing" 7 | ) 8 | 9 | func TestGetEditor(t *testing.T) { 10 | tt := []struct { 11 | Name string 12 | EditorEnv string 13 | Cmd string 14 | Args []string 15 | }{ 16 | { 17 | Name: "default", 18 | Cmd: "nano", 19 | }, 20 | { 21 | Name: "vim", 22 | EditorEnv: "vim", 23 | Cmd: "vim", 24 | }, 25 | { 26 | Name: "vim with flag", 27 | EditorEnv: "vim --foo", 28 | Cmd: "vim", 29 | Args: []string{"--foo"}, 30 | }, 31 | { 32 | Name: "code", 33 | EditorEnv: "code -w", 34 | Cmd: "code", 35 | Args: []string{"-w"}, 36 | }, 37 | } 38 | 39 | for _, tc := range tt { 40 | t.Run(tc.Name, func(t *testing.T) { 41 | var err error 42 | switch tc.EditorEnv { 43 | case "": 44 | err = os.Unsetenv("EDITOR") 45 | default: 46 | err = os.Setenv("EDITOR", tc.EditorEnv) 47 | } 48 | if err != nil { 49 | t.Logf("could not (un)set env: %v", err) 50 | t.FailNow() 51 | } 52 | 53 | cmd, args := getEditor() 54 | 55 | if cmd != tc.Cmd { 56 | t.Logf("cmd is incorrect: want %q but got %q", tc.Cmd, cmd) 57 | t.FailNow() 58 | } 59 | 60 | if argStr, tcArgStr := fmt.Sprint(args), fmt.Sprint(tc.Args); argStr != tcArgStr { 61 | t.Logf("args are incorrect: want %q but got %q", tcArgStr, argStr) 62 | t.FailNow() 63 | } 64 | }) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/maaslalani/nap 2 | 3 | go 1.19 4 | 5 | require ( 6 | github.com/adrg/xdg v0.4.0 7 | github.com/alecthomas/chroma/v2 v2.10.0 8 | github.com/aquilax/truncate v1.0.0 9 | github.com/atotto/clipboard v0.1.4 10 | github.com/caarlos0/env/v6 v6.10.1 11 | github.com/charmbracelet/bubbles v0.16.1 12 | github.com/charmbracelet/bubbletea v0.24.2 13 | github.com/charmbracelet/lipgloss v0.9.1 14 | github.com/dustin/go-humanize v1.0.1 15 | github.com/mattn/go-isatty v0.0.20 16 | github.com/sahilm/fuzzy v0.1.0 17 | golang.org/x/exp v0.0.0-20231006140011-7918f672742d 18 | gopkg.in/yaml.v3 v3.0.1 19 | ) 20 | 21 | require ( 22 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 23 | github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect 24 | github.com/davecgh/go-spew v1.1.1 // indirect 25 | github.com/dlclark/regexp2 v1.10.0 // indirect 26 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 27 | github.com/mattn/go-localereader v0.0.1 // indirect 28 | github.com/mattn/go-runewidth v0.0.15 // indirect 29 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect 30 | github.com/muesli/cancelreader v0.2.2 // indirect 31 | github.com/muesli/reflow v0.3.0 // indirect 32 | github.com/muesli/termenv v0.15.2 // indirect 33 | github.com/rivo/uniseg v0.4.4 // indirect 34 | golang.org/x/sync v0.4.0 // indirect 35 | golang.org/x/sys v0.13.0 // indirect 36 | golang.org/x/term v0.13.0 // indirect 37 | golang.org/x/text v0.13.0 // indirect 38 | ) 39 | -------------------------------------------------------------------------------- /snippet.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | "time" 9 | 10 | "github.com/alecthomas/chroma/v2/quick" 11 | ) 12 | 13 | // default values for empty state. 14 | const ( 15 | defaultSnippetFolder = "misc" 16 | defaultLanguage = "go" 17 | defaultSnippetName = "Untitled Snippet" 18 | defaultSnippetFileName = defaultSnippetName + "." + defaultLanguage 19 | ) 20 | 21 | // defaultSnippet is a snippet with all of the default values, used for when 22 | // there are no snippets available. 23 | var defaultSnippet = Snippet{ 24 | Name: defaultSnippetName, 25 | Folder: defaultSnippetFolder, 26 | Language: defaultLanguage, 27 | File: defaultSnippetFileName, 28 | Date: time.Now(), 29 | Tags: make([]string, 0), 30 | } 31 | 32 | // Snippet represents a snippet of code in a language. 33 | // It is nested within a folder and can be tagged with metadata. 34 | type Snippet struct { 35 | Tags []string `json:"tags"` 36 | Folder string `json:"folder"` 37 | Date time.Time `json:"date"` 38 | Favorite bool `json:"favorite"` 39 | Name string `json:"title"` 40 | File string `json:"file"` 41 | Language string `json:"language"` 42 | } 43 | 44 | // String returns the folder/name.ext of the snippet. 45 | func (s Snippet) String() string { 46 | return fmt.Sprintf("%s/%s.%s", s.Folder, s.Name, s.Language) 47 | } 48 | 49 | // LegacyPath returns the legacy path - 50 | func (s Snippet) LegacyPath() string { 51 | return s.File 52 | } 53 | 54 | // Path returns the path / 55 | func (s Snippet) Path() string { 56 | return filepath.Join(s.Folder, s.File) 57 | } 58 | 59 | // Content returns the snippet contents. 60 | func (s Snippet) Content(highlight bool) string { 61 | config := readConfig() 62 | file := filepath.Join(config.Home, s.Path()) 63 | content, err := os.ReadFile(file) 64 | if err != nil { 65 | return "" 66 | } 67 | 68 | if !highlight { 69 | return string(content) 70 | } 71 | 72 | var b bytes.Buffer 73 | err = quick.Highlight(&b, string(content), s.Language, "terminal16m", config.Theme) 74 | if err != nil { 75 | return string(content) 76 | } 77 | return b.String() 78 | } 79 | 80 | // Snippets is a wrapper for a snippets array to implement the fuzzy.Source 81 | // interface. 82 | type Snippets struct { 83 | snippets []Snippet 84 | } 85 | 86 | // String returns the string of the snippet at the specified position i 87 | func (s Snippets) String(i int) string { 88 | return s.snippets[i].String() 89 | } 90 | 91 | // Len returns the length of the snippets array. 92 | func (s Snippets) Len() int { 93 | return len(s.snippets) 94 | } 95 | -------------------------------------------------------------------------------- /keys.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/charmbracelet/bubbles/key" 4 | 5 | // KeyMap is the mappings of actions to key bindings. 6 | type KeyMap struct { 7 | Quit key.Binding 8 | Search key.Binding 9 | ToggleHelp key.Binding 10 | NewSnippet key.Binding 11 | MoveSnippetUp key.Binding 12 | MoveSnippetDown key.Binding 13 | DeleteSnippet key.Binding 14 | EditSnippet key.Binding 15 | CopySnippet key.Binding 16 | PasteSnippet key.Binding 17 | SetFolder key.Binding 18 | RenameSnippet key.Binding 19 | TagSnippet key.Binding 20 | SetLanguage key.Binding 21 | Confirm key.Binding 22 | Cancel key.Binding 23 | NextPane key.Binding 24 | PreviousPane key.Binding 25 | ChangeFolder key.Binding 26 | } 27 | 28 | // DefaultKeyMap is the default key map for the application. 29 | var DefaultKeyMap = KeyMap{ 30 | Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "exit")), 31 | Search: key.NewBinding(key.WithKeys("/"), key.WithHelp("/", "search")), 32 | ToggleHelp: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")), 33 | NewSnippet: key.NewBinding(key.WithKeys("n"), key.WithHelp("n", "new")), 34 | MoveSnippetDown: key.NewBinding(key.WithKeys("J"), key.WithHelp("J", "move snippet down")), 35 | MoveSnippetUp: key.NewBinding(key.WithKeys("K"), key.WithHelp("K", "move snippet up")), 36 | DeleteSnippet: key.NewBinding(key.WithKeys("x"), key.WithHelp("x", "delete")), 37 | EditSnippet: key.NewBinding(key.WithKeys("e"), key.WithHelp("e", "edit")), 38 | CopySnippet: key.NewBinding(key.WithKeys("c"), key.WithHelp("c", "copy")), 39 | PasteSnippet: key.NewBinding(key.WithKeys("p"), key.WithHelp("p", "paste")), 40 | RenameSnippet: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "rename snippet")), 41 | SetFolder: key.NewBinding(key.WithKeys("R"), key.WithHelp("R", "rename folder")), 42 | SetLanguage: key.NewBinding(key.WithKeys("L"), key.WithHelp("L", "set file type")), 43 | TagSnippet: key.NewBinding(key.WithKeys("t"), key.WithHelp("t", "tag"), key.WithDisabled()), 44 | Confirm: key.NewBinding(key.WithKeys("y"), key.WithHelp("y", "confirm")), 45 | Cancel: key.NewBinding(key.WithKeys("N", "esc"), key.WithHelp("N", "cancel")), 46 | NextPane: key.NewBinding(key.WithKeys("tab", "right"), key.WithHelp("tab", "navigate")), 47 | PreviousPane: key.NewBinding(key.WithKeys("shift+tab", "left"), key.WithHelp("shift+tab", "navigate")), 48 | ChangeFolder: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "change folder"), key.WithDisabled()), 49 | } 50 | 51 | // ShortHelp returns a quick help menu. 52 | func (k KeyMap) ShortHelp() []key.Binding { 53 | return []key.Binding{ 54 | k.NextPane, 55 | k.Search, 56 | k.EditSnippet, 57 | k.DeleteSnippet, 58 | k.CopySnippet, 59 | k.NewSnippet, 60 | k.ToggleHelp, 61 | } 62 | } 63 | 64 | // FullHelp returns all help options in a more detailed view. 65 | func (k KeyMap) FullHelp() [][]key.Binding { 66 | return [][]key.Binding{ 67 | {k.NewSnippet, k.EditSnippet, k.PasteSnippet, k.CopySnippet, k.DeleteSnippet}, 68 | {k.MoveSnippetDown, k.MoveSnippetUp}, 69 | {k.RenameSnippet, k.SetFolder, k.TagSnippet, k.SetLanguage}, 70 | {k.NextPane, k.PreviousPane}, 71 | {k.Search, k.ToggleHelp, k.Quit}, 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /config.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "io/fs" 6 | "os" 7 | "path/filepath" 8 | "strings" 9 | 10 | "github.com/adrg/xdg" 11 | "github.com/caarlos0/env/v6" 12 | "gopkg.in/yaml.v3" 13 | ) 14 | 15 | // Config holds the configuration options for the application. 16 | // 17 | // At the moment, it is quite limited, only supporting the home folder and the 18 | // file name of the metadata. 19 | type Config struct { 20 | Home string `env:"NAP_HOME" yaml:"home"` 21 | File string `env:"NAP_FILE" yaml:"file"` 22 | 23 | DefaultLanguage string `env:"NAP_DEFAULT_LANGUAGE" yaml:"default_language"` 24 | 25 | Theme string `env:"NAP_THEME" yaml:"theme"` 26 | 27 | PrimaryColor string `env:"NAP_PRIMARY_COLOR" yaml:"primary_color"` 28 | PrimaryColorSubdued string `env:"NAP_PRIMARY_COLOR_SUBDUED" yaml:"primary_color_subdued"` 29 | BrightGreenColor string `env:"NAP_BRIGHT_GREEN" yaml:"bright_green"` 30 | GreenColor string `env:"NAP_GREEN" yaml:"green"` 31 | BrightRedColor string `env:"NAP_BRIGHT_RED" yaml:"bright_red"` 32 | RedColor string `env:"NAP_RED" yaml:"red"` 33 | ForegroundColor string `env:"NAP_FOREGROUND" yaml:"foreground"` 34 | BackgroundColor string `env:"NAP_BACKGROUND" yaml:"background"` 35 | GrayColor string `env:"NAP_GRAY" yaml:"gray"` 36 | BlackColor string `env:"NAP_BLACK" yaml:"black"` 37 | WhiteColor string `env:"NAP_WHITE" yaml:"white"` 38 | } 39 | 40 | func newConfig() Config { 41 | return Config{ 42 | Home: defaultHome(), 43 | File: "snippets.json", 44 | DefaultLanguage: defaultLanguage, 45 | Theme: "dracula", 46 | PrimaryColor: "#AFBEE1", 47 | PrimaryColorSubdued: "#64708D", 48 | BrightGreenColor: "#BCE1AF", 49 | GreenColor: "#527251", 50 | BrightRedColor: "#E49393", 51 | RedColor: "#A46060", 52 | ForegroundColor: "15", 53 | BackgroundColor: "235", 54 | GrayColor: "241", 55 | BlackColor: "#373b41", 56 | WhiteColor: "#FFFFFF", 57 | } 58 | } 59 | 60 | // default helpers for the configuration. 61 | // We use $XDG_DATA_HOME to avoid cluttering the user's home directory. 62 | func defaultHome() string { return filepath.Join(xdg.DataHome, "nap") } 63 | 64 | // defaultConfig returns the default config path 65 | func defaultConfig() string { 66 | if c := os.Getenv("NAP_CONFIG"); c != "" { 67 | return c 68 | } 69 | cfgPath, err := xdg.ConfigFile("nap/config.yaml") 70 | if err != nil { 71 | return "config.yaml" 72 | } 73 | return cfgPath 74 | } 75 | 76 | // readConfig returns a configuration read from the environment. 77 | func readConfig() Config { 78 | config := newConfig() 79 | fi, err := os.Open(defaultConfig()) 80 | if err != nil && !errors.Is(err, fs.ErrNotExist) { 81 | return newConfig() 82 | } 83 | if fi != nil { 84 | defer fi.Close() 85 | if err := yaml.NewDecoder(fi).Decode(&config); err != nil { 86 | return newConfig() 87 | } 88 | } 89 | 90 | if err := env.Parse(&config); err != nil { 91 | return newConfig() 92 | } 93 | 94 | if strings.HasPrefix(config.Home, "~") { 95 | home, err := os.UserHomeDir() 96 | if err == nil { 97 | config.Home = filepath.Join(home, config.Home[1:]) 98 | } 99 | } 100 | 101 | return config 102 | } 103 | 104 | // writeConfig returns a configuration read from the environment. 105 | func (config Config) writeConfig() error { 106 | fi, err := os.Create(defaultConfig()) 107 | if err != nil { 108 | return err 109 | } 110 | if fi != nil { 111 | defer fi.Close() 112 | if err := yaml.NewEncoder(fi).Encode(&config); err != nil { 113 | return err 114 | } 115 | } 116 | 117 | return nil 118 | } 119 | -------------------------------------------------------------------------------- /main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "io" 5 | "os" 6 | "path/filepath" 7 | "testing" 8 | ) 9 | 10 | func TestCLI(t *testing.T) { 11 | tmp := tmpHome(t) 12 | 13 | t.Run("stdin", func(t *testing.T) { 14 | r, w, err := os.Pipe() 15 | if err != nil { 16 | t.Logf("could not open pipe: %v", err) 17 | t.FailNow() 18 | } 19 | os.Stdin = r 20 | 21 | w.WriteString("foo bar baz") 22 | w.Close() 23 | runCLI([]string{"foo/bar.baz"}) 24 | 25 | cfg := readConfig() 26 | snippets := readSnippets(cfg) 27 | 28 | if len(snippets) != 1 { 29 | t.Logf("snippet count is incorrect: got %d but want 1", len(snippets)) 30 | t.FailNow() 31 | } 32 | 33 | fn := filepath.Join(tmp, "foo/bar.baz") 34 | fi, err := os.Open(fn) 35 | if err != nil { 36 | t.Logf("could not open test file: %v", err) 37 | t.FailNow() 38 | } 39 | defer fi.Close() 40 | 41 | content, err := io.ReadAll(fi) 42 | if err != nil { 43 | t.Logf("could not read test file: %v", err) 44 | t.FailNow() 45 | } 46 | 47 | if string(content) != "foo bar baz" { 48 | t.Logf(`snippet is incorrect: got %q but want "foo bar baz"`, string(content)) 49 | t.FailNow() 50 | } 51 | }) 52 | 53 | t.Run("stdout", func(t *testing.T) { 54 | r, w, err := os.Pipe() 55 | if err != nil { 56 | t.Logf("could not open pipe: %v", err) 57 | t.FailNow() 58 | } 59 | os.Stdout = w 60 | runCLI([]string{"foo/bar.baz"}) 61 | w.Close() 62 | out, err := io.ReadAll(r) 63 | if err != nil { 64 | t.Log("could not read stdout") 65 | t.FailNow() 66 | } 67 | 68 | if string(out) != "foo bar baz" { 69 | t.Logf(`snippet is incorrect: got %q but want "foo bar baz"`, string(out)) 70 | t.FailNow() 71 | } 72 | }) 73 | 74 | t.Run("list", func(t *testing.T) { 75 | r, w, err := os.Pipe() 76 | if err != nil { 77 | t.Logf("could not open pipe: %v", err) 78 | t.FailNow() 79 | } 80 | os.Stdout = w 81 | runCLI([]string{"list"}) 82 | w.Close() 83 | out, err := io.ReadAll(r) 84 | if err != nil { 85 | t.Log("could not read stdout") 86 | t.FailNow() 87 | } 88 | 89 | if string(out) != "foo/bar.baz\n" { 90 | t.Logf(`snippet is incorrect: got %q but want "foo/bar.baz\n"`, string(out)) 91 | t.FailNow() 92 | } 93 | }) 94 | } 95 | 96 | func TestScan(t *testing.T) { 97 | tmp := tmpHome(t) 98 | 99 | cfg := readConfig() 100 | snippets := readSnippets(cfg) 101 | snippets = scanSnippets(cfg, snippets) 102 | initNum := len(snippets) 103 | 104 | tmpSnippetFolder := filepath.Join(tmp, "foo") 105 | tmpSnippet := filepath.Join(tmpSnippetFolder, "bar.baz") 106 | if err := os.MkdirAll(tmpSnippetFolder, os.ModePerm); err != nil { 107 | t.Logf("could not create snippet folder: %v", err) 108 | t.FailNow() 109 | } 110 | if err := os.WriteFile(tmpSnippet, []byte("foo bar baz"), os.ModePerm); err != nil { 111 | t.Logf("could not create snippet: %v", err) 112 | t.FailNow() 113 | } 114 | 115 | snippets = scanSnippets(cfg, snippets) 116 | if len(snippets) != initNum+1 { 117 | t.Logf("incorrect number of snippets after initial scanning: want %d but got %d", initNum+1, len(snippets)) 118 | t.FailNow() 119 | } 120 | 121 | if err := os.Remove(tmpSnippet); err != nil { 122 | t.Logf("could not remove snippet: %v", err) 123 | t.FailNow() 124 | } 125 | 126 | snippets = scanSnippets(cfg, snippets) 127 | if len(snippets) != initNum { 128 | t.Logf("incorrect number of snippets after follow-up scanning: want %d but got %d", initNum, len(snippets)) 129 | t.FailNow() 130 | } 131 | } 132 | 133 | func tmpHome(t *testing.T) string { 134 | t.Helper() 135 | 136 | tmp := t.TempDir() 137 | if err := os.Setenv("NAP_HOME", tmp); err != nil { 138 | t.Log("could not set NAP_HOME") 139 | t.FailNow() 140 | } 141 | return tmp 142 | } 143 | -------------------------------------------------------------------------------- /list.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "strings" 7 | "time" 8 | 9 | "github.com/aquilax/truncate" 10 | "github.com/charmbracelet/bubbles/list" 11 | tea "github.com/charmbracelet/bubbletea" 12 | "github.com/dustin/go-humanize" 13 | ) 14 | 15 | // FilterValue is the snippet filter value that can be used when searching. 16 | func (s Snippet) FilterValue() string { 17 | return s.Folder + "/" + s.Name + "\n" + "+" + strings.Join(s.Tags, "+") + "\n" + s.Language 18 | } 19 | 20 | // snippetDelegate represents the snippet list item. 21 | type snippetDelegate struct { 22 | styles SnippetsBaseStyle 23 | state state 24 | } 25 | 26 | // Height is the number of lines the snippet list item takes up. 27 | func (d snippetDelegate) Height() int { 28 | return 2 29 | } 30 | 31 | // Spacing is the number of lines to insert between list items. 32 | func (d snippetDelegate) Spacing() int { 33 | return 1 34 | } 35 | 36 | // Update is called when the list is updated. 37 | // We use this to update the snippet code view. 38 | func (d snippetDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { 39 | return func() tea.Msg { 40 | if m.SelectedItem() == nil { 41 | return nil 42 | } 43 | return updateContentMsg(m.SelectedItem().(Snippet)) 44 | } 45 | } 46 | 47 | // Render renders the list item for the snippet which includes the title, 48 | // folder, and date. 49 | func (d snippetDelegate) Render(w io.Writer, m list.Model, index int, item list.Item) { 50 | if item == nil { 51 | return 52 | } 53 | s, ok := item.(Snippet) 54 | if !ok { 55 | return 56 | } 57 | 58 | titleStyle := d.styles.SelectedTitle 59 | subtitleStyle := d.styles.SelectedSubtitle 60 | if d.state == copyingState { 61 | titleStyle = d.styles.CopiedTitle 62 | subtitleStyle = d.styles.CopiedSubtitle 63 | } else if d.state == deletingState { 64 | titleStyle = d.styles.DeletedTitle 65 | subtitleStyle = d.styles.DeletedSubtitle 66 | } 67 | 68 | if index == m.Index() { 69 | fmt.Fprintln(w, " "+titleStyle.Render(truncate.Truncate(s.Name, 30, "...", truncate.PositionEnd))) 70 | fmt.Fprint(w, " "+subtitleStyle.Render(s.Folder+" • "+humanizeTime(s.Date))) 71 | return 72 | } 73 | fmt.Fprintln(w, " "+d.styles.UnselectedTitle.Render(truncate.Truncate(s.Name, 30, "...", truncate.PositionEnd))) 74 | fmt.Fprint(w, " "+d.styles.UnselectedSubtitle.Render(s.Folder+" • "+humanizeTime(s.Date))) 75 | } 76 | 77 | // Folder represents a group of snippets in a directory. 78 | type Folder string 79 | 80 | // FilterValue is the searchable value for the folder. 81 | func (f Folder) FilterValue() string { 82 | return string(f) 83 | } 84 | 85 | // folderDelegate represents a folder list item. 86 | type folderDelegate struct{ styles FoldersBaseStyle } 87 | 88 | // Height is the number of lines the folder list item takes up. 89 | func (d folderDelegate) Height() int { 90 | return 1 91 | } 92 | 93 | // Spacing is the number of lines to insert between folder items. 94 | func (d folderDelegate) Spacing() int { 95 | return 0 96 | } 97 | 98 | // Update is what is called when the folder selection is updated. 99 | // TODO: Update the filter search for the snippets with the folder name. 100 | func (d folderDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { 101 | return nil 102 | } 103 | 104 | // Render renders a folder list item. 105 | func (d folderDelegate) Render(w io.Writer, m list.Model, index int, item list.Item) { 106 | f, ok := item.(Folder) 107 | if !ok { 108 | return 109 | } 110 | fmt.Fprint(w, " ") 111 | if index == m.Index() { 112 | fmt.Fprint(w, d.styles.Selected.Render("→ "+string(f))) 113 | return 114 | } 115 | fmt.Fprint(w, d.styles.Unselected.Render(" "+string(f))) 116 | } 117 | 118 | const ( 119 | Day = 24 * time.Hour 120 | Week = 7 * Day 121 | Month = 30 * Day 122 | Year = 12 * Month 123 | ) 124 | 125 | var magnitudes = []humanize.RelTimeMagnitude{ 126 | {D: 5 * time.Second, Format: "just now", DivBy: time.Second}, 127 | {D: time.Minute, Format: "moments ago", DivBy: time.Second}, 128 | {D: time.Hour, Format: "%dm %s", DivBy: time.Minute}, 129 | {D: 2 * time.Hour, Format: "1h %s", DivBy: 1}, 130 | {D: Day, Format: "%dh %s", DivBy: time.Hour}, 131 | {D: 2 * Day, Format: "1d %s", DivBy: 1}, 132 | {D: Week, Format: "%dd %s", DivBy: Day}, 133 | {D: 2 * Week, Format: "1w %s", DivBy: 1}, 134 | {D: Month, Format: "%dw %s", DivBy: Week}, 135 | {D: 2 * Month, Format: "1mo %s", DivBy: 1}, 136 | {D: Year, Format: "%dmo %s", DivBy: Month}, 137 | {D: 18 * Month, Format: "1y %s", DivBy: 1}, 138 | {D: 2 * Year, Format: "2y %s", DivBy: 1}, 139 | } 140 | 141 | func humanizeTime(t time.Time) string { 142 | return humanize.CustomRelTime(t, time.Now(), "ago", "from now", magnitudes) 143 | } 144 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Nap 2 | 3 | Nap 4 | 5 | zzz 6 | 7 | Nap is a code snippet manager for your terminal. Create and access new snippets 8 | quickly with the command-line interface or browse, manage, and organize them with the 9 | text-user interface. Keep your code snippets safe, sound, and well-rested in your terminal. 10 | 11 |
12 | 13 |

14 | 15 |

16 | 17 |
18 | 19 | ## Text-based User Interface 20 | 21 | Launch the interactive interface: 22 | 23 | ```bash 24 | nap 25 | ``` 26 | 27 | 28 | 29 |
30 | 31 | Key Bindings 32 | 33 |
34 | 35 | | Action | Key | 36 | | :--- | :--- | 37 | | Create a new snippet | n | 38 | | Edit selected snippet (in `$EDITOR`) | e | 39 | | Copy selected snippet to clipboard | c | 40 | | Paste clipboard to selected snippet | p | 41 | | Delete selected snippet | x | 42 | | Rename selected snippet | r | 43 | | Set folder of selected snippet | f | 44 | | Set language of selected snippet | L | 45 | | Move to next pane | tab | 46 | | Move to previous pane | shift+tab | 47 | | Search for snippets | / | 48 | | Toggle help | ? | 49 | | Quit application | q ctrl+c | 50 | 51 |
52 | 53 | ## Command Line Interface 54 | 55 | Create new snippets: 56 | 57 | ```bash 58 | # Quick save an untitled snippet. 59 | nap < main.go 60 | 61 | # From a file, specify Notes/ folder and Go language. 62 | nap Notes/FizzBuzz.go < main.go 63 | 64 | # Save some code from the internet for later. 65 | curl https://example.com/main.go | nap Notes/FizzBuzz.go 66 | 67 | # Works great with GitHub gists 68 | gh gist view 4ff8a6472247e6dd2315fd4038926522 | nap 69 | ``` 70 | 71 | 72 | 73 | Output saved snippets: 74 | 75 | ```bash 76 | # Fuzzy find snippet. 77 | nap fuzzy 78 | 79 | # Write snippet to a file. 80 | nap go/boilerplate > main.go 81 | 82 | # Copy snippet to clipboard. 83 | nap foobar | pbcopy 84 | nap foobar | xclip 85 | ``` 86 | 87 | 88 | 89 | List snippets: 90 | 91 | ```bash 92 | nap list 93 | ``` 94 | 95 | 96 | Fuzzy find a snippet (with [Gum](https://github.com/charmbracelet/gum)). 97 | 98 | ```bash 99 | nap $(nap list | gum filter) 100 | ``` 101 | 102 | 103 | 104 | ## Installation 105 | 106 | 122 | 123 | Install with Go: 124 | 125 | ```sh 126 | go install github.com/maaslalani/nap@main 127 | ``` 128 | 129 | Or download a binary from the [releases](https://github.com/maaslalani/nap/releases). 130 | 131 | 132 | ## Customization 133 | 134 | Nap is customized through a configuration file located at `NAP_CONFIG` (`$XDG_CONFIG_HOME/nap/config.yaml`). 135 | 136 | ```yaml 137 | # Configuration 138 | home: ~/.nap 139 | default_language: go 140 | theme: nord 141 | 142 | # Colors 143 | background: "0" 144 | foreground: "7" 145 | primary_color: "#AFBEE1" 146 | primary_color_subdued: "#64708D" 147 | green: "#527251" 148 | bright_green: "#BCE1AF" 149 | bright_red: "#E49393" 150 | red: "#A46060" 151 | black: "#373B41" 152 | gray: "240" 153 | white: "#FFFFFF" 154 | ``` 155 | 156 | The configuration file can be overridden through environment variables: 157 | 158 | ```bash 159 | # Configuration 160 | export NAP_CONFIG="~/.nap/config.yaml" 161 | export NAP_HOME="~/.nap" 162 | export NAP_DEFAULT_LANGUAGE="go" 163 | export NAP_THEME="nord" 164 | 165 | # Colors 166 | export NAP_PRIMARY_COLOR="#AFBEE1" 167 | export NAP_RED="#A46060" 168 | export NAP_GREEN="#527251" 169 | export NAP_FOREGROUND="7" 170 | export NAP_BACKGROUND="0" 171 | export NAP_BLACK="#373B41" 172 | export NAP_GRAY="240" 173 | export NAP_WHITE="#FFFFFF" 174 | ``` 175 | 176 |
177 | 178 |

179 | image 184 |

185 | 186 | ## License 187 | 188 | [MIT](https://github.com/maaslalani/nap/blob/master/LICENSE) 189 | 190 | ## Feedback 191 | 192 | I'd love to hear your feedback on improving `nap`. 193 | 194 | Feel free to reach out via: 195 | * [Email](mailto:maas@lalani.dev) 196 | * [Twitter](https://twitter.com/maaslalani) 197 | * [GitHub issues](https://github.com/maaslalani/nap/issues/new) 198 | 199 | --- 200 | 201 | zzz 202 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/adrg/xdg v0.4.0 h1:RzRqFcjH4nE5C6oTAxhBtoE2IRyjBSa62SCbyPidvls= 2 | github.com/adrg/xdg v0.4.0/go.mod h1:N6ag73EX4wyxeaoeHctc1mas01KZgsj5tYiAIwqJE/E= 3 | github.com/alecthomas/assert/v2 v2.2.1 h1:XivOgYcduV98QCahG8T5XTezV5bylXe+lBxLG2K2ink= 4 | github.com/alecthomas/chroma/v2 v2.10.0 h1:T2iQOCCt4pRmRMfL55gTodMtc7cU0y7lc1Jb8/mK/64= 5 | github.com/alecthomas/chroma/v2 v2.10.0/go.mod h1:4TQu7gdfuPjSh76j78ietmqh9LiurGF0EpseFXdKMBw= 6 | github.com/alecthomas/repr v0.2.0 h1:HAzS41CIzNW5syS8Mf9UwXhNH1J9aix/BvDRf1Ml2Yk= 7 | github.com/aquilax/truncate v1.0.0 h1:UgIGS8U/aZ4JyOJ2h3xcF5cSQ06+gGBnjxH2RUHJe0U= 8 | github.com/aquilax/truncate v1.0.0/go.mod h1:BeMESIDMlvlS3bmg4BVvBbbZUNwWtS8uzYPAKXwwhLw= 9 | github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= 10 | github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= 11 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 12 | github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 13 | github.com/caarlos0/env/v6 v6.10.1 h1:t1mPSxNpei6M5yAeu1qtRdPAK29Nbcf/n3G7x+b3/II= 14 | github.com/caarlos0/env/v6 v6.10.1/go.mod h1:hvp/ryKXKipEkcuYjs9mI4bBCg+UI0Yhgm5Zu0ddvwc= 15 | github.com/charmbracelet/bubbles v0.16.1 h1:6uzpAAaT9ZqKssntbvZMlksWHruQLNxg49H5WdeuYSY= 16 | github.com/charmbracelet/bubbles v0.16.1/go.mod h1:2QCp9LFlEsBQMvIYERr7Ww2H2bA7xen1idUDIzm/+Xc= 17 | github.com/charmbracelet/bubbletea v0.24.2 h1:uaQIKx9Ai6Gdh5zpTbGiWpytMU+CfsPp06RaW2cx/SY= 18 | github.com/charmbracelet/bubbletea v0.24.2/go.mod h1:XdrNrV4J8GiyshTtx3DNuYkR1FDaJmO3l2nejekbsgg= 19 | github.com/charmbracelet/lipgloss v0.9.1 h1:PNyd3jvaJbg4jRHKWXnCj1akQm4rh8dbEzN1p/u1KWg= 20 | github.com/charmbracelet/lipgloss v0.9.1/go.mod h1:1mPmG4cxScwUQALAAnacHaigiiHB9Pmr+v1VEawJl6I= 21 | github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 h1:q2hJAaP1k2wIvVRd/hEHD7lacgqrCPS+k8g1MndzfWY= 22 | github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk= 23 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 24 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 25 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 26 | github.com/dlclark/regexp2 v1.10.0 h1:+/GIL799phkJqYW+3YbOd8LCcbHzT0Pbo8zl70MHsq0= 27 | github.com/dlclark/regexp2 v1.10.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= 28 | github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= 29 | github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 30 | github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= 31 | github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= 32 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 33 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 34 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 35 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 36 | github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= 37 | github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= 38 | github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= 39 | github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= 40 | github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 41 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= 42 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= 43 | github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= 44 | github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= 45 | github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= 46 | github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= 47 | github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= 48 | github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= 49 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 50 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 51 | github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 52 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 53 | github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis= 54 | github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 55 | github.com/sahilm/fuzzy v0.1.0 h1:FzWGaw2Opqyu+794ZQ9SYifWv2EIXpwP4q8dY1kDAwI= 56 | github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= 57 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 58 | github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= 59 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 60 | golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= 61 | golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= 62 | golang.org/x/sync v0.4.0 h1:zxkM55ReGkDlKSM+Fu41A+zmbZuaPVbGMzvvdUPznYQ= 63 | golang.org/x/sync v0.4.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= 64 | golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 65 | golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 66 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 67 | golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= 68 | golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 69 | golang.org/x/term v0.13.0 h1:bb+I9cTfFazGW51MZqBVmZy7+JEJMouUHTUSKVQLBek= 70 | golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= 71 | golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k= 72 | golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= 73 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 74 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 75 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 76 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 77 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 78 | -------------------------------------------------------------------------------- /style.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/charmbracelet/lipgloss" 4 | 5 | // SnippetsStyle is the style struct to handle the focusing and blurring of the 6 | // snippets pane in the application. 7 | type SnippetsStyle struct { 8 | Focused SnippetsBaseStyle 9 | Blurred SnippetsBaseStyle 10 | } 11 | 12 | // FoldersStyle is the style struct to handle the focusing and blurring of the 13 | // folders pane in the application. 14 | type FoldersStyle struct { 15 | Focused FoldersBaseStyle 16 | Blurred FoldersBaseStyle 17 | } 18 | 19 | // ContentStyle is the style struct to handle the focusing and blurring of the 20 | // content pane in the application. 21 | type ContentStyle struct { 22 | Focused ContentBaseStyle 23 | Blurred ContentBaseStyle 24 | } 25 | 26 | // SnippetsBaseStyle holds the neccessary styling for the snippets pane of 27 | // the application. 28 | type SnippetsBaseStyle struct { 29 | Base lipgloss.Style 30 | Title lipgloss.Style 31 | TitleBar lipgloss.Style 32 | SelectedSubtitle lipgloss.Style 33 | UnselectedSubtitle lipgloss.Style 34 | SelectedTitle lipgloss.Style 35 | UnselectedTitle lipgloss.Style 36 | CopiedTitleBar lipgloss.Style 37 | CopiedTitle lipgloss.Style 38 | CopiedSubtitle lipgloss.Style 39 | DeletedTitleBar lipgloss.Style 40 | DeletedTitle lipgloss.Style 41 | DeletedSubtitle lipgloss.Style 42 | } 43 | 44 | // FoldersBaseStyle holds the neccessary styling for the folders pane of 45 | // the application. 46 | type FoldersBaseStyle struct { 47 | Base lipgloss.Style 48 | Title lipgloss.Style 49 | TitleBar lipgloss.Style 50 | Selected lipgloss.Style 51 | Unselected lipgloss.Style 52 | } 53 | 54 | // ContentBaseStyle holds the neccessary styling for the content pane of the 55 | // application. 56 | type ContentBaseStyle struct { 57 | Base lipgloss.Style 58 | Title lipgloss.Style 59 | Separator lipgloss.Style 60 | LineNumber lipgloss.Style 61 | EmptyHint lipgloss.Style 62 | EmptyHintKey lipgloss.Style 63 | } 64 | 65 | // Styles is the struct of all styles for the application. 66 | type Styles struct { 67 | Snippets SnippetsStyle 68 | Folders FoldersStyle 69 | Content ContentStyle 70 | } 71 | 72 | var marginStyle = lipgloss.NewStyle().Margin(1, 0, 0, 1) 73 | 74 | // DefaultStyles is the default implementation of the styles struct for all 75 | // styling in the application. 76 | func DefaultStyles(config Config) Styles { 77 | white := lipgloss.Color(config.WhiteColor) 78 | gray := lipgloss.Color(config.GrayColor) 79 | black := lipgloss.Color(config.BackgroundColor) 80 | brightBlack := lipgloss.Color(config.BlackColor) 81 | green := lipgloss.Color(config.GreenColor) 82 | brightGreen := lipgloss.Color(config.BrightGreenColor) 83 | brightBlue := lipgloss.Color(config.PrimaryColor) 84 | blue := lipgloss.Color(config.PrimaryColorSubdued) 85 | red := lipgloss.Color(config.RedColor) 86 | brightRed := lipgloss.Color(config.BrightRedColor) 87 | 88 | return Styles{ 89 | Snippets: SnippetsStyle{ 90 | Focused: SnippetsBaseStyle{ 91 | Base: lipgloss.NewStyle().Width(35), 92 | TitleBar: lipgloss.NewStyle().Background(blue).Width(35-2).Margin(0, 1, 1, 1).Padding(0, 1).Foreground(white), 93 | SelectedSubtitle: lipgloss.NewStyle().Foreground(blue), 94 | UnselectedSubtitle: lipgloss.NewStyle().Foreground(lipgloss.Color("237")), 95 | SelectedTitle: lipgloss.NewStyle().Foreground(brightBlue), 96 | UnselectedTitle: lipgloss.NewStyle().Foreground(gray), 97 | CopiedTitleBar: lipgloss.NewStyle().Background(green).Width(35-2).Margin(0, 1, 1, 1).Padding(0, 1).Foreground(white), 98 | CopiedTitle: lipgloss.NewStyle().Foreground(brightGreen), 99 | CopiedSubtitle: lipgloss.NewStyle().Foreground(green), 100 | DeletedTitleBar: lipgloss.NewStyle().Background(red).Width(35-2).Margin(0, 1, 1, 1).Padding(0, 1).Foreground(white), 101 | DeletedTitle: lipgloss.NewStyle().Foreground(brightRed), 102 | DeletedSubtitle: lipgloss.NewStyle().Foreground(red), 103 | }, 104 | Blurred: SnippetsBaseStyle{ 105 | Base: lipgloss.NewStyle().Width(35), 106 | TitleBar: lipgloss.NewStyle().Background(black).Width(35-2).Margin(0, 1, 1, 1).Padding(0, 1).Foreground(gray), 107 | SelectedSubtitle: lipgloss.NewStyle().Foreground(blue), 108 | UnselectedSubtitle: lipgloss.NewStyle().Foreground(black), 109 | SelectedTitle: lipgloss.NewStyle().Foreground(brightBlue), 110 | UnselectedTitle: lipgloss.NewStyle().Foreground(lipgloss.Color("237")), 111 | CopiedTitleBar: lipgloss.NewStyle().Background(green).Width(35-2).Margin(0, 1, 1, 1).Padding(0, 1), 112 | CopiedTitle: lipgloss.NewStyle().Foreground(brightGreen), 113 | CopiedSubtitle: lipgloss.NewStyle().Foreground(green), 114 | DeletedTitleBar: lipgloss.NewStyle().Background(red).Width(35-2).Margin(0, 1, 1, 1).Padding(0, 1), 115 | DeletedTitle: lipgloss.NewStyle().Foreground(brightRed), 116 | DeletedSubtitle: lipgloss.NewStyle().Foreground(red), 117 | }, 118 | }, 119 | Folders: FoldersStyle{ 120 | Focused: FoldersBaseStyle{ 121 | Base: lipgloss.NewStyle().Width(22), 122 | Title: lipgloss.NewStyle().Padding(0, 1).Foreground(white), 123 | TitleBar: lipgloss.NewStyle().Background(blue).Width(22-2).Margin(0, 1, 1, 1), 124 | Selected: lipgloss.NewStyle().Foreground(brightBlue), 125 | Unselected: lipgloss.NewStyle().Foreground(gray), 126 | }, 127 | Blurred: FoldersBaseStyle{ 128 | Base: lipgloss.NewStyle().Width(22), 129 | Title: lipgloss.NewStyle().Padding(0, 1).Foreground(gray), 130 | TitleBar: lipgloss.NewStyle().Background(black).Width(22-2).Margin(0, 1, 1, 1), 131 | Selected: lipgloss.NewStyle().Foreground(brightBlue), 132 | Unselected: lipgloss.NewStyle().Foreground(lipgloss.Color("237")), 133 | }, 134 | }, 135 | Content: ContentStyle{ 136 | Focused: ContentBaseStyle{ 137 | Base: lipgloss.NewStyle().Margin(0, 1), 138 | Title: lipgloss.NewStyle().Background(blue).Foreground(white).Margin(0, 0, 1, 1).Padding(0, 1), 139 | Separator: lipgloss.NewStyle().Foreground(white).Margin(0, 0, 1, 1), 140 | LineNumber: lipgloss.NewStyle().Foreground(brightBlack), 141 | EmptyHint: lipgloss.NewStyle().Foreground(gray), 142 | EmptyHintKey: lipgloss.NewStyle().Foreground(brightBlue), 143 | }, 144 | Blurred: ContentBaseStyle{ 145 | Base: lipgloss.NewStyle().Margin(0, 1), 146 | Title: lipgloss.NewStyle().Background(black).Foreground(gray).Margin(0, 0, 1, 1).Padding(0, 1), 147 | Separator: lipgloss.NewStyle().Foreground(gray).Margin(0, 0, 1, 1), 148 | LineNumber: lipgloss.NewStyle().Foreground(black), 149 | EmptyHint: lipgloss.NewStyle().Foreground(gray), 150 | EmptyHintKey: lipgloss.NewStyle().Foreground(brightBlue), 151 | }, 152 | }, 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "io" 9 | "io/fs" 10 | "os" 11 | "path/filepath" 12 | "strings" 13 | "time" 14 | 15 | "github.com/mattn/go-isatty" 16 | 17 | "github.com/charmbracelet/bubbles/help" 18 | "github.com/charmbracelet/bubbles/list" 19 | "github.com/charmbracelet/bubbles/textinput" 20 | "github.com/charmbracelet/bubbles/viewport" 21 | tea "github.com/charmbracelet/bubbletea" 22 | "github.com/charmbracelet/lipgloss" 23 | "github.com/sahilm/fuzzy" 24 | "golang.org/x/exp/maps" 25 | "golang.org/x/exp/slices" 26 | ) 27 | 28 | var ( 29 | helpText = strings.TrimSpace(` 30 | Nap is a code snippet manager for your terminal. 31 | https://github.com/maaslalani/nap 32 | 33 | Usage: 34 | nap - for interactive mode 35 | nap list - list all snippets 36 | nap - print snippet to stdout 37 | 38 | Create: 39 | nap < main.go - save snippet from stdin 40 | nap example/main.go < main.go - save snippet with name`) 41 | ) 42 | 43 | func main() { 44 | runCLI(os.Args[1:]) 45 | } 46 | 47 | func runCLI(args []string) { 48 | config := readConfig() 49 | snippets := readSnippets(config) 50 | snippets = migrateSnippets(config, snippets) 51 | snippets = scanSnippets(config, snippets) 52 | 53 | stdin := readStdin() 54 | if stdin != "" { 55 | saveSnippet(stdin, args, config, snippets) 56 | return 57 | } 58 | 59 | if len(args) > 0 { 60 | switch args[0] { 61 | case "list": 62 | listSnippets(snippets) 63 | case "-h", "--help": 64 | fmt.Println(helpText) 65 | default: 66 | snippet := findSnippet(args[0], snippets) 67 | fmt.Print(snippet.Content(isatty.IsTerminal(os.Stdout.Fd()))) 68 | } 69 | return 70 | } 71 | 72 | err := runInteractiveMode(config, snippets) 73 | if err != nil { 74 | fmt.Println("Alas, there's been an error", err) 75 | } 76 | } 77 | 78 | // parseName returns a folder, name, and language for the given name. 79 | // this is useful for parsing file names when passed as command line arguments. 80 | // 81 | // Example: 82 | // 83 | // Notes/Hello.go -> (Notes, Hello, go) 84 | // Hello.go -> (Misc, Hello, go) 85 | // Notes/Hello -> (Notes, Hello, go) 86 | func parseName(s string) (string, string, string) { 87 | var ( 88 | folder = defaultSnippetFolder 89 | name = defaultSnippetName 90 | language = defaultLanguage 91 | remaining string 92 | ) 93 | 94 | tokens := strings.Split(s, "/") 95 | if len(tokens) > 1 { 96 | folder = tokens[0] 97 | remaining = tokens[1] 98 | } else { 99 | remaining = tokens[0] 100 | } 101 | 102 | tokens = strings.Split(remaining, ".") 103 | if len(tokens) > 1 { 104 | name = tokens[0] 105 | language = tokens[1] 106 | } else { 107 | name = tokens[0] 108 | } 109 | 110 | return folder, name, language 111 | } 112 | 113 | // readStdin returns the stdin that is piped in to the command line interface. 114 | func readStdin() string { 115 | stat, err := os.Stdin.Stat() 116 | if err != nil { 117 | return "" 118 | } 119 | 120 | if stat.Mode()&os.ModeCharDevice != 0 { 121 | return "" 122 | } 123 | 124 | reader := bufio.NewReader(os.Stdin) 125 | var b strings.Builder 126 | 127 | for { 128 | r, _, err := reader.ReadRune() 129 | if err != nil && err == io.EOF { 130 | break 131 | } 132 | _, err = b.WriteRune(r) 133 | if err != nil { 134 | return "" 135 | } 136 | } 137 | 138 | return b.String() 139 | } 140 | 141 | // readSnippets returns all the snippets read from the snippets.json file. 142 | func readSnippets(config Config) []Snippet { 143 | var snippets []Snippet 144 | file := filepath.Join(config.Home, config.File) 145 | dir, err := os.ReadFile(file) 146 | if err != nil { 147 | // File does not exist, create one. 148 | err := os.MkdirAll(config.Home, os.ModePerm) 149 | if err != nil { 150 | fmt.Printf("Unable to create directory %s, %+v", config.Home, err) 151 | } 152 | f, err := os.Create(file) 153 | if err != nil { 154 | fmt.Printf("Unable to create file %s, %+v", file, err) 155 | } 156 | defer f.Close() 157 | dir = []byte("[]") 158 | _, _ = f.Write(dir) 159 | } 160 | err = json.Unmarshal(dir, &snippets) 161 | if err != nil { 162 | fmt.Printf("Unable to unmarshal %s file, %+v\n", file, err) 163 | return snippets 164 | } 165 | return snippets 166 | } 167 | 168 | // migrateSnippets migrates any legacy snippet - format to the new / format 169 | func migrateSnippets(config Config, snippets []Snippet) []Snippet { 170 | var migrated bool 171 | for idx, snippet := range snippets { 172 | legacyPath := filepath.Join(config.Home, snippet.LegacyPath()) 173 | if _, err := os.Stat(legacyPath); err != nil { 174 | if !errors.Is(err, fs.ErrNotExist) { 175 | fmt.Printf("could not access %q: %v\n", legacyPath, err) 176 | } 177 | continue 178 | } 179 | file := strings.TrimPrefix(snippet.LegacyPath(), fmt.Sprintf("%s-", snippet.Folder)) 180 | newDir := filepath.Join(config.Home, snippet.Folder) 181 | newPath := filepath.Join(newDir, file) 182 | if err := os.MkdirAll(newDir, os.ModePerm); err != nil { 183 | fmt.Printf("could not create %q: %v\n", newDir, err) 184 | continue 185 | } 186 | if err := os.Rename(legacyPath, newPath); err != nil { 187 | fmt.Printf("could not move %q to %q: %v\n", legacyPath, newPath, err) 188 | } 189 | migrated = true 190 | snippet.File = file 191 | snippets[idx] = snippet 192 | } 193 | if migrated { 194 | writeSnippets(config, snippets) 195 | } 196 | return snippets 197 | } 198 | 199 | // scanSnippets scans for any new/removed snippets and adds them to snippets.json 200 | func scanSnippets(config Config, snippets []Snippet) []Snippet { 201 | var modified bool 202 | snippetExists := func(path string) bool { 203 | for _, snippet := range snippets { 204 | if path == snippet.Path() { 205 | return true 206 | } 207 | } 208 | return false 209 | } 210 | 211 | homeEntries, err := os.ReadDir(config.Home) 212 | if err != nil { 213 | fmt.Printf("could not scan config home: %v\n", err) 214 | return snippets 215 | } 216 | 217 | for _, homeEntry := range homeEntries { 218 | if !homeEntry.IsDir() { 219 | continue 220 | } 221 | if strings.HasPrefix(homeEntry.Name(), ".") { 222 | continue 223 | } 224 | 225 | folderPath := filepath.Join(config.Home, homeEntry.Name()) 226 | folderEntries, err := os.ReadDir(folderPath) 227 | if err != nil { 228 | fmt.Printf("could not scan %q: %v\n", folderPath, err) 229 | continue 230 | } 231 | 232 | for _, folderEntry := range folderEntries { 233 | if folderEntry.IsDir() { 234 | continue 235 | } 236 | 237 | snippetPath := filepath.Join(homeEntry.Name(), folderEntry.Name()) 238 | if !snippetExists(snippetPath) { 239 | name := folderEntry.Name() 240 | ext := filepath.Ext(name) 241 | snippets = append(snippets, Snippet{ 242 | Folder: homeEntry.Name(), 243 | Date: time.Now(), 244 | Name: strings.TrimSuffix(name, ext), 245 | File: name, 246 | Language: strings.TrimPrefix(ext, "."), 247 | Tags: make([]string, 0), 248 | }) 249 | modified = true 250 | } 251 | } 252 | } 253 | 254 | var idx int 255 | for _, snippet := range snippets { 256 | snippetPath := filepath.Join(config.Home, snippet.Path()) 257 | if _, err := os.Stat(snippetPath); !errors.Is(err, fs.ErrNotExist) { 258 | snippets[idx] = snippet 259 | idx++ 260 | modified = true 261 | } 262 | } 263 | snippets = snippets[:idx] 264 | 265 | if modified { 266 | writeSnippets(config, snippets) 267 | } 268 | 269 | return snippets 270 | } 271 | 272 | func saveSnippet(content string, args []string, config Config, snippets []Snippet) { 273 | // Save snippet to location 274 | name := defaultSnippetName 275 | if len(args) > 0 { 276 | name = strings.Join(args, " ") 277 | } 278 | 279 | folder, name, language := parseName(name) 280 | file := fmt.Sprintf("%s.%s", name, language) 281 | filePath := filepath.Join(config.Home, folder, file) 282 | if err := os.MkdirAll(filepath.Dir(filePath), os.ModePerm); err != nil { 283 | fmt.Println("unable to create folder") 284 | return 285 | } 286 | err := os.WriteFile(filePath, []byte(content), 0o644) 287 | if err != nil { 288 | fmt.Println("unable to create snippet") 289 | return 290 | } 291 | 292 | // Add snippet metadata 293 | snippet := Snippet{ 294 | Folder: folder, 295 | Date: time.Now(), 296 | Name: name, 297 | File: file, 298 | Language: language, 299 | } 300 | 301 | snippets = append([]Snippet{snippet}, snippets...) 302 | writeSnippets(config, snippets) 303 | } 304 | 305 | func writeSnippets(config Config, snippets []Snippet) { 306 | b, err := json.Marshal(snippets) 307 | if err != nil { 308 | fmt.Println("Could not marshal latest snippet data.", err) 309 | return 310 | } 311 | err = os.WriteFile(filepath.Join(config.Home, config.File), b, os.ModePerm) 312 | if err != nil { 313 | fmt.Println("Could not save snippets file.", err) 314 | } 315 | } 316 | 317 | func listSnippets(snippets []Snippet) { 318 | for _, snippet := range snippets { 319 | fmt.Println(snippet) 320 | } 321 | } 322 | 323 | func findSnippet(search string, snippets []Snippet) Snippet { 324 | matches := fuzzy.FindFrom(search, Snippets{snippets}) 325 | if len(matches) > 0 { 326 | return snippets[matches[0].Index] 327 | } 328 | return Snippet{} 329 | } 330 | 331 | func runInteractiveMode(config Config, snippets []Snippet) error { 332 | if len(snippets) == 0 { 333 | // welcome to nap! 334 | snippets = append(snippets, defaultSnippet) 335 | } 336 | state := readState() 337 | 338 | folders := make(map[Folder][]list.Item) 339 | for _, snippet := range snippets { 340 | folders[Folder(snippet.Folder)] = append(folders[Folder(snippet.Folder)], list.Item(snippet)) 341 | } 342 | 343 | defaultStyles := DefaultStyles(config) 344 | 345 | var folderItems []list.Item 346 | foldersSlice := maps.Keys(folders) 347 | slices.Sort(foldersSlice) 348 | for _, folder := range foldersSlice { 349 | folderItems = append(folderItems, list.Item(folder)) 350 | } 351 | if len(folderItems) <= 0 { 352 | folderItems = append(folderItems, list.Item(Folder(defaultSnippetFolder))) 353 | } 354 | folderList := list.New(folderItems, folderDelegate{defaultStyles.Folders.Blurred}, 0, 0) 355 | folderList.Title = "Folders" 356 | 357 | folderList.SetShowHelp(false) 358 | folderList.SetFilteringEnabled(false) 359 | folderList.SetShowStatusBar(false) 360 | folderList.DisableQuitKeybindings() 361 | folderList.Styles.NoItems = lipgloss.NewStyle().Margin(0, 2).Foreground(lipgloss.Color(config.GrayColor)) 362 | folderList.SetStatusBarItemName("folder", "folders") 363 | 364 | for idx, folder := range foldersSlice { 365 | if string(folder) == state.CurrentFolder { 366 | folderList.Select(idx) 367 | break 368 | } 369 | } 370 | 371 | content := viewport.New(80, 0) 372 | 373 | lists := map[Folder]*list.Model{} 374 | 375 | currentFolder := folderList.SelectedItem().(Folder) 376 | for folder, items := range folders { 377 | snippetList := newList(items, 20, defaultStyles.Snippets.Focused) 378 | if folder == currentFolder { 379 | for idx, item := range snippetList.Items() { 380 | if s, ok := item.(Snippet); ok && s.File == state.CurrentSnippet { 381 | snippetList.Select(idx) 382 | break 383 | } 384 | } 385 | } 386 | lists[folder] = snippetList 387 | } 388 | 389 | m := &Model{ 390 | Lists: lists, 391 | Folders: folderList, 392 | Code: content, 393 | ContentStyle: defaultStyles.Content.Blurred, 394 | ListStyle: defaultStyles.Snippets.Focused, 395 | FoldersStyle: defaultStyles.Folders.Blurred, 396 | keys: DefaultKeyMap, 397 | help: help.New(), 398 | config: config, 399 | inputs: []textinput.Model{ 400 | newTextInput(defaultSnippetFolder + " "), 401 | newTextInput(defaultSnippetName + " "), 402 | newTextInput(config.DefaultLanguage), 403 | }, 404 | tagsInput: newTextInput("Tags"), 405 | } 406 | p := tea.NewProgram(m, tea.WithAltScreen()) 407 | model, err := p.Run() 408 | if err != nil { 409 | return err 410 | } 411 | fm, ok := model.(*Model) 412 | if !ok { 413 | return err 414 | } 415 | var allSnippets []list.Item 416 | for _, list := range fm.Lists { 417 | allSnippets = append(allSnippets, list.Items()...) 418 | } 419 | b, err := json.Marshal(allSnippets) 420 | if err != nil { 421 | return err 422 | } 423 | err = os.WriteFile(filepath.Join(config.Home, config.File), b, os.ModePerm) 424 | if err != nil { 425 | return err 426 | } 427 | return nil 428 | } 429 | 430 | func newList(items []list.Item, height int, styles SnippetsBaseStyle) *list.Model { 431 | snippetList := list.New(items, snippetDelegate{styles, navigatingState}, 25, height) 432 | snippetList.SetShowHelp(false) 433 | snippetList.SetShowFilter(false) 434 | snippetList.SetShowTitle(false) 435 | snippetList.Styles.StatusBar = lipgloss.NewStyle().Margin(1, 2).Foreground(lipgloss.Color("240")).MaxWidth(35 - 2) 436 | snippetList.Styles.NoItems = lipgloss.NewStyle().Margin(0, 2).Foreground(lipgloss.Color("8")).MaxWidth(35 - 2) 437 | snippetList.FilterInput.Prompt = "Find: " 438 | snippetList.FilterInput.PromptStyle = styles.Title 439 | snippetList.SetStatusBarItemName("snippet", "snippets") 440 | snippetList.DisableQuitKeybindings() 441 | snippetList.Styles.Title = styles.Title 442 | snippetList.Styles.TitleBar = styles.TitleBar 443 | 444 | return &snippetList 445 | } 446 | 447 | func newTextInput(placeholder string) textinput.Model { 448 | i := textinput.New() 449 | i.Prompt = "" 450 | i.PromptStyle = lipgloss.NewStyle().Margin(0, 1) 451 | i.Placeholder = placeholder 452 | return i 453 | } 454 | -------------------------------------------------------------------------------- /model.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "math/rand" 7 | "os" 8 | "path/filepath" 9 | "strings" 10 | "time" 11 | 12 | "github.com/alecthomas/chroma/v2/quick" 13 | "github.com/atotto/clipboard" 14 | "github.com/charmbracelet/bubbles/help" 15 | "github.com/charmbracelet/bubbles/key" 16 | "github.com/charmbracelet/bubbles/list" 17 | "github.com/charmbracelet/bubbles/textinput" 18 | "github.com/charmbracelet/bubbles/viewport" 19 | tea "github.com/charmbracelet/bubbletea" 20 | "github.com/charmbracelet/lipgloss" 21 | "golang.org/x/exp/maps" 22 | "golang.org/x/exp/slices" 23 | ) 24 | 25 | const maxPane = 3 26 | 27 | type pane int 28 | 29 | const ( 30 | snippetPane pane = iota 31 | contentPane 32 | folderPane 33 | ) 34 | 35 | type state int 36 | 37 | const ( 38 | navigatingState state = iota 39 | deletingState 40 | creatingState 41 | copyingState 42 | pastingState 43 | quittingState 44 | editingState 45 | editingTagsState 46 | ) 47 | 48 | type input int 49 | 50 | const ( 51 | folderInput input = iota 52 | nameInput 53 | languageInput 54 | ) 55 | 56 | // Model represents the state of the application. 57 | // It contains all the snippets organized in folders. 58 | type Model struct { 59 | // the config map. 60 | config Config 61 | // the key map. 62 | keys KeyMap 63 | // the help model. 64 | help help.Model 65 | // the height of the terminal. 66 | height int 67 | // the working directory. 68 | Workdir string 69 | // the List of snippets to display to the user. 70 | Lists map[Folder]*list.Model 71 | // the list of Folders to display to the user. 72 | Folders list.Model 73 | // the viewport of the Code snippet. 74 | Code viewport.Model 75 | LineNumbers viewport.Model 76 | // the input for snippet folder, name, language 77 | activeInput input 78 | inputs []textinput.Model 79 | tagsInput textinput.Model 80 | // the current active pane of focus. 81 | pane pane 82 | // the current state / action of the application. 83 | state state 84 | // stying for components 85 | ListStyle SnippetsBaseStyle 86 | FoldersStyle FoldersBaseStyle 87 | ContentStyle ContentBaseStyle 88 | } 89 | 90 | // Init initialzes the application model. 91 | func (m *Model) Init() tea.Cmd { 92 | rand.Seed(time.Now().Unix()) 93 | 94 | m.Folders.Styles.Title = m.FoldersStyle.Title 95 | m.Folders.Styles.TitleBar = m.FoldersStyle.TitleBar 96 | m.updateKeyMap() 97 | 98 | return func() tea.Msg { 99 | return updateContentMsg(m.selectedSnippet()) 100 | } 101 | } 102 | 103 | // updateContentMsg tells the application to update the content view with the 104 | // given snippet. 105 | type updateContentMsg Snippet 106 | 107 | // updateContent instructs the application to fetch the latest contents of the 108 | // snippet file. 109 | // 110 | // This is useful after a Paste or Edit. 111 | func (m *Model) updateContent() tea.Cmd { 112 | return func() tea.Msg { 113 | return updateContentMsg(m.selectedSnippet()) 114 | } 115 | } 116 | 117 | type updateFoldersMsg struct { 118 | items []list.Item 119 | selectedFolderIndex int 120 | } 121 | 122 | // updateFolders returns a Cmd to tell the application that there are possible 123 | // folder changes to update. 124 | func (m *Model) updateFolders() tea.Cmd { 125 | return func() tea.Msg { 126 | msg := m.updateFoldersView() 127 | return msg 128 | } 129 | } 130 | 131 | // changeStateMsg tells the application to enter a different state. 132 | type changeStateMsg struct{ newState state } 133 | 134 | // changeState returns a Cmd to enter a different state. 135 | func changeState(newState state) tea.Cmd { 136 | return func() tea.Msg { 137 | return changeStateMsg{newState} 138 | } 139 | } 140 | 141 | // Update updates the model based on user interaction. 142 | func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 143 | switch msg := msg.(type) { 144 | case updateFoldersMsg: 145 | setItemsCmd := m.Folders.SetItems(msg.items) 146 | m.Folders.Select(msg.selectedFolderIndex) 147 | var cmd tea.Cmd 148 | m.Folders, cmd = m.Folders.Update(msg) 149 | return m, tea.Batch(setItemsCmd, cmd) 150 | case updateContentMsg: 151 | return m.updateContentView(msg) 152 | case changeStateMsg: 153 | m.List().SetDelegate(snippetDelegate{m.ListStyle, msg.newState}) 154 | 155 | var cmd tea.Cmd 156 | 157 | if m.state == msg.newState { 158 | break 159 | } 160 | 161 | wasEditing := m.state == editingState 162 | wasPasting := m.state == pastingState 163 | wasCreating := m.state == creatingState 164 | m.state = msg.newState 165 | m.updateKeyMap() 166 | m.updateActivePane(msg) 167 | 168 | switch msg.newState { 169 | case navigatingState: 170 | if wasPasting || wasCreating { 171 | return m, m.updateContent() 172 | } 173 | 174 | if wasEditing { 175 | m.blurInputs() 176 | i := m.List().Index() 177 | snippet := m.selectedSnippet() 178 | if m.inputs[nameInput].Value() != "" { 179 | snippet.Name = m.inputs[nameInput].Value() 180 | } else { 181 | snippet.Name = defaultSnippetName 182 | } 183 | if m.inputs[folderInput].Value() != "" { 184 | snippet.Folder = m.inputs[folderInput].Value() 185 | } else { 186 | snippet.Folder = defaultSnippetFolder 187 | } 188 | if m.inputs[languageInput].Value() != "" { 189 | snippet.Language = m.inputs[languageInput].Value() 190 | } else { 191 | snippet.Language = m.config.DefaultLanguage 192 | } 193 | file := fmt.Sprintf("%s.%s", snippet.Name, snippet.Language) 194 | snippet.File = file 195 | newPath := filepath.Join(m.config.Home, snippet.Path()) 196 | _ = os.MkdirAll(filepath.Dir(newPath), os.ModePerm) 197 | _ = os.Rename(m.selectedSnippetFilePath(), newPath) 198 | setCmd := m.List().SetItem(i, snippet) 199 | m.pane = snippetPane 200 | cmd = tea.Batch(setCmd, m.updateFolders(), m.updateContent()) 201 | } 202 | case pastingState: 203 | content, err := clipboard.ReadAll() 204 | if err != nil { 205 | return m, changeState(navigatingState) 206 | } 207 | f, err := os.OpenFile(m.selectedSnippetFilePath(), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) 208 | if err != nil { 209 | return m, changeState(navigatingState) 210 | } 211 | defer f.Close() 212 | f.WriteString(content) 213 | return m, changeState(navigatingState) 214 | case deletingState: 215 | m.state = deletingState 216 | case editingState: 217 | m.pane = contentPane 218 | snippet := m.selectedSnippet() 219 | m.inputs[folderInput].SetValue(snippet.Folder) 220 | if snippet.Name == defaultSnippetName { 221 | m.inputs[nameInput].SetValue("") 222 | } else { 223 | m.inputs[nameInput].SetValue(snippet.Name) 224 | } 225 | m.inputs[languageInput].SetValue(snippet.Language) 226 | cmd = m.focusInput(m.activeInput) 227 | case creatingState: 228 | case copyingState: 229 | m.pane = snippetPane 230 | m.state = copyingState 231 | m.updateActivePane(msg) 232 | cmd = tea.Tick(time.Second, func(t time.Time) tea.Msg { 233 | return changeStateMsg{navigatingState} 234 | }) 235 | } 236 | 237 | m.updateKeyMap() 238 | m.updateActivePane(msg) 239 | return m, cmd 240 | case tea.WindowSizeMsg: 241 | m.height = msg.Height - 4 242 | for _, li := range m.Lists { 243 | li.SetHeight(m.height) 244 | } 245 | m.Folders.SetHeight(m.height) 246 | m.Code.Height = m.height 247 | m.LineNumbers.Height = m.height 248 | m.Code.Width = msg.Width - m.List().Width() - m.Folders.Width() - 20 249 | m.LineNumbers.Width = 5 250 | return m, nil 251 | case tea.KeyMsg: 252 | if m.List().FilterState() == list.Filtering { 253 | break 254 | } 255 | 256 | if m.state == deletingState { 257 | switch { 258 | case key.Matches(msg, m.keys.Confirm): 259 | _ = os.Remove(m.selectedSnippetFilePath()) 260 | m.List().RemoveItem(m.List().Index()) 261 | m.state = navigatingState 262 | m.updateKeyMap() 263 | return m, tea.Batch(changeState(navigatingState), func() tea.Msg { 264 | return updateContentMsg(m.selectedSnippet()) 265 | }) 266 | case key.Matches(msg, m.keys.Quit, m.keys.Cancel): 267 | return m, changeState(navigatingState) 268 | } 269 | return m, nil 270 | } else if m.state == copyingState { 271 | return m, changeState(navigatingState) 272 | } else if m.state == editingState { 273 | if msg.String() == "esc" || msg.String() == "enter" { 274 | return m, changeState(navigatingState) 275 | } 276 | var cmd tea.Cmd 277 | var cmds []tea.Cmd 278 | for i := range m.inputs { 279 | m.inputs[i], cmd = m.inputs[i].Update(msg) 280 | cmds = append(cmds, cmd) 281 | } 282 | return m, tea.Batch(cmds...) 283 | } 284 | 285 | switch { 286 | case key.Matches(msg, m.keys.NextPane): 287 | m.nextPane() 288 | case key.Matches(msg, m.keys.PreviousPane): 289 | m.previousPane() 290 | case key.Matches(msg, m.keys.Quit): 291 | m.saveState() 292 | m.state = quittingState 293 | return m, tea.Quit 294 | case key.Matches(msg, m.keys.NewSnippet): 295 | m.state = creatingState 296 | return m, m.createNewSnippetFile() 297 | case key.Matches(msg, m.keys.MoveSnippetDown): 298 | m.moveSnippetDown() 299 | case key.Matches(msg, m.keys.MoveSnippetUp): 300 | m.moveSnippetUp() 301 | case key.Matches(msg, m.keys.PasteSnippet): 302 | return m, changeState(pastingState) 303 | case key.Matches(msg, m.keys.RenameSnippet): 304 | m.activeInput = nameInput 305 | return m, changeState(editingState) 306 | case key.Matches(msg, m.keys.ChangeFolder): 307 | m.pane = snippetPane 308 | cmd := m.updateActivePane(msg) 309 | return m, cmd 310 | case key.Matches(msg, m.keys.ToggleHelp): 311 | m.help.ShowAll = !m.help.ShowAll 312 | 313 | var newHeight int 314 | if m.help.ShowAll { 315 | newHeight = m.height - 4 316 | } else { 317 | newHeight = m.height 318 | } 319 | m.List().SetHeight(newHeight) 320 | m.Folders.SetHeight(newHeight) 321 | m.Code.Height = newHeight 322 | m.LineNumbers.Height = newHeight 323 | case key.Matches(msg, m.keys.SetFolder): 324 | m.activeInput = folderInput 325 | return m, changeState(editingState) 326 | case key.Matches(msg, m.keys.SetLanguage): 327 | m.activeInput = languageInput 328 | return m, changeState(editingState) 329 | case key.Matches(msg, m.keys.CopySnippet): 330 | return m, func() tea.Msg { 331 | content, err := os.ReadFile(m.selectedSnippetFilePath()) 332 | if err != nil { 333 | return changeStateMsg{navigatingState} 334 | } 335 | clipboard.WriteAll(string(content)) 336 | return changeStateMsg{copyingState} 337 | } 338 | case key.Matches(msg, m.keys.DeleteSnippet): 339 | m.pane = snippetPane 340 | m.updateActivePane(msg) 341 | m.List().Title = "Delete? (y/N)" 342 | return m, changeState(deletingState) 343 | case key.Matches(msg, m.keys.EditSnippet): 344 | return m, m.editSnippet() 345 | case key.Matches(msg, m.keys.Search): 346 | m.pane = snippetPane 347 | } 348 | } 349 | 350 | m.updateKeyMap() 351 | cmd := m.updateActivePane(msg) 352 | return m, cmd 353 | } 354 | 355 | // blurInputs blurs all the inputs. 356 | func (m *Model) blurInputs() { 357 | for i := range m.inputs { 358 | m.inputs[i].Blur() 359 | } 360 | } 361 | 362 | // focusInput focuses the speficied input and blurs the rest. 363 | func (m *Model) focusInput(i input) tea.Cmd { 364 | m.blurInputs() 365 | m.inputs[i].CursorEnd() 366 | return m.inputs[i].Focus() 367 | } 368 | 369 | // selectedSnippetFilePath returns the file path of the snippet that is 370 | // currently selected. 371 | func (m *Model) selectedSnippetFilePath() string { 372 | return filepath.Join(m.config.Home, m.selectedSnippet().Path()) 373 | } 374 | 375 | // nextPane sets the next pane to be active. 376 | func (m *Model) nextPane() { 377 | m.pane = (m.pane + 1) % maxPane 378 | } 379 | 380 | // previousPane sets the previous pane to be active. 381 | func (m *Model) previousPane() { 382 | m.pane-- 383 | if m.pane < 0 { 384 | m.pane = maxPane - 1 385 | } 386 | } 387 | 388 | // editSnippet opens the editor with the selected snippet file path. 389 | func (m *Model) editSnippet() tea.Cmd { 390 | return tea.ExecProcess(editorCmd(m.selectedSnippetFilePath()), func(err error) tea.Msg { 391 | return updateContentMsg(m.selectedSnippet()) 392 | }) 393 | } 394 | 395 | func (m *Model) noContentHints() []keyHint { 396 | return []keyHint{ 397 | {m.keys.EditSnippet, "edit contents"}, 398 | {m.keys.PasteSnippet, "paste clipboard"}, 399 | {m.keys.RenameSnippet, "rename"}, 400 | {m.keys.SetFolder, "set folder"}, 401 | {m.keys.SetLanguage, "set language"}, 402 | } 403 | } 404 | 405 | // updateFolderView updates the folders list to display the current folders. 406 | func (m *Model) updateFoldersView() tea.Msg { 407 | var selectedFolder Folder 408 | selectedFolderIndex := m.Folders.Index() 409 | for folder, li := range m.Lists { 410 | for i, item := range li.Items() { 411 | snippet, ok := item.(Snippet) 412 | if !ok { 413 | continue 414 | } 415 | f := Folder(snippet.Folder) 416 | _, ok = m.Lists[f] 417 | if !ok { 418 | m.Lists[f] = newList([]list.Item{}, m.height, m.ListStyle) 419 | selectedFolder = f 420 | } 421 | if f != folder { 422 | li.RemoveItem(i) 423 | m.Lists[f].InsertItem(0, item) 424 | selectedFolder = f 425 | } 426 | } 427 | } 428 | var folderItems []list.Item 429 | 430 | foldersSlice := maps.Keys(m.Lists) 431 | slices.Sort(foldersSlice) 432 | for i, folder := range foldersSlice { 433 | folderItems = append(folderItems, Folder(folder)) 434 | if folder == selectedFolder { 435 | selectedFolderIndex = i 436 | } 437 | } 438 | 439 | return updateFoldersMsg{ 440 | items: folderItems, 441 | selectedFolderIndex: selectedFolderIndex, 442 | } 443 | } 444 | 445 | // updateContentView updates the content view with the correct content based on 446 | // the active snippet or display the appropriate error message / hint message. 447 | func (m *Model) updateContentView(msg updateContentMsg) (tea.Model, tea.Cmd) { 448 | if len(m.List().Items()) <= 0 { 449 | m.displayKeyHint([]keyHint{ 450 | {m.keys.NewSnippet, "create a new snippet."}, 451 | }) 452 | return m, nil 453 | } 454 | 455 | var b bytes.Buffer 456 | content, err := os.ReadFile(filepath.Join(m.config.Home, Snippet(msg).Path())) 457 | if err != nil { 458 | m.displayKeyHint(m.noContentHints()) 459 | return m, nil 460 | } 461 | 462 | if string(content) == "" { 463 | m.displayKeyHint(m.noContentHints()) 464 | return m, nil 465 | } 466 | 467 | // b.WriteString(string(content)) 468 | err = quick.Highlight(&b, string(content), msg.Language, "terminal16m", m.config.Theme) 469 | if err != nil { 470 | m.displayError("Unable to highlight file.") 471 | return m, nil 472 | } 473 | 474 | s := b.String() 475 | m.writeLineNumbers(lipgloss.Height(s)) 476 | m.Code.SetContent(s) 477 | return m, nil 478 | } 479 | 480 | type keyHint struct { 481 | binding key.Binding 482 | help string 483 | } 484 | 485 | // displayKeyHint updates the content viewport with instructions on the 486 | // relevent key binding that the user should most likely press. 487 | func (m *Model) displayKeyHint(hints []keyHint) { 488 | m.LineNumbers.SetContent(strings.Repeat(" ~ \n", len(hints))) 489 | var s strings.Builder 490 | for _, hint := range hints { 491 | s.WriteString( 492 | fmt.Sprintf("%s %s\n", 493 | m.ContentStyle.EmptyHintKey.Render(hint.binding.Help().Key), 494 | m.ContentStyle.EmptyHint.Render("• "+hint.help), 495 | )) 496 | } 497 | m.Code.SetContent(s.String()) 498 | } 499 | 500 | // displayError updates the content viewport with the error message provided. 501 | func (m *Model) displayError(error string) { 502 | m.LineNumbers.SetContent(" ~ ") 503 | m.Code.SetContent(fmt.Sprintf("%s", 504 | m.ContentStyle.EmptyHint.Render(error), 505 | )) 506 | } 507 | 508 | // writeLineNumbers writes the number of line numbers to the line number 509 | // viewport. 510 | func (m *Model) writeLineNumbers(n int) { 511 | var lineNumbers strings.Builder 512 | for i := 1; i < n; i++ { 513 | lineNumbers.WriteString(fmt.Sprintf("%3d \n", i)) 514 | } 515 | m.LineNumbers.SetContent(lineNumbers.String() + " ~ \n") 516 | } 517 | 518 | const tabSpaces = 4 519 | 520 | // updateActivePane updates the currently active pane. 521 | func (m *Model) updateActivePane(msg tea.Msg) tea.Cmd { 522 | var cmds []tea.Cmd 523 | var cmd tea.Cmd 524 | switch m.pane { 525 | case folderPane: 526 | m.ListStyle = DefaultStyles(m.config).Snippets.Blurred 527 | m.ContentStyle = DefaultStyles(m.config).Content.Blurred 528 | m.FoldersStyle = DefaultStyles(m.config).Folders.Focused 529 | m.Folders, cmd = m.Folders.Update(msg) 530 | m.updateKeyMap() 531 | cmds = append(cmds, cmd, m.updateContent()) 532 | case snippetPane: 533 | m.ListStyle = DefaultStyles(m.config).Snippets.Focused 534 | m.ContentStyle = DefaultStyles(m.config).Content.Blurred 535 | m.FoldersStyle = DefaultStyles(m.config).Folders.Blurred 536 | *m.List(), cmd = (*m.List()).Update(msg) 537 | cmds = append(cmds, cmd) 538 | case contentPane: 539 | m.ListStyle = DefaultStyles(m.config).Snippets.Blurred 540 | m.ContentStyle = DefaultStyles(m.config).Content.Focused 541 | m.FoldersStyle = DefaultStyles(m.config).Folders.Blurred 542 | m.Code, cmd = m.Code.Update(msg) 543 | cmds = append(cmds, cmd) 544 | m.LineNumbers, cmd = m.LineNumbers.Update(msg) 545 | cmds = append(cmds, cmd) 546 | } 547 | m.List().SetDelegate(snippetDelegate{m.ListStyle, m.state}) 548 | m.Folders.SetDelegate(folderDelegate{m.FoldersStyle}) 549 | m.Folders.Styles.TitleBar = m.FoldersStyle.TitleBar 550 | m.Folders.Styles.Title = m.FoldersStyle.Title 551 | 552 | return tea.Batch(cmds...) 553 | } 554 | 555 | // updateKeyMap disables or enables the keys based on the current state of the 556 | // snippet list. 557 | func (m *Model) updateKeyMap() { 558 | hasItems := len(m.List().VisibleItems()) > 0 559 | isFiltering := m.List().FilterState() == list.Filtering 560 | isEditing := m.state == editingState 561 | m.keys.DeleteSnippet.SetEnabled(hasItems && !isFiltering && !isEditing) 562 | m.keys.CopySnippet.SetEnabled(hasItems && !isFiltering && !isEditing) 563 | m.keys.PasteSnippet.SetEnabled(hasItems && !isFiltering && !isEditing) 564 | m.keys.EditSnippet.SetEnabled(hasItems && !isFiltering && !isEditing) 565 | m.keys.NewSnippet.SetEnabled(!isFiltering && !isEditing) 566 | m.keys.ChangeFolder.SetEnabled(m.pane == folderPane) 567 | } 568 | 569 | // selectedSnippet returns the currently selected snippet. 570 | func (m *Model) selectedSnippet() Snippet { 571 | item := m.List().SelectedItem() 572 | if item == nil { 573 | return defaultSnippet 574 | } 575 | return item.(Snippet) 576 | } 577 | 578 | // selected folder returns the currently selected folder. 579 | func (m *Model) selectedFolder() Folder { 580 | item := m.Folders.SelectedItem() 581 | if item == nil { 582 | return "misc" 583 | } 584 | return item.(Folder) 585 | } 586 | 587 | // List returns the active list. 588 | func (m *Model) List() *list.Model { 589 | return m.Lists[m.selectedFolder()] 590 | } 591 | 592 | func (m *Model) moveSnippetDown() { 593 | currentPosition := m.List().Index() 594 | currentItem := m.List().SelectedItem() 595 | m.List().InsertItem(currentPosition+2, currentItem) 596 | m.List().RemoveItem(currentPosition) 597 | m.List().CursorDown() 598 | } 599 | 600 | func (m *Model) moveSnippetUp() { 601 | currentPosition := m.List().Index() 602 | currentItem := m.List().SelectedItem() 603 | m.List().RemoveItem(currentPosition) 604 | m.List().InsertItem(currentPosition-1, currentItem) 605 | m.List().CursorUp() 606 | } 607 | 608 | // createNewSnippet creates a new snippet file and adds it to the the list. 609 | func (m *Model) createNewSnippetFile() tea.Cmd { 610 | return func() tea.Msg { 611 | folder := defaultSnippetFolder 612 | folderItem := m.Folders.SelectedItem() 613 | if folderItem != nil && folderItem.FilterValue() != "" { 614 | folder = folderItem.FilterValue() 615 | } 616 | 617 | file := fmt.Sprintf("snippet-%d.%s", rand.Intn(1000000), m.config.DefaultLanguage) 618 | 619 | newSnippet := Snippet{ 620 | Name: defaultSnippetName, 621 | Date: time.Now(), 622 | File: file, 623 | Language: m.config.DefaultLanguage, 624 | Tags: []string{}, 625 | Folder: folder, 626 | } 627 | 628 | _, _ = os.Create(filepath.Join(m.config.Home, newSnippet.Path())) 629 | 630 | m.List().InsertItem(m.List().Index(), newSnippet) 631 | return changeStateMsg{navigatingState} 632 | } 633 | } 634 | 635 | // View returns the view string for the application model. 636 | func (m *Model) View() string { 637 | if m.state == quittingState { 638 | return "" 639 | } 640 | 641 | var ( 642 | folder = m.ContentStyle.Title.Render(m.selectedSnippet().Folder) 643 | name = m.ContentStyle.Title.Render(m.selectedSnippet().Name) 644 | language = m.ContentStyle.Title.Render(m.selectedSnippet().Language) 645 | titleBar = m.ListStyle.TitleBar.Render("Snippets") 646 | ) 647 | 648 | if m.state == editingState { 649 | folder = m.inputs[folderInput].View() 650 | name = m.inputs[nameInput].View() 651 | language = m.inputs[languageInput].View() 652 | } else if m.state == copyingState { 653 | titleBar = m.ListStyle.CopiedTitleBar.Render("Copied Snippet!") 654 | } else if m.state == deletingState { 655 | titleBar = m.ListStyle.DeletedTitleBar.Render("Delete Snippet? (y/N)") 656 | } else if m.List().SettingFilter() { 657 | titleBar = m.ListStyle.TitleBar.Render(m.List().FilterInput.View()) 658 | } 659 | 660 | return lipgloss.JoinVertical( 661 | lipgloss.Top, 662 | lipgloss.JoinHorizontal( 663 | lipgloss.Left, 664 | m.FoldersStyle.Base.Render(m.Folders.View()), 665 | m.ListStyle.Base.Render(titleBar+m.List().View()), 666 | lipgloss.JoinVertical(lipgloss.Top, 667 | lipgloss.JoinHorizontal(lipgloss.Left, 668 | folder, 669 | m.ContentStyle.Separator.Render("/"), 670 | name, 671 | m.ContentStyle.Separator.Render("."), 672 | language, 673 | ), 674 | lipgloss.JoinHorizontal(lipgloss.Left, 675 | m.ContentStyle.LineNumber.Render(m.LineNumbers.View()), 676 | m.ContentStyle.Base.Render(strings.ReplaceAll(m.Code.View(), "\t", strings.Repeat(" ", tabSpaces))), 677 | ), 678 | ), 679 | ), 680 | marginStyle.Render(m.help.View(m.keys)), 681 | ) 682 | } 683 | 684 | func (m *Model) saveState() { 685 | s := State{ 686 | CurrentFolder: string(m.selectedFolder()), 687 | CurrentSnippet: m.selectedSnippet().File, 688 | } 689 | err := s.Save() 690 | if err != nil { 691 | panic(err.Error()) 692 | } 693 | } 694 | --------------------------------------------------------------------------------