From a95b90222ede7c20296fb59c7eb2a1fcc46b53c6 Mon Sep 17 00:00:00 2001 From: Max Kuznetsov Date: Sun, 22 Mar 2026 19:45:34 +0000 Subject: [PATCH 1/2] Support continous build with live reload --- README.md | 1 + go.mod | 1 + go.sum | 2 + internal/cli/client.go | 76 ++++-- internal/cli/client_download_test.go | 132 ++++++++++ internal/cli/client_test.go | 58 ++--- internal/cli/commands.go | 182 +++++++++---- internal/cli/commands_test.go | 36 +-- internal/cli/config.go | 2 +- internal/cli/export_test.go | 17 ++ internal/cli/hasher.go | 9 +- internal/cli/hasher_test.go | 75 ++++-- internal/cli/ui.go | 17 +- internal/cli/watcher.go | 369 +++++++++++++++++++++++++++ internal/cli/watcher_test.go | 295 +++++++++++++++++++++ man/tx.1 | 13 + 16 files changed, 1138 insertions(+), 147 deletions(-) create mode 100644 internal/cli/client_download_test.go create mode 100644 internal/cli/watcher.go create mode 100644 internal/cli/watcher_test.go diff --git a/README.md b/README.md index bf4d0c3..60b8c31 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ tx login # Authenticate with TexOps tx init # Initialize a project in the current directory tx build # Build all documents tx build # Build a specific document +tx build --live # Watch for changes and rebuild automatically tx status # Show project status tx token create [--name "CI"] # Create an API token tx token list # List API tokens diff --git a/go.mod b/go.mod index ce48c9d..3a171f6 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/charmbracelet/bubbles v1.0.0 github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.0 + github.com/fsnotify/fsnotify v1.9.0 github.com/jessevdk/go-flags v1.6.1 github.com/mattn/go-isatty v0.0.20 github.com/muesli/termenv v0.16.0 diff --git a/go.sum b/go.sum index 5e03515..92d16b2 100644 --- a/go.sum +++ b/go.sum @@ -33,6 +33,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= diff --git a/internal/cli/client.go b/internal/cli/client.go index 483c47e..76e6e6c 100644 --- a/internal/cli/client.go +++ b/internal/cli/client.go @@ -4,6 +4,7 @@ import ( "archive/tar" "bufio" "bytes" + "context" "encoding/json" "fmt" "io" @@ -103,7 +104,7 @@ func (c *APIClient) SetHTTPClient(hc *http.Client) { c.httpClient = hc } -func (c *APIClient) CreateProject(name, distVersion, projectKey string) (CreateProjectResponse, error) { +func (c *APIClient) CreateProject(ctx context.Context, name, distVersion, projectKey string) (CreateProjectResponse, error) { payload := map[string]string{ "name": name, "distribution_version": distVersion, @@ -116,7 +117,7 @@ func (c *APIClient) CreateProject(name, distVersion, projectKey string) (CreateP return CreateProjectResponse{}, err } - req, err := http.NewRequest("POST", c.baseURL+"/api/projects", bytes.NewReader(body)) + req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/api/projects", bytes.NewReader(body)) if err != nil { return CreateProjectResponse{}, err } @@ -140,7 +141,7 @@ func (c *APIClient) CreateProject(name, distVersion, projectKey string) (CreateP return result, nil } -func (c *APIClient) GetSession(projectID, distributionVersion string) (SessionResponse, error) { +func (c *APIClient) GetSession(ctx context.Context, projectID, distributionVersion string) (SessionResponse, error) { u := fmt.Sprintf("%s/api/projects/%s/session", c.baseURL, projectID) body, err := json.Marshal(map[string]string{ @@ -150,7 +151,7 @@ func (c *APIClient) GetSession(projectID, distributionVersion string) (SessionRe return SessionResponse{}, err } - req, err := http.NewRequest("POST", u, bytes.NewReader(body)) + req, err := http.NewRequestWithContext(ctx, "POST", u, bytes.NewReader(body)) if err != nil { return SessionResponse{}, err } @@ -418,7 +419,7 @@ func (c *InstanceClient) SetHTTPClient(hc *http.Client) { c.httpClient = hc } -func (c *InstanceClient) Sync(projectID string, files []FileEntry) (SyncResult, error) { +func (c *InstanceClient) Sync(ctx context.Context, projectID string, files []FileEntry) (SyncResult, error) { body := struct { Files []FileEntry `json:"files"` }{Files: files} @@ -430,7 +431,7 @@ func (c *InstanceClient) Sync(projectID string, files []FileEntry) (SyncResult, u := fmt.Sprintf("%s/projects/%s/sync", c.baseURL, projectID) - req, err := http.NewRequest("POST", u, bytes.NewReader(data)) + req, err := http.NewRequestWithContext(ctx, "POST", u, bytes.NewReader(data)) if err != nil { return SyncResult{}, err } @@ -454,12 +455,12 @@ func (c *InstanceClient) Sync(projectID string, files []FileEntry) (SyncResult, return result, nil } -func (c *InstanceClient) Upload(projectID, projectDir string, filePaths []string, onProgress func(sent, total int64)) error { +func (c *InstanceClient) Upload(ctx context.Context, projectID, projectDir string, filePaths []string, onProgress func(sent, total int64)) error { if len(filePaths) == 0 { return nil } - tarData, err := createTar(projectDir, filePaths) + tarData, err := createTar(ctx, projectDir, filePaths) if err != nil { return err } @@ -479,7 +480,7 @@ func (c *InstanceClient) Upload(projectID, projectDir string, filePaths []string } } - req, err := http.NewRequest("POST", u, body) + req, err := http.NewRequestWithContext(ctx, "POST", u, body) if err != nil { return err } @@ -499,10 +500,10 @@ func (c *InstanceClient) Upload(projectID, projectDir string, filePaths []string return nil } -func (c *InstanceClient) UploadRaw(projectID string, tarData []byte) error { +func (c *InstanceClient) UploadRaw(ctx context.Context, projectID string, tarData []byte) error { u := fmt.Sprintf("%s/projects/%s/upload", c.baseURL, projectID) - req, err := http.NewRequest("POST", u, bytes.NewReader(tarData)) + req, err := http.NewRequestWithContext(ctx, "POST", u, bytes.NewReader(tarData)) if err != nil { return err } @@ -521,7 +522,7 @@ func (c *InstanceClient) UploadRaw(projectID string, tarData []byte) error { return nil } -func (c *InstanceClient) BuildWithArgs(projectID, main, directory, distVersion, compiler string, args []string, buildOptions map[string]string, onLog func(string)) (BuildDoneEvent, error) { +func (c *InstanceClient) BuildWithArgs(ctx context.Context, projectID, main, directory, distVersion, compiler string, args []string, buildOptions map[string]string, onLog func(string)) (BuildDoneEvent, error) { payload := map[string]any{ "main": main, "distribution_version": distVersion, @@ -542,7 +543,7 @@ func (c *InstanceClient) BuildWithArgs(projectID, main, directory, distVersion, u := fmt.Sprintf("%s/projects/%s/build", c.baseURL, projectID) - req, err := http.NewRequest("POST", u, bytes.NewReader(body)) + req, err := http.NewRequestWithContext(ctx, "POST", u, bytes.NewReader(body)) if err != nil { return BuildDoneEvent{}, err } @@ -562,7 +563,7 @@ func (c *InstanceClient) BuildWithArgs(projectID, main, directory, distVersion, return ParseSSEStream(resp.Body, onLog) } -func (c *InstanceClient) Build(projectID, main, directory, distVersion, compiler string, buildOptions map[string]string, onLog func(string)) (BuildDoneEvent, error) { +func (c *InstanceClient) Build(ctx context.Context, projectID, main, directory, distVersion, compiler string, buildOptions map[string]string, onLog func(string)) (BuildDoneEvent, error) { payload := map[string]any{ "main": main, "distribution_version": distVersion, @@ -580,7 +581,7 @@ func (c *InstanceClient) Build(projectID, main, directory, distVersion, compiler u := fmt.Sprintf("%s/projects/%s/build", c.baseURL, projectID) - req, err := http.NewRequest("POST", u, bytes.NewReader(body)) + req, err := http.NewRequestWithContext(ctx, "POST", u, bytes.NewReader(body)) if err != nil { return BuildDoneEvent{}, err } @@ -600,13 +601,13 @@ func (c *InstanceClient) Build(projectID, main, directory, distVersion, compiler return ParseSSEStream(resp.Body, onLog) } -func (c *InstanceClient) DownloadPDF(projectID, buildID, outputPath string) error { +func (c *InstanceClient) DownloadPDF(ctx context.Context, projectID, buildID, outputPath string) error { if !validIDPattern.MatchString(buildID) { return fmt.Errorf("invalid build ID format") } u := fmt.Sprintf("%s/projects/%s/builds/%s/output", c.baseURL, projectID, buildID) - req, err := http.NewRequest("GET", u, nil) + req, err := http.NewRequestWithContext(ctx, "GET", u, nil) if err != nil { return err } @@ -622,21 +623,45 @@ func (c *InstanceClient) DownloadPDF(projectID, buildID, outputPath string) erro return fmt.Errorf("PDF download failed (%d)", resp.StatusCode) } - f, err := os.Create(outputPath) + return writeFilePreserveInode(resp.Body, outputPath) +} + +func writeFilePreserveInode(r io.Reader, outputPath string) error { + tmp, err := os.CreateTemp(filepath.Dir(outputPath), ".tx-download-*.tmp") if err != nil { return err } - _, copyErr := io.Copy(f, resp.Body) - closeErr := f.Close() + tmpPath := tmp.Name() + defer os.Remove(tmpPath) + + _, copyErr := io.Copy(tmp, r) + closeErr := tmp.Close() if copyErr != nil { - _ = os.Remove(outputPath) return copyErr } if closeErr != nil { - _ = os.Remove(outputPath) return closeErr } - return nil + + tmpRead, err := os.Open(tmpPath) + if err != nil { + return err + } + + out, err := os.OpenFile(outputPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644) + if err != nil { + _ = tmpRead.Close() + return err + } + + _, copyErr = io.Copy(out, tmpRead) + _ = tmpRead.Close() + closeErr = out.Close() + + if copyErr != nil { + return copyErr + } + return closeErr } func ParseSSEStream(reader io.Reader, onLog func(string)) (BuildDoneEvent, error) { @@ -699,11 +724,14 @@ func extractSSEMessage(data string) string { return data } -func createTar(dir string, filePaths []string) ([]byte, error) { +func createTar(ctx context.Context, dir string, filePaths []string) ([]byte, error) { var buf bytes.Buffer tw := tar.NewWriter(&buf) for _, fp := range filePaths { + if ctx.Err() != nil { + return nil, ctx.Err() + } data, err := os.ReadFile(filepath.Join(dir, fp)) if err != nil { return nil, err diff --git a/internal/cli/client_download_test.go b/internal/cli/client_download_test.go new file mode 100644 index 0000000..4fabdbc --- /dev/null +++ b/internal/cli/client_download_test.go @@ -0,0 +1,132 @@ +package cli_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "syscall" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/texops/tx/internal/cli" +) + +func pdfServer(t *testing.T, content []byte) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/pdf") + _, err := w.Write(content) + require.NoError(t, err) + })) +} + +func failingPDFServer(t *testing.T) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _ = json.NewEncoder(w).Encode(map[string]string{"error": "build not found"}) + })) +} + +func TestDownloadPDF_CreatesNewFile(t *testing.T) { + t.Run("creates file when it does not exist", func(t *testing.T) { + content := []byte("%PDF-1.4 test content") + srv := pdfServer(t, content) + defer srv.Close() + + dir := t.TempDir() + outputPath := filepath.Join(dir, "output.pdf") + + client := cli.NewInstanceClient(srv.URL, "test-jwt") + err := client.DownloadPDF(t.Context(), "prj_abc123", "bld_abc123", outputPath) + require.NoError(t, err) + + got, err := os.ReadFile(outputPath) + require.NoError(t, err) + assert.Equal(t, content, got) + }) +} + +func TestDownloadPDF_PreservesInode(t *testing.T) { + t.Run("preserves inode when file already exists", func(t *testing.T) { + dir := t.TempDir() + outputPath := filepath.Join(dir, "output.pdf") + + require.NoError(t, os.WriteFile(outputPath, []byte("old content"), 0o600)) + + infoBefore, err := os.Stat(outputPath) + require.NoError(t, err) + inoBefore := infoBefore.Sys().(*syscall.Stat_t).Ino + + content := []byte("%PDF-1.4 new content") + srv := pdfServer(t, content) + defer srv.Close() + + client := cli.NewInstanceClient(srv.URL, "test-jwt") + err = client.DownloadPDF(t.Context(), "prj_abc123", "bld_abc123", outputPath) + require.NoError(t, err) + + got, err := os.ReadFile(outputPath) + require.NoError(t, err) + assert.Equal(t, content, got) + + infoAfter, err := os.Stat(outputPath) + require.NoError(t, err) + inoAfter := infoAfter.Sys().(*syscall.Stat_t).Ino + + assert.Equal(t, inoBefore, inoAfter, "inode should be preserved after download") + }) +} + +func TestWriteFilePreserveInode_TempFileLocality(t *testing.T) { + t.Run("creates temp file in target directory", func(t *testing.T) { + dir := t.TempDir() + outputPath := filepath.Join(dir, "output.pdf") + + content := []byte("%PDF-1.4 test content") + err := cli.WriteFilePreserveInode(strings.NewReader(string(content)), outputPath) + require.NoError(t, err) + + got, err := os.ReadFile(outputPath) + require.NoError(t, err) + assert.Equal(t, content, got) + + entries, err := os.ReadDir(dir) + require.NoError(t, err) + for _, e := range entries { + assert.False(t, strings.HasSuffix(e.Name(), ".tmp"), "temp file should be cleaned up: %s", e.Name()) + } + }) +} + +func TestDownloadPDF_FailureLeavesOriginalIntact(t *testing.T) { + t.Run("server error leaves original file intact", func(t *testing.T) { + dir := t.TempDir() + outputPath := filepath.Join(dir, "output.pdf") + originalContent := []byte("original PDF content") + + require.NoError(t, os.WriteFile(outputPath, originalContent, 0o600)) + + srv := failingPDFServer(t) + defer srv.Close() + + client := cli.NewInstanceClient(srv.URL, "test-jwt") + err := client.DownloadPDF(t.Context(), "prj_abc123", "bld_abc123", outputPath) + require.Error(t, err) + + got, err := os.ReadFile(outputPath) + require.NoError(t, err) + assert.Equal(t, originalContent, got, "original file should be untouched after failed download") + + entries, dirErr := os.ReadDir(dir) + require.NoError(t, dirErr) + for _, e := range entries { + assert.False(t, strings.HasSuffix(e.Name(), ".tmp"), "temp file should be cleaned up: %s", e.Name()) + } + }) +} diff --git a/internal/cli/client_test.go b/internal/cli/client_test.go index a349606..77e523c 100644 --- a/internal/cli/client_test.go +++ b/internal/cli/client_test.go @@ -42,7 +42,7 @@ func TestAPIClient_CreateProject(t *testing.T) { defer srv.Close() client := cli.NewAPIClient(srv.URL, "test-key") - result, err := client.CreateProject("myproject", "texlive:2021", "") + result, err := client.CreateProject(t.Context(), "myproject", "texlive:2021", "") require.NoError(t, err) assert.Equal(t, "prj_abc123", result.ID) assert.Equal(t, "myproject", result.Name) @@ -57,7 +57,7 @@ func TestAPIClient_CreateProject(t *testing.T) { defer srv.Close() client := cli.NewAPIClient(srv.URL, "key") - _, err := client.CreateProject("proj", "texlive:2021", "") + _, err := client.CreateProject(t.Context(), "proj", "texlive:2021", "") require.Error(t, err) assert.Contains(t, err.Error(), "create project failed (409)") }) @@ -81,7 +81,7 @@ func TestAPIClient_CreateProject(t *testing.T) { defer srv.Close() client := cli.NewAPIClient(srv.URL, "test-key") - result, err := client.CreateProject("myproject", "texlive:2021", "k7Gx9mR2pL4wN8qY5vBt3a") + result, err := client.CreateProject(t.Context(), "myproject", "texlive:2021", "k7Gx9mR2pL4wN8qY5vBt3a") require.NoError(t, err) assert.Equal(t, "prj_new", result.ID) }) @@ -104,7 +104,7 @@ func TestAPIClient_CreateProject(t *testing.T) { defer srv.Close() client := cli.NewAPIClient(srv.URL, "test-key") - _, err := client.CreateProject("proj", "texlive:2021", "") + _, err := client.CreateProject(t.Context(), "proj", "texlive:2021", "") require.NoError(t, err) }) @@ -121,7 +121,7 @@ func TestAPIClient_CreateProject(t *testing.T) { defer srv.Close() client := cli.NewAPIClient(srv.URL, "test-key") - result, err := client.CreateProject("myproject", "texlive:2021", "k7Gx9mR2pL4wN8qY5vBt3a") + result, err := client.CreateProject(t.Context(), "myproject", "texlive:2021", "k7Gx9mR2pL4wN8qY5vBt3a") require.NoError(t, err) assert.Equal(t, "prj_existing", result.ID) }) @@ -150,7 +150,7 @@ func TestAPIClient_GetSession(t *testing.T) { defer srv.Close() client := cli.NewAPIClient(srv.URL, "my-key") - session, err := client.GetSession("prj_abc", "texlive:2021") + session, err := client.GetSession(t.Context(), "prj_abc", "texlive:2021") require.NoError(t, err) assert.Equal(t, "https://10.0.0.1:8443", session.InstanceURL) assert.Equal(t, "eyJhbGciOi...", session.JWT) @@ -174,7 +174,7 @@ func TestAPIClient_GetSession(t *testing.T) { defer srv.Close() client := cli.NewAPIClient(srv.URL, "my-key") - session, err := client.GetSession("prj_abc", "texlive:2019") + session, err := client.GetSession(t.Context(), "prj_abc", "texlive:2019") require.NoError(t, err) assert.Equal(t, "https://10.0.0.2:8443", session.InstanceURL) assert.Equal(t, "jwt-2019", session.JWT) @@ -188,7 +188,7 @@ func TestAPIClient_GetSession(t *testing.T) { defer srv.Close() client := cli.NewAPIClient(srv.URL, "key") - _, err := client.GetSession("prj_abc", "texlive:2021") + _, err := client.GetSession(t.Context(), "prj_abc", "texlive:2021") require.Error(t, err) assert.Contains(t, err.Error(), "get session failed (503)") }) @@ -215,7 +215,7 @@ func TestInstanceClient_Sync(t *testing.T) { client := cli.NewInstanceClient(srv.URL, "jwt-token") client.SetHTTPClient(srv.Client()) - result, err := client.Sync("prj_123", []cli.FileEntry{ + result, err := client.Sync(t.Context(), "prj_123", []cli.FileEntry{ {Path: "file1.tex", Hash: "aaa"}, {Path: "file2.tex", Hash: "bbb"}, }) @@ -232,7 +232,7 @@ func TestInstanceClient_Sync(t *testing.T) { client := cli.NewInstanceClient(srv.URL, "bad-jwt") client.SetHTTPClient(srv.Client()) - _, err := client.Sync("prj_123", []cli.FileEntry{}) + _, err := client.Sync(t.Context(), "prj_123", []cli.FileEntry{}) require.Error(t, err) assert.Contains(t, err.Error(), "sync failed (401)") }) @@ -263,7 +263,7 @@ func TestInstanceClient_Upload(t *testing.T) { client := cli.NewInstanceClient(srv.URL, "jwt") client.SetHTTPClient(srv.Client()) - err := client.Upload("prj_123", dir, []string{"test.tex"}, nil) + err := client.Upload(t.Context(), "prj_123", dir, []string{"test.tex"}, nil) require.NoError(t, err) }) @@ -276,7 +276,7 @@ func TestInstanceClient_Upload(t *testing.T) { client := cli.NewInstanceClient(srv.URL, "jwt") client.SetHTTPClient(srv.Client()) - err := client.Upload("prj_123", "/tmp", []string{}, nil) + err := client.Upload(t.Context(), "prj_123", "/tmp", []string{}, nil) require.NoError(t, err) assert.False(t, called) }) @@ -295,7 +295,7 @@ func TestInstanceClient_Upload(t *testing.T) { var progressCalls []int64 client := cli.NewInstanceClient(srv.URL, "jwt") client.SetHTTPClient(srv.Client()) - err := client.Upload("prj_123", dir, []string{"test.tex"}, func(sent, total int64) { + err := client.Upload(t.Context(), "prj_123", dir, []string{"test.tex"}, func(sent, total int64) { progressCalls = append(progressCalls, sent) }) require.NoError(t, err) @@ -325,7 +325,7 @@ func TestInstanceClient_Build(t *testing.T) { client := cli.NewInstanceClient(srv.URL, "jwt") client.SetHTTPClient(srv.Client()) var logs []string - result, err := client.Build("prj_123", "paper.tex", "", "texlive:2021", "", nil, func(line string) { + result, err := client.Build(t.Context(), "prj_123", "paper.tex", "", "texlive:2021", "", nil, func(line string) { logs = append(logs, line) }) require.NoError(t, err) @@ -348,7 +348,7 @@ func TestInstanceClient_Build(t *testing.T) { client := cli.NewInstanceClient(srv.URL, "jwt") client.SetHTTPClient(srv.Client()) - result, err := client.Build("prj_123", "paper.tex", "", "texlive:2021", "", nil, nil) + result, err := client.Build(t.Context(), "prj_123", "paper.tex", "", "texlive:2021", "", nil, nil) require.NoError(t, err) assert.Equal(t, "error", result.Status) assert.Equal(t, "Build failed", result.Message) @@ -363,7 +363,7 @@ func TestInstanceClient_Build(t *testing.T) { client := cli.NewInstanceClient(srv.URL, "jwt") client.SetHTTPClient(srv.Client()) - _, err := client.Build("prj_123", "paper.tex", "", "texlive:2021", "", nil, nil) + _, err := client.Build(t.Context(), "prj_123", "paper.tex", "", "texlive:2021", "", nil, nil) require.Error(t, err) assert.Contains(t, err.Error(), "build request failed (500)") }) @@ -385,7 +385,7 @@ func TestInstanceClient_DownloadPDF(t *testing.T) { client := cli.NewInstanceClient(srv.URL, "jwt-token") client.SetHTTPClient(srv.Client()) - err := client.DownloadPDF("prj_123", "bld_abc", outputPath) + err := client.DownloadPDF(t.Context(), "prj_123", "bld_abc", outputPath) require.NoError(t, err) written, err := os.ReadFile(outputPath) @@ -401,7 +401,7 @@ func TestInstanceClient_DownloadPDF(t *testing.T) { client := cli.NewInstanceClient(srv.URL, "jwt") client.SetHTTPClient(srv.Client()) - err := client.DownloadPDF("prj_123", "bld_bad", "/tmp/out.pdf") + err := client.DownloadPDF(t.Context(), "prj_123", "bld_bad", "/tmp/out.pdf") require.Error(t, err) assert.Contains(t, err.Error(), "PDF download failed (404)") }) @@ -745,11 +745,11 @@ func TestE2E_TwoClients(t *testing.T) { defer apiSrv.Close() api := cli.NewAPIClient(apiSrv.URL, "test-api-key") - project, err := api.CreateProject("test-project", "texlive:2021", "") + project, err := api.CreateProject(t.Context(), "test-project", "texlive:2021", "") require.NoError(t, err) assert.Equal(t, "prj_test", project.ID) - session, err := api.GetSession(project.ID, "texlive:2021") + session, err := api.GetSession(t.Context(), project.ID, "texlive:2021") require.NoError(t, err) assert.Equal(t, instSrv.URL, session.InstanceURL) assert.Equal(t, "test-jwt-token", session.JWT) @@ -758,17 +758,17 @@ func TestE2E_TwoClients(t *testing.T) { inst.SetHTTPClient(instSrv.Client()) files := []cli.FileEntry{{Path: "paper.tex", Hash: "abc123"}} - syncResult, err := inst.Sync(project.ID, files) + syncResult, err := inst.Sync(t.Context(), project.ID, files) require.NoError(t, err) assert.Len(t, syncResult.Missing, 1) dir := t.TempDir() require.NoError(t, os.WriteFile(filepath.Join(dir, "paper.tex"), []byte("\\documentclass{article}"), 0o600)) - err = inst.Upload(project.ID, dir, syncResult.Missing, nil) + err = inst.Upload(t.Context(), project.ID, dir, syncResult.Missing, nil) require.NoError(t, err) var logs []string - result, err := inst.Build(project.ID, "paper.tex", "", "texlive:2021", "", nil, func(line string) { + result, err := inst.Build(t.Context(), project.ID, "paper.tex", "", "texlive:2021", "", nil, func(line string) { logs = append(logs, line) }) require.NoError(t, err) @@ -776,7 +776,7 @@ func TestE2E_TwoClients(t *testing.T) { assert.Equal(t, []string{"Running latexmk..."}, logs) outputPath := filepath.Join(dir, "paper.pdf") - err = inst.DownloadPDF(project.ID, "bld_001", outputPath) + err = inst.DownloadPDF(t.Context(), project.ID, "bld_001", outputPath) require.NoError(t, err) written, err := os.ReadFile(outputPath) @@ -807,7 +807,7 @@ func TestInstanceClient_Build_DirectoryInPayload(t *testing.T) { ic := cli.NewInstanceClient(srv.URL, "test-jwt") ic.SetHTTPClient(srv.Client()) - _, err := ic.Build("prj_001", "paper.tex", "chapters/paper", "texlive:2021", "", nil, nil) + _, err := ic.Build(t.Context(), "prj_001", "paper.tex", "chapters/paper", "texlive:2021", "", nil, nil) require.NoError(t, err) assert.Equal(t, "paper.tex", receivedPayload["main"]) @@ -836,7 +836,7 @@ func TestInstanceClient_Build_DirectoryInPayload(t *testing.T) { ic := cli.NewInstanceClient(srv.URL, "test-jwt") ic.SetHTTPClient(srv.Client()) - _, err := ic.Build("prj_001", "paper.tex", "", "texlive:2021", "", nil, nil) + _, err := ic.Build(t.Context(), "prj_001", "paper.tex", "", "texlive:2021", "", nil, nil) require.NoError(t, err) assert.NotContains(t, string(receivedBody), "directory") @@ -865,7 +865,7 @@ func TestInstanceClient_Build_CompilerInPayload(t *testing.T) { ic := cli.NewInstanceClient(srv.URL, "test-jwt") ic.SetHTTPClient(srv.Client()) - _, err := ic.Build("prj_001", "paper.tex", "", "texlive:2021", "xelatex", nil, nil) + _, err := ic.Build(t.Context(), "prj_001", "paper.tex", "", "texlive:2021", "xelatex", nil, nil) require.NoError(t, err) assert.Equal(t, "paper.tex", receivedPayload["main"]) @@ -894,7 +894,7 @@ func TestInstanceClient_Build_CompilerInPayload(t *testing.T) { ic := cli.NewInstanceClient(srv.URL, "test-jwt") ic.SetHTTPClient(srv.Client()) - _, err := ic.Build("prj_001", "paper.tex", "", "texlive:2021", "", nil, nil) + _, err := ic.Build(t.Context(), "prj_001", "paper.tex", "", "texlive:2021", "", nil, nil) require.NoError(t, err) assert.NotContains(t, string(receivedBody), "compiler") @@ -921,7 +921,7 @@ func TestInstanceClient_Build_CompilerInPayload(t *testing.T) { ic := cli.NewInstanceClient(srv.URL, "test-jwt") ic.SetHTTPClient(srv.Client()) - _, err := ic.BuildWithArgs("prj_001", "paper.tex", "", "texlive:2021", "lualatex", nil, nil, nil) + _, err := ic.BuildWithArgs(t.Context(), "prj_001", "paper.tex", "", "texlive:2021", "lualatex", nil, nil, nil) require.NoError(t, err) assert.Equal(t, "lualatex", receivedPayload["compiler"]) diff --git a/internal/cli/commands.go b/internal/cli/commands.go index 70a16c2..c36302e 100644 --- a/internal/cli/commands.go +++ b/internal/cli/commands.go @@ -3,10 +3,12 @@ package cli import ( "bufio" "bytes" + "context" "errors" "fmt" "os" "os/exec" + "os/signal" "path/filepath" "runtime" "strconv" @@ -83,6 +85,7 @@ type InitCmd struct { type BuildCmd struct { Args struct{ Names []string } `positional-args:"true"` NoCache bool `long:"no-cache" description:"Clear build cache and rebuild from scratch"` + Live bool `long:"live" description:"Watch for changes and rebuild automatically"` UI *UI `no-flag:"true"` } @@ -100,6 +103,15 @@ type docResult struct { Err error } +type buildParams struct { + dir string + docs []Document + api *APIClient + noCache bool + ui *UI + projectID string +} + func defaultUI() *UI { return NewUI(os.Stdout) } @@ -515,12 +527,24 @@ func (cmd *BuildCmd) Execute(args []string) error { if ui == nil { ui = defaultUI() } - return RunBuild(dir, cmd.Args.Names, cmd.NoCache, ui) + var ctx context.Context + if cmd.Live { + var stop context.CancelFunc + ctx, stop = signal.NotifyContext(context.Background(), os.Interrupt) + defer stop() + } else { + ctx = context.Background() + } + err = RunBuild(ctx, dir, cmd.Args.Names, cmd.NoCache, cmd.Live, ui) + if cmd.Live && ctx.Err() != nil { + return nil + } + return err } var errInitDeclined = errors.New("no project config found; run `tx init` to set up your project") -func runBuild(dir string, names []string, noCache bool, ui *UI) error { +func runBuild(ctx context.Context, dir string, names []string, noCache bool, live bool, ui *UI) error { buildStart := time.Now() configPath := filepath.Join(dir, ".texops.yaml") @@ -595,7 +619,7 @@ func runBuild(dir string, names []string, noCache bool, ui *UI) error { } sp := ui.Spin("Resolving project...") - project, err := api.CreateProject(filepath.Base(dir), config.Texlive, config.ProjectKey) + project, err := api.CreateProject(ctx, filepath.Base(dir), config.Texlive, config.ProjectKey) if err != nil { sp.Fail(fmt.Sprintf("Failed to resolve project: %s", err)) return err @@ -603,24 +627,88 @@ func runBuild(dir string, names []string, noCache bool, ui *UI) error { sp.Stop("Project ready") projectID := project.ID - // Collect files once (shared across all version groups) - sp = ui.Spin("Collecting files...") - files, err := CollectFiles(dir) + p := buildParams{ + dir: dir, + docs: docs, + api: api, + noCache: noCache, + ui: ui, + projectID: projectID, + } + results, err := buildOnce(ctx, p) if err != nil { - sp.Fail(fmt.Sprintf("Failed to collect files: %s", err)) return err } + + // Print build summary + succeeded := 0 + failed := 0 + for _, r := range results { + if r.Success { + succeeded++ + } else { + failed++ + } + } + elapsed := time.Since(buildStart) + ui.Log("") + ui.Status(fmt.Sprintf("Build complete: %d succeeded, %d failed (%.1fs)", succeeded, failed, elapsed.Seconds())) + for _, r := range results { + if r.Success { + ui.Log(fmt.Sprintf(" %s => %s", r.Name, r.Output)) + } else { + ui.Log(fmt.Sprintf(" %s !! FAILED", r.Name)) + } + } + + if live { + return watchAndBuild(ctx, dir, p) + } + + for _, r := range results { + if !r.Success { + return fmt.Errorf("one or more documents failed to build") + } + } + + return nil +} + +func buildOnce(ctx context.Context, p buildParams) ([]docResult, error) { + sp := p.ui.Spin("Collecting files...") + files, err := CollectFiles(ctx, p.dir) + if err != nil { + if ctx.Err() != nil { + sp.Cancel() + return nil, ctx.Err() + } + sp.Fail(fmt.Sprintf("Failed to collect files: %s", err)) + return nil, err + } + + outputFiles := make(map[string]bool) + for _, doc := range p.docs { + outputFiles[doc.Output] = true + } + // Reuse the slice backing array: safe because CollectFiles returns a fresh slice each call. + filtered := files[:0] + for _, f := range files { + if !outputFiles[f.Path] { + filtered = append(filtered, f) + } + } + files = filtered + totalSize := TotalSize(files) sp.Stop(fmt.Sprintf("Found %d files (%s)", len(files), FormatSize(totalSize))) - // Group documents by effective texlive version type versionGroup struct { version string docs []Document } groupMap := make(map[string]*versionGroup) var groupOrder []string - for _, doc := range docs { + for _, doc := range p.docs { v := doc.Texlive if _, ok := groupMap[v]; !ok { groupMap[v] = &versionGroup{version: v} @@ -634,12 +722,14 @@ func runBuild(dir string, names []string, noCache bool, ui *UI) error { for _, version := range groupOrder { group := groupMap[version] - // Get session for this version group - sp = ui.Spin("Getting session...") - session, err := api.GetSession(projectID, version) + sp = p.ui.Spin("Getting session...") + session, err := p.api.GetSession(ctx, p.projectID, version) if err != nil { + if ctx.Err() != nil { + sp.Cancel() + return nil, ctx.Err() + } sp.Fail(fmt.Sprintf("Failed to get session: %s", err)) - // Mark all docs in this group as failed for _, doc := range group.docs { results = append(results, docResult{Name: doc.Name, Output: doc.Output, Err: err}) } @@ -649,10 +739,13 @@ func runBuild(dir string, names []string, noCache bool, ui *UI) error { inst := NewInstanceClientFn(session.InstanceURL, session.JWT) - // Sync files once per version group - sp = ui.Spin("Syncing with instance...") - syncResult, err := inst.Sync(projectID, files) + sp = p.ui.Spin("Syncing with instance...") + syncResult, err := inst.Sync(ctx, p.projectID, files) if err != nil { + if ctx.Err() != nil { + sp.Cancel() + return nil, ctx.Err() + } sp.Fail(fmt.Sprintf("Sync failed: %s", err)) for _, doc := range group.docs { results = append(results, docResult{Name: doc.Name, Output: doc.Output, Err: err}) @@ -660,10 +753,12 @@ func runBuild(dir string, names []string, noCache bool, ui *UI) error { continue } - if err := handleUpload(ui, inst, projectID, dir, files, syncResult, sp); err != nil { - // User cancellation should abort the entire build + if err := handleUpload(ctx, p.ui, inst, p.projectID, p.dir, files, syncResult, sp); err != nil { + if ctx.Err() != nil { + return nil, ctx.Err() + } if strings.Contains(err.Error(), "cancelled by user") { - return err + return nil, err } for _, doc := range group.docs { results = append(results, docResult{Name: doc.Name, Output: doc.Output, Err: err}) @@ -671,46 +766,20 @@ func runBuild(dir string, names []string, noCache bool, ui *UI) error { continue } - // Build each document in this group for _, doc := range group.docs { - r := buildDocument(ui, inst, projectID, dir, doc, noCache) + r := buildDocument(ctx, p.ui, inst, p.projectID, p.dir, doc, p.noCache) + if ctx.Err() != nil { + return nil, ctx.Err() + } results = append(results, r) } } - // Print build summary - succeeded := 0 - failed := 0 - for _, r := range results { - if r.Success { - succeeded++ - } else { - failed++ - } - } - elapsed := time.Since(buildStart) - ui.Log("") - ui.Status(fmt.Sprintf("Build complete: %d succeeded, %d failed (%.1fs)", succeeded, failed, elapsed.Seconds())) - for _, r := range results { - if r.Success { - ui.Log(fmt.Sprintf(" %s => %s", r.Name, r.Output)) - } else { - ui.Log(fmt.Sprintf(" %s !! FAILED", r.Name)) - } - } - - // Return error if any document failed - for _, r := range results { - if !r.Success { - return fmt.Errorf("one or more documents failed to build") - } - } - - return nil + return results, nil } // handleUpload processes file sync results and uploads missing files. -func handleUpload(ui *UI, inst *InstanceClient, projectID, dir string, files []FileEntry, syncResult SyncResult, sp *Spinner) error { +func handleUpload(ctx context.Context, ui *UI, inst *InstanceClient, projectID, dir string, files []FileEntry, syncResult SyncResult, sp *Spinner) error { if len(syncResult.Missing) > 0 { knownPaths := make(map[string]bool) filesByPath := make(map[string]FileEntry) @@ -740,12 +809,15 @@ func handleUpload(ui *UI, inst *InstanceClient, projectID, dir string, files []F uploadLabel := fmt.Sprintf("Uploading %d files", len(validMissing)) pb := ui.Progress(uploadLabel, uploadSize) - if err := inst.Upload(projectID, dir, validMissing, func(sent, total int64) { + if err := inst.Upload(ctx, projectID, dir, validMissing, func(sent, total int64) { if total > 0 { pb.Update(float64(sent) / float64(total)) } }); err != nil { pb.Abort() + if ctx.Err() != nil { + return ctx.Err() + } ui.Errorf("Upload failed: %s", err) return err } @@ -757,7 +829,7 @@ func handleUpload(ui *UI, inst *InstanceClient, projectID, dir string, files []F } // buildDocument builds a single document and returns the result. -func buildDocument(ui *UI, inst *InstanceClient, projectID, dir string, doc Document, noCache bool) docResult { +func buildDocument(ctx context.Context, ui *UI, inst *InstanceClient, projectID, dir string, doc Document, noCache bool) docResult { displayMain := doc.Main if doc.Directory != "" { displayMain = filepath.Join(doc.Directory, doc.Main) @@ -768,7 +840,7 @@ func buildDocument(ui *UI, inst *InstanceClient, projectID, dir string, doc Docu buildOptions = map[string]string{"no_cache": "true"} } compileStart := time.Now() - result, err := inst.Build(projectID, doc.Main, doc.Directory, doc.Texlive, doc.Compiler, buildOptions, func(line string) { + result, err := inst.Build(ctx, projectID, doc.Main, doc.Directory, doc.Texlive, doc.Compiler, buildOptions, func(line string) { ui.Log(line) }) if err != nil { @@ -788,7 +860,7 @@ func buildDocument(ui *UI, inst *InstanceClient, projectID, dir string, doc Docu } sp := ui.Spin(fmt.Sprintf("Downloading %s...", doc.Output)) - if err := inst.DownloadPDF(projectID, result.BuildID, outputPath); err != nil { + if err := inst.DownloadPDF(ctx, projectID, result.BuildID, outputPath); err != nil { sp.Fail(fmt.Sprintf("Download failed: %s", err)) return docResult{Name: doc.Name, Output: doc.Output, Err: err} } diff --git a/internal/cli/commands_test.go b/internal/cli/commands_test.go index ea31509..ab5cae8 100644 --- a/internal/cli/commands_test.go +++ b/internal/cli/commands_test.go @@ -721,7 +721,7 @@ documents: os.WriteFile(filepath.Join(dir, "paper.tex"), []byte("\\documentclass{article}\\begin{document}Hello\\end{document}"), 0o600) ui, buf := testUI() - err := cli.RunBuild(dir, nil, false, ui) + err := cli.RunBuild(t.Context(), dir, nil, false, false, ui) require.NoError(t, err) written, err := os.ReadFile(filepath.Join(dir, "paper.pdf")) @@ -812,7 +812,7 @@ documents: os.WriteFile(filepath.Join(dir, "paper.tex"), []byte("\\documentclass{article}\\begin{document}Hello\\end{document}"), 0o600) ui, buf := testUI() - err := cli.RunBuild(dir, nil, false, ui) + err := cli.RunBuild(t.Context(), dir, nil, false, false, ui) require.NoError(t, err) assert.Equal(t, "prj_auto123", createdProjectID) @@ -855,7 +855,7 @@ documents: t.Setenv("TX_API_URL", apiSrv.URL) ui, _ := testUI() - err := cli.RunBuild(dir, nil, false, ui) + err := cli.RunBuild(t.Context(), dir, nil, false, false, ui) // RunBuild will error after project creation (mock only handles /api/projects), // but the project_key generation side effect should have completed. require.Error(t, err) @@ -876,7 +876,7 @@ func TestBuildCmd_AutoInit(t *testing.T) { os.WriteFile(filepath.Join(dir, "paper.tex"), []byte(`\documentclass{article}\begin{document}Hello\end{document}`), 0o600) ui, _ := testUI() - err := cli.RunBuild(dir, nil, false, ui) + err := cli.RunBuild(t.Context(), dir, nil, false, false, ui) require.Error(t, err) assert.Contains(t, err.Error(), "run `tx init` to set up your project") }) @@ -891,7 +891,7 @@ func TestBuildCmd_AutoInit(t *testing.T) { ui := cli.NewUIWithTTYOptions(buf, true, false, in) // Build will init then fail on auth — that's fine, we just check init happened. - err := cli.RunBuild(dir, nil, false, ui) + err := cli.RunBuild(t.Context(), dir, nil, false, false, ui) configData, readErr := os.ReadFile(filepath.Join(dir, ".texops.yaml")) require.NoError(t, readErr) @@ -911,7 +911,7 @@ func TestBuildCmd_AutoInit(t *testing.T) { in := strings.NewReader("n\n") ui := cli.NewUIWithOptions(buf, true, in) - err := cli.RunBuild(dir, nil, false, ui) + err := cli.RunBuild(t.Context(), dir, nil, false, false, ui) require.Error(t, err) assert.Contains(t, err.Error(), "run `tx init` to set up your project") @@ -1004,7 +1004,7 @@ documents: os.WriteFile(filepath.Join(dir, "paper.tex"), []byte("\\documentclass{article}\\begin{document}Hello\\end{document}"), 0o600) ui, _ := testUI() - err := cli.RunBuild(dir, nil, true, ui) + err := cli.RunBuild(t.Context(), dir, nil, true, false, ui) require.NoError(t, err) require.NotNil(t, receivedBuildOptions, "build_options should be sent in request") assert.Equal(t, "true", receivedBuildOptions["no_cache"]) @@ -1089,7 +1089,7 @@ documents: os.WriteFile(filepath.Join(dir, "paper.tex"), []byte("\\documentclass{article}\\begin{document}Hello\\end{document}"), 0o600) ui, _ := testUI() - err := cli.RunBuild(dir, nil, false, ui) + err := cli.RunBuild(t.Context(), dir, nil, false, false, ui) require.NoError(t, err) _, hasBuildOptions := receivedBody["build_options"] assert.False(t, hasBuildOptions, "build_options should not be sent when --no-cache is not set") @@ -1233,7 +1233,7 @@ documents: s := multiDocSetup(t, config, "") ui, buf := testUI() - err := cli.RunBuild(s.dir, nil, false, ui) + err := cli.RunBuild(t.Context(), s.dir, nil, false, false, ui) require.NoError(t, err) // Should get exactly one session and one sync for same-version docs @@ -1268,7 +1268,7 @@ documents: s := multiDocSetup(t, config, "") ui, buf := testUI() - err := cli.RunBuild(s.dir, nil, false, ui) + err := cli.RunBuild(t.Context(), s.dir, nil, false, false, ui) require.NoError(t, err) // Should get two sessions (one per version) and two syncs @@ -1293,7 +1293,7 @@ documents: s := multiDocSetup(t, config, "") ui, _ := testUI() - err := cli.RunBuild(s.dir, []string{"paper"}, false, ui) + err := cli.RunBuild(t.Context(), s.dir, []string{"paper"}, false, false, ui) require.NoError(t, err) // Only one document should be built @@ -1312,7 +1312,7 @@ documents: os.WriteFile(filepath.Join(dir, ".texops.yaml"), []byte(config), 0o600) ui, _ := testUI() - err := cli.RunBuild(dir, []string{"nonexistent"}, false, ui) + err := cli.RunBuild(t.Context(), dir, []string{"nonexistent"}, false, false, ui) require.Error(t, err) assert.Contains(t, err.Error(), "unknown document") assert.Contains(t, err.Error(), "nonexistent") @@ -1330,7 +1330,7 @@ documents: s := multiDocSetup(t, config, "slides.tex") // slides will fail ui, buf := testUI() - err := cli.RunBuild(s.dir, nil, false, ui) + err := cli.RunBuild(t.Context(), s.dir, nil, false, false, ui) require.Error(t, err) assert.Contains(t, err.Error(), "one or more documents failed to build") @@ -1356,7 +1356,7 @@ documents: s := multiDocSetup(t, config, "") ui, buf := testUI() - err := cli.RunBuild(s.dir, nil, false, ui) + err := cli.RunBuild(t.Context(), s.dir, nil, false, false, ui) require.NoError(t, err) output := buf.String() @@ -1379,7 +1379,7 @@ documents: os.WriteFile(filepath.Join(s.dir, "chapters", "paper", "paper.tex"), []byte(`\documentclass{article}\begin{document}Paper\end{document}`), 0o600) ui, buf := testUI() - err := cli.RunBuild(s.dir, nil, false, ui) + err := cli.RunBuild(t.Context(), s.dir, nil, false, false, ui) require.NoError(t, err) assert.Len(t, *s.buildRequests, 2) @@ -1480,7 +1480,7 @@ documents: os.WriteFile(filepath.Join(dir, "paper.tex"), []byte(`\documentclass{article}\begin{document}Hello\end{document}`), 0o600) ui, _ := testUI() - err := cli.RunBuild(dir, nil, false, ui) + err := cli.RunBuild(t.Context(), dir, nil, false, false, ui) require.NoError(t, err) assert.Equal(t, "xelatex", receivedCompiler, "compiler from config should be sent in build request") @@ -1575,7 +1575,7 @@ documents: os.WriteFile(filepath.Join(dir, "slides.tex"), []byte(`\documentclass{beamer}\begin{document}Slides\end{document}`), 0o600) ui, _ := testUI() - err := cli.RunBuild(dir, nil, false, ui) + err := cli.RunBuild(t.Context(), dir, nil, false, false, ui) require.NoError(t, err) require.Len(t, receivedCompilers, 2) @@ -1663,7 +1663,7 @@ documents: os.WriteFile(filepath.Join(dir, "paper.tex"), []byte(`\documentclass{article}\begin{document}Hello\end{document}`), 0o600) ui, _ := testUI() - err := cli.RunBuild(dir, nil, false, ui) + err := cli.RunBuild(t.Context(), dir, nil, false, false, ui) require.NoError(t, err) assert.Equal(t, "pdflatex", receivedCompiler, "default compiler should be pdflatex") diff --git a/internal/cli/config.go b/internal/cli/config.go index a279dee..e5b28b3 100644 --- a/internal/cli/config.go +++ b/internal/cli/config.go @@ -161,7 +161,7 @@ func ParseConfig(content string) (Config, error) { return Config{}, fmt.Errorf("invalid config: documents[%d] 'output' must be a string", i) } if outputStr != "" { - output = outputStr + output = filepath.Clean(outputStr) } } if escapesRoot(output) { diff --git a/internal/cli/export_test.go b/internal/cli/export_test.go index df262a6..d24e699 100644 --- a/internal/cli/export_test.go +++ b/internal/cli/export_test.go @@ -1,5 +1,10 @@ package cli +import ( + "context" + "io" +) + var ( HasDocumentclass = hasDocumentclass ParseDuration = parseDuration @@ -12,6 +17,18 @@ var ( GenerateConfigYAML = generateConfigYAML ) +func WriteFilePreserveInode(r io.Reader, outputPath string) error { + return writeFilePreserveInode(r, outputPath) +} + +// DocResult is exported for testing. +type DocResult = docResult + +// WatchAndBuildWith wraps watchAndBuildWith for testing. +func WatchAndBuildWith(ctx context.Context, dir string, ui *UI, build func(context.Context) ([]DocResult, error)) error { + return watchAndBuildWith(ctx, dir, ui, build, nil) +} + // SelectModel is an alias for selectModel, exported for testing. type SelectModel = selectModel diff --git a/internal/cli/hasher.go b/internal/cli/hasher.go index e9693fb..b7c331c 100644 --- a/internal/cli/hasher.go +++ b/internal/cli/hasher.go @@ -1,6 +1,7 @@ package cli import ( + "context" "crypto/sha256" "encoding/hex" "errors" @@ -33,7 +34,7 @@ type txignoreEntry struct { anyPattern *ignore.GitIgnore } -func CollectFiles(dir string) ([]FileEntry, error) { +func CollectFiles(ctx context.Context, dir string) ([]FileEntry, error) { ig := loadGitignore(dir) txignoreCache := make(map[string]*txignoreEntry) @@ -53,6 +54,9 @@ func CollectFiles(dir string) ([]FileEntry, error) { if err != nil { return err } + if ctx.Err() != nil { + return ctx.Err() + } rel, err := filepath.Rel(dir, path) if err != nil { @@ -106,6 +110,9 @@ func CollectFiles(dir string) ([]FileEntry, error) { entries := make([]FileEntry, 0, len(found)) for _, f := range found { + if ctx.Err() != nil { + return nil, ctx.Err() + } data, err := os.ReadFile(filepath.Join(dir, f.path)) if err != nil { return nil, err diff --git a/internal/cli/hasher_test.go b/internal/cli/hasher_test.go index 6f5111c..8080a0f 100644 --- a/internal/cli/hasher_test.go +++ b/internal/cli/hasher_test.go @@ -1,8 +1,10 @@ package cli_test import ( + "context" "os" "path/filepath" + "sync/atomic" "testing" "github.com/stretchr/testify/assert" @@ -11,6 +13,24 @@ import ( "github.com/texops/tx/internal/cli" ) +// cancelAfterN wraps a context and returns context.Canceled after the +// underlying Err() has been called more than n times. This lets tests +// deterministically cancel between the walk phase and the hashing phase +// of CollectFiles. +type cancelAfterN struct { + context.Context //nolint:containedctx + + calls atomic.Int32 + n int32 +} + +func (c *cancelAfterN) Err() error { + if c.calls.Add(1) > c.n { + return context.Canceled + } + return c.Context.Err() +} + func TestHashContent(t *testing.T) { t.Run("produces consistent SHA-256 hash for known content", func(t *testing.T) { hash := cli.HashContent([]byte("hello world")) @@ -38,7 +58,7 @@ func TestCollectFiles(t *testing.T) { require.NoError(t, os.WriteFile(filepath.Join(dir, "paper.tex"), paperContent, 0o600)) require.NoError(t, os.WriteFile(filepath.Join(dir, "refs.bib"), refsContent, 0o600)) - files, err := cli.CollectFiles(dir) + files, err := cli.CollectFiles(t.Context(), dir) require.NoError(t, err) assert.Len(t, files, 2) assert.Equal(t, "paper.tex", files[0].Path) @@ -57,7 +77,7 @@ func TestCollectFiles(t *testing.T) { require.NoError(t, os.Mkdir(filepath.Join(dir, "build"), 0o750)) require.NoError(t, os.WriteFile(filepath.Join(dir, "build", "output.pdf"), []byte("pdf"), 0o600)) - files, err := cli.CollectFiles(dir) + files, err := cli.CollectFiles(t.Context(), dir) require.NoError(t, err) paths := make([]string, len(files)) for i, f := range files { @@ -75,7 +95,7 @@ func TestCollectFiles(t *testing.T) { require.NoError(t, os.Mkdir(filepath.Join(dir, ".git"), 0o750)) require.NoError(t, os.WriteFile(filepath.Join(dir, ".git", "config"), []byte("git config"), 0o600)) - files, err := cli.CollectFiles(dir) + files, err := cli.CollectFiles(t.Context(), dir) require.NoError(t, err) paths := make([]string, len(files)) for i, f := range files { @@ -90,7 +110,7 @@ func TestCollectFiles(t *testing.T) { require.NoError(t, os.WriteFile(filepath.Join(dir, "main.tex"), []byte("main"), 0o600)) require.NoError(t, os.WriteFile(filepath.Join(dir, "chapters", "intro.tex"), []byte("intro"), 0o600)) - files, err := cli.CollectFiles(dir) + files, err := cli.CollectFiles(t.Context(), dir) require.NoError(t, err) paths := make([]string, len(files)) for i, f := range files { @@ -101,7 +121,7 @@ func TestCollectFiles(t *testing.T) { t.Run("returns empty slice for empty directory", func(t *testing.T) { dir := t.TempDir() - files, err := cli.CollectFiles(dir) + files, err := cli.CollectFiles(t.Context(), dir) require.NoError(t, err) assert.Empty(t, files) }) @@ -112,7 +132,7 @@ func TestCollectFiles(t *testing.T) { require.NoError(t, os.WriteFile(filepath.Join(dir, "paper.tex"), []byte("content"), 0o600)) require.NoError(t, os.WriteFile(filepath.Join(dir, "debug.log"), []byte("log data"), 0o600)) - files, err := cli.CollectFiles(dir) + files, err := cli.CollectFiles(t.Context(), dir) require.NoError(t, err) paths := make([]string, len(files)) for i, f := range files { @@ -130,7 +150,7 @@ func TestCollectFiles(t *testing.T) { require.NoError(t, os.WriteFile(filepath.Join(dir, "paper.aux"), []byte("aux data"), 0o600)) require.NoError(t, os.WriteFile(filepath.Join(dir, "debug.log"), []byte("log data"), 0o600)) - files, err := cli.CollectFiles(dir) + files, err := cli.CollectFiles(t.Context(), dir) require.NoError(t, err) paths := make([]string, len(files)) for i, f := range files { @@ -150,7 +170,7 @@ func TestCollectFiles(t *testing.T) { require.NoError(t, os.WriteFile(filepath.Join(dir, "figures", "diagram.psd"), []byte("psd data"), 0o600)) require.NoError(t, os.WriteFile(filepath.Join(dir, "figures", "diagram.png"), []byte("png data"), 0o600)) - files, err := cli.CollectFiles(dir) + files, err := cli.CollectFiles(t.Context(), dir) require.NoError(t, err) paths := make([]string, len(files)) for i, f := range files { @@ -170,7 +190,7 @@ func TestCollectFiles(t *testing.T) { require.NoError(t, os.WriteFile(filepath.Join(dir, "paper.tex"), []byte("content"), 0o600)) require.NoError(t, os.WriteFile(filepath.Join(dir, "figures", "sketch.tmp"), []byte("tmp data"), 0o600)) - files, err := cli.CollectFiles(dir) + files, err := cli.CollectFiles(t.Context(), dir) require.NoError(t, err) paths := make([]string, len(files)) for i, f := range files { @@ -188,7 +208,7 @@ func TestCollectFiles(t *testing.T) { require.NoError(t, os.WriteFile(filepath.Join(dir, "figures", ".txignore"), []byte("*.psd\n"), 0o600)) require.NoError(t, os.WriteFile(filepath.Join(dir, "paper.tex"), []byte("content"), 0o600)) - files, err := cli.CollectFiles(dir) + files, err := cli.CollectFiles(t.Context(), dir) require.NoError(t, err) paths := make([]string, len(files)) for i, f := range files { @@ -209,7 +229,7 @@ func TestCollectFiles(t *testing.T) { require.NoError(t, os.WriteFile(filepath.Join(dir, "data", "scratch.tmp"), []byte("scratch"), 0o600)) require.NoError(t, os.WriteFile(filepath.Join(dir, "data", "keep.tmp"), []byte("important"), 0o600)) - files, err := cli.CollectFiles(dir) + files, err := cli.CollectFiles(t.Context(), dir) require.NoError(t, err) paths := make([]string, len(files)) for i, f := range files { @@ -232,7 +252,7 @@ func TestCollectFiles(t *testing.T) { require.NoError(t, os.WriteFile(filepath.Join(dir, "data", "scratch.tmp"), []byte("scratch"), 0o600)) require.NoError(t, os.WriteFile(filepath.Join(dir, "data", "keep.tmp"), []byte("important"), 0o600)) - files, err := cli.CollectFiles(dir) + files, err := cli.CollectFiles(t.Context(), dir) require.NoError(t, err) paths := make([]string, len(files)) for i, f := range files { @@ -251,7 +271,7 @@ func TestCollectFiles(t *testing.T) { dir := t.TempDir() require.NoError(t, os.WriteFile(filepath.Join(dir, ".txignore"), []byte("*.log\n"), 0o000)) - _, err := cli.CollectFiles(dir) + _, err := cli.CollectFiles(t.Context(), dir) assert.Error(t, err, "should return error when .txignore exists but cannot be read") }) @@ -262,7 +282,7 @@ func TestCollectFiles(t *testing.T) { require.NoError(t, os.WriteFile(filepath.Join(dir, "paper.aux"), []byte("aux data"), 0o600)) require.NoError(t, os.WriteFile(filepath.Join(dir, "paper.tex"), []byte("content"), 0o600)) - files, err := cli.CollectFiles(dir) + files, err := cli.CollectFiles(t.Context(), dir) require.NoError(t, err) paths := make([]string, len(files)) for i, f := range files { @@ -279,7 +299,7 @@ func TestCollectFiles(t *testing.T) { require.NoError(t, os.WriteFile(filepath.Join(dir, "drafts", "old.tex"), []byte("old content"), 0o600)) require.NoError(t, os.WriteFile(filepath.Join(dir, "paper.tex"), []byte("content"), 0o600)) - files, err := cli.CollectFiles(dir) + files, err := cli.CollectFiles(t.Context(), dir) require.NoError(t, err) paths := make([]string, len(files)) for i, f := range files { @@ -289,13 +309,36 @@ func TestCollectFiles(t *testing.T) { assert.NotContains(t, paths, "drafts/old.tex", "drafts/ directory should be skipped entirely by .txignore") }) + t.Run("returns error when context is already cancelled", func(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "paper.tex"), []byte("content"), 0o600)) + + ctx, cancel := context.WithCancel(t.Context()) + cancel() + + _, err := cli.CollectFiles(ctx, dir) + assert.ErrorIs(t, err, context.Canceled) + }) + + t.Run("returns error when context cancelled during hashing", func(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "a.tex"), []byte("content"), 0o600)) + + // Walk visits root "." and "a.tex" (2 Err() calls). + // The 3rd call happens in the hashing loop and should trigger cancellation. + ctx := &cancelAfterN{Context: t.Context(), n: 2} + + _, err := cli.CollectFiles(ctx, dir) + assert.ErrorIs(t, err, context.Canceled) + }) + t.Run("populates Size for all files", func(t *testing.T) { dir := t.TempDir() require.NoError(t, os.Mkdir(filepath.Join(dir, "sub"), 0o750)) require.NoError(t, os.WriteFile(filepath.Join(dir, "a.tex"), []byte("hello"), 0o600)) require.NoError(t, os.WriteFile(filepath.Join(dir, "sub", "b.tex"), []byte("world!"), 0o600)) - files, err := cli.CollectFiles(dir) + files, err := cli.CollectFiles(t.Context(), dir) require.NoError(t, err) require.Len(t, files, 2) assert.Equal(t, int64(5), files[0].Size) // "hello" = 5 bytes diff --git a/internal/cli/ui.go b/internal/cli/ui.go index 717d7bb..0fe20d2 100644 --- a/internal/cli/ui.go +++ b/internal/cli/ui.go @@ -274,10 +274,19 @@ func (s *Spinner) Fail(errMsg string) { } } +// Cancel stops the spinner without printing anything. +func (s *Spinner) Cancel() { + if s.program != nil { + s.program.Send(spinnerDoneMsg{cancelled: true}) + <-s.done + } +} + // spinnerDoneMsg is sent to the bubbletea program to signal completion. type spinnerDoneMsg struct { - text string - style lipgloss.Style + text string + style lipgloss.Style + cancelled bool } // spinnerModel is the bubbletea model for an inline spinner. @@ -306,7 +315,9 @@ func (m spinnerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case spinnerDoneMsg: m.finished = true - m.finalMsg = msg.style.Render(msg.text) + if !msg.cancelled { + m.finalMsg = msg.style.Render(msg.text) + } return m, tea.Quit case spinner.TickMsg: var cmd tea.Cmd diff --git a/internal/cli/watcher.go b/internal/cli/watcher.go new file mode 100644 index 0000000..3559f65 --- /dev/null +++ b/internal/cli/watcher.go @@ -0,0 +1,369 @@ +package cli + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/fsnotify/fsnotify" + ignore "github.com/sabhiram/go-gitignore" +) + +type FileWatcher struct { + fsw *fsnotify.Watcher + dir string + gitignore *ignore.GitIgnore + txignoreCache map[string]*txignoreEntry + excludes map[string]bool + Events chan string + Errors chan error +} + +var editorNoiseNames = map[string]bool{ + "4913": true, + ".DS_Store": true, +} + +func isEditorNoise(name string) bool { + base := filepath.Base(name) + if editorNoiseNames[base] { + return true + } + if strings.HasSuffix(base, "~") { + return true + } + if strings.HasSuffix(base, ".swp") { + return true + } + if strings.HasSuffix(base, ".swo") { + return true + } + if strings.HasPrefix(base, ".tx-download-") && strings.HasSuffix(base, ".tmp") { + return true + } + return false +} + +func NewFileWatcher(dir string, excludes []string) (*FileWatcher, error) { + fsw, err := fsnotify.NewWatcher() + if err != nil { + return nil, err + } + + ig := loadGitignore(dir) + txignoreCache := make(map[string]*txignoreEntry) + entry, loadErr := loadTxignore(dir) + if loadErr != nil { + _ = fsw.Close() + return nil, loadErr + } + if entry != nil { + txignoreCache["."] = entry + } + + excludeMap := make(map[string]bool) + for _, e := range excludes { + excludeMap[e] = true + } + + w := &FileWatcher{ + fsw: fsw, + dir: dir, + gitignore: ig, + txignoreCache: txignoreCache, + excludes: excludeMap, + Events: make(chan string, 1), + Errors: make(chan error, 1), + } + + err = w.addRecursive(dir) + if err != nil { + _ = fsw.Close() + return nil, err + } + + return w, nil +} + +func (w *FileWatcher) Run(ctx context.Context) { + defer close(w.Events) + defer close(w.Errors) + + for { + select { + case <-ctx.Done(): + return + case ev, ok := <-w.fsw.Events: + if !ok { + return + } + w.handleEvent(ev) + case err, ok := <-w.fsw.Errors: + if !ok { + return + } + w.sendError(err) + } + } +} + +func (w *FileWatcher) Close() error { + return w.fsw.Close() +} + +func (w *FileWatcher) addRecursive(root string) error { + return filepath.Walk(root, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if !info.IsDir() { + return nil + } + + rel, relErr := filepath.Rel(w.dir, path) + if relErr != nil { + return relErr + } + + if rel != "." { + if w.isIgnored(rel, true) { + return filepath.SkipDir + } + entry, loadErr := loadTxignore(path) + if loadErr != nil { + return loadErr + } + if entry != nil { + w.txignoreCache[rel] = entry + } + } + + return w.fsw.Add(path) + }) +} + +func (w *FileWatcher) isIgnored(rel string, isDir bool) bool { + checkPath := rel + if isDir { + checkPath = rel + "/" + } + if w.gitignore.MatchesPath(checkPath) { + return true + } + if matchesTxignore(w.txignoreCache, rel, isDir) { + return true + } + return false +} + +func (w *FileWatcher) reloadIgnoreRules() { + w.gitignore = loadGitignore(w.dir) + newCache := make(map[string]*txignoreEntry) + if entry, err := loadTxignore(w.dir); err == nil && entry != nil { + newCache["."] = entry + } + w.txignoreCache = newCache + if err := w.addRecursive(w.dir); err != nil { + w.sendError(fmt.Errorf("reloading watched directories: %w", err)) + } +} + +func (w *FileWatcher) handleEvent(ev fsnotify.Event) { + rel, err := filepath.Rel(w.dir, ev.Name) + if err != nil { + return + } + + if ev.Has(fsnotify.Chmod) && !ev.Has(fsnotify.Write) && !ev.Has(fsnotify.Create) { + return + } + + if isEditorNoise(rel) { + return + } + + if w.excludes[rel] { + return + } + + if base := filepath.Base(rel); base == ".gitignore" || base == ".txignore" { + w.reloadIgnoreRules() + w.sendEvent(rel) + return + } + + if ev.Has(fsnotify.Create) { + info, statErr := os.Stat(ev.Name) + if statErr == nil && info.IsDir() { + if !w.isIgnored(rel, true) { + if addErr := w.addRecursive(ev.Name); addErr != nil { + w.sendError(fmt.Errorf("watching new directory %s: %w", rel, addErr)) + } + } + return + } + } + + if ev.Has(fsnotify.Remove) || ev.Has(fsnotify.Rename) { + info, statErr := os.Stat(ev.Name) + if statErr != nil { + if !w.isIgnored(rel, false) { + w.sendEvent(rel) + } + return + } + if info.IsDir() { + return + } + } + + isDir := false + if info, statErr := os.Stat(ev.Name); statErr == nil { + isDir = info.IsDir() + } + if isDir { + return + } + + if w.isIgnored(rel, false) { + return + } + + w.sendEvent(rel) +} + +// sendEvent coalesces rapid file events: buffer-1 channel with non-blocking send +// means multiple events between consumer reads collapse into one rebuild trigger. +func (w *FileWatcher) sendEvent(rel string) { + select { + case w.Events <- rel: + default: + } +} + +func (w *FileWatcher) sendError(err error) { + select { + case w.Errors <- err: + default: + } +} + +const debounceDuration = 500 * time.Millisecond + +func watchAndBuild(ctx context.Context, dir string, p buildParams) error { + excludes := make([]string, 0, len(p.docs)) + for _, doc := range p.docs { + excludes = append(excludes, doc.Output) + } + return watchAndBuildWith(ctx, dir, p.ui, func(ctx context.Context) ([]docResult, error) { + return buildOnce(ctx, p) + }, excludes) +} + +func watchAndBuildWith(ctx context.Context, dir string, ui *UI, build func(context.Context) ([]docResult, error), excludes []string) error { + fw, err := NewFileWatcher(dir, excludes) + if err != nil { + return fmt.Errorf("failed to start file watcher: %w", err) + } + defer fw.Close() + + ui.Log("") + ui.Status("Watching for changes... (Ctrl+C to stop)") + + go fw.Run(ctx) + + buildSignal := make(chan struct{}, 1) + + go func() { + var debounceTimer *time.Timer + var debounceC <-chan time.Time + for { + select { + case <-ctx.Done(): + if debounceTimer != nil { + debounceTimer.Stop() + } + return + case _, ok := <-fw.Events: + if !ok { + return + } + if debounceTimer != nil { + debounceTimer.Stop() + } + debounceTimer = time.NewTimer(debounceDuration) + debounceC = debounceTimer.C + case err, ok := <-fw.Errors: + if !ok { + return + } + ui.Errorf("Watcher: %s", err) + case <-debounceC: + debounceC = nil + select { + case buildSignal <- struct{}{}: + default: + } + } + } + }() + + for { + select { + case <-ctx.Done(): + return nil + case <-buildSignal: + runBuildCycle(ctx, ui, build, buildSignal) + } + } +} + +func runBuildCycle(ctx context.Context, ui *UI, build func(context.Context) ([]docResult, error), buildSignal chan struct{}) { + for { + results, err := build(ctx) + if err != nil { + if ctx.Err() != nil { + return + } + ui.Errorf("Build error: %s", err) + } else { + printBuildResult(ui, results) + } + + select { + case <-buildSignal: + if ctx.Err() != nil { + return + } + continue + default: + return + } + } +} + +func printBuildResult(ui *UI, results []docResult) { + now := time.Now().Format("15:04:05") + var succeeded []string + anyFailed := false + for _, r := range results { + if r.Success { + succeeded = append(succeeded, filepath.Base(r.Output)) + } else { + anyFailed = true + } + } + if anyFailed { + for _, r := range results { + if !r.Success { + ui.Errorf("[%s] Build failed: %s", now, r.Name) + } + } + } + if len(succeeded) > 0 { + ui.Status(fmt.Sprintf("[%s] Built %s", now, strings.Join(succeeded, ", "))) + } +} diff --git a/internal/cli/watcher_test.go b/internal/cli/watcher_test.go new file mode 100644 index 0000000..59a7144 --- /dev/null +++ b/internal/cli/watcher_test.go @@ -0,0 +1,295 @@ +package cli_test + +import ( + "bytes" + "context" + "fmt" + "os" + "path/filepath" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/texops/tx/internal/cli" +) + +func waitForEvent(t *testing.T, events <-chan string, timeout time.Duration) (string, bool) { + t.Helper() + select { + case ev, ok := <-events: + return ev, ok + case <-time.After(timeout): + return "", false + } +} + +func noEvent(t *testing.T, events <-chan string, wait time.Duration) bool { + t.Helper() + select { + case ev := <-events: + t.Errorf("unexpected event: %s", ev) + return false + case <-time.After(wait): + return true + } +} + +func TestFileWatcher(t *testing.T) { + t.Run("detects file creation", func(t *testing.T) { + dir := t.TempDir() + + w, err := cli.NewFileWatcher(dir, nil) + require.NoError(t, err) + defer w.Close() + + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + go w.Run(ctx) + + // Create a file + err = os.WriteFile(filepath.Join(dir, "test.tex"), []byte("hello"), 0o600) + require.NoError(t, err) + + ev, ok := waitForEvent(t, w.Events, 2*time.Second) + assert.True(t, ok, "expected an event") + assert.Equal(t, "test.tex", ev) + }) + + t.Run("filters editor noise files", func(t *testing.T) { + dir := t.TempDir() + + w, err := cli.NewFileWatcher(dir, nil) + require.NoError(t, err) + defer w.Close() + + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + go w.Run(ctx) + + // Create editor noise files + noiseFiles := []string{"file~", "4913", ".DS_Store", "file.swp", "file.swo", ".tx-download-abc.tmp"} + for _, name := range noiseFiles { + err = os.WriteFile(filepath.Join(dir, name), []byte("noise"), 0o600) + require.NoError(t, err) + } + + // None of these should produce events + noEvent(t, w.Events, 500*time.Millisecond) + + // Now create a real file to confirm the watcher is working + err = os.WriteFile(filepath.Join(dir, "real.tex"), []byte("content"), 0o600) + require.NoError(t, err) + + ev, ok := waitForEvent(t, w.Events, 2*time.Second) + assert.True(t, ok, "expected event for real file") + assert.Equal(t, "real.tex", ev) + }) + + t.Run("excludes specified files", func(t *testing.T) { + dir := t.TempDir() + + w, err := cli.NewFileWatcher(dir, []string{"output.pdf"}) + require.NoError(t, err) + defer w.Close() + + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + go w.Run(ctx) + + // Write to an excluded file + err = os.WriteFile(filepath.Join(dir, "output.pdf"), []byte("pdf"), 0o600) + require.NoError(t, err) + + noEvent(t, w.Events, 500*time.Millisecond) + + // Write to a non-excluded file + err = os.WriteFile(filepath.Join(dir, "main.tex"), []byte("content"), 0o600) + require.NoError(t, err) + + ev, ok := waitForEvent(t, w.Events, 2*time.Second) + assert.True(t, ok, "expected event for non-excluded file") + assert.Equal(t, "main.tex", ev) + }) + + t.Run("watches new subdirectories", func(t *testing.T) { + dir := t.TempDir() + + w, err := cli.NewFileWatcher(dir, nil) + require.NoError(t, err) + defer w.Close() + + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + go w.Run(ctx) + + // Create a new subdirectory + subdir := filepath.Join(dir, "chapters") + err = os.MkdirAll(subdir, 0o750) + require.NoError(t, err) + + // Give the watcher time to add the new directory + time.Sleep(200 * time.Millisecond) + + // Create a file inside the new subdirectory + err = os.WriteFile(filepath.Join(subdir, "ch1.tex"), []byte("chapter 1"), 0o600) + require.NoError(t, err) + + ev, ok := waitForEvent(t, w.Events, 2*time.Second) + assert.True(t, ok, "expected event for file in new subdirectory") + assert.Equal(t, filepath.Join("chapters", "ch1.tex"), ev) + }) + + t.Run("ignores gitignored files", func(t *testing.T) { + dir := t.TempDir() + + // Create .gitignore + err := os.WriteFile(filepath.Join(dir, ".gitignore"), []byte("*.aux\nbuild/\n"), 0o600) + require.NoError(t, err) + + w, err := cli.NewFileWatcher(dir, nil) + require.NoError(t, err) + defer w.Close() + + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + go w.Run(ctx) + + // Create a gitignored file + err = os.WriteFile(filepath.Join(dir, "output.aux"), []byte("aux"), 0o600) + require.NoError(t, err) + + // Create a gitignored directory and file inside it + err = os.MkdirAll(filepath.Join(dir, "build"), 0o750) + require.NoError(t, err) + err = os.WriteFile(filepath.Join(dir, "build", "out.pdf"), []byte("pdf"), 0o600) + require.NoError(t, err) + + noEvent(t, w.Events, 500*time.Millisecond) + + // Create a non-ignored file + err = os.WriteFile(filepath.Join(dir, "main.tex"), []byte("content"), 0o600) + require.NoError(t, err) + + ev, ok := waitForEvent(t, w.Events, 2*time.Second) + assert.True(t, ok, "expected event for non-ignored file") + assert.Equal(t, "main.tex", ev) + }) +} + +func TestWatchAndBuild(t *testing.T) { + t.Run("rapid events within 500ms trigger only one build", func(t *testing.T) { + dir := t.TempDir() + + ui := cli.NewUIWithOptions(&bytes.Buffer{}, false, nil) + + var buildCount atomic.Int32 + build := func(ctx context.Context) ([]cli.DocResult, error) { + buildCount.Add(1) + return []cli.DocResult{{Name: "doc", Output: "doc.pdf", Success: true}}, nil + } + + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + + done := make(chan error, 1) + go func() { + done <- cli.WatchAndBuildWith(ctx, dir, ui, build) + }() + + // Give the watcher time to start + time.Sleep(100 * time.Millisecond) + + // Write multiple files rapidly (within 500ms debounce window) + for i := range 5 { + err := os.WriteFile(filepath.Join(dir, fmt.Sprintf("file%d.tex", i)), []byte("content"), 0o600) + require.NoError(t, err) + time.Sleep(50 * time.Millisecond) + } + + // Wait for debounce (500ms) + build time + margin + time.Sleep(1000 * time.Millisecond) + + assert.Equal(t, int32(1), buildCount.Load(), "expected exactly one build from rapid events") + + cancel() + <-done + }) + + t.Run("changes during build trigger exactly one follow-up rebuild", func(t *testing.T) { + dir := t.TempDir() + + ui := cli.NewUIWithOptions(&bytes.Buffer{}, false, nil) + + var buildCount atomic.Int32 + buildStarted := make(chan struct{}, 5) + buildRelease := make(chan struct{}) + + build := func(ctx context.Context) ([]cli.DocResult, error) { + n := buildCount.Add(1) + buildStarted <- struct{}{} + if n == 1 { + // First build blocks until released + select { + case <-buildRelease: + case <-ctx.Done(): + return nil, ctx.Err() + } + } + return []cli.DocResult{{Name: "doc", Output: "doc.pdf", Success: true}}, nil + } + + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + + done := make(chan error, 1) + go func() { + done <- cli.WatchAndBuildWith(ctx, dir, ui, build) + }() + + // Give the watcher time to start + time.Sleep(100 * time.Millisecond) + + // Trigger first build + err := os.WriteFile(filepath.Join(dir, "a.tex"), []byte("v1"), 0o600) + require.NoError(t, err) + + // Wait for first build to start + select { + case <-buildStarted: + case <-time.After(3 * time.Second): + t.Fatal("first build did not start") + } + + // While first build is running, make multiple changes + // These should coalesce into exactly one follow-up build + for i := range 3 { + err = os.WriteFile(filepath.Join(dir, fmt.Sprintf("b%d.tex", i)), []byte("content"), 0o600) + require.NoError(t, err) + time.Sleep(50 * time.Millisecond) + } + + // Wait for debounce to fire (signal gets queued) + time.Sleep(700 * time.Millisecond) + + // Release the first build + close(buildRelease) + + // Wait for the follow-up build to start and finish + select { + case <-buildStarted: + case <-time.After(3 * time.Second): + t.Fatal("follow-up build did not start") + } + + // Give it time to ensure no extra builds + time.Sleep(500 * time.Millisecond) + + assert.Equal(t, int32(2), buildCount.Load(), "expected exactly two builds: initial + one follow-up") + + cancel() + <-done + }) +} diff --git a/man/tx.1 b/man/tx.1 index 55e470d..f0b2345 100644 --- a/man/tx.1 +++ b/man/tx.1 @@ -14,6 +14,7 @@ .Op Fl -main Ar file .Nm .Cm build +.Op Fl -live .Op Fl -no-cache .Op Ar name ... .Nm @@ -125,6 +126,14 @@ When stdout is not a TTY, the build fails with an error. .Pp Flags: .Bl -tag -width Ds +.It Fl -live +Watch for file changes and rebuild automatically. +After the initial build, the command monitors the project directory for +modifications and triggers a rebuild approximately 500ms after the last change. +Rapid successive saves are coalesced into a single rebuild. +Output PDFs are rewritten in place (preserving the file inode) so that PDF +viewers such as Preview.app and Skim auto-refresh without reopening the file. +Press Ctrl+C to stop watching and exit. .It Fl -no-cache Rebuild without using the remote build cache. .El @@ -414,6 +423,10 @@ Build a specific document: .Pp .Dl $ tx build paper .Pp +Watch for changes and rebuild automatically: +.Pp +.Dl $ tx build --live +.Pp Rebuild from scratch without cache: .Pp .Dl $ tx build --no-cache From b09343751b014f82fa90aabe65c691fab70c8b4c Mon Sep 17 00:00:00 2001 From: Max Kuznetsov Date: Sun, 22 Mar 2026 22:50:47 +0000 Subject: [PATCH 2/2] update man --- man/tx.1.txt | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/man/tx.1.txt b/man/tx.1.txt index ff3a4d9..04dbbed 100644 --- a/man/tx.1.txt +++ b/man/tx.1.txt @@ -6,7 +6,7 @@ NAME SYNOPSIS tx login tx init [--texlive version] [--compiler name] [--main file] - tx build [--no-cache] [name ...] + tx build [--live] [--no-cache] [name ...] tx status tx token create [--name name] [--expires-in duration] [--no-expiry] tx token list @@ -84,6 +84,15 @@ COMMANDS Flags: + --live Watch for file changes and rebuild automatically. After the + initial build, the command monitors the project directory for + modifications and triggers a rebuild approximately 500ms after + the last change. Rapid successive saves are coalesced into a + single rebuild. Output PDFs are rewritten in place (preserving + the file inode) so that PDF viewers such as Preview.app and Skim + auto-refresh without reopening the file. Press Ctrl+C to stop + watching and exit. + --no-cache Rebuild without using the remote build cache. @@ -279,6 +288,10 @@ EXAMPLES $ tx build paper + Watch for changes and rebuild automatically: + + $ tx build --live + Rebuild from scratch without cache: $ tx build --no-cache