From a027fe17033eeb71d9c2dc524440b1a7232a663b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BB=9D=E5=B0=98?= <237809796@qq.com> Date: Sat, 16 May 2026 21:01:03 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E:=20u-tabs=20=E5=88=9D?= =?UTF-8?q?=E5=A7=8B=E7=89=88=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Go TUI 项目启动器,基于 bubbletea v2 + lipgloss v2。 支持分组 Tab、多选启动、编号跳转、Windows Terminal 集成。 --- README.md | 56 ++++ config.example.yaml | 50 ++++ docs/00-文档目录.md | 73 +++++ docs/02-技术文档/架构设计.md | 222 ++++++++++++++ docs/2026-05-16-备忘录-历史会话功能.md | 245 ++++++++++++++++ go.mod | 27 ++ go.sum | 44 +++ internal/app.go | 387 +++++++++++++++++++++++++ internal/config.go | 103 +++++++ internal/style/style.go | 50 ++++ internal/workspace.go | 80 +++++ main.go | 19 ++ 12 files changed, 1356 insertions(+) create mode 100644 README.md create mode 100644 config.example.yaml create mode 100644 docs/00-文档目录.md create mode 100644 docs/02-技术文档/架构设计.md create mode 100644 docs/2026-05-16-备忘录-历史会话功能.md create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/app.go create mode 100644 internal/config.go create mode 100644 internal/style/style.go create mode 100644 internal/workspace.go create mode 100644 main.go diff --git a/README.md b/README.md new file mode 100644 index 0000000..0beb237 --- /dev/null +++ b/README.md @@ -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) diff --git a/config.example.yaml b/config.example.yaml new file mode 100644 index 0000000..933a119 --- /dev/null +++ b/config.example.yaml @@ -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: "~" diff --git a/docs/00-文档目录.md b/docs/00-文档目录.md new file mode 100644 index 0000000..608a8b6 --- /dev/null +++ b/docs/00-文档目录.md @@ -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 脚本 +``` diff --git a/docs/02-技术文档/架构设计.md b/docs/02-技术文档/架构设计.md new file mode 100644 index 0000000..1153e48 --- /dev/null +++ b/docs/02-技术文档/架构设计.md @@ -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 | diff --git a/docs/2026-05-16-备忘录-历史会话功能.md b/docs/2026-05-16-备忘录-历史会话功能.md new file mode 100644 index 0000000..b4237f4 --- /dev/null +++ b/docs/2026-05-16-备忘录-历史会话功能.md @@ -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 ` 恢复会话 +- 通过 `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/<编码目录>/.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 `: + +```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,二次进入从缓存加载 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..688deb2 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..34bfc4f --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/app.go b/internal/app.go new file mode 100644 index 0000000..ce580ea --- /dev/null +++ b/internal/app.go @@ -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()) +} diff --git a/internal/config.go b/internal/config.go new file mode 100644 index 0000000..c03b4f5 --- /dev/null +++ b/internal/config.go @@ -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 +} diff --git a/internal/style/style.go b/internal/style/style.go new file mode 100644 index 0000000..0abbf30 --- /dev/null +++ b/internal/style/style.go @@ -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), +} diff --git a/internal/workspace.go b/internal/workspace.go new file mode 100644 index 0000000..68315fb --- /dev/null +++ b/internal/workspace.go @@ -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)) +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..e1762b3 --- /dev/null +++ b/main.go @@ -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) + } +}