Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ module github.com/bborn/workflow
go 1.25.0

require (
github.com/alecthomas/chroma/v2 v2.20.0
github.com/atotto/clipboard v0.1.4
github.com/charmbracelet/bubbles v1.0.0
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/glamour v1.0.0
Expand All @@ -20,9 +22,7 @@ require (
)

require (
github.com/alecthomas/chroma/v2 v2.20.0 // indirect
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/catppuccin/go v0.3.0 // indirect
Expand Down
38 changes: 38 additions & 0 deletions internal/ui/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,8 @@ type KeyMap struct {
// Spotlight mode
Spotlight key.Binding
SpotlightSync key.Binding
// Read-only file/diff viewer in the detail view
ViewDiff key.Binding
}

// ShortHelp returns key bindings to show in the mini help.
Expand Down Expand Up @@ -281,6 +283,10 @@ func DefaultKeyMap() KeyMap {
key.WithKeys("F"),
key.WithHelp("F", "spotlight sync"),
),
ViewDiff: key.NewBinding(
key.WithKeys("v"),
key.WithHelp("v", "review changes"),
),
}
}

Expand Down Expand Up @@ -781,6 +787,18 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m.updateDetail(msg)
}

// Diff viewer comment input: route all messages (keys + cursor blink) so the
// text input stays live, but exempt the viewer's own async result messages
// so they reach their top-level cases instead of being eaten here.
if m.currentView == ViewDetail && m.detailView != nil && m.detailView.InCommentInput() {
switch msg.(type) {
case diffContentLoadedMsg, diffFilesLoadedMsg, reviewSentMsg:
// fall through to the main switch / default detail routing
default:
return m.updateDetail(msg)
}
}

// Handle filter input mode (needs all message types for text input)
if m.currentView == ViewDashboard && m.filterActive {
return m.updateFilterMode(msg)
Expand Down Expand Up @@ -2467,6 +2485,14 @@ func (m *AppModel) updateDetail(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, cmd
}

// While the diff viewer's comment input is open, route every key into it
// (bypassing the detail-view keybindings) so the user can type freely.
if m.detailView != nil && m.detailView.InCommentInput() {
var cmd tea.Cmd
m.detailView, cmd = m.detailView.UpdateCommentInput(msg)
return m, cmd
}

// Handle key messages
keyMsg, ok := msg.(tea.KeyMsg)
if !ok {
Expand All @@ -2479,6 +2505,15 @@ func (m *AppModel) updateDetail(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}

// When the read-only file/diff viewer is open, let it consume navigation
// keys (file up/down, mode toggle, esc to close) before the normal detail
// keybindings. Keys it doesn't consume (j/k scrolling, etc.) fall through.
if m.detailView != nil && m.detailView.FileViewerActive() {
if handled, vcmd := m.detailView.HandleFileViewerKey(keyMsg); handled {
return m, vcmd
}
}

if key.Matches(keyMsg, m.keys.Back) {
m.currentView = ViewDashboard
// Clear origin column when exiting detail view
Expand Down Expand Up @@ -2610,6 +2645,9 @@ func (m *AppModel) updateDetail(msg tea.Msg) (tea.Model, tea.Cmd) {
m.detailView.ToggleShellPane()
return m, nil
}
if key.Matches(keyMsg, m.keys.ViewDiff) && m.detailView != nil && m.selectedTask != nil {
return m, m.detailView.OpenFileViewer()
}

// Arrow key navigation to prev/next task in the same column
// j/k keys are passed through to the viewport for scrolling
Expand Down
105 changes: 103 additions & 2 deletions internal/ui/detail.go
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,12 @@ type DetailModel struct {
relatedTasksLoading bool // true while loading related tasks
relatedTasksLoaded bool // true once loaded (even if empty)
lastRelatedSearch string // cache key for related task search

// Read-only git file & diff viewer (see diffviewer.go). When active, the
// detail box renders a changed-file tree plus the selected file's diff
// instead of the task body. All of its display state is folded into
// viewSignature so the View render cache stays correct.
diff *diffViewer
}

// Message types for async pane loading
Expand Down Expand Up @@ -768,7 +774,7 @@ func (m *DetailModel) initViewport() {
// Just use full height - the TUI pane will be resized by tmux
}

m.viewport = viewport.New(m.width-4, vpHeight)
m.viewport = viewport.New(m.contentViewportWidth(), vpHeight)
m.setViewportContent()
m.ready = true
}
Expand All @@ -780,7 +786,7 @@ func (m *DetailModel) SetSize(width, height int) {
if m.ready {
headerHeight := 6
footerHeight := 2
m.viewport.Width = width - 4
m.viewport.Width = m.contentViewportWidth()
m.viewport.Height = height - headerHeight - footerHeight
m.setViewportContent()
}
Expand Down Expand Up @@ -855,6 +861,20 @@ func (m *DetailModel) Update(msg tea.Msg) (*DetailModel, tea.Cmd) {
log.Info("panesRefreshMsg: refreshing panes for task %d", m.task.ID)
// Re-start the async pane setup
return m, m.startPanesAsync()

case diffFilesLoadedMsg:
// Changed-file list for the file/diff viewer loaded.
return m, m.HandleDiffFilesLoaded(msg)

case diffContentLoadedMsg:
// Selected file's diff/rendered content loaded.
m.HandleDiffContentLoaded(msg)
return m, nil

case reviewSentMsg:
// Review comments delivered to the executor (or clipboard).
m.HandleReviewSent(msg)
return m, nil
}

// Pass all messages to viewport for scrolling support
Expand Down Expand Up @@ -2362,6 +2382,14 @@ func (m *DetailModel) View() string {

content := m.viewport.View()

// When the file/diff viewer is open, render the changed-file tree to the
// left of the (narrowed) content viewport.
if m.diff != nil && m.diff.active {
tree := m.renderDiffTree(m.viewport.Height)
gutter := lipgloss.NewStyle().Width(1).Height(m.viewport.Height).Render("")
content = lipgloss.JoinHorizontal(lipgloss.Top, tree, gutter, content)
}

// Use dimmed border when unfocused
borderColor := ColorPrimary
if !m.focused {
Expand Down Expand Up @@ -2457,6 +2485,23 @@ func (m *DetailModel) viewSignature(header, help string) uint64 {
h.int(m.viewport.Height)
h.int(m.viewport.TotalLineCount())
h.int(m.viewport.VisibleLineCount())
// File/diff viewer state drives the left tree column rendered in View();
// fold it in so selecting a file or toggling the viewer busts the cache.
if m.diff != nil {
h.boolean(m.diff.active)
h.boolean(m.diff.loading)
h.int(m.diff.selected)
h.int(len(m.diff.files))
h.boolean(m.diff.showRendered)
h.str(m.diff.loadErr)
// Interactive review state: the line cursor, comment input, and status
// all affect the rendered content/footer, so fold them in.
h.int(m.diff.cursor)
h.int(len(m.diff.comments))
h.boolean(m.diff.commenting)
h.str(m.diff.input.Value())
h.str(m.diff.statusMsg)
}
h.str(header)
h.str(help)
return h.h
Expand Down Expand Up @@ -2801,6 +2846,11 @@ func (m *DetailModel) computeLogHash() uint64 {
func (m *DetailModel) renderContent() string {
t := m.task

// File/diff viewer takes over the content pane when open.
if m.diff != nil && m.diff.active {
return m.renderDiffContent()
}

// Check if we can use cached content
// Note: We don't cache when related tasks are loading/changing
logHash := m.computeLogHash()
Expand Down Expand Up @@ -3031,6 +3081,52 @@ func (m *DetailModel) renderHelp() string {
disabled bool // When disabled, always show grayed out
}

// File/diff viewer has its own, focused help line.
if m.diff != nil && m.diff.active {
// While typing a comment, the footer is the input field.
if m.diff.commenting {
prompt := HelpKey.Render("comment") + " " + m.diff.input.View() +
" " + HelpKey.Render("enter") + " " + HelpDesc.Render("save") +
" " + HelpKey.Render("esc") + " " + HelpDesc.Render("cancel")
return HelpBar.Render(prompt)
}
noFiles := len(m.diff.files) == 0
cursorMode := m.diff.cursorActive()
scrollDesc := "scroll"
if cursorMode {
scrollDesc = "line"
}
viewerKeys := []helpKey{
{IconArrowUp() + "/" + IconArrowDown(), "file", noFiles},
{"j/k", scrollDesc, false},
{"tab", "diff/rendered", noFiles},
{"c", "comment", noFiles},
{"s", "send", len(m.diff.comments) == 0},
{"esc", "close", false},
}
var vh string
for i, k := range viewerKeys {
if i > 0 {
vh += " "
}
if k.disabled || !m.focused {
vh += lipgloss.NewStyle().Foreground(lipgloss.Color("#6B7280")).Render(k.key) + " " +
lipgloss.NewStyle().Foreground(lipgloss.Color("#4B5563")).Render(k.desc)
} else {
vh += HelpKey.Render(k.key) + " " + HelpDesc.Render(k.desc)
}
}
// Transient status (sent / copied / error) replaces the tail of the bar.
if m.diff.statusMsg != "" {
col := lipgloss.Color("#98C379")
if m.diff.statusIsErr {
col = lipgloss.Color("#E06C75")
}
vh += " " + lipgloss.NewStyle().Foreground(col).Render(m.diff.statusMsg)
}
return HelpBar.Render(vh)
}

// Check if navigation is available (more than 1 task in column)
hasNavigation := m.totalInColumn > 1

Expand Down Expand Up @@ -3100,6 +3196,11 @@ func (m *DetailModel) renderHelp() string {
}
}

// Review changes (file/diff viewer) when the task has a worktree
if m.task != nil && m.task.WorktreePath != "" {
keys = append(keys, helpKey{"v", "review changes", false})
}

// Open PR shortcut (only when task has a PR)
if m.task != nil && m.task.PRURL != "" {
keys = append(keys, helpKey{"G", "open PR", false})
Expand Down
Loading