From d7ba03ace62a374049b858fcc5e943f49cbb9619 Mon Sep 17 00:00:00 2001 From: R-Hart80 Date: Mon, 13 Apr 2026 17:08:25 -0300 Subject: [PATCH 1/4] fix: dim labels and reposition note for webhook-driven readonly fields When a project is webhook-driven, the four GitHub integration input fields (repo owner, repo name, branch, file path) were already marked readOnly and visually styled as disabled on the inputs themselves. This commit extends the visual treatment to their labels by applying opacity-60 when isWebhookDriven is true, and moves the explanatory note ("Repository fields are managed by the GitHub integration.") to appear after all four fields so it clearly covers the whole group. Co-Authored-By: Claude Sonnet 4.6 --- app/projects/[id]/settings/page.tsx | 32 ++++++++++++++++++++--------- 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/app/projects/[id]/settings/page.tsx b/app/projects/[id]/settings/page.tsx index e420d8d4..0c846e41 100644 --- a/app/projects/[id]/settings/page.tsx +++ b/app/projects/[id]/settings/page.tsx @@ -2883,7 +2883,10 @@ function RemoteSyncSection({ {/* Repository */}
-
-
- {isWebhookDriven && ( -

- Repository fields are managed by the GitHub integration. -

- )} - {/* Same-repo info when editing form matches GitHub integration */} {!isWebhookDriven && githubIntegration && repoOwner === githubIntegration.repo_owner && @@ -2939,7 +2939,10 @@ function RemoteSyncSection({ {/* Branch + File path */}
-
-
+ {isWebhookDriven && ( +

+ Repository fields are managed by the GitHub integration. +

+ )} + {/* Frequency + Update mode */}
From e6fdc28362603d0b89995670dded9d9ec8287697 Mon Sep 17 00:00:00 2001 From: R-Hart80 Date: Mon, 11 May 2026 11:02:56 -0300 Subject: [PATCH 2/4] fix: highlight ancestor nodes whose labels match the search query mergePathsIntoTree now accepts a query string and marks any node isSearchMatch when its label contains the query (case-insensitive), not only the leaf IRIs returned by the backend. Plumbed searchQuery from useTreeSearch through UseFilteredTreeOptions into the merge. Four new tests cover: ancestor highlight, case-insensitivity, empty query no-op, and non-matching ancestor stays unhighlighted. Fixes #209. Co-Authored-By: Claude Sonnet 4.6 --- __tests__/lib/hooks/useFilteredTree.test.ts | 66 +++++++++++++++++++ .../developer/DeveloperEditorLayout.tsx | 1 + .../editor/standard/StandardEditorLayout.tsx | 1 + lib/hooks/useFilteredTree.ts | 14 ++-- 4 files changed, 78 insertions(+), 4 deletions(-) diff --git a/__tests__/lib/hooks/useFilteredTree.test.ts b/__tests__/lib/hooks/useFilteredTree.test.ts index afc55306..5cebdc4c 100644 --- a/__tests__/lib/hooks/useFilteredTree.test.ts +++ b/__tests__/lib/hooks/useFilteredTree.test.ts @@ -188,6 +188,72 @@ describe("mergePathsIntoTree", () => { expect(tree).toHaveLength(0); }); + it("highlights an ancestor whose label matches the query, even though it is not a backend result", () => { + const paths: AncestorPath[] = [ + { + matchIri: "urn:leaf", + matchLabel: "Document Collection Event", + ancestors: [ + { iri: "urn:root", label: "Event", child_count: 1 }, + { iri: "urn:ancestor", label: "Document Collection and Production Events", child_count: 1 }, + ], + }, + ]; + + const tree = mergePathsIntoTree(paths, "document"); + + // Root ("Event") does not match + expect(tree[0].isSearchMatch).toBeFalsy(); + // Ancestor ("Document Collection and Production Events") matches the query + expect(tree[0].children[0].isSearchMatch).toBe(true); + // Leaf ("Document Collection Event") is a backend match AND label matches + expect(tree[0].children[0].children[0].isSearchMatch).toBe(true); + }); + + it("is case-insensitive when matching ancestor labels against the query", () => { + const paths: AncestorPath[] = [ + { + matchIri: "urn:leaf", + matchLabel: "Leaf", + ancestors: [{ iri: "urn:ancestor", label: "Document Archive", child_count: 1 }], + }, + ]; + + const tree = mergePathsIntoTree(paths, "DOCUMENT"); + + expect(tree[0].isSearchMatch).toBe(true); + }); + + it("does not highlight ancestors when query is empty", () => { + const paths: AncestorPath[] = [ + { + matchIri: "urn:leaf", + matchLabel: "Leaf", + ancestors: [{ iri: "urn:ancestor", label: "Some Ancestor", child_count: 1 }], + }, + ]; + + const tree = mergePathsIntoTree(paths, ""); + + expect(tree[0].isSearchMatch).toBeFalsy(); + expect(tree[0].children[0].isSearchMatch).toBe(true); + }); + + it("does not highlight ancestors whose labels do not contain the query", () => { + const paths: AncestorPath[] = [ + { + matchIri: "urn:leaf", + matchLabel: "Document Event", + ancestors: [{ iri: "urn:ancestor", label: "Unrelated Category", child_count: 1 }], + }, + ]; + + const tree = mergePathsIntoTree(paths, "document"); + + expect(tree[0].isSearchMatch).toBeFalsy(); + expect(tree[0].children[0].isSearchMatch).toBe(true); + }); + it("assigns entityType 'class' to all nodes", () => { const paths: AncestorPath[] = [ { diff --git a/components/editor/developer/DeveloperEditorLayout.tsx b/components/editor/developer/DeveloperEditorLayout.tsx index d6638d7b..fce7b083 100644 --- a/components/editor/developer/DeveloperEditorLayout.tsx +++ b/components/editor/developer/DeveloperEditorLayout.tsx @@ -331,6 +331,7 @@ export function DeveloperEditorLayout(props: DeveloperEditorLayoutProps) { projectId, accessToken, branch: activeBranch, + searchQuery, }); const handleSearchSelect = (iri: string) => { diff --git a/components/editor/standard/StandardEditorLayout.tsx b/components/editor/standard/StandardEditorLayout.tsx index 7bdeca78..ed932f32 100644 --- a/components/editor/standard/StandardEditorLayout.tsx +++ b/components/editor/standard/StandardEditorLayout.tsx @@ -296,6 +296,7 @@ export function StandardEditorLayout(props: StandardEditorLayoutProps) { projectId, accessToken, branch: activeBranch, + searchQuery, }); const handleSearchSelect = (iri: string) => { diff --git a/lib/hooks/useFilteredTree.ts b/lib/hooks/useFilteredTree.ts index 25e02af8..49258428 100644 --- a/lib/hooks/useFilteredTree.ts +++ b/lib/hooks/useFilteredTree.ts @@ -11,6 +11,7 @@ interface UseFilteredTreeOptions { projectId: string; accessToken?: string; branch?: string; + searchQuery?: string; } interface UseFilteredTreeReturn { @@ -29,6 +30,7 @@ export function useFilteredTree({ projectId, accessToken, branch, + searchQuery, }: UseFilteredTreeOptions): UseFilteredTreeReturn { const [filteredNodes, setFilteredNodes] = useState(null); const [isBuilding, setIsBuilding] = useState(false); @@ -92,7 +94,7 @@ export function useFilteredTree({ if (buildId !== buildIdRef.current) return; // Merge all ancestor paths into a unified tree - const tree = mergePathsIntoTree(ancestorPaths); + const tree = mergePathsIntoTree(ancestorPaths, searchQuery ?? ""); setFilteredNodes(tree); setFirstMatchIri(limitedResults[0]?.iri ?? null); @@ -121,8 +123,11 @@ export interface AncestorPath { /** * Merge multiple ancestor paths into a unified EntityTreeNode tree. * Matched nodes get `isSearchMatch: true`, all ancestors are `isExpanded: true`. + * If `query` is provided, any node whose label contains the query (case-insensitive) + * is also marked as a match — not just the leaf IRIs the backend returned. */ -export function mergePathsIntoTree(paths: AncestorPath[]): EntityTreeNode[] { +export function mergePathsIntoTree(paths: AncestorPath[], query = ""): EntityTreeNode[] { + const q = query.trim().toLowerCase(); // nodeMap: iri -> EntityTreeNode const nodeMap = new Map(); // childrenMap: parentIri -> Set @@ -143,6 +148,7 @@ export function mergePathsIntoTree(paths: AncestorPath[]): EntityTreeNode[] { for (let i = 0; i < fullPath.length; i++) { const item = fullPath[i]; + const labelMatches = q.length > 0 && item.label.toLowerCase().includes(q); if (!nodeMap.has(item.iri)) { nodeMap.set(item.iri, { @@ -153,12 +159,12 @@ export function mergePathsIntoTree(paths: AncestorPath[]): EntityTreeNode[] { isLoading: false, hasChildren: item.hasChildren, entityType: "class", - isSearchMatch: matchIris.has(item.iri), + isSearchMatch: matchIris.has(item.iri) || labelMatches, }); } else { const existing = nodeMap.get(item.iri)!; existing.hasChildren = existing.hasChildren || item.hasChildren; - if (matchIris.has(item.iri)) { + if (matchIris.has(item.iri) || labelMatches) { existing.isSearchMatch = true; } } From 99689cddc241aa1cd8eb65506a58e1142da9e048 Mon Sep 17 00:00:00 2001 From: R-Hart80 Date: Thu, 14 May 2026 11:23:28 -0300 Subject: [PATCH 3/4] fix: eliminate setState-in-effect anti-patterns (Category D, issue #200) Replace four direct setState calls inside useEffect bodies with lazy initialisers and derived state: - EntityTreeToolbar: useState lazy init reads localStorage instead of setting state in a mount effect; removes the useEffect entirely. - app/auth/error: move auto-retry call into a setTimeout callback so it is asynchronous; removes the redundant setRetryCount that duplicated what retry() already does. - app/settings: useState lazy init reads window.location.hash so the highlight value is available on the first render without a setState-in-effect. - app/page: replace authDefaultApplied ref + useEffect with a derived filter (null-coalesce on userFilter) so authenticated users default to "mine" without an extra render cycle. Co-Authored-By: Claude Sonnet 4.6 --- app/auth/error/page.tsx | 5 ++--- app/page.tsx | 13 +++---------- app/settings/page.tsx | 11 ++++++----- components/editor/shared/EntityTreeToolbar.tsx | 14 +++++--------- 4 files changed, 16 insertions(+), 27 deletions(-) diff --git a/app/auth/error/page.tsx b/app/auth/error/page.tsx index b892b235..7ed748be 100644 --- a/app/auth/error/page.tsx +++ b/app/auth/error/page.tsx @@ -36,9 +36,8 @@ function ErrorContent() { useEffect(() => { if (!isTransient || retryCount >= MAX_RETRIES || retrying) return; if (countdown <= 0) { - setRetryCount((c) => c + 1); - retry(); - return; + const id = setTimeout(() => retry(), 0); + return () => clearTimeout(id); } const timer = setTimeout(() => setCountdown((c) => c - 1), 1000); return () => clearTimeout(timer); diff --git a/app/page.tsx b/app/page.tsx index a2817afb..3eeffa99 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -17,20 +17,13 @@ type FilterType = "public" | "private" | "mine" | "all"; export default function HomePage() { const { data: session, status } = useSession(); - const [filter, setFilter] = useState("public"); + const [userFilter, setUserFilter] = useState(null); const [searchQuery, setSearchQuery] = useState(""); const [debouncedSearch, setDebouncedSearch] = useState(""); const isAuthenticated = status === "authenticated"; - - // Default authenticated users to "mine" tab - const authDefaultApplied = useRef(false); - useEffect(() => { - if (!authDefaultApplied.current && status !== "loading") { - authDefaultApplied.current = true; - if (isAuthenticated) setFilter("mine"); - } - }, [status, isAuthenticated]); + const filter = userFilter ?? (status !== "loading" && isAuthenticated ? "mine" : "public"); + const setFilter = useCallback((f: FilterType) => setUserFilter(f), []); // Debounce search input const debounceRef = useRef | null>(null); diff --git a/app/settings/page.tsx b/app/settings/page.tsx index e43cba5f..ff9cba65 100644 --- a/app/settings/page.tsx +++ b/app/settings/page.tsx @@ -330,16 +330,17 @@ function EditorPreferencesSection() { const setTheme = useEditorModeStore((s) => s.setTheme); const preferEditMode = useEditorModeStore((s) => s.preferEditMode); const setPreferEditMode = useEditorModeStore((s) => s.setPreferEditMode); - const [highlightedSetting, setHighlightedSetting] = useState(null); + const [highlightedSetting, setHighlightedSetting] = useState(() => { + if (typeof window === "undefined") return null; + return window.location.hash.slice(1) || null; + }); - // Highlight and scroll to the setting referenced by the URL hash + // Scroll to the setting referenced by the URL hash and clear the highlight after 2 s useEffect(() => { const hash = window.location.hash.slice(1); if (!hash) return; const el = document.getElementById(hash); - if (!el) return; - el.scrollIntoView({ behavior: "smooth", block: "center" }); - setHighlightedSetting(hash); + if (el) el.scrollIntoView({ behavior: "smooth", block: "center" }); const timer = setTimeout(() => setHighlightedSetting(null), 2000); return () => clearTimeout(timer); }, []); diff --git a/components/editor/shared/EntityTreeToolbar.tsx b/components/editor/shared/EntityTreeToolbar.tsx index 234dec7c..c053bf40 100644 --- a/components/editor/shared/EntityTreeToolbar.tsx +++ b/components/editor/shared/EntityTreeToolbar.tsx @@ -1,6 +1,6 @@ "use client"; -import { useRef, useCallback, useState, useEffect } from "react"; +import { useRef, useCallback, useState } from "react"; import { Search, X, Plus, ChevronDown, ChevronsDown, ChevronRight, ChevronsRight, Loader2 } from "lucide-react"; import { cn } from "@/lib/utils"; @@ -44,17 +44,13 @@ export function EntityTreeToolbar({ const internalRef = useRef(null); const inputRef = searchInputRef || internalRef; - // Dismissible tip - const [showTip, setShowTip] = useState(false); - useEffect(() => { + const [showTip, setShowTip] = useState(() => { try { - if (!localStorage.getItem(TIP_DISMISSED_KEY)) { - setShowTip(true); - } + return !localStorage.getItem(TIP_DISMISSED_KEY); } catch { - // localStorage unavailable + return false; } - }, []); + }); const dismissTip = useCallback(() => { setShowTip(false); From 4efc369636141976d5455d6ede2ab60a5728d328 Mon Sep 17 00:00:00 2001 From: R-Hart80 Date: Sat, 23 May 2026 16:38:00 -0300 Subject: [PATCH 4/4] fix: add searchQuery to useFilteredTree effect dependency array Missing dep caused stale tree highlights when the search query changed but searchResults did not (e.g. same result set, different query string). Co-Authored-By: Claude Sonnet 4.6 --- lib/hooks/useFilteredTree.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/hooks/useFilteredTree.ts b/lib/hooks/useFilteredTree.ts index 49258428..4ea61839 100644 --- a/lib/hooks/useFilteredTree.ts +++ b/lib/hooks/useFilteredTree.ts @@ -109,7 +109,7 @@ export function useFilteredTree({ } } })(); - }, [searchResults, projectId, accessToken, branch]); + }, [searchResults, searchQuery, projectId, accessToken, branch]); return { filteredNodes, isBuilding, firstMatchIri, truncated }; }