P3 sweep fixes: snap-row CSS, tree expand, --no-ownership drop, target path

Bug fixes from the Playwright sweep against the live smoke server:

1. Snapshot-picker layout. The .snap-row class was used in the wireframe
   but never landed in web/styles/input.css; rows rendered as vertical
   blocks instead of a 6-column grid. Added the token (mirrors host-row
   shape with restore-specific column widths).

2. Tree expansion. hx-target='closest .tree-row + .tree-children' isn't
   a valid HTMX selector — modifiers don't chain. Replaced HTMX-driven
   expansion with a small window.__rmTreeToggle helper that uses plain
   fetch + .tree-pair wrapper structure for trivial sibling lookup.
   Caches loaded state per node.

3. --no-ownership flag dropped. Restic 0.17 introduced --no-ownership;
   0.16 rejects it ('unknown flag') before doing any work. Since the
   agent runs as root in the systemd unit, restored files keep their
   original uid/gid either way and the parent dir is root-owned, so
   the 'cp without sudo' rationale doesn't hold. Drop the flag entirely.

4. Default target dir moved to /var/lib/restic-manager/restore. The
   systemd unit pins ReadWritePaths to /etc/restic-manager +
   /var/lib/restic-manager (with ProtectSystem=strict making the rest
   of /var read-only); writes to /var/restic-restore failed with
   'read-only file system'.

5. Confirm summary HTML escaping. defaultTarget JS literal evaluates
   to a string with literal angle brackets; insertion into innerHTML
   must escape them. Added an inline HTML-escape pass.

tasks.md ticked for the Restore sub-phase with a sweep summary
covering the live end-to-end test.
This commit is contained in:
2026-05-04 15:57:42 +01:00
parent c417b5e9ab
commit 65a0134101
9 changed files with 133 additions and 56 deletions
+7 -5
View File
@@ -141,9 +141,11 @@ esac
} }
} }
// TestRunRestoreNewDirArgvHasNoOwnership: complement of the above — // TestRunRestoreNewDirArgvShape: non-in-place restore passes --target
// non-in-place restore must include --no-ownership. // to the operator-chosen new directory and includes the path filters.
func TestRunRestoreNewDirArgvHasNoOwnership(t *testing.T) { // We deliberately do NOT pass --no-ownership (added in restic 0.17;
// older versions error out — the comment in restore.go explains why).
func TestRunRestoreNewDirArgvShape(t *testing.T) {
t.Parallel() t.Parallel()
bin := setupScript(t, ` bin := setupScript(t, `
@@ -175,8 +177,8 @@ esac
if argv == "" { if argv == "" {
t.Fatal("no argv echo") t.Fatal("no argv echo")
} }
if !strings.Contains(argv, "--no-ownership") { if strings.Contains(argv, "--no-ownership") {
t.Errorf("new-dir restore should pass --no-ownership; got argv=%q", argv) t.Errorf("restic 0.16 doesn't accept --no-ownership; got argv=%q", argv)
} }
if !strings.Contains(argv, "--target /tmp/restore-out") { if !strings.Contains(argv, "--target /tmp/restore-out") {
t.Errorf("expected --target /tmp/restore-out; got argv=%q", argv) t.Errorf("expected --target /tmp/restore-out; got argv=%q", argv)
+9 -3
View File
@@ -65,9 +65,15 @@ func (e Env) RunRestore(ctx context.Context, snapshotID string, paths []string,
target = "/" target = "/"
} }
args = append(args, "--target", target) args = append(args, "--target", target)
if !inPlace { // NOTE: restic added --no-ownership in 0.17. Older versions reject
args = append(args, "--no-ownership") // the flag with "unknown flag: --no-ownership" before doing any
} // work. Since the agent runs as root in the systemd unit, files
// land under /var/restic-restore with their original uid/gid
// either way — the original "cp without sudo" rationale doesn't
// hold (operators copying from /var/restic-restore need sudo
// regardless because the parent dir is root-owned). Drop the flag
// entirely until we drop 0.16 support; revisit if a non-root
// agent deployment requirement comes back.
for _, p := range paths { for _, p := range paths {
args = append(args, "--include", p) args = append(args, "--include", p)
} }
+7 -4
View File
@@ -384,11 +384,14 @@ func (s *Server) handleUIRestoreTree(w stdhttp.ResponseWriter, r *stdhttp.Reques
} }
// defaultRestoreTargetRoot is the parent of the per-job restore // defaultRestoreTargetRoot is the parent of the per-job restore
// directory. Chosen on a per-host basis would be nicer but the agent // directory. The agent's systemd unit pins ReadWritePaths to
// is the one that actually creates it, and /var/restic-restore is // /etc/restic-manager + /var/lib/restic-manager (with ProtectSystem=
// fine for Linux hosts (the agent's systemd unit runs as root). // strict making the rest of /var read-only); restore writes have to
// land inside one of those, so we keep them under
// /var/lib/restic-manager/restore where the agent is already allowed
// to write. The /restore subdir is created by the agent on demand.
func defaultRestoreTargetRoot() string { func defaultRestoreTargetRoot() string {
return "/var/restic-restore" return "/var/lib/restic-manager/restore"
} }
// defaultRestoreTargetDir surfaces the placeholder path shown on the // defaultRestoreTargetDir surfaces the placeholder path shown on the
+2 -2
View File
@@ -302,8 +302,8 @@ func TestRestorePostHappyPathDispatches(t *testing.T) {
if cp.Restore.InPlace { if cp.Restore.InPlace {
t.Fatal("expected new-directory mode (in_place=false)") t.Fatal("expected new-directory mode (in_place=false)")
} }
if !strings.HasPrefix(cp.Restore.TargetDir, "/var/restic-restore/") { if !strings.HasPrefix(cp.Restore.TargetDir, "/var/lib/restic-manager/restore/") {
t.Fatalf("target_dir: got %q, want prefix /var/restic-restore/", cp.Restore.TargetDir) t.Fatalf("target_dir: got %q, want prefix /var/lib/restic-manager/restore/", cp.Restore.TargetDir)
} }
if len(cp.Restore.Paths) != 2 { if len(cp.Restore.Paths) != 2 {
t.Fatalf("paths: got %d, want 2", len(cp.Restore.Paths)) t.Fatalf("paths: got %d, want 2", len(cp.Restore.Paths))
+14 -8
View File
@@ -246,18 +246,24 @@ Sizes: **S** = under a day, **M** = 13 days, **L** = 37 days.
> doesn't have a confirmed need yet, so it's moved to the **Future / > doesn't have a confirmed need yet, so it's moved to the **Future /
> unscheduled** section at the end of this file. > unscheduled** section at the end of this file.
### Phase 3 — Restore (in progress, brand `p3-restore`) ### Phase 3 — Restore
> Spec: `docs/superpowers/specs/2026-05-04-p3-restore-design.md`. > Spec: `docs/superpowers/specs/2026-05-04-p3-restore-design.md`.
> Wireframe: `_diag/p3-restore-wizard/wireframe.html`. > Wireframe: `_diag/p3-restore-wizard/wireframe.html`.
> Sweep screenshots: `_diag/p3-restore-sweep/`.
> Shipped on branch `p3-restore`.
- [ ] **P3-X1** (S) Cancel-job feature. New `command.cancel` WS envelope; agent tracks per-job ctx.CancelFunc and kills the running `restic` subprocess (SIGTERM, SIGKILL after 5s grace); server endpoint `POST /api/jobs/{id}/cancel` bridges UI → WS; the existing UI Cancel button on `/jobs/{id}` becomes real for any running kind. Foundational — restore depends on it. - [x] **P3-X1** (S) Cancel-job feature. `command.cancel` WS envelope; agent tracks per-job ctx.CancelFunc and kills the running `restic` subprocess via context cancel (SIGTERM, SIGKILL after 5s grace via `cmd.Cancel` + `cmd.WaitDelay`); server endpoint `POST /api/jobs/{id}/cancel` bridges UI → WS; the existing UI Cancel button on `/jobs/{id}` is now real for any running kind. Sandbox-aware: `internal/restic/cancel_{unix,windows}.go` build-tags pick SIGTERM on POSIX vs `os.Kill` on Windows (which can't deliver SIGTERM). Tests: cancel mid-run via 'sleep 30' fake-restic returns JobCancelled with exit 130 in <200ms.
- [ ] **P3-X2** (S) Tree-list synchronous WS RPC. New `tree.list` request / `tree.list.result` reply on the existing correlation-ID infra; agent runs `restic ls --json <sid> <path>` per call; server-side mediator `ws.SendRPC` + per-wizard-session in-memory cache (~30-min TTL). - [x] **P3-X2** (S) Tree-list synchronous WS RPC. `MsgTreeList` `MsgTreeListResult` with `Envelope.ID` correlation; generic `Hub.SendRPC` helper (registry of buffered channels keyed by ULID, ctx-cancel + timeout aware). `internal/restic.ListTreeChildren` wraps `restic ls --json` and filters its recursive output to direct children. Server-side `treeCache` is per-wizard-session (keyed by session cookie + host + snapshot + path) with a 30-min TTL and lazy sweep.
- [ ] **P3-01** (L) Restore wizard backend: tree browse via `tree.list` RPC (P3-X2), path picker validation, target selection (new-dir vs in-place + typed-confirm), dispatch endpoint `POST /hosts/{id}/restore`, audit row `host.restore`. - [x] **P3-01** (L) Restore wizard backend (`internal/server/http/ui_restore.go`). GET handlers render the four-step wizard against the wireframe. HTMX/fetch tree partial endpoint hits `fetchTreeWithCache`. POST validates: snapshot_id, ≥1 absolute path, in-place ⇒ confirm_hostname == host name, agent online; on error re-renders with operator's input intact. Happy path mints job_id, target = `/var/lib/restic-manager/restore/<job-id>` (server-picked, agent's writable dir under the systemd sandbox's `ReadWritePaths`), creates job row, ships `command.run` with `RestorePayload`, writes `host.restore` audit row, returns HX-Redirect (or 303) to the live job page.
- [ ] **P3-02** (L) Restore wizard UI: single-page progressively-enabled four-step form at `/hosts/{id}/restore` (and pre-selected variant `/hosts/{id}/snapshots/{sid}/restore`); tree-browser HTMX partials. Top-level "Restore" button on host detail. - [x] **P3-02** (L) Wizard UI templates (`web/templates/pages/host_restore.html` + `partials/tree_node.html`). Single-page progressively-enabled four-step form. Form-state-driven JS computes a running tally + step-4 confirm summary client-side. Tree expansion uses plain fetch (not HTMX) for simpler target lookup; loaded-state cached per node. Top-level Restore button on host detail right rail + per-snapshot Restore action on snapshot rows. New `.snap-row` token in `web/styles/input.css`.
- [ ] **P3-03** (M) Restore execution: `restic.RunRestore` (paths, --target, --no-ownership for new-dir; preserves ownership for in-place); agent dispatcher case `JobRestore`; restore-specific job page variant with files-restored / bytes-restored / throughput / ETA / current-file widget. - [x] **P3-03** (M) Restore execution. `restic.RunRestore` builds `restore <sid> --target <dir> [--include p]...` with --json; new `pumpRestoreStdout` parses status + summary objects. `--no-ownership` is **not** passed — restic 0.17 added that flag, 0.16 errors out, and the agent's systemd unit runs as root anyway so the original "cp without sudo" rationale doesn't hold (parent dir is root-owned regardless). `runner.RunRestore` translates `RestoreStatus` into `job.progress` (mapping FilesRestored → FilesDone, etc.); agent dispatcher case `JobRestore` reuses the `spawn()` helper from P3-X1 so cancel works. Restore-shaped job-detail variant with current-file display under the progress bar.
- [ ] **P3-09** (S) `diff` between two snapshots in UI: `JobDiff` JobKind, `restic.RunDiff`, `POST /api/hosts/{id}/snapshots/diff` dispatcher, snapshot-picker UI on Snapshots tab to pick A+B; output streams as `log.stream` to the standard live job log page. - [x] **P3-09** (S) `diff` between two snapshots. `JobDiff` JobKind + `restic.RunDiff` + `runner.RunDiff`; `POST /api/hosts/{id}/snapshots/diff` (and HTMX-form variant on the unprefixed path) dispatcher with two-snapshot guard + per-host snapshot-list validation; UI panel on host detail right rail (visible when 2+ snapshots) with two short-id inputs + Diff button. Output streams as log.stream to the standard live job log page.
- [ ] **P3-X3** (S) Recent-restores panel on host detail: small line below the existing init-status, surfacing latest `JobRestore` outcome (succeeded N hours ago / failed → live log link). Backed by `store.LatestJobByKind(host_id, JobRestore)`. - [x] **P3-X3** (S) Recent-restores line on host detail. `hostChromeData` grows `RestoreStatus` / `RestoreAt` / `RestoreJobID` populated via `store.LatestJobByKind(host_id, 'restore')` (already exists from P2R). `host_chrome.html` renders a small line below the init-status one with status-coloured copy + a link to the job log. Hidden when no restore has ever run on this host.
> **Migration 0012** widens the `jobs.kind` CHECK constraint to include `restore` and `diff`. Rebuild required (SQLite can't ALTER CHECK in place); follows the safe pattern from 0005, with a defensive temp-table backup of `job_logs` so the cascade-trap that bit migration 0007 wouldn't take the log history with it.
> **As shipped (Playwright sweep against the live smoke env, 2026-05-04):** login → host detail → Restore button → wizard step 1 picks snapshot a1ac4006 (most recent) → tree drill-down `/home/steve/test` (3 lazy loads) → tick `file1` + `file2` → step 4 confirm summary populated → dispatch → live job page with running progress widget → restore succeeds, files land on disk at `/var/lib/restic-manager/restore/<job-id>/home/steve/test/file{1,2}`. Snapshot diff between `a1ac4006` and `5f78c788` → diff job page, statistics output streamed (738 bytes added, 0 removed). Recent-restores line on host detail reads "last restore · succeeded 28s ago · job log →".
### Phase 3 — Alerts (not started) ### Phase 3 — Alerts (not started)
File diff suppressed because one or more lines are too long
+19
View File
@@ -206,6 +206,25 @@
.src-row.clickable > .row-link { pointer-events: auto; } .src-row.clickable > .row-link { pointer-events: auto; }
.src-row.clickable > .row-action { pointer-events: auto; } .src-row.clickable > .row-action { pointer-events: auto; }
/* ---------- snapshot picker rows (Restore wizard step 1) ---------- */
.snap-row {
display: grid; align-items: center;
grid-template-columns: 150px 130px 1fr 90px 130px 80px;
column-gap: 16px;
padding: 11px 14px; font-size: 13px;
border-bottom: 1px solid var(--line-soft);
cursor: pointer;
transition: background 100ms ease;
}
.snap-row:last-child { border-bottom: 0; }
.snap-row:hover { background: var(--panel-hi); }
.snap-row.head {
font-size: 11px; color: var(--ink-fade);
text-transform: uppercase; letter-spacing: 0.08em;
padding-top: 9px; padding-bottom: 9px; cursor: default;
}
.snap-row.head:hover { background: transparent; }
/* ---------- schedule rows (Schedules tab) ---------- */ /* ---------- schedule rows (Schedules tab) ---------- */
.schd-row { .schd-row {
display: grid; align-items: center; display: grid; align-items: center;
+53 -12
View File
@@ -114,13 +114,22 @@
<div class="p-[18px]"> <div class="p-[18px]">
{{if $page.Selected}} {{if $page.Selected}}
<div class="rounded-[6px] border border-line-soft bg-bg overflow-hidden p-2"> <div class="rounded-[6px] border border-line-soft bg-bg overflow-hidden p-2">
{{/* The tree browser is server-rendered as a single root node; HTMX expand-on-click loads children. */}} {{/* Root tree node — fetched on first wizard render; child
<div hx-get="/hosts/{{$host.ID}}/restore/tree?snapshot={{$page.Selected.ID}}&path=/" expansions reuse the same tree.list cache server-side. */}}
hx-trigger="load" <div id="tree-root">
hx-swap="innerHTML">
<div class="text-ink-mute text-[12.5px] mono px-3 py-2">loading…</div> <div class="text-ink-mute text-[12.5px] mono px-3 py-2">loading…</div>
</div> </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]" <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%);"> 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-accent" id="tally-count">0 files selected</span>
@@ -158,7 +167,7 @@
<div class="text-[14px] font-medium text-ink">New directory</div> <div class="text-[14px] font-medium text-ink">New directory</div>
<div class="text-[12px] text-ink-mute mt-1 leading-[1.55]"> <div class="text-[12px] text-ink-mute mt-1 leading-[1.55]">
Files restore into a fresh path on the host. Original files untouched. Files restore into a fresh path on the host. Original files untouched.
Restored as the agent user (no ownership preservation) so you can <span class="mono">cp</span> them out without sudo. Original ownership (uid/gid/mode) is preserved.
</div> </div>
<div class="mt-3 px-3 py-[9px] rounded-[5px] mono text-[12px] text-ink flex items-center gap-2.5" <div class="mt-3 px-3 py-[9px] rounded-[5px] mono text-[12px] text-ink flex items-center gap-2.5"
style="background: var(--bg); border: 1px solid var(--line-soft);"> style="background: var(--bg); border: 1px solid var(--line-soft);">
@@ -237,10 +246,42 @@
</form> </form>
</div> </div>
{{/* Lightweight JS to drive the live tally + summary card. No HTMX {{/* Lightweight JS to drive the live tally + summary card + tree toggle.
here; the tree HTML is HTMX-loaded but the running tally is just The tree-toggle is plain fetch (not HTMX) so its target lookup is
reading the form state on click. */}} trivial — the .tree-children div is always the next sibling
inside the same .tree-pair wrapper. */}}
<script> <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() { (function() {
const form = document.getElementById('restore-form'); const form = document.getElementById('restore-form');
if (!form) return; if (!form) return;
@@ -292,12 +333,12 @@
summary.innerHTML = '<div class="text-[12px] text-ink-mute py-2">A summary will appear here once you\'ve made your selections.</div>'; summary.innerHTML = '<div class="text-[12px] text-ink-mute py-2">A summary will appear here once you\'ve made your selections.</div>';
} else { } else {
const inPlace = inplaceRadio && inplaceRadio.checked; const inPlace = inplaceRadio && inplaceRadio.checked;
const escTarget = defaultTarget
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const targetLine = inPlace const targetLine = inPlace
? '<span class="text-bad">in place · originals will be overwritten</span>' ? '<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">' + defaultTarget + '</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 = inPlace const ownLine = 'preserved (uid/gid/mode/mtime)';
? 'preserved (uid/gid/mode/mtime)'
: 'Restored as agent user · <span class="mono">--no-ownership</span>';
const pathLines = paths.slice(0, 12).map(p => '<div>' + p + '</div>').join(''); 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>') : ''; const more = paths.length > 12 ? ('<div class="text-ink-fade">… and ' + (paths.length - 12) + ' more</div>') : '';
summary.innerHTML = ` summary.innerHTML = `
+21 -21
View File
@@ -3,7 +3,6 @@
{{if $page.Error}} {{if $page.Error}}
<div class="px-3 py-2 mono text-[12px] text-bad">error: {{$page.Error}}</div> <div class="px-3 py-2 mono text-[12px] text-bad">error: {{$page.Error}}</div>
{{else}} {{else}}
{{/* parent path heading + collapse marker */}}
<div class="flex items-center gap-2 px-3 py-1.5 text-[12px] text-ink-mute border-b border-line-soft"> <div class="flex items-center gap-2 px-3 py-1.5 text-[12px] text-ink-mute border-b border-line-soft">
<span class="mono text-ink-mid">{{$page.Path}}</span> <span class="mono text-ink-mid">{{$page.Path}}</span>
{{if not $page.Children}} {{if not $page.Children}}
@@ -11,29 +10,30 @@
{{end}} {{end}}
</div> </div>
{{range $page.Children}} {{range $page.Children}}
<div class="grid items-center gap-2 px-3 py-[5px] mono text-[12.5px] border-b border-line-soft last:border-b-0" <div class="tree-pair">
style="grid-template-columns: 14px 16px auto 1fr auto;"> <div class="grid items-center gap-2 px-3 py-[5px] mono text-[12.5px] border-b border-line-soft"
style="grid-template-columns: 14px 16px auto 1fr auto;">
{{if .IsDir}}
<button type="button"
class="tree-toggle text-ink-mute text-[10px] cursor-pointer"
data-tree-url="/hosts/{{$page.HostID}}/restore/tree?snapshot={{$page.SnapshotID}}&path={{.Path}}"
data-loaded="false"
onclick="window.__rmTreeToggle(this)">▸</button>
{{else}}
<span class="text-ink-fade text-center">·</span>
{{end}}
<label class="cursor-pointer flex items-center justify-center">
<input type="checkbox" name="paths" value="{{.Path}}"
class="w-[13px] h-[13px] cursor-pointer" />
</label>
<span class="{{if .IsDir}}text-ink{{else}}text-ink-mid{{end}}">{{.Name}}{{if .IsDir}}/{{end}}</span>
<span></span>
<span class="text-[11px] text-ink-fade">{{if not .IsDir}}{{if .Size}}{{bytes .Size}}{{else}}—{{end}}{{end}}</span>
</div>
{{if .IsDir}} {{if .IsDir}}
<button type="button" <div class="tree-children hidden pl-5 border-l border-line-soft ml-5"></div>
class="text-ink-mute text-[10px] cursor-pointer"
hx-get="/hosts/{{$page.HostID}}/restore/tree?snapshot={{$page.SnapshotID}}&path={{.Path}}"
hx-target="next .tree-children"
hx-swap="innerHTML"
onclick="this.parentElement.querySelector('.tree-children').classList.toggle('hidden'); this.textContent = this.parentElement.querySelector('.tree-children').classList.contains('hidden') ? '▸' : '▾';">▸</button>
{{else}}
<span class="text-ink-fade text-center">·</span>
{{end}} {{end}}
<label class="cursor-pointer flex items-center justify-center">
<input type="checkbox" name="paths" value="{{.Path}}"
class="w-[13px] h-[13px] cursor-pointer" />
</label>
<span class="{{if .IsDir}}text-ink{{else}}text-ink-mid{{end}}">{{.Name}}{{if .IsDir}}/{{end}}</span>
<span></span>
<span class="text-[11px] text-ink-fade">{{if not .IsDir}}{{if .Size}}{{bytes .Size}}{{else}}—{{end}}{{end}}</span>
</div> </div>
{{if .IsDir}}
<div class="tree-children hidden pl-5 border-l border-line-soft ml-5"></div>
{{end}}
{{end}} {{end}}
{{end}} {{end}}
{{end}} {{end}}