diff --git a/.changeset/share-button-root-fallback.md b/.changeset/share-button-root-fallback.md
new file mode 100644
index 000000000..5d47af31f
--- /dev/null
+++ b/.changeset/share-button-root-fallback.md
@@ -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.
diff --git a/packages/app/src/components/EditorHeader.dom.test.tsx b/packages/app/src/components/EditorHeader.dom.test.tsx
index 8395dddd8..f8670e519 100644
--- a/packages/app/src/components/EditorHeader.dom.test.tsx
+++ b/packages/app/src/components/EditorHeader.dom.test.tsx
@@ -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 }),
@@ -38,7 +39,14 @@ mock.module('./EditorTabs', () => ({
}));
mock.module('./ShareButton', () => ({
- ShareButton: () => ,
+ ShareButton: ({ input }: { input: unknown }) => {
+ lastShareInput = input;
+ return (
+
+ );
+ },
}));
mock.module('./PublishToGitHubDialog', () => ({
@@ -92,6 +100,7 @@ describe('EditorHeader runtime behavior', () => {
activeTarget = { kind: 'doc' };
sidebarState = 'expanded';
isDraggingRail = false;
+ lastShareInput = undefined;
});
test('exports the EditorHeader component', async () => {
@@ -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();
+ });
});
diff --git a/packages/app/src/components/EditorHeader.tsx b/packages/app/src/components/EditorHeader.tsx
index d190b73b3..d15937c35 100644
--- a/packages/app/src/components/EditorHeader.tsx
+++ b/packages/app/src/components/EditorHeader.tsx
@@ -46,6 +46,9 @@ export function EditorHeader({ onSignIn, onSetIdentity, onOpenSearch }: EditorHe
if (activeDocName && !managedArtifact) {
return buildDocShareInput(activeDocName);
}
+ if (!activeTarget && !activeDocName) {
+ return buildFolderShareInput('');
+ }
return null;
})();
diff --git a/packages/app/src/components/FileSidebar.share.dom.test.tsx b/packages/app/src/components/FileSidebar.share.dom.test.tsx
new file mode 100644
index 000000000..44e2ec51b
--- /dev/null
+++ b/packages/app/src/components/FileSidebar.share.dom.test.tsx
@@ -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
{children}
;
+}
+
+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 (
+
+ );
+}
+
+mock.module('@/lib/perf', () => ({ ProfilerBoundary: PassThrough }));
+
+mock.module('@/components/FileTree', () => ({
+ FileTree: () => ,
+}));
+
+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) => (
+ {children as ReactNode}
+ ),
+ 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: () =>
,
+ 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: () => ,
+ 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( {}} />);
+ 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( {}} />);
+
+ 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( {}} />);
+
+ await screen.findByTestId('empty-space-menu-new-file');
+ expect(screen.queryByTestId('empty-space-menu-share')).toBeNull();
+ });
+});
diff --git a/packages/app/src/components/FileSidebar.tsx b/packages/app/src/components/FileSidebar.tsx
index 073c0115c..9c054f846 100644
--- a/packages/app/src/components/FileSidebar.tsx
+++ b/packages/app/src/components/FileSidebar.tsx
@@ -8,6 +8,7 @@ import {
FolderPlus,
FoldVertical,
ListCollapse,
+ Share2,
SquarePen,
UnfoldVertical,
} from 'lucide-react';
@@ -62,6 +63,7 @@ import {
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { useDocumentContext } from '@/editor/DocumentContext';
import { useFolderConfig } from '@/hooks/use-folder-config';
+import { useGitSyncStatusDetailed } from '@/hooks/use-git-sync-status';
import { useIsEmbedded } from '@/hooks/use-is-embedded';
import { useConfigContext } from '@/lib/config-provider';
import { subscribeToCreateTopLevelFile } from '@/lib/create-file-events';
@@ -77,6 +79,8 @@ import {
} from '@/lib/file-tree-menu-action-events';
import { VISIBLE_TARGETS } from '@/lib/handoff/targets';
import { ProfilerBoundary } from '@/lib/perf';
+import { scheduleClipboardWrite } from '@/lib/share/clipboard-adapter';
+import { buildFolderShareInput, runShareAction } from '@/lib/share/run-share-action';
import { useWorkspace } from '@/lib/use-workspace';
import { cn } from '@/lib/utils';
@@ -184,6 +188,8 @@ function FileSidebarInner({ onOpenSearch }: FileSidebarProps) {
const bridge = typeof window !== 'undefined' ? window.okDesktop : undefined;
const workspace = useWorkspace();
+ const { status: gitSyncStatus } = useGitSyncStatusDetailed();
+ const hasRemote = gitSyncStatus?.hasRemote === true;
const projectName =
bridge?.config?.projectName ||
workspace?.contentDir.split('/').filter(Boolean).pop() ||
@@ -198,6 +204,9 @@ function FileSidebarInner({ onOpenSearch }: FileSidebarProps) {
const showEmptySpaceTreeStateSection = showEmptySpaceExpandAll || showEmptySpaceCollapseAll;
const handleSidebarSurfaceContextMenu: MouseEventHandler = (event) => {
+ if (event.target instanceof Element && event.target.closest('[data-sidebar-root-context]')) {
+ return;
+ }
if (isInteractiveSidebarControl(event.target)) {
event.preventDefault();
event.stopPropagation();
@@ -230,6 +239,23 @@ function FileSidebarInner({ onOpenSearch }: FileSidebarProps) {
toast.error(t`Could not copy full path`);
}
};
+ const handleEmptySpaceShare = () => {
+ void runShareAction(
+ {
+ ...buildFolderShareInput(''),
+ hasRemote,
+ onClickWhenNoRemote: () => {
+ toast.error(t`Connect this project to GitHub to share.`);
+ },
+ },
+ {
+ clipboardWrite: scheduleClipboardWrite,
+ toastSuccess: (msg) => toast.success(msg),
+ toastError: (msg) => toast.error(msg),
+ logEvent: (msg) => console.log(msg),
+ },
+ );
+ };
const handleEmptySpaceShowHiddenFilesToggle = (checked: boolean) => {
if (projectLocalBinding === null) return;
const result = projectLocalBinding.patch({
@@ -604,7 +630,10 @@ function FileSidebarInner({ onOpenSearch }: FileSidebarProps) {
headers + their content share the same gutter alignment. */}
-
+
{projectName}
@@ -756,6 +785,12 @@ function FileSidebarInner({ onOpenSearch }: FileSidebarProps) {
installStates={handoffInstallStates}
dispatch={dispatchHandoff}
/>
+ {hasRemote ? (
+
+
+ Share
+
+ ) : null}
{
+ lastShareInput = input;
+ return { kind: 'copied' as const, shareUrl: 'https://example.test/x', branch: 'main' };
+});
+
+type MenuItemProps = {
+ children?: ReactNode;
+ checked?: boolean;
+ disabled?: boolean;
+ onCheckedChange?: (checked: boolean) => void;
+ onSelect?: () => void;
+ [key: string]: unknown;
+};
+
+function PassThrough({ children }: { children?: ReactNode }) {
+ return <>{children}>;
+}
+
+function MenuItem({
+ children,
+ checked,
+ disabled,
+ onCheckedChange,
+ onSelect,
+ variant: _variant,
+ ...props
+}: MenuItemProps) {
+ const handleClick = () => {
+ onCheckedChange?.(!checked);
+ onSelect?.();
+ };
+ if (checked !== undefined) {
+ return (
+
+ );
+ }
+ return (
+
+ );
+}
+
+function MenuContent({ children }: { children?: ReactNode }) {
+ return {children}
;
+}
+
+function MenuSeparator() {
+ return
;
+}
+
+const toastSuccessMock = mock(() => {});
+const toastErrorMock = mock(() => {});
+
+const DOCUMENTS: FileEntry[] = [
+ { kind: 'folder', path: 'notes', size: 0, modified: '2026-05-18T00:00:00.000Z' },
+ {
+ kind: 'document',
+ docName: 'notes/source',
+ docExt: '.mdx',
+ size: 1,
+ modified: '2026-05-18T00:00:00.000Z',
+ },
+ {
+ kind: 'asset',
+ path: 'images/logo.png',
+ assetExt: '.png',
+ mediaKind: 'image',
+ referencedBy: ['notes/source'],
+ size: 1,
+ modified: '2026-05-18T00:00:00.000Z',
+ } as FileEntry,
+];
+
+class StubItem {
+ expanded = false;
+ selected = false;
+ constructor(
+ readonly path: string,
+ private readonly directory: boolean,
+ ) {}
+ getPath() {
+ return this.path;
+ }
+ isDirectory() {
+ return this.directory;
+ }
+ isExpanded() {
+ return this.expanded;
+ }
+ expand() {
+ this.expanded = true;
+ }
+ collapse() {
+ this.expanded = false;
+ }
+ isSelected() {
+ return this.selected;
+ }
+ select() {
+ this.selected = true;
+ }
+ deselect() {
+ this.selected = false;
+ }
+ focus() {}
+}
+
+class StubModel {
+ focusedPath: string | null = null;
+ selectedPaths: string[] = [];
+ items = new Map();
+ startRenaming = mock(() => {});
+ getFocusedPath() {
+ return this.focusedPath;
+ }
+ getFocusedIndex() {
+ return -1;
+ }
+ getItemHeight() {
+ return 24;
+ }
+ getSelectedPaths() {
+ return this.selectedPaths;
+ }
+ getItem(path: string) {
+ return this.items.get(path) ?? null;
+ }
+ resetPaths(paths: string[]) {
+ this.items.clear();
+ for (const path of paths) {
+ this.items.set(path, new StubItem(path, path.endsWith('/')));
+ }
+ }
+ add(path: string) {
+ this.items.set(path, new StubItem(path, path.endsWith('/')));
+ }
+ move() {}
+ remove() {}
+ subscribe() {
+ return () => {};
+ }
+ onMutation() {
+ return () => {};
+ }
+ isSearchOpen() {
+ return false;
+ }
+}
+
+let model = new StubModel();
+let menuItem: { kind: 'file' | 'directory'; path: string };
+let closeMenuMock = mock(() => {});
+
+function jsonResponse(body: unknown, status = 200): Response {
+ return new Response(JSON.stringify(body), {
+ status,
+ headers: { 'content-type': 'application/json' },
+ });
+}
+
+function makeFetchMock() {
+ return mock(async (input: RequestInfo | URL) => {
+ const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
+ if (url.startsWith('/api/documents')) return jsonResponse({ documents: DOCUMENTS });
+ if (url === '/api/workspace') {
+ return jsonResponse({
+ contentDir: '/tmp/open-knowledge',
+ pathSeparator: '/',
+ symlinkResolved: true,
+ });
+ }
+ return jsonResponse({});
+ });
+}
+
+mock.module('sonner', () => ({
+ toast: { success: toastSuccessMock, error: toastErrorMock },
+}));
+
+mock.module('next-themes', () => ({
+ useTheme: () => ({ resolvedTheme: 'light' }),
+}));
+
+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', () => ({
+ buildDocShareInput: (docName: string) => ({ kind: 'doc', docName }),
+ buildFolderShareInput: (folderRelativePath: string) => ({ kind: 'folder', folderRelativePath }),
+ runShareAction: runShareActionMock,
+}));
+
+mock.module('@/editor/DocumentContext', () => ({
+ useDocumentContext: () => ({
+ activeDocName: 'notes/source',
+ activeTarget: { kind: 'doc', target: 'notes/source', docName: 'notes/source' },
+ closeTabs: () => {},
+ closeDocument: () => {},
+ closeAndClearDocument: async () => {},
+ closeAndClearForDelete: async () => {},
+ closeAndClearForRename: async () => {},
+ getPoolActiveDocName: () => 'notes/source',
+ poolHas: () => true,
+ isNewTabActive: false,
+ openTarget: () => {},
+ prewarm: () => {},
+ remapTabsForRename: () => {},
+ }),
+}));
+
+mock.module('@/components/PageListContext', () => ({
+ usePageList: () => ({ addPage: () => {}, pageMeta: new Map() }),
+}));
+
+mock.module('./ui/sidebar', () => ({
+ useSidebar: () => ({ notifySidebarFileSelected: () => {} }),
+}));
+
+mock.module('@/lib/config-provider', () => ({
+ useConfigContext: () => ({ okignoreBinding: null, projectLocalBinding: null, merged: null }),
+}));
+
+mock.module('./handoff/useInstalledAgents', () => ({
+ useInstalledAgents: () => ({ states: {} }),
+}));
+
+mock.module('./handoff/useHandoffDispatch', () => ({
+ buildFolderHandoffInput: () => null,
+ buildHandoffInput: () => null,
+ useHandoffDispatch: () => ({ dispatch: async () => ({ ok: true as const }) }),
+}));
+
+mock.module('./handoff/OpenInAgentContextSubmenu', () => ({
+ OpenInAgentContextSubmenu: () => (
+
+ ),
+}));
+
+mock.module('./sidebar-hover-prewarm', () => ({
+ cancelHoverPrewarm: () => {},
+ scheduleHoverPrewarm: () => {},
+}));
+
+mock.module('@/components/ui/button', () => ({
+ Button: ({ children, ...props }: { children?: ReactNode; [key: string]: unknown }) => (
+
+ ),
+}));
+
+mock.module('@/components/ui/dialog', () => ({ Dialog: PassThrough }));
+
+mock.module('@/components/ui/dropdown-menu', () => ({
+ DropdownMenu: PassThrough,
+ DropdownMenuCheckboxItem: MenuItem,
+ DropdownMenuContent: MenuContent,
+ DropdownMenuItem: MenuItem,
+ DropdownMenuSeparator: MenuSeparator,
+ DropdownMenuSub: PassThrough,
+ DropdownMenuSubContent: MenuContent,
+ DropdownMenuSubTrigger: MenuItem,
+ DropdownMenuTrigger: PassThrough,
+}));
+
+mock.module('@/components/ui/skeleton', () => ({
+ Skeleton: ({ className }: { className?: string }) => ,
+}));
+
+mock.module('@/components/DeleteConfirmationDialog', () => ({
+ DeleteConfirmationDialog: () => null,
+}));
+
+mock.module('@/components/NewItemDialog', () => ({ NewItemDialog: () => null }));
+
+mock.module('@/components/TrashFailureModal', () => ({
+ TrashFailureModal: () => null,
+ coerceTrashFailureReason: (reason: string) => reason,
+}));
+
+mock.module('@/components/use-selection-mirror', () => ({
+ asDirectoryHandle: (item: StubItem | null) => (item?.isDirectory() ? item : null),
+ useSelectionMirror: () => {},
+}));
+
+mock.module('@pierre/trees', () => ({
+ FILE_TREE_TAG_NAME: 'ok-file-tree',
+ themeToTreeStyles: () => ({}),
+}));
+
+mock.module('@pierre/trees/react', () => ({
+ useFileTree: () => ({ model }),
+ FileTree: ({
+ renderContextMenu,
+ onClickCapture,
+ onMouseMove,
+ onMouseLeave,
+ }: {
+ renderContextMenu?: (
+ item: typeof menuItem,
+ context: { close: typeof closeMenuMock },
+ ) => ReactNode;
+ onClickCapture?: MouseEventHandler;
+ onMouseMove?: MouseEventHandler;
+ onMouseLeave?: MouseEventHandler;
+ }) => (
+
+ {renderContextMenu?.(menuItem, { close: closeMenuMock })}
+
+ ),
+}));
+
+const { FileTree } = await import('./FileTree');
+
+function renderFileTree() {
+ return render();
+}
+
+describe('FileTree context-menu Share action', () => {
+ let consoleLogSpy: ReturnType;
+
+ beforeEach(() => {
+ model = new StubModel();
+ menuItem = { kind: 'file', path: 'notes/source.mdx' };
+ closeMenuMock = mock(() => {});
+ hasRemote = true;
+ lastShareInput = undefined;
+ globalThis.fetch = makeFetchMock() as unknown as typeof fetch;
+ runShareActionMock.mockClear();
+ toastSuccessMock.mockClear();
+ toastErrorMock.mockClear();
+ consoleLogSpy = spyOn(console, 'log').mockImplementation(() => {});
+ });
+
+ afterEach(() => {
+ cleanup();
+ consoleLogSpy.mockRestore();
+ });
+
+ test('a doc row shows Share and dispatches a doc-scope share input', async () => {
+ const user = userEvent.setup();
+ renderFileTree();
+
+ const share = await screen.findByTestId('file-tree-menu-share');
+ expect(share.querySelector('svg[aria-hidden="true"]')).not.toBeNull();
+
+ await user.click(share);
+
+ expect(closeMenuMock).toHaveBeenCalled();
+ await waitFor(() => expect(runShareActionMock).toHaveBeenCalledTimes(1));
+ expect(lastShareInput).toMatchObject({ kind: 'doc', docName: 'notes/source', hasRemote: true });
+ });
+
+ test('a folder row dispatches a folder-scope share input', async () => {
+ menuItem = { kind: 'directory', path: 'notes/' };
+ const user = userEvent.setup();
+ renderFileTree();
+
+ await user.click(await screen.findByTestId('file-tree-menu-share'));
+
+ await waitFor(() => expect(runShareActionMock).toHaveBeenCalledTimes(1));
+ expect(lastShareInput).toMatchObject({ kind: 'folder', folderRelativePath: 'notes' });
+ });
+
+ test('an asset row does not show Share (no shareable doc path)', async () => {
+ menuItem = { kind: 'file', path: 'images/logo.png' };
+ renderFileTree();
+
+ await screen.findByText('Copy path');
+ expect(screen.queryByTestId('file-tree-menu-share')).toBeNull();
+ });
+
+ test('Share is hidden when the project has no GitHub remote', async () => {
+ hasRemote = false;
+ renderFileTree();
+
+ await screen.findByText('Copy path');
+ expect(screen.queryByTestId('file-tree-menu-share')).toBeNull();
+ });
+});
diff --git a/packages/app/src/components/FileTree.tsx b/packages/app/src/components/FileTree.tsx
index 7842bd291..d204533a8 100644
--- a/packages/app/src/components/FileTree.tsx
+++ b/packages/app/src/components/FileTree.tsx
@@ -39,6 +39,7 @@ import {
Info,
Pencil,
RefreshCw,
+ Share2,
SquarePen,
Trash2,
TriangleAlert,
@@ -179,6 +180,7 @@ import { captureRenameSnapshots } from '@/editor/editor-cache';
import { assetTabId, docTabId, folderTabId, remapPathForFolderRenames } from '@/editor/editor-tabs';
import { useConflicts } from '@/hooks/use-conflicts';
import { useFolderConfig } from '@/hooks/use-folder-config';
+import { useGitSyncStatusDetailed } from '@/hooks/use-git-sync-status';
import { useConfigContext } from '@/lib/config-provider';
import {
hashFromAssetPath,
@@ -195,6 +197,13 @@ import {
import { parseServerResponse, parseSuccessOrWarn } from '@/lib/parse-server-response';
import { createRefreshScheduler } from '@/lib/refresh-scheduler';
import { getRelaunchInFlightSnapshot, useRelaunchInFlight } from '@/lib/relaunch-store';
+import { scheduleClipboardWrite } from '@/lib/share/clipboard-adapter';
+import {
+ buildDocShareInput,
+ buildFolderShareInput,
+ runShareAction,
+ type ShareTargetInput,
+} from '@/lib/share/run-share-action';
import {
consumeShowAllStream,
isNdjsonResponse,
@@ -656,6 +665,46 @@ function FileTreeMenu({
const closeForInlineSurface = () => context.close({ restoreFocus: false });
const close = () => context.close();
+ const { status: gitSyncStatus } = useGitSyncStatusDetailed();
+ const hasRemote = gitSyncStatus?.hasRemote === true;
+ const shareInput: ShareTargetInput | null =
+ isAsset || target.kind === 'asset'
+ ? null
+ : isFolder
+ ? buildFolderShareInput(treeDirectoryPathToFolderPath(item.path))
+ : buildDocShareInput(treeFilePathToDocName(item.path));
+ const canShare = hasRemote && shareInput !== null;
+ const handleShare = () => {
+ if (!shareInput) return;
+ void runShareAction(
+ {
+ ...shareInput,
+ hasRemote,
+ onClickWhenNoRemote: () => {
+ toast.error(t`Connect this project to GitHub to share.`);
+ },
+ },
+ {
+ clipboardWrite: scheduleClipboardWrite,
+ toastSuccess: (msg) => toast.success(msg),
+ toastError: (msg) => toast.error(msg),
+ logEvent: (msg) => console.log(msg),
+ },
+ );
+ };
+ const shareMenuItem = canShare ? (
+ {
+ close();
+ handleShare();
+ }}
+ >
+
+ Share
+
+ ) : null;
+
const handleShowHiddenFilesToggle = (checked: boolean) => {
if (projectLocalBinding === null) return;
const result = projectLocalBinding.patch({
@@ -753,6 +802,7 @@ function FileTreeMenu({
isElectronHost={handoff.isElectronHost}
dispatch={handoff.dispatch}
/>
+ {shareMenuItem}
@@ -892,6 +942,7 @@ function FileTreeMenu({
dispatch={handoff.dispatch}
/>
)}
+ {shareMenuItem}
diff --git a/packages/app/src/locales/en/messages.json b/packages/app/src/locales/en/messages.json
index b6d8ef281..662902dec 100644
--- a/packages/app/src/locales/en/messages.json
+++ b/packages/app/src/locales/en/messages.json
@@ -1145,6 +1145,7 @@
"WqLq6M": ["Open with AI ", ["displayName"]],
"Wt5W56": ["Sync is off — your edits will not sync to the remote repository."],
"Wtw22s": ["Installing"],
+ "WyQxwa": ["Connect this project to GitHub to share."],
"WyXG7w": ["Close all"],
"Wyxs0x": ["Path cannot contain \"..\""],
"X0ttSN": ["No tags match \"", ["tagListQuery"], "\"."],
diff --git a/packages/app/src/locales/en/messages.po b/packages/app/src/locales/en/messages.po
index 911c4280b..ab4dec821 100644
--- a/packages/app/src/locales/en/messages.po
+++ b/packages/app/src/locales/en/messages.po
@@ -1354,6 +1354,11 @@ msgstr "Connect GitHub to continue."
msgid "Connect OpenKnowledge to other tools you use."
msgstr "Connect OpenKnowledge to other tools you use."
+#: src/components/FileSidebar.tsx
+#: src/components/FileTree.tsx
+msgid "Connect this project to GitHub to share."
+msgstr "Connect this project to GitHub to share."
+
#: src/components/ConsentDialogBody.tsx
#: src/components/CreateProjectDialog.tsx
msgid "Connect to AI tools"
@@ -5553,6 +5558,8 @@ msgstr "Setup files include: <0>.ok/0>, AI-tool MCP configs (<1>.mcp.json1>
msgid "Setup OpenKnowledge in this folder?"
msgstr "Setup OpenKnowledge in this folder?"
+#: src/components/FileSidebar.tsx
+#: src/components/FileTree.tsx
#: src/components/ShareButton.tsx
msgid "Share"
msgstr "Share"
diff --git a/packages/app/src/locales/pseudo/messages.json b/packages/app/src/locales/pseudo/messages.json
index 9e796e527..99aeea713 100644
--- a/packages/app/src/locales/pseudo/messages.json
+++ b/packages/app/src/locales/pseudo/messages.json
@@ -1145,6 +1145,7 @@
"WqLq6M": ["Ōƥēń ŵĩţĥ ÀĨ ", ["displayName"]],
"Wt5W56": ["Śŷńć ĩś ōƒƒ — ŷōũŕ ēďĩţś ŵĩĺĺ ńōţ śŷńć ţō ţĥē ŕēḿōţē ŕēƥōśĩţōŕŷ."],
"Wtw22s": ["Ĩńśţàĺĺĩńĝ"],
+ "WyQxwa": ["Ćōńńēćţ ţĥĩś ƥŕōĴēćţ ţō ĜĩţĤũƀ ţō śĥàŕē."],
"WyXG7w": ["Ćĺōśē àĺĺ"],
"Wyxs0x": ["Ƥàţĥ ćàńńōţ ćōńţàĩń \"..\""],
"X0ttSN": ["Ńō ţàĝś ḿàţćĥ \"", ["tagListQuery"], "\"."],
diff --git a/packages/app/src/locales/pseudo/messages.po b/packages/app/src/locales/pseudo/messages.po
index 677443900..3e1e93599 100644
--- a/packages/app/src/locales/pseudo/messages.po
+++ b/packages/app/src/locales/pseudo/messages.po
@@ -1349,6 +1349,11 @@ msgstr ""
msgid "Connect OpenKnowledge to other tools you use."
msgstr ""
+#: src/components/FileSidebar.tsx
+#: src/components/FileTree.tsx
+msgid "Connect this project to GitHub to share."
+msgstr ""
+
#: src/components/ConsentDialogBody.tsx
#: src/components/CreateProjectDialog.tsx
msgid "Connect to AI tools"
@@ -5548,6 +5553,8 @@ msgstr ""
msgid "Setup OpenKnowledge in this folder?"
msgstr ""
+#: src/components/FileSidebar.tsx
+#: src/components/FileTree.tsx
#: src/components/ShareButton.tsx
msgid "Share"
msgstr ""