init commit
This commit is contained in:
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
|
||||
}
|
||||
Reference in New Issue
Block a user