11
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
/cache
|
||||
/project.local.yml
|
||||
@@ -0,0 +1,133 @@
|
||||
# the name by which the project can be referenced within Serena
|
||||
project_name: "superwork"
|
||||
|
||||
|
||||
# list of languages for which language servers are started; choose from:
|
||||
# al angular ansible bash clojure
|
||||
# cpp cpp_ccls crystal csharp csharp_omnisharp
|
||||
# dart elixir elm erlang fortran
|
||||
# fsharp go groovy haskell haxe
|
||||
# hlsl html java json julia
|
||||
# kotlin lean4 lua luau markdown
|
||||
# matlab msl nix ocaml pascal
|
||||
# perl php php_phpactor powershell python
|
||||
# python_jedi python_ty r rego ruby
|
||||
# ruby_solargraph rust scala scss solidity
|
||||
# svelte swift systemverilog terraform toml
|
||||
# typescript typescript_vts vue yaml zig
|
||||
# (This list may be outdated. For the current list, see values of Language enum here:
|
||||
# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py
|
||||
# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.)
|
||||
# Note:
|
||||
# - For C, use cpp
|
||||
# - For JavaScript, use typescript
|
||||
# - For Angular projects, use angular (subsumes typescript+html; requires `npm install` in the project root)
|
||||
# - For Svelte projects, use svelte (subsumes typescript/javascript for .svelte projects; requires npm)
|
||||
# - For SCSS / Sass / plain CSS, use scss (some-sass-language-server handles all three)
|
||||
# - For Free Pascal/Lazarus, use pascal
|
||||
# Special requirements:
|
||||
# Some languages require additional setup/installations.
|
||||
# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers
|
||||
# When using multiple languages, the first language server that supports a given file will be used for that file.
|
||||
# The first language is the default language and the respective language server will be used as a fallback.
|
||||
# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
|
||||
languages:
|
||||
- go
|
||||
|
||||
# the encoding used by text files in the project
|
||||
# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings
|
||||
encoding: "utf-8"
|
||||
|
||||
# line ending convention to use when writing source files.
|
||||
# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default)
|
||||
# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings.
|
||||
line_ending:
|
||||
|
||||
# The language backend to use for this project.
|
||||
# If not set, the global setting from serena_config.yml is used.
|
||||
# Valid values: LSP, JetBrains
|
||||
# Note: the backend is fixed at startup. If a project with a different backend
|
||||
# is activated post-init, an error will be returned.
|
||||
language_backend:
|
||||
|
||||
# whether to use project's .gitignore files to ignore files
|
||||
ignore_all_files_in_gitignore: true
|
||||
|
||||
# advanced configuration option allowing to configure language server-specific options.
|
||||
# Maps the language key to the options.
|
||||
# Have a look at the docstring of the constructors of the LS implementations within solidlsp (e.g., for C# or PHP) to see which options are available.
|
||||
# No documentation on options means no options are available.
|
||||
ls_specific_settings: {}
|
||||
|
||||
# list of additional workspace folder paths for cross-package reference support (e.g. in monorepos).
|
||||
# Paths can be absolute or relative to the project root.
|
||||
# Each folder is registered as an LSP workspace folder, enabling language servers to discover
|
||||
# symbols and references across package boundaries.
|
||||
# Currently supported for: TypeScript.
|
||||
# Example:
|
||||
# additional_workspace_folders:
|
||||
# - ../sibling-package
|
||||
# - ../shared-lib
|
||||
additional_workspace_folders: []
|
||||
|
||||
# list of additional paths to ignore in this project.
|
||||
# Same syntax as gitignore, so you can use * and **.
|
||||
# Note: global ignored_paths from serena_config.yml are also applied additively.
|
||||
ignored_paths: []
|
||||
|
||||
# whether the project is in read-only mode
|
||||
# If set to true, all editing tools will be disabled and attempts to use them will result in an error
|
||||
# Added on 2025-04-18
|
||||
read_only: false
|
||||
|
||||
# list of tool names to exclude.
|
||||
# This extends the existing exclusions (e.g. from the global configuration)
|
||||
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
|
||||
excluded_tools: []
|
||||
|
||||
# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default).
|
||||
# This extends the existing inclusions (e.g. from the global configuration).
|
||||
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
|
||||
included_optional_tools: []
|
||||
|
||||
# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools.
|
||||
# This cannot be combined with non-empty excluded_tools or included_optional_tools.
|
||||
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
|
||||
fixed_tools: []
|
||||
|
||||
# list of mode names that are to be activated by default, overriding the setting in the global configuration.
|
||||
# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes.
|
||||
# If the setting is undefined/empty, the default_modes from the global configuration (serena_config.yml) apply.
|
||||
# Otherwise, this overrides the setting from the global configuration (serena_config.yml).
|
||||
# Therefore, you can set this to [] if you do not want the default modes defined in the global config to apply
|
||||
# for this project.
|
||||
# This setting can, in turn, be overridden by CLI parameters (--mode).
|
||||
# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes
|
||||
default_modes:
|
||||
|
||||
# list of mode names to be activated additionally for this project, e.g. ["query-projects"]
|
||||
# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes.
|
||||
# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes
|
||||
added_modes:
|
||||
|
||||
# initial prompt for the project. It will always be given to the LLM upon activating the project
|
||||
# (contrary to the memories, which are loaded on demand).
|
||||
initial_prompt: ""
|
||||
|
||||
# time budget (seconds) per tool call for the retrieval of additional symbol information
|
||||
# such as docstrings or parameter information.
|
||||
# This overrides the corresponding setting in the global configuration; see the documentation there.
|
||||
# If null or missing, use the setting from the global configuration.
|
||||
symbol_info_budget:
|
||||
|
||||
# list of regex patterns which, when matched, mark a memory entry as read‑only.
|
||||
# Extends the list from the global configuration, merging the two lists.
|
||||
read_only_memory_patterns: []
|
||||
|
||||
# list of regex patterns for memories to completely ignore.
|
||||
# Matching memories will not appear in list_memories or activate_project output
|
||||
# and cannot be accessed via read_memory or write_memory.
|
||||
# To access ignored memory files, use the read_file tool on the raw file path.
|
||||
# Extends the list from the global configuration, merging the two lists.
|
||||
# Example: ["_archive/.*", "_episodes/.*"]
|
||||
ignored_memory_patterns: []
|
||||
+193
-14
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
@@ -117,7 +118,7 @@ var (
|
||||
|
||||
// detailBody builds the two stacked blocks (header + STATE JSON table) shown for
|
||||
// an issue, sized to the given outer width.
|
||||
func detailBody(iss issue.Issue, width int) string {
|
||||
func (m Model) detailBody(iss issue.Issue, width int) string {
|
||||
box := lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(detailBorderColor).
|
||||
@@ -144,9 +145,8 @@ func detailBody(iss issue.Issue, width int) string {
|
||||
}
|
||||
header := lipgloss.JoinVertical(lipgloss.Left,
|
||||
cell(title, false),
|
||||
cell("", false),
|
||||
cell(hint.Render("[b] 头脑风暴 [i] 实施 [v] 审查 [t] 测试 [p] PR提交 [g] PR摘要"), false),
|
||||
cell(hint.Render("[f] 配置 [F] 测试配置 [o] 打开文件 [c] 关闭 [d] 删除 esc 返回"), false),
|
||||
cell(hint.Render("↑↓/jk 选择 ←→/hl 列 H/L 移动 c 关闭 d 删除 p 前置 P 清前置 n 新建"), false),
|
||||
cell(m.actionBarView(), false),
|
||||
)
|
||||
headerBox := box.Render(header)
|
||||
|
||||
@@ -178,22 +178,201 @@ func detailBody(iss issue.Issue, width int) string {
|
||||
return lipgloss.JoinVertical(lipgloss.Left, headerBox, tableBox)
|
||||
}
|
||||
|
||||
// detailView renders the issue detail as a centered modal (two stacked blocks)
|
||||
// floating over the board via lipgloss v2 layer compositing.
|
||||
// detailView renders the board above a bottom issue property panel, matching
|
||||
// the VSCode extension's kanban + attributes split.
|
||||
func (m Model) detailView() string {
|
||||
bg := m.boardView()
|
||||
if m.detailIss == nil {
|
||||
return bg
|
||||
iss, ok := m.currentDetailIssue()
|
||||
if !ok {
|
||||
return m.boardView()
|
||||
}
|
||||
|
||||
width := 84
|
||||
if m.width > 0 && m.width-8 < width {
|
||||
width = m.width - 8
|
||||
return m.boardWithDetailView(iss)
|
||||
}
|
||||
|
||||
func (m Model) boardWithDetailView(iss issue.Issue) string {
|
||||
width := m.width - 2
|
||||
if m.width <= 0 {
|
||||
width = 100
|
||||
}
|
||||
if width < 40 {
|
||||
width = 40
|
||||
}
|
||||
|
||||
modal := detailBody(*m.detailIss, width)
|
||||
return placeOverlayCenter(bg, modal)
|
||||
panel := m.bottomTabsPanel(iss, width)
|
||||
panelH := lipgloss.Height(panel)
|
||||
|
||||
statusLine := ""
|
||||
indicator := ""
|
||||
if m.hasChanges {
|
||||
indicator = " " + confirmStyle.Render("● 未提交")
|
||||
}
|
||||
if status := m.statusBar(); status != "" {
|
||||
statusLine = status + indicator
|
||||
} else if indicator != "" {
|
||||
statusLine = indicator
|
||||
}
|
||||
|
||||
reserved := panelH + 1
|
||||
if statusLine != "" {
|
||||
reserved++
|
||||
}
|
||||
boardH := m.height - reserved
|
||||
if m.height == 0 {
|
||||
boardH = 12
|
||||
}
|
||||
if boardH < 5 {
|
||||
boardH = 5
|
||||
}
|
||||
|
||||
parts := []string{m.boardOnlyView(boardH)}
|
||||
if statusLine != "" {
|
||||
parts = append(parts, statusLine)
|
||||
}
|
||||
parts = append(parts, panel)
|
||||
return lipgloss.JoinVertical(lipgloss.Left, parts...)
|
||||
}
|
||||
|
||||
func (m Model) bottomTabsPanel(iss issue.Issue, width int) string {
|
||||
tabs := m.bottomTabsHeader(width)
|
||||
content := ""
|
||||
switch m.bottomTab {
|
||||
case bottomTabProfile:
|
||||
content = m.profileTabBody(width)
|
||||
case bottomTabSessions:
|
||||
content = m.sessionsTabBody(width)
|
||||
case bottomTabCommits:
|
||||
content = m.commitsTabBody(width)
|
||||
default:
|
||||
content = m.detailBody(iss, width)
|
||||
}
|
||||
return lipgloss.JoinVertical(lipgloss.Left, tabs, content)
|
||||
}
|
||||
|
||||
func (m Model) bottomTabsHeader(width int) string {
|
||||
labels := []struct {
|
||||
tab bottomTab
|
||||
label string
|
||||
}{
|
||||
{bottomTabIssue, "1 工单"},
|
||||
{bottomTabProfile, "2 Profile"},
|
||||
{bottomTabSessions, "3 会话"},
|
||||
{bottomTabCommits, "4 提交"},
|
||||
}
|
||||
var parts []string
|
||||
for _, item := range labels {
|
||||
prefix := " "
|
||||
if m.focusPane == focusPanel && m.bottomTab == item.tab {
|
||||
prefix = "▸"
|
||||
}
|
||||
text := prefix + item.label + " "
|
||||
if m.bottomTab == item.tab {
|
||||
parts = append(parts, selectedCardStyle.Render(text))
|
||||
} else {
|
||||
parts = append(parts, helpStyle.Render(text))
|
||||
}
|
||||
}
|
||||
line := strings.Join(parts, " ")
|
||||
hint := helpStyle.Render(" Tab 切焦点;面板聚焦后 ↑↓/←→ 切 tab;点击区域也可切焦点")
|
||||
if lipgloss.Width(line)+lipgloss.Width(hint) < width {
|
||||
line += hint
|
||||
}
|
||||
return lipgloss.NewStyle().Width(width).Render(line)
|
||||
}
|
||||
|
||||
func (m Model) tabBox(title string, lines []string, width int) string {
|
||||
box := lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(detailBorderColor).
|
||||
Width(width)
|
||||
titleLine := lipgloss.NewStyle().Bold(true).Render(title)
|
||||
body := append([]string{titleLine, ""}, lines...)
|
||||
return box.Render(lipgloss.JoinVertical(lipgloss.Left, body...))
|
||||
}
|
||||
|
||||
func (m Model) profileTabBody(width int) string {
|
||||
lines := []string{
|
||||
helpStyle.Render("只读摘要;按 G 打开 Profile 表格编辑器"),
|
||||
}
|
||||
if len(m.profileGridData.Profiles) == 0 {
|
||||
lines = append(lines, "未加载 Profile 数据,按 2 重新加载")
|
||||
return m.tabBox("Profile", lines, width)
|
||||
}
|
||||
lines = append(lines, "Profiles: "+strings.Join(m.profileGridData.Profiles, ", "))
|
||||
if len(m.profileGridData.Rows) == 0 {
|
||||
lines = append(lines, "暂无配置行")
|
||||
return m.tabBox("Profile", lines, width)
|
||||
}
|
||||
maxRows := 8
|
||||
for i, row := range m.profileGridData.Rows {
|
||||
if i >= maxRows {
|
||||
lines = append(lines, fmt.Sprintf("... 还有 %d 行", len(m.profileGridData.Rows)-maxRows))
|
||||
break
|
||||
}
|
||||
var vals []string
|
||||
for _, prof := range m.profileGridData.Profiles {
|
||||
vals = append(vals, fmt.Sprintf("%s=%s", prof, orDash(row.Values[prof])))
|
||||
}
|
||||
lines = append(lines, truncate(row.Key+" "+strings.Join(vals, " "), width-6))
|
||||
}
|
||||
return m.tabBox("Profile", lines, width)
|
||||
}
|
||||
|
||||
func (m Model) sessionsTabBody(width int) string {
|
||||
lines := []string{
|
||||
helpStyle.Render("只读摘要;按 m 打开会话管理器"),
|
||||
}
|
||||
if len(m.managedSessions.Sessions) == 0 {
|
||||
lines = append(lines, "暂无托管会话")
|
||||
return m.tabBox("会话", lines, width)
|
||||
}
|
||||
maxRows := 8
|
||||
for i, sess := range m.managedSessions.Sessions {
|
||||
if i >= maxRows {
|
||||
lines = append(lines, fmt.Sprintf("... 还有 %d 个会话", len(m.managedSessions.Sessions)-maxRows))
|
||||
break
|
||||
}
|
||||
ts := time.Unix(sess.CreatedAt, 0).Format("2006-01-02 15:04")
|
||||
id := sess.ID
|
||||
if len(id) > 12 {
|
||||
id = id[:12]
|
||||
}
|
||||
lines = append(lines, truncate(fmt.Sprintf("%s %s %s %s", sess.Name, id, profileDisplay(sess.ProfilePath), ts), width-6))
|
||||
}
|
||||
return m.tabBox("会话", lines, width)
|
||||
}
|
||||
|
||||
func (m Model) commitsTabBody(width int) string {
|
||||
lines := []string{
|
||||
helpStyle.Render("只读摘要;按 R 打开 PR 提交确认面板"),
|
||||
}
|
||||
if m.prCommitsIss == nil || m.prCommitsIss.PR == "" {
|
||||
lines = append(lines, "当前工单无关联 PR")
|
||||
return m.tabBox("提交", lines, width)
|
||||
}
|
||||
if m.prCommitsErr != nil {
|
||||
lines = append(lines, errorStyle.Render(m.prCommitsErr.Error()))
|
||||
return m.tabBox(fmt.Sprintf("PR #%s 提交", m.prCommitsIss.PR), lines, width)
|
||||
}
|
||||
if !m.prCommitsLoaded {
|
||||
lines = append(lines, "加载中...")
|
||||
return m.tabBox(fmt.Sprintf("PR #%s 提交", m.prCommitsIss.PR), lines, width)
|
||||
}
|
||||
maxCommits := 5
|
||||
lines = append(lines, fmt.Sprintf("Commits (%d):", len(m.prCommits)))
|
||||
for i, c := range m.prCommits {
|
||||
if i >= maxCommits {
|
||||
lines = append(lines, fmt.Sprintf("... 还有 %d 个 commit", len(m.prCommits)-maxCommits))
|
||||
break
|
||||
}
|
||||
mark := " "
|
||||
if m.prConfirmedSHAs[c.SHA] {
|
||||
mark = "x"
|
||||
}
|
||||
lines = append(lines, truncate(fmt.Sprintf("[%s] %s %s", mark, shortSHA(c.SHA), firstLine(c.Commit.Message)), width-6))
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf("Files: %d", len(m.prFiles)))
|
||||
if allFilesConfirmed(m.prFiles, m.prConfirmed) && len(m.prFiles) > 0 {
|
||||
lines = append(lines, confirmStyle.Render("所有文件已确认"))
|
||||
}
|
||||
return m.tabBox(fmt.Sprintf("PR #%s 提交", m.prCommitsIss.PR), lines, width)
|
||||
}
|
||||
|
||||
@@ -4,8 +4,10 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
"superwork-tui/internal/issue"
|
||||
"superwork-tui/internal/store"
|
||||
)
|
||||
|
||||
func TestDetailBody(t *testing.T) {
|
||||
@@ -31,7 +33,7 @@ func TestDetailBody(t *testing.T) {
|
||||
AutoReview: &autoReview,
|
||||
}
|
||||
|
||||
out := detailBody(iss, 80)
|
||||
out := Model{}.detailBody(iss, 80)
|
||||
|
||||
checks := []string{
|
||||
"#42",
|
||||
@@ -58,10 +60,9 @@ func TestDetailBody(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestDetailModal_OverlaysBoard verifies the detail view is a centered modal
|
||||
// composited over the board (true overlay), not a full-screen replacement, and
|
||||
// that it never overflows the terminal height.
|
||||
func TestDetailModal_OverlaysBoard(t *testing.T) {
|
||||
// TestDetailView_RendersBoardAbovePropertyPanel verifies the detail view keeps
|
||||
// the kanban board visible above a bottom property panel, matching VSCode.
|
||||
func TestDetailView_RendersBoardAbovePropertyPanel(t *testing.T) {
|
||||
var issues []issue.Issue
|
||||
for i := 0; i < 11; i++ {
|
||||
issues = append(issues, issue.Issue{Number: 238 - i, Title: "支持部署时使用客户自定义域名", Column: issue.ColumnTodo})
|
||||
@@ -81,18 +82,201 @@ func TestDetailModal_OverlaysBoard(t *testing.T) {
|
||||
out := m.detailView()
|
||||
|
||||
if !strings.Contains(out, "#238") {
|
||||
t.Errorf("modal title missing")
|
||||
t.Errorf("detail title missing")
|
||||
}
|
||||
if !strings.Contains(out, "STATE JSON") {
|
||||
t.Errorf("STATE JSON section missing")
|
||||
}
|
||||
if !strings.Contains(out, "todo (11)") {
|
||||
t.Errorf("board background (todo header) not visible behind modal — not a true overlay")
|
||||
t.Errorf("board header not visible above detail panel")
|
||||
}
|
||||
if !strings.Contains(out, "╭") {
|
||||
t.Errorf("modal rounded border missing")
|
||||
t.Errorf("detail panel rounded border missing")
|
||||
}
|
||||
boardIdx := strings.Index(out, "todo (11)")
|
||||
panelIdx := strings.Index(out, "STATE JSON")
|
||||
if boardIdx == -1 || panelIdx == -1 || boardIdx > panelIdx {
|
||||
t.Errorf("detail view should render board before property panel")
|
||||
}
|
||||
if h := lipgloss.Height(out); h > m.height {
|
||||
t.Errorf("detail overlay height %d exceeds terminal height %d", h, m.height)
|
||||
t.Errorf("detail split height %d exceeds terminal height %d", h, m.height)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBoardView_DefaultDetailPanelFollowsSelection(t *testing.T) {
|
||||
issues := []issue.Issue{
|
||||
{Number: 1, Title: "First issue", Column: issue.ColumnTodo},
|
||||
{Number: 2, Title: "Second issue", Column: issue.ColumnTodo},
|
||||
}
|
||||
m := makeLoadedModel(issues, 0, 0)
|
||||
|
||||
out := m.boardView()
|
||||
if !strings.Contains(out, "STATE JSON") || !strings.Contains(out, "First issue") {
|
||||
t.Fatalf("default board view should show selected issue properties, got: %q", out)
|
||||
}
|
||||
|
||||
m2, _ := sendKey(m, "j")
|
||||
if m2.state != stateLoaded {
|
||||
t.Fatalf("moving selection should stay in board mode, got state %v", m2.state)
|
||||
}
|
||||
out = m2.boardView()
|
||||
if !strings.Contains(out, "Second issue") {
|
||||
t.Fatalf("detail panel should follow moved selection, got: %q", out)
|
||||
}
|
||||
|
||||
m3, _ := sendKey(m2, "enter")
|
||||
if m3.state != stateLoaded {
|
||||
t.Fatalf("enter should not steal keyboard focus, got state %v", m3.state)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBoardView_BottomTabsSwitchWithoutStealingBoardFocus(t *testing.T) {
|
||||
issues := []issue.Issue{
|
||||
{Number: 1, Title: "First issue", Column: issue.ColumnTodo},
|
||||
{Number: 2, Title: "Second issue", Column: issue.ColumnTodo},
|
||||
}
|
||||
m := makeLoadedModel(issues, 0, 0)
|
||||
m.profileGridData = store.ProfilesData{Profiles: []string{"dev"}, Rows: []store.ProfileRow{{Key: "model", Values: map[string]string{"dev": "sonnet"}}}}
|
||||
m.managedSessions = store.ManagedSessionsData{Sessions: []store.ManagedSession{{ID: "abcdef123456", Name: "daily", CreatedAt: 1}}}
|
||||
|
||||
out := m.boardView()
|
||||
for _, want := range []string{"1 工单", "2 Profile", "3 会话", "4 提交"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("bottom tabs missing %q: %q", want, out)
|
||||
}
|
||||
}
|
||||
|
||||
m2, _ := sendKey(m, "2")
|
||||
if m2.state != stateLoaded || m2.bottomTab != bottomTabProfile {
|
||||
t.Fatalf("profile tab should stay in board mode, state=%v tab=%v", m2.state, m2.bottomTab)
|
||||
}
|
||||
if out := m2.boardView(); !strings.Contains(out, "Profile") || !strings.Contains(out, "model") {
|
||||
t.Fatalf("profile tab body missing expected content: %q", out)
|
||||
}
|
||||
|
||||
m3, _ := sendKey(m2, "j")
|
||||
if m3.state != stateLoaded || m3.rowIdx != 1 {
|
||||
t.Fatalf("board selection should still move while tab is active, state=%v row=%d", m3.state, m3.rowIdx)
|
||||
}
|
||||
|
||||
m4, _ := sendKey(m3, "3")
|
||||
if out := m4.boardView(); !strings.Contains(out, "会话") || !strings.Contains(out, "daily") {
|
||||
t.Fatalf("sessions tab body missing expected content: %q", out)
|
||||
}
|
||||
|
||||
m5, _ := sendKey(m4, "tab")
|
||||
if m5.focusPane != focusPanel || m5.bottomTab != bottomTabSessions {
|
||||
t.Fatalf("tab should focus panel without changing tab, focus=%v tab=%v", m5.focusPane, m5.bottomTab)
|
||||
}
|
||||
m6, _ := sendKey(m5, "j")
|
||||
if m6.focusPane != focusPanel || m6.bottomTab != bottomTabCommits {
|
||||
t.Fatalf("panel-focused j on tabs should cycle to commits, focus=%v tab=%v", m6.focusPane, m6.bottomTab)
|
||||
}
|
||||
m7, _ := sendKey(m6, "tab")
|
||||
if m7.focusPane != focusBoard {
|
||||
t.Fatalf("second tab should focus board, got %v", m7.focusPane)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBoardView_PanelActionBarKeyboardAndMouse(t *testing.T) {
|
||||
issues := []issue.Issue{
|
||||
{Number: 1, Title: "First issue", Column: issue.ColumnTodo},
|
||||
}
|
||||
m := makeLoadedModel(issues, 0, 0)
|
||||
m.height = 40
|
||||
|
||||
m2, _ := sendKey(m, "tab")
|
||||
m3, _ := sendKey(m2, "j")
|
||||
if m3.panelFocus != panelFocusActions {
|
||||
t.Fatalf("down in focused issue panel should enter action row, got %v", m3.panelFocus)
|
||||
}
|
||||
m4, _ := sendKey(m3, "l")
|
||||
if m4.actionIdx != 1 {
|
||||
t.Fatalf("right should move action selection, got %d", m4.actionIdx)
|
||||
}
|
||||
m5, cmd := sendKey(m4, "enter")
|
||||
if cmd != nil {
|
||||
t.Fatalf("implement action without plan should not start command")
|
||||
}
|
||||
if !strings.Contains(m5.statusMsg, "无计划文件") {
|
||||
t.Fatalf("enter should trigger selected action, status=%q", m5.statusMsg)
|
||||
}
|
||||
|
||||
panelY, ok := m.panelStartY()
|
||||
if !ok {
|
||||
t.Fatal("expected panel start")
|
||||
}
|
||||
clickImplement := tea.MouseClickMsg(tea.Mouse{X: 18, Y: panelY + 4, Button: tea.MouseLeft})
|
||||
next, cmd := m.Update(clickImplement)
|
||||
m6 := next.(Model)
|
||||
if cmd != nil {
|
||||
t.Fatalf("mouse implement action without plan should not start command")
|
||||
}
|
||||
if m6.focusPane != focusPanel || m6.panelFocus != panelFocusActions || m6.actionIdx != 1 {
|
||||
t.Fatalf("mouse click should focus action, focus=%v panel=%v action=%d", m6.focusPane, m6.panelFocus, m6.actionIdx)
|
||||
}
|
||||
if !strings.Contains(m6.statusMsg, "无计划文件") {
|
||||
t.Fatalf("mouse click should trigger action, status=%q", m6.statusMsg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBoardView_PanelActionBarMouseHover(t *testing.T) {
|
||||
issues := []issue.Issue{
|
||||
{Number: 1, Title: "First issue", Column: issue.ColumnTodo},
|
||||
}
|
||||
m := makeLoadedModel(issues, 0, 0)
|
||||
m.height = 40
|
||||
|
||||
panelY, ok := m.panelStartY()
|
||||
if !ok {
|
||||
t.Fatal("expected panel start")
|
||||
}
|
||||
|
||||
hoverImplement := tea.MouseMotionMsg(tea.Mouse{X: 18, Y: panelY + 4})
|
||||
next, _ := m.Update(hoverImplement)
|
||||
m2 := next.(Model)
|
||||
if !m2.hoverActionOK || m2.hoverActionIdx != 1 {
|
||||
t.Fatalf("hover should select implement action, ok=%v idx=%d", m2.hoverActionOK, m2.hoverActionIdx)
|
||||
}
|
||||
|
||||
gapBetweenActions := tea.MouseMotionMsg(tea.Mouse{X: 15, Y: panelY + 4})
|
||||
next, _ = m2.Update(gapBetweenActions)
|
||||
mGap := next.(Model)
|
||||
if mGap.hoverActionOK {
|
||||
t.Fatalf("hover should not select gap between actions")
|
||||
}
|
||||
|
||||
leave := tea.MouseMotionMsg(tea.Mouse{X: 2, Y: 1})
|
||||
next, _ = mGap.Update(leave)
|
||||
m3 := next.(Model)
|
||||
if m3.hoverActionOK {
|
||||
t.Fatalf("hover should clear after leaving action row")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBoardView_MouseClickChangesFocusedPaneAndTab(t *testing.T) {
|
||||
issues := []issue.Issue{
|
||||
{Number: 1, Title: "First issue", Column: issue.ColumnTodo},
|
||||
}
|
||||
m := makeLoadedModel(issues, 0, 0)
|
||||
m.height = 40
|
||||
|
||||
panelY, ok := m.panelStartY()
|
||||
if !ok {
|
||||
t.Fatal("expected panel start")
|
||||
}
|
||||
|
||||
clickPanel := tea.MouseClickMsg(tea.Mouse{X: 22, Y: panelY, Button: tea.MouseLeft})
|
||||
next, _ := m.Update(clickPanel)
|
||||
m2 := next.(Model)
|
||||
if m2.focusPane != focusPanel || m2.bottomTab != bottomTabSessions {
|
||||
t.Fatalf("clicking sessions tab should focus panel and switch tab, focus=%v tab=%v", m2.focusPane, m2.bottomTab)
|
||||
}
|
||||
|
||||
clickBoard := tea.MouseClickMsg(tea.Mouse{X: 2, Y: 1, Button: tea.MouseLeft})
|
||||
next, _ = m2.Update(clickBoard)
|
||||
m3 := next.(Model)
|
||||
if m3.focusPane != focusBoard {
|
||||
t.Fatalf("clicking board should focus board, got %v", m3.focusPane)
|
||||
}
|
||||
}
|
||||
|
||||
+440
-17
@@ -92,6 +92,31 @@ type confirmPending struct {
|
||||
issue issue.Issue
|
||||
}
|
||||
|
||||
// ── bottom panel tabs ────────────────────────────────────────────────────────
|
||||
|
||||
type bottomTab int
|
||||
|
||||
const (
|
||||
bottomTabIssue bottomTab = iota
|
||||
bottomTabProfile
|
||||
bottomTabSessions
|
||||
bottomTabCommits
|
||||
)
|
||||
|
||||
type focusPane int
|
||||
|
||||
const (
|
||||
focusBoard focusPane = iota
|
||||
focusPanel
|
||||
)
|
||||
|
||||
type panelFocus int
|
||||
|
||||
const (
|
||||
panelFocusTabs panelFocus = iota
|
||||
panelFocusActions
|
||||
)
|
||||
|
||||
// ── state enum ──────────────────────────────────────────────────────────────
|
||||
|
||||
type boardState int
|
||||
@@ -166,8 +191,14 @@ type Model struct {
|
||||
createInboxDir string // /tmp/spx-inbox/<nonce> for this create session
|
||||
|
||||
// detail panel state
|
||||
detailIss *issue.Issue
|
||||
sessionMgr *SessionManager
|
||||
detailIss *issue.Issue
|
||||
sessionMgr *SessionManager
|
||||
bottomTab bottomTab
|
||||
focusPane focusPane
|
||||
panelFocus panelFocus
|
||||
actionIdx int
|
||||
hoverActionIdx int
|
||||
hoverActionOK bool
|
||||
|
||||
// PR commits review panel state
|
||||
prCommitsIss *issue.Issue
|
||||
@@ -179,6 +210,7 @@ type Model struct {
|
||||
prCursor int // cursor over commit rows then file rows
|
||||
prConfirmed map[string]bool // file path → confirmed
|
||||
prConfirmedSHAs map[string]bool // commit sha → confirmed
|
||||
prCommitsReturn boardState
|
||||
|
||||
// webhook server lifecycle
|
||||
webhookCtx context.Context
|
||||
@@ -566,6 +598,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.width = msg.Width
|
||||
m.height = msg.Height
|
||||
|
||||
case tea.MouseClickMsg:
|
||||
return m.handleMouseClick(msg)
|
||||
|
||||
case tea.MouseMotionMsg:
|
||||
return m.handleMouseMotion(msg)
|
||||
|
||||
case spinner.TickMsg:
|
||||
if m.state == stateLoading || (m.state == statePRCommits && !m.prCommitsLoaded) {
|
||||
var cmd tea.Cmd
|
||||
@@ -1092,7 +1130,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.buckets = bucketize(m.issues)
|
||||
m.statusMsg = fmt.Sprintf("正在启动实施 #%d…", iss.Number)
|
||||
return m, implementCmd(iss, m.sessionMgr)
|
||||
case "p":
|
||||
case "p", "R":
|
||||
if m.detailIss == nil {
|
||||
break
|
||||
}
|
||||
@@ -1103,6 +1141,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
iss := *m.detailIss
|
||||
m.prCommitsIss = &iss
|
||||
m.prBaseRef = ""
|
||||
m.prCommitsReturn = stateDetail
|
||||
m.state = statePRCommits
|
||||
m.prCommitsLoaded = false
|
||||
m.prCommitsErr = nil
|
||||
@@ -1321,6 +1360,16 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
if m.state == stateLoaded {
|
||||
if handledModel, cmd, handled := m.handleFocusKey(msg); handled {
|
||||
return handledModel, cmd
|
||||
}
|
||||
}
|
||||
|
||||
if m.state == stateLoaded && isInlineDetailKey(msg.String()) {
|
||||
return m.handleInlineDetailKey(msg)
|
||||
}
|
||||
|
||||
switch msg.String() {
|
||||
case "q", "ctrl+c":
|
||||
if m.webhookCancel != nil {
|
||||
@@ -1328,11 +1377,24 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
return m, tea.Quit
|
||||
|
||||
case "enter":
|
||||
if m.state == stateLoaded && len(m.buckets[m.colIdx]) > 0 {
|
||||
iss := m.buckets[m.colIdx][m.rowIdx]
|
||||
m.detailIss = &iss
|
||||
m.state = stateDetail
|
||||
case "1":
|
||||
if m.state == stateLoaded {
|
||||
return m.switchBottomTab(bottomTabIssue)
|
||||
}
|
||||
|
||||
case "2":
|
||||
if m.state == stateLoaded {
|
||||
return m.switchBottomTab(bottomTabProfile)
|
||||
}
|
||||
|
||||
case "3":
|
||||
if m.state == stateLoaded {
|
||||
return m.switchBottomTab(bottomTabSessions)
|
||||
}
|
||||
|
||||
case "4":
|
||||
if m.state == stateLoaded {
|
||||
return m.switchBottomTab(bottomTabCommits)
|
||||
}
|
||||
|
||||
case "r":
|
||||
@@ -1553,6 +1615,7 @@ func prevNonEmpty(buckets [4][]issue.Issue, cur int) int {
|
||||
func (m Model) View() tea.View {
|
||||
v := tea.NewView(m.viewString())
|
||||
v.AltScreen = true
|
||||
v.MouseMode = tea.MouseModeAllMotion
|
||||
return v
|
||||
}
|
||||
|
||||
@@ -1602,7 +1665,9 @@ func (m Model) viewString() string {
|
||||
}
|
||||
|
||||
func (m Model) boardView() string {
|
||||
colWidth := m.colWidth()
|
||||
if iss, ok := m.selectedIssue(); ok {
|
||||
return m.boardWithDetailView(iss)
|
||||
}
|
||||
|
||||
boxH := m.height - 2
|
||||
if m.height == 0 {
|
||||
@@ -1611,15 +1676,9 @@ func (m Model) boardView() string {
|
||||
if boxH < 5 {
|
||||
boxH = 5
|
||||
}
|
||||
board := m.boardOnlyView(boxH)
|
||||
|
||||
cols := make([]string, 4)
|
||||
for ci := range columnOrder {
|
||||
cols[ci] = m.renderColumn(ci, colWidth, boxH)
|
||||
}
|
||||
|
||||
board := lipgloss.JoinHorizontal(lipgloss.Top, cols...)
|
||||
|
||||
help := helpStyle.Render("↑↓/jk 选择 ←→/hl 列 H/L 移动 enter 详情 c 关闭 d 删除 p 前置 P 清前置 n 新建 C 提交 S 同步 E 环境锁 , 设置 r 刷新 q 退出")
|
||||
help := helpStyle.Render("↑↓/jk 选择 ←→/hl 列 H/L 移动 c 关闭 d 删除 p 前置 P 清前置 n 新建 C 提交 S 同步 E 环境锁 , 设置 r 刷新 q 退出")
|
||||
indicator := ""
|
||||
if m.hasChanges {
|
||||
indicator = " " + confirmStyle.Render("● 未提交")
|
||||
@@ -1634,6 +1693,370 @@ func (m Model) boardView() string {
|
||||
return board + "\n" + help
|
||||
}
|
||||
|
||||
func (m Model) selectedIssue() (issue.Issue, bool) {
|
||||
if m.colIdx < 0 || m.colIdx >= len(m.buckets) {
|
||||
return issue.Issue{}, false
|
||||
}
|
||||
if m.rowIdx < 0 || m.rowIdx >= len(m.buckets[m.colIdx]) {
|
||||
return issue.Issue{}, false
|
||||
}
|
||||
return m.buckets[m.colIdx][m.rowIdx], true
|
||||
}
|
||||
|
||||
func (m Model) currentDetailIssue() (issue.Issue, bool) {
|
||||
if m.state == stateDetail && m.detailIss != nil {
|
||||
return *m.detailIss, true
|
||||
}
|
||||
return m.selectedIssue()
|
||||
}
|
||||
|
||||
func (m Model) switchBottomTab(tab bottomTab) (Model, tea.Cmd) {
|
||||
m.bottomTab = tab
|
||||
switch tab {
|
||||
case bottomTabProfile:
|
||||
return m, loadProfileGridCmd()
|
||||
case bottomTabSessions:
|
||||
return m, loadManagedSessionsCmd()
|
||||
case bottomTabCommits:
|
||||
iss, ok := m.selectedIssue()
|
||||
if !ok {
|
||||
m.statusMsg = "无选中工单"
|
||||
return m, nil
|
||||
}
|
||||
if iss.PR == "" {
|
||||
m.statusMsg = "当前工单无关联 PR"
|
||||
m.prCommitsIss = &iss
|
||||
m.prCommitsLoaded = true
|
||||
m.prCommitsErr = nil
|
||||
m.prCommits = nil
|
||||
m.prFiles = nil
|
||||
return m, nil
|
||||
}
|
||||
m.prCommitsIss = &iss
|
||||
m.prBaseRef = ""
|
||||
m.prCommitsReturn = stateLoaded
|
||||
m.prCommitsLoaded = false
|
||||
m.prCommitsErr = nil
|
||||
m.prCommits = nil
|
||||
m.prFiles = nil
|
||||
m.prCursor = 0
|
||||
m.prConfirmed = map[string]bool{}
|
||||
m.prConfirmedSHAs = map[string]bool{}
|
||||
return m, tea.Batch(m.spinner.Tick, loadPRCommitsCmd(iss))
|
||||
default:
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
|
||||
type panelAction struct {
|
||||
key string
|
||||
label string
|
||||
}
|
||||
|
||||
var panelActions = []panelAction{
|
||||
{"b", "头脑风暴"},
|
||||
{"i", "实施"},
|
||||
{"v", "审查"},
|
||||
{"t", "测试"},
|
||||
{"R", "PR提交"},
|
||||
{"g", "PR摘要"},
|
||||
{"o", "打开文件"},
|
||||
{"r", "刷新"},
|
||||
{"q", "退出"},
|
||||
}
|
||||
|
||||
func (m Model) actionBarView() string {
|
||||
var parts []string
|
||||
for i, action := range panelActions {
|
||||
text := fmt.Sprintf("[%s] %s", action.key, action.label)
|
||||
focused := m.focusPane == focusPanel && m.panelFocus == panelFocusActions && m.actionIdx == i
|
||||
hovered := m.hoverActionOK && m.hoverActionIdx == i
|
||||
if focused || hovered {
|
||||
parts = append(parts, selectedCardStyle.Render(text))
|
||||
} else {
|
||||
parts = append(parts, helpStyle.Render(text))
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
func (m Model) handleFocusKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd, bool) {
|
||||
switch msg.String() {
|
||||
case "tab":
|
||||
if m.focusPane == focusPanel {
|
||||
m.focusPane = focusBoard
|
||||
} else {
|
||||
m.focusPane = focusPanel
|
||||
}
|
||||
return m, nil, true
|
||||
case "shift+tab":
|
||||
if m.focusPane == focusPanel {
|
||||
m.focusPane = focusBoard
|
||||
} else {
|
||||
m.focusPane = focusPanel
|
||||
}
|
||||
return m, nil, true
|
||||
}
|
||||
|
||||
if m.focusPane != focusPanel {
|
||||
return m, nil, false
|
||||
}
|
||||
|
||||
if m.bottomTab == bottomTabIssue && m.panelFocus == panelFocusActions {
|
||||
switch msg.String() {
|
||||
case "left", "h":
|
||||
if m.actionIdx > 0 {
|
||||
m.actionIdx--
|
||||
}
|
||||
return m, nil, true
|
||||
case "right", "l":
|
||||
if m.actionIdx < len(panelActions)-1 {
|
||||
m.actionIdx++
|
||||
}
|
||||
return m, nil, true
|
||||
case "up", "k":
|
||||
m.panelFocus = panelFocusTabs
|
||||
return m, nil, true
|
||||
case "enter":
|
||||
return m.triggerPanelAction(m.actionIdx)
|
||||
}
|
||||
}
|
||||
|
||||
switch msg.String() {
|
||||
case "up", "k", "left", "h":
|
||||
next := (m.bottomTab + 3) % 4
|
||||
nextM, cmd := m.switchBottomTab(next)
|
||||
nextM.focusPane = focusPanel
|
||||
return nextM, cmd, true
|
||||
case "down", "j", "right", "l":
|
||||
if m.bottomTab == bottomTabIssue && (msg.String() == "down" || msg.String() == "j") {
|
||||
m.panelFocus = panelFocusActions
|
||||
if m.actionIdx < 0 || m.actionIdx >= len(panelActions) {
|
||||
m.actionIdx = 0
|
||||
}
|
||||
return m, nil, true
|
||||
}
|
||||
next := (m.bottomTab + 1) % 4
|
||||
nextM, cmd := m.switchBottomTab(next)
|
||||
nextM.focusPane = focusPanel
|
||||
return nextM, cmd, true
|
||||
}
|
||||
return m, nil, false
|
||||
}
|
||||
|
||||
func (m Model) triggerPanelAction(idx int) (tea.Model, tea.Cmd, bool) {
|
||||
if idx < 0 || idx >= len(panelActions) {
|
||||
return m, nil, true
|
||||
}
|
||||
key := panelActions[idx].key
|
||||
if key == "r" {
|
||||
m.state = stateLoading
|
||||
m.loadErr = nil
|
||||
m.statusMsg = ""
|
||||
return m, tea.Batch(m.spinner.Tick, loadCmd()), true
|
||||
}
|
||||
if key == "q" {
|
||||
if m.webhookCancel != nil {
|
||||
m.webhookCancel()
|
||||
}
|
||||
return m, tea.Quit, true
|
||||
}
|
||||
next, cmd := m.handleInlineDetailKey(tea.KeyPressMsg{Code: []rune(key)[0], Text: key})
|
||||
return next, cmd, true
|
||||
}
|
||||
|
||||
func (m Model) handleMouseClick(msg tea.MouseClickMsg) (tea.Model, tea.Cmd) {
|
||||
mouse := tea.Mouse(msg)
|
||||
if mouse.Button != tea.MouseLeft {
|
||||
return m, nil
|
||||
}
|
||||
if m.state != stateLoaded {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
panelY, ok := m.panelStartY()
|
||||
if !ok {
|
||||
m.focusPane = focusBoard
|
||||
return m, nil
|
||||
}
|
||||
if mouse.Y < panelY {
|
||||
m.focusPane = focusBoard
|
||||
return m, nil
|
||||
}
|
||||
|
||||
m.focusPane = focusPanel
|
||||
if mouse.Y == panelY {
|
||||
m.panelFocus = panelFocusTabs
|
||||
if tab, ok := tabAtX(mouse.X); ok {
|
||||
nextM, cmd := m.switchBottomTab(tab)
|
||||
nextM.focusPane = focusPanel
|
||||
return nextM, cmd
|
||||
}
|
||||
}
|
||||
if m.bottomTab == bottomTabIssue && mouse.Y == panelY+4 {
|
||||
m.panelFocus = panelFocusActions
|
||||
if idx, ok := actionAtX(mouse.X); ok {
|
||||
m.actionIdx = idx
|
||||
m.hoverActionIdx = idx
|
||||
m.hoverActionOK = true
|
||||
next, cmd, _ := m.triggerPanelAction(idx)
|
||||
return next, cmd
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m Model) handleMouseMotion(msg tea.MouseMotionMsg) (tea.Model, tea.Cmd) {
|
||||
mouse := tea.Mouse(msg)
|
||||
if m.state != stateLoaded || m.bottomTab != bottomTabIssue {
|
||||
m.hoverActionOK = false
|
||||
return m, nil
|
||||
}
|
||||
panelY, ok := m.panelStartY()
|
||||
if !ok || mouse.Y != panelY+4 {
|
||||
m.hoverActionOK = false
|
||||
return m, nil
|
||||
}
|
||||
idx, ok := actionAtX(mouse.X)
|
||||
if !ok {
|
||||
m.hoverActionOK = false
|
||||
return m, nil
|
||||
}
|
||||
m.hoverActionIdx = idx
|
||||
m.hoverActionOK = true
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m Model) panelStartY() (int, bool) {
|
||||
iss, ok := m.selectedIssue()
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
width := m.width - 2
|
||||
if m.width <= 0 {
|
||||
width = 100
|
||||
}
|
||||
if width < 40 {
|
||||
width = 40
|
||||
}
|
||||
panel := m.bottomTabsPanel(iss, width)
|
||||
panelH := lipgloss.Height(panel)
|
||||
|
||||
statusLine := ""
|
||||
indicator := ""
|
||||
if m.hasChanges {
|
||||
indicator = " " + confirmStyle.Render("● 未提交")
|
||||
}
|
||||
if status := m.statusBar(); status != "" {
|
||||
statusLine = status + indicator
|
||||
} else if indicator != "" {
|
||||
statusLine = indicator
|
||||
}
|
||||
|
||||
reserved := panelH + 1
|
||||
if statusLine != "" {
|
||||
reserved++
|
||||
}
|
||||
boardH := m.height - reserved
|
||||
if m.height == 0 {
|
||||
boardH = 12
|
||||
}
|
||||
if boardH < 5 {
|
||||
boardH = 5
|
||||
}
|
||||
if statusLine != "" {
|
||||
return boardH + 1, true
|
||||
}
|
||||
return boardH, true
|
||||
}
|
||||
|
||||
func tabAtX(x int) (bottomTab, bool) {
|
||||
// Approximate ranges for the plain header text:
|
||||
// " 1 工单 " " 2 Profile " " 3 会话 " " 4 提交 ".
|
||||
switch {
|
||||
case x >= 0 && x < 8:
|
||||
return bottomTabIssue, true
|
||||
case x >= 8 && x < 20:
|
||||
return bottomTabProfile, true
|
||||
case x >= 20 && x < 28:
|
||||
return bottomTabSessions, true
|
||||
case x >= 28 && x < 36:
|
||||
return bottomTabCommits, true
|
||||
default:
|
||||
return bottomTabIssue, false
|
||||
}
|
||||
}
|
||||
|
||||
func actionAtX(x int) (int, bool) {
|
||||
start := 2 // left border + cell's leading inset
|
||||
for i, action := range panelActions {
|
||||
w := lipgloss.Width(fmt.Sprintf("[%s] %s", action.key, action.label))
|
||||
end := start + w
|
||||
if x >= start && x < end {
|
||||
return i, true
|
||||
}
|
||||
start = end + 2
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func isInlineDetailKey(key string) bool {
|
||||
switch key {
|
||||
case "b", "i", "v", "t", "R", "g", "f", "F", "o", "O", "w", "M":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (m Model) handleInlineDetailKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
|
||||
iss, ok := m.selectedIssue()
|
||||
if !ok {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
m.detailIss = &iss
|
||||
m.state = stateDetail
|
||||
next, cmd := m.Update(msg)
|
||||
nextModel, ok := next.(Model)
|
||||
if !ok {
|
||||
return next, cmd
|
||||
}
|
||||
|
||||
switch nextModel.state {
|
||||
case stateDetail:
|
||||
nextModel.state = stateLoaded
|
||||
nextModel.detailIss = nil
|
||||
case stateProfilePicker:
|
||||
if nextModel.profilePickerPrevState == stateDetail {
|
||||
nextModel.profilePickerPrevState = stateLoaded
|
||||
}
|
||||
case stateFilePicker:
|
||||
if nextModel.filePickerPrevState == stateDetail {
|
||||
nextModel.filePickerPrevState = stateLoaded
|
||||
}
|
||||
case statePRCommits:
|
||||
if nextModel.prCommitsReturn == stateDetail {
|
||||
nextModel.prCommitsReturn = stateLoaded
|
||||
}
|
||||
}
|
||||
return nextModel, cmd
|
||||
}
|
||||
|
||||
func (m Model) boardOnlyView(boxH int) string {
|
||||
colWidth := m.colWidth()
|
||||
if boxH < 4 {
|
||||
boxH = 4
|
||||
}
|
||||
|
||||
cols := make([]string, 4)
|
||||
for ci := range columnOrder {
|
||||
cols[ci] = m.renderColumn(ci, colWidth, boxH)
|
||||
}
|
||||
|
||||
return lipgloss.JoinHorizontal(lipgloss.Top, cols...)
|
||||
}
|
||||
|
||||
// statusBar returns the status line content.
|
||||
func (m Model) statusBar() string {
|
||||
if m.state == stateInput {
|
||||
|
||||
@@ -191,6 +191,11 @@ func (m Model) cursorOnFile() (int, bool) {
|
||||
func (m Model) handlePRCommitsKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch msg.String() {
|
||||
case "esc":
|
||||
if m.prCommitsReturn != stateLoading {
|
||||
m.state = m.prCommitsReturn
|
||||
m.prCommitsReturn = stateLoading
|
||||
return m, nil
|
||||
}
|
||||
m.state = stateDetail
|
||||
return m, nil
|
||||
|
||||
|
||||
Reference in New Issue
Block a user