diff --git a/apps/obsidian/src/components/RelationshipSection.tsx b/apps/obsidian/src/components/RelationshipSection.tsx index 8ddd7a061..6536d8348 100644 --- a/apps/obsidian/src/components/RelationshipSection.tsx +++ b/apps/obsidian/src/components/RelationshipSection.tsx @@ -1,11 +1,17 @@ import { TFile, Notice } from "obsidian"; -import { useState, useRef, useEffect, useCallback, useMemo } from "react"; +import React, { + useState, + useRef, + useEffect, + useCallback, + useMemo, +} from "react"; import { QueryEngine } from "~/services/QueryEngine"; import SearchBar from "./SearchBar"; import { DiscourseNode } from "~/types"; import DropdownSelect from "./DropdownSelect"; import { usePlugin } from "./PluginContext"; -import { getNodeTypeById } from "~/utils/typeUtils"; +import { getNodeTypeById, formatImportSource } from "~/utils/typeUtils"; import type { RelationInstance } from "~/types"; import { getNodeInstanceIdForFile, @@ -13,7 +19,9 @@ import { resolveEndpointToFile, addRelation, removeRelationBySourceDestinationType, + updateRelation, } from "~/utils/relationsStore"; +import { ridToSpaceUriAndLocalId } from "~/utils/rid"; type RelationTypeOption = { id: string; @@ -370,16 +378,71 @@ type GroupedRelation = { type CurrentRelationshipsProps = RelationshipSectionProps & { relationsVersion: number; + onRelationsChange?: () => void; +}; + +const getSpaceNameFromRid = ( + rid: string, + spaceNames?: Record, +): string => { + try { + const { spaceUri } = ridToSpaceUriAndLocalId(rid); + return formatImportSource(spaceUri, spaceNames); + } catch { + return rid; + } +}; + +const buildGroupedRelations = ( + relations: RelationInstance[], + activeIds: Set, + plugin: ReturnType, +): Map => { + const map = new Map(); + for (const r of relations) { + const relationType = plugin.settings.relationTypes.find( + (rt) => rt.id === r.type, + ); + if (!relationType) continue; + + const isSource = activeIds.has(r.source); + const relationLabel = isSource + ? relationType.label + : relationType.complement; + const relationKey = `${r.type}-${isSource ? "source" : "target"}`; + + if (!map.has(relationKey)) { + map.set(relationKey, { + relationTypeOptions: { + id: relationType.id, + label: relationLabel, + isSource, + }, + linkedEntries: [], + }); + } + + const group = map.get(relationKey)!; + const otherId = isSource ? r.destination : r.source; + const linkedFile = resolveEndpointToFile(plugin, otherId); + if ( + linkedFile && + !group.linkedEntries.some((e) => e.relation.id === r.id) + ) { + group.linkedEntries.push({ file: linkedFile, relation: r }); + } + } + return map; }; const CurrentRelationships = ({ activeFile, relationsVersion, + onRelationsChange, }: CurrentRelationshipsProps) => { const plugin = usePlugin(); - const [groupedRelationships, setGroupedRelationships] = useState< - GroupedRelation[] - >([]); + const [acceptedGroups, setAcceptedGroups] = useState([]); + const [tentativeGroups, setTentativeGroups] = useState([]); const loadCurrentRelationships = useCallback(async () => { const fileCache = plugin.app.metadataCache.getFileCache(activeFile); @@ -395,43 +458,15 @@ const CurrentRelationships = ({ if (activeIds.size === 0) return; const relations = await getRelationsForFile(plugin, activeFile); - const tempRelationships = new Map(); - for (const r of relations) { - const relationType = plugin.settings.relationTypes.find( - (rt) => rt.id === r.type, - ); - if (!relationType) continue; - - const isSource = activeIds.has(r.source); - const relationLabel = isSource - ? relationType.label - : relationType.complement; - const relationKey = `${r.type}-${isSource ? "source" : "target"}`; - - if (!tempRelationships.has(relationKey)) { - tempRelationships.set(relationKey, { - relationTypeOptions: { - id: relationType.id, - label: relationLabel, - isSource, - }, - linkedEntries: [], - }); - } + const accepted = relations.filter((r) => r.tentative !== false); + const tentative = relations.filter((r) => r.tentative === false); - const group = tempRelationships.get(relationKey)!; - const otherId = isSource ? r.destination : r.source; - const linkedFile = resolveEndpointToFile(plugin, otherId); - if (linkedFile) { - const already = group.linkedEntries.some((e) => e.relation.id === r.id); - if (!already) { - group.linkedEntries.push({ file: linkedFile, relation: r }); - } - } - } + const acceptedMap = buildGroupedRelations(accepted, activeIds, plugin); + const tentativeMap = buildGroupedRelations(tentative, activeIds, plugin); - setGroupedRelationships(Array.from(tempRelationships.values())); + setAcceptedGroups(Array.from(acceptedMap.values())); + setTentativeGroups(Array.from(tentativeMap.values())); }, [activeFile, plugin]); useEffect(() => { @@ -452,12 +487,11 @@ const CurrentRelationships = ({ entry.relation.destination, relationTypeId, ); - new Notice( `Successfully removed ${relationType.label} with ${entry.file.basename}`, ); - await loadCurrentRelationships(); + onRelationsChange?.(); } catch (error) { console.error("Failed to delete relationship:", error); new Notice( @@ -465,71 +499,134 @@ const CurrentRelationships = ({ ); } }, - [plugin, loadCurrentRelationships], + [plugin, loadCurrentRelationships, onRelationsChange], ); - if (groupedRelationships.length === 0) return null; + const acceptRelation = useCallback( + async (relationId: string) => { + await updateRelation(plugin, relationId, { tentative: true }); + await loadCurrentRelationships(); + onRelationsChange?.(); + }, + [plugin, loadCurrentRelationships, onRelationsChange], + ); + + const renderEntries = ( + group: GroupedRelation, + renderAction: (entry: LinkedEntry) => React.ReactNode, + ) => ( +
  • +
    +
    + {group.relationTypeOptions.isSource ? "→" : "←"} +
    +
    {group.relationTypeOptions.label}
    +
    + +
  • + ); + + const hasAccepted = acceptedGroups.some((g) => g.linkedEntries.length > 0); + const tentativeCount = tentativeGroups.reduce( + (sum, g) => sum + g.linkedEntries.length, + 0, + ); + const hasTentative = tentativeCount > 0; + const [showTentative, setShowTentative] = useState(true); + + if (!hasAccepted && !hasTentative) return null; return ( -
    -

    Current Relationships

    -
      - {groupedRelationships.map( - (group) => - group.linkedEntries.length > 0 && ( -
    • -
      -
      - {group.relationTypeOptions.isSource ? "→" : "←"} -
      -
      - {group.relationTypeOptions.label} -
      -
      - -
        - {group.linkedEntries.map((entry) => ( -
      • + {hasAccepted && ( +
        +

        Current Relationships

        +
          + {acceptedGroups.map( + (group) => + group.linkedEntries.length > 0 && + renderEntries(group, (entry) => ( + + )), + )} +
        +
        + )} + {hasTentative && ( +
        +
        + + {showTentative ? "Hide" : "Show"} ({tentativeCount}) tentative{" "} + {tentativeCount === 1 ? "relation" : "relations"} + +
        setShowTentative((v) => !v)} + > + +
        +
        + {showTentative && ( + -
      • - ), - )} -
      -
    + ✓ + + )), + )} + + )} + + )} + ); }; @@ -546,6 +643,7 @@ export const RelationshipSection = ({ , +): Promise => { + const data = await loadRelations(plugin); + if (!data.relations[id]) return; + data.relations[id] = { ...data.relations[id], ...patch }; + await saveRelations(plugin, data); +}; + export const getRelationsForNodeInstanceId = async ( plugin: DiscourseGraphPlugin, nodeInstanceId: string,