Files
lxy 558b676eea 修复: 技能面板视口按行收口与截断尾部乱码
- 技能列表视口改按行数切片(含描述行),修复变长条目撑爆面板
- 大文件截断改按 rune 边界回退,修复中文内容尾部乱码
- 补视口与截断边界测试
2026-10-03 10:13:35 +08:00

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
}