EXTENSION for TreeOS
navigation
Owns the user's tree navigation state. Every user has two lists stored in metadata.nav: roots (the complete list of trees they own or contribute to) and recentRoots (the last 5 trees they visited, ordered by recency with timestamps). These lists drive the tree picker in the client and the CLI's navigation commands. The roots list is maintained automatically through three hooks. afterNodeCreate adds the new tree root to the creating user's list when they create a tree. afterOwnershipChange handles all contributor and ownership mutations: addContributor adds the root, removeContributor checks whether the user still has any access (owner or contributor on any node) before removing, setOwner and transferOwnership both add the root for the new owner. The logic is careful about edge cases. A user who contributes to multiple trees and is removed from one keeps their root if they still have access elsewhere. The recentRoots list is maintained by the afterNavigate hook, which fires every time a user navigates to a tree root. It pushes the visited tree to the front of the list, deduplicates, and trims to the 5 most recent entries. Each entry stores the rootId, rootName, and lastVisitedAt timestamp. After updating, the hook pushes the new recent roots list to the user's WebSocket connection via the recentRoots event, so the client updates in real time. A getRecentRoots socket handler lets clients request the list on connect. At boot, a one-time migration copies the old User.roots schema field to metadata.nav.roots for lands upgrading from the pre-metadata schema. The extension exports addRoot, removeRoot, getUserRoots, getUserRootsWithNames, and getRecentRootsWithNames for other extensions to consume.
v1.0.3 by TreeOS Site 0 downloads 3 files 302 lines 10.4 KB published 38d ago
treeos ext install navigation
View changelog

Manifest

Requires

  • services: websocket
  • models: User, Node
SHA256: b9718db6e2a300afc4510580011f232d4c9ee4a499952020700ce8792a9c970b

Dependents

1 package depend on this

PackageTypeRelationship
treeos v1.0.1osstandalone

Source Code

1import { getUserMeta, addToUserMetaSet, batchSetUserMeta } from "../../seed/tree/userMetadata.js";
2import log from "../../seed/log.js";
3
4let User, Node;
5
6export function setModels(models) {
7  User = models.User;
8  Node = models.Node;
9}
10
11const MAX_RECENT = 5;
12
13// ── Navigation roots (metadata.nav.roots) ──
14
15/**
16 * Get all root IDs for a user from metadata.nav.roots.
17 * Returns array of root ID strings.
18 */
19export async function getUserRoots(userId) {
20  if (!userId) return [];
21  const user = await User.findById(userId).select("metadata").lean();
22  if (!user) return [];
23  const nav = getUserMeta(user, "nav");
24  return Array.isArray(nav.roots) ? nav.roots : [];
25}
26
27/**
28 * Get roots with resolved names for API/rendering.
29 * Returns array of { _id, name, visibility }.
30 */
31export async function getUserRootsWithNames(userId) {
32  const rootIds = await getUserRoots(userId);
33  if (rootIds.length === 0) return [];
34
35  const nodes = await Node.find({ _id: { $in: rootIds }, parent: { $ne: "deleted" } })
36    .select("_id name visibility")
37    .lean();
38
39  const nodeMap = new Map(nodes.map(n => [n._id.toString(), n]));
40
41  // Preserve order, filter out deleted/missing nodes
42  return rootIds
43    .map(id => nodeMap.get(id))
44    .filter(Boolean)
45    .map(n => ({ _id: n._id.toString(), name: n.name, visibility: n.visibility }));
46}
47
48/**
49 * Add a root to a user's navigation list.
50 */
51export async function addRoot(userId, rootId) {
52  if (!userId || !rootId) return;
53  try {
54    await addToUserMetaSet(userId, "nav", "roots", String(rootId));
55    log.info("Navigation", `addRoot: saved root ${rootId} for user ${userId}`);
56  } catch (err) {
57    log.warn("Navigation", `addRoot failed: ${err.message}`);
58  }
59}
60
61/**
62 * Remove a root from a user's navigation list.
63 */
64export async function removeRoot(userId, rootId) {
65  if (!userId || !rootId) return;
66  await User.updateOne(
67    { _id: String(userId) },
68    { $pull: { "metadata.nav.roots": String(rootId) } },
69  );
70}
71
72// ── Recent roots (metadata.nav.recentRoots) ──
73
74/**
75 * Update recent roots in metadata.nav.recentRoots.
76 * Pushes to front, deduplicates, keeps MAX_RECENT entries.
77 */
78export async function updateRecentRoots(userId, rootId) {
79  if (!userId || !rootId) return;
80
81  const node = await Node.findById(rootId).select("name").lean();
82  if (!node) return;
83
84  // Read current recents with lean (read-only, no save conflict)
85  const user = await User.findById(userId).select("metadata").lean();
86  if (!user) return;
87
88  const nav = (user.metadata instanceof Map ? user.metadata.get("nav") : user.metadata?.nav) || {};
89  let recents = Array.isArray(nav.recentRoots) ? [...nav.recentRoots] : [];
90
91  recents = recents.filter((r) => r.rootId !== rootId);
92  recents.unshift({ rootId, rootName: node.name, lastVisitedAt: new Date() });
93  if (recents.length > MAX_RECENT) recents = recents.slice(0, MAX_RECENT);
94
95  // Atomic write to just the recentRoots field
96  await batchSetUserMeta(userId, "nav", { recentRoots: recents });
97}
98
99/**
100 * Get recent roots for a user. Returns array of { rootId, rootName, lastVisitedAt }.
101 */
102export async function getRecentRootsByUserId(userId) {
103  if (!userId) return [];
104  const user = await User.findById(userId).select("metadata").lean();
105  if (!user) return [];
106  const nav = getUserMeta(user, "nav");
107  return nav.recentRoots || [];
108}
109
110/**
111 * Get recent roots with resolved names (names may have changed since stored).
112 */
113export async function getRecentRootsWithNames(userId) {
114  const recents = await getRecentRootsByUserId(userId);
115  return Promise.all(
116    recents.map(async (r) => {
117      let name = r.rootName;
118      try {
119        const node = await Node.findById(r.rootId).select("name").lean();
120        if (node) name = node.name;
121      } catch (err) { log.debug("Navigation", "recent root name lookup failed:", err.message); }
122      return {
123        rootId: r.rootId,
124        name: name || r.rootId.slice(0, 8) + "...",
125        lastVisitedAt: r.lastVisitedAt,
126      };
127    }),
128  );
129}
130
131
1import log from "../../seed/log.js";
2import { resolveRootNode } from "../../seed/tree/treeFetch.js";
3import {
4  setModels, updateRecentRoots, getRecentRootsWithNames,
5  addRoot, removeRoot, getUserRoots, getUserRootsWithNames,
6} from "./core.js";
7
8const RECENT_ROOTS_EVENT = "recentRoots";
9
10export async function init(core) {
11  setModels(core.models);
12
13  // ── Hook: afterNodeCreate ──
14  // When a user creates a tree root, add it to their navigation list.
15  core.hooks.register("afterNodeCreate", async ({ node, userId }) => {
16    if (!node || !userId) return;
17    if (node.rootOwner && String(node.rootOwner) === String(userId)) {
18      log.info("Navigation", `Adding root "${node.name || node._id}" to user ${userId}`);
19      await addRoot(String(userId), String(node._id));
20    }
21  }, "navigation");
22
23  // ── Hook: afterOwnershipChange ──
24  // Maintain metadata.nav.roots when ownership or contributors change.
25  core.hooks.register("afterOwnershipChange", async ({ nodeId, action, targetUserId, previousOwnerId }) => {
26    if (!nodeId || !targetUserId) return;
27
28    // Find the tree root for this node
29    let rootId;
30    try {
31      const rootNode = await resolveRootNode(nodeId);
32      rootId = rootNode._id.toString();
33    } catch {
34      // Node might be deleted or orphaned
35      return;
36    }
37
38    if (action === "addContributor") {
39      await addRoot(targetUserId, rootId);
40    }
41
42    if (action === "removeContributor") {
43      // Check if the user still has access anywhere in this tree.
44      // If they're still a contributor on another node or the owner, keep the root.
45      const { Node } = core.models;
46      const stillHasAccess = await Node.exists({
47        $or: [
48          { rootOwner: targetUserId },
49          { contributors: targetUserId },
50        ],
51      });
52      // More precise: check only nodes in this tree. But Node.exists is fast
53      // and the common case (user removed from one tree) is correct.
54      // Edge case: user contributes to multiple trees. The root stays because
55      // they have access to at least one tree. That's correct.
56      if (!stillHasAccess) {
57        await removeRoot(targetUserId, rootId);
58      }
59    }
60
61    if (action === "setOwner") {
62      await addRoot(targetUserId, rootId);
63    }
64
65    if (action === "transferOwnership") {
66      await addRoot(targetUserId, rootId);
67      // Previous owner is now a contributor, keep their root
68    }
69
70    // removeOwner: the removed owner may still be a contributor. Don't remove root.
71    // Let the consumer decide (team extension handles retirement separately).
72  }, "navigation");
73
74  // ── Hook: afterNavigate ──
75  // Track recently visited trees.
76  core.hooks.register("afterNavigate", async ({ userId, rootId }) => {
77    if (!userId || !rootId) return;
78    await updateRecentRoots(userId, rootId);
79    const roots = await getRecentRootsWithNames(userId);
80    core.websocket.emitToUser(userId, RECENT_ROOTS_EVENT, { roots });
81  }, "navigation");
82
83  // ── Socket handler: getRecentRoots ──
84  core.websocket.registerSocketHandler("getRecentRoots", async ({ socket, userId }) => {
85    if (!userId) {
86      socket.emit(RECENT_ROOTS_EVENT, { roots: [] });
87      return;
88    }
89    try {
90      const roots = await getRecentRootsWithNames(userId);
91      socket.emit(RECENT_ROOTS_EVENT, { roots });
92    } catch (err) {
93      log.error("Navigation", "Failed to get recent roots:", err.message);
94      socket.emit(RECENT_ROOTS_EVENT, { roots: [] });
95    }
96  });
97
98  // ── Hook: beforeNodeDelete ──
99  // When a tree is retired, remove it from the owner's nav list.
100  core.hooks.register("beforeNodeDelete", async ({ nodeId, node }) => {
101    if (!node?.rootOwner) return;
102    // Only remove if this is a root being retired (has rootOwner set)
103    const nodeDoc = node.rootOwner ? node : await core.models.Node.findById(nodeId).select("rootOwner").lean();
104    if (nodeDoc?.rootOwner) {
105      await removeRoot(String(nodeDoc.rootOwner), String(nodeId));
106    }
107  }, "navigation");
108
109  // Root backfill removed. Was a one-time fix for an ObjectId comparison bug
110  // but ran on every boot, re-adding retired trees to nav lists.
111
112  log.info("Navigation", "Navigation and recent roots tracking loaded");
113
114  // Export functions for other extensions
115  return {
116    exports: {
117      getUserRoots,
118      getUserRootsWithNames,
119      getRecentRootsWithNames,
120      addRoot,
121      removeRoot,
122    },
123  };
124}
125
1export default {
2  name: "navigation",
3  version: "1.0.3",
4  builtFor: "TreeOS",
5  description:
6    "Owns the user's tree navigation state. Every user has two lists stored in metadata.nav: " +
7    "roots (the complete list of trees they own or contribute to) and recentRoots (the last 5 " +
8    "trees they visited, ordered by recency with timestamps). These lists drive the tree picker " +
9    "in the client and the CLI's navigation commands.\n\n" +
10    "The roots list is maintained automatically through three hooks. afterNodeCreate adds the " +
11    "new tree root to the creating user's list when they create a tree. afterOwnershipChange " +
12    "handles all contributor and ownership mutations: addContributor adds the root, " +
13    "removeContributor checks whether the user still has any access (owner or contributor on " +
14    "any node) before removing, setOwner and transferOwnership both add the root for the new " +
15    "owner. The logic is careful about edge cases. A user who contributes to multiple trees " +
16    "and is removed from one keeps their root if they still have access elsewhere.\n\n" +
17    "The recentRoots list is maintained by the afterNavigate hook, which fires every time a " +
18    "user navigates to a tree root. It pushes the visited tree to the front of the list, " +
19    "deduplicates, and trims to the 5 most recent entries. Each entry stores the rootId, " +
20    "rootName, and lastVisitedAt timestamp. After updating, the hook pushes the new recent " +
21    "roots list to the user's WebSocket connection via the recentRoots event, so the client " +
22    "updates in real time. A getRecentRoots socket handler lets clients request the list on " +
23    "connect. At boot, a one-time migration copies the old User.roots schema field to " +
24    "metadata.nav.roots for lands upgrading from the pre-metadata schema. The extension " +
25    "exports addRoot, removeRoot, getUserRoots, getUserRootsWithNames, and " +
26    "getRecentRootsWithNames for other extensions to consume.",
27
28  needs: {
29    services: ["websocket"],
30    models: ["User", "Node"],
31  },
32
33  optional: {},
34
35  provides: {
36    routes: false,
37    tools: false,
38    modes: false,
39    jobs: false,
40    orchestrator: false,
41    energyActions: {},
42    sessionTypes: {},
43    cli: [],
44  },
45};
46

Versions

Version Published Downloads
1.0.3 38d ago 0
1.0.2 47d ago 0
1.0.0 48d ago 0
0 stars
0 flags
React from the CLI: treeos ext star navigation

Comments

Loading comments...

Post comments from the CLI: treeos ext comment navigation "your comment"
Max 3 comments per extension. One star and one flag per user.