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"
|
"fmt"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"charm.land/lipgloss/v2"
|
"charm.land/lipgloss/v2"
|
||||||
"github.com/charmbracelet/x/ansi"
|
"github.com/charmbracelet/x/ansi"
|
||||||
@@ -117,7 +118,7 @@ var (
|
|||||||
|
|
||||||
// detailBody builds the two stacked blocks (header + STATE JSON table) shown for
|
// detailBody builds the two stacked blocks (header + STATE JSON table) shown for
|
||||||
// an issue, sized to the given outer width.
|
// 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().
|
box := lipgloss.NewStyle().
|
||||||
Border(lipgloss.RoundedBorder()).
|
Border(lipgloss.RoundedBorder()).
|
||||||
BorderForeground(detailBorderColor).
|
BorderForeground(detailBorderColor).
|
||||||
@@ -144,9 +145,8 @@ func detailBody(iss issue.Issue, width int) string {
|
|||||||
}
|
}
|
||||||
header := lipgloss.JoinVertical(lipgloss.Left,
|
header := lipgloss.JoinVertical(lipgloss.Left,
|
||||||
cell(title, false),
|
cell(title, false),
|
||||||
cell("", false),
|
cell(hint.Render("↑↓/jk 选择 ←→/hl 列 H/L 移动 c 关闭 d 删除 p 前置 P 清前置 n 新建"), false),
|
||||||
cell(hint.Render("[b] 头脑风暴 [i] 实施 [v] 审查 [t] 测试 [p] PR提交 [g] PR摘要"), false),
|
cell(m.actionBarView(), false),
|
||||||
cell(hint.Render("[f] 配置 [F] 测试配置 [o] 打开文件 [c] 关闭 [d] 删除 esc 返回"), false),
|
|
||||||
)
|
)
|
||||||
headerBox := box.Render(header)
|
headerBox := box.Render(header)
|
||||||
|
|
||||||
@@ -178,22 +178,201 @@ func detailBody(iss issue.Issue, width int) string {
|
|||||||
return lipgloss.JoinVertical(lipgloss.Left, headerBox, tableBox)
|
return lipgloss.JoinVertical(lipgloss.Left, headerBox, tableBox)
|
||||||
}
|
}
|
||||||
|
|
||||||
// detailView renders the issue detail as a centered modal (two stacked blocks)
|
// detailView renders the board above a bottom issue property panel, matching
|
||||||
// floating over the board via lipgloss v2 layer compositing.
|
// the VSCode extension's kanban + attributes split.
|
||||||
func (m Model) detailView() string {
|
func (m Model) detailView() string {
|
||||||
bg := m.boardView()
|
iss, ok := m.currentDetailIssue()
|
||||||
if m.detailIss == nil {
|
if !ok {
|
||||||
return bg
|
return m.boardView()
|
||||||
}
|
}
|
||||||
|
|
||||||
width := 84
|
return m.boardWithDetailView(iss)
|
||||||
if m.width > 0 && m.width-8 < width {
|
}
|
||||||
width = m.width - 8
|
|
||||||
|
func (m Model) boardWithDetailView(iss issue.Issue) string {
|
||||||
|
width := m.width - 2
|
||||||
|
if m.width <= 0 {
|
||||||
|
width = 100
|
||||||
}
|
}
|
||||||
if width < 40 {
|
if width < 40 {
|
||||||
width = 40
|
width = 40
|
||||||
}
|
}
|
||||||
|
|
||||||
modal := detailBody(*m.detailIss, width)
|
panel := m.bottomTabsPanel(iss, width)
|
||||||
return placeOverlayCenter(bg, modal)
|
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"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
tea "charm.land/bubbletea/v2"
|
||||||
"charm.land/lipgloss/v2"
|
"charm.land/lipgloss/v2"
|
||||||
"superwork-tui/internal/issue"
|
"superwork-tui/internal/issue"
|
||||||
|
"superwork-tui/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestDetailBody(t *testing.T) {
|
func TestDetailBody(t *testing.T) {
|
||||||
@@ -31,7 +33,7 @@ func TestDetailBody(t *testing.T) {
|
|||||||
AutoReview: &autoReview,
|
AutoReview: &autoReview,
|
||||||
}
|
}
|
||||||
|
|
||||||
out := detailBody(iss, 80)
|
out := Model{}.detailBody(iss, 80)
|
||||||
|
|
||||||
checks := []string{
|
checks := []string{
|
||||||
"#42",
|
"#42",
|
||||||
@@ -58,10 +60,9 @@ func TestDetailBody(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestDetailModal_OverlaysBoard verifies the detail view is a centered modal
|
// TestDetailView_RendersBoardAbovePropertyPanel verifies the detail view keeps
|
||||||
// composited over the board (true overlay), not a full-screen replacement, and
|
// the kanban board visible above a bottom property panel, matching VSCode.
|
||||||
// that it never overflows the terminal height.
|
func TestDetailView_RendersBoardAbovePropertyPanel(t *testing.T) {
|
||||||
func TestDetailModal_OverlaysBoard(t *testing.T) {
|
|
||||||
var issues []issue.Issue
|
var issues []issue.Issue
|
||||||
for i := 0; i < 11; i++ {
|
for i := 0; i < 11; i++ {
|
||||||
issues = append(issues, issue.Issue{Number: 238 - i, Title: "支持部署时使用客户自定义域名", Column: issue.ColumnTodo})
|
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()
|
out := m.detailView()
|
||||||
|
|
||||||
if !strings.Contains(out, "#238") {
|
if !strings.Contains(out, "#238") {
|
||||||
t.Errorf("modal title missing")
|
t.Errorf("detail title missing")
|
||||||
}
|
}
|
||||||
if !strings.Contains(out, "STATE JSON") {
|
if !strings.Contains(out, "STATE JSON") {
|
||||||
t.Errorf("STATE JSON section missing")
|
t.Errorf("STATE JSON section missing")
|
||||||
}
|
}
|
||||||
if !strings.Contains(out, "todo (11)") {
|
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, "╭") {
|
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 {
|
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
|
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 ──────────────────────────────────────────────────────────────
|
// ── state enum ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
type boardState int
|
type boardState int
|
||||||
@@ -166,8 +191,14 @@ type Model struct {
|
|||||||
createInboxDir string // /tmp/spx-inbox/<nonce> for this create session
|
createInboxDir string // /tmp/spx-inbox/<nonce> for this create session
|
||||||
|
|
||||||
// detail panel state
|
// detail panel state
|
||||||
detailIss *issue.Issue
|
detailIss *issue.Issue
|
||||||
sessionMgr *SessionManager
|
sessionMgr *SessionManager
|
||||||
|
bottomTab bottomTab
|
||||||
|
focusPane focusPane
|
||||||
|
panelFocus panelFocus
|
||||||
|
actionIdx int
|
||||||
|
hoverActionIdx int
|
||||||
|
hoverActionOK bool
|
||||||
|
|
||||||
// PR commits review panel state
|
// PR commits review panel state
|
||||||
prCommitsIss *issue.Issue
|
prCommitsIss *issue.Issue
|
||||||
@@ -179,6 +210,7 @@ type Model struct {
|
|||||||
prCursor int // cursor over commit rows then file rows
|
prCursor int // cursor over commit rows then file rows
|
||||||
prConfirmed map[string]bool // file path → confirmed
|
prConfirmed map[string]bool // file path → confirmed
|
||||||
prConfirmedSHAs map[string]bool // commit sha → confirmed
|
prConfirmedSHAs map[string]bool // commit sha → confirmed
|
||||||
|
prCommitsReturn boardState
|
||||||
|
|
||||||
// webhook server lifecycle
|
// webhook server lifecycle
|
||||||
webhookCtx context.Context
|
webhookCtx context.Context
|
||||||
@@ -566,6 +598,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.width = msg.Width
|
m.width = msg.Width
|
||||||
m.height = msg.Height
|
m.height = msg.Height
|
||||||
|
|
||||||
|
case tea.MouseClickMsg:
|
||||||
|
return m.handleMouseClick(msg)
|
||||||
|
|
||||||
|
case tea.MouseMotionMsg:
|
||||||
|
return m.handleMouseMotion(msg)
|
||||||
|
|
||||||
case spinner.TickMsg:
|
case spinner.TickMsg:
|
||||||
if m.state == stateLoading || (m.state == statePRCommits && !m.prCommitsLoaded) {
|
if m.state == stateLoading || (m.state == statePRCommits && !m.prCommitsLoaded) {
|
||||||
var cmd tea.Cmd
|
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.buckets = bucketize(m.issues)
|
||||||
m.statusMsg = fmt.Sprintf("正在启动实施 #%d…", iss.Number)
|
m.statusMsg = fmt.Sprintf("正在启动实施 #%d…", iss.Number)
|
||||||
return m, implementCmd(iss, m.sessionMgr)
|
return m, implementCmd(iss, m.sessionMgr)
|
||||||
case "p":
|
case "p", "R":
|
||||||
if m.detailIss == nil {
|
if m.detailIss == nil {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -1103,6 +1141,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
iss := *m.detailIss
|
iss := *m.detailIss
|
||||||
m.prCommitsIss = &iss
|
m.prCommitsIss = &iss
|
||||||
m.prBaseRef = ""
|
m.prBaseRef = ""
|
||||||
|
m.prCommitsReturn = stateDetail
|
||||||
m.state = statePRCommits
|
m.state = statePRCommits
|
||||||
m.prCommitsLoaded = false
|
m.prCommitsLoaded = false
|
||||||
m.prCommitsErr = nil
|
m.prCommitsErr = nil
|
||||||
@@ -1321,6 +1360,16 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return m, nil
|
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() {
|
switch msg.String() {
|
||||||
case "q", "ctrl+c":
|
case "q", "ctrl+c":
|
||||||
if m.webhookCancel != nil {
|
if m.webhookCancel != nil {
|
||||||
@@ -1328,11 +1377,24 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
return m, tea.Quit
|
return m, tea.Quit
|
||||||
|
|
||||||
case "enter":
|
case "1":
|
||||||
if m.state == stateLoaded && len(m.buckets[m.colIdx]) > 0 {
|
if m.state == stateLoaded {
|
||||||
iss := m.buckets[m.colIdx][m.rowIdx]
|
return m.switchBottomTab(bottomTabIssue)
|
||||||
m.detailIss = &iss
|
}
|
||||||
m.state = stateDetail
|
|
||||||
|
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":
|
case "r":
|
||||||
@@ -1553,6 +1615,7 @@ func prevNonEmpty(buckets [4][]issue.Issue, cur int) int {
|
|||||||
func (m Model) View() tea.View {
|
func (m Model) View() tea.View {
|
||||||
v := tea.NewView(m.viewString())
|
v := tea.NewView(m.viewString())
|
||||||
v.AltScreen = true
|
v.AltScreen = true
|
||||||
|
v.MouseMode = tea.MouseModeAllMotion
|
||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1602,7 +1665,9 @@ func (m Model) viewString() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) boardView() string {
|
func (m Model) boardView() string {
|
||||||
colWidth := m.colWidth()
|
if iss, ok := m.selectedIssue(); ok {
|
||||||
|
return m.boardWithDetailView(iss)
|
||||||
|
}
|
||||||
|
|
||||||
boxH := m.height - 2
|
boxH := m.height - 2
|
||||||
if m.height == 0 {
|
if m.height == 0 {
|
||||||
@@ -1611,15 +1676,9 @@ func (m Model) boardView() string {
|
|||||||
if boxH < 5 {
|
if boxH < 5 {
|
||||||
boxH = 5
|
boxH = 5
|
||||||
}
|
}
|
||||||
|
board := m.boardOnlyView(boxH)
|
||||||
|
|
||||||
cols := make([]string, 4)
|
help := helpStyle.Render("↑↓/jk 选择 ←→/hl 列 H/L 移动 c 关闭 d 删除 p 前置 P 清前置 n 新建 C 提交 S 同步 E 环境锁 , 设置 r 刷新 q 退出")
|
||||||
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 退出")
|
|
||||||
indicator := ""
|
indicator := ""
|
||||||
if m.hasChanges {
|
if m.hasChanges {
|
||||||
indicator = " " + confirmStyle.Render("● 未提交")
|
indicator = " " + confirmStyle.Render("● 未提交")
|
||||||
@@ -1634,6 +1693,370 @@ func (m Model) boardView() string {
|
|||||||
return board + "\n" + help
|
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.
|
// statusBar returns the status line content.
|
||||||
func (m Model) statusBar() string {
|
func (m Model) statusBar() string {
|
||||||
if m.state == stateInput {
|
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) {
|
func (m Model) handlePRCommitsKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
switch msg.String() {
|
switch msg.String() {
|
||||||
case "esc":
|
case "esc":
|
||||||
|
if m.prCommitsReturn != stateLoading {
|
||||||
|
m.state = m.prCommitsReturn
|
||||||
|
m.prCommitsReturn = stateLoading
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
m.state = stateDetail
|
m.state = stateDetail
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user