修复: 技能面板视口按行收口与截断尾部乱码
- 技能列表视口改按行数切片(含描述行),修复变长条目撑爆面板 - 大文件截断改按 rune 边界回退,修复中文内容尾部乱码 - 补视口与截断边界测试
This commit is contained in:
+18
-6
@@ -5,6 +5,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
tea "charm.land/bubbletea/v2"
|
tea "charm.land/bubbletea/v2"
|
||||||
)
|
)
|
||||||
@@ -215,16 +216,27 @@ func (m *Model) loadSkillContent(path string) {
|
|||||||
m.Skills.ContentPath = path
|
m.Skills.ContentPath = path
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
content := string(data)
|
m.Skills.Content = truncateSkillContent(string(data), 100*1024)
|
||||||
// 限制内容大小,避免大文件卡顿
|
|
||||||
if len(content) > 100*1024 {
|
|
||||||
content = content[:100*1024] + "\n\n... (文件过大,截断显示)"
|
|
||||||
}
|
|
||||||
m.Skills.Content = content
|
|
||||||
m.Skills.ContentPath = path
|
m.Skills.ContentPath = path
|
||||||
m.Skills.ContentScroll = 0
|
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
|
// IsSkillsTab 判断当前是否在技能 tab
|
||||||
func (m *Model) IsSkillsTab() bool {
|
func (m *Model) IsSkillsTab() bool {
|
||||||
return m.activeGroup == len(Groups)+1
|
return m.activeGroup == len(Groups)+1
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+28
-7
@@ -65,10 +65,16 @@ func (m *Model) skillsListLines(w, listH int) []string {
|
|||||||
if len(skills) == 0 {
|
if len(skills) == 0 {
|
||||||
return []string{style.DimFg.Render(fitWidth(" empty", w))}
|
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++ {
|
type skillBlock struct {
|
||||||
sk := skills[i]
|
lines []string
|
||||||
|
}
|
||||||
|
var blocks []skillBlock
|
||||||
|
totalLines := 0
|
||||||
|
curLineOffset := 0
|
||||||
|
|
||||||
|
for i, sk := range skills {
|
||||||
cur := " "
|
cur := " "
|
||||||
if i == m.Skills.SkillCur {
|
if i == m.Skills.SkillCur {
|
||||||
cur = "▸"
|
cur = "▸"
|
||||||
@@ -78,7 +84,7 @@ func (m *Model) skillsListLines(w, listH int) []string {
|
|||||||
if i == m.Skills.SkillCur && m.Skills.FocusPanel == 0 {
|
if i == m.Skills.SkillCur && m.Skills.FocusPanel == 0 {
|
||||||
sty = style.SelStyle
|
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 != "" {
|
if sk.Description != "" {
|
||||||
descLine := " " + 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 {
|
if i == m.Skills.SkillCur && m.Skills.FocusPanel == 0 {
|
||||||
descSty = style.SelStyle
|
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
|
||||||
}
|
}
|
||||||
return lines
|
totalLines += len(blockLines)
|
||||||
|
blocks = append(blocks, skillBlock{lines: blockLines})
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
func (m *Model) skillsFileLines(w, listH int) []string {
|
||||||
|
|||||||
@@ -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])
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user