diff --git a/README.md b/README.md index a852460..f8ff28e 100644 --- a/README.md +++ b/README.md @@ -181,7 +181,10 @@ claude-mode lmstudio [preset] default preset: lmstudio claude-mode presets list presets (* = active) claude-mode preset show -claude-mode preset new [from] copy an existing preset +claude-mode preset new [from] copy an existing preset (default: `default`) +claude-mode preset new --provider

[--blank] + copy that provider's default, or start empty +claude-mode preset rename repoints state.json if it is in use claude-mode preset set claude-mode preset all point every tier at one model claude-mode preset rm diff --git a/linux/claude-key-helper.sh b/linux/claude-key-helper.sh index 12f6c80..ce20f2a 100644 --- a/linux/claude-key-helper.sh +++ b/linux/claude-key-helper.sh @@ -28,17 +28,24 @@ mode="$("$PY" "$JSON" get "$state" mode 2>/dev/null)" [ "$mode" = "anthropic" ] && exit 0 [ -n "$mode" ] || exit 0 -preset_name="$("$PY" "$JSON" get "$state" preset 2>/dev/null)" -[ -n "$preset_name" ] || exit 0 +# `preset rename` on the active preset moves the file while this may be running +# in any live session. The name read from state.json can be the old one by the +# time the file is opened, so the preset is read in a single open (a removal +# after that cannot change what was read), and a miss re-reads state.json once: +# the rename repoints it before removing the old name, so the second read finds +# the new one. A second miss is a real fault. +auth='' +for _attempt in 1 2; do + preset_name="$("$PY" "$JSON" get "$state" preset 2>/dev/null)" + [ -n "$preset_name" ] || exit 0 + preset="$CM_ROOT/presets/$preset_name.json" + auth="$("$PY" "$JSON" auth-of "$preset" 2>/dev/null)" && break + auth='' +done +[ -n "$auth" ] || fail "state.json names preset '$preset_name' but $preset does not exist. Run: claude-mode presets" -preset="$CM_ROOT/presets/$preset_name.json" -[ -f "$preset" ] || fail "state.json names preset '$preset_name' but $preset does not exist. Run: claude-mode presets" - -auth_mode="$("$PY" "$JSON" get "$preset" auth.mode 2>/dev/null)" -[ -z "$auth_mode" ] && auth_mode="vault" +auth_mode="${auth%%$'\t'*}" +key_ref="${auth#*$'\t'}" [ "$auth_mode" = "vault" ] || exit 0 # inline token: nothing for us to emit -key_ref="$("$PY" "$JSON" get "$preset" auth.keyRef 2>/dev/null)" -[ -n "$key_ref" ] || key_ref="openrouter" - cm_vault_get "$key_ref" || fail "no key readable for ref '$key_ref' from $(cm_vault_backend_label 2>/dev/null || echo 'the vault'). Run: claude-mode set-key $key_ref" diff --git a/linux/claude-mode b/linux/claude-mode index 7158df5..19829a6 100755 --- a/linux/claude-mode +++ b/linux/claude-mode @@ -265,7 +265,10 @@ claude-mode - switch Claude Code between Anthropic, OpenRouter, Z.AI, LM Studio claude-mode presets list presets claude-mode preset show - claude-mode preset new [from] create a preset (copies 'from') + claude-mode preset new [from] create a preset (copies 'from', else 'default') + claude-mode preset new --provider

[--blank] + copy that provider's default, or start empty + claude-mode preset rename claude-mode preset set claude-mode preset all claude-mode preset url point a preset at another server @@ -310,6 +313,16 @@ state_preset() { jget "$CM_STATE" preset; } preset_path() { printf '%s/%s.json' "$CM_PRESETS" "$1"; } +# Preset names become file names, so nothing but a plain word gets through - no +# slash, and no leading dot. Without this `preset show ../../etc/passwd` +# printed whatever it pointed at. +valid_preset_name() { + case "$1" in + ''|.*|*[!A-Za-z0-9._-]*) return 1 ;; + esac + return 0 +} + preset_names() { "$PY" "$JSON" presets "$CM_PRESETS" | cut -f1; } # Deliberately no awk anywhere in this script: it is absent from minimal images @@ -476,6 +489,18 @@ cm_preflight() { return 1 fi + # A preset with no tier mapped - a fresh `preset new --blank` - would switch + # cleanly and leave Claude Code asking the gateway for its own default + # Anthropic models: billed at full list price on OpenRouter, refused by the + # others. Nothing else here would catch it, since the cost guard only sees + # ids that are actually set. + if ! "$PY" "$JSON" models "$pf" 2>/dev/null | grep -v '^subagent' | cut -f2 | grep -q .; then + cm_pf_set 'no-models' "Preset '$preset' has no models set" \ + "Every tier is empty, so Claude Code would ask for its own default Anthropic models instead - billed at full price through OpenRouter, refused by the other providers." \ + "claude-mode preset set $preset " 'edit-preset' + return 1 + fi + base="$(jget "$pf" baseUrl)"; CM_PF_BASEURL="$base" auth_mode="$(jget "$pf" auth.mode)"; [ -z "$auth_mode" ] && auth_mode=vault @@ -1508,26 +1533,92 @@ else: print(' ok spend %.2f of %.2f limit (%s), %.2f remaining' % (use, lim, cmd_preset() { local sub="${1:-}" name="${2:-}" + if [ -n "$name" ] && ! valid_preset_name "$name"; then + err "invalid preset name '$name' - letters, digits, . _ and - only, not starting with a dot" + return 1 + fi case "$sub" in show) [ -n "$name" ] || { err 'usage: claude-mode preset show '; return 1; } + [ -f "$(preset_path "$name")" ] || { err "preset '$name' not found"; return 1; } cat "$(preset_path "$name")" ;; new) - [ -n "$name" ] || { err 'usage: claude-mode preset new [copy-from]'; return 1; } + [ -n "$name" ] || { err 'usage: claude-mode preset new [from] | --provider

[--blank]'; return 1; } [ -f "$(preset_path "$name")" ] && { err "preset '$name' already exists"; return 1; } - local from="${3:-default}" + local from='' provider='' blank=0 a + shift 2 + while [ $# -gt 0 ]; do + a="$1"; shift + case "$a" in + --provider) + [ $# -gt 0 ] || { err '--provider needs openrouter, zai or lmstudio'; return 1; } + provider="$1"; shift ;; + --blank) blank=1 ;; + -*) err "unknown option '$a'"; return 1 ;; + *) from="$a" ;; + esac + done + case "$provider" in + ''|openrouter|zai|lmstudio) ;; + *) err "unknown provider '$provider' - openrouter, zai or lmstudio"; return 1 ;; + esac + + if [ "$blank" -eq 1 ]; then + [ -n "$provider" ] || { err '--blank needs --provider to know which template'; return 1; } + [ -z "$from" ] || { err '--blank starts from nothing; drop the source preset'; return 1; } + "$PY" "$JSON" scaffold "$provider" > "$(preset_path "$name")" || { + rm -f "$(preset_path "$name")"; return 1; } + ok "created blank $provider preset '$name'" + say 'every tier is empty: set them with claude-mode preset set, or the bar panel' + return 0 + fi + + # A literal `default` is only right for OpenRouter. Given a provider + # and no source, start from that provider's own default preset. + if [ -z "$from" ]; then + if [ -n "$provider" ]; then from="$(resolve_preset "$provider")" || return 1 + else from=default; fi + fi + valid_preset_name "$from" || { err "invalid source preset name '$from'"; return 1; } [ -f "$(preset_path "$from")" ] || { err "source preset '$from' not found"; return 1; } + if [ -n "$provider" ]; then + local got; got="$(jget "$(preset_path "$from")" provider)"; [ -z "$got" ] && got=openrouter + [ "$got" = "$provider" ] || { err "'$from' is for $got, not $provider"; return 1; } + fi cp "$(preset_path "$from")" "$(preset_path "$name")" ok "created $(preset_path "$name") from '$from'" ;; + rename) + local new="${3:-}" out + [ -n "$name" ] && [ -n "$new" ] || { err 'usage: claude-mode preset rename '; return 1; } + valid_preset_name "$new" || { err "invalid preset name '$new' - letters, digits, . _ and - only, not starting with a dot"; return 1; } + [ -f "$(preset_path "$name")" ] || { err "preset '$name' not found"; return 1; } + [ "$name" = "$new" ] && { ok "already called '$new'"; return 0; } + [ -e "$(preset_path "$new")" ] && { err "preset '$new' already exists"; return 1; } + out="$("$PY" "$JSON" preset-rename "$CM_PRESETS" "$name" "$new" "$CM_STATE" 2>&1)" || { + err "$out"; return 1; } + ok "renamed '$name' -> '$new'" + case "$out" in + *'"active": true'*) + say 'it is the active preset; state.json now names it, and running sessions keep working' + write_health "$(state_mode)" "$new" ;; + esac + ;; rm) [ -n "$name" ] || { err 'usage: claude-mode preset rm '; return 1; } [ -f "$(preset_path "$name")" ] || { err "preset '$name' not found"; return 1; } if [ "$name" = "$(state_preset)" ] && [ "$(state_mode)" != "anthropic" ]; then err "preset '$name' is active. Switch away first."; return 1 fi + local prov; prov="$(jget "$(preset_path "$name")" provider)"; [ -z "$prov" ] && prov=openrouter rm -f "$(preset_path "$name")"; ok "deleted preset '$name'" + # Allowed, but not quietly: with no preset left, `claude-mode ` + # has nothing to resolve to. + if [ -z "$(presets_for_provider "$prov")" ]; then + warn "that was the last $prov preset - 'claude-mode $prov' has nothing to switch to now" + say "create one with: claude-mode preset new --provider $prov [--blank]" + fi ;; set) local tier="${3:-}" model="${4:-}" @@ -1935,9 +2026,7 @@ ui_new_preset() { printf ' name (letters, digits, dash; blank = cancel): ' local name; IFS= read -r name [ -n "$name" ] || return - case "$name" in - *[!A-Za-z0-9._-]*) err "invalid name '$name'"; return ;; - esac + valid_preset_name "$name" || { err "invalid name '$name'"; return; } [ -f "$(preset_path "$name")" ] && { err "preset '$name' already exists"; return; } if [ "$choice" -eq 0 ]; then diff --git a/linux/cm-json.py b/linux/cm-json.py index 69303fa..e1fb7c7 100644 --- a/linux/cm-json.py +++ b/linux/cm-json.py @@ -17,6 +17,8 @@ Subcommands: scan-sessions [max-age-days] [ignored] broken transcripts, as JSON ignore-session add|remove|clear|list [id] cache-models <1|0> [baseUrl] store a TSV catalogue (stdin) + preset-rename rename, repointing state.json + auth-of "modekeyRef"; fails if missing """ import glob @@ -250,6 +252,53 @@ def cmd_scaffold(argv): print(json.dumps(base, indent=2)) +def cmd_auth_of(argv): + """auth-of - 'modekeyRef' for the key helper, defaults filled in. + + Exits non-zero when the file is missing, which load() would otherwise + hide as an empty preset - and an empty preset reads as vault/openrouter, + so a preset renamed mid-read would quietly hand out the wrong key. + """ + try: + with open(argv[0], encoding="utf-8") as fh: + text = fh.read().strip() + except OSError: + sys.exit(1) + p = json.loads(text) if text else {} + auth = p.get("auth") or {} + print("%s\t%s" % (auth.get("mode") or "vault", auth.get("keyRef") or "openrouter")) + + +def cmd_preset_rename(argv): + """preset-rename + + Every running session's key helper resolves the active preset through + state.json on each key fetch, so at no instant may state.json name a file + that is not there. The new name is linked in first, state.json repointed, + and only then does the old name go. Prints {"renamed", "active"}. + """ + d, old, new, state_path = argv[:4] + src = os.path.join(d, old + ".json") + dst = os.path.join(d, new + ".json") + if not os.path.isfile(src): + raise SystemExit("preset '%s' not found" % old) + if os.path.exists(dst): + raise SystemExit("preset '%s' already exists" % new) + try: + os.link(src, dst) + except OSError: + __import__("shutil").copy2(src, dst) # filesystems without hard links + + state = load(state_path, {}) + active = isinstance(state, dict) and state.get("preset") == old + if active: + state["preset"] = new + save(state_path, state) + + os.unlink(src) + print(json.dumps({"renamed": True, "active": active})) + + def cmd_get(argv): data = load(argv[0]) cur = data @@ -1176,6 +1225,8 @@ COMMANDS = { "set-all": cmd_set_all, "set-auth": cmd_set_auth, "scaffold": cmd_scaffold, + "preset-rename": cmd_preset_rename, + "auth-of": cmd_auth_of, "get": cmd_get, "presets": cmd_presets, "managed": cmd_managed, diff --git a/omarchy/smoido.claude-mode/Panel.qml b/omarchy/smoido.claude-mode/Panel.qml index d969615..f0f45dc 100644 --- a/omarchy/smoido.claude-mode/Panel.qml +++ b/omarchy/smoido.claude-mode/Panel.qml @@ -62,7 +62,8 @@ Panel { // Both questions are answered by claude-mode rather than re-derived here, so // the widget and the CLI can never disagree about whether a switch is allowed // or about what is running. - property string stage: "list" // list | blocked | confirm | server | repair | preset | presetTier + property string stage: "list" // list | blocked | confirm | server | repair + // | preset | presetTier | presetNew | presetRename | presetDelete property var blocker: null // the preflight verdict, when it refused property var sessionInfo: null // sessions --json, when any were found property string pendingMode: "" @@ -239,13 +240,15 @@ Panel { property var opQueue: [] property string opLabel: "" property string opLanding: "" + property var opThen: null // run on success, before landing - function runOps(label, ops, landing) { + function runOps(label, ops, landing, then) { if (root.busy) return root.busy = true root.lastError = "" root.opLabel = label root.opLanding = landing + root.opThen = then || null root.opQueue = ops.slice() root.nextOp() } @@ -282,7 +285,12 @@ Panel { if (opProc.finalHealth) { root.busy = false if (widget) widget.refresh() - if (root.lastError === "") root.stage = root.opLanding + var then = root.opThen + root.opThen = null + if (root.lastError === "") { + if (then) then() + root.stage = root.opLanding + } return } if (code !== 0) { @@ -414,6 +422,80 @@ Panel { return Math.round(s / 86400) + " days ago" } + // ---- Preset lifecycle: new, duplicate, rename, delete + // + // Names become file names, so the CLI's valid_preset_name rule is checked + // here too. The CLI checks again; this only says what is wrong while typing. + readonly property var presetNameRe: /^[A-Za-z0-9_-][A-Za-z0-9._-]*$/ + property string newProvider: "" + property string newFrom: "" // source preset to copy; "" = blank template + property string newName: "" + property string renameTo: "" + + function presetsOf(provider) { + var all = (health && health.presets) ? health.presets : [] + return all.filter(function (p) { return String(p.provider) === provider }) + } + + // `keep` is a name that is allowed to exist already - the preset's own, + // when renaming. + function nameProblem(name, keep) { + var n = String(name || "").trim() + if (n === "") return "Give it a name." + if (!root.presetNameRe.test(n)) return "Letters, digits, . _ and - only, not starting with a dot." + if (n !== keep && root.presetEntry(n)) return "There is already a preset called '" + n + "'." + return "" + } + + function uniqueName(base) { + var n = base + for (var i = 2; root.presetEntry(n); i++) n = base + "-" + i + return n + } + + // `from` null means "whatever makes sense": the provider's first preset, + // which carries a working server, key and context, else a blank template. + function openNewPreset(provider, from) { + root.lastError = "" + root.newProvider = provider + var sibs = root.presetsOf(provider) + root.newFrom = from !== null && from !== undefined ? from : (sibs.length > 0 ? String(sibs[0].name) : "") + root.newName = root.uniqueName(from ? from + "-copy" : provider + "-new") + newNameField.text = root.newName + root.stage = "presetNew" + Qt.callLater(function () { newNameField.forceActiveFocus(); newNameField.selectAll() }) + } + + function createPreset() { + var n = root.newName.trim() + if (root.nameProblem(n, "") !== "") return + var argv = root.newFrom === "" + ? ["preset", "new", n, "--provider", root.newProvider, "--blank"] + : ["preset", "new", n, root.newFrom] + root.runOps("creating", [argv], "preset", function () { root.editPreset = n }) + } + + function openRename() { + root.lastError = "" + root.renameTo = root.editPreset + renameField.text = root.editPreset + root.stage = "presetRename" + Qt.callLater(function () { renameField.forceActiveFocus(); renameField.selectAll() }) + } + + function renamePreset() { + var n = root.renameTo.trim() + if (n === root.editPreset) { root.stage = "preset"; return } + if (root.nameProblem(n, root.editPreset) !== "") return + var old = root.editPreset + root.runOps("renaming", [["preset", "rename", old, n]], "preset", function () { root.editPreset = n }) + } + + function deletePreset() { + if (root.editIsActive || root.editPreset === "") return + root.runOps("deleting", [["preset", "rm", root.editPreset]], "list", function () { root.editPreset = "" }) + } + function storeServerKey() { remedyProc.command = root.cli(["set-key", root.serverKeyRef, "--terminal"]) remedyProc.running = true @@ -430,6 +512,7 @@ Panel { root.repairTarget = null root.editPreset = "" root.editTier = "" + root.newProvider = "" } function switchTo(mode, presetName) { @@ -622,6 +705,8 @@ Panel { implicitWidth: pbText.implicitWidth + Style.space(20) implicitHeight: Style.space(25) radius: Style.space(4) + // `enabled: false` greys it out; the MouseArea inherits the flag. + opacity: enabled ? 1.0 : 0.4 readonly property color tint: primary ? Color.accent : Color.popups.text color: pbArea.containsMouse @@ -843,10 +928,11 @@ Panel { } } - // ---- Presets for this provider, unfolded in place. + // ---- Presets for this provider, unfolded in place. Shown even + // with none left, so "New preset…" is still there to click. Column { width: parent.width - visible: modeEntry.isExpanded && modeEntry.presetList.length > 0 + visible: modeEntry.isExpanded spacing: Style.space(1) Repeater { @@ -941,6 +1027,40 @@ Panel { } } } + + Item { + width: modeEntry.width + height: Style.space(24) + + Rectangle { + anchors.fill: parent + anchors.leftMargin: Style.space(20) + anchors.rightMargin: -Style.space(6) + radius: Style.space(4) + color: newPresetArea.containsMouse + ? Qt.rgba(Color.accent.r, Color.accent.g, Color.accent.b, 0.14) + : "transparent" + } + + Text { + anchors.left: parent.left + anchors.leftMargin: Style.space(30) + anchors.verticalCenter: parent.verticalCenter + text: "New " + Modes.title(modeEntry.thisMode) + " preset…" + color: newPresetArea.containsMouse ? Color.accent : Color.muted + font.family: root.bar ? root.bar.fontFamily : Style.font.family + font.pixelSize: Style.space(11) + } + + MouseArea { + id: newPresetArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + enabled: !root.busy + onClicked: root.openNewPreset(modeEntry.thisMode, null) + } + } } } } @@ -1003,6 +1123,18 @@ Panel { visible: root.blocker && String(root.blocker.remedyKind) === "set-key" onTriggered: root.runRemedy() } + // A preset with every tier empty: the fix is one click away here, + // not in a terminal. + PillButton { + label: "Edit preset…" + primary: true + visible: root.blocker && String(root.blocker.remedyKind) === "edit-preset" + onTriggered: { + var p = root.pendingPreset + root.resetFlow() + root.openPresetEditor(p) + } + } PillButton { label: "Server settings…" primary: root.blocker && ["start-server", "set-url", "needs-key"].indexOf(String(root.blocker.remedyKind)) >= 0 @@ -1260,8 +1392,231 @@ Panel { visible: root.editProvider === "lmstudio" onTriggered: root.openServerSettings(root.editPreset, "preset") } + PillButton { label: "Duplicate…"; onTriggered: root.openNewPreset(root.editProvider, root.editPreset) } + PillButton { label: "Rename…"; onTriggered: root.openRename() } + // The CLI refuses to delete the preset in use. A button that always + // fails would be worse than one that says why it is off. + PillButton { + label: "Delete…" + enabled: !root.editIsActive + onTriggered: { root.lastError = ""; root.stage = "presetDelete" } + } PillButton { label: "Done"; primary: true; onTriggered: root.resetFlow() } } + + Text { + width: parent.width + visible: root.editIsActive + text: "In use, so it cannot be deleted. Switch to another preset first." + color: Color.muted + wrapMode: Text.WordWrap + font.family: root.bar ? root.bar.fontFamily : Style.font.family + font.pixelSize: Style.space(9) + } + } + + // ---- New preset: a name, and what to start from. + Column { + width: parent.width + visible: root.stage === "presetNew" + spacing: Style.space(7) + + Text { + width: parent.width + text: "New " + Modes.title(root.newProvider) + " preset" + color: Color.popups.text + elide: Text.ElideRight + font.family: root.bar ? root.bar.fontFamily : Style.font.family + font.pixelSize: Style.space(13) + font.bold: true + } + + TextField { + id: newNameField + width: parent.width + text: root.newName + placeholderText: "name" + foreground: Color.popups.text + font.family: root.bar ? root.bar.fontFamily : Style.font.family + font.pixelSize: Style.space(11) + onTextChanged: root.newName = text + onAccepted: root.createPreset() + } + + Text { + width: parent.width + readonly property string problem: root.stage === "presetNew" ? root.nameProblem(root.newName, "") : "" + visible: problem !== "" + text: problem + color: root.urgentColor + wrapMode: Text.WordWrap + font.family: root.bar ? root.bar.fontFamily : Style.font.family + font.pixelSize: Style.space(10) + } + + Text { + text: "Start from" + color: Color.muted + font.family: root.bar ? root.bar.fontFamily : Style.font.family + font.pixelSize: Style.space(10) + } + + // Only this provider's presets: a copy keeps the provider, and a + // preset cannot change provider afterwards. + Flow { + width: parent.width + spacing: Style.space(6) + + Repeater { + model: root.stage === "presetNew" ? root.presetsOf(root.newProvider) : [] + + PillButton { + required property var modelData + label: "copy of " + String(modelData.name) + primary: root.newFrom === String(modelData.name) + onTriggered: root.newFrom = String(modelData.name) + } + } + + PillButton { + label: "blank" + primary: root.newFrom === "" + onTriggered: root.newFrom = "" + } + } + + Text { + id: newFromHint + width: parent.width + text: root.newFrom === "" + ? "Every tier starts empty; the editor opens next to fill them in." + : "Same server, key and models as '" + root.newFrom + "'. The editor opens next to change them." + color: Color.muted + wrapMode: Text.WordWrap + font.family: root.bar ? root.bar.fontFamily : Style.font.family + font.pixelSize: Style.space(10) + } + + Flow { + width: parent.width + spacing: Style.space(7) + + PillButton { label: "Create"; primary: true; onTriggered: root.createPreset() } + PillButton { + label: "Cancel" + onTriggered: { + root.lastError = "" + if (root.editPreset !== "") root.stage = "preset" + else root.resetFlow() + } + } + } + } + + // ---- Rename: the file moves, and state.json follows if it is in use. + Column { + width: parent.width + visible: root.stage === "presetRename" + spacing: Style.space(7) + + Text { + width: parent.width + text: "Rename '" + root.editPreset + "'" + color: Color.popups.text + elide: Text.ElideRight + font.family: root.bar ? root.bar.fontFamily : Style.font.family + font.pixelSize: Style.space(13) + font.bold: true + } + + TextField { + id: renameField + width: parent.width + text: root.renameTo + placeholderText: "new name" + foreground: Color.popups.text + font.family: root.bar ? root.bar.fontFamily : Style.font.family + font.pixelSize: Style.space(11) + onTextChanged: root.renameTo = text + onAccepted: root.renamePreset() + } + + Text { + width: parent.width + readonly property string problem: root.stage === "presetRename" ? root.nameProblem(root.renameTo, root.editPreset) : "" + visible: problem !== "" + text: problem + color: root.urgentColor + wrapMode: Text.WordWrap + font.family: root.bar ? root.bar.fontFamily : Style.font.family + font.pixelSize: Style.space(10) + } + + Text { + width: parent.width + visible: root.editIsActive + text: "It is the preset in use. The switch follows the new name, and running sessions keep working." + color: Color.muted + wrapMode: Text.WordWrap + font.family: root.bar ? root.bar.fontFamily : Style.font.family + font.pixelSize: Style.space(10) + } + + Flow { + width: parent.width + spacing: Style.space(7) + + PillButton { label: "Rename"; primary: true; onTriggered: root.renamePreset() } + PillButton { label: "Back"; onTriggered: { root.lastError = ""; root.stage = "preset" } } + } + } + + // ---- Delete: confirmed, and says so when it leaves a provider empty. + Column { + width: parent.width + visible: root.stage === "presetDelete" + spacing: Style.space(7) + + readonly property bool lastOne: root.stage === "presetDelete" + && root.presetsOf(root.editProvider).length <= 1 + + Text { + width: parent.width + text: "Delete '" + root.editPreset + "'?" + color: Color.popups.text + elide: Text.ElideRight + font.family: root.bar ? root.bar.fontFamily : Style.font.family + font.pixelSize: Style.space(13) + font.bold: true + } + + Text { + width: parent.width + text: "The preset file is removed, and there is no undo from here. Its key stays in the vault." + color: Color.muted + wrapMode: Text.WordWrap + font.family: root.bar ? root.bar.fontFamily : Style.font.family + font.pixelSize: Style.space(10) + } + + Text { + width: parent.width + visible: parent.lastOne + text: "It is the only " + Modes.title(root.editProvider) + " preset, so " + + Modes.title(root.editProvider) + " will have nothing to switch to until you create another." + color: root.urgentColor + wrapMode: Text.WordWrap + font.family: root.bar ? root.bar.fontFamily : Style.font.family + font.pixelSize: Style.space(10) + } + + Flow { + width: parent.width + spacing: Style.space(7) + + PillButton { label: "Delete it"; primary: true; onTriggered: root.deletePreset() } + PillButton { label: "Back"; onTriggered: { root.lastError = ""; root.stage = "preset" } } + } } // ---- One tier: pick from the cached catalogue, or type any id.