feat: move snippets, keybinding actions and macros out of core into plugins

This commit is contained in:
LukeGus committed 2026-09-29 11:39:14 -05:00
1 parent d9bba10612
commit e0c30a4e6f
262 files changed
+9091 -10744

No files matched your search

+33 -1
View File
@@ -195,5 +195,37 @@
"shareFolderHint": "Shares every snippet in this folder. Snippets added later are not shared automatically.",
"shareFolderSummary": "Shared {{count}} snippet(s) in this folder",
"grantedBy": "Granted by",
"expired": "Expired"
"expired": "Expired",
"variablesDialogTitle": "Run \"{{name}}\"",
"variablesDialogDescription": "Fill in the values this snippet needs before running it.",
"variablesPreviewLabel": "Preview",
"variablesConfirmButton": "Run",
"keybindings": {
"runSnippet": "Run existing snippet",
"snippetLabel": "Snippet",
"selectSnippet": "Select a snippet",
"snippetMissing": "snippet not found",
"snippetRequired": "Select a snippet"
},
"paletteHintRun": "Run in the active terminal tab",
"host": {
"startupTitle": "Startup Snippet",
"quickActionsTitle": "Quick Actions",
"quickActionsDescription": "Quick actions appear as buttons in the Host Metrics toolbar for one-click command execution.",
"noQuickActions": "No quick actions yet.",
"buttonLabel": "Button label",
"selectSnippet": "Select snippet...",
"addQuickAction": "Add Action",
"tabTitle": "Snippets",
"startupDescription": "Runs in the terminal each time it connects to this host. Variables like $HOST are filled in; a snippet that asks for inputs is skipped.",
"none": "None",
"removeQuickAction": "Remove quick action"
},
"quickActions": {
"running": "Executing {{name}}...",
"succeeded": "{{name}} completed successfully",
"failed": "{{name}} failed",
"error": "Failed to execute {{name}}"
},
"paletteHintPaste": "Paste in the active terminal tab"
}
@@ -0,0 +1,29 @@
{
"variablesDialogTitle": "Begin \"{{name}}\"",
"variablesDialogDescription": "Vul die waardes in wat hierdie brokkie benodig voordat dit uitgevoer word.",
"variablesPreviewLabel": "Voorskou",
"variablesConfirmButton": "Hardloop",
"keybindings": {
"runSnippet": "Voer bestaande brokkie uit",
"snippetLabel": "Brokkie",
"selectSnippet": "Kies 'n uittreksel",
"snippetMissing": "brokkie nie gevind nie",
"snippetRequired": "Kies 'n uittreksel"
},
"paletteHintRun": "Begin in die aktiewe terminaal-oortjie",
"host": {
"startupTitle": "Opstart-brokkie",
"quickActionsTitle": "Vinnige Aksies",
"quickActionsDescription": "Vinnige aksies verskyn as knoppies in die Host Metrics-nutsbalk vir die uitvoering van opdragte met een klik.",
"noQuickActions": "Geen vinnige aksies nog nie.",
"buttonLabel": "Knoppie-etiket",
"selectSnippet": "Kies uittreksel...",
"addQuickAction": "Voeg Aksie by"
},
"quickActions": {
"running": "Voer tans {{name}} uit ...",
"succeeded": "{{name}} suksesvol voltooi",
"failed": "{{name}} het misluk",
"error": "Kon nie {{name}} uitvoer nie"
}
}
@@ -0,0 +1,29 @@
{
"variablesDialogTitle": "تشغيل \"{{name}}\"",
"variablesDialogDescription": "أدخل القيم التي يحتاجها هذا المقتطف البرمجي قبل تشغيله.",
"variablesPreviewLabel": "معاينة",
"variablesConfirmButton": "يجري",
"keybindings": {
"runSnippet": "تشغيل المقتطف البرمجي الحالي",
"snippetLabel": "مقتطف",
"selectSnippet": "حدد جزءًا من التعليمات البرمجية",
"snippetMissing": "لم يتم العثور على المقتطف",
"snippetRequired": "حدد جزءًا من التعليمات البرمجية"
},
"paletteHintRun": "قم بتشغيله في علامة تبويب الطرفية النشطة",
"host": {
"startupTitle": "مقتطف بدء التشغيل",
"quickActionsTitle": "إجراءات سريعة",
"quickActionsDescription": "تظهر الإجراءات السريعة كأزرار في شريط أدوات مقاييس المضيف لتنفيذ الأوامر بنقرة واحدة.",
"noQuickActions": "لا توجد إجراءات سريعة بعد.",
"buttonLabel": "تسمية الزر",
"selectSnippet": "اختر مقتطفًا...",
"addQuickAction": "إضافة إجراء"
},
"quickActions": {
"running": "جارٍ تنفيذ {{name}}...",
"succeeded": "تم إكمال {{name}} بنجاح",
"failed": "فشل {{name}}",
"error": "فشل تنفيذ {{name}}"
}
}
@@ -0,0 +1,29 @@
{
"variablesDialogTitle": "Изпълни „{{name}}“",
"variablesDialogDescription": "Попълнете стойностите, необходими на този фрагмент, преди да го изпълните.",
"variablesPreviewLabel": "Преглед",
"variablesConfirmButton": "Бягане",
"keybindings": {
"runSnippet": "Изпълнете съществуващия фрагмент",
"snippetLabel": "Фрагмент",
"selectSnippet": "Изберете фрагмент",
"snippetMissing": "фрагмент не е намерен",
"snippetRequired": "Изберете фрагмент"
},
"paletteHintRun": "Изпълняване в активния раздел на терминала",
"host": {
"startupTitle": "Стартов фрагмент",
"quickActionsTitle": "Бързи действия",
"quickActionsDescription": "Бързите действия се показват като бутони в лентата с инструменти Host Metrics за изпълнение на команди с едно щракване.",
"noQuickActions": "Все още няма бързи действия.",
"buttonLabel": "Етикет на бутона",
"selectSnippet": "Изберете фрагмент...",
"addQuickAction": "Добавяне на действие"
},
"quickActions": {
"running": "Изпълнява се {{name}}...",
"succeeded": "{{name}} завършено успешно",
"failed": "{{name}} неуспешно",
"error": "Неуспешно изпълнение на {{name}}"
}
}
@@ -0,0 +1,29 @@
{
"variablesDialogTitle": "দৌড়াও \"{{name}}\"",
"variablesDialogDescription": "এই কোড স্নিপেটটি চালানোর আগে প্রয়োজনীয় মানগুলি পূরণ করুন।",
"variablesPreviewLabel": "প্রিভিউ",
"variablesConfirmButton": "দৌড়",
"keybindings": {
"runSnippet": "বিদ্যমান কোড চালান",
"snippetLabel": "স্নিপেট",
"selectSnippet": "একটি স্নিপেট নির্বাচন করুন",
"snippetMissing": "স্নিপেটটি খুঁজে পাওয়া যায়নি",
"snippetRequired": "একটি স্নিপেট নির্বাচন করুন"
},
"paletteHintRun": "সক্রিয় টার্মিনাল ট্যাবে চালান",
"host": {
"startupTitle": "স্টার্টআপ স্নিপেট",
"quickActionsTitle": "দ্রুত পদক্ষেপ",
"quickActionsDescription": "এক ক্লিকে কমান্ড কার্যকর করার জন্য কুইক অ্যাকশনগুলো হোস্ট মেট্রিক্স টুলবারে বাটন হিসেবে প্রদর্শিত হয়।",
"noQuickActions": "এখনো কোনো দ্রুত পদক্ষেপ নেওয়া হয়নি।",
"buttonLabel": "বোতাম লেবেল",
"selectSnippet": "স্নিপেট নির্বাচন করুন...",
"addQuickAction": "অ্যাকশন যোগ করুন"
},
"quickActions": {
"running": "{{name}} কার্যকর করা হচ্ছে ...",
"succeeded": "{{name}} সফলভাবে সম্পন্ন হয়েছে",
"failed": "{{name}} ব্যর্থ হয়েছে",
"error": "{{name}} কার্যকর করতে ব্যর্থ হয়েছে"
}
}
@@ -0,0 +1,29 @@
{
"variablesDialogTitle": "Executa \"{{name}}\"",
"variablesDialogDescription": "Empleneu els valors que necessita aquest fragment abans d'executar-lo.",
"variablesPreviewLabel": "Vista prèvia",
"variablesConfirmButton": "Corre",
"keybindings": {
"runSnippet": "Executa el fragment existent",
"snippetLabel": "Fragment",
"selectSnippet": "Selecciona un fragment",
"snippetMissing": "fragment no trobat",
"snippetRequired": "Selecciona un fragment"
},
"paletteHintRun": "Executar a la pestanya activa del terminal",
"host": {
"startupTitle": "Fragment d'inici",
"quickActionsTitle": "Accions ràpides",
"quickActionsDescription": "Les accions ràpides apareixen com a botons a la barra d'eines de mètriques de l'amfitrió per executar ordres amb un sol clic.",
"noQuickActions": "Encara no hi ha accions ràpides.",
"buttonLabel": "Etiqueta del botó",
"selectSnippet": "Selecciona un fragment...",
"addQuickAction": "Afegeix una acció"
},
"quickActions": {
"running": "Executant {{name}}...",
"succeeded": "{{name}} s'ha completat correctament",
"failed": "{{name}} ha fallat",
"error": "No s'ha pogut executar {{name}}"
}
}
@@ -0,0 +1,29 @@
{
"variablesDialogTitle": "Spustit „{{name}}“",
"variablesDialogDescription": "Před spuštěním úryvku kódu vyplňte jeho potřebné hodnoty.",
"variablesPreviewLabel": "Náhled",
"variablesConfirmButton": "Běh",
"keybindings": {
"runSnippet": "Spustit existující úryvek kódu",
"snippetLabel": "Úryvek",
"selectSnippet": "Vyberte úryvek",
"snippetMissing": "úryvek nenalezen",
"snippetRequired": "Vyberte úryvek"
},
"paletteHintRun": "Spustit v aktivní kartě terminálu",
"host": {
"startupTitle": "Úryvek pro spuštění",
"quickActionsTitle": "Rychlé akce",
"quickActionsDescription": "Rychlé akce se zobrazují jako tlačítka v panelu nástrojů Metriky hostitele pro spuštění příkazů jedním kliknutím.",
"noQuickActions": "Zatím žádné rychlé akce.",
"buttonLabel": "Štítek tlačítka",
"selectSnippet": "Vybrat úryvek...",
"addQuickAction": "Přidat akci"
},
"quickActions": {
"running": "Spouštění {{name}}...",
"succeeded": "{{name}} úspěšně dokončeno",
"failed": "{{name}} selhalo",
"error": "Nepodařilo se spustit {{name}}"
}
}
@@ -0,0 +1,29 @@
{
"variablesDialogTitle": "Kør \"{{name}}\"",
"variablesDialogDescription": "Udfyld de værdier, som dette kodestykke skal bruge, før det køres.",
"variablesPreviewLabel": "Forhåndsvisning",
"variablesConfirmButton": "Løbe",
"keybindings": {
"runSnippet": "Kør eksisterende kodestykke",
"snippetLabel": "Uddrag",
"selectSnippet": "Vælg et uddrag",
"snippetMissing": "uddrag ikke fundet",
"snippetRequired": "Vælg et uddrag"
},
"paletteHintRun": "Kør i den aktive terminalfane",
"host": {
"startupTitle": "Opstartskodestykke",
"quickActionsTitle": "Hurtige handlinger",
"quickActionsDescription": "Hurtige handlinger vises som knapper i værktøjslinjen Host Metrics til udførelse af kommandoer med et enkelt klik.",
"noQuickActions": "Ingen hurtige handlinger endnu.",
"buttonLabel": "Knapmærke",
"selectSnippet": "Vælg uddrag...",
"addQuickAction": "Tilføj handling"
},
"quickActions": {
"running": "Udfører {{name}}...",
"succeeded": "{{name}} gennemført",
"failed": "{{name}} mislykkedes",
"error": "Kunne ikke udføre {{name}}"
}
}
@@ -0,0 +1,29 @@
{
"variablesDialogTitle": "Run \"{{name}}\"",
"variablesDialogDescription": "Füllen Sie die für diesen Codeabschnitt erforderlichen Werte aus, bevor Sie ihn ausführen.",
"variablesPreviewLabel": "Vorschau",
"variablesConfirmButton": "Laufen",
"keybindings": {
"runSnippet": "Vorhandenen Codeausschnitt ausführen",
"snippetLabel": "Ausschnitt",
"selectSnippet": "Wählen Sie einen Ausschnitt aus",
"snippetMissing": "Ausschnitt nicht gefunden",
"snippetRequired": "Wählen Sie einen Ausschnitt aus"
},
"paletteHintRun": "Im aktiven Terminal-Tab ausführen",
"host": {
"startupTitle": "Startup-Snippet",
"quickActionsTitle": "Schnellaktionen",
"quickActionsDescription": "Schnellaktionen erscheinen als Schaltflächen in der Host-Metriken-Symbolleiste zur Ausführung von Befehlen mit einem Klick.",
"noQuickActions": "Noch keine Schnellaktionen.",
"buttonLabel": "Schaltflächenbeschriftung",
"selectSnippet": "Snippet auswählen...",
"addQuickAction": "Aktion hinzufügen"
},
"quickActions": {
"running": "{{name}} wird ausgeführt...",
"succeeded": "{{name}} erfolgreich abgeschlossen",
"failed": "{{name}} fehlgeschlagen",
"error": "Fehler beim Ausführen von {{name}}"
}
}
@@ -0,0 +1,29 @@
{
"variablesDialogTitle": "Εκτέλεση \"{{name}}\"",
"variablesDialogDescription": "Συμπληρώστε τις τιμές που χρειάζεται αυτό το τμήμα κώδικα πριν το εκτελέσετε.",
"variablesPreviewLabel": "Πρεμιέρα",
"variablesConfirmButton": "Τρέξιμο",
"keybindings": {
"runSnippet": "Εκτέλεση υπάρχοντος αποσπάσματος",
"snippetLabel": "Απόσπασμα",
"selectSnippet": "Επιλέξτε ένα απόσπασμα",
"snippetMissing": "το απόσπασμα δεν βρέθηκε",
"snippetRequired": "Επιλέξτε ένα απόσπασμα"
},
"paletteHintRun": "Εκτέλεση στην ενεργή καρτέλα τερματικού",
"host": {
"startupTitle": "Απόσπασμα εκκίνησης",
"quickActionsTitle": "Γρήγορες ενέργειες",
"quickActionsDescription": "Οι γρήγορες ενέργειες εμφανίζονται ως κουμπιά στη γραμμή εργαλείων Μετρήσεων κεντρικού υπολογιστή για εκτέλεση εντολών με ένα κλικ.",
"noQuickActions": "Δεν υπάρχουν ακόμη γρήγορες ενέργειες.",
"buttonLabel": "Ετικέτα κουμπιού",
"selectSnippet": "Επιλογή αποσπάσματος...",
"addQuickAction": "Προσθήκη ενέργειας"
},
"quickActions": {
"running": "Εκτέλεση {{name}}...",
"succeeded": "{{name}} ολοκληρώθηκε με επιτυχία",
"failed": "{{name}} απέτυχε",
"error": "Αποτυχία εκτέλεσης {{name}}"
}
}
@@ -0,0 +1,29 @@
{
"variablesDialogTitle": "Ejecutar \"{{name}}\"",
"variablesDialogDescription": "Rellena los valores que necesita este fragmento de código antes de ejecutarlo.",
"variablesPreviewLabel": "Avance",
"variablesConfirmButton": "Correr",
"keybindings": {
"runSnippet": "Ejecutar el fragmento existente",
"snippetLabel": "Retazo",
"selectSnippet": "Seleccione un fragmento",
"snippetMissing": "Fragmento no encontrado",
"snippetRequired": "Seleccione un fragmento"
},
"paletteHintRun": "Ejecutar en la pestaña de terminal activa",
"host": {
"startupTitle": "Fragmento de inicio",
"quickActionsTitle": "Acciones rápidas",
"quickActionsDescription": "Las acciones rápidas aparecen como botones en la barra de herramientas de Métricas del host para la ejecución de comandos con un solo clic.",
"noQuickActions": "Aún no hay acciones rápidas.",
"buttonLabel": "Etiqueta del botón",
"selectSnippet": "Seleccionar fragmento...",
"addQuickAction": "Añadir acción"
},
"quickActions": {
"running": "Ejecutando {{name}}...",
"succeeded": "{{name}} completado correctamente",
"failed": "{{name}} falló",
"error": "Error al ejecutar {{name}}"
}
}
@@ -0,0 +1,29 @@
{
"variablesDialogTitle": "Suorita \"{{name}}\"",
"variablesDialogDescription": "Täytä tämän koodinpätkän tarvitsemat arvot ennen sen suorittamista.",
"variablesPreviewLabel": "Esikatselu",
"variablesConfirmButton": "Juokse",
"keybindings": {
"runSnippet": "Suorita olemassa oleva koodinpätkä",
"snippetLabel": "Katkelma",
"selectSnippet": "Valitse katkelma",
"snippetMissing": "katkelmaa ei löytynyt",
"snippetRequired": "Valitse katkelma"
},
"paletteHintRun": "Suorita aktiivisessa päätevälilehdessä",
"host": {
"startupTitle": "Käynnistyskoodinpätkä",
"quickActionsTitle": "Pikatoiminnot",
"quickActionsDescription": "Pikatoiminnot näkyvät painikkeina isäntämetriikkatyökalurivillä komentojen suorittamista varten yhdellä napsautuksella.",
"noQuickActions": "Ei vielä nopeita toimia.",
"buttonLabel": "Painikkeen nimi",
"selectSnippet": "Valitse katkelma...",
"addQuickAction": "Lisää toiminto"
},
"quickActions": {
"running": "Suoritetaan {{name}}...",
"succeeded": "{{name}} suoritettu onnistuneesti",
"failed": "{{name}} epäonnistui",
"error": "Suoritus epäonnistui {{name}}"
}
}
@@ -0,0 +1,29 @@
{
"variablesDialogTitle": "Exécutez \"{{name}}\"",
"variablesDialogDescription": "Renseignez les valeurs nécessaires à cet extrait de code avant de l'exécuter.",
"variablesPreviewLabel": "Aperçu",
"variablesConfirmButton": "Courir",
"keybindings": {
"runSnippet": "Exécuter l'extrait de code existant",
"snippetLabel": "Fragment",
"selectSnippet": "Sélectionnez un extrait",
"snippetMissing": "extrait introuvable",
"snippetRequired": "Sélectionnez un extrait"
},
"paletteHintRun": "Exécuter dans l'onglet du terminal actif",
"host": {
"startupTitle": "Extrait de démarrage",
"quickActionsTitle": "Actions rapides",
"quickActionsDescription": "Les actions rapides apparaissent sous forme de boutons dans la barre d'outils Métriques de l'hôte pour une exécution en un clic.",
"noQuickActions": "Aucune action rapide pour le moment.",
"buttonLabel": "Étiquette du bouton",
"selectSnippet": "Sélectionner un snippet...",
"addQuickAction": "Ajouter une action"
},
"quickActions": {
"running": "Exécution de {{name}}...",
"succeeded": "{{name}} terminé avec succès",
"failed": "Échec de {{name}}",
"error": "Échec de l'exécution de {{name}}"
}
}
@@ -0,0 +1,29 @@
{
"variablesDialogTitle": "הפעל \"{{name}}\"",
"variablesDialogDescription": "מלא את הערכים הדרושים לקטע זה לפני הפעלתו.",
"variablesPreviewLabel": "תצוגה מקדימה",
"variablesConfirmButton": "לָרוּץ",
"keybindings": {
"runSnippet": "הפעלת קטע קוד קיים",
"snippetLabel": "קטע",
"selectSnippet": "בחר קטע",
"snippetMissing": "קטע לא נמצא",
"snippetRequired": "בחר קטע"
},
"paletteHintRun": "הפעל בלשונית הטרמינל הפעילה",
"host": {
"startupTitle": "קטע הפעלה",
"quickActionsTitle": "פעולות מהירות",
"quickActionsDescription": "פעולות מהירות מופיעות ככפתורים בסרגל הכלים של Host Metrics לביצוע פקודה בלחיצה אחת.",
"noQuickActions": "אין עדיין פעולות מהירות.",
"buttonLabel": "תווית כפתור",
"selectSnippet": "בחר קטע...",
"addQuickAction": "הוסף פעולה"
},
"quickActions": {
"running": "מבצע {{name}}...",
"succeeded": "{{name}} הושלם בהצלחה",
"failed": "{{name}} נכשל",
"error": "נכשל בביצוע {{name}}"
}
}
@@ -0,0 +1,29 @@
{
"variablesDialogTitle": "रन \"{{name}}\"",
"variablesDialogDescription": "इस कोड स्निपेट को चलाने से पहले इसमें आवश्यक मान भरें।",
"variablesPreviewLabel": "पूर्व दर्शन",
"variablesConfirmButton": "दौड़ना",
"keybindings": {
"runSnippet": "मौजूदा स्निपेट चलाएँ",
"snippetLabel": "टुकड़ा",
"selectSnippet": "एक अंश का चयन करें",
"snippetMissing": "अंश नहीं मिला",
"snippetRequired": "एक अंश का चयन करें"
},
"paletteHintRun": "सक्रिय टर्मिनल टैब में चलाएँ",
"host": {
"startupTitle": "स्टार्टअप स्निपेट",
"quickActionsTitle": "त्वरित कार्रवाइयाँ",
"quickActionsDescription": "त्वरित कार्रवाइयाँ एक-क्लिक कमांड निष्पादन के लिए होस्ट मेट्रिक्स टूलबार में बटन के रूप में दिखाई देती हैं।",
"noQuickActions": "अभी तक कोई त्वरित कार्रवाई नहीं।",
"buttonLabel": "बटन लेबल",
"selectSnippet": "स्निपेट चुनें...",
"addQuickAction": "कार्रवाई जोड़ें"
},
"quickActions": {
"running": "{{name}} निष्पादित हो रहा है...",
"succeeded": "{{name}} सफलतापूर्वक पूर्ण हुआ",
"failed": "{{name}} विफल रहा",
"error": "{{name}} निष्पादित करने में विफल"
}
}
@@ -0,0 +1,29 @@
{
"variablesDialogTitle": "Futtassa a \"{{name}}\"",
"variablesDialogDescription": "Töltse ki a kódrészlet futtatása előtt szükséges értékeket.",
"variablesPreviewLabel": "Előnézet",
"variablesConfirmButton": "Fut",
"keybindings": {
"runSnippet": "Meglévő kódrészlet futtatása",
"snippetLabel": "Töredék",
"selectSnippet": "Válasszon ki egy részletet",
"snippetMissing": "részlet nem található",
"snippetRequired": "Válasszon ki egy részletet"
},
"paletteHintRun": "Futtatás az aktív terminálfülön",
"host": {
"startupTitle": "Indítási kódrészlet",
"quickActionsTitle": "Gyors műveletek",
"quickActionsDescription": "A gyors műveletek gombokként jelennek meg a Host Metrics eszköztáron, így egyetlen kattintással végrehajthatók a parancsok.",
"noQuickActions": "Még nincsenek gyors intézkedések.",
"buttonLabel": "Gomb felirata",
"selectSnippet": "Válasszon részletet...",
"addQuickAction": "Művelet hozzáadása"
},
"quickActions": {
"running": "{{name}} végrehajtása ...",
"succeeded": "{{name}} sikeresen befejeződött",
"failed": "{{name}} sikertelen",
"error": "Nem sikerült végrehajtani a {{name}} parancsot."
}
}
@@ -0,0 +1,29 @@
{
"variablesDialogTitle": "Jalankan \"{{name}}\"",
"variablesDialogDescription": "Isi nilai-nilai yang dibutuhkan cuplikan kode ini sebelum menjalankannya.",
"variablesPreviewLabel": "Pratinjau",
"variablesConfirmButton": "Berlari",
"keybindings": {
"runSnippet": "Jalankan cuplikan kode yang sudah ada.",
"snippetLabel": "Potongan",
"selectSnippet": "Pilih cuplikan",
"snippetMissing": "cuplikan tidak ditemukan",
"snippetRequired": "Pilih cuplikan"
},
"paletteHintRun": "Jalankan di tab terminal aktif",
"host": {
"startupTitle": "Cuplikan Startup",
"quickActionsTitle": "Tindakan Cepat",
"quickActionsDescription": "Tindakan cepat muncul sebagai tombol di bilah alat Metrik Host untuk eksekusi perintah sekali klik.",
"noQuickActions": "Belum ada tindakan cepat.",
"buttonLabel": "Label tombol",
"selectSnippet": "Pilih cuplikan...",
"addQuickAction": "Tambahkan Tindakan"
},
"quickActions": {
"running": "Menjalankan {{name}}...",
"succeeded": "{{name}} berhasil diselesaikan",
"failed": "{{name}} gagal",
"error": "Gagal mengeksekusi {{name}}"
}
}
@@ -0,0 +1,29 @@
{
"variablesDialogTitle": "Esegui \"{{name}}\"",
"variablesDialogDescription": "Inserisci i valori richiesti da questo frammento di codice prima di eseguirlo.",
"variablesPreviewLabel": "Anteprima",
"variablesConfirmButton": "Correre",
"keybindings": {
"runSnippet": "Esegui il frammento di codice esistente",
"snippetLabel": "Frammento",
"selectSnippet": "Seleziona un frammento",
"snippetMissing": "frammento non trovato",
"snippetRequired": "Seleziona un frammento"
},
"paletteHintRun": "Esegui nella scheda del terminale attiva",
"host": {
"startupTitle": "Frammento di avvio",
"quickActionsTitle": "Azioni Rapide",
"quickActionsDescription": "Le azioni rapide appaiono come pulsanti nella barra delle metriche host per l'esecuzione di comandi con un clic.",
"noQuickActions": "Nessuna azione rapida ancora.",
"buttonLabel": "Etichetta pulsante",
"selectSnippet": "Seleziona snippet...",
"addQuickAction": "Aggiungi Azione"
},
"quickActions": {
"running": "Esecuzione di {{name}}...",
"succeeded": "{{name}} completato con successo",
"failed": "{{name}} non riuscito",
"error": "Impossibile eseguire {{name}}"
}
}
@@ -0,0 +1,29 @@
{
"variablesDialogTitle": "「{{name}} を実行」",
"variablesDialogDescription": "このコードスニペットを実行する前に、必要な値を入力してください。",
"variablesPreviewLabel": "プレビュー",
"variablesConfirmButton": "走る",
"keybindings": {
"runSnippet": "既存のスニペットを実行する",
"snippetLabel": "抜粋",
"selectSnippet": "抜粋を選択してください",
"snippetMissing": "スニペットが見つかりません",
"snippetRequired": "抜粋を選択してください"
},
"paletteHintRun": "アクティブなターミナルタブで実行します",
"host": {
"startupTitle": "起動時スニペット",
"quickActionsTitle": "クイックアクション",
"quickActionsDescription": "クイックアクションはホストメトリクスツールバーにボタンとして表示され、ワンクリックでコマンドを実行できます。",
"noQuickActions": "クイックアクションはまだありません",
"buttonLabel": "ボタンラベル",
"selectSnippet": "スニペットを選択...",
"addQuickAction": "アクションを追加"
},
"quickActions": {
"running": "{{name}} を実行中...",
"succeeded": "{{name}} が正常に完了しました",
"failed": "{{name}} が失敗しました",
"error": "{{name}} の実行に失敗しました"
}
}
@@ -0,0 +1,29 @@
{
"variablesDialogTitle": "\"{{name}} \" 실행",
"variablesDialogDescription": "이 코드 조각을 실행하기 전에 필요한 값을 입력하세요.",
"variablesPreviewLabel": "시사",
"variablesConfirmButton": "달리다",
"keybindings": {
"runSnippet": "기존 코드 조각을 실행하세요",
"snippetLabel": "단편",
"selectSnippet": "코드 조각을 선택하세요",
"snippetMissing": "스니펫을 찾을 수 없습니다.",
"snippetRequired": "코드 조각을 선택하세요"
},
"paletteHintRun": "현재 활성화된 터미널 탭에서 실행하세요",
"host": {
"startupTitle": "시작 스니펫",
"quickActionsTitle": "빠른 작업",
"quickActionsDescription": "빠른 작업은 한 번의 클릭으로 명령을 실행할 수 있도록 호스트 메트릭 도구 모음에 버튼으로 표시됩니다.",
"noQuickActions": "아직 빠른 작업이 없습니다.",
"buttonLabel": "버튼 레이블",
"selectSnippet": "스니펫 선택...",
"addQuickAction": "작업 추가"
},
"quickActions": {
"running": "{{name}} 실행 중...",
"succeeded": "{{name}}이(가) 성공적으로 완료되었습니다.",
"failed": "{{name}} 실패",
"error": "{{name}} 실행에 실패했습니다."
}
}
@@ -0,0 +1,29 @@
{
"variablesDialogTitle": "Uitvoeren \"{{name}}\"",
"variablesDialogDescription": "Vul de waarden in die dit codefragment nodig heeft voordat je het uitvoert.",
"variablesPreviewLabel": "Voorbeeld",
"variablesConfirmButton": "Loop",
"keybindings": {
"runSnippet": "Voer het bestaande codefragment uit.",
"snippetLabel": "Fragment",
"selectSnippet": "Selecteer een fragment",
"snippetMissing": "fragment niet gevonden",
"snippetRequired": "Selecteer een fragment"
},
"paletteHintRun": "Voer dit uit in het actieve terminaltabblad.",
"host": {
"startupTitle": "Opstartfragment",
"quickActionsTitle": "Snelle acties",
"quickActionsDescription": "Snelle acties verschijnen als knoppen in de Host Metrics-werkbalk, zodat u opdrachten met één klik kunt uitvoeren.",
"noQuickActions": "Nog geen snelle acties ondernomen.",
"buttonLabel": "Knoplabel",
"selectSnippet": "Selecteer fragment...",
"addQuickAction": "Actie toevoegen"
},
"quickActions": {
"running": "Het uitvoeren van {{name}}...",
"succeeded": "{{name}} succesvol voltooid",
"failed": "{{name}} mislukt",
"error": "Het uitvoeren van {{name}} is mislukt."
}
}
@@ -0,0 +1,29 @@
{
"variablesDialogTitle": "Kjør «{{name}}»",
"variablesDialogDescription": "Fyll inn verdiene dette kodestykket trenger før du kjører det.",
"variablesPreviewLabel": "Forhåndsvisning",
"variablesConfirmButton": "Løp",
"keybindings": {
"runSnippet": "Kjør eksisterende kodebit",
"snippetLabel": "Utdrag",
"selectSnippet": "Velg et utdrag",
"snippetMissing": "utdraget ikke funnet",
"snippetRequired": "Velg et utdrag"
},
"paletteHintRun": "Kjør i den aktive terminalfanen",
"host": {
"startupTitle": "Oppstartsutdrag",
"quickActionsTitle": "Hurtighandlinger",
"quickActionsDescription": "Hurtighandlinger vises som knapper i verktøylinjen for vertsmålinger for utførelse av kommandoer med ett klikk.",
"noQuickActions": "Ingen hurtighandlinger ennå.",
"buttonLabel": "Knappetikett",
"selectSnippet": "Velg utdrag...",
"addQuickAction": "Legg til handling"
},
"quickActions": {
"running": "Utfører {{name}}...",
"succeeded": "{{name}} fullført",
"failed": "{{name}} mislyktes",
"error": "Kunne ikke utføre {{name}}"
}
}
@@ -0,0 +1,29 @@
{
"variablesDialogTitle": "Uruchom „{{name}}”",
"variablesDialogDescription": "Przed uruchomieniem snippetu wprowadź wymagane wartości.",
"variablesPreviewLabel": "Podgląd",
"variablesConfirmButton": "Uruchom",
"keybindings": {
"runSnippet": "Uruchom istniejący snippet",
"snippetLabel": "Snippet",
"selectSnippet": "Wybierz snippet",
"snippetMissing": "nie znaleziono snippetu",
"snippetRequired": "Wybierz snippet"
},
"paletteHintRun": "Uruchom w aktywnej karcie terminala",
"host": {
"startupTitle": "Snippet startowy",
"quickActionsTitle": "Szybkie akcje",
"quickActionsDescription": "Szybkie akcje pojawiają się jako przyciski na pasku narzędzi Metryki hosta, umożliwiając wykonanie polecenia jednym kliknięciem.",
"noQuickActions": "Brak szybkich akcji.",
"buttonLabel": "Etykieta przycisku",
"selectSnippet": "Wybierz snippet...",
"addQuickAction": "Dodaj akcję"
},
"quickActions": {
"running": "Wykonywanie {{name}}...",
"succeeded": "{{name}} ukończono pomyślnie",
"failed": "{{name}} nie powiodło się",
"error": "Nie udało się wykonać {{name}}"
}
}
@@ -0,0 +1,29 @@
{
"variablesDialogTitle": "Executar \"{{name}}\"",
"variablesDialogDescription": "Preencha os valores necessários para este trecho de código antes de executá-lo.",
"variablesPreviewLabel": "Pré-visualização",
"variablesConfirmButton": "Correr",
"keybindings": {
"runSnippet": "Executar trecho de código existente",
"snippetLabel": "Trecho",
"selectSnippet": "Selecione um trecho",
"snippetMissing": "trecho não encontrado",
"snippetRequired": "Selecione um trecho"
},
"paletteHintRun": "Execute na aba do terminal ativo",
"host": {
"startupTitle": "Trecho de startup",
"quickActionsTitle": "Ações rápidas",
"quickActionsDescription": "As ações rápidas aparecem como botões na barra de ferramentas do Host Metrics para execução de comandos com um único clique.",
"noQuickActions": "Nenhuma ação imediata ainda.",
"buttonLabel": "Rótulo do botão",
"selectSnippet": "Selecione o trecho...",
"addQuickAction": "Adicionar ação"
},
"quickActions": {
"running": "Executando {{name}}...",
"succeeded": "{{name}} concluído com sucesso",
"failed": "{{name}} falhou",
"error": "Falha ao executar {{name}}"
}
}
@@ -0,0 +1,29 @@
{
"variablesDialogTitle": "Executar \"{{name}}\"",
"variablesDialogDescription": "Preencha os valores necessários para este excerto de código antes de o executar.",
"variablesPreviewLabel": "Pré-visualização",
"variablesConfirmButton": "Correr",
"keybindings": {
"runSnippet": "Executar excerto de código existente",
"snippetLabel": "Trecho",
"selectSnippet": "Selecione um excerto",
"snippetMissing": "trecho não encontrado",
"snippetRequired": "Selecione um excerto"
},
"paletteHintRun": "Execute no separador do terminal ativo",
"host": {
"startupTitle": "Fragmento de inicialização",
"quickActionsTitle": "Ações Rápidas",
"quickActionsDescription": "As ações rápidas aparecem como botões na barra de ferramentas de Métricas do Host para execução de comandos com um clique.",
"noQuickActions": "Ainda não existem ações rápidas.",
"buttonLabel": "Texto do botão",
"selectSnippet": "Selecionar snippet...",
"addQuickAction": "Adicionar Ação"
},
"quickActions": {
"running": "A executar {{name}}...",
"succeeded": "{{name}} concluído com sucesso",
"failed": "{{name}} falhou",
"error": "Falha ao executar {{name}}"
}
}
@@ -0,0 +1,29 @@
{
"variablesDialogTitle": "Executați „{{name}}”",
"variablesDialogDescription": "Completați valorile necesare acestui fragment de cod înainte de a-l rula.",
"variablesPreviewLabel": "Previzualizare",
"variablesConfirmButton": "Aleargă",
"keybindings": {
"runSnippet": "Execută fragmentul existent",
"snippetLabel": "Fragment",
"selectSnippet": "Selectați un fragment",
"snippetMissing": "fragmentul nu a fost găsit",
"snippetRequired": "Selectați un fragment"
},
"paletteHintRun": "Execută în fila activă a terminalului",
"host": {
"startupTitle": "Fragment de pornire",
"quickActionsTitle": "Acțiuni rapide",
"quickActionsDescription": "Acțiunile rapide apar sub formă de butoane în bara de instrumente Host Metrics pentru executarea comenzilor cu un singur clic.",
"noQuickActions": "Nicio acțiune rapidă încă.",
"buttonLabel": "Etichetă buton",
"selectSnippet": "Selectați fragmentul...",
"addQuickAction": "Adăugați o acțiune"
},
"quickActions": {
"running": "Se execută {{name}}...",
"succeeded": "{{name}} finalizat cu succes",
"failed": "{{name}} a eșuat",
"error": "Executarea {{name}} a eșuat"
}
}
@@ -0,0 +1,29 @@
{
"variablesDialogTitle": "Запуск \"{{name}}\"",
"variablesDialogDescription": "Перед запуском заполните необходимые для этого фрагмента значения.",
"variablesPreviewLabel": "Предварительный просмотр",
"variablesConfirmButton": "Бегать",
"keybindings": {
"runSnippet": "Запустить существующий фрагмент",
"snippetLabel": "Фрагмент",
"selectSnippet": "Выберите фрагмент",
"snippetMissing": "фрагмент не найден",
"snippetRequired": "Выберите фрагмент"
},
"paletteHintRun": "Запустите в активной вкладке терминала.",
"host": {
"startupTitle": "Фрагмент запуска",
"quickActionsTitle": "Быстрые действия",
"quickActionsDescription": "Быстрые действия отображаются в виде кнопок на панели инструментов метрик хоста для выполнения команд в один клик.",
"noQuickActions": "Пока нет быстрых действий.",
"buttonLabel": "Надпись кнопки",
"selectSnippet": "Выбрать сниппет...",
"addQuickAction": "Добавить действие"
},
"quickActions": {
"running": "Выполняется {{name}}...",
"succeeded": "{{name}} успешно завершено",
"failed": "{{name}} не удалось",
"error": "Не удалось выполнить {{name}}"
}
}
@@ -0,0 +1,29 @@
{
"variablesDialogTitle": "Покрени „{{name}}“",
"variablesDialogDescription": "Унесите вредности које су потребне овом фрагменту пре него што га покренете.",
"variablesPreviewLabel": "Преглед",
"variablesConfirmButton": "Трчи",
"keybindings": {
"runSnippet": "Покрени постојећи исечак кода",
"snippetLabel": "Исечак",
"selectSnippet": "Изаберите исечак",
"snippetMissing": "фрагмент није пронађен",
"snippetRequired": "Изаберите исечак"
},
"paletteHintRun": "Покрени у активној картици терминала",
"host": {
"startupTitle": "Исечак за покретање",
"quickActionsTitle": "Брзе акције",
"quickActionsDescription": "Брзе акције се појављују као дугмад у траци са алаткама Host Metrics за извршавање команди једним кликом.",
"noQuickActions": "Још нема брзих акција.",
"buttonLabel": "Ознака дугмета",
"selectSnippet": "Изаберите исечак...",
"addQuickAction": "Додај акцију"
},
"quickActions": {
"running": "Извршава се {{name}}...",
"succeeded": "{{name}} успешно завршено",
"failed": "{{name}} није успело",
"error": "Није успело извршавање {{name}}"
}
}
@@ -0,0 +1,29 @@
{
"variablesDialogTitle": "Kör \"{{name}}\"",
"variablesDialogDescription": "Fyll i de värden som detta kodavsnitt behöver innan du kör det.",
"variablesPreviewLabel": "Förhandsvisning",
"variablesConfirmButton": "Sikt",
"keybindings": {
"runSnippet": "Kör befintligt kodavsnitt",
"snippetLabel": "Utdrag",
"selectSnippet": "Välj ett utdrag",
"snippetMissing": "utdraget hittades inte",
"snippetRequired": "Välj ett utdrag"
},
"paletteHintRun": "Kör i den aktiva terminalfliken",
"host": {
"startupTitle": "Startkodavsnitt",
"quickActionsTitle": "Snabbåtgärder",
"quickActionsDescription": "Snabbåtgärder visas som knappar i verktygsfältet Host Metrics för att köra kommandon med ett klick.",
"noQuickActions": "Inga snabba åtgärder ännu.",
"buttonLabel": "Knappetikett",
"selectSnippet": "Välj utdrag...",
"addQuickAction": "Lägg till åtgärd"
},
"quickActions": {
"running": "Kör {{name}}...",
"succeeded": "{{name}} slutfört",
"failed": "{{name}} misslyckades",
"error": "Misslyckades med att köra {{name}}"
}
}
@@ -0,0 +1,29 @@
{
"variablesDialogTitle": "วิ่ง \"{{name}}\"",
"variablesDialogDescription": "กรอกค่าที่จำเป็นสำหรับโค้ดส่วนนี้ก่อนที่จะเรียกใช้งาน",
"variablesPreviewLabel": "ตัวอย่าง",
"variablesConfirmButton": "วิ่ง",
"keybindings": {
"runSnippet": "เรียกใช้โค้ดตัวอย่างที่มีอยู่",
"snippetLabel": "ส่วนหนึ่ง",
"selectSnippet": "เลือกส่วนย่อย",
"snippetMissing": "ไม่พบส่วนย่อย",
"snippetRequired": "เลือกส่วนย่อย"
},
"paletteHintRun": "เรียกใช้ในแท็บเทอร์มินัลที่ใช้งานอยู่",
"host": {
"startupTitle": "ตัวอย่างโค้ดสำหรับเริ่มต้น",
"quickActionsTitle": "การดำเนินการด่วน",
"quickActionsDescription": "การดำเนินการด่วนจะปรากฏเป็นปุ่มในแถบเครื่องมือ Host Metrics เพื่อให้สามารถเรียกใช้คำสั่งได้ด้วยการคลิกเพียงครั้งเดียว",
"noQuickActions": "ยังไม่มีการดำเนินการใดๆ อย่างเร่งด่วนในขณะนี้",
"buttonLabel": "ป้ายกำกับปุ่ม",
"selectSnippet": "เลือกส่วนย่อย...",
"addQuickAction": "เพิ่มการดำเนินการ"
},
"quickActions": {
"running": "กำลังดำเนินการ {{name}}...",
"succeeded": "{{name}} เสร็จสมบูรณ์เรียบร้อยแล้ว",
"failed": "{{name}} ล้มเหลว",
"error": "ไม่สามารถดำเนินการ {{name}} ได้"
}
}
@@ -0,0 +1,29 @@
{
"variablesDialogTitle": "Çalıştır \"{{name}}\"",
"variablesDialogDescription": "Bu kod parçasını çalıştırmadan önce gerekli değerleri girin.",
"variablesPreviewLabel": "Önizleme",
"variablesConfirmButton": "Koşmak",
"keybindings": {
"runSnippet": "Mevcut kod parçasını çalıştır",
"snippetLabel": "Alıntı",
"selectSnippet": "Bir parça seçin",
"snippetMissing": "Kod parçası bulunamadı",
"snippetRequired": "Bir parça seçin"
},
"paletteHintRun": "Etkin terminal sekmesinde çalıştırın.",
"host": {
"startupTitle": "Başlangıç Betiği",
"quickActionsTitle": "Hızlı Eylemler",
"quickActionsDescription": "Hızlı eylemler, tek tıklamayla komut çalıştırmak için Ana Bilgisayar Metrikleri araç çubuğunda düğme olarak görünür.",
"noQuickActions": "Henüz hızlı eylem yok.",
"buttonLabel": "Düğme etiketi",
"selectSnippet": "Parçacık seç...",
"addQuickAction": "Eylem Ekle"
},
"quickActions": {
"running": "{{name}} çalıştırılıyor...",
"succeeded": "{{name}} başarıyla tamamlandı",
"failed": "{{name}} başarısız oldu",
"error": "{{name}} çalıştırılamadı"
}
}
@@ -0,0 +1,29 @@
{
"variablesDialogTitle": "Виконати \"{{name}}\"",
"variablesDialogDescription": "Заповніть значення, необхідні цьому фрагменту, перш ніж запускати його.",
"variablesPreviewLabel": "Попередній перегляд",
"variablesConfirmButton": "Бігти",
"keybindings": {
"runSnippet": "Запустити існуючий фрагмент коду",
"snippetLabel": "Фрагмент",
"selectSnippet": "Виберіть фрагмент",
"snippetMissing": "фрагмент не знайдено",
"snippetRequired": "Виберіть фрагмент"
},
"paletteHintRun": "Запустити в активній вкладці терміналу",
"host": {
"startupTitle": "Фрагмент запуску",
"quickActionsTitle": "Швидкі дії",
"quickActionsDescription": "Швидкі дії відображаються як кнопки на панелі інструментів «Метрики хоста» для виконання команд одним клацанням миші.",
"noQuickActions": "Поки що жодних швидких дій.",
"buttonLabel": "Мітка кнопки",
"selectSnippet": "Вибрати фрагмент...",
"addQuickAction": "Додати дію"
},
"quickActions": {
"running": "Виконання {{name}}...",
"succeeded": "{{name}} успішно завершено",
"failed": "{{name}} не вдалося",
"error": "Не вдалося виконати {{name}}"
}
}
@@ -0,0 +1,29 @@
{
"variablesDialogTitle": "Chạy \"{{name}}\"",
"variablesDialogDescription": "Hãy điền đầy đủ các giá trị cần thiết cho đoạn mã này trước khi chạy.",
"variablesPreviewLabel": "Xem trước",
"variablesConfirmButton": "Chạy",
"keybindings": {
"runSnippet": "Chạy đoạn mã hiện có",
"snippetLabel": "Đoạn trích",
"selectSnippet": "Chọn một đoạn trích",
"snippetMissing": "Không tìm thấy đoạn mã.",
"snippetRequired": "Chọn một đoạn trích"
},
"paletteHintRun": "Chạy trong tab cửa sổ terminal đang hoạt động",
"host": {
"startupTitle": "Đoạn mã khởi động",
"quickActionsTitle": "Hành động nhanh",
"quickActionsDescription": "Hành động nhanh xuất hiện dưới dạng nút trên thanh công cụ Chỉ số máy để thực thi lệnh một lần nhấp.",
"noQuickActions": "Chưa có hành động nhanh nào.",
"buttonLabel": "Nhãn nút",
"selectSnippet": "Chọn đoạn mã...",
"addQuickAction": "Thêm hành động"
},
"quickActions": {
"running": "Đang thực thi {{name}}...",
"succeeded": "{{name}} đã hoàn tất thành công",
"failed": "{{name}} thất bại",
"error": "Thực thi {{name}} thất bại"
}
}
@@ -0,0 +1,29 @@
{
"variablesDialogTitle": "运行“{{name}}”",
"variablesDialogDescription": "运行此代码片段之前,请填写所需的值。",
"variablesPreviewLabel": "预览",
"variablesConfirmButton": "运行",
"keybindings": {
"runSnippet": "运行现有代码片段",
"snippetLabel": "片段",
"selectSnippet": "选择代码片段",
"snippetMissing": "未找到代码片段",
"snippetRequired": "选择代码片段"
},
"paletteHintRun": "在活动终端标签页中运行",
"host": {
"startupTitle": "启动代码片段",
"quickActionsTitle": "快速操作",
"quickActionsDescription": "快速操作以按钮形式显示在主机指标工具栏中,以便一键执行命令。",
"noQuickActions": "尚无快速操作。",
"buttonLabel": "按钮标签",
"selectSnippet": "选择代码片段...",
"addQuickAction": "添加操作"
},
"quickActions": {
"running": "正在执行 {{name}}...",
"succeeded": "{{name}} 执行成功",
"failed": "{{name}} 执行失败",
"error": "执行 {{name}} 失败"
}
}
@@ -0,0 +1,29 @@
{
"variablesDialogTitle": "運行“{{name}}",
"variablesDialogDescription": "在運行此程式碼片段之前,請填寫所需的值。",
"variablesPreviewLabel": "預覽",
"variablesConfirmButton": "跑步",
"keybindings": {
"runSnippet": "運行現有程式碼片段",
"snippetLabel": "片段",
"selectSnippet": "選擇一段文字",
"snippetMissing": "未找到程式碼片段",
"snippetRequired": "選擇一段文字"
},
"paletteHintRun": "在活動終端選項卡中運行",
"host": {
"startupTitle": "啟動程式碼片段",
"quickActionsTitle": "快速動作",
"quickActionsDescription": "快速動作會以按鈕形式顯示在主機計量工具列上,可一鍵執行指令。",
"noQuickActions": "尚無快速動作。",
"buttonLabel": "按鈕標籤",
"selectSnippet": "選取程式碼片段...",
"addQuickAction": "新增動作"
},
"quickActions": {
"running": "正在執行 {{name}}...",
"succeeded": "{{name}} 已成功完成",
"failed": "{{name}} 失敗",
"error": "無法執行 {{name}}"
}
}
+45 -2
View File
@@ -95,8 +95,51 @@
"descriptionKey": "settings.confirmExecution.description",
"default": false
}
]
],
"host": {
"editorGroup": "ssh",
"editorOrder": 15,
"fields": [
{
"key": "startupSnippetId",
"type": "number",
"labelKey": "host.startupTitle",
"descriptionKey": "host.startupDescription",
"hidden": true,
"min": 1,
"shareRead": "view"
},
{
"key": "quickActions",
"type": "json",
"labelKey": "host.quickActionsTitle",
"descriptionKey": "host.quickActionsDescription",
"hidden": true,
"default": [],
"shareRead": "view"
}
]
}
},
"syncEntities": ["snippetFolders", "snippets"]
"syncEntities": ["snippetFolders", "snippets"],
"keybindingActions": [
{
"id": "runSnippet",
"params": {
"snippetId": {
"type": "string",
"pattern": "^[0-9]+$",
"maxLength": 20,
"syncEntity": "snippets"
},
"appendEnter": {
"type": "boolean"
}
}
}
],
"http": {
"adminImpersonation": true
}
}
}
+2 -52
View File
@@ -4,58 +4,8 @@ export interface SnippetExecutionResult {
error?: string;
}
export interface SnippetHostVars {
ip?: string;
username?: string;
port?: number | string;
name?: string;
}
const INPUT_PATTERN =
/\$\{INPUT_(\d+)(?::([^}$]+))?\}|\$INPUT_(\d+)(?![a-zA-Z0-9_])/g;
function replaceVar(content: string, name: string, value?: string): string {
if (value === undefined) return content;
const pattern = new RegExp(`\\$\\{?${name}\\}?`, "g");
return content.replace(pattern, value);
}
/**
* Mirrors src/ui/lib/snippet-variables.ts resolveSnippetContent. Frontend and
* backend are separate builds, so this is kept as a small standalone copy
* rather than a shared package for one pure function.
*/
export function resolveSnippetCommand(
content: string,
host: SnippetHostVars | null,
inputValues: Record<string, string> = {},
): string {
let resolved = content;
resolved = replaceVar(resolved, "HOST", host?.ip);
resolved = replaceVar(resolved, "USER", host?.username);
resolved = replaceVar(
resolved,
"PORT",
host?.port !== undefined ? String(host.port) : undefined,
);
resolved = replaceVar(resolved, "NAME", host?.name);
resolved = resolved.replace(
INPUT_PATTERN,
(
fullMatch,
braceDigits: string | undefined,
_label,
plainDigits: string | undefined,
) => {
const key = `INPUT_${braceDigits ?? plainDigits}`;
return key in inputValues ? inputValues[key] : fullMatch;
},
);
return resolved;
}
export { resolveSnippetContent as resolveSnippetCommand } from "../shared/variables.js";
export type { SnippetHostContext as SnippetHostVars } from "../shared/variables.js";
export function getSnippetExecutionTimeoutMs(
value = process.env.SNIPPET_EXECUTION_TIMEOUT_SECONDS,
+117
View File
@@ -0,0 +1,117 @@
import {
PLUGIN_ID,
readQuickActions,
readStartupSnippetId,
} from "../shared/host-settings.js";
/**
* Registered as "snippets.hostImportNormalizer": a 2.8 or early 2.9 export
* carried quick actions as a host field and the startup snippet inside
* terminalConfig. An export that already carries this plugin's
* pluginSettings has had them written by core.
*/
export function hostImportNormalizer(
raw: Record<string, unknown>,
): Record<string, unknown> | null {
const carried = (raw.pluginSettings as Record<string, unknown> | undefined)?.[
PLUGIN_ID
];
if (
carried &&
typeof carried === "object" &&
Object.keys(carried).length > 0
) {
return null;
}
const values: Record<string, unknown> = {};
const quickActions = readQuickActions(raw.quickActions);
if (quickActions.length > 0) values.quickActions = quickActions;
let terminalConfig = raw.terminalConfig;
if (typeof terminalConfig === "string") {
try {
terminalConfig = JSON.parse(terminalConfig);
} catch {
terminalConfig = null;
}
}
const startup = readStartupSnippetId(
(terminalConfig as { startupSnippetId?: unknown } | null)?.startupSnippetId,
);
if (startup !== null) values.startupSnippetId = startup;
return Object.keys(values).length > 0 ? values : null;
}
/**
* Registered as "snippets.hostPayloadLegacy": quickActions and
* terminalConfig.startupSnippetId in their 2.8 shape, for clients that
* still read them (Termix-Mobile). Remove once the mobile app reads
* pluginSettings.snippets.
*/
export function hostPayloadLegacy(
values: Record<string, unknown>,
): Record<string, unknown> {
const out: Record<string, unknown> = {
quickActions: readQuickActions(values.quickActions),
};
const startup = readStartupSnippetId(values.startupSnippetId);
if (startup !== null) out.terminalConfig = { startupSnippetId: startup };
return out;
}
/** What hostSettingsSync needs from the repository. */
export interface SnippetSyncLookup {
findSyncIdById: (id: number) => Promise<string | null>;
findIdBySyncId: (syncId: string) => Promise<number | null>;
}
/**
* Registered as "snippets.hostSettingsSync": both host settings hold local
* snippet ids, so remote sync carries the snippets' sync ids instead, which
* are the same on both sides.
*/
export function createHostSettingsSync(lookup: SnippetSyncLookup) {
const toSyncId = async (id: number) => lookup.findSyncIdById(id);
const toId = async (value: unknown) =>
typeof value === "string" && value ? lookup.findIdBySyncId(value) : null;
return {
exportValue: async (key: string, value: unknown) => {
if (key === "startupSnippetId") {
const id = readStartupSnippetId(value);
return id === null ? null : await toSyncId(id);
}
if (key === "quickActions") {
const actions = readQuickActions(value);
const out = [];
for (const action of actions) {
const syncId = await toSyncId(action.snippetId);
if (syncId) out.push({ name: action.name, snippetId: syncId });
}
return out;
}
return value;
},
importValue: async (key: string, value: unknown) => {
if (key === "startupSnippetId") return await toId(value);
if (key === "quickActions") {
if (!Array.isArray(value)) return [];
const out = [];
for (const entry of value) {
const row = (entry ?? {}) as { name?: unknown; snippetId?: unknown };
const id = await toId(row.snippetId);
if (id !== null) {
out.push({
name: typeof row.name === "string" ? row.name : "",
snippetId: id,
});
}
}
return out;
}
return value;
},
};
}
+28 -4
View File
@@ -3,7 +3,15 @@ import type { PluginContext } from "@termix/plugin-sdk/backend";
import { snippetAccess, snippetFolders, snippets } from "./tables.js";
import { createSnippetRepository } from "./repository.js";
import { registerSnippetRoutes } from "./routes.js";
import { resolveSnippetCommand } from "./execution.js";
import {
extractSnippetInputs,
resolveSnippetContent,
} from "../shared/variables.js";
import {
createHostSettingsSync,
hostImportNormalizer,
hostPayloadLegacy,
} from "./host-import.js";
/** What other plugins get from ctx.services.get("snippets.access", {userId}). */
export interface SnippetsService {
@@ -74,8 +82,24 @@ export async function activate(ctx: PluginContext) {
type: "snippets",
table: snippetsTable,
order: 60,
// Host quick actions and keybindings in core point at snippets by this.
answersTo: ["commandSnippet"],
});
// Host settings that name a snippet by its local id, and the 2.8 host
// fields they came from. Keybindings name one through the runSnippet
// declaration in the manifest, which core translates over sync.
ctx.registry.provide("snippets.hostImportNormalizer", hostImportNormalizer);
ctx.registry.provide("snippets.hostPayloadLegacy", hostPayloadLegacy);
ctx.registry.provide(
"snippets.hostSettingsSync",
createHostSettingsSync(repo),
);
// $HOST and $INPUT_n handling for commands other plugins run (fleets). A
// pure function, so it goes through the registry rather than a service
// gated on snippets.view.
ctx.registry.provide("snippets.variables", {
resolve: resolveSnippetContent,
extractInputs: extractSnippetInputs,
});
// The user row survives a password-reset data wipe, so it never triggers
@@ -133,7 +157,7 @@ export async function activate(ctx: PluginContext) {
if (!userId) return null;
const snippet = await findAccessible(userId, id);
if (!snippet) return null;
return resolveSnippetCommand(snippet.content, vars, inputValues);
return resolveSnippetContent(snippet.content, vars, inputValues);
},
create: async (input) => {
const userId = ctx.currentActor();
@@ -882,6 +882,27 @@ export function createSnippetRepository(
return rows[0] ?? null;
},
/** A snippet's sync id, for a host setting that names it over sync. */
async findSyncIdById(snippetId: number): Promise<string | null> {
const drizzle = await client();
const rows = await drizzle
.select({ syncId: snippets.syncId })
.from(snippets)
.where(eq(snippets.id, snippetId))
.limit(1);
return (rows[0]?.syncId as string | null | undefined) ?? null;
},
async findIdBySyncId(syncId: string): Promise<number | null> {
const drizzle = await client();
const rows = await drizzle
.select({ id: snippets.id })
.from(snippets)
.where(eq(snippets.syncId, syncId))
.limit(1);
return (rows[0]?.id as number | undefined) ?? null;
},
/** A user's role ids, for the sharing filters above. */
async listUserRoleIds(userId: string): Promise<number[]> {
const drizzle = await client();
@@ -0,0 +1,171 @@
import { Plus, Play, Trash2, Zap } from "lucide-react";
import {
useTranslation,
type HostEditorSectionProps,
} from "@termix/plugin-sdk/frontend";
import { Button, Input, SectionCard, Select2 } from "@termix/plugin-sdk/ui";
import { PLUGIN_ID, readStartupSnippetId } from "../shared/host-settings.js";
import { useSnippetOptions } from "./use-snippet-options";
type PluginSettingsBag = Record<string, Record<string, unknown>>;
/** A quick action row while it is being edited: the snippet may be unset. */
interface QuickActionRow {
name: string;
snippetId: number | null;
}
function readRows(value: unknown): QuickActionRow[] {
if (!Array.isArray(value)) return [];
return value.map((entry) => {
const row = (entry ?? {}) as { name?: unknown; snippetId?: unknown };
const id = Number(row.snippetId);
return {
name: typeof row.name === "string" ? row.name : "",
snippetId: Number.isInteger(id) && id > 0 ? id : null,
};
});
}
/**
* The host editor's Snippets tab: the snippet a terminal runs when it
* connects, and the quick action buttons Host Metrics shows for the host.
* Both are this plugin's host settings, saved with the host.
*/
export function HostSnippetsSection({
form,
updateForm,
adminTargetUserId,
}: HostEditorSectionProps) {
const { t } = useTranslation();
const { options } = useSnippetOptions(adminTargetUserId);
const own =
(form.pluginSettings as PluginSettingsBag | undefined)?.[PLUGIN_ID] ?? {};
const startupSnippetId = readStartupSnippetId(own.startupSnippetId);
const quickActions = readRows(own.quickActions);
const write = (values: Record<string, unknown>) =>
updateForm((current) => {
const all =
(current.pluginSettings as PluginSettingsBag | undefined) ?? {};
return {
...current,
pluginSettings: {
...all,
[PLUGIN_ID]: { ...(all[PLUGIN_ID] ?? {}), ...values },
},
};
});
const setQuickActions = (rows: QuickActionRow[]) =>
write({ quickActions: rows });
return (
<>
<SectionCard
title={t("host.startupTitle")}
icon={<Play className="size-3.5" />}
>
<div className="flex flex-col gap-1.5 py-3">
<p className="text-xs text-muted-foreground">
{t("host.startupDescription")}
</p>
<Select2
aria-label={t("host.startupTitle")}
value={startupSnippetId ?? ""}
onChange={(e) =>
write({
startupSnippetId: e.target.value
? Number(e.target.value)
: null,
})
}
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
>
<option value="">{t("host.none")}</option>
{options.map((snippet) => (
<option key={snippet.id} value={snippet.id}>
{snippet.name}
</option>
))}
</Select2>
</div>
</SectionCard>
<SectionCard
title={t("host.quickActionsTitle")}
icon={<Zap className="size-3.5" />}
action={
<Button
variant="outline"
size="sm"
className="h-6 text-[10px] px-2 border-accent-brand/40 text-accent-brand"
onClick={() =>
setQuickActions([...quickActions, { name: "", snippetId: null }])
}
>
<Plus className="size-3 mr-1" /> {t("host.addQuickAction")}
</Button>
}
>
<div className="flex flex-col gap-3 py-3">
<p className="text-xs text-muted-foreground">
{t("host.quickActionsDescription")}
</p>
{quickActions.length === 0 && (
<div className="flex flex-col items-center justify-center py-4 text-muted-foreground/40 gap-1.5">
<Zap className="size-6" />
<span className="text-xs">{t("host.noQuickActions")}</span>
</div>
)}
{quickActions.map((row, index) => (
<div
key={index}
className="flex items-center gap-2 p-2 bg-muted/20 border border-border group"
>
<Input
className="h-7 text-xs flex-1"
placeholder={t("host.buttonLabel")}
value={row.name}
onChange={(e) => {
const next = [...quickActions];
next[index] = { ...row, name: e.target.value };
setQuickActions(next);
}}
/>
<Select2
aria-label={t("host.selectSnippet")}
className="h-7 text-xs flex-1 border border-border bg-background px-2 outline-none focus:ring-1 focus:ring-ring"
value={row.snippetId ?? ""}
onChange={(e) => {
const next = [...quickActions];
next[index] = {
...row,
snippetId: e.target.value ? Number(e.target.value) : null,
};
setQuickActions(next);
}}
>
<option value="">{t("host.selectSnippet")}</option>
{options.map((snippet) => (
<option key={snippet.id} value={snippet.id}>
{snippet.name}
</option>
))}
</Select2>
<button
type="button"
aria-label={t("host.removeQuickAction")}
className="text-muted-foreground hover:text-destructive opacity-0 group-hover:opacity-100 transition-opacity"
onClick={() =>
setQuickActions(quickActions.filter((_, i) => i !== index))
}
>
<Trash2 className="size-3.5" />
</button>
</div>
))}
</div>
</SectionCard>
</>
);
}
@@ -0,0 +1,147 @@
import { useMemo, useState } from "react";
import { RefreshCw } from "lucide-react";
import { toast } from "sonner";
import {
usePermission,
usePluginApi,
useTranslation,
} from "@termix/plugin-sdk/frontend";
import { Button, Separator } from "@termix/plugin-sdk/ui";
import {
readQuickActions,
snippetHostSettings,
type QuickAction,
} from "../shared/host-settings.js";
import { hasSnippetInputs } from "../shared/variables.js";
import { askForInputs, shouldConfirmExecution } from "./prompt-store";
import { createSnippetsApi } from "./snippets-api";
import { errorMessage } from "./types";
interface ToolbarHost {
id?: number | string;
name?: string;
ip?: string;
username?: string;
port?: number;
pluginSettings?: Record<string, Record<string, unknown>>;
}
/**
* The host's quick actions as buttons in the Host Metrics toolbar (the
* host-metrics.toolbar slot). Each runs its snippet on the host over a fresh
* SSH connection and reports the result in a toast.
*/
export function QuickActionButtons(props: Record<string, unknown>) {
const host = props.host as ToolbarHost | undefined;
const { t } = useTranslation();
const api = usePluginApi();
const client = useMemo(() => createSnippetsApi(api), [api]);
const canView = usePermission("view");
const [running, setRunning] = useState<Set<number>>(new Set());
const actions = readQuickActions(snippetHostSettings(host).quickActions);
const hostId = Number(host?.id);
if (!canView || actions.length === 0 || !Number.isInteger(hostId)) {
return null;
}
const setBusy = (snippetId: number, busy: boolean) =>
setRunning((prev) => {
const next = new Set(prev);
if (busy) next.add(snippetId);
else next.delete(snippetId);
return next;
});
async function execute(
action: QuickAction,
inputValues: Record<string, string>,
) {
const toastId = `quick-action-${hostId}-${action.snippetId}`;
setBusy(action.snippetId, true);
toast.loading(t("quickActions.running", { name: action.name }), {
id: toastId,
});
try {
const result = await client.execute(
action.snippetId,
hostId,
Object.keys(inputValues).length > 0 ? inputValues : undefined,
);
if (result.success) {
toast.success(t("quickActions.succeeded", { name: action.name }), {
id: toastId,
description: result.output?.substring(0, 200),
duration: 5000,
});
} else {
toast.error(t("quickActions.failed", { name: action.name }), {
id: toastId,
description: result.error || result.output,
duration: 5000,
});
}
} catch (error) {
toast.error(t("quickActions.error", { name: action.name }), {
id: toastId,
description: errorMessage(error, t("executionFailed")),
duration: 5000,
});
} finally {
setBusy(action.snippetId, false);
}
}
async function run(action: QuickAction) {
let inputValues: Record<string, string> = {};
const snippet = await client.get(action.snippetId).catch(() => null);
if (snippet && hasSnippetInputs(snippet.content)) {
const values = await askForInputs(
{ name: action.name || snippet.name, content: snippet.content },
host
? {
ip: host.ip,
username: host.username,
port: host.port,
name: host.name,
}
: null,
);
if (!values) return;
inputValues = values;
}
const go = () => void execute(action, inputValues);
if (!shouldConfirmExecution()) {
go();
return;
}
toast(t("confirmRunMessage", { name: action.name }), {
action: { label: t("confirmRunButton"), onClick: go },
duration: 6000,
});
}
return (
<>
<div className="mr-3 flex flex-wrap gap-2">
{actions.map((action, index) => {
const busy = running.has(action.snippetId);
return (
<Button
key={index}
variant="outline"
size="sm"
className="h-8 text-xs font-semibold"
disabled={busy}
onClick={() => void run(action)}
>
{busy && <RefreshCw className="mr-1 size-3 animate-spin" />}
{action.name}
</Button>
);
})}
</div>
<Separator orientation="vertical" className="mx-3 h-8" />
</>
);
}
@@ -0,0 +1,32 @@
import { useEffect } from "react";
import { useSettings } from "@termix/plugin-sdk/frontend";
import { SnippetVariablesDialog } from "./SnippetVariablesDialog";
import { readSnippetSettings } from "./settings";
import { setConfirmExecution, usePendingPrompt } from "./prompt-store";
/**
* Always mounted at the shell's root (the shell.overlay slot), so a snippet
* started from the command palette, a keybinding or a host button can ask
* for its inputs after whatever started it has closed.
*/
export function SnippetOverlay() {
const settings = useSettings("user");
const prompt = usePendingPrompt();
const confirmExecution = readSnippetSettings(
settings.values,
).confirmExecution;
useEffect(() => {
setConfirmExecution(confirmExecution);
}, [confirmExecution]);
if (!prompt) return null;
return (
<SnippetVariablesDialog
snippet={prompt.snippet}
host={prompt.host}
onCancel={() => prompt.settle(null)}
onConfirm={(_resolved, values) => prompt.settle(values)}
/>
);
}
@@ -0,0 +1,107 @@
import { useState, useEffect, useMemo } from "react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import {
Button,
Input,
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@termix/plugin-sdk/ui";
import {
extractSnippetInputs,
resolveSnippetContent,
type SnippetHostContext,
} from "../shared/variables.js";
/** Enough of a snippet to fill in its inputs. */
export interface VariablesDialogSnippet {
name: string;
content: string;
}
/**
* Shown before running a snippet that contains $INPUT_n placeholders:
* collects a value per placeholder and previews the fully resolved command
* (host vars and inputs) before handing the result back to the caller.
*/
export function SnippetVariablesDialog({
snippet,
host,
onCancel,
onConfirm,
}: {
snippet: VariablesDialogSnippet;
host: SnippetHostContext | null;
onCancel: () => void;
onConfirm: (
resolvedContent: string,
inputValues: Record<string, string>,
) => void;
}) {
const { t } = useTranslation();
const inputs = useMemo(
() => extractSnippetInputs(snippet.content),
[snippet.content],
);
const [values, setValues] = useState<Record<string, string>>({});
useEffect(() => {
setValues({});
}, [snippet]);
const preview = resolveSnippetContent(snippet.content, host, values);
return (
<Dialog open onOpenChange={(v) => !v && onCancel()}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle className="text-lg font-bold">
{t("variablesDialogTitle", { name: snippet.name })}
</DialogTitle>
<DialogDescription className="text-xs text-muted-foreground">
{t("variablesDialogDescription")}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4 mt-1">
{inputs.map((input) => (
<div key={input.key} className="flex flex-col gap-1.5">
<label className="text-xs font-semibold">{input.label}</label>
<Input
autoFocus={inputs[0]?.key === input.key}
value={values[input.key] ?? ""}
onChange={(e) =>
setValues((prev) => ({
...prev,
[input.key]: e.target.value,
}))
}
/>
</div>
))}
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold text-muted-foreground">
{t("variablesPreviewLabel")}
</label>
<span className="text-xs text-muted-foreground font-mono px-2.5 py-2 border border-border bg-muted/20 min-w-0 break-all whitespace-pre-wrap">
{preview}
</span>
</div>
</div>
<div className="flex items-center justify-end gap-2 mt-2">
<Button variant="ghost" onClick={onCancel}>
{t("cancel")}
</Button>
<Button
variant="outline"
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
onClick={() => onConfirm(preview, values)}
>
{t("variablesConfirmButton")}
</Button>
</div>
</DialogContent>
</Dialog>
);
}
+124 -80
View File
@@ -1,15 +1,53 @@
import { Play } from "lucide-react";
import type { PanelProps, TermixApp } from "@termix/plugin-sdk/frontend";
import { Clipboard, Play } from "lucide-react";
import type {
PaletteItem,
PanelProps,
PluginHostRecord,
TermixApp,
} from "@termix/plugin-sdk/frontend";
import type { Snippet } from "./types";
import { SnippetsPanel } from "./SnippetsPanel";
import { createSnippetsApi } from "./snippets-api";
import { resolveSnippetContent, hasSnippetInputs } from "./snippet-variables";
import {
AdminUserSnippets,
adminOptions,
mapSnippets,
} from "./AdminUserSnippets";
extractSnippetInputs,
resolveSnippetContent,
type SnippetHostContext,
} from "../shared/variables.js";
import {
readStartupSnippetId,
snippetHostSettings,
} from "../shared/host-settings.js";
import { AdminUserSnippets } from "./AdminUserSnippets";
import { SnippetOverlay } from "./SnippetOverlay";
import { HostSnippetsSection } from "./HostSnippetsSection";
import { QuickActionButtons } from "./QuickActionButtons";
import { resetPrompts } from "./prompt-store";
import { fetchSnippet, runInSession } from "./run-flows";
import {
RUN_SNIPPET_ACTION,
RunSnippetEditor,
RunSnippetSummary,
runSnippetBinding,
validateRunSnippet,
} from "./keybinding";
function hostContext(host: unknown): SnippetHostContext | null {
if (!host || typeof host !== "object") return null;
const record = host as Record<string, unknown>;
return {
ip: typeof record.ip === "string" ? record.ip : undefined,
username: typeof record.username === "string" ? record.username : undefined,
port:
typeof record.port === "number" || typeof record.port === "string"
? record.port
: undefined,
name: typeof record.name === "string" ? record.name : undefined,
};
}
export function activate(app: TermixApp): void {
app.onDispose(resetPrompts);
app.registerRailItem({
id: "snippets",
icon: Play,
@@ -33,6 +71,73 @@ export function activate(app: TermixApp): void {
},
});
// Every snippet in the command palette, run in the terminal the user is
// working in.
app.registerPaletteGroup({
id: "snippets",
titleKey: "nav.snippets",
order: 10,
load: async (): Promise<PaletteItem[]> => {
if (!(await app.hasPermission("view"))) return [];
const snippets: Snippet[] = await createSnippetsApi(app.api)
.list()
.catch(() => []);
return snippets.map((snippet) => ({
id: String(snippet.id),
title: snippet.name,
description: snippet.content,
icon: snippet.isNote ? Clipboard : Play,
keywords: [snippet.description ?? ""],
needsTarget: true,
hint: app.t(snippet.isNote ? "paletteHintPaste" : "paletteHintRun"),
run: ({ targetTab }) => {
if (!targetTab) return;
void runInSession(
app,
snippet,
{ sessionId: targetTab.id },
hostContext(targetTab.host),
{ announce: true },
);
},
}));
},
});
// Keeps the input dialog and the confirm setting alive at the shell root.
app.registerSlotContribution("shell.overlay", {
actionId: "snippets.overlay",
titleKey: "nav.snippets",
kind: "component",
component: SnippetOverlay as never,
});
app.registerKeybindingAction({
id: RUN_SNIPPET_ACTION,
titleKey: "keybindings.runSnippet",
editor: RunSnippetEditor,
summary: RunSnippetSummary,
validate: validateRunSnippet,
run: (action, context) => runSnippetBinding(app, action, context),
});
app.registerHostEditorSection({
id: "snippets",
group: "ssh",
titleKey: "host.tabTitle",
icon: Play,
order: 15,
component: HostSnippetsSection,
});
// Host Metrics shows a host's quick actions as toolbar buttons.
app.registerSlotContribution("host-metrics.toolbar", {
actionId: "snippets.quickActions",
titleKey: "host.quickActionsTitle",
kind: "component",
component: QuickActionButtons,
});
app.registerSlotContribution("onboarding.features", {
actionId: "snippets.feature",
titleKey: "onboarding.feature_snippets",
@@ -49,71 +154,17 @@ export function activate(app: TermixApp): void {
order: 10,
});
// Core's snippet pickers (the host editor's startup snippet, keybindings,
// the command palette) read the list through this. An admin editing
// another user's host passes that user's id.
app.registerAction("snippet.list", (async (options?: {
targetUserId?: string;
}) => {
const response = options?.targetUserId
? await app.api.get("/", adminOptions(options.targetUserId))
: await app.api.get("/");
const raw = response.data as unknown;
const list = Array.isArray(raw)
? raw
: ((raw as { snippets?: unknown[] })?.snippets ?? []);
return (list as Record<string, unknown>[]).map((row) => ({
...mapSnippets([row])[0],
isNote: row.isNote === true,
}));
}) as never);
// Resolves a snippet by id for a terminal (startup snippet, custom
// keybindings, the command palette), so core never imports snippet code.
const resolveForTerminal = async (
snippetId: number,
host: {
ip?: string;
username?: string;
port?: number;
name?: string;
} | null,
inputValues?: Record<string, string>,
// The command a terminal types when it connects to a host: the host's
// startup snippet with its variables filled in, or null.
app.registerAction("snippets.startupCommand", (async (
host: PluginHostRecord | null,
vars?: SnippetHostContext,
) => {
const api = createSnippetsApi(app.api);
const snippet = await api.get(snippetId).catch(() => null);
const id = readStartupSnippetId(snippetHostSettings(host).startupSnippetId);
if (id === null) return null;
const snippet = await fetchSnippet(app, id);
if (!snippet) return null;
if (
hasSnippetInputs(snippet.content) &&
(!inputValues || Object.keys(inputValues).length === 0)
) {
return {
needsInputs: true,
content: snippet.content,
isNote: snippet.isNote,
};
}
return {
needsInputs: false,
content: resolveSnippetContent(snippet.content, host, inputValues ?? {}),
isNote: snippet.isNote,
};
};
app.registerAction("snippet.resolveForTerminal", resolveForTerminal as never);
app.registerAction(
"snippets.resolveForTerminal",
resolveForTerminal as never,
);
// For other plugins' quick actions (host metrics): the snippet's content,
// so the caller can ask for $INPUT_n values, and a run on a host.
app.registerAction("snippets.get", (async (snippetId: number) => {
const snippet = await createSnippetsApi(app.api)
.get(snippetId)
.catch(() => null);
return snippet
? { id: snippet.id, name: snippet.name, content: snippet.content }
: null;
return resolveSnippetContent(snippet.content, vars ?? hostContext(host));
}) as never);
// For pickers in other plugins (the AI assistant's @-mentions): id and name.
@@ -124,14 +175,7 @@ export function activate(app: TermixApp): void {
return snippets.map((snippet) => ({ id: snippet.id, name: snippet.name }));
}) as never);
app.registerAction("snippets.execute", (async (
snippetId: number,
hostId: number,
inputValues?: Record<string, string>,
) =>
createSnippetsApi(app.api).execute(
snippetId,
hostId,
inputValues,
)) as never);
// Variable handling for commands other plugins run (fleets' run box).
app.registerAction("snippets.extractInputs", ((content: string) =>
extractSnippetInputs(typeof content === "string" ? content : "")) as never);
}
@@ -0,0 +1,89 @@
import {
useTranslation,
type KeybindingAction,
type KeybindingActionEditorProps,
type KeybindingRunContext,
type TermixApp,
} from "@termix/plugin-sdk/frontend";
import { fetchSnippet, runInSession } from "./run-flows";
import { useSnippetOptions } from "./use-snippet-options";
/** Stored as action.type; kept from 2.8 so saved bindings keep working. */
export const RUN_SNIPPET_ACTION = "runSnippet";
function snippetIdOf(action: KeybindingAction): string {
return typeof action.snippetId === "string" ? action.snippetId : "";
}
/** Picks the snippet a "Run snippet" binding runs. */
export function RunSnippetEditor({
action,
onChange,
}: KeybindingActionEditorProps) {
const { t } = useTranslation();
const { options } = useSnippetOptions();
return (
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold">
{t("keybindings.snippetLabel")}
</label>
<select
aria-label={t("keybindings.snippetLabel")}
value={snippetIdOf(action)}
onChange={(e) =>
onChange({
type: RUN_SNIPPET_ACTION,
snippetId: e.target.value || undefined,
appendEnter: true,
})
}
className="h-8 border border-border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring"
>
<option value="">{t("keybindings.selectSnippet")}</option>
{options.map((snippet) => (
<option key={snippet.id} value={String(snippet.id)}>
{snippet.name}
</option>
))}
</select>
</div>
);
}
/** The bound snippet's name in the binding list, or a warning once it is gone. */
export function RunSnippetSummary({ action }: KeybindingActionEditorProps) {
const { t } = useTranslation();
const { options, loaded } = useSnippetOptions();
if (!loaded) return null;
const snippet = options.find((s) => String(s.id) === snippetIdOf(action));
if (snippet) return <span>({snippet.name})</span>;
return (
<span className="text-destructive">
({t("keybindings.snippetMissing")})
</span>
);
}
export function validateRunSnippet(action: KeybindingAction): string | null {
return /^\d+$/.test(snippetIdOf(action))
? null
: "keybindings.snippetRequired";
}
/** Runs a bound snippet in the terminal the key was pressed in. */
export function runSnippetBinding(
app: Pick<TermixApp, "api" | "t">,
action: KeybindingAction,
context: KeybindingRunContext,
): void {
void fetchSnippet(app, Number(snippetIdOf(action))).then((snippet) => {
if (!snippet) return;
void runInSession(
app,
{ ...snippet, isNote: false },
{ sessionId: context.sessionId, send: context.send },
context.host ?? null,
{ pressEnter: action.appendEnter !== false },
);
});
}
@@ -0,0 +1,72 @@
import { useSyncExternalStore } from "react";
import type { SnippetHostContext } from "../shared/variables.js";
export interface PendingPrompt {
snippet: { name: string; content: string };
host: SnippetHostContext | null;
settle: (values: Record<string, string> | null) => void;
}
let pending: PendingPrompt | null = null;
let confirmExecution = false;
const listeners = new Set<() => void>();
function notify(): void {
for (const listener of [...listeners]) listener();
}
function subscribe(listener: () => void): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}
/**
* Asks for a snippet's $INPUT_n values through the dialog the overlay draws.
* Resolves null when the user cancels. A second request cancels the first.
*/
export function askForInputs(
snippet: { name: string; content: string },
host: SnippetHostContext | null,
): Promise<Record<string, string> | null> {
pending?.settle(null);
return new Promise((resolve) => {
const prompt: PendingPrompt = {
snippet,
host,
settle: (values) => {
if (pending === prompt) {
pending = null;
notify();
}
resolve(values);
},
};
pending = prompt;
notify();
});
}
export function usePendingPrompt(): PendingPrompt | null {
return useSyncExternalStore(
subscribe,
() => pending,
() => pending,
);
}
/** Kept current by the overlay from the user's confirmExecution setting. */
export function setConfirmExecution(value: boolean): void {
confirmExecution = value;
}
export function shouldConfirmExecution(): boolean {
return confirmExecution;
}
/** Cancels anything waiting, for deactivate. */
export function resetPrompts(): void {
pending?.settle(null);
pending = null;
confirmExecution = false;
notify();
}
+121
View File
@@ -0,0 +1,121 @@
import { toast } from "sonner";
import { invokeAction, type TermixApp } from "@termix/plugin-sdk/frontend";
import {
hasSnippetInputs,
resolveSnippetContent,
type SnippetHostContext,
} from "../shared/variables.js";
import { askForInputs, shouldConfirmExecution } from "./prompt-store";
import { createSnippetsApi } from "./snippets-api";
export interface RunnableSnippet {
id: number;
name: string;
content: string;
isNote?: boolean;
}
/** Where a run sends its text: a session by id, or a raw writer. */
export interface RunTarget {
sessionId?: string;
send?: (data: string) => void;
}
/**
* The snippet with its variables filled in, asking for $INPUT_n values when
* it has any. Null when the user cancels.
*/
export async function resolveForRun(
snippet: { name: string; content: string },
host: SnippetHostContext | null,
): Promise<string | null> {
if (!hasSnippetInputs(snippet.content)) {
return resolveSnippetContent(snippet.content, host);
}
const values = await askForInputs(snippet, host);
if (!values) return null;
return resolveSnippetContent(snippet.content, host, values);
}
/** Runs `execute` now, or after the user confirms when they asked to. */
export function confirmThenRun(
app: Pick<TermixApp, "t">,
name: string,
execute: () => void,
): void {
if (!shouldConfirmExecution()) {
execute();
return;
}
toast(app.t("confirmRunMessage", { name }), {
action: { label: app.t("confirmRunButton"), onClick: execute },
duration: 6000,
});
}
/**
* Types a snippet into a terminal session: resolves its variables, asks for
* inputs and confirmation as the user's settings say, then sends it. A note
* is pasted without running.
*/
export async function runInSession(
app: Pick<TermixApp, "t">,
snippet: RunnableSnippet,
target: RunTarget,
host: SnippetHostContext | null,
options: { pressEnter?: boolean; announce?: boolean } = {},
): Promise<void> {
const content = await resolveForRun(snippet, host);
if (content === null) return;
const run = !snippet.isNote && options.pressEnter !== false;
const send = async () => {
let sent = true;
if (target.send) {
target.send(content + (run ? "\r" : ""));
} else if (target.sessionId) {
sent =
(await invokeAction(
"terminal.sendToSession",
target.sessionId,
content,
{
run,
},
)) === true;
} else {
sent = false;
}
if (!options.announce) return;
if (!sent) {
toast.error(app.t("noTerminalTabsOpen"));
return;
}
toast.success(
app.t(snippet.isNote ? "pasteSuccess" : "runSuccess", {
name: snippet.name,
count: 1,
}),
);
};
if (snippet.isNote) void send();
else confirmThenRun(app, snippet.name, () => void send());
}
/** One snippet by id, or null when it is gone or unreadable. */
export async function fetchSnippet(
app: Pick<TermixApp, "api">,
id: number,
): Promise<RunnableSnippet | null> {
if (!Number.isInteger(id) || id <= 0) return null;
const snippet = await createSnippetsApi(app.api)
.get(id)
.catch(() => null);
return snippet
? {
id: snippet.id,
name: snippet.name,
content: snippet.content,
isNote: snippet.isNote,
}
: null;
}
@@ -0,0 +1,53 @@
import { useEffect, useState } from "react";
import { usePluginApi } from "@termix/plugin-sdk/frontend";
import { adminOptions } from "./AdminUserSnippets";
export interface SnippetOption {
id: number;
name: string;
}
/**
* The user's snippets as picker options: their own, or the target user's
* while an admin edits that user's host. `loaded` turns true once the list
* came back, so a picker can tell "no snippets" from "not loaded yet".
*/
export function useSnippetOptions(targetUserId?: string): {
options: SnippetOption[];
loaded: boolean;
} {
const api = usePluginApi();
const [state, setState] = useState<{
options: SnippetOption[];
loaded: boolean;
}>({ options: [], loaded: false });
useEffect(() => {
let cancelled = false;
const request = targetUserId
? api.get("/", adminOptions(targetUserId))
: api.get("/");
request
.then((response) => {
const raw = response.data as unknown;
const list = Array.isArray(raw)
? raw
: ((raw as { snippets?: unknown[] })?.snippets ?? []);
const options = (list as Array<{ id?: unknown; name?: unknown }>)
.filter((row) => typeof row.id === "number")
.map((row) => ({
id: row.id as number,
name: typeof row.name === "string" ? row.name : `#${row.id}`,
}));
if (!cancelled) setState({ options, loaded: true });
})
.catch(() => {
if (!cancelled) setState({ options: [], loaded: true });
});
return () => {
cancelled = true;
};
}, [api, targetUserId]);
return state;
}
@@ -5,12 +5,12 @@ import {
useTranslation,
} from "@termix/plugin-sdk/frontend";
import { toast } from "sonner";
import { SnippetVariablesDialog } from "@termix/plugin-sdk/ui";
import {
hasSnippetInputs,
resolveSnippetContent,
type SnippetHostContext,
} from "./snippet-variables";
} from "../shared/variables.js";
import { SnippetVariablesDialog } from "./SnippetVariablesDialog";
import { createSnippetsApi } from "./snippets-api";
import {
ExecutionResultsDialog,
@@ -259,7 +259,7 @@ export function useSnippetRunner(confirmExecution = false) {
const variablesDialog = runningSnippet ? (
<SnippetVariablesDialog
snippet={runningSnippet.snippet as never}
snippet={runningSnippet.snippet}
host={runningSnippet.host}
onCancel={() => setRunningSnippet(null)}
onConfirm={runningSnippet.onConfirm}
@@ -0,0 +1,48 @@
/**
* The snippets plugin's host settings: the startup snippet a terminal runs
* when it connects, and the quick action buttons Host Metrics shows.
*/
export const PLUGIN_ID = "snippets";
export interface QuickAction {
name: string;
snippetId: number;
}
function snippetId(value: unknown): number | null {
const id = typeof value === "string" ? Number(value) : value;
return typeof id === "number" && Number.isInteger(id) && id > 0 ? id : null;
}
/** Quick actions in their stored shape, dropping any without a snippet. */
export function readQuickActions(value: unknown): QuickAction[] {
let list = value;
if (typeof list === "string") {
try {
list = JSON.parse(list);
} catch {
return [];
}
}
if (!Array.isArray(list)) return [];
return list.flatMap((entry): QuickAction[] => {
if (!entry || typeof entry !== "object") return [];
const id = snippetId((entry as { snippetId?: unknown }).snippetId);
if (id === null) return [];
const name = (entry as { name?: unknown }).name;
return [{ name: typeof name === "string" ? name : "", snippetId: id }];
});
}
export function readStartupSnippetId(value: unknown): number | null {
return snippetId(value);
}
/** This plugin's slice of a host record's pluginSettings. */
export function snippetHostSettings(host: unknown): Record<string, unknown> {
const all = (host as { pluginSettings?: Record<string, unknown> } | null)
?.pluginSettings;
const own = all?.[PLUGIN_ID];
return own && typeof own === "object" ? (own as Record<string, unknown>) : {};
}
@@ -1,3 +1,9 @@
/**
* $HOST, $USER, $PORT, $NAME and $INPUT_n in a command. The one copy: the
* frontend, the backend and other plugins (fleets, through the
* "snippets.variables" registry entry and action) all use this.
*/
export interface SnippetInput {
key: string;
label: string;
@@ -98,6 +98,28 @@ describe("snippets activate", () => {
expect(after).toHaveLength(0);
});
it("registers its host settings hooks and the variables helper", async () => {
server = await startServer();
const registry = server.mock.ctx.registry;
for (const key of [
"snippets.hostImportNormalizer",
"snippets.hostPayloadLegacy",
"snippets.hostSettingsSync",
]) {
expect(registry.consume(key), key).toBeDefined();
}
const variables = registry.consume<{
resolve: (c: string, h: { ip?: string } | null) => string;
extractInputs: (c: string) => Array<{ key: string }>;
}>("snippets.variables");
expect(variables?.resolve("ping $HOST", { ip: "10.0.0.1" })).toBe(
"ping 10.0.0.1",
);
expect(variables?.extractInputs("echo $INPUT_1")).toEqual([
{ key: "INPUT_1", label: "Input 1" },
]);
});
it("fails closed without db:own", async () => {
db = await createTestDb(pluginDir);
const mock = createMockCtx({
@@ -0,0 +1,79 @@
import { describe, expect, it } from "vitest";
import {
createHostSettingsSync,
hostImportNormalizer,
hostPayloadLegacy,
} from "../../src/backend/host-import.js";
describe("hostImportNormalizer", () => {
it("reads a 2.8 export's quick actions and startup snippet", () => {
expect(
hostImportNormalizer({
quickActions: [
{ name: "Restart", snippetId: "3" },
{ name: "Broken", snippetId: "" },
],
terminalConfig: JSON.stringify({ startupSnippetId: 5, fontSize: 12 }),
}),
).toEqual({
quickActions: [{ name: "Restart", snippetId: 3 }],
startupSnippetId: 5,
});
});
it("leaves an export that carries this plugin's settings to core", () => {
expect(
hostImportNormalizer({
pluginSettings: { snippets: { startupSnippetId: 1 } },
terminalConfig: { startupSnippetId: 5 },
}),
).toBeNull();
});
it("writes nothing for a host without either", () => {
expect(hostImportNormalizer({ name: "web" })).toBeNull();
});
});
describe("hostPayloadLegacy", () => {
it("puts the 2.8 fields back for older clients", () => {
expect(
hostPayloadLegacy({
quickActions: [{ name: "Up", snippetId: 2 }],
startupSnippetId: 7,
}),
).toEqual({
quickActions: [{ name: "Up", snippetId: 2 }],
terminalConfig: { startupSnippetId: 7 },
});
expect(hostPayloadLegacy({})).toEqual({ quickActions: [] });
});
});
describe("hostSettingsSync", () => {
const sync = createHostSettingsSync({
findSyncIdById: async (id) => (id === 2 ? "sync-2" : null),
findIdBySyncId: async (syncId) => (syncId === "sync-2" ? 9 : null),
});
it("sends sync ids and drops what does not map", async () => {
expect(await sync.exportValue("startupSnippetId", 2)).toBe("sync-2");
expect(await sync.exportValue("startupSnippetId", 4)).toBeNull();
expect(
await sync.exportValue("quickActions", [
{ name: "A", snippetId: 2 },
{ name: "B", snippetId: 4 },
]),
).toEqual([{ name: "A", snippetId: "sync-2" }]);
});
it("maps sync ids back to this side's rows", async () => {
expect(await sync.importValue("startupSnippetId", "sync-2")).toBe(9);
expect(await sync.importValue("startupSnippetId", "missing")).toBeNull();
expect(
await sync.importValue("quickActions", [
{ name: "A", snippetId: "sync-2" },
]),
).toEqual([{ name: "A", snippetId: 9 }]);
});
});
@@ -3,7 +3,7 @@ import {
extractSnippetInputs,
hasSnippetInputs,
resolveSnippetContent,
} from "../../src/frontend/snippet-variables";
} from "../../src/shared/variables.js";
describe("extractSnippetInputs", () => {
it("extracts a single unlabeled input", () => {
@@ -1,5 +1,6 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { screen } from "@testing-library/react";
import { act, fireEvent, screen } from "@testing-library/react";
import { runKeybindingAction } from "@termix/plugin-sdk/frontend";
import {
renderWithApp,
type RenderedPluginApp,
@@ -41,11 +42,24 @@ describe(`${manifest.id} activate`, () => {
expect(railItem?.permission).toBe("snippets.view");
});
it("registers the snippets.resolveForTerminal action", async () => {
it("registers what other surfaces reach it through", async () => {
rendered = await renderWithApp(plugin, { manifest, locales });
expect(rendered.registered.actions()).toContain(
"snippets.resolveForTerminal",
expect(rendered.registered.actions()).toEqual(
expect.arrayContaining([
"snippets.startupCommand",
"snippets.list",
"snippets.extractInputs",
]),
);
expect(rendered.registered.paletteGroups()).toEqual(["snippets"]);
expect(rendered.registered.keybindingActions()).toEqual(["runSnippet"]);
expect(rendered.registered.hostEditorSections()).toEqual(["snippets"]);
expect(rendered.registered.slot("host-metrics.toolbar")).toEqual([
"snippets.quickActions",
]);
expect(rendered.registered.slot("shell.overlay")).toEqual([
"snippets.overlay",
]);
});
it("removes everything it registered on deactivate", async () => {
@@ -54,6 +68,143 @@ describe(`${manifest.id} activate`, () => {
expect(app.registered.panels()).toEqual([]);
expect(app.registered.railItems()).toEqual([]);
expect(app.registered.actions()).toEqual([]);
expect(app.registered.paletteGroups()).toEqual([]);
expect(app.registered.keybindingActions()).toEqual([]);
});
});
const deploy = {
id: 3,
name: "Deploy",
content: "deploy $HOST",
description: null,
isNote: false,
};
describe("snippets in the command palette", () => {
it("lists snippets that run in the target tab", async () => {
const api = { get: vi.fn(async () => ({ data: [deploy] })) };
rendered = await renderWithApp(plugin, {
manifest,
locales,
api: api as never,
permissions: ["snippets.view"],
});
const items = await rendered.loadPaletteGroup("snippets");
expect(items).toEqual([
expect.objectContaining({
id: "3",
title: "Deploy",
description: "deploy $HOST",
needsTarget: true,
}),
]);
});
it("offers nothing without the view permission", async () => {
rendered = await renderWithApp(plugin, {
manifest,
locales,
permissions: [],
});
expect(await rendered.loadPaletteGroup("snippets")).toEqual([]);
});
});
describe("the runSnippet keybinding", () => {
it("types the resolved snippet into the session it was pressed in", async () => {
const api = { get: vi.fn(async () => ({ data: deploy })) };
rendered = await renderWithApp(plugin, {
manifest,
locales,
api: api as never,
});
const send = vi.fn();
const handled = runKeybindingAction(
{ type: "runSnippet", snippetId: "3", appendEnter: true },
{ host: { ip: "10.0.0.9" }, send },
);
expect(handled).toBe(true);
await vi.waitFor(() =>
expect(send).toHaveBeenCalledWith("deploy 10.0.0.9\r"),
);
expect(api.get).toHaveBeenCalledWith("/3");
});
});
describe("the startup command", () => {
it("resolves the host's startup snippet, or nothing without one", async () => {
const api = { get: vi.fn(async () => ({ data: deploy })) };
rendered = await renderWithApp(plugin, {
manifest,
locales,
api: api as never,
});
const host = {
id: "1",
name: "web",
ip: "10.0.0.2",
port: 22,
pluginSettings: { snippets: { startupSnippetId: 3 } },
};
expect(
await rendered.app.invokeAction("snippets.startupCommand", host),
).toBe("deploy 10.0.0.2");
expect(
await rendered.app.invokeAction("snippets.startupCommand", {
...host,
pluginSettings: {},
}),
).toBeNull();
});
});
describe("the host editor's Snippets tab", () => {
it("writes the startup snippet and quick actions into this plugin's settings", async () => {
const api = { get: vi.fn(async () => ({ data: [deploy] })) };
rendered = await renderWithApp(plugin, {
manifest,
locales,
api: api as never,
});
let form: Record<string, unknown> = {
pluginSettings: { other: { keep: 1 } },
};
const updateForm = (
patch: (current: Record<string, unknown>) => Record<string, unknown>,
) => {
form = patch(form);
};
rendered.renderHostEditorSection("snippets", { form, updateForm });
const startup = await screen.findByRole("combobox", {
name: "Startup Snippet",
});
await screen.findAllByRole("option", { name: "Deploy" });
act(() => {
fireEvent.change(startup, { target: { value: "3" } });
});
expect(form.pluginSettings).toEqual({
other: { keep: 1 },
snippets: { startupSnippetId: 3 },
});
});
});
describe("the Host Metrics quick action buttons", () => {
it("draws a button per quick action and nothing without any", async () => {
rendered = await renderWithApp(plugin, {
manifest,
locales,
permissions: ["snippets.view"],
});
const host = {
id: 4,
pluginSettings: {
snippets: { quickActions: [{ name: "Restart", snippetId: 3 }] },
},
};
rendered.renderSlot("host-metrics.toolbar", { hostId: 4, host });
expect(await screen.findByRole("button", { name: "Restart" })).toBeTruthy();
});
});
@@ -31,22 +31,23 @@ afterEach(async () => {
rendered = null;
});
describe("snippet.list", () => {
it("lists the user's own snippets", async () => {
describe("snippet pickers while an admin edits another user's host", () => {
it("list the user's own snippets", async () => {
const api = fakeApi([{ id: 1, name: "a", content: "ls", isNote: true }]);
rendered = await renderWithApp(plugin, { manifest, locales, api });
const rows = await rendered.app.invokeAction("snippet.list");
rendered.renderHostEditorSection("snippets", { form: {} });
await screen.findAllByRole("option", { name: "a" });
expect(api.get).toHaveBeenCalledWith("/");
expect(rows).toEqual([
{ id: 1, name: "a", content: "ls", folder: null, isNote: true },
]);
});
it("lists another user's snippets for an admin", async () => {
it("list the target user's snippets", async () => {
const api = fakeApi([]);
rendered = await renderWithApp(plugin, { manifest, locales, api });
await rendered.app.invokeAction("snippet.list", { targetUserId: "u2" });
expect(api.get).toHaveBeenCalledWith("/", TARGET);
rendered.renderHostEditorSection("snippets", {
form: {},
adminTargetUserId: "u2",
});
await waitFor(() => expect(api.get).toHaveBeenCalledWith("/", TARGET));
});
});