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:
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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'));
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user