Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
5 changes: 5 additions & 0 deletions .changeset/graph-external-link-os-browser.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@inkeep/open-knowledge-app": patch
---

External URL nodes in the graph now open in your default OS browser on the desktop app, instead of a new in-app Open Knowledge window. Clicking an external node (or its "Open link" button in the graph side panel) routes through the desktop bridge's `openExternal`, so the link lands in your system browser the same way external links already open elsewhere in the editor. On the web build the behavior is unchanged (a new browser tab). Previously these three graph call sites used a raw `window.open`, which Electron turns into a new BrowserWindow rather than handing off to the OS.
3 changes: 2 additions & 1 deletion packages/app/src/components/GraphPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import {
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { hashFromDocName } from '@/lib/doc-hash';
import { openExternalUrl } from '@/lib/external-link';
import { cn } from '@/lib/utils';

const FULLSCREEN_HUB_LIMIT = 50;
Expand Down Expand Up @@ -390,7 +391,7 @@ export function GraphPanel({ activeDocName }: { activeDocName: string }) {
actionLabel: t`Open link`,
secondaryLabel: selectedNode.url,
onAction: () => {
window.open(selectedNode.url, '_blank', 'noopener,noreferrer');
openExternalUrl(selectedNode.url);
setIsExpanded(false);
},
};
Expand Down
5 changes: 3 additions & 2 deletions packages/app/src/components/GraphView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import ForceGraph2D, {
import { usePageList } from '@/components/PageListContext';
import { hashFromDocName } from '@/lib/doc-hash';
import { subscribeToDocumentsChanged } from '@/lib/documents-events';
import { openExternalUrl } from '@/lib/external-link';
import { cn } from '@/lib/utils';
import { clusterColor } from './graph-colors';
import {
Expand Down Expand Up @@ -413,7 +414,7 @@ function applyGraphNodeClick({
const action = resolveGraphNodeClickAction(node, docClickBehavior);

if (action.kind === 'external') {
window.open(action.url, '_blank', 'noopener,noreferrer');
openExternalUrl(action.url);
return;
}

Expand Down Expand Up @@ -1169,7 +1170,7 @@ export function GraphView({
showPointerCursor={(obj) => Boolean(obj && 'kind' in obj)}
onNodeClick={(node: NodeObject<GraphNode>) => {
if (node.kind === 'external') {
window.open(node.url, '_blank', 'noopener,noreferrer');
openExternalUrl(node.url);
return;
}
if (node.docName) {
Expand Down
32 changes: 32 additions & 0 deletions packages/app/src/lib/external-link.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { describe, expect, mock, test } from 'bun:test';
import { openExternalUrl } from './external-link.ts';

describe('openExternalUrl — Electron host', () => {
test('routes through okDesktop.shell.openExternal and does NOT open a new window', () => {
const openExternal = mock(async () => {});
const openWindow = mock(() => null);
openExternalUrl('https://youtube.com/watch?v=abc', {
okDesktop: { shell: { openExternal } },
openWindow,
});
expect(openExternal).toHaveBeenCalledTimes(1);
expect(openExternal).toHaveBeenCalledWith('https://youtube.com/watch?v=abc');
expect(openWindow).not.toHaveBeenCalled();
});
});

describe('openExternalUrl — web host (no bridge)', () => {
test('falls back to window.open with the new-tab + noopener features', () => {
const openWindow = mock(() => null);
openExternalUrl('https://example.com', { okDesktop: undefined, openWindow });
expect(openWindow).toHaveBeenCalledTimes(1);
expect(openWindow).toHaveBeenCalledWith('https://example.com', '_blank', 'noopener,noreferrer');
});

test('falls back to window.open when the bridge has no openExternal', () => {
const openWindow = mock(() => null);
openExternalUrl('https://example.com', { okDesktop: { shell: {} }, openWindow });
expect(openWindow).toHaveBeenCalledTimes(1);
expect(openWindow).toHaveBeenCalledWith('https://example.com', '_blank', 'noopener,noreferrer');
});
});
18 changes: 18 additions & 0 deletions packages/app/src/lib/external-link.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,21 @@ export function dispatchExternalLinkClick(e: { preventDefault: () => void }, url
e.preventDefault();
void openExternal(url);
}

interface OpenExternalUrlDeps {
readonly okDesktop?: { shell?: { openExternal?: (url: string) => Promise<void> } };
readonly openWindow?: (url: string, target: string, features: string) => unknown;
}

export function openExternalUrl(url: string, deps: OpenExternalUrlDeps = {}): void {
const globalBridge = typeof window !== 'undefined' ? window.okDesktop : undefined;
const okDesktop = 'okDesktop' in deps ? deps.okDesktop : globalBridge;
const openExternal = okDesktop?.shell?.openExternal;
if (openExternal) {
void openExternal(url);
return;
}
const globalOpen = typeof window !== 'undefined' ? window.open.bind(window) : undefined;
const openWindow = deps.openWindow ?? globalOpen;
openWindow?.(url, '_blank', 'noopener,noreferrer');
}