From dc1636f94694cdafc9579d5b5b5f5fdbbfa6030a Mon Sep 17 00:00:00 2001
From: miles-kt-inkeep <135626743+miles-kt-inkeep@users.noreply.github.com>
Date: Tue, 30 Jun 2026 12:50:41 -0400
Subject: [PATCH] feat(open-knowledge): make sharing reachable from empty
editor, file tree, and project root (#2286)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* fix(open-knowledge): share project root when nothing is selected
The editor-header Share trigger disabled whenever there was no active doc
and no selected folder, so a freshly opened project (or one with everything
deselected) had no way to share the project as a whole. Default the
genuinely-empty editor to the folder-scope share's empty-string root
sentinel so Share targets the content root instead of being a dead control.
Non-shareable surfaces (asset previews, skill-bundle files, missing docs,
managed skill/template tabs) keep the disabled trigger: each has a target or
synthetic doc name set and never falls through to the new root default.
* feat(open-knowledge): add Share to the file-tree right-click menu
Add a Share item to the file-tree context menu, alongside the editor-header
Share button. It builds the same share-link via runShareAction — folder rows
share the folder (content-relative folderPath), doc rows share the doc — and
copies the URL with a confirmation toast.
Gating mirrors the header's shareable-surface rules: shown only for folders
and real docs (never assets, which have no shareable doc path) and only when
a GitHub remote exists (useGitSyncStatusDetailed().hasRemote). No-remote
routing to the Publish wizard stays the header's job; the sidebar simply
hides the item, matching the "if sharing is enabled" intent. The rare
server-side no-remote divergence falls back to an explanatory toast.
* feat(open-knowledge): share the project root from the sidebar root menu
Right-clicking the project-root header row (the project name at the top of
the file tree) previously did nothing — it's a CollapsibleTrigger button, so
the sidebar's context-menu handler suppressed it as an interactive control.
Exempt the root header (data-sidebar-root-context) so the right-click falls
through to the existing project-scoped ContextMenu instead of being swallowed;
left-click still toggles the group.
Add a Share item to that project-root menu (the empty-space menu), gated on a
GitHub remote like the header button and file-tree rows. It shares the content
root via buildFolderShareInput('') and copies the link with a confirmation
toast, reusing runShareAction.
* refactor(open-knowledge): address share-menu review suggestions
Apply the actionable suggestions from the PR review:
- Hoist the duplicated Share menu-item JSX in FileTreeMenu into a single
shareMenuItem const rendered in both the folder and file branches, so the
item's icon/test-id/ordering has one source of truth.
- Move the sidebar empty-space Share item above "Copy full path" so the
project-root menu ordering (OpenInAgent -> Share -> Copy path) matches the
file-tree row menu.
- Add an EditorHeader test pinning the managed-artifact (skill/template)
share-disable path — a skill doc name yields a null share input — covering
the parseManagedArtifactName guard that was previously untested.
Deferred (out of scope): extracting a shared makeShareDeps/useShareDispatch
factory for the runShareAction wiring across FileTree, FileSidebar, and
ShareButton. Worth a focused follow-up since ShareButton's clipboard-error
toast suppression is a real variation to parameterize.
* chore(open-knowledge): trim verbose share-feature comments
Collapse the multi-line explanatory comments added for the share work down to
single descriptive lines across EditorHeader, FileTree, FileSidebar, and their
share tests. No behavior change.
---------
GitOrigin-RevId: 147efaf2d619c5e2afce6a9aff1a1b1046dfde52
---
.changeset/share-button-root-fallback.md | 11 +
.../src/components/EditorHeader.dom.test.tsx | 51 ++-
packages/app/src/components/EditorHeader.tsx | 3 +
.../components/FileSidebar.share.dom.test.tsx | 235 ++++++++++
packages/app/src/components/FileSidebar.tsx | 37 +-
.../components/FileTree.share.dom.test.tsx | 415 ++++++++++++++++++
packages/app/src/components/FileTree.tsx | 51 +++
packages/app/src/locales/en/messages.json | 1 +
packages/app/src/locales/en/messages.po | 7 +
packages/app/src/locales/pseudo/messages.json | 1 +
packages/app/src/locales/pseudo/messages.po | 7 +
11 files changed, 817 insertions(+), 2 deletions(-)
create mode 100644 .changeset/share-button-root-fallback.md
create mode 100644 packages/app/src/components/FileSidebar.share.dom.test.tsx
create mode 100644 packages/app/src/components/FileTree.share.dom.test.tsx
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