Files
u-tabs/internal/launch.go
T
lxy 043a7d9227 修复: 同事机器无 pwsh 启动失败 0x80070002
- 探测优先 pwsh (PS7),找不到回退 powershell (PS5,Windows 自带)
- 脚本仅用兼容语法 (Write-Host/cd),PS5 可直接运行
2026-07-06 16:53:49 +08:00

274 lines
6.7 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()
}
// --- 启动辅助 ---
// powershellExe 返回可用的 PowerShell 可执行文件名:优先 pwsh (PS7),找不到则回退 powershell (PS5,Windows 自带)。
// 脚本只用 $Host.UI.RawUI、Write-Host、cd 等兼容语法,PS5 也能跑。
func powershellExe() string {
if _, err := exec.LookPath("pwsh"); err == nil {
return "pwsh"
}
return "powershell"
}
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, powershellExe(), "-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
}