新增: 会话分叉功能,优化增量扫描缓存

- 会话分叉: 按 c 键从历史会话分叉,支持带方向提示
- 启动自动加载历史记录
- 增量扫描: 缓存内存化、目录 modTime 跳过、已删除条目裁剪
- 刷新按钮复用 onTabSwitch 单一入口
This commit is contained in:
2026-05-31 21:40:20 +08:00
parent 0bd9848df9
commit 36aeef4bb7
10 changed files with 1327 additions and 1228 deletions

250
internal/launch.go Normal file
View File

@@ -0,0 +1,250 @@
package internal
import (
"fmt"
"log"
"os/exec"
"path/filepath"
"strconv"
"strings"
"charm.land/bubbletea/v2"
)
// SessionAction 描述一个 Claude Code 会话启动动作
type SessionAction struct {
Cwd string // 工作目录
Title string // 窗口标题
SessionID string // resume/fork 目标(空=新建)
Fork bool // 是否分叉
Prompt string // 初始提示fork 方向或 workspace prompt
Name string // --name 参数workspace 启动用)
}
// Execute 执行启动动作
func (a SessionAction) Execute(m *Model) {
if isWindows {
cwd := toWinPath(a.Cwd)
launchInWtWithTitle(cwd, a.Title, a.buildScript())
} else {
m.pendingCmd = a.buildScript()
}
}
// buildScript 根据平台构建启动脚本
func (a SessionAction) buildScript() string {
if isWindows {
return a.pwshScript()
}
return a.bashScript()
}
func (a SessionAction) pwshScript() string {
var b strings.Builder
b.WriteString(fmt.Sprintf(`$Host.UI.RawUI.WindowTitle = "%s"`, a.Title))
b.WriteString(fmt.Sprintf("\n\t\tWrite-Host \"=== %s ===\" -ForegroundColor Cyan", a.Title))
if a.Prompt != "" && a.Name == "" {
// workspace prompt 显示
b.WriteString(fmt.Sprintf("\n\t\tWrite-Host \"Prompt: %s\" -ForegroundColor Yellow", a.Prompt))
}
b.WriteString(fmt.Sprintf("\n\t\tcd \"%s\"", toWinPath(a.Cwd)))
b.WriteString("\n\t\tclaude")
if a.SessionID != "" {
b.WriteString(fmt.Sprintf(" -r %s", a.SessionID))
if a.Fork {
b.WriteString(" --fork-session")
}
}
if a.Prompt != "" && a.SessionID != "" {
escaped := strings.ReplaceAll(a.Prompt, `"`, "`\"")
b.WriteString(fmt.Sprintf(` "%s"`, escaped))
}
if a.Name != "" {
b.WriteString(fmt.Sprintf(` --name "%s"`, a.Name))
}
b.WriteString(" --permission-mode bypassPermissions")
return b.String()
}
func (a SessionAction) bashScript() string {
var b strings.Builder
b.WriteString(fmt.Sprintf(`printf '\033]0;%s\007' && echo "=== %s ==="`, a.Title, a.Title))
if a.Prompt != "" && a.Name == "" {
b.WriteString(fmt.Sprintf(` && echo "Prompt: %s"`, a.Prompt))
}
b.WriteString(fmt.Sprintf(` && cd "%s"`, a.Cwd))
b.WriteString(" && claude")
if a.SessionID != "" {
b.WriteString(fmt.Sprintf(" -r %s", a.SessionID))
if a.Fork {
b.WriteString(" --fork-session")
}
}
if a.Prompt != "" && a.SessionID != "" {
escaped := strings.ReplaceAll(a.Prompt, "'", "'\\''")
b.WriteString(fmt.Sprintf(` '%s'`, escaped))
}
if a.Name != "" {
b.WriteString(fmt.Sprintf(` --name "%s"`, a.Name))
}
b.WriteString(" --permission-mode bypassPermissions")
return b.String()
}
// --- 启动辅助 ---
func launchInWtWithTitle(dir, tabTitle, script string) {
encoded := encodePSCommand(script)
color := fmt.Sprintf("#%02X%02X%02X", randRange(80, 255), randRange(80, 255), randRange(80, 255))
args := []string{"-w", "0", "-d", dir, "--tabColor", color}
if tabTitle != "" {
args = append(args, "--title", tabTitle)
}
args = append(args, "pwsh", "-NoExit", "-EncodedCommand", encoded)
cmd := exec.Command("wt.exe", args...)
if err := cmd.Start(); err != nil {
log.Printf("[u-tabs] launch fail: %v", err)
}
}
// buildResumeAction 构建恢复会话动作
func buildResumeAction(s *Session) SessionAction {
title := s.CustomTitle
if title == "" {
title = s.ID[:8]
}
return SessionAction{
Cwd: s.Cwd,
Title: "resume: " + title,
SessionID: s.ID,
}
}
// buildForkAction 构建分叉会话动作
func buildForkAction(s *Session, prompt string) SessionAction {
title := s.CustomTitle
if title == "" {
title = s.ID[:8]
}
return SessionAction{
Cwd: s.Cwd,
Title: "fork: " + title,
SessionID: s.ID,
Fork: true,
Prompt: prompt,
}
}
// buildNewAction 构建新建会话动作
func buildNewAction(cwd string) SessionAction {
title := filepath.Base(cwd) + " - new"
return SessionAction{
Cwd: cwd,
Title: title,
}
}
// buildWorkspaceAction 构建工作空间启动动作
func buildWorkspaceAction(ws Workspace) SessionAction {
return SessionAction{
Cwd: ws.Dir,
Title: ws.Title,
Prompt: ws.Prompt,
Name: ws.Title,
}
}
// --- 会话操作 ---
func (m *Model) resumeSelected() (*Model, tea.Cmd) {
s := m.currentSession()
if s == nil {
return m, nil
}
action := buildResumeAction(s)
action.Execute(m)
m.launched = "resume: " + s.CustomTitle
return m, nil
}
func (m *Model) newSessionFromHistory() (*Model, tea.Cmd) {
s := m.currentSession()
cwd := ""
if s != nil {
cwd = s.Cwd
} else {
cwd = m.currentDirCwd()
}
if cwd == "" {
return m, nil
}
action := buildNewAction(cwd)
action.Execute(m)
m.launched = "new: " + filepath.Base(cwd)
return m, nil
}
func (m *Model) executeFork() (*Model, tea.Cmd) {
s := m.currentSession()
if s == nil {
m.forkMode = false
return m, nil
}
m.forkMode = false
action := buildForkAction(s, m.forkBuf)
m.forkBuf = ""
action.Execute(m)
title := s.CustomTitle
if title == "" {
title = s.ID[:8]
}
m.launched = "fork: " + title
return m, nil
}
func (m *Model) launchSelected() (*Model, tea.Cmd) {
if len(m.selected) > 0 {
var launched []string
for idx := range m.selected {
ws := &AllWorkspaces[idx]
action := buildWorkspaceAction(*ws)
action.Execute(m)
launched = append(launched, ws.Title)
}
m.launched = strings.Join(launched, ", ")
m.selected = make(map[int]bool)
return m, nil
}
svcs := WorkspacesByGroup(Groups[m.activeGroup].Label)
if m.cursor < len(svcs) {
ws := svcs[m.cursor]
action := buildWorkspaceAction(ws)
action.Execute(m)
m.launched = ws.Title
}
return m, nil
}
func (m *Model) launchByInput() (*Model, tea.Cmd) {
num, err := strconv.Atoi(m.inputBuf)
if err != nil {
m.inputBuf = ""
return m, nil
}
ws := FindByNumber(num)
if ws != nil {
action := buildWorkspaceAction(*ws)
action.Execute(m)
m.launched = ws.Title
}
m.inputBuf = ""
return m, nil
}