262 lines
6.1 KiB
Go
262 lines
6.1 KiB
Go
package internal
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"unicode/utf8"
|
|
|
|
tea "charm.land/bubbletea/v2"
|
|
)
|
|
|
|
// ScanSkillsCompleteMsg 技能扫描完成消息
|
|
type ScanSkillsCompleteMsg struct {
|
|
Skills []*SkillInfo
|
|
}
|
|
|
|
// ScanSkillsCmd 扫描 ~/.claude/skills/ 目录
|
|
func ScanSkillsCmd() tea.Cmd {
|
|
return func() tea.Msg {
|
|
skills, err := ScanSkills()
|
|
if err != nil {
|
|
return ScanSkillsCompleteMsg{}
|
|
}
|
|
return ScanSkillsCompleteMsg{Skills: skills}
|
|
}
|
|
}
|
|
|
|
// SkillInfo 单个技能信息
|
|
type SkillInfo struct {
|
|
Name string
|
|
Dir string
|
|
Description string
|
|
Root *SkillFileNode
|
|
}
|
|
|
|
// SkillFileNode 技能文件树节点
|
|
type SkillFileNode struct {
|
|
Name string
|
|
Path string
|
|
IsDir bool
|
|
Expanded bool
|
|
Children []*SkillFileNode
|
|
}
|
|
|
|
// SkillsState 技能 tab 视图状态
|
|
type SkillsState struct {
|
|
Skills []*SkillInfo
|
|
SkillCur int // 技能列表光标 (panel 0)
|
|
FileCur int // 文件树光标 (panel 1)
|
|
FocusPanel int // 0=技能列表 1=文件树 2=内容
|
|
Content string
|
|
ContentPath string
|
|
ContentScroll int // 内容面板纵向滚动偏移(行)
|
|
Loaded bool
|
|
Scanning bool
|
|
}
|
|
|
|
// flatNode 文件树扁平化节点
|
|
type flatNode struct {
|
|
*SkillFileNode
|
|
Depth int
|
|
}
|
|
|
|
// ScanSkills 扫描 ~/.claude/skills/ 目录
|
|
func ScanSkills() ([]*SkillInfo, error) {
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
skillsDir := filepath.Join(home, ".claude", "skills")
|
|
return scanSkillsDir(skillsDir)
|
|
}
|
|
|
|
func scanSkillsDir(dir string) ([]*SkillInfo, error) {
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var skills []*SkillInfo
|
|
for _, entry := range entries {
|
|
if !entry.IsDir() {
|
|
continue
|
|
}
|
|
name := entry.Name()
|
|
if strings.HasPrefix(name, ".") {
|
|
continue
|
|
}
|
|
skillPath := filepath.Join(dir, name)
|
|
info := &SkillInfo{
|
|
Name: name,
|
|
Dir: skillPath,
|
|
}
|
|
// 读取 SKILL.md 获取描述
|
|
skillMd := filepath.Join(skillPath, "SKILL.md")
|
|
if data, err := os.ReadFile(skillMd); err == nil {
|
|
info.Description = extractSkillDesc(string(data))
|
|
}
|
|
// 构建文件树
|
|
info.Root = buildFileTree(skillPath)
|
|
if info.Root != nil {
|
|
info.Root.Expanded = true // 默认展开根节点
|
|
}
|
|
skills = append(skills, info)
|
|
}
|
|
sort.Slice(skills, func(i, j int) bool {
|
|
return skills[i].Name < skills[j].Name
|
|
})
|
|
return skills, nil
|
|
}
|
|
|
|
// extractSkillDesc 从 SKILL.md 提取描述文本
|
|
func extractSkillDesc(content string) string {
|
|
lines := strings.Split(content, "\n")
|
|
afterHeading := false
|
|
for _, line := range lines {
|
|
line = strings.TrimSpace(line)
|
|
if strings.HasPrefix(line, "#") {
|
|
afterHeading = true
|
|
continue
|
|
}
|
|
// 跳过分隔线、引言、元数据
|
|
if strings.HasPrefix(line, "---") || strings.HasPrefix(line, ">") ||
|
|
strings.HasPrefix(line, "##") || strings.HasPrefix(line, "|") ||
|
|
strings.HasPrefix(line, "```") || strings.HasPrefix(line, "<!--") {
|
|
continue
|
|
}
|
|
if afterHeading && line != "" {
|
|
return truncStr(line, 80)
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// buildFileTree 递归构建文件树
|
|
func buildFileTree(rootPath string) *SkillFileNode {
|
|
info, err := os.Stat(rootPath)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
node := &SkillFileNode{
|
|
Name: filepath.Base(rootPath),
|
|
Path: rootPath,
|
|
IsDir: info.IsDir(),
|
|
}
|
|
if !info.IsDir() {
|
|
return node
|
|
}
|
|
entries, err := os.ReadDir(rootPath)
|
|
if err != nil {
|
|
return node
|
|
}
|
|
for _, entry := range entries {
|
|
name := entry.Name()
|
|
if strings.HasPrefix(name, ".") {
|
|
continue
|
|
}
|
|
child := buildFileTree(filepath.Join(rootPath, name))
|
|
if child != nil {
|
|
node.Children = append(node.Children, child)
|
|
}
|
|
}
|
|
// 排序:目录在前,文件在后;同类型按字母序
|
|
sort.Slice(node.Children, func(i, j int) bool {
|
|
if node.Children[i].IsDir != node.Children[j].IsDir {
|
|
return node.Children[i].IsDir
|
|
}
|
|
return node.Children[i].Name < node.Children[j].Name
|
|
})
|
|
return node
|
|
}
|
|
|
|
// --- 文件树扁平化 ---
|
|
|
|
func (m *Model) skillsFlattened() []flatNode {
|
|
s := m.currentSkill()
|
|
if s == nil || s.Root == nil {
|
|
return nil
|
|
}
|
|
return flattenNode(s.Root, 0)
|
|
}
|
|
|
|
func flattenNode(node *SkillFileNode, depth int) []flatNode {
|
|
var result []flatNode
|
|
result = append(result, flatNode{SkillFileNode: node, Depth: depth})
|
|
if node.IsDir && node.Expanded {
|
|
for _, child := range node.Children {
|
|
result = append(result, flattenNode(child, depth+1)...)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// --- 查找与导航 ---
|
|
|
|
func (m *Model) currentSkill() *SkillInfo {
|
|
if m.Skills.Skills == nil || m.Skills.SkillCur < 0 || m.Skills.SkillCur >= len(m.Skills.Skills) {
|
|
return nil
|
|
}
|
|
return m.Skills.Skills[m.Skills.SkillCur]
|
|
}
|
|
|
|
func (m *Model) currentFlatNode() *flatNode {
|
|
flat := m.skillsFlattened()
|
|
if flat == nil || m.Skills.FileCur < 0 || m.Skills.FileCur >= len(flat) {
|
|
return nil
|
|
}
|
|
return &flat[m.Skills.FileCur]
|
|
}
|
|
|
|
// loadSkillContent 加载文件内容到右侧面板
|
|
func (m *Model) loadSkillContent(path string) {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
m.Skills.Content = "无法读取文件"
|
|
m.Skills.ContentPath = path
|
|
return
|
|
}
|
|
m.Skills.Content = truncateSkillContent(string(data), 100*1024)
|
|
m.Skills.ContentPath = path
|
|
m.Skills.ContentScroll = 0
|
|
}
|
|
|
|
// truncateSkillContent 限制内容大小避免大文件卡顿;截断点可能落在
|
|
// UTF-8 多字节序列中间,逐字节回退到合法边界,避免尾部出现乱码
|
|
func truncateSkillContent(content string, limit int) string {
|
|
if len(content) <= limit {
|
|
return content
|
|
}
|
|
content = content[:limit]
|
|
for len(content) > 0 {
|
|
if r, size := utf8.DecodeLastRuneInString(content); r != utf8.RuneError || size > 1 {
|
|
break
|
|
}
|
|
content = content[:len(content)-1]
|
|
}
|
|
return content + "\n\n... (文件过大,截断显示)"
|
|
}
|
|
|
|
// IsSkillsTab 判断当前是否在技能 tab
|
|
func (m *Model) IsSkillsTab() bool {
|
|
return m.activeGroup == len(Groups)+1
|
|
}
|
|
|
|
// --- 文件树节点操作 ---
|
|
|
|
// findNodeByPath 在树中按路径查找节点
|
|
func findNodeByPath(root *SkillFileNode, path string) *SkillFileNode {
|
|
if root == nil {
|
|
return nil
|
|
}
|
|
if root.Path == path {
|
|
return root
|
|
}
|
|
for _, child := range root.Children {
|
|
if found := findNodeByPath(child, path); found != nil {
|
|
return found
|
|
}
|
|
}
|
|
return nil
|
|
}
|