新增: skills 视图与标题编辑测试(未落库功能批量入库)
This commit is contained in:
@@ -0,0 +1,249 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
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
|
||||
}
|
||||
content := string(data)
|
||||
// 限制内容大小,避免大文件卡顿
|
||||
if len(content) > 100*1024 {
|
||||
content = content[:100*1024] + "\n\n... (文件过大,截断显示)"
|
||||
}
|
||||
m.Skills.Content = content
|
||||
m.Skills.ContentPath = path
|
||||
m.Skills.ContentScroll = 0
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user