mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
feat: rebuild workspaces as the reference plugin
This commit is contained in:
1 parent
d774df8b41
commit
f217d8db89
79 files changed
+32995
-2184
No files matched your search
@@ -29,6 +29,16 @@
|
||||
"dependencies": {
|
||||
"automations": "^1.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"workspaces": "^1.0.0"
|
||||
},
|
||||
"requires": [
|
||||
{
|
||||
"service": "workspaces.saved",
|
||||
"versionRange": "^1.0.0",
|
||||
"optional": true
|
||||
}
|
||||
],
|
||||
"providesSecret": [
|
||||
{
|
||||
"key": "api-key",
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import { setPluginSsh } from "./ssh.js";
|
||||
import { setPluginServices } from "./services.js";
|
||||
import { startAiService, stopAiService } from "./routes.js";
|
||||
import { createCurrentAiRepository } from "../../../../src/backend/database/repositories/factory.js";
|
||||
|
||||
export async function activate(ctx: PluginContext) {
|
||||
setPluginSsh(ctx.ssh);
|
||||
ctx.disposables.add(() => setPluginSsh(null));
|
||||
setPluginServices(ctx.services);
|
||||
ctx.disposables.add(() => setPluginServices(null));
|
||||
startAiService(ctx.http.router());
|
||||
|
||||
// Offered by reference, not copied: this hands over a resolver, so the key
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { PluginServices } from "@termix/plugin-sdk/backend";
|
||||
|
||||
let current: PluginServices | null = null;
|
||||
|
||||
/** Set in activate, cleared on deactivate. */
|
||||
export function setPluginServices(services: PluginServices | null): void {
|
||||
current = services;
|
||||
}
|
||||
|
||||
/** Another plugin's workspaces, as ctx.services.get("workspaces.saved") returns them. */
|
||||
interface SavedWorkspaces {
|
||||
list: () => Promise<Array<{ id: number; name: string; isDefault: boolean }>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The user's saved workspaces, or null when the workspaces plugin is off or
|
||||
* the user may not use it. Optional: the assistant works without it.
|
||||
*/
|
||||
export async function listSavedWorkspaces(
|
||||
userId: string,
|
||||
): Promise<Array<{ id: number; name: string; isDefault: boolean }> | null> {
|
||||
if (!current) return null;
|
||||
try {
|
||||
return await current
|
||||
.get<SavedWorkspaces>("workspaces.saved", { userId })
|
||||
.list();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -7,9 +7,9 @@ import {
|
||||
createCurrentHostRepository,
|
||||
createCurrentNetworkTopologyRepository,
|
||||
createCurrentSnippetRepository,
|
||||
createCurrentWorkspaceRepository,
|
||||
} from "../../../../../src/backend/database/repositories/factory.js";
|
||||
import { num, objectSchema, type AiTool } from "./types.js";
|
||||
import { listSavedWorkspaces } from "../services.js";
|
||||
|
||||
/**
|
||||
* Read tools project explicit fields rather than spreading rows. Redaction runs
|
||||
@@ -178,16 +178,9 @@ export const readTools: AiTool[] = [
|
||||
category: "read",
|
||||
parameters: objectSchema({}),
|
||||
handler: async (_args, context) => {
|
||||
const workspaces = await createCurrentWorkspaceRepository().listByUser(
|
||||
context.userId,
|
||||
);
|
||||
return {
|
||||
workspaces: workspaces.map((workspace: any) => ({
|
||||
id: workspace.id,
|
||||
name: workspace.name,
|
||||
isDefault: workspace.isDefault ?? false,
|
||||
})),
|
||||
};
|
||||
const workspaces = await listSavedWorkspaces(context.userId);
|
||||
if (workspaces === null) return { workspaces: [], unavailable: true };
|
||||
return { workspaces };
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -3,3 +3,13 @@
|
||||
## 1.0.0
|
||||
|
||||
Bundled with Termix 2.9.0.
|
||||
|
||||
- Owns its data: core's `user_workspaces` table is renamed to
|
||||
`p_workspaces_workspaces` on first activation, keeping every saved workspace.
|
||||
- New `workspaces.use` permission, given to the admin and user roles. Without
|
||||
it the rail item is hidden and the routes answer 403.
|
||||
- Saved workspaces sync between the desktop app and a server. The per-device
|
||||
"Last Session" does not.
|
||||
- Offers `workspaces.saved` to other plugins, used by the AI assistant.
|
||||
- A tab from a plugin that is off is kept as a placeholder when a workspace is
|
||||
applied, instead of being dropped.
|
||||
@@ -2,12 +2,15 @@
|
||||
|
||||
Save and restore named tab/split layouts per user.
|
||||
|
||||
Bundled with Termix and enabled by default.
|
||||
Bundled with Termix and enabled by default. This is the reference
|
||||
conversion: it imports only `@termix/plugin-sdk`, and "Converting a feature
|
||||
into a plugin" in packages/plugin-sdk/ARCHITECTURE.md points here for each step.
|
||||
|
||||
## Layout
|
||||
|
||||
manifest.json id, capabilities and what this plugin contributes
|
||||
src/backend/ activate(ctx) / deactivate(), plus the routes
|
||||
src/backend/ activate(ctx), the table, repository and routes
|
||||
migrations/ one .sql per dialect, generated from src/backend/tables.ts
|
||||
src/frontend/ the UI it registers
|
||||
locales/en.json its strings (English only; the rest are translated)
|
||||
tests/backend/ vitest, node
|
||||
@@ -18,5 +21,6 @@ Bundled with Termix and enabled by default.
|
||||
npm run build bundle into dist/
|
||||
npm run test run this plugin's tests
|
||||
npm run typecheck
|
||||
npx termix-plugin migrations <name> after changing src/backend/tables.ts
|
||||
|
||||
See packages/plugin-sdk/ARCHITECTURE.md for the contract this follows.
|
||||
@@ -41,7 +41,13 @@
|
||||
"lastSessionDescription": "Automatically updated with your most recent tab arrangement. Apply it to recover what you had open.",
|
||||
"lastUsed": "Last used {{time}}",
|
||||
"neverUsed": "Never applied",
|
||||
"tabsSkipped": "Workspace applied — {{count}} tab(s) skipped (host no longer exists): {{names}}"
|
||||
"tabsSkipped": "Workspace applied, but {{count}} tab(s) were skipped because their host no longer exists: {{names}}",
|
||||
"timeAgo": {
|
||||
"seconds": "{{count}}s ago",
|
||||
"minutes": "{{count}}m ago",
|
||||
"hours": "{{count}}h ago",
|
||||
"days": "{{count}}d ago"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -51,5 +57,11 @@
|
||||
"onboarding": {
|
||||
"workflow_workspaces": "Workspaces",
|
||||
"workflow_workspaces_desc": "Save a set of open tabs and reopen the whole layout later."
|
||||
},
|
||||
"permissions": {
|
||||
"use": {
|
||||
"title": "Use workspaces",
|
||||
"description": "Save, apply and manage saved tab layouts."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,13 @@
|
||||
"api": "1"
|
||||
},
|
||||
"capabilities": ["db:own", "network:serve", "ui:surface"],
|
||||
"provides": [
|
||||
{
|
||||
"service": "workspaces.saved",
|
||||
"version": "1.0.0",
|
||||
"permission": "workspaces.use"
|
||||
}
|
||||
],
|
||||
"locales": "locales",
|
||||
"platforms": ["linux", "win32", "darwin"],
|
||||
"contributes": {
|
||||
@@ -24,6 +31,14 @@
|
||||
"titleKey": "nav.workspaces",
|
||||
"icon": "LayoutTemplate"
|
||||
}
|
||||
],
|
||||
"permissions": [
|
||||
{
|
||||
"name": "use",
|
||||
"titleKey": "permissions.use.title",
|
||||
"descriptionKey": "permissions.use.description",
|
||||
"defaultRoles": ["admin", "user"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
-- workspaces 0001: adopt_user_workspaces
|
||||
-- Generated by termix-plugin migrations. Review before committing.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `user_workspaces` (
|
||||
`id` int AUTO_INCREMENT PRIMARY KEY,
|
||||
`user_id` varchar(255) NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`color` text,
|
||||
`icon` text,
|
||||
`kind` text NOT NULL DEFAULT ('manual'),
|
||||
`is_default` boolean NOT NULL DEFAULT false,
|
||||
`payload` text NOT NULL DEFAULT ('{}'),
|
||||
`sync_id` varchar(255) UNIQUE,
|
||||
`created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
|
||||
`updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
|
||||
`last_used_at` text,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
|
||||
);
|
||||
RENAME TABLE `user_workspaces` TO `p_workspaces_workspaces`;
|
||||
@@ -0,0 +1,20 @@
|
||||
-- workspaces 0001: adopt_user_workspaces
|
||||
-- Generated by termix-plugin migrations. Review before committing.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "user_workspaces" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"user_id" varchar(255) NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"color" text,
|
||||
"icon" text,
|
||||
"kind" text NOT NULL DEFAULT 'manual',
|
||||
"is_default" boolean NOT NULL DEFAULT false,
|
||||
"payload" text NOT NULL DEFAULT '{}',
|
||||
"sync_id" varchar(255) UNIQUE,
|
||||
"created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"last_used_at" text,
|
||||
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS "idx_user_workspaces_user_id" ON "user_workspaces" ("user_id");
|
||||
ALTER TABLE "user_workspaces" RENAME TO "p_workspaces_workspaces";
|
||||
@@ -0,0 +1,69 @@
|
||||
{
|
||||
"version": 1,
|
||||
"tables": {
|
||||
"workspaces": {
|
||||
"columns": {
|
||||
"id": {
|
||||
"type": "id",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"userId": {
|
||||
"type": "refUser",
|
||||
"length": 255,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"type": "text",
|
||||
"notNull": true
|
||||
},
|
||||
"color": {
|
||||
"type": "text"
|
||||
},
|
||||
"icon": {
|
||||
"type": "text"
|
||||
},
|
||||
"kind": {
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"defaultValue": "manual"
|
||||
},
|
||||
"isDefault": {
|
||||
"type": "boolean",
|
||||
"notNull": true,
|
||||
"defaultValue": false
|
||||
},
|
||||
"payload": {
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"defaultValue": "{}"
|
||||
},
|
||||
"syncId": {
|
||||
"type": "varchar",
|
||||
"length": 255,
|
||||
"unique": true
|
||||
},
|
||||
"createdAt": {
|
||||
"type": "timestamp",
|
||||
"notNull": true,
|
||||
"defaultNow": true
|
||||
},
|
||||
"updatedAt": {
|
||||
"type": "timestamp",
|
||||
"notNull": true,
|
||||
"defaultNow": true
|
||||
},
|
||||
"lastUsedAt": {
|
||||
"type": "timestamp"
|
||||
}
|
||||
},
|
||||
"indexes": [
|
||||
{
|
||||
"name": "idx_user_workspaces_user_id",
|
||||
"columns": ["userId"]
|
||||
}
|
||||
],
|
||||
"adopts": "user_workspaces"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
-- workspaces 0001: adopt_user_workspaces
|
||||
-- Generated by termix-plugin migrations. Review before committing.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "user_workspaces" (
|
||||
"id" integer PRIMARY KEY AUTOINCREMENT,
|
||||
"user_id" text NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"color" text,
|
||||
"icon" text,
|
||||
"kind" text NOT NULL DEFAULT 'manual',
|
||||
"is_default" integer NOT NULL DEFAULT 0,
|
||||
"payload" text NOT NULL DEFAULT '{}',
|
||||
"sync_id" text UNIQUE,
|
||||
"created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"last_used_at" text,
|
||||
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS "idx_user_workspaces_user_id" ON "user_workspaces" ("user_id");
|
||||
ALTER TABLE "user_workspaces" RENAME TO "p_workspaces_workspaces";
|
||||
@@ -1,11 +1,49 @@
|
||||
import type { Router } from "express";
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import { startWorkspacesService, stopWorkspacesService } from "./routes.js";
|
||||
import { workspaces } from "./tables.js";
|
||||
import { createWorkspaceRepository } from "./repository.js";
|
||||
import { registerWorkspaceRoutes } from "./routes.js";
|
||||
|
||||
/** What other plugins get from ctx.services.get("workspaces.saved"). */
|
||||
export interface WorkspacesService {
|
||||
/** The calling user's workspaces, without their layouts. */
|
||||
list: () => Promise<Array<{ id: number; name: string; isDefault: boolean }>>;
|
||||
}
|
||||
|
||||
export async function activate(ctx: PluginContext) {
|
||||
startWorkspacesService(ctx.http.router());
|
||||
const table = await ctx.db.define(workspaces);
|
||||
const repo = createWorkspaceRepository(ctx.db, table);
|
||||
|
||||
registerWorkspaceRoutes(ctx.http.router<Router>(), repo, ctx);
|
||||
|
||||
// The last_session row belongs to one install. Syncing it would leave the
|
||||
// user with two, one from each side.
|
||||
ctx.sync.registerEntity({
|
||||
type: "workspaces",
|
||||
table,
|
||||
order: 200,
|
||||
shouldSync: (row) => row.kind !== "last_session",
|
||||
});
|
||||
|
||||
const service: WorkspacesService = {
|
||||
list: async () => {
|
||||
const userId = ctx.currentActor();
|
||||
if (!userId) return [];
|
||||
const rows = await repo.listByUser(userId);
|
||||
return rows
|
||||
.filter((row) => row.kind === "manual")
|
||||
.map((row) => ({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
isDefault: !!row.isDefault,
|
||||
}));
|
||||
},
|
||||
};
|
||||
ctx.services.provide("workspaces.saved", service);
|
||||
|
||||
ctx.log.info("Workspaces routes mounted at /plugin-api/workspaces");
|
||||
}
|
||||
|
||||
export async function deactivate() {
|
||||
stopWorkspacesService();
|
||||
// Everything above was registered through ctx and is disposed by core.
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { and, eq, ne } from "drizzle-orm";
|
||||
import type { PluginDatabase } from "@termix/plugin-sdk/backend";
|
||||
|
||||
export type WorkspaceKind = "manual" | "last_session";
|
||||
|
||||
export interface WorkspaceRecord {
|
||||
id: number;
|
||||
userId: string;
|
||||
name: string;
|
||||
color: string | null;
|
||||
icon: string | null;
|
||||
kind: WorkspaceKind;
|
||||
isDefault: boolean;
|
||||
payload: string;
|
||||
syncId: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
lastUsedAt: string | null;
|
||||
}
|
||||
|
||||
export interface WorkspaceCreateInput {
|
||||
name: string;
|
||||
color?: string | null;
|
||||
icon?: string | null;
|
||||
payload: string;
|
||||
}
|
||||
|
||||
export interface WorkspaceUpdateInput {
|
||||
name?: string;
|
||||
color?: string | null;
|
||||
icon?: string | null;
|
||||
}
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
// The table comes from ctx.db.define, 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 */
|
||||
|
||||
export type WorkspaceRepository = ReturnType<typeof createWorkspaceRepository>;
|
||||
|
||||
/**
|
||||
* Per-user workspace storage.
|
||||
*
|
||||
* Written without RETURNING so it runs the same on all three engines: an
|
||||
* insert is read back by the sync id it was given, an update by its id.
|
||||
*/
|
||||
export function createWorkspaceRepository(db: PluginDatabase, table: Table) {
|
||||
const client = () => db.client<Drizzle>();
|
||||
|
||||
async function findById(
|
||||
userId: string,
|
||||
id: number,
|
||||
): Promise<WorkspaceRecord | null> {
|
||||
const drizzle = await client();
|
||||
const rows = await drizzle
|
||||
.select()
|
||||
.from(table)
|
||||
.where(and(eq(table.id, id), eq(table.userId, userId)))
|
||||
.limit(1);
|
||||
return (rows[0] as WorkspaceRecord) ?? null;
|
||||
}
|
||||
|
||||
async function findBySyncId(syncId: string): Promise<WorkspaceRecord> {
|
||||
const drizzle = await client();
|
||||
const rows = await drizzle
|
||||
.select()
|
||||
.from(table)
|
||||
.where(eq(table.syncId, syncId))
|
||||
.limit(1);
|
||||
return rows[0] as WorkspaceRecord;
|
||||
}
|
||||
|
||||
async function findLastSession(
|
||||
userId: string,
|
||||
): Promise<WorkspaceRecord | null> {
|
||||
const drizzle = await client();
|
||||
const rows = await drizzle
|
||||
.select()
|
||||
.from(table)
|
||||
.where(and(eq(table.userId, userId), eq(table.kind, "last_session")))
|
||||
.limit(1);
|
||||
return (rows[0] as WorkspaceRecord) ?? null;
|
||||
}
|
||||
|
||||
async function insert(
|
||||
userId: string,
|
||||
values: Omit<
|
||||
WorkspaceRecord,
|
||||
"id" | "userId" | "syncId" | "createdAt" | "updatedAt" | "lastUsedAt"
|
||||
>,
|
||||
now: string,
|
||||
): Promise<WorkspaceRecord> {
|
||||
const syncId = randomUUID();
|
||||
const drizzle = await client();
|
||||
await drizzle
|
||||
.insert(table)
|
||||
.values({ ...values, userId, syncId, createdAt: now, updatedAt: now });
|
||||
await db.persist();
|
||||
return findBySyncId(syncId);
|
||||
}
|
||||
|
||||
async function write(
|
||||
userId: string,
|
||||
id: number,
|
||||
values: Record<string, unknown>,
|
||||
): Promise<WorkspaceRecord | null> {
|
||||
const drizzle = await client();
|
||||
await drizzle
|
||||
.update(table)
|
||||
.set(values)
|
||||
.where(and(eq(table.id, id), eq(table.userId, userId)));
|
||||
await db.persist();
|
||||
return findById(userId, id);
|
||||
}
|
||||
|
||||
/** A manual workspace the user owns, or null. Last Session is not editable. */
|
||||
async function findManual(
|
||||
userId: string,
|
||||
id: number,
|
||||
): Promise<WorkspaceRecord | null> {
|
||||
const existing = await findById(userId, id);
|
||||
return existing && existing.kind === "manual" ? existing : null;
|
||||
}
|
||||
|
||||
return {
|
||||
findById,
|
||||
findLastSession,
|
||||
|
||||
async listByUser(userId: string): Promise<WorkspaceRecord[]> {
|
||||
const drizzle = await client();
|
||||
return drizzle.select().from(table).where(eq(table.userId, userId));
|
||||
},
|
||||
|
||||
async upsertLastSession(
|
||||
userId: string,
|
||||
payload: string,
|
||||
now = new Date().toISOString(),
|
||||
): Promise<WorkspaceRecord> {
|
||||
const existing = await findLastSession(userId);
|
||||
if (!existing) {
|
||||
return insert(
|
||||
userId,
|
||||
{
|
||||
name: "Last Session",
|
||||
color: null,
|
||||
icon: null,
|
||||
kind: "last_session",
|
||||
isDefault: false,
|
||||
payload,
|
||||
},
|
||||
now,
|
||||
);
|
||||
}
|
||||
return write(userId, existing.id, { payload, updatedAt: now });
|
||||
},
|
||||
|
||||
async create(
|
||||
userId: string,
|
||||
input: WorkspaceCreateInput,
|
||||
now = new Date().toISOString(),
|
||||
): Promise<WorkspaceRecord> {
|
||||
return insert(
|
||||
userId,
|
||||
{
|
||||
name: input.name,
|
||||
color: input.color ?? null,
|
||||
icon: input.icon ?? null,
|
||||
kind: "manual",
|
||||
isDefault: false,
|
||||
payload: input.payload,
|
||||
},
|
||||
now,
|
||||
);
|
||||
},
|
||||
|
||||
async update(
|
||||
userId: string,
|
||||
id: number,
|
||||
input: WorkspaceUpdateInput,
|
||||
now = new Date().toISOString(),
|
||||
): Promise<WorkspaceRecord | null> {
|
||||
const existing = await findManual(userId, id);
|
||||
if (!existing) return null;
|
||||
return write(userId, id, {
|
||||
name: input.name ?? existing.name,
|
||||
color: input.color === undefined ? existing.color : input.color,
|
||||
icon: input.icon === undefined ? existing.icon : input.icon,
|
||||
updatedAt: now,
|
||||
});
|
||||
},
|
||||
|
||||
async updateContent(
|
||||
userId: string,
|
||||
id: number,
|
||||
payload: string,
|
||||
now = new Date().toISOString(),
|
||||
): Promise<WorkspaceRecord | null> {
|
||||
if (!(await findManual(userId, id))) return null;
|
||||
return write(userId, id, { payload, updatedAt: now });
|
||||
},
|
||||
|
||||
async setDefault(
|
||||
userId: string,
|
||||
id: number,
|
||||
now = new Date().toISOString(),
|
||||
): Promise<WorkspaceRecord | null> {
|
||||
if (!(await findManual(userId, id))) return null;
|
||||
const drizzle = await client();
|
||||
await drizzle
|
||||
.update(table)
|
||||
.set({ isDefault: false, updatedAt: now })
|
||||
.where(
|
||||
and(
|
||||
eq(table.userId, userId),
|
||||
eq(table.isDefault, true),
|
||||
ne(table.id, id),
|
||||
),
|
||||
);
|
||||
return write(userId, id, { isDefault: true, updatedAt: now });
|
||||
},
|
||||
|
||||
async unsetDefault(
|
||||
userId: string,
|
||||
id: number,
|
||||
now = new Date().toISOString(),
|
||||
): Promise<WorkspaceRecord | null> {
|
||||
if (!(await findManual(userId, id))) return null;
|
||||
return write(userId, id, { isDefault: false, updatedAt: now });
|
||||
},
|
||||
|
||||
async touchLastUsed(
|
||||
userId: string,
|
||||
id: number,
|
||||
now = new Date().toISOString(),
|
||||
): Promise<void> {
|
||||
await write(userId, id, { lastUsedAt: now });
|
||||
},
|
||||
|
||||
async delete(userId: string, id: number): Promise<boolean> {
|
||||
if (!(await findManual(userId, id))) return false;
|
||||
const drizzle = await client();
|
||||
await drizzle
|
||||
.delete(table)
|
||||
.where(and(eq(table.id, id), eq(table.userId, userId)));
|
||||
await db.persist();
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,15 +1,6 @@
|
||||
import type { AuthenticatedRequest } from "../../../../src/types/index.js";
|
||||
import express, { type Request, type Response, type Router } from "express";
|
||||
import { databaseLogger } from "../../../../src/backend/utils/logger.js";
|
||||
import { AuthManager } from "../../../../src/backend/utils/auth-manager.js";
|
||||
import { createCurrentWorkspaceRepository } from "../../../../src/backend/database/repositories/factory.js";
|
||||
import type { WorkspaceRecord } from "../../../../src/backend/database/repositories/workspace-repository.js";
|
||||
|
||||
export const router = express.Router();
|
||||
|
||||
const authManager = AuthManager.getInstance();
|
||||
const authenticateJWT = authManager.createAuthMiddleware();
|
||||
const requireDataAccess = authManager.createDataAccessMiddleware();
|
||||
import type { Request, Response, Router } from "express";
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import type { WorkspaceRecord, WorkspaceRepository } from "./repository.js";
|
||||
|
||||
function isNonEmptyString(val: unknown): val is string {
|
||||
return typeof val === "string" && val.trim().length > 0;
|
||||
@@ -46,73 +37,93 @@ function serialize(record: WorkspaceRecord) {
|
||||
};
|
||||
}
|
||||
|
||||
function actor(ctx: PluginContext): string {
|
||||
// Core's plugin router authenticates every request and runs it as that user.
|
||||
return ctx.currentActor() as string;
|
||||
}
|
||||
|
||||
function logError(
|
||||
ctx: PluginContext,
|
||||
message: string,
|
||||
error: unknown,
|
||||
details: { operation: string; workspaceId?: number },
|
||||
): void {
|
||||
const target =
|
||||
details.workspaceId !== undefined ? ` ${details.workspaceId}` : "";
|
||||
ctx.log.error(
|
||||
`${message}${target} (${details.operation})`,
|
||||
error instanceof Error ? error : new Error(String(error)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /workspaces:
|
||||
* get:
|
||||
* summary: List the current user's saved workspaces
|
||||
* description: Returns every manual workspace plus the single auto-maintained "Last Session" workspace, each with a computed tabCount.
|
||||
* tags:
|
||||
* - Workspaces
|
||||
* responses:
|
||||
* 200:
|
||||
* description: List of workspaces.
|
||||
* Mounts the workspace routes on the plugin's router, which core serves at
|
||||
* /plugin-api/workspaces with auth in front. Every route needs workspaces.use,
|
||||
* so an admin can hide the feature per role.
|
||||
*/
|
||||
router.get(
|
||||
"/",
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
export function registerWorkspaceRoutes(
|
||||
router: Router,
|
||||
repo: WorkspaceRepository,
|
||||
ctx: PluginContext,
|
||||
): void {
|
||||
router.use(ctx.rbac.require("use") as never);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/workspaces:
|
||||
* get:
|
||||
* summary: List the current user's saved workspaces
|
||||
* description: Returns every manual workspace plus the single auto-maintained "Last Session" workspace, each with a computed tabCount.
|
||||
* tags:
|
||||
* - Workspaces
|
||||
* responses:
|
||||
* 200:
|
||||
* description: List of workspaces.
|
||||
*/
|
||||
router.get("/", async (req: Request, res: Response) => {
|
||||
const userId = actor(ctx);
|
||||
|
||||
try {
|
||||
const records =
|
||||
await createCurrentWorkspaceRepository().listByUser(userId);
|
||||
const records = await repo.listByUser(userId);
|
||||
res.json(records.map(serialize));
|
||||
} catch (err) {
|
||||
databaseLogger.error("Failed to list workspaces", err, {
|
||||
logError(ctx, "Failed to list workspaces", err, {
|
||||
operation: "workspace_list_failed",
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to list workspaces" });
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /workspaces:
|
||||
* post:
|
||||
* summary: Save the current tab arrangement as a new named workspace
|
||||
* tags:
|
||||
* - Workspaces
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* name:
|
||||
* type: string
|
||||
* color:
|
||||
* type: string
|
||||
* icon:
|
||||
* type: string
|
||||
* payload:
|
||||
* type: object
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Workspace created.
|
||||
* 400:
|
||||
* description: Invalid request body.
|
||||
*/
|
||||
router.post(
|
||||
"/",
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/workspaces:
|
||||
* post:
|
||||
* summary: Save the current tab arrangement as a new named workspace
|
||||
* tags:
|
||||
* - Workspaces
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* name:
|
||||
* type: string
|
||||
* color:
|
||||
* type: string
|
||||
* icon:
|
||||
* type: string
|
||||
* payload:
|
||||
* type: object
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Workspace created.
|
||||
* 400:
|
||||
* description: Invalid request body.
|
||||
*/
|
||||
router.post("/", async (req: Request, res: Response) => {
|
||||
const userId = actor(ctx);
|
||||
const { name, color, icon, payload } = req.body ?? {};
|
||||
|
||||
if (!isNonEmptyString(name)) {
|
||||
@@ -125,7 +136,7 @@ router.post(
|
||||
}
|
||||
|
||||
try {
|
||||
const created = await createCurrentWorkspaceRepository().create(userId, {
|
||||
const created = await repo.create(userId, {
|
||||
name: name.trim(),
|
||||
color: isNonEmptyString(color) ? color : null,
|
||||
icon: isNonEmptyString(icon) ? icon : null,
|
||||
@@ -133,77 +144,64 @@ router.post(
|
||||
});
|
||||
res.json(serialize(created));
|
||||
} catch (err) {
|
||||
databaseLogger.error("Failed to create workspace", err, {
|
||||
logError(ctx, "Failed to create workspace", err, {
|
||||
operation: "workspace_create_failed",
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to create workspace" });
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /workspaces/last-session:
|
||||
* get:
|
||||
* summary: Fetch the auto-maintained "Last Session" workspace
|
||||
* description: Returns null if the current session has never been auto-saved yet.
|
||||
* tags:
|
||||
* - Workspaces
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The Last Session workspace, or null.
|
||||
*/
|
||||
router.get(
|
||||
"/last-session",
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/workspaces/last-session:
|
||||
* get:
|
||||
* summary: Fetch the auto-maintained "Last Session" workspace
|
||||
* description: Returns null if the current session has never been auto-saved yet.
|
||||
* tags:
|
||||
* - Workspaces
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The Last Session workspace, or null.
|
||||
*/
|
||||
router.get("/last-session", async (req: Request, res: Response) => {
|
||||
const userId = actor(ctx);
|
||||
|
||||
try {
|
||||
const record =
|
||||
await createCurrentWorkspaceRepository().findLastSession(userId);
|
||||
const record = await repo.findLastSession(userId);
|
||||
res.json(record ? serialize(record) : null);
|
||||
} catch (err) {
|
||||
databaseLogger.error("Failed to fetch last session workspace", err, {
|
||||
logError(ctx, "Failed to fetch last session workspace", err, {
|
||||
operation: "workspace_last_session_get_failed",
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to fetch last session workspace" });
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /workspaces/last-session:
|
||||
* put:
|
||||
* summary: Upsert the auto-maintained "Last Session" workspace
|
||||
* description: Always overwrites the single Last Session row for the caller - never creates a second one. Called by the frontend's debounced auto-save effect.
|
||||
* tags:
|
||||
* - Workspaces
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* payload:
|
||||
* type: object
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Last Session workspace saved.
|
||||
* 400:
|
||||
* description: Invalid request body.
|
||||
*/
|
||||
router.put(
|
||||
"/last-session",
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/workspaces/last-session:
|
||||
* put:
|
||||
* summary: Upsert the auto-maintained "Last Session" workspace
|
||||
* description: Always overwrites the single Last Session row for the caller - never creates a second one. Called by the frontend's debounced auto-save effect.
|
||||
* tags:
|
||||
* - Workspaces
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* payload:
|
||||
* type: object
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Last Session workspace saved.
|
||||
* 400:
|
||||
* description: Invalid request body.
|
||||
*/
|
||||
router.put("/last-session", async (req: Request, res: Response) => {
|
||||
const userId = actor(ctx);
|
||||
const { payload } = req.body ?? {};
|
||||
|
||||
if (!isValidPayload(payload)) {
|
||||
@@ -213,49 +211,43 @@ router.put(
|
||||
}
|
||||
|
||||
try {
|
||||
const saved = await createCurrentWorkspaceRepository().upsertLastSession(
|
||||
const saved = await repo.upsertLastSession(
|
||||
userId,
|
||||
JSON.stringify(payload),
|
||||
);
|
||||
res.json(serialize(saved));
|
||||
} catch (err) {
|
||||
databaseLogger.error("Failed to save last session workspace", err, {
|
||||
logError(ctx, "Failed to save last session workspace", err, {
|
||||
operation: "workspace_last_session_save_failed",
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to save last session workspace" });
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /workspaces/{id}:
|
||||
* patch:
|
||||
* summary: Rename or recolor a workspace
|
||||
* description: Does not accept a payload - use PUT /workspaces/{id}/content to overwrite a workspace's saved tab arrangement. Rejects the Last Session workspace.
|
||||
* tags:
|
||||
* - Workspaces
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Workspace updated.
|
||||
* 400:
|
||||
* description: Invalid request, or target is the Last Session workspace.
|
||||
* 404:
|
||||
* description: Workspace not found.
|
||||
*/
|
||||
router.patch(
|
||||
"/:id",
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/workspaces/{id}:
|
||||
* patch:
|
||||
* summary: Rename or recolor a workspace
|
||||
* description: Does not accept a payload - use PUT /workspaces/{id}/content to overwrite a workspace's saved tab arrangement. Rejects the Last Session workspace.
|
||||
* tags:
|
||||
* - Workspaces
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Workspace updated.
|
||||
* 400:
|
||||
* description: Invalid request, or target is the Last Session workspace.
|
||||
* 404:
|
||||
* description: Workspace not found.
|
||||
*/
|
||||
router.patch("/:id", async (req: Request, res: Response) => {
|
||||
const userId = actor(ctx);
|
||||
const id = parseWorkspaceId(req.params.id);
|
||||
if (id === null) {
|
||||
return res.status(400).json({ error: "Invalid workspace ID" });
|
||||
@@ -267,68 +259,58 @@ router.patch(
|
||||
}
|
||||
|
||||
try {
|
||||
const updated = await createCurrentWorkspaceRepository().update(
|
||||
userId,
|
||||
id,
|
||||
{
|
||||
name: name !== undefined ? name.trim() : undefined,
|
||||
color,
|
||||
icon,
|
||||
},
|
||||
);
|
||||
const updated = await repo.update(userId, id, {
|
||||
name: name !== undefined ? name.trim() : undefined,
|
||||
color,
|
||||
icon,
|
||||
});
|
||||
|
||||
if (!updated) {
|
||||
return res.status(404).json({ error: "Workspace not found" });
|
||||
}
|
||||
res.json(serialize(updated));
|
||||
} catch (err) {
|
||||
databaseLogger.error("Failed to update workspace", err, {
|
||||
logError(ctx, "Failed to update workspace", err, {
|
||||
operation: "workspace_update_failed",
|
||||
userId,
|
||||
workspaceId: id,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to update workspace" });
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /workspaces/{id}/content:
|
||||
* put:
|
||||
* summary: Overwrite a workspace's saved tab arrangement with a new payload
|
||||
* description: Used by "Update with current" in the Workspaces panel. Rejects the Last Session workspace.
|
||||
* tags:
|
||||
* - Workspaces
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* payload:
|
||||
* type: object
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Workspace content updated.
|
||||
* 400:
|
||||
* description: Invalid request body.
|
||||
* 404:
|
||||
* description: Workspace not found.
|
||||
*/
|
||||
router.put(
|
||||
"/:id/content",
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/workspaces/{id}/content:
|
||||
* put:
|
||||
* summary: Overwrite a workspace's saved tab arrangement with a new payload
|
||||
* description: Used by "Update with current" in the Workspaces panel. Rejects the Last Session workspace.
|
||||
* tags:
|
||||
* - Workspaces
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* payload:
|
||||
* type: object
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Workspace content updated.
|
||||
* 400:
|
||||
* description: Invalid request body.
|
||||
* 404:
|
||||
* description: Workspace not found.
|
||||
*/
|
||||
router.put("/:id/content", async (req: Request, res: Response) => {
|
||||
const userId = actor(ctx);
|
||||
const id = parseWorkspaceId(req.params.id);
|
||||
if (id === null) {
|
||||
return res.status(400).json({ error: "Invalid workspace ID" });
|
||||
@@ -342,7 +324,7 @@ router.put(
|
||||
}
|
||||
|
||||
try {
|
||||
const updated = await createCurrentWorkspaceRepository().updateContent(
|
||||
const updated = await repo.updateContent(
|
||||
userId,
|
||||
id,
|
||||
JSON.stringify(payload),
|
||||
@@ -353,50 +335,44 @@ router.put(
|
||||
}
|
||||
res.json(serialize(updated));
|
||||
} catch (err) {
|
||||
databaseLogger.error("Failed to update workspace content", err, {
|
||||
logError(ctx, "Failed to update workspace content", err, {
|
||||
operation: "workspace_content_update_failed",
|
||||
userId,
|
||||
workspaceId: id,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to update workspace content" });
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /workspaces/{id}/duplicate:
|
||||
* post:
|
||||
* summary: Duplicate a workspace's content and color/icon under a new name
|
||||
* tags:
|
||||
* - Workspaces
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* name:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: New workspace created from the duplicate.
|
||||
* 404:
|
||||
* description: Workspace not found.
|
||||
*/
|
||||
router.post(
|
||||
"/:id/duplicate",
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/workspaces/{id}/duplicate:
|
||||
* post:
|
||||
* summary: Duplicate a workspace's content and color/icon under a new name
|
||||
* tags:
|
||||
* - Workspaces
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* name:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: New workspace created from the duplicate.
|
||||
* 404:
|
||||
* description: Workspace not found.
|
||||
*/
|
||||
router.post("/:id/duplicate", async (req: Request, res: Response) => {
|
||||
const userId = actor(ctx);
|
||||
const id = parseWorkspaceId(req.params.id);
|
||||
const { name } = req.body ?? {};
|
||||
if (id === null) {
|
||||
@@ -407,13 +383,12 @@ router.post(
|
||||
}
|
||||
|
||||
try {
|
||||
const repository = createCurrentWorkspaceRepository();
|
||||
const source = await repository.findById(userId, id);
|
||||
const source = await repo.findById(userId, id);
|
||||
if (!source) {
|
||||
return res.status(404).json({ error: "Workspace not found" });
|
||||
}
|
||||
|
||||
const created = await repository.create(userId, {
|
||||
const created = await repo.create(userId, {
|
||||
name: name.trim(),
|
||||
color: source.color,
|
||||
icon: source.icon,
|
||||
@@ -421,224 +396,181 @@ router.post(
|
||||
});
|
||||
res.json(serialize(created));
|
||||
} catch (err) {
|
||||
databaseLogger.error("Failed to duplicate workspace", err, {
|
||||
logError(ctx, "Failed to duplicate workspace", err, {
|
||||
operation: "workspace_duplicate_failed",
|
||||
userId,
|
||||
workspaceId: id,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to duplicate workspace" });
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /workspaces/{id}/set-default:
|
||||
* post:
|
||||
* summary: Mark a workspace as the restore-on-login default
|
||||
* description: Clears isDefault on any other workspace for the caller. Idempotent if the target is already the default. Rejects the Last Session workspace.
|
||||
* tags:
|
||||
* - Workspaces
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Workspace set as default.
|
||||
* 404:
|
||||
* description: Workspace not found.
|
||||
*/
|
||||
router.post(
|
||||
"/:id/set-default",
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/workspaces/{id}/set-default:
|
||||
* post:
|
||||
* summary: Mark a workspace as the restore-on-login default
|
||||
* description: Clears isDefault on any other workspace for the caller. Idempotent if the target is already the default. Rejects the Last Session workspace.
|
||||
* tags:
|
||||
* - Workspaces
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Workspace set as default.
|
||||
* 404:
|
||||
* description: Workspace not found.
|
||||
*/
|
||||
router.post("/:id/set-default", async (req: Request, res: Response) => {
|
||||
const userId = actor(ctx);
|
||||
const id = parseWorkspaceId(req.params.id);
|
||||
if (id === null) {
|
||||
return res.status(400).json({ error: "Invalid workspace ID" });
|
||||
}
|
||||
|
||||
try {
|
||||
const updated = await createCurrentWorkspaceRepository().setDefault(
|
||||
userId,
|
||||
id,
|
||||
);
|
||||
const updated = await repo.setDefault(userId, id);
|
||||
if (!updated) {
|
||||
return res.status(404).json({ error: "Workspace not found" });
|
||||
}
|
||||
res.json(serialize(updated));
|
||||
} catch (err) {
|
||||
databaseLogger.error("Failed to set default workspace", err, {
|
||||
logError(ctx, "Failed to set default workspace", err, {
|
||||
operation: "workspace_set_default_failed",
|
||||
userId,
|
||||
workspaceId: id,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to set default workspace" });
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /workspaces/{id}/unset-default:
|
||||
* post:
|
||||
* summary: Remove a workspace as the restore-on-login default
|
||||
* description: Idempotent if the target is not currently the default. Rejects the Last Session workspace.
|
||||
* tags:
|
||||
* - Workspaces
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Workspace unset as default.
|
||||
* 404:
|
||||
* description: Workspace not found.
|
||||
*/
|
||||
router.post(
|
||||
"/:id/unset-default",
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/workspaces/{id}/unset-default:
|
||||
* post:
|
||||
* summary: Remove a workspace as the restore-on-login default
|
||||
* description: Idempotent if the target is not currently the default. Rejects the Last Session workspace.
|
||||
* tags:
|
||||
* - Workspaces
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Workspace unset as default.
|
||||
* 404:
|
||||
* description: Workspace not found.
|
||||
*/
|
||||
router.post("/:id/unset-default", async (req: Request, res: Response) => {
|
||||
const userId = actor(ctx);
|
||||
const id = parseWorkspaceId(req.params.id);
|
||||
if (id === null) {
|
||||
return res.status(400).json({ error: "Invalid workspace ID" });
|
||||
}
|
||||
|
||||
try {
|
||||
const updated = await createCurrentWorkspaceRepository().unsetDefault(
|
||||
userId,
|
||||
id,
|
||||
);
|
||||
const updated = await repo.unsetDefault(userId, id);
|
||||
if (!updated) {
|
||||
return res.status(404).json({ error: "Workspace not found" });
|
||||
}
|
||||
res.json(serialize(updated));
|
||||
} catch (err) {
|
||||
databaseLogger.error("Failed to unset default workspace", err, {
|
||||
logError(ctx, "Failed to unset default workspace", err, {
|
||||
operation: "workspace_unset_default_failed",
|
||||
userId,
|
||||
workspaceId: id,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to unset default workspace" });
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /workspaces/{id}/apply:
|
||||
* post:
|
||||
* summary: Fetch a workspace to apply and mark it as just used
|
||||
* description: Returns the full workspace with its payload parsed, and touches lastUsedAt server-side so the caller does not need a second round trip.
|
||||
* tags:
|
||||
* - Workspaces
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The workspace to apply.
|
||||
* 404:
|
||||
* description: Workspace not found.
|
||||
*/
|
||||
router.post(
|
||||
"/:id/apply",
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/workspaces/{id}/apply:
|
||||
* post:
|
||||
* summary: Fetch a workspace to apply and mark it as just used
|
||||
* description: Returns the full workspace with its payload parsed, and touches lastUsedAt server-side so the caller does not need a second round trip.
|
||||
* tags:
|
||||
* - Workspaces
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The workspace to apply.
|
||||
* 404:
|
||||
* description: Workspace not found.
|
||||
*/
|
||||
router.post("/:id/apply", async (req: Request, res: Response) => {
|
||||
const userId = actor(ctx);
|
||||
const id = parseWorkspaceId(req.params.id);
|
||||
if (id === null) {
|
||||
return res.status(400).json({ error: "Invalid workspace ID" });
|
||||
}
|
||||
|
||||
try {
|
||||
const repository = createCurrentWorkspaceRepository();
|
||||
const record = await repository.findById(userId, id);
|
||||
const record = await repo.findById(userId, id);
|
||||
if (!record) {
|
||||
return res.status(404).json({ error: "Workspace not found" });
|
||||
}
|
||||
|
||||
await repository.touchLastUsed(userId, id);
|
||||
await repo.touchLastUsed(userId, id);
|
||||
res.json(serialize(record));
|
||||
} catch (err) {
|
||||
databaseLogger.error("Failed to apply workspace", err, {
|
||||
logError(ctx, "Failed to apply workspace", err, {
|
||||
operation: "workspace_apply_failed",
|
||||
userId,
|
||||
workspaceId: id,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to apply workspace" });
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /workspaces/{id}:
|
||||
* delete:
|
||||
* summary: Delete a workspace
|
||||
* description: Rejects the Last Session workspace, which is not user-deletable.
|
||||
* tags:
|
||||
* - Workspaces
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Workspace deleted.
|
||||
* 404:
|
||||
* description: Workspace not found.
|
||||
*/
|
||||
router.delete(
|
||||
"/:id",
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/workspaces/{id}:
|
||||
* delete:
|
||||
* summary: Delete a workspace
|
||||
* description: Rejects the Last Session workspace, which is not user-deletable.
|
||||
* tags:
|
||||
* - Workspaces
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Workspace deleted.
|
||||
* 404:
|
||||
* description: Workspace not found.
|
||||
*/
|
||||
router.delete("/:id", async (req: Request, res: Response) => {
|
||||
const userId = actor(ctx);
|
||||
const id = parseWorkspaceId(req.params.id);
|
||||
if (id === null) {
|
||||
return res.status(400).json({ error: "Invalid workspace ID" });
|
||||
}
|
||||
|
||||
try {
|
||||
const deleted = await createCurrentWorkspaceRepository().delete(
|
||||
userId,
|
||||
id,
|
||||
);
|
||||
const deleted = await repo.delete(userId, id);
|
||||
if (!deleted) {
|
||||
return res.status(404).json({ error: "Workspace not found" });
|
||||
}
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
databaseLogger.error("Failed to delete workspace", err, {
|
||||
logError(ctx, "Failed to delete workspace", err, {
|
||||
operation: "workspace_delete_failed",
|
||||
userId,
|
||||
workspaceId: id,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to delete workspace" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export function startWorkspacesService(mountOn: Router): void {
|
||||
mountOn.use(router);
|
||||
}
|
||||
|
||||
export function stopWorkspacesService(): void {
|
||||
// Unmounted by the runtime when the plugin deactivates.
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import {
|
||||
adoptLegacyTable,
|
||||
boolean,
|
||||
defineTable,
|
||||
id,
|
||||
refUser,
|
||||
text,
|
||||
timestamp,
|
||||
varchar,
|
||||
} from "@termix/plugin-sdk/db";
|
||||
|
||||
/**
|
||||
* Saved tab arrangements, one row per workspace plus one "last_session" row
|
||||
* per user that the frontend keeps current.
|
||||
*
|
||||
* Adopted from core's user_workspaces, so the column and index names are the
|
||||
* legacy ones: the rename carries the existing rows and index across.
|
||||
*/
|
||||
export const workspaces = adoptLegacyTable(
|
||||
"user_workspaces",
|
||||
defineTable(
|
||||
"workspaces",
|
||||
{
|
||||
id: id(),
|
||||
userId: refUser(),
|
||||
name: text().notNull(),
|
||||
color: text(),
|
||||
icon: text(),
|
||||
// "manual" | "last_session"
|
||||
kind: text().notNull().default("manual"),
|
||||
isDefault: boolean().notNull().default(false),
|
||||
// JSON: the shell layout from app.tabs.getLayout
|
||||
payload: text().notNull().default("{}"),
|
||||
syncId: varchar().unique(),
|
||||
createdAt: timestamp().notNull().defaultNow(),
|
||||
updatedAt: timestamp().notNull().defaultNow(),
|
||||
lastUsedAt: timestamp(),
|
||||
},
|
||||
{
|
||||
indexes: [{ name: "idx_user_workspaces_user_id", columns: ["userId"] }],
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
export const tables = [workspaces];
|
||||
@@ -1,6 +1,9 @@
|
||||
import { getErrorMessage } from "@/lib/error-message.js";
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
||||
import {
|
||||
usePluginApi,
|
||||
useTranslation,
|
||||
type ShellLayout,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
LayoutTemplate,
|
||||
@@ -12,40 +15,34 @@ import {
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/button";
|
||||
import { Input } from "@/components/input";
|
||||
import { Badge } from "@/components/badge";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from "@/components/dialog";
|
||||
import { FOLDER_COLORS } from "@/lib/theme";
|
||||
import {
|
||||
listWorkspaces,
|
||||
createWorkspace,
|
||||
renameWorkspace,
|
||||
updateWorkspaceContent,
|
||||
deleteWorkspace,
|
||||
duplicateWorkspace,
|
||||
setDefaultWorkspace,
|
||||
unsetDefaultWorkspace,
|
||||
} from "./workspaces-api";
|
||||
import type { Workspace, WorkspacePayload } from "@/types/ui-types";
|
||||
FOLDER_COLORS,
|
||||
Input,
|
||||
} from "@termix/plugin-sdk/ui";
|
||||
import { createWorkspacesApi, type WorkspacesApi } from "./workspaces-api";
|
||||
import { errorMessage, type Workspace } from "./types";
|
||||
|
||||
function timeAgo(isoStr: string): string {
|
||||
type Translate = (key: string, options?: Record<string, unknown>) => string;
|
||||
|
||||
function timeAgo(isoStr: string, t: Translate): string {
|
||||
const ms = Date.now() - new Date(isoStr).getTime();
|
||||
if (!Number.isFinite(ms)) return "";
|
||||
const prefix = "newUi.sidebar.workspaces.timeAgo";
|
||||
const sec = Math.floor(ms / 1000);
|
||||
if (sec < 60) return `${sec}s ago`;
|
||||
if (sec < 60) return t(`${prefix}.seconds`, { count: sec });
|
||||
const min = Math.floor(sec / 60);
|
||||
if (min < 60) return `${min}m ago`;
|
||||
if (min < 60) return t(`${prefix}.minutes`, { count: min });
|
||||
const hr = Math.floor(min / 60);
|
||||
if (hr < 24) return `${hr}h ago`;
|
||||
return `${Math.floor(hr / 24)}d ago`;
|
||||
if (hr < 24) return t(`${prefix}.hours`, { count: hr });
|
||||
return t(`${prefix}.days`, { count: Math.floor(hr / 24) });
|
||||
}
|
||||
|
||||
function WorkspaceSaveDialog({
|
||||
@@ -153,10 +150,12 @@ function WorkspaceSaveDialog({
|
||||
}
|
||||
|
||||
function WorkspaceRenameDialog({
|
||||
api,
|
||||
workspace,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
api: WorkspacesApi;
|
||||
workspace: Workspace | null;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
@@ -180,12 +179,12 @@ function WorkspaceRenameDialog({
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
await renameWorkspace(workspace.id, { name: name.trim(), color });
|
||||
await api.rename(workspace.id, { name: name.trim(), color });
|
||||
toast.success(t("newUi.sidebar.workspaces.workspaceUpdated"));
|
||||
onSaved();
|
||||
onClose();
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error, "");
|
||||
const message = errorMessage(error);
|
||||
toast.error(message || t("newUi.sidebar.workspaces.updateFailed"));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
@@ -309,7 +308,7 @@ function WorkspaceRow({
|
||||
? t("newUi.sidebar.workspaces.lastSessionDescription")
|
||||
: workspace.lastUsedAt
|
||||
? t("newUi.sidebar.workspaces.lastUsed", {
|
||||
time: timeAgo(workspace.lastUsedAt),
|
||||
time: timeAgo(workspace.lastUsedAt, t),
|
||||
})
|
||||
: t("newUi.sidebar.workspaces.neverUsed")}
|
||||
</span>
|
||||
@@ -390,10 +389,12 @@ export function WorkspacesPanel({
|
||||
onApplyWorkspace,
|
||||
}: {
|
||||
active?: boolean;
|
||||
currentPayload: () => WorkspacePayload;
|
||||
currentPayload: () => ShellLayout | null;
|
||||
onApplyWorkspace: (workspace: Workspace) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const pluginApi = usePluginApi();
|
||||
const api = useMemo(() => createWorkspacesApi(pluginApi), [pluginApi]);
|
||||
const [workspaces, setWorkspaces] = useState<Workspace[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saveDialogOpen, setSaveDialogOpen] = useState(false);
|
||||
@@ -404,13 +405,13 @@ export function WorkspacesPanel({
|
||||
|
||||
const loadWorkspaces = useCallback(async () => {
|
||||
try {
|
||||
const data = await listWorkspaces();
|
||||
const data = await api.list();
|
||||
setWorkspaces(data);
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error, "");
|
||||
const message = errorMessage(error);
|
||||
toast.error(message || t("newUi.sidebar.workspaces.loadFailed"));
|
||||
}
|
||||
}, [t]);
|
||||
}, [api, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active || hasLoadedRef.current) return;
|
||||
@@ -421,11 +422,13 @@ export function WorkspacesPanel({
|
||||
|
||||
async function handleSaveNew(name: string, color: string) {
|
||||
try {
|
||||
await createWorkspace({ name, color, payload: currentPayload() });
|
||||
const payload = currentPayload();
|
||||
if (!payload) return;
|
||||
await api.create({ name, color, payload });
|
||||
toast.success(t("newUi.sidebar.workspaces.workspaceCreated"));
|
||||
loadWorkspaces();
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error, "");
|
||||
const message = errorMessage(error);
|
||||
toast.error(message || t("newUi.sidebar.workspaces.saveFailed"));
|
||||
throw error;
|
||||
}
|
||||
@@ -433,18 +436,20 @@ export function WorkspacesPanel({
|
||||
|
||||
async function handleUpdateWithCurrent(workspace: Workspace) {
|
||||
try {
|
||||
await updateWorkspaceContent(workspace.id, currentPayload());
|
||||
const payload = currentPayload();
|
||||
if (!payload) return;
|
||||
await api.updateContent(workspace.id, payload);
|
||||
toast.success(t("newUi.sidebar.workspaces.workspaceUpdated"));
|
||||
loadWorkspaces();
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error, "");
|
||||
const message = errorMessage(error);
|
||||
toast.error(message || t("newUi.sidebar.workspaces.updateFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDuplicate(workspace: Workspace) {
|
||||
try {
|
||||
await duplicateWorkspace(
|
||||
await api.duplicate(
|
||||
workspace.id,
|
||||
t("newUi.sidebar.workspaces.duplicateNameSuffix", {
|
||||
name: workspace.name,
|
||||
@@ -453,7 +458,7 @@ export function WorkspacesPanel({
|
||||
toast.success(t("newUi.sidebar.workspaces.workspaceDuplicated"));
|
||||
loadWorkspaces();
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error, "");
|
||||
const message = errorMessage(error);
|
||||
toast.error(message || t("newUi.sidebar.workspaces.duplicateFailed"));
|
||||
}
|
||||
}
|
||||
@@ -461,13 +466,13 @@ export function WorkspacesPanel({
|
||||
async function handleToggleDefault(workspace: Workspace) {
|
||||
try {
|
||||
if (workspace.isDefault) {
|
||||
await unsetDefaultWorkspace(workspace.id);
|
||||
await api.unsetDefault(workspace.id);
|
||||
} else {
|
||||
await setDefaultWorkspace(workspace.id);
|
||||
await api.setDefault(workspace.id);
|
||||
}
|
||||
loadWorkspaces();
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error, "");
|
||||
const message = errorMessage(error);
|
||||
toast.error(message || t("newUi.sidebar.workspaces.updateFailed"));
|
||||
}
|
||||
}
|
||||
@@ -475,12 +480,12 @@ export function WorkspacesPanel({
|
||||
async function handleDelete() {
|
||||
if (!deleteTarget) return;
|
||||
try {
|
||||
await deleteWorkspace(deleteTarget.id);
|
||||
await api.remove(deleteTarget.id);
|
||||
toast.success(t("newUi.sidebar.workspaces.workspaceDeleted"));
|
||||
setDeleteTarget(null);
|
||||
loadWorkspaces();
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error, "");
|
||||
const message = errorMessage(error);
|
||||
toast.error(message || t("newUi.sidebar.workspaces.deleteFailed"));
|
||||
}
|
||||
}
|
||||
@@ -584,6 +589,7 @@ export function WorkspacesPanel({
|
||||
/>
|
||||
|
||||
<WorkspaceRenameDialog
|
||||
api={api}
|
||||
workspace={renameTarget}
|
||||
onClose={() => setRenameTarget(null)}
|
||||
onSaved={loadWorkspaces}
|
||||
|
||||
@@ -1,34 +1,28 @@
|
||||
import { LayoutTemplate } from "lucide-react";
|
||||
import i18next from "i18next";
|
||||
import { toast } from "sonner";
|
||||
import type { PanelProps, TermixApp } from "@termix/plugin-sdk/frontend";
|
||||
import type { Workspace, WorkspacePayload } from "@/types/ui-types";
|
||||
import { WorkspacesPanel } from "./WorkspacesPanel";
|
||||
import {
|
||||
applyWorkspaceServer,
|
||||
listWorkspaces,
|
||||
saveLastSessionWorkspace,
|
||||
} from "./workspaces-api";
|
||||
import { createWorkspacesApi } from "./workspaces-api";
|
||||
import type { Workspace } from "./types";
|
||||
|
||||
const LAST_SESSION_DELAY_MS = 2000;
|
||||
|
||||
export function activate(app: TermixApp): void {
|
||||
const api = createWorkspacesApi(app.api);
|
||||
|
||||
const applyWorkspace = async (workspace: Workspace) => {
|
||||
const { skipped } = await app.tabs.applyLayout(
|
||||
workspace.payload as unknown as Parameters<
|
||||
typeof app.tabs.applyLayout
|
||||
>[0],
|
||||
{ name: workspace.name },
|
||||
);
|
||||
const { skipped } = await app.tabs.applyLayout(workspace.payload, {
|
||||
name: workspace.name,
|
||||
});
|
||||
if (skipped.length > 0) {
|
||||
toast.warning(
|
||||
i18next.t("workspaces:newUi.sidebar.workspaces.tabsSkipped", {
|
||||
app.t("newUi.sidebar.workspaces.tabsSkipped", {
|
||||
count: skipped.length,
|
||||
names: skipped.join(", "),
|
||||
}),
|
||||
);
|
||||
}
|
||||
applyWorkspaceServer(workspace.id).catch(() => {});
|
||||
api.apply(workspace.id).catch(() => {});
|
||||
};
|
||||
|
||||
function Panel({ active }: PanelProps) {
|
||||
@@ -36,9 +30,7 @@ export function activate(app: TermixApp): void {
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||
<WorkspacesPanel
|
||||
active={active}
|
||||
currentPayload={() =>
|
||||
app.tabs.getLayout() as unknown as WorkspacePayload
|
||||
}
|
||||
currentPayload={() => app.tabs.getLayout()}
|
||||
onApplyWorkspace={(workspace) => void applyWorkspace(workspace)}
|
||||
/>
|
||||
</div>
|
||||
@@ -50,22 +42,32 @@ export function activate(app: TermixApp): void {
|
||||
icon: LayoutTemplate,
|
||||
titleKey: "nav.workspaces",
|
||||
after: "split-screen",
|
||||
permission: "use",
|
||||
});
|
||||
app.registerPanel("workspaces", Panel);
|
||||
|
||||
// Without workspaces.use the restore and the autosave below would only
|
||||
// collect 403s. The lookup is cached, so asking each time is cheap and a
|
||||
// revoke takes effect.
|
||||
const allowed = () => app.hasPermission("use");
|
||||
|
||||
// After login, a default workspace applies unless the session already
|
||||
// brought tabs back, which is the more precise restore.
|
||||
app.tabs.onReady(() => {
|
||||
const layout = app.tabs.getLayout() as unknown as WorkspacePayload | null;
|
||||
if (layout && layout.tabs.length > 0) return;
|
||||
listWorkspaces()
|
||||
.then((workspaces) => {
|
||||
const layout = app.tabs.getLayout() as { tabs?: unknown[] } | null;
|
||||
if (layout && (layout.tabs?.length ?? 0) > 0) return;
|
||||
void allowed().then(async (ok) => {
|
||||
if (!ok) return;
|
||||
try {
|
||||
const workspaces = await api.list();
|
||||
const preferred = workspaces.find(
|
||||
(workspace) => workspace.kind === "manual" && workspace.isDefault,
|
||||
);
|
||||
if (preferred) void applyWorkspace(preferred);
|
||||
})
|
||||
.catch(() => {});
|
||||
if (preferred) await applyWorkspace(preferred);
|
||||
} catch {
|
||||
// A failed restore leaves the empty session as it is.
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Keeps an implicit "last session" snapshot current, so an arrangement can
|
||||
@@ -75,11 +77,10 @@ export function activate(app: TermixApp): void {
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = setTimeout(() => {
|
||||
const layout = app.tabs.getLayout();
|
||||
if (layout) {
|
||||
saveLastSessionWorkspace(layout as unknown as WorkspacePayload).catch(
|
||||
() => {},
|
||||
);
|
||||
}
|
||||
if (!layout) return;
|
||||
void allowed().then((ok) => {
|
||||
if (ok) api.saveLastSession(layout).catch(() => {});
|
||||
});
|
||||
}, LAST_SESSION_DELAY_MS);
|
||||
});
|
||||
app.onDispose(() => {
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { ShellLayout } from "@termix/plugin-sdk/frontend";
|
||||
|
||||
export type WorkspaceKind = "manual" | "last_session";
|
||||
|
||||
/** A saved workspace as the routes return it. */
|
||||
export interface Workspace {
|
||||
id: number;
|
||||
userId: string;
|
||||
name: string;
|
||||
color: string | null;
|
||||
icon: string | null;
|
||||
kind: WorkspaceKind;
|
||||
isDefault: boolean;
|
||||
/** Whatever app.tabs.getLayout returned when it was saved. */
|
||||
payload: ShellLayout & { tabs?: unknown[] };
|
||||
syncId: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
lastUsedAt: string | null;
|
||||
tabCount: number;
|
||||
}
|
||||
|
||||
/** The server's error text when it sent one, else the error's own message. */
|
||||
export function errorMessage(error: unknown): string {
|
||||
const data = (error as { response?: { data?: { error?: unknown } } })
|
||||
?.response?.data;
|
||||
if (typeof data?.error === "string") return data.error;
|
||||
return "";
|
||||
}
|
||||
@@ -1,132 +1,38 @@
|
||||
import { authApi, handleApiError } from "@/main-axios";
|
||||
import type { Workspace, WorkspacePayload } from "@/types/ui-types";
|
||||
import type { PluginApiClient, ShellLayout } from "@termix/plugin-sdk/frontend";
|
||||
import type { Workspace } from "./types";
|
||||
|
||||
export async function listWorkspaces(): Promise<Workspace[]> {
|
||||
try {
|
||||
const response = await authApi.get("/plugin-api/workspaces");
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "fetch workspaces");
|
||||
}
|
||||
/**
|
||||
* The workspace routes, through the plugin's own client. Paths are relative
|
||||
* to /plugin-api/workspaces/, which the client already points at.
|
||||
*/
|
||||
export function createWorkspacesApi(api: PluginApiClient) {
|
||||
const data = async <T>(request: Promise<{ data: T }>) => (await request).data;
|
||||
|
||||
return {
|
||||
list: () => data(api.get<Workspace[]>("/")),
|
||||
create: (body: {
|
||||
name: string;
|
||||
color?: string | null;
|
||||
icon?: string | null;
|
||||
payload: ShellLayout;
|
||||
}) => data(api.post<Workspace>("/", body)),
|
||||
rename: (
|
||||
id: number,
|
||||
body: { name?: string; color?: string | null; icon?: string | null },
|
||||
) => data(api.patch<Workspace>(`/${id}`, body)),
|
||||
updateContent: (id: number, payload: ShellLayout) =>
|
||||
data(api.put<Workspace>(`/${id}/content`, { payload })),
|
||||
remove: (id: number) => data(api.delete<{ success: boolean }>(`/${id}`)),
|
||||
duplicate: (id: number, name: string) =>
|
||||
data(api.post<Workspace>(`/${id}/duplicate`, { name })),
|
||||
setDefault: (id: number) => data(api.post<Workspace>(`/${id}/set-default`)),
|
||||
unsetDefault: (id: number) =>
|
||||
data(api.post<Workspace>(`/${id}/unset-default`)),
|
||||
apply: (id: number) => data(api.post<Workspace>(`/${id}/apply`)),
|
||||
getLastSession: () => data(api.get<Workspace | null>("/last-session")),
|
||||
saveLastSession: (payload: ShellLayout) =>
|
||||
data(api.put<Workspace>("/last-session", { payload })),
|
||||
};
|
||||
}
|
||||
|
||||
export async function createWorkspace(data: {
|
||||
name: string;
|
||||
color?: string | null;
|
||||
icon?: string | null;
|
||||
payload: WorkspacePayload;
|
||||
}): Promise<Workspace> {
|
||||
try {
|
||||
const response = await authApi.post("/plugin-api/workspaces", data);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "create workspace");
|
||||
}
|
||||
}
|
||||
|
||||
export async function renameWorkspace(
|
||||
id: number,
|
||||
data: { name?: string; color?: string | null; icon?: string | null },
|
||||
): Promise<Workspace> {
|
||||
try {
|
||||
const response = await authApi.patch(`/plugin-api/workspaces/${id}`, data);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "update workspace");
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateWorkspaceContent(
|
||||
id: number,
|
||||
payload: WorkspacePayload,
|
||||
): Promise<Workspace> {
|
||||
try {
|
||||
const response = await authApi.put(`/plugin-api/workspaces/${id}/content`, {
|
||||
payload,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "update workspace content");
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteWorkspace(
|
||||
id: number,
|
||||
): Promise<{ success: boolean }> {
|
||||
try {
|
||||
const response = await authApi.delete(`/plugin-api/workspaces/${id}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "delete workspace");
|
||||
}
|
||||
}
|
||||
|
||||
export async function duplicateWorkspace(
|
||||
id: number,
|
||||
name: string,
|
||||
): Promise<Workspace> {
|
||||
try {
|
||||
const response = await authApi.post(
|
||||
`/plugin-api/workspaces/${id}/duplicate`,
|
||||
{
|
||||
name,
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "duplicate workspace");
|
||||
}
|
||||
}
|
||||
|
||||
export async function setDefaultWorkspace(id: number): Promise<Workspace> {
|
||||
try {
|
||||
const response = await authApi.post(
|
||||
`/plugin-api/workspaces/${id}/set-default`,
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "set default workspace");
|
||||
}
|
||||
}
|
||||
|
||||
export async function unsetDefaultWorkspace(id: number): Promise<Workspace> {
|
||||
try {
|
||||
const response = await authApi.post(
|
||||
`/plugin-api/workspaces/${id}/unset-default`,
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "unset default workspace");
|
||||
}
|
||||
}
|
||||
|
||||
export async function applyWorkspaceServer(id: number): Promise<Workspace> {
|
||||
try {
|
||||
const response = await authApi.post(`/plugin-api/workspaces/${id}/apply`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "apply workspace");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getLastSessionWorkspace(): Promise<Workspace | null> {
|
||||
try {
|
||||
const response = await authApi.get("/plugin-api/workspaces/last-session");
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "fetch last session workspace");
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveLastSessionWorkspace(
|
||||
payload: WorkspacePayload,
|
||||
): Promise<Workspace> {
|
||||
try {
|
||||
const response = await authApi.put("/plugin-api/workspaces/last-session", {
|
||||
payload,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "save last session workspace");
|
||||
}
|
||||
}
|
||||
export type WorkspacesApi = ReturnType<typeof createWorkspacesApi>;
|
||||
@@ -0,0 +1,76 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import express from "express";
|
||||
import {
|
||||
createMockCtx,
|
||||
createTestDb,
|
||||
type TestDb,
|
||||
} from "@termix/plugin-sdk/testing";
|
||||
import { PluginCapabilityError } from "@termix/plugin-sdk/backend";
|
||||
import { activate } from "../../src/backend/index.js";
|
||||
import { manifest, pluginDir, startServer, type TestServer } from "./helpers";
|
||||
|
||||
let db: TestDb | null = null;
|
||||
let server: TestServer | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
db?.close();
|
||||
db = null;
|
||||
await server?.close();
|
||||
server = null;
|
||||
});
|
||||
|
||||
describe("workspaces activate", () => {
|
||||
it("registers a sync entity that leaves the per-install last session out", async () => {
|
||||
server = await startServer();
|
||||
const [entity] = server.mock.syncEntities;
|
||||
expect(entity.type).toBe("workspaces");
|
||||
expect(entity.shouldSync?.({ kind: "manual" })).toBe(true);
|
||||
expect(entity.shouldSync?.({ kind: "last_session" })).toBe(false);
|
||||
});
|
||||
|
||||
it("offers the user's manual workspaces as a service", async () => {
|
||||
server = await startServer();
|
||||
await server.request("POST", "/", {
|
||||
body: { name: "Prod", payload: { version: 1, tabs: [] } },
|
||||
});
|
||||
await server.request("PUT", "/last-session", {
|
||||
body: { payload: { version: 1, tabs: [] } },
|
||||
});
|
||||
|
||||
const service = server.mock.services.get("workspaces.saved") as {
|
||||
list: () => Promise<unknown>;
|
||||
};
|
||||
const listed = await server.mock.ctx.asUser("user-1", () => service.list());
|
||||
expect(listed).toEqual([
|
||||
{ id: expect.any(Number), name: "Prod", isDefault: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it("fails closed without db:own", async () => {
|
||||
db = await createTestDb(pluginDir);
|
||||
const mock = createMockCtx({
|
||||
pluginId: manifest.id,
|
||||
manifest,
|
||||
capabilities: ["network:serve", "ui:surface"],
|
||||
db: db.database,
|
||||
router: () => express.Router(),
|
||||
});
|
||||
await expect(activate(mock.ctx)).rejects.toBeInstanceOf(
|
||||
PluginCapabilityError,
|
||||
);
|
||||
});
|
||||
|
||||
it("fails closed without network:serve", async () => {
|
||||
db = await createTestDb(pluginDir);
|
||||
const mock = createMockCtx({
|
||||
pluginId: manifest.id,
|
||||
manifest,
|
||||
capabilities: ["db:own", "ui:surface"],
|
||||
db: db.database,
|
||||
router: () => express.Router(),
|
||||
});
|
||||
await expect(activate(mock.ctx)).rejects.toBeInstanceOf(
|
||||
PluginCapabilityError,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import http from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import express, { type Router } from "express";
|
||||
import {
|
||||
createMockCtx,
|
||||
createTestDb,
|
||||
type MockPluginContext,
|
||||
type TestDb,
|
||||
} from "@termix/plugin-sdk/testing";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
import manifestJson from "../../manifest.json";
|
||||
|
||||
export const pluginDir = fileURLToPath(new URL("../..", import.meta.url));
|
||||
export const manifest = manifestJson as unknown as PluginManifest;
|
||||
|
||||
export interface TestServer {
|
||||
db: TestDb;
|
||||
mock: MockPluginContext;
|
||||
request: (
|
||||
method: string,
|
||||
path: string,
|
||||
options?: { user?: string; body?: unknown },
|
||||
// Route bodies vary per test; asserting on them is the point.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
) => Promise<{ status: number; body: any }>;
|
||||
close: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The plugin activated against a real in-memory database, its router mounted
|
||||
* on an express app the way core mounts it: JSON parsed, the acting user set
|
||||
* from the request, everything else left to the plugin.
|
||||
*/
|
||||
export async function startServer(
|
||||
options: { permissions?: string[]; users?: string[] } = {},
|
||||
): Promise<TestServer> {
|
||||
const db = await createTestDb(pluginDir);
|
||||
for (const user of options.users ?? ["user-1", "user-2"]) {
|
||||
db.sqlite
|
||||
.prepare("INSERT INTO users (id, username) VALUES (?, ?)")
|
||||
.run(user, user);
|
||||
}
|
||||
|
||||
let router: Router | null = null;
|
||||
const mock = createMockCtx({
|
||||
pluginId: manifest.id,
|
||||
manifest,
|
||||
capabilities: manifest.capabilities,
|
||||
db: db.database,
|
||||
router: () => (router = express.Router()),
|
||||
permissions: options.permissions ?? ["workspaces.use"],
|
||||
});
|
||||
|
||||
const { activate } = await import("../../src/backend/index.js");
|
||||
await activate(mock.ctx);
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
mock.setActor(req.header("x-test-user") ?? undefined);
|
||||
next();
|
||||
});
|
||||
app.use((req, res, next) => router!(req, res, next));
|
||||
|
||||
const server = http.createServer(app);
|
||||
await new Promise<void>((resolve) => server.listen(0, resolve));
|
||||
const { port } = server.address() as AddressInfo;
|
||||
|
||||
return {
|
||||
db,
|
||||
mock,
|
||||
async request(method, path, { user = "user-1", body: given } = {}) {
|
||||
const body = method === "GET" ? undefined : given;
|
||||
const response = await fetch(`http://127.0.0.1:${port}${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
"x-test-user": user,
|
||||
...(body !== undefined ? { "content-type": "application/json" } : {}),
|
||||
},
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
const text = await response.text();
|
||||
return { status: response.status, body: text ? JSON.parse(text) : null };
|
||||
},
|
||||
async close() {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
db.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const layout = (
|
||||
tabs: unknown[] = [{ slotId: "a", type: "terminal" }],
|
||||
) => ({
|
||||
version: 1,
|
||||
tabs,
|
||||
activeSlotId: null,
|
||||
splitMode: "none",
|
||||
paneTabIds: [],
|
||||
rowSizes: [],
|
||||
rowColSizes: [],
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { createTestDb, type TestDb } from "@termix/plugin-sdk/testing";
|
||||
import { pluginDir } from "./helpers";
|
||||
|
||||
// user_workspaces as core's SQLite bootstrap created it before 2.9.0, plus the
|
||||
// index performance-indexes.ts added.
|
||||
const LEGACY_DDL = `
|
||||
CREATE TABLE user_workspaces (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
color TEXT,
|
||||
icon TEXT,
|
||||
kind TEXT NOT NULL DEFAULT 'manual',
|
||||
is_default INTEGER NOT NULL DEFAULT 0,
|
||||
payload TEXT NOT NULL DEFAULT '{}',
|
||||
sync_id TEXT UNIQUE,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_used_at TEXT
|
||||
);
|
||||
CREATE INDEX idx_user_workspaces_user_id ON user_workspaces (user_id);
|
||||
`;
|
||||
|
||||
let db: TestDb | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
db?.close();
|
||||
db = null;
|
||||
});
|
||||
|
||||
const tableExists = (name: string) =>
|
||||
!!db!.sqlite
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
|
||||
.get(name);
|
||||
|
||||
const indexOn = (table: string) =>
|
||||
(
|
||||
db!.sqlite
|
||||
.prepare(
|
||||
"SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = ? AND name NOT LIKE 'sqlite_%'",
|
||||
)
|
||||
.all(table) as { name: string }[]
|
||||
).map((row) => row.name);
|
||||
|
||||
describe("adopting user_workspaces", () => {
|
||||
it("keeps every existing row when the legacy table is there", async () => {
|
||||
db = await createTestDb(pluginDir, {
|
||||
before: (sqlite) => {
|
||||
sqlite.exec("INSERT INTO users (id) VALUES ('user-1'), ('user-2')");
|
||||
sqlite.exec(LEGACY_DDL);
|
||||
sqlite
|
||||
.prepare(
|
||||
"INSERT INTO user_workspaces (user_id, name, kind, is_default, payload, sync_id, last_used_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.run(
|
||||
"user-1",
|
||||
"Prod",
|
||||
"manual",
|
||||
1,
|
||||
'{"version":1,"tabs":[{"type":"terminal"}]}',
|
||||
"sync-a",
|
||||
"2026-01-01T00:00:00.000Z",
|
||||
);
|
||||
sqlite
|
||||
.prepare(
|
||||
"INSERT INTO user_workspaces (user_id, name, kind, payload, sync_id) VALUES (?, ?, ?, ?, ?)",
|
||||
)
|
||||
.run("user-2", "Last Session", "last_session", "{}", "sync-b");
|
||||
},
|
||||
});
|
||||
|
||||
expect(db.applied).toEqual(["0001_adopt_user_workspaces"]);
|
||||
expect(tableExists("user_workspaces")).toBe(false);
|
||||
expect(tableExists("p_workspaces_workspaces")).toBe(true);
|
||||
|
||||
const rows = db.sqlite
|
||||
.prepare(
|
||||
"SELECT user_id, name, kind, is_default, payload, sync_id, last_used_at FROM p_workspaces_workspaces ORDER BY id",
|
||||
)
|
||||
.all();
|
||||
expect(rows).toEqual([
|
||||
{
|
||||
user_id: "user-1",
|
||||
name: "Prod",
|
||||
kind: "manual",
|
||||
is_default: 1,
|
||||
payload: '{"version":1,"tabs":[{"type":"terminal"}]}',
|
||||
sync_id: "sync-a",
|
||||
last_used_at: "2026-01-01T00:00:00.000Z",
|
||||
},
|
||||
{
|
||||
user_id: "user-2",
|
||||
name: "Last Session",
|
||||
kind: "last_session",
|
||||
is_default: 0,
|
||||
payload: "{}",
|
||||
sync_id: "sync-b",
|
||||
last_used_at: null,
|
||||
},
|
||||
]);
|
||||
expect(indexOn("p_workspaces_workspaces")).toEqual([
|
||||
"idx_user_workspaces_user_id",
|
||||
]);
|
||||
});
|
||||
|
||||
it("creates the table fresh when there is no legacy one", async () => {
|
||||
db = await createTestDb(pluginDir);
|
||||
|
||||
expect(tableExists("user_workspaces")).toBe(false);
|
||||
expect(tableExists("p_workspaces_workspaces")).toBe(true);
|
||||
expect(indexOn("p_workspaces_workspaces")).toEqual([
|
||||
"idx_user_workspaces_user_id",
|
||||
]);
|
||||
expect(
|
||||
db.sqlite.prepare("SELECT * FROM p_workspaces_workspaces").all(),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("removes a user's workspaces when the user is deleted", async () => {
|
||||
db = await createTestDb(pluginDir, {
|
||||
before: (sqlite) => {
|
||||
sqlite.exec("INSERT INTO users (id) VALUES ('user-1'), ('user-2')");
|
||||
},
|
||||
});
|
||||
const insert = db.sqlite.prepare(
|
||||
"INSERT INTO p_workspaces_workspaces (user_id, name) VALUES (?, ?)",
|
||||
);
|
||||
insert.run("user-1", "Mine");
|
||||
insert.run("user-2", "Theirs");
|
||||
|
||||
db.sqlite.exec("DELETE FROM users WHERE id = 'user-1'");
|
||||
|
||||
const names = (
|
||||
db.sqlite.prepare("SELECT name FROM p_workspaces_workspaces").all() as {
|
||||
name: string;
|
||||
}[]
|
||||
).map((row) => row.name);
|
||||
expect(names).toEqual(["Theirs"]);
|
||||
});
|
||||
});
|
||||
@@ -1,204 +0,0 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { TestSqliteDatabase } from "../../../../../src/backend/tests/database/repositories/test-support.js";
|
||||
import { WorkspaceRepository } from "../../../../../src/backend/database/repositories/workspace-repository.js";
|
||||
|
||||
describe("WorkspaceRepository", () => {
|
||||
let adapter: TestSqliteDatabase | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
if (adapter) {
|
||||
await adapter.close();
|
||||
adapter = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function createRepository(
|
||||
onWrite?: () => void | Promise<void>,
|
||||
): Promise<WorkspaceRepository> {
|
||||
adapter = new TestSqliteDatabase();
|
||||
const context = await adapter.connect();
|
||||
await adapter.exec(`
|
||||
INSERT INTO users (id, username, password_hash)
|
||||
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
|
||||
`);
|
||||
|
||||
return new WorkspaceRepository(context, onWrite);
|
||||
}
|
||||
|
||||
it("creates and lists manual workspaces scoped to the owning user", async () => {
|
||||
const repo = await createRepository();
|
||||
|
||||
await repo.create("user-1", { name: "Prod Debugging", payload: "{}" });
|
||||
await repo.create("user-2", { name: "Other user's", payload: "{}" });
|
||||
|
||||
const list = await repo.listByUser("user-1");
|
||||
expect(list).toHaveLength(1);
|
||||
expect(list[0]).toMatchObject({
|
||||
userId: "user-1",
|
||||
name: "Prod Debugging",
|
||||
kind: "manual",
|
||||
isDefault: false,
|
||||
});
|
||||
expect(list[0].syncId).toBeTruthy();
|
||||
});
|
||||
|
||||
it("finds a workspace by id scoped to the owner", async () => {
|
||||
const repo = await createRepository();
|
||||
const created = await repo.create("user-1", {
|
||||
name: "Test A",
|
||||
payload: "{}",
|
||||
});
|
||||
|
||||
expect(await repo.findById("user-1", created.id)).toMatchObject({
|
||||
id: created.id,
|
||||
});
|
||||
expect(await repo.findById("user-2", created.id)).toBeNull();
|
||||
});
|
||||
|
||||
it("upsertLastSession creates then updates a single row, never a second one", async () => {
|
||||
const repo = await createRepository();
|
||||
|
||||
const first = await repo.upsertLastSession("user-1", '{"tabs":[]}');
|
||||
expect(first.kind).toBe("last_session");
|
||||
|
||||
const second = await repo.upsertLastSession(
|
||||
"user-1",
|
||||
'{"tabs":[{"slotId":"a"}]}',
|
||||
);
|
||||
expect(second.id).toBe(first.id);
|
||||
expect(second.payload).toBe('{"tabs":[{"slotId":"a"}]}');
|
||||
|
||||
const all = await repo.listByUser("user-1");
|
||||
expect(all.filter((w) => w.kind === "last_session")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("update renames/recolors a manual workspace but rejects last_session", async () => {
|
||||
const repo = await createRepository();
|
||||
const manual = await repo.create("user-1", {
|
||||
name: "Old Name",
|
||||
payload: "{}",
|
||||
});
|
||||
const lastSession = await repo.upsertLastSession("user-1", "{}");
|
||||
|
||||
const updated = await repo.update("user-1", manual.id, {
|
||||
name: "New Name",
|
||||
color: "#fff",
|
||||
});
|
||||
expect(updated).toMatchObject({ name: "New Name", color: "#fff" });
|
||||
|
||||
const rejected = await repo.update("user-1", lastSession.id, {
|
||||
name: "Should not work",
|
||||
});
|
||||
expect(rejected).toBeNull();
|
||||
});
|
||||
|
||||
it("updateContent overwrites payload for a manual workspace but rejects last_session", async () => {
|
||||
const repo = await createRepository();
|
||||
const manual = await repo.create("user-1", {
|
||||
name: "Test A",
|
||||
payload: "{}",
|
||||
});
|
||||
const lastSession = await repo.upsertLastSession("user-1", "{}");
|
||||
|
||||
const updated = await repo.updateContent(
|
||||
"user-1",
|
||||
manual.id,
|
||||
'{"tabs":[1]}',
|
||||
);
|
||||
expect(updated?.payload).toBe('{"tabs":[1]}');
|
||||
|
||||
const rejected = await repo.updateContent(
|
||||
"user-1",
|
||||
lastSession.id,
|
||||
'{"tabs":[2]}',
|
||||
);
|
||||
expect(rejected).toBeNull();
|
||||
});
|
||||
|
||||
it("setDefault clears any prior default and rejects last_session", async () => {
|
||||
const repo = await createRepository();
|
||||
const a = await repo.create("user-1", { name: "A", payload: "{}" });
|
||||
const b = await repo.create("user-1", { name: "B", payload: "{}" });
|
||||
const lastSession = await repo.upsertLastSession("user-1", "{}");
|
||||
|
||||
await repo.setDefault("user-1", a.id);
|
||||
expect((await repo.findById("user-1", a.id))?.isDefault).toBe(true);
|
||||
|
||||
await repo.setDefault("user-1", b.id);
|
||||
expect((await repo.findById("user-1", a.id))?.isDefault).toBe(false);
|
||||
expect((await repo.findById("user-1", b.id))?.isDefault).toBe(true);
|
||||
|
||||
const rejected = await repo.setDefault("user-1", lastSession.id);
|
||||
expect(rejected).toBeNull();
|
||||
});
|
||||
|
||||
it("unsetDefault clears isDefault on a manual workspace and rejects last_session", async () => {
|
||||
const repo = await createRepository();
|
||||
const a = await repo.create("user-1", { name: "A", payload: "{}" });
|
||||
const lastSession = await repo.upsertLastSession("user-1", "{}");
|
||||
|
||||
await repo.setDefault("user-1", a.id);
|
||||
expect((await repo.findById("user-1", a.id))?.isDefault).toBe(true);
|
||||
|
||||
await repo.unsetDefault("user-1", a.id);
|
||||
expect((await repo.findById("user-1", a.id))?.isDefault).toBe(false);
|
||||
|
||||
const rejected = await repo.unsetDefault("user-1", lastSession.id);
|
||||
expect(rejected).toBeNull();
|
||||
});
|
||||
|
||||
it("touchLastUsed sets lastUsedAt", async () => {
|
||||
const repo = await createRepository();
|
||||
const workspace = await repo.create("user-1", {
|
||||
name: "A",
|
||||
payload: "{}",
|
||||
});
|
||||
expect(workspace.lastUsedAt).toBeNull();
|
||||
|
||||
await repo.touchLastUsed(
|
||||
"user-1",
|
||||
workspace.id,
|
||||
"2026-08-11T00:00:00.000Z",
|
||||
);
|
||||
expect((await repo.findById("user-1", workspace.id))?.lastUsedAt).toBe(
|
||||
"2026-08-11T00:00:00.000Z",
|
||||
);
|
||||
});
|
||||
|
||||
it("delete removes a manual workspace but rejects last_session", async () => {
|
||||
const repo = await createRepository();
|
||||
const manual = await repo.create("user-1", { name: "A", payload: "{}" });
|
||||
const lastSession = await repo.upsertLastSession("user-1", "{}");
|
||||
|
||||
await expect(repo.delete("user-1", lastSession.id)).resolves.toBe(false);
|
||||
await expect(repo.delete("user-1", manual.id)).resolves.toBe(true);
|
||||
expect(await repo.findById("user-1", manual.id)).toBeNull();
|
||||
});
|
||||
|
||||
it("triggers writes on create/update/delete", async () => {
|
||||
let writeCount = 0;
|
||||
const repo = await createRepository(() => {
|
||||
writeCount += 1;
|
||||
});
|
||||
|
||||
const created = await repo.create("user-1", {
|
||||
name: "A",
|
||||
payload: "{}",
|
||||
});
|
||||
await repo.update("user-1", created.id, { name: "B" });
|
||||
await repo.delete("user-1", created.id);
|
||||
|
||||
expect(writeCount).toBe(3);
|
||||
});
|
||||
|
||||
it("deleteByUserId removes every workspace owned by the user", async () => {
|
||||
const repo = await createRepository();
|
||||
await repo.create("user-1", { name: "A", payload: "{}" });
|
||||
await repo.create("user-1", { name: "B", payload: "{}" });
|
||||
await repo.create("user-2", { name: "C", payload: "{}" });
|
||||
|
||||
await expect(repo.deleteByUserId("user-1")).resolves.toBe(2);
|
||||
expect(await repo.listByUser("user-1")).toHaveLength(0);
|
||||
expect(await repo.listByUser("user-2")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { createTestDb, type TestDb } from "@termix/plugin-sdk/testing";
|
||||
import { workspaces } from "../../src/backend/tables.js";
|
||||
import {
|
||||
createWorkspaceRepository,
|
||||
type WorkspaceRepository,
|
||||
} from "../../src/backend/repository.js";
|
||||
import { pluginDir } from "./helpers";
|
||||
|
||||
let db: TestDb;
|
||||
let repo: WorkspaceRepository;
|
||||
|
||||
beforeEach(async () => {
|
||||
db = await createTestDb(pluginDir, {
|
||||
before: (sqlite) =>
|
||||
sqlite.exec("INSERT INTO users (id) VALUES ('user-1'), ('user-2')"),
|
||||
});
|
||||
repo = createWorkspaceRepository(
|
||||
db.database,
|
||||
await db.database.define(workspaces),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => db.close());
|
||||
|
||||
describe("workspace repository", () => {
|
||||
it("reads a new row back by its own sync id", async () => {
|
||||
const a = await repo.create("user-1", { name: "A", payload: "{}" });
|
||||
const b = await repo.create("user-1", { name: "B", payload: "{}" });
|
||||
expect(a.name).toBe("A");
|
||||
expect(b.name).toBe("B");
|
||||
expect(a.syncId).not.toBe(b.syncId);
|
||||
expect(a.createdAt).toBe(a.updatedAt);
|
||||
});
|
||||
|
||||
it("returns the updated last session rather than a new one", async () => {
|
||||
const first = await repo.upsertLastSession("user-1", '{"tabs":[]}', "t1");
|
||||
const second = await repo.upsertLastSession("user-1", '{"tabs":[1]}', "t2");
|
||||
expect(second.id).toBe(first.id);
|
||||
expect(second.payload).toBe('{"tabs":[1]}');
|
||||
expect(second.updatedAt).toBe("t2");
|
||||
});
|
||||
|
||||
it("switches the default without touching another user's", async () => {
|
||||
const theirs = await repo.create("user-2", { name: "T", payload: "{}" });
|
||||
await repo.setDefault("user-2", theirs.id);
|
||||
|
||||
const a = await repo.create("user-1", { name: "A", payload: "{}" });
|
||||
const b = await repo.create("user-1", { name: "B", payload: "{}" });
|
||||
await repo.setDefault("user-1", a.id);
|
||||
await repo.setDefault("user-1", b.id);
|
||||
|
||||
expect((await repo.findById("user-1", a.id))?.isDefault).toBe(false);
|
||||
expect((await repo.findById("user-1", b.id))?.isDefault).toBe(true);
|
||||
expect((await repo.findById("user-2", theirs.id))?.isDefault).toBe(true);
|
||||
});
|
||||
|
||||
it("refuses to edit or delete the last session", async () => {
|
||||
const last = await repo.upsertLastSession("user-1", "{}");
|
||||
expect(await repo.update("user-1", last.id, { name: "x" })).toBeNull();
|
||||
expect(await repo.setDefault("user-1", last.id)).toBeNull();
|
||||
expect(await repo.delete("user-1", last.id)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,518 +1,203 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import type { Request, Response, Router } from "express";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { layout, startServer, type TestServer } from "./helpers";
|
||||
|
||||
type WorkspaceRow = {
|
||||
id: number;
|
||||
userId: string;
|
||||
name: string;
|
||||
color: string | null;
|
||||
icon: string | null;
|
||||
kind: "manual" | "last_session";
|
||||
isDefault: boolean;
|
||||
payload: string;
|
||||
syncId: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
lastUsedAt: string | null;
|
||||
};
|
||||
let server: TestServer;
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
currentUserId: "user-1",
|
||||
workspaces: new Map<number, WorkspaceRow>(),
|
||||
nextId: 1,
|
||||
}));
|
||||
|
||||
vi.mock("../../../../src/backend/database/db/index.js", () => ({ db: {} }));
|
||||
|
||||
vi.mock("../../../../src/backend/utils/logger.js", () => ({
|
||||
databaseLogger: {
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
info: vi.fn(),
|
||||
success: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../../../src/backend/utils/auth-manager.js", () => ({
|
||||
AuthManager: {
|
||||
getInstance: () => ({
|
||||
createAuthMiddleware:
|
||||
() =>
|
||||
(req: Record<string, unknown>, _res: unknown, next: () => void) => {
|
||||
req.userId = state.currentUserId;
|
||||
next();
|
||||
},
|
||||
createDataAccessMiddleware:
|
||||
() => (_req: unknown, _res: unknown, next: () => void) =>
|
||||
next(),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
function findByIdForUser(userId: string, id: number): WorkspaceRow | null {
|
||||
const row = state.workspaces.get(id);
|
||||
return row && row.userId === userId ? row : null;
|
||||
}
|
||||
|
||||
vi.mock("../../../../src/backend/database/repositories/factory.js", () => ({
|
||||
createCurrentWorkspaceRepository: () => ({
|
||||
listByUser: async (userId: string) =>
|
||||
[...state.workspaces.values()].filter((w) => w.userId === userId),
|
||||
findById: async (userId: string, id: number) => findByIdForUser(userId, id),
|
||||
findLastSession: async (userId: string) =>
|
||||
[...state.workspaces.values()].find(
|
||||
(w) => w.userId === userId && w.kind === "last_session",
|
||||
) ?? null,
|
||||
upsertLastSession: async (userId: string, payload: string) => {
|
||||
const existing = [...state.workspaces.values()].find(
|
||||
(w) => w.userId === userId && w.kind === "last_session",
|
||||
);
|
||||
if (existing) {
|
||||
existing.payload = payload;
|
||||
existing.updatedAt = new Date().toISOString();
|
||||
return existing;
|
||||
}
|
||||
const row: WorkspaceRow = {
|
||||
id: state.nextId++,
|
||||
userId,
|
||||
name: "Last Session",
|
||||
color: null,
|
||||
icon: null,
|
||||
kind: "last_session",
|
||||
isDefault: false,
|
||||
payload,
|
||||
syncId: `sync-${state.nextId}`,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
lastUsedAt: null,
|
||||
};
|
||||
state.workspaces.set(row.id, row);
|
||||
return row;
|
||||
},
|
||||
create: async (
|
||||
userId: string,
|
||||
input: {
|
||||
name: string;
|
||||
color?: string | null;
|
||||
icon?: string | null;
|
||||
payload: string;
|
||||
},
|
||||
) => {
|
||||
const row: WorkspaceRow = {
|
||||
id: state.nextId++,
|
||||
userId,
|
||||
name: input.name,
|
||||
color: input.color ?? null,
|
||||
icon: input.icon ?? null,
|
||||
kind: "manual",
|
||||
isDefault: false,
|
||||
payload: input.payload,
|
||||
syncId: `sync-${state.nextId}`,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
lastUsedAt: null,
|
||||
};
|
||||
state.workspaces.set(row.id, row);
|
||||
return row;
|
||||
},
|
||||
update: async (
|
||||
userId: string,
|
||||
id: number,
|
||||
input: { name?: string; color?: string | null; icon?: string | null },
|
||||
) => {
|
||||
const row = findByIdForUser(userId, id);
|
||||
if (!row || row.kind !== "manual") return null;
|
||||
if (input.name !== undefined) row.name = input.name;
|
||||
if (input.color !== undefined) row.color = input.color;
|
||||
if (input.icon !== undefined) row.icon = input.icon;
|
||||
return row;
|
||||
},
|
||||
updateContent: async (userId: string, id: number, payload: string) => {
|
||||
const row = findByIdForUser(userId, id);
|
||||
if (!row || row.kind !== "manual") return null;
|
||||
row.payload = payload;
|
||||
return row;
|
||||
},
|
||||
setDefault: async (userId: string, id: number) => {
|
||||
const row = findByIdForUser(userId, id);
|
||||
if (!row || row.kind !== "manual") return null;
|
||||
for (const w of state.workspaces.values()) {
|
||||
if (w.userId === userId && w.kind === "manual") w.isDefault = false;
|
||||
}
|
||||
row.isDefault = true;
|
||||
return row;
|
||||
},
|
||||
unsetDefault: async (userId: string, id: number) => {
|
||||
const row = findByIdForUser(userId, id);
|
||||
if (!row || row.kind !== "manual") return null;
|
||||
row.isDefault = false;
|
||||
return row;
|
||||
},
|
||||
touchLastUsed: async (userId: string, id: number) => {
|
||||
const row = findByIdForUser(userId, id);
|
||||
if (row) row.lastUsedAt = new Date().toISOString();
|
||||
},
|
||||
delete: async (userId: string, id: number) => {
|
||||
const row = findByIdForUser(userId, id);
|
||||
if (!row || row.kind !== "manual") return false;
|
||||
state.workspaces.delete(id);
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock(
|
||||
"../../../../src/backend/database/routes/workspace-dispatch.js",
|
||||
() => ({
|
||||
registerWorkspacesRouter: vi.fn(),
|
||||
unregisterWorkspacesRouter: vi.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
const { router } = await import("../../src/backend/routes.js");
|
||||
|
||||
function findLayer(method: string, path: string) {
|
||||
const stack = (router as unknown as Router).stack as Array<{
|
||||
route?: {
|
||||
path: string;
|
||||
methods: Record<string, boolean>;
|
||||
stack: Array<{ handle: (req: Request, res: Response) => unknown }>;
|
||||
};
|
||||
}>;
|
||||
const layer = stack.find(
|
||||
(l) => l.route?.path === path && l.route?.methods[method],
|
||||
);
|
||||
if (!layer?.route) throw new Error(`No route for ${method} ${path}`);
|
||||
return layer.route.stack[layer.route.stack.length - 1].handle;
|
||||
}
|
||||
|
||||
function makeReqRes(overrides: {
|
||||
body?: Record<string, unknown>;
|
||||
params?: Record<string, unknown>;
|
||||
}) {
|
||||
const req = {
|
||||
userId: state.currentUserId,
|
||||
body: overrides.body ?? {},
|
||||
params: overrides.params ?? {},
|
||||
headers: {},
|
||||
} as unknown as Request;
|
||||
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
jsonBody: null as unknown,
|
||||
status(code: number) {
|
||||
(this as unknown as { statusCode: number }).statusCode = code;
|
||||
return this;
|
||||
},
|
||||
json(payload: unknown) {
|
||||
(this as unknown as { jsonBody: unknown }).jsonBody = payload;
|
||||
return this;
|
||||
},
|
||||
} as unknown as Response & { statusCode: number; jsonBody: unknown };
|
||||
|
||||
return { req, res };
|
||||
}
|
||||
|
||||
async function invoke(
|
||||
method: string,
|
||||
path: string,
|
||||
overrides: {
|
||||
body?: Record<string, unknown>;
|
||||
params?: Record<string, unknown>;
|
||||
} = {},
|
||||
) {
|
||||
const handler = findLayer(method, path);
|
||||
const { req, res } = makeReqRes(overrides);
|
||||
await handler(req, res);
|
||||
return res as unknown as {
|
||||
statusCode: number;
|
||||
jsonBody: Record<string, unknown> | null;
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
state.currentUserId = "user-1";
|
||||
state.workspaces = new Map();
|
||||
state.nextId = 1;
|
||||
beforeEach(async () => {
|
||||
server = await startServer();
|
||||
});
|
||||
|
||||
describe("GET /", () => {
|
||||
it("returns the list with a computed tabCount", async () => {
|
||||
await invoke("post", "/", {
|
||||
body: {
|
||||
name: "Test A",
|
||||
payload: { version: 1, tabs: [{ slotId: "a" }, { slotId: "b" }] },
|
||||
},
|
||||
});
|
||||
afterEach(async () => {
|
||||
await server.close();
|
||||
});
|
||||
|
||||
const res = await invoke("get", "/");
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.jsonBody).toHaveLength(1);
|
||||
async function create(name = "Prod", user = "user-1") {
|
||||
const res = await server.request("POST", "/", {
|
||||
user,
|
||||
body: { name, color: "#ef4444", payload: layout() },
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
return res.body;
|
||||
}
|
||||
|
||||
describe("workspace routes", () => {
|
||||
it("creates and lists workspaces with a parsed payload and tab count", async () => {
|
||||
const created = await create();
|
||||
expect(created).toMatchObject({
|
||||
name: "Prod",
|
||||
color: "#ef4444",
|
||||
kind: "manual",
|
||||
isDefault: false,
|
||||
tabCount: 1,
|
||||
});
|
||||
expect(created.payload.tabs).toHaveLength(1);
|
||||
expect(created.syncId).toEqual(expect.any(String));
|
||||
|
||||
const list = await server.request("GET", "/");
|
||||
expect(list.body).toHaveLength(1);
|
||||
expect(list.body[0].id).toBe(created.id);
|
||||
});
|
||||
|
||||
it("rejects a missing name or payload", async () => {
|
||||
expect(
|
||||
(res.jsonBody as unknown as { tabCount: number }[])[0].tabCount,
|
||||
).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /", () => {
|
||||
it("400s when name is missing", async () => {
|
||||
const res = await invoke("post", "/", {
|
||||
body: { payload: { version: 1, tabs: [] } },
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it("400s when payload has no tabs array", async () => {
|
||||
const res = await invoke("post", "/", {
|
||||
body: { name: "Test A", payload: { version: 1 } },
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it("200s and creates a manual workspace", async () => {
|
||||
const res = await invoke("post", "/", {
|
||||
body: { name: "Test A", payload: { version: 1, tabs: [] } },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.jsonBody).toMatchObject({ name: "Test A", kind: "manual" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("PATCH /:id", () => {
|
||||
it("rejects renaming the last_session row", async () => {
|
||||
const created = await invoke("put", "/last-session", {
|
||||
body: { payload: { version: 1, tabs: [] } },
|
||||
});
|
||||
const id = (created.jsonBody as unknown as { id: number }).id;
|
||||
|
||||
const res = await invoke("patch", "/:id", {
|
||||
params: { id: String(id) },
|
||||
body: { name: "Nope" },
|
||||
});
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it("renames a manual workspace", async () => {
|
||||
const created = await invoke("post", "/", {
|
||||
body: { name: "Old", payload: { version: 1, tabs: [] } },
|
||||
});
|
||||
const id = (created.jsonBody as unknown as { id: number }).id;
|
||||
|
||||
const res = await invoke("patch", "/:id", {
|
||||
params: { id: String(id) },
|
||||
body: { name: "New" },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.jsonBody).toMatchObject({ name: "New" });
|
||||
});
|
||||
|
||||
it("404s for a nonexistent id", async () => {
|
||||
const res = await invoke("patch", "/:id", {
|
||||
params: { id: "999" },
|
||||
body: { name: "New" },
|
||||
});
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PUT /:id/content", () => {
|
||||
it("updates payload for a manual workspace", async () => {
|
||||
const created = await invoke("post", "/", {
|
||||
body: { name: "A", payload: { version: 1, tabs: [] } },
|
||||
});
|
||||
const id = (created.jsonBody as unknown as { id: number }).id;
|
||||
|
||||
const res = await invoke("put", "/:id/content", {
|
||||
params: { id: String(id) },
|
||||
body: { payload: { version: 1, tabs: [{ slotId: "x" }] } },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.jsonBody).toMatchObject({ tabCount: 1 });
|
||||
});
|
||||
|
||||
it("404s on wrong owner", async () => {
|
||||
const created = await invoke("post", "/", {
|
||||
body: { name: "A", payload: { version: 1, tabs: [] } },
|
||||
});
|
||||
const id = (created.jsonBody as unknown as { id: number }).id;
|
||||
|
||||
state.currentUserId = "user-2";
|
||||
const res = await invoke("put", "/:id/content", {
|
||||
params: { id: String(id) },
|
||||
body: { payload: { version: 1, tabs: [] } },
|
||||
});
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DELETE /:id", () => {
|
||||
it("rejects deleting the last_session row", async () => {
|
||||
const created = await invoke("put", "/last-session", {
|
||||
body: { payload: { version: 1, tabs: [] } },
|
||||
});
|
||||
const id = (created.jsonBody as unknown as { id: number }).id;
|
||||
|
||||
const res = await invoke("delete", "/:id", { params: { id: String(id) } });
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it("404s for a nonexistent id", async () => {
|
||||
const res = await invoke("delete", "/:id", { params: { id: "999" } });
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it("deletes a manual workspace", async () => {
|
||||
const created = await invoke("post", "/", {
|
||||
body: { name: "A", payload: { version: 1, tabs: [] } },
|
||||
});
|
||||
const id = (created.jsonBody as unknown as { id: number }).id;
|
||||
|
||||
const res = await invoke("delete", "/:id", { params: { id: String(id) } });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.jsonBody).toEqual({ success: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /:id/duplicate", () => {
|
||||
it("produces a second independent row", async () => {
|
||||
const created = await invoke("post", "/", {
|
||||
body: {
|
||||
name: "A",
|
||||
payload: { version: 1, tabs: [{ slotId: "a" }] },
|
||||
},
|
||||
});
|
||||
const id = (created.jsonBody as unknown as { id: number }).id;
|
||||
|
||||
const dup = await invoke("post", "/:id/duplicate", {
|
||||
params: { id: String(id) },
|
||||
body: { name: "A (copy)" },
|
||||
});
|
||||
expect(dup.statusCode).toBe(200);
|
||||
expect(dup.jsonBody).toMatchObject({ name: "A (copy)", tabCount: 1 });
|
||||
|
||||
const updateOriginal = await invoke("put", "/:id/content", {
|
||||
params: { id: String(id) },
|
||||
body: { payload: { version: 1, tabs: [] } },
|
||||
});
|
||||
expect(updateOriginal.jsonBody).toMatchObject({ tabCount: 0 });
|
||||
|
||||
const dupId = (dup.jsonBody as unknown as { id: number }).id;
|
||||
const refetched = await invoke("get", "/");
|
||||
const dupRow = (
|
||||
refetched.jsonBody as unknown as { id: number; tabCount: number }[]
|
||||
).find((w) => w.id === dupId);
|
||||
expect(dupRow?.tabCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /:id/set-default", () => {
|
||||
it("clears a previous default and sets the new one", async () => {
|
||||
const a = await invoke("post", "/", {
|
||||
body: { name: "A", payload: { version: 1, tabs: [] } },
|
||||
});
|
||||
const b = await invoke("post", "/", {
|
||||
body: { name: "B", payload: { version: 1, tabs: [] } },
|
||||
});
|
||||
const aId = (a.jsonBody as unknown as { id: number }).id;
|
||||
const bId = (b.jsonBody as unknown as { id: number }).id;
|
||||
|
||||
await invoke("post", "/:id/set-default", { params: { id: String(aId) } });
|
||||
const second = await invoke("post", "/:id/set-default", {
|
||||
params: { id: String(bId) },
|
||||
});
|
||||
expect(second.jsonBody).toMatchObject({ isDefault: true });
|
||||
|
||||
const list = await invoke("get", "/");
|
||||
const aRow = (
|
||||
list.jsonBody as unknown as { id: number; isDefault: boolean }[]
|
||||
).find((w) => w.id === aId);
|
||||
expect(aRow?.isDefault).toBe(false);
|
||||
});
|
||||
|
||||
it("is idempotent when re-called on an already-default row", async () => {
|
||||
const a = await invoke("post", "/", {
|
||||
body: { name: "A", payload: { version: 1, tabs: [] } },
|
||||
});
|
||||
const aId = (a.jsonBody as unknown as { id: number }).id;
|
||||
|
||||
await invoke("post", "/:id/set-default", { params: { id: String(aId) } });
|
||||
const again = await invoke("post", "/:id/set-default", {
|
||||
params: { id: String(aId) },
|
||||
});
|
||||
expect(again.statusCode).toBe(200);
|
||||
expect(again.jsonBody).toMatchObject({ isDefault: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /:id/unset-default", () => {
|
||||
it("clears isDefault on a manual workspace", async () => {
|
||||
const a = await invoke("post", "/", {
|
||||
body: { name: "A", payload: { version: 1, tabs: [] } },
|
||||
});
|
||||
const aId = (a.jsonBody as unknown as { id: number }).id;
|
||||
|
||||
await invoke("post", "/:id/set-default", { params: { id: String(aId) } });
|
||||
const res = await invoke("post", "/:id/unset-default", {
|
||||
params: { id: String(aId) },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.jsonBody).toMatchObject({ isDefault: false });
|
||||
});
|
||||
|
||||
it("rejects unsetting the last_session row", async () => {
|
||||
const created = await invoke("put", "/last-session", {
|
||||
body: { payload: { version: 1, tabs: [] } },
|
||||
});
|
||||
const id = (created.jsonBody as unknown as { id: number }).id;
|
||||
|
||||
const res = await invoke("post", "/:id/unset-default", {
|
||||
params: { id: String(id) },
|
||||
});
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /:id/apply", () => {
|
||||
it("touches lastUsedAt and returns the parsed payload", async () => {
|
||||
const created = await invoke("post", "/", {
|
||||
body: { name: "A", payload: { version: 1, tabs: [{ slotId: "a" }] } },
|
||||
});
|
||||
const id = (created.jsonBody as unknown as { id: number }).id;
|
||||
|
||||
const res = await invoke("post", "/:id/apply", {
|
||||
params: { id: String(id) },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.jsonBody).toMatchObject({
|
||||
payload: { version: 1, tabs: [{ slotId: "a" }] },
|
||||
});
|
||||
(await server.request("POST", "/", { body: { payload: layout() } }))
|
||||
.status,
|
||||
).toBe(400);
|
||||
expect(
|
||||
(res.jsonBody as unknown as { lastUsedAt: string | null }).lastUsedAt,
|
||||
).toBeTruthy();
|
||||
(await server.request("POST", "/", { body: { name: "x", payload: {} } }))
|
||||
.status,
|
||||
).toBe(400);
|
||||
});
|
||||
|
||||
it("keeps one last session row per user and never lists it as editable", async () => {
|
||||
expect((await server.request("GET", "/last-session")).body).toBeNull();
|
||||
|
||||
await server.request("PUT", "/last-session", {
|
||||
body: { payload: layout() },
|
||||
});
|
||||
const second = await server.request("PUT", "/last-session", {
|
||||
body: { payload: layout([]) },
|
||||
});
|
||||
expect(second.body.tabCount).toBe(0);
|
||||
|
||||
const list = (await server.request("GET", "/")).body;
|
||||
expect(
|
||||
list.filter(
|
||||
(w: { kind: string; isDefault: boolean; id: number }) =>
|
||||
w.kind === "last_session",
|
||||
),
|
||||
).toHaveLength(1);
|
||||
|
||||
const id = second.body.id;
|
||||
expect(
|
||||
(await server.request("PATCH", `/${id}`, { body: { name: "x" } })).status,
|
||||
).toBe(404);
|
||||
expect((await server.request("DELETE", `/${id}`)).status).toBe(404);
|
||||
});
|
||||
|
||||
it("renames, replaces content and duplicates", async () => {
|
||||
const created = await create();
|
||||
|
||||
const renamed = await server.request("PATCH", `/${created.id}`, {
|
||||
body: { name: " Staging ", color: "#22c55e" },
|
||||
});
|
||||
expect(renamed.body).toMatchObject({ name: "Staging", color: "#22c55e" });
|
||||
|
||||
const replaced = await server.request("PUT", `/${created.id}/content`, {
|
||||
body: { payload: layout([]) },
|
||||
});
|
||||
expect(replaced.body.tabCount).toBe(0);
|
||||
|
||||
const copy = await server.request("POST", `/${created.id}/duplicate`, {
|
||||
body: { name: "Staging (copy)" },
|
||||
});
|
||||
expect(copy.body).toMatchObject({
|
||||
name: "Staging (copy)",
|
||||
color: "#22c55e",
|
||||
tabCount: 0,
|
||||
});
|
||||
expect(copy.body.id).not.toBe(created.id);
|
||||
});
|
||||
|
||||
it("keeps a single default", async () => {
|
||||
const a = await create("A");
|
||||
const b = await create("B");
|
||||
|
||||
await server.request("POST", `/${a.id}/set-default`);
|
||||
await server.request("POST", `/${b.id}/set-default`);
|
||||
|
||||
const list = (await server.request("GET", "/")).body;
|
||||
const defaults = list.filter(
|
||||
(w: { kind: string; isDefault: boolean; id: number }) => w.isDefault,
|
||||
);
|
||||
expect(
|
||||
defaults.map(
|
||||
(w: { kind: string; isDefault: boolean; id: number }) => w.id,
|
||||
),
|
||||
).toEqual([b.id]);
|
||||
|
||||
const unset = await server.request("POST", `/${b.id}/unset-default`);
|
||||
expect(unset.body.isDefault).toBe(false);
|
||||
});
|
||||
|
||||
it("marks a workspace as used when it is applied", async () => {
|
||||
const created = await create();
|
||||
expect(created.lastUsedAt).toBeNull();
|
||||
|
||||
const applied = await server.request("POST", `/${created.id}/apply`);
|
||||
expect(applied.status).toBe(200);
|
||||
|
||||
const list = (await server.request("GET", "/")).body;
|
||||
expect(list[0].lastUsedAt).toEqual(expect.any(String));
|
||||
});
|
||||
|
||||
it("deletes a workspace", async () => {
|
||||
const created = await create();
|
||||
expect((await server.request("DELETE", `/${created.id}`)).body).toEqual({
|
||||
success: true,
|
||||
});
|
||||
expect((await server.request("GET", "/")).body).toEqual([]);
|
||||
expect((await server.request("DELETE", `/${created.id}`)).status).toBe(404);
|
||||
});
|
||||
|
||||
it("rejects ids that are not numbers", async () => {
|
||||
expect((await server.request("DELETE", "/abc")).status).toBe(400);
|
||||
expect((await server.request("POST", "/abc/apply")).status).toBe(400);
|
||||
});
|
||||
|
||||
it("never shows or changes another user's workspaces", async () => {
|
||||
const mine = await create("Mine", "user-1");
|
||||
|
||||
expect((await server.request("GET", "/", { user: "user-2" })).body).toEqual(
|
||||
[],
|
||||
);
|
||||
for (const [method, path] of [
|
||||
["PATCH", `/${mine.id}`],
|
||||
["DELETE", `/${mine.id}`],
|
||||
["POST", `/${mine.id}/apply`],
|
||||
["POST", `/${mine.id}/set-default`],
|
||||
["POST", `/${mine.id}/duplicate`],
|
||||
] as const) {
|
||||
const res = await server.request(method, path, {
|
||||
user: "user-2",
|
||||
body: { name: "stolen" },
|
||||
});
|
||||
expect(res.status).toBe(404);
|
||||
}
|
||||
});
|
||||
|
||||
it("flushes the database after every write", async () => {
|
||||
const before = server.db.persisted;
|
||||
const created = await create();
|
||||
await server.request("POST", `/${created.id}/set-default`);
|
||||
await server.request("DELETE", `/${created.id}`);
|
||||
expect(server.db.persisted).toBeGreaterThanOrEqual(before + 3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PUT /last-session and GET /last-session", () => {
|
||||
it("returns null before any save", async () => {
|
||||
const res = await invoke("get", "/last-session");
|
||||
expect(res.jsonBody).toBeNull();
|
||||
});
|
||||
describe("workspaces.use", () => {
|
||||
it("refuses every route without it", async () => {
|
||||
await server.close();
|
||||
server = await startServer({ permissions: [] });
|
||||
|
||||
it("upserts idempotently - two calls produce one row", async () => {
|
||||
await invoke("put", "/last-session", {
|
||||
body: { payload: { version: 1, tabs: [] } },
|
||||
});
|
||||
await invoke("put", "/last-session", {
|
||||
body: { payload: { version: 1, tabs: [{ slotId: "a" }] } },
|
||||
});
|
||||
|
||||
const list = await invoke("get", "/");
|
||||
const lastSessionRows = (
|
||||
list.jsonBody as unknown as { kind: string }[]
|
||||
).filter((w) => w.kind === "last_session");
|
||||
expect(lastSessionRows).toHaveLength(1);
|
||||
|
||||
const res = await invoke("get", "/last-session");
|
||||
expect(res.jsonBody).toMatchObject({ tabCount: 1 });
|
||||
for (const [method, path] of [
|
||||
["GET", "/"],
|
||||
["POST", "/"],
|
||||
["GET", "/last-session"],
|
||||
["PUT", "/last-session"],
|
||||
["PATCH", "/1"],
|
||||
["PUT", "/1/content"],
|
||||
["POST", "/1/duplicate"],
|
||||
["POST", "/1/set-default"],
|
||||
["POST", "/1/unset-default"],
|
||||
["POST", "/1/apply"],
|
||||
["DELETE", "/1"],
|
||||
] as const) {
|
||||
const res = await server.request(method, path, {
|
||||
body: { name: "x", payload: layout() },
|
||||
});
|
||||
expect(res.status, `${method} ${path}`).toBe(403);
|
||||
expect(res.body.required).toBe("workspaces.use");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,6 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, screen, waitFor } from "@testing-library/react";
|
||||
import type { PluginApiClient } from "@termix/plugin-sdk/frontend";
|
||||
import {
|
||||
renderWithApp,
|
||||
type RenderedPluginApp,
|
||||
@@ -51,3 +53,164 @@ describe(`${manifest.id} activate`, () => {
|
||||
expect(app.registered.actions()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
function workspace(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 1,
|
||||
userId: "user-1",
|
||||
name: "Prod",
|
||||
color: "#ef4444",
|
||||
icon: null,
|
||||
kind: "manual",
|
||||
isDefault: false,
|
||||
payload: {
|
||||
version: 1,
|
||||
tabs: [{ slotId: "s1", type: "network_graph", label: "Graph" }],
|
||||
activeSlotId: "s1",
|
||||
splitMode: "none",
|
||||
paneTabIds: [],
|
||||
rowSizes: [],
|
||||
rowColSizes: [],
|
||||
},
|
||||
syncId: "sync-1",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
lastUsedAt: null,
|
||||
tabCount: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function stubApi(workspaces: unknown[]) {
|
||||
return {
|
||||
get: vi.fn(async () => ({ data: workspaces })),
|
||||
post: vi.fn(async () => ({ data: workspaces[0] })),
|
||||
put: vi.fn(async () => ({ data: {} })),
|
||||
patch: vi.fn(async () => ({ data: {} })),
|
||||
delete: vi.fn(async () => ({ data: { success: true } })),
|
||||
};
|
||||
}
|
||||
|
||||
async function applyThroughPanel(app: RenderedPluginApp, name: string) {
|
||||
app.renderPanel("workspaces", { active: true });
|
||||
fireEvent.click(await screen.findByText(name));
|
||||
fireEvent.click(
|
||||
await screen.findByText("Apply Workspace", { selector: "button" }),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(app.shellCalls.some((call) => call.method === "applyLayout")).toBe(
|
||||
true,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
describe("workspaces panel", () => {
|
||||
it("gates its rail item on workspaces.use", async () => {
|
||||
rendered = await renderWithApp(plugin, { manifest, locales });
|
||||
expect(rendered.registered.railItems()).toEqual([
|
||||
expect.objectContaining({
|
||||
id: "workspaces",
|
||||
permission: "workspaces.use",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("lists the user's workspaces", async () => {
|
||||
const api = stubApi([
|
||||
workspace(),
|
||||
workspace({ id: 2, name: "Last Session", kind: "last_session" }),
|
||||
]);
|
||||
rendered = await renderWithApp(plugin, {
|
||||
manifest,
|
||||
locales,
|
||||
api: api as unknown as PluginApiClient,
|
||||
});
|
||||
rendered.renderPanel("workspaces", { active: true });
|
||||
|
||||
expect(await screen.findByText("Prod")).toBeTruthy();
|
||||
expect(
|
||||
screen.getByText(locales.newUi.sidebar.workspaces.lastSession),
|
||||
).toBeTruthy();
|
||||
expect(api.get).toHaveBeenCalledWith("/");
|
||||
});
|
||||
|
||||
it("applies a workspace to the tabs and marks it used", async () => {
|
||||
const saved = workspace();
|
||||
const api = stubApi([saved]);
|
||||
rendered = await renderWithApp(plugin, {
|
||||
manifest,
|
||||
locales,
|
||||
api: api as unknown as PluginApiClient,
|
||||
});
|
||||
|
||||
await applyThroughPanel(rendered, "Prod");
|
||||
|
||||
const call = rendered.shellCalls.find((c) => c.method === "applyLayout");
|
||||
expect(call?.args[0]).toEqual(saved.payload);
|
||||
expect(call?.args[1]).toEqual({ name: "Prod" });
|
||||
await waitFor(() => expect(api.post).toHaveBeenCalledWith("/1/apply"));
|
||||
});
|
||||
|
||||
it("keeps a tab from a plugin that is not running and shows its placeholder", async () => {
|
||||
const saved = workspace({
|
||||
payload: {
|
||||
version: 1,
|
||||
tabs: [{ slotId: "s1", type: "not-installed-view", label: "Gone" }],
|
||||
activeSlotId: "s1",
|
||||
splitMode: "none",
|
||||
paneTabIds: [],
|
||||
rowSizes: [],
|
||||
rowColSizes: [],
|
||||
},
|
||||
});
|
||||
rendered = await renderWithApp(plugin, {
|
||||
manifest,
|
||||
locales,
|
||||
api: stubApi([saved]) as unknown as PluginApiClient,
|
||||
});
|
||||
|
||||
await applyThroughPanel(rendered, "Prod");
|
||||
|
||||
expect(rendered.openedTabs()).toEqual([
|
||||
{ type: "not-installed-view", hostId: undefined, label: "Gone" },
|
||||
]);
|
||||
const tab = rendered.renderOpenedTab(0);
|
||||
expect(
|
||||
tab.querySelector('[data-testid="plugin-view-placeholder"]'),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it("restores the default workspace once the shell is ready", async () => {
|
||||
const saved = workspace({ isDefault: true });
|
||||
rendered = await renderWithApp(plugin, {
|
||||
manifest,
|
||||
locales,
|
||||
permissions: ["workspaces.use"],
|
||||
ready: true,
|
||||
api: stubApi([saved]) as unknown as PluginApiClient,
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
rendered!.shellCalls.find((c) => c.method === "applyLayout")?.args[0],
|
||||
).toEqual(saved.payload),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not restore or autosave for a user without workspaces.use", async () => {
|
||||
const api = stubApi([workspace({ isDefault: true })]);
|
||||
rendered = await renderWithApp(plugin, {
|
||||
manifest,
|
||||
locales,
|
||||
permissions: [],
|
||||
ready: true,
|
||||
api: api as unknown as PluginApiClient,
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
expect(api.get).not.toHaveBeenCalled();
|
||||
expect(rendered.shellCalls.some((c) => c.method === "applyLayout")).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,133 +1,57 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { PluginApiClient } from "@termix/plugin-sdk/frontend";
|
||||
import { createWorkspacesApi } from "../../src/frontend/workspaces-api";
|
||||
|
||||
const authApiMock = vi.hoisted(() => ({
|
||||
get: vi.fn(async () => ({ data: [] })),
|
||||
post: vi.fn(async () => ({ data: {} })),
|
||||
patch: vi.fn(async () => ({ data: {} })),
|
||||
put: vi.fn(async () => ({ data: {} })),
|
||||
delete: vi.fn(async () => ({ data: { success: true } })),
|
||||
}));
|
||||
function fakeClient() {
|
||||
const reply = (data: unknown) => vi.fn(async () => ({ data }));
|
||||
return {
|
||||
get: reply([]),
|
||||
post: reply({ id: 1 }),
|
||||
put: reply({ id: 1 }),
|
||||
patch: reply({ id: 1 }),
|
||||
delete: reply({ success: true }),
|
||||
};
|
||||
}
|
||||
|
||||
vi.mock("@/main-axios", () => ({
|
||||
authApi: authApiMock,
|
||||
handleApiError: (error: unknown) => error,
|
||||
}));
|
||||
const layout = { version: 1, tabs: [] };
|
||||
|
||||
import {
|
||||
listWorkspaces,
|
||||
createWorkspace,
|
||||
renameWorkspace,
|
||||
updateWorkspaceContent,
|
||||
deleteWorkspace,
|
||||
duplicateWorkspace,
|
||||
setDefaultWorkspace,
|
||||
applyWorkspaceServer,
|
||||
getLastSessionWorkspace,
|
||||
saveLastSessionWorkspace,
|
||||
} from "../../src/frontend/workspaces-api";
|
||||
import type { WorkspacePayload } from "@/types/ui-types";
|
||||
describe("workspaces api", () => {
|
||||
it("calls each route relative to the plugin's own mount point", async () => {
|
||||
const client = fakeClient();
|
||||
const api = createWorkspacesApi(client as unknown as PluginApiClient);
|
||||
|
||||
beforeEach(() => {
|
||||
authApiMock.get.mockClear();
|
||||
authApiMock.post.mockClear();
|
||||
authApiMock.patch.mockClear();
|
||||
authApiMock.put.mockClear();
|
||||
authApiMock.delete.mockClear();
|
||||
});
|
||||
await api.list();
|
||||
await api.create({ name: "A", color: "#fff", payload: layout });
|
||||
await api.rename(3, { name: "B" });
|
||||
await api.updateContent(3, layout);
|
||||
await api.remove(3);
|
||||
await api.duplicate(3, "C");
|
||||
await api.setDefault(3);
|
||||
await api.unsetDefault(3);
|
||||
await api.apply(3);
|
||||
await api.getLastSession();
|
||||
await api.saveLastSession(layout);
|
||||
|
||||
const samplePayload: WorkspacePayload = {
|
||||
version: 1,
|
||||
tabs: [],
|
||||
activeSlotId: null,
|
||||
splitMode: "none",
|
||||
paneTabIds: [null, null, null, null, null, null],
|
||||
rowSizes: [100],
|
||||
rowColSizes: [[100]],
|
||||
};
|
||||
|
||||
describe("workspaces-api", () => {
|
||||
it("listWorkspaces GETs /plugin-api/workspaces", async () => {
|
||||
authApiMock.get.mockResolvedValueOnce({ data: [{ id: 1 }] });
|
||||
const result = await listWorkspaces();
|
||||
expect(authApiMock.get).toHaveBeenCalledWith("/plugin-api/workspaces");
|
||||
expect(result).toEqual([{ id: 1 }]);
|
||||
expect(client.get.mock.calls).toEqual([["/"], ["/last-session"]]);
|
||||
expect(client.post.mock.calls).toEqual([
|
||||
["/", { name: "A", color: "#fff", payload: layout }],
|
||||
["/3/duplicate", { name: "C" }],
|
||||
["/3/set-default"],
|
||||
["/3/unset-default"],
|
||||
["/3/apply"],
|
||||
]);
|
||||
expect(client.patch.mock.calls).toEqual([["/3", { name: "B" }]]);
|
||||
expect(client.put.mock.calls).toEqual([
|
||||
["/3/content", { payload: layout }],
|
||||
["/last-session", { payload: layout }],
|
||||
]);
|
||||
expect(client.delete.mock.calls).toEqual([["/3"]]);
|
||||
});
|
||||
|
||||
it("createWorkspace POSTs /plugin-api/workspaces with the payload", async () => {
|
||||
await createWorkspace({
|
||||
name: "Test A",
|
||||
color: "#fff",
|
||||
payload: samplePayload,
|
||||
});
|
||||
expect(authApiMock.post).toHaveBeenCalledWith("/plugin-api/workspaces", {
|
||||
name: "Test A",
|
||||
color: "#fff",
|
||||
payload: samplePayload,
|
||||
});
|
||||
});
|
||||
|
||||
it("renameWorkspace PATCHes /plugin-api/workspaces/:id", async () => {
|
||||
await renameWorkspace(5, { name: "New Name" });
|
||||
expect(authApiMock.patch).toHaveBeenCalledWith("/plugin-api/workspaces/5", {
|
||||
name: "New Name",
|
||||
});
|
||||
});
|
||||
|
||||
it("updateWorkspaceContent PUTs /plugin-api/workspaces/:id/content", async () => {
|
||||
await updateWorkspaceContent(5, samplePayload);
|
||||
expect(authApiMock.put).toHaveBeenCalledWith(
|
||||
"/plugin-api/workspaces/5/content",
|
||||
{
|
||||
payload: samplePayload,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("deleteWorkspace DELETEs /plugin-api/workspaces/:id", async () => {
|
||||
await deleteWorkspace(5);
|
||||
expect(authApiMock.delete).toHaveBeenCalledWith("/plugin-api/workspaces/5");
|
||||
});
|
||||
|
||||
it("duplicateWorkspace POSTs /plugin-api/workspaces/:id/duplicate with a name", async () => {
|
||||
await duplicateWorkspace(5, "Test A (copy)");
|
||||
expect(authApiMock.post).toHaveBeenCalledWith(
|
||||
"/plugin-api/workspaces/5/duplicate",
|
||||
{
|
||||
name: "Test A (copy)",
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("setDefaultWorkspace POSTs /plugin-api/workspaces/:id/set-default", async () => {
|
||||
await setDefaultWorkspace(5);
|
||||
expect(authApiMock.post).toHaveBeenCalledWith(
|
||||
"/plugin-api/workspaces/5/set-default",
|
||||
);
|
||||
});
|
||||
|
||||
it("applyWorkspaceServer POSTs /plugin-api/workspaces/:id/apply", async () => {
|
||||
await applyWorkspaceServer(5);
|
||||
expect(authApiMock.post).toHaveBeenCalledWith(
|
||||
"/plugin-api/workspaces/5/apply",
|
||||
);
|
||||
});
|
||||
|
||||
it("getLastSessionWorkspace GETs /plugin-api/workspaces/last-session", async () => {
|
||||
authApiMock.get.mockResolvedValueOnce({ data: null });
|
||||
const result = await getLastSessionWorkspace();
|
||||
expect(authApiMock.get).toHaveBeenCalledWith(
|
||||
"/plugin-api/workspaces/last-session",
|
||||
);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("saveLastSessionWorkspace PUTs /plugin-api/workspaces/last-session", async () => {
|
||||
await saveLastSessionWorkspace(samplePayload);
|
||||
expect(authApiMock.put).toHaveBeenCalledWith(
|
||||
"/plugin-api/workspaces/last-session",
|
||||
{
|
||||
payload: samplePayload,
|
||||
},
|
||||
);
|
||||
it("returns the response body", async () => {
|
||||
const client = fakeClient();
|
||||
client.get = vi.fn(async () => ({ data: [{ id: 7 }] }));
|
||||
const api = createWorkspacesApi(client as unknown as PluginApiClient);
|
||||
expect(await api.list()).toEqual([{ id: 7 }]);
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,4 @@
|
||||
{
|
||||
"extends": "@termix/plugin-sdk/tsconfig.plugin.json",
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"@/types/*": ["../../src/types/*"],
|
||||
"@/*": ["../../src/ui/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts", "tests/**/*.tsx"]
|
||||
}
|
||||
Reference in new issue
Block a user