package internal import tea "charm.land/bubbletea/v2" // --- 按键分发 --- func (m *Model) handleKey(s string) (*Model, tea.Cmd) { // 编辑标题时优先走输入处理,避免 q/1/2/u 等被全局拦截 if m.editTitleMode { return m.handleEditTitleInput(s) } switch s { case "q", "ctrl+c": return m, tea.Quit case "u": if m.update.Available && !m.update.Updating { m.update.Updating = true return m, SelfUpdateCmd(m.update.DownloadURL, m.update.SHA256, m.update.NewVersion) } } // Tab 快捷切换: 1=工作空间 2=历史对话 3=技能 if s == "1" { m.editTitleMode = false m.editTitleBuf = "" if m.IsHistoryTab() || m.IsSkillsTab() { m.activeGroup = 0 m.resetCursor() m.wsFocus = 1 } return m, nil } if s == "2" { if !m.IsHistoryTab() { m.activeGroup = len(Groups) // HISTORY m.resetCursor() m.wsFocus = 1 return m, m.onTabSwitch() } return m, nil } if s == "3" { if !m.IsSkillsTab() { m.activeGroup = len(Groups) + 1 // SKILLS m.resetCursor() m.wsFocus = 1 return m, m.onSkillsTabSwitch() } return m, nil } if m.IsSkillsTab() { return m.handleSkillsKey(s) } if m.IsHistoryTab() { return m.handleHistoryKey(s) } return m.handleWorkspaceKey(s) } func (m *Model) onTabSwitch() tea.Cmd { if !m.IsHistoryTab() || m.history.Scanning { return nil } m.history.Scanning = true return ScanSessionsCmd() } func (m *Model) onSkillsTabSwitch() tea.Cmd { if m.Skills.Loaded { return nil } m.Skills.Scanning = true return ScanSkillsCmd() } // --- 技能 tab 按键 --- func (m *Model) handleSkillsKey(s string) (*Model, tea.Cmd) { switch s { case "up", "k": switch m.Skills.FocusPanel { case 0: m.moveSkillsSkill(-1) case 1: m.moveSkillsFile(-1) case 2: m.scrollSkillContent(-1) } case "down", "j": switch m.Skills.FocusPanel { case 0: m.moveSkillsSkill(1) case 1: m.moveSkillsFile(1) case 2: m.scrollSkillContent(1) } case "pgup": if m.Skills.FocusPanel == 2 { m.scrollSkillContent(-10) } case "pgdown": if m.Skills.FocusPanel == 2 { m.scrollSkillContent(10) } case "tab", "right", "l": // 进内容面板需已加载内容 if m.Skills.FocusPanel < 1 { m.Skills.FocusPanel = 1 m.autoLoadCurrentFile() } else if m.Skills.FocusPanel == 1 && m.Skills.ContentPath != "" { m.Skills.FocusPanel = 2 } case "shift+tab", "left", "h": if m.Skills.FocusPanel > 0 { m.Skills.FocusPanel-- } case "enter": switch m.Skills.FocusPanel { case 0: m.Skills.FocusPanel = 1 m.Skills.FileCur = 0 m.autoLoadCurrentFile() case 1: fn := m.currentFlatNode() if fn != nil { if fn.IsDir { fn.Expanded = !fn.Expanded flat := m.skillsFlattened() if m.Skills.FileCur >= len(flat) { m.Skills.FileCur = len(flat) - 1 } } else { m.loadSkillContent(fn.Path) m.Skills.FocusPanel = 2 // 打开文件即聚焦内容,便于滚动 } } } case "r", "f5": m.Skills.Loaded = false m.Skills.Scanning = true return m, ScanSkillsCmd() } return m, nil } // scrollSkillContent 滚动内容面板,边界由 View 层按实际高度再夹紧 func (m *Model) scrollSkillContent(dir int) { m.Skills.ContentScroll += dir if m.Skills.ContentScroll < 0 { m.Skills.ContentScroll = 0 } } // autoLoadCurrentFile 若当前文件树光标停在文件上,自动加载其内容 func (m *Model) autoLoadCurrentFile() { if fn := m.currentFlatNode(); fn != nil && !fn.IsDir { m.loadSkillContent(fn.Path) } } func (m *Model) moveSkillsSkill(dir int) { if len(m.Skills.Skills) == 0 { return } m.Skills.SkillCur += dir if m.Skills.SkillCur < 0 { m.Skills.SkillCur = len(m.Skills.Skills) - 1 } if m.Skills.SkillCur >= len(m.Skills.Skills) { m.Skills.SkillCur = 0 } m.Skills.FileCur = 0 m.Skills.Content = "" m.Skills.ContentPath = "" } func (m *Model) moveSkillsFile(dir int) { flat := m.skillsFlattened() if len(flat) == 0 { return } m.Skills.FileCur += dir if m.Skills.FileCur < 0 { m.Skills.FileCur = len(flat) - 1 } if m.Skills.FileCur >= len(flat) { m.Skills.FileCur = 0 } // 光标移到文件即自动加载内容;目录则清空内容区 if fn := m.currentFlatNode(); fn != nil { if fn.IsDir { m.Skills.Content = "" m.Skills.ContentPath = "" m.Skills.ContentScroll = 0 } else { m.loadSkillContent(fn.Path) } } } // --- Workspace 按键 --- func (m *Model) handleWorkspaceKey(s string) (*Model, tea.Cmd) { switch s { case "left", "h": if m.wsFocus > 0 { m.wsFocus = 0 } case "right", "l": if m.wsFocus < 1 { m.wsFocus = 1 } case "tab", "shift+tab": m.wsFocus = (m.wsFocus + 1) % 2 case "up", "k": if m.wsFocus == 0 { m.moveGroupCursor(-1) } else { m.moveCursor(-1) } case "down", "j": if m.wsFocus == 0 { m.moveGroupCursor(1) } else { m.moveCursor(1) } case "enter": if m.inputBuf != "" { return m.launchByInput() } if m.wsFocus == 0 { if m.wsTabCur == len(Groups) { m.activeGroup = len(Groups) m.resetCursor() return m, m.onTabSwitch() } if m.wsTabCur == len(Groups)+1 { m.activeGroup = len(Groups) + 1 m.resetCursor() return m, m.onSkillsTabSwitch() } m.activeGroup = m.wsTabCur m.wsFocus = 1 } else { return m.launchSelected() } default: if len(s) == 1 && s[0] >= '0' && s[0] <= '9' && len(m.inputBuf) < 4 { m.inputBuf += s } } return m, nil } func (m *Model) resetCursor() { m.cursor = 0 m.inputBuf = "" } func (m *Model) moveGroupCursor(dir int) { total := len(Groups) + 2 // 含历史对话入口和技能入口 m.wsTabCur = (m.wsTabCur + dir + total) % total if m.wsTabCur < len(Groups) { m.activeGroup = m.wsTabCur } m.resetCursor() } // --- HISTORY 按键 --- func (m *Model) handleHistoryKey(s string) (*Model, tea.Cmd) { if m.forkMode { return m.handleForkInput(s) } if m.editTitleMode { return m.handleEditTitleInput(s) } switch s { case "up", "k": if m.history.FocusPanel == 0 { m.moveHistoryDir(-1) } else { m.moveHistorySess(-1) } case "down", "j": if m.history.FocusPanel == 0 { m.moveHistoryDir(1) } else { m.moveHistorySess(1) } case "tab", "right", "l": if m.history.FocusPanel < 1 { m.history.FocusPanel = 1 } case "shift+tab", "left", "h": if m.history.FocusPanel > 0 { m.history.FocusPanel = 0 } case "enter": if m.history.FocusPanel == 0 { if m.history.DirCursor == len(m.history.Projects)+2 { m.activeGroup = 0 m.resetCursor() m.wsTabCur = 0 m.wsFocus = 1 } m.history.FocusPanel = 1 } else { return m.resumeSelected() } case "n": return m.newSessionFromHistory() case "r", "f5": if cmd := m.onTabSwitch(); cmd != nil { return m, cmd } case "f": m.toggleFavorite() case "e", "E": if m.currentSession() != nil && !m.forkMode { s := m.currentSession() m.editTitleMode = true m.editTitleBuf = s.DisplayTitle() m.editTitleCursor = len([]rune(m.editTitleBuf)) m.history.FocusPanel = 1 } case "c": if m.currentSession() != nil && m.history.FocusPanel == 1 { m.forkMode = true m.forkBuf = "" } } return m, nil } func (m *Model) handleEditTitleInput(s string) (*Model, tea.Cmd) { switch s { case "enter": return m.confirmEditTitle() case "esc": m.editTitleMode = false m.editTitleBuf = "" m.editTitleCursor = 0 case "backspace": m.backspaceTitle() case "delete": m.deleteTitleChar() case "left", "h": if m.editTitleCursor > 0 { m.editTitleCursor-- } case "right", "l": r := []rune(m.editTitleBuf) if m.editTitleCursor < len(r) { m.editTitleCursor++ } case "home", "ctrl+a": m.editTitleCursor = 0 case "end", "ctrl+e": m.editTitleCursor = len([]rune(m.editTitleBuf)) case "space": m.insertTitleRune(' ') default: switch s { case "up", "down", "tab", "shift+tab": default: if len(s) == 1 && s[0] >= 32 { m.insertTitleRune(rune(s[0])) } else if len(s) > 1 { m.insertTitleString(s) } } } return m, nil } func (m *Model) insertTitleRune(r rune) { buf := []rune(m.editTitleBuf) pos := m.editTitleCursor if pos > len(buf) { pos = len(buf) } buf = append(buf[:pos], append([]rune{r}, buf[pos:]...)...) m.editTitleBuf = string(buf) m.editTitleCursor = pos + 1 } func (m *Model) insertTitleString(s string) { buf := []rune(m.editTitleBuf) pos := m.editTitleCursor if pos > len(buf) { pos = len(buf) } ins := []rune(s) buf = append(buf[:pos], append(ins, buf[pos:]...)...) m.editTitleBuf = string(buf) m.editTitleCursor = pos + len(ins) } func (m *Model) backspaceTitle() { buf := []rune(m.editTitleBuf) if m.editTitleCursor > 0 && len(buf) > 0 { pos := m.editTitleCursor - 1 if pos > len(buf) { pos = len(buf) - 1 } buf = append(buf[:pos], buf[pos+1:]...) m.editTitleBuf = string(buf) m.editTitleCursor-- } } func (m *Model) deleteTitleChar() { buf := []rune(m.editTitleBuf) pos := m.editTitleCursor if len(buf) > 0 && pos < len(buf) { buf = append(buf[:pos], buf[pos+1:]...) m.editTitleBuf = string(buf) } } func (m *Model) handleForkInput(s string) (*Model, tea.Cmd) { switch s { case "enter": return m.executeFork() case "esc": m.forkMode = false m.forkBuf = "" case "backspace": if len(m.forkBuf) > 0 { m.forkBuf = m.forkBuf[:len(m.forkBuf)-1] } default: if len(s) == 1 && s[0] >= 32 && s[0] < 127 { m.forkBuf += s } else if len(s) > 1 { m.forkBuf += s } } return m, nil } // --- 光标移动 --- func (m *Model) moveHistoryDir(dir int) { if len(m.history.Projects) == 0 { return } m.history.DirCursor += dir maxDir := len(m.history.Projects) + 3 if m.history.DirCursor < 0 { m.history.DirCursor = maxDir - 1 } if m.history.DirCursor >= maxDir { m.history.DirCursor = 0 } m.history.SessCursor = 0 } func (m *Model) moveHistorySess(dir int) { sessions := m.currentSessions() if len(sessions) == 0 { return } m.history.SessCursor += dir if m.history.SessCursor < 0 { m.history.SessCursor = len(sessions) - 1 } if m.history.SessCursor >= len(sessions) { m.history.SessCursor = 0 } } func (m *Model) currentSessions() []*Session { if m.history.DirCursor == 0 { if m.allSessionsDirty || m.allSessionsCache == nil { total := 0 for _, pd := range m.history.Projects { total += len(pd.Sessions) } all := make([]*Session, 0, total) for _, pd := range m.history.Projects { all = append(all, pd.Sessions...) } m.allSessionsCache = all m.allSessionsDirty = false } return m.allSessionsCache } if m.history.DirCursor == 1 { return collectFavorites(m.history.Projects, m.history.Favorites) } idx := m.history.DirCursor - 2 if idx >= len(m.history.Projects) { return nil } return m.history.Projects[idx].Sessions } func (m *Model) currentSession() *Session { sessions := m.currentSessions() if len(sessions) == 0 { return nil } if m.history.SessCursor >= len(sessions) { m.history.SessCursor = len(sessions) - 1 } return sessions[m.history.SessCursor] } func (m *Model) currentDirCwd() string { if m.history.DirCursor <= 1 { return "" } idx := m.history.DirCursor - 2 if idx >= len(m.history.Projects) { return "" } return m.history.Projects[idx].Dir } func (m *Model) moveCursor(dir int) { svcs := WorkspacesByGroup(Groups[m.activeGroup].Label) if len(svcs) == 0 { return } m.cursor += dir if m.cursor < 0 { m.cursor = len(svcs) - 1 } if m.cursor >= len(svcs) { m.cursor = 0 } } func (m *Model) toggleMultiSelect() { svcs := WorkspacesByGroup(Groups[m.activeGroup].Label) if m.cursor < len(svcs) { idx := svcs[m.cursor].Index if m.selected[idx] { delete(m.selected, idx) } else { m.selected[idx] = true } } }