mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-23 21:38:44 +08:00
feat: init disk remote source
This commit is contained in:
+7
@@ -0,0 +1,7 @@
|
||||
export function shouldReloadDiskSyncSession(
|
||||
enabled: boolean,
|
||||
previousFolder: string | null,
|
||||
nextFolder: string | null
|
||||
) {
|
||||
return enabled && previousFolder !== nextFolder;
|
||||
}
|
||||
+27
@@ -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);
|
||||
});
|
||||
});
|
||||
+116
@@ -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
|
||||
? {
|
||||
|
||||
Reference in New Issue
Block a user