fix(ci): format code and platform-gate the ctrl glyph in keymap test (#67)

cargo fmt over app.rs/pane.rs/settings.rs/keymap.rs (rustfmt CI job).

key_chords_splits_a_sequence_into_keycap_groups hard-coded ⌃ for the
ctrl modifier, which only renders that way on macOS; elsewhere key_tokens
maps ctrl to "Ctrl", so the test failed on the Linux and Windows runners.
Gate the expected glyph behind target_os like the existing SECONDARY and
SHIFT constants.

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
This commit is contained in:
l0ng-ai
2026-07-13 16:14:21 +08:00
committed by GitHub
co-authored by l0ng-ai
parent f25667cc1d
commit e1d502a3f2
11 changed files with 30300 additions and 80 deletions
@@ -0,0 +1,79 @@
---
name: repomix-reference-tty7
description: Reference codebase for Tty7. Use this skill when you need to understand the structure, implementation patterns, or code details of the Tty7 project.
---
# Tty7 Codebase Reference
76 files | 29369 lines | 318031 tokens
## Overview
Use this skill when you need to:
- Understand project structure and file organization
- Find where specific functionality is implemented
- Read source code for any file
- Search for code patterns or keywords
## Files
| File | Contents |
|------|----------|
| `references/summary.md` | **Start here** - Purpose, format explanation, and statistics |
| `references/project-structure.md` | Directory tree with line counts per file |
| `references/files.md` | All file contents (search with `## File: <path>`) |
| `references/tech-stacks.md` | Languages, frameworks, and dependencies per package (search with `## Tech Stack: <path>`) |
## How to Use
### 1. Find file locations
Check `project-structure.md` for the directory tree:
```
src/
index.ts (42 lines)
utils/
helpers.ts (128 lines)
```
### 2. Read file contents
Grep in `files.md` for the file path:
```
## File: src/utils/helpers.ts
```
### 3. Search for code
Grep in `files.md` for keywords:
```
function calculateTotal
```
## Common Use Cases
**Understand a feature:**
1. Search `project-structure.md` for related file names
2. Read the main implementation file in `files.md`
3. Search for imports/references to trace dependencies
**Debug an error:**
1. Grep the error message or class name in `files.md`
2. Check line counts in `project-structure.md` to find large files
**Find all usages:**
1. Grep function or variable name in `files.md`
## Tips
- Use line counts in `project-structure.md` to estimate file complexity
- Search `## File:` pattern to jump between files
- Check `summary.md` for excluded files, format details, and file statistics
- Check `tech-stacks.md` for languages, frameworks, and dependencies (search `## Tech Stack:` to list packages)
---
This skill was generated by [Repomix](https://github.com/yamadashy/repomix)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,96 @@
# Directory Structure
```
.cargo/
config.toml (5 lines)
.claude/
skills/
verify/
SKILL.md (46 lines)
.github/
ISSUE_TEMPLATE/
config.yml (8 lines)
issue.yml (43 lines)
scripts/
bundle-linux.sh (35 lines)
bundle-macos.sh (127 lines)
bundle-windows.ps1 (45 lines)
windows-installer.iss (59 lines)
workflows/
ci.yml (67 lines)
release.yml (106 lines)
dependabot.yml (22 lines)
scripts/
bench/
doom-fire-fps.patch (24 lines)
fire.sh (41 lines)
io.sh (27 lines)
mem.sh (79 lines)
README.md (142 lines)
run_one.sh (102 lines)
setup.sh (46 lines)
fig-convert/
.gitignore (8 lines)
convert.mjs (74 lines)
README.md (70 lines)
src/
core/
actions.rs (13 lines)
config.rs (897 lines)
mod.rs (29 lines)
osc.rs (248 lines)
session.rs (186 lines)
shells.rs (440 lines)
threads.rs (42 lines)
update.rs (381 lines)
daemon/
mod.rs (80 lines)
pane.rs (2145 lines)
pidfile.rs (121 lines)
protocol.rs (609 lines)
server.rs (646 lines)
shell_integration.rs (977 lines)
spawn.rs (511 lines)
transport.rs (689 lines)
winproc.rs (274 lines)
terminal/
cmd_editor.rs (641 lines)
completion.rs (732 lines)
element.rs (1625 lines)
fps.rs (157 lines)
fuzzy.rs (228 lines)
highlight.rs (170 lines)
history.rs (730 lines)
hold.rs (265 lines)
input.rs (788 lines)
mod.rs (66 lines)
palette.rs (134 lines)
remote.rs (1549 lines)
reverse_search.rs (305 lines)
search.rs (558 lines)
signature.rs (347 lines)
size.rs (34 lines)
typeahead.rs (352 lines)
view.rs (4530 lines)
ui/
app.rs (1711 lines)
hints.rs (240 lines)
home.rs (243 lines)
keymap.rs (181 lines)
mod.rs (26 lines)
palette.rs (409 lines)
pane.rs (548 lines)
presets.rs (287 lines)
settings.rs (1035 lines)
tab_strip.rs (382 lines)
theme.rs (242 lines)
main.rs (307 lines)
.gitignore (28 lines)
build.rs (31 lines)
Cargo.toml (172 lines)
CHANGELOG.md (334 lines)
LICENSE (201 lines)
README.md (148 lines)
README.zh-CN.md (137 lines)
repomix.config.json (32 lines)
```
@@ -0,0 +1,69 @@
This file is a merged representation of a subset of the codebase, containing files not matching ignore patterns, combined into a single document by Repomix.
The content has been processed where content has been compressed (code blocks are separated by ⋮---- delimiter).
# Summary
## Purpose
This is a reference codebase organized into multiple files for AI consumption.
It is designed to be easily searchable using grep and other text-based tools.
## File Structure
This skill contains the following reference files:
| File | Contents |
|------|----------|
| `project-structure.md` | Directory tree with line counts per file |
| `files.md` | All file contents (search with `## File: <path>`) |
| `tech-stacks.md` | Languages, frameworks, and dependencies per package (search with `## Tech Stack: <path>`) |
| `summary.md` | This file - purpose and format explanation |
## Usage Guidelines
- This file should be treated as read-only. Any changes should be made to the
original repository files, not this packed version.
- When processing this file, use the file path to distinguish
between different files in the repository.
- Be aware that this file may contain sensitive information. Handle it with
the same level of security as you would the original repository.
## Notes
- Some files may have been excluded based on .gitignore rules and Repomix's configuration
- Binary files are not included in this packed representation. Please refer to the Repository Structure section for a complete list of file paths, including binary files
- Files matching these patterns are excluded: target/**, dist/**, Cargo.lock, assets/**, repomix-output.*, target/**, dist/**, Cargo.lock, assets/**, repomix-output.*
- Files matching patterns in .gitignore are excluded
- Files matching default ignore patterns are excluded
- Content has been compressed - code blocks are separated by ⋮---- delimiter
- Files are sorted by Git change count (files with more changes are at the bottom)
## Statistics
76 files | 29,369 lines
| Language | Files | Lines |
|----------|------:|------:|
| Rust | 48 | 27,141 |
| Shell | 7 | 457 |
| Markdown | 6 | 877 |
| YAML | 5 | 246 |
| No Extension | 3 | 237 |
| TOML | 2 | 177 |
| ISS | 1 | 59 |
| PATCH | 1 | 24 |
| JavaScript (ESM) | 1 | 74 |
| JSON | 1 | 32 |
| Other | 1 | 45 |
**Largest files:**
- `src/terminal/view.rs` (4,530 lines)
- `src/daemon/pane.rs` (2,145 lines)
- `src/ui/app.rs` (1,711 lines)
- `src/terminal/element.rs` (1,625 lines)
- `src/terminal/remote.rs` (1,549 lines)
- `src/ui/settings.rs` (1,035 lines)
- `src/daemon/shell_integration.rs` (977 lines)
- `src/core/config.rs` (897 lines)
- `src/terminal/input.rs` (788 lines)
- `src/terminal/completion.rs` (732 lines)
@@ -0,0 +1,26 @@
# Tech Stacks
## Tech Stack: .
### Languages
- Rust
### Dependencies
- gpui
- gpui_platform
- gpui-component
- gpui-component-assets
- serde
- reqwest_client
- alacritty_terminal
- notify-rust
- notify
- portable-pty
- memchr
### Configuration Files
- .gitignore
- Cargo.toml
+117
View File
@@ -0,0 +1,117 @@
# Plan — issue #61「tmux like input model」落地方案
> Issue:<https://github.com/l0ng-ai/tty7/issues/61>
> 方向已在 issue 评论中定调:**不解析 `~/.tmux.conf`**、不改默认零配置体验;分三步 —— ① Settings 内直接改快捷键 → ② 补齐 pane/tab 操作 → ③ 一键 tmux preset。
> 本文把三步落成三个可独立发版的 PR,含文件级落点、验收标准与已核实的框架事实。
## 0. 已核实的技术事实(方案成立的前提)
| 事实 | 出处 | 对方案的意义 |
|---|---|---|
| gpui 原生支持多击序列绑定 + pending 回放:`ctrl-b n` 这类绑定会把 `ctrl-b` 置为 pending,1s 超时后 flush 并**回放**给下层(终端能收到);后继键未命中绑定时两键都回放 | zed rev `1d217ee` `gpui/src/window.rs:4744-4806` | tmux prefix 不需要自造输入模型;裸 `ctrl-b` 到 shell 有 1s 延迟(可接受,需文案说明) |
| `App::bind_keys` 后绑定优先;有 `NoAction`/`Unbind` 空绑定可压掉旧键 | `gpui/src/action.rs:4``gpui/src/keymap.rs:66,196` | 热重绑可以**只追加**:NoAction 压旧键 + 追加新表,不必 clear |
| `cx.clear_key_bindings()` 清的是**全局** keymap | `gpui/src/app.rs:2011` | 禁用——会连 gpui-component 的 input/list/menu 绑定一起清掉(`gpui-component …/src/{input/state.rs:133, list/list.rs:28, root.rs:25} …`) |
| `App::intercept_keystrokes` 在 keymap 派发**之前**执行,可吞键 | `gpui/src/app.rs:1982``gpui/src/window.rs:4742` | 录键控件的实现基础:录键时按 ⌘T 必须被录下而不是触发 NewTab |
| config 里的 keystroke 值**今天就接受序列**(按空白拆 chord 逐个 `Keystroke::parse`) | `src/ui/keymap.rs:168`(`keystroke_is_valid`) | preset 只是数据;但 `key_tokens`(`keymap.rs:105`)不会渲染序列,Settings 列表显示有缺口,需补 |
| ⌘/Ctrl+1-9 切标签是裸 `on_key_down` 硬编码,不是 action | `src/ui/app.rs:1213-1230` | 目前不可重绑、preset 也够不着;需迁移成正式 action |
| defaults→overrides 合并逻辑写了两份 | `src/ui/keymap.rs:19-29`(init)与 `src/ui/settings.rs:1302-1312`(渲染) | 先收敛成单一 `effective_bindings()`,PR 3 还要在中间插 preset 层 |
| pane 树是纯二叉 split 树,leaf 泛型可用 `Pane<u32>` 纯值测试;ratio 夹在 0.1..0.9 | `src/ui/pane.rs:25,57,404` | 方向聚焦/resize/swap 全部可做成纯树算法 + 纯值单测 |
## 1. PR 1 — Settings 内直接改快捷键(录键 + 即时生效)
**目标**:Keybindings 页从只读变可编辑;改完立即生效并 `Config::save()` 写回 `config.json`;删掉 "restart to apply" 文案。config 格式不变(`keybindings: action名 → keystroke`,`core/config.rs:49`)。
### 落点
- **`ui/keymap.rs` — 抽 `effective_bindings(cx) -> Vec<(String, String)>`**
- defaults → user overrides 的合并收敛为单一来源;`init``effective_key`、Settings 渲染全部改走它。
- **`ui/keymap.rs` — 新增 `rebind(cx)`(热更新,不 clear)**
- 记录上一份生效表(存 gpui global 或 keymap.rs 内部 static);对其中每个被改动/删除的 keystroke 追加一条 `KeyBinding::new(key, NoAction, ctx)` 压掉旧绑定,再整体追加新的生效表(后绑定优先,新表在 NoAction 之后追加即可复用同一个键)。
- keymap 仅随「用户编辑次数」线性增长,可接受;`secondary-+` 别名与 `tab`/`shift-tab` 的 Terminal 特绑(`keymap.rs:44-51`)在 rebind 中原样保留。
- **`ui/settings.rs` — Keybindings 行内编辑**
- 点击行 → 该行进入录键态(显示 "Press new shortcut… / Esc to cancel");录键期间挂 `cx.intercept_keystrokes` 吞键并记录;Esc 取消,Backspace/Delete = 清除 override 回默认。
- 归一化:录到的平台主修饰键(mac ⌘ / 其它 Ctrl)写成 `secondary`,保持 config 跨平台;其余照实写。
- **冲突检测**:新键与生效表其他 action 相同 → 行内警告 + 「覆盖并解绑原 action」二次确认(原 action 写入 NoAction 语义 = override 为空?→ 直接把原 action 的 override 设为被让出,UI 显示 "—")。
- 每行 reset 按钮 + 顶部 "Restore all defaults"(清空 `config.keybindings`)。
- 持久化路径:`cx.update_global::<Config>``Config::save()`(`core/config.rs:334`,原子写已就绪)→ `keymap::rebind(cx)``cx.notify()`
- **顺手修显示缺口**:`key_tokens` 支持空白分隔序列,渲染成两组 keycap(`⌃B` `N`);Settings/palette 的 hint 同步受益(palette 走 `effective_key`,已动态读 config,无需改)。
### 验收 & 测试
- [ ] 改键后不重启:新键立即触发、旧键立即失效;写盘后重启行为一致。
- [ ] 录键时按 ⌘T/⌘W 等被录下而非触发原 action;Esc 退出录键态无副作用。
- [ ] 单测:override 合并优先级;NoAction 压键后同键重绑生效;录键 `Keystroke` → spec 字符串 → `Keystroke::parse` 往返一致;`key_tokens` 序列渲染。
## 2. PR 2 — 补齐 pane / tab 操作(tmux 用户依赖的缺口)
**目标**:方向聚焦 / resize / swap / 相邻及编号切 tab 全部成为正式 action(可绑键、进命令面板、被 preset 引用)。
### 新 action 与实现要点
| Action(`core/actions.rs`) | 实现(落点) | 默认键 |
|---|---|---|
| `FocusPaneLeft/Right/Up/Down` | `pane.rs` 纯树算法:根 = 单位矩形,按 split 轴 + ratio 递归算每个 leaf 的归一化 rect;从焦点 leaf 沿方向找「边缘最近、垂直轴重叠最大」的 leaf(tmux 语义) | `secondary-alt-left/right/up/down` |
| `ResizePaneLeft/Right/Up/Down` | 找包含焦点 leaf 且轴向匹配的**最近祖先 split**,按焦点在 `a`/`b` 侧调 ratio ±0.05(沿用 `MIN/MAX_RATIO` 夹紧,`pane.rs:17`);结构变更后 `save_session` | 不绑(palette + 自行绑) |
| `SwapPaneNext/Prev` | 交换两个 leaf 的 payload(`Entity<TerminalView>`),焦点跟随;`maximized` 置 None | 不绑 |
| `NextTab` / `PrevTab` | `app.rs` `activate(active±1 mod len)`;目前相邻切 tab 完全没有 action | `ctrl-tab` / `ctrl-shift-tab`(需验证与系统/终端冲突,冲突则不绑) |
| `ActivateTab1..9`(9 个 unit action) | 迁移 `app.rs:1213-1230` 硬编码逻辑 → keymap 正式绑定,行为不变;删掉裸 `on_key_down` 分支 | `secondary-1..9` |
- unit action 而非带参 action:`make_binding`(`keymap.rs:181`)是「字符串 action 名 → 绑定」的模型,config/Settings 面板全按名字索引,9 个 unit struct 最贴合。
- **palette**:`ui/palette.rs``CommandKind` 补全部新 action,未绑键的操作至少可从面板执行。
- **Settings 列表**:`default_bindings()` 表加新行;未绑默认键的 action 显示 "—",靠 PR 1 的编辑器绑定。
- **文档**:README Keybindings 表 + zh-CN 同步更新。
### 验收 & 测试
- [ ] `Pane<u32>` 纯值单测:rect 计算(嵌套 split + 非 0.5 ratio)、方向查找(含并列取重叠最大)、resize 祖先选择(焦点在 `a`/`b` 两侧、无匹配轴时 no-op)、swap 后 `leaves()` 顺序。
- [ ] ⌘1-9 迁移后行为与现状逐键一致,且可在 Settings 重绑。
- [ ] 结构操作(resize/swap)后 session 持久化正确,重启还原布局。
## 3. PR 3 — 一键 tmux preset
**目标**:Settings 一键切换 tmux 风格前缀键位;纯数据层(映射表)+ 一层合并,零新输入模型。
### 落点
- **`core/config.rs`**:新增 `keybinding_preset: "default" | "tmux"`(serde 宽松解析,未知值回 default)+ `prefix: String`(默认 `"ctrl-b"`,校验 `Keystroke::parse`;常见备选 `ctrl-a`)。
- **`ui/keymap.rs`**:生效表改为三层合并 **defaults → preset(前缀代入)→ user overrides**,全走 PR 1 的 `effective_bindings()`;切换 preset = 改 config + `save()` + `rebind()`
- **preset 映射表**(tmux 肌肉记忆 → action;`P` = prefix):
| tmux | tty7 action | 备注 |
|---|---|---|
| `P c` | NewTab | |
| `P x` | CloseActiveTab | 语义即「关焦点 pane,最后一个则关 tab」(`app.rs:1913``close_pane`),与 kill-pane 吻合;`&` 不做 |
| `P %` / `P "` | SplitRight / SplitDown | |
| `P ←→↑↓` | FocusPane 方向聚焦 | PR 2 |
| `P ctrl-←→↑↓` | ResizePane 方向 | PR 2;不支持按住连发,每次完整序列(先接受) |
| `P {` / `P }` | SwapPanePrev / Next | PR 2 |
| `P z` | ToggleMaximizePane | |
| `P o` / `P ;` | FocusNextPane / FocusPrevPane | `;` 是 last-pane 的近似 |
| `P n` / `P p` | NextTab / PrevTab | PR 2 |
| `P 1..9` | ActivateTab1..9 | PR 2 |
| `P [`、send-keys、choose-tree 等 | **不做** | 无对应语义,维持 issue 回复立场 |
- **`ui/settings.rs`**:Keybindings 页顶部加 preset 选择(Default / tmux)+ prefix 选择;列表实时显示序列 keycap(PR 1 已支持渲染)。
- **文案(必须)**:开启 preset 后,裸 prefix 键到 shell 有 1s 延迟(gpui pending 超时回放);`prefix+未绑定键` 会原样回放进终端。
- **issue 收尾**:三个 PR 合入后在 #61 回复(英文)并关闭。
### 验收 & 测试
- [ ] 三层合并优先级单测:preset 覆盖 default、user override 覆盖 preset;prefix 代入后所有序列可 parse。
- [ ] 实测:开 preset 后 `ctrl-b %` 分屏、裸 `ctrl-b` 1s 后到达 readline(光标左移);关 preset 立即还原。
- [ ] 终端内 `ctrl-b` 序列 pending 期间,焦点在 settings/输入框时无异常吞键。
## 4. 风险与权衡
- **热重绑(PR 1)是全案风险最高点**:NoAction 层叠语义、intercept 吞键边界(录键中途失焦/关窗)需 dogfood;建议 PR 1 先行合入留观察期。
- **裸 prefix 1s 延迟**:tmux 用户可接受(tmux 本身就吞 prefix),但必须在 UI 与 README 写明;若反馈强烈,后续可探索缩短 gpui pending 超时(上游改动,本期不做)。
- **`ctrl-tab` 默认键**:部分平台/输入法占用,合入前逐平台验证,冲突平台不给默认键。
- **keymap 追加式增长**:仅随编辑次数增长、进程内有界;若未来做「频繁热切 preset」再考虑上游暴露分层 keymap。
## 5. 顺序与工作量
| PR | 依赖 | 估算 |
|---|---|---|
| PR 1 编辑器 + 热重绑 | — | ~400-600 行(含测试),2-3 天 |
| PR 2 pane/tab action | 与 PR 1 无硬依赖,可并行 | ~500-700 行(树算法 + 单测占大头),2-3 天 |
| PR 3 tmux preset | 依赖 PR 1(合并层/渲染)+ PR 2(action) | ~200-300 行,1 天 |
+103
View File
@@ -0,0 +1,103 @@
# Plan — 垂直标签侧边栏(vertical tab sidebar)落地方案
> 目标:在保留现有顶部水平标签条的前提下,新增一个**左侧垂直标签侧边栏**,作为标签列表的另一种呈现。
> 设计立场(借鉴 Warp `warp-src` 的做法):**模型 / 操作 / 标签文本 / 颜色 token 全部复用,只有「壳」(方向、行形状、布局位置)是新写的**。tty7 的一致性成本天然很低——`Tab` 模型、`tab_label()`、`activate/close_tab/move_tab`、`DragTab`、以及全部颜色都已走 `cx.theme()` 单一来源,不需要「抽取共享」,只需换一个方向复用。
> 本文分三个可独立发版的阶段(P0 骨架 / P1 打磨 / P2 增强),含文件级落点、验收标准与已核实的框架事实。
## 0. 已核实的技术事实(方案成立的前提)
| 事实 | 出处 | 对方案的意义 |
|---|---|---|
| `Tab { pane, name: Option<String>, settings }`——**无 id/color/pinned/group**,标签身份靠 `Vec<Tab>` 里的位置索引;active 是独立 `usize` | `src/ui/app.rs:66``:114-115` | 侧边栏直接读同一个 `self.tabs`/`self.active`,**零新状态**,任一处改动天然同步 |
| 标签文本逻辑 `tab_label(tab, i, cx)`(name → `short_title(leaf_title)``"Session N"`)与布局方向无关 | `src/ui/tab_strip.rs:151``short_title`:60 | 侧边栏**原样调用**,文本与顶部条一致 |
| 全部标签操作已是 `pub(crate)` 方法:`activate`(`app.rs:1125`)、`close_tab`(`:1161`)、`move_tab`(`:1206`)、`start_rename`(`:1237`)、`new_tab`(`:886`)、`new_tab_with_shell`(`:892`) | `src/ui/app.rs` | 侧边栏只是新增一批**点击目标**,不新增业务逻辑 |
| 顶部条颜色**全部**读命名 token,无一处 hex:active=`secondary`+`foreground`、inactive=`muted_foreground`+hover `muted`、拖放高亮=`drag_border.opacity(0.2)` | `src/ui/tab_strip.rs:316-324` | 侧边栏复用同批 token,视觉一致是**构造上保证** |
| 侧边栏专属配色 token **已存在且已接线**:`sidebar/sidebar_sel/sidebar_fg`(`presets.rs:133-135`)已喂给 gpui-component 的 `tokens.sidebar` 等(`theme.rs:156-164`) | `src/ui/presets.rs:133``src/ui/theme.rs:156` | 若想让侧边栏比正文沉一档,现成可用;⚠️ gpui-component `Sidebar``tokens.sidebar` 而非普通字段,手写 `v_flex` 则无此坑 |
| 拖拽重排今天就有:`DragTab { index, label }`(mini `Render` 预览)+ `drag_over` + `on_drop → move_tab` | `src/ui/tab_strip.rs:126-145``:323-327` | `DragTab` 提为 `pub(crate)` 即可在侧边栏复用,甚至条↔栏互拖 |
| 布局根是 `flex_col`:`TitleBar(h40).child(strip)` + `div.flex_1(body)`;body 是单一全宽子节点 | `src/ui/app.rs:2088`、TitleBar:2226、body:2238 | 侧边栏 = 把 `body` 那行包成 `h_flex`,首子放 sidebar、`body``flex_1` 第二子 |
| 顶部条**刻意不放 per-chip 图标**,label 承载整个 chip("No leading context glyph") | `src/ui/tab_strip.rs:339-341` | 侧边栏延续同样克制,行也以 label 为主,视觉才对得上 |
| config 用 `#[serde(default, deserialize_with = "de_lenient")]` 收窄枚举(如 `NewTabPosition`),未知值回默认 | `src/core/config.rs:79-84``:18` | 新增 `tab_bar_position` 枚举照抄此模式,前向兼容 |
| 持久化模型 `Session/SessionTab`(`session.rs:55-68`)同样**无 id/color/pin/group** | `src/core/session.rs:55` | P0/P1 不碰;将来若给标签加颜色/分组,须**同时**扩展 `Tab``SessionTab` |
| tmux 键位工作正在 `feat/keybindings-input-model` 分支进行(见 `plan-issue61-keybindings.md`) | 本仓库 | 新增 `ToggleTabSidebar` action 正好并入当前键位体系 |
## 决策点(需拍板,已在本方案取推荐默认)
- **❓ 模式切换 vs 并存**:本方案按**模式切换**设计(配置 `tab_bar_position: top | left`,对应 Warp 的 `uses_vertical_tabs`)——`left` 时顶部条收起 chips 只留 `+`/`⋯`,侧边栏显示列表。对终端更干净、不冗余。若要「顶部窄条 + 左侧富列表」并存,P0 的布局分叉与顶部条收起逻辑需改。**推荐:模式切换。**
- **取色策略**:P0 用**顶部条同批 token**(`secondary`/`muted`/`muted_foreground`),像素级同色最一致;是否改用专属 `sidebar*` token(IDE 活动栏观感)留到 P1 再定。**推荐:先同批 token。**
## 1. P0 — 骨架(功能完整、视觉即一致,可发版)
**目标**:新增 `tab_bar_position` 配置 + 左侧 `v_flex` 侧边栏(纯 label 行)+ 布局分叉 + 切换 action。此阶段结束,垂直模式已可用且与顶部条同源。
### 落点
- **`src/core/config.rs` — 新增 `tab_bar_position`**
- `pub enum TabBarPosition { Top, Left }`(`#[serde(rename_all="kebab-case")]` + `de_lenient`,默认 `Top`),照抄 `NewTabPosition` 模式(`config.rs:79`)。字段挂到 `Config`(`config.rs:18`)。
- **`src/core/actions.rs` — 新增 `ToggleTabSidebar`**
- 加进 `actions!` 列表(`actions.rs:8`);语义 = 在 `Top`/`Left` 间切换并 `Config::save()`
- **`src/ui/tab_sidebar.rs` — 新文件,`impl Tty7App` 块**(与 `tab_strip.rs` 完全同构的拆分)
- `fn tab_sidebar(&self, window, cx) -> impl IntoElement`:`v_flex()` 固定宽(`w(px(220.))`)、`overflow_y` 竖直滚动;遍历 `self.tabs` 渲染整行。
- 每行 `h_flex().id(("tab-row", i)).w_full().h(px(34.)).pl_3().pr_2().rounded_lg()`:
- `child(tab_label(...))` 左对齐 `.truncate()`,active 加 `FontWeight::MEDIUM`;
- active → `.bg(secondary).text_color(foreground)`;inactive → `.text_color(muted_foreground).hover(bg(muted))`(**照抄 `tab_strip.rs:316-322`**);
- 单击 `activate(i)` / 双击 `start_rename(i)`(复用 `self.renaming``Input` 分支,照抄 `tab_strip.rs:227-264`);
- 尾部 close `Button`(hover 显隐,active 常显,照抄 `tab_strip.rs:349-389`);
- `on_drag(DragTab{...})` + `drag_over` + `on_drop(move_tab)`(照抄 `tab_strip.rs:323-327`)。
- 底部或顶部放一枚 `+` 按钮,复用 `tab_strip.rs:402-475` 的 shell dropdown(可先只 `new_tab`,P1 再补 shell 菜单)。
- 空态:`self.tabs` 空时不进垂直模式(与首页逻辑一致),无需空态文案。
- **`src/ui/mod.rs`** — 挂 `mod tab_sidebar;`
- **`src/ui/app.rs::render` — 布局分叉**(`app.rs:2088``:2238`)
-`cx.global::<Config>().tab_bar_position`
- `Top`(现状):不变。
- `Left`:① `strip` 收起 chips(顶部条只渲染 `+`/`⋯`,或整体隐藏——见决策点);② 把 `.child(div().flex_1()...child(body))` 换成 `.child(h_flex().flex_1().child(self.tab_sidebar(..)).child(div().flex_1()...child(body)))`
- 根元素加 `.on_action(cx.listener(|this,_:&ToggleTabSidebar,w,cx| this.toggle_tab_sidebar(w,cx)))`(照抄 `app.rs:2128` 一众 `on_action`)。
- **`src/ui/keymap.rs`** — 给 `ToggleTabSidebar` 一个默认键(建议无默认或 `secondary-shift-e`,避免冲突;进 Settings/palette 可绑),并加进 palette 的命令目录(`ui/palette.rs`)。
### 验收 & 测试
- [ ] 配置 `tab_bar_position: left` 后启动:左侧出现标签列表,顶部条 chips 按决策收起;`top` 恢复现状。
- [ ] 侧边栏行的 active/hover/拖放高亮与顶部条**同色**(同 token,肉眼比对无差)。
- [ ] 侧边栏内:单击切换、双击改名、close 关闭、拖拽重排,行为与顶部条逐项一致(共用同一批方法)。
- [ ] `ToggleTabSidebar`(键位 + palette)即时切换并写盘;重启保持。
- [ ] 顶部/侧边两模式下 `⌘1-9``⌘T``⌘W``ctrl-tab` 等既有 action 行为不变(action 派发不受布局影响)。
## 2. P1 — 打磨(交互质感)
**目标**:侧边栏达到「可长期使用」的完成度——可改宽、宽度持久化、副标题、滚动细节。
### 落点
- **可拖拽改宽**:侧边栏右缘加拖拽条,`w` 夹在 `[180, 视窗宽*0.5]`(参考 Warp `MIN_PANEL_WIDTH`/`MAX_PANEL_WIDTH_RATIO`)。优先用 gpui-component 现成 resizable;否则手写 drag 更新一个 `sidebar_width: f32` 状态。
- **宽度持久化**:`sidebar_width` 存入 `Config`(或 session),`Config::save()`;重启还原。
- **行内副标题(可选)**:竖排空间富余,行改两行——主行 `tab_label`,副行 dimmed `muted_foreground` 的 cwd(取 `leaf_title`/cwd)。默认可先关,配置项 `sidebar_show_subtitle: bool`。若要更克制则跳过。
- **取色策略定稿**:决定沿用 `secondary`/`muted`,还是切 `sidebar*` token(`theme.rs:156-164` 已接线;注意 `tokens.sidebar` 坑)。
- **滚动 & 溢出**:标签很多时竖直滚动顺滑;active 行自动滚入可视区。
### 验收 & 测试
- [ ] 拖拽改宽夹紧上下限;宽度重启保持。
- [ ] 副标题(若启用)不撑高行、超长 `.truncate()`
- [ ] 数十个标签时滚动顺滑,切到不可见标签自动滚入视区。
## 3. P2 — 增强(可选,视需求做)
- **`DragTab` 提共享**:移到公共位置并 `pub(crate)`,支持顶部条 ↔ 侧边栏互拖(同一 payload,`move_tab` 复用)。
- **行内副标题富化**:pane 数、运行中命令等(仍保持克制,别做成 icon-per-row)。
- **右键菜单**:rename / close / duplicate / (未来)move,复用现有方法。
- **小重构(非必需)**:把顶部 chip body 的点击/改名/拖拽绑定抽成 `tab_row(tab,i,is_active)` inline helper,`h_flex``v_flex` 共调——但因这些本就是 `Tty7App` 方法,各自调也无重复逻辑,收益有限。
- **标签着色/分组(大改)**:需**同时**扩展 `Tab``SessionTab`(`session.rs:55`)加字段并处理迁移;超出本方案范围,单独立项。
## 4. 风险与权衡
- **布局分叉是主要改动面**:`app.rs::render`(`:2238`)从「TitleBar + 单 body」变「TitleBar(收起) + [sidebar | body] 行」,需确保 `maximized`、focus ring、命令面板 overlay 在两模式下都正确;建议 P0 先 dogfood 一版再上打磨。
- **顶部条收起的取舍**:`left` 模式下顶部条留 `+`/`⋯` 还是整条隐藏,影响标题栏空白与窗口拖拽区(gpui-component `TitleBar``WindowControlArea::Drag`)。倾向保留 `+`/`⋯` 一行,避免标题栏空荡。
- **无 per-tab 图标是刻意的**:侧边栏若擅自加图标会与顶部条风格背离;保持 label 优先。
- **持久化字段扩展**:P2 的着色/分组会动到 `Session` 结构,牵涉存量 `config`/session 迁移,单独发版。
## 5. 顺序与工作量
| 阶段 | 依赖 | 估算 |
|---|---|---|
| P0 骨架(配置 + `tab_sidebar.rs` + 布局分叉 + action) | — | ~300-400 行(含测试),1-2 天 |
| P1 打磨(改宽 + 持久化 + 副标题 + 取色定稿) | P0 | ~200-300 行,1-2 天 |
| P2 增强(互拖 / 右键菜单 / 着色分组) | P0(着色分组另依赖 Session 扩展) | 按选做项计,0.5-N 天 |
+56 -30
View File
@@ -22,7 +22,9 @@ use crate::terminal::view::{ChildExited, TerminalView};
use crate::ui::palette::{Command, CommandKind, PaletteEvent, PaletteView};
use crate::ui::pane::{CloseOutcome, Dir, Pane};
use crate::ui::presets::Fill;
use crate::ui::settings::{Recording, SettingsSection, SettingsState, ThemeEditor, humanize_action};
use crate::ui::settings::{
Recording, SettingsSection, SettingsState, ThemeEditor, humanize_action,
};
use crate::ui::theme::{apply_theme, set_menus};
/// One editable color of a user theme, targeted by the in-app color editor. Maps
@@ -2217,7 +2219,10 @@ impl Tty7App {
"backspace" | "delete" => {
if has_chords {
// Edit the sequence: drop the last chord and keep capturing.
if let Some(r) = self.active_settings_mut().and_then(|s| s.recording.as_mut()) {
if let Some(r) = self
.active_settings_mut()
.and_then(|s| s.recording.as_mut())
{
r.chords.pop();
}
let still_has = self
@@ -2244,7 +2249,10 @@ impl Tty7App {
let Some(spec) = crate::ui::keymap::spec_from_keystroke(keystroke) else {
return;
};
if let Some(r) = self.active_settings_mut().and_then(|s| s.recording.as_mut()) {
if let Some(r) = self
.active_settings_mut()
.and_then(|s| s.recording.as_mut())
{
r.chords.push(spec);
}
self.schedule_recording_commit(cx);
@@ -2492,27 +2500,45 @@ impl Render for Tty7App {
.on_action(cx.listener(|this, _: &ResizePaneDown, window, cx| {
this.resize_pane(Dir::Down, window, cx)
}))
.on_action(cx.listener(|this, _: &SwapPaneNext, window, cx| {
this.swap_pane(true, window, cx)
}))
.on_action(cx.listener(|this, _: &SwapPanePrev, window, cx| {
this.swap_pane(false, window, cx)
}))
.on_action(cx.listener(|this, _: &NextTab, window, cx| {
this.cycle_tab(true, window, cx)
}))
.on_action(cx.listener(|this, _: &PrevTab, window, cx| {
this.cycle_tab(false, window, cx)
}))
.on_action(cx.listener(|this, _: &ActivateTab1, window, cx| this.activate(0, window, cx)))
.on_action(cx.listener(|this, _: &ActivateTab2, window, cx| this.activate(1, window, cx)))
.on_action(cx.listener(|this, _: &ActivateTab3, window, cx| this.activate(2, window, cx)))
.on_action(cx.listener(|this, _: &ActivateTab4, window, cx| this.activate(3, window, cx)))
.on_action(cx.listener(|this, _: &ActivateTab5, window, cx| this.activate(4, window, cx)))
.on_action(cx.listener(|this, _: &ActivateTab6, window, cx| this.activate(5, window, cx)))
.on_action(cx.listener(|this, _: &ActivateTab7, window, cx| this.activate(6, window, cx)))
.on_action(cx.listener(|this, _: &ActivateTab8, window, cx| this.activate(7, window, cx)))
.on_action(cx.listener(|this, _: &ActivateTab9, window, cx| this.activate(8, window, cx)))
.on_action(
cx.listener(|this, _: &SwapPaneNext, window, cx| this.swap_pane(true, window, cx)),
)
.on_action(
cx.listener(|this, _: &SwapPanePrev, window, cx| this.swap_pane(false, window, cx)),
)
.on_action(
cx.listener(|this, _: &NextTab, window, cx| this.cycle_tab(true, window, cx)),
)
.on_action(
cx.listener(|this, _: &PrevTab, window, cx| this.cycle_tab(false, window, cx)),
)
.on_action(
cx.listener(|this, _: &ActivateTab1, window, cx| this.activate(0, window, cx)),
)
.on_action(
cx.listener(|this, _: &ActivateTab2, window, cx| this.activate(1, window, cx)),
)
.on_action(
cx.listener(|this, _: &ActivateTab3, window, cx| this.activate(2, window, cx)),
)
.on_action(
cx.listener(|this, _: &ActivateTab4, window, cx| this.activate(3, window, cx)),
)
.on_action(
cx.listener(|this, _: &ActivateTab5, window, cx| this.activate(4, window, cx)),
)
.on_action(
cx.listener(|this, _: &ActivateTab6, window, cx| this.activate(5, window, cx)),
)
.on_action(
cx.listener(|this, _: &ActivateTab7, window, cx| this.activate(6, window, cx)),
)
.on_action(
cx.listener(|this, _: &ActivateTab8, window, cx| this.activate(7, window, cx)),
)
.on_action(
cx.listener(|this, _: &ActivateTab9, window, cx| this.activate(8, window, cx)),
)
.on_action(cx.listener(|this, _: &IncreaseFontSize, _window, cx| {
this.change_font_size(FONT_SIZE_STEP, cx)
}))
@@ -2781,11 +2807,7 @@ mod keybinding_gpui_tests {
}
/// Open Settings → Keybindings and begin capturing `action`.
fn begin_capture(
window: &WindowHandle<Tty7App>,
vcx: &mut VisualTestContext,
action: &str,
) {
fn begin_capture(window: &WindowHandle<Tty7App>, vcx: &mut VisualTestContext, action: &str) {
let action = action.to_string();
window
.update(vcx, |app, window, cx| {
@@ -2831,7 +2853,11 @@ mod keybinding_gpui_tests {
app.active_settings().map(|s| s.recording.is_some())
})
.unwrap();
assert_eq!(recording, Some(false), "capture should end after committing");
assert_eq!(
recording,
Some(false),
"capture should end after committing"
);
}
// A two-chord sequence (the tmux-style `ctrl-b x`) records as one binding.
+12 -2
View File
@@ -453,6 +453,11 @@ mod tests {
const SHIFT: &str = "";
#[cfg(not(target_os = "macos"))]
const SHIFT: &str = "Shift";
// Literal `ctrl` renders as ⌃ on macOS, "Ctrl" elsewhere.
#[cfg(target_os = "macos")]
const CTRL: &str = "";
#[cfg(not(target_os = "macos"))]
const CTRL: &str = "Ctrl";
#[test]
fn key_tokens_maps_modifiers_to_glyphs() {
@@ -475,7 +480,10 @@ mod tests {
// A tmux-style sequence renders as two distinct clusters.
assert_eq!(
key_chords("ctrl-b n"),
vec![vec!["".to_string(), "B".to_string()], vec!["N".to_string()]]
vec![
vec![CTRL.to_string(), "B".to_string()],
vec!["N".to_string()]
]
);
// A single chord is one group.
assert_eq!(key_chords("secondary-t"), vec![vec![SECONDARY, "T"]]);
@@ -594,7 +602,9 @@ mod gpui_tests {
rebind(cx);
let eff = effective_bindings(cx);
assert_eq!(
eff.iter().find(|(a, _)| a == "SplitRight").map(|(_, k)| k.as_str()),
eff.iter()
.find(|(a, _)| a == "SplitRight")
.map(|(_, k)| k.as_str()),
Some("secondary-d")
);
});
+46 -8
View File
@@ -266,7 +266,9 @@ impl<L: Clone> Pane<L> {
fn collect_rects(&self, area: Rect, out: &mut Vec<(L, Rect)>) {
match self {
Pane::Leaf(v) => out.push((v.clone(), area)),
Pane::Split { axis, a, b, ratio, .. } => {
Pane::Split {
axis, a, b, ratio, ..
} => {
let r = ratio.get().clamp(MIN_RATIO, MAX_RATIO);
match axis {
Axis::Horizontal => {
@@ -1021,7 +1023,10 @@ mod tests {
fn assert_rect(got: Rect, want: Rect) {
let close = |a: f32, b: f32| (a - b).abs() < 1e-5;
assert!(
close(got.x, want.x) && close(got.y, want.y) && close(got.w, want.w) && close(got.h, want.h),
close(got.x, want.x)
&& close(got.y, want.y)
&& close(got.w, want.w)
&& close(got.h, want.h),
"rect {got:?} != {want:?}"
);
}
@@ -1038,12 +1043,39 @@ mod tests {
Pane::Leaf(0),
TestPane::split_node(Axis::Vertical, 0.6, Pane::Leaf(1), Pane::Leaf(2)),
);
assert_rect(rect_of(&pane, 0), Rect { x: 0.0, y: 0.0, w: 0.25, h: 1.0 });
assert_rect(rect_of(&pane, 1), Rect { x: 0.25, y: 0.0, w: 0.75, h: 0.6 });
assert_rect(rect_of(&pane, 2), Rect { x: 0.25, y: 0.6, w: 0.75, h: 0.4 });
assert_rect(
rect_of(&pane, 0),
Rect {
x: 0.0,
y: 0.0,
w: 0.25,
h: 1.0,
},
);
assert_rect(
rect_of(&pane, 1),
Rect {
x: 0.25,
y: 0.0,
w: 0.75,
h: 0.6,
},
);
assert_rect(
rect_of(&pane, 2),
Rect {
x: 0.25,
y: 0.6,
w: 0.75,
h: 0.4,
},
);
// Rects come back in leaves() order.
assert_eq!(
pane.leaf_rects().iter().map(|(v, _)| *v).collect::<Vec<_>>(),
pane.leaf_rects()
.iter()
.map(|(v, _)| *v)
.collect::<Vec<_>>(),
pane.leaves()
);
}
@@ -1129,10 +1161,16 @@ mod tests {
// Inner split moved; outer untouched.
match &pane {
Pane::Split { ratio, b, .. } => {
assert!((ratio.get() - 0.5).abs() < 1e-6, "outer split must not move");
assert!(
(ratio.get() - 0.5).abs() < 1e-6,
"outer split must not move"
);
match &**b {
Pane::Split { ratio, .. } => {
assert!((ratio.get() - 0.55).abs() < 1e-6, "inner split should grow 1");
assert!(
(ratio.get() - 0.55).abs() < 1e-6,
"inner split should grow 1"
);
}
_ => unreachable!(),
}
+22 -40
View File
@@ -1428,11 +1428,7 @@ impl Tty7App {
// and the wider picker instead of clustering at the left edge. Rows stay
// ragged-right like real terminal text.
let bar = |frac: f32, color: gpui::Rgba| {
div()
.h(px(4.))
.w(relative(frac))
.rounded(px(1.5))
.bg(color)
div().h(px(4.)).w(relative(frac)).rounded(px(1.5)).bg(color)
};
v_flex()
@@ -1736,25 +1732,19 @@ impl Tty7App {
};
// A preset toggle button, highlighted when active.
let preset_button = |id: &'static str, label: &'static str, value: &'static str, on: bool| {
Button::new(id)
.label(label)
.small()
.selected(on)
.on_click(cx.listener(move |this, _, _w, cx| {
this.set_keybinding_preset(value, cx)
}))
};
let preset_button =
|id: &'static str, label: &'static str, value: &'static str, on: bool| {
Button::new(id).label(label).small().selected(on).on_click(
cx.listener(move |this, _, _w, cx| this.set_keybinding_preset(value, cx)),
)
};
// A prefix choice button (tmux preset only).
let prefix_button = |id: &'static str, label: &'static str, value: &'static str, on: bool| {
Button::new(id)
.label(label)
.small()
.selected(on)
.on_click(cx.listener(move |this, _, _w, cx| {
this.set_keybinding_prefix(value, cx)
}))
};
let prefix_button =
|id: &'static str, label: &'static str, value: &'static str, on: bool| {
Button::new(id).label(label).small().selected(on).on_click(
cx.listener(move |this, _, _w, cx| this.set_keybinding_prefix(value, cx)),
)
};
let preset_row = h_flex()
.items_center()
@@ -1812,9 +1802,7 @@ impl Tty7App {
let count = effective.len();
let mut list = v_flex().mt_2();
for (i, (action, key)) in effective.into_iter().enumerate() {
let is_recording = recording
.as_ref()
.is_some_and(|(a, _)| a == &action);
let is_recording = recording.as_ref().is_some_and(|(a, _)| a == &action);
let is_overridden = overridden.contains(&action);
// Keycap clusters for a spec: one cluster per whitespace-separated
@@ -1837,12 +1825,7 @@ impl Tty7App {
.unwrap_or_default();
let row = h_flex().gap_2().items_center();
let row = if chords.is_empty() {
row.child(
div()
.text_xs()
.text_color(accent)
.child("Press keys…"),
)
row.child(div().text_xs().text_color(accent).child("Press keys…"))
} else {
row.child(keycaps(&chords.join(" "))).child(
div()
@@ -1873,9 +1856,7 @@ impl Tty7App {
.py_1()
.rounded_md()
.cursor_pointer()
.when(is_recording, |d| {
d.border_1().border_color(accent)
})
.when(is_recording, |d| d.border_1().border_color(accent))
.hover(|d| d.bg(kbd_bg))
.child(captured)
.on_click(cx.listener(move |this, _, window, cx| {
@@ -1883,9 +1864,11 @@ impl Tty7App {
}));
let action_for_reset = action.clone();
let right = h_flex().items_center().gap_1().child(capture).when(
is_overridden,
|r| {
let right = h_flex()
.items_center()
.gap_1()
.child(capture)
.when(is_overridden, |r| {
r.child(
Button::new(SharedString::from(format!("reset-{action}")))
.label("Reset")
@@ -1894,8 +1877,7 @@ impl Tty7App {
this.reset_keybinding(action_for_reset.clone(), cx)
})),
)
},
);
});
list = list.child(
h_flex()