feat: add the snippets plugin (B2)

This commit is contained in:
LukeGus committed 2026-09-23 08:59:16 -05:00
1 parent 1eac0f54b0
commit 3656caf168
29 files changed
+5293

No files matched your search

+82
View File
@@ -0,0 +1,82 @@
export interface SnippetExecutionResult {
success: boolean;
output: string;
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 function getSnippetExecutionTimeoutMs(
value = process.env.SNIPPET_EXECUTION_TIMEOUT_SECONDS,
): number | undefined {
if (value === undefined || value.trim() === "") return undefined;
const seconds = Number(value);
if (!Number.isFinite(seconds) || seconds <= 0) return undefined;
return seconds * 1000;
}
export function createSnippetExecutionResult(
exitCode: number | null,
output: string,
errorOutput: string,
): SnippetExecutionResult {
const success = exitCode === 0 || (exitCode === null && !errorOutput);
return {
success,
output,
...(errorOutput ? { error: errorOutput } : {}),
};
}
+156
View File
@@ -0,0 +1,156 @@
import type { Router } from "express";
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";
/** What other plugins get from ctx.services.get("snippets.access", {userId}). */
export interface SnippetsService {
/** The calling user's own snippets (not shared ones), full content included. */
list: () => Promise<
Array<{
id: number;
name: string;
content: string;
description: string | null;
isNote: boolean;
folder: string | null;
}>
>;
/** A snippet the calling user owns or was shared with them. */
get: (id: number) => Promise<{
id: number;
name: string;
content: string;
isNote: boolean;
} | null>;
resolveCommand: (
id: number,
vars: { ip?: string; username?: string; port?: number; name?: string },
inputValues?: Record<string, string>,
) => Promise<string | null>;
/** Creates a snippet owned by the calling user. */
create: (input: {
name: string;
content: string;
description?: string | null;
folder?: string | null;
}) => Promise<{ id: number; name: string }>;
/** Updates a snippet the calling user owns. Throws if not found. */
update: (
id: number,
changes: {
name?: string;
content?: string;
description?: string | null;
folder?: string | null;
},
) => Promise<void>;
/** Deletes a snippet the calling user owns. Returns whether one was deleted. */
remove: (id: number) => Promise<boolean>;
}
export async function activate(ctx: PluginContext) {
const snippetsTable = await ctx.db.define(snippets);
const foldersTable = await ctx.db.define(snippetFolders);
const accessTable = await ctx.db.define(snippetAccess);
const repo = createSnippetRepository(
ctx.db,
snippetsTable,
foldersTable,
accessTable,
);
registerSnippetRoutes(ctx.http.router<Router>(), repo, ctx);
ctx.sync.registerEntity({
type: "snippetFolders",
table: foldersTable,
order: 40,
});
ctx.sync.registerEntity({
type: "snippets",
table: snippetsTable,
order: 60,
});
// The user row survives a password-reset data wipe, so it never triggers
// our refUser() cascade; this topic is core's way of telling us to drop
// our copy of that user's data too.
ctx.events.on("user.data_wiped", (payload) => {
const userId = (payload as { userId?: string } | null)?.userId;
if (userId) void repo.deleteByUserId(userId);
});
async function findAccessible(userId: string, id: number) {
const owned = await repo.findOwnedById(userId, id);
if (owned) return owned;
const roleIds = await repo.listUserRoleIds(userId);
return repo.findAccessibleSharedSnippet(id, userId, roleIds);
}
const service: SnippetsService = {
list: async () => {
const userId = ctx.currentActor();
if (!userId) return [];
const owned = await repo.listOwnedSnippets(userId);
return owned.map((s) => ({
id: s.id,
name: s.name,
content: s.content,
description: s.description,
isNote: s.isNote,
folder: s.folder,
}));
},
get: async (id) => {
const userId = ctx.currentActor();
if (!userId) return null;
const snippet = await findAccessible(userId, id);
if (!snippet) return null;
return {
id: snippet.id,
name: snippet.name,
content: snippet.content,
isNote: snippet.isNote,
};
},
resolveCommand: async (id, vars, inputValues = {}) => {
const userId = ctx.currentActor();
if (!userId) return null;
const snippet = await findAccessible(userId, id);
if (!snippet) return null;
return resolveSnippetCommand(snippet.content, vars, inputValues);
},
create: async (input) => {
const userId = ctx.currentActor();
if (!userId) throw new Error("snippets.access.create needs an actor");
const created = await repo.createSnippet(userId, input);
return { id: created.id, name: created.name };
},
update: async (id, changes) => {
const userId = ctx.currentActor();
if (!userId) throw new Error("snippets.access.update needs an actor");
const result = await repo.updateSnippet(userId, id, changes);
if (!result) throw new Error("Snippet not found");
},
remove: async (id) => {
const userId = ctx.currentActor();
if (!userId) throw new Error("snippets.access.remove needs an actor");
const existing = await repo.deleteSnippet(userId, id);
if (existing?.syncId) {
await ctx.sync.recordTombstone(userId, "snippets", existing.syncId);
}
return !!existing;
},
};
ctx.services.provide("snippets.access", service);
ctx.log.info("Snippets routes mounted at /plugin-api/snippets");
}
export async function deactivate() {
// Everything above was registered through ctx and is disposed by core.
}
+29
View File
@@ -0,0 +1,29 @@
import type { SnippetReorderUpdate } from "./repository.js";
type SnippetReorderRequestBody = {
snippets?: unknown;
updates?: unknown;
};
export function extractSnippetReorderUpdates(
body: unknown,
): SnippetReorderUpdate[] | null {
if (!body || typeof body !== "object") {
return null;
}
const payload = body as SnippetReorderRequestBody;
// Keep accepting the legacy `updates` key so older clients do not break
// while the web and desktop helpers converge on `snippets`.
const snippetsUpdates = Array.isArray(payload.snippets)
? payload.snippets
: Array.isArray(payload.updates)
? payload.updates
: null;
if (!snippetsUpdates) {
return null;
}
return snippetsUpdates as SnippetReorderUpdate[];
}
+896
View File
@@ -0,0 +1,896 @@
import { randomUUID } from "node:crypto";
import {
and,
asc,
desc,
eq,
gte,
inArray,
isNull,
like,
or,
sql,
} from "drizzle-orm";
import type { PluginDatabase } from "@termix/plugin-sdk/backend";
export interface SnippetRecord {
id: number;
userId: string;
name: string;
content: string;
description: string | null;
folder: string | null;
order: number;
syncId: string | null;
createdAt: string;
updatedAt: string;
hostFilter: string | null;
isNote: boolean;
}
export interface SnippetFolderRecord {
id: number;
userId: string;
name: string;
color: string | null;
icon: string | null;
syncId: string | null;
createdAt: string;
updatedAt: string;
}
export interface RenameSnippetFolderResult {
status: "renamed" | "missing" | "conflict";
}
export interface SnippetReorderUpdate {
id: number;
order: number;
folder?: string;
}
export interface NewSnippetInput {
name: string;
content: string;
description?: string | null;
folder?: string | null;
order?: number | null;
hostFilter?: unknown;
isNote?: boolean;
}
export interface SnippetUpdateInput {
name?: string;
content?: string;
description?: string | null;
folder?: string | null;
order?: number;
hostFilter?: unknown;
isNote?: boolean;
}
export interface UpdateSnippetResult {
existing: SnippetRecord;
updated: SnippetRecord;
}
export interface SnippetBulkImportResult {
snippetsImported: number;
snippetsSkipped: number;
snippetsUpdated: number;
foldersImported: number;
foldersSkipped: number;
failed: number;
errors: string[];
}
interface BulkImportSnippetInput {
name?: unknown;
content?: unknown;
description?: string | null;
folder?: string | null;
order?: unknown;
hostFilter?: string | null;
}
interface BulkImportFolderInput {
name?: unknown;
color?: string | null;
icon?: string | null;
}
export interface RbacAccessListItem {
id: number;
targetType: "user" | "role";
userId: string | null;
roleId: number | null;
username: string | null;
roleName: string | null;
roleDisplayName: string | null;
grantedBy: string;
grantedByUsername: string | null;
permissionLevel: string;
expiresAt: string | null;
createdAt: string;
}
export type SnippetAccessTarget =
| { targetType: "user"; targetUserId: string }
| { targetType: "role"; targetRoleId: number };
export type UpsertSnippetAccessInput = SnippetAccessTarget & {
snippetId: number;
grantedBy: string;
expiresAt: string | null;
};
export interface RbacSharedSnippet {
id: number;
name: string;
content: string;
description: string | null;
folder: string | null;
ownerUsername: string;
permissionLevel: string;
expiresAt: string | null;
}
export interface RbacVisibleSharedSnippet extends RbacSharedSnippet {
userId: string;
order: number;
createdAt: string;
updatedAt: string;
isNote: boolean;
}
export interface RbacAccessibleSnippet extends RbacVisibleSharedSnippet {
hostFilter: string | null;
}
/* eslint-disable @typescript-eslint/no-explicit-any */
// The tables come from ctx.db.define/ctx.db.refs, which the SDK hands back
// untyped, and the drizzle handle is the server's own. Typed at this module's
// edge instead.
type Table = any;
type Drizzle = any;
/* eslint-enable @typescript-eslint/no-explicit-any */
function isNonEmptyString(value: unknown): value is string {
return typeof value === "string" && value.trim().length > 0;
}
function toAccessListItem(
row: Omit<RbacAccessListItem, "targetType">,
): RbacAccessListItem {
return { ...row, targetType: row.userId ? "user" : "role" };
}
export type SnippetRepository = ReturnType<typeof createSnippetRepository>;
/**
* Snippet, folder and sharing storage. Written without RETURNING so it runs
* the same on all three engines: an insert or update is read back by id/sync
* id rather than relying on `.returning()`.
*/
export function createSnippetRepository(
db: PluginDatabase,
snippets: Table,
folders: Table,
access: Table,
) {
const client = () => db.client<Drizzle>();
const refs = () =>
db.refs<{ users: Table; roles: Table; userRoles: Table }>();
async function findOwnedById(
userId: string,
snippetId: number,
): Promise<SnippetRecord | null> {
const drizzle = await client();
const rows = await drizzle
.select()
.from(snippets)
.where(and(eq(snippets.id, snippetId), eq(snippets.userId, userId)))
.limit(1);
return rows[0] ?? null;
}
async function findFolderByName(
userId: string,
name: string,
): Promise<SnippetFolderRecord | null> {
const drizzle = await client();
const rows = await drizzle
.select()
.from(folders)
.where(and(eq(folders.userId, userId), eq(folders.name, name)))
.limit(1);
return rows[0] ?? null;
}
async function maxOrderForFolder(
userId: string,
folder: string | null,
): Promise<number> {
const drizzle = await client();
const result = await drizzle
.select({ maxOrder: sql<number>`MAX(${snippets.order})` })
.from(snippets)
.where(
and(
eq(snippets.userId, userId),
folder
? eq(snippets.folder, folder)
: sql`(${snippets.folder} IS NULL OR ${snippets.folder} = '')`,
),
);
return result[0]?.maxOrder ?? -1;
}
async function nextOrderForFolder(
userId: string,
folder: string,
): Promise<number> {
return (await maxOrderForFolder(userId, folder || null)) + 1;
}
async function findByNameAndFolder(
userId: string,
name: string,
folder: string | null,
): Promise<SnippetRecord | null> {
const drizzle = await client();
const rows = await drizzle
.select()
.from(snippets)
.where(
and(
eq(snippets.userId, userId),
eq(snippets.name, name),
folder
? eq(snippets.folder, folder)
: sql`(${snippets.folder} IS NULL OR ${snippets.folder} = '')`,
),
)
.limit(1);
return rows[0] ?? null;
}
function userOrRoleAccessFilter(userId: string, roleIds: number[]) {
if (roleIds.length === 0) return eq(access.userId, userId);
return or(eq(access.userId, userId), inArray(access.roleId, roleIds));
}
async function findSnippetAccess(
snippetId: number,
target: SnippetAccessTarget,
) {
const drizzle = await client();
const rows = await drizzle
.select()
.from(access)
.where(
and(
eq(access.snippetId, snippetId),
target.targetType === "user"
? eq(access.userId, target.targetUserId)
: eq(access.roleId, target.targetRoleId),
),
)
.limit(1);
return rows[0] ?? null;
}
return {
findOwnedById,
async listFolders(userId: string): Promise<SnippetFolderRecord[]> {
const drizzle = await client();
return drizzle
.select()
.from(folders)
.where(eq(folders.userId, userId))
.orderBy(asc(folders.name));
},
async listSnippetsForExport(userId: string): Promise<SnippetRecord[]> {
const drizzle = await client();
return drizzle
.select()
.from(snippets)
.where(eq(snippets.userId, userId))
.orderBy(sql`coalesce(${snippets.folder}, '')`, asc(snippets.order));
},
async listFoldersForExport(userId: string): Promise<SnippetFolderRecord[]> {
const drizzle = await client();
return drizzle
.select()
.from(folders)
.where(eq(folders.userId, userId))
.orderBy(asc(folders.name));
},
async listOwnedSnippetsInFolder(
userId: string,
folder: string,
): Promise<SnippetRecord[]> {
const drizzle = await client();
return drizzle
.select()
.from(snippets)
.where(
and(
eq(snippets.userId, userId),
or(
eq(snippets.folder, folder),
like(snippets.folder, `${folder} / %`),
),
),
);
},
async listOwnedSnippets(userId: string): Promise<SnippetRecord[]> {
const drizzle = await client();
return drizzle
.select()
.from(snippets)
.where(eq(snippets.userId, userId))
.orderBy(
sql`CASE WHEN ${snippets.folder} IS NULL OR ${snippets.folder} = '' THEN 0 ELSE 1 END`,
asc(snippets.folder),
asc(snippets.order),
sql`${snippets.updatedAt} DESC`,
);
},
async reorderSnippets(
userId: string,
updates: SnippetReorderUpdate[],
): Promise<void> {
const drizzle = await client();
for (const update of updates) {
const { id, order, folder } = update;
if (!id || order === undefined) continue;
const updateFields: Partial<{ order: number; folder: string | null }> =
{
order,
};
if (folder !== undefined) updateFields.folder = folder?.trim() || null;
await drizzle
.update(snippets)
.set(updateFields)
.where(and(eq(snippets.id, id), eq(snippets.userId, userId)));
}
await db.persist();
},
async createSnippet(
userId: string,
input: NewSnippetInput,
): Promise<SnippetRecord> {
const folderValue = input.folder?.trim() || "";
const order =
input.order === undefined || input.order === null
? await nextOrderForFolder(userId, folderValue)
: input.order;
const syncId = randomUUID();
const drizzle = await client();
await drizzle.insert(snippets).values({
syncId,
userId,
name: input.name.trim(),
content: input.content.trim(),
description: input.description?.trim() || null,
folder: input.folder?.trim() || null,
order,
hostFilter: input.hostFilter ? JSON.stringify(input.hostFilter) : null,
isNote: input.isNote ?? false,
});
await db.persist();
const rows = await drizzle
.select()
.from(snippets)
.where(eq(snippets.syncId, syncId))
.limit(1);
return rows[0];
},
async updateSnippet(
userId: string,
snippetId: number,
input: SnippetUpdateInput,
): Promise<UpdateSnippetResult | null> {
const existing = await findOwnedById(userId, snippetId);
if (!existing) return null;
const updateFields: Record<string, unknown> = {
updatedAt: sql`CURRENT_TIMESTAMP`,
};
if (input.name !== undefined) updateFields.name = input.name.trim();
if (input.content !== undefined)
updateFields.content = input.content.trim();
if (input.description !== undefined)
updateFields.description = input.description?.trim() || null;
if (input.folder !== undefined)
updateFields.folder = input.folder?.trim() || null;
if (input.order !== undefined) updateFields.order = input.order;
if (input.hostFilter !== undefined)
updateFields.hostFilter = input.hostFilter
? JSON.stringify(input.hostFilter)
: null;
if (input.isNote !== undefined) updateFields.isNote = input.isNote;
const drizzle = await client();
await drizzle
.update(snippets)
.set(updateFields)
.where(and(eq(snippets.id, snippetId), eq(snippets.userId, userId)));
await db.persist();
const updated = await findOwnedById(userId, snippetId);
return { existing, updated: updated as SnippetRecord };
},
async deleteSnippet(
userId: string,
snippetId: number,
): Promise<SnippetRecord | null> {
const existing = await findOwnedById(userId, snippetId);
if (!existing) return null;
const drizzle = await client();
await drizzle
.delete(snippets)
.where(and(eq(snippets.id, snippetId), eq(snippets.userId, userId)));
await db.persist();
return existing;
},
async bulkImport(
userId: string,
snippetsToImport: unknown[] | undefined,
foldersToImport: unknown[] | undefined,
overwrite: boolean,
): Promise<SnippetBulkImportResult> {
const results: SnippetBulkImportResult = {
snippetsImported: 0,
snippetsSkipped: 0,
snippetsUpdated: 0,
foldersImported: 0,
foldersSkipped: 0,
failed: 0,
errors: [],
};
const drizzle = await client();
let changed = false;
if (Array.isArray(foldersToImport)) {
for (const rawFolder of foldersToImport) {
const folder = rawFolder as BulkImportFolderInput;
if (!isNonEmptyString(folder.name)) {
results.failed++;
results.errors.push(`Folder missing name`);
continue;
}
const existing = await findFolderByName(userId, folder.name);
if (existing) {
results.foldersSkipped++;
continue;
}
await drizzle.insert(folders).values({
syncId: randomUUID(),
userId,
name: folder.name.trim(),
color: folder.color?.trim() || null,
icon: folder.icon?.trim() || null,
});
changed = true;
results.foldersImported++;
}
}
if (Array.isArray(snippetsToImport)) {
for (let i = 0; i < snippetsToImport.length; i++) {
const snippet = snippetsToImport[i] as BulkImportSnippetInput;
if (
!isNonEmptyString(snippet.name) ||
!isNonEmptyString(snippet.content)
) {
results.failed++;
results.errors.push(
`Snippet ${i + 1}: name and content are required`,
);
continue;
}
const folderVal = snippet.folder?.trim() || null;
const existing = await findByNameAndFolder(
userId,
snippet.name.trim(),
folderVal,
);
if (existing) {
if (!overwrite) {
results.snippetsSkipped++;
continue;
}
await drizzle
.update(snippets)
.set({
content: snippet.content.trim(),
description: snippet.description?.trim() || null,
folder: folderVal,
order:
typeof snippet.order === "number"
? snippet.order
: existing.order,
hostFilter: snippet.hostFilter || null,
updatedAt: sql`CURRENT_TIMESTAMP`,
})
.where(
and(eq(snippets.id, existing.id), eq(snippets.userId, userId)),
);
changed = true;
results.snippetsUpdated++;
continue;
}
const maxOrder = await maxOrderForFolder(userId, folderVal);
await drizzle.insert(snippets).values({
syncId: randomUUID(),
userId,
name: snippet.name.trim(),
content: snippet.content.trim(),
description: snippet.description?.trim() || null,
folder: folderVal,
order:
typeof snippet.order === "number" ? snippet.order : maxOrder + 1,
hostFilter: snippet.hostFilter || null,
});
changed = true;
results.snippetsImported++;
}
}
if (changed) await db.persist();
return results;
},
async createFolder(
userId: string,
name: string,
color: string | null | undefined,
icon: string | null | undefined,
): Promise<SnippetFolderRecord | null> {
const existing = await findFolderByName(userId, name);
if (existing) return null;
const syncId = randomUUID();
const drizzle = await client();
await drizzle.insert(folders).values({
syncId,
userId,
name: name.trim(),
color: color?.trim() || null,
icon: icon?.trim() || null,
});
await db.persist();
const rows = await drizzle
.select()
.from(folders)
.where(eq(folders.syncId, syncId))
.limit(1);
return rows[0] ?? null;
},
async updateFolderMetadata(
userId: string,
name: string,
color: string | null | undefined,
icon: string | null | undefined,
): Promise<SnippetFolderRecord | null> {
const existing = await findFolderByName(userId, name);
if (!existing) return null;
const updateFields: Record<string, unknown> = {
updatedAt: sql`CURRENT_TIMESTAMP`,
};
if (color !== undefined) updateFields.color = color?.trim() || null;
if (icon !== undefined) updateFields.icon = icon?.trim() || null;
const drizzle = await client();
await drizzle
.update(folders)
.set(updateFields)
.where(and(eq(folders.userId, userId), eq(folders.name, name)));
await db.persist();
return findFolderByName(userId, name);
},
async renameFolder(
userId: string,
oldName: string,
newName: string,
): Promise<RenameSnippetFolderResult> {
const existing = await findFolderByName(userId, oldName);
if (!existing) return { status: "missing" };
const nameExists = await findFolderByName(userId, newName);
if (nameExists) return { status: "conflict" };
const drizzle = await client();
await drizzle
.update(folders)
.set({ name: newName, updatedAt: sql`CURRENT_TIMESTAMP` })
.where(and(eq(folders.userId, userId), eq(folders.name, oldName)));
await drizzle
.update(snippets)
.set({ folder: newName })
.where(and(eq(snippets.userId, userId), eq(snippets.folder, oldName)));
await db.persist();
return { status: "renamed" };
},
async deleteFolder(
userId: string,
name: string,
): Promise<{ syncId: string | null } | null> {
const drizzle = await client();
await drizzle
.update(snippets)
.set({ folder: null })
.where(and(eq(snippets.userId, userId), eq(snippets.folder, name)));
const existing = await findFolderByName(userId, name);
if (!existing) {
await db.persist();
return null;
}
await drizzle
.delete(folders)
.where(and(eq(folders.userId, userId), eq(folders.name, name)));
await db.persist();
return { syncId: existing.syncId };
},
/** Wipes a user's snippets and folders, for the password-reset data-wipe flow. */
async deleteByUserId(userId: string): Promise<void> {
const drizzle = await client();
await drizzle.delete(snippets).where(eq(snippets.userId, userId));
await drizzle.delete(folders).where(eq(folders.userId, userId));
await db.persist();
},
// Sharing (moved from core's RbacAccessRepository)
async listSnippetAccess(snippetId: number): Promise<RbacAccessListItem[]> {
const drizzle = await client();
const { users, roles } = await refs();
const rows = await drizzle
.select({
id: access.id,
userId: access.userId,
roleId: access.roleId,
username: users.username,
roleName: roles.name,
roleDisplayName: roles.displayName,
grantedBy: access.grantedBy,
grantedByUsername: sql<
string | null
>`(SELECT username FROM users WHERE id = ${access.grantedBy})`,
permissionLevel: access.permissionLevel,
expiresAt: access.expiresAt,
createdAt: access.createdAt,
})
.from(access)
.leftJoin(users, eq(access.userId, users.id))
.leftJoin(roles, eq(access.roleId, roles.id))
.where(eq(access.snippetId, snippetId))
.orderBy(desc(access.createdAt));
return rows.map(toAccessListItem);
},
async upsertSnippetAccess(
input: UpsertSnippetAccessInput,
): Promise<{ id: number; created: boolean }> {
const existing = await findSnippetAccess(input.snippetId, input);
const drizzle = await client();
if (existing) {
await drizzle
.update(access)
.set({ expiresAt: input.expiresAt })
.where(eq(access.id, existing.id));
await db.persist();
return { id: existing.id, created: false };
}
const now = new Date().toISOString();
await drizzle.insert(access).values({
snippetId: input.snippetId,
userId: input.targetType === "user" ? input.targetUserId : null,
roleId: input.targetType === "role" ? input.targetRoleId : null,
grantedBy: input.grantedBy,
permissionLevel: "view",
expiresAt: input.expiresAt,
createdAt: now,
});
await db.persist();
const created = await findSnippetAccess(input.snippetId, input);
return { id: created!.id, created: true };
},
async revokeSnippetAccess(
accessId: number,
snippetId: number,
): Promise<void> {
const drizzle = await client();
await drizzle
.delete(access)
.where(and(eq(access.id, accessId), eq(access.snippetId, snippetId)));
await db.persist();
},
async listSharedSnippets(
userId: string,
roleIds: number[],
now = new Date().toISOString(),
): Promise<RbacSharedSnippet[]> {
const drizzle = await client();
const { users } = await refs();
const directShared = await drizzle
.select({
id: snippets.id,
name: snippets.name,
content: snippets.content,
description: snippets.description,
folder: snippets.folder,
ownerUsername: users.username,
permissionLevel: access.permissionLevel,
expiresAt: access.expiresAt,
})
.from(access)
.innerJoin(snippets, eq(access.snippetId, snippets.id))
.innerJoin(users, eq(snippets.userId, users.id))
.where(
and(
eq(access.userId, userId),
or(isNull(access.expiresAt), gte(access.expiresAt, now)),
),
);
if (roleIds.length === 0) return directShared;
const directIds = new Set(directShared.map((s: { id: number }) => s.id));
const roleShared = await drizzle
.select({
id: snippets.id,
name: snippets.name,
content: snippets.content,
description: snippets.description,
folder: snippets.folder,
ownerUsername: users.username,
permissionLevel: access.permissionLevel,
expiresAt: access.expiresAt,
})
.from(access)
.innerJoin(snippets, eq(access.snippetId, snippets.id))
.innerJoin(users, eq(snippets.userId, users.id))
.where(
and(
or(isNull(access.expiresAt), gte(access.expiresAt, now)),
inArray(access.roleId, roleIds),
),
);
return [
...directShared,
...roleShared.filter((s: { id: number }) => !directIds.has(s.id)),
];
},
async listVisibleSharedSnippets(
userId: string,
roleIds: number[],
now = new Date().toISOString(),
): Promise<RbacVisibleSharedSnippet[]> {
const drizzle = await client();
const { users } = await refs();
return drizzle
.select({
id: snippets.id,
userId: snippets.userId,
name: snippets.name,
content: snippets.content,
description: snippets.description,
folder: snippets.folder,
order: snippets.order,
createdAt: snippets.createdAt,
updatedAt: snippets.updatedAt,
isNote: snippets.isNote,
ownerUsername: users.username,
permissionLevel: access.permissionLevel,
expiresAt: access.expiresAt,
})
.from(access)
.innerJoin(snippets, eq(access.snippetId, snippets.id))
.innerJoin(users, eq(snippets.userId, users.id))
.where(
and(
userOrRoleAccessFilter(userId, roleIds),
or(isNull(access.expiresAt), gte(access.expiresAt, now)),
),
);
},
async findAccessibleSharedSnippet(
snippetId: number,
userId: string,
roleIds: number[],
now = new Date().toISOString(),
): Promise<RbacAccessibleSnippet | null> {
const drizzle = await client();
const { users } = await refs();
const rows = await drizzle
.select({
id: snippets.id,
userId: snippets.userId,
name: snippets.name,
content: snippets.content,
description: snippets.description,
folder: snippets.folder,
order: snippets.order,
createdAt: snippets.createdAt,
updatedAt: snippets.updatedAt,
hostFilter: snippets.hostFilter,
isNote: snippets.isNote,
ownerUsername: users.username,
permissionLevel: access.permissionLevel,
expiresAt: access.expiresAt,
})
.from(access)
.innerJoin(snippets, eq(access.snippetId, snippets.id))
.innerJoin(users, eq(snippets.userId, users.id))
.where(
and(
eq(access.snippetId, snippetId),
userOrRoleAccessFilter(userId, roleIds),
or(isNull(access.expiresAt), gte(access.expiresAt, now)),
),
)
.limit(1);
return rows[0] ?? null;
},
/** A user's role ids, for the sharing filters above. */
async listUserRoleIds(userId: string): Promise<number[]> {
const drizzle = await client();
const { userRoles } = await refs();
const rows = await drizzle
.select({ roleId: userRoles.roleId })
.from(userRoles)
.where(eq(userRoles.userId, userId));
return rows.map((r: { roleId: number }) => r.roleId);
},
};
}
File diff suppressed because it is too large. Load diff
+73
View File
@@ -0,0 +1,73 @@
import {
adoptLegacyTable,
boolean,
defineTable,
id,
integer,
refUser,
text,
timestamp,
varchar,
} from "@termix/plugin-sdk/db";
export const snippets = adoptLegacyTable(
"snippets",
defineTable(
"snippets",
{
id: id(),
userId: refUser(),
name: text().notNull(),
content: text().notNull(),
description: text(),
folder: text(),
order: integer().notNull().default(0),
syncId: varchar().unique(),
createdAt: timestamp().notNull().defaultNow(),
updatedAt: timestamp().notNull().defaultNow(),
hostFilter: text(),
isNote: boolean().notNull().default(false),
},
{ indexes: [{ name: "idx_snippets_user_id", columns: ["userId"] }] },
),
);
export const snippetFolders = adoptLegacyTable(
"snippet_folders",
defineTable("snippet_folders", {
id: id(),
userId: refUser(),
name: text().notNull(),
color: text(),
icon: text(),
syncId: varchar().unique(),
createdAt: timestamp().notNull().defaultNow(),
updatedAt: timestamp().notNull().defaultNow(),
}),
);
export const snippetAccess = adoptLegacyTable(
"snippet_access",
defineTable(
"snippet_access",
{
id: id(),
snippetId: integer().notNull(),
userId: varchar(),
roleId: integer(),
grantedBy: refUser(),
permissionLevel: text().notNull().default("view"),
expiresAt: timestamp(),
createdAt: timestamp().notNull().defaultNow(),
},
{
indexes: [
{ name: "idx_snippet_access_user_id", columns: ["userId"] },
{ name: "idx_snippet_access_snippet_id", columns: ["snippetId"] },
{ name: "idx_snippet_access_role_id", columns: ["roleId"] },
],
},
),
);
export const tables = [snippets, snippetFolders, snippetAccess];
@@ -0,0 +1,742 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import {
usePluginApi,
useTranslation,
useSettings,
usePermission,
type PanelProps,
} from "@termix/plugin-sdk/frontend";
import { toast } from "sonner";
import {
ChevronDown,
ChevronRight,
Copy,
Download,
Folder,
FolderPlus,
Pencil,
Play,
Plus,
Search,
Share2,
Trash2,
Upload,
} from "lucide-react";
import {
Badge,
Button,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
Input,
Select2,
Switch,
Textarea,
} from "@termix/plugin-sdk/ui";
import { createSnippetsApi } from "./snippets-api";
import { useSnippetRunner } from "./use-snippet-runner";
import {
errorMessage,
FOLDER_ICONS,
type Snippet,
type SnippetFolder,
} from "./types";
const ROOT_FOLDER = "__root__";
interface EditState {
id: number | null;
name: string;
content: string;
description: string;
folder: string;
isNote: boolean;
}
const EMPTY_EDIT: EditState = {
id: null,
name: "",
content: "",
description: "",
folder: "",
isNote: false,
};
function groupByFolder(snippets: Snippet[]): Map<string, Snippet[]> {
const groups = new Map<string, Snippet[]>();
for (const snippet of snippets) {
const key = snippet.folder || ROOT_FOLDER;
const list = groups.get(key) ?? [];
list.push(snippet);
groups.set(key, list);
}
return groups;
}
function SnippetEditDialog({
open,
initial,
folders,
onClose,
onSave,
}: {
open: boolean;
initial: EditState;
folders: SnippetFolder[];
onClose: () => void;
onSave: (state: EditState) => Promise<void>;
}) {
const { t } = useTranslation();
const [state, setState] = useState<EditState>(initial);
const [saving, setSaving] = useState(false);
useEffect(() => {
if (open) setState(initial);
}, [open, initial]);
const isEdit = initial.id !== null;
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>
{t(isEdit ? "editSnippetTitle" : "createSnippetTitle")}
</DialogTitle>
<DialogDescription>
{t(isEdit ? "editSnippetDescription" : "createSnippetDescription")}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold">{t("nameLabel")}</label>
<Input
value={state.name}
placeholder={t("namePlaceholder")}
onChange={(e) =>
setState((s) => ({ ...s, name: e.target.value }))
}
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold">
{t("descriptionLabel")}
</label>
<Input
value={state.description}
placeholder={t("descriptionPlaceholder")}
onChange={(e) =>
setState((s) => ({ ...s, description: e.target.value }))
}
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold">{t("folderLabel")}</label>
<Select2
value={state.folder}
onChange={(e) =>
setState((s) => ({ ...s, folder: e.target.value }))
}
>
<option value="">{t("noFolder")}</option>
{folders.map((f) => (
<option key={f.id} value={f.name}>
{f.name}
</option>
))}
</Select2>
</div>
<div className="flex items-center justify-between">
<label className="text-xs font-semibold">{t("typeLabel")}</label>
<div className="flex items-center gap-2 text-xs">
<span>{t("typeCommand")}</span>
<Switch
checked={state.isNote}
onCheckedChange={(checked) =>
setState((s) => ({ ...s, isNote: checked }))
}
/>
<span>{t("typeNote")}</span>
</div>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold">
{t(state.isNote ? "noteLabel" : "commandLabel")}
</label>
<Textarea
className="min-h-32 font-mono text-xs"
value={state.content}
placeholder={t(
state.isNote ? "notePlaceholder" : "commandPlaceholder",
)}
onChange={(e) =>
setState((s) => ({ ...s, content: e.target.value }))
}
/>
</div>
</div>
<DialogFooter>
<Button variant="ghost" onClick={onClose}>
{t("cancel")}
</Button>
<Button
disabled={saving || !state.name.trim() || !state.content.trim()}
onClick={async () => {
setSaving(true);
try {
await onSave(state);
} finally {
setSaving(false);
}
}}
>
{t(isEdit ? "saveSnippetButton" : "createSnippetButton")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function FolderEditDialog({
open,
initial,
onClose,
onSave,
}: {
open: boolean;
initial: {
name: string;
color: string | null;
icon: string | null;
isEdit: boolean;
};
onClose: () => void;
onSave: (
name: string,
color: string | null,
icon: string | null,
) => Promise<void>;
}) {
const { t } = useTranslation();
const [name, setName] = useState(initial.name);
const [icon, setIcon] = useState(initial.icon ?? FOLDER_ICONS[0]);
const [saving, setSaving] = useState(false);
useEffect(() => {
if (open) {
setName(initial.name);
setIcon(initial.icon ?? FOLDER_ICONS[0]);
}
}, [open, initial]);
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>
{t(initial.isEdit ? "editFolderTitle" : "createFolderTitle")}
</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold">
{t("folderNameLabel")}
</label>
<Input
value={name}
placeholder={t("folderNamePlaceholder")}
onChange={(e) => setName(e.target.value)}
disabled={initial.isEdit}
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold">
{t("folderIconLabel")}
</label>
<Select2 value={icon} onChange={(e) => setIcon(e.target.value)}>
{FOLDER_ICONS.map((id) => (
<option key={id} value={id}>
{id}
</option>
))}
</Select2>
</div>
</div>
<DialogFooter>
<Button variant="ghost" onClick={onClose}>
{t("cancel")}
</Button>
<Button
disabled={saving || !name.trim()}
onClick={async () => {
setSaving(true);
try {
await onSave(name.trim(), null, icon);
} finally {
setSaving(false);
}
}}
>
{t(initial.isEdit ? "saveFolderButton" : "createFolderButton")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export function SnippetsPanel({ active: _active }: PanelProps) {
const { t } = useTranslation();
const api = usePluginApi();
const client = useMemo(() => createSnippetsApi(api), [api]);
const canView = usePermission("view");
const canCreate = usePermission("create");
const canEdit = usePermission("edit");
const canDelete = usePermission("delete");
const { runOnActive, dialog: runnerDialog } = useSnippetRunner();
const collapsedSetting = useSettings("user");
const [collapsedFolders, setCollapsedFolders] = useState<Set<string>>(
new Set(),
);
const defaultCollapsed = collapsedSetting.values.foldersCollapsed !== false;
const [snippets, setSnippets] = useState<Snippet[]>([]);
const [folders, setFolders] = useState<SnippetFolder[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState("");
const [editState, setEditState] = useState<EditState | null>(null);
const [folderEditState, setFolderEditState] = useState<{
name: string;
color: string | null;
icon: string | null;
isEdit: boolean;
} | null>(null);
const load = useCallback(async () => {
if (!canView) {
setLoading(false);
return;
}
setLoading(true);
try {
const [snippetList, folderList] = await Promise.all([
client.list(),
client.listFolders(),
]);
setSnippets(snippetList);
setFolders(folderList);
} catch {
toast.error(t("loading"));
} finally {
setLoading(false);
}
}, [canView, client, t]);
useEffect(() => {
void load();
}, [load]);
const filtered = useMemo(() => {
const query = search.trim().toLowerCase();
if (!query) return snippets;
return snippets.filter(
(s) =>
s.name.toLowerCase().includes(query) ||
s.description?.toLowerCase().includes(query),
);
}, [snippets, search]);
const grouped = useMemo(() => groupByFolder(filtered), [filtered]);
function isCollapsed(folder: string): boolean {
if (collapsedFolders.has(folder)) return !defaultCollapsed ? false : true;
return defaultCollapsed;
}
function toggleFolder(folder: string) {
setCollapsedFolders((prev) => {
const next = new Set(prev);
if (next.has(folder)) next.delete(folder);
else next.add(folder);
return next;
});
}
async function handleSaveSnippet(state: EditState) {
try {
if (state.id !== null) {
await client.update(state.id, {
name: state.name.trim(),
content: state.content.trim(),
description: state.description.trim() || null,
folder: state.folder || null,
isNote: state.isNote,
});
toast.success(t("updateSuccess"));
} else {
await client.create({
name: state.name.trim(),
content: state.content.trim(),
description: state.description.trim() || null,
folder: state.folder || null,
isNote: state.isNote,
});
toast.success(t("createSuccess"));
}
setEditState(null);
await load();
} catch (err) {
toast.error(
errorMessage(
err,
t(state.id !== null ? "updateFailed" : "createFailed"),
),
);
}
}
async function handleDeleteSnippet(snippet: Snippet) {
try {
await client.remove(snippet.id);
await load();
} catch (err) {
toast.error(errorMessage(err, t("deleteFailed")));
}
}
async function handleSaveFolder(
name: string,
color: string | null,
icon: string | null,
) {
try {
if (folderEditState?.isEdit) {
await client.updateFolderMetadata(name, { color, icon });
toast.success(t("folderEditSuccess"));
} else {
await client.createFolder({ name, color, icon });
toast.success(t("folderCreateSuccess"));
}
setFolderEditState(null);
await load();
} catch (err) {
toast.error(
errorMessage(
err,
t(
folderEditState?.isEdit ? "folderEditFailed" : "folderCreateFailed",
),
),
);
}
}
async function handleDeleteFolder(folder: SnippetFolder) {
try {
await client.deleteFolder(folder.name);
toast.success(t("folderDeleteSuccess"));
await load();
} catch {
toast.error(t("folderDeleteFailed"));
}
}
async function handleCopy(snippet: Snippet) {
try {
await navigator.clipboard.writeText(snippet.content);
toast.success(t("copySuccess"));
} catch {
// Clipboard access denied; nothing further to do.
}
}
async function handleExport() {
try {
const data = await client.export();
const blob = new Blob([JSON.stringify(data, null, 2)], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = "snippets-export.json";
link.click();
URL.revokeObjectURL(url);
} catch {
// Export failed silently; nothing to recover client-side.
}
}
async function handleImport(file: File) {
try {
const text = await file.text();
const data = JSON.parse(text);
await client.bulkImport({
snippets: data.snippets,
folders: data.folders,
overwrite: false,
});
await load();
} catch {
toast.error(t("importFailed"));
}
}
if (!canView) {
return null;
}
const folderKeys = Array.from(grouped.keys()).sort((a, b) => {
if (a === ROOT_FOLDER) return -1;
if (b === ROOT_FOLDER) return 1;
return a.localeCompare(b);
});
return (
<div className="flex flex-1 min-h-0 flex-col">
<div className="flex items-center gap-2 p-2 border-b border-border">
<div className="relative flex-1">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground" />
<Input
className="pl-7 h-8"
value={search}
placeholder={t("searchPlaceholder")}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
{canCreate && (
<>
<Button
size="icon"
variant="ghost"
title={t("newFolder")}
onClick={() =>
setFolderEditState({
name: "",
color: null,
icon: null,
isEdit: false,
})
}
>
<FolderPlus className="size-4" />
</Button>
<Button
size="icon"
variant="ghost"
title={t("newSnippet")}
onClick={() => setEditState({ ...EMPTY_EDIT })}
>
<Plus className="size-4" />
</Button>
</>
)}
</div>
<div className="flex items-center gap-2 px-2 py-1.5 border-b border-border text-xs">
<Button size="sm" variant="ghost" onClick={handleExport}>
<Download className="size-3.5 mr-1" />
{t("importExport")}
</Button>
<label className="inline-flex">
<input
type="file"
accept="application/json"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) void handleImport(file);
e.target.value = "";
}}
/>
<Button size="sm" variant="ghost" asChild>
<span>
<Upload className="size-3.5 mr-1" />
{t("importExport")}
</span>
</Button>
</label>
</div>
<div className="flex-1 min-h-0 overflow-y-auto">
{loading ? (
<div className="p-4 text-xs text-muted-foreground">
{t("loading")}
</div>
) : (
folderKeys.map((folderKey) => {
const items = grouped.get(folderKey) ?? [];
const folder = folders.find((f) => f.name === folderKey);
const isRoot = folderKey === ROOT_FOLDER;
const collapsed = isCollapsed(folderKey);
return (
<div key={folderKey}>
{!isRoot && (
<div className="flex items-center justify-between px-2 py-1.5 hover:bg-muted/40 group">
<button
className="flex items-center gap-1.5 flex-1 text-left text-xs font-semibold"
onClick={() => toggleFolder(folderKey)}
>
{collapsed ? (
<ChevronRight className="size-3.5" />
) : (
<ChevronDown className="size-3.5" />
)}
<Folder className="size-3.5" />
{folderKey}
<Badge variant="outline" className="ml-1 text-[10px]">
{items.length}
</Badge>
</button>
{canEdit && folder && (
<div className="hidden group-hover:flex items-center gap-1">
<button
onClick={() =>
setFolderEditState({
name: folder.name,
color: folder.color,
icon: folder.icon,
isEdit: true,
})
}
>
<Pencil className="size-3.5 text-muted-foreground" />
</button>
{canDelete && (
<button
onClick={() => void handleDeleteFolder(folder)}
>
<Trash2 className="size-3.5 text-muted-foreground" />
</button>
)}
</div>
)}
</div>
)}
{!collapsed && items.length === 0 && (
<div className="px-4 py-2 text-xs text-muted-foreground">
{t("noSnippetsInFolder")}
</div>
)}
{!collapsed &&
items.map((snippet) => (
<div
key={snippet.id}
className="flex items-center justify-between gap-2 px-3 py-1.5 hover:bg-muted/40 group"
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5 text-xs font-medium truncate">
{snippet.name}
{snippet.isShared && (
<Badge variant="outline" className="text-[10px]">
{t("shareTitle")}
</Badge>
)}
</div>
{snippet.description && (
<div className="text-[11px] text-muted-foreground truncate">
{snippet.description}
</div>
)}
</div>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100">
{!snippet.isNote && (
<button
title={t("run")}
onClick={() => runOnActive(snippet, null)}
>
<Play className="size-3.5 text-muted-foreground" />
</button>
)}
{snippet.isNote && (
<button
title={t("pasteToTerminal")}
onClick={() => runOnActive(snippet, null)}
>
<Copy className="size-3.5 text-muted-foreground" />
</button>
)}
<button
title={t("copySuccess")}
onClick={() => void handleCopy(snippet)}
>
<Copy className="size-3.5 text-muted-foreground" />
</button>
{canEdit && !snippet.isShared && (
<button
title={t("editSnippetTitle")}
onClick={() =>
setEditState({
id: snippet.id,
name: snippet.name,
content: snippet.content,
description: snippet.description ?? "",
folder: snippet.folder ?? "",
isNote: snippet.isNote,
})
}
>
<Pencil className="size-3.5 text-muted-foreground" />
</button>
)}
{!snippet.isShared && (
<button title={t("shareTitle")}>
<Share2 className="size-3.5 text-muted-foreground" />
</button>
)}
{canDelete && !snippet.isShared && (
<button
title={t("deleteFailed")}
onClick={() => void handleDeleteSnippet(snippet)}
>
<Trash2 className="size-3.5 text-muted-foreground" />
</button>
)}
</div>
</div>
))}
</div>
);
})
)}
</div>
<div className="flex items-center justify-between px-2 py-1.5 border-t border-border text-xs">
<span>{t("foldersCollapsedLabel")}</span>
<Switch
checked={defaultCollapsed}
onCheckedChange={(checked) => {
setCollapsedFolders(new Set());
void collapsedSetting.save({ foldersCollapsed: checked });
}}
/>
</div>
{editState && (
<SnippetEditDialog
open
initial={editState}
folders={folders}
onClose={() => setEditState(null)}
onSave={handleSaveSnippet}
/>
)}
{folderEditState && (
<FolderEditDialog
open
initial={folderEditState}
onClose={() => setFolderEditState(null)}
onSave={handleSaveFolder}
/>
)}
{runnerDialog}
</div>
);
}
+67
View File
@@ -0,0 +1,67 @@
import { Play } from "lucide-react";
import type { PanelProps, TermixApp } from "@termix/plugin-sdk/frontend";
import { SnippetsPanel } from "./SnippetsPanel";
import { createSnippetsApi } from "./snippets-api";
import { resolveSnippetContent, hasSnippetInputs } from "./snippet-variables";
export function activate(app: TermixApp): void {
app.registerRailItem({
id: "snippets",
icon: Play,
titleKey: "nav.snippets",
permission: "view",
});
app.registerPanel("snippets", (props: PanelProps) => (
<SnippetsPanel {...props} />
));
app.registerPaletteEntry({
id: "snippets.run",
titleKey: "nav.snippets",
scope: "global",
icon: Play,
run: async (shell) => {
shell.openRailView("snippets");
},
});
app.registerSlotContribution("onboarding.features", {
actionId: "snippets.feature",
titleKey: "onboarding.feature_snippets",
descriptionKey: "onboarding.feature_snippets_desc",
icon: Play,
});
// Resolves a snippet by id for core's terminal (startup snippet, custom
// keybindings), so core never imports snippet code or data directly.
app.registerAction("snippets.resolveForTerminal", (async (
snippetId: number,
host: {
ip?: string;
username?: string;
port?: number;
name?: string;
} | null,
inputValues?: Record<string, string>,
) => {
const api = createSnippetsApi(app.api);
const snippet = await api.get(snippetId).catch(() => null);
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,
};
}) as never);
}
@@ -0,0 +1,75 @@
export interface SnippetInput {
key: string;
label: string;
}
export interface SnippetHostContext {
ip?: string;
username?: string;
port?: number | string;
name?: string;
}
const INPUT_PATTERN =
/\$\{INPUT_(\d+)(?::([^}$]+))?\}|\$INPUT_(\d+)(?![a-zA-Z0-9_])/g;
export function extractSnippetInputs(content: string): SnippetInput[] {
const seen = new Map<string, SnippetInput>();
let match: RegExpExecArray | null;
INPUT_PATTERN.lastIndex = 0;
while ((match = INPUT_PATTERN.exec(content)) !== null) {
const digits = match[1] ?? match[3];
const key = `INPUT_${digits}`;
if (!seen.has(key)) {
seen.set(key, { key, label: match[2]?.trim() || `Input ${digits}` });
}
}
return Array.from(seen.values());
}
export function hasSnippetInputs(content: string): boolean {
INPUT_PATTERN.lastIndex = 0;
return INPUT_PATTERN.test(content);
}
function replaceVar(
content: string,
name: string,
value: string | undefined,
): string {
if (value === undefined) return content;
const pattern = new RegExp(`\\$\\{?${name}\\}?`, "g");
return content.replace(pattern, value);
}
export function resolveSnippetContent(
content: string,
host: SnippetHostContext | 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;
}
@@ -0,0 +1,125 @@
import type { PluginApiClient } from "@termix/plugin-sdk/frontend";
import type { Snippet, SnippetAccessEntry, SnippetFolder } from "./types";
export interface NewSnippetInput {
name: string;
content: string;
description?: string | null;
folder?: string | null;
order?: number | null;
hostFilter?: unknown;
isNote?: boolean;
}
export interface SnippetUpdateInput {
name?: string;
content?: string;
description?: string | null;
folder?: string | null;
order?: number;
hostFilter?: unknown;
isNote?: boolean;
}
export interface SnippetReorderUpdate {
id: number;
order: number;
folder?: string;
}
export interface SnippetExecutionResult {
success: boolean;
output: string;
error?: string;
}
export interface SnippetShareInput {
targetType: "user" | "role";
targetUserId?: string;
targetRoleId?: number;
expiresAt?: string | null;
}
/**
* The snippets routes, through the plugin's own client. Paths are relative
* to /plugin-api/snippets/, which the client already points at.
*/
export function createSnippetsApi(api: PluginApiClient) {
const data = async <T>(request: Promise<{ data: T }>) => (await request).data;
return {
list: () => data(api.get<Snippet[]>("/")),
get: (id: number) => data(api.get<Snippet>(`/${id}`)),
create: (body: NewSnippetInput) => data(api.post<Snippet>("/", body)),
update: (id: number, body: SnippetUpdateInput) =>
data(api.put<Snippet>(`/${id}`, body)),
remove: (id: number) => data(api.delete<{ success: boolean }>(`/${id}`)),
execute: (
snippetId: number,
hostId: number,
inputValues?: Record<string, string>,
) =>
data(
api.post<SnippetExecutionResult>("/execute", {
snippetId,
hostId,
inputValues,
}),
),
reorder: (updates: SnippetReorderUpdate[]) =>
data(api.put<{ success: boolean }>("/reorder", { snippets: updates })),
export: () =>
data(api.get<{ snippets: unknown[]; folders: unknown[] }>("/export")),
bulkImport: (body: {
snippets?: unknown[];
folders?: unknown[];
overwrite?: boolean;
}) => data(api.post<{ success: boolean }>("/bulk-import", body)),
listFolders: () => data(api.get<SnippetFolder[]>("/folders")),
createFolder: (body: {
name: string;
color?: string | null;
icon?: string | null;
}) => data(api.post<SnippetFolder>("/folders", body)),
updateFolderMetadata: (
name: string,
body: { color?: string | null; icon?: string | null },
) =>
data(
api.put<SnippetFolder>(
`/folders/${encodeURIComponent(name)}/metadata`,
body,
),
),
renameFolder: (oldName: string, newName: string) =>
data(
api.put<{ success: boolean }>("/folders/rename", { oldName, newName }),
),
deleteFolder: (name: string) =>
data(
api.delete<{ success: boolean }>(
`/folders/${encodeURIComponent(name)}`,
),
),
share: (snippetId: number, body: SnippetShareInput) =>
data(api.post<{ success: boolean }>(`/${snippetId}/share`, body)),
shareFolder: (folder: string, body: SnippetShareInput) =>
data(
api.put<{ success: boolean; snippetsShared: number }>("/folder/share", {
folder,
...body,
}),
),
getAccess: (snippetId: number) =>
data(api.get<SnippetAccessEntry[]>(`/${snippetId}/access`)),
revokeAccess: (snippetId: number, accessId: number) =>
data(
api.delete<{ success: boolean }>(`/${snippetId}/access/${accessId}`),
),
listShared: () => data(api.get<{ sharedSnippets: Snippet[] }>("/shared")),
};
}
export type SnippetsApi = ReturnType<typeof createSnippetsApi>;
+71
View File
@@ -0,0 +1,71 @@
export const FOLDER_ICONS = [
"folder",
"server",
"cloud",
"database",
"box",
"network",
"copy",
"settings",
"cpu",
"globe",
] as const;
export type FolderIconId = (typeof FOLDER_ICONS)[number];
export interface Snippet {
id: number;
userId: string;
name: string;
content: string;
description: string | null;
folder: string | null;
order: number;
hostFilter: string | null;
isNote: boolean;
isShared?: boolean;
createdAt: string;
updatedAt: string;
}
export interface SnippetFolder {
id: number;
userId: string;
name: string;
color: string | null;
icon: string | null;
createdAt: string;
updatedAt: string;
}
export interface SnippetAccessEntry {
id: number;
targetType: "user" | "role";
userId: string | null;
roleId: number | null;
username: string | null;
roleName: string | null;
roleDisplayName: string | null;
grantedBy: string;
grantedByUsername: string | null;
permissionLevel: string;
expiresAt: string | null;
createdAt: string;
}
export function errorMessage(error: unknown, fallback: string): string {
if (
error &&
typeof error === "object" &&
"response" in error &&
error.response &&
typeof error.response === "object" &&
"data" in error.response &&
error.response.data &&
typeof error.response.data === "object" &&
"error" in error.response.data &&
typeof error.response.data.error === "string"
) {
return error.response.data.error;
}
return fallback;
}
@@ -0,0 +1,164 @@
import { useCallback, useState } from "react";
import { useTranslation, invokeAction } from "@termix/plugin-sdk/frontend";
import { toast } from "sonner";
import { SnippetVariablesDialog } from "@termix/plugin-sdk/ui";
import {
hasSnippetInputs,
resolveSnippetContent,
type SnippetHostContext,
} from "./snippet-variables";
import type { Snippet } from "./types";
export interface RunTarget {
sessionId: string;
host: SnippetHostContext | null;
}
/**
* Shared "run this snippet against these terminal sessions" flow: resolves
* $HOST-style vars per target, prompts once for $INPUT_n placeholders when
* present, and gates on the confirm-before-running setting. Sends through
* the ssh-terminal plugin's terminal.sendToActive/sendToSession actions
* rather than touching a terminal ref directly, since core's tab state is
* not part of the plugin surface.
*/
export function useSnippetRunner() {
const { t } = useTranslation();
const [runningSnippet, setRunningSnippet] = useState<{
snippet: Snippet;
host: SnippetHostContext | null;
onConfirm: (
resolvedContent: string,
inputValues: Record<string, string>,
) => void;
} | null>(null);
const handleConfirmRun = useCallback(
(snippet: Snippet, execute: () => void) => {
const shouldConfirm =
localStorage.getItem("confirmSnippetExecution") === "true";
if (!shouldConfirm) {
execute();
return;
}
toast(t("confirmRunMessage", { name: snippet.name }), {
action: {
label: t("confirmRunButton"),
onClick: execute,
},
duration: 6000,
});
},
[t],
);
async function sendResolvedToTarget(
target: RunTarget,
snippet: Snippet,
inputValues: Record<string, string>,
) {
const content = resolveSnippetContent(
snippet.content,
target.host,
inputValues,
);
const run = !snippet.isNote;
await invokeAction("terminal.sendToSession", target.sessionId, content, {
run,
});
}
const runSnippet = useCallback(
(snippet: Snippet, targets: RunTarget[]) => {
const runWithInputs = (inputValues: Record<string, string>) => {
const doSend = () => {
Promise.all(
targets.map((target) =>
sendResolvedToTarget(target, snippet, inputValues),
),
)
.then(() => {
toast.success(
t(snippet.isNote ? "pasteSuccess" : "runSuccess", {
name: snippet.name,
count: targets.length,
}),
);
})
.catch(() => {});
};
if (snippet.isNote) doSend();
else handleConfirmRun(snippet, doSend);
};
if (hasSnippetInputs(snippet.content)) {
setRunningSnippet({
snippet,
host: targets[0]?.host ?? null,
onConfirm: (_resolvedContent, inputValues) => {
setRunningSnippet(null);
runWithInputs(inputValues);
},
});
} else {
runWithInputs({});
}
},
[handleConfirmRun, t],
);
const runOnActive = useCallback(
(snippet: Snippet, host: SnippetHostContext | null) => {
const runWithInputs = (inputValues: Record<string, string>) => {
const doSend = () => {
const content = resolveSnippetContent(
snippet.content,
host,
inputValues,
);
void invokeAction("terminal.sendToActive", content, {
run: !snippet.isNote,
}).then((sent) => {
if (sent === false) {
toast.error(t("noTerminalTabsOpen"));
return;
}
toast.success(
t(snippet.isNote ? "pasteSuccess" : "runSuccess", {
name: snippet.name,
count: 1,
}),
);
});
};
if (snippet.isNote) doSend();
else handleConfirmRun(snippet, doSend);
};
if (hasSnippetInputs(snippet.content)) {
setRunningSnippet({
snippet,
host,
onConfirm: (_resolvedContent, inputValues) => {
setRunningSnippet(null);
runWithInputs(inputValues);
},
});
} else {
runWithInputs({});
}
},
[handleConfirmRun, t],
);
const dialog = runningSnippet ? (
<SnippetVariablesDialog
snippet={runningSnippet.snippet as never}
host={runningSnippet.host}
onCancel={() => setRunningSnippet(null)}
onConfirm={runningSnippet.onConfirm}
/>
) : null;
return { runSnippet, runOnActive, handleConfirmRun, dialog };
}