Skip to content

Commit ab00780

Browse files
trangdoan982claude
andauthored
ENG-1514: Separate tentative (imported) relations from current in RelationshipSection (#941)
* ENG-1514: Separate tentative (imported) relations from current in RelationshipSection - Rename `provisional` → `tentative` on RelationInstance for consistent terminology - Add `updateRelation()` to relationsStore for patching relation fields - Split RelationshipSection into "Current Relationships" (tentative !== false) and "Tentative Relationships" (tentative === false) sections - Tentative section has a native Obsidian toggle to show/hide, a ✓ accept button per entry with tooltip "Accept relation from space <name>", and moves the relation to Current on acceptance Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * get spaceName * address PR comment --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent f95ede6 commit ab00780

4 files changed

Lines changed: 211 additions & 108 deletions

File tree

apps/obsidian/src/components/RelationshipSection.tsx

Lines changed: 193 additions & 101 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,25 @@
11
import { TFile, Notice } from "obsidian";
2-
import { useState, useRef, useEffect, useCallback, useMemo } from "react";
2+
import React, {
3+
useState,
4+
useRef,
5+
useEffect,
6+
useCallback,
7+
useMemo,
8+
} from "react";
39
import { QueryEngine } from "~/services/QueryEngine";
410
import SearchBar from "./SearchBar";
511
import { DiscourseNode } from "~/types";
612
import DropdownSelect from "./DropdownSelect";
713
import { usePlugin } from "./PluginContext";
8-
import { getNodeTypeById } from "~/utils/typeUtils";
14+
import { getNodeTypeById, getAndFormatImportSource } from "~/utils/typeUtils";
915
import type { RelationInstance } from "~/types";
1016
import {
1117
getNodeInstanceIdForFile,
1218
getRelationsForFile,
1319
resolveEndpointToFile,
1420
addRelation,
1521
removeRelationBySourceDestinationType,
22+
updateRelation,
1623
} from "~/utils/relationsStore";
1724

1825
type RelationTypeOption = {
@@ -370,16 +377,59 @@ type GroupedRelation = {
370377

371378
type CurrentRelationshipsProps = RelationshipSectionProps & {
372379
relationsVersion: number;
380+
onRelationsChange?: () => void;
381+
};
382+
383+
const buildGroupedRelations = (
384+
relations: RelationInstance[],
385+
activeIds: Set<string>,
386+
plugin: ReturnType<typeof usePlugin>,
387+
): Map<string, GroupedRelation> => {
388+
const map = new Map<string, GroupedRelation>();
389+
for (const r of relations) {
390+
const relationType = plugin.settings.relationTypes.find(
391+
(rt) => rt.id === r.type,
392+
);
393+
if (!relationType) continue;
394+
395+
const isSource = activeIds.has(r.source);
396+
const relationLabel = isSource
397+
? relationType.label
398+
: relationType.complement;
399+
const relationKey = `${r.type}-${isSource ? "source" : "target"}`;
400+
401+
if (!map.has(relationKey)) {
402+
map.set(relationKey, {
403+
relationTypeOptions: {
404+
id: relationType.id,
405+
label: relationLabel,
406+
isSource,
407+
},
408+
linkedEntries: [],
409+
});
410+
}
411+
412+
const group = map.get(relationKey)!;
413+
const otherId = isSource ? r.destination : r.source;
414+
const linkedFile = resolveEndpointToFile(plugin, otherId);
415+
if (
416+
linkedFile &&
417+
!group.linkedEntries.some((e) => e.relation.id === r.id)
418+
) {
419+
group.linkedEntries.push({ file: linkedFile, relation: r });
420+
}
421+
}
422+
return map;
373423
};
374424

375425
const CurrentRelationships = ({
376426
activeFile,
377427
relationsVersion,
428+
onRelationsChange,
378429
}: CurrentRelationshipsProps) => {
379430
const plugin = usePlugin();
380-
const [groupedRelationships, setGroupedRelationships] = useState<
381-
GroupedRelation[]
382-
>([]);
431+
const [acceptedGroups, setAcceptedGroups] = useState<GroupedRelation[]>([]);
432+
const [tentativeGroups, setTentativeGroups] = useState<GroupedRelation[]>([]);
383433

384434
const loadCurrentRelationships = useCallback(async () => {
385435
const fileCache = plugin.app.metadataCache.getFileCache(activeFile);
@@ -395,43 +445,15 @@ const CurrentRelationships = ({
395445
if (activeIds.size === 0) return;
396446

397447
const relations = await getRelationsForFile(plugin, activeFile);
398-
const tempRelationships = new Map<string, GroupedRelation>();
399448

400-
for (const r of relations) {
401-
const relationType = plugin.settings.relationTypes.find(
402-
(rt) => rt.id === r.type,
403-
);
404-
if (!relationType) continue;
405-
406-
const isSource = activeIds.has(r.source);
407-
const relationLabel = isSource
408-
? relationType.label
409-
: relationType.complement;
410-
const relationKey = `${r.type}-${isSource ? "source" : "target"}`;
411-
412-
if (!tempRelationships.has(relationKey)) {
413-
tempRelationships.set(relationKey, {
414-
relationTypeOptions: {
415-
id: relationType.id,
416-
label: relationLabel,
417-
isSource,
418-
},
419-
linkedEntries: [],
420-
});
421-
}
449+
const accepted = relations.filter((r) => r.tentative !== false);
450+
const tentative = relations.filter((r) => r.tentative === false);
422451

423-
const group = tempRelationships.get(relationKey)!;
424-
const otherId = isSource ? r.destination : r.source;
425-
const linkedFile = resolveEndpointToFile(plugin, otherId);
426-
if (linkedFile) {
427-
const already = group.linkedEntries.some((e) => e.relation.id === r.id);
428-
if (!already) {
429-
group.linkedEntries.push({ file: linkedFile, relation: r });
430-
}
431-
}
432-
}
452+
const acceptedMap = buildGroupedRelations(accepted, activeIds, plugin);
453+
const tentativeMap = buildGroupedRelations(tentative, activeIds, plugin);
433454

434-
setGroupedRelationships(Array.from(tempRelationships.values()));
455+
setAcceptedGroups(Array.from(acceptedMap.values()));
456+
setTentativeGroups(Array.from(tentativeMap.values()));
435457
}, [activeFile, plugin]);
436458

437459
useEffect(() => {
@@ -452,84 +474,153 @@ const CurrentRelationships = ({
452474
entry.relation.destination,
453475
relationTypeId,
454476
);
455-
456477
new Notice(
457478
`Successfully removed ${relationType.label} with ${entry.file.basename}`,
458479
);
459-
460480
await loadCurrentRelationships();
481+
onRelationsChange?.();
461482
} catch (error) {
462483
console.error("Failed to delete relationship:", error);
463484
new Notice(
464485
`Failed to delete relationship: ${error instanceof Error ? error.message : "Unknown error"}`,
465486
);
466487
}
467488
},
468-
[plugin, loadCurrentRelationships],
489+
[plugin, loadCurrentRelationships, onRelationsChange],
469490
);
470491

471-
if (groupedRelationships.length === 0) return null;
492+
const acceptRelation = useCallback(
493+
async (relationId: string) => {
494+
try {
495+
await updateRelation(plugin, relationId, { tentative: true });
496+
await loadCurrentRelationships();
497+
onRelationsChange?.();
498+
} catch (error) {
499+
console.error("Failed to accept relationship:", error);
500+
new Notice(
501+
`Failed to accept relationship: ${error instanceof Error ? error.message : "Unknown error"}`,
502+
);
503+
}
504+
},
505+
[plugin, loadCurrentRelationships, onRelationsChange],
506+
);
507+
508+
const renderEntries = (
509+
group: GroupedRelation,
510+
renderAction: (entry: LinkedEntry) => React.ReactNode,
511+
) => (
512+
<li
513+
key={`${group.relationTypeOptions.id}-${group.relationTypeOptions.isSource}`}
514+
className="border-modifier-border border-b px-3 py-2"
515+
>
516+
<div className="mb-1 flex items-center">
517+
<div className="mr-2">
518+
{group.relationTypeOptions.isSource ? "→" : "←"}
519+
</div>
520+
<div className="font-bold">{group.relationTypeOptions.label}</div>
521+
</div>
522+
<ul className="m-0 ml-6 list-none p-0">
523+
{group.linkedEntries.map((entry) => (
524+
<li key={entry.relation.id} className="mt-1 flex items-center gap-2">
525+
<a
526+
href="#"
527+
className="text-accent-text flex-1"
528+
onClick={(e) => {
529+
e.preventDefault();
530+
void plugin.app.workspace.openLinkText(
531+
entry.file.path,
532+
activeFile.path,
533+
);
534+
}}
535+
>
536+
{entry.file.basename}
537+
</a>
538+
{renderAction(entry)}
539+
</li>
540+
))}
541+
</ul>
542+
</li>
543+
);
544+
545+
const hasAccepted = acceptedGroups.some((g) => g.linkedEntries.length > 0);
546+
const tentativeCount = tentativeGroups.reduce(
547+
(sum, g) => sum + g.linkedEntries.length,
548+
0,
549+
);
550+
const hasTentative = tentativeCount > 0;
551+
const [showTentative, setShowTentative] = useState(true);
552+
553+
if (!hasAccepted && !hasTentative) return null;
472554

473555
return (
474-
<div className="current-relationships mb-6">
475-
<h4 className="mb-2 text-base font-medium">Current Relationships</h4>
476-
<ul className="border-modifier-border m-0 list-none rounded border p-0">
477-
{groupedRelationships.map(
478-
(group) =>
479-
group.linkedEntries.length > 0 && (
480-
<li
481-
key={`${group.relationTypeOptions.id}-${group.relationTypeOptions.isSource}`}
482-
className="border-modifier-border border-b px-3 py-2"
483-
>
484-
<div className="mb-1 flex items-center">
485-
<div className="mr-2">
486-
{group.relationTypeOptions.isSource ? "→" : "←"}
487-
</div>
488-
<div className="font-bold">
489-
{group.relationTypeOptions.label}
490-
</div>
491-
</div>
492-
493-
<ul className="m-0 ml-6 list-none p-0">
494-
{group.linkedEntries.map((entry) => (
495-
<li
496-
key={entry.relation.id}
497-
className="mt-1 flex items-center gap-2"
556+
<>
557+
{hasAccepted && (
558+
<div className="current-relationships mb-6">
559+
<h4 className="mb-2 text-base font-medium">Current Relationships</h4>
560+
<ul className="border-modifier-border m-0 list-none rounded border p-0">
561+
{acceptedGroups.map(
562+
(group) =>
563+
group.linkedEntries.length > 0 &&
564+
renderEntries(group, (entry) => (
565+
<button
566+
className="!text-muted hover:!text-error flex h-6 w-6 cursor-pointer items-center justify-center border-0 !bg-transparent text-sm"
567+
onClick={(e) => {
568+
e.preventDefault();
569+
void deleteRelationship(
570+
entry,
571+
group.relationTypeOptions.id,
572+
);
573+
}}
574+
title="Delete relationship"
575+
>
576+
×
577+
</button>
578+
)),
579+
)}
580+
</ul>
581+
</div>
582+
)}
583+
{hasTentative && (
584+
<div className="tentative-relationships mb-6">
585+
<div className="mb-2 flex items-center justify-between">
586+
<span className="text-base font-medium">
587+
{showTentative ? "Hide" : "Show"} ({tentativeCount}) tentative{" "}
588+
{tentativeCount === 1 ? "relation" : "relations"}
589+
</span>
590+
<div
591+
className={`checkbox-container ${showTentative ? "is-enabled" : ""}`}
592+
onClick={() => setShowTentative((v) => !v)}
593+
>
594+
<input type="checkbox" checked={showTentative} readOnly />
595+
</div>
596+
</div>
597+
{showTentative && (
598+
<ul className="border-modifier-border m-0 list-none rounded border p-0">
599+
{tentativeGroups.map(
600+
(group) =>
601+
group.linkedEntries.length > 0 &&
602+
renderEntries(group, (entry) => (
603+
<button
604+
className="!text-muted hover:!text-accent flex h-6 w-6 cursor-pointer items-center justify-center border-0 !bg-transparent text-sm"
605+
onClick={(e) => {
606+
e.preventDefault();
607+
void acceptRelation(entry.relation.id);
608+
}}
609+
title={
610+
entry.relation.importedFromRid
611+
? `Accept relation from space ${getAndFormatImportSource(entry.relation.importedFromRid, plugin.settings.spaceNames)}`
612+
: "Accept relationship"
613+
}
498614
>
499-
<a
500-
href="#"
501-
className="text-accent-text flex-1"
502-
onClick={(e) => {
503-
e.preventDefault();
504-
void plugin.app.workspace.openLinkText(
505-
entry.file.path,
506-
activeFile.path,
507-
);
508-
}}
509-
>
510-
{entry.file.basename}
511-
</a>
512-
<button
513-
className="!text-muted hover:!text-error flex h-6 w-6 cursor-pointer items-center justify-center border-0 !bg-transparent text-sm"
514-
onClick={(e) => {
515-
e.preventDefault();
516-
void deleteRelationship(
517-
entry,
518-
group.relationTypeOptions.id,
519-
);
520-
}}
521-
title="Delete relationship"
522-
>
523-
×
524-
</button>
525-
</li>
526-
))}
527-
</ul>
528-
</li>
529-
),
530-
)}
531-
</ul>
532-
</div>
615+
616+
</button>
617+
)),
618+
)}
619+
</ul>
620+
)}
621+
</div>
622+
)}
623+
</>
533624
);
534625
};
535626

@@ -546,6 +637,7 @@ export const RelationshipSection = ({
546637
<CurrentRelationships
547638
activeFile={activeFile}
548639
relationsVersion={relationsVersion}
640+
onRelationsChange={onRelationsChange}
549641
/>
550642
<AddRelationship
551643
activeFile={activeFile}

apps/obsidian/src/types.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,8 @@ export type RelationInstance = {
4747
lastModified?: number;
4848
publishedToGroupId?: string[];
4949
importedFromRid?: string;
50-
/** Pre-emptive: for future UI where user approves relations. On first import, set to false. */
51-
provisional?: boolean;
50+
/** Tracks acceptance of imported relations. false = imported, not yet accepted. true or undefined = accepted/local. */
51+
tentative?: boolean;
5252
};
5353

5454
export type Settings = {

apps/obsidian/src/utils/importRelations.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -368,7 +368,7 @@ export const importRelationsForImportedNodes = async ({
368368
source: sourceEndpointId,
369369
destination: destEndpointId,
370370
importedFromRid: relationImportedFromRid,
371-
provisional: false,
371+
tentative: false,
372372
});
373373
imported++;
374374

0 commit comments

Comments
 (0)