From 080a77d2c019719f80baec16c17b876eeafaf367 Mon Sep 17 00:00:00 2001
From: miles-kt-inkeep <135626743+miles-kt-inkeep@users.noreply.github.com>
Date: Fri, 24 Jul 2026 15:40:02 -0400
Subject: [PATCH 1/3] Match link-target suggestions by name, not just slash
paths (#2903)
* [US-001] link suggestions: match bare-name queries and demote dot-dir paths in browse order
Core matcher no longer requires a leading slash to produce suggestions, and
ties (the empty/browse case) rank real content paths ahead of dot-directory
files so .github/.changeset entries no longer monopolize the capped list.
* [US-002] link suggestions: open the panel for any path target, suppress URLs
The link-target field now opens name suggestions as you type (Cmd+K parity),
not only for a leading-slash path. Trigger classification is self-contained in
the component via isExternalHref + a leading-# check, so external URLs and bare
in-doc anchors do not pop a page list. Shared across the internal-link dialog,
the wiki-link panel, and the bubble-menu link popover.
* [chore] changeset for link-target name suggestions
* [review] gate empty-state to strong path signal; browse-only dot-demotion
Local review follow-ups:
- Only surface the 'No matching paths' empty-state on an empty browse or an
explicit leading slash, so typing/pasting a scheme-less URL (example.com) into
the dual-purpose field no longer flashes an empty page list. Bare-name matches
still surface. Reuses isSlashPathSuggestionValue (no longer dead).
- Only compute suggestions when a panel could show, keeping URL input out of the
matcher and off the per-keystroke scoring path.
- Apply dot-directory demotion to browse ordering only; on a scored query it
could push an exact-matched dotfile past the result cap.
- Test: a bare name with no match stays silent (no empty-state).
* [review] pin AC2 basename ranking for the bare-name form too
* [docs] spec for link-target name suggestions
* [polish] show full link path as a hover tooltip when the row is truncated
The suggestion row truncates long paths with an ellipsis; add a native title
tooltip carrying the full /path so a clipped row is still readable on hover.
---------
GitOrigin-RevId: d9d51b4d407486b1770ae249e31653e089699ec5
---
.changeset/link-suggest-by-name.md | 7 +++
knip.config.ts | 4 +-
.../src/editor/link-path-suggestions-core.ts | 29 ++++++++---
.../editor/link-path-suggestions.dom.test.tsx | 47 ++++++++++++++++++
.../src/editor/link-path-suggestions.test.ts | 48 +++++++++++++++---
.../app/src/editor/link-path-suggestions.tsx | 49 ++++++++++++++-----
.../core/src/bridge/bind-frontmatter-doc.ts | 1 +
packages/core/src/bridge/bridge-invariant.ts | 1 +
.../core/src/bridge/doc-boundary-space.ts | 1 +
packages/core/src/bridge/growth-detect.ts | 1 +
packages/core/src/bridge/merge-three-way.ts | 1 +
packages/core/src/bridge/normalize.ts | 1 +
.../src/bridge/pm-structural-equivalence.ts | 3 ++
packages/core/src/bridge/subsequence.ts | 1 +
.../core/src/markdown/callout-transformer.ts | 1 +
packages/core/src/markdown/code-fence.ts | 1 +
.../core/src/markdown/comment-promoter.ts | 1 +
.../src/markdown/dedent-block-jsx-close.ts | 1 +
.../markdown/details-accordion-promoter.ts | 1 +
packages/core/src/markdown/fence-regions.ts | 1 +
packages/core/src/markdown/fixtures/index.ts | 6 +++
.../src/markdown/fixtures/perf/generate.ts | 5 ++
.../src/markdown/guard-flanking-matrix.ts | 1 +
.../core/src/markdown/handler-shadow-audit.ts | 1 +
.../core/src/markdown/highlight-promoter.ts | 1 +
packages/core/src/markdown/html-to-mdast.ts | 4 +-
packages/core/src/markdown/image-promoter.ts | 1 +
packages/core/src/markdown/index.ts | 9 ++++
.../core/src/markdown/lint/config-files.ts | 1 +
.../core/src/markdown/lint/config-schemas.ts | 1 +
.../core/src/markdown/lint/default-config.ts | 1 +
packages/core/src/markdown/lint/index.ts | 1 +
packages/core/src/markdown/lint/plugins.ts | 2 +
packages/core/src/markdown/lint/types.ts | 2 +
packages/core/src/markdown/math-promoter.ts | 1 +
.../src/markdown/mdast-to-hast-handlers.ts | 1 +
packages/core/src/markdown/mdast-to-html.ts | 1 +
packages/core/src/markdown/merged-walker.ts | 1 +
.../core/src/markdown/mermaid-promoter.ts | 1 +
.../core/src/markdown/parse-with-fallback.ts | 5 ++
.../core/src/markdown/parser-drop-closure.ts | 1 +
packages/core/src/markdown/pipeline.ts | 1 +
.../core/src/markdown/position-aware-join.ts | 1 +
packages/core/src/markdown/position-slice.ts | 4 +-
.../rehype-plugins/skip-notion-whitespace.ts | 1 +
.../rehype-plugins/strip-cocoa-meta.ts | 1 +
.../rehype-plugins/strip-gdocs-wrapper.ts | 1 +
.../rehype-plugins/strip-github-hovercard.ts | 1 +
.../rehype-plugins/strip-gmail-classes.ts | 1 +
.../rehype-plugins/strip-gsheets-wrapper.ts | 1 +
.../rehype-plugins/strip-mso-styles.ts | 1 +
.../rehype-plugins/strip-slack-classes.ts | 1 +
.../rehype-plugins/strip-vscode-spans.ts | 1 +
.../core/src/markdown/resolve-image-url.ts | 1 +
packages/core/src/markdown/safe-url.ts | 1 +
.../serialize-fidelity.test-helper.ts | 1 +
.../core/src/markdown/serialize-helpers.ts | 1 +
.../markdown/single-dollar-math-promoter.ts | 1 +
.../core/src/markdown/to-markdown-handlers.ts | 1 +
.../core/src/markdown/unknown-mdast-guard.ts | 1 +
.../core/src/markdown/whitespace-char-ref.ts | 1 +
.../core/src/markdown/wiki-link-micromark.ts | 3 ++
62 files changed, 243 insertions(+), 30 deletions(-)
create mode 100644 .changeset/link-suggest-by-name.md
diff --git a/.changeset/link-suggest-by-name.md b/.changeset/link-suggest-by-name.md
new file mode 100644
index 000000000..6bf400b90
--- /dev/null
+++ b/.changeset/link-suggest-by-name.md
@@ -0,0 +1,7 @@
+---
+"@inkeep/open-knowledge": patch
+---
+
+Link-target suggestions now match by name as you type, like the command palette.
+
+When editing a link target (the "Edit markdown link" dialog, the wiki-link panel, and the bubble-menu link popover all share the same field), typing a bare page name such as `install` now opens the suggestion dropdown and surfaces the matching page — a leading `/` is no longer required. Typing or pasting an external URL, or a bare `#anchor`, still does not pop a page list. The empty/browse list also keeps real content pages ahead of dot-directory files (`.github/`, `.changeset/`) so they no longer fill the list on projects rooted at a repository.
diff --git a/knip.config.ts b/knip.config.ts
index 296751a02..6e3847861 100644
--- a/knip.config.ts
+++ b/knip.config.ts
@@ -121,7 +121,9 @@ export default {
'packages/server': {
entry: ['src/**/*.test.ts', 'src/parse-worker.ts'],
project: 'src/**',
- ignoreDependencies: ['@types/shell-quote'],
+ ignoreDependencies: [
+ '@types/shell-quote',
+ ],
},
'packages/cli': {
entry: ['src/**/*.test.ts', 'scripts/*.ts', 'tests/**/*.ts', 'src/parse-worker.ts'],
diff --git a/packages/app/src/editor/link-path-suggestions-core.ts b/packages/app/src/editor/link-path-suggestions-core.ts
index 2a9e3fed2..48216f604 100644
--- a/packages/app/src/editor/link-path-suggestions-core.ts
+++ b/packages/app/src/editor/link-path-suggestions-core.ts
@@ -29,11 +29,6 @@ function normalizeInputPath(value: string): string {
.replace(/\/+$/g, '');
}
-function slashPathQuery(value: string): string | null {
- if (!isSlashPathSuggestionValue(value)) return null;
- return normalizeInputPath(value.slice(1)).toLowerCase();
-}
-
export function isSlashPathSuggestionValue(value: string): boolean {
return value.startsWith('/') && !value.startsWith('//');
}
@@ -43,6 +38,10 @@ function basename(path: string): string {
return parts.at(-1) ?? path;
}
+function firstSegmentIsDot(path: string): boolean {
+ return path.split('/')[0]?.startsWith('.') ?? false;
+}
+
function scorePath(path: string, query: string): number | null {
if (!query) return 0;
const lowerPath = path.toLowerCase();
@@ -92,8 +91,15 @@ function collectSuggestions(options: BuildLinkPathSuggestionsOptions): LinkPathS
export function buildLinkPathSuggestions(
options: BuildLinkPathSuggestionsOptions,
): LinkPathSuggestion[] {
- const query = slashPathQuery(options.value);
- if (query === null) return [];
+ // Any input is a query, not just a leading-slash path — typing a bare name
+ // matches by basename like the command palette does. Whether to suggest at
+ // all (path vs external URL vs anchor) is the caller's decision, not this
+ // pure matcher's. An empty query browses (every candidate scores 0).
+ const query = normalizeInputPath(options.value).toLowerCase();
+ // Dot-demotion is a browse-ordering concern only. On a real query, relevance
+ // (score) leads; applying it to scored ties could push an exact-matched
+ // dotfile past the result cap behind equally-scored siblings.
+ const browsing = query === '';
const scored: ScoredSuggestion[] = [];
for (const suggestion of collectSuggestions(options)) {
@@ -106,6 +112,15 @@ export function buildLinkPathSuggestions(
if (a.score !== b.score) return a.score - b.score;
const kindCompare = kindRank(a.suggestion.kind) - kindRank(b.suggestion.kind);
if (kindCompare !== 0) return kindCompare;
+ if (browsing) {
+ // Rank real content paths ahead of dot-directory files (.github,
+ // .changeset) — otherwise the browse list's alphabetical fallback fills
+ // the cap with dotfiles before any content page appears.
+ const dotCompare =
+ (firstSegmentIsDot(a.suggestion.path) ? 1 : 0) -
+ (firstSegmentIsDot(b.suggestion.path) ? 1 : 0);
+ if (dotCompare !== 0) return dotCompare;
+ }
return a.suggestion.path.localeCompare(b.suggestion.path);
});
diff --git a/packages/app/src/editor/link-path-suggestions.dom.test.tsx b/packages/app/src/editor/link-path-suggestions.dom.test.tsx
index 9403f7562..ccfa0c4bc 100644
--- a/packages/app/src/editor/link-path-suggestions.dom.test.tsx
+++ b/packages/app/src/editor/link-path-suggestions.dom.test.tsx
@@ -63,6 +63,8 @@ describe('LinkPathSuggestionInput', () => {
expect(screen.getByRole('listbox', { name: 'Path suggestions' })).toBeDefined();
expect(screen.getByRole('option', { name: '/docs/install Page' })).toBeDefined();
+ // Full path is exposed as a hover tooltip so a truncated row is still readable.
+ expect(screen.getByTitle('/docs/install')).toBeDefined();
});
test('opens project path suggestions when slash input has focus', () => {
@@ -77,6 +79,51 @@ describe('LinkPathSuggestionInput', () => {
expect(screen.getByRole('option', { name: '/guides/intro Page' })).toBeDefined();
});
+ test('opens suggestions for a bare name and lists the matching page', () => {
+ render();
+
+ fireEvent.focus(screen.getByRole('combobox', { name: 'Link target' }));
+
+ expect(screen.getByRole('listbox', { name: 'Path suggestions' })).toBeDefined();
+ expect(screen.getByRole('option', { name: '/docs/install Page' })).toBeDefined();
+ });
+
+ test('does not open the panel while typing an external URL', () => {
+ render();
+
+ fireEvent.focus(screen.getByRole('combobox', { name: 'Link target' }));
+
+ expect(screen.queryByRole('listbox', { name: 'Path suggestions' })).toBeNull();
+ });
+
+ test('does not open the panel for a protocol-relative or scheme URL', () => {
+ render();
+
+ fireEvent.focus(screen.getByRole('combobox', { name: 'Link target' }));
+
+ expect(screen.queryByRole('listbox', { name: 'Path suggestions' })).toBeNull();
+ });
+
+ test('does not open the panel for a bare in-doc anchor', () => {
+ render();
+
+ fireEvent.focus(screen.getByRole('combobox', { name: 'Link target' }));
+
+ expect(screen.queryByRole('listbox', { name: 'Path suggestions' })).toBeNull();
+ });
+
+ test('stays silent for a bare name with no match — no empty-state flash', () => {
+ // A scheme-less value that matches nothing (e.g. a URL-in-progress like
+ // example.com) must not pop the "No matching paths" empty-state. Only an
+ // empty browse or an explicit leading slash surfaces that state.
+ render();
+
+ fireEvent.focus(screen.getByRole('combobox', { name: 'Link target' }));
+
+ expect(screen.queryByRole('listbox', { name: 'Path suggestions' })).toBeNull();
+ expect(screen.queryByRole('status')).toBeNull();
+ });
+
test('shows an empty state for slash input with no matching paths', () => {
render();
diff --git a/packages/app/src/editor/link-path-suggestions.test.ts b/packages/app/src/editor/link-path-suggestions.test.ts
index 9c49ac8fe..a3fdf5189 100644
--- a/packages/app/src/editor/link-path-suggestions.test.ts
+++ b/packages/app/src/editor/link-path-suggestions.test.ts
@@ -15,12 +15,24 @@ describe('buildLinkPathSuggestions', () => {
const folderPaths = new Set(['docs', 'guides', 'notes']);
const assetPaths = new Set(['assets/logo.png', 'guides/demo.mov']);
- test('does not suggest paths until the value starts with a single slash', () => {
- expect(buildLinkPathSuggestions({ value: 'guides', pages, folderPaths })).toEqual([]);
+ test('matches a bare name query without requiring a leading slash', () => {
+ expect(buildLinkPathSuggestions({ value: 'guides', pages, folderPaths })).toEqual([
+ { kind: 'folder', path: 'guides' },
+ { kind: 'page', path: 'guides/bun' },
+ { kind: 'page', path: 'guides/intro' },
+ ]);
+ expect(buildLinkPathSuggestions({ value: 'install', pages, folderPaths })).toEqual([
+ { kind: 'page', path: 'docs/install' },
+ ]);
+ });
+
+ test('returns nothing for a bare query that matches no path', () => {
+ expect(buildLinkPathSuggestions({ value: 'zzz-nope', pages, folderPaths })).toEqual([]);
+ // URL-shaped values match no path; suppression of the panel for real URLs
+ // is the component's job, not this pure matcher's.
expect(buildLinkPathSuggestions({ value: 'https://example.com', pages, folderPaths })).toEqual(
[],
);
- expect(buildLinkPathSuggestions({ value: '//example.com', pages, folderPaths })).toEqual([]);
});
test('suggests matching existing page and folder paths after slash input', () => {
@@ -61,14 +73,36 @@ describe('buildLinkPathSuggestions', () => {
});
test('ranks basename substring matches before full-path-only substring matches', () => {
+ const ranked = [
+ { kind: 'page', path: 'notes/api' },
+ { kind: 'page', path: 'guides/api/reference' },
+ ];
+ // Same ranking whether the query carries a leading slash or not — the query
+ // is normalized before scoring, so both forms hit the identical code path.
expect(
buildLinkPathSuggestions({
value: '/api',
pages: new Set(['guides/api/reference', 'notes/api']),
}),
- ).toEqual([
- { kind: 'page', path: 'notes/api' },
- { kind: 'page', path: 'guides/api/reference' },
- ]);
+ ).toEqual(ranked);
+ expect(
+ buildLinkPathSuggestions({
+ value: 'api',
+ pages: new Set(['guides/api/reference', 'notes/api']),
+ }),
+ ).toEqual(ranked);
+ });
+
+ test('browse ordering keeps content pages ahead of dot-directory files', () => {
+ const mixed = new Set(['.github/ci', '.changeset/note', 'docs/install', 'guides/intro']);
+ const browsed = buildLinkPathSuggestions({ value: '', pages: mixed }).map((s) => s.path);
+ // Every non-dot page appears before any dot-directory page.
+ const firstDotIndex = browsed.findIndex((p) => p.split('/')[0]?.startsWith('.'));
+ const lastNonDotIndex = browsed.reduce(
+ (last, p, i) => (p.split('/')[0]?.startsWith('.') ? last : i),
+ -1,
+ );
+ expect(lastNonDotIndex).toBeLessThan(firstDotIndex);
+ expect(browsed.slice(0, 2)).toEqual(['docs/install', 'guides/intro']);
});
});
diff --git a/packages/app/src/editor/link-path-suggestions.tsx b/packages/app/src/editor/link-path-suggestions.tsx
index b749281d0..ffa01c483 100644
--- a/packages/app/src/editor/link-path-suggestions.tsx
+++ b/packages/app/src/editor/link-path-suggestions.tsx
@@ -1,4 +1,5 @@
import { autoUpdate, computePosition, flip, offset, shift, size } from '@floating-ui/dom';
+import { isExternalHref } from '@inkeep/open-knowledge-core';
import { useLingui } from '@lingui/react/macro';
import {
type ComponentProps,
@@ -114,25 +115,45 @@ export function LinkPathSuggestionInput({
const panelRef = useRef(null);
const listId = useId();
- const emptyTriggered = value.trim() === '';
- const suggestionValue = emptyTriggered ? '/' : value;
- const suggestions = buildLinkPathSuggestions({
- value: suggestionValue,
- pages,
- folderPaths,
- assetPaths,
- includeAssets,
- });
+ const trimmedValue = value.trim();
+ const emptyTriggered = trimmedValue === '';
+ const slashTriggered = isSlashPathSuggestionValue(value);
+ // Suggest for any internal path target — a bare name matches by basename like
+ // the command palette, not just a leading-slash path. Suppress for an
+ // external URL or a bare in-doc anchor, which are valid targets that must not
+ // pop a page list.
+ const pathTargetTriggered = !isExternalHref(trimmedValue) && !trimmedValue.startsWith('#');
+ const suggestionTriggered = emptyTriggered || pathTargetTriggered;
+ const suggestionValue = emptyTriggered ? '' : value;
+ // Only score the page list when a suggestion could actually show — avoids
+ // re-scoring the whole KB on every keystroke of a URL, and keeps URL-shaped
+ // input out of the matcher.
+ const suggestions = suggestionTriggered
+ ? buildLinkPathSuggestions({
+ value: suggestionValue,
+ pages,
+ folderPaths,
+ assetPaths,
+ includeAssets,
+ })
+ : [];
const suggestionsKey = suggestions
.map((suggestion) => `${suggestion.kind}:${suggestion.path}`)
.join('\u0000');
- const slashTriggered = isSlashPathSuggestionValue(value);
- const suggestionTriggered = emptyTriggered || slashTriggered;
const showSuggestionOptions = suggestions.length > 0;
const showSuggestions =
focused && !dismissed && suggestionTriggered && (showSuggestionOptions || loading);
+ // The "no matching paths" empty-state is louder than a silent field, so only
+ // surface it on a strong path signal — an empty browse or an explicit leading
+ // slash. A bare name that matches nothing stays silent, so typing/pasting a
+ // scheme-less URL (e.g. example.com) into the dual-purpose field never flashes
+ // an empty page list.
const showNoMatches =
- focused && !dismissed && suggestionTriggered && !loading && !showSuggestionOptions;
+ focused &&
+ !dismissed &&
+ (emptyTriggered || slashTriggered) &&
+ !loading &&
+ !showSuggestionOptions;
const showSuggestionPanel = showSuggestions || showNoMatches;
const activeIndex = Math.min(highlightedIndex, Math.max(suggestions.length - 1, 0));
const activeId = showSuggestionOptions ? `${listId}-option-${activeIndex}` : undefined;
@@ -297,7 +318,9 @@ export function LinkPathSuggestionInput({
onClick={() => selectSuggestion(suggestion)}
>
{suggestionIcon(suggestion)}
- /{suggestion.path}
+
+ /{suggestion.path}
+ {kindLabel}
);
diff --git a/packages/core/src/bridge/bind-frontmatter-doc.ts b/packages/core/src/bridge/bind-frontmatter-doc.ts
index c014b1413..8f9d95287 100644
--- a/packages/core/src/bridge/bind-frontmatter-doc.ts
+++ b/packages/core/src/bridge/bind-frontmatter-doc.ts
@@ -1,3 +1,4 @@
+
import type * as Y from 'yjs';
import type { Err, Ok, Result } from '../config/result.ts';
import { type FrontmatterValidationError, toFrontmatterIssue } from '../frontmatter/errors.ts';
diff --git a/packages/core/src/bridge/bridge-invariant.ts b/packages/core/src/bridge/bridge-invariant.ts
index 005627195..8bfdb40cd 100644
--- a/packages/core/src/bridge/bridge-invariant.ts
+++ b/packages/core/src/bridge/bridge-invariant.ts
@@ -1,3 +1,4 @@
+
import { fnv1aDigest } from './hash-util.ts';
export type BridgeInvariantSite = 'observer-b' | 'persistence' | 'test-harness';
diff --git a/packages/core/src/bridge/doc-boundary-space.ts b/packages/core/src/bridge/doc-boundary-space.ts
index 8890f6739..c18c086d5 100644
--- a/packages/core/src/bridge/doc-boundary-space.ts
+++ b/packages/core/src/bridge/doc-boundary-space.ts
@@ -1,3 +1,4 @@
+
import { FRONTMATTER_RE, stripFrontmatter } from '../extensions/frontmatter.ts';
const LEADING_BOUNDARY_RE = /^(?:\r?\n)+/;
diff --git a/packages/core/src/bridge/growth-detect.ts b/packages/core/src/bridge/growth-detect.ts
index 9d9c244b0..463e89595 100644
--- a/packages/core/src/bridge/growth-detect.ts
+++ b/packages/core/src/bridge/growth-detect.ts
@@ -1,3 +1,4 @@
+
const DEFAULT_MIN_SUBSTANTIVE_LINE_LENGTH = 16;
export const DUPLICATION_GATE_MIN_LINE_LENGTH = 8;
diff --git a/packages/core/src/bridge/merge-three-way.ts b/packages/core/src/bridge/merge-three-way.ts
index 3ab9cc6ef..3c927eb47 100644
--- a/packages/core/src/bridge/merge-three-way.ts
+++ b/packages/core/src/bridge/merge-three-way.ts
@@ -38,6 +38,7 @@ function mergeThreeWayImpl(baseline: string, userText: string, agentText: string
return parts.join('\n');
}
+
export type BridgeMergeContentLossSide = 'user' | 'agent';
export type BridgeMergeContentLossWhich = 'substring' | 'order' | 'growth';
diff --git a/packages/core/src/bridge/normalize.ts b/packages/core/src/bridge/normalize.ts
index 4b8890476..3f0880aa5 100644
--- a/packages/core/src/bridge/normalize.ts
+++ b/packages/core/src/bridge/normalize.ts
@@ -1,3 +1,4 @@
+
const COMMONMARK_ESCAPE_RE = /\\([!"#$%&'()*+,\-./:;<=>?@[\]^_`{|}~])/g;
const TABLE_ALIGN_ROW_RE = /^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)+\|?\s*$/;
diff --git a/packages/core/src/bridge/pm-structural-equivalence.ts b/packages/core/src/bridge/pm-structural-equivalence.ts
index 754dc5cc2..0be5203e4 100644
--- a/packages/core/src/bridge/pm-structural-equivalence.ts
+++ b/packages/core/src/bridge/pm-structural-equivalence.ts
@@ -32,6 +32,7 @@ const BR_SENTINEL: PmStructuralNode = { type: '__br__' };
const BR_LITERAL_RE = /^ $/i;
+
interface DegradeEntry {
readonly label: string;
readonly severity: ToleranceClassSeverity;
@@ -127,6 +128,7 @@ export interface ComparePmStructuralOptions {
ignoreAttrs?: (attrKey: string) => boolean;
}
+
/** Rebuild a node with `fn` applied to each child; identity when childless.
* Shared by the degrade canonicalizers so none re-implements the walk. */
function mapChildren(
@@ -248,6 +250,7 @@ function applyDegrades(tree: PmStructuralNode): {
return { normalized: current, fired };
}
+
export function comparePmStructural(
expected: PmStructuralNode,
actual: PmStructuralNode,
diff --git a/packages/core/src/bridge/subsequence.ts b/packages/core/src/bridge/subsequence.ts
index af20de1e2..dca644141 100644
--- a/packages/core/src/bridge/subsequence.ts
+++ b/packages/core/src/bridge/subsequence.ts
@@ -1,3 +1,4 @@
+
/** True when `needle` is a subsequence of `haystack` (two-pointer, O(n+m)):
* insertions in `haystack` are free; any dropped or substituted `needle` byte
* fails. */
diff --git a/packages/core/src/markdown/callout-transformer.ts b/packages/core/src/markdown/callout-transformer.ts
index 83d47404a..a363dfd54 100644
--- a/packages/core/src/markdown/callout-transformer.ts
+++ b/packages/core/src/markdown/callout-transformer.ts
@@ -1,3 +1,4 @@
+
import type { Blockquote, Paragraph, Root } from 'mdast';
import type { MdxJsxAttribute, MdxJsxFlowElement } from 'mdast-util-mdx';
import { visit } from 'unist-util-visit';
diff --git a/packages/core/src/markdown/code-fence.ts b/packages/core/src/markdown/code-fence.ts
index 9807a7e82..4499dbc92 100644
--- a/packages/core/src/markdown/code-fence.ts
+++ b/packages/core/src/markdown/code-fence.ts
@@ -1,3 +1,4 @@
+
export function selectFenceChar(info: string): '`' | '~' {
return info.includes('`') ? '~' : '`';
}
diff --git a/packages/core/src/markdown/comment-promoter.ts b/packages/core/src/markdown/comment-promoter.ts
index 193e286b0..47f0d07e4 100644
--- a/packages/core/src/markdown/comment-promoter.ts
+++ b/packages/core/src/markdown/comment-promoter.ts
@@ -1,3 +1,4 @@
+
import type { Nodes, Paragraph, PhrasingContent, Root, RootContent, Text } from 'mdast';
import { SKIP, visit } from 'unist-util-visit';
import type { VFile } from 'vfile';
diff --git a/packages/core/src/markdown/dedent-block-jsx-close.ts b/packages/core/src/markdown/dedent-block-jsx-close.ts
index ece5c02a1..b6299a948 100644
--- a/packages/core/src/markdown/dedent-block-jsx-close.ts
+++ b/packages/core/src/markdown/dedent-block-jsx-close.ts
@@ -1,3 +1,4 @@
+
import { findFencedRegions, isInsideFence } from './fence-regions.ts';
const INDENTED_BLOCK_JSX_CLOSE_RE = /^([ ]{1,3})(<\/[A-Z][A-Za-z0-9_]*\s*>)([ \t]*)$/gm;
diff --git a/packages/core/src/markdown/details-accordion-promoter.ts b/packages/core/src/markdown/details-accordion-promoter.ts
index 54774764a..acd398aa6 100644
--- a/packages/core/src/markdown/details-accordion-promoter.ts
+++ b/packages/core/src/markdown/details-accordion-promoter.ts
@@ -1,3 +1,4 @@
+
import type { Nodes, Paragraph, Parent, Root, Text } from 'mdast';
import type { MdxJsxAttribute, MdxJsxFlowElement } from 'mdast-util-mdx';
import { visit } from 'unist-util-visit';
diff --git a/packages/core/src/markdown/fence-regions.ts b/packages/core/src/markdown/fence-regions.ts
index c7518d614..fb5ac8e8a 100644
--- a/packages/core/src/markdown/fence-regions.ts
+++ b/packages/core/src/markdown/fence-regions.ts
@@ -1,3 +1,4 @@
+
const FENCE_RE = /^(`{3,}|~{3,})/gm;
export function findFencedRegions(src: string): Array<[number, number]> {
diff --git a/packages/core/src/markdown/fixtures/index.ts b/packages/core/src/markdown/fixtures/index.ts
index 00f5771ee..6931623c7 100644
--- a/packages/core/src/markdown/fixtures/index.ts
+++ b/packages/core/src/markdown/fixtures/index.ts
@@ -1,3 +1,4 @@
+
import { readFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -8,6 +9,7 @@ function fixturePath(...segments: string[]): string {
return resolve(FIXTURES_DIR, ...segments);
}
+
interface GfmExample {
section: string;
markdown: string;
@@ -17,6 +19,7 @@ export function loadGfmExamples(): GfmExample[] {
return JSON.parse(readFileSync(fixturePath('gfm', 'examples.json'), 'utf8')) as GfmExample[];
}
+
interface MdxCrashEntry {
id: string;
input: string;
@@ -68,6 +71,7 @@ export function loadLargeEmbedFixtures(): LargeEmbedFixture[] {
) as LargeEmbedFixture[];
}
+
export function loadPrd6955Before(): string {
return readFileSync(fixturePath('regression', 'prd-6955-before.md'), 'utf8');
}
@@ -76,6 +80,7 @@ export function loadPrd6955CorruptedTriplicated(): string {
return readFileSync(fixturePath('regression', 'prd-6955-corrupted-triplicated.md'), 'utf8');
}
+
export interface NgPinnedCase {
id: string;
name: string;
@@ -92,6 +97,7 @@ export function loadNgPinnedCases(): NgPinnedCase[] {
) as NgPinnedCase[];
}
+
export function loadLargeRealistic(): string {
return readFileSync(fixturePath('perf', 'large-realistic.md'), 'utf8');
}
diff --git a/packages/core/src/markdown/fixtures/perf/generate.ts b/packages/core/src/markdown/fixtures/perf/generate.ts
index 8b1eb8519..daa3ef82b 100644
--- a/packages/core/src/markdown/fixtures/perf/generate.ts
+++ b/packages/core/src/markdown/fixtures/perf/generate.ts
@@ -1,3 +1,4 @@
+
import { writeFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -10,6 +11,7 @@ const BASELINE_ONLY_COUNTS = [500, 2500] as const;
const SEED = 0xf1de1117;
+
function mulberry32(seed: number): () => number {
let a = seed >>> 0;
return () => {
@@ -36,6 +38,7 @@ function pickWeighted(rand: () => number, items: readonly [T, number][]): T {
return items[items.length - 1][0];
}
+
const WORDS = [
'lorem',
'ipsum',
@@ -153,6 +156,7 @@ function mdxBlock(rand: () => number): string {
return `<${name}>\n\n${sentence(rand)}\n\n${name}>`;
}
+
type BlockKind = 'paragraph' | 'heading' | 'list' | 'code' | 'table' | 'mdx';
const MIX: readonly [BlockKind, number][] = [
@@ -191,6 +195,7 @@ function generateDocument(blockCount: number, seed: number): string {
return `${blocks.join('\n\n')}\n`;
}
+
function main(): void {
for (const count of [...BLOCK_COUNTS, ...BASELINE_ONLY_COUNTS]) {
const doc = generateDocument(count, SEED);
diff --git a/packages/core/src/markdown/guard-flanking-matrix.ts b/packages/core/src/markdown/guard-flanking-matrix.ts
index 420dc0fe5..32c055c5b 100644
--- a/packages/core/src/markdown/guard-flanking-matrix.ts
+++ b/packages/core/src/markdown/guard-flanking-matrix.ts
@@ -7,6 +7,7 @@ import {
import { BACKSLASH_GUARD_SUBSTITUTIONS, encodeBackslashEscapes } from './backslash-escape-guard.ts';
import { ENTITY_REF_GUARD_SUBSTITUTIONS, encodeEntityRefs } from './entity-ref-guard.ts';
+
export const ATTENTION_DELIMITERS = ['*', '_', '**', '~~', '=='] as const;
export type FlankClass = 'whitespace' | 'punctuation' | 'other';
diff --git a/packages/core/src/markdown/handler-shadow-audit.ts b/packages/core/src/markdown/handler-shadow-audit.ts
index 2653473da..283913255 100644
--- a/packages/core/src/markdown/handler-shadow-audit.ts
+++ b/packages/core/src/markdown/handler-shadow-audit.ts
@@ -1,3 +1,4 @@
+
export interface HandlerShadowWitness {
input: string;
expect: 'byte' | 'byte-and-reparse-type';
diff --git a/packages/core/src/markdown/highlight-promoter.ts b/packages/core/src/markdown/highlight-promoter.ts
index f55912407..6319f264f 100644
--- a/packages/core/src/markdown/highlight-promoter.ts
+++ b/packages/core/src/markdown/highlight-promoter.ts
@@ -1,3 +1,4 @@
+
import type { Nodes, Parent, PhrasingContent, Root, Text } from 'mdast';
import type { MdxJsxTextElement } from 'mdast-util-mdx';
import { SKIP, visit } from 'unist-util-visit';
diff --git a/packages/core/src/markdown/html-to-mdast.ts b/packages/core/src/markdown/html-to-mdast.ts
index b1f252c31..8dc3a81b5 100644
--- a/packages/core/src/markdown/html-to-mdast.ts
+++ b/packages/core/src/markdown/html-to-mdast.ts
@@ -1,3 +1,4 @@
+
import type { Root as HastRoot } from 'hast';
import type { List, Root as MdastRoot } from 'mdast';
import rehypeParse from 'rehype-parse';
@@ -85,7 +86,8 @@ export function htmlToMdast(html: string, options?: HtmlToMdastOptions): MdastRo
throw new HtmlPayloadTooLargeError(html.length, maxBytes);
}
- const processor = unified().use(rehypeParse, { fragment: true });
+ const processor = unified()
+ .use(rehypeParse, { fragment: true });
for (const plugin of cleanupPlugins) {
processor.use(plugin);
diff --git a/packages/core/src/markdown/image-promoter.ts b/packages/core/src/markdown/image-promoter.ts
index 706fa9afb..994eb6917 100644
--- a/packages/core/src/markdown/image-promoter.ts
+++ b/packages/core/src/markdown/image-promoter.ts
@@ -1,3 +1,4 @@
+
import type { Image, Paragraph, Root } from 'mdast';
import type { MdxJsxAttribute, MdxJsxFlowElement } from 'mdast-util-mdx';
import { visit } from 'unist-util-visit';
diff --git a/packages/core/src/markdown/index.ts b/packages/core/src/markdown/index.ts
index 8b36807fd..fc30bda02 100644
--- a/packages/core/src/markdown/index.ts
+++ b/packages/core/src/markdown/index.ts
@@ -1,3 +1,4 @@
+
import {
type FromProseMirrorOptions,
fromPmMark,
@@ -196,6 +197,7 @@ export class MarkdownManager {
}
}
+
const registry = createRegistry();
function destructureAttrs(
@@ -244,6 +246,7 @@ function destructureAttrs(
return result;
}
+
function hasDirtyDescendant(node: PmNode): boolean {
let found = false;
node.descendants((child) => {
@@ -263,6 +266,7 @@ function effectiveDirty(node: PmNode, freshnessChecker?: StructuralFreshnessChec
return freshnessChecker ? freshnessChecker.isDiverged(node.toJSON()) : false;
}
+
function isEmptyMdastParagraph(node: MdastNodes): boolean {
if (node.type !== 'paragraph') return false;
const children = node.children ?? [];
@@ -331,6 +335,7 @@ function extractTextFromMdastNodes(nodes: MdastNodes[]): string {
return out;
}
+
import {
AUDIO_EXTENSIONS,
FILE_ATTACHMENT_EXTENSIONS,
@@ -577,6 +582,7 @@ function buildMdastToPmHandlers(
}));
}
+
if (m.emphasis) {
handlers.emphasis = toPmMark(m.emphasis, (node: Emphasis) => ({
sourceDelimiter: node.data?.sourceDelimiter ?? '*',
@@ -657,6 +663,7 @@ function buildMdastToPmHandlers(
}));
}
+
if (m.link) {
const sourceLiteralMark = m.sourceLiteral;
handlers.link = (node: Link, _parent: MdastParent, state: MdastToPmState) => {
@@ -935,6 +942,7 @@ function buildMdastToPmHandlers(
};
}
+
const blockUnknownHandler = (node: {
type: string;
position?: { start: { offset: number }; end: { offset: number } };
@@ -1060,6 +1068,7 @@ function buildMdastToPmHandlers(
return handlers as RemarkProseMirrorOptions['handlers'];
}
+
function buildPmToMdastHandlers(
schema: Schema,
freshness: FreshnessCheckerHolder,
diff --git a/packages/core/src/markdown/lint/config-files.ts b/packages/core/src/markdown/lint/config-files.ts
index 312c9c74b..94b34dee1 100644
--- a/packages/core/src/markdown/lint/config-files.ts
+++ b/packages/core/src/markdown/lint/config-files.ts
@@ -1,3 +1,4 @@
+
const MARKDOWNLINT_JSON_CONFIG_FILES: ReadonlySet = new Set([
'.markdownlint.json',
'.markdownlint.jsonc',
diff --git a/packages/core/src/markdown/lint/config-schemas.ts b/packages/core/src/markdown/lint/config-schemas.ts
index 9291ee4db..d90a7e5ab 100644
--- a/packages/core/src/markdown/lint/config-schemas.ts
+++ b/packages/core/src/markdown/lint/config-schemas.ts
@@ -29,6 +29,7 @@ const fullPluginShape = Object.fromEntries(
LINT_PLUGINS.map((plugin) => [plugin.id, plugin.sliceSchema]),
) as z.ZodRawShape;
+
export const LinterConfigSchema = z.object({
enabled: z.boolean(),
plugins: z.object(fullPluginShape),
diff --git a/packages/core/src/markdown/lint/default-config.ts b/packages/core/src/markdown/lint/default-config.ts
index d48eea5e4..a51f2ffb0 100644
--- a/packages/core/src/markdown/lint/default-config.ts
+++ b/packages/core/src/markdown/lint/default-config.ts
@@ -6,6 +6,7 @@ export const DEFAULT_MARKDOWNLINT_CONFIG: Record | undefined,
): Configuration {
diff --git a/packages/core/src/markdown/lint/index.ts b/packages/core/src/markdown/lint/index.ts
index 754f5e68e..a445cec4c 100644
--- a/packages/core/src/markdown/lint/index.ts
+++ b/packages/core/src/markdown/lint/index.ts
@@ -1,3 +1,4 @@
+
import { LINT_PLUGINS, type LinterConfig } from './plugins.ts';
import type { LintDiagnostic } from './types.ts';
diff --git a/packages/core/src/markdown/lint/plugins.ts b/packages/core/src/markdown/lint/plugins.ts
index 1cd40ef3a..65a1b564c 100644
--- a/packages/core/src/markdown/lint/plugins.ts
+++ b/packages/core/src/markdown/lint/plugins.ts
@@ -1,3 +1,4 @@
+
import { z } from 'zod';
import { DEFAULT_MARKDOWNLINT_CONFIG, resolveMarkdownlintConfig } from './default-config.ts';
import { fixMarkdownText, runMarkdownlint } from './markdownlint-runner.ts';
@@ -8,6 +9,7 @@ import {
type MarkdownlintSlice,
} from './types.ts';
+
const MarkdownlintRuleSettingSchema = z.union([
z.boolean(),
z.enum(MARKDOWNLINT_RULE_SEVERITIES),
diff --git a/packages/core/src/markdown/lint/types.ts b/packages/core/src/markdown/lint/types.ts
index 0f756702b..015a94c1c 100644
--- a/packages/core/src/markdown/lint/types.ts
+++ b/packages/core/src/markdown/lint/types.ts
@@ -1,3 +1,4 @@
+
export const LINT_PLUGIN_IDS = ['markdownlint'] as const;
export type LintPluginId = (typeof LINT_PLUGIN_IDS)[number];
@@ -53,6 +54,7 @@ export interface MarkdownlintSlice {
rules: Record;
}
+
interface RuleOptionSpecBase {
key: string;
description: string;
diff --git a/packages/core/src/markdown/math-promoter.ts b/packages/core/src/markdown/math-promoter.ts
index a5b511f25..bd79424c7 100644
--- a/packages/core/src/markdown/math-promoter.ts
+++ b/packages/core/src/markdown/math-promoter.ts
@@ -1,3 +1,4 @@
+
import type { Code, Root } from 'mdast';
import type { Math as MdastMath } from 'mdast-util-math';
import type { MdxJsxAttribute, MdxJsxFlowElement } from 'mdast-util-mdx';
diff --git a/packages/core/src/markdown/mdast-to-hast-handlers.ts b/packages/core/src/markdown/mdast-to-hast-handlers.ts
index 679c72ef7..f84967798 100644
--- a/packages/core/src/markdown/mdast-to-hast-handlers.ts
+++ b/packages/core/src/markdown/mdast-to-hast-handlers.ts
@@ -1,3 +1,4 @@
+
import type { Comment, Element, ElementContent, Properties } from 'hast';
import type { FootnoteDefinition, FootnoteReference } from 'mdast';
import type { MdxJsxFlowElement, MdxJsxTextElement } from 'mdast-util-mdx';
diff --git a/packages/core/src/markdown/mdast-to-html.ts b/packages/core/src/markdown/mdast-to-html.ts
index 8ce2b2492..df788a80d 100644
--- a/packages/core/src/markdown/mdast-to-html.ts
+++ b/packages/core/src/markdown/mdast-to-html.ts
@@ -1,3 +1,4 @@
+
import type { Element, Root as HastRoot, Text as HastText } from 'hast';
import type { Root as MdastRoot, Text as MdastText } from 'mdast';
import type { Handler } from 'mdast-util-to-hast';
diff --git a/packages/core/src/markdown/merged-walker.ts b/packages/core/src/markdown/merged-walker.ts
index bfd7e3109..fa3cfcd86 100644
--- a/packages/core/src/markdown/merged-walker.ts
+++ b/packages/core/src/markdown/merged-walker.ts
@@ -1,3 +1,4 @@
+
import type { Nodes, Parent, Root } from 'mdast';
import { SKIP, visit } from 'unist-util-visit';
import type { VFile } from 'vfile';
diff --git a/packages/core/src/markdown/mermaid-promoter.ts b/packages/core/src/markdown/mermaid-promoter.ts
index 1a897fca8..17ed2b829 100644
--- a/packages/core/src/markdown/mermaid-promoter.ts
+++ b/packages/core/src/markdown/mermaid-promoter.ts
@@ -1,3 +1,4 @@
+
import type { Code, Root } from 'mdast';
import type { MdxJsxAttribute, MdxJsxFlowElement } from 'mdast-util-mdx';
import { visit } from 'unist-util-visit';
diff --git a/packages/core/src/markdown/parse-with-fallback.ts b/packages/core/src/markdown/parse-with-fallback.ts
index 9864b3b60..7e4a9a48b 100644
--- a/packages/core/src/markdown/parse-with-fallback.ts
+++ b/packages/core/src/markdown/parse-with-fallback.ts
@@ -168,6 +168,7 @@ export function parseRecursive(
}
}
+
interface VFilePlace {
offset?: number;
start?: { offset?: number };
@@ -186,6 +187,7 @@ function extractErrorOffset(err: unknown): number | undefined {
return undefined;
}
+
interface Region {
start: number;
end: number;
@@ -209,6 +211,7 @@ function nearestBlankLineAfter(src: string, offset: number): number | null {
return null;
}
+
export interface TagEvent {
kind: 'open' | 'close' | 'self-close';
name: string;
@@ -355,6 +358,7 @@ function findFallbackRegion(src: string, errorOffset: number): Region {
return { start: blockStart, end: blockEnd };
}
+
interface SourceBlock {
src: string;
start: number;
@@ -452,6 +456,7 @@ function tryPerBlockFallback(
};
}
+
function wholeDocRawText(source: string): JSONContent {
return {
type: 'doc',
diff --git a/packages/core/src/markdown/parser-drop-closure.ts b/packages/core/src/markdown/parser-drop-closure.ts
index e6ab4fd57..43d420187 100644
--- a/packages/core/src/markdown/parser-drop-closure.ts
+++ b/packages/core/src/markdown/parser-drop-closure.ts
@@ -1,3 +1,4 @@
+
export type DroppedTokenAdjudication =
| {
kind: 'format-dof-axis';
diff --git a/packages/core/src/markdown/pipeline.ts b/packages/core/src/markdown/pipeline.ts
index 7f2d5adca..e9e507469 100644
--- a/packages/core/src/markdown/pipeline.ts
+++ b/packages/core/src/markdown/pipeline.ts
@@ -1,3 +1,4 @@
+
import {
type FromProseMirrorOptions,
fromProseMirror,
diff --git a/packages/core/src/markdown/position-aware-join.ts b/packages/core/src/markdown/position-aware-join.ts
index 5a5e4c42a..f8f5e8530 100644
--- a/packages/core/src/markdown/position-aware-join.ts
+++ b/packages/core/src/markdown/position-aware-join.ts
@@ -1,3 +1,4 @@
+
import type { Join } from 'mdast-util-to-markdown';
import type { Position } from 'unist';
diff --git a/packages/core/src/markdown/position-slice.ts b/packages/core/src/markdown/position-slice.ts
index 0f399d34c..e5b4c9eba 100644
--- a/packages/core/src/markdown/position-slice.ts
+++ b/packages/core/src/markdown/position-slice.ts
@@ -1,3 +1,4 @@
+
import type { Nodes, Root } from 'mdast';
import { visit } from 'unist-util-visit';
import type { VFile } from 'vfile';
@@ -476,7 +477,8 @@ export function applyPositionSliceToNode(
const first = source[startOff];
if (first !== '[' && first !== '<' && !node.title) {
node.data.sourceStyle = 'gfm-autolink';
- } else if (first === '[') {
+ }
+ else if (first === '[') {
const slice = source.slice(startOff, endOff);
const closeBracketIdx = slice.lastIndexOf('](');
if (closeBracketIdx !== -1) {
diff --git a/packages/core/src/markdown/rehype-plugins/skip-notion-whitespace.ts b/packages/core/src/markdown/rehype-plugins/skip-notion-whitespace.ts
index db10fa54b..ca225ce9b 100644
--- a/packages/core/src/markdown/rehype-plugins/skip-notion-whitespace.ts
+++ b/packages/core/src/markdown/rehype-plugins/skip-notion-whitespace.ts
@@ -1,3 +1,4 @@
+
import type { Comment, Element, Root, Text } from 'hast';
import type { Plugin } from 'unified';
diff --git a/packages/core/src/markdown/rehype-plugins/strip-cocoa-meta.ts b/packages/core/src/markdown/rehype-plugins/strip-cocoa-meta.ts
index 34068d103..3a2524a9a 100644
--- a/packages/core/src/markdown/rehype-plugins/strip-cocoa-meta.ts
+++ b/packages/core/src/markdown/rehype-plugins/strip-cocoa-meta.ts
@@ -1,3 +1,4 @@
+
import type { Element, ElementContent, Root } from 'hast';
import type { Plugin } from 'unified';
diff --git a/packages/core/src/markdown/rehype-plugins/strip-gdocs-wrapper.ts b/packages/core/src/markdown/rehype-plugins/strip-gdocs-wrapper.ts
index 93151d43b..2279a0189 100644
--- a/packages/core/src/markdown/rehype-plugins/strip-gdocs-wrapper.ts
+++ b/packages/core/src/markdown/rehype-plugins/strip-gdocs-wrapper.ts
@@ -1,3 +1,4 @@
+
import type { Element, Root } from 'hast';
import type { Plugin } from 'unified';
diff --git a/packages/core/src/markdown/rehype-plugins/strip-github-hovercard.ts b/packages/core/src/markdown/rehype-plugins/strip-github-hovercard.ts
index 188f02d83..a0f467c42 100644
--- a/packages/core/src/markdown/rehype-plugins/strip-github-hovercard.ts
+++ b/packages/core/src/markdown/rehype-plugins/strip-github-hovercard.ts
@@ -1,3 +1,4 @@
+
import type { Element, Root } from 'hast';
import type { Plugin } from 'unified';
diff --git a/packages/core/src/markdown/rehype-plugins/strip-gmail-classes.ts b/packages/core/src/markdown/rehype-plugins/strip-gmail-classes.ts
index 4ba2c3418..4e8542a21 100644
--- a/packages/core/src/markdown/rehype-plugins/strip-gmail-classes.ts
+++ b/packages/core/src/markdown/rehype-plugins/strip-gmail-classes.ts
@@ -1,3 +1,4 @@
+
import type { Element, Root } from 'hast';
import type { Plugin } from 'unified';
diff --git a/packages/core/src/markdown/rehype-plugins/strip-gsheets-wrapper.ts b/packages/core/src/markdown/rehype-plugins/strip-gsheets-wrapper.ts
index 1a74b1272..a3c96b24a 100644
--- a/packages/core/src/markdown/rehype-plugins/strip-gsheets-wrapper.ts
+++ b/packages/core/src/markdown/rehype-plugins/strip-gsheets-wrapper.ts
@@ -1,3 +1,4 @@
+
import type { Element, ElementContent, Root } from 'hast';
import type { Plugin } from 'unified';
diff --git a/packages/core/src/markdown/rehype-plugins/strip-mso-styles.ts b/packages/core/src/markdown/rehype-plugins/strip-mso-styles.ts
index de35e7b79..e95cf43d6 100644
--- a/packages/core/src/markdown/rehype-plugins/strip-mso-styles.ts
+++ b/packages/core/src/markdown/rehype-plugins/strip-mso-styles.ts
@@ -1,3 +1,4 @@
+
import type { Comment, Element, ElementContent, Root } from 'hast';
import type { Plugin } from 'unified';
diff --git a/packages/core/src/markdown/rehype-plugins/strip-slack-classes.ts b/packages/core/src/markdown/rehype-plugins/strip-slack-classes.ts
index b4be1b2ea..ffdb4d5b5 100644
--- a/packages/core/src/markdown/rehype-plugins/strip-slack-classes.ts
+++ b/packages/core/src/markdown/rehype-plugins/strip-slack-classes.ts
@@ -1,3 +1,4 @@
+
import type { Element, ElementContent, Root } from 'hast';
import type { Plugin } from 'unified';
diff --git a/packages/core/src/markdown/rehype-plugins/strip-vscode-spans.ts b/packages/core/src/markdown/rehype-plugins/strip-vscode-spans.ts
index 3c8d88b7c..e2a96d1a7 100644
--- a/packages/core/src/markdown/rehype-plugins/strip-vscode-spans.ts
+++ b/packages/core/src/markdown/rehype-plugins/strip-vscode-spans.ts
@@ -1,3 +1,4 @@
+
import type { Element, Root } from 'hast';
import type { Plugin } from 'unified';
diff --git a/packages/core/src/markdown/resolve-image-url.ts b/packages/core/src/markdown/resolve-image-url.ts
index cf2c9da8e..0389575ac 100644
--- a/packages/core/src/markdown/resolve-image-url.ts
+++ b/packages/core/src/markdown/resolve-image-url.ts
@@ -1,3 +1,4 @@
+
import { isRelativeUrl } from './safe-url.ts';
function isDevDiagnosticContext(): boolean {
diff --git a/packages/core/src/markdown/safe-url.ts b/packages/core/src/markdown/safe-url.ts
index 4d3250d3a..8be632ba4 100644
--- a/packages/core/src/markdown/safe-url.ts
+++ b/packages/core/src/markdown/safe-url.ts
@@ -1,3 +1,4 @@
+
export const SAFE_URL_SCHEMES = ['https', 'http', 'mailto', 'tel', 'ftp', 'sms'] as const;
const SCHEME_ALT = SAFE_URL_SCHEMES.map((s) => `${s}:`).join('|');
diff --git a/packages/core/src/markdown/serialize-fidelity.test-helper.ts b/packages/core/src/markdown/serialize-fidelity.test-helper.ts
index 0dd54070e..96304e7a5 100644
--- a/packages/core/src/markdown/serialize-fidelity.test-helper.ts
+++ b/packages/core/src/markdown/serialize-fidelity.test-helper.ts
@@ -1,3 +1,4 @@
+
import type { JSONContent } from '@tiptap/core';
import type { Nodes } from 'mdast';
import { expect } from 'vitest';
diff --git a/packages/core/src/markdown/serialize-helpers.ts b/packages/core/src/markdown/serialize-helpers.ts
index 46cc6a074..542204403 100644
--- a/packages/core/src/markdown/serialize-helpers.ts
+++ b/packages/core/src/markdown/serialize-helpers.ts
@@ -1,3 +1,4 @@
+
import type { Node as PmNode } from '@tiptap/pm/model';
import type { MdxJsxAttribute, MdxJsxExpressionAttribute, MdxJsxFlowElement } from 'mdast-util-mdx';
import type { PropDef, SerializeContext } from '../registry/types.ts';
diff --git a/packages/core/src/markdown/single-dollar-math-promoter.ts b/packages/core/src/markdown/single-dollar-math-promoter.ts
index be97d4d62..fe73659e6 100644
--- a/packages/core/src/markdown/single-dollar-math-promoter.ts
+++ b/packages/core/src/markdown/single-dollar-math-promoter.ts
@@ -1,3 +1,4 @@
+
import type { PhrasingContent, Root, Text } from 'mdast';
import type { InlineMath } from 'mdast-util-math';
import { SKIP, visit } from 'unist-util-visit';
diff --git a/packages/core/src/markdown/to-markdown-handlers.ts b/packages/core/src/markdown/to-markdown-handlers.ts
index 7f6dfcfc3..fd22d2c7b 100644
--- a/packages/core/src/markdown/to-markdown-handlers.ts
+++ b/packages/core/src/markdown/to-markdown-handlers.ts
@@ -1,3 +1,4 @@
+
import type { Nodes, Parents } from 'mdast';
import type { MdxJsxAttribute, MdxJsxExpressionAttribute, MdxJsxFlowElement } from 'mdast-util-mdx';
import type { Handle, Info, State } from 'mdast-util-to-markdown';
diff --git a/packages/core/src/markdown/unknown-mdast-guard.ts b/packages/core/src/markdown/unknown-mdast-guard.ts
index cd245c38c..c5bfd2386 100644
--- a/packages/core/src/markdown/unknown-mdast-guard.ts
+++ b/packages/core/src/markdown/unknown-mdast-guard.ts
@@ -1,3 +1,4 @@
+
import type { Root as MdastRoot } from 'mdast';
import type { VFile } from 'vfile';
diff --git a/packages/core/src/markdown/whitespace-char-ref.ts b/packages/core/src/markdown/whitespace-char-ref.ts
index d578476a7..ac9f5239b 100644
--- a/packages/core/src/markdown/whitespace-char-ref.ts
+++ b/packages/core/src/markdown/whitespace-char-ref.ts
@@ -1,3 +1,4 @@
+
const INLINE_WHITESPACE_BY_CODE: ReadonlyMap = new Map([
[0x20, ' '],
[0x09, '\t'],
diff --git a/packages/core/src/markdown/wiki-link-micromark.ts b/packages/core/src/markdown/wiki-link-micromark.ts
index 6dad26200..afd9936fe 100644
--- a/packages/core/src/markdown/wiki-link-micromark.ts
+++ b/packages/core/src/markdown/wiki-link-micromark.ts
@@ -17,6 +17,7 @@ declare module 'micromark-util-types' {
}
}
+
const CODE_BANG = 33; // !
const CODE_LBRACKET = 91; // [
const CODE_RBRACKET = 93; // ]
@@ -260,6 +261,7 @@ export function wikiLinkSyntax(): Extension {
};
}
+
function enterWikiLink(this: CompileContext, token: Token) {
this.enter(
{
@@ -385,6 +387,7 @@ export const wikiLinkToMarkdown: {
unsafe: [{ character: '[', inConstruct: ['phrasing'] }],
};
+
const MICROMARK_EXT = wikiLinkSyntax();
export function remarkWikiLink(this: Processor) {
From f975010a3f8bcdb066194d68b177fc7a323891e5 Mon Sep 17 00:00:00 2001
From: miles-kt-inkeep <135626743+miles-kt-inkeep@users.noreply.github.com>
Date: Fri, 24 Jul 2026 15:54:59 -0400
Subject: [PATCH 2/3] feat: pull-only sync mode (one-directional GitHub sync)
(#2857)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* spec: pull-only sync mode (approved; audit-corrected)
Adds the finalized spec, evidence trail, and meta artifacts for the
pull-only (one-directional) sync mode: config mode knob, engine mode,
B/B1 follower posture, one-shot pull contract, consent surfaces,
substrate build items S1-S6. Decision log D1-D15; overnight decision
journal included for morning review.
* [US-001] Add autoSync.mode single sync knob with legacy-enabled derive
Introduce autoSync.mode ('off' | 'pull' | 'full' | null) as the canonical
per-machine sync knob and the single source of truth for whether a project
pushes (only 'full' pushes). A config with no mode key derives its mode from
the legacy enabled boolean (true becomes full, false becomes off), so old and
new config shapes coexist with no migration. Widen the committed
autoSync.default seed to the same vocabulary while keeping the legacy boolean
seed valid.
- New packages/core/src/config/auto-sync-mode.ts holds the mode vocabulary and
the layer-resolution rules: legacy derive, committed-default translate,
per-machine precedence, and effective-mode composition.
- schema.ts registers the mode enum leaf and the widened default union with
.register before the nullable/default wrappers; the exhaustive field-registry
coverage test picks up the new leaf, proving the ordering.
- server-factory boot resolution resolves the effective mode and returns
mode === 'full', byte-identical for legacy enabled/default configs; pull
resolves to off until the engine grows a real mode.
- Widen the onboarding-gate input type to accept the mode vocabulary; gate
behavior is unchanged.
Tests cover parse of mode/default/enabled, the derive and precedence truth
tables, the cross-version pin (an old mode-unaware schema reads a mode-only
config as sync-off and never pushes), and unknown-key write-back preservation.
* [US-002] Engine sync-mode enum, single push gate, mode-aware denied handling
Replace the SyncEngine's syncEnabled boolean with a mode enum
(off/pull/full) as the single source of truth. setMode is the primary
API; setEnabled stays as a back-compat adapter (true maps to full, false
to off) with the idempotent same-value early-return preserved.
Push is suppressed by one authoritative gate at the runPushCycle
entrance, plus an early-return in schedulePush so a pull-only project
never even schedules a push cycle. A denied push probe now pauses only
full mode; a pull-only follower treats denial as its normal condition
and keeps fetching.
Add an additive syncMode field to SyncStatusSchema (optional for
version-skew safety), the engine status interface, and getStatus, with
syncEnabled derived as mode not equal to off. server-factory resolves
the effective mode and threads it into the engine constructor and the
config-reapply path. The app status hook mirrors the new optional field.
* [US-003] Add sync-wired test harness with real origin and engine
Give the integration harness a way to boot a server whose SyncEngine is
attached to a real remote, so downstream stories can drive the composed
fetch, fast-forward, CRDT-import, status chain in-process instead of
against a stubbed engine.
createSyncWiredTestServer builds a bare origin repo, a persistent author
clone that seeds and pushes origin/main, and a follower clone (the server
content dir) carrying the origin remote plus a project-local autoSync.mode.
The engine picks up that mode through the existing config-to-engine boot
path, so the real resolution chain is exercised. The helper returns the
attached engine, a pushToOrigin authoring helper, and the origin path, and
wraps cleanup to remove every scratch directory.
ServerOptions gains optional pullIntervalSeconds and pushIntervalSeconds,
forwarded to the SyncEngine constructor. These are additive test-tuning
knobs, matching the existing debounce and commitDebounceMs pattern; the
engine keeps its 30s and 60s defaults when they are omitted, so no existing
consumer changes behavior. The sync-wired harness parks them far out so a
background cadence never races the scenario a test drives explicitly.
A new smoke test proves the composed chain end to end: an upstream commit
fast-forwards onto disk, loads into a connecting client's live document,
and settles the engine status. A second test proves pull-only mode attaches
to the remote and fast-forwards without a push side.
* [US-004] Pull-only B1 fast-forward cycle with document-layer overlay
In pull mode the pull cycle now fast-forwards to origin tip without ever committing, merging, stashing, or leaving a MERGE_HEAD. Local uncommitted edits ride as a working-tree overlay: non-overlapping files pass through the FF, byte-identical overlays converge silently, overlapping edits are restored then re-applied keep-mine on the new tip, and an uncommitted deletion the tip modifies is re-applied (not resurrected). Diverged local history pauses instead of merging. A direct-exec fast-forward exposes git's exit code so refusals classify as divergence vs overlay-overlap, pinned against real git.
* [US-005] B1 conflict substrate: working-tree variant, auto-combine, no-commit resolution
Reconcile a pull-only overlay against the fast-forwarded tip per content file:
different-line edits auto-combine into a new overlay, same-line collisions keep
the local edit and raise a working-tree conflict the existing resolver serves,
and every path never commits on the user's behalf.
- core: add tryLineLevelCombine, reusing the diff3Merge primitive but reporting
same-line collisions instead of character-merging them.
- conflict-storage: add ConflictEntry.variant 'working-tree' and a parallel
resolveWorkingTreeConflict that writes the chosen bytes without any git
checkout/add/commit; the merge-native path is unchanged.
- sync-engine: doPullCycleB1 plans overlap reconciliation (content-gated
auto-combine vs collision with pinned theirs/base blobs), stays idle so the
rest of the repo keeps fast-forwarding, re-pins or auto-dissolves existing
conflicts each pull, and fires a resolved callback on resolution.
- api-extension: conflict-content serves theirs/base from the pinned blobs and
ours from the live doc or disk for working-tree entries.
- server-factory: clear the doc conflict lifecycle when a working-tree conflict
resolves or auto-dissolves (keep-mine changes no disk bytes).
- boot + engine reconciliation tolerate working-tree entries: a no-MERGE_HEAD
reconcile clears only merge-native conflicts and never pauses the engine.
* [US-006] One-shot pull with bounded outcome observability
Add a one-shot pull (trigger op 'pull') that runs in every mode and
reports a bounded outcome, the primitive spec B's receive surfaces
consume.
- doPullCycle and doPullCycleB1 now return a PullOutcome at every exit
(succeeded, up-to-date, conflict, refused, error-class). A single
recordPullOutcome writes additive lastPullUtc + lastPullOutcome and
fires the sync-status CC1 signal at every pull completion, background
or one-shot, so lastPullUtc changes each time (change-detection
contract).
- New pullOnce() runs in every mode: it refuses (recording 'refused')
only for single-flight, a pending conflict, or no remote, and for an
off/null project restores its resting state so a one-shot never leaves
the project enabled or starts a background loop. trigger('pull') routes
through it. The full-mode merge path stays byte-identical.
- PullOutcomeSchema in core sync-seed plus additive lastPullUtc and
lastPullOutcome on SyncStatusSchema and the app GitSyncStatus mirror,
both optional and nullable for version skew.
- Tests: core schema coverage, eight engine one-shot scenarios on real
bare-origin clones (mode-off no-enable, up-to-date, conflict,
single-flight refused, unreachable-remote, change-detection, no-remote),
and a composed HTTP test that POSTs trigger op 'pull' and polls status
for the outcome.
* [US-007] Mode transitions: stranded-commit overlay conversion and upgrades
Entering pull-only with local commits ahead of origin now folds those
commits into a working-tree overlay and realigns the branch, instead of
parking on a diverged-local-commits pause. A --mixed reset to the merge
base moves the branch ref and index without touching the working tree, so
on-screen docs are byte-identical: ahead-only lands directly on origin's
tip, a diverged branch lands on the common ancestor with the local edits
as overlay and the next fast-forward cycle carries it the rest of the way
per the existing B1 reconciliation. The pre-conversion tree is checkpointed
to the recoverable timeline before the ref moves.
Full->pull downgrades drain an in-flight push first (soft-disable
semantics); full mode keeps stranded commits to push and skips conversion;
pull->full needs nothing special (the overlay is uncommitted content the
push cycle commits and pushes). Adds a public probeUnpushedCommitCount for
the downgrade-confirm stranded count, and wires the checkpoint callback to
a shadow-repo safety checkpoint in server-factory.
* [US-008] Pull-only consent surfaces: onboarding fork + mode writers
Fork the auto-sync onboarding prompt on the push-permission probe:
allowed keeps today's full-sync prompt, denied gets a truthful pull-only
variant, unknown/pending suppress. The gate now resolves the answered
state from autoSync.mode (falling back to the legacy enabled boolean) and
returns the prompt variant instead of a boolean.
Add useSyncModeWriter that writes autoSync.mode on the project-local
binding; the onboarding dialog uses it (pull, full, or off). The legacy
useSyncEnabledWriter stays as-is so the share-receive surfaces and their
tests are untouched; the engine still honors the legacy key.
Give AutoSyncEnableWarning, the onboarding dialog, and the enable-confirm
dialog a pull-only variant with truthful copy (updates flow in, edits
stay local, overlapping edits pick a side). The confirm dialog gains a
stranded-commit disclosure for the full to pull downgrade.
Disclose silent enablement: a committed default of pull fires a one-time
notice on a never-asked machine (full and off stay silent), and a new
worktree inherits the parent's mode with the existing one-time notice.
Regenerate the en and pseudo Lingui catalogs.
* [US-009] Settings three-way sync mode control and paused-notice switch action
* [US-010] Badge: pull-only always visible with distinct following states
* [US-011] Auth-conditional pull cadence for pull-only followers
Anonymous pull-only followers now schedule background pulls at a gentler
floor (>=180s) while signed-in followers keep the responsive interval;
full sync cadence is unchanged. The engine resolves its credential tier
from the same gh, token-store, anonymous order the push probe uses, refreshed
before each pull-mode schedule. Jitter and counted backoff are unchanged in
both branches; cadence values stay hard-coded.
* [US-012] Bounded telemetry for sync mode, one-shot outcomes, and overlay stock
Add structured pino telemetry across the pull-only sync surfaces, all
bounded enums or numbers so nothing blows up log cardinality:
- setMode logs the mode change with a bounded source (config,
committed-default, worktree-inherit). The prompt, Settings, and
paused-notice surfaces all write the same per-machine mode, so the
server cannot tell them apart and they collapse to config.
- pullOnce logs its mode and outcome, so one-shot pulls are observable
without the noise of a per-background-cycle log.
- The pull-only fast-forward log carries the conflict-lifecycle counts
(created, auto-combined, auto-dissolved) plus an overlay-stock gauge
(docs carrying a standing overlay), which the per-pull rates miss.
- Working-tree conflict resolution logs the chosen strategy.
- Boot logs the active mode and its source so committed-default
activations show up alongside runtime changes.
- The desktop worktree seed logs the inherited mode under the
worktree-inherit source, the only site that knows that origin.
The overlay-stock read is best-effort so a transient git failure never
turns a completed pull into an error.
* [US-013] Corrigendum, changeset, and i18n sweep for pull-only sync
Append the post-ship corrigendum breadcrumb to every NG3 occurrence in the
2026-05-26 read-only-mode spec, plus its Future Work entry for the same
deferral, recording that NG3 read-only-fetch shipped as pull-only sync mode.
Add the pull-only sync mode changeset (minor per the pre-1.0 convention).
Confirm the en and pseudo Lingui catalogs are current (no diff; pseudo carries
the new pull-only strings).
Fix two latent defects the first full pnpm check surfaced (prior iterations ran
tiers in isolation and never hit md-conformance or the integration meta-tests):
- Regenerate the md-audit ng-anchors catalog, whose testFileCount was stale
after the feature added a test file under the audit's test globs.
- Convert SettingsDialogBody.sync-mode.dom.test.tsx from a dynamic
@testing-library/react import to a static one so it satisfies the Tier-3
filename contract; it is a genuine DOM mount test.
* test: structural guard confining legacy autoSync.enabled reads to the derive path
* qa: keep conflict lifecycle set while the engine still holds the conflict
A B1 pull-only conflict pull fast-forwards the working tree via a raw git
write (outside writeTracker), which the file-watcher re-emits as an 'update'.
That reconcile computes noop (base==ours==theirs, conflict content already
applied) and clearLifecycleConflict wiped the lifecycle.status the pull had
just set — desyncing the editor->resolver swap from the ConflictStore. The
resolver appeared briefly then vanished on the next pull and never returned on
reopen, while conflictCount stayed > 0.
Guard clearLifecycleConflict so it never clears while the engine still holds a
standing conflict for that doc; clearing is correct only once the engine agrees
the conflict is gone (resolve / auto-dissolve). No commit is ever created, so
D5 holds. Exports entryMatchesDocName from conflict-lifecycle-seed for reuse.
Also stabilizes the previously-flaky sync-wired-harness same-line-collision
integration test (same root cause).
* qa: fix lint in the autoSync.enabled confinement guard test
* fixup! local-review: baseline (pre-review state)
* fixup! local-review: address findings (pass 1)
* fixup! local-review: address findings (pass 1)
* spec: overnight ship journal and changelog for pull-only sync mode
* spec: close A1 wired-token cell after owner verification of denied-push pull
* fix(app): follower sync surfaces state the mode, not the push verdict
Two follower-facing UX corrections surfaced in review of the shipped
Settings/badge surfaces:
- Badge popover: a pull-only project now reads "Pull-only sync — updates
flow in from your remote…" instead of "You don't have permission to
push to this repo." A follower never pushes, so the push-permission
verdict is irrelevant; the signed-out reconnect line is likewise
suppressed for followers (push-framed).
- Settings mode control: the Full option is disabled for a genuine
read-only denial (selecting it would only enable, fail to push, and
pause). Signed-out denial keeps Full enabled — that user may have push
access once authenticated. Off and Pull-only stay reachable in both.
* fix(app): tooltip on the disabled Full sync option explains the push denial
A native title on a disabled button is suppressed by most browsers
(no pointer events), so hovering the greyed-out Full option showed
nothing. Wrap it in the shadcn Tooltip via a wrapper span that still
receives hover, surfacing "You don't have permission to push to this
repo." Keyboard users get the same reason from the read-only hint text.
* feat(app): drop the committed-default activation toast (D15 reopened)
Owner reopened D15 and chose symmetric silence: a committed
autoSync.default is silently effective for every mode with no
one-time notice. The toast disclosed only the safe mode (pull-only,
which never egresses local edits) while a committed 'full' default —
which auto-commits and pushes local edits — stayed silent, so the
disclosure ran the wrong way round. Rather than extend it to full,
remove it: the committed default is the maintainer's call and simply
takes effect (still suppresses the onboarding prompt; gate unchanged).
Deletes use-committed-default-autosync-notice + its test and the
EditorPane mount; the committedDefaultNoticeShown loose key is no
longer written. Worktree-inheritance notice is untouched. Spec FR9/D15
corrigendum'd; changeset + catalogs updated.
* fix(server): checkpoint the overlay before a B1 fast-forward reset
Closes the pull-only crash window the spec's failure-mode table calls
out as mandatory: when a background pull finds an overlapping
uncommitted edit, it resets that path to HEAD before the fast-forward,
so between the reset and the overlay re-write the bytes live only in
memory. A hard crash there would lose them for a doc with no live
client (an open doc is covered by client CRDT persistence).
New checkpointBeforeOverlayRestore engine seam fires before the reset,
only on overlap pulls, best-effort (mirrors convertStrandedCommitsToOverlay);
server-factory wires it to the shadow-repo safetyCheckpoint so the
pre-reset bytes survive on the shadow timeline. 3 engine tests incl. an
ordering proof that the checkpoint sees the overlay still on disk.
* feat(server): pull-only follows upstream on delete/modify
In pull-only mode a locally-deleted content doc that origin modified is
now restored from origin rather than surfaced as a delete/modify
conflict — the follower tracks upstream and a deletion has no authored
content to lose, so the deletion intent yields to the remote's change.
This deliberately reverses the 'never silently resurrect a deletion'
guard for pull-only content docs (owner directive).
Scope: the B1 planOverlapReconciliation deletion branch only. Non-content
(config/asset) deletions still stay deleted; full-sync's merge-native
delete/modify conflict is unchanged. Adds a bounded autoRestored count to
the pull telemetry. 2 engine tests flipped, 1 added; SPEC FR3/Q6
corrigendum'd.
* feat(app): redesign the sync badge popover — active/paused switch + mode toggle
The header Switch now means active-vs-paused, and a separate Full/Follow
control (shown only for push-capable users) sets the mode. Turning the
Switch off pauses instead of hiding the badge: the popover shows a paused
state and the mode stays editable. Pausing persists as mode:'off' plus a
new additive autoSync.resumeMode memory, so an older app reads a paused
project as not-syncing and never pushes for it. Entering Full always
confirms; resuming/staying on Follow never pushes. The manual action is a
single 'Sync' label (op pushes only when full+active) and is hidden only
for a never-enabled project. Behind-arrow dropped in the paused state (no
fetch while paused).
Adds core helpers (isSyncPaused/hasEverEnabledSync/displayActiveMode/
resumeModeOf + SYNC_ACTIVE_MODES) and useBadgeSyncControls. 36 badge dom
tests (6 rewritten, 3 added); full app dom tier green (2253).
* feat(app): rename the user-facing sync mode 'Pull-only' to 'Follow'
Non-git users read 'Follow' more clearly than 'Pull-only'. Sweeps the
visible strings across Settings, onboarding, the enable/confirm dialogs,
and the worktree-inheritance notice; dom-test assertions updated to match.
Internal identifiers and the config value are renamed in a follow-up.
* refactor: rename the sync mode value 'pull' to 'follow'
Completes the Pull-only -> Follow rename by renaming the internal
config/wire value and code identifiers to 'follow', so the codebase
matches the user-facing label (and the existing 'following' naming).
SYNC_MODES is now ['off','follow','full']. The earlier 'pull' value is a
permanently-accepted read alias (normalizeStoredMode + STORED_SYNC_MODES),
so on-disk configs — including local test projects — keep working with no
migration. The git operation 'pull' (trigger op, doPullCycle, git pull,
PullOutcome) is deliberately unchanged — it's a git verb, not the mode.
Docs, changeset, and configuration reference 'follow'; the spec keeps its
'pull-only' design-record terminology with a top corrigendum. Core config
348, server sync suites, app dom 2253, integration harness all green.
* test: wire SyncModeSchema accepts 'follow' (mode value rename)
* test: onboarding gate returns the 'follow' variant (mode rename)
* fix(desktop): normalize legacy 'pull' mode in worktree autosync inheritance
asModeOrBool/resolveRootAutoSyncMode used isSyncMode, which no longer
matches the legacy 'pull' value after the mode rename — a root config
carrying mode:'pull' would have failed to inherit. Route through
normalizeStoredMode so a stored 'pull' resolves to 'follow' (the tests now
seed 'pull' and assert 'follow', exercising the alias).
* fix(app): add a divider above the sync popover metadata and widen it
Matches the mockup: a separator line between the mode description and the
Repository/Last update/Mode rows, and w-64 -> w-80 so the description
reads on two lines instead of wrapping tight.
* fix(ok): address pull-only sync review findings
- Surface a conflict-store persist failure after fast-forward as an error
outcome instead of a clean conflict (save/addConflict/removeConflict now
report success; doPullCycleB1 backs off when a persist fails).
- Wrap planOverlapReconciliation so a thrown cat-file failure routes through
handleError and backs off instead of re-firing at the base interval.
- Re-assert realpath containment before the working-tree 'theirs' write after
the cat-file await, and add the realpath guard plus traced write to the
merge-native 'content' resolution.
- Guard the working-tree conflict invariant: skip a collision whose theirs blob
cannot be pinned, and reject a blob-less working-tree entry at addConflict.
- Rename the lastPullOutcome value 'error-class' to 'error'.
- Broaden the enabled-read confinement regex to catch bracket-indexed access
and document the destructuring blind spot.
- Add tests for the onContentConflictsResolved auto-dissolve callback and the
mineRestore overlay-restore after a fast-forward refusal.
* chore(ok): clean up pull->follow rename leftovers
Follow-up to the mode rename: update stale JSDoc/comments that still cited the
legacy 'pull' value (sync-seed SyncModeSchema + SyncDefaultWriter docs, core
barrel comment, useSyncModeWriter doc, EnableSyncConfirmDialog docs) and rename
the four settings-sync-*-pull data-testids to -follow so Settings matches the
badge popover's 'follow' selectors. Test selectors updated in lock-step.
* fix(app): sync badge popover polish and resume-flicker fix
- keep the badge mounted while a resume-from-paused is in flight: the local
config flips to an active mode before the server status catches up, so the
popover no longer closes and the icon no longer flickers on resume
- switch the Full/Follow mode toggle to the shared segmented control
- move the last-sync timestamp into the footer (bottom-left) beside the Sync
button (bottom-right); label it Synced (full) / Updated (follow)
- right-align the repository link to match the mode and footer rows
* udpate docs
* update docs sync section
---------
GitOrigin-RevId: 4209e45c1b9773df592d213bc077554970000b80
---
.changeset/pull-only-sync-mode.md | 22 +
docs/content/features/github-sync.mdx | 37 +-
docs/content/features/share.mdx | 2 +-
docs/content/reference/configuration.mdx | 7 +-
.../src/components/AutoSyncEnableWarning.tsx | 159 +-
.../AutoSyncOnboardingDialog.dom.test.tsx | 83 +-
.../components/AutoSyncOnboardingDialog.tsx | 36 +-
.../src/components/EditorPane.dom.test.tsx | 66 +-
packages/app/src/components/EditorPane.tsx | 12 +-
.../EnableSyncConfirmDialog.dom.test.tsx | 73 +
.../components/EnableSyncConfirmDialog.tsx | 36 +-
.../components/SyncStatusBadge.dom.test.tsx | 252 ++-
.../app/src/components/SyncStatusBadge.tsx | 480 ++--
.../auto-sync-onboarding-gate.test.ts | 114 +-
.../components/auto-sync-onboarding-gate.ts | 83 +-
.../src/components/bottom-composer-gate.ts | 2 +-
.../SettingsDialogBody.sections.dom.test.tsx | 140 +-
.../SettingsDialogBody.sync-mode.dom.test.tsx | 330 +++
.../src/components/settings/SyncSection.tsx | 345 ++-
.../use-enable-sync-with-confirm.dom.test.tsx | 226 +-
.../src/hooks/use-enable-sync-with-confirm.ts | 295 ++-
packages/app/src/hooks/use-git-sync-status.ts | 23 +-
.../use-worktree-autosync-notice.dom.test.tsx | 47 +-
.../hooks/use-worktree-autosync-notice.tsx | 44 +-
packages/app/src/locales/en/messages.json | 96 +-
packages/app/src/locales/en/messages.po | 212 +-
packages/app/src/locales/pseudo/messages.json | 96 +-
packages/app/src/locales/pseudo/messages.po | 202 +-
.../integration/sync-wired-harness.test.ts | 341 +++
.../app/tests/integration/test-harness.ts | 187 +-
packages/core/src/bridge/index.ts | 1 +
packages/core/src/bridge/merge-three-way.ts | 27 +
.../config/auto-sync-mode-confinement.test.ts | 96 +
.../core/src/config/auto-sync-mode.test.ts | 124 ++
packages/core/src/config/auto-sync-mode.ts | 176 ++
.../core/src/config/field-registry.test.ts | 18 +-
.../core/src/config/merge-layered.test.ts | 2 +-
.../core/src/config/schema-jsonschema.test.ts | 25 +
packages/core/src/config/schema-leaf.test.ts | 30 +
packages/core/src/config/schema.test.ts | 122 ++
packages/core/src/config/schema.ts | 89 +-
.../src/config/write-config-patch.test.ts | 28 +
packages/core/src/index.ts | 30 +
.../core/src/schemas/api/sync-seed.test.ts | 73 +
packages/core/src/schemas/api/sync-seed.ts | 46 +
.../main/worktree-autosync-inherit.test.ts | 112 +-
.../src/main/worktree-autosync-inherit.ts | 98 +-
packages/server/src/api-extension.ts | 93 +
.../server/src/boot-conflict-restore.test.ts | 75 +-
packages/server/src/boot.ts | 42 +-
.../server/src/conflict-lifecycle-seed.ts | 2 +-
packages/server/src/conflict-storage.test.ts | 98 +
packages/server/src/conflict-storage.ts | 134 +-
packages/server/src/server-factory.test.ts | 24 +-
packages/server/src/server-factory.ts | 132 +-
packages/server/src/symlink-guard.test.ts | 80 +
packages/server/src/symlink-guard.ts | 115 +
packages/server/src/sync-engine.test.ts | 1941 ++++++++++++++++-
packages/server/src/sync-engine.ts | 1321 ++++++++++-
packages/server/src/sync-timing.test.ts | 24 +-
packages/server/src/sync-timing.ts | 30 +
61 files changed, 8254 insertions(+), 1002 deletions(-)
create mode 100644 .changeset/pull-only-sync-mode.md
create mode 100644 packages/app/src/components/EnableSyncConfirmDialog.dom.test.tsx
create mode 100644 packages/app/src/components/settings/SettingsDialogBody.sync-mode.dom.test.tsx
create mode 100644 packages/app/tests/integration/sync-wired-harness.test.ts
create mode 100644 packages/core/src/config/auto-sync-mode-confinement.test.ts
create mode 100644 packages/core/src/config/auto-sync-mode.test.ts
create mode 100644 packages/core/src/config/auto-sync-mode.ts
create mode 100644 packages/server/src/symlink-guard.test.ts
create mode 100644 packages/server/src/symlink-guard.ts
diff --git a/.changeset/pull-only-sync-mode.md b/.changeset/pull-only-sync-mode.md
new file mode 100644
index 000000000..7effab3a7
--- /dev/null
+++ b/.changeset/pull-only-sync-mode.md
@@ -0,0 +1,22 @@
+---
+"@inkeep/open-knowledge": minor
+---
+
+Add Follow — a one-directional sync mode for people who follow a knowledge base they can read but not push to (share-link recipients and read-only followers).
+
+Previously, opening a repository you lacked push access to left your copy silently frozen: the sync onboarding prompt was suppressed, no sync config was written, and the engine parked itself the moment a push was denied. Follow fixes that dead end.
+
+What it does:
+
+- Keeps your local copy current automatically by fast-forwarding to the latest on GitHub, without ever pushing or committing on your behalf.
+- Lets your own uncommitted edits ride safely on top of incoming updates. Non-overlapping changes combine automatically; edits to the same lines the upstream changed surface in the familiar conflict view, where you choose keep-mine or take-theirs. Your branch always tracks the upstream tip and never forks.
+- Is always opt-in. Push-denied users are now offered Follow at first open with plain-language copy ("updates flow in from GitHub; your edits stay on this computer"), and anyone can choose Off / Follow / Full in Settings. A project owner can commit `autoSync.default: follow` so a fresh clone starts following.
+
+Also included:
+
+- Full-sync users who lose push access now get a "Switch to Follow" action on the sync-paused notice instead of a silent stall; switching keeps any unshared local commits as a local overlay.
+- The sync status badge stays visible for a followed project and shows a distinct following state (updating / up to date / conflict / offline).
+- Anonymous followers of public repositories poll more gently than signed-in followers.
+- A one-shot "Sync" pull that reports its outcome, for surfaces that need an on-demand refresh.
+
+Configuration note: the sync preference is now stored as a single `autoSync.mode` (`off` / `follow` / `full`). Existing `autoSync.enabled` settings keep working, and choosing a mode clears the legacy flag so the two can't disagree across versions. An app that predates `autoSync.mode` then reads the config as unanswered and re-prompts rather than acting on a stale toggle, so it never pushes for a mode you didn't consent to there.
diff --git a/docs/content/features/github-sync.mdx b/docs/content/features/github-sync.mdx
index 254ba3c1f..09f3648d6 100644
--- a/docs/content/features/github-sync.mdx
+++ b/docs/content/features/github-sync.mdx
@@ -3,10 +3,18 @@ title: GitHub sync
icon: custom/GitHub
description: Clone repos from GitHub, auto-sync changes with your team, and resolve conflicts.
---
-OpenKnowledge can connect a project to a GitHub remote repository and keep it synced over time. When GitHub sync is enabled, OpenKnowledge actively pulls commits from the remote and pushes commits back.
+
+import { Cloud } from 'lucide-react';
+
+OpenKnowledge can connect a project to a GitHub remote repository and keep it synced over time. When GitHub sync is enabled, OpenKnowledge keeps your copy current with the remote.
+
+There are two modes for GitHub sync:
+
+- **Full** pulls commits from the remote and pushes your commits back.
+- **Follow** fetches updates from the remote and never pushes or commits on your behalf.
- Only enable GitHub sync for repositories where you are comfortable with OpenKnowledge writing commits to the remote history. If you are worried about automated commits cluttering your Git history, do not enable sync for that repository.
+ Only enable full GitHub sync for repositories where you are comfortable with OpenKnowledge writing commits to the remote history. If you are worried about automated commits cluttering your Git history, do not enable full sync for that repository.
## What GitHub sync is for
@@ -27,7 +35,7 @@ These steps use the desktop app; from a terminal, [`ok clone`](/docs/reference/c
### Open the clone dialog
- From the Navigator window, click **Clone from GitHub**. You can open the navigator window from the editor by clicking on your project name in the bottom left and then clicking **Switch project**.
+ From the Navigator window, click **Clone from GitHub**. You can open the Navigator window from the editor by clicking on your project name in the bottom left and then clicking **Switch project**.
@@ -57,19 +65,30 @@ This will open a dialog where you can choose an owner and repository name. You c
## Enable GitHub sync
-When you open a freshly cloned project, you will be prompted to enable sync. You can also toggle it from the project settings page at any time.
+When you open a freshly cloned project, you will be prompted to enable sync. If you have full access to the repository, you will be prompted to enable **Full** sync. If you have read only access, you will be prompted to enable **Follow** sync. You can change the sync mode from the sync popover (the icon) or from **Settings → Sync** at any time.
+
+If you want to set a default sync setting for future collaborators, go to the project settings page and navigate to the **Sync** → **Shared default** section. This writes [`autoSync.default`](/docs/reference/configuration) — one of `off`, `follow`, or `full` — to the project's committed `.ok/config.yml`, pre-answering the enable-sync prompt for everyone who clones the repo; each machine's own auto-sync choice overrides it.
-While sync is active, OpenKnowledge:
+### Full
+
+With full sync active, OpenKnowledge:
- Fetches commits from the remote
- Commits your edits locally using the configured Git identity
- Pushes those commits back to the remote so collaborators see your changes
-If your Git identity isn't set, sync still commits under a default "OpenKnowledge" author; the sync status indicator reminds you to set one so teammates see your name.
+If your Git identity isn't set, OpenKnowledge commits under a default "OpenKnowledge" author; the sync status indicator reminds you to set one so teammates see your name.
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. This writes [`autoSync.default`](/docs/reference/configuration) to the project's committed `.ok/config.yml`, pre-answering the enable-sync prompt for everyone who clones the repo; each machine's own auto-sync choice overrides it.
+### Follow
+
+With follow sync active, OpenKnowledge:
+
+- Fetches commits from the remote
+- Never commits or pushes your edits to the remote
+
+Your own edits stay on this computer. You can keep editing a followed project: when an update arrives, non-overlapping changes combine automatically, and an edit to the same lines the remote changed surfaces in the [conflict view](#resolving-a-conflict) where you choose which side to keep. Your branch always tracks the remote tip and never forks.
## Authentication
@@ -104,7 +123,7 @@ A few situations stop sync from completing successfully. In each case the sync s
### Conflicts and pending changes
- **Unresolved conflicts.** If even one file has a conflict between your edits and your team's, sync waits. You can keep working on every other doc; only the conflicted files are frozen until you choose a side from the diff view. See [Resolving a conflict](#resolving-a-conflict) above.
-- **Local changes would be overwritten by an incoming update.** A teammate's update is ready to pull, but a file it touches has uncommitted local changes. Doc edits are committed automatically before merging, so this usually means other tracked files, like configs. Sync pauses rather than clobber your work; commit, stash, or discard those changes from a terminal and sync resumes.
+- **Local changes would be overwritten by an incoming update.** A teammate's update is ready to pull, but a file it touches has uncommitted local changes. Doc edits are committed automatically before merging, so this usually means other tracked files, like configs. Sync pauses rather than clobbering your work; commit, stash, or discard those changes from a terminal and sync resumes.
- **Edits made outside the app aren't ready yet.** If you (or another tool) edited a file directly on disk while sync wanted to merge, OpenKnowledge waits until those external edits settle before merging. Usually this clears in a second or two on its own.
### Project isn't on a normal branch
@@ -115,7 +134,7 @@ A few situations stop sync from completing successfully. In each case the sync s
### GitHub-side blockers
- **Authentication errors.** Your stored credentials expired, were revoked, or don't have access to this repository anymore. Reconnect from the sync indicator (or from **Settings → Account**) to clear the error.
-- **You don't have permission to push to this repo.** Someone shared a project backed by a GitHub repo where your account isn't a collaborator (or has read-only access on a private repo). OpenKnowledge checks this up front: instead of prompting you to enable sync that would just fail, the toggle is disabled with an inline "You don't have permission to push to this repo" message in both the sync indicator popover and **Settings → Sync**. If you had sync enabled on a different repo and switch to one you can't push to, OpenKnowledge pauses sync in-memory rather than mutating your preference; your `Auto-sync: on` setting is preserved for the next repo you have full access to. Ask the project owner for write access, then click sync to re-check.
+- **You don't have permission to push to this repo.** Someone shared a project backed by a GitHub repo where your account isn't a collaborator (or has read-only access on a private repo). OpenKnowledge checks this up front and offers **Follow** instead of full sync.
- **Protected branches and rejected pushes.** If the target branch requires reviews, signed commits, or status checks, GitHub rejects the push. OpenKnowledge turns sync off automatically so it stops retrying, and it stays off until you turn it back on from the sync indicator or **Settings → Sync**. Switch to a branch you can push to, or use a pull request for the protected branch instead.
### Temporary problems
diff --git a/docs/content/features/share.mdx b/docs/content/features/share.mdx
index 957df9262..a172ad524 100644
--- a/docs/content/features/share.mdx
+++ b/docs/content/features/share.mdx
@@ -66,6 +66,6 @@ Even on the right branch, a doc or folder can be renamed or deleted after a link
### Receiving without push permission
-If the shared repo is one your GitHub account can't push to (you're not a collaborator on a public repo, or you have read-only access on a private one), OpenKnowledge skips the usual "Enable auto-sync?" prompt. Sync is also disabled for the same reason in **Settings → Sync**. If the project owner grants you write access later, push permission is re-checked the next time the project's server starts. See [GitHub sync: GitHub-side blockers](/docs/features/github-sync#github-side-blockers) for the full set of permission shapes the sync UI handles.
+If the shared repo is one your GitHub account can't push to (you're not a collaborator on a public repo, or you have read-only access on a private one), OpenKnowledge offers **[Follow](/docs/features/github-sync#follow)** at first open instead of the full "Enable auto-sync?" prompt: your copy keeps up to date by pulling the latest from GitHub automatically, while your own edits stay on your computer. You can keep editing — an incoming update combines with your non-overlapping changes automatically, and an edit to the same lines surfaces as a conflict to resolve.
See [GitHub sync](/docs/features/github-sync) for how to automatically sync changes to GitHub, and [Timeline and recovery](/docs/features/timeline-and-recovery) for per-doc history once the doc is open.
diff --git a/docs/content/reference/configuration.mdx b/docs/content/reference/configuration.mdx
index 90ede825c..ac31b5871 100644
--- a/docs/content/reference/configuration.mdx
+++ b/docs/content/reference/configuration.mdx
@@ -8,7 +8,7 @@ OpenKnowledge reads YAML config from three places:
- **Project**: `./.ok/config.yml` (this project, committed to git)
- **User**: `~/.ok/global.yml` (every project on your machine)
-- **Project-local**: `./.ok/local/config.yml` (this project on this machine; gitignored). Holds per-machine-per-project preferences such as `autoSync.enabled`. Maintained by the editor (auto-sync onboarding modal, sync popover, Settings pane Sync section); you don't normally hand-edit it.
+- **Project-local**: `./.ok/local/config.yml` (this project on this machine; gitignored). Holds per-machine-per-project preferences such as `autoSync.mode`. Maintained by the editor (auto-sync onboarding modal, sync popover, Settings pane Sync section); you don't normally hand-edit it.
All files are optional; defaults cover everything.
@@ -36,8 +36,9 @@ A key set in a file more specific than its scope (a user-scope key in `.ok/confi
| `contentRules.plugins.markdownlint.enabled` | boolean | `false` | project | Enable the [markdownlint](/docs/advanced/content-rules/markdownlint) content-rules plugin for this project (shared via git). Off by default — turn it on in **Settings → This project → Plugins**. The rules themselves live in your native `.markdownlint.*` file, not here. |
| `appearance.theme` | `"light" \| "dark" \| "system"` | (unset) | user | Editor theme. |
| `appearance.sidebar.showHiddenFiles` | boolean | `false` | project-local | Show files whose path segments start with `.`. The sidebar otherwise lists every file on disk under the content directory; tooling internals (`.git/`, `.ok/`, `node_modules/`) stay hidden regardless. Toggled from the sidebar's right-click menu or the macOS **View → Show Hidden Files** item. |
-| `autoSync.enabled` | `boolean \| null` | `null` | project-local | Per-machine git auto-sync toggle. `null` means "unanswered"; the editor's onboarding modal triggers on first remote-detected open. |
-| `autoSync.default` | `boolean \| null` | `null` | project | Committed seed for each machine's first-open auto-sync choice: `true` = on, `false` = off, `null` = ask. Lets a maintainer pre-answer the onboarding prompt for everyone who clones; a per-machine `autoSync.enabled` overrides it. |
+| `autoSync.mode` | `"off" \| "follow" \| "full" \| null` | `null` | project-local | Per-machine sync mode for this project: `off` (no sync), `follow` (one-directional — pull remote changes, never push your own; the earlier value `pull` is accepted as an alias), `full` (bidirectional pull and push). `null` means "unanswered"; the editor's onboarding modal triggers on first remote-detected open. Supersedes `autoSync.enabled`. |
+| `autoSync.enabled` | `boolean \| null` | `null` | project-local | Legacy per-machine auto-sync toggle, superseded by `autoSync.mode`. Read only when `mode` is absent (`true` = full, `false` = off). `null` means "unanswered". |
+| `autoSync.default` | `"off" \| "follow" \| "full" \| boolean \| null` | `null` | project | Committed seed for each machine's first-open sync mode: `off` / `follow` / `full`, or the legacy boolean (`true` = full, `false` = off). `null` = ask. Lets a maintainer pre-answer the onboarding prompt for everyone who clones; a per-machine `autoSync.mode` overrides it. Compatibility note: a committed string value (`off` / `follow` / `full`) is rejected by app versions released before `autoSync.mode` existed, resetting that machine to config defaults until it updates (it never silently syncs). The legacy boolean seed (`true` / `false`) stays readable by older apps — commit `follow` only once collaborators are on a current version. |
| `editor.wordWrap` | boolean | `true` | user | Soft-wrap long lines in the source-mode CodeMirror editor. A personal preference, not project-shared. Toggle from the Settings pane. |
| `appearance.preview.autoOpen` | boolean | `true` | user | Whether the agent should open or refresh the OpenKnowledge preview when it edits a doc through the MCP. Default `true` lets the agent route the preview by host capability: the host's in-app browser (Cursor preview pane, Codex's built-in browser, Claude Code Desktop) when one exists, the system browser otherwise. Set `false` to keep the agent's hands off your preview window. This is useful when you're already viewing the doc in OK Desktop, a browser tab on a second display, a non-default browser, or any flow where your extensions / accessibility tooling only work in your own browser. The agent then surfaces the URL on request but does not navigate. The change takes effect on the next preview-related tool call. Toggle from **Settings → Preferences → "Open preview when agent edits"**. |
| `terminal.enabled` | `boolean \| null` | `null` | project-local | Opt-out for the in-app terminal (a real OS shell at full user privilege). On by default; set `false` to disable it for this project on this machine. |
diff --git a/packages/app/src/components/AutoSyncEnableWarning.tsx b/packages/app/src/components/AutoSyncEnableWarning.tsx
index 4e854cbdc..988e7f032 100644
--- a/packages/app/src/components/AutoSyncEnableWarning.tsx
+++ b/packages/app/src/components/AutoSyncEnableWarning.tsx
@@ -1,8 +1,37 @@
import { Trans } from '@lingui/react/macro';
-import { ArrowRightLeft, Eye, GitCommitVertical } from 'lucide-react';
+import {
+ ArrowDownToLine,
+ ArrowRightLeft,
+ Eye,
+ GitCommitVertical,
+ GitMerge,
+ Laptop,
+} from 'lucide-react';
+import type { ReactNode } from 'react';
+import type { AutoSyncOnboardingVariant } from '@/components/auto-sync-onboarding-gate';
import { DialogDescription, DialogTitle } from '@/components/ui/dialog';
-export function AutoSyncEnableDialogIntro() {
+interface SyncVariantProps {
+ /** 'full' = bidirectional sync copy (default); 'pull' = one-directional. */
+ variant?: AutoSyncOnboardingVariant;
+}
+
+export function AutoSyncEnableDialogIntro({ variant = 'full' }: SyncVariantProps) {
+ if (variant === 'follow') {
+ return (
+ <>
+
+ Enable Follow?
+
+
+
+ Follow fetches updates from your remote git repository and fast-forwards your copy
+ automatically. Your local edits stay on this computer and are never pushed.
+
+
+ >
+ );
+ }
return (
<>
@@ -18,7 +47,7 @@ export function AutoSyncEnableDialogIntro() {
);
}
-export function AutoSyncEnableWarning() {
+export function AutoSyncEnableWarning({ variant = 'full' }: SyncVariantProps) {
return (
@@ -28,48 +57,104 @@ export function AutoSyncEnableWarning() {
Heads up
- Pulls may overwrite uncommitted edits in your local files.
-
-
-
-
+ }
+ title={Uncommitted changes}
+ body={Pulls may overwrite uncommitted edits in your local files.}
+ />
+
-
-
- Commits happen automatically
-
-
-
- OpenKnowledge will create commits and push them to your remote automatically. If you
- do not want automatic commits in your git history, you should not enable auto-sync.
-
-
-
-
-
-
-
-
- Shared repositories
-
-
- Collaborators see your in-progress edits as soon as they sync.
-
-
-
+ }
+ title={Commits happen automatically}
+ body={
+
+ OpenKnowledge will create commits and push them to your remote automatically. If you do
+ not want automatic commits in your git history, you should not enable auto-sync.
+
+ }
+ />
+ }
+ title={Shared repositories}
+ body={Collaborators see your in-progress edits as soon as they sync.}
+ />
+ >
+ );
+}
+
+function PullOnlyBullets() {
+ return (
+ <>
+
+ }
+ title={Updates flow in}
+ body={New changes from your remote appear in your copy automatically.}
+ />
+
+ }
+ title={Your edits stay on this computer}
+ body={
+
+ Follow never pushes or commits your local changes. They stay only on this machine.
+
+ }
+ />
+
+ }
+ title={Overlapping edits}
+ body={
+
+ If an update arrives for something you are editing, you choose which version to keep,
+ like a merge conflict.
+
+ }
+ />
+ >
+ );
+}
+
+function WarningBullet({
+ icon,
+ title,
+ body,
+}: {
+ icon: ReactNode;
+ title: ReactNode;
+ body: ReactNode;
+}) {
+ return (
+
+ {icon}
+
+
{title}
+
{body}
);
diff --git a/packages/app/src/components/AutoSyncOnboardingDialog.dom.test.tsx b/packages/app/src/components/AutoSyncOnboardingDialog.dom.test.tsx
index a387b2112..0238051c5 100644
--- a/packages/app/src/components/AutoSyncOnboardingDialog.dom.test.tsx
+++ b/packages/app/src/components/AutoSyncOnboardingDialog.dom.test.tsx
@@ -1,11 +1,13 @@
+import type { SyncMode } from '@inkeep/open-knowledge-core';
import { cleanup, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { ReactNode } from 'react';
import { afterEach, describe, expect, test, vi } from 'vitest';
+import type { AutoSyncOnboardingVariant } from './auto-sync-onboarding-gate.ts';
-type SyncWriter = (enabled: boolean) => { ok: true } | { ok: false; error: string };
+type ModeWriter = (mode: SyncMode) => { ok: true } | { ok: false; error: string };
-let writer: SyncWriter | null = null;
+let writer: ModeWriter | null = null;
const toastErrors: string[] = [];
import * as actualLinguiMacro from '@lingui/react/macro';
@@ -28,12 +30,15 @@ vi.doMock('sonner', () => ({
}));
vi.doMock('@/hooks/use-enable-sync-with-confirm', () => ({
- useSyncEnabledWriter: () => writer,
+ useSyncModeWriter: () => writer,
}));
-async function renderDialog(onResolved: () => void = () => {}) {
+async function renderDialog(
+ variant: AutoSyncOnboardingVariant = 'full',
+ onResolved: () => void = () => {},
+) {
const { AutoSyncOnboardingDialog } = await import('./AutoSyncOnboardingDialog');
- render();
+ render();
}
describe('AutoSyncOnboardingDialog runtime behavior', () => {
@@ -48,9 +53,9 @@ describe('AutoSyncOnboardingDialog runtime behavior', () => {
expect(typeof mod.AutoSyncOnboardingDialog).toBe('function');
});
- test('renders stable primary and secondary choices without a close affordance', async () => {
+ test('full variant renders the bidirectional prompt without a close affordance', async () => {
writer = () => ({ ok: true });
- await renderDialog();
+ await renderDialog('full');
expect(screen.getByRole('button', { name: 'Enable auto-sync' })).not.toBeNull();
expect(screen.getByRole('button', { name: 'Keep disabled' })).not.toBeNull();
@@ -58,45 +63,61 @@ describe('AutoSyncOnboardingDialog runtime behavior', () => {
expect(screen.getByRole('note').textContent).toContain('Heads up');
});
- test('disables both choices until the project-local sync writer is ready', async () => {
- writer = null;
- await renderDialog();
+ test('pull variant explains one-directional sync and that edits stay local', async () => {
+ writer = () => ({ ok: true });
+ await renderDialog('follow');
- expect(
- (screen.getByRole('button', { name: 'Enable auto-sync' }) as HTMLButtonElement).disabled,
- ).toBe(true);
- expect(
- (screen.getByRole('button', { name: 'Keep disabled' }) as HTMLButtonElement).disabled,
- ).toBe(true);
+ expect(screen.getByRole('button', { name: 'Enable Follow' })).not.toBeNull();
+ const note = screen.getByRole('note').textContent ?? '';
+ expect(note).toContain('Updates flow in');
+ expect(note).toContain('stay only on this machine');
});
- test('persists enable and disable choices through the shared writer', async () => {
- const writerCalls: boolean[] = [];
- const resolvedCalls: string[] = [];
- writer = (enabled) => {
- writerCalls.push(enabled);
+ test('persists the resolved mode per variant through the writer', async () => {
+ const modeWrites: SyncMode[] = [];
+ writer = (mode) => {
+ modeWrites.push(mode);
return { ok: true };
};
- await renderDialog(() => resolvedCalls.push('resolved'));
+ await renderDialog('full');
await userEvent.click(screen.getByRole('button', { name: 'Enable auto-sync' }));
- expect(writerCalls).toEqual([true]);
- expect(resolvedCalls).toEqual(['resolved']);
+ expect(modeWrites).toEqual(['full']);
cleanup();
- writerCalls.length = 0;
- resolvedCalls.length = 0;
- await renderDialog(() => resolvedCalls.push('resolved'));
+ modeWrites.length = 0;
+ await renderDialog('follow');
+ await userEvent.click(screen.getByRole('button', { name: 'Enable Follow' }));
+ expect(modeWrites).toEqual(['follow']);
+ });
+
+ test('Keep disabled writes mode off in either variant', async () => {
+ const modeWrites: SyncMode[] = [];
+ writer = (mode) => {
+ modeWrites.push(mode);
+ return { ok: true };
+ };
+ await renderDialog('follow');
await userEvent.click(screen.getByRole('button', { name: 'Keep disabled' }));
+ expect(modeWrites).toEqual(['off']);
+ });
+
+ test('disables both choices until the project-local sync writer is ready', async () => {
+ writer = null;
+ await renderDialog('follow');
- expect(writerCalls).toEqual([false]);
- expect(resolvedCalls).toEqual(['resolved']);
+ expect(
+ (screen.getByRole('button', { name: 'Enable Follow' }) as HTMLButtonElement).disabled,
+ ).toBe(true);
+ expect(
+ (screen.getByRole('button', { name: 'Keep disabled' }) as HTMLButtonElement).disabled,
+ ).toBe(true);
});
test('surfaces writer failures without resolving the dialog', async () => {
const resolvedCalls: string[] = [];
writer = () => ({ ok: false, error: 'binding unavailable' });
- await renderDialog(() => resolvedCalls.push('resolved'));
+ await renderDialog('full', () => resolvedCalls.push('resolved'));
await userEvent.click(screen.getByRole('button', { name: 'Enable auto-sync' }));
@@ -107,7 +128,7 @@ describe('AutoSyncOnboardingDialog runtime behavior', () => {
test('Escape does not resolve the non-dismissible prompt', async () => {
const resolvedCalls: string[] = [];
writer = () => ({ ok: true });
- await renderDialog(() => resolvedCalls.push('resolved'));
+ await renderDialog('full', () => resolvedCalls.push('resolved'));
await userEvent.keyboard('{Escape}');
await waitFor(() => {
diff --git a/packages/app/src/components/AutoSyncOnboardingDialog.tsx b/packages/app/src/components/AutoSyncOnboardingDialog.tsx
index 695f68a6c..0318cdbbb 100644
--- a/packages/app/src/components/AutoSyncOnboardingDialog.tsx
+++ b/packages/app/src/components/AutoSyncOnboardingDialog.tsx
@@ -1,11 +1,13 @@
/**
- * AutoSyncOnboardingDialog — first-run prompt explaining git auto-sync.
+ * AutoSyncOnboardingDialog — first-run prompt explaining git sync.
*
- * Shown once per project when the sync engine reports a remote exists AND
- * the project-local config field `autoSync.enabled` has not been set
- * (`=== null`). Both buttons write through the project-local ConfigBinding
- * so the choice flows down the standard Y.Text → persistence-hook →
- * file-watcher → SyncEngine pipeline.
+ * Shown once per project when the sync engine reports a remote exists AND this
+ * machine has not chosen a sync mode. `resolveAutoSyncOnboarding` decides
+ * whether to show it and which `variant` to pass: 'full' (bidirectional, the
+ * push-capable prompt) or 'pull' (one-directional, for a push-denied follower).
+ * Both buttons write `autoSync.mode` through the project-local ConfigBinding so
+ * the choice flows down the standard Y.Text → persistence-hook → file-watcher →
+ * SyncEngine pipeline.
*/
import { Trans, useLingui } from '@lingui/react/macro';
import { toast } from 'sonner';
@@ -13,6 +15,7 @@ import {
AutoSyncEnableDialogIntro,
AutoSyncEnableWarning,
} from '@/components/AutoSyncEnableWarning';
+import type { AutoSyncOnboardingVariant } from '@/components/auto-sync-onboarding-gate';
import { Button } from '@/components/ui/button';
import {
DialogBody,
@@ -21,23 +24,28 @@ import {
DialogHeader,
Dialog as DialogRoot,
} from '@/components/ui/dialog';
-import { useSyncEnabledWriter } from '@/hooks/use-enable-sync-with-confirm';
+import { useSyncModeWriter } from '@/hooks/use-enable-sync-with-confirm';
interface AutoSyncOnboardingDialogProps {
open: boolean;
+ variant: AutoSyncOnboardingVariant;
onResolved: () => void;
}
-export function AutoSyncOnboardingDialog({ open, onResolved }: AutoSyncOnboardingDialogProps) {
+export function AutoSyncOnboardingDialog({
+ open,
+ variant,
+ onResolved,
+}: AutoSyncOnboardingDialogProps) {
const { t } = useLingui();
- const writer = useSyncEnabledWriter();
+ const writer = useSyncModeWriter();
function persistChoice(enabled: boolean): void {
if (writer === null) {
toast.error(t`Sync settings not yet loaded — try again in a moment`);
return;
}
- const result = writer(enabled);
+ const result = writer(enabled ? (variant === 'follow' ? 'follow' : 'full') : 'off');
if (!result.ok) {
const detail = result.error;
toast.error(
@@ -60,14 +68,14 @@ export function AutoSyncOnboardingDialog({ open, onResolved }: AutoSyncOnboardin
>
-
+
-
+
- You can turn this on later in Settings → Sync.
+ You can change this later in Settings → Sync.
@@ -82,7 +90,7 @@ export function AutoSyncOnboardingDialog({ open, onResolved }: AutoSyncOnboardin
Keep disabled
diff --git a/packages/app/src/components/EditorPane.dom.test.tsx b/packages/app/src/components/EditorPane.dom.test.tsx
index c220fdaf1..ccc17f229 100644
--- a/packages/app/src/components/EditorPane.dom.test.tsx
+++ b/packages/app/src/components/EditorPane.dom.test.tsx
@@ -68,11 +68,12 @@ let projectLocalSynced = false;
let projectSynced = false;
let projectLocalConfig: { autoSync?: { enabled?: boolean | null } } | null = null;
let projectConfig: { autoSync?: { default?: boolean | null } } | null = null;
+let pushPermissionCheckStatus: 'allowed' | 'denied' | 'unknown' | undefined = 'allowed';
vi.doMock('@/hooks/use-git-sync-status', () => ({
useGitSyncStatus: () => ({
hasRemote,
- pushPermission: { checkStatus: 'allowed' },
+ pushPermission: { checkStatus: pushPermissionCheckStatus },
}),
}));
@@ -163,11 +164,20 @@ vi.doMock('@/editor/components/TagDialog', () => ({
}));
vi.doMock('./AutoSyncOnboardingDialog', () => ({
- AutoSyncOnboardingDialog: ({ open, onResolved }: { open: boolean; onResolved: () => void }) => (
+ AutoSyncOnboardingDialog: ({
+ open,
+ variant,
+ onResolved,
+ }: {
+ open: boolean;
+ variant: string;
+ onResolved: () => void;
+ }) => (
diff --git a/packages/app/src/components/SyncStatusBadge.dom.test.tsx b/packages/app/src/components/SyncStatusBadge.dom.test.tsx
index 33fbc98fb..77db0bc3c 100644
--- a/packages/app/src/components/SyncStatusBadge.dom.test.tsx
+++ b/packages/app/src/components/SyncStatusBadge.dom.test.tsx
@@ -26,7 +26,13 @@ vi.doMock('@lingui/react/macro', () => ({
let status: GitSyncStatus | null = null;
let fetchError: 'network' | 'server' | null = null;
-let projectLocalConfig: { autoSync?: { enabled?: boolean | null } } | null = {
+let projectLocalConfig: {
+ autoSync?: {
+ enabled?: boolean | null;
+ mode?: 'off' | 'follow' | 'full' | null;
+ resumeMode?: 'follow' | 'full';
+ };
+} | null = {
autoSync: { enabled: false },
};
let projectLocalSynced = true;
@@ -183,6 +189,58 @@ describe('SyncStatusBadge helper behavior', () => {
);
expect(shouldOfferSignInAgain(undefined)).toBe(false);
});
+
+ test('a pull-only toggle stays enabled even when push is denied', async () => {
+ const { shouldDisableSyncSwitch } = await import('./SyncStatusBadge');
+
+ // Pull-only never pushes, so a denied probe is irrelevant to its toggle.
+ expect(shouldDisableSyncSwitch(true, 'denied', 'follow')).toBe(false);
+ // Off/full still gate on denial — enabling there reaches push-requiring full sync.
+ expect(shouldDisableSyncSwitch(true, 'denied', 'off')).toBe(true);
+ expect(shouldDisableSyncSwitch(true, 'denied', 'full')).toBe(true);
+ // Cold start (project-local config not yet synced) disables regardless of mode.
+ expect(shouldDisableSyncSwitch(false, 'allowed', 'follow')).toBe(true);
+ });
+
+ test('displayState promotes a pull-only idle-with-conflicts project to conflict', async () => {
+ const { displayState } = await import('./SyncStatusBadge');
+
+ expect(
+ displayState({ ...baseStatus, syncMode: 'follow', state: 'idle', conflictCount: 1 }),
+ ).toBe('conflict');
+ // No conflicts, or full sync, leaves the state untouched (full sets 'conflict' itself).
+ expect(
+ displayState({ ...baseStatus, syncMode: 'follow', state: 'idle', conflictCount: 0 }),
+ ).toBe('idle');
+ expect(displayState({ ...baseStatus, syncMode: 'full', state: 'idle', conflictCount: 1 })).toBe(
+ 'idle',
+ );
+ expect(
+ displayState({ ...baseStatus, syncMode: 'follow', state: 'pulling', conflictCount: 1 }),
+ ).toBe('pulling');
+ });
+
+ test('tooltipLabel frames a following project as up to date, never "Sync off"', async () => {
+ const { tooltipLabel } = await import('./SyncStatusBadge');
+ const following = { ...baseStatus, syncMode: 'follow' as const };
+
+ expect(tooltipLabel({ ...following, state: 'idle', behind: 0 })).toBe('Up to date');
+ expect(tooltipLabel({ ...following, state: 'idle', behind: 3 })).toBe('3 behind');
+ expect(tooltipLabel({ ...following, state: 'pulling' })).toBe('Updating');
+ expect(tooltipLabel({ ...following, state: 'idle', conflictCount: 2 })).toBe('2 conflicts');
+ // Even a following payload that arrives with syncEnabled false is never "Sync off".
+ expect(tooltipLabel({ ...following, state: 'idle', syncEnabled: false })).toBe('Up to date');
+ // Full sync is unchanged.
+ expect(tooltipLabel({ ...baseStatus, state: 'idle' })).toBe('Synced');
+ expect(tooltipLabel({ ...baseStatus, state: 'idle', syncEnabled: false })).toBe('Sync off');
+ });
+
+ test('formatPausedReason explains a pull-only divergence in plain language', async () => {
+ const { formatPausedReason } = await import('./SyncStatusBadge');
+ expect(formatPausedReason('diverged-local-commits')).toBe(
+ 'Local commits are keeping this copy from updating',
+ );
+ });
});
describe('SyncStatusBadge runtime behavior', () => {
@@ -234,6 +292,24 @@ describe('SyncStatusBadge runtime behavior', () => {
expect(screen.getByRole('button', { name: /Sync status:/ })).toBeTruthy();
});
+ test('stays visible while a resume is in flight (config active, server still disabled)', async () => {
+ // Resuming from paused flips the local config mode 'off'→active optimistically
+ // (so `paused` clears) while the server status still lags at disabled/off. The
+ // badge must stay mounted in that window — unmounting closes the popover and
+ // flickers the icon (the user toggles on, the popover vanishes, then returns).
+ projectLocalConfig = { autoSync: { mode: 'follow' } };
+ status = {
+ ...baseStatus,
+ state: 'disabled',
+ syncEnabled: false,
+ syncMode: 'off',
+ pausedReason: undefined,
+ } as GitSyncStatus;
+ await renderBadge();
+
+ expect(screen.getByRole('button', { name: /Sync status:/ })).toBeTruthy();
+ });
+
test('paused disabled state opens details explaining why sync stopped', async () => {
status = {
...baseStatus,
@@ -247,13 +323,13 @@ describe('SyncStatusBadge runtime behavior', () => {
expect(screen.getByText('Protected branch — cannot push')).toBeTruthy();
});
- test('popover switch checked state reads local config, not server syncEnabled', async () => {
+ test('popover switch is on (Pause sync) when a mode is active', async () => {
status = { ...baseStatus, state: 'idle', syncEnabled: false };
projectLocalConfig = { autoSync: { enabled: true } };
await renderBadge();
await openPopover();
- const toggle = screen.getByRole('switch', { name: 'Disable sync' });
+ const toggle = screen.getByRole('switch', { name: 'Pause sync' });
expect(toggle.getAttribute('aria-checked')).toBe('true');
});
@@ -264,20 +340,182 @@ describe('SyncStatusBadge runtime behavior', () => {
await renderBadge();
await openPopover();
- const toggle = screen.getByRole('switch', { name: 'Enable sync' }) as HTMLButtonElement;
+ const toggle = screen.getByRole('switch', { name: 'Resume sync' }) as HTMLButtonElement;
expect(toggle.disabled).toBe(true);
});
- test('off to on opens confirmation before patching project-local config', async () => {
+ test('resuming from off opens confirmation before patching (defaults to full)', async () => {
status = { ...baseStatus, state: 'idle', syncEnabled: false };
projectLocalConfig = { autoSync: { enabled: false } };
await renderBadge();
await openPopover();
- await userEvent.click(screen.getByRole('switch', { name: 'Enable sync' }));
+ await userEvent.click(screen.getByRole('switch', { name: 'Resume sync' }));
expect(patches).toEqual([]);
+ // Resuming a never-enabled project defaults to full sync (which pushes), so
+ // it confirms; the write clears the resume memory.
await userEvent.click(screen.getByRole('button', { name: 'Enable auto-sync' }));
- expect(patches).toEqual([{ autoSync: { enabled: true } }]);
+ expect(patches).toEqual([{ autoSync: { mode: 'full', enabled: null, resumeMode: null } }]);
+ });
+
+ test.each([
+ ['idle', { state: 'idle' }, 'Sync status: Up to date'],
+ ['pulling', { state: 'pulling' }, 'Sync status: Updating'],
+ ['fetching', { state: 'fetching' }, 'Sync status: Checking for updates'],
+ ['offline', { state: 'offline' }, 'Sync status: Offline'],
+ ['auth-error', { state: 'auth-error' }, 'Sync status: Reconnect required'],
+ ] as const)('pull-only %s renders a distinct following badge', async (_label, override, ariaName) => {
+ status = { ...baseStatus, syncMode: 'follow', ...override } as GitSyncStatus;
+ await renderBadge();
+
+ expect(screen.getByRole('button', { name: ariaName })).toBeTruthy();
+ });
+
+ test('pull-only conflict surfaces on the badge even though the engine stays idle', async () => {
+ // Pull-only holds the engine idle while a same-line collision waits in the
+ // ledger; the badge promotes conflictCount to the conflict rendering.
+ status = {
+ ...baseStatus,
+ syncMode: 'follow',
+ state: 'idle',
+ conflictCount: 1,
+ } as GitSyncStatus;
+ await renderBadge();
+
+ expect(screen.getByRole('button', { name: 'Sync status: Conflict' })).toBeTruthy();
+ expect(screen.getByText('1')).toBeTruthy();
+ });
+
+ test('pull-only stays visible even in a disabled-without-reason payload', async () => {
+ // The engine never parks a pull-only project here, but the hide rule must
+ // exempt following projects so one is never silently hidden.
+ status = {
+ ...baseStatus,
+ syncMode: 'follow',
+ state: 'disabled',
+ pausedReason: undefined,
+ } as GitSyncStatus;
+ await renderBadge();
+
+ expect(screen.getByRole('button', { name: /Sync status:/ })).toBeTruthy();
+ });
+
+ test('following popover shows the follow state, not "sync off", with a Sync action', async () => {
+ status = { ...baseStatus, syncMode: 'follow', state: 'idle' } as GitSyncStatus;
+ projectLocalConfig = { autoSync: { mode: 'follow' } };
+ await renderBadge();
+ await openPopover();
+
+ const toggle = screen.getByRole('switch', { name: 'Pause sync' });
+ expect(toggle.getAttribute('aria-checked')).toBe('true');
+ expect(screen.queryByText(/Sync is off/)).toBeNull();
+ expect(screen.getByRole('button', { name: 'Sync' })).toBeTruthy();
+ });
+
+ test('following popover keeps the Switch reachable when push is denied', async () => {
+ status = {
+ ...baseStatus,
+ syncMode: 'follow',
+ state: 'idle',
+ pushPermission: { checkStatus: 'denied' },
+ } as GitSyncStatus;
+ projectLocalConfig = { autoSync: { mode: 'follow' } };
+ await renderBadge();
+ await openPopover();
+
+ const toggle = screen.getByRole('switch', { name: 'Pause sync' }) as HTMLButtonElement;
+ expect(toggle.disabled).toBe(false);
+ });
+
+ test('following popover states the mode instead of the push-permission verdict', async () => {
+ status = {
+ ...baseStatus,
+ syncMode: 'follow',
+ state: 'idle',
+ pushPermission: { checkStatus: 'denied', deniedReason: 'no-collaborator' },
+ } as GitSyncStatus;
+ projectLocalConfig = { autoSync: { mode: 'follow' } };
+ await renderBadge();
+ await openPopover();
+
+ expect(screen.getByTestId('sync-popover-mode-line').textContent).toContain('Follow');
+ expect(screen.queryByText(/don't have permission to push/)).toBeNull();
+ // A genuine read-only user cannot choose Full, so the mode toggle is hidden.
+ expect(screen.queryByTestId('sync-popover-mode-toggle')).toBeNull();
+ });
+
+ test('pull-only popover suppresses the signed-out reconnect line (push-framed)', async () => {
+ status = {
+ ...baseStatus,
+ syncMode: 'follow',
+ state: 'idle',
+ pushPermission: { checkStatus: 'denied', deniedReason: 'not-authenticated' },
+ } as GitSyncStatus;
+ projectLocalConfig = { autoSync: { mode: 'follow' } };
+ await renderBadge();
+ await openPopover();
+
+ expect(screen.queryByText(/signed out — sign in to resume syncing/)).toBeNull();
+ expect(screen.getByTestId('sync-popover-mode-line')).toBeTruthy();
+ });
+
+ test('a paused (was-enabled) project stays visible and shows the paused popover', async () => {
+ // Engine reports a paused project as disabled; the config (resumeMode set)
+ // keeps the badge visible and drives the paused rendering.
+ status = {
+ ...baseStatus,
+ state: 'disabled',
+ syncMode: 'off',
+ pausedReason: undefined,
+ syncEnabled: false,
+ } as GitSyncStatus;
+ projectLocalConfig = { autoSync: { mode: 'off', resumeMode: 'full' } };
+ await renderBadge();
+
+ // Not hidden (the disabled-hide rule exempts a paused project).
+ expect(screen.getByRole('button', { name: 'Sync status: Sync paused' })).toBeTruthy();
+ await openPopover();
+
+ expect(screen.getByTestId('sync-popover-paused-line')).toBeTruthy();
+ // Switch is off (resume), and a manual Sync is still offered (ever enabled).
+ expect(screen.getByRole('switch', { name: 'Resume sync' }).getAttribute('aria-checked')).toBe(
+ 'false',
+ );
+ expect(screen.getByRole('button', { name: 'Sync' })).toBeTruthy();
+ // The mode is still editable while paused (push-capable user).
+ expect(screen.getByTestId('sync-popover-mode-toggle')).toBeTruthy();
+ });
+
+ test('changing the mode while paused only updates the resume memory (no sync)', async () => {
+ status = {
+ ...baseStatus,
+ state: 'disabled',
+ syncMode: 'off',
+ syncEnabled: false,
+ } as GitSyncStatus;
+ projectLocalConfig = { autoSync: { mode: 'off', resumeMode: 'full' } };
+ await renderBadge();
+ await openPopover();
+
+ // Flip Full → Follow while paused: writes resumeMode only, mode stays off,
+ // no confirmation (nothing syncs until the Switch is turned on).
+ await userEvent.click(screen.getByTestId('sync-popover-mode-follow'));
+ expect(patches).toEqual([{ autoSync: { resumeMode: 'follow' } }]);
+ });
+
+ test('the manual Sync action is hidden for a never-enabled project', async () => {
+ // Off with no resume memory = never enabled → no manual Sync affordance.
+ status = {
+ ...baseStatus,
+ state: 'dormant',
+ syncMode: 'off',
+ syncEnabled: false,
+ } as GitSyncStatus;
+ projectLocalConfig = { autoSync: { mode: 'off' } };
+ await renderBadge();
+ await openPopover();
+
+ expect(screen.queryByRole('button', { name: 'Sync' })).toBeNull();
});
});
diff --git a/packages/app/src/components/SyncStatusBadge.tsx b/packages/app/src/components/SyncStatusBadge.tsx
index 88f06b699..fce99831b 100644
--- a/packages/app/src/components/SyncStatusBadge.tsx
+++ b/packages/app/src/components/SyncStatusBadge.tsx
@@ -7,7 +7,14 @@
* Click opens a popover with last-sync details and action buttons.
*/
-import type { PushPermissionWire, SyncErrorCode } from '@inkeep/open-knowledge-core';
+import {
+ isSyncActiveMode,
+ isSyncPaused,
+ type PushPermissionWire,
+ resolveLocalAutoSyncMode,
+ type SyncErrorCode,
+ type SyncMode,
+} from '@inkeep/open-knowledge-core';
import { plural, t } from '@lingui/core/macro';
import { Plural, Trans, useLingui } from '@lingui/react/macro';
import {
@@ -16,14 +23,12 @@ import {
Cloud,
CloudOff,
LogIn,
+ Pause,
RefreshCw,
UserCog,
} from 'lucide-react';
import { useConflicts } from '@/hooks/use-conflicts';
-import {
- useEnableSyncWithConfirm,
- useSyncEnabledWriter,
-} from '@/hooks/use-enable-sync-with-confirm';
+import { useBadgeSyncControls } from '@/hooks/use-enable-sync-with-confirm';
import type { GitSyncStatus } from '@/hooks/use-git-sync-status';
import { useGitSyncStatusDetailed } from '@/hooks/use-git-sync-status';
import { useConfigContext } from '@/lib/config-provider';
@@ -33,6 +38,7 @@ import { EnableSyncConfirmDialog } from './EnableSyncConfirmDialog';
import { Button } from './ui/button';
import { Popover, PopoverContent, PopoverTrigger } from './ui/popover';
import { Switch } from './ui/switch';
+import { ToggleGroup, ToggleGroupItem } from './ui/toggle-group';
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
// ── helpers ──────────────────────────────────────────────────────────────────
@@ -42,9 +48,12 @@ import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
* stream drives the visible state, so a rejected trigger (offline / server
* down) needs no UI handling — but it gets a breadcrumb rather than being
* swallowed silently, so a "Sync now did nothing" report is triageable.
+ *
+ * A following (pull-only) project uses the `pull` op so the trigger runs the
+ * one-directional cycle; full sync uses `sync` (fetch + merge + push).
*/
-function triggerSyncFromBadge(): void {
- triggerSync('sync').catch((err) => {
+function triggerSyncFromBadge(op: 'sync' | 'pull' = 'sync'): void {
+ triggerSync(op).catch((err) => {
console.warn(
'[sync-badge] manual sync trigger failed',
err instanceof Error ? err.message : err,
@@ -67,15 +76,41 @@ function formatRelative(iso: string | null): string {
return new Date(iso).toLocaleDateString();
}
+// ── mode-aware state derivation ───────────────────────────────────────────────
+
+/** True when the project follows upstream one-directionally (pull-only). */
+function isFollowingMode(status: GitSyncStatus): boolean {
+ return status.syncMode === 'follow';
+}
+
+/**
+ * The state the badge renders. A pull-only engine deliberately holds `idle`
+ * while a same-line collision waits in the conflict ledger — that keeps the rest
+ * of the repo fast-forwarding, but a following project must still surface the
+ * collision, so promote it to `conflict` on the badge. Full sync sets the
+ * `conflict` state directly, so this never changes what it renders.
+ */
+export function displayState(status: GitSyncStatus): GitSyncStatus['state'] {
+ if (isFollowingMode(status) && status.state === 'idle' && status.conflictCount > 0) {
+ return 'conflict';
+ }
+ return status.state;
+}
+
// ── inner: icon + color per state ────────────────────────────────────────────
interface BadgeIconProps {
status: GitSyncStatus;
+ /** True when the user paused a previously-enabled project (config-driven). */
+ paused?: boolean;
}
-function BadgeIcon({ status }: BadgeIconProps) {
+function BadgeIcon({ status, paused }: BadgeIconProps) {
const cls = 'size-3.5';
- switch (status.state) {
+ // Paused is a config state the engine's `state` can't express (it reads as
+ // disabled/dormant), so it wins the icon.
+ if (paused) return ;
+ switch (displayState(status)) {
case 'dormant':
// Available: remote exists but sync not yet enabled
return ;
@@ -104,9 +139,11 @@ function BadgeIcon({ status }: BadgeIconProps) {
}
function badgeLabel(status: GitSyncStatus): string {
- switch (status.state) {
+ switch (displayState(status)) {
case 'idle':
- if (status.ahead > 0) return `↑${status.ahead}`;
+ // A following project never pushes, so "ahead" is not an actionable
+ // signal — only surface how far behind upstream the copy is.
+ if (!isFollowingMode(status) && status.ahead > 0) return `↑${status.ahead}`;
if (status.behind > 0) return `↓${status.behind}`;
return '';
case 'fetching':
@@ -126,16 +163,18 @@ function badgeLabel(status: GitSyncStatus): string {
// ── popover content ───────────────────────────────────────────────────────────
-function stateLabel(state: GitSyncStatus['state']): string {
+function stateLabel(state: GitSyncStatus['state'], following = false): string {
switch (state) {
case 'dormant':
return t`No git remote`;
case 'idle':
- return t`Synced`;
+ // A following project tracks upstream one-directionally, so "Synced"
+ // (which implies a two-way exchange) would overstate what happened.
+ return following ? t`Up to date` : t`Synced`;
case 'fetching':
- return t`Fetching`;
+ return following ? t`Checking for updates` : t`Fetching`;
case 'pulling':
- return t`Pulling`;
+ return following ? t`Updating` : t`Pulling`;
case 'pushing':
return t`Pushing`;
case 'conflict':
@@ -161,6 +200,10 @@ export function formatPausedReason(reason: string): string {
return t`Resolve conflict in your terminal`;
case 'detached-head':
return t`Detached HEAD — checkout a branch to resume`;
+ case 'diverged-local-commits':
+ // Pull-only reaches this when local commits sit ahead of origin: it never
+ // pushes or merges to reconcile them, so updates stall until they're gone.
+ return t`Local commits are keeping this copy from updating`;
case 'auth-error':
return t`Reconnect required`;
case 'protected-branch':
@@ -393,16 +436,37 @@ export function shouldOfferSignInAgain(pushPermission: PushPermissionWire | unde
export function shouldDisableSyncSwitch(
projectLocalSynced: boolean | undefined,
pushPermissionCheckStatus: 'allowed' | 'denied' | 'unknown' | undefined,
+ currentMode?: SyncMode,
): boolean {
if (!projectLocalSynced) return true;
+ // A following (pull-only) project never pushes, so a denied push probe is
+ // irrelevant to it — the toggle (whose only action is turning sync off) stays
+ // enabled. A denied probe still disables an off/full project, where enabling
+ // reaches the push-requiring full mode.
+ if (currentMode === 'follow') return false;
if (pushPermissionCheckStatus === 'denied') return true;
return false;
}
-function tooltipLabel(status: GitSyncStatus): string {
- if (!status.syncEnabled) return t`Sync off`;
- if (status.state === 'idle') {
+export function tooltipLabel(status: GitSyncStatus, paused = false): string {
+ if (paused) return t`Sync paused`;
+ const following = isFollowingMode(status);
+ // A following project is always on, so it never reads as "Sync off" — that
+ // label is reserved for a genuinely disabled (mode off) project.
+ if (!status.syncEnabled && !following) return t`Sync off`;
+ const state = displayState(status);
+ if (state === 'conflict' && status.conflictCount > 0) {
+ const { conflictCount } = status;
+ return plural(conflictCount, { one: '# conflict', other: '# conflicts' });
+ }
+ if (state === 'idle') {
const { ahead, behind } = status;
+ if (following) {
+ // "ahead" is not actionable for a project that never pushes; only how far
+ // behind upstream it is (before a pull completes) is worth surfacing.
+ if (behind > 0) return t`${behind} behind`;
+ return t`Up to date`;
+ }
if (ahead > 0 && behind > 0) {
return t`${ahead} ahead, ${behind} behind`;
}
@@ -410,11 +474,7 @@ function tooltipLabel(status: GitSyncStatus): string {
if (behind > 0) return t`${behind} behind`;
return t`Synced`;
}
- if (status.state === 'conflict' && status.conflictCount > 0) {
- const { conflictCount } = status;
- return plural(conflictCount, { one: '# conflict', other: '# conflicts' });
- }
- return stateLabel(status.state);
+ return stateLabel(state, following);
}
interface PopoverBodyProps {
@@ -425,112 +485,163 @@ interface PopoverBodyProps {
function PopoverBody({ status, onSignIn, onSetIdentity }: PopoverBodyProps) {
const { t } = useLingui();
- const { ahead, behind, conflictCount } = status;
+ const { behind, conflictCount } = status;
const { projectLocalConfig, projectLocalSynced } = useConfigContext();
- const enabled = projectLocalConfig?.autoSync?.enabled ?? false;
+ const autoSync = projectLocalConfig?.autoSync;
+ const {
+ active,
+ paused,
+ everEnabled,
+ toggleMode,
+ confirmOpen,
+ setConfirmOpen,
+ pendingMode,
+ strandedCommitCount,
+ onToggleActive,
+ onModeSelect,
+ onConfirm,
+ } = useBadgeSyncControls(autoSync, status.ahead);
+ const localMode = resolveLocalAutoSyncMode(autoSync) ?? 'off';
+ // `following` (status-driven) is the ACTIVE pull state; the paused branch is
+ // config-driven since the engine reports a paused project as disabled.
+ const following = isFollowingMode(status);
+ const state = displayState(status);
const lastSyncedRelative = formatRelative(status.lastSyncUtc);
- const writer = useSyncEnabledWriter();
- const { confirmOpen, setConfirmOpen, onToggleRequest, onConfirm } =
- useEnableSyncWithConfirm(writer);
+ // The Full/Follow control appears only for a user who can actually choose —
+ // a genuine read-only collaborator can only follow, so it's hidden for them
+ // (a signed-out user may gain push after auth, so they keep it).
+ const genuineReadOnly =
+ status.pushPermission?.checkStatus === 'denied' &&
+ status.pushPermission.deniedReason !== 'not-authenticated';
+ const canChooseMode = !genuineReadOnly;
// The "Review conflicts" affordance navigates to the first conflicted file
// (so the editor-area DiffViewBoundary mounts via the lifecycle observer).
- // There is no side-sheet to open — the sidebar Conflicts section is the
- // project-level list, the editor-area DiffView is the resolution surface.
const { conflicts } = useConflicts();
const firstConflict = conflicts[0] ?? null;
+ const showConflictButton = !paused && state === 'conflict' && firstConflict !== null;
+ const showAuthButton = !paused && state === 'auth-error';
+ const showSyncButton =
+ everEnabled && !showConflictButton && !showAuthButton && (paused || state !== 'dormant');
+ // A manual sync from a full-active project pushes too; every other case
+ // (following, or paused) pulls only.
+ const manualSyncOp: 'sync' | 'pull' = active && localMode === 'full' ? 'sync' : 'pull';
+
return (
- ))}
- {shouldOfferReconnect(status.pushPermission) ? (
- // Signed-out denial (no credential resolved) — reconnecting resumes
- // sync, so offer it here. Takes precedence over the button-less
- // `pausedReason`/`denied` branches below, which is exactly the stuck
- // state (sync was enabled, then the credential went away). The genuine
- // read-only-collaborator denial keeps the button-less copy.
-
-
- You're signed out — sign in to resume syncing.
-
- ) : shouldOfferSignInAgain(status.pushPermission) ? (
- // Probe-401 branch: surface a "Sign in again" affordance without
- // disabling sync — the probe couldn't reach a verdict, so the user's
- // existing sync preference is preserved while they re-authenticate.
- // The button reuses `onSignIn`, the same handler wired for the
- // `auth-error` state below.
-
-
- Your GitHub session expired — sign in again to verify push access.
-
- Sync paused — resolve conflicts to resume.
+ {following ? (
+ // A follower keeps fast-forwarding the rest of the repo while a single
+ // document waits on resolution, so it is never globally "paused".
+ A document has a conflict — resolve it to keep it up to date.
+ ) : (
+ Sync paused — resolve conflicts to resume.
+ )}
+ {showSyncButton && (
+ // One label for every mode ("Sync"); the op pushes only when full
+ // and active, and pulls otherwise (following, or a paused one-shot).
+
+ )}
+ {showAuthButton && (
+
+ )}
+ {showConflictButton && firstConflict && (
+
+ )}
+
+
+ )}
);
}
@@ -649,6 +783,17 @@ interface SyncStatusBadgeProps {
export function SyncStatusBadge({ onSignIn, onSetIdentity }: SyncStatusBadgeProps = {}) {
const { t } = useLingui();
const { status, fetchError } = useGitSyncStatusDetailed();
+ const { projectLocalConfig } = useConfigContext();
+ // Paused = the user turned off a previously-enabled project. It's config-only
+ // (the engine reports it as disabled/dormant), so the badge reads it here to
+ // stay visible and render the paused icon/label.
+ const paused = isSyncPaused(projectLocalConfig?.autoSync);
+ // The local config is the source of truth for user intent; the server status
+ // lags a resume by a beat. When the config already resolves to an active mode
+ // (follow/full) but the engine still reports the pre-resume `disabled`, keep
+ // the badge visible so the resume doesn't unmount it — unmounting closes the
+ // popover and flickers the icon until the status catches up.
+ const localWantsSync = isSyncActiveMode(resolveLocalAutoSyncMode(projectLocalConfig?.autoSync));
// Surface a lightweight connectivity warning when the server has been
// reachable before (we have a prior status) but the last refresh failed.
@@ -692,11 +837,24 @@ export function SyncStatusBadge({ onSignIn, onSetIdentity }: SyncStatusBadgeProp
// protected-branch) so the user can see *why* sync stopped — without it,
// the only signal would be a missing badge. Manual disable clears
// `pausedReason`; auto-disable sets it. (Unsafe states like auth-error /
- // conflict / offline already render — they need attention.)
- if (status.state === 'disabled' && !status.pausedReason) return null;
+ // conflict / offline already render — they need attention.) A following
+ // (pull-only) project is always on, so it must always show its state.
+ // A paused project (config: was enabled, now off) stays visible so the user
+ // isn't stranded in Settings to resume — the engine reports it as disabled.
+ if (
+ status.state === 'disabled' &&
+ !status.pausedReason &&
+ status.syncMode !== 'follow' &&
+ !paused &&
+ !localWantsSync
+ ) {
+ return null;
+ }
- const label = badgeLabel(status);
- const syncStateLabel = stateLabel(status.state);
+ const label = paused ? '' : badgeLabel(status);
+ const syncStateLabel = paused
+ ? t`Sync paused`
+ : stateLabel(displayState(status), isFollowingMode(status));
const showIdentityDot = Boolean(status.identityUnresolved);
return (
@@ -714,7 +872,7 @@ export function SyncStatusBadge({ onSignIn, onSetIdentity }: SyncStatusBadgeProp
: t`Sync status: ${syncStateLabel}`
}
>
-
+
{label && (
{label}
@@ -729,9 +887,9 @@ export function SyncStatusBadge({ onSignIn, onSetIdentity }: SyncStatusBadgeProp
- {tooltipLabel(status)}
+ {tooltipLabel(status, paused)}
-
+
diff --git a/packages/app/src/components/auto-sync-onboarding-gate.test.ts b/packages/app/src/components/auto-sync-onboarding-gate.test.ts
index 54b75f8c9..160cfefec 100644
--- a/packages/app/src/components/auto-sync-onboarding-gate.test.ts
+++ b/packages/app/src/components/auto-sync-onboarding-gate.test.ts
@@ -1,110 +1,122 @@
import { describe, expect, test } from 'vitest';
import {
type AutoSyncOnboardingGateInputs,
- shouldShowAutoSyncOnboarding,
+ resolveAutoSyncOnboarding,
} from './auto-sync-onboarding-gate.ts';
-// Baseline = every condition aligned so the modal SHOWS. Each test flips one
-// input and asserts the gate's response, keeping every condition on its own
-// independently verifiable row.
+// Baseline = every condition aligned so the modal SHOWS the full-sync variant
+// (probe allowed). Each test flips one input and asserts the gate's response,
+// keeping every condition on its own independently verifiable row.
const SHOWING: AutoSyncOnboardingGateInputs = {
autoSyncOnboardingDismissed: false,
hasRemote: true,
projectLocalSynced: true,
projectSynced: true,
- projectLocalConfig: { autoSync: { enabled: null } },
+ projectLocalConfig: { autoSync: { mode: null, 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);
+describe('resolveAutoSyncOnboarding', () => {
+ test('shows the full-sync variant when aligned and the probe allows pushing', () => {
+ expect(resolveAutoSyncOnboarding(SHOWING)).toBe('full');
});
test('hidden once dismissed this session', () => {
- expect(shouldShowAutoSyncOnboarding({ ...SHOWING, autoSyncOnboardingDismissed: true })).toBe(
- false,
- );
+ expect(resolveAutoSyncOnboarding({ ...SHOWING, autoSyncOnboardingDismissed: true })).toBeNull();
});
test('hidden without a git remote', () => {
- expect(shouldShowAutoSyncOnboarding({ ...SHOWING, hasRemote: false })).toBe(false);
- expect(shouldShowAutoSyncOnboarding({ ...SHOWING, hasRemote: undefined })).toBe(false);
+ expect(resolveAutoSyncOnboarding({ ...SHOWING, hasRemote: false })).toBeNull();
+ expect(resolveAutoSyncOnboarding({ ...SHOWING, hasRemote: undefined })).toBeNull();
});
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);
+ expect(resolveAutoSyncOnboarding({ ...SHOWING, projectLocalSynced: false })).toBeNull();
+ expect(resolveAutoSyncOnboarding({ ...SHOWING, projectLocalSynced: undefined })).toBeNull();
});
test('hidden until the committed project binding has synced (flash-free)', () => {
- // Without the projectSynced guard, a project shipping default:false would
- // briefly read the schema default (null) and flash the modal open.
- expect(shouldShowAutoSyncOnboarding({ ...SHOWING, projectSynced: false })).toBe(false);
- expect(shouldShowAutoSyncOnboarding({ ...SHOWING, projectSynced: undefined })).toBe(false);
+ // Without the projectSynced guard, a project shipping a committed default
+ // would briefly read the schema default (null) and flash the modal open.
+ expect(resolveAutoSyncOnboarding({ ...SHOWING, projectSynced: false })).toBeNull();
+ expect(resolveAutoSyncOnboarding({ ...SHOWING, projectSynced: undefined })).toBeNull();
});
test('hidden until project-local config hydrates', () => {
- expect(shouldShowAutoSyncOnboarding({ ...SHOWING, projectLocalConfig: null })).toBe(false);
+ expect(resolveAutoSyncOnboarding({ ...SHOWING, projectLocalConfig: null })).toBeNull();
+ });
+
+ test('hidden once this machine has answered via mode (off/pull/full)', () => {
+ for (const mode of ['off', 'follow', 'full'] as const) {
+ expect(
+ resolveAutoSyncOnboarding({
+ ...SHOWING,
+ projectLocalConfig: { autoSync: { mode } },
+ }),
+ ).toBeNull();
+ }
});
- test('hidden once this machine has answered (enabled true or false)', () => {
+ test('hidden once this machine has answered via the legacy enabled boolean', () => {
expect(
- shouldShowAutoSyncOnboarding({
+ resolveAutoSyncOnboarding({
...SHOWING,
projectLocalConfig: { autoSync: { enabled: true } },
}),
- ).toBe(false);
+ ).toBeNull();
expect(
- shouldShowAutoSyncOnboarding({
+ resolveAutoSyncOnboarding({
...SHOWING,
projectLocalConfig: { autoSync: { enabled: false } },
}),
- ).toBe(false);
+ ).toBeNull();
});
- test('suppressed when the maintainer committed autoSync.default: false', () => {
+ test('suppressed when the maintainer committed a mode-valued default', () => {
+ for (const def of ['off', 'follow', 'full'] as const) {
+ expect(
+ resolveAutoSyncOnboarding({
+ ...SHOWING,
+ projectConfig: { autoSync: { default: def } },
+ }),
+ ).toBeNull();
+ }
+ });
+
+ test('suppressed when the maintainer committed a legacy boolean default', () => {
expect(
- shouldShowAutoSyncOnboarding({
+ resolveAutoSyncOnboarding({
...SHOWING,
projectConfig: { autoSync: { default: false } },
}),
- ).toBe(false);
- });
-
- test('suppressed when the maintainer committed autoSync.default: true', () => {
+ ).toBeNull();
expect(
- shouldShowAutoSyncOnboarding({
+ resolveAutoSyncOnboarding({
...SHOWING,
projectConfig: { autoSync: { default: true } },
}),
- ).toBe(false);
+ ).toBeNull();
});
test('still asks when committed config is absent or default is null/absent', () => {
- // projectConfig === null (committed doc empty) → no seed → ask.
- expect(shouldShowAutoSyncOnboarding({ ...SHOWING, projectConfig: null })).toBe(true);
- // autoSync present but default absent → no seed → ask.
- expect(shouldShowAutoSyncOnboarding({ ...SHOWING, projectConfig: { autoSync: {} } })).toBe(
- true,
- );
- // autoSync absent entirely → no seed → ask.
- expect(shouldShowAutoSyncOnboarding({ ...SHOWING, projectConfig: {} })).toBe(true);
+ expect(resolveAutoSyncOnboarding({ ...SHOWING, projectConfig: null })).toBe('full');
+ expect(resolveAutoSyncOnboarding({ ...SHOWING, projectConfig: { autoSync: {} } })).toBe('full');
+ expect(resolveAutoSyncOnboarding({ ...SHOWING, projectConfig: {} })).toBe('full');
});
- 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('forks to the follow variant when the push probe is denied', () => {
+ expect(resolveAutoSyncOnboarding({ ...SHOWING, pushPermissionCheckStatus: 'denied' })).toBe(
+ 'follow',
);
});
- test('shows on probe unknown (graceful degradation)', () => {
- expect(shouldShowAutoSyncOnboarding({ ...SHOWING, pushPermissionCheckStatus: 'unknown' })).toBe(
- true,
- );
+ test('suppressed while the push probe is unknown or still pending', () => {
+ expect(
+ resolveAutoSyncOnboarding({ ...SHOWING, pushPermissionCheckStatus: 'unknown' }),
+ ).toBeNull();
+ expect(
+ resolveAutoSyncOnboarding({ ...SHOWING, pushPermissionCheckStatus: undefined }),
+ ).toBeNull();
});
});
diff --git a/packages/app/src/components/auto-sync-onboarding-gate.ts b/packages/app/src/components/auto-sync-onboarding-gate.ts
index 0a0a4dd10..52ba550a7 100644
--- a/packages/app/src/components/auto-sync-onboarding-gate.ts
+++ b/packages/app/src/components/auto-sync-onboarding-gate.ts
@@ -1,7 +1,8 @@
/**
* Pure decision function for the AutoSync onboarding modal.
*
- * The dialog opens once per machine per project when:
+ * Returns which prompt variant to show, or `null` to stay hidden. The dialog
+ * opens once per machine per project when every gate condition aligns:
* 1. The user has not yet dismissed it this session.
* 2. A git remote exists for the project (`hasRemote === true`).
* 3. The project-local CRDT binding has synced from disk
@@ -11,29 +12,39 @@
* 4. The committed project binding has synced (`projectSynced === true`) —
* the same flash-free guard for the committed `autoSync.default` read in
* condition 7. Until the committed doc lands, `default` reads as the
- * schema default (`null`), so a project that ships `default: false` would
- * flash the modal open and then close once the real value arrives.
+ * schema default (`null`), so a project that ships a default would flash
+ * the modal open and then close once the real value arrives.
* 5. The local config is hydrated (`projectLocalConfig !== null`).
- * 6. The user hasn't answered the autoSync prompt yet
- * (`autoSync.enabled === null`). The schema's `nullable().default(null)`
- * makes `null` the canonical "unanswered" sentinel — never `undefined`.
+ * 6. This machine hasn't answered yet — the resolved local mode is `null`.
+ * `resolveLocalAutoSyncMode` reads `autoSync.mode` and falls back to the
+ * legacy `autoSync.enabled` boolean, so a machine that answered under
+ * either shape is treated as answered.
* 7. The maintainer has NOT committed an `autoSync.default` seed
- * (`projectConfig.autoSync.default` is `null`/absent). A committed `true`
- * or `false` pre-answers the prompt for everyone who clones the project,
- * so the modal is suppressed; only a null/absent default still asks.
- * 8. The push-permission probe HAS resolved AND did not return `'denied'`.
- * The gate requires the probe to settle first — `undefined` (probe
- * still pending) keeps the dialog hidden so we don't flash it open
- * and then close it the moment the probe returns `denied` (the
- * common case on share-linked clones of someone else's repo).
- * `'unknown'` (probe failed) still passes — graceful degradation
- * preserves the read+write user's onboarding ask when the probe
- * can't reach a verdict.
+ * (`modeFromCommittedDefault(default)` is `null`). A committed default
+ * (off/pull/full, or the legacy boolean) pre-answers the prompt for
+ * everyone who clones the project, so the modal is suppressed; only a
+ * null/absent default still asks.
+ * 8. The push-permission probe HAS resolved to a forking verdict. `allowed`
+ * returns the full-sync variant; `denied` (incl. the anonymous no-network
+ * short-circuit, the common case on share-linked clones) returns the
+ * pull-only variant. `unknown` (probe failed) and `undefined` (probe
+ * pending) both suppress: showing a full-sync prompt to a maybe-denied
+ * user promises pushes we can't keep, and Settings stays available for a
+ * later opt-in. The pending suppression is also the flash-free guard.
*
* Extracted from EditorPane into a pure function so each input contributes
* to an independently testable truth table. The cheapest checks come first
* to short-circuit before the more expensive reads.
*/
+import {
+ modeFromCommittedDefault,
+ resolveLocalAutoSyncMode,
+ type StoredSyncMode,
+} from '@inkeep/open-knowledge-core';
+
+/** Which onboarding prompt to show; `follow` explains one-directional sync. */
+export type AutoSyncOnboardingVariant = 'full' | 'follow';
+
export interface AutoSyncOnboardingGateInputs {
/** Local React state — has the user already dismissed this session? */
autoSyncOnboardingDismissed: boolean;
@@ -44,32 +55,34 @@ export interface AutoSyncOnboardingGateInputs {
/** CRDT lifecycle: has the committed project config doc finished its first sync? */
projectSynced: boolean | undefined;
/** Project-local config — null until the binding hydrates. */
- projectLocalConfig: { autoSync?: { enabled: boolean | null } | null } | null;
+ projectLocalConfig: {
+ autoSync?: { mode?: StoredSyncMode | null; enabled?: boolean | null } | null;
+ } | null;
/** Committed project config — carries the maintainer's autoSync.default seed. */
- projectConfig: { autoSync?: { default?: boolean | null } | null } | null;
+ projectConfig: { autoSync?: { default?: boolean | StoredSyncMode | null } | null } | null;
/** Push-permission probe outcome. */
pushPermissionCheckStatus: 'allowed' | 'denied' | 'unknown' | undefined;
}
-export function shouldShowAutoSyncOnboarding(inputs: AutoSyncOnboardingGateInputs): boolean {
- return (
+export function resolveAutoSyncOnboarding(
+ inputs: AutoSyncOnboardingGateInputs,
+): AutoSyncOnboardingVariant | null {
+ const aligned =
!inputs.autoSyncOnboardingDismissed &&
inputs.hasRemote === true &&
inputs.projectLocalSynced === true &&
inputs.projectSynced === true &&
inputs.projectLocalConfig !== null &&
- inputs.projectLocalConfig.autoSync?.enabled === null &&
- // A committed autoSync.default (true OR false) pre-answers the prompt for
- // everyone who clones the project — suppress the modal whenever the
- // maintainer set one; only a null/absent default falls through to the ask.
- // Gated on projectSynced above so this compares against the real committed
- // value, not the schema default during the cold-start sync window.
- (inputs.projectConfig?.autoSync?.default ?? null) === null &&
- // Probe must have resolved AND not be 'denied' — `undefined` (pending)
- // would flash the dialog and then close on the probe's `denied` return.
- // `'unknown'` passes for graceful degradation when the probe can't
- // reach a verdict (network failure, rate-limit, etc.).
- (inputs.pushPermissionCheckStatus === 'allowed' ||
- inputs.pushPermissionCheckStatus === 'unknown')
- );
+ resolveLocalAutoSyncMode(inputs.projectLocalConfig.autoSync ?? undefined) === null &&
+ modeFromCommittedDefault(inputs.projectConfig?.autoSync?.default) === null;
+ if (!aligned) return null;
+
+ switch (inputs.pushPermissionCheckStatus) {
+ case 'allowed':
+ return 'full';
+ case 'denied':
+ return 'follow';
+ default:
+ return null;
+ }
}
diff --git a/packages/app/src/components/bottom-composer-gate.ts b/packages/app/src/components/bottom-composer-gate.ts
index a9acdf69d..4c400c665 100644
--- a/packages/app/src/components/bottom-composer-gate.ts
+++ b/packages/app/src/components/bottom-composer-gate.ts
@@ -23,7 +23,7 @@
*
* Extracted from EditorArea into a pure function so each input contributes to
* an independently testable truth table — mirrors `shouldPaintOverlay` and
- * `shouldShowAutoSyncOnboarding`.
+ * `resolveAutoSyncOnboarding`.
*/
export interface BottomComposerGateInputs {
/** Whether the docked terminal is currently visible. */
diff --git a/packages/app/src/components/settings/SettingsDialogBody.sections.dom.test.tsx b/packages/app/src/components/settings/SettingsDialogBody.sections.dom.test.tsx
index 30bdd11f8..421f7d828 100644
--- a/packages/app/src/components/settings/SettingsDialogBody.sections.dom.test.tsx
+++ b/packages/app/src/components/settings/SettingsDialogBody.sections.dom.test.tsx
@@ -13,11 +13,15 @@ type SyncStatus = {
unknownError?: string;
};
syncEnabled?: boolean;
+ syncMode?: 'off' | 'follow' | 'full';
+ ahead?: number;
remote?: { label: string; webUrl: string | null } | null;
} | null;
let syncStatus: SyncStatus = null;
-let projectLocalConfig: { autoSync?: { enabled?: boolean } } | null = {
+let projectLocalConfig: {
+ autoSync?: { enabled?: boolean; mode?: 'off' | 'follow' | 'full' };
+} | null = {
autoSync: { enabled: true },
};
let projectLocalSynced = true;
@@ -33,8 +37,9 @@ let projectBinding: {
patch: (patch: unknown) => { ok: true } | { ok: false; error: unknown };
} | null = null;
let projectBindingPatchCalls: unknown[] = [];
-let syncWriterCalls: boolean[] = [];
-let syncDefaultWriterCalls: Array = [];
+let syncModeWriterCalls: string[] = [];
+let syncModeSelectCalls: string[] = [];
+let syncDefaultWriterCalls: Array = [];
let okignoreProps: Array<{ binding: unknown; synced: boolean }> = [];
let installDialogProps: Array<{
open: boolean;
@@ -264,36 +269,42 @@ vi.doMock('@/lib/config-provider', () => ({
}),
}));
+type ModeWriterFn = (mode: string) => { ok: true };
vi.doMock('@/hooks/use-enable-sync-with-confirm', () => ({
- useSyncEnabledWriter: () => ({
- write: (enabled: boolean) => {
- syncWriterCalls.push(enabled);
- return true;
- },
- }),
- useSyncDefaultWriter: () => (next: boolean | null) => {
+ useSyncModeWriter: (): ModeWriterFn => (mode: string) => {
+ syncModeWriterCalls.push(mode);
+ return { ok: true };
+ },
+ useSyncDefaultWriter: () => (next: boolean | string | null) => {
syncDefaultWriterCalls.push(next);
return { ok: true };
},
- useEnableSyncWithConfirm: (writer: { write: (enabled: boolean) => boolean }) => {
+ // Thin recorder mirroring the real hook's gating so the section's wiring is
+ // exercised (deep confirm-flow behavior is covered against the real hook in
+ // SettingsDialogBody.sync-mode.dom.test.tsx and the hook's own test).
+ useSyncModeSelection: (writer: ModeWriterFn, currentMode: string) => {
const [confirmOpen, setConfirmOpen] = useState(false);
+ const [pendingMode, setPendingMode] = useState<'follow' | 'full' | null>(null);
return {
confirmOpen,
setConfirmOpen,
- onToggleRequest: (enabled: boolean) => {
- if (enabled) {
- setConfirmOpen(true);
+ pendingMode,
+ onModeSelect: (next: string) => {
+ syncModeSelectCalls.push(next);
+ if (next === currentMode) return;
+ if (next === 'off') {
+ writer('off');
return;
}
- writer.write(false);
+ setPendingMode(next as 'follow' | 'full');
+ setConfirmOpen(true);
},
onConfirm: () => {
- writer.write(true);
+ if (pendingMode) writer(pendingMode);
setConfirmOpen(false);
},
};
},
- EnableSyncConfirmDialog: () => null,
}));
vi.doMock('@/components/EnableSyncConfirmDialog', () => ({
@@ -351,7 +362,8 @@ describe('SettingsDialogBody section runtime dispatch', () => {
return { ok: true };
},
};
- syncWriterCalls = [];
+ syncModeWriterCalls = [];
+ syncModeSelectCalls = [];
syncDefaultWriterCalls = [];
okignoreProps = [];
installDialogProps = [];
@@ -518,22 +530,24 @@ describe('SettingsDialogBody section runtime dispatch', () => {
);
});
- test('sync section reads checked state from project-local config and keeps the writer/confirm path', async () => {
+ test('sync section reflects the resolved mode and wires the three-way control', async () => {
syncStatus = {
- state: 'enabled',
+ state: 'idle',
hasRemote: true,
- syncEnabled: false,
+ syncEnabled: true,
+ syncMode: 'full',
remote: {
label: 'inkeep/open-knowledge',
webUrl: 'https://github.com/inkeep/open-knowledge',
},
};
+ // Legacy `enabled: true` derives to full mode.
projectLocalConfig = { autoSync: { enabled: true } };
await renderBody({ activeId: 'sync' });
- const toggle = screen.getByTestId('settings-sync-toggle');
- expect(toggle.getAttribute('aria-checked')).toBe('true');
+ const modeToggle = screen.getByTestId('settings-sync-mode-toggle');
+ expect(modeToggle.getAttribute('data-value')).toBe('full');
expect(screen.getByTestId('settings-sync-remote-link').getAttribute('href')).toBe(
'https://github.com/inkeep/open-knowledge',
);
@@ -541,23 +555,32 @@ describe('SettingsDialogBody section runtime dispatch', () => {
'noopener noreferrer',
);
- fireEvent.click(toggle);
- expect(syncWriterCalls).toEqual([false]);
+ // Selecting Off is the safe direction — commits immediately, no confirm.
+ fireEvent.click(screen.getByTestId('settings-sync-mode-off'));
+ expect(syncModeSelectCalls).toEqual(['off']);
+ expect(syncModeWriterCalls).toEqual(['off']);
cleanup();
syncStatus = {
- state: 'enabled',
+ state: 'idle',
hasRemote: true,
syncEnabled: true,
+ syncMode: 'follow',
remote: { label: 'ssh://git.example/repo.git', webUrl: null },
};
- projectLocalConfig = { autoSync: { enabled: false } };
+ projectLocalConfig = { autoSync: { mode: 'follow' } };
projectLocalSynced = false;
await renderBody({ activeId: 'sync' });
- expect(screen.getByTestId('settings-sync-toggle').getAttribute('aria-checked')).toBe('false');
- expect(screen.getByTestId('settings-sync-toggle').hasAttribute('disabled')).toBe(true);
+ // Explicit mode wins; the control is disabled until the project-local config
+ // has hydrated (cold-start guard).
+ expect(screen.getByTestId('settings-sync-mode-toggle').getAttribute('data-value')).toBe(
+ 'follow',
+ );
+ expect(screen.getByTestId('settings-sync-mode-toggle').getAttribute('data-disabled')).toBe(
+ 'true',
+ );
expect(screen.getByTestId('settings-sync-remote-label').textContent).toBe(
'ssh://git.example/repo.git',
);
@@ -584,13 +607,17 @@ describe('SettingsDialogBody section runtime dispatch', () => {
'off',
);
- // "On by default" writes the committed seed `true`.
- fireEvent.click(screen.getByTestId('settings-sync-default-on'));
+ // "Full by default" writes the legacy boolean seed `true` (older builds honor it).
+ fireEvent.click(screen.getByTestId('settings-sync-default-full'));
expect(syncDefaultWriterCalls).toEqual([true]);
- // "Ask each person" clears the committed seed (writes null → RFC 7396 delete).
+ // "Pull-only by default" writes the widened mode string.
+ fireEvent.click(screen.getByTestId('settings-sync-default-follow'));
+ expect(syncDefaultWriterCalls).toEqual([true, 'follow']);
+
+ // "None" clears the committed seed (writes null → RFC 7396 delete).
fireEvent.click(screen.getByTestId('settings-sync-default-ask'));
- expect(syncDefaultWriterCalls).toEqual([true, null]);
+ expect(syncDefaultWriterCalls).toEqual([true, 'follow', null]);
});
test('committed default control is disabled until the committed config has synced', async () => {
@@ -616,31 +643,60 @@ describe('SettingsDialogBody section runtime dispatch', () => {
);
});
- test('sync section disables the toggle with denied-specific accessible copy when push permission is denied', async () => {
+ test('sync section keeps the mode control enabled and points a denied receiver at pull-only', async () => {
syncStatus = {
state: 'idle',
hasRemote: true,
syncEnabled: false,
+ syncMode: 'off',
pushPermission: { checkStatus: 'denied', deniedReason: 'no-collaborator' },
remote: {
label: 'inkeep/open-knowledge',
webUrl: 'https://github.com/inkeep/open-knowledge',
},
};
- projectLocalConfig = { autoSync: { enabled: false } };
+ projectLocalConfig = { autoSync: { mode: 'off' } };
projectLocalSynced = true;
await renderBody({ activeId: 'sync' });
- const toggle = screen.getByTestId('settings-sync-toggle') as HTMLButtonElement;
- expect(toggle.disabled).toBe(true);
- expect(toggle.getAttribute('aria-label')).toBe(
- "Sync disabled — you don't have permission to push",
+ // A denied probe no longer disables the control — pull-only never pushes, so
+ // the receiver must be able to reach it.
+ expect(screen.getByTestId('settings-sync-mode-toggle').getAttribute('data-disabled')).toBe(
+ 'false',
);
- expect(screen.getByTestId('settings-sync-body').textContent).toContain(
- "you don't have permission to push",
+ expect(screen.getByTestId('settings-sync-denied-hint').textContent).toContain(
+ "You don't have permission to push",
);
- expect(screen.queryByTestId('settings-sync-reason')).toBeNull();
+ // Off mode is not paused; the switch-to-pull affordance is full-only.
+ expect(screen.queryByTestId('settings-sync-switch-follow')).toBeNull();
+ });
+
+ test('sync section offers Switch to pull-only when full sync is paused on a denied push probe', async () => {
+ syncStatus = {
+ state: 'disabled',
+ hasRemote: true,
+ syncEnabled: true,
+ syncMode: 'full',
+ pausedReason: 'no-push-permission',
+ ahead: 2,
+ remote: {
+ label: 'inkeep/open-knowledge',
+ webUrl: 'https://github.com/inkeep/open-knowledge',
+ },
+ };
+ projectLocalConfig = { autoSync: { mode: 'full' } };
+ projectLocalSynced = true;
+
+ await renderBody({ activeId: 'sync' });
+
+ expect(screen.getByTestId('settings-sync-switch-follow')).not.toBeNull();
+ // The action routes through the mode selector toward pull-only.
+ fireEvent.click(screen.getByTestId('settings-sync-switch-follow-action'));
+ expect(syncModeSelectCalls).toEqual(['follow']);
+ // Opening the confirm, not an immediate write (consent gate).
+ expect(syncModeWriterCalls).toEqual([]);
+ expect(screen.getByTestId('sync-confirm-dialog').getAttribute('data-open')).toBe('true');
});
test('sync section renders shared paused-reason copy for non-permission pause reasons', async () => {
diff --git a/packages/app/src/components/settings/SettingsDialogBody.sync-mode.dom.test.tsx b/packages/app/src/components/settings/SettingsDialogBody.sync-mode.dom.test.tsx
new file mode 100644
index 000000000..e13b44fac
--- /dev/null
+++ b/packages/app/src/components/settings/SettingsDialogBody.sync-mode.dom.test.tsx
@@ -0,0 +1,330 @@
+/**
+ * Integration coverage for the three-way sync-mode control in the Settings Sync
+ * section. Unlike the sibling sections test (which stubs the sync hooks), this
+ * renders the REAL `useSyncModeSelection` / `useSyncModeWriter` hooks and the
+ * REAL `EnableSyncConfirmDialog`, mocking only the config-binding boundary, the
+ * status feed, and leaf UI primitives. It proves the actual wiring: select a
+ * mode -> the consent dialog opens with the right copy -> confirming patches
+ * `autoSync.mode` on the project-local binding.
+ */
+import { cleanup, fireEvent, render, screen } from '@testing-library/react';
+import { createContext, type ReactNode, use } from 'react';
+import { beforeEach, describe, expect, test, vi } from 'vitest';
+import { renderLinguiTemplate } from '@/test-utils/lingui-mock';
+
+type SyncStatus = {
+ state: string;
+ hasRemote: boolean;
+ pausedReason?: string;
+ pushPermission?: { checkStatus: 'allowed' | 'denied' | 'unknown'; deniedReason?: string };
+ syncEnabled?: boolean;
+ syncMode?: 'off' | 'follow' | 'full';
+ ahead?: number;
+ remote?: { label: string; webUrl: string | null } | null;
+} | null;
+
+let syncStatus: SyncStatus = null;
+let projectLocalConfig: {
+ autoSync?: { enabled?: boolean; mode?: 'off' | 'follow' | 'full' };
+} | null = null;
+let projectConfig: {
+ autoSync?: { default?: boolean | string | null };
+ content: { attachmentFolderPath: string };
+} | null = null;
+let projectLocalSynced = true;
+let projectSynced = true;
+let localPatchCalls: unknown[] = [];
+const projectLocalBinding: {
+ patch: (patch: unknown) => { ok: true } | { ok: false; error: unknown };
+} = {
+ patch: (patch: unknown) => {
+ localPatchCalls.push(patch);
+ return { ok: true };
+ },
+};
+
+import * as actualLinguiMacro from '@lingui/react/macro';
+
+vi.doMock('@lingui/react/macro', () => ({
+ ...actualLinguiMacro,
+ Plural: ({ value, one, other }: { value: number; one: string; other: string }) => (
+ <>{(value === 1 ? one : other).replace('#', String(value))}>
+ ),
+ Trans: ({ children }: { children?: ReactNode }) => <>{children}>,
+ useLingui: () => ({ t: renderLinguiTemplate }),
+}));
+
+vi.doMock('@lingui/core/macro', () => ({
+ ...actualLinguiMacro,
+ msg: renderLinguiTemplate,
+ plural: (value: number, options: { one: string; other: string }) =>
+ (value === 1 ? options.one : options.other).replace('#', String(value)),
+ t: renderLinguiTemplate,
+}));
+
+const toastErrors: string[] = [];
+vi.doMock('sonner', () => ({ toast: { error: (m: string) => toastErrors.push(m) } }));
+
+vi.doMock('@/components/ui/button', () => ({
+ Button: ({ children, ...props }: { children?: ReactNode; [key: string]: unknown }) => (
+
+ ),
+}));
+
+vi.doMock('@/components/ui/collapsible', () => ({
+ Collapsible: ({ children }: { children?: ReactNode }) =>
+ ),
+ TooltipTrigger: ({ children }: { children?: ReactNode }) => <>{children}>,
+}));
+
+vi.doMock('@/components/PublishToGitHubDialog', () => ({
+ PublishToGitHubDialog: () => null,
+}));
+vi.doMock('@/components/AuthModal', () => ({ AuthModal: () => null }));
+vi.doMock('@/components/InstallInClaudeDesktopDialog', () => ({
+ InstallInClaudeDesktopDialog: () => null,
+}));
+vi.doMock('./OkignoreSection', () => ({ OkignoreSection: () => null }));
+vi.doMock('./ProjectTemplatesSection', () => ({ ProjectTemplatesSection: () => null }));
+
+vi.doMock('@/hooks/use-git-sync-status', () => ({
+ useGitSyncStatus: () => syncStatus,
+ useGitSyncStatusDetailed: () => ({ status: syncStatus, fetchError: null }),
+}));
+
+vi.doMock('@/lib/config-provider', () => ({
+ useConfigContext: () => ({
+ projectBinding: projectLocalBinding,
+ projectConfig,
+ projectLocalConfig,
+ projectLocalBinding,
+ projectLocalSynced,
+ projectSynced,
+ }),
+}));
+
+// Import the component AFTER the doMock calls so it picks up the mocked deps
+// while keeping the REAL sync hooks + REAL EnableSyncConfirmDialog. RTL itself
+// depends on none of the mocked modules, so it stays a static top-level import
+// (the Tier-3 filename contract requires it).
+async function renderSyncSection() {
+ const { SettingsDialogBody } = await import('./SettingsDialogBody');
+ render(
+ ,
+ );
+}
+
+describe('Settings Sync section — three-way mode control (real hooks + dialog)', () => {
+ beforeEach(() => {
+ cleanup();
+ syncStatus = {
+ state: 'idle',
+ hasRemote: true,
+ syncEnabled: false,
+ syncMode: 'off',
+ ahead: 0,
+ remote: {
+ label: 'inkeep/open-knowledge',
+ webUrl: 'https://github.com/inkeep/open-knowledge',
+ },
+ };
+ projectLocalConfig = { autoSync: { mode: 'off' } };
+ projectConfig = { autoSync: { default: null }, content: { attachmentFolderPath: './' } };
+ projectLocalSynced = true;
+ projectSynced = true;
+ localPatchCalls = [];
+ toastErrors.length = 0;
+ });
+
+ test('selecting Pull-only opens the one-directional confirm and patches mode on confirm', async () => {
+ await renderSyncSection();
+
+ fireEvent.click(screen.getByTestId('settings-sync-mode-follow'));
+
+ // The real pull-variant confirmation renders; no write yet.
+ expect(screen.getByRole('button', { name: 'Enable Follow' })).not.toBeNull();
+ expect(screen.getByRole('note').textContent ?? '').toContain('Updates flow in');
+ expect(localPatchCalls).toEqual([]);
+
+ fireEvent.click(screen.getByRole('button', { name: 'Enable Follow' }));
+ expect(localPatchCalls).toEqual([{ autoSync: { mode: 'follow', enabled: null } }]);
+ });
+
+ test('selecting Full opens the bidirectional confirm and patches full on confirm', async () => {
+ await renderSyncSection();
+
+ fireEvent.click(screen.getByTestId('settings-sync-mode-full'));
+
+ expect(screen.getByRole('button', { name: 'Enable auto-sync' })).not.toBeNull();
+ expect(screen.getByRole('note').textContent ?? '').toContain('Commits happen automatically');
+
+ fireEvent.click(screen.getByRole('button', { name: 'Enable auto-sync' }));
+ expect(localPatchCalls).toEqual([{ autoSync: { mode: 'full', enabled: null } }]);
+ });
+
+ test('selecting Off writes immediately with no confirmation', async () => {
+ projectLocalConfig = { autoSync: { mode: 'full' } };
+ syncStatus = { ...syncStatus, syncMode: 'full', syncEnabled: true } as SyncStatus;
+
+ await renderSyncSection();
+
+ fireEvent.click(screen.getByTestId('settings-sync-mode-off'));
+
+ expect(localPatchCalls).toEqual([{ autoSync: { mode: 'off', enabled: null } }]);
+ // No confirmation dialog for the safe direction.
+ expect(screen.queryByRole('button', { name: /Enable/ })).toBeNull();
+ });
+
+ test('Switch to pull-only from a paused full sync discloses stranded commits then patches pull', async () => {
+ projectLocalConfig = { autoSync: { mode: 'full' } };
+ syncStatus = {
+ state: 'disabled',
+ hasRemote: true,
+ syncEnabled: true,
+ syncMode: 'full',
+ pausedReason: 'no-push-permission',
+ ahead: 2,
+ remote: {
+ label: 'inkeep/open-knowledge',
+ webUrl: 'https://github.com/inkeep/open-knowledge',
+ },
+ };
+
+ await renderSyncSection();
+
+ fireEvent.click(screen.getByTestId('settings-sync-switch-follow-action'));
+
+ // Pull-variant confirm with the stranded-commit disclosure sourced from ahead.
+ expect(
+ screen.getByText("You have 2 changes you haven't shared. They will stay on this computer."),
+ ).not.toBeNull();
+
+ fireEvent.click(screen.getByRole('button', { name: 'Enable Follow' }));
+ expect(localPatchCalls).toEqual([{ autoSync: { mode: 'follow', enabled: null } }]);
+ });
+
+ test('a genuine read-only denial disables Full but keeps Off and Pull-only reachable', async () => {
+ syncStatus = {
+ ...syncStatus,
+ pushPermission: { checkStatus: 'denied', deniedReason: 'no-collaborator' },
+ } as SyncStatus;
+
+ await renderSyncSection();
+
+ expect((screen.getByTestId('settings-sync-mode-full') as HTMLButtonElement).disabled).toBe(
+ true,
+ );
+ expect((screen.getByTestId('settings-sync-mode-off') as HTMLButtonElement).disabled).toBe(
+ false,
+ );
+ expect((screen.getByTestId('settings-sync-mode-follow') as HTMLButtonElement).disabled).toBe(
+ false,
+ );
+ // The disabled Full item carries a tooltip explaining the read-only denial.
+ expect(screen.getByTestId('settings-sync-mode-full-tip').textContent ?? '').toContain(
+ "You don't have permission to push to this repo",
+ );
+ });
+
+ test('a signed-out denial keeps Full enabled (push access is unknowable until sign-in)', async () => {
+ syncStatus = {
+ ...syncStatus,
+ pushPermission: { checkStatus: 'denied', deniedReason: 'not-authenticated' },
+ } as SyncStatus;
+
+ await renderSyncSection();
+
+ expect((screen.getByTestId('settings-sync-mode-full') as HTMLButtonElement).disabled).toBe(
+ false,
+ );
+ });
+});
diff --git a/packages/app/src/components/settings/SyncSection.tsx b/packages/app/src/components/settings/SyncSection.tsx
index 3ead62020..176a37580 100644
--- a/packages/app/src/components/settings/SyncSection.tsx
+++ b/packages/app/src/components/settings/SyncSection.tsx
@@ -1,13 +1,20 @@
/**
- * Sync section — surface the git auto-sync toggle in Settings so users
- * have a deliberate path to re-enable when the header badge is hidden
- * (state === 'disabled' hides the badge).
+ * Sync section — the Settings home for the three-way sync mode
+ * (off / pull-only / full) plus the committed shared default, so users have a
+ * deliberate path to change modes even when the header badge is hidden
+ * (state === 'disabled' hides the badge for non-following projects).
*
- * The toggle writes through the project-local ConfigBinding so the choice
- * lands in `/.ok/local/config.yml`; the file watcher then drives
- * the SyncEngine to match.
+ * The mode control writes through the project-local ConfigBinding so the
+ * choice lands in `/.ok/local/config.yml`; the file watcher then
+ * drives the SyncEngine to match.
*/
+import {
+ isSyncMode,
+ modeFromCommittedDefault,
+ resolveLocalAutoSyncMode,
+ type SyncMode,
+} from '@inkeep/open-knowledge-core';
import { Trans, useLingui } from '@lingui/react/macro';
import { ArrowUpRight, ChevronRight } from 'lucide-react';
import { useState } from 'react';
@@ -17,27 +24,25 @@ import { EnableSyncConfirmDialog } from '@/components/EnableSyncConfirmDialog';
import { PublishToGitHubDialog } from '@/components/PublishToGitHubDialog';
import {
formatPausedReason,
- shouldDisableSyncSwitch,
shouldOfferReconnect,
shouldOfferSignInAgain,
} from '@/components/SyncStatusBadge';
import { Button } from '@/components/ui/button';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
-import { Switch } from '@/components/ui/switch';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
+import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import {
- useEnableSyncWithConfirm,
useSyncDefaultWriter,
- useSyncEnabledWriter,
+ useSyncModeSelection,
+ useSyncModeWriter,
} from '@/hooks/use-enable-sync-with-confirm';
import { useGitSyncStatus } from '@/hooks/use-git-sync-status';
import { useConfigContext } from '@/lib/config-provider';
-// The selected committed-default option uses the app's primary blue (the same
-// token as the Button default variant), not the muted ToggleGroup default, so
-// the active stance reads as clearly chosen and matches the accent used
-// elsewhere in the app.
-const COMMITTED_DEFAULT_SELECTED_CLASS =
+// Selected toggle items use the app's primary blue (the same token as the
+// Button default variant), not the muted ToggleGroup default, so the active
+// stance reads as clearly chosen and matches the accent used elsewhere.
+const SYNC_SELECTED_TOGGLE_CLASS =
'data-[state=on]:border-primary data-[state=on]:bg-primary data-[state=on]:text-primary-foreground data-[state=on]:hover:bg-primary/90';
export function SyncSection() {
@@ -45,10 +50,14 @@ export function SyncSection() {
const status = useGitSyncStatus();
const { projectConfig, projectLocalConfig, projectLocalSynced, projectSynced } =
useConfigContext();
- const writer = useSyncEnabledWriter();
+ const modeWriter = useSyncModeWriter();
const defaultWriter = useSyncDefaultWriter();
- const { confirmOpen, setConfirmOpen, onToggleRequest, onConfirm } =
- useEnableSyncWithConfirm(writer);
+ // Per-machine mode: an explicit `autoSync.mode` wins, else derive from the
+ // legacy `enabled` boolean; never-answered resolves to off for display (the
+ // committed shared default has its own control below).
+ const localMode = resolveLocalAutoSyncMode(projectLocalConfig?.autoSync) ?? 'off';
+ const { confirmOpen, setConfirmOpen, pendingMode, onModeSelect, onConfirm } =
+ useSyncModeSelection(modeWriter, localMode);
const [publishOpen, setPublishOpen] = useState(false);
// Local AuthModal control for the Sign-in-again affordance surfaced when
// the probe returns 401. The editor header has its own AuthModal — settings
@@ -119,47 +128,82 @@ export function SyncSection() {
);
}
- // Read user intent from the synchronous local CRDT preference (the same
- // binding `useSyncEnabledWriter` writes to). Don't read from the server's
- // engine-state projection — that round-trips through ~2 s persistence
- // debounce + chokidar settle + 100 ms CC1 debounce, making the Switch
- // appear to lag every click.
- const enabled = projectLocalConfig?.autoSync?.enabled ?? false;
- // Mirrors the SyncStatusBadge popover so both surfaces gate identically.
- // Disable on cold start OR on a denied probe; never disable on
- // undefined / unknown / pending (preserves read+write parity).
- const disabledControl = shouldDisableSyncSwitch(
- projectLocalSynced,
- status?.pushPermission?.checkStatus,
- );
- // Whether the body line should carry the no-permission copy inline (instead
- // of the standard "your edits stay local" string + a redundant paragraph
- // underneath). Fires for both the probe-`denied` path AND the in-memory
- // pause path (autoSync was already enabled when probe came back denied —
- // engine sets `pausedReason='no-push-permission'`).
+ // Cold-start guard only: disable the control until the project-local config
+ // has hydrated. Unlike the old boolean toggle, a denied probe does NOT disable
+ // it — pull-only never pushes, so a push-denied receiver must still be able to
+ // select it (the whole point of the mode).
+ const modeControlDisabled = !projectLocalSynced;
+ // Full sync paused (or would pause) because the push probe came back denied.
+ // Only `full` cares about push permission — `pull`/`off` never push.
const isPushDenied =
status?.pushPermission?.checkStatus === 'denied' ||
status?.pausedReason === 'no-push-permission';
- const sectionMessage =
- isPushDenied || !status?.pausedReason ? null : formatPausedReason(status.pausedReason);
+ // Signed-out denial ('denied/not-authenticated') — signing back in restores
+ // the full sync the user already consented to, so it takes precedence over
+ // the switch-to-pull-only offer (that one is for genuinely revoked access).
+ const offerReconnect = shouldOfferReconnect(status?.pushPermission);
+ const showReconnect = localMode === 'full' && isPushDenied && offerReconnect;
+ const showSwitchToPullOnly = localMode === 'full' && isPushDenied && !offerReconnect;
+ // Full sync would immediately fail-and-pause for a genuine read-only user, so
+ // don't offer it. Signed-out denial is excluded — that user may well have push
+ // access once they authenticate, so Full stays reachable for them.
+ const genuineReadOnlyDenied =
+ status?.pushPermission?.checkStatus === 'denied' &&
+ status.pushPermission.deniedReason !== 'not-authenticated';
+ // A non-permission pause reason (protected-branch, dirty-tree, …) — reachable
+ // only under full sync. Suppressed when the switch-to-pull-only affordance
+ // already explains a paused full-sync engine.
+ const pausedNotice =
+ showSwitchToPullOnly || isPushDenied || !status?.pausedReason
+ ? null
+ : formatPausedReason(status.pausedReason);
+
+ function onModeChange(next: string) {
+ // Radix single ToggleGroup emits '' when the active item is re-pressed
+ // (deselect) — ignore so there is always exactly one selected mode.
+ if (!isSyncMode(next)) return;
+ onModeSelect(next);
+ }
// Committed project default (`autoSync.default`) — the maintainer-facing,
- // git-shared seed for everyone's first open. true/false/null map to the three
- // ToggleGroup options; `null` (ask) is the absence of a committed seed.
- const committedDefault = projectConfig?.autoSync?.default ?? null;
- const committedDefaultValue =
- committedDefault === true ? 'on' : committedDefault === false ? 'off' : 'ask';
+ // git-shared seed for everyone's first open. Widened to the mode vocabulary so
+ // a maintainer can ship a pull-only default; `modeFromCommittedDefault` reads
+ // both the mode strings and the legacy boolean seed, `null` (ask) = no seed.
+ const committedDefaultValue = modeFromCommittedDefault(projectConfig?.autoSync?.default) ?? 'ask';
function onCommittedDefaultChange(next: string) {
// Radix single ToggleGroup emits '' when the active item is re-pressed
// (deselect) — ignore it so there is always exactly one committed stance.
- if (next !== 'ask' && next !== 'on' && next !== 'off') return;
+ if (next !== 'ask' && !isSyncMode(next)) return;
if (defaultWriter === null) {
toast.error(t`Sync settings not yet loaded — try again in a moment`);
return;
}
- // 'ask' writes null, which clears the committed key (RFC 7396 merge-patch) →
- // unanswered machines see the onboarding prompt again.
- const value = next === 'on' ? true : next === 'off' ? false : null;
+ // 'ask' clears the committed key (RFC 7396 merge-patch) → unanswered machines
+ // see the onboarding prompt again. off/full stay legacy booleans so an older
+ // OK build still honors them verbatim; 'pull' has no legacy equivalent, so it
+ // is written as the mode string (older builds safely re-prompt on it).
+ // Exhaustive per value: this writes committed (git-shared) config, so a
+ // future mode must make a deliberate serialization choice here rather than
+ // silently falling through to one arm.
+ let value: boolean | SyncMode | null;
+ switch (next) {
+ case 'ask':
+ value = null;
+ break;
+ case 'off':
+ value = false;
+ break;
+ case 'full':
+ value = true;
+ break;
+ case 'follow':
+ value = 'follow';
+ break;
+ default: {
+ const exhaustive: never = next;
+ throw new Error(`unhandled committed default: ${String(exhaustive)}`);
+ }
+ }
const result = defaultWriter(value);
if (!result.ok) {
const detail = result.error;
@@ -175,40 +219,27 @@ export function SyncSection() {
- Auto-sync pushes/pulls commits to your git remote on intervals and on save. Toggling on
- requires confirmation.
+ Keep this project in sync with your git remote. Follow fetches updates without pushing;
+ full sync pushes your commits too. Turning sync on requires confirmation.
-
+
-
+
+ Git sync
+
- {isPushDenied ? (
- // Probe denied (or engine paused in-memory because autoSync was
- // already on when probe denied). Replace the standard body copy
- // with the permission-specific message — the redundant
- // sectionMessage paragraph below is suppressed in this case.
- // "Paused", not "off": the user's preference is still on (the
- // toggle shows it), sync is just blocked. Signed-out vs genuine
- // read-only get different remedies.
- shouldOfferReconnect(status?.pushPermission) ? (
- Auto-sync is paused — sign in to resume.
- ) : (
-
- Auto-sync is paused — you don't have permission to push to this repo.
-
- )
- ) : enabled ? (
+ {localMode === 'full' ? (
+ Full sync — your commits push and remote changes pull automatically.
+ ) : localMode === 'follow' ? (
- Auto-sync is on — your commits push and remote changes pull on intervals.
+ Follow — updates flow in from your remote; your edits stay on this computer.
) : (
- Auto-sync is off — your edits stay local until you commit and push manually.
+ Sync is off — your edits stay on this computer until you commit and push manually.
)}
-
+
+
+ Off
+
+
+ Follow
+
+ {genuineReadOnlyDenied ? (
+
+
+ {/* A disabled button emits no pointer events, so the tooltip
+ hangs off a wrapper span that still receives hover — the
+ only way to surface why Full is greyed out. Keyboard users
+ get the same reason from the read-only hint text below. */}
+
+
+ Full
+
+
+
+
+ You don't have permission to push to this repo
+
+
+ ) : (
+
+ Full
+
+ )}
+
- {sectionMessage !== null && (
+ {showReconnect && (
+ // "Paused", not "off": the preference is still full sync, it's just
+ // blocked by a signed-out session — reconnecting resumes it. Mirrors
+ // the popover's reconnect affordance.
+
+
+ Auto-sync is paused — sign in to resume.
+
+
+
+ )}
+ {showSwitchToPullOnly && (
+
+
+
+ Auto-sync is paused — you don't have permission to push to this repo. Switch to
+ Follow to keep receiving updates.
+
+
+
+
+ )}
+ {!showSwitchToPullOnly && !showReconnect && isPushDenied && localMode !== 'follow' && (
+ // Push-denied and not yet following: point the receiver at pull-only,
+ // which the mode control above already offers. Suppressed for the
+ // signed-out shape — permission is unknowable until they sign in.
+
+
+ You don't have permission to push to this repo. Follow can still keep your copy up to
+ date.
+
+
)}
- {shouldOfferReconnect(status?.pushPermission) && (
- // Signed-out denial ('denied/not-authenticated') — reconnecting
- // resumes sync (the body copy above reads "sign in to resume"), so
- // surface the button. Mirrors the popover's reconnect affordance.
-
- {/* Default size (not xs) to match the None/On/Off toggle row above:
- both resolve to h-8 / px-2.5 / text-sm. */}
-
-
- )}
-
+
Shared default
- Set the auto-sync default for users opening this project for the first time. This
- setting is committed to your repository.
+ Set the sync default for users opening this project for the first time. This setting
+ is committed to your repository.
@@ -311,36 +423,45 @@ export function SyncSection() {
value={committedDefaultValue}
onValueChange={onCommittedDefaultChange}
disabled={!projectSynced}
- aria-label={t`Shared auto-sync default`}
+ aria-labelledby="settings-sync-default-label"
data-testid="settings-sync-default-toggle"
>
None
-
- On
- Off
+
+ Follow
+
+
+ Full
+