Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
229 changes: 229 additions & 0 deletions apps/roam/src/components/DiscoverSharedNodesDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
import {
Button,
Callout,
Classes,
Dialog,
HTMLTable,
InputGroup,
Intent,
NonIdealState,
Spinner,
Tag,
Tooltip,
} from "@blueprintjs/core";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import createOverlayRender from "roamjs-components/util/createOverlayRender";
import {
discoverSharedNodes,
type DiscoveredSharedNode,
} from "~/utils/discoverSharedNodes";
import { getLoggedInClient, getSupabaseContext } from "~/utils/supabaseContext";

const formatModifiedAt = (modifiedAt: string): string =>
new Date(modifiedAt).toLocaleString();

const SharedNodeRow = ({ node }: { node: DiscoveredSharedNode }) => (
<tr>
<td>
<Tag minimal>{node.sourceApp}</Tag>
</td>
<td>
<div className="max-w-52 font-medium [overflow-wrap:anywhere]">
{node.sourceSpaceName}
</div>
<div
className={[
Classes.MONOSPACE_TEXT,
Classes.TEXT_MUTED,
"max-w-52 truncate text-xs",
].join(" ")}
title={node.sourceSpaceId}
>
{node.sourceSpaceId}
</div>
</td>
<td>
<div className="max-w-72 font-medium [overflow-wrap:anywhere]">
{node.title}
</div>
</td>
<td>
{node.sourceNodeId ? (
<div
className={[Classes.MONOSPACE_TEXT, "max-w-44 truncate text-xs"].join(
" ",
)}
title={node.sourceNodeRid}
>
{node.sourceNodeId}
</div>
) : (
<span className={Classes.TEXT_MUTED}>Not provided</span>
)}
</td>
<td className="whitespace-nowrap" title={node.modifiedAt}>
{formatModifiedAt(node.modifiedAt)}
</td>
<td>
{node.alreadyImported ? (
<Tag intent={Intent.SUCCESS} minimal>
Imported
</Tag>
) : (
<Tag minimal>Available</Tag>
)}
</td>
</tr>
);

const DiscoverSharedNodesDialog = ({ onClose }: { onClose: () => void }) => {
const [nodes, setNodes] = useState<DiscoveredSharedNode[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [search, setSearch] = useState("");

const loadNodes = useCallback(async (): Promise<void> => {
setLoading(true);
setError("");
try {
const [client, context] = await Promise.all([
getLoggedInClient(),
getSupabaseContext(),
]);
Comment thread
sid597 marked this conversation as resolved.
Outdated
if (!client || !context)
throw new Error("Could not connect to shared persistence.");
setNodes(
await discoverSharedNodes({
client,
currentSpaceId: context.spaceId,
}),
);
} catch (loadError) {
console.error("Failed to discover shared nodes:", loadError);
setError(
loadError instanceof Error
? loadError.message
: "Could not load shared nodes.",
);
} finally {
setLoading(false);
}
}, []);

useEffect(() => {
void loadNodes();
}, [loadNodes]);

const visibleNodes = useMemo(() => {
const normalizedSearch = search.trim().toLocaleLowerCase();
if (!normalizedSearch) return nodes;
return nodes.filter((node) =>
[
node.sourceApp,
node.sourceSpaceName,
node.sourceSpaceId,
node.title,
node.sourceNodeId,
].some((value) => value?.toLocaleLowerCase().includes(normalizedSearch)),
);
}, [nodes, search]);

return (
<Dialog
autoFocus={false}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Roam's command palette dispatches commands on Enter keyup. Without autoFocus={false} that keyup lands as a click on the freshly-focused close button and dismisses the dialog immediately. Same props as AdvancedSearchDialog.

canEscapeKeyClose
canOutsideClickClose
enforceFocus={false}
style={{ width: "min(68rem, calc(100vw - 2rem))" }}
isOpen
onClose={onClose}
title="Discover shared nodes"
>
<div
className={[Classes.DIALOG_BODY, "flex min-h-72 flex-col gap-3"].join(
" ",
)}
>
<div className="flex items-center gap-2">
<InputGroup
className="min-w-0 flex-1"
leftIcon="search"
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
setSearch(event.target.value)
}
placeholder="Search shared nodes"
value={search}
/>
<Tooltip content="Reload shared nodes">
<Button
aria-label="Reload shared nodes"
disabled={loading}
icon="refresh"
minimal
onClick={() => void loadNodes()}
/>
</Tooltip>
</div>

{loading ? (
<div className="flex min-h-52 items-center justify-center">
<Spinner />
</div>
) : error ? (
<Callout intent={Intent.DANGER} title="Could not load shared nodes">
<div className="mb-3">{error}</div>
<Button icon="refresh" onClick={() => void loadNodes()}>
Try again
</Button>
</Callout>
) : visibleNodes.length === 0 ? (
<div className="flex min-h-52 items-center justify-center">
<NonIdealState
icon="search"
title={search ? "No matching shared nodes" : "No shared nodes"}
/>
</div>
) : (
<div className="min-h-0 overflow-auto">
<HTMLTable striped className="w-full">
<thead>
<tr>
<th>Source app</th>

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This stays intentionally read-only: these columns match ENG-1855's pre-import metadata, while materialization remains in ENG-1859.

<th>Source space</th>
<th>Title</th>
<th>Source ID</th>
<th>Modified</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{visibleNodes.map((node) => (
<SharedNodeRow key={node.sourceNodeRid} node={node} />
))}
</tbody>
</HTMLTable>
</div>
)}
</div>
<div className={Classes.DIALOG_FOOTER}>
<div className="flex items-center justify-between">
<span className={[Classes.TEXT_MUTED, "text-xs"].join(" ")}>
{loading || error
? ""
: `${visibleNodes.length} of ${nodes.length} nodes`}
</span>
<Button onClick={onClose}>Close</Button>
</div>
</div>
</Dialog>
);
};

type Props = Record<string, never>;

export const renderDiscoverSharedNodesDialog = createOverlayRender<Props>(
"discourse-discover-shared-nodes",
DiscoverSharedNodesDialog,
);

export default DiscoverSharedNodesDialog;
163 changes: 163 additions & 0 deletions apps/roam/src/utils/__tests__/discoverSharedNodes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import { describe, expect, it } from "vitest";
import { buildDiscoveredSharedNodes } from "~/utils/discoverSharedNodes";

type BuildArgs = Parameters<typeof buildDiscoveredSharedNodes>[0];

const resources: BuildArgs["resources"] = [
{ space_id: 20, source_local_id: "node-1" },
{ space_id: 20, source_local_id: "schema-1" },
];
const spaces: BuildArgs["spaces"] = [
{
id: 20,
name: "Research vault",
platform: "Obsidian",
url: "obsidian:vault-a",
},
];
const concepts: BuildArgs["concepts"] = [
{
is_schema: false,
last_modified: "2026-06-14T12:00:00.000Z",
schema_id: 200,
source_local_id: "node-1",
space_id: 20,
},
];
const contents: BuildArgs["contents"] = [
{
content_type: "text/plain",
last_modified: "2026-06-14T13:00:00.000Z",
source_local_id: "node-1",
space_id: 20,
text: "EVD - REM sleep and recall",
variant: "direct",
},
{
content_type: "text/markdown",
last_modified: "2026-06-14T15:00:00.000Z",
source_local_id: "node-1",
space_id: 20,
text: "# EVD - REM sleep and recall",
variant: "full",
},
];
const sourceNodeRid = "orn:obsidian.note:vault-a/node-1";

const build = ({
conceptsOverride = concepts,
contentsOverride = contents,
currentSpaceId = 10,
importedSourceRids = new Set<string>(),
resourcesOverride = resources,
spacesOverride = spaces,
}: {
conceptsOverride?: typeof concepts;
contentsOverride?: typeof contents;
currentSpaceId?: number;
importedSourceRids?: ReadonlySet<string>;
resourcesOverride?: typeof resources;
spacesOverride?: typeof spaces;
} = {}) =>
buildDiscoveredSharedNodes({
concepts: conceptsOverride,
contents: contentsOverride,
currentSpaceId,
importedSourceRids,
resources: resourcesOverride,
spaces: spacesOverride,
});

describe("buildDiscoveredSharedNodes", () => {
it("builds a group-shared contract node with stable source identity", () => {
expect(build({ importedSourceRids: new Set([sourceNodeRid]) })).toEqual([
{
alreadyImported: true,
modifiedAt: "2026-06-14T15:00:00.000Z",
sourceApp: "Obsidian",
sourceNodeId: "node-1",
sourceNodeRid,
sourceSpaceId: "obsidian:vault-a",
sourceSpaceName: "Research vault",
title: "EVD - REM sleep and recall",
},
]);
});

it("does not discover shared resources from the current space", () => {
expect(build({ currentSpaceId: 20 })).toEqual([]);
});

it("requires the exact shared resource identity", () => {
expect(
build({
resourcesOverride: [{ space_id: 21, source_local_id: "node-1" }],
}),
).toEqual([]);
});

it.each([
{
name: "schema concept",
conceptsOverride: [{ ...concepts[0], is_schema: true }],
contentsOverride: contents,
},
{
name: "missing node type",
conceptsOverride: [{ ...concepts[0], schema_id: null }],
contentsOverride: contents,
},
{
name: "missing direct content",
conceptsOverride: concepts,
contentsOverride: [contents[1]],
},
{
name: "missing full content",
conceptsOverride: concepts,
contentsOverride: [contents[0]],
},
{
name: "untyped full content",
conceptsOverride: concepts,
contentsOverride: [contents[0], { ...contents[1], content_type: null }],
},
])("filters a node with $name", ({ conceptsOverride, contentsOverride }) => {
expect(build({ conceptsOverride, contentsOverride })).toEqual([]);
});

it("matches imports by RID rather than source-local ID alone", () => {
expect(
build({
importedSourceRids: new Set(["orn:obsidian.note:another-vault/node-1"]),
})[0]?.alreadyImported,
).toBe(false);
});

it("sorts newest nodes first", () => {
const olderConcept = {
...concepts[0],
last_modified: "2026-06-10T12:00:00.000Z",
source_local_id: "node-2",
};
const olderContents = contents.map((content) => ({
...content,
last_modified: "2026-06-10T12:00:00.000Z",
source_local_id: "node-2",
text:
content.variant === "direct"
? "Older shared node"
: "# Older shared node",
}));
expect(
build({
conceptsOverride: [olderConcept, concepts[0]],
contentsOverride: [...olderContents, ...contents],
resourcesOverride: [
...resources,
{ space_id: 20, source_local_id: "node-2" },
],
}).map((node) => node.sourceNodeId),
).toEqual(["node-1", "node-2"]);
});
});
Loading