diff --git a/cmd/roborev/tui/handlers_modal.go b/cmd/roborev/tui/handlers_modal.go index 5700ed20..681dbeb7 100644 --- a/cmd/roborev/tui/handlers_modal.go +++ b/cmd/roborev/tui/handlers_modal.go @@ -1,6 +1,9 @@ package tui import ( + "fmt" + "os" + "path/filepath" "strings" "time" "unicode" @@ -465,6 +468,39 @@ func (m model) handleTasksKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { // handlePatchKey handles key input in the patch viewer. func (m model) handlePatchKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + // When the save-filename input is active, route keys there. + if m.savePatchInputActive { + switch msg.String() { + case "ctrl+c": + return m, tea.Quit + case "esc": + m.savePatchInputActive = false + m.savePatchInput = "" + return m, nil + case "enter": + path := strings.TrimSpace(m.savePatchInput) + if path == "" { + return m, nil + } + m.savePatchInputActive = false + m.savePatchInput = "" + return m, m.savePatchToFile(path) + case "backspace": + if len(m.savePatchInput) > 0 { + runes := []rune(m.savePatchInput) + m.savePatchInput = string(runes[:len(runes)-1]) + } + return m, nil + default: + for _, r := range msg.Runes { + if unicode.IsPrint(r) { + m.savePatchInput += string(r) + } + } + return m, nil + } + } + switch msg.String() { case "ctrl+c": return m, tea.Quit @@ -474,6 +510,10 @@ func (m model) handlePatchKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.patchScroll = 0 m.patchJobID = 0 return m, nil + case "s": + m.savePatchInputActive = true + m.savePatchInput = filepath.Join(os.TempDir(), fmt.Sprintf("roborev-%d.patch", m.patchJobID)) + return m, nil case "up", "k": if m.patchScroll > 0 { m.patchScroll-- @@ -501,3 +541,14 @@ func (m model) handlePatchKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } return m, nil } + +// savePatchToFile writes the current patch text to path. +func (m model) savePatchToFile(path string) tea.Cmd { + patch := m.patchText + return func() tea.Msg { + if err := os.WriteFile(path, []byte(patch), 0o644); err != nil { + return savePatchResultMsg{err: err} + } + return savePatchResultMsg{path: path} + } +} diff --git a/cmd/roborev/tui/handlers_msg.go b/cmd/roborev/tui/handlers_msg.go index 10ce4d2e..a527c2d0 100644 --- a/cmd/roborev/tui/handlers_msg.go +++ b/cmd/roborev/tui/handlers_msg.go @@ -653,6 +653,16 @@ func (m model) handleClipboardResultMsg( return m, nil } +// handleSavePatchResultMsg processes save-patch-to-file results. +func (m model) handleSavePatchResultMsg(msg savePatchResultMsg) (tea.Model, tea.Cmd) { + if msg.err != nil { + m.err = fmt.Errorf("save patch failed: %w", msg.err) + } else { + m.setFlash("Saved to "+msg.path, 3*time.Second, viewPatch) + } + return m, nil +} + // handleReconnectMsg processes daemon reconnection attempts. func (m model) handleReconnectMsg(msg reconnectMsg) (tea.Model, tea.Cmd) { m.reconnecting = false diff --git a/cmd/roborev/tui/render_tasks.go b/cmd/roborev/tui/render_tasks.go index e2b67d25..83a94b9b 100644 --- a/cmd/roborev/tui/render_tasks.go +++ b/cmd/roborev/tui/render_tasks.go @@ -6,6 +6,7 @@ import ( "path/filepath" "strings" "time" + "unicode/utf8" "github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss/table" @@ -495,9 +496,24 @@ func (m model) renderPatchView() string { } } - b.WriteString(renderHelpTable([][]helpItem{ - {{"j/k/↑/↓", "scroll"}, {"esc", "back to tasks"}}, - }, m.width)) + if m.savePatchInputActive { + label := "Save to: " + inputWidth := max(m.width-len(label)-2, 10) + display := m.savePatchInput + if size := utf8.RuneCountInString(display); size > inputWidth { + rs := []rune(display) + display = string(rs[size-inputWidth:]) + } + display = display + strings.Repeat(" ", max(inputWidth-len(display), 0)) + b.WriteString(helpStyle.Render(label) + display + "\x1b[K\n") + b.WriteString(renderHelpTable([][]helpItem{ + {{"enter", "save"}, {"esc", "cancel"}}, + }, m.width)) + } else { + b.WriteString(renderHelpTable([][]helpItem{ + {{"j/k/↑/↓", "scroll"}, {"s", "save"}, {"esc", "back to tasks"}}, + }, m.width)) + } b.WriteString("\x1b[K\x1b[J") return b.String() } diff --git a/cmd/roborev/tui/tui.go b/cmd/roborev/tui/tui.go index 73f96c66..43494f04 100644 --- a/cmd/roborev/tui/tui.go +++ b/cmd/roborev/tui/tui.go @@ -370,14 +370,16 @@ type model struct { reviewFromView viewKind // View to return to when exiting review (queue or tasks) // Fix task state - fixJobs []storage.ReviewJob // Fix jobs for tasks view - fixSelectedIdx int // Selected index in tasks view - fixPromptText string // Editable fix prompt text - fixPromptJobID int64 // Parent job ID for fix prompt modal - fixShowHelp bool // Show help overlay in tasks view - patchText string // Current patch text for patch viewer - patchScroll int // Scroll offset in patch viewer - patchJobID int64 // Job ID of the patch being viewed + fixJobs []storage.ReviewJob // Fix jobs for tasks view + fixSelectedIdx int // Selected index in tasks view + fixPromptText string // Editable fix prompt text + fixPromptJobID int64 // Parent job ID for fix prompt modal + fixShowHelp bool // Show help overlay in tasks view + patchText string // Current patch text for patch viewer + patchScroll int // Scroll offset in patch viewer + patchJobID int64 // Job ID of the patch being viewed + savePatchInputActive bool // Whether the save-filename input is visible + savePatchInput string // Current text in the save-filename input // Inline fix panel (review view) reviewFixPanelOpen bool // true when fix panel is visible in review view @@ -692,6 +694,8 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { result, cmd = m.handlePatchResultMsg(msg) case applyPatchResultMsg: result, cmd = m.handleApplyPatchResultMsg(msg) + case savePatchResultMsg: + result, cmd = m.handleSavePatchResultMsg(msg) case configSaveErrMsg: m.colOptionsDirty = true m.setFlash( diff --git a/cmd/roborev/tui/types.go b/cmd/roborev/tui/types.go index 8e30fed1..5fb28977 100644 --- a/cmd/roborev/tui/types.go +++ b/cmd/roborev/tui/types.go @@ -230,6 +230,11 @@ type patchMsg struct { err error } +type savePatchResultMsg struct { + path string + err error +} + // ClipboardWriter is an interface for clipboard operations (allows mocking in tests) type ClipboardWriter interface { WriteText(text string) error