f0dfa689fe
Three small follow-ups from review:
1. Restore target is now operator-editable. Default value is the
literal '\$HOME/rm-restore/<job-id>/' (agent expands \$HOME at
run time using os.UserHomeDir(); also handles \${HOME} and ~/
prefixes). Operator can replace with any absolute path.
- ui_restore.go validates the input is either absolute or starts
with one of the recognised prefixes; other env-var refs (\$PATH
etc.) are deliberately rejected so operator paths can't pick up
arbitrary agent env values.
- host_restore.html replaces the read-only mono-text display with
a real <input>; help text spells out that \$HOME resolves
agent-side and <job-id> is substituted on dispatch.
- install.sh + the systemd unit prep /root/rm-restore so the
default works under the sandbox: ReadWritePaths gains a soft
'-/root/rm-restore' entry (the '-' makes the bind-mount soft-fail
if missing, but install.sh pre-creates it root-owned 0700).
2. --no-ownership flag now gated on restic version. The flag was
added in restic 0.17 and 0.16 rejects it. Previously dropped it
wholesale — that meant new-dir restores silently preserved
ownership against design intent on 0.17+. Now the agent threads
its detected restic version (sysinfo already collects it) through
runner.Config -> restic.Env, and RunRestore appends --no-ownership
only when AtLeastVersion(0, 17) returns true. 0.16 hosts still
restore with original uid/gid; help text in the wizard explicitly
notes this. The previous 'Original ownership is preserved' copy
was wrong for new-dir mode and is corrected.
3. golangci-lint misspell locale switched US -> UK and the codebase
swept (73 corrections, mostly behaviour/serialise/recognise/honour).
Wire-format ErrorCode 'unauthorized' -> 'unauthorised' is a tiny
contract change but the agent doesn't parse those codes today and
no external API consumers exist yet. Tests passed before + after.
Tests:
- internal/restic/version_test.go covers Env.AtLeastVersion across
edge cases (empty, exact match, patch above, minor below, non-
numeric) and expandHome on \$HOME / \${HOME} / ~/, plus
pass-through for absolute paths and refusal of other env vars.
- ui_restore_test updated: TargetDir now starts '\$HOME/rm-restore/'
with the job_id substituted into the placeholder.
Live verified on the smoke env: default target restored to
/root/rm-restore/<job-id>/ as the agent's expanded \$HOME (2 files,
14 bytes); custom override '/tmp/custom-restore/<job-id>/' restored
into the agent's PrivateTmp namespace (1 file, 6 bytes); both jobs
'succeeded', exit 0.
381 lines
21 KiB
HTML
381 lines
21 KiB
HTML
{{define "title"}}{{.Title}}{{end}}
|
|
|
|
{{define "content"}}
|
|
{{template "host_chrome" .}}
|
|
{{$page := .Page}}
|
|
{{$host := $page.Host}}
|
|
<div class="max-w-[1280px] mx-auto px-8 pt-6 pb-14">
|
|
|
|
<div class="flex items-baseline justify-between mb-4">
|
|
<div>
|
|
<h2 class="text-[19px] font-medium tracking-[-0.005em]">Restore from snapshot</h2>
|
|
<div class="text-[12.5px] text-ink-mute mt-1">
|
|
Pick a snapshot, choose paths, decide where files go, then dispatch.
|
|
Live progress streams to a job page once you start.
|
|
</div>
|
|
</div>
|
|
<div class="flex gap-2">
|
|
<a href="/hosts/{{$host.ID}}" class="btn">Cancel</a>
|
|
</div>
|
|
</div>
|
|
|
|
{{if $page.Error}}
|
|
<div class="rounded-[6px] px-3.5 py-3 text-[13px] mb-4"
|
|
style="border: 1px solid color-mix(in oklch, var(--bad), transparent 60%); background: color-mix(in oklch, var(--bad), transparent 92%);">
|
|
{{$page.Error}}
|
|
</div>
|
|
{{end}}
|
|
|
|
<form method="post" action="/hosts/{{$host.ID}}/restore" id="restore-form" class="space-y-4">
|
|
|
|
{{/* ============ STEP 1 — snapshot picker ============ */}}
|
|
<section class="rounded-[8px] border border-line-soft bg-panel overflow-hidden">
|
|
<header class="flex items-center justify-between px-[18px] py-[14px] border-b border-line-soft"
|
|
style="background: color-mix(in oklch, var(--panel), var(--panel-hi) 30%);">
|
|
<div class="flex items-center gap-3">
|
|
{{if $page.Selected}}
|
|
<span class="inline-flex items-center justify-center w-[22px] h-[22px] rounded-full mono text-[11px] font-medium"
|
|
style="background: color-mix(in oklch, var(--ok), transparent 86%); color: var(--ok); border: 1px solid color-mix(in oklch, var(--ok), transparent 60%);">✓</span>
|
|
{{else}}
|
|
<span class="inline-flex items-center justify-center w-[22px] h-[22px] rounded-full mono text-[11px] font-medium"
|
|
style="background: color-mix(in oklch, var(--accent), transparent 84%); color: var(--accent); border: 1px solid color-mix(in oklch, var(--accent), transparent 50%);">1</span>
|
|
{{end}}
|
|
<div>
|
|
<div class="text-[14px] font-medium">Snapshot</div>
|
|
<div class="text-[12px] text-ink-mute mt-0.5">Pick the point-in-time you want to restore from.</div>
|
|
</div>
|
|
</div>
|
|
<span class="mono text-[11px] text-ink-fade">step 1 of 4</span>
|
|
</header>
|
|
<div class="p-[18px]">
|
|
{{if $page.Selected}}
|
|
{{/* selected summary card */}}
|
|
<div class="grid items-center gap-4 px-3.5 py-3 rounded-[6px] bg-bg border border-line-soft"
|
|
style="grid-template-columns: auto 1fr auto auto;">
|
|
<span class="mono text-[12px] text-accent">{{$page.Selected.ShortID}}</span>
|
|
<div>
|
|
<div class="text-[13px] text-ink">{{$page.Selected.Time.Format "2006-01-02 15:04 MST"}} <span class="text-ink-fade mx-2">·</span><span class="text-ink-mute">{{relTime $page.Selected.Time}}</span></div>
|
|
<div class="mt-1 text-[12px] text-ink-mute">
|
|
{{range $page.Selected.Tags}}<span class="tag mr-1.5">{{.}}</span>{{end}}
|
|
paths:
|
|
{{range $i, $p := $page.Selected.Paths}}{{if $i}}, {{end}}<span class="mono text-ink-mid">{{$p}}</span>{{end}}
|
|
{{if $page.Selected.SizeBytes}} · {{bytes $page.Selected.SizeBytes}}{{end}}
|
|
</div>
|
|
</div>
|
|
<span class="text-ink-fade text-[12px]">picked from {{len $page.Snapshots}} snapshots</span>
|
|
<a href="/hosts/{{$host.ID}}/restore" class="btn">Change</a>
|
|
</div>
|
|
<input type="hidden" name="snapshot_id" value="{{$page.Selected.ID}}" />
|
|
{{else}}
|
|
{{/* full picker table */}}
|
|
<div class="rounded-[6px] border border-line-soft bg-bg overflow-hidden">
|
|
<div class="snap-row head">
|
|
<div>Time</div>
|
|
<div>Tag</div>
|
|
<div>Paths</div>
|
|
<div>Size</div>
|
|
<div>Snapshot ID</div>
|
|
<div></div>
|
|
</div>
|
|
{{if not $page.Snapshots}}
|
|
<div class="px-4 py-8 text-center text-ink-mute text-[13px]">No snapshots yet. Run a backup first.</div>
|
|
{{end}}
|
|
{{range $page.Snapshots}}
|
|
<a href="/hosts/{{$host.ID}}/snapshots/{{.ID}}/restore" class="snap-row" style="text-decoration: none; color: inherit;">
|
|
<div class="mono text-ink-mid">{{relTime .Time}}</div>
|
|
<div>{{range .Tags}}<span class="tag">{{.}}</span>{{end}}</div>
|
|
<div class="text-ink-mute" style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
|
|
{{range $i, $p := .Paths}}{{if $i}}, {{end}}<span class="mono text-ink-mid">{{$p}}</span>{{end}}
|
|
</div>
|
|
<div class="mono text-ink-mid">{{if .SizeBytes}}{{bytes .SizeBytes}}{{else}}—{{end}}</div>
|
|
<div class="mono text-ink-mid">{{.ShortID}}</div>
|
|
<div></div>
|
|
</a>
|
|
{{end}}
|
|
</div>
|
|
{{end}}
|
|
</div>
|
|
</section>
|
|
|
|
{{/* ============ STEP 2 — paths (tree browser) ============ */}}
|
|
<section class="rounded-[8px] border border-line-soft bg-panel overflow-hidden {{if not $page.Selected}}opacity-40 pointer-events-none{{end}}">
|
|
<header class="flex items-center justify-between px-[18px] py-[14px] border-b border-line-soft"
|
|
style="background: color-mix(in oklch, var(--panel), var(--panel-hi) 30%);">
|
|
<div class="flex items-center gap-3">
|
|
<span class="inline-flex items-center justify-center w-[22px] h-[22px] rounded-full mono text-[11px] font-medium"
|
|
style="{{if $page.Selected}}background: color-mix(in oklch, var(--accent), transparent 84%); color: var(--accent); border: 1px solid color-mix(in oklch, var(--accent), transparent 50%);{{else}}background: var(--bg); color: var(--ink-mute); border: 1px solid var(--line);{{end}}">2</span>
|
|
<div>
|
|
<div class="text-[14px] font-medium">Paths</div>
|
|
<div class="text-[12px] text-ink-mute mt-0.5">Tick files and directories to restore. Folders restore recursively.</div>
|
|
</div>
|
|
</div>
|
|
<span class="mono text-[11px] text-ink-fade">step 2 of 4</span>
|
|
</header>
|
|
<div class="p-[18px]">
|
|
{{if $page.Selected}}
|
|
<div class="rounded-[6px] border border-line-soft bg-bg overflow-hidden p-2">
|
|
{{/* Root tree node — fetched on first wizard render; child
|
|
expansions reuse the same tree.list cache server-side. */}}
|
|
<div id="tree-root">
|
|
<div class="text-ink-mute text-[12.5px] mono px-3 py-2">loading…</div>
|
|
</div>
|
|
</div>
|
|
<script>
|
|
(function() {
|
|
fetch('/hosts/{{$host.ID}}/restore/tree?snapshot={{$page.Selected.ID}}&path=/', { credentials: 'same-origin' })
|
|
.then(function(r) { return r.text(); })
|
|
.then(function(html) {
|
|
document.getElementById('tree-root').innerHTML = html;
|
|
document.body.dispatchEvent(new CustomEvent('tree:loaded'));
|
|
});
|
|
})();
|
|
</script>
|
|
<div class="mt-3 px-3.5 py-2.5 rounded-[6px] text-[12.5px]"
|
|
style="border: 1px solid color-mix(in oklch, var(--accent), transparent 70%); background: color-mix(in oklch, var(--accent), transparent 92%);">
|
|
<span class="text-accent" id="tally-count">0 files selected</span>
|
|
<span class="text-ink-fade mx-2">·</span>
|
|
<span class="text-ink-mute mono" id="tally-paths">tick a file or directory above</span>
|
|
</div>
|
|
{{else}}
|
|
<div class="text-ink-mute text-[13px]">Pick a snapshot above to load its paths.</div>
|
|
{{end}}
|
|
</div>
|
|
</section>
|
|
|
|
{{/* ============ STEP 3 — target ============ */}}
|
|
<section class="rounded-[8px] border border-line-soft bg-panel overflow-hidden {{if not $page.Selected}}opacity-40 pointer-events-none{{end}}">
|
|
<header class="flex items-center justify-between px-[18px] py-[14px] border-b border-line-soft"
|
|
style="background: color-mix(in oklch, var(--panel), var(--panel-hi) 30%);">
|
|
<div class="flex items-center gap-3">
|
|
<span class="inline-flex items-center justify-center w-[22px] h-[22px] rounded-full mono text-[11px] font-medium"
|
|
style="background: color-mix(in oklch, var(--accent), transparent 84%); color: var(--accent); border: 1px solid color-mix(in oklch, var(--accent), transparent 50%);">3</span>
|
|
<div>
|
|
<div class="text-[14px] font-medium">Target</div>
|
|
<div class="text-[12px] text-ink-mute mt-0.5">Where should the files land? Defaults to a fresh, isolated directory.</div>
|
|
</div>
|
|
</div>
|
|
<span class="mono text-[11px] text-ink-fade">step 3 of 4</span>
|
|
</header>
|
|
<div class="p-[18px]">
|
|
<div class="grid grid-cols-2 gap-3.5">
|
|
<label class="block rounded-[7px] p-4 cursor-pointer transition border target-card-new"
|
|
id="target-new-card"
|
|
style="border-color: color-mix(in oklch, var(--accent), transparent 50%); background: color-mix(in oklch, var(--accent), transparent 95%);">
|
|
<div class="flex items-start gap-3">
|
|
<input type="radio" name="target_mode" value="new_dir" class="mt-1" {{if not $page.FormInPlace}}checked{{end}} />
|
|
<div class="flex-1">
|
|
<div class="text-[14px] font-medium text-ink">New directory</div>
|
|
<div class="text-[12px] text-ink-mute mt-1 leading-[1.55]">
|
|
Files restore into a fresh path on the host. Original files untouched.
|
|
Restored as the agent user — original uid/gid is dropped (restic ≥ 0.17;
|
|
older versions preserve it).
|
|
</div>
|
|
<div class="mt-3 flex items-center gap-2.5">
|
|
<span class="text-ink-fade mono text-[12px]">→</span>
|
|
<input type="text" name="target_dir" id="target-dir-input"
|
|
class="field mono text-[12px] flex-1"
|
|
value="{{if $page.FormTargetDir}}{{$page.FormTargetDir}}{{else}}{{$page.DefaultTargetDir}}{{end}}"
|
|
placeholder="$HOME/rm-restore/<job-id>/" />
|
|
</div>
|
|
<div class="text-[11.5px] text-ink-fade mt-1.5">
|
|
<span class="mono">$HOME</span> resolves to the agent user's home;
|
|
<span class="mono"><job-id></span> is substituted on dispatch.
|
|
Edit if you want a specific directory.
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</label>
|
|
|
|
<label class="block rounded-[7px] p-4 cursor-pointer transition border target-card-inplace"
|
|
id="target-inplace-card"
|
|
style="border-color: color-mix(in oklch, var(--bad), transparent 70%); background: color-mix(in oklch, var(--bad), transparent 96%);">
|
|
<div class="flex items-start gap-3">
|
|
<input type="radio" name="target_mode" value="in_place" class="mt-1" {{if $page.FormInPlace}}checked{{end}} />
|
|
<div class="flex-1">
|
|
<div class="text-[14px] font-medium">
|
|
<span class="text-bad">In place</span>
|
|
<span class="text-ink-mute font-normal">— overwrite original paths</span>
|
|
</div>
|
|
<div class="text-[12px] text-ink-mute mt-1 leading-[1.55]">
|
|
Files replace whatever is at their original paths.
|
|
Original ownership and permissions are preserved.
|
|
<span class="text-bad">Destructive — cannot be undone.</span>
|
|
</div>
|
|
<div class="mt-3 px-3 py-3 rounded-[5px]"
|
|
style="background: color-mix(in oklch, var(--bad), transparent 92%); border: 1px solid color-mix(in oklch, var(--bad), transparent 60%);">
|
|
<div class="text-[11px] text-bad uppercase tracking-[0.08em] font-medium">Confirm host name</div>
|
|
<div class="text-[11.5px] text-ink-mute mt-1 leading-[1.55]">
|
|
Type <span class="mono text-ink">{{$host.Name}}</span> to enable this option.
|
|
</div>
|
|
<input type="text" name="confirm_hostname" class="field mono mt-2"
|
|
placeholder="{{$host.Name}}"
|
|
value="{{$page.FormConfirmHN}}" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
{{/* ============ STEP 4 — confirm ============ */}}
|
|
<section class="rounded-[8px] border border-line-soft bg-panel overflow-hidden {{if not $page.Selected}}opacity-40 pointer-events-none{{end}}">
|
|
<header class="flex items-center justify-between px-[18px] py-[14px]"
|
|
style="background: color-mix(in oklch, var(--panel), var(--panel-hi) 30%);">
|
|
<div class="flex items-center gap-3">
|
|
<span class="inline-flex items-center justify-center w-[22px] h-[22px] rounded-full mono text-[11px] font-medium"
|
|
style="background: color-mix(in oklch, var(--accent), transparent 84%); color: var(--accent); border: 1px solid color-mix(in oklch, var(--accent), transparent 50%);">4</span>
|
|
<div>
|
|
<div class="text-[14px] font-medium">Confirm & start</div>
|
|
<div class="text-[12px] text-ink-mute mt-0.5">Final review. Logs and progress will stream live.</div>
|
|
</div>
|
|
</div>
|
|
<span class="mono text-[11px] text-ink-fade">step 4 of 4</span>
|
|
</header>
|
|
<div class="px-[18px] pb-[18px]" id="confirm-summary">
|
|
<div class="text-[12px] text-ink-mute py-2">A summary will appear here once you've made your selections.</div>
|
|
</div>
|
|
</section>
|
|
|
|
{{/* sticky-style action bar */}}
|
|
<div class="rounded-[8px] border border-line-soft px-[18px] py-[14px] flex items-center justify-between"
|
|
style="background: color-mix(in oklch, var(--panel), var(--panel-hi) 30%);">
|
|
<div class="text-[12.5px] text-ink-mute">
|
|
Audit row <span class="mono text-ink-mid">host.restore</span> will be written on dispatch.
|
|
</div>
|
|
<div class="flex items-center gap-2.5">
|
|
<a href="/hosts/{{$host.ID}}" class="btn">Back</a>
|
|
<button type="submit" id="dispatch-btn" class="btn btn-primary btn-lg" {{if not $page.Online}}disabled title="agent is offline"{{end}}>
|
|
Start restore →
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
|
|
{{/* Lightweight JS to drive the live tally + summary card + tree toggle.
|
|
The tree-toggle is plain fetch (not HTMX) so its target lookup is
|
|
trivial — the .tree-children div is always the next sibling
|
|
inside the same .tree-pair wrapper. */}}
|
|
<script>
|
|
window.__rmTreeToggle = function(btn) {
|
|
var pair = btn.closest('.tree-pair');
|
|
if (!pair) return;
|
|
var kids = pair.querySelector(':scope > .tree-children');
|
|
if (!kids) return;
|
|
var loaded = btn.getAttribute('data-loaded') === 'true';
|
|
if (!loaded) {
|
|
var url = btn.getAttribute('data-tree-url');
|
|
btn.disabled = true;
|
|
fetch(url, { credentials: 'same-origin' })
|
|
.then(function(r) { return r.text(); })
|
|
.then(function(html) {
|
|
kids.innerHTML = html;
|
|
kids.classList.remove('hidden');
|
|
btn.textContent = '▾';
|
|
btn.setAttribute('data-loaded', 'true');
|
|
btn.disabled = false;
|
|
// Notify the wizard's recompute() that tally state may have changed.
|
|
document.body.dispatchEvent(new CustomEvent('tree:loaded'));
|
|
})
|
|
.catch(function(e) {
|
|
kids.innerHTML = '<div class="px-3 py-2 mono text-[12px] text-bad">load failed: ' + e + '</div>';
|
|
kids.classList.remove('hidden');
|
|
btn.textContent = '▾';
|
|
btn.disabled = false;
|
|
});
|
|
return;
|
|
}
|
|
kids.classList.toggle('hidden');
|
|
btn.textContent = kids.classList.contains('hidden') ? '▸' : '▾';
|
|
};
|
|
(function() {
|
|
const form = document.getElementById('restore-form');
|
|
if (!form) return;
|
|
const tallyCount = document.getElementById('tally-count');
|
|
const tallyPaths = document.getElementById('tally-paths');
|
|
const dispatchBtn = document.getElementById('dispatch-btn');
|
|
const summary = document.getElementById('confirm-summary');
|
|
const inplaceRadio = document.querySelector('input[name="target_mode"][value="in_place"]');
|
|
const newRadio = document.querySelector('input[name="target_mode"][value="new_dir"]');
|
|
const newCard = document.getElementById('target-new-card');
|
|
const inplaceCard = document.getElementById('target-inplace-card');
|
|
const confirmInput = document.querySelector('input[name="confirm_hostname"]');
|
|
const hostName = {{$host.Name | js}};
|
|
const defaultTarget = {{$page.DefaultTargetDir | js}};
|
|
const selectedSnapID = {{if $page.Selected}}{{$page.Selected.ShortID | js}}{{else}}""{{end}};
|
|
const selectedSnapTime = {{if $page.Selected}}{{$page.Selected.Time.Format "2006-01-02 15:04 MST" | js}}{{else}}""{{end}};
|
|
|
|
function getCheckedPaths() {
|
|
return Array.from(form.querySelectorAll('input[name="paths"]:checked')).map(i => i.value);
|
|
}
|
|
function recompute() {
|
|
const paths = getCheckedPaths();
|
|
const count = paths.length;
|
|
if (tallyCount) tallyCount.textContent = count + ' file' + (count === 1 ? '' : 's') + ' selected';
|
|
if (tallyPaths) {
|
|
tallyPaths.textContent = count === 0 ? 'tick a file or directory above'
|
|
: paths.slice(0, 4).join(' · ') + (count > 4 ? ' …' : '');
|
|
}
|
|
// Card emphasis on radio change
|
|
if (newCard && inplaceCard && inplaceRadio && newRadio) {
|
|
const isInPlace = inplaceRadio.checked;
|
|
newCard.style.borderColor = isInPlace ? 'var(--line-soft)' : 'color-mix(in oklch, var(--accent), transparent 50%)';
|
|
newCard.style.background = isInPlace ? 'var(--bg)' : 'color-mix(in oklch, var(--accent), transparent 95%)';
|
|
inplaceCard.style.borderColor = isInPlace ? 'color-mix(in oklch, var(--bad), transparent 35%)' : 'color-mix(in oklch, var(--bad), transparent 70%)';
|
|
inplaceCard.style.background = isInPlace ? 'color-mix(in oklch, var(--bad), transparent 90%)' : 'color-mix(in oklch, var(--bad), transparent 96%)';
|
|
}
|
|
// Dispatch button state
|
|
if (dispatchBtn) {
|
|
const inPlace = inplaceRadio && inplaceRadio.checked;
|
|
const okConfirm = !inPlace || (confirmInput && confirmInput.value.trim() === hostName);
|
|
const enabled = count > 0 && okConfirm;
|
|
dispatchBtn.disabled = !enabled || !{{if $page.Online}}true{{else}}false{{end}};
|
|
dispatchBtn.textContent = inPlace ? 'Start restore (overwrite) →' : 'Start restore →';
|
|
if (inPlace) dispatchBtn.classList.add('btn-danger'); else dispatchBtn.classList.remove('btn-danger');
|
|
}
|
|
// Summary card
|
|
if (summary) {
|
|
if (count === 0) {
|
|
summary.innerHTML = '<div class="text-[12px] text-ink-mute py-2">A summary will appear here once you\'ve made your selections.</div>';
|
|
} else {
|
|
const inPlace = inplaceRadio && inplaceRadio.checked;
|
|
const escTarget = defaultTarget
|
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
const targetLine = inPlace
|
|
? '<span class="text-bad">in place · originals will be overwritten</span>'
|
|
: '<span class="text-ink">New directory</span> <span class="text-ink-fade mx-2">·</span> <span class="mono text-ink-mid">' + escTarget + '</span>';
|
|
const ownLine = 'preserved (uid/gid/mode/mtime)';
|
|
const pathLines = paths.slice(0, 12).map(p => '<div>' + p + '</div>').join('');
|
|
const more = paths.length > 12 ? ('<div class="text-ink-fade">… and ' + (paths.length - 12) + ' more</div>') : '';
|
|
summary.innerHTML = `
|
|
<div class="rounded-[6px] border border-line-soft p-3.5 bg-bg">
|
|
<div class="grid gap-y-2.5" style="grid-template-columns: 140px 1fr; column-gap: 18px; font-size: 13px;">
|
|
<span class="text-[11px] text-ink-fade uppercase tracking-[0.08em] pt-0.5">Source</span>
|
|
<div>snapshot <span class="mono text-accent">${selectedSnapID}</span> · <span class="text-ink-mid">${selectedSnapTime}</span></div>
|
|
<span class="text-[11px] text-ink-fade uppercase tracking-[0.08em] pt-0.5">Paths</span>
|
|
<div>
|
|
<span class="text-ink">${count} file${count === 1 ? '' : 's'}</span>
|
|
<div class="mono text-[11.5px] text-ink-mute mt-1.5 leading-[1.7]">${pathLines}${more}</div>
|
|
</div>
|
|
<span class="text-[11px] text-ink-fade uppercase tracking-[0.08em] pt-0.5">Target</span>
|
|
<div>${targetLine}</div>
|
|
<span class="text-[11px] text-ink-fade uppercase tracking-[0.08em] pt-0.5">Ownership</span>
|
|
<div class="text-ink-mute">${ownLine}</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
}
|
|
}
|
|
// Recompute on any change in the form (path checks, radio swap, typed-confirm).
|
|
form.addEventListener('change', recompute);
|
|
form.addEventListener('input', recompute);
|
|
// Also after HTMX swaps in tree fragments (so initial state is right).
|
|
document.body.addEventListener('htmx:afterSwap', recompute);
|
|
recompute();
|
|
})();
|
|
</script>
|
|
|
|
{{end}}
|