Show and edit option values laid out as in named.conf

Values with blocks (logging, controls, ...) are shown over several lines
with indentation, and edited that way: the editor grows with its content,
Tab indents, Enter keeps the indentation. With a draft open, option tables
show the draft's values.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adamandClaude Opus 5.5 committed 2026-10-10 11:55:41 +00:00
1 parent 27d1f9e538
commit ad8eb3fc3d
7 files changed
+93 -5

No files matched your search

+4
View File
@@ -77,6 +77,10 @@ type Option struct {
Where string `json:"where"` // e.g. "view internal"
Description string `json:"description,omitempty"`
Status string `json:"status,omitempty"`
// Formatted is the value laid out over several lines, as in
// named.conf, when it has a block (for display and editing; Value is
// the same on one line).
Formatted string `json:"formatted,omitempty"`
}
// User is a user with formatted grants.
+18
View File
@@ -36,6 +36,24 @@ func Compact(items []Item) string {
// CompactStmt returns the statement on a single line with its semicolon.
func CompactStmt(s *Stmt) string { return Compact(s.Items) + ";" }
// FormatValue returns an option's value laid out as named.conf writes it:
// blocks over several lines, indented with tabs. Values without a block,
// and values that don't parse, are returned as they are.
func FormatValue(name, value string) string {
if !strings.Contains(value, "{") {
return value
}
st, err := ParseOptionValue(name, value)
if err != nil {
return value
}
out := strings.TrimSuffix(strings.TrimRight(FormatStmt(st, 0), "\n"), ";")
if rest, ok := strings.CutPrefix(out, st.Keyword()); ok {
return strings.TrimLeft(rest, " ")
}
return value
}
// QuoteString returns s as a named.conf quoted string.
func QuoteString(s string) string {
var sb strings.Builder
+18
View File
@@ -0,0 +1,18 @@
package namedconf
import "testing"
func TestFormatValue(t *testing.T) {
for _, c := range []struct{ name, value, want string }{
{"logging", `{ channel c { file "/var/log/named/x.log" versions 3; print-time yes; }; category default { c; }; }`,
// A short inner block stays on one line, as in named.conf.
"{\n\tchannel c { file \"/var/log/named/x.log\" versions 3; print-time yes; };\n\tcategory default { c; };\n}"},
{"allow-query", `{ allowed_clients; }`, `{ allowed_clients; }`}, // short: unchanged
{"directory", `"/var/cache/bind"`, `"/var/cache/bind"`},
{"logging", `{ broken`, `{ broken`}, // unparsable: as it is
} {
if got := FormatValue(c.name, c.value); got != c.want {
t.Errorf("FormatValue(%s, %q) =\n%s\nwant\n%s", c.name, c.value, got, c.want)
}
}
}
+4
View File
@@ -9,6 +9,7 @@ import (
"jarl/internal/dnsname"
"jarl/internal/engine"
"jarl/internal/model"
"jarl/internal/namedconf"
"jarl/internal/render"
"jarl/internal/rr"
"jarl/internal/version"
@@ -100,6 +101,9 @@ func presentOption(st *model.State, o *model.Option) api.Option {
if c, ok := catalog.Default().Lookup(scope, o.Name); ok {
out.Description, out.Status = c.Description, c.Status
}
if f := namedconf.FormatValue(o.Name, o.Value); f != o.Value {
out.Formatted = f
}
return out
}
+38 -5
View File
@@ -1,5 +1,5 @@
import { h, mount, table, field, input, select, textarea, toast, errorBox, badge, confirmDialog, lines } from "../ui.js";
import { get, change, describeResult } from "../api.js";
import { get, change, describeResult, draftQuery } from "../api.js";
import { rerender } from "../app.js";
import { emailOf } from "./zones.js";
import { amlContext, suggestions } from "../aml.js";
@@ -28,7 +28,7 @@ function valueWidget(stmt, value, ctx) {
return { el: h("div", null, t, ctx ? suggestions(t, ctx) : null), get: () => "{ " + lines(t.value).map((l) => l.replace(/;$/, "") + ";").join(" ") + " }", hint: "One element per line: address or prefix, !negated, key NAME, ACL name, any, none, localhost, localnets. First match wins." };
}
if (kind === "raw") {
const t = textarea(value || "", { class: "mono", rows: 3 });
const t = codeArea(value || "");
// Statements such as allow-transfer and listen-on take an address
// match list after optional parameters: suggest names at the cursor.
const takesList = stmt && /address_match_element/.test(stmt.grammar || "");
@@ -39,9 +39,42 @@ function valueWidget(stmt, value, ctx) {
return { el: i, get: () => i.value.trim() };
}
// codeArea is a textarea for named.conf text: as tall as its content
// (up to 30 lines), Tab indents, Enter keeps the line's indentation. After
// Esc, Tab moves on to the next field again.
function codeArea(value) {
const t = textarea(value, { class: "mono code", spellcheck: "false" });
const fit = () => { t.rows = Math.min(30, Math.max(3, t.value.split("\n").length + 1)); };
fit();
let tabMoves = false;
const insert = (text) => {
const { selectionStart: a, selectionEnd: b } = t;
t.setRangeText(text, a, b, "end");
fit();
};
t.addEventListener("input", fit);
t.addEventListener("focus", () => { tabMoves = false; });
t.addEventListener("keydown", (ev) => {
if (ev.key === "Escape") { tabMoves = true; return; }
if (ev.key === "Tab" && !tabMoves && !ev.shiftKey) {
ev.preventDefault();
insert("\t");
} else if (ev.key === "Enter" && !ev.shiftKey && !ev.ctrlKey && !ev.metaKey) {
const line = t.value.slice(0, t.selectionStart).split("\n").pop();
let indent = line.match(/^\t*/)[0];
if (line.trimEnd().endsWith("{")) indent += "\t";
ev.preventDefault();
insert("\n" + indent);
}
});
return t;
}
// optionsEditor renders the options of one scope.
export async function optionsEditor(el, { scope, scope_id, catalogScope, title, intro }) {
const [opts, cat, ctx] = await Promise.all([get("/options", { scope, scope_id }), catalog(catalogScope), amlContext()]);
// With a draft open, show the options as they will be: a staged edit
// stays visible after Save.
const [opts, cat, ctx] = await Promise.all([get("/options", draftQuery({ scope, scope_id })), catalog(catalogScope), amlContext()]);
const byName = Object.fromEntries(cat.map((s) => [s.name, s]));
const err = h("div");
const listEl = h("div");
@@ -49,7 +82,7 @@ export async function optionsEditor(el, { scope, scope_id, catalogScope, title,
const draw = () => {
mount(listEl, table([
{ label: "Option", render: (o) => h("span", null, h("strong", { class: "mono" }, o.name), o.status ? [" ", badge(o.status, "warn")] : null), tdClass: "nowrap" },
{ label: "Value", render: (o) => editing === o.id ? editRow(o) : h("pre", { style: { padding: "2px 6px", whiteSpace: "pre-wrap" } }, o.value || "(no value)") },
{ label: "Value", render: (o) => editing === o.id ? editRow(o) : h("pre", { class: "code", style: { padding: "2px 6px", whiteSpace: "pre-wrap" } }, o.formatted || o.value || "(no value)") },
{ label: "Description", render: (o) => h("span", { class: "muted small" }, o.description || ""), tdClass: "small" },
{ label: "", render: (o) => editing === o.id ? "" : h("span", null,
h("button", { class: "small", onclick: () => { editing = o.id; draw(); } }, "Edit"), " ",
@@ -57,7 +90,7 @@ export async function optionsEditor(el, { scope, scope_id, catalogScope, title,
], opts, { empty: "No options set; BIND's defaults apply." }));
};
const editRow = (o) => {
const w = valueWidget(byName[o.name], o.value, ctx);
const w = valueWidget(byName[o.name], o.formatted || o.value, ctx);
const e2 = h("div");
return h("div", null, w.el, w.hint ? h("div", { class: "hint" }, w.hint) : null,
h("div", { class: "actions", style: { marginTop: "6px" } },
+2
View File
@@ -443,6 +443,8 @@ footer.site a:hover { color: var(--accent); text-decoration-color: currentColor;
.query-log li.pending code { opacity: 0.85; }
.err-text { color: var(--err); }
ul.plain { list-style: none; margin: 8px 0; padding: 0; }
.code { tab-size: 4; }
textarea.code { width: 100%; white-space: pre; overflow-wrap: normal; overflow-x: auto; }
details.view-picker { position: relative; }
details.view-picker summary { cursor: pointer; padding: 5px 8px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--panel); min-width: 12em; max-width: 24em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
details.view-picker[open] .view-picker-list { position: absolute; z-index: 20; top: calc(100% + 4px); left: 0; min-width: 14em; max-height: 260px; overflow-y: auto; display: flex; flex-direction: column; gap: 4px; padding: 8px 10px; background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); }