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
- YankRun + YankRun

Go Version OS Support @@ -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. +![yankrun serve workbench: scan, fill placeholders, preview, apply](doc/serve-demo.gif) + ## 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 @@

Y
YankRun Workbench
-
{{.Dir}} · {{.StartDelim}}KEY{{.EndDelim}}
+
+
{{.Dir}}
+
+ + KEY + + +
+