Skip to content

Commit 63c4d9f

Browse files
committed
feat: implement plugins catalog and refactor skill groups into a dedicated bar component
1 parent 432d3ab commit 63c4d9f

5 files changed

Lines changed: 221 additions & 100 deletions

File tree

src/assets/plugins.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"tools": [
3+
{
4+
"name": "gstack",
5+
"description": "Use Garry Tan's exact Claude Code setup: 23 opinionated tools that serve as CEO, Designer, Eng Manager, Release Manager, Doc Engineer, and QA",
6+
"url": "https://github.com/garrytan/gstack",
7+
"setup": "git clone --single-branch --depth 1 https://github.com/garrytan/gstack.git ~/.claude/skills/gstack && cd ~/.claude/skills/gstack && ./setup"
8+
}
9+
]
10+
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { useState } from 'react';
2+
import { openUrl } from '@tauri-apps/plugin-opener';
3+
import { ExternalLink, Terminal, Copy, Check } from 'lucide-react';
4+
import { Button } from '@/components/ui/button';
5+
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
6+
import pluginsData from '@/assets/plugins.json';
7+
8+
interface Plugin {
9+
name: string;
10+
description: string;
11+
url: string;
12+
setup: string;
13+
}
14+
15+
function SetupPopover({ setup }: { setup: string }) {
16+
const [copied, setCopied] = useState(false);
17+
18+
const handleCopy = async () => {
19+
await navigator.clipboard.writeText(setup);
20+
setCopied(true);
21+
setTimeout(() => setCopied(false), 2000);
22+
};
23+
24+
return (
25+
<Popover>
26+
<PopoverTrigger asChild>
27+
<Button variant="ghost" size="icon" className="h-7 w-7 shrink-0" title="Show setup command">
28+
<Terminal className="h-3.5 w-3.5" />
29+
</Button>
30+
</PopoverTrigger>
31+
<PopoverContent className="w-96 p-0" align="end">
32+
<div className="flex items-center justify-between px-3 py-2 border-b">
33+
<span className="text-xs font-medium text-muted-foreground">Setup command</span>
34+
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => void handleCopy()}>
35+
{copied ? <Check className="h-3 w-3 text-green-500" /> : <Copy className="h-3 w-3" />}
36+
</Button>
37+
</div>
38+
<pre className="p-3 text-[11px] font-mono text-foreground whitespace-pre-wrap break-all leading-relaxed">
39+
{setup}
40+
</pre>
41+
</PopoverContent>
42+
</Popover>
43+
);
44+
}
45+
46+
function PluginCard({ plugin }: { plugin: Plugin }) {
47+
return (
48+
<div className="flex items-start gap-3 rounded-lg border bg-card p-3 hover:bg-accent/30 transition-colors">
49+
<div className="flex-1 min-w-0">
50+
<div className="flex items-center gap-1.5 mb-1">
51+
<span className="text-sm font-medium truncate">{plugin.name}</span>
52+
</div>
53+
<p className="text-xs text-muted-foreground leading-relaxed line-clamp-2">
54+
{plugin.description}
55+
</p>
56+
<button
57+
type="button"
58+
className="mt-1.5 flex items-center gap-1 text-[11px] text-primary/70 hover:text-primary transition-colors"
59+
onClick={() => void openUrl(plugin.url)}
60+
>
61+
<ExternalLink className="h-3 w-3" />
62+
<span className="truncate max-w-[200px]">{plugin.url}</span>
63+
</button>
64+
</div>
65+
<SetupPopover setup={plugin.setup} />
66+
</div>
67+
);
68+
}
69+
70+
export function PluginsCatalogView() {
71+
const plugins = (pluginsData as { tools: Plugin[] }).tools;
72+
73+
return (
74+
<div className="flex flex-col gap-2 p-4">
75+
{plugins.map((plugin) => (
76+
<PluginCard key={plugin.name} plugin={plugin} />
77+
))}
78+
</div>
79+
);
80+
}
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import { useState } from 'react';
2+
import { Plus } from 'lucide-react';
3+
import { Button } from '@/components/ui/button';
4+
import { Input } from '@/components/ui/input';
5+
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
6+
import { type SkillGroupsConfig } from '@/services';
7+
import { cn } from '@/lib/utils';
8+
9+
interface SkillGroupsBarProps {
10+
groupsConfig: SkillGroupsConfig;
11+
selectedGroupId: string | null;
12+
onSelectGroup: (id: string | null) => void;
13+
onAddGroup: (name: string) => Promise<void>;
14+
}
15+
16+
export function SkillGroupsBar({
17+
groupsConfig,
18+
selectedGroupId,
19+
onSelectGroup,
20+
onAddGroup,
21+
}: SkillGroupsBarProps) {
22+
const [dialogOpen, setDialogOpen] = useState(false);
23+
const [newGroupName, setNewGroupName] = useState('');
24+
25+
const handleSave = async () => {
26+
const name = newGroupName.trim();
27+
if (!name) { setDialogOpen(false); return; }
28+
await onAddGroup(name);
29+
setNewGroupName('');
30+
setDialogOpen(false);
31+
};
32+
33+
const handleCancel = () => {
34+
setNewGroupName('');
35+
setDialogOpen(false);
36+
};
37+
38+
return (
39+
<>
40+
<div className="flex items-center gap-1.5 px-4 py-1.5 min-h-[32px]">
41+
<Button
42+
size="icon-sm"
43+
variant="ghost"
44+
onClick={() => setDialogOpen(true)}
45+
title="New group"
46+
>
47+
<Plus />
48+
</Button>
49+
50+
<div className="flex flex-1 items-center gap-1 overflow-x-auto scrollbar-none">
51+
<button
52+
type="button"
53+
onClick={() => onSelectGroup(null)}
54+
className={cn(
55+
'shrink-0 rounded-full border px-2.5 py-0.5 text-[11px] font-medium transition-colors whitespace-nowrap',
56+
selectedGroupId === null
57+
? 'border-primary/50 bg-primary/10 text-primary'
58+
: 'border-muted text-muted-foreground hover:border-muted-foreground/50 hover:text-foreground'
59+
)}
60+
>
61+
All groups
62+
</button>
63+
64+
{groupsConfig.groups.map((g) => (
65+
<button
66+
key={g.id}
67+
type="button"
68+
onClick={() => onSelectGroup(selectedGroupId === g.id ? null : g.id)}
69+
className={cn(
70+
'shrink-0 rounded-full border px-2.5 py-0.5 text-[11px] font-medium transition-colors whitespace-nowrap',
71+
selectedGroupId === g.id
72+
? 'border-primary/50 bg-primary/10 text-primary'
73+
: 'border-muted text-muted-foreground hover:border-muted-foreground/50 hover:text-foreground'
74+
)}
75+
>
76+
{g.name}
77+
{g.skillNames.length > 0 && (
78+
<span className="ml-1 opacity-50">{g.skillNames.length}</span>
79+
)}
80+
</button>
81+
))}
82+
</div>
83+
</div>
84+
85+
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
86+
<DialogContent>
87+
<DialogHeader>
88+
<DialogTitle>New Skill Group</DialogTitle>
89+
</DialogHeader>
90+
<Input
91+
value={newGroupName}
92+
onChange={(e) => setNewGroupName(e.target.value)}
93+
placeholder="Group name..."
94+
autoFocus
95+
onKeyDown={(e) => { if (e.key === 'Enter') void handleSave(); }}
96+
/>
97+
<DialogFooter>
98+
<Button variant="ghost" onClick={handleCancel}>Cancel</Button>
99+
<Button onClick={() => void handleSave()}>Save changes</Button>
100+
</DialogFooter>
101+
</DialogContent>
102+
</Dialog>
103+
</>
104+
);
105+
}

src/components/features/skills/SkillsView.tsx

Lines changed: 18 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,11 @@
11
import { useCallback, useEffect, useState } from 'react';
2-
import { Search, Plus } from 'lucide-react';
2+
import { Search } from 'lucide-react';
33
import { Input } from '@/components/ui/input';
4-
import { Button } from '@/components/ui/button';
54
import { BrowseTab } from '@/components/features/skills/BrowseTab';
5+
import { SkillGroupsBar } from '@/components/features/skills/SkillGroupsBar';
66
import { useWorkspaceStore, useLayoutStore, usePluginStore } from '@/stores';
77
import { useTrafficLightConfig } from '@/hooks';
88
import { type SkillGroup, type SkillGroupsConfig, listCentralSkills, readSkillGroups, writeSkillGroups } from '@/services';
9-
import { cn } from '@/lib/utils';
10-
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
119
import { toast } from '@/components/ui/use-toast';
1210
import { v4 as uuidv4 } from 'uuid';
1311

@@ -22,8 +20,6 @@ export default function SkillsView() {
2220
const [groupsConfig, setGroupsConfig] = useState<SkillGroupsConfig>({ groups: [] });
2321
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
2422

25-
const [addingGroup, setAddingGroup] = useState(false);
26-
const [newGroupName, setNewGroupName] = useState('');
2723

2824
useEffect(() => {
2925
listCentralSkills(scope, cwd ?? undefined)
@@ -57,107 +53,35 @@ export default function SkillsView() {
5753
setInstalledRefreshKey((k) => k + 1);
5854
}, []);
5955

60-
const handleAddGroup = async () => {
61-
const name = newGroupName.trim();
62-
if (!name) { setAddingGroup(false); return; }
56+
const handleAddGroup = async (name: string) => {
6357
const newGroupId = uuidv4();
6458
const newGroup: SkillGroup = { id: newGroupId, name, skillNames: [] };
6559
const updated = { groups: [...groupsConfig.groups, newGroup] };
6660
await saveGroups(updated);
6761
setSelectedGroupId(newGroupId);
68-
setNewGroupName('');
69-
setAddingGroup(false);
70-
};
71-
72-
const handleGroupChipClick = (groupId: string) => {
73-
setSelectedGroupId((prev) => (prev === groupId ? null : groupId));
7462
};
7563

7664
return (
7765
<div className="flex flex-col h-full">
7866

7967
<div>
80-
<span className="flex items-center justify-between gap-2 px-4 h-12">
81-
<div className="relative">
82-
<Search className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground pointer-events-none" />
83-
<Input
84-
placeholder="Search skills…"
85-
value={searchQuery}
86-
onChange={(e) => setSearchQuery(e.target.value)}
87-
className="h-7 w-36 pl-8 text-xs bg-muted/30 border-none ring-offset-background focus-visible:ring-1"
88-
/>
89-
</div>
90-
</span>
91-
92-
{/* Groups bar */}
93-
<div className="flex items-center gap-1.5 px-4 py-1.5 min-h-[32px]">
94-
<Button
95-
size="icon-sm"
96-
variant="ghost"
97-
onClick={() => setAddingGroup(true)}
98-
title="New group"
99-
>
100-
<Plus />
101-
</Button>
102-
<div className="flex flex-1 items-center gap-1 overflow-x-auto scrollbar-none">
103-
<button
104-
type="button"
105-
onClick={() => setSelectedGroupId(null)}
106-
className={cn(
107-
'shrink-0 rounded-full border px-2.5 py-0.5 text-[11px] font-medium transition-colors whitespace-nowrap',
108-
selectedGroupId === null
109-
? 'border-primary/50 bg-primary/10 text-primary'
110-
: 'border-muted text-muted-foreground hover:border-muted-foreground/50 hover:text-foreground'
111-
)}
112-
>
113-
All groups
114-
</button>
115-
{groupsConfig.groups.map((g) => (
116-
<button
117-
key={g.id}
118-
type="button"
119-
onClick={() => handleGroupChipClick(g.id)}
120-
className={cn(
121-
'shrink-0 rounded-full border px-2.5 py-0.5 text-[11px] font-medium transition-colors whitespace-nowrap',
122-
selectedGroupId === g.id
123-
? 'border-primary/50 bg-primary/10 text-primary'
124-
: 'border-muted text-muted-foreground hover:border-muted-foreground/50 hover:text-foreground'
125-
)}
126-
>
127-
{g.name}
128-
{g.skillNames.length > 0 && (
129-
<span className="ml-1 opacity-50">{g.skillNames.length}</span>
130-
)}
131-
</button>
132-
))}
133-
</div>
68+
<div className="relative mx-20 py-4">
69+
<Search className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground pointer-events-none" />
70+
<Input
71+
placeholder="Search skills…"
72+
value={searchQuery}
73+
onChange={(e) => setSearchQuery(e.target.value)}
74+
className="w-full pl-8 text-xs bg-muted/30 border-none ring-offset-background focus-visible:ring-1"
75+
/>
13476
</div>
13577

136-
<Dialog open={addingGroup} onOpenChange={setAddingGroup}>
137-
<DialogContent>
138-
<DialogHeader>
139-
<DialogTitle>New Skill Group</DialogTitle>
140-
</DialogHeader>
141-
<Input
142-
id="name"
143-
value={newGroupName}
144-
onChange={(e) => setNewGroupName(e.target.value)}
145-
placeholder="Group name..."
146-
autoFocus
147-
onKeyDown={(e) => {
148-
if (e.key === 'Enter') void handleAddGroup();
149-
}}
150-
/>
151-
<DialogFooter>
152-
<Button variant="ghost" onClick={() => { setAddingGroup(false); setNewGroupName(''); }}>
153-
Cancel
154-
</Button>
155-
<Button onClick={() => void handleAddGroup()}>
156-
Save changes
157-
</Button>
158-
</DialogFooter>
159-
</DialogContent>
160-
</Dialog>
78+
{/* Groups bar */}
79+
<SkillGroupsBar
80+
groupsConfig={groupsConfig}
81+
selectedGroupId={selectedGroupId}
82+
onSelectGroup={setSelectedGroupId}
83+
onAddGroup={handleAddGroup}
84+
/>
16185
</div>
16286

16387
{/* Content */}

0 commit comments

Comments
 (0)