mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15: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
@@ -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>;
|
||||
Reference in new issue
Block a user