feat: create workspace from loading existing exported file (#2122)

Co-authored-by: Himself65 <himself65@outlook.com>
This commit is contained in:
Peng Xiao
2023-05-09 15:30:01 +08:00
committed by GitHub
parent 5432aae85c
commit 7c2574b1ca
93 changed files with 2999 additions and 1406 deletions

View File

@@ -45,6 +45,7 @@ module.exports = {
teamId: process.env.APPLE_TEAM_ID,
}
: undefined,
// do we need the following line?
extraResource: ['./resources/app-update.yml'],
},
makers: [

View File

@@ -0,0 +1,7 @@
/* eslint-disable @typescript-eslint/consistent-type-imports */
// This file contains the main process events
// It will guide preload and main process on the correct event types and payloads
export type MainIPCHandlerMap = typeof import('./main/src/exposed').handlers;
export type MainIPCEventMap = typeof import('./main/src/exposed').events;

View File

@@ -1,3 +0,0 @@
import log from 'electron-log';
export const logger = log;

View File

@@ -1,6 +0,0 @@
// This file contains the main process events
// It will guide preload and main process on the correct event types and payloads
export interface MainEventMap {
'main:on-db-update': (workspaceId: string) => void;
'main:client-update-available': (version: string) => void;
}

View File

@@ -1,226 +0,0 @@
import assert from 'node:assert';
import path from 'node:path';
import fs from 'fs-extra';
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import * as Y from 'yjs';
const registeredHandlers = new Map<string, (...args: any[]) => any>();
// common mock dispatcher for ipcMain.handle and app.on
async function dispatch(key: string, ...args: any[]) {
const handler = registeredHandlers.get(key);
assert(handler);
return await handler(null, ...args);
}
const APP_PATH = path.join(__dirname, './tmp');
const browserWindow = {
isDestroyed: () => {
return false;
},
setWindowButtonVisibility: (_v: boolean) => {
// will be stubbed later
},
webContents: {
send: (_type: string, ..._args: any[]) => {
// ...
},
},
};
const ipcMain = {
handle: (key: string, callback: (...args: any[]) => any) => {
registeredHandlers.set(key, callback);
},
};
const nativeTheme = {
themeSource: 'light',
};
function compareBuffer(a: Uint8Array, b: Uint8Array) {
if (a.length !== b.length) {
return false;
}
for (let i = 0; i < a.length; i++) {
if (a[i] !== b[i]) {
return false;
}
}
return true;
}
// dynamically import handlers so that we can inject local variables to mocks
vi.doMock('electron', () => {
return {
app: {
getPath: (name: string) => {
assert(name === 'appData');
return APP_PATH;
},
name: 'affine-test',
on: (name: string, callback: (...args: any[]) => any) => {
registeredHandlers.set(name, callback);
},
},
BrowserWindow: {
getAllWindows: () => {
return [browserWindow];
},
},
nativeTheme: nativeTheme,
ipcMain,
};
});
beforeEach(async () => {
// clean up tmp folder
const { registerHandlers } = await import('../handlers');
registerHandlers();
});
afterEach(async () => {
const { cleanupWorkspaceDBs } = await import('../handlers');
cleanupWorkspaceDBs();
await fs.remove(APP_PATH);
});
describe('ensureWorkspaceDB', () => {
test('should create db file on connection if it does not exist', async () => {
const id = 'test-workspace-id';
const { ensureWorkspaceDB } = await import('../handlers');
const workspaceDB = await ensureWorkspaceDB(id);
const file = workspaceDB.path;
const fileExists = await fs.pathExists(file);
expect(fileExists).toBe(true);
});
});
describe('workspace handlers', () => {
test('list all workspace ids', async () => {
const ids = ['test-workspace-id', 'test-workspace-id-2'];
const { ensureWorkspaceDB } = await import('../handlers');
await Promise.all(ids.map(id => ensureWorkspaceDB(id)));
const list = await dispatch('workspace:list');
expect(list).toEqual(ids);
});
test('delete workspace', async () => {
const ids = ['test-workspace-id', 'test-workspace-id-2'];
const { ensureWorkspaceDB } = await import('../handlers');
await Promise.all(ids.map(id => ensureWorkspaceDB(id)));
await dispatch('workspace:delete', 'test-workspace-id-2');
const list = await dispatch('workspace:list');
expect(list).toEqual(['test-workspace-id']);
});
});
describe('UI handlers', () => {
test('theme-change', async () => {
await dispatch('ui:theme-change', 'dark');
expect(nativeTheme.themeSource).toBe('dark');
await dispatch('ui:theme-change', 'light');
expect(nativeTheme.themeSource).toBe('light');
});
test('sidebar-visibility-change (macOS)', async () => {
vi.stubGlobal('process', { platform: 'darwin' });
const setWindowButtonVisibility = vi.fn();
browserWindow.setWindowButtonVisibility = setWindowButtonVisibility;
await dispatch('ui:sidebar-visibility-change', true);
expect(setWindowButtonVisibility).toBeCalledWith(true);
await dispatch('ui:sidebar-visibility-change', false);
expect(setWindowButtonVisibility).toBeCalledWith(false);
vi.unstubAllGlobals();
});
test('sidebar-visibility-change (non-macOS)', async () => {
vi.stubGlobal('process', { platform: 'linux' });
const setWindowButtonVisibility = vi.fn();
browserWindow.setWindowButtonVisibility = setWindowButtonVisibility;
await dispatch('ui:sidebar-visibility-change', true);
expect(setWindowButtonVisibility).not.toBeCalled();
vi.unstubAllGlobals();
});
});
describe('db handlers', () => {
test('will reconnect on activate', async () => {
const { ensureWorkspaceDB } = await import('../handlers');
const workspaceDB = await ensureWorkspaceDB('test-workspace-id');
const instance = vi.spyOn(workspaceDB, 'reconnectDB');
await dispatch('activate');
expect(instance).toBeCalled();
});
test('apply doc and get doc updates', async () => {
const workspaceId = 'test-workspace-id';
const bin = await dispatch('db:get-doc', workspaceId);
// ? is this a good test?
expect(bin.every((byte: number) => byte === 0)).toBe(true);
const ydoc = new Y.Doc();
const ytext = ydoc.getText('test');
ytext.insert(0, 'hello world');
const bin2 = Y.encodeStateAsUpdate(ydoc);
await dispatch('db:apply-doc-update', workspaceId, bin2);
const bin3 = await dispatch('db:get-doc', workspaceId);
const ydoc2 = new Y.Doc();
Y.applyUpdate(ydoc2, bin3);
const ytext2 = ydoc2.getText('test');
expect(ytext2.toString()).toBe('hello world');
});
test('get non existent doc', async () => {
const workspaceId = 'test-workspace-id';
const bin = await dispatch('db:get-blob', workspaceId, 'non-existent-id');
expect(bin).toBeNull();
});
test('list blobs (empty)', async () => {
const workspaceId = 'test-workspace-id';
const list = await dispatch('db:get-persisted-blobs', workspaceId);
expect(list).toEqual([]);
});
test('CRUD blobs', async () => {
const testBin = new Uint8Array([1, 2, 3, 4, 5]);
const testBin2 = new Uint8Array([6, 7, 8, 9, 10]);
const workspaceId = 'test-workspace-id';
// add blob
await dispatch('db:add-blob', workspaceId, 'testBin', testBin);
// get blob
expect(
compareBuffer(
await dispatch('db:get-blob', workspaceId, 'testBin'),
testBin
)
).toBe(true);
// add another blob
await dispatch('db:add-blob', workspaceId, 'testBin2', testBin2);
expect(
compareBuffer(
await dispatch('db:get-blob', workspaceId, 'testBin2'),
testBin2
)
).toBe(true);
// list blobs
let lists = await dispatch('db:get-persisted-blobs', workspaceId);
expect(lists).toHaveLength(2);
expect(lists).toContain('testBin');
expect(lists).toContain('testBin2');
// delete blob
await dispatch('db:delete-blob', workspaceId, 'testBin');
lists = await dispatch('db:get-persisted-blobs', workspaceId);
expect(lists).toEqual(['testBin2']);
});
});

View File

@@ -1,9 +1,12 @@
import { app } from 'electron';
import path from 'path';
export const appContext = {
appName: app.name,
appDataPath: path.join(app.getPath('appData'), app.name),
get appName() {
return app.name;
},
get appDataPath() {
return app.getPath('sessionData');
},
};
export type AppContext = typeof appContext;

View File

@@ -1,34 +0,0 @@
import fs from 'fs-extra';
import { logger } from '../../../logger';
import type { WorkspaceDatabase } from './sqlite';
/**
* Start a backup of the database to the given destination.
*/
export async function exportDatabase(db: WorkspaceDatabase, dest: string) {
await fs.copyFile(db.path, dest);
logger.log('export: ', dest);
}
// export async function startBackup(db: WorkspaceDatabase, dest: string) {
// let timeout: NodeJS.Timeout | null;
// async function backup() {
// await fs.copyFile(db.path, dest);
// logger.log('backup: ', dest);
// }
// backup();
// const _db = await db.sqliteDB$;
// _db.on('change', () => {
// if (timeout) {
// clearTimeout(timeout);
// }
// timeout = setTimeout(async () => {
// await backup();
// timeout = null;
// }, 1000);
// });
// }

View File

@@ -1,7 +0,0 @@
import type { WatchListener } from 'fs-extra';
import fs from 'fs-extra';
export function watchFile(path: string, callback: WatchListener<string>) {
const watcher = fs.watch(path, callback);
return () => watcher.close();
}

View File

@@ -1,34 +0,0 @@
import path from 'node:path';
import fs from 'fs-extra';
import { logger } from '../../../logger';
import type { AppContext } from '../context';
export async function listWorkspaces(context: AppContext) {
const basePath = path.join(context.appDataPath, 'workspaces');
try {
return fs
.readdir(basePath, {
withFileTypes: true,
})
.then(dirs => dirs.filter(dir => dir.isDirectory()).map(dir => dir.name));
} catch (error) {
logger.error('listWorkspaces', error);
return [];
}
}
export async function deleteWorkspace(context: AppContext, id: string) {
const basePath = path.join(context.appDataPath, 'workspaces', id);
const movedPath = path.join(
context.appDataPath,
'delete-workspaces',
`${id}`
);
try {
return fs.move(basePath, movedPath);
} catch (error) {
logger.error('deleteWorkspace', error);
}
}

View File

@@ -0,0 +1,26 @@
import { Subject } from 'rxjs';
import type { MainEventListener } from './type';
export const dbSubjects = {
// emit workspace ids
dbFileMissing: new Subject<string>(),
// emit workspace ids
dbFileUpdate: new Subject<string>(),
};
export const dbEvents = {
onDbFileMissing: (fn: (workspaceId: string) => void) => {
const sub = dbSubjects.dbFileMissing.subscribe(fn);
return () => {
sub.unsubscribe();
};
},
onDbFileUpdate: (fn: (workspaceId: string) => void) => {
const sub = dbSubjects.dbFileUpdate.subscribe(fn);
return () => {
sub.unsubscribe();
};
},
} satisfies Record<string, MainEventListener>;

View File

@@ -0,0 +1,7 @@
export * from './register';
import { dbSubjects } from './db';
export const subjects = {
db: dbSubjects,
};

View File

@@ -0,0 +1,30 @@
import { app, BrowserWindow } from 'electron';
import { logger } from '../logger';
import { dbEvents } from './db';
import { updaterEvents } from './updater';
export const allEvents = {
db: dbEvents,
updater: updaterEvents,
};
function getActiveWindows() {
return BrowserWindow.getAllWindows().filter(win => !win.isDestroyed());
}
export function registerEvents() {
// register events
for (const [namespace, namespaceEvents] of Object.entries(allEvents)) {
for (const [key, eventRegister] of Object.entries(namespaceEvents)) {
const subscription = eventRegister((...args: any) => {
const chan = `${namespace}:${key}`;
logger.info('[ipc-event]', chan, args);
getActiveWindows().forEach(win => win.webContents.send(chan, ...args));
});
app.on('before-quit', () => {
subscription();
});
}
}
}

View File

@@ -0,0 +1 @@
export type MainEventListener = (...args: any[]) => () => void;

View File

@@ -0,0 +1,21 @@
import { Subject } from 'rxjs';
import type { MainEventListener } from './type';
interface UpdateMeta {
version: string;
}
export const updaterSubjects = {
// means it is ready for restart and install the new version
clientUpdateReady: new Subject<UpdateMeta>(),
};
export const updaterEvents = {
onClientUpdateReady: (fn: (versionMeta: UpdateMeta) => void) => {
const sub = updaterSubjects.clientUpdateReady.subscribe(fn);
return () => {
sub.unsubscribe();
};
},
} satisfies Record<string, MainEventListener>;

View File

@@ -0,0 +1,5 @@
import { allEvents as events } from './events';
import { allHandlers as handlers } from './handlers';
// this will be used by preload script to expose all handlers and events to the renderer process
export { events, handlers };

View File

@@ -1,23 +0,0 @@
const redirectUri = 'https://affine.pro/client/auth-callback';
export const oauthEndpoint = `https://accounts.google.com/o/oauth2/v2/auth?client_id=${process.env.AFFINE_GOOGLE_CLIENT_ID}&redirect_uri=${redirectUri}&response_type=code&scope=openid https://www.googleapis.com/auth/userinfo.email profile&access_type=offline&customParameters={"prompt":"select_account"}`;
const tokenEndpoint = 'https://oauth2.googleapis.com/token';
export const getExchangeTokenParams = (code: string) => {
const postData = {
code,
client_id: process.env.AFFINE_GOOGLE_CLIENT_ID || '',
client_secret: process.env.AFFINE_GOOGLE_CLIENT_SECRET || '',
redirect_uri: redirectUri,
grant_type: 'authorization_code',
};
const requestInit: RequestInit = {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams(postData).toString(),
};
return { requestInit, url: tokenEndpoint };
};

View File

@@ -1,232 +0,0 @@
import {
app,
BrowserWindow,
dialog,
ipcMain,
nativeTheme,
shell,
} from 'electron';
import { parse } from 'url';
import { logger } from '../../logger';
import { isMacOS } from '../../utils';
import { appContext } from './context';
import { exportDatabase } from './data/export';
import { watchFile } from './data/fs-watch';
import type { WorkspaceDatabase } from './data/sqlite';
import { openWorkspaceDatabase } from './data/sqlite';
import { deleteWorkspace, listWorkspaces } from './data/workspace';
import { getExchangeTokenParams, oauthEndpoint } from './google-auth';
import { sendMainEvent } from './send-main-event';
import { updateClient } from './updater';
let currentWorkspaceId = '';
const dbMapping = new Map<string, WorkspaceDatabase>();
const dbWatchers = new Map<string, () => void>();
const dBLastUse = new Map<string, number>();
export async function ensureWorkspaceDB(id: string) {
let workspaceDB = dbMapping.get(id);
if (!workspaceDB) {
// hmm... potential race condition?
workspaceDB = await openWorkspaceDatabase(appContext, id);
dbMapping.set(id, workspaceDB);
logger.info('watch db file', workspaceDB.path);
dbWatchers.set(
id,
watchFile(workspaceDB.path, (event, filename) => {
const minTime = 1000;
logger.debug(
'db file changed',
event,
filename,
Date.now() - dBLastUse.get(id)!
);
if (Date.now() - dBLastUse.get(id)! < minTime || !filename) {
logger.debug('skip db update');
return;
}
sendMainEvent('main:on-db-update', id);
// handle DB file update by other process
dbWatchers.get(id)?.();
dbMapping.delete(id);
dbWatchers.delete(id);
ensureWorkspaceDB(id);
})
);
}
dBLastUse.set(id, Date.now());
return workspaceDB;
}
export async function cleanupWorkspaceDBs() {
for (const [id, db] of dbMapping) {
logger.info('close db connection', id);
db.destroy();
dbWatchers.get(id)?.();
}
dbMapping.clear();
dbWatchers.clear();
dBLastUse.clear();
}
function registerWorkspaceHandlers() {
ipcMain.handle('workspace:list', async _ => {
logger.info('list workspaces');
return listWorkspaces(appContext);
});
ipcMain.handle('workspace:delete', async (_, id) => {
logger.info('delete workspace', id);
return deleteWorkspace(appContext, id);
});
}
function registerUIHandlers() {
ipcMain.handle('ui:theme-change', async (_, theme) => {
nativeTheme.themeSource = theme;
logger.info('theme change', theme);
});
ipcMain.handle('ui:sidebar-visibility-change', async (_, visible) => {
// todo
// detect if os is macos
if (isMacOS()) {
const windows = BrowserWindow.getAllWindows();
windows.forEach(w => {
// hide window buttons when sidebar is not visible
w.setWindowButtonVisibility(visible);
});
logger.info('sidebar visibility change', visible);
}
});
ipcMain.handle('ui:workspace-change', async (_, workspaceId) => {
logger.info('workspace change', workspaceId);
currentWorkspaceId = workspaceId;
});
// @deprecated
ipcMain.handle('ui:get-google-oauth-code', async () => {
logger.info('starting google sign in ...');
shell.openExternal(oauthEndpoint);
return new Promise((resolve, reject) => {
const handleOpenUrl = async (_: any, url: string) => {
const mainWindow = BrowserWindow.getAllWindows().find(
w => !w.isDestroyed()
);
const urlObj = parse(url.replace('??', '?'), true);
if (!mainWindow || !url.startsWith('affine://auth-callback')) return;
const code = urlObj.query['code'] as string;
if (!code) return;
logger.info('google sign in code received from callback', code);
app.removeListener('open-url', handleOpenUrl);
resolve(getExchangeTokenParams(code));
};
app.on('open-url', handleOpenUrl);
setTimeout(() => {
reject(new Error('Timed out'));
app.removeListener('open-url', handleOpenUrl);
}, 30000);
});
});
ipcMain.handle('main:env-update', async (_, env, value) => {
process.env[env] = value;
});
ipcMain.handle('ui:client-update-install', async () => {
await updateClient();
});
}
function registerDBHandlers() {
app.on('activate', () => {
for (const [_, workspaceDB] of dbMapping) {
workspaceDB.reconnectDB();
}
});
ipcMain.handle('db:get-doc', async (_, id) => {
logger.log('main: get doc', id);
const workspaceDB = await ensureWorkspaceDB(id);
return workspaceDB.getEncodedDocUpdates();
});
ipcMain.handle('db:apply-doc-update', async (_, id, update) => {
logger.log('main: apply doc update', id);
const workspaceDB = await ensureWorkspaceDB(id);
return workspaceDB.applyUpdate(update);
});
ipcMain.handle('db:add-blob', async (_, workspaceId, key, data) => {
logger.log('main: add blob', workspaceId, key);
const workspaceDB = await ensureWorkspaceDB(workspaceId);
return workspaceDB.addBlob(key, data);
});
ipcMain.handle('db:get-blob', async (_, workspaceId, key) => {
logger.log('main: get blob', workspaceId, key);
const workspaceDB = await ensureWorkspaceDB(workspaceId);
return workspaceDB.getBlob(key);
});
ipcMain.handle('db:get-persisted-blobs', async (_, workspaceId) => {
logger.log('main: get persisted blob keys', workspaceId);
const workspaceDB = await ensureWorkspaceDB(workspaceId);
return workspaceDB.getPersistentBlobKeys();
});
ipcMain.handle('db:delete-blob', async (_, workspaceId, key) => {
logger.log('main: delete blob', workspaceId, key);
const workspaceDB = await ensureWorkspaceDB(workspaceId);
return workspaceDB.deleteBlob(key);
});
ipcMain.handle('ui:open-db-folder', async _ => {
const workspaceDB = await ensureWorkspaceDB(currentWorkspaceId);
logger.log('main: open db folder', workspaceDB.path);
shell.showItemInFolder(workspaceDB.path);
});
ipcMain.handle('ui:open-load-db-file-dialog', async () => {
// todo
});
ipcMain.handle('ui:open-save-db-file-dialog', async () => {
logger.log('main: open save db file dialog', currentWorkspaceId);
const workspaceDB = await ensureWorkspaceDB(currentWorkspaceId);
const ret = await dialog.showSaveDialog({
properties: ['showOverwriteConfirmation'],
title: 'Save Workspace',
buttonLabel: 'Save',
defaultPath: currentWorkspaceId + '.db',
message: 'Save Workspace as SQLite Database',
});
const filePath = ret.filePath;
if (ret.canceled || !filePath) {
return null;
}
await exportDatabase(workspaceDB, filePath);
shell.showItemInFolder(filePath);
return filePath;
});
}
export const registerHandlers = () => {
registerWorkspaceHandlers();
registerUIHandlers();
registerDBHandlers();
};

View File

@@ -0,0 +1,472 @@
import assert from 'node:assert';
import path from 'node:path';
import fs from 'fs-extra';
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import * as Y from 'yjs';
import type { MainIPCHandlerMap } from '../../../../constraints';
const registeredHandlers = new Map<
string,
((...args: any[]) => Promise<any>)[]
>();
const delay = (ms: number) => new Promise(r => setTimeout(r, ms));
type WithoutFirstParameter<T> = T extends (_: any, ...args: infer P) => infer R
? (...args: P) => R
: T;
// common mock dispatcher for ipcMain.handle AND app.on
// alternatively, we can use single parameter for T & F, eg, dispatch('workspace:list'),
// however this is too hard to be typed correctly
async function dispatch<
T extends keyof MainIPCHandlerMap,
F extends keyof MainIPCHandlerMap[T]
>(
namespace: T,
functionName: F,
// @ts-ignore
...args: Parameters<WithoutFirstParameter<MainIPCHandlerMap[T][F]>>
): // @ts-ignore
ReturnType<MainIPCHandlerMap[T][F]> {
// @ts-ignore
const handlers = registeredHandlers.get(namespace + ':' + functionName);
assert(handlers);
// we only care about the first handler here
return await handlers[0](null, ...args);
}
const SESSION_DATA_PATH = path.join(__dirname, './tmp', 'affine-test');
const browserWindow = {
isDestroyed: () => {
return false;
},
setWindowButtonVisibility: (_v: boolean) => {
// will be stubbed later
},
webContents: {
send: (_type: string, ..._args: any[]) => {
// will be stubbed later
},
},
};
const ipcMain = {
handle: (key: string, callback: (...args: any[]) => Promise<any>) => {
const handlers = registeredHandlers.get(key) || [];
handlers.push(callback);
registeredHandlers.set(key, handlers);
},
};
const nativeTheme = {
themeSource: 'light',
};
function compareBuffer(a: Uint8Array | null, b: Uint8Array | null) {
if (
(a === null && b === null) ||
a === null ||
b === null ||
a.length !== b.length
) {
return false;
}
for (let i = 0; i < a.length; i++) {
if (a[i] !== b[i]) {
return false;
}
}
return true;
}
const electronModule = {
app: {
getPath: (name: string) => {
assert(name === 'sessionData');
return SESSION_DATA_PATH;
},
name: 'affine-test',
on: (name: string, callback: (...args: any[]) => any) => {
const handlers = registeredHandlers.get(name) || [];
handlers.push(callback);
registeredHandlers.set(name, handlers);
},
},
BrowserWindow: {
getAllWindows: () => {
return [browserWindow];
},
},
nativeTheme: nativeTheme,
ipcMain,
shell: {} as Partial<Electron.Shell>,
dialog: {} as Partial<Electron.Dialog>,
};
// dynamically import handlers so that we can inject local variables to mocks
vi.doMock('electron', () => {
return electronModule;
});
beforeEach(async () => {
const { registerHandlers } = await import('../register');
registerHandlers();
// should also register events
const { registerEvents } = await import('../../events');
registerEvents();
});
afterEach(async () => {
const { cleanupSQLiteDBs } = await import('../db/ensure-db');
await cleanupSQLiteDBs();
await fs.remove(SESSION_DATA_PATH);
// reset registered handlers
registeredHandlers.get('before-quit')?.forEach(fn => fn());
});
describe('ensureSQLiteDB', () => {
test('should create db file on connection if it does not exist', async () => {
const id = 'test-workspace-id';
const { ensureSQLiteDB } = await import('../db/ensure-db');
const workspaceDB = await ensureSQLiteDB(id);
const file = workspaceDB.path;
const fileExists = await fs.pathExists(file);
expect(fileExists).toBe(true);
});
test('when db file is removed', async () => {
// stub webContents.send
const sendStub = vi.fn();
browserWindow.webContents.send = sendStub;
const id = 'test-workspace-id';
const { ensureSQLiteDB } = await import('../db/ensure-db');
let workspaceDB = await ensureSQLiteDB(id);
const file = workspaceDB.path;
const fileExists = await fs.pathExists(file);
expect(fileExists).toBe(true);
await fs.remove(file);
// wait for 1000ms for file watcher to detect file removal
await delay(2000);
expect(sendStub).toBeCalledWith('db:onDbFileMissing', id);
// ensureSQLiteDB should recreate the db file
workspaceDB = await ensureSQLiteDB(id);
const fileExists2 = await fs.pathExists(file);
expect(fileExists2).toBe(true);
});
test('when db file is updated', async () => {
// stub webContents.send
const sendStub = vi.fn();
browserWindow.webContents.send = sendStub;
const id = 'test-workspace-id';
const { ensureSQLiteDB } = await import('../db/ensure-db');
const workspaceDB = await ensureSQLiteDB(id);
const file = workspaceDB.path;
const fileExists = await fs.pathExists(file);
expect(fileExists).toBe(true);
// wait to make sure
await delay(500);
// writes some data to the db file
await fs.appendFile(file, 'random-data', { encoding: 'binary' });
// write again
await fs.appendFile(file, 'random-data', { encoding: 'binary' });
// wait for 200ms for file watcher to detect file change
await delay(2000);
expect(sendStub).toBeCalledWith('db:onDbFileUpdate', id);
// should only call once for multiple writes
expect(sendStub).toBeCalledTimes(1);
});
});
describe('workspace handlers', () => {
test('list all workspace ids', async () => {
const ids = ['test-workspace-id', 'test-workspace-id-2'];
const { ensureSQLiteDB } = await import('../db/ensure-db');
await Promise.all(ids.map(id => ensureSQLiteDB(id)));
const list = await dispatch('workspace', 'list');
expect(list.map(([id]) => id)).toEqual(ids);
});
test('delete workspace', async () => {
const ids = ['test-workspace-id', 'test-workspace-id-2'];
const { ensureSQLiteDB } = await import('../db/ensure-db');
await Promise.all(ids.map(id => ensureSQLiteDB(id)));
await dispatch('workspace', 'delete', 'test-workspace-id-2');
const list = await dispatch('workspace', 'list');
expect(list.map(([id]) => id)).toEqual(['test-workspace-id']);
});
});
describe('UI handlers', () => {
test('theme-change', async () => {
await dispatch('ui', 'handleThemeChange', 'dark');
expect(nativeTheme.themeSource).toBe('dark');
await dispatch('ui', 'handleThemeChange', 'light');
expect(nativeTheme.themeSource).toBe('light');
});
test('sidebar-visibility-change (macOS)', async () => {
vi.stubGlobal('process', { platform: 'darwin' });
const setWindowButtonVisibility = vi.fn();
browserWindow.setWindowButtonVisibility = setWindowButtonVisibility;
await dispatch('ui', 'handleSidebarVisibilityChange', true);
expect(setWindowButtonVisibility).toBeCalledWith(true);
await dispatch('ui', 'handleSidebarVisibilityChange', false);
expect(setWindowButtonVisibility).toBeCalledWith(false);
vi.unstubAllGlobals();
});
test('sidebar-visibility-change (non-macOS)', async () => {
vi.stubGlobal('process', { platform: 'linux' });
const setWindowButtonVisibility = vi.fn();
browserWindow.setWindowButtonVisibility = setWindowButtonVisibility;
await dispatch('ui', 'handleSidebarVisibilityChange', true);
expect(setWindowButtonVisibility).not.toBeCalled();
vi.unstubAllGlobals();
});
});
describe('db handlers', () => {
test('apply doc and get doc updates', async () => {
const workspaceId = 'test-workspace-id';
const bin = await dispatch('db', 'getDocAsUpdates', workspaceId);
// ? is this a good test?
expect(bin.every((byte: number) => byte === 0)).toBe(true);
const ydoc = new Y.Doc();
const ytext = ydoc.getText('test');
ytext.insert(0, 'hello world');
const bin2 = Y.encodeStateAsUpdate(ydoc);
await dispatch('db', 'applyDocUpdate', workspaceId, bin2);
const bin3 = await dispatch('db', 'getDocAsUpdates', workspaceId);
const ydoc2 = new Y.Doc();
Y.applyUpdate(ydoc2, bin3);
const ytext2 = ydoc2.getText('test');
expect(ytext2.toString()).toBe('hello world');
});
test('get non existent blob', async () => {
const workspaceId = 'test-workspace-id';
const bin = await dispatch('db', 'getBlob', workspaceId, 'non-existent-id');
expect(bin).toBeNull();
});
test('list blobs (empty)', async () => {
const workspaceId = 'test-workspace-id';
const list = await dispatch('db', 'getPersistedBlobs', workspaceId);
expect(list).toEqual([]);
});
test('CRUD blobs', async () => {
const testBin = new Uint8Array([1, 2, 3, 4, 5]);
const testBin2 = new Uint8Array([6, 7, 8, 9, 10]);
const workspaceId = 'test-workspace-id';
// add blob
await dispatch('db', 'addBlob', workspaceId, 'testBin', testBin);
// get blob
expect(
compareBuffer(
await dispatch('db', 'getBlob', workspaceId, 'testBin'),
testBin
)
).toBe(true);
// add another blob
await dispatch('db', 'addBlob', workspaceId, 'testBin2', testBin2);
expect(
compareBuffer(
await dispatch('db', 'getBlob', workspaceId, 'testBin2'),
testBin2
)
).toBe(true);
// list blobs
let lists = await dispatch('db', 'getPersistedBlobs', workspaceId);
expect(lists).toHaveLength(2);
expect(lists).toContain('testBin');
expect(lists).toContain('testBin2');
// delete blob
await dispatch('db', 'deleteBlob', workspaceId, 'testBin');
lists = await dispatch('db', 'getPersistedBlobs', workspaceId);
expect(lists).toEqual(['testBin2']);
});
});
describe('dialog handlers', () => {
test('revealDBFile', async () => {
const mockShowItemInFolder = vi.fn();
electronModule.shell.showItemInFolder = mockShowItemInFolder;
const id = 'test-workspace-id';
const { ensureSQLiteDB } = await import('../db/ensure-db');
const db = await ensureSQLiteDB(id);
await dispatch('dialog', 'revealDBFile', id);
expect(mockShowItemInFolder).toBeCalledWith(db.path);
});
test('saveDBFileAs (skipped)', async () => {
const mockShowSaveDialog = vi.fn(() => {
return { filePath: undefined };
}) as any;
const mockShowItemInFolder = vi.fn();
electronModule.dialog.showSaveDialog = mockShowSaveDialog;
electronModule.shell.showItemInFolder = mockShowItemInFolder;
const id = 'test-workspace-id';
const { ensureSQLiteDB } = await import('../db/ensure-db');
await ensureSQLiteDB(id);
await dispatch('dialog', 'saveDBFileAs', id);
expect(mockShowSaveDialog).toBeCalled();
expect(mockShowItemInFolder).not.toBeCalled();
});
test('saveDBFileAs', async () => {
const newSavedPath = path.join(SESSION_DATA_PATH, 'saved-to');
const mockShowSaveDialog = vi.fn(() => {
return { filePath: newSavedPath };
}) as any;
const mockShowItemInFolder = vi.fn();
electronModule.dialog.showSaveDialog = mockShowSaveDialog;
electronModule.shell.showItemInFolder = mockShowItemInFolder;
const id = 'test-workspace-id';
const { ensureSQLiteDB } = await import('../db/ensure-db');
await ensureSQLiteDB(id);
await dispatch('dialog', 'saveDBFileAs', id);
expect(mockShowSaveDialog).toBeCalled();
expect(mockShowItemInFolder).toBeCalledWith(newSavedPath);
// check if file is saved to new path
expect(await fs.exists(newSavedPath)).toBe(true);
});
test('loadDBFile (skipped)', async () => {
const mockShowOpenDialog = vi.fn(() => {
return { filePaths: undefined };
}) as any;
electronModule.dialog.showOpenDialog = mockShowOpenDialog;
const res = await dispatch('dialog', 'loadDBFile');
expect(mockShowOpenDialog).toBeCalled();
expect(res.canceled).toBe(true);
});
test('loadDBFile (error, in app-data)', async () => {
const mockShowOpenDialog = vi.fn(() => {
return {
filePaths: [path.join(SESSION_DATA_PATH, 'workspaces')],
};
}) as any;
electronModule.dialog.showOpenDialog = mockShowOpenDialog;
const res = await dispatch('dialog', 'loadDBFile');
expect(mockShowOpenDialog).toBeCalled();
expect(res.error).toBe('DB_FILE_PATH_INVALID');
});
test('loadDBFile (error, not a valid db file)', async () => {
// create a random db file
const basePath = path.join(SESSION_DATA_PATH, 'random-path');
const dbPath = path.join(basePath, 'xxx.db');
await fs.ensureDir(basePath);
await fs.writeFile(dbPath, 'hello world');
const mockShowOpenDialog = vi.fn(() => {
return { filePaths: [dbPath] };
}) as any;
electronModule.dialog.showOpenDialog = mockShowOpenDialog;
const res = await dispatch('dialog', 'loadDBFile');
expect(mockShowOpenDialog).toBeCalled();
expect(res.error).toBe('DB_FILE_INVALID');
});
test('loadDBFile', async () => {
// we use ensureSQLiteDB to create a valid db file
const id = 'test-workspace-id';
const { ensureSQLiteDB } = await import('../db/ensure-db');
const db = await ensureSQLiteDB(id);
// copy db file to dbPath
const basePath = path.join(SESSION_DATA_PATH, 'random-path');
const originDBFilePath = path.join(basePath, 'xxx.db');
await fs.ensureDir(basePath);
await fs.copyFile(db.path, originDBFilePath);
// remove db
await fs.remove(db.path);
// try load originDBFilePath
const mockShowOpenDialog = vi.fn(() => {
return { filePaths: [originDBFilePath] };
}) as any;
electronModule.dialog.showOpenDialog = mockShowOpenDialog;
const res = await dispatch('dialog', 'loadDBFile');
expect(mockShowOpenDialog).toBeCalled();
expect(res.workspaceId).not.toBeUndefined();
const importedDb = await ensureSQLiteDB(res.workspaceId!);
expect(await fs.realpath(importedDb.path)).toBe(originDBFilePath);
expect(importedDb.path).not.toBe(originDBFilePath);
// try load it again, will trigger error (db file already loaded)
const res2 = await dispatch('dialog', 'loadDBFile');
expect(res2.error).toBe('DB_FILE_ALREADY_LOADED');
});
test('moveDBFile', async () => {
const newPath = path.join(SESSION_DATA_PATH, 'affine-test', 'xxx');
const mockShowSaveDialog = vi.fn(() => {
return { filePath: newPath };
}) as any;
electronModule.dialog.showSaveDialog = mockShowSaveDialog;
const id = 'test-workspace-id';
const { ensureSQLiteDB } = await import('../db/ensure-db');
await ensureSQLiteDB(id);
const res = await dispatch('dialog', 'moveDBFile', id);
expect(mockShowSaveDialog).toBeCalled();
expect(res.filePath).toBe(newPath);
});
test('moveDBFile (skipped)', async () => {
const mockShowSaveDialog = vi.fn(() => {
return { filePath: null };
}) as any;
electronModule.dialog.showSaveDialog = mockShowSaveDialog;
const id = 'test-workspace-id';
const { ensureSQLiteDB } = await import('../db/ensure-db');
await ensureSQLiteDB(id);
const res = await dispatch('dialog', 'moveDBFile', id);
expect(mockShowSaveDialog).toBeCalled();
expect(res.filePath).toBe(undefined);
});
});

View File

@@ -0,0 +1,89 @@
import { watch } from 'chokidar';
import { appContext } from '../../context';
import { subjects } from '../../events';
import { logger } from '../../logger';
import { debounce, ts } from '../../utils';
import type { WorkspaceSQLiteDB } from './sqlite';
import { openWorkspaceDatabase } from './sqlite';
const dbMapping = new Map<string, Promise<WorkspaceSQLiteDB>>();
const dbWatchers = new Map<string, () => void>();
// if we removed the file, we will stop watching it
function startWatchingDBFile(db: WorkspaceSQLiteDB) {
if (dbWatchers.has(db.workspaceId)) {
return dbWatchers.get(db.workspaceId);
}
logger.info('watch db file', db.path);
const watcher = watch(db.path);
const debounceOnChange = debounce(() => {
logger.info(
'db file changed on disk',
db.workspaceId,
ts() - db.lastUpdateTime,
'ms'
);
// reconnect db
db.reconnectDB();
subjects.db.dbFileUpdate.next(db.workspaceId);
}, 1000);
watcher.on('change', () => {
const currentTime = ts();
if (currentTime - db.lastUpdateTime > 100) {
debounceOnChange();
}
});
dbWatchers.set(db.workspaceId, () => {
watcher.close();
});
// todo: there is still a possibility that the file is deleted
// but we didn't get the event soon enough and another event tries to
// access the db
watcher.on('unlink', () => {
logger.info('db file missing', db.workspaceId);
subjects.db.dbFileMissing.next(db.workspaceId);
// cleanup
watcher.close().then(() => {
db.destroy();
dbWatchers.delete(db.workspaceId);
dbMapping.delete(db.workspaceId);
});
});
}
export async function ensureSQLiteDB(id: string) {
let workspaceDB = dbMapping.get(id);
if (!workspaceDB) {
logger.info('[ensureSQLiteDB] open db connection', id);
workspaceDB = openWorkspaceDatabase(appContext, id);
dbMapping.set(id, workspaceDB);
startWatchingDBFile(await workspaceDB);
}
return await workspaceDB;
}
export async function disconnectSQLiteDB(id: string) {
const dbp = dbMapping.get(id);
if (dbp) {
const db = await dbp;
logger.info('close db connection', id);
db.destroy();
dbWatchers.get(id)?.();
dbWatchers.delete(id);
dbMapping.delete(id);
}
}
export async function cleanupSQLiteDBs() {
for (const [id] of dbMapping) {
logger.info('close db connection', id);
await disconnectSQLiteDB(id);
}
dbMapping.clear();
dbWatchers.clear();
}

View File

@@ -0,0 +1,33 @@
import { appContext } from '../../context';
import type { NamespaceHandlers } from '../type';
import { ensureSQLiteDB } from './ensure-db';
export const dbHandlers = {
getDocAsUpdates: async (_, id: string) => {
const workspaceDB = await ensureSQLiteDB(id);
return workspaceDB.getDocAsUpdates();
},
applyDocUpdate: async (_, id: string, update: Uint8Array) => {
const workspaceDB = await ensureSQLiteDB(id);
return workspaceDB.applyUpdate(update);
},
addBlob: async (_, workspaceId: string, key: string, data: Uint8Array) => {
const workspaceDB = await ensureSQLiteDB(workspaceId);
return workspaceDB.addBlob(key, data);
},
getBlob: async (_, workspaceId: string, key: string) => {
const workspaceDB = await ensureSQLiteDB(workspaceId);
return workspaceDB.getBlob(key);
},
deleteBlob: async (_, workspaceId: string, key: string) => {
const workspaceDB = await ensureSQLiteDB(workspaceId);
return workspaceDB.deleteBlob(key);
},
getPersistedBlobs: async (_, workspaceId: string) => {
const workspaceDB = await ensureSQLiteDB(workspaceId);
return workspaceDB.getPersistentBlobKeys();
},
getDefaultStorageLocation: async () => {
return appContext.appDataPath;
},
} satisfies NamespaceHandlers;

View File

@@ -5,8 +5,9 @@ import sqlite from 'better-sqlite3';
import fs from 'fs-extra';
import * as Y from 'yjs';
import { logger } from '../../../logger';
import type { AppContext } from '../context';
import type { AppContext } from '../../context';
import { logger } from '../../logger';
import { ts } from '../../utils';
const schemas = [
`CREATE TABLE IF NOT EXISTS "updates" (
@@ -33,46 +34,68 @@ interface BlobRow {
timestamp: string;
}
export class WorkspaceDatabase {
sqliteDB: Database;
const SQLITE_ORIGIN = Symbol('sqlite-origin');
export class WorkspaceSQLiteDB {
db: Database;
ydoc = new Y.Doc();
firstConnect = false;
lastUpdateTime = ts();
constructor(public path: string) {
this.sqliteDB = this.reconnectDB();
constructor(public path: string, public workspaceId: string) {
this.db = this.reconnectDB();
}
// release resources
destroy = () => {
this.sqliteDB?.close();
this.db?.close();
this.ydoc.destroy();
};
getWorkspaceName = () => {
return this.ydoc.getMap('space:meta').get('name') as string;
};
reconnectDB = () => {
logger.log('open db', this.path);
if (this.sqliteDB) {
this.sqliteDB.close();
logger.log('open db', this.workspaceId);
if (this.db) {
this.db.close();
}
// use cached version?
const db = (this.sqliteDB = sqlite(this.path));
const db = (this.db = sqlite(this.path));
db.exec(schemas.join(';'));
if (!this.firstConnect) {
this.ydoc.on('update', this.addUpdateToSQLite);
this.ydoc.on('update', (update: Uint8Array, origin) => {
if (origin !== SQLITE_ORIGIN) {
this.addUpdateToSQLite(update);
}
});
}
const updates = this.getUpdates();
updates.forEach(update => {
Y.applyUpdate(this.ydoc, update.data);
Y.transact(this.ydoc, () => {
const updates = this.getUpdates();
updates.forEach(update => {
// give SQLITE_ORIGIN to skip self update
Y.applyUpdate(this.ydoc, update.data, SQLITE_ORIGIN);
});
});
this.lastUpdateTime = ts();
if (this.firstConnect) {
logger.info('db reconnected', this.workspaceId);
} else {
logger.info('db connected', this.workspaceId);
}
this.firstConnect = true;
return db;
};
getEncodedDocUpdates = () => {
getDocAsUpdates = () => {
return Y.encodeStateAsUpdate(this.ydoc);
};
@@ -80,18 +103,23 @@ export class WorkspaceDatabase {
// after that, the update is added to the db
applyUpdate = (data: Uint8Array) => {
Y.applyUpdate(this.ydoc, data);
// todo: trim the updates when the number of records is too large
// 1. store the current ydoc state in the db
// 2. then delete the old updates
// yjs-idb will always trim the db for the first time after DB is loaded
this.lastUpdateTime = ts();
logger.debug('applyUpdate', this.workspaceId, this.lastUpdateTime);
};
addBlob = (key: string, data: Uint8Array) => {
this.lastUpdateTime = ts();
try {
const statement = this.sqliteDB.prepare(
const statement = this.db.prepare(
'INSERT INTO blobs (key, data) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET data = ?'
);
statement.run(key, data, data);
return key;
} catch (error) {
logger.error('addBlob', error);
}
@@ -99,9 +127,7 @@ export class WorkspaceDatabase {
getBlob = (key: string) => {
try {
const statement = this.sqliteDB.prepare(
'SELECT data FROM blobs WHERE key = ?'
);
const statement = this.db.prepare('SELECT data FROM blobs WHERE key = ?');
const row = statement.get(key) as BlobRow;
if (!row) {
return null;
@@ -114,10 +140,9 @@ export class WorkspaceDatabase {
};
deleteBlob = (key: string) => {
this.lastUpdateTime = ts();
try {
const statement = this.sqliteDB.prepare(
'DELETE FROM blobs WHERE key = ?'
);
const statement = this.db.prepare('DELETE FROM blobs WHERE key = ?');
statement.run(key);
} catch (error) {
logger.error('deleteBlob', error);
@@ -126,7 +151,7 @@ export class WorkspaceDatabase {
getPersistentBlobKeys = () => {
try {
const statement = this.sqliteDB.prepare('SELECT key FROM blobs');
const statement = this.db.prepare('SELECT key FROM blobs');
const rows = statement.all() as BlobRow[];
return rows.map(row => row.key);
} catch (error) {
@@ -137,7 +162,7 @@ export class WorkspaceDatabase {
private getUpdates = () => {
try {
const statement = this.sqliteDB.prepare('SELECT * FROM updates');
const statement = this.db.prepare('SELECT * FROM updates');
const rows = statement.all() as UpdateRow[];
return rows;
} catch (error) {
@@ -150,25 +175,57 @@ export class WorkspaceDatabase {
private addUpdateToSQLite = (data: Uint8Array) => {
try {
const start = performance.now();
const statement = this.sqliteDB.prepare(
const statement = this.db.prepare(
'INSERT INTO updates (data) VALUES (?)'
);
statement.run(data);
logger.debug('addUpdateToSQLite', performance.now() - start, 'ms');
logger.debug(
'addUpdateToSQLite',
this.workspaceId,
'length:',
data.length,
performance.now() - start,
'ms'
);
} catch (error) {
logger.error('addUpdateToSQLite', error);
}
};
}
export async function openWorkspaceDatabase(
export async function getWorkspaceDBPath(
context: AppContext,
workspaceId: string
) {
const basePath = path.join(context.appDataPath, 'workspaces', workspaceId);
// hmmm.... blocking api but it should be fine, right?
await fs.ensureDir(basePath);
const dbPath = path.join(basePath, 'storage.db');
return new WorkspaceDatabase(dbPath);
return path.join(basePath, 'storage.db');
}
export async function openWorkspaceDatabase(
context: AppContext,
workspaceId: string
) {
const dbPath = await getWorkspaceDBPath(context, workspaceId);
return new WorkspaceSQLiteDB(dbPath, workspaceId);
}
export function isValidDBFile(path: string) {
try {
const db = sqlite(path);
// check if db has two tables, one for updates and onefor blobs
const statement = db.prepare(
`SELECT name FROM sqlite_schema WHERE type='table'`
);
const rows = statement.all() as { name: string }[];
const tableNames = rows.map(row => row.name);
if (!tableNames.includes('updates') || !tableNames.includes('blobs')) {
return false;
}
db.close();
return true;
} catch (error) {
logger.error('isValidDBFile', error);
return false;
}
}

View File

@@ -0,0 +1,293 @@
import path from 'node:path';
import { dialog, shell } from 'electron';
import fs from 'fs-extra';
import { nanoid } from 'nanoid';
import { appContext } from '../../context';
import { logger } from '../../logger';
import { ensureSQLiteDB } from '../db/ensure-db';
import { getWorkspaceDBPath, isValidDBFile } from '../db/sqlite';
import { listWorkspaces } from '../workspace/workspace';
// NOTE:
// we are using native dialogs because HTML dialogs do not give full file paths
export async function revealDBFile(workspaceId: string) {
const workspaceDB = await ensureSQLiteDB(workspaceId);
shell.showItemInFolder(workspaceDB.path);
}
// provide a backdoor to set dialog path for testing in playwright
interface FakeDialogResult {
canceled?: boolean;
filePath?: string;
filePaths?: string[];
}
// result will be used in the next call to showOpenDialog
// if it is being read once, it will be reset to undefined
let fakeDialogResult: FakeDialogResult | undefined = undefined;
function getFakedResult() {
const result = fakeDialogResult;
fakeDialogResult = undefined;
return result;
}
export function setFakeDialogResult(result: FakeDialogResult | undefined) {
fakeDialogResult = result;
// for convenience, we will fill filePaths with filePath if it is not set
if (result?.filePaths === undefined && result?.filePath !== undefined) {
result.filePaths = [result.filePath];
}
}
const ErrorMessages = [
'DB_FILE_ALREADY_LOADED',
'DB_FILE_PATH_INVALID',
'DB_FILE_INVALID',
'UNKNOWN_ERROR',
] as const;
type ErrorMessage = (typeof ErrorMessages)[number];
interface SaveDBFileResult {
filePath?: string;
canceled?: boolean;
error?: ErrorMessage;
}
/**
* This function is called when the user clicks the "Save" button in the "Save Workspace" dialog.
*
* It will just copy the file to the given path
*/
export async function saveDBFileAs(
workspaceId: string
): Promise<SaveDBFileResult> {
try {
const db = await ensureSQLiteDB(workspaceId);
const ret =
getFakedResult() ??
(await dialog.showSaveDialog({
properties: ['showOverwriteConfirmation'],
title: 'Save Workspace',
showsTagField: false,
buttonLabel: 'Save',
defaultPath: `${db.getWorkspaceName()}_${workspaceId}.db`,
message: 'Save Workspace as a SQLite Database file',
}));
const filePath = ret.filePath;
if (ret.canceled || !filePath) {
return {
canceled: true,
};
}
await fs.copyFile(db.path, filePath);
logger.log('saved', filePath);
shell.showItemInFolder(filePath);
return { filePath };
} catch (err) {
logger.error('saveDBFileAs', err);
return {
error: 'UNKNOWN_ERROR',
};
}
}
interface SelectDBFileLocationResult {
filePath?: string;
error?: ErrorMessage;
canceled?: boolean;
}
export async function selectDBFileLocation(): Promise<SelectDBFileLocationResult> {
try {
const ret =
getFakedResult() ??
(await dialog.showSaveDialog({
properties: ['showOverwriteConfirmation'],
title: 'Set database location',
showsTagField: false,
buttonLabel: 'Select',
defaultPath: `workspace-storage.db`,
message: "Select a location to store the workspace's database file",
}));
const filePath = ret.filePath;
if (ret.canceled || !filePath) {
return {
canceled: true,
};
}
// the same db file cannot be loaded twice
if (await dbFileAlreadyLoaded(filePath)) {
return {
error: 'DB_FILE_ALREADY_LOADED',
};
}
return { filePath };
} catch (err) {
logger.error('selectDBFileLocation', err);
return {
error: (err as any).message,
};
}
}
interface LoadDBFileResult {
workspaceId?: string;
error?: ErrorMessage;
canceled?: boolean;
}
/**
* This function is called when the user clicks the "Load" button in the "Load Workspace" dialog.
*
* It will
* - symlink the source db file to a new workspace id to app-data
* - return the new workspace id
*
* eg, it will create a new folder in app-data:
* <app-data>/<app-name>/workspaces/<workspace-id>/storage.db
*
* On the renderer side, after the UI got a new workspace id, it will
* update the local workspace id list and then connect to it.
*
*/
export async function loadDBFile(): Promise<LoadDBFileResult> {
try {
const ret =
getFakedResult() ??
(await dialog.showOpenDialog({
properties: ['openFile'],
title: 'Load Workspace',
buttonLabel: 'Load',
filters: [
{
name: 'SQLite Database',
// do we want to support other file format?
extensions: ['db'],
},
],
message: 'Load Workspace from a SQLite Database file',
}));
const filePath = ret.filePaths?.[0];
if (ret.canceled || !filePath) {
logger.info('loadDBFile canceled');
return { canceled: true };
}
// the imported file should not be in app data dir
if (filePath.startsWith(path.join(appContext.appDataPath, 'workspaces'))) {
logger.warn('loadDBFile: db file in app data dir');
return { error: 'DB_FILE_PATH_INVALID' };
}
if (await dbFileAlreadyLoaded(filePath)) {
logger.warn('loadDBFile: db file already loaded');
return { error: 'DB_FILE_ALREADY_LOADED' };
}
if (!isValidDBFile(filePath)) {
// TODO: report invalid db file error?
return { error: 'DB_FILE_INVALID' }; // invalid db file
}
// symlink the db file to a new workspace id
const workspaceId = nanoid(10);
const linkedFilePath = await getWorkspaceDBPath(appContext, workspaceId);
await fs.ensureDir(path.join(appContext.appDataPath, 'workspaces'));
await fs.symlink(filePath, linkedFilePath);
logger.info(`loadDBFile, symlink: ${filePath} -> ${linkedFilePath}`);
return { workspaceId };
} catch (err) {
logger.error('loadDBFile', err);
return {
error: 'UNKNOWN_ERROR',
};
}
}
interface MoveDBFileResult {
filePath?: string;
error?: ErrorMessage;
canceled?: boolean;
}
/**
* This function is called when the user clicks the "Move" button in the "Move Workspace Storage" setting.
*
* It will
* - move the source db file to a new location
* - symlink the new location to the old db file
* - return the new file path
*/
export async function moveDBFile(
workspaceId: string,
dbFileLocation?: string
): Promise<MoveDBFileResult> {
try {
const db = await ensureSQLiteDB(workspaceId);
// get the real file path of db
const realpath = await fs.realpath(db.path);
const isLink = realpath !== db.path;
const newFilePath =
dbFileLocation ||
(
getFakedResult() ||
(await dialog.showSaveDialog({
properties: ['showOverwriteConfirmation'],
title: 'Move Workspace Storage',
showsTagField: false,
buttonLabel: 'Save',
defaultPath: realpath,
message: 'Move Workspace storage file',
}))
).filePath;
// skips if
// - user canceled the dialog
// - user selected the same file
// - user selected the same file in the link file in app data dir
if (!newFilePath || newFilePath === realpath || db.path === newFilePath) {
return {
canceled: true,
};
}
if (isLink) {
// remove the old link to unblock new link
await fs.unlink(db.path);
}
await fs.move(realpath, newFilePath, {
overwrite: true,
});
await fs.ensureSymlink(newFilePath, db.path);
logger.info(`openMoveDBFileDialog symlink: ${realpath} -> ${newFilePath}`);
db.reconnectDB();
return {
filePath: newFilePath,
};
} catch (err) {
logger.error('moveDBFile', err);
return {
error: 'UNKNOWN_ERROR',
};
}
}
async function dbFileAlreadyLoaded(path: string) {
const meta = await listWorkspaces(appContext);
const realpath = await fs.realpath(path);
const paths = meta.map(m => m[1].realpath);
return paths.includes(realpath);
}

View File

@@ -0,0 +1,33 @@
import type { NamespaceHandlers } from '../type';
import {
loadDBFile,
moveDBFile,
revealDBFile,
saveDBFileAs,
selectDBFileLocation,
setFakeDialogResult,
} from './dialog';
export const dialogHandlers = {
revealDBFile: async (_, workspaceId: string) => {
return revealDBFile(workspaceId);
},
loadDBFile: async () => {
return loadDBFile();
},
saveDBFileAs: async (_, workspaceId: string) => {
return saveDBFileAs(workspaceId);
},
moveDBFile: async (_, workspaceId: string, dbFileLocation?: string) => {
return moveDBFile(workspaceId, dbFileLocation);
},
selectDBFileLocation: async () => {
return selectDBFileLocation();
},
setFakeDialogResult: async (
_,
result: Parameters<typeof setFakeDialogResult>[0]
) => {
return setFakeDialogResult(result);
},
} satisfies NamespaceHandlers;

View File

@@ -0,0 +1 @@
export * from './register';

View File

@@ -0,0 +1,63 @@
import { ipcMain } from 'electron';
import { getLogFilePath, logger, revealLogFile } from '../logger';
import { dbHandlers } from './db';
import { dialogHandlers } from './dialog';
import { uiHandlers } from './ui';
import { updaterHandlers } from './updater';
import { workspaceHandlers } from './workspace';
type IsomorphicHandler = (
e: Electron.IpcMainInvokeEvent,
...args: any[]
) => Promise<any>;
type NamespaceHandlers = {
[key: string]: IsomorphicHandler;
};
export const debugHandlers = {
revealLogFile: async () => {
return revealLogFile();
},
logFilePath: async () => {
return getLogFilePath();
},
};
// Note: all of these handlers will be the single-source-of-truth for the apis exposed to the renderer process
export const allHandlers = {
workspace: workspaceHandlers,
ui: uiHandlers,
db: dbHandlers,
dialog: dialogHandlers,
debug: debugHandlers,
updater: updaterHandlers,
} satisfies Record<string, NamespaceHandlers>;
export const registerHandlers = () => {
for (const [namespace, namespaceHandlers] of Object.entries(allHandlers)) {
for (const [key, handler] of Object.entries(namespaceHandlers)) {
const chan = `${namespace}:${key}`;
ipcMain.handle(chan, async (e, ...args) => {
const start = performance.now();
try {
const result = await handler(e, ...args);
logger.info(
'[ipc-api]',
chan,
args.filter(
arg => typeof arg !== 'function' && typeof arg !== 'object'
),
'-',
(performance.now() - start).toFixed(2),
'ms'
);
return result;
} catch (error) {
logger.error('[ipc]', chan, error);
}
});
}
}
};

View File

@@ -0,0 +1,8 @@
export type IsomorphicHandler = (
e: Electron.IpcMainInvokeEvent,
...args: any[]
) => Promise<any>;
export type NamespaceHandlers = {
[key: string]: IsomorphicHandler;
};

View File

@@ -0,0 +1,58 @@
import { app, BrowserWindow, shell } from 'electron';
import { parse } from 'url';
import { logger } from '../../logger';
const redirectUri = 'https://affine.pro/client/auth-callback';
export const oauthEndpoint = `https://accounts.google.com/o/oauth2/v2/auth?client_id=${process.env.AFFINE_GOOGLE_CLIENT_ID}&redirect_uri=${redirectUri}&response_type=code&scope=openid https://www.googleapis.com/auth/userinfo.email profile&access_type=offline&customParameters={"prompt":"select_account"}`;
const tokenEndpoint = 'https://oauth2.googleapis.com/token';
export const getExchangeTokenParams = (code: string) => {
const postData = {
code,
client_id: process.env.AFFINE_GOOGLE_CLIENT_ID || '',
client_secret: process.env.AFFINE_GOOGLE_CLIENT_SECRET || '',
redirect_uri: redirectUri,
grant_type: 'authorization_code',
};
const requestInit: RequestInit = {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams(postData).toString(),
};
return { requestInit, url: tokenEndpoint };
};
export function getGoogleOauthCode() {
shell.openExternal(oauthEndpoint);
return new Promise<ReturnType<typeof getExchangeTokenParams>>(
(resolve, reject) => {
const handleOpenUrl = async (_: any, url: string) => {
const mainWindow = BrowserWindow.getAllWindows().find(
w => !w.isDestroyed()
);
const urlObj = parse(url.replace('??', '?'), true);
if (!mainWindow || !url.startsWith('affine://auth-callback')) return;
const code = urlObj.query['code'] as string;
if (!code) return;
logger.info('google sign in code received from callback', code);
app.removeListener('open-url', handleOpenUrl);
resolve(getExchangeTokenParams(code));
};
app.on('open-url', handleOpenUrl);
setTimeout(() => {
reject(new Error('Timed out'));
app.removeListener('open-url', handleOpenUrl);
}, 30000);
}
);
}

View File

@@ -0,0 +1,23 @@
import { BrowserWindow, nativeTheme } from 'electron';
import { isMacOS } from '../../../../utils';
import type { NamespaceHandlers } from '../type';
import { getGoogleOauthCode } from './google-auth';
export const uiHandlers = {
handleThemeChange: async (_, theme: (typeof nativeTheme)['themeSource']) => {
nativeTheme.themeSource = theme;
},
handleSidebarVisibilityChange: async (_, visible: boolean) => {
if (isMacOS()) {
const windows = BrowserWindow.getAllWindows();
windows.forEach(w => {
// hide window buttons when sidebar is not visible
w.setWindowButtonVisibility(visible);
});
}
},
getGoogleOauthCode: async () => {
return getGoogleOauthCode();
},
} satisfies NamespaceHandlers;

View File

@@ -0,0 +1,10 @@
import type { NamespaceHandlers } from '../type';
import { updateClient } from './updater';
export const updaterHandlers = {
updateClient: async () => {
return updateClient();
},
} satisfies NamespaceHandlers;
export * from './updater';

View File

@@ -0,0 +1,69 @@
import type { AppUpdater } from 'electron-updater';
import { isMacOS } from '../../../../utils';
import { updaterSubjects } from '../../events/updater';
import { logger } from '../../logger';
const buildType = (process.env.BUILD_TYPE || 'canary').trim().toLowerCase();
const mode = process.env.NODE_ENV;
const isDev = mode === 'development';
let _autoUpdater: AppUpdater | null = null;
export const updateClient = async () => {
_autoUpdater?.quitAndInstall();
};
export const registerUpdater = async () => {
// require it will cause some side effects and will break generate-main-exposed-meta,
// so we wrap it in a function
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { autoUpdater } = await import('electron-updater');
_autoUpdater = autoUpdater;
autoUpdater.autoDownload = false;
autoUpdater.allowPrerelease = buildType !== 'stable';
autoUpdater.autoInstallOnAppQuit = false;
autoUpdater.autoRunAppAfterInstall = true;
autoUpdater.setFeedURL({
channel: buildType,
provider: 'github',
repo: 'AFFiNE',
owner: 'toeverything',
releaseType: buildType === 'stable' ? 'release' : 'prerelease',
});
autoUpdater.autoDownload = false;
autoUpdater.allowPrerelease = buildType !== 'stable';
autoUpdater.autoInstallOnAppQuit = false;
autoUpdater.autoRunAppAfterInstall = true;
autoUpdater.setFeedURL({
channel: buildType,
provider: 'github',
repo: 'AFFiNE',
owner: 'toeverything',
releaseType: buildType === 'stable' ? 'release' : 'prerelease',
});
if (isMacOS()) {
autoUpdater.on('update-available', () => {
autoUpdater.downloadUpdate();
logger.info('Update available, downloading...');
});
autoUpdater.on('download-progress', e => {
logger.info(`Download progress: ${e.percent}`);
});
autoUpdater.on('update-downloaded', e => {
updaterSubjects.clientUpdateReady.next({
version: e.version,
});
logger.info('Update downloaded, ready to install');
});
autoUpdater.on('error', e => {
logger.error('Error while updating client', e);
});
autoUpdater.forceDevUpdateConfig = isDev;
await autoUpdater.checkForUpdatesAndNotify();
}
};

View File

@@ -0,0 +1,8 @@
import { appContext } from '../../context';
import type { NamespaceHandlers } from '../type';
import { deleteWorkspace, listWorkspaces } from './workspace';
export const workspaceHandlers = {
list: async () => listWorkspaces(appContext),
delete: async (_, id: string) => deleteWorkspace(appContext, id),
} satisfies NamespaceHandlers;

View File

@@ -0,0 +1,60 @@
import path from 'node:path';
import fs from 'fs-extra';
import type { AppContext } from '../../context';
import { logger } from '../../logger';
interface WorkspaceMeta {
path: string;
realpath: string;
}
export async function listWorkspaces(
context: AppContext
): Promise<[workspaceId: string, meta: WorkspaceMeta][]> {
const basePath = path.join(context.appDataPath, 'workspaces');
try {
await fs.ensureDir(basePath);
const dirs = await fs.readdir(basePath, {
withFileTypes: true,
});
const meta = await Promise.all(
dirs.map(async dir => {
const dbFilePath = path.join(basePath, dir.name, 'storage.db');
if (dir.isDirectory() && (await fs.exists(dbFilePath))) {
// try read storage.db under it
const realpath = await fs.realpath(dbFilePath);
return [dir.name, { path: dbFilePath, realpath }] as [
string,
WorkspaceMeta
];
} else {
return null;
}
})
);
return meta.filter((w): w is [string, WorkspaceMeta] => !!w);
} catch (error) {
logger.error('listWorkspaces', error);
return [];
}
}
export async function deleteWorkspace(context: AppContext, id: string) {
const basePath = path.join(context.appDataPath, 'workspaces', id);
const movedPath = path.join(
context.appDataPath,
'delete-workspaces',
`${id}`
);
try {
return await fs.move(basePath, movedPath, {
overwrite: true,
});
} catch (error) {
logger.error('deleteWorkspace', error);
}
}

View File

@@ -1,24 +1,21 @@
import './security-restrictions';
import { app } from 'electron';
import path from 'path';
import { logger } from '../../logger';
import { registerEvents } from './events';
import { registerHandlers } from './handlers';
import { registerUpdater } from './handlers/updater';
import { logger } from './logger';
import { restoreOrCreateWindow } from './main-window';
import { registerProtocol } from './protocol';
import { registerUpdater } from './updater';
if (require('electron-squirrel-startup')) app.exit();
if (process.defaultApp) {
if (process.argv.length >= 2) {
app.setAsDefaultProtocolClient('affine', process.execPath, [
path.resolve(process.argv[1]),
]);
}
} else {
app.setAsDefaultProtocolClient('affine');
// allow tests to overwrite app name through passing args
if (process.argv.includes('--app-name')) {
const appNameIndex = process.argv.indexOf('--app-name');
const appName = process.argv[appNameIndex + 1];
app.setName(appName);
}
/**
* Prevent multiple instances
*/
@@ -58,6 +55,7 @@ app
.whenReady()
.then(registerProtocol)
.then(registerHandlers)
.then(registerEvents)
.then(restoreOrCreateWindow)
.then(registerUpdater)
.catch(e => console.error('Failed create window:', e));

View File

@@ -0,0 +1,13 @@
import { shell } from 'electron';
import log from 'electron-log';
export const logger = log;
export function getLogFilePath() {
return log.transports.file.getFile().path;
}
export function revealLogFile() {
const filePath = getLogFilePath();
shell.showItemInFolder(filePath);
}

View File

@@ -2,8 +2,8 @@ import { BrowserWindow, nativeTheme } from 'electron';
import electronWindowState from 'electron-window-state';
import { join } from 'path';
import { logger } from '../../logger';
import { isMacOS } from '../../utils';
import { logger } from './logger';
const IS_DEV: boolean =
process.env.NODE_ENV === 'development' && !process.env.CI;
@@ -60,7 +60,9 @@ async function createWindow() {
logger.info('main window is ready to show');
if (DEV_TOOL) {
browserWindow.webContents.openDevTools();
browserWindow.webContents.openDevTools({
mode: 'detach',
});
}
});
@@ -75,9 +77,11 @@ async function createWindow() {
*/
const pageUrl = process.env.DEV_SERVER_URL || 'file://./index.html'; // see protocol.ts
logger.info('loading page at', pageUrl);
await browserWindow.loadURL(pageUrl);
logger.info('main window is loaded at' + pageUrl);
logger.info('main window is loaded at', pageUrl);
return browserWindow;
}

View File

@@ -33,7 +33,6 @@ export function registerProtocol() {
protocol.interceptFileProtocol('file', (request, callback) => {
const url = request.url.replace(/^file:\/\//, '');
const realpath = toAbsolutePath(url);
// console.log('realpath', realpath, 'for', url);
callback(realpath);
return true;
});
@@ -41,7 +40,6 @@ export function registerProtocol() {
protocol.registerFileProtocol('assets', (request, callback) => {
const url = request.url.replace(/^assets:\/\//, '');
const realpath = toAbsolutePath(url);
// console.log('realpath', realpath, 'for', url);
callback(realpath);
return true;
});

View File

@@ -1,14 +0,0 @@
import { BrowserWindow } from 'electron';
import type { MainEventMap } from '../../main-events';
function getActiveWindows() {
return BrowserWindow.getAllWindows().filter(win => !win.isDestroyed());
}
export function sendMainEvent<T extends keyof MainEventMap>(
type: T,
...args: Parameters<MainEventMap[T]>
) {
getActiveWindows().forEach(win => win.webContents.send(type, ...args));
}

View File

@@ -1,43 +0,0 @@
import { autoUpdater } from 'electron-updater';
import { isMacOS } from '../../utils';
import { sendMainEvent } from './send-main-event';
const buildType = (process.env.BUILD_TYPE || 'canary').trim().toLowerCase();
const mode = process.env.NODE_ENV;
const isDev = mode === 'development';
autoUpdater.autoDownload = false;
autoUpdater.allowPrerelease = buildType !== 'stable';
autoUpdater.autoInstallOnAppQuit = false;
autoUpdater.autoRunAppAfterInstall = false;
autoUpdater.setFeedURL({
channel: buildType,
provider: 'github',
repo: 'AFFiNE',
owner: 'toeverything',
releaseType: buildType === 'stable' ? 'release' : 'prerelease',
});
export const updateClient = async () => {
autoUpdater.quitAndInstall();
};
export const registerUpdater = async () => {
if (isMacOS()) {
autoUpdater.on('update-available', () => {
autoUpdater.downloadUpdate();
});
autoUpdater.on('download-progress', e => {
console.log(e.percent);
});
autoUpdater.on('update-downloaded', e => {
sendMainEvent('main:client-update-available', e.version);
});
autoUpdater.on('error', e => {
console.log(e.message);
});
autoUpdater.forceDevUpdateConfig = isDev;
await autoUpdater.checkForUpdatesAndNotify();
}
};

View File

@@ -0,0 +1,19 @@
export function debounce<T extends (...args: any[]) => void>(
fn: T,
delay: number
) {
let timeoutId: NodeJS.Timer | undefined;
return (...args: Parameters<T>) => {
if (timeoutId) {
clearTimeout(timeoutId);
}
timeoutId = setTimeout(() => {
fn(...args);
timeoutId = undefined;
}, delay);
};
}
export function ts() {
return new Date().getTime();
}

View File

@@ -1,6 +1,7 @@
/* eslint-disable @typescript-eslint/consistent-type-imports */
interface Window {
apis: typeof import('./src/affine-apis').apis;
appInfo: typeof import('./src/affine-apis').appInfo;
apis?: typeof import('./src/affine-apis').apis;
events?: typeof import('./src/affine-apis').events;
appInfo?: typeof import('./src/affine-apis').appInfo;
}

View File

@@ -1,88 +1,88 @@
/* eslint-disable @typescript-eslint/no-var-requires */
// NOTE: we will generate preload types from this file
import { ipcRenderer } from 'electron';
import type { MainEventMap } from '../../main-events';
import type { MainIPCEventMap, MainIPCHandlerMap } from '../../constraints';
// main -> renderer
function onMainEvent<T extends keyof MainEventMap>(
eventName: T,
callback: MainEventMap[T]
): () => void {
// @ts-expect-error fix me later
const fn = (_, ...args) => callback(...args);
ipcRenderer.on(eventName, fn);
return () => ipcRenderer.off(eventName, fn);
}
type WithoutFirstParameter<T> = T extends (_: any, ...args: infer P) => infer R
? (...args: P) => R
: T;
const apis = {
db: {
// workspace providers
getDoc: (id: string): Promise<Uint8Array | null> =>
ipcRenderer.invoke('db:get-doc', id),
applyDocUpdate: (id: string, update: Uint8Array) =>
ipcRenderer.invoke('db:apply-doc-update', id, update),
addBlob: (workspaceId: string, key: string, data: Uint8Array) =>
ipcRenderer.invoke('db:add-blob', workspaceId, key, data),
getBlob: (workspaceId: string, key: string): Promise<Uint8Array | null> =>
ipcRenderer.invoke('db:get-blob', workspaceId, key),
deleteBlob: (workspaceId: string, key: string) =>
ipcRenderer.invoke('db:delete-blob', workspaceId, key),
getPersistedBlobs: (workspaceId: string): Promise<string[]> =>
ipcRenderer.invoke('db:get-persisted-blobs', workspaceId),
// listeners
onDBUpdate: (callback: (workspaceId: string) => void) => {
return onMainEvent('main:on-db-update', callback);
},
},
workspace: {
list: (): Promise<string[]> => ipcRenderer.invoke('workspace:list'),
delete: (id: string): Promise<void> =>
ipcRenderer.invoke('workspace:delete', id),
// create will be implicitly called by db functions
},
openLoadDBFileDialog: () => ipcRenderer.invoke('ui:open-load-db-file-dialog'),
openSaveDBFileDialog: () => ipcRenderer.invoke('ui:open-save-db-file-dialog'),
// ui
onThemeChange: (theme: string) =>
ipcRenderer.invoke('ui:theme-change', theme),
onSidebarVisibilityChange: (visible: boolean) =>
ipcRenderer.invoke('ui:sidebar-visibility-change', visible),
onWorkspaceChange: (workspaceId: string) =>
ipcRenderer.invoke('ui:workspace-change', workspaceId),
openDBFolder: () => ipcRenderer.invoke('ui:open-db-folder'),
/**
* Try sign in using Google and return a request object to exchange the code for a token
* Not exchange in Node side because it is easier to do it in the renderer with VPN
*/
getGoogleOauthCode: (): Promise<{ requestInit: RequestInit; url: string }> =>
ipcRenderer.invoke('ui:get-google-oauth-code'),
/**
* Secret backdoor to update environment variables in main process
*/
updateEnv: (env: string, value: string) => {
ipcRenderer.invoke('main:env-update', env, value);
},
onClientUpdateInstall: () => {
ipcRenderer.invoke('ui:client-update-install');
},
onClientUpdateAvailable: (callback: (version: string) => void) => {
return onMainEvent('main:client-update-available', callback);
},
type HandlersMap<N extends keyof MainIPCHandlerMap> = {
[K in keyof MainIPCHandlerMap[N]]: WithoutFirstParameter<
MainIPCHandlerMap[N][K]
>;
};
type PreloadHandlers = {
[N in keyof MainIPCHandlerMap]: HandlersMap<N>;
};
type MainExposedMeta = {
handlers: [namespace: string, handlerNames: string[]][];
events: [namespace: string, eventNames: string[]][];
};
// main handlers that can be invoked from the renderer process
const apis: PreloadHandlers = (() => {
// the following were generated by the build script
// 1. bundle extra main/src/expose.ts entry
// 2. use generate-main-exposed-meta.mjs to generate exposed-meta.js in dist
//
// we cannot directly import main/src/handlers.ts because it will be bundled into the preload bundle
// eslint-disable-next-line @typescript-eslint/no-var-requires
const {
handlers: handlersMeta,
}: MainExposedMeta = require('../main/exposed-meta');
const all = handlersMeta.map(([namespace, functionNames]) => {
const namespaceApis = functionNames.map(name => {
const channel = `${namespace}:${name}`;
return [
name,
(...args: any[]) => {
return ipcRenderer.invoke(channel, ...args);
},
];
});
return [namespace, Object.fromEntries(namespaceApis)];
});
return Object.fromEntries(all);
})();
// main events that can be listened to from the renderer process
const events: MainIPCEventMap = (() => {
const {
events: eventsMeta,
}: MainExposedMeta = require('../main/exposed-meta');
const all = eventsMeta.map(([namespace, eventNames]) => {
const namespaceEvents = eventNames.map(name => {
const channel = `${namespace}:${name}`;
return [
name,
(callback: (...args: any[]) => void) => {
const fn: (
event: Electron.IpcRendererEvent,
...args: any[]
) => void = (_, ...args) => {
callback(...args);
};
ipcRenderer.on(channel, fn);
return () => {
ipcRenderer.off(channel, fn);
};
},
];
});
return [namespace, Object.fromEntries(namespaceEvents)];
});
return Object.fromEntries(all);
})();
const appInfo = {
electron: true,
};
export { apis, appInfo };
export { apis, appInfo, events };

View File

@@ -14,4 +14,5 @@ import * as affineApis from './affine-apis';
*/
contextBridge.exposeInMainWorld('apis', affineApis.apis);
contextBridge.exposeInMainWorld('events', affineApis.events);
contextBridge.exposeInMainWorld('appInfo', affineApis.appInfo);

View File

@@ -11,15 +11,18 @@
"homepage": "https://github.com/toeverything/AFFiNE",
"scripts": {
"dev": "yarn electron-rebuild && yarn cross-env DEV_SERVER_URL=http://localhost:8080 node scripts/dev.mjs",
"watch": "yarn electron-rebuild && yarn cross-env DEV_SERVER_URL=http://localhost:8080 node scripts/dev.mjs --watch",
"prod": "yarn electron-rebuild && yarn node scripts/dev.mjs",
"build-layers": "zx scripts/build-layers.mjs",
"generate-assets": "zx scripts/generate-assets.mjs",
"generate-main-exposed-meta": "zx scripts/generate-main-exposed-meta.mjs",
"package": "electron-forge package",
"make": "electron-forge make",
"make-macos-arm64": "electron-forge make --platform=darwin --arch=arm64",
"make-macos-x64": "electron-forge make --platform=darwin --arch=x64",
"make-windows-x64": "electron-forge make --platform=win32 --arch=x64",
"make-linux-x64": "electron-forge make --platform=linux --arch=x64",
"rebuild:for-test": "yarn rebuild better-sqlite3",
"rebuild:for-unit-test": "yarn rebuild better-sqlite3",
"rebuild:for-electron": "yarn electron-rebuild",
"test": "playwright test"
},
@@ -55,7 +58,10 @@
},
"dependencies": {
"better-sqlite3": "^8.3.0",
"chokidar": "^3.5.3",
"electron-updater": "^5.3.0",
"nanoid": "^4.0.2",
"rxjs": "^7.8.1",
"yjs": "^13.6.1"
},
"build": {

View File

@@ -1,17 +0,0 @@
#!/usr/bin/env ts-node-esm
import * as esbuild from 'esbuild';
import { config } from './common.mjs';
const common = config();
await esbuild.build(common.preload);
await esbuild.build({
...common.main,
define: {
...common.main.define,
'process.env.NODE_ENV': `"production"`,
},
});
console.log('Compiled successfully.');

View File

@@ -0,0 +1,28 @@
#!/usr/bin/env zx
import 'zx/globals';
import * as esbuild from 'esbuild';
import { config } from './common.mjs';
const NODE_ENV =
process.env.NODE_ENV === 'development' ? 'development' : 'production';
async function buildLayers() {
const common = config();
await esbuild.build(common.preload);
await esbuild.build({
...common.main,
define: {
...common.main.define,
'process.env.NODE_ENV': `"${NODE_ENV}"`,
'process.env.BUILD_TYPE': `"${process.env.BUILD_TYPE || 'stable'}"`,
},
});
await $`yarn workspace @affine/electron generate-main-exposed-meta`;
}
await buildLayers();
echo('Build layers done');

View File

@@ -5,6 +5,13 @@ import { fileURLToPath } from 'url';
export const root = fileURLToPath(new URL('..', import.meta.url));
export const NODE_MAJOR_VERSION = 18;
// hard-coded for now:
// fixme(xp): report error if app is not running on DEV_SERVER_URL
const DEV_SERVER_URL = process.env.DEV_SERVER_URL;
/** @type 'production' | 'development'' */
const mode = (process.env.NODE_ENV = process.env.NODE_ENV || 'development');
const nativeNodeModulesPlugin = {
name: 'native-node-modules',
setup(build) {
@@ -20,22 +27,32 @@ const ENV_MACROS = ['AFFINE_GOOGLE_CLIENT_ID', 'AFFINE_GOOGLE_CLIENT_SECRET'];
/** @return {{main: import('esbuild').BuildOptions, preload: import('esbuild').BuildOptions}} */
export const config = () => {
const define = Object.fromEntries(
ENV_MACROS.map(key => [
const define = Object.fromEntries([
...ENV_MACROS.map(key => [
'process.env.' + key,
JSON.stringify(process.env[key] ?? ''),
])
);
]),
['process.env.NODE_ENV', `"${mode}"`],
]);
if (DEV_SERVER_URL) {
define['process.env.DEV_SERVER_URL'] = `"${DEV_SERVER_URL}"`;
}
return {
main: {
entryPoints: [resolve(root, './layers/main/src/index.ts')],
entryPoints: [
resolve(root, './layers/main/src/index.ts'),
resolve(root, './layers/main/src/exposed.ts'),
],
outdir: resolve(root, './dist/layers/main'),
bundle: true,
target: `node${NODE_MAJOR_VERSION}`,
platform: 'node',
external: ['electron', 'yjs', 'better-sqlite3'],
external: ['electron', 'yjs', 'better-sqlite3', 'electron-updater'],
plugins: [nativeNodeModulesPlugin],
define: define,
format: 'cjs',
},
preload: {
entryPoints: [resolve(root, './layers/preload/src/index.ts')],
@@ -43,7 +60,8 @@ export const config = () => {
bundle: true,
target: `node${NODE_MAJOR_VERSION}`,
platform: 'node',
external: ['electron'],
external: ['electron', '../main/exposed-meta'],
plugins: [nativeNodeModulesPlugin],
define: define,
},
};

View File

@@ -1,4 +1,5 @@
import { spawn } from 'node:child_process';
/* eslint-disable no-async-promise-executor */
import { execSync, spawn } from 'node:child_process';
import { readFileSync } from 'node:fs';
import path from 'node:path';
@@ -7,8 +8,8 @@ import * as esbuild from 'esbuild';
import { config, root } from './common.mjs';
/** @type 'production' | 'development'' */
const mode = (process.env.NODE_ENV = process.env.NODE_ENV || 'development');
// this means we don't spawn electron windows, mainly for testing
const watchMode = process.argv.includes('--watch');
/** Messages on stderr that match any of the contained patterns will be stripped from output */
const stderrFilterPatterns = [
@@ -29,14 +30,13 @@ try {
);
}
// hard-coded for now:
// fixme(xp): report error if app is not running on DEV_SERVER_URL
const DEV_SERVER_URL = process.env.DEV_SERVER_URL;
/** @type {ChildProcessWithoutNullStreams | null} */
let spawnProcess = null;
function spawnOrReloadElectron() {
if (watchMode) {
return;
}
if (spawnProcess !== null) {
spawnProcess.off('exit', process.exit);
spawnProcess.kill('SIGINT');
@@ -59,77 +59,80 @@ function spawnOrReloadElectron() {
console.error(data);
});
// Stops the watch script when the application has been quit
// Stops the watch script when the application has quit
spawnProcess.on('exit', process.exit);
}
const common = config();
async function main() {
async function watchPreload(onInitialBuild) {
function watchPreload() {
return new Promise(async resolve => {
let initialBuild = false;
const preloadBuild = await esbuild.context({
...common.preload,
plugins: [
...(common.preload.plugins ?? []),
{
name: 'affine-dev:reload-app-on-preload-change',
name: 'electron-dev:reload-app-on-preload-change',
setup(build) {
let initialBuild = false;
build.onEnd(() => {
if (initialBuild) {
console.log(`[preload] has changed`);
console.log(`[preload] has changed, [re]launching electron...`);
spawnOrReloadElectron();
} else {
resolve();
initialBuild = true;
onInitialBuild();
}
});
},
},
],
});
// watch will trigger build.onEnd() on first run & on subsequent changes
await preloadBuild.watch();
}
});
}
async function watchMain() {
const define = {
...common.main.define,
'process.env.NODE_ENV': `"${mode}"`,
};
if (DEV_SERVER_URL) {
define['process.env.DEV_SERVER_URL'] = `"${DEV_SERVER_URL}"`;
}
async function watchMain() {
return new Promise(async resolve => {
let initialBuild = false;
const mainBuild = await esbuild.context({
...common.main,
define: define,
plugins: [
...(common.main.plugins ?? []),
{
name: 'affine-dev:reload-app-on-main-change',
name: 'electron-dev:reload-app-on-main-change',
setup(build) {
let initialBuild = false;
build.onEnd(() => {
execSync('yarn generate-main-exposed-meta');
if (initialBuild) {
console.log(`[main] has changed, [re]launching electron...`);
spawnOrReloadElectron();
} else {
resolve();
initialBuild = true;
}
spawnOrReloadElectron();
});
},
},
],
});
await mainBuild.watch();
}
await watchPreload(async () => {
await watchMain();
spawnOrReloadElectron();
console.log(`Electron is started, watching for changes...`);
});
}
async function main() {
await watchMain();
await watchPreload();
if (watchMode) {
console.log(`Watching for changes...`);
} else {
spawnOrReloadElectron();
console.log(`Electron is started, watching for changes...`);
}
}
main();

View File

@@ -3,10 +3,6 @@ import 'zx/globals';
import path from 'node:path';
import * as esbuild from 'esbuild';
import { config } from './common.mjs';
const repoRootDir = path.join(__dirname, '..', '..', '..');
const electronRootDir = path.join(__dirname, '..');
const publicDistDir = path.join(electronRootDir, 'resources');
@@ -37,8 +33,7 @@ if (process.platform === 'win32') {
cd(repoRootDir);
// step 1: build electron resources
await buildLayers();
echo('Build layers done');
await $`yarn workspace @affine/electron build-layers`;
// step 2: build web (nextjs) dist
if (!process.env.SKIP_WEB_BUILD) {
@@ -75,17 +70,3 @@ async function cleanup() {
await fs.emptyDir(path.join(electronRootDir, 'layers', 'preload', 'dist'));
await fs.remove(path.join(electronRootDir, 'out'));
}
async function buildLayers() {
const common = config();
await esbuild.build(common.preload);
await esbuild.build({
...common.main,
define: {
...common.main.define,
'process.env.NODE_ENV': `"production"`,
'process.env.BUILD_TYPE': `"${process.env.BUILD_TYPE || 'statble'}"`,
},
});
}

View File

@@ -0,0 +1,40 @@
#!/usr/bin/env zx
/* eslint-disable @typescript-eslint/no-restricted-imports */
import 'zx/globals';
const mainDistDir = path.resolve(__dirname, '../dist/layers/main');
// be careful and avoid any side effects in
const { handlers, events } = await import(
path.resolve(mainDistDir, 'exposed.js')
);
const handlersMeta = Object.entries(handlers).map(
([namespace, namespaceHandlers]) => {
return [
namespace,
Object.keys(namespaceHandlers).map(handlerName => handlerName),
];
}
);
const eventsMeta = Object.entries(events).map(
([namespace, namespaceHandlers]) => {
return [
namespace,
Object.keys(namespaceHandlers).map(handlerName => handlerName),
];
}
);
const meta = {
handlers: handlersMeta,
events: eventsMeta,
};
await fs.writeFile(
path.resolve(mainDistDir, 'exposed-meta.js'),
`module.exports = ${JSON.stringify(meta)};`
);
console.log('generate main exposed-meta.js done');

View File

@@ -1,48 +1,17 @@
import { resolve } from 'node:path';
import { test, testResultDir } from '@affine-test/kit/playwright';
import type { Page } from '@playwright/test';
import { expect } from '@playwright/test';
import type { ElectronApplication } from 'playwright';
import { _electron as electron } from 'playwright';
let electronApp: ElectronApplication;
let page: Page;
import { test } from './fixture';
test.beforeEach(async () => {
electronApp = await electron.launch({
args: [resolve(__dirname, '..')],
executablePath: resolve(__dirname, '../node_modules/.bin/electron'),
colorScheme: 'light',
});
page = await electronApp.firstWindow();
await page.getByTestId('onboarding-modal-close-button').click({
delay: 100,
});
// cleanup page data
await page.evaluate(() => localStorage.clear());
});
test.afterEach(async () => {
// cleanup page data
await page.evaluate(() => localStorage.clear());
await page.close();
await electronApp.close();
});
test('new page', async () => {
test('new page', async ({ page, workspace }) => {
await page.getByTestId('new-page-button').click({
delay: 100,
});
await page.waitForSelector('v-line');
const flavour = await page.evaluate(
// @ts-expect-error
() => globalThis.currentWorkspace.flavour
);
const flavour = (await workspace.current()).flavour;
expect(flavour).toBe('local');
});
test('app theme', async () => {
test('app theme', async ({ page, electronApp }) => {
await page.waitForSelector('v-line');
const root = page.locator('html');
{
@@ -50,25 +19,35 @@ test('app theme', async () => {
element.getAttribute('data-theme')
);
expect(themeMode).toBe('light');
// check if electron theme source is set to light
const themeSource = await electronApp.evaluate(({ nativeTheme }) => {
return nativeTheme.themeSource;
});
expect(themeSource).toBe('light');
}
await page.screenshot({
path: resolve(testResultDir, 'affine-light-theme-electron.png'),
});
await page.getByTestId('editor-option-menu').click();
await page.getByTestId('change-theme-dark').click();
await page.waitForTimeout(50);
{
const themeMode = await root.evaluate(element =>
element.getAttribute('data-theme')
);
expect(themeMode).toBe('dark');
await page.getByTestId('editor-option-menu').click();
await page.getByTestId('change-theme-dark').click();
await page.waitForTimeout(50);
{
const themeMode = await root.evaluate(element =>
element.getAttribute('data-theme')
);
expect(themeMode).toBe('dark');
}
const themeSource = await electronApp.evaluate(({ nativeTheme }) => {
return nativeTheme.themeSource;
});
expect(themeSource).toBe('dark');
}
await page.screenshot({
path: resolve(testResultDir, 'affine-dark-theme-electron.png'),
});
});
test('affine cloud disabled', async () => {
test('affine cloud disabled', async ({ page }) => {
await page.getByTestId('new-page-button').click({
delay: 100,
});
@@ -79,7 +58,8 @@ test('affine cloud disabled', async () => {
state: 'visible',
});
});
test('affine onboarding button', async () => {
test('affine onboarding button', async ({ page }) => {
await page.getByTestId('help-island').click();
await page.getByTestId('easy-guide').click();
const onboardingModal = page.locator('[data-testid=onboarding-modal]');

View File

@@ -0,0 +1,86 @@
// eslint-disable-next-line @typescript-eslint/triple-slash-reference
/// <reference path="../layers/preload/preload.d.ts" />
/* eslint-disable no-empty-pattern */
import crypto from 'node:crypto';
import { resolve } from 'node:path';
import { test as base } from '@affine-test/kit/playwright';
import fs from 'fs-extra';
import type { ElectronApplication, Page } from 'playwright';
import { _electron as electron } from 'playwright';
function generateUUID() {
return crypto.randomUUID();
}
export const test = base.extend<{
page: Page;
electronApp: ElectronApplication;
appInfo: {
appPath: string;
appData: string;
sessionData: string;
};
workspace: {
// get current workspace
current: () => Promise<any>; // todo: type
};
}>({
page: async ({ electronApp }, use) => {
const page = await electronApp.firstWindow();
await page.getByTestId('onboarding-modal-close-button').click({
delay: 100,
});
if (!process.env.CI) {
await electronApp.evaluate(({ BrowserWindow }) => {
BrowserWindow.getAllWindows()[0].webContents.openDevTools({
mode: 'detach',
});
});
}
const logFilePath = await page.evaluate(async () => {
return window.apis?.debug.logFilePath();
});
await use(page);
await page.close();
if (logFilePath) {
const logs = await fs.readFile(logFilePath, 'utf-8');
console.log(logs);
}
},
electronApp: async ({}, use) => {
// a random id to avoid conflicts between tests
const id = generateUUID();
const electronApp = await electron.launch({
args: [resolve(__dirname, '..'), '--app-name', 'affine-test-' + id],
executablePath: resolve(__dirname, '../node_modules/.bin/electron'),
colorScheme: 'light',
});
const sessionDataPath = await electronApp.evaluate(async ({ app }) => {
return app.getPath('sessionData');
});
await use(electronApp);
await fs.rm(sessionDataPath, { recursive: true, force: true });
},
appInfo: async ({ electronApp }, use) => {
const appInfo = await electronApp.evaluate(async ({ app }) => {
return {
appPath: app.getAppPath(),
appData: app.getPath('appData'),
sessionData: app.getPath('sessionData'),
};
});
await use(appInfo);
},
workspace: async ({ page }, use) => {
await use({
current: async () => {
return await page.evaluate(async () => {
// @ts-expect-error
return globalThis.currentWorkspace;
});
},
});
},
});

View File

@@ -0,0 +1,7 @@
import { execSync } from 'node:child_process';
export default async function () {
execSync('yarn ts-node-esm scripts/', {
cwd: path.join(__dirname, '..'),
});
}

View File

@@ -0,0 +1,97 @@
import path from 'node:path';
import { expect } from '@playwright/test';
import fs from 'fs-extra';
import { test } from './fixture';
test('check workspace has a DB file', async ({ appInfo, workspace }) => {
const w = await workspace.current();
const dbPath = path.join(
appInfo.sessionData,
'workspaces',
w.id,
'storage.db'
);
// check if db file exists
expect(await fs.exists(dbPath)).toBe(true);
});
test('move workspace db file', async ({ page, appInfo, workspace }) => {
const w = await workspace.current();
const settingButton = page.getByTestId('slider-bar-workspace-setting-button');
// goto settings
await settingButton.click();
const tmpPath = path.join(appInfo.sessionData, w.id + '-tmp.db');
// move db file to tmp folder
await page.evaluate(tmpPath => {
window.apis?.dialog.setFakeDialogResult({
filePath: tmpPath,
});
}, tmpPath);
await page.getByTestId('move-folder').click();
// check if db file exists
await page.waitForSelector('text="Move folder success"');
expect(await fs.exists(tmpPath)).toBe(true);
});
test('export then add', async ({ page, appInfo, workspace }) => {
const w = await workspace.current();
const settingButton = page.getByTestId('slider-bar-workspace-setting-button');
// goto settings
await settingButton.click();
const originalId = w.id;
const newWorkspaceName = 'new-test-name';
// change workspace name
await page.getByTestId('workspace-name-input').fill(newWorkspaceName);
await page.getByTestId('save-workspace-name').click();
await page.waitForSelector('text="Update workspace name success"');
await page.click('[data-tab-key="export"]');
const tmpPath = path.join(appInfo.sessionData, w.id + '-tmp.db');
// move db file to tmp folder
await page.evaluate(tmpPath => {
window.apis?.dialog.setFakeDialogResult({
filePath: tmpPath,
});
}, tmpPath);
await page.getByTestId('export-affine-backup').click();
await page.waitForSelector('text="Export success"');
expect(await fs.exists(tmpPath)).toBe(true);
// add workspace
// we are reusing the same db file so that we don't need to maintain one
// in the codebase
await page.getByTestId('current-workspace').click();
await page.getByTestId('add-or-new-workspace').click();
await page.evaluate(tmpPath => {
window.apis?.dialog.setFakeDialogResult({
filePath: tmpPath,
});
}, tmpPath);
// load the db file
await page.getByTestId('add-workspace').click();
// should show "Added Successfully" dialog
await page.waitForSelector('text="Added Successfully"');
await page.getByTestId('create-workspace-continue-button').click();
// sleep for a while to wait for the workspace to be added :D
await page.waitForTimeout(2000);
const newWorkspace = await workspace.current();
expect(newWorkspace.id).not.toBe(originalId);
// check its name is correct
await expect(page.getByTestId('workspace-name')).toHaveText(newWorkspaceName);
});

View File

@@ -11,6 +11,7 @@ import type { Page } from '@blocksuite/store';
import { atom } from 'jotai';
import { atomWithStorage } from 'jotai/utils';
import type { CreateWorkspaceMode } from '../components/affine/create-workspace-modal';
import { WorkspacePlugins } from '../plugins';
const logger = new DebugLogger('web:atoms');
@@ -49,9 +50,9 @@ rootWorkspacesMetadataAtom.onMount = setAtom => {
});
if (environment.isDesktop) {
window.apis.workspace.list().then(workspaceIDs => {
window.apis?.workspace.list().then(workspaceIDs => {
const newMetadata = workspaceIDs.map(w => ({
id: w,
id: w[0],
flavour: WorkspaceFlavour.LOCAL,
}));
setAtom(metadata => {
@@ -75,7 +76,7 @@ export const currentEditorAtom = rootCurrentEditorAtom;
// modal atoms
export const openWorkspacesModalAtom = atom(false);
export const openCreateWorkspaceModalAtom = atom(false);
export const openCreateWorkspaceModalAtom = atom<CreateWorkspaceMode>(false);
export const openQuickSearchModalAtom = atom(false);
export const openOnboardingModalAtom = atom(false);

View File

@@ -0,0 +1,43 @@
import { globalStyle, style } from '@vanilla-extract/css';
export const header = style({
position: 'relative',
height: '44px',
});
export const content = style({
padding: '0 40px',
fontSize: '18px',
lineHeight: '26px',
});
globalStyle(`${content} p`, {
marginTop: '12px',
marginBottom: '16px',
});
export const contentTitle = style({
fontSize: '20px',
lineHeight: '28px',
fontWeight: 600,
paddingBottom: '16px',
});
export const buttonGroup = style({
display: 'flex',
justifyContent: 'flex-end',
gap: '20px',
margin: '24px 0',
});
export const radioGroup = style({
display: 'flex',
flexDirection: 'column',
gap: '8px',
});
export const radio = style({
cursor: 'pointer',
appearance: 'auto',
marginRight: '12px',
});

View File

@@ -0,0 +1,346 @@
import {
Button,
Input,
Modal,
ModalCloseButton,
ModalWrapper,
toast,
Tooltip,
} from '@affine/component';
import { DebugLogger } from '@affine/debug';
import { config } from '@affine/env';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { HelpIcon } from '@blocksuite/icons';
import { useSetAtom } from 'jotai';
import type { KeyboardEvent } from 'react';
import { useEffect } from 'react';
import { useLayoutEffect } from 'react';
import { useCallback, useRef, useState } from 'react';
import { openDisableCloudAlertModalAtom } from '../../../atoms';
import { useAppHelper } from '../../../hooks/use-workspaces';
import * as style from './index.css';
type CreateWorkspaceStep =
| 'set-db-location'
| 'name-workspace'
| 'set-syncing-mode';
export type CreateWorkspaceMode = 'add' | 'new' | false;
const logger = new DebugLogger('CreateWorkspaceModal');
interface ModalProps {
mode: CreateWorkspaceMode; // false means not open
onClose: () => void;
onCreate: (id: string) => void;
}
interface NameWorkspaceContentProps {
onClose: () => void;
onConfirmName: (name: string) => void;
}
const NameWorkspaceContent = ({
onConfirmName,
onClose,
}: NameWorkspaceContentProps) => {
const [workspaceName, setWorkspaceName] = useState('');
const isComposition = useRef(false);
const handleCreateWorkspace = useCallback(() => {
onConfirmName(workspaceName);
}, [onConfirmName, workspaceName]);
const handleKeyDown = useCallback(
(event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter' && workspaceName && !isComposition.current) {
handleCreateWorkspace();
}
},
[handleCreateWorkspace, workspaceName]
);
const t = useAFFiNEI18N();
return (
<div className={style.content}>
<div className={style.contentTitle}>{t['Name Your Workspace']()}</div>
<p>{t['Workspace description']()}</p>
<Input
ref={ref => {
if (ref) {
setTimeout(() => ref.focus(), 0);
}
}}
data-testid="create-workspace-input"
onKeyDown={handleKeyDown}
placeholder={t['Set a Workspace name']()}
maxLength={15} // TODO: the max workspace name length?
minLength={0}
onChange={value => {
setWorkspaceName(value);
}}
onCompositionStart={() => {
isComposition.current = true;
}}
onCompositionEnd={() => {
isComposition.current = false;
}}
/>
<div className={style.buttonGroup}>
<Button
data-testid="create-workspace-close-button"
type="light"
onClick={() => {
onClose();
}}
>
{t.Cancel()}
</Button>
<Button
data-testid="create-workspace-create-button"
disabled={!workspaceName}
style={{
opacity: !workspaceName ? 0.5 : 1,
}}
type="primary"
onClick={() => {
handleCreateWorkspace();
}}
>
{t.Create()}
</Button>
</div>
</div>
);
};
interface SetDBLocationContentProps {
onConfirmLocation: (dir?: string) => void;
}
const SetDBLocationContent = ({
onConfirmLocation,
}: SetDBLocationContentProps) => {
const t = useAFFiNEI18N();
const [defaultDBLocation, setDefaultDBLocation] = useState('');
useEffect(() => {
window.apis?.db.getDefaultStorageLocation().then(dir => {
setDefaultDBLocation(dir);
});
}, []);
return (
<div className={style.content}>
<div className={style.contentTitle}>{t['Set database location']()}</div>
<p>{t['Workspace database storage description']()}</p>
<div className={style.buttonGroup}>
<Button
data-testid="create-workspace-customize-button"
type="light"
onClick={async () => {
const result = await window.apis?.dialog.selectDBFileLocation();
if (result) {
onConfirmLocation(result.filePath);
}
}}
>
{t['Customize']()}
</Button>
<Tooltip
zIndex={1000}
content={t['Default db location hint']({
location: defaultDBLocation,
})}
placement="top-start"
>
<Button
data-testid="create-workspace-default-location-button"
type="primary"
onClick={() => {
onConfirmLocation();
}}
icon={<HelpIcon />}
iconPosition="end"
>
{t['Default Location']()}
</Button>
</Tooltip>
</div>
</div>
);
};
interface SetSyncingModeContentProps {
mode: CreateWorkspaceMode;
onConfirmMode: (enableCloudSyncing: boolean) => void;
}
const SetSyncingModeContent = ({
mode,
onConfirmMode,
}: SetSyncingModeContentProps) => {
const t = useAFFiNEI18N();
const [enableCloudSyncing, setEnableCloudSyncing] = useState(false);
return (
<div className={style.content}>
<div className={style.contentTitle}>
{t[mode === 'new' ? 'Created Successfully' : 'Added Successfully']()}
</div>
<div className={style.radioGroup}>
<label onClick={() => setEnableCloudSyncing(false)}>
<input
className={style.radio}
type="radio"
readOnly
checked={!enableCloudSyncing}
/>
{t['Use on current device only']()}
</label>
<label onClick={() => setEnableCloudSyncing(true)}>
<input
className={style.radio}
type="radio"
readOnly
checked={enableCloudSyncing}
/>
{t['Sync across devices with AFFiNE Cloud']()}
</label>
</div>
<div className={style.buttonGroup}>
<Button
data-testid="create-workspace-continue-button"
type="primary"
onClick={() => {
onConfirmMode(enableCloudSyncing);
}}
>
{t['Continue']()}
</Button>
</div>
</div>
);
};
export const CreateWorkspaceModal = ({
mode,
onClose,
onCreate,
}: ModalProps) => {
const { createLocalWorkspace, addLocalWorkspace } = useAppHelper();
const [step, setStep] = useState<CreateWorkspaceStep>();
const [addedId, setAddedId] = useState<string>();
const [workspaceName, setWorkspaceName] = useState<string>();
const [dbFileLocation, setDBFileLocation] = useState<string>();
const setOpenDisableCloudAlertModal = useSetAtom(
openDisableCloudAlertModalAtom
);
const t = useAFFiNEI18N();
// todo: maybe refactor using xstate?
useLayoutEffect(() => {
let canceled = false;
// if mode changed, reset step
if (mode === 'add') {
// a hack for now
// when adding a workspace, we will immediately let user select a db file
// after it is done, it will effectively add a new workspace to app-data folder
// so after that, we will be able to load it via importLocalWorkspace
(async () => {
if (!window.apis) {
return;
}
logger.info('load db file');
setStep(undefined);
const result = await window.apis.dialog.loadDBFile();
if (result.workspaceId && !canceled) {
setAddedId(result.workspaceId);
setStep('set-syncing-mode');
} else if (result.error || result.canceled) {
if (result.error) {
toast(t[result.error]());
}
onClose();
}
})();
} else if (mode === 'new') {
setStep(environment.isDesktop ? 'set-db-location' : 'name-workspace');
} else {
setStep(undefined);
}
return () => {
canceled = true;
};
}, [mode, onClose, t]);
return (
<Modal open={mode !== false && !!step} onClose={onClose}>
<ModalWrapper width={560} style={{ padding: '10px' }}>
<div className={style.header}>
<ModalCloseButton
top={6}
right={6}
onClick={() => {
onClose();
}}
/>
</div>
{step === 'name-workspace' && (
<NameWorkspaceContent
// go to previous step instead?
onClose={onClose}
onConfirmName={async name => {
setWorkspaceName(name);
if (environment.isDesktop) {
setStep('set-syncing-mode');
} else {
// this will be the last step for web for now
// fix me later
const id = await createLocalWorkspace(name);
onCreate(id);
}
}}
/>
)}
{step === 'set-db-location' && (
<SetDBLocationContent
onConfirmLocation={dir => {
setDBFileLocation(dir);
setStep('name-workspace');
}}
/>
)}
{step === 'set-syncing-mode' && (
<SetSyncingModeContent
mode={mode}
onConfirmMode={async enableCloudSyncing => {
if (!config.enableLegacyCloud && enableCloudSyncing) {
setOpenDisableCloudAlertModal(true);
} else {
let id = addedId;
// syncing mode is also the last step
if (addedId && mode === 'add') {
await addLocalWorkspace(addedId);
} else if (mode === 'new' && workspaceName) {
id = await createLocalWorkspace(workspaceName);
// if dbFileLocation is set, move db file to that location
if (dbFileLocation) {
await window.apis?.dialog.moveDBFile(id, dbFileLocation);
}
} else {
logger.error('invalid state');
return;
}
if (id) {
onCreate(id);
}
}
}}
/>
)}
</ModalWrapper>
</Modal>
);
};

View File

@@ -26,7 +26,7 @@ export const StyleTips = styled('div')(() => {
userSelect: 'none',
margin: '20px 0',
a: {
color: 'var(--affine-background-primary-color)',
color: 'var(--affine-primary-color)',
},
};
});

View File

@@ -0,0 +1,192 @@
// import { styled } from '@affine/component';
// import { FlexWrapper } from '@affine/component';
import { globalStyle, style, styleVariants } from '@vanilla-extract/css';
export const container = style({
display: 'flex',
flexDirection: 'column',
padding: '52px 52px 0 52px',
height: 'calc(100vh - 52px)',
});
export const sidebar = style({
marginTop: '52px',
});
export const content = style({
overflow: 'auto',
flex: 1,
marginTop: '40px',
});
const baseAvatar = style({
position: 'relative',
marginRight: '20px',
cursor: 'pointer',
});
globalStyle(`${baseAvatar} .camera-icon`, {
position: 'absolute',
top: 0,
left: 0,
display: 'none',
width: '100%',
height: '100%',
borderRadius: '50%',
backgroundColor: 'rgba(60, 61, 63, 0.5)',
justifyContent: 'center',
alignItems: 'center',
zIndex: 10,
});
globalStyle(`${baseAvatar}:hover .camera-icon`, {
display: 'flex',
});
export const avatar = styleVariants({
disabled: [
baseAvatar,
{
cursor: 'default',
},
],
enabled: [
baseAvatar,
{
cursor: 'pointer',
},
],
});
const baseTagItem = style({
display: 'flex',
margin: '0 48px 0 0',
height: '34px',
fontWeight: '500',
fontSize: 'var(--affine-font-h6)',
lineHeight: 'var(--affine-line-height)',
cursor: 'pointer',
transition: 'all 0.15s ease',
});
export const tagItem = styleVariants({
active: [
baseTagItem,
{
color: 'var(--affine-primary-color)',
},
],
inactive: [
baseTagItem,
{
color: 'var(--affine-text-secondary-color)',
},
],
});
export const settingKey = style({
width: '140px',
fontSize: 'var(--affine-font-base)',
fontWeight: 500,
marginRight: '56px',
flexShrink: 0,
});
export const settingItemLabel = style({
fontSize: 'var(--affine-font-base)',
fontWeight: 600,
flexShrink: 0,
});
export const settingItemLabelHint = style({
fontSize: 'var(--affine-font-sm)',
color: 'var(--affine-text-secondary-color)',
fontWeight: 400,
flexShrink: 0,
marginTop: '4px',
});
export const row = style({
padding: '40px 0',
display: 'flex',
gap: '60px',
selectors: {
'&': {
borderBottom: '1px solid var(--affine-border-color)',
},
'&:first-child': {
paddingTop: 0,
},
},
});
export const col = style({
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
flexShrink: 0,
selectors: {
[`${row} &:nth-child(1)`]: {
flex: 3,
},
[`${row} &:nth-child(2)`]: {
flex: 5,
},
[`${row} &:nth-child(3)`]: {
flex: 2,
alignItems: 'flex-end',
},
},
});
export const workspaceName = style({
fontWeight: '400',
fontSize: 'var(--affine-font-h6)',
});
export const indicator = style({
height: '2px',
background: 'var(--affine-primary-color)',
position: 'absolute',
left: '0',
bottom: '0',
transition: 'left .3s, width .3s',
});
export const tabButtonWrapper = style({
display: 'flex',
position: 'relative',
});
export const storageTypeWrapper = style({
width: '100%',
display: 'flex',
alignItems: 'flex-start',
padding: '12px',
borderRadius: '10px',
gap: '12px',
boxShadow: 'var(--affine-shadow-1)',
cursor: 'pointer',
selectors: {
'&:hover': {
boxShadow: 'var(--affine-shadow-2)',
},
'&:not(:last-child)': {
marginBottom: '12px',
},
},
});
export const storageTypeLabelWrapper = style({
flex: 1,
});
export const storageTypeLabel = style({
fontSize: 'var(--affine-font-base)',
});
export const storageTypeLabelHint = style({
fontSize: 'var(--affine-font-sm)',
color: 'var(--affine-text-secondary-color)',
});

View File

@@ -9,18 +9,12 @@ import { preload } from 'swr';
import { useIsWorkspaceOwner } from '../../../hooks/affine/use-is-workspace-owner';
import { fetcher, QueryKey } from '../../../plugins/affine/fetcher';
import type { AffineOfficialWorkspace } from '../../../shared';
import * as style from './index.css';
import { CollaborationPanel } from './panel/collaboration';
import { ExportPanel } from './panel/export';
import { GeneralPanel } from './panel/general';
import { PublishPanel } from './panel/publish';
import { SyncPanel } from './panel/sync';
import {
StyledIndicator,
StyledSettingContainer,
StyledSettingContent,
StyledTabButtonWrapper,
WorkspaceSettingTagItem,
} from './style';
export type WorkspaceSettingDetailProps = {
workspace: AffineOfficialWorkspace;
@@ -133,39 +127,43 @@ export const WorkspaceSettingDetail: React.FC<
);
const Component = useMemo(() => panelMap[currentTab].ui, [currentTab]);
return (
<StyledSettingContainer
<div
className={style.container}
aria-label="workspace-setting-detail"
ref={containerRef}
>
<StyledTabButtonWrapper>
<div className={style.tabButtonWrapper}>
{Object.entries(panelMap).map(([key, value]) => {
if ('enable' in value && !value.enable(workspace.flavour)) {
return null;
}
return (
<WorkspaceSettingTagItem
<div
className={
style.tagItem[currentTab === key ? 'active' : 'inactive']
}
key={key}
isActive={currentTab === key}
data-tab-key={key}
onClick={handleTabClick}
>
{t[value.name]()}
</WorkspaceSettingTagItem>
</div>
);
})}
<StyledIndicator
<div
className={style.indicator}
ref={ref => {
indicatorRef.current = ref;
startTransaction();
}}
/>
</StyledTabButtonWrapper>
<StyledSettingContent>
</div>
<div className={style.content}>
{/* todo: add skeleton */}
<Suspense fallback="loading panel...">
<Component {...props} key={currentTab} data-tab-ui={currentTab} />
</Suspense>
</StyledSettingContent>
</StyledSettingContainer>
</div>
</div>
);
};

View File

@@ -1,7 +1,10 @@
import { Button, Wrapper } from '@affine/component';
import { Button, toast, Wrapper } from '@affine/component';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { rootCurrentWorkspaceIdAtom } from '@affine/workspace/atom';
import { useAtomValue } from 'jotai';
export const ExportPanel = () => {
const id = useAtomValue(rootCurrentWorkspaceIdAtom);
const t = useAFFiNEI18N();
return (
<>
@@ -9,9 +12,12 @@ export const ExportPanel = () => {
<Button
type="light"
shape="circle"
disabled={!environment.isDesktop}
onClick={() => {
window.apis.openSaveDBFileDialog();
disabled={!environment.isDesktop || !id}
data-testid="export-affine-backup"
onClick={async () => {
if (id && (await window.apis?.dialog.saveDBFileAs(id))) {
toast(t['Export success']());
}
}}
>
{t['Export AFFiNE backup file']()}

View File

@@ -37,11 +37,15 @@ export const WorkspaceDeleteModal = ({
const t = useAFFiNEI18N();
const handleDelete = useCallback(() => {
onDeleteWorkspace().then(() => {
toast(t['Successfully deleted'](), {
portal: document.body,
onDeleteWorkspace()
.then(() => {
toast(t['Successfully deleted'](), {
portal: document.body,
});
})
.catch(() => {
// ignore error
});
});
}, [onDeleteWorkspace, t]);
return (

View File

@@ -1,30 +1,27 @@
import { Button, FlexWrapper, MuiFade } from '@affine/component';
import { Button, toast } from '@affine/component';
import { WorkspaceAvatar } from '@affine/component/workspace-avatar';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { WorkspaceFlavour } from '@affine/workspace/type';
import {
ArrowRightSmallIcon,
DeleteIcon,
FolderIcon,
MoveToIcon,
SaveIcon,
} from '@blocksuite/icons';
import { useBlockSuiteWorkspaceAvatarUrl } from '@toeverything/hooks/use-block-suite-workspace-avatar-url';
import { useBlockSuiteWorkspaceName } from '@toeverything/hooks/use-block-suite-workspace-name';
import clsx from 'clsx';
import type React from 'react';
import { useState } from 'react';
import { useIsWorkspaceOwner } from '../../../../../hooks/affine/use-is-workspace-owner';
import { Upload } from '../../../../pure/file-upload';
import {
CloudWorkspaceIcon,
JoinedWorkspaceIcon,
LocalWorkspaceIcon,
} from '../../../../pure/icons';
import type { PanelProps } from '../../index';
import { StyledRow, StyledSettingKey } from '../../style';
import * as style from '../../index.css';
import { WorkspaceDeleteModal } from './delete';
import { CameraIcon } from './icons';
import { WorkspaceLeave } from './leave';
import {
StyledAvatar,
StyledEditButton,
StyledInput,
StyledWorkspaceInfo,
} from './style';
import { StyledInput } from './style';
export const GeneralPanel: React.FC<PanelProps> = ({
workspace,
@@ -37,11 +34,11 @@ export const GeneralPanel: React.FC<PanelProps> = ({
);
const [input, setInput] = useState<string>(name);
const isOwner = useIsWorkspaceOwner(workspace);
const [showEditInput, setShowEditInput] = useState(false);
const t = useAFFiNEI18N();
const handleUpdateWorkspaceName = (name: string) => {
setName(name);
toast(t['Update workspace name success']());
};
const [, update] = useBlockSuiteWorkspaceAvatarUrl(
@@ -49,187 +46,189 @@ export const GeneralPanel: React.FC<PanelProps> = ({
);
return (
<>
<StyledRow>
<StyledSettingKey>{t['Workspace Avatar']()}</StyledSettingKey>
<StyledAvatar disabled={!isOwner}>
{isOwner ? (
<Upload
accept="image/gif,image/jpeg,image/jpg,image/png,image/svg"
fileChange={update}
data-testid="upload-avatar"
<div data-testid="avatar-row" className={style.row}>
<div className={style.col}>
<div className={style.settingItemLabel}>
{t['Workspace Avatar']()}
</div>
<div className={style.settingItemLabelHint}>
{t['Change avatar hint']()}
</div>
</div>
<div className={clsx(style.col)}>
<div className={style.avatar[isOwner ? 'enabled' : 'disabled']}>
{isOwner ? (
<Upload
accept="image/gif,image/jpeg,image/jpg,image/png,image/svg"
fileChange={update}
data-testid="upload-avatar"
>
<>
<div className="camera-icon">
<CameraIcon></CameraIcon>
</div>
<WorkspaceAvatar size={72} workspace={workspace} />
</>
</Upload>
) : (
<WorkspaceAvatar size={72} workspace={workspace} />
)}
</div>
</div>
<div className={clsx(style.col)}></div>
</div>
<div data-testid="workspace-name-row" className={style.row}>
<div className={style.col}>
<div className={style.settingItemLabel}>{t['Workspace Name']()}</div>
<div className={style.settingItemLabelHint}>
{t['Change workspace name hint']()}
</div>
</div>
<div className={style.col}>
<StyledInput
width={284}
height={38}
value={input}
data-testid="workspace-name-input"
placeholder={t['Workspace Name']()}
maxLength={50}
minLength={0}
onChange={newName => {
setInput(newName);
}}
></StyledInput>
</div>
<div className={style.col}>
<Button
type="light"
size="middle"
data-testid="save-workspace-name"
icon={<SaveIcon />}
disabled={input === workspace.blockSuiteWorkspace.meta.name}
onClick={() => {
handleUpdateWorkspaceName(input);
}}
>
{t['Save']()}
</Button>
</div>
</div>
{environment.isDesktop && (
<div className={style.row}>
<div className={style.col}>
<div className={style.settingItemLabel}>
{t['Storage Folder']()}
</div>
<div className={style.settingItemLabelHint}>
{t['Storage Folder Hint']()}
</div>
</div>
<div className={style.col}>
<div
className={style.storageTypeWrapper}
onClick={() => {
if (environment.isDesktop) {
window.apis?.dialog.revealDBFile(workspace.id);
}
}}
>
<>
<div className="camera-icon">
<CameraIcon></CameraIcon>
<FolderIcon color="var(--affine-primary-color)" />
<div className={style.storageTypeLabelWrapper}>
<div className={style.storageTypeLabel}>
{t['Open folder']()}
</div>
<WorkspaceAvatar size={72} workspace={workspace} />
</>
</Upload>
<div className={style.storageTypeLabelHint}>
{t['Open folder hint']()}
</div>
</div>
<ArrowRightSmallIcon color="var(--affine-primary-color)" />
</div>
<div
data-testid="move-folder"
className={style.storageTypeWrapper}
onClick={async () => {
if (await window.apis?.dialog.moveDBFile(workspace.id)) {
toast(t['Move folder success']());
}
}}
>
<MoveToIcon color="var(--affine-primary-color)" />
<div className={style.storageTypeLabelWrapper}>
<div className={style.storageTypeLabel}>
{t['Move folder']()}
</div>
<div className={style.storageTypeLabelHint}>
{t['Move folder hint']()}
</div>
</div>
<ArrowRightSmallIcon color="var(--affine-primary-color)" />
</div>
</div>
<div className={style.col}></div>
</div>
)}
<div className={style.row}>
<div className={style.col}>
<div className={style.settingItemLabel}>
{t['Delete Workspace']()}
</div>
<div className={style.settingItemLabelHint}>
{t['Delete Workspace Label Hint']()}
</div>
</div>
<div className={style.col}></div>
<div className={style.col}>
{isOwner ? (
<>
<Button
type="warning"
data-testid="delete-workspace-button"
size="middle"
icon={<DeleteIcon />}
onClick={() => {
setShowDelete(true);
}}
>
{t['Delete']()}
</Button>
<WorkspaceDeleteModal
onDeleteWorkspace={onDeleteWorkspace}
open={showDelete}
onClose={() => {
setShowDelete(false);
}}
workspace={workspace}
/>
</>
) : (
<WorkspaceAvatar size={72} workspace={workspace} />
)}
</StyledAvatar>
</StyledRow>
<StyledRow>
<StyledSettingKey>{t['Workspace Name']()}</StyledSettingKey>
<div style={{ position: 'relative' }}>
<MuiFade in={!showEditInput}>
<FlexWrapper>
{name}
{isOwner && (
<StyledEditButton
onClick={() => {
setShowEditInput(true);
}}
>
{t['Edit']()}
</StyledEditButton>
)}
</FlexWrapper>
</MuiFade>
{isOwner && (
<MuiFade in={showEditInput}>
<FlexWrapper style={{ position: 'absolute', top: 0, left: 0 }}>
<StyledInput
width={284}
height={38}
value={input}
placeholder={t['Workspace Name']()}
maxLength={50}
minLength={0}
onChange={newName => {
setInput(newName);
}}
></StyledInput>
<Button
type="light"
shape="circle"
style={{ marginLeft: '24px' }}
disabled={input === workspace.blockSuiteWorkspace.meta.name}
onClick={() => {
handleUpdateWorkspaceName(input);
setShowEditInput(false);
}}
>
{t['Confirm']()}
</Button>
<Button
type="default"
shape="circle"
style={{ marginLeft: '24px' }}
onClick={() => {
setInput(workspace.blockSuiteWorkspace.meta.name ?? '');
setShowEditInput(false);
}}
>
{t['Cancel']()}
</Button>
</FlexWrapper>
</MuiFade>
<>
<Button
type="warning"
size="middle"
onClick={() => {
setShowLeave(true);
}}
>
{t['Leave']()}
</Button>
<WorkspaceLeave
open={showLeave}
onClose={() => {
setShowLeave(false);
}}
/>
</>
)}
</div>
</StyledRow>
{/* fixme(himself65): how to know a workspace owner by api? */}
{/*{!isOwner && (*/}
{/* <StyledRow>*/}
{/* <StyledSettingKey>{t('Workspace Owner')}</StyledSettingKey>*/}
{/* <FlexWrapper alignItems="center">*/}
{/* <MuiAvatar*/}
{/* sx={{ width: 72, height: 72, marginRight: '12px' }}*/}
{/* alt="owner avatar"*/}
{/* // src={currentWorkspace?.owner?.avatar}*/}
{/* >*/}
{/* <EmailIcon />*/}
{/* </MuiAvatar>*/}
{/* /!*<span>{currentWorkspace?.owner?.name}</span>*!/*/}
{/* </FlexWrapper>*/}
{/* </StyledRow>*/}
{/*)}*/}
{/*{!isOwner && (*/}
{/* <StyledRow>*/}
{/* <StyledSettingKey>{t('Members')}</StyledSettingKey>*/}
{/* <FlexWrapper alignItems="center">*/}
{/* /!*<span>{currentWorkspace?.memberCount}</span>*!/*/}
{/* </FlexWrapper>*/}
{/* </StyledRow>*/}
{/*)}*/}
<StyledRow
onClick={() => {
if (environment.isDesktop) {
window.apis.openDBFolder();
}
}}
>
<StyledSettingKey>{t['Workspace Type']()}</StyledSettingKey>
{isOwner ? (
workspace.flavour === WorkspaceFlavour.LOCAL ? (
<StyledWorkspaceInfo>
<LocalWorkspaceIcon />
<span>{t['Local Workspace']()}</span>
</StyledWorkspaceInfo>
) : (
<StyledWorkspaceInfo>
<CloudWorkspaceIcon />
<span>{t['Cloud Workspace']()}</span>
</StyledWorkspaceInfo>
)
) : (
<StyledWorkspaceInfo>
<JoinedWorkspaceIcon />
<span>{t['Joined Workspace']()}</span>
</StyledWorkspaceInfo>
)}
</StyledRow>
<StyledRow>
<StyledSettingKey> {t['Delete Workspace']()}</StyledSettingKey>
{isOwner ? (
<>
<Button
type="warning"
shape="circle"
style={{ borderRadius: '40px' }}
data-testid="delete-workspace-button"
onClick={() => {
setShowDelete(true);
}}
>
{t['Delete Workspace']()}
</Button>
<WorkspaceDeleteModal
onDeleteWorkspace={onDeleteWorkspace}
open={showDelete}
onClose={() => {
setShowDelete(false);
}}
workspace={workspace}
/>
</>
) : (
<>
<Button
type="warning"
shape="circle"
onClick={() => {
setShowLeave(true);
}}
>
{t['Leave Workspace']()}
</Button>
<WorkspaceLeave
open={showLeave}
onClose={() => {
setShowLeave(false);
}}
/>
</>
)}
</StyledRow>
</div>
</>
);
};

View File

@@ -4,7 +4,7 @@ import { Input } from '@affine/component';
export const StyledInput = styled(Input)(() => {
return {
border: '1px solid var(--affine-border-color)',
borderRadius: '10px',
borderRadius: '8px',
fontSize: 'var(--affine-font-sm)',
};
});

View File

@@ -1,115 +0,0 @@
import { styled } from '@affine/component';
import { FlexWrapper } from '@affine/component';
export const StyledSettingContainer = styled('div')(() => {
return {
display: 'flex',
flexDirection: 'column',
padding: '52px 0 0 52px',
height: 'calc(100vh - 52px)',
};
});
export const StyledSettingSidebar = styled('div')(() => {
{
return {
marginTop: '52px',
};
}
});
export const StyledSettingContent = styled('div')(() => {
return {
overflow: 'auto',
flex: 1,
paddingTop: '48px',
};
});
export const WorkspaceSettingTagItem = styled('li')<{ isActive?: boolean }>(
({ isActive }) => {
{
return {
display: 'flex',
margin: '0 48px 0 0',
height: '34px',
color: isActive
? 'var(--affine-primary-color)'
: 'var(--affine-text-primary-color)',
fontWeight: '500',
fontSize: 'var(--affine-font-h6)',
lineHeight: 'var(--affine-line-height)',
cursor: 'pointer',
transition: 'all 0.15s ease',
};
}
}
);
export const StyledSettingKey = styled('div')(() => {
return {
width: '140px',
fontSize: 'var(--affine-font-base)',
fontWeight: 500,
marginRight: '56px',
flexShrink: 0,
};
});
export const StyledRow = styled(FlexWrapper)(() => {
return {
marginBottom: '42px',
};
});
export const StyledWorkspaceName = styled('span')(() => {
return {
fontWeight: '400',
fontSize: 'var(--affine-font-h6)',
};
});
export const StyledIndicator = styled('div')(() => {
return {
height: '2px',
background: 'var(--affine-primary-color)',
position: 'absolute',
left: '0',
bottom: '0',
transition: 'left .3s, width .3s',
};
});
export const StyledTabButtonWrapper = styled('div')(() => {
return {
display: 'flex',
position: 'relative',
};
});
// export const StyledDownloadCard = styled('div')<{ active?: boolean }>(
// ({ theme, active }) => {
// return {
// width: '240px',
// height: '86px',
// border: '1px solid',
// borderColor: active
// ? 'var(--affine-primary-color)'
// : 'var(--affine-border-color)',
// borderRadius: '10px',
// padding: '8px 12px',
// position: 'relative',
// ':not(:last-of-type)': {
// marginRight: '24px',
// },
// svg: {
// display: active ? 'block' : 'none',
// ...positionAbsolute({ top: '-12px', right: '-12px' }),
// },
// };
// }
// );
// export const StyledDownloadCardDes = styled('div')(({ theme }) => {
// return {
// fontSize: 'var(--affine-font-sm)',
// color: 'var(--affine-icon-color)',
// };
// });

View File

@@ -1,123 +0,0 @@
import {
Button,
Input,
Modal,
ModalCloseButton,
ModalWrapper,
styled,
} from '@affine/component';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import type { KeyboardEvent } from 'react';
import { useCallback, useRef, useState } from 'react';
interface ModalProps {
open: boolean;
onClose: () => void;
onCreate: (name: string) => void;
}
export const CreateWorkspaceModal = ({
open,
onClose,
onCreate,
}: ModalProps) => {
const [workspaceName, setWorkspaceName] = useState('');
const isComposition = useRef(false);
const handleCreateWorkspace = useCallback(() => {
onCreate(workspaceName);
}, [onCreate, workspaceName]);
const handleKeyDown = useCallback(
(event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter' && workspaceName && !isComposition.current) {
handleCreateWorkspace();
}
},
[handleCreateWorkspace, workspaceName]
);
const t = useAFFiNEI18N();
return (
<Modal open={open} onClose={onClose}>
<ModalWrapper width={560} height={342} style={{ padding: '10px' }}>
<Header>
<ModalCloseButton
top={6}
right={6}
onClick={() => {
onClose();
}}
/>
</Header>
<Content>
<ContentTitle>{t['New Workspace']()}</ContentTitle>
<p>{t['Workspace description']()}</p>
<Input
ref={ref => {
if (ref) {
setTimeout(() => ref.focus(), 0);
}
}}
data-testid="create-workspace-input"
onKeyDown={handleKeyDown}
placeholder={t['Set a Workspace name']()}
maxLength={15}
minLength={0}
onChange={value => {
setWorkspaceName(value);
}}
onCompositionStart={() => {
isComposition.current = true;
}}
onCompositionEnd={() => {
isComposition.current = false;
}}
/>
<Button
data-testid="create-workspace-button"
disabled={!workspaceName}
style={{
width: '260px',
textAlign: 'center',
marginTop: '16px',
opacity: !workspaceName ? 0.5 : 1,
}}
type="primary"
onClick={() => {
handleCreateWorkspace();
}}
>
{t['Create']()}
</Button>
</Content>
</ModalWrapper>
</Modal>
);
};
const Header = styled('div')({
position: 'relative',
height: '44px',
});
const Content = styled('div')(() => {
return {
padding: '0 84px',
textAlign: 'center',
fontSize: '18px',
lineHeight: '26px',
p: {
marginTop: '12px',
marginBottom: '16px',
},
};
});
const ContentTitle = styled('div')(() => {
return {
fontSize: '20px',
lineHeight: '28px',
fontWeight: 600,
textAlign: 'center',
paddingBottom: '16px',
};
});

View File

@@ -1,4 +1,6 @@
import {
Menu,
MenuItem,
Modal,
ModalCloseButton,
ModalWrapper,
@@ -9,14 +11,19 @@ import { useAFFiNEI18N } from '@affine/i18n/hooks';
import type { AccessTokenMessage } from '@affine/workspace/affine/login';
import type { AffineWorkspace, LocalWorkspace } from '@affine/workspace/type';
import { WorkspaceFlavour } from '@affine/workspace/type';
import { HelpIcon, PlusIcon } from '@blocksuite/icons';
import { HelpIcon, ImportIcon, PlusIcon } from '@blocksuite/icons';
import type { DragEndEvent } from '@dnd-kit/core';
import { useCallback } from 'react';
import { useCallback, useRef } from 'react';
import type { AllWorkspace } from '../../../shared';
import { Footer } from '../footer';
import {
StyledCreateWorkspaceCard,
StyledCreateWorkspaceCardPill,
StyledCreateWorkspaceCardPillContainer,
StyledCreateWorkspaceCardPillContent,
StyledCreateWorkspaceCardPillIcon,
StyledCreateWorkspaceCardPillTextSecondary,
StyledHelperContainer,
StyledModalContent,
StyledModalHeader,
@@ -39,7 +46,8 @@ interface WorkspaceModalProps {
onClickWorkspaceSetting: (workspace: AllWorkspace) => void;
onClickLogin: () => void;
onClickLogout: () => void;
onCreateWorkspace: () => void;
onNewWorkspace: () => void;
onAddWorkspace: () => void;
onMoveWorkspace: (activeId: string, overId: string) => void;
}
@@ -53,12 +61,13 @@ export const WorkspaceListModal = ({
onClickLogout,
onClickWorkspace,
onClickWorkspaceSetting,
onCreateWorkspace,
onNewWorkspace,
onAddWorkspace,
currentWorkspaceId,
onMoveWorkspace,
}: WorkspaceModalProps) => {
const t = useAFFiNEI18N();
const anchorEL = useRef<HTMLDivElement>(null);
return (
<Modal open={open} onClose={onClose}>
<ModalWrapper
@@ -115,19 +124,96 @@ export const WorkspaceListModal = ({
[onMoveWorkspace]
)}
/>
<StyledCreateWorkspaceCard
data-testid="new-workspace"
onClick={onCreateWorkspace}
>
<StyleWorkspaceAdd className="add-icon">
<PlusIcon />
</StyleWorkspaceAdd>
{!environment.isDesktop && (
<StyledCreateWorkspaceCard
onClick={onNewWorkspace}
data-testid="new-workspace"
>
<StyleWorkspaceAdd className="add-icon">
<PlusIcon />
</StyleWorkspaceAdd>
<StyleWorkspaceInfo>
<StyleWorkspaceTitle>{t['New Workspace']()}</StyleWorkspaceTitle>
<p>{t['Create Or Import']()}</p>
</StyleWorkspaceInfo>
</StyledCreateWorkspaceCard>
<StyleWorkspaceInfo>
<StyleWorkspaceTitle>
{t['New Workspace']()}
</StyleWorkspaceTitle>
<p>{t['Create Or Import']()}</p>
</StyleWorkspaceInfo>
</StyledCreateWorkspaceCard>
)}
{environment.isDesktop && (
<Menu
placement="auto"
trigger={['click', 'hover']}
zIndex={1000}
content={
<StyledCreateWorkspaceCardPillContainer>
<StyledCreateWorkspaceCardPill>
<MenuItem
style={{
height: 'auto',
padding: '8px 12px',
}}
onClick={onNewWorkspace}
data-testid="new-workspace"
>
<StyledCreateWorkspaceCardPillContent>
<div>
<p>{t['New Workspace']()}</p>
<StyledCreateWorkspaceCardPillTextSecondary>
<p>{t['Create your own workspace']()}</p>
</StyledCreateWorkspaceCardPillTextSecondary>
</div>
<StyledCreateWorkspaceCardPillIcon>
<PlusIcon />
</StyledCreateWorkspaceCardPillIcon>
</StyledCreateWorkspaceCardPillContent>
</MenuItem>
</StyledCreateWorkspaceCardPill>
<StyledCreateWorkspaceCardPill>
<MenuItem
disabled={!environment.isDesktop}
onClick={onAddWorkspace}
data-testid="add-workspace"
style={{
height: 'auto',
padding: '8px 12px',
}}
>
<StyledCreateWorkspaceCardPillContent>
<div>
<p>{t['Add Workspace']()}</p>
<StyledCreateWorkspaceCardPillTextSecondary>
<p>{t['Add Workspace Hint']()}</p>
</StyledCreateWorkspaceCardPillTextSecondary>
</div>
<StyledCreateWorkspaceCardPillIcon>
<ImportIcon />
</StyledCreateWorkspaceCardPillIcon>
</StyledCreateWorkspaceCardPillContent>
</MenuItem>
</StyledCreateWorkspaceCardPill>
</StyledCreateWorkspaceCardPillContainer>
}
>
<StyledCreateWorkspaceCard
ref={anchorEL}
data-testid="add-or-new-workspace"
>
<StyleWorkspaceAdd className="add-icon">
<PlusIcon />
</StyleWorkspaceAdd>
<StyleWorkspaceInfo>
<StyleWorkspaceTitle>
{t['New Workspace']()}
</StyleWorkspaceTitle>
<p>{t['Create Or Import']()}</p>
</StyleWorkspaceInfo>
</StyledCreateWorkspaceCard>
</Menu>
)}
</StyledModalContent>
<Footer user={user} onLogin={onClickLogin} onLogout={onClickLogout} />

View File

@@ -64,6 +64,50 @@ export const StyledCreateWorkspaceCard = styled('div')(() => {
},
};
});
export const StyledCreateWorkspaceCardPillContainer = styled('div')(() => {
return {
padding: '12px',
borderRadius: '10px',
display: 'flex',
margin: '-8px -4px',
flexFlow: 'column',
gap: '12px',
background: 'var(--affine-background-overlay-panel-color)',
};
});
export const StyledCreateWorkspaceCardPill = styled('div')(() => {
return {
borderRadius: '5px',
display: 'flex',
boxShadow: '0px 0px 6px 0px rgba(0, 0, 0, 0.1)',
background: 'var(--affine-background-primary-color)',
};
});
export const StyledCreateWorkspaceCardPillContent = styled('div')(() => {
return {
display: 'flex',
gap: '12px',
alignItems: 'center',
justifyContent: 'space-between',
};
});
export const StyledCreateWorkspaceCardPillIcon = styled('div')(() => {
return {
fontSize: '20px',
width: '1em',
height: '1em',
};
});
export const StyledCreateWorkspaceCardPillTextSecondary = styled('div')(() => {
return {
fontSize: '12px',
color: 'var(--affine-text-secondary-color)',
};
});
export const StyledModalHeaderLeft = styled('div')(() => {
return { ...displayFlex('flex-start', 'center') };

View File

@@ -77,7 +77,7 @@ export const RootAppSidebar = ({
const sidebarOpen = useAtomValue(appSidebarOpenAtom);
useEffect(() => {
if (environment.isDesktop && typeof sidebarOpen === 'boolean') {
window.apis?.onSidebarVisibilityChange(sidebarOpen);
window.apis?.ui.handleSidebarVisibilityChange(sidebarOpen);
}
}, [sidebarOpen]);
const [ref, setRef] = useState<HTMLElement | null>(null);

View File

@@ -1,3 +1,4 @@
import { DebugLogger } from '@affine/debug';
import { rootCurrentPageIdAtom } from '@affine/workspace/atom';
import { useAtom, useAtomValue } from 'jotai';
import type { NextRouter } from 'next/router';
@@ -5,6 +6,9 @@ import { useEffect, useRef } from 'react';
import { rootCurrentWorkspaceAtom } from '../atoms/root';
export const HALT_PROBLEM_TIMEOUT = 1000;
const logger = new DebugLogger('useRouterWithWorkspaceIdDefense');
export function useRouterAndWorkspaceWithPageIdDefense(router: NextRouter) {
const currentWorkspace = useAtomValue(rootCurrentWorkspaceAtom);
const [currentPageId, setCurrentPageId] = useAtom(rootCurrentPageIdAtom);
@@ -15,19 +19,19 @@ export function useRouterAndWorkspaceWithPageIdDefense(router: NextRouter) {
}
const { workspaceId, pageId } = router.query;
if (typeof pageId !== 'string') {
console.warn('pageId is not a string', pageId);
logger.warn('pageId is not a string', pageId);
return;
}
if (typeof workspaceId !== 'string') {
console.warn('workspaceId is not a string', workspaceId);
logger.warn('workspaceId is not a string', workspaceId);
return;
}
if (currentWorkspace?.id !== workspaceId) {
console.warn('workspaceId is not currentWorkspace', workspaceId);
logger.warn('workspaceId is not currentWorkspace', workspaceId);
return;
}
if (currentPageId !== pageId && !fallbackModeRef.current) {
console.log('set current page id', pageId);
logger.info('set pageId', pageId, 'for workspace', workspaceId);
setCurrentPageId(pageId);
void router.push({
pathname: '/workspace/[workspaceId]/[pageId]',
@@ -51,7 +55,7 @@ export function useRouterAndWorkspaceWithPageIdDefense(router: NextRouter) {
const firstOne =
currentWorkspace.blockSuiteWorkspace.meta.pageMetas.at(0);
if (firstOne) {
console.warn(
logger.warn(
'cannot find page',
currentPageId,
'so redirect to',

View File

@@ -1,3 +1,4 @@
import { DebugLogger } from '@affine/debug';
import {
rootCurrentPageIdAtom,
rootCurrentWorkspaceIdAtom,
@@ -7,6 +8,8 @@ import { useAtom, useAtomValue, useSetAtom } from 'jotai';
import type { NextRouter } from 'next/router';
import { useEffect } from 'react';
const logger = new DebugLogger('useRouterWithWorkspaceIdDefense');
export function useRouterWithWorkspaceIdDefense(router: NextRouter) {
const metadata = useAtomValue(rootWorkspacesMetadataAtom);
const [currentWorkspaceId, setCurrentWorkspaceId] = useAtom(
@@ -30,6 +33,7 @@ export function useRouterWithWorkspaceIdDefense(router: NextRouter) {
if (!firstOne) {
throw new Error('no workspace');
}
logger.debug('redirect to', firstOne.id);
void router.push({
pathname: '/workspace/[workspaceId]/all',
query: {

View File

@@ -1,3 +1,4 @@
import { DebugLogger } from '@affine/debug';
import {
rootCurrentWorkspaceIdAtom,
rootWorkspacesMetadataAtom,
@@ -6,6 +7,8 @@ import { useAtom, useAtomValue } from 'jotai';
import type { NextRouter } from 'next/router';
import { useEffect } from 'react';
const logger = new DebugLogger('useSyncRouterWithCurrentWorkspaceId');
export function useSyncRouterWithCurrentWorkspaceId(router: NextRouter) {
const [currentWorkspaceId, setCurrentWorkspaceId] = useAtom(
rootCurrentWorkspaceIdAtom
@@ -23,6 +26,7 @@ export function useSyncRouterWithCurrentWorkspaceId(router: NextRouter) {
if (currentWorkspaceId !== workspaceId) {
const target = metadata.find(workspace => workspace.id === workspaceId);
if (!target) {
logger.debug('workspace not exist, redirect to current one');
// workspaceId is invalid, redirect to currentWorkspaceId
void router.push({
pathname: router.pathname,
@@ -41,9 +45,7 @@ export function useSyncRouterWithCurrentWorkspaceId(router: NextRouter) {
if (targetWorkspace) {
console.log('set workspace id', workspaceId);
setCurrentWorkspaceId(targetWorkspace.id);
if (environment.isDesktop) {
window.apis?.onWorkspaceChange(targetWorkspace.id);
}
logger.debug('redirect to', targetWorkspace.id);
void router.push({
pathname: router.pathname,
query: {
@@ -56,6 +58,7 @@ export function useSyncRouterWithCurrentWorkspaceId(router: NextRouter) {
if (targetWorkspace) {
console.log('set workspace id', workspaceId);
setCurrentWorkspaceId(targetWorkspace.id);
logger.debug('redirect to', targetWorkspace.id);
void router.push({
pathname: router.pathname,
query: {

View File

@@ -1,5 +1,6 @@
import { DebugLogger } from '@affine/debug';
import { rootWorkspacesMetadataAtom } from '@affine/workspace/atom';
import { saveWorkspaceToLocalStorage } from '@affine/workspace/local/crud';
import type { LocalWorkspace } from '@affine/workspace/type';
import { WorkspaceFlavour } from '@affine/workspace/type';
import { createEmptyBlockSuiteWorkspace } from '@affine/workspace/utils';
@@ -39,6 +40,21 @@ export function useAppHelper() {
},
[workspaces]
),
addLocalWorkspace: useCallback(
async (workspaceId: string): Promise<string> => {
saveWorkspaceToLocalStorage(workspaceId);
set(workspaces => [
...workspaces,
{
id: workspaceId,
flavour: WorkspaceFlavour.LOCAL,
},
]);
logger.debug('imported local workspace', workspaceId);
return workspaceId;
},
[set]
),
createLocalWorkspace: useCallback(
async (name: string): Promise<string> => {
const blockSuiteWorkspace = createEmptyBlockSuiteWorkspace(

View File

@@ -97,14 +97,15 @@ const SettingPage: NextPageWithLayout = () => {
const helper = useAppHelper();
const onDeleteWorkspace = useCallback(() => {
const onDeleteWorkspace = useCallback(async () => {
assertExists(currentWorkspace);
const workspaceId = currentWorkspace.id;
if (workspaceIds.length === 1 && workspaceId === workspaceIds[0].id) {
toast(t['You cannot delete the last workspace']());
throw new Error('You cannot delete the last workspace');
} else {
return await helper.deleteWorkspace(workspaceId);
}
return helper.deleteWorkspace(workspaceId);
}, [currentWorkspace, helper, t, workspaceIds]);
const onTransformWorkspace = useOnTransformWorkspace();
if (!router.isReady) {

View File

@@ -18,7 +18,7 @@ import { useAffineLogOut } from '../hooks/affine/use-affine-log-out';
import { useCurrentUser } from '../hooks/current/use-current-user';
import { useCurrentWorkspace } from '../hooks/current/use-current-workspace';
import { useRouterHelper } from '../hooks/use-router-helper';
import { useAppHelper, useWorkspaces } from '../hooks/use-workspaces';
import { useWorkspaces } from '../hooks/use-workspaces';
import { WorkspaceSubPath } from '../shared';
const WorkspaceListModal = lazy(() =>
@@ -28,7 +28,7 @@ const WorkspaceListModal = lazy(() =>
);
const CreateWorkspaceModal = lazy(() =>
import('../components/pure/create-workspace-modal').then(module => ({
import('../components/affine/create-workspace-modal').then(module => ({
default: module.CreateWorkspaceModal,
}))
);
@@ -69,7 +69,6 @@ export function Modals() {
const setWorkspaces = useSetAtom(rootWorkspacesMetadataAtom);
const currentWorkspaceId = useAtomValue(currentWorkspaceIdAtom);
const [, setCurrentWorkspace] = useCurrentWorkspace();
const { createLocalWorkspace } = useAppHelper();
const [transitioning, transition] = useTransition();
const env = getEnvironment();
const onCloseOnboardingModal = useCallback(() => {
@@ -134,27 +133,28 @@ export function Modals() {
)}
onClickLogin={useAffineLogIn()}
onClickLogout={useAffineLogOut()}
onCreateWorkspace={useCallback(() => {
setOpenCreateWorkspaceModal(true);
onNewWorkspace={useCallback(() => {
setOpenCreateWorkspaceModal('new');
}, [setOpenCreateWorkspaceModal])}
onAddWorkspace={useCallback(async () => {
setOpenCreateWorkspaceModal('add');
}, [setOpenCreateWorkspaceModal])}
/>
</Suspense>
<Suspense>
<CreateWorkspaceModal
open={openCreateWorkspaceModal}
mode={openCreateWorkspaceModal}
onClose={useCallback(() => {
setOpenCreateWorkspaceModal(false);
}, [setOpenCreateWorkspaceModal])}
onCreate={useCallback(
async name => {
const id = await createLocalWorkspace(name);
async id => {
setOpenCreateWorkspaceModal(false);
setOpenWorkspacesModal(false);
setCurrentWorkspace(id);
return jumpToSubPath(id, WorkspaceSubPath.ALL);
return jumpToSubPath(id, WorkspaceSubPath.SETTING);
},
[
createLocalWorkspace,
jumpToSubPath,
setCurrentWorkspace,
setOpenCreateWorkspaceModal,

View File

@@ -11,7 +11,7 @@ const DesktopThemeSync = memo(function DesktopThemeSync() {
const lastThemeRef = useRef(theme);
if (lastThemeRef.current !== theme) {
if (environment.isDesktop && theme) {
window.apis?.onThemeChange(theme);
window.apis?.ui.handleThemeChange(theme as 'dark' | 'light' | 'system');
}
lastThemeRef.current = theme;
}