Charm CLI中File-picker无法进入嵌套目录问题排查
多视图BubbleTea应用中File-Picker无法进入子目录的修复方案
问题描述
我正在开发一个支持条件渲染的多视图CLI,基于BubbleTea的文件选择器和多视图官方示例实现。目前能正常渲染第二个视图(file-picker),但按下回车或右箭头尝试进入子目录时,光标会回到顶部,推测是组件被重新渲染导致状态丢失。
问题根源
- 主Update函数的状态干扰:提前调用了
m.Filepicker.Update(msg),但Go是值传递,这里的修改无法传递到后续子更新函数,反而打乱了组件状态 - 消息处理位置错误:文件选择器的逻辑被放在第一个视图的
updateChoices函数中,而实际上只有进入第二个视图后才需要操作filepicker - 视图切换后未处理组件消息:进入第二个视图(
m.Chosen为true)后,updateChosen完全没处理filepicker的消息,导致回车、方向键等操作无法被组件响应
修复后的完整代码
package main // An example demonstrating an application with multiple views. // // Note that this example was produced before the Bubbles progress component // was available (github.com/charmbracelet/bubbles/progress) and thus, we're // implementing a progress bar from scratch here. import ( "errors" "fmt" "math" "os" "strconv" "strings" "time" "github.com/charmbracelet/bubbles/filepicker" tea "github.com/charmbracelet/bubbletea" "github.com/fogleman/ease" "github.com/lucasb-eyer/go-colorful" "github.com/muesli/reflow/indent" "github.com/muesli/termenv" ) const ( progressBarWidth = 71 progressFullChar = "█" progressEmptyChar = "░" ) // General stuff for styling the view var ( term = termenv.EnvColorProfile() keyword = makeFgStyle("211") subtle = makeFgStyle("241") progressEmpty = subtle(progressEmptyChar) dot = colorFg(" • ", "236") // Gradient colors we'll use for the progress bar ramp = makeRamp("#B14FFF", "#00FFA3", progressBarWidth) ) func main() { var tm tea.Model var err error fp := filepicker.New() fp.AllowedTypes = []string{".mod", ".sum", ".go", ".txt", ".md"} fp.CurrentDirectory, _ = os.UserHomeDir() // fp.ShowHidden = true initialModel := model{fp, "", 0, false, 10, 0, 0, false, false} p := tea.NewProgram(&initialModel, tea.WithOutput(os.Stderr)) if tm, err = p.Run(); err != nil { fmt.Println("could not start program:", err) } mm := tm.(model) fmt.Println("\n You selected: " + initialModel.Filepicker.Styles.Selected.Render(mm.selectedFile) + "\n") } type ( tickMsg struct{} frameMsg struct{} ) func tick() tea.Cmd { return tea.Tick(time.Second, func(time.Time) tea.Msg { return tickMsg{} }) } func frame() tea.Cmd { return tea.Tick(time.Second/60, func(time.Time) tea.Msg { return frameMsg{} }) } type model struct { Filepicker filepicker.Model selectedFile string Choice int Chosen bool Ticks int Frames int Progress float64 Loaded bool Quitting bool } func (m model) Init() tea.Cmd { return tea.Batch(tick(), m.Filepicker.Init()) } // Main update function. func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Make sure these keys always quit if msg, ok := msg.(tea.KeyMsg); ok { k := msg.String() if k == "q" || k == "esc" || k == "ctrl+c" { m.Quitting = true return m, tea.Quit } } // Hand off the message and model to the appropriate update function for the // appropriate view based on the current state. if !m.Chosen { return updateChoices(msg, m) } return updateChosen(msg, m) } // The main view, which just calls the appropriate sub-view func (m model) View() string { var s string if m.Quitting { return "\n See you later!\n\n" } if !m.Chosen { s = choicesView(m) } else { s = chosenView(m) } return indent.String("\n"+s+"\n\n", 2) } // Sub-update functions // Update loop for the first view where you're choosing a task. func updateChoices(msg tea.Msg, m model) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: switch msg.String() { case "j", "down": m.Choice++ if m.Choice > 3 { m.Choice = 3 } case "k", "up": m.Choice-- if m.Choice < 0 { m.Choice = 0 } case "enter": m.Chosen = true return m, frame() } case tickMsg: if m.Ticks == 0 { m.Quitting = true return m, tea.Quit } m.Ticks-- return m, tick() } return m, nil } // Update loop for the second view after a choice has been made func updateChosen(msg tea.Msg, m model) (tea.Model, tea.Cmd) { // 优先处理filepicker的更新 var fpCmd tea.Cmd m.Filepicker, fpCmd = m.Filepicker.Update(msg) // 处理filepicker的选择事件 if didSelect, path := m.Filepicker.DidSelectFile(msg); didSelect { m.selectedFile = path } if didSelect, path := m.Filepicker.DidSelectDisabledFile(msg); didSelect { fmt.Println(errors.New(path + " is not valid.")) m.selectedFile = "" } // 处理原有视图逻辑 switch msg.(type) { case frameMsg: if !m.Loaded { m.Frames++ m.Progress = ease.OutBounce(float64(m.Frames) / float64(100)) if m.Progress >= 1 { m.Progress = 1 m.Loaded = true m.Ticks = 100 return m, tea.Batch(fpCmd, tick()) } return m, tea.Batch(fpCmd, frame()) } case tickMsg: if m.Loaded { if m.Ticks == 0 { m.Quitting = true return m, tea.Batch(fpCmd, tea.Quit) } m.Ticks-- return m, tea.Batch(fpCmd, tick()) } } return m, fpCmd } // Sub-views // The first view, where you're choosing a task func choicesView(m model) string { c := m.Choice tpl := "What to do today?\n\n" tpl += "%s\n\n" tpl += "Program quits in %s seconds\n\n" tpl += subtle("j/k, up/down: select") + dot + subtle("enter: choose") + dot + subtle("q, esc: quit") choices := fmt.Sprintf( "%s\n%s", checkbox("Choose an Aesir file", c == 0), checkbox("Start REPL", c == 1), ) return fmt.Sprintf(tpl, choices, colorFg(strconv.Itoa(m.Ticks), "79")) } // The second view, after a task has been chosen func chosenView(m model) string { var msg string switch m.Choice { case 0: var s strings.Builder s.WriteString("\n ") if m.selectedFile == "" { s.WriteString("Pick a file:") } else { s.WriteString("Selected file: " + m.Filepicker.Styles.Selected.Render(m.selectedFile)) } s.WriteString("\n\n" + m.Filepicker.View() + "\n") msg = s.String() case 1: msg = fmt.Sprintf("A trip to the market?\n\nOkay, then we should install %s and %s...", keyword("marketkit"), keyword("libshopping")) default: msg = fmt.Sprintf("It’s always good to see friends.\n\nFetching %s and %s...", keyword("social-skills"), keyword("conversationutils")) } label := "Downloading..." if m.Loaded { label = fmt.Sprintf("Downloaded. Exiting in %s seconds...", colorFg(strconv.Itoa(m.Ticks), "79")) } return msg + "\n\n" + label + "\n" + progressbar(m.Progress) + "%" } func checkbox(label string, checked bool) string { if checked { return colorFg("[x] "+label, "212") } return fmt.Sprintf("[ ] %s", label) } func progressbar(percent float64) string { w := float64(progressBarWidth) fullSize := int(math.Round(w * percent)) var fullCells string for i := 0; i < fullSize; i++ { fullCells += termenv.String(progressFullChar).Foreground(term.Color(ramp[i])).String() } emptySize := int(w) - fullSize emptyCells := strings.Repeat(progressEmpty, emptySize) return fmt.Sprintf("%s%s %3.0f", fullCells, emptyCells, math.Round(percent*100)) } // Utils // Color a string's foreground with the given value. func colorFg(val, color string) string { return termenv.String(val).Foreground(term.Color(color)).String() } // Return a function that will colorize the foreground of a given string. func makeFgStyle(color string) func(string) string { return termenv.Style{}.Foreground(term.Color(color)).Styled } // Generate a blend of colors. func makeRamp(colorA, colorB string, steps float64) (s []string) { cA, _ := colorful.Hex(colorA) cB, _ := colorful.Hex(colorB) for i := 0.0; i < steps; i++ { c := cA.BlendLuv(cB, i/steps) s = append(s, colorToHex(c)) } return } // Convert a colorful.Color to a hexadecimal format compatible with termenv. func colorToHex(c colorful.Color) string { return fmt.Sprintf("#%s%s%s", colorFloatToHex(c.R), colorFloatToHex(c.G), colorFloatToHex(c.B)) } // Helper function for converting colors to hex. Assumes a value between 0 and // 1. func colorFloatToHex(f float64) (s string) { s = strconv.FormatInt(int64(f*255), 16) if len(s) == 1 { s = "0" + s } return }
修复说明
- 移除主Update中的无效调用:删掉主函数里提前执行的
m.Filepicker.Update(msg),避免状态混乱 - 迁移filepicker逻辑到正确视图:把filepicker的消息更新、选中事件处理全部移到
updateChosen函数,确保只有在渲染filepicker的视图中才处理它的消息 - 正确合并命令:在
updateChosen中合并filepicker的命令和原有视图的命令,保证所有异步操作被正确调度 - 修正值传递问题:通过子函数返回修改后的模型,确保组件状态被正确保留和传递
内容的提问来源于stack exchange,提问作者Towhid.kahn
相关产品推荐
相关产品推荐

