feat: init disk remote source

This commit is contained in:
DarkSky
2026-02-27 02:39:53 +08:00
parent 895e774569
commit 6557e5d01d
51 changed files with 6793 additions and 158 deletions
@@ -0,0 +1,7 @@
export function shouldReloadDiskSyncSession(
enabled: boolean,
previousFolder: string | null,
nextFolder: string | null
) {
return enabled && previousFolder !== nextFolder;
}
@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest';
import { shouldReloadDiskSyncSession } from './disk-sync-session';
describe('shouldReloadDiskSyncSession', () => {
it('does not reload when feature is disabled', () => {
expect(
shouldReloadDiskSyncSession(false, '/tmp/folder-a', '/tmp/folder-b')
).toBe(false);
});
it('does not reload when folder is unchanged', () => {
expect(
shouldReloadDiskSyncSession(true, '/tmp/folder-a', '/tmp/folder-a')
).toBe(false);
});
it('reloads when folder changes while enabled', () => {
expect(
shouldReloadDiskSyncSession(true, '/tmp/folder-a', '/tmp/folder-b')
).toBe(true);
});
it('reloads when clearing folder while enabled', () => {
expect(shouldReloadDiskSyncSession(true, '/tmp/folder-a', null)).toBe(true);
});
});
@@ -0,0 +1,116 @@
import { notify, Switch } from '@affine/component';
import { SettingRow } from '@affine/component/setting-components';
import { Button } from '@affine/component/ui/button';
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
import { DesktopApiService } from '@affine/core/modules/desktop-api';
import { FeatureFlagService } from '@affine/core/modules/feature-flag';
import {
DISK_SYNC_FOLDERS_GLOBAL_STATE_KEY,
getDiskSyncFolderPath,
setDiskSyncFolderPath,
} from '@affine/core/modules/workspace-engine/impls/disk-config';
import { useLiveData, useService } from '@toeverything/infra';
import { useCallback, useEffect, useState } from 'react';
import { shouldReloadDiskSyncSession } from './disk-sync-session';
export const DiskSyncPanel = ({ workspaceId }: { workspaceId: string }) => {
const desktopApi = useService(DesktopApiService);
const featureFlagService = useService(FeatureFlagService);
const enabled = useLiveData(featureFlagService.flags.enable_disk_sync.$);
const [folder, setFolder] = useState<string | null>(() =>
getDiskSyncFolderPath(workspaceId)
);
useEffect(() => {
setFolder(getDiskSyncFolderPath(workspaceId));
const unwatch = desktopApi.sharedStorage.globalState.watch<
Record<string, string>
>(DISK_SYNC_FOLDERS_GLOBAL_STATE_KEY, folders => {
const next = folders?.[workspaceId];
setFolder(typeof next === 'string' && next.length > 0 ? next : null);
});
return () => {
unwatch();
};
}, [desktopApi.sharedStorage.globalState, workspaceId]);
const onToggle = useCallback(
(checked: boolean) => {
featureFlagService.flags.enable_disk_sync.set(checked);
},
[featureFlagService]
);
const onChooseFolder = useAsyncCallback(async () => {
const result = await desktopApi.handler.dialog.selectDBFileLocation();
if (result?.canceled || !result?.filePath) {
return;
}
if (result.filePath === folder) {
return;
}
setDiskSyncFolderPath(workspaceId, result.filePath);
setFolder(result.filePath);
if (shouldReloadDiskSyncSession(enabled, folder, result.filePath)) {
window.location.reload();
return;
}
notify.success({
title: 'Disk sync folder updated',
});
}, [desktopApi.handler.dialog, enabled, folder, workspaceId]);
const onClearFolder = useCallback(() => {
if (!folder) {
return;
}
setDiskSyncFolderPath(workspaceId, null);
setFolder(null);
if (shouldReloadDiskSyncSession(enabled, folder, null)) {
window.location.reload();
}
}, [enabled, folder, workspaceId]);
return (
<>
<SettingRow
name={'Markdown Folder Sync (Experimental)'}
desc={
'Enable local-folder Markdown sync through native pseudo remote (Electron only).'
}
>
<Switch
aria-label="Disk Markdown Sync"
data-testid="disk-sync-toggle"
checked={!!enabled}
onChange={onToggle}
/>
</SettingRow>
<SettingRow
name={'Sync Folder'}
desc={folder ?? 'No folder selected'}
spreadCol={false}
>
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
<Button
data-testid="disk-sync-choose-folder"
disabled={!enabled}
onClick={onChooseFolder}
>
Choose Folder
</Button>
{folder ? (
<Button
data-testid="disk-sync-clear-folder"
disabled={!enabled}
onClick={onClearFolder}
>
Clear
</Button>
) : null}
</div>
</SettingRow>
</>
);
};
@@ -9,6 +9,7 @@ import { useLiveData, useService } from '@toeverything/infra';
import { EnableCloudPanel } from '../preference/enable-cloud';
import { BlobManagementPanel } from './blob-management';
import { DiskSyncPanel } from './disk-sync';
import { DesktopExportPanel } from './export';
import { WorkspaceQuotaPanel } from './workspace-quota';
@@ -35,6 +36,11 @@ export const WorkspaceSettingStorage = ({
{workspace.flavour === 'local' ? (
<>
<EnableCloudPanel onCloseSetting={onCloseSetting} />{' '}
{BUILD_CONFIG.isElectron && (
<SettingWrapper>
<DiskSyncPanel workspaceId={workspace.id} />
</SettingWrapper>
)}
{BUILD_CONFIG.isElectron && (
<SettingWrapper>
<DesktopExportPanel workspace={workspace} />
@@ -287,6 +287,14 @@ export const AFFINE_FLAGS = {
configurable: true,
defaultState: isMobile,
},
enable_disk_sync: {
category: 'affine',
displayName: 'Enable Disk Markdown Sync',
description:
'Enable experimental local-folder Markdown bidirectional sync on Electron desktop. WARNING: We are not responsible for any data loss without thorough testing.',
configurable: BUILD_CONFIG.isElectron && isCanaryBuild,
defaultState: false,
},
enable_mobile_database_editing: {
category: 'blocksuite',
bsFlag: 'enable_mobile_database_editing',
@@ -0,0 +1,39 @@
import { BehaviorSubject } from 'rxjs';
import { describe, expect, it, vi } from 'vitest';
import { bindReloadOnFlagChange } from './feature-flag';
describe('bindReloadOnFlagChange', () => {
it('reloads only when flag value changes after initialization', () => {
const flag$ = new BehaviorSubject(false);
const reload = vi.fn();
const subscription = bindReloadOnFlagChange(flag$, reload);
expect(reload).not.toHaveBeenCalled();
flag$.next(false);
expect(reload).not.toHaveBeenCalled();
flag$.next(true);
expect(reload).toHaveBeenCalledTimes(1);
flag$.next(true);
expect(reload).toHaveBeenCalledTimes(1);
flag$.next(false);
expect(reload).toHaveBeenCalledTimes(2);
subscription.unsubscribe();
});
it('stops reloading after unsubscribe', () => {
const flag$ = new BehaviorSubject(false);
const reload = vi.fn();
const subscription = bindReloadOnFlagChange(flag$, reload);
subscription.unsubscribe();
flag$.next(true);
expect(reload).not.toHaveBeenCalled();
});
});
@@ -1,19 +1,36 @@
import { OnEvent, Service } from '@toeverything/infra';
import type { Observable, Subscription } from 'rxjs';
import { distinctUntilChanged, skip } from 'rxjs';
import { ApplicationStarted } from '../../lifecycle';
import { Flags, type FlagsExt } from '../entities/flags';
export function bindReloadOnFlagChange(
flag$: Observable<boolean>,
reload: () => void
): Subscription {
return flag$.pipe(distinctUntilChanged(), skip(1)).subscribe(() => {
reload();
});
}
@OnEvent(ApplicationStarted, e => e.setupRestartListener)
export class FeatureFlagService extends Service {
flags = this.framework.createEntity(Flags) as FlagsExt;
setupRestartListener() {
this.flags.enable_ai.$.pipe(distinctUntilChanged(), skip(1)).subscribe(
() => {
// when enable_ai flag changes, reload the page.
window.location.reload();
}
const reload = () => window.location.reload();
const enableAiReload = bindReloadOnFlagChange(
this.flags.enable_ai.$,
reload
);
const diskReload = bindReloadOnFlagChange(
this.flags.enable_disk_sync.$,
reload
);
this.disposables.push(
() => enableAiReload.unsubscribe(),
() => diskReload.unsubscribe()
);
}
}
@@ -0,0 +1,78 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
DISK_SYNC_FEATURE_FLAG_KEY,
DISK_SYNC_FOLDERS_GLOBAL_STATE_KEY,
getDiskSyncEnabled,
getDiskSyncFolderPath,
getDiskSyncRemoteOptions,
setDiskSyncEnabled,
setDiskSyncFolderPath,
} from './disk-config';
describe('disk-config', () => {
const originalBuildConfig = globalThis.BUILD_CONFIG;
const originalSharedStorage = (globalThis as any).__sharedStorage;
const state = new Map<string, unknown>();
beforeEach(() => {
state.clear();
(globalThis as any).__sharedStorage = {
globalState: {
get<T>(key: string): T | undefined {
return state.get(key) as T | undefined;
},
set<T>(key: string, value: T): void {
state.set(key, value);
},
},
};
globalThis.BUILD_CONFIG = {
...originalBuildConfig,
isElectron: true,
};
});
afterEach(() => {
globalThis.BUILD_CONFIG = originalBuildConfig;
(globalThis as any).__sharedStorage = originalSharedStorage;
});
it('reads and writes feature flag from electron global state', () => {
expect(getDiskSyncEnabled()).toBe(false);
setDiskSyncEnabled(true);
expect(getDiskSyncEnabled()).toBe(true);
expect(state.get(DISK_SYNC_FEATURE_FLAG_KEY)).toBe(true);
});
it('stores folder path per workspace and resolves remote options only when enabled', () => {
setDiskSyncFolderPath('workspace-a', '/tmp/a');
expect(getDiskSyncFolderPath('workspace-a')).toBe('/tmp/a');
expect(state.get(DISK_SYNC_FOLDERS_GLOBAL_STATE_KEY)).toEqual({
'workspace-a': '/tmp/a',
});
expect(getDiskSyncRemoteOptions('workspace-a')).toBeNull();
setDiskSyncEnabled(true);
expect(getDiskSyncRemoteOptions('workspace-a')).toEqual({
syncFolder: '/tmp/a',
});
});
it('ignores config when not running in electron', () => {
globalThis.BUILD_CONFIG = {
...globalThis.BUILD_CONFIG,
isElectron: false,
};
state.set(DISK_SYNC_FEATURE_FLAG_KEY, true);
state.set(DISK_SYNC_FOLDERS_GLOBAL_STATE_KEY, {
'workspace-b': '/tmp/b',
});
expect(getDiskSyncEnabled()).toBe(false);
expect(getDiskSyncFolderPath('workspace-b')).toBeNull();
expect(getDiskSyncRemoteOptions('workspace-b')).toBeNull();
});
});
@@ -0,0 +1,99 @@
const DISK_SYNC_FLAG_STORAGE_KEY = 'affine-flag:enable_disk_sync';
const DISK_SYNC_FOLDERS_STORAGE_KEY = 'workspace-engine:disk-sync-folders:v1';
type GlobalStateStorageLike = {
get<T>(key: string): T | undefined;
set<T>(key: string, value: T): void;
};
function getElectronGlobalStateStorage(): GlobalStateStorageLike | null {
if (!BUILD_CONFIG.isElectron) {
return null;
}
const sharedStorage = (
globalThis as {
__sharedStorage?: { globalState?: GlobalStateStorageLike };
}
).__sharedStorage;
return sharedStorage?.globalState ?? null;
}
function normalizeFolderMap(value: unknown): Record<string, string> {
if (!value || typeof value !== 'object') {
return {};
}
const validEntries = Object.entries(value).filter(
([workspaceId, folder]) =>
typeof workspaceId === 'string' &&
workspaceId.length > 0 &&
typeof folder === 'string' &&
folder.length > 0
);
return Object.fromEntries(validEntries);
}
function readFolderMap(): Record<string, string> {
const storage = getElectronGlobalStateStorage();
if (!storage) {
return {};
}
return normalizeFolderMap(
storage.get<Record<string, string>>(DISK_SYNC_FOLDERS_STORAGE_KEY)
);
}
export function getDiskSyncEnabled(): boolean {
const storage = getElectronGlobalStateStorage();
if (!storage) {
return false;
}
return storage.get<boolean>(DISK_SYNC_FLAG_STORAGE_KEY) ?? false;
}
export function setDiskSyncEnabled(enabled: boolean): void {
const storage = getElectronGlobalStateStorage();
if (!storage) {
return;
}
storage.set(DISK_SYNC_FLAG_STORAGE_KEY, enabled);
}
export function getDiskSyncFolderPath(workspaceId: string): string | null {
return readFolderMap()[workspaceId] ?? null;
}
export function setDiskSyncFolderPath(
workspaceId: string,
folder: string | null
): void {
const storage = getElectronGlobalStateStorage();
if (!storage) {
return;
}
const folders = readFolderMap();
if (!folder) {
delete folders[workspaceId];
} else {
folders[workspaceId] = folder;
}
storage.set(DISK_SYNC_FOLDERS_STORAGE_KEY, folders);
}
export function getDiskSyncRemoteOptions(workspaceId: string): {
syncFolder: string;
} | null {
if (!getDiskSyncEnabled()) {
return null;
}
const folder = getDiskSyncFolderPath(workspaceId);
if (!folder) {
return null;
}
return { syncFolder: folder };
}
export const DISK_SYNC_FEATURE_FLAG_KEY = DISK_SYNC_FLAG_STORAGE_KEY;
export const DISK_SYNC_FOLDERS_GLOBAL_STATE_KEY = DISK_SYNC_FOLDERS_STORAGE_KEY;
@@ -6,6 +6,7 @@ import {
type ListedBlobRecord,
universalId,
} from '@affine/nbstore';
import { DiskDocStorage } from '@affine/nbstore/disk';
import {
IndexedDBBlobStorage,
IndexedDBBlobSyncStorage,
@@ -46,6 +47,7 @@ import type {
WorkspaceProfileInfo,
} from '../../workspace';
import { WorkspaceImpl } from '../../workspace/impls/workspace';
import { getDiskSyncRemoteOptions } from './disk-config';
import { getWorkspaceProfileWorker } from './out-worker';
import {
dedupeWorkspaceIds,
@@ -430,6 +432,7 @@ class LocalWorkspaceFlavourProvider implements WorkspaceFlavourProvider {
}
getEngineWorkerInitOptions(workspaceId: string): WorkerInitOptions {
const disk = getDiskSyncRemoteOptions(workspaceId);
return {
local: {
doc: {
@@ -488,6 +491,21 @@ class LocalWorkspaceFlavourProvider implements WorkspaceFlavourProvider {
},
},
remotes: {
...(disk
? {
disk: {
doc: {
name: DiskDocStorage.identifier,
opts: {
flavour: this.flavour,
type: 'workspace',
id: workspaceId,
syncFolder: disk.syncFolder,
},
},
},
}
: {}),
v1: {
doc: this.DocStorageV1Type
? {