From f9f063ca011ce3a2ce421b0516a030b4aa505052 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Thu, 26 Feb 2026 17:45:49 +0700 Subject: [PATCH] fix(shared): polish shared data UX and route persistence --- src/web-server/shared-routes.ts | 222 ++++- tests/unit/web-server/shared-routes.test.ts | 128 +++ ui/src/App.tsx | 14 +- ui/src/components/layout/app-sidebar.tsx | 9 +- ui/src/components/layout/layout.tsx | 10 +- ui/src/hooks/use-shared.ts | 110 ++- ui/src/lib/last-route.ts | 37 + ui/src/pages/shared.tsx | 803 ++++++++++++++++-- .../unit/ui/pages/shared/shared-page.test.tsx | 131 +++ 9 files changed, 1366 insertions(+), 98 deletions(-) create mode 100644 ui/src/lib/last-route.ts create mode 100644 ui/tests/unit/ui/pages/shared/shared-page.test.tsx diff --git a/src/web-server/shared-routes.ts b/src/web-server/shared-routes.ts index 507270e5..af876196 100644 --- a/src/web-server/shared-routes.ts +++ b/src/web-server/shared-routes.ts @@ -16,6 +16,10 @@ export const sharedRoutes = Router(); const MAX_DIRECTORY_TRAVERSAL_DEPTH = 10; const MAX_DESCRIPTION_LENGTH = 140; const MAX_MARKDOWN_FILE_BYTES = 1024 * 1024; // 1 MiB +const MAX_CONTENT_FILE_BYTES = 2 * 1024 * 1024; // 2 MiB +const SHARED_ITEMS_CACHE_TTL_MS = 1000; + +type SharedCollectionType = 'commands' | 'skills' | 'agents'; interface SharedItem { name: string; @@ -24,6 +28,14 @@ interface SharedItem { type: 'command' | 'skill' | 'agent'; } +interface SharedItemsCacheEntry { + items: SharedItem[]; + sharedDir: string; + expiresAt: number; +} + +const sharedItemsCache = new Map(); + /** * GET /api/shared/commands */ @@ -48,6 +60,41 @@ sharedRoutes.get('/agents', (_req: Request, res: Response) => { res.json({ items }); }); +/** + * GET /api/shared/content?type=commands|skills|agents&path= + */ +sharedRoutes.get('/content', (req: Request, res: Response) => { + const typeParam = req.query.type; + const itemPathParam = req.query.path; + + if (!isSharedCollectionType(typeParam)) { + res.status(400).json({ error: 'Invalid or missing type parameter' }); + return; + } + if (typeof itemPathParam !== 'string' || itemPathParam.trim().length === 0) { + res.status(400).json({ error: 'Invalid or missing path parameter' }); + return; + } + + const ccsDir = getCcsDir(); + const sharedDir = path.join(ccsDir, 'shared', typeParam); + if (!fs.existsSync(sharedDir)) { + res.status(404).json({ error: 'Shared directory not found' }); + return; + } + + const sharedDirRoot = safeRealPath(sharedDir) ?? path.resolve(sharedDir); + const allowedRoots = resolveAllowedRoots(typeParam, ccsDir, sharedDirRoot); + const contentResult = getSharedItemContent(typeParam, itemPathParam, allowedRoots); + + if (!contentResult) { + res.status(404).json({ error: 'Shared content not found' }); + return; + } + + res.json(contentResult); +}); + /** * GET /api/shared/summary */ @@ -65,17 +112,20 @@ sharedRoutes.get('/summary', (_req: Request, res: Response) => { }); }); -function getSharedItems(type: 'commands' | 'skills' | 'agents'): SharedItem[] { - const ccsDir = getCcsDir(); - const sharedDir = path.join(ccsDir, 'shared', type); +function isSharedCollectionType(value: unknown): value is SharedCollectionType { + return value === 'commands' || value === 'skills' || value === 'agents'; +} - if (!fs.existsSync(sharedDir)) { - return []; +function resolveAllowedRoots( + type: SharedCollectionType, + ccsDir: string, + sharedDirRoot: string +): Set { + if (type === 'commands') { + return new Set([sharedDirRoot]); } - const items: SharedItem[] = []; - const sharedDirRoot = safeRealPath(sharedDir) ?? path.resolve(sharedDir); - const allowedRoots = new Set([ + return new Set([ sharedDirRoot, ...[ path.join(getClaudeConfigDir(), type), @@ -85,9 +135,35 @@ function getSharedItems(type: 'commands' | 'skills' | 'agents'): SharedItem[] { .map((dirPath) => safeRealPath(dirPath)) .filter((dirPath): dirPath is string => typeof dirPath === 'string'), ]); +} + +function getSharedItems(type: SharedCollectionType): SharedItem[] { + const ccsDir = getCcsDir(); + const sharedDir = path.join(ccsDir, 'shared', type); + const now = Date.now(); + + if (!fs.existsSync(sharedDir)) { + sharedItemsCache.delete(type); + return []; + } + + const cached = sharedItemsCache.get(type); + if (cached && cached.sharedDir === sharedDir && cached.expiresAt > now) { + return cached.items; + } + + const items: SharedItem[] = []; + const sharedDirRoot = safeRealPath(sharedDir) ?? path.resolve(sharedDir); + const allowedRoots = resolveAllowedRoots(type, ccsDir, sharedDirRoot); if (type === 'commands') { - return getCommandItems(sharedDir, allowedRoots); + const commandItems = getCommandItems(sharedDir, allowedRoots); + sharedItemsCache.set(type, { + items: commandItems, + sharedDir, + expiresAt: now + SHARED_ITEMS_CACHE_TTL_MS, + }); + return commandItems; } try { @@ -123,7 +199,13 @@ function getSharedItems(type: 'commands' | 'skills' | 'agents'): SharedItem[] { // Directory read failed } - return items.sort((a, b) => a.name.localeCompare(b.name)); + const sortedItems = items.sort((a, b) => a.name.localeCompare(b.name)); + sharedItemsCache.set(type, { + items: sortedItems, + sharedDir, + expiresAt: now + SHARED_ITEMS_CACHE_TTL_MS, + }); + return sortedItems; } function getCommandItems(sharedDir: string, allowedRoots: Set): SharedItem[] { @@ -300,7 +382,7 @@ function extractDescription(content: string): string { const lines = stripFrontmatter(content).split('\n'); for (const line of lines) { const trimmed = line.trim(); - if (trimmed && !trimmed.startsWith('#') && !trimmed.startsWith('