diff --git a/internal/skills.go b/internal/skills.go index 125007e..9aef1df 100644 --- a/internal/skills.go +++ b/internal/skills.go @@ -5,6 +5,7 @@ import ( "path/filepath" "sort" "strings" + "unicode/utf8" tea "charm.land/bubbletea/v2" ) @@ -215,16 +216,27 @@ func (m *Model) loadSkillContent(path string) { 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.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 diff --git a/internal/skills_test.go b/internal/skills_test.go new file mode 100644 index 0000000..aed54f0 --- /dev/null +++ b/internal/skills_test.go @@ -0,0 +1,50 @@ +package internal + +import ( + "strings" + "testing" + "unicode/utf8" +) + +const truncSuffix = "\n\n... (文件过大,截断显示)" + +// TestTruncateSkillContentRuneBoundary 验证截断点落在多字节序列中间时 +// 逐字节回退到合法 rune 边界,结果不含残缺字节 +func TestTruncateSkillContentRuneBoundary(t *testing.T) { + limit := 100 * 1024 + // 中文各 3 字节:分别构造截断点落在中文字符第 1、2 字节后的场景 + for _, cutInChar := range []int{1, 2} { + prefix := strings.Repeat("a", limit-cutInChar) + content := prefix + "中文内容超出限制" + got := truncateSkillContent(content, limit) + body := strings.TrimSuffix(got, truncSuffix) + if !utf8.ValidString(body) { + t.Fatalf("截断点落在字符第 %d 字节后, 结果应为合法 UTF-8", cutInChar) + } + if !strings.HasPrefix(body, prefix) { + t.Fatalf("截断点落在字符第 %d 字节后, 不应吞掉 ASCII 前缀", cutInChar) + } + if len(body) > limit { + t.Fatalf("截断点落在字符第 %d 字节后, body 长度 %d 超限 %d", cutInChar, len(body), limit) + } + if !strings.HasSuffix(got, truncSuffix) { + t.Fatalf("截断后应带省略提示后缀, got %q", got[len(got)-40:]) + } + } +} + +// TestTruncateSkillContentEdge 验证边界:内容不超限原样返回,恰在边界截断不误删 +func TestTruncateSkillContentEdge(t *testing.T) { + limit := 100 * 1024 + short := "短内容" + if got := truncateSkillContent(short, limit); got != short { + t.Fatalf("未超限内容应原样返回, got %q", got) + } + // 恰好 limit 字节后跟多字节字符:截断点在边界上,不应回退删掉合法内容 + exact := strings.Repeat("a", limit) + got := truncateSkillContent(exact+"中文", limit) + body := strings.TrimSuffix(got, truncSuffix) + if body != exact { + t.Fatalf("边界截断不应删减合法内容, body 长度 = %d, 期望 %d", len(body), limit) + } +} diff --git a/internal/view_skills.go b/internal/view_skills.go index afa2a98..5c00804 100644 --- a/internal/view_skills.go +++ b/internal/view_skills.go @@ -65,10 +65,16 @@ func (m *Model) skillsListLines(w, listH int) []string { if len(skills) == 0 { return []string{style.DimFg.Render(fitWidth(" empty", w))} } - start, end := viewport(m.Skills.SkillCur, len(skills), listH) - var lines []string - for i := start; i < end; i++ { - sk := skills[i] + // 先为所有技能累积全部行(含描述行),再按行数视口切片, + // 避免按条目数计算视口导致变长行撑爆面板 + type skillBlock struct { + lines []string + } + var blocks []skillBlock + totalLines := 0 + curLineOffset := 0 + + for i, sk := range skills { cur := " " if i == m.Skills.SkillCur { cur = "▸" @@ -78,7 +84,7 @@ func (m *Model) skillsListLines(w, listH int) []string { if i == m.Skills.SkillCur && m.Skills.FocusPanel == 0 { sty = style.SelStyle } - lines = append(lines, sty.Render(padRightByWidth(truncateByWidth(line, w), w))) + blockLines := []string{sty.Render(padRightByWidth(truncateByWidth(line, w), w))} // 如果有描述,追加一行 if sk.Description != "" { descLine := " " + sk.Description @@ -86,10 +92,25 @@ func (m *Model) skillsListLines(w, listH int) []string { if i == m.Skills.SkillCur && m.Skills.FocusPanel == 0 { descSty = style.SelStyle } - lines = append(lines, descSty.Render(padRightByWidth(truncateByWidth(descLine, w), w))) + blockLines = append(blockLines, descSty.Render(padRightByWidth(truncateByWidth(descLine, w), w))) } + if i == m.Skills.SkillCur { + curLineOffset = totalLines + } + totalLines += len(blockLines) + blocks = append(blocks, skillBlock{lines: blockLines}) } - return lines + + start, end := viewport(curLineOffset, totalLines, listH) + + var allLines []string + for _, b := range blocks { + allLines = append(allLines, b.lines...) + } + if end > len(allLines) { + end = len(allLines) + } + return allLines[start:end] } func (m *Model) skillsFileLines(w, listH int) []string { diff --git a/internal/view_skills_test.go b/internal/view_skills_test.go new file mode 100644 index 0000000..7cc5a43 --- /dev/null +++ b/internal/view_skills_test.go @@ -0,0 +1,32 @@ +package internal + +import ( + "strings" + "testing" +) + +// TestSkillsListLinesViewportByLines 验证技能列表视口按行数(含描述行)收口, +// 返回行数不超过 listH,避免变长行撑爆面板 +func TestSkillsListLinesViewportByLines(t *testing.T) { + m := NewModel() + m.Skills.Loaded = true + for i := 0; i < 3; i++ { + m.Skills.Skills = append(m.Skills.Skills, &SkillInfo{ + Name: "skill-" + strings.Repeat("x", i), + Description: "描述行 " + string(rune('A'+i)), + }) + } + m.Skills.SkillCur = 0 + m.Skills.FocusPanel = 0 + + listH := 3 // 足够只显示部分(总共 6 行:3 技能 × 2 行) + lines := m.skillsListLines(30, listH) + if len(lines) > listH { + t.Fatalf("skillsListLines 应返回 ≤ %d 行, got %d", listH, len(lines)) + } + + // 光标在首个技能时,视口应从头开始且光标行可见 + if !strings.Contains(lines[0], "▸") { + t.Fatalf("首行应含光标标记, got %q", lines[0]) + } +}