@tangle-network/ui 11.15.1 → 11.15.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # @tangle-network/ui
2
2
 
3
+ ## 11.15.2
4
+
5
+ ### Patch Changes
6
+
7
+ - 11d515d: Keep file-tree selection callbacks current after rerenders and synchronize controlled selection through the native item API.
8
+ Closing a file clears its selection so the same file can be opened again.
9
+
3
10
  ## 11.15.1
4
11
 
5
12
  ### Patch Changes
@@ -1,4 +1,4 @@
1
- import { useEffect, useMemo } from "react";
1
+ import { useEffect, useMemo, useRef } from "react";
2
2
  import { jsx } from "react/jsx-runtime";
3
3
  import { FileTree, useFileTree } from "@pierre/trees/react";
4
4
  //#region src/files/rich-file-tree.tsx
@@ -66,13 +66,24 @@ function RichFileTree({ root, paths, selectedPath, onSelect, search = true, init
66
66
  if (root) return flattenFileNode(root);
67
67
  return [];
68
68
  }, [paths, root]);
69
+ const selectionRef = useRef({
70
+ selectedPath,
71
+ onSelect
72
+ });
73
+ selectionRef.current = {
74
+ selectedPath,
75
+ onSelect
76
+ };
77
+ const syncingSelectionRef = useRef(false);
78
+ const previousSelectedPathRef = useRef(selectedPath);
69
79
  const { model } = useFileTree({
70
80
  paths: flatPaths,
71
81
  search,
72
82
  initialExpansion,
73
83
  onSelectionChange: (selected) => {
74
84
  const next = selected[0];
75
- if (next && next !== selectedPath) onSelect?.(next);
85
+ const current = selectionRef.current;
86
+ if (!syncingSelectionRef.current && next && next !== current.selectedPath) current.onSelect?.(next);
76
87
  }
77
88
  });
78
89
  useEffect(() => {
@@ -83,11 +94,23 @@ function RichFileTree({ root, paths, selectedPath, onSelect, search = true, init
83
94
  model.resetPaths(flatPaths);
84
95
  }, [model, flatPaths]);
85
96
  useEffect(() => {
86
- if (!selectedPath) return;
97
+ const previous = previousSelectedPathRef.current;
98
+ previousSelectedPathRef.current = selectedPath;
99
+ if (selectedPath === void 0 && previous === void 0) return;
87
100
  const current = model.getSelectedPaths();
88
- if (current.length === 1 && current[0] === selectedPath) return;
89
- model.setSelectedPaths?.([selectedPath]);
90
- }, [model, selectedPath]);
101
+ if (current.length === (selectedPath ? 1 : 0) && current[0] === selectedPath) return;
102
+ syncingSelectionRef.current = true;
103
+ try {
104
+ for (const path of [...current]) model.getItem(path)?.deselect();
105
+ if (selectedPath) model.getItem(selectedPath)?.select();
106
+ } finally {
107
+ syncingSelectionRef.current = false;
108
+ }
109
+ }, [
110
+ model,
111
+ selectedPath,
112
+ flatPaths
113
+ ]);
91
114
  const theme = {
92
115
  ...DEFAULT_THEME,
93
116
  ...themeOverrides
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tangle-network/ui",
3
- "version": "11.15.1",
3
+ "version": "11.15.2",
4
4
  "description": "Generic React UI components for Tangle products — primitives, chat, run, files, editor, markdown.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,7 +1,15 @@
1
- import { describe, it, expect } from "vitest"
2
- import { render } from "@testing-library/react"
1
+ import { describe, it, expect, vi } from "vitest"
2
+ import { fireEvent, render, waitFor } from "@testing-library/react"
3
3
  import { RichFileTree } from "./rich-file-tree"
4
4
 
5
+ function treeRow(container: HTMLElement, path: string): HTMLElement {
6
+ const host = container.querySelector("file-tree-container");
7
+ const row = Array.from(host?.shadowRoot?.querySelectorAll("[data-item-path]") ?? [])
8
+ .find((element) => element.getAttribute("data-item-path") === path);
9
+ if (!(row instanceof HTMLElement)) throw new Error(`Missing tree row: ${path}`);
10
+ return row;
11
+ }
12
+
5
13
  describe("RichFileTree", () => {
6
14
  it("renders without crashing given a flat path list", () => {
7
15
  const { container } = render(
@@ -34,6 +42,46 @@ describe("RichFileTree", () => {
34
42
  expect(container.querySelectorAll("*").length).toBeGreaterThan(0)
35
43
  })
36
44
 
45
+ it("calls the current selection callback after its owner rerenders", async () => {
46
+ const original = vi.fn();
47
+ const current = vi.fn();
48
+ const paths = ["brief.md", "outreach.md"];
49
+ const { container, rerender } = render(<RichFileTree paths={paths} onSelect={original} />);
50
+ rerender(<RichFileTree paths={paths} onSelect={current} />);
51
+ fireEvent.click(treeRow(container, "outreach.md"));
52
+ await waitFor(() => expect(current).toHaveBeenCalledWith("outreach.md"));
53
+ expect(original).not.toHaveBeenCalled();
54
+ });
55
+
56
+ it("clears controlled selection so a closed file can be selected again", async () => {
57
+ const onSelect = vi.fn();
58
+ const paths = ["brief.md", "outreach.md"];
59
+ const { container, rerender } = render(
60
+ <RichFileTree paths={paths} selectedPath="brief.md" onSelect={onSelect} />,
61
+ );
62
+ rerender(<RichFileTree paths={paths} selectedPath={undefined} onSelect={onSelect} />);
63
+ await waitFor(() => expect(treeRow(container, "brief.md")).toHaveAttribute("aria-selected", "false"));
64
+ fireEvent.click(treeRow(container, "brief.md"));
65
+ await waitFor(() => expect(onSelect).toHaveBeenCalledExactlyOnceWith("brief.md"));
66
+ });
67
+
68
+ it("preserves uncontrolled selection when the file listing refreshes", async () => {
69
+ const { container, rerender } = render(<RichFileTree paths={["brief.md"]} />);
70
+ fireEvent.click(treeRow(container, "brief.md"));
71
+ await waitFor(() => expect(treeRow(container, "brief.md")).toHaveAttribute("aria-selected", "true"));
72
+ rerender(<RichFileTree paths={["brief.md", "outreach.md"]} />);
73
+ await waitFor(() => expect(treeRow(container, "brief.md")).toHaveAttribute("aria-selected", "true"));
74
+ });
75
+
76
+ it("does not notify the owner when a controlled selection is synchronized", async () => {
77
+ const onSelect = vi.fn();
78
+ const paths = ["brief.md", "outreach.md"];
79
+ const { container, rerender } = render(<RichFileTree paths={paths} selectedPath="brief.md" onSelect={onSelect} />);
80
+ rerender(<RichFileTree paths={paths} selectedPath="outreach.md" onSelect={onSelect} />);
81
+ await waitFor(() => expect(treeRow(container, "outreach.md")).toHaveAttribute("aria-selected", "true"));
82
+ expect(onSelect).not.toHaveBeenCalled();
83
+ });
84
+
37
85
  it("throws when both root and paths are passed", () => {
38
86
  expect(() =>
39
87
  render(<RichFileTree root={{ name: "x", path: "x", type: "file" }} paths={["y"]} />),
@@ -27,7 +27,7 @@ import type {
27
27
  GitStatus,
28
28
  GitStatusEntry,
29
29
  } from "@pierre/trees";
30
- import { useEffect, useMemo, type CSSProperties, type ReactNode } from "react";
30
+ import { useEffect, useMemo, useRef, type CSSProperties, type ReactNode } from "react";
31
31
  import type { FileNode } from "./file-tree";
32
32
 
33
33
  /**
@@ -169,13 +169,22 @@ export function RichFileTree({
169
169
  return [];
170
170
  }, [paths, root]);
171
171
 
172
+ // Pierre creates its model once; event handlers must read the current owner.
173
+ const selectionRef = useRef({ selectedPath, onSelect });
174
+ selectionRef.current = { selectedPath, onSelect };
175
+ const syncingSelectionRef = useRef(false);
176
+ const previousSelectedPathRef = useRef(selectedPath);
177
+
172
178
  const { model } = usePierreFileTree({
173
179
  paths: flatPaths,
174
180
  search,
175
181
  initialExpansion,
176
182
  onSelectionChange: (selected) => {
177
- const next = selected[0]
178
- if (next && next !== selectedPath) onSelect?.(next)
183
+ const next = selected[0];
184
+ const current = selectionRef.current;
185
+ if (!syncingSelectionRef.current && next && next !== current.selectedPath) {
186
+ current.onSelect?.(next);
187
+ }
179
188
  },
180
189
  });
181
190
 
@@ -192,17 +201,21 @@ export function RichFileTree({
192
201
  model.resetPaths(flatPaths);
193
202
  }, [model, flatPaths]);
194
203
 
195
- // Drive selection from props (controlled). Skip when already selected
196
- // to avoid an emit loop with onSelectionChange.
204
+ // Drive controlled selection with Pierre's public item API, including clearing it.
197
205
  useEffect(() => {
198
- if (!selectedPath) return;
206
+ const previous = previousSelectedPathRef.current;
207
+ previousSelectedPathRef.current = selectedPath;
208
+ if (selectedPath === undefined && previous === undefined) return;
199
209
  const current = model.getSelectedPaths();
200
- if (current.length === 1 && current[0] === selectedPath) return;
201
- // The public API exposes selection via `select` semantics through the
202
- // controller — fall back to direct setter when present, no-op otherwise.
203
- const m = model as unknown as { setSelectedPaths?: (paths: readonly string[]) => void };
204
- m.setSelectedPaths?.([selectedPath]);
205
- }, [model, selectedPath]);
210
+ if (current.length === (selectedPath ? 1 : 0) && current[0] === selectedPath) return;
211
+ syncingSelectionRef.current = true;
212
+ try {
213
+ for (const path of [...current]) model.getItem(path)?.deselect();
214
+ if (selectedPath) model.getItem(selectedPath)?.select();
215
+ } finally {
216
+ syncingSelectionRef.current = false;
217
+ }
218
+ }, [model, selectedPath, flatPaths]);
206
219
 
207
220
  const theme = { ...DEFAULT_THEME, ...themeOverrides };
208
221
  const themeStyle = useMemo<CSSProperties>(