🐛 Fix nested libraries vanishing from the libraries modal (#12031)

* 🐛 Fix nested libraries vanishing from the libraries modal

The "This file" tab nests each linked library under a linked library
that uses it, taken from the team shared files. A library with such a
parent was removed from the top level and only drawn under its parent
if the parent was at the top level itself. So:

- In a chain of two or more levels (file -> A -> B -> C), C was lost.
- In a cycle (A uses B and B uses A), both had a parent, none was at
  the top level, and both were lost.

The libraries showed while the shared files were loading, and vanished
when they arrived.

Build the list as a tree instead: direct libraries and libraries not
used by any other linked library are roots, nested libraries follow
their parent at any depth, and each library is added only once, even
when the dependencies form a cycle.

AI-assisted-by: claude-opus-5-5

* ♻️ Move the linked libraries tree to sort-linked-libraries

Extract the code that orders the linked libraries as a tree from the
file tab to a public function, so it can be tested on its own. Add
tests for a cycle with a direct root, a cycle with no root, a deep
chain and no dependencies yet.

AI-assisted-by: claude-opus-5-5

* ♻️ Use connected-to to find library parents when sorting

The caller already computes :connected-to for each linked library, the
reverse index of the dependencies map. sort-linked-libraries rebuilt
the same relation from dependencies. Read it from :connected-to
instead, so the function takes only the libraries and the component
memo no longer depends on dependencies.

Reword the cycle-without-root test to say it models the unknown direct
flag, and add tests for a diamond, a self-dependency, a cycle of three
without root, a parent that is not a linked library, and for no
library being lost or duplicated.

AI-assisted-by: claude-sonnet-5-5

---------

Co-authored-by: Andrés Moya <andres.moya@kaleidos.net>
This commit is contained in:
Eva Marco 2026-10-08 09:54:46 +02:00 committed by GitHub
parent b3a62eeb4b
commit 41efc3558a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 159 additions and 35 deletions

View File

@ -107,6 +107,58 @@
:token-sets token-sets
:token-themes token-themes}))
(defn sort-linked-libraries
"Given the linked libraries of a file, each with a `:connected-to`
collection of the ids of the files that use it, return the libraries
ordered as a tree: each library is followed by the libraries nested
under it, marked with `:nested?` and `:parent-name`.
Direct libraries (`:is-indirect` false) and libraries not used by
any other linked library are roots. Every library is returned
exactly once, at any depth and even when the dependencies form a
cycle."
[libraries]
(let [by-id (d/index-by :id libraries)
sort-by-name (partial sort-by (comp str/lower :name))
parents
(d/update-vals by-id
(fn [{:keys [id connected-to]}]
(into #{}
(filter #(and (not= % id)
(contains? by-id %)))
connected-to)))
root?
(fn [{:keys [id is-indirect]}]
(or (false? is-indirect)
(empty? (get parents id))))
children
(fn [parent-id]
(->> libraries
(remove root?)
(filter #(contains? (get parents (:id %)) parent-id))
(sort-by-name)))
visit
(fn visit [[result visited :as acc] library parent]
(if (contains? visited (:id library))
acc
(reduce #(visit %1 %2 library)
[(conj result (assoc library
:nested? (some? parent)
:parent-name (:name parent)))
(conj visited (:id library))]
(children (:id library)))))]
;; The second pass picks up libraries only reachable through a
;; cycle, which have no root to hang from.
(as-> [[] #{}] $
(reduce #(visit %1 %2 nil) $ (sort-by-name (filter root? libraries)))
(reduce #(visit %1 %2 nil) $ (sort-by-name libraries))
(first $))))
(defn- describe-library
[components-count graphics-count colors-count typography-count]
(let [all-zero? (and (zero? components-count)
@ -666,11 +718,6 @@
dependencies (mf/with-memo [shared-libraries]
(into {} (map (juxt :id :library-file-ids) (vals shared-libraries))))
library-names (mf/with-memo [shared-libraries]
(into {} (map (fn [{:keys [id name]}]
[id name])
(vals shared-libraries))))
find-connected-to
(mf/use-fn
(mf/deps dependencies)
@ -679,36 +726,10 @@
(keep (fn [[k v]] (when (contains? v library-id) k))))))
linked-libraries
(mf/with-memo [linked-libraries find-connected-to library-names]
(let [libs
(->> (vals linked-libraries)
(map #(assoc % :connected-to (find-connected-to (:id %)))))
linked-ids
(into #{} (map :id) libs)
sort-by-name
(partial sort-by (comp str/lower :name))
parent-id
(fn [{:keys [connected-to]}]
(first (filter linked-ids connected-to)))
nested-by-parent
(->> libs
(filter parent-id)
(map #(assoc %
:nested? true
:parent-name (library-names (parent-id %))))
(group-by parent-id)
(d/mapm (fn [_ v] (sort-by-name v))))]
(->> libs
(remove parent-id)
(map #(assoc % :nested? false))
(sort-by-name)
(mapcat (fn [{:keys [id] :as library}]
(cons library (get nested-by-parent id [])))))))
(mf/with-memo [linked-libraries find-connected-to]
(->> (vals linked-libraries)
(map #(assoc % :connected-to (find-connected-to (:id %))))
(sort-linked-libraries)))
linked-libraries-ids
(mf/with-memo [linked-libraries]

View File

@ -120,6 +120,7 @@
[frontend-tests.ui.shortcuts-labels-test]
[frontend-tests.ui.sidebar-scroll-test]
[frontend-tests.ui.stroke-menu-test]
[frontend-tests.ui.workspace-libraries-test]
[frontend-tests.util-clipboard-test]
[frontend-tests.util-object-test]
[frontend-tests.util-queue-test]
@ -260,6 +261,7 @@
'frontend-tests.ui.shortcuts-labels-test
'frontend-tests.ui.sidebar-scroll-test
'frontend-tests.ui.stroke-menu-test
'frontend-tests.ui.workspace-libraries-test
'frontend-tests.util-clipboard-test
'frontend-tests.util-object-test
'frontend-tests.util-queue-test

View File

@ -0,0 +1,101 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns frontend-tests.ui.workspace-libraries-test
(:require
[app.main.ui.workspace.libraries :as wl]
[cljs.test :as t :include-macros true]))
(defn- lib
"A linked library; `connected-to` lists the ids of the files using it."
[id indirect? connected-to]
{:id id :name (name id) :is-indirect indirect? :connected-to connected-to})
(defn- summarize
[libraries]
(mapv (juxt :id :nested? :parent-name) libraries))
(t/deftest sort-linked-libraries-cycle-with-direct-root
;; file -> App, Foundations; App -> Foundations;
;; Foundations <-> Comments (cycle, as left by an import)
(let [libraries [(lib :foundations false [:file :app :comments])
(lib :app false [:file])
(lib :comments true [:foundations])]
result (wl/sort-linked-libraries libraries)]
(t/is (= [[:app false nil]
[:foundations false nil]
[:comments true "foundations"]]
(summarize result)))
(t/is (= (count libraries) (count result)))))
(t/deftest sort-linked-libraries-cycle-without-root
;; The direct flag is still unknown (nil) while the shared files load,
;; so both libraries are only reachable through the cycle. `:app` is
;; not a linked library, so it is ignored as a parent.
(let [libraries [(lib :foundations nil [:app :comments])
(lib :comments nil [:foundations])]
result (wl/sort-linked-libraries libraries)]
(t/is (= [[:comments false nil]
[:foundations true "comments"]]
(summarize result)))
(t/is (= (count libraries) (count result)))))
(t/deftest sort-linked-libraries-cycle-of-three-without-root
;; a -> b -> c -> a, all indirect: no root, nothing is lost
(let [libraries [(lib :c true [:b])
(lib :b true [:a])
(lib :a true [:c])]
result (wl/sort-linked-libraries libraries)]
(t/is (= [[:a false nil]
[:b true "a"]
[:c true "b"]]
(summarize result)))
(t/is (= (count libraries) (count result)))))
(t/deftest sort-linked-libraries-deep-chain
;; file -> a -> b -> c
(let [libraries [(lib :c true [:b])
(lib :b true [:a])
(lib :a false [:file])]]
(t/is (= [[:a false nil]
[:b true "a"]
[:c true "b"]]
(summarize (wl/sort-linked-libraries libraries))))))
(t/deftest sort-linked-libraries-diamond
;; a -> b, c; b -> d; c -> d: d is listed once, under its first parent
(let [libraries [(lib :d true [:b :c])
(lib :c true [:a])
(lib :b true [:a])
(lib :a false [:file])]
result (wl/sort-linked-libraries libraries)]
(t/is (= [[:a false nil]
[:b true "a"]
[:d true "b"]
[:c true "a"]]
(summarize result)))
(t/is (= (count libraries) (count result)))))
(t/deftest sort-linked-libraries-self-dependency
(let [libraries [(lib :a true [:a])]]
(t/is (= [[:a false nil]]
(summarize (wl/sort-linked-libraries libraries))))))
(t/deftest sort-linked-libraries-parent-not-linked
;; The only user of each library is not a linked library, so both are roots
(let [libraries [(lib :b true [:other])
(lib :a true [:file])]]
(t/is (= [[:a false nil]
[:b false nil]]
(summarize (wl/sort-linked-libraries libraries))))))
(t/deftest sort-linked-libraries-without-dependencies
;; Before get-team-shared-files answers, all libraries are flat
(let [libraries [(lib :b true nil)
(lib :a false nil)]]
(t/is (= [[:a false nil]
[:b false nil]]
(summarize (wl/sort-linked-libraries libraries))))))