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
11 changes: 11 additions & 0 deletions .changeset/share-button-root-fallback.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@inkeep/open-knowledge-app": patch
---

Make sharing reachable from more places.

Keep the editor-header Share button usable when nothing is open or selected. Previously the trigger was disabled whenever there was no active doc and no selected folder (a freshly opened project, or after deselecting everything), leaving no way to grab a link to the project as a whole. The genuinely-empty editor now defaults to sharing the content root via the folder-scope share's empty-string root sentinel, so Share is no longer a dead control just because nothing is focused. Non-shareable surfaces — asset previews, skill-bundle files, missing docs, and managed skill/template tabs — still keep the trigger disabled, since those have a target or synthetic doc name and never fall through to the root default.

Add a Share item to the file-tree right-click menu. It appears for folders and docs (never assets) and only when the project has a GitHub remote, reusing the same share-link path as the header button — folder rows share the folder, doc rows share the doc, and the link is copied to the clipboard with a confirmation toast.

Make the project-root context menu reachable. Right-clicking the project-root header row (the project name at the top of the sidebar) previously did nothing — it was suppressed as an interactive control. It now opens the existing project-scoped menu, which gains a Share item that shares the content root (and shows alongside New file/folder, Reveal, Copy path, etc.). Root Share is likewise gated on a GitHub remote.
51 changes: 50 additions & 1 deletion packages/app/src/components/EditorHeader.dom.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ let activeDocName: string | null = 'docs/notes';
let activeTarget: unknown = { kind: 'doc' };
let sidebarState: 'expanded' | 'collapsed' = 'expanded';
let isDraggingRail = false;
let lastShareInput: unknown;

mock.module('@/editor/DocumentContext', () => ({
useDocumentContext: () => ({ activeDocName, activeTarget }),
Expand All @@ -38,7 +39,14 @@ mock.module('./EditorTabs', () => ({
}));

mock.module('./ShareButton', () => ({
ShareButton: () => <button type="button">Share</button>,
ShareButton: ({ input }: { input: unknown }) => {
lastShareInput = input;
return (
<button type="button" disabled={input === null}>
Share
</button>
);
},
}));

mock.module('./PublishToGitHubDialog', () => ({
Expand Down Expand Up @@ -92,6 +100,7 @@ describe('EditorHeader runtime behavior', () => {
activeTarget = { kind: 'doc' };
sidebarState = 'expanded';
isDraggingRail = false;
lastShareInput = undefined;
});

test('exports the EditorHeader component', async () => {
Expand Down Expand Up @@ -166,4 +175,44 @@ describe('EditorHeader runtime behavior', () => {
expect(screen.queryByText('projectName')).toBeNull();
expect(screen.queryByText('assetFileName')).toBeNull();
});

test('an active doc yields a doc-scope share input', async () => {
activeDocName = 'docs/notes';
activeTarget = { kind: 'doc' };
await renderHeader();

expect(lastShareInput).toEqual({ kind: 'doc', docName: 'docs/notes' });
});

test('a selected folder yields a folder-scope share input', async () => {
activeDocName = null;
activeTarget = { kind: 'folder', folderPath: 'guides' };
await renderHeader();

expect(lastShareInput).toEqual({ kind: 'folder', folderRelativePath: 'guides' });
});

test('nothing open or selected defaults to sharing the project root', async () => {
activeDocName = null;
activeTarget = null;
await renderHeader();

expect(lastShareInput).toEqual({ kind: 'folder', folderRelativePath: '' });
});

test('a managed-artifact doc (skill/template) keeps the share trigger disabled', async () => {
activeDocName = '__skill__/project/my-skill';
activeTarget = { kind: 'doc' };
await renderHeader();

expect(lastShareInput).toBeNull();
});

test('a non-shareable asset target keeps the share trigger disabled', async () => {
activeDocName = null;
activeTarget = { kind: 'asset', assetPath: 'img/logo.png' };
await renderHeader();

expect(lastShareInput).toBeNull();
});
});
3 changes: 3 additions & 0 deletions packages/app/src/components/EditorHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ export function EditorHeader({ onSignIn, onSetIdentity, onOpenSearch }: EditorHe
if (activeDocName && !managedArtifact) {
return buildDocShareInput(activeDocName);
}
if (!activeTarget && !activeDocName) {
return buildFolderShareInput('');
}
return null;
})();

Expand Down
235 changes: 235 additions & 0 deletions packages/app/src/components/FileSidebar.share.dom.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
import { cleanup, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { ReactNode } from 'react';

let hasRemote = true;
let lastShareInput: unknown;
const runShareActionMock = mock(async (input: unknown) => {
lastShareInput = input;
return { kind: 'copied' as const, shareUrl: 'https://example.test/x', branch: 'main' };
});

function PassThrough({ children }: { children?: ReactNode }) {
return <>{children}</>;
}

function ElementPassThrough({
children,
asChild: _asChild,
...props
}: {
children?: ReactNode;
asChild?: boolean;
[key: string]: unknown;
}) {
return <div {...props}>{children}</div>;
}

function Button({
children,
asChild: _asChild,
onCheckedChange,
onSelect,
checked,
size: _size,
variant: _variant,
...props
}: {
children?: ReactNode;
asChild?: boolean;
onCheckedChange?: (checked: boolean) => void;
onSelect?: () => void;
checked?: boolean;
size?: unknown;
variant?: unknown;
[key: string]: unknown;
}) {
return (
<button
type="button"
onClick={() => {
onCheckedChange?.(!checked);
onSelect?.();
}}
{...props}
>
{children}
</button>
);
}

mock.module('@/lib/perf', () => ({ ProfilerBoundary: PassThrough }));

mock.module('@/components/FileTree', () => ({
FileTree: () => <div data-testid="file-tree-stub" />,
}));

mock.module('@/components/ConflictsSection', () => ({ ConflictsSection: () => null }));

mock.module('@/hooks/use-git-sync-status', () => ({
useGitSyncStatusDetailed: () => ({
status: { hasRemote, syncEnabled: hasRemote, behind: 0, ahead: 0 },
fetchError: null,
}),
useGitSyncStatus: () => ({ hasRemote, syncEnabled: hasRemote, behind: 0, ahead: 0 }),
}));

mock.module('@/lib/share/clipboard-adapter', () => ({
scheduleClipboardWrite: async () => {},
}));

mock.module('@/lib/share/run-share-action', () => ({
buildFolderShareInput: (folderRelativePath: string) => ({ kind: 'folder', folderRelativePath }),
runShareAction: runShareActionMock,
}));

mock.module('@/components/ui/button', () => ({ Button }));

mock.module('@/components/ui/collapsible', () => ({
Collapsible: ({ children, defaultOpen: _defaultOpen, ...props }: Record<string, unknown>) => (
<div {...props}>{children as ReactNode}</div>
),
CollapsibleContent: ElementPassThrough,
CollapsibleTrigger: Button,
}));

mock.module('@/components/ui/sidebar', () => ({
Sidebar: ElementPassThrough,
SidebarContent: ElementPassThrough,
SidebarFooter: ElementPassThrough,
SidebarHeader: ElementPassThrough,
SidebarMenu: ElementPassThrough,
SidebarMenuItem: ElementPassThrough,
SidebarGroup: ElementPassThrough,
SidebarGroupContent: ElementPassThrough,
SidebarGroupLabel: ElementPassThrough,
SidebarRail: () => null,
useSidebar: () => ({ state: 'expanded', toggleSidebar: () => {} }),
}));

mock.module('@/components/SkillsSidebarSection', () => ({
SkillsSidebarSection: () => null,
}));

mock.module('@/components/ui/context-menu', () => ({
ContextMenu: PassThrough,
ContextMenuCheckboxItem: Button,
ContextMenuContent: ElementPassThrough,
ContextMenuItem: Button,
ContextMenuSeparator: () => <hr />,
ContextMenuSub: PassThrough,
ContextMenuSubContent: ElementPassThrough,
ContextMenuSubTrigger: Button,
ContextMenuTrigger: PassThrough,
}));

mock.module('@/components/ui/dropdown-menu', () => ({
DropdownMenu: PassThrough,
DropdownMenuContent: ElementPassThrough,
DropdownMenuItem: Button,
DropdownMenuSeparator: () => null,
DropdownMenuTrigger: PassThrough,
}));

mock.module('@/components/ui/tooltip', () => ({
Tooltip: PassThrough,
TooltipContent: ElementPassThrough,
TooltipTrigger: PassThrough,
}));

mock.module('@/components/handoff/OpenInAgentEmptySpaceSubmenu', () => ({
OpenInAgentEmptySpaceSubmenu: () => null,
}));

mock.module('@/components/handoff/useHandoffDispatch', () => ({
buildFolderHandoffInput: () => null,
buildHandoffInput: () => null,
buildProjectScopedHandoffInput: () => ({ docContext: null, docPath: '', projectDir: '/tmp/ok' }),
useHandoffDispatch: () => ({ dispatch: async () => ({ ok: true as const }) }),
}));

mock.module('@/components/handoff/useInstalledAgents', () => ({
useInstalledAgents: () => ({ states: {} }),
}));

mock.module('@/components/ProjectSwitcher', () => ({ ProjectSwitcher: () => null }));

mock.module('@/components/SidebarSearchBar', () => ({
SidebarSearchBar: () => <button type="button">Search</button>,
onPillRenderError: () => {},
}));

mock.module('@/components/UpdateNotices', () => ({ UpdateNotices: () => null }));

mock.module('@/editor/DocumentContext', () => ({
useDocumentContext: () => ({
activeDocName: 'notes/source',
activeTarget: { kind: 'doc', target: 'notes/source', docName: 'notes/source' },
}),
}));

mock.module('@/hooks/use-folder-config', () => ({
useFolderConfig: () => ({
state: { status: 'ready', data: { folder: { templates_available: [] } } },
}),
}));

mock.module('@/lib/config-provider', () => ({
useConfigContext: () => ({
projectLocalBinding: { patch: () => ({ ok: true as const }) },
merged: { appearance: { sidebar: { showHiddenFiles: false } } },
}),
}));

mock.module('@/lib/use-workspace', () => ({
useWorkspace: () => ({ contentDir: '/tmp/open-knowledge', pathSeparator: '/' }),
}));

mock.module('sonner', () => ({
toast: { error: mock(() => {}), success: mock(() => {}) },
}));

const { FileSidebar } = await import('./FileSidebar');

describe('FileSidebar project-root Share', () => {
beforeEach(() => {
hasRemote = true;
lastShareInput = undefined;
runShareActionMock.mockClear();
Object.defineProperty(window, 'okDesktop', { configurable: true, value: undefined });
});

afterEach(() => {
cleanup();
});

test('the project-root header is marked so right-clicks open the project menu', async () => {
render(<FileSidebar onOpenSearch={() => {}} />);
const header = await screen.findByText('open-knowledge');
expect(header.closest('[data-sidebar-root-context]')).not.toBeNull();
});

test('empty-space menu shows Share and dispatches a root-scope share input', async () => {
const user = userEvent.setup();
render(<FileSidebar onOpenSearch={() => {}} />);

const share = await screen.findByTestId('empty-space-menu-share');
await user.click(share);

expect(runShareActionMock).toHaveBeenCalledTimes(1);
expect(lastShareInput).toMatchObject({
kind: 'folder',
folderRelativePath: '',
hasRemote: true,
});
});

test('Share is hidden when the project has no GitHub remote', async () => {
hasRemote = false;
render(<FileSidebar onOpenSearch={() => {}} />);

await screen.findByTestId('empty-space-menu-new-file');
expect(screen.queryByTestId('empty-space-menu-share')).toBeNull();
});
});
Loading