mirror of
https://github.com/github/gh-stack.git
synced 2026-09-14 20:26:28 +08:00
797c62e9b4
* Make the TUIs adapt to light and dark terminal backgrounds
The submit, view, and modify TUIs were tuned for dark terminals. On light
or solarized-light backgrounds the result was hard to read and inverted:
primary text used ANSI white (invisible on white), dim chrome used light
grays (too faint), and accents used bright cyan (low contrast) — so
"active" things looked lighter than "disabled" ones.
Introduce a centralized, background-aware color palette and migrate all
three TUIs to it:
- internal/tui/shared/theme.go: a semantic palette of lipgloss.AdaptiveColor
values (primary/muted/faint text, chrome/border, accent, PR-state colors,
badge backgrounds, row shade, button, switch). lipgloss resolves the
light/dark variant per render from the terminal background, which Bubble
Tea detects at startup; terminals that don't report it fall back to dark,
preserving the original look.
- Replace every hardcoded ANSI color in shared/, submitview/, and
modifyview/ with palette roles. The four pre-rendered status icons now
render at use-time so their adaptive colors resolve correctly. The submit
markdown preview picks glamour's light or dark style from the detected
background.
- GH_STACK_THEME=auto|light|dark forces the palette for terminals that
mis-detect (some SSH/tmux setups); wired via the root command's
PersistentPreRun before any render. Documented in the README and CLI docs.
Neutral text/chrome use truecolor hex (GitHub Primer-inspired) for
predictability across themes, including solarized which repurposes ANSI
8-15; lipgloss downsamples on terminals without truecolor.
Tests verify the palette resolves differently for light vs dark and that
GH_STACK_THEME is honored.
* Apply background-aware colors to all command output
Background detection and the GH_STACK_THEME override (added for the TUIs)
only affected the interactive screens. Plain command output -- status
messages and interactive prompts -- went through the mgutz/ansi library
with fixed ANSI palette names (green/red/yellow/cyan/...), so it never
adapted to the terminal background and could read poorly on light or
solarized themes.
Unify everything on the same adaptive palette so all colors react to the
detected background and to GH_STACK_THEME.
- Extract internal/theme, a foundational package with no internal
dependencies, that owns:
- the background-aware lipgloss.AdaptiveColor palette (moved out of
internal/tui/shared),
- ApplyOverride(), the GH_STACK_THEME=auto|light|dark logic, and
- non-TUI colorizers (Success/Error/Warning/Blue/Magenta/Cyan/Gray/
Bold) plus FgSeqs(), which returns the raw start/reset escapes used
to color the user's echoed prompt input.
- internal/tui/shared/theme.go now re-exports the palette, so the TUI code
keeps referring to shared.ColorX unchanged.
- internal/config/config.go wires the Config.Color* funcs to the theme
colorizers and drops mgutz/ansi (now an indirect dependency only).
- cmd/utils.go colors the prompt icon and echoed input via theme.
- cmd/root.go calls theme.ApplyOverride() in PersistentPreRun.
Detection adds no cost: because the command package imports Bubble Tea,
its init() already triggers (and caches) the terminal background query for
every command, so the non-TUI colorizers just read the cached value.
Terminals that don't answer the query fall back to the dark palette;
GH_STACK_THEME=light|dark forces it. Colors are truecolor on capable
terminals and downsample to the nearest ANSI color elsewhere.
Tests: internal/theme covers palette adaptiveness, ApplyOverride, the
colorizers, and FgSeqs; a new internal/config test verifies the wired-up
Config.Color* funcs adapt to the background when color is enabled.
Docs: README and the CLI reference note that GH_STACK_THEME now controls
all colored output, not just the interactive screens.
No behavior change beyond colors.
158 lines
4.4 KiB
Go
158 lines
4.4 KiB
Go
package submitview
|
|
|
|
import (
|
|
"os"
|
|
"os/exec"
|
|
"strings"
|
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/charmbracelet/glamour"
|
|
"github.com/charmbracelet/glamour/styles"
|
|
"github.com/charmbracelet/lipgloss"
|
|
)
|
|
|
|
// editorFinishedMsg is delivered after the external $EDITOR process exits.
|
|
type editorFinishedMsg struct {
|
|
path string
|
|
err error
|
|
}
|
|
|
|
// togglePreview flips the description between edit and preview, blurring or
|
|
// focusing the textarea accordingly. It returns any focus command.
|
|
func (m *Model) togglePreview() tea.Cmd {
|
|
// Edit and preview use different line coordinates, so reset the scroll.
|
|
m.descScroll = 0
|
|
m.descScrollPinned = false
|
|
m.descPreview = !m.descPreview
|
|
if m.descPreview {
|
|
m.descArea.Blur()
|
|
return nil
|
|
}
|
|
if m.focusedField == fieldDescription {
|
|
return m.descArea.Focus()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// openEditor launches $EDITOR on the focused branch's description, returning the
|
|
// ExecProcess command. If no editor is configured it surfaces a brief error and
|
|
// leaves the in-TUI textarea editable.
|
|
func (m Model) openEditor() (tea.Model, tea.Cmd) {
|
|
n := m.currentNode()
|
|
if n == nil || n.State != StateNew {
|
|
return m, nil
|
|
}
|
|
m.saveEditor()
|
|
|
|
editor := resolveEditor()
|
|
if editor == "" {
|
|
m.statusMessage = "$EDITOR is not set — edit inline or set $EDITOR"
|
|
m.statusIsError = true
|
|
return m, nil
|
|
}
|
|
|
|
path, err := writeTempDescription(m.nodes[m.cursor].Description)
|
|
if err != nil {
|
|
m.statusMessage = "Could not open editor: " + err.Error()
|
|
m.statusIsError = true
|
|
return m, nil
|
|
}
|
|
|
|
fields := strings.Fields(editor)
|
|
args := append(fields[1:], path)
|
|
cmd := exec.Command(fields[0], args...)
|
|
return m, tea.ExecProcess(cmd, func(err error) tea.Msg {
|
|
return editorFinishedMsg{path: path, err: err}
|
|
})
|
|
}
|
|
|
|
// handleEditorFinished reloads the description from the temp file after the
|
|
// editor exits, then removes the file.
|
|
func (m Model) handleEditorFinished(msg editorFinishedMsg) (tea.Model, tea.Cmd) {
|
|
defer func() { _ = os.Remove(msg.path) }()
|
|
|
|
if msg.err != nil {
|
|
m.statusMessage = "Editor exited with an error — your inline edits are kept"
|
|
m.statusIsError = true
|
|
return m, nil
|
|
}
|
|
|
|
data, err := os.ReadFile(msg.path)
|
|
if err != nil {
|
|
m.statusMessage = "Could not read the editor's output"
|
|
m.statusIsError = true
|
|
return m, nil
|
|
}
|
|
|
|
content := strings.TrimRight(string(data), "\n")
|
|
if m.cursor >= 0 && m.cursor < len(m.nodes) {
|
|
m.nodes[m.cursor].Description = content
|
|
m.descArea.SetValue(content)
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
// resolveEditor returns the configured editor command, checking GH_EDITOR,
|
|
// VISUAL, then EDITOR. It returns "" when none are set.
|
|
func resolveEditor() string {
|
|
for _, key := range []string{"GH_EDITOR", "VISUAL", "EDITOR"} {
|
|
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
|
|
return v
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// writeTempDescription writes content to a temporary markdown file and returns
|
|
// its path.
|
|
func writeTempDescription(content string) (string, error) {
|
|
f, err := os.CreateTemp("", "gh-stack-pr-*.md")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer f.Close()
|
|
if _, err := f.WriteString(content); err != nil {
|
|
return "", err
|
|
}
|
|
return f.Name(), nil
|
|
}
|
|
|
|
// renderMarkdown renders markdown to styled terminal output using Glamour. It
|
|
// selects the light or dark Glamour style from the already-detected terminal
|
|
// background (lipgloss.HasDarkBackground, cached at startup) rather than
|
|
// glamour.WithAutoStyle(): auto-style probes the terminal with an OSC query whose
|
|
// response is consumed by Bubble Tea's own input reader, so the query blocks
|
|
// forever and freezes the UI. On any error it falls back to the raw markdown so
|
|
// the user still sees their content.
|
|
func renderMarkdown(md string, width int) string {
|
|
if strings.TrimSpace(md) == "" {
|
|
return hintStyle.Render("(no description)")
|
|
}
|
|
if width < 10 {
|
|
width = 10
|
|
}
|
|
// Match the preview to the terminal background, then drop the document
|
|
// block's default 2-column margin so the preview text aligns flush-left with
|
|
// the edit-mode textarea instead of being indented. Copying the struct and
|
|
// replacing the Margin pointer leaves the shared package-level style
|
|
// untouched.
|
|
style := styles.DarkStyleConfig
|
|
if !lipgloss.HasDarkBackground() {
|
|
style = styles.LightStyleConfig
|
|
}
|
|
var noMargin uint
|
|
style.Document.Margin = &noMargin
|
|
r, err := glamour.NewTermRenderer(
|
|
glamour.WithStyles(style),
|
|
glamour.WithWordWrap(width),
|
|
)
|
|
if err != nil {
|
|
return md
|
|
}
|
|
out, err := r.Render(md)
|
|
if err != nil {
|
|
return md
|
|
}
|
|
return strings.Trim(out, "\n")
|
|
}
|