551 lines
12 KiB
Go
551 lines
12 KiB
Go
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
|
|
}
|
|
}
|
|
}
|