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/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/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 */}
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/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/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); 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..4ea61839 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); @@ -107,7 +109,7 @@ export function useFilteredTree({ } } })(); - }, [searchResults, projectId, accessToken, branch]); + }, [searchResults, searchQuery, projectId, accessToken, branch]); return { filteredNodes, isBuilding, firstMatchIri, truncated }; } @@ -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; } }