feat add inline directory expand/collapse in files view

Directories in the files view now use <details>/<summary> to expand and collapse inline without JavaScript or page reloads. The Tree.preload resolver recursively loads subdirectory contents for directories with fewer than 10 entries. Directories with 10+ entries show a teaser message ('Directory contains more than 10 items...') that links to the full subdirectory page. A right-aligned chevron (›) rotates on open. Nested entries are indented with a left border for visual hierarchy. Directory names remain clickable links to their dedicated tree page for deep-linking.

Commit
b0b9c128b6b19f9aeb1ee358a406a2d70b18f66a
Author
Claude Sonnet 4 <ai@anthropic.com>
Author date
Committer
Claude Sonnet 4 <ai@anthropic.com>
Committer date
Changed files
lib/handlers.ml
index e11855e2..38bc7dbd 100644..100644
@@ -141,13 +141,16 @@
141 141
142 142 let files_at_head repository context =
143 143 let* tree = Resolvers.Tree.head repository in
144 Removed: Views.Repo.files context [] tree
144 Added: let* preloaded = Resolvers.Tree.preload repository tree in
145 Added: Views.Repo.files context [] preloaded
145 146
146 147 let file_id repository context id =
147 148 let* trail = Resolvers.Tree.find_path repository id in
148 149 let* object_ = Resolvers.blob_or_tree repository id in
149 150 match object_ with
150 Removed: | `Tree tree -> Views.Repo.files context trail tree
151 Added: | `Tree tree ->
152 Added: let* preloaded = Resolvers.Tree.preload repository tree in
153 Added: Views.Repo.files context trail preloaded
151 154 | `Blob blob -> Views.Repo.file context trail blob
152 155
153 156 let branches repository context =
lib/resolvers.ml
index 64a01d40..4375d711 100644..100644
@@ -331,6 +331,11 @@
331 331 module Tree = struct
332 332 type t = { entries : Entry.t list }
333 333
334 Added: type preloaded_entry = {
335 Added: entry : Entry.t;
336 Added: children : preloaded_entry list option;
337 Added: }
338 Added:
334 339 let to_t tree =
335 340 { entries = Store.Value.Tree.to_list tree |> List.map Entry.to_t }
336 341
@@ -351,6 +356,33 @@
351 356 let head repository =
352 357 let* hash = head_tree_hash repository in
353 358 of_hash repository hash
359 Added:
360 Added: let preload repository tree =
361 Added: let rec preload_entry (entry : Entry.t) =
362 Added: if entry.perm <> Entry.Dir then
363 Added: Lwt_result.return { entry; children = None }
364 Added: else
365 Added: let* hash = hash_of_hex entry.hash in
366 Added: let* subtree =
367 Added: Lwt_result.bind (read_value repository hash) @@ function
368 Added: | Git.Value.Tree t -> Lwt_result.return (to_t t)
369 Added: | _ -> Lwt_result.return { entries = [] }
370 Added: in
371 Added: let count = List.length subtree.entries in
372 Added: if count >= 10 then Lwt_result.return { entry; children = Some [] }
373 Added: else
374 Added: let* children = preload_entries subtree.entries in
375 Added: Lwt_result.return { entry; children = Some children }
376 Added: and preload_entries entries =
377 Added: let rec go acc = function
378 Added: | [] -> Lwt_result.return (List.rev acc)
379 Added: | e :: rest ->
380 Added: let* pe = preload_entry e in
381 Added: go (pe :: acc) rest
382 Added: in
383 Added: go [] entries
384 Added: in
385 Added: preload_entries tree.entries
354 386
355 387 let find_path repository target_hash =
356 388 let* target = hash_of_hex target_hash in
lib/resolvers.mli
index 557b49f2..601b7daf 100644..100644
@@ -80,7 +80,13 @@
80 80 module Tree : sig
81 81 type t = { entries : Entry.t list }
82 82
83 Added: type preloaded_entry = {
84 Added: entry : Entry.t;
85 Added: children : preloaded_entry list option;
86 Added: }
87 Added:
83 88 val head : repository -> (t, error) Lwt_result.t
89 Added: val preload : repository -> t -> (preloaded_entry list, error) Lwt_result.t
84 90
85 91 val find_path :
86 92 repository -> string -> ((string * string) list, error) Lwt_result.t
lib/static/styles.css
index 2c6d2c15..8da122c5 100644..100644
@@ -559,6 +559,83 @@
559 559 color: white;
560 560 }
561 561
562 Added: /* Tree directory expand/collapse */
563 Added:
564 Added: .tree-dir {
565 Added: padding: 0;
566 Added: }
567 Added:
568 Added: .tree-dir > details {
569 Added: width: 100%;
570 Added: }
571 Added:
572 Added: .tree-toggle {
573 Added: display: flex;
574 Added: align-items: center;
575 Added: justify-content: space-between;
576 Added: cursor: pointer;
577 Added: list-style: none;
578 Added: padding: 0.75em;
579 Added: min-height: 44px;
580 Added: box-sizing: border-box;
581 Added: }
582 Added:
583 Added: .tree-toggle::-webkit-details-marker {
584 Added: display: none;
585 Added: }
586 Added:
587 Added: .tree-toggle::marker {
588 Added: content: "";
589 Added: }
590 Added:
591 Added: .tree-toggle::after {
592 Added: content: "\203a";
593 Added: font-size: 1.3em;
594 Added: color: #b0b0b0;
595 Added: transition: transform 0.15s ease;
596 Added: flex-shrink: 0;
597 Added: min-width: 44px;
598 Added: text-align: center;
599 Added: }
600 Added:
601 Added: details[open] > .tree-toggle::after {
602 Added: transform: rotate(90deg);
603 Added: }
604 Added:
605 Added: .tree-toggle a {
606 Added: color: white;
607 Added: text-decoration: none;
608 Added: }
609 Added:
610 Added: .tree-toggle a:hover {
611 Added: text-decoration: underline;
612 Added: }
613 Added:
614 Added: .tree-nested {
615 Added: padding-left: 1.5em;
616 Added: list-style: none;
617 Added: margin: 0;
618 Added: }
619 Added:
620 Added: .tree-nested li {
621 Added: border-left: 1px solid #3a3a3a;
622 Added: }
623 Added:
624 Added: .tree-overflow {
625 Added: padding: 0.5em 0.75em;
626 Added: }
627 Added:
628 Added: .tree-overflow a {
629 Added: color: #b0b0b0;
630 Added: text-decoration: none;
631 Added: font-style: italic;
632 Added: }
633 Added:
634 Added: .tree-overflow a:hover {
635 Added: color: white;
636 Added: text-decoration: underline;
637 Added: }
638 Added:
562 639 .diff-file {
563 640 margin: 1.5em 0;
564 641 border: 1px solid #3a3a3a;
lib/views/repo.ml
index 7af65471..f92b695c 100644..100644
@@ -152,6 +152,54 @@
152 152 in
153 153 HTML.(li [] [ Routes.link_to route text ])
154 154
155 Added: let rec li_of_preloaded repo (pe : Resolvers.Tree.preloaded_entry) =
156 Added: let entry = pe.entry in
157 Added: let route = Routes.File (repo, entry.hash) in
158 Added: match pe.children with
159 Added: | None ->
160 Added: (* Regular file *)
161 Added: HTML.(li [] [ Routes.link_to route (txt "%s" entry.name) ])
162 Added: | Some [] ->
163 Added: (* Directory with 10+ items — show teaser *)
164 Added: HTML.(
165 Added: li
166 Added: [ class_ "tree-dir" ]
167 Added: [
168 Added: details []
169 Added: [
170 Added: summary
171 Added: [ class_ "tree-toggle" ]
172 Added: [ Routes.link_to route (txt "%s/" entry.name) ];
173 Added: ul
174 Added: [ class_ "tree-nested" ]
175 Added: [
176 Added: li
177 Added: [ class_ "tree-overflow" ]
178 Added: [
179 Added: Routes.link_to route
180 Added: (txt
181 Added: "Directory contains more than 10 items\xe2\x80\xa6");
182 Added: ];
183 Added: ];
184 Added: ];
185 Added: ])
186 Added: | Some children ->
187 Added: (* Directory with <10 items — fully preloaded *)
188 Added: HTML.(
189 Added: li
190 Added: [ class_ "tree-dir" ]
191 Added: [
192 Added: details []
193 Added: [
194 Added: summary
195 Added: [ class_ "tree-toggle" ]
196 Added: [ Routes.link_to route (txt "%s/" entry.name) ];
197 Added: ul
198 Added: [ class_ "tree-nested" ]
199 Added: (List.map (li_of_preloaded repo) children);
200 Added: ];
201 Added: ])
202 Added:
155 203 let summary context branches commits =
156 204 render_page context ~active:Summary
157 205 HTML.
@@ -268,11 +316,11 @@
268 316 [ class_ "breadcrumbs"; Aria.label "File path" ]
269 317 [ ul [] (root_link :: crumbs) ])
270 318
271 Removed: let files context trail (tree : Resolvers.Tree.t) =
319 Added: let files context trail (entries : Resolvers.Tree.preloaded_entry list) =
272 320 let bc = breadcrumbs context.repo trail in
273 321 let tb = toolbar [ bc ] in
274 322 render_page context ~active:Files
275 Removed: HTML.[ tb; ul [] (List.map (li_of_entry context.repo) tree.entries) ]
323 Added: HTML.[ tb; ul [] (List.map (li_of_preloaded context.repo) entries) ]
276 324
277 325 let file ?(active = Layout.Files) context trail (blob : Resolvers.Blob.t) =
278 326 let to_numbered_line number line =