mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-02 16:19:27 +00:00
fix(shared): support file-based agents and recursive commands
This commit is contained in:
+240
-54
@@ -7,6 +7,7 @@
|
|||||||
import { Router, Request, Response } from 'express';
|
import { Router, Request, Response } from 'express';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
import * as yaml from 'js-yaml';
|
||||||
import { getCcsDir } from '../utils/config-manager';
|
import { getCcsDir } from '../utils/config-manager';
|
||||||
import { getClaudeConfigDir } from '../utils/claude-config-path';
|
import { getClaudeConfigDir } from '../utils/claude-config-path';
|
||||||
|
|
||||||
@@ -70,7 +71,7 @@ function getSharedItems(type: 'commands' | 'skills' | 'agents'): SharedItem[] {
|
|||||||
|
|
||||||
const items: SharedItem[] = [];
|
const items: SharedItem[] = [];
|
||||||
const sharedDirRoot = safeRealPath(sharedDir) ?? path.resolve(sharedDir);
|
const sharedDirRoot = safeRealPath(sharedDir) ?? path.resolve(sharedDir);
|
||||||
const allowedSkillAgentRoots = new Set<string>([
|
const allowedRoots = new Set<string>([
|
||||||
sharedDirRoot,
|
sharedDirRoot,
|
||||||
...[
|
...[
|
||||||
path.join(getClaudeConfigDir(), type),
|
path.join(getClaudeConfigDir(), type),
|
||||||
@@ -81,62 +82,35 @@ function getSharedItems(type: 'commands' | 'skills' | 'agents'): SharedItem[] {
|
|||||||
.filter((dirPath): dirPath is string => typeof dirPath === 'string'),
|
.filter((dirPath): dirPath is string => typeof dirPath === 'string'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
if (type === 'commands') {
|
||||||
|
return getCommandItems(sharedDir, allowedRoots);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const entries = fs.readdirSync(sharedDir, { withFileTypes: true });
|
const entries = fs.readdirSync(sharedDir, { withFileTypes: true });
|
||||||
|
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
try {
|
try {
|
||||||
const entryPath = path.join(sharedDir, entry.name);
|
const entryPath = path.join(sharedDir, entry.name);
|
||||||
|
const resolvedEntryPath = safeRealPath(entryPath);
|
||||||
if (type === 'commands') {
|
if (!resolvedEntryPath || !isPathWithinAny(resolvedEntryPath, allowedRoots)) {
|
||||||
if (!entry.name.endsWith('.md')) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (!entry.isFile() && !entry.isSymbolicLink()) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const commandPath = safeRealPath(entryPath);
|
|
||||||
if (!commandPath || !isPathWithin(commandPath, sharedDirRoot)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const description = readMarkdownDescription(commandPath, sharedDirRoot);
|
|
||||||
if (!description) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
items.push({
|
|
||||||
name: entry.name.replace('.md', ''),
|
|
||||||
description,
|
|
||||||
path: entryPath,
|
|
||||||
type: 'command',
|
|
||||||
});
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skills/agents are directory-based and may be symlinked directories.
|
const stats = fs.statSync(resolvedEntryPath);
|
||||||
if (!entry.isDirectory() && !entry.isSymbolicLink()) {
|
const item = getSkillOrAgentItem(
|
||||||
|
type,
|
||||||
|
entry,
|
||||||
|
entryPath,
|
||||||
|
resolvedEntryPath,
|
||||||
|
allowedRoots,
|
||||||
|
stats
|
||||||
|
);
|
||||||
|
if (!item) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const entryRoot = safeRealPath(entryPath);
|
items.push(item);
|
||||||
if (!entryRoot || !isPathWithinAny(entryRoot, allowedSkillAgentRoots)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const markdownFile = type === 'skills' ? 'SKILL.md' : 'prompt.md';
|
|
||||||
const description = readMarkdownDescription(path.join(entryRoot, markdownFile), entryRoot);
|
|
||||||
if (!description) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
items.push({
|
|
||||||
name: entry.name,
|
|
||||||
description,
|
|
||||||
path: entryPath,
|
|
||||||
type: type === 'skills' ? 'skill' : 'agent',
|
|
||||||
});
|
|
||||||
} catch {
|
} catch {
|
||||||
// Fail soft per entry so one bad item does not hide valid results.
|
// Fail soft per entry so one bad item does not hide valid results.
|
||||||
}
|
}
|
||||||
@@ -148,22 +122,234 @@ function getSharedItems(type: 'commands' | 'skills' | 'agents'): SharedItem[] {
|
|||||||
return items.sort((a, b) => a.name.localeCompare(b.name));
|
return items.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractDescription(content: string): string {
|
function getCommandItems(sharedDir: string, allowedRoots: Set<string>): SharedItem[] {
|
||||||
// Extract first non-empty, non-heading line
|
const markdownFiles = collectMarkdownFiles(sharedDir, allowedRoots);
|
||||||
const lines = content.split('\n');
|
const items: SharedItem[] = [];
|
||||||
for (const line of lines) {
|
|
||||||
const trimmed = line.trim();
|
for (const markdownFile of markdownFiles) {
|
||||||
if (trimmed && !trimmed.startsWith('#') && !trimmed.startsWith('---')) {
|
const description = readMarkdownDescription(markdownFile.resolvedPath, allowedRoots);
|
||||||
return trimmed.slice(0, 100);
|
if (!description) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const relativePath = path.relative(sharedDir, markdownFile.displayPath);
|
||||||
|
const normalizedName = relativePath.split(path.sep).join('/').replace(/\.md$/i, '');
|
||||||
|
if (!normalizedName) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
items.push({
|
||||||
|
name: normalizedName,
|
||||||
|
description,
|
||||||
|
path: markdownFile.displayPath,
|
||||||
|
type: 'command',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return items.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSkillOrAgentItem(
|
||||||
|
type: 'skills' | 'agents',
|
||||||
|
entry: fs.Dirent,
|
||||||
|
entryPath: string,
|
||||||
|
resolvedEntryPath: string,
|
||||||
|
allowedRoots: Set<string>,
|
||||||
|
stats: fs.Stats
|
||||||
|
): SharedItem | null {
|
||||||
|
if (type === 'skills') {
|
||||||
|
if (!stats.isDirectory()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const description = readMarkdownDescription(
|
||||||
|
path.join(resolvedEntryPath, 'SKILL.md'),
|
||||||
|
allowedRoots
|
||||||
|
);
|
||||||
|
if (!description) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: entry.name,
|
||||||
|
description,
|
||||||
|
path: entryPath,
|
||||||
|
type: 'skill',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stats.isDirectory()) {
|
||||||
|
const description = readFirstMarkdownDescription(
|
||||||
|
[
|
||||||
|
path.join(resolvedEntryPath, 'prompt.md'),
|
||||||
|
path.join(resolvedEntryPath, 'AGENT.md'),
|
||||||
|
path.join(resolvedEntryPath, 'agent.md'),
|
||||||
|
],
|
||||||
|
allowedRoots
|
||||||
|
);
|
||||||
|
if (!description) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: entry.name,
|
||||||
|
description,
|
||||||
|
path: entryPath,
|
||||||
|
type: 'agent',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!stats.isFile() || !entry.name.toLowerCase().endsWith('.md')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const description = readMarkdownDescription(resolvedEntryPath, allowedRoots);
|
||||||
|
if (!description) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: entry.name.replace(/\.md$/i, ''),
|
||||||
|
description,
|
||||||
|
path: entryPath,
|
||||||
|
type: 'agent',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MarkdownFileEntry {
|
||||||
|
displayPath: string;
|
||||||
|
resolvedPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectMarkdownFiles(sharedDir: string, allowedRoots: Set<string>): MarkdownFileEntry[] {
|
||||||
|
const directoriesToVisit = [sharedDir];
|
||||||
|
const visitedDirectories = new Set<string>();
|
||||||
|
const markdownFiles: MarkdownFileEntry[] = [];
|
||||||
|
|
||||||
|
while (directoriesToVisit.length > 0) {
|
||||||
|
const currentDir = directoriesToVisit.pop();
|
||||||
|
if (!currentDir) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedCurrentDir = safeRealPath(currentDir);
|
||||||
|
if (!resolvedCurrentDir || !isPathWithinAny(resolvedCurrentDir, allowedRoots)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedDirPath = normalizeForPathComparison(resolvedCurrentDir);
|
||||||
|
if (visitedDirectories.has(normalizedDirPath)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
visitedDirectories.add(normalizedDirPath);
|
||||||
|
|
||||||
|
let entries: fs.Dirent[] = [];
|
||||||
|
try {
|
||||||
|
entries = fs.readdirSync(currentDir, { withFileTypes: true });
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const entryPath = path.join(currentDir, entry.name);
|
||||||
|
const resolvedEntryPath = safeRealPath(entryPath);
|
||||||
|
if (!resolvedEntryPath || !isPathWithinAny(resolvedEntryPath, allowedRoots)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let stats: fs.Stats;
|
||||||
|
try {
|
||||||
|
stats = fs.statSync(resolvedEntryPath);
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stats.isDirectory()) {
|
||||||
|
directoriesToVisit.push(entryPath);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stats.isFile() && entry.name.toLowerCase().endsWith('.md')) {
|
||||||
|
markdownFiles.push({
|
||||||
|
displayPath: entryPath,
|
||||||
|
resolvedPath: resolvedEntryPath,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return markdownFiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractDescription(content: string): string {
|
||||||
|
const frontmatterDescription = extractFrontmatterDescription(content);
|
||||||
|
if (frontmatterDescription) {
|
||||||
|
return trimDescription(frontmatterDescription);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract first non-empty, non-heading line from the markdown body.
|
||||||
|
const lines = stripFrontmatter(content).split('\n');
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (trimmed && !trimmed.startsWith('#') && !trimmed.startsWith('<!--')) {
|
||||||
|
return trimDescription(trimmed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return 'No description';
|
return 'No description';
|
||||||
}
|
}
|
||||||
|
|
||||||
function readMarkdownDescription(markdownPath: string, allowedRoot: string): string | null {
|
function extractFrontmatterDescription(content: string): string | null {
|
||||||
|
const frontmatterMatch = content.match(/^---\s*\r?\n([\s\S]*?)\r?\n---(?:\s*\r?\n|$)/);
|
||||||
|
if (!frontmatterMatch) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = yaml.load(frontmatterMatch[1]) as Record<string, unknown> | null;
|
||||||
|
const description = parsed?.description;
|
||||||
|
if (typeof description !== 'string') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = description.trim();
|
||||||
|
return trimmed.length > 0 ? trimmed : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripFrontmatter(content: string): string {
|
||||||
|
return content.replace(/^---\s*\r?\n[\s\S]*?\r?\n---\s*\r?\n?/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function trimDescription(description: string): string {
|
||||||
|
const maxLength = 140;
|
||||||
|
if (description.length <= maxLength) {
|
||||||
|
return description;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${description.slice(0, maxLength - 3).trimEnd()}...`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readFirstMarkdownDescription(
|
||||||
|
markdownPaths: string[],
|
||||||
|
allowedRoots: Set<string>
|
||||||
|
): string | null {
|
||||||
|
for (const markdownPath of markdownPaths) {
|
||||||
|
const description = readMarkdownDescription(markdownPath, allowedRoots);
|
||||||
|
if (description) {
|
||||||
|
return description;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readMarkdownDescription(markdownPath: string, allowedRoots: Set<string>): string | null {
|
||||||
try {
|
try {
|
||||||
const resolvedMarkdownPath = safeRealPath(markdownPath);
|
const resolvedMarkdownPath = safeRealPath(markdownPath);
|
||||||
if (!resolvedMarkdownPath || !isPathWithin(resolvedMarkdownPath, allowedRoot)) {
|
if (!resolvedMarkdownPath || !isPathWithinAny(resolvedMarkdownPath, allowedRoots)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -114,7 +114,19 @@ describe('web-server shared-routes', () => {
|
|||||||
fs.mkdirSync(sharedSkillsDir, { recursive: true });
|
fs.mkdirSync(sharedSkillsDir, { recursive: true });
|
||||||
const targetDir = path.join(ccsDir, '.claude', 'skills', 'my-skill');
|
const targetDir = path.join(ccsDir, '.claude', 'skills', 'my-skill');
|
||||||
fs.mkdirSync(targetDir, { recursive: true });
|
fs.mkdirSync(targetDir, { recursive: true });
|
||||||
fs.writeFileSync(path.join(targetDir, 'SKILL.md'), 'name: my-skill\n\nMy test skill');
|
fs.writeFileSync(
|
||||||
|
path.join(targetDir, 'SKILL.md'),
|
||||||
|
[
|
||||||
|
'---',
|
||||||
|
'name: my-skill',
|
||||||
|
'description: This skill handles daily maintenance workflows.',
|
||||||
|
'---',
|
||||||
|
'',
|
||||||
|
'# My Skill',
|
||||||
|
'',
|
||||||
|
'My test skill body',
|
||||||
|
].join('\n')
|
||||||
|
);
|
||||||
|
|
||||||
const linkPath = path.join(sharedSkillsDir, 'my-skill');
|
const linkPath = path.join(sharedSkillsDir, 'my-skill');
|
||||||
createDirectorySymlink(targetDir, linkPath);
|
createDirectorySymlink(targetDir, linkPath);
|
||||||
@@ -128,7 +140,7 @@ describe('web-server shared-routes', () => {
|
|||||||
name: 'my-skill',
|
name: 'my-skill',
|
||||||
type: 'skill',
|
type: 'skill',
|
||||||
});
|
});
|
||||||
expect(payload.items[0].description.length).toBeGreaterThan(0);
|
expect(payload.items[0].description).toBe('This skill handles daily maintenance workflows.');
|
||||||
expect(payload.items[0].path).toBe(linkPath);
|
expect(payload.items[0].path).toBe(linkPath);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -155,6 +167,70 @@ describe('web-server shared-routes', () => {
|
|||||||
expect(payload.items[0].path).toBe(linkPath);
|
expect(payload.items[0].path).toBe(linkPath);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('lists file-based agents from symlinked ~/.claude/agents directory', async () => {
|
||||||
|
const sharedAgentsDir = path.join(ccsDir, 'shared', 'agents');
|
||||||
|
const claudeAgentsDir = path.join(ccsDir, '.claude', 'agents');
|
||||||
|
fs.mkdirSync(claudeAgentsDir, { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(claudeAgentsDir, 'planner.md'),
|
||||||
|
[
|
||||||
|
'---',
|
||||||
|
'description: Plans implementation phases and dependencies.',
|
||||||
|
'---',
|
||||||
|
'',
|
||||||
|
'# Planner',
|
||||||
|
].join('\n')
|
||||||
|
);
|
||||||
|
createDirectorySymlink(claudeAgentsDir, sharedAgentsDir);
|
||||||
|
|
||||||
|
const payload = await getJson<{
|
||||||
|
items: Array<{ name: string; type: string; description: string; path: string }>;
|
||||||
|
}>(baseUrl, '/api/shared/agents');
|
||||||
|
|
||||||
|
expect(payload.items).toEqual([
|
||||||
|
{
|
||||||
|
name: 'planner',
|
||||||
|
type: 'agent',
|
||||||
|
description: 'Plans implementation phases and dependencies.',
|
||||||
|
path: path.join(sharedAgentsDir, 'planner.md'),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lists command markdown files recursively', async () => {
|
||||||
|
const commandsDir = path.join(ccsDir, 'shared', 'commands');
|
||||||
|
fs.mkdirSync(path.join(commandsDir, 'mkt'), { recursive: true });
|
||||||
|
fs.mkdirSync(path.join(commandsDir, 'engineer'), { recursive: true });
|
||||||
|
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(commandsDir, 'mkt', 'campaign.md'),
|
||||||
|
'# Campaign\n\nDraft campaign brief'
|
||||||
|
);
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(commandsDir, 'engineer', 'review.md'),
|
||||||
|
['---', 'description: Code review command for engineering workflows.', '---'].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: 'engineer/review',
|
||||||
|
type: 'command',
|
||||||
|
description: 'Code review command for engineering workflows.',
|
||||||
|
path: path.join(commandsDir, 'engineer', 'review.md'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'mkt/campaign',
|
||||||
|
type: 'command',
|
||||||
|
description: 'Draft campaign brief',
|
||||||
|
path: path.join(commandsDir, 'mkt', 'campaign.md'),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it('ignores markdown files in shared skills root', async () => {
|
it('ignores markdown files in shared skills root', async () => {
|
||||||
const sharedSkillsDir = path.join(ccsDir, 'shared', 'skills');
|
const sharedSkillsDir = path.join(ccsDir, 'shared', 'skills');
|
||||||
fs.mkdirSync(sharedSkillsDir, { recursive: true });
|
fs.mkdirSync(sharedSkillsDir, { recursive: true });
|
||||||
|
|||||||
+33
-5
@@ -1,16 +1,31 @@
|
|||||||
import { useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
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 { useSharedItems, useSharedSummary } from '@/hooks/use-shared';
|
||||||
import { FileText, Sparkles, Bot, AlertTriangle } from 'lucide-react';
|
import { FileText, Sparkles, Bot, AlertTriangle, Search } from 'lucide-react';
|
||||||
|
|
||||||
type TabType = 'commands' | 'skills' | 'agents';
|
type TabType = 'commands' | 'skills' | 'agents';
|
||||||
|
|
||||||
export function SharedPage() {
|
export function SharedPage() {
|
||||||
const [tab, setTab] = useState<TabType>('commands');
|
const [tab, setTab] = useState<TabType>('commands');
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
const { data: summary } = useSharedSummary();
|
const { data: summary } = useSharedSummary();
|
||||||
const { data: items, isLoading } = useSharedItems(tab);
|
const { data: items, isLoading } = useSharedItems(tab);
|
||||||
|
const normalizedQuery = query.trim().toLowerCase();
|
||||||
|
const filteredItems = useMemo(() => {
|
||||||
|
const allItems = items?.items ?? [];
|
||||||
|
if (!normalizedQuery) {
|
||||||
|
return allItems;
|
||||||
|
}
|
||||||
|
|
||||||
|
return allItems.filter((item) =>
|
||||||
|
[item.name, item.description, item.path].some((value) =>
|
||||||
|
value.toLowerCase().includes(normalizedQuery)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}, [items, normalizedQuery]);
|
||||||
|
|
||||||
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: 'Commands', icon: FileText, count: summary?.commands ?? 0 },
|
||||||
@@ -44,7 +59,10 @@ export function SharedPage() {
|
|||||||
key={t.id}
|
key={t.id}
|
||||||
variant={tab === t.id ? 'default' : 'ghost'}
|
variant={tab === t.id ? 'default' : 'ghost'}
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setTab(t.id)}
|
onClick={() => {
|
||||||
|
setTab(t.id);
|
||||||
|
setQuery('');
|
||||||
|
}}
|
||||||
className="flex items-center gap-2"
|
className="flex items-center gap-2"
|
||||||
>
|
>
|
||||||
<t.icon className="w-4 h-4" />
|
<t.icon className="w-4 h-4" />
|
||||||
@@ -53,15 +71,25 @@ export function SharedPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</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 */}
|
{/* Content */}
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="text-muted-foreground">Loading...</div>
|
<div className="text-muted-foreground">Loading...</div>
|
||||||
) : items?.items.length === 0 ? (
|
) : filteredItems.length === 0 ? (
|
||||||
<div className="text-muted-foreground">No {tab} found</div>
|
<div className="text-muted-foreground">No {tab} found</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-3">
|
<div className="grid gap-3">
|
||||||
{items?.items.map((item) => (
|
{filteredItems.map((item) => (
|
||||||
<Card key={item.name}>
|
<Card key={item.name}>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="font-medium">{item.name}</div>
|
<div className="font-medium">{item.name}</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user