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
7 changes: 7 additions & 0 deletions .changeset/committed-autosync-default.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@inkeep/open-knowledge": patch
---

Add a committed, project-level auto-sync default (`autoSync.default`). A maintainer can now ship a project that opens quietly for collaborators: set **Settings → Sync → Default for everyone** to "Off by default" (or "On by default") and the choice is committed to `.ok/config.yml` and travels with the repo via git. New clones then skip the "enable auto-sync" onboarding prompt and open with sync in the chosen state.

`autoSync.default` is `true | false | null` (null/absent = ask, the previous behavior) and seeds each machine's `autoSync.enabled` on first open. It is a soft default — anyone can still change auto-sync for their own machine in Settings, which overrides the committed default for that machine only. The per-machine `autoSync.enabled` setting stays gitignored and per-machine.
2 changes: 2 additions & 0 deletions docs/content/features/github-sync.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ While sync is active, Open Knowledge:

Pulls can overwrite uncommitted local file changes, so commit or discard work-in-progress before you enable sync.

If you want to set a default auto-sync setting for future collaborators, go to the project settings page and navigate to the **Sync** -> **Shared default** section.

## Authentication

If you are logged in with the GitHub CLI, Open Knowledge will use those credentials for GitHub operations. If not, you can complete device authentication to generate an OAuth token. Open Knowledge stores this token in the keychain and reuses it for syncing, cloning, and publishing.
Expand Down
92 changes: 83 additions & 9 deletions packages/app/src/components/EditorPane.dom.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ mock.module('@lingui/react/macro', () => ({

let hasRemote = false;
let projectLocalSynced = false;
let projectSynced = false;
let projectLocalConfig: { autoSync?: { enabled?: boolean | null } } | null = null;
let projectConfig: { autoSync?: { default?: boolean | null } } | null = null;

mock.module('@/hooks/use-git-sync-status', () => ({
useGitSyncStatus: () => ({
Expand All @@ -24,7 +26,12 @@ mock.module('@/hooks/use-git-sync-status', () => ({
}));

mock.module('@/lib/config-provider', () => ({
useConfigContext: () => ({ projectLocalConfig, projectLocalSynced }),
useConfigContext: () => ({
projectConfig,
projectLocalConfig,
projectLocalSynced,
projectSynced,
}),
}));

mock.module('@/lib/use-workspace', () => ({
Expand Down Expand Up @@ -102,35 +109,100 @@ describe('EditorPane auto-sync onboarding gate', () => {
cleanup();
hasRemote = false;
projectLocalSynced = false;
projectSynced = false;
projectLocalConfig = null;
projectConfig = null;
});

test('exports the EditorPane component', async () => {
const mod = await import('./EditorPane');
expect(typeof mod.EditorPane).toBe('function');
});

test('opens only when remote exists, project-local config is synced, and autoSync.enabled is null', async () => {
test('opens when remote exists, both configs synced, enabled is null, and no committed default', async () => {
hasRemote = true;
projectSynced = true;
projectLocalSynced = true;
projectLocalConfig = { autoSync: { enabled: null } };
projectConfig = { autoSync: { default: null } };

await renderEditorPane();

expect(screen.getByTestId('auto-sync-onboarding').getAttribute('data-open')).toBe('true');
});

test.each([
['no remote', false, true, { autoSync: { enabled: null } }],
['project-local config not synced', true, false, { autoSync: { enabled: null } }],
['project-local config missing', true, true, null],
['enabled true already answered', true, true, { autoSync: { enabled: true } }],
['enabled false already answered', true, true, { autoSync: { enabled: false } }],
['enabled undefined is not the unanswered sentinel', true, true, { autoSync: {} }],
] as const)('stays closed when %s', async (_label, nextHasRemote, nextSynced, nextProjectLocalConfig) => {
[
'no remote',
false,
true,
true,
{ autoSync: { enabled: null } },
{ autoSync: { default: null } },
],
[
'committed config not synced',
true,
false,
true,
{ autoSync: { enabled: null } },
{ autoSync: { default: null } },
],
[
'project-local config not synced',
true,
true,
false,
{ autoSync: { enabled: null } },
{ autoSync: { default: null } },
],
['project-local config missing', true, true, true, null, { autoSync: { default: null } }],
[
'enabled true already answered',
true,
true,
true,
{ autoSync: { enabled: true } },
{ autoSync: { default: null } },
],
[
'enabled false already answered',
true,
true,
true,
{ autoSync: { enabled: false } },
{ autoSync: { default: null } },
],
[
'enabled undefined is not the unanswered sentinel',
true,
true,
true,
{ autoSync: {} },
{ autoSync: { default: null } },
],
[
'committed default off suppresses the prompt',
true,
true,
true,
{ autoSync: { enabled: null } },
{ autoSync: { default: false } },
],
[
'committed default on suppresses the prompt',
true,
true,
true,
{ autoSync: { enabled: null } },
{ autoSync: { default: true } },
],
] as const)('stays closed when %s', async (_label, nextHasRemote, nextProjectSynced, nextSynced, nextProjectLocalConfig, nextProjectConfig) => {
hasRemote = nextHasRemote;
projectSynced = nextProjectSynced;
projectLocalSynced = nextSynced;
projectLocalConfig = nextProjectLocalConfig;
projectConfig = nextProjectConfig;

await renderEditorPane();

Expand All @@ -139,8 +211,10 @@ describe('EditorPane auto-sync onboarding gate', () => {

test('resolved onboarding dismisses the dialog in the same render path', async () => {
hasRemote = true;
projectSynced = true;
projectLocalSynced = true;
projectLocalConfig = { autoSync: { enabled: null } };
projectConfig = { autoSync: { default: null } };
await renderEditorPane();

const dialog = screen.getByTestId('auto-sync-onboarding');
Expand Down
5 changes: 4 additions & 1 deletion packages/app/src/components/EditorPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,8 @@ export function EditorPane({ onOpenSearch }: EditorPaneProps = {}) {
const launchNonceRef = useRef(0);

const syncStatus = useGitSyncStatus();
const { projectLocalConfig, projectLocalSynced } = useConfigContext();
const { projectConfig, projectLocalConfig, projectLocalSynced, projectSynced } =
useConfigContext();
const workspace = useWorkspace();

const { activeDocName } = useDocumentContext();
Expand All @@ -63,7 +64,9 @@ export function EditorPane({ onOpenSearch }: EditorPaneProps = {}) {
autoSyncOnboardingDismissed,
hasRemote: syncStatus?.hasRemote,
projectLocalSynced,
projectSynced,
projectLocalConfig,
projectConfig,
pushPermissionCheckStatus: syncStatus?.pushPermission?.checkStatus,
});

Expand Down
102 changes: 102 additions & 0 deletions packages/app/src/components/auto-sync-onboarding-gate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { describe, expect, test } from 'bun:test';
import {
type AutoSyncOnboardingGateInputs,
shouldShowAutoSyncOnboarding,
} from './auto-sync-onboarding-gate.ts';

const SHOWING: AutoSyncOnboardingGateInputs = {
autoSyncOnboardingDismissed: false,
hasRemote: true,
projectLocalSynced: true,
projectSynced: true,
projectLocalConfig: { autoSync: { enabled: null } },
projectConfig: { autoSync: { default: null } },
pushPermissionCheckStatus: 'allowed',
};

describe('shouldShowAutoSyncOnboarding', () => {
test('shows when every condition is aligned (unanswered machine, no committed default)', () => {
expect(shouldShowAutoSyncOnboarding(SHOWING)).toBe(true);
});

test('hidden once dismissed this session', () => {
expect(shouldShowAutoSyncOnboarding({ ...SHOWING, autoSyncOnboardingDismissed: true })).toBe(
false,
);
});

test('hidden without a git remote', () => {
expect(shouldShowAutoSyncOnboarding({ ...SHOWING, hasRemote: false })).toBe(false);
expect(shouldShowAutoSyncOnboarding({ ...SHOWING, hasRemote: undefined })).toBe(false);
});

test('hidden until the project-local binding has synced (flash-free)', () => {
expect(shouldShowAutoSyncOnboarding({ ...SHOWING, projectLocalSynced: false })).toBe(false);
expect(shouldShowAutoSyncOnboarding({ ...SHOWING, projectLocalSynced: undefined })).toBe(false);
});

test('hidden until the committed project binding has synced (flash-free)', () => {
expect(shouldShowAutoSyncOnboarding({ ...SHOWING, projectSynced: false })).toBe(false);
expect(shouldShowAutoSyncOnboarding({ ...SHOWING, projectSynced: undefined })).toBe(false);
});

test('hidden until project-local config hydrates', () => {
expect(shouldShowAutoSyncOnboarding({ ...SHOWING, projectLocalConfig: null })).toBe(false);
});

test('hidden once this machine has answered (enabled true or false)', () => {
expect(
shouldShowAutoSyncOnboarding({
...SHOWING,
projectLocalConfig: { autoSync: { enabled: true } },
}),
).toBe(false);
expect(
shouldShowAutoSyncOnboarding({
...SHOWING,
projectLocalConfig: { autoSync: { enabled: false } },
}),
).toBe(false);
});

test('suppressed when the maintainer committed autoSync.default: false', () => {
expect(
shouldShowAutoSyncOnboarding({
...SHOWING,
projectConfig: { autoSync: { default: false } },
}),
).toBe(false);
});

test('suppressed when the maintainer committed autoSync.default: true', () => {
expect(
shouldShowAutoSyncOnboarding({
...SHOWING,
projectConfig: { autoSync: { default: true } },
}),
).toBe(false);
});

test('still asks when committed config is absent or default is null/absent', () => {
expect(shouldShowAutoSyncOnboarding({ ...SHOWING, projectConfig: null })).toBe(true);
expect(shouldShowAutoSyncOnboarding({ ...SHOWING, projectConfig: { autoSync: {} } })).toBe(
true,
);
expect(shouldShowAutoSyncOnboarding({ ...SHOWING, projectConfig: {} })).toBe(true);
});

test('hidden when the push-permission probe denied or is still pending', () => {
expect(shouldShowAutoSyncOnboarding({ ...SHOWING, pushPermissionCheckStatus: 'denied' })).toBe(
false,
);
expect(shouldShowAutoSyncOnboarding({ ...SHOWING, pushPermissionCheckStatus: undefined })).toBe(
false,
);
});

test('shows on probe unknown (graceful degradation)', () => {
expect(shouldShowAutoSyncOnboarding({ ...SHOWING, pushPermissionCheckStatus: 'unknown' })).toBe(
true,
);
});
});
4 changes: 4 additions & 0 deletions packages/app/src/components/auto-sync-onboarding-gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ export interface AutoSyncOnboardingGateInputs {
autoSyncOnboardingDismissed: boolean;
hasRemote: boolean | undefined;
projectLocalSynced: boolean | undefined;
projectSynced: boolean | undefined;
projectLocalConfig: { autoSync?: { enabled: boolean | null } | null } | null;
projectConfig: { autoSync?: { default?: boolean | null } | null } | null;
pushPermissionCheckStatus: 'allowed' | 'denied' | 'unknown' | undefined;
}

Expand All @@ -11,8 +13,10 @@ export function shouldShowAutoSyncOnboarding(inputs: AutoSyncOnboardingGateInput
!inputs.autoSyncOnboardingDismissed &&
inputs.hasRemote === true &&
inputs.projectLocalSynced === true &&
inputs.projectSynced === true &&
inputs.projectLocalConfig !== null &&
inputs.projectLocalConfig.autoSync?.enabled === null &&
(inputs.projectConfig?.autoSync?.default ?? null) === null &&
(inputs.pushPermissionCheckStatus === 'allowed' ||
inputs.pushPermissionCheckStatus === 'unknown')
);
Expand Down
Loading