huhaa-myskills 0.2.13 → 0.3.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -375
- package/bin/huhaa-myskills.mjs +194 -12
- package/{scripts → build}/verify.mjs +3 -1
- package/docs/Frontend-Spec.md +1149 -0
- package/docs/assets/layout-wireframe.png +0 -0
- package/docs/assets/theme-reference.png +0 -0
- package/docs/scan_rules.md +172 -0
- package/package.json +33 -9
- package/packages/scanner/src/adapters/directory-skill.mjs +239 -0
- package/packages/scanner/src/adapters/skill-adapter.mjs +345 -0
- package/packages/scanner/src/icon/brand-map.mjs +84 -0
- package/packages/scanner/src/icon/icon-extractor.mjs +246 -0
- package/packages/scanner/src/index.mjs +107 -82
- package/packages/scanner/src/types.d.ts +18 -0
- package/packages/scanner/test/icon.test.mjs +61 -0
- package/packages/scanner/test/scanner.test.mjs +37 -41
- package/packages/scanner/test/skill-adapter.test.mjs +70 -0
- package/packages/scanner/tests/directory-skill.test.mjs +93 -0
- package/packages/scanner/tests/scanner-integration.test.mjs +139 -0
- package/packages/server/src/index.mjs +164 -12
- package/packages/server/src/labels.mjs +62 -0
- package/packages/server/test/server.test.mjs +1 -1
- package/packages/web/README.md +33 -2
- package/packages/web/dist/assets/index-BCY2cRaO.js +175 -0
- package/packages/web/dist/assets/index-DALKjJPi.css +1 -0
- package/packages/web/dist/favicon-16x16.png +0 -0
- package/packages/web/dist/favicon-192x192.png +0 -0
- package/packages/web/dist/favicon-32x32.png +0 -0
- package/packages/web/dist/favicon-512x512.png +0 -0
- package/packages/web/dist/favicon-source.svg +5 -0
- package/packages/web/dist/index.html +23 -9
- package/packages/web/index.html +22 -8
- package/packages/web/package-lock.json +2989 -0
- package/packages/web/package.json +21 -8
- package/packages/web/postcss.config.js +6 -0
- package/packages/web/public/favicon-16x16.png +0 -0
- package/packages/web/public/favicon-192x192.png +0 -0
- package/packages/web/public/favicon-32x32.png +0 -0
- package/packages/web/public/favicon-512x512.png +0 -0
- package/packages/web/public/favicon-source.svg +5 -0
- package/packages/web/src/App.test.ts +40 -0
- package/packages/web/src/App.tsx +177 -0
- package/packages/web/src/components/ComingSoon.tsx +14 -0
- package/packages/web/src/components/layout/Sidebar.test.tsx +74 -0
- package/packages/web/src/components/layout/Sidebar.tsx +114 -0
- package/packages/web/src/components/layout/Topbar.tsx +74 -0
- package/packages/web/src/components/ui/ActionButton.tsx +48 -0
- package/packages/web/src/components/ui/button.tsx +48 -0
- package/packages/web/src/components/ui/card.tsx +45 -0
- package/packages/web/src/components/views/DashboardView.tsx +122 -0
- package/packages/web/src/components/views/OtherSkillsView.test.tsx +126 -0
- package/packages/web/src/components/views/OtherSkillsView.tsx +306 -0
- package/packages/web/src/components/views/SettingsView.tsx +95 -0
- package/packages/web/src/components/views/SkillDetail.test.tsx +57 -0
- package/packages/web/src/components/views/SkillDetail.tsx +81 -0
- package/packages/web/src/components/views/SkillsView.test.ts +28 -0
- package/packages/web/src/components/views/SkillsView.tsx +226 -0
- package/packages/web/src/hooks/QUICK_REFERENCE.md +165 -0
- package/packages/web/src/hooks/README.md +378 -0
- package/packages/web/src/hooks/__tests__/useOtherSkills.test.ts +422 -0
- package/packages/web/src/hooks/__tests__/useSkillIcons.test.ts +272 -0
- package/packages/web/src/hooks/useLiveReload.ts +20 -0
- package/packages/web/src/hooks/useOtherSkills.ts +387 -0
- package/packages/web/src/hooks/useSkillIcons.ts +248 -0
- package/packages/web/src/hooks/useTheme.ts +36 -0
- package/packages/web/src/index.css +161 -0
- package/packages/web/src/lib/api.test.ts +47 -0
- package/packages/web/src/lib/api.ts +77 -0
- package/packages/web/src/lib/cn.ts +7 -0
- package/packages/web/src/lib/editors.test.ts +30 -0
- package/packages/web/src/lib/editors.ts +70 -0
- package/packages/web/src/lib/markdown.test.ts +27 -0
- package/packages/web/src/lib/markdown.ts +14 -0
- package/packages/web/src/main.tsx +13 -0
- package/packages/web/src/types/index.ts +62 -0
- package/packages/web/src/types/other-skill.ts +88 -0
- package/packages/web/src/types/skill.ts +136 -0
- package/packages/web/src/types.ts +47 -0
- package/packages/web/src/vite-env.d.ts +1 -0
- package/packages/web/tailwind.config.ts +66 -0
- package/packages/web/tsconfig.json +28 -0
- package/packages/web/vite.config.ts +36 -0
- package/packages/web/vitest.config.ts +21 -0
- package/packages/web/vitest.setup.ts +1 -0
- package/scripts/activate-data-sources.sh +223 -0
- package/scripts/brand-completion.py +586 -0
- package/scripts/init-nextjs-project.sh +421 -0
- package/scripts/install-and-sync.sh +209 -0
- package/scripts/start-dev.sh +72 -0
- package/docs/GUIDE.md +0 -190
- package/docs/PLAN.md +0 -359
- package/docs/RULES.md +0 -329
- package/docs/RUNBOOK-myskills.md +0 -258
- package/docs/SYNC-SKILLS.md +0 -259
- package/docs/releases/README.md +0 -47
- package/docs/releases/v0.1.0.md +0 -256
- package/docs/releases/v0.1.1.md +0 -297
- package/docs/releases/v0.1.2.md +0 -207
- package/docs/releases/v0.1.3.md +0 -269
- package/docs/todo/v0.1.2.md +0 -98
- package/docs/todo/v0.1.3.md +0 -40
- package/docs/todo/v0.1.4.md +0 -36
- package/docs/todo/v0.1.5.md +0 -41
- package/docs/todo/v0.1.6.md +0 -43
- package/docs/todo/v0.1.7.md +0 -38
- package/docs/todo/v0.1.8.md +0 -36
- package/docs/todo/v0.1.9.md +0 -54
- package/packages/web/dist/assets/index-BPepSb3q.js +0 -36
- package/packages/web/dist/assets/index-CIGkgT5w.css +0 -1
- package/packages/web/src/App.vue +0 -483
- package/packages/web/src/components/AppTree.vue +0 -230
- package/packages/web/src/components/DirectoryTree.vue +0 -263
- package/packages/web/src/components/SkillTree.vue +0 -208
- package/packages/web/src/lib/api.js +0 -43
- package/packages/web/src/main.js +0 -6
- package/packages/web/src/stores/i18n.js +0 -161
- package/packages/web/src/stores/skills.js +0 -287
- package/packages/web/src/styles.css +0 -365
- package/packages/web/vite.config.js +0 -17
- /package/{scripts → build}/build-web.mjs +0 -0
- /package/{scripts → build}/prepare-publish.mjs +0 -0
|
@@ -1,20 +1,33 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@huhaa/web",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"private": true,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
7
7
|
"dev": "vite --host 127.0.0.1 --port 11521",
|
|
8
8
|
"build": "vite build",
|
|
9
|
-
"preview": "vite preview --host 127.0.0.1 --port 11522"
|
|
9
|
+
"preview": "vite preview --host 127.0.0.1 --port 11522",
|
|
10
|
+
"type-check": "tsc --noEmit"
|
|
10
11
|
},
|
|
11
12
|
"dependencies": {
|
|
12
|
-
"
|
|
13
|
-
"
|
|
14
|
-
"vue": "^3.5.13",
|
|
15
|
-
"pinia": "^2.3.0",
|
|
13
|
+
"class-variance-authority": "^0.7.1",
|
|
14
|
+
"clsx": "^2.1.1",
|
|
16
15
|
"fuse.js": "^7.0.0",
|
|
17
|
-
"
|
|
16
|
+
"lucide-react": "^0.460.0",
|
|
17
|
+
"markdown-it": "^14.1.0",
|
|
18
|
+
"react": "^18.3.1",
|
|
19
|
+
"react-dom": "^18.3.1",
|
|
20
|
+
"tailwind-merge": "^2.5.5"
|
|
18
21
|
},
|
|
19
|
-
"devDependencies": {
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"@types/markdown-it": "^14.1.2",
|
|
24
|
+
"@types/react": "^18.3.12",
|
|
25
|
+
"@types/react-dom": "^18.3.1",
|
|
26
|
+
"@vitejs/plugin-react": "^4.3.4",
|
|
27
|
+
"autoprefixer": "^10.4.20",
|
|
28
|
+
"postcss": "^8.4.49",
|
|
29
|
+
"tailwindcss": "^3.4.15",
|
|
30
|
+
"typescript": "^5.6.3",
|
|
31
|
+
"vite": "^6.0.0"
|
|
32
|
+
}
|
|
20
33
|
}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<svg width="192" height="192" viewBox="0 0 192 192" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
3
|
+
<rect width="192" height="192" rx="48" fill="#7c3aed"/>
|
|
4
|
+
<text x="96" y="120" font-size="100" font-weight="bold" fill="white" text-anchor="middle" font-family="system-ui">🛠</text>
|
|
5
|
+
</svg>
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { reducer, initialState, type UIState } from './App'
|
|
3
|
+
|
|
4
|
+
describe('App reducer 状态机(module × view)', () => {
|
|
5
|
+
it('editor 动作进入技能视图并重置 kind/选中(避免脏筛选)', () => {
|
|
6
|
+
const dirty: UIState = {
|
|
7
|
+
...initialState,
|
|
8
|
+
view: 'dashboard',
|
|
9
|
+
kindFilter: 'skill',
|
|
10
|
+
selectedId: 'abc',
|
|
11
|
+
}
|
|
12
|
+
const next = reducer(dirty, { type: 'editor', key: 'Cursor' })
|
|
13
|
+
expect(next.view).toBe('skills')
|
|
14
|
+
expect(next.editorFilter).toBe('Cursor')
|
|
15
|
+
expect(next.kindFilter).toBeNull()
|
|
16
|
+
expect(next.selectedId).toBeNull()
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('editor=null 表示全部技能', () => {
|
|
20
|
+
expect(reducer(initialState, { type: 'editor', key: null }).editorFilter).toBeNull()
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('dashboard / settings 切换视图', () => {
|
|
24
|
+
expect(reducer(initialState, { type: 'settings' }).view).toBe('settings')
|
|
25
|
+
const back = reducer(reducer(initialState, { type: 'settings' }), { type: 'dashboard' })
|
|
26
|
+
expect(back.view).toBe('dashboard')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('query / kind / select 只改对应字段', () => {
|
|
30
|
+
expect(reducer(initialState, { type: 'query', query: 'mcp' }).query).toBe('mcp')
|
|
31
|
+
expect(reducer(initialState, { type: 'kind', kind: 'skill' }).kindFilter).toBe('skill')
|
|
32
|
+
expect(reducer(initialState, { type: 'select', id: 'x1' }).selectedId).toBe('x1')
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('module 切换不丢其它状态', () => {
|
|
36
|
+
const s = reducer({ ...initialState, query: 'q' }, { type: 'module', module: 'commands' })
|
|
37
|
+
expect(s.module).toBe('commands')
|
|
38
|
+
expect(s.query).toBe('q')
|
|
39
|
+
})
|
|
40
|
+
})
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { useCallback, useEffect, useReducer, useState } from 'react'
|
|
2
|
+
import { Sidebar } from '@/components/layout/Sidebar'
|
|
3
|
+
import { Topbar, type ModuleKey } from '@/components/layout/Topbar'
|
|
4
|
+
import { DashboardView } from '@/components/views/DashboardView'
|
|
5
|
+
import { SkillsView } from '@/components/views/SkillsView'
|
|
6
|
+
import { SettingsView } from '@/components/views/SettingsView'
|
|
7
|
+
import { OtherSkillsView } from '@/components/views/OtherSkillsView'
|
|
8
|
+
import { ComingSoon } from '@/components/ComingSoon'
|
|
9
|
+
import { useLiveReload } from '@/hooks/useLiveReload'
|
|
10
|
+
import { fetchSkills, fetchStats, reload } from '@/lib/api'
|
|
11
|
+
import type { SkillItem, Stats } from '@/types'
|
|
12
|
+
|
|
13
|
+
export type View = 'dashboard' | 'skills' | 'otherSkills' | 'settings'
|
|
14
|
+
|
|
15
|
+
export interface UIState {
|
|
16
|
+
module: ModuleKey
|
|
17
|
+
view: View
|
|
18
|
+
editorFilter: string | null
|
|
19
|
+
kindFilter: string | null
|
|
20
|
+
query: string
|
|
21
|
+
selectedId: string | null
|
|
22
|
+
otherSkillsQuery: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type Action =
|
|
26
|
+
| { type: 'module'; module: ModuleKey }
|
|
27
|
+
| { type: 'dashboard' }
|
|
28
|
+
| { type: 'settings' }
|
|
29
|
+
| { type: 'otherSkills' }
|
|
30
|
+
| { type: 'otherSkillsQuery'; query: string }
|
|
31
|
+
| { type: 'editor'; key: string | null }
|
|
32
|
+
| { type: 'query'; query: string }
|
|
33
|
+
| { type: 'kind'; kind: string | null }
|
|
34
|
+
| { type: 'select'; id: string }
|
|
35
|
+
|
|
36
|
+
export const initialState: UIState = {
|
|
37
|
+
module: 'skills',
|
|
38
|
+
view: 'dashboard',
|
|
39
|
+
editorFilter: null,
|
|
40
|
+
kindFilter: null,
|
|
41
|
+
query: '',
|
|
42
|
+
selectedId: null,
|
|
43
|
+
otherSkillsQuery: '',
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function reducer(state: UIState, action: Action): UIState {
|
|
47
|
+
switch (action.type) {
|
|
48
|
+
case 'module':
|
|
49
|
+
return { ...state, module: action.module }
|
|
50
|
+
case 'dashboard':
|
|
51
|
+
return { ...state, view: 'dashboard' }
|
|
52
|
+
case 'settings':
|
|
53
|
+
return { ...state, view: 'settings' }
|
|
54
|
+
case 'otherSkills':
|
|
55
|
+
return { ...state, view: 'otherSkills' }
|
|
56
|
+
case 'otherSkillsQuery':
|
|
57
|
+
return { ...state, otherSkillsQuery: action.query }
|
|
58
|
+
case 'editor':
|
|
59
|
+
// 切换来源:进入技能视图,重置 kind/选中
|
|
60
|
+
return { ...state, view: 'skills', editorFilter: action.key, kindFilter: null, selectedId: null }
|
|
61
|
+
case 'query':
|
|
62
|
+
return { ...state, query: action.query }
|
|
63
|
+
case 'kind':
|
|
64
|
+
return { ...state, kindFilter: action.kind }
|
|
65
|
+
case 'select':
|
|
66
|
+
return { ...state, selectedId: action.id }
|
|
67
|
+
default:
|
|
68
|
+
return state
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export default function App() {
|
|
73
|
+
const [items, setItems] = useState<SkillItem[]>([])
|
|
74
|
+
const [stats, setStats] = useState<Stats | null>(null)
|
|
75
|
+
const [loading, setLoading] = useState(true)
|
|
76
|
+
const [error, setError] = useState<string | null>(null)
|
|
77
|
+
const [reloading, setReloading] = useState(false)
|
|
78
|
+
const [ui, dispatch] = useReducer(reducer, initialState)
|
|
79
|
+
|
|
80
|
+
async function load() {
|
|
81
|
+
setLoading(true)
|
|
82
|
+
setError(null)
|
|
83
|
+
try {
|
|
84
|
+
const [skills, s] = await Promise.all([fetchSkills(), fetchStats()])
|
|
85
|
+
setItems(skills)
|
|
86
|
+
setStats(s)
|
|
87
|
+
} catch (e) {
|
|
88
|
+
setError(e instanceof Error ? e.message : '加载失败')
|
|
89
|
+
} finally {
|
|
90
|
+
setLoading(false)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
useEffect(() => {
|
|
95
|
+
load()
|
|
96
|
+
}, [])
|
|
97
|
+
|
|
98
|
+
// SSE 静默刷新:文件变更时不闪「加载中」,只更新数据
|
|
99
|
+
const refresh = useCallback(async () => {
|
|
100
|
+
try {
|
|
101
|
+
const [skills, s] = await Promise.all([fetchSkills(), fetchStats()])
|
|
102
|
+
setItems(skills)
|
|
103
|
+
setStats(s)
|
|
104
|
+
} catch {
|
|
105
|
+
// 静默:实时刷新失败不打断当前界面
|
|
106
|
+
}
|
|
107
|
+
}, [])
|
|
108
|
+
useLiveReload(refresh)
|
|
109
|
+
|
|
110
|
+
async function handleReload() {
|
|
111
|
+
setReloading(true)
|
|
112
|
+
try {
|
|
113
|
+
await reload()
|
|
114
|
+
await load()
|
|
115
|
+
} finally {
|
|
116
|
+
setReloading(false)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function renderMain() {
|
|
121
|
+
if (ui.module !== 'skills') {
|
|
122
|
+
return <ComingSoon title={ui.module === 'commands' ? '命令' : '编辑器'} />
|
|
123
|
+
}
|
|
124
|
+
if (loading) return <p className="text-body-sm text-muted-foreground">加载中…</p>
|
|
125
|
+
if (error) {
|
|
126
|
+
return (
|
|
127
|
+
<div className="detail border-destructive">
|
|
128
|
+
<p className="text-body-sm text-destructive">加载失败:{error}</p>
|
|
129
|
+
</div>
|
|
130
|
+
)
|
|
131
|
+
}
|
|
132
|
+
if (ui.view === 'dashboard') return <DashboardView stats={stats} items={items} />
|
|
133
|
+
if (ui.view === 'otherSkills')
|
|
134
|
+
return (
|
|
135
|
+
<OtherSkillsView
|
|
136
|
+
query={ui.otherSkillsQuery}
|
|
137
|
+
onQuery={(q) => dispatch({ type: 'otherSkillsQuery', query: q })}
|
|
138
|
+
selectedId={ui.selectedId}
|
|
139
|
+
onSelect={(id) => dispatch({ type: 'select', id })}
|
|
140
|
+
/>
|
|
141
|
+
)
|
|
142
|
+
if (ui.view === 'settings') return <SettingsView />
|
|
143
|
+
return (
|
|
144
|
+
<SkillsView
|
|
145
|
+
items={items}
|
|
146
|
+
editorFilter={ui.editorFilter}
|
|
147
|
+
query={ui.query}
|
|
148
|
+
onQuery={(q) => dispatch({ type: 'query', query: q })}
|
|
149
|
+
kindFilter={ui.kindFilter}
|
|
150
|
+
onKind={(k) => dispatch({ type: 'kind', kind: k })}
|
|
151
|
+
selectedId={ui.selectedId}
|
|
152
|
+
onSelect={(id) => dispatch({ type: 'select', id })}
|
|
153
|
+
/>
|
|
154
|
+
)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return (
|
|
158
|
+
<div className="app-shell">
|
|
159
|
+
<Topbar
|
|
160
|
+
module={ui.module}
|
|
161
|
+
onModule={(m) => dispatch({ type: 'module', module: m })}
|
|
162
|
+
onReload={handleReload}
|
|
163
|
+
reloading={reloading}
|
|
164
|
+
/>
|
|
165
|
+
<Sidebar
|
|
166
|
+
view={ui.view}
|
|
167
|
+
editorFilter={ui.editorFilter}
|
|
168
|
+
stats={stats}
|
|
169
|
+
onDashboard={() => dispatch({ type: 'dashboard' })}
|
|
170
|
+
onSettings={() => dispatch({ type: 'settings' })}
|
|
171
|
+
onOtherSkills={() => dispatch({ type: 'otherSkills' })}
|
|
172
|
+
onEditor={(key) => dispatch({ type: 'editor', key })}
|
|
173
|
+
/>
|
|
174
|
+
<main className="main-pane">{renderMain()}</main>
|
|
175
|
+
</div>
|
|
176
|
+
)
|
|
177
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
interface ComingSoonProps {
|
|
2
|
+
title: string
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export function ComingSoon({ title }: ComingSoonProps) {
|
|
6
|
+
return (
|
|
7
|
+
<div className="grid h-full place-items-center text-muted-foreground">
|
|
8
|
+
<div className="text-center">
|
|
9
|
+
<p className="text-h4 text-foreground">{title}</p>
|
|
10
|
+
<p className="mt-1 text-body-sm">待开发,敬请期待</p>
|
|
11
|
+
</div>
|
|
12
|
+
</div>
|
|
13
|
+
)
|
|
14
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
2
|
+
import '@testing-library/jest-dom/vitest'
|
|
3
|
+
import { render, screen, fireEvent } from '@testing-library/react'
|
|
4
|
+
import { Sidebar } from './Sidebar'
|
|
5
|
+
import type { Stats } from '@/types'
|
|
6
|
+
|
|
7
|
+
function statsWith(byEditor: Record<string, number>): Stats {
|
|
8
|
+
const total = Object.values(byEditor).reduce((a, b) => a + b, 0)
|
|
9
|
+
return {
|
|
10
|
+
total,
|
|
11
|
+
bySource: {},
|
|
12
|
+
byEditor,
|
|
13
|
+
byKind: {},
|
|
14
|
+
byCategory: {},
|
|
15
|
+
byBrand: {},
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const noop = () => {}
|
|
20
|
+
|
|
21
|
+
describe('Sidebar editor 导航(D2 + C6 临界缺口)', () => {
|
|
22
|
+
it('渲染真实 editor 项,(none) 不作首项且标注「未分类」', () => {
|
|
23
|
+
render(
|
|
24
|
+
<Sidebar
|
|
25
|
+
view="skills"
|
|
26
|
+
editorFilter={null}
|
|
27
|
+
stats={statsWith({ 'Claude Code': 3, Cursor: 1, '(none)': 2 })}
|
|
28
|
+
onDashboard={noop}
|
|
29
|
+
onSettings={noop}
|
|
30
|
+
onOtherSkills={noop}
|
|
31
|
+
onEditor={noop}
|
|
32
|
+
/>
|
|
33
|
+
)
|
|
34
|
+
// 真实 editor 出现
|
|
35
|
+
expect(screen.getByText('Claude Code')).toBeInTheDocument()
|
|
36
|
+
expect(screen.getByText('Cursor')).toBeInTheDocument()
|
|
37
|
+
// (none) 桶渲染为「未分类」,绝不出现字面 "(none)"
|
|
38
|
+
expect(screen.getByText('未分类')).toBeInTheDocument()
|
|
39
|
+
expect(screen.queryByText('(none)')).toBeNull()
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('byEditor 全为 (none) 时不产出垃圾首项,只显示「未分类」', () => {
|
|
43
|
+
render(
|
|
44
|
+
<Sidebar
|
|
45
|
+
view="skills"
|
|
46
|
+
editorFilter={null}
|
|
47
|
+
stats={statsWith({ '(none)': 5 })}
|
|
48
|
+
onDashboard={noop}
|
|
49
|
+
onSettings={noop}
|
|
50
|
+
onOtherSkills={noop}
|
|
51
|
+
onEditor={noop}
|
|
52
|
+
/>
|
|
53
|
+
)
|
|
54
|
+
expect(screen.queryByText('(none)')).toBeNull()
|
|
55
|
+
expect(screen.getByText('未分类')).toBeInTheDocument()
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('点击 editor 项回传其 key', () => {
|
|
59
|
+
const onEditor = vi.fn()
|
|
60
|
+
render(
|
|
61
|
+
<Sidebar
|
|
62
|
+
view="skills"
|
|
63
|
+
editorFilter={null}
|
|
64
|
+
stats={statsWith({ 'Claude Code': 3 })}
|
|
65
|
+
onDashboard={noop}
|
|
66
|
+
onSettings={noop}
|
|
67
|
+
onOtherSkills={noop}
|
|
68
|
+
onEditor={onEditor}
|
|
69
|
+
/>
|
|
70
|
+
)
|
|
71
|
+
fireEvent.click(screen.getByText('Claude Code'))
|
|
72
|
+
expect(onEditor).toHaveBeenCalledWith('Claude Code')
|
|
73
|
+
})
|
|
74
|
+
})
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { LayoutDashboard, Settings, Layers, Sparkles } from 'lucide-react'
|
|
2
|
+
import { cn } from '@/lib/cn'
|
|
3
|
+
import { getEditorMeta, isNoneEditor } from '@/lib/editors'
|
|
4
|
+
import type { Stats } from '@/types'
|
|
5
|
+
|
|
6
|
+
interface SidebarProps {
|
|
7
|
+
view: 'dashboard' | 'skills' | 'otherSkills' | 'settings'
|
|
8
|
+
editorFilter: string | null
|
|
9
|
+
stats: Stats | null
|
|
10
|
+
onDashboard: () => void
|
|
11
|
+
onSettings: () => void
|
|
12
|
+
onOtherSkills: () => void
|
|
13
|
+
/** key=null 表示「全部技能」 */
|
|
14
|
+
onEditor: (key: string | null) => void
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function Sidebar({
|
|
18
|
+
view,
|
|
19
|
+
editorFilter,
|
|
20
|
+
stats,
|
|
21
|
+
onDashboard,
|
|
22
|
+
onSettings,
|
|
23
|
+
onOtherSkills,
|
|
24
|
+
onEditor,
|
|
25
|
+
}: SidebarProps) {
|
|
26
|
+
const byEditor = stats?.byEditor ?? {}
|
|
27
|
+
|
|
28
|
+
// 真实 editor 项(过滤 (none)),按数量降序
|
|
29
|
+
const editors = Object.entries(byEditor)
|
|
30
|
+
.filter(([k]) => !isNoneEditor(k))
|
|
31
|
+
.sort((a, b) => b[1] - a[1])
|
|
32
|
+
const noneCount = Object.entries(byEditor).find(([k]) => isNoneEditor(k))?.[1] ?? 0
|
|
33
|
+
const total = stats?.total ?? 0
|
|
34
|
+
|
|
35
|
+
const rowCls = (active: boolean) =>
|
|
36
|
+
cn(
|
|
37
|
+
'flex items-center justify-between gap-2 rounded-md px-3 py-2 text-body-sm transition-colors',
|
|
38
|
+
active
|
|
39
|
+
? 'bg-primary-soft text-primary'
|
|
40
|
+
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<aside className="sidebar">
|
|
45
|
+
<button onClick={onDashboard} className={rowCls(view === 'dashboard')}>
|
|
46
|
+
<span className="flex items-center gap-2">
|
|
47
|
+
<LayoutDashboard size={16} />
|
|
48
|
+
仪表盘
|
|
49
|
+
</span>
|
|
50
|
+
</button>
|
|
51
|
+
|
|
52
|
+
<button onClick={onOtherSkills} className={rowCls(view === 'otherSkills')}>
|
|
53
|
+
<span className="flex items-center gap-2">
|
|
54
|
+
<Sparkles size={16} />
|
|
55
|
+
其它技能
|
|
56
|
+
</span>
|
|
57
|
+
</button>
|
|
58
|
+
|
|
59
|
+
{/* 技能来源 (Editor) */}
|
|
60
|
+
<p className="mt-3 px-3 text-caption text-muted-foreground/70">技能来源</p>
|
|
61
|
+
|
|
62
|
+
<button
|
|
63
|
+
onClick={() => onEditor(null)}
|
|
64
|
+
className={rowCls(view === 'skills' && editorFilter === null)}
|
|
65
|
+
>
|
|
66
|
+
<span className="flex items-center gap-2">
|
|
67
|
+
<Layers size={16} />
|
|
68
|
+
全部来源
|
|
69
|
+
</span>
|
|
70
|
+
<span className="text-caption opacity-80">{total}</span>
|
|
71
|
+
</button>
|
|
72
|
+
|
|
73
|
+
{editors.map(([key, count]) => {
|
|
74
|
+
const meta = getEditorMeta(key)
|
|
75
|
+
const Icon = meta.icon
|
|
76
|
+
const active = view === 'skills' && editorFilter === key
|
|
77
|
+
return (
|
|
78
|
+
<button key={key} onClick={() => onEditor(key)} className={rowCls(active)}>
|
|
79
|
+
<span className="flex min-w-0 items-center gap-2">
|
|
80
|
+
<span
|
|
81
|
+
className="grid h-5 w-5 shrink-0 place-items-center rounded"
|
|
82
|
+
style={{ backgroundColor: meta.color + '1A', color: meta.color }}
|
|
83
|
+
>
|
|
84
|
+
<Icon size={13} />
|
|
85
|
+
</span>
|
|
86
|
+
<span className="truncate">{meta.label}</span>
|
|
87
|
+
</span>
|
|
88
|
+
<span className="text-caption opacity-80">{count}</span>
|
|
89
|
+
</button>
|
|
90
|
+
)
|
|
91
|
+
})}
|
|
92
|
+
|
|
93
|
+
{noneCount > 0 && (
|
|
94
|
+
<button
|
|
95
|
+
onClick={() => onEditor('(none)')}
|
|
96
|
+
className={rowCls(view === 'skills' && editorFilter === '(none)')}
|
|
97
|
+
>
|
|
98
|
+
<span className="flex items-center gap-2 text-muted-foreground">
|
|
99
|
+
<Layers size={16} />
|
|
100
|
+
未分类
|
|
101
|
+
</span>
|
|
102
|
+
<span className="text-caption opacity-80">{noneCount}</span>
|
|
103
|
+
</button>
|
|
104
|
+
)}
|
|
105
|
+
|
|
106
|
+
<button onClick={onSettings} className={cn(rowCls(view === 'settings'), 'mt-auto')}>
|
|
107
|
+
<span className="flex items-center gap-2">
|
|
108
|
+
<Settings size={16} />
|
|
109
|
+
设置
|
|
110
|
+
</span>
|
|
111
|
+
</button>
|
|
112
|
+
</aside>
|
|
113
|
+
)
|
|
114
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { Moon, Sun, RefreshCw, Boxes } from 'lucide-react'
|
|
2
|
+
import { Button } from '@/components/ui/button'
|
|
3
|
+
import { useTheme } from '@/hooks/useTheme'
|
|
4
|
+
import { cn } from '@/lib/cn'
|
|
5
|
+
|
|
6
|
+
export type ModuleKey = 'skills' | 'commands' | 'editor'
|
|
7
|
+
|
|
8
|
+
const MODULES: { key: ModuleKey; label: string; soon?: boolean }[] = [
|
|
9
|
+
{ key: 'skills', label: '技能 Skills' },
|
|
10
|
+
{ key: 'commands', label: '命令', soon: true },
|
|
11
|
+
{ key: 'editor', label: '编辑器', soon: true },
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
interface TopbarProps {
|
|
15
|
+
module: ModuleKey
|
|
16
|
+
onModule: (m: ModuleKey) => void
|
|
17
|
+
onReload: () => void
|
|
18
|
+
reloading: boolean
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function Topbar({ module, onModule, onReload, reloading }: TopbarProps) {
|
|
22
|
+
const { theme, toggle } = useTheme()
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<header className="topbar">
|
|
26
|
+
{/* 品牌 */}
|
|
27
|
+
<div className="flex items-center gap-2 pr-2">
|
|
28
|
+
<span className="grid h-8 w-8 place-items-center rounded-md bg-primary text-primary-foreground">
|
|
29
|
+
<Boxes size={18} />
|
|
30
|
+
</span>
|
|
31
|
+
<span className="text-body-sm font-bold text-foreground">HuHaa</span>
|
|
32
|
+
</div>
|
|
33
|
+
|
|
34
|
+
{/* 模块标签 */}
|
|
35
|
+
<nav className="flex items-center gap-1">
|
|
36
|
+
{MODULES.map((m) => {
|
|
37
|
+
const active = module === m.key
|
|
38
|
+
return (
|
|
39
|
+
<button
|
|
40
|
+
key={m.key}
|
|
41
|
+
disabled={m.soon}
|
|
42
|
+
onClick={() => !m.soon && onModule(m.key)}
|
|
43
|
+
className={cn(
|
|
44
|
+
'rounded-md px-3 py-1.5 text-body-sm transition-colors',
|
|
45
|
+
active
|
|
46
|
+
? 'bg-primary-soft text-primary'
|
|
47
|
+
: m.soon
|
|
48
|
+
? 'cursor-not-allowed text-muted-foreground/60'
|
|
49
|
+
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
|
|
50
|
+
)}
|
|
51
|
+
>
|
|
52
|
+
{m.label}
|
|
53
|
+
{m.soon && <span className="ml-1 text-caption opacity-70">待开发</span>}
|
|
54
|
+
</button>
|
|
55
|
+
)
|
|
56
|
+
})}
|
|
57
|
+
</nav>
|
|
58
|
+
|
|
59
|
+
<div className="ml-auto flex items-center gap-1">
|
|
60
|
+
<Button variant="ghost" size="icon" onClick={onReload} aria-label="重新扫描">
|
|
61
|
+
<RefreshCw size={18} className={reloading ? 'animate-spin' : undefined} />
|
|
62
|
+
</Button>
|
|
63
|
+
<Button
|
|
64
|
+
variant="ghost"
|
|
65
|
+
size="icon"
|
|
66
|
+
onClick={toggle}
|
|
67
|
+
aria-label={theme === 'dark' ? '切换到亮色' : '切换到暗色'}
|
|
68
|
+
>
|
|
69
|
+
{theme === 'dark' ? <Sun size={18} /> : <Moon size={18} />}
|
|
70
|
+
</Button>
|
|
71
|
+
</div>
|
|
72
|
+
</header>
|
|
73
|
+
)
|
|
74
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
import { cn } from '@/lib/cn'
|
|
3
|
+
|
|
4
|
+
interface ActionButtonProps {
|
|
5
|
+
label: string
|
|
6
|
+
/** 返回 {ok} 的异步动作(copy/open);ok=false 或抛错都显示失败 */
|
|
7
|
+
onAction: () => Promise<{ ok: boolean; error?: string }>
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
type Status = 'idle' | 'ok' | 'err'
|
|
11
|
+
|
|
12
|
+
/** 执行一次性动作并短暂反馈结果(用于复制/打开)。 */
|
|
13
|
+
export function ActionButton({ label, onAction }: ActionButtonProps) {
|
|
14
|
+
const [status, setStatus] = useState<Status>('idle')
|
|
15
|
+
const [busy, setBusy] = useState(false)
|
|
16
|
+
|
|
17
|
+
async function run() {
|
|
18
|
+
setBusy(true)
|
|
19
|
+
try {
|
|
20
|
+
const r = await onAction()
|
|
21
|
+
setStatus(r.ok ? 'ok' : 'err')
|
|
22
|
+
} catch {
|
|
23
|
+
setStatus('err')
|
|
24
|
+
} finally {
|
|
25
|
+
setBusy(false)
|
|
26
|
+
setTimeout(() => setStatus('idle'), 1400)
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<button
|
|
32
|
+
type="button"
|
|
33
|
+
onClick={run}
|
|
34
|
+
disabled={busy}
|
|
35
|
+
className={cn(
|
|
36
|
+
'rounded-md border px-2.5 py-1 text-caption transition-colors disabled:opacity-60',
|
|
37
|
+
status === 'ok'
|
|
38
|
+
? 'border-primary bg-primary-soft text-primary'
|
|
39
|
+
: status === 'err'
|
|
40
|
+
? 'border-destructive text-destructive'
|
|
41
|
+
: 'border-border bg-card text-muted-foreground hover:text-foreground'
|
|
42
|
+
)}
|
|
43
|
+
>
|
|
44
|
+
{status === 'ok' ? '✓ ' : status === 'err' ? '✗ ' : ''}
|
|
45
|
+
{label}
|
|
46
|
+
</button>
|
|
47
|
+
)
|
|
48
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { forwardRef } from 'react'
|
|
2
|
+
import { cva, type VariantProps } from 'class-variance-authority'
|
|
3
|
+
import { cn } from '@/lib/cn'
|
|
4
|
+
|
|
5
|
+
// shadcn 风格按钮:用 cva 描述变体,token 全部来自 Tailwind 主题。
|
|
6
|
+
const buttonVariants = cva(
|
|
7
|
+
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-body-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50',
|
|
8
|
+
{
|
|
9
|
+
variants: {
|
|
10
|
+
variant: {
|
|
11
|
+
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
|
12
|
+
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
|
13
|
+
destructive:
|
|
14
|
+
'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
|
15
|
+
outline:
|
|
16
|
+
'border border-border bg-transparent hover:bg-muted hover:text-foreground',
|
|
17
|
+
ghost: 'hover:bg-muted hover:text-foreground',
|
|
18
|
+
},
|
|
19
|
+
size: {
|
|
20
|
+
default: 'h-9 px-4 py-2',
|
|
21
|
+
sm: 'h-8 px-3',
|
|
22
|
+
lg: 'h-10 px-6',
|
|
23
|
+
icon: 'h-9 w-9',
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
defaultVariants: {
|
|
27
|
+
variant: 'default',
|
|
28
|
+
size: 'default',
|
|
29
|
+
},
|
|
30
|
+
}
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
export interface ButtonProps
|
|
34
|
+
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
|
35
|
+
VariantProps<typeof buttonVariants> {}
|
|
36
|
+
|
|
37
|
+
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
|
38
|
+
({ className, variant, size, ...props }, ref) => (
|
|
39
|
+
<button
|
|
40
|
+
ref={ref}
|
|
41
|
+
className={cn(buttonVariants({ variant, size }), className)}
|
|
42
|
+
{...props}
|
|
43
|
+
/>
|
|
44
|
+
)
|
|
45
|
+
)
|
|
46
|
+
Button.displayName = 'Button'
|
|
47
|
+
|
|
48
|
+
export { buttonVariants }
|