Skip to content

Commit 69df4c8

Browse files
author
Bruce Hill
committed
Working version of fast diagnostics
1 parent 16b7eb1 commit 69df4c8

File tree

3 files changed

+255
-51
lines changed

3 files changed

+255
-51
lines changed

pkg/cmd/dev.go

Lines changed: 46 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ type fetchBuildMsg *stainless.Build
4747
type fetchDiagnosticsMsg []stainless.BuildDiagnostic
4848
type errorMsg error
4949
type downloadMsg stainless.Target
50+
type fileChangeMsg struct{}
5051

5152
func NewBuildModel(cc *apiCommandContext, ctx context.Context, branch string, fn func() (*stainless.Build, error)) BuildModel {
5253
return BuildModel{
@@ -160,6 +161,10 @@ func (m BuildModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
160161
case errorMsg:
161162
m.err = msg
162163
cmds = append(cmds, tea.Quit)
164+
165+
case fileChangeMsg:
166+
// File change detected, exit with success
167+
cmds = append(cmds, tea.Quit)
163168
}
164169
return m, tea.Sequence(cmds...)
165170
}
@@ -367,6 +372,9 @@ func runPreview(ctx context.Context, cmd *cli.Command) error {
367372
if hasBlockingDiagnostic(diagnostics) {
368373
fmt.Println("\nDiagnostic checks will re-run once you edit your configuration files...")
369374
if err := waitTillConfigChanges(ctx, cmd, cc); err != nil {
375+
if errors.Is(err, ErrUserCancelled) {
376+
return nil
377+
}
370378
return err
371379
}
372380
continue
@@ -400,32 +408,46 @@ func waitTillConfigChanges(ctx context.Context, cmd *cli.Command, cc *apiCommand
400408
stainlessConfigPath = cmd.String("stainless-config")
401409
}
402410

403-
files := []string{openapiSpecPath, stainlessConfigPath}
404-
lastModified := make(map[string]time.Time)
405-
for _, file := range files {
406-
if stat, err := os.Stat(file); err == nil {
407-
lastModified[file] = stat.ModTime()
408-
}
411+
// Get initial file modification times
412+
openapiSpecInfo, err := os.Stat(openapiSpecPath)
413+
if err != nil {
414+
return fmt.Errorf("failed to get file info for %s: %v", openapiSpecPath, err)
415+
}
416+
openapiSpecModTime := openapiSpecInfo.ModTime()
417+
418+
stainlessConfigInfo, err := os.Stat(stainlessConfigPath)
419+
if err != nil {
420+
return fmt.Errorf("failed to get file info for %s: %v", stainlessConfigPath, err)
409421
}
422+
stainlessConfigModTime := stainlessConfigInfo.ModTime()
423+
424+
fmt.Println("Waiting for file changes...")
425+
426+
// Poll for file changes every 250ms
427+
ticker := time.NewTicker(250 * time.Millisecond)
428+
defer ticker.Stop()
410429

411430
for {
412-
time.Sleep(500 * time.Millisecond)
431+
select {
432+
case <-ticker.C:
433+
// Check OpenAPI spec file
434+
if info, err := os.Stat(openapiSpecPath); err == nil {
435+
if info.ModTime().After(openapiSpecModTime) {
436+
return nil
437+
}
438+
}
413439

414-
for _, file := range files {
415-
if stat, err := os.Stat(file); err == nil {
416-
if stat.ModTime().After(lastModified[file]) {
440+
// Check Stainless config file
441+
if info, err := os.Stat(stainlessConfigPath); err == nil {
442+
if info.ModTime().After(stainlessConfigModTime) {
417443
return nil
418444
}
419445
}
420-
}
421-
// Check for cancellation
422-
select {
446+
423447
case <-ctx.Done():
424448
return ctx.Err()
425-
default:
426449
}
427450
}
428-
return nil
429451
}
430452

431453
func runDevBuild(ctx context.Context, cc *apiCommandContext, cmd *cli.Command, branch string, languages []stainless.Target) error {
@@ -496,6 +518,15 @@ func getCurrentGitBranch() (string, error) {
496518
return branch, nil
497519
}
498520

521+
type GenerateSpecParams struct {
522+
Project string `json:"project"`
523+
Source struct {
524+
Type string `json:"type"`
525+
OpenAPISpec string `json:"openapi_spec"`
526+
StainlessConfig string `json:"stainless_config"`
527+
} `json:"source"`
528+
}
529+
499530
func getDiagnostics(ctx context.Context, cmd *cli.Command, cc *apiCommandContext) ([]stainless.BuildDiagnostic, error) {
500531
var specParams GenerateSpecParams
501532
if cmd.IsSet("project") {

pkg/cmd/dev_view.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -394,7 +394,7 @@ func ViewDiagnosticsPrint(diagnostics []stainless.BuildDiagnostic, maxDiagnostic
394394

395395
s.WriteString(SProperty(0, "diagnostics", summary))
396396
s.WriteString(lipgloss.NewStyle().
397-
Padding(1).
397+
Padding(0).
398398
Border(lipgloss.RoundedBorder()).
399399
BorderForeground(lipgloss.Color("208")).
400400
Render(strings.TrimRight(sub.String(), "\n")),

pkg/cmd/lint.go

Lines changed: 208 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,14 @@ package cmd
22

33
import (
44
"context"
5-
"encoding/json"
65
"fmt"
76
"os"
7+
"time"
88

9-
"github.com/tidwall/gjson"
9+
"github.com/charmbracelet/bubbles/spinner"
10+
tea "github.com/charmbracelet/bubbletea"
11+
"github.com/charmbracelet/lipgloss"
12+
"github.com/stainless-api/stainless-api-go"
1013
"github.com/urfave/cli/v3"
1114
)
1215

@@ -38,48 +41,218 @@ var lintCommand = cli.Command{
3841
Action: runLinter,
3942
}
4043

41-
type GenerateSpecParams struct {
42-
Project string `json:"project"`
43-
Source struct {
44-
Type string `json:"type"`
45-
OpenAPISpec string `json:"openapi_spec"`
46-
StainlessConfig string `json:"stainless_config"`
47-
} `json:"source"`
44+
type lintModel struct {
45+
spinner spinner.Model
46+
diagnostics []stainless.BuildDiagnostic
47+
error error
48+
watching bool
49+
watchedFiles map[string]time.Time
50+
ctx context.Context
51+
cmd *cli.Command
52+
cc *apiCommandContext
53+
stopPolling chan struct{}
54+
help helpModel
55+
}
56+
57+
type helpModel struct {
58+
width int
59+
height int
60+
}
61+
62+
type fileChangedEvent struct{}
63+
64+
func waitForFileChanges(m lintModel) tea.Cmd {
65+
return func() tea.Msg {
66+
ticker := time.NewTicker(250 * time.Millisecond)
67+
defer ticker.Stop()
68+
69+
for {
70+
select {
71+
case <-ticker.C:
72+
for file, lastModTime := range m.watchedFiles {
73+
stat, err := os.Stat(file)
74+
if err != nil {
75+
continue
76+
}
77+
78+
if stat.ModTime().After(lastModTime) {
79+
m.watchedFiles[file] = stat.ModTime()
80+
return fileChangedEvent{}
81+
}
82+
}
83+
case <-m.stopPolling:
84+
return nil
85+
}
86+
}
87+
}
88+
}
89+
90+
func (m lintModel) Init() tea.Cmd {
91+
return m.spinner.Tick
92+
}
93+
94+
func (m lintModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
95+
switch msg := msg.(type) {
96+
case tea.KeyMsg:
97+
if msg.String() == "ctrl+c" {
98+
return m, tea.Quit
99+
}
100+
101+
case diagnosticsMsg:
102+
m.diagnostics = msg.diagnostics
103+
m.error = msg.err
104+
m.ctx = msg.ctx
105+
m.cmd = msg.cmd
106+
m.cc = msg.cc
107+
108+
if m.watching {
109+
return m, waitForFileChanges(m)
110+
}
111+
return m, tea.Quit
112+
113+
case fileChangedEvent:
114+
m.diagnostics = nil // Clear diagnostics while linting
115+
return m, getDiagnosticsCmd(m.ctx, m.cmd, m.cc)
116+
117+
case spinner.TickMsg:
118+
var cmd tea.Cmd
119+
m.spinner, cmd = m.spinner.Update(msg)
120+
return m, cmd
121+
122+
case tea.WindowSizeMsg:
123+
m.help.width = msg.Width
124+
m.help.height = msg.Height
125+
return m, nil
126+
}
127+
128+
return m, nil
129+
}
130+
131+
func (m lintModel) View() string {
132+
if m.error != nil {
133+
return fmt.Sprintf("Error: %s\n", m.error)
134+
}
135+
136+
var content string
137+
if m.diagnostics == nil {
138+
content = m.spinner.View() + " Linting"
139+
} else {
140+
content = ViewDiagnosticsPrint(m.diagnostics, -1)
141+
if m.watching {
142+
content += "\n" + m.spinner.View() + " Waiting for configuration changes"
143+
}
144+
}
145+
146+
// Add help menu
147+
helpStyle := lipgloss.NewStyle().
148+
Foreground(lipgloss.Color("241")).
149+
Margin(1, 0, 0, 0)
150+
151+
helpText := helpStyle.Render("Press Ctrl+C to exit")
152+
153+
return content + helpText
154+
}
155+
156+
type diagnosticsMsg struct {
157+
diagnostics []stainless.BuildDiagnostic
158+
err error
159+
ctx context.Context
160+
cmd *cli.Command
161+
cc *apiCommandContext
162+
}
163+
164+
func getDiagnosticsCmd(ctx context.Context, cmd *cli.Command, cc *apiCommandContext) tea.Cmd {
165+
return func() tea.Msg {
166+
diagnostics, err := getDiagnostics(ctx, cmd, cc)
167+
return diagnosticsMsg{
168+
diagnostics: diagnostics,
169+
err: err,
170+
ctx: ctx,
171+
cmd: cmd,
172+
cc: cc,
173+
}
174+
}
48175
}
49176

50177
func runLinter(ctx context.Context, cmd *cli.Command) error {
51178
cc := getAPICommandContext(cmd)
52-
for {
53-
diagnostics, err := getDiagnostics(ctx, cmd, cc)
54-
if err != nil {
179+
180+
s := spinner.New()
181+
s.Spinner = spinner.MiniDot
182+
s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("208"))
183+
184+
m := lintModel{
185+
spinner: s,
186+
watching: cmd.Bool("watch"),
187+
ctx: ctx,
188+
cmd: cmd,
189+
cc: cc,
190+
watchedFiles: make(map[string]time.Time),
191+
stopPolling: make(chan struct{}),
192+
help: helpModel{},
193+
}
194+
195+
if m.watching {
196+
if err := setupFileWatcher(&m, cmd, cc); err != nil {
55197
return err
56198
}
199+
}
57200

58-
if cmd.IsSet("format") {
59-
rawJson, err := json.Marshal(diagnostics)
60-
if err != nil {
61-
return err
62-
}
63-
jsonObj := gjson.Parse(string(rawJson))
64-
if err := ShowJSON("Diagnostics", jsonObj, cmd.String("format"), ""); err != nil {
65-
return err
66-
}
67-
} else {
68-
fmt.Println(ViewDiagnosticsPrint(diagnostics, -1))
69-
}
201+
p := tea.NewProgram(m, tea.WithContext(ctx))
70202

71-
if cmd.Bool("watch") {
72-
fmt.Println("\nDiagnostic checks will re-run once you edit your configuration files...")
73-
if err := waitTillConfigChanges(ctx, cmd, cc); err != nil {
74-
return err
75-
}
76-
} else {
77-
if hasBlockingDiagnostic(diagnostics) {
78-
os.Exit(1)
79-
}
80-
break
81-
}
203+
// Start the diagnostics process
204+
go func() {
205+
time.Sleep(100 * time.Millisecond) // Small delay to let the UI initialize
206+
p.Send(getDiagnosticsCmd(ctx, cmd, cc)())
207+
}()
208+
209+
model, err := p.Run()
210+
if err != nil {
211+
return err
82212
}
83213

214+
finalModel := model.(lintModel)
215+
if finalModel.stopPolling != nil {
216+
close(finalModel.stopPolling)
217+
}
218+
219+
if finalModel.error != nil {
220+
return finalModel.error
221+
}
222+
223+
// If not in watch mode and we have blocking diagnostics, exit with error code
224+
if !finalModel.watching && hasBlockingDiagnostic(finalModel.diagnostics) {
225+
os.Exit(1)
226+
}
227+
228+
return nil
229+
}
230+
231+
func setupFileWatcher(m *lintModel, cmd *cli.Command, cc *apiCommandContext) error {
232+
// Watch OpenAPI spec file
233+
openapiSpecPath := cc.workspaceConfig.OpenAPISpec
234+
if cmd.IsSet("openapi-spec") {
235+
openapiSpecPath = cmd.String("openapi-spec")
236+
}
237+
238+
if err := addFileToWatch(m, openapiSpecPath); err != nil {
239+
return err
240+
}
241+
242+
// Watch Stainless config file
243+
stainlessConfigPath := cc.workspaceConfig.StainlessConfig
244+
if cmd.IsSet("stainless-config") {
245+
stainlessConfigPath = cmd.String("stainless-config")
246+
}
247+
248+
return addFileToWatch(m, stainlessConfigPath)
249+
}
250+
251+
func addFileToWatch(m *lintModel, path string) error {
252+
stat, err := os.Stat(path)
253+
if err != nil {
254+
return fmt.Errorf("failed to get file info for %s: %v", path, err)
255+
}
256+
m.watchedFiles[path] = stat.ModTime()
84257
return nil
85258
}

0 commit comments

Comments
 (0)