commit ee23a4f39c6fac73607d21775e97cfb7d5c23ec5 Author: ysandler Date: Tue Jan 27 22:01:30 2026 -0600 init commit diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..f502f25 --- /dev/null +++ b/Makefile @@ -0,0 +1,36 @@ +VERSION := $(shell cat VERSION) +BINARY := klp +LDFLAGS := -ldflags "-X github.com/yeho/klp/cmd.Version=$(VERSION)" + +.PHONY: build install clean service-install service-uninstall + +build: + go build $(LDFLAGS) -o $(BINARY) . + +install: build + mkdir -p ~/.local/bin + -~/.local/bin/klp service stop 2>/dev/null || true + cp $(BINARY) ~/.local/bin/ + ~/.local/bin/klp service start + +clean: + rm -f $(BINARY) + +service-install: + mkdir -p ~/.config/systemd/user + cp klp.service ~/.config/systemd/user/ + systemctl --user daemon-reload + @echo "Service installed. Enable with: systemctl --user enable --now klp" + +service-uninstall: + systemctl --user stop klp || true + systemctl --user disable klp || true + rm -f ~/.config/systemd/user/klp.service + systemctl --user daemon-reload + +autostart-install: + mkdir -p ~/.config/autostart + cp klp.desktop ~/.config/autostart/ + +autostart-uninstall: + rm -f ~/.config/autostart/klp.desktop diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..6e8bf73 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.1.0 diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 0000000..4049d48 --- /dev/null +++ b/cmd/root.go @@ -0,0 +1,279 @@ +package cmd + +import ( + "fmt" + "os" + "strconv" + + "github.com/spf13/cobra" + "github.com/yeho/klp/internal/clipboard" + "github.com/yeho/klp/internal/config" + "github.com/yeho/klp/internal/database" + "github.com/yeho/klp/internal/search" + "github.com/yeho/klp/internal/tui" +) + +var ( + Version = "dev" // Set at build time via ldflags + + versionFlag bool + listFlag bool + listLimitFlag int + limitFlag int + searchFlag string + deleteFlag string + + cfg *config.Config +) + +var rootCmd = &cobra.Command{ + Use: "klp [id]", + Short: "A clipboard history manager for Linux", + Long: `klp is a clipboard history manager that monitors clipboard changes +and provides quick access to your clipboard history.`, + Args: cobra.MaximumNArgs(1), + Run: func(cmd *cobra.Command, args []string) { + if versionFlag { + fmt.Println(Version) + return + } + + if deleteFlag != "" { + deleteEntry(deleteFlag) + return + } + + if searchFlag != "" { + searchEntries(searchFlag) + return + } + + // If ID provided as argument, copy that entry to clipboard + if len(args) == 1 { + copyEntryToClipboard(args[0]) + return + } + + if listFlag { + // Non-interactive list + limit := cfg.DefaultLimit + if listLimitFlag > 0 { + limit = listLimitFlag + } + listEntriesNonInteractive(limit) + return + } + + // Default: interactive list + limit := cfg.DefaultLimit + if limitFlag > 0 { + limit = limitFlag + } + listEntriesInteractive(limit) + }, +} + +func Execute() { + if err := rootCmd.Execute(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func init() { + cobra.OnInitialize(initConfig) + + rootCmd.Flags().BoolVarP(&versionFlag, "version", "v", false, "Print version") + rootCmd.Flags().BoolVarP(&listFlag, "list", "l", false, "Non-interactive list output") + rootCmd.Flags().IntVarP(&listLimitFlag, "list-limit", "n", 0, "Limit for non-interactive list (use with -l)") + rootCmd.Flags().IntVar(&limitFlag, "limit", 0, "Limit for interactive list") + rootCmd.Flags().StringVarP(&searchFlag, "search", "s", "", "Search clipboard entries") + rootCmd.Flags().StringVarP(&deleteFlag, "delete", "d", "", "Delete entry by ID") +} + +func initConfig() { + var err error + cfg, err = config.Load() + if err != nil { + fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err) + os.Exit(1) + } +} + +func loadDatabase() *database.Database { + db, err := database.Load(cfg.DBPath()) + if err != nil { + fmt.Fprintf(os.Stderr, "Error loading database: %v\n", err) + os.Exit(1) + } + return db +} + +func copyEntryToClipboard(id string) { + db := loadDatabase() + + entry, err := db.Get(id) + if err != nil { + fmt.Fprintf(os.Stderr, "Entry not found: %s\n", id) + os.Exit(1) + } + + clip, err := clipboard.New() + if err != nil { + fmt.Fprintf(os.Stderr, "Error initializing clipboard: %v\n", err) + os.Exit(1) + } + + if err := clip.Write(entry.Value); err != nil { + fmt.Fprintf(os.Stderr, "Error writing to clipboard: %v\n", err) + os.Exit(1) + } + + preview := entry.Value + if len(preview) > 50 { + preview = preview[:50] + "..." + } + fmt.Printf("Copied to clipboard: %s\n", cleanForDisplay(preview)) +} + +func deleteEntry(id string) { + db := loadDatabase() + + if err := db.Delete(id); err != nil { + fmt.Fprintf(os.Stderr, "Error deleting entry: %v\n", err) + os.Exit(1) + } + + fmt.Printf("Deleted entry: %s\n", id) +} + +func searchEntries(query string) { + db := loadDatabase() + searcher := search.NewSearcher(db) + + limit := cfg.DefaultLimit + if limitFlag > 0 { + limit = limitFlag + } + + results := searcher.Search(query, search.Options{MaxResults: limit}) + + if len(results) == 0 { + fmt.Printf("No results found for '%s'\n", query) + return + } + + // Convert results to entries for display + entries := make([]*database.Entry, len(results)) + for i, r := range results { + entries[i] = r.Entry + } + + if listFlag { + // Non-interactive search results + printEntries(entries) + return + } + + // Interactive search results + title := fmt.Sprintf("Search results for '%s' (%d found)", query, len(results)) + selected, err := tui.Run(entries, db, title) + if err != nil { + fmt.Fprintf(os.Stderr, "Error running TUI: %v\n", err) + os.Exit(1) + } + + if selected != nil { + copyToClipboard(selected) + } +} + +func listEntriesNonInteractive(limit int) { + db := loadDatabase() + entries := db.List(limit) + + if len(entries) == 0 { + fmt.Println("No clipboard entries found") + return + } + + printEntries(entries) +} + +func listEntriesInteractive(limit int) { + db := loadDatabase() + entries := db.List(limit) + + if len(entries) == 0 { + fmt.Println("No clipboard entries found") + return + } + + title := fmt.Sprintf("Clipboard History (%d entries)", len(entries)) + selected, err := tui.Run(entries, db, title) + if err != nil { + fmt.Fprintf(os.Stderr, "Error running TUI: %v\n", err) + os.Exit(1) + } + + if selected != nil { + copyToClipboard(selected) + } +} + +func printEntries(entries []*database.Entry) { + for i, entry := range entries { + preview := entry.Value + if len(preview) > 60 { + preview = preview[:60] + "..." + } + preview = cleanForDisplay(preview) + + fmt.Printf("%s. [%s] %s %s\n", + padLeft(strconv.Itoa(i+1), 2), + entry.ID, + entry.DateTime.Format("01/02 15:04"), + preview, + ) + } +} + +func copyToClipboard(entry *database.Entry) { + clip, err := clipboard.New() + if err != nil { + fmt.Fprintf(os.Stderr, "Error initializing clipboard: %v\n", err) + os.Exit(1) + } + + if err := clip.Write(entry.Value); err != nil { + fmt.Fprintf(os.Stderr, "Error writing to clipboard: %v\n", err) + os.Exit(1) + } + + preview := entry.Value + if len(preview) > 50 { + preview = preview[:50] + "..." + } + fmt.Printf("Copied to clipboard: %s\n", cleanForDisplay(preview)) +} + +func cleanForDisplay(s string) string { + result := make([]byte, 0, len(s)) + for i := 0; i < len(s); i++ { + if s[i] == '\n' || s[i] == '\r' { + result = append(result, ' ') + } else if s[i] == '\t' { + result = append(result, ' ') + } else { + result = append(result, s[i]) + } + } + return string(result) +} + +func padLeft(s string, width int) string { + for len(s) < width { + s = " " + s + } + return s +} diff --git a/cmd/service.go b/cmd/service.go new file mode 100644 index 0000000..0a7c3e5 --- /dev/null +++ b/cmd/service.go @@ -0,0 +1,211 @@ +package cmd + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strconv" + "syscall" + + "github.com/spf13/cobra" + "github.com/yeho/klp/internal/database" + "github.com/yeho/klp/internal/service" +) + +var ( + foregroundFlag bool +) + +var serviceCmd = &cobra.Command{ + Use: "service", + Short: "Service management commands", + Long: `Commands for managing the clipboard monitoring service.`, +} + +var serviceStartCmd = &cobra.Command{ + Use: "start", + Short: "Start the clipboard monitor", + Long: `Start the clipboard monitoring service as a background daemon. +The service monitors clipboard changes and stores them in the database. + +Use --foreground to run in foreground instead of daemonizing. +Use 'klp service stop' to stop the daemon.`, + Run: func(cmd *cobra.Command, args []string) { + if !foregroundFlag && os.Getenv("KLP_DAEMON") != "1" { + // Daemonize: re-exec ourselves in background + daemonize() + return + } + + // Running as daemon or in foreground mode + runMonitor() + }, +} + +var serviceStopCmd = &cobra.Command{ + Use: "stop", + Short: "Stop the clipboard monitor daemon", + Run: func(cmd *cobra.Command, args []string) { + pidFile := getPidFile() + data, err := os.ReadFile(pidFile) + if err != nil { + fmt.Println("No running daemon found") + return + } + + pid, err := strconv.Atoi(string(data)) + if err != nil { + fmt.Fprintf(os.Stderr, "Invalid PID file: %v\n", err) + os.Exit(1) + } + + process, err := os.FindProcess(pid) + if err != nil { + fmt.Println("No running daemon found") + os.Remove(pidFile) + return + } + + if err := process.Signal(syscall.SIGTERM); err != nil { + fmt.Printf("Daemon not running (PID %d)\n", pid) + os.Remove(pidFile) + return + } + + fmt.Printf("Stopped daemon (PID %d)\n", pid) + os.Remove(pidFile) + }, +} + +var serviceStatusCmd = &cobra.Command{ + Use: "status", + Short: "Check if the clipboard monitor is running", + Run: func(cmd *cobra.Command, args []string) { + pidFile := getPidFile() + data, err := os.ReadFile(pidFile) + if err != nil { + fmt.Println("Daemon is not running") + return + } + + pid, err := strconv.Atoi(string(data)) + if err != nil { + fmt.Println("Daemon is not running (invalid PID file)") + return + } + + // Check if process exists + process, err := os.FindProcess(pid) + if err != nil { + fmt.Println("Daemon is not running") + os.Remove(pidFile) + return + } + + // On Unix, FindProcess always succeeds, so we need to send signal 0 to check + if err := process.Signal(syscall.Signal(0)); err != nil { + fmt.Println("Daemon is not running") + os.Remove(pidFile) + return + } + + fmt.Printf("Daemon is running (PID %d)\n", pid) + }, +} + +func init() { + rootCmd.AddCommand(serviceCmd) + serviceCmd.AddCommand(serviceStartCmd) + serviceCmd.AddCommand(serviceStopCmd) + serviceCmd.AddCommand(serviceStatusCmd) + + serviceStartCmd.Flags().BoolVarP(&foregroundFlag, "foreground", "f", false, "Run in foreground instead of daemonizing") +} + +func getPidFile() string { + homeDir, _ := os.UserHomeDir() + return filepath.Join(homeDir, ".config", "klp", "klp.pid") +} + +func getLogFile() string { + homeDir, _ := os.UserHomeDir() + return filepath.Join(homeDir, ".config", "klp", "klp.log") +} + +func daemonize() { + // Check if already running + pidFile := getPidFile() + if data, err := os.ReadFile(pidFile); err == nil { + if pid, err := strconv.Atoi(string(data)); err == nil { + if process, err := os.FindProcess(pid); err == nil { + if err := process.Signal(syscall.Signal(0)); err == nil { + fmt.Printf("Daemon already running (PID %d)\n", pid) + return + } + } + } + } + + // Get executable path + executable, err := os.Executable() + if err != nil { + fmt.Fprintf(os.Stderr, "Error getting executable path: %v\n", err) + os.Exit(1) + } + + // Ensure config directory exists + os.MkdirAll(filepath.Dir(pidFile), 0755) + + // Open log file + logFile, err := os.OpenFile(getLogFile(), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) + if err != nil { + fmt.Fprintf(os.Stderr, "Error opening log file: %v\n", err) + os.Exit(1) + } + + // Start daemon process + cmd := exec.Command(executable, "service", "start", "--foreground") + cmd.Env = append(os.Environ(), "KLP_DAEMON=1") + cmd.Stdout = logFile + cmd.Stderr = logFile + cmd.SysProcAttr = &syscall.SysProcAttr{ + Setsid: true, // Create new session + } + + if err := cmd.Start(); err != nil { + fmt.Fprintf(os.Stderr, "Error starting daemon: %v\n", err) + os.Exit(1) + } + + // Write PID file + if err := os.WriteFile(pidFile, []byte(strconv.Itoa(cmd.Process.Pid)), 0644); err != nil { + fmt.Fprintf(os.Stderr, "Error writing PID file: %v\n", err) + } + + fmt.Printf("Daemon started (PID %d)\n", cmd.Process.Pid) + fmt.Printf("Log file: %s\n", getLogFile()) +} + +func runMonitor() { + db, err := database.Load(cfg.DBPath()) + if err != nil { + fmt.Fprintf(os.Stderr, "Error loading database: %v\n", err) + os.Exit(1) + } + + monitor, err := service.NewMonitor(cfg, db) + if err != nil { + fmt.Fprintf(os.Stderr, "Error creating monitor: %v\n", err) + os.Exit(1) + } + + // Clean up PID file on exit + pidFile := getPidFile() + defer os.Remove(pidFile) + + if err := monitor.Run(); err != nil { + fmt.Fprintf(os.Stderr, "Error running monitor: %v\n", err) + os.Exit(1) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..c553fc8 --- /dev/null +++ b/go.mod @@ -0,0 +1,47 @@ +module github.com/yeho/klp + +go 1.24.0 + +toolchain go1.24.12 + +require ( + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.0 + github.com/spf13/cobra v1.10.2 + github.com/spf13/viper v1.21.0 + golang.design/x/clipboard v0.7.1 +) + +require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/x/ansi v0.10.1 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/sagikazarmark/locafero v0.11.0 // indirect + github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect + github.com/spf13/afero v1.15.0 // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/exp/shiny v0.0.0-20250606033433-dcc06ee1d476 // indirect + golang.org/x/image v0.28.0 // indirect + golang.org/x/mobile v0.0.0-20250606033058-a2a15c67f36f // indirect + golang.org/x/sys v0.36.0 // indirect + golang.org/x/text v0.28.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..0816bf5 --- /dev/null +++ b/go.sum @@ -0,0 +1,101 @@ +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= +github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= +github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= +github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.design/x/clipboard v0.7.1 h1:OEG3CmcYRBNnRwpDp7+uWLiZi3hrMRJpE9JkkkYtz2c= +golang.design/x/clipboard v0.7.1/go.mod h1:i5SiIqj0wLFw9P/1D7vfILFK0KHMk7ydE72HRrUIgkg= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= +golang.org/x/exp/shiny v0.0.0-20250606033433-dcc06ee1d476 h1:Wdx0vgH5Wgsw+lF//LJKmWOJBLWX6nprsMqnf99rYDE= +golang.org/x/exp/shiny v0.0.0-20250606033433-dcc06ee1d476/go.mod h1:ygj7T6vSGhhm/9yTpOQQNvuAUFziTH7RUiH74EoE2C8= +golang.org/x/image v0.28.0 h1:gdem5JW1OLS4FbkWgLO+7ZeFzYtL3xClb97GaUzYMFE= +golang.org/x/image v0.28.0/go.mod h1:GUJYXtnGKEUgggyzh+Vxt+AviiCcyiwpsl8iQ8MvwGY= +golang.org/x/mobile v0.0.0-20250606033058-a2a15c67f36f h1:/n+PL2HlfqeSiDCuhdBbRNlGS/g2fM4OHufalHaTVG8= +golang.org/x/mobile v0.0.0-20250606033058-a2a15c67f36f/go.mod h1:ESkJ836Z6LpG6mTVAhA48LpfW/8fNR0ifStlH2axyfg= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= +golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..621a472 --- /dev/null +++ b/install.sh @@ -0,0 +1,40 @@ +#!/bin/bash +set -e + +echo "Building klp..." +make build + +echo "Installing to ~/.local/bin..." +make install + +echo "Creating config directory..." +mkdir -p ~/.config/klp + +# Ensure ~/.local/bin is in PATH for this script +export PATH="$HOME/.local/bin:$PATH" + +echo "Starting clipboard monitor daemon..." +klp service start + +echo "" +echo "Installation complete!" +echo "" +echo "Make sure ~/.local/bin is in your PATH (add to ~/.bashrc or ~/.zshrc):" +echo ' export PATH="$HOME/.local/bin:$PATH"' +echo "" +echo "Service management:" +echo " klp service status # Check if daemon is running" +echo " klp service stop # Stop the daemon" +echo " klp service start # Start the daemon" +echo "" +echo "For auto-start on login, choose one:" +echo " make service-install # systemd user service" +echo " make autostart-install # desktop autostart entry" +echo "" +echo "Usage:" +echo " klp # Interactive list" +echo " klp -l # Non-interactive list" +echo " klp # Copy entry to clipboard" +echo " klp -s 'query' # Search" +echo " klp -d # Delete entry" +echo " klp -v # Version" diff --git a/internal/clipboard/clipboard.go b/internal/clipboard/clipboard.go new file mode 100644 index 0000000..16940e6 --- /dev/null +++ b/internal/clipboard/clipboard.go @@ -0,0 +1,13 @@ +package clipboard + +import "context" + +// Clipboard provides an interface for clipboard operations +type Clipboard interface { + // Read returns the current clipboard content + Read() (string, error) + // Write sets the clipboard content + Write(content string) error + // Watch returns a channel that emits clipboard changes + Watch(ctx context.Context) <-chan string +} diff --git a/internal/clipboard/detector.go b/internal/clipboard/detector.go new file mode 100644 index 0000000..1cced26 --- /dev/null +++ b/internal/clipboard/detector.go @@ -0,0 +1,39 @@ +package clipboard + +import "os" + +type DisplayServer int + +const ( + DisplayServerUnknown DisplayServer = iota + DisplayServerX11 + DisplayServerWayland +) + +// Detect returns the current display server type +func Detect() DisplayServer { + // Check for Wayland first + if os.Getenv("WAYLAND_DISPLAY") != "" { + return DisplayServerWayland + } + + // Check for X11 + if os.Getenv("DISPLAY") != "" { + return DisplayServerX11 + } + + return DisplayServerUnknown +} + +// New creates a clipboard implementation for the current display server +func New() (Clipboard, error) { + switch Detect() { + case DisplayServerWayland: + return NewWayland() + case DisplayServerX11: + return NewX11() + default: + // Try X11 as fallback + return NewX11() + } +} diff --git a/internal/clipboard/wayland.go b/internal/clipboard/wayland.go new file mode 100644 index 0000000..b49eb83 --- /dev/null +++ b/internal/clipboard/wayland.go @@ -0,0 +1,77 @@ +package clipboard + +import ( + "bufio" + "context" + "os/exec" + "strings" +) + +type WaylandClipboard struct{} + +func NewWayland() (*WaylandClipboard, error) { + // Check if wl-paste is available + if _, err := exec.LookPath("wl-paste"); err != nil { + return nil, err + } + if _, err := exec.LookPath("wl-copy"); err != nil { + return nil, err + } + return &WaylandClipboard{}, nil +} + +func (c *WaylandClipboard) Read() (string, error) { + cmd := exec.Command("wl-paste", "--no-newline") + output, err := cmd.Output() + if err != nil { + // wl-paste returns error if clipboard is empty + return "", nil + } + return string(output), nil +} + +func (c *WaylandClipboard) Write(content string) error { + cmd := exec.Command("wl-copy") + cmd.Stdin = strings.NewReader(content) + return cmd.Run() +} + +func (c *WaylandClipboard) Watch(ctx context.Context) <-chan string { + out := make(chan string) + + go func() { + defer close(out) + + // Use wl-paste --watch to detect clipboard changes + // When clipboard changes, it runs "echo x" which outputs a single line + // Then we read the full clipboard content separately + cmd := exec.CommandContext(ctx, "wl-paste", "--watch", "echo", "x") + stdout, err := cmd.StdoutPipe() + if err != nil { + return + } + + if err := cmd.Start(); err != nil { + return + } + + scanner := bufio.NewScanner(stdout) + for scanner.Scan() { + select { + case <-ctx.Done(): + cmd.Process.Kill() + return + default: + // Clipboard changed, read full content + content, err := c.Read() + if err == nil && content != "" { + out <- content + } + } + } + + cmd.Wait() + }() + + return out +} diff --git a/internal/clipboard/x11.go b/internal/clipboard/x11.go new file mode 100644 index 0000000..20d1833 --- /dev/null +++ b/internal/clipboard/x11.go @@ -0,0 +1,51 @@ +package clipboard + +import ( + "context" + + "golang.design/x/clipboard" +) + +type X11Clipboard struct{} + +func NewX11() (*X11Clipboard, error) { + if err := clipboard.Init(); err != nil { + return nil, err + } + return &X11Clipboard{}, nil +} + +func (c *X11Clipboard) Read() (string, error) { + return string(clipboard.Read(clipboard.FmtText)), nil +} + +func (c *X11Clipboard) Write(content string) error { + clipboard.Write(clipboard.FmtText, []byte(content)) + return nil +} + +func (c *X11Clipboard) Watch(ctx context.Context) <-chan string { + out := make(chan string) + + go func() { + defer close(out) + + // golang.design/x/clipboard provides a Watch function + ch := clipboard.Watch(ctx, clipboard.FmtText) + for { + select { + case <-ctx.Done(): + return + case data, ok := <-ch: + if !ok { + return + } + if len(data) > 0 { + out <- string(data) + } + } + } + }() + + return out +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..3ca87f4 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,82 @@ +package config + +import ( + "os" + "path/filepath" + + "github.com/spf13/viper" +) + +type Config struct { + DefaultLimit int `json:"defaultLimit" mapstructure:"defaultLimit"` + DBLocation string `json:"dbLocation" mapstructure:"dbLocation"` + MaxEntrySize int `json:"maxEntrySize" mapstructure:"maxEntrySize"` +} + +func DefaultConfig() *Config { + homeDir, _ := os.UserHomeDir() + return &Config{ + DefaultLimit: 20, + DBLocation: filepath.Join(homeDir, ".klp.db"), + MaxEntrySize: 0, // 0 means unlimited + } +} + +func Load() (*Config, error) { + homeDir, err := os.UserHomeDir() + if err != nil { + return nil, err + } + + configDir := filepath.Join(homeDir, ".config", "klp") + if err := os.MkdirAll(configDir, 0755); err != nil { + return nil, err + } + + viper.SetConfigName("config") + viper.SetConfigType("json") + viper.AddConfigPath(configDir) + + // Set defaults + defaults := DefaultConfig() + viper.SetDefault("defaultLimit", defaults.DefaultLimit) + viper.SetDefault("dbLocation", defaults.DBLocation) + viper.SetDefault("maxEntrySize", defaults.MaxEntrySize) + + if err := viper.ReadInConfig(); err != nil { + if _, ok := err.(viper.ConfigFileNotFoundError); ok { + // Config file not found, create with defaults + configPath := filepath.Join(configDir, "config.json") + if err := viper.SafeWriteConfigAs(configPath); err != nil { + return nil, err + } + } else { + return nil, err + } + } + + var cfg Config + if err := viper.Unmarshal(&cfg); err != nil { + return nil, err + } + + // Expand ~ in db location + cfg.DBLocation = expandPath(cfg.DBLocation) + + return &cfg, nil +} + +func (c *Config) DBPath() string { + return c.DBLocation +} + +func expandPath(path string) string { + if len(path) > 0 && path[0] == '~' { + homeDir, err := os.UserHomeDir() + if err != nil { + return path + } + return filepath.Join(homeDir, path[1:]) + } + return path +} diff --git a/internal/database/database.go b/internal/database/database.go new file mode 100644 index 0000000..879b33e --- /dev/null +++ b/internal/database/database.go @@ -0,0 +1,149 @@ +package database + +import ( + "bufio" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "os" + "sort" + "time" +) + +var ( + ErrNotFound = errors.New("entry not found") +) + +type Database struct { + path string + entries map[string]*Entry +} + +func Load(path string) (*Database, error) { + db := &Database{ + path: path, + entries: make(map[string]*Entry), + } + + file, err := os.Open(path) + if err != nil { + if os.IsNotExist(err) { + return db, nil + } + return nil, err + } + defer file.Close() + + scanner := bufio.NewScanner(file) + // Set larger buffer for potentially long clipboard entries + buf := make([]byte, 0, 64*1024) + scanner.Buffer(buf, 1024*1024) // 1MB max line size + + for scanner.Scan() { + line := scanner.Text() + if line == "" { + continue + } + + var entry Entry + if err := json.Unmarshal([]byte(line), &entry); err != nil { + continue + } + + db.entries[entry.ID] = &entry + } + + if err := scanner.Err(); err != nil { + return nil, err + } + + return db, nil +} + +func (db *Database) Save() error { + file, err := os.Create(db.path) + if err != nil { + return err + } + defer file.Close() + + for _, entry := range db.entries { + data, err := json.Marshal(entry) + if err != nil { + return err + } + if _, err := file.Write(data); err != nil { + return err + } + if _, err := file.WriteString("\n"); err != nil { + return err + } + } + + return nil +} + +func generateID() string { + bytes := make([]byte, 2) // 2 bytes = 4 hex chars + rand.Read(bytes) + return hex.EncodeToString(bytes) +} + +func (db *Database) Add(value string) (*Entry, error) { + entry := &Entry{ + ID: generateID(), + Value: value, + DateTime: time.Now(), + } + + db.entries[entry.ID] = entry + return entry, db.Save() +} + +func (db *Database) Get(id string) (*Entry, error) { + entry, exists := db.entries[id] + if !exists { + return nil, ErrNotFound + } + return entry, nil +} + +func (db *Database) Delete(id string) error { + if _, exists := db.entries[id]; !exists { + return ErrNotFound + } + + delete(db.entries, id) + return db.Save() +} + +func (db *Database) List(limit int) []*Entry { + entries := make([]*Entry, 0, len(db.entries)) + for _, entry := range db.entries { + entries = append(entries, entry) + } + + // Sort by datetime descending (most recent first) + sort.Slice(entries, func(i, j int) bool { + return entries[i].DateTime.After(entries[j].DateTime) + }) + + if limit > 0 && limit < len(entries) { + entries = entries[:limit] + } + + return entries +} + +func (db *Database) MostRecent() *Entry { + entries := db.List(1) + if len(entries) == 0 { + return nil + } + return entries[0] +} + +func (db *Database) Count() int { + return len(db.entries) +} diff --git a/internal/database/entry.go b/internal/database/entry.go new file mode 100644 index 0000000..6c907ed --- /dev/null +++ b/internal/database/entry.go @@ -0,0 +1,9 @@ +package database + +import "time" + +type Entry struct { + ID string `json:"id"` + Value string `json:"value"` + DateTime time.Time `json:"datetime"` +} diff --git a/internal/search/search.go b/internal/search/search.go new file mode 100644 index 0000000..c7e9509 --- /dev/null +++ b/internal/search/search.go @@ -0,0 +1,46 @@ +package search + +import ( + "strings" + + "github.com/yeho/klp/internal/database" +) + +type Result struct { + Entry *database.Entry +} + +type Options struct { + MaxResults int +} + +func DefaultOptions() Options { + return Options{ + MaxResults: 20, + } +} + +type Searcher struct { + db *database.Database +} + +func NewSearcher(db *database.Database) *Searcher { + return &Searcher{db: db} +} + +func (s *Searcher) Search(query string, opts Options) []Result { + queryLower := strings.ToLower(query) + results := []Result{} + + entries := s.db.List(0) // Get all entries + for _, entry := range entries { + if strings.Contains(strings.ToLower(entry.Value), queryLower) { + results = append(results, Result{Entry: entry}) + if opts.MaxResults > 0 && len(results) >= opts.MaxResults { + break + } + } + } + + return results +} diff --git a/internal/service/monitor.go b/internal/service/monitor.go new file mode 100644 index 0000000..2c067eb --- /dev/null +++ b/internal/service/monitor.go @@ -0,0 +1,113 @@ +package service + +import ( + "context" + "fmt" + "os" + "os/signal" + "syscall" + + "github.com/yeho/klp/internal/clipboard" + "github.com/yeho/klp/internal/config" + "github.com/yeho/klp/internal/database" +) + +type Monitor struct { + cfg *config.Config + db *database.Database + clipboard clipboard.Clipboard +} + +func NewMonitor(cfg *config.Config, db *database.Database) (*Monitor, error) { + clip, err := clipboard.New() + if err != nil { + return nil, fmt.Errorf("failed to initialize clipboard: %w", err) + } + + return &Monitor{ + cfg: cfg, + db: db, + clipboard: clip, + }, nil +} + +func (m *Monitor) Run() error { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Handle shutdown signals + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGTERM, syscall.SIGINT) + + go func() { + <-sigChan + fmt.Println("\nShutting down...") + cancel() + }() + + fmt.Println("Clipboard monitor started. Press Ctrl+C to stop.") + + changes := m.clipboard.Watch(ctx) + for { + select { + case <-ctx.Done(): + return nil + case content, ok := <-changes: + if !ok { + return nil + } + if err := m.handleChange(content); err != nil { + fmt.Fprintf(os.Stderr, "Error handling clipboard change: %v\n", err) + } + } + } +} + +func (m *Monitor) handleChange(content string) error { + // Skip empty content + if content == "" { + return nil + } + + // Check max entry size if configured + if m.cfg.MaxEntrySize > 0 && len(content) > m.cfg.MaxEntrySize { + return nil + } + + // Skip if duplicate of most recent entry + recent := m.db.MostRecent() + if recent != nil && recent.Value == content { + return nil + } + + // Add to database + entry, err := m.db.Add(content) + if err != nil { + return err + } + + // Log the addition (truncate for display) + preview := content + if len(preview) > 50 { + preview = preview[:50] + "..." + } + // Replace newlines for cleaner display + preview = replaceNewlines(preview) + fmt.Printf("Added [%s]: %s\n", entry.ID, preview) + + return nil +} + +func replaceNewlines(s string) string { + result := make([]byte, 0, len(s)) + for i := 0; i < len(s); i++ { + if s[i] == '\n' { + result = append(result, ' ') + } else if s[i] == '\r' { + // skip + } else { + result = append(result, s[i]) + } + } + return string(result) +} diff --git a/internal/tui/styles.go b/internal/tui/styles.go new file mode 100644 index 0000000..17c4bc6 --- /dev/null +++ b/internal/tui/styles.go @@ -0,0 +1,53 @@ +package tui + +import "github.com/charmbracelet/lipgloss" + +var ( + // Colors + secondaryColor = lipgloss.Color("241") // Gray + dimColor = lipgloss.Color("245") // Dimmer gray + + // Title + TitleStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("213")) // Magenta/pink + + // ID styles + IDStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("82")) // Bright green + + IDStyleSelected = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("154")) // Even brighter green + + // DateTime styles + DateTimeStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("244")) // Medium gray + + DateTimeStyleSelected = lipgloss.NewStyle(). + Foreground(lipgloss.Color("250")) // Lighter + + // Content styles + ContentStyle = lipgloss.NewStyle(). + Foreground(dimColor) + + ContentStyleSelected = lipgloss.NewStyle(). + Foreground(lipgloss.Color("252")) // Brighter when selected + + // Cursor + CursorStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("212")) + + // Help + HelpStyle = lipgloss.NewStyle(). + Foreground(secondaryColor). + MarginTop(1) + + // No results + NoResultsStyle = lipgloss.NewStyle(). + Foreground(secondaryColor). + Italic(true). + MarginTop(1) +) diff --git a/internal/tui/tui.go b/internal/tui/tui.go new file mode 100644 index 0000000..e454ed9 --- /dev/null +++ b/internal/tui/tui.go @@ -0,0 +1,231 @@ +package tui + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/yeho/klp/internal/database" +) + +type Model struct { + entries []*database.Entry + db *database.Database + cursor int + selected *database.Entry + title string + quitting bool + width int + height int +} + +func New(entries []*database.Entry, db *database.Database, title string) Model { + return Model{ + entries: entries, + db: db, + cursor: 0, + title: title, + } +} + +func (m Model) Init() tea.Cmd { + return nil +} + +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c", "q", "esc": + m.quitting = true + return m, tea.Quit + + case "enter": + if len(m.entries) > 0 && m.cursor < len(m.entries) { + m.selected = m.entries[m.cursor] + } + return m, tea.Quit + + case "up", "k": + if m.cursor > 0 { + m.cursor-- + } + + case "down", "j": + if m.cursor < len(m.entries)-1 { + m.cursor++ + } + + case "home", "g": + m.cursor = 0 + + case "end", "G": + m.cursor = len(m.entries) - 1 + + case "d": + if len(m.entries) > 0 && m.cursor < len(m.entries) && m.db != nil { + entry := m.entries[m.cursor] + if err := m.db.Delete(entry.ID); err == nil { + // Remove from local list + m.entries = append(m.entries[:m.cursor], m.entries[m.cursor+1:]...) + // Adjust cursor if needed + if m.cursor >= len(m.entries) && m.cursor > 0 { + m.cursor-- + } + // Update title with new count + m.title = fmt.Sprintf("Clipboard History (%d entries)", len(m.entries)) + } + } + } + + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + } + + return m, nil +} + +func (m Model) View() string { + if len(m.entries) == 0 { + return NoResultsStyle.Render("No clipboard entries found") + } + + var b strings.Builder + + // Title + b.WriteString(TitleStyle.Render(m.title)) + b.WriteString("\n") + + // Calculate visible window + maxVisible := 10 + if m.height > 0 { + maxVisible = m.height - 6 // Account for title, help, margins + if maxVisible < 5 { + maxVisible = 5 + } + } + + start := 0 + end := len(m.entries) + if len(m.entries) > maxVisible { + start = m.cursor - maxVisible/2 + if start < 0 { + start = 0 + } + end = start + maxVisible + if end > len(m.entries) { + end = len(m.entries) + start = end - maxVisible + if start < 0 { + start = 0 + } + } + } + + // Calculate max content width + maxContentWidth := 60 + if m.width > 0 { + // Reserve space for cursor, ID, datetime, and margins + maxContentWidth = m.width - 30 + if maxContentWidth < 20 { + maxContentWidth = 20 + } + } + + for i := start; i < end; i++ { + entry := m.entries[i] + isSelected := i == m.cursor + + // Cursor indicator + cursor := " " + if isSelected { + cursor = CursorStyle.Render("> ") + } + + // ID + var id string + if isSelected { + id = IDStyleSelected.Render(entry.ID) + } else { + id = IDStyle.Render(entry.ID) + } + + // DateTime + dateStr := entry.DateTime.Format("01/02 15:04") + var dateTime string + if isSelected { + dateTime = DateTimeStyleSelected.Render(dateStr) + } else { + dateTime = DateTimeStyle.Render(dateStr) + } + + // Content preview (truncate and clean) + preview := cleanPreview(entry.Value, maxContentWidth) + var content string + if isSelected { + content = ContentStyleSelected.Render(preview) + } else { + content = ContentStyle.Render(preview) + } + + line := fmt.Sprintf("%s%s %s %s", cursor, id, dateTime, content) + b.WriteString(line) + b.WriteString("\n") + } + + // Scroll indicator + if len(m.entries) > maxVisible { + scrollInfo := fmt.Sprintf("(%d-%d of %d)", start+1, end, len(m.entries)) + b.WriteString(DateTimeStyle.Render(scrollInfo)) + b.WriteString("\n") + } + + // Help + help := "\n j/k up/down enter select d delete q quit" + b.WriteString(HelpStyle.Render(help)) + + return b.String() +} + +func cleanPreview(s string, maxLen int) string { + // Replace newlines with spaces + result := strings.ReplaceAll(s, "\n", " ") + result = strings.ReplaceAll(result, "\r", "") + result = strings.ReplaceAll(result, "\t", " ") + + // Collapse multiple spaces + for strings.Contains(result, " ") { + result = strings.ReplaceAll(result, " ", " ") + } + + result = strings.TrimSpace(result) + + // Truncate + if len(result) > maxLen { + result = result[:maxLen-3] + "..." + } + + return result +} + +func (m Model) Selected() *database.Entry { + return m.selected +} + +func (m Model) Quitting() bool { + return m.quitting +} + +func Run(entries []*database.Entry, db *database.Database, title string) (*database.Entry, error) { + model := New(entries, db, title) + p := tea.NewProgram(model) + + finalModel, err := p.Run() + if err != nil { + return nil, err + } + + m := finalModel.(Model) + return m.Selected(), nil +} diff --git a/klp b/klp new file mode 100755 index 0000000..9cac0cf Binary files /dev/null and b/klp differ diff --git a/klp.desktop b/klp.desktop new file mode 100644 index 0000000..835c679 --- /dev/null +++ b/klp.desktop @@ -0,0 +1,8 @@ +[Desktop Entry] +Type=Application +Name=klp +Comment=Clipboard history monitor +Exec=klp service start +Hidden=false +NoDisplay=true +X-GNOME-Autostart-enabled=true diff --git a/klp.service b/klp.service new file mode 100644 index 0000000..d9dc52a --- /dev/null +++ b/klp.service @@ -0,0 +1,12 @@ +[Unit] +Description=klp clipboard history monitor +After=graphical-session.target + +[Service] +Type=simple +ExecStart=%h/.local/bin/klp service start +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=default.target diff --git a/main.go b/main.go new file mode 100644 index 0000000..1a8b297 --- /dev/null +++ b/main.go @@ -0,0 +1,7 @@ +package main + +import "github.com/yeho/klp/cmd" + +func main() { + cmd.Execute() +}