Compare commits
4 Commits
e125ac6088
...
36aeef4bb7
| Author | SHA1 | Date | |
|---|---|---|---|
| 36aeef4bb7 | |||
| 0bd9848df9 | |||
| a6d5c024e7 | |||
| ef32867f10 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -20,3 +20,6 @@ Thumbs.db
|
|||||||
|
|
||||||
# Claude
|
# Claude
|
||||||
.claude/
|
.claude/
|
||||||
|
*.exe
|
||||||
|
*.exe.old
|
||||||
|
*~
|
||||||
|
|||||||
1150
internal/app.go
1150
internal/app.go
File diff suppressed because it is too large
Load Diff
@@ -30,6 +30,7 @@ type Session struct {
|
|||||||
Completed []string // AI 提取的已完成项
|
Completed []string // AI 提取的已完成项
|
||||||
Pending []string // AI 提取的待办项
|
Pending []string // AI 提取的待办项
|
||||||
FilePath string // JSONL 文件完整路径
|
FilePath string // JSONL 文件完整路径
|
||||||
|
ForkFrom string // 分叉来源 session ID(空表示非分叉)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProjectDir 按目录分组的会话集合
|
// ProjectDir 按目录分组的会话集合
|
||||||
@@ -89,6 +90,7 @@ type HistoryState struct {
|
|||||||
Loaded bool
|
Loaded bool
|
||||||
Scanning bool
|
Scanning bool
|
||||||
Favorites map[string]bool
|
Favorites map[string]bool
|
||||||
|
Cache map[string]*cacheEntry // 内存缓存,避免重复 I/O
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsHistoryTab 判断当前是否在 HISTORY Tab
|
// IsHistoryTab 判断当前是否在 HISTORY Tab
|
||||||
@@ -100,36 +102,53 @@ func (m *Model) IsHistoryTab() bool {
|
|||||||
|
|
||||||
type ScanCompleteMsg struct {
|
type ScanCompleteMsg struct {
|
||||||
Projects []*ProjectDir
|
Projects []*ProjectDir
|
||||||
UpdatedIDs map[string]bool // modTime 变化的会话,需要重新生成摘要
|
Cache map[string]*cacheEntry // 更新后的缓存
|
||||||
|
UpdatedIDs map[string]bool // modTime 变化的会话,需要重新生成摘要
|
||||||
}
|
}
|
||||||
|
|
||||||
func ScanSessionsCmd() tea.Cmd {
|
func ScanSessionsCmd() tea.Cmd {
|
||||||
return func() tea.Msg {
|
return func() tea.Msg {
|
||||||
projects, updated := scanAllProjects()
|
projects, cache, updated := scanAllProjects()
|
||||||
return ScanCompleteMsg{Projects: projects, UpdatedIDs: updated}
|
return ScanCompleteMsg{Projects: projects, Cache: cache, UpdatedIDs: updated}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func scanAllProjects() ([]*ProjectDir, map[string]bool) {
|
func scanAllProjects() ([]*ProjectDir, map[string]*cacheEntry, map[string]bool) {
|
||||||
home, err := os.UserHomeDir()
|
home, err := os.UserHomeDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil
|
return nil, nil, nil
|
||||||
}
|
}
|
||||||
projectsDir := filepath.Join(home, ".claude", "projects")
|
projectsDir := filepath.Join(home, ".claude", "projects")
|
||||||
entries, err := os.ReadDir(projectsDir)
|
entries, err := os.ReadDir(projectsDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil
|
return nil, nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
cache := loadCache(home)
|
cache, projTimes := loadCacheWithTimes(home)
|
||||||
dirMap := make(map[string]*ProjectDir)
|
dirMap := make(map[string]*ProjectDir)
|
||||||
updatedIDs := make(map[string]bool)
|
updatedIDs := make(map[string]bool)
|
||||||
|
seenIDs := make(map[string]bool)
|
||||||
|
|
||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
if !entry.IsDir() {
|
if !entry.IsDir() {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
projectPath := filepath.Join(projectsDir, entry.Name())
|
projectPath := filepath.Join(projectsDir, entry.Name())
|
||||||
|
info, err := entry.Info()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 项目目录 modTime 未变 → 从缓存直接重建该目录下的会话
|
||||||
|
if prev, ok := projTimes[projectPath]; ok && prev.Equal(info.ModTime()) {
|
||||||
|
for _, id := range rebuildFromCache(cache, dirMap, projectPath) {
|
||||||
|
seenIDs[id] = true
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// 目录有变更,记录新 modTime
|
||||||
|
projTimes[projectPath] = info.ModTime()
|
||||||
|
|
||||||
files, err := os.ReadDir(projectPath)
|
files, err := os.ReadDir(projectPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
@@ -139,13 +158,14 @@ func scanAllProjects() ([]*ProjectDir, map[string]bool) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
sessionID := strings.TrimSuffix(f.Name(), ".jsonl")
|
sessionID := strings.TrimSuffix(f.Name(), ".jsonl")
|
||||||
|
seenIDs[sessionID] = true
|
||||||
filePath := filepath.Join(projectPath, f.Name())
|
filePath := filepath.Join(projectPath, f.Name())
|
||||||
info, err := f.Info()
|
finfo, err := f.Info()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if cached, ok := cache[sessionID]; ok && cached.ModTime.Equal(info.ModTime()) {
|
if cached, ok := cache[sessionID]; ok && cached.ModTime.Equal(finfo.ModTime()) {
|
||||||
addSessionToDir(dirMap, cached.ToSession(sessionID))
|
addSessionToDir(dirMap, cached.ToSession(sessionID))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -160,11 +180,31 @@ func scanAllProjects() ([]*ProjectDir, map[string]bool) {
|
|||||||
session.Pending = nil
|
session.Pending = nil
|
||||||
updatedIDs[sessionID] = true
|
updatedIDs[sessionID] = true
|
||||||
addSessionToDir(dirMap, session)
|
addSessionToDir(dirMap, session)
|
||||||
cache[sessionID] = cacheEntryFrom(session, info.ModTime())
|
cache[sessionID] = cacheEntryFrom(session, finfo.ModTime())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
saveCache(home, cache)
|
// 裁剪缓存:删除磁盘已不存在的条目
|
||||||
|
for id := range cache {
|
||||||
|
if !seenIDs[id] {
|
||||||
|
delete(cache, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清理已不存在项目的 projTimes
|
||||||
|
activeProjects := make(map[string]bool, len(entries))
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.IsDir() {
|
||||||
|
activeProjects[filepath.Join(projectsDir, e.Name())] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for p := range projTimes {
|
||||||
|
if !activeProjects[p] {
|
||||||
|
delete(projTimes, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
saveCacheWithTimes(home, cache, projTimes)
|
||||||
|
|
||||||
result := make([]*ProjectDir, 0, len(dirMap))
|
result := make([]*ProjectDir, 0, len(dirMap))
|
||||||
for _, pd := range dirMap {
|
for _, pd := range dirMap {
|
||||||
@@ -176,7 +216,20 @@ func scanAllProjects() ([]*ProjectDir, map[string]bool) {
|
|||||||
sort.Slice(result, func(i, j int) bool {
|
sort.Slice(result, func(i, j int) bool {
|
||||||
return result[i].Dir < result[j].Dir
|
return result[i].Dir < result[j].Dir
|
||||||
})
|
})
|
||||||
return result, updatedIDs
|
return result, cache, updatedIDs
|
||||||
|
}
|
||||||
|
|
||||||
|
// rebuildFromCache 从缓存重建指定项目目录下的所有会话,返回匹配的 session ID
|
||||||
|
func rebuildFromCache(cache map[string]*cacheEntry, dirMap map[string]*ProjectDir, projectPath string) []string {
|
||||||
|
prefix := filepath.Base(projectPath)
|
||||||
|
var seen []string
|
||||||
|
for id, e := range cache {
|
||||||
|
if filepath.Base(filepath.Dir(e.FilePath)) == prefix {
|
||||||
|
addSessionToDir(dirMap, e.ToSession(id))
|
||||||
|
seen = append(seen, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return seen
|
||||||
}
|
}
|
||||||
|
|
||||||
func addSessionToDir(dirMap map[string]*ProjectDir, s *Session) {
|
func addSessionToDir(dirMap map[string]*ProjectDir, s *Session) {
|
||||||
@@ -360,8 +413,9 @@ func findStringEnd(data []byte) int {
|
|||||||
const cacheVersion = 2
|
const cacheVersion = 2
|
||||||
|
|
||||||
type cacheFile struct {
|
type cacheFile struct {
|
||||||
Version int `json:"version"`
|
Version int `json:"version"`
|
||||||
Sessions map[string]*cacheEntry `json:"sessions"`
|
Sessions map[string]*cacheEntry `json:"sessions"`
|
||||||
|
ProjectTimes map[string]time.Time `json:"projectTimes"` // 项目目录 modTime
|
||||||
}
|
}
|
||||||
|
|
||||||
type cacheEntry struct {
|
type cacheEntry struct {
|
||||||
@@ -377,6 +431,7 @@ type cacheEntry struct {
|
|||||||
Completed []string `json:"completed"`
|
Completed []string `json:"completed"`
|
||||||
Pending []string `json:"pending"`
|
Pending []string `json:"pending"`
|
||||||
FilePath string `json:"filePath"`
|
FilePath string `json:"filePath"`
|
||||||
|
ForkFrom string `json:"forkFrom"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *cacheEntry) ToSession(id string) *Session {
|
func (e *cacheEntry) ToSession(id string) *Session {
|
||||||
@@ -386,7 +441,7 @@ func (e *cacheEntry) ToSession(id string) *Session {
|
|||||||
MsgCount: e.MsgCount, FirstMsg: e.FirstMsg,
|
MsgCount: e.MsgCount, FirstMsg: e.FirstMsg,
|
||||||
AwaySummary: e.AwaySummary, AISummary: e.AISummary,
|
AwaySummary: e.AwaySummary, AISummary: e.AISummary,
|
||||||
Completed: e.Completed, Pending: e.Pending,
|
Completed: e.Completed, Pending: e.Pending,
|
||||||
FilePath: e.FilePath,
|
FilePath: e.FilePath, ForkFrom: e.ForkFrom,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -397,7 +452,7 @@ func cacheEntryFrom(s *Session, modTime time.Time) *cacheEntry {
|
|||||||
MsgCount: s.MsgCount, FirstMsg: s.FirstMsg,
|
MsgCount: s.MsgCount, FirstMsg: s.FirstMsg,
|
||||||
AwaySummary: s.AwaySummary, AISummary: s.AISummary,
|
AwaySummary: s.AwaySummary, AISummary: s.AISummary,
|
||||||
Completed: s.Completed, Pending: s.Pending,
|
Completed: s.Completed, Pending: s.Pending,
|
||||||
FilePath: s.FilePath,
|
FilePath: s.FilePath, ForkFrom: s.ForkFrom,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -406,19 +461,32 @@ func cachePath(home string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func loadCache(home string) map[string]*cacheEntry {
|
func loadCache(home string) map[string]*cacheEntry {
|
||||||
|
c, _ := loadCacheWithTimes(home)
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadCacheWithTimes(home string) (map[string]*cacheEntry, map[string]time.Time) {
|
||||||
data, err := os.ReadFile(cachePath(home))
|
data, err := os.ReadFile(cachePath(home))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return make(map[string]*cacheEntry)
|
return make(map[string]*cacheEntry), make(map[string]time.Time)
|
||||||
}
|
}
|
||||||
var cf cacheFile
|
var cf cacheFile
|
||||||
if json.Unmarshal(data, &cf) != nil || cf.Version != cacheVersion {
|
if json.Unmarshal(data, &cf) != nil || cf.Version != cacheVersion {
|
||||||
return make(map[string]*cacheEntry)
|
return make(map[string]*cacheEntry), make(map[string]time.Time)
|
||||||
}
|
}
|
||||||
return cf.Sessions
|
projTimes := cf.ProjectTimes
|
||||||
|
if projTimes == nil {
|
||||||
|
projTimes = make(map[string]time.Time)
|
||||||
|
}
|
||||||
|
return cf.Sessions, projTimes
|
||||||
}
|
}
|
||||||
|
|
||||||
func saveCache(home string, m map[string]*cacheEntry) {
|
func saveCache(home string, m map[string]*cacheEntry) {
|
||||||
cf := cacheFile{Version: cacheVersion, Sessions: m}
|
saveCacheWithTimes(home, m, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveCacheWithTimes(home string, m map[string]*cacheEntry, projTimes map[string]time.Time) {
|
||||||
|
cf := cacheFile{Version: cacheVersion, Sessions: m, ProjectTimes: projTimes}
|
||||||
data, err := json.Marshal(cf)
|
data, err := json.Marshal(cf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
@@ -536,12 +604,10 @@ func extractMessagesForSummary(path string) string {
|
|||||||
return strings.Join(msgs, "\n")
|
return strings.Join(msgs, "\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
func saveSummaryToCache(sessionID, summary string, completed, pending []string) {
|
func updateSummaryInCache(cache map[string]*cacheEntry, sessionID, summary string, completed, pending []string) {
|
||||||
home, err := os.UserHomeDir()
|
if cache == nil {
|
||||||
if err != nil {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
cache := loadCache(home)
|
|
||||||
entry, ok := cache[sessionID]
|
entry, ok := cache[sessionID]
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
@@ -549,6 +615,11 @@ func saveSummaryToCache(sessionID, summary string, completed, pending []string)
|
|||||||
entry.AISummary = summary
|
entry.AISummary = summary
|
||||||
entry.Completed = completed
|
entry.Completed = completed
|
||||||
entry.Pending = pending
|
entry.Pending = pending
|
||||||
|
// 异步持久化
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
saveCache(home, cache)
|
saveCache(home, cache)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -624,3 +695,89 @@ func collectFavorites(projects []*ProjectDir, favs map[string]bool) []*Session {
|
|||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Fork 记录 ---
|
||||||
|
|
||||||
|
func forksPath(home string) string {
|
||||||
|
return filepath.Join(home, ".u-tabs", "forks.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadForks() map[string]string {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return make(map[string]string)
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(forksPath(home))
|
||||||
|
if err != nil {
|
||||||
|
return make(map[string]string)
|
||||||
|
}
|
||||||
|
var m map[string]string
|
||||||
|
if json.Unmarshal(data, &m) != nil {
|
||||||
|
return make(map[string]string)
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func recordFork(targetID, sourceID string) {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
forks := loadForks()
|
||||||
|
forks[targetID] = sourceID
|
||||||
|
data, err := json.Marshal(forks)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dir := filepath.Join(home, ".u-tabs")
|
||||||
|
os.MkdirAll(dir, 0o755)
|
||||||
|
os.WriteFile(forksPath(home), data, 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- AI 摘要与收藏操作 ---
|
||||||
|
|
||||||
|
func (m *Model) applySummary(sessionID, summary string, completed, pending []string) {
|
||||||
|
if summary == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, pd := range m.history.Projects {
|
||||||
|
for _, s := range pd.Sessions {
|
||||||
|
if s.ID == sessionID {
|
||||||
|
s.AISummary = summary
|
||||||
|
s.Completed = completed
|
||||||
|
s.Pending = pending
|
||||||
|
updateSummaryInCache(m.history.Cache, sessionID, summary, completed, pending)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) nextSummaryCmd() tea.Cmd {
|
||||||
|
for _, pd := range m.history.Projects {
|
||||||
|
for _, s := range pd.Sessions {
|
||||||
|
needSummary := s.AISummary == "" && s.FirstMsg != ""
|
||||||
|
needDetail := s.AISummary != "" && len(s.Completed) == 0 && len(s.Pending) == 0
|
||||||
|
if (needSummary || needDetail) && s.FilePath != "" {
|
||||||
|
return generateSummaryCmd(s.FilePath, s.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) toggleFavorite() {
|
||||||
|
if m.history.Favorites == nil {
|
||||||
|
m.history.Favorites = loadFavorites()
|
||||||
|
}
|
||||||
|
s := m.currentSession()
|
||||||
|
if s == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if m.history.Favorites[s.ID] {
|
||||||
|
delete(m.history.Favorites, s.ID)
|
||||||
|
} else {
|
||||||
|
m.history.Favorites[s.ID] = true
|
||||||
|
}
|
||||||
|
saveFavorites(m.history.Favorites)
|
||||||
|
}
|
||||||
|
|||||||
287
internal/key.go
Normal file
287
internal/key.go
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
package internal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"charm.land/bubbletea/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- 按键分发 ---
|
||||||
|
|
||||||
|
func (m *Model) handleKey(s string) (*Model, tea.Cmd) {
|
||||||
|
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=历史对话
|
||||||
|
if s == "1" {
|
||||||
|
if m.IsHistoryTab() {
|
||||||
|
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 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()
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 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()
|
||||||
|
}
|
||||||
|
m.activeGroup = m.wsTabCur
|
||||||
|
m.wsFocus = 1
|
||||||
|
} else {
|
||||||
|
return m.launchSelected()
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
if len(s) == 1 && s[0] >= '0' && s[0] <= '9' {
|
||||||
|
m.inputBuf += s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) resetCursor() {
|
||||||
|
m.cursor = 0
|
||||||
|
m.inputBuf = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) moveGroupCursor(dir int) {
|
||||||
|
total := len(Groups) + 1 // 含历史入口
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 "c":
|
||||||
|
if m.currentSession() != nil && m.history.FocusPanel == 1 {
|
||||||
|
m.forkMode = true
|
||||||
|
m.forkBuf = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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 {
|
||||||
|
var all []*Session
|
||||||
|
for _, pd := range m.history.Projects {
|
||||||
|
all = append(all, pd.Sessions...)
|
||||||
|
}
|
||||||
|
return all
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
250
internal/launch.go
Normal file
250
internal/launch.go
Normal 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
|
||||||
|
}
|
||||||
@@ -7,7 +7,6 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"runtime"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -15,7 +14,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Version 当前版本,发布时更新
|
// Version 当前版本,发布时更新
|
||||||
const Version = "0.1.0"
|
const Version = "0.2.0"
|
||||||
|
|
||||||
// --- 远程 JSON 结构 ---
|
// --- 远程 JSON 结构 ---
|
||||||
|
|
||||||
@@ -103,7 +102,7 @@ func CheckUpdateCmd() tea.Cmd {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 查找当前平台的下载信息
|
// 查找当前平台的下载信息
|
||||||
platformKey := runtime.GOOS + "-" + runtime.GOARCH
|
platformKey := platformKey
|
||||||
pi, ok := info.Platforms[platformKey]
|
pi, ok := info.Platforms[platformKey]
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil
|
return nil
|
||||||
@@ -192,7 +191,7 @@ func SelfUpdateCmd(downloadURL, expectedSHA256, newVersion string) tea.Cmd {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Linux/macOS: 设置可执行权限
|
// Linux/macOS: 设置可执行权限
|
||||||
if runtime.GOOS != "windows" {
|
if !isWindows {
|
||||||
os.Chmod(exePath, 0755)
|
os.Chmod(exePath, 0755)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
134
internal/util.go
Normal file
134
internal/util.go
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
package internal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"math/big"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"unicode/utf16"
|
||||||
|
"unicode/utf8"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 平台信息(启动时确定)
|
||||||
|
var (
|
||||||
|
isWindows = runtime.GOOS == "windows"
|
||||||
|
platformKey = runtime.GOOS + "-" + runtime.GOARCH
|
||||||
|
)
|
||||||
|
|
||||||
|
// toWinPath 将路径中的 / 替换为 \
|
||||||
|
func toWinPath(s string) string {
|
||||||
|
return strings.ReplaceAll(s, "/", "\\")
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 字符串宽度 ---
|
||||||
|
|
||||||
|
func stringWidth(s string) int {
|
||||||
|
w := 0
|
||||||
|
for _, r := range s {
|
||||||
|
w += runeWidth(r)
|
||||||
|
}
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
func runeWidth(r rune) int {
|
||||||
|
if r >= 0x1100 &&
|
||||||
|
(r <= 0x115F || r == 0x2329 || r == 0x232A ||
|
||||||
|
(r >= 0x2E80 && r <= 0xA4CF && r != 0x303F) ||
|
||||||
|
(r >= 0xAC00 && r <= 0xD7A3) ||
|
||||||
|
(r >= 0xF900 && r <= 0xFAFF) ||
|
||||||
|
(r >= 0xFE10 && r <= 0xFE19) ||
|
||||||
|
(r >= 0xFE30 && r <= 0xFE6F) ||
|
||||||
|
(r >= 0xFF01 && r <= 0xFF60) ||
|
||||||
|
(r >= 0xFFE0 && r <= 0xFFE6) ||
|
||||||
|
(r >= 0x20000 && r <= 0x2FFFD) ||
|
||||||
|
(r >= 0x30000 && r <= 0x3FFFD)) {
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func padRightByWidth(s string, targetW int) string {
|
||||||
|
w := stringWidth(s)
|
||||||
|
if w >= targetW {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return s + strings.Repeat(" ", targetW-w)
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncateByWidth(s string, maxW int) string {
|
||||||
|
w := 0
|
||||||
|
for i := 0; i < len(s); {
|
||||||
|
r, size := utf8.DecodeRuneInString(s[i:])
|
||||||
|
rw := runeWidth(r)
|
||||||
|
if w+rw > maxW {
|
||||||
|
return s[:i]
|
||||||
|
}
|
||||||
|
w += rw
|
||||||
|
i += size
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func wrapByWidth(s string, maxW int) []string {
|
||||||
|
if maxW <= 0 || s == "" {
|
||||||
|
return []string{s}
|
||||||
|
}
|
||||||
|
var lines []string
|
||||||
|
for s != "" {
|
||||||
|
cut := truncateByWidth(s, maxW)
|
||||||
|
lines = append(lines, cut)
|
||||||
|
s = s[len(cut):]
|
||||||
|
}
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
|
func fitWidth(s string, w int) string {
|
||||||
|
return padRightByWidth(truncateByWidth(s, w), w)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 视口 ---
|
||||||
|
|
||||||
|
func viewport(cursor, total, height int) (int, int) {
|
||||||
|
if total <= height {
|
||||||
|
return 0, total
|
||||||
|
}
|
||||||
|
half := height / 2
|
||||||
|
start := cursor - half
|
||||||
|
if start < 0 {
|
||||||
|
start = 0
|
||||||
|
}
|
||||||
|
end := start + height
|
||||||
|
if end > total {
|
||||||
|
end = total
|
||||||
|
start = end - height
|
||||||
|
if start < 0 {
|
||||||
|
start = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return start, end
|
||||||
|
}
|
||||||
|
|
||||||
|
func padLinesTo(lines []string, w, target int) string {
|
||||||
|
for len(lines) < target {
|
||||||
|
lines = append(lines, strings.Repeat(" ", w))
|
||||||
|
}
|
||||||
|
return strings.Join(lines, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Windows Terminal ---
|
||||||
|
|
||||||
|
func encodePSCommand(script string) string {
|
||||||
|
u16 := utf16.Encode([]rune(script))
|
||||||
|
b := make([]byte, len(u16)*2)
|
||||||
|
for i, r := range u16 {
|
||||||
|
b[i*2] = byte(r)
|
||||||
|
b[i*2+1] = byte(r >> 8)
|
||||||
|
}
|
||||||
|
return base64.StdEncoding.EncodeToString(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func randRange(min, max int) int {
|
||||||
|
n, _ := rand.Int(rand.Reader, big.NewInt(int64(max-min)))
|
||||||
|
return min + int(n.Int64())
|
||||||
|
}
|
||||||
155
internal/view.go
Normal file
155
internal/view.go
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
package internal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"charm.land/bubbletea/v2"
|
||||||
|
"charm.land/lipgloss/v2"
|
||||||
|
"u-tabs/internal/style"
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- View 入口 ---
|
||||||
|
|
||||||
|
func (m *Model) View() tea.View {
|
||||||
|
v := tea.NewView(m.render())
|
||||||
|
v.AltScreen = true
|
||||||
|
v.MouseMode = tea.MouseModeCellMotion
|
||||||
|
v.WindowTitle = "U-TABS"
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) render() string {
|
||||||
|
if m.width == 0 {
|
||||||
|
return "loading..."
|
||||||
|
}
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
|
||||||
|
b.WriteString(m.renderTabBar())
|
||||||
|
b.WriteString("\n")
|
||||||
|
b.WriteString(lipgloss.NewStyle().Foreground(style.BgPanel).Render(strings.Repeat("─", m.width)))
|
||||||
|
b.WriteString("\n")
|
||||||
|
|
||||||
|
if m.IsHistoryTab() {
|
||||||
|
b.WriteString(m.renderHistory())
|
||||||
|
} else {
|
||||||
|
b.WriteString(m.renderWorkspace())
|
||||||
|
}
|
||||||
|
|
||||||
|
// footer
|
||||||
|
b.WriteString("\n")
|
||||||
|
if m.inputBuf != "" {
|
||||||
|
b.WriteString(style.InputStyle.Render(fmt.Sprintf(" ▶ num:%s [Enter]go [Esc]cancel", m.inputBuf)))
|
||||||
|
}
|
||||||
|
if m.forkMode {
|
||||||
|
b.WriteString(style.InputStyle.Render(fmt.Sprintf(" ⎇ fork:%s [Enter]confirm [Esc]cancel", m.forkBuf)))
|
||||||
|
}
|
||||||
|
if m.launched != "" {
|
||||||
|
b.WriteString(lipgloss.NewStyle().Foreground(style.Accent).Render(fmt.Sprintf(" ✓ %s", m.launched)))
|
||||||
|
m.launched = ""
|
||||||
|
}
|
||||||
|
if m.update.Done {
|
||||||
|
b.WriteString(lipgloss.NewStyle().Foreground(style.Success).Bold(true).
|
||||||
|
Render(fmt.Sprintf(" updated to v%s, restart to apply", m.update.NewVersion)))
|
||||||
|
} else if m.update.Error != nil {
|
||||||
|
b.WriteString(lipgloss.NewStyle().Foreground(style.Red).
|
||||||
|
Render(fmt.Sprintf(" update failed: %v", m.update.Error)))
|
||||||
|
} else if m.update.Updating {
|
||||||
|
b.WriteString(lipgloss.NewStyle().Foreground(style.Warning).
|
||||||
|
Render(" updating..."))
|
||||||
|
} else if m.update.Available {
|
||||||
|
b.WriteString(lipgloss.NewStyle().Foreground(style.Warning).Bold(true).
|
||||||
|
Render(fmt.Sprintf(" v%s available [u]update", m.update.NewVersion)))
|
||||||
|
}
|
||||||
|
b.WriteString("\n")
|
||||||
|
|
||||||
|
helpParts := m.renderHelp()
|
||||||
|
helpStr := " " + strings.Join(helpParts, " ")
|
||||||
|
verStr := lipgloss.NewStyle().Foreground(style.Dim).Render("绝尘 v" + Version)
|
||||||
|
padW := m.width - lipgloss.Width(helpStr) - lipgloss.Width(verStr)
|
||||||
|
if padW > 0 {
|
||||||
|
b.WriteString(helpStr + strings.Repeat(" ", padW) + verStr)
|
||||||
|
} else {
|
||||||
|
b.WriteString(helpStr + " " + verStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
if hint := ConfigHint(); hint != "" {
|
||||||
|
b.WriteString("\n" + hint)
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderTabBar 顶部两个主 tab
|
||||||
|
func (m *Model) renderTabBar() string {
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(style.TitleStyle.Render(" U-TABS "))
|
||||||
|
sep := style.TabSep.Render(" │ ")
|
||||||
|
|
||||||
|
wsLabel := " 1 工作空间 "
|
||||||
|
if !m.IsHistoryTab() {
|
||||||
|
b.WriteString(lipgloss.NewStyle().
|
||||||
|
Bold(true).Background(style.BgPanel).Foreground(style.Accent).
|
||||||
|
Render(wsLabel))
|
||||||
|
} else {
|
||||||
|
b.WriteString(style.TabInactiveStyle.Render(wsLabel))
|
||||||
|
}
|
||||||
|
|
||||||
|
b.WriteString(sep)
|
||||||
|
histLabel := " 2 历史对话 "
|
||||||
|
if m.IsHistoryTab() {
|
||||||
|
b.WriteString(lipgloss.NewStyle().
|
||||||
|
Bold(true).Background(style.BgPanel).Foreground(style.Cyan).
|
||||||
|
Render(histLabel))
|
||||||
|
} else {
|
||||||
|
b.WriteString(style.TabInactiveStyle.Render(histLabel))
|
||||||
|
}
|
||||||
|
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) renderHelp() []string {
|
||||||
|
if m.IsHistoryTab() {
|
||||||
|
return []string{
|
||||||
|
m.fmtHelp("↑↓", "sel"),
|
||||||
|
m.fmtHelp("←→", "panel"),
|
||||||
|
m.fmtHelp("Enter", "resume"),
|
||||||
|
m.fmtHelp("f", "star"),
|
||||||
|
m.fmtHelp("n", "new"),
|
||||||
|
m.fmtHelp("c", "fork"),
|
||||||
|
m.fmtHelp("1", "workspace"),
|
||||||
|
m.fmtHelp("r/F5", "refresh"),
|
||||||
|
m.fmtHelp("q", "quit"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return []string{
|
||||||
|
m.fmtHelp("←→", "col"),
|
||||||
|
m.fmtHelp("↑↓", "sel"),
|
||||||
|
m.fmtHelp("Enter", "run"),
|
||||||
|
m.fmtHelp("2", "history"),
|
||||||
|
m.fmtHelp("q", "quit"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) fmtHelp(key, desc string) string {
|
||||||
|
return lipgloss.NewStyle().Foreground(style.Accent).Render(key) +
|
||||||
|
lipgloss.NewStyle().Foreground(style.Dim).Render(":" + desc)
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderNavEntry(lines *[]string, w int, label string, activeFg string, isCur, focused bool) {
|
||||||
|
sep := lipgloss.NewStyle().Foreground(style.BgPanel).Render(fitWidth(" ─────────", w))
|
||||||
|
*lines = append(*lines, sep)
|
||||||
|
if isCur {
|
||||||
|
curLabel := " → " + label
|
||||||
|
line := padRightByWidth(truncateByWidth(curLabel, w), w)
|
||||||
|
if focused {
|
||||||
|
*lines = append(*lines, style.SelStyle.Render(line))
|
||||||
|
} else {
|
||||||
|
*lines = append(*lines, lipgloss.NewStyle().Foreground(lipgloss.Color(activeFg)).Bold(true).Render(line))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
inactiveLabel := " " + label
|
||||||
|
line := padRightByWidth(truncateByWidth(inactiveLabel, w), w)
|
||||||
|
*lines = append(*lines, lipgloss.NewStyle().Foreground(style.Dim).Render(line))
|
||||||
|
}
|
||||||
|
}
|
||||||
255
internal/view_hist.go
Normal file
255
internal/view_hist.go
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
package internal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"charm.land/lipgloss/v2"
|
||||||
|
"u-tabs/internal/style"
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- HISTORY 三栏渲染 ---
|
||||||
|
|
||||||
|
func (m *Model) renderHistory() string {
|
||||||
|
if !m.history.Loaded && !m.history.Scanning {
|
||||||
|
return style.ScanningStyle.Render(" press Tab to load sessions...")
|
||||||
|
}
|
||||||
|
if len(m.history.Projects) == 0 && m.history.Scanning {
|
||||||
|
return style.ScanningStyle.Render(" scanning sessions...")
|
||||||
|
}
|
||||||
|
if len(m.history.Projects) == 0 {
|
||||||
|
return style.SubtitleStyle.Render(" no sessions found")
|
||||||
|
}
|
||||||
|
|
||||||
|
avail := m.width - 8
|
||||||
|
dirW := max(18, avail*20/100)
|
||||||
|
sessW := max(28, avail*40/100)
|
||||||
|
detailW := avail - dirW - sessW
|
||||||
|
listH := max(3, m.height-7)
|
||||||
|
|
||||||
|
dirCw := dirW - 2
|
||||||
|
sessCw := sessW - 2
|
||||||
|
detailCw := detailW - 2
|
||||||
|
|
||||||
|
dirLines := m.dirColumnLines(dirCw, listH-1)
|
||||||
|
sessLines := m.sessColumnLines(sessCw, listH-1)
|
||||||
|
detailLines := m.detailColumnLines(detailCw, listH-1)
|
||||||
|
|
||||||
|
maxRows := min(listH-1, max(max(len(dirLines), len(sessLines)), len(detailLines)))
|
||||||
|
|
||||||
|
panelSty := lipgloss.NewStyle().
|
||||||
|
Border(lipgloss.RoundedBorder()).
|
||||||
|
BorderForeground(style.BgPanel)
|
||||||
|
|
||||||
|
dirBox := panelSty.Width(dirW).Render(
|
||||||
|
style.DetailTitle.Render("dirs") + "\n" + padLinesTo(dirLines, dirCw, maxRows))
|
||||||
|
sessBox := panelSty.Width(sessW).Render(
|
||||||
|
style.DetailTitle.Render("sessions") + "\n" + padLinesTo(sessLines, sessCw, maxRows))
|
||||||
|
detailBox := panelSty.Width(detailW).Render(
|
||||||
|
style.DetailTitle.Render("detail") + "\n" + padLinesTo(detailLines, detailCw, maxRows))
|
||||||
|
|
||||||
|
return lipgloss.JoinHorizontal(lipgloss.Top, dirBox, " ", sessBox, " ", detailBox)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) dirColumnLines(w, listH int) []string {
|
||||||
|
var lines []string
|
||||||
|
total := len(m.history.Projects) + 3
|
||||||
|
start, end := viewport(m.history.DirCursor, total, listH)
|
||||||
|
|
||||||
|
prefixW := 3
|
||||||
|
|
||||||
|
buildDirLine := func(cur, name, cnt string, selected bool) string {
|
||||||
|
cntW := stringWidth(cnt)
|
||||||
|
nameW := max(2, w-prefixW-cntW)
|
||||||
|
name = truncateByWidth(name, nameW)
|
||||||
|
line := " " + cur + " " + padRightByWidth(name, nameW) + cnt
|
||||||
|
sty := style.NormStyle
|
||||||
|
if selected {
|
||||||
|
sty = style.SelStyle
|
||||||
|
}
|
||||||
|
return sty.Render(padRightByWidth(truncateByWidth(line, w), w))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 0: 全部
|
||||||
|
if start == 0 {
|
||||||
|
totalSess := 0
|
||||||
|
for _, pd := range m.history.Projects {
|
||||||
|
totalSess += len(pd.Sessions)
|
||||||
|
}
|
||||||
|
cur := " "
|
||||||
|
if m.history.DirCursor == 0 {
|
||||||
|
cur = "▸"
|
||||||
|
}
|
||||||
|
lines = append(lines, buildDirLine(cur, "全部", fmt.Sprintf("(%d)", totalSess),
|
||||||
|
m.history.DirCursor == 0 && m.history.FocusPanel == 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1: 收藏
|
||||||
|
if start <= 1 && end > 1 {
|
||||||
|
favCnt := countFavorites(m.history.Projects, m.history.Favorites)
|
||||||
|
cur := " "
|
||||||
|
if m.history.DirCursor == 1 {
|
||||||
|
cur = "▸"
|
||||||
|
}
|
||||||
|
lines = append(lines, buildDirLine(cur, "★ 收藏", fmt.Sprintf("(%d)", favCnt),
|
||||||
|
m.history.DirCursor == 1 && m.history.FocusPanel == 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2..N+1: 项目目录
|
||||||
|
for i := 0; i < len(m.history.Projects); i++ {
|
||||||
|
idx := i + 2
|
||||||
|
if idx < start || idx >= end {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pd := m.history.Projects[i]
|
||||||
|
cur := " "
|
||||||
|
if m.history.DirCursor == idx {
|
||||||
|
cur = "▸"
|
||||||
|
}
|
||||||
|
lines = append(lines, buildDirLine(cur, pd.DirShort, fmt.Sprintf("(%d)", len(pd.Sessions)),
|
||||||
|
m.history.DirCursor == idx && m.history.FocusPanel == 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
// N+2: 回到工作空间入口
|
||||||
|
backIdx := len(m.history.Projects) + 2
|
||||||
|
if end > backIdx {
|
||||||
|
renderNavEntry(&lines, w, "工作空间", "#7aa2f7", m.history.DirCursor == backIdx, m.history.FocusPanel == 0)
|
||||||
|
}
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) sessColumnLines(w, listH int) []string {
|
||||||
|
sessions := m.currentSessions()
|
||||||
|
if len(sessions) == 0 {
|
||||||
|
return []string{style.SubtitleStyle.Render(fitWidth(" no sessions", w))}
|
||||||
|
}
|
||||||
|
|
||||||
|
prefixW := 16
|
||||||
|
textW := max(8, w-prefixW)
|
||||||
|
|
||||||
|
type sessBlock struct {
|
||||||
|
lines []string
|
||||||
|
}
|
||||||
|
var blocks []sessBlock
|
||||||
|
totalLines := 0
|
||||||
|
curLineOffset := 0
|
||||||
|
|
||||||
|
for i, s := range sessions {
|
||||||
|
cur := " "
|
||||||
|
if i == m.history.SessCursor {
|
||||||
|
cur = "▸ "
|
||||||
|
}
|
||||||
|
timeStr := s.StartTime.Local().Format("01-02 15:04")
|
||||||
|
favMark := " "
|
||||||
|
if m.history.Favorites[s.ID] {
|
||||||
|
favMark = "★"
|
||||||
|
}
|
||||||
|
title := s.DisplayTitle()
|
||||||
|
|
||||||
|
wrapped := wrapByWidth(title, textW)
|
||||||
|
if len(wrapped) == 0 {
|
||||||
|
wrapped = []string{""}
|
||||||
|
}
|
||||||
|
|
||||||
|
sty := style.NormStyle
|
||||||
|
if i == m.history.SessCursor && m.history.FocusPanel == 1 {
|
||||||
|
sty = style.SelStyle
|
||||||
|
}
|
||||||
|
|
||||||
|
var blockLines []string
|
||||||
|
for li, part := range wrapped {
|
||||||
|
var line string
|
||||||
|
if li == 0 {
|
||||||
|
line = fmt.Sprintf(" %s%s%s %s", cur, timeStr, favMark, part)
|
||||||
|
} else {
|
||||||
|
line = strings.Repeat(" ", prefixW+1) + part
|
||||||
|
}
|
||||||
|
blockLines = append(blockLines, sty.Render(padRightByWidth(truncateByWidth(line, w), w)))
|
||||||
|
}
|
||||||
|
|
||||||
|
if i == m.history.SessCursor {
|
||||||
|
curLineOffset = totalLines
|
||||||
|
}
|
||||||
|
totalLines += len(blockLines)
|
||||||
|
blocks = append(blocks, sessBlock{lines: blockLines})
|
||||||
|
}
|
||||||
|
|
||||||
|
startLine, endLine := viewport(curLineOffset, totalLines, listH)
|
||||||
|
|
||||||
|
var allLines []string
|
||||||
|
for _, b := range blocks {
|
||||||
|
allLines = append(allLines, b.lines...)
|
||||||
|
}
|
||||||
|
if endLine > len(allLines) {
|
||||||
|
endLine = len(allLines)
|
||||||
|
}
|
||||||
|
return allLines[startLine:endLine]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) detailColumnLines(w, listH int) []string {
|
||||||
|
s := m.currentSession()
|
||||||
|
if s == nil {
|
||||||
|
return []string{lipgloss.NewStyle().Foreground(style.Dim).Render(fitWidth(" ← select to view", w))}
|
||||||
|
}
|
||||||
|
|
||||||
|
sepSty := lipgloss.NewStyle().Foreground(style.BgPanel)
|
||||||
|
var lines []string
|
||||||
|
|
||||||
|
favPrefix := " "
|
||||||
|
if m.history.Favorites[s.ID] {
|
||||||
|
favPrefix = "★ "
|
||||||
|
}
|
||||||
|
lines = append(lines, style.DetailTitle.Render(fitWidth(favPrefix+s.DisplayTitle(), w)))
|
||||||
|
lines = append(lines, sepSty.Render(fitWidth(strings.Repeat("─", w), w)))
|
||||||
|
|
||||||
|
fav := "-"
|
||||||
|
if m.history.Favorites[s.ID] {
|
||||||
|
fav = "★"
|
||||||
|
}
|
||||||
|
kvRows := []struct{ k, v string }{
|
||||||
|
{"dir", s.Cwd},
|
||||||
|
{"time", fmt.Sprintf("%s ~ %s", s.StartTime.Local().Format("01-02 15:04"), s.EndTime.Local().Format("15:04"))},
|
||||||
|
{"msgs", fmt.Sprintf("%d", s.MsgCount)},
|
||||||
|
{"fav", fav},
|
||||||
|
}
|
||||||
|
for _, r := range kvRows {
|
||||||
|
line := fmt.Sprintf(" %-4s %s", r.k, r.v)
|
||||||
|
lines = append(lines, style.NormStyle.Render(fitWidth(line, w)))
|
||||||
|
}
|
||||||
|
|
||||||
|
if summary := s.DisplaySummary(); summary != "" {
|
||||||
|
lines = append(lines, sepSty.Render(fitWidth(strings.Repeat("─", w), w)))
|
||||||
|
lines = append(lines, style.SessionSummaryStyle.Render(fitWidth(" "+summary, w)))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(s.Completed) > 0 {
|
||||||
|
lines = append(lines, sepSty.Render(fitWidth(strings.Repeat("─", w), w)))
|
||||||
|
lines = append(lines, lipgloss.NewStyle().Foreground(style.Success).Bold(true).Render(fitWidth(" ✓ 已完成", w)))
|
||||||
|
for _, item := range s.Completed {
|
||||||
|
lines = append(lines, style.NormStyle.Render(fitWidth(" · "+cleanText(item), w)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(s.Pending) > 0 {
|
||||||
|
lines = append(lines, sepSty.Render(fitWidth(strings.Repeat("─", w), w)))
|
||||||
|
lines = append(lines, lipgloss.NewStyle().Foreground(style.Warning).Bold(true).Render(fitWidth(" ○ 待办", w)))
|
||||||
|
for _, item := range s.Pending {
|
||||||
|
lines = append(lines, style.NormStyle.Render(fitWidth(" · "+cleanText(item), w)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lines = append(lines, lipgloss.NewStyle().Foreground(style.Dim).Render(fitWidth(" [Enter]resume", w)))
|
||||||
|
|
||||||
|
if s.ForkFrom != "" {
|
||||||
|
forkID := s.ForkFrom
|
||||||
|
if len(forkID) > 8 {
|
||||||
|
forkID = forkID[:8]
|
||||||
|
}
|
||||||
|
lines = append(lines, lipgloss.NewStyle().Foreground(style.Cyan).Render(fitWidth(fmt.Sprintf(" ⎇ fork from %s", forkID), w)))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(lines) > listH {
|
||||||
|
lines = lines[:listH]
|
||||||
|
}
|
||||||
|
return lines
|
||||||
|
}
|
||||||
189
internal/view_ws.go
Normal file
189
internal/view_ws.go
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
package internal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"charm.land/lipgloss/v2"
|
||||||
|
"u-tabs/internal/style"
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- Workspace 三栏渲染 ---
|
||||||
|
|
||||||
|
func (m *Model) renderWorkspace() string {
|
||||||
|
g := Groups[m.activeGroup]
|
||||||
|
svcs := WorkspacesByGroup(g.Label)
|
||||||
|
if len(svcs) == 0 {
|
||||||
|
return style.SubtitleStyle.Render(" empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
avail := m.width - 8
|
||||||
|
tabW := max(14, min(20, avail*18/100))
|
||||||
|
listW := max(30, avail*45/100)
|
||||||
|
detailW := avail - tabW - listW
|
||||||
|
|
||||||
|
tabCw := tabW - 2
|
||||||
|
listCw := listW - 2
|
||||||
|
detailCw := detailW - 2
|
||||||
|
|
||||||
|
listH := max(3, m.height-7)
|
||||||
|
|
||||||
|
tabLines := m.tabsColumnLines(tabCw, listH-1)
|
||||||
|
svcLines := m.wsListLines(g, svcs, listCw, listH-1)
|
||||||
|
detailLines := m.wsDetailLines(svcs, detailCw, listH-1)
|
||||||
|
|
||||||
|
maxRows := min(listH-1, max(max(len(tabLines), len(svcLines)), len(detailLines)))
|
||||||
|
|
||||||
|
tabBorderFg := style.BgPanel
|
||||||
|
if m.wsFocus == 0 {
|
||||||
|
tabBorderFg = style.Accent
|
||||||
|
}
|
||||||
|
listBorderFg := style.BgPanel
|
||||||
|
if m.wsFocus == 1 {
|
||||||
|
listBorderFg = style.Accent
|
||||||
|
}
|
||||||
|
|
||||||
|
panelSty := lipgloss.NewStyle().Border(lipgloss.RoundedBorder())
|
||||||
|
|
||||||
|
tabBox := panelSty.BorderForeground(tabBorderFg).Width(tabW).Render(
|
||||||
|
style.DetailTitle.Render("tabs") + "\n" + padLinesTo(tabLines, tabCw, maxRows))
|
||||||
|
listBox := panelSty.BorderForeground(listBorderFg).Width(listW).Render(
|
||||||
|
style.DetailTitle.Render(g.Label) + "\n" + padLinesTo(svcLines, listCw, maxRows))
|
||||||
|
detailBox := panelSty.BorderForeground(style.BgPanel).Width(detailW).Render(
|
||||||
|
style.DetailTitle.Render("detail") + "\n" + padLinesTo(detailLines, detailCw, maxRows))
|
||||||
|
|
||||||
|
return lipgloss.JoinHorizontal(lipgloss.Top, tabBox, " ", listBox, " ", detailBox)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) tabsColumnLines(w, listH int) []string {
|
||||||
|
var lines []string
|
||||||
|
total := len(Groups) + 1
|
||||||
|
start, end := viewport(m.wsTabCur, total, listH)
|
||||||
|
|
||||||
|
focused := m.wsFocus == 0
|
||||||
|
|
||||||
|
for i := start; i < end; i++ {
|
||||||
|
if i == len(Groups) {
|
||||||
|
renderNavEntry(&lines, w, "历史对话", "#7dcfff", m.wsTabCur == i, focused)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
g := Groups[i]
|
||||||
|
label := g.Label
|
||||||
|
gs := style.GroupStyles[g.Label]
|
||||||
|
isActive := m.wsTabCur == i
|
||||||
|
if isActive {
|
||||||
|
line := " ▸ " + label
|
||||||
|
line = padRightByWidth(truncateByWidth(line, w), w)
|
||||||
|
if focused && gs.GetForeground() != (lipgloss.Color("")) {
|
||||||
|
sty := lipgloss.NewStyle().Foreground(style.BgDark).Background(gs.GetForeground()).Bold(true)
|
||||||
|
lines = append(lines, sty.Render(line))
|
||||||
|
} else if gs.GetForeground() != (lipgloss.Color("")) {
|
||||||
|
sty := lipgloss.NewStyle().Foreground(gs.GetForeground()).Bold(true)
|
||||||
|
lines = append(lines, sty.Render(line))
|
||||||
|
} else {
|
||||||
|
lines = append(lines, style.SelStyle.Render(line))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
line := " " + label
|
||||||
|
line = padRightByWidth(truncateByWidth(line, w), w)
|
||||||
|
if gs.GetForeground() != (lipgloss.Color("")) {
|
||||||
|
lines = append(lines, gs.Render(line))
|
||||||
|
} else {
|
||||||
|
lines = append(lines, style.NormStyle.Render(line))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) wsListLines(g Group, svcs []Workspace, w, listH int) []string {
|
||||||
|
start, end := viewport(m.cursor, len(svcs), listH)
|
||||||
|
|
||||||
|
maxTitleW := 0
|
||||||
|
for _, ws := range svcs {
|
||||||
|
if tw := stringWidth(ws.Title); tw > maxTitleW {
|
||||||
|
maxTitleW = tw
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var lines []string
|
||||||
|
for i := start; i < end; i++ {
|
||||||
|
ws := svcs[i]
|
||||||
|
cur := " "
|
||||||
|
if i == m.cursor {
|
||||||
|
cur = "▸"
|
||||||
|
}
|
||||||
|
mark := " "
|
||||||
|
if m.selected[ws.Index] {
|
||||||
|
mark = "✓"
|
||||||
|
}
|
||||||
|
|
||||||
|
paddedTitle := padRightByWidth(ws.Title, maxTitleW)
|
||||||
|
|
||||||
|
if i == m.cursor {
|
||||||
|
prefix := cur + " " + mark + " " + fmt.Sprintf("%02d", ws.N) + " "
|
||||||
|
remainW := max(10, w-stringWidth(prefix))
|
||||||
|
text := truncateByWidth(paddedTitle+" "+ws.Prompt, remainW)
|
||||||
|
sty := style.SelStyle
|
||||||
|
if m.wsFocus != 1 {
|
||||||
|
sty = lipgloss.NewStyle().Foreground(style.Accent).Bold(true)
|
||||||
|
}
|
||||||
|
lines = append(lines, sty.Render(padRightByWidth(truncateByWidth(prefix+text, w), w)))
|
||||||
|
} else {
|
||||||
|
markStr := " "
|
||||||
|
if m.selected[ws.Index] {
|
||||||
|
markStr = style.MarkStyle.Render("✓")
|
||||||
|
}
|
||||||
|
num := style.NumStyle.Render(fmt.Sprintf("%02d", ws.N))
|
||||||
|
prefix := cur + " " + markStr + " " + num + " "
|
||||||
|
remainW := max(10, w-stringWidth(prefix))
|
||||||
|
text := truncateByWidth(paddedTitle+" "+ws.Prompt, remainW)
|
||||||
|
lines = append(lines, style.NormStyle.Render(padRightByWidth(truncateByWidth(prefix+text, w), w)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) wsDetailLines(svcs []Workspace, w, listH int) []string {
|
||||||
|
if m.cursor >= len(svcs) {
|
||||||
|
return []string{lipgloss.NewStyle().Foreground(style.Dim).Render(fitWidth(" ← select to view", w))}
|
||||||
|
}
|
||||||
|
|
||||||
|
ws := svcs[m.cursor]
|
||||||
|
sepSty := lipgloss.NewStyle().Foreground(style.BgPanel)
|
||||||
|
var lines []string
|
||||||
|
|
||||||
|
lines = append(lines, style.DetailTitle.Render(fitWidth(" "+ws.Title, w)))
|
||||||
|
lines = append(lines, sepSty.Render(fitWidth(strings.Repeat("─", w), w)))
|
||||||
|
|
||||||
|
rows := []struct {
|
||||||
|
key string
|
||||||
|
val string
|
||||||
|
sty lipgloss.Style
|
||||||
|
}{
|
||||||
|
{"dir", ws.Dir, style.ValStyle},
|
||||||
|
{"no", fmt.Sprintf("%02d · %s", ws.N, ws.Group), style.NumStyle},
|
||||||
|
{"desc", ws.Prompt, style.ValStyle},
|
||||||
|
{"tech", ws.Tech, style.TechStyle},
|
||||||
|
{"deploy", ws.Deploy, style.DeployStyle},
|
||||||
|
}
|
||||||
|
for _, r := range rows {
|
||||||
|
line := fmt.Sprintf(" %-6s %s", r.key, r.sty.Render(r.val))
|
||||||
|
lines = append(lines, style.NormStyle.Render(fitWidth(line, w)))
|
||||||
|
}
|
||||||
|
|
||||||
|
lines = append(lines, sepSty.Render(fitWidth(strings.Repeat("─", w), w)))
|
||||||
|
|
||||||
|
hintCmd := "wt"
|
||||||
|
if !isWindows {
|
||||||
|
hintCmd = "bash"
|
||||||
|
}
|
||||||
|
lines = append(lines, lipgloss.NewStyle().Foreground(style.Accent).Italic(true).Render(fitWidth(" $ "+hintCmd+" → CC @"+ws.Title, w)))
|
||||||
|
lines = append(lines, lipgloss.NewStyle().Foreground(style.Dim).Render(fitWidth(" [Enter]start [Space]multi", w)))
|
||||||
|
|
||||||
|
if len(lines) > listH {
|
||||||
|
lines = lines[:listH]
|
||||||
|
}
|
||||||
|
return lines
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user