package tui import ( "fmt" "os" "os/exec" "strings" "time" "greq/internal/api" "greq/internal/storage" "github.com/atotto/clipboard" "github.com/charmbracelet/bubbles/list" "github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" ) func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Window resize is handled regardless of state. if sz, ok := msg.(tea.WindowSizeMsg); ok { m.width, m.height = sz.Width, sz.Height m = m.applySize() return m, nil } switch m.state { case stateWizard: return m.updateWizard(msg) case stateCurlImport: return m.updateCurlImport(msg) case stateEditorPicker: return m.updateEditorPicker(msg) default: return m.updateList(msg) } } // --------------------------------------------------------------------------- // List / main state // --------------------------------------------------------------------------- func (m Model) updateList(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: // Let the list handle its own filter-mode keys first. if m.list.FilterState() == list.Filtering { var cmd tea.Cmd m.list, cmd = m.list.Update(msg) return m, cmd } switch msg.String() { case "ctrl+c", "q": return m, tea.Quit case "n": m.state = stateWizard m.wizardInputs = newWizardInputs() m.wizardFocus = 0 m.statusMsg = "" return m, nil case "i": // import from cURL ti := textinput.New() ti.Placeholder = "Paste curl command…" ti.CharLimit = 4096 ti.Width = m.width - 12 ti.Focus() m.curlInput = ti m.state = stateCurlImport return m, nil case "c": // copy selected request as cURL if i, ok := m.list.SelectedItem().(requestItem); ok { curl := api.ExportCURL(i.request, m.envs) if err := copyToClipboard(curl); err != nil { // Clipboard tool not available — show the command in the // response panel so the user can manually select & copy it. m.respText = curl m.vpResp.SetContent(curl) m.vpResp.GotoTop() m.statusMsg = clipboardInstallHint() } else { m.statusMsg = "Copied cURL to clipboard ✓" } } return m, nil case "d": // diff current response vs snapshot if m.response != nil && m.lastRequest != nil { name := m.lastRequest.Name if !snapshotExists(name) { m.statusMsg = "No baseline — press S to save current as baseline" } else { old, err := loadSnapshot(name) if err != nil { m.statusMsg = "Snapshot error: " + err.Error() } else { lines := computeDiff(old, m.response.Body) m.diffText = renderDiff(lines, m.vpDiff.Width) m.vpDiff.SetContent(m.diffText) m.vpDiff.GotoTop() m.showDiff = !m.showDiff } } } else { m.statusMsg = "Run a request first" } return m, nil case "S": // save snapshot baseline (shift+s) if m.response != nil && m.lastRequest != nil { if err := saveSnapshot(m.lastRequest.Name, m.response.Body); err != nil { m.statusMsg = "Snapshot error: " + err.Error() } else { m.statusMsg = "Baseline saved ✓" } } else { m.statusMsg = "Nothing to save" } return m, nil case "1", "2", "3", "4", "5", "6", "7", "8", "9": idx := int(msg.String()[0]-'0') - 1 if idx < len(m.envList) { envs, err := storage.LoadEnvFromFile(m.envList[idx].Path) if err != nil { m.statusMsg = "Env load error: " + err.Error() } else { m.activeEnv = idx m.envs = envs m.statusMsg = "Env: " + m.envList[idx].Name } } return m, nil case "e": if i, ok := m.list.SelectedItem().(requestItem); ok { return m.openInEditor(i.path) } case "r": m.statusMsg = "Reloading…" return m, reloadCmd(&m) case "h": m.showHistory = !m.showHistory if m.showHistory { content := renderHistoryTable(m.history, m.vpHistory.Width) m.vpHistory.SetContent(content) m.vpHistory.GotoTop() } return m, nil case "tab": if !m.showHistory { m.vpFocus = !m.vpFocus } return m, nil case "esc": if m.showDiff { m.showDiff = false return m, nil } if m.showHistory { m.showHistory = false return m, nil } case "s": // stress test if i, ok := m.list.SelectedItem().(requestItem); ok { m.stressLoading = true m.stressResult = nil m.response = nil m.lastRequest = nil m.err = nil m.respText = "" m.vpResp.SetContent("") m.statusMsg = fmt.Sprintf("⚡ Running stress test (%d requests)…", api.StressCount) return m, doStressCmd(i.request, m.envs) } return m, nil case "enter": if i, ok := m.list.SelectedItem().(requestItem); ok { m.loading = true m.statusMsg = "Sending…" m.respText = "" m.response = nil m.lastRequest = nil m.err = nil m.stressResult = nil m.vpResp.SetContent("") return m, doRequestCmd(i.request, m.envs) } // folderItem selected → do nothing return m, nil } case responseMsg: m.loading = false m.response = msg.resp m.lastRequest = &msg.req m.testResults = msg.testResults m.err = nil highlighted := highlightJSON(msg.resp.Body) m.respText = highlighted m.vpResp.SetContent(highlighted) m.vpResp.GotoTop() ct := msg.resp.Headers.Get("Content-Type") if idx := strings.Index(ct, ";"); idx != -1 { ct = strings.TrimSpace(ct[:idx]) } m.history = appendHistory(m.history, HistoryEntry{ At: timeNow(), Name: msg.req.Name, Method: msg.req.Method, URL: api.ResolveVariables(msg.req.URL, m.envs), StatusCode: msg.resp.StatusCode, Status: msg.resp.Status, Duration: msg.resp.Duration, Size: msg.resp.Size, ContentType: ct, }) switch { case api.SavedToken != "": m.statusMsg = "Token captured ✓" case testSummary(m.testResults) != "": m.statusMsg = testSummary(m.testResults) default: m.statusMsg = "" } return m, nil case errMsg: m.loading = false m.err = msg.err m.lastRequest = &msg.req m.statusMsg = "Error: " + msg.err.Error() errText := "Error:\n\n" + msg.err.Error() m.vpResp.SetContent(errText) m.vpResp.GotoTop() m.history = appendHistory(m.history, HistoryEntry{ At: timeNow(), Name: msg.req.Name, Method: msg.req.Method, URL: api.ResolveVariables(msg.req.URL, m.envs), IsError: true, ErrMsg: msg.err.Error(), }) return m, nil case stressResultMsg: m.stressLoading = false m.stressResult = &msg.result m.lastRequest = &msg.req text := renderStressResult(msg.result) m.respText = text m.vpResp.SetContent(text) m.vpResp.GotoTop() m.statusMsg = fmt.Sprintf("⚡ Done: %d requests, %.1f%% errors", msg.result.Total, msg.result.ErrRate()) return m, nil case editorDoneMsg: m.statusMsg = "Reloading after edit…" return m, reloadCmd(&m) case itemsReloadedMsg: m.list.SetItems(msg.items) envs, _ := storage.LoadEnv() m.envs = envs if m.statusMsg == "Reloading…" || m.statusMsg == "Reloading after edit…" { m.statusMsg = "" } return m, nil } // Route keyboard/mouse to the focused panel. var cmd tea.Cmd switch { case m.showDiff: m.vpDiff, cmd = m.vpDiff.Update(msg) case m.showHistory: m.vpHistory, cmd = m.vpHistory.Update(msg) case m.vpFocus: m.vpResp, cmd = m.vpResp.Update(msg) default: m.list, cmd = m.list.Update(msg) } return m, cmd } // --------------------------------------------------------------------------- // Wizard state // --------------------------------------------------------------------------- func (m Model) updateWizard(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: switch msg.String() { case "esc", "ctrl+c": m.state = stateList return m, nil case "tab", "shift+tab": m.wizardInputs[m.wizardFocus].Blur() if msg.String() == "tab" { m.wizardFocus = (m.wizardFocus + 1) % len(m.wizardInputs) } else { m.wizardFocus = (m.wizardFocus - 1 + len(m.wizardInputs)) % len(m.wizardInputs) } m.wizardInputs[m.wizardFocus].Focus() return m, nil case "enter": // On the last field → save and return; otherwise advance. if m.wizardFocus < len(m.wizardInputs)-1 { m.wizardInputs[m.wizardFocus].Blur() m.wizardFocus++ m.wizardInputs[m.wizardFocus].Focus() return m, nil } // Save rf, folder := wizardToRequest(m.wizardInputs) if _, err := storage.SaveRequest(rf, folder); err != nil { m.statusMsg = "Save failed: " + err.Error() } else { m.statusMsg = "Saved " + rf.Name } m.state = stateList return m, reloadCmd(&m) } } // Forward key events to the focused input. var cmd tea.Cmd m.wizardInputs[m.wizardFocus], cmd = m.wizardInputs[m.wizardFocus].Update(msg) return m, cmd } // --------------------------------------------------------------------------- // Commands // --------------------------------------------------------------------------- func doRequestCmd(rf storage.RequestFile, envs map[string]string) tea.Cmd { return func() tea.Msg { // 1. Run pre-script (shell) to get extra headers extraHeaders, err := api.RunPreScript(rf.PreScript, rf, envs) if err != nil { return errMsg{req: rf, err: err} } // Merge extra headers into a copy of the request if len(extraHeaders) > 0 { merged := make(map[string]string) for k, v := range rf.Headers { merged[k] = v } for k, v := range extraHeaders { merged[k] = v } rf = rf // make a local copy rf.Headers = merged } // 2. Execute the HTTP request resp, err := api.Do(rf, envs) if err != nil { return errMsg{req: rf, err: err} } // 3. Run test script (Lua) testResults, _ := api.RunTestScript(rf.TestScript, resp) return responseMsg{req: rf, resp: resp, testResults: testResults} } } func doStressCmd(rf storage.RequestFile, envs map[string]string) tea.Cmd { return func() tea.Msg { result := api.RunStress(rf, envs) return stressResultMsg{req: rf, result: result} } } func reloadCmd(m *Model) tea.Cmd { return func() tea.Msg { items, _ := buildListItems() return itemsReloadedMsg{items} } } // --------------------------------------------------------------------------- // Sizing // --------------------------------------------------------------------------- const listWidth = 36 // total width of the left panel including borders // statsBarLines is the number of lines reserved at the bottom of the response // panel for the request-info line + divider + stats bar. const statsBarLines = 3 func (m Model) applySize() Model { headerH := 1 footerH := 2 // divider + key hints bodyH := m.height - headerH - footerH if bodyH < 4 { bodyH = 4 } leftContent := listWidth - 2 // subtract border chars rightTotal := m.width - listWidth if rightTotal < 4 { rightTotal = 4 } rightContent := rightTotal - 2 m.list.SetSize(leftContent, bodyH-2) // Response viewport: leave room for request-info + divider + stats bar vpH := bodyH - 2 - statsBarLines if vpH < 1 { vpH = 1 } m.vpResp = viewport.New(rightContent, vpH) m.vpResp.SetContent(m.respText) // History viewport ovH := m.height - 8 if ovH < 3 { ovH = 3 } ovW := m.width - 6 if ovW < 20 { ovW = 20 } m.vpHistory = viewport.New(ovW-4, ovH) m.vpHistory.SetContent(renderHistoryTable(m.history, ovW-4)) // Diff viewport (same size as history overlay) m.vpDiff = viewport.New(ovW-4, ovH) m.vpDiff.SetContent(m.diffText) return m } // --------------------------------------------------------------------------- // Wizard helper // --------------------------------------------------------------------------- // --------------------------------------------------------------------------- // History helpers // --------------------------------------------------------------------------- func appendHistory(history []HistoryEntry, e HistoryEntry) []HistoryEntry { history = append(history, e) if len(history) > maxHistory { history = history[len(history)-maxHistory:] } return history } // timeNow is a variable so tests can override it. var timeNow = func() time.Time { return time.Now() } // testSummary returns a short status string from test results, or "". func testSummary(results []api.TestResult) string { if len(results) == 0 { return "" } var passed, failed int for _, r := range results { if r.Pass { passed++ } else { failed++ } } if failed == 0 { return fmt.Sprintf("Tests: %d passed ✓", passed) } return fmt.Sprintf("Tests: %d failed, %d passed", failed, passed) } // copyToClipboard writes text to the system clipboard. func copyToClipboard(text string) error { return clipboard.WriteAll(text) } // clipboardInstallHint returns a short install suggestion based on the detected // desktop session (Wayland vs X11) or falls back to a generic message. func clipboardInstallHint() string { session := strings.ToLower(os.Getenv("XDG_SESSION_TYPE")) switch session { case "wayland": return "Clipboard unavailable — install wl-clipboard: sudo apt install wl-clipboard" case "x11", "x": return "Clipboard unavailable — install xclip: sudo apt install xclip" default: // Could be macOS (pbcopy built-in, shouldn't fail), or unknown. // Show generic hint; the curl is displayed in the panel. return "Clipboard unavailable — curl shown in panel (install xclip / wl-clipboard)" } } // --------------------------------------------------------------------------- // Editor resolution + picker // --------------------------------------------------------------------------- // openInEditor resolves the editor to use and either launches it immediately // or transitions to stateEditorPicker when no editor is configured/available. func (m Model) openInEditor(path string) (tea.Model, tea.Cmd) { // Priority: 1) .greq/config.yaml 2) $EDITOR 3) picker editorStr := m.cfg.Editor if editorStr == "" { editorStr = os.Getenv("EDITOR") } if editorStr != "" { bin, args := editorCommand(editorStr) cmdArgs := append(args, path) c := exec.Command(bin, cmdArgs...) return m, tea.ExecProcess(c, func(err error) tea.Msg { if err != nil { return errMsg{err: err} } return editorDoneMsg{} }) } // No editor configured — show picker editors := availableEditors() if len(editors) == 0 { m.statusMsg = "No editor found. Set EDITOR or add 'editor:' to .greq/config.yaml" return m, nil } m.state = stateEditorPicker m.pickerEditors = editors m.pickerCursor = 0 m.pickerTargetPath = path return m, nil } func (m Model) updateEditorPicker(msg tea.Msg) (tea.Model, tea.Cmd) { if key, ok := msg.(tea.KeyMsg); ok { switch key.String() { case "esc", "ctrl+c": m.state = stateList return m, nil case "up", "k": if m.pickerCursor > 0 { m.pickerCursor-- } return m, nil case "down", "j": if m.pickerCursor < len(m.pickerEditors)-1 { m.pickerCursor++ } return m, nil case "enter", " ": chosen := m.pickerEditors[m.pickerCursor] // Ask whether to save as default (second Enter press would be too // complex; we always save — the user can edit config.yaml to change it) m.cfg.Editor = chosen.bin if len(chosen.args) > 0 { m.cfg.Editor = chosen.bin + " " + strings.Join(chosen.args, " ") } _ = storage.SaveConfig(m.cfg) m.statusMsg = "Default editor saved: " + m.cfg.Editor m.state = stateList cmdArgs := append(chosen.args, m.pickerTargetPath) c := exec.Command(chosen.bin, cmdArgs...) return m, tea.ExecProcess(c, func(err error) tea.Msg { if err != nil { return errMsg{err: err} } return editorDoneMsg{} }) } } return m, nil } // --------------------------------------------------------------------------- // cURL import state // --------------------------------------------------------------------------- func (m Model) updateCurlImport(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: switch msg.String() { case "esc", "ctrl+c": m.state = stateList return m, nil case "enter": raw := m.curlInput.Value() rf, err := api.ImportCURL(raw) if err != nil { m.statusMsg = "cURL parse error: " + err.Error() m.state = stateList return m, nil } // Pre-fill wizard inputs with parsed values m.state = stateWizard m.wizardInputs = newWizardInputs() m.wizardInputs[0].SetValue(rf.Name) m.wizardInputs[1].SetValue(rf.Method) m.wizardInputs[2].SetValue(rf.URL) if len(rf.Headers) > 0 { var hparts []string for k, v := range rf.Headers { hparts = append(hparts, k+":"+v) } m.wizardInputs[4].SetValue(strings.Join(hparts, ", ")) } m.wizardInputs[5].SetValue(rf.Body) m.wizardFocus = 0 return m, nil } } var cmd tea.Cmd m.curlInput, cmd = m.curlInput.Update(msg) return m, cmd } // --------------------------------------------------------------------------- // Wizard helper // --------------------------------------------------------------------------- // wizardToRequest converts the wizard textinputs into a RequestFile and folder. func wizardToRequest(inputs []textinput.Model) (storage.RequestFile, string) { get := func(i int) string { if i < len(inputs) { return strings.TrimSpace(inputs[i].Value()) } return "" } method := strings.ToUpper(get(1)) if method == "" { method = "GET" } rf := storage.RequestFile{ Name: get(0), Method: method, URL: get(2), Body: get(5), } // Parse headers: "Key:Value, Key2:Value2" if rawHeaders := get(4); rawHeaders != "" { rf.Headers = make(map[string]string) for _, pair := range strings.Split(rawHeaders, ",") { parts := strings.SplitN(strings.TrimSpace(pair), ":", 2) if len(parts) == 2 { rf.Headers[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1]) } } } folder := get(3) return rf, folder }