diff --git a/.gitignore b/.gitignore index 3aed0ad..3db17f0 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ coverage/ /.cache /.tmp .claude/ +.playwright-mcp/ diff --git a/.structlint.yaml b/.structlint.yaml index b2738df..fd7898a 100644 --- a/.structlint.yaml +++ b/.structlint.yaml @@ -45,6 +45,7 @@ file_naming_pattern: - "*.png" - "*.jpg" - "*.svg" + - "*.gif" - "README*" - "LICENSE*" - "CHANGELOG*" diff --git a/COMMANDS.md b/COMMANDS.md index 47b0fff..8a2c802 100644 --- a/COMMANDS.md +++ b/COMMANDS.md @@ -96,6 +96,7 @@ Use `serve` when you want: - file-level placeholder trees - evaluated transform previews - idle refresh while editing values +- edit the delimiter pair in the browser and rescan instantly, no restart - saved presets per repo/template in browser IndexedDB - JSON import/export for presets diff --git a/EXAMPLES.md b/EXAMPLES.md index 7a31081..f38d943 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -39,6 +39,8 @@ Open `http://127.0.0.1:17817`, then use: The workbench shows a file tree for each placeholder and evaluated transform previews such as `APP_NAME:toUpperCase -> TEMPLATETESTER`. +If the repo uses a different delimiter pair, you don't need to restart the server: edit the `[[` `]]` pair shown next to the directory path and click **Set** to rescan with the new pair. It applies to Local, Clone, and Generate alike. + ## Clone with SSH auth ```sh diff --git a/README.md b/README.md index 6d3dedc..e267551 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # YankRun
+
@@ -11,6 +11,8 @@
**Template smarter**: Clone repos, replace tokens, or template existing projects — safely, with custom delimiters that won't clash with Helm, Jinja, or any other template language.
+
+
## TL;DR
```sh
@@ -47,7 +49,7 @@ yankrun template --dir ./project --input values.yaml --startDelim "<%" --endDeli
- **Transformation functions** (`toUpperCase`, `toLowerCase`, `gsub`)
- **Template file processing** (`.tpl` files processed and renamed)
- **Caching** for `generate` - caches GitHub repos and template variables in `~/.yankrun/cache.yaml`
-- **Interactive workbench** (`serve`) for local/clone/generate workflows with file trees, evaluated transform previews, saved presets, and JSON import/export
+- **Interactive workbench** (`serve`) for local/clone/generate workflows with file trees, evaluated transform previews, saved presets, JSON import/export, and in-browser custom delimiters
- **Safe terminal workflow** (`tui`) for preview-first directory templating
## Documentation
@@ -301,12 +303,15 @@ yankrun serve --dir ./my-project --addr 127.0.0.1:19090
yankrun serve --dir ./my-project --input values.yaml --dryRun
```
+See it in action in the demo GIF at the top of this README.
+
The workbench supports:
- local scan, preview, and apply using the same replacement logic as `template`
- clone from SSH or HTTPS repositories using the existing cloner/auth behavior
- generate from configured templates and GitHub discovery
- file-level placeholder trees and evaluated transform previews
+- **custom delimiters from the browser** — edit the `[[` `]]` pair next to the directory path and click **Set** to rescan with a new pair, no restart required (still `--startDelim`/`--endDelim` to set the starting pair)
- local saved presets in IndexedDB, with JSON import/export
**Flags:**
diff --git a/doc/banner.png b/doc/banner.png
deleted file mode 100644
index 289338c..0000000
Binary files a/doc/banner.png and /dev/null differ
diff --git a/doc/serve-demo.gif b/doc/serve-demo.gif
new file mode 100644
index 0000000..dc2ed84
Binary files /dev/null and b/doc/serve-demo.gif differ
diff --git a/doc/yankrun-logo.png b/doc/yankrun-logo.png
new file mode 100644
index 0000000..d529fda
Binary files /dev/null and b/doc/yankrun-logo.png differ
diff --git a/docs/AI/README.md b/docs/AI/README.md
index 1649793..be532ae 100644
--- a/docs/AI/README.md
+++ b/docs/AI/README.md
@@ -82,7 +82,7 @@ User Command → main.go → actions/*.go → internal/workflow → services/*.g
| `services/cloner.go` | Git clone operations | `CloneRepository()`, `CloneRepositoryBranch()` |
| `services/configio.go` | Config file management | `Load()`, `Save()`, `Reset()` |
| `internal/workflow/workflow.go` | Shared workflow used by CLI/TUI/web | `ScanDir()`, `ApplyDir()`, `CloneAndApply()` |
-| `internal/web/server.go` | Embedded local workbench API | `Scan()`, `Apply()`, `Clone()`, `Generate()` |
+| `internal/web/server.go` | Embedded local workbench API | `Scan()`, `Apply()`, `Clone()`, `Generate()`, `SetDelimiters()`, `ValidateDelimiters()` |
| `internal/tui/tui.go` | Preview-first terminal workflow | `Run()` |
| `actions/clone.go` | Clone command handler | `Execute()` |
| `actions/template.go` | Template command handler | `Execute()` |
@@ -92,6 +92,7 @@ User Command → main.go → actions/*.go → internal/workflow → services/*.g
- `serve` embeds `internal/web/templates` and `internal/web/static` into the single binary.
- The web UI supports local scan/apply, direct clone, and generate from configured templates.
- Preview responses include file-level placeholder trees and evaluated transform previews.
+- `POST /api/delimiters` lets the browser change the active start/end delimiter pair at runtime (`Server.SetDelimiters`); it validates with `ValidateDelimiters` (rejects empty, equal, or mutually-containing pairs — an empty pair would otherwise hang the literal scan in `services/replacer.go`), updates `Server.startDelim`/`endDelim` under `Server.mu`, and returns a fresh scan. The new pair applies to Local, Clone, and Generate alike since they all read it from the same `Server.settings()`.
- Browser IndexedDB stores saved presets locally; JSON import/export is client-side only.
- `tui` uses the same workflow engine for local directory scan/apply and remains preview-first.
diff --git a/docs/user/README.md b/docs/user/README.md
index 810d350..3b3c94f 100644
--- a/docs/user/README.md
+++ b/docs/user/README.md
@@ -348,6 +348,7 @@ The workbench provides:
- file-level placeholder trees so you can see what will change
- evaluated transform previews, for example `APP_NAME:toUpperCase -> MYAPP`
- idle refresh after value edits so previews stay current
+- editable delimiters in the browser — change the `[[` `]]` pair shown next to the directory path and click **Set** to rescan the whole workbench (Local, Clone, and Generate) with the new pair, no restart needed
- saved presets stored in browser IndexedDB, searchable by repo/template/branch/output/value keys
- preset JSON export/import for moving saved runs between browsers
@@ -372,9 +373,12 @@ Typical flow:
1. Start the server with an optional local directory and values file.
2. Use **Local**, **Clone**, or **Generate** mode.
-3. Click **Preview** before applying.
-4. Edit values; evaluated previews refresh after a short idle delay.
-5. Restore prior work from the left preset rail when repeating a repo/template.
+3. If your files use a different delimiter pair, edit it next to the directory path and click **Set** to rescan — no restart needed.
+4. Click **Preview** before applying.
+5. Edit values; evaluated previews refresh after a short idle delay.
+6. Restore prior work from the left preset rail when repeating a repo/template.
+
+Delimiters set from the browser apply to every mode (Local, Clone, Generate) for the rest of the session; a rejected change (empty, identical, or overlapping delimiters) leaves the previous pair active and shows the reason in the notice banner.
For clone mode:
diff --git a/internal/web/server.go b/internal/web/server.go
index 80fa308..600980c 100644
--- a/internal/web/server.go
+++ b/internal/web/server.go
@@ -110,6 +110,11 @@ type GenerateRequest struct {
DryRun bool `json:"dryRun"`
}
+type DelimitersRequest struct {
+ StartDelim string `json:"startDelim"`
+ EndDelim string `json:"endDelim"`
+}
+
func New(opts Options) (*Server, error) {
page, err := loadTemplate()
if err != nil {
@@ -191,6 +196,7 @@ func (s *Server) routes() {
s.mux.HandleFunc("/api/templates", s.handleTemplates)
s.mux.HandleFunc("/api/clone", s.handleClone)
s.mux.HandleFunc("/api/generate", s.handleGenerate)
+ s.mux.HandleFunc("/api/delimiters", s.handleSetDelimiters)
}
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
@@ -198,11 +204,14 @@ func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
return
}
+ s.mu.Lock()
+ dir, startDelim, endDelim := s.dir, s.startDelim, s.endDelim
+ s.mu.Unlock()
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = s.page.Execute(w, map[string]any{
- "Dir": s.dir,
- "StartDelim": s.startDelim,
- "EndDelim": s.endDelim,
+ "Dir": dir,
+ "StartDelim": startDelim,
+ "EndDelim": endDelim,
"ForceDryRun": s.forceDryRun,
})
}
@@ -259,6 +268,67 @@ func (s *Server) handleEvaluate(w http.ResponseWriter, r *http.Request) {
writeJSON(w, summary, nil)
}
+func (s *Server) handleSetDelimiters(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ defer r.Body.Close()
+ var req DelimitersRequest
+ if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<10)).Decode(&req); err != nil {
+ http.Error(w, "invalid JSON body", http.StatusBadRequest)
+ return
+ }
+ summary, err := s.SetDelimiters(req.StartDelim, req.EndDelim)
+ if err != nil {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusBadRequest)
+ _ = json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
+ return
+ }
+ writeJSON(w, summary, nil)
+}
+
+// SetDelimiters swaps the active start/end delimiters and returns a fresh scan
+// of the current directory using them.
+func (s *Server) SetDelimiters(start, end string) (PlaceholderSummary, error) {
+ start, end, err := ValidateDelimiters(start, end)
+ if err != nil {
+ return PlaceholderSummary{}, err
+ }
+ s.mu.Lock()
+ s.startDelim = start
+ s.endDelim = end
+ s.mu.Unlock()
+ return s.Scan()
+}
+
+// ValidateDelimiters rejects delimiter pairs that would make scanning hang or
+// silently corrupt results, and returns the trimmed start/end pair to use.
+//
+// The literal scan (walkAndAnalyzeFiles in services/replacer.go) finds each
+// delimiter with strings.Index, which returns 0 without consuming any input
+// for an empty needle. If both delimiters were empty the scan loop would spin
+// forever on any non-empty file, hanging the request goroutine indefinitely -
+// so empty (or whitespace-only) delimiters are rejected outright. Requiring
+// start != end and that neither contains the other rules out the remaining
+// cases where the scan and regex-based replace paths would silently disagree
+// on where a placeholder begins and ends.
+func ValidateDelimiters(start, end string) (string, string, error) {
+ start = strings.TrimSpace(start)
+ end = strings.TrimSpace(end)
+ if start == "" || end == "" {
+ return "", "", fmt.Errorf("start and end delimiters are required")
+ }
+ if start == end {
+ return "", "", fmt.Errorf("start and end delimiters must be different")
+ }
+ if strings.Contains(start, end) || strings.Contains(end, start) {
+ return "", "", fmt.Errorf("start and end delimiters must not contain each other")
+ }
+ return start, end, nil
+}
+
func (s *Server) Scan() (PlaceholderSummary, error) {
s.mu.Lock()
dir := s.dir
@@ -422,6 +492,8 @@ func writeJSON(w http.ResponseWriter, payload any, err error) {
}
func (s *Server) settings() workflow.TemplateSettings {
+ s.mu.Lock()
+ defer s.mu.Unlock()
return workflow.TemplateSettings{
StartDelim: s.startDelim,
EndDelim: s.endDelim,
diff --git a/internal/web/server_test.go b/internal/web/server_test.go
index 8b56ce0..4307880 100644
--- a/internal/web/server_test.go
+++ b/internal/web/server_test.go
@@ -4,11 +4,15 @@ import (
"bytes"
"encoding/json"
"fmt"
+ "io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
+ "strings"
+ "sync"
"testing"
+ "time"
"github.com/AxeForging/yankrun/domain"
"github.com/AxeForging/yankrun/services"
@@ -284,3 +288,312 @@ func TestCloneAndGenerateUseSharedWorkflow(t *testing.T) {
t.Fatalf("generate output = %q", string(got))
}
}
+
+func mustGetJSON(t *testing.T, url string, out any) {
+ t.Helper()
+ resp, err := http.Get(url)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ b, _ := io.ReadAll(resp.Body)
+ t.Fatalf("GET %s status = %d body=%s", url, resp.StatusCode, b)
+ }
+ if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func mustPostJSON(t *testing.T, url string, payload, out any) {
+ t.Helper()
+ buf, err := json.Marshal(payload)
+ if err != nil {
+ t.Fatal(err)
+ }
+ resp, err := http.Post(url, "application/json", bytes.NewReader(buf))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ b, _ := io.ReadAll(resp.Body)
+ t.Fatalf("POST %s status = %d body=%s", url, resp.StatusCode, b)
+ }
+ if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestValidateDelimiters(t *testing.T) {
+ cases := []struct {
+ name string
+ start string
+ end string
+ wantErr bool
+ errSubstr string
+ wantStart string
+ wantEnd string
+ }{
+ {name: "valid custom pair", start: "<%", end: "%>", wantStart: "<%", wantEnd: "%>"},
+ {name: "valid default pair", start: "[[", end: "]]", wantStart: "[[", wantEnd: "]]"},
+ {name: "trims surrounding whitespace", start: " <% ", end: " %> ", wantStart: "<%", wantEnd: "%>"},
+ {name: "regex metacharacters are treated literally", start: "(", end: ")", wantStart: "(", wantEnd: ")"},
+ {name: "unicode delimiters", start: "«", end: "»", wantStart: "«", wantEnd: "»"},
+ {name: "non-overlapping equal-length pair", start: "ab", end: "ba", wantStart: "ab", wantEnd: "ba"},
+ {name: "both empty", start: "", end: "", wantErr: true, errSubstr: "required"},
+ {name: "start empty", start: "", end: "]]", wantErr: true, errSubstr: "required"},
+ {name: "end empty", start: "[[", end: "", wantErr: true, errSubstr: "required"},
+ {name: "whitespace-only start counts as empty", start: " ", end: "]]", wantErr: true, errSubstr: "required"},
+ {name: "whitespace-only end counts as empty", start: "[[", end: " ", wantErr: true, errSubstr: "required"},
+ {name: "equal delimiters", start: "##", end: "##", wantErr: true, errSubstr: "different"},
+ {name: "start contains end", start: "[[[", end: "[[", wantErr: true, errSubstr: "contain"},
+ {name: "end contains start", start: "[[", end: "[[[", wantErr: true, errSubstr: "contain"},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ start, end, err := ValidateDelimiters(tc.start, tc.end)
+ if tc.wantErr {
+ if err == nil {
+ t.Fatalf("ValidateDelimiters(%q, %q) = nil error, want error", tc.start, tc.end)
+ }
+ if tc.errSubstr != "" && !strings.Contains(err.Error(), tc.errSubstr) {
+ t.Fatalf("error = %q, want substring %q", err.Error(), tc.errSubstr)
+ }
+ return
+ }
+ if err != nil {
+ t.Fatalf("ValidateDelimiters(%q, %q) unexpected error: %v", tc.start, tc.end, err)
+ }
+ if start != tc.wantStart || end != tc.wantEnd {
+ t.Fatalf("ValidateDelimiters(%q, %q) = (%q, %q), want (%q, %q)", tc.start, tc.end, start, end, tc.wantStart, tc.wantEnd)
+ }
+ })
+ }
+}
+
+// TestSetDelimitersRejectsEmptyWithoutHanging guards against a real bug found
+// while building this feature: the literal scan in services/replacer.go finds
+// delimiters with strings.Index, which returns 0 without consuming input for
+// an empty needle, so an empty/empty pair spins forever on any non-empty file
+// instead of erroring. SetDelimiters must reject the pair before it ever
+// reaches the scanner.
+func TestSetDelimitersRejectsEmptyWithoutHanging(t *testing.T) {
+ dir := t.TempDir()
+ if err := os.WriteFile(filepath.Join(dir, "app.txt"), []byte("Hello [[NAME]]"), 0644); err != nil {
+ t.Fatal(err)
+ }
+ s := testServer(t, dir, false)
+
+ done := make(chan error, 1)
+ go func() {
+ _, err := s.SetDelimiters("", "")
+ done <- err
+ }()
+
+ select {
+ case err := <-done:
+ if err == nil {
+ t.Fatal("SetDelimiters(\"\", \"\") = nil error, want rejection")
+ }
+ case <-time.After(2 * time.Second):
+ t.Fatal("SetDelimiters(\"\", \"\") did not return within 2s; the empty-delimiter scan likely hung")
+ }
+}
+
+func TestSetDelimitersEndpointRescansWithNewPair(t *testing.T) {
+ dir := t.TempDir()
+ if err := os.WriteFile(filepath.Join(dir, "default.txt"), []byte("Hi [[OTHER]]"), 0644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(dir, "custom.txt"), []byte("Hello <%NAME%>"), 0644); err != nil {
+ t.Fatal(err)
+ }
+
+ server := httptest.NewServer(testServer(t, dir, false).Handler())
+ defer server.Close()
+
+ var before PlaceholderSummary
+ mustGetJSON(t, server.URL+"/api/scan", &before)
+ if before.Counts["OTHER"] != 1 || before.Counts["NAME"] != 0 {
+ t.Fatalf("before switch summary = %+v, want OTHER=1 NAME=0", before.Counts)
+ }
+
+ var after PlaceholderSummary
+ mustPostJSON(t, server.URL+"/api/delimiters", DelimitersRequest{StartDelim: "<%", EndDelim: "%>"}, &after)
+ if after.Counts["NAME"] != 1 || after.Counts["OTHER"] != 0 {
+ t.Fatalf("after switch summary = %+v, want NAME=1 OTHER=0 (old default pair must stop matching)", after.Counts)
+ }
+
+ body := bytes.NewBufferString(`{"values":{"NAME":"World"},"dryRun":false}`)
+ resp, err := http.Post(server.URL+"/api/apply", "application/json", body)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer resp.Body.Close()
+ var applied ApplyResponse
+ if err := json.NewDecoder(resp.Body).Decode(&applied); err != nil {
+ t.Fatal(err)
+ }
+ if !applied.Applied || applied.TotalMatches != 1 {
+ t.Fatalf("apply response = %+v, want 1 applied match using the new delimiters", applied)
+ }
+ got, err := os.ReadFile(filepath.Join(dir, "custom.txt"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(got) != "Hello World" {
+ t.Fatalf("custom.txt = %q, want replaced using the new delimiters", string(got))
+ }
+ unrelated, err := os.ReadFile(filepath.Join(dir, "default.txt"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(unrelated) != "Hi [[OTHER]]" {
+ t.Fatalf("default.txt changed unexpectedly: %q", string(unrelated))
+ }
+}
+
+func TestSetDelimitersEndpointRejectsInvalidAndLeavesStateUnchanged(t *testing.T) {
+ dir := t.TempDir()
+ if err := os.WriteFile(filepath.Join(dir, "app.txt"), []byte("Hello [[NAME]]"), 0644); err != nil {
+ t.Fatal(err)
+ }
+
+ server := httptest.NewServer(testServer(t, dir, false).Handler())
+ defer server.Close()
+
+ cases := []struct {
+ name string
+ start string
+ end string
+ }{
+ {name: "both empty", start: "", end: ""},
+ {name: "start empty", start: "", end: "]]"},
+ {name: "end empty", start: "[[", end: ""},
+ {name: "equal delimiters", start: "##", end: "##"},
+ {name: "start contains end", start: "[[[", end: "[["},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ buf, err := json.Marshal(DelimitersRequest{StartDelim: tc.start, EndDelim: tc.end})
+ if err != nil {
+ t.Fatal(err)
+ }
+ resp, err := http.Post(server.URL+"/api/delimiters", "application/json", bytes.NewReader(buf))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusBadRequest {
+ t.Fatalf("status = %d, want 400", resp.StatusCode)
+ }
+ var errBody map[string]string
+ if err := json.NewDecoder(resp.Body).Decode(&errBody); err != nil {
+ t.Fatal(err)
+ }
+ if errBody["error"] == "" {
+ t.Fatal("expected a non-empty error message")
+ }
+
+ var summary PlaceholderSummary
+ mustGetJSON(t, server.URL+"/api/scan", &summary)
+ if summary.Counts["NAME"] != 1 {
+ t.Fatalf("after rejected change, NAME count = %d, want 1 (delimiters must stay unchanged)", summary.Counts["NAME"])
+ }
+ })
+ }
+}
+
+func TestSetDelimitersEndpointRejectsNonPost(t *testing.T) {
+ dir := t.TempDir()
+ server := httptest.NewServer(testServer(t, dir, false).Handler())
+ defer server.Close()
+
+ resp, err := http.Get(server.URL + "/api/delimiters")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusMethodNotAllowed {
+ t.Fatalf("status = %d, want 405", resp.StatusCode)
+ }
+}
+
+func TestSetDelimitersEndpointRejectsMalformedJSON(t *testing.T) {
+ dir := t.TempDir()
+ server := httptest.NewServer(testServer(t, dir, false).Handler())
+ defer server.Close()
+
+ resp, err := http.Post(server.URL+"/api/delimiters", "application/json", bytes.NewBufferString("{not json"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusBadRequest {
+ t.Fatalf("status = %d, want 400", resp.StatusCode)
+ }
+}
+
+func TestSetDelimitersPersistsForSettingsUsedByCloneAndGenerate(t *testing.T) {
+ dir := t.TempDir()
+ s := testServer(t, dir, false)
+
+ if _, err := s.SetDelimiters("<%", "%>"); err != nil {
+ t.Fatal(err)
+ }
+ got := s.settings()
+ if got.StartDelim != "<%" || got.EndDelim != "%>" {
+ t.Fatalf("settings() after SetDelimiters = %+v, want StartDelim=<%% EndDelim=%%>", got)
+ }
+}
+
+func TestIndexReflectsCurrentDelimitersAfterChange(t *testing.T) {
+ dir := t.TempDir()
+ server := httptest.NewServer(testServer(t, dir, false).Handler())
+ defer server.Close()
+
+ var summary PlaceholderSummary
+ mustPostJSON(t, server.URL+"/api/delimiters", DelimitersRequest{StartDelim: "<%", EndDelim: "%>"}, &summary)
+
+ resp, err := http.Get(server.URL + "/")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer resp.Body.Close()
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ t.Fatal(err)
+ }
+ html := string(body)
+ if !strings.Contains(html, `value="<%"`) {
+ t.Fatalf("index page does not reflect updated start delimiter:\n%s", html)
+ }
+ if !strings.Contains(html, `value="%>"`) {
+ t.Fatalf("index page does not reflect updated end delimiter:\n%s", html)
+ }
+}
+
+func TestSetDelimitersConcurrentAccessDoesNotRace(t *testing.T) {
+ dir := t.TempDir()
+ if err := os.WriteFile(filepath.Join(dir, "app.txt"), []byte("Hello [[NAME]] and <%NAME%>"), 0644); err != nil {
+ t.Fatal(err)
+ }
+ s := testServer(t, dir, false)
+
+ pairs := [][2]string{{"[[", "]]"}, {"<%", "%>"}, {"{{", "}}"}}
+ var wg sync.WaitGroup
+ for i := 0; i < 20; i++ {
+ pair := pairs[i%len(pairs)]
+ wg.Add(2)
+ go func(start, end string) {
+ defer wg.Done()
+ _, _ = s.SetDelimiters(start, end)
+ }(pair[0], pair[1])
+ go func() {
+ defer wg.Done()
+ _, _ = s.Scan()
+ }()
+ }
+ wg.Wait()
+}
diff --git a/internal/web/static/app.js b/internal/web/static/app.js
index 3d02717..ab56cdf 100644
--- a/internal/web/static/app.js
+++ b/internal/web/static/app.js
@@ -5,6 +5,9 @@ const cloneRepo = document.querySelector("#cloneRepo");
const templateSelect = document.querySelector("#templateSelect");
const savedRunsList = document.querySelector("#savedRunsList");
const presetSearch = document.querySelector("#presetSearch");
+const delimForm = document.querySelector("#delimForm");
+const delimStart = document.querySelector("#delimStart");
+const delimEnd = document.querySelector("#delimEnd");
let summary = { keys: [], counts: {}, values: {} };
let repoType = "ssh";
let activeMode = "local";
@@ -220,6 +223,22 @@ async function apply(dryRun) {
}
}
+async function setDelimiters(e) {
+ e.preventDefault();
+ const start = delimStart.value;
+ const end = delimEnd.value;
+ setBusy("updating delimiters");
+ try {
+ summary = await postJSON("/api/delimiters", { startDelim: start, endDelim: end });
+ render();
+ show("Delimiters set to " + start.trim() + "KEY" + end.trim() + ". Rescanned with the new pair.", "ok");
+ } catch (err) {
+ show(err.message || "Failed to set delimiters", "err");
+ } finally {
+ setReady();
+ }
+}
+
function scheduleEvaluate() {
clearTimeout(evaluateTimer);
statusEl.textContent = "editing";
@@ -399,6 +418,7 @@ document.querySelectorAll("[data-preset-filter]").forEach(b => b.addEventListene
renderSavedRuns();
}));
presetSearch.addEventListener("input", renderSavedRuns);
+delimForm.addEventListener("submit", setDelimiters);
document.querySelector("#refresh").addEventListener("click", scan);
document.querySelector("#preview").addEventListener("click", () => apply(true));
document.querySelector("#apply").addEventListener("click", () => apply(false));
diff --git a/internal/web/static/style.css b/internal/web/static/style.css
index 262db22..349ae72 100644
--- a/internal/web/static/style.css
+++ b/internal/web/static/style.css
@@ -9,6 +9,12 @@ body{margin:0;background:radial-gradient(circle at 12% 10%,#fff1eb 0,#fff7f3 26%
.brand{display:flex;align-items:center;gap:12px;font-weight:850;letter-spacing:-.02em}
.mark{width:36px;height:36px;border-radius:11px;background:linear-gradient(135deg,#ff5a24,#f03716);display:grid;place-items:center;color:white;font-weight:900;box-shadow:0 18px 30px -18px rgba(255,90,36,.9)}
.pill{border:1px solid var(--line);background:rgba(255,255,255,.72);backdrop-filter:blur(14px);border-radius:999px;padding:10px 14px;color:var(--muted);font-size:13px}
+.top-meta{display:flex;gap:10px;flex-wrap:wrap;align-items:center}
+.delim-form{display:inline-flex;align-items:center;gap:6px;margin:0}
+.delim-input{width:52px;padding:5px 8px;font-size:12px;text-align:center;border-radius:8px}
+.delim-key{color:var(--muted);font-size:12px;font:12px ui-monospace,SFMono-Regular,Menlo,monospace}
+.delim-set{border:1px solid var(--line);background:white;border-radius:999px;padding:5px 12px;font-size:12px;font-weight:700;color:var(--ink);cursor:pointer}
+.delim-set:hover{border-color:#ff8a63;color:var(--brand-dark)}
.grid{display:grid;grid-template-columns:minmax(0,1.08fr) minmax(320px,.72fr);gap:24px;align-items:start}
.hero{padding:18px 0}
.eyebrow{display:inline-flex;gap:8px;align-items:center;border:1px solid #ffd6c7;background:#fff7f2;color:#9f3418;border-radius:999px;padding:8px 12px;font-size:12px;font-weight:800;letter-spacing:.12em;text-transform:uppercase}
diff --git a/internal/web/templates/workspace.html.tmpl b/internal/web/templates/workspace.html.tmpl
index d5d52f7..7d5877f 100644
--- a/internal/web/templates/workspace.html.tmpl
+++ b/internal/web/templates/workspace.html.tmpl
@@ -31,7 +31,15 @@