Add snippet/command library with CRUD and send-to-session

Full command snippet library for saving and sending reusable commands:

Backend:
- SQLite migration 003 creates snippets table
- Snippet model with name, command, description, folder, tags
- Store CRUD methods and API endpoints

Frontend:
- SnippetLibrary class with searchable list + detail/editor split view
- Create, edit, delete snippets in modal dialog
- 'Send to Session' button writes command to active terminal
- 'Send Snippet' option in terminal right-click context menu
- Ctrl+Shift+S keyboard shortcut to open library
This commit is contained in:
2026-04-17 00:00:00 +01:00
parent 9cf2010c70
commit b6d77e485c
11 changed files with 544 additions and 1 deletions
+48
View File
@@ -574,6 +574,54 @@ func (s *Service) ListAuditEntries(limit int) ([]models.AuditEntry, error) {
return result, nil
}
// ---------------------------------------------------------------------------
// Snippets (command library)
// ---------------------------------------------------------------------------
// ListSnippets returns all saved command snippets.
func (s *Service) ListSnippets() ([]models.Snippet, error) {
snippets, err := s.store.ListSnippets()
if err != nil {
return nil, err
}
result := make([]models.Snippet, len(snippets))
for i, sn := range snippets {
result[i] = *sn
}
return result, nil
}
// CreateSnippet saves a new command snippet.
func (s *Service) CreateSnippet(snippet models.Snippet) (models.Snippet, error) {
if snippet.ID == "" {
snippet.ID = uuid.New().String()
}
now := time.Now()
snippet.CreatedAt = now
snippet.UpdatedAt = now
if snippet.Tags == nil {
snippet.Tags = []string{}
}
if err := s.store.CreateSnippet(&snippet); err != nil {
return models.Snippet{}, fmt.Errorf("failed to create snippet: %w", err)
}
return snippet, nil
}
// UpdateSnippet updates an existing command snippet.
func (s *Service) UpdateSnippet(snippet models.Snippet) (models.Snippet, error) {
snippet.UpdatedAt = time.Now()
if err := s.store.UpdateSnippet(&snippet); err != nil {
return models.Snippet{}, fmt.Errorf("failed to update snippet: %w", err)
}
return snippet, nil
}
// DeleteSnippet removes a command snippet by ID.
func (s *Service) DeleteSnippet(id string) error {
return s.store.DeleteSnippet(id)
}
// ---------------------------------------------------------------------------
// Utility
// ---------------------------------------------------------------------------
+12
View File
@@ -256,3 +256,15 @@ type AuditEntry struct {
SessionID string `json:"sessionId,omitempty"`
Details string `json:"details"`
}
// Snippet stores a reusable command for the snippet library.
type Snippet struct {
ID string `json:"id"`
Name string `json:"name"`
Command string `json:"command"`
Description string `json:"description"`
Folder string `json:"folder"`
Tags []string `json:"tags"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
@@ -0,0 +1,15 @@
-- Migration 003: Add snippets table for the command snippet library.
-- Stores reusable commands that can be sent to active sessions.
CREATE TABLE IF NOT EXISTS snippets (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
command TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
folder TEXT NOT NULL DEFAULT '',
tags TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
INSERT INTO schema_version (version, applied_at) VALUES (3, datetime('now'));
+79
View File
@@ -709,3 +709,82 @@ func nullableJSON(ptr interface{}, data []byte) interface{} {
}
return string(data)
}
// ---------------------------------------------------------------------------
// Snippet CRUD
// ---------------------------------------------------------------------------
// CreateSnippet inserts a new snippet into the database.
func (s *Store) CreateSnippet(snippet *models.Snippet) error {
tagsJSON, _ := json.Marshal(snippet.Tags)
_, err := s.db.Exec(`INSERT INTO snippets (id, name, command, description, folder, tags, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
snippet.ID, snippet.Name, snippet.Command, snippet.Description,
snippet.Folder, string(tagsJSON),
snippet.CreatedAt.Format(time.RFC3339),
snippet.UpdatedAt.Format(time.RFC3339),
)
return err
}
// UpdateSnippet updates an existing snippet.
func (s *Store) UpdateSnippet(snippet *models.Snippet) error {
tagsJSON, _ := json.Marshal(snippet.Tags)
_, err := s.db.Exec(`UPDATE snippets SET name=?, command=?, description=?, folder=?, tags=?, updated_at=? WHERE id=?`,
snippet.Name, snippet.Command, snippet.Description,
snippet.Folder, string(tagsJSON),
snippet.UpdatedAt.Format(time.RFC3339),
snippet.ID,
)
return err
}
// DeleteSnippet removes a snippet by ID.
func (s *Store) DeleteSnippet(id string) error {
_, err := s.db.Exec(`DELETE FROM snippets WHERE id = ?`, id)
return err
}
// ListSnippets returns all snippets ordered by name.
func (s *Store) ListSnippets() ([]*models.Snippet, error) {
rows, err := s.db.Query(`SELECT id, name, command, description, folder, tags, created_at, updated_at FROM snippets ORDER BY name ASC`)
if err != nil {
return nil, err
}
defer rows.Close()
var snippets []*models.Snippet
for rows.Next() {
sn := &models.Snippet{}
var tagsStr, createdAt, updatedAt string
if err := rows.Scan(&sn.ID, &sn.Name, &sn.Command, &sn.Description, &sn.Folder, &tagsStr, &createdAt, &updatedAt); err != nil {
return nil, err
}
_ = json.Unmarshal([]byte(tagsStr), &sn.Tags)
if sn.Tags == nil {
sn.Tags = []string{}
}
sn.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
sn.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt)
snippets = append(snippets, sn)
}
return snippets, rows.Err()
}
// GetSnippet returns a single snippet by ID.
func (s *Store) GetSnippet(id string) (*models.Snippet, error) {
sn := &models.Snippet{}
var tagsStr, createdAt, updatedAt string
err := s.db.QueryRow(`SELECT id, name, command, description, folder, tags, created_at, updated_at FROM snippets WHERE id = ?`, id).
Scan(&sn.ID, &sn.Name, &sn.Command, &sn.Description, &sn.Folder, &tagsStr, &createdAt, &updatedAt)
if err != nil {
return nil, err
}
_ = json.Unmarshal([]byte(tagsStr), &sn.Tags)
if sn.Tags == nil {
sn.Tags = []string{}
}
sn.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
sn.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt)
return sn, nil
}