diff --git a/frontend/index.html b/frontend/index.html index 52ce539..48e244f 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -212,6 +212,7 @@ + diff --git a/frontend/src/js/app.js b/frontend/src/js/app.js index 2628c39..09280ef 100644 --- a/frontend/src/js/app.js +++ b/frontend/src/js/app.js @@ -4,7 +4,7 @@ * Initialises the application shell, loads profiles, and sets up event listeners. */ -const { State, Sidebar, TabManager, TerminalManager, ProfileEditor, RawViewer, Notifications, Settings, CommandPalette, SecretPrompt, SplitPaneManager, RecordingManager, ExportImportUI, SFTPBrowser, stateLabel } = window.NexusTerminal; +const { State, Sidebar, TabManager, TerminalManager, ProfileEditor, RawViewer, Notifications, Settings, CommandPalette, SecretPrompt, SplitPaneManager, RecordingManager, ExportImportUI, SFTPBrowser, SnippetLibrary, stateLabel } = window.NexusTerminal; class App { constructor() { @@ -22,6 +22,7 @@ class App { this.recordings = new RecordingManager(this); this.exportImport = new ExportImportUI(this); this.sftp = new SFTPBrowser(this); + this.snippets = new SnippetLibrary(this); } /** @@ -392,6 +393,13 @@ class App { return; } + // Ctrl/Cmd+Shift+S — open snippet library + if (mod && e.shiftKey && e.key === 'S') { + e.preventDefault(); + this.snippets.open(); + return; + } + }); } diff --git a/frontend/src/js/snippets.js b/frontend/src/js/snippets.js new file mode 100644 index 0000000..c902fef --- /dev/null +++ b/frontend/src/js/snippets.js @@ -0,0 +1,306 @@ +(function() { +/** + * Nexus Terminal — snippet / command library. + * Provides a panel for managing and sending reusable commands to active sessions. + */ + +const { createElement, escapeHtml, dismissContextMenus } = window.NexusTerminal; + +class SnippetLibrary { + /** + * @param {App} app + */ + constructor(app) { + this.app = app; + /** @type {Array} */ + this.snippets = []; + this.searchQuery = ''; + } + + // ── Public API ─────────────────────────────────────────────────── + + /** + * Opens the snippet library modal. + */ + async open() { + await this._loadSnippets(); + this._showModal(); + } + + /** + * Sends a snippet command to the active session. + * @param {string} command + */ + sendToActiveSession(command) { + const sessionId = this.app.state.activeSessionId; + if (!sessionId) { + this.app.notifications.error('No active session'); + return; + } + const session = this.app.state.getSession(sessionId); + if (!session || session.state !== 'connected') { + this.app.notifications.error('Session is not connected'); + return; + } + try { + const b64 = btoa(command + '\n'); + window.go.api.Service.WriteSession(sessionId, b64); + this.app.notifications.info('Snippet sent'); + } catch (err) { + this.app.notifications.error('Failed to send snippet: ' + err); + } + } + + // ── Data ───────────────────────────────────────────────────────── + + /** @private */ + async _loadSnippets() { + try { + this.snippets = await window.go.api.Service.ListSnippets() || []; + } catch (err) { + this.snippets = []; + console.error('Failed to load snippets:', err); + } + } + + // ── Modal ──────────────────────────────────────────────────────── + + /** @private */ + _showModal() { + const backdrop = createElement('div', 'modal-backdrop'); + const modal = createElement('div', 'modal modal-lg'); + + modal.innerHTML = ` + + + `; + + backdrop.appendChild(modal); + document.body.appendChild(backdrop); + requestAnimationFrame(() => { + backdrop.classList.add('is-visible'); + modal.classList.add('is-visible'); + }); + + const close = () => { + backdrop.classList.remove('is-visible'); + modal.classList.remove('is-visible'); + setTimeout(() => backdrop.remove(), 200); + }; + + modal.querySelector('.modal-close').addEventListener('click', close); + backdrop.addEventListener('click', (e) => { if (e.target === backdrop) close(); }); + + // Search + const searchInput = modal.querySelector('.snippet-search'); + searchInput.addEventListener('input', () => { + this.searchQuery = searchInput.value.trim().toLowerCase(); + this._renderList(modal, close); + }); + + // New snippet button + modal.querySelector('.snippet-add-btn').addEventListener('click', () => { + this._showEditor(modal, null, close); + }); + + this._renderList(modal, close); + } + + /** @private */ + _renderList(modal, closeFn) { + const listEl = modal.querySelector('.snippet-list'); + if (!listEl) return; + listEl.innerHTML = ''; + + const filtered = this.snippets.filter((sn) => { + if (!this.searchQuery) return true; + return sn.name.toLowerCase().includes(this.searchQuery) || + sn.command.toLowerCase().includes(this.searchQuery) || + (sn.description || '').toLowerCase().includes(this.searchQuery); + }); + + if (filtered.length === 0) { + listEl.innerHTML = '
No snippets found
'; + return; + } + + for (const sn of filtered) { + const item = createElement('div', 'snippet-item', { + dataset: { id: sn.id }, + }); + item.style.cssText = 'padding:var(--space-2);border-radius:var(--radius-md);cursor:pointer;border:1px solid var(--border-subtle);'; + item.innerHTML = ` +
${escapeHtml(sn.name)}
+
${escapeHtml(sn.command)}
+ `; + item.addEventListener('click', () => this._showDetail(modal, sn, closeFn)); + item.addEventListener('mousedown', (e) => e.preventDefault()); + item.addEventListener('mouseenter', () => { item.style.background = 'var(--bg-hover)'; }); + item.addEventListener('mouseleave', () => { item.style.background = ''; }); + listEl.appendChild(item); + } + } + + /** @private */ + _showDetail(modal, snippet, closeFn) { + const detailPanel = modal.querySelector('.snippet-detail-panel'); + if (!detailPanel) return; + detailPanel.innerHTML = ''; + + const nameEl = createElement('div', '', { + innerHTML: `${escapeHtml(snippet.name)}`, + }); + detailPanel.appendChild(nameEl); + + if (snippet.description) { + const descEl = createElement('div', '', { + textContent: snippet.description, + }); + descEl.style.cssText = 'font-size:var(--text-sm);color:var(--text-secondary);'; + detailPanel.appendChild(descEl); + } + + const cmdEl = createElement('pre', '', { + textContent: snippet.command, + }); + cmdEl.style.cssText = 'flex:1;font-family:var(--font-mono);font-size:var(--text-sm);background:var(--bg-base);padding:var(--space-3);border-radius:var(--radius-md);overflow:auto;color:var(--text-primary);margin:0;white-space:pre-wrap;word-break:break-all;'; + detailPanel.appendChild(cmdEl); + + // Action buttons + const actions = createElement('div', '', {}); + actions.style.cssText = 'display:flex;gap:var(--space-2);'; + + const sendBtn = createElement('button', 'btn btn-sm btn-primary', { textContent: 'Send to Session' }); + sendBtn.addEventListener('click', () => { + this.sendToActiveSession(snippet.command); + }); + + const editBtn = createElement('button', 'btn btn-sm btn-secondary', { textContent: 'Edit' }); + editBtn.addEventListener('click', () => { + this._showEditor(modal, snippet, closeFn); + }); + + const deleteBtn = createElement('button', 'btn btn-sm btn-danger', { textContent: 'Delete' }); + deleteBtn.addEventListener('click', async () => { + try { + await window.go.api.Service.DeleteSnippet(snippet.id); + this.snippets = this.snippets.filter((s) => s.id !== snippet.id); + this._renderList(modal, closeFn); + detailPanel.innerHTML = '
Snippet deleted
'; + } catch (err) { + this.app.notifications.error('Failed to delete: ' + err); + } + }); + + actions.appendChild(sendBtn); + actions.appendChild(editBtn); + actions.appendChild(deleteBtn); + detailPanel.appendChild(actions); + } + + /** @private */ + _showEditor(modal, snippet, closeFn) { + const detailPanel = modal.querySelector('.snippet-detail-panel'); + if (!detailPanel) return; + detailPanel.innerHTML = ''; + + const isNew = !snippet; + const title = isNew ? 'New Snippet' : 'Edit Snippet'; + + detailPanel.innerHTML = ` +
${title}
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ `; + + detailPanel.querySelector('.snippet-save-btn').addEventListener('click', async () => { + const name = detailPanel.querySelector('.snippet-ed-name').value.trim(); + const command = detailPanel.querySelector('.snippet-ed-cmd').value; + const description = detailPanel.querySelector('.snippet-ed-desc').value.trim(); + const folder = detailPanel.querySelector('.snippet-ed-folder').value.trim(); + + if (!name) { + this.app.notifications.error('Name is required'); + return; + } + if (!command) { + this.app.notifications.error('Command is required'); + return; + } + + try { + const data = { + id: isNew ? '' : snippet.id, + name, + command, + description, + folder, + tags: isNew ? [] : (snippet.tags || []), + }; + + let saved; + if (isNew) { + saved = await window.go.api.Service.CreateSnippet(data); + this.snippets.push(saved); + } else { + saved = await window.go.api.Service.UpdateSnippet(data); + const idx = this.snippets.findIndex((s) => s.id === saved.id); + if (idx !== -1) this.snippets[idx] = saved; + } + + this._renderList(modal, closeFn); + this._showDetail(modal, saved, closeFn); + this.app.notifications.info(isNew ? 'Snippet created' : 'Snippet updated'); + } catch (err) { + this.app.notifications.error('Failed to save snippet: ' + err); + } + }); + + detailPanel.querySelector('.snippet-cancel-btn').addEventListener('click', () => { + if (snippet) { + this._showDetail(modal, snippet, closeFn); + } else { + detailPanel.innerHTML = '
Select a snippet or create a new one
'; + } + }); + } +} + +window.NexusTerminal = window.NexusTerminal || {}; +window.NexusTerminal.SnippetLibrary = SnippetLibrary; + +})(); diff --git a/frontend/src/js/terminal.js b/frontend/src/js/terminal.js index 93cdef4..fd48f0a 100644 --- a/frontend/src/js/terminal.js +++ b/frontend/src/js/terminal.js @@ -393,6 +393,10 @@ class TerminalManager { label: 'Find…', action: () => this.app._openTerminalSearch(), }, + { + label: 'Send Snippet…', + action: () => this.app.snippets.open(), + }, { divider: true }, { label: 'Clear Terminal', diff --git a/frontend/wailsjs/go/api/Service.d.ts b/frontend/wailsjs/go/api/Service.d.ts index e5d120e..98970bd 100755 --- a/frontend/wailsjs/go/api/Service.d.ts +++ b/frontend/wailsjs/go/api/Service.d.ts @@ -17,6 +17,8 @@ export function ConnectSession(arg1:string):Promise; export function CreateProfile(arg1:models.Profile):Promise; +export function CreateSnippet(arg1:models.Snippet):Promise; + export function DeleteKnownHost(arg1:string):Promise; export function DeleteProfile(arg1:string):Promise; @@ -25,6 +27,8 @@ export function DeleteProfileSecrets(arg1:string):Promise; export function DeleteRecording(arg1:string):Promise; +export function DeleteSnippet(arg1:string):Promise; + export function DisconnectSession(arg1:string):Promise; export function DuplicateProfile(arg1:string):Promise; @@ -77,6 +81,8 @@ export function ListRecordings():Promise>>; export function ListSessions():Promise>; +export function ListSnippets():Promise>; + export function LockSecretStore():Promise; export function NewProfile(arg1:string):Promise; @@ -139,6 +145,8 @@ export function UnlockSecretStore(arg1:string):Promise; export function UpdateProfile(arg1:models.Profile):Promise; +export function UpdateSnippet(arg1:models.Snippet):Promise; + export function WriteSession(arg1:string,arg2:string):Promise; export function WriteSessionEscaped(arg1:string,arg2:string):Promise; diff --git a/frontend/wailsjs/go/api/Service.js b/frontend/wailsjs/go/api/Service.js index 765a3a3..2edc2e1 100755 --- a/frontend/wailsjs/go/api/Service.js +++ b/frontend/wailsjs/go/api/Service.js @@ -30,6 +30,10 @@ export function CreateProfile(arg1) { return window['go']['api']['Service']['CreateProfile'](arg1); } +export function CreateSnippet(arg1) { + return window['go']['api']['Service']['CreateSnippet'](arg1); +} + export function DeleteKnownHost(arg1) { return window['go']['api']['Service']['DeleteKnownHost'](arg1); } @@ -46,6 +50,10 @@ export function DeleteRecording(arg1) { return window['go']['api']['Service']['DeleteRecording'](arg1); } +export function DeleteSnippet(arg1) { + return window['go']['api']['Service']['DeleteSnippet'](arg1); +} + export function DisconnectSession(arg1) { return window['go']['api']['Service']['DisconnectSession'](arg1); } @@ -150,6 +158,10 @@ export function ListSessions() { return window['go']['api']['Service']['ListSessions'](); } +export function ListSnippets() { + return window['go']['api']['Service']['ListSnippets'](); +} + export function LockSecretStore() { return window['go']['api']['Service']['LockSecretStore'](); } @@ -274,6 +286,10 @@ export function UpdateProfile(arg1) { return window['go']['api']['Service']['UpdateProfile'](arg1); } +export function UpdateSnippet(arg1) { + return window['go']['api']['Service']['UpdateSnippet'](arg1); +} + export function WriteSession(arg1, arg2) { return window['go']['api']['Service']['WriteSession'](arg1, arg2); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 10937e7..ff16430 100755 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -499,6 +499,52 @@ export namespace models { return a; } } + export class Snippet { + id: string; + name: string; + command: string; + description: string; + folder: string; + tags: string[]; + // Go type: time + createdAt: any; + // Go type: time + updatedAt: any; + + static createFrom(source: any = {}) { + return new Snippet(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.id = source["id"]; + this.name = source["name"]; + this.command = source["command"]; + this.description = source["description"]; + this.folder = source["folder"]; + this.tags = source["tags"]; + this.createdAt = this.convertValues(source["createdAt"], null); + this.updatedAt = this.convertValues(source["updatedAt"], null); + } + + convertValues(a: any, classs: any, asMap: boolean = false): any { + if (!a) { + return a; + } + if (a.slice && a.map) { + return (a as any[]).map(elem => this.convertValues(elem, classs)); + } else if ("object" === typeof a) { + if (asMap) { + for (const key of Object.keys(a)) { + a[key] = new classs(a[key]); + } + return a; + } + return new classs(a); + } + return a; + } + } } diff --git a/internal/api/api.go b/internal/api/api.go index 2bdbf58..bec58e6 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -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 // --------------------------------------------------------------------------- diff --git a/internal/models/models.go b/internal/models/models.go index bb19b38..c1d2ff1 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -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"` +} diff --git a/internal/storage/migrations/003_snippets.sql b/internal/storage/migrations/003_snippets.sql new file mode 100644 index 0000000..9145df0 --- /dev/null +++ b/internal/storage/migrations/003_snippets.sql @@ -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')); diff --git a/internal/storage/store.go b/internal/storage/store.go index 02f7749..8d9dae6 100644 --- a/internal/storage/store.go +++ b/internal/storage/store.go @@ -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 +}