新增: u-tabs 初始版本
Go TUI 项目启动器,基于 bubbletea v2 + lipgloss v2。 支持分组 Tab、多选启动、编号跳转、Windows Terminal 集成。
This commit is contained in:
56
README.md
Normal file
56
README.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# u-tabs
|
||||
|
||||
TUI 工作空间启动器 — 在终端快速选择项目,一键启动 Claude Code。
|
||||
|
||||
## 功能
|
||||
|
||||
- YAML 配置驱动,分组管理工作空间
|
||||
- 左右布局:列表 + 详情面板
|
||||
- 一键启动 Windows Terminal + Claude Code
|
||||
- 多选批量启动、编号快捷跳转、复制启动命令
|
||||
- Tokyo Night 主题,CJK 字符安全渲染
|
||||
|
||||
## 安装
|
||||
|
||||
```bash
|
||||
git clone https://gitea.1216.top/lxy/u-tabs.git
|
||||
cd u-tabs && go build .
|
||||
```
|
||||
|
||||
或下载 [Release](https://gitea.1216.top/lxy/u-tabs/releases) 中的 exe。
|
||||
|
||||
## 配置
|
||||
|
||||
首次运行读取 `~/.u-tabs/config.yaml`,参考 [config.example.yaml](./config.example.yaml)。
|
||||
|
||||
```yaml
|
||||
groups:
|
||||
- label: CORE
|
||||
desc: 核心业务
|
||||
items:
|
||||
- title: my-project
|
||||
prompt: 项目描述
|
||||
tech: Go/Gin
|
||||
deploy: 本地开发
|
||||
dir: "E:\\projects\\my-project"
|
||||
```
|
||||
|
||||
## 快捷键
|
||||
|
||||
| 按键 | 功能 |
|
||||
|------|------|
|
||||
| j/k | 上下选择 |
|
||||
| Tab | 切换分组 |
|
||||
| Enter | 启动 |
|
||||
| Space | 多选 |
|
||||
| c | 复制命令 |
|
||||
| 数字 | 按编号跳转 |
|
||||
| q | 退出 |
|
||||
|
||||
## 技术栈
|
||||
|
||||
Go 1.26 / Bubbletea v2 / Lipgloss v2
|
||||
|
||||
## 文档
|
||||
|
||||
- [架构设计](./docs/02-技术文档/架构设计.md)
|
||||
50
config.example.yaml
Normal file
50
config.example.yaml
Normal file
@@ -0,0 +1,50 @@
|
||||
# u-tabs 工作空间配置
|
||||
# 复制为 config.yaml 放到 ~/.u-tabs/ 目录即可生效
|
||||
# 优先级: ~/.u-tabs/config.yaml > exe同目录/config.yaml
|
||||
|
||||
groups:
|
||||
- label: CORE
|
||||
desc: 核心业务
|
||||
base: 10
|
||||
items:
|
||||
- title: my-api
|
||||
prompt: 主力后端服务
|
||||
tech: "Go1.26/Gin · MySQL+Redis"
|
||||
deploy: "生产服务器 | 8080 | api.example.com"
|
||||
dir: "E:\\projects\\my-api"
|
||||
|
||||
- title: my-web
|
||||
prompt: 前端管理后台
|
||||
tech: "Vue3/Arco/Vite5+TS"
|
||||
deploy: "Nginx静态 | - | admin.example.com"
|
||||
dir: "E:\\projects\\my-web"
|
||||
|
||||
- label: LAB
|
||||
desc: 实验室
|
||||
base: 30
|
||||
items:
|
||||
- title: demo
|
||||
prompt: 演示项目
|
||||
tech: "Python/FastAPI"
|
||||
deploy: "本地 | 3000 | 演示用"
|
||||
dir: "E:\\lab\\demo"
|
||||
|
||||
- label: TOOLS
|
||||
desc: 工具
|
||||
base: 50
|
||||
items:
|
||||
- title: scripts
|
||||
prompt: 运维脚本集
|
||||
tech: "Bash/Python"
|
||||
deploy: "本地 | - | 日常运维工具"
|
||||
dir: "E:\\tools\\scripts"
|
||||
|
||||
- label: ME
|
||||
desc: 个人
|
||||
base: 60
|
||||
items:
|
||||
- title: 默认
|
||||
prompt: Home 目录
|
||||
tech: "默认工作区"
|
||||
deploy: "本地 | - | 用户 Home 目录"
|
||||
dir: "~"
|
||||
73
docs/00-文档目录.md
Normal file
73
docs/00-文档目录.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# u-tabs 文档索引
|
||||
|
||||
> 最后更新:2026-05-15 | 项目状态:活跃
|
||||
|
||||
## 文档列表
|
||||
|
||||
| 文档 | 说明 |
|
||||
|------|------|
|
||||
| [架构设计](./02-技术文档/架构设计.md) | 项目架构、数据模型、UI布局、交互设计、启动逻辑 |
|
||||
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
cd u-tabs && go run main.go
|
||||
```
|
||||
|
||||
## 配置
|
||||
|
||||
工作空间数据从 YAML 配置加载,不硬编码。
|
||||
|
||||
```
|
||||
优先级: ~/.u-tabs/config.yaml > exe同目录/config.yaml > 内置默认值(workspace.go)
|
||||
```
|
||||
|
||||
示例文件:`config.example.yaml`
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
u-tabs/
|
||||
├── main.go # 入口: tea.NewProgram → p.Run()
|
||||
├── go.mod # Go 1.26, Bubbletea v2
|
||||
├── config.example.yaml # 配置示例
|
||||
├── internal/
|
||||
│ ├── app.go # 主 Model: 渲染 + 键盘交互 + 启动逻辑
|
||||
│ ├── config.go # YAML 配置加载 + Config→内部结构转换
|
||||
│ ├── workspace.go # Workspace/Group 数据结构 + 内置默认值 + wsByNum 索引
|
||||
│ └── style/
|
||||
│ └── style.go # Tokyo Night 主题 + GroupStyles
|
||||
└── docs/
|
||||
├── 00-文档目录.md # 本文件
|
||||
└── 02-技术文档/
|
||||
└── 架构设计.md # 完整架构文档
|
||||
```
|
||||
|
||||
## 核心依赖
|
||||
|
||||
| 依赖 | 版本 | 用途 |
|
||||
|------|------|------|
|
||||
| charm.land/bubbletea/v2 | v2.0.2 | TUI 框架 (Elm 架构) |
|
||||
| charm.land/lipgloss/v2 | v2.0.2 | 终端样式/布局 |
|
||||
| gopkg.in/yaml.v3 | v3.0.1 | 配置文件解析 |
|
||||
|
||||
## 交互
|
||||
|
||||
| 按键 | 功能 |
|
||||
|------|------|
|
||||
| j/k / ↑↓ | 当前分组内移动光标 |
|
||||
| Tab / ←→ | 切换分组 |
|
||||
| 1-4 | 快速跳转分组 |
|
||||
| Enter | 启动选中项(wt.exe 新标签页) |
|
||||
| Space | 切换多选标记,再 Enter 批量启动 |
|
||||
| 数字键 | 追加到输入缓冲,按编号启动 |
|
||||
| c | 复制启动命令到剪贴板 |
|
||||
| q / Ctrl+C | 退出 |
|
||||
|
||||
## 启动机制
|
||||
|
||||
```
|
||||
u-tabs → wt.exe → pwsh -NoExit -EncodedCommand → claude --name xxx (交互模式)
|
||||
↑ ↑
|
||||
Windows Terminal UTF-16LE → Base64 编码的 PS 脚本
|
||||
```
|
||||
222
docs/02-技术文档/架构设计.md
Normal file
222
docs/02-技术文档/架构设计.md
Normal file
@@ -0,0 +1,222 @@
|
||||
# u-tabs 架构设计
|
||||
|
||||
> 最后更新:2026-05-15 | 基于 u-tabs v0.3.0
|
||||
|
||||
## 1. 项目概述
|
||||
|
||||
u-tabs 是一个 TUI 工作空间启动器,用 Bubble Tea v2 实现。管理多个工作空间(YAML 配置驱动),通过 Windows Terminal + Claude Code 标签页启动。
|
||||
|
||||
**技术栈**: Go 1.26 / BubbleTea v2 / LipGloss v2
|
||||
|
||||
## 2. 架构
|
||||
|
||||
```
|
||||
main.go → Model (app.go)
|
||||
├── 分组 Tab 栏
|
||||
├── 左侧项目列表
|
||||
├── 右侧详情面板
|
||||
└── Enter → wt.exe → claude
|
||||
```
|
||||
|
||||
## 3. 配置系统
|
||||
|
||||
### 3.1 加载优先级
|
||||
|
||||
```
|
||||
~/.u-tabs/config.yaml → exe同目录/config.yaml → 内置默认值 (workspace.go)
|
||||
```
|
||||
|
||||
### 3.2 YAML 结构
|
||||
|
||||
```yaml
|
||||
groups:
|
||||
- label: CORE # 分组标签,决定编号基数
|
||||
desc: 核心业务
|
||||
items:
|
||||
- title: flux # 短名
|
||||
prompt: 描述 # AI prompt
|
||||
tech: 技术栈 # 精确到版本
|
||||
deploy: 部署 # 服务器/端口/域名
|
||||
dir: 目录路径 # 启动目录
|
||||
```
|
||||
|
||||
### 3.3 编号规则
|
||||
|
||||
由 `GroupConfig.Base` 字段决定,若未设置默认从 0 开始。
|
||||
|
||||
### 3.4 数据转换
|
||||
|
||||
`Config.ToInternal()` 将 YAML 配置转为运行时结构,自动生成:
|
||||
- `Index`: 全局索引 (0, 1, 2...)
|
||||
- `N`: 编号 (base + item索引)
|
||||
- `Group`: 继承父级 label
|
||||
- `Dir`: `~` 展开为用户主目录
|
||||
|
||||
## 4. 数据模型
|
||||
|
||||
### 4.1 Workspace
|
||||
|
||||
```go
|
||||
type Workspace struct {
|
||||
Index int // 全局索引
|
||||
N int // 编号
|
||||
Title string // 短名
|
||||
Prompt string // 描述
|
||||
Tech string // 技术栈
|
||||
Deploy string // 部署情况
|
||||
Dir string // 目录路径
|
||||
Group string // 分组标签
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 Group
|
||||
|
||||
```go
|
||||
type Group struct {
|
||||
Label string
|
||||
Desc string
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 运行时索引
|
||||
|
||||
| 变量 | 类型 | 用途 |
|
||||
|------|------|------|
|
||||
| `Groups` | `[]Group` | 分组定义 |
|
||||
| `AllWorkspaces` | `[]Workspace` | 全部工作空间 |
|
||||
| `wsByNum` | `map[int]*Workspace` | 编号→Workspace O(1) 索引 |
|
||||
|
||||
## 5. 主模型 (app.go)
|
||||
|
||||
### 5.1 Model
|
||||
|
||||
```go
|
||||
type Model struct {
|
||||
activeGroup int // 当前分组索引
|
||||
cursor int // 分组内光标
|
||||
selected map[int]bool // 多选标记
|
||||
inputBuf string // 数字快捷输入缓冲
|
||||
width, height int
|
||||
launched string // 启动提示
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 Update 消息路由
|
||||
|
||||
```
|
||||
KeyPressMsg
|
||||
├── q, ctrl+c → tea.Quit
|
||||
├── tab, right, l → activeGroup++ → cursor=0
|
||||
├── shift+tab, left, h → activeGroup--
|
||||
├── 1, 2, 3, 4 → 跳转分组
|
||||
├── up, k → cursor--
|
||||
├── down, j → cursor++
|
||||
├── Space → toggleMultiSelect
|
||||
├── enter → inputBuf非空? launchByInput() : launchSelected()
|
||||
├── c → copyCommand()
|
||||
└── [0-9] → 追加到 inputBuf
|
||||
```
|
||||
|
||||
### 5.3 View 渲染
|
||||
|
||||
```
|
||||
View()
|
||||
└── 启动器模式:
|
||||
├─ 第1行: "u-tabs"标题 + 分组 Tab 栏
|
||||
├─ 分隔线
|
||||
├─ 左右布局 (JoinHorizontal):
|
||||
│ ├── 左侧列表: 编号 + 标题 + 描述 (CJK安全截断)
|
||||
│ └── 右侧详情: 目录/编号/描述/技术/部署/命令预览
|
||||
├─ 输入缓冲提示
|
||||
├─ 启动成功提示
|
||||
└─ 帮助栏 (快捷键高亮)
|
||||
```
|
||||
|
||||
### 5.4 CJK 安全截断
|
||||
|
||||
`truncateByWidth()` 按 rune 显示宽度截断,中文=2宽,英文=1宽,不会切断多字节字符:
|
||||
|
||||
```go
|
||||
func truncateByWidth(s string, maxW int) string {
|
||||
w := 0
|
||||
for i := 0; i < len(s); {
|
||||
_, size := utf8.DecodeRuneInString(s[i:])
|
||||
rw := runeWidth(rune(s[i]))
|
||||
if w+rw > maxW { return s[:i] }
|
||||
w += rw
|
||||
i += size
|
||||
}
|
||||
return s
|
||||
}
|
||||
```
|
||||
|
||||
## 6. 启动逻辑
|
||||
|
||||
### 6.1 流程
|
||||
|
||||
```
|
||||
用户按 Enter
|
||||
↓
|
||||
buildLaunchScript(ws) → PS 脚本
|
||||
↓
|
||||
encodePSCommand(script) → UTF-16LE → Base64
|
||||
↓
|
||||
exec.Command("wt.exe", "-w", "0",
|
||||
"-d", ws.Dir,
|
||||
"--tabColor", randomColor,
|
||||
"pwsh", "-NoExit", "-EncodedCommand", encoded).Start()
|
||||
↓
|
||||
Windows Terminal 新标签页 → claude 交互模式
|
||||
```
|
||||
|
||||
### 6.2 PS 脚本模板
|
||||
|
||||
```powershell
|
||||
$Host.UI.RawUI.WindowTitle = "{Title}"
|
||||
Write-Host "=== {Title} ===" -ForegroundColor Cyan
|
||||
Write-Host "Prompt: {Prompt}" -ForegroundColor Yellow
|
||||
cd "{Dir}"
|
||||
claude --name "{Title}" --permission-mode bypassPermissions
|
||||
```
|
||||
|
||||
### 6.3 UTF-16LE 编码
|
||||
|
||||
```go
|
||||
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)
|
||||
}
|
||||
```
|
||||
|
||||
## 7. 样式系统 (Tokyo Night 主题)
|
||||
|
||||
### 7.1 色板
|
||||
|
||||
| 常量 | Hex | 用途 |
|
||||
|------|-----|------|
|
||||
| BgDark | #1a1b26 | 深底色 |
|
||||
| BgPanel | #292e42 | 面板/边框/分隔线 |
|
||||
| Dim | #565f89 | 次要文字 |
|
||||
| Fg | #a9b1d6 | 正文 |
|
||||
| Bright | #c0caf5 | 高亮文字 |
|
||||
| Accent | #7aa2f7 | 主强调 (蓝色) |
|
||||
| Success | #9ece6a | 绿色 (部署/标记) |
|
||||
| Warning | #e0af68 | 黄色 (输入提示) |
|
||||
| Cyan | #7dcfff | 青色 (编号/详情) |
|
||||
| Purple | #bb9af7 | 紫色 (选中行) |
|
||||
| Red | #f7768e | 红色 (CORE分组) |
|
||||
|
||||
### 7.2 分组颜色
|
||||
|
||||
| 分组 | Hex |
|
||||
|------|-----|
|
||||
| CORE | #f7768e |
|
||||
| LAB | #9ece6a |
|
||||
| TOOLS | #e0af68 |
|
||||
| ME | #bb9af7 |
|
||||
| TEMP | #7dcfff |
|
||||
245
docs/2026-05-16-备忘录-历史会话功能.md
Normal file
245
docs/2026-05-16-备忘录-历史会话功能.md
Normal file
@@ -0,0 +1,245 @@
|
||||
# u-tabs 历史会话功能 — 需求备忘录
|
||||
|
||||
> 日期: 2026-05-16 | 状态: 分析完成
|
||||
|
||||
---
|
||||
|
||||
## 1. 需求概述
|
||||
|
||||
在 u-tabs 最后一个 Tab 新增「历史会话」功能,浏览和管理 Claude Code 所有项目的历史会话记录。
|
||||
|
||||
## 2. 核心需求
|
||||
|
||||
### 2.1 新增 HISTORY Tab
|
||||
|
||||
- 作为最后一个 Tab 追加到现有 Tab 栏(CORE / LAB / TOOLS / ME / **HISTORY**)
|
||||
- 切换到该 Tab 时展示三栏布局(其他 Tab 保持原有两栏不变)
|
||||
|
||||
### 2.2 三栏布局
|
||||
|
||||
```
|
||||
┌──────────────┬────────────────────┬──────────────────────────┐
|
||||
│ 项目目录 │ 会话列表 │ 会话详情 │
|
||||
│ │ │ │
|
||||
│ ▸ E:/wk-flux │ ▸ 05-16 01:27 │ 标题: 龙享花API对接 │
|
||||
│ E:/wk-lab │ 05-15 23:01 │ 时间: 05-16 01:27 │
|
||||
│ E:/wk-oth │ 05-14 15:30 │ 目录: E:/wk-flux │
|
||||
│ E:/wk-abc │ │ 消息: 94条 │
|
||||
│ ... │ │ 摘要: 整理龙享花平台API │
|
||||
│ │ │ 文档并编写测试代码... │
|
||||
└──────────────┴────────────────────┴──────────────────────────┘
|
||||
```
|
||||
|
||||
| 栏位 | 内容 | 交互 |
|
||||
|------|------|------|
|
||||
| 左栏 | 按 `cwd` 分组的项目目录列表 | 上下键切换目录 |
|
||||
| 中栏 | 当前目录下的会话列表(时间+标题) | 上下键切换会话 |
|
||||
| 右栏 | 选中会话的详情(标题/时间/消息数/AI摘要) | 只读展示 |
|
||||
|
||||
### 2.3 恢复会话
|
||||
|
||||
- 按 `Enter` 在对应项目目录下执行 `claude -r <session-id>` 恢复会话
|
||||
- 通过 `wt.exe` 打开新 Tab 执行(与现有启动逻辑一致)
|
||||
|
||||
### 2.4 AI 摘要生成
|
||||
|
||||
- 会话标题和摘要通过 AI 分析生成(`claude -p` 非交互模式)
|
||||
- 生成结果缓存到本地(`~/.u-tabs/session-cache.json`),避免重复分析
|
||||
- 分层策略:
|
||||
1. 优先用已有的 `awaySummary`(Claude 自动生成的离开摘要)
|
||||
2. 次选 `customTitle` + 首条用户消息 + 消息数
|
||||
3. 用户按 `s` 键触发 AI 按需生成,结果缓存
|
||||
|
||||
---
|
||||
|
||||
## 3. 数据源分析
|
||||
|
||||
### 3.1 存储位置
|
||||
|
||||
```
|
||||
~/.claude/projects/<编码目录>/<session-uuid>.jsonl
|
||||
```
|
||||
|
||||
编码规则:`E:\wk-lab\u-tabs` → `E--wk-lab-u-tabs`
|
||||
|
||||
### 3.2 JSONL 关键 entry 类型
|
||||
|
||||
| type | 关键字段 | 用途 |
|
||||
|------|----------|------|
|
||||
| `custom-title` | `customTitle` | 会话标题 |
|
||||
| `user` | `message.content` | 用户消息(string 或 text block 数组) |
|
||||
| `assistant` | `message.content` | 助手回复 |
|
||||
| `system` (subtype=`away_summary`) | `content` | AI 自动生成的离开摘要 |
|
||||
| 大多数 entry | `cwd`, `timestamp`, `sessionId`, `gitBranch` | 通用元数据 |
|
||||
|
||||
### 3.3 扫描策略
|
||||
|
||||
**流式扫描,不全量加载:**
|
||||
- `bufio.Scanner` 逐行读取,`bytes.Contains` 预过滤避免无用 JSON 解析
|
||||
- 超过 2000 行的文件提前终止(元数据通常在前 50-100 行)
|
||||
- 缓存机制:`~/.u-tabs/session-cache.json` 记录每个 session 的 modTime + 元数据
|
||||
- 只重新读取 modTime 变化的文件
|
||||
- 后续启动从缓存加载,接近即时完成
|
||||
|
||||
---
|
||||
|
||||
## 4. 技术方案
|
||||
|
||||
### 4.1 现有代码结构
|
||||
|
||||
```
|
||||
internal/
|
||||
├── app.go # Model, Update, View, 启动逻辑 (405行)
|
||||
├── workspace.go # Workspace/Group 结构体, 运行时状态
|
||||
├── config.go # YAML 配置加载
|
||||
└── style/style.go # Tokyo Night 样式
|
||||
```
|
||||
|
||||
### 4.2 文件变更清单
|
||||
|
||||
| 文件 | 操作 | 职责 |
|
||||
|------|------|------|
|
||||
| `internal/history.go` | **新建** | Session/ProjectDir/HistoryState 结构体、JSONL 扫描器、缓存、三栏渲染、按键处理、会话恢复 |
|
||||
| `internal/app.go` | **修改** | Model 增加 `history` 字段、Update/View 分支 HISTORY 逻辑、Tab 栏追加 HISTORY、ScanCompleteMsg 处理 |
|
||||
| `internal/style/style.go` | **修改** | GroupStyles 增加 HISTORY、会话列表/详情专用样式 |
|
||||
| `internal/workspace.go` | 不变 | — |
|
||||
| `internal/config.go` | 不变 | — |
|
||||
| `main.go` | 不变 | — |
|
||||
|
||||
### 4.3 核心结构体
|
||||
|
||||
```go
|
||||
// Session — 单个会话的元数据
|
||||
type Session struct {
|
||||
ID string // UUID (文件名)
|
||||
CustomTitle string // customTitle 字段
|
||||
Cwd string // 实际工作目录
|
||||
StartTime time.Time // 首条 timestamp
|
||||
EndTime time.Time // 末条 timestamp
|
||||
MsgCount int // user + assistant 行数
|
||||
FirstMsg string // 首条用户消息 (截断)
|
||||
AwaySummary string // away_summary 系统摘要
|
||||
}
|
||||
|
||||
// ProjectDir — 按目录分组的会话
|
||||
type ProjectDir struct {
|
||||
Dir string // 完整路径
|
||||
Sessions []*Session // 按时间倒序
|
||||
}
|
||||
|
||||
// HistoryState — HISTORY Tab 视图状态
|
||||
type HistoryState struct {
|
||||
Projects []*ProjectDir
|
||||
DirCursor int // 左栏光标
|
||||
SessCursor int // 中栏光标
|
||||
FocusPanel int // 0=左栏 1=中栏
|
||||
Loaded bool
|
||||
Scanning bool
|
||||
}
|
||||
```
|
||||
|
||||
### 4.4 Model 修改
|
||||
|
||||
```go
|
||||
type Model struct {
|
||||
// ... 现有字段不变 ...
|
||||
history HistoryState // 新增:HISTORY Tab 状态
|
||||
}
|
||||
```
|
||||
|
||||
HISTORY Tab 判断:`m.activeGroup == len(Groups)`(最后一个位置)。
|
||||
|
||||
### 4.5 Tab 切换改动
|
||||
|
||||
- 现有:`% len(Groups)` 循环
|
||||
- 改为:`% (len(Groups) + 1)` 包含 HISTORY
|
||||
- 新增 `5` 键直接跳转 HISTORY
|
||||
|
||||
### 4.6 三栏渲染
|
||||
|
||||
```
|
||||
可用宽度 = terminal width - 2 (分隔符)
|
||||
左栏 (目录): 20% 宽度, 最小 20 字符
|
||||
中栏 (会话): 40% 宽度, 最小 30 字符
|
||||
右栏 (详情): 剩余宽度, 最小 30 字符
|
||||
```
|
||||
|
||||
### 4.7 异步扫描
|
||||
|
||||
```go
|
||||
type ScanCompleteMsg struct {
|
||||
Projects []*ProjectDir
|
||||
}
|
||||
|
||||
// 首次进入 HISTORY Tab 时触发
|
||||
func ScanSessionsCmd() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
return ScanCompleteMsg{Projects: scanAllProjects()}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
扫描期间显示 "scanning..." 占位。
|
||||
|
||||
### 4.8 会话恢复
|
||||
|
||||
复用现有 `encodePSCommand` + `wt.exe` 模式,改用 `claude -r <session-id>`:
|
||||
|
||||
```go
|
||||
func resumeSession(s *Session) {
|
||||
script := fmt.Sprintf(`cd "%s"; claude -r %s`, s.Cwd, s.ID)
|
||||
encoded := encodePSCommand(script)
|
||||
exec.Command("wt.exe", "-w", "0", "-d", s.Cwd,
|
||||
"--tabColor", randomColor,
|
||||
"pwsh", "-NoExit", "-EncodedCommand", encoded).Start()
|
||||
}
|
||||
```
|
||||
|
||||
### 4.9 HISTORY Tab 按键
|
||||
|
||||
| 键 | 动作 |
|
||||
|----|------|
|
||||
| `j`/`down` | 当前面板光标下移 |
|
||||
| `k`/`up` | 当前面板光标上移 |
|
||||
| `tab`/`right`/`l` | 焦点切到中栏 |
|
||||
| `shift+tab`/`left`/`h` | 焦点切到左栏 |
|
||||
| `enter` | 恢复选中会话 |
|
||||
| `s` | AI 生成摘要(按需) |
|
||||
| `5` | 从其他 Tab 跳转到 HISTORY |
|
||||
| `q`/`ctrl+c` | 退出 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 实现阶段
|
||||
|
||||
| 阶段 | 内容 | 涉及文件 |
|
||||
|------|------|----------|
|
||||
| P1 数据层 | Session 结构体 + JSONL 流式扫描器 + 缓存 | `history.go` |
|
||||
| P2 集成 | Model 扩展 + Update 分支 + Tab 栏扩展 | `app.go` |
|
||||
| P3 渲染 | 三栏布局 + 目录/会话/详情面板 | `history.go` |
|
||||
| P4 动作 | 会话恢复 + AI 摘要按需生成 | `history.go`, `app.go` |
|
||||
| P5 样式 | HISTORY Tab 样式 + 会话专用样式 | `style.go` |
|
||||
|
||||
---
|
||||
|
||||
## 6. 风险与对策
|
||||
|
||||
| 风险 | 对策 |
|
||||
|------|------|
|
||||
| 大文件 (1MB+) 扫描慢 | 流式扫描 + 2000 行截断 + modTime 缓存 |
|
||||
| 项目目录过多 | 左栏滚动显示,按最近活跃排序 |
|
||||
| `claude -p` 生成摘要慢 | 按需触发(`s` 键),非自动;结果缓存 |
|
||||
| 窄终端三栏挤压 | 定义面板最小宽度,不足时降级为两栏 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 验证标准
|
||||
|
||||
1. HISTORY Tab 出现在 Tab 栏末尾,样式与其他 Tab 一致
|
||||
2. 左栏列出所有有会话的项目目录,按路径排序
|
||||
3. 选中目录后中栏显示该目录下所有会话,按时间倒序
|
||||
4. 选中会话后右栏显示完整详情
|
||||
5. `Enter` 键在正确目录下通过 `wt.exe` 恢复会话
|
||||
6. `s` 键触发 AI 摘要生成,结果写入缓存
|
||||
7. 其他 Tab 功能不受影响
|
||||
8. 首次进入扫描显示 loading,二次进入从缓存加载
|
||||
27
go.mod
Normal file
27
go.mod
Normal file
@@ -0,0 +1,27 @@
|
||||
module u-tabs
|
||||
|
||||
go 1.26
|
||||
|
||||
require (
|
||||
charm.land/bubbletea/v2 v2.0.2
|
||||
charm.land/lipgloss/v2 v2.0.2
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/charmbracelet/colorprofile v0.4.2 // indirect
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.11.6 // indirect
|
||||
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||
github.com/charmbracelet/x/termios v0.1.1 // indirect
|
||||
github.com/charmbracelet/x/windows v0.2.2 // indirect
|
||||
github.com/clipperhouse/displaywidth v0.11.0 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.21 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
)
|
||||
44
go.sum
Normal file
44
go.sum
Normal file
@@ -0,0 +1,44 @@
|
||||
charm.land/bubbletea/v2 v2.0.2 h1:4CRtRnuZOdFDTWSff9r8QFt/9+z6Emubz3aDMnf/dx0=
|
||||
charm.land/bubbletea/v2 v2.0.2/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ=
|
||||
charm.land/lipgloss/v2 v2.0.2 h1:xFolbF8JdpNkM2cEPTfXEcW1p6NRzOWTSamRfYEw8cs=
|
||||
charm.land/lipgloss/v2 v2.0.2/go.mod h1:KjPle2Qd3YmvP1KL5OMHiHysGcNwq6u83MUjYkFvEkM=
|
||||
github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
|
||||
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
|
||||
github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY=
|
||||
github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8=
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 h1:eyFRbAmexyt43hVfeyBofiGSEmJ7krjLOYt/9CF5NKA=
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8/go.mod h1:SQpCTRNBtzJkwku5ye4S3HEuthAlGy2n9VXZnWkEW98=
|
||||
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
|
||||
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
|
||||
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
||||
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
||||
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
|
||||
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
|
||||
github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
|
||||
github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
|
||||
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
|
||||
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w=
|
||||
github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
387
internal/app.go
Normal file
387
internal/app.go
Normal file
@@ -0,0 +1,387 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"log"
|
||||
"math/big"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf16"
|
||||
"unicode/utf8"
|
||||
|
||||
"charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
"u-tabs/internal/style"
|
||||
)
|
||||
|
||||
// Model 主模型
|
||||
type Model struct {
|
||||
activeGroup int
|
||||
cursor int
|
||||
selected map[int]bool
|
||||
inputBuf string
|
||||
width int
|
||||
height int
|
||||
launched string
|
||||
}
|
||||
|
||||
func NewModel() *Model {
|
||||
return &Model{
|
||||
activeGroup: 0,
|
||||
selected: make(map[int]bool),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyPressMsg:
|
||||
s := msg.String()
|
||||
switch s {
|
||||
case "q", "ctrl+c":
|
||||
return m, tea.Quit
|
||||
case "tab", "right", "l":
|
||||
m.activeGroup = (m.activeGroup + 1) % len(Groups)
|
||||
m.cursor = 0
|
||||
m.inputBuf = ""
|
||||
case "shift+tab", "left", "h":
|
||||
m.activeGroup = (m.activeGroup - 1 + len(Groups)) % len(Groups)
|
||||
m.cursor = 0
|
||||
m.inputBuf = ""
|
||||
case "1", "2", "3", "4":
|
||||
idx, _ := strconv.Atoi(s)
|
||||
if idx <= len(Groups) {
|
||||
m.activeGroup = idx - 1
|
||||
m.cursor = 0
|
||||
m.inputBuf = ""
|
||||
}
|
||||
case "up", "k":
|
||||
m.moveCursor(-1)
|
||||
case "down", "j":
|
||||
m.moveCursor(1)
|
||||
case " ":
|
||||
m.toggleMultiSelect()
|
||||
case "enter":
|
||||
if m.inputBuf != "" {
|
||||
return m.launchByInput()
|
||||
}
|
||||
return m.launchSelected()
|
||||
default:
|
||||
if len(s) == 1 && s[0] >= '0' && s[0] <= '9' {
|
||||
m.inputBuf += s
|
||||
}
|
||||
}
|
||||
case tea.WindowSizeMsg:
|
||||
m.width = msg.Width
|
||||
m.height = msg.Height
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) launchSelected() (*Model, tea.Cmd) {
|
||||
if len(m.selected) > 0 {
|
||||
var launched []string
|
||||
for idx := range m.selected {
|
||||
ws := &AllWorkspaces[idx]
|
||||
go launchWorkspace(*ws)
|
||||
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]
|
||||
go launchWorkspace(ws)
|
||||
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 {
|
||||
go launchWorkspace(*ws)
|
||||
m.launched = ws.Title
|
||||
}
|
||||
m.inputBuf = ""
|
||||
return m, nil
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
|
||||
// layout widths
|
||||
listW := max(42, min(65, m.width*55/100))
|
||||
detailW := max(30, m.width-listW-3)
|
||||
|
||||
// ── header: title + tabs ──
|
||||
b.WriteString(style.TitleStyle.Render(" u-tabs "))
|
||||
sep := style.TabSep.Render(" | ")
|
||||
for i, g := range Groups {
|
||||
if i > 0 {
|
||||
b.WriteString(sep)
|
||||
}
|
||||
label := fmt.Sprintf(" %s %s ", g.Label, g.Desc)
|
||||
if i == m.activeGroup {
|
||||
gs, ok := style.GroupStyles[g.Label]
|
||||
if ok {
|
||||
b.WriteString(lipgloss.NewStyle().
|
||||
Bold(true).Background(style.BgPanel).Foreground(gs.GetForeground()).
|
||||
Padding(0, 1).Render(label))
|
||||
} else {
|
||||
b.WriteString(style.TabActiveStyle.Render(label))
|
||||
}
|
||||
} else {
|
||||
b.WriteString(style.TabInactiveStyle.Render(label))
|
||||
}
|
||||
}
|
||||
b.WriteString("\n")
|
||||
b.WriteString(lipgloss.NewStyle().Foreground(style.BgPanel).Render(strings.Repeat("─", m.width)))
|
||||
b.WriteString("\n")
|
||||
|
||||
g := Groups[m.activeGroup]
|
||||
gs, _ := style.GroupStyles[g.Label]
|
||||
svcs := WorkspacesByGroup(g.Label)
|
||||
if len(svcs) == 0 {
|
||||
b.WriteString(style.SubtitleStyle.Render(" empty"))
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// ═══ left: list ═══
|
||||
var left strings.Builder
|
||||
innerW := listW - 4
|
||||
for i, ws := range svcs {
|
||||
cur := " "
|
||||
if i == m.cursor {
|
||||
cur = "▸"
|
||||
}
|
||||
mark := " "
|
||||
if m.selected[ws.Index] {
|
||||
mark = style.MarkStyle.Render("✓")
|
||||
}
|
||||
num := style.NumStyle.Render(fmt.Sprintf("%02d", ws.N))
|
||||
|
||||
prefix := cur + " " + mark + " " + num + " "
|
||||
remainW := max(10, innerW-lipgloss.Width(prefix))
|
||||
text := truncateByWidth(ws.Title+" "+ws.Prompt, remainW)
|
||||
|
||||
line := prefix + text
|
||||
if i == m.cursor {
|
||||
left.WriteString(style.SelStyle.Width(innerW).Render(line))
|
||||
} else {
|
||||
left.WriteString(style.NormStyle.Render(line))
|
||||
}
|
||||
left.WriteString("\n")
|
||||
}
|
||||
|
||||
groupHeader := gs.Render(fmt.Sprintf(" %s · %d ", g.Label, len(svcs)))
|
||||
listBox := lipgloss.NewStyle().
|
||||
Border(lipgloss.NormalBorder()).
|
||||
BorderForeground(style.BgPanel).
|
||||
Width(listW).
|
||||
Padding(0, 1).
|
||||
Render(groupHeader + "\n" + left.String())
|
||||
|
||||
// ═══ right: detail ═══
|
||||
var right strings.Builder
|
||||
if m.cursor < len(svcs) {
|
||||
ws := svcs[m.cursor]
|
||||
|
||||
right.WriteString(style.DetailTitle.Render(" detail "))
|
||||
right.WriteString(lipgloss.NewStyle().Foreground(style.BgPanel).Render(strings.Repeat("─", detailW-10)))
|
||||
right.WriteString("\n\n")
|
||||
|
||||
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 {
|
||||
right.WriteString(" ")
|
||||
right.WriteString(style.KeyStyle.Render(r.key))
|
||||
right.WriteString(" ")
|
||||
right.WriteString(r.sty.Render(r.val))
|
||||
right.WriteString("\n")
|
||||
}
|
||||
|
||||
right.WriteString("\n")
|
||||
right.WriteString(lipgloss.NewStyle().Foreground(style.BgPanel).Render(" " + strings.Repeat("─", detailW-6)))
|
||||
right.WriteString("\n")
|
||||
right.WriteString(lipgloss.NewStyle().Foreground(style.Accent).Italic(true).Render(" $ wt → CC @" + ws.Title))
|
||||
right.WriteString("\n")
|
||||
right.WriteString(lipgloss.NewStyle().Foreground(style.Dim).Render(" [Enter]start [Space]multi"))
|
||||
} else {
|
||||
right.WriteString(lipgloss.NewStyle().Foreground(style.Dim).Render(" ← select to view"))
|
||||
}
|
||||
|
||||
detailBox := lipgloss.NewStyle().
|
||||
Border(lipgloss.NormalBorder()).
|
||||
BorderForeground(style.BgPanel).
|
||||
Width(detailW).
|
||||
Padding(0, 1).
|
||||
Render(right.String())
|
||||
|
||||
b.WriteString(lipgloss.JoinHorizontal(lipgloss.Left, listBox, " ", detailBox))
|
||||
|
||||
// ── footer ──
|
||||
b.WriteString("\n")
|
||||
if m.inputBuf != "" {
|
||||
b.WriteString(style.InputStyle.Render(fmt.Sprintf(" ▶ num:%s [Enter]go [Esc]cancel", m.inputBuf)))
|
||||
}
|
||||
if m.launched != "" {
|
||||
b.WriteString(lipgloss.NewStyle().Foreground(style.Accent).Render(fmt.Sprintf(" ✓ %s", m.launched)))
|
||||
m.launched = ""
|
||||
}
|
||||
b.WriteString("\n")
|
||||
|
||||
helpParts := []string{
|
||||
m.fmtHelp("j/k", "sel"),
|
||||
m.fmtHelp("Enter", "run"),
|
||||
m.fmtHelp("Space", "multi"),
|
||||
m.fmtHelp("Tab", "group"),
|
||||
m.fmtHelp("q", "quit"),
|
||||
}
|
||||
b.WriteString(" " + strings.Join(helpParts, " "))
|
||||
|
||||
if hint := ConfigHint(); hint != "" {
|
||||
b.WriteString("\n" + hint)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (m *Model) fmtHelp(key, desc string) string {
|
||||
return lipgloss.NewStyle().Foreground(style.Accent).Render(key) +
|
||||
lipgloss.NewStyle().Foreground(style.Dim).Render(":" + desc)
|
||||
}
|
||||
|
||||
// truncateByWidth 按显示宽度截断字符串,不切断多字节字符
|
||||
func truncateByWidth(s string, maxW int) string {
|
||||
w := 0
|
||||
for i := 0; i < len(s); {
|
||||
_, size := utf8.DecodeRuneInString(s[i:])
|
||||
r := rune(s[i])
|
||||
rw := runeWidth(r)
|
||||
if w+rw > maxW {
|
||||
return s[:i]
|
||||
}
|
||||
w += rw
|
||||
i += size
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// runeWidth 返回单个 rune 的终端显示宽度 (CJK=2, 其他=1)
|
||||
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
|
||||
}
|
||||
|
||||
// --- launch ---
|
||||
|
||||
func launchWorkspace(ws Workspace) {
|
||||
script := buildLaunchScript(ws)
|
||||
encoded := encodePSCommand(script)
|
||||
color := fmt.Sprintf("#%02X%02X%02X", randRange(80, 255), randRange(80, 255), randRange(80, 255))
|
||||
cmd := exec.Command("wt.exe", "-w", "0",
|
||||
"-d", ws.Dir,
|
||||
"--tabColor", color,
|
||||
"pwsh", "-NoExit", "-EncodedCommand", encoded,
|
||||
)
|
||||
if err := cmd.Start(); err != nil {
|
||||
log.Printf("[u-tabs] launch fail %s(%d): %v", ws.Title, ws.N, err)
|
||||
}
|
||||
}
|
||||
|
||||
func buildLaunchScript(ws Workspace) string {
|
||||
return fmt.Sprintf(`$Host.UI.RawUI.WindowTitle = "%s"
|
||||
Write-Host "=== %s ===" -ForegroundColor Cyan
|
||||
Write-Host "Prompt: %s" -ForegroundColor Yellow
|
||||
cd "%s"
|
||||
claude --name "%s" --permission-mode bypassPermissions`,
|
||||
ws.Title, ws.Title, ws.Prompt, ws.Dir, ws.Title)
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
103
internal/config.go
Normal file
103
internal/config.go
Normal file
@@ -0,0 +1,103 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// Config YAML 配置结构(嵌套:分组 → 工作空间)
|
||||
type Config struct {
|
||||
Groups []GroupConfig `yaml:"groups"`
|
||||
}
|
||||
|
||||
type GroupConfig struct {
|
||||
Label string `yaml:"label"`
|
||||
Desc string `yaml:"desc"`
|
||||
Base int `yaml:"base"`
|
||||
Items []WorkspaceConfig `yaml:"items"`
|
||||
}
|
||||
|
||||
type WorkspaceConfig struct {
|
||||
Title string `yaml:"title"`
|
||||
Prompt string `yaml:"prompt"`
|
||||
Tech string `yaml:"tech"`
|
||||
Deploy string `yaml:"deploy"`
|
||||
Dir string `yaml:"dir"`
|
||||
}
|
||||
|
||||
// LoadConfig 加载配置文件
|
||||
// 优先级: ~/.u-tabs/config.yaml > exe同目录/config.yaml
|
||||
func LoadConfig() (*Config, error) {
|
||||
// 1. 尝试用户目录
|
||||
userDir, err := os.UserHomeDir()
|
||||
if err == nil {
|
||||
userCfg := filepath.Join(userDir, ".u-tabs", "config.yaml")
|
||||
if cfg, err := loadConfigFile(userCfg); err == nil {
|
||||
return cfg, nil
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 尝试 exe 同目录
|
||||
exePath, err := os.Executable()
|
||||
if err == nil {
|
||||
exeDir := filepath.Dir(exePath)
|
||||
exeCfg := filepath.Join(exeDir, "config.yaml")
|
||||
if cfg, err := loadConfigFile(exeCfg); err == nil {
|
||||
return cfg, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("未找到配置文件")
|
||||
}
|
||||
|
||||
func loadConfigFile(path string) (*Config, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var cfg Config
|
||||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
// ToInternal 将 YAML 配置转换为内部数据结构
|
||||
// 自动生成: index(全局索引), n(编号=base+序号), group(继承父级)
|
||||
func (c *Config) ToInternal() ([]Group, []Workspace) {
|
||||
groups := make([]Group, len(c.Groups))
|
||||
var allWorkspaces []Workspace
|
||||
globalIdx := 0
|
||||
|
||||
for gi, g := range c.Groups {
|
||||
groups[gi] = Group{Label: g.Label, Desc: g.Desc}
|
||||
|
||||
for wi, w := range g.Items {
|
||||
allWorkspaces = append(allWorkspaces, Workspace{
|
||||
Index: globalIdx,
|
||||
N: g.Base + wi,
|
||||
Title: w.Title,
|
||||
Prompt: w.Prompt,
|
||||
Tech: w.Tech,
|
||||
Deploy: w.Deploy,
|
||||
Dir: expandHome(w.Dir),
|
||||
Group: g.Label,
|
||||
})
|
||||
globalIdx++
|
||||
}
|
||||
}
|
||||
return groups, allWorkspaces
|
||||
}
|
||||
|
||||
// expandHome 将 ~ 展开为用户主目录
|
||||
func expandHome(dir string) string {
|
||||
if strings.HasPrefix(dir, "~") {
|
||||
home, _ := os.UserHomeDir()
|
||||
return strings.Replace(dir, "~", home, 1)
|
||||
}
|
||||
return dir
|
||||
}
|
||||
50
internal/style/style.go
Normal file
50
internal/style/style.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package style
|
||||
|
||||
import (
|
||||
"charm.land/lipgloss/v2"
|
||||
)
|
||||
|
||||
// ── palette: cool gray + single accent (teal) ──
|
||||
var (
|
||||
BgDark = lipgloss.Color("#1a1b26") // deep navy
|
||||
BgPanel = lipgloss.Color("#292e42") // panel bg / borders
|
||||
Dim = lipgloss.Color("#565f89") // muted text
|
||||
Fg = lipgloss.Color("#a9b1d6") // normal text
|
||||
Bright = lipgloss.Color("#c0caf5") // bright text
|
||||
Accent = lipgloss.Color("#7aa2f7") // blue accent
|
||||
Success = lipgloss.Color("#9ece6a") // green
|
||||
Warning = lipgloss.Color("#e0af68") // yellow
|
||||
Cyan = lipgloss.Color("#7dcfff") // cyan highlight
|
||||
Purple = lipgloss.Color("#bb9af7") // purple for selected
|
||||
Red = lipgloss.Color("#f7768e") // red
|
||||
|
||||
// ── component styles ──
|
||||
TitleStyle = lipgloss.NewStyle().Bold(true).Foreground(Accent)
|
||||
SubtitleStyle = lipgloss.NewStyle().Foreground(Dim)
|
||||
|
||||
TabActiveStyle = lipgloss.NewStyle().Bold(true).Background(BgPanel).Foreground(Bright).Padding(0, 1)
|
||||
TabInactiveStyle = lipgloss.NewStyle().Foreground(Dim).Padding(0, 1)
|
||||
TabSep = lipgloss.NewStyle().Foreground(BgPanel)
|
||||
|
||||
SelStyle = lipgloss.NewStyle().Foreground(BgDark).Background(Purple).Bold(true)
|
||||
NormStyle = lipgloss.NewStyle().Foreground(Fg)
|
||||
NumStyle = lipgloss.NewStyle().Foreground(Cyan)
|
||||
MarkStyle = lipgloss.NewStyle().Foreground(Success).Bold(true)
|
||||
|
||||
HelpStyle = lipgloss.NewStyle().Foreground(Dim)
|
||||
InputStyle = lipgloss.NewStyle().Foreground(Warning).Bold(true)
|
||||
|
||||
KeyStyle = lipgloss.NewStyle().Foreground(Dim).Width(6).Inline(true)
|
||||
ValStyle = lipgloss.NewStyle().Foreground(Fg).Inline(true)
|
||||
TechStyle = lipgloss.NewStyle().Foreground(Accent).Bold(true).Inline(true)
|
||||
DeployStyle = lipgloss.NewStyle().Foreground(Success).Inline(true)
|
||||
DetailTitle = lipgloss.NewStyle().Foreground(Cyan).Bold(true)
|
||||
)
|
||||
|
||||
var GroupStyles = map[string]lipgloss.Style{
|
||||
"CORE": lipgloss.NewStyle().Bold(true).Foreground(Red),
|
||||
"LAB": lipgloss.NewStyle().Bold(true).Foreground(Success),
|
||||
"TOOLS": lipgloss.NewStyle().Bold(true).Foreground(Warning),
|
||||
"ME": lipgloss.NewStyle().Bold(true).Foreground(Purple),
|
||||
"TEMP": lipgloss.NewStyle().Bold(true).Foreground(Cyan),
|
||||
}
|
||||
80
internal/workspace.go
Normal file
80
internal/workspace.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
"u-tabs/internal/style"
|
||||
)
|
||||
|
||||
// Workspace 工作空间定义
|
||||
type Workspace struct {
|
||||
Index int // 全局索引
|
||||
N int // 编号
|
||||
Title string // 短名
|
||||
Prompt string // 描述
|
||||
Tech string // 技术栈
|
||||
Deploy string // 部署情况
|
||||
Dir string // 目录路径
|
||||
Group string // 分组标签
|
||||
}
|
||||
|
||||
// Group 分组定义
|
||||
type Group struct {
|
||||
Label string
|
||||
Desc string
|
||||
}
|
||||
|
||||
// 运行时数据
|
||||
var Groups []Group
|
||||
var AllWorkspaces []Workspace
|
||||
var wsByNum map[int]*Workspace
|
||||
|
||||
// NoConfig 配置文件不存在标记
|
||||
var NoConfig bool
|
||||
|
||||
func InitData() {
|
||||
cfg, err := LoadConfig()
|
||||
if err != nil {
|
||||
NoConfig = true
|
||||
home, _ := os.UserHomeDir()
|
||||
Groups = []Group{{Label: "HOME", Desc: "默认"}}
|
||||
AllWorkspaces = []Workspace{
|
||||
{Index: 0, N: 0, Title: "home", Prompt: "用户主目录", Tech: "-", Deploy: "本地", Dir: home, Group: "HOME"},
|
||||
}
|
||||
wsByNum = map[int]*Workspace{0: &AllWorkspaces[0]}
|
||||
return
|
||||
}
|
||||
Groups, AllWorkspaces = cfg.ToInternal()
|
||||
wsByNum = make(map[int]*Workspace, len(AllWorkspaces))
|
||||
for i := range AllWorkspaces {
|
||||
wsByNum[AllWorkspaces[i].N] = &AllWorkspaces[i]
|
||||
}
|
||||
}
|
||||
|
||||
func WorkspacesByGroup(groupLabel string) []Workspace {
|
||||
var result []Workspace
|
||||
for _, ws := range AllWorkspaces {
|
||||
if ws.Group == groupLabel {
|
||||
result = append(result, ws)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func FindByNumber(n int) *Workspace {
|
||||
return wsByNum[n]
|
||||
}
|
||||
|
||||
// ConfigHint 配置文件提示文案
|
||||
func ConfigHint() string {
|
||||
if !NoConfig {
|
||||
return ""
|
||||
}
|
||||
home, _ := os.UserHomeDir()
|
||||
path := filepath.Join(home, ".u-tabs", "config.yaml")
|
||||
return lipgloss.NewStyle().Foreground(style.Warning).Bold(true).Render(
|
||||
fmt.Sprintf(" ⚠ 未找到配置文件,请创建: %s (参考 config.example.yaml)", path))
|
||||
}
|
||||
19
main.go
Normal file
19
main.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"charm.land/bubbletea/v2"
|
||||
"u-tabs/internal"
|
||||
)
|
||||
|
||||
func main() {
|
||||
internal.InitData()
|
||||
m := internal.NewModel()
|
||||
p := tea.NewProgram(m)
|
||||
if _, err := p.Run(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "启动失败: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user