feat(phase 3b filtering): full tree-page filter bar (search + chips + counts + HTMX swap)

Tree page (/) gains every navigation dimension m asked for:

- Debounced search input matching title/slug/aliases/content_md/paths
  case-insensitively (?q=…)
- Tag chip row (?tag=a,b — AND within tags, as before)
- Management chip row with ?mgmt=mai,self,external,unmanaged
  (OR within management; "unmanaged" is the synthetic empty-array case)
- Status chip row with ?status=active,done,archived (default = active;
  archived rows only surface when the separate show-archived toggle is on)
- Has-link chip row ?has=caldav-list,gitea-repo
- Each chip carries the count it would yield if toggled — honest user
  cue, computed via per-dimension recomputation in pure Go (cheap at
  m's scale)
- URL is the source of truth — every filter goes through the query
  string, so any view is bookmarkable; HTMX swaps the tree-section in
  place with hx-push-url=true on every chip click and on search keyup
- Empty-state copy with a clear-all link

Implementation:

- web/tree_filter.go new: TreeFilter struct + ParseTreeFilter +
  QueryString/URL + Toggle* helpers + Matches + applyTreeFilter
  (replacement for buildForest) + computeChipCounts.
- web/tree_filter_test.go: parse defaults + every dimension's match +
  URL round-trip + ancestor-keep semantics + chip counting.
- Linkages: linkKindsByItem on Server fans across the two has-link
  ref_types in one pass and feeds the filter.
- tree.tmpl reduced to a one-liner that calls tree-section; new
  tree_section partial powers both the initial page render and HTMX
  fragment swaps (matches the pattern from phases 2.a/b/d).

docs/design.md §4: tree-filter contract — URL keys, AND/OR rules,
count semantics, archived ergonomics.
This commit is contained in:
mAi
2026-05-15 18:21:26 +02:00
parent bb9a89fbad
commit d5e7796cf6
7 changed files with 799 additions and 144 deletions

View File

@@ -106,3 +106,24 @@ table.classify input, table.classify select { width: 100%; }
.issues li.issue-row .milestone { font-size: 0.72em; padding: 1px 6px; border-radius: 4px; background: #fff; border: 1px solid var(--border); color: var(--warn); }
.issues li.issue-row .assignee { font-size: 0.78em; color: var(--muted); }
.issues ul.closed .title { color: var(--muted); }
/* Tree-page filter bar (phase 3b). */
.tree-section { display: block; }
#tree-filterbar { padding: 12px 0; border-bottom: 1px dotted var(--border); margin-bottom: 12px; }
#tree-filterbar .search { margin: 0 0 8px; display: flex; }
#tree-filterbar .search input[type="search"] {
width: 100%; font: inherit; padding: 6px 10px;
border: 1px solid var(--border); background: #fff; border-radius: 4px;
}
#tree-filterbar .chip-row { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin: 4px 0; }
#tree-filterbar .chip-row .muted { width: 4em; flex-shrink: 0; }
.mgmt-chip, .status-chip, .has-chip {
display: inline-block; font-size: 0.78em; padding: 1px 8px; border-radius: 999px;
background: #fff; border: 1px solid var(--border); color: var(--muted); text-decoration: none;
}
.mgmt-chip:hover, .status-chip:hover, .has-chip:hover { color: var(--fg); border-color: var(--accent); }
.chip-on { background: var(--accent); color: #fff; border-color: var(--accent); }
.chip-on:hover { color: #fff; filter: brightness(0.92); }
#tree-filterbar small { opacity: 0.75; margin-left: 2px; }
.tree-section .empty { padding: 24px; color: var(--muted); }
.tree-section .clear { color: var(--bad); }