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:
@@ -212,6 +212,7 @@
|
|||||||
<script src="/src/js/recordings.js"></script>
|
<script src="/src/js/recordings.js"></script>
|
||||||
<script src="/src/js/sftp.js"></script>
|
<script src="/src/js/sftp.js"></script>
|
||||||
<script src="/src/js/splitpane.js"></script>
|
<script src="/src/js/splitpane.js"></script>
|
||||||
|
<script src="/src/js/snippets.js"></script>
|
||||||
<script src="/src/js/app.js"></script>
|
<script src="/src/js/app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
* Initialises the application shell, loads profiles, and sets up event listeners.
|
* 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 {
|
class App {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -22,6 +22,7 @@ class App {
|
|||||||
this.recordings = new RecordingManager(this);
|
this.recordings = new RecordingManager(this);
|
||||||
this.exportImport = new ExportImportUI(this);
|
this.exportImport = new ExportImportUI(this);
|
||||||
this.sftp = new SFTPBrowser(this);
|
this.sftp = new SFTPBrowser(this);
|
||||||
|
this.snippets = new SnippetLibrary(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -392,6 +393,13 @@ class App {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ctrl/Cmd+Shift+S — open snippet library
|
||||||
|
if (mod && e.shiftKey && e.key === 'S') {
|
||||||
|
e.preventDefault();
|
||||||
|
this.snippets.open();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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<Object>} */
|
||||||
|
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 = `
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3 class="modal-title">Command Library</h3>
|
||||||
|
<button class="btn btn-icon btn-xs modal-close">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body snippet-body" style="display:flex;gap:var(--space-3);height:400px;">
|
||||||
|
<div class="snippet-list-panel" style="flex:1;display:flex;flex-direction:column;gap:var(--space-2);overflow:hidden;">
|
||||||
|
<div style="display:flex;gap:var(--space-2);">
|
||||||
|
<input type="text" class="form-input snippet-search" placeholder="Search snippets…" style="flex:1;" />
|
||||||
|
<button class="btn btn-sm btn-primary snippet-add-btn">+ New</button>
|
||||||
|
</div>
|
||||||
|
<div class="snippet-list" style="flex:1;overflow-y:auto;"></div>
|
||||||
|
</div>
|
||||||
|
<div class="snippet-detail-panel" style="flex:1;display:flex;flex-direction:column;gap:var(--space-2);overflow:hidden;">
|
||||||
|
<div class="snippet-detail-empty" style="display:flex;align-items:center;justify-content:center;flex:1;color:var(--text-tertiary);font-size:var(--text-sm);">
|
||||||
|
Select a snippet or create a new one
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
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 = '<div style="color:var(--text-tertiary);font-size:var(--text-sm);padding:var(--space-3);text-align:center;">No snippets found</div>';
|
||||||
|
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 = `
|
||||||
|
<div style="font-weight:var(--weight-medium);font-size:var(--text-sm);color:var(--text-primary);">${escapeHtml(sn.name)}</div>
|
||||||
|
<div style="font-family:var(--font-mono);font-size:var(--text-xs);color:var(--text-secondary);margin-top:2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${escapeHtml(sn.command)}</div>
|
||||||
|
`;
|
||||||
|
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: `<strong style="font-size:var(--text-base);">${escapeHtml(snippet.name)}</strong>`,
|
||||||
|
});
|
||||||
|
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 = '<div class="snippet-detail-empty" style="display:flex;align-items:center;justify-content:center;flex:1;color:var(--text-tertiary);font-size:var(--text-sm);">Snippet deleted</div>';
|
||||||
|
} 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 = `
|
||||||
|
<div style="font-weight:var(--weight-medium);font-size:var(--text-sm);">${title}</div>
|
||||||
|
<div class="form-group" style="margin-bottom:var(--space-2);">
|
||||||
|
<label class="form-label">Name</label>
|
||||||
|
<input type="text" class="form-input snippet-ed-name" value="${isNew ? '' : escapeHtml(snippet.name)}" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="margin-bottom:var(--space-2);">
|
||||||
|
<label class="form-label">Description</label>
|
||||||
|
<input type="text" class="form-input snippet-ed-desc" value="${isNew ? '' : escapeHtml(snippet.description || '')}" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="flex:1;display:flex;flex-direction:column;">
|
||||||
|
<label class="form-label">Command</label>
|
||||||
|
<textarea class="form-input snippet-ed-cmd" style="flex:1;font-family:var(--font-mono);font-size:var(--text-sm);resize:none;">${isNew ? '' : escapeHtml(snippet.command)}</textarea>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="margin-bottom:var(--space-2);">
|
||||||
|
<label class="form-label">Folder</label>
|
||||||
|
<input type="text" class="form-input snippet-ed-folder" value="${isNew ? '' : escapeHtml(snippet.folder || '')}" placeholder="e.g. Networking, Debugging" />
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:var(--space-2);">
|
||||||
|
<button class="btn btn-sm btn-primary snippet-save-btn">Save</button>
|
||||||
|
<button class="btn btn-sm snippet-cancel-btn">Cancel</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
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 = '<div class="snippet-detail-empty" style="display:flex;align-items:center;justify-content:center;flex:1;color:var(--text-tertiary);font-size:var(--text-sm);">Select a snippet or create a new one</div>';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.NexusTerminal = window.NexusTerminal || {};
|
||||||
|
window.NexusTerminal.SnippetLibrary = SnippetLibrary;
|
||||||
|
|
||||||
|
})();
|
||||||
@@ -393,6 +393,10 @@ class TerminalManager {
|
|||||||
label: 'Find…',
|
label: 'Find…',
|
||||||
action: () => this.app._openTerminalSearch(),
|
action: () => this.app._openTerminalSearch(),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: 'Send Snippet…',
|
||||||
|
action: () => this.app.snippets.open(),
|
||||||
|
},
|
||||||
{ divider: true },
|
{ divider: true },
|
||||||
{
|
{
|
||||||
label: 'Clear Terminal',
|
label: 'Clear Terminal',
|
||||||
|
|||||||
Vendored
+8
@@ -17,6 +17,8 @@ export function ConnectSession(arg1:string):Promise<models.SessionInfo>;
|
|||||||
|
|
||||||
export function CreateProfile(arg1:models.Profile):Promise<models.Profile>;
|
export function CreateProfile(arg1:models.Profile):Promise<models.Profile>;
|
||||||
|
|
||||||
|
export function CreateSnippet(arg1:models.Snippet):Promise<models.Snippet>;
|
||||||
|
|
||||||
export function DeleteKnownHost(arg1:string):Promise<void>;
|
export function DeleteKnownHost(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function DeleteProfile(arg1:string):Promise<void>;
|
export function DeleteProfile(arg1:string):Promise<void>;
|
||||||
@@ -25,6 +27,8 @@ export function DeleteProfileSecrets(arg1:string):Promise<void>;
|
|||||||
|
|
||||||
export function DeleteRecording(arg1:string):Promise<void>;
|
export function DeleteRecording(arg1:string):Promise<void>;
|
||||||
|
|
||||||
|
export function DeleteSnippet(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function DisconnectSession(arg1:string):Promise<void>;
|
export function DisconnectSession(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function DuplicateProfile(arg1:string):Promise<models.Profile>;
|
export function DuplicateProfile(arg1:string):Promise<models.Profile>;
|
||||||
@@ -77,6 +81,8 @@ export function ListRecordings():Promise<Array<Record<string, any>>>;
|
|||||||
|
|
||||||
export function ListSessions():Promise<Array<models.SessionInfo>>;
|
export function ListSessions():Promise<Array<models.SessionInfo>>;
|
||||||
|
|
||||||
|
export function ListSnippets():Promise<Array<models.Snippet>>;
|
||||||
|
|
||||||
export function LockSecretStore():Promise<void>;
|
export function LockSecretStore():Promise<void>;
|
||||||
|
|
||||||
export function NewProfile(arg1:string):Promise<models.Profile>;
|
export function NewProfile(arg1:string):Promise<models.Profile>;
|
||||||
@@ -139,6 +145,8 @@ export function UnlockSecretStore(arg1:string):Promise<void>;
|
|||||||
|
|
||||||
export function UpdateProfile(arg1:models.Profile):Promise<models.Profile>;
|
export function UpdateProfile(arg1:models.Profile):Promise<models.Profile>;
|
||||||
|
|
||||||
|
export function UpdateSnippet(arg1:models.Snippet):Promise<models.Snippet>;
|
||||||
|
|
||||||
export function WriteSession(arg1:string,arg2:string):Promise<void>;
|
export function WriteSession(arg1:string,arg2:string):Promise<void>;
|
||||||
|
|
||||||
export function WriteSessionEscaped(arg1:string,arg2:string):Promise<void>;
|
export function WriteSessionEscaped(arg1:string,arg2:string):Promise<void>;
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ export function CreateProfile(arg1) {
|
|||||||
return window['go']['api']['Service']['CreateProfile'](arg1);
|
return window['go']['api']['Service']['CreateProfile'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function CreateSnippet(arg1) {
|
||||||
|
return window['go']['api']['Service']['CreateSnippet'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function DeleteKnownHost(arg1) {
|
export function DeleteKnownHost(arg1) {
|
||||||
return window['go']['api']['Service']['DeleteKnownHost'](arg1);
|
return window['go']['api']['Service']['DeleteKnownHost'](arg1);
|
||||||
}
|
}
|
||||||
@@ -46,6 +50,10 @@ export function DeleteRecording(arg1) {
|
|||||||
return window['go']['api']['Service']['DeleteRecording'](arg1);
|
return window['go']['api']['Service']['DeleteRecording'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function DeleteSnippet(arg1) {
|
||||||
|
return window['go']['api']['Service']['DeleteSnippet'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function DisconnectSession(arg1) {
|
export function DisconnectSession(arg1) {
|
||||||
return window['go']['api']['Service']['DisconnectSession'](arg1);
|
return window['go']['api']['Service']['DisconnectSession'](arg1);
|
||||||
}
|
}
|
||||||
@@ -150,6 +158,10 @@ export function ListSessions() {
|
|||||||
return window['go']['api']['Service']['ListSessions']();
|
return window['go']['api']['Service']['ListSessions']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function ListSnippets() {
|
||||||
|
return window['go']['api']['Service']['ListSnippets']();
|
||||||
|
}
|
||||||
|
|
||||||
export function LockSecretStore() {
|
export function LockSecretStore() {
|
||||||
return window['go']['api']['Service']['LockSecretStore']();
|
return window['go']['api']['Service']['LockSecretStore']();
|
||||||
}
|
}
|
||||||
@@ -274,6 +286,10 @@ export function UpdateProfile(arg1) {
|
|||||||
return window['go']['api']['Service']['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) {
|
export function WriteSession(arg1, arg2) {
|
||||||
return window['go']['api']['Service']['WriteSession'](arg1, arg2);
|
return window['go']['api']['Service']['WriteSession'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -499,6 +499,52 @@ export namespace models {
|
|||||||
return a;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -574,6 +574,54 @@ func (s *Service) ListAuditEntries(limit int) ([]models.AuditEntry, error) {
|
|||||||
return result, nil
|
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
|
// Utility
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -256,3 +256,15 @@ type AuditEntry struct {
|
|||||||
SessionID string `json:"sessionId,omitempty"`
|
SessionID string `json:"sessionId,omitempty"`
|
||||||
Details string `json:"details"`
|
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)
|
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