init commit
This commit is contained in:
36
Makefile
Normal file
36
Makefile
Normal file
@@ -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
|
||||||
279
cmd/root.go
Normal file
279
cmd/root.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
211
cmd/service.go
Normal file
211
cmd/service.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
47
go.mod
Normal file
47
go.mod
Normal file
@@ -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
|
||||||
|
)
|
||||||
101
go.sum
Normal file
101
go.sum
Normal file
@@ -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=
|
||||||
40
install.sh
Executable file
40
install.sh
Executable file
@@ -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 <id> # Copy entry to clipboard"
|
||||||
|
echo " klp -s 'query' # Search"
|
||||||
|
echo " klp -d <id> # Delete entry"
|
||||||
|
echo " klp -v # Version"
|
||||||
13
internal/clipboard/clipboard.go
Normal file
13
internal/clipboard/clipboard.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
39
internal/clipboard/detector.go
Normal file
39
internal/clipboard/detector.go
Normal file
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
77
internal/clipboard/wayland.go
Normal file
77
internal/clipboard/wayland.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
51
internal/clipboard/x11.go
Normal file
51
internal/clipboard/x11.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
82
internal/config/config.go
Normal file
82
internal/config/config.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
149
internal/database/database.go
Normal file
149
internal/database/database.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
9
internal/database/entry.go
Normal file
9
internal/database/entry.go
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type Entry struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Value string `json:"value"`
|
||||||
|
DateTime time.Time `json:"datetime"`
|
||||||
|
}
|
||||||
46
internal/search/search.go
Normal file
46
internal/search/search.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
113
internal/service/monitor.go
Normal file
113
internal/service/monitor.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
53
internal/tui/styles.go
Normal file
53
internal/tui/styles.go
Normal file
@@ -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)
|
||||||
|
)
|
||||||
231
internal/tui/tui.go
Normal file
231
internal/tui/tui.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
8
klp.desktop
Normal file
8
klp.desktop
Normal file
@@ -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
|
||||||
12
klp.service
Normal file
12
klp.service
Normal file
@@ -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
|
||||||
Reference in New Issue
Block a user