mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-03 00:17:47 +00:00
fix(shared): polish shared data UX and route persistence
This commit is contained in:
+211
-11
@@ -16,6 +16,10 @@ export const sharedRoutes = Router();
|
|||||||
const MAX_DIRECTORY_TRAVERSAL_DEPTH = 10;
|
const MAX_DIRECTORY_TRAVERSAL_DEPTH = 10;
|
||||||
const MAX_DESCRIPTION_LENGTH = 140;
|
const MAX_DESCRIPTION_LENGTH = 140;
|
||||||
const MAX_MARKDOWN_FILE_BYTES = 1024 * 1024; // 1 MiB
|
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 {
|
interface SharedItem {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -24,6 +28,14 @@ interface SharedItem {
|
|||||||
type: 'command' | 'skill' | 'agent';
|
type: 'command' | 'skill' | 'agent';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SharedItemsCacheEntry {
|
||||||
|
items: SharedItem[];
|
||||||
|
sharedDir: string;
|
||||||
|
expiresAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sharedItemsCache = new Map<SharedCollectionType, SharedItemsCacheEntry>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/shared/commands
|
* GET /api/shared/commands
|
||||||
*/
|
*/
|
||||||
@@ -48,6 +60,41 @@ sharedRoutes.get('/agents', (_req: Request, res: Response) => {
|
|||||||
res.json({ items });
|
res.json({ items });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/shared/content?type=commands|skills|agents&path=<item-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
|
* GET /api/shared/summary
|
||||||
*/
|
*/
|
||||||
@@ -65,17 +112,20 @@ sharedRoutes.get('/summary', (_req: Request, res: Response) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function getSharedItems(type: 'commands' | 'skills' | 'agents'): SharedItem[] {
|
function isSharedCollectionType(value: unknown): value is SharedCollectionType {
|
||||||
const ccsDir = getCcsDir();
|
return value === 'commands' || value === 'skills' || value === 'agents';
|
||||||
const sharedDir = path.join(ccsDir, 'shared', type);
|
}
|
||||||
|
|
||||||
if (!fs.existsSync(sharedDir)) {
|
function resolveAllowedRoots(
|
||||||
return [];
|
type: SharedCollectionType,
|
||||||
|
ccsDir: string,
|
||||||
|
sharedDirRoot: string
|
||||||
|
): Set<string> {
|
||||||
|
if (type === 'commands') {
|
||||||
|
return new Set<string>([sharedDirRoot]);
|
||||||
}
|
}
|
||||||
|
|
||||||
const items: SharedItem[] = [];
|
return new Set<string>([
|
||||||
const sharedDirRoot = safeRealPath(sharedDir) ?? path.resolve(sharedDir);
|
|
||||||
const allowedRoots = new Set<string>([
|
|
||||||
sharedDirRoot,
|
sharedDirRoot,
|
||||||
...[
|
...[
|
||||||
path.join(getClaudeConfigDir(), type),
|
path.join(getClaudeConfigDir(), type),
|
||||||
@@ -85,9 +135,35 @@ function getSharedItems(type: 'commands' | 'skills' | 'agents'): SharedItem[] {
|
|||||||
.map((dirPath) => safeRealPath(dirPath))
|
.map((dirPath) => safeRealPath(dirPath))
|
||||||
.filter((dirPath): dirPath is string => typeof dirPath === 'string'),
|
.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') {
|
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 {
|
try {
|
||||||
@@ -123,7 +199,13 @@ function getSharedItems(type: 'commands' | 'skills' | 'agents'): SharedItem[] {
|
|||||||
// Directory read failed
|
// 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<string>): SharedItem[] {
|
function getCommandItems(sharedDir: string, allowedRoots: Set<string>): SharedItem[] {
|
||||||
@@ -300,7 +382,7 @@ function extractDescription(content: string): string {
|
|||||||
const lines = stripFrontmatter(content).split('\n');
|
const lines = stripFrontmatter(content).split('\n');
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
const trimmed = line.trim();
|
const trimmed = line.trim();
|
||||||
if (trimmed && !trimmed.startsWith('#') && !trimmed.startsWith('<!--')) {
|
if (isDescriptionBodyLine(trimmed)) {
|
||||||
return trimDescription(trimmed);
|
return trimDescription(trimmed);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -308,6 +390,18 @@ function extractDescription(content: string): string {
|
|||||||
return 'No description';
|
return 'No description';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isDescriptionBodyLine(line: string): boolean {
|
||||||
|
if (!line) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line === '---' || line === '...') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return !line.startsWith('#') && !line.startsWith('<!--');
|
||||||
|
}
|
||||||
|
|
||||||
function extractFrontmatterDescription(content: string): string | null {
|
function extractFrontmatterDescription(content: string): string | null {
|
||||||
const frontmatterMatch = content.match(/^---\s*\r?\n([\s\S]*?)\r?\n---(?:\s*\r?\n|$)/);
|
const frontmatterMatch = content.match(/^---\s*\r?\n([\s\S]*?)\r?\n---(?:\s*\r?\n|$)/);
|
||||||
if (!frontmatterMatch) {
|
if (!frontmatterMatch) {
|
||||||
@@ -375,6 +469,112 @@ function readMarkdownDescription(markdownPath: string, allowedRoots: Set<string>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readMarkdownContent(markdownPath: string, allowedRoots: Set<string>): string | null {
|
||||||
|
try {
|
||||||
|
const resolvedMarkdownPath = safeRealPath(markdownPath);
|
||||||
|
if (!resolvedMarkdownPath || !isPathWithinAny(resolvedMarkdownPath, allowedRoots)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = fs.statSync(resolvedMarkdownPath);
|
||||||
|
if (!stats.isFile()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (stats.size > MAX_CONTENT_FILE_BYTES) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return fs.readFileSync(resolvedMarkdownPath, 'utf8');
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveReadableMarkdownPath(
|
||||||
|
markdownPaths: string[],
|
||||||
|
allowedRoots: Set<string>
|
||||||
|
): string | null {
|
||||||
|
for (const markdownPath of markdownPaths) {
|
||||||
|
const resolvedMarkdownPath = safeRealPath(markdownPath);
|
||||||
|
if (!resolvedMarkdownPath || !isPathWithinAny(resolvedMarkdownPath, allowedRoots)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stats = fs.statSync(resolvedMarkdownPath);
|
||||||
|
if (!stats.isFile() || stats.size > MAX_CONTENT_FILE_BYTES) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return resolvedMarkdownPath;
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSharedItemContent(
|
||||||
|
type: SharedCollectionType,
|
||||||
|
itemPath: string,
|
||||||
|
allowedRoots: Set<string>
|
||||||
|
): { content: string; contentPath: string } | null {
|
||||||
|
const resolvedItemPath = safeRealPath(itemPath);
|
||||||
|
if (!resolvedItemPath || !isPathWithinAny(resolvedItemPath, allowedRoots)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let itemStats: fs.Stats;
|
||||||
|
try {
|
||||||
|
itemStats = fs.statSync(resolvedItemPath);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let markdownPath: string | null = null;
|
||||||
|
if (type === 'commands') {
|
||||||
|
if (!itemStats.isFile() || !itemPath.toLowerCase().endsWith('.md')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
markdownPath = resolvedItemPath;
|
||||||
|
} else if (type === 'skills') {
|
||||||
|
if (!itemStats.isDirectory()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
markdownPath = resolveReadableMarkdownPath(
|
||||||
|
[path.join(resolvedItemPath, 'SKILL.md')],
|
||||||
|
allowedRoots
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
if (itemStats.isDirectory()) {
|
||||||
|
markdownPath = resolveReadableMarkdownPath(
|
||||||
|
[
|
||||||
|
path.join(resolvedItemPath, 'prompt.md'),
|
||||||
|
path.join(resolvedItemPath, 'AGENT.md'),
|
||||||
|
path.join(resolvedItemPath, 'agent.md'),
|
||||||
|
],
|
||||||
|
allowedRoots
|
||||||
|
);
|
||||||
|
} else if (itemStats.isFile() && itemPath.toLowerCase().endsWith('.md')) {
|
||||||
|
markdownPath = resolvedItemPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!markdownPath) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = readMarkdownContent(markdownPath, allowedRoots);
|
||||||
|
if (!content) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
content,
|
||||||
|
contentPath: markdownPath,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function safeRealPath(targetPath: string): string | null {
|
function safeRealPath(targetPath: string): string | null {
|
||||||
try {
|
try {
|
||||||
return fs.realpathSync(targetPath);
|
return fs.realpathSync(targetPath);
|
||||||
|
|||||||
@@ -231,6 +231,75 @@ describe('web-server shared-routes', () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('returns full content for a shared command markdown file', async () => {
|
||||||
|
const commandsDir = path.join(ccsDir, 'shared', 'commands');
|
||||||
|
fs.mkdirSync(commandsDir, { recursive: true });
|
||||||
|
const commandPath = path.join(commandsDir, 'review.md');
|
||||||
|
fs.writeFileSync(commandPath, '# Review\n\nDetailed workflow steps');
|
||||||
|
|
||||||
|
const payload = await getJson<{ content: string; contentPath: string }>(
|
||||||
|
baseUrl,
|
||||||
|
`/api/shared/content?type=commands&path=${encodeURIComponent(commandPath)}`
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(payload.content).toContain('Detailed workflow steps');
|
||||||
|
expect(payload.contentPath).toBe(fs.realpathSync(commandPath));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns full content for a shared skill from SKILL.md', async () => {
|
||||||
|
const skillsDir = path.join(ccsDir, 'shared', 'skills');
|
||||||
|
fs.mkdirSync(skillsDir, { recursive: true });
|
||||||
|
|
||||||
|
const skillTargetDir = path.join(ccsDir, '.claude', 'skills', 'planner-skill');
|
||||||
|
fs.mkdirSync(skillTargetDir, { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(skillTargetDir, 'SKILL.md'),
|
||||||
|
[
|
||||||
|
'---',
|
||||||
|
'description: Plan things',
|
||||||
|
'---',
|
||||||
|
'',
|
||||||
|
'# Planner Skill',
|
||||||
|
'',
|
||||||
|
'Full skill details',
|
||||||
|
].join('\n')
|
||||||
|
);
|
||||||
|
|
||||||
|
const linkPath = path.join(skillsDir, 'planner-skill');
|
||||||
|
createDirectorySymlink(skillTargetDir, linkPath);
|
||||||
|
|
||||||
|
const payload = await getJson<{ content: string; contentPath: string }>(
|
||||||
|
baseUrl,
|
||||||
|
`/api/shared/content?type=skills&path=${encodeURIComponent(linkPath)}`
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(payload.content).toContain('Full skill details');
|
||||||
|
expect(payload.contentPath).toBe(fs.realpathSync(path.join(skillTargetDir, 'SKILL.md')));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not use markdown frontmatter fences as descriptions when frontmatter is malformed', async () => {
|
||||||
|
const commandsDir = path.join(ccsDir, 'shared', 'commands');
|
||||||
|
fs.mkdirSync(commandsDir, { recursive: true });
|
||||||
|
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(commandsDir, 'broken-frontmatter.md'),
|
||||||
|
['---', '', '# Heading', '', 'Fallback body description'].join('\n')
|
||||||
|
);
|
||||||
|
|
||||||
|
const payload = await getJson<{
|
||||||
|
items: Array<{ name: string; type: string; description: string; path: string }>;
|
||||||
|
}>(baseUrl, '/api/shared/commands');
|
||||||
|
|
||||||
|
expect(payload.items).toEqual([
|
||||||
|
{
|
||||||
|
name: 'broken-frontmatter',
|
||||||
|
type: 'command',
|
||||||
|
description: 'Fallback body description',
|
||||||
|
path: path.join(commandsDir, 'broken-frontmatter.md'),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it('skips markdown files deeper than traversal depth limit', async () => {
|
it('skips markdown files deeper than traversal depth limit', async () => {
|
||||||
const commandsDir = path.join(ccsDir, 'shared', 'commands');
|
const commandsDir = path.join(ccsDir, 'shared', 'commands');
|
||||||
let currentDir = commandsDir;
|
let currentDir = commandsDir;
|
||||||
@@ -378,6 +447,65 @@ describe('web-server shared-routes', () => {
|
|||||||
expect(payload.items).toEqual([]);
|
expect(payload.items).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('rejects shared command content lookups that escape the commands root', async () => {
|
||||||
|
const commandsDir = path.join(ccsDir, 'shared', 'commands');
|
||||||
|
fs.mkdirSync(commandsDir, { recursive: true });
|
||||||
|
|
||||||
|
const outsideMarkdown = path.join(tempHome, 'outside-command.md');
|
||||||
|
fs.writeFileSync(outsideMarkdown, 'Outside command should not be read');
|
||||||
|
const escapedLinkPath = path.join(commandsDir, 'outside.md');
|
||||||
|
createFileSymlink(outsideMarkdown, escapedLinkPath);
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`${baseUrl}/api/shared/content?type=commands&path=${encodeURIComponent(escapedLinkPath)}`
|
||||||
|
);
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
|
||||||
|
const payload = (await response.json()) as { error: string };
|
||||||
|
expect(payload.error).toBe('Shared content not found');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 400 for invalid shared content query parameters', async () => {
|
||||||
|
const missingType = await fetch(
|
||||||
|
`${baseUrl}/api/shared/content?path=${encodeURIComponent('/tmp/a.md')}`
|
||||||
|
);
|
||||||
|
expect(missingType.status).toBe(400);
|
||||||
|
|
||||||
|
const missingPath = await fetch(`${baseUrl}/api/shared/content?type=commands`);
|
||||||
|
expect(missingPath.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores command file symlinks that escape into CCS_HOME/.claude/commands', async () => {
|
||||||
|
const commandsDir = path.join(ccsDir, 'shared', 'commands');
|
||||||
|
fs.mkdirSync(commandsDir, { recursive: true });
|
||||||
|
|
||||||
|
const claudeCommandsDir = path.join(ccsDir, '.claude', 'commands');
|
||||||
|
fs.mkdirSync(claudeCommandsDir, { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(claudeCommandsDir, 'borrowed.md'),
|
||||||
|
'Borrowed command should be ignored'
|
||||||
|
);
|
||||||
|
|
||||||
|
fs.writeFileSync(path.join(commandsDir, 'local.md'), 'Local command stays visible');
|
||||||
|
createFileSymlink(
|
||||||
|
path.join(claudeCommandsDir, 'borrowed.md'),
|
||||||
|
path.join(commandsDir, 'borrowed.md')
|
||||||
|
);
|
||||||
|
|
||||||
|
const payload = await getJson<{
|
||||||
|
items: Array<{ name: string; type: string; description: string; path: string }>;
|
||||||
|
}>(baseUrl, '/api/shared/commands');
|
||||||
|
|
||||||
|
expect(payload.items).toEqual([
|
||||||
|
{
|
||||||
|
name: 'local',
|
||||||
|
type: 'command',
|
||||||
|
description: 'Local command stays visible',
|
||||||
|
path: path.join(commandsDir, 'local.md'),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it('summary uses CLAUDE_CONFIG_DIR for symlink status and counts', async () => {
|
it('summary uses CLAUDE_CONFIG_DIR for symlink status and counts', async () => {
|
||||||
const sharedDir = path.join(ccsDir, 'shared');
|
const sharedDir = path.join(ccsDir, 'shared');
|
||||||
const claudeConfigDir = path.join(tempHome, 'custom-claude');
|
const claudeConfigDir = path.join(tempHome, 'custom-claude');
|
||||||
|
|||||||
+12
-2
@@ -1,5 +1,5 @@
|
|||||||
import { lazy, Suspense } from 'react';
|
import { lazy, Suspense } from 'react';
|
||||||
import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
import { BrowserRouter, Navigate, Routes, Route } from 'react-router-dom';
|
||||||
import { QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClientProvider } from '@tanstack/react-query';
|
||||||
import { Toaster } from 'sonner';
|
import { Toaster } from 'sonner';
|
||||||
import { queryClient } from '@/lib/query-client';
|
import { queryClient } from '@/lib/query-client';
|
||||||
@@ -8,6 +8,7 @@ import { PrivacyProvider } from '@/contexts/privacy-context';
|
|||||||
import { AuthProvider } from '@/contexts/auth-context';
|
import { AuthProvider } from '@/contexts/auth-context';
|
||||||
import { RequireAuth } from '@/components/auth/require-auth';
|
import { RequireAuth } from '@/components/auth/require-auth';
|
||||||
import { Layout } from '@/components/layout/layout';
|
import { Layout } from '@/components/layout/layout';
|
||||||
|
import { getStoredLastRoute, shouldRestoreRoute } from '@/lib/last-route';
|
||||||
import { Loader2 } from 'lucide-react';
|
import { Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
// Eager load: HomePage (initial route) + LoginPage (auth flow)
|
// Eager load: HomePage (initial route) + LoginPage (auth flow)
|
||||||
@@ -47,6 +48,15 @@ function PageLoader() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function HomeEntryRoute() {
|
||||||
|
const lastRoute = getStoredLastRoute();
|
||||||
|
if (shouldRestoreRoute(lastRoute)) {
|
||||||
|
return <Navigate to={lastRoute} replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <HomePage />;
|
||||||
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
@@ -61,7 +71,7 @@ export default function App() {
|
|||||||
{/* Protected routes: wrapped with RequireAuth */}
|
{/* Protected routes: wrapped with RequireAuth */}
|
||||||
<Route element={<RequireAuth />}>
|
<Route element={<RequireAuth />}>
|
||||||
<Route element={<Layout />}>
|
<Route element={<Layout />}>
|
||||||
<Route path="/" element={<HomePage />} />
|
<Route path="/" element={<HomeEntryRoute />} />
|
||||||
<Route
|
<Route
|
||||||
path="/analytics"
|
path="/analytics"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -145,6 +145,13 @@ export function AppSidebar() {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getPrimaryRoute = (item: SidebarItem) => {
|
||||||
|
if (item.path === '/accounts') {
|
||||||
|
return '/shared';
|
||||||
|
}
|
||||||
|
return item.path;
|
||||||
|
};
|
||||||
|
|
||||||
const renderMenuIcon = (item: Pick<SidebarItem, 'icon' | 'iconSrc'>) => {
|
const renderMenuIcon = (item: Pick<SidebarItem, 'icon' | 'iconSrc'>) => {
|
||||||
if (item.iconSrc) {
|
if (item.iconSrc) {
|
||||||
return <img src={item.iconSrc} alt="" className="w-4 h-4 object-contain" />;
|
return <img src={item.iconSrc} alt="" className="w-4 h-4 object-contain" />;
|
||||||
@@ -180,7 +187,7 @@ export function AppSidebar() {
|
|||||||
<SidebarMenuButton
|
<SidebarMenuButton
|
||||||
tooltip={getItemLabel(item)}
|
tooltip={getItemLabel(item)}
|
||||||
isActive={isParentActive(item.children)}
|
isActive={isParentActive(item.children)}
|
||||||
onClick={() => navigate(item.path)}
|
onClick={() => navigate(getPrimaryRoute(item))}
|
||||||
>
|
>
|
||||||
{renderMenuIcon(item)}
|
{renderMenuIcon(item)}
|
||||||
<span className="group-data-[collapsible=icon]:hidden">
|
<span className="group-data-[collapsible=icon]:hidden">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Suspense } from 'react';
|
import { Suspense, useEffect } from 'react';
|
||||||
import { Outlet } from 'react-router-dom';
|
import { Outlet, useLocation } from 'react-router-dom';
|
||||||
import { SidebarProvider } from '@/components/ui/sidebar';
|
import { SidebarProvider } from '@/components/ui/sidebar';
|
||||||
import { AppSidebar } from './app-sidebar';
|
import { AppSidebar } from './app-sidebar';
|
||||||
import { ThemeToggle } from './theme-toggle';
|
import { ThemeToggle } from './theme-toggle';
|
||||||
@@ -16,6 +16,7 @@ import { DeviceCodeDialog } from '@/components/shared/device-code-dialog';
|
|||||||
import { UserMenu } from '@/components/auth/user-menu';
|
import { UserMenu } from '@/components/auth/user-menu';
|
||||||
import { useProjectSelection } from '@/hooks/use-project-selection';
|
import { useProjectSelection } from '@/hooks/use-project-selection';
|
||||||
import { useDeviceCode } from '@/hooks/use-device-code';
|
import { useDeviceCode } from '@/hooks/use-device-code';
|
||||||
|
import { storeLastRoute } from '@/lib/last-route';
|
||||||
|
|
||||||
function PageLoader() {
|
function PageLoader() {
|
||||||
return (
|
return (
|
||||||
@@ -27,9 +28,14 @@ function PageLoader() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Layout() {
|
export function Layout() {
|
||||||
|
const location = useLocation();
|
||||||
const { isOpen, prompt, onSelect, onClose } = useProjectSelection();
|
const { isOpen, prompt, onSelect, onClose } = useProjectSelection();
|
||||||
const deviceCode = useDeviceCode();
|
const deviceCode = useDeviceCode();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
storeLastRoute(location.pathname, location.search, location.hash);
|
||||||
|
}, [location.pathname, location.search, location.hash]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarProvider>
|
<SidebarProvider>
|
||||||
<AppSidebar />
|
<AppSidebar />
|
||||||
|
|||||||
+107
-3
@@ -1,6 +1,6 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
interface SharedItem {
|
export interface SharedItem {
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
path: string;
|
path: string;
|
||||||
@@ -15,12 +15,64 @@ interface SharedSummary {
|
|||||||
symlinkStatus: { valid: boolean; message: string };
|
symlinkStatus: { valid: boolean; message: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SharedItemContent {
|
||||||
|
content: string;
|
||||||
|
contentPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractErrorFromPayload(payload: unknown, fallbackMessage: string): string {
|
||||||
|
if (
|
||||||
|
typeof payload === 'object' &&
|
||||||
|
payload !== null &&
|
||||||
|
'error' in payload &&
|
||||||
|
typeof payload.error === 'string'
|
||||||
|
) {
|
||||||
|
return payload.error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return fallbackMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJsonPayload(payloadText: string): unknown | null {
|
||||||
|
const trimmed = payloadText.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(trimmed) as unknown;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function looksLikeHtml(payloadText: string): boolean {
|
||||||
|
const trimmed = payloadText.trim().toLowerCase();
|
||||||
|
return trimmed.startsWith('<!doctype html') || trimmed.startsWith('<html');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readJsonOrThrow<T>(response: Response, fallbackMessage: string): Promise<T> {
|
||||||
|
const payloadText = await response.text();
|
||||||
|
const payload = parseJsonPayload(payloadText);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorMessage = extractErrorFromPayload(payload, fallbackMessage);
|
||||||
|
throw new Error(errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload === null) {
|
||||||
|
throw new Error(fallbackMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload as T;
|
||||||
|
}
|
||||||
|
|
||||||
export function useSharedSummary() {
|
export function useSharedSummary() {
|
||||||
return useQuery<SharedSummary>({
|
return useQuery<SharedSummary>({
|
||||||
queryKey: ['shared', 'summary'],
|
queryKey: ['shared', 'summary'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await fetch('/api/shared/summary');
|
const res = await fetch('/api/shared/summary');
|
||||||
return res.json();
|
return readJsonOrThrow<SharedSummary>(res, 'Failed to fetch shared summary');
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -30,7 +82,59 @@ export function useSharedItems(type: 'commands' | 'skills' | 'agents') {
|
|||||||
queryKey: ['shared', type],
|
queryKey: ['shared', type],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await fetch(`/api/shared/${type}`);
|
const res = await fetch(`/api/shared/${type}`);
|
||||||
return res.json();
|
return readJsonOrThrow<{ items: SharedItem[] }>(res, `Failed to fetch shared ${type}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSharedItemContent(
|
||||||
|
type: 'commands' | 'skills' | 'agents',
|
||||||
|
itemPath: string | null
|
||||||
|
) {
|
||||||
|
return useQuery<SharedItemContent>({
|
||||||
|
queryKey: ['shared', type, 'content', itemPath],
|
||||||
|
enabled: typeof itemPath === 'string' && itemPath.length > 0,
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!itemPath) {
|
||||||
|
throw new Error('Missing shared item path');
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
type,
|
||||||
|
path: itemPath,
|
||||||
|
});
|
||||||
|
const res = await fetch(`/api/shared/content?${params.toString()}`);
|
||||||
|
const payloadText = await res.text();
|
||||||
|
const payload = parseJsonPayload(payloadText);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(extractErrorFromPayload(payload, `Failed to fetch shared ${type} content`));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload && typeof payload === 'object' && typeof payload.content === 'string') {
|
||||||
|
return {
|
||||||
|
content: payload.content,
|
||||||
|
contentPath:
|
||||||
|
typeof payload.contentPath === 'string' && payload.contentPath.length > 0
|
||||||
|
? payload.contentPath
|
||||||
|
: itemPath,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payloadText.trim().length > 0) {
|
||||||
|
if (looksLikeHtml(payloadText)) {
|
||||||
|
throw new Error(
|
||||||
|
'Shared content endpoint unavailable. Restart `ccs config` and try again.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: payloadText,
|
||||||
|
contentPath: itemPath,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Failed to fetch shared ${type} content`);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
const LAST_ROUTE_STORAGE_KEY = 'ccs-dashboard:last-route';
|
||||||
|
const NON_RESTORABLE_PATHS = new Set(['/login']);
|
||||||
|
|
||||||
|
export function storeLastRoute(pathname: string, search = '', hash = ''): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(LAST_ROUTE_STORAGE_KEY, `${pathname}${search}${hash}`);
|
||||||
|
} catch {
|
||||||
|
// Ignore storage failures (private mode, quota, etc.)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStoredLastRoute(): string | null {
|
||||||
|
try {
|
||||||
|
const route = localStorage.getItem(LAST_ROUTE_STORAGE_KEY);
|
||||||
|
if (!route || !route.startsWith('/')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pathOnly = route.split(/[?#]/, 1)[0];
|
||||||
|
if (NON_RESTORABLE_PATHS.has(pathOnly)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return route;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldRestoreRoute(route: string | null): route is string {
|
||||||
|
if (!route) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pathOnly = route.split(/[?#]/, 1)[0];
|
||||||
|
return pathOnly !== '/' && !NON_RESTORABLE_PATHS.has(pathOnly);
|
||||||
|
}
|
||||||
+724
-79
@@ -1,108 +1,753 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { type ReactNode, useMemo, useState } from 'react';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||||
import { useSharedItems, useSharedSummary } from '@/hooks/use-shared';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { FileText, Sparkles, Bot, AlertTriangle, Search } from 'lucide-react';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
|
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
|
import { useSharedItemContent, useSharedItems, useSharedSummary } from '@/hooks/use-shared';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import {
|
||||||
|
AlertCircle,
|
||||||
|
AlertTriangle,
|
||||||
|
Bot,
|
||||||
|
FileText,
|
||||||
|
FolderOpen,
|
||||||
|
RefreshCw,
|
||||||
|
Search,
|
||||||
|
Sparkles,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
type TabType = 'commands' | 'skills' | 'agents';
|
type TabType = 'commands' | 'skills' | 'agents';
|
||||||
|
|
||||||
|
const tabLabels: Record<TabType, string> = {
|
||||||
|
commands: 'Commands',
|
||||||
|
skills: 'Skills',
|
||||||
|
agents: 'Agents',
|
||||||
|
};
|
||||||
|
|
||||||
export function SharedPage() {
|
export function SharedPage() {
|
||||||
const [tab, setTab] = useState<TabType>('commands');
|
const [tab, setTab] = useState<TabType>('commands');
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
const { data: summary } = useSharedSummary();
|
const [selectedItemPath, setSelectedItemPath] = useState<string | null>(null);
|
||||||
const { data: items, isLoading } = useSharedItems(tab);
|
|
||||||
|
const {
|
||||||
|
data: summary,
|
||||||
|
isError: isSummaryError,
|
||||||
|
error: summaryError,
|
||||||
|
refetch: refetchSummary,
|
||||||
|
} = useSharedSummary();
|
||||||
|
const { data: items, isLoading, isFetching, isError, error, refetch } = useSharedItems(tab);
|
||||||
|
const allItems = items?.items ?? [];
|
||||||
const normalizedQuery = query.trim().toLowerCase();
|
const normalizedQuery = query.trim().toLowerCase();
|
||||||
|
const activeQuery = query.trim();
|
||||||
|
|
||||||
const filteredItems = useMemo(() => {
|
const filteredItems = useMemo(() => {
|
||||||
const allItems = items?.items ?? [];
|
const sourceItems = items?.items ?? [];
|
||||||
if (!normalizedQuery) {
|
if (!normalizedQuery) {
|
||||||
return allItems;
|
return sourceItems;
|
||||||
}
|
}
|
||||||
|
|
||||||
return allItems.filter((item) =>
|
return sourceItems.filter((item) =>
|
||||||
[item.name, item.description, item.path].some((value) =>
|
[item.name, item.description, item.path].some((value) =>
|
||||||
value.toLowerCase().includes(normalizedQuery)
|
value.toLowerCase().includes(normalizedQuery)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}, [items, normalizedQuery]);
|
}, [items, normalizedQuery]);
|
||||||
|
|
||||||
|
const selectedItem = useMemo(() => {
|
||||||
|
if (filteredItems.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!selectedItemPath) {
|
||||||
|
return filteredItems[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
return filteredItems.find((item) => item.path === selectedItemPath) ?? filteredItems[0];
|
||||||
|
}, [filteredItems, selectedItemPath]);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: selectedItemContent,
|
||||||
|
isLoading: isContentLoading,
|
||||||
|
isError: isContentError,
|
||||||
|
error: contentError,
|
||||||
|
refetch: refetchContent,
|
||||||
|
} = useSharedItemContent(tab, selectedItem?.path ?? null);
|
||||||
|
|
||||||
const tabs: { id: TabType; label: string; icon: typeof FileText; count: number }[] = [
|
const tabs: { id: TabType; label: string; icon: typeof FileText; count: number }[] = [
|
||||||
{ id: 'commands', label: 'Commands', icon: FileText, count: summary?.commands ?? 0 },
|
{ id: 'commands', label: tabLabels.commands, icon: FileText, count: summary?.commands ?? 0 },
|
||||||
{ id: 'skills', label: 'Skills', icon: Sparkles, count: summary?.skills ?? 0 },
|
{ id: 'skills', label: tabLabels.skills, icon: Sparkles, count: summary?.skills ?? 0 },
|
||||||
{ id: 'agents', label: 'Agents', icon: Bot, count: summary?.agents ?? 0 },
|
{ id: 'agents', label: tabLabels.agents, icon: Bot, count: summary?.agents ?? 0 },
|
||||||
];
|
];
|
||||||
|
const totalSharedItems = tabs.reduce((sum, tabOption) => sum + tabOption.count, 0);
|
||||||
|
|
||||||
|
const hasNoItems = !isLoading && !isError && allItems.length === 0;
|
||||||
|
const hasNoMatches = !isLoading && !isError && allItems.length > 0 && filteredItems.length === 0;
|
||||||
|
|
||||||
|
const summaryErrorMessage = getSharedErrorMessage(
|
||||||
|
summaryError,
|
||||||
|
'Shared item totals could not be loaded. Listing still works.'
|
||||||
|
);
|
||||||
|
const itemsErrorMessage = getSharedErrorMessage(
|
||||||
|
error,
|
||||||
|
`Unable to fetch shared ${tab}. Please try again.`
|
||||||
|
);
|
||||||
|
const contentErrorMessage = getSharedErrorMessage(
|
||||||
|
contentError,
|
||||||
|
`Unable to load content for ${selectedItem?.name ?? 'selected item'}.`
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 max-w-6xl mx-auto space-y-8">
|
<div className="h-full overflow-hidden flex flex-col">
|
||||||
<div>
|
<div className="p-6 pb-4 space-y-3">
|
||||||
<h1 className="text-2xl font-bold">Shared Data</h1>
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
|
||||||
<p className="text-muted-foreground">
|
<div className="space-y-3">
|
||||||
Commands, skills, and agents shared across Claude instances
|
<div>
|
||||||
</p>
|
<h1 className="text-2xl font-bold">Shared Data</h1>
|
||||||
</div>
|
<p className="text-muted-foreground">
|
||||||
|
Commands, skills, and agents shared across Claude instances
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
{summary && !summary.symlinkStatus.valid && (
|
<Tabs
|
||||||
<Alert variant="warning">
|
value={tab}
|
||||||
<AlertTriangle className="h-4 w-4" />
|
onValueChange={(nextTab) => {
|
||||||
<AlertTitle>Configuration Required</AlertTitle>
|
setTab(nextTab as TabType);
|
||||||
<AlertDescription>
|
setQuery('');
|
||||||
{summary.symlinkStatus.message}. Run `ccs sync` to configure.
|
setSelectedItemPath(null);
|
||||||
</AlertDescription>
|
}}
|
||||||
</Alert>
|
>
|
||||||
)}
|
<TabsList className="h-auto flex-wrap justify-start">
|
||||||
|
{tabs.map((t) => (
|
||||||
{/* Tab buttons */}
|
<TabsTrigger key={t.id} value={t.id} className="flex items-center gap-2">
|
||||||
<div className="flex gap-2 border-b pb-2">
|
<t.icon className="w-4 h-4" />
|
||||||
{tabs.map((t) => (
|
<span>{t.label}</span>
|
||||||
<Button
|
<span className="text-xs text-muted-foreground">({t.count})</span>
|
||||||
key={t.id}
|
</TabsTrigger>
|
||||||
variant={tab === t.id ? 'default' : 'ghost'}
|
))}
|
||||||
size="sm"
|
</TabsList>
|
||||||
onClick={() => {
|
</Tabs>
|
||||||
setTab(t.id);
|
|
||||||
setQuery('');
|
|
||||||
}}
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<t.icon className="w-4 h-4" />
|
|
||||||
{t.label} ({t.count})
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative max-w-md">
|
|
||||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
|
||||||
<Input
|
|
||||||
value={query}
|
|
||||||
onChange={(event) => setQuery(event.target.value)}
|
|
||||||
placeholder={`Filter ${tab} by name, description, or path`}
|
|
||||||
className="pl-9"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<div className="mt-4">
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="text-muted-foreground">Loading...</div>
|
|
||||||
) : filteredItems.length === 0 ? (
|
|
||||||
<div className="text-muted-foreground">No {tab} found</div>
|
|
||||||
) : (
|
|
||||||
<div className="grid gap-3">
|
|
||||||
{filteredItems.map((item) => (
|
|
||||||
<Card key={item.name}>
|
|
||||||
<CardContent>
|
|
||||||
<div className="font-medium">{item.name}</div>
|
|
||||||
<p className="text-sm text-muted-foreground mt-1">{item.description}</p>
|
|
||||||
<p className="text-xs text-muted-foreground mt-2 font-mono truncate">
|
|
||||||
{item.path}
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2 lg:items-end">
|
||||||
|
<div className="grid w-full gap-2 sm:w-auto sm:min-w-[340px] sm:grid-cols-3">
|
||||||
|
<HeaderMetricCard label="Total Shared" value={totalSharedItems} />
|
||||||
|
<HeaderMetricCard label={tabLabels[tab]} value={allItems.length} />
|
||||||
|
<HeaderMetricCard label="Visible" value={filteredItems.length} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 text-xs">
|
||||||
|
<Badge variant="secondary">Markdown detail view</Badge>
|
||||||
|
{activeQuery ? <Badge variant="outline">Filter: {activeQuery}</Badge> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{summary && !summary.symlinkStatus.valid && (
|
||||||
|
<Alert variant="warning">
|
||||||
|
<AlertTriangle className="h-4 w-4" />
|
||||||
|
<AlertTitle>Configuration Required</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
{summary.symlinkStatus.message}. Run `ccs sync` to configure.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{isSummaryError && (
|
||||||
|
<Alert variant="info">
|
||||||
|
<AlertTriangle className="h-4 w-4" />
|
||||||
|
<AlertTitle>Counts unavailable</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
<p>{summaryErrorMessage}</p>
|
||||||
|
<div className="mt-3">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
void refetchSummary();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RefreshCw className="w-4 h-4 mr-2" />
|
||||||
|
Retry counts
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-h-0 px-6 pb-6">
|
||||||
|
<div className="h-full rounded-lg border overflow-hidden bg-background">
|
||||||
|
<div className="grid h-full min-h-0 lg:grid-cols-[320px_minmax(0,1fr)]">
|
||||||
|
<div className="min-h-0 border-b lg:border-b-0 lg:border-r flex flex-col bg-muted/30">
|
||||||
|
<div className="p-4 border-b bg-background space-y-3">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<FolderOpen className="w-4 h-4 text-primary shrink-0" />
|
||||||
|
<h2 className="font-semibold truncate">{tabLabels[tab]}</h2>
|
||||||
|
</div>
|
||||||
|
{!isLoading && !isError && (
|
||||||
|
<Badge variant="outline" className="text-[10px] h-5">
|
||||||
|
{filteredItems.length}/{allItems.length}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
value={query}
|
||||||
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
|
placeholder={`Filter ${tab} by name, description, or path`}
|
||||||
|
aria-label={`Filter ${tab} by name, description, or path`}
|
||||||
|
className="pl-8 h-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isLoading && !isError && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Showing {filteredItems.length} of {allItems.length} {tab}
|
||||||
|
{activeQuery ? ` for "${activeQuery}"` : ''}
|
||||||
|
{isFetching ? ' (refreshing...)' : ''}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ScrollArea className="flex-1 min-h-0">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="p-4 text-sm text-muted-foreground">Loading shared {tab}...</div>
|
||||||
|
) : isError ? (
|
||||||
|
<div className="p-4 text-center">
|
||||||
|
<div className="space-y-3 py-8">
|
||||||
|
<AlertCircle className="w-10 h-10 mx-auto text-destructive/50" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">Failed to load shared {tab}</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">{itemsErrorMessage}</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
void refetch();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RefreshCw className="w-4 h-4 mr-2" />
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : hasNoItems ? (
|
||||||
|
<div className="p-4 text-sm text-muted-foreground">
|
||||||
|
No shared {tab} found. Run `ccs sync` or add items in your shared directory.
|
||||||
|
</div>
|
||||||
|
) : hasNoMatches ? (
|
||||||
|
<div className="p-4 text-sm text-muted-foreground">
|
||||||
|
No {tab} match "{activeQuery}".
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="p-2 space-y-1">
|
||||||
|
{filteredItems.map((item) => (
|
||||||
|
<button
|
||||||
|
key={`${item.type}:${item.path}`}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedItemPath(item.path)}
|
||||||
|
className={cn(
|
||||||
|
'w-full text-left p-3 rounded-md border transition-colors',
|
||||||
|
selectedItem?.path === item.path
|
||||||
|
? 'bg-primary/10 border-primary/30'
|
||||||
|
: 'bg-background hover:bg-muted border-transparent'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<p className="text-sm font-medium truncate">{item.name}</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
|
||||||
|
{item.description}
|
||||||
|
</p>
|
||||||
|
<p className="text-[11px] text-muted-foreground/90 mt-2 font-mono truncate">
|
||||||
|
{item.path}
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-w-0 min-h-0 flex flex-col bg-muted/20">
|
||||||
|
{!selectedItem ? (
|
||||||
|
<div className="min-h-[320px] flex items-center justify-center p-6 text-center text-muted-foreground">
|
||||||
|
Select a {tab.slice(0, -1)} to view full content.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="px-4 py-3 border-b bg-background">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h2 className="text-base font-semibold truncate">{selectedItem.name}</h2>
|
||||||
|
<Badge variant="outline" className="uppercase text-[10px]">
|
||||||
|
{selectedItem.type}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 space-y-4 min-h-0 flex-1 flex flex-col">
|
||||||
|
<div className="rounded-md border bg-muted/35 p-3">
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
|
<MetadataField label="Path" value={selectedItem.path} mono />
|
||||||
|
{selectedItemContent?.contentPath &&
|
||||||
|
selectedItemContent.contentPath !== selectedItem.path && (
|
||||||
|
<MetadataField
|
||||||
|
label="Resolved Source"
|
||||||
|
value={selectedItemContent.contentPath}
|
||||||
|
mono
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="min-h-0 flex-1">
|
||||||
|
<CardContent className="p-0 h-full">
|
||||||
|
<ScrollArea className="h-full px-5 py-4">
|
||||||
|
{isContentLoading ? (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Loading markdown content...
|
||||||
|
</p>
|
||||||
|
) : isContentError ? (
|
||||||
|
<Alert variant="destructive" className="max-w-2xl">
|
||||||
|
<AlertTriangle className="h-4 w-4" />
|
||||||
|
<AlertTitle>Failed to load content</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
<p>{contentErrorMessage}</p>
|
||||||
|
<div className="mt-3">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
void refetchContent();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RefreshCw className="w-4 h-4 mr-2" />
|
||||||
|
Retry content
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : (
|
||||||
|
<MarkdownViewer content={selectedItemContent?.content ?? ''} />
|
||||||
|
)}
|
||||||
|
</ScrollArea>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function HeaderMetricCard({ label, value }: { label: string; value: number }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border bg-muted/30 px-3 py-2">
|
||||||
|
<p className="text-[11px] uppercase tracking-wide text-muted-foreground">{label}</p>
|
||||||
|
<p className="text-lg font-semibold leading-tight mt-1">{value}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MetadataField({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
mono = false,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
mono?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">{label}</p>
|
||||||
|
<p className={cn('text-xs mt-1 break-words', mono ? 'font-mono' : 'text-sm')}>{value}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSharedErrorMessage(error: unknown, fallbackMessage: string): string {
|
||||||
|
if (!(error instanceof Error)) {
|
||||||
|
return fallbackMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = error.message.toLowerCase();
|
||||||
|
if (normalized.includes('failed to fetch') || normalized.includes('network')) {
|
||||||
|
return 'Connection to dashboard server lost or restarting. Keep `ccs config` running, then retry.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return error.message || fallbackMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MarkdownBlockHeading {
|
||||||
|
type: 'heading';
|
||||||
|
level: number;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MarkdownBlockParagraph {
|
||||||
|
type: 'paragraph';
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MarkdownBlockList {
|
||||||
|
type: 'unordered-list' | 'ordered-list';
|
||||||
|
items: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MarkdownBlockCode {
|
||||||
|
type: 'code';
|
||||||
|
language: string;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type MarkdownBlock =
|
||||||
|
| MarkdownBlockHeading
|
||||||
|
| MarkdownBlockParagraph
|
||||||
|
| MarkdownBlockList
|
||||||
|
| MarkdownBlockCode;
|
||||||
|
|
||||||
|
interface MarkdownFrontmatterEntry {
|
||||||
|
key: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ParsedMarkdownDocument {
|
||||||
|
blocks: MarkdownBlock[];
|
||||||
|
frontmatter: MarkdownFrontmatterEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function MarkdownViewer({ content }: { content: string }) {
|
||||||
|
const parsedDocument = useMemo(() => parseMarkdownDocument(content), [content]);
|
||||||
|
|
||||||
|
if (parsedDocument.blocks.length === 0 && parsedDocument.frontmatter.length === 0) {
|
||||||
|
return <p className="text-sm text-muted-foreground">No markdown content available.</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
{parsedDocument.frontmatter.length > 0 ? (
|
||||||
|
<div className="rounded-md border bg-muted/35 p-3">
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{parsedDocument.frontmatter.map((entry) => (
|
||||||
|
<div key={`${entry.key}:${entry.value}`} className="min-w-0">
|
||||||
|
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||||
|
{formatFrontmatterLabel(entry.key)}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs mt-1 break-words">{entry.value}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{parsedDocument.blocks.map((block, index) => {
|
||||||
|
if (block.type === 'heading') {
|
||||||
|
const headingClass =
|
||||||
|
block.level <= 1
|
||||||
|
? 'text-xl font-semibold'
|
||||||
|
: block.level === 2
|
||||||
|
? 'text-lg font-semibold'
|
||||||
|
: 'text-base font-semibold';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<h3 key={`heading-${index}`} className={headingClass}>
|
||||||
|
{renderInlineMarkdown(block.text, `heading-${index}`)}
|
||||||
|
</h3>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (block.type === 'paragraph') {
|
||||||
|
return (
|
||||||
|
<p key={`paragraph-${index}`} className="text-sm leading-6 whitespace-pre-wrap">
|
||||||
|
{renderInlineMarkdown(block.text, `paragraph-${index}`)}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (block.type === 'unordered-list') {
|
||||||
|
return (
|
||||||
|
<ul key={`ul-${index}`} className="list-disc pl-5 space-y-1 text-sm leading-6">
|
||||||
|
{block.items.map((item, itemIndex) => (
|
||||||
|
<li key={`ul-item-${index}-${itemIndex}`}>
|
||||||
|
{renderInlineMarkdown(item, `ul-item-${index}-${itemIndex}`)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (block.type === 'ordered-list') {
|
||||||
|
return (
|
||||||
|
<ol key={`ol-${index}`} className="list-decimal pl-5 space-y-1 text-sm leading-6">
|
||||||
|
{block.items.map((item, itemIndex) => (
|
||||||
|
<li key={`ol-item-${index}-${itemIndex}`}>
|
||||||
|
{renderInlineMarkdown(item, `ol-item-${index}-${itemIndex}`)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`code-${index}`}
|
||||||
|
className="rounded-md border bg-muted/60 p-3 font-mono text-xs leading-5 overflow-x-auto"
|
||||||
|
>
|
||||||
|
{block.language && (
|
||||||
|
<div className="text-[10px] uppercase tracking-wide text-muted-foreground mb-2">
|
||||||
|
{block.language}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<pre className="whitespace-pre-wrap break-words m-0">{block.content}</pre>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatFrontmatterLabel(key: string): string {
|
||||||
|
return key.replace(/[-_]/g, ' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderInlineMarkdown(text: string, keyPrefix: string): ReactNode[] {
|
||||||
|
const inlinePattern = /(\*\*([^*]+)\*\*|`([^`]+)`|\*([^*]+)\*|\[([^\]]+)\]\(([^)]+)\))/g;
|
||||||
|
const nodes: ReactNode[] = [];
|
||||||
|
let cursor = 0;
|
||||||
|
let tokenIndex = 0;
|
||||||
|
|
||||||
|
for (const match of text.matchAll(inlinePattern)) {
|
||||||
|
const fullMatch = match[0];
|
||||||
|
const offset = match.index ?? 0;
|
||||||
|
|
||||||
|
if (offset > cursor) {
|
||||||
|
nodes.push(text.slice(cursor, offset));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (match[2]) {
|
||||||
|
nodes.push(
|
||||||
|
<strong key={`${keyPrefix}-strong-${tokenIndex}`} className="font-semibold">
|
||||||
|
{match[2]}
|
||||||
|
</strong>
|
||||||
|
);
|
||||||
|
} else if (match[3]) {
|
||||||
|
nodes.push(
|
||||||
|
<code
|
||||||
|
key={`${keyPrefix}-code-${tokenIndex}`}
|
||||||
|
className="rounded bg-muted px-1 py-0.5 font-mono text-[0.82em]"
|
||||||
|
>
|
||||||
|
{match[3]}
|
||||||
|
</code>
|
||||||
|
);
|
||||||
|
} else if (match[4]) {
|
||||||
|
nodes.push(
|
||||||
|
<em key={`${keyPrefix}-em-${tokenIndex}`} className="italic">
|
||||||
|
{match[4]}
|
||||||
|
</em>
|
||||||
|
);
|
||||||
|
} else if (match[5] && match[6]) {
|
||||||
|
const href = match[6].trim();
|
||||||
|
if (/^(https?:\/\/|mailto:)/i.test(href)) {
|
||||||
|
nodes.push(
|
||||||
|
<a
|
||||||
|
key={`${keyPrefix}-link-${tokenIndex}`}
|
||||||
|
href={href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="text-primary underline underline-offset-2 hover:opacity-90"
|
||||||
|
>
|
||||||
|
{match[5]}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
nodes.push(match[5]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
nodes.push(fullMatch);
|
||||||
|
}
|
||||||
|
|
||||||
|
cursor = offset + fullMatch.length;
|
||||||
|
tokenIndex += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cursor < text.length) {
|
||||||
|
nodes.push(text.slice(cursor));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nodes.length === 0) {
|
||||||
|
return [text];
|
||||||
|
}
|
||||||
|
|
||||||
|
return nodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseMarkdownDocument(content: string): ParsedMarkdownDocument {
|
||||||
|
const normalized = content.replace(/\r\n/g, '\n').trim();
|
||||||
|
if (!normalized) {
|
||||||
|
return { blocks: [], frontmatter: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
let markdownBody = normalized;
|
||||||
|
const frontmatter: MarkdownFrontmatterEntry[] = [];
|
||||||
|
|
||||||
|
if (markdownBody.startsWith('---\n')) {
|
||||||
|
const frontmatterEndIndex = markdownBody.indexOf('\n---\n', 4);
|
||||||
|
if (frontmatterEndIndex !== -1) {
|
||||||
|
const rawFrontmatter = markdownBody.slice(4, frontmatterEndIndex).trim();
|
||||||
|
for (const line of rawFrontmatter.split('\n')) {
|
||||||
|
const entryMatch = line.match(/^([A-Za-z0-9_-]+)\s*:\s*(.+)$/);
|
||||||
|
if (!entryMatch) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
frontmatter.push({
|
||||||
|
key: entryMatch[1],
|
||||||
|
value: entryMatch[2].trim().replace(/^['"]|['"]$/g, ''),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
markdownBody = markdownBody.slice(frontmatterEndIndex + 5).trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
blocks: parseMarkdownBlocks(markdownBody),
|
||||||
|
frontmatter,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseMarkdownBlocks(content: string): MarkdownBlock[] {
|
||||||
|
if (!content.trim()) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = content.split('\n');
|
||||||
|
const blocks: MarkdownBlock[] = [];
|
||||||
|
let paragraphLines: string[] = [];
|
||||||
|
let unorderedItems: string[] = [];
|
||||||
|
let orderedItems: string[] = [];
|
||||||
|
let codeLanguage = '';
|
||||||
|
let codeLines: string[] | null = null;
|
||||||
|
|
||||||
|
const flushParagraph = () => {
|
||||||
|
if (paragraphLines.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
blocks.push({
|
||||||
|
type: 'paragraph',
|
||||||
|
text: paragraphLines.join(' '),
|
||||||
|
});
|
||||||
|
paragraphLines = [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const flushUnorderedList = () => {
|
||||||
|
if (unorderedItems.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
blocks.push({
|
||||||
|
type: 'unordered-list',
|
||||||
|
items: unorderedItems,
|
||||||
|
});
|
||||||
|
unorderedItems = [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const flushOrderedList = () => {
|
||||||
|
if (orderedItems.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
blocks.push({
|
||||||
|
type: 'ordered-list',
|
||||||
|
items: orderedItems,
|
||||||
|
});
|
||||||
|
orderedItems = [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const flushCodeBlock = () => {
|
||||||
|
if (!codeLines) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
blocks.push({
|
||||||
|
type: 'code',
|
||||||
|
language: codeLanguage,
|
||||||
|
content: codeLines.join('\n'),
|
||||||
|
});
|
||||||
|
codeLanguage = '';
|
||||||
|
codeLines = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (codeLines) {
|
||||||
|
if (line.trim().startsWith('```')) {
|
||||||
|
flushCodeBlock();
|
||||||
|
} else {
|
||||||
|
codeLines.push(line);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.trim().startsWith('```')) {
|
||||||
|
flushParagraph();
|
||||||
|
flushUnorderedList();
|
||||||
|
flushOrderedList();
|
||||||
|
codeLanguage = line.trim().replace(/^```/, '').trim();
|
||||||
|
codeLines = [];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.trim().length === 0) {
|
||||||
|
flushParagraph();
|
||||||
|
flushUnorderedList();
|
||||||
|
flushOrderedList();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const headingMatch = line.match(/^(#{1,6})\s+(.*)$/);
|
||||||
|
if (headingMatch) {
|
||||||
|
flushParagraph();
|
||||||
|
flushUnorderedList();
|
||||||
|
flushOrderedList();
|
||||||
|
blocks.push({
|
||||||
|
type: 'heading',
|
||||||
|
level: headingMatch[1].length,
|
||||||
|
text: headingMatch[2].trim(),
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const unorderedMatch = line.match(/^\s*[-*]\s+(.*)$/);
|
||||||
|
if (unorderedMatch) {
|
||||||
|
flushParagraph();
|
||||||
|
flushOrderedList();
|
||||||
|
unorderedItems.push(unorderedMatch[1].trim());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const orderedMatch = line.match(/^\s*\d+\.\s+(.*)$/);
|
||||||
|
if (orderedMatch) {
|
||||||
|
flushParagraph();
|
||||||
|
flushUnorderedList();
|
||||||
|
orderedItems.push(orderedMatch[1].trim());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
flushUnorderedList();
|
||||||
|
flushOrderedList();
|
||||||
|
paragraphLines.push(line.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
flushParagraph();
|
||||||
|
flushUnorderedList();
|
||||||
|
flushOrderedList();
|
||||||
|
flushCodeBlock();
|
||||||
|
return blocks;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { render, screen, userEvent, waitFor } from '@tests/setup/test-utils';
|
||||||
|
import { SharedPage } from '@/pages/shared';
|
||||||
|
|
||||||
|
function jsonResponse(payload: unknown, status = 200): Response {
|
||||||
|
return new Response(JSON.stringify(payload), {
|
||||||
|
status,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestUrl(input: RequestInfo | URL): string {
|
||||||
|
if (typeof input === 'string') {
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
if (input instanceof URL) {
|
||||||
|
return input.toString();
|
||||||
|
}
|
||||||
|
return input.url;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('SharedPage', () => {
|
||||||
|
const fetchMock = vi.fn<typeof fetch>();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
fetchMock.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows an actionable error state when shared items request fails', async () => {
|
||||||
|
fetchMock.mockImplementation(async (input) => {
|
||||||
|
const url = requestUrl(input);
|
||||||
|
if (url.endsWith('/api/shared/summary')) {
|
||||||
|
return jsonResponse({
|
||||||
|
commands: 0,
|
||||||
|
skills: 0,
|
||||||
|
agents: 0,
|
||||||
|
total: 0,
|
||||||
|
symlinkStatus: { valid: true, message: 'Symlinks active' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (url.endsWith('/api/shared/commands')) {
|
||||||
|
return jsonResponse({ error: 'Backend unavailable' }, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonResponse({ items: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<SharedPage />);
|
||||||
|
|
||||||
|
expect(await screen.findByText('Failed to load shared commands')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Backend unavailable')).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows detail content and distinguishes no-match state from loaded results', async () => {
|
||||||
|
fetchMock.mockImplementation(async (input) => {
|
||||||
|
const url = requestUrl(input);
|
||||||
|
if (url.endsWith('/api/shared/summary')) {
|
||||||
|
return jsonResponse({
|
||||||
|
commands: 1,
|
||||||
|
skills: 0,
|
||||||
|
agents: 0,
|
||||||
|
total: 1,
|
||||||
|
symlinkStatus: { valid: true, message: 'Symlinks active' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (url.endsWith('/api/shared/commands')) {
|
||||||
|
return jsonResponse({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
name: 'engineer/review',
|
||||||
|
description: 'Review the latest PR changes.',
|
||||||
|
path: '/tmp/commands/engineer/review.md',
|
||||||
|
type: 'command',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (url.includes('/api/shared/content?')) {
|
||||||
|
return jsonResponse({
|
||||||
|
content: '# Review\n\nFull review workflow',
|
||||||
|
contentPath: '/tmp/commands/engineer/review.md',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonResponse({ items: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<SharedPage />);
|
||||||
|
|
||||||
|
expect(await screen.findByText('Showing 1 of 1 commands')).toBeInTheDocument();
|
||||||
|
await waitFor(() => {
|
||||||
|
const requestedUrls = fetchMock.mock.calls.map(([input]) => requestUrl(input));
|
||||||
|
expect(requestedUrls.some((url) => url.includes('/api/shared/content?'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
const searchInput = screen.getByRole('textbox', {
|
||||||
|
name: 'Filter commands by name, description, or path',
|
||||||
|
});
|
||||||
|
await userEvent.type(searchInput, 'no-match');
|
||||||
|
|
||||||
|
expect(await screen.findByText('No commands match "no-match".')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows offline guidance when network request fails', async () => {
|
||||||
|
fetchMock.mockImplementation(async (input) => {
|
||||||
|
const url = requestUrl(input);
|
||||||
|
if (url.endsWith('/api/shared/summary')) {
|
||||||
|
throw new TypeError('Failed to fetch');
|
||||||
|
}
|
||||||
|
throw new TypeError('Failed to fetch');
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<SharedPage />);
|
||||||
|
|
||||||
|
expect(await screen.findByText('Counts unavailable')).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getAllByText(
|
||||||
|
'Connection to dashboard server lost or restarting. Keep `ccs config` running, then retry.'
|
||||||
|
).length
|
||||||
|
).toBeGreaterThan(0);
|
||||||
|
expect(screen.getByRole('button', { name: 'Retry counts' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user