chore: added tui to sipp-go 23ccb1b9
Steve Simkins · 2026-05-16 18:11 15 file(s) · +1500 −110
apps/sipp-go/cmd/sipp/main.go +66 −7
1 1
// Sipp CLI: minimal command dispatcher.
2 2
//
3 -
//	sipp server              start the web server
4 -
//	sipp [-r URL] [-k KEY] <file>   upload a file to a remote sipp server
3 +
//	sipp                              launch the interactive TUI
4 +
//	sipp tui [-r URL] [-k KEY]        launch the interactive TUI
5 +
//	sipp auth                         save remote URL + API key to config
6 +
//	sipp server [--host H] [--port P] start the web server
7 +
//	sipp [-r URL] [-k KEY] <file>     upload a file to a remote sipp server
5 8
//	sipp --help
6 -
//
7 -
// The interactive TUI from the Rust version is not ported.
8 9
package main
9 10
10 11
import (
12 +
	"bufio"
11 13
	"bytes"
12 14
	"encoding/json"
13 15
	"fmt"
19 21
	"strings"
20 22
21 23
	"github.com/stevedylandev/andromeda/apps/sipp-go/server"
24 +
	"github.com/stevedylandev/andromeda/apps/sipp-go/tui"
22 25
	"github.com/stevedylandev/andromeda/crates-go/config"
23 26
)
24 27
25 28
const usage = `sipp — minimal code sharing CLI
26 29
27 30
usage:
31 +
  sipp                              launch interactive TUI
32 +
  sipp tui [-r URL] [-k KEY]        launch interactive TUI
33 +
  sipp auth                         save remote URL + API key to ~/.config/sipp/config.toml
28 34
  sipp server [--host HOST] [--port PORT]
29 35
  sipp [-r URL] [-k KEY] <file>     create a snippet from FILE on the remote server
30 36
  sipp --help
32 38
env:
33 39
  SIPP_REMOTE_URL  default remote URL
34 40
  SIPP_API_KEY     API key used for authenticated requests
41 +
  SIPP_DB_PATH     local sqlite path for TUI in local mode
35 42
`
36 43
37 44
func main() {
38 45
	config.LoadDotEnv(".env")
39 46
	args := os.Args[1:]
40 -
	if len(args) == 0 || args[0] == "-h" || args[0] == "--help" {
41 -
		fmt.Print(usage)
47 +
	if len(args) == 0 {
48 +
		runTUI(nil)
42 49
		return
43 50
	}
44 51
	switch args[0] {
52 +
	case "-h", "--help":
53 +
		fmt.Print(usage)
45 54
	case "server":
46 55
		runServer(args[1:])
56 +
	case "tui":
57 +
		runTUI(args[1:])
58 +
	case "auth":
59 +
		runAuth()
47 60
	default:
48 61
		runUpload(args)
49 62
	}
74 87
	}
75 88
}
76 89
90 +
func runTUI(args []string) {
91 +
	if err := tui.Run(tui.ParseArgs(args)); err != nil {
92 +
		fmt.Fprintln(os.Stderr, err)
93 +
		os.Exit(1)
94 +
	}
95 +
}
96 +
97 +
func runAuth() {
98 +
	cfg, _ := tui.LoadConfig()
99 +
	in := bufio.NewReader(os.Stdin)
100 +
101 +
	fmt.Printf("Remote URL [%s]: ", cfg.RemoteURL)
102 +
	url, _ := in.ReadString('\n')
103 +
	url = strings.TrimSpace(url)
104 +
	if url != "" {
105 +
		cfg.RemoteURL = url
106 +
	}
107 +
108 +
	masked := ""
109 +
	if cfg.APIKey != "" {
110 +
		masked = "********"
111 +
	}
112 +
	fmt.Printf("API key [%s]: ", masked)
113 +
	key, _ := in.ReadString('\n')
114 +
	key = strings.TrimSpace(key)
115 +
	if key != "" {
116 +
		cfg.APIKey = key
117 +
	}
118 +
119 +
	if err := tui.SaveConfig(cfg); err != nil {
120 +
		fmt.Fprintln(os.Stderr, "save config:", err)
121 +
		os.Exit(1)
122 +
	}
123 +
	path, _ := tui.ConfigPath()
124 +
	fmt.Println("saved", path)
125 +
}
126 +
77 127
func runUpload(args []string) {
78 128
	remote := os.Getenv("SIPP_REMOTE_URL")
79 129
	apiKey := os.Getenv("SIPP_API_KEY")
102 152
		os.Exit(2)
103 153
	}
104 154
	if remote == "" {
105 -
		fmt.Fprintln(os.Stderr, "remote URL not set (use -r or SIPP_REMOTE_URL)")
155 +
		cfg, _ := tui.LoadConfig()
156 +
		if cfg.RemoteURL != "" {
157 +
			remote = cfg.RemoteURL
158 +
		}
159 +
		if apiKey == "" {
160 +
			apiKey = cfg.APIKey
161 +
		}
162 +
	}
163 +
	if remote == "" {
164 +
		fmt.Fprintln(os.Stderr, "remote URL not set (use -r, SIPP_REMOTE_URL, or `sipp auth`)")
106 165
		os.Exit(2)
107 166
	}
108 167
apps/sipp-go/go.mod +24 −1
3 3
go 1.24.4
4 4
5 5
require (
6 +
	github.com/BurntSushi/toml v1.6.0
6 7
	github.com/alecthomas/chroma/v2 v2.14.0
8 +
	github.com/atotto/clipboard v0.1.4
9 +
	github.com/charmbracelet/bubbles v1.0.0
10 +
	github.com/charmbracelet/bubbletea v1.3.10
11 +
	github.com/charmbracelet/lipgloss v1.1.0
7 12
	github.com/stevedylandev/andromeda/crates-go/auth v0.0.0
8 13
	github.com/stevedylandev/andromeda/crates-go/config v0.0.0
9 14
	github.com/stevedylandev/andromeda/crates-go/darkmatter v0.0.0
12 17
)
13 18
14 19
require (
20 +
	github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
21 +
	github.com/charmbracelet/colorprofile v0.4.1 // indirect
22 +
	github.com/charmbracelet/x/ansi v0.11.6 // indirect
23 +
	github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
24 +
	github.com/charmbracelet/x/term v0.2.2 // indirect
25 +
	github.com/clipperhouse/displaywidth v0.9.0 // indirect
26 +
	github.com/clipperhouse/stringish v0.1.1 // indirect
27 +
	github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
15 28
	github.com/dlclark/regexp2 v1.11.0 // indirect
16 29
	github.com/dustin/go-humanize v1.0.1 // indirect
30 +
	github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
17 31
	github.com/google/uuid v1.6.0 // indirect
32 +
	github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
18 33
	github.com/mattn/go-isatty v0.0.20 // indirect
34 +
	github.com/mattn/go-localereader v0.0.1 // indirect
35 +
	github.com/mattn/go-runewidth v0.0.19 // indirect
36 +
	github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
37 +
	github.com/muesli/cancelreader v0.2.2 // indirect
38 +
	github.com/muesli/termenv v0.16.0 // indirect
19 39
	github.com/ncruces/go-strftime v0.1.9 // indirect
20 40
	github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
41 +
	github.com/rivo/uniseg v0.4.7 // indirect
42 +
	github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
21 43
	golang.org/x/crypto v0.39.0 // indirect
22 44
	golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
23 -
	golang.org/x/sys v0.33.0 // indirect
45 +
	golang.org/x/sys v0.38.0 // indirect
46 +
	golang.org/x/text v0.26.0 // indirect
24 47
	modernc.org/libc v1.65.7 // indirect
25 48
	modernc.org/mathutil v1.7.1 // indirect
26 49
	modernc.org/memory v1.11.0 // indirect
apps/sipp-go/go.sum +59 −6
1 +
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
2 +
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
3 +
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
4 +
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
1 5
github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE=
2 6
github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
3 7
github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E=
4 8
github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I=
5 9
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
6 10
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
11 +
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
12 +
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
13 +
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
14 +
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
15 +
github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
16 +
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
17 +
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
18 +
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
19 +
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
20 +
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
21 +
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
22 +
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
23 +
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
24 +
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
25 +
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
26 +
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
27 +
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
28 +
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
29 +
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
30 +
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
31 +
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
32 +
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
33 +
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
34 +
github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
35 +
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
36 +
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
37 +
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
38 +
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
7 39
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
8 40
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
9 41
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
10 42
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
43 +
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
44 +
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
11 45
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
12 46
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
13 47
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
14 48
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
15 49
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
16 50
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
51 +
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
52 +
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
17 53
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
18 54
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
55 +
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
56 +
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
57 +
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
58 +
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
59 +
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
60 +
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
61 +
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
62 +
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
63 +
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
64 +
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
19 65
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
20 66
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
21 67
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
22 68
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
69 +
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
70 +
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
71 +
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
72 +
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
23 73
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
24 74
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
25 75
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
26 76
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
27 -
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
28 -
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
29 -
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
30 -
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
77 +
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
78 +
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
79 +
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
80 +
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
81 +
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
31 82
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
32 -
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
33 -
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
83 +
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
84 +
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
85 +
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
86 +
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
34 87
golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc=
35 88
golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI=
36 89
modernc.org/cc/v4 v4.26.1 h1:+X5NtzVBn0KgsBCBe+xkDC7twLb/jNVj9FPgiwSQO3s=
apps/sipp-go/internal/store/store.go (added) +109 −0
1 +
package store
2 +
3 +
import (
4 +
	"database/sql"
5 +
	"errors"
6 +
7 +
	"github.com/stevedylandev/andromeda/crates-go/auth"
8 +
	_ "modernc.org/sqlite"
9 +
)
10 +
11 +
type Snippet struct {
12 +
	ID      int64  `json:"id"`
13 +
	ShortID string `json:"short_id"`
14 +
	Content string `json:"content"`
15 +
	Name    string `json:"name"`
16 +
}
17 +
18 +
type SnippetInput struct {
19 +
	Name    string `json:"name"`
20 +
	Content string `json:"content"`
21 +
}
22 +
23 +
const schema = `
24 +
CREATE TABLE IF NOT EXISTS snippets (
25 +
    id INTEGER PRIMARY KEY AUTOINCREMENT,
26 +
    short_id TEXT NOT NULL UNIQUE,
27 +
    content TEXT NOT NULL,
28 +
    name TEXT NOT NULL
29 +
);
30 +
`
31 +
32 +
func Open(path string) (*sql.DB, error) {
33 +
	db, err := sql.Open("sqlite", path)
34 +
	if err != nil {
35 +
		return nil, err
36 +
	}
37 +
	db.SetMaxOpenConns(1)
38 +
	db.SetMaxIdleConns(1)
39 +
	if _, err := db.Exec(schema); err != nil {
40 +
		return nil, err
41 +
	}
42 +
	return db, nil
43 +
}
44 +
45 +
func scanSnippet(s interface{ Scan(...any) error }) (*Snippet, error) {
46 +
	var sn Snippet
47 +
	err := s.Scan(&sn.ID, &sn.ShortID, &sn.Content, &sn.Name)
48 +
	if errors.Is(err, sql.ErrNoRows) {
49 +
		return nil, nil
50 +
	}
51 +
	if err != nil {
52 +
		return nil, err
53 +
	}
54 +
	return &sn, nil
55 +
}
56 +
57 +
func Create(db *sql.DB, name, content string) (*Snippet, error) {
58 +
	shortID, err := auth.GenerateShortID(10)
59 +
	if err != nil {
60 +
		return nil, err
61 +
	}
62 +
	res, err := db.Exec(`INSERT INTO snippets (short_id, content, name) VALUES (?, ?, ?)`, shortID, content, name)
63 +
	if err != nil {
64 +
		return nil, err
65 +
	}
66 +
	id, _ := res.LastInsertId()
67 +
	return &Snippet{ID: id, ShortID: shortID, Content: content, Name: name}, nil
68 +
}
69 +
70 +
func GetByShortID(db *sql.DB, shortID string) (*Snippet, error) {
71 +
	return scanSnippet(db.QueryRow(`SELECT id, short_id, content, name FROM snippets WHERE short_id = ?`, shortID))
72 +
}
73 +
74 +
func List(db *sql.DB) ([]Snippet, error) {
75 +
	rows, err := db.Query(`SELECT id, short_id, content, name FROM snippets ORDER BY id DESC`)
76 +
	if err != nil {
77 +
		return nil, err
78 +
	}
79 +
	defer rows.Close()
80 +
	out := []Snippet{}
81 +
	for rows.Next() {
82 +
		s, err := scanSnippet(rows)
83 +
		if err != nil {
84 +
			return nil, err
85 +
		}
86 +
		out = append(out, *s)
87 +
	}
88 +
	return out, rows.Err()
89 +
}
90 +
91 +
func DeleteByShortID(db *sql.DB, shortID string) (bool, error) {
92 +
	res, err := db.Exec(`DELETE FROM snippets WHERE short_id = ?`, shortID)
93 +
	if err != nil {
94 +
		return false, err
95 +
	}
96 +
	n, _ := res.RowsAffected()
97 +
	return n > 0, nil
98 +
}
99 +
100 +
func UpdateByShortID(db *sql.DB, shortID, name, content string) (*Snippet, error) {
101 +
	res, err := db.Exec(`UPDATE snippets SET name = ?, content = ? WHERE short_id = ?`, name, content, shortID)
102 +
	if err != nil {
103 +
		return nil, err
104 +
	}
105 +
	if n, _ := res.RowsAffected(); n == 0 {
106 +
		return nil, nil
107 +
	}
108 +
	return GetByShortID(db, shortID)
109 +
}
apps/sipp-go/server/server.go +10 −96
6 6
	"database/sql"
7 7
	"embed"
8 8
	"encoding/json"
9 -
	"errors"
10 9
	"html/template"
11 10
	"io"
12 11
	"log"
21 20
	"github.com/alecthomas/chroma/v2/formatters/html"
22 21
	"github.com/alecthomas/chroma/v2/lexers"
23 22
	"github.com/alecthomas/chroma/v2/styles"
23 +
	"github.com/stevedylandev/andromeda/apps/sipp-go/internal/store"
24 24
	"github.com/stevedylandev/andromeda/crates-go/auth"
25 25
	"github.com/stevedylandev/andromeda/crates-go/config"
26 26
	"github.com/stevedylandev/andromeda/crates-go/darkmatter"
27 27
	"github.com/stevedylandev/andromeda/crates-go/web"
28 -
	_ "modernc.org/sqlite"
29 28
)
30 29
31 30
//go:embed templates/*.html static/*
32 31
var appFS embed.FS
33 32
34 -
type Snippet struct {
35 -
	ID      int64  `json:"id"`
36 -
	ShortID string `json:"short_id"`
37 -
	Content string `json:"content"`
38 -
	Name    string `json:"name"`
39 -
}
33 +
type Snippet = store.Snippet
40 34
41 35
type App struct {
42 36
	DB              *sql.DB
50 44
	MaxContentSize  int
51 45
}
52 46
53 -
const schema = `
54 -
CREATE TABLE IF NOT EXISTS snippets (
55 -
    id INTEGER PRIMARY KEY AUTOINCREMENT,
56 -
    short_id TEXT NOT NULL UNIQUE,
57 -
    content TEXT NOT NULL,
58 -
    name TEXT NOT NULL
59 -
);
60 -
`
61 -
62 -
func openDB(path string) (*sql.DB, error) {
63 -
	db, err := sql.Open("sqlite", path)
64 -
	if err != nil {
65 -
		return nil, err
66 -
	}
67 -
	db.SetMaxOpenConns(1)
68 -
	db.SetMaxIdleConns(1)
69 -
	if _, err := db.Exec(schema); err != nil {
70 -
		return nil, err
71 -
	}
72 -
	return db, nil
73 -
}
74 -
75 -
func scanSnippet(s interface{ Scan(...any) error }) (*Snippet, error) {
76 -
	var sn Snippet
77 -
	err := s.Scan(&sn.ID, &sn.ShortID, &sn.Content, &sn.Name)
78 -
	if errors.Is(err, sql.ErrNoRows) {
79 -
		return nil, nil
80 -
	}
81 -
	if err != nil {
82 -
		return nil, err
83 -
	}
84 -
	return &sn, nil
85 -
}
86 -
87 -
func createSnippet(db *sql.DB, name, content string) (*Snippet, error) {
88 -
	shortID, err := auth.GenerateShortID(10)
89 -
	if err != nil {
90 -
		return nil, err
91 -
	}
92 -
	res, err := db.Exec(`INSERT INTO snippets (short_id, content, name) VALUES (?, ?, ?)`, shortID, content, name)
93 -
	if err != nil {
94 -
		return nil, err
95 -
	}
96 -
	id, _ := res.LastInsertId()
97 -
	return &Snippet{ID: id, ShortID: shortID, Content: content, Name: name}, nil
98 -
}
99 -
100 -
func getSnippetByShortID(db *sql.DB, shortID string) (*Snippet, error) {
101 -
	return scanSnippet(db.QueryRow(`SELECT id, short_id, content, name FROM snippets WHERE short_id = ?`, shortID))
102 -
}
103 -
104 -
func getAllSnippets(db *sql.DB) ([]Snippet, error) {
105 -
	rows, err := db.Query(`SELECT id, short_id, content, name FROM snippets ORDER BY id DESC`)
106 -
	if err != nil {
107 -
		return nil, err
108 -
	}
109 -
	defer rows.Close()
110 -
	out := []Snippet{}
111 -
	for rows.Next() {
112 -
		s, err := scanSnippet(rows)
113 -
		if err != nil {
114 -
			return nil, err
115 -
		}
116 -
		out = append(out, *s)
117 -
	}
118 -
	return out, rows.Err()
119 -
}
120 -
121 -
func deleteSnippetByShortID(db *sql.DB, shortID string) (bool, error) {
122 -
	res, err := db.Exec(`DELETE FROM snippets WHERE short_id = ?`, shortID)
123 -
	if err != nil {
124 -
		return false, err
125 -
	}
126 -
	n, _ := res.RowsAffected()
127 -
	return n > 0, nil
128 -
}
129 -
130 -
func updateSnippetByShortID(db *sql.DB, shortID, name, content string) (*Snippet, error) {
131 -
	res, err := db.Exec(`UPDATE snippets SET name = ?, content = ? WHERE short_id = ?`, name, content, shortID)
132 -
	if err != nil {
133 -
		return nil, err
134 -
	}
135 -
	if n, _ := res.RowsAffected(); n == 0 {
136 -
		return nil, nil
137 -
	}
138 -
	return getSnippetByShortID(db, shortID)
139 -
}
47 +
var (
48 +
	createSnippet         = store.Create
49 +
	getSnippetByShortID   = store.GetByShortID
50 +
	getAllSnippets        = store.List
51 +
	deleteSnippetByShortID = store.DeleteByShortID
52 +
	updateSnippetByShortID = store.UpdateByShortID
53 +
)
140 54
141 55
func highlight(name, content string) string {
142 56
	ext := ""
434 348
	logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
435 349
436 350
	dbPath := config.Getenv("SIPP_DB_PATH", "sipp.sqlite")
437 -
	db, err := openDB(dbPath)
351 +
	db, err := store.Open(dbPath)
438 352
	if err != nil {
439 353
		return err
440 354
	}
apps/sipp-go/tui/backend.go (added) +208 −0
1 +
package tui
2 +
3 +
import (
4 +
	"bytes"
5 +
	"database/sql"
6 +
	"encoding/json"
7 +
	"fmt"
8 +
	"io"
9 +
	"net/http"
10 +
	"os"
11 +
	"strings"
12 +
	"time"
13 +
14 +
	"github.com/stevedylandev/andromeda/apps/sipp-go/internal/store"
15 +
	"github.com/stevedylandev/andromeda/crates-go/config"
16 +
)
17 +
18 +
type Snippet = store.Snippet
19 +
20 +
type Backend interface {
21 +
	List() ([]Snippet, error)
22 +
	Get(shortID string) (*Snippet, error)
23 +
	Create(name, content string) (*Snippet, error)
24 +
	Update(shortID, name, content string) (*Snippet, error)
25 +
	Delete(shortID string) (bool, error)
26 +
	RemoteURL() string
27 +
	Close() error
28 +
}
29 +
30 +
type LocalBackend struct {
31 +
	DB *sql.DB
32 +
}
33 +
34 +
func (b *LocalBackend) List() ([]Snippet, error)                      { return store.List(b.DB) }
35 +
func (b *LocalBackend) Get(s string) (*Snippet, error)                { return store.GetByShortID(b.DB, s) }
36 +
func (b *LocalBackend) Create(n, c string) (*Snippet, error)          { return store.Create(b.DB, n, c) }
37 +
func (b *LocalBackend) Update(s, n, c string) (*Snippet, error)       { return store.UpdateByShortID(b.DB, s, n, c) }
38 +
func (b *LocalBackend) Delete(s string) (bool, error)                 { return store.DeleteByShortID(b.DB, s) }
39 +
func (b *LocalBackend) RemoteURL() string                             { return "" }
40 +
func (b *LocalBackend) Close() error                                  { return b.DB.Close() }
41 +
42 +
type RemoteBackend struct {
43 +
	BaseURL string
44 +
	APIKey  string
45 +
	Client  *http.Client
46 +
}
47 +
48 +
func (r *RemoteBackend) RemoteURL() string { return r.BaseURL }
49 +
func (r *RemoteBackend) Close() error      { return nil }
50 +
51 +
func (r *RemoteBackend) do(method, path string, body any, out any) error {
52 +
	var reader io.Reader
53 +
	if body != nil {
54 +
		buf, err := json.Marshal(body)
55 +
		if err != nil {
56 +
			return err
57 +
		}
58 +
		reader = bytes.NewReader(buf)
59 +
	}
60 +
	req, err := http.NewRequest(method, strings.TrimRight(r.BaseURL, "/")+path, reader)
61 +
	if err != nil {
62 +
		return err
63 +
	}
64 +
	if body != nil {
65 +
		req.Header.Set("Content-Type", "application/json")
66 +
	}
67 +
	if r.APIKey != "" {
68 +
		req.Header.Set("x-api-key", r.APIKey)
69 +
	}
70 +
	resp, err := r.Client.Do(req)
71 +
	if err != nil {
72 +
		return err
73 +
	}
74 +
	defer resp.Body.Close()
75 +
	if resp.StatusCode == http.StatusNotFound {
76 +
		return errNotFound
77 +
	}
78 +
	if resp.StatusCode >= 400 {
79 +
		b, _ := io.ReadAll(resp.Body)
80 +
		return fmt.Errorf("%s: %s", resp.Status, strings.TrimSpace(string(b)))
81 +
	}
82 +
	if out == nil || resp.StatusCode == http.StatusNoContent {
83 +
		return nil
84 +
	}
85 +
	return json.NewDecoder(resp.Body).Decode(out)
86 +
}
87 +
88 +
var errNotFound = fmt.Errorf("not found")
89 +
90 +
func (r *RemoteBackend) List() ([]Snippet, error) {
91 +
	var out []Snippet
92 +
	if err := r.do("GET", "/api/snippets", nil, &out); err != nil {
93 +
		return nil, err
94 +
	}
95 +
	return out, nil
96 +
}
97 +
98 +
func (r *RemoteBackend) Get(shortID string) (*Snippet, error) {
99 +
	var s Snippet
100 +
	if err := r.do("GET", "/api/snippets/"+shortID, nil, &s); err != nil {
101 +
		if err == errNotFound {
102 +
			return nil, nil
103 +
		}
104 +
		return nil, err
105 +
	}
106 +
	return &s, nil
107 +
}
108 +
109 +
func (r *RemoteBackend) Create(name, content string) (*Snippet, error) {
110 +
	var s Snippet
111 +
	if err := r.do("POST", "/api/snippets", store.SnippetInput{Name: name, Content: content}, &s); err != nil {
112 +
		return nil, err
113 +
	}
114 +
	return &s, nil
115 +
}
116 +
117 +
func (r *RemoteBackend) Update(shortID, name, content string) (*Snippet, error) {
118 +
	var s Snippet
119 +
	if err := r.do("PUT", "/api/snippets/"+shortID, store.SnippetInput{Name: name, Content: content}, &s); err != nil {
120 +
		if err == errNotFound {
121 +
			return nil, nil
122 +
		}
123 +
		return nil, err
124 +
	}
125 +
	return &s, nil
126 +
}
127 +
128 +
func (r *RemoteBackend) Delete(shortID string) (bool, error) {
129 +
	if err := r.do("DELETE", "/api/snippets/"+shortID, nil, nil); err != nil {
130 +
		if err == errNotFound {
131 +
			return false, nil
132 +
		}
133 +
		return false, err
134 +
	}
135 +
	return true, nil
136 +
}
137 +
138 +
type Options struct {
139 +
	RemoteURL string
140 +
	APIKey    string
141 +
	DBPath    string
142 +
}
143 +
144 +
func ParseArgs(args []string) Options {
145 +
	opts := Options{}
146 +
	for i := 0; i < len(args); i++ {
147 +
		a := args[i]
148 +
		switch {
149 +
		case (a == "--remote" || a == "-r") && i+1 < len(args):
150 +
			opts.RemoteURL = args[i+1]
151 +
			i++
152 +
		case strings.HasPrefix(a, "--remote="):
153 +
			opts.RemoteURL = strings.TrimPrefix(a, "--remote=")
154 +
		case (a == "--api-key" || a == "-k") && i+1 < len(args):
155 +
			opts.APIKey = args[i+1]
156 +
			i++
157 +
		case strings.HasPrefix(a, "--api-key="):
158 +
			opts.APIKey = strings.TrimPrefix(a, "--api-key=")
159 +
		case a == "--db" && i+1 < len(args):
160 +
			opts.DBPath = args[i+1]
161 +
			i++
162 +
		}
163 +
	}
164 +
	return opts
165 +
}
166 +
167 +
func ResolveBackend(opts Options) (Backend, error) {
168 +
	cfg, _ := LoadConfig()
169 +
170 +
	remoteURL := opts.RemoteURL
171 +
	if remoteURL == "" {
172 +
		remoteURL = os.Getenv("SIPP_REMOTE_URL")
173 +
	}
174 +
	apiKey := opts.APIKey
175 +
	if apiKey == "" {
176 +
		apiKey = os.Getenv("SIPP_API_KEY")
177 +
	}
178 +
	if apiKey == "" {
179 +
		apiKey = cfg.APIKey
180 +
	}
181 +
182 +
	dbPath := opts.DBPath
183 +
	if dbPath == "" {
184 +
		dbPath = config.Getenv("SIPP_DB_PATH", "sipp.sqlite")
185 +
	}
186 +
187 +
	useRemote := remoteURL != ""
188 +
	if !useRemote {
189 +
		if _, err := os.Stat(dbPath); err != nil && cfg.RemoteURL != "" {
190 +
			remoteURL = cfg.RemoteURL
191 +
			useRemote = true
192 +
		}
193 +
	}
194 +
195 +
	if useRemote {
196 +
		return &RemoteBackend{
197 +
			BaseURL: remoteURL,
198 +
			APIKey:  apiKey,
199 +
			Client:  &http.Client{Timeout: 15 * time.Second},
200 +
		}, nil
201 +
	}
202 +
203 +
	db, err := store.Open(dbPath)
204 +
	if err != nil {
205 +
		return nil, err
206 +
	}
207 +
	return &LocalBackend{DB: db}, nil
208 +
}
apps/sipp-go/tui/config.go (added) +56 −0
1 +
package tui
2 +
3 +
import (
4 +
	"os"
5 +
	"path/filepath"
6 +
7 +
	"github.com/BurntSushi/toml"
8 +
)
9 +
10 +
type Config struct {
11 +
	RemoteURL string `toml:"remote_url"`
12 +
	APIKey    string `toml:"api_key"`
13 +
}
14 +
15 +
func ConfigPath() (string, error) {
16 +
	dir, err := os.UserConfigDir()
17 +
	if err != nil {
18 +
		return "", err
19 +
	}
20 +
	return filepath.Join(dir, "sipp", "config.toml"), nil
21 +
}
22 +
23 +
func LoadConfig() (Config, error) {
24 +
	var cfg Config
25 +
	path, err := ConfigPath()
26 +
	if err != nil {
27 +
		return cfg, err
28 +
	}
29 +
	data, err := os.ReadFile(path)
30 +
	if err != nil {
31 +
		if os.IsNotExist(err) {
32 +
			return cfg, nil
33 +
		}
34 +
		return cfg, err
35 +
	}
36 +
	if err := toml.Unmarshal(data, &cfg); err != nil {
37 +
		return cfg, err
38 +
	}
39 +
	return cfg, nil
40 +
}
41 +
42 +
func SaveConfig(cfg Config) error {
43 +
	path, err := ConfigPath()
44 +
	if err != nil {
45 +
		return err
46 +
	}
47 +
	if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
48 +
		return err
49 +
	}
50 +
	f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
51 +
	if err != nil {
52 +
		return err
53 +
	}
54 +
	defer f.Close()
55 +
	return toml.NewEncoder(f).Encode(cfg)
56 +
}
apps/sipp-go/tui/editor.go (added) +59 −0
1 +
package tui
2 +
3 +
import (
4 +
	"fmt"
5 +
	"os"
6 +
	"os/exec"
7 +
	"path/filepath"
8 +
	"runtime"
9 +
10 +
	tea "github.com/charmbracelet/bubbletea"
11 +
)
12 +
13 +
func openExternalEditor(shortID, name, content string) tea.Cmd {
14 +
	editor := os.Getenv("EDITOR")
15 +
	if editor == "" {
16 +
		return func() tea.Msg {
17 +
			return statusMsg{text: "$EDITOR not set", ok: false}
18 +
		}
19 +
	}
20 +
21 +
	base := name
22 +
	if base == "" {
23 +
		base = "snippet.txt"
24 +
	}
25 +
	tmp := filepath.Join(os.TempDir(), fmt.Sprintf("sipp-%s-%s", shortID, filepath.Base(base)))
26 +
	if err := os.WriteFile(tmp, []byte(content), 0o600); err != nil {
27 +
		return func() tea.Msg {
28 +
			return statusMsg{text: "tempfile: " + err.Error(), ok: false}
29 +
		}
30 +
	}
31 +
32 +
	cmd := exec.Command(editor, tmp)
33 +
	return tea.ExecProcess(cmd, func(err error) tea.Msg {
34 +
		defer os.Remove(tmp)
35 +
		if err != nil {
36 +
			return editorFinishedMsg{shortID: shortID, err: err}
37 +
		}
38 +
		b, rerr := os.ReadFile(tmp)
39 +
		if rerr != nil {
40 +
			return editorFinishedMsg{shortID: shortID, err: rerr}
41 +
		}
42 +
		return editorFinishedMsg{shortID: shortID, content: string(b)}
43 +
	})
44 +
}
45 +
46 +
func openURL(url string) error {
47 +
	var cmd *exec.Cmd
48 +
	switch runtime.GOOS {
49 +
	case "linux":
50 +
		cmd = exec.Command("xdg-open", url)
51 +
	case "darwin":
52 +
		cmd = exec.Command("open", url)
53 +
	case "windows":
54 +
		cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url)
55 +
	default:
56 +
		return fmt.Errorf("unsupported platform %s", runtime.GOOS)
57 +
	}
58 +
	return cmd.Start()
59 +
}
apps/sipp-go/tui/highlight.go (added) +62 −0
1 +
package tui
2 +
3 +
import (
4 +
	"bytes"
5 +
	"strings"
6 +
7 +
	"github.com/alecthomas/chroma/v2"
8 +
	"github.com/alecthomas/chroma/v2/formatters"
9 +
	"github.com/alecthomas/chroma/v2/lexers"
10 +
	"github.com/alecthomas/chroma/v2/styles"
11 +
)
12 +
13 +
type highlighter struct {
14 +
	cache map[string]string
15 +
}
16 +
17 +
func newHighlighter() *highlighter {
18 +
	return &highlighter{cache: map[string]string{}}
19 +
}
20 +
21 +
func (h *highlighter) render(shortID, name, content string) string {
22 +
	if v, ok := h.cache[shortID]; ok {
23 +
		return v
24 +
	}
25 +
	out := highlightCode(name, content)
26 +
	h.cache[shortID] = out
27 +
	return out
28 +
}
29 +
30 +
func (h *highlighter) invalidate(shortID string) {
31 +
	delete(h.cache, shortID)
32 +
}
33 +
34 +
func highlightCode(name, content string) string {
35 +
	var lexer chroma.Lexer
36 +
	if name != "" {
37 +
		lexer = lexers.Match(name)
38 +
	}
39 +
	if lexer == nil {
40 +
		lexer = lexers.Analyse(content)
41 +
	}
42 +
	if lexer == nil {
43 +
		lexer = lexers.Fallback
44 +
	}
45 +
	style := styles.Get("monokai")
46 +
	if style == nil {
47 +
		style = styles.Fallback
48 +
	}
49 +
	formatter := formatters.Get("terminal256")
50 +
	if formatter == nil {
51 +
		formatter = formatters.Fallback
52 +
	}
53 +
	iter, err := lexer.Tokenise(nil, content)
54 +
	if err != nil {
55 +
		return content
56 +
	}
57 +
	var buf bytes.Buffer
58 +
	if err := formatter.Format(&buf, style, iter); err != nil {
59 +
		return content
60 +
	}
61 +
	return strings.TrimRight(buf.String(), "\n")
62 +
}
apps/sipp-go/tui/keys.go (added) +61 −0
1 +
package tui
2 +
3 +
import "github.com/charmbracelet/bubbles/key"
4 +
5 +
type keyMap struct {
6 +
	Up          key.Binding
7 +
	Down        key.Binding
8 +
	Open        key.Binding
9 +
	Back        key.Binding
10 +
	Quit        key.Binding
11 +
	Create      key.Binding
12 +
	Edit        key.Binding
13 +
	ExtEdit     key.Binding
14 +
	Delete      key.Binding
15 +
	Copy        key.Binding
16 +
	CopyLink    key.Binding
17 +
	OpenBrowser key.Binding
18 +
	Search      key.Binding
19 +
	Refresh     key.Binding
20 +
	Help        key.Binding
21 +
	Save        key.Binding
22 +
	SwitchField key.Binding
23 +
	Cancel      key.Binding
24 +
}
25 +
26 +
func defaultKeys() keyMap {
27 +
	return keyMap{
28 +
		Up:          key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up")),
29 +
		Down:        key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")),
30 +
		Open:        key.NewBinding(key.WithKeys("enter", "l"), key.WithHelp("⏎/l", "open")),
31 +
		Back:        key.NewBinding(key.WithKeys("h", "esc"), key.WithHelp("h/esc", "back")),
32 +
		Quit:        key.NewBinding(key.WithKeys("q"), key.WithHelp("q", "quit")),
33 +
		Create:      key.NewBinding(key.WithKeys("c"), key.WithHelp("c", "create")),
34 +
		Edit:        key.NewBinding(key.WithKeys("e"), key.WithHelp("e", "edit")),
35 +
		ExtEdit:     key.NewBinding(key.WithKeys("E"), key.WithHelp("E", "$EDITOR")),
36 +
		Delete:      key.NewBinding(key.WithKeys("d"), key.WithHelp("d", "delete")),
37 +
		Copy:        key.NewBinding(key.WithKeys("y"), key.WithHelp("y", "copy text")),
38 +
		CopyLink:    key.NewBinding(key.WithKeys("Y"), key.WithHelp("Y", "copy link")),
39 +
		OpenBrowser: key.NewBinding(key.WithKeys("o"), key.WithHelp("o", "browser")),
40 +
		Search:      key.NewBinding(key.WithKeys("/"), key.WithHelp("/", "search")),
41 +
		Refresh:     key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "refresh")),
42 +
		Help:        key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")),
43 +
		Save:        key.NewBinding(key.WithKeys("ctrl+s"), key.WithHelp("⌃s", "save")),
44 +
		SwitchField: key.NewBinding(key.WithKeys("tab"), key.WithHelp("⇥", "switch field")),
45 +
		Cancel:      key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "cancel")),
46 +
	}
47 +
}
48 +
49 +
func (k keyMap) ShortHelp() []key.Binding {
50 +
	return []key.Binding{k.Open, k.Create, k.Edit, k.Delete, k.Search, k.Help, k.Quit}
51 +
}
52 +
53 +
func (k keyMap) FullHelp() [][]key.Binding {
54 +
	return [][]key.Binding{
55 +
		{k.Up, k.Down, k.Open, k.Back},
56 +
		{k.Create, k.Edit, k.ExtEdit, k.Delete},
57 +
		{k.Copy, k.CopyLink, k.OpenBrowser, k.Search},
58 +
		{k.Refresh, k.Help, k.Save, k.SwitchField},
59 +
		{k.Cancel, k.Quit},
60 +
	}
61 +
}
apps/sipp-go/tui/messages.go (added) +29 −0
1 +
package tui
2 +
3 +
type snippetsLoadedMsg struct {
4 +
	snippets []Snippet
5 +
	err      error
6 +
}
7 +
8 +
type snippetSavedMsg struct {
9 +
	snippet *Snippet
10 +
	err     error
11 +
}
12 +
13 +
type snippetDeletedMsg struct {
14 +
	shortID string
15 +
	err     error
16 +
}
17 +
18 +
type editorFinishedMsg struct {
19 +
	shortID string
20 +
	content string
21 +
	err     error
22 +
}
23 +
24 +
type statusMsg struct {
25 +
	text string
26 +
	ok   bool
27 +
}
28 +
29 +
type clearStatusMsg struct{}
apps/sipp-go/tui/model.go (added) +145 −0
1 +
package tui
2 +
3 +
import (
4 +
	"strings"
5 +
	"time"
6 +
7 +
	"github.com/charmbracelet/bubbles/help"
8 +
	"github.com/charmbracelet/bubbles/textarea"
9 +
	"github.com/charmbracelet/bubbles/textinput"
10 +
	"github.com/charmbracelet/bubbles/viewport"
11 +
	tea "github.com/charmbracelet/bubbletea"
12 +
)
13 +
14 +
type Focus int
15 +
16 +
const (
17 +
	FocusList Focus = iota
18 +
	FocusContent
19 +
	FocusCreateName
20 +
	FocusCreateContent
21 +
	FocusEditName
22 +
	FocusEditContent
23 +
	FocusSearch
24 +
)
25 +
26 +
type Model struct {
27 +
	backend  Backend
28 +
	isRemote bool
29 +
30 +
	snippets []Snippet
31 +
	filtered []int
32 +
	cursor   int
33 +
34 +
	focus         Focus
35 +
	showHelp      bool
36 +
	confirmDelete bool
37 +
38 +
	nameInput   textinput.Model
39 +
	contentArea textarea.Model
40 +
	searchInput textinput.Model
41 +
	contentVP   viewport.Model
42 +
	help        help.Model
43 +
	keys        keyMap
44 +
45 +
	highlighter *highlighter
46 +
47 +
	editShortID string
48 +
49 +
	status      string
50 +
	statusOK    bool
51 +
	statusUntil time.Time
52 +
53 +
	width, height int
54 +
	ready         bool
55 +
	loading       bool
56 +
}
57 +
58 +
func newModel(backend Backend) Model {
59 +
	ti := textinput.New()
60 +
	ti.Placeholder = "name.ext"
61 +
	ti.Prompt = ""
62 +
	ti.CharLimit = 200
63 +
64 +
	ta := textarea.New()
65 +
	ta.Placeholder = "Paste code..."
66 +
	ta.ShowLineNumbers = true
67 +
	ta.Prompt = ""
68 +
69 +
	si := textinput.New()
70 +
	si.Placeholder = "search names"
71 +
	si.Prompt = "/ "
72 +
73 +
	vp := viewport.New(0, 0)
74 +
75 +
	return Model{
76 +
		backend:     backend,
77 +
		isRemote:    backend.RemoteURL() != "",
78 +
		focus:       FocusList,
79 +
		nameInput:   ti,
80 +
		contentArea: ta,
81 +
		searchInput: si,
82 +
		contentVP:   vp,
83 +
		help:        help.New(),
84 +
		keys:        defaultKeys(),
85 +
		highlighter: newHighlighter(),
86 +
	}
87 +
}
88 +
89 +
func (m Model) Init() tea.Cmd {
90 +
	return loadSnippetsCmd(m.backend)
91 +
}
92 +
93 +
func (m *Model) visible() []Snippet {
94 +
	if m.filtered == nil {
95 +
		return m.snippets
96 +
	}
97 +
	out := make([]Snippet, 0, len(m.filtered))
98 +
	for _, i := range m.filtered {
99 +
		out = append(out, m.snippets[i])
100 +
	}
101 +
	return out
102 +
}
103 +
104 +
func (m *Model) current() *Snippet {
105 +
	list := m.visible()
106 +
	if m.cursor < 0 || m.cursor >= len(list) {
107 +
		return nil
108 +
	}
109 +
	return &list[m.cursor]
110 +
}
111 +
112 +
func (m *Model) applyFilter(q string) {
113 +
	q = strings.TrimSpace(strings.ToLower(q))
114 +
	if q == "" {
115 +
		m.filtered = nil
116 +
		if m.cursor >= len(m.snippets) {
117 +
			m.cursor = 0
118 +
		}
119 +
		return
120 +
	}
121 +
	idx := []int{}
122 +
	for i, s := range m.snippets {
123 +
		if strings.Contains(strings.ToLower(s.Name), q) || strings.Contains(strings.ToLower(s.ShortID), q) {
124 +
			idx = append(idx, i)
125 +
		}
126 +
	}
127 +
	m.filtered = idx
128 +
	if m.cursor >= len(idx) {
129 +
		m.cursor = 0
130 +
	}
131 +
}
132 +
133 +
func (m *Model) setStatus(text string, ok bool) tea.Cmd {
134 +
	m.status = text
135 +
	m.statusOK = ok
136 +
	m.statusUntil = time.Now().Add(2 * time.Second)
137 +
	return tea.Tick(2*time.Second, func(time.Time) tea.Msg { return clearStatusMsg{} })
138 +
}
139 +
140 +
func (m *Model) shareURL(shortID string) string {
141 +
	if m.backend.RemoteURL() == "" {
142 +
		return ""
143 +
	}
144 +
	return strings.TrimRight(m.backend.RemoteURL(), "/") + "/s/" + shortID
145 +
}
apps/sipp-go/tui/tui.go (added) +17 −0
1 +
package tui
2 +
3 +
import (
4 +
	tea "github.com/charmbracelet/bubbletea"
5 +
)
6 +
7 +
func Run(opts Options) error {
8 +
	backend, err := ResolveBackend(opts)
9 +
	if err != nil {
10 +
		return err
11 +
	}
12 +
	defer backend.Close()
13 +
14 +
	p := tea.NewProgram(newModel(backend), tea.WithAltScreen())
15 +
	_, err = p.Run()
16 +
	return err
17 +
}
apps/sipp-go/tui/update.go (added) +403 −0
1 +
package tui
2 +
3 +
import (
4 +
	"strings"
5 +
6 +
	"github.com/atotto/clipboard"
7 +
	"github.com/charmbracelet/bubbles/key"
8 +
	tea "github.com/charmbracelet/bubbletea"
9 +
)
10 +
11 +
func loadSnippetsCmd(b Backend) tea.Cmd {
12 +
	return func() tea.Msg {
13 +
		list, err := b.List()
14 +
		return snippetsLoadedMsg{snippets: list, err: err}
15 +
	}
16 +
}
17 +
18 +
func saveSnippetCmd(b Backend, shortID, name, content string) tea.Cmd {
19 +
	return func() tea.Msg {
20 +
		var (
21 +
			s   *Snippet
22 +
			err error
23 +
		)
24 +
		if shortID == "" {
25 +
			s, err = b.Create(name, content)
26 +
		} else {
27 +
			s, err = b.Update(shortID, name, content)
28 +
		}
29 +
		return snippetSavedMsg{snippet: s, err: err}
30 +
	}
31 +
}
32 +
33 +
func deleteSnippetCmd(b Backend, shortID string) tea.Cmd {
34 +
	return func() tea.Msg {
35 +
		_, err := b.Delete(shortID)
36 +
		return snippetDeletedMsg{shortID: shortID, err: err}
37 +
	}
38 +
}
39 +
40 +
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
41 +
	switch msg := msg.(type) {
42 +
43 +
	case tea.WindowSizeMsg:
44 +
		m.width, m.height = msg.Width, msg.Height
45 +
		m.ready = true
46 +
		m.resizePanes()
47 +
		return m, nil
48 +
49 +
	case snippetsLoadedMsg:
50 +
		m.loading = false
51 +
		if msg.err != nil {
52 +
			return m, m.setStatus("load: "+msg.err.Error(), false)
53 +
		}
54 +
		m.snippets = msg.snippets
55 +
		m.applyFilter(m.searchInput.Value())
56 +
		m.refreshPreview()
57 +
		return m, nil
58 +
59 +
	case snippetSavedMsg:
60 +
		if msg.err != nil {
61 +
			return m, m.setStatus("save: "+msg.err.Error(), false)
62 +
		}
63 +
		if msg.snippet != nil && m.highlighter != nil {
64 +
			m.highlighter.invalidate(msg.snippet.ShortID)
65 +
		}
66 +
		m.focus = FocusList
67 +
		m.nameInput.Reset()
68 +
		m.contentArea.Reset()
69 +
		m.editShortID = ""
70 +
		return m, tea.Batch(loadSnippetsCmd(m.backend), m.setStatus("saved", true))
71 +
72 +
	case snippetDeletedMsg:
73 +
		if msg.err != nil {
74 +
			return m, m.setStatus("delete: "+msg.err.Error(), false)
75 +
		}
76 +
		if m.highlighter != nil {
77 +
			m.highlighter.invalidate(msg.shortID)
78 +
		}
79 +
		return m, tea.Batch(loadSnippetsCmd(m.backend), m.setStatus("deleted", true))
80 +
81 +
	case editorFinishedMsg:
82 +
		if msg.err != nil {
83 +
			return m, m.setStatus("editor: "+msg.err.Error(), false)
84 +
		}
85 +
		if msg.shortID == "" {
86 +
			m.contentArea.SetValue(msg.content)
87 +
			return m, nil
88 +
		}
89 +
		var orig *Snippet
90 +
		for i := range m.snippets {
91 +
			if m.snippets[i].ShortID == msg.shortID {
92 +
				orig = &m.snippets[i]
93 +
				break
94 +
			}
95 +
		}
96 +
		if orig == nil || strings.TrimRight(orig.Content, "\n") == strings.TrimRight(msg.content, "\n") {
97 +
			return m, nil
98 +
		}
99 +
		return m, saveSnippetCmd(m.backend, msg.shortID, orig.Name, msg.content)
100 +
101 +
	case statusMsg:
102 +
		return m, m.setStatus(msg.text, msg.ok)
103 +
104 +
	case clearStatusMsg:
105 +
		m.status = ""
106 +
		return m, nil
107 +
108 +
	case tea.KeyMsg:
109 +
		return m.handleKey(msg)
110 +
	}
111 +
112 +
	return m, nil
113 +
}
114 +
115 +
func (m *Model) resizePanes() {
116 +
	if !m.ready {
117 +
		return
118 +
	}
119 +
	listW := m.width * 30 / 100
120 +
	if listW < 24 {
121 +
		listW = 24
122 +
	}
123 +
	contentW := m.width - listW - 2
124 +
	if contentW < 20 {
125 +
		contentW = 20
126 +
	}
127 +
	bodyH := m.height - 2
128 +
	if bodyH < 5 {
129 +
		bodyH = 5
130 +
	}
131 +
132 +
	m.contentVP.Width = contentW - 2
133 +
	m.contentVP.Height = bodyH - 2
134 +
135 +
	m.nameInput.Width = contentW - 4
136 +
	m.contentArea.SetWidth(contentW - 2)
137 +
	m.contentArea.SetHeight(bodyH - 5)
138 +
139 +
	m.searchInput.Width = listW - 4
140 +
141 +
	m.refreshPreview()
142 +
}
143 +
144 +
func (m *Model) refreshPreview() {
145 +
	s := m.current()
146 +
	if s == nil {
147 +
		m.contentVP.SetContent("")
148 +
		return
149 +
	}
150 +
	body := s.Content
151 +
	if m.highlighter != nil {
152 +
		body = m.highlighter.render(s.ShortID, s.Name, s.Content)
153 +
	}
154 +
	m.contentVP.SetContent(body)
155 +
}
156 +
157 +
func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
158 +
	if msg.String() == "ctrl+c" {
159 +
		return m, tea.Quit
160 +
	}
161 +
162 +
	if m.confirmDelete {
163 +
		switch msg.String() {
164 +
		case "y", "Y":
165 +
			s := m.current()
166 +
			m.confirmDelete = false
167 +
			if s == nil {
168 +
				return m, nil
169 +
			}
170 +
			return m, deleteSnippetCmd(m.backend, s.ShortID)
171 +
		case "n", "N", "esc", "q":
172 +
			m.confirmDelete = false
173 +
			return m, nil
174 +
		}
175 +
		return m, nil
176 +
	}
177 +
178 +
	if m.showHelp {
179 +
		if key.Matches(msg, m.keys.Help) || msg.String() == "esc" || msg.String() == "q" {
180 +
			m.showHelp = false
181 +
		}
182 +
		return m, nil
183 +
	}
184 +
185 +
	switch m.focus {
186 +
	case FocusList:
187 +
		return m.keyList(msg)
188 +
	case FocusContent:
189 +
		return m.keyContent(msg)
190 +
	case FocusCreateName, FocusCreateContent, FocusEditName, FocusEditContent:
191 +
		return m.keyForm(msg)
192 +
	case FocusSearch:
193 +
		return m.keySearch(msg)
194 +
	}
195 +
	return m, nil
196 +
}
197 +
198 +
func (m Model) keyList(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
199 +
	list := m.visible()
200 +
	switch {
201 +
	case key.Matches(msg, m.keys.Quit):
202 +
		return m, tea.Quit
203 +
	case key.Matches(msg, m.keys.Down):
204 +
		if m.cursor < len(list)-1 {
205 +
			m.cursor++
206 +
			m.refreshPreview()
207 +
		}
208 +
	case key.Matches(msg, m.keys.Up):
209 +
		if m.cursor > 0 {
210 +
			m.cursor--
211 +
			m.refreshPreview()
212 +
		}
213 +
	case key.Matches(msg, m.keys.Open):
214 +
		if len(list) > 0 {
215 +
			m.focus = FocusContent
216 +
			m.contentVP.GotoTop()
217 +
		}
218 +
	case key.Matches(msg, m.keys.Create):
219 +
		m.focus = FocusCreateName
220 +
		m.editShortID = ""
221 +
		m.nameInput.SetValue("")
222 +
		m.contentArea.SetValue("")
223 +
		m.nameInput.Focus()
224 +
		m.contentArea.Blur()
225 +
	case key.Matches(msg, m.keys.Edit):
226 +
		s := m.current()
227 +
		if s != nil {
228 +
			m.focus = FocusEditName
229 +
			m.editShortID = s.ShortID
230 +
			m.nameInput.SetValue(s.Name)
231 +
			m.contentArea.SetValue(s.Content)
232 +
			m.nameInput.Focus()
233 +
			m.contentArea.Blur()
234 +
		}
235 +
	case key.Matches(msg, m.keys.ExtEdit):
236 +
		s := m.current()
237 +
		if s != nil {
238 +
			return m, openExternalEditor(s.ShortID, s.Name, s.Content)
239 +
		}
240 +
	case key.Matches(msg, m.keys.Delete):
241 +
		if m.current() != nil {
242 +
			m.confirmDelete = true
243 +
		}
244 +
	case key.Matches(msg, m.keys.Copy):
245 +
		s := m.current()
246 +
		if s != nil {
247 +
			if err := clipboard.WriteAll(s.Content); err != nil {
248 +
				return m, m.setStatus("clipboard: "+err.Error(), false)
249 +
			}
250 +
			return m, m.setStatus("copied text", true)
251 +
		}
252 +
	case key.Matches(msg, m.keys.CopyLink):
253 +
		s := m.current()
254 +
		if s != nil && m.isRemote {
255 +
			link := m.shareURL(s.ShortID)
256 +
			if err := clipboard.WriteAll(link); err != nil {
257 +
				return m, m.setStatus("clipboard: "+err.Error(), false)
258 +
			}
259 +
			return m, m.setStatus("copied link", true)
260 +
		}
261 +
		return m, m.setStatus("local mode: no link", false)
262 +
	case key.Matches(msg, m.keys.OpenBrowser):
263 +
		s := m.current()
264 +
		if s != nil && m.isRemote {
265 +
			link := m.shareURL(s.ShortID)
266 +
			if err := openURL(link); err != nil {
267 +
				return m, m.setStatus("open: "+err.Error(), false)
268 +
			}
269 +
			return m, m.setStatus("opened "+link, true)
270 +
		}
271 +
	case key.Matches(msg, m.keys.Search):
272 +
		m.focus = FocusSearch
273 +
		m.searchInput.Focus()
274 +
	case key.Matches(msg, m.keys.Refresh):
275 +
		m.loading = true
276 +
		return m, loadSnippetsCmd(m.backend)
277 +
	case key.Matches(msg, m.keys.Help):
278 +
		m.showHelp = true
279 +
	}
280 +
	return m, nil
281 +
}
282 +
283 +
func (m Model) keyContent(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
284 +
	switch {
285 +
	case key.Matches(msg, m.keys.Quit), key.Matches(msg, m.keys.Back):
286 +
		m.focus = FocusList
287 +
		return m, nil
288 +
	case key.Matches(msg, m.keys.Down):
289 +
		m.contentVP.ScrollDown(1)
290 +
	case key.Matches(msg, m.keys.Up):
291 +
		m.contentVP.ScrollUp(1)
292 +
	case key.Matches(msg, m.keys.Edit):
293 +
		s := m.current()
294 +
		if s != nil {
295 +
			m.focus = FocusEditName
296 +
			m.editShortID = s.ShortID
297 +
			m.nameInput.SetValue(s.Name)
298 +
			m.contentArea.SetValue(s.Content)
299 +
			m.nameInput.Focus()
300 +
		}
301 +
	case key.Matches(msg, m.keys.ExtEdit):
302 +
		s := m.current()
303 +
		if s != nil {
304 +
			return m, openExternalEditor(s.ShortID, s.Name, s.Content)
305 +
		}
306 +
	case key.Matches(msg, m.keys.Copy):
307 +
		s := m.current()
308 +
		if s != nil {
309 +
			clipboard.WriteAll(s.Content)
310 +
			return m, m.setStatus("copied text", true)
311 +
		}
312 +
	case key.Matches(msg, m.keys.CopyLink):
313 +
		s := m.current()
314 +
		if s != nil && m.isRemote {
315 +
			clipboard.WriteAll(m.shareURL(s.ShortID))
316 +
			return m, m.setStatus("copied link", true)
317 +
		}
318 +
	case key.Matches(msg, m.keys.OpenBrowser):
319 +
		s := m.current()
320 +
		if s != nil && m.isRemote {
321 +
			openURL(m.shareURL(s.ShortID))
322 +
		}
323 +
	case key.Matches(msg, m.keys.Help):
324 +
		m.showHelp = true
325 +
	}
326 +
	var cmd tea.Cmd
327 +
	m.contentVP, cmd = m.contentVP.Update(msg)
328 +
	return m, cmd
329 +
}
330 +
331 +
func (m Model) keyForm(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
332 +
	switch {
333 +
	case key.Matches(msg, m.keys.Cancel):
334 +
		m.focus = FocusList
335 +
		m.nameInput.Blur()
336 +
		m.contentArea.Blur()
337 +
		return m, nil
338 +
	case key.Matches(msg, m.keys.Save):
339 +
		name := strings.TrimSpace(m.nameInput.Value())
340 +
		if name == "" {
341 +
			return m, m.setStatus("name required", false)
342 +
		}
343 +
		content := m.contentArea.Value()
344 +
		if strings.TrimSpace(content) == "" {
345 +
			return m, m.setStatus("content required", false)
346 +
		}
347 +
		return m, saveSnippetCmd(m.backend, m.editShortID, name, content)
348 +
	case key.Matches(msg, m.keys.SwitchField):
349 +
		switch m.focus {
350 +
		case FocusCreateName:
351 +
			m.focus = FocusCreateContent
352 +
		case FocusCreateContent:
353 +
			m.focus = FocusCreateName
354 +
		case FocusEditName:
355 +
			m.focus = FocusEditContent
356 +
		case FocusEditContent:
357 +
			m.focus = FocusEditName
358 +
		}
359 +
		m.applyFormFocus()
360 +
		return m, nil
361 +
	}
362 +
363 +
	var cmd tea.Cmd
364 +
	switch m.focus {
365 +
	case FocusCreateName, FocusEditName:
366 +
		m.nameInput, cmd = m.nameInput.Update(msg)
367 +
	case FocusCreateContent, FocusEditContent:
368 +
		m.contentArea, cmd = m.contentArea.Update(msg)
369 +
	}
370 +
	return m, cmd
371 +
}
372 +
373 +
func (m *Model) applyFormFocus() {
374 +
	switch m.focus {
375 +
	case FocusCreateName, FocusEditName:
376 +
		m.nameInput.Focus()
377 +
		m.contentArea.Blur()
378 +
	case FocusCreateContent, FocusEditContent:
379 +
		m.contentArea.Focus()
380 +
		m.nameInput.Blur()
381 +
	}
382 +
}
383 +
384 +
func (m Model) keySearch(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
385 +
	switch msg.String() {
386 +
	case "esc":
387 +
		m.searchInput.SetValue("")
388 +
		m.searchInput.Blur()
389 +
		m.focus = FocusList
390 +
		m.applyFilter("")
391 +
		m.refreshPreview()
392 +
		return m, nil
393 +
	case "enter":
394 +
		m.searchInput.Blur()
395 +
		m.focus = FocusList
396 +
		return m, nil
397 +
	}
398 +
	var cmd tea.Cmd
399 +
	m.searchInput, cmd = m.searchInput.Update(msg)
400 +
	m.applyFilter(m.searchInput.Value())
401 +
	m.refreshPreview()
402 +
	return m, cmd
403 +
}
apps/sipp-go/tui/view.go (added) +192 −0
1 +
package tui
2 +
3 +
import (
4 +
	"fmt"
5 +
	"strings"
6 +
7 +
	"github.com/charmbracelet/lipgloss"
8 +
)
9 +
10 +
var (
11 +
	borderStyle = lipgloss.NewStyle().
12 +
			Border(lipgloss.RoundedBorder()).
13 +
			BorderForeground(lipgloss.Color("240"))
14 +
	borderActive = lipgloss.NewStyle().
15 +
			Border(lipgloss.RoundedBorder()).
16 +
			BorderForeground(lipgloss.Color("214"))
17 +
	titleStyle = lipgloss.NewStyle().
18 +
			Bold(true).
19 +
			Foreground(lipgloss.Color("214")).
20 +
			Padding(0, 1)
21 +
	itemStyle    = lipgloss.NewStyle().Padding(0, 1)
22 +
	itemSelected = lipgloss.NewStyle().
23 +
			Padding(0, 1).
24 +
			Bold(true).
25 +
			Foreground(lipgloss.Color("214"))
26 +
	dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("244"))
27 +
	statusOK = lipgloss.NewStyle().
28 +
			Foreground(lipgloss.Color("82")).
29 +
			Bold(true)
30 +
	statusErr = lipgloss.NewStyle().
31 +
			Foreground(lipgloss.Color("196")).
32 +
			Bold(true)
33 +
	hintStyle = lipgloss.NewStyle().
34 +
			Foreground(lipgloss.Color("244"))
35 +
	modalStyle = lipgloss.NewStyle().
36 +
			Border(lipgloss.RoundedBorder()).
37 +
			BorderForeground(lipgloss.Color("214")).
38 +
			Padding(1, 2).
39 +
			Background(lipgloss.Color("236"))
40 +
)
41 +
42 +
func (m Model) View() string {
43 +
	if !m.ready {
44 +
		return "loading..."
45 +
	}
46 +
47 +
	listW := m.width * 30 / 100
48 +
	if listW < 24 {
49 +
		listW = 24
50 +
	}
51 +
	contentW := m.width - listW - 2
52 +
	bodyH := m.height - 2
53 +
54 +
	left := m.renderList(listW, bodyH)
55 +
	right := m.renderRight(contentW, bodyH)
56 +
57 +
	body := lipgloss.JoinHorizontal(lipgloss.Top, left, right)
58 +
	footer := m.renderFooter()
59 +
60 +
	view := lipgloss.JoinVertical(lipgloss.Left, body, footer)
61 +
62 +
	if m.showHelp {
63 +
		view = lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center,
64 +
			modalStyle.Render(m.help.FullHelpView(m.keys.FullHelp())),
65 +
			lipgloss.WithWhitespaceChars(" "))
66 +
	}
67 +
	if m.confirmDelete {
68 +
		s := m.current()
69 +
		name := ""
70 +
		if s != nil {
71 +
			name = s.Name
72 +
		}
73 +
		view = lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center,
74 +
			modalStyle.Render(fmt.Sprintf("Delete %q?\n\ny / n", name)),
75 +
			lipgloss.WithWhitespaceChars(" "))
76 +
	}
77 +
	if m.status != "" {
78 +
		st := statusOK
79 +
		if !m.statusOK {
80 +
			st = statusErr
81 +
		}
82 +
		view = lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Bottom,
83 +
			modalStyle.Render(st.Render(m.status)),
84 +
			lipgloss.WithWhitespaceChars(" "))
85 +
	}
86 +
	return view
87 +
}
88 +
89 +
func (m Model) renderList(w, h int) string {
90 +
	style := borderStyle
91 +
	if m.focus == FocusList || m.focus == FocusSearch {
92 +
		style = borderActive
93 +
	}
94 +
95 +
	list := m.visible()
96 +
	rows := make([]string, 0, len(list)+2)
97 +
	rows = append(rows, titleStyle.Render("snippets"))
98 +
	if len(list) == 0 {
99 +
		rows = append(rows, hintStyle.Render("  (empty — press c)"))
100 +
	}
101 +
	for i, s := range list {
102 +
		label := s.Name
103 +
		if label == "" {
104 +
			label = s.ShortID
105 +
		}
106 +
		line := truncate(label, w-6)
107 +
		id := dimStyle.Render(" " + s.ShortID)
108 +
		if i == m.cursor {
109 +
			rows = append(rows, itemSelected.Render("▶ "+line)+id)
110 +
		} else {
111 +
			rows = append(rows, itemStyle.Render("  "+line)+id)
112 +
		}
113 +
	}
114 +
115 +
	if m.focus == FocusSearch || m.searchInput.Value() != "" {
116 +
		rows = append(rows, "", hintStyle.Render(m.searchInput.View()))
117 +
	}
118 +
119 +
	content := strings.Join(rows, "\n")
120 +
	return style.Width(w).Height(h).Render(content)
121 +
}
122 +
123 +
func (m Model) renderRight(w, h int) string {
124 +
	switch m.focus {
125 +
	case FocusCreateName, FocusCreateContent, FocusEditName, FocusEditContent:
126 +
		return m.renderForm(w, h)
127 +
	}
128 +
	return m.renderContent(w, h)
129 +
}
130 +
131 +
func (m Model) renderContent(w, h int) string {
132 +
	style := borderStyle
133 +
	if m.focus == FocusContent {
134 +
		style = borderActive
135 +
	}
136 +
	header := "preview"
137 +
	s := m.current()
138 +
	if s != nil {
139 +
		header = s.Name
140 +
		if header == "" {
141 +
			header = s.ShortID
142 +
		}
143 +
	}
144 +
	body := m.contentVP.View()
145 +
	inner := lipgloss.JoinVertical(lipgloss.Left, titleStyle.Render(header), body)
146 +
	return style.Width(w).Height(h).Render(inner)
147 +
}
148 +
149 +
func (m Model) renderForm(w, h int) string {
150 +
	header := "new snippet"
151 +
	if m.editShortID != "" {
152 +
		header = "edit"
153 +
	}
154 +
	name := m.nameInput.View()
155 +
	if m.focus == FocusCreateName || m.focus == FocusEditName {
156 +
		name = borderActive.Render(name)
157 +
	} else {
158 +
		name = borderStyle.Render(name)
159 +
	}
160 +
161 +
	body := m.contentArea.View()
162 +
	if m.focus == FocusCreateContent || m.focus == FocusEditContent {
163 +
		body = borderActive.Render(body)
164 +
	} else {
165 +
		body = borderStyle.Render(body)
166 +
	}
167 +
168 +
	inner := lipgloss.JoinVertical(lipgloss.Left, titleStyle.Render(header), name, body)
169 +
	return borderStyle.Width(w).Height(h).Render(inner)
170 +
}
171 +
172 +
func (m Model) renderFooter() string {
173 +
	mode := "local"
174 +
	if m.isRemote {
175 +
		mode = "remote " + m.backend.RemoteURL()
176 +
	}
177 +
	help := m.help.ShortHelpView(m.keys.ShortHelp())
178 +
	return hintStyle.Render(fmt.Sprintf("[%s] %s", mode, help))
179 +
}
180 +
181 +
func truncate(s string, n int) string {
182 +
	if n < 1 {
183 +
		return ""
184 +
	}
185 +
	if len(s) <= n {
186 +
		return s
187 +
	}
188 +
	if n <= 1 {
189 +
		return "…"
190 +
	}
191 +
	return s[:n-1] + "…"
192 +
}