mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-12 04:18:54 +00:00
feat: create workspace from loading existing exported file (#2122)
Co-authored-by: Himself65 <himself65@outlook.com>
This commit is contained in:
@@ -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: [
|
||||
|
||||
7
apps/electron/layers/constraints.ts
Normal file
7
apps/electron/layers/constraints.ts
Normal 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;
|
||||
@@ -1,3 +0,0 @@
|
||||
import log from 'electron-log';
|
||||
|
||||
export const logger = log;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
// });
|
||||
// }
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
26
apps/electron/layers/main/src/events/db.ts
Normal file
26
apps/electron/layers/main/src/events/db.ts
Normal 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>;
|
||||
7
apps/electron/layers/main/src/events/index.ts
Normal file
7
apps/electron/layers/main/src/events/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export * from './register';
|
||||
|
||||
import { dbSubjects } from './db';
|
||||
|
||||
export const subjects = {
|
||||
db: dbSubjects,
|
||||
};
|
||||
30
apps/electron/layers/main/src/events/register.ts
Normal file
30
apps/electron/layers/main/src/events/register.ts
Normal 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();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
1
apps/electron/layers/main/src/events/type.ts
Normal file
1
apps/electron/layers/main/src/events/type.ts
Normal file
@@ -0,0 +1 @@
|
||||
export type MainEventListener = (...args: any[]) => () => void;
|
||||
21
apps/electron/layers/main/src/events/updater.ts
Normal file
21
apps/electron/layers/main/src/events/updater.ts
Normal 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>;
|
||||
5
apps/electron/layers/main/src/exposed.ts
Normal file
5
apps/electron/layers/main/src/exposed.ts
Normal 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 };
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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();
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
89
apps/electron/layers/main/src/handlers/db/ensure-db.ts
Normal file
89
apps/electron/layers/main/src/handlers/db/ensure-db.ts
Normal 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();
|
||||
}
|
||||
33
apps/electron/layers/main/src/handlers/db/index.ts
Normal file
33
apps/electron/layers/main/src/handlers/db/index.ts
Normal 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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
293
apps/electron/layers/main/src/handlers/dialog/dialog.ts
Normal file
293
apps/electron/layers/main/src/handlers/dialog/dialog.ts
Normal 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);
|
||||
}
|
||||
33
apps/electron/layers/main/src/handlers/dialog/index.ts
Normal file
33
apps/electron/layers/main/src/handlers/dialog/index.ts
Normal 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;
|
||||
1
apps/electron/layers/main/src/handlers/index.ts
Normal file
1
apps/electron/layers/main/src/handlers/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './register';
|
||||
63
apps/electron/layers/main/src/handlers/register.ts
Normal file
63
apps/electron/layers/main/src/handlers/register.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
8
apps/electron/layers/main/src/handlers/type.ts
Normal file
8
apps/electron/layers/main/src/handlers/type.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export type IsomorphicHandler = (
|
||||
e: Electron.IpcMainInvokeEvent,
|
||||
...args: any[]
|
||||
) => Promise<any>;
|
||||
|
||||
export type NamespaceHandlers = {
|
||||
[key: string]: IsomorphicHandler;
|
||||
};
|
||||
58
apps/electron/layers/main/src/handlers/ui/google-auth.ts
Normal file
58
apps/electron/layers/main/src/handlers/ui/google-auth.ts
Normal 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);
|
||||
}
|
||||
);
|
||||
}
|
||||
23
apps/electron/layers/main/src/handlers/ui/index.ts
Normal file
23
apps/electron/layers/main/src/handlers/ui/index.ts
Normal 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;
|
||||
10
apps/electron/layers/main/src/handlers/updater/index.ts
Normal file
10
apps/electron/layers/main/src/handlers/updater/index.ts
Normal 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';
|
||||
69
apps/electron/layers/main/src/handlers/updater/updater.ts
Normal file
69
apps/electron/layers/main/src/handlers/updater/updater.ts
Normal 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();
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
13
apps/electron/layers/main/src/logger.ts
Normal file
13
apps/electron/layers/main/src/logger.ts
Normal 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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
19
apps/electron/layers/main/src/utils.ts
Normal file
19
apps/electron/layers/main/src/utils.ts
Normal 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();
|
||||
}
|
||||
5
apps/electron/layers/preload/preload.d.ts
vendored
5
apps/electron/layers/preload/preload.d.ts
vendored
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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.');
|
||||
28
apps/electron/scripts/build-layers.mjs
Normal file
28
apps/electron/scripts/build-layers.mjs
Normal 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');
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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'}"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
40
apps/electron/scripts/generate-main-exposed-meta.mjs
Normal file
40
apps/electron/scripts/generate-main-exposed-meta.mjs
Normal 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');
|
||||
@@ -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]');
|
||||
|
||||
86
apps/electron/tests/fixture.ts
Normal file
86
apps/electron/tests/fixture.ts
Normal 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;
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
7
apps/electron/tests/setup.ts
Normal file
7
apps/electron/tests/setup.ts
Normal 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, '..'),
|
||||
});
|
||||
}
|
||||
97
apps/electron/tests/workspace.spec.ts
Normal file
97
apps/electron/tests/workspace.spec.ts
Normal 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);
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
346
apps/web/src/components/affine/create-workspace-modal/index.tsx
Normal file
346
apps/web/src/components/affine/create-workspace-modal/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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)',
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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)',
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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']()}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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)',
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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)',
|
||||
// };
|
||||
// });
|
||||
@@ -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',
|
||||
};
|
||||
});
|
||||
@@ -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} />
|
||||
|
||||
@@ -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') };
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user