Files
u-tabs/internal/launch.go
T
lxy 45e32193af 修复: 缓存原子写与 fork 关系持久化,优化摘要重试
- 原子写: 新增 atomicWriteFile,缓存/收藏/forks 改为临时文件+rename,避免崩溃损坏
- fork 关系: executeFork 记录待绑定项,扫描后按 cwd+时间窗口匹配新会话并持久化
- 摘要重试: 去掉 needDetail 反复刷新逻辑,仅对未生成摘要的会话触发,避免浪费 claude -p 调用
- 脚本转义: pwsh/bash 的 Prompt 回显段转义双引号与 $,防止注入和语法错误
- 输入限制: 工作空间数字输入限制 4 位,避免无限增长
- 品牌统一: 底部版本号文案 绝尘 → U-TABS
2026-07-01 01:29:01 +08:00

265 lines
6.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package internal
import (
"fmt"
"log"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
tea "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 显示(转义双引号,避免破坏 PowerShell 脚本)
pe := strings.ReplaceAll(a.Prompt, "\"", "`\"\"")
b.WriteString(fmt.Sprintf("\n\t\tWrite-Host \"Prompt: %s\" -ForegroundColor Yellow", pe))
}
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 == "" {
// bash: 转义双引号与 $ 反斜杠,避免命令注入/语法错误
pe := strings.ReplaceAll(a.Prompt, "\\", "\\\\")
pe = strings.ReplaceAll(pe, "\"", "\\\"")
pe = strings.ReplaceAll(pe, "$", "\\$")
b.WriteString(fmt.Sprintf(" && echo \"Prompt: %s\"", pe))
}
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)
// 记录待绑定 fork:新会话 ID 由 claude CLI 生成,此刻拿不到,
// 留到下次扫描时按 cwd + 时间窗口匹配
m.pendingForks = append(m.pendingForks, pendingFork{
SourceID: s.ID,
Cwd: s.Cwd,
At: time.Now(),
})
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
}